Merge branch 'stable-2.15'

* stable-2.15:
  Documentation: Remove references to optional BouncyCastle libraries

Change-Id: I46f9d1c13543b95f0c8b6488b71691529dd88200
diff --git a/.gitignore b/.gitignore
index 4dfd6f2..0e954ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,4 @@
 # Keep following lines sorted according to `LC_COLLATE=C sort`
-*.asc
 *.eml
 *.iml
 *.pyc
@@ -31,3 +30,4 @@
 /plugins/cookbook-plugin/
 /test_site
 /tools/format
+/.vscode
\ No newline at end of file
diff --git a/.gitmodules b/.gitmodules
index ec8afee..8d75bcc 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,8 @@
+[submodule "plugins/codemirror-editor"]
+	path = plugins/codemirror-editor
+	url = ../plugins/codemirror-editor
+	branch = .
+
 [submodule "plugins/commit-message-length-validator"]
 	path = plugins/commit-message-length-validator
 	url = ../plugins/commit-message-length-validator
diff --git a/BUILD b/BUILD
index 722e240..2258a37 100644
--- a/BUILD
+++ b/BUILD
@@ -45,15 +45,15 @@
 )
 
 API_DEPS = [
-    "//gerrit-acceptance-framework:acceptance-framework_deploy.jar",
-    "//gerrit-acceptance-framework:liblib-src.jar",
-    "//gerrit-acceptance-framework:acceptance-framework-javadoc",
-    "//gerrit-extension-api:extension-api_deploy.jar",
-    "//gerrit-extension-api:libapi-src.jar",
-    "//gerrit-extension-api:extension-api-javadoc",
-    "//gerrit-plugin-api:plugin-api_deploy.jar",
-    "//gerrit-plugin-api:plugin-api-sources_deploy.jar",
-    "//gerrit-plugin-api:plugin-api-javadoc",
+    "//java/com/google/gerrit/acceptance:framework_deploy.jar",
+    "//java/com/google/gerrit/acceptance:libframework-lib-src.jar",
+    "//java/com/google/gerrit/acceptance:framework-javadoc",
+    "//java/com/google/gerrit/extensions:extension-api_deploy.jar",
+    "//java/com/google/gerrit/extensions:libapi-src.jar",
+    "//java/com/google/gerrit/extensions:extension-api-javadoc",
+    "//plugins:plugin-api_deploy.jar",
+    "//plugins:plugin-api-sources_deploy.jar",
+    "//plugins:plugin-api-javadoc",
     "//gerrit-plugin-gwtui:gwtui-api_deploy.jar",
     "//gerrit-plugin-gwtui:gwtui-api-source_deploy.jar",
     "//gerrit-plugin-gwtui:gwtui-api-javadoc",
diff --git a/Documentation/BUILD b/Documentation/BUILD
index 624802c..2e6f4bc 100644
--- a/Documentation/BUILD
+++ b/Documentation/BUILD
@@ -19,14 +19,14 @@
 
 genrule(
     name = "prettify_min_css",
-    srcs = ["//gerrit-prettify:src/main/resources/com/google/gerrit/prettify/client/prettify.css"],
+    srcs = ["//resources/com/google/gerrit/prettify:client/prettify.css"],
     outs = ["prettify.min.css"],
     cmd = "cp $< $@",
 )
 
 genrule(
     name = "prettify_min_js",
-    srcs = ["//gerrit-prettify:src/main/resources/com/google/gerrit/prettify/client/prettify.js"],
+    srcs = ["//resources/com/google/gerrit/prettify:client/prettify.js"],
     outs = ["prettify.min.js"],
     cmd = "cp $< $@",
 )
@@ -47,9 +47,9 @@
     name = "licenses",
     opts = ["--asciidoctor"],
     targets = [
-        "//gerrit-pgm:pgm",
         "//gerrit-gwtui:ui_module",
         "//polygerrit-ui/app:polygerrit_ui",
+        "//java/com/google/gerrit/pgm",
     ],
     visibility = ["//visibility:public"],
 )
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index bf9cb6d..a4f03d5 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -1299,7 +1299,8 @@
 
 Allow to link:cmd-set-account.html[modify accounts over the ssh prompt].
 This capability allows the granted group members to modify any user account
-setting.
+setting. In addition this capability is required to view secondary emails
+of other accounts.
 
 [[capability_priority]]
 === Priority
@@ -1386,6 +1387,10 @@
 of link:config-gerrit.html#accounts.visibility[accounts.visibility]
 setting.
 
+This capability allows to view all accounts but not all account data.
+E.g. secondary emails of all accounts can only be viewed with the
+link:#capability_modifyAccount[Modify Account] capability.
+
 
 [[capability_viewCaches]]
 === View Caches
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/cmd-show-caches.txt b/Documentation/cmd-show-caches.txt
index e47ae81..215463b 100644
--- a/Documentation/cmd-show-caches.txt
+++ b/Documentation/cmd-show-caches.txt
@@ -61,11 +61,11 @@
     adv_bases                     |                     |         |         |
     changes                       |                     |  27.1ms |  0%     |
     groups                        |  5646               |  11.8ms | 97%     |
-    groups_byinclude              |   230               |   2.4ms | 62%     |
+    groups_bymember               |                     |         |         |
     groups_byname                 |                     |         |         |
+    groups_bysubgroup             |   230               |   2.4ms | 62%     |
     groups_byuuid                 |  5612               |  29.2ms | 99%     |
     groups_external               |     1               |   1.5s  | 98%     |
-    groups_members                |  5714               |  19.7ms | 99%     |
     ldap_group_existence          |                     |         |         |
     ldap_groups                   |   650               | 680.5ms | 99%     |
     ldap_groups_byinclude         |  1024               |         | 83%     |
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index d577470..fd8c3fe 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -740,6 +740,9 @@
 * `"diff"`: default is `10m` (10 MiB of memory)
 * `"diff_intraline"`: default is `10m` (10 MiB of memory)
 * `"diff_summary"`: default is `10m` (10 MiB of memory)
+* `"groups"`: default is unlimited
+* `"groups_byname"`: default is unlimited
+* `"groups_byuuid"`: default is unlimited
 * `"plugin_resources"`: default is 2m (2 MiB of memory)
 
 +
@@ -767,13 +770,8 @@
 cache `"accounts"`::
 +
 Cache entries contain important details of an active user, including
-their display name, preferences, known email addresses, and group
-memberships.  Entry information is obtained from the following
-database tables:
-+
-* `accounts`
-+
-* `account_group_members`
+their display name, preferences, and known email addresses. Entry
+information is obtained from the `accounts` database table.
 
 +
 If direct updates are made to any of these database tables, this
@@ -846,23 +844,54 @@
 
 cache `"groups"`::
 +
-Caches the basic group information from the `account_groups` table,
+Caches the basic group information of internal groups by group ID,
 including the group owner, name, and description.
 +
-Gerrit group membership obtained from the `account_group_members`
-table is cached under the `"accounts"` cache, above.  External group
-membership obtained from LDAP is cached under `"ldap_groups"`.
-
-cache `"groups_byinclude"`::
+For this cache it is important to configure a size that is larger than
+the number of internal Gerrit groups, otherwise general Gerrit
+performance may be poor. This is why by default this cache is
+unlimited.
 +
-Caches group inclusions in other groups.  If direct updates are made
+External group membership obtained from LDAP is cached under
+`"ldap_groups"`.
+
+cache `"groups_byname"`::
++
+Caches the basic group information of internal groups by group name,
+including the group owner, name, and description.
++
+For this cache it is important to configure a size that is larger than
+the number of internal Gerrit groups, otherwise general Gerrit
+performance may be poor. This is why by default this cache is
+unlimited.
++
+External group membership obtained from LDAP is cached under
+`"ldap_groups"`.
+
+cache `"groups_byuuid"`::
++
+Caches the basic group information of internal groups by group UUID,
+including the group owner, name, and description.
++
+For this cache it is important to configure a size that is larger than
+the number of internal Gerrit groups, otherwise general Gerrit
+performance may be poor. This is why by default this cache is
+unlimited.
++
+External group membership obtained from LDAP is cached under
+`"ldap_groups"`.
+
+cache `"groups_bymember"`::
++
+Caches the groups which contain a specific member (account). If direct
+updates are made to the `account_group_members` table, this cache should
+be flushed.
+
+cache `"groups_bysubgroups"`::
++
+Caches the parent groups of a subgroup.  If direct updates are made
 to the `account_group_includes` table, this cache should be flushed.
 
-cache `"groups_members"`::
-+
-Caches subgroups.  If direct updates are made to the
-`account_group_includes` table, this cache should be flushed.
-
 cache `"ldap_groups"`::
 +
 Caches the LDAP groups that a user belongs to, if LDAP has been
@@ -1104,6 +1133,18 @@
 +
 Default is true.
 
+[[change.api.allowedIdentifier]]change.api.allowedIdentifier::
++
+Change identifier(s) that are allowed on the API. See
+link:rest-api-changes.html#change-id[Change Id] for more information.
++
+Possible values are `ALL`, `TRIPLET`, `NUMERIC_ID`, `I_HASH`, and
+`COMMIT_HASH` or any combination of those as a string list.
+`PROJECT_NUMERIC_ID` is always allowed and doesn't need to be listed
+explicitly.
++
+Default is `ALL`.
+
 [[change.cacheAutomerge]]change.cacheAutomerge::
 +
 When reviewing diff commits, the left-hand side shows the output of the
@@ -1211,6 +1252,12 @@
 +
 The default limit is 1024kB.
 
+[[change.disablePrivateChanges]]change.disablePrivateChanges::
++
+If set to true, users are not allowed to create private changes.
++
+The default is false.
+
 [[changeCleanup]]
 === Section changeCleanup
 
@@ -1339,10 +1386,13 @@
 example, to match the string `bug` in a case insensitive way the match
 pattern `[bB][uU][gG]` needs to be used.
 +
-The regular expression pattern is applied to the HTML form of the message
-in question, which means it needs to assume the data has been escaped.
-So `"` needs to be matched as `&amp;quot;`, `<` as `&amp;lt;`, and `'` as
-`&amp;#39;`.
+Between the GWT UI and PolyGerrit, the commentlink.name.match regular
+expressions are applied differently. Whereas in the GWT UI the
+expressions are applied to the formatted and escaped HTML result, the
+PolyGerrit UI applies them only to the raw, unformatted and unescaped
+text form. PolyGerrit does not support regex matching against HTML.
+Comment link patterns that are written in this style should be updated
+to match text formats.
 +
 A common pattern to match is `bug\\s+(\\d+)`.
 
@@ -2162,6 +2212,11 @@
 +
 Path prefix for PolyGerrit's static resources if using a CDN.
 
+[[gerrit.faviconPath]]gerrit.faviconPath::
++
+Path for PolyGerrit's favicon after link:#gerrit.canonicalWebUrl[default URL],
+including icon name and extension (.ico should be used).
+
 [[gerrit.ui]]gerrit.ui::
 +
 Default UI when the user does not request a different preference via argument
@@ -3315,6 +3370,19 @@
 +
 Defaults to true.
 
+[[log.compress]]log.compress::
++
+If set to true, log files are compressed at server startup and then daily at 11pm
+(in the server's local time zone).
++
+Defaults to true.
+
+[[log.rotate]]log.rotate::
++
+If set to true, log files are rotated daily at midnight (GMT).
++
+Defaults to true.
+
 [[mimetype]]
 === Section mimetype
 
@@ -3383,6 +3451,7 @@
 Defaults to 20 seconds; unit suffixes are supported, and assumes milliseconds if
 not specified.
 
+
 [[oauth]]
 === Section oauth
 
@@ -3685,7 +3754,7 @@
 [[repository.name.defaultSubmitType]]repository.<name>.defaultSubmitType::
 +
 The default submit type for newly created projects. Supported values
-are `MERGE_IF_NECESSARY`, `FAST_FORWARD_ONLY`, `REBASE_IF_NECESSARY`,
+are `INHERIT`, `MERGE_IF_NECESSARY`, `FAST_FORWARD_ONLY`, `REBASE_IF_NECESSARY`,
 `REBASE_ALWAYS`, `MERGE_ALWAYS` and `CHERRY_PICK`.
 +
 For more details see link:project-configuration.html#submit_type[Submit Types].
@@ -4596,9 +4665,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]
@@ -4608,8 +4676,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.
@@ -4756,7 +4824,7 @@
 Username that is displayed in the Gerrit Web UI and in e-mail
 notifications if the full name of the user is not set.
 +
-By default "Anonymous Coward" is used.
+By default "Name of user not set" is used.
 
 [[secure.config]]
 == File `etc/secure.config`
diff --git a/Documentation/config-groups.txt b/Documentation/config-groups.txt
new file mode 100644
index 0000000..4db4cb3
--- /dev/null
+++ b/Documentation/config-groups.txt
@@ -0,0 +1,109 @@
+
+= Gerrit Code Review - Groups
+
+== Overview
+
+In Gerrit, we assign permissions to groups of accounts. These groups
+can be provided by an external system such as LDAP, but Gerrit also
+has a group system built-in ("internal groups")
+
+Starting from 2.16, these internal groups are fully stored in
+link:note-db.html[NoteDb].
+
+A group is characterized by the following information:
+
+* list of members (accounts)
+* list of subgroups
+* properties
+  - visibleToAll
+  - group owner
+
+Groups are keyed by the following unique identifiers:
+
+* GroupID, the former database key (a sequential number)
+
+* UUID, an opaque identifier. Internal groups use a 40 byte hex string
+as UUID
+
+* Name: Gerrit enforces that group names are unique
+
+== Storage format
+
+Group data is stored in the
+link:config-accounts.html#all-users[`All-Users` repository]. For each
+group, there is a ref, stored as a sharded UUID, e.g.
+
+----
+  refs/groups/ef/deafbeefdeafbeefdeafbeefdeafbeefdeafbeef
+----
+
+The ref points to commits holding files. The files are
+
+* `members`, holding numeric account IDs of members, one per line
+* `subgroups`, holding group UUIDs of subgroups, one per line
+* `group.config`, holding further configuration.
+
+The `group.config` file follows the following format
+
+----
+[group]
+  name = <name of the group>
+  id = 42
+  visibleToAll = false
+  description = <description of the group>
+  groupOwnerUuid = <UUID of the owner group>
+----
+
+Gerrit updates the ref for a group based on REST API calls, and the
+commit log effectively forms an audit log which shows how group
+membership evolved over time.
+
+To ensure uniqueness of the name, a separate ref
+`refs/meta/group-names` contains a notemap, ie. a map represented as a
+branch with a flat list of files.
+
+The format of this map is as follows:
+
+* keys are the normal SHA1 of the group name
+* values are blobs that look like
++
+----
+[group]
+  name = <name of the group>
+  uuid = <hex UUID identifier of the group>
+----
+
+To ensure uniqueness of the sequential ID, the ID for each new group
+is taken from the sequence counter under `refs/sequences/groups`,
+which works analogously to the ones for accounts and changes.
+
+== Visibility
+
+Group ownership together with `visibleToAll` determines visibility of
+the groups in the REST API.
+
+Fetching a group ref is permitted to the group's owners that also have
+READ permissions on the ref. For users that are not owners, the
+permissions on the ref are ignored. In addition, anyone with the
+link:access-control.html#capability_accessDatabase[Access Database]
+capability can read all group refs. The `refs/meta/group-names` ref is
+visible only to users with the
+link:access-control.html#capability_accessDatabase[Access Database]
+capability.
+
+== Pushing to group refs
+
+Validation on push for changes to the group ref is not implemented, so
+pushes are rejected. Pushes that bypass Gerrit should be avoided since
+the names, IDs and UUIDs must be internally consistent between all the
+branches involved. In addition, group references should not be created
+or deleted manually either. If you attempt any of these actions
+anyway, don't forget to link:rest-api-groups.html#index-group[Index
+Group] reindex the affected groups manually.
+
+== Replication
+
+In a replicated setting (eg. backups and or master/slave
+configurations), all refs in the `All-Users` project must be copied
+onto all replicas, including `refs/groups/*`, `refs/meta/group-names`
+and `refs/sequences/groups`.
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index c53d1fd..84e4062 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -95,7 +95,7 @@
   [label "Verified"]
       function = MaxWithBlock
       value = -1 Fails
-      value =  0 No score
+      value = 0 No score
       value = +1 Verified
       copyAllScoresIfNoCodeChange = true
 ----
@@ -377,7 +377,7 @@
   [label "Copyright-Check"]
       function = MaxWithBlock
       value = -1 Do not have copyright
-      value =  0 No score
+      value = 0 No score
       value = +1 Copyright clear
 ----
 
@@ -399,7 +399,7 @@
       value = -3 Ohh, hell no!
       value = -2 Hmm, I'm not a fan
       value = -1 I'm not sure I like this
-      value =  0 No score
+      value = 0 No score
       value = +1 I like, but need another to like it as well
       value = +2 Hmm, this is pretty nice
       value = +3 Ohh, hell yes!
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index af2bd98..6bd6b3d 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:
 
@@ -203,6 +204,11 @@
 +
 The subject limited to 72 characters, with an ellipsis if it exceeds that.
 
+$change.shortOriginalSubject::
++
+The original subject limited to 72 characters, with an ellipsis if it exceeds
+that.
+
 $change.ownerEmail::
 +
 The email address of the owner of the change.
@@ -231,6 +237,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/config-project-config.txt b/Documentation/config-project-config.txt
index 3644845..3b2b65f 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -236,6 +236,10 @@
 This option only takes effect in submit strategies which already modify the commit, i.e.
 Cherry Pick, Rebase Always, and (perhaps) Rebase If Necessary.
 
+- 'rejectEmptyCommit': Defines whether empty commits should be rejected when a change is merged.
+Changes might not seem empty at first but when attempting to merge, rebasing can lead to an empty
+commit. If this option is set to 'true' the merge would fail.
+
 Merge strategy
 
 
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index a6f8f5f..fc71d26 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -193,13 +193,13 @@
 Debug test example:
 
 ----
-  bazel test --test_output=streamed --test_filter=com.google.gerrit.acceptance.api.change.ChangeIT.getAmbiguous //gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change:api_change
+  bazel test --test_output=streamed --test_filter=com.google.gerrit.acceptance.api.change.ChangeIT.getAmbiguous //javatests/com/google/gerrit/acceptance/api/change:api_change
 ----
 
 To run a specific test group, e.g. the rest-account test group:
 
 ----
-  bazel test //gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:rest_account
+  bazel test //javatests/com/google/gerrit/acceptance/rest/account:rest_account
 ----
 
 To run the tests against NoteDb backend with write
@@ -358,25 +358,69 @@
 `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
+----
+
+[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).
+
+[[repository_cache]]
+
+To accelerate fetches, local repository cache can be activated. This cache is
+only used for rules_closure external repository and transitive dependendcies.
+That's because rules_closure uses standard Bazel download facility. For all
+other gerrit dependencies, the download_artifacts repository cache is used
+already.
+
+To activate the local repository cache, create accessible cache directory:
+
+----
+ mkdir -p ~/.gerritcodereview/bazel-cache/repository
+----
+
+and add this line to your `~/.bazelrc` file:
+
+----
+build --experimental_repository_cache=/home/<user>/.gerritcodereview/bazel-cache/repository
+----
+
+[NOTE] `experimental_repository_cache` 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-pg.txt b/Documentation/dev-plugins-pg.txt
deleted file mode 100644
index e1bf39e..0000000
--- a/Documentation/dev-plugins-pg.txt
+++ /dev/null
@@ -1,128 +0,0 @@
-= 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
-
-link:https://gerrit-review.googlesource.com/Documentation/js-api.html#_entry_point[Entry
-point] for the plugin and the loading method is based on
-link:http://w3c.github.io/webcomponents/spec/imports/[HTML Imports] spec.
-
-* The plugin provides index.html, similar to
-  link:https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#deployment[.js
-  Web UI plugins]
-* index.html contains a `dom-module` tag with a script that uses
-  `Gerrit.install()`.
-* PolyGerrit imports index.html along with all required resources defined in it
-  (fonts, styles, etc)
-* For standalone plugins, the entry point file is a `pluginname.html` file
-  located in `gerrit-site/plugins` folder, where `pluginname` is an alphanumeric
-  plugin name.
-
-Here's a sample `myplugin.html`:
-
-``` html
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(function() { console.log('Ready.'); });
-  </script>
-</dom-module>
-```
-
-[[low-level-api]]
-== Low-level DOM API
-
-Basically, the DOM is the API surface. Low-level API provides methods for
-decorating, replacing, and styling DOM elements exposed through a set of
-endpoints.
-
-PolyGerrit provides a simple way for accessing the DOM via DOM hooks API. A DOM
-hook is a custom element that is instantiated for the plugin endpoint. In the
-decoration case, a hook is set with a `content` attribute that points to the DOM
-element.
-
-1. Get the DOM hook API instance via `plugin.hook(endpointName)`
-2. Set up an `onAttached` callback
-3. Callback is called when the hook element is created and inserted into DOM
-4. Use element.content to get UI element
-
-``` js
-Gerrit.install(function(plugin) {
-  const domHook = plugin.hook('reply-text');
-  domHook.onAttached(element => {
-    if (!element.content) { return; }
-    // element.content is a reply dialog text area.
-  });
-});
-```
-
-[[low-level-decorating]]
-=== Decorating DOM Elements
-
-For each endpoint, PolyGerrit provides a list of DOM properties (such as
-attributes and events) that are supported in the long-term.
-
-NOTE: TODO: Insert link to the full endpoints API.
-
-``` js
-Gerrit.install(function(plugin) {
-  const domHook = plugin.hook('reply-text');
-  domHook.onAttached(element => {
-    if (!element.content) { return; }
-    element.content.style.border = '1px red dashed';
-  });
-});
-```
-
-[[low-level-replacing]]
-=== Replacing DOM Elements
-
-An endpoint's contents can be replaced by passing the replace attribute as an
-option.
-
-``` js
-Gerrit.install(function(plugin) {
-  const domHook = plugin.hook('header-title', {replace: true});
-  domHook.onAttached(element => {
-    element.appendChild(document.createElement('my-site-header'));
-  });
-});
-```
-
-[[low-level-style]]
-=== Styling DOM Elements
-
-A plugin may provide Polymer's
-https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[style
-modules] to style individual endpoints using
-`plugin.registerStyleModule(endpointName, moduleName)`. A style must be defined
-as a standalone `<dom-module>` defined in the same .html file.
-
-Note: TODO: Insert link to the full styling API.
-
-``` html
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(function(plugin) {
-      plugin.registerStyleModule('change-metadata', 'some-style-module');
-    });
-  </script>
-</dom-module>
-
-<dom-module id="some-style-module">
-  <style>
-    html {
-      --change-metadata-label-status: {
-        display: none;
-      }
-      --change-metadata-strategy: {
-        display: none;
-      }
-    }
-  </style>
-</dom-module>
-```
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 1a026d1..4a64e68 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -3,6 +3,9 @@
 The Gerrit server functionality can be extended by installing plugins.
 This page describes how plugins for Gerrit can be developed.
 
+For PolyGerrit-specific plugin development, consult with
+link:pg-plugin-dev.html[PolyGerrit Plugin Development] guide.
+
 Depending on how tightly the extension code is coupled with the Gerrit
 server code, there is a distinction between `plugins` and `extensions`.
 
@@ -407,6 +410,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 +486,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
 
@@ -2233,7 +2249,7 @@
 link:rest-api-accounts.html#create-account[account creation] REST API and
 inject additional external identifiers for an account that represents a user
 in some external user store. For that, an implementation of the extension
-point `com.google.gerrit.server.api.accounts.AccountExternalIdCreator`
+point `com.google.gerrit.server.account.AccountExternalIdCreator`
 must be registered.
 
 [source,java]
@@ -2574,7 +2590,7 @@
 Compiled plugins and extensions can be deployed to a running Gerrit
 server using the link:cmd-plugin-install.html[plugin install] command.
 
-Web UI plugins distributed as a single `.js` file (or `.html' file for
+Web UI plugins distributed as a single `.js` file (or `.html` file for
 Polygerrit) can be deployed without the overhead of JAR packaging. For
 more information refer to link:cmd-plugin-install.html[plugin install]
 command.
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/index.txt b/Documentation/index.txt
index 24c538f..51ce9d6 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -67,7 +67,8 @@
 . link:config-auto-site-initialization.html[Automatic Site Initialization on Startup]
 . link:pgm-index.html[Server Side Administrative Tools]
 . link:note-db.html[NoteDb]
-. link:config-accounts.html[Accounts]
+. link:config-accounts.html[Accounts on NoteDb]
+. link:config-groups.html[Groups on NoteDb]
 
 == Developer
 . Getting Started
diff --git a/Documentation/intro-gerrit-walkthrough.txt b/Documentation/intro-gerrit-walkthrough.txt
index 071267a..fcb4de2 100644
--- a/Documentation/intro-gerrit-walkthrough.txt
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -182,7 +182,7 @@
 
 Later in the day, Max decides to check on his change and notices Hannah's
 feedback. He opens up the source file and incorporates her feedback. Because
-Max's change includes a change-id, all he has to is follow the typical git
+Max's change includes a change-id, all he has to do is follow the typical git
 workflow for updating a commit:
 
 * Check out the commit
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 901f15a..229c463 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -13,6 +13,13 @@
 * `build/label`: Version of Gerrit server software.
 * `events`: Triggered events.
 
+=== Actions
+
+* `action/retry_attempt_counts`: Distribution of number of attempts made
+by RetryHelper to execute an action (1 == single attempt, no retry)
+* `action/retry_timeout_count`: Number of action executions of RetryHelper
+that ultimately timed out
+
 === Process
 
 * `proc/birth_timestamp`: Time at which the Gerrit process started.
@@ -43,6 +50,7 @@
 * `http/server/error_count`: Rate of REST API error responses.
 * `http/server/success_count`: Rate of REST API success responses.
 * `http/server/rest_api/count`: Rate of REST API calls by view.
+* `http/server/rest_api/change_id_type`: Rate of REST API calls by change ID type.
 * `http/server/rest_api/error_count`: Rate of REST API calls by view.
 * `http/server/rest_api/server_latency`: REST API call latency by view.
 * `http/server/rest_api/response_bytes`: Size of REST API response on network
@@ -86,10 +94,6 @@
 
 * `batch_update/execute_change_ops`: BatchUpdate change update latency,
 excluding reindexing
-* `batch_update/retry_attempt_counts`: Distribution of number of attempts made
-by RetryHelper (1 == single attempt, no retry)
-* `batch_update/retry_timeout_count`: Number of executions of RetryHelper that
-ultimately timed out
 
 === NoteDb
 
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
index 1a986e2..9035cda 100644
--- a/Documentation/note-db.txt
+++ b/Documentation/note-db.txt
@@ -31,7 +31,10 @@
 - Storing link:config-accounts.html[account data] is fully implemented in the
   2.15 release. Account data is migrated automatically during the upgrade
   process by running `gerrit.war init`.
-- Account and change metadata on the servers behind `googlesource.com` is fully
+- Storing link:config-groups.html[group metadata] is fully implemented
+  for the 2.16 release. Group data is migrated automatically during
+  the upgrade process by running `gerrit.war init`
+- Account, group and change metadata on the servers behind `googlesource.com` is fully
   migrated to NoteDb. In other words, if you use
   link:https://gerrit-review.googlesource.com/[gerrit-review], you're already
   using NoteDb.
@@ -44,8 +47,6 @@
 
 == Future Work ("Gerrit 3.0")
 
-- Storing group data is a work in progress. Like account data, it will be
-  migrated automatically.
 - NoteDb will be the only database format supported by Gerrit 3.0. The offline
   change data migration tool will be included in Gerrit 3.0, but online
   migration will only be available in the 2.x line.
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
new file mode 100644
index 0000000..fc704a1
--- /dev/null
+++ b/Documentation/pg-plugin-dev.txt
@@ -0,0 +1,424 @@
+= Gerrit Code Review - PolyGerrit Plugin Development
+
+CAUTION: Work in progress. Hard hat area. Please
+link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20plugins[send
+feedback] if something's not right.
+
+For migrating existing GWT UI plugins, please check out the
+link:pg-plugin-migration.html#migration[migration guide].
+
+[[loading]]
+== Plugin loading and initialization
+
+link:js-api.html#_entry_point[Entry point] for the plugin and the loading method
+is based on link:http://w3c.github.io/webcomponents/spec/imports/[HTML Imports]
+spec.
+
+* The plugin provides pluginname.html, and can be a standalone file or a static
+  asset in a jar as a link:dev-plugins.html#deployment[Web UI plugin].
+* pluginname.html contains a `dom-module` tag with a script that uses
+  `Gerrit.install()`. There should only be single `Gerrit.install()` per file.
+* PolyGerrit imports pluginname.html along with all required resources defined in it
+  (fonts, styles, etc).
+* For standalone plugins, the entry point file is a `pluginname.html` file
+  located in `gerrit-site/plugins` folder, where `pluginname` is an alphanumeric
+  plugin name.
+
+Note: Code examples target modern browsers (Chrome, Firefox, Safari, Edge).
+
+Here's a recommended starter `myplugin.html`:
+
+``` html
+<dom-module id="my-plugin">
+  <script>
+    Gerrit.install(plugin => {
+      'use strict';
+
+      // Your code here.
+    });
+  </script>
+</dom-module>
+```
+
+[[low-level-api-concepts]]
+== Low-level DOM API concepts
+
+Basically, the DOM is the API surface. Low-level API provides methods for
+decorating, replacing, and styling DOM elements exposed through a set of
+link:pg-plugin-endpoints.html[endpoints].
+
+PolyGerrit provides a simple way for accessing the DOM via DOM hooks API. A DOM
+hook is a custom element that is instantiated for the plugin endpoint. In the
+decoration case, a hook is set with a `content` attribute that points to the DOM
+element.
+
+1. Get the DOM hook API instance via `plugin.hook(endpointName)`
+2. Set up an `onAttached` callback
+3. Callback is called when the hook element is created and inserted into DOM
+4. Use element.content to get UI element
+
+``` js
+Gerrit.install(plugin => {
+  const domHook = plugin.hook('reply-text');
+  domHook.onAttached(element => {
+    if (!element.content) { return; }
+    // element.content is a reply dialog text area.
+  });
+});
+```
+
+[[low-level-decorating]]
+=== Decorating DOM Elements
+
+For each endpoint, PolyGerrit provides a list of DOM properties (such as
+attributes and events) that are supported in the long-term.
+
+``` js
+Gerrit.install(plugin => {
+  const domHook = plugin.hook('reply-text');
+  domHook.onAttached(element => {
+    if (!element.content) { return; }
+    element.content.style.border = '1px red dashed';
+  });
+});
+```
+
+[[low-level-replacing]]
+=== Replacing DOM Elements
+
+An endpoint's contents can be replaced by passing the replace attribute as an
+option.
+
+``` js
+Gerrit.install(plugin => {
+  const domHook = plugin.hook('header-title', {replace: true});
+  domHook.onAttached(element => {
+    element.appendChild(document.createElement('my-site-header'));
+  });
+});
+```
+
+[[low-level-style]]
+=== Styling DOM Elements
+
+A plugin may provide Polymer's
+https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[style
+modules] to style individual endpoints using
+`plugin.registerStyleModule(endpointName, moduleName)`. A style must be defined
+as a standalone `<dom-module>` defined in the same .html file.
+
+Note: TODO: Insert link to the full styling API.
+
+``` html
+<dom-module id="my-plugin">
+  <script>
+    Gerrit.install(plugin => {
+      plugin.registerStyleModule('change-metadata', 'some-style-module');
+    });
+  </script>
+</dom-module>
+
+<dom-module id="some-style-module">
+  <style>
+    html {
+      --change-metadata-label-status: {
+        display: none;
+      }
+      --change-metadata-strategy: {
+        display: none;
+      }
+    }
+  </style>
+</dom-module>
+```
+
+[[high-level-api-concepts]]
+== High-level DOM API concepts
+
+High level API is based on low-level DOM API and is essentially a standardized
+way for doing common tasks. It's less flexible, but will be a bit more stable.
+
+The common way to access high-level API is through `plugin` instance passed
+into setup callback parameter of `Gerrit.install()`, also sometimes referred to
+as `self`.
+
+[[low-level-api]]
+== Low-level DOM API
+
+The 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)`
+
+See list of supported link:pg-plugin-endpoints.html[endpoints].
+
+Note: TODO
+
+=== registerCustomComponent
+`plugin.registerCustomComponent(endpointName, opt_moduleName, opt_options)`
+
+See list of supported link:pg-plugin-endpoints.html[endpoints].
+
+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
+
+=== panel
+`plugin.panel(extensionpoint, callback)`
+
+Deprecated. Use `plugin.registerCustomComponent()` instead.
+
+``` js
+Gerrit.install(function(self) {
+  self.panel('CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK', function(context) {
+    context.body.innerHTML =
+      'Sample link: <a href="http://some.com/foo">Foo</a>';
+    context.show();
+  });
+});
+```
+
+Here's the recommended approach that uses Polymer for generating custom elements:
+
+``` html
+<dom-module id="some-plugin">
+  <script>
+    Gerrit.install(plugin => {
+      plugin.registerCustomComponent(
+        'change-view-integration', 'some-ci-module');
+    });
+  </script>
+</dom-module>
+
+<dom-module id="some-ci-module">
+  <template>
+    Sample link: <a href="http://some.com/foo">Foo</a>
+  </template>
+  <script>
+    Polymer({is: 'some-ci-module'});
+  </script>
+</dom-module>
+```
+
+Here's a minimal example that uses low-level DOM Hooks API for the same purpose:
+
+``` js
+Gerrit.install(plugin => {
+  plugin.hook('change-view-integration', el => {
+    el.innerHTML = 'Sample link: <a href="http://some.com/foo">Foo</a>';
+  });
+});
+```
+
+=== popup
+`plugin.popup(moduleName)`
+
+Note: TODO
+
+=== post
+`plugin.post(url, payload, opt_callback)`
+
+Note: TODO
+
+[plugin-repo]
+=== repo
+`plugin.repo()`
+
+.Params:
+- none
+
+.Returns:
+- Instance of link:pg-plugin-repo-api.html[GrRepoApi].
+
+=== put
+`plugin.put(url, payload, opt_callback)`
+
+Note: TODO
+
+=== screen
+`plugin.screen(screenName, opt_moduleName)`
+
+.Params:
+- `*string* screenName` URL path fragment of the screen, e.g.
+`/x/pluginname/*screenname*`
+- `*string* opt_moduleName` (Optional) Web component to be instantiated for this
+screen.
+
+.Returns:
+- Instance of GrDomHook.
+
+=== screenUrl
+`plugin.url(opt_screenName)`
+
+.Params:
+- `*string* screenName` (optional) URL path fragment of the screen, e.g.
+`/x/pluginname/*screenname*`
+
+.Returns:
+- Absolute URL for the screen, e.g. `http://localhost/base/x/pluginname/screenname`
+
+[[plugin-settings]]
+=== settings
+`plugin.settings()`
+
+.Params:
+- none
+
+.Returns:
+- Instance of link:pg-plugin-settings-api.html[GrSettingsApi].
+
+=== settingsScreen
+`plugin.settingsScreen(path, menu, callback)`
+
+Deprecated. Use link:#plugin-settings[`plugin.settings()`] instead.
+
+=== theme
+`plugin.theme()`
+
+Note: TODO
+
+=== url
+`plugin.url(opt_path)`
+
+Note: TODO
+
+[[deprecated-api]]
+== Deprecated APIs
+
+Some of the deprecated APIs have limited implementation in PolyGerrit to serve
+as a "stepping stone" to allow gradual migration.
+
+=== install
+`plugin.deprecated.install()`
+
+.Params:
+- none
+
+Replaces plugin APIs with a deprecated version. This allows use of deprecated
+APIs without changing JS code. For example, `onAction` is not available by
+default, and after `plugin.deprecated.install()` it's accessible via
+`self.onAction()`.
+
+=== onAction
+`plugin.deprecated.onAction(type, view_name, callback)`
+
+.Params:
+- `*string* type` Action type.
+- `*string* view_name` REST API action.
+- `*function(actionContext)* callback` Callback invoked on action button click.
+
+Adds a button to the UI with a click callback. Exact button location depends on
+parameters. Callback is triggered with an instance of
+link:#deprecated-action-context[action context].
+
+Support is limited:
+
+- type is either `change` or `revision`.
+
+See link:js-api.html#self_onAction[self.onAction] for more info.
+
+=== panel
+`plugin.deprecated.panel(extensionpoint, callback)`
+
+.Params:
+- `*string* extensionpoint`
+- `*function(screenContext)* callback`
+
+Adds a UI DOM element and triggers a callback with context to allow direct DOM
+access.
+
+Support is limited:
+
+- extensionpoint is one of the following:
+ * CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK
+ * CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK
+
+See link:js-api.html#self_panel[self.panel] for more info.
+
+=== settingsScreen
+`plugin.deprecated.settingsScreent(path, menu, callback)`
+
+.Params:
+- `*string* path` URL path fragment of the screen for direct link.
+- `*string* menu` Menu item title.
+- `*function(settingsScreenContext)* callback`
+
+Adds a settings menu item and a section in the settings screen that is provided
+to plugin for setup.
+
+See link:js-api.html#self_settingsScreen[self.settingsScreen] for more info.
+
+[[deprecated-action-context]]
+=== Action Context (deprecated)
+Instance of Action Context is passed to `onAction()` callback.
+
+Support is limited:
+
+- `popup()`
+- `hide()`
+- `refresh()`
+- `textfield()`
+- `br()`
+- `msg()`
+- `div()`
+- `button()`
+- `checkbox()`
+- `label()`
+- `prependLabel()`
+- `call()`
+
+See link:js-api.html#ActionContext[Action Context] for more info.
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
new file mode 100644
index 0000000..b838e87
--- /dev/null
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -0,0 +1,82 @@
+= Gerrit Code Review - PolyGerrit Plugin Styling
+
+Plugins should be html-based and imported following PolyGerrit's
+link:pg-plugin-dev.html#loading[dev guide].
+
+Sample code for testing endpoints:
+
+``` js
+Gerrit.install(plugin => {
+  // Change endpoint below
+  const endpoint = 'change-metadata-item';
+  plugin.hook(endpoint).onAttached(element => {
+    console.log(endpoint, element);
+    const el = element.appendChild(document.createElement('div'));
+    el.textContent = 'Ah, there it is. Lovely.';
+    el.style = 'background: pink; line-height: 4em; text-align: center;';
+  });
+});
+```
+
+== Default parameters
+All endpoints receive the following parameters, set as attributes to custom
+components that are instantiated at the endpoint:
+
+* `plugin`
++
+the current plugin instance, the one that is used by `Gerrit.install()`.
+
+* `content`
++
+decorated DOM Element, is only set for registrations that decorate existing
+components.
+
+== Plugin endpoints
+
+The following endpoints are available to plugins.
+
+=== change-view-integration
+The `change-view-integration` extension point is located between `Files` and
+`Messages` section on the change view page, and it may take full page's
+width. Primary purpose is to enable plugins to display custom CI-related
+information (build status, etc).
+
+* `change`
++
+current change displayed, an instance of
+link:rest-api-changes.html#change-info[ChangeInfo]
+
+* `revision`
++
+current revision displayed, an instance of
+link:rest-api-changes.html#revision-info[RevisionInfo]
+
+=== change-metadata-item
+The `change-metadata-item` extension point is located on the bottom of the
+change view left panel, under the `Label Status` and `Links` sections. Its width
+is equal to the left panel's, and its primary purpose is to allow plugins to add
+sections of metadata to the left panel.
+
+In addition to default parameters, the following are available:
+
+* `change`
++
+current change displayed, an instance of
+link:rest-api-changes.html#change-info[ChangeInfo]
+
+* `revision`
++
+current revision displayed, an instance of
+link:rest-api-changes.html#revision-info[RevisionInfo]
+
+=== robot-comment-controls
+The `robot-comment-controls` extension point is located inside each comment
+rendered on the diff page, and is only visible when the comment is a robot
+comment, specifically if the comment has a `robot_id` property.
+
+In addition to default parameters, the following are available:
+
+* `comment`
++
+current comment displayed, an instance of
+link:rest-api-changes.html#comment-info[CommentInfo]
diff --git a/Documentation/pg-plugin-migration.txt b/Documentation/pg-plugin-migration.txt
new file mode 100644
index 0000000..39c3c4d
--- /dev/null
+++ b/Documentation/pg-plugin-migration.txt
@@ -0,0 +1,153 @@
+= Gerrit Code Review - PolyGerrit Plugin Development
+
+CAUTION: Work in progress. Hard hat area. Please
+link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20plugins[send
+feedback] if something's not right.
+
+[[migration]]
+== Incremental migration of existing GWT UI plugins
+
+link:pg-plugin-dev.html[PolyGerrit plugin API] operates different concepts and
+provides different type of API compared to ones available to GWT
+plugins. Depending on the plugin, it might require significant modifications to
+existing UI scripts to fully take advantage of benefits PolyGerrit API
+provides.
+
+To make migration easier, PolyGerrit recommends incremental migration
+strategy. Starting with a .js file that works for GWT UI, plugin author can
+incrementally migrate deprecated APIs to new plugin API.
+
+The goal for this guide is to provide migration path from .js-based UI script to
+.html-based.
+
+NOTE: Web UI plugins distributed as a single .js file are not covered in this
+guide.
+
+Let's start with a basic plugin that has an UI module. Commonly, file tree
+should look like this:
+
+  ├── BUILD
+  ├── LICENSE
+  └── src
+      └── main
+          ├── java
+          │   └── com
+          │       └── foo
+          │           └── SamplePluginModule.java
+          └── resources
+              └── static
+                  └── sampleplugin.js
+
+For simplicity's sake, let's assume SamplePluginModule.java has following
+content:
+
+``` java
+public class SamplePluginModule extends AbstractModule {
+
+  @Override
+  protected void configure() {
+    DynamicSet.bind(binder(), WebUiPlugin.class)
+        .toInstance(new JavaScriptPlugin("sampleplugin.js"));
+  }
+}
+```
+
+=== Step 1: Create `sampleplugin.html`
+
+As a first step, create starter `sampleplugin.html` and include UI script in the
+module file.
+
+NOTE: GWT UI ignore .html since it's not supported.
+
+``` java
+  @Override
+  protected void configure() {
+    DynamicSet.bind(binder(), WebUiPlugin.class)
+        .toInstance(new JavaScriptPlugin("sampleplugin.js"));
+    DynamicSet.bind(binder(), WebUiPlugin.class)
+        .toInstance(new JavaScriptPlugin("sampleplugin.html"));
+  }
+```
+
+Here's recommended starter code for `sampleplugin.html`:
+
+NOTE: By specification, the `id` attribute of `dom-module` *must* contain a dash
+(-).
+
+``` html
+<dom-module id="sample-plugin">
+  <script>
+    Gerrit.install(plugin => {
+        // Setup block, is executed before sampleplugin.js
+
+        // Install deprecated JS APIs (onAction, popup, etc)
+        plugin.deprecated.install();
+    });
+  </script>
+
+  <script src="./sampleplugin.js"></script>
+
+  <script>
+    Gerrit.install(plugin => {
+        // Cleanup block, is executed after sampleplugin.js
+    });
+  </script>
+</dom-module>
+```
+
+Here's how this works:
+
+- PolyGerrit detects migration scenario because UI scripts have same filename
+and different extensions
+ * PolyGerrit will load `sampleplugin.html` and skip `sampleplugin.js`
+ * PolyGerrit will reuse `plugin` (aka `self`) instance for `Gerrit.install()`
+callbacks
+- `sampleplugin.js` is loaded since it's referenced in `sampleplugin.html`
+- setup script tag code is executed before `sampleplugin.js`
+- cleanup script tag code is executed after `sampleplugin.js`
+- `plugin.deprecated.install()` enables deprecated APIs (onAction(), popup(),
+etc) before `sampleplugin.js` is loaded
+
+So the purpose is to share plugin instance between .html-based and .js-based
+code, making it possible to gradually and incrementally transfer code to new API.
+
+=== Step 2: Create cut-off marker in `sampleplugin.js`
+
+Commonly, window.Polymer is being used to detect in GWT UI script if it's being
+executed inside PolyGerrit. This could be used to separate code that was already
+migrated to new APIs from the one that hasn't been migrated yet.
+
+During incremental migration, some of the UI code will be reimplemented using
+PolyGerrit plugin API. However, old code still could be required for the plugin
+to work in GWT UI.
+
+To handle this case, add following code to be the last thing in installation
+callback in `sampleplugin.js`
+
+``` js
+Gerrit.install(function(self) {
+
+  // Existing code here, not modified.
+
+  if (window.Polymer) { return; } // Cut-off marker
+
+  // Everything below was migrated to PolyGerrit plugin API.
+  // Code below is still needed for the plugin to work in GWT UI.
+});
+```
+
+=== Step 3: Migrate!
+
+The code that uses deprecated APIs should be eventually rewritten using
+non-deprecated counterparts. Duplicated pieces could be kept under cut-off
+marker to work in GWT UI.
+
+If some data or functions needs to be shared between code in .html and .js, it
+could be stored on `plugin` (aka `self`) object that's shared between both
+
+=== Step 4: Cleanup
+
+Once deprecated APIs are migrated, `sampleplugin.js` will only contain
+duplicated code that's required for GWT UI to work. With sudden but inevitable
+GWT code removal from Gerrit that file can be simply deleted, along with script
+tag loading it.
diff --git a/Documentation/pg-plugin-repo-api.txt b/Documentation/pg-plugin-repo-api.txt
new file mode 100644
index 0000000..1272ea6
--- /dev/null
+++ b/Documentation/pg-plugin-repo-api.txt
@@ -0,0 +1,36 @@
+= Gerrit Code Review - Repo admin customization API
+
+This API is provided by link:pg-plugin-dev.html#plugin-repo[plugin.repo()]
+and provides customization to admin page.
+
+== createCommand
+`repoApi.createCommand(title, checkVisibleCallback)`
+
+Create a repo command in the admin panel.
+
+.Params
+- *title* String title.
+- *checkVisibleCallback* function to configure command visibility.
+
+.Returns
+- GrRepoApi for chaining.
+
+`checkVisibleCallback(repoName, repoConfig)`
+
+.Params
+- *repoName* String project name.
+- *repoConfig* Object REST API response for repo config.
+
+.Returns
+- `false` to hide the command for the specific project.
+
+== onTap
+`repoApi.onTap(tapCalback)`
+
+Add a command tap callback.
+
+.Params
+- *tapCallback* function that's excuted on command tap.
+
+.Returns
+- Nothing
diff --git a/Documentation/pg-plugin-settings-api.txt b/Documentation/pg-plugin-settings-api.txt
new file mode 100644
index 0000000..985809d
--- /dev/null
+++ b/Documentation/pg-plugin-settings-api.txt
@@ -0,0 +1,40 @@
+= Gerrit Code Review - Settings admin customization API
+
+This API is provided by link:pg-plugin-dev.html#plugin-settings[plugin.settings()]
+and provides customization to settings page.
+
+== title
+`settingsApi.title(title)`
+
+.Params
+- `*string* title` Menu item and settings section title
+
+.Returns
+- `GrSettingsApi` for chaining.
+
+== token
+`settingsApi.token(token)`
+
+.Params
+- `*string* token` URL path fragment of the screen for direct link, e.g.
+`settings/#x/some-plugin/*token*`
+
+.Returns
+- `GrSettingsApi` for chaining.
+
+== module
+`settingsApi.module(token)`
+
+.Params
+- `*string* module` Custom element name for instantiating in the settings plugin
+area.
+
+.Returns
+- `GrSettingsApi` for chaining.
+
+== build
+
+.Params
+- none
+
+Apply all other configuration parameters and create required UI elements.
diff --git a/Documentation/pg-plugin-styling.txt b/Documentation/pg-plugin-styling.txt
new file mode 100644
index 0000000..301da51
--- /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 (i.e. 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 use case to track and maintain for future releases.
+
+Plugins should be html-based and imported following PolyGerrit's
+link:pg-plugin-dev.html#loading[dev guide].
+
+Plugins should provide Style Module, for example:
+
+``` html
+  <dom-module id="some-style">
+    <style>
+      :root {
+        --css-mixin-name: {
+          property: value;
+        }
+      }
+    </style>
+  </dom-module>
+```
+
+Plugins 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/project-configuration.txt b/Documentation/project-configuration.txt
index af8077c..6260f5b 100644
--- a/Documentation/project-configuration.txt
+++ b/Documentation/project-configuration.txt
@@ -57,6 +57,12 @@
 its dependencies are also submitted, with exceptions documented below.
 The following submit types are supported:
 
+[[submit_type_inherit]]
+* Inherit
++
+Inherit the submit type from the parent project. In `All-Projects`, this
+is equivalent to link:#merge_if_necessary[Merge If Necessary].
+
 [[fast_forward_only]]
 * Fast Forward Only
 +
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 99b45a2..7561286 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -517,8 +517,9 @@
     Author = label('Author-is-John-Doe', need(_)).
 
 submit_rule(submit(Author)) :-
-    gerrit:commit_author(A, _, 'john.doe@example.com'),
-    Author = label('Author-is-John-Doe', ok(A)).
+    gerrit:commit_author(_, _, 'john.doe@example.com'),
+    gerrit:uploader(U),
+    Author = label('Author-is-John-Doe', ok(U)).
 ----
 
 or by user id (assuming it is `1000000`):
@@ -544,8 +545,9 @@
     Author = label('Author-is-John-Doe', need(_)).
 
 submit_rule(submit(Author)) :-
-    gerrit:commit_author(A, 'John Doe', 'john.doe@example.com'),
-    Author = label('Author-is-John-Doe', ok(A)).
+    gerrit:commit_author(_, 'John Doe', 'john.doe@example.com'),
+    gerrit:uploader(U),
+    Author = label('Author-is-John-Doe', ok(U)).
 ----
 
 === Example 7: Make change submittable if commit message starts with "Fix "
@@ -571,8 +573,8 @@
 
 submit_rule(submit(Fix)) :-
     gerrit:commit_message(M), name(M, L), starts_with(L, "Fix "),
-    gerrit:commit_author(A),
-    Fix = label('Commit-Message-starts-with-Fix', ok(A)).
+    gerrit:uploader(U),
+    Fix = label('Commit-Message-starts-with-Fix', ok(U)).
 
 starts_with(L, []).
 starts_with([H|T1], [H|T2]) :- starts_with(T1, T2).
@@ -597,8 +599,8 @@
 
 submit_rule(submit(Fix)) :-
     gerrit:commit_message_matches('^Fix '),
-    gerrit:commit_author(A),
-    Fix = label('Commit-Message-starts-with-Fix', ok(A)).
+    gerrit:uploader(U),
+    Fix = label('Commit-Message-starts-with-Fix', ok(U)).
 ----
 
 The previous example could also be written so that it first checks if the commit
@@ -610,8 +612,8 @@
 ----
 submit_rule(submit(Fix)) :-
     gerrit:commit_message_matches('^Fix '),
-    gerrit:commit_author(A),
-    Fix = label('Commit-Message-starts-with-Fix', ok(A)),
+    gerrit:uploader(U),
+    Fix = label('Commit-Message-starts-with-Fix', ok(U)),
     !.
 
 % Message does not start with 'Fix ' so Fix is needed to submit
@@ -1009,8 +1011,8 @@
 submit_rule(submit(R)) :-
     gerrit:unresolved_comments_count(0),
     !,
-    gerrit:commit_author(A),
-    R = label('All-Comments-Resolved', ok(A)).
+    gerrit:uploader(U),
+    R = label('All-Comments-Resolved', ok(U)).
 
 submit_rule(submit(R)) :-
     gerrit:unresolved_comments_count(U),
@@ -1029,8 +1031,8 @@
     base(CR, V),
     gerrit:unresolved_comments_count(0),
     !,
-    gerrit:commit_author(A),
-    R = label('All-Comments-Resolved', ok(A)).
+    gerrit:uploader(U),
+    R = label('All-Comments-Resolved', ok(U)).
 
 submit_rule(submit(CR, V, R)) :-
     base(CR, V),
@@ -1059,8 +1061,8 @@
 submit_rule(submit(R)) :-
     gerrit:pure_revert(1),
     !,
-    gerrit:commit_author(A),
-    R = label('Is-Pure-Revert', ok(A)).
+    gerrit:uploader(U),
+    R = label('Is-Pure-Revert', ok(U)).
 
 submit_rule(submit(R)) :-
     gerrit:pure_revert(U),
@@ -1079,8 +1081,8 @@
     base(CR, V),
     gerrit:pure_revert(1),
     !,
-    gerrit:commit_author(A),
-    R = label('Is-Pure-Revert', ok(A)).
+    gerrit:uploader(U),
+    R = label('Is-Pure-Revert', ok(U)).
 
 submit_rule(submit(CR, V, R)) :-
     base(CR, V),
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 8dc3b9d..5912d1f 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -63,7 +63,9 @@
 
 [[all-emails]]
 --
-* `ALL_EMAILS`: Includes all registered emails.
+* `ALL_EMAILS`: Includes all registered emails. Requires the caller
+to have the link:access-control.html#capability_modifyAccount[Modify
+Account] global capability.
 --
 
 [[suggest-account]]
@@ -79,6 +81,10 @@
   GET /accounts/?suggest&q=John HTTP/1.0
 ----
 
+Secondary emails are only included if the calling user has the
+link:access-control.html#capability_modifyAccount[Modify Account]
+capability.
+
 .Response
 ----
   HTTP/1.1 200 OK
@@ -2159,7 +2165,10 @@
 |`secondary_emails`|optional|
 A list of the secondary email addresses of the user. +
 Only set for account queries when the link:#all-emails[ALL_EMAILS]
-option is set.
+option or the link:#suggest-account[suggest] parameter is set. +
+Secondary emails are only included if the calling user has the
+link:access-control.html#capability_modifyAccount[Modify Account], and
+hence is allowed to see secondary emails of other users.
 |`username`        |optional|The username of the user. +
 Only set if detailed account information is requested. +
 See option link:rest-api-changes.html#detailed-accounts[
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index ea577f3..5e67b38 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -5202,6 +5202,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.
@@ -5384,8 +5387,8 @@
 Identifier that uniquely identifies one change. It contains the URL-encoded
 project name as well as the change number: "'$$<project>~<numericId>$$'"
 
-Gerrit still supports the following deprecated identifiers. These will be
-removed in a future release:
+Depending on the server's configuration, Gerrit can still support the following
+deprecated identifiers. These will be removed in a future release:
 
 * an ID of the change in the format "'$$<project>~<branch>~<Change-Id>$$'",
   where for the branch the `refs/heads/` prefix can be omitted
@@ -5394,6 +5397,10 @@
   ("I8473b95934b5732ac55d26311a706c9c2bde9940")
 * a numeric change ID ("4247")
 
+If you need more time to migrate off of old change IDs, please see
+link:config-gerrit.html#change.api.allowedIdentifier[change.api.allowedIdentifier]
+for more information on how to enable the use of deprecated identifiers.
+
 [[comment-id]]
 === \{comment-id\}
 UUID of a published comment.
@@ -6445,7 +6452,10 @@
 set's subject
 |`inheritParent`      |optional, default to `false`|
 Use the current patch set's first parent as the merge tip when set to `true`.
-Otherwise, use the current branch tip of the destination branch.
+|`base_change`        |optional|
+A link:#change-id[\{change-id\}] that identifies a change. When `inheritParent`
+is `false`, the merge tip will be the current patch set of the `base_change` if
+it's set. Otherwise, the current branch tip of the destination branch will be used.
 |`merge`              ||
 The detail of the source commit for merge as a link:#merge-input[MergeInput]
 entity.
@@ -6695,23 +6705,15 @@
 |`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
 input. +
-Allowed values are `DELETE`, `PUBLISH`, `PUBLISH_ALL_REVISIONS` and
-`KEEP`. All values except `PUBLISH_ALL_REVISIONS` operate only on drafts
-for a single revision. +
+Allowed values are `PUBLISH`, `PUBLISH_ALL_REVISIONS` and `KEEP`. All values
+except `PUBLISH_ALL_REVISIONS` operate only on drafts for a single revision. +
 Only `KEEP` is allowed when used in conjunction with `on_behalf_of`. +
-If not set, the default is `DELETE`, unless `on_behalf_of` is set, in
-which case the default is `KEEP` and any other value is disallowed.
+If not set, the default is `KEEP`. If `on_behalf_of` is set, then no other value
+besides `KEEP` is allowed.
 |`notify`                 |optional|
 Notify handling that defines to whom email notifications should be sent
 after the review is stored. +
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 08cd876..8aa1f42 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -136,7 +136,7 @@
       "from": 0
     },
     "user": {
-      "anonymous_coward_name": "Anonymous Coward"
+      "anonymous_coward_name": "Name of user not set"
     }
   }
 ----
@@ -333,7 +333,7 @@
         "mem": 12
       }
     },
-    "groups_byinclude": {
+    "groups_bymember": {
       "type": "MEM",
       "entries": {},
       "hit_ratio": {}
@@ -343,6 +343,11 @@
       "entries": {},
       "hit_ratio": {}
     },
+    "groups_bysubgroup": {
+      "type": "MEM",
+      "entries": {},
+      "hit_ratio": {}
+    },
     "groups_byuuid": {
       "type": "MEM",
       "entries": {
@@ -358,16 +363,6 @@
       "entries": {},
       "hit_ratio": {}
     },
-    groups_members": {
-      "type": "MEM",
-      "entries": {
-        "mem": 4
-      },
-      "average_get": "697.8us",
-      "hit_ratio": {
-        "mem": 82
-      }
-    },
     "permission_sort": {
       "type": "MEM",
       "entries": {
@@ -467,11 +462,11 @@
     "diff_intraline",
     "git_tags",
     "groups",
-    "groups_byinclude",
+    "groups_bymember",
     "groups_byname",
+    "groups_bysubgroup",
     "groups_byuuid",
     "groups_external",
-    "groups_members",
     "permission_sort",
     "plugin_resources",
     "project_list",
@@ -1403,6 +1398,8 @@
 |`submit_whole_topic` ||
 link:config-gerrit.html#change.submitWholeTopic[A configuration if
 the whole topic is submitted].
+|`disable_private_changes` |not set if `false`|
+Returns true if private changes are disabled.
 |=============================
 
 [[check-account-external-ids-input]]
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 a85b8e6..34c0e72 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -325,7 +325,8 @@
 ----
 
 All::
-Get all projects, including those whose state is "HIDDEN".
+Get all projects, including those whose state is "HIDDEN". May not be used
+together with the `state` option.
 +
 .Request
 ----
@@ -351,6 +352,89 @@
   }
 ----
 
+State(s)::
+Get all projects with the given state. May not be used together with the
+`all` option.
++
+.Request
+----
+GET /projects/?state=HIDDEN HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "some-other-project": {
+      "id": "some-other-project",
+      "state": "HIDDEN"
+    }
+  }
+----
+
+[[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
 --
@@ -410,7 +494,7 @@
 
   {
     "description": "This is a demo project.",
-    "submit_type": "CHERRY_PICK",
+    "submit_type": "INHERIT",
     "owners": [
       "MyProject-Owners"
     ]
@@ -737,7 +821,12 @@
       "configured_value": "15m",
       "inherited_value": "20m"
     },
-    "submit_type": "MERGE_IF_NECESSARY",
+    "submit_type": "INHERIT",
+    "default_submit_type": {
+      "value": "MERGE_IF_NECESSARY",
+      "configured_value": "INHERIT",
+      "inherited_value": "MERGE_IF_NECESSARY"
+    },
     "state": "ACTIVE",
     "commentlinks": {},
     "plugin_config": {
@@ -849,6 +938,11 @@
       "inherited_value": "20m"
     },
     "submit_type": "REBASE_IF_NECESSARY",
+    "default_submit_type": {
+      "value": "REBASE_IF_NECESSARY",
+      "configured_value": "INHERIT",
+      "inherited_value": "REBASE_IF_NECESSARY"
+    },
     "state": "ACTIVE",
     "commentlinks": {}
   }
@@ -2337,6 +2431,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
@@ -2764,10 +2860,12 @@
 The link:config-gerrit.html#receive.maxObjectSizeLimit[max object size
 limit] of this project as a link:#max-object-size-limit-info[
 MaxObjectSizeLimitInfo] entity.
+|`default_submit_type`     ||
+link:#submit-type-info[SubmitTypeInfo] that describes the default submit type of
+the project, when not overridden at the change level.
 |`submit_type`               ||
-The default submit type of the project, can be `MERGE_IF_NECESSARY`,
-`FAST_FORWARD_ONLY`, `REBASE_IF_NECESSARY`, `REBASE_ALWAYS`, `MERGE_ALWAYS` or
-`CHERRY_PICK`.
+Deprecated; equivalent to link:#submit-type-info[`value`] in
+`default_submit_type`.
 |`match_author_to_committer_date` |optional|
 link:#inherited-boolean-info[InheritedBooleanInfo] that indicates whether
 a change's author date will be changed to match its submitter date upon submit.
@@ -2790,6 +2888,9 @@
 |`actions`                                 |optional|
 Actions the caller might be able to perform on this project. The
 information is a map of view names to
+|`reject_empty_commit`                     |optional|
+link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
+empty commits should be rejected when a change is merged.
 link:rest-api-changes.html#action-info[ActionInfo] entities.
 |=======================================================
 
@@ -2854,6 +2955,10 @@
 |`plugin_config_values`                    |optional|
 Plugin configuration values as map which maps the plugin name to a map
 of parameter names to values.
+|`reject_empty_commit`                     |optional|
+Whether empty commits should be rejected when a change is merged.
+Can be `TRUE`, `FALSE` or `INHERIT`. +
+If not set, this setting is not updated.
 |======================================================
 
 [[config-parameter-info]]
@@ -3187,6 +3292,9 @@
 |`plugin_config_values`      |optional|
 Plugin configuration values as map which maps the plugin name to a map
 of parameter names to values.
+|`reject_empty_commit`       |optional|
+Whether empty commits should be rejected when a change is merged
+(`TRUE`, `FALSE`, `INHERIT`).
 |=========================================
 
 [[project-parent-input]]
@@ -3235,6 +3343,27 @@
 |`size_of_packed_objects`  |Size of packed objects in bytes.
 |======================================
 
+[[submit-type-info]]
+=== SubmitTypeInfo
+Information about the link:project-configuration.html#submit_type[default submit
+type of a project], taking into account project inheritance.
+
+Valid values for each field are `MERGE_IF_NECESSARY`, `FAST_FORWARD_ONLY`,
+`REBASE_IF_NECESSARY`, `REBASE_ALWAYS`, `MERGE_ALWAYS` or `CHERRY_PICK`, plus
+`INHERIT` where applicable.
+
+[options="header",cols="1,6"]
+|===============================
+|Field Name         |Description
+|`value`            |
+The effective submit type value. Never `INHERIT`.
+|`configured_value` |
+The configured value, can be one of the submit types, or `INHERIT` to inherit
+from the parent project.
+|`inherited_value`  |
+The effective value that would be inherited from the parent. Never `INHERIT`.
+|===============================
+
 [[tag-info]]
 === TagInfo
 The `TagInfo` entity contains information about a tag.
@@ -3251,6 +3380,11 @@
 the signature.
 |`tagger`|Only set for annotated tags, if present in the tag.|The tagger as a
 link:rest-api-changes.html#git-person-info[GitPersonInfo] entity.
+|`created`|optional|The link:rest-api.html#timestamp[timestamp] of when the tag
+was created. For annotated and signed tags, this is the timestamp of the tag object
+and is the same as the `date` field in the `tagger`. For lightweight tags, it is
+the commit timestamp of the commit to which the tag points, when the object is a
+commit. It is not set when the object is any other type.
 |`can_delete`|not set if `false`|
 Whether the calling user can delete this tag.
 |`web_links` |optional|
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/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 556a8bb..b5579e4 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -300,12 +300,20 @@
 option:
 
 ----
-  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%m=This_is_a_rebase_on_master
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%m=This_is_a_rebase_on_master%21
 ----
 
 [NOTE]
-git push refs parameter does not allow spaces.  Use the '_' character instead,
-it will then be applied as "This is a rebase on master".
+git push refs parameter does not allow spaces. Use the '_' or '+' character
+to represent spaces, and percent-encoding to represent other special chars.
+The above example will thus be applied as "This is a rebase on master!"
+
+To avoid confusion in parsing the git ref, at least the following characters
+must be percent-encoded: " %^@.~-+_:/!". Note that some of the reserved
+characters (like tilde) are not escaped in the standard URL encoding rules,
+so a language-provided function (e.g. encodeURIComponent(), in javascript)
+might not suffice. To be safest, you might consider percent-encoding all
+non-alphanumeric characters (and all multibyte UTF-8 code points).
 
 [[publish-comments]]
 ==== Publish Draft Comments
diff --git a/ReleaseNotes/.gitignore b/ReleaseNotes/.gitignore
deleted file mode 100644
index 8a3da24..0000000
--- a/ReleaseNotes/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*.html
-/.published
diff --git a/ReleaseNotes/BUILD b/ReleaseNotes/BUILD
deleted file mode 100644
index b0c8a13..0000000
--- a/ReleaseNotes/BUILD
+++ /dev/null
@@ -1,25 +0,0 @@
-load("//tools/bzl:asciidoc.bzl", "release_notes_attributes")
-load("//tools/bzl:asciidoc.bzl", "genasciidoc")
-load("//tools/bzl:asciidoc.bzl", "genasciidoc_zip")
-
-SRCS = glob(["*.txt"])
-
-genasciidoc(
-    name = "ReleaseNotes",
-    srcs = SRCS,
-    attributes = release_notes_attributes(),
-    backend = "html5",
-    resources = False,
-    searchbox = False,
-    visibility = ["//visibility:public"],
-)
-
-genasciidoc_zip(
-    name = "html",
-    srcs = SRCS,
-    attributes = release_notes_attributes(),
-    backend = "html5",
-    resources = False,
-    searchbox = False,
-    visibility = ["//visibility:public"],
-)
diff --git a/ReleaseNotes/ReleaseNotes-2.0.10.txt b/ReleaseNotes/ReleaseNotes-2.0.10.txt
deleted file mode 100644
index 33078d9..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.10.txt
+++ /dev/null
@@ -1,62 +0,0 @@
-= Release notes for Gerrit 2.0.10
-
-Gerrit 2.0.10 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-
-== New Features
-
-* GERRIT-129  Make the browser window title reflect the current scre...
-+
-Useful usability enhancement when you have multiple tabs open.
-
-* GERRIT-132  Allow binary files to be downloaded from changes for l...
-+
-Useful if you need to view say a Microsoft Word document or a PDF.
-
-* GERRIT-130  Allow publishing comments on non-current patch sets
-+
-Now comments can still be published, even if the change owner has uploaded a replacement while you were creating drafts.
-
-* GERRIT-138  Show the author name in change submitted email notific...
-+
-Minor enhancement to the way submitted emails are formatted.
-
-== Bug Fixes
-
-* GERRIT-91   Delay updating the UI until a Screen instance is fully...
-+
-This is a huge UI improvement.  Gerrit now waits to display until the data is ready and the UI is updated.  Thus you won't see it show stale data, and then suddenly update to
-whatever you actually clicked on.
-
-* GERRIT-134  Allow users to preview how Gerrit will format an inlin...
-+
-Also a huge usability improvement.
-
-* Update SSHD to 1.0-r766258_M5
-+
-This version of MINA SSHD correctly supports SSH ControlMaster, a trick to reuse SSH connections, supported by repo.  See [http://jira.source.android.com/jira/browse/REPO-11 REPO-11].
-
-* GERRIT-122  Fix too wide SSH Keys table by clipping the server hos...
-* GERRIT-131  Fix comment editors on the last line of a file
-* GERRIT-135  Enable Save button after paste in a comment editor
-* GERRIT-137  Error out if a user forgets to squash when replacing a...
-
-== Other Changes
-* Start 2.0.10 development
-* Add missing super.onSign{In,Out} calls to ChangeScreen
-* Remove the now pointless sign in callback support
-* Change our site icon to be more git-like
-* Ensure blank space between subject line and body of co...
-* Create a debug mode only method of logging in to Gerrit
-* Refactor UI construction to be more consistent across ...
-* Do not permit GWT buttons to wrap text
-* Fix the sign in dialog to prevent line wrapping "Link ...
-* Change Patch.ChangeType.ADD to be past tense
-* Improve initial page load by embedding user account da...
-* Automatically expand inline comment editors for larger...
-* Merge change 9533
-* Upgrade MINA SSHD to SVN 761333 and mina-core to 2.0.0...
-* Use gwtexpui 1.0.4 final
-* gerrit 2.0.10
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.11.txt b/ReleaseNotes/ReleaseNotes-2.0.11.txt
deleted file mode 100644
index 5bd6ca0..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.11.txt
+++ /dev/null
@@ -1,122 +0,0 @@
-= Release notes for Gerrit 2.0.11
-
-Gerrit 2.0.11 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-*WARNING: This version contains a schema change.*
-
-Apply the schema upgrade:
-----
-  java -jar gerrit.war --cat sql/upgrade009_010.sql | psql reviewdb
-----
-
-== Important Notes
-
-=== Cache directory
-
-Gerrit now prefers having a temporary directory to store a disk-based content cache.  This cache used to be in the PostgreSQL database, and was the primary reason for the rather large size of the Gerrit schema.  In 2.0.11 the cache has been moved to the local filesystem, and now has automatic expiration management to prevent it from growing too large.  As this is only a cache, making backups of this directory is not required.
-
-It is suggested (but not required) that you enable this cache:
-----
-  mkdir $site_path/disk_cache
-  chown gerrituser $site_path/disk_cache
-  chmod 700 $site_path/disk_cache           ; # just to be paranoid
-----
-The directory can also be placed elsewhere in the local filesystem, see `cache.directory` in the `gerrit.config` file.
-
-link:http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html[http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html]
-
-=== Protocol change
-
-The protocol between the browser based JavaScript and the server has changed.  After installing 2.0.11 users need to load the site page again to ensure they are running 2.0.11 or later.  Users can verify they have the new version by checking the version number in the footer in the lower right.  Users who don't load the new version (e.g. are using a stale tab from a week ago) will see errors when trying to view patches.
-
-== New Features
-
-* GERRIT-8    Add 'Whole File' as a context preference in the user s...
-* GERRIT-9    Honor user's "Default Context" preference
-* GERRIT-14   Split patch view RPCs into two halves
-* GERRIT-61   Database error in side by side view
-* GERRIT-156  Rewrite the side-by-side and unified diff viewers
-+
-The side by side and unified patch viewers have been completely rewritten.  Gerrit now honors the user's Default Context setting (from My > Settings) in both the side by side and the unified patch view.  A new "Whole File" setting is also available, showing the complete file.
-
-* GERRIT-154  Add the branch name to the beginning of the subject li...
-* Sending mail when merge failed due to path conflict, m...
-+
-Some improvements have been made with regards to the emails sent by Gerrit.
-
-* Configure the JGit WindowCache from $site_path/gerrit....
-* Document the new gerrit.config file
-+
-Gerrit now supports a Git-style "$site_path/gerrit.config" configuration file.  Currently this supports configuration of the various memory caches, including control over JGit's pack file cache.  See the updated documentation section for more details:
-link:http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html[http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html]
-
-* Add "gerrit show-caches" to view cache statistics
-+
-There is a new administrative command over SSH called "gerrit show-caches" which displays current cache statistics for the various caches within the Gerrit memory space.
-
-* Expand local part emails when creating new changes
-+
-Simple DWIMery: users can now do `repo upload --reviewer=who` to have the reviewer email automatically expand according to the email_format column in system_config, e.g. by expanding `who` to `who@example.com`.
-
-== Bug Fixes
-
-* GERRIT-81   Can't repack a repository while Gerrit is running
-+
-Running "git repack", "git gc" or "git fetch" in a repository owned by Gerrit is now safe while Gerrit is running.
-
-* GERRIT-165  Don't create new user accounts as full name = "null nu...
-+
-New users coming from Google Accounts OpenID provider where given a full name of "null null" rather than "Anonymous Coward".
-
-* Honor account.preferred_email when checking co...
-+
-Service users created by manually inserting into the accounts table didn't permit using their preferred_email in commits or tags; administrators had to also insert a dummy record into the account_external_ids table.  The dummy account_external_ids record is no longer necessary.
-
-== Other Changes
-* Start 2.0.11 development
-* Include the 'Google Format' style we selected in our p...
-* Upgrade JGit to v0.4.0-310-g3da8761
-* Include JGit sources when building GWT code
-* Cleanup classpath and use source JARs to build JavaScr...
-* Remove the ImportGerrit1 command line utility
-* Remove EncryptContactInfo helper program
-* Add custom serialization for jgit.diff.Edit
-* Add Ehcache 1.6.0-beta5 to our dependency list
-* Start/stop Ehcache when GerritServer starts/stops
-* Cache OpenID discovery results inside of Ehcache
-* Cache JGit FileHeader and EditList inside of Ehcache
-* Store FileHeader and EditList in Ehache during patch s...
-* Remove the now dead patch_contents table from the data...
-* Fix "null null" user names during schema upgrade from ...
-* Work around asciidoc 8.2.2 not including our APLv2 lic...
-* Remove unused logger from SshServlet
-* Reuse is administrator test in admin SSH commands
-* Use common PrintWriter construction in command impleme...
-* Refactor gerrit flush-caches to just flush everything ...
-* GERRIT-166  Move the SSH key cache into Ehcache
-* Change the diff cache serialization of JGit ObjectId i...
-* Fix git_base_path documentation in config-gerrit
-* Clarify the default max_session_age in config-gerrit
-* Enhance the site_path entry in config-gerrit
-* Clarify the caching of static assets under $site_path/...
-* Minor grammar fixes in the Google Analytics documentat...
-* Document that replication honors StrictHostKeyChecking
-* Document how ~/.ssh/known_hosts is used during replica...
-* Document how ssh-agent cannot be used for replication
-* Fix git_base_path references in project-setup
-* Cleanup project setup documentation
-* Expand the config-contact documentation to describe th...
-* Clarify the gitweb integration documentation
-* Minor corrections in install documentation
-* Reformat the config-gerrit page to free up section hea...
-* Enable table of contents in documentation files
-* Add the source code version number to documentation
-* More reformatting of the config-gerrit page
-* Cleanup formatting references for file system path var...
-* Cleanup the documentation index
-* Kill the feature roadmap in the documentation
-* Only use the disk cache directory if we can write to it
-* Change the title of the installation guide
-* Note in the developer install guides that you need to ...
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.12.txt b/ReleaseNotes/ReleaseNotes-2.0.12.txt
deleted file mode 100644
index 0e1df04..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.12.txt
+++ /dev/null
@@ -1,133 +0,0 @@
-= Release notes for Gerrit 2.0.12
-
-Gerrit 2.0.12 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-*WARNING: This version contains a schema change.*
-
-Apply the schema upgrade:
-----
-  java -jar gerrit.war --cat sql/upgrade010_011.sql | psql reviewdb
-----
-
-== Important Notes
-
-=== Java 6 Required
-
-Gerrit now requires running within a Java 6 (or later) JVM.
-
-=== Protocol change
-
-The protocol between the browser based JavaScript and the server has changed.  After installing 2.0.12 users need to load the site page again to ensure they are running 2.0.12 or later.  Users can verify they have the new version by checking the version number in the footer in the lower right.  Users who don't load the new version (e.g. are using a stale tab from a week ago) will see errors when trying to view patches.
-
-== New Features
-* Honor --reviewer=not.preferred.email during upload
-* Also scan by preferred email for --reviewers and --cc ...
-+
-Better DWIMery for matching reviewers by name, email address, or just local name (e.g. "jdoe") if using HTTP authentication with email_format.
-
-* Add support for MySQL database
-+
-Now MySQL can be used as a backend data store.
-
-* Switch all current SSH commands to use args4j
-* Allow targeted cache flushes to only specific caches
-+
-SSH commands, especially administrative ones like "gerrit show-caches", "gerrit flush-caches", or "gerrit show-connections" now accept options like "-h"/"--help" to view command line options, and use a more typical option parsing semantics.
-
-* GERRIT-164  Bind our SSH daemon with SO_REUSEADDR
-* Honor sshd.tcpKeepAlive for TCP keep alive controls
-* Enable SSH daemon cipher and MAC configuration
-+
-The SSH daemon now binds with SO_REUSEADDR, making warm-restarts of the daemon easier, especially if the site is busy.  Additionally, gerrit.config gained some new options to further control the behavior of the internal SSHD.
-
-* Add admin command 'gerrit show-connections'
-+
-The new "gerrit show-connections" command reports who is connected, from what host, and what command(s) they are running on that SSH session.
-
-* Replace the top menu bar with a tab panel and links
-* GERRIT-27   Add a search box to quickly locate changes by change n...
-+
-The top menu bar area has been redesigned, and a search box has been added on the right, below the username and Settings links.  Currently the search box only accepts change numbers, but in the future we hope to support additional types of query strings.
-
-* Allow users to disable clippy the flash movie if they ...
-+
-A new per-account setting permits users to disable the clippy Flash movie that supports copying text to the clipboard.  In every context where this movie appears clicking on the text converts it to a text box, allowing a fast "click Ctrl-C" interaction to place the text on the clipboard.  Personally I've found that loading 3 Flash movies on a change page really slowed down the UI rendering, so I wanted to disable the Flash movies.
-
-* Allow users to control the number of results per page
-+
-A new per-account setting allows users to control how many rows appear per page in the All screens, like All Open Changes, etc.
-
-* Rewrite the keyboard event handlers to use new GlobalK...
-* GERRIT-136  Implement n/p keys to jump to next/previous diff chunk...
-* Add keyboard bindings n/p for all change lists to pagi...
-* Put the "Use '?' for keyboard help" ahead of the versi...
-* GERRIT-136  Use 'f' in a patch to browse the list of files in the ...
-* Add global jump navigation keys for the main menu
-+
-Keyboard bindings have been completely overhauled in this release, and should now work on every browser.  Press '?' in any context to see the available actions.  Please note that this help is context sensitive, so you will only see keys that make sense in the current context.  Actions in a user dashboard screen differ from actions in a patch (for example), but where possible the same key is used when the logical meaning is unchanged.
-
-== Bug Fixes
-* Ignore "SshException: Already closed" errors
-+
-Hides some non-errors from the log file.
-
-* GERRIT-86   Stop generating raw #target anchor tags
-+
-Should be a minor improvement for MSIE 6 users.
-
-== Other Changes
-* Start 2.0.12 development
-* Report what version we want on a schema version mismat...
-* Remove unused imports in SshServlet
-* Fix vararg warnings in GerritSshDaemon
-* Update Ehcache to 1.6.0-beta5
-* Update SSHD to 1.0-r773859
-* Start targeting Java 1.6
-* Switch Maven GWT plugin to org.codehaus.mojo:gwt-maven...
-* GERRIT-75   Upgrade to GWT 1.6.4
-* GERRIT-75   Switch to GWT 1.6's new HostedMode debugging utility
-* Allow become any account to use GET parameters
-* Switch to gwtexpui's new CSS linker module
-* Load the GWT theme before any other stylesheets
-* Switch from our own LazyTabChild to GWT 1.6's LazyPanel
-* GERRIT-75   Convert all GWT 1.5 listener uses to GWT 1.6 handlers
-* Stop bundling the PostgreSQL driver
-* Upgrade JGit to 0.4.0-372-gbd3c3db
-* Add args4j 2.0.12 as a dependency
-* Describe MySQL and H2 setup in jetty_gerrit.xml templa...
-* Actually deregister a command when it exits
-* Put the link to the review inside the body instead of ...
-* Fix change permalinks after breaking them during GWT 1...
-* Delete dead CSS bundle code
-* Always use NpTextBox or NpTextArea to prevent GlobalKe...
-* Detect cases where system_config has too many rows
-* Remove unnecessary warning suppressions
-* Remove dead code, these aren't used anymore
-* Fix warnings about potential serialization problems
-* Fix warning about debug code in OpenIdServiceImpl
-* Blur menu item hyperlinks on activation
-* Fix LinkMenuItem blur on older browsers
-* Remove dead LoginService, SignInResult classes
-* Remove pointless GWT.isClient calls in Gerrit module
-* Refactor how user preferences are applied to the UI
-* Move the watched project list to its own tab in settin...
-* Refactor account preferences model
-* Sort the RSA host key before the DSA host key
-* Clarify what the "known hosts entry" is
-* Cleanup the name of the search focus key registration
-* Change sign out handler to use GWT's HandlerManager su...
-* Fix all onLoad, onUnload methods to be protected acces...
-* Honor GWT 1.6's handleAsClick logic in DirectScreenLink
-* Switch all hyperlinks to be InlineHyperlink
-* Fix unused import in PatchScreen
-* Make n/p only honor comments on file adds/deletes
-* Switch to gwtjsonrpc's new Handler based status update...
-* Move the comment editor actions into their own keyboar...
-* Ensure the row pointer is visible before moving it
-* Automatically reposition/resize file browser if window...
-* Minor cleanup to Gerrit module bootstrap code path
-* Make escape in the search box abort the search
-* Switch to tagged gwtexpui, gwtjsonrpc, gwtorm
-* gerrit 2.0.12
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.13.txt b/ReleaseNotes/ReleaseNotes-2.0.13.txt
deleted file mode 100644
index 7589568..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.13.txt
+++ /dev/null
@@ -1,167 +0,0 @@
-= Release notes for Gerrit 2.0.13, 2.0.13.1
-
-Gerrit 2.0.13.1 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-*WARNING: This version contains a major configuration change.*
-
-The schema upgrade needs to run in multiple parts.  Apply the first half:
-----
-  java -jar gerrit.war --cat sql/upgrade011_012_part1.sql | psql reviewdb
-----
-
-Now convert the system_config table to `$site_path/gerrit.config`.
-----
-  java -jar gerrit.war ConvertSystemConfig
-----
-or, do this conversion by hand.  See below for the mapping.
-
-After verifying `$site_path/gerrit.config` is correct for your installation, drop the old columns from the system_config table.  *This causes configuration data loss.*
-----
-  java -jar gerrit.war --cat sql/upgrade011_012_part2.sql | psql reviewdb
-----
-
-== Configuration Mapping
-|| *system_config*                || *$site_path/gerrit.config*     ||
-|| max_session_age                || auth.maxSessionAge             ||
-|| canonical_url                  || gerrit.canonicalWebUrl         ||
-|| gitweb_url                     || gitweb.url                     ||
-|| git_base_path                  || gerrit.basePath                ||
-|| gerrit_git_name                || user.name                      ||
-|| gerrit_git_email               || user.email                     ||
-|| login_type                     || auth.type                      ||
-|| login_http_header              || auth.httpHeader                ||
-|| email_format                   || auth.emailFormat               ||
-|| allow_google_account_upgrade   || auth.allowGoogleAccountUpgrade ||
-|| use_contributor_agreements     || auth.contributorAgreements     ||
-|| sshd_port                      || sshd.listenAddress             ||
-|| use_repo_download              || repo.showDownloadCommand       ||
-|| git_daemon_url                 || gerrit.canonicalGitUrl         ||
-|| contact_store_url              || contactstore.url               ||
-|| contact_store_appsec           || contactstore.appsec            ||
-
-See also [http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html Gerrit2 Configuration].
-
-== New Features
-* GERRIT-180  Rewrite outgoing email to be more user friendly
-+
-A whole slew of feature improvements on outgoing email formatting was closed by this one (massive) rewrite of the outgoing email implementation.
-
-* GERRIT-187  Make n/p jump to last/first line if no more hunks are ...
-+
-When in a patch view (side by side or unified) new key bindings n/p jump to the previous or next hunk, which is very useful if you have context set to Whole File.
-
-* GERRIT-59   Add Next/Previous/Up links to the PatchScreen
-+
-Patch views now contain links to the next and previous file in the patch set, as well as back up to the change.  This has been a very long standing UI glitch that is finally resolved.
-
-* Add "gerrit show-queue" to display the work queue
-* GERRIT-110  Add admin command "gerrit replicate" to force resync a...
-* Document all server side command line tools
-+
-There are new admin commands available over SSH, and all commands are now documented online.  See [http://gerrit.googlecode.com/svn/documentation/2.0/cmd-index.html Command Line Tools].  The new `gerrit replicate` is very useful when a slave goes offline for a bit, and returns later.
-
-* Add remote.`<`name`>`.replicationdelay to control delay
-* GERRIT-110  Automatically replicate all projects at startup
-* GERRIT-110  Allow replication to match only some hosts
-* GERRIT-200  Schedule replication by remote, not by project
-+
-Replication has been made more robust by allowing the administrator to control the delay, as to isolate replication scheduling into different pools.  This is very useful when replicating to multiple sites, e.g. to a warm-spare in the same data center, and to a far away slave in another country.  Gerrit also now forces a full replication on startup, to ensure all slaves are consistent.
-
-* Move sshd_port to gerrit.config as sshd.listenaddress
-+
-The internal SSHD can now be bound to any IP address/port combinations, which can be useful if the system has multiple virtual IP addresses on a single network interface.
-
-* Switch from Java Mail to Apache Commons NET basic SMTP...
-* Block rcpt to addresses not on a whitelist
-+
-The new `sendemail` section of `$site_path/gerrit.config` now controls the configuration of the outgoing SMTP server, rather than relying upon a JNDI resource.  See [http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html configuration] section sendemail for more details.
-
-== Bug Fixes
-* Fix file browser in patch that is taller than the wind...
-* GERRIT-184  Make 'f' toggle the file browser popup closed
-* GERRIT-188  Fix key bindings in patch when changing the old or new...
-* GERRIT-211  Remove spurious whitespace from blank lines in diff vi...
-* GERRIT-196  Fix CSS styling on the history table
-* GERRIT-193  Automatically switch from empty side-by-side to unifie...
-+
-Misc. bug fixes on the patch view screens that I identified after the 2.0.12 release.
-
-* GERRIT-182  Don't NPE when the remote peer address isn't yet known
-* GERRIT-192  Fix NPE in MergeOp when submit to new branch fails due...
-* GERRIT-207  Fix StackOverflowError during cherry-pick submit
-+
-Misc. internal bugs, primarily caused by stupid programming mistakes.
-
-* Invalid sshkeys cache entries when the sshUserName cha...
-+
-If a user tried to connect with the wrong user name, then tried to change their SSH User Name through the web UI (by selecting a different preferred email address), the negative cache entry created during their first connection attempt was stuck in the cache and future connections were still rejected.  Gerrit now flushes both the old and the new user name cache entries when the user name changes.
-
-* GERRIT-210  Allow MINA SSHD to log about host key creation
-* Make SSH host key loading more consistent over time
-+
-It has been pointed out several times that its unclear why Gerrit keeps changing its host key with each startup; this is due to a failure to write the generated host key to disk.  We now log about it, and make it less likely that other sorts of configuration modifications would cause an unexpected host key change.
-
-* Always run SSH replication in BatchMode
-* Special case NoRemoteRepository during replication
-* Simplify error logged for invalid URLs in replication....
-* Special case UnknownHostKey during replication
-* Allow replication.config to drive the thread pool larg...
-* Fix treatment of symbolic refs in PushOp
-+
-A bunch of bug fixes related to error handling during replication.  Errors are now logged in a more clear fashion, which should help administrators to debug replication problems.
-
-* Restore Ctrl-Backspace in comment editor
-* Use server name for ssh_info instead of local address
-* Use server name for advertised SSH host keys
-* Don't reverse resolve CNAMEs when advertising our SSHD
-+
-Bug fixes identified after release of 2.0.13, rolled into 2.0.13.1.
-
-== Other Changes
-* Start 2.0.13 development
-* Use gwtexpui 1.1.1-SNAPSHOT
-* Document the Patch.PatchType and Patch.ChangeType enum
-* Document the Change.Status enum
-* Remove useless boolean return value from ChangeMail he...
-* Remove pointless null assignment from PatchScreen
-* Move ChangeMail into its own server side package
-* Fix patch set replacement emails to correctly retain r...
-* Document ReviewDb.nextChangeMessageId
-* Document some of the core database entity graph
-* Rewrite the replication documentation
-* Add an anchor for Other Servlet Containers
-* Fix minor formatting style nit in PushQueue
-* Extract the PushOp logic from PushQueue
-* Refactor PushQueue.scheduleUpdate to be smaller methods
-* Refactor WorkQueue to support task inspection
-* Reload the submit queue on startup with a 15 second de...
-* Move the per-command ReviewDb handle up to AbstractCom...
-* Don't attempt to replicate the magic "-- All Projects ...
-* Document that remote.<name>.uploadpack is also support...
-* Correct the defaults for remote uploadpack, receivepack
-* Use a HashSet for the active tasks, rather than a List
-* Use gwtorm 1.1.1-SNAPSHOT
-* Remove references in documentation to My>Settings
-* Mention 'git receive-pack' --cc/--reviewer args
-* Fix NPE in "gerrit replicate --all"
-* Put a link back to the index in every page footer
-* Document the other standard caches
-* Delete now unnecessary ImportProjectSubmitTypes
-* Don't start background queues during command line tools
-* Create GerritConfig after parsing gerrit.config file
-* Create a utility to export system_config to gerrit.con...
-* Move contact store configuration to gerrit.config
-* Move gerrit_git_email,gerrit_git_name to gerrit.config
-* Move authentication fields from system_config to gerri...
-* Move gitwebUrl to gerrit.config
-* Move use_repo_download to gerrit.config
-* Move canonical_url, git_daemon_url to gerrit.config
-* Move git_base_path to gerrit.config
-* Document where the nextval_project_id function is for ...
-* Use gwtorm, gwtexpui 1.1.1 final versions
-* Add sendemail.enable to disable email output
-* Use mvn -offline mode when running ./to_hosted.sh
-* Disable AES192CBC and AES256CBC if unlimited cryptogra...
-* gerrit 2.0.13
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.14.txt b/ReleaseNotes/ReleaseNotes-2.0.14.txt
deleted file mode 100644
index 128036d..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.14.txt
+++ /dev/null
@@ -1,112 +0,0 @@
-= Release notes for Gerrit 2.0.14, 2.0.14.1
-
-Gerrit 2.0.14.1 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-*WARNING: This version contains a schema change* (since 2.0.13)
-
-Apply the database specific schema script:
-----
-  java -jar gerrit.war --cat sql/upgrade012_013_postgres.sql | psql reviewdb
-  java -jar gerrit.war --cat sql/upgrade012_013_mysql.sql | mysql reviewdb
-----
-
-== New Features
-* GERRIT-177  Display branch name next to project in change list
-+
-Now its easier to see from your Mine>Changes what branch each change goes to.  For some users this may help prioritize reviews.
-
-* GERRIT-27   Add commit SHA-1 search to search panel
-+
-The search box in the top right now accepts full or abbreviated commit SHA-1s, in addition to change numbers.  This is a more user friendly way to locate a change, instead of hacking the URL with the legacy /r/commitsha1 reference.
-
-* Add "Ignore whitespace" to patch views
-+
-You can now ask for a difference ignoring leading/trailing whitespace, which may be useful in a review when a block of code is moved underneath an if, or moved out of an if.
-
-* Added a checkbox to switch between contextual/full fil...
-+
-You can now switch a side-by-side view to full context without going to Settings and returning back.
-
-* GERRIT-115  Automatically close changes when a commit is pushed in...
-* GERRIT-54   Close change if a replacement patch set is already sub...
-+
-These pair of changes basically mean that if you download and merge a commit locally, then push that directly into a branch (assuming you have been granted Push access), the change closes automatically.  Likewise, if a replacement patch set is uploaded to a change, but is already merged to a branch, the change closes automatically.  These close some loopholes where the branches and the changes weren't necessarily always in sync.
-
-* Add a micro scp daemon to our SSHD
-* Create gerrit-cherry-pick for client usage
-+
-Gerrit now runs a micro scp daemon as part of its SSHD, and that scp provides a read-only access of some utility functions for client computers.
-gerrit-cherry-pick is a small Bourne shell script end-users can scp onto their local system, then use to download and cherry-pick changes from Gerrit by change number.
-More tools are likely to be developed in the future.
-
-* Audit group member addition and removals
-* Add automaticMembership flag to account groups
-* GERRIT-17   Enable groups to manage contributor agreements
-+
-Group membership changes are now audited in the account_group_members_audit table, but the information is not currently published in the web UI.  This is a start in the direction of keeping track of "who had access to do what when".  In addition, if you use contributor agreements (like review.source.android.com does), CLA acknowledgement can now be done through group membership, rather than a per-user basis.
-
-* GERRIT-174  Record the submitter in the reflog during merge
-+
-This is really for the server admin, the Git reflogs are now more likely to contain actual user information in them, rather than generic "gerrit2@localhost" identities.  This may help if you are mining "WTF happened to this branch" data from Git directly.
-
-== Bug Fixes
-* GERRIT-213  Fix n/p on a file with only one edit
-* GERRIT-66   Always show comments in patch views, even if no edit e...
-* Correctly handle comments after last hunk of patch
-+
-Bug fixes for patch views (e.g. side by side and unified).  Always showing comments is a really nice plus, it helps during a review to ensure that reviewer comments were addressed, even if there was no edit made in that region of the file.
-
-* Don't allow commits to replace in wrong project
-+
-It was possible to upload a replacement commit in project Foo to a change created in project Bar, putting the Bar change into a corrupt and not-viewable state.  This is now correctly error-checked.
-
-* Update SSHD to 1.0-r784137
-* GERRIT-199  Update JGit to 0.4.0-388-gd3d9379
-* Update JGit to 0.4.0-398-ge866578
-+
-JGit suffered from some performance problems when the client was very far ahead of the server, e.g. fetching an Android msm kernel (which is based on an older Linux kernel) into a recent bleeding edge kernel repository took hours.  It now takes seconds.  SSHD was bumped to pick up MINA 2.0.0-M6 which fixes some minor bugs, and is likely to be the final 2.0.0 release version.
-
-* Fix double click on patch set SHA-1 to select only SHA...
-* GERRIT-190  Provide feedback when a reviewer is invalid
-* GERRIT-191  Show email address matched by completion rather than p...
-+
-Minor cosmetic improvements.
-
-* Fix multiple recipient To/CC headers in emails
-+
-Fixed run-on addresses when more than one user was listed in To/CC headers.
-
-== Other Changes
-* Start 2.0.14 development (again)
-* Small doc updates.
-* Merge change 10282
-* documentation: Use git config --file path
-* Skip the ssh:// download URL if the SSHD is unknown
-* Refactor submitter to PersonIdent mapping in MergeOp
-* Refactor MergeOp.getSubmitter to return the ChangeAppr...
-* Remove invalid usage of List.subList(int,int)
-* Convert command line programs to use args4j
-* Don't permit overlapping Edit instances in patch scrip...
-* Merge change 10347
-* Update executablewar to 1.2
-* Pass the PatchScriptSettings back as part of the Patch...
-* Move PatchScriptSettings to .data package
-* Use ValueChangedHandler for CheckBox update events in ...
-* Display post-image lines in side-by-side view when ign...
-* Use binary search when pulling lines from SparseFileCo...
-* Fix compile error in PatchFile
-* Don't try to auto-close changes on branch delete
-* Document the new gerrit-cherry-pick command
-* gerrit 2.0.14
-+
-
-* Start 2.0.15 development
-* GERRIT-221  Ensure RevCommit's body buffer is available when needed
-* Fix stack trace capture in Receive error path
-* Fix --reviewer during replace patch set
-* Document git receive-pack with Gerrit options
-* Add toString debugging aids to SparseFileContent
-* GERRIT-220  Fix bad diff display near empty comment caused edits
-* gerrit 2.0.14.1
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.15.txt b/ReleaseNotes/ReleaseNotes-2.0.15.txt
deleted file mode 100644
index a8d60a4..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.15.txt
+++ /dev/null
@@ -1,35 +0,0 @@
-= Release notes for Gerrit 2.0.15
-
-Gerrit 2.0.15 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Schema Change
-
-None.  For a change.  :-)
-
-== New Features
-* Allow other ignore whitespace settings beyond IGNORE_S...
-+
-Now you can ignore whitespace inside the middle of a line, in addition to on the ends.
-
-== Bug Fixes
-* Update SSHD to include SSHD-28 (deadlock on close) bug...
-+
-Fixes a major stability problem with the internal SSHD.  Without this patch the daemon can become unresponsive, requiring a complete JVM restart to recover the daemon.  The symptom is connections appear to work sporadically... some connections are fine while others freeze during setup, or during data transfer.
-
-* Fix line-wrapped To/CC email headers
-+
-Long To/CC headers with multiple recipients sometimes ran together, making Reply-to-all in the user's email client not address them correctly.  This was a bug in the header formatting code, it wasn't RFC 2822 compliant.
-
-* GERRIT-227  Fix server error when remaining hunks are comments
-* Fix binary search in SparseFileContent
-+
-Stupid bugs in the patch viewing code.  Random server errors and/or client UI crashes.
-
-== Other Changes
-* Restart 2.0.15 development
-* Update JGit to 0.4.0-411-g8076bdb
-* Remove dead isGerrit method from AbstractGitCommand
-* Update JSch to 0.1.41
-* gerrit 2.0.15
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.16.txt b/ReleaseNotes/ReleaseNotes-2.0.16.txt
deleted file mode 100644
index 4f5a5ba..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.16.txt
+++ /dev/null
@@ -1,80 +0,0 @@
-= Release notes for Gerrit 2.0.16
-
-Gerrit 2.0.16 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Schema Change
-
-*WARNING: This version contains a schema change* (since 2.0.14)
-
-Apply the database specific schema script:
-----
-  java -jar gerrit.war --cat sql/upgrade013_014_postgres.sql | psql reviewdb
-  java -jar gerrit.war --cat sql/upgrade013_014_mysql.sql | mysql reviewdb
-----
-
-== New Features
-* Search for changes created or reviewed by a user
-+
-The search box in the upper right corner now accepts "owner:email" and "reviewer:email", in addition to change numbers and commit SHA-1s.  Using owner: and reviewer: is not the most efficient query plan, as potentially the entire database is scanned.  We hope to improve on that as we move to a pure git based backend.
-
-* Make History panel settings in a diff screen sticky
-+
-When comparing different patch sets, e.g. patch set 3 against patch set 2, the settings are now sticky across files in the same change, reducing the number of clicks required to re-review an existing change.
-
-* GERRIT-113  Permit projects to require Signed-off-by lines to crea...
-+
-GERRIT-113 requested that project owners be able to enforce having a Signed-off-by line in the footer of a commit message.  Forks of the Linux kernel require this line in order to contribute back upstream.  If enabled in the project settings screen there must be a SOB line for the author, the committer, and the uploader of a change (though typically committer == uploader).
-
-* Use Tested-by: instead of Verified-by: during cherry-p...
-+
-The Verified-by footer line created during a cherry-picked submit is now called Tested-by.  This better matches with the upstream Linux kernel's conventions of what the role means.  Since the kernel is more widespread than Gerrit Code Review, I'm sticking with the kernel's conventions.
-
-* Extract reviewer suggestions from commit messages
-+
-If a commit message contains Reviewed-by, Tested-by or CC footer lines and those email addresses are registered in Gerrit, those users will receive notification of the new change.  This is an alternate method to supplying reviewer address on the command line.
-
-* Drop the unnecessary host page servlet name from URLs
-+
-The "/Gerrit" suffix is no longer necessary in the URL.  Gerrit now favors just "/" as its path location.  This drops one redirection during initial page loading, slightly improving page loading performance, and making all URLs 6 characters shorter.  :-)
-
-== Bug Fixes
-* Don't create reflogs for patch set refs
-+
-Previously Gerrit created pointless 1 record reflogs for each change ref under refs/changes/.  These waste an inode on the local filesystem and provide no metadata value, as the same information is also stored in the metadata database.  These reflogs are no longer created.
-
-* Fix "Error out if a user forgets to squash when replac...
-+
-Users were still able to find a way to make a change depend upon itself, which makes the change unsubmittable.  Often this was done by creating a merge commit, then committing on top of that, and uploading it as a replacement.  Gerrit failed to notice this condition because it only considered direct ancestors, now it also looks for indirect ancestors.
-
-* Fix syntax error in MySQL URL in jetty_gerrit.xml
-+
-Someone noticed that the MySQL URL was invalid XML, its fixed now.
-
-* Catch OpenID errors caused by clock skew and present t...
-+
-OpenID errors caused by clock skew (or other factors) now present as an error in the client user interface, and in the server log file, making it more obvious when an OpenID failure occurs.  New administrators trying to setup Gerrit installations have often run into problems here, due to bad error reporting.
-
-* GERRIT-232  Support HTTP connections tunneled through SSH
-+
-If the hostname is "localhost" or "127.0.0.1", such as might happen when a user tries to proxy through an SSH tunnel, we honor the hostname anyway if OpenID is not being used.
-
-== Other Changes
-* Start 2.0.16 development
-* Update JGit to 0.4.9-18-g393ad45
-* Name replication threads by their remote name
-* Exclude JGit's JSch version during build
-* Update ehcache to 1.6.0 release
-* Update JGit to 0.5.0
-* Update openid4java to 0.9.5 release
-* Remove --offline mode from to_hosted.sh
-* Save all project settings in one RPC
-* Don't tag Reviewed-by, Tested-by if already Signed-off...
-* Don't append duplicate Reviewed-on Gerrit URLs during ...
-* Don't append duplicate Verified-by or Tested-by lines
-* Use the List<FooterLine> to determine if a paragraph b...
-* Try harder to pretty-print an exception name in error ...
-* Fix minor whitespace issues in ErrorDialog
-* Document how to contribute to Gerrit Code Review
-* gerrit 2.0.16
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.17.txt b/ReleaseNotes/ReleaseNotes-2.0.17.txt
deleted file mode 100644
index 8a24b22..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.17.txt
+++ /dev/null
@@ -1,103 +0,0 @@
-= Release notes for Gerrit 2.0.17
-
-Gerrit 2.0.17 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Schema Change
-
-*WARNING: This version contains a schema change* (since 2.0.16)
-
-Apply the database specific schema script:
-----
-  java -jar gerrit.war --cat sql/upgrade014_015_part1_postgres.sql | psql reviewdb
-  java -jar gerrit.war --cat sql/upgrade014_015_part1_mysql.sql | mysql reviewdb
-----
-
-After the upgrade is successful, apply the final script to drop dead columns:
-----
-  java -jar gerrit.war --cat sql/upgrade014_015_part2.sql | psql reviewdb
-  java -jar gerrit.war --cat sql/upgrade014_015_part2.sql | mysql reviewdb
-----
-
-== New Features
-* Add '[' and ']' shortcuts to PatchScreen.
-+
-The keys '[' and ']' can be used to navigate to previous and next file in a patch set.
-
-* GERRIT-241  Always show History panel in PatchScreen
-+
-The History panel in a patch screen is now always shown, even if there is only one patch set for this file.  This permits viewing the number of comments more easily when navigating through files with ']'.
-
-* Add 'Reply' button to comments on diff screen
-+
-There is now a 'Reply' button on the last comment, making it easier to create a new comment to reply to a prior comment on the same line.  However, Gerrit still does not quote the prior comment when you reply to it.
-
-* GERRIT-228  Apply syntax highlighting when showing file content
-+
-Files are now syntax highlighted.  The following languages are supported, keyed from common file extensions:  C (and friends), Java, Python, Bash, SQL, HTML, XML, CSS, JavaScript, and Makefiles.
-
-* GERRIT-139  Allow mimetype.NAME.safe to enable viewing files
-+
-The new configuration option mimetype.NAME.safe can be set to enable unzipped download of a file, for example a Microsoft Word document.  See http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html for examples.
-
-* GERRIT-179  Display images inline for compare if mimetype.image/*....
-+
-If mimetype.image/TYPE.safe is true images can be viewed inline in order to more easily visually compare them when an image is modified.  Primarily useful for viewing icons in an icon library.
-
-* File review status tracking.
-+
-Per-user green check marks now appear when you view a file.  This makes it easier to keep track of which patch set you last looked at, and within a patch set, which files you have looked at, and which ones you have not.
-
-* GERRIT-247  Allow multiple groups to own a project
-+
-The owner of a project was moved from the General tab to the Access Rights tab, under a new category called Owner.  This permits multiple groups to be designated the Owner of the project (simply grant Owner status to each group).
-
-== Bug Fixes
-* Permit author Signed-off-by to be optional
-+
-If a project requires Signed-off-by tags to appear the author tag is now optional, only the committer/uploader must provide a Signed-off-by tag.
-
-* GERRIT-197  Move 'f' and 'u' navigation to PatchScreen
-+
-The 'f' and 'u' keystrokes in a patch screen were disabled when there were no differences to view.  This was fixed, they are now always available.
-
-* Remove annoying 'no differences' error dialog
-* GERRIT-248  Fix server crash when showing no difference
-+
-The "No Differences" error dialog has been removed.  Instead the "No Differences" message is displayed in the patch screen.  This makes navigation through a pair of patch sets easier with ']' (no dialog stopping to interrupt you when you encounter a file that has not changed and has no comments).
-
-* GERRIT-244  Always enable Save button on comment editors
-+
-Some WebKit based browsers (Apple Safari, Google Chrome) didn't always enable the Save button when selecting a word and deleting it from a comment editor.  This is a bug in the browser, it doesn't send an event to the Gerrit UI.  As a workaround the Save button is now just always enabled.
-
-* GERRIT-206  Permit showing changes to gitlinks (aka submodule poin...
-+
-You can now view a change made to a gitlink (aka a submodule path).
-
-* GERRIT-171  Don't crash the submit queue when a change is a criss-...
-+
-Instead of crashing on a criss-cross merge case, Gerrit unsubmits the change and attaches a message, like it does when it encounters a path conflict.
-
-== Other Changes
-* Start 2.0.17 development
-* Move '[' and ']' key bindings to Navigation category
-* Use gwtexpui 1.1.2-SNAPSHOT to fix navigation keys
-* A few Javadocs and toString() methods for Patch and Pa...
-* Merge change 10646
-* Include the mime-util library to guess file MIME types
-* Merge change 10667
-* Added missing access method for accountPatchReviews
-* Fix bad upgrade014_015 ALTER TABLE command
-* GERRIT-245  Update PatchBrowserPopup when reviewed status is modif...
-* Remove DiffCacheContent.isNoDifference
-* Fix upgrade014_015 part1 scripts WHERE clause
-* Don't allow users to amend commits made by Gerrit Code...
-* Fix bad formatting in UnifiedDiffTable appendImgTag
-* GERRIT-228  Add google-code-prettify 21-May-2009 version
-* GERRIT-228  Load Google prettify JavaScript into client
-* Fix formatting errors in PatchScreen
-* Remove unused imports
-* GERRIT-250  Fix syntax highlighting of multi-line comments
-* Use gwtexpui 1.1.2
-* gerrit 2.0.17
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.18.txt b/ReleaseNotes/ReleaseNotes-2.0.18.txt
deleted file mode 100644
index 1028185..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.18.txt
+++ /dev/null
@@ -1,310 +0,0 @@
-= Release notes for Gerrit 2.0.18
-
-Gerrit 2.0.18 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Important Notices
-
-Please ensure you read the following important notices about this release; .18 is a much larger release than usual.
-
-* OpenID Configuration
-+
-If you use OpenID authentication, the `trusted_external_ids`
-table has moved from the database to the local gerrit.config
-file.  Please ensure you copy any critical patterns to the
-`auth.trustedOpenID` setting in gerrit.config before upgrading
-your server.  Failure to set a pattern will allow Gerrit
-to trust any OpenID provider.  Refer to `auth.trustedOpenID` in
-[http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html Configuration] for more details.
-
-* Caches
-+
-The groups that a user is a member of is no longer stored in the
-`groups` cache; it is now part of the `accounts` cache.  If you
-use a cron script to update the `account_groups` database table
-based upon an external data source (such as LDAP), you will need
-to adjust your script to flush the `accounts` cache.
-The `diff` cache is no longer written to disk by default.
-To enable the disk store again, administrators must explicitly
-set `cache.directory` in the gerrit.config file prior to starting
-Gerrit.
-
-* SSH Usernames
-+
-SSH usernames are no longer automatically assigned to the
-local part of the user's email address.  With 2.0.18, usernames
-must also be unique within the database.  These changes were
-implemented to resolve a minor potential security issue with
-the SSH authentication system.  More details can be found in the
-[http://android.git.kernel.org/?p=tools/gerrit.git;a=commit;h=080b40f7bbe00ac5fc6f2b10a861b63ce63e8add commit message].
-
-== Schema Change
-
-*WARNING: This version contains a schema change* (since 2.0.17)
-
-Important notes about this schema change:
-
-* The schema change may be difficult to undo once applied.
-+
-Downgrading could be very difficult once the upgrade has been started.
-Going back to 2.0.17 may not be possible.
-
-* Do not run the schema change while the server is running.
-+
-This upgrade changes the primary keys of several tables, an operation
-which shouldn't occur while end-users are able to make modifications to
-the database.  I _strongly_ suggest a full shutdown, schema upgrade,
-then startup approach for this release.
-Apply the database specific schema script:
-----
-  java -jar gerrit.war --cat sql/upgrade015_016_part1_postgres.sql | psql reviewdb
-  java -jar gerrit.war --cat sql/upgrade015_016_part1_mysql.sql    | mysql reviewdb
-----
-
-After the upgrade is successful, apply the final script to drop dead tables:
-----
-  java -jar gerrit.war --cat sql/upgrade015_016_part2.sql | psql reviewdb
-  java -jar gerrit.war --cat sql/upgrade015_016_part2.sql | mysql reviewdb
-----
-
-== New Bugs
-* Memory leaks during warm restarts
-
-2.0.18 includes [http://code.google.com/p/google-guice/ Google Guice], which leaves a finalizer thread dangling when the Gerrit web application is halted by the servlet container.  As this thread does not terminate, the web context stays loaded in memory indefinitely, creating a memory leak.  Cold restarting the container in order to restart Gerrit is highly recommended.
-
-
-== New Features
-* GERRIT-104  Allow end-users to select their own SSH username
-+
-End users may now select their own SSH username through the web interface.  The username must be unique within a Gerrit server installation.  During upgrades from 2.0.17 duplicate users are resolved by giving the username to the user who most recently logged in under it; other users will need to login through the web interface and select a unique username.  This change was necessary to fix a very minor security bug (see above).
-
-* Display supported commands when subcommand is not prov...
-+
-Running `ssh -p 29418 gerrit.example.com gerrit` now lists the complete set of subcommands recognized by the gerrit top level command.  This (slightly) improves discoverability of the remote command execution facilities.
-
-* Add a Register link in the menu bar when not signed in
-+
-The Register link in the top right shows up on OpenID based sites when the user is not yet signed in.  This should help discoverability of signing into a Gerrit server to establish your account identity.
-
-* Combine all initial page data into a single object
-* Avoid XSRF initialization requests by using one token ...
-+
-An initial XSRF token is now sent as part of the initial HTTP request, and used for all subsequent RPCs from that browser.  This reduces the initial page load time by cutting out a few round trips that previously were used to bootstrap that XSRF token.
-
-* Redirect /Gerrit#foo to /#foo on the client side
-+
-Gerrit now favors "/#mine" rather than "/Gerrit#mine" for URLs.  Older style URLs will be redirected to the newer style automatically, for the foreseeable future.
-
-* Sort permissions in project access tab
-* Get branches directly from Git rather than database
-* Style tab panel headers like our menu bar header
-* Narrow tables that don't have to be 100% width
-* Cleanup display of external ids in the user settings
-+
-A few minor UI nits in the Settings and Admin panels.  The new UI is a bit more consistent with the theme, and formats data in a bit more sane way.  Nothing earth shattering.
-
-* Make disk cache completely optional
-+
-As noted above in the section about cache changes, the disk cache is now completely optional.
-
-== Bug Fixes
-* GERRIT-5    Remove PatchSetInfo from database and get it always fr...
-+
-A very, very old bug.  We no longer mirror the commit data into the SQL database, but instead pull it directly from Git when needed.  Removing duplicated data simplifies the data store model, something that is important as we shift from an SQL database to a Git backed database.
-
-* GERRIT-220  Fix infinite loop in PatchScriptBuilder
-+
-Under somewhat rare conditions web request threads locked up in an infinite loop while obtaining the data necessary to show a side-by-side or unified patch view to a browser.  The loop doesn't allocate any memory, or perform any database requests, but it still ties up a database connection and a servlet container request processing thread indefinitely.  We found the bug internally at Google when our Gerrit server load average spiked to 32... and we had no more connections in our database connection pool, which was also sized at a max of 32 handles.
-
-* Fix Reviewed-On lines to only include the server URL o...
-+
-The Reviewed-On lines in cherry-picked commits were duplicating the server URL.
-
-* Set outgoing email header Content-Transfer-Encoding: 8...
-+
-Emails are sent in UTF-8, which may have the high bit set.  Thus the transfer encoding must always be set as 8bit, to prevent gateways from potentially discarding the high bits and corrupting the UTF-8 message payload.
-
-* Ensure OpenID related responses aren't cached by proxi...
-+
-Some OpenID related login responses may have sent HTTP headers which were confusing to proxies, potentially allowing a proxy to cache something it should not have cached.  The headers were clarified to better denote no caching is permitted.
-
-* Move ChangeApproval to be a child of PatchSet
-+
-The database schema changed, adding `patch_set_id` to the approval object, and renaming the approval table to `patch_set_approvals`.  If you have external code writing to this table, uh, sorry, its broken with this release, you'll have to update that code first.  :-\
-
-== Other Changes
-
-This release is really massive because the internal code moved from some really ugly static data variables to doing almost everything through Guice injection.  Nothing user visible, but code cleanup that needed to occur before we started making additional changes to the system.
-
-* Start 2.0.18 development
-* Remove bad import of HostPageServlet
-* Upgrade GWT to 1.7.0
-* Update gwt-maven-plugin to 1.1 release
-* Remove dead gwt-maven repository
-* Stop including gwt-dev JARs in project classpath
-* Remove ConvertSystemConfig utility
-* Update SSHD to 1.0-r798139
-* Update JGit to 0.5.0-57-g4c5eb17
-* Replace our RepositoryCache with JGit's RepositoryCache
-* Make missing project descriptions an empty file
-* Remove unused imports.
-* Move all service implementations into server side code
-* Move RpcConstants out of Common class
-* Move the CurrentAccountImpl accessor to Gerrit onModul...
-* Move workflow function access to CategoryFunction class
-* Move ChangeDetail.load to strictly server side code
-* Move the workflow package to be strictly server side
-* Add Guice 2.0 to our dependencies
-* Switch web.xml to Guice based injection
-* Use Guice injection to pass GerritServer to HttpServle...
-* Use Guice to inject GerritServer into RPC backends
-* Move calls to Common.getSchemaFactory to GerritServer....
-* Create the EncyptedContactStore during servlet startup
-* Move OpenID implementation setup to Guice
-* Remove more Common.getSchemaFactory invocations to dir...
-* Pass GerritServer down through SSH command factory
-* Pass GerritServer instance down through the push queue
-* Use Guice to setup the FileTypeRegistery singleton
-* Delete unnecessary GerritCacheControlFilter
-* Remove pointless Srv subclasses of GerritJsonServlet
-* Refactor FileTypeRegsitery to be an interface
-* Let Guice inject the ContactStore implementation
-* Remove dependency on gwtexpui, gwtjsonrpc and gwtorm p...
-* Use Guice to bring up the SSH daemon and its configura...
-* Remove unnecessary GerritServer field in Receive comma...
-* Move PushQueue and ReplicationQueue to singletons mana...
-* Get rid of the GerritServer static singleton
-* Provide SchemFactory ReviewDb by Guice and not Gerrit...
-* Get the SystemConfig from Guice rather than GerritServ...
-* Merge change 10823
-* Inject the site path configuration setting directly
-* Use FileBasedConfig Config rather than RepositoryConfig
-* Correct copyright dates in SitePath support to be 2009
-* Load gerrit.config through Guice injection
-* Refactor outgoing email to be constructed by Guice
-* Move contact store configuration off GerritServer
-* Configure Eclipse projects to cleanup trailing whitesp...
-* Move PatchSetPublishDetail.load() to server side and i...
-* Hide GerritServer.getGerritConfig and use Guice outsid...
-* Use Guice to create the per-request GerritCall object
-* RegisterNewEmailSender is managed by Guice through Ass...
-* AddReviewerSender class is managed by Guice through As...
-* Merge change 10856
-* Merge change 10858
-* FilebasedConfig requires File pointing at config file ...
-* CreateChangeSender class is managed by Guice through A...
-* AbandonedSender is managed by Guice now.
-* Move RegisterNewEmailSender to servlet module
-* Move authentication bits out of GerritServer
-* Update Ehcache to 1.6.1
-* Move Ehcache construction out of GerritServer to Guice
-* CommentSender is managed by Guice now.
-* MergedSender class is managed by Guice now.
-* MergeFailSender is managed by Guice now.
-* Make ReplacePatchSetSender managed by Guice.
-* Refactor MergeOp to use assisted injection
-* Inject the canonicalweburl rather than using GerritSer...
-* Use JGit's cached hostname when URL can't give us the ...
-* Remove use of PatchSetInfoAccess interface in PatchDet...
-* Merge change 10839
-* Use member injection for OutgoingEmail related depende...
-* Fix CanonicalWebUrl when it is null
-* Inject the Provider GerritCall rather than looking it...
-* Use assisted injection to create the PushOp instances
-* Use PatchSetInfoFactory in OutgoingEmail class.
-* Simplify the setup of assisted injection factories
-* Inject the WorkQueue via Guice
-* Fix ProvisionException catch blocks in GerritServletCo...
-* Move system configuration related code to the server.c...
-* Move servlets related to UI RPCs into the server.rpc p...
-* Reduce CreateSchema dependencies to avoid cache
-* Start injectors in production mode
-* Isolate SSHD module from web module
-* Use ServletContext injection to load files from context
-* Refactor SSH commands into their own package
-* Support Guice request and session scopes in SSHD
-* Cleanup CommandFactory to be session aware
-* Refactor CurrentUser to always be request scoped
-* Cleanup names of SSH daemon related classes
-* Refactor command handling to support subcommands in Gu...
-* Refactor command thread creation logic into BaseCommand
-* Move command line parsing to BaseCommand
-* Avoid duplicate singletons
-* Don't inject fields in providers
-* Run Gerrit servlet container in PRODUCTION mode
-* Make database error reporting more predictable from th...
-* Fix duplicate definition of ReviewDb injection
-* Cleanup unused imports in client code
-* Remove unnecessary references to HttpServletRequest
-* Get HttpServletRequest via injection rather than JsonS...
-* Get the remote peer address via the @RemotePeer annota...
-* Move HTTP related classes to an HTTP specific package
-* Drop ServletName in favor a unique annotation object
-* Move all URL lookup to the CanonicalUrlProvider
-* Only load the OpenID servlets if we are using OpenID a...
-* Make the magic "Become" mode for development a normal ...
-* Present new users with a registion welcome screen
-* Fix SSH daemon in web mode to actually have commands
-* Explicitly bind RemoteJsonService implementations to t...
-* Use Anchor for become rather than location assignment
-* Move the become any account form to a real HTML file
-* Document the DEVELOPMENT_BECOME_ANY_ACCOUNT auth.type ...
-* Fix upgrade014_015_part1_mysql syntax errors
-* Merge change 10972
-* Inject most server references to GerritConfig
-* Cleanup CacheManagerProvider's construction of the con...
-* Make DiffCacheEntryFactory package private
-* Cache account ids by email address key in Ehcache
-* Rename ReviewDbProvider to ReviewDbDatabaseProvider
-* Perform per-request cleanup actions at the end of a re...
-* Refactor ChangeDetailService to use injected database ...
-* Move ChangeDetailService code to its own package
-* Refactor ChangeManageService to use the new Handler st...
-* Refactor SSH commands to use request scoped database h...
-* Fix docs in BaseCommand
-* Mark all of BaseServiceImplementation deprecated
-* Refactor SSH command permission checks to use CurrentU...
-* Move ProjectCache to server side and rewrite entire pe...
-* Make existing BaseServiceImplementation use per-reques...
-* Use Project.NameKey in admin panels rather than Projec...
-* Change project to use Project.NameKey as the primary k...
-* Fix sshdAddress in GerritConfig object sent to clients
-* Rename ProjectCache.invalidate to evict
-* Rename ChangeDetailModule to ChangeModule
-* Move account related RPCs to the account package
-* Move patch RPC stuff to the rpc.patch package
-* Remove unnecessary injected dependencies from CatServl...
-* Document why we abuse the GerritCall in HostPageServlet
-* Create a dummy account if the user account no longer e...
-* Construct the AgreementInfo only from server code
-* Merge change 11021
-* Convert GroupCache to be injected by Guice and stored ...
-* Remove Common.getAccountId and use Guice injection only
-* Remove Common.getSchemaFactory
-* Remove Common.getAccountCache
-* Consolidate account lookups to the AccountResolver
-* Rename GerritServerModule to GerritGlobalModule
-* Rename SshDaemonModule to SshModule
-* Update documentation on the named caches
-* Move trusted_external_ids to auth.trustedOpenID
-* Paper bag fix OutgoingEmail initialization
-* Paper bag fix submit action
-* Fix server error 'Array index out of range' on some pa...
-* Merge branch 'maint'
-* Move ChangeApproval to be a child of PatchSet
-* Make #register alone go to the registration form
-* Enable register new email after saving contact informa...
-* Bind ApprovalTypes without using GerritConfig
-* Remove Common class entirely
-* Catch missing BouncyCastle PGP during contact store cr...
-* Correct Owner project_rights min_values during upgrade
-* Unset use_contributor_agreements if agreements are dis...
-* Sort permissions in project access tab
-* Get branches directly from Git rather than database
-* Style tab panel headers like our menu bar header
-* Narrow tables that don't have to be 100% width
-* Cleanup display of external ids in the user settings
-* Preserve negative approvals when replacing patch sets
-* Use gwtjsonrpc 1.1.1
-* gerrit 2.0.18
diff --git a/ReleaseNotes/ReleaseNotes-2.0.19.txt b/ReleaseNotes/ReleaseNotes-2.0.19.txt
deleted file mode 100644
index c9d9c56..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.19.txt
+++ /dev/null
@@ -1,372 +0,0 @@
-= Release notes for Gerrit 2.0.19, 2.0.19.1, 2.0.19.2
-
-Gerrit 2.0.19.2 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Important Notices
-
-* Prior User Sessions
-+
-The cookie used to identify a signed-in user has been changed.  All users
-will be automatically signed-out during this upgrade, and will need to
-sign-in again after the upgrade is complete.
-Users who try to use a web session from before the upgrade may receive the
-obtuse error message "Invalid xsrfKey in request".  Prior web clients are
-misinterpreting the error from the server.  Users need to sign-out and
-sign-in again to pick up a new session.
-This change was necessary to close GERRIT-83, see below.
-
-* Preserving Sessions Across Restarts
-+
-Administrators who wish to preserve user sessions across server restarts must
-set [http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html#cache.directory cache.directory] in gerrit.config.  This allows Gerrit to flush the set
-of active sessions to disk during shutdown, and load them back during startup.
-
-== Schema Change
-
-*WARNING: This version contains a schema change* (since 2.0.18)
-
-Important notes about this schema change:
-
-* Do not run the schema change while the server is running.
-+
-This upgrade adds a new required column to the changes table, something
-which cannot be done while users are creating records. Like .18, I _strongly_
-suggest a full shutdown, schema upgrade, then startup approach.
-Apply the database specific schema script:
-----
-  java -jar gerrit.war --cat sql/upgrade016_017_postgres.sql | psql reviewdb
-  java -jar gerrit.war --cat sql/upgrade016_017_mysql.sql    | mysql reviewdb
-----
-
-
-== New Features
-* New ssh create-project command
-+
-Thanks to Ulrik Sjölin we now have `gerrit create-project`
-available over SSH, to construct a new repository and database
-record for a project.  Documentation has also been updated to
-reflect that the command is now available.
-
-* Be more liberal in accepting Signed-off-by lines
-+
-The "Require Signed-off-by line" feature in a project is now
-more liberal.  Gerrit now requires that the commit be signed off
-by either the author or the committer.  This was relaxed because
-kernel developers often cherry-pick in patches signed off by
-the author and by Linus Torvalds, but not by the committer who
-did the backport cherry-pick.
-
-* Allow cache.name.diskLimit = 0 to disable on disk cache
-+
-Setting cache.name.diskLimit to 0 will disable the disk for
-that cache, even though cache.directory was set.  This allows
-sites to set cache.diff.diskLimit to 0 to avoid caching the diff
-records on disk, but still allow caching web_sessions to disk,
-so that live sessions are maintained across server restarts.
-This is a change in behavior, the prior meaning of diskLimit =
-0 was "unlimited", which is not very sane given how Ehcache
-manages the on disk cache files.
-
-* Allow human-readable units in config.name.maxage
-+
-Timeouts for any cache.name.maxAge may now be specified in human
-readable units, such as "12 days" or "3 hours".  The server will
-automatically convert them to minutes during parsing.  If no
-unit is specified, minutes are assumed, to retain compatibility
-with prior releases.
-
-* Add native LDAP support to Gerrit
-+
-Gerrit now has native LDAP support.  Setting auth.type to
-HTTP_LDAP and then configuring the handful of ldap properties
-in gerrit.config will allow Gerrit to load group membership
-directly from the organization's LDAP server.  This replaces
-the need for the sync-groups script posted in the wiki.  See:
-link:http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html#ldap[http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html#ldap]
-If you use the sync-groups script from the wiki page, you would
-also need to delete the group members after upgrading, to remove
-unnecessary records in your database:
-{{{
-DELETE FROM account_group_members
-WHERE group_id IN (
-SELECT group_id FROM account_groups
-WHERE automatic_membership = 'Y');
-}}}
-
-* Don't allow users to edit their name if it comes from LDAP
-+
-User information loaded from LDAP, such as full name or SSH
-username, cannot be modified by the end-user.  This allows the
-Gerrit site administrator to require that users conform to the
-standard information published by the organization's directory
-service.  Updates in LDAP are automatically reflected in Gerrit
-the next time the user signs-in.
-
-* Remembers anchor during HTTP logins
-+
-When using an HTTP SSO product, clicking on a Gerrit link received
-out-of-band (e.g. by email or IM) often required clicking the
-link twice.  On the first click Gerrit redirect you to the
-organization's single-sign-on authentication system, which upon
-success redirected to your dashboard.  The actual target of the
-link was often lost, so a second click was required.
-With .19 and later, if the administrator changes the frontend web
-server to perform authentication only for the /login/ subdirectory
-of Gerrit, this can be avoided.  For example with Apache:
-----
-     <Location "/login/">
-       AuthType Basic
-       AuthName "Gerrit Code Review"
-       Require valid-user
-       ...
-     </Location>
-----
-   During a request for an arbitrary URL, such as '/#change,42',
-   Gerrit realizes the user is not logged in.  Instead of sending an
-   immediate redirect for authentication, Gerrit sends JavaScript
-   to save the target token (the part after the '#' in the URL)
-   by redirecting the user to '/login/change,42'.  This enters
-   the secured area, and performs the authentication.  When the
-   authenticated user returns to '/login/change,42' Gerrit sends
-   a redirect back to the original URL, '/#change,42'.
-
-
-* Create check_schema_version during schema creation
-+
-Schema upgrades for PostgreSQL now validate that the current
-schema version matches the expected schema version at the start
-of the upgrade script.  If the schema does not match, the script
-aborts, although it will spew many errors.
-
-* Reject disconnected ancestries when creating changes
-+
-Uploading commits to a project now requires that the new commits
-share a common ancestry with the existing commits of that project.
-This catches and prevents problems caused by a user making a typo
-in the project name, and inadvertently selecting the wrong project.
-
-* Change-Id tags in commit messages to associate commits
-+
-Gerrit now looks for 'Change-Id: I....' in the footer area of a
-commit message and uses this to identify a change record within
-the project.
-If the listed Change-Id has not been seen before, a new change
-record is created.  If the Change-Id is already known, Gerrit
-updates the change with the new commit.  This simplifies updating
-multiple changes at once, such as might happen when rebasing an
-entire series of commits that are still being reviewed.
-A commit-msg hook can be installed to automatically generate
-these Change-Id lines during initial commit:
-{{{
-scp -P 29418 review.example.com:hooks/commit-msg .git/hooks/
-}}}
-Using this hook ensures that the Change-Id is predicatable once
-the commit is uploaded for review.
-For more details, please see the docs:
-link:http://gerrit.googlecode.com/svn/documentation/2.0/user-changeid.html[http://gerrit.googlecode.com/svn/documentation/2.0/user-changeid.html]
-
-== Bug Fixes
-* Fix yet another ArrayIndexOutOfBounds during side-by-s...
-+
-We found yet another bug with the side-by-side view failing
-under certain conditions.  I think this is the last bug.
-
-* Apply URL decoding to parameter of /cat/
-* Fix old image when shown inline in unified diff
-+
-Images weren't displaying correctly, even though
-mimetype.image/png.safe was true in gerrit.config.
-Turned out to be a problem with the parameter decoding of the
-/cat/ servlet, as well as the link being generated wrong.
-
-* Fix high memory usage seen in `gerrit show-caches`
-+
-In Gerrit 2.0.18 JGit had a bug where the repository wasn't being
-reused in memory.  This meant that we were constantly reloading
-the repository data in from disk, so the server was always maxed
-out at core.packedGitLimit and core.packedGitOpenFiles, as no
-data was being reused from the cache.  Fixed in this release.
-
-* Fix display of timeouts in `gerrit show-caches`
-+
-Timeouts were not always shown correctly, sometimes 12 hours
-was showing up as 2.5 days, which is completely wrong.  Fixed.
-
-* GERRIT-261  Fix reply button when comment is on the last line
-+
-The "Reply" button didn't work if the comment was on the last
-line of the file, the browser caught an array index out of
-bounds exception as we walked off the end of the table looking
-for where to insert the new editor box.
-
-* GERRIT-83   Make sign-out really invalidate the user's session
-+
-The sign-out link now does more than delete the cookie from the
-user's browser, it also removes the token from the server side.
-By removing it from the server, we prevent replay attacks where
-an attacker has observed the user's cookie and then later tries
-to issue their own requests with the user's cookie.  Note that
-this sort of attack is difficult if SSL is used, as the attacker
-would have a much more difficult time of sniffing the user's
-cookie while it was still live.
-
-* Evict account record after changing SSH username
-+
-Changing the SSH username on the web immediately affected the
-SSH daemon, but the web still showed the old username.  This
-was due to the change operation not flushing the cache that
-the web code was displaying from.  Fixed.
-
-* Really don't allow commits to replace in wrong project
-+
-It was possible for users to upload replacement commits to the
-wrong project, e.g. uploading a replacement commit to project
-B while picking a change number from project A.  Fixed.
-
-== =Fixes in 2.0.19.1=
-
-* Fix NPE during direct push to branch closing a change
-+
-Closing changes by pushing their commits directly into the branch didn't
-always work as expected, due to some data not being initialized correctly.
-
-* Ignore harmless "Pipe closed" in scp command
-+
-scp command on the server side threw exceptions when a client aborted the
-data transfer.  We typically don't care to log such cases.
-
-* Refactor user lookup during permission checking
-* GERRIT-264  Fix membership in Registered Users group
-+
-Users were not a member of "Registered Users", this was a rather serious
-bug in the code as it meant many users lost their access rights.
-
-* GERRIT-265  Correctly catch "Invalid xsrfKey in request" error as ...
-+
-Above I mentioned we should handle this error as "Not Signed In", only
-the pattern match wasn't quite right.  Fixed.
-
-* GERRIT-263  Fix --re=bob to match bob@example.com when using HTTP_LDAP
-+
-HTTP_LDAP broke using local usernames to match an account.  Fixed.
-
-== =Fixes in 2.0.19.2=
-* Don't line wrap project or group names in admin panels
-+
-Line wrapping group names like "All Users" when the description column
-has a very long name in it is ugly.
-
-* GERRIT-267  Don't add users to a change review if they cannot access
-+
-If a user cannot access a change, let the owner know when they try to
-add the user as a reviewer, or CC them on it.
-
-* commit-msg: Do not insert Change-Id if the message is ...
-+
-The commit-msg hook didn't allow users to abort accidental git commit
-invocations, as it still modified the file, making git commit think
-that the end-user wanted to make a commit.  Anyone who has a copy of
-the hook should upgrade to the new hook, if possible.
-
-* Support recursive queries against LDAP directories
-* Fix parsing of LDAP search scope properties
-+
-As reported on repo-discuss, recursive search is sometimes necessary,
-and is now the default.
-
-== Removed Features
-
-* Remove support for /user/email style URLs
-+
-I decided to remove this URL, its a pain to support and not
-discoverable.  Its unlikely anyone is really using it, but if
-they are, they could try using "#q,owner:email,n,z" instead.
-
-== Other Changes
-
-* Start 2.0.19 development
-* Document the Failure and UnloggedFailure classes in Ba...
-* Merge change 11109
-* Document gerrit receive-pack is alias for git receive-...
-* Define a simple query language for Gerrit
-* Create new projects on remote systems with mkdir -p
-* Set the GIT_DIR/description file during gerrit create-...
-* Remove unnecessary toLowerCase calls in AdminCreatePro...
-* Remove unnecessary exception from AdminCreateProject
-* Remove unused import from AccountExternalId
-* Abstract out account creation and simplify sign-on for...
-* Implement server side sign-out handling
-* Cleanup private keys in system_config table
-* Remove dead max_session_age field from system_config
-* Report 'Invalid xsrfKey' as 'Not Signed In'
-* Update gerrit flush-caches documentation about web_ses...
-* Update documentation on cache "web_sessions" configura...
-* Add getSchemeRest to AccountExternalId
-* Cleanup ContactStore and WebModule injection
-* Catch Bouncy Castle Crypto not installed when loading ...
-* Declare caches in Guice rather than hardcoded in Cache...
-* Remove old commented out cache configuration code
-* Don't NPE in SSH keys panel when SSHD is bound to loca...
-* Don't send users to #register,register,mine
-* Document the new LDAP support
-* Cleanup section anchors to be more useful
-* Put anchors on every configuration variable section
-* Add missing AOSP copyright header to WebSession
-* Fix short header lines in gerrit-config.txt
-* Update documentation about system_config private key f...
-* Fetch groups from LDAP during user authentication
-* Actually honor cache.ldap_groups.maxage
-* Add enum parsing support to ConfigUtil
-* Rename LoginType to AuthType
-* Support loading the sshUserName from LDAP
-* Change ldap.accountDisplayName to ldap.accountFullName
-* Fix parsing set-to-nothing options in ldap section
-* Report more friendly errors from gwtjsonrpc
-* Ensure dialog box displays correctly on network failure
-* Document how setting LDAP properties disables web UI
-* Ensure the commit body is parsed before getting the co...
-* Cleanup more section anchors
-* Make documentation table of contents anchors human rea...
-* Remove notes about HTML 5 offline support
-* Fix typo in LegacyGerritServlet javadoc
-* Use subList in server side change query code
-* Remove unsupported /all_unclaimed
-* Rewrite UrlRewriteFilter in terms of Guice bindings
-* Create a commit-msg hook to generate Change-Id tags
-* Add change_key to changes table in database
-* Allow searching for changes by Change-Id strings
-* Display the change key, aka Change-ID in the informati...
-* Display abbreviated change ids in change lists
-* Change javax.security AccountNotFoundException to NoSu...
-* Automatically update existing changes during refs/for/...
-* Automatically close changes when pushing into a branch...
-* Document the new commit-msg hook supplied by Gerrit
-* Correct title of "Command Line Tools" documentation pa...
-* Correct URL example used in Google Analytics Integrati...
-* Correct comment about customizing categories and caches
-* Fix formatting of remote.name.timeout section in docum...
-* Add anchors for remote settings in replication.config ...
-* Widen the search panel now that Change-Ids are 41 char...
-* Revert "Ensure dialog box displays correctly on networ...
-* Allow searches for Change-Ids starting with lowercase ...
-* Fix line wrapped formatting in ChangeListServiceImpl
-* Move Change.Key abbreviation to Change.Key class
-* Format change ids in listing tables with a fixed with ...
-* Cleanup documentation of the commit-msg hook
-* Cleanup the command line tool index page
-* Correct stale documentation section about SSH authenti...
-* Correct access control documentation about project own...
-* Quote the current directory when running asciidoc
-* Move the Default Workflow link into the top of the Use...
-* Correct formatting of usage in gerrit-cherry-pick docu...
-* Document how Gerrit uses Change-Id lines
-* Add Change-Id lines during cherry-pick if not already ...
-* Fix "no common ancestry" bug
-* Fix commit-msg hook to handle first lines like "foo: f...
-* Add a link to Gerrit's project to the top of gerrit-ch...
-* Add full ASLv2 copyright notice to commit-msg hook
-* Embed Gerrit's version number into shell scripts copie...
-* Don't drop max_session_age column in transaction durin...
-* gerrit 2.0.19
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.2.txt b/ReleaseNotes/ReleaseNotes-2.0.2.txt
deleted file mode 100644
index eb8546c..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.2.txt
+++ /dev/null
@@ -1,67 +0,0 @@
-= Release notes for Gerrit 2.0.2
-
-Gerrit 2.0.2 is now available for download:
-
-link:https://www.gerritcodereview.com/[https://www.gerritcodereview.com/]
-
-== Important Notes
-
-Starting with this version, Gerrit is now packaged as a single WAR file.
-Just download and drop into your webapps directory for easier deployment.
-The WAR file itself is also executable via "java -jar gerrit.war", so tools
-like CreateSchema are easier to invoke ("java -jar gerrit.war
-CreateSchema").
-
-The following optional 3rd party JARs are not included in the WAR:
-
-* Bouncy Castle Crypto API
-* H2 JDBC Driver
-* c3p0 pooled DataSource
-+
-Existing Gerrit administrators either need to change the SSH host key used
-by their servers, or download the Bouncy Castle Crypto API.  The OpenSSH key
-file format can only be read if Bouncy Castle is available, so you need to
-install that library to continue using an existing host key.  If you are
-using Jetty, you can download the library (
-http://www.bouncycastle.org/java.html) to $JETTY_HOME/lib/plus, then restart
-Jetty.
-If you use H2 as your database, you will need to download the JDBC driver
-and insert it into your container's CLASSPATH.  But I think all known
-instances are on PostgreSQL, so this is probably not a concern to anyone.
-
-== New Features
-
-* Trailing whitespace is highlighted in diff views
-* SSHD upgraded with "faster connection" patch discussed on list
-* Git reflogs now contain the Gerrit account information of who did the push
-* Insanely long change subjects are now clipped at 80 characters
-
-== All Changes
-
-* Switch back to -SNAPSHOT builds
-* Overhaul our build system to only create a WAR file
-* Rename top level directory devutil to gerrit1_import
-* Move appjar contents up one level to normalize our struc...
-* Refactor the project admin screen into tabs
-* Move "Publish Comments" before "Submit Patch Set"
-* Fix to_jetty.sh to account for the WAR not having a scri...
-* Don't close SSH command streams as MINA SSHD does it for...
-* Avoid NPE if sign-in goes bad and is missing a token
-* Describe how to make /ssh_info unprotected for repo
-* Improve documentation links to Apache SSHD
-* Fix Documentation Makefile to correctly handle new files
-* Insert some line breaks to make Documentation/install.tx...
-* Don't require Bouncy Castle Crypto
-* Don't require c3p0 or H2 drivers
-* Show the account id in the user settings screen
-* Fix log4j.properties to not run in DEBUG
-* Don't log DEBUG data out of c3p0's SqlUtils class
-* Fix to_jetty so it doesn't unpack c3p0 from our WAR
-* Cleanup c3p0 connection pools if used
-* Yank the mobile specific OpenID login panel
-* GERRIT-23  Highlight common whitespace errors such as whitespace on...
-* Fix tabs in Gerrit.css to be 2 spaces
-* Record the account identity in all reflogs
-* Don't allow the project name in change tables to wrap
-* Clip all change subject lines at 80 columns in change ta...
-* gerrit 2.0.2
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.20.txt b/ReleaseNotes/ReleaseNotes-2.0.20.txt
deleted file mode 100644
index 4f15bb0..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.20.txt
+++ /dev/null
@@ -1,82 +0,0 @@
-= Release notes for Gerrit 2.0.20
-
-Gerrit 2.0.20 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Schema Change
-
-A prior bug (GERRIT-262) permitted some invalid data to enter into some databases.  Administrators should consider running the following update statement as part of their upgrade to .20 to make any comments which were created with this bug visible:
-----
-  UPDATE patch_comments SET line_nbr = 1 WHERE line_nbr < 1;
-----
-Unfortunately the correct position of the comment has been lost, and the statement above will simply position them on the first line of the file.  Fortunately the lost comments were only on the wrong side of an insertion or deletion, and are generally rare.  (On my servers only 0.33% of the comments were created like this.)
-
-== New Features
-* New ssh command approve
-+
-Patch sets can now be approved remotely via SSH.  For more
-details on this new feature please see the user documentation:
-link:http://gerrit.googlecode.com/svn/documentation/2.0/cmd-approve.html[http://gerrit.googlecode.com/svn/documentation/2.0/cmd-approve.html]
-
-* Support changing Google Account identity strings
-+
-For various reasons, including but not being limited to server
-host name changes, the Google Accounts OpenID provider service
-may change the identity string it returns to users.  By setting
-auth.allowGoogleAccountUpgrade = true in the configuration file
-administrators may permit automatically updating an existing
-account with a new identity by matching on the email address.
-
-== Bug Fixes
-* GERRIT-262  Disallow creating comments on line 0
-+
-Users were able to create comments in dead regions of a file.
-That is, if a region was deleted, and thus the left hand side
-showed red deletion of lines, and the right hand side showed a
-grey background of nothing, users were able to place a comment on
-the right hand side in the nothing area.  Since this line did not
-actually exist, the comment was positioned on line 0 of the file.
-Because line 0 does not exist (lines are numbered 1..n), these
-comments become hidden and could not be seen, but showed up in
-the "X comments" counter seen on the Patch History or in the
-listing of files in a patch set.
-The UI and RPC layer was fixed to prevent comments on line 0,
-but existing comments need to be manually moved to a real line.
-See above for the suggested SQL UPDATE command.
-
-* Make ID column same font size as rest of table
-+
-The font size of the ID column was too small, it is now the
-same size as the other columns in the table.
-
-* Fix ALTER INDEX in upgrade015_016_part1_mysql
-* GERRIT-269  Fix bad change_key creation in upgrade016_017_mysql
-+
-MySQL schema upgrade scripts had a few bugs, fixed.
-
-== Other Changes
-* Restart 2.0.20
-* Update MINA SSHD to 0.2.0 release
-* Update args4j to snapshot built from current CVS
-* Cleanup newCmdLineParser method in BaseCommand
-* Remove unnecessary throws IOException in ApproveCommand
-* Cleanup formatting in ApproveCommand
-* Cleanup assumption of Branch.NameKey parent is Project...
-* Fix deprecated constructor warning in PatchSetIdHandler
-* Don't log command line caused failures in flush-caches
-* Use Guice to create custom arg4j OptionHandler instanc...
-* gerrit approve: Allow --code-review=+2
-* gerrit approve: Cleanup invalid patch set error handli...
-* gerrit approve: Cleanup error reporting for missing ob...
-* Parse project names through custom args4j OptionHandler
-* git receive-pack: Use args4j to parse --reviewer and -...
-* Move args4j handlers to their own package
-* gerrit approve: Cleanup option parsing to reduce unnec...
-* gerrit approve: accept commit SHA-1s for approval too
-* gerrit approve: Allow approving multiple commits at on...
-* gerrit approve: Add user documentation
-* Remove unused imports from PatchSetDetailServiceImpl
-* Only enable auth.allowGoogleAccountUpgrade when auth.t...
-* Rename loginType to authType
-* gerrit 2.0.20
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.21.txt b/ReleaseNotes/ReleaseNotes-2.0.21.txt
deleted file mode 100644
index 5de84ff..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.21.txt
+++ /dev/null
@@ -1,337 +0,0 @@
-= Release notes for Gerrit 2.0.21
-
-Gerrit 2.0.21 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-
-== Schema Change
-
-*WARNING: This version contains a schema change* (since 2.0.19)
-
-* The schema change may be difficult to undo once applied.
-+
-Downgrading could be very difficult once the upgrade has been
-started.  Going back to 2.0.20 may not be possible.
-
-* Do not run the schema change while the server is running.
-+
-This upgrade changes the primary key of a table, an operation
-which shouldn't occur while end-users are able to make
-modifications to the database.  I _strongly_ suggest a full
-shutdown, schema upgrade, then startup approach for this release.
-
-* There may be some duplicate keys
-+
-This upgrade removes a column from the primary key of a table,
-which may result in duplicates being found.  You can search
-for these duplicates before updating:
-{{{
-SELECT account_id,external_id FROM account_external_ids e
-WHERE e.external_id IN (SELECT external_id
-FROM account_external_ids
-GROUP BY external_id
-HAVING COUNT(*) > 1);
-}}}
-Resolving duplicates is left up to the administrator, in
-general though you will probably want to remove one of the
-duplicate records.  E.g. in one case I had 3 users with the
-same mailing list email address registered.  I just deleted
-those and sent private email asking the users to use their
-personal/work address instead of a mailing list.
-Apply the database specific schema script:
-----
-  java -jar gerrit.war --cat sql/upgrade017_018_postgres.sql | psql reviewdb
-  java -jar gerrit.war --cat sql/upgrade017_018_mysql.sql    | mysql reviewdb
-----
-
-
-== Important Notices
-
-* Prior User Sessions
-+
-The cookie used to identify a signed-in user has been changed.
-Again.  All users will be automatically signed-out during
-this upgrade, and will need to sign-in again after the upgrade
-is complete.  The new schema has more room for extensions, so
-this might be the last time we will need to invalidate sessions.
-
-* Harmless error on first startup
-+
-Starting 2.0.21 on an instance which previously had the diff
-cache stored on disk will result in the following non-fatal error
-in the server logs during the first launch of .21 on that system:
-----
-2009-09-02 18:50:07,446::INFO : com.google.gerrit.server.cache.CachePool  - Enabling disk cache /home/gerrit2/android_codereview/disk_cache
-Sep 2, 2009 6:50:07 PM net.sf.ehcache.store.DiskStore readIndex
-SEVERE: Class loading problem reading index. Creating new index. Initial cause was com.google.gerrit.server.patch.DiffCacheKey
-java.lang.ClassNotFoundException: com.google.gerrit.server.patch.DiffCacheKey
-    at java.net.URLClassLoader$1.run(URLClassLoader.java:200)
-    at java.security.AccessController.doPrivileged(Native Method)
-    at java.net.URLClassLoader.findClass(URLClassLoader.java:188)
-...
-----
-    This error can be safely ignored.  It is caused by a change
-    in the diff cache's on disk schema, invalidating all existing
-    cache entries.
-
-* Significantly larger "diff" cache
-+
-The diff cache schema change noted above changed the element
-stored in the cache from per-file to per-patchset.  That is,
-a patch set which modifies 500 files will now occupy only 1
-element in the diff cache, rather than 500 distinct elements.
-Accordingly, the default `cache.diff.memoryLimit` setting has
-been reduced to 128.
-
-* Removed configuration settings
-+
-The following configuration settings are no longer honored:
-`cache.maxAge`, `cache.memoryLimit`, `cache.diskLimit`, and
-`cache.diskBuffer`.  These settings may now only be set on a
-per-cache basis (e.g. `cache.diff.maxAge`).
-
-* Connection pool recommendation: Apache Commons DBCP
-+
-All of the servers I run now use Apache Commons DBCP instead
-of c3p0 for their connection pools, and the setup guide and
-sample jetty_gerrit.xml reference DBCP now.
-We've run into problems with c3p0 under high loads, or when
-the connection pool is completely exhausted.  DBCP seems to
-fail more gracefully, and seems to give us less trouble.
-Changing pool implementations is not required, c3p0 is still
-a supported provider.  I just want to make it clear that I no
-longer recommend it in production.
-
-== New Features
-
-* GERRIT-189  Show approval status in account dashboards
-+
-Account dashboards now show a summary of the approval status on
-each change.  Unreviewed changes are now highlighted to bring
-the reviewer's attention to them.  Tooltips when hovering over
-a cell will bring up slightly more detailed information.
-
-* GERRIT-276  Allow users to see what groups they are members of
-+
-Under Settings > Groups a user can now view what groups Gerrit
-has placed them into.  This may help administrators to debug
-a user's access problems, as they can ask the user to verify
-Gerrit is seeing what they expect.
-
-* GERRIT-276  Show simple properties of an LDAP group
-+
-If auth.type is HTTP_LDAP, groups which are marked as automatic
-membership now show non-repeating LDAP attributes below their
-description under Admin > Groups.  This display should help an
-administrator to verify that Gerrit has mapped an LDAP group
-correctly.
-
-* Move Patch entity out of database and store in cache
-+
-The `patches` database table has been deleted, Gerrit now makes
-the list of affected files on the fly and stores it within the
-diff cache.  This change is part of a long-running series to
-remove redundant information from the database before we switch
-to a pure Git backed data storage system.
-
-* Only copy blocking negative votes to replacement patch
-+
-Previously Gerrit copied any negative vote in any approval
-category whenever a replacement patch set was uploaded to
-a change.  Now Gerrit only copies "Code Review -2".
-This change should make it easier for reviewers (and scripts
-scanning `patch_set_approvals`) to identify updated changes
-which might require a new review.
-Adminstrators who have created their own categories and want to
-copy the blocking negative vote should set `copy_min_score = 'Y'`
-in the corresponding approval_categories records.
-
-* show-caches: Make output more concise
-+
-Instead of showing ~12 lines of output per cache, each cache is
-displayed as one line of a table.
-
-* Handle multiple accountBase and groupBase
-+
-ldap.accountBase and ldap.groupBase may now be specified multiple
-times in gerrit.config, to search more than one subtree within
-the directory.
-
-* Summarize collapsed comments
-+
-Collapsed comments (both inline on a file and on the change
-itself) now show a short summary of the comment message, making
-it faster to locate the relevant comment to expand for more
-detailed reading.
-
-* Edit inline drafts on Publish Comments screen
-+
-Inline comment drafts may now be directly edited on the Publish
-Comments screen, which can be useful for fixing up a minor typo
-prior to publication.
-
-* Less toggly thingies on change screen
-+
-The change description and the approvals are no longer nested
-inside of a foldy block.  Most users never collapse these, but
-instead just scroll the page to locate the information they are
-looking for.
-
-* Restore Enter/o to toggle collapse state of comments
-+
-Enter and 'o' now expand or collapse an inline comment on the
-the current row of a file.
-
-* Display abbreviated hexy Change-Id in screen titles
-* Use hexy Change-Id in emails sent from Gerrit
-+
-Change-Id abbreviations are now used through more of the UI,
-including emails sent by Gerrit and window/page titles.  This
-change breaks email threading for any existing review emails.
-That is comments on a change created before the upgrade will
-not appear under the original change notification thread.
-
-* Add sendemail.from to control setting From header
-+
-Gerrit no longer forges the From header in notification emails.
-To enable the prior forging behavior, set `sendemail.from`
-to `USER` in gerrit.config.  For more details see
-link:http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html#sendemail.from[sendemail.from]
-
-== Bug Fixes
-
-* Fix ReviewDb to actually be per-request scoped
-+
-When we switched to Guice a misconfiguration allowed Guice to
-give out multiple database connections per web or SSH request.
-This could exhaust the connection pool faster than expected.
-
-* Send no-cache headers during HTTP login
-+
-An oversight in the HTTP login code path may have allowed a proxy
-server between the user's browser and the Gerrit server to cache
-a user's session cookie.  Fixed by sending the correct no-cache
-headers, disallowing any caching of the authentication response.
-
-* Fix project owner permissions
-+
-Folks reported on repo-discuss that a project owner also had to
-have READ permission to use the Branches tab of their project.
-This was a regression introduced when we refactored some of the
-code when adding Guice to the project.  Fixed.
-
-* GERRIT-277  Fix hyperlinks in messages
-+
-Hyperlinks in commit messages such as "<http://foo>" were
-including the trailing > in the URL, making the link broken.
-The trailing > is now properly not included in the URL.
-
-* GERRIT-266  Fix web session cookie refresh time
-+
-In 2.0.19 we introduced web sessions stored in Ehcache, but the
-logic was causing sessions to expire roughly half-way through the
-`cache.web_sessions.maxAge` time.  At the default setting, active
-sessions were expiring after 6 hours.  The cache management has
-been refactored to make this a lot less likely.
-
-* Cleanup not signed in error to be more user friendly
-+
-The error message which comes up when your session is expired
-is now much more useful.  From the dialog you can restart your
-session by clicking the "Sign-In" button, and return to the
-screen you are currently on.
-
-* Fix commit-msg hook to work with commit -v option
-+
-The commit-msg hook was buggy and did not handle `git commit -v`
-correctly.  It also did some bad insertions, placing the magic
-`Change-Id: I...` line at the wrong position in the commit
-message.  The updated hook resolves most of these problems,
-but must be recopied to individual Git repositories by end-users.
-
-* Identify PGP configuration errors during startup
-+
-If the encrypted contact store is enabled, the required encryption
-algorithms are checked at startup to ensure they are enabled
-in the underlying JVM.  This is necessary in case the JVM is
-updated and the administrator forgot to install the unlimited
-strength policy file in the new runtime directory.  Recently
-review.source.android.com was bitten by just such an upgrade.
-
-* GERRIT-278  Fix missing reply comments on old patch set
-+
-Some comments were not visible because they were replies made
-to a comment on say patch set 1 while looking at the difference
-between patch set 1 and patch set 2 of a change.  Fixed.
-
-* Make external_id primary key of account_external_ids
-+
-The database schema incorrectly allowed two user accounts to have
-the same email address, or to have the same OpenID auth token.
-Fixed by asserting a unique constraint on the column.
-
-== Other Changes
-* Start 2.0.21 development
-* Support cleaning up a Commons DBCP connection pool
-* Clarify which Factory we are importing in ApproveComma...
-* Avoid loading Patch object in /cat/ servlet
-* Remove unnecessary reference of patch key in save draft
-* GERRIT-266  Tweak cache defaults to be more reasonable
-* Merge change I131e6c4c
-* Bring back the "No Differences" message when files are...
-* Pick up gwtorm 1.1.2-SNAPSHOT
-* Refactor GroupListScreen's inner table for reuse
-* Do not normalize approval scores on closed changes in ...
-* Don't obtain 0 approvals or submit approvals in dashbo...
-* Update JGit to 0.5.0-93-g5b89a2c
-* Add tests for Change-Id generating commit-msg hook
-* Add test for commit-msg with commit -v
-* Fix formatting error in ApprovalCategory
-* Fix typo in change table column header "Last Update"
-* Fix reference to the All Projects broken when we remov...
-* Use category abbreviations in the dashboard approval c...
-* Format approvals columns in change tables with minimal...
-* Shrink the Last Updated column in dashboards and chang...
-* Highlight changes which need to be reviewed by this us...
-* Fix typo in ChangeTable comment
-* Reduce the window used for "Mon dd" vs. "Mon dd yyyy" ...
-* Don't assume "Anonymous Users" and "Registered Users" ...
-* Log encrypted contact store failures
-* Identify PGP configuration errors during startup
-* Take the change description block out of the disclosure...
-* Move the approval table out of a disclosure panel
-* Explicitly show what value is needed to submit
-* Modernize the display of comments on a change
-* Modernize the display of inline comments on a file
-* Fix "Publish Comments" when there are no inline drafts
-* Merge change 11666
-* Fix display of "Gerrit Code Review" authored comments
-* Fix source code formatting error in FormatUtil
-* Remove unnecessary fake author on inline comments
-* Auto expand all drafts on publish comments screen
-* Remove unused local variable in PublishCommentsScreen
-* Remove unused import from PublishCommentsScreen
-* Use gwtorm, gwtexpui release versions
-* Add javadoc for Change.getKey
-* Updated documentation for eclipse development.
-* Merge change 11698
-* Merge change 11699
-* Merge change 11700
-* Merge change 11703
-* Merge change 11705
-* Moved creation of GerritPersonIdent to a separate provi...
-* Remove unused dependency on GerritServer.
-* Renamed GerritServert to GitRepositoryManager and moved...
-* Remove declaration of OrmException that is never thrown.
-* Increase margin space between buttons of comment editors
-* Simplify GerritCallback error handling
-* Correct comment documenting SignInDialog
-* Remove unused CSS class gerrit-ErrorDialog-ErrorMessage
-* Clarify become any account servlet errors
-* Fix anchor in sshd.reuseAddress documentation
-* Extract parametrized string formatting out of LdapQuery
-* Make cache APIs interfaces for mocking
-* Add easymock 2.5.1 to our test dependencies
-* Add sendemail.from to control setting From header
-* gerrit 2.0.21
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.22.txt b/ReleaseNotes/ReleaseNotes-2.0.22.txt
deleted file mode 100644
index 5e2f8b5..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.22.txt
+++ /dev/null
@@ -1,155 +0,0 @@
-= Release notes for Gerrit 2.0.22
-
-Gerrit 2.0.22 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Schema Change
-
-There is no schema change in this release.
-
-* Restriction on SSH Username
-+
-There is a new restriction placed on the SSH Username field
-within an account.  Users who are using invalid names should
-be asked to change their name to something more suitable.
-Administrators can identify these users with the following query:
-----
-     -- PostgreSQL
-     SELECT account_id,preferred_email,ssh_user_name
-     FROM accounts
-     WHERE NOT (ssh_user_name ~ '[a-zA-Z][a-zA-Z0-9._-]*[a-zA-Z0-9]$');
-
-     -- MySQL
-     SELECT account_id,preferred_email,ssh_user_name
-     FROM accounts
-     WHERE NOT (ssh_user_name REGEXP '[a-zA-Z][a-zA-Z0-9._-]*[a-zA-Z0-9]$');
-----
-   Administrators can force these users to select a new name by
-   setting ssh_user_name to NULL; the user will not be able to
-   login over SSH until they return and select a new name.
-
-
-== New Features
-* GERRIT-280  create-project: Add --branch and cleanup arguments
-+
-The --branch option to create-project can be used to setup the
-default initial branch to be a name other than 'master'.
-Argument parsing also changed slightly, especially around the
-boolean options and submit type.  Please recheck the documentation
-and/or the output of --help.
-
-* GERRIT-216  Add slave mode to ssh daemon
-+
-The standalone SSH daemon can now be run in a read-only
-mode.  This allows use Gerrit's access control database for
-access decisions when serving a read-only copy of the project
-repositories.  Placing a read-only slave local to a remote office
-may reduce sync times for those closer to the slave server.
-
-* Enable multi-line comment highlighting for Scala code
-+
-Scala source code now highlights more like Java source code does,
-especially for multiline `/** ... */` style comments.
-
-* GERRIT-271  Enable forcing ldap.accountSshUserName to lowercase
-+
-The following properties may now be configured from LDAP using
-more complex expressions: accountFullName, accountEmailAddress,
-accountSshUserName.  Property expressions permit forcing
-to a lowercase string, or performing string concatenation.
-These features may help some environments to better integrate
-with their local LDAP server.
-
-* Support username/password authentication by LDAP
-+
-A new auth.type of LDAP was added to support Gerrit prompting
-the end-user for their username and password, and then doing a
-simple bind against the LDAP server to authenticate the user.
-This can simplify installation in environments which lack a
-web based single-sign-on solution, but which already have a
-centralized LDAP directory for user management.
-
-* Inform submitter of merge failure by dialog box
-+
-When a change submit fails, a dialog box is now displayed showing
-the merge failure message.  This saves the user from needing to
-scroll down to the end of the change page to determine if their
-submit was successful, or not.
-
-* Better submit error messages
-+
-Missing dependency submit errors are now much more descriptive
-of the problem, helping the user to troubleshoot the issue on
-their own.  Merge errors from projects using the cherry-pick
-and fast-forward submit types are also more descriptive of the
-real cause.  Unfortunately path conflict errors are not any more
-descriptive, but path conflict is now only reported when there
-is actually a path conflict.
-
-* issue 285   Include pull command line in email notifications
-+
-Sample git pull lines are now included in email notifications.
-
-== Bug Fixes
-* create-project: Document needing to double quote descr...
-+
-The --description flag to create-project require two levels
-of quoting if the new description string contains whitespace.
-The documentation has been updated to reflect that, and shows some
-examples .  Unfortunately this is not easily fixed in software,
-due to the way the SSH client passes the command line to the
-remote server.
-
-* GERRIT-281  daemon: Remove unnecessary requirement of HttpServletR...
-+
-The standalone SSH daemon now starts correctly, without needing
-to put the Java servlet API into the CLASSPATH.
-
-* Enforce Account.sshUserName to match expression
-* Restrict typeable characters in SSH username
-* Disallow ., `_` and - in end of SSH Username
-+
-SSH usernames were permitted to contain any character, including
-oddball characters like '\0' and '/'.  We really want them to
-be a restricted subset which won't cause errors when we try to
-map SSH usernames as file names in a Git repository as we try
-to move away from an SQL database.
-
-* GERRIT-282  Fix reply to comment on left side
-+
-Clicking 'Reply' to a comment on the left hand side sometimes
-generated a server error due to a subtle bug in how the reply
-was being setup.  Fixed.
-
-* issue 282   Fix NullPointerException if ldap.password is missing
-+
-The server NPE'd when trying to open an LDAP connection if
-ldap.username was set, but ldap.password was missing.  We now
-assume an unset ldap.password is the same as an empty password.
-
-* issue 284   Make cursor pointer when hovering over OpenID links
-+
-The cursor was wrong in the OpenID sign-in dialog.  Fixed.
-
-* Use abbreviated Change-Id in merge messages
-+
-Merge commits created by Gerrit were still using the older style
-integer change number; changed to use the abbreviated Change-Id.
-
-== Other Changes
-* Start 2.0.22 development
-* Configure Maven to build with UTF-8 encoding
-* Document minimum build requirement for Mac OS X
-* Merge change 10296
-* Remove trailing whitespace.
-* Update issue tracking link in documentation
-* Merge branch 'doc-update'
-* Move client.openid to auth.openid
-* Fix minor errors in install documentation.
-* Merge change 11961
-* Cleanup merge op to better handle cherry-pick, error c...
-* GERRIT-67   Wait for dependencies to submit before claiming merge ...
-* Move abandonChange to ChangeManageService
-* Remove trailing whitespace in install.txt
-* Gerrit 2.0.22
diff --git a/ReleaseNotes/ReleaseNotes-2.0.23.txt b/ReleaseNotes/ReleaseNotes-2.0.23.txt
deleted file mode 100644
index a3f28a7..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.23.txt
+++ /dev/null
@@ -1,44 +0,0 @@
-= Release notes for Gerrit 2.0.23
-
-Gerrit 2.0.23 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Schema Change
-
-There is no schema change in this release.
-
-
-== New Features
-
-* Adding support to list merged and abandoned changes
-+
-The project link in a change now lists all changes merged in that
-project, or abandoned in that project, based upon the state of the
-change the link is displayed in.  So open changes link to all open
-changes in the same project while merged changes link to all merged
-changes in the same project.  These links are bookmarkable.
-
-== Bug Fixes
-
-* Fix new change email to always have SSH pull URL
-* Move git pull URL to bottom of email notifications
-+
-The new change emails were missing the SSH pull URL, fixed.  Also
-the SSH pull URL is now further away from the web URL, to make it
-less likely one is clicked by accident in an email client.
-
-* issue 286    Fix Not Signed In errors when multiple tabs are open
-+
-Users with multiple tabs open were getting session errors due to
-the tabs not agreeing about the session state.  Fixed.
-
-* Fix MySQL CREATE USER example in install documentation
-
-== Other Changes
-* Start 2.0.23 development
-* Move Jetty 6.x resources into a jetty6 directory
-* Move the Jetty 6.x start script to our extra directory
-* Add scripts for Jetty 7.x and make that the default i...
-* Merge change I574b992d
-* Gerrit 2.0.23
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.24.txt b/ReleaseNotes/ReleaseNotes-2.0.24.txt
deleted file mode 100644
index 7da1693..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.24.txt
+++ /dev/null
@@ -1,191 +0,0 @@
-= Release notes for Gerrit 2.0.24, 2.0.24.1, 2.0.24.2
-
-Gerrit 2.0.24 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-
-== Schema Change
-
-*WARNING: This version contains a schema change* (since 2.0.21)
-
-Apply the database specific schema script:
-----
-  java -jar gerrit.war --cat sql/upgrade018_019_postgres.sql | psql reviewdb
-  java -jar gerrit.war --cat sql/upgrade018_019_mysql.sql    | mysql reviewdb
-----
-
-
-== LDAP Change
-
-LDAP groups are now bound via their full distinguished name, and not
-by their common name.  Sites using LDAP groups will need to have the
-site administrator visit every LDAP backed group through the web UI
-(Admin > Groups), search for, and select the underlying LDAP group
-from the directory server.
-
-This change was made to remove some of the guesswork when it comes
-to setting up an LDAP enabled group, as well as to permit creating
-new LDAP enabled groups completely from the web UI.  It also removes
-an ambiguous case when different parts of the same directory space
-create identically named groups.
-
-
-== New Features
-* Check if the user has permission to upload changes
-+
-The new READ +2 permission is required to upload a change to a
-project, while READ +1 permits read but denies uploading a change.
-The schema upgrade script automatically converts READ +1 to +2.
-
-* Use LDAP DN to match LDAP group to Gerrit group
-* issue 297    Allow admins to search for and bind to LDAP groups
-+
-As noted above, LDAP groups now use the full DN to match to their
-Gerrit database counterpart, rather than just the common name.
-Administrators may now create Gerrit groups and attach them to
-any LDAP group, by performing a query on the LDAP directory for
-matching groups and selecting a result.
-
-* issue 301    Try to prevent forgotten `git add` during replace
-+
-Users are now stopped from performing a replace of a patch set if
-they have not made a meaningful change (modify a file, or modify
-the commit message).  If only the commit message was modified,
-a warning is printed, but the replace still occurs.
-
-* issue 126    Link to our issue tracker in the page footer
-+
-The footer now includes a link to the Gerrit project's issue
-tracker, so end-users can more easily report bugs or feature
-requests back to the developers.
-
-* issue 300    Support SMTP over SSL/TLS
-+
-Encrypted SMTP is now supported natively within Gerrit, see
-link:http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html#sendemail.smtpEncryption[sendemail.smtpEncryption]
-
-== Bug Fixes
-* issue 290    Fix invalid drop index in upgrade017_018_mysql
-+
-Minor syntax error in SQL script.
-
-* Fixed ActiveDirectory LDAP group support. Allows recu...
-* issue 307    Set proper LDAP defaults for Active Directory
-+
-ActiveDirectory is now better supported out of the box.  Defaults
-for the LDAP configuration settings are automatically guessed at
-startup based upon the type of server configured in ldap.server.
-Recursive groups (group which is a member of a group) is also
-now supported when using an ActiveDirectory server.  Other LDAP
-servers (e.g. OpenLDAP) probably don't support this.
-
-* "250-AUTH " will be returned if 'AUTH' response does ...
-* Fix: Authentication fail when authTypes is empty
-* Fix a typo that broke the gerrit build
-+
-Outgoing SMTP sometimes failed to authenticate against a
-SMTP server due to slightly incorrect handling of the AUTH
-advertisement.
-
-* Correct scp commands in documentation to include -p
-+
-Our documentation of how to copy the commit-msg hook down via
-scp did not include the -p option, which is necessary to make
-the client preserve the executable flag on the hook script.
-
-* issue 291    Suggest latin1 charset for MySQL databases
-+
-Documentation was updated to encourage using latin1 for MySQL
-as MySQL fails with key too long errors during schema creation
-when the database is using the UTF-8 character set.
-
-* issue 294    Fix OpenID self registration dialog
-+
-OpenID 'Register' hyperlink was broken due to the dialog having
-no content added to it before display.  This bug was fixed by
-using the proper OpenID login dialog.
-
-* issue 309    Clear message on publish comments screen after submit...
-+
-The publish comments button preserved your last comment, making
-it easy for a user to accidentally publish the same message on
-the same change twice.  The message is now cleared after it has
-been successfully sent.
-
-* issue 299    Remove the branches table from the database
-* Display current branch SHA-1 in Branches tab
-* issue 299    Display not-yet-born HEAD branch in Branches tab
-+
-The not-yet-born branch in an empty project is now shown in the
-Branches tab.  (This is based on the value of the HEAD symbolic
-reference within the project's Git repository.)
-The branches table was removed from the database.  We now fully
-rely upon the Git repository to determine which branches exist
-and thus permit changes to be uploaded to.
-
-* issue 296    Make help more friendly over SSH
-+
-`ssh -p 29418 localhost help` is now more user friendly.
-
-* Don't request registration if the account exists
-* issue 38     Fix OpenID delegate authentication
-+
-OpenID authentication was sometimes asking providers for
-registation data when we already had it on hand, fixed.
-OpenID delegate identities were being stored rather than claimed
-identities when the claimed identity is just a delegate to the
-delegate provider.  We now store both in the account.
-
-== Fixes in 2.0.24.1
-* Fix unused import in OpenIdServiceImpl
-* dev-readme: Fix formatting of initdb command
-+
-Minor documentation/code fixes with no impact on execution.
-
-* Fix LDAP account lookup when user not in group
-+
-Fixes a NullPointerException when a user is not in any group
-and the underlying LDAP server is ActiveDirectory.
-
-* issue 315    Correct sendemail.smtppass
-+
-Fixes sendemail configuration to use the documented smtppass
-variable and not the undocumented smtpuserpass variable.
-
-== Fixes in 2.0.24.2
-* Fix CreateSchema to create Administrators group
-* Fix CreateSchema to set type of Registered Users group
-* Default AccountGroup instances to type INTERNAL
-* Document the various AccountGroup.Type states better
-+
-CreateSchema was broken in 2.0.24 and 2.0.24.1 due to the default
-groups being misconfigured during insertion.  Fixed.
-
-* Grant anonymous uses READ +1, registered users READ +...
-+
-Default permissions were a bit confusing, there is no point in an
-anonymous user having READ +2.
-
-* Use the H2 database for unit tests
-* Unit test for SystemConfigProvider and CreateSchema
-+
-Added unit tests to validate CreateSchema works properly, so we
-don't have a repeat of breakage here.
-
-== Other Changes
-* Start 2.0.24 development
-* Merge change Ie16b8ca2
-* Switch to the new org.eclipse.jgit package
-* Allow default of $JETTY_HOME in to_jetty.sh
-* LdapRealm: Remove unused throws declaration
-* LdapRealm: Fix missing type parameter warnings
-* Remove dead exists method from AccountManager
-* Document ldap.groupPattern
-* AuthSMTPClient: Fix formatting errors
-* style fixup: remote trailing whitespace from our sour...
-* show-caches: Correct example output in documentation
-* Move server programs section under User Guide
-* Revert "Remove dead exists method from AccountManager"
-* Ensure prior commit body is parsed before comparing m...
-* Gerrit 2.0.24
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.3.txt b/ReleaseNotes/ReleaseNotes-2.0.3.txt
deleted file mode 100644
index d319b35..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.3.txt
+++ /dev/null
@@ -1,64 +0,0 @@
-= Release notes for Gerrit 2.0.3
-
-Gerrit 2.0.3 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-I would like to express a big thank you to Brad Larson for diving into
-Gerrit and coming up with the implementation for  "Add reviewer to an
-existing change".  This has been an open issue in the bug tracker for a
-while, and its finally closed thanks to his work.
-
-== New Features
-
-* GERRIT-37  Add additional reviewers to an existing change
-* Display old and new image line numbers in unified diff
-* Make 'c', 'r' in a patch view open a new comment editor
-* Allow up/down arrow keys to scroll the page in patch view
-* Use a Java applet to help users load public SSH keys
-
-== Bug Fixes
-
-* GERRIT-72  Make review comments standout more from the surrounding text
-* GERRIT-7   Restart the merge queue when Gerrit starts up
-* Fix message threading for comment replies
-* Fix unified diff view to support creating a comment
-* Fix line numbers for new post-image comments in unified diff
-* Don't store SSH keys we know to be invalid
-* Bust out of an iframe if Gerrit is embedded in one
-+
-The last item is a security fix.  It prevents Gerrit from being loaded
-inside of an iframe, which is a potential security flaw if some evil outer
-page used CSS tricks to show only a portion of a particular part of the
-Gerrit UI.  Such a display might be able to convince a user they are
-clicking on one thing, while doing something else entirely.
-
-== Other Changes
-
-* Restore -SNAPSHOT suffix after 2.0.2
-* Add a document describing Gerrit's high level design
-* Rename the gerrit artifact to be gerrit-$version.war
-* Ensure our SSHD always disables compression
-* Make the magic refs/heads/ constant available in GWT
-* Move Account to PersonIdent code to ChangeUtil for reuse
-* GERRIT-20  Add a Branches tab to the project admin screen
-* Add a link to our project homepage in the documentation
-* Add documentation on all of the software licenses
-* Add a link to the Android Open Source Project workflow a...
-* Fix a minor language typo in project setup documentation
-* Abstract the account name hint into AddMemberBox
-* Fix vertical-align: center to be middle
-* Fixed some minor documentation typos
-* Actually return failure to clients if new change creatio...
-* Log any failures while creating patch set refs
-* Update approvals table when adding reviewer.
-* Include "a=commit" in direct gitweb commit links
-* Add a loading message, link to the project, before the U...
-* Fix detach assertion error caused by loading messing bei...
-* Allow callers of AddMemberBox to control the button text
-* Cleanup the ApprovalTable formatting for adding a review...
-* Display the text "invalid key" instead of a red X icon i...
-* Add a clear button to make it easier to replace the key
-* Make Gerrit.getVersion public for other code to use
-* Allow embedded applets to be cached indefinitely by prox...
-* gerrit 2.0.3
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.4.txt b/ReleaseNotes/ReleaseNotes-2.0.4.txt
deleted file mode 100644
index 0b10756..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.4.txt
+++ /dev/null
@@ -1,48 +0,0 @@
-= Release notes for Gerrit 2.0.4
-
-Gerrit 2.0.4 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-*WARNING: This version contains a schema change.*
-
-Simple version of the schema upgrade:
-
-  java -jar gerrit.war --cat sql/upgrade004_005_part1.sql | psql reviewdb
-
-If you aren't collecting the contact information fields on individual
-user accounts (the accounts columns contact_address, contact_country,
-contact_phone_nbr, contact_fax_nbr) then you can safely apply both the
-part1 and part2 upgrades without further thought.
-
-  java -jar gerrit.war --cat sql/upgrade004_005_part2.sql | psql reviewdb
-
-After this upgrade, the contact fields under My > Settings > Contact
-Information will be hidden.
-
-A much longer upgrade process is explained in the documentation if you
-need to store the contact data.
-
-* http://gerrit.googlecode.com/svn/documentation/2.0/config-contact.html
-* http://gerrit.googlecode.com/svn/documentation/2.0/config-contact.html#upgrade_203
-+
-This horribly painful change was necessary to better protect
-individual user's privacy by strongly encrypting their contact
-information, and storing it "off site".
-
-== Other Changes
-* Change to 2.0.3-SNAPSHOT
-* Correct grammar in the patch conflict messages
-* Document how to create branches through SSH and web
-* Add how/why we call Gerrit Gerrit to the background sect...
-* Don't bother logging IO errors caused by disappearing cl...
-* Remove old entries from our feature roadmap
-* Add a link to our issue tracker to the feature roadmap
-* Add documentation on the access control lists and rights
-* Escape single quotes when escaping text for HTML inclusi...
-* Document that install was tested with Jetty 6.1.14 and l...
-* Add a note about CA Siteminder long headers and Jetty
-* Make sure the WorkQueue terminates when running command ...
-* Move all contact information out of database to encrypte...
-* Peg the versions of JGit and MINA SSHD to something known
-* gerrit 2.0.4
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.5.txt b/ReleaseNotes/ReleaseNotes-2.0.5.txt
deleted file mode 100644
index 8006e12..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.5.txt
+++ /dev/null
@@ -1,69 +0,0 @@
-= Release notes for Gerrit 2.0.5
-
-Gerrit 2.0.5 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-WARNING: This version contains a schema change.
-
-Schema upgrade:
-
- java -jar gerrit.war --cat sql/upgrade005_006.sql | psql reviewdb
-
-If you use an OpenID authentication provider, you may want to review the new trusted providers functionality added by this release.  See the OpenID section in the SSO documentation for more details:
-
-link:http://gerrit.googlecode.com/svn/documentation/2.0/config-sso.html[http://gerrit.googlecode.com/svn/documentation/2.0/config-sso.html]
-
-== New Features
-
-* GERRIT-62  Work around IE6's inability to set innerHTML on a tbody ...
-* GERRIT-62  Upgrade to gwtjsonrpc 1.0.2 for ie6 support
-+
-These add (crude) support for Microsoft Internet Explorer 6 and 7.
-
-* Allow users to delete OpenID identities no longer used
-* Show the trust status of a user's identities
-* Allow effective permissions only for trusted OpenID prov...
-+
-These features allow a site to lock down access to only a trusted OpenID provider.  review.source.android.com uses this to give out approval access only to users who have registered with the site's trusted OpenID provider, Google Accounts.
-
-* Add clippy.swf to support copying download commands to t...
-* Display the clippy button for the permalink of a change
-* Allow clicking on a copyable text to switch label to inp...
-+
-These features make it easier to copy patch download commands.
-
-== Bug Fixes
-
-* GERRIT-79  Error out with more useful message on "push :refs/change...
-* Invalidate all SSH keys when otherwise flushing all cach...
-
-== Other Changes
-
-* Set version 2.0.4-SNAPSHOT
-* Correct note in developer setup about building SSHD
-* Change the order of links in developer setup
-* Document how to enable SSL with Jetty and Apache2
-* Ignore errors when current row no longer exists in a tab...
-* Show the Web Identities panel when on HTTP authentication
-* Relabel the "Web Identities" tab as just "Identities
-* Use an &nbsp; when showing an empty cell in the identity...
-* Simplify the Gerrit install from source procedure to avoi...
-* Support -DgwtStyle=DETAILED to support browser debugging
-* Don't link to JIRA in our docs, link to our issues page
-* Use &nbsp; in the identities table email column when emp...
-* Fix GWT Mac OS X launcher to include all sources
-* Catch any unexpected exceptions while closing a replicat...
-* Fix indentation in UserAgent.gwt.xml
-* Only load the flash clippy button if flash plugin is ava...
-* Fix border in the info block on the settings page
-* Reuse code that was moved to gwtexpui
-* Rename our CSS to encourage caching
-* Add gwtexpui to our license list
-* Fix account settings screen by correcting row offset
-* Replace DomUtil with SafeHtmlBuilder
-* Mention the OpenID provider restriction feature in our d...
-* Mention the contact information encryption in our design...
-* Switch to gwtexpui's iframe busting code
-* Use gwtexpui 1.0
-* gerrit 2.0.5
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.6.txt b/ReleaseNotes/ReleaseNotes-2.0.6.txt
deleted file mode 100644
index 1e28da8..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.6.txt
+++ /dev/null
@@ -1,36 +0,0 @@
-= Release notes for Gerrit 2.0.6
-
-Gerrit 2.0.6 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== New Features
-
-* GERRIT-41  Add support for abandoning a dead change
-+
-Everyone cheer for Brad Larson for providing this!
-
-* Bold substrings which match query when showing completi...
-
-== Bug Fixes
-
-* GERRIT-43  Work around Safari 3.2.1 OpenID login problems
-* GERRIT-43  Suggest boosting the headerBufferSize when deploying un...
-* GERRIT-94  Only show the progress meter if we haven't reset the ta...
-* GERRIT-94  Defer showing the patch set table until it is fully bui...
-* GERRIT-76  Upgrade to JGit 0.4.0-209-g9c26a41
-* Ensure branches modified through web UI replicate
-
-== Other Changes
-
-* Start 2.0.6 development
-* Generate the id for the iframe used during OpenID login
-* Fix formatting after method rename caused longer lines
-* Change copyright messages in file headers to AOSP
-* Add missing copyright notice headers to Java sources
-* Support running the SSH daemon from the command line
-* Ignore GerritServer.properties at the top level
-* Fix gerrit_macos.launch to make Eclipse happy
-* Merge
-* Merge
-* Gerrit 2.0.6
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.7.txt b/ReleaseNotes/ReleaseNotes-2.0.7.txt
deleted file mode 100644
index d1bc38f..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.7.txt
+++ /dev/null
@@ -1,48 +0,0 @@
-= Release notes for Gerrit 2.0.7
-
-Gerrit 2.0.7 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-Of note is the WAR file doubled in size.  This is due to the switch to openid4java for the OpenID relying party implementation, as it is more compliant to the OpenID 2.0 draft standard than the prior relying party, dyuproject.
-
-Installation of openid4java may require installing Xalan/Xerces from the WAR into your application container's secure classes directory or something equally obtuse.  Under stock Jetty it still works fine to just drop the WAR in.  If you aren't using Jetty and are using OpenID authentication, be warned, the upgrade may be a bit harder than just dropping the WAR in due to Xalan/Xerces issues.
-
-Gerrit is still Apache 2/MIT/BSD licensed, despite the switch of a dependency.
-
-== New Features
-
-* GERRIT-103  Display our server host keys for the client to copy an...
-+
-For the paranoid user, they can check the key fingerprint, or even copy the complete host key line for ~/.ssh/known_hosts, directly from Settings > SSH Keys.
-
-== Bug Fixes
-
-* GERRIT-98   Require that a change be open in order to abandon it
-* GERRIT-101  Switch OpenID relying party to openid4java
-* GERRIT-102  Never place an OpenID provider into an iframe
-+
-These are fixes suggested by the OpenID team at Google, or by the security team at Google.
-
-* Use a TOPO sort when processing commits in the merge q...
-* Upgrade JGit to 0.4.0-236-gcb63365
-+
-The upgrade of JGit should resolves issues relating to not being able to upload a merge commit change for review when merging in an upstream change that is already available under another tracking branch.  Multiple groups have reported this problem late last week.
-
-* Fix a NullPointerException in OpenIdServiceImpl on res...
-
-== Other Changes
-* Start 2.0.7 development
-* Upgrade JGit to 0.4.0-212-g9057f1b
-* Make the sign in dialog a bit taller to avoid clipping...
-* Define our own version of a URL encoding helper
-* Refactor the openid_identifier field name to be a cons...
-* GERRIT-102  Simplify the OpenID login code now that the iframe is ...
-* Sort request parameters during OpenID response handling
-* Shorten our OpenID return_to URL by removing unnecessa...
-* Honor the "Remember Me" checkbox when it comes to cook...
-* Remove the now dead SetCookie.html page
-* Don't ask for registration information on existing acc...
-* Disable spell checking on the SSH key add text area
-* Hide the SSH key add field if we already have keys reg...
-* gerrit 2.0.7
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.8.txt b/ReleaseNotes/ReleaseNotes-2.0.8.txt
deleted file mode 100644
index 89e7fdd..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.8.txt
+++ /dev/null
@@ -1,45 +0,0 @@
-= Release notes for Gerrit 2.0.8
-
-Gerrit 2.0.8 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-*WARNING: This version contains a schema change.*
-
-Schema upgrade:
-
-  java -jar gerrit.war --cat sql/upgrade006_007.sql | psql reviewdb
-
-This version has some major bug fixes for JGit.  I strongly encourage people to upgrade, we had a number of JGit bugs identified last week, all of them should be fixed in this release.
-
-
-== New Features
-* Allow users to subscribe to submitted change events
-+
-Someone asked me on an IRC channel to have Gerrit send emails when changes are actually merged into a project.  This is what triggered the schema change; there is a new checkbox on the Watched Projects list under Settings to subscribe to these email notifications.
-
-* BCC any user who has starred a change when sending rela...
-+
-A nice idea.  If the user starred the change, keep them informed on all emails related to that change, even if they aren't otherwise watching that project.
-
-* GERRIT-33  Quote the line a comment applies to when sending commen...
-+
-A long standing "bug"/feature request.  I had a small chunk of time I didn't know what else to do with on Friday... it was too small for most items on the open list, so this got done instead.
-
-* Record the remote host name in the reflogs
-* Record the starting revision expression used when makin...
-+
-The reflogs now contain the remote user's IP address when Gerrit makes edits, resulting in slightly more detail than was there before.
-
-== Bug Fixes
-* Make sure only valid ObjectIds can be passed into git d...
-* GERRIT-92  Upgrade JGit to 0.4.0-262-g3c268c8
-+
-The JGit bug fixes are rather major.  I would strongly encourage upgrading.
-
-== Other Changes
-* Start 2.0.8 development
-* Upgrade MINA SSHD to SVN trunk 755651
-* Fix a minor whitespace error in ChangeMail
-* Refactor patch parsing support to be usable outside of ...
-* Gerrit 2.0.8
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.9.txt b/ReleaseNotes/ReleaseNotes-2.0.9.txt
deleted file mode 100644
index 1f683cf..0000000
--- a/ReleaseNotes/ReleaseNotes-2.0.9.txt
+++ /dev/null
@@ -1,63 +0,0 @@
-= Release notes for Gerrit 2.0.9
-
-Gerrit 2.0.9 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-*WARNING: This version contains schema changes.*
-
-Schema upgrades:
-----
-  java -jar gerrit.war --cat sql/upgrade007_008.sql | psql reviewdb
-  java -jar gerrit.war --cat sql/upgrade008_009.sql | psql reviewdb
-----
-
-If one or more of your projects are using the undocumented `gerrit.fastforwardonly` configuration option, you should import that setting into the database:
-----
-  java -DGerritServer=GerritServer.properties -jar gerrit.war ImportProjectSubmitTypes
-----
-
-The SQL statement to insert a new project into the database has been changed.  Please see [http://gerrit.googlecode.com/svn/documentation/2.0/project-setup.html Project Setup] for the modified statement.
-
-== New Features
-* GERRIT-69   Make the merge commit message more detailed when mergi...
-* Show the user's starred/not-starred icon in the change...
-* Modify Push Annotated Tag to require signed tags, or r...
-* GERRIT-77   Record who submitted a change in the change message
-+
-
-* Support different project level merge policies
-* GERRIT-111  Support cherry-picking changes instead of merging them
-+
-These last two changes move the hidden gerrit.fastforwardonly feature to the database and the user interface, so project owners can make use of it (or not).  Please see the new 'Change Submit Action' section in the user documentation:
-link:http://gerrit.googlecode.com/svn/documentation/2.0/project-setup.html[http://gerrit.googlecode.com/svn/documentation/2.0/project-setup.html]
-
-== Bug Fixes
-* Work around focus bugs in WebKit based browsers
-* Include our license list in the WAR file
-* Whack any prior submit approvals by myself when replac...
-* GERRIT-35   Handle unwrapped commit message more gracefully
-* GERRIT-85   ie6: Correct rendering of commit messages
-* GERRIT-89   ie6: Fix date line wrapping in messages
-
-== Other Changes
-* Start 2.0.9 development
-* Always show the commit SHA-1 next to the patch set hea...
-* Silence more non-critical log messages from openid4java
-* Fix default READ access on new database initialization
-* Don't permit project rights to be created backwards
-* Don't permit project rights to be created backwards (p...
-* Select better defaults for min/max access rights when ...
-* Show the + or - numeric level when adding a new ACL en...
-* Fix odd formatting errors in MergeOp.java
-* Fix tab formatting in pom.xml
-* Require the submitter approval to be > 0 to claim it i...
-* Fix the copyright header in pom.xml to be AOSP
-* Add some missing copyright headers
-* Remove Gerrit 1.x to 2.x import tools
-* Upgrade JGit to 0.4.0-272-g7322ea2
-* Upgrade gwtexpui to 1.0.2
-* Attach submitter identity to change messages about suc...
-* Automatically generate unique names for our CSS code
-* Cache `*`.nocache.js and don't cache the host page
-* gerrit 2.0.9
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.1.1.txt b/ReleaseNotes/ReleaseNotes-2.1.1.txt
deleted file mode 100644
index 38b6caf..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.1.txt
+++ /dev/null
@@ -1,205 +0,0 @@
-= Release notes for Gerrit 2.1.1, 2.1.1.1
-
-Gerrit 2.1.1.1 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Schema Change
-
-*WARNING* This release contains a schema change.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-== Patch 2.1.1.1
-
-* Update MINA SSHD to SVN 897374
-+
-A deadlock was recently discovered in the SSHD, causing an
-IoProcessor thread to freeze and stop servicing clients.  This
-manifests itself as spotty SSH service; sometimes a connection
-works, sometimes it hangs and never executes the command.  Fixed.
-
-* issue 376    Fix deletion of comments on publish comments screen
-+
-Discarding a comment from the publish comments screen caused
-a ConcurrentModificationException.  Fixed.
-
-== New Features
-
-* issue 322    Update to GWT 2.0.0
-+
-JavaScript code generation is now based upon GWT 2.0, which
-is the latest stable release available.  One benefit of this
-is the initial JavaScript download is smaller, by omitting
-less-frequently used sections of the UI like the admin screens
-or user preferences.
-
-* Support creating new users in `DEVELOPMENT_BECOME_ANY_`...
-+
-Developers can now create new users (to facilitate testing
-scenarios) through the /become URL, rather than manually
-inserting account records or switching over to OpenID/LDAP.
-
-* issue 371    Make gitweb url links customizable, add support for c...
-+
-The linkage to gitweb is now more configurable, and we also
-support linking to cgit, a popular C based alternative to the
-Perl based gitweb.cgi.
-
-* Log SSH activity to $site_path/logs/sshd_log
-+
-SSH authentication failures and commands are now logged, including
-execution times, so administrators can monitor server activity.
-The log file is local to the server running the daemon process,
-and came about to help replace the lastUsedOn columns which were
-dropped from the database (see below).
-
-* Drop the lastUsedOn from AccountSshKeys, AccountExternalIds
-* Implement automatic schema upgrading
-+
-The lastUsedOn column is no longer updated in the database,
-and was actually removed by a schema upgrade in this release.
-
-* issue 162    Record submitters as the author of a merge commit
-+
-Merge commits created by Gerrit during change submission now
-use the submitter's identity as the author identity, and generic
-Gerrit user identity as the committer identity.
-
-* issue 162    Summarize single change merges with short description
-+
-The short description of a merge commit including exactly
-one change into the branch now includes that change's short
-description, making the log easier to read.
-
-* Reload GerritSiteHeader, GerritSiteFooter, GerritSite...
-+
-The site header/footer files are reloaded on the fly if they are
-modified, allowing the administrator to abuse the header for a
-"message of the day" feature, if desired.
-
-* Reduce the size (and cost) of the host page
-* Use server side permutation selection
-* Allow ?s=0 to disable server side permutation
-+
-The host page was compacted slightly, and the CPU time used on
-the server to send it to a client was reduced by reusing as much
-work as possible between sessions.
-Additionally, the host page now selects the correct JavaScript
-based on the User-Agent HTTP header, removing one HTTP round
-trip during initial page load, and saving ~5 KiB of transfer.
-
-* Make hyperlinks update URL when screen is visible
-+
-The address bar now only updates when the corresponding content
-is actually visible.  This matches the behavior used within
-other AJAX applications like Gmail.
-
-* Use a glass pane behind our dialogs, make most modal
-+
-Error dialogs are now more noticeable, and less easily dismissed
-by an accidental click.  This is especially useful when there
-is a merge error during submit.
-
-== Bug Fixes
-
-* issue 359    Allow updates of commits where only the parent changes
-+
-Commit replacements were sometimes rejected when the only thing
-that changed as the parent pointer, e.g. rebasing a change because
-the parent's commit message was modified to correct a typo.
-We now allow these replacements, with a warning to the console.
-
-* gsql: Fix \d table missing first column
-+
-The gsql tool skipped the first column of any table, e.g. when
-showing "\d accounts" the registered_on column wasn't displayed.
-
-* Default to the en locale
-* Limit permutations to only the en locale
-+
-The WAR file shrank because we deleted a large chunk of JavaScript
-which was never used.  GWT created this code in case the browser
-didn't get forced into the 'en' locale, but we always force it to
-use the 'en' locale because the top of our HTML page demands it.
-
-* issue 364    Fix SchemaCreatorTest to work when localized errors a...
-+
-This test failed when the JVM's default locale wasn't en_US, as it
-was testing a translated string against an English expected value.
-
-* issue 365    Skip CommitMsgHookTest on Win32
-+
-This test failed on Windows platforms, where there is no shell
-or perl available from a native Win32 application like the JVM.
-For now, we skip the test.
-
-* issue 369    Add missing repositories to build search path
-+
-The out-of-the-box build of Gerrit's own source code didn't work,
-due to missing Maven repository URLs in our pom.xml.  I never
-noticed the failure because my local repository already had the
-required JARs present.
-
-* Fix MSIE 8 compatibility
-+
-Releases between 2.0.18 and 2.1.1 have not supported MSIE 8,
-due to a broken GWT upgrade.  Fixed.
-
-* Ensure gitweb.cgi pipes are closed
-+
-Exceptions may have allowed our internal gitweb CGI invocations
-to leak file descriptors, as pipes to the external CGI were not
-always closed.  Fixed.
-
-== Other
-* Switch to ClientBundle
-* Update to gwtexpui-1.2.0-SNAPSHOT
-* Merge branch 'master' into gwt-2.0
-* Use gwt-maven's -Dgwt.style rather than our own
-* Don't build the "Story of Your Compile" report by def...
-* Drop the com.google.gerrit.httpd.auth.become system p...
-* Move all of our CSS rules into our CssResource
-* Start splitting our code to reduce initial download
-* Defer our large JavaScript parsing until later
-* Move prettify to be loaded as part of our patch split...
-* issue 363    Update Google Code Prettify to 3-Dec-2009
-* Start next release development
-* Merge branch 'gwt-2.0'
-* documentation: Remove Eclipse user library
-* Fix disclosure panel CSS
-* Simplify pretty printer loading
-* Fix formatting of whitespace errors
-* Correct URL to apache license in CSS headers
-* Restore the CSS linker for GWT's stylesheet
-* documentation: Correct calculation of QPS
-* Consolidate windows platform tests to a single class
-* documentation: Correct other calculations of QPS
-* issue 370    Revert "Defer our large JavaScript parsing until late...
-* Merge change If238e2bd
-* Remove unnecessary /login/`*` URLs when auth.type = LDAP
-* Stop using AccountExternalId lastUsedOn for most rece...
-* Revert "Remove unnecessary /login/* URLs when auth.ty...
-* Document why LoginRedirectServlet is required
-* Cleanup Maven build by pushing component dependencies...
-* Cleanup Maven build by using common plugin management
-* Fix package-before-copyright in GerritLauncher
-* Fix unified patch view
-* Fix background of RPC loading status message
-* Use @def for common CSS definitions
-* Correct comment panel border styles
-* Improve keyapplet referencing
-* Remove the duplicate Version class
-* Be specific about the Maven plugin groupId
-* Fix automatic formatting in SshPanel
-* Remove unnecessary compile scope tags
-* Disable unnecessary class operations
-* Use the full name 'Gerrit Code Review' in sign-in dia...
-* init: Defer all prune executions until upgrade cycle ...
-* Fix automatic formatting in LdapRealm
-* Update gwtorm, gwtjsonrpc, gwtexpui
-* Push Command.destroy down through DispatchCommand red...
-* Quote usernames in the sshd_log if necessary
-* Document why ReplicationUser doesn't use registered g...
-* Configure the gwtorm KeyUtil.Encoder during module lo...
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.1.10.txt b/ReleaseNotes/ReleaseNotes-2.1.10.txt
deleted file mode 100644
index 5c5bcc6..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.10.txt
+++ /dev/null
@@ -1,11 +0,0 @@
-= Release notes for Gerrit 2.1.10
-
-There are no schema changes from link:ReleaseNotes-2.1.9.html[2.1.9].
-
-link:https://www.gerritcodereview.com/download/gerrit-2.1.10.war[https://www.gerritcodereview.com/download/gerrit-2.1.10.war]
-
-== Bug Fixes
-* Fix clone for modern Git clients
-+
-The security fix in 2.1.9 broke clone for recent Git clients,
-throwing an ArrayIndexOutOfBoundsException. Fixed.
diff --git a/ReleaseNotes/ReleaseNotes-2.1.2.1.txt b/ReleaseNotes/ReleaseNotes-2.1.2.1.txt
deleted file mode 100644
index b181fee..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.2.1.txt
+++ /dev/null
@@ -1,45 +0,0 @@
-= Release notes for Gerrit 2.1.2.1
-
-Gerrit 2.1.2.1 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Bug Fixes
-
-* Include smart http:// URLs in gitweb
-+
-The managed gitweb configuration file didn't know about our smart
-http URLs, so it didn't advertise them for projects to clone by.
-Fixed.
-
-* issue 493 documentation: Document the internal gitweb
-* issue 496 documentation: Explain etc/gitweb_config.perl
-+
-The documentation on configuring gitweb didn't talk about our own
-managed support, where we can write the gitweb configuration file
-based on our own settings, and run the CGI directly from within
-our servlet container.  Its an older feature that we have had for
-a while now.  Fixed.
-
-* issue 494 Look for gitweb in /usr/share/gitweb
-* issue 495 Fix gitweb CGI when in subdirectory
-+
-The CGI didn't always load its supporting assets like CSS and icon
-from the right URLs.  Fixed.
-
-* Move generated gitweb_config.perl to hidden tmp directory
-+
-The generated gitweb configuration file was written to /tmp,
-which might cause it to be deleted every 7 days on some Linux
-distributions.  Moved to our private application temporary directory,
-which is usually under $HOME/.gerritcodereview/tmp.
-
-* Update documentation regarding tag deletion
-+
-The documentation incorrectly described tag deletion.  Fixed.
-
-* Allow schema upgrades to display messages
-+
-On MySQL servers, schema upgrades from older versions failed if the
-administrator didn't create the nextval functions for administrative
-purposes.  Fixed by making this a warning and not a hard-stop.
diff --git a/ReleaseNotes/ReleaseNotes-2.1.2.2.txt b/ReleaseNotes/ReleaseNotes-2.1.2.2.txt
deleted file mode 100644
index 305e3e1..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.2.2.txt
+++ /dev/null
@@ -1,19 +0,0 @@
-= Release notes for Gerrit 2.1.2.2
-
-Gerrit 2.1.2.2 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Bug Fixes
-
-* Add ',' to be encoded in email headers.
-+
-Email headers which used UTF-8 character set encoding did not
-properly escape a comma.  Fixed.
-
-* issue 513 Log OpenID SSL failures
-+
-If OpenID authentication fails, such as due to the JRE not having
-access to any of the root certificates and therefore being unable
-to open an https connection, the problem is now logged to the
-server's error_log.
diff --git a/ReleaseNotes/ReleaseNotes-2.1.2.3.txt b/ReleaseNotes/ReleaseNotes-2.1.2.3.txt
deleted file mode 100644
index f81092c..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.2.3.txt
+++ /dev/null
@@ -1,80 +0,0 @@
-= Release notes for Gerrit 2.1.2.3
-
-Gerrit 2.1.2.3 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Bug Fixes
-
-* issue 528 gsql: Fix escaping of quotes in JSON
-+
-JSON output was not properly escaped, due to a bug in the underlying
-Gson library.  Fixed by upgrading.
-
-* issue 531 commit-msg: Fix jumbling of URL at end of message
-+
-URLs at the end of a commit message sometimes caused the Change-Id
-to be inserted above the URL, rather than below it.  Fixed, but
-users will need to recopy the hook to their local repositories.
-
-* issue 538 create-project: Don't destroy description of repository
-+
-If the repository `foo` existed without the standard `.git` suffix,
-executing `gerrit create-project -n foo` trashed the description
-file that existed in `foo`, while also creating a useless sibling
-directory called `foo.git`.  Fixed by detecting the existing `foo`
-during create-project and refusing to continue.
-
-* issue 521 Use OpenID PAPE extension to force reauthentication
-+
-The new configuration parameter auth.maxOpenIdSessionAge is now
-sent as part of OpenID authentication requests, encouraging the
-provider to verify the user's password.
-
-* issue 507 Enter on auto-complete causes application error
-+
-Pressing enter while the auto-complete box was open inside of
-the project watch panel or the project rights panel caused an
-application error.  Fixed.
-
-* Advertise our relying party XRDS document
-+
-The OpenID 2.0 specification requests relying parties to document
-themselves, so the provider can verify the request is authentic
-for this domain.  Document Gerrit's requests in the standard XRDS
-format, and advertise it properly.  This hides warnings during the
-Yahoo! provider's login process.
-
-* Don't allow OWN to be inherited from All Projects
-+
-The project Owner permission was accidentally inherited from the
-magical All Projects in certain cases.  This was not meant to happen,
-ownership cannot be inherited down.  Fortunately we didn't permit
-the Owner permission to be added to All Projects, so this was not
-likely to have occurred in real installations.
-
-* Traverse all LDAP groups that a user is member of
-* Expand LDAP groups only if accountMemberField set
-+
-Fixes traversal of groups on an Active Directory server, ensuring
-that the user's grandparent groups are available to Gerrit as part
-of their user session.
-
-* Serve gitweb.js when serving gitweb.cgi
-+
-Recent versions of gitweb have a JavaScript asset which provides
-additional features.  Make sure that is served to browsers, in
-addition to the CSS and logo image.
-
-* Allow gitweb assets to be cached by browser
-+
-Browsers were always loading the gitweb assets on each request,
-as no caching data was made available to them.  Now assets are
-cached for up to 5 minutes, and 304 Not Modified replies can be
-sent when the assets haven't changed.
-
-* Define a toString for PatchListKey to improve errors
-+
-Minor bug fix to improve the level of detail that is available when
-the server is unable to difference two patch sets on demand for a
-user request.
diff --git a/ReleaseNotes/ReleaseNotes-2.1.2.4.txt b/ReleaseNotes/ReleaseNotes-2.1.2.4.txt
deleted file mode 100644
index 45fcb40..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.2.4.txt
+++ /dev/null
@@ -1,55 +0,0 @@
-= Release notes for Gerrit 2.1.2.4
-
-Gerrit 2.1.2.4 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== New Features
-
-* Add 'checkout' download command to patch sets
-+
-The Download area of a patch set now offers a command line to fetch
-and checkout the patch set on a detached HEAD.  This is more suitable
-for building and testing the change locally.
-
-== Bug Fixes
-
-* issue 545 Fallback to ISO-8859-1 if charset isn't supported
-+
-Some input files are misrecognized by the jchardet library that is
-used to automatically guess a character set.  A guessed charset
-might not even be supported by the local JRE.  In such cases the
-ISO-8859-1 character set is used as a fallback, so the file content
-is still visible.
-
-* issue 553 Bugs sometimes added as change reviewers
-+
-Bug references were sometimes added as an 'Anonymous Coward' change
-reviewer when the line used to mention the bug in the commit message
-was the same length as 'Signed-off-by'.  Fixed.
-
-* Update JGit to 0.7.1.46-gdd63f5c to fix empty tree bug
-+
-Repositories which contained an empty tree object (very uncommon, its
-technically a bug to produce a repository like this) wouldn't clone
-properly from the embedded Gerrit SSH or HTTP daemon.  Fixed upstream
-in JGit 0.7.0, but we never picked up the bug fix release.
-
-* Allow LDAP to unset the user name
-+
-If the user name is configured to be set only by the LDAP directory,
-and an account has a user name, but the name is no longer present
-in the directory, Gerrit crashed during sign-in while trying to
-clear out the user name.  Fixed.
-
-=== Documentation Corrections
-
-* documentation: Elaborate on branch level Owner
-+
-Documentation didn't describe that the Owner permission within a
-project can be used to delegate control over a branch namespace to
-another group.
-
-* documentation: Document Read Access +2 aka Upload Access
-+
-The documentation didn't describe what Read +2 means.
diff --git a/ReleaseNotes/ReleaseNotes-2.1.2.5.txt b/ReleaseNotes/ReleaseNotes-2.1.2.5.txt
deleted file mode 100644
index eece1e7..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.2.5.txt
+++ /dev/null
@@ -1,22 +0,0 @@
-= Release notes for Gerrit 2.1.2.5
-
-Gerrit 2.1.2.5 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Bug Fixes
-
-* issue 390 Resolve objects going missing
-+
-Clients disconnecting from the SSH server sometimes caused an
-interrupt to be delivered to their corresponding server work thread.
-That interrupt delivered at the wrong time caused a file to be
-closed unexpectedly, resulting in JGit marking the file as invalid
-and thereby losing access to its contents.  Fixed by serializing
-access to the file.
-
-* ps: Fix implementation to alias to gerrit show-queue
-+
-The SSH command `ps` was meant to be an alias for `gerrit show-queue`
-but due to a copy-and-paste error was actually an alias for a
-different command.  Fixed.
diff --git a/ReleaseNotes/ReleaseNotes-2.1.2.txt b/ReleaseNotes/ReleaseNotes-2.1.2.txt
deleted file mode 100644
index 8e7cd5c..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.2.txt
+++ /dev/null
@@ -1,708 +0,0 @@
-= Release notes for Gerrit 2.1.2
-
-Gerrit 2.1.2 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Schema Change
-
-*WARNING* This release contains multiple schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-
-== Breakages
-
-* issue 421 Force validation of the author and committer lines
-+
-The author line must now match the authenticated user when uploading a
-change, and both author and committer must match when pushing directly
-into a branch with the Push Branch permission.  This is a new
-restriction that did not exist in prior versions and was necessary to
-close a hole that permitted users to completely forge commits if they
-had Push Branch +1 granted.
-+
-Project owners may grant the new Forge Identity permission to permit a
-user group to forge the author and/or committer lines in commit
-objects they are pushing for review, or directly into a branch.  To
-match prior behavior grant Forge Identity +1 where Read +2 (Upload)
-exists, and Forge Identity +2 where Push Branch >= +1 exists.
-
-
-== New Features
-
-=== UI - Diff Viewer
-
-* issue 169 Highlight line-level (aka word) differences in files
-+
-Differences within a replaced line are now highlighted with a
-brighter red or green background color.  Some heuristics are
-applied to identify and highlight reindented blocks in popular
-C/C++/Java/C#-like and Python-like languages.  The highlighting
-algorithm is still simple and could benefit from more fine-tuning,
-as its largely driven by a simple Myers O(ND) character difference
-over the replaced lines.
-+
-The configuration variable cache.diff.intraline can be used to
-disable this feature site-wide, if it causes problems.
-
-* Improve side-by-side viewer look-and-feel
-+
-The look-and-feel of the side-by-side viewer (and also of the unified
-viewer) has been significantly improved in this release.  Coloring of
-regions is more consistently applied, reducing reader distraction.
-Comment boxes use a cleaner display, and take up less space per line.
-
-* Adjustable patch display settings
-+
-Users can now set the tab size or number of columns when displaying a
-patch.  Toggles are also available to enable or disable syntax
-coloring, intraline differences, whitespace errors, and visible tabs.
-
-* issue 416 Add download links to side-by-side viewer
-+
-The side-by-side viewer now offers links to download the complete file
-of either the left or right side.  To protect the users from malicious
-cross-site scripting attacks, the download links force the content to
-be wrapped inside of a ZIP archive with a randomized file name.
-Server administrators may use the mimetype.safe configuration setting
-to avoid this wrapping if they trust users to only upload safe file
-content.
-
-* Improve performance of 'Show Full Files'
-+
-The 'Show Full File' checkbox in the file viewers no longer requires
-an RPC if the file is sufficiently small enough and syntax coloring
-was enabled.  The browser can update the UI using the cached data it
-already has on hand.
-
-* Show old file paths on renamed/copied files
-+
-If a file was renamed or copied, the side-by-side viewer now shows the
-old file path in the column header instead of the generic header text
-'Old Version'.
-
-* Improved character set detection
-+
-Gerrit now uses the Mozilla character set detection algorithm when
-trying to determine what charset was used to write a text file.
-For UTF-8 or ISO-8859-1/ASCII users, there should be no difference
-over prior releases.  With this change, the server can now also
-automatically recognize source files encoded in:
-
-a. Chinese (ISO-2022-CN, BIG5, EUC-TW, GB18030, HZ-GB-23121)
-b. Cyrillic (ISO-8859-5, KOI8-R, WINDOWS-1251, MACCYRILLIC, IBM866, IBM855)
-c. Greek (ISO-8859-7, WINDOWS-1253)
-d. Hebrew (ISO-8859-8, WINDOWS-1255)
-e. Japanese (ISO-2022-JP, SHIFT_JIS, EUC-JP)
-f. Korean (ISO-2022-KR, EUC-KR)
-g. Unicode (UTF-8, UTF-16BE / UTF-16LE, UTF-32BE / UTF-32LE / X-ISO-10646-UCS-4-34121 / X-ISO-10646-UCS-4-21431)
-h. WINDOWS-1252
-
-* issue 405 Add canned per-line comment reply of 'Done'
-* issue 380 Use N/P to jump to next/previous comments
-* Use RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK for tabs
-* Use a tooltip to explain whitespace errors
-
-=== UI - Other
-
-* issue 408 Show summary of code review, verified on all open changes
-+
-The open changes views now show the status summary columns, just like
-a user dashboard shows.  This requires an extra RPC per page display,
-but can save user time when trying to identify which reviews should be
-examined.
-
-* Only enable 'Delete' button when there are selections
-+
-In Settings panels the delete button is enabled only if at least one
-row has been selected to be removed.
-
-* SSH commands stop option parsing on \--
-+
-Like most POSIX commands, `\--` now signifies the end of options for
-any command accessible over SSH.
-
-* Include formatted HTML documentation in WAR
-+
-Official release WARs now contain the formatted HTML documentation,
-and a 'Documentation' menu will display in the main UI (alongside
-'All', 'My', 'Admin') to help users access the local copy rather
-than jumping to the remote Google Code project site.
-
-* Enhanced patch set download commands
-+
-Download commands for patch sets are now offered as a tabbed panel,
-allowing the user to select between 'repo download', 'git pull',
-or 'git fetch ... && git cherry-pick' or 'git fetch ... && git
-format-patch' styles, as well as to select the transport protocol
-used, including anonymous Git or HTTP, or authenticated SSH or HTTP.
-The current selections are remembered for signed-in users, permitting
-end-users to quickly reuse their preferred method of grabbing a
-patch set.
-
-* Theme the web UI with different skin colors
-+
-Site administrators can now theme the UI with local site colors
-by setting theme variables in gerrit.config.
-
-=== Permissions
-
-* issue 60 Change permissions to be branch based
-+
-Almost all permissions are now per-branch within each project.  This
-includes Code Review, Verified, Submit, Push Branch, and even Owner.
-Permissions can be set on a specific branch, or on a wildcard that
-matches all branches that start with that prefix.  Read permission is
-still handled at the project level, but future versions should support
-per-branch read access as well.
-
-* MaxNoBlock category for advisory review levels
-+
-The new MaxNoBlock category function can be used in a custom approval
-category for reviews that are performed by automated lint tools.
-See link:http://gerrit.googlecode.com/svn/documentation/2.1.2/access-control.html#function_MaxNoBlock[access control]
-for more details on this function.
-
-=== Remote Access
-
-* Enable smart HTTP under /p/ URLs
-+
-Git 1.6.6 and later support a more efficient HTTP protocol for both
-fetch/clone and push, by relying upon Git specific server side logic.
-Gerrit Code Review now includes the necessary server side support when
-accessing repositories using URLs of the form
-`http://review.example.com/p/'$projectname'.git`.
-Authentication over smart HTTP URLs is performed using standard HTTP
-digest authentication, with the username matching the SSH username,
-but the password coming from a field that is generated by Gerrit and
-accessible to the user on their Settings > SSH Keys tab.
-Smart HTTP requests enter the same resource queue as SSH requests,
-using the embedded Jetty server to suspend the request and later
-resume it when processing resources are available.  This ensures HTTP
-repository requests don't overtax the server when made concurrently
-with SSH requests.
-
-* issue 392 Make hooks/commit-msg available over HTTP
-+
-The scp filesystem holding client side tools and hooks is now
-available over `http://review.example.com/tools/'$name'`.  User
-documentation is updated with example URLs.
-
-* issue 470 Allow /r/I... URLs
-+
-Change-Ids can now be searched for by accessing the URL
-`http://example.com/r/'Ichangeid'`, similar to how commits
-can be searched by `http://example.com/r/'commitsha1'`.
-
-* gerrit-sshd: Allow double quoted strings
-+
-SSH command arguments may now be quoted with double quotes, in
-addition to single quotes.  This can make it easier to intermix
-quoting styles with the shell that is calling the SSH client .
-
-=== Server Administration
-
-* issue 383 Add event hook support
-+
-Site administrator managed hook scripts can now be invoked at various
-points in processing.  Currently these scripts are informational only
-and cannot influence the outcome of an event.  For more details see
-link:http://gerrit.googlecode.com/svn/documentation/2.1.2/config-hooks.html[hooks].
-
-* Add stream-events command
-+
-The new 'gerrit stream-events' command can be used over SSH by an
-end-user to watch a live stream of any visible patch set creation,
-comments and change submissions.  For more details see
-link:http://gerrit.googlecode.com/svn/documentation/2.1.2/cmd-stream-events.html[gerrit stream-events].
-
-* Log HTTP activity to $site_path/logs/httpd_log
-+
-When httpd.listenUrl is http:// or https://, requests are
-logged into `'$site_path'/logs/httpd_log`.  This mirrors the
-behavior of the SSH daemon, which also logs requests into the
-same directory.  For proxy URLs HTTP requests aren't logged,
-since the front-end server is expected to be performing the
-logging.  Logging can be forced on, or forced off by setting
-link:http://gerrit.googlecode.com/svn/documentation/2.1.2/config-gerrit.html#httpd.requestLog[httpd.requestLog].
-
-* Allow the daemon's host key to authenticate to itself
-+
-The SSH daemon's host key can now be used to authenticate as the
-magic user `Gerrit Code Review`.  This user identity is blessed as
-even more powerful than a user in the Administrators group, as using
-it requires access to the private half of the host key.  For example:
-+
-----
-  ssh -p 29418 -i site_path/etc/ssh_host_rsa_key 'Gerrit Code Review'@localhost gerrit flush-caches --all
-----
-
-* Allow $site_path/etc/peer_keys to authenticate peer daemons
-+
-Additional public keys for the magical 'Gerrit Code Review' user may
-be specified in an OpenSSH authorized_keys style file and are
-functionally equivalent to authenticating with the daemon's host key.
-The keys are primarily intended to be other daemons, most likely
-slaves, that share the same set of repositories and database.
-
-* Allow suexec to run any command as any user
-+
-The new SSH based suexec command can only be invoked by the magic user
-`Gerrit Code Review` and permits executing any other command as any
-other registered user account.  This forms the foundation of allowing
-a slave daemon process to transparently proxy any write request from a
-client forward to the current master.
-+
-The transparent proxy support is not yet implemented in the slave.
-
-* Support automation of gsql by JSON, -c option
-+
-The gsql command now supports JSON as an output format, making
-software driven queries over SSH easier.  The -c option accepts
-one query, executes it, and returns.
-
-=== Other
-
-* Warn when a commit message isn't wrapped
-+
-During receive Gerrit warns the user if their commit messages appears
-to be incorrectly formatted, by having lines that aren't hard-wrapped
-or that has an extremely long subject line.
-
-* During merge use existing author identity values
-+
-When Gerrit creates a merge commit in order to submit a change, the
-author information of the merge commit is taken from the submitter.
-If all of the commits being submitted were written by the submitter,
-the authorship of the merge commit is copied from one of those commits
-rather than from the user's preferred account information.
-
-
-== Bug Fixes
-
-=== UI
-
-* Change "Publish Comments" to "Review"
-+
-The term "Publish Comments" was used on two different buttons that
-performed two different actions.  The first usage was to open the
-screen which shows the scoring buttons, provides the cover letter
-editor, and shows the in-line comments for final review before
-publication.  The button that opens that review screen has been
-renamed "Review".  The second usage of the button was to actually send
-out the notification emails, and expose the comments to others.  This
-button is still called "Publish Comments".
-
-* issue 448 Disable syntax highlighting on unified views
-+
-Syntax highlighting in the unified patch view isn't useful if it hides
-the added and removed lines red/green text color.  Disable it entirely
-so the add/remove coloring shows up instead.
-
-* Disable 'Syntax Highlighting' and 'Show Full File' on big files
-+
-If the file is really big (over 9000 lines), 'Show Full File' is
-actually disabled on the server side, to prevent the client from
-being overrun with data.  The UI now reflects this by disabling
-the checkbox for the user, and adds a tooltip to indicate why its
-greyed out.
-
-* Don't try to syntax highlight plain text
-+
-Plain text files can't benefit from syntax highlighting, its actually
-more confusing than it is useful.  Skip highlighting on them.
-
-* issue 251 Fix bad syntax highlighting
-+
-Prior versions performed syntax highlighting on a per-line basis,
-resulting in confusing or bogus results in multi-line contexts like
-C/Java's "/\* ... \*/" style comment.  Fixed by performing
-highlighting on the entire file contents, even if only some lines are
-displayed to meet the user's context setting.
-
-* Ensure vertical tabs are visible
-+
-Vertical tab markers are red, which means they can be hidden against a
-whitespace error, or deleted region marker.  Tabs are now shown as
-black against these cases.
-
-* Handle bare CR in the middle of a line
-+
-If a CR ("\r") appears in the middle of a line rather than nestled
-against an LF as a CRLF pair, its now displayed as a whitespace
-error, and the line isn't broken at the CR.  This fixes an issue
-where a mostly CRLF file with a single malformed line ending caused
-the side-by-side display to render incorrectly (or not at all).
-
-* issue 438 Skip gitlink modes as we can't get a content difference
-+
-The special gitlink mode inside of a tree points to a commit in the
-submodule project.  We can't show the content of it inside of the
-supermodule.
-
-* issue 456 Support enter to submit on most forms
-+
-Enter key on a lot of forms did not activate the reasonable default
-action, e.g. add a reviewer to an existing review.  Fixed.
-
-* issue 347 Improve handling of files renamed between patch sets
-+
-Comment counts in the "history" section of a file viewer were not
-displayed when the file was renamed between two different patch sets
-of the same change.  Fixed.
-
-* Fix the style of the Reviewed column header
-+
-The reviewed column header wasn't displaying with the same style as
-its siblings.  Fixed.
-
-* Fix duplicate "Needed By" pointers between changes
-+
-If a change's current patch set was used as the parent for multiple
-patch sets of another change, that dependent change showed up more
-than once in the "Needed By" list.  Fixed.
-
-* Expand group names to be 255 characters
-* Update URL for GitHub's SSH key guide
-* issue 314 Hide group type choice if LDAP is not enabled
-
-=== Email
-
-* Send missing dependencies to owners if they are the only reviewer
-+
-If the owner of the change is the only reviewer and the change can't
-be submitted due to a missing dependency, Gerrit failed to send out an
-email notification.  Fixed.
-
-* issue 387 Use quoted printable strings in outgoing email
-+
-Names or subjects with non-ASCII characters were not quoted properly
-in the email notification headers.  Fixed.
-
-* issue 475 Include the name/email in email body if not in envelope
-+
-When the email address from line is a generic server identity,
-there is no way to know who wrote a comment or voted on a change.
-An additional from line is now injected at the start of the email
-body to indicate the actual user.
-
-=== Remote Access
-
-* issue 385 Delete session cookie when session is expired
-+
-If the session expires and the user clicks "Close" in the session
-expired popup dialog box, delete the cookie so the user can continue
-to use the website as an anonymous user.
-
-* Dequote saved OpenID URLs
-+
-Certain OpenID URLs were getting double quotes thrown around them
-after being saved in the last identity cookie on the client.  The
-quotes were loading back into the dialog on a subsequent sign-in
-attempt, resulting in an error as double quotes aren't valid in an
-HTTP URL.  Fixed by dropping the quotes if present.
-
-* Fix NoShell to flush the error before exiting
-+
-Sometimes users missed the standard error message that indicated no
-shell was available, due to a thread race condition not always
-flushing the outgoing buffer.  Fixed.
-
-* issue 488 Allow gerrit approve to post comments on closed changes
-+
-The 'gerrit approve' command previously refused to work on a closed
-change, but the web UI permitted comments to be added anyway.
-Fixed by allowing the command line tool to also post comments to
-closed changes.
-
-* issue 466 Reject pushing to invalid reference names
-+
-Gerrit allowed the invalid `HEAD:/refs/for/master` push refspec
-to actually create the branch `refs/heads/refs/for/master`, which
-confused any other client trying to push.  Fixed.
-
-* issue 485 Trim the username before requesting authentication
-+
-LDAP usernames no longer are permitted to start with or end with
-whitespace, removing a common source of typos that lead to users
-being automatically assigned more than one Gerrit user account.
-
-=== Server Administration
-
-* daemon: Really allow httpd.listenUrl to end with /
-+
-If httpd.listenUrl ended with / the configuration got botched during
-init and the site didn't work as expected.  Fixed by correctly
-handling an optional trailing / in this variable.
-
-* issue 478 Catch daemon startup failures in error_log
-+
-Startup errors often went to /dev/null, leaving the admin wondering
-why the server didn't launch as expected.  Fixed.
-
-* issue 483 Ensure uncaught exceptions are logged
-+
-Some exceptions were reaching the top of the stack frame without
-being caught and logged, causing the JRE to print the exception to
-stderr and then terminate the thread.  Since stderr was redirected
-to /dev/null by gerrit.sh, we usually lost these messages.  Exception
-handlers are now installed to trap and log any uncaught errors.
-
-* issue 451 gerrit.sh: Wait until the daemon is serving requests
-+
-The gerrit.sh script now waits until the daemon is actually running
-and able to serve requests before returning to the caller with a
-successful exit status code.  This makes it easier to then start up
-dependent tasks that need the server to be ready before they can run.
-
-* gerrit.sh: Don't use let, dash doesn't support it
-+
-/bin/sh on Debian/Ubuntu systems is dash, not bash.  The dash
-shell does not support the let command.
-
-* gerrit.sh: Correct JAVA_HOME behavior
-+
-JAVA_HOME now can be overridden by container.javaHome, as the
-documentation states.
-
-* init: Only suggest downloading BouncyCastle on new installs
-+
-Upgrades of an existing installation which has not installed the
-BouncyCastle library shouldn't be encouraged to download and install
-the library again.  The administrator has already chosen not to use
-it, we shouldn't nag them about it.
-
-* issue 389 Catch bad commentlink patterns and report them
-+
-A bad commentlink.match pattern could cause the change screen to
-simply not load, with no errors in the server log, and nothing
-immediately visible on the client.  Most bad patterns are now caught
-during server startup and are reported in the server error_log.
-Certain failures are caught on the client side, and sent to the server
-error log over RPC.  Bad patterns are simply skipped when logged.
-
-* issue 419 MySQL: Fix account\_group\_members\_audit removed\_on
-+
-MySQL has a "feature" which prevented the removed_on column from being
-NULL when we meant for it to be NULL.  Fixed by using the MySQL
-suggested work around, which is non-standard SQL.
-
-* issue 424 WAR truncated during init
-+
-init sometimes truncated the WAR file to 0 bytes if it was running
-from the destination WAR.  Fixed by using JGit's LockFile class which
-writes to a temporary file and does an atomic rename to finish.
-
-* issue 423 Bind to LDAP using only the end-user identity
-+
-Microsoft Active Directory doesn't support anonymous binds, and some
-installations might not be able to create a generic role account for
-Gerrit Code Review.  The new auth.type LDAP_BIND permits Gerrit to
-authenticate using only the end-user's credentials, avoiding the need
-for an anonymous or role account bind.
-
-* issue 423 Defer LDAP server type discovery until first authentication
-+
-Microsoft Active Directory wasn't being detected, because the
-anonymous bind during server startup failed.  Instead the server
-type is detected during the first user authentication, where we
-have a valid directory context to query over.
-
-* issue 486 Reload UI if code split fails to download
-+
-If the server gets upgraded and the user hasn't reloaded their
-browser tab since the upgrade, opening a new section of the UI
-sometimes failed.  Fixed by executing an implicit reload in these
-cases, reducing the number of times a user sees a failure.
-
-=== Development
-
-* issue 427 Adjust SocketUtilTest to be more likely to pass
-+
-Some DNS environments, especially those based on OpenDNS, were failing
-this test case during a build because the upstream resolver was
-returning back a bogus record for an invalid domain name.  The test
-was adjusted to use a name that is less likely to be resolved by a
-broken upstream resolver.
-
-* Fix /become?user_name=... under GWT debugger
-+
-The /become URL now accepts ?user_name=who to authenticate, making
-it easier to setup a launch configuration to debug a particular
-user account in development.
-
-* Show localhost based SSH URLs
-+
-SSH URLs using localhost as the hostname are now visible in the
-web UI, making it easier to copy and paste SSH URLs when debugging
-fetching of changes.
-
-* issue 490 Try Titlecase class name first when launching programs
-+
-Launching daemon or init from the classes directory on a case
-insensitive filesystem like Mac OS X HFS+ or Windows NTFS failed.
-Fixed.
-
-* Misc. license issues
-+
-The CDDL javax.servlet package was replaced by an Apache License 2.0
-implementation from the Apache Foundation.  The unnecessary OpenXRI
-package, which was never even included in the distribution, was
-removed from the license file.
-
-
-== Schema Changes in Detail
-
-* Remove Project.Id and use only Project.NameKey
-+
-The project_id column was dropped from the projects table, and all
-associated subtables, and only the name is now used to link records
-in the database.  This simplifies the schema for eventual changes
-onto less-traditional storage systems.
-
-* Move sshUserName from Account to AccountExternalId
-+
-The ssh\_user\_name column in accounts was moved to an additional row
-in account\_external\_ids, using external\_id prefix `username:`.
-This removes the non-primary key unique index from the table, making
-it easier to move to less traditional storage systems.
-
-* Replace all transactions with single row updates
-+
-Schema update operations have been reworked to not require multi-row
-transaction support in the database.  This makes it easier to port
-onto a distributed storage system where multi-row atomic updates
-aren't possible, or to run on MySQL MyISAM tables.
-
-
-== Other Changes
-* Update gwtorm to 1.1.4-SNAPSHOT
-* Add unique column ids to every column
-* Remove unused byName @SecondaryKey from ApprovalCategory
-* Remove @SecondaryKey from AccountGroup
-* documentation: Remove mention of mysql_nextval.sql script
-* Drop MySQL function nextval_project_id
-* documentation: Remove project_id from manual insert
-* Update JGit to 0.5.1.106-g10a3391
-* Split the core receive logic out of the SSH code
-* Move toProject into PageLinks for reuse
-* Correct SSH Username to be just Username
-* Don't display the magic username identity on the identities tab
-* Show Status column header on the SSH key table
-* Queue smart HTTP requests alongside SSH requests
-* Add a password field to the account identities
-* Authenticate /p/ HTTP and SSH access by password
-* Advertise the smart HTTP URLs to references
-* Refactor the SSH session state
-* Fixing Eclipse settings file
-* Add --commit to comment-added as there was previously no way to kno...
-* Fix imports inside of PatchScreen.java
-* Fix crash while loading project Access tab
-* Replace our own @Nullable with javax.annotation.Nullable.
-* Correctly hide delete button on inherited permissions
-* Allow per-branch OWN +1 to delegate branch ownership
-* Block inheritance by default on per-branch permissions.
-* Simplify FunctionState as discussed previously
-* Restore delete right checkboxes in wild card project
-* issue 393 Require branch deletion permission for pushes over HTTP
-* issue 399 Update JGit to 0.5.1.140-g660fd39
-* Add standard eclipse generated files to .gitignore
-* Don't reformat the source if the files are identical
-* Fix schema 27 upgrade for H2
-* Update JGit to 0.5.1.141-g3eee606
-* Manage database connections directly in PatchScriptFactory
-* issue 425 Update user documentation to explain branch access control
-* Update to gwtjsonrpc 1.2.2-SNAPSHOT
-* Allow refs/* pattern on new reference rights
-* Trim reference name from user when adding access right
-* Execute Git commands with AccessPath.GIT
-* Update to GWT 2.0.1
-* Update to Ehcache 1.7.2
-* Update to mime-util 2.1.3
-* Update to H2 1.2.128
-* issue 442 Fix IncorrectObjectTypeException on initial commit
-* Compute allowed approval categories separately.
-* Move new change display to PostReceiveHook
-* Drop unused formatLanguage property from patch table
-* issue 447 documentation: Improve Apache mod_proxy configuration
-* issue 445 Fix whitespace errors with word diff enabled
-* issue 439 Move syntax highlighting back to client
-* Remove Mozilla Rhino from our build
-* Add missing step to add gwtui_dbg configuration
-* Remove useless imports from Schema_28
-* Fix upgrading H2 from schema 20 to current
-* Move release notes into the repository
-* issue 454 documentation: Improve bugzilla link example to include #
-* Drop unused err PrintWriter in Receive
-* documentation: Describe how to do case insensitive commentlink
-* Add patch releases to release notes
-* Update to gwtorm 1.1.4, gwtjsonrpc 1.2.2, gwtexpui 1.2.1
-* Update to GWT 2.0.2
-* documentation: Remove stupid ReleaseNotes build rules
-* documentation: Use a per-version directory
-* Draft 2.1.2 release notes
-* documentation: Fix version number to only consider x.y.z format
-* Drop XRI related support from our notices list
-* documentation: Correct sorting error in notices
-* documentation: Add JSR 305 and AOP Alliance to licenses
-* documentation: Correct links to the MPL 1.1 license
-* Replace CDDL javax.servlet with APLv2 implementation
-* documentation: Document database.pool* variables
-* Update 2.1.2 release notes to mention juniversalchardet
-* Fix whitespace ignore feature
-* Fix database connection leak in git-receive-pack
-* Delay marking a file reviewed until its displaying
-* Simplify patch display to a single RPC
-* Fix missing right side border of history, dependency tables
-* Cleanup useless leftmost/rightmost CSS classes
-* Don't RPC to load the full file if we already have it
-* Add Forge Identity +3 to permit pushing filtered history
-* Fix source code formatting in RefControl
-* Fix combined diffs on merge commits
-* Fix SparseFileContent for delete-only patches
-* Simplify some CSS rules for side-by-side viewer
-* Color entire replace block same background shade
-* Cleanup CSS for side-by-side view when there are character differen...
-* documentation: Fix typo on the word database
-* Always use class wdc on replace line common sections
-* Fix side-by-side table header CSS glitch
-* Fix file line padding in side-by-side viewer
-* Improve the way inline comments are shown
-* Fix side by side view column headers to use normal font
-* Tweak the intraline difference heuristics
-* Refactor and add to streaming events schema
-* Documentation schema for stream-events command
-* Fix source code formatting errors in MergeOp
-* Cleanup display of branches panel when gitweb isn't configured
-* Fix "Show Tabs" checkbox
-* Update 2.1.2 release notes
-* Reorganize 2.1.2 release notes into categories
-* Hide syntax highlighting checkbox in unified view
-* Change default tab width to 8
-* Ensure drafts redisplay when refreshing the page
-* Fix tab marker RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK
-* issue 473 Don't aggressively coalesce across lines
-* Fix intraline difference off-by-one when LF is added
-* Mark add or delete regions with darker colors
-* Invalidate the diff cache
-* Fix build breakage due to missing constants
-* Fix editable username when authType is LDAP or HTTP_LDAP
-* issue 481 Fix enter with completion in add reviewer box
-* Make intraline differences easier to debug
-* Avoid "es" replaced by "es = Address"
-* Cleanup line insertions joined against indentation change
-* Change become to use user_name field
-* Stop leaking patch controls CSS to other widgets
-* Fix coloring of tab markers in syntax highlighting
-* Fix toggling syntax highlighting on partial file
-* Permit use of syntax highlighting in unified view
-* Use hunk background colors on unified views with syntax highlighting
-* Fix source code formatting in ApproveCommand.java
-* issue 483 Log the type of a non-task after it executes
-* Update to GWT 2.0.3
-* issue 489 Drop host name resolution failure test
-* issue 483 Remove reliance on afterExecute from WorkQueue
-
-71b04c00b174b056ed2579683e2c1546d156b75a
diff --git a/ReleaseNotes/ReleaseNotes-2.1.3.txt b/ReleaseNotes/ReleaseNotes-2.1.3.txt
deleted file mode 100644
index 6226b93..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.3.txt
+++ /dev/null
@@ -1,299 +0,0 @@
-= Release notes for Gerrit 2.1.3
-
-Gerrit 2.1.3 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Schema Change
-
-*WARNING* This release contains multiple schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-
-== New Features
-
-=== Web UI
-
-* issue 289 Remove reviewers (or self) from a change
-+
-Project and change owners can now remove any reviewer from a change
-by clicking an "X" next to their name in the approval table.
-Individual users can also remove themselves from any change.
-This feature permits users to stop getting notified about a change
-they no longer have an interest in, but had commented on previously.
-
-* issue 124 Index changes by external issue tracking id numbers
-+
-Changes can be searched for by an external issue tracking system's
-id numbers.  Site administrators can configure trackingid sections in
-gerrit.config to parse and extract issue tracking links from a commit
-message's footer, and have them indexed by Gerrit.  Users can search
-for relevant changes using the search operator `tr:` or `bug:`,
-for example `tr:432181` or `bug:JIRA-42`.  Administrators can index
-existing change records using the ScanTrackingIds program.
-
-* List branches/tags containing a merged change
-+
-Merged change pages now display a new expandable section, 'Included
-In', listing all branches and tags that contain the change.
-
-* issue 391 Reduce clicks need to approve and submit
-+
-Users who have Submit +1 permission for a change can now click
-'Publish Comments and Submit' on the publish comments screen,
-combining the 'Publish Comments' and 'Submit Patch Set n' actions
-into a single click.
-
-* Simplify setup of non-range access such as Submit
-+
-If an access control doesn't really make sense as a range of values,
-Gerrit now displays only one box to select the maximum permitted
-value from, rather than two boxes to set the min/max.
-
-* Make Admin > Projects UI accessible to all users
-+
-All projects that are visible to the current user are now listed
-in the Admin > Projects page, as are the project's Branches and
-Access tabs.  Editing is obviously disabled, unless the user has
-owner level access to the project, or one of its branches.
-
-=== Access Controls
-
-* Branch-level read access is now supported
-+
-Project owners/administrators can now use the access tab to
-control which groups can read certain branches, enabling hidden
-branches within a more widely visible project.  Additionally,
-replication.config honors these settings through the authGroup
-variable, allowing a server administrator to limit which branches
-are replicated to certain mirrors.
-
-* issue 273 Inherit project permissions from more than just All Projects
-+
-Projects can now be organized into an inheritance hierarchy, allowing
-administrators to cluster common access rules for different groups
-of projects.  The create-project command learned a new \--parent
-option to set the hierarchy immediately.
-
-* auth.allowedOpenID can limit which providers can be used
-+
-Administrators can now set auth.allowedOpenID in gerrit.config
-to restrict which OpenID provider(s) a user can use to register
-for an account.  This may be useful to restrict login to only the
-organization's local provider, or a single trusted 3rd party.
-
-* Branch-level access control is now inherited by default
-+
-Previously branch level access controls were exclusive, locking out
-all other groups that may have been inherited from All Projects,
-or through a wildcard like 'refs/heads/*'.  Branch access is now
-inherited by default, but the old exclusive behavior can be obtained
-by prefixing the reference with '-'.
-
-=== SSH Commands
-
-* create-account: Permit creation of batch user accounts over SSH
-* issue 269 Enable create-project for non-Administrators
-
-* ls-projects: New -b option displays the sha1 of each branch
-* ls-projects: New -t option shows the project hierarchy
-
-* gerrit show-queue is now accessible to all users
-+
-Results are filtered to display only queue entries that are operating
-on projects the user is permitted to see.  Replication URLs are
-masked for non-admin users, and instead display the remote name
-from the replication.config file.
-
-* issue 310 review \--submit: Submit a change over SSH
-+
-Changes can now be submitted over SSH by using the new \--submit
-command line flag to gerrit review.
-
-* gerrit approve deprecated
-+
-To support the new \--submit flag, gerrit approve has been renamed
-to gerrit review, better matching the web UI name for the concept.
-The old `gerrit approve` name will be kept around as an alias to
-provide time to migrate hooks/scripts/etc.
-
-=== Hooks / Stream Events
-
-* \--change-url parameter passed to hooks
-+
-The change URL was supplied in the stream-events feed, but was
-not passed into hooks, making it difficult for a hook to send a
-notification email with a link back to Gerrit.  Fixed by adding
-the parameter.
-
-* Patch set uploader passed to hooks
-+
-The identity of the user who uploaded a patch set was added as both
-a parameter to patchset-created hook, and to the patch set entity
-sent through stream-events.
-
-* issue 506 stream-events: Include the ref in patch sets
-+
-The reference (e.g. 'refs/changes/12/812/2') to download a patch
-set is now included in the stream-events record, making it possible
-for a monitor to easily pull down a patch set and compile it.
-
-=== Contrib
-
-* Example hook to auto-re-approve a trivial rebase
-
-=== Misc.
-
-* transfer.timeout: Support configurable timeouts for dead clients
-+
-Sometimes `repo sync` can leave dead connections open to Gerrit Code
-Review, resulting in worker threads that are tied up indefinitely,
-waiting for client IO that will never occur.  Administrators may set
-transfer.timeout to place an upper bound on how long the server will
-wait for the client before aborting the connection and releasing
-the worker thread back into the pool.
-
-* container.slave: Automatically enable --slave
-+
-Adminstrators can now add `container.slave = true` to their slave's
-gerrit.config file, avoiding the need to make sure they always
-pass the --slave flag on the command line when starting their
-slave server.
-
-* Add separate task queue for non-interactive users
-+
-Users who are a member of the special 'Non Interactive Users' group
-can now have all of their SSH commands scheduled onto a different
-thread pool than everyone else.  If enabled, this feature can help
-ensure quick response time for normal users when the system is
-heavily loaded by batch tasks.
-
-* Explain a remote rejection of a non-fast-forward
-+
-If the remote peer rejected a non-fast-forward replication, make
-it clear that it was the remote that rejected the push, and not
-Gerrit Code Review's client logic.  The error is often caused by
-the remote repository having receive.denyNonFastForwards being set
-to true in $GIT_DIR/config.  Gerrit's error log message now hints
-at checking this setting on the remote repository.
-
-* Internal dependencies updated
-+
-Updated JGit to 0.8.4, Jetty to 7.0.2.v20100331, H2 database to
-1.2.134, Apache Commons Codec to 1.4, Apache Commons Net to 2.1,
-Apache Commons DBCP to 1.4.
-
-
-== Bug Fixes
-
-=== Web UI
-
-* issue 396 Prevent 'no-score' approvals from being recorded
-+
-Change messages no longer say 'No score; no score' when the user
-has not selected a particular approval setting.
-
-* issue 396 Summarize the number of inline comments
-+
-A change message is now always recorded at the top level of a change
-anytime inline comments are published, even if no score change
-took place, and no cover letter was supplied by the user. The
-auto-generated message is a one line summary indicating how many
-inline comments were published at that time.  This makes it easier
-to see what has occurred on the change.
-
-* issue 461 Space out Review and Submit Patch Set buttons
-+
-The risk of clicking 'Submit Patch Set n' when the user meant to
-click 'Review' has been reduced by spacing the buttons further apart.
-
-* issue 587 Fix user site header/footer preference
-+
-The user preference to hide the site header/footer wasn't always
-being applied.  Fixed.
-
-* issue 575 Require branches to always start from commits
-+
-Branches could be created starting from annotated tags, resulting
-in crashes when a change gets submitted to the branch.  Fixed by
-ensuring branches always start from commits.
-
-* issue 574 Add Cancel button to Register New Email dialog
-+
-Users couldn't (easily) get out of the dialog popped up by the
-'Register New Email...' button.  A cancel button was added to
-close the dialog.
-
-=== Server Programs
-
-* init: Import non-standardly named Git repositories
-+
-When scanning for projects, any directory that is a valid Git
-repository is now imported, even if its name does not end with
-the standard '.git' suffix.
-
-* issue 460 gerrit.sh: Request at least 1024 file descriptors
-+
-In the default configuration, Gerrit Code Review started with a
-hard limit of 256 file descriptors, which is too small for any site.
-This caused a number of failures, and a number of bugs were filed.
-The default has been raised to 1024.
-
-* issue 578 Improve schema version update by avoiding early pruning
-+
-Previously init kept trying to remove unused tables or columns
-during each schema upgrade step.  These removes are now deferred
-until the last step.
-
-* review: Actually log an internal server error's root cause
-+
-Internal server failures (such as database connectivity errors)
-were not properly logged by `gerrit approve` (now gerrit review).
-Fixed by logging the root cause of the failure.
-
-=== Configuration
-
-* Display error when HTTP authentication isn't configured
-+
-Error reporting for a failed login attempt when auth.type is HTTP
-and the HTTP server isn't supplying the expected header is now more
-explicit about describing the problem.  This helps new site setups,
-but doesn't have any impact on an existing site.
-
-* Fix javax.naming.PartialResultException: Unprocessed Continuation
-+
-LDAP directory trees that require following a referral in order
-to lookup a name usually failed with the above Java exception
-during sign-in.  Administrators can enable following by adding
-`ldap.referral = follow` to their gerrit.config file.
-
-=== Documentation
-
-* documentation: Clarified the ownership of '\-- All Projects \--'
-+
-The magic project All Projects isn't allowed to have ownership
-delegated, and the documentation wasn't clear why.  Fixed by
-explaining the rationale in more detail.
-
-* issue 533 Fix JAR versions in other container installation
-+
-The installation process for putting Gerrit Code Review under a
-3rd party servlet container was out of date, as some JARs had
-the wrong versions listed.  Fixed.
-
-* suexec: Document the suexec command
-+
-The suexec command introduced in 2.1.2 was never documented.  Fixed.
-
-* Corrected Eclipse documentation on importing Maven projects
-+
-The Maven plugin changed some of its user interface, resulting in
-our step-by-step documentation being out of date.  Fixed to match
-the current stable version of the Maven plugin.
-
-
-== Version
-
-e8fd49f5f7481e2f916cb0d8cfbada79309562b4
diff --git a/ReleaseNotes/ReleaseNotes-2.1.4.txt b/ReleaseNotes/ReleaseNotes-2.1.4.txt
deleted file mode 100644
index 72eec55..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.4.txt
+++ /dev/null
@@ -1,212 +0,0 @@
-= Release notes for Gerrit 2.1.4
-
-Gerrit 2.1.4 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-== Schema Change
-
-*WARNING* This release contains multiple schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-== New Features
-
-=== Change Management
-
-* issue 504 Implement full query operators
-+
-The search box now implements a wide range of operators and boolean
-expressions, permitting complex queries such as `is:open CodeReview>=1
-(has:draft OR is:starred)` to locate open changes that have been code
-reviewed, but still have unpublished drafts or were starred by the
-current user.  The full range of supported operators is documented
-in the user guide.
-
-* Change lists now use query operators
-+
-All current change lists have been reimplemented using query
-operators, so selecting 'All open changes' actually performs the query
-'is:open'.  This is to help end-users learn the different operators
-that are supported, and simplifies the internal implementation
-considerably by removing redundant code.
-
-* issue 51 Tag changes with topic branches
-+
-Changes can be tagged with a topic name during upload.  To add the tag
-'query' when pushing to branch 'master', use `git push URL
-HEAD:refs/for/master/query`.  To add a topic name with `repo upload`
-use the `-t` command line flag.  Topic names are displayed next to the
-branch name in the web UI, and can be searched for with the `topic:`
-query operator.
-
-* Filter the list of open changes by watched projects
-+
-The query operator `is:watched` matches changes matching the user's
-watched project list, and a new menu item was added under the My menu
-to select open changes matching these watched projects.
-
-=== Web UI
-
-* issue 579 Remember diff formatting preferences
-+
-Formatting options at the top of a side-by-side or unified diff page
-are now remembered by saving the current preferences into the user's
-account whenever 'Update' is clicked.
-
-* issue 680 Show commit message on the per-file review pages
-
-* issue 498 Improved keyboard navigation
-+
-More keyboard bindings have been added, reducing the need to switch to
-the mouse while navigating through a change and performing a review.
-
-* issue 395 Open new window/new tab for all files in a change
-+
-New buttons permit opening all modified files of a change into
-new windows or tabs.
-
-* issue 440 Add copy to clipboard button for change-id
-+
-The Change-Id field in the upper left side of a change now support to
-copy "Change-Id: I...." onto the clipboard, making it easier to paste
-into a commit message.
-
-* issue 559 Allow copying user public ssh key to clipboard
-
-* issue 509 Make branch columns link to changes on that branch
-
-=== Email Notifications
-
-* issue 311 No longer CC a user by default
-+
-The user who causes a notification to be sent is no longer CC'd on the
-email when it is sent.  This reduces the number of messages sent to a
-user, but can be re-enabled through a checkbox in the Settings >
-Preferences panel.
-
-* issue 535 Enable watching of all projects
-+
-Adding the magic `\-- All Projects \--` to the watched project list
-permits the user to be notified of any change occurring in any
-project.  Project specific entries override the notification settings
-for all projects.
-
-* issue 492 Allow watching specific branches or any other search query
-+
-In addition to watching a project, users can register a query string
-to match specific changes, reducing notifications to be a smaller
-subset of the changes that occur in a project.
-
-* issue 70 Allow file:^regex to match affected files
-+
-The file:^path operator can be used in a watch filter to receive
-notifications only when files matching the regular expression are
-modified by the change.
-
-* issue 623 Include Gerrit-Owner, Gerrit-Reviewer in email footers
-+
-New fields in the email footer provide additional detail, enabling
-better filtering and classification of messages.
-
-=== Access Control
-
-* Support regular expressions for ref access rules
-+
-References in an access rule can now be specified by regular
-expression by prefixing the reference name with ^.
-
-* issue 577 Support $\{username\} in access rules
-+
-Adding `$\{username\}` into a reference causes the current username to
-be inserted at that position.  When combined with the Push Branch
-permission this creates a per-user branch namespace feature, giving
-each user their own "sandbox" to push changes to.
-
-* issue 313 ssh gerrit create-group
-+
-Groups can now be created over SSH by administrators using the
-`gerrit create-group` command.
-
-=== Authentication
-
-* Remove password authentication over SSH
-+
-Adding password authentication over SSH turned out to be a major
-mistake.  Users primarily use SSH public keys, and the password
-prompt just got in the way or confused them.  Password support has
-been removed from the SSH server.
-
-* Username cannot be changed once assigned
-+
-Once a username has been selected for a user account, it
-cannot be modified by the user.
-
-* issue 555 Make LDAP sessions persistent for the session age
-+
-Web sessions are now persistent for the cache.web_sessions.maxAge
-setting, rather than expiring when the browser closes.  (Previously
-sessions expired when the browser exited.)
-
-=== Misc.
-
-* Add topic, lastUpdated, sortKey to ChangeAttribute
-+
-Additional change fields are now exported as part of the
-stream-events output.
-
-* issue 504 gerrit query SSH command
-+
-Queries to lookup change information can be executed over SSH through
-the `gerrit query` command, with results output in either human
-readable text or machine readable JSON.  Change queries can also be
-run over HTTP with the `/query?q=<query>&format=JSON` URL.  Both
-interfaces are intended for automated tools.
-
-* Remove git diff-tree dependency
-+
-Gerrit no longer requires `git` in the PATH; differences are now
-constructed in pure Java code.  Remote repository initialization over
-SSH still requires `git` on the remote host's PATH.
-
-* Internal dependencies updated
-+
-Updated JGit to 0.8.4.89-ge2f5716, log4j to 1.2.16, GWT to 2.0.4,
-sfl4j to 1.6.1, easymock to 3.0, JUnit to 4.8.1.
-
-== Bug Fixes
-
-=== Web UI
-
-* issue 352 Confirm branch deletion in web UI
-+
-Deleting a branch now presents a confirmation dialog to give the user
-a second chance to abort the destructive operation.
-
-* Fix some JavaScript errors under Chrome
-+
-The GWT compiler started to define symbols in the same namespace as
-the prettify syntax highlighting library.  We moved the prettify
-library into its own iframe so it has a different JavaScript namespace
-in the browser.
-
-* Close button on OpenId register / sign-in dialog
-+
-There was no obvious way to leave the sign-in dialog. Fixed.
-
-* Links in OpenId sign-in dialog not focusable
-+
-Keyboard navigation to standard links like 'Google Accounts'
-wasn't supported.  Fixed.
-
-=== Misc.
-
-* issue 614 Fix 503 error when Jetty cancels a request
-+
-A bug was introduced in 2.1.3 that caused a server 503 error
-when a fetch/pull/clone or push request timed out.  Fixed.
-
-== Version
-
-ae59d1bf232bba16d4d03ca924884234c68be0f2
diff --git a/ReleaseNotes/ReleaseNotes-2.1.5.txt b/ReleaseNotes/ReleaseNotes-2.1.5.txt
deleted file mode 100644
index 88288e2..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.5.txt
+++ /dev/null
@@ -1,162 +0,0 @@
-= Release notes for Gerrit 2.1.5
-
-Gerrit 2.1.5 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.1.5.war[https://www.gerritcodereview.com/download/gerrit-2.1.5.war]
-
-This is primarily a bug fix release to 2.1.4, but some additional
-new features were included so its named 2.1.5 rather than 2.1.4.1.
-
-== Upgrade Instructions
-
-If upgrading from version 2.1.4, simply replace the WAR file in
-`'site_path'/bin/gerrit.war` and restart Gerrit.
-
-If upgrading from version 2.1.3 or earlier, stop Gerrit, use
-`java -jar gerrit.war init -d 'site_path'` to upgrade the schema,
-and restart Gerrit.
-
-== New Features
-
-=== Web UI
-* issue 361 Enable commenting on commit messages
-+
-The commit message of a change can now be commented on inline, and
-even compared between patch sets, just like any other file contents.
-The message is presented as a magical file called 'Commit Message',
-in the first row of every change.
-
-* issue 312 Implement 'Restore Change' to undo 'Abandon Change'
-+
-Any user who can abandon a change (the change owner, project owner,
-or any site administrator) can now restore the change from Abandoned
-status back to Review in Progress.
-
-* issue 583 Enable/disable download protocols
-+
-The new download section in `gerrit.config` controls how the patch
-set download links are presented in the web UI.  Administrators
-can use this section to enable `repo download`, `git://`, or to
-disable `http://` style URLs.  This section replaces the older
-repo.showDownloadCommand.
-
-* issue 499 Display the size of a patch (lines added/removed)
-+
-A 'diffstat' is shown for each file, summarizing the size of the
-change on that file in terms of number of lines added or deleted.
-
-=== Email Notifications
-* issue 452 Include a quick summary of the size of a change in email
-+
-After the file listing, a summary totaling the number of files
-changed, lines added, and lines removed is displayed.  This may
-help reviewers to get a quick estimation on the time required for
-them to review the change.
-
-== Bug Fixes
-
-=== Web UI
-* issue 639 Fix keyboard shortcuts under Chrome/Safari
-+
-Keyboard shortcuts didn't work properly on modern WebKit browsers
-like Chrome and Safari.  We kept trying to blame this on the browser,
-but it was Gerrit Code Review at fault.  The UI was using the wrong
-listener type to receive keyboard events in comment editors.  Fixed.
-
-* Make 'u' go up to the last change listing
-+
-Previously the 'u' key on a change page was hardcoded to take
-the user to their own dashboard.  However, if they arrived at the
-change through a query such as `is:starred status:open`, this was
-quite annoying, as the query had to be started over again to move
-to the next matching change.  Now the 'u' key goes back to the
-query results.
-
-* issue 671 Honor user's syntax coloring preference in unified view
-+
-The user's syntax coloring preference was always ignored in the
-unified view, even though the side-by-side view honored it.  Fixed.
-
-* issue 651 Display stars in dependency tables
-+
-The 'Depends On' and 'Needed By' tables on a change page did not
-show the current user's star settings, even though the star icon
-is present and will toggle the user's starred flag for that change.
-Fixed.
-
-=== Access Control
-* issue 672 Fix branch owner adding exclusive ACL
-+
-Branch owners could not add exclusive ACLs within their branch
-namespace.  This was caused by the server trying to match the leading
-`-` entered by the branch administrator against patterns that did
-not contain `-`, and therefore always failed.  Fixed by removing
-the magical `-` from the proposed new specification before testing
-the access rights.
-
-* '@' in ref specs shouldn't be magical.
-+
-The dk.brics.automaton package that is used to handle regular
-expressions on branch access patterns supports '@' to mean
-"any string".  We don't want that behavior.  Fixed by disabling
-the optional features of dk.brics.automaton, thereby making '@'
-mean a literal '@' sign as expected.
-
-* issue 668 Fix inherited Read Access +2 not inheriting
-+
-Upload access (aka Read +2) did not inherit properly from the parent
-project (e.g. '\-- All Projects \--') if there was any branch level
-Read access control within the local project.  This was a coding
-bug which failed to consider the project inheritance if any branch
-(not just the one being uploaded to) denied upload access.
-
-=== Misc.
-* issue 641 Don't pass null arguments to hooks
-+
-Some hooks crashed inside of the server during invocation because the
-`gerrit.canonicalWebUrl` variable wasn't configured, and the hook
-was started out of an SSH or background thread context, so the URL
-couldn't be assumed from the current request.  The bug was worked
-around by not passing the `\--change-url` flag in these cases.
-Administrators whose hooks always need the flag should configure
-`gerrit.canonicalWebUrl`.
-
-* issue 652 Fix NPE during merge failure on new branch
-+
-Submitting a change with a missing dependency to a new branch
-resulted in a NullPointerException in the server, because the server
-tried to create the branch anyway, even though there was no commit
-ready because one or more dependencies were missing.  Fixed.
-
-* Fix NPE while matching `file:^` pattern on deleted files
-+
-Sending email notifications crashed with NullPointerException if the
-change contained a deleted file and one or more users had a project
-watch on that project using a `file:^` pattern in their filter.
-Fixed.
-
-* issue 658 Allow to use refspec shortcuts for push replication
-+
-A push refspec of `refs/heads/\*` in replication.config is now
-supported as a shorthand notation for `refs/heads/\*:refs/heads/\*`.
-
-* issue 676 Fix clearing of topic during replace
-+
-The topic was cleared if a replacement patch set was uploaded without
-the topic name.  The topic is now left as-is during replacement
-if no new topic was supplied.  If a new topic is supplied, it is
-changed to match the new topic given.
-
-* Allow ; and & to separate parameters in gitweb
-+
-gitweb.cgi accepts either ';' or '&' between parameters, but
-Gerrit Code Review was only accepting the ';' syntax.  Fixed
-to support both.
-
-=== Documentation
-* Fixed example for gerrit create-account.
-* gerrit.sh: Correct /etc/default path in error message
-
-== Version
-
-2765ff9e5f821100e9ca671f4d502b5c938457a5
diff --git a/ReleaseNotes/ReleaseNotes-2.1.6.1.txt b/ReleaseNotes/ReleaseNotes-2.1.6.1.txt
deleted file mode 100644
index 4626c7b..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.6.1.txt
+++ /dev/null
@@ -1,59 +0,0 @@
-= Release notes for Gerrit 2.1.6.1
-
-Gerrit 2.1.6.1 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.1.6.1.war[https://www.gerritcodereview.com/download/gerrit-2.1.6.1.war]
-
-== Schema Change
-
-If upgrading from 2.1.6, there are no schema changes.  Replace the
-WAR and restart the daemon.
-
-If upgrading from 2.1.5 or earlier, there are schema changes.
-To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-== New Features
-* Display the originator of each access rule
-+
-The project access panel now shows which project each rule inherits
-from.  This can be informative when the inheritance chain is more
-than 1 project deep (for example C inherits from B, which inherits
-from A, which inherits from \-- All Projects \--).
-
-* Improved user->gerrit push speed
-+
-Pushing changes for review (or directly to a branch) should be
-quicker now, especially if the project contains many changes.
-
-* Allow Owner permission to inherit
-+
-The project Owner permission can now be inherited from any parent
-project, provided that the parent project is not the root level
-\-- All Projects \--.
-
-== Bug Fixes
-* Fix disabled intraline difference checkbox
-+
-Intraline difference couldn't be enabled once it was disabled by
-a user in their user preferences.  Fixed.
-
-* Fix push over HTTP
-+
-Users couldn't push to Gerrit over http://, due to a bug in the
-way user authentication was handled for the request.  Fixed.
-
-* issue 751 Update displayed owner group after group rename
-+
-The group owner field didn't update when a group was self-owned,
-and the self-owned group was renamed.  This left the owner name
-at the old name, leaving the user to wonder if the group owner was
-also reassigned by another user.  Fixed.
-
-* init: Fix string out of bounds when importing projects
-+
-Project importing died when the top level directory contained a
-".git" directory (usually by accident by the site administrator).
-Fixed.
diff --git a/ReleaseNotes/ReleaseNotes-2.1.6.txt b/ReleaseNotes/ReleaseNotes-2.1.6.txt
deleted file mode 100644
index 83689e7..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.6.txt
+++ /dev/null
@@ -1,307 +0,0 @@
-= Release notes for Gerrit 2.1.6
-
-Gerrit 2.1.6 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.1.6.war[https://www.gerritcodereview.com/download/gerrit-2.1.6.war]
-
-== Schema Change
-
-*WARNING* This release contains multiple schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-
-== New Features
-
-=== Web UI
-* issue 312 Abandoned changes can now be restored.
-* issue 698 Make date and time fields customizable
-* issue 556 Preference to display patch sets in reverse order
-* issue 584 Allow deleted and/or uncommented files to be skipped
-
-* Use HistogramDiff for content differences
-+
-HistogramDiff is an adaptation of Bram Cohen's Patience Difference
-algorithm, and was recently included in the upstream JGit project.
-Patience Difference tends to produce more readable differences for
-source code files, and JGit's HistogramDiff implementation tends to
-run several times faster than the prior Myers O(ND) algorithm.
-
-* Automatic merge file content during submit
-+
-Project owners can now enable file-level content merge during submit,
-allowing Gerrit to automatically resolve many path conflict cases.
-This is built upon experimental merge code inherited from JGit,
-and is therefore still experimental in Gerrit.
-
-=== Change Query
-* issue 688 Match branch, topic, project, ref by regular expressions
-+
-Similar to other features in Gerrit Code Review, starting any of these
-expressions with \^ will now treat the argument as a regular
-expression instead of an exact string match.
-
-* Search changes by commit messages with `message:` operator.
-
-* issue 729 query: Add a \--all-approvals option to queries
-+
-The new flag includes approval information for all patch sets in the
-resulting query output.
-
-Notifications
-~~~~~~~~~~~~
-* Customize email notification templates
-+
-Email notifications are now driven by the Velocity template engine,
-and may be modified by the site administrator by editing a template
-file under `'$site_path'/etc/mail`.
-
-* issue 311 Clarify email texts/subject
-+
-The default email notification formatting was changed to make the
-subject lines and message bodies more consistent, and easier to
-understand.
-
-* issue 204 Add project list popup under Settings > Watched Projects
-+
-The project list panel makes it easier for users to browse all
-projects they have at least READ +1 access to, and add them to their
-watched project set so notifications can be configured.
-
-* stream-event support for all ref-update events
-+
-Whenever a ref is updated via either a direct push to a branch or a
-Gerrit change submission, Gerrit will now send a new "ref-updated"
-event to the event stream.
-
-=== User Management
-* SSO via client SSL certificates
-+
-A new auth.type of CLIENT_SSL_CERT_LDAP supports authenticating users
-using client SSL certificates.  This feature requires using the
-embedded Jetty web server with SSL enabled, and an LDAP directory to
-lookup individual account information.
-
-* issue 503 Inactive accounts may be disabled.
-+
-Administrators can manually update the accounts table, setting
-inactive = `Y` to mark user accounts inactive.  Inactive accounts
-cannot sign-in, cannot be added as a reviewer, and cannot be added
-to a group.
-
-* Improve the no-interactive-shell error message over SSH
-+
-Instead of giving a short 'no shell available' error, Gerrit Code
-Review now prints a banner letting the user know they have
-authenticated successfully, interactive shells are disabled, and how
-to clone a hosted project:
-+
-----
-$ ssh -p 29418 review.example.com
-
-  ****    Welcome to Gerrit Code Review    ****
-
-  Hi A. U. Thor, you have successfully connected over SSH.
-
-  Unfortunately, interactive shells are disabled.
-  To clone a hosted Git repository, use:
-
-  git clone ssh://author@review.example.com:29418/REPOSITORY_NAME.git
-
-Connection to review.example.com closed.
-----
-
-* Configure SSHD maxAuthTries, loginGraceTime, maxConnectionsPerUser
-+
-The internal SSH daemon now supports additional configuration
-settings to reduce the risk of abuse.
-
-=== Administration
-* issue 558 Allow Access rights to be edited by clicking on them.
-
-* New 'Project Owner' system group to define default rights
-+
-The new system group 'Project Owners' can be used in access
-rights to mean any user that is a member of any group that
-has the 'Owner' access category granted within that project.
-This system group is primarily useful in higher level projects
-such as '\-- All Projects \--' to define standard access rights
-for all project owners.
-
-* issue 557 Allow rejection of changes without Change-Id line.
-+
-Project owners can set a flag to require all commits to include
-the Gerrit specific 'Change-Id: I...' line during initial upload,
-reducing the risk of confusion when amends need to occur to
-incorporate reviewer feedback.
-
-* issue 613 create-project: Add --permissions-only option
-+
-The new flag skips creating the associated Git repository, making the
-new project suitable for use as a parent to inherit permissions from.
-
-* create-project: Optionally create empty initial commit
-+
-The `repo` tool used by Android doesn't like to clone an empty Git
-repository, making it difficult to setup a review for the initial file
-contents.  create-project can now optionally create an empty initial
-commit, permitting repo to sync the empty project.
-
-* Block off commands on a server for certain user groups.
-+
-The upload.allowGroup and receive.allowGroup settings in gerrit.config
-can be used to restrict which users can perform git clone/fetch or git
-push on this server.  This can be useful if clone/fetch should be
-limited to only site administrators, while normal users are supposed
-to use to less expensive mirror servers.
-
-* issue 685 Define gerrit.replicateOnStartup to control replication
-+
-The automatic replicate every project action that occurs during server
-startup can now be disabled by setting replicateOnStartup = false.
-This is primarily useful for sites with extremely large numbers of
-projects and replication targets, but runs the risk of having a target
-be out of date relative to the master server.
-
-* New non-blocking function category "NoBlock"
-+
-Site defined approval categories may now use the function "NoBlock"
-to permit scoring without blocking submission.  This is mostly
-useful for automated tools to provide optional feedback on a change.
-
-* Ability to reject commits from entering repository
-+
-The Git-note style branch `refs/meta/reject-commits` can be created
-by the project owner or site administrator to define a list of
-commits that must not be pushed into the repository.  This can be
-useful after performing a project-wide filter-branch operation to
-prevent the older (pre-filter-branch) history from being reintroduced
-into the repository.
-
-== Bug Fixes
-
-=== Web UI
-* issue 498 Enable Keyboard navigation after change submit
-* issue 691 Make ']' on last file go up to change
-* issue 741 Make ENTER work for 'Create Group'
-* issue 622 Denote a symbolic link in side-by-side viewer
-* issue 612 Display empty branch list when project has no repository
-* issue 672 Fix deleting exclusive branch level rights
-* issue 645 Display 'No difference' between unchanged patchsets
-* Display groups as links to group information
-* Remove ctrl-d keybinding to discard comment, honor browser default
-* Do not auto enable save buttons, wait for changes to be made
-* Disable 'Create Group' button if group name not entered
-* Show commit message in PatchScreen if old patch sets are compared
-* Fixed a number of focus and shortcut bugs in Firefox, Chrome
-
-* issue 487 Work around buggy MyersDiff by killing threads
-+
-MyersDiff sometimes locked up in an infinite loop when computing
-the intraline difference information for a file.  These threads
-are now killed after an administrator specified timeout
-(cache.diff_intraline.timeout, default is 5 seconds).  If the
-timeout is reached the file content is displayed without intraline
-differences.  This offers reduced functionality to the end-user, but
-prevents the "path of death" which usually took down a Gerrit server.
-
-* Hide access rights not visible to user
-+
-Users were able to view access rights for branches they didn't
-actually have READ +1 permission on.  This may have leaked
-information about branches and/or groups to users that shouldn't
-know about code names contained within either string.  Users that
-are not project owners may now only view access rights for branches
-they have at least READ +1 permission on.
-
-=== Change Query
-* issue 689 Fix age:4days to parse correctly
-* Make branch: operator slightly less ambiguous
-
-=== Push Support
-* issue 695 Permit changing only the author of a commit
-+
-Correcting only the author of a change failed to upload the new patch
-set onto the existing change, as neither the message nor the files
-were modified.  Fixed.
-
-* issue 576 Allow Push Branch +3 to force replace a tag
-+
-Previously it was not possible to replace a tag object, even if
-`git push \--force` was used.  Fixed.
-
-* issue 690 Refuse to run receive-pack if refs/for/branch exists
-+
-If a server repository was corrupted by an administrator manually
-creating a reference within the magical refs/for/ namespace, Gerrit
-became confused when changes were uploaded for review.  If this case
-occurs push now aborts very early, with a clear error message
-indicating the problem.  To recover an administrator must clear the
-refs/for/ namespace manually.
-
-* Allow receive-pack without Read +2 but with Push Head +1
-+
-Users who had direct branch push permission but lacked the ability to
-create changes for review were unable to push to a project.  Fixed.
-This (finally) makes Gerrit a replacement for Gitosis or Gitolite.
-
-=== Replication
-* issue 683 Don't assume authGroup = "Registered Users" in replication
-+
-Previously a misconfigured authGroup in replication.config may have
-caused the server to assume "Registered Users" instead of the group(s)
-admin actually wanted.  This may have caused the replication to see
-(or not see) the correct set of projects.
-
-* issue 482 Upon replication fail, automatically retry later
-+
-If replication fails (for example due to temporary network
-connectivity problems), other pending replication events to the
-same server are deferred and retried later until successful.
-
-* Replicate all refs received from push
-+
-Replication now replicates all references, not just those that
-appear under `refs/heads`, `refs/tags`, or `refs/changes`.  This
-fix may be relevant if the server supports user-private sandboxes
-such as `refs/dev/'$\{username\}'/*`.
-
-* issue 658 Allow refspec shortcuts (push = master) for replication
-
-=== User Management
-* Ensure proper escaping of LDAP group names
-+
-Some special characters may appear in LDAP group names, these must be
-escape when looking up the group information from JNDI, otherwise the
-lookup fails.  Fixed by applying the necessary escape sequences.
-
-* Let login fail if user name cannot be set
-+
-If the user name for a new account is supposed to import from LDAP
-but cannot because it is already in use by another user on this
-server, the new account won't be created.
-
-=== Administration
-* gerrit.sh: actually verify running processes
-+
-Previously `gerrit.sh check` claimed a server was running if the
-pid file was present, even if the process itself was dead.  It now
-checks `ps` for the process before claiming it is running.
-
-* Don't allow exclusive branch rights to block Owner inheritance
-+
-Exclusive branch level rights prevented the a higher level branch
-owner from managing the branch rights, unless they had an additional
-access right for the exclusive rights.  Now Owner inheritance cannot
-be blocked, ensuring that the higher level owner can manage their
-entire namespace.
-
-* Allow overriding permissions from parent project
-+
-Permissions in the parent project could not be overridden in the
-child project.  Permissions can now be overidden if the category,
-group name and reference name all match.
-
-== Version
-ef16a1816f293d00c33de9f90470021e2468a709
diff --git a/ReleaseNotes/ReleaseNotes-2.1.7.2.txt b/ReleaseNotes/ReleaseNotes-2.1.7.2.txt
deleted file mode 100644
index 9c9e6e1..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.7.2.txt
+++ /dev/null
@@ -1,30 +0,0 @@
-= Release notes for Gerrit 2.1.7.2
-
-Gerrit 2.1.7.2 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.1.7.2.war[https://www.gerritcodereview.com/download/gerrit-2.1.7.2.war]
-
-== Bug Fixes
-* issue 997 Resolve Project Owners when checking access rights
-+
-Members of the 'Project Owners' magical group did not always have
-their project owner privileges when Gerrit Code Review was looking
-for "access to any ref" at the project level. This was caused by
-not expanding the 'Project Owner's group to the actual ownership
-list. Fixed.
-
-* issue 999 Do not reset Patch History selection on navigation
-+
-Navigating to the next/previous file lost the setting of the
-"Old Version" made under the "Patch History" expandable control
-on a specific file view. This was accidentally broken when the
-"Old Version History" control was added to the change page. Fixed.
-
-* Fix API breakage on ChangeDetailService
-+
-Version 2.1.7 broke the Gerrit Code Review plugin for Mylyn Reviews
-due to an accidental signature change of one of the remote JSON
-APIs. The ChangeDetailService.patchSetDetail() method is back to the
-old signature and a new patchSetDetail2() method has been added to
-handle the newer calling convention used in some contexts of the
-web UI.
diff --git a/ReleaseNotes/ReleaseNotes-2.1.7.txt b/ReleaseNotes/ReleaseNotes-2.1.7.txt
deleted file mode 100644
index ad440b5..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.7.txt
+++ /dev/null
@@ -1,378 +0,0 @@
-= Release notes for Gerrit 2.1.7
-
-Gerrit 2.1.7 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.1.7.war[https://www.gerritcodereview.com/download/gerrit-2.1.7.war]
-
-== Schema Change
-*WARNING* This release contains multiple schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-To export prior review information into `refs/notes/review` branches
-within each Git repository:
-----
-  java -jar gerrit.war ExportReviewNotes -d site_path
-----
-
-== Memory Usage Increase
-*WARNING* The JGit delta base cache, whose size is controlled by
-`core.deltaBaseCacheLimit`, has changed in this release from being a
-JVM-wide singleton to per-thread. This alters the memory usage, going
-from 10M for the entire JVM to 10M per concurrent operation. The
-change improves performance on big repositories, but may need a larger
-`container.heapLimit` if the number of concurrent operations is high.
-
-== New Features
-
-=== Change Data
-* issue 64 Create Git notes for submitted changes
-+
-Git notes are automatically added to the `refs/notes/review`.
-
-=== Query
-* Search project names by substring
-+
-Entering a word with no operator (for example `gerrit`) will be
-expanded to all projects whose names contain the string 'gerrit'.
-
-* issue 722 ownerin and reviewerin search predicates
-+
-New search predicates `ownerin:'GROUP'` and `reviewerin:'GROUP'`
-search for changes whose owner or that has a reviewer in (or not
-in if prefixed with `-`) the specified group.
-
-=== Web UI
-* Add reviewer/verifier name beside check/plus/minus
-+
-Change lists (such as from a search result, or in a user's dashboard)
-can now optionally display the name of the reviewer or verifier who
-gave the score being shown in the summary column. This is an optional
-per-user preference that can be enabled in the Settings screen.
-
-* Add a "revert change"-button to a submitted patchset
-+
-Clicking "Revert Change" creates a new change with the inverse of
-the submitted patch set ready for review and submission. This makes
-it easy to undo a build-breaking change right from the web UI.
-
-* issue 194 Diff patch sets
-+
-Change pages now offer a selection box, "Old Version History",
-to compare patch sets against one another and view only the files
-that differ between two patch sets. This new feature can speed up
-re-reviewing a change.
-
-* issue 913 Support different color palette when not signed in
-+
-Site administrators can configure a different theme in gerrit.config for
-the signed-in and signed-out states, making it more obvious to site users
-they are currently signed-in (or not).
-
-* Add parent info to each change screen Patch Set
-+
-This mirrors the data shown in the 'Commit Message' file, making
-it easy to identify the parent(s) of the commit without opening
-up the Commit Message or gitweb.
-
-* Remove the SSH key loading applet
-+
-The Java based SSH key loading applet is no longer included as part of
-the Gerrit Code Review interface. Users need to copy and paste their
-SSH public key files by hand.
-
-
-=== SSH Commands
-* issue 674 Add abandon/restore to `gerrit review`
-* Add `gerrit version` command
-
-=== Change Upload
-* Display a more verbose "you are not author/committer" message
-
-=== Documentation
-* Detailed error message explanations
-+
-Most common error messages are now described in detail in the
-documentation under 'User Guide', 'Error Messages'.  Each error is
-explained, along with possible courses of action for an end-user to
-resolve the issue.
-
-* issue 905 Document reverse proxy using Nginx
-* Updated system scaling data in 'System Design'
-
-=== Outgoing Mail
-* Optionally add Importance and Expiry-Days headers
-+
-New gerrit.config variable `sendemail.importance` can be set to `high`
-or `low` to classify outgoing mail, and `sendemail.expiryDays` can be
-set to suggest clients should automatically expire or expunge messages
-this many days after being sent.
-
-* Add support for SMTP AUTH LOGIN
-
-=== Administration
-* Group option to make group visible to all users
-+
-A new group option permits the group to be visible to all users,
-rather than just its members. Some sites may find this useful for
-a project owners group, to help users contact the relevant folks.
-
-* Group option to only email change authors on updates
-+
-A new group option causes all users who are a member of that group to
-only send email notifications to change authors, excluding reviewers
-and watchers. This can be useful for automated build and testing users
-to reduce the amount of email sent to reviewers.
-
-* Hide non-visible groups from suggestion service
-+
-Groups that are not visible to a user are not shown as suggestions in
-contexts where a group name completion is supported.  The previously
-mentioned 'make group visible to all users' flag can be used on a
-per-group basis to expose groups to everyone.
-
-* Use suggest.accounts to control user completion suggestions
-+
-The new `suggest.accounts` configuration variable in gerrit.config
-can control how suggestions for users are offered.
-
-* Permit groups to be members of other groups
-+
-Groups can now be a member of another group, users are automatically
-a member of the transitive closure of their group membership.
-
-* READ +3 permission required to upload merges
-+
-The new READ +3 permission is required to upload merge commits. Users
-with only READ +2 permission may upload new changes, but not merges.
-The schema upgrade will automatically convert any current READ +2
-access lines to be READ +3 to maintain prior behavior.
-
-* "Show Inherited Rights" checkbox in Project Access
-+
-This checkbox enables showing or hiding the lines that are inherited
-from the parent project. This makes it easier to find the rules that
-are unique to the project being viewed.
-
-* Allow single letter usernames
-+
-Username requirements are relaxed to permit single letter usernames.
-
-* Fine-grained control over authentication cookie
-+
-Site administrators can now set `auth.cookieSecure` to request
-browsers only send the cookie over https:// connections, preventing
-eavesdropping.
-+
-Site administrators can now set `auth.cookiePath` to override the
-path used for the authentication cookie, which may be necessary if
-a reverse proxy maps requests to the managed gitweb.
-
-=== Replication
-* Add adminUrl to replication for repository creation
-+
-Replication remotes can be configured with `remote.name.adminUrl` to
-indicate an SSH path for repository creation that is different from
-the normal push URL in `remote.name.url`. The adminUrl can be used by
-Gerrit to create a new repository when the normal URL is a non-SSH
-URL, such as git:// or http://.
-
-* Support HTTP authentication for replication
-+
-Replication can now be performed over an authenticated smart HTTP
-transport, in addition to anonymous Git and authenticated SSH.
-
-=== Misc.
-* Alternative URL for Gerrit's managed Gitweb
-+
-The internal gitweb served from `/gitweb` can now appear to be from a
-different URL by using a reverse proxy that does URL rewriting.
-
-* Internal dependencies updated
-+
-Updated H2 Database to 1.2.147, PostgreSQL JDBC Client to 9.0-801,
-openid4java to 0.9.6, ANTLR to 3.2, GWT to 2.1.1, JSch to 0.1.44, Gson
-to 1.6, Apache Commons Net to 2.2, Apache Commons Pool to 1.5.5, JGit
-to 0.12.1.53-g5ec4977, MINA SSHD to 0.5.1-r1095809.
-
-== Bug Fixes
-
-=== Web UI
-* issue 853 Incorrect side-by-side display of modified lines
-+
-A bug in JGit lead to the side-by-side view displaying wrong and
-confusing output of modified lines. This bug also caused some
-automatic merges to be carried out incorrectly, usually resulting in
-compile failures. Fixed.
-
-* Disallow negative/zero columns in difference views
-+
-Previously a negative or zero value in the number of columns field
-would break the user's account and prevent them from viewing any file
-differences through the web UI. Values less than 1 are now rejected,
-and existing broken accounts will work again by resetting to a sane
-column count.
-
-* Fix branches table displaying symbolic references (e.g. HEAD).
-+
-In the project's "Branches" tab symbolic references like HEAD always
-displayed the wrong target name. Fixed to display the target name of
-the reference.
-
-* Disallow deletion of HEAD and targets of symbolic refs
-+
-Deleting the target of a symbolic reference causes the symbolic to
-become dangling, and it becomes useless.
-
-* Prevent creating 'refs/for/branch' in web UI.
-
-* issue 804 Display proper error message on invalid group
-+
-Attempting to browse a group that does not exist or that is not
-visible to the current user now displays a proper error message,
-instead of a scary generic "Application Error, Server Error".
-
-* issue 822 Up To Change link activates last browsed patch set
-* issue 846 Disable buttons during RPCs
-* issue 915 Always display button text in black
-* issue 946 Make sure that ENTER works in all text fields
-* issue 963 Go back to change screen if 'Publish and Submit' fails
-* Enable "Sign Out" when auth.type = CLIENT_SSL_CERT_LDAP.
-* Fix handling of "Session Expired" with SSL certificates.
-* Fix compatibility with recent releases of Gitweb.
-* Fix "review" link in Gitweb integration.
-* Always display button text in black
-* Always disable content merge option if user can't change project
-
-=== commit-msg Hook
-* issue 922 Fix commit-msg hook to run on Solaris
-
-=== Outgoing Mail
-* issue 780 E-mail about failed merge should not use Anonymous Coward
-+
-Some email was sent as Anonymous Coward, even when the user had a
-configured name and email address. Fixed.
-
-* Fix calculation of project owners
-+
-When sending out new changes for review, Gerrit automatically
-tries to address the project owners on the To line of the outgoing
-message. This sometimes included the owner of a branch. Fixed.
-
-* Do not email reviewers adding themselves as reviewers
-* Fix comma/space separation in email templates
-
-=== Pushing Changes
-* Avoid huge pushes during refs/for/BRANCH push
-+
-With Gerrit 2.1.6, clients started to push possibly hundreds of
-megabytes for what should be a tiny patch set changing 1 line of 1
-file. This large push was caused by the server advancing ahead of the
-client (e.g. due to another change being submitted) and the client not
-having fetched the new version. Fixed by adding some recent history to
-the advertisement so that clients don't have to upload the entire
-project for a small change.
-
-* issue 414 Reject pushing multiple commits with same Change-Id
-+
-If multiple new commits are uploaded to a refs/for/ branch and
-they have the same Change-Id, the push is now rejected.  Within
-a project, the Change-Id should be unique and users should either
-squash the commits, or modify them to use unique Change-Ids.
-
-* issue 635 Match Change-Id by project and branch combination
-* issue 635 Auto close changes by Change-Id on same branch only
-+
-Changes are automatically closed during direct push to branch only if
-the Change-Id line matches and the branch name matches. Previously
-changes were closed automatically if only the Change-Id matched,
-making it difficult to cherry-pick changes across branches.
-
-* issue 947 Disallow to push to non-connected target
-+
-If a repository stores disconnected history graphs on different
-branches, changes may only be pushed to the correct branch.
-
-* Always do Change-Id checks on receiving commits
-+
-Ensure Change-Ids aren't incorrectly used, even if the project does
-not require them to be present.  Previously some validity checks were
-only performed if the project required Change-Id lines.
-
-* Make Change-Id requirement applicable only to reviews
-+
-Change-Ids are not required when directly pushing to a branch. This
-permits projects that normally require Change-Ids to still perform
-direct branch pushes for updates received from an upstream project
-that does not use Change-Ids.
-
-* Reject invalid Change-Id lines
-+
-Severely malformed Change-Id lines were previously accepted by the
-server. These are now rejected.
-
-* Fix error message returned on push to closed change
-+
-If a commit with a Change-Id was pushed, and the corresponding change
-was already closed, the server incorrectly errored out with "No new
-changes". Now it reports the change is closed and does not accept a
-new patch set.
-
-* Fix error message for rejecting a change of another project
-+
-Instead of saying 'change not found' when pushing to a commit to
-a refs/changes/NNNN reference that belongs in another project, the
-error now indicates the change belongs to another project.
-
-* Better help message when commit message is malformed
-+
-If the commit message is badly formatted Gerrit displays an error
-message to the client. This message has been extended to offer
-suggestions on how to correct the commit message.
-
-* Log warning on 'change state corrupt' error
-+
-If a change state corrupt error is reported to a client, there was
-no mention if it on the server error log. Now it is reported so the
-site administrator also knows about it.
-
-=== SSH Commands
-* issue 755 Send new patchset event after its available
-* issue 814 Evict initial members of group created by SSH
-* issue 879 Fix replication of initial empty commit in new project
-* Disallow setting a project as parent for itself
-* Automatically create user account(s) as necessary
-* Move SSH command creation off NioProcessor threads
-
-=== Administration
-* Enable git reflog for all newly created projects
-+
-Previously branch updates were not being recorded in the native Git
-reflogs ($GIT_DIR/logs/refs/heads) due to a misconfiguration on new
-projects created by gerrit create-project. Fixed.
-
-* Fix IllegalArgumentException caused by non-ASCII user names
-+
-An invalid username is now always reported in UTF-8.
-
-* PostgreSQL: conditional installation of PL/pgSQL.
-+
-Conditional installation is needed to install Gerrit on PostgreSQL 9.
-
-* issue 961 Fix NPE on Gerrit startup if mail.from is invalid
-* issue 966 Enable git:// download URLs if canonicalGitUrl set
-* Stop logging 'keepalive@jcraft.com' errors in error_log
-* gerrit.sh: Fix issues on SuSE Linux
-* gerrit.sh: Fix issues on Solaris
-* gerrit.sh: Support spaces in JAVA_HOME
-
-=== Documentation
-* issue 800 documentation: Show example of review -m
-* issue 896 Clarify that $\{name\} is required for replication.
-* Fix spelling mistake in 'Searching Changes' documentation
-* Fix spelling mistake in user-upload documentation
-* Document cache diff_intraline
-* Document change set dependencies and cherry-pick
-* Include user in scp commands to copy commit hook
-* Adjust documentation to build with current AsciiDoc version
diff --git a/ReleaseNotes/ReleaseNotes-2.1.8.txt b/ReleaseNotes/ReleaseNotes-2.1.8.txt
deleted file mode 100644
index e1ed11c..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.8.txt
+++ /dev/null
@@ -1,54 +0,0 @@
-= Release notes for Gerrit 2.1.8
-
-Gerrit 2.1.8 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.1.8.war[https://www.gerritcodereview.com/download/gerrit-2.1.8.war]
-
-== New Features
-* Add cache for tag advertisements
-+
-When READ level access controls are used on references/branches, this
-cache provides a massive performance boost. On some repositories,
-no-op Git client requests can go from 7.910s to 0.550s.  Since all
-of the time reduction is server CPU, this is a major performance
-improvement for busy servers.
-
-* Substantially speed up pushing changes for review
-+
-Pushing changes to big projects was very slow, for similar issues
-as the READ level access controls. Push checks have been improved,
-reducing the amount of server CPU required to validate a push for
-review is connected to the branch its intended for.
-
-* Avoid costly findMergedInto during push to refs/for/*
-+
-Checking to see if a new commit uploaded for review has already been
-merged into a branch turns out to be expensive, and not very useful.
-Since the commit is brand new to the server, it cannot possibly ever
-have been merged. Skip the merge check to get a major performance
-improvement on upload to big projects.
-
-* Allow serving static files in subdirectories
-+
-The /static/ subdirectory can now serve static files contained within
-subdirectories. This change also patches the code to perform better
-checks to ensure the requested URL is actually in the subdirectory.
-These additional checks are only relevant on Windows servers, where
-MS-DOS compatibility may have permitted access to special device
-files in any directory, rather than just the "\\.\" device namespace.
-
-== Bug Fixes
-* issue 518 Fix MySQL counter resets
-+
-MySQL databases lost their change_id, account_id counters after
-server restarts, causing duplicate key insertion errors. Fixed.
-
-* issue 1019 Normalize OpenID URLs with http:// prefix
-+
-OpenID standards require sites to add "http://" to an OpenID
-identifier if the user did not enter it themselves.
-
-* Ignore PartialResultException from LDAP.
-+
-Instead of crashing with an exception, partial results are ignored
-when configured to be ignored.
diff --git a/ReleaseNotes/ReleaseNotes-2.1.9.txt b/ReleaseNotes/ReleaseNotes-2.1.9.txt
deleted file mode 100644
index 63bcb20..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.9.txt
+++ /dev/null
@@ -1,14 +0,0 @@
-= Release notes for Gerrit 2.1.9
-
-There are no schema changes from link:ReleaseNotes-2.1.8.html[2.1.8].
-
-link:https://www.gerritcodereview.com/download/gerrit-2.1.9.war[https://www.gerritcodereview.com/download/gerrit-2.1.9.war]
-
-== Bug Fixes
-* Patch JGit security hole
-+
-The security hole may permit a modified Git client to gain access
-to hidden or deleted branches if the user has read permission on
-at least one branch in the repository. Access requires knowing a
-SHA-1 to request, which may be discovered out-of-band from an issue
-tracker or gitweb instance.
diff --git a/ReleaseNotes/ReleaseNotes-2.1.txt b/ReleaseNotes/ReleaseNotes-2.1.txt
deleted file mode 100644
index 28cc90d..0000000
--- a/ReleaseNotes/ReleaseNotes-2.1.txt
+++ /dev/null
@@ -1,371 +0,0 @@
-= Release notes for Gerrit 2.1
-
-Gerrit 2.1 is now available in the usual location:
-
-link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
-
-
-== New site_path Layout
-
-The layout of the `$site_path` directory has been changed in 2.1.
-Configuration files are now stored within the `etc/` subdirectory
-and will be automatically moved there by the init subcommand.
-
-== Upgrading From 2.0.x
-
-  If the server is running a version older than 2.0.24, upgrade the
-  database schema to the current schema version of 19.  Download
-  'schema-upgrades003_019.zip' from the download area and run the
-  scripts by hand as listed in README until the server is caught up.
-
-Run init to convert the layout of $site_path:
-----
-  java -jar gerrit.war init -d $site_path
-----
-
-If there is a GerritServer.properties file handy, ensure it is in the
-current working directory or inside of $site_path when running init.
-If present, init will reuse this information rather than prompting
-for it.  If the file is not found, init will prompt for database
-connection information.
-
-While moving the server's configuration files into the new
-etc/ subdirectory, init will also move secret settings such as
-sendemail.smtpPass and ldap.password out of gerrit.config into a
-read-protected secure.config file.
-
-== New Daemon Mode
-
-Gerrit 2.1 and later embeds the Jetty servlet container, and
-runs it automatically as part of `java -jar gerrit.war daemon`.
-This is the preferred method of running Gerrit Code Review, and is
-how sites like review.source.android.com are operating.
-
-To simplify management on UNIX systems an rc.d style startup script
-is created in `$site_path/bin/gerrit.sh`.  This script can be used
-to start and stop the background daemon process.  When started
-from this script the daemon calls itself `GerritCodeReview` in ps,
-but may still show up in top as `java`.
-
-Configuration of the daemon is handled by gerrit.config.  For more
-information see the 2.1 documentation.
-
-link:http://gerrit.googlecode.com/svn/documentation/2.1/index.html[http://gerrit.googlecode.com/svn/documentation/2.1/index.html]
-
-
-== New Features
-
-* issue 19     Link to issue tracker systems from commits
-+
-Hyperlinks from commit messages and any inline comments to
-bug tracking systems can be enabled by configuring one or
-more commentlink regular expressions in gerrit.config.
-
-* Git replication security
-+
-Git replication can now be controlled on the sending side by
-configuring one or more authGroups for a remote and granting
-READ +1 access to only certain projects.
-
-* Better repo upload/git push throughput
-+
-MINA SSHD was misconfiguring the host's TCP/IP stack, this
-limited throughput of git push to under 16 KiB/s.  Fixed.
-Its such a huge improvement that its an important feature,
-rather than a bug fix.  :-)
-
-* issue 320    Queue SSH commands and ensure consistent throughput
-+
-SSH commands are entered into a queue and executed in FIFO order
-as processor capacity becomes available.  The queue enables
-the server to work on a finite number of commands at once and
-ensures running commands complete in a timely fashion, no matter
-how many concurrent connections are being established.
-The queue allows sites to maintain consistent throughput without
-thrashing, even as the number of requests increase beyond server
-capacity.  The change was made in anticipation of `repo sync`
-learning how to fetch all projects at once, inducing a load of
-over 200 concurrent commands per user/Android checkout.
-Server administrative commands such as kill or gsql (below) bypass
-the queue and are allowed to execute as soon as they are received.
-
-* kill: Support killing any queued task
-+
-A new administrative kill command was introduced to terminate
-any queued or running tasks.  Unlike UNIX kill, a killed task
-will continue until its next safe interruption point, which is
-usually at the next network read or write.
-
-* issue 327    gsql: query tool on command line and SSH
-+
-Gerrit supports an interactive SQL query tool for administrators.
-The query tool is available over SSH as `gerrit gsql`, or locally
-as `java -jar gerrit.war gsql`.  The query tool is primarily
-useful with H2 databases, where the database is only accessible
-to the running Java process.
-
-* issue 202    Self contained daemon mode
-* issue 328    daemon: Automatically log into $site_path/logs
-* daemon: Automatically compress our log files
-+
-As noted above, Jetty 7.0.1.v20091125 is now bundled, making new
-site installation easier.  Logs from daemon mode are written
-out to the site's logs/ subdirectory.  Logs are rotated and
-compressed daily.
-
-* issue 330    init: Create a command to setup a new site
-* issue 343    init: Create database indexes during schema creation
-* Remove CreateSchema command
-+
-The init command can be used to initialize a new site, or
-as noted above, to upgrade an existing site to the current
-software version.  Since init now does the work of CreateSchema,
-and everything else that used to be listed out as individual
-steps in the installation guide, CreateSchema was deleted.
-
-* issue 325    Allow secure.config to overlay gerrit.config
-* Configure database from gerrit.config
-+
-Database connectivity is now configured out of gerrit.config
-and secure.config, rather than GerritServer.properties.
-
-* Bundle PostgreSQL, H2, DBCP, MySQL, Bouncy Castle
-+
-JDBC drivers for PostgreSQL, H2, and the Apache Commons DBCP
-connection pool implementation are now bundled, reducing the
-number of external dependencies that must be obtained before
-getting a working installation.
-The MySQL driver is automatically downloaded and verified by
-init if required, as is the Bouncy Castle Crypto provider.
-These JARs are not packaged in the standard distribution due to
-export and/or license restrictions.
-
-* issue 183    Support invoking gitweb from within Gerrit
-+
-The standard gitweb.cgi can now be automatically configured and
-executed through Gerrit's servlet container, making it easier to
-publish a repository for browsing on the web.
-Project level access controls are honored when browsing through
-this gitweb interface.
-
-* issue 105    Support OpenID when behind an HTTP proxy
-* issue 323    Use JGit's http_proxy based initialization
-+
-HTTP proxies are now supported for OpenID authentication, as
-well as for init's optional external library download.
-
-* Add a Register link when using LDAP authentication
-+
-When auth.type is LDAP the Register link in the top right corner
-can point to an administrator defined URL.  This external URL
-might be as simple as a 'mailto:...' link, to help the user
-request a new LDAP account from the directory administrators.
-
-* Switch remote JSON services to use JSON-RPC 2.0
-+
-The JSON-RPC interface now speaks the JSON-RPC 2.0 draft
-specification, in addition to the prior JSON-RPC 1.1
-specification previously used.
-
-* issue 336    Update MINA SSHD to SVN 891122
-* issue 324    Update JGit to 0.5.1.51-g96b2e76
-* Update JUnit to 3.8.2
-* Update args4j to 2.0.16
-* Update slf4j-log4j12 to 1.5.8
-* Update Ehcache to 1.7.1
-* Update commons-pool to 1.5.4
-* Update H2 to 1.2.125
-* Update to gwtjsonrpc 1.2.0, gwtexpui 1.1.4
-+
-Most dependencies were updated to their current stable versions.
-
-== Bug Fixes
-
-* issue 259    Improve search hint to include owner:email
-+
-The hint text in the search box in the upper right corner has
-been improved to suggest owner:email and reviewer:email, as
-these tags were not discoverable.
-
-* issue 335    daemon: Refuse to launch unless gerrit.config exists
-+
-Gerrit now refuses to launch until the site path has been
-properly initialized with init.  This is true both in daemon
-mode and also when deployed inside of any servlet container.
-
-* issue 152    Allow adding users who are missing a preferred email
-+
-The user suggestion boxes now permit adding a user that has not
-yet selected a preferred email address on their contact panel.
-
-* issue 319    Automatically set preferred email to first configured
-+
-If a user has no email addresses, the first address they register
-through the next OpenID login, LDAP login, or 'Register New Email'
-feature will be automatically set as the preferred email address
-for their account.
-
-* issue 356    Fix committer identity on cherry-pick
-+
-The committer identity created when cherry-picking a change was
-formatted incorrectly, it used the internal account identity.
-Fixed to use the submitter's preferred email address only.
-
-* issue 345    Make "call11" readable in file content
-+
-The prior font made the string "call11" (c-a-ell-ell-one-one)
-impossible to read because the ell and one looked the same.
-Fixed by changing to different fonts for the fixed width file
-content display.
-
-* Automatically make first user account administrator
-+
-To simplify installation, the first user to login to a brand
-new site is added to the 'Administrators' group.  This avoids
-the need to update the database manually via SQL and restart
-the daemon to have it be picked up.
-
-* Always trim Change-Id lines to handle whitespace
-+
-Some users were adding trailing whitespace on a Change-Id line
-by accident, causing Gerrit to not always honor it when uploading
-a replacement patch.  Fixed.
-
-* Fix duplicate branches in the branches panel
-+
-The Branches tab under a project displayed the HEAD branch twice,
-but every other branch once.  Fixed.
-
-* Enforce all HTTP requests through SSL
-+
-JSON-RPC requests are now required to be over SSL if the site
-prefers to use SSL for communication.
-Prior to 2.1 the JSON-RPC requests from the web UI were performed
-over https:// if the web UI loaded over https://, but JSON-RPC
-requests from other tools (e.g. a command line script) were
-still allowed over plain text HTTP.
-
-* Work around NPE when patch list fails to compute
-+
-Rather than return NullPointerException to the browser return
-a "not found" error, as the source of the null pointer is the
-underlying diff operation returned no results.
-
-* Fix stuck "Loading Gerrit Code Review ..."
-+
-Many users have noticed that after about a week of server uptime
-Gerrit no longer loads in their browser, until the server is
-restarted.  This was usually caused by Jetty unpacking the WAR
-file contents to /tmp, and the system having a cron task that
-deleted files more than a week old from /tmp.
-Under the daemon command the WAR file contents are unpacked into
-`$HOME/.gerritcodereview/tmp`, which should be isolated from
-the host system's /tmp cleaner.
-
-== Other=
-
-* Pick up gwtexpui 1.1.4-SNAPSHOT
-* Merge change Ia64286d3
-* Merge branch 'maint-2.0.24.1'
-* Merge change Ic6f00304
-* Merge branch 'maint-2.0.24.2'
-* Add H2 database as a test dependency
-* Update Ehcache to 1.7.0
-* Fix formatting
-* Rewrite our build as modular maven components
-* Forbid use of anonymous servlets on any container
-* Use listeners to manage server startup/shutdown
-* Load additional JARs from $site_path/lib
-* Fix PostgreSQL/H2 access under gwtdebug sessions
-* Fix Become link in hosted mode debugging sessions
-* Fix ssh:// URLs on change pages
-* daemon: Update help for --slave option
-* daemon: Remove -DGerritServer from documentation
-* Launcher: Clarify the purpose of the daemon command
-* daemon: Fix --site-path documentation
-* Remove unused imports from pgm.DataSourceProvider
-* launcher: Don't print stack trace with reflection frames
-* Move H2 database down into $site_path/db
-* Remove dead code identified by Eclipse 3.5.1
-* Add missing default serialVersionUID
-* pgm_daemon: Remove unnecessary -DGerritServer flag
-* Move configuration files under $site_path/etc
-* Update documentation to point to etc subdirectory
-* Display the full stack trace if requested
-* init: Don't delete site path on database creation fail
-* Simplify errors reported by command line database fail
-* init: Correct defaults for httpd.listenUrl in --batch
-* issue 341    gsql: Fix \d on H2
-* gsql: Improve formatting of column types and indexes
-* pgm: Move non commands into a util package
-* issue 342    gsql: Reduce connections used to only 1
-* WorkQueue: Drop the word "-thread" from thread names
-* documentation: Correct links in dev-design
-* Fix port number in ssh pull lines in emails
-* Update MINA SSHD to 0.3.0
-* Update Jetty to 7.0.1.v20091125
-* launcher: Refactor how we return the status code
-* cat, ls: move inside our main program package
-* Default temporary directory to $HOME/.gerritcodereview
-* Clean up stale empty temporary directories
-* daemon: Unpack the WAR contents to a local directory
-* daemon: Run correctly under Eclipse debugger
-* Create a rc.d style start/stop script for our daemon
-* Remove unused ADMIN_PEOPLE link
-* Ignore unsupported ulimit -x errors
-* Use more portable printf instead of echo -n
-* Support starting as current user without start-stop-daemon
-* Make startup output universally the same
-* Get the canonical path to our temporary directory
-* init: Start daemon and open web browser when done
-* documentation: Clean up references to 'Gerrit2'
-* Cleanup the reflog identity generation
-* Update to gwtjsonrpc 1.2.0-SNAPSHOT
-* init: Configure gerrit.canonicalWebUrl if reverse proxy
-* tools/version.sh: Quick hack to edit our Maven version
-* Call the next version 2.1
-* documentation: Rewrite installation guide
-* Fix gerrit.sh to run properly on SuSE systems
-* documentation: Fix formatting of remote.name.authGroup
-* Fix missing @Override warning in IoUtil
-* Don't enable replication if replication.config is empty
-* Give H2 a canonical file path
-* init: Add --no-auto-start to prevent starting the daemon
-* init: Support updating an existing site configuration
-* init: Open browser to gerrit.canonicalWebUrl
-* daemon: Allow httpd.listenUrl to end with /
-* issue 358    init: Don't abort on empty directory
-* init: Initialize system_config.site_path
-* Remove dead class MessagePanel
-* issue 331    documentation: Update developer docs
-* documentation: Link to apache2 reverse proxy setup
-* init: Fix LDAP prompts to store to ldap section
-* init: Store httpd.sslKeyPasword in secure.config
-* init: Fix a minor source code formatting error
-* commentlink: Support raw HTML replacements
-* documentation: Cleanup formatting in gerrit-config
-* Delete legacy schema upgrade scripts
-* Remove legacy tools/to_jetty.sh
-* Remove standalone Jetty 6.x support scripts
-* Move all resource files into src/main/resources
-* init: Move optional library download configuration
-* init: Refactor init to be small parts created
-* Test SitePaths class
-* Test SocketUtil class
-* Test init's Libraries class
-* Test init's upgrade from 2.0.x layout to 2.1 layout
-* pgm_daemon launch: Run ../test_site like docs suggest...
-* tools/version.sh: Don't mangle the git describe output
-* Use SitePaths to locate the logs directory
-* Resolve out any symlinks before starting logging
-* Mark compressed log files read-only
-* tools/release.sh: Simplify our release build process
-* Teach Main to check the Java runtime version
-* documentation: Mention Google Code Prettify in licens...
-* Refactor GitRepositoryManager to be an interface
-* issue 346    Fix duplicate branches showing in the Branches tab
-* Completely remove GerritServer.properties
-* Clean up the DWIMery for database.* configuration set...
-* Never compress a pid file under $site_path/logs
-* Fix reading the $site_path/etc/ssh_host_key in serial...
-* gerrit 2.1
\ No newline at end of file
diff --git a/ReleaseNotes/ReleaseNotes-2.10.1.txt b/ReleaseNotes/ReleaseNotes-2.10.1.txt
deleted file mode 100644
index 72d26d1..0000000
--- a/ReleaseNotes/ReleaseNotes-2.10.1.txt
+++ /dev/null
@@ -1,37 +0,0 @@
-= Release notes for Gerrit 2.10.1
-
-There are no schema changes from link:ReleaseNotes-2.10.html[2.10].
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.10.1.war[
-https://www.gerritcodereview.com/download/gerrit-2.10.1.war]
-
-== Bug Fixes
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2260[Issue 2260]:
-LDAP horrendous login time due to recursive lookup.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3210[Issue 3210]:
-Null Pointer Exception for query command with --comments switch.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3211[Issue 3211]:
-Intermittent Null Pointer Exception when showing process queue.
-
-== LDAP
-
-* Several performance improvements when using LDAP, both in the number of LDAP
-requests and in the amount of data transferred.
-
-* Sites using LDAP for authentication but otherwise rely on local Gerrit groups
-should set the new `ldap.fetchMemberOfEagerly` option to `false`.
-
-== OAuth
-
-* Expose extension point for generic OAuth providers.
-
-== OpenID
-
-* Add support for Launchpad on the login form.
-
-* Remove pre-configured Google OpenID 2.0 provider from the login form, that is
-going to be shut down on 20, April 2015.
diff --git a/ReleaseNotes/ReleaseNotes-2.10.2.txt b/ReleaseNotes/ReleaseNotes-2.10.2.txt
deleted file mode 100644
index 49be04e..0000000
--- a/ReleaseNotes/ReleaseNotes-2.10.2.txt
+++ /dev/null
@@ -1,27 +0,0 @@
-= Release notes for Gerrit 2.10.2
-
-There are no schema changes from link:ReleaseNotes-2.10.1.html[2.10.1].
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.10.2.war[
-https://www.gerritcodereview.com/download/gerrit-2.10.2.war]
-
-== Bug Fixes
-
-* Work around MyersDiff infinite loop in PatchListLoader. If the MyersDiff diff
-doesn't finish within 5 seconds, interrupt it and fall back to a different diff
-algorithm. From the user perspective, the only difference when the infinite
-loop is detected is that the files in the commit will not be compared in-depth,
-which will result in bigger edit regions.
-
-== Secondary Index
-
-* Online reindexing: log the number of done/failed changes in the error_log.
-Administrators can use the logged information to decide whether to activate the
-new index version or not.
-
-== Gitweb
-
-* Do not return `Forbidden` when clicking on Gitweb breadcrumb. Now when the
-user clicks on the parent folder, redirect to Gerrit projects list screen with
-the parent folder path as the filter.
diff --git a/ReleaseNotes/ReleaseNotes-2.10.3.1.txt b/ReleaseNotes/ReleaseNotes-2.10.3.1.txt
deleted file mode 100644
index 7777bd8..0000000
--- a/ReleaseNotes/ReleaseNotes-2.10.3.1.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-= Release notes for Gerrit 2.10.3.1
-
-There are no schema changes from link:ReleaseNotes-2.10.3.html[2.10.3].
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.10.3.1.war[
-https://www.gerritcodereview.com/download/gerrit-2.10.3.1.war]
-
-The 2.10.3 release packaged wrong version of the core plugins due to a bug
-in our buck build scripts. This version fixes this issue.
diff --git a/ReleaseNotes/ReleaseNotes-2.10.3.txt b/ReleaseNotes/ReleaseNotes-2.10.3.txt
deleted file mode 100644
index 1dd96e7..0000000
--- a/ReleaseNotes/ReleaseNotes-2.10.3.txt
+++ /dev/null
@@ -1,111 +0,0 @@
-= Release notes for Gerrit 2.10.3
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.10.3.war[
-https://www.gerritcodereview.com/download/gerrit-2.10.3.war]
-
-== Important Notes
-
-*WARNING:* There are no schema changes from
-link:ReleaseNotes-2.10.2.html[2.10.2], but Bouncycastle was upgraded to 1.51.
-It is therefore important to upgrade the site with the `init` program, rather
-than only copying the .war file over the existing one.
-
-*WARNING:* When upgrading from version 2.8.4 or older with a site that uses
-Bouncy Castle Crypto, new versions of the libraries will be downloaded. The old
-libraries should be manually removed from site's `lib` folder to prevent the
-startup failure described in
-link:https://code.google.com/p/gerrit/issues/detail?id=3084[Issue 3084].
-
-It is recommended to run the `init` program in interactive mode. Warnings will
-be suppressed in batch mode.
-
-----
-  java -jar gerrit.war init -d site_path
-----
-
-== New Features
-
-* Support hybrid OpenID and OAuth2 authentication
-+
-OpenID auth scheme is aware of optional OAuth2 plugin-based authentication.
-This feature is considered to be experimental and hasn't reached full feature set yet.
-Particularly, linking of user identities across protocol boundaries and even from
-one OAuth2 identity to another OAuth2 identity wasn't implemented yet.
-
-=== Configuration
-
-* Allow to configure
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10.3/config-gerrit.html#sshd.rekeyBytesLimit[
-SSHD rekey parameters].
-
-== SSH
-
-* Update SSHD to 0.14.0.
-+
-This fixes link:https://issues.apache.org/jira/browse/SSHD-348[SSHD-348] which
-was causing ssh threads allocated to stream-events clients to get stuck.
-+
-Also update SSHD Mina to 2.0.8 and Bouncycastle to 1.51.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2797[Issue 2797]:
-Add support for ECDSA based public key authentication.
-
-== Bug Fixes
-
-* Prevent wrong content type for CSS files.
-+
-The mime-util library contains two content type mappings for .css files:
-`application/x-pointplus` and `text/css`.  Unfortunately, using the wrong one
-will result in most browsers discarding the file as a CSS file.  Ensure we only
-use the correct type for CSS files.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3289[Issue 3289]:
-Prevent NullPointerException in Gitweb servlet.
-
-=== Replication plugin
-
-* Set connection timeout to 120 seconds for SSH remote operations.
-+
-The creation of a missing Git, before starting replication, is a blocking
-operation. By setting a timeout, we ensure the operation does not get stuck
-forever, essentially blocking all future remote git creation operations.
-
-=== OAuth extension point
-
-* Respect servlet context path in URL for login token
-+
-On sites with non empty context path, first redirect was broken and ended up
-with 404 Not found.
-
-* Invalidate OAuth session after web_sessions cache expiration
-+
-After web session cache expiration there is no way to re-sign-in into Gerrit.
-
-=== Daemon
-
-* Print proper names for tasks in output of `show-queue` command.
-+
-Some tasks were not displayed with the proper name.
-
-=== Web UI
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3044[Issue 3044]:
-Remove stripping `#` in login redirect.
-
-=== SSH
-
-* Prevent double authentication for the same public key.
-
-
-== Performance
-
-* Improved performance when creating a new branch on a repository with a large
-number of changes.
-
-
-== Upgrades
-
-* Update Bouncycastle to 1.51.
-
-* Update SSHD to 0.14.0.
diff --git a/ReleaseNotes/ReleaseNotes-2.10.4.txt b/ReleaseNotes/ReleaseNotes-2.10.4.txt
deleted file mode 100644
index c69a946..0000000
--- a/ReleaseNotes/ReleaseNotes-2.10.4.txt
+++ /dev/null
@@ -1,44 +0,0 @@
-= Release notes for Gerrit 2.10.4
-
-There are no schema changes from link:ReleaseNotes-2.10.3.1.html[2.10.3.1].
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.10.4.war[
-https://www.gerritcodereview.com/download/gerrit-2.10.4.war]
-
-== New Features
-
-* Support identity linking in hybrid OpenID and OAuth2 authentication.
-+
-Linking of user identities across protocol boundaries and from one OAuth2
-identity to another OAuth2 identity is supported.
-
-* Support identity linking in OAuth2 extension point.
-+
-Linking of user identities from one OAuth2 identity to another OAuth2
-identity is supported.
-
-== Bug Fixes
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3300[Issue 3300]:
-Fix >10x performance degradation for Git push and replication operations.
-+
-A link:https://bugs.eclipse.org/bugs/show_bug.cgi?id=465509[regression in jgit]
-caused a performance degradation.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3312[Issue 3312]:
-Flush padding on patches downloaded as base64.
-+
-The padding was not flushed, which caused the downloaded patch to not be
-valid base64.
-
-=== OAuth extension point
-
-* Check for session validity during logout.
-+
-When user was trying to log out, after Gerrit restart, the session was
-invalidated and IllegalStateException was recorded in the error_log.
-
-== Updates
-
-* Update jgit to 4.0.0.201505050340-m2.
diff --git a/ReleaseNotes/ReleaseNotes-2.10.5.txt b/ReleaseNotes/ReleaseNotes-2.10.5.txt
deleted file mode 100644
index a221b58..0000000
--- a/ReleaseNotes/ReleaseNotes-2.10.5.txt
+++ /dev/null
@@ -1,24 +0,0 @@
-= Release notes for Gerrit 2.10.5
-
-There are no schema changes from link:ReleaseNotes-2.10.4.html[2.10.4].
-
-Download:
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.10.5.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.10.5.war]
-
-== Bug Fixes
-
-* Update JGit to include a memory leak fix as discussed
-link:https://groups.google.com/forum/#!topic/repo-discuss/RRQT_xCqz4o[here]
-
-* Attempt to fix the "Cannot read project" issue in Gerrit, as discussed
-link:https://groups.google.com/forum/\#!topic/repo-discuss/ZeGWPyyJlrM[here]
-and
-link:https://groups.google.com/forum/#!topic/repo-discuss/CYYoHfDxCfA[here]
-
-* Fixed a regression caused by the defaultValue feature which broke the ability
-to remove labels in subprojects
-
-== Updates
-
-* Update JGit to v4.0.0.201506090130-r
diff --git a/ReleaseNotes/ReleaseNotes-2.10.6.txt b/ReleaseNotes/ReleaseNotes-2.10.6.txt
deleted file mode 100644
index 7c12d11..0000000
--- a/ReleaseNotes/ReleaseNotes-2.10.6.txt
+++ /dev/null
@@ -1,12 +0,0 @@
-= Release notes for Gerrit 2.10.6
-
-There are no schema changes from link:ReleaseNotes-2.10.5.html[2.10.5].
-
-Download:
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.10.6.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.10.6.war]
-
-== Bug Fixes
-
-* Fix generation of licenses in documentation.
-
diff --git a/ReleaseNotes/ReleaseNotes-2.10.7.txt b/ReleaseNotes/ReleaseNotes-2.10.7.txt
deleted file mode 100644
index f369999..0000000
--- a/ReleaseNotes/ReleaseNotes-2.10.7.txt
+++ /dev/null
@@ -1,17 +0,0 @@
-= Release notes for Gerrit 2.10.7
-
-There are no schema changes from link:ReleaseNotes-2.10.6.html[2.10.6].
-
-Download:
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.10.7.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.10.7.war]
-
-== Bug Fixes
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3361[Issue 3361]:
-Synchronize Myers diff and Histogram diff invocations to prevent pack file
-corruption.
-+
-See also the link:https://bugs.eclipse.org/bugs/show_bug.cgi?id=467467[
-bug report on JGit].
-
diff --git a/ReleaseNotes/ReleaseNotes-2.10.txt b/ReleaseNotes/ReleaseNotes-2.10.txt
deleted file mode 100644
index 4f068cc..0000000
--- a/ReleaseNotes/ReleaseNotes-2.10.txt
+++ /dev/null
@@ -1,679 +0,0 @@
-= Release notes for Gerrit 2.10
-
-
-Gerrit 2.10 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.10.war[
-https://www.gerritcodereview.com/download/gerrit-2.10.war]
-
-Gerrit 2.10 includes the bug fixes done with
-link:ReleaseNotes-2.9.1.html[Gerrit 2.9.1],
-link:ReleaseNotes-2.9.2.html[Gerrit 2.9.2],
-link:ReleaseNotes-2.9.3.html[Gerrit 2.9.3] and
-link:ReleaseNotes-2.9.4.html[Gerrit 2.9.4].
-These bug fixes are *not* listed in these release notes.
-
-== Important Notes
-
-
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* When upgrading from an existing site that was initialized with Gerrit
-version 2.6 to version 2.9.1, the primary key column order will be updated for
-some tables. It is therefore important to upgrade the site with the `init` program,
-rather than only copying the .war file over the existing one.
-
-It is recommended to run the `init` program in interactive mode. Warnings will
-be suppressed in batch mode.
-
-*WARNING:* Upgrading to 2.10.x requires the server be first upgraded to 2.8
-(or 2.9) and then to 2.10.x. If you are upgrading from 2.8.x or
-later, you may ignore this warning and upgrade directly to 2.10.x.
-
-*WARNING:* The `auth.allowGoogleAccountUpgrade` setting is no longer supported.
-
-*WARNING:* When upgrading from version 2.8.4 or older with a site that uses
-Bouncy Castle Crypto, new versions of the libraries will be downloaded. The old
-libraries should be manually removed from site's `lib` folder to prevent the
-startup failure described in
-link:https://code.google.com/p/gerrit/issues/detail?id=3084[Issue 3084].
-
-
-== Release Highlights
-
-
-* Support for externally loaded plugins.
-+
-Plugins can be implemented in Scala or Groovy using the
-link:https://gerrit-review.googlesource.com/\#/admin/projects/plugins/scripting/groovy-provider[
-Groovy provider] and
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripting/scala-provider[
-Scala provider] plugins.
-
-* Customizable 'My' menu.
-+
-Users can customize the contents of the 'My' menu in the top menu.  Administrators
-can configure the default contents of the menu.
-
-
-== New Features
-
-
-=== Web UI
-
-
-==== Global
-
-* Add 'All-Users' project to store meta data for all users.
-
-* Administrators can customize the default contents of the 'My' menu.
-
-* Add 'My' > 'Groups' menu entry that shows the list of own groups.
-
-* Allow UiActions to perform redirects without JavaScript.
-
-
-==== Change Screen
-
-
-* Display avatar for author, committer, and change owner.
-
-* Remove message box when editing topic of change.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2573[Issue 2573]:
-Add option to quickly add current user as reviewer of a change.
-+
-An 'Add Me' button is displayed next to the 'Add' button when searching for
-reviewers to add to a change. This allows users to quickly add themselves as a
-reviewer on the change without having to type their name in the search
-box.
-
-* Link project name to dashboard.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2667[Issue 2667]:
-Allow to customize Submit button label and tooltip.
-
-
-==== Side-by-Side Diff Screen
-
-* Allow the user to select the syntax highlighter.
-
-* Add `Shift-a` keybinding to show/hide left side.
-
-* Allow to toggle empty pane for added and deleted files.
-
-* Add syntax highlighting of the commit message.
-
-
-==== Change List / Dashboards
-
-* Remove age operator when drilling down from a dashboard to a query.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2646[Issue 2646]:
-Add option to show Change-ID in the change table.
-
-* Make the own user dashboard available under '/dashboard/self'.
-
-* Add 'R' key binding to refresh custom dashboards.
-+
-Account dashboards, search results and the change screen refresh their content
-when 'R' is pressed.  The same binding is added for custom dashboards.
-
-
-==== Project Screens
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2751[Issue 2751]:
-Add support for filtering by regex in project list screen.
-
-* Disable content merge option if project's merge strategy is fast forward only.
-
-* Add branch actions to 'Projects > Branches' view.
-
-==== User Preferences
-
-
-* Users can customize the contents of the 'My' menu from the preferences
-screen.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2628[Issue 2628]:
-Replace 'Display name in review category' preference with a list of options.
-+
-Including new options 'Show Abbreviated Name' to display abbreviated reviewer
-names and 'Show Username' to show usernames in the change list.
-
-
-=== Secondary Index / Search
-
-
-* Allow to search projects by prefix.
-
-* Add search fields for number of changed lines.
-
-* Add suggestions for 'is:pending' and 'status:pending'.
-
-* Add 'pending' as alias for 'open'.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2545[Issue 2545]:
-Support `topic:""` to find changes with no topic.
-
-* Search more fields in the default search query.
-+
-If a search is given with only a text, search over a variety of fields
-rather than just the project name.
-
-
-=== ssh
-
-
-* Expose SSHD backend in
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/cmd-show-connections.html[
-`show connections`] SSH command.
-
-* Add support for JCE (Java Cryptography Extension) ciphers.
-
-=== REST API
-
-
-==== General
-
-
-* Remove `kind` attribute from REST containers.
-
-* Support `AcceptsPost` on non top-level REST collections.
-
-* Accept `HEAD` in RestApiServlet.
-
-==== Accounts
-
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-accounts.html#get-user-preferences[
-Get user preferences].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-accounts.html#set-user-preferences[
-Set user preferences].
-
-==== Changes
-
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2338[Issue 2338]:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-changes.html#create-change[
-Create change].
-
-* Add `other-branches` option on
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-changes.html#get-mergeable[
-Get mergeable] endpoint.
-+
-If the `other-branches` option is specified, the mergeability will also be
-checked for all other branches.
-
-==== Config
-
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-config.html#list-tasks[
-List tasks].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-config.html#get-task[
-Get task].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-config.html#delete-task[
-Delete task].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-config.html#list-caches[
-List caches].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-config.html#flush-cache[
-Flush cache].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-config.html#flush-several-caches[
-Flush several caches].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-config.html#flush-all-caches[
-Flush all caches].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-config.html#get-summary[
-Get server summary].
-
-==== Projects
-
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-projects.html#ban-commit[
-Ban commits].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-projects.html#get-content[
-Get the content of a file from a certain commit].
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2604[Issue 2604]:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-projects.html#get-commit[
-Get an arbitrary commit from a project].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-projects.html#get-reflog[
-Get the reflog of a branch].
-
-* Add option 'S' to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-projects.html#list-projects[
-list projects endpoint] to support query offset.
-
-
-=== Daemon
-
-
-* Add change subject to output of change URL on push.
-
-* Indicate trivial rebase and commit message update on push.
-
-* Add support for
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/user-upload.html#review_labels[
-adding review labels on changes] during git push.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2634[Issue 2634]:
-Add change kind to PatchSetCreatedEvent.
-
-
-=== Configuration
-
-* Use
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/config-gerrit.html#core.useRecursiveMerge[
-recursive merge] by default.
-
-* Allow to configure the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/config-gerrit.html#download.archive[
-available download archive formats].
-
-* Add support for
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/database-setup.html#createdb_maxdb[
-SAP MaxDB].
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2041[Issue 2041]:
-Allow
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/config-labels.html#label_defaultValue[
-configuration of a default value for a label].
-
-* Allow projects to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/config-project-config.html#mimetype-section[
-configure MIME types for files].
-
-* Allow to configure
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/config-gerrit.html#gc[
-periodic garbage collection of all projects].
-
-* Remove `auth.allowGoogleAccountUpgrade` setting.
-+
-It's been more than 5 years since Gerrit ran on Google AppEngine.  It is assumed
-that everyone has upgraded their installations to a modern 2.x based server, and
-will not need to have this upgrade path enabled.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2618[Issue 2618]:
-Remove `label.Label-Name.abbreviation` setting.
-+
-The setting was no longer used, so it has been removed.
-
-* New `httpd.registerMBeans` setting.
-+
-The
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/config-gerrit.html#httpd.registerMBeans[
-`httpd.registerMBeans` setting] allows to enable (or disable) registration of
-Jetty MBeans for Java JMX.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2600[Issue 2600]:
-Add documentation of how to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/install-j2ee.html#tomcat[
-configure Tomcat] to allow embedded slashes.
-
-
-=== Misc
-
-* Don't allow empty user name and passwords in InternalAuthBackend.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2596[Issue 2596]:
-Add change-owner parameter to gerrit hooks.
-
-
-=== Plugins
-
-* Support for externally loaded plugins.
-+
-Plugins can be implemented in Scala or Groovy using the
-link:https://gerrit-review.googlesource.com/\#/admin/projects/plugins/scripting/groovy-provider[
-Groovy provider] and
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripting/scala-provider[
-Scala provider] plugins.
-
-* Allow plugins to replace the WebSession implementation.
-+
-Plugins can replace the existing implementation with the statement:
-`DynamicItem.bind(binder(), WebSession.class).to(...);`
-in a module designated as a `<Gerrit-HttpModule>` in the manifest.
-+
-Just the Cache implementation used for web sessions can be changed
-by binding to a subclass of the now abstract `CacheBasedWebSession`
-which supplies the Cache in the superclass constructor.
-+
-This is a step towards solving web session issues with multi-master.
-+
-The link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/websession-flatfile[
-websession-flatfile plugin] replaces the built-in Gerrit WebSession implementation
-with one that uses a flat file based cache.
-
-* Allow http and ssh plugins to replace the Gerrit-provided DynamicItem.
-
-* New extension point to listen to usage data published events.
-+
-Plugins implementing the `UsageDataPublishedListener` can listen to
-events published about usage data.
-
-* New extension point to link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/dev-plugins.html#pre-upload-hook[
-register JGit PreUploadHook].
-+
-Plugins may register PreUploadHook instances in order to get
-notified when JGit is about to upload a pack. This may be useful
-for those plugins which would like to monitor usage in Git
-repositories.
-
-* New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/config-validation.html#pre-upload-validation[
-pre-upload validation extension point].
-+
-Plugins implementing the `UploadValidationListener` interface can
-perform additional validation checks before any upload operations
-(clone, fetch, pull). The validation is executed right before Gerrit
-begins to send a pack back to the git client.
-
-* New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/dev-plugins.html#links-to-external-tools[
-external tool links extension points].
-+
-Plugins can now contribute project links that will be displayed on the project
-list screen in the 'Repository Browser' column, and revision links that will be
-shown on the change screen.
-
-* Allow creation of persistent caches after server is started.
-+
-This enables plugins to create own persistent caches when they are
-installed.
-
-* Make gerrit's HttpServletRequest and HttpServletResponse visible to http
-plugins.
-
-* New extensions in the Java Plugin API:
-
-** Query changes
-** Create/get/list projects
-** Get/set review status
-** Create change
-** Get account
-** Star/unstar changes
-** Check if revision needs rebase
-
-== Bug Fixes
-
-=== General
-
-* Use fixed rate instead of fixed delay for log file compression.
-+
-Log file compression was scheduled using a fixed delay. This caused the start
-times to drift over time. Use a fixed rate instead so that the compression
-reoccurs at the same time every day.
-
-* Don't email project watchers on new draft changes.
-+
-If a draft change is created by pushing to `refs/drafts/master`, only the reviewers
-explicitly named on the command line (which may be empty) should be notified of
-the change. Users watching the project should not be notified, as the change has
-not yet been published.
-
-* Fix resource exhaustion due to unclosed LDAP connection.
-+
-When `auth.type` is set to `LDAP` (not `LDAP_BIND`), two LDAP connections are
-made, but one was not being closed. This eventually caused resource exhaustion
-and LDAP authentications failed.
-
-=== Access Permissions
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2995[Issue 2995]:
-Fix faulty behaviour in `BLOCK` permission.
-+
-`BLOCK` can be overruled with `ALLOW` on the same project, however there was a
-bug when a child of the above project duplicates the `ALLOW` permission. In this
-case the `BLOCK` would always win for the child, even though the `BLOCK` was
-overruled in the parent.
-
-=== Web UI
-
-==== General
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2595[Issue 2595]:
-Make gitweb redirect to login.
-+
-Gitweb redirects to the login page if the user isn't currently logged.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2631[Issue 2631]:
-Re-arrange info at footer of Gerrit web UI pages.
-+
-Move the Gerrit info link so that there are no links close to the next page link.
-
-* Only create All-Projects ACL once.
-+
-If `refs/meta/config` already existed it was overwritten with default configuration
-if a site administrator ran `java -war gerrit.war init -d /some/existing/site --batch`.
-
-
-==== Change Screen
-
-* Don't linkify trailing dot or comma in messages.
-+
-As linkifying trailing dots and trailing commas does more harm than
-good, we only treat dots and commas as being part of urls, if they are
-neither followed by whitespace nor occur at the end of a string.
-
-* Re-enable the 'Cherry Pick' button after canceling the dialog.
-+
-If the dialog was canceled, the button remained disabled and could not be
-used again.
-
-* Improve message when removing a reviewer.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=527[Issue 527]:
-Preserve line breaks in inline and review comments.
-
-* Always show 'No Score' as label help for zero votings.
-
-* Only reset the edited commit message text on cancel.
-
-* Only include message on quick approve if reply is open.
-
-* List reviewers with dummy approvals on closed changes.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2890[Issue 2890]:
-Enable scrollbars for "Edit Commit Message" TextArea.
-
-* Use current time instead of submitter time for cherry-picked commits.
-+
-Cherry picking with the submitter time could cause massive clock skew
-in the Git commit graph if the server was shutdown before the submit could
-finish, and restarted hours later.
-
-* Fix exception when clicking on a binary file without being signed in.
-
-
-==== Side-By-Side Diff
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2970[Issue 2970]:
-Fix misalignment of side A and side B for long insertion/deletion blocks.
-
-* Give B side full width when A side is hidden.
-
-* Fix scroll alignment when showing hidden A side.
-
-* Bind Shift-N to search-prev in vim mode.
-
-* Allow text selection in diff header.
-
-* Display diff header on mode changes and renames.
-
-* Document Shift-{Left,Right} in `?` help popup.
-
-* Show `[` and `]` shortcut keys in nav arrow tooltips.
-
-* Disable "Render = Slow" mode on files over 4000 lines.
-
-* Keep keyboard bindings alive after click in padding.
-
-* Jump to the first change on either side.
-
-* Expand margin between paragraphs in comments.
-
-* Include content on identical files with mode change.
-
-
-==== User Settings
-
-* Avoid loading all SSH keys when adding a new one.
-
-
-=== Secondary Index / Search
-
-
-* Omit corrupt changes from search results.
-
-* Allow illegal label names from default search predicate.
-
-=== REST
-
-==== General
-
-* Fix REST API responses for 3xx and 4xx classes.
-
-==== Changes
-
-* Fix inconsistent behaviour in the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-changes.html#add-reviewer[
-add reviewer endpoint]
-+
-When adding a single reviewer to a change, it was possible to use the endpoint
-to add a user who had no visibility to the change or whose account was invalid.
-
-
-==== Changes
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2583[Issue 2583]:
-Reject inline comments on files that do not exist in the patch set.
-
-* Allow forcing mergeability check on
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-changes.html#get-mergeable[
-Get mergeable].
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2622[Issue 2622]:
-Respect patch set visibility for messages.
-+
-Messages retrieval didn't check for patch set visbility and thus messages for
-draft patch sets were returned back to the client.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2782[Issue 2782]:
-Add missing documentation of the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-changes.html#get-related-changes[
-Get Related Changes] endpoint.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2723[Issue 2723]:
-Clarify the response info in the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-changes.html#get-change-detail[
-Get Change Detail] endpoint.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2693[Issue 2693]:
-Clarify the response info in the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-changes.html#list-comments[
-List Comments] endpoint.
-
-=== SSH
-
-
-* Prevent double authentication for the same public key.
-+
-This is a workaround for link:https://issues.apache.org/jira/browse/SSHD-300[
-SSHD-300].
-
-* Let `kill` SSH command only kill tasks that are visible to the caller.
-
-* Require 'Administrate Server' capability to see server summary output from
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/cmd-show-caches.html[
-`show-caches`] command.
-
-* Include all command arguments in SSH log entry.
-+
-The SSH log only included the first argument. This prevented the repository name
-from being logged when `git receive-pack` was executed instead of `git-receive-pack`.
-
-
-=== Daemon
-
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2284[Issue 2284]:
-More detailed error message when failing to upload new change.
-+
-When the uploaded change cannot be created on the underlying Git repository, a
-more descriptive error message is displayed on both client and server side. This
-allows to troubleshoot internal errors (e.g. JGit lock failures or other causes)
-and help out in the resolution.
-
-* Enforce HTTP password checking on gitBasicAuth.
-
-* Fix missing commit messages on submodule direct pushes.
-+
-The commit message in superproject was missing on submodule's
-directly pushed changes.
-
-
-=== Plugins
-
-==== General
-
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2895[Issue 2895]:
-Fix reload of plugins that use DynamicItem.
-
-* Invoke `StartPluginListener` and `ReloadPluginListener` only after start/reload
-is fully done.
-
-* Set `Last-Modified` on cached Documentation resources.
-
-* Return HTTP 304 for not modified SmallResources.
-
-* Fix ChangeListener auto-registered implementations.
-
-==== Replication
-
-
-* Move replication logs into a separate file.
-
-* Promote replication scheduled logs to info.
-
-* Show replication ID in the log and in show-queue command.
-
-
-== Upgrades
-
-
-* Update Guava to 17.0
-
-* Update Guice to 4.0-beta5
-
-* Update GWT to 2.6.1
-
-* Update httpclient to 4.3.4
-
-* Update httpcore to 4.3.2
-
-* Update Jcraft SSH to 0.1.51
-
-* Update Jetty to 9.2
-
-* Update JGit to 3.6.2.201501210735-r
-
-* Update log4j to 1.2.17
-
-* Update Servlet API to 8.0.5
-
-* Update slf4j to 1.7.7
-
-* Update Velocity to 1.7
-
diff --git a/ReleaseNotes/ReleaseNotes-2.11.1.txt b/ReleaseNotes/ReleaseNotes-2.11.1.txt
deleted file mode 100644
index 3583421..0000000
--- a/ReleaseNotes/ReleaseNotes-2.11.1.txt
+++ /dev/null
@@ -1,181 +0,0 @@
-= Release notes for Gerrit 2.11.1
-
-Gerrit 2.11.1 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.11.1.war[
-https://www.gerritcodereview.com/download/gerrit-2.11.1.war]
-
-Gerrit 2.11.1 includes the bug fixes done with
-link:ReleaseNotes-2.10.4.html[Gerrit 2.10.4] and
-link:ReleaseNotes-2.10.5.html[Gerrit 2.10.5]. These bug fixes are *not* listed
-in these release notes.
-
-There are no schema changes from link:ReleaseNotes-2.11.html[2.11].
-
-
-== New Features
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=321[Issue 321]:
-Use in-memory Lucene index for a better reviewer suggestion.
-+
-Instead of a linear full text search through a list of accounts, use an
-in-memory Lucene index. The index is periodically refreshed. The refresh period
-is configurable via the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11.1/config-gerrit.html#suggest.fullTextSearchRefresh[
-suggest.fullTextSearchRefresh] parameter.
-
-
-== Bug Fixes
-
-=== Performance
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3363[Issue 3363]:
-Fix performance degrade in background mergeability checks.
-+
-When neither `index.batchThreads` nor `changeMerge.threadPoolSize` was defined,
-the background mergeability check fell back to using an interactive executor.
-+
-This led to a severe performance degradation during git push operations because
-the `ref-update` listener was reindexing all open changes on the target branch
-interactively. The degradation increased linearly with number of open changes on
-the target branch.
-+
-Now, instead of indexing interactively, it falls back to a batch thread pool
-with the number of available logical CPUs.
-
-* Reduce unnecessary database access when querying changes.
-+
-Searching for changes was retrieving more information than necessary from the
-database. This has been optimized to reduce database access and make better use
-of the secondary index.
-
-* Remove unnecessary REST API call when opening the 'Patch Sets' drop down.
-+
-The change edit information was being loaded twice.
-
-=== Index
-
-* Fix `PatchLineCommentsUtil.draftByChangeAuthor`.
-+
-There is not a native index for this, and the ReviewDb case was not properly
-filtering a result by change.
-
-* Don't show stack trace when failing to build BloomFilter during reindex.
-
-=== Permissions
-
-* Require 'View Plugins' capability to list plugins through SSH.
-+
-The 'View Plugins' capability was required to list plugins through the REST API,
-but not through SSH.
-
-* Fix project creation with plugin config if user is not project owner.
-+
-On project creation it is possible to specify plugin configuration values that
-should be stored in the `project.config` file. This failed if the calling user
-was not becoming owner of the created project, because only project owners can
-edit the `project.config` file.
-
-
-=== Change Screen / Diff / Inline Edit
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3191[Issue 3191]:
-Always show 'Not Current' as state when looking at old patch set.
-+
-For merged changes it was confusing for users to see the status as 'Merged' when
-they look at an old patch set.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3337[Issue 3337]:
-Reenable 'Revert' button when revert is cancelled.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3378[Issue 3378]:
-Improve the cursor style in side-by-side diff and inline editor.
-+
-The cursor style is changed from an underscore to a solid vertical bar.
-+
-In the side-by-side diff, the cursor is placed on the first column of the diff,
-rather than at the end.
-
-=== Web Container
-
-* Fix `gc_log` when running in a web container.
-+
-All logs supposed to be in the `gc_log` file were ending up in the main log
-instead when deploying Gerrit in a web container.
-
-* Fix binding of SecureStore modules.
-+
-The SecureStore modules were not correctly added when Gerrit was deployed in a
-web container with the site path configured using the `gerrit.site_path`
-property.
-
-=== Plugins
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3310[Issue 3310]:
-Fix disabling plugins when Gerrit is running on Windows.
-+
-When running Gerrit on Windows it was not possible to disable a plugin due to an
-error renaming the plugin's JAR file.
-
-* Replication
-
-** Fix creation of missing repositories.
-+
-Missing projects were not being created on the destination.
-
-** Emit replication status events after initial full sync.
-+
-When `replicateOnStartup` is enabled, the plugin was not emitting the status
-events after the initial sync.
-
-=== Miscellaneous
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3328[Issue 3328]:
-Allow to push a tag that points to a non-commit object.
-+
-When pushing a tag that points to a non-commit object, like
-link:https://git.kernel.org/cgit/linux/kernel/git/stable/linux-stable.git/tag/?id=v2.6.11[
-`v2.6.11` on linux-stable] which points to a tree, or
-link:https://git.eclipse.org/c/jgit/jgit.git/tag/?id=spearce-gpg-pub[
-`spearce-gpg-pub` on jgit] which points to a blob, Gerrit rejected the push with
-the error message 'missing object(s)'.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3323[Issue 3323]:
-Fix internal server error when cloning from a slave while hiding some refs.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3342[Issue 3342]:
-Log `IOException` on failure to update project configuration.
-+
-Without logging these exceptions it's hard to guess why the update of the
-project configuration is failing.
-
-* Remove temporary GitWeb config on Gerrit exit.
-+
-A temporary directory was being created but not removed.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2791[Issue 2791]:
-Fix email validation for new TLDs such as `.systems`.
-
-* Assume change kind is 'rework' if `LargeObjectException` occurs.
-
-=== Documentation
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3325[Issue 3325]:
-Add missing `--newrev` parameter to the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11.1/config-hooks.html#_change_merged[
-change-merged hook documentation].
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3346[Issue 3346]:
-Fix typo in the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11.1/config-reverseproxy.html[
-Apache 2 configuration documentation].
-
-* Fix incorrect documentatation of
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11.1/config-gerrit.html#auth.registerUrl[
-auth types].
-
-== Updates
-
-* Update CodeMirror to 5.0.
-
-* Update commons-validator to 1.4.1.
diff --git a/ReleaseNotes/ReleaseNotes-2.11.10.txt b/ReleaseNotes/ReleaseNotes-2.11.10.txt
deleted file mode 100644
index a352aac..0000000
--- a/ReleaseNotes/ReleaseNotes-2.11.10.txt
+++ /dev/null
@@ -1,28 +0,0 @@
-= Release notes for Gerrit 2.11.10
-
-Gerrit 2.11.10 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.11.10.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.11.10.war]
-
-There are no schema changes from link:ReleaseNotes-2.11.9.html[2.11.9].
-
-== Bug Fixes
-
-* Fix synchronization of Myers diff and Histogram diff invocations.
-+
-The fix for
-link:https://code.google.com/p/gerrit/issues/detail?id=3361[Issue 3361]
-that was included in Gerrit versions 2.10.7 and 2.11.4 introduced a
-regression that prevented more than one file header diff from being
-computed at the same time across the entire server.
-
-* Fix `sshd.idleTimeout` setting being ignored.
-+
-The `sshd.idleTimeout` setting was not being correctly set on the SSHD
-backend, causing idle sessions to not time out.
-
-* Add the correct license for AsciiDoctor.
-+
-AsciiDoctor is licensed under the MIT License, not Apache2 as previously
-documented.
diff --git a/ReleaseNotes/ReleaseNotes-2.11.2.txt b/ReleaseNotes/ReleaseNotes-2.11.2.txt
deleted file mode 100644
index 98e66b0..0000000
--- a/ReleaseNotes/ReleaseNotes-2.11.2.txt
+++ /dev/null
@@ -1,99 +0,0 @@
-= Release notes for Gerrit 2.11.2
-
-Gerrit 2.11.2 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.11.2.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.11.2.war]
-
-Gerrit 2.11.2 includes the bug fixes done with
-link:ReleaseNotes-2.10.6.html[Gerrit 2.10.6]. These bug fixes are *not* listed
-in these release notes.
-
-There are no schema changes from link:ReleaseNotes-2.11.1.html[2.11.1].
-
-== New Features
-
-New SSH commands:
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11.2/cmd-index-start.html[
-`index start`]
-+
-Allows to restart the online indexer without restarting the Gerrit server.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11.2/cmd-index-activate.html[
-`index activate`]
-+
-Allows to activate the latest index version even if the indexing encountered
-problems.
-
-
-== Bug Fixes
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2761[Issue 2761]:
-Fix incorrect file list when comparing patchsets.
-+
-When comparing a patchset with another one, the added and deleted files were not
-displayed properly.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3460[Issue 3460]:
-Fix regression in the search box auto-suggestions.
-+
-A change introduced in version 2.11 caused the auto-suggestions to not work
-any more.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3355[Issue 3355]:
-Fix corruption of database when deleting draft change ref fails.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3426[Issue 3426]:
-Fix regression in the `%base` option.
-+
-A change introduced in version 2.11 caused the `%base` option to not work
-any more, meaning it was not possible to push a commit, which is already merged
-into a branch, for review to another branch of the same project.
-
-* link:https://bugs.eclipse.org/bugs/show_bug.cgi?id=468024[JGit bug 468024]:
-Fix data loss if a pack is pushed to a JGit based server and gc runs
-concurrently on the same repository.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3371[Issue 3371]:
-Fix wrong date/time for commits in `refs/meta/config` branch.
-+
-When the `refs/meta/config` branch was modified using the PutConfig REST endpoint
-(e.g. when changing the project configuration in the web UI) the commit date/time
-was wrong. Instead of the actual date/time the date/time of the last Gerrit server
-start was used.
-
-* Fix NullPointerException in the 'related changes' REST API endpoint.
-
-* Make sure `/a` is not in the project name for git-over-http requests.
-+
-The `/a` prefix is used to trigger authentication but was not removed from the
-request. Therefore, it was included in the project name and hence the project
-wasn't found when performing, for example `git fetch http://server/a/project`.
-
-* Fix disabling of git ssh commands.
-+
-The ssh commands were available even when ssh commands were disabled.
-
-* Fix native string handling in Plugin API.
-+
-The results of REST API calls were incorrectly being converted from NativeString
-to String when called from Javascript.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3440[Issue 3440]:
-Include prettify source files in the documentation.
-+
-The prettify source files were being loaded from `cdnjs.cloudflare.com`, which
-may cause trouble if the Gerrit instance is behind a firewall on a machine not
-allowed to access the Internet at large.
-+
-Now those files are bundled with the documentation.
-
-* Print proper name for project indexer tasks in `show-queue` command.
-
-* Print proper name for reindex after update tasks in `show-queue` command.
-
-== Updates
-
-* Update JGit to 4.0.1.201506240215-r.
-
diff --git a/ReleaseNotes/ReleaseNotes-2.11.3.txt b/ReleaseNotes/ReleaseNotes-2.11.3.txt
deleted file mode 100644
index f705d1e..0000000
--- a/ReleaseNotes/ReleaseNotes-2.11.3.txt
+++ /dev/null
@@ -1,90 +0,0 @@
-= Release notes for Gerrit 2.11.3
-
-Gerrit 2.11.3 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.11.3.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.11.3.war]
-
-There are no schema changes from link:ReleaseNotes-2.11.2.html[2.11.2].
-
-
-== Bug Fixes
-
-* Do not suggest inactive accounts.
-+
-When, for example, adding accounts to a group, the drop down list would also
-suggest inactive accounts.
-+
-Inactive accounts are now excluded from the suggestion.
-
-* Fix performance of side-by-side diff screen for huge files.
-+
-The `Render=Slow` preference was not being disabled for huge files, resulting
-in poor performance on most browsers.
-
-* Prefer JavaScript clipboard API if available.
-+
-Modern versions of Chrome support a draft clipboard API from JavaScript that
-allows copying without use of a Flash widget. If the API appears to be available
-in the browser, it is now used instead of the Flash widget.
-
-* Fix markdown rendering for the Gitiles plugin.
-+
-The Gitiles project uses the grappa library which causes a class collision with
-parboiled which was used by Gerrit. This resulted in markdown files not being
-rendered by the Gitiles plugin.
-
-* Fix submodule subscription for nested projects.
-+
-If the project name was 'a/b', and a project named 'b' also existed, the
-subscription would be incorrectly set on project 'b'.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3478[Issue 3478]:
-Show correct status line for draft patch sets.
-+
-If a new patch set was uploaded as draft to an existing published change,
-the status line did not reflect the draft status of the now current patch
-set.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3477[Issue 3477]:
-Fix client error when current patch set is not visible to user.
-+
-If the latest patch set of a change was a draft that was not visible to the
-logged in user, clicking on the side by side diff link caused a javascript error
-on the client.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3468[Issue 3468]:
-Include URL to change in "change closed" error message.
-+
-Instead of only the change number, the error message now includes the URL to
-the change.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3366[Issue 3366]:
-Call `NewProjectCreatedListeners` after project creation is complete.
-+
-The listeners were being called before all project details had been created
-and recorded.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3505[Issue 3505]:
-Add "Uploaded patch set 1" message for changes created via the UI.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3504[Issue 3504]:
-Prevent users from publishing change edits if they have not signed the CLA.
-+
-It was possible for users who had not signed the Contribution License Agreement
-(CLA) to publish change edits on projects that require a CLA.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2209[Issue 2209]:
-Honor username provided by container.
-
-* Stop logging unknown group membership for null UUID.
-Null UUIDs are now skipped rather than spamming the log.
-+
-UUIDs which have no registered backends are still logged. These may be errors
-caused by plugins not loading that an admin should pay attention to and try to
-resolve.
-
-== Updates
-
-* Update Guice to 4.0.
-* Replace parboiled 1.1.7 with grappa 1.0.4.
diff --git a/ReleaseNotes/ReleaseNotes-2.11.4.txt b/ReleaseNotes/ReleaseNotes-2.11.4.txt
deleted file mode 100644
index cfa8576..0000000
--- a/ReleaseNotes/ReleaseNotes-2.11.4.txt
+++ /dev/null
@@ -1,143 +0,0 @@
-= Release notes for Gerrit 2.11.4
-
-Gerrit 2.11.4 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.11.4.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.11.4.war]
-
-Gerrit 2.11.4 includes the bug fixes done with
-link:ReleaseNotes-2.10.7.html[Gerrit 2.10.7]. These bug fixes are *not* listed
-in these release notes.
-
-There are no schema changes from link:ReleaseNotes-2.11.3.html[2.11.3].
-
-
-== Bug Fixes
-
-* Fix NullPointerException in `ls-project` command with `--has-acl-for` option.
-+
-Using the `--has-acl-for` option for external groups (e.g. LDAP groups) was
-causing a NullPointerException.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3328[Issue 3328]:
-Allow to push a tag that points to a non-commit object.
-+
-When pushing a tag that points to a non-commit object, like
-link:https://git.kernel.org/cgit/linux/kernel/git/stable/linux-stable.git/tag/?id=v2.6.11[
-`v2.6.11` on linux-stable] which points to a tree, or
-link:https://git.eclipse.org/c/jgit/jgit.git/tag/?id=spearce-gpg-pub[
-`spearce-gpg-pub` on jgit] which points to a blob, Gerrit rejected the push with
-the error message 'missing object(s)'.
-+
-Note: This was previously fixed in Gerrit version 2.11.1, but was inadvertently
-reverted in 2.11.2 and 2.11.3.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2817[Issue 2817]:
-Insert `Change-Id` footer into access right changes.
-+
-When modifications of access rights were saved for review, the change
-did not have a `Change-Id` footer in the commit message.
-
-* Fix duplicated log lines after reloading a plugin.
-+
-If a plugin was reloaded, logs emitted from the plugin were duplicated.
-
-* Remove `--recheck-mergeable` option from `reindex` command documentation.
-+
-The `--recheck-mergeable` option was removed in Gerrit version 2.11.
-
-* Use the correct validation policy for commits created by Gerrit.
-+
-Commits created by Gerrit were being validated in the same way as commits
-received from users.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3557[Issue 3557]:
-Disallow invalid reference patterns in project configuration.
-+
-When editing a project configuration by using the UI or by submitting a change
-to `refs/meta/config`, it was possible to add a permission to an invalid
-reference pattern. This caused the project to be unavailable and the `ls-projects`
-command to fail whenever this project was encountered.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3574[Issue 3574]:
-Fix review labels with `AnyWithBlock` function.
-+
-The review labels with `AnyWithBlock` with 0 and +1 values blocked submit when
-reviewers were added.
-
-* Fix ref in tag list for signed/annotated tags.
-+
-The tag name from the header was used, rather than the ref name. In some cases
-this resulted in the wrong tag ref being listed.
-
-* Prevent user from bypassing `ref-update` hook through gerrit-created commits.
-+
-If the user used the cherry-pick ability in the UI or via the REST API, they
-could put a commit on a branch that bypassed the requirements of the `ref-update`
-hook (such as that certain branches require QA-tickets to be referenced in the
-commit message).
-
-* Allow `InternalUsers` to see drafts.
-+
-According to the documentation, `InternalUsers` should have full read access.
-This was not true, since `InternalUsers` could not see drafts.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2683[Issue 2683]:
-Fix non-ASCII password authentication failure under tomcat (LDAP).
-+
-The authentication with LDAP failed when the password contained non-ASCII
-characters such as ä, ö, Ä, and Ö.
-
-* Do not double decode the login URL token.
-+
-The login URL token used to redirect from the login servlet to the target page
-is already decoded and should not be decoded again.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3020[Issue 3020]:
-Include approvals specified on push in change message.
-+
-When using the `%l` option to apply a review label on uploaded changes or
-patch sets, the applied label was not mentioned in the change message.
-
-* Fire the `comment-added` hook for approvals specified on push.
-+
-When using the `%l` option to apply a review label on uploaded changes or
-patch sets, the `comment-added` hook was not being fired.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3602[Issue 3602]:
-Use uploader for approvals specified on push, not the committer.
-+
-When using the `%l` option to apply a review label on uploaded changes or
-patch sets, the review label was in some cases applied as the committer rather
-than the uploader.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3531[Issue 3531]:
-Fix internal server error on unified diff screen for anonymous users.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2414[Issue 2414]:
-Improve detection of requiring sign-in.
-+
-Some queries, such as the `has:*` operators, require the user to be signed in.
-+
-Also, when handling a REST API failure, detect 'Invalid authentication' responses
-as also requiring a new session.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3052[Issue 3052]:
-Fix 'Conflicts With' list for merge commits.
-+
-The 'Conflicts List' was not being populated correctly if the change being viewed
-was a merge commit, or if the change being viewed conflicted with an open merge
-commit.
-
-== Plugin Bugfixes
-
-* singleusergroup: Allow to add a user to a project's ACL using `user/username`.
-+
-A user could not be added to a project's ACL unless the user already had READ
-permission in the project's ACL.
-
-* replication: Add waiting time and number of retries to replication log.
-+
-Only the replication execution time was printed in the 'replication completed'
-log statement. The waiting time and retry count is added, to help debug
-replication delays.
diff --git a/ReleaseNotes/ReleaseNotes-2.11.5.txt b/ReleaseNotes/ReleaseNotes-2.11.5.txt
deleted file mode 100644
index 6957827..0000000
--- a/ReleaseNotes/ReleaseNotes-2.11.5.txt
+++ /dev/null
@@ -1,102 +0,0 @@
-= Release notes for Gerrit 2.11.5
-
-Gerrit 2.11.5 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.11.5.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.11.5.war]
-
-There are no schema changes from link:ReleaseNotes-2.11.4.html[2.11.4].
-
-
-== Important Notes
-
-*WARNING:* This release uses a forked version of buck.
-
-Buck was forked to cherry-pick an upstream fix for building on Mac OSX
-El Capitan.
-
-To build this release from source, the Google repository must be added to
-the remotes in the buck checkout:
-
-----
- $ git remote add google https://gerrit.googlesource.com/buck
-----
-
-
-== Bug Fixes
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3442[Issue 3442]:
-Handle commit validation errors when creating/editing changes via REST.
-+
-When an exception was thrown by a commit validator during creation of
-a new change, or during publish of an inline edit, this resulted in an
-internal server error message which did not include the actual reason
-for the error.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3616[Issue 3616]:
-Strip trailing blank lines from commit messages when modified in the inline
-editor.
-+
-Blank lines were not trimmed from the end of commit messages, which caused
-problems when the commit was merged and then cherry-picked with the `-x`
-option (from the command line).
-
-* Tweak JS clipboard API integration to work on Firefox.
-+
-The JS 'copy' functionality was working on Chrome, but not on Firefox.
-
-* Use image instead of unicode character for copy button.
-+
-Some browsers were unable to render the unicode character.
-
-* Include server config module in init step.
-+
-This allows SecureStore to be used during plugins' init step.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3659[Issue 3659]:
-Show inline comments in change screen history when inline edit is active.
-+
-It was not possible to see the inline comments in the history on the
-change screen when in edit mode.
-
-* Improve rendering of `stream-events` tasks in the `show-queue` output.
-+
-Entries for `stream-events` are now rendered as 'Stream Events (username)'.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3655[Issue 3655]:
-Fix incorrect owner group matching behavior.
-+
-When the given group did not match any group, the group was matched
-on a group whose name starts with the argument, instead of throwing an
-error to notify the user.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3664[Issue 3664]:
-Fix double slash on URL when switching account.
-+
-One too many slashes on the URL caused redirection back to the root
-page instead of the intended location.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3666[Issue 3666]:
-Fix server error when commit validator is invoked on initial commit.
-+
-If a commit was uploaded for review as the first commit in a repository
-that was created with no initial empty commit, invoking a commit validator
-on the new commit would cause an internal error.
-
-* Replication plugin.
-
-** Parse replication delay and retry times as time units.
-+
-The replication delay and retry values were interpreted as seconds and
-minutes respectively, but were being parsed as integers.
-+
-This is inconsistent with how time units are handled in other Gerrit
-configuration settings, and can cause confusion when the user configures
-them using the time unit syntax such as '15s' and it causes the plugin
-to fail with 'invalid value'.
-+
-The delay and retry now are parsed as time units. The value can be given
-in any recognized time unit, and the defaults remain the same as before;
-15 seconds and 1 minute respectively.
-
-** Remove documentation of obsolete `remote.NAME.timeout` setting.
diff --git a/ReleaseNotes/ReleaseNotes-2.11.6.txt b/ReleaseNotes/ReleaseNotes-2.11.6.txt
deleted file mode 100644
index 977ea14..0000000
--- a/ReleaseNotes/ReleaseNotes-2.11.6.txt
+++ /dev/null
@@ -1,123 +0,0 @@
-= Release notes for Gerrit 2.11.6
-
-Gerrit 2.11.6 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.11.6.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.11.6.war]
-
-There are no schema changes from link:ReleaseNotes-2.11.5.html[2.11.5].
-
-== Bug Fixes
-
-=== General
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3742[Issue 3742]:
-Use merge strategy for mergeability testing on 'Rebase if Necessary' strategy.
-+
-When pushing several interdependent commits to a project with the
-'Rebase if Necessary' strategy, all the commits except the first one were
-marked as 'Cannot merge'.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3762[Issue 3762]:
-Fix server error when querying changes with the `query` ssh command.
-
-* Fix server error when listing annotated/signed tag that has no tagger info.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3698[Issue 3698]:
-Fix creation of the administrator user on databases with pre-allocated
-auto-increment column values.
-+
-When using a database configuration where auto-increment column values are
-pre-allocated, it was possible that the 'Administrators' group was created
-with an ID other than `1`. In this case, the created admin user was not added
-to the correct group, and did not have the correct admin permissions.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3018[Issue 3018]:
-Fix query for changes using a label with a group operator.
-+
-The `group` operator was being ignored when searching for changes with labels
-because the search index does not contain group information.
-
-* Fix online reindexing of changes that don't already exist in the index.
-+
-Changes are now always reloaded from the database during online reindex.
-
-* Fix reviewer suggestion for accounts containing upper case letters.
-+
-When an email for an account contained upper-case letter(s), this account
-couldn't be added as a reviewer by selecting it from the suggested list of
-accounts.
-
-=== Authentication
-
-* Fix handling of lowercase HTTP username.
-+
-When `auth.userNameToLowerCase` is set to true the HTTP-provided username
-should be converted to lowercase as it is done on all the other authentication
-mechanisms.
-
-* Don't create new account when claimed OAuth identity is unknown.
-+
-The Claimed Identity feature was enabled to support old Google OpenID accounts,
-that cannot be activated anymore. In some corner cases, when for example the URL
-is not from the production Gerrit site, for example on a staging instance, the
-OpenID identity may deviate from the original one. In case of mismatch, the lookup
-of the user for the claimed identity would fail, causing a new account to be
-created.
-
-=== UI
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3714[Issue 3714]:
-Improve visibility of comments on dark themes.
-
-* Fix highlighting of search results and trailing whitespaces in intraline
-diff chunks.
-
-=== Plugins
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3768[Issue 3768]:
-Fix usage of `EqualsFilePredicate` in plugins.
-
-* Suggest to upgrade installed plugins per default during site initialization
-to new Gerrit version.
-+
-The default was 'No' which resulted in some sites not upgrading core
-plugins and running the wrong versions.
-
-* Fix reading of plugin documentation.
-+
-Under some circumstances it was possible to fail with an IO error.
-
-* Replication
-
-** Recursively include parent groups of groups specified in `authGroup`.
-+
-An `authGroup` could be included in other groups and should be granted the
-same permission as its parents.
-
-** Put back erroneously removed documentation of `remote.NAME.timeout`.
-
-** Add logging of cancelled replication events.
-
-* API
-
-** Allow to use `CurrentSchemaVersion`.
-
-** Allow to use `InternalChangeQuery.query()`.
-
-** Allow to use `JdbcUtil.port()`.
-
-** Allow to use GWTORM `Key` classes.
-
-== Documentation Updates
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=412[Issue 412]:
-Update documentation of `commentlink.match` regular expression to clarify
-that the expression is applied to the rendered HTML.
-
-* Remove warning about unstable change edit REST API endpoints.
-+
-These endpoints should be considered stable since version 2.11.
-
-* Document that `ldap.groupBase` and `ldap.accountBase` are repeatable.
-
diff --git a/ReleaseNotes/ReleaseNotes-2.11.7.txt b/ReleaseNotes/ReleaseNotes-2.11.7.txt
deleted file mode 100644
index 6742279..0000000
--- a/ReleaseNotes/ReleaseNotes-2.11.7.txt
+++ /dev/null
@@ -1,42 +0,0 @@
-= Release notes for Gerrit 2.11.7
-
-Gerrit 2.11.7 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.11.7.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.11.7.war]
-
-There are no schema changes from link:ReleaseNotes-2.11.6.html[2.11.6].
-
-== Bug Fixes
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3882[Issue 3882]:
-Fix 'No user on email thread' exception when label with group parameter is
-used in watched projects predicate.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3877[Issue 3877]:
-Include files in output when using `gerrit query` with combination of
-search operators.
-+
-A regression introduced in version 2.11.6 caused files to be omitted
-in the output.
-
-* Include comments in output when using `gerrit query` with the
-`--current-patch-set` option.
-+
-Comments were added at the change level but were not added at the
-patch set level.
-
-* Honor the `sendemail.allowrcpt` setting when adding new email address.
-+
-When adding a new email address via the UI or REST API, it was possible for
-the user to add an address that does not belong to a domain allowed by the
-`sendemail.allowrcpt` configuration. However, when sending the verification
-email, the recipient address was (correctly) dropped, and the email had no
-recipients. This resulted in an error from the SMTP server and an 'Internal
-server error' message to the user.
-
-* Remove unnecessary log messages.
-+
-The messages 'Assuming empty group membership' and 'Skipping delivery of
-email' do not add any value and were filling up the error log.
-
diff --git a/ReleaseNotes/ReleaseNotes-2.11.8.txt b/ReleaseNotes/ReleaseNotes-2.11.8.txt
deleted file mode 100644
index 0aa8dfc..0000000
--- a/ReleaseNotes/ReleaseNotes-2.11.8.txt
+++ /dev/null
@@ -1,41 +0,0 @@
-= Release notes for Gerrit 2.11.8
-
-Gerrit 2.11.8 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.11.8.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.11.8.war]
-
-There are no schema changes from link:ReleaseNotes-2.11.7.html[2.11.7].
-
-== Bug Fixes
-
-* Upgrade Apache commons-collections to version 3.2.2.
-+
-Includes a fix for a link:https://issues.apache.org/jira/browse/COLLECTIONS-580[
-remote code execution exploit].
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1207[Issue 1207]:
-Fix keyboard shortcuts for non-US keyboards on side-by-side diff screen.
-+
-The forward/backward navigation keys `[` and `]` only worked on keyboards where
-these characters could be typed without using any modifier key (like CTRL, ALT,
-etc.).
-+
-Note that the problem still exists on the unified diff screen.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3919[Issue 3919]:
-Explicitly set parent project to 'All-Projects' when a project is created
-without giving the parent.
-
-* Don't add message twice on abandon or restore via ssh review command.
-+
-When abandoning or reviewing a change via the ssh `review` command, and
-providing a message with the `--message` option, the message was added to
-the change twice.
-
-* Clear the input box after cancelling add reviewer action.
-+
-When the action was cancelled, the content of the input box was still
-there when opening it again.
-
-* Fix internal server error when aborting ssh command.
diff --git a/ReleaseNotes/ReleaseNotes-2.11.9.txt b/ReleaseNotes/ReleaseNotes-2.11.9.txt
deleted file mode 100644
index 52ee3fe..0000000
--- a/ReleaseNotes/ReleaseNotes-2.11.9.txt
+++ /dev/null
@@ -1,49 +0,0 @@
-= Release notes for Gerrit 2.11.9
-
-Gerrit 2.11.9 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.11.9.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.11.9.war]
-
-There are no schema changes from link:ReleaseNotes-2.11.8.html[2.11.8].
-
-== Bug Fixes
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=4070[Issue 4070]:
-Don't return current patch set in queries if the current patch set is not
-visible.
-+
-When querying changes with the `gerrit query` ssh command, and passing the
-`--current-patch-set` option, the current patch set was included even when
-it is not visible to the caller (for example when the patch set is a draft,
-and the caller cannot see drafts).
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3970[Issue 3970]:
-Fix keyboard shortcuts for special processing of CTRL and META.
-+
-The processing of CTRL and META was incorrectly removed in Gerrit version
-2.11.8, resulting in shortcuts like 'STRG+T' being interpreted as 'T'.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=4056[Issue 4056]:
-Fix download URLs for BouncyCastle libraries.
-+
-The location of the libraries was moved, so the download URLs are updated
-accordingly.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=4055[Issue 4055]:
-Fix subject for 'Updated Changes' lines on push.
-+
-When a change was updated it showed the subject from the previous patch set
-instead of the subject from the new current patch set.
-
-* Fix incorrect loading of access sections in `project.config` files.
-
-* Fix internal server error when `auth.userNameToLowerCase` is enabled
-and the auth backend does not provide the username.
-
-* Fix error reindexing changes when a change no longer exists.
-
-* Fix internal server error when loading submit rules.
-
-* Fix internal server error when parsing tracking footers from commit
-messages.
diff --git a/ReleaseNotes/ReleaseNotes-2.11.txt b/ReleaseNotes/ReleaseNotes-2.11.txt
deleted file mode 100644
index 1ca6825..0000000
--- a/ReleaseNotes/ReleaseNotes-2.11.txt
+++ /dev/null
@@ -1,837 +0,0 @@
-= Release notes for Gerrit 2.11
-
-
-Gerrit 2.11 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.11.war[
-https://www.gerritcodereview.com/download/gerrit-2.11.war]
-
-Gerrit 2.11 includes the bug fixes done with
-link:ReleaseNotes-2.10.1.html[Gerrit 2.10.1],
-link:ReleaseNotes-2.10.2.html[Gerrit 2.10.2] and
-link:ReleaseNotes-2.10.3.html[Gerrit 2.10.3].
-These bug fixes are *not* listed in these release notes.
-
-
-== Important Notes
-
-
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-Gerrit 2.11 requires a secondary index, which can be created offline
-by running the `reindex` program:
-
-----
-  java -jar gerrit.war reindex -d site_path
-----
-
-If the site that is upgraded already has a secondary index, the
-secondary index can be upgraded online. This is important for large
-sites since running the `reindex` program can take a long time and
-contributes significantly to the downtime that is required for the
-upgrade.
-
-Gerrit 2.11 supports online reindexing only from the index version `11`
-which is the index version of Gerrit 2.10. This means if you come from
-an older release it makes sense to first upgrade to 2.10 and then do
-the upgrade to 2.11 so that you can profit from online reindexing.
-
-In case you are upgrading from 2.10 it is *important* to check *before*
-the upgrade to 2.11 that the index version of your Gerrit 2.10 site is
-`11`. You can check the index version in
-`$site_path/index/gerrit_index.config`. Your Gerrit 2.10 site may run
-with an older index version (e.g. if online reindexing to index version
-`11` is still running or if online reindexing to version `11` has
-failed). In this case you first need to successfully migrate your index
-version of your Gerrit 2.10 site to `11` and only then start with the
-2.11 upgrade. If you start the 2.11 upgrade when the schema version of
-your Gerrit 2.10 site is older than `11`, online reindexing is no longer
-possible and you need to reindex offline by using the `reindex` program.
-
-*WARNING:* Upgrading to 2.11.x requires the server be first upgraded to 2.8 (or
-2.9) and then to 2.11.x. If you are upgrading from 2.8.x or later, you may ignore
-this warning and upgrade directly to 2.11.x.
-
-*WARNING:* When upgrading from version 2.8.4 or older with a site that uses
-Bouncy Castle Crypto, new versions of the libraries will be downloaded. The old
-libraries should be manually removed from site's `lib` folder to prevent the
-startup failure described in
-link:https://code.google.com/p/gerrit/issues/detail?id=3084[Issue 3084].
-
-*WARNING:* The 'Generate HTTP Password' capability has been
-link:#remove-generate-http-password-capability[removed].
-
-*WARNING:* Google will
-link:https://developers.google.com/+/api/auth-migration[shut down their OpenID
-service on 20th April 2015]. Administrators of sites whose users are registered
-with Google OpenID accounts should encourage the users to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-sso.html#_multiple_identities[
-add an alternative identity to their account] before this date. Users who do
-not add an alternative identity before this date will need to create a new
-account and ask the site administrator to
-link:https://code.google.com/p/gerrit/wiki/SqlMergeUserAccounts[merge it].
-
-*WARNING:* The
-link:https://gerrit-review.googlesource.com/Documentation/2.10/rest-api-changes.html#message[
-Edit Commit Message] REST API endpoint is removed
-
-*WARNING:* The deprecated '/query' URL is removed and will now return `Not Found`.
-
-== Release Highlights
-
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=505[Issue 505]:
-Changes can be created and edited directly in the browser. See the
-link:#inline-editing[Inline editing] section for more details.
-
-* Many improvements in the new change screen.
-
-* The old change screen is removed.
-
-
-== New Features
-
-
-=== Web UI
-
-[[inline-editing]]
-==== Inline Editing
-
-Refer to the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/user-inline-edit.html[
-inline editing user guide] for detailed instructions.
-
-* New changes can be created directly in the browser via a 'Create Change'
-button on the project info screen.
-
-* New follow-up changes can be created via a 'Follow-Up' button on the change
-screen.
-
-* File content can be edited in a full screen CodeMirror editor with support for
-themes and syntax highlighting.
-
-* The CodeMirror screen can be configured in the same way as the side-by-side
-diff screen.
-
-* The file table in the change screen supports seamless navigation to the
-CodeMirror editor.
-
-* Edit mode can be started from the side-by-side diff screen with seamless
-navigation to the CodeMirror editor.
-
-* The commit message must now be changed in the context of a change edit. The
-'Edit Message' button is removed from the change screen.
-
-* Files can be added, deleted, restored and modified directly in browser.
-
-==== Change Screen
-
-* Remove the 'Edit Message' button from the change screen.
-+
-The commit message is now edited using the inline edit feature.
-
-* Add support for changing parent revision with the 'Rebase' button.
-+
-Using the 'Rebase' button it is now possible to rebase a change onto a
-different change (on the same destination branch), rather than only onto the
-head of the destination branch or the latest patch set of the predecessor change.
-
-* Show the parent commit's subject as a tooltip.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2541[Issue 2541],
-link:http://code.google.com/p/gerrit/issues/detail?id=2974[Issue 2974]:
-Allow the 'Reply' button's
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#change.replyLabel[
-label and tooltip] to be configured.
-
-* Improve file sorting for C and C++ files.
-+
-Header files are now listed before implementation files.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3148[Issue 3148]:
-Allow display of colored size bars to be enabled or disabled per user.
-+
-The 'Show Change Sizes As Colored Bars In Changes Table' setting is renamed to
-'Show Change Sizes As Colored Bars' and is now used to also control how the
-change size is shown per file in the file table.
-+
-When enabled (which is the default), the change size per file is shown as a sum
-of lines added/removed, and also representated by a colored bar showing the
-proportion of added/removed lines.
-+
-When disabled, the colored bar is not shown and the change size per file is shown
-in the same way as it used to be in the old change screen.
-
-* Show changes across all projects and branches in the `Same Topic` tab.
-
-
-==== Side-By-Side Diff
-
-* New button to switch between side-by-side diff and unified diff.
-
-* New preference setting to toggle auto-hiding of the diff table header.
-+
-The setting determines whether or not the diff table header with the patch set
-selection should be automatically hidden when scrolling down more than half of
-a page.
-
-* Highlight search results on scrollbar.
-+
-Search results in vim mode are highlighted in the scrollbar with gold
-colored annotations.
-
-* Set line length to 72 characters for commit messages.
-
-* Add syntax highlighting for several new modes:
-
-** link:https://code.google.com/p/gerrit/issues/detail?id=2848[Issue 2848]: CSharp
-** Dart
-** Dockerfile
-** GLSL shader
-** Go
-** Objective C
-** RELAX NG
-** link:http://code.google.com/p/gerrit/issues/detail?id=2779[Issue 2779]: reStructured text
-** Soy
-
-
-==== Projects Screen
-
-* Add pagination and filtering on the branch list page.
-
-* Add an 'Edit Config' button on the project info page.
-+
-The button creates a new change on the `refs/meta/config` branch and opens the
-`project.config` file in the inline editor.
-+
-This allows project owners to easily edit the `project.config` file from the
-browser, which is useful since it is possible that not all configuration options
-are available in the UI.
-
-=== REST
-
-==== Accounts
-
-* Add new link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-accounts.html#suggest-account[
-Suggest Account endpoint].
-
-==== Changes
-
-* The link:https://gerrit-review.googlesource.com/Documentation/2.10/rest-api-changes.html#message[
-Edit Commit Message] endpoint is removed in favor of the new
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#put-change-edit-message[
-Change commit message in Change Edit] and
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#publish-edit[
-Publish Change Edit] endpoints.
-
-* Add new
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#check-change[
-Check Change endpoint].
-+
-In the past, Gerrit bugs, lack of transactions, and unreliable NoSQL backends
-have at various times produced a bewildering variety of corrupt states.
-+
-This endpoint can be used to detect, explain, and repair some of these possible
-states of a change.
-
-* Add new
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#get-revision-actions[
-Get Revision Actions endpoint].
-
-* Add
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#change-actions[
-`CHANGE_ACTIONS`] option on the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#get-change-detail[
-Get Change Detail] endpoint.
-
-
-==== Change Edits
-
-Several new endpoints are added to support the inline edit feature.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#get-edit-detail[
-Get Edit Detail].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#put-edit-file[
-Change file content in Change Edit].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#post-edit[
-Restore file content in Change Edit].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#put-change-edit-message[
-Change commit message in Change Edit].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#delete-edit-file[
-Delete file in Change Edit].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#get-edit-file[
-Retrieve file content from Change Edit].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#get-edit-message[
-Retrieve commit message from Change Edit or current patch set of the change].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#publish-edit[
-Publish Change Edit].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#rebase-edit[
-Rebase Change Edit].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#delete-edit[
-Delete Change Edit].
-
-
-==== Projects
-
-* Add new
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-projects.html#delete-branches[
-Delete Branches] endpoint.
-
-* Add filtering and pagination options on the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-projects.html#list-branches[
-List Branches] endpoint.
-
-* Add new
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-projects.html#list-tags[
-List Tags] endpoint.
-
-* Add new
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-projects.html#get-tag[
-Get Tag] endpoint.
-
-
-=== Configuration
-
-* Add support for
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#auth.httpExternalIdHeader[
-HTTP external ID header].
-+
-This can be used when authenticating with a federated identity token from
-an external system, e.g. GitHub's OAuth 2.0 authentication.
-
-* Add
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-labels.html#label_copyAllScoresIfNoChange[
-`copyAllScoresIfNoChange`] setting for labels.
-+
-Allows to copy scores forward when a new patch set is uploaded that has the same
-parent tree, code delta, and commit message as the previous patch set.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2786[Issue 2786]:
-Allow non-administrators to modify user accounts.
-+
-A new global capability,
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/access-control.html#capability_modifyAccount[
-'Modify Account'], which allows the granted group members to modify user account
-settings via the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/cmd-set-account.html[
-`set-account` SSH command].
-+
-Modification of users' SSH keys is still restricted to administrators.
-
-* Add support for
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#ldap.useConnectionPooling[
-LDAP connection pooling].
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=699[Issue 699]: Allow to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#receive.maxBatchChanges[
-limit max number of changes pushed in a batch].
-+
-Can be overridden by members of groups that are granted the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/access-control.html#capability_batchChangesLimit[
-Batch Changes Limit] capability.
-
-* Allow to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#gerrit.disableReverseDnsLookup[
-disable reverse DNS lookup].
-+
-This option can be set to improve push time from hosts without a reverse DNS
-entry.
-
-* Allow to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#cache.projects.loadOnStartup[
-load the project cache at server startup].
-
-* Allow members of groups granted the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/access-control.html#capability_accessDatabase[
-AccessDatabase capability] to view metadata refs.
-
-* Allow to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#http.addUserAsRequestAttribute[
-add the user to the http request attributes].
-
-* Allow to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#suggest.fullTextSearch[
-enable full text search in memory for review suggestions].
-+
-The maximum number of reviewers evaluated can be limited with
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#suggest.fullTextSearchMaxMatches[
-suggest.fullTextSearchMaxMatches].
-
-* Allow to provide an alternative
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#gerrit.secureStoreClass[
-secure store implementation].
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1195[Issue 1195]:
-Allow projects to be configured to create a new change for every uploaded commit that is not in the target branch.
-
-* Allow to configure
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#container.daemonOpt[
-options to pass to the daemon].
-
-=== Daemon
-
-* Allow to enable the http daemon when running in slave mode.
-+
-The `--enable-httpd` option can be used in conjunction with the `--slave` option
-to allow clients to fetch from the slave over the http protocol.
-+
-HTTP Authentication may also be used when running in slave mode.
-
-* Include the submitter's name in the change message when a change is submitted.
-
-* Add a message to changes created via cherry pick.
-+
-When a change is cherry-picked to another branch using the cherry-pick action,
-the message 'Patch Set <number>: Cherry Picked from branch <name>.' is added as
-a change message on the created change.
-
-* Don't send 'new patch set' notification emails for trivial rebases.
-
-
-=== SSH
-
-* Add new commands
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/cmd-logging-ls-level.html[
-`logging ls-level`] and
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/cmd-logging-set-level.html[
-`logging set-level`] to show and set the logging level at runtime.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=602[Issue 602]:
-Add `--json` option to the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/cmd-review.html[
-`review` SSH command].
-+
-Review input can be given to the `review` command in JSON format corresponding
-to the REST API's
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#review-input[
-ReviewInput] entity.
-
-*  link:https://code.google.com/p/gerrit/issues/detail?id=2824[Issue 2824]:
-Add `--rebase` option to the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/cmd-review.html[
-`review` SSH command].
-
-* Add `--clear-http-password` option to the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/cmd-set-account.html[
-`set-account` SSH command].
-
-* Add `--preferred-email` option to the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/cmd-set-account.html[
-`set-account` SSH command].
-
-=== Email
-
-* Add `$change.originalSubject` field for email templates.
-+
-GMail threads messages together by subject and ignores the list headers included
-by Gerrit.
-+
-Site administrators that run servers whose end-user base is mostly on GMail can
-modify the site's `ChangeSubject.vm` template to use `$change.originalSubject` to
-improve threading for GMail inboxes.
-+
-The `originalSubject` field is automatically taken from the existing subject
-field during first use.
-
-
-=== Plugins
-
-==== General
-
-* Plugins can listen to account group membership changes.
-+
-The audit log service allows to register listeners to group member added and
-group member deleted events. A default listener logs these events to the database
-as before, but additional listeners may now be registered for these events using
-the `GroupMemberAuditListener` interface.
-
-* Plugins can validate ref operations.
-+
-Plugins implementing the `RefOperationValidationListener` interface can
-perform additional validation checks against ref creation/deletion operations
-before they are applied to the git repository.
-
-* Plugins can provide project-aware top menu extensions
-+
-Plugins can provide sub-menu items within the 'Projects' context. The
-'$\{projectName\}' placeholder is replaced by the project name.
-
-* Auto register static/init.js as JavaScript plugin.
-+
-When a plugin does not expose Guice Modules explicitly, auto discover and
-register static/init.js as WebUi extension if found by the plugin content
-scanner.
-
-* Plugins can validate outgoing emails.
-+
-Plugins implementing `OutgoingEmailValidationListener` interface can filter
-and modify outgoing emails before they are sent.
-
-* Plugins that provide initialization steps may now use functionality
-from InitUtil in core Gerrit.
-
-* Plugins can post change reviews with historic timestamps.
-+
-This allows, for example, to write a plugin that can import a project including
-review information from another Gerrit server.
-
-* New extensions in the Java Plugin API:
-
-** Set/Put topic.
-** Get mergeable status.
-** link:https://code.google.com/p/gerrit/issues/detail?id=461[Issue 461]:
-Get current user.
-** Get file content.
-** Get file diff.
-** Get comments and drafts.
-** Get change edit.
-
-==== Replication
-
-* Projects can be specified with wildcard in the `start` command.
-
-
-== Bug Fixes
-
-=== Daemon
-
-* Change 'Merge topic' to 'Merge changes from topic'.
-+
-When multiple changes from a topic are submitted resulting in a merge commit,
-the title of the merge commit is now 'Merge changes from topic' instead of
-'Merge topic'.
-
-* Fix visibility checks for `refs/meta/config`.
-+
-Under some conditions it was possible for the `refs/meta/config` branch to be
-erroneously considered not visible to the user.
-
-* Sort list of updated changes in output from push.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2940[Issue 2940]:
-Improve warning messages when `Change-Id` is missing in the commit message.
-
-** Add a hint to amend the commit after installing the commit-msg hook.
-** Don't show 'Suggestion for commit message' when `Change-Id` is missing.
-
-* Allow to publish draft patch sets even when `allowDrafts` is false.
-+
-If a user uploaded a change while `allowDrafts` was enabled, and then it was
-disabled by the administrator, the uploaded change could not be published and
-was stuck in the draft state.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3249[Issue 3249]:
-Fix server error when checking mergeability of a change.
-
-* Workaround Guice bug "getPathInfo not decoded".
-+
-Due to link:https://github.com/google/guice/issues/745[Guice issue 745], cloning
-of a repository with a space in its name was impossible.
-
-
-=== Secondary Index / Search
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2822[Issue 2822]:
-Improve Lucene analysis of words linked with underscore or dot.
-+
-Instead of treating words linked with underscore or dot as one word, Lucene now
-treats them as separate words.
-
-* Fix support for `change~branch~id` in query syntax.
-
-
-=== Configuration
-
-[[remove-generate-http-password-capability]]
-* Remove the 'Generate HTTP Password' capability.
-+
-The 'Generate HTTP Password' capability has been removed to close a security
-vulnerability.  Now only administrators are allowed to generate and delete other
-users' http passwords via the REST or SSH interface.
-+
-It is encouraged to clean up your `project.config` settings after upgrading.
-
-* Fix support for multiple `footer` tokens in tracking ID config.
-+
-Contrary to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#trackingid[
-the documentation], if more than one `footer` token was specified in the
-`trackingid` section, only the first was used.
-
-* Treat empty
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#hooks[
-`hooks.*`] values as missing, rather than trying to execute the hooks
-directory.
-
-* Fix `changed-merged` hook configuration.
-+
-Contrary to the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#hooks[
-documentation], the changed-merged hook configuration value was being
-read from `hooks.changeMerged`. Fix to use `hooks.changeMergedHook` as
-documented.
-
-=== Web UI
-
-==== Change List
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3304[Issue 3304]:
-Always show a tooltip on the label column entries.
-
-==== Change Screen
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3147[Issue 3147]:
-Allow to disable muting of common path prefixes in the file list.
-+
-In the file table, parts of the file path that are common to the file previously
-listed are muted. The purpose of this is to make it easier to see files that all
-belong under the same path, but some users find it annoying.
-+
-This feature can now be enabled or disabled, per user, with the 'Mute Common
-Path Prefixes In File List' setting.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3130[Issue 3130]:
-Remove special handling of 'LGTM' in review comments
-+
-Typing 'LGTM' in the review cover message no longer automatically selects the
-highest available Code-Review score.
-
-* Show a confirmation dialog before deleting a draft change or patch set.
-+
-Previously there was no confirmation and a draft change or revision patch
-set would be lost if the button was accidentally clicked.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2533[Issue 2533]:
-Improve the layout and color scheme of buttons.
-+
-Several improvements have been made:
-+
-** Move 'Publish' and 'Delete Change/Revision' buttons into header.
-+
-If a change/revision is a draft the natural next step is to publish (or delete)
-it, hence these buttons should be displayed in a more prominent place.
-
-** Highlight the 'Publish' button in blue.
-+
-If a change is a draft the natural next step is to publish it, hence
-the 'Publish' button should be highlighted similar to the quick
-approve button.
-
-** Fix the border color of buttons on the reply popup.
-+
-The buttons are blue but had white borders, which was inconsistent with the
-buttons on the change screen.
-
-** Remove red color for 'Abandon' and 'Restore' buttons.
-+
-There is nothing dangerous about these operations that justifies
-highlighting the buttons in red color. When the buttons are clicked
-there is a popup where the user must confirm the operation, so it can
-still be canceled.
-
-** Hide quick approve button for draft changes.
-+
-A draft change cannot be submitted, hence quick approving it is not that
-important. Hiding the quick approve button on draft changes makes space in the
-header for displaying more important actions such as 'Publish'.
-
-* Differentiate between conflicts and already merged errors in cherry-pick
-+
-When a cherry-pick operation failed with 'Cherry pick failed' error, there was no
-way to know the reason for the failure: merge conflict or the commit is already
-on the target branch.  These failures are now differentiated and an appropriate
-error is reported.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2837[Issue 2837]:
-Improve display of long user names for collapsed comments in history.
-+
-If there were several users with long user names with the same prefix, e.g.
-'AutomaticGerritVoterLinux' and 'AutomaticGerritVoterWindows', they would both
-be shown as 'AutomaticGerritVo...' and users had to expand the comment to see
-the full user name.
-+
-The ellipsis is now inserted in the middle of the user name so that the start
-and end of the user name are always visible, e.g. 'AutomaticG...VoterLinux' and
-'AutomaticG...terWindows'.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2992[Issue 2992]:
-Fix display of review comments for Chrome on Android.
-+
-Chrome for Android has Font Boosting, which caused the review comments to
-be displayed too large.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2909[Issue 2909]:
-Make change owner votes removable.
-+
-If a change owner voted on a change, it was not possible for anyone other
-than the owner to remove the vote.
-
-* Preserve topic when cherry-picking.
-+
-When a change is cherry-picked, the topic from the source change is preserved
-on the newly created change.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3007[Issue 3007]:
-Make the selected tab persistent.
-+
-If a change from the 'Same Topic' tab was clicked, the selected tab would reset
-to the default tab ('Related Changes').
-
-* Left-align column titles in the file list.
-
-* Increase right margin of download box to make space for scrollbar.
-+
-Under some circumstances the browser's scrollbar would be shown over the
-copy-to-clipboard icons in the download dropdown.
-
-* Display +1 score's text next to the checkbox for simple boolean labels.
-+
-In the reply box, the text of the label score is displayed on the right hand
-side when a score is selected, but this was missing for simple boolean labels.
-
-* Don't show missing accounts as reviewer suggestions.
-
-* Show the email address that matched the search in reviewer suggestions.
-+
-When matching accounts by email address against an external account, results
-now show the email address that matched, not the preferred email address.
-
-* Fix accidental reviewer selection on slow networks.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3120[Issue 3120]:
-Align parent weblinks with parent commits in the commit box.
-
-
-==== Side-By-Side Diff
-
-* Return to normal mode after editing a draft comment.
-+
-Previously it would remain in visual mode.
-
-* Fix C++ header and source syntax highlighting
-+
-cpp and hpp files were sometimes rendered with C mode and not the extended C++
-mode.  This prevented keywords like `class` from being colored by the
-highlighter.
-
-
-==== Project Screen
-
-* Fix alignment of checkboxes on project access screen.
-+
-The 'Exclusive' checkbox was not aligned with the other checkboxes.
-
-=== REST API
-
-==== Changes
-
-* Remove the administrator restriction on the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#index-change[
-index change] endpoint.
-+
-The endpoint can now be used by any user who has visibility of the change.
-
-* Only include account ID in responses unless `DETAILED_ACCOUNTS` option is set.
-+
-The behavior was inconsistent with the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-accounts.html#account-info[
-documentation]. In the default case it was including only the account name,
-rather than only the account ID.
-
-* Include revision's ref in responses.
-+
-The ref of a revision was only returned as part of the fetch info, which is only
-available if the download commands are installed.
-
-* Correctly set the limit to the default when no limit is given in the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#suggest-reviewers[
-suggest reviewers] endpoint.
-
-* Return correct response from 'delete draft' endpoints.
-+
-When the `change.allowDrafts` setting is False, it is not allowed to delete
-draft changes or patch sets.
-+
-In this case the response `405 Method Not Allowed` is now returned, instead of
-`409 Conflict`.
-
-
-==== Projects
-
-* Make it mandatory to specify at least one of the `--prefix`, `--match` or `--regex`
-options in the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-projects.html#list-projects[
-list projects] endpoint.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2706[Issue 2706]:
-Do not delete branches concurrently.
-+
-Deleting multiple branches from the UI was resulting in a server error when
-branches were in the packed-refs.
-
-* Add retry logic for lock failure when deleting a branch.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3153[Issue 3153]:
-Fix handling of project names ending with `.git`.
-+
-The projects REST API documentation states that the `.git` suffix will be
-stripped off the input project name, if present.
-+
-This was working for the 'Create Project' endpoint, but not for any of the
-others.
-
-
-=== Plugins
-
-==== Replication
-
-* Create missing repositories on the remote when replicating with the git
-protocol.
-
-* Make `createMissingRepositories = false` take effect on `project-created` event.
-+
-Previously `createMissingRepositories = false` would prevent the replication
-plugin from trying to create a new project when a `ref-updated` event was fired,
-but when a `project-created` event was fired the replication plugin would try to
-create a project on the remote.
-
-
-== Upgrades
-
-* Update Antlr to 3.5.2.
-
-* Update ASM to 5.0.3.
-
-* Update CodeMirror to 4.10.0-6-gd0a2dda.
-
-* Update Guava to 18.0.
-
-* Update Guice to 4.0-beta5.
-
-* Update GWT to 2.7.
-
-* Update gwtjsonrpc to 1.7-2-g272ca32.
-
-* Update gwtorm to 1.14-14-gf54f1f1.
-
-* Update Jetty to 9.2.9.v20150224.
-
-* Update JGit to 3.7.0.201502260915-r.58-g65c379e.
-
-* Update Lucene to 4.10.2.
-
-* Update Parboiled to 1.1.7.
-
-* Update Pegdown to 1.4.2.
diff --git a/ReleaseNotes/ReleaseNotes-2.12.1.txt b/ReleaseNotes/ReleaseNotes-2.12.1.txt
deleted file mode 100644
index 8f94810..0000000
--- a/ReleaseNotes/ReleaseNotes-2.12.1.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-= Release notes for Gerrit 2.12.1
-
-Release notes have been moved to the project homepage:
-link:https://www.gerritcodereview.com/releases/2.12.md#2.12.1[
-Release notes for Gerrit 2.12.1].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.2.txt b/ReleaseNotes/ReleaseNotes-2.12.2.txt
deleted file mode 100644
index 35682ed..0000000
--- a/ReleaseNotes/ReleaseNotes-2.12.2.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-= Release notes for Gerrit 2.12.2
-
-Release notes have been moved to the project homepage:
-link:https://www.gerritcodereview.com/releases/2.12.md#2.12.2[
-Release notes for Gerrit 2.12.2].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.3.txt b/ReleaseNotes/ReleaseNotes-2.12.3.txt
deleted file mode 100644
index 06b18da..0000000
--- a/ReleaseNotes/ReleaseNotes-2.12.3.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-= Release notes for Gerrit 2.12.3
-
-Release notes have been moved to the project homepage:
-link:https://www.gerritcodereview.com/releases/2.12.md#2.12.3[
-Release notes for Gerrit 2.12.3].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.4.txt b/ReleaseNotes/ReleaseNotes-2.12.4.txt
deleted file mode 100644
index 8321efa..0000000
--- a/ReleaseNotes/ReleaseNotes-2.12.4.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-= Release notes for Gerrit 2.12.4
-
-Release notes have been moved to the project homepage:
-link:https://www.gerritcodereview.com/releases/2.12.md#2.12.4[
-Release notes for Gerrit 2.12.4].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.5.txt b/ReleaseNotes/ReleaseNotes-2.12.5.txt
deleted file mode 100644
index 4199fe0..0000000
--- a/ReleaseNotes/ReleaseNotes-2.12.5.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-= Release notes for Gerrit 2.12.5
-
-Release notes have been moved to the project homepage:
-link:https://www.gerritcodereview.com/releases/2.12.md#2.12.5[
-Release notes for Gerrit 2.12.5].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.txt b/ReleaseNotes/ReleaseNotes-2.12.txt
deleted file mode 100644
index 3eae5e4..0000000
--- a/ReleaseNotes/ReleaseNotes-2.12.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-= Release notes for Gerrit 2.12
-
-Release notes have been moved to the project homepage:
-link:https://www.gerritcodereview.com/releases/2.12.md[
-Release notes for Gerrit 2.12].
diff --git a/ReleaseNotes/ReleaseNotes-2.13.1.txt b/ReleaseNotes/ReleaseNotes-2.13.1.txt
deleted file mode 100644
index 7b27ad3..0000000
--- a/ReleaseNotes/ReleaseNotes-2.13.1.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-= Release notes for Gerrit 2.13.1
-
-Release notes have been moved to the project homepage:
-link:https://www.gerritcodereview.com/releases/2.13.md#2.13.1[
-Release notes for Gerrit 2.13.1].
diff --git a/ReleaseNotes/ReleaseNotes-2.13.2.txt b/ReleaseNotes/ReleaseNotes-2.13.2.txt
deleted file mode 100644
index 72bd218..0000000
--- a/ReleaseNotes/ReleaseNotes-2.13.2.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-= Release notes for Gerrit 2.13.2
-
-Release notes have been moved to the project homepage:
-link:https://www.gerritcodereview.com/releases/2.13.md#2.13.2[
-Release notes for Gerrit 2.13.2].
diff --git a/ReleaseNotes/ReleaseNotes-2.13.txt b/ReleaseNotes/ReleaseNotes-2.13.txt
deleted file mode 100644
index b3e125d..0000000
--- a/ReleaseNotes/ReleaseNotes-2.13.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-= Release notes for Gerrit 2.13
-
-Release notes have been moved to the project homepage:
-link:https://www.gerritcodereview.com/releases/2.13.md[
-Release notes for Gerrit 2.13].
diff --git a/ReleaseNotes/ReleaseNotes-2.2.0.txt b/ReleaseNotes/ReleaseNotes-2.2.0.txt
deleted file mode 100644
index 5cc54f9..0000000
--- a/ReleaseNotes/ReleaseNotes-2.2.0.txt
+++ /dev/null
@@ -1,59 +0,0 @@
-= Release notes for Gerrit 2.2.0
-
-Gerrit 2.2.0 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.2.0.war[https://www.gerritcodereview.com/download/gerrit-2.2.0.war]
-
-== Schema Change
-*WARNING:* Upgrading to 2.2.0 requires the server be first upgraded
-to 2.1.7, and then to 2.2.0.
-
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* The "projects" and "ref_rights" tables are no longer
-stored in the SQL database. The tables have been moved to Git
-storage, inside of the `refs/meta/config` branch of each managed
-Git repository. The init based upgrade tool will automatically
-export the current table contents and create the Git data.
-
-== New Features
-
-=== Project Administration
-* issue 436 List projects by scanning the managed Git directory
-+
-Instead of generating the list of projects from SQL database, the
-project list is obtained by recursively scanning the Git directory.
-Adding new projects is now simply a matter of creating the Git
-repository under the directory and flushing the "projects" cache
-to force the server to rescan the directory. Administrators may
-also continue to use `gerrit create-project`.
-
-* Move "projects" table into Git
-+
-The projects table columns are now stored in the `project.config`
-file of the `refs/meta/config` branch of each managed Git repository.
-
-* Move "ref_rights" table into Git
-+
-The "ref_rights" table is now stored in the "access" sections of
-the `project.config` file on the `refs/meta/config` branch of each
-managed Git repository. This brings version control auditing to the
-access data of each project.
-
-* New project Access screen to edit access controls
-+
-The Access panel of the project administration has been rewritten
-with a new UI that reflects the new Git based storage format.
-
-== Bug Fixes
-
-=== Project Administration
-* Avoid unnecessary updates to $GIT_DIR/description
-+
-Gerrit always tried to rewrite the gitweb "description" file when the
-project was modified. This lead to unnecessary changes in the local
-filesystem, leading to more data to rsync to backups than necessary.
-Fixed to only update the file if the content changes.
diff --git a/ReleaseNotes/ReleaseNotes-2.2.1.txt b/ReleaseNotes/ReleaseNotes-2.2.1.txt
deleted file mode 100644
index 26aa8db..0000000
--- a/ReleaseNotes/ReleaseNotes-2.2.1.txt
+++ /dev/null
@@ -1,75 +0,0 @@
-= Release notes for Gerrit 2.2.1
-
-Gerrit 2.2.1 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.2.1.war[https://www.gerritcodereview.com/download/gerrit-2.2.1.war]
-
-== Schema Change
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* Upgrading to 2.2.x requires the server be first upgraded
-to 2.1.7, and then to 2.2.x.
-
-== New Features
-* Add 'Expand All Comments' checkbox in PatchScreen
-+
-Allows users to save a user preference that automatically expands
-any inline comment boxes when a page displays.
-
-* Multiple branches in ls-project
-+
-The -b option may be supplied multiple times to ls-project, each
-usage adds a new column of output per project line listing the
-current value of that branch.
-
-== Bug Fixes
-* issue 994 Rename "-- All Projects --" to "All-Projects"
-+
-The name "-- All Projects --.git" is difficult to work with on
-the UNIX command line, due to tools assuming the name is actually
-part of a long option. The project has been renamed to remove these
-leading hyphens, and remove spaces, making it more friendly to work
-with on the command line.
-
-* issue 997 Resolve Project Owners when checking access rights
-+
-Members of the 'Project Owners' magical group did not always have
-their project owner privileges when Gerrit Code Review was looking
-for "access to any ref" at the project level. This was caused by
-not expanding the 'Project Owner's group to the actual ownership
-list. Fixed.
-
-* issue 999 Do not reset Patch History selection on navigation
-+
-Navigating to the next/previous file lost the setting of the
-"Old Version" made under the "Patch History" expandable control
-on a specific file view. This was accidentally broken when the
-"Old Version History" control was added to the change page. Fixed.
-
-* issue 1001 Fix search by codereview status
-+
-Searching for labels (or any approval scores) was broken due to an
-incorrect usage of the Java "equals()" method. Fixed.
-
-* issue 1000 Fix administration of projects with no access controls
-+
-Projects that had no access sections could not have additional
-sections added to them, due to a bug in the web UI. Fixed.
-
-* Fix API breakage on ChangeDetailService
-+
-Version 2.1.7 broke the Gerrit Code Review plugin for Mylyn Reviews
-due to an accidental signature change of one of the remote JSON
-APIs. The ChangeDetailService.patchSetDetail() method is back to the
-old signature and a new patchSetDetail2() method has been added to
-handle the newer calling convention used in some contexts of the
-web UI.
-
-* Add error messages for abandon and restore when in bad state
-+
-Instead of crashing with internal NullPointerExceptions, report
-a better error message to clients when a change is being moved
-between states.
diff --git a/ReleaseNotes/ReleaseNotes-2.2.2.1.txt b/ReleaseNotes/ReleaseNotes-2.2.2.1.txt
deleted file mode 100644
index 37f5a76..0000000
--- a/ReleaseNotes/ReleaseNotes-2.2.2.1.txt
+++ /dev/null
@@ -1,50 +0,0 @@
-= Release notes for Gerrit 2.2.2.1
-
-Gerrit 2.2.2.1 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.2.2.1.war[https://www.gerritcodereview.com/download/gerrit-2.2.2.1.war]
-
-
-There are no schema changes from 2.2.2.  However, if upgrading from
-anything but 2.2.2, follow the upgrade procedure in the 2.2.2
-link:ReleaseNotes-2.2.2.html[ReleaseNotes].
-
-
-== Bug Fixes
-* issue 1139 Fix change state in patch set approval if reviewer is added to
-closed change
-+
-For the dummy patch set approval that is created when a reviewer is
-added the cached change state is always open, which is incorrect if a
-reviewer is added to a closed change. As a result the closed change will
-appear in the reviewers dashboard in the 'Review Requests' section and will
-stay there forever.  Ensure the correct change state is cached in the dummy
-patch set approval when it is created.
-
-* issue 1171 Fix ownerin and reviewerin searches
-+
-Update the ownerin and reviewerin searches to use AccountGroup.UUID as
-required by commit e662fb3d4d7d0ad05791b8d2143ac5ce58117335.
-
-* issue 871 Display hash of the cherry-pick merge in comment
-+
-After merging a change via cherry-pick, we add the commit's
-hash to the comment. This was accidentally removed in
-commit 14246de3c0f81c06bba8d4530e6bf00e918c11b0
-
-
-== Documentation
-* Update top level SUBMITTING_PATCHES
-+
-This document is out of date, the URLs are from last August.
-Direct readers to the new server.
-
-* Add contributing guideline document
-
-* Documentation: update version references for 2.2.2
-+
-Correct wording and instructions to be sure they match what would
-be observed with the indicated version of gerrit.
-Expand instructions when needed to ensure all commands could be
-executed and were successful.
-Indent commands and output based on a run of the instructions
diff --git a/ReleaseNotes/ReleaseNotes-2.2.2.2.txt b/ReleaseNotes/ReleaseNotes-2.2.2.2.txt
deleted file mode 100644
index f50c4e7..0000000
--- a/ReleaseNotes/ReleaseNotes-2.2.2.2.txt
+++ /dev/null
@@ -1,22 +0,0 @@
-= Release notes for Gerrit 2.2.2.2
-
-Gerrit 2.2.2.2 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.2.2.2.war[https://www.gerritcodereview.com/download/gerrit-2.2.2.2.war]
-
-There are no schema changes from 2.2.2, or 2.2.2.1.
-
-However, if upgrading from anything earlier, follow the upgrade
-procedure in the 2.2.2 link:ReleaseNotes-2.2.2.html[ReleaseNotes].
-
-== Security Fixes
-* Some access control sections may be ignored
-+
-Gerrit sometimes ignored an access control section in a project
-if the exact same section name appeared in All-Projects. The bug
-required an unrelated project to have access.inheritFrom set to
-All-Projects and be accessed before the project that has the same
-section name as All-Projects. This is an unlikely scenario for
-most servers, as Gerrit does not normally set inheritFrom equal to
-All-Projects. The usual behavior is to not supply this property in
-project.config, and permit the implicit inheritance to take place.
diff --git a/ReleaseNotes/ReleaseNotes-2.2.2.txt b/ReleaseNotes/ReleaseNotes-2.2.2.txt
deleted file mode 100644
index 276714c..0000000
--- a/ReleaseNotes/ReleaseNotes-2.2.2.txt
+++ /dev/null
@@ -1,645 +0,0 @@
-= Release notes for Gerrit 2.2.2
-
-Gerrit 2.2.2 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.2.2.war[https://www.gerritcodereview.com/download/gerrit-2.2.2.war]
-
-== Schema Change
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* Upgrading to 2.2.x requires the server be first upgraded
-to 2.1.7 (or a later 2.1.x version), and then to 2.2.x.
-
-== New Features
-
-=== Prolog
-* issue 971 Use Prolog Cafe for ChangeControl.canSubmit()
-
-*  Add per-project prolog submit rule files
-+
-When loading the prolog environment, now checks refs/meta/config
-branch for a file called rules.pl. If it exists, consult the
-file. Expects a predicate called submit_rule. If no file is found,
-uses the default_submit predicate in common_rules.pl.
-
-*  Add inheritance of prolog rules
-+
-Projects now inherit the prolog rules defined in their parent
-project. Submit results from the child project are filtered by the
-parent project using the filter predicate defined in the parent's
-rules.pl. The results of the filtering are then passed up to the
-parent's parent and filtered, repeating this process up to the top
-level All-Projects.
-
-* Load precompiled prolog rules from jar file
-+
-Looks in (site)/cache/rules for a jar file called:
-  rules-(sha1 of rules.pl).jar
-Loads the precompiled prolog rules and uses them instead of
-consulting rules.pl. If the jar does not exist, consults rules.pl.
-If rules.pl does not exist, uses the default submit rules.
-
-* Cmd line tool rulec to compile jar from prolog
-+
-Rulec takes rules.pl from the refs/meta/config branch and creates a
-jar file named rules-(sha1 of rules.pl).jar in (sitepath)/cache/rules.
-Generates temporary prolog, java src, and class files which are
-deleted afterwards.
-
-* prolog-shell: Simple command line Prolog interpreter
-+
-Define a small interactive interpreter that users or site
-administrators can play around with by downloading the Gerrit WAR
-file and executing: java -jar gerrit.war prolog-shell
-
-==== Prolog Predicates
-*  Add Prolog Predicates to check commit messages and edits
-+
-commit_message returns the commit message as a symbol.
-+
-commit_message_matches takes in a regex pattern and checks it against
-the commit message.
-+
-commit_edits takes in a regex pattern for filenames and a regex
-pattern for edits. For all files in a commit that match the filename
-regex.  Returns true if the edits in any of those files match the
-edit regex.
-
-* Add Prolog  Predicates to expose commit filelist
-+
-commit_delta/1,3,4 each takes a regular expression and matches it to
-the path of all the files in the latest patchset of a commit.
-If applicable (changes where the file is renamed or copied), the
-regex is also checked against the old path.
-+
-commit_delta/1 returns true if any files match the regex
-+
-commit_delta/3 returns the changetype and path, if the changetype is
-renamed, it also returns the old path. If the changetype is rename,
-it returns a delete for oldpath and an add for newpath. If the
-changetype is copy, an add is returned along with newpath.
-+
-commit_delta/4 returns the changetype, new path, and old path
- (if applicable).
-
-* Add Prolog predicates that expose the branch, owner,
-project, and  topic of a change, the author and committer of the most
-recent patchset in the change, and who is the current user.
-
-* For user-related predicates, if the user is not a gerrit user, will
-return user(anonymous) or similar. Author and committer predicates
-for commits return user(id), name, and email.
-
-* Make max_with_block/4 public
-+
-This is the current rule generally applied to a label function. Make
-it exportable for now until we can come back and clean up the legacy
-approval data code.
-
-=== Web
-
-* Support in Firefox delete key in NpIntTextBox
-+
-Pressing the delete key while being in a NpIntTextBox (e.g. in the
-text box for the Tab Width or Columns preference when comparing a
-file) now works in Firefox.
-
-* Make sure special keys work in text fields
-+
-There is a bug in gwt 2.1.0 that prevents pressing special keys like
-Enter, Backspace etc. from being properly recognized and so they have no effect.
-
-==== ChangeScreen
-* issue 855 Indicate outdated dependencies on the ChangeScreen
-+
-If a change dependency is no longer the latest patchSet for that
-change, mark it OUTDATED in the dependencies table and make
-its row red, and add a warning message to the dependencies
-header, also keep the dependencies disclosure panel open
-even when an outdated dependent change is merged.
-Additionally make the link for dependencies link to the
-exact patchSet of the dependent change.
-
-* issue 881 Allow adding groups as reviewer
-+
-On the ChangeScreen it is now possible to add a group as reviewer for
-a change. When a group is added as reviewer the group is resolved and
-all its members are added as reviewers to the change.
-
-* Update approvals in web UI to adapt to rules.pl submit_rule
-+
-The UI now shows whatever the results of the submit_rule are, which
-permits the submit_rule to make an ApprovalCategory optional, or to
-make a new label required.
-
-==== Diff Screen
-* Add top level menus for a new PatchScreen header
-+
-Modify the PatchScreen so that the header contents is selectable
-using top level menus. Allow the header to display the commit
-message, the preferences, the Patch Sets, or the File List.
-
-* Add SideBySide and Unified links to Differences top level menus
-+
-These new menu entries allow a user to switch view types easily
-without returning to the ChangeScreen.  Also, they double as a
-way to hide the header on the PatchScreen (when clicking on the
-currently displayed type).
-
-* Add user pref to retain PatchScreen Header when changing files
-
-* Flip the orientation of PatchHistory Table
-
-* Remove the 'Change SHA1:' from the PatchScreen title
-
-* Remove scrollbar from Commit Message
-
-* Allow comment editing with single click on line numbers
-+
-Make it easier to comment (and now possible on android devices which
-zoom on double click) on a patch by simply clicking on the line number.
-
-* Add a "Save" button to the PatchScriptSettingsPanel
-+
-The "Update" button now only updates the display.  Additionally,
-for logged in users, a "Save" button now behaves the way that
-"Update" used to behave for logged in users.
-
-* issue 665 Display merge changes as differences from automatic result
-+
-Instead of displaying nothing for a two-parent merge commit, compute
-the automatic merge result and display the difference between the
-automatic result that Git would create, and the actual result that
-was uploaded by the author/committer of the merge.
-
-==== Groups
-* Add menu to AccountGroupScreen
-+
-This change introduces a menu in the AccountGroupScreen and
-different screens for subsets of the functionality (similar as it's
-done for the ProjectScreen).  Links from other screens to the
-AccountGroupScreen are resolved depending on the group type.
-
-* Display groupUUID on AccountGroupInfoScreen
-+
-To assign a privilege to a new group by editing the
-'project.config' file, the new group needs to be added to the
-'groups' file in the 'refs/meta/config' branch which requires
-the UUID of the group to be known.
-
-==== Project Access
-* Automatically add new rule when adding new permission
-+
-If a new permission was added to a block, immediately create the new
-group entry box and focus it, so the user can assign the permission.
-
-* Only show Exclusive checkbox on reference sections
-+
-In the access editor, hide the Exclusive checkbox on the
-Global Capabilities section since it has no inheritance and
-the exclusive bit isn't supported.
-
-* Disable editing after successful save of Access screen
-+
-When the access has been successfully modified for a project,
-switch back to the "read-only" view where the widgets are all
-disabled and the Edit button is enabled.
-
-==== Project Branches
-* Display refs/meta/config branch on ProjectBranchesScreen
-+
-The new refs/meta/config branch was not shown in the ProjectBranchesScreen.
-Since refs/meta/config is not just any branch, but has a special
-meaning to Gerrit it is now displayed at the top below HEAD.
-
-* Highlight HEAD and refs/meta/config
-+
-Since HEAD and refs/meta/config do not represent ordinary branches,
-highlight their rows with a special style in the ProjectBranchesScreen.
-
-==== URLs
-* Modernize URLs to be shorter and consistent
-+
-Instead of http://site/#change,1234 we now use a slightly more
-common looking   http://site/#/c/1234  URL to link to a change.
-+
-Files within a patch set are now denoted below the change, as in
-http://site/#/c/1234/1/src/module/foo.c
-+
-Also fix the dynamic redirects of http://site/1234
-and http://site/r/deadbeef to jump directly to the corresponding
-change if there is exactly one possible URL.
-+
-Entities that have multiple views suffix the URL with ",view-name"
-to indicate which view the user wants to see.
-
-* issue 1018 Accept ~ in linkify() URLs
-
-=== SSH
-* Added a set-reviewers ssh command
-
-* Support removing more than one reviewer at once
-+
-This way we can batch delete reviewers from a change.
-
-* issue 881 Support adding groups as reviewer by SSH command
-+
-With the set-reviewers SSH command it is now possible to also add
-groups as reviewer for a change.
-
-* Fail review command for changing labels when change is closed
-+
-If a reviewer attempts to change a review label (approval) after a
-change is closed using the ssh review command, cause it to fail the
-command and output a message.
-
-* ls-projects: Fix display of All-Projects under --tree
-+
-Everything should be nested below All-Projects, since that is actually
-the root level.
-
-* ls-projects: Add --type to filter by project type
-+
-ls-projects now supports --type code|permissions|all.  The default is
-code which now skips permissions only projects, restoring the output
-to what appears from Gerrit 2.1.7 and earlier.
-
-* show-caches: Improve memory reporting
-+
-Change the way memory is reported to show the actual values,
-and the equation that determines how these are put together
-to form the current usage.  Include some additional data including
-server version, current time, process uptime, active SSH
-connections, and tasks in the task queue. The --show-jvm option
-will report additional data about the JVM, and tell the caller
-where it is running.
-
-==== Queries
-* Output patchset creation date for 'query' command.
-
-* issue 1053 Support comments option in query command
-+
-Query SSH command will show all comments if option --comments is
-used. If --comments is used together with --patch-sets all inline
-comments are included in the output.
-
-=== Config
-* Move batch user priority to a capability
-+
-Instead of using a magical group, use a special capability to
-denote users that should get the batch priority behavior.
-
-* issue 742 Make administrator, create-project a global capability
-+
-This gets rid of the special entries in system_config and
-gerrit.config related to who the Administrators group is,
-or which groups are permitted to create new projects on
-this server.
-
-* issue 48 & 742  Add fine-grained capabilities for administrative actions
-+
-The Global Capabilities section in All-Projects can now be used to
-grant subcommands that are available over SSH and were previously
-restricted to only Administrators.
-
-* Disallow project names ending in "/"
-
-* issue 934 query: Enable configurable result limit
-+
-Allow site administrators to configure the query limit for user to be
-above the default hard-coded value of 500 by adding a global
-[capability] block to All-Projects project.config file with group(s)
-that should have different limits.
-
-* Introduced a new PermissionRule.Action: BLOCK.
-+
-Besides already existing ALLOW and DENY actions this change
-introduces the BLOCK action in order to enable blocking some
-permission rules globally.
-
-* issue 813 Use remote.name.replicatePermissions to hide permissions
-+
-Administrators can now disable replication of permissions-only
-projects and the per-project refs/meta/config in replication.config
-by setting the replicatePermissions field to false.
-
-* Add a Restored.vm template and use it.
-+
-The restore action has been erroneously using the Abandoned.vm
-template.  Create a template and sender for the restorecommand.
-
-* sshd.advertisedAddress: specify the displayed SSH host/port
-+
-This allows aliases which redirect to gerrit's ssh port (say
-from port 22) to be setup and advertised to users.
-
-=== Dev
-* Updated eclipse settings for 3.7 and m2e 1.0
-
-* Fix build in m2eclipse 1.0
-+
-Ignore the antrun and the build-helper-maven-plugin tasks in m2eclipse.
-
-* Make Gerrit with gwt 2.3.0 run in gwtdebug mode
-
-* Fix a number of build warnings that have crept in
-
-* Accept email address automatically
-+
-Enable Gerrit to accept email address automatically in
-"DEVELOPMENT_BECOME_ANY_ACCOUNT" mode without a confirmation email.
-
-* Added clickable user names at the BecomeAnyAccountLoginServlet.
-+
-The first 5 (by accountId) user names are displayed as clickable
-links. Clicking a user name logs in as this user, speeding up
-switching between different users when using the
-DEVELOPMENT_BECOME_ANY_ACCOUNT authentication type.
-
-=== Miscellaneous
-* Permit adding reviewers to closed changes
-+
-Permit adding a reviewer to closed changes to support post-submit
-discussion threads.
-
-* issue 805 Don't check for multiple change-ids when pushing directly
-to refs/heads.
-
-* Avoid costly findMergedInto during push to refs/for/*
-+
-No longer close a change when a commit is pushed to res/for/* and the
-Change-Id in the commit message footer matches another commit on an
-existing branch or tag.
-
-* Allow serving static files in subdirectories
-
-* issue 1019 Normalize OpenID URLs with http:// prefix
-+
-No longer violate OpenID 1.1 and 2.0, both of which require
-OpenIDs to be normalized (http:// added).
-
-* Allow container-based authentication for git over http
-+
-Gerrit was insisting on DIGEST authentication when doing git over
-http. A new boolean configuration parameter auth.trustContainerAuth
-allows gerrit to be configured to trust the container to do the
-authentication.
-
-* issue 848 Add rpc method for GerritConfig
-+
-Exposes what types of reviews are possible via json rpc, so that the
-Eclipse Reviews plugin currently can parse the javascript from a
-gerrit page load.
-
-
-== Performance
-* Bumped Brics version to 1.11.8
-+
-This Brics version fixes a performance issue in some larger Gerrit systems.
-
-* Add permission_sort cache to remember sort orderings
-+
-Cache the order AccessSections should be sorted in, making any future
-sorting for the same reference name and same set of section patterns
-cheaper.
-
-* Refactor how permissions are matched by ProjectControl, RefControl
-+
-More aggressively cache many of the auth objects at a cost of memory,
-but this should be an improvement in response times.
-
-* Substantially speed up pushing changes for review
-+
-Pushing a new change for review checks if the change is related to
-the branch it's destined for. It used to do this in a way that
-required a topo-sort of the rev history, and now uses JGit's
-merge-base functionality.
-
-* Add cache for tag advertisements
-+
-To make the general case more efficient, introduce a cache called "git_tags".
-+
-On a trivial usage of the Linux kernel repository, the average
-running time of the VisibleRefFilter when caches were hot was
-7195.68 ms.  With this commit, it is a mere 5.07 milliseconds
-on a hot cache.  A reduction of 99% of the running time.
-
-* Don't set lastCheckTime in ProjectState
-+
-The lastCheckTime/generation fields are actually a counter that
-is incremented using a background thread. The values don't match
-the system clock, and thus reading System.currentTimeMillis()
-during the construction of ProjectState is a waste of resources.
-
-
-== Upgrades
-* Upgrade to GWT 2.3.0
-* Upgrade to Gson to 1.7.1
-* Upgrade to gwtjsonrpc 1.2.4
-* Upgrade to gwtexpui 1.2.5
-* Upgrade to Jsch 0.1.44-1
-* Upgrade to Brics 1.11.8
-
-
-== Bug Fixes
-* Fix: Issue where Gerrit could not linkify certain URLs
-
-* issue 1015 Fix handling of regex ref patterns in Access panel
-+
-regex patterns such as "\^refs/heads/[A-Z]{2,}\-[0-9]\+.\*" were being
-prefixed with "refs/heads/", resulting in invalid reference patterns
-like "refs/heads/^refs/heads/[A-Z]{2,}-[0-9]+.*".
-
-* issue 1002 Check for and disallow pushing of invalid refs/meta/config
-+
-If the project.config or groups files are somehow invalid on
-the refs/meta/config branch, or would be made invalid due to
-a bad code review being submitted to this branch, reject the
-user's attempt to push.
-
-* issue 1002 Fix NPE in PermissionRuleEditor when group lacks UUID
-+
-If a group does not have an entry in the "groups" table within
-the refs/meta/config branch render the group name as a span,
-without the link instead of crashing the UI.
-
-* issue 972 Filter access section rules to only visible groups
-+
-Users who are not the owner of an access section can now only
-see group names and rules for groups which they are a member of,
-are visible to all users, or that they own.
-
-* Correctly handle missing refs/meta/config branch
-+
-If the refs/meta/config branch did not exist, getRevision() no longer
-throws an NPE when trying to access the ProjectDetail.
-
-* Allow loading Project Access when there is no refs/meta/config
-+
-Enable loading the access screen with a null revision field,
-and on save of any edits require the branch to be new.
-
-* create-project: Fix creation vs. replication order
-+
-Create the project on remote mirrors before creating either the
-refs/meta/config or the initial empty branch. This way those can be
-replicated to the remote mirrors once they have been created locally.
-
-* create-project: Bring back --permissions-only flag
-+
-If a project is permissions only, assign HEAD to point to
-refs/meta/config. This way the gitweb view of the project
-shows the permissions history by default, and clients that
-clone the project are able to get a detached HEAD pointing
-to the current permission state, rather than an empty
-repository.
-
-* create-project: Fix error reporting when repository exists
-+
-If a repository already exists, tell the user it already is
-available, without disclosing the server side path from gerrit.basePath.
-
-* Do not log timeout errors on upload and receive connections
-
-* Only automatically create accounts for LDAP systems
-+
-If the account management is LDAP, try to automatically create
-accounts by looking up the data in LDAP. Otherwise fail and reject an
-invalid account reference that was supplied on the command line via
-SSH.
-
-* Add missing RevWalk.reset() after checking merge base
-+
-This fixes an exception from RevWalk when trying to push a new
-commit for review.
-
-* issue 1069 Do not send an email on reviews when there is no message.
-+
-No longer send an email when reviewing a change via ssh, and
-the change message is blank (when no change message is actually
-added to the review).
-
-* Ignore PartialResultException from LDAP.
-+
-This exception occurs when the server isn't following referrals for
-you, and thus the result contains a referral. That happens when
-you're using Active Directory. You almost certainly don't really want
-to follow referrals in AD *anyways*, so just ignore these exceptions,
-so we can still use the actual data.
-
-* issue 518 Fix MySQL counter resets
-+
-gwtorm 1.1.5 was patched to leave in the dummy row that incremented
-the counter, ensuring the server will use MAX() + 1 instead of 1 on
-the next increment after restart.
-
-* Don't delete account_id row on MySQL
-+
-If the table is an InnoDB table deleting the row after allocation may
-cause the sequence to reset when the server restarts, giving out
-duplicate account_ids later.
-
-
-== Documentation
-
-=== New Documents
-* First Cut of Gerrit Walkthrough Introduction documentation.
-+
-Add a new document intended to be a complement for the existing
-reference documentation to allow potential users to easily get a
-feel for how Gerrit is used, where it fits and whether it will
-work for them.
-
-* Introducing a quick and dirty setup tutorial
-+
-The new document covers quick installation, new project and first
-upload.  It contains lots of quoted output, with a demo style to it.
-
-=== Access Control
-* Code review
-
-* Conversion table between 2.1 and 2.2
-+
-Add a table to ease conversion from 2.1.x. The table tries to address
-the old permissions one by one except for the push tag permission which
-in effect needed two permissions to work properly. This should
-be familiar to the administrator used to the 2.1.x permission model
-however.
-
-* Reformatted text
-
-* Verify
-+
-Updated some text in the Per project-section and edited the verified
-section to reflect the current label.
-
-* Capabilities
-+
-Adds general information about global capabilities, how the server
-ownership is administered.
-
-* Added non-interactive users
-+
-This change adds the non-interactive user group.
-It also adds that groups can be members of other groups.
-The groups are now sorted in alphabetical order.
-
-* Reordering categories
-+
-Access categories are now sorted to match drop down box in UI
-
-=== Other Documentation
-* Added additional information on the install instructions.
-+
-The installation instructions presumes much prior knowledge,
-make some of that knowledge less implicit.
-
-* Provides a template to the download example.
-+
-Clarifies that the example host must be replaced with proper
-hostname.
-
-* Provided an example on how to abandon a change from
-the command line
-
-* update links from kernel.org to code.google.com
-
-
-* Rename '-- All Projects --' in documentation to 'All-Projects'
-
-* Explain 'Automatically resolve conflicts'
-
-* Update documentation for testing SSH connection
-+
-The command output that is shown in the example and the description
-how to set the ssh username were outdated.
-
-* Remove unneeded escape characters from the documentation
-+
-The old version of asciidoc required certain characters to be escaped
-with a backslash and when the upgrade to the new version was done all
-those backslashes that were used for escaping became visible.
-
-* Clean up pgm-index
-+
-Break out the utilities into their own section, and correct
-some of the item descriptions.
-
-* Update manual project creation instructions
-
-* Update project configuration documentation
-+
-Remove the textual reference to obsolete SQL insert statement to
-create new projects.
-
-* Clean up command line documentation, examples
-+
-The formatting was pretty wrong after upgrading to a newer version
-of AsciiDoc, so fix up most of the formatting, correct some order
-of commands in the index, and make create-project conform to the
-same format used by create-account and create-group.
-
-* Correct syntax of SQL statement for inserting approval category
diff --git a/ReleaseNotes/ReleaseNotes-2.3.1.txt b/ReleaseNotes/ReleaseNotes-2.3.1.txt
deleted file mode 100644
index 627fba5..0000000
--- a/ReleaseNotes/ReleaseNotes-2.3.1.txt
+++ /dev/null
@@ -1,22 +0,0 @@
-= Release notes for Gerrit 2.3.1
-
-Gerrit 2.3.1 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.3.1.war[https://www.gerritcodereview.com/download/gerrit-2.3.1.war]
-
-There are no schema changes from 2.3.
-
-However, if upgrading from anything earlier, follow the upgrade
-procedure in the 2.3 link:ReleaseNotes-2.3.html[ReleaseNotes].
-
-== Security Fixes
-* Some access control sections may be ignored
-+
-Gerrit sometimes ignored an access control section in a project
-if the exact same section name appeared in All-Projects. The bug
-required an unrelated project to have access.inheritFrom set to
-All-Projects and be accessed before the project that has the same
-section name as All-Projects. This is an unlikely scenario for
-most servers, as Gerrit does not normally set inheritFrom equal to
-All-Projects. The usual behavior is to not supply this property in
-project.config, and permit the implicit inheritance to take place.
diff --git a/ReleaseNotes/ReleaseNotes-2.3.txt b/ReleaseNotes/ReleaseNotes-2.3.txt
deleted file mode 100644
index 7a29d0e..0000000
--- a/ReleaseNotes/ReleaseNotes-2.3.txt
+++ /dev/null
@@ -1,462 +0,0 @@
-= Release notes for Gerrit 2.3
-
-Gerrit 2.3 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.3.war[https://www.gerritcodereview.com/download/gerrit-2.3.war]
-
-== Schema Change
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* Upgrading to 2.3.x requires the server be first upgraded
-to 2.1.7 (or a later 2.1.x version), and then to 2.3.x.
-
-If you are upgrading from 2.2.x.x, you may ignore this warning and
-upgrade directly to 2.3.x.
-
-
-== New Features
-=== Drafts
-* New draft statuses and magic branches
-+
-Adds draft status to Change. DRAFT status in change occurs before NEW
-and will be for a change that is not meant for review (yet).
-Also adds magic branches refs/drafts/ and refs/publish/ that
-will handle whether or not a patchset is a draft or goes straight to
-review. refs/for/ should be deprecated in favor of explicitly marking
-a patchset as a draft or directly to review.
-
-* Draft patchset and change visibility in UI
-+
-If a patchset is a draft, adds a (DRAFT) label next to the revision
-(or gitweb link if it exists). If a change is a draft, adds a (DRAFT)
-next to the subject and changes the status appropriately.
-
-* Publish draft patchsets in UI and SSH
-+
-Adds Publish button to draft patchsets in UI and an option to
-publish draft patchsets in the review ssh command. Publishing a draft
-patchset makes it visible. Publishing a draft patchset in a draft
-change irreversibly upgrades the change status to NEW.
-
-* Delete draft changes and patchsets
-+
-Adds ability to delete draft changes and patchsets that are not meant
-or fit for code review. Deleting a draft patchset also deletes the
-corresponding ref from the repository and decrements the next patch
-set number for the change if necessary. Deleting a draft change
-deletes all of its (draft) patchsets.
-
-* Add pushing drafts to refs/drafts/
-+
-Pushing to refs/drafts/ will now push a draft patchset. If this is the
-first patch set, change created will be in draft status. Pushing a
-draft patchset to a draft change keeps it in draft status. Pushing
-a non-draft patchset (with refs/publish/ or refs/for/, they do the
-same thing) to a draft change turns it into a non-draft change.
-Draft patchsets cannot be submitted.
-
-* When pushing changes as drafts, output [DRAFT] next to the change link
-
-
-=== Web
-* issue 203 Create project through web interface
-+
-Add a new panel in the Admin->Projects Screen.  It
-enables the users that are allowed to create projects
-via command-line to create them also via web interface.
-
-* Suggest parent for 'create-project' in the UI.
-+
-Add a list of parent suggestions for 'create project'
-in the UI, so the user can select a parent for the new
-project from a list of projects that are already parents to
-other projects.
-
-* issue 981 Fix diffs skipping one line
-+
-Don't show '... skipping 1 common line ...'.  The text to show this
-takes up just as much space as showing the line which was skipped.
-
-* issue 18 Support expanding lines of context in diff
-+
-Allow lines of context which were skipped in the side-by-side diff
-view to be expanded.  This makes it easier to get more code context
-when needed but not show huge amounts of unneeded data.
-
-* Move checkbox to mark file as reviewed into title bar
-
-* Redirect the user to the reverted change (when reverting).
-
-* On group rename update the group name in the page title
-
-* In ProjectAccessScreen add link to history of project.config in gitweb
-
-* Removed superfluous 'comment' for patch history table.
-
-* Make OpenID login images transparent
-
-* Disable SSH Keys in the web UI if SSHD is disabled
-
-
-=== SSH
-* Adds --description (-d) option to ls-projects
-+
-Allows listing of projects together with their respective
-description.
-
-* ls-projects: new option to list all accessible projects
-+
-Add a new option '--all' to the 'ls-projects' SSH command to display
-all projects that are accessible by the calling user account. Besides
-the projects that the calling user account has been granted 'READ'
-access to, this includes all projects that are owned by the calling
-user account (even if for these projects the 'READ' access right is
-not assigned to the calling user account).
-
-* Suggest parent for 'create-project' in the SSH command
-+
-Add an option '--suggest-parents' which will print out
-a list of projects that are already parents to another
-projects, thus it can help user to find a suitable
-parent for the new project.
-
-* Support reparenting all children of a parent project
-+
-This change adds a new option to the 'set-project-parent' command that
-allows reparenting all child projects of one parent project to another
-parent project.
-
-* set-parent-project: evict child projects from project cache
-
-* Add ssh command to list groups.
-
-* ls-groups: add option to list groups for a project
-+
-Add an option to the ls-groups SSH command that allows to list only
-those groups for which any permission is assigned to a project.
-
-* ls-groups: Add option to only list groups that are visible to all
-
-* ls-groups: Support listing groups by group type
-
-* ls-groups: Support listing of groups for a user
-
-* Add new SSH command to rename groups
-
-* Support for --file option for ssh queries.
-+
-Allows user to list files and attributes (ADDED,
-MODIFIED, DELETED, RENAMED, COPIED) when querying for
-patch sets.
-
-* Output full commit message in query results
-
-* Option for SSHD review-cmd to always publish the message.
-+
-"--force-message" option for the SSHD review command,
-which allows Gerrit to publish the "--message", even if the
-labels could not be applied due to change being closed.
-
-
-=== Config
-* issue 349 Apply states for projects (active, readonly and hidden)
-+
-Active state indicates the project is regular and is the default value.
-+
-Read Only means that users can see the project if read permission is
-granted, but all modification operations are disabled.
-+
-Hidden means the project is not visible for those who are not owners
-
-* Enable case insensitive login to Gerrit WebUI for LDAP authentication
-+
-Gerrit treats user names as case sensitive, while some LDAP servers
-don't. On first login to Gerrit the user enters his user name and
-Gerrit queries LDAP for it. Since LDAP is case-insensitive with regards
-to  the username, the LDAP authentication succeeds regardless in
-which case the user typed in his user name. The username is stored in
-Gerrit exactly as entered by the user. For further logins the user
-always has to use the same case. If the user specifies his user name in
-a different case Gerrit tries to create a new account which fails with
-"Cannot assign user name ... to account ...; name already in use.".
-This error occurs because the LDAP query resolves to the same LDAP
-user and storing the username for SSH (which is by default always
-lower case) fails because such an entry exists already for the first
-account that the user created.
-+
-This change introduces a new configuration parameter that converts the
-user name always to lower case before doing the LDAP authentication.
-By this the login to the Gerrit WebUI gets case insensitive. If this
-configuration parameter is set, the user names for all existing
-accounts have to be converted to lower case. This change includes a
-server program to do this conversion.
-
-* Enable case insensitive authentication for git operations
-+
-A new configuration parameter is introduced that converts the username
-that is received to authenticate a git operation to lower case for
-looking up the user account in Gerrit.
-+
-By setting this parameter a case insensitive authentication for the
-git operations can be achieved, if it is ensured that the usernames in
-Gerrit (scheme 'username') are stored in lower case (e.g. if the
-parameter 'ldap.accountSshUserName' is set to
-'${sAMAccountName.toLowerCase}').
-
-* Support replication to local folder
-
-* Read timeout parameter for LDAP connections: ldap.readTimeout
-+
-This helps prevent a very slow LDAP server from blocking
-all SSH command creation threads.
-
-* Introduce a git maxObjectSizeLimit in the [receive] config
-+
-This limits the size of uploaded files
-
-* Make 'Anonymous Coward' configurable
-
-* Add property to configure path separator in URLs for a gitweb service
-
-* Customize link-name pointing to gitweb-service.
-+
-Previously the link to the external gitweb-type
-pages said "(gitweb)" regardless if using cgit
-or a custom service.
-
-* Support gitweb.type=disabled
-
-* rules.enable: Support disabling per project prolog rules in gerrit.config
-
-* Allow site administrators to define Git-over-HTTP mirror URL
-
-* Allow sshd.listenAddress = off to disable the daemon
-
-* daemon: Allow httpd without sshd
-
-* Allow disabling certain features of HostPageServlet
-+
-These features are: user agent detection and automatic refresh
-logic associated with the site header, footer and CSS.
-
-
-=== Dev
-* Fix 'No source code is available for type org.eclipse.jgit.lib.Constants'
-
-* Fix miscellaneous compiler warnings
-
-* Add entries to .gitignore for m2e settings/preference files
-
-* Package source JARs for antlr, httpd, server
-
-* pom.xml: change gerrit-war's dependency on gerrit-main to runtime
-+
-This only seems to matter to IntelliJ, since the Main class is
-provided to the war via an overlay in gerrit-war/pom.xml
-
-* Fixed the full name of the MAVEN2_CLASSPATH_CONTAINER
-+
-Fixes java.lang.NoClassDefFoundError: com/google/gwt/dev/DevMode
-
-
-=== Miscellaneous
-* Allow superprojects to subscribe to submodules updates
-+
-The feature introduced in this release allows superprojects to
-subscribe to submodules updates.
-+
-When a commit is merged to a project, the commit content is scanned
-to identify if it registers submodules (if the commit contains new
-gitlinks and .gitmodules file with required info) and if so, a new
-submodule subscription is registered.
-+
-When a new commit of a registered submodule is merged, gerrit
-automatically updates the subscribers to the submodule with new
-commit having the updated gitlinks.
-+
-The most notable benefit of the feature is to not require
-to push/merge commits of super projects (subscribers) with gitlinks
-whenever a project being a submodule is updated. It is only
-required to push commits with gitlinks when they are created
-(and in this case it is also required to push .gitmodules file).
-
-* Allow Realm to participate when linking an account identity
-+
-When linking a new user identity to an existing account, permit the
-Realm to observe the new incoming identity and the current account,
-and to alter the request. This enables a Realm to observe when a
-user verifies a new email address link.
-
-* issue 871 Show latest patchset with cherry-picked merge
-+
-When a change is published via the cherry-pick merge strategy,
-show the final commit as a patchset in the change history.
-This now makes it possible to search for the cherry-picked SHA1.
-
-* issue 871 Display hash of the cherry-pick merge in comment
-
-* Added more verbose messages when changes are being rejected
-
-* Display proper error message when LDAP is unavailable
-
-* Clarify error msg when user's not allowed to '--force push'.
-
-* ContainerAuthFilter: fail with FORBIDDEN if username not set
-
-* Resolve 'Project Owners' group if it is included into another group
-
-* Hide SSH URL in email footers if SSH is disabled
-
-* Sort the jar files from the war before adding to classpath.
-
-* Apply user preferences when loading site
-
-* Ensure HttpLog can always get the user identity
-
-* Prevent comments spam for abandoned commit
-+
-If some change was abandoned but later submitted (e.g. by
-cherry-picking it to a another branch) then pushing a new branch
-that contains this change no longer adds a new comment.
-
-* Make Address, EmailHeader visible to other EmailSenders
-
-* Use transactions to handle comments when possible
-
-* Try to use transactions when creating changes
-
-* gerrit.sh: disown doesn't accept pid as a argument, fix script
-
-* gerrit.sh: detach gerrit properly so it won't keep bad ssh sessions open.
-
-* Cache list of all groups in the group cache
-
-* issue 1161 Evict project in user cache on save of project meta data
-
-* Ensure that the site paths are resolved to their canonical form (for Windows)
-
-* Connect Velocity to slf4j
-
-* Expose project permissionOnly status via JSON-RPC
-
-* Make HEAD of All-Projects point to refs/meta/config
-
-* issue 1158 Added support for European style dates
-
-* Make macros in email templates local to the template
-
-* Support http://server/project for Git access
-
-* Use _ instead of $ for implementation-detail Prolog predicates
-
-* Update the Sign In anchor with current URL
-+
-Always update the href of the Sign In anchor in the menu bar with
-the current page URL after /login/, making the redirect process
-bring users back to the current view after sign in.
-
-* Improve validation of email registration tokens
-
-
-== Upgrades
-* Upgrade to gwtorm 1.2
-
-* Upgrade to JGit 1.1.0.201109151100-r.119-gb4495d1
-+
-This is needed because of this change:
-https://gerrit-review.googlesource.com/#/c/30450/
-
-* Support Velocity 1.5 (as well as previous 1.6.4)
-
-
-== Bug Fixes
-* Avoid NPE when group is missing
-
-* Do not fail with NPE if context path of request is null
-
-* Fix NPE in set-project-parent command if parent is not specified
-
-* Only send mail to author and committer if they are registered to prevent an NPE
-
-* Avoid potential NPE when querying the queue.
-
-* Allow loading Project Access when there is no refs/meta/config
-
-* Fix calculation of project name if repo is not existing
-+
-If a project inherits from a non existing parent, prevent a
-StringIndexOutOfBoundsException.
-
-* Fix: Suppress "Error on refs/cache-automerge" warnings.
-
-* Don't allow registering for cleanup after cleanup runs
-+
-This prevents leaking a database connection.
-
-* issue 807 Fix: Tags are not replicated properly
-
-* Prevent smtp rejected users from rejecting emails for all users
-
-* Fix token saving redirect in container auth
-+
-Update the jump page that redirects users from /#TOKEN to
-/login/TOKEN.  This forces using the container based
-authentication.  Also correct "/login//" to be just "/login/".
-
-* Use custom error messages for Git-over-HTTP
-+
-Ensure clients see messages related to contributor agreement not
-being activated even if they push over HTTP.
-
-* Avoid double key event for GroupReferenceBox
-
-* Fix git push authentication over HTTP
-
-* Fix http://login/ redirect bug
-
-* Fix missing targets in /login/ URLs
-
-* set-project-parent: if update of 1 project fails continue with others
-
-* Verify the case of the project name before opening git repository
-
-* Update top level SUBMITTING_PATCHES URLs
-
-
-== Documentation
-* Some updates to the design docs
-
-* cmd-index: Fix link to documentation of rename-group command
-
-* Update documentation for testing SSH connection
-
-* Bypass review updated with 2.2.x permissions
-
-* Add documentation for 'peer_keys'
-
-* Improve 'Push Merge Commit' access right documentation
-
-* Access control: Capabilities documented
-** Administrate Server
-** Create Account
-** Create Group
-** Create Project
-** Flush Caches
-** Kill Task
-** Priority
-** Query Limit
-** Start Replication
-** View caches
-** View connections
-** View queue
-
-* Access control: Example roles documented
-** Contributor
-** Developer
-** CI System
-** Integrator
-** Project owner
-** Administrator
diff --git a/ReleaseNotes/ReleaseNotes-2.4.1.txt b/ReleaseNotes/ReleaseNotes-2.4.1.txt
deleted file mode 100644
index f3c4765..0000000
--- a/ReleaseNotes/ReleaseNotes-2.4.1.txt
+++ /dev/null
@@ -1,53 +0,0 @@
-= Release notes for Gerrit 2.4.1
-
-Gerrit 2.4.1 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.4.1.war[https://www.gerritcodereview.com/download/gerrit-2.4.1.war]
-
-
-There are no schema changes from 2.4.  However, if upgrading from
-anything but 2.4, follow the upgrade procedure in the 2.4
-link:ReleaseNotes-2.4.html[ReleaseNotes].
-
-
-== Bug Fixes
-* Catch all exceptions when async emailing
-+
-This fixes email notification issues reported
-link:https://groups.google.com/group/repo-discuss/browse_thread/thread/dd157ebc55b962ef/652822d6fbe61e71[here].
-
-* Fixed cleanup of propagated SshScopes
-+
-This improves error reporting in case of email notification errors.
-
-* issue 1394 Fix lookup of the 'Commit Message' file in patch set
-+
-There is an assumption that the commit message is always first in the list of
-files of a patch set. However, there was another place in Gerrit code, which
-did binary search through the list of the files, without taking this assumption
-into account. In case when a patch set contained a file which lexicographically
-sorted before '/COMMIT_MSG' (like '.gitignore' for example) it could have
-happened that the commit message was not found and, as a side effect, it wasn't
-possible to review it.
-
-* issue 1162 Fix deadlock on destroy of CommandFactoryProvider
-
-* Honor the sendmail.smtpUser from gerrit.config on upgrade
-+
-If sendmail.smtpUser was not present in the gerrit.config then don't set it in
-site upgrade.
-
-* issue 1420 Forge committer bypassed
-+
-It was possible to forge committer even without having permission for that.
-This was a regression from 2.3.
-
-* Make sure the "Object too large..." error message is printed when an object
-larger than receive.maxObjectSizeLimit is rejected by Gerrit
-
-* Display proper error if file diff fails because content is too large
-
-* Get around a log4j bug that causes AsyncAppender-Dispatcher thread to die and
-block other threads
-** Make async logging buffer size configurable
-** Make logging events discardable, prevent NPE in AsyncAppender-Dispatcher thread
diff --git a/ReleaseNotes/ReleaseNotes-2.4.2.txt b/ReleaseNotes/ReleaseNotes-2.4.2.txt
deleted file mode 100644
index d5c2a11..0000000
--- a/ReleaseNotes/ReleaseNotes-2.4.2.txt
+++ /dev/null
@@ -1,22 +0,0 @@
-= Release notes for Gerrit 2.4.2
-
-Gerrit 2.4.2 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.4.2.war[https://www.gerritcodereview.com/download/gerrit-2.4.2.war]
-
-There are no schema changes from 2.4, or 2.4.1.
-
-However, if upgrading from anything earlier, follow the upgrade
-procedure in the 2.4 link:ReleaseNotes-2.4.html[ReleaseNotes].
-
-== Security Fixes
-* Some access control sections may be ignored
-+
-Gerrit sometimes ignored an access control section in a project
-if the exact same section name appeared in All-Projects. The bug
-required an unrelated project to have access.inheritFrom set to
-All-Projects and be accessed before the project that has the same
-section name as All-Projects. This is an unlikely scenario for
-most servers, as Gerrit does not normally set inheritFrom equal to
-All-Projects. The usual behavior is to not supply this property in
-project.config, and permit the implicit inheritance to take place.
diff --git a/ReleaseNotes/ReleaseNotes-2.4.3.txt b/ReleaseNotes/ReleaseNotes-2.4.3.txt
deleted file mode 100644
index ece0bda..0000000
--- a/ReleaseNotes/ReleaseNotes-2.4.3.txt
+++ /dev/null
@@ -1,14 +0,0 @@
-= Release notes for Gerrit 2.4.3
-
-There are no schema changes from link:ReleaseNotes-2.4.2.html[2.4.2].
-
-link:https://www.gerritcodereview.com/download/gerrit-2.4.3.war[https://www.gerritcodereview.com/download/gerrit-2.4.3.war]
-
-== Bug Fixes
-* Patch JGit security hole
-+
-The security hole may permit a modified Git client to gain access
-to hidden or deleted branches if the user has read permission on
-at least one branch in the repository. Access requires knowing a
-SHA-1 to request, which may be discovered out-of-band from an issue
-tracker or gitweb instance.
diff --git a/ReleaseNotes/ReleaseNotes-2.4.4.txt b/ReleaseNotes/ReleaseNotes-2.4.4.txt
deleted file mode 100644
index f9ea6b5..0000000
--- a/ReleaseNotes/ReleaseNotes-2.4.4.txt
+++ /dev/null
@@ -1,11 +0,0 @@
-= Release notes for Gerrit 2.4.4
-
-There are no schema changes from link:ReleaseNotes-2.4.4.html[2.4.4].
-
-link:https://www.gerritcodereview.com/download/gerrit-2.4.4.war[https://www.gerritcodereview.com/download/gerrit-2.4.4.war]
-
-== Bug Fixes
-* Fix clone for modern Git clients
-+
-The security fix in 2.4.3 broke clone for recent Git clients,
-throwing an ArrayIndexOutOfBoundsException. Fixed.
diff --git a/ReleaseNotes/ReleaseNotes-2.4.txt b/ReleaseNotes/ReleaseNotes-2.4.txt
deleted file mode 100644
index 1db4ba3..0000000
--- a/ReleaseNotes/ReleaseNotes-2.4.txt
+++ /dev/null
@@ -1,241 +0,0 @@
-= Release notes for Gerrit 2.4
-
-Gerrit 2.4 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.4.war[https://www.gerritcodereview.com/download/gerrit-2.4.war]
-
-== Schema Change
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* Upgrading to 2.4.x requires the server be first upgraded to 2.1.7 (or
-a later 2.1.x version), and then to 2.4.x.  If you are upgrading from 2.2.x.x or
-newer, you may ignore this warning and upgrade directly to 2.4.x.
-
-== New Features
-
-=== Security
-
-* Restrict visibility to arbitrary user dashboards
-+
-Administrators have some expectation when using the 'suggest.accounts'
-visibility restriction feature that users cannot get the names or
-email addresses for arbitrary accounts. In fact, because account IDs
-are sequential, it would be easy for an adversary to get personal
-information of all users on the server by requesting every user's
-dashboard.
-+
-This includes changing the meaning of the 'suggest.accounts' config
-option to be a boolean indicating whether account suggestion should
-happen at all, which is now orthogonal to the account visibility
-restriction policy. We still recognize the old values for
-'suggest.accounts', with the slight behavior change that
-'suggest.accounts=OFF' now means that users cannot access the dashboards
-of any other users. Administrators who do not want this behavior can
-update their configuration.
-
-* Indicate that 'not found' may actually be a permission issue
-
-=== Web
-
-* Add user preference to mark files reviewed automatically or manually
-+
-Add a checkbox to the preferences header on the diff
-screen which allows a user to specify whether they
-want manual-reviewing enabled or disabled.  Previously,
-every file was auto marked reviewed when a user first
-displayed it.  The new manual mode prevents this auto
-marking and only marks a file reviewed when the user
-explicitly clicks on the reviewed checkbox.
-
-* Use 'Auto Merge' for merge commit's base comparison
-+
-When reviewing a merge commit, the old wording in the version history dropdown
-of 'Base' doesn't really match Gerrit's behavior.  Updating this to use
-'Auto Merge' as suggested by Shawn Pearce on IRC.
-
-* issue 1035 Add rebase button to the change screen
-+
-This change adds a rebase button along with the rest of
-the action buttons in the change page. When pressing the
-button, the most recent patch set will be rebased onto
-the tip of the destination branch or the latest patchset
-of the change we depend upon. A new patch set containing
-the rebased commit will be produced and added to the
-change.
-+
-Rebasing of a change in web UI is restricted to change owner, submitter or
-those with the (new) 'rebase' permission.
-
-* Add a new permission 'rebase' to permit rebasing changes in the web UI
-
-* Make a user's dashboard visible if any of the changes are visible to the
-current user.
-
-* Change 'Loading ...' to say 'Working ...' as, often, there is more going on
-than just loading a response.
-
-=== Performance
-
-* Asynchronously send email so it does not block the UI
-* Optimize queries for open/merged changes by project + branch
-
-=== Git
-
-* Implement a multi-sub-task progress monitor for ReceiveCommits
-
-* Close corresponding change when pushing to 'refs/heads/*'
-+
-Gerrit would not close the open changes with matching change-ids,
-when the user pushes commits directly to 'refs/heads/*'.
-+
-This issue could be triggered for two reasons:
-
-. It is triggered when Gerrit detects no changes between the
-pushed commits and the current patchset on the open changes. This
-patch make sure that the matching open change is always closed when
-pushing to 'refs/heads/*', even if no visible changes is detected.
-
-. The same commit exists on another branch than the destination
-branch. This could trick gerrit into just "re-closing" the wrong
-change.
-
-* Run ReceiveCommits in a shared thread pool
-+
-Since the work to ReceiveCommits may take a long, potentially unbounded
-amount of time, we would like to have it run in the background so it
-can be monitored for timeouts and cancelled, and have stalls reported
-to the user from the main thread.
-
-=== Search
-
-* Add the '--dependencies' option to the 'query' command.
-+
-This option includes information about patch sets which depend on, or are
-needed by, each patch set.
-
-* Branch Operator: Support full branch names
-+
-The search operator for branches required the provided value to be the
-short branch name that is shown in the web interface (without the
-'refs/heads/' prefix). Change the branch operator so that it also
-supports full branch names as value.
-+
-It is intuitive that searching with 'branch:master' and searching with
-'branch:refs/for/master' deliver the same result. So far
-'branch:refs/for/master' was the same as searching with
-'refs:refs/heads/refs/heads/master' which is unexpected for most users.
-
-* Add comment inclusion via '&comments=true' over HTTP
-+
-With this change, we can fetch the comments on a patchset by sending a
-request to 'https://site/query?comments=true'
-
-=== Access Rights
-
-* Added the 'emailReviewers' as a global capability.
-+
-This replaces the 'emailOnlyAuthors' flag of account groups.
-
-=== Dev
-
-* issue 1272 Add scripts to create release notes from git log
-+
-These script generates a list of commits from git log between two given commits
-and outputs the asciidoc format containing list of commits subject and body.
-
-* Update URL for m2eclipse
-+
-The project is now under the Eclipse Foundation umbrella.
-
-* Add missing ignore for m2e prefs in gerrit-ehcache
-
-* Add '--issues' and '--issue_numbers' options to the 'gitlog2asciidoc.py'
-
-=== Miscellaneous
-
-* Remove perl from 'commit-msg' hook
-+
-Removing perl from the commit-msg hook reduces the dependencies
-gerrit imposes on its users.
-
-* updating contrib 'trivial_rebase.py' for 2.2.2.1
-
-== Upgrades
-
-* Updated to Guice 3.0.
-* Updated to gwtorm 1.4.
-* Update JGit to 1.3.0.201202151440-r.75-gff13648
-* Update to gwtjsonrpc 1.3
-+
-The change also shrinks the built WAR from 38M to 23M
-by excluding the now unnecessary GWT server code.
-
-== Bug Fixes
-
-* issue 904 Users who starred a change should receive all the emails about a change.
-
-* Fix: 'Diff All Side-by-Side' and 'Diff All Unified' buttons
-+
-When pressing the 'Diff All Side-by-Side' or
-'Diff All Unified' button on the change screen, the
-opened browser windows/tabs shows diffs using "Base"
-as old version and the latest one as active patch set,
-regardless what has been set using the
-"Old Version History:" drop down menu and what is
-currently active patch set.
-+
-Gerrit doesn't remember the base patch set in the URL,
-making it impossible to copy-and-paste the URL to
-co-workers to show them the same diff a user is
-looking at.
-+
-This change fixes this behavior to make sure that
-the opened new browser windows shows diffs using the
-correct old patch set and active patch set.
-
-* Fix NPEs looking up groups by UUID in GroupCache
-
-* Fix default 'receive.timeout'
-+
-This should be in milliseconds, not seconds. Set the default to be
-2 minutes in milliseconds and update the documentation to reflect
-that milliseconds are the default unit of time used here.
-
-* Fix 'development_become_any_account' redirects
-* issue 1299 Allow configuration of optional pattern for gitweb file history link
-* Use servlet context path during logout
-
-* issue 1353 Fix case check for project name so that symlinks work again
-* Fix merging of access sections
-* Fix inconsistent behavior when replicating refs/meta/config
-* Fix duplicated results on status:open project:P branch:B
-
-== Documentation
-
-=== Access Rights
-* Capabilities introduced
-* Kill and priority capabilities
-* Administrate server capability
-* Create account capability
-* Create group and project capability
-* Flush caches capability
-* Capability replication and view caches
-* Capability view conn. & queue
-* Example roles introduced
-* Developer example role
-* CI system example role
-* Integrator example role
-* Project owner example role
-* Administrator example role
-
-=== Miscellaneous
-* User upload documentation: Replace changes
-* Add visible-to-all flag in the documentation for cmd-create-group
-* Add a contributing guideline for annotations
-* Add missing header for suggest.accounts documentation
-* Fix anchors for description of gitweb config parameters
-* Add missing section name to config-gerrit documentation
-* Fix documentation of ls-projects
diff --git a/ReleaseNotes/ReleaseNotes-2.5.1.txt b/ReleaseNotes/ReleaseNotes-2.5.1.txt
deleted file mode 100644
index c2982df..0000000
--- a/ReleaseNotes/ReleaseNotes-2.5.1.txt
+++ /dev/null
@@ -1,92 +0,0 @@
-= Release notes for Gerrit 2.5.1
-
-Gerrit 2.5.1 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-full-2.5.1.war[https://www.gerritcodereview.com/download/gerrit-full-2.5.1.war]
-
-There are no schema changes from 2.5, or 2.5.1.
-
-However, if upgrading from a version older than 2.5, follow the upgrade
-procedure in the 2.5 link:ReleaseNotes-2.5.html[Release Notes].
-
-== Security Fixes
-* Correctly identify Git-over-HTTP operations
-+
-Git operations over HTTP should be classified as using AccessPath.GIT
-and not WEB_UI. This ensures RefControl will correctly test for Create,
-Push or Delete access on a reference instead of Owner.
-+
-E.g. without this fix project owners are able to force push commits
-via HTTP that are already in the history of the target branch, even
-without having any Push access right assigned.
-
-* Make sure only Gerrit admins can change the parent of a project
-+
-Only Gerrit administrators should be able to change the parent of a
-project because by changing the parent project access rights and BLOCK
-rules which are configured on a parent project can be avoided.
-+
-The `set-project-parent` SSH command already verifies that the caller
-is a Gerrit administrator, however project owners can change the parent
-project by modifying the `project.config` file and pushing to the
-`refs/meta/config` branch.
-+
-This fix ensures that changes to the `project.config` file that change
-the parent project can only be pushed/submitted by Gerrit
-administrators.
-+
-In addition it is now no longer possible to
-+
-** set a non-existing project as parent (as this would make the project
-  be orphaned)
-** set a parent project for the `All-Projects` root project (the root
-  project by definition has no parent)
-by pushing changes of the `project.config` file to `refs/meta/config`.
-
-== Bug Fixes
-* Fix RequestCleanup bug with Git over HTTP
-+
-Decide if a continuation is going to be used early, before the filter
-that will attempt to cleanup a RequestCleanup. If so don't allow
-entering the RequestCleanup part of the system until the request is
-actually going to be processed.
-+
-This fixes the IllegalStateException `Request has already been cleaned
-up` that occurred when running on Jetty and pushing over HTTP for URLs
-where the path starts with `/p/`.
-
-* Match all git fetch/clone/push commands to the command executor
-+
-Route not just `/p/` but any Git access to the same thread pool as the
-SSH server is using, allowing all requests to compete fairly for
-resources.
-
-* Fix auto closing of changes on direct push
-+
-When a commit is directly pushed into a repository (bypassing code
-review) and this commit has a Change-Id in its commit message then the
-corresponding change is automatically closed if it is open.
-
-* Allow assigning `Push` for `refs/meta/config` on `All-Projects`
-+
-The `refs/meta/config` branch of the `All-Projects project` should only
-be modified by Gerrit administrators because being able to do
-modifications on this branch means that the user could assign himself
-administrator permissions.
-+
-In addition to being administrator we already require that the
-administrator has the `Push` access right for `refs/meta/config` in
-order to be able to modify it (just as with all other branches
-administrators do not have edit permissions by default).
-+
-The problem was that assigning the `Push` access right for
-`refs/meta/config` on the `All-Projects` project was not allowed.
-+
-Having the `Push` access right for `refs/meta/config` on the
-`All-Projects` project without being administrator already has no
-effect.
-+
-Prohibiting to assign the Push access right for `refs/meta/config` on
-the `All-Project` project was anyway pointless since it was e.g.
-possible to assign the `Push` access right on `refs/meta/*`.
-
diff --git a/ReleaseNotes/ReleaseNotes-2.5.2.txt b/ReleaseNotes/ReleaseNotes-2.5.2.txt
deleted file mode 100644
index 9bedeac..0000000
--- a/ReleaseNotes/ReleaseNotes-2.5.2.txt
+++ /dev/null
@@ -1,136 +0,0 @@
-= Release notes for Gerrit 2.5.2
-
-Gerrit 2.5.2 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-full-2.5.2.war[https://www.gerritcodereview.com/download/gerrit-full-2.5.2.war]
-
-There are no schema changes from 2.5, or 2.5.1.
-
-However, if upgrading from any earlier version, follow the upgrade
-procedure in the 2.5 link:ReleaseNotes-2.5.html[Release Notes].
-
-== Bug Fixes
-* Improve performance of ReceiveCommits for repos with many refs
-+
-When validating the received commits all existing refs were added as
-uninteresting to the RevWalk. This resulted in bad performance when a
-repository had many refs (>100000). Putting existing 'refs/changes/'
-or 'refs/tags/' into the RevWalk is now avoided, which improves the
-performance.
-
-* Improve Push performance by discarding 'cache-automerge/*' refs
-  early in VisibleRefFilter
-+
-For a typical large Git repository, with many refs and lots of cached
-merges, the push time goes down significantly.
-
-* Don't display all files from a merge-commit when auto-merge fails
-+
-For merge commits Gerrit shows the difference to the automatic merge
-result. The creation of the auto-merge result may fail, e.g. when the
-merge commit has multiple merge bases (because JGit doesn't support
-this case yet). In this case Gerrit was showing all files from the
-merge commit. This caused several issues:
-+
---
-** the file list was too large for projects with a large number of
-   files
-** Gerrit would send too many false notification emails to users
-   watching changes under certain paths
-** both client and server needed a lot of resources in order to handle
-   such a large list of files
---
-+
-Now the file list for a merge commit will be empty when the creation
-of the auto-merge result fails.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1726[issue 1726]:
-  Create ref for new patch set on direct push
-+
-If a change is in review and a new commit that has the Change-Id of
-this change in its commit message is pushed directly, then a new patch
-set for this commit is created and the change gets automatically
-closed. The problem was that no change ref for this new patch set was
-created and as result the change ref that was shown for the new patch
-set in the WebUI, and which was contained in the patchset-created
-event, was invalid.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1767[issue 1767]:
-  Remove wrong error message when pushing a new ref fails
-+
-If pushing a new ref was rejected because the user was not allowed to
-create it the error message always told the user that he's missing the
-'Create Reference' access right. This message was incorrect in some
-cases. Users that have the 'Create Reference' access right assigned
-are e.g. not allowed to create the ref if:
-+
---
-** they are pushing an annotated tag without having the
-   'Push Annotated Tag' access right
-** they are pushing a signed tag without having the 'Push Signed Tag'
-   access right
-** the project state is set to 'Read Only'
---
-+
-Now the error message just says 'Prohibited by Gerrit'. This generic
-error message is better than a more concrete error message which is
-wrong in same cases because a wrong message is misleading and
-confuses the user.
-+
-In addition the description of the 'Prohibited by Gerrit' error in the
-documentation has been updated to explain some additional cases in
-which the 'Prohibited by Gerrit' error occurs.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1444[issue 1444]:
-  Remove 'Mailing-List' header from sent emails
-+
-The non-standard 'Mailing-List' header that is included in the emails
-sent by Gerrit isn't allowed by the Amazon Simple Email Service and is
-now removed.
-
-* Improve SMTP client error messages
-+
-The wording of the error messages in the SMTP client was changed to
-make it more clear at exactly what stage in the SMTP transaction the
-server returned an error. Also the server's response text is now
-always included.
-+
-In addition it is now ensured that already rejected recipients are
-included in the error message when the server rejects the DATA
-command. Without this there is no way of debugging rejected
-recipients if all recipients are rejected since that typically
-results in a DATA command rejection. Because some SMTP servers (e.g.
-Postfix with the default configuration) delay rejection of HELO/EHLO
-and MAIL FROM commands to the RCPT TO stage, this can happen not only
-for bad recipients.
-
-* Allow time unit variables to be '0'
-+
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html[
-Gerrit Configuration parameters] that expect a numerical time unit as
-value can now be set to '0'.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1076[issue 1076]:
-  Fix CLA hyperlink on account registration page
-+
-The New Contributor Agreement hyperlink on the Account Registration page
-was malformed.
-
-* Fix broken link to repo command reference
-+
-The link to the repo command reference in the 'repo upload' section of
-the 'Uploading Changes' documentation was broken.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1569[issue 1569]:
-Fix unexpected behavior in the commit-msg hook caused by `GREP_OPTIONS`
-+
-If `GREP_OPTIONS` was set, it caused unexpected behavior in the
-commit-msg hook.  For example if it included a setting like
-`--exclude=".git/*"` it caused a new `Change-Id` line to be appended
-to the commit message on every amend.
-+
-`GREP_OPTIONS` is now unset at the beginning of the commit-msg script
-to prevent such problems from occurring.
-+
-The `GREP_OPTIONS` setting in the user's environment is unaffected
-by this change.
diff --git a/ReleaseNotes/ReleaseNotes-2.5.3.txt b/ReleaseNotes/ReleaseNotes-2.5.3.txt
deleted file mode 100644
index 6448f1c..0000000
--- a/ReleaseNotes/ReleaseNotes-2.5.3.txt
+++ /dev/null
@@ -1,20 +0,0 @@
-= Release notes for Gerrit 2.5.3
-
-Gerrit 2.5.3 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.5.3.war[https://www.gerritcodereview.com/download/gerrit-2.5.3.war]
-
-There are no schema changes from any of the 2.5.x versions.
-
-However, if upgrading from a version older than 2.5, follow the upgrade
-procedure in the 2.5 link:ReleaseNotes-2.5.html[Release Notes].
-
-== Security Fixes
-* Patch vulnerabilities in OpenID client library
-+
-Installations using OpenID for authentication were vulnerable to a
-number of attacks over the network.  The openid4java client library
-was identified as the entry point.  In this release Gerrit updated to
-the latest 0.9.8 release, which patches the known attack vectors.
-
-No other changes since 2.5.2.
diff --git a/ReleaseNotes/ReleaseNotes-2.5.4.txt b/ReleaseNotes/ReleaseNotes-2.5.4.txt
deleted file mode 100644
index 6ea93bb..0000000
--- a/ReleaseNotes/ReleaseNotes-2.5.4.txt
+++ /dev/null
@@ -1,20 +0,0 @@
-= Release notes for Gerrit 2.5.4
-
-Gerrit 2.5.4 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.5.4.war[https://www.gerritcodereview.com/download/gerrit-2.5.4.war]
-
-There are no schema changes from any of the 2.5.x versions.
-
-However, if upgrading from a version older than 2.5, follow the upgrade
-procedure in the 2.5 link:ReleaseNotes-2.5.html[Release Notes].
-
-== Bug Fixes
-* Require preferred email to be verified
-+
-Some users were able to select a preferred email address that was
-not previously verified. This may have allowed the server to send
-notifications to an invalid destination, resulting in higher than
-usual bounce rates.
-
-No other changes since 2.5.3.
diff --git a/ReleaseNotes/ReleaseNotes-2.5.5.txt b/ReleaseNotes/ReleaseNotes-2.5.5.txt
deleted file mode 100644
index 27dd2b6..0000000
--- a/ReleaseNotes/ReleaseNotes-2.5.5.txt
+++ /dev/null
@@ -1,14 +0,0 @@
-= Release notes for Gerrit 2.5.5
-
-There are no schema changes from link:ReleaseNotes-2.5.4.html[2.5.4].
-
-link:https://www.gerritcodereview.com/download/gerrit-2.5.5.war[https://www.gerritcodereview.com/download/gerrit-2.5.5.war]
-
-== Bug Fixes
-* Patch JGit security hole
-+
-The security hole may permit a modified Git client to gain access
-to hidden or deleted branches if the user has read permission on
-at least one branch in the repository. Access requires knowing a
-SHA-1 to request, which may be discovered out-of-band from an issue
-tracker or gitweb instance.
diff --git a/ReleaseNotes/ReleaseNotes-2.5.6.txt b/ReleaseNotes/ReleaseNotes-2.5.6.txt
deleted file mode 100644
index 393eb93..0000000
--- a/ReleaseNotes/ReleaseNotes-2.5.6.txt
+++ /dev/null
@@ -1,11 +0,0 @@
-= Release notes for Gerrit 2.5.6
-
-There are no schema changes from link:ReleaseNotes-2.5.6.html[2.5.6].
-
-link:https://www.gerritcodereview.com/download/gerrit-2.5.6.war[https://www.gerritcodereview.com/download/gerrit-2.5.6.war]
-
-== Bug Fixes
-* Fix clone for modern Git clients
-+
-The security fix in 2.5.4 broke clone for recent Git clients,
-throwing an ArrayIndexOutOfBoundsException. Fixed.
diff --git a/ReleaseNotes/ReleaseNotes-2.5.txt b/ReleaseNotes/ReleaseNotes-2.5.txt
deleted file mode 100644
index 6bcb87a..0000000
--- a/ReleaseNotes/ReleaseNotes-2.5.txt
+++ /dev/null
@@ -1,1912 +0,0 @@
-= Release notes for Gerrit 2.5
-
-Gerrit 2.5 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-full-2.5.war[https://www.gerritcodereview.com/download/gerrit-full-2.5.war]
-
-Gerrit 2.5 includes the bug fixes done with
-link:ReleaseNotes-2.4.1.html[Gerrit 2.4.1] and
-link:ReleaseNotes-2.4.2.html[Gerrit 2.4.2]. These bug fixes are *not*
-listed in these release notes.
-
-== Schema Change
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* Upgrading to 2.5.x requires the server be first upgraded to 2.1.7 (or
-a later 2.1.x version), and then to 2.5.x.  If you are upgrading from 2.2.x.x or
-newer, you may ignore this warning and upgrade directly to 2.5.x.
-
-=== Warning on upgrade to schema version 68
-
-The migration to schema version 68, may result in a warning, which can
-be ignored when running init in the interactive mode.
-
-E.g. this warning may look like this:
-
-----
-Upgrading database schema from version 67 to 68 ...
-warning: Cannot create index for submodule subscriptions
-Duplicate key name 'submodule_subscriptions_access_bySubscription'
-Ignore warning and proceed with schema upgrade [y/N]?
-----
-
-This migration is creating an index for the
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/user-submodules.html[submodule feature] in
-Gerrit. When the submodule feature was introduced the index was only
-created when a new site was initialized, but not when Gerrit was
-upgraded. This migration tries to create the index, but it will only
-succeed if the index does not exist yet. If the index exists already,
-the creation of the index will fail. There was no database independent
-way to detect this case and this is why this migration leaves it to the
-user to decide if a failure should be ignored or not. If from the error
-message you can see that the migration failed because the index exists
-already (as in the example above), you can safely ignore this warning.
-
-== Upgrade Warnings
-
-[[replication]]
-=== Replication
-
-Gerrit 2.5 no longer includes replication support out of the box.
-Servers that reply upon `replication.config` to copy Git repository
-data to other locations must also install the replication plugin.
-
-=== Cache Configuration
-
-Disk caches are now backed by individual H2 databases, rather than
-Ehcache's own private format. Administrators are encouraged to clear
-the `'$site_path'/cache` directory before starting the new server.
-
-The `cache.NAME.diskLimit` configuration variable is now expressed in
-bytes of disk used. This is a change from previous versions of Gerrit,
-which expressed the limit as the number of entries rather than bytes.
-Bytes of disk is a more accurate way to size what is held. Admins that
-set this variable must update their configurations, as the old values
-are too small. For example a setting of `diskLimit = 65535` will only
-store 64 KiB worth of data on disk and can no longer hold 65,000 patch
-sets. It is recommended to delete the diskLimit variable (if set) and
-rely on the built-in default of `128m`.
-
-The `cache.diff.memoryLimit` and `cache.diff_intraline.memoryLimit`
-configuration variables are now expressed in bytes of memory used,
-rather than number of entries in the cache. This is a change from
-previous versions of Gerrit and gives administrators more control over
-how memory is partitioned within a server. Admins that set this variable
-must update their configurations, as the old values are too small.
-For example a setting of `memoryLimit = 1024` now means only 1 KiB of
-data (which may not even hold 1 patch set), not 1024 patch sets.  It
-is recommended to set these to `10m` for 10 MiB of memory, and
-increase as necessary.
-
-The `cache.NAME.maxAge` variable now means the maximum amount of time
-that can elapse between reads of the source data into the cache, no
-matter how often it is being accessed. In prior versions it meant how
-long an item could be held without being requested by a client before
-it was discarded. The new meaning of elapsed time before consulting
-the source data is more useful, as it enables a strict bound on how
-stale the cached data can be. This is especially useful for slave
-servers account and permission data, or the `ldap_groups` cache, where
-updates are often made to the source without telling Gerrit to reload
-the cache.
-
-== New Features
-
-=== Plugins
-
-The Gerrit server functionality can be extended by installing plugins.
-Depending on how tightly the extension code is coupled with the Gerrit
-server code, there is a distinction between
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#plugin[plugins] and
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#extension[extensions].
-
-* link:#replication[Move replication logic to replication plugin]
-+
-This splits all of the replication code out of the core server
-and moves it into a standard plugin.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html[Documentation about
-  plugin development] including instructions for:
-** link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#getting-started[how to get
-   started with plugin development]
-** link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#deployment[plugin
-   deployment/installation]
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#API[API for plugins and
-  extensions]
-
-* Support for link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#ssh[SSH command
-  plugins]
-+
-Allows plugin developers to declare additional SSH commands.
-
-* Enable link:#ssh-alias[aliases for SSH commands]
-+
-Site administrators can alias SSH commands from a plugin into the
-`gerrit` namespace.
-+
-The aliases are configured statically at server startup, but are
-resolved dynamically at invocation time to the currently loaded
-version of the plugin. If the plugin is not loaded, or does not
-define the command, "not found" is returned to the user.
-
-* Support for link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#http[HTTP
-  plugins]
-+
-Plugins may contribute to the /plugins/NAME/ URL space.
-
-* Automatic registration of plugin bindings
-+
-If a plugin has no modules declared in the manifest, automatically
-generate the modules for the plugin based on the class files that
-appear in the plugin and the `@Export` annotations that appear on
-these concrete classes.
-+
-For any non-abstract command that extends SshCommand, plugins may
-declare the command with `@Export("name")` to
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#ssh[bind the implementation
-as that SSH command].
-+
-Likewise link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#http[HTTP servlets
-can also be bound to URLs].
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#data-directory[Support a data
-  directory for plugins on demand]
-
-* Support serving static/ and Documentation/ from plugins
-+
-The static/ and Documentation/ resource directories of a plugin can be
-served over HTTP for any loaded and running plugin, even if it has no
-other HTTP handlers. This permits a plugin to supply icons or other
-graphics for the web UI, or documentation content to help users learn
-how to use the plugin.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#documentation[Auto-formatting
-  of plugin HTTP pages from Markdown files]
-+
-If Gerrit detects that a requested plugin resource does not exist, but
-instead a file with a `.md` extension does exist, Gerrit opens the
-`.md` file and reformats it as html.
-
-* Support of link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#macros[macros in
-  Markdown plugin documentation]
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#auto-index[Automatic
-  generation of an index for the plugin documentation]
-
-* Support for audit plugins
-+
-Plugins can implement an `AuditListener` to be informed about auditable
-actions:
-+
-----
-  @Listener
-  public class MyAuditTrail extends AuditListener
-----
-+
-The plugin must define a plugin module that binds the implementation of
-the audit listener in the `configure()` method:
-+
-----
-  DynamicSet.bind(binder(), AuditListener.class).to(MyAuditTrail.class);
-----
-
-* Web UI for plugins
-+
-Administrators can see the list of installed plugins in the WebUI
-under `Admin` > `Plugins`. For each plugin the plugin status is shown
-and it is possible to navigate to the plugin documentation.
-
-* Servlet to list plugins
-+
-Administrators can retrieve plugin information from a REST interface
-by loading `<server-url>/a/plugins/`.
-
-* Support SSH commands to
-** link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-plugin-ls.html[list the installed
-   plugins]
-** link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-plugin-install.html[install plugins]
-** link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-plugin-enable.html[enable plugins]
-** link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-plugin-remove.html[disable plugins]
-** link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-plugin-reload.html[reload plugins]
-
-* Support installation of core plugin on site initialization
-
-* Automatically load/unload/reload plugins
-+
-The PluginScanner thread runs every 1 minute by default and loads any
-newly created plugins, unloads any deleted plugins, and reloads any
-plugins that have been modified.
-+
-The check frequency can be configured by setting
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#plugins.checkFrequency[
-plugins.checkFrequency] in the Gerrit config file. By configuration
-the scanner can also be disabled.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#classpath[Loading of plugins
-  in own ClassLoader]
-
-* Plugin cleanup in the background
-+
-When a plugin is stopped, schedule a Plugin Cleaner task to run
-1 minute later to try and clean out the garbage and release the
-JAR from `$site_path/tmp`.
-
-* Export `LifecycleListener` as extension point
-+
-Extensions may need to know when they are starting or stopping.
-Export the interface that they can use to learn this information.
-
-* Support injection of `ServerInformation` into extensions and plugins
-+
-Plugins can take this value by injection and learn the current
-server state during their own LifecycleListener. This enables a
-plugin to determine if it is loading as part of server startup, or
-because it was dynamically installed or reloaded by an administrator.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-plugins.html#getting-started[Maven
-  archetype for creating gerrit plugin projects]
-
-* Enables the use of session management in Jetty
-+
-This enables plugins to make use of servlet sessions.
-
-=== REST API
-Gerrit now supports a REST like API available over HTTP. The API is
-suitable for automated tools to build upon, as well as supporting some
-ad-hoc scripting use cases.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/rest-api.html[Documentation of the REST API]
-
-* Support REST endpoints to
-** link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/rest-api-changes.html[query changes]
-** link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/rest-api-projects.html[list projects]
-** link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/rest-api-projects.html#suggest-projects[suggest
-   projects]
-** link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/rest-api-accounts.html#list-account-capabilities[query
-   the global capabilities of the calling user]
-
-* Support link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/rest-api.html#authentication[anonymous
-  and authenticated access] to the REST endpoints
-
-* Support link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/rest-api.html#output[JSON output
-  format] for the REST endpoints
-
-The new REST API is used from the Gerrit WebUI.
-
-Some of the methods from the old internal JSON-RPC interface were
-completely replaced by the new REST API and got deleted:
-
-* `ProjectAdminService.visibleProjects(AsyncCallback<ProjectList>)`
-* `ProjectAdminService.suggestParentCandidates(AsyncCallback<List<Project>>)`
-* `ChangeListService.myStarredChangeIds(AsyncCallback<Set<Change.Id>>)`
-* `ChangeListService.allQueryNext(String, String, int, AsyncCallback<SingleListChangeInfo>)`
-* `ChangeListService.allQueryPrev(String, String, int, AsyncCallback<SingleListChangeInfo>)`
-* `ChangeListService.forAccount(Account.Id, AsyncCallback<AccountDashboardInfo>)`
-
-[[query-deprecation]]
-In addition the `/query` API has been deprecated. By default it is
-still available but server administrators may disable it by setting
-the link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#site.enableDeprecatedQuery[
-`site.enableDeprecatedQuery`] parameter in the Gerrit config file. This
-allows to enforce tools to move to the new API.
-
-=== Web
-
-==== Change Screen
-
-* Display commit message in a box
-+
-The commit message on the change screen is now placed in a box with a
-title and emphasis on the commit summary. The star icon and the
-permalink are displayed in the box header. The header from the change
-screen is removed as it only held duplicate information.
-
-* Open the dependency section automatically when the change is needed
-  by an open change
-
-* Only show a change as needed by if its current patch set depends on
-  the change
-
-* Show only changes of the same project in the 'Depends On' section
-+
-If two projects share the same history it can happen that the same
-commit is pushed for both projects, resulting in two changes. If now
-a successor commit is pushed for one of the projects, the resulting
-successor change was wrongly listing both changes in the 'Depends On'
-section. Now only the predecessor change of the own project is listed.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1383[issue 1383]:
-  Display the approval table on the PublishCommentsScreen.
-+
-So far the approval table that shows the reviewers and their current
-votes was only shown on the ChangeScreen. Now it is also shown on the
-PublishCommentScreen. This allows the reviewer to see all existing
-votes and reviewers when doing their own voting and publishing of
-comments. Seeing the existing votes helps the reviewer in
-understanding which votes are still required before the change can be
-submitted.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1380[issue 1380]:
-  Display time next to change comments
-+
-When a comment was posted yesterday, or any time older than 1 day but
-less than 1 year ago, display the time too. Display "May 2 17:37" rather
-than just "May 2".
-
-* Only show "Can Merge" when the change is new or draft
-
-* Allow auto suggesting reviewers to draft changes
-+
-Auto completing users for draft changes did't work as the other
-users didn't have access to the drafts. The visibility check for
-the reviewer suggestion is now skipped.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1294[issue 1294]:
-  Shorten subject of parent commit for displaying in the UI
-+
-If the parent commit has a very long subject (> 80 characters) shorten
-the subject for displaying it in the Gerrit web UI on the change screen.
-This avoids that the 'Parent(s)' cell for the patch set becomes very
-wide.
-
-* If subject is shortened for displaying in the UI indicate this by '...'
-+
-If a commit has a very long subject line (> 80 characters) it is
-shortened when it is displayed in the Gerrit Web UI. Indicate to the
-user that the subject was shortened by appending '...' to the shortened
-subject.
-+
-Also the subject is now cropped after a whitespace if possible.
-
-* Insert Change-Id for revert commits
-+
-The 'Revert Change' action on a merged change allows to create a new
-change that reverts the merged change. The commit message of the revert
-commit now contains a Change-Id.
-+
-It is convenient if a Change-Id is automatically created and inserted
-into the commit message of the revert commit since it makes rebasing of
-the revert commit easier.
-
-* Use more gentle shade of red to highlight outdated dependencies
-
-==== Patch Screens
-
-* New patch screen header
-+
-A new patch screen header was added that is displayed above both the
-side-by-side and unified views. The new header contains actual links to
-the available patchsets and shows which patchset is being currently
-displayed.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1192[issue 1192]:
-  Add download links to the unified diff view
-
-* Improvement of the side-by-side viewer table
-+
-The line number column for the right side was moved to be on the far
-right of the table, so that the layout now looks like this:
-+
-----
-  1 |  foo       |       bar   | 1
-  2 |  hello     |       hello | 2
-----
-+
-This looks nicer when reading a lot of code, as the line numbers are
-less relevant than the code itself which is now in the center of the
-UI.
-+
-Line numbers are still links to create comment editors, but they
-use a light shade of gray and skip the underline decoration, making
-them less visually distracting.
-+
-Skip lines now use a paler shade of blue and also hide the fact they
-contain anchors, until you hover over them and the anchor shows up.
-+
-The expand before and after are changed to be arrows showing in
-which direction the lines will appear above or below the skip
-line.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=626[issue 626]:
-  Option to display line endings
-+
-There is a new user preference that allows to display Windows EOL/Cr-Lf.
-'\r' is shown in a dotted-line box (similar to how '\r' is displayed in
-GitWeb).
-
-* Streamlined review workflow
-+
-A link was added next to the "Reviewed" checkbox that marks the current
-patch as reviewed and goes to the next unreviewed patch.
-
-* Add key commands to mark a patch as reviewed
-+
-Add key commands
-+
-. to toggle the reviewed flag for a patch ('m')
-+
-and
-+
-. to mark the patch as reviewed and navigate to the next unreviewed
-patch ('M').
-
-* Use download icons instead of the `Download` text links
-
-==== User Dashboard
-* Support for link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/user-custom-dashboards.html[custom
-  dashboards]
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1407[issue 1407]:
-  Improve highlighting of unreviewed changes in the user's dashboard
-+
-A change will be highlighted as unreviewed if
-+
-. the user is reviewer of the change but hasn't published any change
-  message for the current patch set
-. the user has published a change message for the current patch set,
-  but afterwards the change owner has published a change message on
-  the change
-
-* Sort outgoing reviews in the user dashboard by created date
-
-* Sort incoming reviews in the user dashboard by updated date
-+
-Sorting the incoming reviews by last updated date, descending, places
-the most recently updated reviews at the top of the list for a user,
-and the oldest stale at the bottom. This may help users to identify
-items to take immediate action on, as they appear closer to the top.
-
-==== Access Rights Screen
-
-* Display error if modifying access rights for a ref is forbidden
-+
-If a user is owner of at least one ref he is able to edit the access
-rights on a project. If he adds access rights for other refs, these
-access rights were silently ignored on save. Instead of this now an
-error message is displayed to inform the user that he doesn't have
-permissions to do the update for these refs.
-+
-In case of such an error the project access screen stays in the edit
-mode so that the unsaved modifications are not lost. The user may now
-propose the changes to the access rights through code review.
-
-* Allow to propose changes to access rights through code review
-+
-Users that are able to upload changes for code review for the
-`refs/meta/config` branch can now propose changes to the project access
-rights through code review directly from the ProjectAccessScreen.
-+
-When editing the project access rights there is a new button
-'Save for Review' which will create a new change for the access
-rights modifications. Project owners are automatically added as
-reviewer to this change. If a project owner agrees to the access rights
-modifications he can simply approve and submit the change.
-
-* Show all access rights in WebUI if user can read `refs/meta/config`
-+
-Users who can read the `refs/meta/config` branch, can see all access
-rights by fetching this branch and looking at the `project.config`
-file. Now they can see the same information in the web UI.
-
-* Allow extra group suggestions for project owners
-+
-When suggesting groups to a user, only groups that are visible to the
-user are suggested. These are those group that the user is member of.
-For project owners now also groups to which they are not a member are
-suggested when editing the access rights of the project.
-
-==== Other
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1592[issue 1592]:
-  Ask user to login if change is not found
-+
-Accessing a change URL was failing with 'Application Error - The page
-you requested was not found, or you do not have permission to view this
-page' if the user was not signed in and the change was not visible to
-`Anonymous Users`. Instead Gerrit now asks the user to login and
-afterwards shows the change to the user if it exists and is visible.
-If the change doesn't exist or is not visible, the user will still get
-the NotFoundScreen after sign in.
-
-* Link to owner query from user names
-+
-Instead of linking from a user name to the user's dashboards, link to
-a search for changes owned by that user.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#gerrit.reportBugUrl[Allow
-  configuring the `Report Bug` URL]
-+
-Let site administrators direct users to their own ticket queue, as for
-many servers most of the reported bugs are small internal problems like
-asking for a repository to be created or updating group memberships.
-
-* On project creation allow choosing the parent project from a popup
-+
-In the create project UI a user can now browse all projects and select
-one as parent for the new project.
-
-* Check for open changes on branch deletion
-+
-Check for open changes when deleting a branch in the Gerrit WebUI.
-Delete a branch only if there are no open changes for this branch.
-This makes users aware of open changes when deleting a branch.
-
-* Enable ProjectBranchesScreen for the `All-Projects` project
-+
-This allows to see the branches of the `All-Projects` project in the
-web UI.
-
-* Show for each project in the project list a link to the repository
-  browser (e.g. GitWeb).
-
-* Move the project listing menu items to a new top-level item
-+
-Finding the project listing was very opaque to end users. Nobody
-expected to look under `Admin` and furthermore, anonymous users were
-unable to find that link at all.
-+
-Introduced a new top-level `Projects` menu that has `List` in it to
-take you to the project listing.
-+
-In addition the `Create new project` link from the top of that listing
-was moved to this new menu.
-
-* Move the Groups and Plugins menu items to the top level
-+
-The top-level Admin menu is removed as it is now unnecessary after the
-Projects, Groups and Plugins menu items were moved to the top-level.
-
-* Move form for group creation to own screen
-+
-Move the form for the group creation from the GroupListScreen to an
-own new CreateGroupScreen and add a link to this screen at the
-beginning of the GroupListScreen. The link to the CreateGroupScreen is
-only visible if the user has the permission to create new groups.
-
-* Drop the `Owners` column from the group list screen
-+
-The `Owners` column on the group list screen has been dropped in order
-to link:#performance-issue-on-showing-group-list[speed up the loading
-of the group list screen].
-
-* Drop the `Group Type` column from the group list screen
-+
-Since link:#migrate-ldap-groups[the LDAP group type was removed] there
-is no need to display the group type on the group list screen anymore.
-There are only 3 `SYSTEM` groups using well known names, and everything
-else has the type `INTERNAL`.
-
-* When adding a user to a group create an account for the user if needed
-+
-Trying to add a user to a group that doesn't have an account fails with
-'... is not a registered user.'. Now adding a user to a group does not
-immediately fail if there is no account for the user, but it tries to
-authenticate the user and if the authentication is successful a user
-account is automatically created, so that the user can be added to the
-group. This only works if LDAP is used as user backend.
-+
-This allows to add users to groups that did not log in into Gerrit
-before.
-
-* Differentiate between draft changes and draft comments
-+
-Show the draft changes of the user when he clicks on `My` > `Drafts`.
-The user's draft comments are now available under `My` >
-`Draft Comments`.
-
-* Show NotFoundScreen if a user that can't create projects tries to
-  access the ProjectCreationScreen
-
-* Add Edit, Reload next to non-editable Full Name field
-+
-If the user database is actually an external system users might need go
-to another server to edit their account data, and then re-import their
-account data by going through a login cycle. This is highly similar to
-LDAP where the directory provides account data and its refreshed every
-time the user visits the `/login/` URL handler.
-+
-The URL for the external system can be configured for the
-link:#custom-extension[`CUSTOM_EXTENSION`] auth type.
-
-=== Access Rights
-
-* Restrict rebasing of a change in the web UI to the change owner and
-  the submitter
-
-* Add a new link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/access-control.html#category_rebase[
-  access right to permit rebasing changes in the web UI]
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=930[issue 930]:
-  Add new link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/access-control.html#category_abandon[
-  access right for abandoning changes]
-
-* Check if user can upload in order to restore
-+
-Restoring a change is similar to uploading a new change. If a branch
-gets closed by removing the access rights to upload new changes it
-shouldn't be possible to restore changes for this branch.
-
-[[hide-config]]
-* Make read access to `refs/meta/config` by default exclusive to
-  project owners
-+
-When initializing a new site a set of default access rights is
-configured on the `All-Projects` project. These default access rights
-include read access on `refs/*` for `Anonymous Users` and read access
-on `refs/meta/config` for `Project Owners`. Since the read access on
-`refs/meta/config` for `Project Owners` was not exclusive,
-`Anonymous users` were able to access the `refs/meta/config` branch
-which by default should only be accessible by the project owners.
-
-=== Search
-* Offer suggestions for the search operators in the search panel
-+
-There are many search operators and it's difficult to remember all of
-them. Now the search operators are suggested as the user types the
-query.
-
-* Support alias `self` in queries
-+
-Writing an expression like "owner:self status:open" will now identify
-changes that the caller owns and are still open. This `self` alias
-is valid in contexts where a user is expected as an argument to a
-query operator.
-
-* Add parent(s) revision information to output of query command
-
-* Add owner username to output of query command
-
-* `/query` API has been link:#query-deprecation[deprecated]
-
-=== SSH
-* link:http://code.google.com/p/gerrit/issues/detail?id=1095[issue 1095]
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-set-account.html[SSH command to manage
-  accounts]
-
-* On link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-create-account.html[account creation] a
-  password for HTTP can be specified.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-set-project.html[SSH command to manage
-  project settings]
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-test-submit-rule.html[SSH command to test
-  submit rules]
-+
-The command creates a fresh Prolog environment and loads a Prolog
-script from stdin. `can_submit` is then queried and the results are
-returned to the user.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-ban-commit.html[SSH command to ban
-  commits]
-
-[[ssh-alias]]
-* Enable aliases for SSH commands
-+
-Site administrators can define aliases for SSH commands in the
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#ssh-alias[`ssh-alias` section]
-of the Gerrit configuration.
-
-* Add submit records to the output of the
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-query.html[query] SSH command:
-+
-Add a command line option to the `query` SSH command to include submit
-records in the output.
-+
-This facilitates the querying of information relating to the submit
-status from the command line and by API clients, including information
-such as whether the change can be submitted as-is, and whether the
-submission criteria for each review label has been met.
-
-* Support JSON output format for the
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-ls-projects.html[ls-projects] SSH command
-
-* Support creation of multiple branches in
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-create-project.html[create-project] SSH
-  command
-+
-In case if a project has some kind of waterfall automerging
-a->b->c it is convenient to create all these branches at the
-project creation time.
-+
-e.g. '.. gerrit create-project -b master -b foo -b bar ...'
-
-* Add verbose output option to
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-ls-groups.html[ls-groups] command
-+
-The verbose mode enabled by the new option makes the ls-groups
-command output a tab-separated table containing all available
-information about each group (though not its members).
-
-=== Documentation
-
-==== Commands
-
-* document for the link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-create-group.html[`create-group`]
-  command that for unknown users an account is automatically created if
-  the LDAP authentication succeeds
-
-* Update documentation and help text for the
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-review.html[`review`] SSH command
-+
-The review command can be applied to multiple changes, but the
-help text was written in singular tense.
-+
-Add a paragraph in the documentation explaining that the
-`--force-message` option will not be effective if the `review` command
-fails because the user is not permitted to change the label.
-
-* Clarify that `init --batch` doesn't drop old database objects
-
-* Update the list of unsupported slave commands
-
-* Fix link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-stream-events.html[`stream-events`]
-  documentation
-+
-Some attributes contained in the events were not described, for a few
-others the name was given in a wrong case.
-
-* Fix and complete synopsis of commands
-
-==== Access Control
-
-* Clarify the ref format for
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/access-control.html#category_push_merge[`Push
-  Merge Commit`]
-+
-Elaborate on the required format of the ref used for `Push Merge Commit`
-access right entries to avoid user confusion when granting access to
-`refs/heads/*` still doesn't allow them to push any merge commits.
-
-* Document the
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/access-control.html#capability_emailReviewers[
-  `emailReviewers`] capability
-
-==== Error
-* Improve documentation of link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/error-change-closed.html[
-  `change closed` error]
-+
-The `change closed` error can also occur when trying to submit a
-review label with the SSH review command onto a change that has
-been closed (submitted and merged, or abandoned) or onto a patchset
-that has been replaced by a newer patchset.
-
-* Correct documentation of `invalid author` and `invalid committer`
-  errors
-+
-The error messages `you are not committer ...` and `you are not
-author ...` were replaced with `invalid author` and `invalid
-committer`.
-
-* Describe that the `prohibited by Gerrit` error is returned if pushing
-  a tag fails because the tagger is somebody else and the `Forge
-  Committer` access right is not assigned.
-
-==== Dev
-
-* Update push URL in link:../SUBMITTING_PATCHES[SUBMITTING_PATCHES]
-+
-Pushes are now accepted at the same address as clone/fetch/pull.
-
-* Update link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-contributing.html[contributor
-  document]
-+
-We now prefer to use Guava (previously known as Google Collections).
-
-* Fixed broken link to source code
-+
-Updated the documentation source code links to point to:
-http://code.google.com/p/gerrit/source/checkout
-
-* State link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-eclipse.html#known-problems[known issues]
-  when debugging Gerrit with Eclipse
-
-* Improved the section on
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-eclipse.html#hosted-mode[hosted mode
-  debugging]
-+
-The existing section on hosted mode debugging left out a couple of
-steps, and the requirement to use `DEVELOPMENT_BECOME_ANY_ACCOUNT`
-instead of `OpenID` was not mentioned anywhere.
-
-* Add a link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-release.html[release preparation
-  document]
-+
-Document what it takes to make a Gerrit stable or stable-fix release,
-and how to release Gerrit subprojects.
-
-==== Other
-* Add link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/prolog-cookbook.html[Cookbook for Prolog
-  submit rules]
-+
-A new document providing a step by step introduction into implementing
-specific submit policies using Prolog based submit rules was added.
-
-* Describe link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/refs-notes-review.html[
-  `refs/notes/review` and its contents]
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-mail.html[Document `RebasedPatchSet.vm`
-  and `Reverted.vm` mail templates]
-
-* Specify output file for curl commands in documentation
-+
-For downloading the `commit-msg` hook and the `gerrit-cherry-pick`
-script users can either use scp or curl. Specify the output file for
-each curl command so that the result is equal to the matching scp
-command.
-
-* Document that user must be in repository root to install `commit-msg`
-  hook
-
-* Add some clarifications to the
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/install-quick.html[quick installation guide]
-
-* Add missing documentation about
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#hooks[hook configuration]
-+
-Add documentation of hook config for `change-restored`, `ref-updated`
-and `cla-signed` hooks.
-
-* Document that the commit message hook file should be executable
-
-* Mention that also MySQL supports replication, not just Postgres
-
-* Make sorting of release notes consistent so that the release notes
-  for the newest release is always on top
-
-* Various corrections
-+
-Correct typos, spelling mistakes, and grammatical errors.
-
-=== Dev
-* Add link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-release.html#plugin-api[script for
-  releasing plugin API jars]
-
-* Pushes are now accepted at the same address as clone/fetch/pull
-+
-To submit patches commits can be pushed to
-https://gerrit.googlesource.com/gerrit
-
-* Add `-Pchrome`, `-Pwebkit`, `-Pfirefox` aliases for building
-+
-This makes it easier to build for the browser you want to
-test on, rather than remembering what its GWT name is.
-
-* Disable assertions for KeyCommandSet when running in gwtdebug mode
-+
-The assertions in the KeyCommandSet class cause exceptions when a
-KeyCommand is registered several times.
-
-* Add the run profiles to the favorites menu
-
-* Add Intellij IDEA files to ignore list
-
-* Move local Maven repository to Google Cloud Storage
-
-* Make sure asciidoc uses unix line endings in generated HTML.
-+
-Use an explicit asciidoc attribute to make sure the produced HTML will
-always contain unix line endings.  This will help in producing build
-results that are better comparable by size.
-
-* Remove timestamp from all `org.eclipse.core.resources.prefs` files
-+
-Eclipse overwrites these files when we import projects using m2e.
-Eclipse 3 writes a timestamp at the top of these files making the Git
-working tree dirty.  Eclipse 4 (Juno) still overwrites these files but
-doesn't write the timestamp.  This should help to keep the working tree
-clean.  However, since the timestamp is currently present in these
-files, Eclipse 4 would still make them dirty by overwriting and
-effectively removing the timestamp.
-+
-This change removes the timestamp from these files. This helps those
-using Eclipse 4 and doesn't make it worse for those still using Eclipse
-3.
-
-* Add Maven profile to skip build of plugin modules
-+
-Building the plugin modules ('Plugin API' and 'Plugin Archetype') may
-take a significant amount of time (since many jars are downloaded).
-During development it is not needed to build the plugin modules. A new
-Maven profile was added that skips the build of the plugin modules,
-so that developers have a faster turnaround. This profile is called
-`no-plugins` and it's active by default. To include the plugin modules
-into the build activate the `all` profile:
-+
-----
-  mvn clean package -P all
-----
-+
-The script to make release builds has been adapted to activate the
-`all` profile so that the plugin modules are always built for release
-builds.
-
-=== Mail
-
-* Add unified diff to newchange mail template
-+
-Add `$email.UnifiedDiff` as new macro to the `NewChange.vm` mail
-template. This macro is expanded to a unified diff of the patch.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#sendemail.includeDiff[
-  sendemail.includeDiff]: Enable `$email.UnifiedDiff` in `NewChange.vm`
-+
-Instead of making site administrators hack the email template, allow
-admins to enable the diff feature by setting a configuration variable
-in `gerrit.config`.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#sendemail.maximumDiffSize[
-  sendemail.maximumDiffSize]: Limit the size of diffs sent by email
-+
-If a unified diff included in an email will exceed the limit configured
-by the system administrator, only the affected file paths are listed in
-the email instead. This gives interested parties some context on the
-size and scope of the change, without killing their inbox.
-
-* Catch all exceptions when emailing change update
-
-* Allow unique from address generation
-+
-Allow the from email address to be a ParameterizedString that handles
-the `${userHash}` variable. The value of the variable is the md5 hash
-of the user name. This allows unique generation of email addresses, so
-GMAIL threads names of users in conversations correctly. For example,
-the from pattern for gerrit-review defined in the Gerrit configuration
-looks like this:
-+
-----
-  [sendemail]
-    from = ${user} <noreply-gerritcodereview+${userHash}@google.com>
-----
-
-* Show new change URLs in the body of the new change email
-+
-Some email clients hide the signature section of an email
-automatically.  If there are no reviewers listed on a new change,
-such as when a change is pushed over HTTP and a notification is
-automatically sent out to any subscribed watchers, the URL was
-hidden inside of the signature and not readily available.
-+
-Show the URL right away in the body.
-
-=== Miscellaneous
-* Back in-memory caches with Guava, disk caches with H2
-+
-Instead of using Ehcache for in-memory caches, use Guava. The Guava
-cache code has been more completely tested by Google in high load
-production environments, and it tends to have fewer bugs. It enables
-caches to be built at any time, rather than only at server startup.
-+
-By creating a Guava cache as soon as it is declared, rather than
-during the LifecycleListener.start() for the CachePool, we can promise
-any downstream consumer of the cache that the cache is ready to
-execute requests the moment it is supplied by Guice. This fixes a
-startup ordering problem in the GroupCache and the ProjectCache, where
-code wants to use one of these caches during startup to resolve a
-group or project by name.
-+
-Tracking the Guava backend caches with a DynamicMap makes it possible
-for plugins to define their own in-memory caches using CacheModule's
-cache() function to declare the cache. It allows the core server to
-make the cache available to administrators over SSH with the gerrit
-show-caches and gerrit `flush-caches` commands.
-+
-Persistent caches store in a private H2 database per cache, with a
-simple one-table schema that stores each entry in a table row as a
-pair of serialized objects (key and value). Database reads are gated
-by a BloomFilter, to reduce the number of calls made to H2 during
-cache misses. In theory less than 3% of cache misses will reach H2 and
-find nothing. Stores happen on a background thread quickly after the
-put is made to the cache, reducing the risk that a diff or web_session
-record is lost during an ungraceful shutdown.
-+
-Cache databases are capped around 128M worth of stored data by running
-a prune cycle each day at 1 AM local server time. Records are removed
-from the database by ordering on the last access time, where last
-accessed is the last time the record was moved from disk to memory.
-
-* Add OpenID SSO support.
-+
-Setting `OPENID_SSO` for
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#auth.type[`auth.type`] in the
-`gerrit.config` will allow the admin to specify an SSO entry point URL
-so that users clicking on "Sign In" are sent directly to that URL.
-
-* Git over HTTP BasicAuth against Gerrit basic auth.
-+
-Allows the configuration of native Gerrit username/password
-authentication scheme used for Git over HTTP BasicAuth, as alternative
-of the default DigestAuth scheme against the random generated password
-on Gerrit DB.
-+
-Example setting for link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#auth.type[
-`auth.type`] and link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#auth.gitBasicAuth[
-`auth.gitBasicAuth`]:
-+
-----
-  [auth]
-    type = LDAP
-    gitBasicAuth = true
-----
-+
-With this configuration Git over HTTP protocol will be authenticated
-using `HTTP-BasicAuth` and credentials checked on LDAP.
-
-* Abstract group systems into GroupBackend interface
-+
-Group backends are supposed to use unique prefixes to isolate the
-namespaces. E.g. the group backend for LDAP is using `ldap/` as prefix
-for the group names.
-+
-This means that to refer to an LDAP group in the WebUI the group name
-needs to be prefixed with the `ldap/` string. E.g. if there is a group
-in LDAP which is called "Developers", Gerrit will suggest this group
-when the user types `ldap/De`.
-+
-WARNING: External groups are not anymore allowed to be members of
-internal groups.
-
-[[migrate-ldap-groups]]
-* Migrate existing internal LDAP groups
-+
-Previously, LDAP groups were mirrored in the AccountGroup table and
-given an Id and UUID the same as internal groups. Update these groups
-to be backed by only a GroupReference, with a special "ldap:" UUID
-prefix. Migrate all existing references to the UUID in ownerGroupUUID
-and any `project.config`.
-+
-This made the LDAP group type obsolete and it was removed.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=548[issue 548]:
-  Make commands to download patch sets
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#download.command[configurable]
-+
-For patch sets on the ChangeScreen different commands for downloading
-the patch sets are offered. For some installations not all commands are
-needed. Allow Gerrit administrators to configure which download
-commands should be offered.
-
-* Add more link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#theme[theme color
-  options]
-+
-** Add a theme option to change outdated background color
-** Add odd/even row background color for tables such as list of open
-reviews.  This makes them more visible without clicking on them.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/user-notify.html[Add `notify` section in
-  `project.config`]
-+
-The notify section allows project owners to include emails to users
-directly from `project.config`. This removes the need to create fake
-user accounts to always BCC a group mailing list.
-
-* Include the contributor agreements in the `project.config` and
-  migrate contributor agreements to `All-Projects`
-+
-Update the parsing of `project.config` to support the contributor
-agreements.
-+
-Add a new schema to move the ContributorAgreement, AccountAgreement,
-and AccountGroupAgreement information into the `All-Projects`
-`project.config`.
-
-* Add `sameGroupVisibility` to `All-Projects` `project.config`
-+
-The `sameGroupVisiblity` is needed to restrict the visibility of
-accounts when `accountVisibility` is `SAME_GROUP`. Namely, this is a
-way to make sure the `autoVerify` group in a `contributor-agreements`
-section is never suggested.
-
-* Add change topic in hook arguments
-+
-It was not possible for hook scripts to include topic-specific
-behavior because the topic name was not included in the arguments.
-
-* Add `--is-draft` argument on `patchset-created` hook
-+
-The `--is-draft` argument will be passed with either `true` if
-the patchset is a draft, or `false` otherwise.
-+
-This can be used by hooks that need to behave differently if the
-change is a draft.
-
-* Log sign in failures on info level
-+
-If for a user signing in into the Gerrit web UI fails, this can have
-many reasons, e.g. username is wrong, password is wrong, user is marked
-as inactive, user is locked in the user backend etc. In all cases the
-user just gets a generic error message 'Incorrect username or
-password.'. Gerrit administrators had trouble to find the exact reason
-for the sign in problem because the corresponding AccountException was
-not logged.
-
-* Do not log 'Object too large' as error with full stacktrace
-+
-If a user pushes an object which is larger than the configured
-`receive.maxObjectSizeLimit` parameter, the push is rejected with an
-'Object too large' error. In addition an error log entry with the full
-stacktrace was written into the error log.
-+
-This is not really a server error, but just a user doing something that
-is not allowed, and thus it should not be logged as error. For a Gerrit
-administrator it might still be interesting how often the limit is hit.
-This is why it makes sense to still log this on info level.
-+
-For the user pushing a too large object we now do not print the
-'fatal: Unpack error, check server log' message anymore, but only the
-'Object too large' error message.
-
-* Add better explanations to rejection messages
-+
-Provide information to the user why a certain push was rejected.
-
-* Automatic schema upgrade on Gerrit startup
-+
-In case when Gerrit administrator(s) don't have a direct access to the
-file system where the review site is located it gets difficult to
-perform a schema upgrade (run the init program). For such cases it is
-convenient if Gerrit performs schema upgrade automatically on its
-startup.
-+
-Since this is a potentially dangerous operation, by default it will not
-be performed. The configuration parameter
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#site.upgradeSchemaOnStartup[
-site.upgradeSchemaOnStartup] is used to switch on automatic schema
-upgrade.
-
-* Shorten column names that are longer than 30 characters
-+
-Some databases can't deal with column names that are longer than 30
-characters. Examples are MaxDB and
-link:http://groups.google.com/group/repo-discuss/browse_thread/thread/ecb713d42c04ae8a/cc963525d8247a17?lnk=gst#cc963525d8247a17[Oracle].
-+
-Gerrit had two column names in the `accounts` table that exceeded the
-30 characters: `displayPatchSetsInReverseOrder`,
-`displayPersonNameInReviewCategory`
-+
-These 2 columns were renamed so that their names fit within the 30
-character range.
-
-* Increase the maximum length for tracking ID's to 32 characters
-+
-So far tracking ID's had a maximum length of only 20 characters.
-
-* Set `GERRIT_SITE` in Gerrit hooks as environment variable
-+
-Allows development of hooks parameterized on Gerrit location. This can
-be useful to allow hooks to load the Gerrit configuration when needed
-(from `$GERRIT_SITE`) or even store their additional config files under
-`$GERRIT_SITE/etc` and retrieve them at startup.
-
-* Add an exponentially rolling garbage collection script
-+
-`git-exproll.sh` is a git garbage collection script aimed specifically
-at reducing excessive garbage collection and particularly large
-packfile churn for Gerrit installations.
-+
-Excessive garbage collection on "dormant" repos is wasteful of both CPU
-and disk IO.  Large packfile churn can lead to heavy RAM and FS usage
-on Gerrit servers when the Gerrit process continues to hold open the
-old delete packfiles.  This situation is most detrimental when jgit is
-configured with large caching parameters.  Aside from these downsides,
-running git gc often can be very beneficial to performance on servers.
-This script attempts to implement a git gc policy which avoids the
-downsides mentioned above so that git gc can be comfortably run very
-regularly.
-+
-`git-exproll.sh` uses keep files to manage which files will get
-repacked.  It also uses timestamps on the repos to detect dormant repos
-to avoid repacking them at all.  The primary packfile objective is to
-keep around a series of packfiles with sizes spaced out exponentially
-from each other, and to roll smaller packfiles into larger ones once
-the smaller ones have grown.  This strategy attempts to balance disk
-space usage with avoiding rewriting large packfiles most of the time.
-+
-The exponential packing objective above does not save a large amount of
-time or CPU, but it does prevent the packfile churn.  Depending on repo
-usage, however the dormant repo detection and avoidance can result in a
-very large time savings.
-
-* Automatically flush persistent H2 cache if the existing cache entries
-  are incompatible with the cache entry class and thus can't be
-  deserialized
-
-* Unpack JARs for running servers in `$site_path/tmp`
-+
-Instead of unpacking a running server into `~/.gerritcodereview/tmp`
-only use that location for commands like init where there is no active
-site. From gerrit.sh always use `$site_path/tmp` for the JARs to
-isolate servers that run on the same host under the same UNIX user
-account.
-
-[[custom-extension]]
-* Allow for the `CUSTOM_EXTENSION` `auth.type` to configure URLs for
-  editing the user name and obtaining an HTTP password
-+
-Allow `CUSTOM_EXTENSION` auth type to supply by `auth.editFullNameUrl`
-a URL in the web UI that links users to the other account system,
-where they can edit their name, and then use another reload URL to
-cycle through the `/login/` step and refresh the data cached by Gerrit.
-+
-Allow `CUSTOM_EXTENSION` auth type to supply by `auth.httpPasswordUrl`
-a URL in the web UI that allows users to obtain an HTTP password.
-+
-Like the rest of the `CUSTOM_EXTENSION` stuff, this is hack that will
-eventually go away when there is proper support for authentication
-plugins.
-
-=== Performance
-[[performance-issue-on-showing-group-list]]
-* Fix performance issues on showing the list of groups in the Gerrit
-  WebUI
-+
-Loading `Admin` > `Groups` on large servers was very slow. The entire
-group membership database was downloaded to the browser when showing
-just the list of groups.
-+
-Now the amount of data that needs to be downloaded to the browser is
-reduced by using the more lightweight `AccountGroup` type instead of
-the `GroupDetail` type when showing the groups in a list format. As a
-consequence the `Owners` column that showed the name of the owner group
-had been dropped.
-
-* Add LDAP-cache to minimize number of queries when unnesting groups
-+
-A new cache named "ldap_groups_byinclude" is introduced to help lessen
-the number of queries needed to resolve nested LDAP-groups.
-
-* Add index for accessing change messages by patch set
-+
-This improves the performance of loading the dashboards.
-
-* Add a fast path to avoid checking every commit on push
-+
-If a user can forge author, committer and gerrit server identity, and
-can upload merges, don't bother checking the commit history of what is
-being uploaded. This can save time on servers that are trying to accept
-a large project import using the push permission.
-
-* Improve performance of `ReceiveCommits` by reducing `RevWalk` load
-+
-JGit RevWalk does not perform well when a large number of objects are
-added to the start set by `markStart` or `markUninteresting`. Avoid
-putting existing `refs/changes/` or `refs/tags/` into the `RevWalk` and
-instead use only the `refs/heads` namespace and the name of the branch
-used in the `refs/for/` push line.
-+
-Catch existing changes by looking for their exact commit SHA-1, rather
-than complete ancestry. This should have roughly the same outcome for
-anyone pushing a new commit on top of an existing open change, but
-with lower computational cost at the server.
-
-* Lookup changes in parallel during `ReceiveCommits`
-+
-If the database has high query latency, the loop that locates existing
-changes on the destination branch given Change-Id can be slow. Start
-all of the queries as commits are discovered, but don't block on
-results until all queries were started.
-+
-If the database can build the `ResultSet` in the background, this may
-hide some of the query latency by allowing the queries to overlap when
-more than one lookup must be performed for a push.
-
-* Perform change update on multiple threads
-+
-When multiple changes need to be created or updated for a single push
-operation they are now inserted into the database by parallel threads,
-up to the maximum allowed thread count. The current thread is used
-when the thread pool is already fully in use, falling back to the
-prior behavior where each concurrent push operation can do its own
-concurrent database update. The thread pool exists to reduce latency
-so long as there are sufficient threads available.
-+
-This helps push times on databases that are high latency, such as
-database servers that are running on a different machine from the
-Gerrit server itself, e.g. gerrit.googlesource.com.
-+
-The new thread pool is
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#receive.changeUpdateThreads[
-disabled by default], limiting the overhead to servers that have good
-latency with their database, such as using in-process H2 database, or
-a MySQL or PostgreSQL on the same host.
-
-* Use `BatchRefUpdate` to execute reference changes
-+
-Some storage backends for JGit are able to update multiple references
-in a single pass efficiently. Take advantage of this by pushing
-any normal reference updates (such as direct push or branch create)
-into a single `BatchRefUpdate` object.
-
-* Assume labels are correct in ListChanges
-+
-To reduce end-user latency when displaying changes in a search result
-or user dashboard, assume the labels are accurate in the database at
-display time and don't recompute the access privileges of a reviewer.
-
-* Notify the cache that the git_tags was modified
-+
-The tag cache was updated in-place, which prevented the H2 based
-storage from writing out the updated tag information. This meant
-servers almost never had the right data stored on disk and had to
-recompute it at startup.
-+
-Anytime the value is now modified in place, put it back into the
-cache so it can be saved for use on the next startup.
-
-* Special case hiding `refs/meta/config` from Git clients
-+
-VisibleRefFilter requires a lot of server CPU to accurately provide
-the correct listing to clients when they cannot read `refs/*`.
-+
-Since the default configuration is now to link:#hide-config[
-hide `refs/meta/config`], use a special case in VisibleRefFilter that
-permits showing every reference except `refs/meta/config` if a user can
-read every other reference in the repository.
-
-* Avoid second remote call to lookup approvals when loading change
-  results
-+
-By using the new link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/rest-api-changes.html[`/changes/`]
-REST endpoint the web UI client now obtains the label information
-during the query and avoids a second round trip to lookup the current
-approvals for each displayed change. For most users this should improve
-the way the page renders. The verified and code review columns will be
-populated before the table is made visible, preventing the layout from
-"jumping" the way the old UI did when the 2nd RPC finally finished and
-supplied the label data.
-
-* Load patch set approvals in parallel
-+
-ResultSet is a future-like interface, the database system is free to
-execute each result set asynchronously in the background if it
-supports that. gwtorm's default SQL backend always runs queries
-immediately and then returns a ListResultSet, so for most installs this
-has no real impact in ordering.
-+
-For the system that runs gerrit-review, each query has a high cost in
-network latency, the system treats ResultSet as a future promise to
-supply the matching rows. Getting all of the necessary ResultSets up
-front allows the database to send all requests to the backend as early
-as possible, allowing the network latency to overlap.
-
-== Upgrades
-* Update Gson to 2.1
-* Update GWT to 2.4.0
-* Update JGit to 2.0.0.201206130900-r.23-gb3dbf19
-
-* Use gwtexpui 1.2.6
-+
-** Hide superfluous status text from clippy flash widget
-** Fix disappearance of text in CopyableLabel when clicking on it
-
-* Update Guava to 12.0.1
-+
-This fixes a performance problem with LoadingCache where the cache's
-inner table did not dynamically resize to handle a larger number
-of cached items, causing O(N) lookup performance for most objects.
-
-== Bug Fixes
-
-=== Security
-* Ensure that only administrators can change the global capabilities
-+
-Only Gerrit server administrators (members of the groups that have
-the `administrateServer` capability) should be able to edit the
-global capabilities because being able to edit the global capabilities
-means being able to assign the `administrateServer` capability.
-+
-Because of this on the `All-Projects` project it is disallowed to assign
-+
-. the `owner` access rights on `refs/*`
-+
-Project owners (members of groups to which the `owner` access right
-is assigned) are able to edit the access control list of the projects
-they own. Hence being owner of the `All-Projects` project would allow
-to edit the global capabilities and assign the `administrateServer`
-capability without being Gerrit administrator.
-+
-In earlier Gerrit versions (2.1.x) it was already implemented like
-this but the corresponding checks got lost.
-+
-. the 'push' access right on `refs/meta/config`
-+
-Being able to push configuration changes to the `All-Projects` project
-allows to edit the global capabilities and hence a user with this
-access right could assign the `administrateServer` capability without
-being Gerrit administrator.
-+
-From the Gerrit WebUI (ProjectAccessScreen) it is not possible anymore
-to assign on the `All-Projects` project the `owner` access right on
-`refs/*` and the `push` access right on `refs/meta/config`.
-+
-In addition it is ensured that an `owner` access right that is assigned
-for `refs/*` on the `All-Projects` project has no effect and that only
-Gerrit administrators with the `push` access right can push
-configuration changes to the `All-Projects` project.
-+
-It is still possible to assign both access rights (`owner` on `refs/*`
-and `push` on `refs/meta/config`) on the `All-Projects` project by directly
-editing its `project.config` file and pushing to `refs/meta/config`.
-To fix this it would be needed to reject assigning these access rights
-on the `All-Projects` project as invalid configuration, however doing this
-would mean to break existing configurations of the `All-Projects` project
-that assign these access rights. At the moment there is no migration
-framework in place that would allow to migrate `project.config` files.
-Hence this check is currently not done and these access rights in this
-case have simply no effect.
-
-=== Web
-
-* Do not show "Session cookie not available" on sign in
-+
-When LDAP is used for authentication, clicking on the 'Sign In' link
-opens a user/password dialog. In this dialog the "Session cookie not
-available." message was always shown as warning. This warning was
-pretty useless since the user was about to sign in because he had no
-current session.
-+
-This problem was discussed on the
-link:https://groups.google.com/forum/#!topic/repo-discuss/j-t77m8-7I0/discussion[
-Gerrit mailing list].
-
-* Reject restoring a change if its destination branch does not exist
-  anymore
-
-* Reject submitting a change if its destination branch does not exist
-  anymore
-+
-If a branch got deleted and there was an open change for this branch,
-it was still possible to submit this open change. As result the
-destination branch was implicitly recreated, even if the user
-submitting the change had no privileges to create branches.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1352[issue 1352]:
-  Don't display "Download" link for `/COMMIT_MSG`
-+
-The commit message file is special, it doesn't actually exist and
-cannot be downloaded. Don't offer the download link in the side by
-side viewer.
-
-* Dependencies were lost in the ChangeScreen's "Needed By" table
-+
-Older patchsets are now iterated for descendants, so that the dependency
-chain does not break on new upstream patchsets.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1442[issue 1442]:
-  Only show draft change dependency if current user is owner or reviewer
-+
-In the change screen, the dependencies panel was showing draft changes
-in the "Depends On" and "Needed By" lists for all users, and when there
-was no user logged in.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1558[issue 1558]:
-  Create a draft patch set when a draft patch set is rebased
-+
-Rebasing a draft patch set created a non-draft patch set. It was
-unexpected that rebasing a draft patch set published the modifications
-done in the draft patch set.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1176[issue 1176]:
-  Fix disappearance of download command in Firefox
-+
-Clicking on the download command for a patch set in Firefox made the
-download command disappear.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1587[issue 1587]:
-  Fix disappearance of action buttons when selecting the last patch set
-  as `Old Version History`
-
-* Fix updating patch list when `Old Version History` is changed
-+
-If a collapsed patch set panel was expanded and re-closed it's patch
-list wasn't updated anymore when the selection for `Old Version History`
-was changed.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1523[issue 1523]:
-  Update diff base to match old version history
-+
-When changing the diff base in the `Old Version History` on the change
-screen and then entering the Side-By-Side view for a file, clicking on
-the back button in the browser (reentering the change screen) was
-causing the files to be wrongly compared with `Base` again.
-
-* Don't NPE if current patch set is not available
-+
-Broken changes may have the current patch set field incorrectly
-specified, causing currentPatchSet to be unable to locate the
-correct data and return it. When this happens don't NPE, just
-claim the change is not reviewed.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1555[issue 1555]:
-  Fix displaying of file diff if draft patch has been deleted
-+
-Displaying any file diff for a patch set failed if the change had any
-gaps in its patch set history. Patch sets can be missing, if they
-have been drafts and were deleted.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=856[issue 856]:
-  Fix displaying of comments on deleted files
-+
-Published and draft comments that are posted on deleted files were not
-loaded and displayed.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=735[issue 735]:
-  Fix `ArrayIndexOutOfBoundsException` on navigation to next/previous
-  patch
-+
-An `ArrayIndexOutOfBoundsException` could occur when navigating from
-one patch to the next/previous patch if the next/previous patch was a
-newly added binary file. The exception occurred if the user was not
-signed in or if the user was signed in and had `Syntax Coloring` in the
-preferences enabled.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=816[issue 816]:
-  Fix wrong file indention in Side-by-Sie diff viewer on right side
-
-* Only set reviewed attribute on open changes
-+
-If a change is merged or abandoned, do not consider the reviewed
-property for the calling user, so that the change is not highlighted
-as unreviewed on the user's dashboard.
-
-* Change PatchTable pointer when loading patch
-+
-This patch fixes an issue with the "file list" table displayed by
-clicking on the "Files" sub-menu when viewing a diff.
-+
-Originally when navigating between patch screens the highlighted row
-(pointer) of the file list table would not change when not directly
-interacting with the table e.g. by clicking on the previous or next
-file link.
-+
-This patch updates the file list table whenever a new patch screen is loaded
-so that the pointer corresponds to the current patch being displayed.
-
-* Don't hyperlink non-internal groups
-+
-When an external group (such as LDAP) is used in a permission rule,
-don't attempt to link to the group in the internal account system UI.
-The group won't load successfully. Instead just display the name and
-put the UUID into a tooltip to show the full DN.
-
-* Fix: Popup jumps back to original position when resizing screen
-+
-On 'Watched Projects' screen, the 'Browse' button displays a popup
-window. If the user moves it and then resizes the screen, it won't snap
-back to the original position.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1457[issue 1457]:
-  Prevent groups from being renamed to empty string
-
-* Fixed AccountGroupInfoScreen search callback
-+
-If the search returned no results, the search button would not be
-enabled and the status panel was not shown. Fixed the panel and button
-to always be enabled.
-
-* Fix NullPointerException on `/p/`
-+
-Requesting just `/p/` caused a NullPointerException as the redirection
-logic had no project name to form a URL from. Detect requests for `/p/`
-and redirect to 'Admin' > 'Projects' to show the projects the caller
-has access to.
-
-=== Mail
-
-* Fix: Rebase did not mail all reviewers
-
-* Fix email showing in AccountLink instead of names
-+
-Prefer the full name for the display text of the link.
-
-* Fix signature delimiter for e-mail messages
-+
-Make sure the signature delimiter is "-- " (two dashes and a space).
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1397[issue 1397]:
-  Don't wait for banner message from SMTP server after STARTTLS
-  negotiation
-+
-According to RFC 2847 section 5.2, SMTP server won't send the banner
-message again after STARTTLS negotiation. The original code will hang
-until SMTP server kicks it off due to timeout and can't send email with
-STARTTLS enabled, aka. `sendemail.smtpEncryption = tls`.
-
-* Extract all mail templates during site init
-+
-The example mail templates `RebasedPatchSet.vm`, `Restored.vm` and
-`Reverted.vm` were not extracted during the initialization of a new
-site.
-
-=== SSH
-* Fix reject message if bypassing code review is not allowed
-+
-If a user is not allowed to bypass code review, but tries to push a
-commit directly, Gerrit rejected this push with the error message
-"can not update the reference as a fast forward". This message was
-confusing to the user since the push only failed due to missing
-access rights. Go back to the old message that says "prohibited
-by Gerrit".
-
-* Fix reject message if pushing tag is rejected because tagger is
-  somebody else
-+
-Pushing a tag that has somebody else as tagger requires the `Forge
-Committer` access right. If this access right was missing Gerrit
-was rejecting the push with "can not create new references". This error
-message was misleading because the user may have thought that the
-`Create Reference` access right was missing which was actually assigned.
-+
-The same reject message was also returned on push of an annotated tag
-if the `Push Annotated Tag` access right was missing. Also in this case
-the error message was not ideal.
-+
-Go back to the old more generic message which says `prohibited by
-Gerrit`.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1437[issue 1437]:
-  Send event to stream when draft change is published
-+
-When a change is uploaded as a draft, a `patchset-created` event is
-sent to the event stream, but since drafts are private to the owner,
-the event is not publicly visible.  When the draft is later published,
-no publicly visible event was sent. As result of this external tools
-that rely on the event stream to detect new changes didn't receive
-events for any changes that were first uploaded as draft.
-+
-There is now a new event, `draft-published`, which is sent to the
-event stream when a draft change is published.  The content of this
-event is the same as `patchset-created`.
-
-* Fix: Wrong ps/rev in `change-merged` stream-event
-+
-When using cherry-pick as merge strategy, the wrong ref was set in the
-`change-merged` stream-event.
-+
-The issue stems from Gerrit would not acknowledge the resulting new
-pachset (the actual cherry-pick).
-
-* Fix NullPointerException in `query` SSH command
-+
-Running the `query` SSH command with the options `--comments` and
-`--format=JSON` failed with a NullPointerException if a change had a
-message without author. Change messages have no author if they were
-created by Gerrit. For such messages now the Gerrit Server identity is
-returned as author.
-
-* Fix the `export-review-notes` command's Guice bindings
-+
-The `export-review-notes` command was broken because of the CachePool
-class being bound twice. The startup of the command failed because of
-that.
-
-* Fix sorting of SSH help text
-+
-Commands were displaying in random order, sort commands before output.
-
-* `replicate` command: Do not log errors for wrong user input
-+
-If the user provided an invalid combination of command options or an
-non existing project name this was logged in the `error.log` but
-printing the error out to the user is sufficient.
-
-=== Authentication
-
-* Fix NPE in LdapRealm caused by non-LDAP users
-+
-Servers that are connected to LDAP but have non-LDAP user accounts
-created by `gerrit create-account` (e.g. batch role accounts for
-build systems) were crashing with a NullPointerException when the
-LdapRealm tried to discover which LDAP groups the non-LDAP user
-was a member of in the directory.
-
-* Fix domain field of HTTP digest authentication
-+
-Per RFC 2617 the domain field is optional. If it is not present,
-the digest token is valid on any URL on the server. When set it
-must be a path prefix describing the URLs that the password would
-be valid against.
-+
-When a canonical URL is known, supply that as the only domain that
-is valid. When the URL is missing (e.g. because the provider is
-still broken) rely on the context path of the application instead.
-
-=== Replication
-
-* Fix inconsistent behavior when replicating `refs/meta/config`
-+
-In `replication.config`, if `authGroup` is set to be used together with
-`mirror = true`, refs blocked through the `authGroup` are deleted from
-the slave/mirror. The same correctly applies if the `authGroup` is used
-to block `refs/meta/config`.
-+
-However, if `replicatePermission` was set to `false`, Gerrit was
-refusing to clean up `refs/meta/config` on the slave/mirror.
-
-* Fix bug with member assignment order in PushReplication.
-+
-The groupCache was being used before it was set in the class. Fix the
-ordering of the assignment.
-
-=== Approval Categories
-
-* Make `NoBlock` and `NoOp` approval category functions work
-+
-The approval category functions `NoBlock` and `NoOp` have not worked
-since the integration of Prolog.
-+
-`MAY` was introduced as a new submit record status to complement `OK`,
-`REJECT`, `NEED`, and `IMPOSSIBLE`. This allows the expression of
-approval categories (labels) that are optional, i.e. could either be
-set or unset without ever influencing whether the change could be
-submitted. Previously there was no way to express this property in
-the submit record.
-+
-This enables the `NoBlock` and `NoOp` approval category functions to
-work as they now emit may() terms from the Prolog rules. Previously
-they returned ok() terms lacking a nested user term, leading to
-exceptions in code that expected a user context if the label was `OK`.
-
-* Fix category block status without negative score
-+
-Categories without blocking or approval scores will result in the
-blocking/approved image appearing in the category column after changes
-are merged should the score by the reviewer match the minimum or
-maximum value respectively.
-+
-A check to ignore "No Score" values of 0 was added.
-
-* Don't remove dashes from approval category name
-+
-If an approval category name contained a dash, it was removed by
-Gerrit. On the other side a space in an approval category name is
-converted to a dash. This was confusing for writing Prolog submit
-rules. If, for example, one defined a new category named `X-Y`, then in
-the Prolog code the proper name for that category would have been `XY`
-which was unintuitive.
-
-* Fix NPE in `PRED__load_commit_labels_1`
-+
-If a change query uses reviewer information and loads the approvals
-map, but there are no approvals for a given patch set available, the
-collection came out null, which cannot be iterated. Make it always be
-an empty list.
-
-=== Other
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1554[issue 1554]:
-  Fix cloning of new projects from slave servers
-+
-If a new project is created in Gerrit the replication creates the
-repository for this new project directly in the filesystem of the slave
-server. The slave server was not discovering this new repository and as
-result any attempt to clone the corresponding project from the slave
-server failed.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1548[issue 1548]:
-  Create a ref for the patch set that is created when a change is
-  cherry-picked and trigger the replication for it:
-+
-If Cherry Pick is chosen as submit type, on submit a new commit is
-created by the cherry-pick. For this commit a new patch set is created
-which is added to the change. Using any of the download commands to
-fetch this new patch set failed with 'Couldn't find remote ref' because
-no ref for the new patch set was created.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1626[issue 1626]:
-  Fix NullPointerException on cherry-pick if `changeMerge.test` is enabled
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1491[issue 1491]:
-  Fix nested submodule updates
-
-* Set link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#transfer.timeout[transfer
-  timeout] for pushes through HTTP
-+
-The transfer timeout was only set when pushing via SSH.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/config-gerrit.html#receive.maxObjectSizeLimit[
-  Limit maximum Git object size] when pushing through HTTP
-+
-The limit for the maximum object size was only set when pushing via SSH.
-
-* Fix units of `httpd.maxwait`
-+
-The default unit here is minutes, but Jetty wants to get milliseconds
-from the maxWait field. Convert the minutes returned by getTimeUnit to
-be milliseconds, matching what Jetty expects.
-+
-This should resolve a large number of 503 errors for Git over HTTP.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1493[issue 1493]:
-  Fix wrong "change ... closed" message on direct push
-+
-Pushing a commit directly into the central repository with bypassing
-code review wrongly resulted in a "change ... closed" message if the
-commit was already pushed for review and if a Change-Id was included in
-the commit message. Despite of the error message the push succeeded and
-the corresponding change got closed. Now the message is not printed
-anymore.
-
-* Fix NPE that can hide guice CreationException on site init
-+
-Note that the `--show-stack-trace` option is needed to print the stack
-trace when a program stops with a Die exception.
-
-* Do not automatically add author/committer as reviewer to drafts
-
-* Do not automatically add reviewers from footer lines to drafts
-
-* Fix NullPointerException in MergeOp
-+
-The body of the commit object may have been discarded earlier to
-save memory, so ensure it exists before asking for the author.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1396[issue 1396]:
-  Initialize the submodule commit message buffer
-
-* Fix file name matching in `commit_delta` to perform substring
-  matching
-+
-The `commit_delta` predicate was matching the entire file name against
-the given regular expression while other predicates (`commit_edits`,
-`commit_message_matches`) performed substring matching. It was
-inconsistent that for `commit_delta` it was needed to write something
-like:
-+
-----
-  commit_delta('.*\.java')
-----
-+
-to match all `*.java` files, while for `commit_edits` it was:
-+
-----
-  commit_edits('\.java$', '...')
-----
-+
-to match the same set of (Java) files.
-
-* Create index for submodule subscriptions on site upgrade
-
-* Fix URL to Jetty XML DTDs so they can be properly validated
-
-* Fix resource leak when `changeMerge.test` is `true`
-
-* Fix possible synchronization issue in TaskThunk
-
-* Fix possible NPEs in `ReplaceRequest.cmd` usage in `ReceiveCommits`
-+
-The `cmd` field is populated by `validate(boolean)`. If this method
-fails, results on some `ReplaceRequests` may not be set. Guard the
-attempt to access the field with a null check.
-
-* Match no labels if current patch set is not available
-+
-If the current patch set cannot be loaded from `ChangeData`, assume no
-label information. This works around an NullPointerException inside of
-`ChangeControl` where the `PatchSet` is otherwise required.
-
-* Create new patch set references before database records
-+
-Ensure the commit used by a new change or replacement patch set
-always exists in the Git repository by writing the reference first
-as part of the overall `BatchRefUpdate`, then inserting the database
-records if all of the references stored successfully.
-
-* Fix rebase patch set and revert change to update Git first
-+
-Update the Git reference before writing to the database. This way the
-repository cannot be corrupted if the server goes down between the two
-actions.
-
-* Make sure we use only one type of NoteMerger for review notes creation
-
-* Fix generation of owner group in GroupDetail
-+
-Set the GroupDetail.ownerGroup to the AccountGroup.ownerGroupUUID
-instead of the groupUUID.
-
-* Ensure that ObjectOutputStream in H2CacheImpl is closed
-
-* Ensure that RevWalk in SubmoduleOp is released
diff --git a/ReleaseNotes/ReleaseNotes-2.6.1.txt b/ReleaseNotes/ReleaseNotes-2.6.1.txt
deleted file mode 100644
index 94de483..0000000
--- a/ReleaseNotes/ReleaseNotes-2.6.1.txt
+++ /dev/null
@@ -1,14 +0,0 @@
-= Release notes for Gerrit 2.6.1
-
-There are no schema changes from link:ReleaseNotes-2.6.html[2.6].
-
-link:https://www.gerritcodereview.com/download/gerrit-2.6.1.war[https://www.gerritcodereview.com/download/gerrit-2.6.1.war]
-
-== Bug Fixes
-* Patch JGit security hole
-+
-The security hole may permit a modified Git client to gain access
-to hidden or deleted branches if the user has read permission on
-at least one branch in the repository. Access requires knowing a
-SHA-1 to request, which may be discovered out-of-band from an issue
-tracker or gitweb instance.
diff --git a/ReleaseNotes/ReleaseNotes-2.6.txt b/ReleaseNotes/ReleaseNotes-2.6.txt
deleted file mode 100644
index 26b0b0e..0000000
--- a/ReleaseNotes/ReleaseNotes-2.6.txt
+++ /dev/null
@@ -1,1738 +0,0 @@
-= Release notes for Gerrit 2.6
-
-Gerrit 2.6 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.6.war[https://www.gerritcodereview.com/download/gerrit-2.6.war]
-
-Gerrit 2.6 includes the bug fixes done with
-link:ReleaseNotes-2.5.1.html[Gerrit 2.5.1],
-link:ReleaseNotes-2.5.2.html[Gerrit 2.5.2],
-link:ReleaseNotes-2.5.3.html[Gerrit 2.5.3], and
-link:ReleaseNotes-2.5.4.html[Gerrit 2.5.4]. These bug fixes are *not*
-listed in these release notes.
-
-== Schema Change
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* Upgrading to 2.6.x requires the server be first upgraded to 2.1.7 (or
-a later 2.1.x version), and then to 2.6.x.  If you are upgrading from 2.2.x.x or
-newer, you may ignore this warning and upgrade directly to 2.6.x.
-
-== Reverse Proxy Configuration Changes
-
-If you are running a reverse proxy in front of Gerrit (e.g. Apache or Nginx),
-make sure to check your configuration, especially if you are encountering
-'Page Not Found' errors when opening the change screen.
-See the link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-reverseproxy.html[
-Reverse Proxy Configuration] for details.
-
-Gerrit now requires passed URLs to be unchanged by the proxy.
-
-== Release Highlights
-* 42x improvement on `git clone` and `git fetch`
-+
-Running link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/cmd-gc.html[
-gerrit gc] allows JGit to optimize a repository to serve clone and fetch
-faster than C Git can, with massively lower server CPU required. Typically
-Gerrit 2.6 can completely transfer a project to a client faster than C Git
-can finish "Counting" the objects.
-
-* Completely customizable workflow
-+
-Individual projects can add (or remove) score categories through
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-labels.html[
-labels] and link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/prolog-cookbook.html[
-Prolog rules].
-
-== New Features
-
-=== Web UI
-
-==== Global
-
-* New Login Screens
-+
-New form based HTML screens for login allow browsers to offer the
-choice to save the login data locally in the user's password store.
-
-* Rename "Groups" top-level menu to "People"
-
-* Move "Draft Comments" link next to "Drafts" link
-
-* Highlight the active menu item
-
-* Move user info, settings, and logout to popup dialog
-
-* Show a small version of the avatar image next to the user's name.
-
-* Show avatar image in user info popup dialog
-
-* Always show 'Working ...' message
-+
-The 'Working ...' message is relatively positioned from the top of
-the browser, so that the message is always visible, even if the user
-has scrolled down the page.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-gerrit.html#suggest.from[
-  suggest.from] configures a minimum number of characters before
-  matches for reviewers, accounts, groups or projects are offered.
-
-* Make the default font size "small".
-
-* Mark all CSS classes as external so users can rely on them.
-
-* Add a link to the REST API documentation in the top menu.
-
-==== Search
-* Suggest projects, groups and users in search panel
-+
-Suggest projects, groups and users in the search panel as parameter for
-those search operators that expect a project, group or user.
-
-* In search panel suggest 'self' as value for operators that expect a user
-
-* Quote values suggested for search operators only if needed
-+
-The values that are suggested for the search operators in the search
-panel are now only quoted if they contain a whitespace.
-
-==== Change Screens
-
-* A change's commit message can be edited from the change screen.
-
-* A change's topic can be added, removed or changed from the
-  change screen.
-
-* An "Add Comment" button is added to change screen
-
-* The reviewer matrix on a change displays gray boxes where permissions
-  do not allow voting in that category.
-+
-The coloring enables authors to quickly identify if another reviewer
-is necessary to continue the change.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=353[Issue 353] &
-  link:https://code.google.com/p/gerrit/issues/detail?id=1123[Issue 1123]:
-  New link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/project-setup.html#rebase_if_necessary[
-  Rebase If Necessary] submit type
-+
-This is similar to cherry pick, but honors change dependency
-information.
-
-* The rebase button is hidden when the patch set is current.
-
-* Improved review message when a change is rebased in the UI
-+
-When a change is rebased in the UI by pressing the rebase button, a
-comment is added onto the review. Instead of only saying 'Rebased' the
-message is now more verbose, e.g. 'Patch Set 1 was rebased'.
-
-* The submit type that is used for submitting a change is shown on the
-  change screen in the info block.
-+
-This is useful because the submit type of a change can now be
-link:#submit-type-from-prolog[controlled by Prolog].
-
-* Replace the All Diff buttons on the change screen with links
-+
-The action buttons to open the diff for all files in own tabs consumed
-too much space due to the long label texts.
-
-* The patch set review screen can include radio buttons for custom
-  labels if enabled by
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/prolog-cookbook.html#_how_to_write_submit_rules[submit rules].
-
-* Voting on draft changes is now possible.
-
-* Recommend rebase on Path Conflict.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1685[Issue 1685]:
-  After 'Up to change' expand the patch set that was just reviewed
-+
-After clicking on the 'Up to change' link on a patch screen, the patch
-set that was just reviewed is automatically expanded on the change
-screen.
-
-* Allow direct change URLs to end with '/'.
-
-* Slightly increase commit message text size from 8px to 9px.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1381[Issue 1381]:
-  Remove the ID column from change tables
-+
-Users don't really need the ID column present. For most changes the
-subject is descriptive and unique enough to identify the correct
-change.
-
-* Do not wrap project/branch/owner fields in change table.
-+
-This makes it easier to use Gerrit on narrow screens.
-
-* Rename "Old Version History" to "Reference Version".
-
-==== Patch Screens
-
-* Support for file comments
-+
-It is now possible to comment on a whole file in a patch.
-
-* Have the reviewed panel also at the bottom of the patch screen
-+
-Reviewers normally review patches top down, finishing the review when
-they reach the bottom of the patch. To use the streamlined review
-workflow they now don't need to scroll back to the top to find the
-reviewed checkbox and link.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1494[Issue 1494]:
-  Use mono-font for displaying the file contents
-+
-This avoids alignment errors when syntax highlighting is enabled.
-
-* Distinguish between error and timeout in intraline diff error message.
-
-* Enable expanding skipped lines even if 'Syntax Coloring' is off.
-
-==== Project Screens
-
-* Support filtering of projects in the project list screen
-+
-Filter matches are highlighted by bold printing.
-+
-The filter is reflected by the `filter` URL parameter.
-
-* Support filtering of projects in ProjectListPopup
-+
-Filter matches are highlighted by bold printing.
-
-* Display a query icon for each project in the project list screen that
-  links to the default query/dashboard of that project.
-
-* Replace projects side menus with top menus
-+
-The top menus are submenus to the Project Menu and they appear only
-when a project has been selected.
-
-* Remember the last Project Screen used
-+
-Remember the last project screen used every time a project screen is
-loaded. Go to the remembered screen when selecting a new project from
-the project list instead of always going to the project info screen.
-
-* Remember the last project viewed
-+
-Remember the last project viewed when navigating away from a project
-screen.  If there is a remembered project, then the extra project links
-are not hidden.
-
-* Add clone panel to the project general screen
-
-* New screen for listing and accessing the project dashboards.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1677[Issue 1677]:
-  Place the 'Browse' button to select a watched project next to input field
-
-* Ask user to login if project is not found
-+
-Accessing a project URL was failing with 'Not Found - The page you
-requested was not found, or you do not have permission to view this
-page' if the user was not signed in and the project was not visible to
-'Anonymous Users'. Instead Gerrit now asks the user to login and
-afterwards shows the project to the user if it exists and is visible.
-If the project doesn't exist or is not visible, the user will still get
-the Not Found screen after sign in.
-
-* Improve error handling on branch creation
-+
-Improve the error messages that are displayed in the WebUI if the
-creation of a branch fails due to invalid user input.
-
-==== Group Screens
-
-* Support filtering of groups in the group list screen
-+
-Filter matches are highlighted by bold printing.
-+
-The filter is reflected by the `filter` URL parameter.
-
-* Remove group type from group info screen
-+
-The information about the group type was not much helpful. All groups
-that can be seen in Gerrit are of type 'INTERNAL', except a few
-well-known system groups which are of type 'SYSTEM'. The system groups
-are so well-known that there is no need to display the type for them.
-
-==== Dashboard Screens
-
-* Link dashboard title to a URL version of itself
-+
-When using a stable project dashboard URL, the URL obfuscates the
-content of the dashboard which can make it hard to debug a dashboard or
-copy and modify it. In the special case of stable dashboards, make the
-title a link to an unstable URL version of the dashboard with the URL
-reflecting the actual dashboard contents the way a custom dashboard
-does.
-
-* Increase time span for "Recently Closed" section in user dashboard to 4 weeks.
-
-==== Account Screens
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1740[Issue 1740]:
-  Display description how to generate SSH Key in SshPanel
-+
-Display a description of how to generate an SSH Key in an expandable
-section in the SshPanel instead of linking to the GitHub SSH tutorial.
-The GitHub SSH tutorial was partially not relevant and confused users.
-
-* Make the text for "Register" customizable
-
-==== Plugin Screens
-
-* Show status for enabled plugins in the WebUI as 'Enabled'
-+
-Earlier no status was shown for enabled plugins, which was confusing to
-some users.
-
-=== REST API
-
-* A big chunk of the Gerrit functionality is now available via the
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/rest-api.html[REST API].
-+
-The REST API is *NOT* complete yet and some functionality is still missing.
-+
-To find out which functionality is available, check the REST endpoint documentation for
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/rest-api-projects.html[projects],
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/rest-api-changes.html[changes],
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/rest-api-groups.html[groups] and
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/rest-api-accounts.html[accounts].
-
-* Support setting `HEAD` of a project
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/rest-api-projects.html#set-head[via REST].
-
-* Audit support for REST API.
-+
-Allow generating Audit events related to REST API execution. The
-structure of the AuditEvent has been extended to support the new
-name-multivalue pairs used in the REST API.
-+
-This is breaking compatibility with the 2.5 API as it changes the
-params data type, this is needed anyway as the previous list of
-Objects was not providing all the necessary information of
-"what relates to what" in terms of parameters info.
-+
-Existing support for SSH and JSON-RPC events have been adapted in
-order to fit into the new name-multivalue syntax: this allow a
-generic audit plug-in to capture all parameters regardless of where
-they have been generated.
-
-* Remove support for deprecated `--format` option when listing changes
-+
-Querying changes via REST is now always producing JSON output.
-
-* Introduce `id` property on REST entities
-+
-The `/changes/` entities now use `id` to include a triplet of the
-project, branch and change-id string to uniquely identify that change
-on the server. This moves the old `id` field to be named `change_id`,
-which is a breaking change.
-
-* Accept common forms of malformed JSON
-+
-Some clients may send JSON-ish instead of JSON. Be nice to those
-clients and accept various useful forms of incorrect syntax:
-+
-** End of line comments starting with `//` or `#` and ending with a
-   newline character.
-** C-style comments starting with `/*` and ending with `*/`
-   Such comments may not be nested.
-** Names that are unquoted or single quoted.
-** Strings that are unquoted or single quoted.
-** Array elements separated by `;` instead of `,`.
-** Unnecessary array separators. These are interpreted as if null was
-   the omitted value.
-** Names and values separated by `=` or `=>` instead of `:`.
-** Name/value pairs separated by `;` instead of `,`.
-
-* Be more liberal about parsing JSON responses
-+
-If the response begins with the JSON magic string, remove it before
-parsing. If a response is missing this leading string, parse the
-response as-is.
-
-* Accept simple form encoded data for REST APIs
-+
-Simple cases like `/review` or `/abandon` can now accept standard form
-values for basic properties, making it simple for tools to directly
-post data:
-+
-----
-  curl -n --digest \
-  --data 'message=Does not compile.' \
-  --data labels.Verified=-1 \
-  http://localhost:8080/a/changes/3/revisions/1/review
-----
-+
-Form field names are JSON field names in the top level object.  If dot
-appears in the name the part to the left is taken as the JSON field
-name and the part to the right as the key for a Map. This nicely fits
-with the labels structure used by `/review`, but doesn't support the
-much more complex inline comment case. Clients that need to use more
-complex fields must use JSON formatting for the request body.
-
-* Allow administrators to see other user capabilities
-+
-Expand `/accounts/{id}/capabilities` to permit an administrator
-to inspect another user's effective capabilities.
-
-* Declare kind in JSON API results
-+
-This is recommended to hint to clients what the entity type is when
-processing the JSON payload.
-
-* Format h/help output as plain text not JSON
-+
-The output produced when the client requested the h or help property
-from a JSON API is always produced from constant compiled into the
-server. Assume this safe to return to the client as text/plain content
-and avoid wrapping it into an HTML escaped JSON string.
-
-* Use string for JSON encoded plain text replies
-+
-Instead of wrapping the value into an object, just return the
-string by itself. This better matches what happens with the plain
-text return format.
-
-* Wrap possible HTML plain text in JSON on GET
-+
-If the HTML appears like MSIE might guess it is HTML (such as if it
-contains `<`) encode the response as a JSON object instead of as a
-simple plain text string. This won't show up very often for clients,
-and protects MSIE users stuck on ancient versions (pre MSIE 8).
-
-* Ask MSIE to never sniff content types on REST API responses
-+
-Newer versions of MSIE can disable the content sniffing feature if the
-server asks it to by setting an extension header. It is annoying, but
-necessary, that a server needs to say "No really, I _am_ telling you
-the right Content-Type, trust it."
-+
-This feature was added in MSIE 8 Beta 2 so it doesn't protect users
-running MSIE 6 or 7, but those are ancient and users should upgrade.
-+
-Enable this on the REST API responses because we sometimes send back
-text/plain results that are really just plain text. Existing JSON
-responses are protected from accidental sniffing and treatment as
-HTML thanks to Gson encoding HTML control characters using Unicode
-character escapes within JSON strings.
-
-=== Project Dashboards
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-dashboards.html#project-dashboards[
-  Support for storing custom dashboards for projects]
-+
-Custom dashboards can now be stored in the projects
-`refs/meta/dashboards/*` branches.
-+
-The project dashboards are shown in a new project screen and can be
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/rest-api-projects.html#dashboard-endpoints[
-accessed via REST].
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-dashboards.html#project-default-dashboard[
-  Allow defining a default dashboard for projects]
-
-* Support inheritance for project dashboards.
-+
-In dashboards queries the `${project}` token can be used as placeholder
-for the project name. This token will be replaced with the project to
-which a dashboard is being applied.
-
-* On the project list screen a query icon is displayed for each project
-  that links to the default dashboard of that project.
-
-* Support a `foreach` parameter for custom dashboards.
-+
-The `foreach` parameter which will get appended to all the queries in
-the dashboard.
-
-=== Access Controls
-* Allow to overrule `BLOCK` permissions on the same project
-+
-It was impossible to block a permission for a group and allow the same
-permission for a sub-group of that group as the `BLOCK` permission
-always won over any `ALLOW` permission. For example, it was impossible
-to block the "Forge Committer" permission for all users and then allow
-it only for a couple of privileged users.
-+
-An `ALLOW` permission has now  priority over a `BLOCK` permission when
-they are defined in the same access section of a project. To achieve the
-above mentioned policy the following could be defined:
-+
-  [access "refs/heads/*"]
-    forgeCommitter = block group Anonymous Users
-    forgeCommitter = group Privileged Users
-+
-Across projects the `BLOCK` permission still wins over any `ALLOW`
-permission. This way one cannot override an inherited `BLOCK`
-permission in a subproject.
-+
-Overruling of `BLOCK` permissions with `ALLOW` permissions also works
-for labels i.e. permission ranges. If a dedicated 'Verifiers' group
-need to be the only group who can vote in the 'Verified' label and it
-must be ensured that even project owners cannot change this policy,
-then the following can be defined in a common parent project:
-+
-  [access "refs/heads/*"]
-    label-Verified = block -1..+1 group Anonymous Users
-    label-Verified = -1..+1 group Verifiers
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1516[issue 1516]:
-  Show global capabilities to all users that can read `refs/meta/config`
-+
-Users can now propose changes to the global capabilities for review
-from the WebUI.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/access-control.html#category_remove_reviewer[
-  Remove Reviewer] is a new permission.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/access-control.html#category_push_signed[
-  Pushing a signed tag] is a new permission.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/access-control.html#category_edit_topic_name[
-  Editing the topic name] is a new permission.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/access-control.html#capability_accessDatabase[
-  Raw database access] with the `gsql` command is a new global capability.
-+
-Previously site administrators had this capability by default.  Now it has
-to be explicitly assigned, even for site administrators.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1585[Issue 1585]:
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/access-control.html#category_view_drafts[
-  Viewing other users' draft changes] is a new permission.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1675[Issue 1675]:
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/access-control.html#category_delete_drafts[Deleting] and
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/access-control.html#category_publish_drafts[publishing]
-  other users' draft changes is a new permission.
-
-* Grant most permissions when creating `All-Projects`
-+
-Make Gerrit more like a Git server out-of-the box by granting both
-Administrators and Project Owners permissions to review changes, submit
-them, create branches, create tags, and push directly to branches.
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-gerrit.html#ldap.groupName[
-  LDAP group names] are configurable, `cn` is still the default.
-
-* Kerberos authentication to LDAP servers is now supported.
-
-* Basic project properties are now inherited by default from parent
-  projects: Use Content Merge, Require Contributor Agreement, Require
-  Change Id, Require Signed Off By.
-
-* Allow assigning `Push` for `refs/meta/config` on `All-Projects`
-+
-The `refs/meta/config` branch of the `All-Projects` project should only
-be modified by Gerrit administrators because being able to do
-modifications on this branch means that the user could assign himself
-administrator permissions.
-+
-In addition to being administrator Gerrit requires that the
-administrator has the `Push` access right for `refs/meta/config` in
-order to be able to modify it (just as with all other branches
-administrators do not have edit permissions by default).
-+
-The problem was that assigning the `Push` access right for
-`refs/meta/config` on the `All-Projects` project was not allowed.
-+
-Having the `Push` access right for `refs/meta/config` on the
-`All-Projects` project without being administrator has no effect.
-
-=== Hooks
-* Change topic is passed to hooks as `--topic NAME`.
-* link:https://code.google.com/p/gerrit/issues/detail?id=1200[Issue 1200]:
-New `reviewer-added` hook and stream event when a reviewer is added.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1237[Issue 1237]:
-New `merge-failed` hook and stream event when a change cannot be submitted due to failed merge.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=925[Issue 925]:
-New `ref-update` hook run before a push is accepted by Gerrit.
-
-* Add `--is-draft` parameter to `comment-added` hook
-
-=== Git
-* Add options to `refs/for/` magic branch syntax
-+
-Git doesn't want to modify the network protocol to support passing
-data from the git push client to the server. Work around this by
-embedding option data into a new style of reference specification:
-+
-----
-  refs/for/master%r=alice,cc=bob,cc=charlie,topic=options
-----
-+
-is now parsed by the server as:
-+
---
-** set topic to "options"
-** CC charlie and bob
-** add reviewer alice
-** for branch refs/heads/master
---
-+
-If `%` is used the extra information after the branch name is
-parsed as options with args4j. Each option is delimited by `,`.
-+
-Selecting publish vs. draft should be done with the options `draft` or
-`publish`, appearing anywhere in the refspec after the `%` marker:
-+
-----
-  refs/for/master%draft
-  refs/for/master%draft,r=alice
-  refs/for/master%r=alice,draft
-  refs/for/master%r=alice,publish
-----
-
-* Enable content merge by default
-+
-Most teams seem to expect Gerrit to manage simple merges within a
-source code file. Enable this out-of-the-box.
-
-* Added a link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-gerrit.html#core.useRecursiveMerge[
-  server-level option] to use JGit's new, experimental recursive merger.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1608[Issue 1608]:
-Commits pushed without a Change-Id now warn with instructions on how
-to download and install the commit-msg hook.
-
-* Add `oldObjectId` and `newObjectId` to the `GitReferenceUpdatedListener.Update`
-
-=== SSH
-* New SSH command to http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/cmd-gc.html[
-  run Git garbage collection]
-+
-All GC runs are logged in a GC log file.
-
-* Descriptions are added to ssh commands.
-+
-If `gerrit` is called without arguments, it will now show a list of available
-commands with their descriptions.
-
-* `create-account --http-password` enables setting/resetting the
-  HTTP password of role accounts, for Git or REST API access.
-
-* http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/cmd-ls-user-refs.html[
-  ls-user-refs] lists which refs are visible for a given user.
-
-* `ls-projects --has-acl-for` lists projects that mention a group
-  in an ACL, identifying where rights are granted.
-
-* `review` command supports project-specific labels
-
-* `test-submit-rule` was renamed to `test-submit rule`:
-+
-`rule` is now a subcommand of the `test-submit` command.
-
-* http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/cmd-test-submit-type.html[
-  test-submit type] tests the Prolog submit type with a chosen change.
-
-=== Query
-* Allow `{}` to be used for quoting in query expressions
-+
-This makes it a little easier to query for group names that contain
-a space over SSH:
-+
-  ssh srv gerrit query " 'status:open NOT reviewerin:{Developer Group}' "
-
-* The query summary block includes `resumeSortKey`.
-
-* Query results include author and change size information when certain
-  options are specified.
-
-* When a file is renamed the old file name is included in the Patch
-  attribute
-
-=== Plugins
-* Plugins can contribute Prolog facts/predicates from Java.
-* Plugins can prompt for parameters during `init` with `InitStep`.
-* Plugins can now contribute JavaScript to the web UI. UI plugins can
-  also be written and compiled with GWT.
-* New Maven archetypes for JavaScript and GWT plugins.
-* Plugins can contribute validation steps to received commits.
-* Commit message length checks are moved to the `commit-message-length-validator`
-  plugin which is included as a core plugin in the Gerrit distribution and
-  can be installed during site initialization.
-* Creation of code review notes is moved to the `reviewnotes` plugin
-  which is included as a core plugin in the Gerrit distribution and can
-  be installed during site initialization.
-* A plugin extension point for avatar images was added.
-* Allow HTTP plugins to change `static` or `docs` prefixes
-+
-An HTTP plugin may want more control over its URL space, but still
-delegate to the plugin servlet's magic handling for static files and
-documentation. Add JAR attributes to configure these prefixes.
-
-=== Prolog
-[[submit-type-from-prolog]]
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/prolog-cookbook.html#HowToWriteSubmitType[
-  Support controlling the submit type for changes from Prolog]
-+
-Similarly like the `submit_rule` there is now a `submit_type` predicate
-which returns the allowed submit type for a change. When the
-`submit_type` predicate is not provided in the `rules.pl` then the
-project default submit type is used for all changes of that project.
-+
-Filtering the results of the `submit_type` is also supported in the
-same way like filtering the results of the `submit_rule`. Using a
-`submit_type_filter` predicate one can enforce a particular submit type
-from a parent project.
-
-* Plugins can contribute Prolog facts/predicates from Java.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=288[Issue 288]:
-  Expose basic commit statistics for the Prolog rule engine
-+
-A new method `gerrit:commit_stats(-Files,-Insertions, -Deletions)` was
-added.
-
-* A new `max_with_block` predicate was added for more convenient usage
-
-=== Email
-* Notify project watchers if draft change is published
-* Notify users mentioned in commit footer on draft publish
-* Add new notify type that allows watching of new patch sets
-* link:https://code.google.com/p/gerrit/issues/detail?id=1686[Issue 1686]:
-  Add new notify type that allows watching abandoning of changes
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-notify.html[
-  Notifications configured in `project.config`] can now be addressed
-  using any of To, CC, or BCC headers.
-* link:https://code.google.com/p/gerrit/issues/detail?id=1531[Issue 1531]:
-Email footers now include `Gerrit-HasComments: {Yes|No}`.
-* `#if($email.hasInlineComments())` can be used in templates to test
-  if there are comments to be included in this email.
-* Notification emails are sent to included groups.
-* Comment notification emails are sent to project watchers.
-* "Change Merged" emails include the diff output when `sendemail.includeDiff` is enabled.
-* When link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/rest-api-changes.html#set-review[
-  posting a review via REST] the caller can control email delivery
-+
-This may help automated systems to be less noisy. Tools can now choose
-which review updates should send email, and which categories of users
-on a change should get that email.
-
-=== Labels
-* Approval categories stored in the database have been replaced with labels
-  configured in `project.config`. Existing categories are migrated to
-  `project.config` in `All-Projects` as part of the schema upgrade; no user
-  action is required.
-* Labels are no longer global;
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-labels.html[
-  projects may define their own labels], with inheritance.
-* Don't create `Verify` category by default
-+
-Most project teams seem confused with the out-of-the-box experience
-needing to vote on both `Code-Review` and `Verified` categories in
-order to submit a change. Simplify the out-of-the-box workflow to only
-have `Code-Review`. When a team installs the Hudson/Jenkins integration
-or their own build system they can now trivially add the `Verified`
-category by pasting 5 lines into `project.config`.
-
-=== Dev
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/dev-readme.html#debug-javascript[
-  Support loading debug JavaScript]
-
-* Gerrit acceptance tests
-+
-An infrastructure for testing the Gerrit daemon via REST and/or SSH
-protocols has been added. Gerrit daemon is run in the headless mode and
-in the same JVM where the tests run. Besides using REST/SSH, the tests
-can also access Gerrit server internals to prepare the test environment
-and to perform assertions.
-+
-A new review site is created for each test and the Gerrit daemon is
-started on that site. When the test has finished the Gerrit daemon is
-shutdown.
-
-* Lightweight LDAP server for debugging
-
-* Add asciidoc checks in the documentation makefile
-+
-Exit with error if the asciidoc executable is not available or has
-version lower than 8.6.3.
-+
-The release script is aborted if asciidoc is missing.
-
-* Added sublime project files to `.gitignore`
-
-* Exclude all `pom.xml` files that are archetype resources in `version.sh`
-
-* Source files generated by Prolog are now correctly included in the Eclipse
-project.
-
-* Core plugins are now included as git submodules.
-
-* `mvn package` now generates the documentation by default.
-+
-The documentation will always be generated unless `-Dgerrit.documentation.skip`
-is given on the command line.
-
-* `mvn verify` now runs acceptance tests by default.
-+
-The `acceptance` profile is no longer used.  Acceptance tests will always
-be run unless `-Dgerrit.acceptance-tests.skip=True` is given on the command line.
-
-* Vertically align the "Choose:" header on the Become Any Account page.
-* "Become Any Account" can be used for accounts whose full name is an empty string.
-
-
-=== Performance
-* Bitmap Optimizations
-+
-On running the http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/cmd-gc.html[
-garbage collection] JGit creates bitmap data that is saved to an
-auxiliary file. The bitmap optimizations improve the clone and fetch
-performance. git-core will ignore the bitmap data.
-
-* Improve suggest user performance when adding a reviewer.
-+
-Do not check the visibility of the change for each suggested account if
-the ref is visible by all registered users.
-+
-On a system with about 2-3000 users, where most of the projects are
-visible by every registered user, this improves the performance of the
-suggesting reviewer by a factor of 1000 at least.
-
-* Cache RefControl.isVisible()
-+
-For Git repositories with many changes the time for calculating visible
-refs is reduced by 30-50%.
-
-* Allow admins to disable magic ref check on upload
-+
-Some sites manage to run their repositories in a way that prevents
-users from ever being able to create `refs/for`, `refs/drafts` or
-`refs/publish` names in a repository. Allow admins on those servers
-to disable this (somewhat) expensive check before every upload.
-
-* Permit ProjectCacheClock to be completely disabled
-+
-Some admins may just want to require all updates to projects to be
-made through the web interface, and avoid the small expense of a
-background thread ticking off changes.
-
-* Batch read Change objects during query
-
-* Default `core.streamFileThreshold` to a larger value
-+
-If this value is not configured by the server administrator
-performance on larger text files suffers considerably and
-Gerrit may grind to a halt and be unable to answer users.
-+
-Default to either 25% of the available JVM heap or ~2048m.
-
-* Improve performance of ReceiveCommits for repositories with many refs
-+
-Avoid adding `refs/changes/` and `refs/tags/` to RevWalk's as
-uninteresting since JGit RevWalk doesn't perform well when a large
-number of objects is marked as uninteresting.
-
-* PatchSet.isRef()-optimizations.
-+
-PatchSet.isRef() is used extensively when preparing for a ref
-advertisement and the regular expression used by isRefs() was notably
-costly in these circumstances, especially since it could not be
-pre-compiled.
-+
-The regular expression is removed and the check is now directly
-implemented. As result the performance of `git ls-remote` could be
-increased by up to 15%.
-
-* New config option `receive.checkReferencedObjectsAreReachable`
-+
-If set to true, Gerrit will validate that all referenced objects that
-are not included in the received pack are reachable by the user.
-+
-Carrying out this check on Git repositories with many refs and commits
-can be a very CPU-heavy operation. For non public Gerrit servers it may
-make sense to disable this check, which is now possible.
-
-* Cache config value in LdapAuthBackend
-
-* Perform a single /accounts/self/capabilities on page load
-+
-This joins up 3 requests into a single call, which should speed up
-initial page load for most users.
-
-* Only gzip compress responses that are smaller compressed
-
-* Caching of changes
-+
-During Ref Advertisements (via VisibleRefFilter), all changes need to
-be fetched from the database to allow Gerrit to figure out which change
-refs are visible and should be advertised to the user. To reduce
-database traffic a cache for changes was introduced. This cache is
-disabled by default since it can mess up multi-server setups.
-
-=== Misc
-* Add config parameter to make new groups by default visible to all
-+
-Add a new http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-gerrit.html#groups.newGroupsVisibleToAll[
-Gerrit configuration parameter] that controls whether newly
-created groups should be by default visible to all registered users.
-
-* Support for OpenID domain filtering
-+
-Added the ability to only allow email addresses under specific domains
-to be used for OpenID login.
-+
-The allowed domains can be configured by setting
-http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-gerrit.html#auth.openIdDomain[
-auth.openIdDomain] in the Gerrit configuration.
-
-* Always configure
-  http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-gerrit.html#gerrit.canonicalWebUrl[
-  gerrit.canonicalWebUrl] on init
-+
-Gerrit has been requiring this field for several versions now, but init
-did not configure it. Ensure there is a value set so the server is not
-confused at runtime.
-
-* Add submodule subscriptions fetching by projects
-+
-While submodule subscriptions can be fetched by branch, some plugins
-(e.g.: delete-project) would rather need to access all submodule
-subscriptions of a project (regardless of the branch). Instead of
-iterating over all branches of a project, and fetching the
-subscription for each branch separately, we allow fetching of
-subscriptions directly by projects.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1805[Issue 1805]:
-  Make client SSL certificates that contain an email address work
-+
-Authentication with CLIENT_SSL_CERT_LDAP didn't work if the certificate
-contained email address.
-
-* Guess LDAP type of Active Directory LDS as ActiveDirectory
-+
-If Gerrit connects to an AD LDS [1] server it will guess its type as
-RCF_2307 instead of ActiveDirectory. The reason is that an AD LDS
-doesn't support the "1.2.840.113556.1.4.800" capability.  However,
-AD LDS behaves like ActiveDirectory and Gerrit also needs to guess
-its type as ActiveDirectory to make the default query patterns work
-properly.
-+
-Extend the LDAP server type guessing by checking for presence of the
-"1.2.840.113556.1.4.1851" capability which indicates that this LDAP
-server runs ActiveDirectory as AD LDS [2].
-+
-Also remove the check for the presence of the "defaultNamingContext"
-attribute as we don't use it anywhere and, by default, this attribute is
-not set on an AD LDS [3]
-+
-[1] http://msdn.microsoft.com/en-us/library/aa705886(VS.85).aspx +
-[2] http://msdn.microsoft.com/en-us/library/cc223364.aspx +
-[3] http://technet.microsoft.com/en-us/library/cc816929(v=ws.10).aspx
-
-* Allow group descriptions to supply email and URL
-+
-Some backends have external management interfaces that are not
-embedded into Gerrit Code Review. Allow those backends to supply
-a URL to the web management interface for a group, so a user can
-manage their membership, view current members, or do whatever other
-features the group system might support.
-+
-Some backends also have an email address associated with every
-group. Sending email to that address will distribute the message to
-the group's members. Permit backends to supply an optional email
-address, and use this in the project level notification system if
-a group is selected as the target for a message.
-
-* Allow group backends to guess on relevant UUIDs
-+
-Expose all cheaply known group UUIDs from the ProjectCache,
-enumerating groups used by access controls. This allows a backend
-that has a large number of groups to filter its getKnownGroups()
-output to only groups that may be relevant for this Gerrit server.
-+
-The best use case to consider is an LDAP server at a large
-organization. A typical user may belong to 50 LDAP groups, but only
-3 are relevant to this Gerrit server. Taking the intersection of
-the two groups limits the output Gerrit displays to users, or uses
-when considering same group visibility.
-
-* Add more forbidden characters for project names
-+
-`?`, `%`, `*`, `:`, `<`, `>`, `|`, `$`, `\r` are now forbidden in
-project names.
-
-* Make `gerrit.sh` LSB compliant
-+
-** Add LSB headers
-** Add 'status' as synonym for 'check'
-** Fix exit status codes according to http://refspecs.linux-foundation.org/LSB_3.2.0/LSB-Core-generic/LSB-Core-generic/iniscrptact.html
-
-* Option to start headless Gerrit daemon
-+
-Add `--headless` option to the Daemon which will start Gerrit daemon
-without the Web UI front end (headless mode).
-+
-This may be useful for running Gerrit server with an alternative (rest
-based) UI or when starting Gerrit server for the purpose of automated
-REST/SSH based testing.
-+
-Currently this option is only supported via the `--headless` option of
-the daemon program. We would need to introduce a config option in order
-to support this feature for deployed war mode.
-
-* Show path to gerrit.war in command for upgrade schema
-
-=== Upgrades
-* link:https://code.google.com/p/gerrit/issues/detail?id=1619[Issue 1619]:
-Embedded Jetty is now 8.1.7.v20120910.
-
-* ASM bytecode library is now 4.0.
-* JGit is now 2.3.1.201302201838-r.208-g75e1bdb.
-* asciidoc 8.6.3 is now required to build the documentation.
-* link:https://code.google.com/p/gerrit/issues/detail?id=1155[Issue 1155]:
-prettify is now r225
-
-* The used GWT version is now 2.5.0
-+
-Fixes some issues with IE9 and IE10.
-
-== Bug Fixes
-
-=== Web UI
-* link:https://code.google.com/p/gerrit/issues/detail?id=1662[Issue 1662]:
-  Don't show error on ACL modification if empty permissions are added
-+
-This error message was incorrectly displayed if a permission without
-rules was added, although the save was actually successful.
-
-* Don't show error on ACL modification if a section is added more than once
-+
-This error message was incorrectly displayed if multiple sections for
-the same ref were added, although the save was actually successful.
-
-* Links to CGit were broken when `remove-suffix` was enabled.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=926[Issue 926]:
-Internet Explorer versions 9 and 10 are supported.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1664[Issue 1664]:
-  Reverting a change did not preserve the change's topic
-
-* Fix: User could get around restrictions by reverting a commit
-+
-The Gerrit server may enforce several restrictions on the commit
-message (change-id required, signed-off-by, etc). A user was able to
-get around these restrictions by reverting a commit using the UI.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1518[Issue 1518]:
-  Reset 'Old Version History' if dependent change is opened
-+
-Following the navigation link in the dependencies table on the
-change screen, the user can directly navigate to dependent changes.
-The value for 'Old Version History' of the current change was
-incorrectly applied to the new change. If the value was invalid for
-the new change the 'Old Version History' field became blank.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1736[Issue 1736]:
-  Clear 'Old Version History' ListBox before populating it
-+
-The ListBox was not always cleared and as result the same entries were
-sometimes added multiple times e.g. after rebasing a change in the
-WebUI.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1673[Issue 1673]:
-  Fix disappearance of patch headers when compared patches are identical
-+
-When two patches were compared that were identical 'No Differences' is
-displayed to the user. In this case the patch headers disappeared and
-as result the user couldn't change the patch selection anymore.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1759[Issue 1759]:
-  Fix ArrayIndexOutOfBoundsException on intraline diff
-+
-In some cases displaying the intraline diff failed with an exception like
-this:
-+
-----
-java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 10
-  at com.google.gerrit.prettify.common.SparseFileContent.mapIndexToLine(SparseFileContent.java:149)
-  at com.google.gerrit.prettify.common.PrettyFormatter.format(PrettyFormatter.java:188)
-  at com.google.gerrit.client.patches.AbstractPatchContentTable.getSparseHtmlFileB(AbstractPatchContentTable.java:287)
-  at com.google.gerrit.client.patches.SideBySideTable.render(SideBySideTable.java:113)
-  at com.google.gerrit.client.patches.AbstractPatchContentTable.display(AbstractPatchContentTable.java:238)
-  at com.google.gerrit.client.patches.PatchScreen.onResult(PatchScreen.java:444)
-...
-----
-+
-This happened when the old line was:
-+
-----
-  foo-old<LF>
-----
-+
-and the new line was:
-+
-----
-  foo-new<CRLF>
-----
-
-* Prevent leading and trailing spaces on user's Full Name
-+
-Strip off the leading and trailing spaces from the Full Name that the
-user enters on the Contact Information form.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1480[Issue 1480]:
-  Show proper error message if registering email address fails
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=816[Issue 816]:
-  Due to issues with the diff_intraline cache the file indention in the
-  Side-By-Side diff was sometimes wrong.
-
-* Make rebase failed and merge failed messages consistent
-
-* Select 'Projects' menu on loading of a project screen
-+
-If in the top level menu 'All' is selected and the user navigates to
-a change and then from the change to the project by clicking on the
-project link in the ChangeInfoBlock, the project screen is loaded but
-the 'Projects' menu was not selected.
-
-* Fix display issues for inline comments and inline comment editors
-+
-** Sometimes a second comment editor was shown instead of using the
-   existing comment editor.
-** Fix duplicated border line between comments.
-** Sometimes the parts of the border were missing when a comment was
-   expanded.
-** Fix displaying the blue line that marks the current line when there
-   are several published comments.
-** Sometimes on discard of a comment some frames of the comment editor
-   stayed and some border lines of neighbor comments disappeared.
-
-* In diff view don't let arrow column accept clicks.
-
-* Fix display of commit message
-+
-If there are no HTML tags in the text, just break on lines.
-
-* Upon selection in AddMemberBox, focus the box's text field
-+
-In the change screen, after clicking on an element of the 'Add
-Reviewer' suggestion list, users expect to be able to add the reviewer
-by hitting enter. This did not work in Firefox.
-
-* Fix enter key detection in project creation screen
-+
-The enter key detection was not working in all browsers (e.g. Firefox).
-
-* Display a tooltip for all tiny icons and ensure that the cursor is
-  shown as pointer when hovering over them.
-
-* Clean query string when switching pages
-+
-If we load a page without a query string, such as Projects->List,
-My->Changes, or Settings, it might be confusing to show the old query
-string from the previous page. The query string is now cleared out
-when switching pages, leaving the help text visible.
-
-* Fix highlighting in search suggestions
-+
-The provided suggestions should highlight the part that the user has
-already typed as bold text. This only worked for the first operator.
-For suggestions of any further operator no highlighting was done.
-
-* Fix style of hint text in search box on initial page load
-+
-The hint text should be a light gray on the white background,
-but was coming up black on initial page load due to a style setup
-ordering issue between the SearchPanel and the HintTextBox.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1661[Issue 1661]:
-  Update links to Change-Id and Signed-off-by documentation on project info
-  screen
-
-* Use href="javascript;" for All {Side-by-Side,Unified} links
-+
-These links shouldn't have an anchor location. There is nothing for
-the browser to remember or visit if it were opened in a new tab for
-example.
-
-* Improve message for unsatisfiable dependencies
-+
-If a change cannot be merged due to unsatisfiable dependencies a
-comment is added to the change that lists the missing commits and says
-that a rebase is necessary.
-+
-For each missing commit the comment said "Depends on patch set X
-of ..., however the current patch set is Y."
-+
-If multiple commits are missing it may be that for some commits the
-dependency is not outdated (X == Y). In this case the message was
-confusing.
-+
-", however the current patch set is Y." is now skipped if Y == X.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1843[Issue 1843]:
-  Enable the "Create Project" and "Create Group" buttons when pasting the name
-  into the text box.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1370[Issue 1370]:
-  Fix PatchScreen leak when moving between files.
-
-* Prevent account's full name from being set to empty string.  Set it to
-  null instead.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1682[Issue 1682]:
-Correctly handle paths with URL-escaped characters
-+
-URL-unescape the path portion of a change history token to correctly
-handle paths with URL-escapable characters, i.e. '+', ' ', etc.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1915[Issue 1915]:
-Don't show non-visible drafts in the diff screens.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1801[Issue 1801]:
-Correctly keep patch set ordering after a new patch set is added via
-the Web UI.
-
-=== REST API
-* Fix returning of 'Email Reviewers' capability via REST
-+
-The `/accounts/self/capabilities/` didn't return the 'Email Reviewers'
-capability when it was not explicitly assigned, although by default
-everyone has the 'Email Reviewers' capability.
-+
-If 'Email Reviewers' capability was allowed or denied,
-`/accounts/self/capabilities/` returned the 'Email Reviewers'
-capability always as true, which was wrong for the DENY case.
-
-* Provide a more descriptive error message for unauthenticated REST
-  API access
-
-=== Git
-* The wildcard `.` is now permitted in reference regex rules.
-
-* Checking if a change is mergeable no longer writes to the repository.
-
-* Submitted but unmerged changes are periodically retried. This is
-  necessary for a multi-master configuration where the second master
-  may need to retry a change not yet merged by the first. Please note
-  we still do not believe this is sufficient to enable multi-master.
-
-* Retry merge after LOCK_FAILURE when updating branch
-+
-If the project requires fast-forwards, the merge cannot succeed once
-a lock failure occurs, but in other cases, it is safe to retry the
-merge immediately.
-
-* Do not automatically add reviewers from footer lines to draft patch sets
-+
-Gerrit already avoids adding reviewers from footer lines when a new
-draft change is created. Now the same is done for draft patch sets.
-
-* Add users mentioned in commit footer as reviewers on draft publish
-
-* Hide any existing magic branches during push
-+
-If there is a magic branch visible during push, just hide it from the
-client. Administrators can clear these by accessing the repository
-directly.
-
-* Prevent from deleting `refs/changes/`
-+
-Everything under `refs/changes/` should be protected by Gerrit, users
-shouldn't be able to delete a particular patch set or a whole change
-from the review process.
-
-* Update description file in Git
-+
-When writing the description to `project.config`, it is also necessary
-to write it to the description file in the repository so the same text
-is visible in CGit or GitWeb.
-
-* Write valid reflog for `HEAD` when creating the `All-Projects`
-  project
-+
-When the `All-Projects` project is created during the schema
-initialization, `HEAD` is set to point to the `refs/meta/config`
-branch. When `HEAD` is updated an entry into the reflog is written.
-This ref log entry should contain the ID of the initial commit as
-target, but instead the target was the zero ID.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1702[Issue 1702]:
-  Fix: 'internal server error' when pushing the same commit twice
-+
-On the second push of the same commit to `refs/for/<branch name>`, Gerrit
-returns 'no new changes'.
-+
-However if the user pushed to 'refs/changes/<change id>', Gerrit returned
-'internal server error'.
-
-* Match all git fetch/clone/push commands to the command executor
-+
-Route not just `/p/` but any Git access to the same thread pool as the
-SSH server is using, allowing all requests to compete fairly for
-resources.
-
-* Fix auto closing of changes on direct push
-+
-When a commit was directly pushed into a repository (bypassing code
-review) and this commit had a Change-Id in its commit message then the
-corresponding change was not automatically closed if it was open.
-
-* Set change state to NEW if merge fails due to non-existing dest branch
-+
-If a submitted change failed to merge because the destination branch
-didn't exist anymore, it stayed in state 'Submitted, Merge Pending'.
-This meant Gerrit was re-attempting to merge this change (e.g. on
-startup), but this didn't make sense. Either the branch did still not
-exist (then there was no need to try merging it) or a new branch with
-the old name was created (then it was questionable if the change should
-still be merged into this branch). This is why it's better to set the
-change back to the 'Review in Progress' state and update it with a
-message saying that it couldn't be merged because the destination
-branch doesn't exist anymore.
-+
-In addition Gerrit was writing an error into the error log if a change
-couldn't be merged because the destination branch was missing.
-That was not really a server error and is not logged anymore.
-
-* Fix NPE when pushing a patch with an invalid author with
-  `Forge Author` permissions
-
-* Fix duplicated GitReferenceUpdated event on project creation.
-+
-Creating a new Gerrit project was firing the GitReferenceUpdated event
-for the `refs/meta/config` branch two times.
-
-* Fix error log message in ReceiveCommits
-+
-When the creation of one or more references failed ReceiveCommits failed
-with 'internal server error' and wrote the following error log:
-"Only X of Y new change refs created in xxx; aborting"
-The printed value for Y could be wrong since it didn't include the
-replaceCount. As a result, a confusing message like
-"Only 0 of 0 new change refs created in xxx; aborting"
-could appear in the error log.
-
-=== SSH
-* `review --restore` allows a review score to be added on the restored change.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1721[Issue 1721]:
-  `review --message` only adds the message once.
-
-* `ls-groups` prints "N/A" if the group's name is not set.
-
-* `set-project-parent --children-of`: Fix getting parent for level 1 projects
-+
-For direct child projects of the `All-Projects` project the name of the
-parent project was incorrectly retrieved if the parent name was not
-explicitly stored as `All-Projects` in the project.config file.
-
-* Fix NPE when abandoning change with invalid author
-+
-If the author of a change isn't known to Gerrit (pushed with
-`Forge Author` permissions), trying to abandon that change over SSH
-failed with an NPE.
-
-* Fix setting account's full name via ssh.
-
-=== Query
-* link:https://code.google.com/p/gerrit/issues/detail?id=1729[Issue 1729]:
-  Fix query by 'label:Verified=0'
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1772[Issue 1772]:
-  Set `_more_changes` if result is limited due to configured query limit
-
-* Fix query cost for "status:merged commit:c0ffee"
-
-=== Plugins
-* Skip disabled plugins on rescan
-+
-In a background thread Gerrit periodically scans for new or changed
-plugins. On every such a rescan disabled plugins were loaded and a new
-copy of their jar files was stored in the review site's tmp folder.
-
-* Fix cleanup of plugins from tmp folder on graceful Gerrit shutdown
-+
-Loaded plugin jars are copied to the review site's tmp folder to support
-hot updates of the plugin jars in the plugins folder. On Gerrit shutdown
-these copies of the jar files should be cleaned up. For this purpose a
-CleanupHandle is created, but the CleanupHandle wasn't enqueued in the
-cleanupQueue which is why cleanup on Gerrit shutdown didn't happen.
-
-* Reattempt deletion of plugin jars from tmp folder on JVM termination
-+
-Loaded plugin jars are copied to the review site's tmp folder to support
-hot updates of the plugin jars in the plugins folder. On Gerrit shutdown
-these copies of the jar files should be cleaned up. For this purpose a
-CleanupHandle is created. The deletion of the tmp file in the
-CleanupHandle can fail although the jar file was closed. In this case
-reattempt the deletion on termination of the virtual machine. This
-normally succeeds.
-
-* Fix unloading of plugins
-+
-When two plugins, say pluginA, and pluginB had been loaded, and pluginA
-was removed from $sitePath/plugins, pluginA got stopped, and a cleaning
-run was ordered. But this cleaning run cleaned both plugins and both
-plugins had their jars removed. This left pluginB visible to Gerrit
-although it's backing jar was gone. Upon calling not yet initialized
-parts of pluginB (e.g.: viewing not yet viewed Documentation pages of
-pluginB), exceptions as following were thrown:
-+
-----
-  java.lang.IllegalStateException: zip file closed
-          at java.util.zip.ZipFile.ensureOpen(ZipFile.java:420)
-          at java.util.zip.ZipFile.getEntry(ZipFile.java:165)
-----
-
-* Fix double bound exception when loading extensions
-+
-ServerInformation class was already bound, therefore it shouldn't be
-bound a second time for Gerrit extensions.
-
-* Do not call onModuleLoad() second time
-+
-onModuleLoad() method is automatically called by GWT framework. Calling
-it once again in PluginGenerator caused double plugin initialization.
-
-* Require `Administrate Server` capability to GET /plugins/
-+
-Listing plugins requires being an administrator. This was missed in the
-REST API.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1827[Issue 1827]:
-  Allow InternalUser (aka plugins) to see any internal group, and run
-  plugin startup and shutdown as PluginUser.
-
-=== Email
-* Merge failure emails are only sent once per day.
-* Unused macros are removed from the mail templates.
-* Unnecessary ellipses are no longer applied to email subjects.
-* The empty diff output from an "octopus merge" is now explained in change notification emails.
-* link:https://code.google.com/p/gerrit/issues/detail?id=1480[Issue 1480]:
-Proper error message is shown when registering an email address fails.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1692[Issue 1692]:
-Review comments are sorted before being added to notification emails.
-
-* Fix watching of 'All Comments' on `All-Projects`
-+
-If a user is watching 'All Comments' on `All-Projects` this should
-apply to all projects.
-
-=== Misc
-* Provide more descriptive message for NoSuchProjectException
-
-* On internal error due to receive timeout include the value of
-  `receive.timeout` into the log message
-
-* Silence INFO/DEBUG output from apache.http
-+
-This spammed the log when using OpenID, for each and every login.
-
-* Remove `mysql_nextval` script
-+
-This function does not work on binary logging enabled servers,
-as MySQL is unable to execute the function on slaves without
-causing possible corruption. Drop the function since it was only
-created to help administrators, and is unsafe.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1312[Issue 1312]:
-  Fix relative URL detection in submodules
-+
-Relative submodules do not start with `/`. Instead they start with
-`../`.  Fix the Submodule Subscriptions engine to recognize relative
-submodules.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1622[Issue 1622]:
-  Fix NPE in LDAP Helper class if username is null
-
-* Fix commit-msg hook failure with spaces in path
-+
-If the project absolute path had any whitespace, the commit
-hook failed to complete because a script variable was not
-enclosed in double quotes.
-
-* Drop the trailing ".git" suffix of the name of new project
-
-* Prevent possible NPE when running `change-merged` hook
-+
-It's possible that the submitter is null. Add a check for this
-before invoking the `change-merged` hook with it.
-
-* Keep change open if its commit is pushed to another branch.
-
-* Fire GitReferenceUpdated event when BanCommit updates the
-  `refs/meta/reject-commits` branch.
-
-* Fix GitWeb Caching
-+
-GitWeb Caching was not working when its cgi file was executed from
-outside. The same approach will also work with vanilla GitWeb.
-
-* Fix infinite loops when walking project hierarchy
-
-* Fix resource leak in MarkdownFormatter
-
-* Query all external groups for internal group memberships
-+
-When asking for the known groups a user belongs to they may belong
-to an internal group by way of membership in a non-internal group,
-such as LDAP. Cache in memory the complete list of any non-internal
-group UUIDs used as members of an internal group. These must get
-checked for membership before completing the known group data from
-the internal backend.
-
-* Handle sorting groups with no name to avoid NPE
-
-* `gerrit.sh`
-** Don't suggest site init if schema version is newer than expected
-** Improve error messages in schema check
-** Suggest changing `gerrit.config` when JDK not found
-** Explicitly set a shell
-** Determine `GERRIT_SITE` from current working directory.
-** Fix `gerrit.sh restart` for relative paths
-** Fix site path computation if '.' occurs in path
-** Whitespace fixes
-
-* Display the reason of an Init injection failure.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1821[Issue 1821]:
-  Warn if `cache.web_sessions.maxAge` is to small
-+
-Setting `maxAge` to a small value can result in the browser endlessly
-redirecting trying to setup a new valid session. Warn administrators
-that the value is set smaller than 5 minutes.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1821[Issue 1821]:
-  Support `cache.web_sessions.maxAge` < 1 minute
-
-* Use SECONDS as default time unit for `cache.web_sessions.maxAge`
-+
-DefaultCacheFactory already uses SECONDS as default time unit for
-`cache.*.maxAge`.
-+
-Update the described default time unit for `cache.*.maxAge` in the
-documentation.
-+
-Administrators may need to update their configuration to for the new
-default time unit.
-
-* Add pylint configuration for contributed Python scripts
-
-* Various fixes and improvements of the `contrib/trivial_rebase.py`
-  script
-+
-** Adapt options to Gerrit 2.6
-** Use change-url flag for ChangeId
-** Prevent exception for empty commit
-** Fix pylint errors
-** Call `gerrit review` instead of `gerrit approve`
-** Make the private key argument optional
-** Support alternative ssh executable, for example `plink`
-** Support custom review labels
-** Correctly handle empty patch ID
-+
-If only one of the patch IDs is empty, it should not be considered
-a trivial rebase.
-
-** Use plain python instead of python2.6
-+
-Windows installation only has python.exe
-
-* Correct MIME type of `favicon.ico` reference
-+
-This is not a GIF, it is an "MS Windows icon resource".
-Some browsers may skip the image if the type is wrong.
-
-* Use `<link rel="shortcut icon">` for `favicon.ico` reference
-+
-IE looks for a two-word "shortcut icon" relationship.  Other browsers
-interpret this as two relationships, one of which is "icon", so they
-can handle this syntax as well.
-+
-See:
-+
-** http://msdn.microsoft.com/en-us/library/ms537656(VS.85).aspx
-** http://jeffcode.blogspot.com/2007/12/why-doesnt-favicon-for-my-site-appear.html
-
-* Remove `servlet-api` from `WAR/lib`
-+
-It is wrong to include the servlet API in a WAR's `WEB-INF/lib`
-directory. This confuses some servlet containers who refuse to
-load the Gerrit WAR. Instead package the Jetty runtime and the
-servlet API in a new `WEB-INF/pgm-lib` directory.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1822[Issue 1822]:
-  Verify session matches container authentication header
-+
-If the user alters their identity in the container invalidate
-the Gerrit user session and force a new one to begin.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1743[Issue 1743]:
-  Move RPC auth token from `Authorization` header to `X-Gerrit-Auth`
-+
-Servers that run with auth.type = HTTP or HTTP_LDAP are unable to
-use the web UI because the Authorization code supplied by the UI
-overrides the browser's native `Authorization` header and causes the
-request to be blocked at the HTTP reverse proxy, before Gerrit even
-sees the request.
-+
-Instead insert a unique token into `X-Gerrit-Auth`, leaving the HTTP
-standard `Authorization` header unspecified and available for use in
-HTTP reverse proxies.
-
-== Documentation
-
-The link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/index.html[
-documentation index] is restructured to make it easier to use for different kinds of
-users.
-
-=== User Documentation
-* Split link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/rest-api.html[
-  REST API documentation] and have one page per top level resource
-
-* Add executable examples for GET requests to
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/rest-api.html[
-  REST API documentation]
-+
-Add examples for GET requests to the REST API documentation on which
-the user can click to fire the requests. This allows users to
-immediately try out the requests and play around with them.
-
-* Document the link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/access-control.html#block[
-  BLOCK access rule].
-
-* Added documentation of
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-upload.html#http[
-  how to authenticate uploads over HTTP].
-
-* Added documentation of the
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-gerrit.html#auth.editFullNameUrl[auth.editFullNameUrl] and
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-gerrit.html#auth.httpPasswordUrl[auth.httpPasswordUrl]
-  configuration parameters.
-
-* Add link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/prolog-cookbook.html[
-  submit_rule examples] from Gerrit User Summit 2012.
-
-* Improved the push tag examples in the access control documentation.
-
-* Improved documentation of error messages related to commit message footer content.
-
-* Added documentation of the
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/error-commit-already-exists.html[
-  commit already exists] error message.
-
-* Added missing documentation of the ssh
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/cmd-version.html[
-  version] command.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1369[Issue 1369]:
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-gitweb.html[
-  Gitweb Instruction Updates]
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1594[Issue 1594]:
-  Document execute permission for commit-msg in
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-changeid.html#creation[
-  Change-Id docs]
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1602[Issue 1602]:
-Corrected references to `refs/changes` in the access control documentation.
-
-* Update documentation of
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-gerrit.html#trackingid.name.match[
-  maximal length for tracking ids]
-
-* Added missing documentation of
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/json.html[JSON attributes].
-
-* Rename `custom-dashboards.html` to
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-dashboards.html[user-dashboards.html]
-+
-This document no longer deals exclusively with custom dashboards, it now describes project level dashboards also.
-
-* Separate the
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-login-register.html[
-  initial user setup instructions] to a shared file
-
-* Separate the
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/database-setup.html[
-  database setup instructions] to a shared file
-
-* Improve the link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/database-setup.html[
-  instructions for PgSQL setup]
-
-* Fix the order of steps in the
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/install-j2ee.html[
-  J2EE Installation document]
-+
-It is better to first define the JNDI data source in the application
-server and then deploy Gerrit than opposite. This should avoid errors
-like "No DataSource" on the first deployment.
-
-* Clarify documentation of
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-gerrit.html#ldap.groupName[
-  LDAP group name setting]
-
-* Improve the documentation of
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-submodule.html[
-  git submodule subscription handling]
-
-* Clarify the documentation of change cache setup.
-
-* Improve the explanation of path conflicts in the
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/project-setup.html[
-  project setup documentation].
-
-* Add explanations of special/magic refs in the
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/access-control.html#references[
-  access control documentation].
-
-* Clarify how to set Global Capabilities.
-* Correct documentation of the `create-account` ssh command.
-* Add documentation of the `database.connectionPool` setting.
-* Adapt documentation to having 'Projects' as top level menu
-* Added missing documentation of mail templates.
-* Added documentation of contributor agreements.
-* Fix `init.d` symbolic link commands.
-* Remove obsolete diskbuffer setting from example config file.
-* Various minor grammatical and formatting corrections.
-* Fix external links in 2.0.21 and 2.0.24 release notes
-* Manual pages can be optionally created/installed for core gerrit ssh commands.
-
-=== Developer And Maintainer Documentation
-* Updated the link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/dev-eclipse.html#maven[
-  Maven plugin installation instructions] for Eclipse 3.7 (Indigo).
-
-* Document link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/dev-contributing.html#commit-message[
-  usage of the past tense in commit messages]
-
-* Add link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/dev-contributing.html[
-  instructions] on how to configure git for pushing to Gerrit's Gerrit
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/dev-contributing.html#process[
-  Stable branches process documentation]
-
-* Improved the
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/dev-release.html[
-  release documentation].
-
-* Document that plans for
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/dev-release.html#stable[
-  stable-fix releases] should be announced
-
-* Document process for
-  link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/dev-release.html#security[
-  security-fix releases]
-
-* The release notes are now made when a release is created by running the `tools/release.sh` script.
-
diff --git a/ReleaseNotes/ReleaseNotes-2.7.txt b/ReleaseNotes/ReleaseNotes-2.7.txt
deleted file mode 100644
index 0870cbf..0000000
--- a/ReleaseNotes/ReleaseNotes-2.7.txt
+++ /dev/null
@@ -1,313 +0,0 @@
-= Release notes for Gerrit 2.7
-
-
-Gerrit 2.7 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.7.war[
-https://www.gerritcodereview.com/download/gerrit-2.7.war]
-
-Gerrit 2.7 includes the bug fixes done with link:ReleaseNotes-2.6.1.html[Gerrit 2.6.1].
-These bug fixes are *not* listed in these release notes.
-
-== Schema Change
-
-
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* Upgrading to 2.7.x requires the server be first upgraded to 2.1.7 (or
-a later 2.1.x version), and then to 2.7.x.  If you are upgrading from 2.2.x.x or
-newer, you may ignore this warning and upgrade directly to 2.7.x.
-
-
-
-== Gerrit Trigger Plugin in Jenkins
-
-
-*WARNING:* Upgrading to 2.7 may cause the Gerrit Trigger Plugin in Jenkins to
-stop working.  Please see the "New 'Stream Events' global capability" section
-below.
-
-
-== Release Highlights
-
-
-* New `copyMaxScore` setting for labels.
-* Comment links configurable per project.
-* Themes configurable per project.
-* Better support for binary files and images in diff screens.
-* User avatars in more places.
-* Several new REST APIs.
-
-
-== New Features
-
-
-=== General
-
-* New `copyMaxScore` setting for labels.
-+
-Labels can be link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/config-labels.html#label_copyMaxScore[
-configured] to copy approvals forward to the next patch set.
-
-* Comment links can be link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/config-gerrit.html#commentlink[
-defined per project in the project configuration].
-
-* Gerrit administrators can define project-specific themes.
-+
-Themes can be link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/config-themes.html[
-configured site-wide or per project].
-
-* New '/a/tools' URL.
-+
-This allows users to download the `commit-msg` hook via the command line if the
-Gerrit server requires authentication globally.
-
-* New 'Stream Events' global capability.
-+
-The link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/access-control.html#capability_streamEvents[
-Stream Events capability] controls access to the `stream-events` ssh command.
-+
-Only administrators and users having this capability are allowed to use `stream-events`.
-+
-If you are using the Gerrit Trigger Plugin in Jenkins, you must make sure that the
-'Non-Interactive Users' group, or whichever group the Jenkins user belongs to, is
-given the 'Stream Events' capability.
-
-* Allow opening new changes on existing commits.
-+
-The `%base` argument can be used with `refs/for/` to identify a specific revision the server should
-start to look for new commits at. Any commits in the range `$base..$tip` will be opened as a new
-change, even if the commit already has another change on a different branch.
-
-* New setting `gitweb.linkDrafts` to control if gitweb links are shown on drafts.
-+
-By default, Gerrit will show links to gitweb on all patch sets.  If the
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/config-gerrit.html#gitweb.linkDrafts[
-gitweb.linkDrafts setting] is set to 'false', links will not be shown on
-draft patch sets.
-
-* Allow changes to be automatically submitted on push.
-+
-Teams that want to use Gerrit's submit strategies to handle contention on busy
-branches can use `%submit` to create a change and have it
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/user-upload.html#auto_merge[
-immediately submitted], if the caller has Submit permission on `refs/for/<ref>`.
-
-* Allow administrators to see all groups.
-
-
-=== Web UI
-
-
-==== Global
-
-* User avatars are displayed in more places in the Web UI.
-
-* 'Diffy' is used as avatar for the Gerrit server itself.
-
-* A popup with user profile information is shown when hovering the
-mouse over avatar images.
-
-
-==== Change Screens
-
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=667[Issue 667]:
-Highlight patch sets that have drafts.
-+
-Patch sets having unpublished draft comments are highlighted with an icon.
-
-* Option to show relative times in change tables.
-+
-A new preference setting allows the user to decide if absolute or relative dates
-should be shown in change tables.
-
-* Option to set default visibility of change comments.
-+
-A new preference setting allows the user to set the default visibility of
-change comments.
-
-
-==== Diff Screens
-
-* Show images in side-by-side and unified diffs.
-
-* Show diffed images above/below each other in unified diffs.
-
-* Harmonize unified diff's styling of images with that of text.
-
-
-=== REST API
-
-
-Several new link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/rest-api.html[
-REST API endpoints] are added.
-
-==== Accounts
-
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/rest-api-accounts.html#get-diff-preferences[
-Get account diff preferences]
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/rest-api-accounts.html#set-diff-preferences[
-Set account diff preferences]
-
-
-==== Changes
-
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1820[Issue 1820]:
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/rest-api-changes.html#list-comments[
-List comments]
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/rest-api-changes.html#get-comment[
-Get comment]
-
-
-
-==== Projects
-
-
-* link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/rest-api-projects.html#get-config[
-Get project configuration]
-
-
-=== ssh
-
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1088[Issue 1088]:
-Support link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/config-gerrit.html#sshd.kerberosKeytab[
-Kerberos authentication for ssh interaction].
-
-
-== Bug Fixes
-
-=== General
-
-* Postpone check for first account until adding an account.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1848[Issue 1848]:
-Mark `ALREADY_MERGED` changes as merged in the database.
-+
-If a change was marked `ALREADY_MERGED`, likely due to a bug in
-merge code, it does not end up in the list of changes to be submitted
-and never gets marked as merged despite the branch head already
-having advanced.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=600[Issue 600]:
-Fix change stuck in SUBMITTED state but actually merged.
-+
-When submitting a commit that has a tag, it could not be merged.
-
-* Fix null-pointer exception when dashboard title is not specified.
-+
-If the title is not specified, the path of the dashboard config file
-is used as title.
-
-* Allow label values to be configured with no text.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1966[Issue 1966]:
-Fix Gerrit plugins under Tomcat by avoiding Guice static filter.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2054[Issue 2054]:
-Expand capabilities of `ldap.groupMemberPattern`.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2098[Issue 2098]:
-Fix re-enabling of disabled plugins.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2128[Issue 2128]:
-Fix null-pointer exception when deleting draft patch set when previous
-draft was already deleted.
-
-
-=== Web UI
-
-
-* Properly handle double-click on external group in GroupTable.
-+
-Double-clicking on an external group opens the group's URL (if it
-is provided).
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1848[Issue 1848]:
-Don't discard inline comments when escape key is pressed.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1863[Issue 1863]:
-Drop Arial Unicode MS font and request only sans-serif.
-+
-Arial Unicode MS does not have a bold version. Selecting this font prevents
-correct display of bold text on Mac OS X. Simplify the selector to sans-serif
-and allow the browser to use the user's preferred font in this family.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1872[Issue 1872]:
-Fix tab expansion in diff screens when syntax coloring is on.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1904[Issue 1904]:
-Fix diff screens for files with CRLF line endings.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2056[Issue 2056]:
-Display custom NoOp label score for open changes.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2093[Issue 2093]:
-Fix incorrect title of "repo download" link on change screen.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2127[Issue 2127]:
-Remove hard-coded documentation links from the admin page.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2010[Issue 2010]:
-Fix null-pointer exception when searching for changes with the query
-`owner:self`.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2039[Issue 2039]:
-Fix browser null-pointer exception when ChangeCache is incomplete.
-
-
-=== REST API
-
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1819[Issue 1819]:
-Include change-level messages to the payload returned from
-the link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/rest-api-changes#get-change-detail[
-Get Change Detail REST API endpoint].
-
-* Correct URL encoding in 'GroupInfo'.
-
-
-=== Email
-
-* Log failure to access reviewer list for notification emails.
-
-* Log when appropriate if email delivery is skipped.
-
-
-=== ssh
-
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2016[Issue 2016]:
-Flush caches after adding or deleting ssh keys via the `set-account` ssh command.
-
-=== Tools
-
-
-* The release build now builds for all browser configurations.
-
-
-== Upgrades
-
-* `gwtexpui` is now built in the gerrit tree rather than linking a separate module.
-
-
-
-== Documentation
-
-
-* Update the access control documentation to clarify how to set
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/access-control.html#global_capabilities[
-global capabilities].
-
-* Clarify the
-link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/config-gerrit.html#cache_names[
-change cache configuration].
-
diff --git a/ReleaseNotes/ReleaseNotes-2.8.1.txt b/ReleaseNotes/ReleaseNotes-2.8.1.txt
deleted file mode 100644
index 5e32cf5..0000000
--- a/ReleaseNotes/ReleaseNotes-2.8.1.txt
+++ /dev/null
@@ -1,45 +0,0 @@
-= Release notes for Gerrit 2.8.1
-
-There are no schema changes from link:ReleaseNotes-2.8.html[2.8].
-
-link:https://www.gerritcodereview.com/download/gerrit-2.8.1.war[https://www.gerritcodereview.com/download/gerrit-2.8.1.war]
-
-== Bug Fixes
-* link:https://code.google.com/p/gerrit/issues/detail?id=2073[Issue 2073]:
-Changes that depend on outdated patch sets were missing in the related changes list.
-+
-After rebasing the first change the other changes disappeared from the related changes list.
-
-* Don't list the same change twice in related changes.
-
-* Fix plugin API packaging.
-+
-Parts from JGit's signed library were included in the plugin API. As a consequence unit
-tests were failing to execute against it.
-
-* Fix IllegalArgumentException in task queue comparator.
-+
-This could happen if you have a long queue and the state of a task (DONE, CANCELLED,
-RUNNING, READY, SLEEPING, OTHER) changes while the sorting is ongoing.
-
-* Delegate to the filters for init and destroy phases in AllRequestFilter.
-+
-This fixes a bug that prevented javamelody from working properly.
-
-* Fix ArrayOutOfBoundsException on initial commits.
-+
-This happened if a new patch set was given for an initial commit in a repository.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2320[Issue 2320],
-link:https://code.google.com/p/gerrit/issues/detail?id=2360[Issue 2360]:
-Enable syntax highlighting for CXX, HXX, Python, Go, Scala, BUCK and .gitmodules.
-
-* Preserve SNAPSHOT suffix in Maven artifact names.
-+
-The SNAPSHOT suffix was being removed, which prevented Buck from
-downloading the Gitblit plugin's custom artifacts from the Gerritforge
-repository.
-
-* Always show repo download command if repo download scheme is enabled.
-
-* Minor fixes in the documentation.
diff --git a/ReleaseNotes/ReleaseNotes-2.8.2.txt b/ReleaseNotes/ReleaseNotes-2.8.2.txt
deleted file mode 100644
index 99cb437..0000000
--- a/ReleaseNotes/ReleaseNotes-2.8.2.txt
+++ /dev/null
@@ -1,311 +0,0 @@
-= Release notes for Gerrit 2.8.2
-
-There are no schema changes from link:ReleaseNotes-2.8.1.html[2.8.1].
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.8.2.war[
-https://www.gerritcodereview.com/download/gerrit-2.8.2.war]
-
-
-== Lucene Index
-
-* Support committing Lucene writes within a fixed interval.
-+
-The `ramBufferSize` and `maxBufferedDocs` options control how often the
-writer is flushed, but this does not fsync files on disk and thus
-might not be permanent, particularly in a machine under heavy load.
-+
-As a result, commits to the index may not be completed, and updates may
-be lost if the server goes down.
-+
-A new option `commitWithin` is added, to control how frequently the
-indexes are committed.
-
-
-== General
-
-* Only add "cherry picked from" when cherry picking a merged change.
-+
-The "(cherry picked from commit ...)" line was being added in the commit
-message when cherry picking from closed changes, which included those that were
-abandoned.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2513[Issue 2513]:
-Improve the "This patchset was cherry picked" message.
-+
-When cherry-picking a change, the message "This patchset was cherry picked to
-change: <Change-Id>" was added as a message on the change.  This was not very
-useful as the Change-Id is the same on the newly created change.
-+
-The message is changed to "This patchset was cherry picked to branch <branch
-name> as commit <SHA1>".
-
-* Fix PUSH permission check for draft changes.
-+
-It was not possible to block pushes to the `refs/drafts` namespace.
-
-* Don't allow project owners to create branches if create is blocked.
-+
-Project owners were able to create branches through the WebUI, REST and SSH
-even when the 'create reference' permission was actually blocked for them.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2397[Issue 2397]:
-Remove quotes and trailing period from "topic edited" messages.
-+
-The quotes and trailing period were causing linkification to fail for topics
-that were set to a URL.
-
-* Check if user can read HEAD commit when resolving detached HEAD.
-+
-If HEAD was detached the `GetHead` REST endpoint refused to resolve HEAD
-when the user was not a project owner.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2392[Issue 2392]:
-Keep `status:closed` limit below MySQL Connector/J's hard limit.
-+
-Since MySQL Connector/J 5.1.21 does not allow limits above 50M rows
-and aborts them with 'setMaxRows() out of range', we cannot use `MAX_VALUE`
-as limit for plain `status:closed` queries.
-
-* Fix IllegalArgumentException when running query with `limit:0` on secondary
-index.
-+
-Running a query with `limit:0` when the secondary index is enabled was causing
-an internal server error.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2331[Issue 2331]:
-Make sure `change-merged` event contains correct patch set number.
-+
-When a change is submitted with the cherry-pick strategy, or when the
-change is rebased with the "rebase if necessary" strategy, a new patch
-set is created.  The newly created patch set was not being set in the
-`change-merged` event.
-
-* Guard against `diff.mnemonicprefix` in `commit-msg` hook.
-+
-When `diff.mnemonicprefix` was enabled in the git config, committing
-changes with `git commit -v` caused the diff to be included in the
-generated commit message.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2453[Issue 2453]:
-Fix submit rule evaluation for non blocking labels.
-+
-Putting a negative score on a label configured as `NoBlock` was causing
-the submit button to be disabled.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2441[Issue 2441]:
-Allow to create branch with new commits.
-+
-Branches could not be created with a new commit which is not on other branches
-already.
-
-* Fix incompatibility between "Rebase if Necessary" and "copy scores".
-+
-When a project was set up with "Rebase if Necessary", one of its labels had
-`copyAllScoresOnTrivialRebase` or `copyMaxScore`, and a change that actually
-needed a trivial rebase was submitted, Gerrit first rebased the change, and in
-the process copied the approval for the label.  It then copied all the
-approvals, including the one already copied, which resulted in a constraint
-violation on the database.
-
-* Add `Implementation-Vendor` default manifest entry for plugins.
-+
-In buck, the `java_binary` rule merges manifest entries from dependent JARs
-unless the input JAR possesses these entries itself.  This was causing some
-plugins to display the wrong vendor information if they had dependency on
-another JAR file that provided a `Implementation-Vendor` value.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2498[Issue 2498]:
-Handle null commits when updating submodules.
-+
-In some edge cases it was possible that a null commit would exist, and this
-caused a crash when updating submodules.
-
-* Update and insert comments/approvals in a single step.
-+
-When a review includes both new label scores and updates to existing label
-scores, use `upsert` to record them all at the same time, rather than in
-separate `update` and `insert` operations.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2374[Issue 2374]:
-Prevent duplicate commits in same project when uploading to `refs/changes/n`.
-+
-Under certain circumstances, when pushing to `refs/changes/n`, the same
-commit could be pushed onto multiple changes even if the changes were on the
-same branch.
-
-* Remove dependency on joda time library in gerrit launcher.
-+
-The joda time library was being unnecessarily packaged in the root of
-the gerrit.war file.
-
-== Change Screen / Diff Screen
-
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2398[Issue 2398]:
-Enable syntax highlighting for Groovy, Clojure, Lisp, Ruby and Perl.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2416[Issue 2416]:
-Fix copy functionality in Firefox and Safari.
-+
-Ctrl-C/Cmd-C was activating the 'insert comment' feature, and preventing the
-browser from copying the selected text to the clipboard.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2428[Issue 2428]:
-Fix truncation of long lines in side-by-side diff.
-+
-Lines whose length exceeded the width of the window were being truncated
-and only shown fully after zooming out/in on the browser.
-
-* Fix handling of the enter key when editing the topic.
-+
-The enter key was causing the file diff view to open, instead of confirming
-the topic edit.
-
-* Fix wrong button being passed to the 'revert' action.
-+
-The action was using the cherry-pick button instead of the revert button.
-
-* Improve the error message shown when cherry picking a change fails.
-+
-The error message "Could not create merge commit during cherry pick" was
-confusing for users, and is replaced with simply "Cherry pick failed".
-
-* Add newline on commit messages created by cherry picking a change in the UI
-or via the REST API.
-+
-If a commit was cherry-picked from the UI or via the REST API, the
-trailing newline on the end of the commit message was lost.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2405[Issue 2405]:
-Update change to invalidate cache after deletion of draft revision.
-+
-When a non-current draft patch set was deleted no update of the change
-was made, causing the change screen to not work properly because it
-relied on cached data.
-
-* Extend change screen's horizontal bars to full width.
-+
-This allows the title of the change message to have some padding within
-the bar.
-
-* Fix tab alignment to be correct width in side-by-side diff.
-+
-This fixes the tab width to be the user's preference, rather than
-1 + user's preference when show tabs is enabled.
-
-* Fill the browser width in side-by-side diff.
-+
-Filling the browser available space with each side of the diff at
-50% size allows the user to more easily view long lines if they
-have a wide display, and better fit on more narrow displays by
-splitting the available width at 50%.
-
-* Fire `comment-added` stream event even when mail notification is not sent.
-+
-Unchecking the "and send email" option on the change screen prevented the
-`comment-added` event from being sent to the event stream.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2493[Issue 2493]:
-Set uploader to current user in `patchset-created` event upon rebasing
-a change in the UI.
-+
-When a change was rebased from the change screen, the `uploader` field
-of the `patchset-created` event was incorrectly set to the original
-change uploader, rather than the user that performed the rebase.
-
-* Display a warning instead of an error when the intraline diff times out.
-+
-Displaying an error was confusing for users and administrators.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2514[Issue 2514]:
-Display an error message when commentlink regex is invalid.
-+
-If a commentlink was configured with an invalid regular expression, for example
-an expression that is valid in Java but not in JavaScript, the change screen
-failed to load.
-+
-Now, an error message will be displayed in the UI.
-
-== ssh
-
-
-* Support for nio2 backend is removed.
-+
-The nio2 backend is link:https://issues.apache.org/jira/browse/SSHD-252[
-broken in MINA SSHD].  Support is removed until the next release of MINA
-SSHD in which it is fixed.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2424[Issue 2424]:
-Add descriptions on commands that are disabled in slave mode.
-+
-Commands that are disabled on a server running in slave mode were being listed
-with an empty description.
-
-* Remove obsolete commands from slave mode commands list.
-+
-The `approve` and `replicate` commands, which no longer exist, were still being
-listed in the available commands shown when running the ssh `gerrit` command
-without any arguments on a server running in slave mode.
-
-* Remove 'including replication' from the `show-queue` command description.
-+
-The `replication` command is provided by the replication plugin, so it is no
-longer relevant to mention this in the description of a core command.
-
-* Fix aliasing of SSH commands.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2515[Issue 2515]:
-Fix internal server error when updating an existing label with `gerrit review`.
-
-== Replication Plugin
-
-
-* Never replicate automerge-cache commits.
-+
-Commits in the `automerge-cache` namespace are used on the master to
-improve performance of the diff UI.  They are not needed on remote
-mirrors and it is wasteful to replicate them.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2420[Issue 2420]:
-Fix failure to create missing remote repository via git:// protocol.
-+
-When replicating to a mirror over the anonymous git:// protocol and the
-repository did not exist on the remote (i.e. if the remote was offline
-when the repository was originally created), the replication failed with
-a "remote repository error", rather than the expected "no repository".
-
-* Improve info logging related to repository creation and deletion, and
-differentiate between local and remote repository errors.
-
-* Update documentation to clarify replication of refs/meta/config when
-refspec is 'all refs'.
-
-== Upgrades
-
-
-* JGit is upgraded to 3.2.0.201312181205-r
-
-== Documentation
-
-
-* Add missing documentation of the secondary index configuration.
-+
-Document that open and closed changes are indexed in separate indexes,
-and for Lucene indexes the RAM buffer size and maximum buffered documents
-can be configured.
-
-* Correct the Gerrit download link.
-+
-The link on the documentation index was pointing to the Google Code page,
-which has not been used for some time.
-
-* Correct the description of the `revisions` field in the REST API's
-`ChangeInfo` entity.
-
-* Add a link from the plugin documentation to the validation listeners API
-documentation.
-
-* Remove double border around code snippets.
-
-* Add border around tables.
diff --git a/ReleaseNotes/ReleaseNotes-2.8.3.txt b/ReleaseNotes/ReleaseNotes-2.8.3.txt
deleted file mode 100644
index f94dce0..0000000
--- a/ReleaseNotes/ReleaseNotes-2.8.3.txt
+++ /dev/null
@@ -1,32 +0,0 @@
-= Release notes for Gerrit 2.8.3
-
-There are no schema changes from link:ReleaseNotes-2.8.2.html[2.8.2].
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.8.3.war[
-https://www.gerritcodereview.com/download/gerrit-2.8.3.war]
-
-
-== Bug Fixes
-
-* Fix for merging multiple changes with "Cherry Pick", "Merge Always" and
-"Merge If Necessary" strategies.
-+
-If 2 or more changes were pending submit to the same project and branch,
-it was possible for them to all be marked as status "merged" but only some of
-them to actually land into the branch.
-
-
-== Documentation
-
-* Minor fixes in the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8.3/dev-buck.html[
-buck build documentation].
-
-* Clarification of the `commitWithin` setting in the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8.3/config-gerrit.html#__a_id_index_a_section_index[
-Lucene index configuration].
-+
-Configuring the Lucene index to commit after every write can cause
-poor performance of the reindex program.
-
diff --git a/ReleaseNotes/ReleaseNotes-2.8.4.txt b/ReleaseNotes/ReleaseNotes-2.8.4.txt
deleted file mode 100644
index 8aac71c..0000000
--- a/ReleaseNotes/ReleaseNotes-2.8.4.txt
+++ /dev/null
@@ -1,166 +0,0 @@
-= Release notes for Gerrit 2.8.4
-
-There are no schema changes from link:ReleaseNotes-2.8.3.html[2.8.3].
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.8.4.war[
-https://www.gerritcodereview.com/download/gerrit-2.8.4.war]
-
-
-== Bug Fixes
-
-
-=== Secondary Index
-
-
-* Disable `commitWithin` when running Reindex.
-+
-If `commitWithin` was set to a low value, it caused poor performance
-when running the Reindex program on sites with a large amount of changes.
-+
-The `commitWithin` setting is now disabled from within Reindex by overriding
-the configuration with '-1'. Index updates are auto-flushed but not
-auto-committed, which is the least safe but the most efficient for reindexing
-the entire site.
-
-* Fix memory leak in Lucene index.
-+
-`SubIndex.NrtFuture` objects were being added as listeners of `searchManager`
-and never released.
-
-=== Change Screen
-
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2456[Issue 2456]:
-Respect the comment visibility preference in the new change screen.
-+
-The "Expand All" and "Collapse All" settings now work like they did on
-the old change screen.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2538[Issue 2538]:
-Don't show the "Patch File" download for merge commits.
-+
-The patch file download does not work for commits with more than one
-parent (i.e. merges) and results in an error being displayed. Now the
-link is not shown for merge commits; a solution for merge patches will
-be investigated for future releases.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2526[Issue 2526]:
-Hide the `refs/heads/` prefix in branch suggestion list for cherry-picks.
-+
-Regular branches like `refs/heads/stable/` will now be displayed as
-just `stable` in the suggestion list when cherry-picking a change in the
-Web UI.
-
-* Disable the "Save" button after it is pressed when editing the commit
-message.
-+
-The "Save" button was not being disabled, and could be pressed multiple
-times while the message was being saved, resulting in multiple new patch
-sets being created.
-
-* Fix syntax highlighting for shell files in new side-by-side diff.
-
-* Fix inconsistent behavior of diff view when viewing binary files.
-+
-In the new change screen, if the user clicked on a binary file in
-the file list, the unified view was used. Then when navigating to
-a previous or next file that is not binary, the diff view stayed in
-the old unified setting.
-
-* Make the skip bar more user friendly in side-by-side diff.
-+
-The whole "skipped xxx common lines" text is now a link, rather
-than just the number.
-
-* Show previous and next file shortcut keys in new side-by-side
-navigation arrow tooltips.
-+
-In the top right corner of a file the navigation cluster has a
-tooltip on the up arrow but did not show the tooltip on the left
-or right arrows.
-
-=== Plugins
-
-
-* Fix ChangeListener auto-registered implementations.
-+
-Add missing `@ExtensionPoint` in `ChangeListener` so implementors can
-use `@Listen` to register.
-
-* Escape dollar sign in plugin manifest entries.
-+
-Plugins could be built, but not loaded, if they had any manifest entries
-that contained a dollar sign.
-
-=== Misc
-
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2564[Issue 2564],
-link:https://code.google.com/p/gerrit/issues/detail?id=2571[Issue 2571]:
-Emit ref-updated event when editing project access via Web UI.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2557[Issue 2557]:
-By default don't allow admins to create new branches by push.
-+
-When pushing changes it is easy to make a typo in the refspec and in this case
-new branches should not be created. If administrators want to create branches
-by push they should explicitly assign themselves the needed access rights.
-
-* Do not refresh project list if filter did not change.
-+
-The project list was being refreshed on every key event even if the
-filter did not change, e.g. moving the cursor inside the text entry was
-causing the list to update unnecessarily.
-
-* Fix mail thread getting stuck when waiting for response from SMTP server.
-+
-It is now possible to configure the default thread pool size, the size of
-the thread pool for sending emails, and the SMTP server connection timeout.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2215[Issue 2215]:
-Paginate the project list screen.
-+
-The project list screen was taking a long time to render over a large
-amount of projects (1,000+) and with even larger number of projects
-(3,000+), it could make the browser unresponsive.
-+
-The project list screen now uses pagination to resolve this issue. The
-number of projects displayed is determined by the 'Maximum Page Size'
-user preference.
-+
-Option 'S' is added to the projects REST API to support query offset.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2599[Issue 2599]:
-Always auto confirm adding reviewers in `set-reviewers` SSH command.
-+
-If a group contains more than 'addreviewer.maxWithoutConfirmation'
-members, adding it as reviewer to a change requires a confirmation. A
-user should only be asked for the confirmation when reviewers are
-added from the Web UI but not when the `set-reviewers` SSH command is
-used.
-
-* Set uploader to current user in `patchset-created` event upon cherry-picking.
-+
-When using the Web UI (both old and new change screens) to cherry-pick a
-change to a branch that already has this change (e.g. cherry-picking
-on the same branch to get rid of dependencies), the corresponding
-`patchset-created` event had its `patchSet.uploader` set to the change's
-owner instead of the current user. It is now set to the current user,
-so stream-events consumers can properly detect who uploaded the
-rebased patch set.
-
-=== Documentation
-
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1273[Issue 1273]:
-Update the MySQL documentation concerning character sets.
-+
-The setup documentation is updated to mention that there is no need to use
-latin1 encoding if you are using an engine other than MyISAM.
-
-* Use consistent grammatical tense in ssh command descriptions.
-
-* Add more detail about `refs/drafts` in
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8.4/access-control.html[
-access control documentation].
diff --git a/ReleaseNotes/ReleaseNotes-2.8.5.txt b/ReleaseNotes/ReleaseNotes-2.8.5.txt
deleted file mode 100644
index ae30530..0000000
--- a/ReleaseNotes/ReleaseNotes-2.8.5.txt
+++ /dev/null
@@ -1,116 +0,0 @@
-= Release notes for Gerrit 2.8.5
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.8.5.war[
-https://www.gerritcodereview.com/download/gerrit-2.8.5.war]
-
-== Schema Changes and Upgrades
-
-
-* There are no schema changes from link:ReleaseNotes-2.8.4.html[2.8.4].
-
-* SSHD is updated to version 0.11.0.
-+
-See the 'ssh' section of 'Bug Fixes' below for details.
-
-* Bouncycastle is updated to version 1.49.
-+
-*WARNING:* Gerrit is not shipped with Bouncycastle included. To get the
-updated library files, the site must be updated:
-+
-----
-  java -jar gerrit.war init -d site_path
-----
-
-== Bug Fixes
-
-
-=== Secondary Index
-
-
-* Fix deadlocks on index shutdown.
-
-
-=== Change Screen
-
-
-* Only permit current patch set to edit the commit message.
-+
-Do not allow users to replace a more recent patch set with an older
-patch set when there is a race between the web UI and the command
-line git client.
-
-* Prevent draft changes from being abandoned.
-+
-When a draft change was abandoned it was published to all
-users by setting the status to ABANDONED.  Restoring the change
-effectively published the change, as the status was set to NEW.
-
-* Don't show the submit button for draft patch sets.
-+
-The button was enabled for all open changes, but if the patch set
-was a draft, pressing it resulted in an error.
-
-* Only reset the commit message text on cancel.
-+
-Allow the user to begin editing the commit message, dismiss the
-box by clicking outside of it (e.g. to copy part of a file name
-from the Files table), and then re-open the current draft text
-without resetting the box.
-+
-Only reset the box when the user explicitly clicks Cancel.
-
-* Fix failure to load side-by-side diff due to "ISE EditIterator out of bounds"
-error.
-
-=== ssh
-
-* Upgrade SSHD to version 0.11.0.
-+
-Fixes link:https://code.google.com/p/gerrit/issues/detail?id=2406[Issue 2406]:
-"git clone" hangs after 100% resolving deltas with git over SSH.
-+
-Fixes a number of other issues including a
-link:https://issues.apache.org/jira/browse/SSHD-307[null pointer exception]
-that could cause ssh commands to hang.
-
-* Upgrade bouncycastle to version 1.49.
-+
-Required by the SSHD upgrade.
-
-* Re-enable nio2 backend.
-+
-The nio2 backend was disabled in Gerrit version 2.8.4 because of a
-link:https://issues.apache.org/jira/browse/SSHD-252[bug in SSHD].  That bug
-was fixed in SSHD version 0.10.0, so now we can re-enable nio2.
-
-=== Misc
-
-
-* Keep old timestamps during data migration.
-+
-Migrating the change database through schema 77, which was introduced in
-Gerrit 2.6, was causing patch set approval timestamps to be changed.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2607[Issue 2607]:
-Fix incorrect "commit already exists (in the project)" error.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2569[Issue 2569]:
-Enable automatic close changes on `refs/meta/config`.
-+
-Changes pushed for review on `refs/meta/config` and then force pushed
-into the repository were not being automatically closed.
-
-* Do not refresh group list if filter did not change.
-+
-The group list was being refreshed on every key event even if the
-filter did not change, e.g. moving the cursor inside the text entry was
-causing the list to update unnecessarily.
-
-* Paginate the group list screen.
-+
-The group list screen now uses pagination. The number of groups displayed is
-determined by the 'Maximum Page Size' user preference.
-+
-Option 'S' is added to the groups REST API to support query offset.
-
diff --git a/ReleaseNotes/ReleaseNotes-2.8.6.1.txt b/ReleaseNotes/ReleaseNotes-2.8.6.1.txt
deleted file mode 100644
index 81e7297..0000000
--- a/ReleaseNotes/ReleaseNotes-2.8.6.1.txt
+++ /dev/null
@@ -1,20 +0,0 @@
-= Release notes for Gerrit 2.8.6.1
-
-There are no schema changes from link:ReleaseNotes-2.8.6.html[2.8.6].
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.8.6.1.war[
-https://www.gerritcodereview.com/download/gerrit-2.8.6.1.war]
-
-== Bug Fixes
-
-* The fix in 2.8.6 for the merge queue race condition caused a regression
-in database transaction handling.
-
-* The fix in 2.8.6 for the LIMIT clause caused a regression in Oracle
-database support.
-
-
-== Updates
-
-* gwtorm is updated to 1.7.3
diff --git a/ReleaseNotes/ReleaseNotes-2.8.6.txt b/ReleaseNotes/ReleaseNotes-2.8.6.txt
deleted file mode 100644
index a810ad0..0000000
--- a/ReleaseNotes/ReleaseNotes-2.8.6.txt
+++ /dev/null
@@ -1,44 +0,0 @@
-= Release notes for Gerrit 2.8.6
-
-There are no schema changes from link:ReleaseNotes-2.8.5.html[2.8.5].
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.8.6.war[
-https://www.gerritcodereview.com/download/gerrit-2.8.6.war]
-
-*Warning*: Support for MySQL's MyISAM storage engine is discontinued.
-Only transactional storage engines are supported.
-
-== Bug Fixes
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2034[Issue 2034],
-link:https://code.google.com/p/gerrit/issues/detail?id=2383[Issue 2383],
-link:https://code.google.com/p/gerrit/issues/detail?id=2702[Issue 2702]:
-Fix race condition in change merge queue when using Cherry-Pick submit
-strategy.
-+
-There was a race in the merge queue between changes submitted via
-the UI, and merges scheduled by the background merge queue reload.
-+
-This resulted in multiple submit actions being scheduled, leading
-to corrupt changes.
-+
-Execute cherry-pick submit DML operations in a database transaction
-boundaries. In combination with implemented transaction management
-for Jdbc dialects it solves the problem recovering from collisions
-between interactive actions and background jobs.
-
-* In gwtorm the LIMIT clause was only honored when followed by a
-constant integer.
-+
-When followed by a placeholder "?" it wasn't included in the generated database
-query. This caused poor performance when moving to the next change page for very
-big projects.
-
-* Fix sporadic SSHD handshake failures
-(link:https://issues.apache.org/jira/browse/SSHD-330[SSHD-330]).
-
-== Updates
-
-* gwtorm is updated to 1.7.1
-* sshd is updated to 0.11.1-atlassian-1
diff --git a/ReleaseNotes/ReleaseNotes-2.8.txt b/ReleaseNotes/ReleaseNotes-2.8.txt
deleted file mode 100644
index 472f0dc..0000000
--- a/ReleaseNotes/ReleaseNotes-2.8.txt
+++ /dev/null
@@ -1,808 +0,0 @@
-= Release notes for Gerrit 2.8
-
-
-Gerrit 2.8 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.8.war[
-https://www.gerritcodereview.com/download/gerrit-2.8.war]
-
-
-== Schema Change
-
-
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* Upgrading to 2.8.x requires the server be first upgraded to 2.1.7 (or
-a later 2.1.x version), and then to 2.8.x.  If you are upgrading from 2.2.x.x or
-later, you may ignore this warning and upgrade directly to 2.8.x.
-
-*WARNING:* The replication plugin now automatically creates missing repositories
-on the destination if during the replication of a ref the target repository is
-found to be missing. This is a change in behavior of the replication plugin. To go
-back to the old behavior, set the parameter `remote.NAME.createMissingRepositories`
-in the `replication.config` file to `false`.
-
-*WARNING:* The deprecated `approve` alias for the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/cmd-review.html[
-review] SSH command has been removed. This is important for all users
-of the Jenkins link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[
-Gerrit Trigger Plugin] since this plugin by default uses the `approve`
-command to vote and comment on changes in Gerrit. If you use the Gerrit
-Trigger Plugin, go to its global configuration in Jenkins and adapt the
-Gerrit commands to use the `review` command instead of the `approve`
-command.
-
-*WARNING:* The new change screen only displays download commands if the
-`download-commands` core plugin or any other plugin providing download
-commands is installed. The `download-commands` plugin provides the
-standard download schemes and commands. It is packaged together with
-Gerrit and can be installed during the
-link:https://gerrit-review.googlesource.com/Documentation/pgm-init.html[
-site initialization].
-
-
-== Release Highlights
-
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/intro-change-screen.html[
-New change screen] with completely redesigned UI and fully using the REST API.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-gerrit.html#__a_id_index_a_section_index[
-Secondary indexing with Lucene and Solr].
-
-* Lots of new link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api.html[
-REST API endpoints].
-
-* New
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/dev-plugins.html#ui_extension[
-UI extension] and
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/js-api.html[
-JavaScript API] for plugins.
-
-* New build system using Facebook's link:http://facebook.github.io/buck/[Buck].
-
-* New core plugin: Download Commands.
-
-
-== New Features
-
-=== Build
-
-* Gerrit is now built with
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/dev-buck.html[
-Buck].
-
-* Documentation is now built with Buck and link:http://asciidoctor.org[Asciidoctor].
-
-
-=== Indexing and Search
-
-Gerrit can be configured to use a
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-gerrit.html#__a_id_index_a_section_index[
-secondary index] with Lucene or Solr.
-
-Existing search operations use the secondary index, when enabled, to increase
-performance and reduce resource usage.
-
-The following additional search operations are possible when secondary indexing
-is enabled:
-
-* New
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/user-search.html#comment[
-`comment` search operator].
-
-* The link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/user-search.html#file[
-`file` operator] can be used to find changes on the specified file.
-
-* Regular expressions are allowed in `file` searches.
-
-
-*WARNING:* After enabling the secondary index, the index must be built using the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/pgm-reindex.html[
-reindex program] before restarting the Gerrit server.
-
-
-=== Configuration
-
-* Project owners can define `receive.maxObjectSizeLimit` in the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-gerrit.html#receive.maxObjectSizeLimit[
-project configuration] to further reduce the global setting.
-
-* Site administrators can define a
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-mail.html#_footer_vm[
-footer template] that will be appended to the end of all outgoing emails after
-the 'ChangeFooter' and 'CommentFooter'.
-
-* New `topic-changed` hook and stream event is fired when a change's topic is
-edited from the Web UI or via a REST API.
-
-* New options `--list-plugins` and `--install-plugins` on the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/pgm-init.html[
-site initialization command].
-
-* New `auth.httpDisplaynameHeader` and `auth.httpEmailHeader` in the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-gerrit.html#__a_id_auth_a_section_auth[
-authentication configuration].
-+
-When using HTTP-based authentication, the SSO can be delegated to check not only
-the user credentials but also to fetch the full user-profile.
-+
-With the config properties `auth.httpDisplaynameHeader` and `auth.httpEmailHeader`
-it is possible to configure the name of the headers used for propagating this extra
-information and enforce them on the user profile during login and beyond.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-gerrit.html#__a_id_httpd_a_section_httpd[
-Customizable registration page for HTTP authentication].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-gerrit.html#__a_id_httpd_a_section_httpd[
-Configurable external `robots.txt` file].
-
-* Support for
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/database-setup.html#createdb_oracle[
-Oracle database].
-
-* New bash completion script for autocompletion of parameters to the gerrit.sh wrapper.
-
-* The site can be
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-auto-site-initialization.html[
-auto-initialized on server startup].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-gerrit.html#httpd.filterClass[
-Configurable filtering of HTTP traffic through Gerrit's HTTP protocol].
-
-* Labels can be
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-labels.html#httpd.label_copyAllScoresIfNoCodeChange[
-configured to copy scores forward to new patch sets if there is no code change].
-
-* Labels can be
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-labels.html#httpd.label_copyAllScoresOnTrivialRebase[
-configured to copy scores forward to new patch sets for trivial rebases].
-
-=== Web UI
-
-
-==== Global
-
-* The change status is shown in a separate column on dashboards and search results.
-
-==== Change Screens
-
-
-* New change screen with completely redesigned UI, using the REST API.
-+
-Site administrators can
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-gerrit.html#gerrit.changeScreen[
-configure which change screen is shown by default].
-+
-Users can choose which one to use in their personal preferences, either using
-the site default or explicitly choosing the old one or new one.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=141[Issue 141]:
-In the new change screen, comments can be added on a range of lines.
-
-* New button to cherry-pick the change to another branch.
-
-* When issuing a rebase via the Web UI, the committer is now the logged in
-  user, rather than "Gerrit Code Review".
-+
-If the user has more than one email address, the preferred email address will
-be used.
-
-* Default user's full name to git committer name if user has not configured a
-full name in their profile.
-
-* Include comment author attributes in comment panels.
-+
-Comment author's email address and name are included as attributes in comment
-panels.  This makes it easier to filter out CI-based comments using user
-scripts.
-
-* Copy reviewed flag to new patch sets for identical files.
-+
-If a user has already seen and reviewed a file, the 'reviewed' flag is forwarded
-on to the next patch set when the content of the file in the next patch set is
-identical to the reviewed file.
-
-* "Uploaded Patch Set 1" change message is added on changes when they
-are uploaded.
-
-
-=== REST API
-
-* Several new link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api.html[
-REST API endpoints] are added.
-
-* REST views can determine how long their response should be cached.
-
-* REST views can handle 'HTTP 422 Unprocessable Entity' responses.
-
-==== Access Rights
-
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-access.html#list-access[
-List access rights for project(s)]
-
-==== Accounts
-
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#create-account[
-Create account]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#get-account-name[
-Get account full name]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#set-account-name[
-Set account full name]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#delete-account-name[
-Delete account full name]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#list-account-emails[
-List account email addresses]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#get-account-email[
-Get account email address]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#set-preferred-email[
-Set account preferred email address]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#create-account-email[
-Create account email]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#delete-account-email[
-Delete account email]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#get-active[
-Get account state]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#set-active[
-Set account state to active]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#delete-active[
-Set account state to inactive]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#get-http-password[
-Get account HTTP password]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#set-http-password[
-Set or generate account HTTP password]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#delete-http-password[
-Delete account HTTP password]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#list-ssh-keys[
-List account SSH keys]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#get-ssh-key[
-Get account SSH key]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#add-ssh-key[
-Add account SSH key]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#delete-ssh-key[
-Delete account SSH key]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#get-username[
-Get account username]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#get-starred-changes[
-Get starred changes]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#star-change[
-Star change]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#unstar-change[
-Unstar change]
-
-==== Changes
-
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-changes.html#rebase-change[
-Rebase change]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-changes.html#cherry-pick[
-Cherry-pick revision]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-changes.html#get-content[
-Get content of a file in a revision]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-changes.html#get-patch[
-Get revision as a formatted patch]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-changes.html#get-diff[
-Get diff of a file in a revision]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-changes.html#get-commit[
-Get parsed commit of a revision]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-changes.html#publish-draft-change[
-Publish draft change]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-changes.html#delete-draft-change[
-Delete draft change]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-changes.html#suggest-reviewers[
-Suggest reviewers]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-changes.html#get-included-in[
-Get included in]
-
-
-==== Config
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-config.html#get-capabilities[
-Get capabilities]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-config.html#get-version[
-Get version] (of the Gerrit server)
-
-
-==== Projects
-
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-projects.html#list-branches[
-List branches]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-projects.html#get-branch[
-Get branch]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-projects.html#create-branch[
-Create branch]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-projects.html#delete-branch[
-Delete branch]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-projects.html#list-child-projects[
-List child projects]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-projects.html#get-child-project[
-Get child project]
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-projects.html#set-config[
-Set configuration]
-
-
-=== Capabilities
-
-
-New global capabilities are added.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/access_control.html#capability_generateHttpPassword[
-Generate Http Password] allows non-administrator users to generate HTTP
-passwords for users other than themselves.
-+
-This capability would typically be assigned to a non-interactive group
-to be able to generate HTTP passwords for users from a tool or web service
-that uses the Gerrit REST API.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/access_control.html#capability_runAs[
-Run As] allows users to impersonate other users by setting the `X-Gerrit-RunAs`
-HTTP header on REST API calls.
-+
-Site administrators do not inherit this capability;  it must be granted
-explicitly.
-
-
-=== Emails
-
-* The `RebasedPatchSet` template is removed.  Email notifications for rebased
-changes are now sent with the `ReplacePatchSet` template.
-
-* Comment notification emails now include context of comments that are replied
-to, and links to the file(s) in which comments are made.
-
-
-=== Plugins
-
-
-==== Global
-
-
-* Plugins may now contribute buttons to various parts of the UI using the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/dev-plugins.html#ui_extension[
-UI extension] and
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/js-api.html[
-JavaScript API].
-
-* Plugins may now provide an 'About' section on their documentation index page.
-
-* Plugins may now provide separate sections for REST API and servlet
-documentation on their index page.
-
-* Plugins may now provide
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-validation.html#pre-merge-validation[
-pre-merge validation steps].
-
-* Plugins may now provide
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/dev-plugins.html#capabilities[
-Global capabilities].
-
-* Plugins may now
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/dev-plugins.html#plugin_name[
-define their own name] and get the name injected at runtime.
-
-* The "hello world" plugin is replaced with the "cookbook plugin" which has more
-examples of the plugin API's usage.
-
-* Plugins may now trigger and listen to a "project deleted"
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/dev-plugins.html#events[
-event].
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2101[Issue 2101]:
-Plugins implementing LifecycleListener can use auto registration.
-
-* Plugins may bind REST endpoints with empty view names.
-
-* Plugins may now provide
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/dev-plugins.html#top-menu-extensions[
-entries in Gerrit's top menu].
-
-* Plugins may now
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/dev-plugins.html#stream-events[
-send events to the events stream].
-
-* Plugins may now bind multiple SSH commands to the same implementation class.
-
-* Plugins may now provide
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/dev-plugins.html#download-commands[
-download schemes and download commands].
-+
-Commonly used download schemes and commands are moved out of core
-Gerrit and are now implemented by a new core plugin, `download-commands`.
-
-
-
-==== Commit Message Length Checker
-
-
-* Commits whose subject or body length exceeds the limit can be rejected.
-
-==== Replication
-
-* Automatically create missing repositories on the destination.
-+
-If during the replication of a ref the target repository is found to be missing,
-the repository is automatically created.
-+
-This is a change in behavior of the replication plugin. To go back to the old
-behavior, set the parameter `remote.NAME.createMissingRepositories` in the
-`replication.config` file to `false`.
-
-* Support for replication of project deletions.
-+
-The replication plugin can now be configured to listen to project deletion events
-and to replicate the project deletions. By default project deletions are *not*
-replicated.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1880[Issue 1880]:
-Make `{name}` placeholder optional when replicating a single project.
-+
-The `{$name}` placeholder is optional when replicating a single project,
-allowing a single project to be replicated under a different name.
-
-* Project names can be matched with wildcard or regex patterns in `replication.config`.
-
-* The `replication start` command does not exit until replication is finished
-when the `--wait` option is used.
-
-* The `replication start` command displays a summary of the replication status.
-
-* Retry counts are added to replication task names, so they can be seen in the
-output of the `show-queue` command.
-
-* The `remoteNameStyle` option can be set to `basenameOnly` to replicate projects
-using only the basename on the target server.
-
-* The `startReplication` global capability is now provided by the plugin.
-
-* Pushes to each destination URI are serialized.
-+
-Scheduling a retry to avoid collision with an in-flight push is differentiated
-from a retry due to a transport error.  In the case of collision avoidance, the
-job is rescheduled according to the replication delay, rather than the retry
-delay.
-
-
-=== ssh
-
-
-* The `commit-msg` hook installation command is now
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-gerrit.html#gerrit.installCommitMsgHookCommand[
-configurable].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/cmd-ls-members.html[
-New `ls-members` command].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/cmd-set-members.html[
-New `set-members` command].
-+
-New command to manipulate group membership. Members can be added or removed
-and groups can be included or excluded in one specific group or number of groups.
-
-* The full commit message is now included in the data sent by the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/cmd-stream-events.html[
-`stream-events` command].
-
-* The link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/cmd-show-queue.html[
-`show-queue` command] now shows the time that a task was added to the queue.
-
-* The deprecated `approve` alias of the `review` command is removed.
-
-* The 'CHANGEID,PATCHSET' format for specifying a patch set in the `review` command
-is no longer considered to be a 'legacy' feature that will be removed in future.
-
-=== Daemon
-
-
-* Add `--init` option to Daemon to initialize site on daemon start.
-+
-The `--init` option will also upgrade an already existing site and is processed in
-non-interactive (batch) mode.
-
-
-== Bug Fixes
-
-
-=== General
-
-
-* Use the parent change on the same branch for rebases.
-+
-Since there can be multiple changes with the same commit on different branches,
-use the parent change on the same branch during rebase.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=600[Issue 600]:
-Fix change stuck in SUBMITTED state but actually merged.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1699[Issue 1699]:
-Fix handling of projects with trailing ".git" suffix.
-
-* Limit retrying of submitted changes to 12 hours.
-
-* Don't allow project owners to delete branches if force push is blocked.
-
-* Allow usernames to begin with digit.
-
-* Verify access to source ref during add branch operation.
-+
-Previously Gerrit didn't check access to source ref during add branch
-operation. Because of that users could create a branch from any known
-commit SHA1, even when they didn't have access to that commit.
-
-* Fix Gerrit API sources JAR contents.
-+
-The gerrit-extension-api-X.Y-all-sources.jar did not actually contain any
-sources.
-
-* Generate javadoc for Gerrit Extension and Plugin APIs.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2244[Issue 2244]:
-Update patch status before skipping duplicate emails.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1640[Issue 1640]:
-Catch missing LDAP accounts in group membership.
-
-* Use `rev-parse` to find gitdir when generating commit-msg hook hint.
-
-* Performance Fix: Minimize number of advertisedHaves.
-+
-By filtering the refs before the objectIds are added to advertisedHaves,
-lots of time can be saved when pushing to complex Gits.
-
-
-=== Configuration
-
-
-* Do not persist default project state in `project.config`.
-
-* Honor the `gerrit.canonicalWebUrl` setting when opening the browser after init.
-
-* Fix 'query disabled' error when Query Limit is set.
-
-* Honor the `gerrit.createChangeId` setting from the git config in the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/cmd-hook-commit-msg.html[
-`commit-msg` hook].
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2045[Issue 2045]:
-Define user scope when parsing server config.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1990[Issue 1990]:
-Support optional Certificate Revocation List (CRL) with `CLIENT_SSL_CERT_LDAP`.
-
-* Do not override error and gc logging configuration provided by the
-`-Dlog4j.configuration` parameter.
-
-* Fix JdbcSQLException when numbers are read from cache.
-
-=== Web UI
-
-
-==== Global
-
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1574[Issue 1574]:
-Correctly highlight matches of text in escaped HTML entities in suggestion results.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1996[Issue 1996]:
-The "Keyboard Shortcuts" help popup can be closed by pressing the Escape key.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2013[Issue 2013]:
-Correctly populate the list of watched changes when watching more than one project.
-
-* Display "Working..." when header is hidden.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2125[Issue 2125]:
-Correctly shows '-1' instead of '1' for label score.
-+
-If a user voted '-1', and then another user voted '+1' for a label, the
-label was shown as a red '1' in the change list instead of red '-1'.
-
-==== Change Screens
-
-
-* Default review comment visibility is changed to expand all recent.
-+
-By default all comments within the last week are expanded, rather than
-only the most recent.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1814[Issue 1814]:
-Sort labels alphabetically by name in the approval table.
-
-* Don't add "This patchset was cherry picked to ..." for the same change.
-+
-If a patchset is cherry-picked to the same destination branch and
-ends up on the same change, it does not make sense to add the "This
-patchset was cherry picked to change ..." message.
-+
-In this case, it makes more sense for the message to say "Uploaded
-patch set N" instead.
-
-* Make links appear with consistent colors.
-
-* Prevent duplicate permitted_labels from being shown in labels list.
-
-==== Diff Screens
-
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1233[Issue 1233]:
-Prevent expansion when whole file isn't loaded.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2122[Issue 2122]:
-Show review comments for unchanged files.
-+
-When comparing patch sets and some comment was put in one side,
-that comment was not shown if there was no code changed between
-the two patch sets
-
-==== Project Screens
-
-
-* Only enable the delete branch button when branches are selected.
-
-* Disable the delete branch button while branch deletion requests are
-still being processed.
-
-==== User Profile Screens
-
-
-* The preferred email address field is shown as empty if the user has no
-preferred email address.
-
-
-=== REST API
-
-
-* Support raw input also in POST requests.
-
-* Show granted date for labels/all when using `/changes/`.
-
-* Return all revisions when `o=ALL_REVISIONS` is set on `/changes/`.
-
-=== ssh
-
-
-* The `--force-message` option is removed from the
-The link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/cmd-review.html[
-`review` command].
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1908[Issue 1908]:
-Provide more informative error messages when rejecting updates.
-
-* Remove the limit in the query of patch sets by revision.
-
-* Add `isDraft` in the `patchSet` attribute of `stream-events` data.
-+
-This allows consumers of the event stream to determine whether or not
-the event is related to a draft patch set.
-
-* Normalize the case of review labels submitted via the
-The link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/cmd-review.html[
-`review` command].
-
-* The `@CommandMetaData(descr)` annotation is deprecated in favor of `@CommandMetaData(description)`.
-
-* Improve the error message when rejecting upload for review to a read-only project.
-
-
-=== Plugins
-
-==== Global
-
-* Better error message when a Javascript plugin cannot be loaded.
-
-* Plugin documentation links are opened in a new tab.
-
-* The GitReferenceUpdatedListener.Event API is simplified.
-+
-The Event exposed the getUpdates method which implied that one Event
-could contain updates of more than one reference. However, this feature
-was never used.
-+
-The API is simplified in the sense that one Event now corresponds to
-one ref update only.
-
-* Make plugin servlet's context path authorization aware.
-
-
-==== Review Notes
-
-* Do not try to create review notes for ref deletion events.
-
-* Fix committing the notes from the export command.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2087[Issue 2087]:
-Fix note creation when the same commit exists in another Git repository.
-
-* Improve the export command performance.
-
-* Create review note also when newObjectId already present in another branch.
-
-* Correct documentation of the export command.
-
-=== Emails
-
-* Email notifications are sent for new changes created via actions in the
-Web UI such as cherry-picking or reverting a change.
-
-
-=== Tools
-
-
-* git-exproll.sh: return non-zero on errors
-
-
-== Documentation
-
-
-* The link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/index.html[
-documentation index page] is rewritten in a hierarchical structure.
-
-* Documentation of
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-project-config.txt[
-project configuration] is added.
-
-* Various spelling mistakes are corrected in the documentation and previous
-release notes.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2144[Issue 2144]:
-Documentation of the query operator is fixed.
-
-
-== Upgrades
-
-* Update JGit to 3.1.0.201310021548-r
-* Update gwtorm to 1.7
-* Update guice to 4.0-beta
-* Update guava to 15.0
-* Update H2 to 1.3.173
-* Update bouncycastle to 1.44
-* Update Apache Mina to 2.0.7
-* link:https://code.google.com/p/gerrit/issues/detail?id=2232[Issue 2232]:
-Update Apache SSHD to 0.9.0.201311081
-* asciidoctor 0.1.4 is now required to build the documentation
-* jsr305 library was removed
-* link:https://code.google.com/p/gerrit/issues/detail?id=2232[Issue 2232]:
-Update Jsch to 1.5.0
diff --git a/ReleaseNotes/ReleaseNotes-2.9.1.txt b/ReleaseNotes/ReleaseNotes-2.9.1.txt
deleted file mode 100644
index b584193..0000000
--- a/ReleaseNotes/ReleaseNotes-2.9.1.txt
+++ /dev/null
@@ -1,109 +0,0 @@
-= Release notes for Gerrit 2.9.1
-
-There are no schema changes from link:ReleaseNotes-2.9.html[2.9].
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.9.1.war[
-https://www.gerritcodereview.com/download/gerrit-2.9.1.war]
-
-*WARNING:* When upgrading from version 2.8.4 or older with a site that uses
-Bouncy Castle Crypto, new versions of the libraries will be downloaded. The old
-libraries should be manually removed from site's `lib` folder to prevent the
-startup failure described in
-link:https://code.google.com/p/gerrit/issues/detail?id=3084[Issue 3084].
-
-== Bug Fixes
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2801[Issue 2801]:
-Set default for review SSH command to `notify=ALL`.
-+
-In 2.9 the default was incorrectly set to `notify=NONE`, which prevented
-mail notifications from being sent for review comments that were added by
-build jobs based on the Gerrit Trigger plugin.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2879[Issue 2879]:
-Remove fixed limit of results returned by secondary index query.
-+
-The limit was hard-coded to 1000 results, which overrode the value set in
-the global query limit capability.
-
-* Don't require secondary index when running server in daemon mode.
-+
-The server failed to start if a secondary index was not present when starting
-the daemon in slave mode.
-+
-Now the daemon can be started in slave mode without requiring the index
-to be present.
-+
-The reindex program and the ssh query command are no longer available on
-a server that is running in slave mode.
-
-* Add full names for options on list groups REST API.
-
-* Add full names for options on list projects REST API.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2878[Issue 2878]:
-Make `-S` an alias of `--start` in changes query REST API.
-
-* Run change hooks and ref-updated events after indexing is done.
-+
-The change hooks and ref-updated events were run parallel to the change
-(re)indexing. This meant that the event-stream sent events to the clients
-before the change indexing was finished.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2877[Issue 2877]:
-Fix NullPointerException when ReviewInput's message is empty.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2500[Issue 2500],
-link:https://code.google.com/p/gerrit/issues/detail?id=1748[Issue 1748]:
-Fix replication of tags.
-
-* Fix NullPointerException in `/projects/{name}/children?recursive` when a
-project has a parent project that is does not exist.
-
-* Fix NullPointerException when submitting review with inline comments via REST.
-
-* Improve error logging in MergeabilityChecker.
-
-* Gracefully skip mergeability checking on broken changes.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2861[Issue 2861]:
-Replace "line" with "end_line" when range is given in inline comment.
-+
-Also update the documentation with an example of a range comment.
-
-* Fix mutual exclusivity of --delete and --submit review command options.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2848[Issue 2848]:
-Add support for CSharp syntax highlighting.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2831[Issue 2831]:
-Add missing call to ref-updated hook for submodule updates.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2773[Issue 2773]
-Fix stale dates in committer field.
-
-* Prevent NullPointerException when trying to add an account that doesn't
-exist as a reviewer.
-
-* Fix potential NullPointerException in cherry-pick submit strategy.
-
-* Add `--start` option to skip changes in ssh `query` command.
-
-* Fix loading of javascript plugins when using non-root Gerrit URLs.
-+
-When Gerrit is not on the root URL path the javascript plugins failed to
-load because of the exact matching required on the request URL.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2279[Issue 2279]:
-Display parents for all changes, not only merge commits.
-+
-In the new change screen the parent commit is now also shown for regular
-commits, as well as merge commits. This makes it consistent with the old
-change screen.
-
-* Fix handling of permissions for user-specific refs.
-+
-Push permission granted on a ref using the `${username}` placeholder, for
-example `refs/heads/users/${username}/*`, was not honored if this was the
-only ref on which the user had push permission.
diff --git a/ReleaseNotes/ReleaseNotes-2.9.2.txt b/ReleaseNotes/ReleaseNotes-2.9.2.txt
deleted file mode 100644
index ec5b77e..0000000
--- a/ReleaseNotes/ReleaseNotes-2.9.2.txt
+++ /dev/null
@@ -1,152 +0,0 @@
-= Release notes for Gerrit 2.9.2
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.9.2.war[
-https://www.gerritcodereview.com/download/gerrit-2.9.2.war]
-
-== Important Notes
-
-*WARNING:* There are no schema changes from
-link:ReleaseNotes-2.9.1.html[2.9.1], but when upgrading from an existing site
-that was initialized with Gerrit version 2.6 or later the primary key column
-order will be updated for some tables. It is therefore important to upgrade the
-site with the `init` program, rather than only copying the .war file over the
-existing one.
-
-It is recommended to run the `init` program in interactive mode. Warnings will
-be suppressed in batch mode.
-
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* When upgrading from version 2.8.4 or older with a site that uses
-Bouncy Castle Crypto, new versions of the libraries will be downloaded. The old
-libraries should be manually removed from site's `lib` folder to prevent the
-startup failure described in
-link:https://code.google.com/p/gerrit/issues/detail?id=3084[Issue 3084].
-
-== Bug Fixes
-
-=== ssh
-
-* Update SSHD to 0.13.0.
-+
-This fixes link:https://issues.apache.org/jira/browse/SSHD-348[SSHD-348] which
-was causing ssh threads allocated to stream-events clients to get stuck.
-+
-Also update SSHD Mina to 2.0.8 and Bouncycastle to 1.51.
-
-=== Database
-
-* Update gwtorm to 1.14.
-+
-The primary key column order for compound keys was wrong for some Gerrit
-database tables. This caused poor performance for those SQL queries which rely
-on using a prefix of the primary key column sequence in their WHERE conditions.
-+
-This version of gwtorm fixes the issue for new sites. For existing sites that
-were initialized with Gerrit version 2.6 or later, the primary key column
-order will be fixed during initialization when upgrading to 2.9.2.
-
-=== Secondary Index
-
-* Fix "400 cannot create query for index" error in "Conflicts With" list.
-+
-The new
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9.2/config-gerrit.html#index.defaultMaxClauseCount[
-index.defaultMaxClauseCount] setting allows to increase the BooleanQuery limit
-for the Lucene index.
-+
-Raising the limit avoids failing a query with `BooleanQuery.TooManyClauses`,
-preventing users from seeing a "400 cannot create query for index" error
-in the "Conflicts With" section of the change screen.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2996[Issue 2996]:
-Delete a change from the index if it is not in the database.
-+
-If for some reason the secondary index is out of date, i.e. a change was deleted
-from the database but wasn't deleted from the secondary index, it was impossible
-to re-index (remove) that change.
-+
-Automatically remove the change from the secondary index if it doesn't exist in
-the database. If a user clicks on search result from a stale change, they will
-get a 404 page and the change will be removed from the index.
-
-=== Change Screen
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2964[Issue 2964]:
-Fix comment box font colors of dark CodeMirror themes.
-+
-When using a dark-colored theme, for example "Twilight", the comments were
-shown in a light color on a light background making them unreadable.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2918[Issue 2918]:
-Fix placement of margin column in side-by-side diff.
-+
-The margin was placed approximately 10% too far to the right.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2970[Issue 2970]:
-Fix display of accented characters in side-by-side diff.
-+
-On some browsers, accented characters were not displayed correctly
-because the line was not high enough.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2960[Issue 2960]:
-Show filename in side-by-side diff screen.
-+
-In the old side-by-side diff screen, the name of the file being diffed was shown
-in the window title. This feature was missed in the new side-by-side diff screen.
-
-* Remove 'send email' checkbox from reply box on change screen.
-
-=== Plugins
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=543[Issue 543]
-Replication plugin: Prevent creating repos on extra servers.
-+
-If using a group to replicate only certain repositories, it was possible
-to be in a state where the authGroup is used on some servers but not
-others.  If this happened, Gerrit would create the repository on all
-servers, even if the authGroup would prevent replicating code to it.
-By ensuring the authGroup can see the project first, the repository is
-not created if it's not needed.
-
-=== Security
-
-* Do not throw away bytes from the CNSPRG when generating HTTP passwords.
-+
-The implementation generated LEN bytes of cryptography-safe random data and
-applied base64 encoding on top of that. The base64 transformation, however,
-inflated the size of the data by 33%, and this meant that only 9 bytes of
-randomness were actually used.
-
-* Increase the size of HTTP passwords.
-+
-The length of generated HTTP passwords is increased from 12 to 42 characters.
-
-* Consider rule action while constructing local owners list
-+
-Previously rule action was not considered during computation of the local
-owners list. This meant that members of a group that was given OWNER permission
-with BLOCK or DENY action were considered as project owners.
-
-
-=== Miscellaneous Fixes
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2911[Issue 2911]:
-Fix Null Pointer Exception after a MergeValidationListener throws
-MergeValidationException.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2989[Issue 2989]:
-Fix incorrect submodule subscriptions.
-+
-The gitlinks update failed after deleting a branch in a super project which had
-other branches subscribed to the same submodule branch.
-
-* Fix infinite loop when checking group membership.
-
-* Fix quoted-printable encoding of e-mail addresses.
-+
-The "(Code Review)" part of the e-mail sender name was truncated when the
-author's name was not pure ASCII.
diff --git a/ReleaseNotes/ReleaseNotes-2.9.3.txt b/ReleaseNotes/ReleaseNotes-2.9.3.txt
deleted file mode 100644
index 1b732cb..0000000
--- a/ReleaseNotes/ReleaseNotes-2.9.3.txt
+++ /dev/null
@@ -1,54 +0,0 @@
-= Release notes for Gerrit 2.9.3
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.9.3.war[
-https://www.gerritcodereview.com/download/gerrit-2.9.3.war]
-
-== Important Notes
-
-*WARNING:* There are no schema changes from
-link:ReleaseNotes-2.9.2.html[2.9.2], but when upgrading from an existing site
-that was initialized with Gerrit version 2.6 to version 2.9.1 the primary key
-column order will be updated for some tables. It is therefore important to
-upgrade the site with the `init` program, rather than only copying the .war file
-over the existing one.
-
-It is recommended to run the `init` program in interactive mode. Warnings will
-be suppressed in batch mode.
-
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* When upgrading from version 2.8.4 or older with a site that uses
-Bouncy Castle Crypto, new versions of the libraries will be downloaded. The old
-libraries should be manually removed from site's `lib` folder to prevent the
-startup failure described in
-link:https://code.google.com/p/gerrit/issues/detail?id=3084[Issue 3084].
-
-== Bug Fixes
-
-*Downgrade SSHD to 0.9.0-4-g5967cfd*
-
-In Gerrit version 2.9.2 SSHD was upgraded to 0.13.0 which included a fix for
-link:https://issues.apache.org/jira/browse/SSHD-348[SSHD-348 (SSH thread pool
-exhaustion)].
-
-It turned out that SSHD 0.13.0 still suffers from this issue, which causes
-problems for users of the stream-events in Gerrit 2.9.2.
-
-SSHD 0.9.0 is known to be free from this particular issue, but we cannot
-downgrade to that version because it includes some other known issues:
-
-* link:https://issues.apache.org/jira/browse/SSHD-254[SSHD-254 ('authenticated
-with partial success' error)]
-* link:https://issues.apache.org/jira/browse/SSHD-330[SSHD-330 (sporadic
-handshake failures)].
-
-SSHD version 0.9.0-4-g5967cfd is based on 0.9.0 and includes fixes for SSHD-254
-and SSHD-330.
-
-Due to the downgrade of SSHD, the following libraries are also downgraded:
-
-* Bouncycastle from 1.51 to 1.49
-* Mina Core from 2.0.8 to 2.0.7
diff --git a/ReleaseNotes/ReleaseNotes-2.9.4.txt b/ReleaseNotes/ReleaseNotes-2.9.4.txt
deleted file mode 100644
index e2ad6ac..0000000
--- a/ReleaseNotes/ReleaseNotes-2.9.4.txt
+++ /dev/null
@@ -1,36 +0,0 @@
-= Release notes for Gerrit 2.9.4
-
-Download:
-link:https://www.gerritcodereview.com/download/gerrit-2.9.4.war[
-https://www.gerritcodereview.com/download/gerrit-2.9.4.war]
-
-== Important Notes
-
-*WARNING:* There are no schema changes from
-link:ReleaseNotes-2.9.3.html[2.9.3], but when upgrading from an existing site
-that was initialized with Gerrit version 2.6 to version 2.9.1 the primary key
-column order will be updated for some tables. It is therefore important to
-upgrade the site with the `init` program, rather than only copying the .war file
-over the existing one.
-
-It is recommended to run the `init` program in interactive mode. Warnings will
-be suppressed in batch mode.
-
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* When upgrading from version 2.8.4 or older with a site that uses
-Bouncy Castle Crypto, new versions of the libraries will be downloaded. The old
-libraries should be manually removed from site's `lib` folder to prevent the
-startup failure described in
-link:https://code.google.com/p/gerrit/issues/detail?id=3084[Issue 3084].
-
-== Bug Fixes
-
-* Update JGit to 3.4.2.201412180340-r
-+
-This JGit version mitigates
-link:http://article.gmane.org/gmane.linux.kernel/1853266[CVE-2014-9390]. See the
-link:https://projects.eclipse.org/projects/technology.jgit/releases/3.4.2[JGit release notes]
-for further details.
diff --git a/ReleaseNotes/ReleaseNotes-2.9.txt b/ReleaseNotes/ReleaseNotes-2.9.txt
deleted file mode 100644
index c026914..0000000
--- a/ReleaseNotes/ReleaseNotes-2.9.txt
+++ /dev/null
@@ -1,725 +0,0 @@
-= Release notes for Gerrit 2.9
-
-
-Gerrit 2.9 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.9.war[
-https://www.gerritcodereview.com/download/gerrit-2.9.war]
-
-*WARNING:* Support for Java 1.6 has been discontinued.
-As of Gerrit 2.9, Java 1.7 is required.
-
-Gerrit 2.9 includes the bug fixes done with
-link:ReleaseNotes-2.8.1.html[Gerrit 2.8.1],
-link:ReleaseNotes-2.8.2.html[Gerrit 2.8.2],
-link:ReleaseNotes-2.8.3.html[Gerrit 2.8.3],
-link:ReleaseNotes-2.8.4.html[Gerrit 2.8.4],
-link:ReleaseNotes-2.8.5.html[Gerrit 2.8.5],
-link:ReleaseNotes-2.8.6.html[Gerrit 2.8.6] and
-link:ReleaseNotes-2.8.6.1.html[Gerrit 2.8.6.1].
-These bug fixes are *not* listed in these release notes.
-
-== Important Notes
-
-
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-  java -jar gerrit.war reindex --recheck-mergeable -d site_path
-----
-
-*WARNING:* Upgrading to 2.9.x requires the server be first upgraded to 2.1.7 (or
-a later 2.1.x version), and then to 2.9.x.  If you are upgrading from 2.2.x.x or
-later, you may ignore this warning and upgrade directly to 2.9.x.
-
-*WARNING:* When upgrading from version 2.8.4 or older with a site that uses
-Bouncy Castle Crypto, new versions of the libraries will be downloaded. The old
-libraries should be manually removed from site's `lib` folder to prevent the
-startup failure described in
-link:https://code.google.com/p/gerrit/issues/detail?id=3084[Issue 3084].
-
-
-*WARNING:* Support for query via the SQL index is removed. The usage of
-a secondary index is now mandatory.
-
-*WARNING:* The `sortkey` and `sortkey_prev` options on the query changes
-REST endpoint are link:#sortkey-deprecation[deprecated].
-
-*WARNING:* The new change screen only displays download commands if the
-`download-commands` core plugin or any other plugin providing download
-commands is installed. The `download-commands` plugin provides the
-standard download schemes and commands. It is packaged together with
-Gerrit and can be installed, or upgraded, during the
-link:https://gerrit-review.googlesource.com/Documentation/pgm-init.html[
-site initialization]:
-
-.Installing the plugin for the first time
-- Batch init:
-+
-By default the batch init does *not* install any core plugin. To
-install the `download-commands` plugin during batch init, specify the
-'--install-plugin download-commands' option:
-+
-----
-  $ java -jar gerrit-2.9.war init -d site --batch --install-plugin download-commands
-----
-
-- Interactive init:
-+
-There is a question whether the `download-commands` plugin should be
-installed. To install the plugin the question must be answered with `y`:
-+
-----
-  Install plugin download-commands version v2.9 [y/N]? y
-----
-
-.Upgrading the plugin
-Pay attention that the `download-commands` plugin from Gerrit 2.8 is
-*not* compatible with Gerrit 2.9 and must be upgraded:
-
-- Batch init:
-+
-With the batch init it is *not* possible to upgrade core plugins.
-
-- Interactive init:
-+
-The interactive init asks whether the plugin should be upgraded:
-+
-----
-  Install plugin download-commands version v2.9 [y/N]? y
-  version v2.8.6.1 is already installed, overwrite it [y/N]? y
-----
-
-- Manual upgrade:
-+
-The plugin can be upgraded manually by copying the new plugin jar into
-the site's `plugins` folder.
-
-
-== Release Highlights
-
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2065[Issue 2065]:
-The new change screen is now the default change screen.
-+
-The
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/user-review-ui.html[
-documentation of the new review UI] describes the new screens in detail
-and highlights the important functionality with screenshots.
-+
-Users that are accessing the new change screen for the first time are
-informed about the new change screen by a welcome popup. The welcome
-popup links to the review UI documentation and allows users to go back
-to the old change screen.
-
-
-== New Features
-
-
-=== Web UI
-
-
-==== Global
-
-* Project links by default link to the project dashboard.
-
-
-==== New Change Screen
-
-
-* The new change screen is now the default change screen.
-
-* The layout was changed so that the focus is now on the commit
-message, the change ID and the change status.
-
-* Draft comments are displayed in the reply box.
-+
-There are links to navigate to the inline comments which can be used if
-a comment needs to be edited.
-
-* New inline comments from other users, that were published after the
-current user last reviewed this change, are highlighted in bold.
-
-* New summary comments from other users, that were published after the
-current user last reviewed this change, are automatically expanded in
-the change history.
-+
-The support for the old comment visibility strategy is discontinued.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=93[Issue 93]:
-Inline comments are shown in the change history.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=592[Issue 592]:
-A reply icon is shown on each change message.
-
-* Quoting is possible when replying to a comment.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2313[Issue 2313]:
-Show whether a related change is merged or old.
-
-* New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/user-review-ui.html#related-changes[
-Related Changes] tabs:
-** `Cherry-Picks`
-** `Same Topic`
-** `Conflicts With`
-
-* The title of the `Patch Sets` drop-down panel shows the number of the
-currently viewed patch set and the total number of patch sets, in the
-form: "current patch set/number of patch sets".
-
-* The currently viewed patch set is displayed in the `Patch Sets` title.
-
-* Keyboard shortcuts to navigate to next/previous patch set.
-
-* Support `[`, `/` and `]` keys to navigate between files in a cycle.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2078[Issue 2078]:
-Show a tooltip on reviewers indicating on which labels they can vote.
-
-* The `Submit` button is enabled even if the change is not mergeable.
-+
-This allows to do the conflict resolution for a change series in a
-single merge commit and submit the changes in reverse order.
-
-* New `Open All` button in files header.
-
-* If a merge commit is viewed this is highlighted by an icon. In this
-case the parent commits are also shown.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2191[Issue 2191]:
-New copy-to-clipboard button for commit ID.
-
-
-==== New Side-by-Side Diff Screen
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=348[Issue 348]:
-The lines of a patch file are linkable.
-+
-These links can be used to directly link to certain inline comments.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2395[Issue 2395]:
-The line length preference is used to draw a margin line at that many
-columns of text.
-+
-This allows a user to configure their preferred width (e.g. 80 columns
-or 100 columns) and see the margin, making it easier to identify lines
-that run over that width.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2530[Issue 2530]:
-All diff preferences are honored.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=148[Issue 148]:
-The full file path is shown.
-
-
-==== Change List / Dashboards
-
-* The `Status` column shows `Merge Conflict` for changes that are not
-mergeable.
-
-* A new `Size` column shows the change size as a colored bar.
-** The user preference `Show Change Sizes As Colored Bars In Changes Table`
-can be disabled to get the size information displayed as text.
-** The number of changed lines by which a change is considered as a
-large change can be
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/config-gerrit.html#change.largeChange[
-configured].
-
-* Support to drill down into dashboard section.
-+
-Clicking on the section title executes the query of this section
-without the `limit` operator.
-
-
-==== Project Screens
-
-* The general project screen provides a copyable clone command that
-automatically installs the `commit-msg` hook.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=562[Issue 562]:
-Project owners can change `HEAD` from the project branches screen.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1298[Issue 1298]:
-Administrators can change the parent project from the project access
-screen; other users can save changes to the parent project for review
-and get the change approved by an administrator.
-
-* The project list displays icons for projects that are read only or
-hidden.
-
-* The Git garbage collection can be triggered from the general project
-screen if the user has the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/access-control.html#capability_runGC[
-Run Garbage Collection] global capability.
-
-
-==== User Preferences
-
-* Users can choose the UK date format to render dates and timestamps in
-the UI.
-
-
-=== Secondary Index
-
-* Support for query via the SQL index is removed. The usage of
-a secondary index is now mandatory.
-
-* New `--recheck-mergeable` option on the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/pgm-reindex.html[
-reindex] program.
-
-=== ssh
-
-* New `--notify` option on the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/cmd-review.html[
-review] command allowing to control when email notifications should be
-sent.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1752[Issue 1752]:
-New `--branch` option on the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/cmd-review.html[
-review] command.
-
-* New `--all-reviewers` option on the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/cmd-query.html[
-query] command allowing query results to include information about all
-reviewers added on the change.
-
-* New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/cmd-apropos.html[
-apropos] command to search the Gerrit documentation.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1156[Issue 1156]:
-New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/cmd-create-branch.html[
-create-branch] command.
-
-=== REST API
-
-
-==== Changes
-
-
-[[sortkey-deprecation]]
-* Results returned by the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/rest-api-changes.html#list-changes[
-query changes] endpoint are now paginated using offsets instead of sortkeys.
-+
-The `sortkey` and `sortkey_prev` parameters on the endpoint are deprecated.  The
-results are now paginated using the `--limit` (`-n`) option to limit the number
-of results, and the `-S` option to set the start point.
-+
-Queries with sortkeys are still supported against old index versions, to enable
-online reindexing while clients have an older JS version.
-
-==== Projects
-
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/rest-api-projects.html#get-content[
-Get content of a file from HEAD of a branch].
-
-==== Documentation
-
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/rest-api-documentation.html#search-documentation.html[
-Search documentation].
-
-=== Access Rights
-
-
-* New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/access-control.html#capability_viewAllAccounts[
-global capability for viewing all accounts].
-
-* New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/access-control.html#capability_viewPlugins[
-global capability for viewing the list of installed plugins].
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1993[Issue 1993]:
-New `Change Owner` group that allows to assign label permissions to the change owner.
-
-* Support link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/access-control.html#category_submit_on_behalf_of[
-on behalf of for submit].
-
-* Allow service users to access REST API if `auth.gitBasicAuth = true`.
-+
-If link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/config-gerrit.html#auth.gitBasicAuth[
-auth.gitBasicAuth] is set to `true` in the `gerrit.config` file all
-HTTP traffic is authenticated using standard `BasicAuth` and the
-credentials are validated using the same auth method as configured for
-the Gerrit Web UI. E.g. for LDAP this means that users must use their
-LDAP password for Git over HTTP and for accessing the REST API.
-+
-Service users are technical users that were created by the
-`create-account` SSH command. These users only exist in Gerrit and
-hence they do not have any LDAP password. This is why service users
-were not able to make use of the REST API if `auth.gitBasicAuth` was
-set to `true`.
-+
-Now if `auth.gitBasicAuth` is set to `true` users that exist only in
-Gerrit but not in LDAP are authenticated with their HTTP password from
-the Gerrit database.
-
-=== Search
-
-* New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/user-search.html#mergeable[
-is:mergeable] search operator.
-+
-Finds changes that have no merge conflicts and can be merged into the
-destination branch.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2163[Issue 2163]:
-New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/user-search.html#parentproject[
-parentproject] search operator.
-+
-Finds changes in the specified project or in one of its child projects.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2162[Issue 2162]:
-New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/user-search.html#conflicts[
-conflicts] search operator.
-+
-Finds changes that conflict with the specified change.
-
-* New operators for absolute last-updated-on search.
-** link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/user-search.html#before_until[
-before / until]
-** link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/user-search.html#after_since[
-after / since]
-
-* Support exact match on file parts in
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/user-search.html#file[
-file] operator.
-
-* Query shortcuts
-** `o` = `owner`
-** `r` = `reviewer`
-** `p` = `project`
-** `f` = `file`
-
-=== Daemon
-
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/dev-inspector.html[
-Gerrit Inspector]: interactive Jython shell.
-+
-New `-s` option is added to the Daemon to start an interactive Jython shell for inspection and
-troubleshooting of live data of the Gerrit instance.
-
-=== Documentation
-
-
-* The documentation is now
-https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/rest-api-documentation.html#search-documentation.html[
-searchable]:
-+
-On each documentation page there is search box in the right top corner
-that allows to search in the documentation.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/user-review-ui.html[
-Documentation of the new review UI].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/intro-project-owner.html[
-New Project Owner Guide].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/index.html[
-Newly structured documentation index].
-
-
-=== Configuration
-
-* New init step for installing the `Verified` label.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2257[Issue 2257]:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/config-gerrit.html#repository.name.defaultSubmitType[
-Default submit type] for newly created projects can be configured.
-
-* `sshd_log` and `httpd_log` can use log4j configuration.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/config-gerrit.html#change.allowDrafts[
-Draft workflow can be disabled].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/config-project-config.html#receive.checkReceivedObjects[
-Project configuration for checking of received objects].
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2318[Issue 2318]:
-Allow the text of the "Report Bug" link to be configured.
-
-
-=== Misc
-
-* The removal of reviewers and their votes is recorded as a change
-message.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2229[Issue 2229]:
-The change URL is returned on push if the change is updated.
-
-* The topic is included into merge commit messages if all merged
-changes have the same topic.
-
-* Stable CSS class names.
-
-
-=== Plugins
-
-
-* Plugin API to invoke the REST API.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/dev-plugins.html#screen[
-Plugins can add entire screens to Gerrit].
-
-* Plugins can have a
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/dev-plugins.html#settings-screen[
-settings screen] which is linked from plugin list screen.
-
-* Support to edit
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/dev-plugins.html#simple-project-specific-configuration[
-project plugin configuration parameters] in the UI.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/config-gerrit.html#plugins.allowRemoteAdmin[
-Remote plugin administration is by default disabled].
-
-
-==== Extension Points
-
-
-* Extension point to provide a "Message Of The Day".
-
-* Validation for
-** link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/config-validation.html#new-project-validation[
-project creation].
-** link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/config-validation.html#new-group-validation[
-group creation].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/dev-plugins.html#init_step[
-Init steps can do initialization after the site is created].
-** The `All-Projects` `project.config` can be read and edited
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/dev-plugins.html#receive-pack[
-Initialization of ReceivePack].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/dev-plugins.html#post-receive-hook[
-Registration of PostReceiveHooks].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/dev-plugins.html#root-level-commands[
-Registration of root level commands].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/dev-plugins.html#multiple-commands[
-Multiple SSH commands can be bound to the same class].
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/config-gerrit.html#database.dataSourceInterceptorClass[
-DataSource Interception].
-
-
-==== JavaScript Plugins
-
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/js-api.html#self_on[
-JavaScript Callbacks]
-** Gerrit.on(\'history\', f)
-** Gerrit.on(\'submitchange\', f)
-** Gerrit.on(\'showchange\', f)
-
-* `change_plugins` element on the new change screen that allows to
-insert arbitrary HTML fragments from plugins.
-
-
-== Bug Fixes
-
-
-=== Access Rights
-
-
-* Fix possibility to overcome BLOCK permissions.
-
-
-=== Web UI
-
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2652[Issue 2652]:
-Copy label approvals when cherry-picking change to same branch.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2662[Issue 2662]:
-Limit file list in new change screen to files that were touched in new
-patch set.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2308[Issue 2308]:
-Show related changes in new change screen for merged changes if there
-are open descendants.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2635[Issue 2635]:
-Fix copying of download commands by 'Cmd-C' in Safari.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2178[Issue 2178]:
-Fix background of reply box on new change screen getting transparent.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2362[Issue 2362]:
-Show quick approve button only for current patch set.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2405[Issue 2405]:
-Update `Patch Sets` drop-down panel when draft patch set is deleted.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2397[Issue 2397]:
-Fix linkifying of topics that are set to a URL.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2151[Issue 2151]:
-Fix overflowing of long lines in commit message block.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2401[Issue 2401]:
-Fix truncated long lines in new side-by-side diff screen.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2225[Issue 2225]:
-Display larger icons for Prev / Next and Up to Change links on new
-side-by-side diff screen.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2340[Issue 2340]:
-Fix selection in new side-by-side diff screen.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2409[Issue 2409]:
-Show in new side-by-side diff screen updates of submodule links.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2481[Issue 2481]:
-After showing a binary file in the unified diff screen switch back to
-the side-by-side diff screen when the user navigates to the
-next/previous file.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2417[Issue 2417]:
-Respect base diff revision for files REST call.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2654[Issue 2654]:
-Require the user to confirm setting the username.
-+
-Once the username has been set, it cannot be edited. This can cause
-problems for users who accidentally set the wrong username. A
-confirmation dialog now warns the user that setting the username is
-permanent and the username is only set when the user confirms.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=2635[Issue 2635]:
-Fix copying from copyable label in Safari.
-
-
-=== Secondary Index
-
-* Fix Online Reindexing.
-
-* Fix for full-text search with Lucene.
-+
-The full-text search was using a fuzzy query which used the edit
-distance to find terms in the index close to the provided search term.
-This produced bizarre results for queries like "message:1234".
-+
-Instead, use Lucene's QueryBuilder with an analyzer to convert a
-full-text search word/phrase into a phrase query.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2281[Issue 2281]:
-Reindex change after updating commit message.
-
-
-=== REST
-
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2568[Issue 2568]:
-Update description file during `PUT /projects/{name}/config`.
-
-
-=== SSH
-
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2516[Issue 2516]:
-Fix parsing of label name on `review` command.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2440[Issue 2440]:
-Clarify for review command when `--verified` can be used.
-
-
-=== Plugins
-
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2551[Issue 2551]:
-Handle absolute URLs in the top level menu.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2391[Issue 2391]:
-Respect servlet context path in URL for top menu items.
-
-
-=== Other
-
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2382[Issue 2382]:
-Clean left over data migration after removal of TrackingIds table.
-
-
-== Upgrades
-
-* Update JGit to 3.4.0.201405051725-m7
-+
-This upgrade fixes the MissingObjectExceptions in Gerrit that are
-described in link:http://code.google.com/p/gerrit/issues/detail?id=2025[
-issue 2025].
-
-* Update gwtjsonrpc to 1.5
-* Update gwtorm to 1.13
-* Update guava to 16.0
-
-* Update H2 to 1.3.174
-+
-This version includes a fix for an LOB deadlock between reading and
-updating LOB columns. This could lead to a deadlock between web and SSH
-clients as described in
-link:http://code.google.com/p/gerrit/issues/detail?id=2365[issue 2365].
-
-* Update Jetty to 9.1.0.v20131115
-* Update Servlet API to 3.1
-* Update Lucene to 4.6.0
-* Update GWT to 2.6.0
-
-
-== Plugins
-
-=== Replication
-
-* Default push refSpec is changed to `refs/*:refs/*` (non-forced push).
-+
-The default push refSpec for the replication plugin has changed from `forced`
-to `non-forced` push (was `+refs/*:refs/*` and now is `refs/*:refs/*`). This change
-should not impact typical replication topologies where the slaves are read-only
-and can be pushed by their masters only. If you wanted explicitly to overwrite
-all changes on the slaves, you need to add a `push=+refs/*:refs/*` configuration
-entry for each replication target.
-
-* Support replication of HEAD updates.
-
-* Stream events for ref replication.
-
-* Replications failed due to "failed to lock" errors are retried.
-
-* Configuration changes can be detected and replication is
-automatically restarted.
-
-=== Issue Tracker System plugins
-
-*WARNING:* The `hooks-*` plugins (`plugins/hooks-bugzilla`,
-`plugins/hooks-jira` and `plugins/hooks-rtc`) are deprecated with
-Gerrit 2.9.
-
-There are new plugins for the integration with Bugzilla, Jira and IBM
-Rational Team Concert:
-
-* link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-bugzilla[plugins/its-bugzilla]
-* link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-jira[plugins/its-jira]
-* link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-rtc[plugins/its-rtc]
-
-The new issue tracker system plugins have a common base which is
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-base[plugins/its-base].
-
-The configuration of the new plugins is slightly different than the
-configuration of the old plugins because they use different section
-names in the Gerrit configuration. For easy migration the new plugins
-have an init step that allows to take over the configuration from the
-old plugins during the Gerrit initialization phase.
-
-New Features:
-
-* The issue tracker integration can be enabled/disabled per project.
-* Parent projects can enforce the issue tracker integration for their
-  child projects.
-* It can be configured for which branches of a project the issue
-  tracker integration is enabled.
-* Whether the issue tracker integration is enabled/disabled for a
-  project can be changed from the ProjectInfoScreen in the Gerrit
-  WebUI.
diff --git a/ReleaseNotes/index.txt b/ReleaseNotes/index.txt
deleted file mode 100644
index 9c28697..0000000
--- a/ReleaseNotes/index.txt
+++ /dev/null
@@ -1,159 +0,0 @@
-= Gerrit Code Review - Release Notes
-
-[[s2_13]]
-== Version 2.13.x
-* link:https://www.gerritcodereview.com/releases/2.13.md#2.13.2[2.13.2]
-* link:https://www.gerritcodereview.com/releases/2.13.md#2.13.1[2.13.1]
-* link:https://www.gerritcodereview.com/releases/2.13.md[2.13]
-
-[[s2_12]]
-== Version 2.12.x
-* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.5[2.12.5]
-* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.4[2.12.4]
-* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.3[2.12.3]
-* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.2[2.12.2]
-* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.1[2.12.1]
-* link:https://www.gerritcodereview.com/releases/2.12.md[2.12]
-
-[[s2_11]]
-== Version 2.11.x
-* link:ReleaseNotes-2.11.10.html[2.11.10]
-* link:ReleaseNotes-2.11.9.html[2.11.9]
-* link:ReleaseNotes-2.11.8.html[2.11.8]
-* link:ReleaseNotes-2.11.7.html[2.11.7]
-* link:ReleaseNotes-2.11.6.html[2.11.6]
-* link:ReleaseNotes-2.11.5.html[2.11.5]
-* link:ReleaseNotes-2.11.4.html[2.11.4]
-* link:ReleaseNotes-2.11.3.html[2.11.3]
-* link:ReleaseNotes-2.11.2.html[2.11.2]
-* link:ReleaseNotes-2.11.1.html[2.11.1]
-* link:ReleaseNotes-2.11.html[2.11]
-
-[[s2_10]]
-== Version 2.10.x
-* link:ReleaseNotes-2.10.7.html[2.10.7]
-* link:ReleaseNotes-2.10.6.html[2.10.6]
-* link:ReleaseNotes-2.10.5.html[2.10.5]
-* link:ReleaseNotes-2.10.4.html[2.10.4]
-* link:ReleaseNotes-2.10.3.1.html[2.10.3.1]
-* link:ReleaseNotes-2.10.3.html[2.10.3]
-* link:ReleaseNotes-2.10.2.html[2.10.2]
-* link:ReleaseNotes-2.10.1.html[2.10.1]
-* link:ReleaseNotes-2.10.html[2.10]
-
-[[s2_9]]
-== Version 2.9.x
-* link:ReleaseNotes-2.9.4.html[2.9.4]
-* link:ReleaseNotes-2.9.3.html[2.9.3]
-* link:ReleaseNotes-2.9.2.html[2.9.2]
-* link:ReleaseNotes-2.9.1.html[2.9.1]
-* link:ReleaseNotes-2.9.html[2.9]
-
-[[s2_8]]
-== Version 2.8.x
-* link:ReleaseNotes-2.8.6.1.html[2.8.6.1]
-* link:ReleaseNotes-2.8.6.html[2.8.6]
-* link:ReleaseNotes-2.8.5.html[2.8.5]
-* link:ReleaseNotes-2.8.4.html[2.8.4]
-* link:ReleaseNotes-2.8.3.html[2.8.3]
-* link:ReleaseNotes-2.8.2.html[2.8.2]
-* link:ReleaseNotes-2.8.1.html[2.8.1]
-* link:ReleaseNotes-2.8.html[2.8]
-
-[[s2_7]]
-== Version 2.7.x
-* link:ReleaseNotes-2.7.html[2.7]
-
-[[s2_6]]
-== Version 2.6.x
-* link:ReleaseNotes-2.6.1.html[2.6.1]
-* link:ReleaseNotes-2.6.html[2.6]
-
-[[s2_5]]
-== Version 2.5.x
-* link:ReleaseNotes-2.5.6.html[2.5.6]
-* link:ReleaseNotes-2.5.5.html[2.5.5]
-* link:ReleaseNotes-2.5.4.html[2.5.4]
-* link:ReleaseNotes-2.5.3.html[2.5.3]
-* link:ReleaseNotes-2.5.2.html[2.5.2]
-* link:ReleaseNotes-2.5.1.html[2.5.1]
-* link:ReleaseNotes-2.5.html[2.5]
-
-[[s2_4]]
-== Version 2.4.x
-* link:ReleaseNotes-2.4.4.html[2.4.4]
-* link:ReleaseNotes-2.4.3.html[2.4.3]
-* link:ReleaseNotes-2.4.2.html[2.4.2]
-* link:ReleaseNotes-2.4.1.html[2.4.1]
-* link:ReleaseNotes-2.4.html[2.4]
-
-[[s2_3]]
-== Version 2.3.x
-* link:ReleaseNotes-2.3.1.html[2.3.1]
-* link:ReleaseNotes-2.3.html[2.3]
-
-[[s2_2]]
-== Version 2.2.x
-* link:ReleaseNotes-2.2.2.2.html[2.2.2.2]
-* link:ReleaseNotes-2.2.2.1.html[2.2.2.1]
-* link:ReleaseNotes-2.2.2.html[2.2.2]
-* link:ReleaseNotes-2.2.1.html[2.2.1]
-* link:ReleaseNotes-2.2.0.html[2.2.0]
-
-[[s2_1]]
-== Version 2.1.x
-* link:ReleaseNotes-2.1.10.html[2.1.10]
-* link:ReleaseNotes-2.1.9.html[2.1.9]
-* link:ReleaseNotes-2.1.8.html[2.1.8]
-* link:ReleaseNotes-2.1.7.2.html[2.1.7.2]
-* link:ReleaseNotes-2.1.7.html[2.1.7]
-* link:ReleaseNotes-2.1.6.1.html[2.1.6.1]
-* link:ReleaseNotes-2.1.6.html[2.1.6]
-* link:ReleaseNotes-2.1.5.html[2.1.5]
-* link:ReleaseNotes-2.1.4.html[2.1.4]
-* link:ReleaseNotes-2.1.3.html[2.1.3]
-* link:ReleaseNotes-2.1.2.5.html[2.1.2.5]
-* link:ReleaseNotes-2.1.2.4.html[2.1.2.4]
-* link:ReleaseNotes-2.1.2.3.html[2.1.2.3]
-* link:ReleaseNotes-2.1.2.2.html[2.1.2.2]
-* link:ReleaseNotes-2.1.2.1.html[2.1.2.1]
-* link:ReleaseNotes-2.1.2.html[2.1.2]
-* link:ReleaseNotes-2.1.1.html[2.1.1.1]
-* link:ReleaseNotes-2.1.1.html[2.1.1]
-* link:ReleaseNotes-2.1.html[2.1]
-
-[[s2_0]]
-== Version 2.0.x
-* link:ReleaseNotes-2.0.24.html[2.0.24.2]
-* link:ReleaseNotes-2.0.24.html[2.0.24.1]
-* link:ReleaseNotes-2.0.24.html[2.0.24]
-* link:ReleaseNotes-2.0.23.html[2.0.23]
-* link:ReleaseNotes-2.0.22.html[2.0.22]
-* link:ReleaseNotes-2.0.21.html[2.0.21]
-* link:ReleaseNotes-2.0.20.html[2.0.20]
-* link:ReleaseNotes-2.0.19.html[2.0.19.2]
-* link:ReleaseNotes-2.0.19.html[2.0.19.1]
-* link:ReleaseNotes-2.0.19.html[2.0.19]
-* link:ReleaseNotes-2.0.18.html[2.0.18]
-* link:ReleaseNotes-2.0.17.html[2.0.17]
-* link:ReleaseNotes-2.0.16.html[2.0.16]
-* link:ReleaseNotes-2.0.15.html[2.0.15]
-* link:ReleaseNotes-2.0.14.html[2.0.14.1]
-* link:ReleaseNotes-2.0.14.html[2.0.14]
-* link:ReleaseNotes-2.0.13.html[2.0.13.1]
-* link:ReleaseNotes-2.0.13.html[2.0.13]
-* link:ReleaseNotes-2.0.12.html[2.0.12]
-* link:ReleaseNotes-2.0.11.html[2.0.11]
-* link:ReleaseNotes-2.0.10.html[2.0.10]
-* link:ReleaseNotes-2.0.9.html[2.0.9]
-* link:ReleaseNotes-2.0.8.html[2.0.8]
-* link:ReleaseNotes-2.0.7.html[2.0.7]
-* link:ReleaseNotes-2.0.6.html[2.0.6]
-* link:ReleaseNotes-2.0.5.html[2.0.5]
-* link:ReleaseNotes-2.0.4.html[2.0.4]
-* link:ReleaseNotes-2.0.3.html[2.0.3]
-* link:ReleaseNotes-2.0.2.html[2.0.2]
-
-GERRIT
-------
-Part of link:https://www.gerritcodereview.com/[Gerrit Code Review]
diff --git a/SUBMITTING_PATCHES b/SUBMITTING_PATCHES
index 553ab34..5a82fd9 100644
--- a/SUBMITTING_PATCHES
+++ b/SUBMITTING_PATCHES
@@ -3,6 +3,7 @@
  - Make small logical changes.
  - Provide a meaningful commit message.
  - Make sure all code is under the Apache License, 2.0.
+ - Make sure all commit messages have a Change-Id.
  - Publish your changes for review:
 
    git push https://gerrit.googlesource.com/gerrit HEAD:refs/for/master
@@ -67,6 +68,13 @@
 
   https://gerrit-review.googlesource.com/#/settings/http-password
 
+Ensure you have installed the commit-msg hook that automatically
+generates and inserts a Change-Id line during "git commit".  This can
+be done from the root directory of the local Git repository:
+
+   curl -Lo .git/hooks/commit-msg https://gerrit-review.googlesource.com/tools/hooks/commit-msg
+   chmod +x .git/hooks/commit-msg
+
 Push your patches over HTTPS to the review server, possibly through
 a remembered remote to make this easier in the future:
 
diff --git a/WORKSPACE b/WORKSPACE
index 0b203d9..ae6ee9e 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")
@@ -96,8 +100,8 @@
 
 maven_jar(
     name = "servlet_api_3_1",
-    artifact = "org.apache.tomcat:tomcat-servlet-api:8.0.24",
-    sha1 = "5d9e2e895e3111622720157d0aa540066d5fce3a",
+    artifact = "org.apache.tomcat:tomcat-servlet-api:8.5.23",
+    sha1 = "021a212688ec94fe77aff74ab34cc74f6f940e60",
 )
 
 GWT_VERS = "2.8.2"
@@ -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,9 +205,9 @@
 )
 
 maven_jar(
-    name = "velocity",
-    artifact = "org.apache.velocity:velocity:1.7",
-    sha1 = "2ceb567b8f3f21118ecdec129fe1271dbc09aa7a",
+    name = "j2objc",
+    artifact = "com.google.j2objc:j2objc-annotations:1.1",
+    sha1 = "ed28ded51a8b1c6b112568def5f4b455e6809019",
 )
 
 maven_jar(
@@ -287,12 +279,6 @@
 )
 
 maven_jar(
-    name = "commons_collections",
-    artifact = "commons-collections:commons-collections:3.2.2",
-    sha1 = "8ad72fe39fa8c91eaaf12aadb21e0c3661fe26d5",
-)
-
-maven_jar(
     name = "commons_compress",
     artifact = "org.apache.commons:commons-compress:1.13",
     sha1 = "15c5e9584200122924e50203ae210b57616b75ee",
@@ -306,8 +292,8 @@
 
 maven_jar(
     name = "commons_lang3",
-    artifact = "org.apache.commons:commons-lang3:3.3.2",
-    sha1 = "90a3822c38ec8c996e84c16a3477ef632cbc87a3",
+    artifact = "org.apache.commons:commons-lang3:3.6",
+    sha1 = "9d28a6b23650e8a7e9063c04588ace6cf7012c17",
 )
 
 maven_jar(
@@ -329,12 +315,6 @@
 )
 
 maven_jar(
-    name = "commons_oro",
-    artifact = "oro:oro:2.0.8",
-    sha1 = "5592374f834645c4ae250f4c9fbb314c9369d698",
-)
-
-maven_jar(
     name = "commons_validator",
     artifact = "commons-validator:commons-validator:1.6",
     sha1 = "e989d1e87cdd60575df0765ed5bac65c905d7908",
@@ -342,8 +322,8 @@
 
 maven_jar(
     name = "automaton",
-    artifact = "dk.brics.automaton:automaton:1.11-8",
-    sha1 = "6ebfa65eb431ff4b715a23be7a750cbc4cc96d0f",
+    artifact = "dk.brics:automaton:1.12-1",
+    sha1 = "959a0c62f9a5c2309e0ad0b0589c74d69e101241",
 )
 
 maven_jar(
@@ -364,34 +344,34 @@
     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"
+MAIL_VERS = "1.6.0"
 
 maven_jar(
     name = "mail",
     artifact = "com.sun.mail:javax.mail:" + MAIL_VERS,
-    sha1 = "ab5daef2f881c42c8e280cbe918ec4d7fdfd7efe",
+    sha1 = "a055c648842c4954c1f7db7254f45d9ad565e278",
 )
 
-MIME4J_VERS = "0.8.0"
+MIME4J_VERS = "0.8.1"
 
 maven_jar(
     name = "mime4j_core",
     artifact = "org.apache.james:apache-mime4j-core:" + MIME4J_VERS,
-    sha1 = "d54f45fca44a2f210569656b4ca3574b42911c95",
+    sha1 = "c62dfe18a3b827a2c626ade0ffba44562ddf3f61",
 )
 
 maven_jar(
     name = "mime4j_dom",
     artifact = "org.apache.james:apache-mime4j-dom:" + MIME4J_VERS,
-    sha1 = "6720c93d14225c3e12c4a69768a0370c80e376a3",
+    sha1 = "f2d653c617004193f3350330d907f77b60c88c56",
 )
 
 maven_jar(
@@ -400,48 +380,48 @@
     sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
 )
 
-OW2_VERS = "5.1"
+OW2_VERS = "6.0"
 
 maven_jar(
     name = "ow2_asm",
     artifact = "org.ow2.asm:asm:" + OW2_VERS,
-    sha1 = "5ef31c4fe953b1fd00b8a88fa1d6820e8785bb45",
+    sha1 = "bc6fa6b19424bb9592fe43bbc20178f92d403105",
 )
 
 maven_jar(
     name = "ow2_asm_analysis",
     artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
-    sha1 = "6d1bf8989fc7901f868bee3863c44f21aa63d110",
+    sha1 = "dd1cc1381a970800268160203aae2d3784da779b",
 )
 
 maven_jar(
     name = "ow2_asm_commons",
     artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
-    sha1 = "25d8a575034dd9cfcb375a39b5334f0ba9c8474e",
+    sha1 = "f256fd215d8dd5a4fa2ab3201bf653de266ed4ec",
 )
 
 maven_jar(
     name = "ow2_asm_tree",
     artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
-    sha1 = "87b38c12a0ea645791ead9d3e74ae5268d1d6c34",
+    sha1 = "a624f1a6e4e428dcd680a01bab2d4c56b35b18f0",
 )
 
 maven_jar(
     name = "ow2_asm_util",
     artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
-    sha1 = "b60e33a6bd0d71831e0c249816d01e6c1dd90a47",
+    sha1 = "430b2fc839b5de1f3643b528853d5cf26096c1de",
 )
 
 maven_jar(
     name = "auto_value",
-    artifact = "com.google.auto.value:auto-value:1.4.1",
-    sha1 = "8172ebbd7970188aff304c8a420b9f17168f6f48",
+    artifact = "com.google.auto.value:auto-value:1.5.3",
+    sha1 = "514df6a7c7938de35c7f68dc8b8f22df86037f38",
 )
 
 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 +571,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:2018-01-03",
+    sha1 = "62089a55675f338bdfb41fba1b29fe610f654b4d",
 )
 
 maven_jar(
@@ -609,8 +589,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 +697,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
@@ -790,8 +770,8 @@
 
 maven_jar(
     name = "javassist",
-    artifact = "org.javassist:javassist:3.20.0-GA",
-    sha1 = "a9cbcdfb7e9f86fbc74d3afae65f2248bfbf82a0",
+    artifact = "org.javassist:javassist:3.22.0-GA",
+    sha1 = "3e83394258ae2089be7219b971ec21a8288528ad",
 )
 
 maven_jar(
@@ -922,8 +902,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 +922,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",
@@ -1085,8 +1077,8 @@
 bower_archive(
     name = "paper-button",
     package = "polymerelements/paper-button",
-    sha1 = "41a8fec68d93dad223ad2076d68515334b2c8d7b",
-    version = "1.0.11",
+    sha1 = "3b01774f58a8085d3c903fc5a32944b26ab7be72",
+    version = "2.0.0",
 )
 
 bower_archive(
@@ -1139,6 +1131,13 @@
 )
 
 bower_archive(
+    name = "paper-toggle-button",
+    package = "polymerelements/paper-toggle-button",
+    sha1 = "4a2edbdb52c4531d39fe091f12de650bccda270f",
+    version = "1.2.0",
+)
+
+bower_archive(
     name = "polymer",
     package = "polymer/polymer",
     sha1 = "62ce80a5079c1b97f6c5c6ebf6b350e741b18b9c",
@@ -1159,6 +1158,13 @@
     version = "1.0.0",
 )
 
+bower_archive(
+    name = "codemirror-minified",
+    package = "Dominator008/codemirror-minified",
+    sha1 = "51ba8d9256c63ce95238253c5b2eb7d5b12d6ed3",
+    version = "5.28.0",
+)
+
 # bower test stuff
 
 bower_archive(
diff --git a/antlr3/BUILD b/antlr3/BUILD
new file mode 100644
index 0000000..4846639
--- /dev/null
+++ b/antlr3/BUILD
@@ -0,0 +1,17 @@
+load("//tools/bzl:genrule2.bzl", "genrule2")
+
+genrule2(
+    name = "query",
+    srcs = ["com/google/gerrit/index/query/Query.g"],
+    outs = ["query_antlr.srcjar"],
+    cmd = " && ".join([
+        "$(location //lib/antlr:antlr-tool) -o $$TMP $<",
+        "cd $$TMP",
+        "$$ROOT/$(location @bazel_tools//tools/zip:zipper) cC $$ROOT/$@ $$(find *)",
+    ]),
+    tools = [
+        "//lib/antlr:antlr-tool",
+        "@bazel_tools//tools/zip:zipper",
+    ],
+    visibility = ["//visibility:public"],
+)
diff --git a/gerrit-index/src/main/antlr3/com/google/gerrit/index/query/Query.g b/antlr3/com/google/gerrit/index/query/Query.g
similarity index 100%
rename from gerrit-index/src/main/antlr3/com/google/gerrit/index/query/Query.g
rename to antlr3/com/google/gerrit/index/query/Query.g
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
index 99022aa..8d1c326 100755
--- a/contrib/abandon_stale.py
+++ b/contrib/abandon_stale.py
@@ -71,6 +71,9 @@
                       action='store_true',
                       help='enable dry-run mode: show stale changes but do '
                            'not abandon them')
+    parser.add_option('-t', '--test', dest='testmode', action='store_true',
+                      help='test mode: query changes with the `test-abandon` '
+                           'topic and ignore age option')
     parser.add_option('-a', '--age', dest='age',
                       metavar='AGE',
                       default="6months",
@@ -114,13 +117,16 @@
         logging.error("Gerrit URL is required")
         return 1
 
-    pattern = re.compile(r"^([\d]+)(month[s]?|year[s]?|week[s]?)")
-    match = pattern.match(options.age)
-    if not match:
-        logging.error("Invalid age: %s", options.age)
-        return 1
-    message = "Abandoning after %s %s or more of inactivity." % \
-        (match.group(1), match.group(2))
+    if options.testmode:
+        message = "Abandoning in test mode"
+    else:
+        pattern = re.compile(r"^([\d]+)(month[s]?|year[s]?|week[s]?)")
+        match = pattern.match(options.age)
+        if not match:
+            logging.error("Invalid age: %s", options.age)
+            return 1
+        message = "Abandoning after %s %s or more of inactivity." % \
+            (match.group(1), match.group(2))
 
     if options.digest_auth:
         auth_type = HTTPDigestAuthFromNetrc
@@ -139,7 +145,10 @@
         stale_changes = []
         offset = 0
         step = 500
-        query_terms = ["status:new", "age:%s" % options.age]
+        if options.testmode:
+            query_terms = ["status:new", "owner:self", "topic:test-abandon"]
+        else:
+            query_terms = ["status:new", "age:%s" % options.age]
         if options.branches:
             query_terms += ["branch:%s" % b for b in options.branches]
         elif options.exclude_branches:
@@ -148,7 +157,7 @@
             query_terms += ["project:%s" % p for p in options.projects]
         elif options.exclude_projects:
             query_terms = ["-project:%s" % p for p in options.exclude_projects]
-        if options.owner:
+        if options.owner and not options.testmode:
             query_terms += ["owner:%s" % options.owner]
         query = "%20".join(query_terms)
         while True:
@@ -177,21 +186,27 @@
         abandon_message += "\n\n" + options.message
     for change in stale_changes:
         number = change["_number"]
-        try:
-            owner = change["owner"]["name"]
-        except:
-            owner = "Unknown"
+        project = ""
+        if len(options.projects) != 1:
+            project = "%s: " % change["project"]
+        owner = ""
+        if options.verbose:
+            try:
+                o = change["owner"]["name"]
+            except KeyError:
+                o = "Unknown"
+            owner = " (%s)" % o
         subject = change["subject"]
         if len(subject) > 70:
             subject = subject[:65] + " [...]"
         change_id = change["id"]
-        logging.info("%s (%s): %s", number, owner, subject)
+        logging.info("%s%s: %s%s", number, owner, project, subject)
         if options.dry_run:
             continue
 
         try:
             gerrit.post("/changes/" + change_id + "/abandon",
-                        json={"message" : "%s" % abandon_message})
+                        json={"message": "%s" % abandon_message})
             abandoned += 1
         except Exception as e:
             errors += 1
@@ -200,5 +215,6 @@
     if not options.dry_run:
         logging.info("Abandoned %d changes. %d errors.", abandoned, errors)
 
+
 if __name__ == "__main__":
     sys.exit(_main())
diff --git a/gerrit-acceptance-framework/BUILD b/gerrit-acceptance-framework/BUILD
deleted file mode 100644
index 8a95346..0000000
--- a/gerrit-acceptance-framework/BUILD
+++ /dev/null
@@ -1,96 +0,0 @@
-load("//tools/bzl:java.bzl", "java_library2")
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-TEST_SRCS = ["src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java"]
-
-SRCS = glob(
-    ["src/test/java/com/google/gerrit/acceptance/*.java"],
-    exclude = TEST_SRCS,
-)
-
-PROVIDED = [
-    "//gerrit-common:annotations",
-    "//gerrit-common:server",
-    "//gerrit-extension-api:api",
-    "//gerrit-httpd:httpd",
-    "//gerrit-index:index",
-    "//gerrit-lucene:lucene",
-    "//gerrit-pgm:init",
-    "//gerrit-reviewdb:server",
-    "//gerrit-server:metrics",
-    "//gerrit-server:receive",
-    "//gerrit-server:server",
-    "//lib:gson",
-    "//lib:jsch",
-    "//lib/jgit/org.eclipse.jgit:jgit",
-    "//lib/mina:sshd",
-    "//lib:servlet-api-3_1",
-]
-
-java_binary(
-    name = "acceptance-framework",
-    testonly = 1,
-    main_class = "Dummy",
-    visibility = ["//visibility:public"],
-    runtime_deps = [":lib"],
-)
-
-java_library2(
-    name = "lib",
-    testonly = 1,
-    srcs = SRCS,
-    exported_deps = [
-        "//gerrit-gpg:gpg",
-        "//gerrit-index:query_exception",
-        "//gerrit-launcher:launcher",
-        "//gerrit-openid:openid",
-        "//gerrit-pgm:daemon",
-        "//gerrit-pgm:http-jetty",
-        "//gerrit-pgm:util-nodep",
-        "//gerrit-server:prolog-common",
-        "//gerrit-server:testutil",
-        "//lib:jimfs",
-        "//lib:truth",
-        "//lib:truth-java8-extension",
-        "//lib/auto:auto-value",
-        "//lib/httpcomponents:fluent-hc",
-        "//lib/httpcomponents:httpclient",
-        "//lib/httpcomponents:httpcore",
-        "//lib/jetty:servlet",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
-        "//lib/log:impl_log4j",
-        "//lib/log:log4j",
-    ],
-    visibility = ["//visibility:public"],
-    deps = PROVIDED + [
-        # We want these deps to be exported_deps
-        "//lib/greenmail:greenmail",
-        "//lib:gwtorm",
-        "//lib/guice:guice",
-        "//lib/guice:guice-assistedinject",
-        "//lib/guice:guice-servlet",
-        "//lib/mail:mail",
-    ],
-)
-
-load("//tools/bzl:javadoc.bzl", "java_doc")
-
-java_doc(
-    name = "acceptance-framework-javadoc",
-    testonly = 1,
-    libs = [":lib"],
-    pkgs = ["com.google.gerrit.acceptance"],
-    title = "Gerrit Acceptance Test Framework Documentation",
-    visibility = ["//visibility:public"],
-)
-
-junit_tests(
-    name = "acceptance_framework_tests",
-    srcs = TEST_SRCS,
-    deps = [
-        ":lib",
-        "//lib:guava",
-        "//lib:truth",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-    ],
-)
diff --git a/gerrit-acceptance-framework/pom.xml b/gerrit-acceptance-framework/pom.xml
deleted file mode 100644
index 1d5e464..0000000
--- a/gerrit-acceptance-framework/pom.xml
+++ /dev/null
@@ -1,86 +0,0 @@
-<project>
-  <modelVersion>4.0.0</modelVersion>
-  <groupId>com.google.gerrit</groupId>
-  <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>2.15-rc2</version>
-  <packaging>jar</packaging>
-  <name>Gerrit Code Review - Acceptance Test Framework</name>
-  <description>Framework for Gerrit's acceptance tests</description>
-  <url>https://www.gerritcodereview.com/</url>
-
-  <licenses>
-    <license>
-      <name>The Apache Software License, Version 2.0</name>
-      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
-      <distribution>repo</distribution>
-    </license>
-  </licenses>
-
-  <scm>
-    <url>https://gerrit.googlesource.com/gerrit</url>
-    <connection>https://gerrit.googlesource.com/gerrit</connection>
-  </scm>
-
-  <developers>
-    <developer>
-      <name>Alice Kober-Sotzek</name>
-    </developer>
-    <developer>
-      <name>Andrew Bonventre</name>
-    </developer>
-    <developer>
-      <name>Becky Siegel</name>
-    </developer>
-    <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
-      <name>David Ostrovsky</name>
-    </developer>
-    <developer>
-      <name>David Pursehouse</name>
-    </developer>
-    <developer>
-      <name>Edwin Kempin</name>
-    </developer>
-    <developer>
-      <name>Hugo Arès</name>
-    </developer>
-    <developer>
-      <name>Kasper Nilsson</name>
-    </developer>
-    <developer>
-      <name>Logan Hanks</name>
-    </developer>
-    <developer>
-      <name>Martin Fick</name>
-    </developer>
-    <developer>
-      <name>Saša Živkov</name>
-    </developer>
-    <developer>
-      <name>Shawn Pearce</name>
-    </developer>
-    <developer>
-      <name>Viktar Donich</name>
-    </developer>
-    <developer>
-      <name>Wyatt Allen</name>
-    </developer>
-  </developers>
-
-  <mailingLists>
-    <mailingList>
-      <name>Repo and Gerrit Discussion</name>
-      <post>repo-discuss@googlegroups.com</post>
-      <subscribe>https://groups.google.com/forum/#!forum/repo-discuss</subscribe>
-      <unsubscribe>https://groups.google.com/forum/#!forum/repo-discuss</unsubscribe>
-      <archive>https://groups.google.com/forum/#!forum/repo-discuss</archive>
-    </mailingList>
-  </mailingLists>
-
-  <issueManagement>
-    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
-    <system>Gerrit Issue Tracker</system>
-  </issueManagement>
-</project>
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
deleted file mode 100644
index aa5ffc5..0000000
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ /dev/null
@@ -1,1392 +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;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.acceptance.GitUtil.initSsh;
-import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
-import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
-import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
-import static org.eclipse.jgit.lib.Constants.HEAD;
-import static org.eclipse.jgit.lib.Constants.R_TAGS;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
-import com.google.common.jimfs.Jimfs;
-import com.google.common.primitives.Chars;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
-import com.google.gerrit.common.Nullable;
-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.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.RevisionApi;
-import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
-import com.google.gerrit.extensions.api.groups.GroupApi;
-import com.google.gerrit.extensions.api.groups.GroupInput;
-import com.google.gerrit.extensions.api.projects.BranchApi;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.ProjectWatchInfo;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeType;
-import com.google.gerrit.extensions.common.DiffInfo;
-import com.google.gerrit.extensions.common.EditInfo;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ChangeFinder;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.OutputFormat;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.change.Abandon;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.FileContentUtil;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.change.Revisions;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.PluginConfigFactory;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.Groups;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.EmailHeader;
-import com.google.gerrit.server.notedb.ChangeNoteUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.MutableNotesMigration;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.Util;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.FakeEmailSender;
-import com.google.gerrit.testutil.FakeEmailSender.Message;
-import com.google.gerrit.testutil.NoteDbMode;
-import com.google.gerrit.testutil.SshMode;
-import com.google.gerrit.testutil.TempFileUtil;
-import com.google.gson.Gson;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.nio.file.DirectoryStream;
-import java.nio.file.FileSystem;
-import java.nio.file.FileSystems;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.regex.Pattern;
-import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.FetchResult;
-import org.eclipse.jgit.transport.RefSpec;
-import org.eclipse.jgit.transport.Transport;
-import org.eclipse.jgit.transport.TransportBundleStream;
-import org.eclipse.jgit.transport.URIish;
-import org.junit.After;
-import org.junit.AfterClass;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.rules.ExpectedException;
-import org.junit.rules.TestRule;
-import org.junit.runner.Description;
-import org.junit.runner.RunWith;
-import org.junit.runners.model.Statement;
-
-@RunWith(ConfigSuite.class)
-public abstract class AbstractDaemonTest {
-  private static GerritServer commonServer;
-  private static Description firstTest;
-
-  @ConfigSuite.Parameter public Config baseConfig;
-  @ConfigSuite.Name private String configName;
-
-  @Rule public ExpectedException exception = ExpectedException.none();
-
-  @Rule
-  public TestRule testRunner =
-      new TestRule() {
-        @Override
-        public Statement apply(Statement base, Description description) {
-          return new Statement() {
-            @Override
-            public void evaluate() throws Throwable {
-              if (firstTest == null) {
-                firstTest = description;
-              }
-              beforeTest(description);
-              try {
-                base.evaluate();
-              } finally {
-                afterTest();
-              }
-            }
-          };
-        }
-      };
-
-  @Inject @CanonicalWebUrl protected Provider<String> canonicalWebUrl;
-  @Inject @GerritPersonIdent protected Provider<PersonIdent> serverIdent;
-  @Inject @GerritServerConfig protected Config cfg;
-  @Inject protected AcceptanceTestRequestScope atrScope;
-  @Inject protected AccountCache accountCache;
-  @Inject protected AccountCreator accountCreator;
-  @Inject protected Accounts accounts;
-  @Inject protected AllProjectsName allProjects;
-  @Inject protected BatchUpdate.Factory batchUpdateFactory;
-  @Inject protected ChangeData.Factory changeDataFactory;
-  @Inject protected ChangeFinder changeFinder;
-  @Inject protected ChangeIndexer indexer;
-  @Inject protected ChangeNoteUtil changeNoteUtil;
-  @Inject protected ChangeResource.Factory changeResourceFactory;
-  @Inject protected FakeEmailSender sender;
-  @Inject protected GerritApi gApi;
-  @Inject protected GitRepositoryManager repoManager;
-  @Inject protected GroupCache groupCache;
-  @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
-  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
-  @Inject protected PatchSetUtil psUtil;
-  @Inject protected ProjectCache projectCache;
-  @Inject protected Provider<InternalChangeQuery> queryProvider;
-  @Inject protected PushOneCommit.Factory pushFactory;
-  @Inject protected PluginConfigFactory pluginConfig;
-  @Inject protected Revisions revisions;
-  @Inject protected SystemGroupBackend systemGroupBackend;
-  @Inject protected MutableNotesMigration notesMigration;
-  @Inject protected ChangeNotes.Factory notesFactory;
-  @Inject protected Abandon changeAbandoner;
-
-  protected EventRecorder eventRecorder;
-  protected GerritServer server;
-  protected Project.NameKey project;
-  protected RestSession adminRestSession;
-  protected RestSession userRestSession;
-  protected ReviewDb db;
-  protected SshSession adminSshSession;
-  protected SshSession userSshSession;
-  protected TestAccount admin;
-  protected TestAccount user;
-  protected TestRepository<InMemoryRepository> testRepo;
-  protected String resourcePrefix;
-  protected Description description;
-  protected boolean testRequiresSsh;
-
-  @Inject private ChangeIndexCollection changeIndexes;
-  @Inject private EventRecorder.Factory eventRecorderFactory;
-  @Inject private InProcessProtocol inProcessProtocol;
-  @Inject private Provider<AnonymousUser> anonymousUser;
-  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
-  @Inject private Groups groups;
-
-  private List<Repository> toClose;
-
-  @Before
-  public void clearSender() {
-    sender.clear();
-  }
-
-  @Before
-  public void startEventRecorder() {
-    eventRecorder = eventRecorderFactory.create(admin);
-  }
-
-  @Before
-  public void assumeSshIfRequired() {
-    if (testRequiresSsh) {
-      // If the test uses ssh, we use assume() to make sure ssh is enabled on
-      // the test suite. JUnit will skip tests annotated with @UseSsh if we
-      // disable them using the command line flag.
-      assume().that(SshMode.useSsh()).isTrue();
-    }
-  }
-
-  @After
-  public void closeEventRecorder() {
-    eventRecorder.close();
-  }
-
-  @AfterClass
-  public static void stopCommonServer() throws Exception {
-    if (commonServer != null) {
-      try {
-        commonServer.close();
-      } catch (Throwable t) {
-        throw new AssertionError(
-            "Error stopping common server in "
-                + (firstTest != null ? firstTest.getTestClass().getName() : "unknown test class"),
-            t);
-      } finally {
-        commonServer = null;
-      }
-    }
-    TempFileUtil.cleanup();
-  }
-
-  protected static Config submitWholeTopicEnabledConfig() {
-    Config cfg = new Config();
-    cfg.setBoolean("change", null, "submitWholeTopic", true);
-    return cfg;
-  }
-
-  protected boolean isSubmitWholeTopicEnabled() {
-    return cfg.getBoolean("change", null, "submitWholeTopic", false);
-  }
-
-  protected boolean isContributorAgreementsEnabled() {
-    return cfg.getBoolean("auth", null, "contributorAgreements", false);
-  }
-
-  protected void beforeTest(Description description) throws Exception {
-    this.description = description;
-    GerritServer.Description classDesc =
-        GerritServer.Description.forTestClass(description, configName);
-    GerritServer.Description methodDesc =
-        GerritServer.Description.forTestMethod(description, configName);
-
-    baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
-    if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
-      if (commonServer == null) {
-        commonServer = GerritServer.initAndStart(classDesc, baseConfig);
-      }
-      server = commonServer;
-    } else {
-      server = GerritServer.initAndStart(methodDesc, baseConfig);
-    }
-
-    server.getTestInjector().injectMembers(this);
-    Transport.register(inProcessProtocol);
-    toClose = Collections.synchronizedList(new ArrayList<Repository>());
-
-    db = reviewDbProvider.open();
-
-    // All groups which were added during the server start (e.g. in SchemaCreator) aren't contained
-    // in the instance of the group index which is available here and in tests. There are two
-    // reasons:
-    // 1) No group index is available in SchemaCreator when using an in-memory database. (This could
-    // be fixed by using the IndexManagerOnInit in InMemoryDatabase similar as BaseInit uses it.)
-    // 2) During the on-init part of the server start, we use another instance of the index than
-    // later on. As test indexes are non-permanent, closing an instance and opening another one
-    // removes all indexed data.
-    // As a workaround, we simply reindex all available groups here.
-    Iterable<AccountGroup> allGroups = groups.getAll(db)::iterator;
-    for (AccountGroup group : allGroups) {
-      groupCache.evict(group.getGroupUUID(), group.getId(), group.getNameKey());
-    }
-
-    admin = accountCreator.admin();
-    user = accountCreator.user();
-
-    // Evict cached user state in case tests modify it.
-    accountCache.evict(admin.getId());
-    accountCache.evict(user.getId());
-
-    adminRestSession = new RestSession(server, admin);
-    userRestSession = new RestSession(server, user);
-
-    testRequiresSsh = classDesc.useSshAnnotation() || methodDesc.useSshAnnotation();
-    if (testRequiresSsh
-        && SshMode.useSsh()
-        && (adminSshSession == null || userSshSession == null)) {
-      // Create Ssh sessions
-      initSsh(admin);
-      Context ctx = newRequestContext(user);
-      atrScope.set(ctx);
-      userSshSession = ctx.getSession();
-      userSshSession.open();
-      ctx = newRequestContext(admin);
-      atrScope.set(ctx);
-      adminSshSession = ctx.getSession();
-      adminSshSession.open();
-    }
-
-    resourcePrefix =
-        UNSAFE_PROJECT_NAME
-            .matcher(description.getClassName() + "_" + description.getMethodName() + "_")
-            .replaceAll("");
-
-    Context ctx = newRequestContext(admin);
-    atrScope.set(ctx);
-    project = createProject(projectInput(description));
-    testRepo = cloneProject(project, getCloneAsAccount(description));
-  }
-
-  private TestAccount getCloneAsAccount(Description description) {
-    TestProjectInput ann = description.getAnnotation(TestProjectInput.class);
-    return accountCreator.get(ann != null ? ann.cloneAs() : "admin");
-  }
-
-  private ProjectInput projectInput(Description description) {
-    ProjectInput in = new ProjectInput();
-    TestProjectInput ann = description.getAnnotation(TestProjectInput.class);
-    in.name = name("project");
-    if (ann != null) {
-      in.parent = Strings.emptyToNull(ann.parent());
-      in.description = Strings.emptyToNull(ann.description());
-      in.createEmptyCommit = ann.createEmptyCommit();
-      in.submitType = ann.submitType();
-      in.useContentMerge = ann.useContributorAgreements();
-      in.useSignedOffBy = ann.useSignedOffBy();
-      in.useContentMerge = ann.useContentMerge();
-    } else {
-      // Defaults should match TestProjectConfig, omitting nullable values.
-      in.createEmptyCommit = true;
-    }
-    updateProjectInput(in);
-    return in;
-  }
-
-  private static final Pattern UNSAFE_PROJECT_NAME = Pattern.compile("[^a-zA-Z0-9._/-]+");
-
-  protected Git git() {
-    return testRepo.git();
-  }
-
-  protected InMemoryRepository repo() {
-    return testRepo.getRepository();
-  }
-
-  /**
-   * Return a resource name scoped to this test method.
-   *
-   * <p>Test methods in a single class by default share a running server. For any resource name you
-   * require to be unique to a test method, wrap it in a call to this method.
-   *
-   * @param name resource name (group, project, topic, etc.)
-   * @return name prefixed by a string unique to this test method.
-   */
-  protected String name(String name) {
-    return resourcePrefix + name;
-  }
-
-  protected Project.NameKey createProject(String nameSuffix) throws RestApiException {
-    return createProject(nameSuffix, null);
-  }
-
-  protected Project.NameKey createProject(String nameSuffix, Project.NameKey parent)
-      throws RestApiException {
-    // Default for createEmptyCommit should match TestProjectConfig.
-    return createProject(nameSuffix, parent, true, null);
-  }
-
-  protected Project.NameKey createProject(
-      String nameSuffix, Project.NameKey parent, boolean createEmptyCommit)
-      throws RestApiException {
-    // Default for createEmptyCommit should match TestProjectConfig.
-    return createProject(nameSuffix, parent, createEmptyCommit, null);
-  }
-
-  protected Project.NameKey createProject(
-      String nameSuffix, Project.NameKey parent, SubmitType submitType) throws RestApiException {
-    // Default for createEmptyCommit should match TestProjectConfig.
-    return createProject(nameSuffix, parent, true, submitType);
-  }
-
-  protected Project.NameKey createProject(
-      String nameSuffix, Project.NameKey parent, boolean createEmptyCommit, SubmitType submitType)
-      throws RestApiException {
-    ProjectInput in = new ProjectInput();
-    in.name = name(nameSuffix);
-    in.parent = parent != null ? parent.get() : null;
-    in.submitType = submitType;
-    in.createEmptyCommit = createEmptyCommit;
-    return createProject(in);
-  }
-
-  private Project.NameKey createProject(ProjectInput in) throws RestApiException {
-    gApi.projects().create(in);
-    return new Project.NameKey(in.name);
-  }
-
-  /**
-   * Modify a project input before creating the initial test project.
-   *
-   * @param in input; may be modified in place.
-   */
-  protected void updateProjectInput(ProjectInput in) {
-    // Default implementation does nothing.
-  }
-
-  protected TestRepository<InMemoryRepository> cloneProject(Project.NameKey p) throws Exception {
-    return cloneProject(p, admin);
-  }
-
-  protected TestRepository<InMemoryRepository> cloneProject(
-      Project.NameKey p, TestAccount testAccount) throws Exception {
-    return GitUtil.cloneProject(p, registerRepoConnection(p, testAccount));
-  }
-
-  /**
-   * Register a repository connection over the test protocol.
-   *
-   * @return a URI string that can be used to connect to this repository for both fetch and push.
-   */
-  protected String registerRepoConnection(Project.NameKey p, TestAccount testAccount)
-      throws Exception {
-    InProcessProtocol.Context ctx =
-        new InProcessProtocol.Context(
-            reviewDbProvider, identifiedUserFactory, testAccount.getId(), p);
-    Repository repo = repoManager.openRepository(p);
-    toClose.add(repo);
-    return inProcessProtocol.register(ctx, repo).toString();
-  }
-
-  protected void afterTest() throws Exception {
-    Transport.unregister(inProcessProtocol);
-    for (Repository repo : toClose) {
-      repo.close();
-    }
-    db.close();
-    if (adminSshSession != null) {
-      adminSshSession.close();
-    }
-    if (userSshSession != null) {
-      userSshSession.close();
-    }
-    if (server != commonServer) {
-      server.close();
-      server = null;
-    }
-    NoteDbMode.resetFromEnv(notesMigration);
-  }
-
-  protected TestRepository<?>.CommitBuilder commitBuilder() throws Exception {
-    return testRepo.branch("HEAD").commit().insertChangeId();
-  }
-
-  protected TestRepository<?>.CommitBuilder amendBuilder() throws Exception {
-    ObjectId head = repo().exactRef("HEAD").getObjectId();
-    TestRepository<?>.CommitBuilder b = testRepo.amendRef("HEAD");
-    Optional<String> id = GitUtil.getChangeId(testRepo, head);
-    // TestRepository behaves like "git commit --amend -m foo", which does not
-    // preserve an existing Change-Id. Tests probably want this.
-    if (id.isPresent()) {
-      b.insertChangeId(id.get().substring(1));
-    } else {
-      b.insertChangeId();
-    }
-    return b;
-  }
-
-  protected PushOneCommit.Result createChange() throws Exception {
-    return createChange("refs/for/master");
-  }
-
-  protected PushOneCommit.Result createChange(String ref) throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    PushOneCommit.Result result = push.to(ref);
-    result.assertOkStatus();
-    return result;
-  }
-
-  protected PushOneCommit.Result createMergeCommitChange(String ref) throws Exception {
-    return createMergeCommitChange(ref, "foo");
-  }
-
-  protected PushOneCommit.Result createMergeCommitChange(String ref, String file) throws Exception {
-    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-
-    PushOneCommit.Result p1 =
-        pushFactory
-            .create(
-                db,
-                admin.getIdent(),
-                testRepo,
-                "parent 1",
-                ImmutableMap.of(file, "foo-1", "bar", "bar-1"))
-            .to(ref);
-
-    // reset HEAD in order to create a sibling of the first change
-    testRepo.reset(initial);
-
-    PushOneCommit.Result p2 =
-        pushFactory
-            .create(
-                db,
-                admin.getIdent(),
-                testRepo,
-                "parent 2",
-                ImmutableMap.of(file, "foo-2", "bar", "bar-2"))
-            .to(ref);
-
-    PushOneCommit m =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            "merge",
-            ImmutableMap.of(file, "foo-1", "bar", "bar-2"));
-    m.setParents(ImmutableList.of(p1.getCommit(), p2.getCommit()));
-    PushOneCommit.Result result = m.to(ref);
-    result.assertOkStatus();
-    return result;
-  }
-
-  protected PushOneCommit.Result createCommitAndPush(
-      TestRepository<InMemoryRepository> repo,
-      String ref,
-      String commitMsg,
-      String fileName,
-      String content)
-      throws Exception {
-    PushOneCommit.Result result =
-        pushFactory.create(db, admin.getIdent(), repo, commitMsg, fileName, content).to(ref);
-    result.assertOkStatus();
-    return result;
-  }
-
-  protected PushOneCommit.Result createChangeWithTopic() throws Exception {
-    return createChangeWithTopic(testRepo, "topic", "message", "a.txt", "content\n");
-  }
-
-  protected PushOneCommit.Result createChangeWithTopic(
-      TestRepository<InMemoryRepository> repo,
-      String topic,
-      String commitMsg,
-      String fileName,
-      String content)
-      throws Exception {
-    assertThat(topic).isNotEmpty();
-    return createCommitAndPush(
-        repo, "refs/for/master/" + name(topic), commitMsg, fileName, content);
-  }
-
-  protected PushOneCommit.Result createWorkInProgressChange() throws Exception {
-    return pushTo("refs/for/master%wip");
-  }
-
-  protected PushOneCommit.Result createChange(String subject, String fileName, String content)
-      throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
-    return push.to("refs/for/master");
-  }
-
-  protected PushOneCommit.Result createChange(
-      String subject, String fileName, String content, String topic) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
-    return push.to("refs/for/master/" + name(topic));
-  }
-
-  protected PushOneCommit.Result createChange(
-      TestRepository<?> repo,
-      String branch,
-      String subject,
-      String fileName,
-      String content,
-      String topic)
-      throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo, subject, fileName, content);
-    return push.to("refs/for/" + branch + "/" + name(topic));
-  }
-
-  protected BranchApi createBranch(Branch.NameKey branch) throws Exception {
-    return gApi.projects()
-        .name(branch.getParentKey().get())
-        .branch(branch.get())
-        .create(new BranchInput());
-  }
-
-  protected BranchApi createBranchWithRevision(Branch.NameKey branch, String revision)
-      throws Exception {
-    BranchInput in = new BranchInput();
-    in.revision = revision;
-    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get()).create(in);
-  }
-
-  private static final List<Character> RANDOM =
-      Chars.asList(new char[] {'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'});
-
-  protected PushOneCommit.Result amendChange(String changeId) throws Exception {
-    return amendChange(changeId, "refs/for/master");
-  }
-
-  protected PushOneCommit.Result amendChange(String changeId, String ref) throws Exception {
-    return amendChange(changeId, ref, admin, testRepo);
-  }
-
-  protected PushOneCommit.Result amendChange(
-      String changeId, String ref, TestAccount testAccount, TestRepository<?> repo)
-      throws Exception {
-    Collections.shuffle(RANDOM);
-    return amendChange(
-        changeId,
-        ref,
-        testAccount,
-        repo,
-        PushOneCommit.SUBJECT,
-        PushOneCommit.FILE_NAME,
-        new String(Chars.toArray(RANDOM)));
-  }
-
-  protected PushOneCommit.Result amendChange(
-      String changeId, String subject, String fileName, String content) throws Exception {
-    return amendChange(changeId, "refs/for/master", admin, testRepo, subject, fileName, content);
-  }
-
-  protected PushOneCommit.Result amendChange(
-      String changeId,
-      String ref,
-      TestAccount testAccount,
-      TestRepository<?> repo,
-      String subject,
-      String fileName,
-      String content)
-      throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, testAccount.getIdent(), repo, subject, fileName, content, changeId);
-    return push.to(ref);
-  }
-
-  protected void merge(PushOneCommit.Result r) throws Exception {
-    revision(r).review(ReviewInput.approve());
-    revision(r).submit();
-  }
-
-  protected ChangeInfo info(String id) throws RestApiException {
-    return gApi.changes().id(id).info();
-  }
-
-  protected ChangeInfo get(String id) throws RestApiException {
-    return gApi.changes().id(id).get();
-  }
-
-  protected Optional<EditInfo> getEdit(String id) throws RestApiException {
-    return gApi.changes().id(id).edit().get();
-  }
-
-  protected ChangeInfo get(String id, ListChangesOption... options) throws RestApiException {
-    return gApi.changes().id(id).get(options);
-  }
-
-  protected List<ChangeInfo> query(String q) throws RestApiException {
-    return gApi.changes().query(q).get();
-  }
-
-  private Context newRequestContext(TestAccount account) {
-    return atrScope.newContext(
-        reviewDbProvider,
-        new SshSession(server, account),
-        identifiedUserFactory.create(account.getId()));
-  }
-
-  /**
-   * Enforce a new request context for the current API user.
-   *
-   * <p>This recreates the IdentifiedUser, hence everything which is cached in the IdentifiedUser is
-   * reloaded (e.g. the email addresses of the user).
-   */
-  protected Context resetCurrentApiUser() {
-    return atrScope.set(newRequestContext(atrScope.get().getSession().getAccount()));
-  }
-
-  protected Context setApiUser(TestAccount account) {
-    return atrScope.set(newRequestContext(account));
-  }
-
-  protected Context setApiUserAnonymous() {
-    return atrScope.set(atrScope.newContext(reviewDbProvider, null, anonymousUser.get()));
-  }
-
-  protected Context disableDb() {
-    notesMigration.setFailOnLoadForTest(true);
-    return atrScope.disableDb();
-  }
-
-  protected void enableDb(Context preDisableContext) {
-    notesMigration.setFailOnLoadForTest(false);
-    atrScope.set(preDisableContext);
-  }
-
-  protected void disableChangeIndexWrites() {
-    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
-      if (!(i instanceof ReadOnlyChangeIndex)) {
-        changeIndexes.addWriteIndex(new ReadOnlyChangeIndex(i));
-      }
-    }
-  }
-
-  protected void enableChangeIndexWrites() {
-    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
-      if (i instanceof ReadOnlyChangeIndex) {
-        changeIndexes.addWriteIndex(((ReadOnlyChangeIndex) i).unwrap());
-      }
-    }
-  }
-
-  protected static Gson newGson() {
-    return OutputFormat.JSON_COMPACT.newGson();
-  }
-
-  protected RevisionApi revision(PushOneCommit.Result r) throws Exception {
-    return gApi.changes().id(r.getChangeId()).current();
-  }
-
-  protected void allow(String ref, String permission, AccountGroup.UUID id) throws Exception {
-    allow(project, ref, permission, id);
-  }
-
-  protected void allow(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.allow(cfg, permission, id, ref);
-    saveProjectConfig(p, cfg);
-  }
-
-  protected void allowGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
-      throws Exception {
-    allowGlobalCapabilities(id, Arrays.asList(capabilityNames));
-  }
-
-  protected void allowGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
-      throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    for (String capabilityName : capabilityNames) {
-      Util.allow(cfg, capabilityName, id);
-    }
-    saveProjectConfig(allProjects, cfg);
-  }
-
-  protected void removeGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
-      throws Exception {
-    removeGlobalCapabilities(id, Arrays.asList(capabilityNames));
-  }
-
-  protected void removeGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
-      throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    for (String capabilityName : capabilityNames) {
-      Util.remove(cfg, capabilityName, id);
-    }
-    saveProjectConfig(allProjects, cfg);
-  }
-
-  protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      ProjectConfig config = ProjectConfig.read(md);
-      config.getProject().setUseContributorAgreements(value);
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
-  }
-
-  protected void setUseSignedOffBy(InheritableBoolean value) throws Exception {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      ProjectConfig config = ProjectConfig.read(md);
-      config.getProject().setUseSignedOffBy(value);
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
-  }
-
-  protected void deny(String ref, String permission, AccountGroup.UUID id) throws Exception {
-    deny(project, ref, permission, id);
-  }
-
-  protected void deny(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.deny(cfg, permission, id, ref);
-    saveProjectConfig(p, cfg);
-  }
-
-  protected PermissionRule block(String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    return block(project, ref, permission, id);
-  }
-
-  protected PermissionRule block(
-      Project.NameKey project, String ref, String permission, AccountGroup.UUID id)
-      throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    PermissionRule rule = Util.block(cfg, permission, id, ref);
-    saveProjectConfig(project, cfg);
-    return rule;
-  }
-
-  protected void blockLabel(
-      String label, int min, int max, AccountGroup.UUID id, String ref, Project.NameKey project)
-      throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.block(cfg, Permission.LABEL + label, min, max, id, ref);
-    saveProjectConfig(project, cfg);
-  }
-
-  protected void saveProjectConfig(Project.NameKey p, ProjectConfig cfg) throws Exception {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(p)) {
-      md.setAuthor(identifiedUserFactory.create(admin.getId()));
-      cfg.commit(md);
-    }
-    projectCache.evict(cfg.getProject());
-  }
-
-  protected void saveProjectConfig(ProjectConfig cfg) throws Exception {
-    saveProjectConfig(project, cfg);
-  }
-
-  protected void grant(Project.NameKey project, String ref, String permission)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    grant(project, ref, permission, false);
-  }
-
-  protected void grant(Project.NameKey project, String ref, String permission, boolean force)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-    grant(project, ref, permission, force, adminGroup.getGroupUUID());
-  }
-
-  protected void grant(
-      Project.NameKey project,
-      String ref,
-      String permission,
-      boolean force,
-      AccountGroup.UUID groupUUID)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      md.setMessage(String.format("Grant %s on %s", permission, ref));
-      ProjectConfig config = ProjectConfig.read(md);
-      AccessSection s = config.getAccessSection(ref, true);
-      Permission p = s.getPermission(permission, true);
-      PermissionRule rule = Util.newRule(config, groupUUID);
-      rule.setForce(force);
-      p.add(rule);
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
-  }
-
-  protected void grantLabel(
-      String label,
-      int min,
-      int max,
-      Project.NameKey project,
-      String ref,
-      boolean force,
-      AccountGroup.UUID groupUUID,
-      boolean exclusive)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    String permission = Permission.LABEL + label;
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      md.setMessage(String.format("Grant %s on %s", permission, ref));
-      ProjectConfig config = ProjectConfig.read(md);
-      AccessSection s = config.getAccessSection(ref, true);
-      Permission p = s.getPermission(permission, true);
-      p.setExclusiveGroup(exclusive);
-      PermissionRule rule = Util.newRule(config, groupUUID);
-      rule.setForce(force);
-      rule.setMin(min);
-      rule.setMax(max);
-      p.add(rule);
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
-  }
-
-  protected void removePermission(Project.NameKey project, String ref, String permission)
-      throws IOException, ConfigInvalidException {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      md.setMessage(String.format("Remove %s on %s", permission, ref));
-      ProjectConfig config = ProjectConfig.read(md);
-      AccessSection s = config.getAccessSection(ref, true);
-      Permission p = s.getPermission(permission, true);
-      p.getRules().clear();
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
-  }
-
-  protected void blockRead(String ref) throws Exception {
-    block(ref, Permission.READ, REGISTERED_USERS);
-  }
-
-  protected void blockForgeCommitter(Project.NameKey project, String ref) throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.block(cfg, Permission.FORGE_COMMITTER, REGISTERED_USERS, ref);
-    saveProjectConfig(project, cfg);
-  }
-
-  protected PushOneCommit.Result pushTo(String ref) throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    return push.to(ref);
-  }
-
-  protected void approve(String id) throws Exception {
-    gApi.changes().id(id).revision("current").review(ReviewInput.approve());
-  }
-
-  protected void recommend(String id) throws Exception {
-    gApi.changes().id(id).revision("current").review(ReviewInput.recommend());
-  }
-
-  protected Map<String, ActionInfo> getActions(String id) throws Exception {
-    return gApi.changes().id(id).revision(1).actions();
-  }
-
-  protected String getETag(String id) throws Exception {
-    return gApi.changes().id(id).current().etag();
-  }
-
-  private static Iterable<String> changeIds(Iterable<ChangeInfo> changes) {
-    return Iterables.transform(changes, i -> i.changeId);
-  }
-
-  protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
-    List<ChangeInfo> actual = gApi.changes().id(chId).submittedTogether();
-    SubmittedTogetherInfo info =
-        gApi.changes().id(chId).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
-
-    assertThat(info.nonVisibleChanges).isEqualTo(0);
-    assertThat(actual).hasSize(expected.length);
-    assertThat(changeIds(actual)).containsExactly((Object[]) expected).inOrder();
-    assertThat(changeIds(info.changes)).containsExactly((Object[]) expected).inOrder();
-  }
-
-  protected PatchSet getPatchSet(PatchSet.Id psId) throws OrmException {
-    return changeDataFactory.create(db, project, psId.getParentKey()).patchSet(psId);
-  }
-
-  protected IdentifiedUser user(TestAccount testAccount) {
-    return identifiedUserFactory.create(testAccount.getId());
-  }
-
-  protected RevisionResource parseCurrentRevisionResource(String changeId) throws Exception {
-    ChangeResource cr = parseChangeResource(changeId);
-    int psId = cr.getChange().currentPatchSetId().get();
-    return revisions.parse(cr, IdString.fromDecoded(Integer.toString(psId)));
-  }
-
-  protected RevisionResource parseRevisionResource(String changeId, int n) throws Exception {
-    return revisions.parse(
-        parseChangeResource(changeId), IdString.fromDecoded(Integer.toString(n)));
-  }
-
-  protected RevisionResource parseRevisionResource(PushOneCommit.Result r) throws Exception {
-    PatchSet.Id psId = r.getPatchSetId();
-    return parseRevisionResource(psId.getParentKey().toString(), psId.get());
-  }
-
-  protected ChangeResource parseChangeResource(String changeId) throws Exception {
-    List<ChangeNotes> notes = changeFinder.find(changeId);
-    assertThat(notes).hasSize(1);
-    return changeResourceFactory.create(notes.get(0), atrScope.get().getUser());
-  }
-
-  protected String createGroup(String name) throws Exception {
-    return createGroup(name, "Administrators");
-  }
-
-  protected String createGroup(String name, String owner) throws Exception {
-    name = name(name);
-    GroupInput in = new GroupInput();
-    in.name = name;
-    in.ownerId = owner;
-    gApi.groups().create(in);
-    return name;
-  }
-
-  protected String createAccount(String name, String group) throws Exception {
-    name = name(name);
-    accountCreator.create(name, group);
-    return name;
-  }
-
-  protected RevCommit getHead(Repository repo, String name) throws Exception {
-    try (RevWalk rw = new RevWalk(repo)) {
-      Ref r = repo.exactRef(name);
-      return r != null ? rw.parseCommit(r.getObjectId()) : null;
-    }
-  }
-
-  protected RevCommit getHead(Repository repo) throws Exception {
-    return getHead(repo, "HEAD");
-  }
-
-  protected RevCommit getRemoteHead(Project.NameKey project, String branch) throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      return getHead(repo, branch.startsWith(Constants.R_REFS) ? branch : "refs/heads/" + branch);
-    }
-  }
-
-  protected RevCommit getRemoteHead(String project, String branch) throws Exception {
-    return getRemoteHead(new Project.NameKey(project), branch);
-  }
-
-  protected RevCommit getRemoteHead() throws Exception {
-    return getRemoteHead(project, "master");
-  }
-
-  protected void grantTagPermissions() throws Exception {
-    grant(project, R_TAGS + "*", Permission.CREATE);
-    grant(project, R_TAGS + "", Permission.DELETE);
-    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
-    grant(project, R_TAGS + "*", Permission.CREATE_SIGNED_TAG);
-  }
-
-  protected void assertMailReplyTo(Message message, String email) throws Exception {
-    assertThat(message.headers()).containsKey("Reply-To");
-    EmailHeader.String replyTo = (EmailHeader.String) message.headers().get("Reply-To");
-    assertThat(replyTo.getString()).contains(email);
-  }
-
-  protected ContributorAgreement configureContributorAgreement(boolean autoVerify)
-      throws Exception {
-    ContributorAgreement ca;
-    if (autoVerify) {
-      String g = createGroup("cla-test-group");
-      GroupApi groupApi = gApi.groups().id(g);
-      groupApi.description("CLA test group");
-      InternalGroup caGroup =
-          groupCache.get(new AccountGroup.UUID(groupApi.detail().id)).orElse(null);
-      GroupReference groupRef = new GroupReference(caGroup.getGroupUUID(), caGroup.getName());
-      PermissionRule rule = new PermissionRule(groupRef);
-      rule.setAction(PermissionRule.Action.ALLOW);
-      ca = new ContributorAgreement("cla-test");
-      ca.setAutoVerify(groupRef);
-      ca.setAccepted(ImmutableList.of(rule));
-    } else {
-      ca = new ContributorAgreement("cla-test-no-auto-verify");
-    }
-    ca.setDescription("description");
-    ca.setAgreementUrl("agreement-url");
-
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    cfg.replace(ca);
-    saveProjectConfig(allProjects, cfg);
-    return ca;
-  }
-
-  protected BinaryResult submitPreview(String changeId) throws Exception {
-    return gApi.changes().id(changeId).current().submitPreview();
-  }
-
-  protected BinaryResult submitPreview(String changeId, String format) throws Exception {
-    return gApi.changes().id(changeId).current().submitPreview(format);
-  }
-
-  protected Map<Branch.NameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
-    try (BinaryResult result = submitPreview(changeId)) {
-      return fetchFromBundles(result);
-    }
-  }
-
-  /**
-   * Fetches each bundle into a newly cloned repository, then it applies the bundle, and returns the
-   * resulting tree id.
-   *
-   * <p>Omits NoteDb meta refs.
-   */
-  protected Map<Branch.NameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
-    assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
-
-    FileSystem fs = Jimfs.newFileSystem();
-    Path previewPath = fs.getPath("preview.zip");
-    try (OutputStream out = Files.newOutputStream(previewPath)) {
-      bundles.writeTo(out);
-    }
-    Map<Branch.NameKey, ObjectId> ret = new HashMap<>();
-    try (FileSystem zipFs = FileSystems.newFileSystem(previewPath, null);
-        DirectoryStream<Path> dirStream =
-            Files.newDirectoryStream(Iterables.getOnlyElement(zipFs.getRootDirectories()))) {
-      for (Path p : dirStream) {
-        if (!Files.isRegularFile(p)) {
-          continue;
-        }
-        String bundleName = p.getFileName().toString();
-        int len = bundleName.length();
-        assertThat(bundleName).endsWith(".git");
-        String repoName = bundleName.substring(0, len - 4);
-        Project.NameKey proj = new Project.NameKey(repoName);
-        TestRepository<?> localRepo = cloneProject(proj);
-
-        try (InputStream bundleStream = Files.newInputStream(p);
-            TransportBundleStream tbs =
-                new TransportBundleStream(
-                    localRepo.getRepository(), new URIish(bundleName), bundleStream)) {
-          FetchResult fr =
-              tbs.fetch(
-                  NullProgressMonitor.INSTANCE,
-                  Arrays.asList(new RefSpec("refs/*:refs/preview/*")));
-          for (Ref r : fr.getAdvertisedRefs()) {
-            String refName = r.getName();
-            if (RefNames.isNoteDbMetaRef(refName)) {
-              continue;
-            }
-            RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
-            ret.put(new Branch.NameKey(proj, refName), c.getTree().copy());
-          }
-        }
-      }
-    }
-    assertThat(ret).isNotEmpty();
-    return ret;
-  }
-
-  /** Assert that the given branches have the given tree ids. */
-  protected void assertTrees(Project.NameKey proj, Map<Branch.NameKey, ObjectId> trees)
-      throws Exception {
-    TestRepository<?> localRepo = cloneProject(proj);
-    GitUtil.fetch(localRepo, "refs/*:refs/*");
-    Map<String, Ref> refs = localRepo.getRepository().getAllRefs();
-    Map<Branch.NameKey, RevTree> refValues = new HashMap<>();
-
-    for (Branch.NameKey b : trees.keySet()) {
-      if (!b.getParentKey().equals(proj)) {
-        continue;
-      }
-
-      Ref r = refs.get(b.get());
-      assertThat(r).isNotNull();
-      RevWalk rw = localRepo.getRevWalk();
-      RevCommit c = rw.parseCommit(r.getObjectId());
-      refValues.put(b, c.getTree());
-
-      assertThat(trees.get(b)).isEqualTo(refValues.get(b));
-    }
-    assertThat(refValues.keySet()).containsAnyIn(trees.keySet());
-  }
-
-  protected void assertDiffForNewFile(
-      DiffInfo diff, RevCommit commit, String path, String expectedContentSideB) throws Exception {
-    List<String> expectedLines = new ArrayList<>();
-    for (String line : expectedContentSideB.split("\n")) {
-      expectedLines.add(line);
-    }
-
-    assertThat(diff.binary).isNull();
-    assertThat(diff.changeType).isEqualTo(ChangeType.ADDED);
-    assertThat(diff.diffHeader).isNotNull();
-    assertThat(diff.intralineStatus).isNull();
-    assertThat(diff.webLinks).isNull();
-
-    assertThat(diff.metaA).isNull();
-    assertThat(diff.metaB).isNotNull();
-    assertThat(diff.metaB.commitId).isEqualTo(commit.name());
-
-    String expectedContentType = "text/plain";
-    if (COMMIT_MSG.equals(path)) {
-      expectedContentType = FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE;
-    } else if (MERGE_LIST.equals(path)) {
-      expectedContentType = FileContentUtil.TEXT_X_GERRIT_MERGE_LIST;
-    }
-    assertThat(diff.metaB.contentType).isEqualTo(expectedContentType);
-
-    assertThat(diff.metaB.lines).isEqualTo(expectedLines.size());
-    assertThat(diff.metaB.name).isEqualTo(path);
-    assertThat(diff.metaB.webLinks).isNull();
-
-    assertThat(diff.content).hasSize(1);
-    DiffInfo.ContentEntry contentEntry = diff.content.get(0);
-    assertThat(contentEntry.b).containsExactlyElementsIn(expectedLines).inOrder();
-    assertThat(contentEntry.a).isNull();
-    assertThat(contentEntry.ab).isNull();
-    assertThat(contentEntry.common).isNull();
-    assertThat(contentEntry.editA).isNull();
-    assertThat(contentEntry.editB).isNull();
-    assertThat(contentEntry.skip).isNull();
-  }
-
-  protected TestRepository<?> createProjectWithPush(
-      String name, @Nullable Project.NameKey parent, SubmitType submitType) throws Exception {
-    Project.NameKey project = createProject(name, parent, true, submitType);
-    grant(project, "refs/heads/*", Permission.PUSH);
-    grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
-    return cloneProject(project);
-  }
-
-  protected void assertPermitted(ChangeInfo info, String label, Integer... expected) {
-    assertThat(info.permittedLabels).isNotNull();
-    Collection<String> strs = info.permittedLabels.get(label);
-    if (expected.length == 0) {
-      assertThat(strs).isNull();
-    } else {
-      assertThat(strs.stream().map(s -> Integer.valueOf(s.trim())).collect(toList()))
-          .containsExactlyElementsIn(Arrays.asList(expected));
-    }
-  }
-
-  protected void assertNotifyTo(TestAccount expected) {
-    assertNotifyTo(expected.emailAddress);
-  }
-
-  protected void assertNotifyTo(Address expected) {
-    assertThat(sender.getMessages()).hasSize(1);
-    Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected);
-    assertThat(((EmailHeader.AddressList) m.headers().get("To")).getAddressList())
-        .containsExactly(expected);
-    assertThat(m.headers().get("CC").isEmpty()).isTrue();
-  }
-
-  protected void assertNotifyCc(TestAccount expected) {
-    assertNotifyCc(expected.emailAddress);
-  }
-
-  protected void assertNotifyCc(Address expected) {
-    assertThat(sender.getMessages()).hasSize(1);
-    Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected);
-    assertThat(m.headers().get("To").isEmpty()).isTrue();
-    assertThat(((EmailHeader.AddressList) m.headers().get("CC")).getAddressList())
-        .containsExactly(expected);
-  }
-
-  protected void assertNotifyBcc(TestAccount expected) {
-    assertThat(sender.getMessages()).hasSize(1);
-    Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected.emailAddress);
-    assertThat(m.headers().get("To").isEmpty()).isTrue();
-    assertThat(m.headers().get("CC").isEmpty()).isTrue();
-  }
-
-  protected interface ProjectWatchInfoConfiguration {
-    void configure(ProjectWatchInfo pwi);
-  }
-
-  protected void watch(String project, ProjectWatchInfoConfiguration config)
-      throws RestApiException {
-    ProjectWatchInfo pwi = new ProjectWatchInfo();
-    pwi.project = project;
-    config.configure(pwi);
-    gApi.accounts().self().setWatchedProjects(ImmutableList.of(pwi));
-  }
-
-  protected void watch(PushOneCommit.Result r, ProjectWatchInfoConfiguration config)
-      throws OrmException, RestApiException {
-    watch(r.getChange().project().get(), config);
-  }
-
-  protected void watch(String project, String filter) throws RestApiException {
-    watch(
-        project,
-        pwi -> {
-          pwi.filter = filter;
-          pwi.notifyAbandonedChanges = true;
-          pwi.notifyNewChanges = true;
-          pwi.notifyAllComments = true;
-        });
-  }
-
-  protected void watch(String project) throws RestApiException {
-    watch(project, (String) null);
-  }
-
-  protected void assertContent(PushOneCommit.Result pushResult, String path, String expectedContent)
-      throws Exception {
-    BinaryResult bin =
-        gApi.changes()
-            .id(pushResult.getChangeId())
-            .revision(pushResult.getCommit().name())
-            .file(path)
-            .content();
-    ByteArrayOutputStream os = new ByteArrayOutputStream();
-    bin.writeTo(os);
-    String res = new String(os.toByteArray(), UTF_8);
-    assertThat(res).isEqualTo(expectedContent);
-  }
-
-  protected RevCommit createNewCommitWithoutChangeId(String branch, String file, String content)
-      throws Exception {
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk walk = new RevWalk(repo)) {
-      Ref ref = repo.exactRef(branch);
-      RevCommit tip = null;
-      if (ref != null) {
-        tip = walk.parseCommit(ref.getObjectId());
-      }
-      TestRepository<?> testSrcRepo = new TestRepository<>(repo);
-      TestRepository<?>.BranchBuilder builder = testSrcRepo.branch(branch);
-      RevCommit revCommit =
-          tip == null
-              ? builder.commit().message("commit 1").add(file, content).create()
-              : builder.commit().parent(tip).message("commit 1").add(file, content).create();
-      assertThat(GitUtil.getChangeId(testSrcRepo, revCommit).isPresent()).isFalse();
-      return revCommit;
-    }
-  }
-
-  protected RevCommit parseCurrentRevision(RevWalk rw, PushOneCommit.Result r) throws Exception {
-    return parseCurrentRevision(rw, r.getChangeId());
-  }
-
-  protected RevCommit parseCurrentRevision(RevWalk rw, String changeId) throws Exception {
-    return rw.parseCommit(
-        ObjectId.fromString(get(changeId, ListChangesOption.CURRENT_REVISION).currentRevision));
-  }
-
-  protected void enableCreateNewChangeForAllNotInTarget() throws Exception {
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config.getProject().setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
-    saveProjectConfig(project, config);
-  }
-}
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
deleted file mode 100644
index af82910..0000000
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ /dev/null
@@ -1,528 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-import static com.google.common.truth.Truth.assertAbout;
-import static com.google.gerrit.extensions.api.changes.RecipientType.BCC;
-import static com.google.gerrit.extensions.api.changes.RecipientType.CC;
-import static com.google.gerrit.extensions.api.changes.RecipientType.TO;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Joiner;
-import com.google.common.collect.ImmutableList;
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.common.truth.Truth;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewResult;
-import com.google.gerrit.extensions.api.projects.ConfigInput;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.EmailHeader;
-import com.google.gerrit.server.mail.send.EmailHeader.AddressList;
-import com.google.gerrit.testutil.FakeEmailSender;
-import com.google.gerrit.testutil.FakeEmailSender.Message;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.Function;
-import org.eclipse.jgit.junit.TestRepository;
-import org.junit.After;
-import org.junit.Before;
-
-public abstract class AbstractNotificationTest extends AbstractDaemonTest {
-  @Before
-  public void enableReviewerByEmail() throws Exception {
-    setApiUser(admin);
-    ConfigInput conf = new ConfigInput();
-    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
-    gApi.projects().name(project.get()).config(conf);
-  }
-
-  private static final SubjectFactory<FakeEmailSenderSubject, FakeEmailSender>
-      FAKE_EMAIL_SENDER_SUBJECT_FACTORY =
-          new SubjectFactory<FakeEmailSenderSubject, FakeEmailSender>() {
-            @Override
-            public FakeEmailSenderSubject getSubject(
-                FailureStrategy failureStrategy, FakeEmailSender target) {
-              return new FakeEmailSenderSubject(failureStrategy, target);
-            }
-          };
-
-  protected static FakeEmailSenderSubject assertThat(FakeEmailSender sender) {
-    return assertAbout(FAKE_EMAIL_SENDER_SUBJECT_FACTORY).that(sender);
-  }
-
-  protected void setEmailStrategy(TestAccount account, EmailStrategy strategy) throws Exception {
-    setEmailStrategy(account, strategy, true);
-  }
-
-  protected void setEmailStrategy(TestAccount account, EmailStrategy strategy, boolean record)
-      throws Exception {
-    if (record) {
-      accountsModifyingEmailStrategy.add(account);
-    }
-    setApiUser(account);
-    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
-    prefs.emailStrategy = strategy;
-    gApi.accounts().self().setPreferences(prefs);
-  }
-
-  protected static class FakeEmailSenderSubject
-      extends Subject<FakeEmailSenderSubject, FakeEmailSender> {
-    private Message message;
-    private StagedUsers users;
-    private Map<RecipientType, List<String>> recipients = new HashMap<>();
-    private Set<String> accountedFor = new HashSet<>();
-
-    FakeEmailSenderSubject(FailureStrategy failureStrategy, FakeEmailSender target) {
-      super(failureStrategy, target);
-    }
-
-    public FakeEmailSenderSubject notSent() {
-      if (actual().peekMessage() != null) {
-        fail("a message wasn't sent");
-      }
-      return this;
-    }
-
-    public FakeEmailSenderSubject sent(String messageType, StagedUsers users) {
-      message = actual().nextMessage();
-      if (message == null) {
-        fail("a message was sent");
-      }
-      recipients = new HashMap<>();
-      recipients.put(TO, parseAddresses(message, "To"));
-      recipients.put(CC, parseAddresses(message, "CC"));
-      recipients.put(
-          BCC,
-          message
-              .rcpt()
-              .stream()
-              .map(Address::getEmail)
-              .filter(e -> !recipients.get(TO).contains(e) && !recipients.get(CC).contains(e))
-              .collect(toList()));
-      this.users = users;
-      if (!message.headers().containsKey("X-Gerrit-MessageType")) {
-        fail("a message was sent with X-Gerrit-MessageType header");
-      }
-      EmailHeader header = message.headers().get("X-Gerrit-MessageType");
-      if (!header.equals(new EmailHeader.String(messageType))) {
-        fail("message of type " + messageType + " was sent; X-Gerrit-MessageType is " + header);
-      }
-
-      // Return a named subject that displays a human-readable table of
-      // recipients.
-      return named(recipientMapToString(recipients, e -> users.emailToName(e)));
-    }
-
-    private static String recipientMapToString(
-        Map<RecipientType, List<String>> recipients, Function<String, String> emailToName) {
-      StringBuilder buf = new StringBuilder();
-      buf.append('[');
-      for (RecipientType type : ImmutableList.of(TO, CC, BCC)) {
-        buf.append('\n');
-        buf.append(type);
-        buf.append(':');
-        String delim = " ";
-        for (String r : recipients.get(type)) {
-          buf.append(delim);
-          buf.append(emailToName.apply(r));
-          delim = ", ";
-        }
-      }
-      buf.append("\n]");
-      return buf.toString();
-    }
-
-    List<String> parseAddresses(Message msg, String headerName) {
-      EmailHeader header = msg.headers().get(headerName);
-      if (header == null) {
-        return ImmutableList.of();
-      }
-      Truth.assertThat(header).isInstanceOf(AddressList.class);
-      AddressList addrList = (AddressList) header;
-      return addrList.getAddressList().stream().map(Address::getEmail).collect(toList());
-    }
-
-    public FakeEmailSenderSubject to(String... emails) {
-      return rcpt(users.supportReviewersByEmail ? TO : null, emails);
-    }
-
-    public FakeEmailSenderSubject cc(String... emails) {
-      return rcpt(users.supportReviewersByEmail ? CC : null, emails);
-    }
-
-    public FakeEmailSenderSubject bcc(String... emails) {
-      return rcpt(users.supportReviewersByEmail ? BCC : null, emails);
-    }
-
-    private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, String[] emails) {
-      for (String email : emails) {
-        rcpt(type, email);
-      }
-      return this;
-    }
-
-    private void rcpt(@Nullable RecipientType type, String email) {
-      rcpt(TO, email, TO.equals(type));
-      rcpt(CC, email, CC.equals(type));
-      rcpt(BCC, email, BCC.equals(type));
-    }
-
-    private void rcpt(@Nullable RecipientType type, String email, boolean expected) {
-      if (recipients.get(type).contains(email) != expected) {
-        fail(
-            expected ? "notifies" : "doesn't notify",
-            "]\n" + type + ": " + users.emailToName(email) + "\n]");
-      }
-      if (expected) {
-        accountedFor.add(email);
-      }
-    }
-
-    public FakeEmailSenderSubject noOneElse() {
-      for (Map.Entry<NotifyType, TestAccount> watchEntry : users.watchers.entrySet()) {
-        if (!accountedFor.contains(watchEntry.getValue().email)) {
-          notTo(watchEntry.getKey());
-        }
-      }
-
-      Map<RecipientType, List<String>> unaccountedFor = new HashMap<>();
-      boolean ok = true;
-      for (Map.Entry<RecipientType, List<String>> entry : recipients.entrySet()) {
-        unaccountedFor.put(entry.getKey(), new ArrayList<>());
-        for (String address : entry.getValue()) {
-          if (!accountedFor.contains(address)) {
-            unaccountedFor.get(entry.getKey()).add(address);
-            ok = false;
-          }
-        }
-      }
-      if (!ok) {
-        fail(
-            "was fully tested, missing assertions for: "
-                + recipientMapToString(unaccountedFor, e -> users.emailToName(e)));
-      }
-      return this;
-    }
-
-    public FakeEmailSenderSubject notTo(String... emails) {
-      return rcpt(null, emails);
-    }
-
-    public FakeEmailSenderSubject to(TestAccount... accounts) {
-      return rcpt(TO, accounts);
-    }
-
-    public FakeEmailSenderSubject cc(TestAccount... accounts) {
-      return rcpt(CC, accounts);
-    }
-
-    public FakeEmailSenderSubject bcc(TestAccount... accounts) {
-      return rcpt(BCC, accounts);
-    }
-
-    public FakeEmailSenderSubject notTo(TestAccount... accounts) {
-      return rcpt(null, accounts);
-    }
-
-    private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, TestAccount[] accounts) {
-      for (TestAccount account : accounts) {
-        rcpt(type, account);
-      }
-      return this;
-    }
-
-    private void rcpt(@Nullable RecipientType type, TestAccount account) {
-      rcpt(type, account.email);
-    }
-
-    public FakeEmailSenderSubject to(NotifyType... watches) {
-      return rcpt(TO, watches);
-    }
-
-    public FakeEmailSenderSubject cc(NotifyType... watches) {
-      return rcpt(CC, watches);
-    }
-
-    public FakeEmailSenderSubject bcc(NotifyType... watches) {
-      return rcpt(BCC, watches);
-    }
-
-    public FakeEmailSenderSubject notTo(NotifyType... watches) {
-      return rcpt(null, watches);
-    }
-
-    private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, NotifyType[] watches) {
-      for (NotifyType watch : watches) {
-        rcpt(type, watch);
-      }
-      return this;
-    }
-
-    private void rcpt(@Nullable RecipientType type, NotifyType watch) {
-      if (!users.watchers.containsKey(watch)) {
-        fail("configured to watch", watch);
-      }
-      rcpt(type, users.watchers.get(watch));
-    }
-  }
-
-  private static final Map<String, StagedUsers> stagedUsers = new HashMap<>();
-
-  // TestAccount doesn't implement hashCode/equals, so this set is according
-  // to object identity. That's fine for our purposes.
-  private Set<TestAccount> accountsModifyingEmailStrategy = new HashSet<>();
-
-  @After
-  public void resetEmailStrategies() throws Exception {
-    for (TestAccount account : accountsModifyingEmailStrategy) {
-      setEmailStrategy(account, EmailStrategy.ENABLED, false);
-    }
-    accountsModifyingEmailStrategy.clear();
-  }
-
-  protected class StagedUsers {
-    public final TestAccount owner;
-    public final TestAccount author;
-    public final TestAccount uploader;
-    public final TestAccount reviewer;
-    public final TestAccount ccer;
-    public final TestAccount starrer;
-    public final TestAccount assignee;
-    public final TestAccount watchingProjectOwner;
-    public final String reviewerByEmail = "reviewerByEmail@example.com";
-    public final String ccerByEmail = "ccByEmail@example.com";
-    private final Map<NotifyType, TestAccount> watchers = new HashMap<>();
-    private final Map<String, TestAccount> accountsByEmail = new HashMap<>();
-    boolean supportReviewersByEmail;
-
-    private String usersCacheKey() {
-      return description.getClassName();
-    }
-
-    private TestAccount evictAndCopy(TestAccount account) throws IOException {
-      accountCache.evict(account.id);
-      return account;
-    }
-
-    public StagedUsers() throws Exception {
-      synchronized (stagedUsers) {
-        if (stagedUsers.containsKey(usersCacheKey())) {
-          StagedUsers existing = stagedUsers.get(usersCacheKey());
-          owner = evictAndCopy(existing.owner);
-          author = evictAndCopy(existing.author);
-          uploader = evictAndCopy(existing.uploader);
-          reviewer = evictAndCopy(existing.reviewer);
-          ccer = evictAndCopy(existing.ccer);
-          starrer = evictAndCopy(existing.starrer);
-          assignee = evictAndCopy(existing.assignee);
-          watchingProjectOwner = evictAndCopy(existing.watchingProjectOwner);
-          watchers.putAll(existing.watchers);
-          return;
-        }
-
-        owner = testAccount("owner");
-        reviewer = testAccount("reviewer");
-        author = testAccount("author");
-        uploader = testAccount("uploader");
-        ccer = testAccount("ccer");
-        starrer = testAccount("starrer");
-        assignee = testAccount("assignee");
-
-        watchingProjectOwner = testAccount("watchingProjectOwner", "Administrators");
-        setApiUser(watchingProjectOwner);
-        watch(allProjects.get(), pwi -> pwi.notifyNewChanges = true);
-
-        for (NotifyType watch : NotifyType.values()) {
-          if (watch == NotifyType.ALL) {
-            continue;
-          }
-          TestAccount watcher = testAccount(watch.toString());
-          setApiUser(watcher);
-          watch(
-              allProjects.get(),
-              pwi -> {
-                pwi.notifyAllComments = watch.equals(NotifyType.ALL_COMMENTS);
-                pwi.notifyAbandonedChanges = watch.equals(NotifyType.ABANDONED_CHANGES);
-                pwi.notifyNewChanges = watch.equals(NotifyType.NEW_CHANGES);
-                pwi.notifyNewPatchSets = watch.equals(NotifyType.NEW_PATCHSETS);
-                pwi.notifySubmittedChanges = watch.equals(NotifyType.SUBMITTED_CHANGES);
-              });
-          watchers.put(watch, watcher);
-        }
-
-        stagedUsers.put(usersCacheKey(), this);
-      }
-    }
-
-    private String email(String username) {
-      // Email validator rejects usernames longer than 64 bytes.
-      if (username.length() > 64) {
-        username = username.substring(username.length() - 64);
-        if (username.startsWith(".")) {
-          username = username.substring(1);
-        }
-      }
-      return username + "@example.com";
-    }
-
-    public TestAccount testAccount(String name) throws Exception {
-      String username = name(name);
-      TestAccount account = accountCreator.create(username, email(username), name);
-      accountsByEmail.put(account.email, account);
-      return account;
-    }
-
-    public TestAccount testAccount(String name, String groupName) throws Exception {
-      String username = name(name);
-      TestAccount account = accountCreator.create(username, email(username), name, groupName);
-      accountsByEmail.put(account.email, account);
-      return account;
-    }
-
-    String emailToName(String email) {
-      if (accountsByEmail.containsKey(email)) {
-        return accountsByEmail.get(email).fullName;
-      }
-      return email;
-    }
-
-    protected void addReviewers(PushOneCommit.Result r) throws Exception {
-      ReviewInput in =
-          ReviewInput.noScore()
-              .reviewer(reviewer.email)
-              .reviewer(reviewerByEmail)
-              .reviewer(ccer.email, ReviewerState.CC, false)
-              .reviewer(ccerByEmail, ReviewerState.CC, false);
-      ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
-      supportReviewersByEmail = true;
-      if (result.reviewers.values().stream().anyMatch(v -> v.error != null)) {
-        supportReviewersByEmail = false;
-        in =
-            ReviewInput.noScore()
-                .reviewer(reviewer.email)
-                .reviewer(ccer.email, ReviewerState.CC, false);
-        result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
-      }
-      Truth.assertThat(result.reviewers.values().stream().allMatch(v -> v.error == null)).isTrue();
-    }
-  }
-
-  protected interface PushOptionGenerator {
-    List<String> pushOptions(StagedUsers users);
-  }
-
-  protected class StagedPreChange extends StagedUsers {
-    public final TestRepository<?> repo;
-    protected final PushOneCommit.Result result;
-    public final String changeId;
-
-    StagedPreChange(String ref) throws Exception {
-      this(ref, null);
-    }
-
-    StagedPreChange(String ref, @Nullable PushOptionGenerator pushOptionGenerator)
-        throws Exception {
-      super();
-      List<String> pushOptions = null;
-      if (pushOptionGenerator != null) {
-        pushOptions = pushOptionGenerator.pushOptions(this);
-      }
-      if (pushOptions != null) {
-        ref = ref + '%' + Joiner.on(',').join(pushOptions);
-      }
-      setApiUser(owner);
-      repo = cloneProject(project, owner);
-      PushOneCommit push = pushFactory.create(db, owner.getIdent(), repo);
-      result = push.to(ref);
-      result.assertOkStatus();
-      changeId = result.getChangeId();
-    }
-  }
-
-  protected StagedPreChange stagePreChange(String ref) throws Exception {
-    return new StagedPreChange(ref);
-  }
-
-  protected StagedPreChange stagePreChange(
-      String ref, @Nullable PushOptionGenerator pushOptionGenerator) throws Exception {
-    return new StagedPreChange(ref, pushOptionGenerator);
-  }
-
-  protected class StagedChange extends StagedPreChange {
-    StagedChange(String ref) throws Exception {
-      super(ref);
-
-      setApiUser(starrer);
-      gApi.accounts().self().starChange(result.getChangeId());
-
-      setApiUser(owner);
-      addReviewers(result);
-      sender.clear();
-    }
-  }
-
-  protected StagedChange stageReviewableChange() throws Exception {
-    return new StagedChange("refs/for/master");
-  }
-
-  protected StagedChange stageWipChange() throws Exception {
-    return new StagedChange("refs/for/master%wip");
-  }
-
-  protected StagedChange stageReviewableWipChange() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    setApiUser(sc.owner);
-    gApi.changes().id(sc.changeId).setWorkInProgress();
-    return sc;
-  }
-
-  protected StagedChange stageAbandonedReviewableChange() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    setApiUser(sc.owner);
-    gApi.changes().id(sc.changeId).abandon();
-    sender.clear();
-    return sc;
-  }
-
-  protected StagedChange stageAbandonedReviewableWipChange() throws Exception {
-    StagedChange sc = stageReviewableWipChange();
-    setApiUser(sc.owner);
-    gApi.changes().id(sc.changeId).abandon();
-    sender.clear();
-    return sc;
-  }
-
-  protected StagedChange stageAbandonedWipChange() throws Exception {
-    StagedChange sc = stageWipChange();
-    setApiUser(sc.owner);
-    gApi.changes().id(sc.changeId).abandon();
-    sender.clear();
-    return sc;
-  }
-}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
deleted file mode 100644
index 987cb97..0000000
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
+++ /dev/null
@@ -1,213 +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;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.RequestCleanup;
-import com.google.gerrit.server.config.RequestScopedReviewDbProvider;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestScopePropagator;
-import com.google.gerrit.testutil.DisabledReviewDb;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Key;
-import com.google.inject.OutOfScopeException;
-import com.google.inject.Provider;
-import com.google.inject.Scope;
-import com.google.inject.util.Providers;
-import java.util.HashMap;
-import java.util.Map;
-
-/** Guice scopes for state during an Acceptance Test connection. */
-public class AcceptanceTestRequestScope {
-  private static final Key<RequestCleanup> RC_KEY = Key.get(RequestCleanup.class);
-
-  private static final Key<RequestScopedReviewDbProvider> DB_KEY =
-      Key.get(RequestScopedReviewDbProvider.class);
-
-  public static class Context implements RequestContext {
-    private final RequestCleanup cleanup = new RequestCleanup();
-    private final Map<Key<?>, Object> map = new HashMap<>();
-    private final SchemaFactory<ReviewDb> schemaFactory;
-    private final SshSession session;
-    private final CurrentUser user;
-
-    final long created;
-    volatile long started;
-    volatile long finished;
-
-    private Context(SchemaFactory<ReviewDb> sf, SshSession s, CurrentUser u, long at) {
-      schemaFactory = sf;
-      session = s;
-      user = u;
-      created = started = finished = at;
-      map.put(RC_KEY, cleanup);
-      map.put(DB_KEY, new RequestScopedReviewDbProvider(schemaFactory, Providers.of(cleanup)));
-    }
-
-    private Context(Context p, SshSession s, CurrentUser c) {
-      this(p.schemaFactory, s, c, p.created);
-      started = p.started;
-      finished = p.finished;
-    }
-
-    SshSession getSession() {
-      return session;
-    }
-
-    @Override
-    public CurrentUser getUser() {
-      if (user == null) {
-        throw new IllegalStateException("user == null, forgot to set it?");
-      }
-      return user;
-    }
-
-    @Override
-    public Provider<ReviewDb> getReviewDbProvider() {
-      return (RequestScopedReviewDbProvider) map.get(DB_KEY);
-    }
-
-    synchronized <T> T get(Key<T> key, Provider<T> creator) {
-      @SuppressWarnings("unchecked")
-      T t = (T) map.get(key);
-      if (t == null) {
-        t = creator.get();
-        map.put(key, t);
-      }
-      return t;
-    }
-  }
-
-  static class ContextProvider implements Provider<Context> {
-    @Override
-    public Context get() {
-      return requireContext();
-    }
-  }
-
-  static class SshSessionProvider implements Provider<SshSession> {
-    @Override
-    public SshSession get() {
-      return requireContext().getSession();
-    }
-  }
-
-  static class Propagator extends ThreadLocalRequestScopePropagator<Context> {
-    private final AcceptanceTestRequestScope atrScope;
-
-    @Inject
-    Propagator(
-        AcceptanceTestRequestScope atrScope,
-        ThreadLocalRequestContext local,
-        Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
-      super(REQUEST, current, local, dbProviderProvider);
-      this.atrScope = atrScope;
-    }
-
-    @Override
-    protected Context continuingContext(Context ctx) {
-      // The cleanup is not chained, since the RequestScopePropagator executors
-      // the Context's cleanup when finished executing.
-      return atrScope.newContinuingContext(ctx);
-    }
-  }
-
-  private static final ThreadLocal<Context> current = new ThreadLocal<>();
-
-  private static Context requireContext() {
-    final Context ctx = current.get();
-    if (ctx == null) {
-      throw new OutOfScopeException("Not in command/request");
-    }
-    return ctx;
-  }
-
-  private final ThreadLocalRequestContext local;
-
-  @Inject
-  AcceptanceTestRequestScope(ThreadLocalRequestContext local) {
-    this.local = local;
-  }
-
-  public Context newContext(SchemaFactory<ReviewDb> sf, SshSession s, CurrentUser user) {
-    return new Context(sf, s, user, TimeUtil.nowMs());
-  }
-
-  private Context newContinuingContext(Context ctx) {
-    return new Context(ctx, ctx.getSession(), ctx.getUser());
-  }
-
-  public Context set(Context ctx) {
-    Context old = current.get();
-    current.set(ctx);
-    local.setContext(ctx);
-    return old;
-  }
-
-  public Context get() {
-    return current.get();
-  }
-
-  public Context disableDb() {
-    Context old = current.get();
-    SchemaFactory<ReviewDb> sf =
-        new SchemaFactory<ReviewDb>() {
-          @Override
-          public ReviewDb open() {
-            return new DisabledReviewDb();
-          }
-        };
-    Context ctx = new Context(sf, old.session, old.user, old.created);
-
-    current.set(ctx);
-    local.setContext(ctx);
-    return old;
-  }
-
-  public Context reopenDb() {
-    // Setting a new context with the same fields is enough to get the ReviewDb
-    // provider to reopen the database.
-    Context old = current.get();
-    return set(new Context(old.schemaFactory, old.session, old.user, old.created));
-  }
-
-  /** Returns exactly one instance per command executed. */
-  static final Scope REQUEST =
-      new Scope() {
-        @Override
-        public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
-          return new Provider<T>() {
-            @Override
-            public T get() {
-              return requireContext().get(key, creator);
-            }
-
-            @Override
-            public String toString() {
-              return String.format("%s[%s]", creator, REQUEST);
-            }
-          };
-        }
-
-        @Override
-        public String toString() {
-          return "Acceptance Test Scope.REQUEST";
-        }
-      };
-}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
deleted file mode 100644
index a8f7767..0000000
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
+++ /dev/null
@@ -1,191 +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;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static java.nio.charset.StandardCharsets.US_ASCII;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.VersionedAuthorizedKeys;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
-import com.google.gerrit.server.group.GroupsUpdate;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.ServerInitiated;
-import com.google.gerrit.server.ssh.SshKeyCache;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.JSchException;
-import com.jcraft.jsch.KeyPair;
-import java.io.ByteArrayOutputStream;
-import java.io.UnsupportedEncodingException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-
-@Singleton
-public class AccountCreator {
-  private final Map<String, TestAccount> accounts;
-
-  private final SchemaFactory<ReviewDb> reviewDbProvider;
-  private final Sequences sequences;
-  private final AccountsUpdate.Server accountsUpdate;
-  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
-  private final GroupCache groupCache;
-  private final Provider<GroupsUpdate> groupsUpdateProvider;
-  private final SshKeyCache sshKeyCache;
-  private final ExternalIdsUpdate.Server externalIdsUpdate;
-  private final boolean sshEnabled;
-
-  @Inject
-  AccountCreator(
-      SchemaFactory<ReviewDb> schema,
-      Sequences sequences,
-      AccountsUpdate.Server accountsUpdate,
-      VersionedAuthorizedKeys.Accessor authorizedKeys,
-      GroupCache groupCache,
-      @ServerInitiated Provider<GroupsUpdate> groupsUpdateProvider,
-      SshKeyCache sshKeyCache,
-      ExternalIdsUpdate.Server externalIdsUpdate,
-      @SshEnabled boolean sshEnabled) {
-    accounts = new HashMap<>();
-    reviewDbProvider = schema;
-    this.sequences = sequences;
-    this.accountsUpdate = accountsUpdate;
-    this.authorizedKeys = authorizedKeys;
-    this.groupCache = groupCache;
-    this.groupsUpdateProvider = groupsUpdateProvider;
-    this.sshKeyCache = sshKeyCache;
-    this.externalIdsUpdate = externalIdsUpdate;
-    this.sshEnabled = sshEnabled;
-  }
-
-  public synchronized TestAccount create(
-      @Nullable String username,
-      @Nullable String email,
-      @Nullable String fullName,
-      String... groupNames)
-      throws Exception {
-
-    TestAccount account = accounts.get(username);
-    if (account != null) {
-      return account;
-    }
-    try (ReviewDb db = reviewDbProvider.open()) {
-      Account.Id id = new Account.Id(sequences.nextAccountId());
-
-      List<ExternalId> extIds = new ArrayList<>(2);
-      String httpPass = null;
-      if (username != null) {
-        httpPass = "http-pass";
-        extIds.add(ExternalId.createUsername(username, id, httpPass));
-      }
-
-      if (email != null) {
-        extIds.add(ExternalId.createEmail(id, email));
-      }
-      externalIdsUpdate.create().insert(extIds);
-
-      accountsUpdate
-          .create()
-          .insert(
-              id,
-              a -> {
-                a.setFullName(fullName);
-                a.setPreferredEmail(email);
-              });
-
-      if (groupNames != null) {
-        for (String n : groupNames) {
-          AccountGroup.NameKey k = new AccountGroup.NameKey(n);
-          Optional<InternalGroup> group = groupCache.get(k);
-          if (!group.isPresent()) {
-            throw new NoSuchGroupException(n);
-          }
-          groupsUpdateProvider.get().addGroupMember(db, group.get().getGroupUUID(), id);
-        }
-      }
-
-      KeyPair sshKey = null;
-      if (sshEnabled && username != null) {
-        sshKey = genSshKey();
-        authorizedKeys.addKey(id, publicKey(sshKey, email));
-        sshKeyCache.evict(username);
-      }
-
-      account = new TestAccount(id, username, email, fullName, sshKey, httpPass);
-      if (username != null) {
-        accounts.put(username, account);
-      }
-      return account;
-    }
-  }
-
-  public TestAccount create(@Nullable String username, String group) throws Exception {
-    return create(username, null, username, group);
-  }
-
-  public TestAccount create() throws Exception {
-    return create(null);
-  }
-
-  public TestAccount create(@Nullable String username) throws Exception {
-    return create(username, null, username, (String[]) null);
-  }
-
-  public TestAccount admin() throws Exception {
-    return create("admin", "admin@example.com", "Administrator", "Administrators");
-  }
-
-  public TestAccount admin2() throws Exception {
-    return create("admin2", "admin2@example.com", "Administrator2", "Administrators");
-  }
-
-  public TestAccount user() throws Exception {
-    return create("user", "user@example.com", "User");
-  }
-
-  public TestAccount user2() throws Exception {
-    return create("user2", "user2@example.com", "User2");
-  }
-
-  public TestAccount get(String username) {
-    return checkNotNull(accounts.get(username), "No TestAccount created for %s", username);
-  }
-
-  public static KeyPair genSshKey() throws JSchException {
-    JSch jsch = new JSch();
-    return KeyPair.genKeyPair(jsch, KeyPair.RSA);
-  }
-
-  public static String publicKey(KeyPair sshKey, String comment)
-      throws UnsupportedEncodingException {
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    sshKey.writePublicKey(out, comment);
-    return out.toString(US_ASCII.name()).trim();
-  }
-}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
deleted file mode 100644
index 72e7058..0000000
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
+++ /dev/null
@@ -1,198 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.common.UserScopedEventListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.data.RefUpdateAttribute;
-import com.google.gerrit.server.events.ChangeMergedEvent;
-import com.google.gerrit.server.events.Event;
-import com.google.gerrit.server.events.RefEvent;
-import com.google.gerrit.server.events.RefUpdatedEvent;
-import com.google.gerrit.server.events.ReviewerDeletedEvent;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
-
-public class EventRecorder {
-  private final RegistrationHandle eventListenerRegistration;
-  private final ListMultimap<String, RefEvent> recordedEvents;
-
-  @Singleton
-  public static class Factory {
-    private final DynamicSet<UserScopedEventListener> eventListeners;
-    private final IdentifiedUser.GenericFactory userFactory;
-
-    @Inject
-    Factory(
-        DynamicSet<UserScopedEventListener> eventListeners,
-        IdentifiedUser.GenericFactory userFactory) {
-      this.eventListeners = eventListeners;
-      this.userFactory = userFactory;
-    }
-
-    public EventRecorder create(TestAccount user) {
-      return new EventRecorder(eventListeners, userFactory.create(user.id));
-    }
-  }
-
-  public EventRecorder(DynamicSet<UserScopedEventListener> eventListeners, IdentifiedUser user) {
-    recordedEvents = LinkedListMultimap.create();
-
-    eventListenerRegistration =
-        eventListeners.add(
-            new UserScopedEventListener() {
-              @Override
-              public void onEvent(Event e) {
-                if (e instanceof ReviewerDeletedEvent) {
-                  recordedEvents.put(ReviewerDeletedEvent.TYPE, (ReviewerDeletedEvent) e);
-                } else if (e instanceof RefEvent) {
-                  RefEvent event = (RefEvent) e;
-                  String key =
-                      refEventKey(
-                          event.getType(), event.getProjectNameKey().get(), event.getRefName());
-                  recordedEvents.put(key, event);
-                }
-              }
-
-              @Override
-              public CurrentUser getUser() {
-                return user;
-              }
-            });
-  }
-
-  private static String refEventKey(String type, String project, String ref) {
-    return String.format("%s-%s-%s", type, project, ref);
-  }
-
-  private ImmutableList<RefUpdatedEvent> getRefUpdatedEvents(
-      String project, String refName, int expectedSize) {
-    String key = refEventKey(RefUpdatedEvent.TYPE, project, refName);
-    if (expectedSize == 0) {
-      assertThat(recordedEvents).doesNotContainKey(key);
-      return ImmutableList.of();
-    }
-
-    assertThat(recordedEvents).containsKey(key);
-    ImmutableList<RefUpdatedEvent> events =
-        FluentIterable.from(recordedEvents.get(key))
-            .transform(RefUpdatedEvent.class::cast)
-            .toList();
-    assertThat(events).hasSize(expectedSize);
-    return events;
-  }
-
-  private ImmutableList<ChangeMergedEvent> getChangeMergedEvents(
-      String project, String branch, int expectedSize) {
-    String key = refEventKey(ChangeMergedEvent.TYPE, project, branch);
-    if (expectedSize == 0) {
-      assertThat(recordedEvents).doesNotContainKey(key);
-      return ImmutableList.of();
-    }
-
-    assertThat(recordedEvents).containsKey(key);
-    ImmutableList<ChangeMergedEvent> events =
-        FluentIterable.from(recordedEvents.get(key))
-            .transform(ChangeMergedEvent.class::cast)
-            .toList();
-    assertThat(events).hasSize(expectedSize);
-    return events;
-  }
-
-  private ImmutableList<ReviewerDeletedEvent> getReviewerDeletedEvents(int expectedSize) {
-    String key = ReviewerDeletedEvent.TYPE;
-    if (expectedSize == 0) {
-      assertThat(recordedEvents).doesNotContainKey(key);
-      return ImmutableList.of();
-    }
-    assertThat(recordedEvents).containsKey(key);
-    ImmutableList<ReviewerDeletedEvent> events =
-        FluentIterable.from(recordedEvents.get(key))
-            .transform(ReviewerDeletedEvent.class::cast)
-            .toList();
-    assertThat(events).hasSize(expectedSize);
-    return events;
-  }
-
-  public void assertRefUpdatedEvents(String project, String branch, String... expected)
-      throws Exception {
-    ImmutableList<RefUpdatedEvent> events =
-        getRefUpdatedEvents(project, branch, expected.length / 2);
-    int i = 0;
-    for (RefUpdatedEvent event : events) {
-      RefUpdateAttribute actual = event.refUpdate.get();
-      String oldRev = expected[i] == null ? ObjectId.zeroId().name() : expected[i];
-      String newRev = expected[i + 1] == null ? ObjectId.zeroId().name() : expected[i + 1];
-      assertThat(actual.oldRev).isEqualTo(oldRev);
-      assertThat(actual.newRev).isEqualTo(newRev);
-      i += 2;
-    }
-  }
-
-  public void assertRefUpdatedEvents(String project, String branch, RevCommit... expected)
-      throws Exception {
-    ImmutableList<RefUpdatedEvent> events =
-        getRefUpdatedEvents(project, branch, expected.length / 2);
-    int i = 0;
-    for (RefUpdatedEvent event : events) {
-      RefUpdateAttribute actual = event.refUpdate.get();
-      String oldRev = expected[i] == null ? ObjectId.zeroId().name() : expected[i].name();
-      String newRev = expected[i + 1] == null ? ObjectId.zeroId().name() : expected[i + 1].name();
-      assertThat(actual.oldRev).isEqualTo(oldRev);
-      assertThat(actual.newRev).isEqualTo(newRev);
-      i += 2;
-    }
-  }
-
-  public void assertChangeMergedEvents(String project, String branch, String... expected)
-      throws Exception {
-    ImmutableList<ChangeMergedEvent> events =
-        getChangeMergedEvents(project, branch, expected.length / 2);
-    int i = 0;
-    for (ChangeMergedEvent event : events) {
-      String id = event.change.get().id;
-      assertThat(id).isEqualTo(expected[i]);
-      assertThat(event.newRev).isEqualTo(expected[i + 1]);
-      i += 2;
-    }
-  }
-
-  public void assertReviewerDeletedEvents(String... expected) {
-    ImmutableList<ReviewerDeletedEvent> events = getReviewerDeletedEvents(expected.length / 2);
-    int i = 0;
-    for (ReviewerDeletedEvent event : events) {
-      String id = event.change.get().id;
-      assertThat(id).isEqualTo(expected[i]);
-      String reviewer = event.reviewer.get().email;
-      assertThat(reviewer).isEqualTo(expected[i + 1]);
-      i += 2;
-    }
-  }
-
-  public void close() {
-    eventListenerRegistration.remove();
-  }
-}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
deleted file mode 100644
index a95ac09..0000000
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
+++ /dev/null
@@ -1,516 +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;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.lucene.LuceneIndexModule;
-import com.google.gerrit.pgm.Daemon;
-import com.google.gerrit.pgm.Init;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
-import com.google.gerrit.server.ssh.NoSshModule;
-import com.google.gerrit.server.util.ManualRequestContext;
-import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.gerrit.server.util.SocketUtil;
-import com.google.gerrit.server.util.SystemLog;
-import com.google.gerrit.testutil.FakeEmailSender;
-import com.google.gerrit.testutil.NoteDbChecker;
-import com.google.gerrit.testutil.NoteDbMode;
-import com.google.gerrit.testutil.SshMode;
-import com.google.gerrit.testutil.TempFileUtil;
-import com.google.inject.Injector;
-import com.google.inject.Key;
-import com.google.inject.Module;
-import java.lang.annotation.Annotation;
-import java.lang.reflect.Field;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
-import java.net.URI;
-import java.nio.file.Path;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.Locale;
-import java.util.Map;
-import java.util.concurrent.BrokenBarrierException;
-import java.util.concurrent.CyclicBarrier;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import java.util.stream.Stream;
-import org.apache.log4j.Level;
-import org.apache.log4j.Logger;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.RepositoryCache;
-import org.eclipse.jgit.util.FS;
-
-public class GerritServer implements AutoCloseable {
-  public static class StartupException extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    StartupException(String msg, Throwable cause) {
-      super(msg, cause);
-    }
-  }
-
-  @AutoValue
-  public abstract static class Description {
-    public static Description forTestClass(
-        org.junit.runner.Description testDesc, String configName) {
-      return new AutoValue_GerritServer_Description(
-          testDesc,
-          configName,
-          !has(UseLocalDisk.class, testDesc.getTestClass()) && !forceLocalDisk(),
-          !has(NoHttpd.class, testDesc.getTestClass()),
-          has(Sandboxed.class, testDesc.getTestClass()),
-          has(UseSsh.class, testDesc.getTestClass()),
-          null, // @GerritConfig is only valid on methods.
-          null, // @GerritConfigs is only valid on methods.
-          null, // @GlobalPluginConfig is only valid on methods.
-          null); // @GlobalPluginConfigs is only valid on methods.
-    }
-
-    public static Description forTestMethod(
-        org.junit.runner.Description testDesc, String configName) {
-      return new AutoValue_GerritServer_Description(
-          testDesc,
-          configName,
-          (testDesc.getAnnotation(UseLocalDisk.class) == null
-                  && !has(UseLocalDisk.class, testDesc.getTestClass()))
-              && !forceLocalDisk(),
-          testDesc.getAnnotation(NoHttpd.class) == null
-              && !has(NoHttpd.class, testDesc.getTestClass()),
-          testDesc.getAnnotation(Sandboxed.class) != null
-              || has(Sandboxed.class, testDesc.getTestClass()),
-          testDesc.getAnnotation(UseSsh.class) != null
-              || has(UseSsh.class, testDesc.getTestClass()),
-          testDesc.getAnnotation(GerritConfig.class),
-          testDesc.getAnnotation(GerritConfigs.class),
-          testDesc.getAnnotation(GlobalPluginConfig.class),
-          testDesc.getAnnotation(GlobalPluginConfigs.class));
-    }
-
-    private static boolean has(Class<? extends Annotation> annotation, Class<?> clazz) {
-      for (; clazz != null; clazz = clazz.getSuperclass()) {
-        if (clazz.getAnnotation(annotation) != null) {
-          return true;
-        }
-      }
-      return false;
-    }
-
-    abstract org.junit.runner.Description testDescription();
-
-    @Nullable
-    abstract String configName();
-
-    abstract boolean memory();
-
-    abstract boolean httpd();
-
-    abstract boolean sandboxed();
-
-    abstract boolean useSshAnnotation();
-
-    boolean useSsh() {
-      return useSshAnnotation() && SshMode.useSsh();
-    }
-
-    @Nullable
-    abstract GerritConfig config();
-
-    @Nullable
-    abstract GerritConfigs configs();
-
-    @Nullable
-    abstract GlobalPluginConfig pluginConfig();
-
-    @Nullable
-    abstract GlobalPluginConfigs pluginConfigs();
-
-    private void checkValidAnnotations() {
-      if (configs() != null && config() != null) {
-        throw new IllegalStateException("Use either @GerritConfigs or @GerritConfig not both");
-      }
-      if (pluginConfigs() != null && pluginConfig() != null) {
-        throw new IllegalStateException(
-            "Use either @GlobalPluginConfig or @GlobalPluginConfigs not both");
-      }
-      if ((pluginConfigs() != null || pluginConfig() != null) && memory()) {
-        throw new IllegalStateException("Must use @UseLocalDisk with @GlobalPluginConfig(s)");
-      }
-    }
-
-    private Config buildConfig(Config baseConfig) {
-      if (configs() != null) {
-        return ConfigAnnotationParser.parse(baseConfig, configs());
-      } else if (config() != null) {
-        return ConfigAnnotationParser.parse(baseConfig, config());
-      } else {
-        return baseConfig;
-      }
-    }
-
-    private Map<String, Config> buildPluginConfigs() {
-      if (pluginConfigs() != null) {
-        return ConfigAnnotationParser.parse(pluginConfigs());
-      } else if (pluginConfig() != null) {
-        return ConfigAnnotationParser.parse(pluginConfig());
-      }
-      return new HashMap<>();
-    }
-  }
-
-  private static boolean forceLocalDisk() {
-    String value = Strings.nullToEmpty(System.getenv("GERRIT_FORCE_LOCAL_DISK"));
-    if (value.isEmpty()) {
-      value = Strings.nullToEmpty(System.getProperty("gerrit.forceLocalDisk"));
-    }
-    switch (value.trim().toLowerCase(Locale.US)) {
-      case "1":
-      case "yes":
-      case "true":
-        return true;
-      default:
-        return false;
-    }
-  }
-
-  /**
-   * Initializes on-disk site but does not start server.
-   *
-   * @param desc server description
-   * @param baseConfig default config values; merged with config from {@code desc} and then written
-   *     into {@code site/etc/gerrit.config}.
-   * @param site temp directory where site will live.
-   * @throws Exception
-   */
-  public static void init(Description desc, Config baseConfig, Path site) throws Exception {
-    checkArgument(!desc.memory(), "can't initialize site path for in-memory test: %s", desc);
-    Config cfg = desc.buildConfig(baseConfig);
-    Map<String, Config> pluginConfigs = desc.buildPluginConfigs();
-
-    MergeableFileBasedConfig gerritConfig =
-        new MergeableFileBasedConfig(
-            site.resolve("etc").resolve("gerrit.config").toFile(), FS.DETECTED);
-    gerritConfig.load();
-    gerritConfig.merge(cfg);
-    mergeTestConfig(gerritConfig);
-    gerritConfig.save();
-
-    Init init = new Init();
-    int rc =
-        init.main(
-            new String[] {
-              "-d", site.toString(), "--batch", "--no-auto-start", "--skip-plugins",
-            });
-    if (rc != 0) {
-      throw new RuntimeException("Couldn't initialize site");
-    }
-
-    for (String pluginName : pluginConfigs.keySet()) {
-      MergeableFileBasedConfig pluginCfg =
-          new MergeableFileBasedConfig(
-              site.resolve("etc").resolve(pluginName + ".config").toFile(), FS.DETECTED);
-      pluginCfg.load();
-      pluginCfg.merge(pluginConfigs.get(pluginName));
-      pluginCfg.save();
-    }
-  }
-
-  /**
-   * Initializes new Gerrit site and returns started server.
-   *
-   * <p>A new temporary directory for the site will be created with {@link TempFileUtil}, even in
-   * the server is otherwise configured in-memory. Closing the server stops the daemon but does not
-   * delete the temporary directory. Callers may either get the directory with {@link
-   * #getSitePath()} and delete it manually, or call {@link TempFileUtil#cleanup()}.
-   *
-   * @param desc server description.
-   * @param baseConfig default config values; merged with config from {@code desc}.
-   * @return started server.
-   * @throws Exception
-   */
-  public static GerritServer initAndStart(Description desc, Config baseConfig) throws Exception {
-    Path site = TempFileUtil.createTempDirectory().toPath();
-    baseConfig = new Config(baseConfig);
-    baseConfig.setString("gerrit", null, "basePath", site.resolve("git").toString());
-    baseConfig.setString("gerrit", null, "tempSiteDir", site.toString());
-    try {
-      if (!desc.memory()) {
-        init(desc, baseConfig, site);
-      }
-      return start(desc, baseConfig, site, null);
-    } catch (Exception e) {
-      TempFileUtil.recursivelyDelete(site.toFile());
-      throw e;
-    }
-  }
-
-  /**
-   * Starts Gerrit server from existing on-disk site.
-   *
-   * @param desc server description.
-   * @param baseConfig default config values; merged with config from {@code desc}.
-   * @param site existing temporary directory for site. Required, but may be empty, for in-memory
-   *     servers. For on-disk servers, assumes that {@link #init} was previously called to
-   *     initialize this directory. Can be retrieved from the returned instance via {@link
-   *     #getSitePath()}.
-   * @param testSysModule optional additional module to add to the system injector.
-   * @param additionalArgs additional command-line arguments for the daemon program; only allowed if
-   *     the test is not in-memory.
-   * @return started server.
-   * @throws Exception
-   */
-  public static GerritServer start(
-      Description desc,
-      Config baseConfig,
-      Path site,
-      @Nullable Module testSysModule,
-      String... additionalArgs)
-      throws Exception {
-    checkArgument(site != null, "site is required (even for in-memory server");
-    desc.checkValidAnnotations();
-    Logger.getLogger("com.google.gerrit").setLevel(Level.DEBUG);
-    CyclicBarrier serverStarted = new CyclicBarrier(2);
-    Daemon daemon =
-        new Daemon(
-            () -> {
-              try {
-                serverStarted.await();
-              } catch (InterruptedException | BrokenBarrierException e) {
-                throw new RuntimeException(e);
-              }
-            },
-            site);
-    daemon.setEmailModuleForTesting(new FakeEmailSender.Module());
-    daemon.setAdditionalSysModuleForTesting(testSysModule);
-    daemon.setEnableSshd(desc.useSsh());
-
-    if (desc.memory()) {
-      checkArgument(additionalArgs.length == 0, "cannot pass args to in-memory server");
-      return startInMemory(desc, site, baseConfig, daemon);
-    }
-    return startOnDisk(desc, site, daemon, serverStarted, additionalArgs);
-  }
-
-  private static GerritServer startInMemory(
-      Description desc, Path site, Config baseConfig, Daemon daemon) throws Exception {
-    Config cfg = desc.buildConfig(baseConfig);
-    mergeTestConfig(cfg);
-    // Set the log4j configuration to an invalid one to prevent system logs
-    // from getting configured and creating log files.
-    System.setProperty(SystemLog.LOG4J_CONFIGURATION, "invalidConfiguration");
-    cfg.setBoolean("httpd", null, "requestLog", false);
-    cfg.setBoolean("sshd", null, "requestLog", false);
-    cfg.setBoolean("index", "lucene", "testInmemory", true);
-    cfg.setString("gitweb", null, "cgi", "");
-    daemon.setEnableHttpd(desc.httpd());
-    daemon.setLuceneModule(LuceneIndexModule.singleVersionAllLatest(0));
-    daemon.setDatabaseForTesting(
-        ImmutableList.<Module>of(new InMemoryTestingDatabaseModule(cfg, site)));
-    daemon.start();
-    return new GerritServer(desc, null, createTestInjector(daemon), daemon, null);
-  }
-
-  private static GerritServer startOnDisk(
-      Description desc,
-      Path site,
-      Daemon daemon,
-      CyclicBarrier serverStarted,
-      String[] additionalArgs)
-      throws Exception {
-    checkNotNull(site);
-    ExecutorService daemonService = Executors.newSingleThreadExecutor();
-    String[] args =
-        Stream.concat(
-                Stream.of(
-                    "-d", site.toString(), "--headless", "--console-log", "--show-stack-trace"),
-                Arrays.stream(additionalArgs))
-            .toArray(String[]::new);
-    @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError =
-        daemonService.submit(
-            () -> {
-              int rc = daemon.main(args);
-              if (rc != 0) {
-                System.err.println("Failed to start Gerrit daemon");
-                serverStarted.reset();
-              }
-              return null;
-            });
-    try {
-      serverStarted.await();
-    } catch (BrokenBarrierException e) {
-      daemon.stop();
-      throw new StartupException("Failed to start Gerrit daemon; see log", e);
-    }
-    System.out.println("Gerrit Server Started");
-
-    return new GerritServer(desc, site, createTestInjector(daemon), daemon, daemonService);
-  }
-
-  private static void mergeTestConfig(Config cfg) {
-    String forceEphemeralPort = String.format("%s:0", getLocalHost().getHostName());
-    String url = "http://" + forceEphemeralPort + "/";
-    cfg.setString("gerrit", null, "canonicalWebUrl", url);
-    cfg.setString("httpd", null, "listenUrl", url);
-    cfg.setString("sshd", null, "listenAddress", forceEphemeralPort);
-    cfg.setBoolean("sshd", null, "testUseInsecureRandom", true);
-    cfg.unset("cache", null, "directory");
-    cfg.setString("gerrit", null, "basePath", "git");
-    cfg.setBoolean("sendemail", null, "enable", true);
-    cfg.setInt("sendemail", null, "threadPoolSize", 0);
-    cfg.setInt("cache", "projects", "checkFrequency", 0);
-    cfg.setInt("plugins", null, "checkFrequency", 0);
-
-    cfg.setInt("sshd", null, "threads", 1);
-    cfg.setInt("sshd", null, "commandStartThreads", 1);
-    cfg.setInt("receive", null, "threadPoolSize", 1);
-    cfg.setInt("index", null, "threads", 1);
-    cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
-
-    NoteDbMode.newNotesMigrationFromEnv().setConfigValues(cfg);
-  }
-
-  private static Injector createTestInjector(Daemon daemon) throws Exception {
-    Injector sysInjector = get(daemon, "sysInjector");
-    Module module =
-        new FactoryModule() {
-          @Override
-          protected void configure() {
-            bindConstant().annotatedWith(SshEnabled.class).to(daemon.getEnableSshd());
-            bind(AccountCreator.class);
-            factory(PushOneCommit.Factory.class);
-            install(InProcessProtocol.module());
-            install(new NoSshModule());
-            install(new AsyncReceiveCommits.Module());
-          }
-        };
-    return sysInjector.createChildInjector(module);
-  }
-
-  @SuppressWarnings("unchecked")
-  private static <T> T get(Object obj, String field)
-      throws SecurityException, NoSuchFieldException, IllegalArgumentException,
-          IllegalAccessException {
-    Field f = obj.getClass().getDeclaredField(field);
-    f.setAccessible(true);
-    return (T) f.get(obj);
-  }
-
-  private static InetAddress getLocalHost() {
-    return InetAddress.getLoopbackAddress();
-  }
-
-  private final Description desc;
-  private final Path sitePath;
-
-  private Daemon daemon;
-  private ExecutorService daemonService;
-  private Injector testInjector;
-  private String url;
-  private InetSocketAddress sshdAddress;
-  private InetSocketAddress httpAddress;
-
-  private GerritServer(
-      Description desc,
-      @Nullable Path sitePath,
-      Injector testInjector,
-      Daemon daemon,
-      @Nullable ExecutorService daemonService) {
-    this.desc = checkNotNull(desc);
-    this.sitePath = sitePath;
-    this.testInjector = checkNotNull(testInjector);
-    this.daemon = checkNotNull(daemon);
-    this.daemonService = daemonService;
-
-    Config cfg = testInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    url = cfg.getString("gerrit", null, "canonicalWebUrl");
-    URI uri = URI.create(url);
-
-    sshdAddress = SocketUtil.resolve(cfg.getString("sshd", null, "listenAddress"), 0);
-    httpAddress = new InetSocketAddress(uri.getHost(), uri.getPort());
-  }
-
-  String getUrl() {
-    return url;
-  }
-
-  InetSocketAddress getSshdAddress() {
-    return sshdAddress;
-  }
-
-  InetSocketAddress getHttpAddress() {
-    return httpAddress;
-  }
-
-  public Injector getTestInjector() {
-    return testInjector;
-  }
-
-  Description getDescription() {
-    return desc;
-  }
-
-  @Override
-  public void close() throws Exception {
-    try {
-      checkNoteDbState();
-    } finally {
-      daemon.getLifecycleManager().stop();
-      if (daemonService != null) {
-        System.out.println("Gerrit Server Shutdown");
-        daemonService.shutdownNow();
-        daemonService.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
-      }
-      RepositoryCache.clear();
-    }
-  }
-
-  public Path getSitePath() {
-    return sitePath;
-  }
-
-  private void checkNoteDbState() throws Exception {
-    NoteDbMode mode = NoteDbMode.get();
-    if (mode != NoteDbMode.CHECK && mode != NoteDbMode.PRIMARY) {
-      return;
-    }
-    NoteDbChecker checker = testInjector.getInstance(NoteDbChecker.class);
-    OneOffRequestContext oneOffRequestContext =
-        testInjector.getInstance(OneOffRequestContext.class);
-    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
-      if (mode == NoteDbMode.CHECK) {
-        checker.rebuildAndCheckAllChanges();
-      } else if (mode == NoteDbMode.PRIMARY) {
-        checker.assertNoReviewDbChanges(desc.testDescription());
-      }
-    }
-  }
-
-  @Override
-  public String toString() {
-    return MoreObjects.toStringHelper(this).addValue(desc).toString();
-  }
-}
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
deleted file mode 100644
index c9a474f..0000000
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java
+++ /dev/null
@@ -1,238 +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;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.reviewdb.client.Project;
-import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.JSchException;
-import com.jcraft.jsch.Session;
-import java.io.IOException;
-import java.util.List;
-import java.util.Optional;
-import java.util.Properties;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicInteger;
-import org.eclipse.jgit.api.FetchCommand;
-import org.eclipse.jgit.api.PushCommand;
-import org.eclipse.jgit.api.TagCommand;
-import org.eclipse.jgit.api.errors.GitAPIException;
-import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.FetchResult;
-import org.eclipse.jgit.transport.JschConfigSessionFactory;
-import org.eclipse.jgit.transport.OpenSshConfig.Host;
-import org.eclipse.jgit.transport.PushResult;
-import org.eclipse.jgit.transport.RefSpec;
-import org.eclipse.jgit.transport.RemoteRefUpdate;
-import org.eclipse.jgit.transport.SshSessionFactory;
-import org.eclipse.jgit.util.FS;
-
-public class GitUtil {
-  private static final AtomicInteger testRepoCount = new AtomicInteger();
-  private static final int TEST_REPO_WINDOW_DAYS = 2;
-
-  public static void initSsh(TestAccount a) {
-    final Properties config = new Properties();
-    config.put("StrictHostKeyChecking", "no");
-    JSch.setConfig(config);
-
-    // register a JschConfigSessionFactory that adds the private key as identity
-    // to the JSch instance of JGit so that SSH communication via JGit can
-    // succeed
-    SshSessionFactory.setInstance(
-        new JschConfigSessionFactory() {
-          @Override
-          protected void configure(Host hc, Session session) {
-            try {
-              final JSch jsch = getJSch(hc, FS.DETECTED);
-              jsch.addIdentity("KeyPair", a.privateKey(), a.sshKey.getPublicKeyBlob(), null);
-            } catch (JSchException e) {
-              throw new RuntimeException(e);
-            }
-          }
-        });
-  }
-
-  /**
-   * Create a new {@link TestRepository} with a distinct commit clock.
-   *
-   * <p>It is very easy for tests to create commits with identical subjects and trees; if such
-   * commits also have identical authors/committers, then the computed Change-Id is identical as
-   * well. Tests may generally assume that Change-Ids are unique, so to ensure this, we provision
-   * TestRepository instances with non-overlapping commit clock times.
-   *
-   * <p>Space test repos 1 day apart, which allows for about 86k ticks per repo before overlapping,
-   * and about 8k instances per process before hitting JGit's year 2038 limit.
-   *
-   * @param repo repository to wrap.
-   * @return wrapped test repository with distinct commit time space.
-   */
-  public static <R extends Repository> TestRepository<R> newTestRepository(R repo)
-      throws IOException {
-    TestRepository<R> tr = new TestRepository<>(repo);
-    tr.tick(
-        Ints.checkedCast(
-            TimeUnit.SECONDS.convert(
-                testRepoCount.getAndIncrement() * TEST_REPO_WINDOW_DAYS, TimeUnit.DAYS)));
-    return tr;
-  }
-
-  public static TestRepository<InMemoryRepository> cloneProject(Project.NameKey project, String uri)
-      throws Exception {
-    DfsRepositoryDescription desc = new DfsRepositoryDescription("clone of " + project.get());
-
-    FS fs = FS.detect();
-
-    // Avoid leaking user state into our tests.
-    fs.setUserHome(null);
-
-    InMemoryRepository dest =
-        new InMemoryRepository.Builder()
-            .setRepositoryDescription(desc)
-            // SshTransport depends on a real FS to read ~/.ssh/config, but
-            // InMemoryRepository by default uses a null FS.
-            // TODO(dborowitz): Remove when we no longer depend on SSH.
-            .setFS(fs)
-            .build();
-    Config cfg = dest.getConfig();
-    cfg.setString("remote", "origin", "url", uri);
-    cfg.setString("remote", "origin", "fetch", "+refs/heads/*:refs/remotes/origin/*");
-    TestRepository<InMemoryRepository> testRepo = newTestRepository(dest);
-    FetchResult result = testRepo.git().fetch().setRemote("origin").call();
-    String originMaster = "refs/remotes/origin/master";
-    if (result.getTrackingRefUpdate(originMaster) != null) {
-      testRepo.reset(originMaster);
-    }
-    return testRepo;
-  }
-
-  public static TestRepository<InMemoryRepository> cloneProject(
-      Project.NameKey project, SshSession sshSession) throws Exception {
-    return cloneProject(project, sshSession.getUrl() + "/" + project.get());
-  }
-
-  public static Ref createAnnotatedTag(TestRepository<?> testRepo, String name, PersonIdent tagger)
-      throws GitAPIException {
-    TagCommand cmd =
-        testRepo.git().tag().setName(name).setAnnotated(true).setMessage(name).setTagger(tagger);
-    return cmd.call();
-  }
-
-  public static Ref updateAnnotatedTag(TestRepository<?> testRepo, String name, PersonIdent tagger)
-      throws GitAPIException {
-    TagCommand tc = testRepo.git().tag().setName(name);
-    return tc.setAnnotated(true).setMessage(name).setTagger(tagger).setForceUpdate(true).call();
-  }
-
-  public static void fetch(TestRepository<?> testRepo, String spec) throws GitAPIException {
-    FetchCommand fetch = testRepo.git().fetch();
-    fetch.setRefSpecs(new RefSpec(spec));
-    fetch.call();
-  }
-
-  public static PushResult pushHead(TestRepository<?> testRepo, String ref) throws GitAPIException {
-    return pushHead(testRepo, ref, false);
-  }
-
-  public static PushResult pushHead(TestRepository<?> testRepo, String ref, boolean pushTags)
-      throws GitAPIException {
-    return pushHead(testRepo, ref, pushTags, false);
-  }
-
-  public static PushResult pushHead(
-      TestRepository<?> testRepo, String ref, boolean pushTags, boolean force)
-      throws GitAPIException {
-    return pushOne(testRepo, "HEAD", ref, pushTags, force, null);
-  }
-
-  public static PushResult pushHead(
-      TestRepository<?> testRepo,
-      String ref,
-      boolean pushTags,
-      boolean force,
-      List<String> pushOptions)
-      throws GitAPIException {
-    return pushOne(testRepo, "HEAD", ref, pushTags, force, pushOptions);
-  }
-
-  public static PushResult deleteRef(TestRepository<?> testRepo, String ref)
-      throws GitAPIException {
-    return pushOne(testRepo, "", ref, false, true, null);
-  }
-
-  public static PushResult pushOne(
-      TestRepository<?> testRepo,
-      String source,
-      String target,
-      boolean pushTags,
-      boolean force,
-      List<String> pushOptions)
-      throws GitAPIException {
-    PushCommand pushCmd = testRepo.git().push();
-    pushCmd.setForce(force);
-    pushCmd.setPushOptions(pushOptions);
-    pushCmd.setRefSpecs(new RefSpec(source + ":" + target));
-    if (pushTags) {
-      pushCmd.setPushTags();
-    }
-    Iterable<PushResult> r = pushCmd.call();
-    return Iterables.getOnlyElement(r);
-  }
-
-  public static void assertPushOk(PushResult result, String ref) {
-    RemoteRefUpdate rru = result.getRemoteUpdate(ref);
-    assertThat(rru.getStatus()).named(rru.toString()).isEqualTo(RemoteRefUpdate.Status.OK);
-  }
-
-  public static void assertPushRejected(PushResult result, String ref, String expectedMessage) {
-    RemoteRefUpdate rru = result.getRemoteUpdate(ref);
-    assertThat(rru.getStatus())
-        .named(rru.toString())
-        .isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
-    assertThat(rru.getMessage()).isEqualTo(expectedMessage);
-  }
-
-  public static PushResult pushTag(TestRepository<?> testRepo, String tag) throws GitAPIException {
-    return pushTag(testRepo, tag, false);
-  }
-
-  public static PushResult pushTag(TestRepository<?> testRepo, String tag, boolean force)
-      throws GitAPIException {
-    PushCommand pushCmd = testRepo.git().push();
-    pushCmd.setForce(force);
-    pushCmd.setRefSpecs(new RefSpec("refs/tags/" + tag + ":refs/tags/" + tag));
-    Iterable<PushResult> r = pushCmd.call();
-    return Iterables.getOnlyElement(r);
-  }
-
-  public static Optional<String> getChangeId(TestRepository<?> tr, ObjectId id) throws IOException {
-    RevCommit c = tr.getRevWalk().parseCommit(id);
-    tr.getRevWalk().parseBody(c);
-    return Lists.reverse(c.getFooterLines(FooterConstants.CHANGE_ID)).stream().findFirst();
-  }
-}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
deleted file mode 100644
index 0f30fa2..0000000
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ /dev/null
@@ -1,146 +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;
-
-import static com.google.inject.Scopes.SINGLETON;
-
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.metrics.DisabledMetricMaker;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePath;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.config.TrackingFootersProvider;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.schema.DataSourceType;
-import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
-import com.google.gerrit.server.schema.ReviewDbFactory;
-import com.google.gerrit.server.schema.SchemaModule;
-import com.google.gerrit.server.schema.SchemaVersion;
-import com.google.gerrit.testutil.InMemoryDatabase;
-import com.google.gerrit.testutil.InMemoryH2Type;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Key;
-import com.google.inject.Provides;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import org.apache.sshd.common.keyprovider.KeyPairProvider;
-import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
-import org.eclipse.jgit.lib.Config;
-
-class InMemoryTestingDatabaseModule extends LifecycleModule {
-  private final Config cfg;
-  private final Path sitePath;
-
-  InMemoryTestingDatabaseModule(Config cfg, Path sitePath) {
-    this.cfg = cfg;
-    this.sitePath = sitePath;
-    makeSiteDirs(sitePath);
-  }
-
-  @Override
-  protected void configure() {
-    bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
-
-    // TODO(dborowitz): Use jimfs.
-    bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
-
-    bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
-    bind(InMemoryRepositoryManager.class).in(SINGLETON);
-
-    bind(MetricMaker.class).to(DisabledMetricMaker.class);
-    bind(DataSourceType.class).to(InMemoryH2Type.class);
-
-    install(new NotesMigration.Module());
-    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
-        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
-    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
-    bind(Key.get(schemaFactory, ReviewDbFactory.class)).to(InMemoryDatabase.class);
-    bind(InMemoryDatabase.class).in(SINGLETON);
-    bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
-
-    listener().to(CreateDatabase.class);
-
-    bind(SitePaths.class);
-    bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class).in(SINGLETON);
-
-    install(new SchemaModule());
-    bind(SchemaVersion.class).to(SchemaVersion.C);
-  }
-
-  @Provides
-  @Singleton
-  KeyPairProvider createHostKey() {
-    return getHostKeys();
-  }
-
-  private static SimpleGeneratorHostKeyProvider keys;
-
-  private static synchronized KeyPairProvider getHostKeys() {
-    if (keys == null) {
-      keys = new SimpleGeneratorHostKeyProvider();
-      keys.setAlgorithm("RSA");
-      keys.loadKeys();
-    }
-    return keys;
-  }
-
-  static class CreateDatabase implements LifecycleListener {
-    private final InMemoryDatabase mem;
-
-    @Inject
-    CreateDatabase(InMemoryDatabase mem) {
-      this.mem = mem;
-    }
-
-    @Override
-    public void start() {
-      try {
-        mem.create();
-      } catch (OrmException e) {
-        throw new OrmRuntimeException(e);
-      }
-    }
-
-    @Override
-    public void stop() {
-      mem.drop();
-    }
-  }
-
-  private static void makeSiteDirs(Path p) {
-    try {
-      Files.createDirectories(p.resolve("etc"));
-    } catch (IOException e) {
-      ProvisionException pe = new ProvisionException(e.getMessage());
-      pe.initCause(e);
-      throw pe;
-    }
-  }
-}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InProcessProtocol.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InProcessProtocol.java
deleted file mode 100644
index e2e29c9..0000000
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InProcessProtocol.java
+++ /dev/null
@@ -1,365 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.Lists;
-import com.google.gerrit.acceptance.InProcessProtocol.Context;
-import com.google.gerrit.common.data.Capable;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.AccessPath;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.RemotePeer;
-import com.google.gerrit.server.RequestCleanup;
-import com.google.gerrit.server.config.GerritRequestModule;
-import com.google.gerrit.server.config.RequestScopedReviewDbProvider;
-import com.google.gerrit.server.git.ReceivePackInitializer;
-import com.google.gerrit.server.git.TransferConfig;
-import com.google.gerrit.server.git.UploadPackInitializer;
-import com.google.gerrit.server.git.VisibleRefFilter;
-import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
-import com.google.gerrit.server.git.validators.UploadValidators;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.RequestScopePropagator;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestScopePropagator;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.AbstractModule;
-import com.google.inject.Inject;
-import com.google.inject.Key;
-import com.google.inject.Module;
-import com.google.inject.OutOfScopeException;
-import com.google.inject.Provider;
-import com.google.inject.Provides;
-import com.google.inject.Scope;
-import com.google.inject.servlet.RequestScoped;
-import com.google.inject.util.Providers;
-import java.io.IOException;
-import java.net.SocketAddress;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.PostReceiveHook;
-import org.eclipse.jgit.transport.PostReceiveHookChain;
-import org.eclipse.jgit.transport.PreUploadHook;
-import org.eclipse.jgit.transport.PreUploadHookChain;
-import org.eclipse.jgit.transport.ReceivePack;
-import org.eclipse.jgit.transport.TestProtocol;
-import org.eclipse.jgit.transport.UploadPack;
-import org.eclipse.jgit.transport.resolver.ReceivePackFactory;
-import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
-import org.eclipse.jgit.transport.resolver.UploadPackFactory;
-
-class InProcessProtocol extends TestProtocol<Context> {
-  static Module module() {
-    return new AbstractModule() {
-      @Override
-      public void configure() {
-        install(new GerritRequestModule());
-        bind(RequestScopePropagator.class).to(Propagator.class);
-        bindScope(RequestScoped.class, InProcessProtocol.REQUEST);
-      }
-
-      @Provides
-      @RemotePeer
-      SocketAddress getSocketAddress() {
-        // TODO(dborowitz): Could potentially fake this with thread ID or
-        // something.
-        throw new OutOfScopeException("No remote peer in acceptance tests");
-      }
-    };
-  }
-
-  private static final Scope REQUEST =
-      new Scope() {
-        @Override
-        public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
-          return new Provider<T>() {
-            @Override
-            public T get() {
-              Context ctx = current.get();
-              if (ctx == null) {
-                throw new OutOfScopeException("Not in TestProtocol scope");
-              }
-              return ctx.get(key, creator);
-            }
-
-            @Override
-            public String toString() {
-              return String.format("%s[%s]", creator, REQUEST);
-            }
-          };
-        }
-
-        @Override
-        public String toString() {
-          return "InProcessProtocol.REQUEST";
-        }
-      };
-
-  private static class Propagator extends ThreadLocalRequestScopePropagator<Context> {
-    @Inject
-    Propagator(
-        ThreadLocalRequestContext local,
-        Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
-      super(REQUEST, current, local, dbProviderProvider);
-    }
-
-    @Override
-    protected Context continuingContext(Context ctx) {
-      return ctx.newContinuingContext();
-    }
-  }
-
-  private static final ThreadLocal<Context> current = new ThreadLocal<>();
-
-  // TODO(dborowitz): Merge this with AcceptanceTestRequestScope.
-  /**
-   * Multi-purpose session/context object.
-   *
-   * <p>Confusingly, Gerrit has two ideas of what a "context" object is: one for Guice {@link
-   * RequestScoped}, and one for its own simplified version of request scoping using {@link
-   * ThreadLocalRequestContext}. This class provides both, in essence just delegating the {@code
-   * ThreadLocalRequestContext} scoping to the Guice scoping mechanism.
-   *
-   * <p>It is also used as the session type for {@code UploadPackFactory} and {@code
-   * ReceivePackFactory}, since, after all, it encapsulates all the information about a single
-   * request.
-   */
-  static class Context implements RequestContext {
-    private static final Key<RequestScopedReviewDbProvider> DB_KEY =
-        Key.get(RequestScopedReviewDbProvider.class);
-    private static final Key<RequestCleanup> RC_KEY = Key.get(RequestCleanup.class);
-    private static final Key<CurrentUser> USER_KEY = Key.get(CurrentUser.class);
-
-    private final SchemaFactory<ReviewDb> schemaFactory;
-    private final IdentifiedUser.GenericFactory userFactory;
-    private final Account.Id accountId;
-    private final Project.NameKey project;
-    private final RequestCleanup cleanup;
-    private final Map<Key<?>, Object> map;
-
-    Context(
-        SchemaFactory<ReviewDb> schemaFactory,
-        IdentifiedUser.GenericFactory userFactory,
-        Account.Id accountId,
-        Project.NameKey project) {
-      this.schemaFactory = schemaFactory;
-      this.userFactory = userFactory;
-      this.accountId = accountId;
-      this.project = project;
-      map = new HashMap<>();
-      cleanup = new RequestCleanup();
-      map.put(DB_KEY, new RequestScopedReviewDbProvider(schemaFactory, Providers.of(cleanup)));
-      map.put(RC_KEY, cleanup);
-
-      IdentifiedUser user = userFactory.create(accountId);
-      user.setAccessPath(AccessPath.GIT);
-      map.put(USER_KEY, user);
-    }
-
-    private Context newContinuingContext() {
-      return new Context(schemaFactory, userFactory, accountId, project);
-    }
-
-    @Override
-    public CurrentUser getUser() {
-      return get(USER_KEY, null);
-    }
-
-    @Override
-    public Provider<ReviewDb> getReviewDbProvider() {
-      return get(DB_KEY, null);
-    }
-
-    private synchronized <T> T get(Key<T> key, Provider<T> creator) {
-      @SuppressWarnings("unchecked")
-      T t = (T) map.get(key);
-      if (t == null) {
-        t = creator.get();
-        map.put(key, t);
-      }
-      return t;
-    }
-  }
-
-  private static class Upload implements UploadPackFactory<Context> {
-    private final Provider<CurrentUser> userProvider;
-    private final VisibleRefFilter.Factory refFilterFactory;
-    private final TransferConfig transferConfig;
-    private final DynamicSet<UploadPackInitializer> uploadPackInitializers;
-    private final DynamicSet<PreUploadHook> preUploadHooks;
-    private final UploadValidators.Factory uploadValidatorsFactory;
-    private final ThreadLocalRequestContext threadContext;
-    private final ProjectCache projectCache;
-    private final PermissionBackend permissionBackend;
-
-    @Inject
-    Upload(
-        Provider<CurrentUser> userProvider,
-        VisibleRefFilter.Factory refFilterFactory,
-        TransferConfig transferConfig,
-        DynamicSet<UploadPackInitializer> uploadPackInitializers,
-        DynamicSet<PreUploadHook> preUploadHooks,
-        UploadValidators.Factory uploadValidatorsFactory,
-        ThreadLocalRequestContext threadContext,
-        ProjectCache projectCache,
-        PermissionBackend permissionBackend) {
-      this.userProvider = userProvider;
-      this.refFilterFactory = refFilterFactory;
-      this.transferConfig = transferConfig;
-      this.uploadPackInitializers = uploadPackInitializers;
-      this.preUploadHooks = preUploadHooks;
-      this.uploadValidatorsFactory = uploadValidatorsFactory;
-      this.threadContext = threadContext;
-      this.projectCache = projectCache;
-      this.permissionBackend = permissionBackend;
-    }
-
-    @Override
-    public UploadPack create(Context req, Repository repo) throws ServiceNotAuthorizedException {
-      // Set the request context, but don't bother unsetting, since we don't
-      // have an easy way to run code when this instance is done being used.
-      // Each operation is run in its own thread, so we don't need to recover
-      // its original context anyway.
-      threadContext.setContext(req);
-      current.set(req);
-
-      try {
-        permissionBackend
-            .user(userProvider)
-            .project(req.project)
-            .check(ProjectPermission.RUN_UPLOAD_PACK);
-      } catch (AuthException e) {
-        throw new ServiceNotAuthorizedException();
-      } catch (PermissionBackendException e) {
-        throw new RuntimeException(e);
-      }
-
-      ProjectState projectState;
-      try {
-        projectState = projectCache.checkedGet(req.project);
-      } catch (IOException e) {
-        throw new RuntimeException(e);
-      }
-      if (projectState == null) {
-        throw new RuntimeException("can't load project state for " + req.project.get());
-      }
-      UploadPack up = new UploadPack(repo);
-      up.setPackConfig(transferConfig.getPackConfig());
-      up.setTimeout(transferConfig.getTimeout());
-      up.setAdvertiseRefsHook(refFilterFactory.create(projectState, repo));
-      List<PreUploadHook> hooks = Lists.newArrayList(preUploadHooks);
-      hooks.add(uploadValidatorsFactory.create(projectState.getProject(), repo, "localhost-test"));
-      up.setPreUploadHook(PreUploadHookChain.newChain(hooks));
-      for (UploadPackInitializer initializer : uploadPackInitializers) {
-        initializer.init(req.project, up);
-      }
-      return up;
-    }
-  }
-
-  private static class Receive implements ReceivePackFactory<Context> {
-    private final Provider<CurrentUser> userProvider;
-    private final ProjectControl.GenericFactory projectControlFactory;
-    private final AsyncReceiveCommits.Factory factory;
-    private final TransferConfig config;
-    private final DynamicSet<ReceivePackInitializer> receivePackInitializers;
-    private final DynamicSet<PostReceiveHook> postReceiveHooks;
-    private final ThreadLocalRequestContext threadContext;
-    private final PermissionBackend permissionBackend;
-
-    @Inject
-    Receive(
-        Provider<CurrentUser> userProvider,
-        ProjectControl.GenericFactory projectControlFactory,
-        AsyncReceiveCommits.Factory factory,
-        TransferConfig config,
-        DynamicSet<ReceivePackInitializer> receivePackInitializers,
-        DynamicSet<PostReceiveHook> postReceiveHooks,
-        ThreadLocalRequestContext threadContext,
-        PermissionBackend permissionBackend) {
-      this.userProvider = userProvider;
-      this.projectControlFactory = projectControlFactory;
-      this.factory = factory;
-      this.config = config;
-      this.receivePackInitializers = receivePackInitializers;
-      this.postReceiveHooks = postReceiveHooks;
-      this.threadContext = threadContext;
-      this.permissionBackend = permissionBackend;
-    }
-
-    @Override
-    public ReceivePack create(Context req, Repository db) throws ServiceNotAuthorizedException {
-      // Set the request context, but don't bother unsetting, since we don't
-      // have an easy way to run code when this instance is done being used.
-      // Each operation is run in its own thread, so we don't need to recover
-      // its original context anyway.
-      threadContext.setContext(req);
-      current.set(req);
-      try {
-        permissionBackend
-            .user(userProvider)
-            .project(req.project)
-            .check(ProjectPermission.RUN_RECEIVE_PACK);
-      } catch (AuthException e) {
-        throw new ServiceNotAuthorizedException();
-      } catch (PermissionBackendException e) {
-        throw new RuntimeException(e);
-      }
-      try {
-        ProjectControl ctl = projectControlFactory.controlFor(req.project, userProvider.get());
-        AsyncReceiveCommits arc = factory.create(ctl, db, null, ImmutableSetMultimap.of());
-        ReceivePack rp = arc.getReceivePack();
-
-        Capable r = arc.canUpload();
-        if (r != Capable.OK) {
-          throw new ServiceNotAuthorizedException();
-        }
-
-        rp.setRefLogIdent(ctl.getUser().asIdentifiedUser().newRefLogIdent());
-        rp.setTimeout(config.getTimeout());
-        rp.setMaxObjectSizeLimit(config.getMaxObjectSizeLimit());
-
-        for (ReceivePackInitializer initializer : receivePackInitializers) {
-          initializer.init(ctl.getProject().getNameKey(), rp);
-        }
-
-        rp.setPostReceiveHook(PostReceiveHookChain.newChain(Lists.newArrayList(postReceiveHooks)));
-        return rp;
-      } catch (NoSuchProjectException | IOException e) {
-        throw new RuntimeException(e);
-      }
-    }
-  }
-
-  @Inject
-  InProcessProtocol(Upload uploadPackFactory, Receive receivePackFactory) {
-    super(uploadPackFactory, receivePackFactory);
-  }
-}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
deleted file mode 100644
index 57d39c0..0000000
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ /dev/null
@@ -1,511 +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;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
-import static java.util.stream.Collectors.toList;
-import static org.junit.Assert.assertEquals;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.stream.Stream;
-import org.eclipse.jgit.api.TagCommand;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.PushResult;
-import org.eclipse.jgit.transport.RemoteRefUpdate;
-import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
-
-public class PushOneCommit {
-  public static final String SUBJECT = "test commit";
-  public static final String FILE_NAME = "a.txt";
-  public static final String FILE_CONTENT = "some content";
-  public static final String PATCH_FILE_ONLY =
-      "diff --git a/a.txt b/a.txt\n"
-          + "new file mode 100644\n"
-          + "index 0000000..f0eec86\n"
-          + "--- /dev/null\n"
-          + "+++ b/a.txt\n"
-          + "@@ -0,0 +1 @@\n"
-          + "+some content\n"
-          + "\\ No newline at end of file\n";
-  public static final String PATCH =
-      "From %s Mon Sep 17 00:00:00 2001\n"
-          + "From: Administrator <admin@example.com>\n"
-          + "Date: %s\n"
-          + "Subject: [PATCH] test commit\n"
-          + "\n"
-          + "Change-Id: %s\n"
-          + "---\n"
-          + "\n"
-          + PATCH_FILE_ONLY;
-
-  public interface Factory {
-    PushOneCommit create(ReviewDb db, PersonIdent i, TestRepository<?> testRepo);
-
-    PushOneCommit create(
-        ReviewDb db,
-        PersonIdent i,
-        TestRepository<?> testRepo,
-        @Assisted("changeId") String changeId);
-
-    PushOneCommit create(
-        ReviewDb db,
-        PersonIdent i,
-        TestRepository<?> testRepo,
-        @Assisted("subject") String subject,
-        @Assisted("fileName") String fileName,
-        @Assisted("content") String content);
-
-    PushOneCommit create(
-        ReviewDb db,
-        PersonIdent i,
-        TestRepository<?> testRepo,
-        @Assisted String subject,
-        @Assisted Map<String, String> files);
-
-    PushOneCommit create(
-        ReviewDb db,
-        PersonIdent i,
-        TestRepository<?> testRepo,
-        @Assisted("subject") String subject,
-        @Assisted("fileName") String fileName,
-        @Assisted("content") String content,
-        @Assisted("changeId") String changeId);
-  }
-
-  public static class Tag {
-    public String name;
-
-    public Tag(String name) {
-      this.name = name;
-    }
-  }
-
-  public static class AnnotatedTag extends Tag {
-    public String message;
-    public PersonIdent tagger;
-
-    public AnnotatedTag(String name, String message, PersonIdent tagger) {
-      super(name);
-      this.message = message;
-      this.tagger = tagger;
-    }
-  }
-
-  private static final AtomicInteger CHANGE_ID_COUNTER = new AtomicInteger();
-
-  private static String nextChangeId() {
-    // Tests use a variety of mechanisms for setting temporary timestamps, so we can't guarantee
-    // that the PersonIdent (or any other field used by the Change-Id generator) for any two test
-    // methods in the same acceptance test class are going to be different. But tests generally
-    // assume that Change-Ids are unique unless otherwise specified. So, don't even bother trying to
-    // reuse JGit's Change-Id generator, just do the simplest possible thing and convert a counter
-    // to hex.
-    return String.format("%040x", CHANGE_ID_COUNTER.incrementAndGet());
-  }
-
-  private final ChangeNotes.Factory notesFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final NotesMigration notesMigration;
-  private final ReviewDb db;
-  private final TestRepository<?> testRepo;
-
-  private final String subject;
-  private final Map<String, String> files;
-  private String changeId;
-  private Tag tag;
-  private boolean force;
-  private List<String> pushOptions;
-
-  private final TestRepository<?>.CommitBuilder commitBuilder;
-
-  @AssistedInject
-  PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
-      NotesMigration notesMigration,
-      @Assisted ReviewDb db,
-      @Assisted PersonIdent i,
-      @Assisted TestRepository<?> testRepo)
-      throws Exception {
-    this(
-        notesFactory,
-        approvalsUtil,
-        queryProvider,
-        notesMigration,
-        db,
-        i,
-        testRepo,
-        SUBJECT,
-        FILE_NAME,
-        FILE_CONTENT);
-  }
-
-  @AssistedInject
-  PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
-      NotesMigration notesMigration,
-      @Assisted ReviewDb db,
-      @Assisted PersonIdent i,
-      @Assisted TestRepository<?> testRepo,
-      @Assisted("changeId") String changeId)
-      throws Exception {
-    this(
-        notesFactory,
-        approvalsUtil,
-        queryProvider,
-        notesMigration,
-        db,
-        i,
-        testRepo,
-        SUBJECT,
-        FILE_NAME,
-        FILE_CONTENT,
-        changeId);
-  }
-
-  @AssistedInject
-  PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
-      NotesMigration notesMigration,
-      @Assisted ReviewDb db,
-      @Assisted PersonIdent i,
-      @Assisted TestRepository<?> testRepo,
-      @Assisted("subject") String subject,
-      @Assisted("fileName") String fileName,
-      @Assisted("content") String content)
-      throws Exception {
-    this(
-        notesFactory,
-        approvalsUtil,
-        queryProvider,
-        notesMigration,
-        db,
-        i,
-        testRepo,
-        subject,
-        fileName,
-        content,
-        null);
-  }
-
-  @AssistedInject
-  PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
-      NotesMigration notesMigration,
-      @Assisted ReviewDb db,
-      @Assisted PersonIdent i,
-      @Assisted TestRepository<?> testRepo,
-      @Assisted String subject,
-      @Assisted Map<String, String> files)
-      throws Exception {
-    this(
-        notesFactory,
-        approvalsUtil,
-        queryProvider,
-        notesMigration,
-        db,
-        i,
-        testRepo,
-        subject,
-        files,
-        null);
-  }
-
-  @AssistedInject
-  PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
-      NotesMigration notesMigration,
-      @Assisted ReviewDb db,
-      @Assisted PersonIdent i,
-      @Assisted TestRepository<?> testRepo,
-      @Assisted("subject") String subject,
-      @Assisted("fileName") String fileName,
-      @Assisted("content") String content,
-      @Nullable @Assisted("changeId") String changeId)
-      throws Exception {
-    this(
-        notesFactory,
-        approvalsUtil,
-        queryProvider,
-        notesMigration,
-        db,
-        i,
-        testRepo,
-        subject,
-        ImmutableMap.of(fileName, content),
-        changeId);
-  }
-
-  private PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
-      NotesMigration notesMigration,
-      ReviewDb db,
-      PersonIdent i,
-      TestRepository<?> testRepo,
-      String subject,
-      Map<String, String> files,
-      String changeId)
-      throws Exception {
-    this.db = db;
-    this.testRepo = testRepo;
-    this.notesFactory = notesFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.queryProvider = queryProvider;
-    this.notesMigration = notesMigration;
-    this.subject = subject;
-    this.files = files;
-    this.changeId = changeId;
-    if (changeId != null) {
-      commitBuilder = testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
-    } else {
-      commitBuilder = testRepo.branch("HEAD").commit().insertChangeId(nextChangeId());
-    }
-    commitBuilder.message(subject).author(i).committer(new PersonIdent(i, testRepo.getDate()));
-  }
-
-  public void setParents(List<RevCommit> parents) throws Exception {
-    commitBuilder.noParents();
-    for (RevCommit p : parents) {
-      commitBuilder.parent(p);
-    }
-  }
-
-  public void setParent(RevCommit parent) throws Exception {
-    commitBuilder.noParents();
-    commitBuilder.parent(parent);
-  }
-
-  public Result to(String ref) throws Exception {
-    for (Map.Entry<String, String> e : files.entrySet()) {
-      commitBuilder.add(e.getKey(), e.getValue());
-    }
-    return execute(ref);
-  }
-
-  public Result rm(String ref) throws Exception {
-    for (String fileName : files.keySet()) {
-      commitBuilder.rm(fileName);
-    }
-    return execute(ref);
-  }
-
-  public Result execute(String ref) throws Exception {
-    RevCommit c = commitBuilder.create();
-    if (changeId == null) {
-      changeId = GitUtil.getChangeId(testRepo, c).get();
-    }
-    if (tag != null) {
-      TagCommand tagCommand = testRepo.git().tag().setName(tag.name);
-      if (tag instanceof AnnotatedTag) {
-        AnnotatedTag annotatedTag = (AnnotatedTag) tag;
-        tagCommand
-            .setAnnotated(true)
-            .setMessage(annotatedTag.message)
-            .setTagger(annotatedTag.tagger);
-      } else {
-        tagCommand.setAnnotated(false);
-      }
-      tagCommand.call();
-    }
-    return new Result(ref, pushHead(testRepo, ref, tag != null, force, pushOptions), c, subject);
-  }
-
-  public void setTag(Tag tag) {
-    this.tag = tag;
-  }
-
-  public void setForce(boolean force) {
-    this.force = force;
-  }
-
-  public List<String> getPushOptions() {
-    return pushOptions;
-  }
-
-  public void setPushOptions(List<String> pushOptions) {
-    this.pushOptions = pushOptions;
-  }
-
-  public void noParents() {
-    commitBuilder.noParents();
-  }
-
-  public class Result {
-    private final String ref;
-    private final PushResult result;
-    private final RevCommit commit;
-    private final String resSubj;
-
-    private Result(String ref, PushResult resSubj, RevCommit commit, String subject) {
-      this.ref = ref;
-      this.result = resSubj;
-      this.commit = commit;
-      this.resSubj = subject;
-    }
-
-    public ChangeData getChange() throws OrmException {
-      return Iterables.getOnlyElement(queryProvider.get().byKeyPrefix(changeId));
-    }
-
-    public PatchSet getPatchSet() throws OrmException {
-      return getChange().currentPatchSet();
-    }
-
-    public PatchSet.Id getPatchSetId() throws OrmException {
-      return getChange().change().currentPatchSetId();
-    }
-
-    public String getChangeId() {
-      return changeId;
-    }
-
-    public RevCommit getCommit() {
-      return commit;
-    }
-
-    public void assertPushOptions(List<String> pushOptions) {
-      assertEquals(pushOptions, getPushOptions());
-    }
-
-    public void assertChange(
-        Change.Status expectedStatus, String expectedTopic, TestAccount... expectedReviewers)
-        throws OrmException {
-      assertChange(
-          expectedStatus, expectedTopic, Arrays.asList(expectedReviewers), ImmutableList.of());
-    }
-
-    public void assertChange(
-        Change.Status expectedStatus,
-        String expectedTopic,
-        List<TestAccount> expectedReviewers,
-        List<TestAccount> expectedCcs)
-        throws OrmException {
-      Change c = getChange().change();
-      assertThat(c.getSubject()).isEqualTo(resSubj);
-      assertThat(c.getStatus()).isEqualTo(expectedStatus);
-      assertThat(Strings.emptyToNull(c.getTopic())).isEqualTo(expectedTopic);
-      if (notesMigration.readChanges()) {
-        assertReviewers(c, ReviewerStateInternal.REVIEWER, expectedReviewers);
-        assertReviewers(c, ReviewerStateInternal.CC, expectedCcs);
-      } else {
-        assertReviewers(
-            c,
-            ReviewerStateInternal.REVIEWER,
-            Stream.concat(expectedReviewers.stream(), expectedCcs.stream()).collect(toList()));
-      }
-    }
-
-    private void assertReviewers(
-        Change c, ReviewerStateInternal state, List<TestAccount> expectedReviewers)
-        throws OrmException {
-      Iterable<Account.Id> actualIds =
-          approvalsUtil.getReviewers(db, notesFactory.createChecked(db, c)).byState(state);
-      assertThat(actualIds)
-          .containsExactlyElementsIn(Sets.newHashSet(TestAccount.ids(expectedReviewers)));
-    }
-
-    public void assertOkStatus() {
-      assertStatus(Status.OK, null);
-    }
-
-    public void assertErrorStatus(String expectedMessage) {
-      assertStatus(Status.REJECTED_OTHER_REASON, expectedMessage);
-    }
-
-    public void assertErrorStatus() {
-      RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
-      assertThat(refUpdate).isNotNull();
-      assertThat(refUpdate.getStatus())
-          .named(message(refUpdate))
-          .isEqualTo(Status.REJECTED_OTHER_REASON);
-    }
-
-    private void assertStatus(Status expectedStatus, String expectedMessage) {
-      RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
-      assertThat(refUpdate).isNotNull();
-      assertThat(refUpdate.getStatus()).named(message(refUpdate)).isEqualTo(expectedStatus);
-      if (expectedMessage == null) {
-        assertThat(refUpdate.getMessage()).isNull();
-      } else {
-        assertThat(refUpdate.getMessage()).contains(expectedMessage);
-      }
-    }
-
-    public void assertMessage(String expectedMessage) {
-      RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
-      assertThat(refUpdate).isNotNull();
-      assertThat(message(refUpdate).toLowerCase()).contains(expectedMessage.toLowerCase());
-    }
-
-    public void assertNotMessage(String message) {
-      RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
-      assertThat(message(refUpdate).toLowerCase()).doesNotContain(message.toLowerCase());
-    }
-
-    public String getMessage() {
-      RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
-      assertThat(refUpdate).isNotNull();
-      return message(refUpdate);
-    }
-
-    private String message(RemoteRefUpdate refUpdate) {
-      StringBuilder b = new StringBuilder();
-      if (refUpdate.getMessage() != null) {
-        b.append(refUpdate.getMessage());
-        b.append("\n");
-      }
-      b.append(result.getMessages());
-      return b.toString();
-    }
-  }
-}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
deleted file mode 100644
index ce80cdd..0000000
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
+++ /dev/null
@@ -1,166 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.util.stream.Collectors.joining;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.Streams;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.api.groups.GroupInput;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.launcher.GerritLauncher;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.SitePaths;
-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.testutil.ConfigSuite;
-import com.google.inject.Injector;
-import com.google.inject.Module;
-import com.google.inject.Provider;
-import java.util.Arrays;
-import java.util.Collections;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Rule;
-import org.junit.rules.RuleChain;
-import org.junit.rules.TemporaryFolder;
-import org.junit.rules.TestRule;
-import org.junit.runner.Description;
-import org.junit.runner.RunWith;
-import org.junit.runners.model.Statement;
-
-@RunWith(ConfigSuite.class)
-@UseLocalDisk
-public abstract class StandaloneSiteTest {
-  protected class ServerContext implements RequestContext, AutoCloseable {
-    private final GerritServer server;
-    private final ManualRequestContext ctx;
-
-    private ServerContext(GerritServer server) throws Exception {
-      this.server = server;
-      Injector i = server.getTestInjector();
-      if (adminId == null) {
-        adminId = i.getInstance(AccountCreator.class).admin().getId();
-      }
-      ctx = i.getInstance(OneOffRequestContext.class).openAs(adminId);
-      GerritApi gApi = i.getInstance(GerritApi.class);
-
-      try {
-        // ServerContext ctor is called multiple times but the group can be only created once
-        gApi.groups().id("Group");
-      } catch (ResourceNotFoundException e) {
-        GroupInput in = new GroupInput();
-        in.members = Collections.singletonList("admin");
-        in.name = "Group";
-        gApi.groups().create(in);
-      }
-    }
-
-    @Override
-    public CurrentUser getUser() {
-      return ctx.getUser();
-    }
-
-    @Override
-    public Provider<ReviewDb> getReviewDbProvider() {
-      return ctx.getReviewDbProvider();
-    }
-
-    public Injector getInjector() {
-      return server.getTestInjector();
-    }
-
-    @Override
-    public void close() throws Exception {
-      try {
-        ctx.close();
-      } finally {
-        server.close();
-      }
-    }
-  }
-
-  @ConfigSuite.Parameter public Config baseConfig;
-  @ConfigSuite.Name private String configName;
-
-  private final TemporaryFolder tempSiteDir = new TemporaryFolder();
-
-  private final TestRule testRunner =
-      new TestRule() {
-        @Override
-        public Statement apply(Statement base, Description description) {
-          return new Statement() {
-            @Override
-            public void evaluate() throws Throwable {
-              beforeTest(description);
-              base.evaluate();
-            }
-          };
-        }
-      };
-
-  @Rule public RuleChain ruleChain = RuleChain.outerRule(tempSiteDir).around(testRunner);
-
-  protected SitePaths sitePaths;
-  protected Account.Id adminId;
-
-  private GerritServer.Description serverDesc;
-
-  private void beforeTest(Description description) throws Exception {
-    serverDesc = GerritServer.Description.forTestMethod(description, configName);
-    sitePaths = new SitePaths(tempSiteDir.getRoot().toPath());
-    GerritServer.init(serverDesc, baseConfig, sitePaths.site_path);
-  }
-
-  protected ServerContext startServer() throws Exception {
-    return startServer(null);
-  }
-
-  protected ServerContext startServer(@Nullable Module testSysModule, String... additionalArgs)
-      throws Exception {
-    return new ServerContext(startImpl(testSysModule, additionalArgs));
-  }
-
-  protected void assertServerStartupFails() throws Exception {
-    try (GerritServer server = startImpl(null)) {
-      fail("expected server startup to fail");
-    } catch (GerritServer.StartupException e) {
-      // Expected.
-    }
-  }
-
-  private GerritServer startImpl(@Nullable Module testSysModule, String... additionalArgs)
-      throws Exception {
-    return GerritServer.start(
-        serverDesc, baseConfig, sitePaths.site_path, testSysModule, additionalArgs);
-  }
-
-  protected static void runGerrit(String... args) throws Exception {
-    assertThat(GerritLauncher.mainImpl(args))
-        .named("gerrit.war " + Arrays.stream(args).collect(joining(" ")))
-        .isEqualTo(0);
-  }
-
-  @SafeVarargs
-  protected static void runGerrit(Iterable<String>... multiArgs) throws Exception {
-    runGerrit(
-        Arrays.stream(multiArgs).flatMap(args -> Streams.stream(args)).toArray(String[]::new));
-  }
-}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java
deleted file mode 100644
index 7acb135..0000000
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java
+++ /dev/null
@@ -1,91 +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;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.net.InetAddresses;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.mail.Address;
-import com.jcraft.jsch.KeyPair;
-import java.io.ByteArrayOutputStream;
-import java.net.InetSocketAddress;
-import java.util.Arrays;
-import java.util.List;
-import org.apache.http.client.utils.URIBuilder;
-import org.eclipse.jgit.lib.PersonIdent;
-
-public class TestAccount {
-  public static List<Account.Id> ids(List<TestAccount> accounts) {
-    return accounts.stream().map(a -> a.id).collect(toList());
-  }
-
-  public static List<String> names(List<TestAccount> accounts) {
-    return accounts.stream().map(a -> a.fullName).collect(toList());
-  }
-
-  public static List<String> names(TestAccount... accounts) {
-    return names(Arrays.asList(accounts));
-  }
-
-  public final Account.Id id;
-  public final String username;
-  public final String email;
-  public final Address emailAddress;
-  public final String fullName;
-  public final KeyPair sshKey;
-  public final String httpPassword;
-  public String status;
-
-  TestAccount(
-      Account.Id id,
-      String username,
-      String email,
-      String fullName,
-      KeyPair sshKey,
-      String httpPassword) {
-    this.id = id;
-    this.username = username;
-    this.email = email;
-    this.emailAddress = new Address(fullName, email);
-    this.fullName = fullName;
-    this.sshKey = sshKey;
-    this.httpPassword = httpPassword;
-  }
-
-  public byte[] privateKey() {
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    sshKey.writePrivateKey(out);
-    return out.toByteArray();
-  }
-
-  public PersonIdent getIdent() {
-    return new PersonIdent(fullName, email);
-  }
-
-  public String getHttpUrl(GerritServer server) {
-    InetSocketAddress addr = server.getHttpAddress();
-    return new URIBuilder()
-        .setScheme("http")
-        .setUserInfo(username, httpPassword)
-        .setHost(InetAddresses.toUriString(addr.getAddress()))
-        .setPort(addr.getPort())
-        .toString();
-  }
-
-  public Account.Id getId() {
-    return id;
-  }
-}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestProjectInput.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestProjectInput.java
deleted file mode 100644
index 739d4f5..0000000
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestProjectInput.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-import static java.lang.annotation.ElementType.METHOD;
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.SubmitType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.Target;
-
-@Target({METHOD})
-@Retention(RUNTIME)
-public @interface TestProjectInput {
-  // Fields from ProjectInput for creating the project.
-
-  String parent() default "";
-
-  boolean createEmptyCommit() default true;
-
-  String description() default "";
-
-  // These may be null in a ProjectInput, but annotations do not allow null
-  // default values. Thus these defaults should match ProjectConfig.
-  SubmitType submitType() default SubmitType.MERGE_IF_NECESSARY;
-
-  InheritableBoolean useContributorAgreements() default InheritableBoolean.INHERIT;
-
-  InheritableBoolean useSignedOffBy() default InheritableBoolean.INHERIT;
-
-  InheritableBoolean useContentMerge() default InheritableBoolean.INHERIT;
-
-  InheritableBoolean requireChangeId() default InheritableBoolean.INHERIT;
-
-  // Fields specific to acceptance test behavior.
-
-  /** Username to use for initial clone, passed to {@link AccountCreator}. */
-  String cloneAs() default "admin";
-}
diff --git a/gerrit-acceptance-tests/BUILD b/gerrit-acceptance-tests/BUILD
deleted file mode 100644
index ebc7c9b..0000000
--- a/gerrit-acceptance-tests/BUILD
+++ /dev/null
@@ -1,48 +0,0 @@
-RESOURCES = glob(["src/test/resources/**/*"])
-
-java_library(
-    name = "lib",
-    testonly = 1,
-    srcs = ["src/test/java/com/google/gerrit/acceptance/Dummy.java"],
-    resources = RESOURCES,
-    visibility = ["//visibility:public"],
-    exports = [
-        "//gerrit-acceptance-framework:lib",
-        "//gerrit-common:annotations",
-        "//gerrit-common:server",
-        "//gerrit-extension-api:api",
-        "//gerrit-gpg:testutil",
-        "//gerrit-httpd:httpd",
-        "//gerrit-launcher:launcher",
-        "//gerrit-lucene:lucene",
-        "//gerrit-pgm:init",
-        "//gerrit-pgm:pgm",
-        "//gerrit-pgm:util",
-        "//gerrit-reviewdb:server",
-        "//gerrit-server:metrics",
-        "//gerrit-server:prolog-common",
-        "//gerrit-server:receive",
-        "//gerrit-server:server",
-        "//gerrit-server:testutil",
-        "//gerrit-sshd:sshd",
-        "//gerrit-test-util:test_util",
-        "//lib:args4j",
-        "//lib:gson",
-        "//lib:guava-retrying",
-        "//lib:gwtjsonrpc",
-        "//lib:gwtorm",
-        "//lib:h2",
-        "//lib:jimfs",
-        "//lib:jsch",
-        "//lib:servlet-api-3_1-without-neverlink",
-        "//lib/bouncycastle:bcpg",
-        "//lib/bouncycastle:bcprov",
-        "//lib/commons:compress",
-        "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-        "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/log:api",
-        "//lib/mina:sshd",
-    ],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/Dummy.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/Dummy.java
deleted file mode 100644
index fb4783b..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/Dummy.java
+++ /dev/null
@@ -1,17 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-public class Dummy {}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/BUILD
deleted file mode 100644
index d16b64a..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/BUILD
+++ /dev/null
@@ -1,7 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*.java"]),
-    group = "annotation",
-    labels = ["annotation"],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
deleted file mode 100644
index 1598762..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ /dev/null
@@ -1,2008 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.accounts;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.gerrit.acceptance.GitUtil.deleteRef;
-import static com.google.gerrit.acceptance.GitUtil.fetch;
-import static com.google.gerrit.gpg.PublicKeyStore.REFS_GPG_KEYS;
-import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
-import static com.google.gerrit.gpg.testutil.TestKeys.allValidKeys;
-import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithExpiration;
-import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithSecondUserId;
-import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithoutExpiration;
-import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
-import static com.google.gerrit.server.StarredChangesUtil.IGNORE_LABEL;
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.Iterables;
-import com.google.common.io.BaseEncoding;
-import com.google.common.util.concurrent.AtomicLongMap;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AccountCreator;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.Sandboxed;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.acceptance.UseSsh;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.accounts.EmailInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.StarsInput;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountsInput;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.GpgKeyInfo;
-import com.google.gerrit.extensions.common.SshKeyInfo;
-import com.google.gerrit.extensions.events.AccountIndexedListener;
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.gpg.Fingerprint;
-import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.gpg.testutil.TestKey;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.AccountConfig;
-import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.account.Emails;
-import com.google.gerrit.server.account.WatchConfig;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
-import com.google.gerrit.server.project.RefPattern;
-import com.google.gerrit.server.query.account.InternalAccountQuery;
-import com.google.gerrit.server.util.MagicBranch;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.FakeEmailSender.Message;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.atomic.AtomicBoolean;
-import org.bouncycastle.bcpg.ArmoredOutputStream;
-import org.bouncycastle.openpgp.PGPPublicKey;
-import org.bouncycastle.openpgp.PGPPublicKeyRing;
-import org.eclipse.jgit.api.errors.TransportException;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.PushCertificateIdent;
-import org.eclipse.jgit.transport.PushResult;
-import org.eclipse.jgit.transport.RemoteRefUpdate;
-import org.eclipse.jgit.treewalk.TreeWalk;
-import org.junit.After;
-import org.junit.Assert;
-import org.junit.Before;
-import org.junit.Test;
-
-public class AccountIT extends AbstractDaemonTest {
-  @ConfigSuite.Default
-  public static Config enableSignedPushConfig() {
-    Config cfg = new Config();
-    cfg.setBoolean("receive", null, "enableSignedPush", true);
-    return cfg;
-  }
-
-  @Inject private Provider<PublicKeyStore> publicKeyStoreProvider;
-
-  @Inject private AllUsersName allUsers;
-
-  @Inject private AccountsUpdate.Server accountsUpdate;
-
-  @Inject private ExternalIds externalIds;
-
-  @Inject private ExternalIdsUpdate.User externalIdsUpdateFactory;
-
-  @Inject private DynamicSet<AccountIndexedListener> accountIndexedListeners;
-
-  @Inject private DynamicSet<GitReferenceUpdatedListener> refUpdateListeners;
-
-  @Inject private Sequences seq;
-
-  @Inject private Provider<InternalAccountQuery> accountQueryProvider;
-
-  @Inject protected Emails emails;
-
-  private AccountIndexedCounter accountIndexedCounter;
-  private RegistrationHandle accountIndexEventCounterHandle;
-  private RefUpdateCounter refUpdateCounter;
-  private RegistrationHandle refUpdateCounterHandle;
-  private ExternalIdsUpdate externalIdsUpdate;
-  private List<ExternalId> savedExternalIds;
-
-  @Before
-  public void addAccountIndexEventCounter() {
-    accountIndexedCounter = new AccountIndexedCounter();
-    accountIndexEventCounterHandle = accountIndexedListeners.add(accountIndexedCounter);
-  }
-
-  @After
-  public void removeAccountIndexEventCounter() {
-    if (accountIndexEventCounterHandle != null) {
-      accountIndexEventCounterHandle.remove();
-    }
-  }
-
-  @Before
-  public void addRefUpdateCounter() {
-    refUpdateCounter = new RefUpdateCounter();
-    refUpdateCounterHandle = refUpdateListeners.add(refUpdateCounter);
-  }
-
-  @After
-  public void removeRefUpdateCounter() {
-    if (refUpdateCounterHandle != null) {
-      refUpdateCounterHandle.remove();
-    }
-  }
-
-  @Before
-  public void saveExternalIds() throws Exception {
-    externalIdsUpdate = externalIdsUpdateFactory.create();
-
-    savedExternalIds = new ArrayList<>();
-    savedExternalIds.addAll(externalIds.byAccount(admin.id));
-    savedExternalIds.addAll(externalIds.byAccount(user.id));
-  }
-
-  @After
-  public void restoreExternalIds() throws Exception {
-    if (savedExternalIds != null) {
-      // savedExternalIds is null when we don't run SSH tests and the assume in
-      // @Before in AbstractDaemonTest prevents this class' @Before method from
-      // being executed.
-      externalIdsUpdate.delete(externalIds.byAccount(admin.id));
-      externalIdsUpdate.delete(externalIds.byAccount(user.id));
-      externalIdsUpdate.insert(savedExternalIds);
-    }
-  }
-
-  @After
-  public void clearPublicKeyStore() throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      Ref ref = repo.exactRef(REFS_GPG_KEYS);
-      if (ref != null) {
-        RefUpdate ru = repo.updateRef(REFS_GPG_KEYS);
-        ru.setForceUpdate(true);
-        assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
-      }
-    }
-  }
-
-  @After
-  public void deleteGpgKeys() throws Exception {
-    String ref = REFS_GPG_KEYS;
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      if (repo.getRefDatabase().exactRef(ref) != null) {
-        RefUpdate ru = repo.updateRef(ref);
-        ru.setForceUpdate(true);
-        assertWithMessage("Failed to delete " + ref)
-            .that(ru.delete())
-            .isEqualTo(RefUpdate.Result.FORCED);
-      }
-    }
-  }
-
-  @Test
-  public void create() throws Exception {
-    Account.Id accountId = create(2); // account creation + external ID creation
-    refUpdateCounter.assertRefUpdateFor(
-        RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
-        RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
-        RefUpdateCounter.projectRef(allUsers, RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS));
-  }
-
-  @Test
-  @UseSsh
-  public void createWithSshKeys() throws Exception {
-    Account.Id accountId = create(3); // account creation + external ID creation + adding SSH keys
-    refUpdateCounter.assertRefUpdateFor(
-        ImmutableMap.of(
-            RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
-            2,
-            RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
-            1,
-            RefUpdateCounter.projectRef(
-                allUsers, RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS),
-            1));
-  }
-
-  private Account.Id create(int expectedAccountReindexCalls) throws Exception {
-    String name = "foo";
-    TestAccount foo = accountCreator.create(name);
-    AccountInfo info = gApi.accounts().id(foo.id.get()).get();
-    assertThat(info.username).isEqualTo(name);
-    assertThat(info.name).isEqualTo(name);
-    accountIndexedCounter.assertReindexOf(foo, expectedAccountReindexCalls);
-    assertUserBranch(foo.getId(), name, null);
-    return foo.getId();
-  }
-
-  @Test
-  public void createAnonymousCoward() throws Exception {
-    TestAccount anonymousCoward = accountCreator.create();
-    accountIndexedCounter.assertReindexOf(anonymousCoward);
-    assertUserBranchWithoutAccountConfig(anonymousCoward.getId());
-  }
-
-  @Test
-  public void updateNonExistingAccount() throws Exception {
-    Account.Id nonExistingAccountId = new Account.Id(999999);
-    AtomicBoolean consumerCalled = new AtomicBoolean();
-    Account account =
-        accountsUpdate.create().update(nonExistingAccountId, a -> consumerCalled.set(true));
-    assertThat(account).isNull();
-    assertThat(consumerCalled.get()).isFalse();
-  }
-
-  @Test
-  public void updateAccountWithoutAccountConfigNoteDb() throws Exception {
-    TestAccount anonymousCoward = accountCreator.create();
-    assertUserBranchWithoutAccountConfig(anonymousCoward.getId());
-
-    String status = "OOO";
-    Account account =
-        accountsUpdate.create().update(anonymousCoward.getId(), a -> a.setStatus(status));
-    assertThat(account).isNotNull();
-    assertThat(account.getFullName()).isNull();
-    assertThat(account.getStatus()).isEqualTo(status);
-    assertUserBranch(anonymousCoward.getId(), null, status);
-  }
-
-  private void assertUserBranchWithoutAccountConfig(Account.Id accountId) throws Exception {
-    assertUserBranch(accountId, null, null);
-  }
-
-  private void assertUserBranch(
-      Account.Id accountId, @Nullable String name, @Nullable String status) throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo);
-        ObjectReader or = repo.newObjectReader()) {
-      Ref ref = repo.exactRef(RefNames.refsUsers(accountId));
-      assertThat(ref).isNotNull();
-      RevCommit c = rw.parseCommit(ref.getObjectId());
-      long timestampDiffMs =
-          Math.abs(
-              c.getCommitTime() * 1000L
-                  - accountCache.get(accountId).getAccount().getRegisteredOn().getTime());
-      assertThat(timestampDiffMs).isAtMost(ChangeRebuilderImpl.MAX_WINDOW_MS);
-
-      // Check the 'account.config' file.
-      try (TreeWalk tw = TreeWalk.forPath(or, AccountConfig.ACCOUNT_CONFIG, c.getTree())) {
-        if (name != null || status != null) {
-          assertThat(tw).isNotNull();
-          Config cfg = new Config();
-          cfg.fromText(new String(or.open(tw.getObjectId(0), OBJ_BLOB).getBytes(), UTF_8));
-          assertThat(cfg.getString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_FULL_NAME))
-              .isEqualTo(name);
-          assertThat(cfg.getString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_STATUS))
-              .isEqualTo(status);
-        } else {
-          // No account properties were set, hence an 'account.config' file was not created.
-          assertThat(tw).isNull();
-        }
-      }
-    }
-  }
-
-  @Test
-  public void get() throws Exception {
-    AccountInfo info = gApi.accounts().id("admin").get();
-    assertThat(info.name).isEqualTo("Administrator");
-    assertThat(info.email).isEqualTo("admin@example.com");
-    assertThat(info.username).isEqualTo("admin");
-    accountIndexedCounter.assertNoReindex();
-  }
-
-  @Test
-  public void getByIntId() throws Exception {
-    AccountInfo info = gApi.accounts().id("admin").get();
-    AccountInfo infoByIntId = gApi.accounts().id(info._accountId).get();
-    assertThat(info.name).isEqualTo(infoByIntId.name);
-    accountIndexedCounter.assertNoReindex();
-  }
-
-  @Test
-  public void self() throws Exception {
-    AccountInfo info = gApi.accounts().self().get();
-    assertUser(info, admin);
-
-    info = gApi.accounts().id("self").get();
-    assertUser(info, admin);
-    accountIndexedCounter.assertNoReindex();
-  }
-
-  @Test
-  public void active() throws Exception {
-    assertThat(gApi.accounts().id("user").getActive()).isTrue();
-    gApi.accounts().id("user").setActive(false);
-    assertThat(gApi.accounts().id("user").getActive()).isFalse();
-    accountIndexedCounter.assertReindexOf(user);
-
-    gApi.accounts().id("user").setActive(true);
-    assertThat(gApi.accounts().id("user").getActive()).isTrue();
-    accountIndexedCounter.assertReindexOf(user);
-  }
-
-  @Test
-  public void deactivateSelf() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cannot deactivate own account");
-    gApi.accounts().self().setActive(false);
-  }
-
-  @Test
-  public void deactivateNotActive() throws Exception {
-    assertThat(gApi.accounts().id("user").getActive()).isTrue();
-    gApi.accounts().id("user").setActive(false);
-    assertThat(gApi.accounts().id("user").getActive()).isFalse();
-    try {
-      gApi.accounts().id("user").setActive(false);
-      fail("Expected exception");
-    } catch (ResourceConflictException e) {
-      assertThat(e.getMessage()).isEqualTo("account not active");
-    }
-    gApi.accounts().id("user").setActive(true);
-  }
-
-  @Test
-  public void starUnstarChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    refUpdateCounter.clear();
-
-    gApi.accounts().self().starChange(triplet);
-    ChangeInfo change = info(triplet);
-    assertThat(change.starred).isTrue();
-    assertThat(change.stars).contains(DEFAULT_LABEL);
-    refUpdateCounter.assertRefUpdateFor(
-        RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
-
-    gApi.accounts().self().unstarChange(triplet);
-    change = info(triplet);
-    assertThat(change.starred).isNull();
-    assertThat(change.stars).isNull();
-    refUpdateCounter.assertRefUpdateFor(
-        RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
-
-    accountIndexedCounter.assertNoReindex();
-  }
-
-  @Test
-  public void starUnstarChangeWithLabels() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    refUpdateCounter.clear();
-
-    assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
-    assertThat(gApi.accounts().self().getStarredChanges()).isEmpty();
-
-    gApi.accounts()
-        .self()
-        .setStars(triplet, new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "red", "blue")));
-    ChangeInfo change = info(triplet);
-    assertThat(change.starred).isTrue();
-    assertThat(change.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
-    assertThat(gApi.accounts().self().getStars(triplet))
-        .containsExactly("blue", "red", DEFAULT_LABEL)
-        .inOrder();
-    List<ChangeInfo> starredChanges = gApi.accounts().self().getStarredChanges();
-    assertThat(starredChanges).hasSize(1);
-    ChangeInfo starredChange = starredChanges.get(0);
-    assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
-    assertThat(starredChange.starred).isTrue();
-    assertThat(starredChange.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
-    refUpdateCounter.assertRefUpdateFor(
-        RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
-
-    gApi.accounts()
-        .self()
-        .setStars(
-            triplet,
-            new StarsInput(ImmutableSet.of("yellow"), ImmutableSet.of(DEFAULT_LABEL, "blue")));
-    change = info(triplet);
-    assertThat(change.starred).isNull();
-    assertThat(change.stars).containsExactly("red", "yellow").inOrder();
-    assertThat(gApi.accounts().self().getStars(triplet)).containsExactly("red", "yellow").inOrder();
-    starredChanges = gApi.accounts().self().getStarredChanges();
-    assertThat(starredChanges).hasSize(1);
-    starredChange = starredChanges.get(0);
-    assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
-    assertThat(starredChange.starred).isNull();
-    assertThat(starredChange.stars).containsExactly("red", "yellow").inOrder();
-    refUpdateCounter.assertRefUpdateFor(
-        RefUpdateCounter.projectRef(
-            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
-
-    accountIndexedCounter.assertNoReindex();
-
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to get stars of another account");
-    gApi.accounts().id(Integer.toString((admin.id.get()))).getStars(triplet);
-  }
-
-  @Test
-  public void starWithInvalidLabels() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid labels: another invalid label, invalid label");
-    gApi.accounts()
-        .self()
-        .setStars(
-            triplet,
-            new StarsInput(
-                ImmutableSet.of(DEFAULT_LABEL, "invalid label", "blue", "another invalid label")));
-  }
-
-  @Test
-  public void starWithDefaultAndIgnoreLabel() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "The labels "
-            + DEFAULT_LABEL
-            + " and "
-            + IGNORE_LABEL
-            + " are mutually exclusive."
-            + " Only one of them can be set.");
-    gApi.accounts()
-        .self()
-        .setStars(triplet, new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "blue", IGNORE_LABEL)));
-  }
-
-  @Test
-  public void ignoreChangeBySetStars() throws Exception {
-    TestAccount user2 = accountCreator.user2();
-    accountIndexedCounter.clear();
-
-    PushOneCommit.Result r = createChange();
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    in = new AddReviewerInput();
-    in.reviewer = user2.email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    setApiUser(user);
-    gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
-
-    sender.clear();
-    setApiUser(admin);
-    gApi.changes().id(r.getChangeId()).abandon();
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(user2.emailAddress);
-    accountIndexedCounter.assertNoReindex();
-  }
-
-  @Test
-  public void addReviewerToIgnoredChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    setApiUser(user);
-    gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
-
-    sender.clear();
-    setApiUser(admin);
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message message = messages.get(0);
-    assertThat(message.rcpt()).containsExactly(user.emailAddress);
-    assertMailReplyTo(message, admin.email);
-    accountIndexedCounter.assertNoReindex();
-  }
-
-  @Test
-  public void suggestAccounts() throws Exception {
-    String adminUsername = "admin";
-    List<AccountInfo> result = gApi.accounts().suggestAccounts().withQuery(adminUsername).get();
-    assertThat(result).hasSize(1);
-    assertThat(result.get(0).username).isEqualTo(adminUsername);
-
-    List<AccountInfo> resultShortcutApi = gApi.accounts().suggestAccounts(adminUsername).get();
-    assertThat(resultShortcutApi).hasSize(result.size());
-
-    List<AccountInfo> emptyResult = gApi.accounts().suggestAccounts("unknown").get();
-    assertThat(emptyResult).isEmpty();
-    accountIndexedCounter.assertNoReindex();
-  }
-
-  @Test
-  public void addEmail() throws Exception {
-    List<String> emails = ImmutableList.of("new.email@example.com", "new.email@example.systems");
-    Set<String> currentEmails = getEmails();
-    for (String email : emails) {
-      assertThat(currentEmails).doesNotContain(email);
-      EmailInput input = newEmailInput(email);
-      gApi.accounts().self().addEmail(input);
-      accountIndexedCounter.assertReindexOf(admin);
-    }
-
-    resetCurrentApiUser();
-    assertThat(getEmails()).containsAllIn(emails);
-  }
-
-  @Test
-  public void addInvalidEmail() throws Exception {
-    List<String> emails =
-        ImmutableList.of(
-            // Missing domain part
-            "new.email",
-
-            // Missing domain part
-            "new.email@",
-
-            // Missing user part
-            "@example.com",
-
-            // Non-supported TLD  (see tlds-alpha-by-domain.txt)
-            "new.email@example.africa");
-    for (String email : emails) {
-      EmailInput input = newEmailInput(email);
-      try {
-        gApi.accounts().self().addEmail(input);
-        fail("Expected BadRequestException for invalid email address: " + email);
-      } catch (BadRequestException e) {
-        assertThat(e).hasMessageThat().isEqualTo("invalid email address");
-      }
-    }
-    accountIndexedCounter.assertNoReindex();
-  }
-
-  @Test
-  public void cannotAddNonConfirmedEmailWithoutModifyAccountPermission() throws Exception {
-    TestAccount account = accountCreator.create(name("user"));
-    EmailInput input = newEmailInput("test@test.com");
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    gApi.accounts().id(account.username).addEmail(input);
-  }
-
-  @Test
-  public void cannotAddEmailAddressUsedByAnotherAccount() throws Exception {
-    String email = "new.email@example.com";
-    EmailInput input = newEmailInput(email);
-    gApi.accounts().self().addEmail(input);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Identity 'mailto:" + email + "' in use by another account");
-    gApi.accounts().id(user.username).addEmail(input);
-  }
-
-  @Test
-  @GerritConfig(
-    name = "auth.registerEmailPrivateKey",
-    value = "HsOc6l+2lhS9G7sE/RsnS7Z6GJjdRDX14co="
-  )
-  public void addEmailSendsConfirmationEmail() throws Exception {
-    String email = "new.email@example.com";
-    EmailInput input = newEmailInput(email, false);
-    gApi.accounts().self().addEmail(input);
-
-    assertThat(sender.getMessages()).hasSize(1);
-    Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(new Address(email));
-  }
-
-  @Test
-  public void deleteEmail() throws Exception {
-    String email = "foo.bar@example.com";
-    EmailInput input = newEmailInput(email);
-    gApi.accounts().self().addEmail(input);
-
-    resetCurrentApiUser();
-    assertThat(getEmails()).contains(email);
-
-    accountIndexedCounter.clear();
-    gApi.accounts().self().deleteEmail(input.email);
-    accountIndexedCounter.assertReindexOf(admin);
-
-    resetCurrentApiUser();
-    assertThat(getEmails()).doesNotContain(email);
-  }
-
-  @Test
-  public void deleteEmailFromCustomExternalIdSchemes() throws Exception {
-    String email = "foo.bar@example.com";
-    String extId1 = "foo:bar";
-    String extId2 = "foo:baz";
-    List<ExternalId> extIds =
-        ImmutableList.of(
-            ExternalId.createWithEmail(ExternalId.Key.parse(extId1), admin.id, email),
-            ExternalId.createWithEmail(ExternalId.Key.parse(extId2), admin.id, email));
-    externalIdsUpdateFactory.create().insert(extIds);
-    accountIndexedCounter.assertReindexOf(admin);
-    assertThat(
-            gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
-        .containsAllOf(extId1, extId2);
-
-    resetCurrentApiUser();
-    assertThat(getEmails()).contains(email);
-
-    gApi.accounts().self().deleteEmail(email);
-    accountIndexedCounter.assertReindexOf(admin);
-
-    resetCurrentApiUser();
-    assertThat(getEmails()).doesNotContain(email);
-    assertThat(
-            gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
-        .containsNoneOf(extId1, extId2);
-  }
-
-  @Test
-  public void deleteEmailOfOtherUser() throws Exception {
-    String email = "foo.bar@example.com";
-    EmailInput input = new EmailInput();
-    input.email = email;
-    input.noConfirmation = true;
-    gApi.accounts().id(user.id.get()).addEmail(input);
-    accountIndexedCounter.assertReindexOf(user);
-
-    setApiUser(user);
-    assertThat(getEmails()).contains(email);
-
-    // admin can delete email of user
-    setApiUser(admin);
-    gApi.accounts().id(user.id.get()).deleteEmail(email);
-    accountIndexedCounter.assertReindexOf(user);
-
-    setApiUser(user);
-    assertThat(getEmails()).doesNotContain(email);
-
-    // user cannot delete email of admin
-    exception.expect(AuthException.class);
-    exception.expectMessage("modify account not permitted");
-    gApi.accounts().id(admin.id.get()).deleteEmail(admin.email);
-  }
-
-  @Test
-  public void lookUpByEmail() throws Exception {
-    // exact match with scheme "mailto:"
-    assertEmail(emails.getAccountFor(admin.email), admin);
-
-    // exact match with other scheme
-    String email = "foo.bar@example.com";
-    externalIdsUpdateFactory
-        .create()
-        .insert(ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id, email));
-    assertEmail(emails.getAccountFor(email), admin);
-
-    // wrong case doesn't match
-    assertThat(emails.getAccountFor(admin.email.toUpperCase(Locale.US))).isEmpty();
-
-    // prefix doesn't match
-    assertThat(emails.getAccountFor(admin.email.substring(0, admin.email.indexOf('@')))).isEmpty();
-
-    // non-existing doesn't match
-    assertThat(emails.getAccountFor("non-existing@example.com")).isEmpty();
-
-    // lookup several accounts by email at once
-    ImmutableSetMultimap<String, Account.Id> byEmails =
-        emails.getAccountsFor(admin.email, user.email);
-    assertEmail(byEmails.get(admin.email), admin);
-    assertEmail(byEmails.get(user.email), user);
-  }
-
-  @Test
-  public void lookUpByPreferredEmail() throws Exception {
-    // create an inconsistent account that has a preferred email without external ID
-    String prefix = "foo.preferred";
-    String prefEmail = prefix + "@example.com";
-    TestAccount foo = accountCreator.create(name("foo"));
-    accountsUpdate.create().update(foo.id, a -> a.setPreferredEmail(prefEmail));
-
-    // verify that the account is still found when using the preferred email to lookup the account
-    ImmutableSet<Account.Id> accountsByPrefEmail = emails.getAccountFor(prefEmail);
-    assertThat(accountsByPrefEmail).hasSize(1);
-    assertThat(Iterables.getOnlyElement(accountsByPrefEmail)).isEqualTo(foo.id);
-
-    // look up by email prefix doesn't find the account
-    accountsByPrefEmail = emails.getAccountFor(prefix);
-    assertThat(accountsByPrefEmail).isEmpty();
-
-    // look up by other case doesn't find the account
-    accountsByPrefEmail = emails.getAccountFor(prefEmail.toUpperCase(Locale.US));
-    assertThat(accountsByPrefEmail).isEmpty();
-  }
-
-  @Test
-  public void putStatus() throws Exception {
-    List<String> statuses = ImmutableList.of("OOO", "Busy");
-    AccountInfo info;
-    for (String status : statuses) {
-      gApi.accounts().self().setStatus(status);
-      admin.status = status;
-      info = gApi.accounts().self().get();
-      assertUser(info, admin);
-      accountIndexedCounter.assertReindexOf(admin);
-    }
-  }
-
-  @Test
-  @Sandboxed
-  public void fetchUserBranch() throws Exception {
-    setApiUser(user);
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
-    String userRefName = RefNames.refsUsers(user.id);
-
-    // remove default READ permissions
-    ProjectConfig cfg = projectCache.checkedGet(allUsers).getConfig();
-    cfg.getAccessSection(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true)
-        .remove(new Permission(Permission.READ));
-    saveProjectConfig(allUsers, cfg);
-
-    // deny READ permission that is inherited from All-Projects
-    deny(allUsers, RefNames.REFS + "*", Permission.READ, ANONYMOUS_USERS);
-
-    // fetching user branch without READ permission fails
-    try {
-      fetch(allUsersRepo, userRefName + ":userRef");
-      Assert.fail("user branch is visible although no READ permission is granted");
-    } catch (TransportException e) {
-      // expected because no READ granted on user branch
-    }
-
-    // allow each user to read its own user branch
-    grant(
-        allUsers,
-        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
-        Permission.READ,
-        false,
-        REGISTERED_USERS);
-
-    // fetch user branch using refs/users/YY/XXXXXXX
-    fetch(allUsersRepo, userRefName + ":userRef");
-    Ref userRef = allUsersRepo.getRepository().exactRef("userRef");
-    assertThat(userRef).isNotNull();
-
-    // fetch user branch using refs/users/self
-    fetch(allUsersRepo, RefNames.REFS_USERS_SELF + ":userSelfRef");
-    Ref userSelfRef = allUsersRepo.getRepository().getRefDatabase().exactRef("userSelfRef");
-    assertThat(userSelfRef).isNotNull();
-    assertThat(userSelfRef.getObjectId()).isEqualTo(userRef.getObjectId());
-
-    accountIndexedCounter.assertNoReindex();
-
-    // fetching user branch of another user fails
-    String otherUserRefName = RefNames.refsUsers(admin.id);
-    exception.expect(TransportException.class);
-    exception.expectMessage("Remote does not have " + otherUserRefName + " available for fetch.");
-    fetch(allUsersRepo, otherUserRefName + ":otherUserRef");
-  }
-
-  @Test
-  public void pushToUserBranch() throws Exception {
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
-    allUsersRepo.reset("userRef");
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
-    push.to(RefNames.refsUsers(admin.id)).assertOkStatus();
-    accountIndexedCounter.assertReindexOf(admin);
-
-    push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
-    push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
-    accountIndexedCounter.assertReindexOf(admin);
-  }
-
-  @Test
-  public void pushToUserBranchForReview() throws Exception {
-    String userRefName = RefNames.refsUsers(admin.id);
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, userRefName + ":userRef");
-    allUsersRepo.reset("userRef");
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
-    PushOneCommit.Result r = push.to(MagicBranch.NEW_CHANGE + userRefName);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).current().submit();
-    accountIndexedCounter.assertReindexOf(admin);
-
-    push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
-    r = push.to(MagicBranch.NEW_CHANGE + RefNames.REFS_USERS_SELF);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).current().submit();
-    accountIndexedCounter.assertReindexOf(admin);
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchForReviewAndSubmit() throws Exception {
-    String userRef = RefNames.refsUsers(admin.id);
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_STATUS, "out-of-office");
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                db,
-                admin.getIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountConfig.ACCOUNT_CONFIG,
-                ac.toText())
-            .to(MagicBranch.NEW_CHANGE + userRef);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
-
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).current().submit();
-    accountIndexedCounter.assertReindexOf(admin);
-
-    AccountInfo info = gApi.accounts().self().get();
-    assertThat(info.email).isEqualTo(admin.email);
-    assertThat(info.name).isEqualTo(admin.fullName);
-    assertThat(info.status).isEqualTo("out-of-office");
-  }
-
-  @Test
-  public void pushAccountConfigWithPrefEmailThatDoesNotExistAsExtIdToUserBranchForReviewAndSubmit()
-      throws Exception {
-    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
-    String userRef = RefNames.refsUsers(foo.id);
-    accountIndexedCounter.clear();
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    String email = "some.email@example.com";
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_PREFERRED_EMAIL, email);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                db,
-                foo.getIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountConfig.ACCOUNT_CONFIG,
-                ac.toText())
-            .to(MagicBranch.NEW_CHANGE + userRef);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
-
-    setApiUser(foo);
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).current().submit();
-
-    accountIndexedCounter.assertReindexOf(foo);
-
-    AccountInfo info = gApi.accounts().self().get();
-    assertThat(info.email).isEqualTo(email);
-    assertThat(info.name).isEqualTo(foo.fullName);
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfConfigIsInvalid()
-      throws Exception {
-    String userRef = RefNames.refsUsers(admin.id);
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                db,
-                admin.getIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountConfig.ACCOUNT_CONFIG,
-                "invalid config")
-            .to(MagicBranch.NEW_CHANGE + userRef);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
-
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format(
-            "invalid account configuration: commit '%s' has an invalid '%s' file for account '%s':"
-                + " Invalid config file %s in commit %s",
-            r.getCommit().name(),
-            AccountConfig.ACCOUNT_CONFIG,
-            admin.id,
-            AccountConfig.ACCOUNT_CONFIG,
-            r.getCommit().name()));
-    gApi.changes().id(r.getChangeId()).current().submit();
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfPreferredEmailIsInvalid()
-      throws Exception {
-    String userRef = RefNames.refsUsers(admin.id);
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    String noEmail = "no.email";
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_PREFERRED_EMAIL, noEmail);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                db,
-                admin.getIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountConfig.ACCOUNT_CONFIG,
-                ac.toText())
-            .to(MagicBranch.NEW_CHANGE + userRef);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
-
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format(
-            "invalid account configuration: invalid preferred email '%s' for account '%s'",
-            noEmail, admin.id));
-    gApi.changes().id(r.getChangeId()).current().submit();
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfOwnAccountIsDeactivated()
-      throws Exception {
-    String userRef = RefNames.refsUsers(admin.id);
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setBoolean(AccountConfig.ACCOUNT, null, AccountConfig.KEY_ACTIVE, false);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                db,
-                admin.getIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountConfig.ACCOUNT_CONFIG,
-                ac.toText())
-            .to(MagicBranch.NEW_CHANGE + userRef);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
-
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("invalid account configuration: cannot deactivate own account");
-    gApi.changes().id(r.getChangeId()).current().submit();
-  }
-
-  @Test
-  @Sandboxed
-  public void pushAccountConfigToUserBranchForReviewDeactivateOtherAccount() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-
-    TestAccount foo = accountCreator.create(name("foo"));
-    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isTrue();
-    String userRef = RefNames.refsUsers(foo.id);
-    accountIndexedCounter.clear();
-
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroup.getGroupUUID());
-    grantLabel("Code-Review", -2, 2, allUsers, userRef, false, adminGroup.getGroupUUID(), false);
-    grant(allUsers, userRef, Permission.SUBMIT, false, adminGroup.getGroupUUID());
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setBoolean(AccountConfig.ACCOUNT, null, AccountConfig.KEY_ACTIVE, false);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                db,
-                admin.getIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountConfig.ACCOUNT_CONFIG,
-                ac.toText())
-            .to(MagicBranch.NEW_CHANGE + userRef);
-    r.assertOkStatus();
-    accountIndexedCounter.assertNoReindex();
-    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
-
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).current().submit();
-    accountIndexedCounter.assertReindexOf(foo);
-
-    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isFalse();
-  }
-
-  @Test
-  public void pushWatchConfigToUserBranch() throws Exception {
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    Config wc = new Config();
-    wc.setString(
-        WatchConfig.PROJECT,
-        project.get(),
-        WatchConfig.KEY_NOTIFY,
-        WatchConfig.NotifyValue.create(null, EnumSet.of(NotifyType.ALL_COMMENTS)).toString());
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            allUsersRepo,
-            "Add project watch",
-            WatchConfig.WATCH_CONFIG,
-            wc.toText());
-    push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
-    accountIndexedCounter.assertReindexOf(admin);
-
-    String invalidNotifyValue = "]invalid[";
-    wc.setString(WatchConfig.PROJECT, project.get(), WatchConfig.KEY_NOTIFY, invalidNotifyValue);
-    push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            allUsersRepo,
-            "Add invalid project watch",
-            WatchConfig.WATCH_CONFIG,
-            wc.toText());
-    PushOneCommit.Result r = push.to(RefNames.REFS_USERS_SELF);
-    r.assertErrorStatus("invalid watch configuration");
-    r.assertMessage(
-        String.format(
-            "%s: Invalid project watch of account %d for project %s: %s",
-            WatchConfig.WATCH_CONFIG, admin.getId().get(), project.get(), invalidNotifyValue));
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranch() throws Exception {
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_STATUS, "out-of-office");
-
-    pushFactory
-        .create(
-            db,
-            admin.getIdent(),
-            allUsersRepo,
-            "Update account config",
-            AccountConfig.ACCOUNT_CONFIG,
-            ac.toText())
-        .to(RefNames.REFS_USERS_SELF)
-        .assertOkStatus();
-    accountIndexedCounter.assertReindexOf(admin);
-
-    AccountInfo info = gApi.accounts().self().get();
-    assertThat(info.email).isEqualTo(admin.email);
-    assertThat(info.name).isEqualTo(admin.fullName);
-    assertThat(info.status).isEqualTo("out-of-office");
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchIsRejectedIfConfigIsInvalid() throws Exception {
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                db,
-                admin.getIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountConfig.ACCOUNT_CONFIG,
-                "invalid config")
-            .to(RefNames.REFS_USERS_SELF);
-    r.assertErrorStatus("invalid account configuration");
-    r.assertMessage(
-        String.format(
-            "commit '%s' has an invalid '%s' file for account '%s':"
-                + " Invalid config file %s in commit %s",
-            r.getCommit().name(),
-            AccountConfig.ACCOUNT_CONFIG,
-            admin.id,
-            AccountConfig.ACCOUNT_CONFIG,
-            r.getCommit().name()));
-    accountIndexedCounter.assertNoReindex();
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchIsRejectedIfPreferredEmailIsInvalid() throws Exception {
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    String noEmail = "no.email";
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_PREFERRED_EMAIL, noEmail);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                db,
-                admin.getIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountConfig.ACCOUNT_CONFIG,
-                ac.toText())
-            .to(RefNames.REFS_USERS_SELF);
-    r.assertErrorStatus("invalid account configuration");
-    r.assertMessage(
-        String.format("invalid preferred email '%s' for account '%s'", noEmail, admin.id));
-    accountIndexedCounter.assertNoReindex();
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchInvalidPreferredEmailButNotChanged() throws Exception {
-    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
-    String userRef = RefNames.refsUsers(foo.id);
-
-    String noEmail = "no.email";
-    accountsUpdate.create().update(foo.id, a -> a.setPreferredEmail(noEmail));
-    accountIndexedCounter.clear();
-
-    grant(allUsers, userRef, Permission.PUSH, false, REGISTERED_USERS);
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    String status = "in vacation";
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_STATUS, status);
-
-    pushFactory
-        .create(
-            db,
-            foo.getIdent(),
-            allUsersRepo,
-            "Update account config",
-            AccountConfig.ACCOUNT_CONFIG,
-            ac.toText())
-        .to(userRef)
-        .assertOkStatus();
-    accountIndexedCounter.assertReindexOf(foo);
-
-    AccountInfo info = gApi.accounts().id(foo.id.get()).get();
-    assertThat(info.email).isEqualTo(noEmail);
-    assertThat(info.name).isEqualTo(foo.fullName);
-    assertThat(info.status).isEqualTo(status);
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchIfPreferredEmailDoesNotExistAsExtId() throws Exception {
-    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
-    String userRef = RefNames.refsUsers(foo.id);
-    accountIndexedCounter.clear();
-
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroup.getGroupUUID());
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    String email = "some.email@example.com";
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_PREFERRED_EMAIL, email);
-
-    pushFactory
-        .create(
-            db,
-            foo.getIdent(),
-            allUsersRepo,
-            "Update account config",
-            AccountConfig.ACCOUNT_CONFIG,
-            ac.toText())
-        .to(userRef)
-        .assertOkStatus();
-    accountIndexedCounter.assertReindexOf(foo);
-
-    AccountInfo info = gApi.accounts().id(foo.id.get()).get();
-    assertThat(info.email).isEqualTo(email);
-    assertThat(info.name).isEqualTo(foo.fullName);
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchIsRejectedIfOwnAccountIsDeactivated() throws Exception {
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setBoolean(AccountConfig.ACCOUNT, null, AccountConfig.KEY_ACTIVE, false);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                db,
-                admin.getIdent(),
-                allUsersRepo,
-                "Update account config",
-                AccountConfig.ACCOUNT_CONFIG,
-                ac.toText())
-            .to(RefNames.REFS_USERS_SELF);
-    r.assertErrorStatus("invalid account configuration");
-    r.assertMessage("cannot deactivate own account");
-    accountIndexedCounter.assertNoReindex();
-  }
-
-  @Test
-  @Sandboxed
-  public void pushAccountConfigToUserBranchDeactivateOtherAccount() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-
-    TestAccount foo = accountCreator.create(name("foo"));
-    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isTrue();
-    String userRef = RefNames.refsUsers(foo.id);
-    accountIndexedCounter.clear();
-
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroup.getGroupUUID());
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, userRef + ":userRef");
-    allUsersRepo.reset("userRef");
-
-    Config ac = getAccountConfig(allUsersRepo);
-    ac.setBoolean(AccountConfig.ACCOUNT, null, AccountConfig.KEY_ACTIVE, false);
-
-    pushFactory
-        .create(
-            db,
-            admin.getIdent(),
-            allUsersRepo,
-            "Update account config",
-            AccountConfig.ACCOUNT_CONFIG,
-            ac.toText())
-        .to(userRef)
-        .assertOkStatus();
-    accountIndexedCounter.assertReindexOf(foo);
-
-    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isFalse();
-  }
-
-  @Test
-  @Sandboxed
-  public void cannotCreateUserBranch() throws Exception {
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
-
-    String userRef = RefNames.refsUsers(new Account.Id(seq.nextAccountId()));
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    PushOneCommit.Result r = pushFactory.create(db, admin.getIdent(), allUsersRepo).to(userRef);
-    r.assertErrorStatus();
-    assertThat(r.getMessage()).contains("Not allowed to create user branch.");
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(userRef)).isNull();
-    }
-  }
-
-  @Test
-  @Sandboxed
-  public void createUserBranchWithAccessDatabaseCapability() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
-
-    String userRef = RefNames.refsUsers(new Account.Id(seq.nextAccountId()));
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    pushFactory.create(db, admin.getIdent(), allUsersRepo).to(userRef).assertOkStatus();
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(userRef)).isNotNull();
-    }
-  }
-
-  @Test
-  @Sandboxed
-  public void cannotCreateNonUserBranchUnderRefsUsersWithAccessDatabaseCapability()
-      throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
-
-    String userRef = RefNames.REFS_USERS + "foo";
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    PushOneCommit.Result r = pushFactory.create(db, admin.getIdent(), allUsersRepo).to(userRef);
-    r.assertErrorStatus();
-    assertThat(r.getMessage()).contains("Not allowed to create non-user branch under refs/users/.");
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(userRef)).isNull();
-    }
-  }
-
-  @Test
-  @Sandboxed
-  public void createDefaultUserBranch() throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(RefNames.REFS_USERS_DEFAULT)).isNull();
-    }
-
-    grant(allUsers, RefNames.REFS_USERS_DEFAULT, Permission.CREATE);
-    grant(allUsers, RefNames.REFS_USERS_DEFAULT, Permission.PUSH);
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    pushFactory
-        .create(db, admin.getIdent(), allUsersRepo)
-        .to(RefNames.REFS_USERS_DEFAULT)
-        .assertOkStatus();
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(RefNames.REFS_USERS_DEFAULT)).isNotNull();
-    }
-  }
-
-  @Test
-  @Sandboxed
-  public void cannotDeleteUserBranch() throws Exception {
-    grant(
-        allUsers,
-        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
-        Permission.DELETE,
-        true,
-        REGISTERED_USERS);
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    String userRef = RefNames.refsUsers(admin.id);
-    PushResult r = deleteRef(allUsersRepo, userRef);
-    RemoteRefUpdate refUpdate = r.getRemoteUpdate(userRef);
-    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
-    assertThat(refUpdate.getMessage()).contains("Not allowed to delete user branch.");
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(userRef)).isNotNull();
-    }
-  }
-
-  @Test
-  @Sandboxed
-  public void deleteUserBranchWithAccessDatabaseCapability() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    grant(
-        allUsers,
-        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
-        Permission.DELETE,
-        true,
-        REGISTERED_USERS);
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    String userRef = RefNames.refsUsers(admin.id);
-    PushResult r = deleteRef(allUsersRepo, userRef);
-    RemoteRefUpdate refUpdate = r.getRemoteUpdate(userRef);
-    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(userRef)).isNull();
-    }
-
-    assertThat(accountCache.getOrNull(admin.id)).isNull();
-    assertThat(accountQueryProvider.get().byDefault(admin.id.toString())).isEmpty();
-  }
-
-  @Test
-  public void addGpgKey() throws Exception {
-    TestKey key = validKeyWithoutExpiration();
-    String id = key.getKeyIdString();
-    addExternalIdEmail(admin, "test1@example.com");
-
-    assertKeyMapContains(key, addGpgKey(key.getPublicKeyArmored()));
-    assertKeys(key);
-
-    setApiUser(user);
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage(id);
-    gApi.accounts().self().gpgKey(id).get();
-  }
-
-  @Test
-  public void reAddExistingGpgKey() throws Exception {
-    addExternalIdEmail(admin, "test5@example.com");
-    TestKey key = validKeyWithSecondUserId();
-    String id = key.getKeyIdString();
-    PGPPublicKey pk = key.getPublicKey();
-
-    GpgKeyInfo info = addGpgKey(armor(pk)).get(id);
-    assertThat(info.userIds).hasSize(2);
-    assertIteratorSize(2, getOnlyKeyFromStore(key).getUserIDs());
-
-    pk = PGPPublicKey.removeCertification(pk, "foo:myId");
-    info = addGpgKey(armor(pk)).get(id);
-    assertThat(info.userIds).hasSize(1);
-    assertIteratorSize(1, getOnlyKeyFromStore(key).getUserIDs());
-  }
-
-  @Test
-  public void addOtherUsersGpgKey_Conflict() throws Exception {
-    // Both users have a matching external ID for this key.
-    addExternalIdEmail(admin, "test5@example.com");
-    externalIdsUpdate.insert(ExternalId.create("foo", "myId", user.getId()));
-    accountIndexedCounter.assertReindexOf(user);
-
-    TestKey key = validKeyWithSecondUserId();
-    addGpgKey(key.getPublicKeyArmored());
-    setApiUser(user);
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("GPG key already associated with another account");
-    addGpgKey(key.getPublicKeyArmored());
-  }
-
-  @Test
-  public void listGpgKeys() throws Exception {
-    List<TestKey> keys = allValidKeys();
-    List<String> toAdd = new ArrayList<>(keys.size());
-    for (TestKey key : keys) {
-      addExternalIdEmail(admin, PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress());
-      toAdd.add(key.getPublicKeyArmored());
-    }
-    gApi.accounts().self().putGpgKeys(toAdd, ImmutableList.<String>of());
-    assertKeys(keys);
-    accountIndexedCounter.assertReindexOf(admin);
-  }
-
-  @Test
-  public void deleteGpgKey() throws Exception {
-    TestKey key = validKeyWithoutExpiration();
-    String id = key.getKeyIdString();
-    addExternalIdEmail(admin, "test1@example.com");
-    addGpgKey(key.getPublicKeyArmored());
-    assertKeys(key);
-
-    gApi.accounts().self().gpgKey(id).delete();
-    accountIndexedCounter.assertReindexOf(admin);
-    assertKeys();
-
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage(id);
-    gApi.accounts().self().gpgKey(id).get();
-  }
-
-  @Test
-  public void addAndRemoveGpgKeys() throws Exception {
-    for (TestKey key : allValidKeys()) {
-      addExternalIdEmail(admin, PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress());
-    }
-    TestKey key1 = validKeyWithoutExpiration();
-    TestKey key2 = validKeyWithExpiration();
-    TestKey key5 = validKeyWithSecondUserId();
-
-    Map<String, GpgKeyInfo> infos =
-        gApi.accounts()
-            .self()
-            .putGpgKeys(
-                ImmutableList.of(key1.getPublicKeyArmored(), key2.getPublicKeyArmored()),
-                ImmutableList.of(key5.getKeyIdString()));
-    assertThat(infos.keySet()).containsExactly(key1.getKeyIdString(), key2.getKeyIdString());
-    assertKeys(key1, key2);
-    accountIndexedCounter.assertReindexOf(admin);
-
-    infos =
-        gApi.accounts()
-            .self()
-            .putGpgKeys(
-                ImmutableList.of(key5.getPublicKeyArmored()),
-                ImmutableList.of(key1.getKeyIdString()));
-    assertThat(infos.keySet()).containsExactly(key1.getKeyIdString(), key5.getKeyIdString());
-    assertKeyMapContains(key5, infos);
-    assertThat(infos.get(key1.getKeyIdString()).key).isNull();
-    assertKeys(key2, key5);
-    accountIndexedCounter.assertReindexOf(admin);
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Cannot both add and delete key: " + keyToString(key2.getPublicKey()));
-    infos =
-        gApi.accounts()
-            .self()
-            .putGpgKeys(
-                ImmutableList.of(key2.getPublicKeyArmored()),
-                ImmutableList.of(key2.getKeyIdString()));
-  }
-
-  @Test
-  public void addMalformedGpgKey() throws Exception {
-    String key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\ntest\n-----END PGP PUBLIC KEY BLOCK-----";
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Failed to parse GPG keys");
-    addGpgKey(key);
-  }
-
-  @Test
-  @UseSsh
-  public void sshKeys() throws Exception {
-    //
-    // The test account should initially have exactly one ssh key
-    List<SshKeyInfo> info = gApi.accounts().self().listSshKeys();
-    assertThat(info).hasSize(1);
-    assertSequenceNumbers(info);
-    SshKeyInfo key = info.get(0);
-    String inital = AccountCreator.publicKey(admin.sshKey, admin.email);
-    assertThat(key.sshPublicKey).isEqualTo(inital);
-    accountIndexedCounter.assertNoReindex();
-
-    // Add a new key
-    String newKey = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
-    gApi.accounts().self().addSshKey(newKey);
-    info = gApi.accounts().self().listSshKeys();
-    assertThat(info).hasSize(2);
-    assertSequenceNumbers(info);
-    accountIndexedCounter.assertReindexOf(admin);
-
-    // Add an existing key (the request succeeds, but the key isn't added again)
-    gApi.accounts().self().addSshKey(inital);
-    info = gApi.accounts().self().listSshKeys();
-    assertThat(info).hasSize(2);
-    assertSequenceNumbers(info);
-    accountIndexedCounter.assertNoReindex();
-
-    // Add another new key
-    String newKey2 = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
-    gApi.accounts().self().addSshKey(newKey2);
-    info = gApi.accounts().self().listSshKeys();
-    assertThat(info).hasSize(3);
-    assertSequenceNumbers(info);
-    accountIndexedCounter.assertReindexOf(admin);
-
-    // Delete second key
-    gApi.accounts().self().deleteSshKey(2);
-    info = gApi.accounts().self().listSshKeys();
-    assertThat(info).hasSize(2);
-    assertThat(info.get(0).seq).isEqualTo(1);
-    assertThat(info.get(1).seq).isEqualTo(3);
-    accountIndexedCounter.assertReindexOf(admin);
-  }
-
-  // reindex is tested by {@link AbstractQueryAccountsTest#reindex}
-  @Test
-  public void reindexPermissions() throws Exception {
-    // admin can reindex any account
-    setApiUser(admin);
-    gApi.accounts().id(user.username).index();
-    accountIndexedCounter.assertReindexOf(user);
-
-    // user can reindex own account
-    setApiUser(user);
-    gApi.accounts().self().index();
-    accountIndexedCounter.assertReindexOf(user);
-
-    // user cannot reindex any account
-    exception.expect(AuthException.class);
-    exception.expectMessage("modify account not permitted");
-    gApi.accounts().id(admin.username).index();
-  }
-
-  @Test
-  @Sandboxed
-  public void checkConsistency() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    resetCurrentApiUser();
-
-    // Create an account with a preferred email.
-    String username = name("foo");
-    String email = username + "@example.com";
-    TestAccount account = accountCreator.create(username, email, "Foo Bar");
-
-    ConsistencyCheckInput input = new ConsistencyCheckInput();
-    input.checkAccounts = new CheckAccountsInput();
-    ConsistencyCheckInfo checkInfo = gApi.config().server().checkConsistency(input);
-    assertThat(checkInfo.checkAccountsResult.problems).isEmpty();
-
-    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
-
-    // Delete the external ID for the preferred email. This makes the account inconsistent since it
-    // now doesn't have an external ID for its preferred email.
-    externalIdsUpdate.delete(ExternalId.createEmail(account.getId(), email));
-    expectedProblems.add(
-        new ConsistencyProblemInfo(
-            ConsistencyProblemInfo.Status.ERROR,
-            "Account '"
-                + account.getId().get()
-                + "' has no external ID for its preferred email '"
-                + email
-                + "'"));
-
-    checkInfo = gApi.config().server().checkConsistency(input);
-    assertThat(checkInfo.checkAccountsResult.problems).hasSize(expectedProblems.size());
-    assertThat(checkInfo.checkAccountsResult.problems).containsExactlyElementsIn(expectedProblems);
-  }
-
-  @Test
-  public void internalQueryFindActiveAndInactiveAccounts() throws Exception {
-    String name = name("foo");
-    assertThat(accountQueryProvider.get().byDefault(name)).isEmpty();
-
-    TestAccount foo1 = accountCreator.create(name + "-1");
-    assertThat(gApi.accounts().id(foo1.username).getActive()).isTrue();
-
-    TestAccount foo2 = accountCreator.create(name + "-2");
-    gApi.accounts().id(foo2.username).setActive(false);
-    assertThat(gApi.accounts().id(foo2.username).getActive()).isFalse();
-
-    assertThat(accountQueryProvider.get().byDefault(name)).hasSize(2);
-  }
-
-  @Test
-  public void checkMetaId() throws Exception {
-    // metaId is set when account is loaded
-    assertThat(accounts.get(admin.getId()).getMetaId()).isEqualTo(getMetaId(admin.getId()));
-
-    // metaId is set when account is created
-    AccountsUpdate au = accountsUpdate.create();
-    Account.Id accountId = new Account.Id(seq.nextAccountId());
-    Account account = au.insert(accountId, a -> {});
-    assertThat(account.getMetaId()).isEqualTo(getMetaId(accountId));
-
-    // metaId is set when account is updated
-    Account updatedAccount = au.update(accountId, a -> a.setFullName("foo"));
-    assertThat(account.getMetaId()).isNotEqualTo(updatedAccount.getMetaId());
-    assertThat(updatedAccount.getMetaId()).isEqualTo(getMetaId(accountId));
-
-    // metaId is set when account is replaced
-    Account newAccount = new Account(accountId, TimeUtil.nowTs());
-    au.replace(newAccount);
-    assertThat(updatedAccount.getMetaId()).isNotEqualTo(newAccount.getMetaId());
-    assertThat(newAccount.getMetaId()).isEqualTo(getMetaId(accountId));
-  }
-
-  private EmailInput newEmailInput(String email, boolean noConfirmation) {
-    EmailInput input = new EmailInput();
-    input.email = email;
-    input.noConfirmation = noConfirmation;
-    return input;
-  }
-
-  private EmailInput newEmailInput(String email) {
-    return newEmailInput(email, true);
-  }
-
-  private String getMetaId(Account.Id accountId) throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo);
-        ObjectReader or = repo.newObjectReader()) {
-      Ref ref = repo.exactRef(RefNames.refsUsers(accountId));
-      return ref != null ? ref.getObjectId().name() : null;
-    }
-  }
-
-  @Test
-  public void groups() throws Exception {
-    assertGroups(
-        admin.username, ImmutableList.of("Anonymous Users", "Registered Users", "Administrators"));
-
-    // TODO: update when test user is fixed to be included in "Anonymous Users" and
-    //      "Registered Users" groups
-    assertGroups(user.username, ImmutableList.of());
-
-    String group = createGroup("group");
-    String newUser = createAccount("user1", group);
-    assertGroups(newUser, ImmutableList.of(group));
-  }
-
-  private void assertGroups(String user, List<String> expected) throws Exception {
-    List<String> actual =
-        gApi.accounts().id(user).getGroups().stream().map(g -> g.name).collect(toList());
-    assertThat(actual).containsExactlyElementsIn(expected);
-  }
-
-  private void assertSequenceNumbers(List<SshKeyInfo> sshKeys) {
-    int seq = 1;
-    for (SshKeyInfo key : sshKeys) {
-      assertThat(key.seq).isEqualTo(seq++);
-    }
-  }
-
-  private PGPPublicKey getOnlyKeyFromStore(TestKey key) throws Exception {
-    try (PublicKeyStore store = publicKeyStoreProvider.get()) {
-      Iterable<PGPPublicKeyRing> keys = store.get(key.getKeyId());
-      assertThat(keys).hasSize(1);
-      return keys.iterator().next().getPublicKey();
-    }
-  }
-
-  private static String armor(PGPPublicKey key) throws Exception {
-    ByteArrayOutputStream out = new ByteArrayOutputStream(4096);
-    try (ArmoredOutputStream aout = new ArmoredOutputStream(out)) {
-      key.encode(aout);
-    }
-    return new String(out.toByteArray(), UTF_8);
-  }
-
-  private static void assertIteratorSize(int size, Iterator<?> it) {
-    List<?> lst = ImmutableList.copyOf(it);
-    assertThat(lst).hasSize(size);
-  }
-
-  private static void assertKeyMapContains(TestKey expected, Map<String, GpgKeyInfo> actualMap) {
-    GpgKeyInfo actual = actualMap.get(expected.getKeyIdString());
-    assertThat(actual).isNotNull();
-    assertThat(actual.id).isNull();
-    actual.id = expected.getKeyIdString();
-    assertKeyEquals(expected, actual);
-  }
-
-  private void assertKeys(TestKey... expectedKeys) throws Exception {
-    assertKeys(Arrays.asList(expectedKeys));
-  }
-
-  private void assertKeys(Iterable<TestKey> expectedKeys) throws Exception {
-    // Check via API.
-    FluentIterable<TestKey> expected = FluentIterable.from(expectedKeys);
-    Map<String, GpgKeyInfo> keyMap = gApi.accounts().self().listGpgKeys();
-    assertThat(keyMap.keySet())
-        .named("keys returned by listGpgKeys()")
-        .containsExactlyElementsIn(expected.transform(TestKey::getKeyIdString));
-
-    for (TestKey key : expected) {
-      assertKeyEquals(key, gApi.accounts().self().gpgKey(key.getKeyIdString()).get());
-      assertKeyEquals(
-          key,
-          gApi.accounts()
-              .self()
-              .gpgKey(Fingerprint.toString(key.getPublicKey().getFingerprint()))
-              .get());
-      assertKeyMapContains(key, keyMap);
-    }
-
-    // Check raw external IDs.
-    Account.Id currAccountId = atrScope.get().getUser().getAccountId();
-    Iterable<String> expectedFps =
-        expected.transform(k -> BaseEncoding.base16().encode(k.getPublicKey().getFingerprint()));
-    Iterable<String> actualFps =
-        externalIds
-            .byAccount(currAccountId, SCHEME_GPGKEY)
-            .stream()
-            .map(e -> e.key().id())
-            .collect(toSet());
-    assertThat(actualFps).named("external IDs in database").containsExactlyElementsIn(expectedFps);
-
-    // Check raw stored keys.
-    for (TestKey key : expected) {
-      getOnlyKeyFromStore(key);
-    }
-  }
-
-  private static void assertKeyEquals(TestKey expected, GpgKeyInfo actual) {
-    String id = expected.getKeyIdString();
-    assertThat(actual.id).named(id).isEqualTo(id);
-    assertThat(actual.fingerprint)
-        .named(id)
-        .isEqualTo(Fingerprint.toString(expected.getPublicKey().getFingerprint()));
-    List<String> userIds = ImmutableList.copyOf(expected.getPublicKey().getUserIDs());
-    assertThat(actual.userIds).named(id).containsExactlyElementsIn(userIds);
-    assertThat(actual.key).named(id).startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n");
-    assertThat(actual.status).isEqualTo(GpgKeyInfo.Status.TRUSTED);
-    assertThat(actual.problems).isEmpty();
-  }
-
-  private void addExternalIdEmail(TestAccount account, String email) throws Exception {
-    checkNotNull(email);
-    externalIdsUpdate.insert(
-        ExternalId.createWithEmail(name("test"), email, account.getId(), email));
-    accountIndexedCounter.assertReindexOf(account);
-    setApiUser(account);
-  }
-
-  private Map<String, GpgKeyInfo> addGpgKey(String armored) throws Exception {
-    Map<String, GpgKeyInfo> gpgKeys =
-        gApi.accounts().self().putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
-    accountIndexedCounter.assertReindexOf(gApi.accounts().self().get());
-    return gpgKeys;
-  }
-
-  private void assertUser(AccountInfo info, TestAccount account) throws Exception {
-    assertThat(info.name).isEqualTo(account.fullName);
-    assertThat(info.email).isEqualTo(account.email);
-    assertThat(info.username).isEqualTo(account.username);
-    assertThat(info.status).isEqualTo(account.status);
-  }
-
-  private Set<String> getEmails() throws RestApiException {
-    return gApi.accounts().self().getEmails().stream().map(e -> e.email).collect(toSet());
-  }
-
-  private void assertEmail(Set<Account.Id> accounts, TestAccount expectedAccount) {
-    assertThat(accounts).hasSize(1);
-    assertThat(Iterables.getOnlyElement(accounts)).isEqualTo(expectedAccount.getId());
-  }
-
-  private Config getAccountConfig(TestRepository<?> allUsersRepo) throws Exception {
-    Config ac = new Config();
-    try (TreeWalk tw =
-        TreeWalk.forPath(
-            allUsersRepo.getRepository(),
-            AccountConfig.ACCOUNT_CONFIG,
-            getHead(allUsersRepo.getRepository()).getTree())) {
-      assertThat(tw).isNotNull();
-      ac.fromText(
-          new String(
-              allUsersRepo
-                  .getRevWalk()
-                  .getObjectReader()
-                  .open(tw.getObjectId(0), OBJ_BLOB)
-                  .getBytes(),
-              UTF_8));
-    }
-    return ac;
-  }
-
-  private static class AccountIndexedCounter implements AccountIndexedListener {
-    private final AtomicLongMap<Integer> countsByAccount = AtomicLongMap.create();
-
-    @Override
-    public void onAccountIndexed(int id) {
-      countsByAccount.incrementAndGet(id);
-    }
-
-    void clear() {
-      countsByAccount.clear();
-    }
-
-    long getCount(Account.Id accountId) {
-      return countsByAccount.get(accountId.get());
-    }
-
-    void assertReindexOf(TestAccount testAccount) {
-      assertReindexOf(testAccount, 1);
-    }
-
-    void assertReindexOf(AccountInfo accountInfo) {
-      assertReindexOf(new Account.Id(accountInfo._accountId), 1);
-    }
-
-    void assertReindexOf(TestAccount testAccount, int expectedCount) {
-      assertThat(getCount(testAccount.id)).isEqualTo(expectedCount);
-      assertThat(countsByAccount).hasSize(1);
-      clear();
-    }
-
-    void assertReindexOf(Account.Id accountId, int expectedCount) {
-      assertThat(getCount(accountId)).isEqualTo(expectedCount);
-      countsByAccount.remove(accountId.get());
-    }
-
-    void assertNoReindex() {
-      assertThat(countsByAccount).isEmpty();
-    }
-  }
-
-  private static class RefUpdateCounter implements GitReferenceUpdatedListener {
-    private final AtomicLongMap<String> countsByProjectRefs = AtomicLongMap.create();
-
-    static String projectRef(Project.NameKey project, String ref) {
-      return projectRef(project.get(), ref);
-    }
-
-    static String projectRef(String project, String ref) {
-      return project + ":" + ref;
-    }
-
-    @Override
-    public void onGitReferenceUpdated(Event event) {
-      countsByProjectRefs.incrementAndGet(projectRef(event.getProjectName(), event.getRefName()));
-    }
-
-    void clear() {
-      countsByProjectRefs.clear();
-    }
-
-    long getCount(String projectRef) {
-      return countsByProjectRefs.get(projectRef);
-    }
-
-    void assertRefUpdateFor(String... projectRefs) {
-      Map<String, Integer> expectedRefUpdateCounts = new HashMap<>();
-      for (String projectRef : projectRefs) {
-        expectedRefUpdateCounts.put(projectRef, 1);
-      }
-      assertRefUpdateFor(expectedRefUpdateCounts);
-    }
-
-    void assertRefUpdateFor(Map<String, Integer> expectedProjectRefUpdateCounts) {
-      for (Map.Entry<String, Integer> e : expectedProjectRefUpdateCounts.entrySet()) {
-        assertThat(getCount(e.getKey())).isEqualTo(e.getValue());
-      }
-      assertThat(countsByProjectRefs).hasSize(expectedProjectRefUpdateCounts.size());
-      clear();
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
deleted file mode 100644
index 0e9d2ab..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ /dev/null
@@ -1,245 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.accounts;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static org.junit.Assert.fail;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.api.projects.BranchInfo;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.common.AgreementInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.extensions.common.ServerInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.TestTimeUtil;
-import java.util.List;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
-import org.junit.Before;
-import org.junit.BeforeClass;
-import org.junit.Test;
-
-public class AgreementsIT extends AbstractDaemonTest {
-  private ContributorAgreement caAutoVerify;
-  private ContributorAgreement caNoAutoVerify;
-
-  @ConfigSuite.Config
-  public static Config enableAgreementsConfig() {
-    Config cfg = new Config();
-    cfg.setBoolean("auth", null, "contributorAgreements", true);
-    return cfg;
-  }
-
-  @BeforeClass
-  public static void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @AfterClass
-  public static void restoreTime() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @Before
-  public void setUp() throws Exception {
-    caAutoVerify = configureContributorAgreement(true);
-    caNoAutoVerify = configureContributorAgreement(false);
-    setApiUser(user);
-  }
-
-  @Test
-  public void getAvailableAgreements() throws Exception {
-    ServerInfo info = gApi.config().server().getInfo();
-    if (isContributorAgreementsEnabled()) {
-      assertThat(info.auth.useContributorAgreements).isTrue();
-      assertThat(info.auth.contributorAgreements).hasSize(2);
-      assertAgreement(info.auth.contributorAgreements.get(0), caAutoVerify);
-      assertAgreement(info.auth.contributorAgreements.get(1), caNoAutoVerify);
-    } else {
-      assertThat(info.auth.useContributorAgreements).isNull();
-      assertThat(info.auth.contributorAgreements).isNull();
-    }
-  }
-
-  @Test
-  public void signNonExistingAgreement() throws Exception {
-    assume().that(isContributorAgreementsEnabled()).isTrue();
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("contributor agreement not found");
-    gApi.accounts().self().signAgreement("does-not-exist");
-  }
-
-  @Test
-  public void signAgreementNoAutoVerify() throws Exception {
-    assume().that(isContributorAgreementsEnabled()).isTrue();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot enter a non-autoVerify agreement");
-    gApi.accounts().self().signAgreement(caNoAutoVerify.getName());
-  }
-
-  @Test
-  public void signAgreement() throws Exception {
-    assume().that(isContributorAgreementsEnabled()).isTrue();
-
-    // List of agreements is initially empty
-    List<AgreementInfo> result = gApi.accounts().self().listAgreements();
-    assertThat(result).isEmpty();
-
-    // Sign the agreement
-    gApi.accounts().self().signAgreement(caAutoVerify.getName());
-
-    // Explicitly reset the user to force a new request context
-    setApiUser(user);
-
-    // Verify that the agreement was signed
-    result = gApi.accounts().self().listAgreements();
-    assertThat(result).hasSize(1);
-    AgreementInfo info = result.get(0);
-    assertAgreement(info, caAutoVerify);
-
-    // Signing the same agreement again has no effect
-    gApi.accounts().self().signAgreement(caAutoVerify.getName());
-    result = gApi.accounts().self().listAgreements();
-    assertThat(result).hasSize(1);
-  }
-
-  @Test
-  public void agreementsDisabledSign() throws Exception {
-    assume().that(isContributorAgreementsEnabled()).isFalse();
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("contributor agreements disabled");
-    gApi.accounts().self().signAgreement(caAutoVerify.getName());
-  }
-
-  @Test
-  public void agreementsDisabledList() throws Exception {
-    assume().that(isContributorAgreementsEnabled()).isFalse();
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("contributor agreements disabled");
-    gApi.accounts().self().listAgreements();
-  }
-
-  @Test
-  public void revertChangeWithoutCLA() throws Exception {
-    assume().that(isContributorAgreementsEnabled()).isTrue();
-
-    // Create a change succeeds when agreement is not required
-    setUseContributorAgreements(InheritableBoolean.FALSE);
-    ChangeInfo change = gApi.changes().create(newChangeInput()).get();
-
-    // Approve and submit it
-    setApiUser(admin);
-    gApi.changes().id(change.changeId).current().review(ReviewInput.approve());
-    gApi.changes().id(change.changeId).current().submit(new SubmitInput());
-
-    // Revert is not allowed when CLA is required but not signed
-    setApiUser(user);
-    setUseContributorAgreements(InheritableBoolean.TRUE);
-    exception.expect(AuthException.class);
-    exception.expectMessage("A Contributor Agreement must be completed");
-    gApi.changes().id(change.changeId).revert();
-  }
-
-  @Test
-  public void cherrypickChangeWithoutCLA() throws Exception {
-    assume().that(isContributorAgreementsEnabled()).isTrue();
-
-    // Create a new branch
-    setApiUser(admin);
-    BranchInfo dest =
-        gApi.projects()
-            .name(project.get())
-            .branch("cherry-pick-to")
-            .create(new BranchInput())
-            .get();
-
-    // Create a change succeeds when agreement is not required
-    setUseContributorAgreements(InheritableBoolean.FALSE);
-    ChangeInfo change = gApi.changes().create(newChangeInput()).get();
-
-    // Approve and submit it
-    gApi.changes().id(change.changeId).current().review(ReviewInput.approve());
-    gApi.changes().id(change.changeId).current().submit(new SubmitInput());
-
-    // Cherry-pick is not allowed when CLA is required but not signed
-    setApiUser(user);
-    setUseContributorAgreements(InheritableBoolean.TRUE);
-    CherryPickInput in = new CherryPickInput();
-    in.destination = dest.ref;
-    in.message = change.subject;
-    exception.expect(AuthException.class);
-    exception.expectMessage("A Contributor Agreement must be completed");
-    gApi.changes().id(change.changeId).current().cherryPick(in);
-  }
-
-  @Test
-  public void createChangeRespectsCLA() throws Exception {
-    assume().that(isContributorAgreementsEnabled()).isTrue();
-
-    // Create a change succeeds when agreement is not required
-    setUseContributorAgreements(InheritableBoolean.FALSE);
-    gApi.changes().create(newChangeInput());
-
-    // Create a change is not allowed when CLA is required but not signed
-    setUseContributorAgreements(InheritableBoolean.TRUE);
-    try {
-      gApi.changes().create(newChangeInput());
-      fail("Expected AuthException");
-    } catch (AuthException e) {
-      assertThat(e.getMessage()).contains("A Contributor Agreement must be completed");
-    }
-
-    // Sign the agreement
-    gApi.accounts().self().signAgreement(caAutoVerify.getName());
-
-    // Explicitly reset the user to force a new request context
-    setApiUser(user);
-
-    // Create a change succeeds after signing the agreement
-    gApi.changes().create(newChangeInput());
-  }
-
-  private void assertAgreement(AgreementInfo info, ContributorAgreement ca) {
-    assertThat(info.name).isEqualTo(ca.getName());
-    assertThat(info.description).isEqualTo(ca.getDescription());
-    assertThat(info.url).isEqualTo(ca.getAgreementUrl());
-    if (ca.getAutoVerify() != null) {
-      assertThat(info.autoVerifyGroup.name).isEqualTo(ca.getAutoVerify().getName());
-    } else {
-      assertThat(info.autoVerifyGroup).isNull();
-    }
-  }
-
-  private ChangeInput newChangeInput() {
-    ChangeInput in = new ChangeInput();
-    in.branch = "master";
-    in.subject = "test";
-    in.project = project.get();
-    return in;
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUILD
deleted file mode 100644
index 3d62cfc..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUILD
+++ /dev/null
@@ -1,10 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "api_account",
-    labels = [
-        "api",
-        "noci",
-    ],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
deleted file mode 100644
index fcf939d..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
+++ /dev/null
@@ -1,176 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.accounts;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
-import static com.google.gerrit.acceptance.GitUtil.fetch;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.Sandboxed;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.extensions.client.Theme;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.account.VersionedAccountPreferences;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.inject.Inject;
-import org.eclipse.jgit.api.errors.TransportException;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.junit.After;
-import org.junit.Test;
-
-@NoHttpd
-@Sandboxed
-public class DiffPreferencesIT extends AbstractDaemonTest {
-  @Inject private AllUsersName allUsers;
-
-  @After
-  public void cleanUp() throws Exception {
-    gApi.accounts().id(admin.getId().toString()).setDiffPreferences(DiffPreferencesInfo.defaults());
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    try {
-      fetch(allUsersRepo, RefNames.REFS_USERS_DEFAULT + ":defaults");
-    } catch (TransportException e) {
-      if (e.getMessage()
-          .equals(
-              "Remote does not have " + RefNames.REFS_USERS_DEFAULT + " available for fetch.")) {
-        return;
-      }
-      throw e;
-    }
-    allUsersRepo.reset("defaults");
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            allUsersRepo,
-            "Delete default preferences",
-            VersionedAccountPreferences.PREFERENCES,
-            "");
-    push.rm(RefNames.REFS_USERS_DEFAULT).assertOkStatus();
-  }
-
-  @Test
-  public void getDiffPreferences() throws Exception {
-    DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
-    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
-    assertPrefs(o, d);
-  }
-
-  @Test
-  public void setDiffPreferences() throws Exception {
-    DiffPreferencesInfo i = DiffPreferencesInfo.defaults();
-
-    // change all default values
-    i.context *= -1;
-    i.tabSize *= -1;
-    i.fontSize *= -1;
-    i.lineLength *= -1;
-    i.cursorBlinkRate = 500;
-    i.theme = Theme.MIDNIGHT;
-    i.ignoreWhitespace = Whitespace.IGNORE_ALL;
-    i.expandAllComments ^= true;
-    i.intralineDifference ^= true;
-    i.manualReview ^= true;
-    i.retainHeader ^= true;
-    i.showLineEndings ^= true;
-    i.showTabs ^= true;
-    i.showWhitespaceErrors ^= true;
-    i.skipDeleted ^= true;
-    i.skipUnchanged ^= true;
-    i.skipUncommented ^= true;
-    i.syntaxHighlighting ^= true;
-    i.hideTopMenu ^= true;
-    i.autoHideDiffTableHeader ^= true;
-    i.hideLineNumbers ^= true;
-    i.renderEntireFile ^= true;
-    i.hideEmptyPane ^= true;
-    i.matchBrackets ^= true;
-    i.lineWrapping ^= true;
-
-    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
-    assertPrefs(o, i);
-
-    // Partially fill input record
-    i = new DiffPreferencesInfo();
-    i.tabSize = 42;
-    DiffPreferencesInfo a = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
-    assertPrefs(a, o, "tabSize");
-    assertThat(a.tabSize).isEqualTo(42);
-  }
-
-  @Test
-  public void getDiffPreferencesWithConfiguredDefaults() throws Exception {
-    DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
-    int newLineLength = d.lineLength + 10;
-    int newTabSize = d.tabSize * 2;
-    int newFontSize = d.fontSize - 2;
-    DiffPreferencesInfo update = new DiffPreferencesInfo();
-    update.lineLength = newLineLength;
-    update.tabSize = newTabSize;
-    update.fontSize = newFontSize;
-    gApi.config().server().setDefaultDiffPreferences(update);
-
-    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
-
-    // assert configured defaults
-    assertThat(o.lineLength).isEqualTo(newLineLength);
-    assertThat(o.tabSize).isEqualTo(newTabSize);
-    assertThat(o.fontSize).isEqualTo(newFontSize);
-
-    // assert hard-coded defaults
-    assertPrefs(o, d, "lineLength", "tabSize", "fontSize");
-  }
-
-  @Test
-  public void overwriteConfiguredDefaults() throws Exception {
-    DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
-    int configuredDefaultLineLength = d.lineLength + 10;
-    DiffPreferencesInfo update = new DiffPreferencesInfo();
-    update.lineLength = configuredDefaultLineLength;
-    gApi.config().server().setDefaultDiffPreferences(update);
-
-    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
-    assertThat(o.lineLength).isEqualTo(configuredDefaultLineLength);
-    assertPrefs(o, d, "lineLength");
-
-    int newLineLength = configuredDefaultLineLength + 10;
-    DiffPreferencesInfo i = new DiffPreferencesInfo();
-    i.lineLength = newLineLength;
-    DiffPreferencesInfo a = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
-    assertThat(a.lineLength).isEqualTo(newLineLength);
-    assertPrefs(a, d, "lineLength");
-
-    a = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
-    assertThat(a.lineLength).isEqualTo(newLineLength);
-    assertPrefs(a, d, "lineLength");
-
-    // overwrite the configured default with original hard-coded default
-    i = new DiffPreferencesInfo();
-    i.lineLength = d.lineLength;
-    a = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
-    assertThat(a.lineLength).isEqualTo(d.lineLength);
-    assertPrefs(a, d, "lineLength");
-
-    a = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
-    assertThat(a.lineLength).isEqualTo(d.lineLength);
-    assertPrefs(a, d, "lineLength");
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
deleted file mode 100644
index 8bf46d6..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ /dev/null
@@ -1,204 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.accounts;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.Sandboxed;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DateFormat;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DefaultBase;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
-import com.google.gerrit.extensions.client.MenuItem;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.HashMap;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-@Sandboxed
-public class GeneralPreferencesIT extends AbstractDaemonTest {
-  @Inject private AllUsersName allUsers;
-
-  private TestAccount user42;
-
-  @Before
-  public void setUp() throws Exception {
-    String name = name("user42");
-    user42 = accountCreator.create(name, name + "@example.com", "User 42");
-  }
-
-  @After
-  public void cleanUp() throws Exception {
-    gApi.accounts().id(user42.getId().toString()).setPreferences(GeneralPreferencesInfo.defaults());
-
-    try (Repository git = repoManager.openRepository(allUsers)) {
-      if (git.exactRef(RefNames.REFS_USERS_DEFAULT) != null) {
-        RefUpdate u = git.updateRef(RefNames.REFS_USERS_DEFAULT);
-        u.setForceUpdate(true);
-        assertThat(u.delete()).isEqualTo(RefUpdate.Result.FORCED);
-      }
-    }
-    accountCache.evictAllNoReindex();
-  }
-
-  @Test
-  public void getAndSetPreferences() throws Exception {
-    GeneralPreferencesInfo o = gApi.accounts().id(user42.id.toString()).getPreferences();
-    assertPrefs(o, GeneralPreferencesInfo.defaults(), "my", "changeTable");
-    assertThat(o.my)
-        .containsExactly(
-            new MenuItem("Changes", "#/dashboard/self", null),
-            new MenuItem("Draft Comments", "#/q/has:draft", null),
-            new MenuItem("Edits", "#/q/has:edit", null),
-            new MenuItem("Watched Changes", "#/q/is:watched+is:open", null),
-            new MenuItem("Starred Changes", "#/q/is:starred", null),
-            new MenuItem("Groups", "#/groups/self", null));
-    assertThat(o.changeTable).isEmpty();
-
-    GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
-
-    // change all default values
-    i.changesPerPage *= -1;
-    i.showSiteHeader ^= true;
-    i.useFlashClipboard ^= true;
-    i.downloadCommand = DownloadCommand.REPO_DOWNLOAD;
-    i.dateFormat = DateFormat.US;
-    i.timeFormat = TimeFormat.HHMM_24;
-    i.emailStrategy = EmailStrategy.DISABLED;
-    i.emailFormat = EmailFormat.PLAINTEXT;
-    i.defaultBaseForMerges = DefaultBase.AUTO_MERGE;
-    i.expandInlineDiffs ^= true;
-    i.highlightAssigneeInChangeTable ^= true;
-    i.relativeDateInChangeTable ^= true;
-    i.sizeBarInChangeTable ^= true;
-    i.legacycidInChangeTable ^= true;
-    i.muteCommonPathPrefixes ^= true;
-    i.signedOffBy ^= true;
-    i.reviewCategoryStrategy = ReviewCategoryStrategy.ABBREV;
-    i.diffView = DiffView.UNIFIED_DIFF;
-    i.my = new ArrayList<>();
-    i.my.add(new MenuItem("name", "url"));
-    i.changeTable = new ArrayList<>();
-    i.changeTable.add("Status");
-    i.urlAliases = new HashMap<>();
-    i.urlAliases.put("foo", "bar");
-
-    o = gApi.accounts().id(user42.getId().toString()).setPreferences(i);
-    assertPrefs(o, i, "my");
-    assertThat(o.my).containsExactlyElementsIn(i.my);
-    assertThat(o.changeTable).containsExactlyElementsIn(i.changeTable);
-  }
-
-  @Test
-  public void getPreferencesWithConfiguredDefaults() throws Exception {
-    GeneralPreferencesInfo d = GeneralPreferencesInfo.defaults();
-    int newChangesPerPage = d.changesPerPage * 2;
-    GeneralPreferencesInfo update = new GeneralPreferencesInfo();
-    update.changesPerPage = newChangesPerPage;
-    gApi.config().server().setDefaultPreferences(update);
-
-    GeneralPreferencesInfo o = gApi.accounts().id(user42.getId().toString()).getPreferences();
-
-    // assert configured defaults
-    assertThat(o.changesPerPage).isEqualTo(newChangesPerPage);
-
-    // assert hard-coded defaults
-    assertPrefs(o, d, "my", "changeTable", "changesPerPage");
-  }
-
-  @Test
-  public void overwriteConfiguredDefaults() throws Exception {
-    GeneralPreferencesInfo d = GeneralPreferencesInfo.defaults();
-    int configuredChangesPerPage = d.changesPerPage * 2;
-    GeneralPreferencesInfo update = new GeneralPreferencesInfo();
-    update.changesPerPage = configuredChangesPerPage;
-    gApi.config().server().setDefaultPreferences(update);
-
-    GeneralPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getPreferences();
-    assertThat(o.changesPerPage).isEqualTo(configuredChangesPerPage);
-    assertPrefs(o, d, "my", "changeTable", "changesPerPage");
-
-    int newChangesPerPage = configuredChangesPerPage * 2;
-    GeneralPreferencesInfo i = new GeneralPreferencesInfo();
-    i.changesPerPage = newChangesPerPage;
-    GeneralPreferencesInfo a = gApi.accounts().id(admin.getId().toString()).setPreferences(i);
-    assertThat(a.changesPerPage).isEqualTo(newChangesPerPage);
-    assertPrefs(a, d, "my", "changeTable", "changesPerPage");
-
-    a = gApi.accounts().id(admin.getId().toString()).getPreferences();
-    assertThat(a.changesPerPage).isEqualTo(newChangesPerPage);
-    assertPrefs(a, d, "my", "changeTable", "changesPerPage");
-
-    // overwrite the configured default with original hard-coded default
-    i = new GeneralPreferencesInfo();
-    i.changesPerPage = d.changesPerPage;
-    a = gApi.accounts().id(admin.getId().toString()).setPreferences(i);
-    assertThat(a.changesPerPage).isEqualTo(d.changesPerPage);
-    assertPrefs(a, d, "my", "changeTable", "changesPerPage");
-
-    a = gApi.accounts().id(admin.getId().toString()).getPreferences();
-    assertThat(a.changesPerPage).isEqualTo(d.changesPerPage);
-    assertPrefs(a, d, "my", "changeTable", "changesPerPage");
-  }
-
-  @Test
-  public void rejectMyMenuWithoutName() throws Exception {
-    GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
-    i.my = new ArrayList<>();
-    i.my.add(new MenuItem(null, "url"));
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("name for menu item is required");
-    gApi.accounts().id(user42.getId().toString()).setPreferences(i);
-  }
-
-  @Test
-  public void rejectMyMenuWithoutUrl() throws Exception {
-    GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
-    i.my = new ArrayList<>();
-    i.my.add(new MenuItem("name", null));
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("URL for menu item is required");
-    gApi.accounts().id(user42.getId().toString()).setPreferences(i);
-  }
-
-  @Test
-  public void trimMyMenuInput() throws Exception {
-    GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
-    i.my = new ArrayList<>();
-    i.my.add(new MenuItem(" name\t", " url\t", " _blank\t", " id\t"));
-
-    GeneralPreferencesInfo o = gApi.accounts().id(user42.getId().toString()).setPreferences(i);
-    assertThat(o.my).containsExactly(new MenuItem("name", "url", "_blank", "id"));
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/AbandonIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/AbandonIT.java
deleted file mode 100644
index 2c1a5b3..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ /dev/null
@@ -1,183 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.util.concurrent.TimeUnit.HOURS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.change.AbandonUtil;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.inject.Inject;
-import java.util.List;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.junit.Test;
-
-public class AbandonIT extends AbstractDaemonTest {
-  @Inject private AbandonUtil abandonUtil;
-
-  @Test
-  public void abandon() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    ChangeInfo info = get(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is abandoned");
-    gApi.changes().id(changeId).abandon();
-  }
-
-  @Test
-  public void batchAbandon() throws Exception {
-    CurrentUser user = atrScope.get().getUser();
-    PushOneCommit.Result a = createChange();
-    PushOneCommit.Result b = createChange();
-    List<ChangeData> list = ImmutableList.of(a.getChange(), b.getChange());
-    changeAbandoner.batchAbandon(
-        batchUpdateFactory, a.getChange().project(), user, list, "deadbeef");
-
-    ChangeInfo info = get(a.getChangeId());
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
-
-    info = get(b.getChangeId());
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
-  }
-
-  @Test
-  public void batchAbandonChangeProject() throws Exception {
-    String project1Name = name("Project1");
-    String project2Name = name("Project2");
-    gApi.projects().create(project1Name);
-    gApi.projects().create(project2Name);
-    TestRepository<InMemoryRepository> project1 = cloneProject(new Project.NameKey(project1Name));
-    TestRepository<InMemoryRepository> project2 = cloneProject(new Project.NameKey(project2Name));
-
-    CurrentUser user = atrScope.get().getUser();
-    PushOneCommit.Result a = createChange(project1, "master", "x", "x", "x", "");
-    PushOneCommit.Result b = createChange(project2, "master", "x", "x", "x", "");
-    List<ChangeData> list = ImmutableList.of(a.getChange(), b.getChange());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format("Project name \"%s\" doesn't match \"%s\"", project2Name, project1Name));
-    changeAbandoner.batchAbandon(batchUpdateFactory, new Project.NameKey(project1Name), user, list);
-  }
-
-  @Test
-  @GerritConfig(name = "changeCleanup.abandonAfter", value = "1w")
-  public void abandonInactiveOpenChanges() throws Exception {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-
-    // create 2 changes which will be abandoned ...
-    int id1 = createChange().getChange().getId().get();
-    int id2 = createChange().getChange().getId().get();
-
-    // ... because they are older than 1 week
-    TestTimeUtil.incrementClock(7 * 24, HOURS);
-
-    // create 1 new change that will not be abandoned
-    ChangeData cd = createChange().getChange();
-    int id3 = cd.getId().get();
-
-    assertThat(toChangeNumbers(query("is:open"))).containsExactly(id1, id2, id3);
-    assertThat(query("is:abandoned")).isEmpty();
-
-    abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
-    assertThat(toChangeNumbers(query("is:open"))).containsExactly(id3);
-    assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id1, id2);
-  }
-
-  @Test
-  public void abandonNotAllowedWithoutPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("abandon not permitted");
-    gApi.changes().id(changeId).abandon();
-  }
-
-  @Test
-  public void abandonAndRestoreAllowedWithPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    grant(project, "refs/heads/master", Permission.ABANDON, false, REGISTERED_USERS);
-    setApiUser(user);
-    gApi.changes().id(changeId).abandon();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
-    gApi.changes().id(changeId).restore();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-  }
-
-  @Test
-  public void restore() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
-
-    gApi.changes().id(changeId).restore();
-    ChangeInfo info = get(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("restored");
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is new");
-    gApi.changes().id(changeId).restore();
-  }
-
-  @Test
-  public void restoreNotAllowedWithoutPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    setApiUser(user);
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
-    exception.expect(AuthException.class);
-    exception.expectMessage("restore not permitted");
-    gApi.changes().id(changeId).restore();
-  }
-
-  private List<Integer> toChangeNumbers(List<ChangeInfo> changes) {
-    return changes.stream().map(i -> i._number).collect(toList());
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUILD
deleted file mode 100644
index 3c4e219..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUILD
+++ /dev/null
@@ -1,10 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "api_change",
-    labels = [
-        "api",
-        "noci",
-    ],
-)
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
deleted file mode 100644
index 780c2d7..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ /dev/null
@@ -1,3555 +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.api.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
-import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
-import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
-import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
-import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
-import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
-import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
-import static com.google.gerrit.extensions.client.ListChangesOption.COMMIT_FOOTERS;
-import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
-import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
-import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
-import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
-import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
-import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
-import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
-import static com.google.gerrit.extensions.client.ReviewerState.CC;
-import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
-import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
-import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.Util.category;
-import static com.google.gerrit.server.project.Util.value;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.util.concurrent.AtomicLongMap;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
-import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
-import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.NotifyInfo;
-import com.google.gerrit.extensions.api.changes.RebaseInput;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
-import com.google.gerrit.extensions.api.changes.ReviewResult;
-import com.google.gerrit.extensions.api.changes.RevisionApi;
-import com.google.gerrit.extensions.api.changes.StarsInput;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.api.projects.ConfigInput;
-import com.google.gerrit.extensions.client.ChangeKind;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.Comment.Range;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.GitPerson;
-import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.MergeInput;
-import com.google.gerrit.extensions.common.MergePatchSetInput;
-import com.google.gerrit.extensions.common.PureRevertInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.common.TrackingIdInfo;
-import com.google.gerrit.extensions.events.ChangeIndexedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-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.server.ChangeMessagesUtil;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.PostReview;
-import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import com.google.gerrit.server.git.ChangeMessageModifier;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.project.Util;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.testutil.FakeEmailSender.Message;
-import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.function.Function;
-import java.util.stream.Stream;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.PushResult;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class ChangeIT extends AbstractDaemonTest {
-  private String systemTimeZone;
-
-  @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
-
-  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
-
-  private ChangeIndexedCounter changeIndexedCounter;
-  private RegistrationHandle changeIndexedCounterHandle;
-
-  @Before
-  public void setTimeForTesting() {
-    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-  }
-
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
-  }
-
-  @Before
-  public void addChangeIndexedCounter() {
-    changeIndexedCounter = new ChangeIndexedCounter();
-    changeIndexedCounterHandle = changeIndexedListeners.add(changeIndexedCounter);
-  }
-
-  @After
-  public void removeChangeIndexedCounter() {
-    if (changeIndexedCounterHandle != null) {
-      changeIndexedCounterHandle.remove();
-    }
-  }
-
-  @Test
-  public void reflog() throws Exception {
-    // Tests are using DfsRepository which does not implement getReflogReader,
-    // so this will always fail.
-    // TODO: change this if/when DfsRepository#getReflogReader is implemented.
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("reflog not supported");
-    gApi.projects().name(project.get()).branch("master").reflog();
-  }
-
-  @Test
-  public void get() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    ChangeInfo c = info(triplet);
-    assertThat(c.id).isEqualTo(triplet);
-    assertThat(c.project).isEqualTo(project.get());
-    assertThat(c.branch).isEqualTo("master");
-    assertThat(c.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(c.subject).isEqualTo("test commit");
-    assertThat(c.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
-    assertThat(c.mergeable).isTrue();
-    assertThat(c.changeId).isEqualTo(r.getChangeId());
-    assertThat(c.created).isEqualTo(c.updated);
-    assertThat(c._number).isEqualTo(r.getChange().getId().get());
-
-    assertThat(c.owner._accountId).isEqualTo(admin.getId().get());
-    assertThat(c.owner.name).isNull();
-    assertThat(c.owner.email).isNull();
-    assertThat(c.owner.username).isNull();
-    assertThat(c.owner.avatars).isNull();
-  }
-
-  @Test
-  public void setPrivateByOwner() throws Exception {
-    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
-    PushOneCommit.Result result =
-        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
-
-    setApiUser(user);
-    String changeId = result.getChangeId();
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
-
-    gApi.changes().id(changeId).setPrivate(true, null);
-    ChangeInfo info = gApi.changes().id(changeId).get();
-    assertThat(info.isPrivate).isTrue();
-    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set private");
-    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_PRIVATE);
-
-    gApi.changes().id(changeId).setPrivate(false, null);
-    info = gApi.changes().id(changeId).get();
-    assertThat(info.isPrivate).isNull();
-    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Unset private");
-    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_UNSET_PRIVATE);
-
-    String msg = "This is a security fix that must not be public.";
-    gApi.changes().id(changeId).setPrivate(true, msg);
-    info = gApi.changes().id(changeId).get();
-    assertThat(info.isPrivate).isTrue();
-    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set private\n\n" + msg);
-    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_PRIVATE);
-
-    msg = "After this security fix has been released we can make it public now.";
-    gApi.changes().id(changeId).setPrivate(false, msg);
-    info = gApi.changes().id(changeId).get();
-    assertThat(info.isPrivate).isNull();
-    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Unset private\n\n" + msg);
-    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_UNSET_PRIVATE);
-  }
-
-  @Test
-  public void administratorCanSetUserChangePrivate() throws Exception {
-    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
-    PushOneCommit.Result result =
-        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
-
-    String changeId = result.getChangeId();
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
-
-    gApi.changes().id(changeId).setPrivate(true, null);
-    setApiUser(user);
-    ChangeInfo info = gApi.changes().id(changeId).get();
-    assertThat(info.isPrivate).isTrue();
-  }
-
-  @Test
-  public void cannotSetOtherUsersChangePrivate() throws Exception {
-    PushOneCommit.Result result = createChange();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to mark private");
-    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
-  }
-
-  @Test
-  public void accessPrivate() throws Exception {
-    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
-    PushOneCommit.Result result =
-        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
-
-    setApiUser(user);
-    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
-    // Owner can always access its private changes.
-    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
-
-    // Add admin as a reviewer.
-    gApi.changes().id(result.getChangeId()).addReviewer(admin.getId().toString());
-
-    // This change should be visible for admin as a reviewer.
-    setApiUser(admin);
-    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
-
-    // Remove admin from reviewers.
-    gApi.changes().id(result.getChangeId()).reviewer(admin.getId().toString()).remove();
-
-    // This change should not be visible for admin anymore.
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + result.getChangeId());
-    gApi.changes().id(result.getChangeId());
-  }
-
-  @Test
-  public void privateChangeOfOtherUserCanBeAccessedWithPermission() throws Exception {
-    PushOneCommit.Result result = createChange();
-    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
-
-    allow("refs/*", Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS);
-    setApiUser(user);
-    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
-  }
-
-  @Test
-  public void administratorCanUnmarkPrivateAfterMerging() throws Exception {
-    PushOneCommit.Result result = createChange();
-    String changeId = result.getChangeId();
-    gApi.changes().id(changeId).setPrivate(true, null);
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
-    merge(result);
-    gApi.changes().id(changeId).setPrivate(false, null);
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
-  }
-
-  @Test
-  public void administratorCanMarkPrivateAfterMerging() throws Exception {
-    PushOneCommit.Result result = createChange();
-    String changeId = result.getChangeId();
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
-    merge(result);
-    gApi.changes().id(changeId).setPrivate(true, null);
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
-  }
-
-  @Test
-  public void ownerCannotMarkPrivateAfterMerging() throws Exception {
-    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
-    PushOneCommit.Result result =
-        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
-
-    String changeId = result.getChangeId();
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
-
-    merge(result);
-
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to mark private");
-    gApi.changes().id(changeId).setPrivate(true, null);
-  }
-
-  @Test
-  public void ownerCanUnmarkPrivateAfterMerging() throws Exception {
-    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
-    PushOneCommit.Result result =
-        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
-
-    String changeId = result.getChangeId();
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
-    gApi.changes().id(changeId).addReviewer(admin.getId().toString());
-    gApi.changes().id(changeId).setPrivate(true, null);
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
-
-    merge(result);
-
-    setApiUser(user);
-    gApi.changes().id(changeId).setPrivate(false, null);
-    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
-  }
-
-  @Test
-  public void setWorkInProgressNotAllowedWithoutPermission() throws Exception {
-    PushOneCommit.Result rwip = createChange();
-    String changeId = rwip.getChangeId();
-
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to set work in progress");
-    gApi.changes().id(changeId).setWorkInProgress();
-  }
-
-  @Test
-  public void setReadyForReviewNotAllowedWithoutPermission() throws Exception {
-    PushOneCommit.Result rready = createChange();
-    String changeId = rready.getChangeId();
-    gApi.changes().id(changeId).setWorkInProgress();
-
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to set ready for review");
-    gApi.changes().id(changeId).setReadyForReview();
-  }
-
-  @Test
-  public void hasReviewStarted() throws Exception {
-    PushOneCommit.Result r = createWorkInProgressChange();
-    String changeId = r.getChangeId();
-    ChangeInfo info = gApi.changes().id(changeId).get();
-    assertThat(info.hasReviewStarted).isFalse();
-
-    gApi.changes().id(changeId).setReadyForReview();
-    info = gApi.changes().id(changeId).get();
-    assertThat(info.hasReviewStarted).isTrue();
-  }
-
-  @Test
-  public void pendingReviewersInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    ConfigInput conf = new ConfigInput();
-    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
-    gApi.projects().name(project.get()).config(conf);
-
-    PushOneCommit.Result r = createWorkInProgressChange();
-    String changeId = r.getChangeId();
-    assertThat(gApi.changes().id(changeId).get().pendingReviewers).isEmpty();
-
-    // Add some pending reviewers.
-    TestAccount user1 =
-        accountCreator.create(name("user1"), name("user1") + "@example.com", "User 1");
-    TestAccount user2 =
-        accountCreator.create(name("user2"), name("user2") + "@example.com", "User 2");
-    TestAccount user3 =
-        accountCreator.create(name("user3"), name("user3") + "@example.com", "User 3");
-    TestAccount user4 =
-        accountCreator.create(name("user4"), name("user4") + "@example.com", "User 4");
-    ReviewInput in =
-        ReviewInput.noScore()
-            .reviewer(user1.email)
-            .reviewer(user2.email)
-            .reviewer(user3.email, CC, false)
-            .reviewer(user4.email, CC, false)
-            .reviewer("byemail1@example.com")
-            .reviewer("byemail2@example.com")
-            .reviewer("byemail3@example.com", CC, false)
-            .reviewer("byemail4@example.com", CC, false);
-    ReviewResult result = gApi.changes().id(changeId).revision("current").review(in);
-    assertThat(result.reviewers).isNotEmpty();
-    ChangeInfo info = gApi.changes().id(changeId).get();
-    Function<Collection<AccountInfo>, Collection<String>> toEmails =
-        ais -> ais.stream().map(ai -> ai.email).collect(toSet());
-    assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
-        .containsExactly(
-            admin.email, user1.email, user2.email, "byemail1@example.com", "byemail2@example.com");
-    assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
-        .containsExactly(user3.email, user4.email, "byemail3@example.com", "byemail4@example.com");
-    assertThat(info.pendingReviewers.get(REMOVED)).isNull();
-
-    // Stage some pending reviewer removals.
-    gApi.changes().id(changeId).reviewer(user1.email).remove();
-    gApi.changes().id(changeId).reviewer(user3.email).remove();
-    gApi.changes().id(changeId).reviewer("byemail1@example.com").remove();
-    gApi.changes().id(changeId).reviewer("byemail3@example.com").remove();
-    info = gApi.changes().id(changeId).get();
-    assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
-        .containsExactly(admin.email, user2.email, "byemail2@example.com");
-    assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
-        .containsExactly(user4.email, "byemail4@example.com");
-    assertThat(toEmails.apply(info.pendingReviewers.get(REMOVED)))
-        .containsExactly(user1.email, user3.email, "byemail1@example.com", "byemail3@example.com");
-
-    // "Undo" a removal.
-    in = ReviewInput.noScore().reviewer(user1.email);
-    gApi.changes().id(changeId).revision("current").review(in);
-    info = gApi.changes().id(changeId).get();
-    assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
-        .containsExactly(admin.email, user1.email, user2.email, "byemail2@example.com");
-    assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
-        .containsExactly(user4.email, "byemail4@example.com");
-    assertThat(toEmails.apply(info.pendingReviewers.get(REMOVED)))
-        .containsExactly(user3.email, "byemail1@example.com", "byemail3@example.com");
-
-    // "Commit" by moving out of WIP.
-    gApi.changes().id(changeId).setReadyForReview();
-    info = gApi.changes().id(changeId).get();
-    assertThat(info.pendingReviewers).isEmpty();
-    assertThat(toEmails.apply(info.reviewers.get(REVIEWER)))
-        .containsExactly(admin.email, user1.email, user2.email, "byemail2@example.com");
-    assertThat(toEmails.apply(info.reviewers.get(CC)))
-        .containsExactly(user4.email, "byemail4@example.com");
-    assertThat(info.reviewers.get(REMOVED)).isNull();
-  }
-
-  @Test
-  public void toggleWorkInProgressState() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    // With message
-    gApi.changes().id(changeId).setWorkInProgress("Needs some refactoring");
-
-    ChangeInfo info = gApi.changes().id(changeId).get();
-
-    assertThat(info.workInProgress).isTrue();
-    assertThat(Iterables.getLast(info.messages).message).contains("Needs some refactoring");
-    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_WIP);
-
-    gApi.changes().id(changeId).setReadyForReview("PTAL");
-
-    info = gApi.changes().id(changeId).get();
-    assertThat(info.workInProgress).isNull();
-    assertThat(Iterables.getLast(info.messages).message).contains("PTAL");
-    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_READY);
-
-    // No message
-    gApi.changes().id(changeId).setWorkInProgress();
-
-    info = gApi.changes().id(changeId).get();
-
-    assertThat(info.workInProgress).isTrue();
-    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set Work In Progress");
-    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_WIP);
-
-    gApi.changes().id(changeId).setReadyForReview();
-
-    info = gApi.changes().id(changeId).get();
-    assertThat(info.workInProgress).isNull();
-    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set Ready For Review");
-    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_READY);
-  }
-
-  @Test
-  public void reviewAndStartReview() throws Exception {
-    PushOneCommit.Result r = createWorkInProgressChange();
-    r.assertOkStatus();
-    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
-
-    ReviewInput in = ReviewInput.noScore().setWorkInProgress(false);
-    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
-    assertThat(result.ready).isTrue();
-
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
-    assertThat(info.workInProgress).isNull();
-  }
-
-  @Test
-  public void reviewAndMoveToWorkInProgress() throws Exception {
-    PushOneCommit.Result r = createChange();
-    r.assertOkStatus();
-    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
-
-    ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
-    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
-    assertThat(result.ready).isNull();
-
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
-    assertThat(info.workInProgress).isTrue();
-  }
-
-  @Test
-  public void reviewAndSetWorkInProgressAndAddReviewerAndVote() throws Exception {
-    PushOneCommit.Result r = createChange();
-    r.assertOkStatus();
-    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
-
-    ReviewInput in =
-        ReviewInput.approve().reviewer(user.email).label("Code-Review", 1).setWorkInProgress(true);
-    gApi.changes().id(r.getChangeId()).revision("current").review(in);
-
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
-    assertThat(info.workInProgress).isTrue();
-    assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
-        .containsExactly(admin.id.get(), user.id.get());
-    assertThat(info.labels.get("Code-Review").recommended._accountId).isEqualTo(admin.id.get());
-  }
-
-  @Test
-  public void reviewWithWorkInProgressAndReadyReturnsError() throws Exception {
-    PushOneCommit.Result r = createChange();
-    r.assertOkStatus();
-    ReviewInput in = ReviewInput.noScore();
-    in.ready = true;
-    in.workInProgress = true;
-    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
-    assertThat(result.error).isEqualTo(PostReview.ERROR_WIP_READY_MUTUALLY_EXCLUSIVE);
-  }
-
-  @Test
-  public void reviewWithWorkInProgressByNonOwnerReturnsError() throws Exception {
-    PushOneCommit.Result r = createChange();
-    r.assertOkStatus();
-    ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
-    setApiUser(user);
-    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
-    assertThat(result.error).isEqualTo(PostReview.ERROR_ONLY_OWNER_CAN_MODIFY_WORK_IN_PROGRESS);
-  }
-
-  @Test
-  public void getAmbiguous() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    String changeId = r1.getChangeId();
-    gApi.changes().id(changeId).get();
-
-    BranchInput b = new BranchInput();
-    b.revision = repo().exactRef("HEAD").getObjectId().name();
-    gApi.projects().name(project.get()).branch("other").create(b);
-
-    PushOneCommit push2 =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            PushOneCommit.FILE_CONTENT,
-            changeId);
-    PushOneCommit.Result r2 = push2.to("refs/for/other");
-    assertThat(r2.getChangeId()).isEqualTo(changeId);
-
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Multiple changes found for " + changeId);
-    gApi.changes().id(changeId).get();
-  }
-
-  @Test
-  public void revert() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-    ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert().get();
-
-    // expected messages on source change:
-    // 1. Uploaded patch set 1.
-    // 2. Patch Set 1: Code-Review+2
-    // 3. Change has been successfully merged by Administrator
-    // 4. Patch Set 1: Reverted
-    List<ChangeMessageInfo> sourceMessages =
-        new ArrayList<>(gApi.changes().id(r.getChangeId()).get().messages);
-    assertThat(sourceMessages).hasSize(4);
-    String expectedMessage =
-        String.format("Created a revert of this change as %s", revertChange.changeId);
-    assertThat(sourceMessages.get(3).message).isEqualTo(expectedMessage);
-
-    assertThat(revertChange.messages).hasSize(1);
-    assertThat(revertChange.messages.iterator().next().message).isEqualTo("Uploaded patch set 1.");
-    assertThat(revertChange.revertOf).isEqualTo(gApi.changes().id(r.getChangeId()).get()._number);
-  }
-
-  @Test
-  public void revertPreservesReviewersAndCcs() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    ReviewInput in = ReviewInput.approve();
-    in.reviewer(user.email);
-    in.reviewer(accountCreator.user2().email, ReviewerState.CC, true);
-    // Add user as reviewer that will create the revert
-    in.reviewer(accountCreator.admin2().email);
-
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-
-    // expect both the original reviewers and CCs to be preserved
-    // original owner should be added as reviewer, user requesting the revert (new owner) removed
-    setApiUser(accountCreator.admin2());
-    Map<ReviewerState, Collection<AccountInfo>> result =
-        gApi.changes().id(r.getChangeId()).revert().get().reviewers;
-    assertThat(result).containsKey(ReviewerState.REVIEWER);
-
-    List<Integer> reviewers =
-        result.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
-    if (notesMigration.readChanges()) {
-      assertThat(result).containsKey(ReviewerState.CC);
-      List<Integer> ccs =
-          result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
-      assertThat(ccs).containsExactly(accountCreator.user2().id.get());
-      assertThat(reviewers).containsExactly(user.id.get(), admin.id.get());
-    } else {
-      assertThat(reviewers)
-          .containsExactly(user.id.get(), admin.id.get(), accountCreator.user2().id.get());
-    }
-  }
-
-  @Test
-  @TestProjectInput(createEmptyCommit = false)
-  public void revertInitialCommit() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Cannot revert initial commit");
-    gApi.changes().id(r.getChangeId()).revert();
-  }
-
-  @FunctionalInterface
-  private interface Rebase {
-    void call(String id) throws RestApiException;
-  }
-
-  @Test
-  public void rebaseViaRevisionApi() throws Exception {
-    testRebase(id -> gApi.changes().id(id).current().rebase());
-  }
-
-  @Test
-  public void rebaseViaChangeApi() throws Exception {
-    testRebase(id -> gApi.changes().id(id).rebase());
-  }
-
-  private void testRebase(Rebase rebase) throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    // Add an approval whose score should be copied on trivial rebase
-    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
-
-    String changeId = r2.getChangeId();
-    // Rebase the second change
-    rebase.call(changeId);
-
-    // Second change should have 2 patch sets and an approval
-    ChangeInfo c2 = gApi.changes().id(changeId).get(CURRENT_REVISION, DETAILED_LABELS);
-    assertThat(c2.revisions.get(c2.currentRevision)._number).isEqualTo(2);
-
-    // ...and the committer and description should be correct
-    ChangeInfo info = gApi.changes().id(changeId).get(CURRENT_REVISION, CURRENT_COMMIT);
-    GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
-    assertThat(committer.name).isEqualTo(admin.fullName);
-    assertThat(committer.email).isEqualTo(admin.email);
-    String description = info.revisions.get(info.currentRevision).description;
-    assertThat(description).isEqualTo("Rebase");
-
-    // ...and the approval was copied
-    LabelInfo cr = c2.labels.get("Code-Review");
-    assertThat(cr).isNotNull();
-    assertThat(cr.all).hasSize(1);
-    assertThat(cr.all.get(0).value).isEqualTo(1);
-
-    if (notesMigration.changePrimaryStorage() == PrimaryStorage.REVIEW_DB) {
-      // Ensure record was actually copied under ReviewDb
-      List<PatchSetApproval> psas =
-          unwrapDb(db)
-              .patchSetApprovals()
-              .byPatchSet(new PatchSet.Id(new Change.Id(c2._number), 2))
-              .toList();
-      assertThat(psas).hasSize(1);
-      assertThat(psas.get(0).getValue()).isEqualTo((short) 1);
-    }
-
-    // Rebasing the second change again should fail
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Change is already up to date");
-    gApi.changes().id(changeId).current().rebase();
-  }
-
-  @Test
-  public void rebaseNotAllowedWithoutPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("rebase not permitted");
-    gApi.changes().id(changeId).rebase();
-  }
-
-  @Test
-  public void rebaseAllowedWithPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    grant(project, "refs/heads/master", Permission.REBASE, false, REGISTERED_USERS);
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    setApiUser(user);
-    gApi.changes().id(changeId).rebase();
-  }
-
-  @Test
-  public void rebaseNotAllowedWithoutPushPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    grant(project, "refs/heads/master", Permission.REBASE, false, REGISTERED_USERS);
-    block("refs/for/*", Permission.PUSH, REGISTERED_USERS);
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("rebase not permitted");
-    gApi.changes().id(changeId).rebase();
-  }
-
-  @Test
-  public void rebaseNotAllowedForOwnerWithoutPushPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    block("refs/for/*", Permission.PUSH, REGISTERED_USERS);
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    exception.expect(AuthException.class);
-    exception.expectMessage("rebase not permitted");
-    gApi.changes().id(changeId).rebase();
-  }
-
-  @Test
-  public void deleteNewChangeAsAdmin() throws Exception {
-    PushOneCommit.Result changeResult = createChange();
-    String changeId = changeResult.getChangeId();
-
-    gApi.changes().id(changeId).delete();
-
-    assertThat(query(changeId)).isEmpty();
-  }
-
-  @Test
-  @TestProjectInput(cloneAs = "user")
-  public void deleteNewChangeAsNormalUser() throws Exception {
-    PushOneCommit.Result changeResult =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-    String changeId = changeResult.getChangeId();
-
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
-    gApi.changes().id(changeId).delete();
-  }
-
-  @Test
-  @TestProjectInput(cloneAs = "user")
-  public void deleteChangeAsUserWithDeleteOwnChangesPermission() throws Exception {
-    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
-
-    try {
-      PushOneCommit.Result changeResult =
-          pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-      String changeId = changeResult.getChangeId();
-      int id = changeResult.getChange().getId().id;
-      RevCommit commit = changeResult.getCommit();
-
-      setApiUser(user);
-      gApi.changes().id(changeId).delete();
-
-      assertThat(query(changeId)).isEmpty();
-
-      String ref = new Change.Id(id).toRefPrefix() + "1";
-      eventRecorder.assertRefUpdatedEvents(project.get(), ref, null, commit, commit, null);
-    } finally {
-      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
-    }
-  }
-
-  @Test
-  @TestProjectInput(cloneAs = "user")
-  public void deleteNewChangeOfAnotherUserAsAdmin() throws Exception {
-    PushOneCommit.Result changeResult =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-    changeResult.assertOkStatus();
-    String changeId = changeResult.getChangeId();
-
-    setApiUser(admin);
-    gApi.changes().id(changeId).delete();
-
-    assertThat(query(changeId)).isEmpty();
-  }
-
-  @Test
-  public void deleteNewChangeOfAnotherUserWithDeleteOwnChangesPermission() throws Exception {
-    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
-
-    try {
-      PushOneCommit.Result changeResult = createChange();
-      String changeId = changeResult.getChangeId();
-
-      setApiUser(user);
-      exception.expect(AuthException.class);
-      exception.expectMessage("delete not permitted");
-      gApi.changes().id(changeId).delete();
-    } finally {
-      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
-    }
-  }
-
-  @Test
-  @TestProjectInput(createEmptyCommit = false)
-  public void deleteNewChangeForBranchWithoutCommits() throws Exception {
-    PushOneCommit.Result changeResult = createChange();
-    String changeId = changeResult.getChangeId();
-
-    gApi.changes().id(changeId).delete();
-
-    assertThat(query(changeId)).isEmpty();
-  }
-
-  @Test
-  @TestProjectInput(cloneAs = "user")
-  public void deleteAbandonedChangeAsNormalUser() throws Exception {
-    PushOneCommit.Result changeResult =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-    String changeId = changeResult.getChangeId();
-
-    setApiUser(user);
-    gApi.changes().id(changeId).abandon();
-
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
-    gApi.changes().id(changeId).delete();
-  }
-
-  @Test
-  @TestProjectInput(cloneAs = "user")
-  public void deleteAbandonedChangeOfAnotherUserAsAdmin() throws Exception {
-    PushOneCommit.Result changeResult =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-    String changeId = changeResult.getChangeId();
-
-    gApi.changes().id(changeId).abandon();
-
-    gApi.changes().id(changeId).delete();
-
-    assertThat(query(changeId)).isEmpty();
-  }
-
-  @Test
-  public void deleteMergedChange() throws Exception {
-    PushOneCommit.Result changeResult = createChange();
-    String changeId = changeResult.getChangeId();
-
-    merge(changeResult);
-
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("delete not permitted");
-    gApi.changes().id(changeId).delete();
-  }
-
-  @Test
-  @TestProjectInput(cloneAs = "user")
-  public void deleteMergedChangeWithDeleteOwnChangesPermission() throws Exception {
-    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
-
-    try {
-      PushOneCommit.Result changeResult =
-          pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-      String changeId = changeResult.getChangeId();
-
-      merge(changeResult);
-
-      setApiUser(user);
-      exception.expect(MethodNotAllowedException.class);
-      exception.expectMessage("delete not permitted");
-      gApi.changes().id(changeId).delete();
-    } finally {
-      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
-    }
-  }
-
-  @Test
-  public void deleteNewChangeWithMergedPatchSet() throws Exception {
-    PushOneCommit.Result changeResult = createChange();
-    String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
-
-    merge(changeResult);
-    setChangeStatus(id, Change.Status.NEW);
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format("Cannot delete change %s: patch set 1 is already merged", id));
-    gApi.changes().id(changeId).delete();
-  }
-
-  @Test
-  public void rebaseUpToDateChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Change is already up to date");
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase();
-  }
-
-  @Test
-  public void rebaseConflict() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            "other content",
-            "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
-    r = push.to("refs/for/master");
-    r.assertOkStatus();
-
-    exception.expect(ResourceConflictException.class);
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase();
-  }
-
-  @Test
-  public void rebaseChangeBase() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-    PushOneCommit.Result r3 = createChange();
-    RebaseInput ri = new RebaseInput();
-
-    // rebase r3 directly onto master (break dep. towards r2)
-    ri.base = "";
-    gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).rebase(ri);
-    PatchSet ps3 = r3.getPatchSet();
-    assertThat(ps3.getId().get()).isEqualTo(2);
-
-    // rebase r2 onto r3 (referenced by ref)
-    ri.base = ps3.getId().toRefName();
-    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
-    PatchSet ps2 = r2.getPatchSet();
-    assertThat(ps2.getId().get()).isEqualTo(2);
-
-    // rebase r1 onto r2 (referenced by commit)
-    ri.base = ps2.getRevision().get();
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
-    PatchSet ps1 = r1.getPatchSet();
-    assertThat(ps1.getId().get()).isEqualTo(2);
-
-    // rebase r1 onto r3 (referenced by change number)
-    ri.base = String.valueOf(r3.getChange().getId().get());
-    gApi.changes().id(r1.getChangeId()).revision(ps1.getRevision().get()).rebase(ri);
-    assertThat(r1.getPatchSetId().get()).isEqualTo(3);
-  }
-
-  @Test
-  public void rebaseChangeBaseRecursion() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-
-    RebaseInput ri = new RebaseInput();
-    ri.base = r2.getCommit().name();
-    String expectedMessage =
-        "base change "
-            + r2.getChangeId()
-            + " is a descendant of the current change - recursion not allowed";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(expectedMessage);
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
-  }
-
-  @Test
-  public void rebaseAbandonedChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    ChangeInfo info = get(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is abandoned");
-    gApi.changes().id(changeId).revision(r.getCommit().name()).rebase();
-  }
-
-  @Test
-  public void rebaseOntoAbandonedChange() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Abandon the first change
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    ChangeInfo info = get(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-
-    RebaseInput ri = new RebaseInput();
-    ri.base = r.getCommit().name();
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("base change is abandoned: " + changeId);
-    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
-  }
-
-  @Test
-  public void rebaseOntoSelf() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    String commit = r.getCommit().name();
-    RebaseInput ri = new RebaseInput();
-    ri.base = commit;
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("cannot rebase change onto itself");
-    gApi.changes().id(changeId).revision(commit).rebase(ri);
-  }
-
-  @Test
-  @TestProjectInput(createEmptyCommit = false)
-  public void changeNoParentToOneParent() throws Exception {
-    // create initial commit with no parent and push it as change, so that patch
-    // set 1 has no parent
-    RevCommit c = testRepo.commit().message("Initial commit").insertChangeId().create();
-    String id = GitUtil.getChangeId(testRepo, c).get();
-    testRepo.reset(c);
-
-    PushResult pr = pushHead(testRepo, "refs/for/master", false);
-    assertPushOk(pr, "refs/for/master");
-
-    ChangeInfo change = gApi.changes().id(id).get();
-    assertThat(change.revisions.get(change.currentRevision).commit.parents).isEmpty();
-
-    // create another initial commit with no parent and push it directly into
-    // the remote repository
-    c = testRepo.amend(c.getId()).message("Initial Empty Commit").create();
-    testRepo.reset(c);
-    pr = pushHead(testRepo, "refs/heads/master", false);
-    assertPushOk(pr, "refs/heads/master");
-
-    // create a successor commit and push it as second patch set to the change,
-    // so that patch set 2 has 1 parent
-    RevCommit c2 =
-        testRepo
-            .commit()
-            .message("Initial commit")
-            .parent(c)
-            .insertChangeId(id.substring(1))
-            .create();
-    testRepo.reset(c2);
-
-    pr = pushHead(testRepo, "refs/for/master", false);
-    assertPushOk(pr, "refs/for/master");
-
-    change = gApi.changes().id(id).get();
-    RevisionInfo rev = change.revisions.get(change.currentRevision);
-    assertThat(rev.commit.parents).hasSize(1);
-    assertThat(rev.commit.parents.get(0).commit).isEqualTo(c.name());
-
-    // check that change kind is correctly detected as REWORK
-    assertThat(rev.kind).isEqualTo(ChangeKind.REWORK);
-  }
-
-  @Test
-  public void pushCommitOfOtherUser() throws Exception {
-    // admin pushes commit of user
-    PushOneCommit push = pushFactory.create(db, user.getIdent(), testRepo);
-    PushOneCommit.Result result = push.to("refs/for/master");
-    result.assertOkStatus();
-
-    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
-    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
-    CommitInfo commit = change.revisions.get(change.currentRevision).commit;
-    assertThat(commit.author.email).isEqualTo(user.email);
-    assertThat(commit.committer.email).isEqualTo(user.email);
-
-    // check that the author/committer was added as reviewer
-    Collection<AccountInfo> reviewers = change.reviewers.get(REVIEWER);
-    assertThat(reviewers).isNotNull();
-    assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
-    assertThat(change.reviewers.get(CC)).isNull();
-
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    assertThat(m.body()).contains(admin.fullName + " has uploaded this change for review");
-    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
-    assertMailReplyTo(m, admin.email);
-  }
-
-  @Test
-  public void pushCommitOfOtherUserThatCannotSeeChange() throws Exception {
-    // create hidden project that is only visible to administrators
-    Project.NameKey p = createProject("p");
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.allow(
-        cfg,
-        Permission.READ,
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID(),
-        "refs/*");
-    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
-    saveProjectConfig(p, cfg);
-
-    // admin pushes commit of user
-    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
-    PushOneCommit push = pushFactory.create(db, user.getIdent(), repo);
-    PushOneCommit.Result result = push.to("refs/for/master");
-    result.assertOkStatus();
-
-    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
-    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
-    CommitInfo commit = change.revisions.get(change.currentRevision).commit;
-    assertThat(commit.author.email).isEqualTo(user.email);
-    assertThat(commit.committer.email).isEqualTo(user.email);
-
-    // check the user cannot see the change
-    setApiUser(user);
-    try {
-      gApi.changes().id(result.getChangeId()).get();
-      fail("Expected ResourceNotFoundException");
-    } catch (ResourceNotFoundException e) {
-      // Expected.
-    }
-
-    // check that the author/committer was NOT added as reviewer (he can't see
-    // the change)
-    assertThat(change.reviewers.get(REVIEWER)).isNull();
-    assertThat(change.reviewers.get(CC)).isNull();
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void pushCommitWithFooterOfOtherUser() throws Exception {
-    // admin pushes commit that references 'user' in a footer
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT
-                + "\n\n"
-                + FooterConstants.REVIEWED_BY.getName()
-                + ": "
-                + user.getIdent().toExternalString(),
-            PushOneCommit.FILE_NAME,
-            PushOneCommit.FILE_CONTENT);
-    PushOneCommit.Result result = push.to("refs/for/master");
-    result.assertOkStatus();
-
-    // check that 'user' was added as reviewer
-    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
-    Collection<AccountInfo> reviewers = change.reviewers.get(REVIEWER);
-    assertThat(reviewers).isNotNull();
-    assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
-    assertThat(change.reviewers.get(CC)).isNull();
-
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
-    assertThat(m.body()).contains("I'd like you to do a code review.");
-    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
-    assertMailReplyTo(m, admin.email);
-  }
-
-  @Test
-  public void pushCommitWithFooterOfOtherUserThatCannotSeeChange() throws Exception {
-    // create hidden project that is only visible to administrators
-    Project.NameKey p = createProject("p");
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.allow(
-        cfg,
-        Permission.READ,
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID(),
-        "refs/*");
-    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
-    saveProjectConfig(p, cfg);
-
-    // admin pushes commit that references 'user' in a footer
-    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            repo,
-            PushOneCommit.SUBJECT
-                + "\n\n"
-                + FooterConstants.REVIEWED_BY.getName()
-                + ": "
-                + user.getIdent().toExternalString(),
-            PushOneCommit.FILE_NAME,
-            PushOneCommit.FILE_CONTENT);
-    PushOneCommit.Result result = push.to("refs/for/master");
-    result.assertOkStatus();
-
-    // check that 'user' cannot see the change
-    setApiUser(user);
-    try {
-      gApi.changes().id(result.getChangeId()).get();
-      fail("Expected ResourceNotFoundException");
-    } catch (ResourceNotFoundException e) {
-      // Expected.
-    }
-
-    // check that 'user' was NOT added as cc ('user' can't see the change)
-    setApiUser(admin);
-    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
-    assertThat(change.reviewers.get(REVIEWER)).isNull();
-    assertThat(change.reviewers.get(CC)).isNull();
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void addReviewerThatCannotSeeChange() throws Exception {
-    // create hidden project that is only visible to administrators
-    Project.NameKey p = createProject("p");
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.allow(
-        cfg,
-        Permission.READ,
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID(),
-        "refs/*");
-    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
-    saveProjectConfig(p, cfg);
-
-    // create change
-    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
-    PushOneCommit.Result result = push.to("refs/for/master");
-    result.assertOkStatus();
-
-    // check the user cannot see the change
-    setApiUser(user);
-    try {
-      gApi.changes().id(result.getChangeId()).get();
-      fail("Expected ResourceNotFoundException");
-    } catch (ResourceNotFoundException e) {
-      // Expected.
-    }
-
-    // try to add user as reviewer
-    setApiUser(admin);
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
-
-    assertThat(r.input).isEqualTo(user.email);
-    assertThat(r.error).contains("does not have permission to see this change");
-    assertThat(r.reviewers).isNull();
-  }
-
-  @Test
-  public void addReviewerThatIsInactive() throws Exception {
-    PushOneCommit.Result result = createChange();
-
-    String username = name("new-user");
-    gApi.accounts().create(username).setActive(false);
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = username;
-    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
-
-    assertThat(r.input).isEqualTo(username);
-    assertThat(r.error).contains("identifies an inactive account");
-    assertThat(r.reviewers).isNull();
-  }
-
-  @Test
-  public void addReviewerThatIsInactiveEmailFallback() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    ConfigInput conf = new ConfigInput();
-    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
-    gApi.projects().name(project.get()).config(conf);
-
-    PushOneCommit.Result result = createChange();
-
-    String username = "user@domain.com";
-    gApi.accounts().create(username).setActive(false);
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = username;
-    in.state = ReviewerState.CC;
-    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
-
-    assertThat(r.input).isEqualTo(username);
-    assertThat(r.error).isNull();
-    // When adding by email, the reviewers field is also empty because we can't
-    // render a ReviewerInfo object for a non-account.
-    assertThat(r.reviewers).isNull();
-  }
-
-  @Test
-  public void addReviewer() throws Exception {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-    PushOneCommit.Result r = createChange();
-    ChangeResource rsrc = parseResource(r);
-    String oldETag = rsrc.getETag();
-    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
-    assertThat(m.body()).contains("I'd like you to do a code review.");
-    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
-    assertMailReplyTo(m, admin.email);
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-
-    // When NoteDb is enabled adding a reviewer records that user as reviewer
-    // in NoteDb. When NoteDb is disabled adding a reviewer results in a dummy 0
-    // approval on the change which is treated as CC when the ChangeInfo is
-    // created.
-    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
-    assertThat(reviewers).isNotNull();
-    assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
-
-    // Ensure ETag and lastUpdatedOn are updated.
-    rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
-    assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
-
-    // Change status of reviewer and ensure ETag is updated.
-    oldETag = rsrc.getETag();
-    gApi.accounts().id(user.id.get()).setStatus("new status");
-    rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
-  }
-
-  @Test
-  public void notificationsForAddedWorkInProgressReviewers() throws Exception {
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    ReviewInput batchIn = new ReviewInput();
-    batchIn.reviewers = ImmutableList.of(in);
-
-    // Added reviewers not notified by default.
-    PushOneCommit.Result r = createWorkInProgressChange();
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-    assertThat(sender.getMessages()).hasSize(0);
-
-    // Default notification handling can be overridden.
-    r = createWorkInProgressChange();
-    in.notify = NotifyHandling.OWNER_REVIEWERS;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-    assertThat(sender.getMessages()).hasSize(1);
-    sender.clear();
-
-    // Reviewers added via PostReview also not notified by default.
-    // In this case, the child ReviewerInput has a notify=OWNER_REVIEWERS
-    // that should be ignored.
-    r = createWorkInProgressChange();
-    gApi.changes().id(r.getChangeId()).revision("current").review(batchIn);
-    assertThat(sender.getMessages()).hasSize(0);
-
-    // Top-level notify property can force notifications when adding reviewer
-    // via PostReview.
-    r = createWorkInProgressChange();
-    batchIn.notify = NotifyHandling.OWNER_REVIEWERS;
-    gApi.changes().id(r.getChangeId()).revision("current").review(batchIn);
-    assertThat(sender.getMessages()).hasSize(1);
-  }
-
-  @Test
-  public void addReviewerWithNoteDbWhenDummyApprovalInReviewDbExists() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    assume().that(notesMigration.changePrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-
-    PushOneCommit.Result r = createChange();
-
-    // insert dummy approval in ReviewDb
-    PatchSetApproval psa =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(r.getPatchSetId(), user.id, new LabelId("Code-Review")),
-            (short) 0,
-            TimeUtil.nowTs());
-    db.patchSetApprovals().insert(Collections.singleton(psa));
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-  }
-
-  @Test
-  public void addSelfAsReviewer() throws Exception {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-    PushOneCommit.Result r = createChange();
-    ChangeResource rsrc = parseResource(r);
-    String oldETag = rsrc.getETag();
-    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    setApiUser(user);
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    // There should be no email notification when adding self
-    assertThat(sender.getMessages()).isEmpty();
-
-    // When NoteDb is enabled adding a reviewer records that user as reviewer
-    // in NoteDb. When NoteDb is disabled adding a reviewer results in a dummy 0
-    // approval on the change which is treated as CC when the ChangeInfo is
-    // created.
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
-    assertThat(reviewers).isNotNull();
-    assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
-
-    // Ensure ETag and lastUpdatedOn are updated.
-    rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
-    assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
-  }
-
-  @Test
-  public void implicitlyCcOnNonVotingReviewPgStyle() throws Exception {
-    PushOneCommit.Result r = createChange();
-    setApiUser(user);
-    assertThat(getReviewerState(r.getChangeId(), user.id)).isEmpty();
-
-    // Exact request format made by PG UI at ddc6b7160fe416fed9e7e3180489d44c82fd64f8.
-    ReviewInput in = new ReviewInput();
-    in.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
-    in.labels = ImmutableMap.of();
-    in.message = "comment";
-    in.reviewers = ImmutableList.of();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
-
-    // If we're not reading from NoteDb, then the CCed user will be returned in the REVIEWER state.
-    assertThat(getReviewerState(r.getChangeId(), user.id))
-        .hasValue(notesMigration.readChanges() ? CC : REVIEWER);
-  }
-
-  @Test
-  public void implicitlyCcOnNonVotingReviewGwtStyle() throws Exception {
-    PushOneCommit.Result r = createChange();
-    setApiUser(user);
-    assertThat(getReviewerState(r.getChangeId(), user.id)).isEmpty();
-
-    // 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);
-
-    // If we're not reading from NoteDb, then the CCed user will be returned in the REVIEWER state.
-    assertThat(getReviewerState(r.getChangeId(), user.id))
-        .hasValue(notesMigration.readChanges() ? CC : REVIEWER);
-  }
-
-  @Test
-  public void implicitlyAddReviewerOnVotingReview() throws Exception {
-    PushOneCommit.Result r = createChange();
-    setApiUser(user);
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(ReviewInput.recommend().message("LGTM"));
-
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    assertThat(c.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
-        .containsExactly(user.id.get());
-
-    // Further test: remove the vote, then comment again. The user should be
-    // implicitly re-added to the ReviewerSet, as a CC if we're using NoteDb.
-    setApiUser(admin);
-    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).remove();
-    c = gApi.changes().id(r.getChangeId()).get();
-    assertThat(c.reviewers.values()).isEmpty();
-
-    setApiUser(user);
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(new ReviewInput().message("hi"));
-    c = gApi.changes().id(r.getChangeId()).get();
-    ReviewerState state = notesMigration.readChanges() ? CC : REVIEWER;
-    assertThat(c.reviewers.get(state).stream().map(ai -> ai._accountId).collect(toList()))
-        .containsExactly(user.id.get());
-  }
-
-  @Test
-  public void addReviewerToClosedChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
-    assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(admin.getId().get());
-    assertThat(c.reviewers).doesNotContainKey(CC);
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    c = gApi.changes().id(r.getChangeId()).get();
-    reviewers = c.reviewers.get(REVIEWER);
-    assertThat(reviewers).hasSize(2);
-    Iterator<AccountInfo> reviewerIt = reviewers.iterator();
-    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.getId().get());
-    assertThat(reviewerIt.next()._accountId).isEqualTo(user.getId().get());
-    assertThat(c.reviewers).doesNotContainKey(CC);
-  }
-
-  @Test
-  public void eTagChangesWhenOwnerUpdatesAccountStatus() throws Exception {
-    PushOneCommit.Result r = createChange();
-    ChangeResource rsrc = parseResource(r);
-    String oldETag = rsrc.getETag();
-
-    gApi.accounts().id(admin.id.get()).setStatus("new status");
-    rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
-  }
-
-  @Test
-  public void emailNotificationForFileLevelComment() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes().id(changeId).addReviewer(in);
-    sender.clear();
-
-    ReviewInput review = new ReviewInput();
-    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
-    comment.path = PushOneCommit.FILE_NAME;
-    comment.side = Side.REVISION;
-    comment.message = "comment 1";
-    review.comments = new HashMap<>();
-    review.comments.put(comment.path, Lists.newArrayList(comment));
-    gApi.changes().id(changeId).current().review(review);
-
-    assertThat(sender.getMessages()).hasSize(1);
-    Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-  }
-
-  @Test
-  public void invalidRange() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    ReviewInput review = new ReviewInput();
-    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
-
-    comment.range = new Range();
-    comment.range.startLine = 1;
-    comment.range.endLine = 1;
-    comment.range.startCharacter = -1;
-    comment.range.endCharacter = 0;
-
-    comment.path = PushOneCommit.FILE_NAME;
-    comment.side = Side.REVISION;
-    comment.message = "comment 1";
-    review.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
-
-    exception.expect(BadRequestException.class);
-    gApi.changes().id(changeId).current().review(review);
-  }
-
-  @Test
-  public void listVotes() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-
-    Map<String, Short> m =
-        gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).votes();
-
-    assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 2));
-
-    setApiUser(user);
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.dislike());
-
-    m = gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).votes();
-
-    assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) -1));
-  }
-
-  @Test
-  public void removeReviewerNoVotes() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-
-    LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
-    cfg.getLabelSections().put(verified.getName(), verified);
-
-    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    String heads = RefNames.REFS_HEADS + "*";
-    Util.allow(cfg, Permission.forLabel(Util.verified().getName()), -1, 1, registeredUsers, heads);
-    saveProjectConfig(project, cfg);
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    gApi.changes().id(changeId).addReviewer(user.getId().toString());
-
-    // ReviewerState will vary between ReviewDb and NoteDb; we just care that it
-    // shows up somewhere.
-    Iterable<AccountInfo> reviewers =
-        Iterables.concat(gApi.changes().id(changeId).get().reviewers.values());
-    assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
-
-    sender.clear();
-    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove();
-    assertThat(gApi.changes().id(changeId).get().reviewers).isEmpty();
-
-    assertThat(sender.getMessages()).hasSize(1);
-    Message message = sender.getMessages().get(0);
-    assertThat(message.body()).contains("Removed reviewer " + user.fullName + ".");
-    assertThat(message.body()).doesNotContain("with the following votes");
-
-    // Make sure the reviewer can still be added again.
-    gApi.changes().id(changeId).addReviewer(user.getId().toString());
-    reviewers = Iterables.concat(gApi.changes().id(changeId).get().reviewers.values());
-    assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
-
-    // Remove again, and then try to remove once more to verify 404 is
-    // returned.
-    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove();
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove();
-  }
-
-  @Test
-  public void removeReviewer() throws Exception {
-    testRemoveReviewer(true);
-  }
-
-  @Test
-  public void removeNoNotify() throws Exception {
-    testRemoveReviewer(false);
-  }
-
-  private void testRemoveReviewer(boolean notify) throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
-
-    setApiUser(user);
-    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.recommend());
-
-    Collection<AccountInfo> reviewers = gApi.changes().id(changeId).get().reviewers.get(REVIEWER);
-
-    assertThat(reviewers).hasSize(2);
-    Iterator<AccountInfo> reviewerIt = reviewers.iterator();
-    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.getId().get());
-    assertThat(reviewerIt.next()._accountId).isEqualTo(user.getId().get());
-
-    sender.clear();
-    setApiUser(admin);
-    DeleteReviewerInput input = new DeleteReviewerInput();
-    if (!notify) {
-      input.notify = NotifyHandling.NONE;
-    }
-    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove(input);
-
-    if (notify) {
-      assertThat(sender.getMessages()).hasSize(1);
-      Message message = sender.getMessages().get(0);
-      assertThat(message.body())
-          .contains("Removed reviewer " + user.fullName + " with the following votes");
-      assertThat(message.body()).contains("* Code-Review+1 by " + user.fullName);
-    } else {
-      assertThat(sender.getMessages()).isEmpty();
-    }
-
-    reviewers = gApi.changes().id(changeId).get().reviewers.get(REVIEWER);
-    assertThat(reviewers).hasSize(1);
-    reviewerIt = reviewers.iterator();
-    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.getId().get());
-
-    eventRecorder.assertReviewerDeletedEvents(changeId, user.email);
-  }
-
-  @Test
-  public void removeReviewerNotPermitted() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
-
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).remove();
-  }
-
-  @Test
-  public void deleteVote() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-
-    setApiUser(user);
-    recommend(r.getChangeId());
-
-    setApiUser(admin);
-    sender.clear();
-    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote("Code-Review");
-
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message msg = messages.get(0);
-    assertThat(msg.rcpt()).containsExactly(user.emailAddress);
-    assertThat(msg.body()).contains(admin.fullName + " has removed a vote on this change.\n");
-    assertThat(msg.body())
-        .contains("Removed Code-Review+1 by " + user.fullName + " <" + user.email + ">\n");
-
-    Map<String, Short> m =
-        gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).votes();
-
-    // Dummy 0 approval on the change to block vote copying to this patch set.
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
-
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-
-    ChangeMessageInfo message = Iterables.getLast(c.messages);
-    assertThat(message.author._accountId).isEqualTo(admin.getId().get());
-    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
-    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
-  }
-
-  @Test
-  public void deleteVoteNotifyNone() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-
-    setApiUser(user);
-    recommend(r.getChangeId());
-
-    setApiUser(admin);
-    sender.clear();
-    DeleteVoteInput in = new DeleteVoteInput();
-    in.label = "Code-Review";
-    in.notify = NotifyHandling.NONE;
-    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void deleteVoteNotifyAccount() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-
-    DeleteVoteInput in = new DeleteVoteInput();
-    in.label = "Code-Review";
-    in.notify = NotifyHandling.NONE;
-
-    // notify unrelated account as TO
-    TestAccount user2 = accountCreator.user2();
-    setApiUser(user);
-    recommend(r.getChangeId());
-    setApiUser(admin);
-    sender.clear();
-    in.notifyDetails = new HashMap<>();
-    in.notifyDetails.put(RecipientType.TO, new NotifyInfo(ImmutableList.of(user2.email)));
-    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
-    assertNotifyTo(user2);
-
-    // notify unrelated account as CC
-    setApiUser(user);
-    recommend(r.getChangeId());
-    setApiUser(admin);
-    sender.clear();
-    in.notifyDetails = new HashMap<>();
-    in.notifyDetails.put(RecipientType.CC, new NotifyInfo(ImmutableList.of(user2.email)));
-    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
-    assertNotifyCc(user2);
-
-    // notify unrelated account as BCC
-    setApiUser(user);
-    recommend(r.getChangeId());
-    setApiUser(admin);
-    sender.clear();
-    in.notifyDetails = new HashMap<>();
-    in.notifyDetails.put(RecipientType.BCC, new NotifyInfo(ImmutableList.of(user2.email)));
-    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
-    assertNotifyBcc(user2);
-  }
-
-  @Test
-  public void deleteVoteNotPermitted() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete vote not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).deleteVote("Code-Review");
-  }
-
-  @Test
-  public void nonVotingReviewerStaysAfterSubmit() throws Exception {
-    LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().put(verified.getName(), verified);
-    String heads = "refs/heads/*";
-    AccountGroup.UUID owners = systemGroupBackend.getGroup(CHANGE_OWNER).getUUID();
-    AccountGroup.UUID registered = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    Util.allow(cfg, Permission.forLabel(verified.getName()), -1, 1, owners, heads);
-    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, registered, heads);
-    saveProjectConfig(project, cfg);
-
-    // Set Code-Review+2 and Verified+1 as admin (change owner)
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    String commit = r.getCommit().name();
-    ReviewInput input = ReviewInput.approve();
-    input.label(verified.getName(), 1);
-    gApi.changes().id(changeId).revision(commit).review(input);
-
-    // Reviewers should only be "admin"
-    ChangeInfo c = gApi.changes().id(changeId).get();
-    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId()));
-    assertThat(c.reviewers.get(CC)).isNull();
-
-    // Add the user as reviewer
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes().id(changeId).addReviewer(in);
-    c = gApi.changes().id(changeId).get();
-    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
-
-    // Approve the change as user, then remove the approval
-    // (only to confirm that the user does have Code-Review+2 permission)
-    setApiUser(user);
-    gApi.changes().id(changeId).revision(commit).review(ReviewInput.approve());
-    gApi.changes().id(changeId).revision(commit).review(ReviewInput.noScore());
-
-    // Submit the change
-    setApiUser(admin);
-    gApi.changes().id(changeId).revision(commit).submit();
-
-    // User should still be on the change
-    c = gApi.changes().id(changeId).get();
-    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
-  }
-
-  @Test
-  public void createEmptyChange() throws Exception {
-    ChangeInput in = new ChangeInput();
-    in.branch = Constants.MASTER;
-    in.subject = "Create a change from the API";
-    in.project = project.get();
-    ChangeInfo info = gApi.changes().create(in).get();
-    assertThat(info.project).isEqualTo(in.project);
-    assertThat(info.branch).isEqualTo(in.branch);
-    assertThat(info.subject).isEqualTo(in.subject);
-    assertThat(Iterables.getOnlyElement(info.messages).message).isEqualTo("Uploaded patch set 1.");
-  }
-
-  @Test
-  public void queryChangesNoQuery() throws Exception {
-    PushOneCommit.Result r = createChange();
-    List<ChangeInfo> results = gApi.changes().query().get();
-    assertThat(results.size()).isAtLeast(1);
-    List<Integer> ids = new ArrayList<>(results.size());
-    for (int i = 0; i < results.size(); i++) {
-      ChangeInfo info = results.get(i);
-      if (i == 0) {
-        assertThat(info._number).isEqualTo(r.getChange().getId().get());
-      }
-      assertThat(Change.Status.forChangeStatus(info.status).isOpen()).isTrue();
-      ids.add(info._number);
-    }
-    assertThat(ids).contains(r.getChange().getId().get());
-  }
-
-  @Test
-  public void queryChangesNoResults() throws Exception {
-    createChange();
-    assertThat(query("message:test")).isNotEmpty();
-    assertThat(query("message:{" + getClass().getName() + "fhqwhgads}")).isEmpty();
-  }
-
-  @Test
-  public void queryChanges() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    createChange();
-    List<ChangeInfo> results = query("project:{" + project.get() + "} " + r1.getChangeId());
-    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r1.getChangeId());
-  }
-
-  @Test
-  public void queryChangesLimit() throws Exception {
-    createChange();
-    PushOneCommit.Result r2 = createChange();
-    List<ChangeInfo> results = gApi.changes().query().withLimit(1).get();
-    assertThat(results).hasSize(1);
-    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r2.getChangeId());
-  }
-
-  @Test
-  public void queryChangesStart() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    createChange();
-    List<ChangeInfo> results =
-        gApi.changes().query("project:{" + project.get() + "}").withStart(1).get();
-    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r1.getChangeId());
-  }
-
-  @Test
-  public void queryChangesNoOptions() throws Exception {
-    PushOneCommit.Result r = createChange();
-    ChangeInfo result = Iterables.getOnlyElement(query(r.getChangeId()));
-    assertThat(result.labels).isNull();
-    assertThat(result.messages).isNull();
-    assertThat(result.revisions).isNull();
-    assertThat(result.actions).isNull();
-  }
-
-  @Test
-  public void queryChangesOptions() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    ChangeInfo result = Iterables.getOnlyElement(gApi.changes().query(r.getChangeId()).get());
-    assertThat(result.labels).isNull();
-    assertThat(result.messages).isNull();
-    assertThat(result.actions).isNull();
-    assertThat(result.revisions).isNull();
-
-    result =
-        Iterables.getOnlyElement(
-            gApi.changes()
-                .query(r.getChangeId())
-                .withOptions(
-                    ALL_REVISIONS, CHANGE_ACTIONS, CURRENT_ACTIONS, DETAILED_LABELS, MESSAGES)
-                .get());
-    assertThat(Iterables.getOnlyElement(result.labels.keySet())).isEqualTo("Code-Review");
-    assertThat(result.messages).hasSize(1);
-    assertThat(result.actions).isNotEmpty();
-
-    RevisionInfo rev = Iterables.getOnlyElement(result.revisions.values());
-    assertThat(rev._number).isEqualTo(r.getPatchSetId().get());
-    assertThat(rev.created).isNotNull();
-    assertThat(rev.uploader._accountId).isEqualTo(admin.getId().get());
-    assertThat(rev.ref).isEqualTo(r.getPatchSetId().toRefName());
-    assertThat(rev.actions).isNotEmpty();
-  }
-
-  @Test
-  public void queryChangesOwnerWithDifferentUsers() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(
-            Iterables.getOnlyElement(query("project:{" + project.get() + "} owner:self")).changeId)
-        .isEqualTo(r.getChangeId());
-    setApiUser(user);
-    assertThat(query("owner:self project:{" + project.get() + "}")).isEmpty();
-  }
-
-  @Test
-  public void checkReviewedFlagBeforeAndAfterReview() throws Exception {
-    PushOneCommit.Result r = createChange();
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    setApiUser(user);
-    assertThat(get(r.getChangeId()).reviewed).isNull();
-
-    revision(r).review(ReviewInput.recommend());
-    assertThat(get(r.getChangeId()).reviewed).isTrue();
-  }
-
-  @Test
-  public void topic() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
-    gApi.changes().id(r.getChangeId()).topic("mytopic");
-    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("mytopic");
-    gApi.changes().id(r.getChangeId()).topic("");
-    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
-  }
-
-  @Test
-  public void editTopicWithoutPermissionNotAllowed() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("edit topic name not permitted");
-    gApi.changes().id(r.getChangeId()).topic("mytopic");
-  }
-
-  @Test
-  public void editTopicWithPermissionAllowed() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
-    grant(project, "refs/heads/master", Permission.EDIT_TOPIC_NAME, false, REGISTERED_USERS);
-    setApiUser(user);
-    gApi.changes().id(r.getChangeId()).topic("mytopic");
-    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("mytopic");
-  }
-
-  @Test
-  public void submitted() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String id = r.getChangeId();
-
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).info();
-    assertThat(c.submitted).isNull();
-    assertThat(c.submitter).isNull();
-
-    gApi.changes().id(id).current().review(ReviewInput.approve());
-    gApi.changes().id(id).current().submit();
-
-    c = gApi.changes().id(r.getChangeId()).info();
-    assertThat(c.submitted).isNotNull();
-    assertThat(c.submitter).isNotNull();
-    assertThat(c.submitter._accountId).isEqualTo(atrScope.get().getUser().getAccountId().get());
-  }
-
-  @Test
-  public void submitStaleChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    disableChangeIndexWrites();
-    try {
-      r = amendChange(r.getChangeId());
-    } finally {
-      enableChangeIndexWrites();
-    }
-
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-
-    gApi.changes().id(r.getChangeId()).current().submit();
-    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
-  }
-
-  @Test
-  public void submitNotAllowedWithoutPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("submit not permitted");
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-  }
-
-  @Test
-  public void submitAllowedWithPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    grant(project, "refs/heads/master", Permission.SUBMIT, false, REGISTERED_USERS);
-    setApiUser(user);
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
-  }
-
-  @Test
-  public void check() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(gApi.changes().id(r.getChangeId()).get().problems).isNull();
-    assertThat(gApi.changes().id(r.getChangeId()).get(CHECK).problems).isEmpty();
-  }
-
-  @Test
-  public void commitFooters() throws Exception {
-    LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
-    LabelType custom1 =
-        category("Custom1", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
-    LabelType custom2 =
-        category("Custom2", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().put(verified.getName(), verified);
-    cfg.getLabelSections().put(custom1.getName(), custom1);
-    cfg.getLabelSections().put(custom2.getName(), custom2);
-    String heads = "refs/heads/*";
-    AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-    Util.allow(cfg, Permission.forLabel("Verified"), -1, 1, anon, heads);
-    Util.allow(cfg, Permission.forLabel("Custom1"), -1, 1, anon, heads);
-    Util.allow(cfg, Permission.forLabel("Custom2"), -1, 1, anon, heads);
-    saveProjectConfig(project, cfg);
-
-    PushOneCommit.Result r1 = createChange();
-    r1.assertOkStatus();
-    PushOneCommit.Result r2 =
-        pushFactory
-            .create(
-                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
-            .to("refs/for/master");
-    r2.assertOkStatus();
-
-    ReviewInput in = new ReviewInput();
-    in.label("Code-Review", 1);
-    in.label("Verified", 1);
-    in.label("Custom1", -1);
-    in.label("Custom2", 1);
-    gApi.changes().id(r2.getChangeId()).current().review(in);
-
-    ChangeInfo actual = gApi.changes().id(r2.getChangeId()).get(ALL_REVISIONS, COMMIT_FOOTERS);
-    assertThat(actual.revisions).hasSize(2);
-
-    // No footers except on latest patch set.
-    assertThat(actual.revisions.get(r1.getCommit().getName()).commitWithFooters).isNull();
-
-    List<String> footers =
-        new ArrayList<>(
-            Arrays.asList(
-                actual.revisions.get(r2.getCommit().getName()).commitWithFooters.split("\\n")));
-    // remove subject + blank line
-    footers.remove(0);
-    footers.remove(0);
-
-    List<String> expectedFooters =
-        Arrays.asList(
-            "Change-Id: " + r2.getChangeId(),
-            "Reviewed-on: " + canonicalWebUrl.get() + r2.getChange().getId(),
-            "Reviewed-by: Administrator <admin@example.com>",
-            "Custom2: Administrator <admin@example.com>",
-            "Tested-by: Administrator <admin@example.com>");
-
-    assertThat(footers).containsExactlyElementsIn(expectedFooters);
-  }
-
-  @Test
-  public void customCommitFooters() throws Exception {
-    PushOneCommit.Result change = createChange();
-    RegistrationHandle handle =
-        changeMessageModifiers.add(
-            new ChangeMessageModifier() {
-              @Override
-              public String onSubmit(
-                  String newCommitMessage,
-                  RevCommit original,
-                  RevCommit mergeTip,
-                  Branch.NameKey destination) {
-                assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
-                return newCommitMessage + "Custom: " + destination.get();
-              }
-            });
-    ChangeInfo actual;
-    try {
-      actual = gApi.changes().id(change.getChangeId()).get(ALL_REVISIONS, COMMIT_FOOTERS);
-    } finally {
-      handle.remove();
-    }
-    List<String> footers =
-        new ArrayList<>(
-            Arrays.asList(
-                actual.revisions.get(change.getCommit().getName()).commitWithFooters.split("\\n")));
-    // remove subject + blank line
-    footers.remove(0);
-    footers.remove(0);
-
-    List<String> expectedFooters =
-        Arrays.asList(
-            "Change-Id: " + change.getChangeId(),
-            "Reviewed-on: " + canonicalWebUrl.get() + change.getChange().getId(),
-            "Custom: refs/heads/master");
-    assertThat(footers).containsExactlyElementsIn(expectedFooters);
-  }
-
-  @Test
-  public void defaultSearchDoesNotTouchDatabase() throws Exception {
-    setApiUser(admin);
-    PushOneCommit.Result r1 = createChange();
-    gApi.changes()
-        .id(r1.getChangeId())
-        .revision(r1.getCommit().name())
-        .review(ReviewInput.approve());
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
-
-    createChange();
-
-    setApiUser(user);
-    AcceptanceTestRequestScope.Context ctx = disableDb();
-    try {
-      assertThat(
-              gApi.changes()
-                  .query()
-                  .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
-                  // Options should match defaults in AccountDashboardScreen.
-                  .withOption(LABELS)
-                  .withOption(DETAILED_ACCOUNTS)
-                  .withOption(REVIEWED)
-                  .get())
-          .hasSize(2);
-    } finally {
-      enableDb(ctx);
-    }
-  }
-
-  @Test
-  public void votable() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    gApi.changes().id(triplet).addReviewer(user.username);
-    ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    LabelInfo codeReview = c.labels.get("Code-Review");
-    assertThat(codeReview.all).hasSize(1);
-    ApprovalInfo approval = codeReview.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.id.get());
-    assertThat(approval.value).isEqualTo(0);
-
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.blockLabel(cfg, "Code-Review", REGISTERED_USERS, "refs/heads/*");
-    saveProjectConfig(project, cfg);
-    c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    codeReview = c.labels.get("Code-Review");
-    assertThat(codeReview.all).hasSize(1);
-    approval = codeReview.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.id.get());
-    assertThat(approval.value).isNull();
-  }
-
-  @Test
-  @GerritConfig(name = "gerrit.editGpgKeys", value = "true")
-  @GerritConfig(name = "receive.enableSignedPush", value = "true")
-  public void pushCertificates() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = amendChange(r1.getChangeId());
-
-    ChangeInfo info = gApi.changes().id(r1.getChangeId()).get(ALL_REVISIONS, PUSH_CERTIFICATES);
-
-    RevisionInfo rev1 = info.revisions.get(r1.getCommit().name());
-    assertThat(rev1).isNotNull();
-    assertThat(rev1.pushCertificate).isNotNull();
-    assertThat(rev1.pushCertificate.certificate).isNull();
-    assertThat(rev1.pushCertificate.key).isNull();
-
-    RevisionInfo rev2 = info.revisions.get(r2.getCommit().name());
-    assertThat(rev2).isNotNull();
-    assertThat(rev2.pushCertificate).isNotNull();
-    assertThat(rev2.pushCertificate.certificate).isNull();
-    assertThat(rev2.pushCertificate.key).isNull();
-  }
-
-  @Test
-  public void anonymousRestApi() throws Exception {
-    setApiUserAnonymous();
-    PushOneCommit.Result r = createChange();
-
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
-    assertThat(info.changeId).isEqualTo(r.getChangeId());
-
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    info = gApi.changes().id(triplet).get();
-    assertThat(info.changeId).isEqualTo(r.getChangeId());
-
-    info = gApi.changes().id(info._number).get();
-    assertThat(info.changeId).isEqualTo(r.getChangeId());
-
-    exception.expect(AuthException.class);
-    gApi.changes().id(triplet).current().review(ReviewInput.approve());
-  }
-
-  @Test
-  public void noteDbCommitsOnPatchSetCreation() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    PushOneCommit.Result r = createChange();
-    pushFactory
-        .create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "4711", r.getChangeId())
-        .to("refs/for/master")
-        .assertOkStatus();
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit commitPatchSetCreation =
-          rw.parseCommit(repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
-
-      assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
-      PersonIdent expectedAuthor =
-          changeNoteUtil.newIdent(
-              accountCache.get(admin.id).getAccount(), c.updated,
-              serverIdent.get(), AnonymousCowardNameProvider.DEFAULT);
-      assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
-      assertThat(commitPatchSetCreation.getCommitterIdent())
-          .isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
-      assertThat(commitPatchSetCreation.getParentCount()).isEqualTo(1);
-
-      RevCommit commitChangeCreation = rw.parseCommit(commitPatchSetCreation.getParent(0));
-      assertThat(commitChangeCreation.getShortMessage()).isEqualTo("Create change");
-      expectedAuthor =
-          changeNoteUtil.newIdent(
-              accountCache.get(admin.id).getAccount(),
-              c.created,
-              serverIdent.get(),
-              AnonymousCowardNameProvider.DEFAULT);
-      assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
-      assertThat(commitChangeCreation.getCommitterIdent())
-          .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
-      assertThat(commitChangeCreation.getParentCount()).isEqualTo(0);
-    }
-  }
-
-  @Test
-  public void createEmptyChangeOnNonExistingBranch() throws Exception {
-    ChangeInput in = new ChangeInput();
-    in.branch = "foo";
-    in.subject = "Create a change on new branch from the API";
-    in.project = project.get();
-    in.newBranch = true;
-    ChangeInfo info = gApi.changes().create(in).get();
-    assertThat(info.project).isEqualTo(in.project);
-    assertThat(info.branch).isEqualTo(in.branch);
-    assertThat(info.subject).isEqualTo(in.subject);
-    assertThat(Iterables.getOnlyElement(info.messages).message).isEqualTo("Uploaded patch set 1.");
-  }
-
-  @Test
-  public void createEmptyChangeOnExistingBranchWithNewBranch() throws Exception {
-    ChangeInput in = new ChangeInput();
-    in.branch = Constants.MASTER;
-    in.subject = "Create a change on new branch from the API";
-    in.project = project.get();
-    in.newBranch = true;
-
-    exception.expect(ResourceConflictException.class);
-    gApi.changes().create(in).get();
-  }
-
-  @Test
-  public void createNewPatchSetWithoutPermission() throws Exception {
-    // Create new project with clean permissions
-    Project.NameKey p = createProject("addPatchSet1");
-
-    // Clone separate repositories of the same project as admin and as user
-    TestRepository<InMemoryRepository> adminTestRepo = cloneProject(p, admin);
-    TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
-
-    // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
-
-    // Create change as admin
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
-    PushOneCommit.Result r1 = push.to("refs/for/master");
-    r1.assertOkStatus();
-
-    // Fetch change
-    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
-    userTestRepo.reset("ps");
-
-    // Amend change as user
-    PushOneCommit.Result r2 = amendChange(r1.getChangeId(), "refs/for/master", user, userTestRepo);
-    r2.assertErrorStatus("cannot add patch set to " + r1.getChange().getId().id + ".");
-  }
-
-  @Test
-  public void createNewSetPatchWithPermission() throws Exception {
-    // Clone separate repositories of the same project as admin and as user
-    TestRepository<?> adminTestRepo = cloneProject(project, admin);
-    TestRepository<?> userTestRepo = cloneProject(project, user);
-
-    // Create change as admin
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
-    PushOneCommit.Result r1 = push.to("refs/for/master");
-    r1.assertOkStatus();
-
-    // Fetch change
-    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
-    userTestRepo.reset("ps");
-
-    // Amend change as user
-    PushOneCommit.Result r2 = amendChange(r1.getChangeId(), "refs/for/master", user, userTestRepo);
-    r2.assertOkStatus();
-  }
-
-  @Test
-  public void createNewPatchSetAsOwnerWithoutPermission() throws Exception {
-    // Create new project with clean permissions
-    Project.NameKey p = createProject("addPatchSet2");
-    // Clone separate repositories of the same project as admin and as user
-    TestRepository<?> adminTestRepo = cloneProject(project, admin);
-
-    // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
-
-    // Create change as admin
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
-    PushOneCommit.Result r1 = push.to("refs/for/master");
-    r1.assertOkStatus();
-
-    // Fetch change
-    GitUtil.fetch(adminTestRepo, r1.getPatchSet().getRefName() + ":ps");
-    adminTestRepo.reset("ps");
-
-    // Amend change as admin
-    PushOneCommit.Result r2 =
-        amendChange(r1.getChangeId(), "refs/for/master", admin, adminTestRepo);
-    r2.assertOkStatus();
-  }
-
-  @Test
-  public void createMergePatchSet() throws Exception {
-    PushOneCommit.Result start = pushTo("refs/heads/master");
-    start.assertOkStatus();
-    // create a change for master
-    PushOneCommit.Result r = createChange();
-    r.assertOkStatus();
-    String changeId = r.getChangeId();
-
-    testRepo.reset(start.getCommit());
-    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
-    currentMaster.assertOkStatus();
-    String parent = currentMaster.getCommit().getName();
-
-    // push a commit into dev branch
-    createBranch(new Branch.NameKey(project, "dev"));
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(db, user.getIdent(), testRepo, "change A", "A.txt", "A content")
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-    gApi.changes().id(changeId).createMergePatchSet(in);
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-    assertThat(changeInfo.revisions.size()).isEqualTo(2);
-    assertThat(changeInfo.subject).isEqualTo(in.subject);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(parent);
-  }
-
-  @Test
-  public void createMergePatchSetInheritParent() throws Exception {
-    PushOneCommit.Result start = pushTo("refs/heads/master");
-    start.assertOkStatus();
-    // create a change for master
-    PushOneCommit.Result r = createChange();
-    r.assertOkStatus();
-    String changeId = r.getChangeId();
-    String parent = r.getCommit().getParent(0).getName();
-
-    // advance master branch
-    testRepo.reset(start.getCommit());
-    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    createBranch(new Branch.NameKey(project, "dev"));
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(db, user.getIdent(), testRepo, "change A", "A.txt", "A content")
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2 inherit parent of ps1";
-    in.inheritParent = true;
-    gApi.changes().id(changeId).createMergePatchSet(in);
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-
-    assertThat(changeInfo.revisions.size()).isEqualTo(2);
-    assertThat(changeInfo.subject).isEqualTo(in.subject);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(parent);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isNotEqualTo(currentMaster.getCommit().getName());
-  }
-
-  @Test
-  public void checkLabelsForUnsubmittedChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
-
-    // add new label and assert that it's returned for existing changes
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType verified = Util.verified();
-    cfg.getLabelSections().put(verified.getName(), verified);
-    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    String heads = RefNames.REFS_HEADS + "*";
-    Util.allow(cfg, Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
-    saveProjectConfig(project, cfg);
-
-    change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review", "Verified");
-    assertPermitted(change, "Code-Review", -2, -1, 0, 1, 2);
-    assertPermitted(change, "Verified", -1, 0, 1);
-
-    // add an approval on the new label
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
-
-    // remove label and assert that it's no longer returned for existing
-    // changes, even if there is an approval for it
-    cfg.getLabelSections().remove(verified.getName());
-    Util.remove(cfg, Permission.forLabel(verified.getName()), registeredUsers, heads);
-    saveProjectConfig(project, cfg);
-
-    change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
-
-    // abandon the change and see that the returned labels stay the same
-    // while all permitted labels disappear.
-    gApi.changes().id(r.getChangeId()).abandon();
-    change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels).isEmpty();
-  }
-
-  @Test
-  public void checkLabelsForMergedChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-
-    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 2);
-
-    // add new label and assert that it's returned for existing changes
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType verified = Util.verified();
-    cfg.getLabelSections().put(verified.getName(), verified);
-    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    String heads = RefNames.REFS_HEADS + "*";
-    Util.allow(cfg, Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
-    saveProjectConfig(project, cfg);
-
-    change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review", "Verified");
-    assertPermitted(change, "Code-Review", 2);
-    assertPermitted(change, "Verified", 0, 1);
-
-    // ignore the new label by Prolog submit rule and assert that the label is
-    // no longer returned
-    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
-    testRepo.reset("config");
-    PushOneCommit push2 =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            "Ignore Verified",
-            "rules.pl",
-            "submit_rule(submit(CR)) :-\n  gerrit:max_with_block(-2, 2, 'Code-Review', CR).");
-    push2.to(RefNames.REFS_CONFIG);
-
-    change = gApi.changes().id(r.getChangeId()).get();
-    assertPermitted(change, "Code-Review", 2);
-    assertPermitted(change, "Verified");
-
-    // add an approval on the new label and assert that the label is now
-    // returned although it is ignored by the Prolog submit rule and hence not
-    // included in the submit records
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
-
-    change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
-    assertPermitted(change, "Code-Review", 2);
-    assertPermitted(change, "Verified");
-
-    // remove label and assert that it's no longer returned for existing
-    // changes, even if there is an approval for it
-    cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().remove(verified.getName());
-    Util.remove(cfg, Permission.forLabel(verified.getName()), registeredUsers, heads);
-    saveProjectConfig(project, cfg);
-
-    change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 2);
-  }
-
-  @Test
-  public void checkLabelsForMergedChangeWithNonAuthorCodeReview() throws Exception {
-    // Configure Non-Author-Code-Review
-    RevCommit oldHead = getRemoteHead();
-    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
-    testRepo.reset("config");
-    PushOneCommit push2 =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            "Configure Non-Author-Code-Review",
-            "rules.pl",
-            "submit_rule(S) :-\n"
-                + "  gerrit:default_submit(X),\n"
-                + "  X =.. [submit | Ls],\n"
-                + "  add_non_author_approval(Ls, R),\n"
-                + "  S =.. [submit | R].\n"
-                + "\n"
-                + "add_non_author_approval(S1, S2) :-\n"
-                + "  gerrit:commit_author(A),\n"
-                + "  gerrit:commit_label(label('Code-Review', 2), R),\n"
-                + "  R \\= A, !,\n"
-                + "  S2 = [label('Non-Author-Code-Review', ok(R)) | S1].\n"
-                + "add_non_author_approval(S1,"
-                + " [label('Non-Author-Code-Review', need(_)) | S1]).");
-    push2.to(RefNames.REFS_CONFIG);
-    testRepo.reset(oldHead);
-
-    // Allow user to approve
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    String heads = RefNames.REFS_HEADS + "*";
-    Util.allow(
-        cfg, Permission.forLabel(Util.codeReview().getName()), -2, 2, registeredUsers, heads);
-    saveProjectConfig(project, cfg);
-
-    PushOneCommit.Result r = createChange();
-
-    setApiUser(user);
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-
-    setApiUser(admin);
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-
-    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Non-Author-Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 0, 1, 2);
-  }
-
-  @Test
-  public void checkLabelsForAutoClosedChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    PushOneCommit.Result result = push.to("refs/heads/master");
-    result.assertOkStatus();
-
-    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 0, 1, 2);
-  }
-
-  @Test
-  public void maxPermittedValueAllowed() throws Exception {
-    final int minPermittedValue = -2;
-    final int maxPermittedValue = +2;
-    String heads = "refs/heads/*";
-
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-
-    gApi.changes().id(triplet).addReviewer(user.username);
-
-    ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    LabelInfo codeReview = c.labels.get("Code-Review");
-    assertThat(codeReview.all).hasSize(1);
-    ApprovalInfo approval = codeReview.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.id.get());
-    assertThat(approval.permittedVotingRange).isNotNull();
-    // default values
-    assertThat(approval.permittedVotingRange.min).isEqualTo(-1);
-    assertThat(approval.permittedVotingRange.max).isEqualTo(1);
-
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.allow(
-        cfg,
-        Permission.forLabel("Code-Review"),
-        minPermittedValue,
-        maxPermittedValue,
-        REGISTERED_USERS,
-        heads);
-    saveProjectConfig(project, cfg);
-
-    c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    codeReview = c.labels.get("Code-Review");
-    assertThat(codeReview.all).hasSize(1);
-    approval = codeReview.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.id.get());
-    assertThat(approval.permittedVotingRange).isNotNull();
-    assertThat(approval.permittedVotingRange.min).isEqualTo(minPermittedValue);
-    assertThat(approval.permittedVotingRange.max).isEqualTo(maxPermittedValue);
-  }
-
-  @Test
-  public void maxPermittedValueBlocked() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.blockLabel(cfg, "Code-Review", REGISTERED_USERS, "refs/heads/*");
-    saveProjectConfig(project, cfg);
-
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-
-    gApi.changes().id(triplet).addReviewer(user.username);
-
-    ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    LabelInfo codeReview = c.labels.get("Code-Review");
-    assertThat(codeReview.all).hasSize(1);
-    ApprovalInfo approval = codeReview.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.id.get());
-    assertThat(approval.permittedVotingRange).isNull();
-  }
-
-  @Test
-  public void unresolvedCommentsBlocked() throws Exception {
-    modifySubmitRules(
-        "submit_rule(submit(R)) :- \n"
-            + "gerrit:unresolved_comments_count(0), \n"
-            + "!,"
-            + "gerrit:commit_author(A), \n"
-            + "R = label('All-Comments-Resolved', ok(A)).\n"
-            + "submit_rule(submit(R)) :- \n"
-            + "gerrit:unresolved_comments_count(U), \n"
-            + "U > 0,"
-            + "R = label('All-Comments-Resolved', need(_)). \n\n");
-
-    String oldHead = getRemoteHead().name();
-    PushOneCommit.Result result1 =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-    testRepo.reset(oldHead);
-    PushOneCommit.Result result2 =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-
-    addComment(result1, "comment 1", true, false, null);
-    addComment(result2, "comment 2", true, true, null);
-
-    gApi.changes().id(result1.getChangeId()).current().submit();
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Failed to submit 1 change due to the following problems");
-    exception.expectMessage("needs All-Comments-Resolved");
-    gApi.changes().id(result2.getChangeId()).current().submit();
-  }
-
-  @Test
-  public void pureRevertFactBlocksSubmissionOfNonReverts() throws Exception {
-    addPureRevertSubmitRule();
-
-    // Create a change that is not a revert of another change
-    PushOneCommit.Result r1 =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-    approve(r1.getChangeId());
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Failed to submit 1 change due to the following problems");
-    exception.expectMessage("needs Is-Pure-Revert");
-    gApi.changes().id(r1.getChangeId()).current().submit();
-  }
-
-  @Test
-  public void pureRevertFactBlocksSubmissionOfNonPureReverts() throws Exception {
-    PushOneCommit.Result r1 =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-    merge(r1);
-
-    addPureRevertSubmitRule();
-
-    // Create a revert and push a content change
-    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
-    amendChange(revertId);
-    approve(revertId);
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Failed to submit 1 change due to the following problems");
-    exception.expectMessage("needs Is-Pure-Revert");
-    gApi.changes().id(revertId).current().submit();
-  }
-
-  @Test
-  public void pureRevertFactAllowsSubmissionOfPureReverts() throws Exception {
-    // Create a change that we can later revert
-    PushOneCommit.Result r1 =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-    merge(r1);
-
-    addPureRevertSubmitRule();
-
-    // Create a revert and submit it
-    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
-    approve(revertId);
-    gApi.changes().id(revertId).current().submit();
-  }
-
-  @Test
-  public void changeCommitMessage() throws Exception {
-    // Tests mutating the commit message as both the owner of the change and a regular user with
-    // addPatchSet permission. Asserts that both cases succeed.
-    PushOneCommit.Result r = createChange();
-    r.assertOkStatus();
-    assertThat(getCommitMessage(r.getChangeId()))
-        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-
-    for (TestAccount acc : ImmutableList.of(admin, user)) {
-      setApiUser(acc);
-      String newMessage =
-          "modified commit by " + acc.username + "\n\nChange-Id: " + r.getChangeId() + "\n";
-      gApi.changes().id(r.getChangeId()).setMessage(newMessage);
-      RevisionApi rApi = gApi.changes().id(r.getChangeId()).current();
-      assertThat(rApi.files().keySet()).containsExactly("/COMMIT_MSG", "a.txt");
-      assertThat(getCommitMessage(r.getChangeId())).isEqualTo(newMessage);
-      assertThat(rApi.description()).isEqualTo("Edit commit message");
-    }
-
-    // Verify tags, which should differ according to whether the change was WIP
-    // at the time the commit message was edited. First, look at the last edit
-    // we created above, when the change was not WIP.
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
-    assertThat(Iterables.getLast(info.messages).tag)
-        .isEqualTo(ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
-
-    // Move the change to WIP and edit the commit message again, to observe a
-    // different tag. Must switch to change owner to move into WIP.
-    setApiUser(admin);
-    gApi.changes().id(r.getChangeId()).setWorkInProgress();
-    String newMessage = "modified commit in WIP change\n\nChange-Id: " + r.getChangeId() + "\n";
-    gApi.changes().id(r.getChangeId()).setMessage(newMessage);
-    info = gApi.changes().id(r.getChangeId()).get();
-    assertThat(Iterables.getLast(info.messages).tag)
-        .isEqualTo(ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
-  }
-
-  @Test
-  public void changeCommitMessageWithNoChangeIdSucceedsIfChangeIdNotRequired() throws Exception {
-    ConfigInput configInput = new ConfigInput();
-    configInput.requireChangeId = InheritableBoolean.FALSE;
-    gApi.projects().name(project.get()).config(configInput);
-
-    PushOneCommit.Result r = createChange();
-    r.assertOkStatus();
-    assertThat(getCommitMessage(r.getChangeId()))
-        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-
-    String newMessage = "modified commit\n";
-    gApi.changes().id(r.getChangeId()).setMessage(newMessage);
-    RevisionApi rApi = gApi.changes().id(r.getChangeId()).current();
-    assertThat(rApi.files().keySet()).containsExactly("/COMMIT_MSG", "a.txt");
-    assertThat(getCommitMessage(r.getChangeId())).isEqualTo(newMessage);
-  }
-
-  @Test
-  public void changeCommitMessageWithNoChangeIdFails() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(getCommitMessage(r.getChangeId()))
-        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("missing Change-Id footer");
-    gApi.changes().id(r.getChangeId()).setMessage("modified commit\n");
-  }
-
-  @Test
-  public void changeCommitMessageWithWrongChangeIdFails() throws Exception {
-    PushOneCommit.Result otherChange = createChange();
-    PushOneCommit.Result r = createChange();
-    assertThat(getCommitMessage(r.getChangeId()))
-        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("wrong Change-Id footer");
-    gApi.changes()
-        .id(r.getChangeId())
-        .setMessage("modified commit\n\nChange-Id: " + otherChange.getChangeId() + "\n");
-  }
-
-  @Test
-  public void changeCommitMessageWithoutPermissionFails() throws Exception {
-    // Create new project with clean permissions
-    Project.NameKey p = createProject("addPatchSetEdit");
-    TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
-    // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
-    // Create change as user
-    PushOneCommit push = pushFactory.create(db, user.getIdent(), userTestRepo);
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    // Try to change the commit message
-    exception.expect(AuthException.class);
-    exception.expectMessage("modifying commit message not permitted");
-    gApi.changes().id(r.getChangeId()).setMessage("foo");
-  }
-
-  @Test
-  public void changeCommitMessageWithSameMessageFails() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(getCommitMessage(r.getChangeId()))
-        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("new and existing commit message are the same");
-    gApi.changes().id(r.getChangeId()).setMessage(getCommitMessage(r.getChangeId()));
-  }
-
-  @Test
-  public void fourByteEmoji() throws Exception {
-    // U+1F601 GRINNING FACE WITH SMILING EYES
-    String smile = new String(Character.toChars(0x1f601));
-    assertThat(smile).isEqualTo("😁");
-    assertThat(smile).hasLength(2); // Thanks, Java.
-    assertThat(smile.getBytes(UTF_8)).hasLength(4);
-
-    String subject = "A happy change " + smile;
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, subject, FILE_NAME, FILE_CONTENT)
-            .to("refs/for/master");
-    r.assertOkStatus();
-    String id = r.getChangeId();
-
-    ReviewInput ri = ReviewInput.approve();
-    ri.message = "I like it " + smile;
-    ReviewInput.CommentInput ci = new ReviewInput.CommentInput();
-    ci.path = FILE_NAME;
-    ci.side = Side.REVISION;
-    ci.message = "Good " + smile;
-    ri.comments = ImmutableMap.of(FILE_NAME, ImmutableList.of(ci));
-    gApi.changes().id(id).current().review(ri);
-
-    ChangeInfo info = gApi.changes().id(id).get(MESSAGES, CURRENT_COMMIT, CURRENT_REVISION);
-    assertThat(info.subject).isEqualTo(subject);
-    assertThat(Iterables.getLast(info.messages).message).endsWith(ri.message);
-    assertThat(Iterables.getOnlyElement(info.revisions.values()).commit.message)
-        .startsWith(subject);
-
-    List<CommentInfo> comments =
-        Iterables.getOnlyElement(gApi.changes().id(id).comments().values());
-    assertThat(Iterables.getOnlyElement(comments).message).isEqualTo(ci.message);
-  }
-
-  @Test
-  public void pureRevertReturnsTrueForPureRevert() throws Exception {
-    PushOneCommit.Result r = createChange();
-    merge(r);
-    String revertId = gApi.changes().id(r.getChangeId()).revert().get().id;
-    // Without query parameter
-    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
-    // With query parameter
-    assertThat(
-            gApi.changes()
-                .id(revertId)
-                .pureRevert(getRemoteHead().toObjectId().name())
-                .isPureRevert)
-        .isTrue();
-  }
-
-  @Test
-  public void pureRevertReturnsFalseOnContentChange() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    merge(r1);
-    // Create a revert and expect pureRevert to be true
-    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
-    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
-
-    // Create a new PS and expect pureRevert to be false
-    PushOneCommit.Result result = amendChange(revertId);
-    result.assertOkStatus();
-    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isFalse();
-  }
-
-  @Test
-  public void pureRevertParameterTakesPrecedence() throws Exception {
-    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
-    merge(r1);
-    String oldHead = getRemoteHead().toObjectId().name();
-
-    PushOneCommit.Result r2 = createChange("commit message", "a.txt", "content2");
-    merge(r2);
-
-    String revertId = gApi.changes().id(r2.getChangeId()).revert().get().changeId;
-    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
-    assertThat(gApi.changes().id(revertId).pureRevert(oldHead).isPureRevert).isFalse();
-  }
-
-  @Test
-  public void pureRevertReturnsFalseOnInvalidInput() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    merge(r1);
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid object ID");
-    gApi.changes().id(createChange().getChangeId()).pureRevert("invalid id");
-  }
-
-  @Test
-  public void pureRevertReturnsTrueWithCleanRebase() throws Exception {
-    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
-    merge(r1);
-
-    PushOneCommit.Result r2 = createChange("commit message", "b.txt", "content2");
-    merge(r2);
-
-    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
-    // Rebase revert onto HEAD
-    gApi.changes().id(revertId).rebase();
-    // Check that pureRevert is true which implies that the commit can be rebased onto the original
-    // commit.
-    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
-  }
-
-  @Test
-  public void pureRevertReturnsFalseWithRebaseConflict() throws Exception {
-    // Create an initial commit to serve as claimed original
-    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
-    merge(r1);
-    String claimedOriginal = getRemoteHead().toObjectId().name();
-
-    // Change contents of the file to provoke a conflict
-    merge(createChange("commit message", "a.txt", "content2"));
-
-    // Create a commit that we can revert
-    PushOneCommit.Result r2 = createChange("commit message", "a.txt", "content3");
-    merge(r2);
-
-    // Create a revert of r2
-    String revertR3Id = gApi.changes().id(r2.getChangeId()).revert().id();
-    // Assert that the change is a pure revert of it's 'revertOf'
-    assertThat(gApi.changes().id(revertR3Id).pureRevert().isPureRevert).isTrue();
-    // Assert that the change is not a pure revert of claimedOriginal because pureRevert is trying
-    // to rebase this on claimed original, which fails.
-    PureRevertInfo pureRevert = gApi.changes().id(revertR3Id).pureRevert(claimedOriginal);
-    assertThat(pureRevert.isPureRevert).isFalse();
-  }
-
-  @Test
-  public void pureRevertThrowsExceptionWhenChangeIsNotARevertAndNoIdProvided() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("no ID was provided and change isn't a revert");
-    gApi.changes().id(createChange().getChangeId()).pureRevert();
-  }
-
-  @Test
-  public void putTopicExceedLimitFails() throws Exception {
-    String changeId = createChange().getChangeId();
-    String topic = Stream.generate(() -> "t").limit(2049).collect(joining());
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("topic length exceeds the limit");
-    gApi.changes().id(changeId).topic(topic);
-  }
-
-  private String getCommitMessage(String changeId) throws RestApiException, IOException {
-    return gApi.changes().id(changeId).current().file("/COMMIT_MSG").content().asString();
-  }
-
-  private void addComment(
-      PushOneCommit.Result r,
-      String message,
-      boolean omitDuplicateComments,
-      Boolean unresolved,
-      String inReplyTo)
-      throws Exception {
-    ReviewInput.CommentInput c = new ReviewInput.CommentInput();
-    c.line = 1;
-    c.message = message;
-    c.path = FILE_NAME;
-    c.unresolved = unresolved;
-    c.inReplyTo = inReplyTo;
-    ReviewInput in = new ReviewInput();
-    in.comments = new HashMap<>();
-    in.comments.put(c.path, Lists.newArrayList(c));
-    in.omitDuplicateComments = omitDuplicateComments;
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
-  }
-
-  private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
-    return Iterables.transform(r, a -> new Account.Id(a._accountId));
-  }
-
-  private ChangeResource parseResource(PushOneCommit.Result r) throws Exception {
-    return parseChangeResource(r.getChangeId());
-  }
-
-  private Optional<ReviewerState> getReviewerState(String changeId, Account.Id accountId)
-      throws Exception {
-    ChangeInfo c = gApi.changes().id(changeId).get(DETAILED_LABELS);
-    Set<ReviewerState> states =
-        c.reviewers
-            .entrySet()
-            .stream()
-            .filter(e -> e.getValue().stream().anyMatch(a -> a._accountId == accountId.get()))
-            .map(e -> e.getKey())
-            .collect(toSet());
-    assertThat(states.size()).named(states.toString()).isAtMost(1);
-    return states.stream().findFirst();
-  }
-
-  private void setChangeStatus(Change.Id id, Change.Status newStatus) throws Exception {
-    try (BatchUpdate batchUpdate =
-        batchUpdateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
-      batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
-      batchUpdate.execute();
-    }
-
-    ChangeStatus changeStatus = gApi.changes().id(id.get()).get().status;
-    assertThat(changeStatus).isEqualTo(newStatus.asChangeStatus());
-  }
-
-  private static class ChangeStatusUpdateOp implements BatchUpdateOp {
-    private final Change.Status newStatus;
-
-    ChangeStatusUpdateOp(Change.Status newStatus) {
-      this.newStatus = newStatus;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws Exception {
-      Change change = ctx.getChange();
-
-      // Change status in database.
-      change.setStatus(newStatus);
-
-      // Change status in NoteDb.
-      PatchSet.Id currentPatchSetId = change.currentPatchSetId();
-      ctx.getUpdate(currentPatchSetId).setStatus(newStatus);
-
-      return true;
-    }
-  }
-
-  private void addPureRevertSubmitRule() throws Exception {
-    modifySubmitRules(
-        "submit_rule(submit(R)) :- \n"
-            + "gerrit:pure_revert(1), \n"
-            + "!,"
-            + "gerrit:commit_author(A), \n"
-            + "R = label('Is-Pure-Revert', ok(A)).\n"
-            + "submit_rule(submit(R)) :- \n"
-            + "gerrit:pure_revert(U), \n"
-            + "U \\= 1,"
-            + "R = label('Is-Pure-Revert', need(_)). \n\n");
-  }
-
-  private void modifySubmitRules(String newContent) throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<?> testRepo = new TestRepository<>((InMemoryRepository) repo);
-      testRepo
-          .branch(RefNames.REFS_CONFIG)
-          .commit()
-          .author(admin.getIdent())
-          .committer(admin.getIdent())
-          .add("rules.pl", newContent)
-          .message("Modify rules.pl")
-          .create();
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "trackingid.jira-bug.footer", value = "Bug:")
-  @GerritConfig(name = "trackingid.jira-bug.match", value = "JRA\\d{2,8}")
-  @GerritConfig(name = "trackingid.jira-bug.system", value = "JIRA")
-  public void trackingIds() throws Exception {
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT + "\n\n" + "Bug:JRA001",
-            PushOneCommit.FILE_NAME,
-            PushOneCommit.FILE_CONTENT);
-    PushOneCommit.Result result = push.to("refs/for/master");
-    result.assertOkStatus();
-
-    ChangeInfo change = gApi.changes().id(result.getChangeId()).get(TRACKING_IDS);
-    Collection<TrackingIdInfo> trackingIds = change.trackingIds;
-    assertThat(trackingIds).isNotNull();
-    assertThat(trackingIds).hasSize(1);
-    assertThat(trackingIds.iterator().next().system).isEqualTo("JIRA");
-    assertThat(trackingIds.iterator().next().id).isEqualTo("JRA001");
-  }
-
-  @Test
-  public void starUnstar() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    changeIndexedCounter.clear();
-
-    gApi.accounts().self().starChange(triplet);
-    ChangeInfo change = info(triplet);
-    assertThat(change.starred).isTrue();
-    assertThat(change.stars).contains(DEFAULT_LABEL);
-    changeIndexedCounter.assertReindexOf(change);
-
-    gApi.accounts().self().unstarChange(triplet);
-    change = info(triplet);
-    assertThat(change.starred).isNull();
-    assertThat(change.stars).isNull();
-    changeIndexedCounter.assertReindexOf(change);
-  }
-
-  @Test
-  public void ignore() throws Exception {
-    TestAccount user2 = accountCreator.user2();
-
-    PushOneCommit.Result r = createChange();
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    in = new AddReviewerInput();
-    in.reviewer = user2.email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    setApiUser(user);
-    gApi.changes().id(r.getChangeId()).ignore(true);
-    assertThat(gApi.changes().id(r.getChangeId()).ignored()).isTrue();
-
-    sender.clear();
-    setApiUser(admin);
-    gApi.changes().id(r.getChangeId()).abandon();
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(user2.emailAddress);
-
-    setApiUser(user);
-    gApi.changes().id(r.getChangeId()).ignore(false);
-    assertThat(gApi.changes().id(r.getChangeId()).ignored()).isFalse();
-  }
-
-  @Test
-  public void cannotIgnoreOwnChange() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot ignore own change");
-    gApi.changes().id(changeId).ignore(true);
-  }
-
-  @Test
-  public void cannotIgnoreStarredChange() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    setApiUser(user);
-    gApi.accounts().self().starChange(changeId);
-    assertThat(gApi.changes().id(changeId).get().starred).isTrue();
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.DEFAULT_LABEL
-            + " and "
-            + StarredChangesUtil.IGNORE_LABEL
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.changes().id(changeId).ignore(true);
-  }
-
-  @Test
-  public void cannotStarIgnoredChange() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    setApiUser(user);
-    gApi.changes().id(changeId).ignore(true);
-    assertThat(gApi.changes().id(changeId).ignored()).isTrue();
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.DEFAULT_LABEL
-            + " and "
-            + StarredChangesUtil.IGNORE_LABEL
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.accounts().self().starChange(changeId);
-  }
-
-  @Test
-  public void markAsReviewed() throws Exception {
-    TestAccount user2 = accountCreator.user2();
-
-    PushOneCommit.Result r = createChange();
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    setApiUser(user);
-    assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isNull();
-    gApi.changes().id(r.getChangeId()).markAsReviewed(true);
-    assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isTrue();
-
-    setApiUser(user2);
-    sender.clear();
-    amendChange(r.getChangeId());
-
-    setApiUser(user);
-    assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isNull();
-
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(user.emailAddress);
-  }
-
-  @Test
-  public void cannotSetUnreviewedLabelForPatchSetThatAlreadyHasReviewedLabel() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    setApiUser(user);
-    gApi.changes().id(changeId).markAsReviewed(true);
-    assertThat(gApi.changes().id(changeId).get().reviewed).isTrue();
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.REVIEWED_LABEL
-            + "/"
-            + 1
-            + " and "
-            + StarredChangesUtil.UNREVIEWED_LABEL
-            + "/"
-            + 1
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.accounts()
-        .self()
-        .setStars(
-            changeId, new StarsInput(ImmutableSet.of(StarredChangesUtil.UNREVIEWED_LABEL + "/1")));
-  }
-
-  @Test
-  public void cannotSetReviewedLabelForPatchSetThatAlreadyHasUnreviewedLabel() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    setApiUser(user);
-    gApi.changes().id(changeId).markAsReviewed(false);
-    assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "The labels "
-            + StarredChangesUtil.REVIEWED_LABEL
-            + "/"
-            + 1
-            + " and "
-            + StarredChangesUtil.UNREVIEWED_LABEL
-            + "/"
-            + 1
-            + " are mutually exclusive. Only one of them can be set.");
-    gApi.accounts()
-        .self()
-        .setStars(
-            changeId, new StarsInput(ImmutableSet.of(StarredChangesUtil.REVIEWED_LABEL + "/1")));
-  }
-
-  @Test
-  public void setReviewedAndUnreviewedLabelsForDifferentPatchSets() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    setApiUser(user);
-    gApi.changes().id(changeId).markAsReviewed(true);
-    assertThat(gApi.changes().id(changeId).get().reviewed).isTrue();
-
-    amendChange(changeId);
-    assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
-
-    gApi.changes().id(changeId).markAsReviewed(false);
-    assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
-
-    assertThat(gApi.accounts().self().getStars(changeId))
-        .containsExactly(
-            StarredChangesUtil.REVIEWED_LABEL + "/" + 1,
-            StarredChangesUtil.UNREVIEWED_LABEL + "/" + 2);
-  }
-
-  @Test
-  public void cannotSetInvalidLabel() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    // label cannot contain whitespace
-    String invalidLabel = "invalid label";
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid labels: " + invalidLabel);
-    gApi.accounts().self().setStars(changeId, new StarsInput(ImmutableSet.of(invalidLabel)));
-  }
-
-  private static class ChangeIndexedCounter implements ChangeIndexedListener {
-    private final AtomicLongMap<Integer> countsByChange = AtomicLongMap.create();
-
-    @Override
-    public void onChangeIndexed(int id) {
-      countsByChange.incrementAndGet(id);
-    }
-
-    @Override
-    public void onChangeDeleted(int id) {
-      countsByChange.incrementAndGet(id);
-    }
-
-    void clear() {
-      countsByChange.clear();
-    }
-
-    long getCount(ChangeInfo info) {
-      return countsByChange.get(info._number);
-    }
-
-    void assertReindexOf(ChangeInfo info) {
-      assertReindexOf(info, 1);
-    }
-
-    void assertReindexOf(ChangeInfo info, int expectedCount) {
-      assertThat(getCount(info)).isEqualTo(expectedCount);
-      assertThat(countsByChange).hasSize(1);
-      clear();
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIdIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
deleted file mode 100644
index e0fc358..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
+++ /dev/null
@@ -1,122 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.change;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.extensions.api.changes.ChangeApi;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Project;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class ChangeIdIT extends AbstractDaemonTest {
-  private ChangeInfo changeInfo;
-
-  @Before
-  public void setup() throws Exception {
-    changeInfo = gApi.changes().create(new ChangeInput(project.get(), "master", "msg")).get();
-  }
-
-  @Test
-  public void projectChangeNumberReturnsChange() throws Exception {
-    ChangeApi cApi = gApi.changes().id(project.get(), changeInfo._number);
-    assertThat(cApi.get().changeId).isEqualTo(changeInfo.changeId);
-  }
-
-  @Test
-  public void projectChangeNumberReturnsChangeWhenProjectContainsSlashes() throws Exception {
-    Project.NameKey p = createProject("foo/bar");
-    ChangeInfo ci = gApi.changes().create(new ChangeInput(p.get(), "master", "msg")).get();
-    ChangeApi cApi = gApi.changes().id(p.get(), ci._number);
-    assertThat(cApi.get().changeId).isEqualTo(ci.changeId);
-  }
-
-  @Test
-  public void wrongProjectInProjectChangeNumberReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: unknown~" + changeInfo._number);
-    gApi.changes().id("unknown", changeInfo._number);
-  }
-
-  @Test
-  public void wrongIdInProjectChangeNumberReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + project.get() + "~" + Integer.MAX_VALUE);
-    gApi.changes().id(project.get(), Integer.MAX_VALUE);
-  }
-
-  @Test
-  public void changeNumberReturnsChange() throws Exception {
-    ChangeApi cApi = gApi.changes().id(changeInfo._number);
-    assertThat(cApi.get().changeId).isEqualTo(changeInfo.changeId);
-  }
-
-  @Test
-  public void wrongChangeNumberReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(Integer.MAX_VALUE);
-  }
-
-  @Test
-  public void tripletChangeIdReturnsChange() throws Exception {
-    ChangeApi cApi = gApi.changes().id(project.get(), changeInfo.branch, changeInfo.changeId);
-    assertThat(cApi.get().changeId).isEqualTo(changeInfo.changeId);
-  }
-
-  @Test
-  public void wrongProjectInTripletChangeIdReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: unknown~" + changeInfo.branch + "~" + changeInfo.changeId);
-    gApi.changes().id("unknown", changeInfo.branch, changeInfo.changeId);
-  }
-
-  @Test
-  public void wrongBranchInTripletChangeIdReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + project.get() + "~unknown~" + changeInfo.changeId);
-    gApi.changes().id(project.get(), "unknown", changeInfo.changeId);
-  }
-
-  @Test
-  public void wrongIdInTripletChangeIdReturnsNotFound() throws Exception {
-    String unknownId = "I1234567890";
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage(
-        "Not found: " + project.get() + "~" + changeInfo.branch + "~" + unknownId);
-    gApi.changes().id(project.get(), changeInfo.branch, unknownId);
-  }
-
-  @Test
-  public void changeIdReturnsChange() throws Exception {
-    // ChangeId is not unique and this method needs a unique changeId to work.
-    // Hence we generate a new change with a different content.
-    ChangeInfo ci =
-        gApi.changes().create(new ChangeInput(project.get(), "master", "different message")).get();
-    ChangeApi cApi = gApi.changes().id(ci.changeId);
-    assertThat(cApi.get()._number).isEqualTo(ci._number);
-  }
-
-  @Test
-  public void wrongChangeIdReturnsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id("I1234567890");
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
deleted file mode 100644
index 28933ad..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ /dev/null
@@ -1,582 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.extensions.client.ChangeKind.MERGE_FIRST_PARENT_UPDATE;
-import static com.google.gerrit.extensions.client.ChangeKind.NO_CHANGE;
-import static com.google.gerrit.extensions.client.ChangeKind.NO_CODE_CHANGE;
-import static com.google.gerrit.extensions.client.ChangeKind.REWORK;
-import static com.google.gerrit.extensions.client.ChangeKind.TRIVIAL_REBASE;
-import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
-import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.Util.category;
-import static com.google.gerrit.server.project.Util.value;
-import static org.eclipse.jgit.lib.Constants.HEAD;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.RevisionApi;
-import com.google.gerrit.extensions.client.ChangeKind;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.Util;
-import java.util.EnumSet;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class StickyApprovalsIT extends AbstractDaemonTest {
-  @Before
-  public void setup() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-
-    // Overwrite "Code-Review" label that is inherited from All-Projects.
-    // This way changes to the "Code Review" label don't affect other tests.
-    LabelType codeReview =
-        category(
-            "Code-Review",
-            value(2, "Looks good to me, approved"),
-            value(1, "Looks good to me, but someone else must approve"),
-            value(0, "No score"),
-            value(-1, "I would prefer that you didn't submit this"),
-            value(-2, "Do not submit"));
-    codeReview.setCopyAllScoresIfNoChange(false);
-    cfg.getLabelSections().put(codeReview.getName(), codeReview);
-
-    LabelType verified =
-        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
-    verified.setCopyAllScoresIfNoChange(false);
-    cfg.getLabelSections().put(verified.getName(), verified);
-
-    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    String heads = RefNames.REFS_HEADS + "*";
-    Util.allow(
-        cfg, Permission.forLabel(Util.codeReview().getName()), -2, 2, registeredUsers, heads);
-    Util.allow(cfg, Permission.forLabel(Util.verified().getName()), -1, 1, registeredUsers, heads);
-    saveProjectConfig(project, cfg);
-  }
-
-  @Test
-  public void notSticky() throws Exception {
-    assertNotSticky(
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE));
-  }
-
-  @Test
-  public void stickyOnMinScore() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review").setCopyMinScore(true);
-    saveProjectConfig(project, cfg);
-
-    for (ChangeKind changeKind :
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(getRemoteHead());
-
-      String changeId = createChange(changeKind);
-      vote(admin, changeId, -1, 1);
-      vote(user, changeId, -2, -1);
-
-      updateChange(changeId, changeKind);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 0, 0, changeKind);
-      assertVotes(c, user, -2, 0, changeKind);
-    }
-  }
-
-  @Test
-  public void stickyOnMaxScore() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review").setCopyMaxScore(true);
-    saveProjectConfig(project, cfg);
-
-    for (ChangeKind changeKind :
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(getRemoteHead());
-
-      String changeId = createChange(changeKind);
-      vote(admin, changeId, 2, 1);
-      vote(user, changeId, 1, -1);
-
-      updateChange(changeId, changeKind);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 2, 0, changeKind);
-      assertVotes(c, user, 0, 0, changeKind);
-    }
-  }
-
-  @Test
-  public void stickyOnTrivialRebase() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
-    saveProjectConfig(project, cfg);
-
-    String changeId = createChange(TRIVIAL_REBASE);
-    vote(admin, changeId, 2, 1);
-    vote(user, changeId, -2, -1);
-
-    updateChange(changeId, NO_CHANGE);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, NO_CHANGE);
-    assertVotes(c, user, -2, 0, NO_CHANGE);
-
-    updateChange(changeId, TRIVIAL_REBASE);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, TRIVIAL_REBASE);
-    assertVotes(c, user, -2, 0, TRIVIAL_REBASE);
-
-    assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE));
-
-    // check that votes are sticky when trivial rebase is done by cherry-pick
-    testRepo.reset(getRemoteHead());
-    changeId = createChange().getChangeId();
-    vote(admin, changeId, 2, 1);
-    vote(user, changeId, -2, -1);
-
-    String cherryPickChangeId = cherryPick(changeId, TRIVIAL_REBASE);
-    c = detailedChange(cherryPickChangeId);
-    assertVotes(c, admin, 2, 0);
-    assertVotes(c, user, -2, 0);
-
-    // check that votes are not sticky when rework is done by cherry-pick
-    testRepo.reset(getRemoteHead());
-    changeId = createChange().getChangeId();
-    vote(admin, changeId, 2, 1);
-    vote(user, changeId, -2, -1);
-
-    cherryPickChangeId = cherryPick(changeId, REWORK);
-    c = detailedChange(cherryPickChangeId);
-    assertVotes(c, admin, 0, 0);
-    assertVotes(c, user, 0, 0);
-  }
-
-  @Test
-  public void stickyOnNoCodeChange() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
-    saveProjectConfig(project, cfg);
-
-    String changeId = createChange(NO_CODE_CHANGE);
-    vote(admin, changeId, 2, 1);
-    vote(user, changeId, -2, -1);
-
-    updateChange(changeId, NO_CHANGE);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 0, 1, NO_CHANGE);
-    assertVotes(c, user, 0, -1, NO_CHANGE);
-
-    updateChange(changeId, NO_CODE_CHANGE);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 0, 1, NO_CODE_CHANGE);
-    assertVotes(c, user, 0, -1, NO_CODE_CHANGE);
-
-    assertNotSticky(EnumSet.of(REWORK, TRIVIAL_REBASE, MERGE_FIRST_PARENT_UPDATE));
-  }
-
-  @Test
-  public void stickyOnMergeFirstParentUpdate() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review").setCopyAllScoresOnMergeFirstParentUpdate(true);
-    saveProjectConfig(project, cfg);
-
-    String changeId = createChange(MERGE_FIRST_PARENT_UPDATE);
-    vote(admin, changeId, 2, 1);
-    vote(user, changeId, -2, -1);
-
-    updateChange(changeId, NO_CHANGE);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, NO_CHANGE);
-    assertVotes(c, user, -2, 0, NO_CHANGE);
-
-    updateChange(changeId, MERGE_FIRST_PARENT_UPDATE);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, MERGE_FIRST_PARENT_UPDATE);
-    assertVotes(c, user, -2, 0, MERGE_FIRST_PARENT_UPDATE);
-
-    assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, TRIVIAL_REBASE));
-  }
-
-  @Test
-  public void removedVotesNotSticky() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
-    cfg.getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
-    saveProjectConfig(project, cfg);
-
-    for (ChangeKind changeKind :
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(getRemoteHead());
-
-      String changeId = createChange(changeKind);
-      vote(admin, changeId, 2, 1);
-      vote(user, changeId, -2, -1);
-
-      // Remove votes by re-voting with 0
-      vote(admin, changeId, 0, 0);
-      vote(user, changeId, 0, 0);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 0, 0, null);
-      assertVotes(c, user, 0, 0, null);
-
-      updateChange(changeId, changeKind);
-      c = detailedChange(changeId);
-      assertVotes(c, admin, 0, 0, changeKind);
-      assertVotes(c, user, 0, 0, changeKind);
-    }
-  }
-
-  @Test
-  public void stickyAcrossMultiplePatchSets() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review").setCopyMaxScore(true);
-    cfg.getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
-    saveProjectConfig(project, cfg);
-
-    String changeId = createChange(REWORK);
-    vote(admin, changeId, 2, 1);
-
-    for (int i = 0; i < 5; i++) {
-      updateChange(changeId, NO_CODE_CHANGE);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 2, 1, NO_CODE_CHANGE);
-    }
-
-    updateChange(changeId, REWORK);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, REWORK);
-  }
-
-  @Test
-  public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review").setCopyMaxScore(true);
-    cfg.getLabelSections().get("Code-Review").setCopyMinScore(true);
-    saveProjectConfig(project, cfg);
-
-    // Vote max score on PS1
-    String changeId = createChange(REWORK);
-    vote(admin, changeId, 2, 1);
-
-    // Have someone else vote min score on PS2
-    updateChange(changeId, REWORK);
-    vote(user, changeId, -2, 0);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, REWORK);
-    assertVotes(c, user, -2, 0, REWORK);
-
-    // No vote changes on PS3
-    updateChange(changeId, REWORK);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, REWORK);
-    assertVotes(c, user, -2, 0, REWORK);
-
-    // Both users revote on PS4
-    updateChange(changeId, REWORK);
-    vote(admin, changeId, 1, 1);
-    vote(user, changeId, 1, 1);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 1, 1, REWORK);
-    assertVotes(c, user, 1, 1, REWORK);
-
-    // New approvals shouldn't carry through to PS5
-    updateChange(changeId, REWORK);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 0, 0, REWORK);
-    assertVotes(c, user, 0, 0, REWORK);
-  }
-
-  @Test
-  public void deleteStickyVote() throws Exception {
-    String label = "Code-Review";
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get(label).setCopyMaxScore(true);
-    saveProjectConfig(project, cfg);
-
-    // Vote max score on PS1
-    String changeId = createChange(REWORK);
-    vote(admin, changeId, label, 2);
-    assertVotes(detailedChange(changeId), admin, label, 2, null);
-    updateChange(changeId, REWORK);
-    assertVotes(detailedChange(changeId), admin, label, 2, REWORK);
-
-    // Delete vote that was copied via sticky approval
-    deleteVote(admin, changeId, "Code-Review");
-    assertVotes(detailedChange(changeId), admin, label, 0, REWORK);
-  }
-
-  private ChangeInfo detailedChange(String changeId) throws Exception {
-    return gApi.changes().id(changeId).get(DETAILED_LABELS, CURRENT_REVISION, CURRENT_COMMIT);
-  }
-
-  private void assertNotSticky(Set<ChangeKind> changeKinds) throws Exception {
-    for (ChangeKind changeKind : changeKinds) {
-      testRepo.reset(getRemoteHead());
-
-      String changeId = createChange(changeKind);
-      vote(admin, changeId, +2, 1);
-      vote(user, changeId, -2, -1);
-
-      updateChange(changeId, changeKind);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 0, 0, changeKind);
-      assertVotes(c, user, 0, 0, changeKind);
-    }
-  }
-
-  private String createChange(ChangeKind kind) throws Exception {
-    switch (kind) {
-      case NO_CODE_CHANGE:
-      case REWORK:
-      case TRIVIAL_REBASE:
-      case NO_CHANGE:
-        return createChange().getChangeId();
-      case MERGE_FIRST_PARENT_UPDATE:
-        return createChangeForMergeCommit();
-      default:
-        throw new IllegalStateException("unexpected change kind: " + kind);
-    }
-  }
-
-  private void updateChange(String changeId, ChangeKind changeKind) throws Exception {
-    switch (changeKind) {
-      case NO_CODE_CHANGE:
-        noCodeChange(changeId);
-        return;
-      case REWORK:
-        rework(changeId);
-        return;
-      case TRIVIAL_REBASE:
-        trivialRebase(changeId);
-        return;
-      case MERGE_FIRST_PARENT_UPDATE:
-        updateFirstParent(changeId);
-        return;
-      case NO_CHANGE:
-        noChange(changeId);
-        return;
-      default:
-        fail("unexpected change kind: " + changeKind);
-    }
-  }
-
-  private void noCodeChange(String changeId) throws Exception {
-    TestRepository<?>.CommitBuilder commitBuilder =
-        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
-    commitBuilder
-        .message("New subject " + System.nanoTime())
-        .author(admin.getIdent())
-        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
-    commitBuilder.create();
-    GitUtil.pushHead(testRepo, "refs/for/master", false);
-    assertThat(getChangeKind(changeId)).isEqualTo(NO_CODE_CHANGE);
-  }
-
-  private void noChange(String changeId) throws Exception {
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    String commitMessage = change.revisions.get(change.currentRevision).commit.message;
-
-    TestRepository<?>.CommitBuilder commitBuilder =
-        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
-    commitBuilder
-        .message(commitMessage)
-        .author(admin.getIdent())
-        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
-    commitBuilder.create();
-    GitUtil.pushHead(testRepo, "refs/for/master", false);
-    assertThat(getChangeKind(changeId)).isEqualTo(NO_CHANGE);
-  }
-
-  private void rework(String changeId) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            "new content " + System.nanoTime(),
-            changeId);
-    push.to("refs/for/master").assertOkStatus();
-    assertThat(getChangeKind(changeId)).isEqualTo(REWORK);
-  }
-
-  private void trivialRebase(String changeId) throws Exception {
-    setApiUser(admin);
-    testRepo.reset(getRemoteHead());
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            "Other Change",
-            "a" + System.nanoTime() + ".txt",
-            PushOneCommit.FILE_CONTENT);
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    ReviewInput in = new ReviewInput().label("Code-Review", 2).label("Verified", 1);
-    revision.review(in);
-    revision.submit();
-
-    gApi.changes().id(changeId).current().rebase();
-    assertThat(getChangeKind(changeId)).isEqualTo(TRIVIAL_REBASE);
-  }
-
-  private String createChangeForMergeCommit() throws Exception {
-    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-
-    PushOneCommit.Result parent1 = createChange("parent 1", "p1.txt", "content 1");
-
-    testRepo.reset(initial);
-    PushOneCommit.Result parent2 = createChange("parent 2", "p2.txt", "content 2");
-
-    testRepo.reset(parent1.getCommit());
-
-    PushOneCommit merge = pushFactory.create(db, admin.getIdent(), testRepo);
-    merge.setParents(ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
-    PushOneCommit.Result result = merge.to("refs/for/master");
-    result.assertOkStatus();
-    return result.getChangeId();
-  }
-
-  private void updateFirstParent(String changeId) throws Exception {
-    ChangeInfo c = detailedChange(changeId);
-    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
-    String parent1 = parents.get(0).commit;
-    String parent2 = parents.get(1).commit;
-    RevCommit commitParent2 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent2));
-
-    testRepo.reset(parent1);
-    PushOneCommit.Result newParent1 = createChange("new parent 1", "p1-1.txt", "content 1-1");
-
-    PushOneCommit merge = pushFactory.create(db, admin.getIdent(), testRepo, changeId);
-    merge.setParents(ImmutableList.of(newParent1.getCommit(), commitParent2));
-    PushOneCommit.Result result = merge.to("refs/for/master");
-    result.assertOkStatus();
-
-    assertThat(getChangeKind(changeId)).isEqualTo(MERGE_FIRST_PARENT_UPDATE);
-  }
-
-  private String cherryPick(String changeId, ChangeKind changeKind) throws Exception {
-    switch (changeKind) {
-      case REWORK:
-      case TRIVIAL_REBASE:
-        break;
-      case NO_CODE_CHANGE:
-      case NO_CHANGE:
-      case MERGE_FIRST_PARENT_UPDATE:
-      default:
-        fail("unexpected change kind: " + changeKind);
-    }
-
-    testRepo.reset(getRemoteHead());
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                db,
-                admin.getIdent(),
-                testRepo,
-                PushOneCommit.SUBJECT,
-                "other.txt",
-                "new content " + System.nanoTime())
-            .to("refs/for/master");
-    r.assertOkStatus();
-    vote(admin, r.getChangeId(), 2, 1);
-    merge(r);
-
-    String subject =
-        TRIVIAL_REBASE.equals(changeKind)
-            ? PushOneCommit.SUBJECT
-            : "Reworked change " + System.nanoTime();
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "master";
-    in.message = String.format("%s\n\nChange-Id: %s", subject, changeId);
-    ChangeInfo c = gApi.changes().id(changeId).revision("current").cherryPick(in).get();
-    return c.changeId;
-  }
-
-  private ChangeKind getChangeKind(String changeId) throws Exception {
-    ChangeInfo c = gApi.changes().id(changeId).get(CURRENT_REVISION);
-    return c.revisions.get(c.currentRevision).kind;
-  }
-
-  private void vote(TestAccount user, String changeId, String label, int vote) throws Exception {
-    setApiUser(user);
-    gApi.changes().id(changeId).current().review(new ReviewInput().label(label, vote));
-  }
-
-  private void vote(TestAccount user, String changeId, int codeReviewVote, int verifiedVote)
-      throws Exception {
-    setApiUser(user);
-    ReviewInput in =
-        new ReviewInput().label("Code-Review", codeReviewVote).label("Verified", verifiedVote);
-    gApi.changes().id(changeId).current().review(in);
-  }
-
-  private void deleteVote(TestAccount user, String changeId, String label) throws Exception {
-    setApiUser(user);
-    gApi.changes().id(changeId).reviewer(user.getId().toString()).deleteVote(label);
-  }
-
-  private void assertVotes(ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote) {
-    assertVotes(c, user, codeReviewVote, verifiedVote, null);
-  }
-
-  private void assertVotes(
-      ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote, ChangeKind changeKind) {
-    assertVotes(c, user, "Code-Review", codeReviewVote, changeKind);
-    assertVotes(c, user, "Verified", verifiedVote, changeKind);
-  }
-
-  private void assertVotes(
-      ChangeInfo c, TestAccount user, String label, int expectedVote, ChangeKind changeKind) {
-    Integer vote = 0;
-    if (c.labels.get(label) != null && c.labels.get(label).all != null) {
-      for (ApprovalInfo approval : c.labels.get(label).all) {
-        if (approval._accountId == user.id.get()) {
-          vote = approval.value;
-          break;
-        }
-      }
-    }
-
-    String name = "label = " + label;
-    if (changeKind != null) {
-      name += "; changeKind = " + changeKind.name();
-    }
-    assertThat(vote).named(name).isEqualTo(expectedVote);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
deleted file mode 100644
index 6036dc5..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
+++ /dev/null
@@ -1,271 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.extensions.client.SubmitType.CHERRY_PICK;
-import static com.google.gerrit.extensions.client.SubmitType.FAST_FORWARD_ONLY;
-import static com.google.gerrit.extensions.client.SubmitType.MERGE_ALWAYS;
-import static com.google.gerrit.extensions.client.SubmitType.MERGE_IF_NECESSARY;
-import static com.google.gerrit.extensions.client.SubmitType.REBASE_ALWAYS;
-import static com.google.gerrit.extensions.client.SubmitType.REBASE_IF_NECESSARY;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.TestSubmitRuleInput;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.VersionedMetaData;
-import com.google.gerrit.testutil.ConfigSuite;
-import java.io.IOException;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicInteger;
-import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class SubmitTypeRuleIT extends AbstractDaemonTest {
-  @ConfigSuite.Default
-  public static Config submitWholeTopicEnabled() {
-    return submitWholeTopicEnabledConfig();
-  }
-
-  private class RulesPl extends VersionedMetaData {
-    private static final String FILENAME = "rules.pl";
-
-    private String rule;
-
-    @Override
-    protected String getRefName() {
-      return RefNames.REFS_CONFIG;
-    }
-
-    @Override
-    protected void onLoad() throws IOException, ConfigInvalidException {
-      rule = readUTF8(FILENAME);
-    }
-
-    @Override
-    protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
-      TestSubmitRuleInput in = new TestSubmitRuleInput();
-      in.rule = rule;
-      try {
-        gApi.changes().id(testChangeId.get()).current().testSubmitType(in);
-      } catch (RestApiException e) {
-        throw new ConfigInvalidException("Invalid submit type rule", e);
-      }
-
-      saveUTF8(FILENAME, rule);
-      return true;
-    }
-  }
-
-  private AtomicInteger fileCounter;
-  private Change.Id testChangeId;
-
-  @Before
-  public void setUp() throws Exception {
-    fileCounter = new AtomicInteger();
-    gApi.projects().name(project.get()).branch("test").create(new BranchInput());
-    testChangeId = createChange("test", "test change").getChange().getId();
-  }
-
-  private void setRulesPl(String rule) throws Exception {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      RulesPl r = new RulesPl();
-      r.load(md);
-      r.rule = rule;
-      r.commit(md);
-    }
-  }
-
-  private static final String SUBMIT_TYPE_FROM_SUBJECT =
-      "submit_type(fast_forward_only) :-"
-          + "gerrit:commit_message(M),"
-          + "regex_matches('.*FAST_FORWARD_ONLY.*', M),"
-          + "!.\n"
-          + "submit_type(merge_if_necessary) :-"
-          + "gerrit:commit_message(M),"
-          + "regex_matches('.*MERGE_IF_NECESSARY.*', M),"
-          + "!.\n"
-          + "submit_type(rebase_if_necessary) :-"
-          + "gerrit:commit_message(M),"
-          + "regex_matches('.*REBASE_IF_NECESSARY.*', M),"
-          + "!.\n"
-          + "submit_type(rebase_always) :-"
-          + "gerrit:commit_message(M),"
-          + "regex_matches('.*REBASE_ALWAYS.*', M),"
-          + "!.\n"
-          + "submit_type(merge_always) :-"
-          + "gerrit:commit_message(M),"
-          + "regex_matches('.*MERGE_ALWAYS.*', M),"
-          + "!.\n"
-          + "submit_type(cherry_pick) :-"
-          + "gerrit:commit_message(M),"
-          + "regex_matches('.*CHERRY_PICK.*', M),"
-          + "!.\n"
-          + "submit_type(T) :- gerrit:project_default_submit_type(T).";
-
-  private PushOneCommit.Result createChange(String dest, String subject) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            subject,
-            "file" + fileCounter.incrementAndGet(),
-            PushOneCommit.FILE_CONTENT);
-    PushOneCommit.Result r = push.to("refs/for/" + dest);
-    r.assertOkStatus();
-    return r;
-  }
-
-  @Test
-  public void unconditionalCherryPick() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertSubmitType(MERGE_IF_NECESSARY, r.getChangeId());
-    setRulesPl("submit_type(cherry_pick).");
-    assertSubmitType(CHERRY_PICK, r.getChangeId());
-  }
-
-  @Test
-  public void submitTypeFromSubject() throws Exception {
-    PushOneCommit.Result r1 = createChange("master", "Default 1");
-    PushOneCommit.Result r2 = createChange("master", "FAST_FORWARD_ONLY 2");
-    PushOneCommit.Result r3 = createChange("master", "MERGE_IF_NECESSARY 3");
-    PushOneCommit.Result r4 = createChange("master", "REBASE_IF_NECESSARY 4");
-    PushOneCommit.Result r5 = createChange("master", "REBASE_ALWAYS 5");
-    PushOneCommit.Result r6 = createChange("master", "MERGE_ALWAYS 6");
-    PushOneCommit.Result r7 = createChange("master", "CHERRY_PICK 7");
-
-    assertSubmitType(MERGE_IF_NECESSARY, r1.getChangeId());
-    assertSubmitType(MERGE_IF_NECESSARY, r2.getChangeId());
-    assertSubmitType(MERGE_IF_NECESSARY, r3.getChangeId());
-    assertSubmitType(MERGE_IF_NECESSARY, r4.getChangeId());
-    assertSubmitType(MERGE_IF_NECESSARY, r5.getChangeId());
-    assertSubmitType(MERGE_IF_NECESSARY, r6.getChangeId());
-    assertSubmitType(MERGE_IF_NECESSARY, r7.getChangeId());
-
-    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
-
-    assertSubmitType(MERGE_IF_NECESSARY, r1.getChangeId());
-    assertSubmitType(FAST_FORWARD_ONLY, r2.getChangeId());
-    assertSubmitType(MERGE_IF_NECESSARY, r3.getChangeId());
-    assertSubmitType(REBASE_IF_NECESSARY, r4.getChangeId());
-    assertSubmitType(REBASE_ALWAYS, r5.getChangeId());
-    assertSubmitType(MERGE_ALWAYS, r6.getChangeId());
-    assertSubmitType(CHERRY_PICK, r7.getChangeId());
-  }
-
-  @Test
-  public void submitTypeIsUsedForSubmit() throws Exception {
-    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
-
-    PushOneCommit.Result r = createChange("master", "CHERRY_PICK 1");
-
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).current().submit();
-
-    List<RevCommit> log = log("master", 1);
-    assertThat(log.get(0).getShortMessage()).isEqualTo("CHERRY_PICK 1");
-    assertThat(log.get(0).name()).isNotEqualTo(r.getCommit().name());
-    assertThat(log.get(0).getFullMessage()).contains("Change-Id: " + r.getChangeId());
-    assertThat(log.get(0).getFullMessage()).contains("Reviewed-on: ");
-  }
-
-  @Test
-  public void mixingSubmitTypesAcrossBranchesSucceeds() throws Exception {
-    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
-
-    PushOneCommit.Result r1 = createChange("master", "MERGE_IF_NECESSARY 1");
-
-    RevCommit initialCommit = r1.getCommit().getParent(0);
-    BranchInput bin = new BranchInput();
-    bin.revision = initialCommit.name();
-    gApi.projects().name(project.get()).branch("branch").create(bin);
-
-    testRepo.reset(initialCommit);
-    PushOneCommit.Result r2 = createChange("branch", "MERGE_ALWAYS 1");
-
-    gApi.changes().id(r1.getChangeId()).topic(name("topic"));
-    gApi.changes().id(r1.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r2.getChangeId()).topic(name("topic"));
-    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r2.getChangeId()).current().submit();
-
-    assertThat(log("master", 1).get(0).name()).isEqualTo(r1.getCommit().name());
-
-    List<RevCommit> branchLog = log("branch", 1);
-    assertThat(branchLog.get(0).getParents()).hasLength(2);
-    assertThat(branchLog.get(0).getParent(1).name()).isEqualTo(r2.getCommit().name());
-  }
-
-  @Test
-  public void mixingSubmitTypesOnOneBranchFails() throws Exception {
-    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
-
-    PushOneCommit.Result r1 = createChange("master", "CHERRY_PICK 1");
-    PushOneCommit.Result r2 = createChange("master", "MERGE_IF_NECESSARY 2");
-
-    gApi.changes().id(r1.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.approve());
-
-    try {
-      gApi.changes().id(r2.getChangeId()).current().submit();
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              "Failed to submit 2 changes due to the following problems:\n"
-                  + "Change "
-                  + r1.getChange().getId()
-                  + ": Change has submit type "
-                  + "CHERRY_PICK, but previously chose submit type MERGE_IF_NECESSARY "
-                  + "from change "
-                  + r2.getChange().getId()
-                  + " in the same batch");
-    }
-  }
-
-  private List<RevCommit> log(String commitish, int n) throws Exception {
-    try (Repository repo = repoManager.openRepository(project);
-        Git git = new Git(repo)) {
-      ObjectId id = repo.resolve(commitish);
-      assertThat(id).isNotNull();
-      return ImmutableList.copyOf(git.log().add(id).setMaxCount(n).call());
-    }
-  }
-
-  private void assertSubmitType(SubmitType expected, String id) throws Exception {
-    assertThat(gApi.changes().id(id).current().submitType()).isEqualTo(expected);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUILD
deleted file mode 100644
index 6d39131..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUILD
+++ /dev/null
@@ -1,7 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "api_config",
-    labels = ["api"],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
deleted file mode 100644
index 54b2a47..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.config;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.inject.Inject;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Test;
-
-@NoHttpd
-public class GeneralPreferencesIT extends AbstractDaemonTest {
-  @Inject private AllUsersName allUsers;
-
-  @After
-  public void cleanUp() throws Exception {
-    try (Repository git = repoManager.openRepository(allUsers)) {
-      if (git.exactRef(RefNames.REFS_USERS_DEFAULT) != null) {
-        RefUpdate u = git.updateRef(RefNames.REFS_USERS_DEFAULT);
-        u.setForceUpdate(true);
-        assertThat(u.delete()).isEqualTo(RefUpdate.Result.FORCED);
-      }
-    }
-    accountCache.evictAllNoReindex();
-  }
-
-  @Test
-  public void getGeneralPreferences() throws Exception {
-    GeneralPreferencesInfo result = gApi.config().server().getDefaultPreferences();
-    assertPrefs(result, GeneralPreferencesInfo.defaults(), "my");
-  }
-
-  @Test
-  public void setGeneralPreferences() throws Exception {
-    boolean newSignedOffBy = !GeneralPreferencesInfo.defaults().signedOffBy;
-    GeneralPreferencesInfo update = new GeneralPreferencesInfo();
-    update.signedOffBy = newSignedOffBy;
-    GeneralPreferencesInfo result = gApi.config().server().setDefaultPreferences(update);
-    assertThat(result.signedOffBy).named("signedOffBy").isEqualTo(newSignedOffBy);
-
-    result = gApi.config().server().getDefaultPreferences();
-    GeneralPreferencesInfo expected = GeneralPreferencesInfo.defaults();
-    expected.signedOffBy = newSignedOffBy;
-    assertPrefs(result, expected, "my");
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUILD
deleted file mode 100644
index 1b90776..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUILD
+++ /dev/null
@@ -1,23 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "api_group",
-    labels = ["api"],
-    deps = [
-        ":util",
-        "//gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:util",
-    ],
-)
-
-java_library(
-    name = "util",
-    srcs = ["GroupAssert.java"],
-    deps = [
-        "//gerrit-extension-api:api",
-        "//gerrit-reviewdb:server",
-        "//gerrit-server:server",
-        "//lib:gwtorm",
-        "//lib:truth",
-    ],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java
deleted file mode 100644
index 305a2b0..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ /dev/null
@@ -1,705 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.group;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.api.group.GroupAssert.assertGroupInfo;
-import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfos;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static java.util.stream.Collectors.toList;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.extensions.api.groups.GroupApi;
-import com.google.gerrit.extensions.api.groups.GroupInput;
-import com.google.gerrit.extensions.api.groups.Groups.ListRequest;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo.GroupMemberAuditEventInfo;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo.Type;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo.UserMemberAuditEventInfo;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.common.GroupOptionsInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.group.Groups;
-import com.google.gerrit.server.group.GroupsUpdate;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.ServerInitiated;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import org.junit.Test;
-
-@NoHttpd
-public class GroupsIT extends AbstractDaemonTest {
-  @Inject @ServerInitiated private Provider<GroupsUpdate> groupsUpdateProvider;
-  @Inject private Groups groups;
-
-  @Test
-  public void systemGroupCanBeRetrievedFromIndex() throws Exception {
-    List<GroupInfo> groupInfos = gApi.groups().query("name:Administrators").get();
-    assertThat(groupInfos).isNotEmpty();
-  }
-
-  @Test
-  public void addToNonExistingGroup_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").addMembers("admin");
-  }
-
-  @Test
-  public void removeFromNonExistingGroup_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").removeMembers("admin");
-  }
-
-  @Test
-  public void addRemoveMember() throws Exception {
-    String g = createGroup("users");
-    gApi.groups().id(g).addMembers("user");
-    assertMembers(g, user);
-
-    gApi.groups().id(g).removeMembers("user");
-    assertNoMembers(g);
-  }
-
-  @Test
-  public void addExistingMember_OK() throws Exception {
-    String g = "Administrators";
-    assertMembers(g, admin);
-    gApi.groups().id("Administrators").addMembers("admin");
-    assertMembers(g, admin);
-  }
-
-  @Test
-  public void addNonExistingMember_UnprocessableEntity() throws Exception {
-    exception.expect(UnprocessableEntityException.class);
-    gApi.groups().id("Administrators").addMembers("non-existing");
-  }
-
-  @Test
-  public void addMultipleMembers() throws Exception {
-    String g = createGroup("users");
-    TestAccount u1 = accountCreator.create("u1", "u1@example.com", "Full Name 1");
-    TestAccount u2 = accountCreator.create("u2", "u2@example.com", "Full Name 2");
-    gApi.groups().id(g).addMembers(u1.username, u2.username);
-    assertMembers(g, u1, u2);
-  }
-
-  @Test
-  public void addMembersWithAtSign() throws Exception {
-    String g = createGroup("users");
-    TestAccount u10 = accountCreator.create("u10", "u10@example.com", "Full Name 10");
-    TestAccount u11_at =
-        accountCreator.create("u11@something", "u11@example.com", "Full Name 11 With At");
-    accountCreator.create("u11", "u11.another@example.com", "Full Name 11 Without At");
-    gApi.groups().id(g).addMembers(u10.username, u11_at.username);
-    assertMembers(g, u10, u11_at);
-  }
-
-  @Test
-  public void includeRemoveGroup() throws Exception {
-    String p = createGroup("parent");
-    String g = createGroup("newGroup");
-    gApi.groups().id(p).addGroups(g);
-    assertIncludes(p, g);
-
-    gApi.groups().id(p).removeGroups(g);
-    assertNoIncludes(p);
-  }
-
-  @Test
-  public void includeExistingGroup_OK() throws Exception {
-    String p = createGroup("parent");
-    String g = createGroup("newGroup");
-    gApi.groups().id(p).addGroups(g);
-    assertIncludes(p, g);
-    gApi.groups().id(p).addGroups(g);
-    assertIncludes(p, g);
-  }
-
-  @Test
-  public void addMultipleIncludes() throws Exception {
-    String p = createGroup("parent");
-    String g1 = createGroup("newGroup1");
-    String g2 = createGroup("newGroup2");
-    List<String> groups = new ArrayList<>();
-    groups.add(g1);
-    groups.add(g2);
-    gApi.groups().id(p).addGroups(g1, g2);
-    assertIncludes(p, g1, g2);
-  }
-
-  @Test
-  public void createGroup() throws Exception {
-    String newGroupName = name("newGroup");
-    GroupInfo g = gApi.groups().create(newGroupName).get();
-    assertGroupInfo(getFromCache(newGroupName), g);
-  }
-
-  @Test
-  public void createDuplicateInternalGroupCaseSensitiveName_Conflict() throws Exception {
-    String dupGroupName = name("dupGroup");
-    gApi.groups().create(dupGroupName);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group '" + dupGroupName + "' already exists");
-    gApi.groups().create(dupGroupName);
-  }
-
-  @Test
-  public void createDuplicateInternalGroupCaseInsensitiveName() throws Exception {
-    String dupGroupName = name("dupGroupA");
-    String dupGroupNameLowerCase = name("dupGroupA").toLowerCase();
-    gApi.groups().create(dupGroupName);
-    gApi.groups().create(dupGroupNameLowerCase);
-    assertThat(gApi.groups().list().getAsMap().keySet()).contains(dupGroupName);
-    assertThat(gApi.groups().list().getAsMap().keySet()).contains(dupGroupNameLowerCase);
-  }
-
-  @Test
-  public void createDuplicateSystemGroupCaseSensitiveName_Conflict() throws Exception {
-    String newGroupName = "Registered Users";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group 'Registered Users' already exists");
-    gApi.groups().create(newGroupName);
-  }
-
-  @Test
-  public void createDuplicateSystemGroupCaseInsensitiveName_Conflict() throws Exception {
-    String newGroupName = "registered users";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group 'Registered Users' already exists");
-    gApi.groups().create(newGroupName);
-  }
-
-  @Test
-  @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
-  public void createGroupWithConfiguredNameOfSystemGroup_Conflict() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group 'All Users' already exists");
-    gApi.groups().create("all users");
-  }
-
-  @Test
-  @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
-  public void createGroupWithDefaultNameOfSystemGroup_Conflict() throws Exception {
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group name 'Anonymous Users' is reserved");
-    gApi.groups().create("anonymous users");
-  }
-
-  @Test
-  public void createGroupWithProperties() throws Exception {
-    GroupInput in = new GroupInput();
-    in.name = name("newGroup");
-    in.description = "Test description";
-    in.visibleToAll = true;
-    in.ownerId = getFromCache("Administrators").getGroupUUID().get();
-    GroupInfo g = gApi.groups().create(in).detail();
-    assertThat(g.description).isEqualTo(in.description);
-    assertThat(g.options.visibleToAll).isEqualTo(in.visibleToAll);
-    assertThat(g.ownerId).isEqualTo(in.ownerId);
-  }
-
-  @Test
-  public void createGroupWithoutCapability_Forbidden() throws Exception {
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    gApi.groups().create(name("newGroup"));
-  }
-
-  @Test
-  public void createdOnFieldIsPopulatedForNewGroup() throws Exception {
-    Timestamp testStartTime = TimeUtil.nowTs();
-    String newGroupName = name("newGroup");
-    GroupInfo group = gApi.groups().create(newGroupName).get();
-
-    assertThat(group.createdOn).isAtLeast(testStartTime);
-  }
-
-  @Test
-  public void createdOnFieldDefaultsToAuditCreationInstantBeforeSchemaUpgrade() throws Exception {
-    String newGroupName = name("newGroup");
-    GroupInfo newGroup = gApi.groups().create(newGroupName).get();
-    setCreatedOnToNull(new AccountGroup.UUID(newGroup.id));
-
-    GroupInfo updatedGroup = gApi.groups().id(newGroup.id).get();
-    assertThat(updatedGroup.createdOn).isEqualTo(AccountGroup.auditCreationInstantTs());
-  }
-
-  @Test
-  public void getGroup() throws Exception {
-    InternalGroup adminGroup = getFromCache("Administrators");
-    testGetGroup(adminGroup.getGroupUUID().get(), adminGroup);
-    testGetGroup(adminGroup.getName(), adminGroup);
-    testGetGroup(adminGroup.getId().get(), adminGroup);
-  }
-
-  private void testGetGroup(Object id, InternalGroup expectedGroup) throws Exception {
-    GroupInfo group = gApi.groups().id(id.toString()).get();
-    assertGroupInfo(expectedGroup, group);
-  }
-
-  @Test
-  @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
-  public void getSystemGroupByConfiguredName() throws Exception {
-    GroupReference anonymousUsersGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
-    assertThat(anonymousUsersGroup.getName()).isEqualTo("All Users");
-
-    GroupInfo group = gApi.groups().id(anonymousUsersGroup.getUUID().get()).get();
-    assertThat(group.name).isEqualTo(anonymousUsersGroup.getName());
-
-    group = gApi.groups().id(anonymousUsersGroup.getName()).get();
-    assertThat(group.id).isEqualTo(Url.encode((anonymousUsersGroup.getUUID().get())));
-  }
-
-  @Test
-  public void getSystemGroupByDefaultName() throws Exception {
-    GroupReference anonymousUsersGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
-    GroupInfo group = gApi.groups().id("Anonymous Users").get();
-    assertThat(group.name).isEqualTo(anonymousUsersGroup.getName());
-    assertThat(group.id).isEqualTo(Url.encode((anonymousUsersGroup.getUUID().get())));
-  }
-
-  @Test
-  @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
-  public void getSystemGroupByDefaultName_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("Anonymous-Users").get();
-  }
-
-  @Test
-  public void groupName() throws Exception {
-    String name = name("group");
-    gApi.groups().create(name);
-
-    // get name
-    assertThat(gApi.groups().id(name).name()).isEqualTo(name);
-
-    // set name to same name
-    gApi.groups().id(name).name(name);
-    assertThat(gApi.groups().id(name).name()).isEqualTo(name);
-
-    // set name with name conflict
-    String other = name("other");
-    gApi.groups().create(other);
-    exception.expect(ResourceConflictException.class);
-    gApi.groups().id(name).name(other);
-  }
-
-  @Test
-  public void groupRename() throws Exception {
-    String name = name("group");
-    gApi.groups().create(name);
-
-    String newName = name("newName");
-    gApi.groups().id(name).name(newName);
-    assertThat(getFromCache(newName)).isNotNull();
-    assertThat(gApi.groups().id(newName).name()).isEqualTo(newName);
-
-    assertThat(getFromCache(name)).isNull();
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id(name).get();
-  }
-
-  @Test
-  public void groupDescription() throws Exception {
-    String name = name("group");
-    gApi.groups().create(name);
-
-    // get description
-    assertThat(gApi.groups().id(name).description()).isEmpty();
-
-    // set description
-    String desc = "New description for the group.";
-    gApi.groups().id(name).description(desc);
-    assertThat(gApi.groups().id(name).description()).isEqualTo(desc);
-
-    // set description to null
-    gApi.groups().id(name).description(null);
-    assertThat(gApi.groups().id(name).description()).isEmpty();
-
-    // set description to empty string
-    gApi.groups().id(name).description("");
-    assertThat(gApi.groups().id(name).description()).isEmpty();
-  }
-
-  @Test
-  public void groupOptions() throws Exception {
-    String name = name("group");
-    gApi.groups().create(name);
-
-    // get options
-    assertThat(gApi.groups().id(name).options().visibleToAll).isNull();
-
-    // set options
-    GroupOptionsInfo options = new GroupOptionsInfo();
-    options.visibleToAll = true;
-    gApi.groups().id(name).options(options);
-    assertThat(gApi.groups().id(name).options().visibleToAll).isTrue();
-  }
-
-  @Test
-  public void groupOwner() throws Exception {
-    String name = name("group");
-    GroupInfo info = gApi.groups().create(name).get();
-    String adminUUID = getFromCache("Administrators").getGroupUUID().get();
-    String registeredUUID = SystemGroupBackend.REGISTERED_USERS.get();
-
-    // get owner
-    assertThat(Url.decode(gApi.groups().id(name).owner().id)).isEqualTo(info.id);
-
-    // set owner by name
-    gApi.groups().id(name).owner("Registered Users");
-    assertThat(Url.decode(gApi.groups().id(name).owner().id)).isEqualTo(registeredUUID);
-
-    // set owner by UUID
-    gApi.groups().id(name).owner(adminUUID);
-    assertThat(Url.decode(gApi.groups().id(name).owner().id)).isEqualTo(adminUUID);
-
-    // set non existing owner
-    exception.expect(UnprocessableEntityException.class);
-    gApi.groups().id(name).owner("Non-Existing Group");
-  }
-
-  @Test
-  public void listNonExistingGroupIncludes_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").includedGroups();
-  }
-
-  @Test
-  public void listEmptyGroupIncludes() throws Exception {
-    String gx = createGroup("gx");
-    assertThat(gApi.groups().id(gx).includedGroups()).isEmpty();
-  }
-
-  @Test
-  public void includeNonExistingGroup() throws Exception {
-    String gx = createGroup("gx");
-    exception.expect(UnprocessableEntityException.class);
-    gApi.groups().id(gx).addGroups("non-existing");
-  }
-
-  @Test
-  public void listNonEmptyGroupIncludes() throws Exception {
-    String gx = createGroup("gx");
-    String gy = createGroup("gy");
-    String gz = createGroup("gz");
-    gApi.groups().id(gx).addGroups(gy);
-    gApi.groups().id(gx).addGroups(gz);
-    assertIncludes(gApi.groups().id(gx).includedGroups(), gy, gz);
-  }
-
-  @Test
-  public void listOneIncludeMember() throws Exception {
-    String gx = createGroup("gx");
-    String gy = createGroup("gy");
-    gApi.groups().id(gx).addGroups(gy);
-    assertIncludes(gApi.groups().id(gx).includedGroups(), gy);
-  }
-
-  @Test
-  public void listNonExistingGroupMembers_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.groups().id("non-existing").members();
-  }
-
-  @Test
-  public void listEmptyGroupMembers() throws Exception {
-    String group = createGroup("empty");
-    assertThat(gApi.groups().id(group).members()).isEmpty();
-  }
-
-  @Test
-  public void listNonEmptyGroupMembers() throws Exception {
-    String group = createGroup("group");
-    String user1 = createAccount("user1", group);
-    String user2 = createAccount("user2", group);
-    assertMembers(gApi.groups().id(group).members(), user1, user2);
-  }
-
-  @Test
-  public void listOneGroupMember() throws Exception {
-    String group = createGroup("group");
-    String user = createAccount("user1", group);
-    assertMembers(gApi.groups().id(group).members(), user);
-  }
-
-  @Test
-  public void listGroupMembersRecursively() throws Exception {
-    String gx = createGroup("gx");
-    String ux = createAccount("ux", gx);
-
-    String gy = createGroup("gy");
-    String uy = createAccount("uy", gy);
-
-    String gz = createGroup("gz");
-    String uz = createAccount("uz", gz);
-
-    gApi.groups().id(gx).addGroups(gy);
-    gApi.groups().id(gy).addGroups(gz);
-    assertMembers(gApi.groups().id(gx).members(), ux);
-    assertMembers(gApi.groups().id(gx).members(true), ux, uy, uz);
-  }
-
-  @Test
-  public void defaultGroupsCreated() throws Exception {
-    Iterable<String> names = gApi.groups().list().getAsMap().keySet();
-    assertThat(names).containsAllOf("Administrators", "Non-Interactive Users").inOrder();
-  }
-
-  @Test
-  public void listAllGroups() throws Exception {
-    List<String> expectedGroups =
-        groups.getAll(db).map(a -> a.getName()).sorted().collect(toList());
-    assertThat(expectedGroups.size()).isAtLeast(2);
-    assertThat(gApi.groups().list().getAsMap().keySet())
-        .containsExactlyElementsIn(expectedGroups)
-        .inOrder();
-  }
-
-  @Test
-  public void onlyVisibleGroupsReturned() throws Exception {
-    String newGroupName = name("newGroup");
-    GroupInput in = new GroupInput();
-    in.name = newGroupName;
-    in.description = "a hidden group";
-    in.visibleToAll = false;
-    in.ownerId = getFromCache("Administrators").getGroupUUID().get();
-    gApi.groups().create(in);
-
-    setApiUser(user);
-    assertThat(gApi.groups().list().getAsMap()).doesNotContainKey(newGroupName);
-
-    setApiUser(admin);
-    gApi.groups().id(newGroupName).addMembers(user.username);
-
-    setApiUser(user);
-    assertThat(gApi.groups().list().getAsMap()).containsKey(newGroupName);
-  }
-
-  @Test
-  public void suggestGroup() throws Exception {
-    Map<String, GroupInfo> groups = gApi.groups().list().withSuggest("adm").getAsMap();
-    assertThat(groups).containsKey("Administrators");
-    assertThat(groups).hasSize(1);
-    assertBadRequest(gApi.groups().list().withSuggest("adm").withSubstring("foo"));
-    assertBadRequest(gApi.groups().list().withSuggest("adm").withRegex("foo.*"));
-    assertBadRequest(gApi.groups().list().withSuggest("adm").withUser("user"));
-    assertBadRequest(gApi.groups().list().withSuggest("adm").withOwned(true));
-    assertBadRequest(gApi.groups().list().withSuggest("adm").withVisibleToAll(true));
-    assertBadRequest(gApi.groups().list().withSuggest("adm").withStart(1));
-  }
-
-  @Test
-  public void withSubstring() throws Exception {
-    Map<String, GroupInfo> groups = gApi.groups().list().withSubstring("dmin").getAsMap();
-    assertThat(groups).containsKey("Administrators");
-    assertThat(groups).hasSize(1);
-
-    groups = gApi.groups().list().withSubstring("admin").getAsMap();
-    assertThat(groups).containsKey("Administrators");
-    assertThat(groups).hasSize(1);
-
-    String other = name("Administrators");
-    gApi.groups().create(other);
-    groups = gApi.groups().list().withSubstring("dmin").getAsMap();
-    assertThat(groups).hasSize(2);
-    assertThat(groups).containsKey("Administrators");
-    assertThat(groups).containsKey(other);
-
-    groups = gApi.groups().list().withSubstring("foo").getAsMap();
-    assertThat(groups).isEmpty();
-  }
-
-  @Test
-  public void withRegex() throws Exception {
-    Map<String, GroupInfo> groups = gApi.groups().list().withRegex("Admin.*").getAsMap();
-    assertThat(groups).containsKey("Administrators");
-    assertThat(groups).hasSize(1);
-
-    groups = gApi.groups().list().withRegex("admin.*").getAsMap();
-    assertThat(groups).isEmpty();
-
-    groups = gApi.groups().list().withRegex(".*istrators").getAsMap();
-    assertThat(groups).containsKey("Administrators");
-    assertThat(groups).hasSize(1);
-
-    assertBadRequest(gApi.groups().list().withRegex(".*istrators").withSubstring("s"));
-  }
-
-  @Test
-  public void allGroupInfoFieldsSetCorrectly() throws Exception {
-    InternalGroup adminGroup = getFromCache("Administrators");
-    Map<String, GroupInfo> groups = gApi.groups().list().addGroup(adminGroup.getName()).getAsMap();
-    assertThat(groups).hasSize(1);
-    assertThat(groups).containsKey("Administrators");
-    assertGroupInfo(adminGroup, Iterables.getOnlyElement(groups.values()));
-  }
-
-  @Test
-  public void getAuditLog() throws Exception {
-    GroupApi g = gApi.groups().create(name("group"));
-    List<? extends GroupAuditEventInfo> auditEvents = g.auditLog();
-    assertThat(auditEvents).hasSize(1);
-    assertAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id, admin.id);
-
-    g.addMembers(user.username);
-    auditEvents = g.auditLog();
-    assertThat(auditEvents).hasSize(2);
-    assertAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id, user.id);
-
-    g.removeMembers(user.username);
-    auditEvents = g.auditLog();
-    assertThat(auditEvents).hasSize(3);
-    assertAuditEvent(auditEvents.get(0), Type.REMOVE_USER, admin.id, user.id);
-
-    String otherGroup = name("otherGroup");
-    gApi.groups().create(otherGroup);
-    g.addGroups(otherGroup);
-    auditEvents = g.auditLog();
-    assertThat(auditEvents).hasSize(4);
-    assertAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id, otherGroup);
-
-    g.removeGroups(otherGroup);
-    auditEvents = g.auditLog();
-    assertThat(auditEvents).hasSize(5);
-    assertAuditEvent(auditEvents.get(0), Type.REMOVE_GROUP, admin.id, otherGroup);
-
-    Timestamp lastDate = null;
-    for (GroupAuditEventInfo auditEvent : auditEvents) {
-      if (lastDate != null) {
-        assertThat(lastDate).isGreaterThan(auditEvent.date);
-      }
-      lastDate = auditEvent.date;
-    }
-  }
-
-  // reindex is tested by {@link AbstractQueryGroupsTest#reindex}
-  @Test
-  public void reindexPermissions() throws Exception {
-    TestAccount groupOwner = accountCreator.user2();
-    GroupInput in = new GroupInput();
-    in.name = name("group");
-    in.members =
-        Collections.singleton(groupOwner).stream().map(u -> u.id.toString()).collect(toList());
-    in.visibleToAll = true;
-    GroupInfo group = gApi.groups().create(in).get();
-
-    // admin can reindex any group
-    setApiUser(admin);
-    gApi.groups().id(group.id).index();
-
-    // group owner can reindex own group (group is owned by itself)
-    setApiUser(groupOwner);
-    gApi.groups().id(group.id).index();
-
-    // user cannot reindex any group
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to index group");
-    gApi.groups().id(group.id).index();
-  }
-
-  private void assertAuditEvent(
-      GroupAuditEventInfo info,
-      Type expectedType,
-      Account.Id expectedUser,
-      Account.Id expectedMember) {
-    assertThat(info.user._accountId).isEqualTo(expectedUser.get());
-    assertThat(info.type).isEqualTo(expectedType);
-    assertThat(info).isInstanceOf(UserMemberAuditEventInfo.class);
-    assertThat(((UserMemberAuditEventInfo) info).member._accountId).isEqualTo(expectedMember.get());
-  }
-
-  private void assertAuditEvent(
-      GroupAuditEventInfo info,
-      Type expectedType,
-      Account.Id expectedUser,
-      String expectedMemberGroupName) {
-    assertThat(info.user._accountId).isEqualTo(expectedUser.get());
-    assertThat(info.type).isEqualTo(expectedType);
-    assertThat(info).isInstanceOf(GroupMemberAuditEventInfo.class);
-    assertThat(((GroupMemberAuditEventInfo) info).member.name).isEqualTo(expectedMemberGroupName);
-  }
-
-  private void assertMembers(String group, TestAccount... expectedMembers) throws Exception {
-    assertMembers(
-        gApi.groups().id(group).members(),
-        TestAccount.names(expectedMembers).stream().toArray(String[]::new));
-    assertAccountInfos(Arrays.asList(expectedMembers), gApi.groups().id(group).members());
-  }
-
-  private void assertMembers(Iterable<AccountInfo> members, String... expectedNames) {
-    assertThat(Iterables.transform(members, i -> i.name))
-        .containsExactlyElementsIn(Arrays.asList(expectedNames))
-        .inOrder();
-  }
-
-  private void assertNoMembers(String group) throws Exception {
-    assertThat(gApi.groups().id(group).members()).isEmpty();
-  }
-
-  private void assertIncludes(String group, String... expectedNames) throws Exception {
-    assertIncludes(gApi.groups().id(group).includedGroups(), expectedNames);
-  }
-
-  private static void assertIncludes(Iterable<GroupInfo> includes, String... expectedNames) {
-    assertThat(Iterables.transform(includes, i -> i.name))
-        .containsExactlyElementsIn(Arrays.asList(expectedNames))
-        .inOrder();
-  }
-
-  private void assertNoIncludes(String group) throws Exception {
-    assertThat(gApi.groups().id(group).includedGroups()).isEmpty();
-  }
-
-  private InternalGroup getFromCache(String name) throws Exception {
-    return groupCache.get(new AccountGroup.NameKey(name)).orElse(null);
-  }
-
-  private void setCreatedOnToNull(AccountGroup.UUID groupUuid) throws Exception {
-    groupsUpdateProvider.get().updateGroup(db, groupUuid, group -> group.setCreatedOn(null));
-  }
-
-  private void assertBadRequest(ListRequest req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException e) {
-      // Expected
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/BUILD
deleted file mode 100644
index 148fb2a..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/BUILD
+++ /dev/null
@@ -1,7 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "api_plugin",
-    labels = ["api"],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/PluginIT.java
deleted file mode 100644
index 0fa09af..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ /dev/null
@@ -1,155 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.plugin;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.extensions.api.plugins.PluginApi;
-import com.google.gerrit.extensions.api.plugins.Plugins.ListRequest;
-import com.google.gerrit.extensions.common.InstallPluginInput;
-import com.google.gerrit.extensions.common.PluginInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.RawInput;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import java.util.List;
-import org.junit.Test;
-
-@NoHttpd
-public class PluginIT extends AbstractDaemonTest {
-  private static final String JS_PLUGIN = "Gerrit.install(function(self){});\n";
-  private static final String HTML_PLUGIN =
-      String.format("<dom-module id=\"test\"><script>%s</script></dom-module>", JS_PLUGIN);
-  private static final RawInput JS_PLUGIN_CONTENT = RawInputUtil.create(JS_PLUGIN.getBytes(UTF_8));
-  private static final RawInput HTML_PLUGIN_CONTENT =
-      RawInputUtil.create(HTML_PLUGIN.getBytes(UTF_8));
-
-  private static final List<String> PLUGINS =
-      ImmutableList.of(
-          "plugin-a.js", "plugin-b.html", "plugin-c.js", "plugin-d.html", "plugin_e.js");
-
-  @Test
-  @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
-  public void pluginManagement() throws Exception {
-    // No plugins are loaded
-    assertThat(list().get()).isEmpty();
-    assertThat(list().all().get()).isEmpty();
-
-    PluginApi api;
-    // Install all the plugins
-    InstallPluginInput input = new InstallPluginInput();
-    for (String plugin : PLUGINS) {
-      input.raw = plugin.endsWith(".js") ? JS_PLUGIN_CONTENT : HTML_PLUGIN_CONTENT;
-      api = gApi.plugins().install(plugin, input);
-      assertThat(api).isNotNull();
-      PluginInfo info = api.get();
-      String name = pluginName(plugin);
-      assertThat(info.id).isEqualTo(name);
-      assertThat(info.version).isEqualTo(pluginVersion(plugin));
-      assertThat(info.indexUrl).isEqualTo(String.format("plugins/%s/", name));
-      assertThat(info.filename).isEqualTo(plugin);
-      assertThat(info.disabled).isNull();
-    }
-    assertPlugins(list().get(), PLUGINS);
-
-    // With pagination
-    assertPlugins(list().start(1).limit(2).get(), PLUGINS.subList(1, 3));
-
-    // With prefix
-    assertPlugins(list().prefix("plugin-b").get(), ImmutableList.of("plugin-b.html"));
-    assertPlugins(list().prefix("PLUGIN-").get(), ImmutableList.of());
-
-    // With substring
-    assertPlugins(list().substring("lugin-").get(), PLUGINS.subList(0, PLUGINS.size() - 1));
-    assertPlugins(list().substring("lugin-").start(1).limit(2).get(), PLUGINS.subList(1, 3));
-
-    // With regex
-    assertPlugins(list().regex(".*in-b").get(), ImmutableList.of("plugin-b.html"));
-    assertPlugins(list().regex("plugin-.*").get(), PLUGINS.subList(0, PLUGINS.size() - 1));
-    assertPlugins(list().regex("plugin-.*").start(1).limit(2).get(), PLUGINS.subList(1, 3));
-
-    // Invalid match combinations
-    assertBadRequest(list().regex(".*in-b").substring("a"));
-    assertBadRequest(list().regex(".*in-b").prefix("a"));
-    assertBadRequest(list().substring(".*in-b").prefix("a"));
-
-    // Disable
-    api = gApi.plugins().name("plugin-a");
-    api.disable();
-    api = gApi.plugins().name("plugin-a");
-    assertThat(api.get().disabled).isTrue();
-    assertPlugins(list().get(), PLUGINS.subList(1, PLUGINS.size()));
-    assertPlugins(list().all().get(), PLUGINS);
-
-    // Enable
-    api.enable();
-    api = gApi.plugins().name("plugin-a");
-    assertThat(api.get().disabled).isNull();
-    assertPlugins(list().get(), PLUGINS);
-  }
-
-  @Test
-  public void installNotAllowed() throws Exception {
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("remote installation is disabled");
-    gApi.plugins().install("test.js", new InstallPluginInput());
-  }
-
-  @Test
-  public void getNonExistingThrowsNotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.plugins().name("does-not-exist");
-  }
-
-  private ListRequest list() throws RestApiException {
-    return gApi.plugins().list();
-  }
-
-  private void assertPlugins(List<PluginInfo> actual, List<String> expected) {
-    List<String> _actual = actual.stream().map(p -> p.id).collect(toList());
-    List<String> _expected = expected.stream().map(p -> pluginName(p)).collect(toList());
-    assertThat(_actual).containsExactlyElementsIn(_expected);
-  }
-
-  private String pluginName(String plugin) {
-    int dot = plugin.indexOf(".");
-    assertThat(dot).isGreaterThan(0);
-    return plugin.substring(0, dot);
-  }
-
-  private String pluginVersion(String plugin) {
-    String name = pluginName(plugin);
-    int dash = name.lastIndexOf("-");
-    return dash > 0 ? name.substring(dash + 1) : "";
-  }
-
-  private void assertBadRequest(ListRequest req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException e) {
-      // Expected
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUILD
deleted file mode 100644
index 8be3101..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUILD
+++ /dev/null
@@ -1,7 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "api_project",
-    labels = ["api"],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
deleted file mode 100644
index 2f92e7a..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ /dev/null
@@ -1,154 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.project;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.config.AccessCheckInfo;
-import com.google.gerrit.extensions.api.config.AccessCheckInput;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import java.util.List;
-import org.junit.Before;
-import org.junit.Test;
-
-public class CheckAccessIT extends AbstractDaemonTest {
-
-  private Project.NameKey normalProject;
-  private Project.NameKey secretProject;
-  private Project.NameKey secretRefProject;
-  private TestAccount privilegedUser;
-  private InternalGroup privilegedGroup;
-
-  @Before
-  public void setUp() throws Exception {
-    normalProject = createProject("normal");
-    secretProject = createProject("secret");
-    secretRefProject = createProject("secretRef");
-    privilegedGroup =
-        groupCache.get(new AccountGroup.NameKey(createGroup("privilegedGroup"))).orElse(null);
-
-    privilegedUser = accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden");
-    gApi.groups().id(privilegedGroup.getGroupUUID().get()).addMembers(privilegedUser.username);
-
-    assertThat(gApi.groups().id(privilegedGroup.getGroupUUID().get()).members().get(0).email)
-        .contains("snowden");
-
-    grant(secretProject, "refs/*", Permission.READ, false, privilegedGroup.getGroupUUID());
-    block(secretProject, "refs/*", Permission.READ, SystemGroupBackend.REGISTERED_USERS);
-
-    // deny/grant/block arg ordering is screwy.
-    deny(secretRefProject, "refs/*", Permission.READ, SystemGroupBackend.ANONYMOUS_USERS);
-    grant(
-        secretRefProject,
-        "refs/heads/secret/*",
-        Permission.READ,
-        false,
-        privilegedGroup.getGroupUUID());
-    block(
-        secretRefProject,
-        "refs/heads/secret/*",
-        Permission.READ,
-        SystemGroupBackend.REGISTERED_USERS);
-    grant(
-        secretRefProject,
-        "refs/heads/*",
-        Permission.READ,
-        false,
-        SystemGroupBackend.REGISTERED_USERS);
-  }
-
-  @Test
-  public void emptyInput() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("input requires 'account'");
-    gApi.projects().name(normalProject.get()).checkAccess(new AccessCheckInput());
-  }
-
-  @Test
-  public void nonexistentEmail() throws Exception {
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("cannot find account doesnotexist@invalid.com");
-    gApi.projects()
-        .name(normalProject.get())
-        .checkAccess(new AccessCheckInput("doesnotexist@invalid.com", null));
-  }
-
-  private static class TestCase {
-    AccessCheckInput input;
-    String project;
-    int want;
-
-    TestCase(String mail, String project, String ref, int want) {
-      this.input = new AccessCheckInput(mail, ref);
-      this.project = project;
-      this.want = want;
-    }
-  }
-
-  @Test
-  public void accessible() throws Exception {
-    List<TestCase> inputs =
-        ImmutableList.of(
-            new TestCase(user.email, normalProject.get(), null, 200),
-            new TestCase(user.email, secretProject.get(), null, 403),
-            new TestCase(user.email, secretRefProject.get(), "refs/heads/secret/master", 403),
-            new TestCase(
-                privilegedUser.email, secretRefProject.get(), "refs/heads/secret/master", 200),
-            new TestCase(privilegedUser.email, normalProject.get(), null, 200),
-            new TestCase(privilegedUser.email, secretProject.get(), null, 200));
-
-    for (TestCase tc : inputs) {
-      String in = newGson().toJson(tc.input);
-      AccessCheckInfo info = null;
-
-      try {
-        info = gApi.projects().name(tc.project).checkAccess(tc.input);
-      } catch (RestApiException e) {
-        fail(String.format("check.access(%s, %s): exception %s", tc.project, in, e));
-      }
-
-      int want = tc.want;
-      if (want != info.status) {
-        fail(
-            String.format("check.access(%s, %s) = %d, want %d", tc.project, in, info.status, want));
-      }
-
-      switch (want) {
-        case 403:
-          assertThat(info.message).contains("cannot see");
-          break;
-        case 404:
-          assertThat(info.message).contains("does not exist");
-          break;
-        case 200:
-          assertThat(info.message).isNull();
-          break;
-        default:
-          fail(String.format("unknown code %d", want));
-      }
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/DashboardIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/DashboardIT.java
deleted file mode 100644
index b140a6e..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ /dev/null
@@ -1,152 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.project;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.api.projects.ProjectApi;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.project.DashboardsCollection;
-import java.util.List;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class DashboardIT extends AbstractDaemonTest {
-  @Before
-  public void setup() throws Exception {
-    allow("refs/meta/dashboards/*", Permission.CREATE, REGISTERED_USERS);
-  }
-
-  @Test
-  public void defaultDashboardDoesNotExist() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    project().defaultDashboard().get();
-  }
-
-  @Test
-  public void dashboardDoesNotExist() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    project().dashboard("my:dashboard").get();
-  }
-
-  @Test
-  public void getDashboard() throws Exception {
-    assertThat(dashboards()).isEmpty();
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
-    DashboardInfo result = project().dashboard(info.id).get();
-    assertThat(result.id).isEqualTo(info.id);
-    assertThat(result.path).isEqualTo(info.path);
-    assertThat(result.ref).isEqualTo(info.ref);
-    assertThat(result.project).isEqualTo(project.get());
-    assertThat(result.definingProject).isEqualTo(project.get());
-    assertThat(dashboards()).hasSize(1);
-  }
-
-  @Test
-  public void setDefaultDashboard() throws Exception {
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
-    assertThat(info.isDefault).isNull();
-    project().dashboard(info.id).setDefault();
-    assertThat(project().dashboard(info.id).get().isDefault).isTrue();
-    assertThat(project().defaultDashboard().get().id).isEqualTo(info.id);
-  }
-
-  @Test
-  public void setDefaultDashboardByProject() throws Exception {
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
-    assertThat(info.isDefault).isNull();
-    project().defaultDashboard(info.id);
-    assertThat(project().dashboard(info.id).get().isDefault).isTrue();
-    assertThat(project().defaultDashboard().get().id).isEqualTo(info.id);
-
-    project().removeDefaultDashboard();
-    assertThat(project().dashboard(info.id).get().isDefault).isNull();
-
-    exception.expect(ResourceNotFoundException.class);
-    project().defaultDashboard().get();
-  }
-
-  @Test
-  public void replaceDefaultDashboard() throws Exception {
-    DashboardInfo d1 = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test1");
-    DashboardInfo d2 = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test2");
-    assertThat(d1.isDefault).isNull();
-    assertThat(d2.isDefault).isNull();
-    project().dashboard(d1.id).setDefault();
-    assertThat(project().dashboard(d1.id).get().isDefault).isTrue();
-    assertThat(project().dashboard(d2.id).get().isDefault).isNull();
-    assertThat(project().defaultDashboard().get().id).isEqualTo(d1.id);
-    project().dashboard(d2.id).setDefault();
-    assertThat(project().defaultDashboard().get().id).isEqualTo(d2.id);
-    assertThat(project().dashboard(d1.id).get().isDefault).isNull();
-    assertThat(project().dashboard(d2.id).get().isDefault).isTrue();
-  }
-
-  @Test
-  public void cannotGetDashboardWithInheritedForNonDefault() throws Exception {
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("inherited flag can only be used with default");
-    project().dashboard(info.id).get(true);
-  }
-
-  private List<DashboardInfo> dashboards() throws Exception {
-    return project().dashboards().get();
-  }
-
-  private ProjectApi project() throws RestApiException {
-    return gApi.projects().name(project.get());
-  }
-
-  private DashboardInfo createDashboard(String ref, String path) throws Exception {
-    DashboardInfo info = DashboardsCollection.newDashboardInfo(ref, path);
-    String canonicalRef = DashboardsCollection.normalizeDashboardRef(info.ref);
-    try {
-      project().branch(canonicalRef).create(new BranchInput());
-    } catch (ResourceConflictException e) {
-      // The branch already exists if this method has already been called once.
-      if (!e.getMessage().contains("already exists")) {
-        throw e;
-      }
-    }
-    try (Repository r = repoManager.openRepository(project)) {
-      TestRepository<Repository>.CommitBuilder cb =
-          new TestRepository<>(r).branch(canonicalRef).commit();
-      String content =
-          "[dashboard]\n"
-              + "Description = Test\n"
-              + "foreach = owner:self\n"
-              + "[section \"Mine\"]\n"
-              + "query = is:open";
-      cb.add(info.path, content);
-      RevCommit c = cb.create();
-      project().commit(c.name());
-    }
-    return info;
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
deleted file mode 100644
index 94dcf31..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ /dev/null
@@ -1,227 +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.api.project;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.api.projects.ConfigInfo;
-import com.google.gerrit.extensions.api.projects.ConfigInput;
-import com.google.gerrit.extensions.api.projects.DescriptionInput;
-import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ProjectState;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.RefNames;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Test;
-
-@NoHttpd
-public class ProjectIT extends AbstractDaemonTest {
-
-  @Test
-  public void createProject() throws Exception {
-    String name = name("foo");
-    assertThat(name).isEqualTo(gApi.projects().create(name).get().name);
-
-    RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
-
-    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master", new String[] {});
-  }
-
-  @Test
-  public void createProjectWithGitSuffix() throws Exception {
-    String name = name("foo");
-    assertThat(name).isEqualTo(gApi.projects().create(name + ".git").get().name);
-
-    RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
-
-    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master", new String[] {});
-  }
-
-  @Test
-  public void createProjectWithInitialCommit() throws Exception {
-    String name = name("foo");
-    ProjectInput input = new ProjectInput();
-    input.name = name;
-    input.createEmptyCommit = true;
-    assertThat(name).isEqualTo(gApi.projects().create(input).get().name);
-
-    RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
-
-    head = getRemoteHead(name, "refs/heads/master");
-    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master", null, head);
-  }
-
-  @Test
-  public void createProjectWithMismatchedInput() throws Exception {
-    ProjectInput in = new ProjectInput();
-    in.name = name("foo");
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("name must match input.name");
-    gApi.projects().name("bar").create(in);
-  }
-
-  @Test
-  public void createProjectNoNameInInput() throws Exception {
-    ProjectInput in = new ProjectInput();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("input.name is required");
-    gApi.projects().create(in);
-  }
-
-  @Test
-  public void createProjectDuplicate() throws Exception {
-    ProjectInput in = new ProjectInput();
-    in.name = name("baz");
-    gApi.projects().create(in);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Project already exists");
-    gApi.projects().create(in);
-  }
-
-  @Test
-  public void createBranch() throws Exception {
-    allow("refs/*", Permission.READ, ANONYMOUS_USERS);
-    gApi.projects().name(project.get()).branch("foo").create(new BranchInput());
-  }
-
-  @Test
-  public void descriptionChangeCausesRefUpdate() throws Exception {
-    RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
-    assertThat(gApi.projects().name(project.get()).description()).isEmpty();
-    DescriptionInput in = new DescriptionInput();
-    in.description = "new project description";
-    gApi.projects().name(project.get()).description(in);
-    assertThat(gApi.projects().name(project.get()).description()).isEqualTo(in.description);
-
-    RevCommit updatedHead = getRemoteHead(project, RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(
-        project.get(), RefNames.REFS_CONFIG, initialHead, updatedHead);
-  }
-
-  @Test
-  public void descriptionIsDeletedWhenNotSpecified() throws Exception {
-    assertThat(gApi.projects().name(project.get()).description()).isEmpty();
-    DescriptionInput in = new DescriptionInput();
-    in.description = "new project description";
-    gApi.projects().name(project.get()).description(in);
-    assertThat(gApi.projects().name(project.get()).description()).isEqualTo(in.description);
-    in.description = null;
-    gApi.projects().name(project.get()).description(in);
-    assertThat(gApi.projects().name(project.get()).description()).isEmpty();
-  }
-
-  @Test
-  public void configChangeCausesRefUpdate() throws Exception {
-    RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
-
-    ConfigInfo info = gApi.projects().name(project.get()).config();
-    assertThat(info.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
-    ConfigInput input = new ConfigInput();
-    input.submitType = SubmitType.CHERRY_PICK;
-    info = gApi.projects().name(project.get()).config(input);
-    assertThat(info.submitType).isEqualTo(SubmitType.CHERRY_PICK);
-    info = gApi.projects().name(project.get()).config();
-    assertThat(info.submitType).isEqualTo(SubmitType.CHERRY_PICK);
-
-    RevCommit updatedHead = getRemoteHead(project, RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(
-        project.get(), RefNames.REFS_CONFIG, initialHead, updatedHead);
-  }
-
-  @Test
-  public void setConfig() throws Exception {
-    ConfigInput input = createTestConfigInput();
-    ConfigInfo info = gApi.projects().name(project.get()).config(input);
-    assertThat(info.description).isEqualTo(input.description);
-    assertThat(info.useContributorAgreements.configuredValue)
-        .isEqualTo(input.useContributorAgreements);
-    assertThat(info.useContentMerge.configuredValue).isEqualTo(input.useContentMerge);
-    assertThat(info.useSignedOffBy.configuredValue).isEqualTo(input.useSignedOffBy);
-    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
-        .isEqualTo(input.createNewChangeForAllNotInTarget);
-    assertThat(info.requireChangeId.configuredValue).isEqualTo(input.requireChangeId);
-    assertThat(info.rejectImplicitMerges.configuredValue).isEqualTo(input.rejectImplicitMerges);
-    assertThat(info.enableReviewerByEmail.configuredValue).isEqualTo(input.enableReviewerByEmail);
-    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
-        .isEqualTo(input.createNewChangeForAllNotInTarget);
-    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo(input.maxObjectSizeLimit);
-    assertThat(info.submitType).isEqualTo(input.submitType);
-    assertThat(info.state).isEqualTo(input.state);
-  }
-
-  @Test
-  public void setPartialConfig() throws Exception {
-    ConfigInput input = createTestConfigInput();
-    ConfigInfo info = gApi.projects().name(project.get()).config(input);
-
-    ConfigInput partialInput = new ConfigInput();
-    partialInput.useContributorAgreements = InheritableBoolean.FALSE;
-    info = gApi.projects().name(project.get()).config(partialInput);
-
-    assertThat(info.description).isNull();
-    assertThat(info.useContributorAgreements.configuredValue)
-        .isEqualTo(partialInput.useContributorAgreements);
-    assertThat(info.useContentMerge.configuredValue).isEqualTo(input.useContentMerge);
-    assertThat(info.useSignedOffBy.configuredValue).isEqualTo(input.useSignedOffBy);
-    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
-        .isEqualTo(input.createNewChangeForAllNotInTarget);
-    assertThat(info.requireChangeId.configuredValue).isEqualTo(input.requireChangeId);
-    assertThat(info.rejectImplicitMerges.configuredValue).isEqualTo(input.rejectImplicitMerges);
-    assertThat(info.enableReviewerByEmail.configuredValue).isEqualTo(input.enableReviewerByEmail);
-    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
-        .isEqualTo(input.createNewChangeForAllNotInTarget);
-    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo(input.maxObjectSizeLimit);
-    assertThat(info.submitType).isEqualTo(input.submitType);
-    assertThat(info.state).isEqualTo(input.state);
-  }
-
-  @Test
-  public void nonOwnerCannotSetConfig() throws Exception {
-    ConfigInput input = createTestConfigInput();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("restricted to project owner");
-    gApi.projects().name(project.get()).config(input);
-  }
-
-  private ConfigInput createTestConfigInput() {
-    ConfigInput input = new ConfigInput();
-    input.description = "some description";
-    input.useContributorAgreements = InheritableBoolean.TRUE;
-    input.useContentMerge = InheritableBoolean.TRUE;
-    input.useSignedOffBy = InheritableBoolean.TRUE;
-    input.createNewChangeForAllNotInTarget = InheritableBoolean.TRUE;
-    input.requireChangeId = InheritableBoolean.TRUE;
-    input.rejectImplicitMerges = InheritableBoolean.TRUE;
-    input.enableReviewerByEmail = InheritableBoolean.TRUE;
-    input.createNewChangeForAllNotInTarget = InheritableBoolean.TRUE;
-    input.maxObjectSizeLimit = "5m";
-    input.submitType = SubmitType.CHERRY_PICK;
-    input.state = ProjectState.HIDDEN;
-    return input;
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUILD
deleted file mode 100644
index 4f15ec0..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUILD
+++ /dev/null
@@ -1,7 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "api_revision",
-    labels = ["api"],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
deleted file mode 100644
index e39f9f5..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ /dev/null
@@ -1,1348 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.revision;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.extensions.common.DiffInfoSubject.assertThat;
-import static com.google.gerrit.extensions.common.FileInfoSubject.assertThat;
-import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toMap;
-
-import com.google.common.base.Joiner;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.PushOneCommit.Result;
-import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.extensions.api.changes.FileApi;
-import com.google.gerrit.extensions.api.changes.RebaseInput;
-import com.google.gerrit.extensions.common.ChangeType;
-import com.google.gerrit.extensions.common.DiffInfo;
-import com.google.gerrit.extensions.common.FileInfo;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.testutil.ConfigSuite;
-import java.awt.image.BufferedImage;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.function.Function;
-import java.util.stream.IntStream;
-import javax.imageio.ImageIO;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Before;
-import org.junit.Test;
-
-public class RevisionDiffIT extends AbstractDaemonTest {
-  // @RunWith(Parameterized.class) can't be used as AbstractDaemonTest is annotated with another
-  // runner. Using different configs is a workaround to achieve the same.
-  private static final String TEST_PARAMETER_MARKER = "test_only_parameter";
-  private static final String CURRENT = "current";
-  private static final String FILE_NAME = "some_file.txt";
-  private static final String FILE_NAME2 = "another_file.txt";
-  private static final String FILE_CONTENT =
-      IntStream.rangeClosed(1, 100)
-          .mapToObj(number -> String.format("Line %d\n", number))
-          .collect(joining());
-  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
-
-  private boolean intraline;
-  private ObjectId commit1;
-  private String changeId;
-  private String initialPatchSetId;
-
-  @ConfigSuite.Config
-  public static Config intralineConfig() {
-    Config config = new Config();
-    config.setBoolean(TEST_PARAMETER_MARKER, null, "intraline", true);
-    return config;
-  }
-
-  @Before
-  public void setUp() throws Exception {
-    intraline = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "intraline", false);
-
-    ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
-    commit1 =
-        addCommit(headCommit, ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
-
-    Result result = createEmptyChange();
-    changeId = result.getChangeId();
-    initialPatchSetId = result.getPatchSetId().getId();
-  }
-
-  @Test
-  public void diff() throws Exception {
-    // The assertions assume that intraline is false.
-    assume().that(intraline).isFalse();
-
-    String fileName = "a_new_file.txt";
-    String fileContent = "First line\nSecond line\n";
-    PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
-    assertDiffForNewFile(result, fileName, fileContent);
-    assertDiffForNewFile(result, COMMIT_MSG, result.getCommit().getFullMessage());
-  }
-
-  @Test
-  public void diffDeletedFile() throws Exception {
-    gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
-    gApi.changes().id(changeId).edit().publish();
-
-    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
-
-    DiffInfo diff = getDiffRequest(changeId, CURRENT, FILE_NAME).get();
-    assertThat(diff.metaA.lines).isEqualTo(100);
-    assertThat(diff.metaB).isNull();
-  }
-
-  @Test
-  public void addedFileIsIncludedInDiff() throws Exception {
-    String newFilePath = "a_new_file.txt";
-    String newFileContent = "arbitrary content";
-    gApi.changes().id(changeId).edit().modifyFile(newFilePath, RawInputUtil.create(newFileContent));
-    gApi.changes().id(changeId).edit().publish();
-
-    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath);
-  }
-
-  @Test
-  public void renamedFileIsIncludedInDiff() throws Exception {
-    String newFilePath = "a_new_file.txt";
-    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
-    gApi.changes().id(changeId).edit().publish();
-
-    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath);
-  }
-
-  @Test
-  public void copiedFileTreatedAsAddedFileInDiff() throws Exception {
-    String copyFilePath = "copy_of_some_file.txt";
-    gApi.changes().id(changeId).edit().modifyFile(copyFilePath, RawInputUtil.create(FILE_CONTENT));
-    gApi.changes().id(changeId).edit().publish();
-
-    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, copyFilePath);
-    // If this ever changes, please add tests which cover copied files.
-    assertThat(changedFiles.get(copyFilePath)).status().isEqualTo('A');
-    assertThat(changedFiles.get(copyFilePath)).linesInserted().isEqualTo(100);
-    assertThat(changedFiles.get(copyFilePath)).linesDeleted().isNull();
-  }
-
-  @Test
-  public void addedBinaryFileIsIncludedInDiff() throws Exception {
-    String imageFileName = "an_image.png";
-    byte[] imageBytes = createRgbImage(255, 0, 0);
-    gApi.changes().id(changeId).edit().modifyFile(imageFileName, RawInputUtil.create(imageBytes));
-    gApi.changes().id(changeId).edit().publish();
-
-    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, imageFileName);
-  }
-
-  @Test
-  public void modifiedBinaryFileIsIncludedInDiff() throws Exception {
-    String imageFileName = "an_image.png";
-    byte[] imageBytes1 = createRgbImage(255, 100, 0);
-    ObjectId commit2 = addCommit(commit1, imageFileName, imageBytes1);
-
-    rebaseChangeOn(changeId, commit2);
-    byte[] imageBytes2 = createRgbImage(0, 100, 255);
-    gApi.changes().id(changeId).edit().modifyFile(imageFileName, RawInputUtil.create(imageBytes2));
-    gApi.changes().id(changeId).edit().publish();
-
-    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, imageFileName);
-  }
-
-  @Test
-  public void diffOnMergeCommitChange() throws Exception {
-    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
-
-    DiffInfo diff;
-
-    // automerge
-    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").get();
-    assertThat(diff.metaA.lines).isEqualTo(5);
-    assertThat(diff.metaB.lines).isEqualTo(1);
-
-    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").get();
-    assertThat(diff.metaA.lines).isEqualTo(5);
-    assertThat(diff.metaB.lines).isEqualTo(1);
-
-    // parent 1
-    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(1).get();
-    assertThat(diff.metaA.lines).isEqualTo(1);
-    assertThat(diff.metaB.lines).isEqualTo(1);
-
-    // parent 2
-    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(2).get();
-    assertThat(diff.metaA.lines).isEqualTo(1);
-    assertThat(diff.metaB.lines).isEqualTo(1);
-  }
-
-  @Test
-  public void addedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
-    ObjectId commit2 = addCommit(commit1, "file_added_in_another_commit.txt", "Some file content");
-
-    rebaseChangeOn(changeId, commit2);
-    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
-  }
-
-  @Test
-  public void removedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
-    ObjectId commit2 = addCommitRemovingFiles(commit1, FILE_NAME2);
-
-    rebaseChangeOn(changeId, commit2);
-    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
-  }
-
-  @Test
-  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
-    ObjectId commit2 = addCommitRenamingFile(commit1, FILE_NAME2, "a_new_file_name.txt");
-
-    rebaseChangeOn(changeId, commit2);
-    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
-  }
-
-  @Test
-  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase_WhenEquallyModifiedInBoth()
-      throws Exception {
-    Function<String, String> contentModification =
-        fileContent -> fileContent.replace("1st line\n", "First line\n");
-    addModifiedPatchSet(changeId, FILE_NAME2, contentModification);
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-
-    // Revert the modification to be able to rebase.
-    addModifiedPatchSet(
-        changeId, FILE_NAME2, fileContent -> fileContent.replace("First line\n", "1st line\n"));
-
-    String renamedFileName = "renamed_file.txt";
-    ObjectId commit2 = addCommitRenamingFile(commit1, FILE_NAME2, renamedFileName);
-    rebaseChangeOn(changeId, commit2);
-    addModifiedPatchSet(changeId, renamedFileName, contentModification);
-    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(previousPatchSetId);
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
-  }
-
-  @Test
-  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase_WhenModifiedDuringRebase()
-      throws Exception {
-    String renamedFilePath = "renamed_some_file.txt";
-    ObjectId commit2 =
-        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
-    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, renamedFilePath);
-
-    rebaseChangeOn(changeId, commit3);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
-  }
-
-  @Test
-  public void fileRenamedDuringRebaseSameAsInPatchSetIsIgnored() throws Exception {
-    String renamedFileName = "renamed_file.txt";
-    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
-    gApi.changes().id(changeId).edit().publish();
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-
-    // Revert the renaming to be able to rebase.
-    gApi.changes().id(changeId).edit().renameFile(renamedFileName, FILE_NAME);
-    gApi.changes().id(changeId).edit().publish();
-
-    ObjectId commit2 = addCommitRenamingFile(commit1, FILE_NAME, renamedFileName);
-    rebaseChangeOn(changeId, commit2);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(previousPatchSetId);
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
-  }
-
-  @Test
-  public void fileWithRebaseHunksRenamedDuringRebaseSameAsInPatchSetIsIgnored() throws Exception {
-    String renamedFileName = "renamed_file.txt";
-    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
-    gApi.changes().id(changeId).edit().publish();
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-
-    // Revert the renaming to be able to rebase.
-    gApi.changes().id(changeId).edit().renameFile(renamedFileName, FILE_NAME);
-    gApi.changes().id(changeId).edit().publish();
-
-    ObjectId commit2 =
-        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 10\n", "Line ten\n"));
-    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, renamedFileName);
-    rebaseChangeOn(changeId, commit3);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(previousPatchSetId);
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
-  }
-
-  @Test
-  public void filesNotTouchedByPatchSetsAndContainingOnlyRebaseHunksAreIgnored() throws Exception {
-    String newFileContent = FILE_CONTENT.replace("Line 10\n", "Line ten\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME2, "a_new_file_name.txt");
-
-    rebaseChangeOn(changeId, commit3);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
-  }
-
-  @Test
-  public void filesTouchedByPatchSetsAndContainingOnlyRebaseHunksAreIgnored() throws Exception {
-    addModifiedPatchSet(
-        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 50\n", "Line fifty\n"));
-    addModifiedPatchSet(
-        changeId, FILE_NAME2, fileContent -> fileContent.replace("1st line\n", "First line\n"));
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-    // Revert the modification to allow rebasing.
-    addModifiedPatchSet(
-        changeId, FILE_NAME2, fileContent -> fileContent.replace("First line\n", "1st line\n"));
-
-    String newFileContent = FILE_CONTENT.replace("Line 10\n", "Line ten\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-    String newFilePath = "a_new_file_name.txt";
-    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME2, newFilePath);
-
-    rebaseChangeOn(changeId, commit3);
-    // Apply the modification again to bring the file into the same state as for the previous
-    // patch set.
-    addModifiedPatchSet(
-        changeId, newFilePath, fileContent -> fileContent.replace("1st line\n", "First line\n"));
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(previousPatchSetId);
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
-  }
-
-  @Test
-  public void rebaseHunksAtStartOfFileAreIdentified() throws Exception {
-    String newFileContent =
-        FILE_CONTENT.replace("Line 1\n", "Line one\n").replace("Line 5\n", "Line five\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-
-    rebaseChangeOn(changeId, commit2);
-    Function<String, String> contentModification =
-        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
-    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
-    assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(3);
-    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 5");
-    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line five");
-    assertThat(diffInfo).content().element(2).isDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(44);
-    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 50");
-    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line fifty");
-    assertThat(diffInfo).content().element(4).isNotDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(50);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
-  }
-
-  @Test
-  public void rebaseHunksAtEndOfFileAreIdentified() throws Exception {
-    String newFileContent =
-        FILE_CONTENT
-            .replace("Line 60\n", "Line sixty\n")
-            .replace("Line 100\n", "Line one hundred\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-
-    rebaseChangeOn(changeId, commit2);
-    Function<String, String> contentModification =
-        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(49);
-    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 50");
-    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line fifty");
-    assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(9);
-    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 60");
-    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line sixty");
-    assertThat(diffInfo).content().element(3).isDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(39);
-    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 100");
-    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line one hundred");
-    assertThat(diffInfo).content().element(5).isDueToRebase();
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
-  }
-
-  @Test
-  public void rebaseHunksInBetweenRegularHunksAreIdentified() throws Exception {
-    String newFileContent =
-        FILE_CONTENT.replace("Line 40\n", "Line forty\n").replace("Line 45\n", "Line forty five\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-
-    rebaseChangeOn(changeId, commit2);
-    Function<String, String> contentModification =
-        fileContent ->
-            fileContent
-                .replace("Line 1\n", "Line one\n")
-                .replace("Line 100\n", "Line one hundred\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
-    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
-    assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(38);
-    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 40");
-    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line forty");
-    assertThat(diffInfo).content().element(2).isDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(4);
-    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 45");
-    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty five");
-    assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(54);
-    assertThat(diffInfo).content().element(6).linesOfA().containsExactly("Line 100");
-    assertThat(diffInfo).content().element(6).linesOfB().containsExactly("Line one hundred");
-    assertThat(diffInfo).content().element(6).isNotDueToRebase();
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
-  }
-
-  @Test
-  public void rebaseHunkIsIdentifiedWhenMovedDownInPreviousPatchSet() throws Exception {
-    // Move the code down by introducing additional lines (pure insert + enlarging replacement) in
-    // the previous patch set.
-    Function<String, String> contentModification1 =
-        fileContent ->
-            "Line zero\n" + fileContent.replace("Line 10\n", "Line ten\nLine ten and a half\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification1);
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-
-    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-
-    rebaseChangeOn(changeId, commit2);
-    Function<String, String> contentModification2 =
-        fileContent -> fileContent.replace("Line 100\n", "Line one hundred\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification2);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(41);
-    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
-    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line forty");
-    assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
-    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 100");
-    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line one hundred");
-    assertThat(diffInfo).content().element(3).isNotDueToRebase();
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(previousPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
-  }
-
-  @Test
-  public void rebaseHunkIsIdentifiedWhenMovedDownInLatestPatchSet() throws Exception {
-    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-
-    rebaseChangeOn(changeId, commit2);
-    // Move the code down by introducing additional lines (pure insert + enlarging replacement) in
-    // the latest patch set.
-    Function<String, String> contentModification =
-        fileContent ->
-            "Line zero\n" + fileContent.replace("Line 10\n", "Line ten\nLine ten and a half\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).linesOfA().isNull();
-    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line zero");
-    assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(9);
-    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 10");
-    assertThat(diffInfo)
-        .content()
-        .element(2)
-        .linesOfB()
-        .containsExactly("Line ten", "Line ten and a half");
-    assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(29);
-    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 40");
-    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty");
-    assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(60);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
-  }
-
-  @Test
-  public void rebaseHunkIsIdentifiedWhenMovedUpInPreviousPatchSet() throws Exception {
-    // Move the code up by removing lines (pure deletion + shrinking replacement) in the previous
-    // patch set.
-    Function<String, String> contentModification1 =
-        fileContent ->
-            fileContent.replace("Line 1\n", "").replace("Line 10\nLine 11\n", "Line ten\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification1);
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-
-    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-
-    rebaseChangeOn(changeId, commit2);
-    Function<String, String> contentModification2 =
-        fileContent -> fileContent.replace("Line 100\n", "Line one hundred\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification2);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(37);
-    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
-    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line forty");
-    assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
-    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 100");
-    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line one hundred");
-    assertThat(diffInfo).content().element(3).isNotDueToRebase();
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(previousPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
-  }
-
-  @Test
-  public void rebaseHunkIsIdentifiedWhenMovedUpInLatestPatchSet() throws Exception {
-    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-
-    rebaseChangeOn(changeId, commit2);
-    // Move the code up by removing lines (pure deletion + shrinking replacement) in the latest
-    // patch set.
-    Function<String, String> contentModification =
-        fileContent ->
-            fileContent.replace("Line 1\n", "").replace("Line 10\nLine 11\n", "Line ten\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
-    assertThat(diffInfo).content().element(0).linesOfB().isNull();
-    assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(8);
-    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 10", "Line 11");
-    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line ten");
-    assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(28);
-    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 40");
-    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty");
-    assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(60);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(3);
-  }
-
-  @Test
-  public void modifiedRebaseHunkWithSameRegionConsideredAsRegularHunk() throws Exception {
-    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-
-    rebaseChangeOn(changeId, commit2);
-    Function<String, String> contentModification =
-        fileContent -> fileContent.replace("Line forty\n", "Line modified after rebase\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(39);
-    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
-    assertThat(diffInfo)
-        .content()
-        .element(1)
-        .linesOfB()
-        .containsExactly("Line modified after rebase");
-    assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(60);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
-  }
-
-  @Test
-  public void rebaseHunkOverlappingAtBeginningConsideredAsRegularHunk() throws Exception {
-    String newFileContent =
-        FILE_CONTENT.replace("Line 40\nLine 41\n", "Line forty\nLine forty one\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-
-    rebaseChangeOn(changeId, commit2);
-    Function<String, String> contentModification =
-        fileContent ->
-            fileContent
-                .replace("Line 39\n", "Line thirty nine\n")
-                .replace("Line forty one\n", "Line 41\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(38);
-    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 39", "Line 40");
-    assertThat(diffInfo)
-        .content()
-        .element(1)
-        .linesOfB()
-        .containsExactly("Line thirty nine", "Line forty");
-    assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(60);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
-  }
-
-  @Test
-  public void rebaseHunkOverlappingAtEndConsideredAsRegularHunk() throws Exception {
-    String newFileContent =
-        FILE_CONTENT.replace("Line 40\nLine 41\n", "Line forty\nLine forty one\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-
-    rebaseChangeOn(changeId, commit2);
-    Function<String, String> contentModification =
-        fileContent ->
-            fileContent
-                .replace("Line forty\n", "Line 40\n")
-                .replace("Line 42\n", "Line forty two\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(40);
-    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 41", "Line 42");
-    assertThat(diffInfo)
-        .content()
-        .element(1)
-        .linesOfB()
-        .containsExactly("Line forty one", "Line forty two");
-    assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(58);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
-  }
-
-  @Test
-  public void rebaseHunkModifiedInsideConsideredAsRegularHunk() throws Exception {
-    String newFileContent =
-        FILE_CONTENT.replace(
-            "Line 39\nLine 40\nLine 41\n", "Line thirty nine\nLine forty\nLine forty one\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-
-    rebaseChangeOn(changeId, commit2);
-    Function<String, String> contentModification =
-        fileContent -> fileContent.replace("Line forty\n", "A different line forty\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(38);
-    assertThat(diffInfo)
-        .content()
-        .element(1)
-        .linesOfA()
-        .containsExactly("Line 39", "Line 40", "Line 41");
-    assertThat(diffInfo)
-        .content()
-        .element(1)
-        .linesOfB()
-        .containsExactly("Line thirty nine", "A different line forty", "Line forty one");
-    assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(3);
-  }
-
-  @Test
-  public void rebaseHunkAfterLineNumberChangingOverlappingHunksIsIdentified() throws Exception {
-    String newFileContent =
-        FILE_CONTENT
-            .replace("Line 40\nLine 41\n", "Line forty\nLine forty one\n")
-            .replace("Line 60\n", "Line sixty\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-
-    rebaseChangeOn(changeId, commit2);
-    Function<String, String> contentModification =
-        fileContent ->
-            fileContent
-                .replace("Line forty\n", "Line 40\n")
-                .replace("Line 42\n", "Line forty two\nLine forty two and a half\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(40);
-    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 41", "Line 42");
-    assertThat(diffInfo)
-        .content()
-        .element(1)
-        .linesOfB()
-        .containsExactly("Line forty one", "Line forty two", "Line forty two and a half");
-    assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(17);
-    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 60");
-    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line sixty");
-    assertThat(diffInfo).content().element(3).isDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(40);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
-  }
-
-  @Test
-  public void rebaseHunksOneLineApartFromRegularHunkAreIdentified() throws Exception {
-    String newFileContent =
-        FILE_CONTENT.replace("Line 1\n", "Line one\n").replace("Line 5\n", "Line five\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-
-    rebaseChangeOn(changeId, commit2);
-    Function<String, String> contentModification =
-        fileContent -> fileContent.replace("Line 3\n", "Line three\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
-    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
-    assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(1);
-    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 3");
-    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line three");
-    assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(1);
-    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 5");
-    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line five");
-    assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(95);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
-  }
-
-  @Test
-  public void multipleRebaseEditsMixedWithRegularEditsCanBeIdentified() throws Exception {
-    addModifiedPatchSet(
-        changeId,
-        FILE_NAME,
-        fileContent -> fileContent.replace("Line 7\n", "Line seven\n").replace("Line 24\n", ""));
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-
-    ObjectId commit2 =
-        addCommit(
-            commit1,
-            FILE_NAME,
-            FILE_CONTENT
-                .replace("Line 2\n", "Line two\n")
-                .replace("Line 18\nLine 19\n", "Line eighteen\nLine nineteen\n")
-                .replace("Line 50\n", "Line fifty\n"));
-
-    rebaseChangeOn(changeId, commit2);
-    addModifiedPatchSet(
-        changeId,
-        FILE_NAME,
-        fileContent ->
-            fileContent
-                .replace("Line seven\n", "Line 7\n")
-                .replace("Line 9\n", "Line nine\n")
-                .replace("Line 60\n", "Line sixty\n"));
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(1);
-    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 2");
-    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line two");
-    assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(4);
-    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line seven");
-    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 7");
-    assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(1);
-    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 9");
-    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line nine");
-    assertThat(diffInfo).content().element(5).isNotDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(8);
-    assertThat(diffInfo).content().element(7).linesOfA().containsExactly("Line 18", "Line 19");
-    assertThat(diffInfo)
-        .content()
-        .element(7)
-        .linesOfB()
-        .containsExactly("Line eighteen", "Line nineteen");
-    assertThat(diffInfo).content().element(7).isDueToRebase();
-    assertThat(diffInfo).content().element(8).commonLines().hasSize(29);
-    assertThat(diffInfo).content().element(9).linesOfA().containsExactly("Line 50");
-    assertThat(diffInfo).content().element(9).linesOfB().containsExactly("Line fifty");
-    assertThat(diffInfo).content().element(9).isDueToRebase();
-    assertThat(diffInfo).content().element(10).commonLines().hasSize(9);
-    assertThat(diffInfo).content().element(11).linesOfA().containsExactly("Line 60");
-    assertThat(diffInfo).content().element(11).linesOfB().containsExactly("Line sixty");
-    assertThat(diffInfo).content().element(11).isNotDueToRebase();
-    assertThat(diffInfo).content().element(12).commonLines().hasSize(40);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(previousPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(3);
-  }
-
-  @Test
-  public void deletedFileDuringRebaseConsideredAsRegularHunkWhenModifiedInDiff() throws Exception {
-    // Modify the file and revert the modifications to allow rebasing.
-    addModifiedPatchSet(
-        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 50\n", "Line fifty\n"));
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-    addModifiedPatchSet(
-        changeId, FILE_NAME, fileContent -> fileContent.replace("Line fifty\n", "Line 50\n"));
-
-    ObjectId commit2 = addCommitRemovingFiles(commit1, FILE_NAME);
-
-    rebaseChangeOn(changeId, commit2);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).changeType().isEqualTo(ChangeType.DELETED);
-    assertThat(diffInfo).content().element(0).linesOfA().hasSize(100);
-    assertThat(diffInfo).content().element(0).linesOfB().isNull();
-    assertThat(diffInfo).content().element(0).isNotDueToRebase();
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(previousPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isNull();
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(100);
-  }
-
-  @Test
-  public void addedFileDuringRebaseConsideredAsRegularHunkWhenModifiedInDiff() throws Exception {
-    String newFilePath = "a_new_file.txt";
-    ObjectId commit2 = addCommit(commit1, newFilePath, "1st line\n2nd line\n3rd line\n");
-
-    rebaseChangeOn(changeId, commit2);
-    addModifiedPatchSet(
-        changeId, newFilePath, fileContent -> fileContent.replace("1st line\n", "First line\n"));
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, newFilePath).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).changeType().isEqualTo(ChangeType.ADDED);
-    assertThat(diffInfo).content().element(0).linesOfA().isNull();
-    assertThat(diffInfo).content().element(0).linesOfB().hasSize(3);
-    assertThat(diffInfo).content().element(0).isNotDueToRebase();
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.get(newFilePath)).linesInserted().isEqualTo(3);
-    assertThat(changedFiles.get(newFilePath)).linesDeleted().isNull();
-  }
-
-  @Test
-  public void rebaseHunkInRenamedFileIsIdentified_WhenFileIsRenamedDuringRebase() throws Exception {
-    String renamedFilePath = "renamed_some_file.txt";
-    ObjectId commit2 =
-        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 1\n", "Line one\n"));
-    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, renamedFilePath);
-
-    rebaseChangeOn(changeId, commit3);
-    Function<String, String> contentModification =
-        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
-    addModifiedPatchSet(changeId, renamedFilePath, contentModification);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, renamedFilePath).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
-    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
-    assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(48);
-    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 50");
-    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line fifty");
-    assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(50);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.get(renamedFilePath)).linesInserted().isEqualTo(1);
-    assertThat(changedFiles.get(renamedFilePath)).linesDeleted().isEqualTo(1);
-  }
-
-  @Test
-  public void rebaseHunkInRenamedFileIsIdentified_WhenFileIsRenamedInPatchSets() throws Exception {
-    String renamedFilePath = "renamed_some_file.txt";
-    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFilePath);
-    gApi.changes().id(changeId).edit().publish();
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-
-    // Revert the renaming to be able to rebase.
-    gApi.changes().id(changeId).edit().renameFile(renamedFilePath, FILE_NAME);
-    gApi.changes().id(changeId).edit().publish();
-
-    ObjectId commit2 =
-        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
-
-    rebaseChangeOn(changeId, commit2);
-    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFilePath);
-    gApi.changes().id(changeId).edit().publish();
-    Function<String, String> contentModification =
-        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
-    addModifiedPatchSet(changeId, renamedFilePath, contentModification);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, renamedFilePath).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
-    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
-    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
-    assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(44);
-    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 50");
-    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line fifty");
-    assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(50);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(previousPatchSetId);
-    assertThat(changedFiles.get(renamedFilePath)).linesInserted().isEqualTo(1);
-    assertThat(changedFiles.get(renamedFilePath)).linesDeleted().isEqualTo(1);
-  }
-
-  @Test
-  public void renamedFileWithOnlyRebaseHunksIsIdentified_WhenRenamedBetweenPatchSets()
-      throws Exception {
-    String newFilePath1 = "renamed_some_file.txt";
-    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath1);
-    gApi.changes().id(changeId).edit().publish();
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-
-    // Revert the renaming to be able to rebase.
-    gApi.changes().id(changeId).edit().renameFile(newFilePath1, FILE_NAME);
-    gApi.changes().id(changeId).edit().publish();
-
-    ObjectId commit2 =
-        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
-
-    rebaseChangeOn(changeId, commit2);
-    String newFilePath2 = "renamed_some_file_to_something_else.txt";
-    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath2);
-    gApi.changes().id(changeId).edit().publish();
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(previousPatchSetId);
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath2);
-    assertThat(changedFiles.get(newFilePath2)).linesInserted().isNull();
-    assertThat(changedFiles.get(newFilePath2)).linesDeleted().isNull();
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, newFilePath2).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
-    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
-    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
-    assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(95);
-  }
-
-  @Test
-  public void renamedFileWithOnlyRebaseHunksIsIdentified_WhenRenamedForRebaseAndForPatchSets()
-      throws Exception {
-    String newFilePath1 = "renamed_some_file.txt";
-    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath1);
-    gApi.changes().id(changeId).edit().publish();
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-
-    // Revert the renaming to be able to rebase.
-    gApi.changes().id(changeId).edit().renameFile(newFilePath1, FILE_NAME);
-    gApi.changes().id(changeId).edit().publish();
-
-    ObjectId commit2 =
-        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
-    String newFilePath2 = "renamed_some_file_during_rebase.txt";
-    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, newFilePath2);
-
-    rebaseChangeOn(changeId, commit3);
-    String newFilePath3 = "renamed_some_file_to_something_else.txt";
-    gApi.changes().id(changeId).edit().renameFile(newFilePath2, newFilePath3);
-    gApi.changes().id(changeId).edit().publish();
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(previousPatchSetId);
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath3);
-    assertThat(changedFiles.get(newFilePath3)).linesInserted().isNull();
-    assertThat(changedFiles.get(newFilePath3)).linesDeleted().isNull();
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, newFilePath3).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
-    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
-    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
-    assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(95);
-  }
-
-  @Test
-  public void copiedAndRenamedFilesWithOnlyRebaseHunksAreIdentified() throws Exception {
-    String newFileContent = FILE_CONTENT.replace("Line 5\n", "Line five\n");
-    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
-
-    rebaseChangeOn(changeId, commit2);
-    // Copies are only identified by JGit when paired with renaming.
-    String copyFileName = "copy_of_some_file.txt";
-    String renamedFileName = "renamed_some_file.txt";
-    gApi.changes()
-        .id(changeId)
-        .edit()
-        .modifyFile(copyFileName, RawInputUtil.create(newFileContent));
-    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
-    gApi.changes().id(changeId).edit().publish();
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(initialPatchSetId);
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, copyFileName, renamedFileName);
-
-    DiffInfo renamedFileDiffInfo =
-        getDiffRequest(changeId, CURRENT, renamedFileName).withBase(initialPatchSetId).get();
-    assertThat(renamedFileDiffInfo).content().element(0).commonLines().hasSize(4);
-    assertThat(renamedFileDiffInfo).content().element(1).linesOfA().containsExactly("Line 5");
-    assertThat(renamedFileDiffInfo).content().element(1).linesOfB().containsExactly("Line five");
-    assertThat(renamedFileDiffInfo).content().element(1).isDueToRebase();
-    assertThat(renamedFileDiffInfo).content().element(2).commonLines().hasSize(95);
-
-    DiffInfo copiedFileDiffInfo =
-        getDiffRequest(changeId, CURRENT, copyFileName).withBase(initialPatchSetId).get();
-    assertThat(copiedFileDiffInfo).content().element(0).commonLines().hasSize(4);
-    assertThat(copiedFileDiffInfo).content().element(1).linesOfA().containsExactly("Line 5");
-    assertThat(copiedFileDiffInfo).content().element(1).linesOfB().containsExactly("Line five");
-    assertThat(copiedFileDiffInfo).content().element(1).isDueToRebase();
-    assertThat(copiedFileDiffInfo).content().element(2).commonLines().hasSize(95);
-  }
-
-  /*
-   *                change PS B
-   *                   |
-   * change PS A    commit4
-   *    |              |
-   * commit2        commit3
-   *    |             /
-   * commit1 --------
-   */
-  @Test
-  public void rebaseHunksWhenRebasingOnAnotherChangeOrPatchSetAreIdentified() throws Exception {
-    ObjectId commit2 =
-        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
-    rebaseChangeOn(changeId, commit2);
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-
-    String commit3FileContent = FILE_CONTENT.replace("Line 35\n", "Line thirty five\n");
-    ObjectId commit3 = addCommit(commit1, FILE_NAME, commit3FileContent);
-    ObjectId commit4 =
-        addCommit(commit3, FILE_NAME, commit3FileContent.replace("Line 60\n", "Line sixty\n"));
-
-    rebaseChangeOn(changeId, commit4);
-    Function<String, String> contentModification =
-        fileContent -> fileContent.replace("Line 20\n", "Line twenty\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
-
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
-    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line five");
-    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 5");
-    assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(14);
-    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 20");
-    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line twenty");
-    assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(14);
-    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 35");
-    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line thirty five");
-    assertThat(diffInfo).content().element(5).isDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(24);
-    assertThat(diffInfo).content().element(7).linesOfA().containsExactly("Line 60");
-    assertThat(diffInfo).content().element(7).linesOfB().containsExactly("Line sixty");
-    assertThat(diffInfo).content().element(7).isDueToRebase();
-    assertThat(diffInfo).content().element(8).commonLines().hasSize(40);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(previousPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
-  }
-
-  /*
-   *                change PS B
-   *                   |
-   * change PS A    commit4
-   *    |              |
-   * commit2        commit3
-   *    |             /
-   * commit1 --------
-   */
-  @Test
-  public void unrelatedFileWhenRebasingOnAnotherChangeOrPatchSetIsIgnored() throws Exception {
-    ObjectId commit2 =
-        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
-    rebaseChangeOn(changeId, commit2);
-    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-
-    ObjectId commit3 =
-        addCommit(commit1, FILE_NAME2, FILE_CONTENT2.replace("2nd line\n", "Second line\n"));
-    ObjectId commit4 =
-        addCommit(commit3, FILE_NAME, FILE_CONTENT.replace("Line 60\n", "Line sixty\n"));
-
-    rebaseChangeOn(changeId, commit4);
-    Function<String, String> contentModification =
-        fileContent -> fileContent.replace("Line 20\n", "Line twenty\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).current().files(previousPatchSetId);
-    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
-  }
-
-  @Test
-  public void rebaseHunksWhenReversingPatchSetOrderAreIdentified() throws Exception {
-    ObjectId commit2 =
-        addCommit(
-            commit1,
-            FILE_NAME,
-            FILE_CONTENT.replace("Line 5\n", "Line five\n").replace("Line 35\n", ""));
-
-    rebaseChangeOn(changeId, commit2);
-    Function<String, String> contentModification =
-        fileContent -> fileContent.replace("Line 20\n", "Line twenty\n");
-    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
-
-    String currentPatchSetId = gApi.changes().id(changeId).get().currentRevision;
-    DiffInfo diffInfo =
-        getDiffRequest(changeId, initialPatchSetId, FILE_NAME).withBase(currentPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
-    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line five");
-    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 5");
-    assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(14);
-    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line twenty");
-    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 20");
-    assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(14);
-    assertThat(diffInfo).content().element(5).linesOfA().isNull();
-    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line 35");
-    assertThat(diffInfo).content().element(5).isDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(65);
-
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(changeId).revision(initialPatchSetId).files(currentPatchSetId);
-    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
-    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
-  }
-
-  private void assertDiffForNewFile(
-      PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
-    DiffInfo diff =
-        gApi.changes()
-            .id(pushResult.getChangeId())
-            .revision(pushResult.getCommit().name())
-            .file(path)
-            .diff();
-
-    List<String> headers = new ArrayList<>();
-    if (path.equals(COMMIT_MSG)) {
-      RevCommit c = pushResult.getCommit();
-
-      RevCommit parentCommit = c.getParents()[0];
-      String parentCommitId =
-          testRepo.getRevWalk().getObjectReader().abbreviate(parentCommit.getId(), 8).name();
-      headers.add("Parent:     " + parentCommitId + " (" + parentCommit.getShortMessage() + ")");
-
-      SimpleDateFormat dtfmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US);
-      PersonIdent author = c.getAuthorIdent();
-      dtfmt.setTimeZone(author.getTimeZone());
-      headers.add("Author:     " + author.getName() + " <" + author.getEmailAddress() + ">");
-      headers.add("AuthorDate: " + dtfmt.format(author.getWhen().getTime()));
-
-      PersonIdent committer = c.getCommitterIdent();
-      dtfmt.setTimeZone(committer.getTimeZone());
-      headers.add("Commit:     " + committer.getName() + " <" + committer.getEmailAddress() + ">");
-      headers.add("CommitDate: " + dtfmt.format(committer.getWhen().getTime()));
-      headers.add("");
-    }
-
-    if (!headers.isEmpty()) {
-      String header = Joiner.on("\n").join(headers);
-      expectedContentSideB = header + "\n" + expectedContentSideB;
-    }
-
-    assertDiffForNewFile(diff, pushResult.getCommit(), path, expectedContentSideB);
-  }
-
-  private void rebaseChangeOn(String changeId, ObjectId newParent) throws Exception {
-    RebaseInput rebaseInput = new RebaseInput();
-    rebaseInput.base = newParent.getName();
-    gApi.changes().id(changeId).current().rebase(rebaseInput);
-  }
-
-  private ObjectId addCommit(ObjectId parentCommit, String filePath, String fileContent)
-      throws Exception {
-    ImmutableMap<String, String> files = ImmutableMap.of(filePath, fileContent);
-    return addCommit(parentCommit, files);
-  }
-
-  private ObjectId addCommit(ObjectId parentCommit, ImmutableMap<String, String> files)
-      throws Exception {
-    testRepo.reset(parentCommit);
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, "Adjust files of repo", files);
-    PushOneCommit.Result result = push.to("refs/for/master");
-    return result.getCommit();
-  }
-
-  private ObjectId addCommit(ObjectId parentCommit, String filePath, byte[] fileContent)
-      throws Exception {
-    testRepo.reset(parentCommit);
-    PushOneCommit.Result result = createEmptyChange();
-    String changeId = result.getChangeId();
-    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
-    gApi.changes().id(changeId).edit().publish();
-    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
-    GitUtil.fetch(testRepo, "refs/*:refs/*");
-    return ObjectId.fromString(currentRevision);
-  }
-
-  private ObjectId addCommitRemovingFiles(ObjectId parentCommit, String... removedFilePaths)
-      throws Exception {
-    testRepo.reset(parentCommit);
-    Map<String, String> files =
-        Arrays.stream(removedFilePaths)
-            .collect(toMap(Function.identity(), path -> "Irrelevant content"));
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, "Remove files from repo", files);
-    PushOneCommit.Result result = push.rm("refs/for/master");
-    return result.getCommit();
-  }
-
-  private ObjectId addCommitRenamingFile(
-      ObjectId parentCommit, String oldFilePath, String newFilePath) throws Exception {
-    testRepo.reset(parentCommit);
-    PushOneCommit.Result result = createEmptyChange();
-    String changeId = result.getChangeId();
-    gApi.changes().id(changeId).edit().renameFile(oldFilePath, newFilePath);
-    gApi.changes().id(changeId).edit().publish();
-    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
-    GitUtil.fetch(testRepo, "refs/*:refs/*");
-    return ObjectId.fromString(currentRevision);
-  }
-
-  private Result createEmptyChange() throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, "Test change", ImmutableMap.of());
-    return push.to("refs/for/master");
-  }
-
-  private void addModifiedPatchSet(
-      String changeId, String filePath, Function<String, String> contentModification)
-      throws Exception {
-    try (BinaryResult content = gApi.changes().id(changeId).current().file(filePath).content()) {
-      String newContent = contentModification.apply(content.asString());
-      gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(newContent));
-    }
-    gApi.changes().id(changeId).edit().publish();
-  }
-
-  private static byte[] createRgbImage(int red, int green, int blue) throws IOException {
-    BufferedImage bufferedImage = new BufferedImage(10, 20, BufferedImage.TYPE_INT_RGB);
-    for (int x = 0; x < bufferedImage.getWidth(); x++) {
-      for (int y = 0; y < bufferedImage.getHeight(); y++) {
-        int rgb = (red << 16) + (green << 8) + blue;
-        bufferedImage.setRGB(x, y, rgb);
-      }
-    }
-
-    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
-    ImageIO.write(bufferedImage, "png", byteArrayOutputStream);
-    return byteArrayOutputStream.toByteArray();
-  }
-
-  private FileApi.DiffRequest getDiffRequest(String changeId, String revisionId, String fileName)
-      throws Exception {
-    return gApi.changes()
-        .id(changeId)
-        .revision(revisionId)
-        .file(fileName)
-        .diffRequest()
-        .withIntraline(intraline);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
deleted file mode 100644
index a2814c3..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ /dev/null
@@ -1,1358 +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.api.revision;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
-import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
-import static com.google.gerrit.acceptance.PushOneCommit.PATCH;
-import static com.google.gerrit.acceptance.PushOneCommit.PATCH_FILE_ONLY;
-import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
-import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
-import static org.eclipse.jgit.lib.Constants.HEAD;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Iterators;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.ChangeApi;
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.api.changes.DraftApi;
-import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.NotifyInfo;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
-import com.google.gerrit.extensions.api.changes.RevisionApi;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.FileInfo;
-import com.google.gerrit.extensions.common.GitPerson;
-import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.MergeableInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.ETagView;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.webui.PatchSetWebLink;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.server.change.GetRevisionActions;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
-import java.io.ByteArrayOutputStream;
-import java.sql.Timestamp;
-import java.text.DateFormat;
-import java.text.SimpleDateFormat;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.RefSpec;
-import org.junit.Test;
-
-public class RevisionIT extends AbstractDaemonTest {
-
-  @Inject private GetRevisionActions getRevisionActions;
-  @Inject private DynamicSet<PatchSetWebLink> patchSetLinks;
-
-  @Test
-  public void reviewTriplet() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(ReviewInput.approve());
-  }
-
-  @Test
-  public void reviewCurrent() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-  }
-
-  @Test
-  public void reviewNumber() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(1).review(ReviewInput.approve());
-
-    r = updateChange(r, "new content");
-    gApi.changes().id(r.getChangeId()).revision(2).review(ReviewInput.approve());
-  }
-
-  @Test
-  public void submit() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = project.get() + "~master~" + r.getChangeId();
-    gApi.changes().id(changeId).current().review(ReviewInput.approve());
-    gApi.changes().id(changeId).current().submit();
-    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
-  }
-
-  @Test
-  public void postSubmitApproval() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = project.get() + "~master~" + r.getChangeId();
-    gApi.changes().id(changeId).current().review(ReviewInput.recommend());
-
-    String label = "Code-Review";
-    ApprovalInfo approval = getApproval(changeId, label);
-    assertThat(approval.value).isEqualTo(1);
-    assertThat(approval.postSubmit).isNull();
-
-    // Submit by direct push.
-    git().push().setRefSpecs(new RefSpec(r.getCommit().name() + ":refs/heads/master")).call();
-    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
-
-    approval = getApproval(changeId, label);
-    assertThat(approval.value).isEqualTo(1);
-    assertThat(approval.postSubmit).isNull();
-    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), "Code-Review", 1, 2);
-
-    // Repeating the current label is allowed. Does not flip the postSubmit bit
-    // due to deduplication codepath.
-    gApi.changes().id(changeId).current().review(ReviewInput.recommend());
-    approval = getApproval(changeId, label);
-    assertThat(approval.value).isEqualTo(1);
-    assertThat(approval.postSubmit).isNull();
-
-    // Reducing vote is not allowed.
-    try {
-      gApi.changes().id(changeId).current().review(ReviewInput.dislike());
-      fail("expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
-    }
-    approval = getApproval(changeId, label);
-    assertThat(approval.value).isEqualTo(1);
-    assertThat(approval.postSubmit).isNull();
-
-    // Increasing vote is allowed.
-    gApi.changes().id(changeId).current().review(ReviewInput.approve());
-    approval = getApproval(changeId, label);
-    assertThat(approval.value).isEqualTo(2);
-    assertThat(approval.postSubmit).isTrue();
-    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), "Code-Review", 2);
-
-    // Decreasing to previous post-submit vote is still not allowed.
-    try {
-      gApi.changes().id(changeId).current().review(ReviewInput.dislike());
-      fail("expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
-    }
-    approval = getApproval(changeId, label);
-    assertThat(approval.value).isEqualTo(2);
-    assertThat(approval.postSubmit).isTrue();
-  }
-
-  @Test
-  public void postSubmitApprovalAfterVoteRemoved() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = project.get() + "~master~" + r.getChangeId();
-
-    setApiUser(admin);
-    revision(r).review(ReviewInput.approve());
-
-    setApiUser(user);
-    revision(r).review(ReviewInput.recommend());
-
-    setApiUser(admin);
-    gApi.changes().id(changeId).reviewer(user.username).deleteVote("Code-Review");
-    Optional<ApprovalInfo> crUser =
-        get(changeId, DETAILED_LABELS)
-            .labels
-            .get("Code-Review")
-            .all
-            .stream()
-            .filter(a -> a._accountId == user.id.get())
-            .findFirst();
-    assertThat(crUser).isPresent();
-    assertThat(crUser.get().value).isEqualTo(0);
-
-    revision(r).submit();
-
-    setApiUser(user);
-    ReviewInput in = new ReviewInput();
-    in.label("Code-Review", 1);
-    in.message = "Still LGTM";
-    revision(r).review(in);
-
-    ApprovalInfo cr =
-        gApi.changes()
-            .id(changeId)
-            .get(DETAILED_LABELS)
-            .labels
-            .get("Code-Review")
-            .all
-            .stream()
-            .filter(a -> a._accountId == user.getId().get())
-            .findFirst()
-            .get();
-    assertThat(cr.postSubmit).isTrue();
-  }
-
-  @Test
-  public void postSubmitDeleteApprovalNotAllowed() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    revision(r).review(ReviewInput.approve());
-    revision(r).submit();
-
-    ReviewInput in = new ReviewInput();
-    in.label("Code-Review", 0);
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Cannot reduce vote on labels for closed change: Code-Review");
-    revision(r).review(in);
-  }
-
-  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
-  @Test
-  public void approvalCopiedDuringSubmitIsNotPostSubmit() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
-    gApi.changes().id(id.get()).current().submit();
-
-    ChangeData cd = r.getChange();
-    assertThat(cd.patchSets()).hasSize(2);
-    PatchSetApproval psa =
-        Iterators.getOnlyElement(
-            cd.currentApprovals().stream().filter(a -> !a.isLegacySubmit()).iterator());
-    assertThat(psa.getPatchSetId().get()).isEqualTo(2);
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getValue()).isEqualTo(2);
-    assertThat(psa.isPostSubmit()).isFalse();
-  }
-
-  @Test
-  public void voteOnAbandonedChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).abandon();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is closed");
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
-  }
-
-  @Test
-  public void voteNotAllowedWithoutPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("is restricted");
-    gApi.changes().id(r.getChange().getId().get()).current().review(ReviewInput.approve());
-  }
-
-  @Test
-  public void cherryPick() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master%topic=someTopic");
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "foo";
-    in.message = "it goes to stable branch";
-    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
-    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
-
-    assertThat(orig.get().messages).hasSize(1);
-    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
-
-    Collection<ChangeMessageInfo> messages =
-        gApi.changes().id(project.get() + "~master~" + r.getChangeId()).get().messages;
-    assertThat(messages).hasSize(2);
-
-    String cherryPickedRevision = cherry.get().currentRevision;
-    String expectedMessage =
-        String.format(
-            "Patch Set 1: Cherry Picked\n\n"
-                + "This patchset was cherry picked to branch %s as commit %s",
-            in.destination, cherryPickedRevision);
-
-    Iterator<ChangeMessageInfo> origIt = messages.iterator();
-    origIt.next();
-    assertThat(origIt.next().message).isEqualTo(expectedMessage);
-
-    assertThat(cherry.get().messages).hasSize(1);
-    Iterator<ChangeMessageInfo> cherryIt = cherry.get().messages.iterator();
-    expectedMessage = "Patch Set 1: Cherry Picked from branch master.";
-    assertThat(cherryIt.next().message).isEqualTo(expectedMessage);
-
-    assertThat(cherry.get().subject).contains(in.message);
-    assertThat(cherry.get().topic).isEqualTo("someTopic-foo");
-    cherry.current().review(ReviewInput.approve());
-    cherry.current().submit();
-  }
-
-  @Test
-  public void cherryPickSetChangeId() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "foo";
-    String id = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbe3f";
-    in.message = "it goes to foo branch\n\nChange-Id: " + id;
-
-    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
-    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
-
-    assertThat(orig.get().messages).hasSize(1);
-    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
-
-    ChangeInfo changeInfo = cherry.get();
-
-    // The cherry-pick honors the ChangeId specified in the input message:
-    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
-    assertThat(revInfo).isNotNull();
-    assertThat(revInfo.commit.message).endsWith(id + "\n");
-  }
-
-  @Test
-  public void cherryPickwithNoTopic() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "foo";
-    in.message = "it goes to stable branch";
-    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
-    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
-
-    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
-    assertThat(cherry.get().topic).isNull();
-    cherry.current().review(ReviewInput.approve());
-    cherry.current().submit();
-  }
-
-  @Test
-  public void cherryPickToSameBranch() throws Exception {
-    PushOneCommit.Result r = createChange();
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "master";
-    in.message = "it generates a new patch set\n\nChange-Id: " + r.getChangeId();
-    ChangeInfo cherryInfo =
-        gApi.changes()
-            .id(project.get() + "~master~" + r.getChangeId())
-            .revision(r.getCommit().name())
-            .cherryPick(in)
-            .get();
-    assertThat(cherryInfo.messages).hasSize(2);
-    Iterator<ChangeMessageInfo> cherryIt = cherryInfo.messages.iterator();
-    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
-    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 2.");
-  }
-
-  @Test
-  public void cherryPickToSameBranchWithRebase() throws Exception {
-    // Push a new change, then merge it
-    PushOneCommit.Result baseChange = createChange();
-    String triplet = project.get() + "~master~" + baseChange.getChangeId();
-    RevisionApi baseRevision = gApi.changes().id(triplet).current();
-    baseRevision.review(ReviewInput.approve());
-    baseRevision.submit();
-
-    // Push a new change (change 1)
-    PushOneCommit.Result r1 = createChange();
-
-    // Push another new change (change 2)
-    String subject = "Test change\n\nChange-Id: Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, subject, "another_file.txt", "another content");
-    PushOneCommit.Result r2 = push.to("refs/for/master");
-
-    // Change 2's parent should be change 1
-    assertThat(r2.getCommit().getParents()[0].name()).isEqualTo(r1.getCommit().name());
-
-    // Cherry pick change 2 onto the same branch
-    triplet = project.get() + "~master~" + r2.getChangeId();
-    ChangeApi orig = gApi.changes().id(triplet);
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "master";
-    in.message = subject;
-    ChangeApi cherry = orig.revision(r2.getCommit().name()).cherryPick(in);
-    ChangeInfo cherryInfo = cherry.get();
-    assertThat(cherryInfo.messages).hasSize(2);
-    Iterator<ChangeMessageInfo> cherryIt = cherryInfo.messages.iterator();
-    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
-    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 2.");
-
-    // Parent of change 2 should now be the change that was merged, i.e.
-    // change 2 is rebased onto the head of the master branch.
-    String newParent =
-        cherryInfo.revisions.get(cherryInfo.currentRevision).commit.parents.get(0).commit;
-    assertThat(newParent).isEqualTo(baseChange.getCommit().name());
-  }
-
-  @Test
-  public void cherryPickIdenticalTree() throws Exception {
-    PushOneCommit.Result r = createChange();
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "foo";
-    in.message = "it goes to stable branch";
-    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
-    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
-
-    assertThat(orig.get().messages).hasSize(1);
-    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
-
-    Collection<ChangeMessageInfo> messages =
-        gApi.changes().id(project.get() + "~master~" + r.getChangeId()).get().messages;
-    assertThat(messages).hasSize(2);
-
-    assertThat(cherry.get().subject).contains(in.message);
-    cherry.current().review(ReviewInput.approve());
-    cherry.current().submit();
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Cherry pick failed: identical tree");
-    orig.revision(r.getCommit().name()).cherryPick(in);
-  }
-
-  @Test
-  public void cherryPickConflict() throws Exception {
-    PushOneCommit.Result r = createChange();
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "foo";
-    in.message = "it goes to stable branch";
-    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
-
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            "another content");
-    push.to("refs/heads/foo");
-
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    ChangeApi orig = gApi.changes().id(triplet);
-    assertThat(orig.get().messages).hasSize(1);
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Cherry pick failed: merge conflict");
-    orig.revision(r.getCommit().name()).cherryPick(in);
-  }
-
-  @Test
-  public void cherryPickToExistingChange() throws Exception {
-    PushOneCommit.Result r1 =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "a")
-            .to("refs/for/master");
-    String t1 = project.get() + "~master~" + r1.getChangeId();
-
-    BranchInput bin = new BranchInput();
-    bin.revision = r1.getCommit().getParent(0).name();
-    gApi.projects().name(project.get()).branch("foo").create(bin);
-
-    PushOneCommit.Result r2 =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "b", r1.getChangeId())
-            .to("refs/for/foo");
-    String t2 = project.get() + "~foo~" + r2.getChangeId();
-    gApi.changes().id(t2).abandon();
-
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "foo";
-    in.message = r1.getCommit().getFullMessage();
-    try {
-      gApi.changes().id(t1).current().cherryPick(in);
-      fail();
-    } catch (ResourceConflictException e) {
-      assertThat(e.getMessage())
-          .isEqualTo(
-              "Cannot create new patch set of change "
-                  + info(t2)._number
-                  + " because it is abandoned");
-    }
-
-    gApi.changes().id(t2).restore();
-    gApi.changes().id(t1).current().cherryPick(in);
-    assertThat(get(t2).revisions).hasSize(2);
-    assertThat(gApi.changes().id(t2).current().file(FILE_NAME).content().asString()).isEqualTo("a");
-  }
-
-  @Test
-  public void cherryPickMergeRelativeToDefaultParent() throws Exception {
-    String parent1FileName = "a.txt";
-    String parent2FileName = "b.txt";
-    PushOneCommit.Result mergeChangeResult =
-        createCherryPickableMerge(parent1FileName, parent2FileName);
-
-    String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
-
-    CherryPickInput cherryPickInput = new CherryPickInput();
-    cherryPickInput.destination = cherryPickBranchName;
-    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
-
-    ChangeInfo cherryPickedChangeInfo =
-        gApi.changes()
-            .id(mergeChangeResult.getChangeId())
-            .current()
-            .cherryPick(cherryPickInput)
-            .get();
-
-    Map<String, FileInfo> cherryPickedFilesByName =
-        cherryPickedChangeInfo.revisions.get(cherryPickedChangeInfo.currentRevision).files;
-    assertThat(cherryPickedFilesByName).containsKey(parent2FileName);
-    assertThat(cherryPickedFilesByName).doesNotContainKey(parent1FileName);
-  }
-
-  @Test
-  public void cherryPickMergeRelativeToSpecificParent() throws Exception {
-    String parent1FileName = "a.txt";
-    String parent2FileName = "b.txt";
-    PushOneCommit.Result mergeChangeResult =
-        createCherryPickableMerge(parent1FileName, parent2FileName);
-
-    String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
-
-    CherryPickInput cherryPickInput = new CherryPickInput();
-    cherryPickInput.destination = cherryPickBranchName;
-    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
-    cherryPickInput.parent = 2;
-
-    ChangeInfo cherryPickedChangeInfo =
-        gApi.changes()
-            .id(mergeChangeResult.getChangeId())
-            .current()
-            .cherryPick(cherryPickInput)
-            .get();
-
-    Map<String, FileInfo> cherryPickedFilesByName =
-        cherryPickedChangeInfo.revisions.get(cherryPickedChangeInfo.currentRevision).files;
-    assertThat(cherryPickedFilesByName).containsKey(parent1FileName);
-    assertThat(cherryPickedFilesByName).doesNotContainKey(parent2FileName);
-  }
-
-  @Test
-  public void cherryPickMergeUsingInvalidParent() throws Exception {
-    String parent1FileName = "a.txt";
-    String parent2FileName = "b.txt";
-    PushOneCommit.Result mergeChangeResult =
-        createCherryPickableMerge(parent1FileName, parent2FileName);
-
-    String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
-
-    CherryPickInput cherryPickInput = new CherryPickInput();
-    cherryPickInput.destination = cherryPickBranchName;
-    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
-    cherryPickInput.parent = 0;
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "Cherry Pick: Parent 0 does not exist. Please specify a parent in range [1, 2].");
-    gApi.changes().id(mergeChangeResult.getChangeId()).current().cherryPick(cherryPickInput);
-  }
-
-  @Test
-  public void cherryPickMergeUsingNonExistentParent() throws Exception {
-    String parent1FileName = "a.txt";
-    String parent2FileName = "b.txt";
-    PushOneCommit.Result mergeChangeResult =
-        createCherryPickableMerge(parent1FileName, parent2FileName);
-
-    String cherryPickBranchName = "branch_for_cherry_pick";
-    createBranch(new Branch.NameKey(project, cherryPickBranchName));
-
-    CherryPickInput cherryPickInput = new CherryPickInput();
-    cherryPickInput.destination = cherryPickBranchName;
-    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
-    cherryPickInput.parent = 3;
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "Cherry Pick: Parent 3 does not exist. Please specify a parent in range [1, 2].");
-    gApi.changes().id(mergeChangeResult.getChangeId()).current().cherryPick(cherryPickInput);
-  }
-
-  @Test
-  public void cherryPickNotify() throws Exception {
-    createBranch(new Branch.NameKey(project, "branch-1"));
-    createBranch(new Branch.NameKey(project, "branch-2"));
-    createBranch(new Branch.NameKey(project, "branch-3"));
-
-    // Creates a change for 'admin'.
-    PushOneCommit.Result result = createChange();
-    String changeId = project.get() + "~master~" + result.getChangeId();
-
-    // 'user' cherry-picks the change to a new branch, the source change's author/committer('admin')
-    // will be added as a reviewer of the newly created change.
-    setApiUser(user);
-    CherryPickInput input = new CherryPickInput();
-    input.message = "it goes to a new branch";
-
-    // Enable the notification. 'admin' as a reviewer should be notified.
-    input.destination = "branch-1";
-    input.notify = NotifyHandling.ALL;
-    sender.clear();
-    gApi.changes().id(changeId).current().cherryPick(input);
-    assertNotifyCc(admin);
-
-    // Disable the notification. 'admin' as a reviewer should not be notified any more.
-    input.destination = "branch-2";
-    input.notify = NotifyHandling.NONE;
-    sender.clear();
-    gApi.changes().id(changeId).current().cherryPick(input);
-    assertThat(sender.getMessages()).hasSize(0);
-
-    // Disable the notification. The user provided in the 'notifyDetails' should still be notified.
-    TestAccount userToNotify = accountCreator.user2();
-    input.destination = "branch-3";
-    input.notify = NotifyHandling.NONE;
-    input.notifyDetails =
-        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
-    sender.clear();
-    gApi.changes().id(changeId).current().cherryPick(input);
-    assertNotifyTo(userToNotify);
-  }
-
-  @Test
-  public void cherryPickKeepReviewers() throws Exception {
-    createBranch(new Branch.NameKey(project, "stable"));
-
-    // Change is created by 'admin'.
-    PushOneCommit.Result r = createChange();
-    // Change is approved by 'admin2'. Change is CC'd to 'user'.
-    setApiUser(accountCreator.admin2());
-    ReviewInput in = ReviewInput.approve();
-    in.reviewer(user.email, ReviewerState.CC, true);
-    gApi.changes().id(r.getChangeId()).current().review(in);
-
-    // Change is cherrypicked by 'user2'.
-    setApiUser(accountCreator.user2());
-    CherryPickInput cin = new CherryPickInput();
-    cin.message = "this need to go to stable";
-    cin.destination = "stable";
-    cin.keepReviewers = true;
-    Map<ReviewerState, Collection<AccountInfo>> result =
-        gApi.changes().id(r.getChangeId()).current().cherryPick(cin).get().reviewers;
-
-    // 'admin' should be a reviewer as the old owner.
-    // 'admin2' should be a reviewer as the old reviewer.
-    // 'user' should be on CC.
-    assertThat(result).containsKey(ReviewerState.REVIEWER);
-    List<Integer> reviewers =
-        result.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
-    if (notesMigration.readChanges()) {
-      assertThat(result).containsKey(ReviewerState.CC);
-      List<Integer> ccs =
-          result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
-      assertThat(ccs).containsExactly(user.id.get());
-      assertThat(reviewers).containsExactly(admin.id.get(), accountCreator.admin2().id.get());
-    } else {
-      assertThat(reviewers)
-          .containsExactly(user.id.get(), admin.id.get(), accountCreator.admin2().id.get());
-    }
-  }
-
-  @Test
-  public void cherryPickToMergedChangeRevision() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
-
-    PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
-    dstChange.assertOkStatus();
-
-    merge(dstChange);
-
-    PushOneCommit.Result result = createChange(testRepo, "foo", SUBJECT, "b.txt", "c", "t");
-    result.assertOkStatus();
-    merge(result);
-
-    PushOneCommit.Result srcChange = createChange();
-
-    CherryPickInput input = new CherryPickInput();
-    input.destination = "foo";
-    input.base = dstChange.getCommit().name();
-    input.message = srcChange.getCommit().getFullMessage();
-    ChangeInfo changeInfo =
-        gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
-    assertCherryPickResult(changeInfo, input, srcChange.getChangeId());
-  }
-
-  @Test
-  public void cherryPickToOpenChangeRevision() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
-
-    PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
-    dstChange.assertOkStatus();
-
-    PushOneCommit.Result srcChange = createChange();
-
-    CherryPickInput input = new CherryPickInput();
-    input.destination = "foo";
-    input.base = dstChange.getCommit().name();
-    input.message = srcChange.getCommit().getFullMessage();
-    ChangeInfo changeInfo =
-        gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
-    assertCherryPickResult(changeInfo, input, srcChange.getChangeId());
-  }
-
-  @Test
-  public void cherryPickToNonVisibleChangeFails() throws Exception {
-    createBranch(new Branch.NameKey(project, "foo"));
-
-    PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
-    dstChange.assertOkStatus();
-
-    gApi.changes().id(dstChange.getChangeId()).setPrivate(true, null);
-
-    PushOneCommit.Result srcChange = createChange();
-
-    CherryPickInput input = new CherryPickInput();
-    input.destination = "foo";
-    input.base = dstChange.getCommit().name();
-    input.message = srcChange.getCommit().getFullMessage();
-
-    setApiUser(user);
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage(
-        String.format("Commit %s does not exist on branch refs/heads/foo", input.base));
-    gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
-  }
-
-  @Test
-  public void cherryPickToAbandonedChangeFails() throws Exception {
-    PushOneCommit.Result change1 = createChange();
-    PushOneCommit.Result change2 = createChange();
-    gApi.changes().id(change2.getChangeId()).abandon();
-
-    CherryPickInput input = new CherryPickInput();
-    input.destination = "master";
-    input.base = change2.getCommit().name();
-    input.message = change1.getCommit().getFullMessage();
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format(
-            "Change %s with commit %s is %s",
-            change2.getChange().getId().get(), input.base, ChangeStatus.ABANDONED));
-    gApi.changes().id(change1.getChangeId()).current().cherryPick(input);
-  }
-
-  @Test
-  public void cherryPickWithInvalidBaseFails() throws Exception {
-    PushOneCommit.Result change1 = createChange();
-
-    CherryPickInput input = new CherryPickInput();
-    input.destination = "master";
-    input.base = "invalid-sha1";
-    input.message = change1.getCommit().getFullMessage();
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(String.format("Base %s doesn't represent a valid SHA-1", input.base));
-    gApi.changes().id(change1.getChangeId()).current().cherryPick(input);
-  }
-
-  @Test
-  public void cherryPickToCommitWithoutChangeId() throws Exception {
-    RevCommit commit1 = createNewCommitWithoutChangeId("refs/heads/foo", "a.txt", "content 1");
-
-    createNewCommitWithoutChangeId("refs/heads/foo", "a.txt", "content 2");
-
-    PushOneCommit.Result srcChange = createChange("subject", "b.txt", "b");
-    srcChange.assertOkStatus();
-
-    CherryPickInput input = new CherryPickInput();
-    input.destination = "foo";
-    input.base = commit1.name();
-    input.message = srcChange.getCommit().getFullMessage();
-    ChangeInfo changeInfo =
-        gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
-    assertCherryPickResult(changeInfo, input, srcChange.getChangeId());
-  }
-
-  @Test
-  public void canRebase() throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    PushOneCommit.Result r1 = push.to("refs/for/master");
-    merge(r1);
-
-    push = pushFactory.create(db, admin.getIdent(), testRepo);
-    PushOneCommit.Result r2 = push.to("refs/for/master");
-    boolean canRebase =
-        gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).canRebase();
-    assertThat(canRebase).isFalse();
-    merge(r2);
-
-    testRepo.reset(r1.getCommit());
-    push = pushFactory.create(db, admin.getIdent(), testRepo);
-    PushOneCommit.Result r3 = push.to("refs/for/master");
-
-    canRebase = gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).canRebase();
-    assertThat(canRebase).isTrue();
-  }
-
-  @Test
-  public void setUnsetReviewedFlag() throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    PushOneCommit.Result r = push.to("refs/for/master");
-
-    gApi.changes().id(r.getChangeId()).current().setReviewed(PushOneCommit.FILE_NAME, true);
-
-    assertThat(Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).current().reviewed()))
-        .isEqualTo(PushOneCommit.FILE_NAME);
-
-    gApi.changes().id(r.getChangeId()).current().setReviewed(PushOneCommit.FILE_NAME, false);
-
-    assertThat(gApi.changes().id(r.getChangeId()).current().reviewed()).isEmpty();
-  }
-
-  @Test
-  public void mergeable() throws Exception {
-    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-
-    PushOneCommit push1 =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            "push 1 content");
-
-    PushOneCommit.Result r1 = push1.to("refs/for/master");
-    assertMergeable(r1.getChangeId(), true);
-    merge(r1);
-
-    // Reset HEAD to initial so the new change is a merge conflict.
-    RefUpdate ru = repo().updateRef(HEAD);
-    ru.setNewObjectId(initial);
-    assertThat(ru.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
-
-    PushOneCommit push2 =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            "push 2 content");
-    PushOneCommit.Result r2 = push2.to("refs/for/master");
-    assertMergeable(r2.getChangeId(), false);
-    // TODO(dborowitz): Test for other-branches.
-  }
-
-  @Test
-  public void files() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Map<String, FileInfo> files =
-        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files();
-    assertThat(files).hasSize(2);
-    assertThat(Iterables.all(files.keySet(), f -> f.matches(FILE_NAME + '|' + COMMIT_MSG)))
-        .isTrue();
-  }
-
-  @Test
-  public void filesOnMergeCommitChange() throws Exception {
-    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
-
-    // list files against auto-merge
-    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files().keySet())
-        .containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "bar");
-
-    // list files against parent 1
-    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(1).keySet())
-        .containsExactly(COMMIT_MSG, MERGE_LIST, "bar");
-
-    // list files against parent 2
-    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(2).keySet())
-        .containsExactly(COMMIT_MSG, MERGE_LIST, "foo");
-  }
-
-  @Test
-  public void listFilesOnDifferentBases() throws Exception {
-    PushOneCommit.Result result1 = createChange();
-    String changeId = result1.getChangeId();
-    PushOneCommit.Result result2 = amendChange(changeId, SUBJECT, "b.txt", "b");
-    PushOneCommit.Result result3 = amendChange(changeId, SUBJECT, "c.txt", "c");
-
-    String revId1 = result1.getCommit().name();
-    String revId2 = result2.getCommit().name();
-    String revId3 = result3.getCommit().name();
-
-    assertThat(gApi.changes().id(changeId).revision(revId1).files(null).keySet())
-        .containsExactly(COMMIT_MSG, "a.txt");
-    assertThat(gApi.changes().id(changeId).revision(revId2).files(null).keySet())
-        .containsExactly(COMMIT_MSG, "a.txt", "b.txt");
-    assertThat(gApi.changes().id(changeId).revision(revId3).files(null).keySet())
-        .containsExactly(COMMIT_MSG, "a.txt", "b.txt", "c.txt");
-
-    assertThat(gApi.changes().id(changeId).revision(revId2).files(revId1).keySet())
-        .containsExactly(COMMIT_MSG, "b.txt");
-    assertThat(gApi.changes().id(changeId).revision(revId3).files(revId1).keySet())
-        .containsExactly(COMMIT_MSG, "b.txt", "c.txt");
-    assertThat(gApi.changes().id(changeId).revision(revId3).files(revId2).keySet())
-        .containsExactly(COMMIT_MSG, "c.txt");
-  }
-
-  @Test
-  public void queryRevisionFiles() throws Exception {
-    Map<String, String> files = ImmutableMap.of("file1.txt", "content 1", "file2.txt", "content 2");
-    PushOneCommit.Result result =
-        pushFactory.create(db, admin.getIdent(), testRepo, SUBJECT, files).to("refs/for/master");
-    result.assertOkStatus();
-    String changeId = result.getChangeId();
-
-    assertThat(gApi.changes().id(changeId).current().queryFiles("file1.txt"))
-        .containsExactly("file1.txt");
-    assertThat(gApi.changes().id(changeId).current().queryFiles("file2.txt"))
-        .containsExactly("file2.txt");
-    assertThat(gApi.changes().id(changeId).current().queryFiles("file1"))
-        .containsExactly("file1.txt");
-    assertThat(gApi.changes().id(changeId).current().queryFiles("file2"))
-        .containsExactly("file2.txt");
-    assertThat(gApi.changes().id(changeId).current().queryFiles("file"))
-        .containsExactly("file1.txt", "file2.txt");
-    assertThat(gApi.changes().id(changeId).current().queryFiles(""))
-        .containsExactly("file1.txt", "file2.txt");
-  }
-
-  @Test
-  public void description() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertDescription(r, "");
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
-    assertDescription(r, "test");
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("");
-    assertDescription(r, "");
-  }
-
-  @Test
-  public void setDescriptionNotAllowedWithoutPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertDescription(r, "");
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("edit description not permitted");
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
-  }
-
-  @Test
-  public void setDescriptionAllowedWithPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertDescription(r, "");
-    grant(project, "refs/heads/master", Permission.OWNER, false, REGISTERED_USERS);
-    setApiUser(user);
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
-    assertDescription(r, "test");
-  }
-
-  private void assertDescription(PushOneCommit.Result r, String expected) throws Exception {
-    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
-        .isEqualTo(expected);
-  }
-
-  @Test
-  public void content() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertContent(r, FILE_NAME, FILE_CONTENT);
-    assertContent(r, COMMIT_MSG, r.getCommit().getFullMessage());
-  }
-
-  @Test
-  public void contentType() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    String endPoint =
-        "/changes/"
-            + r.getChangeId()
-            + "/revisions/"
-            + r.getCommit().name()
-            + "/files/"
-            + FILE_NAME
-            + "/content";
-    RestResponse response = adminRestSession.head(endPoint);
-    response.assertOK();
-    assertThat(response.getContentType()).startsWith("text/plain");
-    assertThat(response.hasContent()).isFalse();
-  }
-
-  @Test
-  public void commit() throws Exception {
-    WebLinkInfo expectedWebLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
-    patchSetLinks.add(
-        new PatchSetWebLink() {
-          @Override
-          public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
-            return expectedWebLinkInfo;
-          }
-        });
-
-    PushOneCommit.Result r = createChange();
-    RevCommit c = r.getCommit();
-
-    CommitInfo commitInfo = gApi.changes().id(r.getChangeId()).current().commit(false);
-    assertThat(commitInfo.commit).isEqualTo(c.name());
-    assertPersonIdent(commitInfo.author, c.getAuthorIdent());
-    assertPersonIdent(commitInfo.committer, c.getCommitterIdent());
-    assertThat(commitInfo.message).isEqualTo(c.getFullMessage());
-    assertThat(commitInfo.subject).isEqualTo(c.getShortMessage());
-    assertThat(commitInfo.parents).hasSize(1);
-    assertThat(Iterables.getOnlyElement(commitInfo.parents).commit)
-        .isEqualTo(c.getParent(0).name());
-    assertThat(commitInfo.webLinks).isNull();
-
-    commitInfo = gApi.changes().id(r.getChangeId()).current().commit(true);
-    assertThat(commitInfo.webLinks).hasSize(1);
-    WebLinkInfo webLinkInfo = Iterables.getOnlyElement(commitInfo.webLinks);
-    assertThat(webLinkInfo.name).isEqualTo(expectedWebLinkInfo.name);
-    assertThat(webLinkInfo.imageUrl).isEqualTo(expectedWebLinkInfo.imageUrl);
-    assertThat(webLinkInfo.url).isEqualTo(expectedWebLinkInfo.url);
-    assertThat(webLinkInfo.target).isEqualTo(expectedWebLinkInfo.target);
-  }
-
-  private void assertPersonIdent(GitPerson gitPerson, PersonIdent expectedIdent) {
-    assertThat(gitPerson.name).isEqualTo(expectedIdent.getName());
-    assertThat(gitPerson.email).isEqualTo(expectedIdent.getEmailAddress());
-    assertThat(gitPerson.date).isEqualTo(new Timestamp(expectedIdent.getWhen().getTime()));
-    assertThat(gitPerson.tz).isEqualTo(expectedIdent.getTimeZoneOffset());
-  }
-
-  private void assertMergeable(String id, boolean expected) throws Exception {
-    MergeableInfo m = gApi.changes().id(id).current().mergeable();
-    assertThat(m.mergeable).isEqualTo(expected);
-    assertThat(m.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
-    assertThat(m.mergeableInto).isNull();
-    ChangeInfo c = gApi.changes().id(id).info();
-    assertThat(c.mergeable).isEqualTo(expected);
-  }
-
-  @Test
-  public void drafts() throws Exception {
-    PushOneCommit.Result r = createChange();
-    DraftInput in = new DraftInput();
-    in.line = 1;
-    in.message = "nit: trailing whitespace";
-    in.path = FILE_NAME;
-
-    DraftApi draftApi =
-        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).createDraft(in);
-    assertThat(draftApi.get().message).isEqualTo(in.message);
-    assertThat(
-            gApi.changes()
-                .id(r.getChangeId())
-                .revision(r.getCommit().name())
-                .draft(draftApi.get().id)
-                .get()
-                .message)
-        .isEqualTo(in.message);
-    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).drafts())
-        .hasSize(1);
-
-    in.message = "good catch!";
-    assertThat(
-            gApi.changes()
-                .id(r.getChangeId())
-                .revision(r.getCommit().name())
-                .draft(draftApi.get().id)
-                .update(in)
-                .message)
-        .isEqualTo(in.message);
-
-    assertThat(
-            gApi.changes()
-                .id(r.getChangeId())
-                .revision(r.getCommit().name())
-                .draft(draftApi.get().id)
-                .get()
-                .author
-                .email)
-        .isEqualTo(admin.email);
-
-    draftApi.delete();
-    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).drafts())
-        .isEmpty();
-  }
-
-  @Test
-  public void comments() throws Exception {
-    PushOneCommit.Result r = createChange();
-    CommentInput in = new CommentInput();
-    in.line = 1;
-    in.message = "nit: trailing whitespace";
-    in.path = FILE_NAME;
-    ReviewInput reviewInput = new ReviewInput();
-    Map<String, List<CommentInput>> comments = new HashMap<>();
-    comments.put(FILE_NAME, Collections.singletonList(in));
-    reviewInput.comments = comments;
-    reviewInput.message = "comment test";
-    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
-
-    Map<String, List<CommentInfo>> out =
-        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).comments();
-    assertThat(out).hasSize(1);
-    CommentInfo comment = Iterables.getOnlyElement(out.get(FILE_NAME));
-    assertThat(comment.message).isEqualTo(in.message);
-    assertThat(comment.author.email).isEqualTo(admin.email);
-    assertThat(comment.path).isNull();
-
-    List<CommentInfo> list =
-        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).commentsAsList();
-    assertThat(list).hasSize(1);
-
-    CommentInfo comment2 = list.get(0);
-    assertThat(comment2.path).isEqualTo(FILE_NAME);
-    assertThat(comment2.line).isEqualTo(comment.line);
-    assertThat(comment2.message).isEqualTo(comment.message);
-    assertThat(comment2.author.email).isEqualTo(comment.author.email);
-
-    assertThat(
-            gApi.changes()
-                .id(r.getChangeId())
-                .revision(r.getCommit().name())
-                .comment(comment.id)
-                .get()
-                .message)
-        .isEqualTo(in.message);
-  }
-
-  @Test
-  public void patch() throws Exception {
-    PushOneCommit.Result r = createChange();
-    ChangeApi changeApi = gApi.changes().id(r.getChangeId());
-    BinaryResult bin = changeApi.revision(r.getCommit().name()).patch();
-    ByteArrayOutputStream os = new ByteArrayOutputStream();
-    bin.writeTo(os);
-    String res = new String(os.toByteArray(), UTF_8);
-    ChangeInfo change = changeApi.get();
-    RevisionInfo rev = change.revisions.get(change.currentRevision);
-    DateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
-    String date = df.format(rev.commit.author.date);
-    assertThat(res).isEqualTo(String.format(PATCH, r.getCommit().name(), date, r.getChangeId()));
-  }
-
-  @Test
-  public void patchWithPath() throws Exception {
-    PushOneCommit.Result r = createChange();
-    ChangeApi changeApi = gApi.changes().id(r.getChangeId());
-    BinaryResult bin = changeApi.revision(r.getCommit().name()).patch(FILE_NAME);
-    ByteArrayOutputStream os = new ByteArrayOutputStream();
-    bin.writeTo(os);
-    String res = new String(os.toByteArray(), UTF_8);
-    assertThat(res).isEqualTo(PATCH_FILE_ONLY);
-
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("File not found: nonexistent-file.");
-    changeApi.revision(r.getCommit().name()).patch("nonexistent-file");
-  }
-
-  @Test
-  public void actions() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(current(r).actions().keySet())
-        .containsExactly("cherrypick", "description", "rebase");
-
-    current(r).review(ReviewInput.approve());
-    assertThat(current(r).actions().keySet())
-        .containsExactly("submit", "cherrypick", "description", "rebase");
-
-    current(r).submit();
-    assertThat(current(r).actions().keySet()).containsExactly("cherrypick");
-  }
-
-  @Test
-  public void actionsETag() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-
-    String oldETag = checkETag(getRevisionActions, r2, null);
-    current(r2).review(ReviewInput.approve());
-    oldETag = checkETag(getRevisionActions, r2, oldETag);
-
-    // Dependent change is included in ETag.
-    current(r1).review(ReviewInput.approve());
-    oldETag = checkETag(getRevisionActions, r2, oldETag);
-
-    current(r2).submit();
-    oldETag = checkETag(getRevisionActions, r2, oldETag);
-  }
-
-  @Test
-  public void deleteVoteOnNonCurrentPatchSet() throws Exception {
-    PushOneCommit.Result r = createChange(); // patch set 1
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-
-    // patch set 2
-    amendChange(r.getChangeId());
-
-    // code-review
-    setApiUser(user);
-    recommend(r.getChangeId());
-
-    // check if it's blocked to delete a vote on a non-current patch set.
-    setApiUser(admin);
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("Cannot access on non-current patch set");
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().getName())
-        .reviewer(user.getId().toString())
-        .deleteVote("Code-Review");
-  }
-
-  @Test
-  public void deleteVoteOnCurrentPatchSet() throws Exception {
-    PushOneCommit.Result r = createChange(); // patch set 1
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-
-    // patch set 2
-    amendChange(r.getChangeId());
-
-    // code-review
-    setApiUser(user);
-    recommend(r.getChangeId());
-
-    setApiUser(admin);
-    gApi.changes()
-        .id(r.getChangeId())
-        .current()
-        .reviewer(user.getId().toString())
-        .deleteVote("Code-Review");
-
-    Map<String, Short> m =
-        gApi.changes().id(r.getChangeId()).current().reviewer(user.getId().toString()).votes();
-
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
-
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    ChangeMessageInfo message = Iterables.getLast(c.messages);
-    assertThat(message.author._accountId).isEqualTo(admin.getId().get());
-    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
-    assertThat(getReviewers(c.reviewers.get(ReviewerState.REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
-  }
-
-  private static void assertCherryPickResult(
-      ChangeInfo changeInfo, CherryPickInput input, String srcChangeId) throws Exception {
-    assertThat(changeInfo.changeId).isEqualTo(srcChangeId);
-    assertThat(changeInfo.revisions.keySet()).containsExactly(changeInfo.currentRevision);
-    RevisionInfo revisionInfo = changeInfo.revisions.get(changeInfo.currentRevision);
-    assertThat(revisionInfo.commit.message).isEqualTo(input.message);
-    assertThat(revisionInfo.commit.parents).hasSize(1);
-    assertThat(revisionInfo.commit.parents.get(0).commit).isEqualTo(input.base);
-  }
-
-  private PushOneCommit.Result updateChange(PushOneCommit.Result r, String content)
-      throws Exception {
-    PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, "test commit", "a.txt", content, r.getChangeId());
-    return push.to("refs/for/master");
-  }
-
-  private RevisionApi current(PushOneCommit.Result r) throws Exception {
-    return gApi.changes().id(r.getChangeId()).current();
-  }
-
-  private String checkETag(ETagView<RevisionResource> view, PushOneCommit.Result r, String oldETag)
-      throws Exception {
-    String eTag = view.getETag(parseRevisionResource(r));
-    assertThat(eTag).isNotEqualTo(oldETag);
-    return eTag;
-  }
-
-  private PushOneCommit.Result createCherryPickableMerge(
-      String parent1FileName, String parent2FileName) throws Exception {
-    RevCommit initialCommit = getHead(repo());
-
-    String branchAName = "branchA";
-    createBranch(new Branch.NameKey(project, branchAName));
-    String branchBName = "branchB";
-    createBranch(new Branch.NameKey(project, branchBName));
-
-    PushOneCommit.Result changeAResult =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "change a", parent1FileName, "Content of a")
-            .to("refs/for/" + branchAName);
-
-    testRepo.reset(initialCommit);
-    PushOneCommit.Result changeBResult =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "change b", parent2FileName, "Content of b")
-            .to("refs/for/" + branchBName);
-
-    PushOneCommit pushableMergeCommit =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            "merge",
-            ImmutableMap.of(parent1FileName, "Content of a", parent2FileName, "Content of b"));
-    pushableMergeCommit.setParents(
-        ImmutableList.of(changeAResult.getCommit(), changeBResult.getCommit()));
-    PushOneCommit.Result mergeChangeResult = pushableMergeCommit.to("refs/for/" + branchAName);
-    mergeChangeResult.assertOkStatus();
-    return mergeChangeResult;
-  }
-
-  private ApprovalInfo getApproval(String changeId, String label) throws Exception {
-    ChangeInfo info = gApi.changes().id(changeId).get(DETAILED_LABELS);
-    LabelInfo li = info.labels.get(label);
-    assertThat(li).isNotNull();
-    int accountId = atrScope.get().getUser().getAccountId().get();
-    return li.all.stream().filter(a -> a._accountId == accountId).findFirst().get();
-  }
-
-  private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
-    return Iterables.transform(r, a -> new Account.Id(a._accountId));
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
deleted file mode 100644
index c440d90..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ /dev/null
@@ -1,1151 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.revision;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
-import static com.google.gerrit.extensions.common.EditInfoSubject.assertThat;
-import static com.google.gerrit.extensions.common.RobotCommentInfoSubject.assertThatList;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
-import com.google.gerrit.extensions.client.Comment;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.EditInfo;
-import com.google.gerrit.extensions.common.FixReplacementInfo;
-import com.google.gerrit.extensions.common.FixSuggestionInfo;
-import com.google.gerrit.extensions.common.RobotCommentInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.BinaryResultSubject;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
-import org.junit.Before;
-import org.junit.Test;
-
-public class RobotCommentsIT extends AbstractDaemonTest {
-  private static final String FILE_NAME = "file_to_fix.txt";
-  private static final String FILE_NAME2 = "another_file_to_fix.txt";
-  private static final String FILE_CONTENT =
-      "First line\nSecond line\nThird line\nFourth line\nFifth line\nSixth line"
-          + "\nSeventh line\nEighth line\nNinth line\nTenth line\n";
-  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
-
-  private String changeId;
-  private FixReplacementInfo fixReplacementInfo;
-  private FixSuggestionInfo fixSuggestionInfo;
-  private RobotCommentInput withFixRobotCommentInput;
-
-  @Before
-  public void setUp() throws Exception {
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            "Provide files which can be used for fixes",
-            ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
-    PushOneCommit.Result changeResult = push.to("refs/for/master");
-    changeId = changeResult.getChangeId();
-
-    fixReplacementInfo = createFixReplacementInfo();
-    fixSuggestionInfo = createFixSuggestionInfo(fixReplacementInfo);
-    withFixRobotCommentInput = createRobotCommentInput(fixSuggestionInfo);
-  }
-
-  @Test
-  public void retrievingRobotCommentsBeforeAddingAnyDoesNotRaiseAnException() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    Map<String, List<RobotCommentInfo>> robotComments =
-        gApi.changes().id(changeId).current().robotComments();
-
-    assertThat(robotComments).isNotNull();
-    assertThat(robotComments).isEmpty();
-  }
-
-  @Test
-  public void addedRobotCommentsCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    RobotCommentInput in = createRobotCommentInput();
-    addRobotComment(changeId, in);
-
-    Map<String, List<RobotCommentInfo>> out = gApi.changes().id(changeId).current().robotComments();
-
-    assertThat(out).hasSize(1);
-    RobotCommentInfo comment = Iterables.getOnlyElement(out.get(in.path));
-    assertRobotComment(comment, in, false);
-  }
-
-  @Test
-  public void addedRobotCommentsCanBeRetrievedByChange() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    RobotCommentInput in = createRobotCommentInput();
-    addRobotComment(changeId, in);
-
-    pushFactory.create(db, admin.getIdent(), testRepo, changeId).to("refs/for/master");
-
-    RobotCommentInput in2 = createRobotCommentInput();
-    addRobotComment(changeId, in2);
-
-    Map<String, List<RobotCommentInfo>> out = gApi.changes().id(changeId).robotComments();
-
-    assertThat(out).hasSize(1);
-    assertThat(out.get(in.path)).hasSize(2);
-
-    RobotCommentInfo comment1 = out.get(in.path).get(0);
-    assertRobotComment(comment1, in, false);
-    RobotCommentInfo comment2 = out.get(in.path).get(1);
-    assertRobotComment(comment2, in2, false);
-  }
-
-  @Test
-  public void robotCommentsCanBeRetrievedAsList() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    RobotCommentInput robotCommentInput = createRobotCommentInput();
-    addRobotComment(changeId, robotCommentInput);
-
-    List<RobotCommentInfo> robotCommentInfos =
-        gApi.changes().id(changeId).current().robotCommentsAsList();
-
-    assertThat(robotCommentInfos).hasSize(1);
-    RobotCommentInfo robotCommentInfo = Iterables.getOnlyElement(robotCommentInfos);
-    assertRobotComment(robotCommentInfo, robotCommentInput);
-  }
-
-  @Test
-  public void specificRobotCommentCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    RobotCommentInput robotCommentInput = createRobotCommentInput();
-    addRobotComment(changeId, robotCommentInput);
-
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-    RobotCommentInfo robotCommentInfo = Iterables.getOnlyElement(robotCommentInfos);
-
-    RobotCommentInfo specificRobotCommentInfo =
-        gApi.changes().id(changeId).current().robotComment(robotCommentInfo.id).get();
-    assertRobotComment(specificRobotCommentInfo, robotCommentInput);
-  }
-
-  @Test
-  public void robotCommentWithoutOptionalFieldsCanBeAdded() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
-    addRobotComment(changeId, in);
-
-    Map<String, List<RobotCommentInfo>> out = gApi.changes().id(changeId).current().robotComments();
-    assertThat(out).hasSize(1);
-    RobotCommentInfo comment = Iterables.getOnlyElement(out.get(in.path));
-    assertRobotComment(comment, in, false);
-  }
-
-  @Test
-  public void hugeRobotCommentIsRejected() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    int defaultSizeLimit = 1024 * 1024;
-    int sizeOfRest = 451;
-    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest + 1);
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("limit");
-    addRobotComment(changeId, withFixRobotCommentInput);
-  }
-
-  @Test
-  public void reasonablyLargeRobotCommentIsAccepted() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    int defaultSizeLimit = 1024 * 1024;
-    int sizeOfRest = 451;
-    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-    assertThat(robotCommentInfos).hasSize(1);
-  }
-
-  @Test
-  @GerritConfig(name = "change.robotCommentSizeLimit", value = "10k")
-  public void maximumAllowedSizeOfRobotCommentCanBeAdjusted() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    int sizeLimit = 10 * 1024;
-    fixReplacementInfo.replacement = getStringFor(sizeLimit);
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("limit");
-    addRobotComment(changeId, withFixRobotCommentInput);
-  }
-
-  @Test
-  @GerritConfig(name = "change.robotCommentSizeLimit", value = "0")
-  public void zeroForMaximumAllowedSizeOfRobotCommentRemovesRestriction() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    int defaultSizeLimit = 1024 * 1024;
-    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-    assertThat(robotCommentInfos).hasSize(1);
-  }
-
-  @Test
-  @GerritConfig(name = "change.robotCommentSizeLimit", value = "-1")
-  public void negativeValueForMaximumAllowedSizeOfRobotCommentRemovesRestriction()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    int defaultSizeLimit = 1024 * 1024;
-    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-    assertThat(robotCommentInfos).hasSize(1);
-  }
-
-  @Test
-  public void addedFixSuggestionCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    assertThatList(robotCommentInfos).onlyElement().onlyFixSuggestion().isNotNull();
-  }
-
-  @Test
-  public void fixIdIsGeneratedForFixSuggestion() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    assertThatList(robotCommentInfos).onlyElement().onlyFixSuggestion().fixId().isNotEmpty();
-    assertThatList(robotCommentInfos)
-        .onlyElement()
-        .onlyFixSuggestion()
-        .fixId()
-        .isNotEqualTo(fixSuggestionInfo.fixId);
-  }
-
-  @Test
-  public void descriptionOfFixSuggestionIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    assertThatList(robotCommentInfos)
-        .onlyElement()
-        .onlyFixSuggestion()
-        .description()
-        .isEqualTo(fixSuggestionInfo.description);
-  }
-
-  @Test
-  public void descriptionOfFixSuggestionIsMandatory() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    fixSuggestionInfo.description = null;
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "A description is required for the suggested fix of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
-  }
-
-  @Test
-  public void addedFixReplacementCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    assertThatList(robotCommentInfos)
-        .onlyElement()
-        .onlyFixSuggestion()
-        .onlyReplacement()
-        .isNotNull();
-  }
-
-  @Test
-  public void fixReplacementsAreMandatory() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    fixSuggestionInfo.replacements = Collections.emptyList();
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "At least one replacement is required"
-                + " for the suggested fix of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
-  }
-
-  @Test
-  public void pathOfFixReplacementIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    assertThatList(robotCommentInfos)
-        .onlyElement()
-        .onlyFixSuggestion()
-        .onlyReplacement()
-        .path()
-        .isEqualTo(fixReplacementInfo.path);
-  }
-
-  @Test
-  public void pathOfFixReplacementIsMandatory() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    fixReplacementInfo.path = null;
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "A file path must be given for the replacement of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
-  }
-
-  @Test
-  public void rangeOfFixReplacementIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    assertThatList(robotCommentInfos)
-        .onlyElement()
-        .onlyFixSuggestion()
-        .onlyReplacement()
-        .range()
-        .isEqualTo(fixReplacementInfo.range);
-  }
-
-  @Test
-  public void rangeOfFixReplacementIsMandatory() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    fixReplacementInfo.range = null;
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "A range must be given for the replacement of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
-  }
-
-  @Test
-  public void rangeOfFixReplacementNeedsToBeValid() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    fixReplacementInfo.range = createRange(13, 9, 5, 10);
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Range (13:9 - 5:10)");
-    addRobotComment(changeId, withFixRobotCommentInput);
-  }
-
-  @Test
-  public void rangesOfFixReplacementsOfSameFixSuggestionForSameFileMayNotOverlap()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
-    fixReplacementInfo1.path = FILE_NAME;
-    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
-    fixReplacementInfo1.replacement = "First modification\n";
-
-    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
-    fixReplacementInfo2.path = FILE_NAME;
-    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
-    fixReplacementInfo2.replacement = "Second modification\n";
-
-    FixSuggestionInfo fixSuggestionInfo =
-        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
-    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("overlap");
-    addRobotComment(changeId, withFixRobotCommentInput);
-  }
-
-  @Test
-  public void rangesOfFixReplacementsOfSameFixSuggestionForDifferentFileMayOverlap()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
-    fixReplacementInfo1.path = FILE_NAME;
-    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
-    fixReplacementInfo1.replacement = "First modification\n";
-
-    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
-    fixReplacementInfo2.path = FILE_NAME2;
-    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
-    fixReplacementInfo2.replacement = "Second modification\n";
-
-    FixSuggestionInfo fixSuggestionInfo =
-        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
-    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-    assertThatList(robotCommentInfos).onlyElement().fixSuggestions().hasSize(1);
-  }
-
-  @Test
-  public void rangesOfFixReplacementsOfDifferentFixSuggestionsForSameFileMayOverlap()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
-    fixReplacementInfo1.path = FILE_NAME;
-    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
-    fixReplacementInfo1.replacement = "First modification\n";
-    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
-
-    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
-    fixReplacementInfo2.path = FILE_NAME;
-    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
-    fixReplacementInfo2.replacement = "Second modification\n";
-    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
-
-    withFixRobotCommentInput.fixSuggestions =
-        ImmutableList.of(fixSuggestionInfo1, fixSuggestionInfo2);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-    assertThatList(robotCommentInfos).onlyElement().fixSuggestions().hasSize(2);
-  }
-
-  @Test
-  public void fixReplacementsDoNotNeedToBeOrderedAccordingToRange() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
-    fixReplacementInfo1.path = FILE_NAME;
-    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
-    fixReplacementInfo1.replacement = "First modification\n";
-
-    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
-    fixReplacementInfo2.path = FILE_NAME;
-    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
-    fixReplacementInfo2.replacement = "Second modification\n";
-
-    FixReplacementInfo fixReplacementInfo3 = new FixReplacementInfo();
-    fixReplacementInfo3.path = FILE_NAME;
-    fixReplacementInfo3.range = createRange(4, 0, 5, 0);
-    fixReplacementInfo3.replacement = "Third modification\n";
-
-    FixSuggestionInfo fixSuggestionInfo =
-        createFixSuggestionInfo(fixReplacementInfo2, fixReplacementInfo1, fixReplacementInfo3);
-    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-    assertThatList(robotCommentInfos).onlyElement().onlyFixSuggestion().replacements().hasSize(3);
-  }
-
-  @Test
-  public void replacementStringOfFixReplacementIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    assertThatList(robotCommentInfos)
-        .onlyElement()
-        .onlyFixSuggestion()
-        .onlyReplacement()
-        .replacement()
-        .isEqualTo(fixReplacementInfo.replacement);
-  }
-
-  @Test
-  public void replacementStringOfFixReplacementIsMandatory() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    fixReplacementInfo.replacement = null;
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "A content for replacement must be "
-                + "indicated for the replacement of the robot comment on %s",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
-  }
-
-  @Test
-  public void fixWithinALineCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    fixReplacementInfo.path = FILE_NAME;
-    fixReplacementInfo.replacement = "Modified content";
-    fixReplacementInfo.range = createRange(3, 1, 3, 3);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-
-    gApi.changes().id(changeId).current().applyFix(fixId);
-
-    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
-    BinaryResultSubject.assertThat(file)
-        .value()
-        .asString()
-        .isEqualTo(
-            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
-                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
-  }
-
-  @Test
-  public void fixSpanningMultipleLinesCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    fixReplacementInfo.path = FILE_NAME;
-    fixReplacementInfo.replacement = "Modified content\n5";
-    fixReplacementInfo.range = createRange(3, 2, 5, 3);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-
-    gApi.changes().id(changeId).current().applyFix(fixId);
-
-    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
-    BinaryResultSubject.assertThat(file)
-        .value()
-        .asString()
-        .isEqualTo(
-            "First line\nSecond line\nThModified content\n5th line\nSixth line\nSeventh line\n"
-                + "Eighth line\nNinth line\nTenth line\n");
-  }
-
-  @Test
-  public void fixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
-    fixReplacementInfo1.path = FILE_NAME;
-    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
-    fixReplacementInfo1.replacement = "First modification\n";
-
-    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
-    fixReplacementInfo2.path = FILE_NAME;
-    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
-    fixReplacementInfo2.replacement = "Some other modified content\n";
-
-    FixSuggestionInfo fixSuggestionInfo =
-        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
-    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-
-    gApi.changes().id(changeId).current().applyFix(fixId);
-
-    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
-    BinaryResultSubject.assertThat(file)
-        .value()
-        .asString()
-        .isEqualTo(
-            "First line\nFirst modification\nSome other modified content\nFourth line\nFifth line\n"
-                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
-  }
-
-  @Test
-  public void twoFixesOnSameFileCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
-    fixReplacementInfo1.path = FILE_NAME;
-    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
-    fixReplacementInfo1.replacement = "First modification\n";
-    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
-
-    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
-    fixReplacementInfo2.path = FILE_NAME;
-    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
-    fixReplacementInfo2.replacement = "Some other modified content\n";
-    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
-
-    RobotCommentInput robotCommentInput1 = createRobotCommentInput(fixSuggestionInfo1);
-    RobotCommentInput robotCommentInput2 = createRobotCommentInput(fixSuggestionInfo2);
-    addRobotComment(changeId, robotCommentInput1);
-    addRobotComment(changeId, robotCommentInput2);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
-    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
-
-    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
-    BinaryResultSubject.assertThat(file)
-        .value()
-        .asString()
-        .isEqualTo(
-            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
-                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
-  }
-
-  @Test
-  public void twoConflictingFixesOnSameFileCannotBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
-    fixReplacementInfo1.path = FILE_NAME;
-    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
-    fixReplacementInfo1.replacement = "First modification\n";
-    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
-
-    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
-    fixReplacementInfo2.path = FILE_NAME;
-    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
-    fixReplacementInfo2.replacement = "Some other modified content\n";
-    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
-
-    RobotCommentInput robotCommentInput1 = createRobotCommentInput(fixSuggestionInfo1);
-    RobotCommentInput robotCommentInput2 = createRobotCommentInput(fixSuggestionInfo2);
-    addRobotComment(changeId, robotCommentInput1);
-    addRobotComment(changeId, robotCommentInput2);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("merge");
-    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
-  }
-
-  @Test
-  public void twoFixesOfSameRobotCommentCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
-    fixReplacementInfo1.path = FILE_NAME;
-    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
-    fixReplacementInfo1.replacement = "First modification\n";
-    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
-
-    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
-    fixReplacementInfo2.path = FILE_NAME;
-    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
-    fixReplacementInfo2.replacement = "Some other modified content\n";
-    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
-
-    withFixRobotCommentInput.fixSuggestions =
-        ImmutableList.of(fixSuggestionInfo1, fixSuggestionInfo2);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
-    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
-
-    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
-    BinaryResultSubject.assertThat(file)
-        .value()
-        .asString()
-        .isEqualTo(
-            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
-                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
-  }
-
-  @Test
-  public void fixReferringToDifferentFileThanRobotCommentCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    fixReplacementInfo.path = FILE_NAME2;
-    fixReplacementInfo.range = createRange(2, 0, 3, 0);
-    fixReplacementInfo.replacement = "Modified content\n";
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-
-    gApi.changes().id(changeId).current().applyFix(fixId);
-
-    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
-    BinaryResultSubject.assertThat(file)
-        .value()
-        .asString()
-        .isEqualTo("1st line\nModified content\n3rd line\n");
-  }
-
-  @Test
-  public void fixInvolvingTwoFilesCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
-    fixReplacementInfo1.path = FILE_NAME;
-    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
-    fixReplacementInfo1.replacement = "First modification\n";
-
-    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
-    fixReplacementInfo2.path = FILE_NAME2;
-    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
-    fixReplacementInfo2.replacement = "Different file modification\n";
-
-    FixSuggestionInfo fixSuggestionInfo =
-        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
-    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-
-    gApi.changes().id(changeId).current().applyFix(fixId);
-
-    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
-    BinaryResultSubject.assertThat(file)
-        .value()
-        .asString()
-        .isEqualTo(
-            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
-                + "Seventh line\nEighth line\nNinth line\nTenth line\n");
-    Optional<BinaryResult> file2 = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
-    BinaryResultSubject.assertThat(file2)
-        .value()
-        .asString()
-        .isEqualTo("Different file modification\n2nd line\n3rd line\n");
-  }
-
-  @Test
-  public void fixReferringToNonExistentFileCannotBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    fixReplacementInfo.path = "a_non_existent_file.txt";
-    fixReplacementInfo.range = createRange(1, 0, 2, 0);
-    fixReplacementInfo.replacement = "Modified content\n";
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(changeId).current().applyFix(fixId);
-  }
-
-  @Test
-  public void fixOnPreviousPatchSetWithoutChangeEditCannotBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    fixReplacementInfo.path = FILE_NAME;
-    fixReplacementInfo.replacement = "Modified content";
-    fixReplacementInfo.range = createRange(3, 1, 3, 3);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    // Remember patch set and add another one.
-    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
-    amendChange(changeId);
-
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("current");
-    gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId);
-  }
-
-  @Test
-  public void fixOnPreviousPatchSetWithExistingChangeEditCanBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    // Create an empty change edit.
-    gApi.changes().id(changeId).edit().create();
-
-    fixReplacementInfo.path = FILE_NAME;
-    fixReplacementInfo.replacement = "Modified content";
-    fixReplacementInfo.range = createRange(3, 1, 3, 3);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    // Remember patch set and add another one.
-    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
-    amendChange(changeId);
-
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-
-    EditInfo editInfo = gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId);
-
-    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
-    BinaryResultSubject.assertThat(file)
-        .value()
-        .asString()
-        .isEqualTo(
-            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
-                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
-    assertThat(editInfo).baseRevision().isEqualTo(previousRevision);
-  }
-
-  @Test
-  public void fixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    // Create an empty change edit.
-    gApi.changes().id(changeId).edit().create();
-
-    // Add another patch set.
-    amendChange(changeId);
-
-    fixReplacementInfo.path = FILE_NAME;
-    fixReplacementInfo.replacement = "Modified content";
-    fixReplacementInfo.range = createRange(3, 1, 3, 3);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("based");
-    gApi.changes().id(changeId).current().applyFix(fixId);
-  }
-
-  @Test
-  public void fixDoesNotModifyCommitMessageOfChangeEdit() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    String changeEditCommitMessage = "This is the commit message of the change edit.\n";
-    gApi.changes().id(changeId).edit().modifyCommitMessage(changeEditCommitMessage);
-
-    fixReplacementInfo.path = FILE_NAME;
-    fixReplacementInfo.replacement = "Modified content";
-    fixReplacementInfo.range = createRange(3, 1, 3, 3);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-
-    gApi.changes().id(changeId).current().applyFix(fixId);
-
-    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
-    assertThat(commitMessage).isEqualTo(changeEditCommitMessage);
-  }
-
-  @Test
-  public void applyingFixTwiceIsIdempotent() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    fixReplacementInfo.path = FILE_NAME;
-    fixReplacementInfo.replacement = "Modified content";
-    fixReplacementInfo.range = createRange(3, 1, 3, 3);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-
-    gApi.changes().id(changeId).current().applyFix(fixId);
-    String expectedEditCommit =
-        gApi.changes().id(changeId).edit().get().map(edit -> edit.commit.commit).orElse("");
-
-    // Apply the fix again.
-    gApi.changes().id(changeId).current().applyFix(fixId);
-
-    Optional<EditInfo> editInfo = gApi.changes().id(changeId).edit().get();
-    assertThat(editInfo).value().commit().commit().isEqualTo(expectedEditCommit);
-  }
-
-  @Test
-  public void nonExistentFixCannotBeApplied() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    fixReplacementInfo.path = FILE_NAME;
-    fixReplacementInfo.replacement = "Modified content";
-    fixReplacementInfo.range = createRange(3, 1, 3, 3);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-    String nonExistentFixId = fixId + "_non-existent";
-
-    exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(changeId).current().applyFix(nonExistentFixId);
-  }
-
-  @Test
-  public void applyingFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    fixReplacementInfo.path = FILE_NAME;
-    fixReplacementInfo.replacement = "Modified content";
-    fixReplacementInfo.range = createRange(3, 1, 3, 3);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-
-    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
-
-    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
-    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
-    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
-    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
-    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
-  }
-
-  @Test
-  public void applyingFixOnTopOfChangeEditReturnsEditInfoForUpdatedChangeEdit() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    gApi.changes().id(changeId).edit().create();
-
-    fixReplacementInfo.path = FILE_NAME;
-    fixReplacementInfo.replacement = "Modified content";
-    fixReplacementInfo.range = createRange(3, 1, 3, 3);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-
-    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
-
-    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
-    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
-    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
-    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
-    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
-  }
-
-  @Test
-  public void createdChangeEditIsBasedOnCurrentPatchSet() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
-
-    fixReplacementInfo.path = FILE_NAME;
-    fixReplacementInfo.replacement = "Modified content";
-    fixReplacementInfo.range = createRange(3, 1, 3, 3);
-
-    addRobotComment(changeId, withFixRobotCommentInput);
-    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
-    List<String> fixIds = getFixIds(robotCommentInfos);
-    String fixId = Iterables.getOnlyElement(fixIds);
-
-    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
-
-    assertThat(editInfo).baseRevision().isEqualTo(currentRevision);
-  }
-
-  @Test
-  public void robotCommentsNotSupportedWithoutNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-
-    RobotCommentInput in = createRobotCommentInput();
-    ReviewInput reviewInput = new ReviewInput();
-    Map<String, List<RobotCommentInput>> robotComments = new HashMap<>();
-    robotComments.put(in.path, ImmutableList.of(in));
-    reviewInput.robotComments = robotComments;
-    reviewInput.message = "comment test";
-
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("robot comments not supported");
-    gApi.changes().id(changeId).current().review(reviewInput);
-  }
-
-  @Test
-  public void queryChangesWithUnresolvedCommentCount() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 =
-        pushFactory
-            .create(
-                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
-            .to("refs/for/master");
-
-    addRobotComment(r2.getChangeId(), createRobotCommentInputWithMandatoryFields());
-
-    AcceptanceTestRequestScope.Context ctx = disableDb();
-    try {
-      ChangeInfo result = Iterables.getOnlyElement(query(r2.getChangeId()));
-      // currently, we create all robot comments as 'resolved' by default.
-      // if we allow users to resolve a robot comment, then this test should
-      // be modified.
-      assertThat(result.unresolvedCommentCount).isEqualTo(0);
-    } finally {
-      enableDb(ctx);
-    }
-  }
-
-  private static RobotCommentInput createRobotCommentInputWithMandatoryFields() {
-    RobotCommentInput in = new RobotCommentInput();
-    in.robotId = "happyRobot";
-    in.robotRunId = "1";
-    in.line = 1;
-    in.message = "nit: trailing whitespace";
-    in.path = FILE_NAME;
-    return in;
-  }
-
-  private static RobotCommentInput createRobotCommentInput(
-      FixSuggestionInfo... fixSuggestionInfos) {
-    RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
-    in.url = "http://www.happy-robot.com";
-    in.properties = new HashMap<>();
-    in.properties.put("key1", "value1");
-    in.properties.put("key2", "value2");
-    in.fixSuggestions = Arrays.asList(fixSuggestionInfos);
-    return in;
-  }
-
-  private static FixSuggestionInfo createFixSuggestionInfo(
-      FixReplacementInfo... fixReplacementInfos) {
-    FixSuggestionInfo newFixSuggestionInfo = new FixSuggestionInfo();
-    newFixSuggestionInfo.fixId = "An ID which must be overwritten.";
-    newFixSuggestionInfo.description = "A description for a suggested fix.";
-    newFixSuggestionInfo.replacements = Arrays.asList(fixReplacementInfos);
-    return newFixSuggestionInfo;
-  }
-
-  private static FixReplacementInfo createFixReplacementInfo() {
-    FixReplacementInfo newFixReplacementInfo = new FixReplacementInfo();
-    newFixReplacementInfo.path = FILE_NAME;
-    newFixReplacementInfo.replacement = "some replacement code";
-    newFixReplacementInfo.range = createRange(3, 9, 8, 4);
-    return newFixReplacementInfo;
-  }
-
-  private static Comment.Range createRange(
-      int startLine, int startCharacter, int endLine, int endCharacter) {
-    Comment.Range range = new Comment.Range();
-    range.startLine = startLine;
-    range.startCharacter = startCharacter;
-    range.endLine = endLine;
-    range.endCharacter = endCharacter;
-    return range;
-  }
-
-  private void addRobotComment(String targetChangeId, RobotCommentInput robotCommentInput)
-      throws Exception {
-    ReviewInput reviewInput = new ReviewInput();
-    reviewInput.robotComments =
-        Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
-    reviewInput.message = "robot comment test";
-    gApi.changes().id(targetChangeId).current().review(reviewInput);
-  }
-
-  private List<RobotCommentInfo> getRobotComments() throws RestApiException {
-    return gApi.changes().id(changeId).current().robotCommentsAsList();
-  }
-
-  private void assertRobotComment(RobotCommentInfo c, RobotCommentInput expected) {
-    assertRobotComment(c, expected, true);
-  }
-
-  private void assertRobotComment(
-      RobotCommentInfo c, RobotCommentInput expected, boolean expectPath) {
-    assertThat(c.robotId).isEqualTo(expected.robotId);
-    assertThat(c.robotRunId).isEqualTo(expected.robotRunId);
-    assertThat(c.url).isEqualTo(expected.url);
-    assertThat(c.properties).isEqualTo(expected.properties);
-    assertThat(c.line).isEqualTo(expected.line);
-    assertThat(c.message).isEqualTo(expected.message);
-
-    assertThat(c.author.email).isEqualTo(admin.email);
-
-    if (expectPath) {
-      assertThat(c.path).isEqualTo(expected.path);
-    } else {
-      assertThat(c.path).isNull();
-    }
-  }
-
-  private static String getStringFor(int numberOfBytes) {
-    char[] chars = new char[numberOfBytes];
-    // 'a' will require one byte even when mapped to a JSON string
-    Arrays.fill(chars, 'a');
-    return new String(chars);
-  }
-
-  private static List<String> getFixIds(List<RobotCommentInfo> robotComments) {
-    assertThatList(robotComments).isNotNull();
-    return robotComments
-        .stream()
-        .map(robotCommentInfo -> robotCommentInfo.fixSuggestions)
-        .filter(Objects::nonNull)
-        .flatMap(List::stream)
-        .map(fixSuggestionInfo -> fixSuggestionInfo.fixId)
-        .collect(toList());
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD
deleted file mode 100644
index 990bad6..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD
+++ /dev/null
@@ -1,10 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    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/edit/ChangeEditIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java
deleted file mode 100644
index 4ca4498..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ /dev/null
@@ -1,849 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.edit;
-
-import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.extensions.common.EditInfoSubject.assertThat;
-import static com.google.gerrit.extensions.restapi.BinaryResultSubject.assertThat;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.DiffInfo;
-import com.google.gerrit.extensions.common.EditInfo;
-import com.google.gerrit.extensions.common.FileInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.change.ChangeEdits.EditMessage;
-import com.google.gerrit.server.change.ChangeEdits.Post;
-import com.google.gerrit.server.change.ChangeEdits.Put;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.Util;
-import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gson.reflect.TypeToken;
-import com.google.gson.stream.JsonReader;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.After;
-import org.junit.AfterClass;
-import org.junit.Before;
-import org.junit.BeforeClass;
-import org.junit.Test;
-
-public class ChangeEditIT extends AbstractDaemonTest {
-
-  private static final String FILE_NAME = "foo";
-  private static final String FILE_NAME2 = "foo2";
-  private static final String FILE_NAME3 = "foo3";
-  private static final byte[] CONTENT_OLD = "bar".getBytes(UTF_8);
-  private static final byte[] CONTENT_NEW = "baz".getBytes(UTF_8);
-  private static final String CONTENT_NEW2_STR = "quxÄÜÖßµ";
-  private static final byte[] CONTENT_NEW2 = CONTENT_NEW2_STR.getBytes(UTF_8);
-
-  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
-
-  private String changeId;
-  private String changeId2;
-  private PatchSet ps;
-
-  @BeforeClass
-  public static void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @AfterClass
-  public static void restoreTime() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @Before
-  public void setUp() throws Exception {
-    db = reviewDbProvider.open();
-    changeId = newChange(admin.getIdent());
-    ps = getCurrentPatchSet(changeId);
-    assertThat(ps).isNotNull();
-    amendChange(admin.getIdent(), changeId);
-    changeId2 = newChange2(admin.getIdent());
-  }
-
-  @After
-  public void cleanup() {
-    db.close();
-  }
-
-  @Test
-  public void parseEditRevision() throws Exception {
-    createArbitraryEditFor(changeId);
-
-    // check that '0' is parsed as edit revision
-    gApi.changes().id(changeId).revision(0).comments();
-
-    // check that 'edit' is parsed as edit revision
-    gApi.changes().id(changeId).revision("edit").comments();
-  }
-
-  @Test
-  public void deleteEditOfCurrentPatchSet() throws Exception {
-    createArbitraryEditFor(changeId);
-    gApi.changes().id(changeId).edit().delete();
-    assertThat(getEdit(changeId)).isAbsent();
-  }
-
-  @Test
-  public void deleteEditOfOlderPatchSet() throws Exception {
-    createArbitraryEditFor(changeId2);
-    amendChange(admin.getIdent(), changeId2);
-
-    gApi.changes().id(changeId2).edit().delete();
-    assertThat(getEdit(changeId2)).isAbsent();
-  }
-
-  @Test
-  public void publishEdit() throws Exception {
-    createArbitraryEditFor(changeId);
-
-    PublishChangeEditInput publishInput = new PublishChangeEditInput();
-    publishInput.notify = NotifyHandling.NONE;
-    gApi.changes().id(changeId).edit().publish(publishInput);
-
-    assertThat(getEdit(changeId)).isAbsent();
-    assertChangeMessages(
-        changeId,
-        ImmutableList.of(
-            "Uploaded patch set 1.",
-            "Uploaded patch set 2.",
-            "Patch Set 3: Published edit on patch set 2."));
-
-    // The tag for the publish edit change message should vary according
-    // to whether the change was WIP at the time of publishing.
-    ChangeInfo info = get(changeId);
-    assertThat(info.messages).isNotEmpty();
-    assertThat(Iterables.getLast(info.messages).tag)
-        .isEqualTo(ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
-
-    // Move the change to WIP, repeat, and verify.
-    gApi.changes().id(changeId).setWorkInProgress();
-    createEmptyEditFor(changeId);
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW2));
-    gApi.changes().id(changeId).edit().publish();
-    info = get(changeId);
-    assertThat(info.messages).isNotEmpty();
-    assertThat(Iterables.getLast(info.messages).tag)
-        .isEqualTo(ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
-  }
-
-  @Test
-  public void publishEditRest() throws Exception {
-    PatchSet oldCurrentPatchSet = getCurrentPatchSet(changeId);
-    createArbitraryEditFor(changeId);
-
-    adminRestSession.post(urlPublish(changeId)).assertNoContent();
-    assertThat(getEdit(changeId)).isAbsent();
-    PatchSet newCurrentPatchSet = getCurrentPatchSet(changeId);
-    assertThat(newCurrentPatchSet.getId()).isNotEqualTo(oldCurrentPatchSet.getId());
-    assertChangeMessages(
-        changeId,
-        ImmutableList.of(
-            "Uploaded patch set 1.",
-            "Uploaded patch set 2.",
-            "Patch Set 3: Published edit on patch set 2."));
-  }
-
-  @Test
-  public void publishEditNotifyRest() throws Exception {
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes().id(changeId).addReviewer(in);
-
-    createArbitraryEditFor(changeId);
-
-    sender.clear();
-    PublishChangeEditInput input = new PublishChangeEditInput();
-    input.notify = NotifyHandling.NONE;
-    adminRestSession.post(urlPublish(changeId), input).assertNoContent();
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void publishEditWithDefaultNotify() throws Exception {
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes().id(changeId).addReviewer(in);
-
-    createArbitraryEditFor(changeId);
-
-    sender.clear();
-    gApi.changes().id(changeId).edit().publish();
-    assertThat(sender.getMessages()).isNotEmpty();
-  }
-
-  @Test
-  public void deleteEditRest() throws Exception {
-    createArbitraryEditFor(changeId);
-    adminRestSession.delete(urlEdit(changeId)).assertNoContent();
-    assertThat(getEdit(changeId)).isAbsent();
-  }
-
-  @Test
-  public void publishEditRestWithoutCLA() throws Exception {
-    createArbitraryEditFor(changeId);
-    setUseContributorAgreements(InheritableBoolean.TRUE);
-    adminRestSession.post(urlPublish(changeId)).assertForbidden();
-    setUseContributorAgreements(InheritableBoolean.FALSE);
-    adminRestSession.post(urlPublish(changeId)).assertNoContent();
-  }
-
-  @Test
-  public void rebaseEdit() throws Exception {
-    PatchSet previousPatchSet = getCurrentPatchSet(changeId2);
-    createEmptyEditFor(changeId2);
-    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-    amendChange(admin.getIdent(), changeId2);
-    PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
-
-    Optional<EditInfo> originalEdit = getEdit(changeId2);
-    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.getRevision().get());
-    Timestamp beforeRebase = originalEdit.get().commit.committer.date;
-    gApi.changes().id(changeId2).edit().rebase();
-    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
-    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME2), CONTENT_NEW2);
-    Optional<EditInfo> rebasedEdit = getEdit(changeId2);
-    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
-    assertThat(rebasedEdit).value().commit().committer().creationDate().isNotEqualTo(beforeRebase);
-  }
-
-  @Test
-  public void rebaseEditRest() throws Exception {
-    PatchSet previousPatchSet = getCurrentPatchSet(changeId2);
-    createEmptyEditFor(changeId2);
-    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-    amendChange(admin.getIdent(), changeId2);
-    PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
-
-    Optional<EditInfo> originalEdit = getEdit(changeId2);
-    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.getRevision().get());
-    Timestamp beforeRebase = originalEdit.get().commit.committer.date;
-    adminRestSession.post(urlRebase(changeId2)).assertNoContent();
-    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
-    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME2), CONTENT_NEW2);
-    Optional<EditInfo> rebasedEdit = getEdit(changeId2);
-    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
-    assertThat(rebasedEdit).value().commit().committer().creationDate().isNotEqualTo(beforeRebase);
-  }
-
-  @Test
-  public void rebaseEditWithConflictsRest_Conflict() throws Exception {
-    PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
-    createEmptyEditFor(changeId2);
-    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-    Optional<EditInfo> edit = getEdit(changeId2);
-    assertThat(edit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            FILE_NAME,
-            new String(CONTENT_NEW2, UTF_8),
-            changeId2);
-    push.to("refs/for/master").assertOkStatus();
-    adminRestSession.post(urlRebase(changeId2)).assertConflict();
-  }
-
-  @Test
-  public void updateExistingFile() throws Exception {
-    createEmptyEditFor(changeId);
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-    assertThat(getEdit(changeId)).isPresent();
-    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
-    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
-  }
-
-  @Test
-  @TestProjectInput(createEmptyCommit = false)
-  public void updateRootCommitMessage() throws Exception {
-    // Re-clone empty repo; TestRepository doesn't let us reset to unborn head.
-    testRepo = cloneProject(project);
-    changeId = newChange(admin.getIdent());
-
-    createEmptyEditFor(changeId);
-    Optional<EditInfo> edit = getEdit(changeId);
-    assertThat(edit).value().commit().parents().isEmpty();
-
-    String msg = String.format("New commit message\n\nChange-Id: %s\n", changeId);
-    gApi.changes().id(changeId).edit().modifyCommitMessage(msg);
-    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
-    assertThat(commitMessage).isEqualTo(msg);
-  }
-
-  @Test
-  public void updateMessageNoChange() throws Exception {
-    createEmptyEditFor(changeId);
-    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("New commit message cannot be same as existing commit message");
-    gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage);
-  }
-
-  @Test
-  public void updateMessageOnlyAddTrailingNewLines() throws Exception {
-    createEmptyEditFor(changeId);
-    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("New commit message cannot be same as existing commit message");
-    gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage + "\n\n");
-  }
-
-  @Test
-  public void updateMessage() throws Exception {
-    createEmptyEditFor(changeId);
-    String msg = String.format("New commit message\n\nChange-Id: %s\n", changeId);
-    gApi.changes().id(changeId).edit().modifyCommitMessage(msg);
-    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
-    assertThat(commitMessage).isEqualTo(msg);
-
-    PublishChangeEditInput publishInput = new PublishChangeEditInput();
-    publishInput.notify = NotifyHandling.NONE;
-    gApi.changes().id(changeId).edit().publish(publishInput);
-    assertThat(getEdit(changeId)).isAbsent();
-
-    ChangeInfo info =
-        get(changeId, ListChangesOption.CURRENT_COMMIT, ListChangesOption.CURRENT_REVISION);
-    assertThat(info.revisions.get(info.currentRevision).commit.message).isEqualTo(msg);
-    assertThat(info.revisions.get(info.currentRevision).description)
-        .isEqualTo("Edit commit message");
-
-    assertChangeMessages(
-        changeId,
-        ImmutableList.of(
-            "Uploaded patch set 1.",
-            "Uploaded patch set 2.",
-            "Patch Set 3: Commit message was updated."));
-  }
-
-  @Test
-  public void updateMessageRest() throws Exception {
-    adminRestSession.get(urlEditMessage(changeId, false)).assertNotFound();
-    EditMessage.Input in = new EditMessage.Input();
-    in.message =
-        String.format(
-            "New commit message\n\n" + CONTENT_NEW2_STR + "\n\nChange-Id: %s\n", changeId);
-    adminRestSession.put(urlEditMessage(changeId, false), in).assertNoContent();
-    RestResponse r = adminRestSession.getJsonAccept(urlEditMessage(changeId, false));
-    r.assertOK();
-    assertThat(readContentFromJson(r)).isEqualTo(in.message);
-    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
-    assertThat(commitMessage).isEqualTo(in.message);
-    in.message = String.format("New commit message2\n\nChange-Id: %s\n", changeId);
-    adminRestSession.put(urlEditMessage(changeId, false), in).assertNoContent();
-    String updatedCommitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
-    assertThat(updatedCommitMessage).isEqualTo(in.message);
-
-    r = adminRestSession.getJsonAccept(urlEditMessage(changeId, true));
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
-      assertThat(readContentFromJson(r)).isEqualTo(commit.getFullMessage());
-    }
-
-    PublishChangeEditInput publishInput = new PublishChangeEditInput();
-    publishInput.notify = NotifyHandling.NONE;
-    gApi.changes().id(changeId).edit().publish(publishInput);
-    assertChangeMessages(
-        changeId,
-        ImmutableList.of(
-            "Uploaded patch set 1.",
-            "Uploaded patch set 2.",
-            "Patch Set 3: Commit message was updated."));
-  }
-
-  @Test
-  public void retrieveEdit() throws Exception {
-    adminRestSession.get(urlEdit(changeId)).assertNoContent();
-    createArbitraryEditFor(changeId);
-    EditInfo editInfo = getEditInfo(changeId, false);
-    ChangeInfo changeInfo = get(changeId);
-    assertThat(editInfo.commit.commit).isNotEqualTo(changeInfo.currentRevision);
-    assertThat(editInfo).commit().parents().hasSize(1);
-    assertThat(editInfo).baseRevision().isEqualTo(changeInfo.currentRevision);
-
-    gApi.changes().id(changeId).edit().delete();
-
-    adminRestSession.get(urlEdit(changeId)).assertNoContent();
-  }
-
-  @Test
-  public void retrieveFilesInEdit() throws Exception {
-    createEmptyEditFor(changeId);
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-
-    EditInfo info = getEditInfo(changeId, true);
-    assertThat(info.files).isNotNull();
-    assertThat(info.files.keySet()).containsExactly(Patch.COMMIT_MSG, FILE_NAME, FILE_NAME2);
-  }
-
-  @Test
-  public void deleteExistingFile() throws Exception {
-    createEmptyEditFor(changeId);
-    gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
-    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
-  }
-
-  @Test
-  public void renameExistingFile() throws Exception {
-    createEmptyEditFor(changeId);
-    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, FILE_NAME3);
-    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_OLD);
-    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
-  }
-
-  @Test
-  public void createEditByDeletingExistingFileRest() throws Exception {
-    adminRestSession.delete(urlEditFile(changeId, FILE_NAME)).assertNoContent();
-    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
-  }
-
-  @Test
-  public void deletingNonExistingEditRest() throws Exception {
-    adminRestSession.delete(urlEdit(changeId)).assertNotFound();
-  }
-
-  @Test
-  public void deleteExistingFileRest() throws Exception {
-    createEmptyEditFor(changeId);
-    adminRestSession.delete(urlEditFile(changeId, FILE_NAME)).assertNoContent();
-    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
-  }
-
-  @Test
-  public void restoreDeletedFileInPatchSet() throws Exception {
-    createEmptyEditFor(changeId2);
-    gApi.changes().id(changeId2).edit().restoreFile(FILE_NAME);
-    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_OLD);
-  }
-
-  @Test
-  public void revertChanges() throws Exception {
-    createEmptyEditFor(changeId2);
-    gApi.changes().id(changeId2).edit().restoreFile(FILE_NAME);
-    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_OLD);
-    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
-    gApi.changes().id(changeId2).edit().restoreFile(FILE_NAME);
-    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_OLD);
-  }
-
-  @Test
-  public void renameFileRest() throws Exception {
-    createEmptyEditFor(changeId);
-    Post.Input in = new Post.Input();
-    in.oldPath = FILE_NAME;
-    in.newPath = FILE_NAME3;
-    adminRestSession.post(urlEdit(changeId), in).assertNoContent();
-    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_OLD);
-    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
-  }
-
-  @Test
-  public void restoreDeletedFileInPatchSetRest() throws Exception {
-    Post.Input in = new Post.Input();
-    in.restorePath = FILE_NAME;
-    adminRestSession.post(urlEdit(changeId2), in).assertNoContent();
-    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_OLD);
-  }
-
-  @Test
-  public void amendExistingFile() throws Exception {
-    createEmptyEditFor(changeId);
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW2));
-    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW2);
-  }
-
-  @Test
-  public void createAndChangeEditInOneRequestRest() throws Exception {
-    Put.Input in = new Put.Input();
-    in.content = RawInputUtil.create(CONTENT_NEW);
-    adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
-    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
-    in.content = RawInputUtil.create(CONTENT_NEW2);
-    adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
-    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW2);
-  }
-
-  @Test
-  public void changeEditRest() throws Exception {
-    createEmptyEditFor(changeId);
-    Put.Input in = new Put.Input();
-    in.content = RawInputUtil.create(CONTENT_NEW);
-    adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
-    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
-  }
-
-  @Test
-  public void emptyPutRequest() throws Exception {
-    createEmptyEditFor(changeId);
-    adminRestSession.put(urlEditFile(changeId, FILE_NAME)).assertNoContent();
-    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), "".getBytes(UTF_8));
-  }
-
-  @Test
-  public void createEmptyEditRest() throws Exception {
-    adminRestSession.post(urlEdit(changeId)).assertNoContent();
-    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_OLD);
-  }
-
-  @Test
-  public void getFileContentRest() throws Exception {
-    Put.Input in = new Put.Input();
-    in.content = RawInputUtil.create(CONTENT_NEW);
-    adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW2));
-    RestResponse r = adminRestSession.getJsonAccept(urlEditFile(changeId, FILE_NAME));
-    r.assertOK();
-    assertThat(readContentFromJson(r)).isEqualTo(new String(CONTENT_NEW2, UTF_8));
-
-    r = adminRestSession.getJsonAccept(urlEditFile(changeId, FILE_NAME, true));
-    r.assertOK();
-    assertThat(readContentFromJson(r)).isEqualTo(new String(CONTENT_OLD, UTF_8));
-  }
-
-  @Test
-  public void getFileNotFoundRest() throws Exception {
-    createEmptyEditFor(changeId);
-    adminRestSession.delete(urlEditFile(changeId, FILE_NAME)).assertNoContent();
-    adminRestSession.get(urlEditFile(changeId, FILE_NAME)).assertNoContent();
-    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
-  }
-
-  @Test
-  public void addNewFile() throws Exception {
-    createEmptyEditFor(changeId);
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
-    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW);
-  }
-
-  @Test
-  public void addNewFileAndAmend() throws Exception {
-    createEmptyEditFor(changeId);
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
-    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW);
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW2));
-    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW2);
-  }
-
-  @Test
-  public void writeNoChanges() throws Exception {
-    createEmptyEditFor(changeId);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("no changes were made");
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_OLD));
-  }
-
-  @Test
-  public void editCommitMessageCopiesLabelScores() throws Exception {
-    String cr = "Code-Review";
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType codeReview = Util.codeReview();
-    codeReview.setCopyAllScoresIfNoCodeChange(true);
-    cfg.getLabelSections().put(cr, codeReview);
-    saveProjectConfig(project, cfg);
-
-    ReviewInput r = new ReviewInput();
-    r.labels = ImmutableMap.of(cr, (short) 1);
-    gApi.changes().id(changeId).current().review(r);
-
-    createEmptyEditFor(changeId);
-    String newSubj = "New commit message";
-    String newMsg = newSubj + "\n\nChange-Id: " + changeId + "\n";
-    gApi.changes().id(changeId).edit().modifyCommitMessage(newMsg);
-    PublishChangeEditInput publishInput = new PublishChangeEditInput();
-    publishInput.notify = NotifyHandling.NONE;
-    gApi.changes().id(changeId).edit().publish(publishInput);
-
-    ChangeInfo info = get(changeId);
-    assertThat(info.subject).isEqualTo(newSubj);
-    List<ApprovalInfo> approvals = info.labels.get(cr).all;
-    assertThat(approvals).hasSize(1);
-    assertThat(approvals.get(0).value).isEqualTo(1);
-  }
-
-  @Test
-  public void hasEditPredicate() throws Exception {
-    createEmptyEditFor(changeId);
-    assertThat(queryEdits()).hasSize(1);
-
-    createEmptyEditFor(changeId2);
-    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-    assertThat(queryEdits()).hasSize(2);
-
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-    gApi.changes().id(changeId).edit().delete();
-    assertThat(queryEdits()).hasSize(1);
-
-    PublishChangeEditInput publishInput = new PublishChangeEditInput();
-    publishInput.notify = NotifyHandling.NONE;
-    gApi.changes().id(changeId2).edit().publish(publishInput);
-    assertThat(queryEdits()).isEmpty();
-
-    setApiUser(user);
-    createEmptyEditFor(changeId);
-    assertThat(queryEdits()).hasSize(1);
-
-    setApiUser(admin);
-    assertThat(queryEdits()).isEmpty();
-  }
-
-  @Test
-  public void files() throws Exception {
-    createEmptyEditFor(changeId);
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-    Optional<EditInfo> edit = getEdit(changeId);
-    assertThat(edit).isPresent();
-    String editCommitId = edit.get().commit.commit;
-
-    RestResponse r = adminRestSession.getJsonAccept(urlRevisionFiles(changeId, editCommitId));
-    Map<String, FileInfo> files = readContentFromJson(r, new TypeToken<Map<String, FileInfo>>() {});
-    assertThat(files).containsKey(FILE_NAME);
-
-    r = adminRestSession.getJsonAccept(urlRevisionFiles(changeId));
-    files = readContentFromJson(r, new TypeToken<Map<String, FileInfo>>() {});
-    assertThat(files).containsKey(FILE_NAME);
-  }
-
-  @Test
-  public void diff() throws Exception {
-    createEmptyEditFor(changeId);
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-    Optional<EditInfo> edit = getEdit(changeId);
-    assertThat(edit).isPresent();
-    String editCommitId = edit.get().commit.commit;
-
-    RestResponse r = adminRestSession.getJsonAccept(urlDiff(changeId, editCommitId, FILE_NAME));
-    DiffInfo diff = readContentFromJson(r, DiffInfo.class);
-    assertThat(diff.diffHeader.get(0)).contains(FILE_NAME);
-
-    r = adminRestSession.getJsonAccept(urlDiff(changeId, FILE_NAME));
-    diff = readContentFromJson(r, DiffInfo.class);
-    assertThat(diff.diffHeader.get(0)).contains(FILE_NAME);
-  }
-
-  @Test
-  public void createEditWithoutPushPatchSetPermission() throws Exception {
-    // Create new project with clean permissions
-    Project.NameKey p = createProject("addPatchSetEdit");
-    // Clone repository as user
-    TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
-
-    // Block default permission
-    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
-
-    // Create change as user
-    PushOneCommit push = pushFactory.create(db, user.getIdent(), userTestRepo);
-    PushOneCommit.Result r1 = push.to("refs/for/master");
-    r1.assertOkStatus();
-
-    // Try to create edit as admin
-    exception.expect(AuthException.class);
-    createEmptyEditFor(r1.getChangeId());
-  }
-
-  private void createArbitraryEditFor(String changeId) throws Exception {
-    createEmptyEditFor(changeId);
-    arbitrarilyModifyEditOf(changeId);
-  }
-
-  private void createEmptyEditFor(String changeId) throws Exception {
-    gApi.changes().id(changeId).edit().create();
-  }
-
-  private void arbitrarilyModifyEditOf(String changeId) throws Exception {
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-  }
-
-  private Optional<BinaryResult> getFileContentOfEdit(String changeId, String filePath)
-      throws Exception {
-    return gApi.changes().id(changeId).edit().getFile(filePath);
-  }
-
-  private List<ChangeInfo> queryEdits() throws Exception {
-    return query("project:{" + project.get() + "} has:edit");
-  }
-
-  private String newChange(PersonIdent ident) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(
-            db, ident, testRepo, PushOneCommit.SUBJECT, FILE_NAME, new String(CONTENT_OLD, UTF_8));
-    return push.to("refs/for/master").getChangeId();
-  }
-
-  private String amendChange(PersonIdent ident, String changeId) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            ident,
-            testRepo,
-            PushOneCommit.SUBJECT,
-            FILE_NAME2,
-            new String(CONTENT_NEW2, UTF_8),
-            changeId);
-    return push.to("refs/for/master").getChangeId();
-  }
-
-  private String newChange2(PersonIdent ident) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(
-            db, ident, testRepo, PushOneCommit.SUBJECT, FILE_NAME, new String(CONTENT_OLD, UTF_8));
-    return push.rm("refs/for/master").getChangeId();
-  }
-
-  private PatchSet getCurrentPatchSet(String changeId) throws Exception {
-    return getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).currentPatchSet();
-  }
-
-  private void ensureSameBytes(Optional<BinaryResult> fileContent, byte[] expectedFileBytes)
-      throws IOException {
-    assertThat(fileContent).value().bytes().isEqualTo(expectedFileBytes);
-  }
-
-  private String urlEdit(String changeId) {
-    return "/changes/" + changeId + "/edit";
-  }
-
-  private String urlEditMessage(String changeId, boolean base) {
-    return "/changes/" + changeId + "/edit:message" + (base ? "?base" : "");
-  }
-
-  private String urlEditFile(String changeId, String fileName) {
-    return urlEditFile(changeId, fileName, false);
-  }
-
-  private String urlEditFile(String changeId, String fileName, boolean base) {
-    return urlEdit(changeId) + "/" + fileName + (base ? "?base" : "");
-  }
-
-  private String urlGetFiles(String changeId) {
-    return urlEdit(changeId) + "?list";
-  }
-
-  private String urlRevisionFiles(String changeId, String revisionId) {
-    return "/changes/" + changeId + "/revisions/" + revisionId + "/files";
-  }
-
-  private String urlRevisionFiles(String changeId) {
-    return "/changes/" + changeId + "/revisions/0/files";
-  }
-
-  private String urlPublish(String changeId) {
-    return "/changes/" + changeId + "/edit:publish";
-  }
-
-  private String urlRebase(String changeId) {
-    return "/changes/" + changeId + "/edit:rebase";
-  }
-
-  private String urlDiff(String changeId, String fileName) {
-    return "/changes/"
-        + changeId
-        + "/revisions/0/files/"
-        + fileName
-        + "/diff?context=ALL&intraline";
-  }
-
-  private String urlDiff(String changeId, String revisionId, String fileName) {
-    return "/changes/"
-        + changeId
-        + "/revisions/"
-        + revisionId
-        + "/files/"
-        + fileName
-        + "/diff?context=ALL&intraline";
-  }
-
-  private EditInfo getEditInfo(String changeId, boolean files) throws Exception {
-    RestResponse r = adminRestSession.get(files ? urlGetFiles(changeId) : urlEdit(changeId));
-    return readContentFromJson(r, EditInfo.class);
-  }
-
-  private <T> T readContentFromJson(RestResponse r, Class<T> clazz) throws Exception {
-    r.assertOK();
-    JsonReader jsonReader = new JsonReader(r.getReader());
-    jsonReader.setLenient(true);
-    return newGson().fromJson(jsonReader, clazz);
-  }
-
-  private <T> T readContentFromJson(RestResponse r, TypeToken<T> typeToken) throws Exception {
-    r.assertOK();
-    JsonReader jsonReader = new JsonReader(r.getReader());
-    jsonReader.setLenient(true);
-    return newGson().fromJson(jsonReader, typeToken.getType());
-  }
-
-  private String readContentFromJson(RestResponse r) throws Exception {
-    return readContentFromJson(r, String.class);
-  }
-
-  private void assertChangeMessages(String changeId, List<String> expectedMessages)
-      throws Exception {
-    ChangeInfo ci = get(changeId);
-    assertThat(ci.messages).isNotNull();
-    assertThat(ci.messages).hasSize(expectedMessages.size());
-    List<String> actualMessages =
-        ci.messages.stream().map(message -> message.message).collect(toList());
-    assertThat(actualMessages).containsExactlyElementsIn(expectedMessages).inOrder();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
deleted file mode 100644
index 5953721..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ /dev/null
@@ -1,2063 +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.git;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
-import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
-import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
-import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
-import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
-import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
-import static com.google.gerrit.extensions.common.EditInfoSubject.assertThat;
-import static com.google.gerrit.server.git.receive.ReceiveConstants.PUSH_OPTION_SKIP_VALIDATION;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.project.Util.category;
-import static com.google.gerrit.server.project.Util.value;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Streams;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.Sandboxed;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.ProjectWatchInfo;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.EditInfo;
-import com.google.gerrit.extensions.common.EditInfoSubject;
-import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.receive.ReceiveConstants;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.project.Util;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.testutil.FakeEmailSender.Message;
-import com.google.gerrit.testutil.TestTimeUtil;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Comparator;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.regex.Pattern;
-import java.util.stream.Stream;
-import org.eclipse.jgit.api.errors.GitAPIException;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.PushResult;
-import org.eclipse.jgit.transport.RefSpec;
-import org.eclipse.jgit.transport.RemoteRefUpdate;
-import org.junit.After;
-import org.junit.AfterClass;
-import org.junit.Before;
-import org.junit.BeforeClass;
-import org.junit.Test;
-
-public abstract class AbstractPushForReview extends AbstractDaemonTest {
-  protected enum Protocol {
-    // TODO(dborowitz): TEST.
-    SSH,
-    HTTP
-  }
-
-  private LabelType patchSetLock;
-
-  @BeforeClass
-  public static void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @AfterClass
-  public static void restoreTime() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @Before
-  public void setUp() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    patchSetLock = Util.patchSetLock();
-    cfg.getLabelSections().put(patchSetLock.getName(), patchSetLock);
-    AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-    Util.allow(
-        cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, anonymousUsers, "refs/heads/*");
-    saveProjectConfig(cfg);
-    grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
-  }
-
-  @After
-  public void tearDown() throws Exception {
-    setApiUser(admin);
-    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
-    prefs.publishCommentsOnPush = false;
-    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
-  }
-
-  protected void selectProtocol(Protocol p) throws Exception {
-    String url;
-    switch (p) {
-      case SSH:
-        url = adminSshSession.getUrl();
-        break;
-      case HTTP:
-        url = admin.getHttpUrl(server);
-        break;
-      default:
-        throw new IllegalArgumentException("unexpected protocol: " + p);
-    }
-    testRepo = GitUtil.cloneProject(project, url + "/" + project.get());
-  }
-
-  @Test
-  public void pushForMaster() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, null);
-  }
-
-  @Test
-  @TestProjectInput(createEmptyCommit = false)
-  public void pushInitialCommitForMasterBranch() throws Exception {
-    RevCommit c = testRepo.commit().message("Initial commit").insertChangeId().create();
-    String id = GitUtil.getChangeId(testRepo, c).get();
-    testRepo.reset(c);
-
-    String r = "refs/for/master";
-    PushResult pr = pushHead(testRepo, r, false);
-    assertPushOk(pr, r);
-
-    ChangeInfo change = gApi.changes().id(id).info();
-    assertThat(change.branch).isEqualTo("master");
-    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.resolve("master")).isNull();
-    }
-
-    gApi.changes().id(change.id).current().review(ReviewInput.approve());
-    gApi.changes().id(change.id).current().submit();
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.resolve("master")).isEqualTo(c);
-    }
-  }
-
-  @Test
-  public void pushInitialCommitForRefsMetaConfigBranch() throws Exception {
-    // delete refs/meta/config
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
-      u.setForceUpdate(true);
-      u.setExpectedOldObjectId(repo.resolve(RefNames.REFS_CONFIG));
-      assertThat(u.delete(rw)).isEqualTo(Result.FORCED);
-    }
-
-    RevCommit c =
-        testRepo
-            .commit()
-            .message("Initial commit")
-            .author(admin.getIdent())
-            .committer(admin.getIdent())
-            .insertChangeId()
-            .create();
-    String id = GitUtil.getChangeId(testRepo, c).get();
-    testRepo.reset(c);
-
-    String r = "refs/for/" + RefNames.REFS_CONFIG;
-    PushResult pr = pushHead(testRepo, r, false);
-    assertPushOk(pr, r);
-
-    ChangeInfo change = gApi.changes().id(id).info();
-    assertThat(change.branch).isEqualTo(RefNames.REFS_CONFIG);
-    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.resolve(RefNames.REFS_CONFIG)).isNull();
-    }
-
-    gApi.changes().id(change.id).current().review(ReviewInput.approve());
-    gApi.changes().id(change.id).current().submit();
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.resolve(RefNames.REFS_CONFIG)).isEqualTo(c);
-    }
-  }
-
-  @Test
-  public void pushInitialCommitForNormalNonExistingBranchFails() throws Exception {
-    RevCommit c =
-        testRepo
-            .commit()
-            .message("Initial commit")
-            .author(admin.getIdent())
-            .committer(admin.getIdent())
-            .insertChangeId()
-            .create();
-    testRepo.reset(c);
-
-    String r = "refs/for/foo";
-    PushResult pr = pushHead(testRepo, r, false);
-    assertPushRejected(pr, r, "branch foo not found");
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.resolve("foo")).isNull();
-    }
-  }
-
-  @Test
-  public void output() throws Exception {
-    String url = canonicalWebUrl.get() + "#/c/" + project.get() + "/+/";
-    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
-    PushOneCommit.Result r1 = pushTo("refs/for/master");
-    Change.Id id1 = r1.getChange().getId();
-    r1.assertOkStatus();
-    r1.assertChange(Change.Status.NEW, null);
-    r1.assertMessage(
-        "New changes:\n  " + url + id1 + " " + r1.getCommit().getShortMessage() + "\n");
-
-    testRepo.reset(initialHead);
-    String newMsg = r1.getCommit().getShortMessage() + " v2";
-    testRepo
-        .branch("HEAD")
-        .commit()
-        .message(newMsg)
-        .insertChangeId(r1.getChangeId().substring(1))
-        .create();
-    PushOneCommit.Result r2 =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "another commit", "b.txt", "bbb")
-            .to("refs/for/master");
-    Change.Id id2 = r2.getChange().getId();
-    r2.assertOkStatus();
-    r2.assertChange(Change.Status.NEW, null);
-    r2.assertMessage(
-        "New changes:\n"
-            + "  "
-            + url
-            + id2
-            + " another commit\n"
-            + "\n"
-            + "\n"
-            + "Updated changes:\n"
-            + "  "
-            + url
-            + id1
-            + " "
-            + newMsg
-            + "\n");
-  }
-
-  @Test
-  public void autoclose() throws Exception {
-    // Create a change
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    r.assertOkStatus();
-
-    // Force push it, closing it
-    String master = "refs/heads/master";
-    assertPushOk(pushHead(testRepo, master, false), master);
-
-    // Attempt to push amended commit to same change
-    String url = canonicalWebUrl.get() + "#/c/" + project.get() + "/+/" + r.getChange().getId();
-    r = amendChange(r.getChangeId(), "refs/for/master");
-    r.assertErrorStatus("change " + url + " closed");
-  }
-
-  @Test
-  public void pushForMasterWithTopic() throws Exception {
-    // specify topic in ref
-    String topic = "my/topic";
-    PushOneCommit.Result r = pushTo("refs/for/master/" + topic);
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, topic);
-
-    // specify topic as option
-    r = pushTo("refs/for/master%topic=" + topic);
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, topic);
-  }
-
-  @Test
-  public void pushForMasterWithTopicOption() throws Exception {
-    String topicOption = "topic=myTopic";
-    List<String> pushOptions = new ArrayList<>();
-    pushOptions.add(topicOption);
-
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    push.setPushOptions(pushOptions);
-    PushOneCommit.Result r = push.to("refs/for/master");
-
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, "myTopic");
-    r.assertPushOptions(pushOptions);
-  }
-
-  @Test
-  public void pushForMasterWithTopicInRefExceedLimitFails() throws Exception {
-    String topic = Stream.generate(() -> "t").limit(2049).collect(joining());
-    PushOneCommit.Result r = pushTo("refs/for/master/" + topic);
-    r.assertErrorStatus("topic length exceeds the limit (2048)");
-  }
-
-  @Test
-  public void pushForMasterWithTopicAsOptionExceedLimitFails() throws Exception {
-    String topic = Stream.generate(() -> "t").limit(2049).collect(joining());
-    PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
-    r.assertErrorStatus("topic length exceeds the limit (2048)");
-  }
-
-  @Test
-  public void pushForMasterWithNotify() throws Exception {
-    // create a user that watches the project
-    TestAccount user3 = accountCreator.create("user3", "user3@example.com", "User3");
-    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
-    ProjectWatchInfo pwi = new ProjectWatchInfo();
-    pwi.project = project.get();
-    pwi.filter = "*";
-    pwi.notifyNewChanges = true;
-    projectsToWatch.add(pwi);
-    setApiUser(user3);
-    gApi.accounts().self().setWatchedProjects(projectsToWatch);
-
-    TestAccount user2 = accountCreator.user2();
-    String pushSpec = "refs/for/master%reviewer=" + user.email + ",cc=" + user2.email;
-
-    sender.clear();
-    PushOneCommit.Result r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE);
-    r.assertOkStatus();
-    assertThat(sender.getMessages()).isEmpty();
-
-    sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.OWNER);
-    r.assertOkStatus();
-    // no email notification about own changes
-    assertThat(sender.getMessages()).isEmpty();
-
-    sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.OWNER_REVIEWERS);
-    r.assertOkStatus();
-    assertThat(sender.getMessages()).hasSize(1);
-    Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-
-    sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.ALL);
-    r.assertOkStatus();
-    assertThat(sender.getMessages()).hasSize(1);
-    m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress, user2.emailAddress, user3.emailAddress);
-
-    sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-to=" + user3.email);
-    r.assertOkStatus();
-    assertNotifyTo(user3);
-
-    sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-cc=" + user3.email);
-    r.assertOkStatus();
-    assertNotifyCc(user3);
-
-    sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-bcc=" + user3.email);
-    r.assertOkStatus();
-    assertNotifyBcc(user3);
-
-    // request that sender gets notified as TO, CC and BCC, email should be sent
-    // even if the sender is the only recipient
-    sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-to=" + admin.email);
-    assertNotifyTo(admin);
-
-    sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-cc=" + admin.email);
-    r.assertOkStatus();
-    assertNotifyCc(admin);
-
-    sender.clear();
-    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-bcc=" + admin.email);
-    r.assertOkStatus();
-    assertNotifyBcc(admin);
-  }
-
-  @Test
-  public void pushForMasterWithCc() throws Exception {
-    // cc one user
-    String topic = "my/topic";
-    PushOneCommit.Result r = pushTo("refs/for/master/" + topic + "%cc=" + user.email);
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, topic, ImmutableList.of(), ImmutableList.of(user));
-
-    // cc several users
-    r =
-        pushTo(
-            "refs/for/master/"
-                + topic
-                + "%cc="
-                + admin.email
-                + ",cc="
-                + user.email
-                + ",cc="
-                + accountCreator.user2().email);
-    r.assertOkStatus();
-    // Check that admin isn't CC'd as they own the change
-    r.assertChange(
-        Change.Status.NEW,
-        topic,
-        ImmutableList.of(),
-        ImmutableList.of(user, accountCreator.user2()));
-
-    // cc non-existing user
-    String nonExistingEmail = "non.existing@example.com";
-    r =
-        pushTo(
-            "refs/for/master/"
-                + topic
-                + "%cc="
-                + admin.email
-                + ",cc="
-                + nonExistingEmail
-                + ",cc="
-                + user.email);
-    r.assertErrorStatus("user \"" + nonExistingEmail + "\" not found");
-  }
-
-  @Test
-  public void pushForMasterWithReviewer() throws Exception {
-    // add one reviewer
-    String topic = "my/topic";
-    PushOneCommit.Result r = pushTo("refs/for/master/" + topic + "%r=" + user.email);
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, topic, user);
-
-    // add several reviewers
-    TestAccount user2 =
-        accountCreator.create("another-user", "another.user@example.com", "Another User");
-    r =
-        pushTo(
-            "refs/for/master/"
-                + topic
-                + "%r="
-                + admin.email
-                + ",r="
-                + user.email
-                + ",r="
-                + user2.email);
-    r.assertOkStatus();
-    // admin is the owner of the change and should not appear as reviewer
-    r.assertChange(Change.Status.NEW, topic, user, user2);
-
-    // add non-existing user as reviewer
-    String nonExistingEmail = "non.existing@example.com";
-    r =
-        pushTo(
-            "refs/for/master/"
-                + topic
-                + "%r="
-                + admin.email
-                + ",r="
-                + nonExistingEmail
-                + ",r="
-                + user.email);
-    r.assertErrorStatus("user \"" + nonExistingEmail + "\" not found");
-  }
-
-  @Test
-  public void pushPrivateChange() throws Exception {
-    // Push a private change.
-    PushOneCommit.Result r = pushTo("refs/for/master%private");
-    r.assertOkStatus();
-    r.assertMessage(" [PRIVATE]");
-    assertThat(r.getChange().change().isPrivate()).isTrue();
-
-    // Pushing a new patch set without --private doesn't remove the privacy flag from the change.
-    r = amendChange(r.getChangeId(), "refs/for/master");
-    r.assertOkStatus();
-    r.assertMessage(" [PRIVATE]");
-    assertThat(r.getChange().change().isPrivate()).isTrue();
-
-    // Remove the privacy flag from the change.
-    r = amendChange(r.getChangeId(), "refs/for/master%remove-private");
-    r.assertOkStatus();
-    r.assertNotMessage(" [PRIVATE]");
-    assertThat(r.getChange().change().isPrivate()).isFalse();
-
-    // Normal push: privacy flag is not added back.
-    r = amendChange(r.getChangeId(), "refs/for/master");
-    r.assertOkStatus();
-    r.assertNotMessage(" [PRIVATE]");
-    assertThat(r.getChange().change().isPrivate()).isFalse();
-
-    // Make the change private again.
-    r = pushTo("refs/for/master%private");
-    r.assertOkStatus();
-    r.assertMessage(" [PRIVATE]");
-    assertThat(r.getChange().change().isPrivate()).isTrue();
-
-    // Can't use --private and --remove-private together.
-    r = pushTo("refs/for/master%private,remove-private");
-    r.assertErrorStatus();
-  }
-
-  @Test
-  public void pushWorkInProgressChange() throws Exception {
-    // Push a work-in-progress change.
-    PushOneCommit.Result r = pushTo("refs/for/master%wip");
-    r.assertOkStatus();
-    r.assertMessage(" [WIP]");
-    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
-    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
-
-    // Pushing a new patch set without --wip doesn't remove the wip flag from the change.
-    r = amendChange(r.getChangeId(), "refs/for/master");
-    r.assertOkStatus();
-    r.assertMessage(" [WIP]");
-    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
-    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
-
-    // Remove the wip flag from the change.
-    r = amendChange(r.getChangeId(), "refs/for/master%ready");
-    r.assertOkStatus();
-    r.assertNotMessage(" [WIP]");
-    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
-    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
-
-    // Normal push: wip flag is not added back.
-    r = amendChange(r.getChangeId(), "refs/for/master");
-    r.assertOkStatus();
-    r.assertNotMessage(" [WIP]");
-    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
-    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
-
-    // Make the change work-in-progress again.
-    r = amendChange(r.getChangeId(), "refs/for/master%wip");
-    r.assertOkStatus();
-    r.assertMessage(" [WIP]");
-    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
-    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
-
-    // Can't use --wip and --ready together.
-    r = amendChange(r.getChangeId(), "refs/for/master%wip,ready");
-    r.assertErrorStatus();
-  }
-
-  private void assertUploadTag(ChangeData cd, String expectedTag) throws Exception {
-    List<ChangeMessage> msgs = cd.messages();
-    assertThat(msgs).isNotEmpty();
-    assertThat(Iterables.getLast(msgs).getTag()).isEqualTo(expectedTag);
-  }
-
-  @Test
-  public void pushWorkInProgressChangeWhenNotOwner() throws Exception {
-    TestRepository<?> userRepo = cloneProject(project, user);
-    PushOneCommit.Result r =
-        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master%wip");
-    r.assertOkStatus();
-    assertThat(r.getChange().change().getOwner()).isEqualTo(user.id);
-    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
-
-    // Other user trying to move from WIP to ready should fail.
-    GitUtil.fetch(testRepo, r.getPatchSet().getRefName() + ":ps");
-    testRepo.reset("ps");
-    r = amendChange(r.getChangeId(), "refs/for/master%ready", admin, testRepo);
-    r.assertErrorStatus(ReceiveConstants.ONLY_OWNER_CAN_MODIFY_WIP);
-
-    // Other user trying to move from WIP to WIP should succeed.
-    r = amendChange(r.getChangeId(), "refs/for/master%wip", admin, testRepo);
-    r.assertOkStatus();
-    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
-
-    // Push as change owner to move change from WIP to ready.
-    r = pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master%ready");
-    r.assertOkStatus();
-    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
-
-    // Other user trying to move from ready to WIP should fail.
-    GitUtil.fetch(testRepo, r.getPatchSet().getRefName() + ":ps");
-    testRepo.reset("ps");
-    r = amendChange(r.getChangeId(), "refs/for/master%wip", admin, testRepo);
-    r.assertErrorStatus(ReceiveConstants.ONLY_OWNER_CAN_MODIFY_WIP);
-
-    // Other user trying to move from ready to ready should succeed.
-    r = amendChange(r.getChangeId(), "refs/for/master%ready", admin, testRepo);
-    r.assertOkStatus();
-  }
-
-  @Test
-  public void pushForMasterAsEdit() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    r.assertOkStatus();
-    Optional<EditInfo> edit = getEdit(r.getChangeId());
-    assertThat(edit).isAbsent();
-    assertThat(query("has:edit")).isEmpty();
-
-    // specify edit as option
-    r = amendChange(r.getChangeId(), "refs/for/master%edit");
-    r.assertOkStatus();
-    edit = getEdit(r.getChangeId());
-    assertThat(edit).isPresent();
-    EditInfo editInfo = edit.get();
-    r.assertMessage(
-        "Updated Changes:\n  "
-            + canonicalWebUrl.get()
-            + "#/c/"
-            + project.get()
-            + "/+/"
-            + r.getChange().getId()
-            + " "
-            + editInfo.commit.subject
-            + " [EDIT]\n");
-
-    // verify that the re-indexing was triggered for the change
-    assertThat(query("has:edit")).hasSize(1);
-  }
-
-  @Test
-  public void pushForMasterWithMessage() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master/%m=my_test_message");
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, null);
-    ChangeInfo ci = get(r.getChangeId());
-    Collection<ChangeMessageInfo> changeMessages = ci.messages;
-    assertThat(changeMessages).hasSize(1);
-    for (ChangeMessageInfo cm : changeMessages) {
-      assertThat(cm.message).isEqualTo("Uploaded patch set 1.\nmy test message");
-    }
-    Collection<RevisionInfo> revisions = ci.revisions.values();
-    assertThat(revisions).hasSize(1);
-    for (RevisionInfo ri : revisions) {
-      assertThat(ri.description).isEqualTo("my test message");
-    }
-  }
-
-  @Test
-  public void pushForMasterWithMessageTwiceWithDifferentMessages() throws Exception {
-    enableCreateNewChangeForAllNotInTarget();
-
-    PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
-    PushOneCommit.Result r = push.to("refs/for/master/%m=my_test_message");
-    r.assertOkStatus();
-
-    push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            "b.txt",
-            "anotherContent",
-            r.getChangeId());
-    r = push.to("refs/for/master/%m=new_test_message");
-    r.assertOkStatus();
-
-    ChangeInfo ci = get(r.getChangeId());
-    Collection<RevisionInfo> revisions = ci.revisions.values();
-    assertThat(revisions).hasSize(2);
-    for (RevisionInfo ri : revisions) {
-      if (ri.isCurrent) {
-        assertThat(ri.description).isEqualTo("new test message");
-      } else {
-        assertThat(ri.description).isEqualTo("my test message");
-      }
-    }
-  }
-
-  @Test
-  public void pushForMasterWithApprovals() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master/%l=Code-Review");
-    r.assertOkStatus();
-    ChangeInfo ci = get(r.getChangeId());
-    LabelInfo cr = ci.labels.get("Code-Review");
-    assertThat(cr.all).hasSize(1);
-    assertThat(cr.all.get(0).name).isEqualTo("Administrator");
-    assertThat(cr.all.get(0).value).isEqualTo(1);
-    assertThat(Iterables.getLast(ci.messages).message)
-        .isEqualTo("Uploaded patch set 1: Code-Review+1.");
-
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            "b.txt",
-            "anotherContent",
-            r.getChangeId());
-    r = push.to("refs/for/master/%l=Code-Review+2");
-
-    ci = get(r.getChangeId());
-    cr = ci.labels.get("Code-Review");
-    assertThat(Iterables.getLast(ci.messages).message)
-        .isEqualTo("Uploaded patch set 2: Code-Review+2.");
-    // Check that the user who pushed the change was added as a reviewer since they added a vote
-    assertThatUserIsOnlyReviewer(ci, admin);
-
-    assertThat(cr.all).hasSize(1);
-    assertThat(cr.all.get(0).name).isEqualTo("Administrator");
-    assertThat(cr.all.get(0).value).isEqualTo(2);
-
-    push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            "c.txt",
-            "moreContent",
-            r.getChangeId());
-    r = push.to("refs/for/master/%l=Code-Review+2");
-    ci = get(r.getChangeId());
-    assertThat(Iterables.getLast(ci.messages).message).isEqualTo("Uploaded patch set 3.");
-  }
-
-  @Test
-  public void pushNewPatchSetForMasterWithApprovals() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    r.assertOkStatus();
-
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            "b.txt",
-            "anotherContent",
-            r.getChangeId());
-    r = push.to("refs/for/master/%l=Code-Review+2");
-
-    ChangeInfo ci = get(r.getChangeId());
-    LabelInfo cr = ci.labels.get("Code-Review");
-    assertThat(Iterables.getLast(ci.messages).message)
-        .isEqualTo("Uploaded patch set 2: Code-Review+2.");
-
-    // Check that the user who pushed the new patch set was added as a reviewer since they added
-    // a vote
-    assertThatUserIsOnlyReviewer(ci, admin);
-
-    assertThat(cr.all).hasSize(1);
-    assertThat(cr.all.get(0).name).isEqualTo("Administrator");
-    assertThat(cr.all.get(0).value).isEqualTo(2);
-  }
-
-  /**
-   * There was a bug that allowed a user with Forge Committer Identity access right to upload a
-   * commit and put *votes on behalf of another user* on it. This test checks that this is not
-   * possible, but that the votes that are specified on push are applied only on behalf of the
-   * uploader.
-   *
-   * <p>This particular bug only occurred when there was more than one label defined. However to
-   * test that the votes that are specified on push are applied on behalf of the uploader a single
-   * label is sufficient.
-   */
-  @Test
-  public void pushForMasterWithApprovalsForgeCommitterButNoForgeVote() throws Exception {
-    // Create a commit with "User" as author and committer
-    RevCommit c =
-        commitBuilder()
-            .author(user.getIdent())
-            .committer(user.getIdent())
-            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
-            .message(PushOneCommit.SUBJECT)
-            .create();
-
-    // Push this commit as "Administrator" (requires Forge Committer Identity)
-    pushHead(testRepo, "refs/for/master/%l=Code-Review+1", false);
-
-    // Expected Code-Review votes:
-    // 1. 0 from User (committer):
-    //    When the committer is forged, the committer is automatically added as
-    //    reviewer, hence we expect a dummy 0 vote for the committer.
-    // 2. +1 from Administrator (uploader):
-    //    On push Code-Review+1 was specified, hence we expect a +1 vote from
-    //    the uploader.
-    ChangeInfo ci = get(GitUtil.getChangeId(testRepo, c).get());
-    LabelInfo cr = ci.labels.get("Code-Review");
-    assertThat(cr.all).hasSize(2);
-    int indexAdmin = admin.fullName.equals(cr.all.get(0).name) ? 0 : 1;
-    int indexUser = indexAdmin == 0 ? 1 : 0;
-    assertThat(cr.all.get(indexAdmin).name).isEqualTo(admin.fullName);
-    assertThat(cr.all.get(indexAdmin).value.intValue()).isEqualTo(1);
-    assertThat(cr.all.get(indexUser).name).isEqualTo(user.fullName);
-    assertThat(cr.all.get(indexUser).value.intValue()).isEqualTo(0);
-    assertThat(Iterables.getLast(ci.messages).message)
-        .isEqualTo("Uploaded patch set 1: Code-Review+1.");
-    // Check that the user who pushed the change was added as a reviewer since they added a vote
-    assertThatUserIsOnlyReviewer(ci, admin);
-  }
-
-  @Test
-  public void pushWithMultipleApprovals() throws Exception {
-    LabelType Q =
-        category("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-    String heads = "refs/heads/*";
-    Util.allow(config, Permission.forLabel("Custom-Label"), -1, 1, anon, heads);
-    config.getLabelSections().put(Q.getName(), Q);
-    saveProjectConfig(project, config);
-
-    RevCommit c =
-        commitBuilder()
-            .author(admin.getIdent())
-            .committer(admin.getIdent())
-            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
-            .message(PushOneCommit.SUBJECT)
-            .create();
-
-    pushHead(testRepo, "refs/for/master/%l=Code-Review+1,l=Custom-Label-1", false);
-
-    ChangeInfo ci = get(GitUtil.getChangeId(testRepo, c).get());
-    LabelInfo cr = ci.labels.get("Code-Review");
-    assertThat(cr.all).hasSize(1);
-    cr = ci.labels.get("Custom-Label");
-    assertThat(cr.all).hasSize(1);
-    // Check that the user who pushed the change was added as a reviewer since they added a vote
-    assertThatUserIsOnlyReviewer(ci, admin);
-  }
-
-  @Test
-  public void pushNewPatchsetToRefsChanges() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    r.assertOkStatus();
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            "b.txt",
-            "anotherContent",
-            r.getChangeId());
-    r = push.to("refs/changes/" + r.getChange().change().getId().get());
-    r.assertOkStatus();
-  }
-
-  @Test
-  public void pushNewPatchsetToPatchSetLockedChange() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    r.assertOkStatus();
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            "b.txt",
-            "anotherContent",
-            r.getChangeId());
-    revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
-    r = push.to("refs/for/master");
-    r.assertErrorStatus("cannot add patch set to " + r.getChange().change().getChangeId() + ".");
-  }
-
-  @Test
-  public void pushForMasterWithApprovals_MissingLabel() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master/%l=Verify");
-    r.assertErrorStatus("label \"Verify\" is not a configured label");
-  }
-
-  @Test
-  public void pushForMasterWithApprovals_ValueOutOfRange() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master/%l=Code-Review-3");
-    r.assertErrorStatus("label \"Code-Review\": -3 is not a valid value");
-  }
-
-  @Test
-  public void pushForNonExistingBranch() throws Exception {
-    String branchName = "non-existing";
-    PushOneCommit.Result r = pushTo("refs/for/" + branchName);
-    r.assertErrorStatus("branch " + branchName + " not found");
-  }
-
-  @Test
-  public void pushForMasterWithHashtags() throws Exception {
-    // Hashtags only work when reading from NoteDB is enabled
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    // specify a single hashtag as option
-    String hashtag1 = "tag1";
-    Set<String> expected = ImmutableSet.of(hashtag1);
-    PushOneCommit.Result r = pushTo("refs/for/master%hashtag=#" + hashtag1);
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, null);
-
-    Set<String> hashtags = gApi.changes().id(r.getChangeId()).getHashtags();
-    assertThat(hashtags).containsExactlyElementsIn(expected);
-
-    // specify a single hashtag as option in new patch set
-    String hashtag2 = "tag2";
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            "b.txt",
-            "anotherContent",
-            r.getChangeId());
-    r = push.to("refs/for/master/%hashtag=" + hashtag2);
-    r.assertOkStatus();
-    expected = ImmutableSet.of(hashtag1, hashtag2);
-    hashtags = gApi.changes().id(r.getChangeId()).getHashtags();
-    assertThat(hashtags).containsExactlyElementsIn(expected);
-  }
-
-  @Test
-  public void pushForMasterWithMultipleHashtags() throws Exception {
-    // Hashtags only work when reading from NoteDB is enabled
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    // specify multiple hashtags as options
-    String hashtag1 = "tag1";
-    String hashtag2 = "tag2";
-    Set<String> expected = ImmutableSet.of(hashtag1, hashtag2);
-    PushOneCommit.Result r =
-        pushTo("refs/for/master%hashtag=#" + hashtag1 + ",hashtag=##" + hashtag2);
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, null);
-
-    Set<String> hashtags = gApi.changes().id(r.getChangeId()).getHashtags();
-    assertThat(hashtags).containsExactlyElementsIn(expected);
-
-    // specify multiple hashtags as options in new patch set
-    String hashtag3 = "tag3";
-    String hashtag4 = "tag4";
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            "b.txt",
-            "anotherContent",
-            r.getChangeId());
-    r = push.to("refs/for/master%hashtag=" + hashtag3 + ",hashtag=" + hashtag4);
-    r.assertOkStatus();
-    expected = ImmutableSet.of(hashtag1, hashtag2, hashtag3, hashtag4);
-    hashtags = gApi.changes().id(r.getChangeId()).getHashtags();
-    assertThat(hashtags).containsExactlyElementsIn(expected);
-  }
-
-  @Test
-  public void pushForMasterWithHashtagsNoteDbDisabled() throws Exception {
-    // Push with hashtags should fail when reading from NoteDb is disabled.
-    assume().that(notesMigration.readChanges()).isFalse();
-    PushOneCommit.Result r = pushTo("refs/for/master%hashtag=tag1");
-    r.assertErrorStatus("cannot add hashtags; noteDb is disabled");
-  }
-
-  @Test
-  public void pushCommitUsingSignedOffBy() throws Exception {
-    PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-
-    setUseSignedOffBy(InheritableBoolean.TRUE);
-    blockForgeCommitter(project, "refs/heads/master");
-
-    push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT
-                + String.format("\n\nSigned-off-by: %s <%s>", admin.fullName, admin.email),
-            "b.txt",
-            "anotherContent");
-    r = push.to("refs/for/master");
-    r.assertOkStatus();
-
-    push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
-    r = push.to("refs/for/master");
-    r.assertErrorStatus("not Signed-off-by author/committer/uploader in commit message footer");
-  }
-
-  @Test
-  public void createNewChangeForAllNotInTarget() throws Exception {
-    enableCreateNewChangeForAllNotInTarget();
-
-    PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-
-    push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
-    r = push.to("refs/for/master");
-    r.assertOkStatus();
-
-    gApi.projects().name(project.get()).branch("otherBranch").create(new BranchInput());
-
-    PushOneCommit.Result r2 = push.to("refs/for/otherBranch");
-    r2.assertOkStatus();
-    assertTwoChangesWithSameRevision(r);
-  }
-
-  @Test
-  public void pushChangeBasedOnChangeOfOtherUserWithCreateNewChangeForAllNotInTarget()
-      throws Exception {
-    enableCreateNewChangeForAllNotInTarget();
-
-    // create a change as admin
-    PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    RevCommit commitChange1 = r.getCommit();
-
-    // create a second change as user (depends on the change from admin)
-    TestRepository<?> userRepo = cloneProject(project, user);
-    GitUtil.fetch(userRepo, r.getPatchSet().getRefName() + ":change");
-    userRepo.reset("change");
-    push =
-        pushFactory.create(
-            db, user.getIdent(), userRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
-    r = push.to("refs/for/master");
-    r.assertOkStatus();
-
-    // assert that no new change was created for the commit of the predecessor change
-    assertThat(query(commitChange1.name())).hasSize(1);
-  }
-
-  @Test
-  public void pushSameCommitTwiceUsingMagicBranchBaseOption() throws Exception {
-    grant(project, "refs/heads/master", Permission.PUSH);
-    PushOneCommit.Result rBase = pushTo("refs/heads/master");
-    rBase.assertOkStatus();
-
-    gApi.projects().name(project.get()).branch("foo").create(new BranchInput());
-
-    PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
-
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-
-    PushResult pr =
-        GitUtil.pushHead(testRepo, "refs/for/foo%base=" + rBase.getCommit().name(), false, false);
-
-    // BatchUpdate implementations differ in how they hook into progress monitors. We mostly just
-    // care that there is a new change.
-    assertThat(pr.getMessages()).containsMatch("changes: new: 1,( refs: 1)? done");
-    assertTwoChangesWithSameRevision(r);
-  }
-
-  @Test
-  public void pushSameCommitTwice() throws Exception {
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config.getProject().setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
-    saveProjectConfig(project, config);
-
-    PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-
-    push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
-    r = push.to("refs/for/master");
-    r.assertOkStatus();
-
-    assertPushRejected(
-        pushHead(testRepo, "refs/for/master", false),
-        "refs/for/master",
-        "commit(s) already exists (as current patchset)");
-  }
-
-  @Test
-  public void pushSameCommitTwiceWhenIndexFailed() throws Exception {
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config.getProject().setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
-    saveProjectConfig(project, config);
-
-    PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-
-    push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
-    r = push.to("refs/for/master");
-    r.assertOkStatus();
-
-    indexer.delete(r.getChange().getId());
-
-    assertPushRejected(
-        pushHead(testRepo, "refs/for/master", false),
-        "refs/for/master",
-        "commit(s) already exists (as current patchset)");
-  }
-
-  private void assertTwoChangesWithSameRevision(PushOneCommit.Result result) throws Exception {
-    List<ChangeInfo> changes = query(result.getCommit().name());
-    assertThat(changes).hasSize(2);
-    ChangeInfo c1 = get(changes.get(0).id);
-    ChangeInfo c2 = get(changes.get(1).id);
-    assertThat(c1.project).isEqualTo(c2.project);
-    assertThat(c1.branch).isNotEqualTo(c2.branch);
-    assertThat(c1.changeId).isEqualTo(c2.changeId);
-    assertThat(c1.currentRevision).isEqualTo(c2.currentRevision);
-  }
-
-  @Test
-  public void pushAFewChanges() throws Exception {
-    testPushAFewChanges();
-  }
-
-  @Test
-  public void pushAFewChangesWithCreateNewChangeForAllNotInTarget() throws Exception {
-    enableCreateNewChangeForAllNotInTarget();
-    testPushAFewChanges();
-  }
-
-  private void testPushAFewChanges() throws Exception {
-    int n = 10;
-    String r = "refs/for/master";
-    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
-    List<RevCommit> commits = createChanges(n, r);
-
-    // Check that a change was created for each.
-    for (RevCommit c : commits) {
-      assertThat(byCommit(c).change().getSubject())
-          .named("change for " + c.name())
-          .isEqualTo(c.getShortMessage());
-    }
-
-    List<RevCommit> commits2 = amendChanges(initialHead, commits, r);
-
-    // Check that there are correct patch sets.
-    for (int i = 0; i < n; i++) {
-      RevCommit c = commits.get(i);
-      RevCommit c2 = commits2.get(i);
-      String name = "change for " + c2.name();
-      ChangeData cd = byCommit(c);
-      assertThat(cd.change().getSubject()).named(name).isEqualTo(c2.getShortMessage());
-      assertThat(getPatchSetRevisions(cd))
-          .named(name)
-          .containsExactlyEntriesIn(ImmutableMap.of(1, c.name(), 2, c2.name()));
-    }
-
-    // Pushing again results in "no new changes".
-    assertPushRejected(pushHead(testRepo, r, false), r, "no new changes");
-  }
-
-  @Test
-  public void pushWithoutChangeId() throws Exception {
-    testPushWithoutChangeId();
-  }
-
-  @Test
-  public void pushWithoutChangeIdWithCreateNewChangeForAllNotInTarget() throws Exception {
-    enableCreateNewChangeForAllNotInTarget();
-    testPushWithoutChangeId();
-  }
-
-  private void testPushWithoutChangeId() throws Exception {
-    RevCommit c = createCommit(testRepo, "Message without Change-Id");
-    assertThat(GitUtil.getChangeId(testRepo, c)).isEmpty();
-    pushForReviewRejected(testRepo, "missing Change-Id in commit message footer");
-
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config.getProject().setRequireChangeID(InheritableBoolean.FALSE);
-    saveProjectConfig(project, config);
-    pushForReviewOk(testRepo);
-  }
-
-  @Test
-  public void pushWithMultipleChangeIds() throws Exception {
-    testPushWithMultipleChangeIds();
-  }
-
-  @Test
-  public void pushWithMultipleChangeIdsWithCreateNewChangeForAllNotInTarget() throws Exception {
-    enableCreateNewChangeForAllNotInTarget();
-    testPushWithMultipleChangeIds();
-  }
-
-  private void testPushWithMultipleChangeIds() throws Exception {
-    createCommit(
-        testRepo,
-        "Message with multiple Change-Id\n"
-            + "\n"
-            + "Change-Id: I10f98c2ef76e52e23aa23be5afeb71e40b350e86\n"
-            + "Change-Id: Ie9a132e107def33bdd513b7854b50de911edba0a\n");
-    pushForReviewRejected(testRepo, "multiple Change-Id lines in commit message footer");
-
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config.getProject().setRequireChangeID(InheritableBoolean.FALSE);
-    saveProjectConfig(project, config);
-    pushForReviewRejected(testRepo, "multiple Change-Id lines in commit message footer");
-  }
-
-  @Test
-  public void pushWithInvalidChangeId() throws Exception {
-    testpushWithInvalidChangeId();
-  }
-
-  @Test
-  public void pushWithInvalidChangeIdWithCreateNewChangeForAllNotInTarget() throws Exception {
-    enableCreateNewChangeForAllNotInTarget();
-    testpushWithInvalidChangeId();
-  }
-
-  private void testpushWithInvalidChangeId() throws Exception {
-    createCommit(testRepo, "Message with invalid Change-Id\n\nChange-Id: X\n");
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
-
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config.getProject().setRequireChangeID(InheritableBoolean.FALSE);
-    saveProjectConfig(project, config);
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
-  }
-
-  @Test
-  public void pushWithInvalidChangeIdFromEgit() throws Exception {
-    testPushWithInvalidChangeIdFromEgit();
-  }
-
-  @Test
-  public void pushWithInvalidChangeIdFromEgitWithCreateNewChangeForAllNotInTarget()
-      throws Exception {
-    enableCreateNewChangeForAllNotInTarget();
-    testPushWithInvalidChangeIdFromEgit();
-  }
-
-  private void testPushWithInvalidChangeIdFromEgit() throws Exception {
-    createCommit(
-        testRepo,
-        "Message with invalid Change-Id\n"
-            + "\n"
-            + "Change-Id: I0000000000000000000000000000000000000000\n");
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
-
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config.getProject().setRequireChangeID(InheritableBoolean.FALSE);
-    saveProjectConfig(project, config);
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
-  }
-
-  @Test
-  public void pushCommitWithSameChangeIdAsPredecessorChange() throws Exception {
-    PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    RevCommit commitChange1 = r.getCommit();
-
-    createCommit(testRepo, commitChange1.getFullMessage());
-
-    pushForReviewRejected(
-        testRepo,
-        "same Change-Id in multiple changes.\n"
-            + "Squash the commits with the same Change-Id or ensure Change-Ids are unique for each"
-            + " commit");
-
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config.getProject().setRequireChangeID(InheritableBoolean.FALSE);
-    saveProjectConfig(project, config);
-
-    pushForReviewRejected(
-        testRepo,
-        "same Change-Id in multiple changes.\n"
-            + "Squash the commits with the same Change-Id or ensure Change-Ids are unique for each"
-            + " commit");
-  }
-
-  @Test
-  public void pushTwoCommitWithSameChangeId() throws Exception {
-    RevCommit commitChange1 = createCommitWithChangeId(testRepo, "some change");
-
-    createCommit(testRepo, commitChange1.getFullMessage());
-
-    pushForReviewRejected(
-        testRepo,
-        "same Change-Id in multiple changes.\n"
-            + "Squash the commits with the same Change-Id or ensure Change-Ids are unique for each"
-            + " commit");
-
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config.getProject().setRequireChangeID(InheritableBoolean.FALSE);
-    saveProjectConfig(project, config);
-
-    pushForReviewRejected(
-        testRepo,
-        "same Change-Id in multiple changes.\n"
-            + "Squash the commits with the same Change-Id or ensure Change-Ids are unique for each"
-            + " commit");
-  }
-
-  private static RevCommit createCommit(TestRepository<?> testRepo, String message)
-      throws Exception {
-    return testRepo.branch("HEAD").commit().message(message).add("a.txt", "content").create();
-  }
-
-  private static RevCommit createCommitWithChangeId(TestRepository<?> testRepo, String message)
-      throws Exception {
-    RevCommit c =
-        testRepo
-            .branch("HEAD")
-            .commit()
-            .message(message)
-            .insertChangeId()
-            .add("a.txt", "content")
-            .create();
-    return testRepo.getRevWalk().parseCommit(c);
-  }
-
-  @Test
-  public void cantAutoCloseChangeAlreadyMergedToBranch() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    Change.Id id1 = r1.getChange().getId();
-    PushOneCommit.Result r2 = createChange();
-    Change.Id id2 = r2.getChange().getId();
-
-    // Merge change 1 behind Gerrit's back.
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<?> tr = new TestRepository<>(repo);
-      tr.branch("refs/heads/master").update(r1.getCommit());
-    }
-
-    assertThat(gApi.changes().id(id1.get()).info().status).isEqualTo(ChangeStatus.NEW);
-    assertThat(gApi.changes().id(id2.get()).info().status).isEqualTo(ChangeStatus.NEW);
-    r2 = amendChange(r2.getChangeId());
-    r2.assertOkStatus();
-
-    // Change 1 is still new despite being merged into the branch, because
-    // ReceiveCommits only considers commits between the branch tip (which is
-    // now the merged change 1) and the push tip (new patch set of change 2).
-    assertThat(gApi.changes().id(id1.get()).info().status).isEqualTo(ChangeStatus.NEW);
-    assertThat(gApi.changes().id(id2.get()).info().status).isEqualTo(ChangeStatus.NEW);
-  }
-
-  @Test
-  public void accidentallyPushNewPatchSetDirectlyToBranchAndRecoverByPushingToRefsChanges()
-      throws Exception {
-    Change.Id id = accidentallyPushNewPatchSetDirectlyToBranch();
-    ChangeData cd = byChangeId(id);
-    String ps1Rev = Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
-
-    String r = "refs/changes/" + id;
-    assertPushOk(pushHead(testRepo, r, false), r);
-
-    // Added a new patch set and auto-closed the change.
-    cd = byChangeId(id);
-    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertThat(getPatchSetRevisions(cd))
-        .containsExactlyEntriesIn(
-            ImmutableMap.of(1, ps1Rev, 2, testRepo.getRepository().resolve("HEAD").name()));
-  }
-
-  @Test
-  public void accidentallyPushNewPatchSetDirectlyToBranchAndCantRecoverByPushingToRefsFor()
-      throws Exception {
-    Change.Id id = accidentallyPushNewPatchSetDirectlyToBranch();
-    ChangeData cd = byChangeId(id);
-    String ps1Rev = Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
-
-    String r = "refs/for/master";
-    assertPushRejected(pushHead(testRepo, r, false), r, "no new changes");
-
-    // Change not updated.
-    cd = byChangeId(id);
-    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.NEW);
-    assertThat(getPatchSetRevisions(cd)).containsExactlyEntriesIn(ImmutableMap.of(1, ps1Rev));
-  }
-
-  @Test
-  public void forcePushAbandonedChange() throws Exception {
-    grant(project, "refs/*", Permission.PUSH, true);
-    PushOneCommit push1 =
-        pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
-    PushOneCommit.Result r = push1.to("refs/for/master");
-    r.assertOkStatus();
-
-    // abandon the change
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    ChangeInfo info = get(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-
-    push1.setForce(true);
-    PushOneCommit.Result r1 = push1.to("refs/heads/master");
-    r1.assertOkStatus();
-    ChangeInfo result = Iterables.getOnlyElement(gApi.changes().query(r.getChangeId()).get());
-    assertThat(result.status).isEqualTo(ChangeStatus.MERGED);
-  }
-
-  private Change.Id accidentallyPushNewPatchSetDirectlyToBranch() throws Exception {
-    PushOneCommit.Result r = createChange();
-    RevCommit ps1Commit = r.getCommit();
-    Change c = r.getChange().change();
-
-    RevCommit ps2Commit;
-    try (Repository repo = repoManager.openRepository(project)) {
-      // Create a new patch set of the change directly in Gerrit's repository,
-      // without pushing it. In reality it's more likely that the client would
-      // create and push this behind Gerrit's back (e.g. an admin accidentally
-      // using direct ssh access to the repo), but that's harder to do in tests.
-      TestRepository<?> tr = new TestRepository<>(repo);
-      ps2Commit =
-          tr.branch("refs/heads/master")
-              .commit()
-              .message(ps1Commit.getShortMessage() + " v2")
-              .insertChangeId(r.getChangeId().substring(1))
-              .create();
-    }
-
-    testRepo.git().fetch().setRefSpecs(new RefSpec("refs/heads/master")).call();
-    testRepo.reset(ps2Commit);
-
-    ChangeData cd = byCommit(ps1Commit);
-    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.NEW);
-    assertThat(getPatchSetRevisions(cd))
-        .containsExactlyEntriesIn(ImmutableMap.of(1, ps1Commit.name()));
-    return c.getId();
-  }
-
-  @Test
-  public void pushWithEmailInFooter() throws Exception {
-    pushWithReviewerInFooter(user.emailAddress.toString(), user);
-  }
-
-  @Test
-  public void pushWithNameInFooter() throws Exception {
-    pushWithReviewerInFooter(user.fullName, user);
-  }
-
-  @Test
-  public void pushWithEmailInFooterNotFound() throws Exception {
-    pushWithReviewerInFooter(new Address("No Body", "notarealuser@example.com").toString(), null);
-  }
-
-  @Test
-  public void pushWithNameInFooterNotFound() throws Exception {
-    pushWithReviewerInFooter("Notauser", null);
-  }
-
-  @Test
-  public void pushNewPatchsetOverridingStickyLabel() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType codeReview = Util.codeReview();
-    codeReview.setCopyMaxScore(true);
-    cfg.getLabelSections().put(codeReview.getName(), codeReview);
-    saveProjectConfig(cfg);
-
-    PushOneCommit.Result r = pushTo("refs/for/master%l=Code-Review+2");
-    r.assertOkStatus();
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            "b.txt",
-            "anotherContent",
-            r.getChangeId());
-    r = push.to("refs/for/master%l=Code-Review+1");
-    r.assertOkStatus();
-  }
-
-  @Test
-  public void createChangeForMergedCommit() throws Exception {
-    String master = "refs/heads/master";
-    grant(project, master, Permission.PUSH, true);
-
-    // Update master with a direct push.
-    RevCommit c1 = testRepo.commit().message("Non-change 1").create();
-    RevCommit c2 =
-        testRepo.parseBody(
-            testRepo.commit().parent(c1).message("Non-change 2").insertChangeId().create());
-    String changeId = Iterables.getOnlyElement(c2.getFooterLines(CHANGE_ID));
-
-    testRepo.reset(c2);
-    assertPushOk(pushHead(testRepo, master, false, true), master);
-
-    String q = "commit:" + c1.name() + " OR commit:" + c2.name() + " OR change:" + changeId;
-    assertThat(gApi.changes().query(q).get()).isEmpty();
-
-    // Push c2 as a merged change.
-    String r = "refs/for/master%merged";
-    assertPushOk(pushHead(testRepo, r, false), r);
-
-    EnumSet<ListChangesOption> opts = EnumSet.of(ListChangesOption.CURRENT_REVISION);
-    ChangeInfo info = gApi.changes().id(changeId).get(opts);
-    assertThat(info.currentRevision).isEqualTo(c2.name());
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-
-    // Only c2 was created as a change.
-    String q1 = "commit: " + c1.name();
-    assertThat(gApi.changes().query(q1).get()).isEmpty();
-
-    // Push c1 as a merged change.
-    testRepo.reset(c1);
-    assertPushOk(pushHead(testRepo, r, false), r);
-    List<ChangeInfo> infos = gApi.changes().query(q1).withOptions(opts).get();
-    assertThat(infos).hasSize(1);
-    info = infos.get(0);
-    assertThat(info.currentRevision).isEqualTo(c1.name());
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-  }
-
-  @Test
-  public void mergedOptionFailsWhenCommitIsNotMerged() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master%merged");
-    r.assertErrorStatus("not merged into branch");
-  }
-
-  @Test
-  public void mergedOptionFailsWhenCommitIsMergedOnOtherBranch() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    r.assertOkStatus();
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).current().submit();
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<?> tr = new TestRepository<>(repo);
-      tr.branch("refs/heads/branch").commit().message("Initial commit on branch").create();
-    }
-
-    pushTo("refs/for/master%merged").assertErrorStatus("not merged into branch");
-  }
-
-  @Test
-  public void mergedOptionFailsWhenChangeExists() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    r.assertOkStatus();
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).current().submit();
-
-    testRepo.reset(r.getCommit());
-    String ref = "refs/for/master%merged";
-    PushResult pr = pushHead(testRepo, ref, false);
-    RemoteRefUpdate rru = pr.getRemoteUpdate(ref);
-    assertThat(rru.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
-    assertThat(rru.getMessage()).contains("no new changes");
-  }
-
-  @Test
-  public void mergedOptionWithNewCommitWithSameChangeIdFails() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    r.assertOkStatus();
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).current().submit();
-
-    RevCommit c2 =
-        testRepo
-            .amend(r.getCommit())
-            .message("New subject")
-            .insertChangeId(r.getChangeId().substring(1))
-            .create();
-    testRepo.reset(c2);
-
-    String ref = "refs/for/master%merged";
-    PushResult pr = pushHead(testRepo, ref, false);
-    RemoteRefUpdate rru = pr.getRemoteUpdate(ref);
-    assertThat(rru.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
-    assertThat(rru.getMessage()).contains("not merged into branch");
-  }
-
-  @Test
-  public void mergedOptionWithExistingChangeInsertsPatchSet() throws Exception {
-    String master = "refs/heads/master";
-    grant(project, master, Permission.PUSH, true);
-
-    PushOneCommit.Result r = pushTo("refs/for/master");
-    r.assertOkStatus();
-    ObjectId c1 = r.getCommit().copy();
-
-    // Create a PS2 commit directly on master in the server's repo. This
-    // simulates the client amending locally and pushing directly to the branch,
-    // expecting the change to be auto-closed, but the change metadata update
-    // fails.
-    ObjectId c2;
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<?> tr = new TestRepository<>(repo);
-      RevCommit commit2 =
-          tr.amend(c1).message("New subject").insertChangeId(r.getChangeId().substring(1)).create();
-      c2 = commit2.copy();
-      tr.update(master, c2);
-    }
-
-    testRepo.git().fetch().setRefSpecs(new RefSpec("refs/heads/master")).call();
-    testRepo.reset(c2);
-
-    String ref = "refs/for/master%merged";
-    assertPushOk(pushHead(testRepo, ref, false), ref);
-
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get(ALL_REVISIONS);
-    assertThat(info.currentRevision).isEqualTo(c2.name());
-    assertThat(info.revisions.keySet()).containsExactly(c1.name(), c2.name());
-    // TODO(dborowitz): Fix ReceiveCommits to also auto-close the change.
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-  }
-
-  @Test
-  public void publishCommentsOnPushPublishesDraftsOnAllRevisions() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String rev1 = r.getCommit().name();
-    CommentInfo c1 = addDraft(r.getChangeId(), rev1, newDraft(FILE_NAME, 1, "comment1"));
-    CommentInfo c2 = addDraft(r.getChangeId(), rev1, newDraft(FILE_NAME, 1, "comment2"));
-
-    r = amendChange(r.getChangeId());
-    String rev2 = r.getCommit().name();
-    CommentInfo c3 = addDraft(r.getChangeId(), rev2, newDraft(FILE_NAME, 1, "comment3"));
-
-    assertThat(getPublishedComments(r.getChangeId())).isEmpty();
-
-    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
-    sender.clear();
-    amendChange(r.getChangeId(), "refs/for/master%publish-comments");
-
-    Collection<CommentInfo> comments = getPublishedComments(r.getChangeId());
-    assertThat(comments.stream().map(c -> c.id)).containsExactly(c1.id, c2.id, c3.id);
-    assertThat(comments.stream().map(c -> c.message))
-        .containsExactly("comment1", "comment2", "comment3");
-    assertThat(getLastMessage(r.getChangeId())).isEqualTo("Uploaded patch set 3.\n\n(3 comments)");
-
-    List<String> messages =
-        sender
-            .getMessages()
-            .stream()
-            .map(m -> m.body())
-            .sorted(Comparator.comparingInt(m -> m.contains("reexamine") ? 0 : 1))
-            .collect(toList());
-    assertThat(messages).hasSize(2);
-
-    assertThat(messages.get(0)).contains("Gerrit-MessageType: newpatchset");
-    assertThat(messages.get(0)).contains("I'd like you to reexamine a change");
-    assertThat(messages.get(0)).doesNotContain("Uploaded patch set 3");
-
-    assertThat(messages.get(1)).contains("Gerrit-MessageType: comment");
-    assertThat(messages.get(1))
-        .containsMatch(
-            Pattern.compile(
-                // A little weird that the comment email contains this text, but it's actually
-                // what's in the ChangeMessage. Really we should fuse the emails into one, but until
-                // then, this test documents the current behavior.
-                "Uploaded patch set 3\\.\n"
-                    + "\n"
-                    + "\\(3 comments\\)\\n.*"
-                    + "PS1, Line 1:.*"
-                    + "comment1\\n.*"
-                    + "PS1, Line 1:.*"
-                    + "comment2\\n.*"
-                    + "PS2, Line 1:.*"
-                    + "comment3\\n",
-                Pattern.DOTALL));
-  }
-
-  @Test
-  public void publishCommentsOnPushWithMessage() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String rev = r.getCommit().name();
-    addDraft(r.getChangeId(), rev, newDraft(FILE_NAME, 1, "comment1"));
-
-    r = amendChange(r.getChangeId(), "refs/for/master%publish-comments,m=The_message");
-
-    Collection<CommentInfo> comments = getPublishedComments(r.getChangeId());
-    assertThat(comments.stream().map(c -> c.message)).containsExactly("comment1");
-    assertThat(getLastMessage(r.getChangeId()))
-        .isEqualTo("Uploaded patch set 2.\n\n(1 comment)\n\nThe message");
-  }
-
-  @Test
-  public void publishCommentsOnPushPublishesDraftsOnMultipleChanges() throws Exception {
-    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
-    List<RevCommit> commits = createChanges(2, "refs/for/master");
-    String id1 = byCommit(commits.get(0)).change().getKey().get();
-    String id2 = byCommit(commits.get(1)).change().getKey().get();
-    CommentInfo c1 = addDraft(id1, commits.get(0).name(), newDraft(FILE_NAME, 1, "comment1"));
-    CommentInfo c2 = addDraft(id2, commits.get(1).name(), newDraft(FILE_NAME, 1, "comment2"));
-
-    assertThat(getPublishedComments(id1)).isEmpty();
-    assertThat(getPublishedComments(id2)).isEmpty();
-
-    amendChanges(initialHead, commits, "refs/for/master%publish-comments");
-
-    Collection<CommentInfo> cs1 = getPublishedComments(id1);
-    assertThat(cs1.stream().map(c -> c.message)).containsExactly("comment1");
-    assertThat(cs1.stream().map(c -> c.id)).containsExactly(c1.id);
-    assertThat(getLastMessage(id1))
-        .isEqualTo("Uploaded patch set 2: Commit message was updated.\n\n(1 comment)");
-
-    Collection<CommentInfo> cs2 = getPublishedComments(id2);
-    assertThat(cs2.stream().map(c -> c.message)).containsExactly("comment2");
-    assertThat(cs2.stream().map(c -> c.id)).containsExactly(c2.id);
-    assertThat(getLastMessage(id2))
-        .isEqualTo("Uploaded patch set 2: Commit message was updated.\n\n(1 comment)");
-  }
-
-  @Test
-  public void publishCommentsOnPushOnlyPublishesDraftsOnUpdatedChanges() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-    String id1 = r1.getChangeId();
-    String id2 = r2.getChangeId();
-    addDraft(id1, r1.getCommit().name(), newDraft(FILE_NAME, 1, "comment1"));
-    CommentInfo c2 = addDraft(id2, r2.getCommit().name(), newDraft(FILE_NAME, 1, "comment2"));
-
-    assertThat(getPublishedComments(id1)).isEmpty();
-    assertThat(getPublishedComments(id2)).isEmpty();
-
-    r2 = amendChange(id2, "refs/for/master%publish-comments");
-
-    assertThat(getPublishedComments(id1)).isEmpty();
-    assertThat(gApi.changes().id(id1).drafts()).hasSize(1);
-
-    Collection<CommentInfo> cs2 = getPublishedComments(id2);
-    assertThat(cs2.stream().map(c -> c.message)).containsExactly("comment2");
-    assertThat(cs2.stream().map(c -> c.id)).containsExactly(c2.id);
-
-    assertThat(getLastMessage(id1)).doesNotMatch("[Cc]omment");
-    assertThat(getLastMessage(id2)).isEqualTo("Uploaded patch set 2.\n\n(1 comment)");
-  }
-
-  @Test
-  public void publishCommentsOnPushWithPreference() throws Exception {
-    PushOneCommit.Result r = createChange();
-    addDraft(r.getChangeId(), r.getCommit().name(), newDraft(FILE_NAME, 1, "comment1"));
-    r = amendChange(r.getChangeId());
-
-    assertThat(getPublishedComments(r.getChangeId())).isEmpty();
-
-    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
-    prefs.publishCommentsOnPush = true;
-    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
-
-    r = amendChange(r.getChangeId());
-    assertThat(getPublishedComments(r.getChangeId()).stream().map(c -> c.message))
-        .containsExactly("comment1");
-  }
-
-  @Test
-  public void publishCommentsOnPushOverridingPreference() throws Exception {
-    PushOneCommit.Result r = createChange();
-    addDraft(r.getChangeId(), r.getCommit().name(), newDraft(FILE_NAME, 1, "comment1"));
-
-    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
-    prefs.publishCommentsOnPush = true;
-    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
-
-    r = amendChange(r.getChangeId(), "refs/for/master%no-publish-comments");
-
-    assertThat(getPublishedComments(r.getChangeId())).isEmpty();
-  }
-
-  @Test
-  public void pushDraftGetsPrivateChange() throws Exception {
-    String changeId1 = createChange("refs/drafts/master").getChangeId();
-    String changeId2 = createChange("refs/for/master%draft").getChangeId();
-
-    ChangeInfo info1 = gApi.changes().id(changeId1).get();
-    ChangeInfo info2 = gApi.changes().id(changeId2).get();
-
-    assertThat(info1.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(info2.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(info1.isPrivate).isEqualTo(true);
-    assertThat(info2.isPrivate).isEqualTo(true);
-    assertThat(info1.revisions).hasSize(1);
-    assertThat(info2.revisions).hasSize(1);
-  }
-
-  @Sandboxed
-  @Test
-  public void pushWithDraftOptionToExistingNewChangeGetsChangeEdit() throws Exception {
-    String changeId = createChange().getChangeId();
-    EditInfoSubject.assertThat(getEdit(changeId)).isAbsent();
-
-    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
-    ChangeStatus originalChangeStatus = changeInfo.status;
-
-    PushOneCommit.Result result = amendChange(changeId, "refs/drafts/master");
-    result.assertOkStatus();
-
-    changeInfo = gApi.changes().id(changeId).get();
-    assertThat(changeInfo.status).isEqualTo(originalChangeStatus);
-    assertThat(changeInfo.isPrivate).isNull();
-    assertThat(changeInfo.revisions).hasSize(1);
-
-    EditInfoSubject.assertThat(getEdit(changeId)).isPresent();
-  }
-
-  @GerritConfig(name = "receive.maxBatchCommits", value = "2")
-  @Test
-  public void maxBatchCommits() throws Exception {
-    List<RevCommit> commits = new ArrayList<>();
-    commits.addAll(initChanges(2));
-    String master = "refs/heads/master";
-    assertPushOk(pushHead(testRepo, master), master);
-
-    commits.addAll(initChanges(3));
-    assertPushRejected(pushHead(testRepo, master), master, "too many commits");
-
-    grantSkipValidation(project, master, SystemGroupBackend.REGISTERED_USERS);
-    PushResult r =
-        pushHead(testRepo, master, false, false, ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
-    assertPushOk(r, master);
-
-    // No open changes; branch was advanced.
-    String q = commits.stream().map(ObjectId::name).collect(joining(" OR commit:", "commit:", ""));
-    assertThat(gApi.changes().query(q).get()).isEmpty();
-    assertThat(gApi.projects().name(project.get()).branch(master).get().revision)
-        .isEqualTo(Iterables.getLast(commits).name());
-  }
-
-  @Test
-  public void pushToPublishMagicBranchIsAllowed() throws Exception {
-    // Push to "refs/publish/*" will be a synonym of "refs/for/*".
-    createChange("refs/publish/master");
-    PushOneCommit.Result result = pushTo("refs/publish/master");
-    result.assertOkStatus();
-    assertThat(result.getMessage())
-        .endsWith("Pushing to refs/publish/* is deprecated, use refs/for/* instead.\n");
-  }
-
-  private DraftInput newDraft(String path, int line, String message) {
-    DraftInput d = new DraftInput();
-    d.path = path;
-    d.side = Side.REVISION;
-    d.line = line;
-    d.message = message;
-    d.unresolved = true;
-    return d;
-  }
-
-  private CommentInfo addDraft(String changeId, String revId, DraftInput in) throws Exception {
-    return gApi.changes().id(changeId).revision(revId).createDraft(in).get();
-  }
-
-  private Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
-    return gApi.changes()
-        .id(changeId)
-        .comments()
-        .values()
-        .stream()
-        .flatMap(cs -> cs.stream())
-        .collect(toList());
-  }
-
-  private String getLastMessage(String changeId) throws Exception {
-    return Streams.findLast(
-            gApi.changes().id(changeId).get(MESSAGES).messages.stream().map(m -> m.message))
-        .get();
-  }
-
-  private void assertThatUserIsOnlyReviewer(ChangeInfo ci, TestAccount reviewer) {
-    assertThat(ci.reviewers).isNotNull();
-    assertThat(ci.reviewers.keySet()).containsExactly(ReviewerState.REVIEWER);
-    assertThat(ci.reviewers.get(ReviewerState.REVIEWER).iterator().next().email)
-        .isEqualTo(reviewer.email);
-  }
-
-  private void pushWithReviewerInFooter(String nameEmail, TestAccount expectedReviewer)
-      throws Exception {
-    int n = 5;
-    String r = "refs/for/master";
-    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
-    List<RevCommit> commits = createChanges(n, r, ImmutableList.of("Acked-By: " + nameEmail));
-    for (int i = 0; i < n; i++) {
-      RevCommit c = commits.get(i);
-      ChangeData cd = byCommit(c);
-      String name = "reviewers for " + (i + 1);
-      if (expectedReviewer != null) {
-        assertThat(cd.reviewers().all()).named(name).containsExactly(expectedReviewer.getId());
-        gApi.changes().id(cd.getId().get()).reviewer(expectedReviewer.getId().toString()).remove();
-      }
-      assertThat(byCommit(c).reviewers().all()).named(name).isEmpty();
-    }
-
-    List<RevCommit> commits2 = amendChanges(initialHead, commits, r);
-    for (int i = 0; i < n; i++) {
-      RevCommit c = commits2.get(i);
-      ChangeData cd = byCommit(c);
-      String name = "reviewers for " + (i + 1);
-      if (expectedReviewer != null) {
-        assertThat(cd.reviewers().all()).named(name).containsExactly(expectedReviewer.getId());
-      } else {
-        assertThat(byCommit(c).reviewers().all()).named(name).isEmpty();
-      }
-    }
-  }
-
-  private List<RevCommit> createChanges(int n, String refsFor) throws Exception {
-    return createChanges(n, refsFor, ImmutableList.of());
-  }
-
-  private List<RevCommit> createChanges(int n, String refsFor, List<String> footerLines)
-      throws Exception {
-    List<RevCommit> commits = initChanges(n, footerLines);
-    assertPushOk(pushHead(testRepo, refsFor, false), refsFor);
-    return commits;
-  }
-
-  private List<RevCommit> initChanges(int n) throws Exception {
-    return initChanges(n, ImmutableList.of());
-  }
-
-  private List<RevCommit> initChanges(int n, List<String> footerLines) throws Exception {
-    List<RevCommit> commits = new ArrayList<>(n);
-    for (int i = 1; i <= n; i++) {
-      String msg = "Change " + i;
-      if (!footerLines.isEmpty()) {
-        StringBuilder sb = new StringBuilder(msg).append("\n\n");
-        for (String line : footerLines) {
-          sb.append(line).append('\n');
-        }
-        msg = sb.toString();
-      }
-      TestRepository<?>.CommitBuilder cb =
-          testRepo.branch("HEAD").commit().message(msg).insertChangeId();
-      if (!commits.isEmpty()) {
-        cb.parent(commits.get(commits.size() - 1));
-      }
-      RevCommit c = cb.create();
-      testRepo.getRevWalk().parseBody(c);
-      commits.add(c);
-    }
-    return commits;
-  }
-
-  private List<RevCommit> amendChanges(
-      ObjectId initialHead, List<RevCommit> origCommits, String refsFor) throws Exception {
-    testRepo.reset(initialHead);
-    List<RevCommit> newCommits = new ArrayList<>(origCommits.size());
-    for (RevCommit c : origCommits) {
-      String msg = c.getShortMessage() + "v2";
-      if (!c.getShortMessage().equals(c.getFullMessage())) {
-        msg = msg + c.getFullMessage().substring(c.getShortMessage().length());
-      }
-      TestRepository<?>.CommitBuilder cb = testRepo.branch("HEAD").commit().message(msg);
-      if (!newCommits.isEmpty()) {
-        cb.parent(origCommits.get(newCommits.size() - 1));
-      }
-      RevCommit c2 = cb.create();
-      testRepo.getRevWalk().parseBody(c2);
-      newCommits.add(c2);
-    }
-    assertPushOk(pushHead(testRepo, refsFor, false), refsFor);
-    return newCommits;
-  }
-
-  private static Map<Integer, String> getPatchSetRevisions(ChangeData cd) throws Exception {
-    Map<Integer, String> revisions = new HashMap<>();
-    for (PatchSet ps : cd.patchSets()) {
-      revisions.put(ps.getPatchSetId(), ps.getRevision().get());
-    }
-    return revisions;
-  }
-
-  private ChangeData byCommit(ObjectId id) throws Exception {
-    List<ChangeData> cds = queryProvider.get().byCommit(id);
-    assertThat(cds).named("change for " + id.name()).hasSize(1);
-    return cds.get(0);
-  }
-
-  private ChangeData byChangeId(Change.Id id) throws Exception {
-    List<ChangeData> cds = queryProvider.get().byLegacyChangeId(id);
-    assertThat(cds).named("change " + id).hasSize(1);
-    return cds.get(0);
-  }
-
-  private static void pushForReviewOk(TestRepository<?> testRepo) throws GitAPIException {
-    pushForReview(testRepo, RemoteRefUpdate.Status.OK, null);
-  }
-
-  private static void pushForReviewRejected(TestRepository<?> testRepo, String expectedMessage)
-      throws GitAPIException {
-    pushForReview(testRepo, RemoteRefUpdate.Status.REJECTED_OTHER_REASON, expectedMessage);
-  }
-
-  private static void pushForReview(
-      TestRepository<?> testRepo, RemoteRefUpdate.Status expectedStatus, String expectedMessage)
-      throws GitAPIException {
-    String ref = "refs/for/master";
-    PushResult r = pushHead(testRepo, ref);
-    RemoteRefUpdate refUpdate = r.getRemoteUpdate(ref);
-    assertThat(refUpdate.getStatus()).isEqualTo(expectedStatus);
-    if (expectedMessage != null) {
-      assertThat(refUpdate.getMessage()).contains(expectedMessage);
-    }
-  }
-
-  private void grantSkipValidation(Project.NameKey project, String ref, AccountGroup.UUID groupUuid)
-      throws Exception {
-    // See SKIP_VALIDATION implementation in default permission backend.
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    Util.allow(config, Permission.FORGE_AUTHOR, groupUuid, ref);
-    Util.allow(config, Permission.FORGE_COMMITTER, groupUuid, ref);
-    Util.allow(config, Permission.FORGE_SERVER, groupUuid, ref);
-    Util.allow(config, Permission.PUSH_MERGE, groupUuid, "refs/for/" + ref);
-    saveProjectConfig(project, config);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD
deleted file mode 100644
index 43ec5bc..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD
+++ /dev/null
@@ -1,28 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "git",
-    labels = ["git"],
-    deps = [
-        ":push_for_review",
-        ":submodule_util",
-    ],
-)
-
-java_library(
-    name = "push_for_review",
-    testonly = 1,
-    srcs = ["AbstractPushForReview.java"],
-    deps = [
-        "//gerrit-acceptance-tests:lib",
-        "//lib/joda:joda-time",
-    ],
-)
-
-java_library(
-    name = "submodule_util",
-    testonly = 1,
-    srcs = ["AbstractSubmoduleSubscription.java"],
-    deps = ["//gerrit-acceptance-tests:lib"],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
deleted file mode 100644
index 0064570..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.git;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.server.git.ProjectConfig;
-import org.eclipse.jgit.lib.ObjectId;
-import org.junit.Test;
-
-public class ImplicitMergeCheckIT extends AbstractDaemonTest {
-
-  @Test
-  public void implicitMergeViaFastForward() throws Exception {
-    setRejectImplicitMerges();
-
-    pushHead(testRepo, "refs/heads/stable", false);
-    PushOneCommit.Result m = push("refs/heads/master", "0", "file", "0");
-    PushOneCommit.Result c = push("refs/for/stable", "1", "file", "1");
-
-    c.assertMessage(implicitMergeOf(m.getCommit()));
-    c.assertErrorStatus();
-  }
-
-  @Test
-  public void implicitMergeViaRealMerge() throws Exception {
-    setRejectImplicitMerges();
-
-    ObjectId base = repo().exactRef("HEAD").getObjectId();
-    push("refs/heads/stable", "0", "f", "0");
-    testRepo.reset(base);
-    PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
-    PushOneCommit.Result c = push("refs/for/stable", "2", "f", "2");
-
-    c.assertMessage(implicitMergeOf(m.getCommit()));
-    c.assertErrorStatus();
-  }
-
-  @Test
-  public void implicitMergeCheckOff() throws Exception {
-    ObjectId base = repo().exactRef("HEAD").getObjectId();
-    push("refs/heads/stable", "0", "f", "0");
-    testRepo.reset(base);
-    PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
-    PushOneCommit.Result c = push("refs/for/stable", "2", "f", "2");
-
-    assertThat(c.getMessage().toLowerCase()).doesNotContain(implicitMergeOf(m.getCommit()));
-  }
-
-  @Test
-  public void notImplicitMerge_noWarning() throws Exception {
-    setRejectImplicitMerges();
-
-    ObjectId base = repo().exactRef("HEAD").getObjectId();
-    push("refs/heads/stable", "0", "f", "0");
-    testRepo.reset(base);
-    PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
-    PushOneCommit.Result c = push("refs/for/master", "2", "f", "2");
-
-    assertThat(c.getMessage().toLowerCase()).doesNotContain(implicitMergeOf(m.getCommit()));
-  }
-
-  private static String implicitMergeOf(ObjectId commit) {
-    return "implicit merge of " + commit.abbreviate(7).name();
-  }
-
-  private void setRejectImplicitMerges() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getProject().setRejectImplicitMerges(InheritableBoolean.TRUE);
-    saveProjectConfig(project, cfg);
-  }
-
-  private PushOneCommit.Result push(String ref, String subject, String fileName, String content)
-      throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
-    return push.to(ref);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
deleted file mode 100644
index 4dfd7ac..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ /dev/null
@@ -1,690 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.git;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.acceptance.GitUtil.fetch;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Predicates;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.Sandboxed;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.VisibleRefFilter;
-import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHook;
-import com.google.gerrit.server.notedb.ChangeNoteUtil;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.project.Util;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.testutil.NoteDbMode;
-import com.google.gerrit.testutil.TestChanges;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Predicate;
-import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class RefAdvertisementIT extends AbstractDaemonTest {
-  @Inject private VisibleRefFilter.Factory refFilterFactory;
-  @Inject private ChangeNoteUtil noteUtil;
-  @Inject @AnonymousCowardName private String anonymousCowardName;
-  @Inject private AllUsersName allUsersName;
-
-  private AccountGroup.UUID admins;
-
-  private ChangeData c1;
-  private ChangeData c2;
-  private ChangeData c3;
-  private ChangeData c4;
-  private String r1;
-  private String r2;
-  private String r3;
-  private String r4;
-
-  @Before
-  public void setUp() throws Exception {
-    admins = groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID();
-    setUpPermissions();
-    setUpChanges();
-  }
-
-  private void setUpPermissions() throws Exception {
-    // Remove read permissions for all users besides admin. This method is idempotent, so is safe
-    // to call on every test setup.
-    ProjectConfig pc = projectCache.checkedGet(allProjects).getConfig();
-    for (AccessSection sec : pc.getAccessSections()) {
-      sec.removePermission(Permission.READ);
-    }
-    Util.allow(pc, Permission.READ, admins, "refs/*");
-    saveProjectConfig(allProjects, pc);
-
-    // Remove all read permissions on All-Users. This method is idempotent, so is safe to call on
-    // every test setup.
-    pc = projectCache.checkedGet(allUsersName).getConfig();
-    for (AccessSection sec : pc.getAccessSections()) {
-      sec.removePermission(Permission.READ);
-    }
-    saveProjectConfig(allUsersName, pc);
-  }
-
-  private static String changeRefPrefix(Change.Id id) {
-    String ps = new PatchSet.Id(id, 1).toRefName();
-    return ps.substring(0, ps.length() - 1);
-  }
-
-  private void setUpChanges() throws Exception {
-    gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
-
-    // First 2 changes are merged, which means the tags pointing to them are
-    // visible.
-    allow("refs/for/refs/heads/*", Permission.SUBMIT, admins);
-    PushOneCommit.Result mr =
-        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%submit");
-    mr.assertOkStatus();
-    c1 = mr.getChange();
-    r1 = changeRefPrefix(c1.getId());
-    PushOneCommit.Result br =
-        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/branch%submit");
-    br.assertOkStatus();
-    c2 = br.getChange();
-    r2 = changeRefPrefix(c2.getId());
-
-    // Second 2 changes are unmerged.
-    mr = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master");
-    mr.assertOkStatus();
-    c3 = mr.getChange();
-    r3 = changeRefPrefix(c3.getId());
-    br = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/branch");
-    br.assertOkStatus();
-    c4 = br.getChange();
-    r4 = changeRefPrefix(c4.getId());
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      // master-tag -> master
-      RefUpdate mtu = repo.updateRef("refs/tags/master-tag");
-      mtu.setExpectedOldObjectId(ObjectId.zeroId());
-      mtu.setNewObjectId(repo.exactRef("refs/heads/master").getObjectId());
-      assertThat(mtu.update()).isEqualTo(RefUpdate.Result.NEW);
-
-      // branch-tag -> branch
-      RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
-      btu.setExpectedOldObjectId(ObjectId.zeroId());
-      btu.setNewObjectId(repo.exactRef("refs/heads/branch").getObjectId());
-      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
-    }
-  }
-
-  @Test
-  public void uploadPackAllRefsVisibleNoRefsMetaConfig() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.allow(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
-    Util.allow(cfg, Permission.READ, admins, RefNames.REFS_CONFIG);
-    Util.doNotInherit(cfg, Permission.READ, RefNames.REFS_CONFIG);
-    saveProjectConfig(project, cfg);
-
-    setApiUser(user);
-    assertUploadPackRefs(
-        "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r2 + "1",
-        r2 + "meta",
-        r3 + "1",
-        r3 + "meta",
-        r4 + "1",
-        r4 + "meta",
-        "refs/heads/branch",
-        "refs/heads/master",
-        "refs/tags/branch-tag",
-        "refs/tags/master-tag");
-  }
-
-  @Test
-  public void uploadPackAllRefsVisibleWithRefsMetaConfig() throws Exception {
-    allow("refs/*", Permission.READ, REGISTERED_USERS);
-    allow(RefNames.REFS_CONFIG, Permission.READ, REGISTERED_USERS);
-
-    assertUploadPackRefs(
-        "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r2 + "1",
-        r2 + "meta",
-        r3 + "1",
-        r3 + "meta",
-        r4 + "1",
-        r4 + "meta",
-        "refs/heads/branch",
-        "refs/heads/master",
-        RefNames.REFS_CONFIG,
-        "refs/tags/branch-tag",
-        "refs/tags/master-tag");
-  }
-
-  @Test
-  public void uploadPackSubsetOfBranchesVisibleIncludingHead() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    deny("refs/heads/branch", Permission.READ, REGISTERED_USERS);
-
-    setApiUser(user);
-    assertUploadPackRefs(
-        "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r3 + "1",
-        r3 + "meta",
-        "refs/heads/master",
-        "refs/tags/master-tag");
-  }
-
-  @Test
-  public void uploadPackSubsetOfBranchesVisibleNotIncludingHead() throws Exception {
-    deny("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    allow("refs/heads/branch", Permission.READ, REGISTERED_USERS);
-
-    setApiUser(user);
-    assertUploadPackRefs(
-        r2 + "1",
-        r2 + "meta",
-        r4 + "1",
-        r4 + "meta",
-        "refs/heads/branch",
-        "refs/tags/branch-tag",
-        // master branch is not visible but master-tag is reachable from branch
-        // (since PushOneCommit always bases changes on each other).
-        "refs/tags/master-tag");
-  }
-
-  @Test
-  public void uploadPackSubsetOfBranchesVisibleWithEdit() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-
-    Change c = notesFactory.createChecked(db, project, c1.getId()).getChange();
-    String changeId = c.getKey().get();
-
-    // Admin's edit is not visible.
-    setApiUser(admin);
-    gApi.changes().id(changeId).edit().create();
-
-    // User's edit is visible.
-    setApiUser(user);
-    gApi.changes().id(changeId).edit().create();
-
-    assertUploadPackRefs(
-        "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r3 + "1",
-        r3 + "meta",
-        "refs/heads/master",
-        "refs/tags/master-tag",
-        "refs/users/01/1000001/edit-" + c1.getId() + "/1");
-  }
-
-  @Test
-  public void uploadPackSubsetOfBranchesAndEditsVisibleWithViewPrivateChanges() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    allow("refs/*", Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS);
-
-    Change change1 = notesFactory.createChecked(db, project, c1.getId()).getChange();
-    String changeId1 = change1.getKey().get();
-    Change change2 = notesFactory.createChecked(db, project, c2.getId()).getChange();
-    String changeId2 = change2.getKey().get();
-
-    // Admin's edit on change1 is visible.
-    setApiUser(admin);
-    gApi.changes().id(changeId1).edit().create();
-
-    // Admin's edit on change2 is not visible since user cannot see the change.
-    gApi.changes().id(changeId2).edit().create();
-
-    // User's edit is visible.
-    setApiUser(user);
-    gApi.changes().id(changeId1).edit().create();
-
-    assertUploadPackRefs(
-        "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r3 + "1",
-        r3 + "meta",
-        "refs/heads/master",
-        "refs/tags/master-tag",
-        "refs/users/00/1000000/edit-" + c1.getId() + "/1",
-        "refs/users/01/1000001/edit-" + c1.getId() + "/1");
-  }
-
-  @Test
-  public void uploadPackSubsetOfRefsVisibleWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    try {
-      deny("refs/heads/master", Permission.READ, REGISTERED_USERS);
-      allow("refs/heads/branch", Permission.READ, REGISTERED_USERS);
-
-      String changeId = c1.change().getKey().get();
-      setApiUser(admin);
-      gApi.changes().id(changeId).edit().create();
-      setApiUser(user);
-
-      assertUploadPackRefs(
-          // Change 1 is visible due to accessDatabase capability, even though
-          // refs/heads/master is not.
-          r1 + "1",
-          r1 + "meta",
-          r2 + "1",
-          r2 + "meta",
-          r3 + "1",
-          r3 + "meta",
-          r4 + "1",
-          r4 + "meta",
-          "refs/heads/branch",
-          "refs/tags/branch-tag",
-          // See comment in subsetOfBranchesVisibleNotIncludingHead.
-          "refs/tags/master-tag",
-          // All edits are visible due to accessDatabase capability.
-          "refs/users/00/1000000/edit-" + c1.getId() + "/1");
-    } finally {
-      removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    }
-  }
-
-  @Test
-  public void uploadPackNoSearchingChangeCacheImpl() throws Exception {
-    allow("refs/heads/*", Permission.READ, REGISTERED_USERS);
-
-    setApiUser(user);
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertRefs(
-          repo,
-          refFilterFactory.create(projectCache.get(project), repo),
-          // Can't use stored values from the index so DB must be enabled.
-          false,
-          "HEAD",
-          r1 + "1",
-          r1 + "meta",
-          r2 + "1",
-          r2 + "meta",
-          r3 + "1",
-          r3 + "meta",
-          r4 + "1",
-          r4 + "meta",
-          "refs/heads/branch",
-          "refs/heads/master",
-          "refs/tags/branch-tag",
-          "refs/tags/master-tag");
-    }
-  }
-
-  @Test
-  public void uploadPackSequencesWithAccessDatabase() throws Exception {
-    assume().that(notesMigration.readChangeSequence()).isTrue();
-    try (Repository repo = repoManager.openRepository(allProjects)) {
-      setApiUser(user);
-      assertRefs(repo, newFilter(repo, allProjects), true);
-
-      allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-      try {
-        setApiUser(user);
-        assertRefs(repo, newFilter(repo, allProjects), true, "refs/sequences/changes");
-      } finally {
-        removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-      }
-    }
-  }
-
-  @Test
-  public void receivePackListsOpenChangesAsAdditionalHaves() throws Exception {
-    ReceiveCommitsAdvertiseRefsHook.Result r = getReceivePackRefs();
-    assertThat(r.allRefs().keySet())
-        .containsExactly(
-            // meta refs are excluded even when NoteDb is enabled.
-            "HEAD",
-            "refs/heads/branch",
-            "refs/heads/master",
-            "refs/meta/config",
-            "refs/tags/branch-tag",
-            "refs/tags/master-tag");
-    assertThat(r.additionalHaves()).containsExactly(obj(c3, 1), obj(c4, 1));
-  }
-
-  @Test
-  public void receivePackRespectsVisibilityOfOpenChanges() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-    deny("refs/heads/branch", Permission.READ, REGISTERED_USERS);
-    setApiUser(user);
-
-    assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c3, 1));
-  }
-
-  @Test
-  public void receivePackListsOnlyLatestPatchSet() throws Exception {
-    testRepo.reset(obj(c3, 1));
-    PushOneCommit.Result r = amendChange(c3.change().getKey().get());
-    r.assertOkStatus();
-    c3 = r.getChange();
-    assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c3, 2), obj(c4, 1));
-  }
-
-  @Test
-  public void receivePackOmitsMissingObject() throws Exception {
-    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<?> tr = new TestRepository<>(repo);
-      String subject = "Subject for missing commit";
-      Change c = new Change(c3.change());
-      PatchSet.Id psId = new PatchSet.Id(c3.getId(), 2);
-      c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
-
-      if (notesMigration.changePrimaryStorage() == PrimaryStorage.REVIEW_DB) {
-        PatchSet ps = TestChanges.newPatchSet(psId, rev, admin.getId());
-        db.patchSets().insert(Collections.singleton(ps));
-        db.changes().update(Collections.singleton(c));
-      }
-
-      if (notesMigration.commitChangeWrites()) {
-        PersonIdent committer = serverIdent.get();
-        PersonIdent author =
-            noteUtil.newIdent(
-                accountCache.get(admin.getId()).getAccount(),
-                committer.getWhen(),
-                committer,
-                anonymousCowardName);
-        tr.branch(RefNames.changeMetaRef(c3.getId()))
-            .commit()
-            .author(author)
-            .committer(committer)
-            .message(
-                "Update patch set "
-                    + psId.get()
-                    + "\n"
-                    + "\n"
-                    + "Patch-set: "
-                    + psId.get()
-                    + "\n"
-                    + "Commit: "
-                    + rev
-                    + "\n"
-                    + "Subject: "
-                    + subject
-                    + "\n")
-            .create();
-      }
-      indexer.index(db, c.getProject(), c.getId());
-    }
-
-    assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c4, 1));
-  }
-
-  @Test
-  public void advertisedReferencesDontShowUserBranchWithoutRead() throws Exception {
-    TestRepository<?> userTestRepository = cloneProject(allUsersName, user);
-    try (Git git = userTestRepository.git()) {
-      assertThat(getUserRefs(git)).isEmpty();
-    }
-  }
-
-  @Test
-  public void advertisedReferencesOmitUserBranchesOfOtherUsers() throws Exception {
-    allow(allUsersName, RefNames.REFS_USERS + "*", Permission.READ, REGISTERED_USERS);
-    TestRepository<?> userTestRepository = cloneProject(allUsersName, user);
-    try (Git git = userTestRepository.git()) {
-      assertThat(getUserRefs(git))
-          .containsExactly(RefNames.REFS_USERS_SELF, RefNames.refsUsers(user.id));
-    }
-  }
-
-  @Test
-  public void advertisedReferencesIncludeAllUserBranchesWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    try {
-      TestRepository<?> userTestRepository = cloneProject(allUsersName, user);
-      try (Git git = userTestRepository.git()) {
-        assertThat(getUserRefs(git))
-            .containsExactly(
-                RefNames.REFS_USERS_SELF,
-                RefNames.refsUsers(user.id),
-                RefNames.refsUsers(admin.id));
-      }
-    } finally {
-      removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    }
-  }
-
-  @Test
-  public void advertisedReferencesOmitPrivateChangesOfOtherUsers() throws Exception {
-    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
-
-    TestRepository<?> userTestRepository = cloneProject(project, user);
-    try (Git git = userTestRepository.git()) {
-      String change3RefName = c3.currentPatchSet().getRefName();
-      assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
-
-      gApi.changes().id(c3.getId().get()).setPrivate(true, null);
-      assertThat(getRefs(git)).doesNotContain(change3RefName);
-    }
-  }
-
-  @Test
-  public void advertisedReferencesIncludePrivateChangesWhenAllRefsMayBeRead() throws Exception {
-    allow("refs/*", Permission.READ, REGISTERED_USERS);
-
-    TestRepository<?> userTestRepository = cloneProject(project, user);
-    try (Git git = userTestRepository.git()) {
-      String change3RefName = c3.currentPatchSet().getRefName();
-      assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
-
-      gApi.changes().id(c3.getId().get()).setPrivate(true, null);
-      assertThat(getRefs(git)).contains(change3RefName);
-    }
-  }
-
-  @Test
-  @Sandboxed
-  public void advertisedReferencesOmitDraftCommentRefsOfOtherUsers() throws Exception {
-    assume().that(notesMigration.commitChangeWrites()).isTrue();
-
-    allow(project, "refs/*", Permission.READ, REGISTERED_USERS);
-    allow(allUsersName, "refs/*", Permission.READ, REGISTERED_USERS);
-
-    setApiUser(user);
-    DraftInput draftInput = new DraftInput();
-    draftInput.line = 1;
-    draftInput.message = "nit: trailing whitespace";
-    draftInput.path = Patch.COMMIT_MSG;
-    gApi.changes().id(c3.getId().get()).current().createDraft(draftInput);
-    String draftCommentRef = RefNames.refsDraftComments(c3.getId(), user.id);
-
-    // user can see the draft comment ref of the own draft comment
-    assertThat(lsRemote(allUsersName, user)).contains(draftCommentRef);
-
-    // user2 can't see the draft comment ref of user's draft comment
-    assertThat(lsRemote(allUsersName, accountCreator.user2())).doesNotContain(draftCommentRef);
-  }
-
-  @Test
-  @Sandboxed
-  public void advertisedReferencesOmitStarredChangesRefsOfOtherUsers() throws Exception {
-    assume().that(notesMigration.commitChangeWrites()).isTrue();
-
-    allow(project, "refs/*", Permission.READ, REGISTERED_USERS);
-    allow(allUsersName, "refs/*", Permission.READ, REGISTERED_USERS);
-
-    setApiUser(user);
-    gApi.accounts().self().starChange(c3.getId().toString());
-    String starredChangesRef = RefNames.refsStarredChanges(c3.getId(), user.id);
-
-    // user can see the starred changes ref of the own star
-    assertThat(lsRemote(allUsersName, user)).contains(starredChangesRef);
-
-    // user2 can't see the starred changes ref of admin's star
-    assertThat(lsRemote(allUsersName, accountCreator.user2())).doesNotContain(starredChangesRef);
-  }
-
-  @Test
-  public void hideMetadata() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    try {
-      // create change
-      TestRepository<?> allUsersRepo = cloneProject(allUsersName);
-      fetch(allUsersRepo, RefNames.REFS_USERS_SELF + ":userRef");
-      allUsersRepo.reset("userRef");
-      PushOneCommit.Result mr =
-          pushFactory
-              .create(db, admin.getIdent(), allUsersRepo)
-              .to("refs/for/" + RefNames.REFS_USERS_SELF);
-      mr.assertOkStatus();
-
-      List<String> expectedNonMetaRefs =
-          ImmutableList.of(
-              RefNames.REFS_USERS_SELF,
-              RefNames.refsUsers(admin.id),
-              RefNames.refsUsers(user.id),
-              RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS,
-              RefNames.REFS_EXTERNAL_IDS,
-              RefNames.REFS_CONFIG);
-
-      List<String> expectedMetaRefs =
-          new ArrayList<>(ImmutableList.of(mr.getPatchSetId().toRefName()));
-      if (NoteDbMode.get() != NoteDbMode.OFF) {
-        expectedMetaRefs.add(changeRefPrefix(mr.getChange().getId()) + "meta");
-      }
-
-      List<String> expectedAllRefs = new ArrayList<>(expectedNonMetaRefs);
-      expectedAllRefs.addAll(expectedMetaRefs);
-
-      try (Repository repo = repoManager.openRepository(allUsersName)) {
-        Map<String, Ref> all = repo.getAllRefs();
-
-        VisibleRefFilter filter = refFilterFactory.create(projectCache.get(allUsersName), repo);
-        assertThat(filter.filter(all, false).keySet()).containsExactlyElementsIn(expectedAllRefs);
-
-        assertThat(filter.setShowMetadata(false).filter(all, false).keySet())
-            .containsExactlyElementsIn(expectedNonMetaRefs);
-      }
-    } finally {
-      removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    }
-  }
-
-  private List<String> lsRemote(Project.NameKey p, TestAccount a) throws Exception {
-    TestRepository<?> testRepository = cloneProject(p, a);
-    try (Git git = testRepository.git()) {
-      return git.lsRemote().call().stream().map(Ref::getName).collect(toList());
-    }
-  }
-
-  private List<String> getRefs(Git git) throws Exception {
-    return getRefs(git, Predicates.alwaysTrue());
-  }
-
-  private List<String> getUserRefs(Git git) throws Exception {
-    return getRefs(git, RefNames::isRefsUsers);
-  }
-
-  private List<String> getRefs(Git git, Predicate<String> predicate) throws Exception {
-    return git.lsRemote().call().stream().map(Ref::getName).filter(predicate).collect(toList());
-  }
-
-  /**
-   * Assert that refs seen by a non-admin user match expected.
-   *
-   * @param expectedWithMeta expected refs, in order. If NoteDb is disabled by the configuration,
-   *     any NoteDb refs (i.e. ending in "/meta") are removed from the expected list before
-   *     comparing to the actual results.
-   * @throws Exception
-   */
-  private void assertUploadPackRefs(String... expectedWithMeta) throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertRefs(
-          repo, refFilterFactory.create(projectCache.get(project), repo), true, expectedWithMeta);
-    }
-  }
-
-  private void assertRefs(
-      Repository repo, VisibleRefFilter filter, boolean disableDb, String... expectedWithMeta)
-      throws Exception {
-    List<String> expected = new ArrayList<>(expectedWithMeta.length);
-    for (String r : expectedWithMeta) {
-      if (notesMigration.commitChangeWrites() || !r.endsWith(RefNames.META_SUFFIX)) {
-        expected.add(r);
-      }
-    }
-
-    AcceptanceTestRequestScope.Context ctx = null;
-    if (disableDb) {
-      ctx = disableDb();
-    }
-    try {
-      Map<String, Ref> all = repo.getAllRefs();
-      assertThat(filter.filter(all, false).keySet()).containsExactlyElementsIn(expected);
-    } finally {
-      if (disableDb) {
-        enableDb(ctx);
-      }
-    }
-  }
-
-  private ReceiveCommitsAdvertiseRefsHook.Result getReceivePackRefs() throws Exception {
-    ReceiveCommitsAdvertiseRefsHook hook =
-        new ReceiveCommitsAdvertiseRefsHook(queryProvider, project);
-    try (Repository repo = repoManager.openRepository(project)) {
-      return hook.advertiseRefs(repo.getAllRefs());
-    }
-  }
-
-  private VisibleRefFilter newFilter(Repository repo, Project.NameKey project) {
-    return refFilterFactory.create(projectCache.get(project), repo);
-  }
-
-  private static ObjectId obj(ChangeData cd, int psNum) throws Exception {
-    PatchSet.Id psId = new PatchSet.Id(cd.getId(), psNum);
-    PatchSet ps = cd.patchSet(psId);
-    assertWithMessage("%s not found in %s", psId, cd.patchSets()).that(ps).isNotNull();
-    return ObjectId.fromString(ps.getRevision().get());
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
deleted file mode 100644
index 689c5b7..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ /dev/null
@@ -1,545 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.git;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.testutil.ConfigSuite;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.PushResult;
-import org.eclipse.jgit.transport.RefSpec;
-import org.eclipse.jgit.transport.RemoteRefUpdate;
-import org.junit.Test;
-
-@NoHttpd
-public class SubmoduleSubscriptionsIT extends AbstractSubmoduleSubscription {
-  @ConfigSuite.Config
-  public static Config submitWholeTopicEnabled() {
-    return submitWholeTopicEnabledConfig();
-  }
-
-  @Test
-  @GerritConfig(name = "submodule.enableSuperProjectSubscriptions", value = "false")
-  public void testSubscriptionWithoutGlobalServerSetting() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
-  }
-
-  @Test
-  public void subscriptionWithoutSpecificSubscription() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
-  }
-
-  @Test
-  public void subscriptionToEmptyRepo() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    pushChangeTo(subRepo, "master");
-    ObjectId subHEAD = pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isTrue();
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
-  }
-
-  @Test
-  public void subscriptionToExistingRepo() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-
-    pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    ObjectId subHEAD = pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isTrue();
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
-  }
-
-  @Test
-  public void subscriptionWildcardACLForSingleBranch() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    // master is allowed to be subscribed to master branch only:
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", null);
-    // create 'branch':
-    pushChangeTo(superRepo, "branch");
-
-    pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "master");
-
-    ObjectId subHEAD = pushChangeTo(subRepo, "master");
-
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
-    assertThat(hasSubmodule(superRepo, "branch", "subscribed-to-project")).isFalse();
-  }
-
-  @Test
-  public void subscriptionWildcardACLForMissingProject() throws Exception {
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/*", "not-existing-super-project", "refs/heads/*");
-    pushChangeTo(subRepo, "master");
-  }
-
-  @Test
-  public void subscriptionWildcardACLForMissingBranch() throws Exception {
-    createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/*", "super-project", "refs/heads/*");
-    pushChangeTo(subRepo, "foo");
-  }
-
-  @Test
-  public void subscriptionWildcardACLForMissingGitmodules() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/*", "super-project", "refs/heads/*");
-    pushChangeTo(superRepo, "master");
-    pushChangeTo(subRepo, "master");
-  }
-
-  @Test
-  public void subscriptionWildcardACLOneOnOneMapping() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    // any branch is allowed to be subscribed to the same superprojects branch:
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/*", "super-project", "refs/heads/*");
-
-    // create 'branch' in both repos:
-    pushChangeTo(superRepo, "branch");
-    pushChangeTo(subRepo, "branch");
-
-    pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "branch");
-
-    ObjectId subHEAD1 = pushChangeTo(subRepo, "master");
-    ObjectId subHEAD2 = pushChangeTo(subRepo, "branch");
-
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD1);
-    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD2);
-
-    // Now test that cross subscriptions do not work:
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "branch");
-    ObjectId subHEAD3 = pushChangeTo(subRepo, "branch");
-
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD1);
-    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD3);
-  }
-
-  @Test
-  public void subscriptionWildcardACLForManyBranches() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-
-    // Any branch is allowed to be subscribed to any superproject branch:
-    allowSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/*", "super-project", null, false);
-    pushChangeTo(superRepo, "branch");
-    pushChangeTo(subRepo, "another-branch");
-    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "another-branch");
-    ObjectId subHEAD = pushChangeTo(subRepo, "another-branch");
-    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD);
-  }
-
-  @Test
-  public void subscriptionWildcardACLOneToManyBranches() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-
-    // Any branch is allowed to be subscribed to any superproject branch:
-    allowSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/*", false);
-    pushChangeTo(superRepo, "branch");
-    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "master");
-    ObjectId subHEAD = pushChangeTo(subRepo, "master");
-    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD);
-
-    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "branch");
-    pushChangeTo(subRepo, "branch");
-
-    // no change expected, as only master is subscribed:
-    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD);
-  }
-
-  @Test
-  @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "false")
-  public void testSubmoduleShortCommitMessage() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-
-    pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-
-    // The first update doesn't include any commit messages
-    ObjectId subRepoId = pushChangeTo(subRepo, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
-    expectToHaveCommitMessage(superRepo, "master", "Update git submodules\n\n");
-
-    // Any following update also has a short message
-    subRepoId = pushChangeTo(subRepo, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
-    expectToHaveCommitMessage(superRepo, "master", "Update git submodules\n\n");
-  }
-
-  @Test
-  @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "SUBJECT_ONLY")
-  public void testSubmoduleSubjectCommitMessage() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-
-    pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    ObjectId subHEAD = pushChangeTo(subRepo, "master");
-
-    // The first update doesn't include the rev log
-    RevWalk rw = subRepo.getRevWalk();
-    expectToHaveCommitMessage(
-        superRepo,
-        "master",
-        "Update git submodules\n\n"
-            + "* Update "
-            + name("subscribed-to-project")
-            + " from branch 'master'\n  to "
-            + subHEAD.getName());
-
-    // The next commit should generate only its commit message,
-    // omitting previous commit logs
-    subHEAD = pushChangeTo(subRepo, "master");
-    RevCommit subCommitMsg = rw.parseCommit(subHEAD);
-    expectToHaveCommitMessage(
-        superRepo,
-        "master",
-        "Update git submodules\n\n"
-            + "* Update "
-            + name("subscribed-to-project")
-            + " from branch 'master'\n  to "
-            + subHEAD.getName()
-            + "\n  - "
-            + subCommitMsg.getShortMessage());
-  }
-
-  @Test
-  public void submoduleCommitMessage() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-
-    pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    ObjectId subHEAD = pushChangeTo(subRepo, "master");
-
-    // The first update doesn't include the rev log
-    RevWalk rw = subRepo.getRevWalk();
-    expectToHaveCommitMessage(
-        superRepo,
-        "master",
-        "Update git submodules\n\n"
-            + "* Update "
-            + name("subscribed-to-project")
-            + " from branch 'master'\n  to "
-            + subHEAD.getName());
-
-    // The next commit should generate only its commit message,
-    // omitting previous commit logs
-    subHEAD = pushChangeTo(subRepo, "master");
-    RevCommit subCommitMsg = rw.parseCommit(subHEAD);
-    expectToHaveCommitMessage(
-        superRepo,
-        "master",
-        "Update git submodules\n\n"
-            + "* Update "
-            + name("subscribed-to-project")
-            + " from branch 'master'\n  to "
-            + subHEAD.getName()
-            + "\n  - "
-            + subCommitMsg.getFullMessage().replace("\n", "\n    "));
-  }
-
-  @Test
-  public void subscriptionUnsubscribe() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-
-    pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-
-    pushChangeTo(subRepo, "master");
-    ObjectId subHEADbeforeUnsubscribing = pushChangeTo(subRepo, "master");
-
-    deleteAllSubscriptions(superRepo, "master");
-    expectToHaveSubmoduleState(
-        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
-
-    pushChangeTo(superRepo, "refs/heads/master", "commit after unsubscribe", "");
-    pushChangeTo(subRepo, "refs/heads/master", "commit after unsubscribe", "");
-    expectToHaveSubmoduleState(
-        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
-  }
-
-  @Test
-  public void subscriptionUnsubscribeByDeletingGitModules() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-
-    pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-
-    pushChangeTo(subRepo, "master");
-    ObjectId subHEADbeforeUnsubscribing = pushChangeTo(subRepo, "master");
-
-    deleteGitModulesFile(superRepo, "master");
-    expectToHaveSubmoduleState(
-        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
-
-    pushChangeTo(superRepo, "refs/heads/master", "commit after unsubscribe", "");
-    pushChangeTo(subRepo, "refs/heads/master", "commit after unsubscribe", "");
-    expectToHaveSubmoduleState(
-        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
-  }
-
-  @Test
-  public void subscriptionToDifferentBranches() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/foo", "super-project", "refs/heads/master");
-
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "foo");
-    ObjectId subFoo = pushChangeTo(subRepo, "foo");
-    pushChangeTo(subRepo, "master");
-
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subFoo);
-  }
-
-  @Test
-  public void branchCircularSubscription() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "super-project", "refs/heads/master", "subscribed-to-project", "refs/heads/master");
-
-    pushChangeTo(subRepo, "master");
-    pushChangeTo(superRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    createSubmoduleSubscription(subRepo, "master", "super-project", "master");
-
-    pushChangeTo(subRepo, "master");
-    pushChangeTo(superRepo, "master");
-
-    assertThat(hasSubmodule(subRepo, "master", "super-project")).isFalse();
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
-  }
-
-  @Test
-  public void projectCircularSubscription() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "super-project", "refs/heads/dev", "subscribed-to-project", "refs/heads/dev");
-
-    pushChangeTo(subRepo, "master");
-    pushChangeTo(superRepo, "master");
-    pushChangeTo(subRepo, "dev");
-    pushChangeTo(superRepo, "dev");
-
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    createSubmoduleSubscription(subRepo, "dev", "super-project", "dev");
-
-    ObjectId subMasterHead = pushChangeTo(subRepo, "master");
-    ObjectId superDevHead = pushChangeTo(superRepo, "dev");
-
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isTrue();
-    assertThat(hasSubmodule(subRepo, "dev", "super-project")).isTrue();
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subMasterHead);
-    expectToHaveSubmoduleState(subRepo, "dev", "super-project", superDevHead);
-  }
-
-  @Test
-  public void subscriptionFailOnMissingACL() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-
-    pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
-  }
-
-  @Test
-  public void subscriptionFailOnWrongProjectACL() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "wrong-super-project", "refs/heads/master");
-
-    pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
-  }
-
-  @Test
-  public void subscriptionFailOnWrongBranchACL() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/wrong-branch");
-
-    pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
-  }
-
-  @Test
-  public void subscriptionInheritACL() throws Exception {
-    createProjectWithPush("config-repo");
-    createProjectWithPush("config-repo2", new Project.NameKey(name("config-repo")));
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo =
-        createProjectWithPush("subscribed-to-project", new Project.NameKey(name("config-repo2")));
-    allowMatchingSubmoduleSubscription(
-        "config-repo", "refs/heads/*", "super-project", "refs/heads/*");
-
-    pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    ObjectId subHEAD = pushChangeTo(subRepo, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
-  }
-
-  @Test
-  public void allowedButNotSubscribed() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-
-    pushChangeTo(subRepo, "master");
-    subRepo
-        .branch("HEAD")
-        .commit()
-        .insertChangeId()
-        .message("some change")
-        .add("b.txt", "b contents for testing")
-        .create();
-    String refspec = "HEAD:refs/heads/master";
-    PushResult r =
-        Iterables.getOnlyElement(
-            subRepo.git().push().setRemote("origin").setRefSpecs(new RefSpec(refspec)).call());
-    assertThat(r.getMessages()).doesNotContain("error");
-    assertThat(r.getRemoteUpdate("refs/heads/master").getStatus())
-        .isEqualTo(RemoteRefUpdate.Status.OK);
-
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
-  }
-
-  @Test
-  public void subscriptionDeepRelative() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("nested/subscribed-to-project");
-    // master is allowed to be subscribed to any superprojects branch:
-    allowMatchingSubmoduleSubscription(
-        "nested/subscribed-to-project", "refs/heads/master", "super-project", null);
-
-    pushChangeTo(subRepo, "master");
-    createRelativeSubmoduleSubscription(
-        superRepo, "master", "../", "nested/subscribed-to-project", "master");
-
-    ObjectId subHEAD = pushChangeTo(subRepo, "master");
-
-    expectToHaveSubmoduleState(superRepo, "master", "nested/subscribed-to-project", subHEAD);
-  }
-
-  @Test
-  @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "SUBJECT_ONLY")
-  @GerritConfig(name = "submodule.maxCommitMessages", value = "1")
-  public void submoduleSubjectCommitMessageCountLimit() throws Exception {
-    testSubmoduleSubjectCommitMessageAndExpectTruncation();
-  }
-
-  @Test
-  @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "SUBJECT_ONLY")
-  @GerritConfig(name = "submodule.maxCombinedCommitMessageSize", value = "220")
-  public void submoduleSubjectCommitMessageSizeLimit() throws Exception {
-    assume().that(isSubmitWholeTopicEnabled()).isFalse();
-    testSubmoduleSubjectCommitMessageAndExpectTruncation();
-  }
-
-  private void testSubmoduleSubjectCommitMessageAndExpectTruncation() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-
-    pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    // The first update doesn't include the rev log, so we ignore it
-    pushChangeTo(subRepo, "master");
-
-    // Next, we push two commits at once. Since maxCommitMessages=1, we expect to see only the first
-    // message plus ellipsis to mark truncation.
-    ObjectId subHEAD = pushChangesTo(subRepo, "master", 2);
-    RevCommit subCommitMsg = subRepo.getRevWalk().parseCommit(subHEAD);
-    expectToHaveCommitMessage(
-        superRepo,
-        "master",
-        String.format(
-            "Update git submodules\n\n* Update %s from branch 'master'\n  to %s\n  - %s\n\n[...]",
-            name("subscribed-to-project"), subHEAD.getName(), subCommitMsg.getShortMessage()));
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
deleted file mode 100644
index 8e3aeaf..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ /dev/null
@@ -1,813 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.git;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.acceptance.GitUtil.getChangeId;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.Submit.TestSubmitInput;
-import com.google.gerrit.testutil.ConfigSuite;
-import java.util.ArrayDeque;
-import java.util.Map;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.RefSpec;
-import org.junit.Test;
-
-@NoHttpd
-public class SubmoduleSubscriptionsWholeTopicMergeIT extends AbstractSubmoduleSubscription {
-
-  @ConfigSuite.Default
-  public static Config mergeIfNecessary() {
-    return submitByMergeIfNecessary();
-  }
-
-  @ConfigSuite.Config
-  public static Config mergeAlways() {
-    return submitByMergeAlways();
-  }
-
-  @ConfigSuite.Config
-  public static Config cherryPick() {
-    return submitByCherryPickConfig();
-  }
-
-  @ConfigSuite.Config
-  public static Config rebaseAlways() {
-    return submitByRebaseAlwaysConfig();
-  }
-
-  @ConfigSuite.Config
-  public static Config rebaseIfNecessary() {
-    return submitByRebaseIfNecessaryConfig();
-  }
-
-  @Test
-  public void subscriptionUpdateOfManyChanges() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-
-    ObjectId subHEAD =
-        subRepo
-            .branch("HEAD")
-            .commit()
-            .insertChangeId()
-            .message("some change")
-            .add("a.txt", "a contents ")
-            .create();
-    subRepo
-        .git()
-        .push()
-        .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
-        .call();
-
-    RevCommit c = subRepo.getRevWalk().parseCommit(subHEAD);
-
-    RevCommit c1 =
-        subRepo
-            .branch("HEAD")
-            .commit()
-            .insertChangeId()
-            .message("first change")
-            .add("asdf", "asdf\n")
-            .create();
-    subRepo
-        .git()
-        .push()
-        .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
-        .call();
-
-    subRepo.reset(c.getId());
-    RevCommit c2 =
-        subRepo
-            .branch("HEAD")
-            .commit()
-            .insertChangeId()
-            .message("qwerty")
-            .add("qwerty", "qwerty")
-            .create();
-
-    RevCommit c3 =
-        subRepo
-            .branch("HEAD")
-            .commit()
-            .insertChangeId()
-            .message("qwerty followup")
-            .add("qwerty", "qwerty\nqwerty\n")
-            .create();
-    subRepo
-        .git()
-        .push()
-        .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
-        .call();
-
-    String id1 = getChangeId(subRepo, c1).get();
-    String id2 = getChangeId(subRepo, c2).get();
-    String id3 = getChangeId(subRepo, c3).get();
-    gApi.changes().id(id1).current().review(ReviewInput.approve());
-    gApi.changes().id(id2).current().review(ReviewInput.approve());
-    gApi.changes().id(id3).current().review(ReviewInput.approve());
-
-    Map<Branch.NameKey, ObjectId> preview = fetchFromSubmitPreview(id1);
-    gApi.changes().id(id1).current().submit();
-    ObjectId subRepoId =
-        subRepo
-            .git()
-            .fetch()
-            .setRemote("origin")
-            .call()
-            .getAdvertisedRef("refs/heads/master")
-            .getObjectId();
-
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
-
-    // As the submodules have changed commits, the superproject tree will be
-    // different, so we cannot directly compare the trees here, so make
-    // assumptions only about the changed branches:
-    Project.NameKey p1 = new Project.NameKey(name("super-project"));
-    Project.NameKey p2 = new Project.NameKey(name("subscribed-to-project"));
-    assertThat(preview).containsKey(new Branch.NameKey(p1, "refs/heads/master"));
-    assertThat(preview).containsKey(new Branch.NameKey(p2, "refs/heads/master"));
-
-    if ((getSubmitType() == SubmitType.CHERRY_PICK)
-        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
-      // each change is updated and the respective target branch is updated:
-      assertThat(preview).hasSize(5);
-    } else if ((getSubmitType() == SubmitType.REBASE_IF_NECESSARY)) {
-      // Either the first is used first as is, then the second and third need
-      // rebasing, or those two stay as is and the first is rebased.
-      // add in 2 master branches, expect 3 or 4:
-      assertThat(preview.size()).isAnyOf(3, 4);
-    } else {
-      assertThat(preview).hasSize(2);
-    }
-  }
-
-  @Test
-  public void subscriptionUpdateIncludingChangeInSuperproject() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-
-    ObjectId subHEAD =
-        subRepo
-            .branch("HEAD")
-            .commit()
-            .insertChangeId()
-            .message("some change")
-            .add("a.txt", "a contents ")
-            .create();
-    subRepo
-        .git()
-        .push()
-        .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
-        .call();
-
-    RevCommit c = subRepo.getRevWalk().parseCommit(subHEAD);
-
-    RevCommit c1 =
-        subRepo
-            .branch("HEAD")
-            .commit()
-            .insertChangeId()
-            .message("first change")
-            .add("asdf", "asdf\n")
-            .create();
-    subRepo
-        .git()
-        .push()
-        .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
-        .call();
-
-    subRepo.reset(c.getId());
-    RevCommit c2 =
-        subRepo
-            .branch("HEAD")
-            .commit()
-            .insertChangeId()
-            .message("qwerty")
-            .add("qwerty", "qwerty")
-            .create();
-
-    RevCommit c3 =
-        subRepo
-            .branch("HEAD")
-            .commit()
-            .insertChangeId()
-            .message("qwerty followup")
-            .add("qwerty", "qwerty\nqwerty\n")
-            .create();
-    subRepo
-        .git()
-        .push()
-        .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
-        .call();
-
-    RevCommit c4 =
-        superRepo
-            .branch("HEAD")
-            .commit()
-            .insertChangeId()
-            .message("new change on superproject")
-            .add("foo", "bar")
-            .create();
-    superRepo
-        .git()
-        .push()
-        .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
-        .call();
-
-    String id1 = getChangeId(subRepo, c1).get();
-    String id2 = getChangeId(subRepo, c2).get();
-    String id3 = getChangeId(subRepo, c3).get();
-    String id4 = getChangeId(superRepo, c4).get();
-    gApi.changes().id(id1).current().review(ReviewInput.approve());
-    gApi.changes().id(id2).current().review(ReviewInput.approve());
-    gApi.changes().id(id3).current().review(ReviewInput.approve());
-    gApi.changes().id(id4).current().review(ReviewInput.approve());
-
-    gApi.changes().id(id1).current().submit();
-    ObjectId subRepoId =
-        subRepo
-            .git()
-            .fetch()
-            .setRemote("origin")
-            .call()
-            .getAdvertisedRef("refs/heads/master")
-            .getObjectId();
-
-    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
-  }
-
-  @Test
-  public void updateManySubmodules() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> sub1 = createProjectWithPush("sub1");
-    TestRepository<?> sub2 = createProjectWithPush("sub2");
-    TestRepository<?> sub3 = createProjectWithPush("sub3");
-
-    allowMatchingSubmoduleSubscription(
-        "sub1", "refs/heads/master", "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "sub2", "refs/heads/master", "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "sub3", "refs/heads/master", "super-project", "refs/heads/master");
-
-    Config config = new Config();
-    prepareSubmoduleConfigEntry(config, "sub1", "master");
-    prepareSubmoduleConfigEntry(config, "sub2", "master");
-    prepareSubmoduleConfigEntry(config, "sub3", "master");
-    pushSubmoduleConfig(superRepo, "master", config);
-
-    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
-
-    ObjectId sub1Id = pushChangeTo(sub1, "refs/for/master", "some message", "same-topic");
-    ObjectId sub2Id = pushChangeTo(sub2, "refs/for/master", "some message", "same-topic");
-    ObjectId sub3Id = pushChangeTo(sub3, "refs/for/master", "some message", "same-topic");
-
-    approve(getChangeId(sub1, sub1Id).get());
-    approve(getChangeId(sub2, sub2Id).get());
-    approve(getChangeId(sub3, sub3Id).get());
-
-    gApi.changes().id(getChangeId(sub1, sub1Id).get()).current().submit();
-
-    expectToHaveSubmoduleState(superRepo, "master", "sub1", sub1, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "sub2", sub2, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "sub3", sub3, "master");
-
-    superRepo
-        .git()
-        .fetch()
-        .setRemote("origin")
-        .call()
-        .getAdvertisedRef("refs/heads/master")
-        .getObjectId();
-
-    assertWithMessage("submodule subscription update should have made one commit")
-        .that(superRepo.getRepository().resolve("origin/master^"))
-        .isEqualTo(superPreviousId);
-  }
-
-  @Test
-  public void doNotUseFastForward() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project", false);
-    TestRepository<?> sub = createProjectWithPush("sub", false);
-
-    allowMatchingSubmoduleSubscription(
-        "sub", "refs/heads/master", "super-project", "refs/heads/master");
-
-    createSubmoduleSubscription(superRepo, "master", "sub", "master");
-
-    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
-
-    ObjectId superId = pushChangeTo(superRepo, "refs/for/master", "some message", "same-topic");
-
-    String subChangeId = getChangeId(sub, subId).get();
-    approve(subChangeId);
-    approve(getChangeId(superRepo, superId).get());
-
-    gApi.changes().id(subChangeId).current().submit();
-
-    expectToHaveSubmoduleState(superRepo, "master", "sub", sub, "master");
-    RevCommit superHead = getRemoteHead(name("super-project"), "master");
-    assertThat(superHead.getShortMessage()).contains("some message");
-    assertThat(superHead.getId()).isNotEqualTo(superId);
-  }
-
-  @Test
-  public void useFastForwardWhenNoSubmodule() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project", false);
-    TestRepository<?> sub = createProjectWithPush("sub", false);
-
-    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
-
-    ObjectId superId = pushChangeTo(superRepo, "refs/for/master", "some message", "same-topic");
-
-    String subChangeId = getChangeId(sub, subId).get();
-    approve(subChangeId);
-    approve(getChangeId(superRepo, superId).get());
-
-    gApi.changes().id(subChangeId).current().submit();
-
-    RevCommit superHead = getRemoteHead(name("super-project"), "master");
-    assertThat(superHead.getShortMessage()).isEqualTo("some message");
-    assertThat(superHead.getId()).isEqualTo(superId);
-  }
-
-  @Test
-  public void sameProjectSameBranchDifferentPaths() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> sub = createProjectWithPush("sub");
-
-    allowMatchingSubmoduleSubscription(
-        "sub", "refs/heads/master", "super-project", "refs/heads/master");
-
-    Config config = new Config();
-    prepareSubmoduleConfigEntry(config, "sub", "master");
-    prepareSubmoduleConfigEntry(config, "sub", "sub-copy", "master");
-    pushSubmoduleConfig(superRepo, "master", config);
-
-    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
-
-    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "");
-
-    approve(getChangeId(sub, subId).get());
-
-    gApi.changes().id(getChangeId(sub, subId).get()).current().submit();
-
-    expectToHaveSubmoduleState(superRepo, "master", "sub", sub, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "sub-copy", sub, "master");
-
-    superRepo
-        .git()
-        .fetch()
-        .setRemote("origin")
-        .call()
-        .getAdvertisedRef("refs/heads/master")
-        .getObjectId();
-
-    assertWithMessage("submodule subscription update should have made one commit")
-        .that(superRepo.getRepository().resolve("origin/master^"))
-        .isEqualTo(superPreviousId);
-  }
-
-  @Test
-  public void sameProjectDifferentBranchDifferentPaths() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> sub = createProjectWithPush("sub");
-
-    allowMatchingSubmoduleSubscription(
-        "sub", "refs/heads/master", "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "sub", "refs/heads/dev", "super-project", "refs/heads/master");
-
-    ObjectId devHead = pushChangeTo(sub, "dev");
-    Config config = new Config();
-    prepareSubmoduleConfigEntry(config, "sub", "sub-master", "master");
-    prepareSubmoduleConfigEntry(config, "sub", "sub-dev", "dev");
-    pushSubmoduleConfig(superRepo, "master", config);
-
-    ObjectId subMasterId =
-        pushChangeTo(sub, "refs/for/master", "some message", "b.txt", "content b", "same-topic");
-
-    sub.reset(devHead);
-    ObjectId subDevId =
-        pushChangeTo(
-            sub, "refs/for/dev", "some message in dev", "b.txt", "content b", "same-topic");
-
-    approve(getChangeId(sub, subMasterId).get());
-    approve(getChangeId(sub, subDevId).get());
-
-    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
-
-    gApi.changes().id(getChangeId(sub, subMasterId).get()).current().submit();
-
-    expectToHaveSubmoduleState(superRepo, "master", "sub-master", sub, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "sub-dev", sub, "dev");
-
-    superRepo
-        .git()
-        .fetch()
-        .setRemote("origin")
-        .call()
-        .getAdvertisedRef("refs/heads/master")
-        .getObjectId();
-
-    assertWithMessage("submodule subscription update should have made one commit")
-        .that(superRepo.getRepository().resolve("origin/master^"))
-        .isEqualTo(superPreviousId);
-  }
-
-  @Test
-  public void nonSubmoduleInSameTopic() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> sub = createProjectWithPush("sub");
-    TestRepository<?> standAlone = createProjectWithPush("standalone");
-
-    allowMatchingSubmoduleSubscription(
-        "sub", "refs/heads/master", "super-project", "refs/heads/master");
-
-    createSubmoduleSubscription(superRepo, "master", "sub", "master");
-
-    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
-
-    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
-    ObjectId standAloneId =
-        pushChangeTo(standAlone, "refs/for/master", "some message", "same-topic");
-
-    String subChangeId = getChangeId(sub, subId).get();
-    String standAloneChangeId = getChangeId(standAlone, standAloneId).get();
-    approve(subChangeId);
-    approve(standAloneChangeId);
-
-    gApi.changes().id(subChangeId).current().submit();
-
-    expectToHaveSubmoduleState(superRepo, "master", "sub", sub, "master");
-
-    ChangeStatus status = gApi.changes().id(standAloneChangeId).info().status;
-    assertThat(status).isEqualTo(ChangeStatus.MERGED);
-
-    superRepo
-        .git()
-        .fetch()
-        .setRemote("origin")
-        .call()
-        .getAdvertisedRef("refs/heads/master")
-        .getObjectId();
-
-    assertWithMessage("submodule subscription update should have made one commit")
-        .that(superRepo.getRepository().resolve("origin/master^"))
-        .isEqualTo(superPreviousId);
-  }
-
-  @Test
-  public void recursiveSubmodules() throws Exception {
-    TestRepository<?> topRepo = createProjectWithPush("top-project");
-    TestRepository<?> midRepo = createProjectWithPush("mid-project");
-    TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
-
-    allowMatchingSubmoduleSubscription(
-        "mid-project", "refs/heads/master", "top-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "bottom-project", "refs/heads/master", "mid-project", "refs/heads/master");
-
-    createSubmoduleSubscription(topRepo, "master", "mid-project", "master");
-    createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
-
-    ObjectId bottomHead = pushChangeTo(bottomRepo, "refs/for/master", "some message", "same-topic");
-    ObjectId topHead = pushChangeTo(topRepo, "refs/for/master", "some message", "same-topic");
-
-    String id1 = getChangeId(bottomRepo, bottomHead).get();
-    String id2 = getChangeId(topRepo, topHead).get();
-
-    gApi.changes().id(id1).current().review(ReviewInput.approve());
-    gApi.changes().id(id2).current().review(ReviewInput.approve());
-
-    gApi.changes().id(id1).current().submit();
-
-    expectToHaveSubmoduleState(midRepo, "master", "bottom-project", bottomRepo, "master");
-    expectToHaveSubmoduleState(topRepo, "master", "mid-project", midRepo, "master");
-  }
-
-  @Test
-  public void triangleSubmodules() throws Exception {
-    TestRepository<?> topRepo = createProjectWithPush("top-project");
-    TestRepository<?> midRepo = createProjectWithPush("mid-project");
-    TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
-
-    allowMatchingSubmoduleSubscription(
-        "mid-project", "refs/heads/master", "top-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "bottom-project", "refs/heads/master", "mid-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "bottom-project", "refs/heads/master", "top-project", "refs/heads/master");
-
-    createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
-    Config config = new Config();
-    prepareSubmoduleConfigEntry(config, "bottom-project", "master");
-    prepareSubmoduleConfigEntry(config, "mid-project", "master");
-    pushSubmoduleConfig(topRepo, "master", config);
-
-    ObjectId bottomHead = pushChangeTo(bottomRepo, "refs/for/master", "some message", "same-topic");
-    ObjectId topHead = pushChangeTo(topRepo, "refs/for/master", "some message", "same-topic");
-
-    String id1 = getChangeId(bottomRepo, bottomHead).get();
-    String id2 = getChangeId(topRepo, topHead).get();
-
-    gApi.changes().id(id1).current().review(ReviewInput.approve());
-    gApi.changes().id(id2).current().review(ReviewInput.approve());
-
-    gApi.changes().id(id1).current().submit();
-
-    expectToHaveSubmoduleState(midRepo, "master", "bottom-project", bottomRepo, "master");
-    expectToHaveSubmoduleState(topRepo, "master", "mid-project", midRepo, "master");
-    expectToHaveSubmoduleState(topRepo, "master", "bottom-project", bottomRepo, "master");
-  }
-
-  private String prepareBranchCircularSubscription() throws Exception {
-    TestRepository<?> topRepo = createProjectWithPush("top-project");
-    TestRepository<?> midRepo = createProjectWithPush("mid-project");
-    TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
-
-    createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
-    createSubmoduleSubscription(topRepo, "master", "mid-project", "master");
-    createSubmoduleSubscription(bottomRepo, "master", "top-project", "master");
-
-    allowMatchingSubmoduleSubscription(
-        "bottom-project", "refs/heads/master", "mid-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "mid-project", "refs/heads/master", "top-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "top-project", "refs/heads/master", "bottom-project", "refs/heads/master");
-
-    ObjectId bottomMasterHead = pushChangeTo(bottomRepo, "refs/for/master", "some message", "");
-    String changeId = getChangeId(bottomRepo, bottomMasterHead).get();
-
-    approve(changeId);
-    exception.expectMessage("Branch level circular subscriptions detected");
-    exception.expectMessage("top-project,refs/heads/master");
-    exception.expectMessage("mid-project,refs/heads/master");
-    exception.expectMessage("bottom-project,refs/heads/master");
-    return changeId;
-  }
-
-  @Test
-  public void branchCircularSubscription() throws Exception {
-    String changeId = prepareBranchCircularSubscription();
-    gApi.changes().id(changeId).current().submit();
-  }
-
-  @Test
-  public void branchCircularSubscriptionPreview() throws Exception {
-    String changeId = prepareBranchCircularSubscription();
-    gApi.changes().id(changeId).current().submitPreview();
-  }
-
-  @Test
-  public void projectCircularSubscriptionWholeTopic() throws Exception {
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-
-    allowMatchingSubmoduleSubscription(
-        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "super-project", "refs/heads/dev", "subscribed-to-project", "refs/heads/dev");
-
-    pushChangeTo(subRepo, "dev");
-    pushChangeTo(superRepo, "dev");
-
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    createSubmoduleSubscription(subRepo, "dev", "super-project", "dev");
-
-    ObjectId subMasterHead =
-        pushChangeTo(
-            subRepo, "refs/for/master", "b.txt", "content b", "some message", "same-topic");
-    ObjectId superDevHead = pushChangeTo(superRepo, "refs/for/dev", "some message", "same-topic");
-
-    approve(getChangeId(subRepo, subMasterHead).get());
-    approve(getChangeId(superRepo, superDevHead).get());
-
-    exception.expectMessage("Project level circular subscriptions detected");
-    exception.expectMessage("subscribed-to-project");
-    exception.expectMessage("super-project");
-    gApi.changes().id(getChangeId(subRepo, subMasterHead).get()).current().submit();
-  }
-
-  @Test
-  public void projectNoSubscriptionWholeTopic() throws Exception {
-    TestRepository<?> repoA = createProjectWithPush("project-a");
-    TestRepository<?> repoB = createProjectWithPush("project-b");
-    // bootstrap the dev branch
-    ObjectId a0 = pushChangeTo(repoA, "dev");
-
-    // bootstrap the dev branch
-    ObjectId b0 = pushChangeTo(repoB, "dev");
-
-    // create a change for master branch in repo a
-    ObjectId aHead =
-        pushChangeTo(
-            repoA,
-            "refs/for/master",
-            "master.txt",
-            "content master A",
-            "some message in a master.txt",
-            "same-topic");
-
-    // create a change for master branch in repo b
-    ObjectId bHead =
-        pushChangeTo(
-            repoB,
-            "refs/for/master",
-            "master.txt",
-            "content master B",
-            "some message in b master.txt",
-            "same-topic");
-
-    // create a change for dev branch in repo a
-    repoA.reset(a0);
-    ObjectId aDevHead =
-        pushChangeTo(
-            repoA,
-            "refs/for/dev",
-            "dev.txt",
-            "content dev A",
-            "some message in a dev.txt",
-            "same-topic");
-
-    // create a change for dev branch in repo b
-    repoB.reset(b0);
-    ObjectId bDevHead =
-        pushChangeTo(
-            repoB,
-            "refs/for/dev",
-            "dev.txt",
-            "content dev B",
-            "some message in b dev.txt",
-            "same-topic");
-
-    approve(getChangeId(repoA, aHead).get());
-    approve(getChangeId(repoB, bHead).get());
-    approve(getChangeId(repoA, aDevHead).get());
-    approve(getChangeId(repoB, bDevHead).get());
-
-    gApi.changes().id(getChangeId(repoA, aDevHead).get()).current().submit();
-    assertThat(getRemoteHead(name("project-a"), "refs/heads/master").getShortMessage())
-        .contains("some message in a master.txt");
-    assertThat(getRemoteHead(name("project-a"), "refs/heads/dev").getShortMessage())
-        .contains("some message in a dev.txt");
-    assertThat(getRemoteHead(name("project-b"), "refs/heads/master").getShortMessage())
-        .contains("some message in b master.txt");
-    assertThat(getRemoteHead(name("project-b"), "refs/heads/dev").getShortMessage())
-        .contains("some message in b dev.txt");
-  }
-
-  @Test
-  public void twoProjectsMultipleBranchesWholeTopic() throws Exception {
-    TestRepository<?> repoA = createProjectWithPush("project-a");
-    TestRepository<?> repoB = createProjectWithPush("project-b");
-    // bootstrap the dev branch
-    pushChangeTo(repoA, "dev");
-
-    // bootstrap the dev branch
-    ObjectId b0 = pushChangeTo(repoB, "dev");
-
-    allowMatchingSubmoduleSubscription(
-        "project-b", "refs/heads/master", "project-a", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "project-b", "refs/heads/dev", "project-a", "refs/heads/dev");
-
-    createSubmoduleSubscription(repoA, "master", "project-b", "master");
-    createSubmoduleSubscription(repoA, "dev", "project-b", "dev");
-
-    // create a change for master branch in repo b
-    ObjectId bHead =
-        pushChangeTo(
-            repoB,
-            "refs/for/master",
-            "master.txt",
-            "content master B",
-            "some message in b master.txt",
-            "same-topic");
-
-    // create a change for dev branch in repo b
-    repoB.reset(b0);
-    ObjectId bDevHead =
-        pushChangeTo(
-            repoB,
-            "refs/for/dev",
-            "dev.txt",
-            "content dev B",
-            "some message in b dev.txt",
-            "same-topic");
-
-    approve(getChangeId(repoB, bHead).get());
-    approve(getChangeId(repoB, bDevHead).get());
-    gApi.changes().id(getChangeId(repoB, bHead).get()).current().submit();
-
-    expectToHaveSubmoduleState(repoA, "master", "project-b", repoB, "master");
-    expectToHaveSubmoduleState(repoA, "dev", "project-b", repoB, "dev");
-  }
-
-  @Test
-  public void retrySubmitAfterTornTopicOnLockFailure() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-
-    TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> sub1 = createProjectWithPush("sub1");
-    TestRepository<?> sub2 = createProjectWithPush("sub2");
-
-    allowMatchingSubmoduleSubscription(
-        "sub1", "refs/heads/master", "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription(
-        "sub2", "refs/heads/master", "super-project", "refs/heads/master");
-
-    Config config = new Config();
-    prepareSubmoduleConfigEntry(config, "sub1", "master");
-    prepareSubmoduleConfigEntry(config, "sub2", "master");
-    pushSubmoduleConfig(superRepo, "master", config);
-
-    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
-
-    String topic = "same-topic";
-    ObjectId sub1Id = pushChangeTo(sub1, "refs/for/master", "some message", topic);
-    ObjectId sub2Id = pushChangeTo(sub2, "refs/for/master", "some message", topic);
-
-    String changeId1 = getChangeId(sub1, sub1Id).get();
-    String changeId2 = getChangeId(sub2, sub2Id).get();
-    approve(changeId1);
-    approve(changeId2);
-
-    TestSubmitInput input = new TestSubmitInput();
-    input.generateLockFailures =
-        new ArrayDeque<>(
-            ImmutableList.of(
-                false, // Change 1, attempt 1: success
-                true, // Change 2, attempt 1: lock failure
-                false, // Change 1, attempt 2: success
-                false, // Change 2, attempt 2: success
-                false)); // Leftover value to check total number of calls.
-    gApi.changes().id(changeId1).current().submit(input);
-
-    assertThat(info(changeId1).status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(info(changeId2).status).isEqualTo(ChangeStatus.MERGED);
-
-    sub1.git().fetch().call();
-    RevWalk rw1 = sub1.getRevWalk();
-    RevCommit master1 = rw1.parseCommit(getRemoteHead(name("sub1"), "master"));
-    RevCommit change1Ps = parseCurrentRevision(rw1, changeId1);
-    assertThat(rw1.isMergedInto(change1Ps, master1)).isTrue();
-
-    sub2.git().fetch().call();
-    RevWalk rw2 = sub2.getRevWalk();
-    RevCommit master2 = rw2.parseCommit(getRemoteHead(name("sub2"), "master"));
-    RevCommit change2Ps = parseCurrentRevision(rw2, changeId2);
-    assertThat(rw2.isMergedInto(change2Ps, master2)).isTrue();
-
-    assertThat(input.generateLockFailures).containsExactly(false);
-
-    expectToHaveSubmoduleState(superRepo, "master", "sub1", sub1, "master");
-    expectToHaveSubmoduleState(superRepo, "master", "sub2", sub2, "master");
-
-    assertWithMessage("submodule subscription update should have made one commit")
-        .that(superRepo.getRepository().resolve("origin/master^"))
-        .isEqualTo(superPreviousId);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUILD
deleted file mode 100644
index 510a593..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUILD
+++ /dev/null
@@ -1,16 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "pgm",
-    labels = ["pgm"],
-    vm_args = ["-Xmx512m"],
-    deps = [":util"],
-)
-
-java_library(
-    name = "util",
-    testonly = 1,
-    srcs = ["IndexUpgradeController.java"],
-    deps = ["//gerrit-acceptance-tests:lib"],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/StandaloneNoteDbMigrationIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/StandaloneNoteDbMigrationIT.java
deleted file mode 100644
index 954246e..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/StandaloneNoteDbMigrationIT.java
+++ /dev/null
@@ -1,330 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.pgm;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.StandaloneSiteTest;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.GerritIndexStatus;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
-import com.google.gerrit.server.notedb.NotesMigrationState;
-import com.google.gerrit.server.schema.ReviewDbFactory;
-import com.google.gerrit.testutil.NoteDbMode;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Key;
-import com.google.inject.TypeLiteral;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.StoredConfig;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
-import org.junit.Before;
-import org.junit.Test;
-
-/**
- * Tests for NoteDb migrations where the entry point is through a program, {@code
- * migrate-to-note-db} or {@code daemon}.
- *
- * <p><strong>Note:</strong> These tests are very slow due to the repeated daemon startup. Prefer
- * adding tests to {@link com.google.gerrit.acceptance.server.notedb.OnlineNoteDbMigrationIT} if
- * possible.
- */
-@NoHttpd
-public class StandaloneNoteDbMigrationIT extends StandaloneSiteTest {
-  private StoredConfig gerritConfig;
-  private StoredConfig noteDbConfig;
-
-  private Project.NameKey project;
-  private Change.Id changeId;
-
-  @Before
-  public void setUp() throws Exception {
-    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.OFF);
-    gerritConfig = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.detect());
-    // Unlike in the running server, for tests, we don't stack notedb.config on gerrit.config.
-    noteDbConfig = new FileBasedConfig(sitePaths.notedb_config.toFile(), FS.detect());
-  }
-
-  @Test
-  public void rebuildOneChangeTrialMode() throws Exception {
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertNoAutoMigrateConfig(noteDbConfig);
-    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
-    setUpOneChange();
-
-    migrate("--trial");
-    assertNotesMigrationState(NotesMigrationState.READ_WRITE_NO_SEQUENCE);
-
-    try (ServerContext ctx = startServer()) {
-      GitRepositoryManager repoManager = ctx.getInjector().getInstance(GitRepositoryManager.class);
-      ObjectId metaId;
-      try (Repository repo = repoManager.openRepository(project)) {
-        Ref ref = repo.exactRef(RefNames.changeMetaRef(changeId));
-        assertThat(ref).isNotNull();
-        metaId = ref.getObjectId();
-      }
-
-      try (ReviewDb db = openUnderlyingReviewDb(ctx)) {
-        Change c = db.changes().get(changeId);
-        assertThat(c).isNotNull();
-        NoteDbChangeState state = NoteDbChangeState.parse(c);
-        assertThat(state).isNotNull();
-        assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-        assertThat(state.getRefState()).hasValue(RefState.create(metaId, ImmutableMap.of()));
-      }
-    }
-  }
-
-  @Test
-  public void migrateOneChange() throws Exception {
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertNoAutoMigrateConfig(noteDbConfig);
-    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
-    setUpOneChange();
-
-    migrate();
-    assertNotesMigrationState(NotesMigrationState.NOTE_DB);
-
-    try (ServerContext ctx = startServer()) {
-      GitRepositoryManager repoManager = ctx.getInjector().getInstance(GitRepositoryManager.class);
-      try (Repository repo = repoManager.openRepository(project)) {
-        assertThat(repo.exactRef(RefNames.changeMetaRef(changeId))).isNotNull();
-      }
-
-      try (ReviewDb db = openUnderlyingReviewDb(ctx)) {
-        Change c = db.changes().get(changeId);
-        assertThat(c).isNotNull();
-        NoteDbChangeState state = NoteDbChangeState.parse(c);
-        assertThat(state).isNotNull();
-        assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.NOTE_DB);
-        assertThat(state.getRefState()).isEmpty();
-
-        ChangeInput in = new ChangeInput(project.get(), "master", "NoteDb-only change");
-        in.newBranch = true;
-        GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
-        Change.Id id2 = new Change.Id(gApi.changes().create(in).info()._number);
-        assertThat(db.changes().get(id2)).isNull();
-      }
-    }
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertAutoMigrateConfig(noteDbConfig, false);
-  }
-
-  @Test
-  public void migrationWithReindex() throws Exception {
-    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
-    setUpOneChange();
-
-    int version = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
-    GerritIndexStatus status = new GerritIndexStatus(sitePaths);
-    assertThat(status.getReady(ChangeSchemaDefinitions.NAME, version)).isTrue();
-    status.setReady(ChangeSchemaDefinitions.NAME, version, false);
-    status.save();
-    assertServerStartupFails();
-
-    migrate();
-    assertNotesMigrationState(NotesMigrationState.NOTE_DB);
-
-    status = new GerritIndexStatus(sitePaths);
-    assertThat(status.getReady(ChangeSchemaDefinitions.NAME, version)).isTrue();
-  }
-
-  @Test
-  public void onlineMigrationViaDaemon() throws Exception {
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertNoAutoMigrateConfig(noteDbConfig);
-
-    testOnlineMigration(u -> startServer(u.module(), "--migrate-to-note-db", "true"));
-
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertAutoMigrateConfig(noteDbConfig, false);
-  }
-
-  @Test
-  public void onlineMigrationViaConfig() throws Exception {
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertNoAutoMigrateConfig(noteDbConfig);
-
-    testOnlineMigration(
-        u -> {
-          gerritConfig.setBoolean("noteDb", "changes", "autoMigrate", true);
-          gerritConfig.save();
-          return startServer(u.module());
-        });
-
-    // Auto-migration is turned off in notedb.config, which takes precedence, but is still on in
-    // gerrit.config. This means Puppet can continue overwriting gerrit.config without turning
-    // auto-migration back on.
-    assertAutoMigrateConfig(gerritConfig, true);
-    assertAutoMigrateConfig(noteDbConfig, false);
-  }
-
-  @Test
-  public void onlineMigrationTrialModeViaFlag() throws Exception {
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertNoTrialConfig(gerritConfig);
-
-    assertNoAutoMigrateConfig(noteDbConfig);
-    assertNoTrialConfig(noteDbConfig);
-
-    testOnlineMigration(
-        u -> startServer(u.module(), "--migrate-to-note-db", "--trial"),
-        NotesMigrationState.READ_WRITE_NO_SEQUENCE);
-
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertNoTrialConfig(gerritConfig);
-
-    assertAutoMigrateConfig(noteDbConfig, true);
-    assertTrialConfig(noteDbConfig, true);
-  }
-
-  @Test
-  public void onlineMigrationTrialModeViaConfig() throws Exception {
-    assertNoAutoMigrateConfig(gerritConfig);
-    assertNoTrialConfig(gerritConfig);
-
-    assertNoAutoMigrateConfig(noteDbConfig);
-    assertNoTrialConfig(noteDbConfig);
-
-    testOnlineMigration(
-        u -> {
-          gerritConfig.setBoolean("noteDb", "changes", "autoMigrate", true);
-          gerritConfig.setBoolean("noteDb", "changes", "trial", true);
-          gerritConfig.save();
-          return startServer(u.module());
-        },
-        NotesMigrationState.READ_WRITE_NO_SEQUENCE);
-
-    assertAutoMigrateConfig(gerritConfig, true);
-    assertTrialConfig(gerritConfig, true);
-
-    assertAutoMigrateConfig(noteDbConfig, true);
-    assertTrialConfig(noteDbConfig, true);
-  }
-
-  @FunctionalInterface
-  private interface StartServerWithMigration {
-    ServerContext start(IndexUpgradeController u) throws Exception;
-  }
-
-  private void testOnlineMigration(StartServerWithMigration start) throws Exception {
-    testOnlineMigration(start, NotesMigrationState.NOTE_DB);
-  }
-
-  private void testOnlineMigration(
-      StartServerWithMigration start, NotesMigrationState expectedEndState) throws Exception {
-    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
-    int prevVersion = ChangeSchemaDefinitions.INSTANCE.getPrevious().getVersion();
-    int currVersion = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
-
-    // Before storing any changes, switch back to the previous version.
-    GerritIndexStatus status = new GerritIndexStatus(sitePaths);
-    status.setReady(ChangeSchemaDefinitions.NAME, currVersion, false);
-    status.setReady(ChangeSchemaDefinitions.NAME, prevVersion, true);
-    status.save();
-
-    setOnlineUpgradeConfig(false);
-    setUpOneChange();
-    setOnlineUpgradeConfig(true);
-
-    IndexUpgradeController u = new IndexUpgradeController(1);
-    try (ServerContext ctx = start.start(u)) {
-      ChangeIndexCollection indexes = ctx.getInjector().getInstance(ChangeIndexCollection.class);
-      assertThat(indexes.getSearchIndex().getSchema().getVersion()).isEqualTo(prevVersion);
-
-      // Index schema upgrades happen after NoteDb migration, so waiting for those to complete
-      // should be sufficient.
-      u.runUpgrades();
-
-      assertThat(indexes.getSearchIndex().getSchema().getVersion()).isEqualTo(currVersion);
-      assertNotesMigrationState(expectedEndState);
-    }
-  }
-
-  private void setUpOneChange() throws Exception {
-    project = new Project.NameKey("project");
-    try (ServerContext ctx = startServer()) {
-      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
-      gApi.projects().create("project");
-
-      ChangeInput in = new ChangeInput(project.get(), "master", "Test change");
-      in.newBranch = true;
-      changeId = new Change.Id(gApi.changes().create(in).info()._number);
-    }
-  }
-
-  private void migrate(String... additionalArgs) throws Exception {
-    runGerrit(
-        ImmutableList.of(
-            "migrate-to-note-db", "-d", sitePaths.site_path.toString(), "--show-stack-trace"),
-        ImmutableList.copyOf(additionalArgs));
-  }
-
-  private void assertNotesMigrationState(NotesMigrationState expected) throws Exception {
-    noteDbConfig.load();
-    assertThat(NotesMigrationState.forConfig(noteDbConfig)).hasValue(expected);
-  }
-
-  private ReviewDb openUnderlyingReviewDb(ServerContext ctx) throws Exception {
-    return ctx.getInjector()
-        .getInstance(Key.get(new TypeLiteral<SchemaFactory<ReviewDb>>() {}, ReviewDbFactory.class))
-        .open();
-  }
-
-  private static void assertNoAutoMigrateConfig(StoredConfig cfg) throws Exception {
-    cfg.load();
-    assertThat(cfg.getString("noteDb", "changes", "autoMigrate")).isNull();
-  }
-
-  private static void assertAutoMigrateConfig(StoredConfig cfg, boolean expected) throws Exception {
-    cfg.load();
-    assertThat(cfg.getString("noteDb", "changes", "autoMigrate")).isNotNull();
-    assertThat(cfg.getBoolean("noteDb", "changes", "autoMigrate", false)).isEqualTo(expected);
-  }
-
-  private static void assertNoTrialConfig(StoredConfig cfg) throws Exception {
-    cfg.load();
-    assertThat(cfg.getString("noteDb", "changes", "trial")).isNull();
-  }
-
-  private static void assertTrialConfig(StoredConfig cfg, boolean expected) throws Exception {
-    cfg.load();
-    assertThat(cfg.getString("noteDb", "changes", "trial")).isNotNull();
-    assertThat(cfg.getBoolean("noteDb", "changes", "trial", false)).isEqualTo(expected);
-  }
-
-  private void setOnlineUpgradeConfig(boolean enable) throws Exception {
-    gerritConfig.load();
-    gerritConfig.setBoolean("index", null, "onlineUpgrade", enable);
-    gerritConfig.save();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUILD
deleted file mode 100644
index ea59d61..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUILD
+++ /dev/null
@@ -1,24 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "rest_account",
-    labels = ["rest"],
-    deps = [":util"],
-)
-
-java_library(
-    name = "util",
-    testonly = 1,
-    srcs = [
-        "AccountAssert.java",
-        "CapabilityInfo.java",
-    ],
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-acceptance-tests:lib",
-        "//gerrit-reviewdb:server",
-        "//lib:gwtorm",
-        "//lib:junit",
-    ],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
deleted file mode 100644
index ae59d6f..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ /dev/null
@@ -1,950 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.account;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.GitUtil.fetch;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-import static org.junit.Assert.fail;
-
-import com.github.rholder.retry.BlockStrategy;
-import com.github.rholder.retry.Retryer;
-import com.github.rholder.retry.RetryerBuilder;
-import com.github.rholder.retry.StopStrategies;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.Sandboxed;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountExternalIdsInput;
-import com.google.gerrit.extensions.common.AccountExternalIdInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdReader;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate.RefsMetaExternalIdsUpdate;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.LockFailureException;
-import com.google.gson.reflect.TypeToken;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Locale;
-import java.util.Set;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicInteger;
-import org.eclipse.jgit.api.errors.TransportException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.PushResult;
-import org.eclipse.jgit.transport.RemoteRefUpdate;
-import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
-import org.eclipse.jgit.util.MutableInteger;
-import org.junit.Test;
-
-@Sandboxed
-public class ExternalIdIT extends AbstractDaemonTest {
-  @Inject private AllUsersName allUsers;
-  @Inject private ExternalIdsUpdate.Server extIdsUpdate;
-  @Inject private ExternalIds externalIds;
-  @Inject private ExternalIdReader externalIdReader;
-  @Inject private MetricMaker metricMaker;
-
-  @Test
-  public void getExternalIds() throws Exception {
-    Collection<ExternalId> expectedIds = accountCache.get(user.getId()).getExternalIds();
-    List<AccountExternalIdInfo> expectedIdInfos = toExternalIdInfos(expectedIds);
-
-    RestResponse response = userRestSession.get("/accounts/self/external.ids");
-    response.assertOK();
-
-    List<AccountExternalIdInfo> results =
-        newGson()
-            .fromJson(
-                response.getReader(), new TypeToken<List<AccountExternalIdInfo>>() {}.getType());
-
-    Collections.sort(expectedIdInfos);
-    Collections.sort(results);
-    assertThat(results).containsExactlyElementsIn(expectedIdInfos);
-  }
-
-  @Test
-  public void getExternalIdsOfOtherUserNotAllowed() throws Exception {
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("access database not permitted");
-    gApi.accounts().id(admin.id.get()).getExternalIds();
-  }
-
-  @Test
-  public void getExternalIdsOfOtherUserWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-
-    Collection<ExternalId> expectedIds = accountCache.get(admin.getId()).getExternalIds();
-    List<AccountExternalIdInfo> expectedIdInfos = toExternalIdInfos(expectedIds);
-
-    RestResponse response = userRestSession.get("/accounts/" + admin.id + "/external.ids");
-    response.assertOK();
-
-    List<AccountExternalIdInfo> results =
-        newGson()
-            .fromJson(
-                response.getReader(), new TypeToken<List<AccountExternalIdInfo>>() {}.getType());
-
-    Collections.sort(expectedIdInfos);
-    Collections.sort(results);
-    assertThat(results).containsExactlyElementsIn(expectedIdInfos);
-  }
-
-  @Test
-  public void deleteExternalIds() throws Exception {
-    setApiUser(user);
-    List<AccountExternalIdInfo> externalIds = gApi.accounts().self().getExternalIds();
-
-    List<String> toDelete = new ArrayList<>();
-    List<AccountExternalIdInfo> expectedIds = new ArrayList<>();
-    for (AccountExternalIdInfo id : externalIds) {
-      if (id.canDelete != null && id.canDelete) {
-        toDelete.add(id.identity);
-        continue;
-      }
-      expectedIds.add(id);
-    }
-
-    assertThat(toDelete).hasSize(1);
-
-    RestResponse response = userRestSession.post("/accounts/self/external.ids:delete", toDelete);
-    response.assertNoContent();
-    List<AccountExternalIdInfo> results = gApi.accounts().self().getExternalIds();
-    // The external ID in WebSession will not be set for tests, resulting that
-    // "mailto:user@example.com" can be deleted while "username:user" can't.
-    assertThat(results).hasSize(1);
-    assertThat(results).containsExactlyElementsIn(expectedIds);
-  }
-
-  @Test
-  public void deleteExternalIdsOfOtherUserNotAllowed() throws Exception {
-    List<AccountExternalIdInfo> extIds = gApi.accounts().self().getExternalIds();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("access database not permitted");
-    gApi.accounts()
-        .id(admin.id.get())
-        .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList()));
-  }
-
-  @Test
-  public void deleteExternalIdOfOtherUserUnderOwnAccount_UnprocessableEntity() throws Exception {
-    List<AccountExternalIdInfo> extIds = gApi.accounts().self().getExternalIds();
-    setApiUser(user);
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage(String.format("External id %s does not exist", extIds.get(0).identity));
-    gApi.accounts()
-        .self()
-        .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList()));
-  }
-
-  @Test
-  public void deleteExternalIdsOfOtherUserWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-
-    List<AccountExternalIdInfo> externalIds = gApi.accounts().self().getExternalIds();
-
-    List<String> toDelete = new ArrayList<>();
-    List<AccountExternalIdInfo> expectedIds = new ArrayList<>();
-    for (AccountExternalIdInfo id : externalIds) {
-      if (id.canDelete != null && id.canDelete) {
-        toDelete.add(id.identity);
-        continue;
-      }
-      expectedIds.add(id);
-    }
-
-    assertThat(toDelete).hasSize(1);
-
-    setApiUser(user);
-    RestResponse response =
-        userRestSession.post("/accounts/" + admin.id + "/external.ids:delete", toDelete);
-    response.assertNoContent();
-    List<AccountExternalIdInfo> results = gApi.accounts().id(admin.id.get()).getExternalIds();
-    // The external ID in WebSession will not be set for tests, resulting that
-    // "mailto:user@example.com" can be deleted while "username:user" can't.
-    assertThat(results).hasSize(1);
-    assertThat(results).containsExactlyElementsIn(expectedIds);
-  }
-
-  @Test
-  public void deleteExternalIdOfPreferredEmail() throws Exception {
-    String preferredEmail = gApi.accounts().self().get().email;
-    assertThat(preferredEmail).isNotNull();
-
-    gApi.accounts()
-        .self()
-        .deleteExternalIds(
-            ImmutableList.of(ExternalId.Key.create(SCHEME_MAILTO, preferredEmail).get()));
-    assertThat(gApi.accounts().self().get().email).isNull();
-  }
-
-  @Test
-  public void deleteExternalIds_Conflict() throws Exception {
-    List<String> toDelete = new ArrayList<>();
-    String externalIdStr = "username:" + user.username;
-    toDelete.add(externalIdStr);
-    RestResponse response = userRestSession.post("/accounts/self/external.ids:delete", toDelete);
-    response.assertConflict();
-    assertThat(response.getEntityContent())
-        .isEqualTo(String.format("External id %s cannot be deleted", externalIdStr));
-  }
-
-  @Test
-  public void deleteExternalIds_UnprocessableEntity() throws Exception {
-    List<String> toDelete = new ArrayList<>();
-    String externalIdStr = "mailto:user@domain.com";
-    toDelete.add(externalIdStr);
-    RestResponse response = userRestSession.post("/accounts/self/external.ids:delete", toDelete);
-    response.assertUnprocessableEntity();
-    assertThat(response.getEntityContent())
-        .isEqualTo(String.format("External id %s does not exist", externalIdStr));
-  }
-
-  @Test
-  public void fetchExternalIdsBranch() throws Exception {
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
-
-    // refs/meta/external-ids is only visible to users with the 'Access Database' capability
-    try {
-      fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
-      fail("expected TransportException");
-    } catch (TransportException e) {
-      assertThat(e.getMessage())
-          .isEqualTo(
-              "Remote does not have " + RefNames.REFS_EXTERNAL_IDS + " available for fetch.");
-    }
-
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-
-    // re-clone to get new request context, otherwise the old global capabilities are still cached
-    // in the IdentifiedUser object
-    allUsersRepo = cloneProject(allUsers, user);
-    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
-  }
-
-  @Test
-  public void pushToExternalIdsBranch() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
-    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
-
-    // different case email is allowed
-    ExternalId newExtId = createExternalIdWithOtherCaseEmail("foo:bar");
-    addExtId(allUsersRepo, newExtId);
-    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
-
-    List<AccountExternalIdInfo> extIdsBefore = gApi.accounts().self().getExternalIds();
-
-    allowPushOfExternalIds();
-    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
-    assertThat(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS).getStatus()).isEqualTo(Status.OK);
-
-    List<AccountExternalIdInfo> extIdsAfter = gApi.accounts().self().getExternalIds();
-    assertThat(extIdsAfter)
-        .containsExactlyElementsIn(
-            Iterables.concat(extIdsBefore, ImmutableSet.of(toExternalIdInfo(newExtId))));
-  }
-
-  @Test
-  public void pushToExternalIdsBranchRejectsExternalIdWithoutAccountId() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
-    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
-
-    insertExternalIdWithoutAccountId(
-        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
-    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
-
-    allowPushOfExternalIds();
-    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
-    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
-  }
-
-  @Test
-  public void pushToExternalIdsBranchRejectsExternalIdWithKeyThatDoesntMatchTheNoteId()
-      throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
-    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
-
-    insertExternalIdWithKeyThatDoesntMatchNoteId(
-        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
-    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
-
-    allowPushOfExternalIds();
-    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
-    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
-  }
-
-  @Test
-  public void pushToExternalIdsBranchRejectsExternalIdWithInvalidConfig() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
-    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
-
-    insertExternalIdWithInvalidConfig(
-        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
-    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
-
-    allowPushOfExternalIds();
-    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
-    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
-  }
-
-  @Test
-  public void pushToExternalIdsBranchRejectsExternalIdWithEmptyNote() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
-    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
-
-    insertExternalIdWithEmptyNote(
-        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
-    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
-
-    allowPushOfExternalIds();
-    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
-    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
-  }
-
-  @Test
-  public void pushToExternalIdsBranchRejectsExternalIdForNonExistingAccount() throws Exception {
-    testPushToExternalIdsBranchRejectsInvalidExternalId(
-        createExternalIdForNonExistingAccount("foo:bar"));
-  }
-
-  @Test
-  public void pushToExternalIdsBranchRejectsExternalIdWithInvalidEmail() throws Exception {
-    testPushToExternalIdsBranchRejectsInvalidExternalId(
-        createExternalIdWithInvalidEmail("foo:bar"));
-  }
-
-  @Test
-  public void pushToExternalIdsBranchRejectsDuplicateEmails() throws Exception {
-    testPushToExternalIdsBranchRejectsInvalidExternalId(
-        createExternalIdWithDuplicateEmail("foo:bar"));
-  }
-
-  @Test
-  public void pushToExternalIdsBranchRejectsBadPassword() throws Exception {
-    testPushToExternalIdsBranchRejectsInvalidExternalId(createExternalIdWithBadPassword("foo"));
-  }
-
-  private void testPushToExternalIdsBranchRejectsInvalidExternalId(ExternalId invalidExtId)
-      throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
-    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
-
-    addExtId(allUsersRepo, invalidExtId);
-    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
-
-    allowPushOfExternalIds();
-    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
-    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
-  }
-
-  @Test
-  public void readExternalIdsWhenInvalidExternalIdsExist() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    resetCurrentApiUser();
-
-    insertValidExternalIds();
-    insertInvalidButParsableExternalIds();
-
-    Set<ExternalId> parseableExtIds = externalIds.all();
-
-    insertNonParsableExternalIds();
-
-    Set<ExternalId> extIds = externalIds.all();
-    assertThat(extIds).containsExactlyElementsIn(parseableExtIds);
-
-    for (ExternalId parseableExtId : parseableExtIds) {
-      ExternalId extId = externalIds.get(parseableExtId.key());
-      assertThat(extId).isEqualTo(parseableExtId);
-    }
-  }
-
-  @Test
-  public void checkConsistency() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    resetCurrentApiUser();
-
-    insertValidExternalIds();
-
-    ConsistencyCheckInput input = new ConsistencyCheckInput();
-    input.checkAccountExternalIds = new CheckAccountExternalIdsInput();
-    ConsistencyCheckInfo checkInfo = gApi.config().server().checkConsistency(input);
-    assertThat(checkInfo.checkAccountExternalIdsResult.problems).isEmpty();
-
-    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
-    expectedProblems.addAll(insertInvalidButParsableExternalIds());
-    expectedProblems.addAll(insertNonParsableExternalIds());
-
-    checkInfo = gApi.config().server().checkConsistency(input);
-    assertThat(checkInfo.checkAccountExternalIdsResult.problems).hasSize(expectedProblems.size());
-    assertThat(checkInfo.checkAccountExternalIdsResult.problems)
-        .containsExactlyElementsIn(expectedProblems);
-  }
-
-  @Test
-  public void checkConsistencyNotAllowed() throws Exception {
-    exception.expect(AuthException.class);
-    exception.expectMessage("access database not permitted");
-    gApi.config().server().checkConsistency(new ConsistencyCheckInput());
-  }
-
-  private ConsistencyProblemInfo consistencyError(String message) {
-    return new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, message);
-  }
-
-  private void insertValidExternalIds() throws IOException, ConfigInvalidException, OrmException {
-    MutableInteger i = new MutableInteger();
-    String scheme = "valid";
-    ExternalIdsUpdate u = extIdsUpdate.create();
-
-    // create valid external IDs
-    u.insert(
-        ExternalId.createWithPassword(
-            ExternalId.Key.parse(nextId(scheme, i)),
-            admin.id,
-            "admin.other@example.com",
-            "secret-password"));
-    u.insert(createExternalIdWithOtherCaseEmail(nextId(scheme, i)));
-  }
-
-  private Set<ConsistencyProblemInfo> insertInvalidButParsableExternalIds()
-      throws IOException, ConfigInvalidException, OrmException {
-    MutableInteger i = new MutableInteger();
-    String scheme = "invalid";
-    ExternalIdsUpdate u = extIdsUpdate.create();
-
-    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
-    ExternalId extIdForNonExistingAccount =
-        createExternalIdForNonExistingAccount(nextId(scheme, i));
-    u.insert(extIdForNonExistingAccount);
-    expectedProblems.add(
-        consistencyError(
-            "External ID '"
-                + extIdForNonExistingAccount.key().get()
-                + "' belongs to account that doesn't exist: "
-                + extIdForNonExistingAccount.accountId().get()));
-
-    ExternalId extIdWithInvalidEmail = createExternalIdWithInvalidEmail(nextId(scheme, i));
-    u.insert(extIdWithInvalidEmail);
-    expectedProblems.add(
-        consistencyError(
-            "External ID '"
-                + extIdWithInvalidEmail.key().get()
-                + "' has an invalid email: "
-                + extIdWithInvalidEmail.email()));
-
-    ExternalId extIdWithDuplicateEmail = createExternalIdWithDuplicateEmail(nextId(scheme, i));
-    u.insert(extIdWithDuplicateEmail);
-    expectedProblems.add(
-        consistencyError(
-            "Email '"
-                + extIdWithDuplicateEmail.email()
-                + "' is not unique, it's used by the following external IDs: '"
-                + extIdWithDuplicateEmail.key().get()
-                + "', 'mailto:"
-                + extIdWithDuplicateEmail.email()
-                + "'"));
-
-    ExternalId extIdWithBadPassword = createExternalIdWithBadPassword("admin-username");
-    u.insert(extIdWithBadPassword);
-    expectedProblems.add(
-        consistencyError(
-            "External ID '"
-                + extIdWithBadPassword.key().get()
-                + "' has an invalid password: unrecognized algorithm"));
-
-    return expectedProblems;
-  }
-
-  private Set<ConsistencyProblemInfo> insertNonParsableExternalIds() throws IOException {
-    MutableInteger i = new MutableInteger();
-    String scheme = "corrupt";
-
-    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo)) {
-      String externalId = nextId(scheme, i);
-      String noteId = insertExternalIdWithoutAccountId(repo, rw, externalId);
-      expectedProblems.add(
-          consistencyError(
-              "Invalid external ID config for note '"
-                  + noteId
-                  + "': Value for 'externalId."
-                  + externalId
-                  + ".accountId' is missing, expected account ID"));
-
-      externalId = nextId(scheme, i);
-      noteId = insertExternalIdWithKeyThatDoesntMatchNoteId(repo, rw, externalId);
-      expectedProblems.add(
-          consistencyError(
-              "Invalid external ID config for note '"
-                  + noteId
-                  + "': SHA1 of external ID '"
-                  + externalId
-                  + "' does not match note ID '"
-                  + noteId
-                  + "'"));
-
-      noteId = insertExternalIdWithInvalidConfig(repo, rw, nextId(scheme, i));
-      expectedProblems.add(
-          consistencyError(
-              "Invalid external ID config for note '" + noteId + "': Invalid line in config file"));
-
-      noteId = insertExternalIdWithEmptyNote(repo, rw, nextId(scheme, i));
-      expectedProblems.add(
-          consistencyError(
-              "Invalid external ID config for note '"
-                  + noteId
-                  + "': Expected exactly 1 'externalId' section, found 0"));
-    }
-
-    return expectedProblems;
-  }
-
-  private ExternalId createExternalIdWithOtherCaseEmail(String externalId) {
-    return ExternalId.createWithPassword(
-        ExternalId.Key.parse(externalId), admin.id, admin.email.toUpperCase(Locale.US), "password");
-  }
-
-  private String insertExternalIdWithoutAccountId(Repository repo, RevWalk rw, String externalId)
-      throws IOException {
-    ObjectId rev = ExternalIdReader.readRevision(repo);
-    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-
-    ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
-
-    try (ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId noteId = extId.key().sha1();
-      Config c = new Config();
-      extId.writeToConfig(c);
-      c.unset("externalId", extId.key().get(), "accountId");
-      byte[] raw = c.toText().getBytes(UTF_8);
-      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-      noteMap.set(noteId, dataBlob);
-
-      ExternalIdsUpdate.commit(
-          allUsers,
-          repo,
-          rw,
-          ins,
-          rev,
-          noteMap,
-          "Add external ID",
-          admin.getIdent(),
-          admin.getIdent(),
-          null,
-          GitReferenceUpdated.DISABLED);
-      return noteId.getName();
-    }
-  }
-
-  private String insertExternalIdWithKeyThatDoesntMatchNoteId(
-      Repository repo, RevWalk rw, String externalId) throws IOException {
-    ObjectId rev = ExternalIdReader.readRevision(repo);
-    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-
-    ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
-
-    try (ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId noteId = ExternalId.Key.parse(externalId + "x").sha1();
-      Config c = new Config();
-      extId.writeToConfig(c);
-      byte[] raw = c.toText().getBytes(UTF_8);
-      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-      noteMap.set(noteId, dataBlob);
-
-      ExternalIdsUpdate.commit(
-          allUsers,
-          repo,
-          rw,
-          ins,
-          rev,
-          noteMap,
-          "Add external ID",
-          admin.getIdent(),
-          admin.getIdent(),
-          null,
-          GitReferenceUpdated.DISABLED);
-      return noteId.getName();
-    }
-  }
-
-  private String insertExternalIdWithInvalidConfig(Repository repo, RevWalk rw, String externalId)
-      throws IOException {
-    ObjectId rev = ExternalIdReader.readRevision(repo);
-    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-
-    try (ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
-      byte[] raw = "bad-config".getBytes(UTF_8);
-      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-      noteMap.set(noteId, dataBlob);
-
-      ExternalIdsUpdate.commit(
-          allUsers,
-          repo,
-          rw,
-          ins,
-          rev,
-          noteMap,
-          "Add external ID",
-          admin.getIdent(),
-          admin.getIdent(),
-          null,
-          GitReferenceUpdated.DISABLED);
-      return noteId.getName();
-    }
-  }
-
-  private String insertExternalIdWithEmptyNote(Repository repo, RevWalk rw, String externalId)
-      throws IOException {
-    ObjectId rev = ExternalIdReader.readRevision(repo);
-    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-
-    try (ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
-      byte[] raw = "".getBytes(UTF_8);
-      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-      noteMap.set(noteId, dataBlob);
-
-      ExternalIdsUpdate.commit(
-          allUsers,
-          repo,
-          rw,
-          ins,
-          rev,
-          noteMap,
-          "Add external ID",
-          admin.getIdent(),
-          admin.getIdent(),
-          null,
-          GitReferenceUpdated.DISABLED);
-      return noteId.getName();
-    }
-  }
-
-  private ExternalId createExternalIdForNonExistingAccount(String externalId) {
-    return ExternalId.create(ExternalId.Key.parse(externalId), new Account.Id(1));
-  }
-
-  private ExternalId createExternalIdWithInvalidEmail(String externalId) {
-    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id, "invalid-email");
-  }
-
-  private ExternalId createExternalIdWithDuplicateEmail(String externalId) {
-    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id, admin.email);
-  }
-
-  private ExternalId createExternalIdWithBadPassword(String username) {
-    return ExternalId.create(
-        ExternalId.Key.create(SCHEME_USERNAME, username),
-        admin.id,
-        null,
-        "non-hashed-password-is-not-allowed");
-  }
-
-  private static String nextId(String scheme, MutableInteger i) {
-    return scheme + ":foo" + ++i.value;
-  }
-
-  @Test
-  public void retryOnLockFailure() throws Exception {
-    Retryer<RefsMetaExternalIdsUpdate> retryer =
-        ExternalIdsUpdate.retryerBuilder()
-            .withBlockStrategy(
-                new BlockStrategy() {
-                  @Override
-                  public void block(long sleepTime) {
-                    // Don't sleep in tests.
-                  }
-                })
-            .build();
-
-    ExternalId.Key fooId = ExternalId.Key.create("foo", "foo");
-    ExternalId.Key barId = ExternalId.Key.create("bar", "bar");
-
-    final AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
-    ExternalIdsUpdate update =
-        new ExternalIdsUpdate(
-            repoManager,
-            accountCache,
-            allUsers,
-            metricMaker,
-            externalIds,
-            new DisabledExternalIdCache(),
-            serverIdent.get(),
-            serverIdent.get(),
-            null,
-            GitReferenceUpdated.DISABLED,
-            () -> {
-              if (!doneBgUpdate.getAndSet(true)) {
-                try {
-                  extIdsUpdate.create().insert(ExternalId.create(barId, admin.id));
-                } catch (IOException | ConfigInvalidException | OrmException e) {
-                  // Ignore, the successful insertion of the external ID is asserted later
-                }
-              }
-            },
-            retryer);
-    assertThat(doneBgUpdate.get()).isFalse();
-    update.insert(ExternalId.create(fooId, admin.id));
-    assertThat(doneBgUpdate.get()).isTrue();
-
-    assertThat(externalIds.get(fooId)).isNotNull();
-    assertThat(externalIds.get(barId)).isNotNull();
-  }
-
-  @Test
-  public void failAfterRetryerGivesUp() throws Exception {
-    ExternalId.Key[] extIdsKeys = {
-      ExternalId.Key.create("foo", "foo"),
-      ExternalId.Key.create("bar", "bar"),
-      ExternalId.Key.create("baz", "baz")
-    };
-    final AtomicInteger bgCounter = new AtomicInteger(0);
-    ExternalIdsUpdate update =
-        new ExternalIdsUpdate(
-            repoManager,
-            accountCache,
-            allUsers,
-            metricMaker,
-            externalIds,
-            new DisabledExternalIdCache(),
-            serverIdent.get(),
-            serverIdent.get(),
-            null,
-            GitReferenceUpdated.DISABLED,
-            () -> {
-              try {
-                extIdsUpdate
-                    .create()
-                    .insert(ExternalId.create(extIdsKeys[bgCounter.getAndAdd(1)], admin.id));
-              } catch (IOException | ConfigInvalidException | OrmException e) {
-                // Ignore, the successful insertion of the external ID is asserted later
-              }
-            },
-            RetryerBuilder.<RefsMetaExternalIdsUpdate>newBuilder()
-                .retryIfException(e -> e instanceof LockFailureException)
-                .withStopStrategy(StopStrategies.stopAfterAttempt(extIdsKeys.length))
-                .build());
-    assertThat(bgCounter.get()).isEqualTo(0);
-    try {
-      update.insert(ExternalId.create(ExternalId.Key.create("abc", "abc"), admin.id));
-      fail("expected LockFailureException");
-    } catch (LockFailureException e) {
-      // Ignore, expected
-    }
-    assertThat(bgCounter.get()).isEqualTo(extIdsKeys.length);
-    for (ExternalId.Key extIdKey : extIdsKeys) {
-      assertThat(externalIds.get(extIdKey)).isNotNull();
-    }
-  }
-
-  @Test
-  public void readExternalIdWithAccountIdThatCanBeExpressedInKiB() throws Exception {
-    ExternalId.Key extIdKey = ExternalId.Key.parse("foo:bar");
-    Account.Id accountId = new Account.Id(1024 * 100);
-    extIdsUpdate.create().insert(ExternalId.create(extIdKey, accountId));
-    ExternalId extId = externalIds.get(extIdKey);
-    assertThat(extId.accountId()).isEqualTo(accountId);
-  }
-
-  @Test
-  public void checkNoReloadAfterUpdate() throws Exception {
-    Set<ExternalId> expectedExtIds = new HashSet<>(externalIds.byAccount(admin.id));
-    externalIdReader.setFailOnLoad(true);
-
-    // insert external ID
-    ExternalId extId = ExternalId.create("foo", "bar", admin.id);
-    extIdsUpdate.create().insert(extId);
-    expectedExtIds.add(extId);
-    assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
-
-    // update external ID
-    expectedExtIds.remove(extId);
-    extId = ExternalId.createWithEmail("foo", "bar", admin.id, "foo.bar@example.com");
-    extIdsUpdate.create().upsert(extId);
-    expectedExtIds.add(extId);
-    assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
-
-    // delete external ID
-    extIdsUpdate.create().delete(extId);
-    expectedExtIds.remove(extId);
-    assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
-  }
-
-  @Test
-  public void byAccountFailIfReadingExternalIdsFails() throws Exception {
-    externalIdReader.setFailOnLoad(true);
-
-    // update external ID branch so that external IDs need to be reloaded
-    insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id));
-
-    exception.expect(IOException.class);
-    externalIds.byAccount(admin.id);
-  }
-
-  @Test
-  public void byEmailFailIfReadingExternalIdsFails() throws Exception {
-    externalIdReader.setFailOnLoad(true);
-
-    // update external ID branch so that external IDs need to be reloaded
-    insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id));
-
-    exception.expect(IOException.class);
-    externalIds.byEmail(admin.email);
-  }
-
-  @Test
-  public void byAccountUpdateExternalIdsBehindGerritsBack() throws Exception {
-    Set<ExternalId> expectedExternalIds = new HashSet<>(externalIds.byAccount(admin.id));
-    ExternalId newExtId = ExternalId.create("foo", "bar", admin.id);
-    insertExtIdBehindGerritsBack(newExtId);
-    expectedExternalIds.add(newExtId);
-    assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExternalIds);
-  }
-
-  private void insertExtIdBehindGerritsBack(ExternalId extId) throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo);
-        ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId rev = ExternalIdReader.readRevision(repo);
-      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-      ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
-      ExternalIdsUpdate.commit(
-          allUsers,
-          repo,
-          rw,
-          ins,
-          rev,
-          noteMap,
-          "insert new ID",
-          serverIdent.get(),
-          serverIdent.get(),
-          null,
-          GitReferenceUpdated.DISABLED);
-    }
-  }
-
-  private void addExtId(TestRepository<?> testRepo, ExternalId... extIds)
-      throws IOException, OrmDuplicateKeyException, ConfigInvalidException {
-    ObjectId rev = ExternalIdReader.readRevision(testRepo.getRepository());
-
-    try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
-      NoteMap noteMap = ExternalIdReader.readNoteMap(testRepo.getRevWalk(), rev);
-      for (ExternalId extId : extIds) {
-        ExternalIdsUpdate.insert(testRepo.getRevWalk(), ins, noteMap, extId);
-      }
-
-      ExternalIdsUpdate.commit(
-          allUsers,
-          testRepo.getRepository(),
-          testRepo.getRevWalk(),
-          ins,
-          rev,
-          noteMap,
-          "Add external ID",
-          admin.getIdent(),
-          admin.getIdent(),
-          null,
-          GitReferenceUpdated.DISABLED);
-    }
-  }
-
-  private List<AccountExternalIdInfo> toExternalIdInfos(Collection<ExternalId> extIds) {
-    return extIds.stream().map(this::toExternalIdInfo).collect(toList());
-  }
-
-  private AccountExternalIdInfo toExternalIdInfo(ExternalId extId) {
-    AccountExternalIdInfo info = new AccountExternalIdInfo();
-    info.identity = extId.key().get();
-    info.emailAddress = extId.email();
-    info.canDelete = !extId.isScheme(SCHEME_USERNAME) ? true : null;
-    info.trusted =
-        extId.isScheme(SCHEME_MAILTO)
-                || extId.isScheme(SCHEME_UUID)
-                || extId.isScheme(SCHEME_USERNAME)
-            ? true
-            : null;
-    return info;
-  }
-
-  private void allowPushOfExternalIds() throws IOException, ConfigInvalidException {
-    grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.READ);
-    grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.PUSH);
-  }
-
-  private void assertRefUpdateFailure(RemoteRefUpdate update, String msg) {
-    assertThat(update.getStatus()).isEqualTo(Status.REJECTED_OTHER_REASON);
-    assertThat(update.getMessage()).isEqualTo(msg);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
deleted file mode 100644
index dcd40b9..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.account;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfo;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.GetDetail.AccountDetailInfo;
-import org.junit.Test;
-
-public class GetAccountDetailIT extends AbstractDaemonTest {
-  @Test
-  public void getDetail() throws Exception {
-    RestResponse r = adminRestSession.get("/accounts/" + admin.username + "/detail/");
-    AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
-    assertAccountInfo(admin, info);
-    Account account = accountCache.get(admin.getId()).getAccount();
-    assertThat(info.registeredOn).isEqualTo(account.getRegisteredOn());
-  }
-}
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
deleted file mode 100644
index 36843a5..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ /dev/null
@@ -1,599 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.account;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.RestSession;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
-import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
-import com.google.gerrit.extensions.api.changes.RevisionApi;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.api.groups.GroupInput;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-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.Comment;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.account.AccountControl;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.Util;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
-import org.apache.http.Header;
-import org.apache.http.message.BasicHeader;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class ImpersonationIT extends AbstractDaemonTest {
-  @Inject private AccountControl.Factory accountControlFactory;
-
-  @Inject private ApprovalsUtil approvalsUtil;
-
-  @Inject private ChangeMessagesUtil cmUtil;
-
-  @Inject private CommentsUtil commentsUtil;
-
-  private RestSession anonRestSession;
-  private TestAccount admin2;
-  private GroupInfo newGroup;
-
-  @Before
-  public void setUp() throws Exception {
-    anonRestSession = new RestSession(server, null);
-    admin2 = accountCreator.admin2();
-    GroupInput gi = new GroupInput();
-    gi.name = name("New-Group");
-    gi.members = ImmutableList.of(user.id.toString());
-    newGroup = gApi.groups().create(gi).get();
-  }
-
-  @After
-  public void tearDown() throws Exception {
-    removeRunAs();
-  }
-
-  @Test
-  public void voteOnBehalfOf() throws Exception {
-    allowCodeReviewOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-
-    ReviewInput in = ReviewInput.recommend();
-    in.onBehalfOf = user.id.toString();
-    in.message = "Message on behalf of";
-    revision.review(in);
-
-    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
-    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getAccountId()).isEqualTo(user.id);
-    assertThat(psa.getValue()).isEqualTo(1);
-    assertThat(psa.getRealAccountId()).isEqualTo(admin.id);
-
-    ChangeData cd = r.getChange();
-    ChangeMessage m = Iterables.getLast(cmUtil.byChange(db, cd.notes()));
-    assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(user.id);
-    assertThat(m.getRealAuthor()).isEqualTo(admin.id);
-  }
-
-  @Test
-  public void voteOnBehalfOfRequiresLabel() 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.message = "Message on behalf of";
-
-    exception.expect(AuthException.class);
-    exception.expectMessage("label required to post review on behalf of \"" + in.onBehalfOf + '"');
-    revision.review(in);
-  }
-
-  @Test
-  public void voteOnBehalfOfInvalidLabel() 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 = true;
-    in.label("Not-A-Label", 5);
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("label \"Not-A-Label\" is not a configured label");
-    revision.review(in);
-  }
-
-  @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();
-    cfg.getLabelSections().put(verified.getName(), verified);
-    saveProjectConfig(project, cfg);
-
-    PushOneCommit.Result r = createChange();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-
-    ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
-    in.label("Verified", 1);
-
-    exception.expect(AuthException.class);
-    exception.expectMessage(
-        "not permitted to modify label \"Verified\" on behalf of \"" + in.onBehalfOf + '"');
-    revision.review(in);
-  }
-
-  @Test
-  public void voteOnBehalfOfWithComment() throws Exception {
-    testVoteOnBehalfOfWithComment();
-  }
-
-  @GerritConfig(name = "notedb.writeJson", value = "true")
-  @Test
-  public void voteOnBehalfOfWithCommentWritingJson() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    testVoteOnBehalfOfWithComment();
-  }
-
-  private void testVoteOnBehalfOfWithComment() throws Exception {
-    allowCodeReviewOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-
-    ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
-    in.label("Code-Review", 1);
-    CommentInput ci = new CommentInput();
-    ci.path = Patch.COMMIT_MSG;
-    ci.side = Side.REVISION;
-    ci.line = 1;
-    ci.message = "message";
-    in.comments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
-    gApi.changes().id(r.getChangeId()).current().review(in);
-
-    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
-    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getAccountId()).isEqualTo(user.id);
-    assertThat(psa.getValue()).isEqualTo(1);
-    assertThat(psa.getRealAccountId()).isEqualTo(admin.id);
-
-    ChangeData cd = r.getChange();
-    Comment c = Iterables.getOnlyElement(commentsUtil.publishedByChange(db, cd.notes()));
-    assertThat(c.message).isEqualTo(ci.message);
-    assertThat(c.author.getId()).isEqualTo(user.id);
-    assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id);
-  }
-
-  @GerritConfig(name = "notedb.writeJson", value = "true")
-  @Test
-  public void voteOnBehalfOfWithRobotComment() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    allowCodeReviewOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-
-    ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
-    in.label("Code-Review", 1);
-    RobotCommentInput ci = new RobotCommentInput();
-    ci.robotId = "my-robot";
-    ci.robotRunId = "abcd1234";
-    ci.path = Patch.COMMIT_MSG;
-    ci.side = Side.REVISION;
-    ci.line = 1;
-    ci.message = "message";
-    in.robotComments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
-    gApi.changes().id(r.getChangeId()).current().review(in);
-
-    ChangeData cd = r.getChange();
-    RobotComment c = Iterables.getOnlyElement(commentsUtil.robotCommentsByChange(cd.notes()));
-    assertThat(c.message).isEqualTo(ci.message);
-    assertThat(c.robotId).isEqualTo(ci.robotId);
-    assertThat(c.robotRunId).isEqualTo(ci.robotRunId);
-    assertThat(c.author.getId()).isEqualTo(user.id);
-    assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id);
-  }
-
-  @Test
-  public void voteOnBehalfOfCannotModifyDrafts() throws Exception {
-    allowCodeReviewOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-
-    setApiUser(user);
-    DraftInput di = new DraftInput();
-    di.path = Patch.COMMIT_MSG;
-    di.side = Side.REVISION;
-    di.line = 1;
-    di.message = "message";
-    gApi.changes().id(r.getChangeId()).current().createDraft(di);
-
-    setApiUser(admin);
-    ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
-    in.label("Code-Review", 1);
-    in.drafts = DraftHandling.PUBLISH;
-
-    exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to modify other user's drafts");
-    gApi.changes().id(r.getChangeId()).current().review(in);
-  }
-
-  @Test
-  public void voteOnBehalfOfMissingUser() throws Exception {
-    allowCodeReviewOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-
-    ReviewInput in = new ReviewInput();
-    in.onBehalfOf = "doesnotexist";
-    in.label("Code-Review", 1);
-
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Account Not Found: doesnotexist");
-    revision.review(in);
-  }
-
-  @Test
-  public void voteOnBehalfOfFailsWhenUserCannotSeeDestinationRef() throws Exception {
-    blockRead(newGroup);
-
-    allowCodeReviewOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-
-    ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
-    in.label("Code-Review", 1);
-
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("on_behalf_of account " + user.id + " cannot see change");
-    revision.review(in);
-  }
-
-  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
-  @Test
-  public void voteOnBehalfOfInvisibleUserNotAllowed() throws Exception {
-    allowCodeReviewOnBehalfOf();
-    setApiUser(accountCreator.user2());
-    assertThat(accountControlFactory.get().canSee(user.id)).isFalse();
-
-    PushOneCommit.Result r = createChange();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-
-    ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
-    in.label("Code-Review", 1);
-
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Account Not Found: " + in.onBehalfOf);
-    revision.review(in);
-  }
-
-  @Test
-  public void submitOnBehalfOf() throws Exception {
-    allowSubmitOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-    String changeId = project.get() + "~master~" + r.getChangeId();
-    gApi.changes().id(changeId).current().review(ReviewInput.approve());
-    SubmitInput in = new SubmitInput();
-    in.onBehalfOf = admin2.email;
-    gApi.changes().id(changeId).current().submit(in);
-
-    ChangeData cd = r.getChange();
-    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
-    PatchSetApproval submitter =
-        approvalsUtil.getSubmitter(db, cd.notes(), cd.change().currentPatchSetId());
-    assertThat(submitter.getAccountId()).isEqualTo(admin2.id);
-    assertThat(submitter.getRealAccountId()).isEqualTo(admin.id);
-  }
-
-  @Test
-  public void submitOnBehalfOfInvalidUser() throws Exception {
-    allowSubmitOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-    String changeId = project.get() + "~master~" + r.getChangeId();
-    gApi.changes().id(changeId).current().review(ReviewInput.approve());
-    SubmitInput in = new SubmitInput();
-    in.onBehalfOf = "doesnotexist";
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Account Not Found: doesnotexist");
-    gApi.changes().id(changeId).current().submit(in);
-  }
-
-  @Test
-  public void submitOnBehalfOfNotPermitted() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId())
-        .current()
-        .review(ReviewInput.approve());
-    SubmitInput in = new SubmitInput();
-    in.onBehalfOf = admin2.email;
-    exception.expect(AuthException.class);
-    exception.expectMessage("submit as not permitted");
-    gApi.changes().id(project.get() + "~master~" + r.getChangeId()).current().submit(in);
-  }
-
-  @Test
-  public void submitOnBehalfOfFailsWhenUserCannotSeeDestinationRef() throws Exception {
-    blockRead(newGroup);
-
-    allowSubmitOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-    String changeId = project.get() + "~master~" + r.getChangeId();
-    gApi.changes().id(changeId).current().review(ReviewInput.approve());
-    SubmitInput in = new SubmitInput();
-    in.onBehalfOf = user.email;
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("on_behalf_of account " + user.id + " cannot see change");
-    gApi.changes().id(changeId).current().submit(in);
-  }
-
-  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
-  @Test
-  public void submitOnBehalfOfInvisibleUserNotAllowed() throws Exception {
-    allowSubmitOnBehalfOf();
-    setApiUser(accountCreator.user2());
-    assertThat(accountControlFactory.get().canSee(user.id)).isFalse();
-
-    PushOneCommit.Result r = createChange();
-    String changeId = project.get() + "~master~" + r.getChangeId();
-    gApi.changes().id(changeId).current().review(ReviewInput.approve());
-    SubmitInput in = new SubmitInput();
-    in.onBehalfOf = user.email;
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Account Not Found: " + in.onBehalfOf);
-    gApi.changes().id(changeId).current().submit(in);
-  }
-
-  @Test
-  public void runAsValidUser() throws Exception {
-    allowRunAs();
-    RestResponse res = adminRestSession.getWithHeader("/accounts/self", runAsHeader(user.id));
-    res.assertOK();
-    AccountInfo account = newGson().fromJson(res.getEntityContent(), AccountInfo.class);
-    assertThat(account._accountId).isEqualTo(user.id.get());
-  }
-
-  @GerritConfig(name = "auth.enableRunAs", value = "false")
-  @Test
-  public void runAsDisabledByConfig() throws Exception {
-    allowRunAs();
-    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader(user.id));
-    res.assertForbidden();
-    assertThat(res.getEntityContent())
-        .isEqualTo("X-Gerrit-RunAs disabled by auth.enableRunAs = false");
-  }
-
-  @Test
-  public void runAsNotPermitted() throws Exception {
-    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader(user.id));
-    res.assertForbidden();
-    assertThat(res.getEntityContent()).isEqualTo("not permitted to use X-Gerrit-RunAs");
-  }
-
-  @Test
-  public void runAsNeverPermittedForAnonymousUsers() throws Exception {
-    allowRunAs();
-    RestResponse res = anonRestSession.getWithHeader("/changes/", runAsHeader(user.id));
-    res.assertForbidden();
-    assertThat(res.getEntityContent()).isEqualTo("not permitted to use X-Gerrit-RunAs");
-  }
-
-  @Test
-  public void runAsInvalidUser() throws Exception {
-    allowRunAs();
-    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader("doesnotexist"));
-    res.assertForbidden();
-    assertThat(res.getEntityContent()).isEqualTo("no account matches X-Gerrit-RunAs");
-  }
-
-  @Test
-  public void voteUsingRunAsAvoidsRestrictionsOfOnBehalfOf() throws Exception {
-    allowRunAs();
-    PushOneCommit.Result r = createChange();
-
-    setApiUser(user);
-    DraftInput di = new DraftInput();
-    di.path = Patch.COMMIT_MSG;
-    di.side = Side.REVISION;
-    di.line = 1;
-    di.message = "inline comment";
-    gApi.changes().id(r.getChangeId()).current().createDraft(di);
-    setApiUser(admin);
-
-    // Things that aren't allowed with on_behalf_of:
-    //  - no labels.
-    //  - publish other user's drafts.
-    ReviewInput in = new ReviewInput();
-    in.message = "message";
-    in.drafts = DraftHandling.PUBLISH;
-    RestResponse res =
-        adminRestSession.postWithHeader(
-            "/changes/" + r.getChangeId() + "/revisions/current/review", in, runAsHeader(user.id));
-    res.assertOK();
-
-    ChangeMessageInfo m = Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages);
-    assertThat(m.message).endsWith(in.message);
-    assertThat(m.author._accountId).isEqualTo(user.id.get());
-
-    CommentInfo c =
-        Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).comments().get(di.path));
-    assertThat(c.author._accountId).isEqualTo(user.id.get());
-    assertThat(c.message).isEqualTo(di.message);
-
-    setApiUser(user);
-    assertThat(gApi.changes().id(r.getChangeId()).drafts()).isEmpty();
-  }
-
-  @Test
-  public void runAsWithOnBehalfOf() throws Exception {
-    // - Has the same restrictions as on_behalf_of (e.g. requires labels).
-    // - Takes the effective user from on_behalf_of (user).
-    // - Takes the real user from the real caller, not the intermediate
-    //   X-Gerrit-RunAs user (user2).
-    allowRunAs();
-    allowCodeReviewOnBehalfOf();
-    TestAccount user2 = accountCreator.user2();
-
-    PushOneCommit.Result r = createChange();
-    ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
-    in.message = "Message on behalf of";
-
-    String endpoint = "/changes/" + r.getChangeId() + "/revisions/current/review";
-    RestResponse res = adminRestSession.postWithHeader(endpoint, in, runAsHeader(user2.id));
-    res.assertForbidden();
-    assertThat(res.getEntityContent())
-        .isEqualTo("label required to post review on behalf of \"" + in.onBehalfOf + '"');
-
-    in.label("Code-Review", 1);
-    adminRestSession.postWithHeader(endpoint, in, runAsHeader(user2.id)).assertOK();
-
-    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
-    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getAccountId()).isEqualTo(user.id);
-    assertThat(psa.getValue()).isEqualTo(1);
-    assertThat(psa.getRealAccountId()).isEqualTo(admin.id); // not user2
-
-    ChangeData cd = r.getChange();
-    ChangeMessage m = Iterables.getLast(cmUtil.byChange(db, cd.notes()));
-    assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(user.id);
-    assertThat(m.getRealAuthor()).isEqualTo(admin.id); // not user2
-  }
-
-  @Test
-  public void changeMessageCreatedOnBehalfOfHasRealUser() throws Exception {
-    allowCodeReviewOnBehalfOf();
-
-    PushOneCommit.Result r = createChange();
-    ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
-    in.message = "Message on behalf of";
-    in.label("Code-Review", 1);
-
-    setApiUser(accountCreator.user2());
-    gApi.changes().id(r.getChangeId()).revision(r.getPatchSetId().getId()).review(in);
-
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get(MESSAGES);
-    assertThat(info.messages).hasSize(2);
-
-    ChangeMessageInfo changeMessageInfo = Iterables.getLast(info.messages);
-    assertThat(changeMessageInfo.realAuthor).isNotNull();
-    assertThat(changeMessageInfo.realAuthor._accountId).isEqualTo(accountCreator.user2().id.get());
-  }
-
-  private void allowCodeReviewOnBehalfOf() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType codeReviewType = Util.codeReview();
-    String forCodeReviewAs = Permission.forLabelAs(codeReviewType.getName());
-    String heads = "refs/heads/*";
-    AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    Util.allow(cfg, forCodeReviewAs, -1, 1, uuid, heads);
-    saveProjectConfig(project, cfg);
-  }
-
-  private void allowSubmitOnBehalfOf() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    String heads = "refs/heads/*";
-    AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    Util.allow(cfg, Permission.SUBMIT_AS, uuid, heads);
-    Util.allow(cfg, Permission.SUBMIT, uuid, heads);
-    LabelType codeReviewType = Util.codeReview();
-    Util.allow(cfg, Permission.forLabel(codeReviewType.getName()), -2, 2, uuid, heads);
-    saveProjectConfig(project, cfg);
-  }
-
-  private void blockRead(GroupInfo group) throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.block(cfg, Permission.READ, new AccountGroup.UUID(group.id), "refs/heads/master");
-    saveProjectConfig(project, cfg);
-  }
-
-  private void allowRunAs() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    Util.allow(
-        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
-    saveProjectConfig(allProjects, cfg);
-  }
-
-  private void removeRunAs() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    Util.remove(
-        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
-    saveProjectConfig(allProjects, cfg);
-  }
-
-  private static Header runAsHeader(Object user) {
-    return new BasicHeader("X-Gerrit-RunAs", user.toString());
-  }
-}
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
deleted file mode 100644
index 7de9d70..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.account;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.account.PutUsername;
-import org.junit.Test;
-
-public class PutUsernameIT extends AbstractDaemonTest {
-  @Test
-  public void set() throws Exception {
-    PutUsername.Input in = new PutUsername.Input();
-    in.username = "myUsername";
-    RestResponse r =
-        adminRestSession.put("/accounts/" + accountCreator.create().id.get() + "/username", in);
-    r.assertOK();
-    assertThat(newGson().fromJson(r.getReader(), String.class)).isEqualTo(in.username);
-  }
-
-  @Test
-  public void setExisting_Conflict() throws Exception {
-    PutUsername.Input in = new PutUsername.Input();
-    in.username = admin.username;
-    adminRestSession
-        .put("/accounts/" + accountCreator.create().id.get() + "/username", in)
-        .assertConflict();
-  }
-
-  @Test
-  public void setNew_MethodNotAllowed() throws Exception {
-    PutUsername.Input in = new PutUsername.Input();
-    in.username = "newUsername";
-    adminRestSession.put("/accounts/" + admin.username + "/username", in).assertMethodNotAllowed();
-  }
-
-  @Test
-  public void delete_MethodNotAllowed() throws Exception {
-    adminRestSession.put("/accounts/" + admin.username + "/username").assertMethodNotAllowed();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
deleted file mode 100644
index 682b5bc..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ /dev/null
@@ -1,1231 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
-import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.toList;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.api.projects.ConfigInput;
-import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.LabelInfo;
-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.BinaryResult;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.change.Submit;
-import com.google.gerrit.server.change.Submit.TestSubmitInput;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.Util;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.validators.ValidationException;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.atomic.AtomicBoolean;
-import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.transport.RefSpec;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public abstract class AbstractSubmit extends AbstractDaemonTest {
-  @ConfigSuite.Config
-  public static Config submitWholeTopicEnabled() {
-    return submitWholeTopicEnabledConfig();
-  }
-
-  @Inject private ApprovalsUtil approvalsUtil;
-
-  @Inject private Submit submitHandler;
-
-  @Inject private IdentifiedUser.GenericFactory userFactory;
-
-  @Inject private DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
-  private RegistrationHandle onSubmitValidatorHandle;
-
-  private String systemTimeZone;
-
-  @Before
-  public void setTimeForTesting() {
-    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
-  }
-
-  @After
-  public void cleanup() {
-    db.close();
-  }
-
-  @After
-  public void removeOnSubmitValidator() {
-    if (onSubmitValidatorHandle != null) {
-      onSubmitValidatorHandle.remove();
-    }
-  }
-
-  protected abstract SubmitType getSubmitType();
-
-  @Test
-  @TestProjectInput(createEmptyCommit = false)
-  public void submitToEmptyRepo() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange();
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
-    RevCommit headAfterSubmitPreview = getRemoteHead();
-    assertThat(headAfterSubmitPreview).isEqualTo(initialHead);
-    assertThat(actual).hasSize(1);
-
-    submit(change.getChangeId());
-    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
-    assertTrees(project, actual);
-  }
-
-  @Test
-  public void submitSingleChange() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange();
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
-    assertThat(headAfterSubmit).isEqualTo(initialHead);
-    assertRefUpdatedEvents();
-    assertChangeMergedEvents();
-
-    if ((getSubmitType() == SubmitType.CHERRY_PICK)
-        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
-      // The change is updated as well:
-      assertThat(actual).hasSize(2);
-    } else {
-      assertThat(actual).hasSize(1);
-    }
-
-    submit(change.getChangeId());
-    assertTrees(project, actual);
-  }
-
-  @Test
-  public void submitMultipleChangesOtherMergeConflictPreview() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
-    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
-    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
-    // change 2 is not approved, but we ignore labels
-    approve(change3.getChangeId());
-
-    try (BinaryResult request = submitPreview(change4.getChangeId())) {
-      assertThat(getSubmitType()).isEqualTo(SubmitType.CHERRY_PICK);
-      submit(change4.getChangeId());
-    } catch (RestApiException e) {
-      switch (getSubmitType()) {
-        case FAST_FORWARD_ONLY:
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Failed to submit 3 changes due to the following problems:\n"
-                      + "Change "
-                      + change2.getChange().getId()
-                      + ": internal error: "
-                      + "change not processed by merge strategy\n"
-                      + "Change "
-                      + change3.getChange().getId()
-                      + ": internal error: "
-                      + "change not processed by merge strategy\n"
-                      + "Change "
-                      + change4.getChange().getId()
-                      + ": Project policy "
-                      + "requires all submissions to be a fast-forward. Please "
-                      + "rebase the change locally and upload again for review.");
-          break;
-        case REBASE_IF_NECESSARY:
-        case REBASE_ALWAYS:
-          String change2hash = change2.getChange().currentPatchSet().getRevision().get();
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Cannot rebase "
-                      + change2hash
-                      + ": The change could "
-                      + "not be rebased due to a conflict during merge.");
-          break;
-        case MERGE_ALWAYS:
-        case MERGE_IF_NECESSARY:
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Failed to submit 3 changes due to the following problems:\n"
-                      + "Change "
-                      + change2.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.\n"
-                      + "Change "
-                      + change3.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.\n"
-                      + "Change "
-                      + change4.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.");
-          break;
-        case CHERRY_PICK:
-        default:
-          fail("Should not reach here.");
-          break;
-      }
-
-      RevCommit headAfterSubmit = getRemoteHead();
-      assertThat(headAfterSubmit).isEqualTo(headAfterFirstSubmit);
-      assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-      assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
-    }
-  }
-
-  @Test
-  public void submitMultipleChangesPreview() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
-    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
-    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
-    // change 2 is not approved, but we ignore labels
-    approve(change3.getChangeId());
-    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change4.getChangeId());
-    Map<String, Map<String, Integer>> expected = new HashMap<>();
-    expected.put(project.get(), new HashMap<>());
-    expected.get(project.get()).put("refs/heads/master", 3);
-
-    assertThat(actual).containsKey(new Branch.NameKey(project, "refs/heads/master"));
-    if (getSubmitType() == SubmitType.CHERRY_PICK) {
-      // CherryPick ignores dependencies, thus only change and destination
-      // branch refs are modified.
-      assertThat(actual).hasSize(2);
-    } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
-      // RebaseAlways takes care of dependencies, therefore Change{2,3,4} and
-      // destination branch will be modified.
-      assertThat(actual).hasSize(4);
-    } else {
-      assertThat(actual).hasSize(1);
-    }
-
-    // check that the submit preview did not actually submit
-    RevCommit headAfterSubmit = getRemoteHead();
-    assertThat(headAfterSubmit).isEqualTo(initialHead);
-    assertRefUpdatedEvents();
-    assertChangeMergedEvents();
-
-    // now check we actually have the same content:
-    approve(change2.getChangeId());
-    submit(change4.getChangeId());
-    assertTrees(project, actual);
-  }
-
-  @Test
-  public void submitNoPermission() throws Exception {
-    // create project where submit is blocked
-    Project.NameKey p = createProject("p");
-    block(p, "refs/*", Permission.SUBMIT, REGISTERED_USERS);
-
-    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
-    PushOneCommit.Result result = push.to("refs/for/master");
-    result.assertOkStatus();
-
-    submit(result.getChangeId(), new SubmitInput(), AuthException.class, "submit not permitted");
-  }
-
-  @Test
-  public void noSelfSubmit() throws Exception {
-    // create project where submit is blocked for the change owner
-    Project.NameKey p = createProject("p");
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.block(cfg, Permission.SUBMIT, CHANGE_OWNER, "refs/*");
-    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
-    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
-    saveProjectConfig(p, cfg);
-
-    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
-    PushOneCommit.Result result = push.to("refs/for/master");
-    result.assertOkStatus();
-
-    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
-    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
-
-    submit(result.getChangeId(), new SubmitInput(), AuthException.class, "submit not permitted");
-
-    setApiUser(user);
-    submit(result.getChangeId());
-  }
-
-  @Test
-  public void onlySelfSubmit() throws Exception {
-    // create project where only the change owner can submit
-    Project.NameKey p = createProject("p");
-    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.block(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/*");
-    Util.allow(cfg, Permission.SUBMIT, CHANGE_OWNER, "refs/*");
-    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
-    saveProjectConfig(p, cfg);
-
-    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
-    PushOneCommit.Result result = push.to("refs/for/master");
-    result.assertOkStatus();
-
-    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
-    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
-
-    setApiUser(user);
-    submit(result.getChangeId(), new SubmitInput(), AuthException.class, "submit not permitted");
-
-    setApiUser(admin);
-    submit(result.getChangeId());
-  }
-
-  @Test
-  public void submitWholeTopicMultipleProjects() throws Exception {
-    assume().that(isSubmitWholeTopicEnabled()).isTrue();
-    String topic = "test-topic";
-
-    // Create test projects
-    TestRepository<?> repoA = createProjectWithPush("project-a", null, getSubmitType());
-    TestRepository<?> repoB = createProjectWithPush("project-b", null, getSubmitType());
-
-    // Create changes on project-a
-    PushOneCommit.Result change1 =
-        createChange(repoA, "master", "Change 1", "a.txt", "content", topic);
-    PushOneCommit.Result change2 =
-        createChange(repoA, "master", "Change 2", "b.txt", "content", topic);
-
-    // Create changes on project-b
-    PushOneCommit.Result change3 =
-        createChange(repoB, "master", "Change 3", "a.txt", "content", topic);
-    PushOneCommit.Result change4 =
-        createChange(repoB, "master", "Change 4", "b.txt", "content", topic);
-
-    approve(change1.getChangeId());
-    approve(change2.getChangeId());
-    approve(change3.getChangeId());
-    approve(change4.getChangeId());
-    submit(change4.getChangeId());
-
-    String expectedTopic = name(topic);
-    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
-    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
-    change3.assertChange(Change.Status.MERGED, expectedTopic, admin);
-    change4.assertChange(Change.Status.MERGED, expectedTopic, admin);
-  }
-
-  @Test
-  public void submitWholeTopicMultipleBranchesOnSameProject() throws Exception {
-    assume().that(isSubmitWholeTopicEnabled()).isTrue();
-    String topic = "test-topic";
-
-    // Create test project
-    String projectName = "project-a";
-    TestRepository<?> repoA = createProjectWithPush(projectName, null, getSubmitType());
-
-    RevCommit initialHead = getRemoteHead(new Project.NameKey(name(projectName)), "master");
-
-    // Create the dev branch on the test project
-    BranchInput in = new BranchInput();
-    in.revision = initialHead.name();
-    gApi.projects().name(name(projectName)).branch("dev").create(in);
-
-    // Create changes on master
-    PushOneCommit.Result change1 =
-        createChange(repoA, "master", "Change 1", "a.txt", "content", topic);
-    PushOneCommit.Result change2 =
-        createChange(repoA, "master", "Change 2", "b.txt", "content", topic);
-
-    // Create  changes on dev
-    repoA.reset(initialHead);
-    PushOneCommit.Result change3 =
-        createChange(repoA, "dev", "Change 3", "a.txt", "content", topic);
-    PushOneCommit.Result change4 =
-        createChange(repoA, "dev", "Change 4", "b.txt", "content", topic);
-
-    approve(change1.getChangeId());
-    approve(change2.getChangeId());
-    approve(change3.getChangeId());
-    approve(change4.getChangeId());
-    submit(change4.getChangeId());
-
-    String expectedTopic = name(topic);
-    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
-    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
-    change3.assertChange(Change.Status.MERGED, expectedTopic, admin);
-    change4.assertChange(Change.Status.MERGED, expectedTopic, admin);
-  }
-
-  @Test
-  public void submitWholeTopic() throws Exception {
-    assume().that(isSubmitWholeTopicEnabled()).isTrue();
-    String topic = "test-topic";
-    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content", topic);
-    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "content", topic);
-    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "content", topic);
-    approve(change1.getChangeId());
-    approve(change2.getChangeId());
-    approve(change3.getChangeId());
-    submit(change3.getChangeId());
-    String expectedTopic = name(topic);
-    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
-    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
-    change3.assertChange(Change.Status.MERGED, expectedTopic, admin);
-
-    // Check for the exact change to have the correct submitter.
-    assertSubmitter(change3);
-    // Also check submitters for changes submitted via the topic relationship.
-    assertSubmitter(change1);
-    assertSubmitter(change2);
-
-    // Check that the repo has the expected commits
-    List<RevCommit> log = getRemoteLog();
-    List<String> commitsInRepo = log.stream().map(c -> c.getShortMessage()).collect(toList());
-    int expectedCommitCount =
-        getSubmitType() == SubmitType.MERGE_ALWAYS
-            ? 5 // initial commit + 3 commits + merge commit
-            : 4; // initial commit + 3 commits
-    assertThat(log).hasSize(expectedCommitCount);
-
-    assertThat(commitsInRepo)
-        .containsAllOf("Initial empty repository", "Change 1", "Change 2", "Change 3");
-    if (getSubmitType() == SubmitType.MERGE_ALWAYS) {
-      assertThat(commitsInRepo).contains("Merge changes from topic \"" + expectedTopic + "\"");
-    }
-  }
-
-  @Test
-  public void submitReusingOldTopic() throws Exception {
-    assume().that(isSubmitWholeTopicEnabled()).isTrue();
-
-    String topic = "test-topic";
-    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content", topic);
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "content", topic);
-    String id1 = change1.getChangeId();
-    String id2 = change2.getChangeId();
-    approve(id1);
-    approve(id2);
-    assertSubmittedTogether(id1, ImmutableList.of(id1, id2));
-    assertSubmittedTogether(id2, ImmutableList.of(id1, id2));
-    submit(id2);
-
-    String expectedTopic = name(topic);
-    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
-    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
-    assertSubmittedTogether(id1, ImmutableList.of(id1, id2));
-    assertSubmittedTogether(id2, ImmutableList.of(id1, id2));
-
-    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "content", topic);
-    String id3 = change3.getChangeId();
-    approve(id3);
-    assertSubmittedTogether(id3, ImmutableList.of());
-    submit(id3);
-
-    change3.assertChange(Change.Status.MERGED, expectedTopic, admin);
-    assertSubmittedTogether(id3, ImmutableList.of());
-  }
-
-  private void assertSubmittedTogether(String changeId, Iterable<String> expected)
-      throws Exception {
-    assertThat(gApi.changes().id(changeId).submittedTogether().stream().map(i -> i.changeId))
-        .containsExactlyElementsIn(expected);
-  }
-
-  @Test
-  public void submitWorkInProgressChange() throws Exception {
-    PushOneCommit.Result change = createWorkInProgressChange();
-    Change.Id num = change.getChange().getId();
-    submitWithConflict(
-        change.getChangeId(),
-        "Failed to submit 1 change due to the following problems:\n"
-            + "Change "
-            + num
-            + ": Change "
-            + num
-            + " is work in progress");
-  }
-
-  @Test
-  public void submitWithHiddenBranchInSameTopic() throws Exception {
-    assume().that(isSubmitWholeTopicEnabled()).isTrue();
-    PushOneCommit.Result visible = createChange("refs/for/master/" + name("topic"));
-    Change.Id num = visible.getChange().getId();
-
-    createBranch(new Branch.NameKey(project, "hidden"));
-    PushOneCommit.Result hidden = createChange("refs/for/hidden/" + name("topic"));
-    approve(hidden.getChangeId());
-    blockRead("refs/heads/hidden");
-
-    submit(
-        visible.getChangeId(),
-        new SubmitInput(),
-        AuthException.class,
-        "A change to be submitted with " + num + " is not visible");
-  }
-
-  @Test
-  public void submitChangeWhenParentOfOtherBranchTip() throws Exception {
-    // Chain of two commits
-    // Push both to topic-branch
-    // Push the first commit for review and submit
-    //
-    // C2 -- tip of topic branch
-    //  |
-    // C1 -- pushed for review
-    //  |
-    // C0 -- Master
-    //
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config.getProject().setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
-    saveProjectConfig(project, config);
-
-    PushOneCommit push1 =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
-    PushOneCommit.Result c1 = push1.to("refs/heads/topic");
-    c1.assertOkStatus();
-    PushOneCommit push2 =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
-    PushOneCommit.Result c2 = push2.to("refs/heads/topic");
-    c2.assertOkStatus();
-
-    PushOneCommit.Result change1 = push1.to("refs/for/master");
-    change1.assertOkStatus();
-
-    approve(change1.getChangeId());
-    submit(change1.getChangeId());
-  }
-
-  @Test
-  public void submitMergeOfNonChangeBranchTip() throws Exception {
-    // Merge a branch with commits that have not been submitted as
-    // changes.
-    //
-    // M  -- mergeCommit (pushed for review and submitted)
-    // | \
-    // |  S -- stable (pushed directly to refs/heads/stable)
-    // | /
-    // I   -- master
-    //
-    RevCommit master = getRemoteHead(project, "master");
-    PushOneCommit stableTip =
-        pushFactory.create(
-            db, admin.getIdent(), testRepo, "Tip of branch stable", "stable.txt", "");
-    PushOneCommit.Result stable = stableTip.to("refs/heads/stable");
-    PushOneCommit mergeCommit =
-        pushFactory.create(db, admin.getIdent(), testRepo, "The merge commit", "merge.txt", "");
-    mergeCommit.setParents(ImmutableList.of(master, stable.getCommit()));
-    PushOneCommit.Result mergeReview = mergeCommit.to("refs/for/master");
-    approve(mergeReview.getChangeId());
-    submit(mergeReview.getChangeId());
-
-    List<RevCommit> log = getRemoteLog();
-    assertThat(log).contains(stable.getCommit());
-    assertThat(log).contains(mergeReview.getCommit());
-  }
-
-  @Test
-  public void submitMergeOfNonChangeBranchNonTip() throws Exception {
-    // Merge a branch with commits that have not been submitted as
-    // changes.
-    //
-    // MC  -- merge commit (pushed for review and submitted)
-    // |\   S2 -- new stable tip (pushed directly to refs/heads/stable)
-    // M \ /
-    // |  S1 -- stable (pushed directly to refs/heads/stable)
-    // | /
-    // I -- master
-    //
-    RevCommit initial = getRemoteHead(project, "master");
-    // push directly to stable to S1
-    PushOneCommit.Result s1 =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "new commit into stable", "stable1.txt", "")
-            .to("refs/heads/stable");
-    // move the stable tip ahead to S2
-    pushFactory
-        .create(db, admin.getIdent(), testRepo, "Tip of branch stable", "stable2.txt", "")
-        .to("refs/heads/stable");
-
-    testRepo.reset(initial);
-
-    // move the master ahead
-    PushOneCommit.Result m =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "Move master ahead", "master.txt", "")
-            .to("refs/heads/master");
-
-    // create merge change
-    PushOneCommit mc =
-        pushFactory.create(db, admin.getIdent(), testRepo, "The merge commit", "merge.txt", "");
-    mc.setParents(ImmutableList.of(m.getCommit(), s1.getCommit()));
-    PushOneCommit.Result mergeReview = mc.to("refs/for/master");
-    approve(mergeReview.getChangeId());
-    submit(mergeReview.getChangeId());
-
-    List<RevCommit> log = getRemoteLog();
-    assertThat(log).contains(s1.getCommit());
-    assertThat(log).contains(mergeReview.getCommit());
-  }
-
-  @Test
-  public void submitChangeWithCommitThatWasAlreadyMerged() throws Exception {
-    // create and submit a change
-    PushOneCommit.Result change = createChange();
-    submit(change.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
-
-    // set the status of the change back to NEW to simulate a failed submit that
-    // merged the commit but failed to update the change status
-    setChangeStatusToNew(change);
-
-    // submitting the change again should detect that the commit was already
-    // merged and just fix the change status to be MERGED
-    submit(change.getChangeId());
-    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
-  }
-
-  @Test
-  public void submitChangesWithCommitsThatWereAlreadyMerged() throws Exception {
-    // create and submit 2 changes
-    PushOneCommit.Result change1 = createChange();
-    PushOneCommit.Result change2 = createChange();
-    approve(change1.getChangeId());
-    if (getSubmitType() == SubmitType.CHERRY_PICK) {
-      submit(change1.getChangeId());
-    }
-    submit(change2.getChangeId());
-    assertMerged(change1.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
-
-    // set the status of the changes back to NEW to simulate a failed submit that
-    // merged the commits but failed to update the change status
-    setChangeStatusToNew(change1, change2);
-
-    // submitting the changes again should detect that the commits were already
-    // merged and just fix the change status to be MERGED
-    submit(change1.getChangeId());
-    submit(change2.getChangeId());
-    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
-  }
-
-  @Test
-  public void submitTopicWithCommitsThatWereAlreadyMerged() throws Exception {
-    assume().that(isSubmitWholeTopicEnabled()).isTrue();
-
-    // create and submit 2 changes with the same topic
-    String topic = name("topic");
-    PushOneCommit.Result change1 = createChange("refs/for/master/" + topic);
-    PushOneCommit.Result change2 = createChange("refs/for/master/" + topic);
-    approve(change1.getChangeId());
-    submit(change2.getChangeId());
-    assertMerged(change1.getChangeId());
-    RevCommit headAfterSubmit = getRemoteHead();
-
-    // set the status of the second change back to NEW to simulate a failed
-    // submit that merged the commits but failed to update the change status of
-    // some changes in the topic
-    setChangeStatusToNew(change2);
-
-    // submitting the topic again should detect that the commits were already
-    // merged and just fix the change status to be MERGED
-    submit(change2.getChangeId());
-    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
-  }
-
-  @Test
-  public void submitWithValidation() throws Exception {
-    AtomicBoolean called = new AtomicBoolean(false);
-    this.addOnSubmitValidationListener(
-        new OnSubmitValidationListener() {
-          @Override
-          public void preBranchUpdate(Arguments args) throws ValidationException {
-            called.set(true);
-            HashSet<String> refs = Sets.newHashSet(args.getCommands().keySet());
-            assertThat(refs).contains("refs/heads/master");
-            refs.remove("refs/heads/master");
-            if (!refs.isEmpty()) {
-              // Some submit strategies need to insert new patchset.
-              assertThat(refs).hasSize(1);
-              assertThat(refs.iterator().next()).startsWith(RefNames.REFS_CHANGES);
-            }
-          }
-        });
-
-    PushOneCommit.Result change = createChange();
-    approve(change.getChangeId());
-    submit(change.getChangeId());
-    assertThat(called.get()).isTrue();
-  }
-
-  @Test
-  public void submitWithValidationMultiRepo() throws Exception {
-    assume().that(isSubmitWholeTopicEnabled()).isTrue();
-    String topic = "test-topic";
-
-    // Create test projects
-    TestRepository<?> repoA = createProjectWithPush("project-a", null, getSubmitType());
-    TestRepository<?> repoB = createProjectWithPush("project-b", null, getSubmitType());
-
-    // Create changes on project-a
-    PushOneCommit.Result change1 =
-        createChange(repoA, "master", "Change 1", "a.txt", "content", topic);
-    PushOneCommit.Result change2 =
-        createChange(repoA, "master", "Change 2", "b.txt", "content", topic);
-
-    // Create changes on project-b
-    PushOneCommit.Result change3 =
-        createChange(repoB, "master", "Change 3", "a.txt", "content", topic);
-    PushOneCommit.Result change4 =
-        createChange(repoB, "master", "Change 4", "b.txt", "content", topic);
-
-    List<PushOneCommit.Result> changes = Lists.newArrayList(change1, change2, change3, change4);
-    for (PushOneCommit.Result change : changes) {
-      approve(change.getChangeId());
-    }
-
-    // Construct validator which will throw on a second call.
-    // Since there are 2 repos, first submit attempt will fail, the second will
-    // succeed.
-    List<String> projectsCalled = new ArrayList<>(4);
-    this.addOnSubmitValidationListener(
-        new OnSubmitValidationListener() {
-          @Override
-          public void preBranchUpdate(Arguments args) throws ValidationException {
-            String master = "refs/heads/master";
-            assertThat(args.getCommands()).containsKey(master);
-            ReceiveCommand cmd = args.getCommands().get(master);
-            ObjectId newMasterId = cmd.getNewId();
-            try (Repository repo = repoManager.openRepository(args.getProject())) {
-              assertThat(repo.exactRef(master).getObjectId()).isEqualTo(cmd.getOldId());
-              assertThat(args.getRef(master)).hasValue(newMasterId);
-              args.getRevWalk().parseBody(args.getRevWalk().parseCommit(newMasterId));
-            } catch (IOException e) {
-              throw new AssertionError("failed checking new ref value", e);
-            }
-            projectsCalled.add(args.getProject().get());
-            if (projectsCalled.size() == 2) {
-              throw new ValidationException("time to fail");
-            }
-          }
-        });
-    submitWithConflict(change4.getChangeId(), "time to fail");
-    assertThat(projectsCalled).containsExactly(name("project-a"), name("project-b"));
-    for (PushOneCommit.Result change : changes) {
-      change.assertChange(Change.Status.NEW, name(topic), admin);
-    }
-
-    submit(change4.getChangeId());
-    assertThat(projectsCalled)
-        .containsExactly(
-            name("project-a"), name("project-b"), name("project-a"), name("project-b"));
-    for (PushOneCommit.Result change : changes) {
-      change.assertChange(Change.Status.MERGED, name(topic), admin);
-    }
-  }
-
-  @Test
-  public void submitWithCommitAndItsMergeCommitTogether() throws Exception {
-    assume().that(isSubmitWholeTopicEnabled()).isTrue();
-
-    RevCommit initialHead = getRemoteHead();
-
-    // Create a stable branch and bootstrap it.
-    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
-    PushOneCommit push =
-        pushFactory.create(db, user.getIdent(), testRepo, "initial commit", "a.txt", "a");
-    PushOneCommit.Result change = push.to("refs/heads/stable");
-
-    RevCommit stable = getRemoteHead(project, "stable");
-    RevCommit master = getRemoteHead(project, "master");
-
-    assertThat(master).isEqualTo(initialHead);
-    assertThat(stable).isEqualTo(change.getCommit());
-
-    testRepo.git().fetch().call();
-    testRepo.git().branchCreate().setName("stable").setStartPoint(stable).call();
-    testRepo.git().branchCreate().setName("master").setStartPoint(master).call();
-
-    // Create a fix in stable branch.
-    testRepo.reset(stable);
-    RevCommit fix =
-        testRepo
-            .commit()
-            .parent(stable)
-            .message("small fix")
-            .add("b.txt", "b")
-            .insertChangeId()
-            .create();
-    testRepo.branch("refs/heads/stable").update(fix);
-    testRepo
-        .git()
-        .push()
-        .setRefSpecs(new RefSpec("refs/heads/stable:refs/for/stable/" + name("topic")))
-        .call();
-
-    // Merge the fix into master.
-    testRepo.reset(master);
-    RevCommit merge =
-        testRepo
-            .commit()
-            .parent(master)
-            .parent(fix)
-            .message("Merge stable into master")
-            .insertChangeId()
-            .create();
-    testRepo.branch("refs/heads/master").update(merge);
-    testRepo
-        .git()
-        .push()
-        .setRefSpecs(new RefSpec("refs/heads/master:refs/for/master/" + name("topic")))
-        .call();
-
-    // Submit together.
-    String fixId = GitUtil.getChangeId(testRepo, fix).get();
-    String mergeId = GitUtil.getChangeId(testRepo, merge).get();
-    approve(fixId);
-    approve(mergeId);
-    submit(mergeId);
-    assertMerged(fixId);
-    assertMerged(mergeId);
-    testRepo.git().fetch().call();
-    RevWalk rw = testRepo.getRevWalk();
-    master = rw.parseCommit(getRemoteHead(project, "master"));
-    assertThat(rw.isMergedInto(merge, master)).isTrue();
-    assertThat(rw.isMergedInto(fix, master)).isTrue();
-  }
-
-  @Test
-  public void retrySubmitSingleChangeOnLockFailure() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-
-    PushOneCommit.Result change = createChange();
-    String id = change.getChangeId();
-    approve(id);
-
-    TestSubmitInput input = new TestSubmitInput();
-    input.generateLockFailures =
-        new ArrayDeque<>(
-            ImmutableList.of(
-                true, // Attempt 1: lock failure
-                false, // Attempt 2: success
-                false)); // Leftover value to check total number of calls.
-    submit(id, input);
-    assertMerged(id);
-
-    testRepo.git().fetch().call();
-    RevWalk rw = testRepo.getRevWalk();
-    RevCommit master = rw.parseCommit(getRemoteHead(project, "master"));
-    RevCommit patchSet = parseCurrentRevision(rw, change);
-    assertThat(rw.isMergedInto(patchSet, master)).isTrue();
-
-    assertThat(input.generateLockFailures).containsExactly(false);
-  }
-
-  @Test
-  public void retrySubmitAfterTornTopicOnLockFailure() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-    assume().that(isSubmitWholeTopicEnabled()).isTrue();
-
-    String topic = "test-topic";
-
-    TestRepository<?> repoA = createProjectWithPush("project-a", null, getSubmitType());
-    TestRepository<?> repoB = createProjectWithPush("project-b", null, getSubmitType());
-
-    PushOneCommit.Result change1 =
-        createChange(repoA, "master", "Change 1", "a.txt", "content", topic);
-    PushOneCommit.Result change2 =
-        createChange(repoB, "master", "Change 2", "b.txt", "content", topic);
-
-    approve(change1.getChangeId());
-    approve(change2.getChangeId());
-
-    TestSubmitInput input = new TestSubmitInput();
-    input.generateLockFailures =
-        new ArrayDeque<>(
-            ImmutableList.of(
-                false, // Change 1, attempt 1: success
-                true, // Change 2, attempt 1: lock failure
-                false, // Change 1, attempt 2: success
-                false, // Change 2, attempt 2: success
-                false)); // Leftover value to check total number of calls.
-    submit(change2.getChangeId(), input);
-
-    String expectedTopic = name(topic);
-    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
-    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
-
-    repoA.git().fetch().call();
-    RevWalk rwA = repoA.getRevWalk();
-    RevCommit masterA = rwA.parseCommit(getRemoteHead(name("project-a"), "master"));
-    RevCommit change1Ps = parseCurrentRevision(rwA, change1);
-    assertThat(rwA.isMergedInto(change1Ps, masterA)).isTrue();
-
-    repoB.git().fetch().call();
-    RevWalk rwB = repoB.getRevWalk();
-    RevCommit masterB = rwB.parseCommit(getRemoteHead(name("project-b"), "master"));
-    RevCommit change2Ps = parseCurrentRevision(rwB, change2);
-    assertThat(rwB.isMergedInto(change2Ps, masterB)).isTrue();
-
-    assertThat(input.generateLockFailures).containsExactly(false);
-  }
-
-  @Test
-  public void authorAndCommitDateAreEqual() throws Exception {
-    assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
-
-    ConfigInput ci = new ConfigInput();
-    ci.matchAuthorToCommitterDate = InheritableBoolean.TRUE;
-    gApi.projects().name(project.get()).config(ci);
-
-    RevCommit initialHead = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change = createChange("Change 1", "b", "b");
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
-
-    if (getSubmitType() == SubmitType.MERGE_IF_NECESSARY
-        || getSubmitType() == SubmitType.REBASE_IF_NECESSARY) {
-      // Merge another change so that change2 is not a fast-forward
-      submit(change.getChangeId());
-    }
-
-    submit(change2.getChangeId());
-    assertAuthorAndCommitDateEquals(getRemoteHead());
-  }
-
-  private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Exception {
-    for (PushOneCommit.Result change : changes) {
-      try (BatchUpdate bu =
-          batchUpdateFactory.create(db, project, userFactory.create(admin.id), TimeUtil.nowTs())) {
-        bu.addOp(
-            change.getChange().getId(),
-            new BatchUpdateOp() {
-              @Override
-              public boolean updateChange(ChangeContext ctx) throws OrmException {
-                ctx.getChange().setStatus(Change.Status.NEW);
-                ctx.getUpdate(ctx.getChange().currentPatchSetId()).setStatus(Change.Status.NEW);
-                return true;
-              }
-            });
-        bu.execute();
-      }
-    }
-  }
-
-  private void assertSubmitter(PushOneCommit.Result change) throws Exception {
-    ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
-    assertThat(info.messages).isNotNull();
-    Iterable<String> messages = Iterables.transform(info.messages, i -> i.message);
-    assertThat(messages).hasSize(3);
-    String last = Iterables.getLast(messages);
-    if (getSubmitType() == SubmitType.CHERRY_PICK) {
-      assertThat(last).startsWith("Change has been successfully cherry-picked as ");
-    } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
-      assertThat(last).startsWith("Change has been successfully rebased and submitted as");
-    } else {
-      assertThat(last).isEqualTo("Change has been successfully merged by Administrator");
-    }
-  }
-
-  @Override
-  protected void updateProjectInput(ProjectInput in) {
-    in.submitType = getSubmitType();
-    if (in.useContentMerge == InheritableBoolean.INHERIT) {
-      in.useContentMerge = InheritableBoolean.FALSE;
-    }
-  }
-
-  protected void submit(String changeId) throws Exception {
-    submit(changeId, new SubmitInput(), null, null);
-  }
-
-  protected void submit(String changeId, SubmitInput input) throws Exception {
-    submit(changeId, input, null, null);
-  }
-
-  protected void submitWithConflict(String changeId, String expectedError) throws Exception {
-    submit(changeId, new SubmitInput(), ResourceConflictException.class, expectedError);
-  }
-
-  protected void submit(
-      String changeId,
-      SubmitInput input,
-      Class<? extends RestApiException> expectedExceptionType,
-      String expectedExceptionMsg)
-      throws Exception {
-    approve(changeId);
-    if (expectedExceptionType == null) {
-      assertSubmittable(changeId);
-    }
-    try {
-      gApi.changes().id(changeId).current().submit(input);
-      if (expectedExceptionType != null) {
-        fail("Expected exception of type " + expectedExceptionType.getSimpleName());
-      }
-    } catch (RestApiException e) {
-      if (expectedExceptionType == null) {
-        throw e;
-      }
-      // More verbose than using assertThat and/or ExpectedException, but gives
-      // us the stack trace.
-      if (!expectedExceptionType.isAssignableFrom(e.getClass())
-          || !e.getMessage().equals(expectedExceptionMsg)) {
-        throw new AssertionError(
-            "Expected exception of type "
-                + expectedExceptionType.getSimpleName()
-                + " with message: \""
-                + expectedExceptionMsg
-                + "\" but got exception of type "
-                + e.getClass().getSimpleName()
-                + " with message \""
-                + e.getMessage()
-                + "\"",
-            e);
-      }
-      return;
-    }
-    ChangeInfo change = gApi.changes().id(changeId).info();
-    assertMerged(change.changeId);
-  }
-
-  protected void assertSubmittable(String changeId) throws Exception {
-    assertThat(get(changeId, SUBMITTABLE).submittable)
-        .named("submit bit on ChangeInfo")
-        .isEqualTo(true);
-    RevisionResource rsrc = parseCurrentRevisionResource(changeId);
-    UiAction.Description desc = submitHandler.getDescription(rsrc);
-    assertThat(desc.isVisible()).named("visible bit on submit action").isTrue();
-    assertThat(desc.isEnabled()).named("enabled bit on submit action").isTrue();
-  }
-
-  protected void assertChangeMergedEvents(String... expected) throws Exception {
-    eventRecorder.assertChangeMergedEvents(project.get(), "refs/heads/master", expected);
-  }
-
-  protected void assertRefUpdatedEvents(RevCommit... expected) throws Exception {
-    eventRecorder.assertRefUpdatedEvents(project.get(), "refs/heads/master", expected);
-  }
-
-  protected void assertCurrentRevision(String changeId, int expectedNum, ObjectId expectedId)
-      throws Exception {
-    ChangeInfo c = get(changeId, CURRENT_REVISION);
-    assertThat(c.currentRevision).isEqualTo(expectedId.name());
-    assertThat(c.revisions.get(expectedId.name())._number).isEqualTo(expectedNum);
-    try (Repository repo = repoManager.openRepository(new Project.NameKey(c.project))) {
-      String refName = new PatchSet.Id(new Change.Id(c._number), expectedNum).toRefName();
-      Ref ref = repo.exactRef(refName);
-      assertThat(ref).named(refName).isNotNull();
-      assertThat(ref.getObjectId()).isEqualTo(expectedId);
-    }
-  }
-
-  protected void assertNew(String changeId) throws Exception {
-    assertThat(get(changeId).status).isEqualTo(ChangeStatus.NEW);
-  }
-
-  protected void assertApproved(String changeId) throws Exception {
-    assertApproved(changeId, admin);
-  }
-
-  protected void assertApproved(String changeId, TestAccount user) throws Exception {
-    ChangeInfo c = get(changeId, DETAILED_LABELS);
-    LabelInfo cr = c.labels.get("Code-Review");
-    assertThat(cr.all).hasSize(1);
-    assertThat(cr.all.get(0).value).isEqualTo(2);
-    assertThat(new Account.Id(cr.all.get(0)._accountId)).isEqualTo(user.getId());
-  }
-
-  protected void assertMerged(String changeId) throws RestApiException {
-    ChangeStatus status = gApi.changes().id(changeId).info().status;
-    assertThat(status).isEqualTo(ChangeStatus.MERGED);
-  }
-
-  protected void assertPersonEquals(PersonIdent expected, PersonIdent actual) {
-    assertThat(actual.getEmailAddress()).isEqualTo(expected.getEmailAddress());
-    assertThat(actual.getName()).isEqualTo(expected.getName());
-    assertThat(actual.getTimeZone()).isEqualTo(expected.getTimeZone());
-  }
-
-  protected void assertAuthorAndCommitDateEquals(RevCommit commit) {
-    assertThat(commit.getAuthorIdent().getWhen()).isEqualTo(commit.getCommitterIdent().getWhen());
-    assertThat(commit.getAuthorIdent().getTimeZone())
-        .isEqualTo(commit.getCommitterIdent().getTimeZone());
-  }
-
-  protected void assertSubmitter(String changeId, int psId) throws Exception {
-    assertSubmitter(changeId, psId, admin);
-  }
-
-  protected void assertSubmitter(String changeId, int psId, TestAccount user) throws Exception {
-    Change c = getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
-    ChangeNotes cn = notesFactory.createChecked(db, c);
-    PatchSetApproval submitter =
-        approvalsUtil.getSubmitter(db, cn, new PatchSet.Id(cn.getChangeId(), psId));
-    assertThat(submitter).isNotNull();
-    assertThat(submitter.isLegacySubmit()).isTrue();
-    assertThat(submitter.getAccountId()).isEqualTo(user.getId());
-  }
-
-  protected void assertNoSubmitter(String changeId, int psId) throws Exception {
-    Change c = getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
-    ChangeNotes cn = notesFactory.createChecked(db, c);
-    PatchSetApproval submitter =
-        approvalsUtil.getSubmitter(db, cn, new PatchSet.Id(cn.getChangeId(), psId));
-    assertThat(submitter).isNull();
-  }
-
-  protected void assertCherryPick(TestRepository<?> testRepo, boolean contentMerge)
-      throws Exception {
-    assertRebase(testRepo, contentMerge);
-    RevCommit remoteHead = getRemoteHead();
-    assertThat(remoteHead.getFooterLines("Reviewed-On")).isNotEmpty();
-    assertThat(remoteHead.getFooterLines("Reviewed-By")).isNotEmpty();
-  }
-
-  protected void assertRebase(TestRepository<?> testRepo, boolean contentMerge) throws Exception {
-    Repository repo = testRepo.getRepository();
-    RevCommit localHead = getHead(repo);
-    RevCommit remoteHead = getRemoteHead();
-    assertThat(localHead.getId()).isNotEqualTo(remoteHead.getId());
-    assertThat(remoteHead.getParentCount()).isEqualTo(1);
-    if (!contentMerge) {
-      assertThat(getLatestRemoteDiff()).isEqualTo(getLatestDiff(repo));
-    }
-    assertThat(remoteHead.getShortMessage()).isEqualTo(localHead.getShortMessage());
-  }
-
-  protected List<RevCommit> getRemoteLog(Project.NameKey project, String branch) throws Exception {
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      rw.markStart(rw.parseCommit(repo.exactRef("refs/heads/" + branch).getObjectId()));
-      return Lists.newArrayList(rw);
-    }
-  }
-
-  protected List<RevCommit> getRemoteLog() throws Exception {
-    return getRemoteLog(project, "master");
-  }
-
-  protected void addOnSubmitValidationListener(OnSubmitValidationListener listener) {
-    assertThat(onSubmitValidatorHandle).isNull();
-    onSubmitValidatorHandle = onSubmitValidationListeners.add(listener);
-  }
-
-  private String getLatestDiff(Repository repo) throws Exception {
-    ObjectId oldTreeId = repo.resolve("HEAD~1^{tree}");
-    ObjectId newTreeId = repo.resolve("HEAD^{tree}");
-    return getLatestDiff(repo, oldTreeId, newTreeId);
-  }
-
-  private String getLatestRemoteDiff() throws Exception {
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId oldTreeId = repo.resolve("refs/heads/master~1^{tree}");
-      ObjectId newTreeId = repo.resolve("refs/heads/master^{tree}");
-      return getLatestDiff(repo, oldTreeId, newTreeId);
-    }
-  }
-
-  private String getLatestDiff(Repository repo, ObjectId oldTreeId, ObjectId newTreeId)
-      throws Exception {
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    try (DiffFormatter fmt = new DiffFormatter(out)) {
-      fmt.setRepository(repo);
-      fmt.format(oldTreeId, newTreeId);
-      fmt.flush();
-      return out.toString();
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
deleted file mode 100644
index b4d8557..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ /dev/null
@@ -1,181 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.change.Submit.TestSubmitInput;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.Test;
-
-public abstract class AbstractSubmitByMerge extends AbstractSubmit {
-
-  @Test
-  public void submitWithMerge() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit oldHead = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
-    submit(change2.getChangeId());
-    RevCommit head = getRemoteHead();
-    assertThat(head.getParentCount()).isEqualTo(2);
-    assertThat(head.getParent(0)).isEqualTo(oldHead);
-    assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge() throws Exception {
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
-    submit(change.getChangeId());
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
-    submit(change2.getChangeId());
-
-    RevCommit oldHead = getRemoteHead();
-    testRepo.reset(change.getCommit());
-    PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
-    submit(change3.getChangeId());
-    RevCommit head = getRemoteHead();
-    assertThat(head.getParentCount()).isEqualTo(2);
-    assertThat(head.getParent(0)).isEqualTo(oldHead);
-    assertThat(head.getParent(1)).isEqualTo(change3.getCommit());
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit oldHead = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
-    submitWithConflict(
-        change2.getChangeId(),
-        "Failed to submit 1 change due to the following problems:\n"
-            + "Change "
-            + change2.getChange().getId()
-            + ": "
-            + "Change could not be merged due to a path conflict. "
-            + "Please rebase the change locally "
-            + "and upload the rebased commit for review.");
-    assertThat(getRemoteHead()).isEqualTo(oldHead);
-  }
-
-  @Test
-  @TestProjectInput(createEmptyCommit = false)
-  public void submitMultipleCommitsToEmptyRepoAsFastForward() throws Exception {
-    PushOneCommit.Result change1 = createChange();
-    PushOneCommit.Result change2 = createChange();
-    approve(change1.getChangeId());
-    submit(change2.getChangeId());
-    assertThat(getRemoteHead().getId()).isEqualTo(change2.getCommit());
-  }
-
-  @Test
-  @TestProjectInput(createEmptyCommit = false)
-  public void submitMultipleCommitsToEmptyRepoWithOneMerge() throws Exception {
-    assume().that(isSubmitWholeTopicEnabled()).isTrue();
-    PushOneCommit.Result change1 =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "Change 1", "a", "a")
-            .to("refs/for/master/" + name("topic"));
-
-    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo, "Change 2", "b", "b");
-    push2.noParents();
-    PushOneCommit.Result change2 = push2.to("refs/for/master/" + name("topic"));
-    change2.assertOkStatus();
-
-    approve(change1.getChangeId());
-    submit(change2.getChangeId());
-
-    RevCommit head = getRemoteHead();
-    assertThat(head.getParents()).hasLength(2);
-    assertThat(head.getParent(0)).isEqualTo(change1.getCommit());
-    assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
-  }
-
-  @Test
-  public void repairChangeStateAfterFailure() throws Exception {
-    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
-    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
-
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-    RevCommit afterChange1Head = getRemoteHead();
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
-    Change.Id id2 = change2.getChange().getId();
-    TestSubmitInput failInput = new TestSubmitInput();
-    failInput.failAfterRefUpdates = true;
-    submit(
-        change2.getChangeId(),
-        failInput,
-        ResourceConflictException.class,
-        "Failing after ref updates");
-
-    // Bad: ref advanced but change wasn't updated.
-    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
-    ChangeInfo info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
-
-    RevCommit tip;
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
-      assertThat(rev1).isNotNull();
-
-      tip = rw.parseCommit(repo.exactRef("refs/heads/master").getObjectId());
-      assertThat(tip.getParentCount()).isEqualTo(2);
-      assertThat(tip.getParent(0)).isEqualTo(afterChange1Head);
-      assertThat(tip.getParent(1)).isEqualTo(change2.getCommit());
-    }
-
-    submit(change2.getChangeId(), new SubmitInput(), null, null);
-
-    // Change status and patch set entities were updated, and branch tip stayed
-    // the same.
-    info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
-    assertThat(Iterables.getLast(info.messages).message)
-        .isEqualTo("Change has been successfully merged by Administrator");
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(tip);
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
deleted file mode 100644
index 5dfc76d..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ /dev/null
@@ -1,458 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.acceptance.GitUtil.getChangeId;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.Submit.TestSubmitInput;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.Util;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.Test;
-
-public abstract class AbstractSubmitByRebase extends AbstractSubmit {
-
-  @Override
-  protected abstract SubmitType getSubmitType();
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithRebase() throws Exception {
-    submitWithRebase(admin);
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithRebaseWithoutAddPatchSetPermission() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.block(cfg, Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/*");
-    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
-    Util.allow(
-        cfg,
-        Permission.forLabel(Util.codeReview().getName()),
-        -2,
-        2,
-        REGISTERED_USERS,
-        "refs/heads/*");
-    saveProjectConfig(project, cfg);
-
-    submitWithRebase(user);
-  }
-
-  private void submitWithRebase(TestAccount submitter) throws Exception {
-    setApiUser(submitter);
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
-    submit(change2.getChangeId());
-    assertRebase(testRepo, false);
-    RevCommit headAfterSecondSubmit = getRemoteHead();
-    assertThat(headAfterSecondSubmit.getParent(0)).isEqualTo(headAfterFirstSubmit);
-    assertApproved(change2.getChangeId(), submitter);
-    assertCurrentRevision(change2.getChangeId(), 2, headAfterSecondSubmit);
-    assertSubmitter(change2.getChangeId(), 1, submitter);
-    assertSubmitter(change2.getChangeId(), 2, submitter);
-    assertPersonEquals(admin.getIdent(), headAfterSecondSubmit.getAuthorIdent());
-    assertPersonEquals(submitter.getIdent(), headAfterSecondSubmit.getCommitterIdent());
-
-    assertRefUpdatedEvents(
-        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(
-        change.getChangeId(),
-        headAfterFirstSubmit.name(),
-        change2.getChangeId(),
-        headAfterSecondSubmit.name());
-  }
-
-  @Test
-  public void submitWithRebaseMultipleChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content");
-    submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
-      assertCurrentRevision(change1.getChangeId(), 2, headAfterFirstSubmit);
-    } else {
-      assertThat(headAfterFirstSubmit.name()).isEqualTo(change1.getCommit().name());
-    }
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
-    assertThat(change2.getCommit().getParent(0)).isNotEqualTo(change1.getCommit());
-    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "third content");
-    PushOneCommit.Result change4 = createChange("Change 4", "d.txt", "fourth content");
-    approve(change2.getChangeId());
-    approve(change3.getChangeId());
-    submit(change4.getChangeId());
-
-    assertRebase(testRepo, false);
-    assertApproved(change2.getChangeId());
-    assertApproved(change3.getChangeId());
-    assertApproved(change4.getChangeId());
-
-    RevCommit headAfterSecondSubmit = parse(getRemoteHead());
-    assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo("Change 4");
-    assertThat(headAfterSecondSubmit).isNotEqualTo(change4.getCommit());
-    assertCurrentRevision(change4.getChangeId(), 2, headAfterSecondSubmit);
-
-    RevCommit parent = parse(headAfterSecondSubmit.getParent(0));
-    assertThat(parent.getShortMessage()).isEqualTo("Change 3");
-    assertThat(parent).isNotEqualTo(change3.getCommit());
-    assertCurrentRevision(change3.getChangeId(), 2, parent);
-
-    RevCommit grandparent = parse(parent.getParent(0));
-    assertThat(grandparent).isNotEqualTo(change2.getCommit());
-    assertCurrentRevision(change2.getChangeId(), 2, grandparent);
-
-    RevCommit greatgrandparent = parse(grandparent.getParent(0));
-    assertThat(greatgrandparent).isEqualTo(headAfterFirstSubmit);
-    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
-      assertCurrentRevision(change1.getChangeId(), 2, greatgrandparent);
-    } else {
-      assertCurrentRevision(change1.getChangeId(), 1, greatgrandparent);
-    }
-
-    assertRefUpdatedEvents(
-        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(
-        change1.getChangeId(),
-        headAfterFirstSubmit.name(),
-        change2.getChangeId(),
-        headAfterSecondSubmit.name(),
-        change3.getChangeId(),
-        headAfterSecondSubmit.name(),
-        change4.getChangeId(),
-        headAfterSecondSubmit.name());
-  }
-
-  @Test
-  public void submitWithRebaseMergeCommit() throws Exception {
-    /*
-       *  (HEAD, origin/master, origin/HEAD) Merge changes X,Y
-       |\
-       | *   Merge branch 'master' into origin/master
-       | |\
-       | | * SHA Added a
-       | |/
-       * | Before
-       |/
-       * Initial empty repository
-    */
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
-
-    PushOneCommit change2Push =
-        pushFactory.create(db, admin.getIdent(), testRepo, "Merge to master", "m.txt", "");
-    change2Push.setParents(ImmutableList.of(initialHead, change1.getCommit()));
-    PushOneCommit.Result change2 = change2Push.to("refs/for/master");
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("Before", "b.txt", "");
-
-    approve(change3.getChangeId());
-    submit(change3.getChangeId());
-
-    approve(change1.getChangeId());
-    approve(change2.getChangeId());
-    submit(change2.getChangeId());
-
-    RevCommit newHead = getRemoteHead();
-    assertThat(newHead.getParentCount()).isEqualTo(2);
-
-    RevCommit headParent1 = parse(newHead.getParent(0).getId());
-    RevCommit headParent2 = parse(newHead.getParent(1).getId());
-
-    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
-      assertCurrentRevision(change3.getChangeId(), 2, headParent1.getId());
-    } else {
-      assertThat(change3.getCommit().getId()).isEqualTo(headParent1.getId());
-    }
-    assertThat(headParent1.getParentCount()).isEqualTo(1);
-    assertThat(headParent1.getParent(0)).isEqualTo(initialHead);
-
-    assertThat(headParent2.getId()).isEqualTo(change2.getCommit().getId());
-    assertThat(headParent2.getParentCount()).isEqualTo(2);
-
-    RevCommit headGrandparent1 = parse(headParent2.getParent(0).getId());
-    RevCommit headGrandparent2 = parse(headParent2.getParent(1).getId());
-
-    assertThat(headGrandparent1.getId()).isEqualTo(initialHead.getId());
-    assertThat(headGrandparent2.getId()).isEqualTo(change1.getCommit().getId());
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
-    submitWithConflict(
-        change2.getChangeId(),
-        "Cannot rebase "
-            + change2.getCommit().name()
-            + ": The change could not be rebased due to a conflict during merge.");
-    RevCommit head = getRemoteHead();
-    assertThat(head).isEqualTo(headAfterFirstSubmit);
-    assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
-    assertNoSubmitter(change2.getChangeId(), 1);
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
-  }
-
-  @Test
-  public void repairChangeStateAfterFailure() throws Exception {
-    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
-    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
-
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
-    Change.Id id2 = change2.getChange().getId();
-    TestSubmitInput failInput = new TestSubmitInput();
-    failInput.failAfterRefUpdates = true;
-    submit(
-        change2.getChangeId(),
-        failInput,
-        ResourceConflictException.class,
-        "Failing after ref updates");
-    RevCommit headAfterFailedSubmit = getRemoteHead();
-
-    // Bad: ref advanced but change wasn't updated.
-    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
-    PatchSet.Id psId2 = new PatchSet.Id(id2, 2);
-    ChangeInfo info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
-    assertThat(getPatchSet(psId2)).isNull();
-
-    ObjectId rev2;
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
-      assertThat(rev1).isNotNull();
-
-      rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
-      assertThat(rev2).isNotNull();
-      assertThat(rev2).isNotEqualTo(rev1);
-      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(headAfterFirstSubmit);
-
-      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
-    }
-
-    submit(change2.getChangeId());
-    RevCommit headAfterSecondSubmit = getRemoteHead();
-    assertThat(headAfterSecondSubmit).isEqualTo(headAfterFailedSubmit);
-
-    // Change status and patch set entities were updated, and branch tip stayed
-    // the same.
-    info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(2);
-    PatchSet ps2 = getPatchSet(psId2);
-    assertThat(ps2).isNotNull();
-    assertThat(ps2.getRevision().get()).isEqualTo(rev2.name());
-    assertThat(Iterables.getLast(info.messages).message)
-        .isEqualTo(
-            "Change has been successfully rebased and submitted as "
-                + rev2.name()
-                + " by Administrator");
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
-    }
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-    assertChangeMergedEvents(
-        change.getChangeId(),
-        headAfterFirstSubmit.name(),
-        change2.getChangeId(),
-        headAfterSecondSubmit.name());
-  }
-
-  protected RevCommit parse(ObjectId id) throws Exception {
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit c = rw.parseCommit(id);
-      rw.parseBody(c);
-      return c;
-    }
-  }
-
-  @Test
-  public void submitAfterReorderOfCommits() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    // Create two commits and push.
-    RevCommit c1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    pushHead(testRepo, "refs/for/master", false);
-
-    String id1 = getChangeId(testRepo, c1).get();
-    String id2 = getChangeId(testRepo, c2).get();
-
-    // Swap the order of commits and push again.
-    testRepo.reset("HEAD~2");
-    testRepo.cherryPick(c2);
-    testRepo.cherryPick(c1);
-    pushHead(testRepo, "refs/for/master", false);
-
-    approve(id1);
-    approve(id2);
-    submit(id1);
-    RevCommit headAfterSubmit = getRemoteHead();
-
-    assertRefUpdatedEvents(initialHead, headAfterSubmit);
-    assertChangeMergedEvents(id2, headAfterSubmit.name(), id1, headAfterSubmit.name());
-  }
-
-  @Test
-  public void submitChangesAfterBranchOnSecond() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    PushOneCommit.Result change = createChange();
-    approve(change.getChangeId());
-
-    PushOneCommit.Result change2 = createChange();
-    approve(change2.getChangeId());
-    Project.NameKey project = change2.getChange().change().getProject();
-    Branch.NameKey branch = new Branch.NameKey(project, "branch");
-    createBranchWithRevision(branch, change2.getCommit().getName());
-    gApi.changes().id(change2.getChangeId()).current().submit();
-    assertMerged(change2.getChangeId());
-    assertMerged(change.getChangeId());
-
-    RevCommit newHead = getRemoteHead();
-    assertRefUpdatedEvents(initialHead, newHead);
-    assertChangeMergedEvents(
-        change.getChangeId(), newHead.name(), change2.getChangeId(), newHead.name());
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitFastForwardIdenticalTree() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "a");
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "a");
-
-    assertThat(change1.getCommit().getTree()).isEqualTo(change2.getCommit().getTree());
-
-    // for rebase if necessary, otherwise, the manual rebase of change2 will
-    // fail since change1 would be merged as fast forward
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change0 = createChange("Change 0", "b.txt", "b");
-    submit(change0.getChangeId());
-    RevCommit headAfterChange0 = getRemoteHead();
-    assertThat(headAfterChange0.getShortMessage()).isEqualTo("Change 0");
-
-    submit(change1.getChangeId());
-    RevCommit headAfterChange1 = getRemoteHead();
-    assertThat(headAfterChange1.getShortMessage()).isEqualTo("Change 1");
-    assertThat(headAfterChange0).isEqualTo(headAfterChange1.getParent(0));
-
-    // Do manual rebase first.
-    gApi.changes().id(change2.getChangeId()).current().rebase();
-    submit(change2.getChangeId());
-    RevCommit headAfterChange2 = getRemoteHead();
-    assertThat(headAfterChange2.getShortMessage()).isEqualTo("Change 2");
-    assertThat(headAfterChange1).isEqualTo(headAfterChange2.getParent(0));
-
-    ChangeInfo info2 = get(change2.getChangeId());
-    assertThat(info2.status).isEqualTo(ChangeStatus.MERGED);
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitChainOneByOne() throws Exception {
-    PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
-    PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
-    submit(change1.getChangeId());
-    submit(change2.getChangeId());
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitChainFailsOnRework() throws Exception {
-    PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
-    RevCommit headAfterChange1 = change1.getCommit();
-    PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
-    testRepo.reset(headAfterChange1);
-    change1 =
-        amendChange(change1.getChangeId(), "subject 1 amend", "fileName 2", "rework content 2");
-    submit(change1.getChangeId());
-    headAfterChange1 = getRemoteHead();
-
-    submitWithConflict(
-        change2.getChangeId(),
-        "Cannot rebase "
-            + change2.getCommit().getName()
-            + ": "
-            + "The change could not be rebased due to a conflict during merge.");
-    assertThat(getRemoteHead()).isEqualTo(headAfterChange1);
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitChainOneByOneManualRebase() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
-    PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
-
-    // for rebase if necessary, otherwise, the manual rebase of change2 will
-    // fail since change1 would be merged as fast forward
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change = createChange();
-    submit(change.getChangeId());
-
-    submit(change1.getChangeId());
-    // Do manual rebase first.
-    gApi.changes().id(change2.getChangeId()).current().rebase();
-    submit(change2.getChangeId());
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
deleted file mode 100644
index 72be321..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ /dev/null
@@ -1,455 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
-import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
-import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.extensions.api.changes.ActionVisitor;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.inject.Inject;
-import java.util.EnumSet;
-import java.util.Map;
-import java.util.Set;
-import java.util.TreeSet;
-import org.eclipse.jgit.lib.Config;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class ActionsIT extends AbstractDaemonTest {
-  @ConfigSuite.Config
-  public static Config submitWholeTopicEnabled() {
-    return submitWholeTopicEnabledConfig();
-  }
-
-  @Inject private ChangeJson.Factory changeJsonFactory;
-
-  @Inject private DynamicSet<ActionVisitor> actionVisitors;
-
-  private RegistrationHandle visitorHandle;
-
-  @Before
-  public void setUp() {
-    visitorHandle = null;
-  }
-
-  @After
-  public void tearDown() {
-    if (visitorHandle != null) {
-      visitorHandle.remove();
-    }
-  }
-
-  @Test
-  public void revisionActionsOneChangePerTopicUnapproved() throws Exception {
-    String changeId = createChangeWithTopic().getChangeId();
-    Map<String, ActionInfo> actions = getActions(changeId);
-    assertThat(actions).hasSize(3);
-    assertThat(actions).containsKey("cherrypick");
-    assertThat(actions).containsKey("rebase");
-    assertThat(actions).containsKey("description");
-  }
-
-  @Test
-  public void revisionActionsOneChangePerTopic() throws Exception {
-    String changeId = createChangeWithTopic().getChangeId();
-    approve(changeId);
-    Map<String, ActionInfo> actions = getActions(changeId);
-    commonActionsAssertions(actions);
-    // We want to treat a single change in a topic not as a whole topic,
-    // so regardless of how submitWholeTopic is configured:
-    noSubmitWholeTopicAssertions(actions, 1);
-  }
-
-  @Test
-  public void revisionActionsTwoChangesInTopic() throws Exception {
-    String changeId = createChangeWithTopic().getChangeId();
-    approve(changeId);
-    String changeId2 = createChangeWithTopic().getChangeId();
-    Map<String, ActionInfo> actions = getActions(changeId);
-    commonActionsAssertions(actions);
-    if (isSubmitWholeTopicEnabled()) {
-      ActionInfo info = actions.get("submit");
-      assertThat(info.enabled).isNull();
-      assertThat(info.label).isEqualTo("Submit whole topic");
-      assertThat(info.method).isEqualTo("POST");
-      assertThat(info.title).isEqualTo("This change depends on other changes which are not ready");
-    } else {
-      noSubmitWholeTopicAssertions(actions, 1);
-
-      assertThat(getActions(changeId2).get("submit")).isNull();
-      approve(changeId2);
-      noSubmitWholeTopicAssertions(getActions(changeId2), 2);
-    }
-  }
-
-  @Test
-  public void revisionActionsETag() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChangeWithTopic().getChangeId();
-    approve(change);
-    String etag1 = getETag(change);
-
-    approve(parent);
-    String etag2 = getETag(change);
-
-    String changeWithSameTopic = createChangeWithTopic().getChangeId();
-    String etag3 = getETag(change);
-
-    approve(changeWithSameTopic);
-    String etag4 = getETag(change);
-
-    if (isSubmitWholeTopicEnabled()) {
-      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
-    } else {
-      assertThat(etag2).isNotEqualTo(etag1);
-      assertThat(etag3).isEqualTo(etag2);
-      assertThat(etag4).isEqualTo(etag2);
-    }
-  }
-
-  @Test
-  public void revisionActionsAnonymousETag() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChangeWithTopic().getChangeId();
-    approve(change);
-
-    setApiUserAnonymous();
-    String etag1 = getETag(change);
-
-    setApiUser(admin);
-    approve(parent);
-
-    setApiUserAnonymous();
-    String etag2 = getETag(change);
-
-    setApiUser(admin);
-    String changeWithSameTopic = createChangeWithTopic().getChangeId();
-
-    setApiUserAnonymous();
-    String etag3 = getETag(change);
-
-    setApiUser(admin);
-    approve(changeWithSameTopic);
-
-    setApiUserAnonymous();
-    String etag4 = getETag(change);
-
-    if (isSubmitWholeTopicEnabled()) {
-      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
-    } else {
-      assertThat(etag2).isNotEqualTo(etag1);
-      assertThat(etag3).isEqualTo(etag2);
-      assertThat(etag4).isEqualTo(etag2);
-    }
-  }
-
-  @Test
-  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
-  public void revisionActionsAnonymousETagCherryPickStrategy() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChange().getChangeId();
-    approve(change);
-
-    setApiUserAnonymous();
-    String etag1 = getETag(change);
-
-    setApiUser(admin);
-    approve(parent);
-
-    setApiUserAnonymous();
-    String etag2 = getETag(change);
-    assertThat(etag2).isEqualTo(etag1);
-  }
-
-  @Test
-  public void revisionActionsTwoChangesInTopic_conflicting() throws Exception {
-    String changeId = createChangeWithTopic().getChangeId();
-    approve(changeId);
-
-    // create another change with the same topic
-    String changeId2 =
-        createChangeWithTopic(testRepo, "topic", "touching b", "b.txt", "real content")
-            .getChangeId();
-    int changeNum2 = gApi.changes().id(changeId2).info()._number;
-    approve(changeId2);
-
-    // collide with the other change in the same topic
-    testRepo.reset("HEAD~2");
-    String collidingChange =
-        createChangeWithTopic(
-                testRepo, "off_topic", "rewriting file b", "b.txt", "garbage\ngarbage\ngarbage")
-            .getChangeId();
-    gApi.changes().id(collidingChange).current().review(ReviewInput.approve());
-    gApi.changes().id(collidingChange).current().submit();
-
-    Map<String, ActionInfo> actions = getActions(changeId);
-    commonActionsAssertions(actions);
-    if (isSubmitWholeTopicEnabled()) {
-      ActionInfo info = actions.get("submit");
-      assertThat(info.enabled).isNull();
-      assertThat(info.label).isEqualTo("Submit whole topic");
-      assertThat(info.method).isEqualTo("POST");
-      assertThat(info.title).isEqualTo("Problems with change(s): " + changeNum2);
-    } else {
-      noSubmitWholeTopicAssertions(actions, 1);
-    }
-  }
-
-  @Test
-  public void revisionActionsTwoChangesInTopicWithAncestorReady() throws Exception {
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-    approve(changeId);
-    String changeId1 = createChangeWithTopic().getChangeId();
-    approve(changeId1);
-    // create another change with the same topic
-    String changeId2 = createChangeWithTopic().getChangeId();
-    approve(changeId2);
-    Map<String, ActionInfo> actions = getActions(changeId1);
-    commonActionsAssertions(actions);
-    if (isSubmitWholeTopicEnabled()) {
-      ActionInfo info = actions.get("submit");
-      assertThat(info.enabled).isTrue();
-      assertThat(info.label).isEqualTo("Submit whole topic");
-      assertThat(info.method).isEqualTo("POST");
-      assertThat(info.title)
-          .isEqualTo(
-              "Submit all 2 changes of the same "
-                  + "topic (3 changes including ancestors "
-                  + "and other changes related by topic)");
-    } else {
-      noSubmitWholeTopicAssertions(actions, 2);
-    }
-  }
-
-  @Test
-  public void revisionActionsReadyWithAncestors() throws Exception {
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-    approve(changeId);
-    String changeId1 = createChange().getChangeId();
-    approve(changeId1);
-    String changeId2 = createChangeWithTopic().getChangeId();
-    approve(changeId2);
-    Map<String, ActionInfo> actions = getActions(changeId2);
-    commonActionsAssertions(actions);
-    // The topic contains only one change, so standard text applies
-    noSubmitWholeTopicAssertions(actions, 3);
-  }
-
-  private void noSubmitWholeTopicAssertions(Map<String, ActionInfo> actions, int nrChanges) {
-    ActionInfo info = actions.get("submit");
-    assertThat(info.enabled).isTrue();
-    if (nrChanges == 1) {
-      assertThat(info.label).isEqualTo("Submit");
-    } else {
-      assertThat(info.label).isEqualTo("Submit including parents");
-    }
-    assertThat(info.method).isEqualTo("POST");
-    if (nrChanges == 1) {
-      assertThat(info.title).isEqualTo("Submit patch set 1 into master");
-    } else {
-      assertThat(info.title)
-          .isEqualTo(
-              String.format(
-                  "Submit patch set 1 and ancestors (%d changes altogether) into master",
-                  nrChanges));
-    }
-  }
-
-  @Test
-  public void changeActionVisitor() throws Exception {
-    String id = createChange().getChangeId();
-    ChangeInfo origChange = gApi.changes().id(id).get(CHANGE_ACTIONS);
-
-    class Visitor implements ActionVisitor {
-      @Override
-      public boolean visit(String name, ActionInfo actionInfo, ChangeInfo changeInfo) {
-        assertThat(changeInfo).isNotNull();
-        assertThat(changeInfo._number).isEqualTo(origChange._number);
-        if (name.equals("followup")) {
-          return false;
-        }
-        if (name.equals("abandon")) {
-          actionInfo.label = "Abandon All Hope";
-        }
-        return true;
-      }
-
-      @Override
-      public boolean visit(
-          String name, ActionInfo actionInfo, ChangeInfo changeInfo, RevisionInfo revisionInfo) {
-        throw new UnsupportedOperationException();
-      }
-    }
-
-    Map<String, ActionInfo> origActions = origChange.actions;
-    assertThat(origActions.keySet()).containsAllOf("followup", "abandon");
-    assertThat(origActions.get("abandon").label).isEqualTo("Abandon");
-
-    Visitor v = new Visitor();
-    visitorHandle = actionVisitors.add(v);
-
-    Map<String, ActionInfo> newActions =
-        gApi.changes().id(id).get(EnumSet.of(ListChangesOption.CHANGE_ACTIONS)).actions;
-
-    Set<String> expectedNames = new TreeSet<>(origActions.keySet());
-    expectedNames.remove("followup");
-    assertThat(newActions.keySet()).isEqualTo(expectedNames);
-
-    ActionInfo abandon = newActions.get("abandon");
-    assertThat(abandon).isNotNull();
-    assertThat(abandon.label).isEqualTo("Abandon All Hope");
-  }
-
-  @Test
-  public void currentRevisionActionVisitor() throws Exception {
-    String id = createChange().getChangeId();
-    amendChange(id);
-    ChangeInfo origChange = gApi.changes().id(id).get(CHANGE_ACTIONS);
-    Change.Id changeId = new Change.Id(origChange._number);
-
-    class Visitor implements ActionVisitor {
-      @Override
-      public boolean visit(String name, ActionInfo actionInfo, ChangeInfo changeInfo) {
-        return true; // Do nothing; implicitly called for CURRENT_ACTIONS.
-      }
-
-      @Override
-      public boolean visit(
-          String name, ActionInfo actionInfo, ChangeInfo changeInfo, RevisionInfo revisionInfo) {
-        assertThat(changeInfo).isNotNull();
-        assertThat(changeInfo._number).isEqualTo(origChange._number);
-        assertThat(revisionInfo).isNotNull();
-        assertThat(revisionInfo._number).isEqualTo(2);
-        if (name.equals("cherrypick")) {
-          return false;
-        }
-        if (name.equals("rebase")) {
-          actionInfo.label = "All Your Base";
-        }
-        return true;
-      }
-    }
-
-    Map<String, ActionInfo> origActions = gApi.changes().id(id).current().actions();
-    assertThat(origActions.keySet()).containsAllOf("cherrypick", "rebase");
-    assertThat(origActions.get("rebase").label).isEqualTo("Rebase");
-
-    Visitor v = new Visitor();
-    visitorHandle = actionVisitors.add(v);
-
-    // Test different codepaths within ActionJson...
-    // ...via revision API.
-    visitedCurrentRevisionActionsAssertions(origActions, gApi.changes().id(id).current().actions());
-
-    // ...via change API with option.
-    EnumSet<ListChangesOption> opts = EnumSet.of(CURRENT_ACTIONS, CURRENT_REVISION);
-    ChangeInfo changeInfo = gApi.changes().id(id).get(opts);
-    RevisionInfo revisionInfo = Iterables.getOnlyElement(changeInfo.revisions.values());
-    visitedCurrentRevisionActionsAssertions(origActions, revisionInfo.actions);
-
-    // ...via ChangeJson directly.
-    ChangeData cd = changeDataFactory.create(db, project, changeId);
-    revisionInfo =
-        changeJsonFactory
-            .create(opts)
-            .getRevisionInfo(cd, cd.patchSet(new PatchSet.Id(changeId, 1)));
-  }
-
-  private void visitedCurrentRevisionActionsAssertions(
-      Map<String, ActionInfo> origActions, Map<String, ActionInfo> newActions) {
-    assertThat(newActions).isNotNull();
-    Set<String> expectedNames = new TreeSet<>(origActions.keySet());
-    expectedNames.remove("cherrypick");
-    assertThat(newActions.keySet()).isEqualTo(expectedNames);
-
-    ActionInfo rebase = newActions.get("rebase");
-    assertThat(rebase).isNotNull();
-    assertThat(rebase.label).isEqualTo("All Your Base");
-  }
-
-  @Test
-  public void oldRevisionActionVisitor() throws Exception {
-    String id = createChange().getChangeId();
-    amendChange(id);
-    ChangeInfo origChange = gApi.changes().id(id).get(CHANGE_ACTIONS);
-
-    class Visitor implements ActionVisitor {
-      @Override
-      public boolean visit(String name, ActionInfo actionInfo, ChangeInfo changeInfo) {
-        return true; // Do nothing; implicitly called for CURRENT_ACTIONS.
-      }
-
-      @Override
-      public boolean visit(
-          String name, ActionInfo actionInfo, ChangeInfo changeInfo, RevisionInfo revisionInfo) {
-        assertThat(changeInfo).isNotNull();
-        assertThat(changeInfo._number).isEqualTo(origChange._number);
-        assertThat(revisionInfo).isNotNull();
-        assertThat(revisionInfo._number).isEqualTo(1);
-        if (name.equals("description")) {
-          actionInfo.label = "Describify";
-        }
-        return true;
-      }
-    }
-
-    Map<String, ActionInfo> origActions = gApi.changes().id(id).revision(1).actions();
-    assertThat(origActions.keySet()).containsExactly("description");
-    assertThat(origActions.get("description").label).isEqualTo("Edit Description");
-
-    Visitor v = new Visitor();
-    visitorHandle = actionVisitors.add(v);
-
-    // Unlike for the current revision, actions for old revisions are only available via the
-    // revision API.
-    Map<String, ActionInfo> newActions = gApi.changes().id(id).revision(1).actions();
-    assertThat(newActions).isNotNull();
-    assertThat(newActions.keySet()).isEqualTo(origActions.keySet());
-
-    ActionInfo description = newActions.get("description");
-    assertThat(description).isNotNull();
-    assertThat(description.label).isEqualTo("Describify");
-  }
-
-  private void commonActionsAssertions(Map<String, ActionInfo> actions) {
-    assertThat(actions).hasSize(4);
-    assertThat(actions).containsKey("cherrypick");
-    assertThat(actions).containsKey("submit");
-    assertThat(actions).containsKey("description");
-    assertThat(actions).containsKey("rebase");
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
deleted file mode 100644
index a905d38..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ /dev/null
@@ -1,193 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.Sandboxed;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.testutil.FakeEmailSender.Message;
-import com.google.gerrit.testutil.TestTimeUtil;
-import java.util.Iterator;
-import java.util.List;
-import org.eclipse.jgit.transport.RefSpec;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-import org.junit.Test;
-
-@NoHttpd
-public class AssigneeIT extends AbstractDaemonTest {
-
-  @BeforeClass
-  public static void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @AfterClass
-  public static void restoreTime() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @Test
-  public void getNoAssignee() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(getAssignee(r)).isNull();
-  }
-
-  @Test
-  public void addGetAssignee() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
-    assertThat(getAssignee(r)._accountId).isEqualTo(user.getId().get());
-
-    assertThat(sender.getMessages()).hasSize(1);
-    Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-  }
-
-  @Test
-  public void setNewAssigneeWhenExists() throws Exception {
-    PushOneCommit.Result r = createChange();
-    setAssignee(r, user.email);
-    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
-  }
-
-  @Test
-  public void getPastAssignees() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    PushOneCommit.Result r = createChange();
-    setAssignee(r, user.email);
-    setAssignee(r, admin.email);
-    List<AccountInfo> assignees = getPastAssignees(r);
-    assertThat(assignees).hasSize(2);
-    Iterator<AccountInfo> itr = assignees.iterator();
-    assertThat(itr.next()._accountId).isEqualTo(user.getId().get());
-    assertThat(itr.next()._accountId).isEqualTo(admin.getId().get());
-  }
-
-  @Test
-  public void assigneeAddedAsReviewer() throws Exception {
-    ReviewerState state;
-    // Assignee is added as CC, if back-end is reviewDb (that does not support
-    // CC) CC is stored as REVIEWER
-    if (notesMigration.readChanges()) {
-      state = ReviewerState.CC;
-    } else {
-      state = ReviewerState.REVIEWER;
-    }
-    PushOneCommit.Result r = createChange();
-    Iterable<AccountInfo> reviewers = getReviewers(r, state);
-    assertThat(reviewers).isNull();
-    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
-    reviewers = getReviewers(r, state);
-    assertThat(reviewers).hasSize(1);
-    AccountInfo reviewer = Iterables.getFirst(reviewers, null);
-    assertThat(reviewer._accountId).isEqualTo(user.getId().get());
-  }
-
-  @Test
-  public void setAlreadyExistingAssignee() throws Exception {
-    PushOneCommit.Result r = createChange();
-    setAssignee(r, user.email);
-    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
-  }
-
-  @Test
-  public void deleteAssignee() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
-    assertThat(deleteAssignee(r)._accountId).isEqualTo(user.getId().get());
-    assertThat(getAssignee(r)).isNull();
-  }
-
-  @Test
-  public void deleteAssigneeWhenNoAssignee() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(deleteAssignee(r)).isNull();
-  }
-
-  @Test
-  @Sandboxed
-  public void setAssigneeToInactiveUser() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.accounts().id(user.getId().get()).setActive(false);
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("is not active");
-    setAssignee(r, user.email);
-  }
-
-  @Test
-  public void setAssigneeForNonVisibleChange() throws Exception {
-    git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
-    testRepo.reset(RefNames.REFS_CONFIG);
-    PushOneCommit.Result r = createChange("refs/for/refs/meta/config");
-    exception.expect(AuthException.class);
-    exception.expectMessage("read not permitted");
-    setAssignee(r, user.email);
-  }
-
-  @Test
-  public void setAssigneeNotAllowedWithoutPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("not permitted");
-    setAssignee(r, user.email);
-  }
-
-  @Test
-  public void setAssigneeAllowedWithPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
-    grant(project, "refs/heads/master", Permission.EDIT_ASSIGNEE, false, REGISTERED_USERS);
-    setApiUser(user);
-    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
-  }
-
-  private AccountInfo getAssignee(PushOneCommit.Result r) throws Exception {
-    return gApi.changes().id(r.getChange().getId().get()).getAssignee();
-  }
-
-  private List<AccountInfo> getPastAssignees(PushOneCommit.Result r) throws Exception {
-    return gApi.changes().id(r.getChange().getId().get()).getPastAssignees();
-  }
-
-  private Iterable<AccountInfo> getReviewers(PushOneCommit.Result r, ReviewerState state)
-      throws Exception {
-    return get(r.getChangeId()).reviewers.get(state);
-  }
-
-  private AccountInfo setAssignee(PushOneCommit.Result r, String identifieer) throws Exception {
-    AssigneeInput input = new AssigneeInput();
-    input.assignee = identifieer;
-    return gApi.changes().id(r.getChange().getId().get()).setAssignee(input);
-  }
-
-  private AccountInfo deleteAssignee(PushOneCommit.Result r) throws Exception {
-    return gApi.changes().id(r.getChange().getId().get()).deleteAssignee();
-  }
-}
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
deleted file mode 100644
index b7ed2e8..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
+++ /dev/null
@@ -1,38 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-SUBMIT_UTIL_SRCS = glob(["AbstractSubmit*.java"])
-
-SUBMIT_TESTS = glob(["Submit*IT.java"])
-
-OTHER_TESTS = glob(
-    ["*IT.java"],
-    exclude = SUBMIT_TESTS,
-)
-
-acceptance_tests(
-    srcs = OTHER_TESTS,
-    group = "rest_change_other",
-    labels = ["rest"],
-    deps = [
-        ":submit_util",
-        "//lib/joda:joda-time",
-    ],
-)
-
-acceptance_tests(
-    srcs = SUBMIT_TESTS,
-    group = "rest_change_submit",
-    labels = ["rest"],
-    deps = [
-        ":submit_util",
-    ],
-)
-
-java_library(
-    name = "submit_util",
-    testonly = 1,
-    srcs = SUBMIT_UTIL_SRCS,
-    deps = [
-        "//gerrit-acceptance-tests:lib",
-    ],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
deleted file mode 100644
index 4c49e4c..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ /dev/null
@@ -1,111 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.TestTimeUtil;
-import java.util.Iterator;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-@RunWith(ConfigSuite.class)
-public class ChangeMessagesIT extends AbstractDaemonTest {
-  private String systemTimeZone;
-
-  @Before
-  public void setTimeForTesting() {
-    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
-  }
-
-  @Test
-  public void messagesNotReturnedByDefault() throws Exception {
-    String changeId = createChange().getChangeId();
-    postMessage(changeId, "Some nits need to be fixed.");
-    ChangeInfo c = info(changeId);
-    assertThat(c.messages).isNull();
-  }
-
-  @Test
-  public void defaultMessage() throws Exception {
-    String changeId = createChange().getChangeId();
-    ChangeInfo c = get(changeId);
-    assertThat(c.messages).isNotNull();
-    assertThat(c.messages).hasSize(1);
-    assertThat(c.messages.iterator().next().message).isEqualTo("Uploaded patch set 1.");
-  }
-
-  @Test
-  public void messagesReturnedInChronologicalOrder() throws Exception {
-    String changeId = createChange().getChangeId();
-    String firstMessage = "Some nits need to be fixed.";
-    postMessage(changeId, firstMessage);
-    String secondMessage = "I like this feature.";
-    postMessage(changeId, secondMessage);
-    ChangeInfo c = get(changeId);
-    assertThat(c.messages).isNotNull();
-    assertThat(c.messages).hasSize(3);
-    Iterator<ChangeMessageInfo> it = c.messages.iterator();
-    assertThat(it.next().message).isEqualTo("Uploaded patch set 1.");
-    assertMessage(firstMessage, it.next().message);
-    assertMessage(secondMessage, it.next().message);
-  }
-
-  @Test
-  public void postMessageWithTag() throws Exception {
-    String changeId = createChange().getChangeId();
-    String tag = "jenkins";
-    String msg = "Message with tag.";
-    postMessage(changeId, msg, tag);
-    ChangeInfo c = get(changeId);
-    assertThat(c.messages).isNotNull();
-    assertThat(c.messages).hasSize(2);
-    Iterator<ChangeMessageInfo> it = c.messages.iterator();
-    assertThat(it.next().message).isEqualTo("Uploaded patch set 1.");
-    ChangeMessageInfo actual = it.next();
-    assertMessage(msg, actual.message);
-    assertThat(actual.tag).isEqualTo(tag);
-  }
-
-  private void assertMessage(String expected, String actual) {
-    assertThat(actual).isEqualTo("Patch Set 1:\n\n" + expected);
-  }
-
-  private void postMessage(String changeId, String msg) throws Exception {
-    postMessage(changeId, msg, null);
-  }
-
-  private void postMessage(String changeId, String msg, String tag) throws Exception {
-    ReviewInput in = new ReviewInput();
-    in.message = msg;
-    in.tag = tag;
-    gApi.changes().id(changeId).current().review(in);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
deleted file mode 100644
index f4526e5..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ /dev/null
@@ -1,325 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.ConfigInput;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.testutil.FakeEmailSender.Message;
-import java.util.List;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class ChangeReviewersByEmailIT extends AbstractDaemonTest {
-
-  @Before
-  public void setUp() throws Exception {
-    ConfigInput conf = new ConfigInput();
-    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
-    gApi.projects().name(project.get()).config(conf);
-  }
-
-  @Test
-  public void addByEmail() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
-
-    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
-      PushOneCommit.Result r = createChange();
-
-      AddReviewerInput input = new AddReviewerInput();
-      input.reviewer = toRfcAddressString(acc);
-      input.state = state;
-      gApi.changes().id(r.getChangeId()).addReviewer(input);
-
-      ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
-      assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
-      // All reviewers added by email should be removable
-      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(acc));
-    }
-  }
-
-  @Test
-  public void addByEmailAndById() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    AccountInfo byEmail = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
-    AccountInfo byId = new AccountInfo(user.id.get());
-
-    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
-      PushOneCommit.Result r = createChange();
-
-      AddReviewerInput inputByEmail = new AddReviewerInput();
-      inputByEmail.reviewer = toRfcAddressString(byEmail);
-      inputByEmail.state = state;
-      gApi.changes().id(r.getChangeId()).addReviewer(inputByEmail);
-
-      AddReviewerInput inputById = new AddReviewerInput();
-      inputById.reviewer = user.email;
-      inputById.state = state;
-      gApi.changes().id(r.getChangeId()).addReviewer(inputById);
-
-      ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
-      assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(byId, byEmail)));
-      // All reviewers (both by id and by email) should be removable
-      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(byId, byEmail));
-    }
-  }
-
-  @Test
-  public void removeByEmail() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
-
-    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
-      PushOneCommit.Result r = createChange();
-
-      AddReviewerInput addInput = new AddReviewerInput();
-      addInput.reviewer = toRfcAddressString(acc);
-      addInput.state = state;
-      gApi.changes().id(r.getChangeId()).addReviewer(addInput);
-
-      gApi.changes().id(r.getChangeId()).reviewer(acc.email).remove();
-
-      ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
-      assertThat(info.reviewers).isEmpty();
-    }
-  }
-
-  @Test
-  public void convertFromCCToReviewer() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
-
-    PushOneCommit.Result r = createChange();
-
-    AddReviewerInput addInput = new AddReviewerInput();
-    addInput.reviewer = toRfcAddressString(acc);
-    addInput.state = ReviewerState.CC;
-    gApi.changes().id(r.getChangeId()).addReviewer(addInput);
-
-    AddReviewerInput modifyInput = new AddReviewerInput();
-    modifyInput.reviewer = addInput.reviewer;
-    modifyInput.state = ReviewerState.REVIEWER;
-    gApi.changes().id(r.getChangeId()).addReviewer(modifyInput);
-
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
-    assertThat(info.reviewers)
-        .isEqualTo(ImmutableMap.of(ReviewerState.REVIEWER, ImmutableList.of(acc)));
-  }
-
-  @Test
-  public void addedReviewersGetNotified() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
-
-    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
-      PushOneCommit.Result r = createChange();
-
-      AddReviewerInput input = new AddReviewerInput();
-      input.reviewer = toRfcAddressString(acc);
-      input.state = state;
-      gApi.changes().id(r.getChangeId()).addReviewer(input);
-
-      List<Message> messages = sender.getMessages();
-      assertThat(messages).hasSize(1);
-      assertThat(messages.get(0).rcpt()).containsExactly(Address.parse(input.reviewer));
-      sender.clear();
-    }
-  }
-
-  @Test
-  public void removingReviewerTriggersNotification() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
-
-    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
-      PushOneCommit.Result r = createChange();
-
-      AddReviewerInput addInput = new AddReviewerInput();
-      addInput.reviewer = toRfcAddressString(acc);
-      addInput.state = state;
-      gApi.changes().id(r.getChangeId()).addReviewer(addInput);
-
-      // Review change as user
-      ReviewInput reviewInput = new ReviewInput();
-      reviewInput.message = "I have a comment";
-      setApiUser(user);
-      revision(r).review(reviewInput);
-      setApiUser(admin);
-
-      sender.clear();
-
-      // Delete as admin
-      gApi.changes().id(r.getChangeId()).reviewer(addInput.reviewer).remove();
-
-      List<Message> messages = sender.getMessages();
-      assertThat(messages).hasSize(1);
-      assertThat(messages.get(0).rcpt())
-          .containsExactly(Address.parse(addInput.reviewer), user.emailAddress);
-      sender.clear();
-    }
-  }
-
-  @Test
-  public void reviewerAndCCReceiveRegularNotification() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
-
-    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
-      PushOneCommit.Result r = createChange();
-
-      AddReviewerInput input = new AddReviewerInput();
-      input.reviewer = toRfcAddressString(acc);
-      input.state = state;
-      gApi.changes().id(r.getChangeId()).addReviewer(input);
-      sender.clear();
-
-      gApi.changes()
-          .id(r.getChangeId())
-          .revision(r.getCommit().name())
-          .review(ReviewInput.approve());
-
-      assertNotifyCc(Address.parse(input.reviewer));
-    }
-  }
-
-  @Test
-  public void reviewerAndCCReceiveSameEmail() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    PushOneCommit.Result r = createChange();
-    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
-      for (int i = 0; i < 10; i++) {
-        AddReviewerInput input = new AddReviewerInput();
-        input.reviewer = String.format("%s-%s@gerritcodereview.com", state, i);
-        input.state = state;
-        gApi.changes().id(r.getChangeId()).addReviewer(input);
-      }
-    }
-
-    // Also add user as a regular reviewer
-    AddReviewerInput input = new AddReviewerInput();
-    input.reviewer = user.email;
-    input.state = ReviewerState.REVIEWER;
-    gApi.changes().id(r.getChangeId()).addReviewer(input);
-
-    sender.clear();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    // Assert that only one email was sent out to everyone
-    assertThat(sender.getMessages()).hasSize(1);
-  }
-
-  @Test
-  public void addingMultipleReviewersAndCCsAtOnceSendsOnlyOneEmail() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    PushOneCommit.Result r = createChange();
-    ReviewInput reviewInput = new ReviewInput();
-    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
-      for (int i = 0; i < 10; i++) {
-        reviewInput.reviewer(String.format("%s-%s@gerritcodereview.com", state, i), state, true);
-      }
-    }
-    assertThat(reviewInput.reviewers).hasSize(20);
-
-    sender.clear();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(reviewInput);
-    assertThat(sender.getMessages()).hasSize(1);
-  }
-
-  @Test
-  public void rejectMissingEmail() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    PushOneCommit.Result r = createChange();
-
-    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("");
-    assertThat(result.error).isEqualTo(" is not a valid user identifier");
-    assertThat(result.reviewers).isNull();
-  }
-
-  @Test
-  public void rejectMalformedEmail() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    PushOneCommit.Result r = createChange();
-
-    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@");
-    assertThat(result.error).isEqualTo("Foo Bar <foo.bar@ is not a valid user identifier");
-    assertThat(result.reviewers).isNull();
-  }
-
-  @Test
-  public void rejectWhenFeatureIsDisabled() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    ConfigInput conf = new ConfigInput();
-    conf.enableReviewerByEmail = InheritableBoolean.FALSE;
-    gApi.projects().name(project.get()).config(conf);
-
-    PushOneCommit.Result r = createChange();
-
-    AddReviewerResult result =
-        gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@gerritcodereview.com>");
-    assertThat(result.error)
-        .isEqualTo(
-            "Foo Bar <foo.bar@gerritcodereview.com> does not identify a registered user or group");
-    assertThat(result.reviewers).isNull();
-  }
-
-  @Test
-  public void reviewersByEmailAreServedFromIndex() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
-
-    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
-      PushOneCommit.Result r = createChange();
-
-      AddReviewerInput input = new AddReviewerInput();
-      input.reviewer = toRfcAddressString(acc);
-      input.state = state;
-      gApi.changes().id(r.getChangeId()).addReviewer(input);
-
-      notesMigration.setFailOnLoadForTest(true);
-      try {
-        ChangeInfo info =
-            Iterables.getOnlyElement(
-                gApi.changes().query(r.getChangeId()).withOption(DETAILED_LABELS).get());
-        assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
-      } finally {
-        notesMigration.setFailOnLoadForTest(false);
-      }
-    }
-  }
-
-  private static String toRfcAddressString(AccountInfo info) {
-    return (new Address(info.name, info.email)).toString();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
deleted file mode 100644
index f291876..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ /dev/null
@@ -1,879 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.extensions.client.ReviewerState.CC;
-import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
-import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
-import static javax.servlet.http.HttpServletResponse.SC_OK;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.Sandboxed;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.NotifyInfo;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewResult;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.change.PostReviewers;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.testutil.FakeEmailSender.Message;
-import com.google.gson.stream.JsonReader;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import org.junit.Test;
-
-public class ChangeReviewersIT extends AbstractDaemonTest {
-  @Test
-  public void addGroupAsReviewer() throws Exception {
-    // Set up two groups, one that is too large too add as reviewer, and one
-    // that is too large to add without confirmation.
-    String largeGroup = createGroup("largeGroup");
-    String mediumGroup = createGroup("mediumGroup");
-
-    int largeGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS + 1;
-    int mediumGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
-    List<TestAccount> users = createAccounts(largeGroupSize, "addGroupAsReviewer");
-    List<String> largeGroupUsernames = new ArrayList<>(mediumGroupSize);
-    for (TestAccount u : users) {
-      largeGroupUsernames.add(u.username);
-    }
-    List<String> mediumGroupUsernames = largeGroupUsernames.subList(0, mediumGroupSize);
-    gApi.groups()
-        .id(largeGroup)
-        .addMembers(largeGroupUsernames.toArray(new String[largeGroupSize]));
-    gApi.groups()
-        .id(mediumGroup)
-        .addMembers(mediumGroupUsernames.toArray(new String[mediumGroupSize]));
-
-    // Attempt to add overly large group as reviewers.
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    AddReviewerResult result = addReviewer(changeId, largeGroup);
-    assertThat(result.input).isEqualTo(largeGroup);
-    assertThat(result.confirm).isNull();
-    assertThat(result.error).contains("has too many members to add them all as reviewers");
-    assertThat(result.reviewers).isNull();
-
-    // Attempt to add medium group without confirmation.
-    result = addReviewer(changeId, mediumGroup);
-    assertThat(result.input).isEqualTo(mediumGroup);
-    assertThat(result.confirm).isTrue();
-    assertThat(result.error)
-        .contains("has " + mediumGroupSize + " members. Do you want to add them all as reviewers?");
-    assertThat(result.reviewers).isNull();
-
-    // Add medium group with confirmation.
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = mediumGroup;
-    in.confirmed = true;
-    result = addReviewer(changeId, in);
-    assertThat(result.input).isEqualTo(mediumGroup);
-    assertThat(result.confirm).isNull();
-    assertThat(result.error).isNull();
-    assertThat(result.reviewers).hasSize(mediumGroupSize);
-
-    // Verify that group members were added as reviewers.
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    assertReviewers(c, REVIEWER, users.subList(0, mediumGroupSize));
-  }
-
-  @Test
-  public void addCcAccount() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    in.state = CC;
-    AddReviewerResult result = addReviewer(changeId, in);
-
-    assertThat(result.input).isEqualTo(user.email);
-    assertThat(result.confirm).isNull();
-    assertThat(result.error).isNull();
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    if (notesMigration.readChanges()) {
-      assertThat(result.reviewers).isNull();
-      assertThat(result.ccs).hasSize(1);
-      AccountInfo ai = result.ccs.get(0);
-      assertThat(ai._accountId).isEqualTo(user.id.get());
-      assertReviewers(c, CC, user);
-    } else {
-      assertThat(result.ccs).isNull();
-      assertThat(result.reviewers).hasSize(1);
-      AccountInfo ai = result.reviewers.get(0);
-      assertThat(ai._accountId).isEqualTo(user.id.get());
-      assertReviewers(c, REVIEWER, user);
-    }
-
-    // Verify email was sent to CCed account.
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    if (notesMigration.readChanges()) {
-      assertThat(m.body()).contains(admin.fullName + " has uploaded this change for review.");
-    } else {
-      assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
-      assertThat(m.body()).contains("I'd like you to do a code review.");
-    }
-  }
-
-  @Test
-  public void addCcGroup() throws Exception {
-    List<TestAccount> users = createAccounts(6, "addCcGroup");
-    List<String> usernames = new ArrayList<>(6);
-    for (TestAccount u : users) {
-      usernames.add(u.username);
-    }
-
-    List<TestAccount> firstUsers = users.subList(0, 3);
-    List<String> firstUsernames = usernames.subList(0, 3);
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = createGroup("cc1");
-    in.state = CC;
-    gApi.groups()
-        .id(in.reviewer)
-        .addMembers(firstUsernames.toArray(new String[firstUsernames.size()]));
-    AddReviewerResult result = addReviewer(changeId, in);
-
-    assertThat(result.input).isEqualTo(in.reviewer);
-    assertThat(result.confirm).isNull();
-    assertThat(result.error).isNull();
-    if (notesMigration.readChanges()) {
-      assertThat(result.reviewers).isNull();
-    } else {
-      assertThat(result.ccs).isNull();
-    }
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    if (notesMigration.readChanges()) {
-      assertReviewers(c, CC, firstUsers);
-    } else {
-      assertReviewers(c, REVIEWER, firstUsers);
-      assertReviewers(c, CC);
-    }
-
-    // Verify emails were sent to each of the group's accounts.
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    List<Address> expectedAddresses = new ArrayList<>(firstUsers.size());
-    for (TestAccount u : firstUsers) {
-      expectedAddresses.add(u.emailAddress);
-    }
-    assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
-
-    // CC a group that overlaps with some existing reviewers and CCed accounts.
-    TestAccount reviewer =
-        accountCreator.create(name("reviewer"), "addCcGroup-reviewer@example.com", "Reviewer");
-    result = addReviewer(changeId, reviewer.username);
-    assertThat(result.error).isNull();
-    sender.clear();
-    in.reviewer = createGroup("cc2");
-    gApi.groups().id(in.reviewer).addMembers(usernames.toArray(new String[usernames.size()]));
-    gApi.groups().id(in.reviewer).addMembers(reviewer.username);
-    result = addReviewer(changeId, in);
-    assertThat(result.input).isEqualTo(in.reviewer);
-    assertThat(result.confirm).isNull();
-    assertThat(result.error).isNull();
-    c = gApi.changes().id(r.getChangeId()).get();
-    if (notesMigration.readChanges()) {
-      assertThat(result.ccs).hasSize(3);
-      assertThat(result.reviewers).isNull();
-      assertReviewers(c, REVIEWER, reviewer);
-      assertReviewers(c, CC, users);
-    } else {
-      assertThat(result.ccs).isNull();
-      assertThat(result.reviewers).hasSize(3);
-      List<TestAccount> expectedUsers = new ArrayList<>(users.size() + 2);
-      expectedUsers.addAll(users);
-      expectedUsers.add(reviewer);
-      assertReviewers(c, REVIEWER, expectedUsers);
-    }
-
-    messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    m = messages.get(0);
-    expectedAddresses = new ArrayList<>(4);
-    for (int i = 0; i < 3; i++) {
-      expectedAddresses.add(users.get(users.size() - i - 1).emailAddress);
-    }
-    if (!notesMigration.readChanges()) {
-      for (int i = 0; i < 3; i++) {
-        expectedAddresses.add(users.get(i).emailAddress);
-      }
-    }
-    expectedAddresses.add(reviewer.emailAddress);
-    assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
-  }
-
-  @Test
-  public void transitionCcToReviewer() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    in.state = CC;
-    addReviewer(changeId, in);
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    if (notesMigration.readChanges()) {
-      assertReviewers(c, REVIEWER);
-      assertReviewers(c, CC, user);
-    } else {
-      assertReviewers(c, REVIEWER, user);
-      assertReviewers(c, CC);
-    }
-
-    in.state = REVIEWER;
-    addReviewer(changeId, in);
-    c = gApi.changes().id(r.getChangeId()).get();
-    assertReviewers(c, REVIEWER, user);
-    assertReviewers(c, CC);
-  }
-
-  @Test
-  public void driveByComment() throws Exception {
-    // Create change owned by admin.
-    PushOneCommit.Result r = createChange();
-
-    // Post drive-by message as user.
-    ReviewInput input = new ReviewInput().message("hello");
-    RestResponse resp =
-        userRestSession.post(
-            "/changes/" + r.getChangeId() + "/revisions/" + r.getCommit().getName() + "/review",
-            input);
-    ReviewResult result = readContentFromJson(resp, 200, ReviewResult.class);
-    assertThat(result.labels).isNull();
-    assertThat(result.reviewers).isNull();
-
-    // Verify user is added to CC list.
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    if (notesMigration.readChanges()) {
-      assertReviewers(c, REVIEWER);
-      assertReviewers(c, CC, user);
-    } else {
-      // If we aren't reading from NoteDb, the user will appear as a
-      // reviewer.
-      assertReviewers(c, REVIEWER, user);
-      assertReviewers(c, CC);
-    }
-  }
-
-  @Test
-  public void addSelfAsReviewer() throws Exception {
-    // Create change owned by admin.
-    PushOneCommit.Result r = createChange();
-
-    // user adds self as REVIEWER.
-    ReviewInput input = new ReviewInput().reviewer(user.username);
-    RestResponse resp =
-        userRestSession.post(
-            "/changes/" + r.getChangeId() + "/revisions/" + r.getCommit().getName() + "/review",
-            input);
-    ReviewResult result = readContentFromJson(resp, 200, ReviewResult.class);
-    assertThat(result.labels).isNull();
-    assertThat(result.reviewers).isNotNull();
-    assertThat(result.reviewers).hasSize(1);
-
-    // Verify reviewer state.
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    assertReviewers(c, REVIEWER, user);
-    assertReviewers(c, CC);
-    LabelInfo label = c.labels.get("Code-Review");
-    assertThat(label).isNotNull();
-    assertThat(label.all).isNotNull();
-    assertThat(label.all).hasSize(1);
-    ApprovalInfo approval = label.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.getId().get());
-  }
-
-  @Test
-  public void addSelfAsCc() throws Exception {
-    // Create change owned by admin.
-    PushOneCommit.Result r = createChange();
-
-    // user adds self as CC.
-    ReviewInput input = new ReviewInput().reviewer(user.username, CC, false);
-    RestResponse resp =
-        userRestSession.post(
-            "/changes/" + r.getChangeId() + "/revisions/" + r.getCommit().getName() + "/review",
-            input);
-    ReviewResult result = readContentFromJson(resp, 200, ReviewResult.class);
-    assertThat(result.labels).isNull();
-    assertThat(result.reviewers).isNotNull();
-    assertThat(result.reviewers).hasSize(1);
-
-    // Verify reviewer state.
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    if (notesMigration.readChanges()) {
-      assertReviewers(c, REVIEWER);
-      assertReviewers(c, CC, user);
-      // Verify no approvals were added.
-      assertThat(c.labels).isNotNull();
-      LabelInfo label = c.labels.get("Code-Review");
-      assertThat(label).isNotNull();
-      assertThat(label.all).isNull();
-    } else {
-      // When approvals are stored in ReviewDb, we still create a label for
-      // the reviewing user, and force them into the REVIEWER state.
-      assertReviewers(c, REVIEWER, user);
-      assertReviewers(c, CC);
-      LabelInfo label = c.labels.get("Code-Review");
-      assertThat(label).isNotNull();
-      assertThat(label.all).isNotNull();
-      assertThat(label.all).hasSize(1);
-      ApprovalInfo approval = label.all.get(0);
-      assertThat(approval._accountId).isEqualTo(user.getId().get());
-    }
-  }
-
-  @Test
-  public void reviewerReplyWithoutVote() throws Exception {
-    // Create change owned by admin.
-    PushOneCommit.Result r = createChange();
-
-    // Verify reviewer state.
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    assertReviewers(c, REVIEWER);
-    assertReviewers(c, CC);
-    LabelInfo label = c.labels.get("Code-Review");
-    assertThat(label).isNotNull();
-    assertThat(label.all).isNull();
-
-    // Add user as REVIEWER.
-    ReviewInput input = new ReviewInput().reviewer(user.username);
-    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
-    assertThat(result.labels).isNull();
-    assertThat(result.reviewers).isNotNull();
-    assertThat(result.reviewers).hasSize(1);
-
-    // Verify reviewer state. Both admin and user should be REVIEWERs now,
-    // because admin gets forced into REVIEWER state by virtue of being owner.
-    c = gApi.changes().id(r.getChangeId()).get();
-    assertReviewers(c, REVIEWER, admin, user);
-    assertReviewers(c, CC);
-    label = c.labels.get("Code-Review");
-    assertThat(label).isNotNull();
-    assertThat(label.all).isNotNull();
-    assertThat(label.all).hasSize(2);
-    Map<Integer, Integer> approvals = new HashMap<>();
-    for (ApprovalInfo approval : label.all) {
-      approvals.put(approval._accountId, approval.value);
-    }
-    assertThat(approvals).containsEntry(admin.getId().get(), 0);
-    assertThat(approvals).containsEntry(user.getId().get(), 0);
-
-    // Comment as user without voting. This should delete the approval and
-    // then replace it with the default value.
-    input = new ReviewInput().message("hello");
-    RestResponse resp =
-        userRestSession.post(
-            "/changes/" + r.getChangeId() + "/revisions/" + r.getCommit().getName() + "/review",
-            input);
-    result = readContentFromJson(resp, 200, ReviewResult.class);
-    assertThat(result.labels).isNull();
-
-    // Verify reviewer state.
-    c = gApi.changes().id(r.getChangeId()).get();
-    assertReviewers(c, REVIEWER, admin, user);
-    assertReviewers(c, CC);
-    label = c.labels.get("Code-Review");
-    assertThat(label).isNotNull();
-    assertThat(label.all).isNotNull();
-    assertThat(label.all).hasSize(2);
-    approvals.clear();
-    for (ApprovalInfo approval : label.all) {
-      approvals.put(approval._accountId, approval.value);
-    }
-    assertThat(approvals).containsEntry(admin.getId().get(), 0);
-    assertThat(approvals).containsEntry(user.getId().get(), 0);
-  }
-
-  @Test
-  public void reviewAndAddReviewers() throws Exception {
-    TestAccount observer = accountCreator.user2();
-    PushOneCommit.Result r = createChange();
-    ReviewInput input =
-        ReviewInput.approve().reviewer(user.email).reviewer(observer.email, CC, false);
-
-    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
-    assertThat(result.labels).isNotNull();
-    assertThat(result.reviewers).isNotNull();
-    assertThat(result.reviewers).hasSize(2);
-
-    // Verify reviewer and CC were added. If not in NoteDb read mode, both
-    // parties will be returned as CCed.
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    if (notesMigration.readChanges()) {
-      assertReviewers(c, REVIEWER, admin, user);
-      assertReviewers(c, CC, observer);
-    } else {
-      // In legacy mode, everyone should be a reviewer.
-      assertReviewers(c, REVIEWER, admin, user, observer);
-      assertReviewers(c, CC);
-    }
-
-    // Verify emails were sent to added reviewers.
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(2);
-
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress, observer.emailAddress);
-    assertThat(m.body()).contains(admin.fullName + " has posted comments on this change.");
-    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
-    assertThat(m.body()).contains("Patch Set 1: Code-Review+2");
-
-    m = messages.get(1);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress, observer.emailAddress);
-    assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
-    assertThat(m.body()).contains("I'd like you to do a code review.");
-  }
-
-  @Test
-  public void reviewAndAddGroupReviewers() throws Exception {
-    int largeGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS + 1;
-    int mediumGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
-    List<TestAccount> users = createAccounts(largeGroupSize, "reviewAndAddGroupReviewers");
-    List<String> usernames = new ArrayList<>(largeGroupSize);
-    for (TestAccount u : users) {
-      usernames.add(u.username);
-    }
-
-    String largeGroup = createGroup("largeGroup");
-    String mediumGroup = createGroup("mediumGroup");
-    gApi.groups().id(largeGroup).addMembers(usernames.toArray(new String[largeGroupSize]));
-    gApi.groups()
-        .id(mediumGroup)
-        .addMembers(usernames.subList(0, mediumGroupSize).toArray(new String[mediumGroupSize]));
-
-    TestAccount observer = accountCreator.user2();
-    PushOneCommit.Result r = createChange();
-
-    // Attempt to add overly large group as reviewers.
-    ReviewInput input =
-        ReviewInput.approve()
-            .reviewer(user.email)
-            .reviewer(observer.email, CC, false)
-            .reviewer(largeGroup);
-    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input, SC_BAD_REQUEST);
-    assertThat(result.labels).isNull();
-    assertThat(result.reviewers).isNotNull();
-    assertThat(result.reviewers).hasSize(3);
-    AddReviewerResult reviewerResult = result.reviewers.get(largeGroup);
-    assertThat(reviewerResult).isNotNull();
-    assertThat(reviewerResult.confirm).isNull();
-    assertThat(reviewerResult.error).isNotNull();
-    assertThat(reviewerResult.error).contains("has too many members to add them all as reviewers");
-
-    // No labels should have changed, and no reviewers/CCs should have been added.
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    assertThat(c.messages).hasSize(1);
-    assertThat(c.reviewers.get(REVIEWER)).isNull();
-    assertThat(c.reviewers.get(CC)).isNull();
-
-    // Attempt to add group large enough to require confirmation, without
-    // confirmation, as reviewers.
-    input =
-        ReviewInput.approve()
-            .reviewer(user.email)
-            .reviewer(observer.email, CC, false)
-            .reviewer(mediumGroup);
-    result = review(r.getChangeId(), r.getCommit().name(), input, SC_BAD_REQUEST);
-    assertThat(result.labels).isNull();
-    assertThat(result.reviewers).isNotNull();
-    assertThat(result.reviewers).hasSize(3);
-    reviewerResult = result.reviewers.get(mediumGroup);
-    assertThat(reviewerResult).isNotNull();
-    assertThat(reviewerResult.confirm).isTrue();
-    assertThat(reviewerResult.error)
-        .contains("has " + mediumGroupSize + " members. Do you want to add them all as reviewers?");
-
-    // No labels should have changed, and no reviewers/CCs should have been added.
-    c = gApi.changes().id(r.getChangeId()).get();
-    assertThat(c.messages).hasSize(1);
-    assertThat(c.reviewers.get(REVIEWER)).isNull();
-    assertThat(c.reviewers.get(CC)).isNull();
-
-    // Retrying with confirmation should successfully approve and add reviewers/CCs.
-    input = ReviewInput.approve().reviewer(user.email).reviewer(mediumGroup, CC, true);
-    result = review(r.getChangeId(), r.getCommit().name(), input);
-    assertThat(result.labels).isNotNull();
-    assertThat(result.reviewers).isNotNull();
-    assertThat(result.reviewers).hasSize(2);
-
-    c = gApi.changes().id(r.getChangeId()).get();
-    assertThat(c.messages).hasSize(2);
-
-    if (notesMigration.readChanges()) {
-      assertReviewers(c, REVIEWER, admin, user);
-      assertReviewers(c, CC, users.subList(0, mediumGroupSize));
-    } else {
-      // If not in NoteDb mode, then everyone is a REVIEWER.
-      List<TestAccount> expected = users.subList(0, mediumGroupSize);
-      expected.add(admin);
-      expected.add(user);
-      assertReviewers(c, REVIEWER, expected);
-      assertReviewers(c, CC);
-    }
-  }
-
-  @Test
-  public void noteDbAddReviewerToReviewerChangeInfo() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    in.state = CC;
-    addReviewer(changeId, in);
-
-    in.state = REVIEWER;
-    addReviewer(changeId, in);
-
-    gApi.changes().id(changeId).current().review(ReviewInput.dislike());
-
-    setApiUser(user);
-    // NoteDb adds reviewer to a change on every review.
-    gApi.changes().id(changeId).current().review(ReviewInput.dislike());
-
-    deleteReviewer(changeId, user).assertNoContent();
-
-    ChangeInfo c = gApi.changes().id(changeId).get();
-    assertThat(c.reviewerUpdates).isNotNull();
-    assertThat(c.reviewerUpdates).hasSize(3);
-
-    Iterator<ReviewerUpdateInfo> it = c.reviewerUpdates.iterator();
-    ReviewerUpdateInfo reviewerChange = it.next();
-    assertThat(reviewerChange.state).isEqualTo(CC);
-    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.getId().get());
-    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.getId().get());
-
-    reviewerChange = it.next();
-    assertThat(reviewerChange.state).isEqualTo(REVIEWER);
-    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.getId().get());
-    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.getId().get());
-
-    reviewerChange = it.next();
-    assertThat(reviewerChange.state).isEqualTo(REMOVED);
-    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.getId().get());
-    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.getId().get());
-  }
-
-  @Test
-  public void addDuplicateReviewers() throws Exception {
-    PushOneCommit.Result r = createChange();
-    ReviewInput input = ReviewInput.approve().reviewer(user.email).reviewer(user.email);
-    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
-    assertThat(result.reviewers).isNotNull();
-    assertThat(result.reviewers).hasSize(1);
-    AddReviewerResult reviewerResult = result.reviewers.get(user.email);
-    assertThat(reviewerResult).isNotNull();
-    assertThat(reviewerResult.confirm).isNull();
-    assertThat(reviewerResult.error).isNull();
-  }
-
-  @Test
-  public void addOverlappingGroups() throws Exception {
-    String emailPrefix = "addOverlappingGroups-";
-    TestAccount user1 =
-        accountCreator.create(name("user1"), emailPrefix + "user1@example.com", "User1");
-    TestAccount user2 =
-        accountCreator.create(name("user2"), emailPrefix + "user2@example.com", "User2");
-    TestAccount user3 =
-        accountCreator.create(name("user3"), emailPrefix + "user3@example.com", "User3");
-    String group1 = createGroup("group1");
-    String group2 = createGroup("group2");
-    gApi.groups().id(group1).addMembers(user1.username, user2.username);
-    gApi.groups().id(group2).addMembers(user2.username, user3.username);
-
-    PushOneCommit.Result r = createChange();
-    ReviewInput input = ReviewInput.approve().reviewer(group1).reviewer(group2);
-    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
-    assertThat(result.reviewers).isNotNull();
-    assertThat(result.reviewers).hasSize(2);
-    AddReviewerResult reviewerResult = result.reviewers.get(group1);
-    assertThat(reviewerResult.error).isNull();
-    assertThat(reviewerResult.reviewers).hasSize(2);
-    reviewerResult = result.reviewers.get(group2);
-    assertThat(reviewerResult.error).isNull();
-    assertThat(reviewerResult.reviewers).hasSize(1);
-
-    // Repeat the above for CCs
-    if (!notesMigration.readChanges()) {
-      return;
-    }
-    r = createChange();
-    input = ReviewInput.approve().reviewer(group1, CC, false).reviewer(group2, CC, false);
-    result = review(r.getChangeId(), r.getCommit().name(), input);
-    assertThat(result.reviewers).isNotNull();
-    assertThat(result.reviewers).hasSize(2);
-    reviewerResult = result.reviewers.get(group1);
-    assertThat(reviewerResult.error).isNull();
-    assertThat(reviewerResult.ccs).hasSize(2);
-    reviewerResult = result.reviewers.get(group2);
-    assertThat(reviewerResult.error).isNull();
-    assertThat(reviewerResult.ccs).hasSize(1);
-
-    // Repeat again with one group REVIEWER, the other CC. The overlapping
-    // member should end up as a REVIEWER.
-    r = createChange();
-    input = ReviewInput.approve().reviewer(group1, REVIEWER, false).reviewer(group2, CC, false);
-    result = review(r.getChangeId(), r.getCommit().name(), input);
-    assertThat(result.reviewers).isNotNull();
-    assertThat(result.reviewers).hasSize(2);
-    reviewerResult = result.reviewers.get(group1);
-    assertThat(reviewerResult.error).isNull();
-    assertThat(reviewerResult.reviewers).hasSize(2);
-    reviewerResult = result.reviewers.get(group2);
-    assertThat(reviewerResult.error).isNull();
-    assertThat(reviewerResult.reviewers).isNull();
-    assertThat(reviewerResult.ccs).hasSize(1);
-  }
-
-  @Test
-  public void removingReviewerRemovesTheirVote() throws Exception {
-    String crLabel = "Code-Review";
-    PushOneCommit.Result r = createChange();
-    ReviewInput input = ReviewInput.approve().reviewer(admin.email);
-    ReviewResult addResult = review(r.getChangeId(), r.getCommit().name(), input);
-    assertThat(addResult.reviewers).isNotNull();
-    assertThat(addResult.reviewers).hasSize(1);
-
-    Map<String, LabelInfo> changeLabels = getChangeLabels(r.getChangeId());
-    assertThat(changeLabels.get(crLabel).all).hasSize(1);
-
-    RestResponse deleteResult = deleteReviewer(r.getChangeId(), admin);
-    deleteResult.assertNoContent();
-
-    changeLabels = getChangeLabels(r.getChangeId());
-    assertThat(changeLabels.get(crLabel).all).isNull();
-
-    // Check that the vote is gone even after the reviewer is added back
-    addReviewer(r.getChangeId(), admin.email);
-    changeLabels = getChangeLabels(r.getChangeId());
-    assertThat(changeLabels.get(crLabel).all).isNull();
-  }
-
-  @Test
-  public void notifyDetailsWorkOnPostReview() throws Exception {
-    PushOneCommit.Result r = createChange();
-    TestAccount userToNotify = createAccounts(1, "notify-details-post-review").get(0);
-
-    ReviewInput reviewInput = new ReviewInput();
-    reviewInput.reviewer(user.email, ReviewerState.REVIEWER, true);
-    reviewInput.notify = NotifyHandling.NONE;
-    reviewInput.notifyDetails =
-        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
-
-    sender.clear();
-    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
-    assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.emailAddress);
-  }
-
-  @Test
-  public void notifyDetailsWorkOnPostReviewers() throws Exception {
-    PushOneCommit.Result r = createChange();
-    TestAccount userToNotify = createAccounts(1, "notify-details-post-reviewers").get(0);
-
-    AddReviewerInput addReviewer = new AddReviewerInput();
-    addReviewer.reviewer = user.email;
-    addReviewer.notify = NotifyHandling.NONE;
-    addReviewer.notifyDetails =
-        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
-
-    sender.clear();
-    gApi.changes().id(r.getChangeId()).addReviewer(addReviewer);
-    assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.emailAddress);
-  }
-
-  @Test
-  public void removeReviewerWithVoteWithoutPermissionFails() throws Exception {
-    PushOneCommit.Result r = createChange();
-    TestAccount newUser = createAccounts(1, name("foo")).get(0);
-
-    setApiUser(user);
-    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Code-Review", 1));
-    setApiUser(newUser);
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
-  }
-
-  @Test
-  @Sandboxed
-  public void removeReviewerWithoutVoteWithPermissionSucceeds() throws Exception {
-    PushOneCommit.Result r = createChange();
-    // This test creates a new user so that it can explicitly check the REMOVE_REVIEWER permission
-    // rather than bypassing the check because of project or ref ownership.
-    TestAccount newUser = createAccounts(1, name("foo")).get(0);
-    grant(project, RefNames.REFS + "*", Permission.REMOVE_REVIEWER, false, REGISTERED_USERS);
-
-    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
-    assertThatUserIsOnlyReviewer(r.getChangeId());
-    setApiUser(newUser);
-    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
-    assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
-  }
-
-  @Test
-  public void removeReviewerWithoutVoteWithoutPermissionFails() throws Exception {
-    PushOneCommit.Result r = createChange();
-    TestAccount newUser = createAccounts(1, name("foo")).get(0);
-
-    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
-    setApiUser(newUser);
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
-  }
-
-  @Test
-  public void removeCCWithoutPermissionFails() throws Exception {
-    PushOneCommit.Result r = createChange();
-    TestAccount newUser = createAccounts(1, name("foo")).get(0);
-
-    AddReviewerInput input = new AddReviewerInput();
-    input.reviewer = user.email;
-    input.state = ReviewerState.CC;
-    gApi.changes().id(r.getChangeId()).addReviewer(input);
-    setApiUser(newUser);
-    exception.expect(AuthException.class);
-    exception.expectMessage("remove reviewer not permitted");
-    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
-  }
-
-  private void assertThatUserIsOnlyReviewer(String changeId) throws Exception {
-    AccountInfo userInfo = new AccountInfo(user.fullName, user.emailAddress.getEmail());
-    userInfo._accountId = user.id.get();
-    userInfo.username = user.username;
-    assertThat(gApi.changes().id(changeId).get().reviewers)
-        .containsExactly(ReviewerState.REVIEWER, ImmutableList.of(userInfo));
-  }
-
-  private AddReviewerResult addReviewer(String changeId, String reviewer) throws Exception {
-    return addReviewer(changeId, reviewer, SC_OK);
-  }
-
-  private AddReviewerResult addReviewer(String changeId, String reviewer, int expectedStatus)
-      throws Exception {
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = reviewer;
-    return addReviewer(changeId, in, expectedStatus);
-  }
-
-  private AddReviewerResult addReviewer(String changeId, AddReviewerInput in) throws Exception {
-    return addReviewer(changeId, in, SC_OK);
-  }
-
-  private AddReviewerResult addReviewer(String changeId, AddReviewerInput in, int expectedStatus)
-      throws Exception {
-    RestResponse resp = adminRestSession.post("/changes/" + changeId + "/reviewers", in);
-    return readContentFromJson(resp, expectedStatus, AddReviewerResult.class);
-  }
-
-  private RestResponse deleteReviewer(String changeId, TestAccount account) throws Exception {
-    return adminRestSession.delete("/changes/" + changeId + "/reviewers/" + account.getId().get());
-  }
-
-  private ReviewResult review(String changeId, String revisionId, ReviewInput in) throws Exception {
-    return review(changeId, revisionId, in, SC_OK);
-  }
-
-  private ReviewResult review(
-      String changeId, String revisionId, ReviewInput in, int expectedStatus) throws Exception {
-    RestResponse resp =
-        adminRestSession.post("/changes/" + changeId + "/revisions/" + revisionId + "/review", in);
-    return readContentFromJson(resp, expectedStatus, ReviewResult.class);
-  }
-
-  private static <T> T readContentFromJson(RestResponse r, int expectedStatus, Class<T> clazz)
-      throws Exception {
-    r.assertStatus(expectedStatus);
-    JsonReader jsonReader = new JsonReader(r.getReader());
-    jsonReader.setLenient(true);
-    return newGson().fromJson(jsonReader, clazz);
-  }
-
-  private static void assertReviewers(
-      ChangeInfo c, ReviewerState reviewerState, TestAccount... accounts) throws Exception {
-    List<TestAccount> accountList = new ArrayList<>(accounts.length);
-    for (TestAccount a : accounts) {
-      accountList.add(a);
-    }
-    assertReviewers(c, reviewerState, accountList);
-  }
-
-  private static void assertReviewers(
-      ChangeInfo c, ReviewerState reviewerState, Iterable<TestAccount> accounts) throws Exception {
-    Collection<AccountInfo> actualAccounts = c.reviewers.get(reviewerState);
-    if (actualAccounts == null) {
-      assertThat(accounts.iterator().hasNext()).isFalse();
-      return;
-    }
-    assertThat(actualAccounts).isNotNull();
-    List<Integer> actualAccountIds = new ArrayList<>(actualAccounts.size());
-    for (AccountInfo account : actualAccounts) {
-      actualAccountIds.add(account._accountId);
-    }
-    List<Integer> expectedAccountIds = new ArrayList<>();
-    for (TestAccount account : accounts) {
-      expectedAccountIds.add(account.getId().get());
-    }
-    assertThat(actualAccountIds).containsExactlyElementsIn(expectedAccountIds);
-  }
-
-  private List<TestAccount> createAccounts(int n, String emailPrefix) throws Exception {
-    List<TestAccount> result = new ArrayList<>(n);
-    for (int i = 0; i < n; i++) {
-      result.add(
-          accountCreator.create(
-              name("u" + i), emailPrefix + "-" + i + "@example.com", "Full Name " + i));
-    }
-    return result;
-  }
-
-  private Map<String, LabelInfo> getChangeLabels(String changeId) throws Exception {
-    return gApi.changes().id(changeId).get(DETAILED_LABELS).labels;
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
deleted file mode 100644
index 3b49b59..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ /dev/null
@@ -1,204 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.junit.Assert.fail;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.Util;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.RefSpec;
-import org.junit.Before;
-import org.junit.Test;
-
-public class ConfigChangeIT extends AbstractDaemonTest {
-  @Before
-  public void setUp() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.allow(cfg, Permission.OWNER, REGISTERED_USERS, "refs/*");
-    Util.allow(cfg, Permission.PUSH, REGISTERED_USERS, "refs/for/refs/meta/config");
-    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, RefNames.REFS_CONFIG);
-    saveProjectConfig(project, cfg);
-
-    setApiUser(user);
-    fetchRefsMetaConfig();
-  }
-
-  @Test
-  @TestProjectInput(cloneAs = "user")
-  public void updateProjectConfig() throws Exception {
-    String id = testUpdateProjectConfig();
-    assertThat(gApi.changes().id(id).get().revisions).hasSize(1);
-  }
-
-  @Test
-  @TestProjectInput(cloneAs = "user", submitType = SubmitType.CHERRY_PICK)
-  public void updateProjectConfigWithCherryPick() throws Exception {
-    String id = testUpdateProjectConfig();
-    assertThat(gApi.changes().id(id).get().revisions).hasSize(2);
-  }
-
-  private String testUpdateProjectConfig() throws Exception {
-    Config cfg = readProjectConfig();
-    assertThat(cfg.getString("project", null, "description")).isNull();
-    String desc = "new project description";
-    cfg.setString("project", null, "description", desc);
-
-    PushOneCommit.Result r = createConfigChange(cfg);
-    String id = r.getChangeId();
-
-    gApi.changes().id(id).current().review(ReviewInput.approve());
-    gApi.changes().id(id).current().submit();
-
-    assertThat(gApi.changes().id(id).info().status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(gApi.projects().name(project.get()).get().description).isEqualTo(desc);
-    fetchRefsMetaConfig();
-    assertThat(readProjectConfig().getString("project", null, "description")).isEqualTo(desc);
-    String changeRev = gApi.changes().id(id).get().currentRevision;
-    String branchRev =
-        gApi.projects().name(project.get()).branch(RefNames.REFS_CONFIG).get().revision;
-    assertThat(changeRev).isEqualTo(branchRev);
-    return id;
-  }
-
-  @Test
-  @TestProjectInput(cloneAs = "user")
-  public void onlyAdminMayUpdateProjectParent() throws Exception {
-    setApiUser(admin);
-    ProjectInput parent = new ProjectInput();
-    parent.name = name("parent");
-    parent.permissionsOnly = true;
-    gApi.projects().create(parent);
-
-    setApiUser(user);
-    Config cfg = readProjectConfig();
-    assertThat(cfg.getString("access", null, "inheritFrom")).isAnyOf(null, allProjects.get());
-    cfg.setString("access", null, "inheritFrom", parent.name);
-
-    PushOneCommit.Result r = createConfigChange(cfg);
-    String id = r.getChangeId();
-
-    gApi.changes().id(id).current().review(ReviewInput.approve());
-    try {
-      gApi.changes().id(id).current().submit();
-      fail("expected submit to fail");
-    } catch (ResourceConflictException e) {
-      int n = gApi.changes().id(id).info()._number;
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              "Failed to submit 1 change due to the following problems:\n"
-                  + "Change "
-                  + n
-                  + ": Change contains a project configuration that"
-                  + " changes the parent project.\n"
-                  + "The change must be submitted by a Gerrit administrator.");
-    }
-
-    assertThat(gApi.projects().name(project.get()).get().parent).isEqualTo(allProjects.get());
-    fetchRefsMetaConfig();
-    assertThat(readProjectConfig().getString("access", null, "inheritFrom"))
-        .isAnyOf(null, allProjects.get());
-
-    setApiUser(admin);
-    gApi.changes().id(id).current().submit();
-    assertThat(gApi.changes().id(id).info().status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(gApi.projects().name(project.get()).get().parent).isEqualTo(parent.name);
-    fetchRefsMetaConfig();
-    assertThat(readProjectConfig().getString("access", null, "inheritFrom")).isEqualTo(parent.name);
-  }
-
-  @Test
-  public void rejectDoubleInheritance() throws Exception {
-    setApiUser(admin);
-    // Create separate projects to test the config
-    Project.NameKey parent = createProject("projectToInheritFrom");
-    Project.NameKey child = createProject("projectWithMalformedConfig");
-
-    String config =
-        gApi.projects()
-            .name(child.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file("project.config")
-            .asString();
-
-    // Append and push malformed project config
-    String pattern = "[access]\n\tinheritFrom = " + allProjects.get() + "\n";
-    String doubleInherit = pattern + "\tinheritFrom = " + parent.get() + "\n";
-    config = config.replace(pattern, doubleInherit);
-
-    TestRepository<InMemoryRepository> childRepo = cloneProject(child, admin);
-    // Fetch meta ref
-    GitUtil.fetch(childRepo, RefNames.REFS_CONFIG + ":cfg");
-    childRepo.reset("cfg");
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), childRepo, "Subject", "project.config", config);
-    PushOneCommit.Result res = push.to(RefNames.REFS_CONFIG);
-    res.assertErrorStatus();
-    res.assertMessage("cannot inherit from multiple projects");
-  }
-
-  private void fetchRefsMetaConfig() throws Exception {
-    git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
-    testRepo.reset(RefNames.REFS_CONFIG);
-  }
-
-  private Config readProjectConfig() throws Exception {
-    RevWalk rw = testRepo.getRevWalk();
-    RevTree tree = rw.parseTree(testRepo.getRepository().resolve("HEAD"));
-    RevObject obj = rw.parseAny(testRepo.get(tree, "project.config"));
-    ObjectLoader loader = rw.getObjectReader().open(obj);
-    String text = new String(loader.getCachedBytes(), UTF_8);
-    Config cfg = new Config();
-    cfg.fromText(text);
-    return cfg;
-  }
-
-  private PushOneCommit.Result createConfigChange(Config cfg) throws Exception {
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                db,
-                user.getIdent(),
-                testRepo,
-                "Update project config",
-                "project.config",
-                cfg.toText())
-            .to("refs/for/refs/meta/config");
-    r.assertOkStatus();
-    return r;
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CorsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CorsIT.java
deleted file mode 100644
index 861a22c..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CorsIT.java
+++ /dev/null
@@ -1,291 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_MAX_AGE;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
-import static com.google.common.net.HttpHeaders.AUTHORIZATION;
-import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
-import static com.google.common.net.HttpHeaders.ORIGIN;
-import static com.google.common.net.HttpHeaders.VARY;
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.truth.StringSubject;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit.Result;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.UrlEncoded;
-import com.google.gerrit.testutil.ConfigSuite;
-import java.nio.charset.StandardCharsets;
-import java.util.Locale;
-import java.util.stream.Stream;
-import org.apache.http.Header;
-import org.apache.http.HttpResponse;
-import org.apache.http.client.fluent.Executor;
-import org.apache.http.client.fluent.Request;
-import org.apache.http.cookie.Cookie;
-import org.apache.http.impl.client.BasicCookieStore;
-import org.apache.http.message.BasicHeader;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Test;
-
-public class CorsIT extends AbstractDaemonTest {
-  @ConfigSuite.Default
-  public static Config allowExampleDotCom() {
-    Config cfg = new Config();
-    cfg.setString("auth", null, "type", "DEVELOPMENT_BECOME_ANY_ACCOUNT");
-    cfg.setStringList(
-        "site",
-        null,
-        "allowOriginRegex",
-        ImmutableList.of("https?://(.+[.])?example[.]com", "http://friend[.]ly"));
-    return cfg;
-  }
-
-  @Test
-  public void missingOriginIsAllowedWithNoCorsResponseHeaders() throws Exception {
-    Result change = createChange();
-    String url = "/changes/" + change.getChangeId() + "/detail";
-    RestResponse r = adminRestSession.get(url);
-    r.assertOK();
-
-    String allowOrigin = r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
-    String allowCred = r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
-    String maxAge = r.getHeader(ACCESS_CONTROL_MAX_AGE);
-    String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
-    String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
-
-    assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNull();
-    assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNull();
-    assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isNull();
-    assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNull();
-    assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNull();
-  }
-
-  @Test
-  public void origins() throws Exception {
-    Result change = createChange();
-    String url = "/changes/" + change.getChangeId() + "/detail";
-
-    check(url, true, "http://example.com");
-    check(url, true, "https://sub.example.com");
-    check(url, true, "http://friend.ly");
-
-    check(url, false, "http://evil.attacker");
-    check(url, false, "http://friendsly");
-  }
-
-  @Test
-  public void putWithServerOriginAcceptedWithNoCorsResponseHeaders() throws Exception {
-    Result change = createChange();
-    String origin = adminRestSession.url();
-    RestResponse r =
-        adminRestSession.putWithHeader(
-            "/changes/" + change.getChangeId() + "/topic", new BasicHeader(ORIGIN, origin), "A");
-    r.assertOK();
-    checkCors(r, false, origin);
-    checkTopic(change, "A");
-  }
-
-  @Test
-  public void putWithOtherOriginAccepted() throws Exception {
-    Result change = createChange();
-    String origin = "http://example.com";
-    RestResponse r =
-        adminRestSession.putWithHeader(
-            "/changes/" + change.getChangeId() + "/topic", new BasicHeader(ORIGIN, origin), "A");
-    r.assertOK();
-    checkCors(r, true, origin);
-  }
-
-  @Test
-  public void preflightOk() throws Exception {
-    Result change = createChange();
-
-    String origin = "http://example.com";
-    Request req =
-        Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
-    req.addHeader(ORIGIN, origin);
-    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
-    req.addHeader(ACCESS_CONTROL_REQUEST_HEADERS, "X-Requested-With");
-
-    RestResponse res = adminRestSession.execute(req);
-    res.assertOK();
-
-    String vary = res.getHeader(VARY);
-    assertThat(vary).named(VARY).isNotNull();
-    assertThat(Splitter.on(", ").splitToList(vary))
-        .containsExactly(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS);
-    checkCors(res, true, origin);
-  }
-
-  @Test
-  public void preflightBadOrigin() throws Exception {
-    Result change = createChange();
-    Request req =
-        Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
-    req.addHeader(ORIGIN, "http://evil.attacker");
-    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
-    adminRestSession.execute(req).assertBadRequest();
-  }
-
-  @Test
-  public void preflightBadMethod() throws Exception {
-    Result change = createChange();
-    Request req =
-        Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
-    req.addHeader(ORIGIN, "http://example.com");
-    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "CALL");
-    adminRestSession.execute(req).assertBadRequest();
-  }
-
-  @Test
-  public void preflightBadHeader() throws Exception {
-    Result change = createChange();
-    Request req =
-        Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
-    req.addHeader(ORIGIN, "http://example.com");
-    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
-    req.addHeader(ACCESS_CONTROL_REQUEST_HEADERS, "X-Secret-Auth-Token");
-    adminRestSession.execute(req).assertBadRequest();
-  }
-
-  @Test
-  public void crossDomainPutTopic() throws Exception {
-    Result change = createChange();
-    BasicCookieStore cookies = new BasicCookieStore();
-    Executor http = Executor.newInstance().cookieStore(cookies);
-
-    Request req = Request.Get(canonicalWebUrl.get() + "/login/?account_id=" + admin.id.get());
-    HttpResponse r = http.execute(req).returnResponse();
-    String auth = null;
-    for (Cookie c : cookies.getCookies()) {
-      if ("GerritAccount".equals(c.getName())) {
-        auth = c.getValue();
-      }
-    }
-    assertThat(auth).named("GerritAccount cookie").isNotNull();
-    cookies.clear();
-
-    UrlEncoded url =
-        new UrlEncoded(canonicalWebUrl.get() + "/changes/" + change.getChangeId() + "/topic");
-    url.put("$m", "PUT");
-    url.put("$ct", "application/json; charset=US-ASCII");
-    url.put("access_token", auth);
-
-    String origin = "http://example.com";
-    req = Request.Post(url.toString());
-    req.setHeader(CONTENT_TYPE, "text/plain");
-    req.setHeader(ORIGIN, origin);
-    req.bodyByteArray("{\"topic\":\"test-xd\"}".getBytes(StandardCharsets.US_ASCII));
-
-    r = http.execute(req).returnResponse();
-    assertThat(r.getStatusLine().getStatusCode()).isEqualTo(200);
-
-    Header vary = r.getFirstHeader(VARY);
-    assertThat(vary).named(VARY).isNotNull();
-    assertThat(Splitter.on(", ").splitToList(vary.getValue())).named(VARY).contains(ORIGIN);
-
-    Header allowOrigin = r.getFirstHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
-    assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNotNull();
-    assertThat(allowOrigin.getValue()).named(ACCESS_CONTROL_ALLOW_ORIGIN).isEqualTo(origin);
-
-    Header allowAuth = r.getFirstHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
-    assertThat(allowAuth).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNotNull();
-    assertThat(allowAuth.getValue()).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isEqualTo("true");
-
-    checkTopic(change, "test-xd");
-  }
-
-  @Test
-  public void crossDomainRejectsBadOrigin() throws Exception {
-    Result change = createChange();
-    UrlEncoded url =
-        new UrlEncoded(canonicalWebUrl.get() + "/changes/" + change.getChangeId() + "/topic");
-    url.put("$m", "PUT");
-    url.put("$ct", "application/json; charset=US-ASCII");
-
-    Request req = Request.Post(url.toString());
-    req.setHeader(CONTENT_TYPE, "text/plain");
-    req.setHeader(ORIGIN, "http://evil.attacker");
-    req.bodyByteArray("{\"topic\":\"test-xd\"}".getBytes(StandardCharsets.US_ASCII));
-    adminRestSession.execute(req).assertBadRequest();
-    checkTopic(change, null);
-  }
-
-  private void checkTopic(Result change, @Nullable String topic) throws RestApiException {
-    ChangeInfo info = gApi.changes().id(change.getChangeId()).get();
-    StringSubject t = assertThat(info.topic).named("topic");
-    if (topic != null) {
-      t.isEqualTo(topic);
-    } else {
-      t.isNull();
-    }
-  }
-
-  private void check(String url, boolean accept, String origin) throws Exception {
-    Header hdr = new BasicHeader(ORIGIN, origin);
-    RestResponse r = adminRestSession.getWithHeader(url, hdr);
-    r.assertOK();
-    checkCors(r, accept, origin);
-  }
-
-  private void checkCors(RestResponse r, boolean accept, String origin) {
-    String vary = r.getHeader(VARY);
-    assertThat(vary).named(VARY).isNotNull();
-    assertThat(Splitter.on(", ").splitToList(vary)).named(VARY).contains(ORIGIN);
-
-    String allowOrigin = r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
-    String allowCred = r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
-    String maxAge = r.getHeader(ACCESS_CONTROL_MAX_AGE);
-    String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
-    String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
-    if (accept) {
-      assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isEqualTo(origin);
-      assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isEqualTo("true");
-      assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isEqualTo("600");
-
-      assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNotNull();
-      assertThat(Splitter.on(", ").splitToList(allowMethods))
-          .named(ACCESS_CONTROL_ALLOW_METHODS)
-          .containsExactly("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS");
-
-      assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNotNull();
-      assertThat(Splitter.on(", ").splitToList(allowHeaders))
-          .named(ACCESS_CONTROL_ALLOW_HEADERS)
-          .containsExactlyElementsIn(
-              Stream.of(AUTHORIZATION, CONTENT_TYPE, "X-Gerrit-Auth", "X-Requested-With")
-                  .map(s -> s.toLowerCase(Locale.US))
-                  .collect(ImmutableSet.toImmutableSet()));
-    } else {
-      assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNull();
-      assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNull();
-      assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isNull();
-      assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNull();
-      assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNull();
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
deleted file mode 100644
index b8b56ce..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ /dev/null
@@ -1,463 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.common.data.Permission.READ;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.PushOneCommit.Result;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.extensions.api.changes.ChangeApi;
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.MergeInput;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import com.google.gerrit.server.git.ChangeAlreadyMergedException;
-import com.google.gerrit.testutil.FakeEmailSender.Message;
-import com.google.gerrit.testutil.TestTimeUtil;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.RefSpec;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-import org.junit.Test;
-
-public class CreateChangeIT extends AbstractDaemonTest {
-  @BeforeClass
-  public static void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @AfterClass
-  public static void restoreTime() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @Test
-  public void createEmptyChange_MissingBranch() throws Exception {
-    ChangeInput ci = new ChangeInput();
-    ci.project = project.get();
-    assertCreateFails(ci, BadRequestException.class, "branch must be non-empty");
-  }
-
-  @Test
-  public void createEmptyChange_MissingMessage() throws Exception {
-    ChangeInput ci = new ChangeInput();
-    ci.project = project.get();
-    ci.branch = "master";
-    assertCreateFails(ci, BadRequestException.class, "commit message must be non-empty");
-  }
-
-  @Test
-  public void createEmptyChange_InvalidStatus() throws Exception {
-    ChangeInput ci = newChangeInput(ChangeStatus.MERGED);
-    assertCreateFails(ci, BadRequestException.class, "unsupported change status");
-  }
-
-  @Test
-  public void createNewChange() throws Exception {
-    assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
-  }
-
-  @Test
-  public void notificationsOnChangeCreation() throws Exception {
-    setApiUser(user);
-    watch(project.get());
-
-    // check that watcher is notified
-    setApiUser(admin);
-    assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
-
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    assertThat(m.body()).contains(admin.fullName + " has uploaded this change for review.");
-
-    // check that watcher is not notified if notify=NONE
-    sender.clear();
-    ChangeInput input = newChangeInput(ChangeStatus.NEW);
-    input.notify = NotifyHandling.NONE;
-    assertCreateSucceeds(input);
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void createNewChangeSignedOffByFooter() throws Exception {
-    setSignedOffByFooter();
-
-    ChangeInfo info = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
-    String message = info.revisions.get(info.currentRevision).commit.message;
-    assertThat(message)
-        .contains(
-            String.format(
-                "%sAdministrator <%s>", SIGNED_OFF_BY_TAG, admin.getIdent().getEmailAddress()));
-  }
-
-  @Test
-  public void createNewPrivateChange() throws Exception {
-    ChangeInput input = newChangeInput(ChangeStatus.NEW);
-    input.isPrivate = true;
-    assertCreateSucceeds(input);
-  }
-
-  @Test
-  public void createNewWorkInProgressChange() throws Exception {
-    ChangeInput input = newChangeInput(ChangeStatus.NEW);
-    input.workInProgress = true;
-    assertCreateSucceeds(input);
-  }
-
-  @Test
-  public void createChangeWithoutAccessToParentCommitFails() throws Exception {
-    Map<String, PushOneCommit.Result> results =
-        changeInTwoBranches("invisible-branch", "a.txt", "visible-branch", "b.txt");
-    block(project, "refs/heads/invisible-branch", READ, REGISTERED_USERS);
-
-    ChangeInput in = newChangeInput(ChangeStatus.NEW);
-    in.branch = "visible-branch";
-    in.baseChange = results.get("invisible-branch").getChangeId();
-    assertCreateFails(
-        in, UnprocessableEntityException.class, "Base change not found: " + in.baseChange);
-  }
-
-  @Test
-  public void createChangeOnInvisibleBranchFails() throws Exception {
-    changeInTwoBranches("invisible-branch", "a.txt", "branchB", "b.txt");
-    block(project, "refs/heads/invisible-branch", READ, REGISTERED_USERS);
-
-    ChangeInput in = newChangeInput(ChangeStatus.NEW);
-    in.branch = "invisible-branch";
-    assertCreateFails(in, ResourceNotFoundException.class, "");
-  }
-
-  @Test
-  public void noteDbCommit() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    ChangeInfo c = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit =
-          rw.parseCommit(repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
-
-      assertThat(commit.getShortMessage()).isEqualTo("Create change");
-
-      PersonIdent expectedAuthor =
-          changeNoteUtil.newIdent(
-              accountCache.get(admin.id).getAccount(),
-              c.created,
-              serverIdent.get(),
-              AnonymousCowardNameProvider.DEFAULT);
-      assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
-
-      assertThat(commit.getCommitterIdent())
-          .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
-      assertThat(commit.getParentCount()).isEqualTo(0);
-    }
-  }
-
-  @Test
-  public void createMergeChange() throws Exception {
-    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
-    ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
-    assertCreateSucceeds(in);
-  }
-
-  @Test
-  public void createMergeChange_Conflicts() throws Exception {
-    changeInTwoBranches("branchA", "shared.txt", "branchB", "shared.txt");
-    ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
-    assertCreateFails(in, RestApiException.class, "merge conflict");
-  }
-
-  @Test
-  public void createMergeChange_Conflicts_Ours() throws Exception {
-    changeInTwoBranches("branchA", "shared.txt", "branchB", "shared.txt");
-    ChangeInput in = newMergeChangeInput("branchA", "branchB", "ours");
-    assertCreateSucceeds(in);
-  }
-
-  @Test
-  public void invalidSource() throws Exception {
-    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
-    ChangeInput in = newMergeChangeInput("branchA", "invalid", "");
-    assertCreateFails(in, BadRequestException.class, "Cannot resolve 'invalid' to a commit");
-  }
-
-  @Test
-  public void invalidStrategy() throws Exception {
-    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
-    ChangeInput in = newMergeChangeInput("branchA", "branchB", "octopus");
-    assertCreateFails(in, BadRequestException.class, "invalid merge strategy: octopus");
-  }
-
-  @Test
-  public void alreadyMerged() throws Exception {
-    ObjectId c0 =
-        testRepo
-            .branch("HEAD")
-            .commit()
-            .insertChangeId()
-            .message("first commit")
-            .add("a.txt", "a contents ")
-            .create();
-    testRepo
-        .git()
-        .push()
-        .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
-        .call();
-
-    testRepo
-        .branch("HEAD")
-        .commit()
-        .insertChangeId()
-        .message("second commit")
-        .add("b.txt", "b contents ")
-        .create();
-    testRepo
-        .git()
-        .push()
-        .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
-        .call();
-
-    ChangeInput in = newMergeChangeInput("master", c0.getName(), "");
-    assertCreateFails(
-        in, ChangeAlreadyMergedException.class, "'" + c0.getName() + "' has already been merged");
-  }
-
-  @Test
-  public void onlyContentMerged() throws Exception {
-    testRepo
-        .branch("HEAD")
-        .commit()
-        .insertChangeId()
-        .message("first commit")
-        .add("a.txt", "a contents ")
-        .create();
-    testRepo
-        .git()
-        .push()
-        .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
-        .call();
-
-    // create a change, and cherrypick into master
-    PushOneCommit.Result cId = createChange();
-    RevCommit commitId = cId.getCommit();
-    CherryPickInput cpi = new CherryPickInput();
-    cpi.destination = "master";
-    cpi.message = "cherry pick the commit";
-    ChangeApi orig = gApi.changes().id(cId.getChangeId());
-    ChangeApi cherry = orig.current().cherryPick(cpi);
-    cherry.current().review(ReviewInput.approve());
-    cherry.current().submit();
-
-    ObjectId remoteId = getRemoteHead();
-    assertThat(remoteId).isNotEqualTo(commitId);
-
-    ChangeInput in = newMergeChangeInput("master", commitId.getName(), "");
-    assertCreateSucceeds(in);
-  }
-
-  @Test
-  public void cherryPickCommitWithoutChangeId() throws Exception {
-    // This test is a little superfluous, since the current cherry-pick code ignores
-    // the commit message of the to-be-cherry-picked change, using the one in
-    // CherryPickInput instead.
-    CherryPickInput input = new CherryPickInput();
-    input.destination = "foo";
-    input.message = "it goes to foo branch";
-    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
-
-    RevCommit revCommit = createNewCommitWithoutChangeId("refs/heads/master", "a.txt", "content");
-    ChangeInfo changeInfo =
-        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
-
-    assertThat(changeInfo.messages).hasSize(1);
-    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
-    String expectedMessage =
-        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
-    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
-
-    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
-    assertThat(revInfo).isNotNull();
-    CommitInfo commitInfo = revInfo.commit;
-    assertThat(commitInfo.message)
-        .isEqualTo(input.message + "\n\nChange-Id: " + changeInfo.changeId + "\n");
-  }
-
-  @Test
-  public void cherryPickCommitWithChangeId() throws Exception {
-    CherryPickInput input = new CherryPickInput();
-    input.destination = "foo";
-
-    RevCommit revCommit = createChange().getCommit();
-    List<String> footers = revCommit.getFooterLines("Change-Id");
-    assertThat(footers).hasSize(1);
-    String changeId = footers.get(0);
-
-    input.message = "it goes to foo branch\n\nChange-Id: " + changeId;
-    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
-
-    ChangeInfo changeInfo =
-        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
-
-    assertThat(changeInfo.messages).hasSize(1);
-    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
-    String expectedMessage =
-        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
-    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
-
-    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
-    assertThat(revInfo).isNotNull();
-    assertThat(revInfo.commit.message).isEqualTo(input.message + "\n");
-  }
-
-  private ChangeInput newChangeInput(ChangeStatus status) {
-    ChangeInput in = new ChangeInput();
-    in.project = project.get();
-    in.branch = "master";
-    in.subject = "Empty change";
-    in.topic = "support-gerrit-workflow-in-browser";
-    in.status = status;
-    return in;
-  }
-
-  private ChangeInfo assertCreateSucceeds(ChangeInput in) throws Exception {
-    ChangeInfo out = gApi.changes().create(in).get();
-    assertThat(out.project).isEqualTo(in.project);
-    assertThat(out.branch).isEqualTo(in.branch);
-    assertThat(out.subject).isEqualTo(in.subject);
-    assertThat(out.topic).isEqualTo(in.topic);
-    assertThat(out.status).isEqualTo(in.status);
-    assertThat(out.isPrivate).isEqualTo(in.isPrivate);
-    assertThat(out.workInProgress).isEqualTo(in.workInProgress);
-    assertThat(out.revisions).hasSize(1);
-    assertThat(out.submitted).isNull();
-    assertThat(in.status).isEqualTo(ChangeStatus.NEW);
-    return out;
-  }
-
-  private void assertCreateFails(
-      ChangeInput in, Class<? extends RestApiException> errType, String errSubstring)
-      throws Exception {
-    exception.expect(errType);
-    exception.expectMessage(errSubstring);
-    gApi.changes().create(in);
-  }
-
-  // TODO(davido): Expose setting of account preferences in the API
-  private void setSignedOffByFooter() throws Exception {
-    RestResponse r = adminRestSession.get("/accounts/" + admin.email + "/preferences");
-    r.assertOK();
-    GeneralPreferencesInfo i = newGson().fromJson(r.getReader(), GeneralPreferencesInfo.class);
-    i.signedOffBy = true;
-
-    r = adminRestSession.put("/accounts/" + admin.email + "/preferences", i);
-    r.assertOK();
-    GeneralPreferencesInfo o = newGson().fromJson(r.getReader(), GeneralPreferencesInfo.class);
-
-    assertThat(o.signedOffBy).isTrue();
-  }
-
-  private ChangeInput newMergeChangeInput(String targetBranch, String sourceRef, String strategy) {
-    // create a merge change from branchA to master in gerrit
-    ChangeInput in = new ChangeInput();
-    in.project = project.get();
-    in.branch = targetBranch;
-    in.subject = "merge " + sourceRef + " to " + targetBranch;
-    in.status = ChangeStatus.NEW;
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = sourceRef;
-    in.merge = mergeInput;
-    if (!Strings.isNullOrEmpty(strategy)) {
-      in.merge.strategy = strategy;
-    }
-    return in;
-  }
-
-  /**
-   * Create an empty commit in master, two new branches with one commit each.
-   *
-   * @param branchA name of first branch to create
-   * @param fileA name of file to commit to branchA
-   * @param branchB name of second branch to create
-   * @param fileB name of file to commit to branchB
-   * @return A {@code Map} of branchName => commit result.
-   * @throws Exception
-   */
-  private Map<String, Result> changeInTwoBranches(
-      String branchA, String fileA, String branchB, String fileB) throws Exception {
-    // create a initial commit in master
-    Result initialCommit =
-        pushFactory
-            .create(db, user.getIdent(), testRepo, "initial commit", "readme.txt", "initial commit")
-            .to("refs/heads/master");
-    initialCommit.assertOkStatus();
-
-    // create two new branches
-    createBranch(new Branch.NameKey(project, branchA));
-    createBranch(new Branch.NameKey(project, branchB));
-
-    // create a commit in branchA
-    Result changeA =
-        pushFactory
-            .create(db, user.getIdent(), testRepo, "change A", fileA, "A content")
-            .to("refs/heads/" + branchA);
-    changeA.assertOkStatus();
-
-    // create a commit in branchB
-    PushOneCommit commitB =
-        pushFactory.create(db, user.getIdent(), testRepo, "change B", fileB, "B content");
-    commitB.setParent(initialCommit.getCommit());
-    Result changeB = commitB.to("refs/heads/" + branchB);
-    changeB.assertOkStatus();
-
-    return ImmutableMap.of("master", initialCommit, branchA, changeA, branchB, changeB);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
deleted file mode 100644
index ff4eb3d..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ /dev/null
@@ -1,105 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.testutil.FakeEmailSender;
-import com.google.gson.reflect.TypeToken;
-import java.util.Collection;
-import java.util.List;
-import java.util.Map;
-import org.junit.Test;
-
-public class DeleteVoteIT extends AbstractDaemonTest {
-  @Test
-  public void deleteVoteOnChange() throws Exception {
-    deleteVote(false);
-  }
-
-  @Test
-  public void deleteVoteOnRevision() throws Exception {
-    deleteVote(true);
-  }
-
-  private void deleteVote(boolean onRevisionLevel) throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-
-    PushOneCommit.Result r2 = amendChange(r.getChangeId());
-
-    setApiUser(user);
-    recommend(r.getChangeId());
-
-    sender.clear();
-    String endPoint =
-        "/changes/"
-            + r.getChangeId()
-            + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
-            + "/reviewers/"
-            + user.getId().toString()
-            + "/votes/Code-Review";
-
-    RestResponse response = adminRestSession.delete(endPoint);
-    response.assertNoContent();
-
-    List<FakeEmailSender.Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    FakeEmailSender.Message msg = messages.get(0);
-    assertThat(msg.rcpt()).containsExactly(user.emailAddress);
-    assertThat(msg.body()).contains(admin.fullName + " has removed a vote on this change.\n");
-    assertThat(msg.body())
-        .contains("Removed Code-Review+1 by " + user.fullName + " <" + user.email + ">\n");
-
-    endPoint =
-        "/changes/"
-            + r.getChangeId()
-            + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
-            + "/reviewers/"
-            + user.getId().toString()
-            + "/votes";
-
-    response = adminRestSession.get(endPoint);
-    response.assertOK();
-
-    Map<String, Short> m =
-        newGson().fromJson(response.getReader(), new TypeToken<Map<String, Short>>() {}.getType());
-
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
-
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-
-    ChangeMessageInfo message = Iterables.getLast(c.messages);
-    assertThat(message.author._accountId).isEqualTo(admin.getId().get());
-    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
-    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
-  }
-
-  private Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
-    return Iterables.transform(r, a -> new Account.Id(a._accountId));
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
deleted file mode 100644
index 08f0699..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ /dev/null
@@ -1,313 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Sets;
-import com.google.common.truth.IterableSubject;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.HashtagsInput;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.testutil.TestTimeUtil;
-import org.junit.AfterClass;
-import org.junit.Before;
-import org.junit.BeforeClass;
-import org.junit.Test;
-
-@NoHttpd
-public class HashtagsIT extends AbstractDaemonTest {
-  @Before
-  public void before() {
-    assume().that(notesMigration.readChanges()).isTrue();
-  }
-
-  @BeforeClass
-  public static void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @AfterClass
-  public static void restoreTime() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @Test
-  public void getNoHashtags() throws Exception {
-    // Get on a change with no hashtags returns an empty list.
-    PushOneCommit.Result r = createChange();
-    assertThatGet(r).isEmpty();
-  }
-
-  @Test
-  public void addSingleHashtag() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    // Adding a single hashtag returns a single hashtag.
-    addHashtags(r, "tag2");
-    assertThatGet(r).containsExactly("tag2");
-    assertMessage(r, "Hashtag added: tag2");
-
-    // Adding another single hashtag to change that already has one hashtag
-    // returns a sorted list of hashtags with existing and new.
-    addHashtags(r, "tag1");
-    assertThatGet(r).containsExactly("tag1", "tag2").inOrder();
-    assertMessage(r, "Hashtag added: tag1");
-  }
-
-  @Test
-  public void addInvalidHashtag() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("hashtags may not contain commas");
-    addHashtags(r, "invalid,hashtag");
-  }
-
-  @Test
-  public void addMultipleHashtags() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    // Adding multiple hashtags returns a sorted list of hashtags.
-    addHashtags(r, "tag3", "tag1");
-    assertThatGet(r).containsExactly("tag1", "tag3").inOrder();
-    assertMessage(r, "Hashtags added: tag1, tag3");
-
-    // Adding multiple hashtags to change that already has hashtags returns a
-    // sorted list of hashtags with existing and new.
-    addHashtags(r, "tag2", "tag4");
-    assertThatGet(r).containsExactly("tag1", "tag2", "tag3", "tag4").inOrder();
-    assertMessage(r, "Hashtags added: tag2, tag4");
-  }
-
-  @Test
-  public void addAlreadyExistingHashtag() throws Exception {
-    // Adding a hashtag that already exists on the change returns a sorted list
-    // of hashtags without duplicates.
-    PushOneCommit.Result r = createChange();
-    addHashtags(r, "tag2");
-    assertThatGet(r).containsExactly("tag2");
-    assertMessage(r, "Hashtag added: tag2");
-    ChangeMessageInfo last = getLastMessage(r);
-
-    addHashtags(r, "tag2");
-    assertThatGet(r).containsExactly("tag2");
-    assertNoNewMessageSince(r, last);
-
-    addHashtags(r, "tag1", "tag2");
-    assertThatGet(r).containsExactly("tag1", "tag2").inOrder();
-    assertMessage(r, "Hashtag added: tag1");
-  }
-
-  @Test
-  public void hashtagsWithPrefix() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    // Leading # is stripped from added tag.
-    addHashtags(r, "#tag1");
-    assertThatGet(r).containsExactly("tag1");
-    assertMessage(r, "Hashtag added: tag1");
-
-    // Leading # is stripped from multiple added tags.
-    addHashtags(r, "#tag2", "#tag3");
-    assertThatGet(r).containsExactly("tag1", "tag2", "tag3").inOrder();
-    assertMessage(r, "Hashtags added: tag2, tag3");
-
-    // Leading # is stripped from removed tag.
-    removeHashtags(r, "#tag2");
-    assertThatGet(r).containsExactly("tag1", "tag3").inOrder();
-    assertMessage(r, "Hashtag removed: tag2");
-
-    // Leading # is stripped from multiple removed tags.
-    removeHashtags(r, "#tag1", "#tag3");
-    assertThatGet(r).isEmpty();
-    assertMessage(r, "Hashtags removed: tag1, tag3");
-
-    // Leading # and space are stripped from added tag.
-    addHashtags(r, "# tag1");
-    assertThatGet(r).containsExactly("tag1");
-    assertMessage(r, "Hashtag added: tag1");
-
-    // Multiple leading # are stripped from added tag.
-    addHashtags(r, "##tag2");
-    assertThatGet(r).containsExactly("tag1", "tag2").inOrder();
-    assertMessage(r, "Hashtag added: tag2");
-
-    // Multiple leading spaces and # are stripped from added tag.
-    addHashtags(r, "# # tag3");
-    assertThatGet(r).containsExactly("tag1", "tag2", "tag3").inOrder();
-    assertMessage(r, "Hashtag added: tag3");
-  }
-
-  @Test
-  public void removeSingleHashtag() throws Exception {
-    // Removing a single tag from a change that only has that tag returns an
-    // empty list.
-    PushOneCommit.Result r = createChange();
-    addHashtags(r, "tag1");
-    assertThatGet(r).containsExactly("tag1");
-    removeHashtags(r, "tag1");
-    assertThatGet(r).isEmpty();
-    assertMessage(r, "Hashtag removed: tag1");
-
-    // Removing a single tag from a change that has multiple tags returns a
-    // sorted list of remaining tags.
-    addHashtags(r, "tag1", "tag2", "tag3");
-    removeHashtags(r, "tag2");
-    assertThatGet(r).containsExactly("tag1", "tag3").inOrder();
-    assertMessage(r, "Hashtag removed: tag2");
-  }
-
-  @Test
-  public void removeMultipleHashtags() throws Exception {
-    // Removing multiple tags from a change that only has those tags returns an
-    // empty list.
-    PushOneCommit.Result r = createChange();
-    addHashtags(r, "tag1", "tag2");
-    assertThatGet(r).containsExactly("tag1", "tag2").inOrder();
-    removeHashtags(r, "tag1", "tag2");
-    assertThatGet(r).isEmpty();
-    assertMessage(r, "Hashtags removed: tag1, tag2");
-
-    // Removing multiple tags from a change that has multiple tags returns a
-    // sorted list of remaining tags.
-    addHashtags(r, "tag1", "tag2", "tag3", "tag4");
-    assertThatGet(r).containsExactly("tag1", "tag2", "tag3", "tag4").inOrder();
-    removeHashtags(r, "tag2", "tag4");
-    assertThatGet(r).containsExactly("tag1", "tag3").inOrder();
-    assertMessage(r, "Hashtags removed: tag2, tag4");
-  }
-
-  @Test
-  public void removeNotExistingHashtag() throws Exception {
-    // Removing a single hashtag from change that has no hashtags returns an
-    // empty list.
-    PushOneCommit.Result r = createChange();
-    ChangeMessageInfo last = getLastMessage(r);
-    removeHashtags(r, "tag1");
-    assertThatGet(r).isEmpty();
-    assertNoNewMessageSince(r, last);
-
-    // Removing a single non-existing tag from a change that only has one other
-    // tag returns a list of only one tag.
-    addHashtags(r, "tag1");
-    last = getLastMessage(r);
-    removeHashtags(r, "tag4");
-    assertThatGet(r).containsExactly("tag1");
-    assertNoNewMessageSince(r, last);
-
-    // Removing a single non-existing tag from a change that has multiple tags
-    // returns a sorted list of tags without any deleted.
-    addHashtags(r, "tag1", "tag2", "tag3");
-    last = getLastMessage(r);
-    removeHashtags(r, "tag4");
-    assertThatGet(r).containsExactly("tag1", "tag2", "tag3").inOrder();
-    assertNoNewMessageSince(r, last);
-  }
-
-  @Test
-  public void addAndRemove() throws Exception {
-    // Adding and remove hashtags in a single request performs correctly.
-    PushOneCommit.Result r = createChange();
-    addHashtags(r, "tag1", "tag2");
-    HashtagsInput input = new HashtagsInput();
-    input.add = Sets.newHashSet("tag3", "tag4");
-    input.remove = Sets.newHashSet("tag1");
-    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
-    assertThatGet(r).containsExactly("tag2", "tag3", "tag4");
-    assertMessage(r, "Hashtags added: tag3, tag4\nHashtag removed: tag1");
-
-    // Adding and removing the same hashtag actually removes it.
-    addHashtags(r, "tag1", "tag2");
-    input = new HashtagsInput();
-    input.add = Sets.newHashSet("tag3", "tag4");
-    input.remove = Sets.newHashSet("tag3");
-    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
-    assertThatGet(r).containsExactly("tag1", "tag2", "tag4");
-    assertMessage(r, "Hashtag removed: tag3");
-  }
-
-  @Test
-  public void hashtagWithMixedCase() throws Exception {
-    PushOneCommit.Result r = createChange();
-    addHashtags(r, "MyHashtag");
-    assertThatGet(r).containsExactly("MyHashtag");
-    assertMessage(r, "Hashtag added: MyHashtag");
-  }
-
-  @Test
-  public void addHashtagWithoutPermissionNotAllowed() throws Exception {
-    PushOneCommit.Result r = createChange();
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("edit hashtags not permitted");
-    addHashtags(r, "MyHashtag");
-  }
-
-  @Test
-  public void addHashtagWithPermissionAllowed() throws Exception {
-    PushOneCommit.Result r = createChange();
-    grant(project, "refs/heads/master", Permission.EDIT_HASHTAGS, false, REGISTERED_USERS);
-    setApiUser(user);
-    addHashtags(r, "MyHashtag");
-    assertThatGet(r).containsExactly("MyHashtag");
-    assertMessage(r, "Hashtag added: MyHashtag");
-  }
-
-  private IterableSubject assertThatGet(PushOneCommit.Result r) throws Exception {
-    return assertThat(gApi.changes().id(r.getChange().getId().get()).getHashtags());
-  }
-
-  private void addHashtags(PushOneCommit.Result r, String... toAdd) throws Exception {
-    HashtagsInput input = new HashtagsInput();
-    input.add = Sets.newHashSet(toAdd);
-    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
-  }
-
-  private void removeHashtags(PushOneCommit.Result r, String... toRemove) throws Exception {
-    HashtagsInput input = new HashtagsInput();
-    input.remove = Sets.newHashSet(toRemove);
-    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
-  }
-
-  private void assertMessage(PushOneCommit.Result r, String expectedMessage) throws Exception {
-    assertThat(getLastMessage(r).message).isEqualTo(expectedMessage);
-  }
-
-  private void assertNoNewMessageSince(PushOneCommit.Result r, ChangeMessageInfo expected)
-      throws Exception {
-    checkNotNull(expected);
-    ChangeMessageInfo last = getLastMessage(r);
-    assertThat(last.message).isEqualTo(expected.message);
-    assertThat(last.id).isEqualTo(expected.id);
-  }
-
-  private ChangeMessageInfo getLastMessage(PushOneCommit.Result r) throws Exception {
-    ChangeMessageInfo lastMessage =
-        Iterables.getLast(gApi.changes().id(r.getChange().getId().get()).get().messages, null);
-    assertThat(lastMessage).named(lastMessage.message).isNotNull();
-    return lastMessage;
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
deleted file mode 100644
index 8388ed0..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ /dev/null
@@ -1,251 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.MoveInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.Util;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Test;
-
-@NoHttpd
-public class MoveChangeIT extends AbstractDaemonTest {
-  @Test
-  public void moveChangeWithShortRef() throws Exception {
-    // Move change to a different branch using short ref name
-    PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
-    createBranch(newBranch);
-    move(r.getChangeId(), newBranch.getShortName());
-    assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
-  }
-
-  @Test
-  public void moveChangeWithFullRef() throws Exception {
-    // Move change to a different branch using full ref name
-    PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
-    createBranch(newBranch);
-    move(r.getChangeId(), newBranch.get());
-    assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
-  }
-
-  @Test
-  public void moveChangeWithMessage() throws Exception {
-    // Provide a message using --message flag
-    PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
-    createBranch(newBranch);
-    String moveMessage = "Moving for the move test";
-    move(r.getChangeId(), newBranch.get(), moveMessage);
-    assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
-    StringBuilder expectedMessage = new StringBuilder();
-    expectedMessage.append("Change destination moved from master to moveTest");
-    expectedMessage.append("\n\n");
-    expectedMessage.append(moveMessage);
-    assertThat(r.getChange().messages().get(1).getMessage()).isEqualTo(expectedMessage.toString());
-  }
-
-  @Test
-  public void moveChangeToSameRefAsCurrent() throws Exception {
-    // Move change to the branch same as change's destination
-    PushOneCommit.Result r = createChange();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Change is already destined for the specified branch");
-    move(r.getChangeId(), r.getChange().change().getDest().get());
-  }
-
-  @Test
-  public void moveChangeToSameChangeId() throws Exception {
-    // Move change to a branch with existing change with same change ID
-    PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
-    createBranch(newBranch);
-    int changeNum = r.getChange().change().getChangeId();
-    createChange(newBranch.get(), r.getChangeId());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "Destination "
-            + newBranch.getShortName()
-            + " has a different change with same change key "
-            + r.getChangeId());
-    move(changeNum, newBranch.get());
-  }
-
-  @Test
-  public void moveChangeToNonExistentRef() throws Exception {
-    // Move change to a non-existing branch
-    PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "does_not_exist");
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Destination " + newBranch.get() + " not found in the project");
-    move(r.getChangeId(), newBranch.get());
-  }
-
-  @Test
-  public void moveClosedChange() throws Exception {
-    // Move a change which is not open
-    PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
-    createBranch(newBranch);
-    merge(r);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Change is merged");
-    move(r.getChangeId(), newBranch.get());
-  }
-
-  @Test
-  public void moveMergeCommitChange() throws Exception {
-    // Move a change which has a merge commit as the current PS
-    // Create a merge commit and push for review
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-    TestRepository<?>.CommitBuilder commitBuilder =
-        testRepo.branch("HEAD").commit().insertChangeId();
-    commitBuilder
-        .parent(r1.getCommit())
-        .parent(r2.getCommit())
-        .message("Move change Merge Commit")
-        .author(admin.getIdent())
-        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
-    RevCommit c = commitBuilder.create();
-    pushHead(testRepo, "refs/for/master", false, false);
-
-    // Try to move the merge commit to another branch
-    Branch.NameKey newBranch = new Branch.NameKey(r1.getChange().change().getProject(), "moveTest");
-    createBranch(newBranch);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Merge commit cannot be moved");
-    move(GitUtil.getChangeId(testRepo, c).get(), newBranch.get());
-  }
-
-  @Test
-  public void moveChangeToBranchWithoutUploadPerms() throws Exception {
-    // Move change to a destination where user doesn't have upload permissions
-    PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "blocked_branch");
-    createBranch(newBranch);
-    block(
-        "refs/for/" + newBranch.get(),
-        Permission.PUSH,
-        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
-    exception.expect(AuthException.class);
-    exception.expectMessage("move not permitted");
-    move(r.getChangeId(), newBranch.get());
-  }
-
-  @Test
-  public void moveChangeFromBranchWithoutAbandonPerms() throws Exception {
-    // Move change for which user does not have abandon permissions
-    PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
-    createBranch(newBranch);
-    block(
-        r.getChange().change().getDest().get(),
-        Permission.ABANDON,
-        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("move not permitted");
-    move(r.getChangeId(), newBranch.get());
-  }
-
-  @Test
-  public void moveChangeToBranchThatContainsCurrentCommit() throws Exception {
-    // Move change to a branch for which current PS revision is reachable from
-    // tip
-
-    // Create a change
-    PushOneCommit.Result r = createChange();
-    int changeNum = r.getChange().change().getChangeId();
-
-    // Create a branch with that same commit
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
-    BranchInput bi = new BranchInput();
-    bi.revision = r.getCommit().name();
-    gApi.projects().name(newBranch.getParentKey().get()).branch(newBranch.get()).create(bi);
-
-    // Try to move the change to the branch with the same commit
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "Current patchset revision is reachable from tip of " + newBranch.get());
-    move(changeNum, newBranch.get());
-  }
-
-  @Test
-  public void moveChangeWithCurrentPatchSetLocked() throws Exception {
-    // Move change that is locked
-    PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
-    createBranch(newBranch);
-
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType patchSetLock = Util.patchSetLock();
-    cfg.getLabelSections().put(patchSetLock.getName(), patchSetLock);
-    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    Util.allow(
-        cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, registeredUsers, "refs/heads/*");
-    saveProjectConfig(cfg);
-    grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
-    revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
-
-    exception.expect(AuthException.class);
-    exception.expectMessage("move not permitted");
-    move(r.getChangeId(), newBranch.get());
-  }
-
-  private void move(int changeNum, String destination) throws RestApiException {
-    gApi.changes().id(changeNum).move(destination);
-  }
-
-  private void move(String changeId, String destination) throws RestApiException {
-    gApi.changes().id(changeId).move(destination);
-  }
-
-  private void move(String changeId, String destination, String message) throws RestApiException {
-    MoveInput in = new MoveInput();
-    in.destinationBranch = destination;
-    in.message = message;
-    gApi.changes().id(changeId).move(in);
-  }
-
-  private PushOneCommit.Result createChange(String branch, String changeId) throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo, changeId);
-    PushOneCommit.Result result = push.to("refs/for/" + branch);
-    result.assertOkStatus();
-    return result;
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
deleted file mode 100644
index a10062c..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
+++ /dev/null
@@ -1,118 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.api.projects.ConfigInput;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.reviewdb.client.Project;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.junit.Before;
-import org.junit.Test;
-
-public class PrivateByDefaultIT extends AbstractDaemonTest {
-  private Project.NameKey project1;
-  private Project.NameKey project2;
-
-  @Before
-  public void setUp() throws Exception {
-    project1 = createProject("project-1");
-    project2 = createProject("project-2", project1);
-    setPrivateByDefault(project1, InheritableBoolean.FALSE);
-  }
-
-  @Test
-  public void createChangeWithPrivateByDefaultEnabled() throws Exception {
-    setPrivateByDefault(project2, InheritableBoolean.TRUE);
-    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
-    assertThat(gApi.changes().create(input).get().isPrivate).isEqualTo(true);
-  }
-
-  @Test
-  public void createChangeBypassPrivateByDefaultEnabled() throws Exception {
-    setPrivateByDefault(project2, InheritableBoolean.TRUE);
-    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
-    input.isPrivate = false;
-    assertThat(gApi.changes().create(input).get().isPrivate).isNull();
-  }
-
-  @Test
-  public void createChangeWithPrivateByDefaultDisabled() throws Exception {
-    ChangeInfo info =
-        gApi.changes().create(new ChangeInput(project2.get(), "master", "empty change")).get();
-    assertThat(info.isPrivate).isNull();
-  }
-
-  @Test
-  public void createChangeWithPrivateByDefaultInherited() throws Exception {
-    setPrivateByDefault(project1, InheritableBoolean.TRUE);
-    ChangeInfo info =
-        gApi.changes().create(new ChangeInput(project2.get(), "master", "empty change")).get();
-    assertThat(info.isPrivate).isTrue();
-  }
-
-  @Test
-  public void pushWithPrivateByDefaultEnabled() throws Exception {
-    setPrivateByDefault(project2, InheritableBoolean.TRUE);
-    assertThat(createChange(project2).getChange().change().isPrivate()).isEqualTo(true);
-  }
-
-  @Test
-  public void pushBypassPrivateByDefaultEnabled() throws Exception {
-    setPrivateByDefault(project2, InheritableBoolean.TRUE);
-    assertThat(
-            createChange(project2, "refs/for/master%remove-private")
-                .getChange()
-                .change()
-                .isPrivate())
-        .isEqualTo(false);
-  }
-
-  @Test
-  public void pushWithPrivateByDefaultDisabled() throws Exception {
-    assertThat(createChange(project2).getChange().change().isPrivate()).isEqualTo(false);
-  }
-
-  @Test
-  public void pushBypassPrivateByDefaultInherited() throws Exception {
-    setPrivateByDefault(project1, InheritableBoolean.TRUE);
-    assertThat(createChange(project2).getChange().change().isPrivate()).isEqualTo(true);
-  }
-
-  private void setPrivateByDefault(Project.NameKey proj, InheritableBoolean value)
-      throws Exception {
-    ConfigInput input = new ConfigInput();
-    input.privateByDefault = value;
-    gApi.projects().name(proj.get()).config(input);
-  }
-
-  private PushOneCommit.Result createChange(Project.NameKey proj) throws Exception {
-    return createChange(proj, "refs/for/master");
-  }
-
-  private PushOneCommit.Result createChange(Project.NameKey proj, String ref) throws Exception {
-    TestRepository<InMemoryRepository> testRepo = cloneProject(proj);
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    PushOneCommit.Result result = push.to(ref);
-    result.assertOkStatus();
-    return result;
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
deleted file mode 100644
index a385932..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ /dev/null
@@ -1,461 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.change.Submit.TestSubmitInput;
-import com.google.gerrit.server.git.ChangeMessageModifier;
-import com.google.gerrit.server.git.strategy.CommitMergeStatus;
-import com.google.inject.Inject;
-import java.util.List;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.Test;
-
-public class SubmitByCherryPickIT extends AbstractSubmit {
-  @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
-
-  @Override
-  protected SubmitType getSubmitType() {
-    return SubmitType.CHERRY_PICK;
-  }
-
-  @Test
-  public void submitWithCherryPickIfFastForwardPossible() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange();
-    submit(change.getChangeId());
-    assertCherryPick(testRepo, false);
-    RevCommit newHead = getRemoteHead();
-    assertThat(newHead.getParent(0)).isEqualTo(change.getCommit().getParent(0));
-
-    assertRefUpdatedEvents(initialHead, newHead);
-    assertChangeMergedEvents(change.getChangeId(), newHead.name());
-  }
-
-  @Test
-  public void submitWithCherryPick() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
-    submit(change2.getChangeId());
-    assertCherryPick(testRepo, false);
-    RevCommit newHead = getRemoteHead();
-    assertThat(newHead.getParentCount()).isEqualTo(1);
-    assertThat(newHead.getParent(0)).isEqualTo(headAfterFirstSubmit);
-    assertCurrentRevision(change2.getChangeId(), 2, newHead);
-    assertSubmitter(change2.getChangeId(), 1);
-    assertSubmitter(change2.getChangeId(), 2);
-    assertPersonEquals(admin.getIdent(), newHead.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), newHead.getCommitterIdent());
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit, headAfterFirstSubmit, newHead);
-    assertChangeMergedEvents(
-        change.getChangeId(), headAfterFirstSubmit.name(), change2.getChangeId(), newHead.name());
-  }
-
-  @Test
-  public void changeMessageOnSubmit() throws Exception {
-    PushOneCommit.Result change = createChange();
-    RegistrationHandle handle =
-        changeMessageModifiers.add(
-            new ChangeMessageModifier() {
-              @Override
-              public String onSubmit(
-                  String newCommitMessage,
-                  RevCommit original,
-                  RevCommit mergeTip,
-                  Branch.NameKey destination) {
-                return newCommitMessage + "Custom: " + destination.get();
-              }
-            });
-    try {
-      submit(change.getChangeId());
-    } finally {
-      handle.remove();
-    }
-    testRepo.git().fetch().setRemote("origin").call();
-    ChangeInfo info = get(change.getChangeId());
-    RevCommit c = testRepo.getRevWalk().parseCommit(ObjectId.fromString(info.currentRevision));
-    testRepo.getRevWalk().parseBody(c);
-    assertThat(c.getFooterLines("Custom")).containsExactly("refs/heads/master");
-    assertThat(c.getFooterLines(FooterConstants.REVIEWED_ON)).hasSize(1);
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
-    submit(change.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
-    submit(change2.getChangeId());
-    RevCommit headAfterSecondSubmit = getRemoteHead();
-
-    testRepo.reset(change.getCommit());
-    PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
-    submit(change3.getChangeId());
-    assertCherryPick(testRepo, true);
-    RevCommit headAfterThirdSubmit = getRemoteHead();
-    assertThat(headAfterThirdSubmit.getParent(0)).isEqualTo(headAfterSecondSubmit);
-    assertApproved(change3.getChangeId());
-    assertCurrentRevision(change3.getChangeId(), 2, headAfterThirdSubmit);
-    assertSubmitter(change2.getChangeId(), 1);
-    assertSubmitter(change2.getChangeId(), 2);
-
-    assertRefUpdatedEvents(
-        initialHead,
-        headAfterFirstSubmit,
-        headAfterFirstSubmit,
-        headAfterSecondSubmit,
-        headAfterSecondSubmit,
-        headAfterThirdSubmit);
-    assertChangeMergedEvents(
-        change.getChangeId(),
-        headAfterFirstSubmit.name(),
-        change2.getChangeId(),
-        headAfterSecondSubmit.name(),
-        change3.getChangeId(),
-        headAfterThirdSubmit.name());
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit newHead = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
-    submitWithConflict(
-        change2.getChangeId(),
-        "Failed to submit 1 change due to the following problems:\n"
-            + "Change "
-            + change2.getChange().getId()
-            + ": Change could not be "
-            + "merged due to a path conflict. Please rebase the change locally and "
-            + "upload the rebased commit for review.");
-
-    assertThat(getRemoteHead()).isEqualTo(newHead);
-    assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
-    assertNoSubmitter(change2.getChangeId(), 1);
-
-    assertRefUpdatedEvents(initialHead, newHead);
-    assertChangeMergedEvents(change.getChangeId(), newHead.name());
-  }
-
-  @Test
-  public void submitOutOfOrder() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    createChange("Change 2", "b.txt", "other content");
-    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "different content");
-    submit(change3.getChangeId());
-    assertCherryPick(testRepo, false);
-    RevCommit headAfterSecondSubmit = getRemoteHead();
-    assertThat(headAfterSecondSubmit.getParent(0)).isEqualTo(headAfterFirstSubmit);
-    assertApproved(change3.getChangeId());
-    assertCurrentRevision(change3.getChangeId(), 2, headAfterSecondSubmit);
-    assertSubmitter(change3.getChangeId(), 1);
-    assertSubmitter(change3.getChangeId(), 2);
-
-    assertRefUpdatedEvents(
-        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(
-        change.getChangeId(),
-        headAfterFirstSubmit.name(),
-        change3.getChangeId(),
-        headAfterSecondSubmit.name());
-  }
-
-  @Test
-  public void submitOutOfOrder_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit newHead = getRemoteHead();
-    testRepo.reset(initialHead);
-    createChange("Change 2", "b.txt", "other content");
-    PushOneCommit.Result change3 = createChange("Change 3", "b.txt", "different content");
-    submitWithConflict(
-        change3.getChangeId(),
-        "Failed to submit 1 change due to the following problems:\n"
-            + "Change "
-            + change3.getChange().getId()
-            + ": Change could not be "
-            + "merged due to a path conflict. Please rebase the change locally and "
-            + "upload the rebased commit for review.");
-
-    assertThat(getRemoteHead()).isEqualTo(newHead);
-    assertCurrentRevision(change3.getChangeId(), 1, change3.getCommit());
-    assertNoSubmitter(change3.getChangeId(), 1);
-
-    assertRefUpdatedEvents(initialHead, newHead);
-    assertChangeMergedEvents(change.getChangeId(), newHead.name());
-  }
-
-  @Test
-  public void submitMultipleChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change = createChange("Change 1", "b", "b");
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
-
-    approve(change.getChangeId());
-    approve(change2.getChangeId());
-    submit(change3.getChangeId());
-
-    List<RevCommit> log = getRemoteLog();
-    assertThat(log.get(0).getShortMessage()).isEqualTo(change3.getCommit().getShortMessage());
-    assertThat(log.get(1).getId()).isEqualTo(initialHead.getId());
-
-    assertNew(change.getChangeId());
-    assertNew(change2.getChangeId());
-
-    assertRefUpdatedEvents(initialHead, log.get(0));
-    assertChangeMergedEvents(change3.getChangeId(), log.get(0).name());
-  }
-
-  @Test
-  public void submitDependentNonConflictingChangesOutOfOrder() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change = createChange("Change 1", "b", "b");
-    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
-    assertThat(change2.getCommit().getParent(0)).isEqualTo(change.getCommit());
-
-    // Submit succeeds; change2 is successfully cherry-picked onto head.
-    submit(change2.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    // Submit succeeds; change is successfully cherry-picked onto head
-    // (which was change2's cherry-pick).
-    submit(change.getChangeId());
-    RevCommit headAfterSecondSubmit = getRemoteHead();
-
-    // change is the new tip.
-    List<RevCommit> log = getRemoteLog();
-    assertThat(log.get(0).getShortMessage()).isEqualTo(change.getCommit().getShortMessage());
-    assertThat(log.get(0).getParent(0)).isEqualTo(log.get(1));
-
-    assertThat(log.get(1).getShortMessage()).isEqualTo(change2.getCommit().getShortMessage());
-    assertThat(log.get(1).getParent(0)).isEqualTo(log.get(2));
-
-    assertThat(log.get(2).getId()).isEqualTo(initialHead.getId());
-
-    assertRefUpdatedEvents(
-        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(
-        change2.getChangeId(),
-        headAfterFirstSubmit.name(),
-        change.getChangeId(),
-        headAfterSecondSubmit.name());
-  }
-
-  @Test
-  public void submitDependentConflictingChangesOutOfOrder() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change = createChange("Change 1", "b", "b1");
-    PushOneCommit.Result change2 = createChange("Change 2", "b", "b2");
-    assertThat(change2.getCommit().getParent(0)).isEqualTo(change.getCommit());
-
-    // Submit fails; change2 contains the delta "b1" -> "b2", which cannot be
-    // applied against tip.
-    submitWithConflict(
-        change2.getChangeId(),
-        "Failed to submit 1 change due to the following problems:\n"
-            + "Change "
-            + change2.getChange().getId()
-            + ": Change could not be "
-            + "merged due to a path conflict. Please rebase the change locally and "
-            + "upload the rebased commit for review.");
-
-    ChangeInfo info3 = get(change2.getChangeId(), ListChangesOption.MESSAGES);
-    assertThat(info3.status).isEqualTo(ChangeStatus.NEW);
-
-    // Tip has not changed.
-    List<RevCommit> log = getRemoteLog();
-    assertThat(log.get(0)).isEqualTo(initialHead.getId());
-    assertNoSubmitter(change2.getChangeId(), 1);
-
-    assertRefUpdatedEvents();
-    assertChangeMergedEvents();
-  }
-
-  @Test
-  public void submitSubsetOfDependentChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change = createChange("Change 1", "b", "b");
-    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
-    PushOneCommit.Result change3 = createChange("Change 3", "e", "e");
-
-    // Out of the above, only submit change 3. Changes 1 and 2 are not
-    // related to change 3 by topic or ancestor (due to cherrypicking!)
-    approve(change2.getChangeId());
-    submit(change3.getChangeId());
-    RevCommit newHead = getRemoteHead();
-
-    assertNew(change.getChangeId());
-    assertNew(change2.getChangeId());
-
-    assertRefUpdatedEvents(initialHead, newHead);
-    assertChangeMergedEvents(change3.getChangeId(), newHead.name());
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitIdenticalTree() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "a");
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "a");
-
-    submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    assertThat(headAfterFirstSubmit.getShortMessage()).isEqualTo("Change 1");
-
-    submit(change2.getChangeId(), new SubmitInput(), null, null);
-
-    assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
-
-    ChangeInfo info2 = get(change2.getChangeId());
-    assertThat(info2.status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(Iterables.getLast(info2.messages).message)
-        .isEqualTo(CommitMergeStatus.SKIPPED_IDENTICAL_TREE.getMessage());
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-    assertChangeMergedEvents(
-        change1.getChangeId(),
-        headAfterFirstSubmit.name(),
-        change2.getChangeId(),
-        headAfterFirstSubmit.name());
-  }
-
-  @Test
-  public void repairChangeStateAfterFailure() throws Exception {
-    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
-    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
-
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
-    Change.Id id2 = change2.getChange().getId();
-    TestSubmitInput failInput = new TestSubmitInput();
-    failInput.failAfterRefUpdates = true;
-    submit(
-        change2.getChangeId(),
-        failInput,
-        ResourceConflictException.class,
-        "Failing after ref updates");
-    RevCommit headAfterFailedSubmit = getRemoteHead();
-
-    // Bad: ref advanced but change wasn't updated.
-    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
-    PatchSet.Id psId2 = new PatchSet.Id(id2, 2);
-    ChangeInfo info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
-    assertThat(getPatchSet(psId2)).isNull();
-
-    ObjectId rev2;
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
-      assertThat(rev1).isNotNull();
-
-      rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
-      assertThat(rev2).isNotNull();
-      assertThat(rev2).isNotEqualTo(rev1);
-      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(headAfterFirstSubmit);
-
-      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
-    }
-
-    submit(change2.getChangeId());
-
-    // Change status and patch set entities were updated, and branch tip stayed
-    // the same.
-    RevCommit headAfterSecondSubmit = getRemoteHead();
-    assertThat(headAfterSecondSubmit).isEqualTo(headAfterFailedSubmit);
-    info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(2);
-    PatchSet ps2 = getPatchSet(psId2);
-    assertThat(ps2).isNotNull();
-    assertThat(ps2.getRevision().get()).isEqualTo(rev2.name());
-    assertThat(Iterables.getLast(info.messages).message)
-        .isEqualTo(
-            "Change has been successfully cherry-picked as " + rev2.name() + " by Administrator");
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
-    }
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-    assertChangeMergedEvents(
-        change.getChangeId(),
-        headAfterFirstSubmit.name(),
-        change2.getChangeId(),
-        headAfterSecondSubmit.name());
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
deleted file mode 100644
index d4397d64..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ /dev/null
@@ -1,218 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.change.Submit.TestSubmitInput;
-import java.util.Map;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.PushResult;
-import org.junit.Test;
-
-public class SubmitByFastForwardIT extends AbstractSubmit {
-
-  @Override
-  protected SubmitType getSubmitType() {
-    return SubmitType.FAST_FORWARD_ONLY;
-  }
-
-  @Test
-  public void submitWithFastForward() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange();
-    submit(change.getChangeId());
-    RevCommit updatedHead = getRemoteHead();
-    assertThat(updatedHead.getId()).isEqualTo(change.getCommit());
-    assertThat(updatedHead.getParent(0)).isEqualTo(initialHead);
-    assertSubmitter(change.getChangeId(), 1);
-
-    assertRefUpdatedEvents(initialHead, updatedHead);
-    assertChangeMergedEvents(change.getChangeId(), updatedHead.name());
-  }
-
-  @Test
-  public void submitMultipleChangesWithFastForward() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    PushOneCommit.Result change = createChange();
-    PushOneCommit.Result change2 = createChange();
-    PushOneCommit.Result change3 = createChange();
-
-    String id1 = change.getChangeId();
-    String id2 = change2.getChangeId();
-    String id3 = change3.getChangeId();
-    approve(id1);
-    approve(id2);
-    submit(id3);
-
-    RevCommit updatedHead = getRemoteHead();
-    assertThat(updatedHead.getId()).isEqualTo(change3.getCommit());
-    assertThat(updatedHead.getParent(0).getId()).isEqualTo(change2.getCommit());
-    assertSubmitter(change.getChangeId(), 1);
-    assertSubmitter(change2.getChangeId(), 1);
-    assertSubmitter(change3.getChangeId(), 1);
-    assertPersonEquals(admin.getIdent(), updatedHead.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), updatedHead.getCommitterIdent());
-    assertSubmittedTogether(id1, id3, id2, id1);
-    assertSubmittedTogether(id2, id3, id2, id1);
-    assertSubmittedTogether(id3, id3, id2, id1);
-
-    assertRefUpdatedEvents(initialHead, updatedHead);
-    assertChangeMergedEvents(
-        id1, updatedHead.name(), id2, updatedHead.name(), id3, updatedHead.name());
-  }
-
-  @Test
-  public void submitTwoChangesWithFastForward_missingDependency() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change1 = createChange();
-    PushOneCommit.Result change2 = createChange();
-
-    Change.Id id1 = change1.getPatchSetId().getParentKey();
-    submitWithConflict(
-        change2.getChangeId(),
-        "Failed to submit 2 changes due to the following problems:\n"
-            + "Change "
-            + id1
-            + ": needs Code-Review");
-
-    RevCommit updatedHead = getRemoteHead();
-    assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
-    assertRefUpdatedEvents();
-    assertChangeMergedEvents();
-  }
-
-  @Test
-  public void submitFastForwardNotPossible_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
-
-    approve(change2.getChangeId());
-    Map<String, ActionInfo> actions = getActions(change2.getChangeId());
-
-    assertThat(actions).containsKey("submit");
-    ActionInfo info = actions.get("submit");
-    assertThat(info.enabled).isNull();
-
-    submitWithConflict(
-        change2.getChangeId(),
-        "Failed to submit 1 change due to the following problems:\n"
-            + "Change "
-            + change2.getChange().getId()
-            + ": Project policy requires "
-            + "all submissions to be a fast-forward. Please rebase the change "
-            + "locally and upload again for review.");
-    assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
-    assertSubmitter(change.getChangeId(), 1);
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
-  }
-
-  @Test
-  public void repairChangeStateAfterFailure() throws Exception {
-    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
-    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
-
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    Change.Id id = change.getChange().getId();
-    TestSubmitInput failInput = new TestSubmitInput();
-    failInput.failAfterRefUpdates = true;
-    submit(
-        change.getChangeId(),
-        failInput,
-        ResourceConflictException.class,
-        "Failing after ref updates");
-
-    // Bad: ref advanced but change wasn't updated.
-    PatchSet.Id psId = new PatchSet.Id(id, 1);
-    ChangeInfo info = gApi.changes().id(id.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
-
-    ObjectId rev;
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      rev = repo.exactRef(psId.toRefName()).getObjectId();
-      assertThat(rev).isNotNull();
-      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev);
-    }
-
-    submit(change.getChangeId());
-
-    // Change status was updated, and branch tip stayed the same.
-    info = gApi.changes().id(id.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
-    assertThat(Iterables.getLast(info.messages).message)
-        .isEqualTo("Change has been successfully merged by Administrator");
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev);
-    }
-
-    assertRefUpdatedEvents();
-    assertChangeMergedEvents(change.getChangeId(), getRemoteHead().name());
-  }
-
-  @Test
-  public void submitSameCommitsAsInExperimentalBranch() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    grant(project, "refs/heads/*", Permission.CREATE);
-    grant(project, "refs/heads/experimental", Permission.PUSH);
-
-    RevCommit c1 = commitBuilder().add("b.txt", "1").message("commit at tip").create();
-    String id1 = GitUtil.getChangeId(testRepo, c1).get();
-
-    PushResult r1 = pushHead(testRepo, "refs/for/master", false);
-    assertThat(r1.getRemoteUpdate("refs/for/master").getNewObjectId()).isEqualTo(c1.getId());
-
-    PushResult r2 = pushHead(testRepo, "refs/heads/experimental", false);
-    assertThat(r2.getRemoteUpdate("refs/heads/experimental").getNewObjectId())
-        .isEqualTo(c1.getId());
-
-    submit(id1);
-    RevCommit headAfterSubmit = getRemoteHead();
-
-    assertThat(getRemoteHead().getId()).isEqualTo(c1.getId());
-    assertSubmitter(id1, 1);
-
-    assertRefUpdatedEvents(initialHead, headAfterSubmit);
-    assertChangeMergedEvents(id1, headAfterSubmit.name());
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
deleted file mode 100644
index bb4abe1..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ /dev/null
@@ -1,539 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
-
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.api.changes.ChangeApi;
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import java.io.File;
-import java.io.InputStream;
-import java.nio.file.Files;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.zip.GZIPInputStream;
-import org.apache.commons.compress.archivers.ArchiveStreamFactory;
-import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
-import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.RefSpec;
-import org.junit.Test;
-
-public class SubmitByMergeIfNecessaryIT extends AbstractSubmitByMerge {
-
-  @Override
-  protected SubmitType getSubmitType() {
-    return SubmitType.MERGE_IF_NECESSARY;
-  }
-
-  @Test
-  public void submitWithFastForward() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change = createChange();
-    submit(change.getChangeId());
-    RevCommit updatedHead = getRemoteHead();
-    assertThat(updatedHead.getId()).isEqualTo(change.getCommit());
-    assertThat(updatedHead.getParent(0)).isEqualTo(initialHead);
-    assertSubmitter(change.getChangeId(), 1);
-    assertPersonEquals(admin.getIdent(), updatedHead.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), updatedHead.getCommitterIdent());
-
-    assertRefUpdatedEvents(initialHead, updatedHead);
-    assertChangeMergedEvents(change.getChangeId(), updatedHead.name());
-  }
-
-  @Test
-  public void submitMultipleChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change = createChange("Change 1", "b", "b");
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
-    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
-    PushOneCommit.Result change5 = createChange("Change 5", "f", "f");
-
-    // Change 2 is a fast-forward, no need to merge.
-    submit(change2.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteLog().get(0);
-    assertThat(headAfterFirstSubmit.getShortMessage())
-        .isEqualTo(change2.getCommit().getShortMessage());
-    assertThat(headAfterFirstSubmit.getParent(0).getId()).isEqualTo(initialHead.getId());
-    assertPersonEquals(admin.getIdent(), headAfterFirstSubmit.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), headAfterFirstSubmit.getCommitterIdent());
-
-    // We need to merge changes 3, 4 and 5.
-    approve(change3.getChangeId());
-    approve(change4.getChangeId());
-    submit(change5.getChangeId());
-
-    RevCommit headAfterSecondSubmit = getRemoteLog().get(0);
-    assertThat(headAfterSecondSubmit.getParent(1).getShortMessage())
-        .isEqualTo(change5.getCommit().getShortMessage());
-    assertThat(headAfterSecondSubmit.getParent(0).getShortMessage())
-        .isEqualTo(change2.getCommit().getShortMessage());
-
-    assertPersonEquals(admin.getIdent(), headAfterSecondSubmit.getAuthorIdent());
-    assertPersonEquals(serverIdent.get(), headAfterSecondSubmit.getCommitterIdent());
-
-    // First change stays untouched.
-    assertNew(change.getChangeId());
-
-    // The two submit operations should have resulted in two ref-update events
-    // and three change-merged events.
-    assertRefUpdatedEvents(
-        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(
-        change2.getChangeId(),
-        headAfterFirstSubmit.name(),
-        change3.getChangeId(),
-        headAfterSecondSubmit.name(),
-        change4.getChangeId(),
-        headAfterSecondSubmit.name(),
-        change5.getChangeId(),
-        headAfterSecondSubmit.name());
-  }
-
-  @Test
-  public void submitChangesAcrossRepos() throws Exception {
-    Project.NameKey p1 = createProject("project-where-we-submit");
-    Project.NameKey p2 = createProject("project-impacted-via-topic");
-    Project.NameKey p3 = createProject("project-impacted-indirectly-via-topic");
-
-    RevCommit initialHead2 = getRemoteHead(p2, "master");
-    RevCommit initialHead3 = getRemoteHead(p3, "master");
-
-    TestRepository<?> repo1 = cloneProject(p1);
-    TestRepository<?> repo2 = cloneProject(p2);
-    TestRepository<?> repo3 = cloneProject(p3);
-
-    PushOneCommit.Result change1a =
-        createChange(
-            repo1,
-            "master",
-            "An ancestor of the change we want to submit",
-            "a.txt",
-            "1",
-            "dependent-topic");
-    PushOneCommit.Result change1b =
-        createChange(
-            repo1,
-            "master",
-            "We're interested in submitting this change",
-            "a.txt",
-            "2",
-            "topic-to-submit");
-
-    PushOneCommit.Result change2a =
-        createChange(repo2, "master", "indirection level 1", "a.txt", "1", "topic-indirect");
-    PushOneCommit.Result change2b =
-        createChange(
-            repo2, "master", "should go in with first change", "a.txt", "2", "dependent-topic");
-
-    PushOneCommit.Result change3 =
-        createChange(repo3, "master", "indirection level 2", "a.txt", "1", "topic-indirect");
-
-    approve(change1a.getChangeId());
-    approve(change2a.getChangeId());
-    approve(change2b.getChangeId());
-    approve(change3.getChangeId());
-
-    // get a preview before submitting:
-    Map<Branch.NameKey, ObjectId> preview = fetchFromSubmitPreview(change1b.getChangeId());
-    submit(change1b.getChangeId());
-
-    RevCommit tip1 = getRemoteLog(p1, "master").get(0);
-    RevCommit tip2 = getRemoteLog(p2, "master").get(0);
-    RevCommit tip3 = getRemoteLog(p3, "master").get(0);
-
-    assertThat(tip1.getShortMessage()).isEqualTo(change1b.getCommit().getShortMessage());
-
-    if (isSubmitWholeTopicEnabled()) {
-      assertThat(tip2.getShortMessage()).isEqualTo(change2b.getCommit().getShortMessage());
-      assertThat(tip3.getShortMessage()).isEqualTo(change3.getCommit().getShortMessage());
-
-      // check that the preview matched what happened:
-      assertThat(preview).hasSize(3);
-
-      assertThat(preview).containsKey(new Branch.NameKey(p1, "refs/heads/master"));
-      assertTrees(p1, preview);
-
-      assertThat(preview).containsKey(new Branch.NameKey(p2, "refs/heads/master"));
-      assertTrees(p2, preview);
-
-      assertThat(preview).containsKey(new Branch.NameKey(p3, "refs/heads/master"));
-      assertTrees(p3, preview);
-    } else {
-      assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
-      assertThat(tip3.getShortMessage()).isEqualTo(initialHead3.getShortMessage());
-      assertThat(preview).hasSize(1);
-      assertThat(preview.get(new Branch.NameKey(p1, "refs/heads/master"))).isNotNull();
-    }
-  }
-
-  @Test
-  public void submitChangesAcrossReposBlocked() throws Exception {
-    Project.NameKey p1 = createProject("project-where-we-submit");
-    Project.NameKey p2 = createProject("project-impacted-via-topic");
-    Project.NameKey p3 = createProject("project-impacted-indirectly-via-topic");
-
-    TestRepository<?> repo1 = cloneProject(p1);
-    TestRepository<?> repo2 = cloneProject(p2);
-    TestRepository<?> repo3 = cloneProject(p3);
-
-    RevCommit initialHead1 = getRemoteHead(p1, "master");
-    RevCommit initialHead2 = getRemoteHead(p2, "master");
-    RevCommit initialHead3 = getRemoteHead(p3, "master");
-
-    PushOneCommit.Result change1a =
-        createChange(
-            repo1,
-            "master",
-            "An ancestor of the change we want to submit",
-            "a.txt",
-            "1",
-            "dependent-topic");
-    PushOneCommit.Result change1b =
-        createChange(
-            repo1,
-            "master",
-            "we're interested to submit this change",
-            "a.txt",
-            "2",
-            "topic-to-submit");
-
-    PushOneCommit.Result change2a =
-        createChange(repo2, "master", "indirection level 2a", "a.txt", "1", "topic-indirect");
-    PushOneCommit.Result change2b =
-        createChange(
-            repo2, "master", "should go in with first change", "a.txt", "2", "dependent-topic");
-
-    PushOneCommit.Result change3 =
-        createChange(repo3, "master", "indirection level 2b", "a.txt", "1", "topic-indirect");
-
-    // Create a merge conflict for change3 which is only indirectly related
-    // via topics.
-    repo3.reset(initialHead3);
-    PushOneCommit.Result change3Conflict =
-        createChange(repo3, "master", "conflicting change", "a.txt", "2\n2", "conflicting-topic");
-    submit(change3Conflict.getChangeId());
-    RevCommit tipConflict = getRemoteLog(p3, "master").get(0);
-    assertThat(tipConflict.getShortMessage())
-        .isEqualTo(change3Conflict.getCommit().getShortMessage());
-
-    approve(change1a.getChangeId());
-    approve(change2a.getChangeId());
-    approve(change2b.getChangeId());
-    approve(change3.getChangeId());
-
-    if (isSubmitWholeTopicEnabled()) {
-      String msg =
-          "Failed to submit 5 changes due to the following problems:\n"
-              + "Change "
-              + change3.getChange().getId()
-              + ": Change could not be "
-              + "merged due to a path conflict. Please rebase the change locally "
-              + "and upload the rebased commit for review.";
-
-      // Get a preview before submitting:
-      try (BinaryResult r = submitPreview(change1b.getChangeId())) {
-        // We cannot just use the ExpectedException infrastructure as provided
-        // by AbstractDaemonTest, as then we'd stop early and not test the
-        // actual submit.
-
-        fail("expected failure");
-      } catch (RestApiException e) {
-        assertThat(e.getMessage()).isEqualTo(msg);
-      }
-      submitWithConflict(change1b.getChangeId(), msg);
-    } else {
-      submit(change1b.getChangeId());
-    }
-
-    RevCommit tip1 = getRemoteLog(p1, "master").get(0);
-    RevCommit tip2 = getRemoteLog(p2, "master").get(0);
-    RevCommit tip3 = getRemoteLog(p3, "master").get(0);
-    if (isSubmitWholeTopicEnabled()) {
-      assertThat(tip1.getShortMessage()).isEqualTo(initialHead1.getShortMessage());
-      assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
-      assertThat(tip3.getShortMessage()).isEqualTo(change3Conflict.getCommit().getShortMessage());
-      assertNoSubmitter(change1a.getChangeId(), 1);
-      assertNoSubmitter(change2a.getChangeId(), 1);
-      assertNoSubmitter(change2b.getChangeId(), 1);
-      assertNoSubmitter(change3.getChangeId(), 1);
-    } else {
-      assertThat(tip1.getShortMessage()).isEqualTo(change1b.getCommit().getShortMessage());
-      assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
-      assertThat(tip3.getShortMessage()).isEqualTo(change3Conflict.getCommit().getShortMessage());
-      assertNoSubmitter(change2a.getChangeId(), 1);
-      assertNoSubmitter(change2b.getChangeId(), 1);
-      assertNoSubmitter(change3.getChangeId(), 1);
-    }
-  }
-
-  @Test
-  public void submitWithMergedAncestorsOnOtherBranch() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    PushOneCommit.Result change1 =
-        createChange(testRepo, "master", "base commit", "a.txt", "1", "");
-    submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-
-    gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
-
-    PushOneCommit.Result change2 =
-        createChange(
-            testRepo, "master", "We want to commit this to master first", "a.txt", "2", "");
-
-    submit(change2.getChangeId());
-
-    RevCommit headAfterSecondSubmit = getRemoteLog(project, "master").get(0);
-    assertThat(headAfterSecondSubmit.getShortMessage())
-        .isEqualTo(change2.getCommit().getShortMessage());
-
-    RevCommit tip2 = getRemoteLog(project, "branch").get(0);
-    assertThat(tip2.getShortMessage()).isEqualTo(change1.getCommit().getShortMessage());
-
-    PushOneCommit.Result change3 =
-        createChange(
-            testRepo,
-            "branch",
-            "This commit is based on master, which includes change2, "
-                + "but is targeted at branch, which doesn't include it.",
-            "a.txt",
-            "3",
-            "");
-
-    submit(change3.getChangeId());
-
-    List<RevCommit> log3 = getRemoteLog(project, "branch");
-    assertThat(log3.get(0).getShortMessage()).isEqualTo(change3.getCommit().getShortMessage());
-    assertThat(log3.get(1).getShortMessage()).isEqualTo(change2.getCommit().getShortMessage());
-
-    assertRefUpdatedEvents(
-        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(
-        change1.getChangeId(),
-        headAfterFirstSubmit.name(),
-        change2.getChangeId(),
-        headAfterSecondSubmit.name());
-  }
-
-  @Test
-  public void submitWithOpenAncestorsOnOtherBranch() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change1 =
-        createChange(testRepo, "master", "base commit", "a.txt", "1", "");
-    submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-
-    gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
-
-    PushOneCommit.Result change2 =
-        createChange(
-            testRepo, "master", "We want to commit this to master first", "a.txt", "2", "");
-
-    approve(change2.getChangeId());
-
-    RevCommit tip1 = getRemoteLog(project, "master").get(0);
-    assertThat(tip1.getShortMessage()).isEqualTo(change1.getCommit().getShortMessage());
-
-    RevCommit tip2 = getRemoteLog(project, "branch").get(0);
-    assertThat(tip2.getShortMessage()).isEqualTo(change1.getCommit().getShortMessage());
-
-    PushOneCommit.Result change3a =
-        createChange(
-            testRepo,
-            "branch",
-            "This commit is based on change2 pending for master, "
-                + "but is targeted itself at branch, which doesn't include it.",
-            "a.txt",
-            "3",
-            "a-topic-here");
-
-    Project.NameKey p3 = createProject("project-related-to-change3");
-    TestRepository<?> repo3 = cloneProject(p3);
-    RevCommit repo3Head = getRemoteHead(p3, "master");
-    PushOneCommit.Result change3b =
-        createChange(
-            repo3,
-            "master",
-            "some accompanying changes for change3a in another repo tied together via topic",
-            "a.txt",
-            "1",
-            "a-topic-here");
-    approve(change3b.getChangeId());
-
-    String cnt = isSubmitWholeTopicEnabled() ? "2 changes" : "1 change";
-    submitWithConflict(
-        change3a.getChangeId(),
-        "Failed to submit "
-            + cnt
-            + " due to the following problems:\n"
-            + "Change "
-            + change3a.getChange().getId()
-            + ": depends on change that"
-            + " was not submitted");
-
-    RevCommit tipbranch = getRemoteLog(project, "branch").get(0);
-    assertThat(tipbranch.getShortMessage()).isEqualTo(change1.getCommit().getShortMessage());
-
-    RevCommit tipmaster = getRemoteLog(p3, "master").get(0);
-    assertThat(tipmaster.getShortMessage()).isEqualTo(repo3Head.getShortMessage());
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name());
-  }
-
-  @Test
-  public void gerritWorkflow() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    // We'll setup a master and a stable branch.
-    // Then we create a change to be applied to master, which is
-    // then cherry picked back to stable. The stable branch will
-    // be merged up into master again.
-    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
-
-    // Push a change to master
-    PushOneCommit push =
-        pushFactory.create(db, user.getIdent(), testRepo, "small fix", "a.txt", "2");
-    PushOneCommit.Result change = push.to("refs/for/master");
-    submit(change.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteLog(project, "master").get(0);
-    assertThat(headAfterFirstSubmit.getShortMessage())
-        .isEqualTo(change.getCommit().getShortMessage());
-
-    // Now cherry pick to stable
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "stable";
-    in.message = "This goes to stable as well\n" + headAfterFirstSubmit.getFullMessage();
-    ChangeApi orig = gApi.changes().id(change.getChangeId());
-    String cherryId = orig.current().cherryPick(in).id();
-    gApi.changes().id(cherryId).current().review(ReviewInput.approve());
-    gApi.changes().id(cherryId).current().submit();
-
-    // Create the merge locally
-    RevCommit stable = getRemoteHead(project, "stable");
-    RevCommit master = getRemoteHead(project, "master");
-    testRepo.git().fetch().call();
-    testRepo.git().branchCreate().setName("stable").setStartPoint(stable).call();
-    testRepo.git().branchCreate().setName("master").setStartPoint(master).call();
-
-    RevCommit merge =
-        testRepo
-            .commit()
-            .parent(master)
-            .parent(stable)
-            .message("Merge stable into master")
-            .insertChangeId()
-            .create();
-
-    testRepo.branch("refs/heads/master").update(merge);
-    testRepo.git().push().setRefSpecs(new RefSpec("refs/heads/master:refs/for/master")).call();
-
-    String changeId = GitUtil.getChangeId(testRepo, merge).get();
-    approve(changeId);
-    submit(changeId);
-    RevCommit headAfterSecondSubmit = getRemoteLog(project, "master").get(0);
-    assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo(merge.getShortMessage());
-
-    assertRefUpdatedEvents(
-        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(
-        change.getChangeId(), headAfterFirstSubmit.name(), changeId, headAfterSecondSubmit.name());
-  }
-
-  @Test
-  public void openChangeForTargetBranchPreventsMerge() throws Exception {
-    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
-
-    // Propose a change for master, but leave it open for master!
-    PushOneCommit change =
-        pushFactory.create(db, user.getIdent(), testRepo, "small fix", "a.txt", "2");
-    PushOneCommit.Result change2result = change.to("refs/for/master");
-
-    // Now cherry pick to stable
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "stable";
-    in.message = "it goes to stable branch";
-    ChangeApi orig = gApi.changes().id(change2result.getChangeId());
-    ChangeApi cherry = orig.current().cherryPick(in);
-    cherry.current().review(ReviewInput.approve());
-    cherry.current().submit();
-
-    // Create a commit locally
-    testRepo.git().fetch().setRefSpecs(new RefSpec("refs/heads/stable")).call();
-
-    PushOneCommit.Result change3 = createChange(testRepo, "stable", "test", "a.txt", "3", "");
-    submitWithConflict(
-        change3.getChangeId(),
-        "Failed to submit 1 change due to the following problems:\n"
-            + "Change "
-            + change3.getPatchSetId().getParentKey().get()
-            + ": depends on change that was not submitted");
-
-    assertRefUpdatedEvents();
-    assertChangeMergedEvents();
-  }
-
-  @Test
-  public void testPreviewSubmitTgz() throws Exception {
-    Project.NameKey p1 = createProject("project-name");
-
-    TestRepository<?> repo1 = cloneProject(p1);
-    PushOneCommit.Result change1 = createChange(repo1, "master", "test", "a.txt", "1", "topic");
-    approve(change1.getChangeId());
-
-    // get a preview before submitting:
-    File tempfile;
-    try (BinaryResult request = submitPreview(change1.getChangeId(), "tgz")) {
-      assertThat(request.getContentType()).isEqualTo("application/x-gzip");
-      tempfile = File.createTempFile("test", null);
-      request.writeTo(Files.newOutputStream(tempfile.toPath()));
-    }
-
-    InputStream is = new GZIPInputStream(Files.newInputStream(tempfile.toPath()));
-
-    List<String> untarredFiles = new ArrayList<>();
-    try (TarArchiveInputStream tarInputStream =
-        (TarArchiveInputStream) new ArchiveStreamFactory().createArchiveInputStream("tar", is)) {
-      TarArchiveEntry entry = null;
-      while ((entry = (TarArchiveEntry) tarInputStream.getNextEntry()) != null) {
-        untarredFiles.add(entry.getName());
-      }
-    }
-    assertThat(untarredFiles).containsExactly(name("project-name") + ".git");
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
deleted file mode 100644
index e4c929a..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.git.ChangeMessageModifier;
-import com.google.inject.Inject;
-import java.util.List;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Test;
-
-public class SubmitByRebaseAlwaysIT extends AbstractSubmitByRebase {
-  @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
-
-  @Override
-  protected SubmitType getSubmitType() {
-    return SubmitType.REBASE_ALWAYS;
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithPossibleFastForward() throws Exception {
-    RevCommit oldHead = getRemoteHead();
-    PushOneCommit.Result change = createChange();
-    submit(change.getChangeId());
-
-    RevCommit head = getRemoteHead();
-    assertThat(head.getId()).isNotEqualTo(change.getCommit());
-    assertThat(head.getParent(0)).isEqualTo(oldHead);
-    assertApproved(change.getChangeId());
-    assertCurrentRevision(change.getChangeId(), 2, head);
-    assertSubmitter(change.getChangeId(), 1);
-    assertSubmitter(change.getChangeId(), 2);
-    assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), head.getCommitterIdent());
-    assertRefUpdatedEvents(oldHead, head);
-    assertChangeMergedEvents(change.getChangeId(), head.name());
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void alwaysAddFooters() throws Exception {
-    PushOneCommit.Result change1 = createChange();
-    PushOneCommit.Result change2 = createChange();
-
-    assertThat(getCurrentCommit(change1).getFooterLines(FooterConstants.REVIEWED_BY)).isEmpty();
-    assertThat(getCurrentCommit(change2).getFooterLines(FooterConstants.REVIEWED_BY)).isEmpty();
-
-    // change1 is a fast-forward, but should be rebased in cherry pick style
-    // anyway, making change2 not a fast-forward, requiring a rebase.
-    approve(change1.getChangeId());
-    submit(change2.getChangeId());
-    // ... but both changes should get reviewed-by footers.
-    assertLatestRevisionHasFooters(change1);
-    assertLatestRevisionHasFooters(change2);
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void changeMessageOnSubmit() throws Exception {
-    PushOneCommit.Result change1 = createChange();
-    PushOneCommit.Result change2 = createChange();
-
-    RegistrationHandle handle =
-        changeMessageModifiers.add(
-            new ChangeMessageModifier() {
-              @Override
-              public String onSubmit(
-                  String newCommitMessage,
-                  RevCommit original,
-                  RevCommit mergeTip,
-                  Branch.NameKey destination) {
-                List<String> custom = mergeTip.getFooterLines("Custom");
-                if (!custom.isEmpty()) {
-                  newCommitMessage += "Custom-Parent: " + custom.get(0) + "\n";
-                }
-                return newCommitMessage + "Custom: " + destination.get();
-              }
-            });
-    try {
-      // change1 is a fast-forward, but should be rebased in cherry pick style
-      // anyway, making change2 not a fast-forward, requiring a rebase.
-      approve(change1.getChangeId());
-      submit(change2.getChangeId());
-    } finally {
-      handle.remove();
-    }
-    // ... but both changes should get custom footers.
-    assertThat(getCurrentCommit(change1).getFooterLines("Custom"))
-        .containsExactly("refs/heads/master");
-    assertThat(getCurrentCommit(change2).getFooterLines("Custom"))
-        .containsExactly("refs/heads/master");
-    assertThat(getCurrentCommit(change2).getFooterLines("Custom-Parent"))
-        .containsExactly("refs/heads/master");
-  }
-
-  private void assertLatestRevisionHasFooters(PushOneCommit.Result change) throws Exception {
-    RevCommit c = getCurrentCommit(change);
-    assertThat(c.getFooterLines(FooterConstants.CHANGE_ID)).isNotEmpty();
-    assertThat(c.getFooterLines(FooterConstants.REVIEWED_BY)).isNotEmpty();
-    assertThat(c.getFooterLines(FooterConstants.REVIEWED_ON)).isNotEmpty();
-  }
-
-  private RevCommit getCurrentCommit(PushOneCommit.Result change) throws Exception {
-    testRepo.git().fetch().setRemote("origin").call();
-    ChangeInfo info = get(change.getChangeId());
-    RevCommit c = testRepo.getRevWalk().parseCommit(ObjectId.fromString(info.currentRevision));
-    testRepo.getRevWalk().parseBody(c);
-    return c;
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
deleted file mode 100644
index bb7da11..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ /dev/null
@@ -1,382 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-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.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.Submit;
-import com.google.gerrit.server.git.ChangeSet;
-import com.google.gerrit.server.git.MergeSuperSet;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Test;
-
-@NoHttpd
-public class SubmitResolvingMergeCommitIT extends AbstractDaemonTest {
-  @Inject private Provider<MergeSuperSet> mergeSuperSet;
-
-  @Inject private Submit submit;
-
-  @ConfigSuite.Default
-  public static Config submitWholeTopicEnabled() {
-    return submitWholeTopicEnabledConfig();
-  }
-
-  @Test
-  public void resolvingMergeCommitAtEndOfChain() throws Exception {
-    /*
-      A <- B <- C <------- D
-      ^                    ^
-      |                    |
-      E <- F <- G <- H <-- M*
-
-      G has a conflict with C and is resolved in M which is a merge
-      commit of H and D.
-    */
-
-    PushOneCommit.Result a = createChange("A");
-    PushOneCommit.Result b =
-        createChange("B", "new.txt", "No conflict line", ImmutableList.of(a.getCommit()));
-    PushOneCommit.Result c = createChange("C", ImmutableList.of(b.getCommit()));
-    PushOneCommit.Result d = createChange("D", ImmutableList.of(c.getCommit()));
-
-    PushOneCommit.Result e = createChange("E", ImmutableList.of(a.getCommit()));
-    PushOneCommit.Result f = createChange("F", ImmutableList.of(e.getCommit()));
-    PushOneCommit.Result g =
-        createChange("G", "new.txt", "Conflicting line", ImmutableList.of(f.getCommit()));
-    PushOneCommit.Result h = createChange("H", ImmutableList.of(g.getCommit()));
-
-    approve(a.getChangeId());
-    approve(b.getChangeId());
-    approve(c.getChangeId());
-    approve(d.getChangeId());
-    submit(d.getChangeId());
-
-    approve(e.getChangeId());
-    approve(f.getChangeId());
-    approve(g.getChangeId());
-    approve(h.getChangeId());
-
-    assertMergeable(e.getChange());
-    assertMergeable(f.getChange());
-    assertNotMergeable(g.getChange());
-    assertNotMergeable(h.getChange());
-
-    PushOneCommit.Result m =
-        createChange(
-            "M", "new.txt", "Resolved conflict", ImmutableList.of(d.getCommit(), h.getCommit()));
-    approve(m.getChangeId());
-
-    assertChangeSetMergeable(m.getChange(), true);
-
-    assertMergeable(m.getChange());
-    submit(m.getChangeId());
-
-    assertMerged(e.getChangeId());
-    assertMerged(f.getChangeId());
-    assertMerged(g.getChangeId());
-    assertMerged(h.getChangeId());
-    assertMerged(m.getChangeId());
-  }
-
-  @Test
-  public void resolvingMergeCommitComingBeforeConflict() throws Exception {
-    /*
-      A <- B <- C <- D
-      ^    ^
-      |    |
-      E <- F* <- G
-
-      F is a merge commit of E and B and resolves any conflict.
-      However G is conflicting with C.
-    */
-
-    PushOneCommit.Result a = createChange("A");
-    PushOneCommit.Result b =
-        createChange("B", "new.txt", "No conflict line", ImmutableList.of(a.getCommit()));
-    PushOneCommit.Result c =
-        createChange("C", "new.txt", "No conflict line #2", ImmutableList.of(b.getCommit()));
-    PushOneCommit.Result d = createChange("D", ImmutableList.of(c.getCommit()));
-    PushOneCommit.Result e =
-        createChange("E", "new.txt", "Conflicting line", ImmutableList.of(a.getCommit()));
-    PushOneCommit.Result f =
-        createChange(
-            "F", "new.txt", "Resolved conflict", ImmutableList.of(b.getCommit(), e.getCommit()));
-    PushOneCommit.Result g =
-        createChange("G", "new.txt", "Conflicting line #2", ImmutableList.of(f.getCommit()));
-
-    assertMergeable(e.getChange());
-
-    approve(a.getChangeId());
-    approve(b.getChangeId());
-    submit(b.getChangeId());
-
-    assertNotMergeable(e.getChange());
-    assertMergeable(f.getChange());
-    assertMergeable(g.getChange());
-
-    approve(c.getChangeId());
-    approve(d.getChangeId());
-    submit(d.getChangeId());
-
-    approve(e.getChangeId());
-    approve(f.getChangeId());
-    approve(g.getChangeId());
-
-    assertNotMergeable(g.getChange());
-    assertChangeSetMergeable(g.getChange(), false);
-  }
-
-  @Test
-  public void resolvingMergeCommitWithTopics() throws Exception {
-    /*
-      Project1:
-        A <- B <-- C <---
-        ^    ^          |
-        |    |          |
-        E <- F* <- G <- L*
-
-      G clashes with C, and F resolves the clashes between E and B.
-      Later, L resolves the clashes between C and G.
-
-      Project2:
-        H <- I
-        ^    ^
-        |    |
-        J <- K*
-
-      J clashes with I, and K resolves all problems.
-      G, K and L are in the same topic.
-    */
-    assume().that(isSubmitWholeTopicEnabled()).isTrue();
-
-    String project1Name = name("Project1");
-    String project2Name = name("Project2");
-    gApi.projects().create(project1Name);
-    gApi.projects().create(project2Name);
-    TestRepository<InMemoryRepository> project1 = cloneProject(new Project.NameKey(project1Name));
-    TestRepository<InMemoryRepository> project2 = cloneProject(new Project.NameKey(project2Name));
-
-    PushOneCommit.Result a = createChange(project1, "A");
-    PushOneCommit.Result b =
-        createChange(project1, "B", "new.txt", "No conflict line", ImmutableList.of(a.getCommit()));
-    PushOneCommit.Result c =
-        createChange(
-            project1, "C", "new.txt", "No conflict line #2", ImmutableList.of(b.getCommit()));
-
-    approve(a.getChangeId());
-    approve(b.getChangeId());
-    approve(c.getChangeId());
-    submit(c.getChangeId());
-
-    PushOneCommit.Result e =
-        createChange(project1, "E", "new.txt", "Conflicting line", ImmutableList.of(a.getCommit()));
-    PushOneCommit.Result f =
-        createChange(
-            project1,
-            "F",
-            "new.txt",
-            "Resolved conflict",
-            ImmutableList.of(b.getCommit(), e.getCommit()));
-    PushOneCommit.Result g =
-        createChange(
-            project1,
-            "G",
-            "new.txt",
-            "Conflicting line #2",
-            ImmutableList.of(f.getCommit()),
-            "refs/for/master/" + name("topic1"));
-
-    PushOneCommit.Result h = createChange(project2, "H");
-    PushOneCommit.Result i =
-        createChange(project2, "I", "new.txt", "No conflict line", ImmutableList.of(h.getCommit()));
-    PushOneCommit.Result j =
-        createChange(project2, "J", "new.txt", "Conflicting line", ImmutableList.of(h.getCommit()));
-    PushOneCommit.Result k =
-        createChange(
-            project2,
-            "K",
-            "new.txt",
-            "Sadly conflicting topic-wise",
-            ImmutableList.of(i.getCommit(), j.getCommit()),
-            "refs/for/master/" + name("topic1"));
-
-    approve(h.getChangeId());
-    approve(i.getChangeId());
-    submit(i.getChangeId());
-
-    approve(e.getChangeId());
-    approve(f.getChangeId());
-    approve(g.getChangeId());
-    approve(j.getChangeId());
-    approve(k.getChangeId());
-
-    assertChangeSetMergeable(g.getChange(), false);
-    assertChangeSetMergeable(k.getChange(), false);
-
-    PushOneCommit.Result l =
-        createChange(
-            project1,
-            "L",
-            "new.txt",
-            "Resolving conflicts again",
-            ImmutableList.of(c.getCommit(), g.getCommit()),
-            "refs/for/master/" + name("topic1"));
-
-    approve(l.getChangeId());
-    assertChangeSetMergeable(l.getChange(), true);
-
-    submit(l.getChangeId());
-    assertMerged(c.getChangeId());
-    assertMerged(g.getChangeId());
-    assertMerged(k.getChangeId());
-  }
-
-  @Test
-  public void resolvingMergeCommitAtEndOfChainAndNotUpToDate() throws Exception {
-    /*
-        A <-- B
-         \
-          C  <- D
-           \   /
-             E
-
-        B is the target branch, and D should be merged with B, but one
-        of C conflicts with B
-    */
-
-    PushOneCommit.Result a = createChange("A");
-    PushOneCommit.Result b =
-        createChange("B", "new.txt", "No conflict line", ImmutableList.of(a.getCommit()));
-
-    approve(a.getChangeId());
-    approve(b.getChangeId());
-    submit(b.getChangeId());
-
-    PushOneCommit.Result c =
-        createChange("C", "new.txt", "Create conflicts", ImmutableList.of(a.getCommit()));
-    PushOneCommit.Result e = createChange("E", ImmutableList.of(c.getCommit()));
-    PushOneCommit.Result d =
-        createChange(
-            "D", "new.txt", "Resolves conflicts", ImmutableList.of(c.getCommit(), e.getCommit()));
-
-    approve(c.getChangeId());
-    approve(e.getChangeId());
-    approve(d.getChangeId());
-    assertNotMergeable(d.getChange());
-    assertChangeSetMergeable(d.getChange(), false);
-  }
-
-  private void submit(String changeId) throws Exception {
-    gApi.changes().id(changeId).current().submit();
-  }
-
-  private void assertChangeSetMergeable(ChangeData change, boolean expected)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException, OrmException,
-          PermissionBackendException {
-    ChangeSet cs = mergeSuperSet.get().completeChangeSet(db, change.change(), user(admin));
-    assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
-  }
-
-  private void assertMergeable(ChangeData change) throws Exception {
-    change.setMergeable(null);
-    assertThat(change.isMergeable()).isTrue();
-  }
-
-  private void assertNotMergeable(ChangeData change) throws Exception {
-    change.setMergeable(null);
-    assertThat(change.isMergeable()).isFalse();
-  }
-
-  private void assertMerged(String changeId) throws Exception {
-    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
-  }
-
-  private PushOneCommit.Result createChange(
-      TestRepository<?> repo,
-      String subject,
-      String fileName,
-      String content,
-      List<RevCommit> parents,
-      String ref)
-      throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo, subject, fileName, content);
-
-    if (!parents.isEmpty()) {
-      push.setParents(parents);
-    }
-
-    PushOneCommit.Result result;
-    if (fileName.isEmpty()) {
-      result = push.execute(ref);
-    } else {
-      result = push.to(ref);
-    }
-    result.assertOkStatus();
-    return result;
-  }
-
-  private PushOneCommit.Result createChange(TestRepository<?> repo, String subject)
-      throws Exception {
-    return createChange(repo, subject, "x", "x", new ArrayList<RevCommit>(), "refs/for/master");
-  }
-
-  private PushOneCommit.Result createChange(
-      TestRepository<?> repo,
-      String subject,
-      String fileName,
-      String content,
-      List<RevCommit> parents)
-      throws Exception {
-    return createChange(repo, subject, fileName, content, parents, "refs/for/master");
-  }
-
-  @Override
-  protected PushOneCommit.Result createChange(String subject) throws Exception {
-    return createChange(
-        testRepo, subject, "", "", Collections.<RevCommit>emptyList(), "refs/for/master");
-  }
-
-  private PushOneCommit.Result createChange(String subject, List<RevCommit> parents)
-      throws Exception {
-    return createChange(testRepo, subject, "", "", parents, "refs/for/master");
-  }
-
-  private PushOneCommit.Result createChange(
-      String subject, String fileName, String content, List<RevCommit> parents) throws Exception {
-    return createChange(testRepo, subject, fileName, content, parents, "refs/for/master");
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
deleted file mode 100644
index cb0d768ef..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ /dev/null
@@ -1,482 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.Sandboxed;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.group.CreateGroup;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.inject.Inject;
-import java.util.Arrays;
-import java.util.List;
-import org.junit.Before;
-import org.junit.Test;
-
-@Sandboxed
-public class SuggestReviewersIT extends AbstractDaemonTest {
-  @Inject private CreateGroup.Factory createGroupFactory;
-
-  private InternalGroup group1;
-  private InternalGroup group2;
-  private InternalGroup group3;
-
-  private TestAccount user1;
-  private TestAccount user2;
-  private TestAccount user3;
-  private TestAccount user4;
-
-  @Before
-  public void setUp() throws Exception {
-    group1 = group("users1");
-    group2 = group("users2");
-    group3 = group("users3");
-
-    user1 = user("user1", "First1 Last1", group1);
-    user2 = user("user2", "First2 Last2", group2);
-    user3 = user("user3", "First3 Last3", group1, group2);
-    user4 = user("jdoe", "John Doe", "JDOE");
-  }
-
-  @Test
-  @GerritConfig(name = "accounts.visibility", value = "NONE")
-  public void suggestReviewersNoResult1() throws Exception {
-    String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
-    assertThat(reviewers).isEmpty();
-  }
-
-  @Test
-  @GerritConfig(name = "suggest.from", value = "1")
-  @GerritConfig(name = "accounts.visibility", value = "NONE")
-  public void suggestReviewersNoResult2() throws Exception {
-    String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
-    assertThat(reviewers).isEmpty();
-  }
-
-  @Test
-  public void suggestReviewersChange() throws Exception {
-    String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
-    assertReviewers(
-        reviewers, ImmutableList.of(user1, user2, user3), ImmutableList.of(group1, group2, group3));
-
-    reviewers = suggestReviewers(changeId, name("u"), 5);
-    assertReviewers(
-        reviewers, ImmutableList.of(user1, user2, user3), ImmutableList.of(group1, group2));
-
-    reviewers = suggestReviewers(changeId, group3.getName(), 10);
-    assertReviewers(reviewers, ImmutableList.of(), ImmutableList.of(group3));
-
-    // Suggested accounts are ordered by activity. All users have no activity,
-    // hence we don't know which of the matching accounts we get when the query
-    // is limited to 1.
-    reviewers = suggestReviewers(changeId, name("u"), 1);
-    assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.get(0).account).isNotNull();
-    assertThat(ImmutableList.of(reviewers.get(0).account._accountId))
-        .containsAnyIn(
-            ImmutableList.of(user1, user2, user3).stream().map(u -> u.id.get()).collect(toList()));
-  }
-
-  @Test
-  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
-  public void suggestReviewersSameGroupVisibility() throws Exception {
-    String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers;
-
-    reviewers = suggestReviewers(changeId, user2.username, 2);
-    assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
-
-    setApiUser(user1);
-    reviewers = suggestReviewers(changeId, user2.fullName, 2);
-    assertThat(reviewers).isEmpty();
-
-    setApiUser(user2);
-    reviewers = suggestReviewers(changeId, user2.username, 2);
-    assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
-
-    setApiUser(user3);
-    reviewers = suggestReviewers(changeId, user2.username, 2);
-    assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
-  }
-
-  @Test
-  public void suggestReviewsPrivateProjectVisibility() throws Exception {
-    String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers;
-
-    setApiUser(user3);
-    block("refs/*", "read", ANONYMOUS_USERS);
-    allow("refs/*", "read", group1.getGroupUUID());
-    reviewers = suggestReviewers(changeId, user2.username, 2);
-    assertThat(reviewers).isEmpty();
-  }
-
-  @Test
-  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
-  public void suggestReviewersViewAllAccounts() throws Exception {
-    String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers;
-
-    setApiUser(user1);
-    reviewers = suggestReviewers(changeId, user2.username, 2);
-    assertThat(reviewers).isEmpty();
-
-    setApiUser(user1); // Clear cached group info.
-    allowGlobalCapabilities(group1.getGroupUUID(), GlobalCapability.VIEW_ALL_ACCOUNTS);
-    reviewers = suggestReviewers(changeId, user2.username, 2);
-    assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
-  }
-
-  @Test
-  @GerritConfig(name = "suggest.maxSuggestedReviewers", value = "2")
-  public void suggestReviewersMaxNbrSuggestions() throws Exception {
-    String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("user"), 5);
-    assertThat(reviewers).hasSize(2);
-  }
-
-  @Test
-  public void suggestReviewersFullTextSearch() throws Exception {
-    String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers;
-
-    reviewers = suggestReviewers(changeId, "first");
-    assertThat(reviewers).hasSize(3);
-
-    reviewers = suggestReviewers(changeId, "first1");
-    assertThat(reviewers).hasSize(1);
-
-    reviewers = suggestReviewers(changeId, "last");
-    assertThat(reviewers).hasSize(3);
-
-    reviewers = suggestReviewers(changeId, "last1");
-    assertThat(reviewers).hasSize(1);
-
-    reviewers = suggestReviewers(changeId, "fi la");
-    assertThat(reviewers).hasSize(3);
-
-    reviewers = suggestReviewers(changeId, "la fi");
-    assertThat(reviewers).hasSize(3);
-
-    reviewers = suggestReviewers(changeId, "first1 la");
-    assertThat(reviewers).hasSize(1);
-
-    reviewers = suggestReviewers(changeId, "fi last1");
-    assertThat(reviewers).hasSize(1);
-
-    reviewers = suggestReviewers(changeId, "first1 last2");
-    assertThat(reviewers).isEmpty();
-
-    reviewers = suggestReviewers(changeId, name("user"));
-    assertThat(reviewers).hasSize(6);
-
-    reviewers = suggestReviewers(changeId, user1.username);
-    assertThat(reviewers).hasSize(1);
-
-    reviewers = suggestReviewers(changeId, "example.com");
-    assertThat(reviewers).hasSize(5);
-
-    reviewers = suggestReviewers(changeId, user1.email);
-    assertThat(reviewers).hasSize(1);
-
-    reviewers = suggestReviewers(changeId, user1.username + " example");
-    assertThat(reviewers).hasSize(1);
-
-    reviewers = suggestReviewers(changeId, user4.email.toLowerCase());
-    assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.get(0).account.email).isEqualTo(user4.email);
-  }
-
-  @Test
-  public void suggestReviewersWithoutLimitOptionSpecified() throws Exception {
-    String changeId = createChange().getChangeId();
-    String query = user3.username;
-    List<SuggestedReviewerInfo> suggestedReviewerInfos =
-        gApi.changes().id(changeId).suggestReviewers(query).get();
-    assertThat(suggestedReviewerInfos).hasSize(1);
-  }
-
-  @Test
-  @GerritConfig(name = "addreviewer.maxAllowed", value = "2")
-  @GerritConfig(name = "addreviewer.maxWithoutConfirmation", value = "1")
-  public void suggestReviewersGroupSizeConsiderations() throws Exception {
-    InternalGroup largeGroup = group("large");
-    InternalGroup mediumGroup = group("medium");
-
-    // Both groups have Administrator as a member. Add two users to large
-    // group to push it past maxAllowed, and one to medium group to push it
-    // past maxWithoutConfirmation.
-    user("individual 0", "Test0 Last0", largeGroup, mediumGroup);
-    user("individual 1", "Test1 Last1", largeGroup);
-
-    String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers;
-    SuggestedReviewerInfo reviewer;
-
-    // Individual account suggestions have count of 1 and no confirm.
-    reviewers = suggestReviewers(changeId, "test", 10);
-    assertThat(reviewers).hasSize(2);
-    reviewer = reviewers.get(0);
-    assertThat(reviewer.count).isEqualTo(1);
-    assertThat(reviewer.confirm).isNull();
-
-    // Large group should never be suggested.
-    reviewers = suggestReviewers(changeId, largeGroup.getName(), 10);
-    assertThat(reviewers).isEmpty();
-
-    // Medium group should be suggested with appropriate count and confirm.
-    reviewers = suggestReviewers(changeId, mediumGroup.getName(), 10);
-    assertThat(reviewers).hasSize(1);
-    reviewer = reviewers.get(0);
-    assertThat(reviewer.group.name).isEqualTo(mediumGroup.getName());
-    assertThat(reviewer.count).isEqualTo(2);
-    assertThat(reviewer.confirm).isTrue();
-  }
-
-  @Test
-  public void defaultReviewerSuggestion() throws Exception {
-    TestAccount user1 = user("customuser1", "User1");
-    TestAccount reviewer1 = user("customuser2", "User2");
-    TestAccount reviewer2 = user("customuser3", "User3");
-
-    setApiUser(user1);
-    String changeId1 = createChangeFromApi();
-
-    setApiUser(reviewer1);
-    reviewChange(changeId1);
-
-    setApiUser(user1);
-    String changeId2 = createChangeFromApi();
-
-    setApiUser(reviewer1);
-    reviewChange(changeId2);
-
-    setApiUser(reviewer2);
-    reviewChange(changeId2);
-
-    setApiUser(user1);
-    String changeId3 = createChangeFromApi();
-    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId3, null, 4);
-    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
-        .containsExactly(reviewer1.id.get(), reviewer2.id.get())
-        .inOrder();
-
-    // check that existing reviewers are filtered out
-    gApi.changes().id(changeId3).addReviewer(reviewer1.email);
-    reviewers = suggestReviewers(changeId3, null, 4);
-    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
-        .containsExactly(reviewer2.id.get())
-        .inOrder();
-  }
-
-  @Test
-  public void defaultReviewerSuggestionOnFirstChange() throws Exception {
-    TestAccount user1 = user("customuser1", "User1");
-    setApiUser(user1);
-    List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChange().getChangeId(), "", 4);
-    assertThat(reviewers).isEmpty();
-  }
-
-  @Test
-  @GerritConfig(name = "suggest.maxSuggestedReviewers", value = "10")
-  public void reviewerRanking() throws Exception {
-    // Assert that user are ranked by the number of times they have applied a
-    // a label to a change (highest), added comments (medium) or owned a
-    // change (low).
-    String fullName = "Primum Finalis";
-    TestAccount userWhoOwns = user("customuser1", fullName);
-    TestAccount reviewer1 = user("customuser2", fullName);
-    TestAccount reviewer2 = user("customuser3", fullName);
-    TestAccount userWhoComments = user("customuser4", fullName);
-    TestAccount userWhoLooksForSuggestions = user("customuser5", fullName);
-
-    // Create a change as userWhoOwns and add some reviews
-    setApiUser(userWhoOwns);
-    String changeId1 = createChangeFromApi();
-
-    setApiUser(reviewer1);
-    reviewChange(changeId1);
-
-    setApiUser(user1);
-    String changeId2 = createChangeFromApi();
-
-    setApiUser(reviewer1);
-    reviewChange(changeId2);
-
-    setApiUser(reviewer2);
-    reviewChange(changeId2);
-
-    // Create a comment as a different user
-    setApiUser(userWhoComments);
-    ReviewInput ri = new ReviewInput();
-    ri.message = "Test";
-    gApi.changes().id(changeId1).revision(1).review(ri);
-
-    // Create a change as a new user to assert that we receive the correct
-    // ranking
-
-    setApiUser(userWhoLooksForSuggestions);
-    List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "Pri", 4);
-    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
-        .containsExactly(
-            reviewer1.id.get(), reviewer2.id.get(), userWhoOwns.id.get(), userWhoComments.id.get())
-        .inOrder();
-  }
-
-  @Test
-  public void reviewerRankingProjectIsolation() throws Exception {
-    // Create new project
-    Project.NameKey newProject = createProject("test");
-
-    // Create users who review changes in both the default and the new project
-    String fullName = "Primum Finalis";
-    TestAccount userWhoOwns = user("customuser1", fullName);
-    TestAccount reviewer1 = user("customuser2", fullName);
-    TestAccount reviewer2 = user("customuser3", fullName);
-
-    setApiUser(userWhoOwns);
-    String changeId1 = createChangeFromApi();
-
-    setApiUser(reviewer1);
-    reviewChange(changeId1);
-
-    setApiUser(userWhoOwns);
-    String changeId2 = createChangeFromApi(newProject);
-
-    setApiUser(reviewer2);
-    reviewChange(changeId2);
-
-    setApiUser(userWhoOwns);
-    String changeId3 = createChangeFromApi(newProject);
-
-    setApiUser(reviewer2);
-    reviewChange(changeId3);
-
-    setApiUser(userWhoOwns);
-    List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "Prim", 4);
-
-    // Assert that reviewer1 is on top, even though reviewer2 has more reviews
-    // in other projects
-    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
-        .containsExactly(reviewer1.id.get(), reviewer2.id.get())
-        .inOrder();
-  }
-
-  @Test
-  public void suggestNoInactiveAccounts() throws Exception {
-    String name = name("foo");
-    TestAccount foo1 = accountCreator.create(name + "-1");
-    assertThat(gApi.accounts().id(foo1.username).getActive()).isTrue();
-
-    TestAccount foo2 = accountCreator.create(name + "-2");
-    assertThat(gApi.accounts().id(foo2.username).getActive()).isTrue();
-
-    String changeId = createChange().getChangeId();
-    assertReviewers(
-        suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
-
-    gApi.accounts().id(foo2.username).setActive(false);
-    assertThat(gApi.accounts().id(foo2.username).getActive()).isFalse();
-    assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(foo1), ImmutableList.of());
-  }
-
-  private List<SuggestedReviewerInfo> suggestReviewers(String changeId, String query)
-      throws Exception {
-    return gApi.changes().id(changeId).suggestReviewers(query).get();
-  }
-
-  private List<SuggestedReviewerInfo> suggestReviewers(String changeId, String query, int n)
-      throws Exception {
-    return gApi.changes().id(changeId).suggestReviewers(query).withLimit(n).get();
-  }
-
-  private InternalGroup group(String name) throws Exception {
-    GroupInfo group = createGroupFactory.create(name(name)).apply(TopLevelResource.INSTANCE, null);
-    return groupCache.get(new AccountGroup.UUID(group.id)).orElse(null);
-  }
-
-  private TestAccount user(String name, String fullName, String emailName, InternalGroup... groups)
-      throws Exception {
-    String[] groupNames = Arrays.stream(groups).map(InternalGroup::getName).toArray(String[]::new);
-    return accountCreator.create(
-        name(name), name(emailName) + "@example.com", fullName, groupNames);
-  }
-
-  private TestAccount user(String name, String fullName, InternalGroup... groups) throws Exception {
-    return user(name, fullName, name, groups);
-  }
-
-  private void reviewChange(String changeId) throws RestApiException {
-    ReviewInput ri = new ReviewInput();
-    ri.label("Code-Review", 1);
-    gApi.changes().id(changeId).current().review(ri);
-  }
-
-  private String createChangeFromApi() throws RestApiException {
-    return createChangeFromApi(project);
-  }
-
-  private String createChangeFromApi(Project.NameKey project) throws RestApiException {
-    ChangeInput ci = new ChangeInput();
-    ci.project = project.get();
-    ci.subject = "Test change at" + System.nanoTime();
-    ci.branch = "master";
-    return gApi.changes().create(ci).get().changeId;
-  }
-
-  private void assertReviewers(
-      List<SuggestedReviewerInfo> actual,
-      List<TestAccount> expectedUsers,
-      List<InternalGroup> expectedGroups) {
-    List<Integer> actualAccountIds =
-        actual
-            .stream()
-            .filter(i -> i.account != null)
-            .map(i -> i.account._accountId)
-            .collect(toList());
-    assertThat(actualAccountIds)
-        .containsExactlyElementsIn(expectedUsers.stream().map(u -> u.id.get()).collect(toList()));
-
-    List<String> actualGroupIds =
-        actual.stream().filter(i -> i.group != null).map(i -> i.group.id).collect(toList());
-    assertThat(actualGroupIds)
-        .containsExactlyElementsIn(
-            expectedGroups.stream().map(g -> g.getGroupUUID().get()).collect(toList()))
-        .inOrder();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUILD
deleted file mode 100644
index 6becf0f..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUILD
+++ /dev/null
@@ -1,7 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "rest_config",
-    labels = ["rest"],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
deleted file mode 100644
index 2ef74b4..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.config;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.config.PostCaches.Operation.FLUSH;
-import static com.google.gerrit.server.config.PostCaches.Operation.FLUSH_ALL;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.server.config.ListCaches.CacheInfo;
-import com.google.gerrit.server.config.PostCaches;
-import java.util.Arrays;
-import org.junit.Test;
-
-public class CacheOperationsIT extends AbstractDaemonTest {
-
-  @Test
-  public void flushAll() throws Exception {
-    RestResponse r = adminRestSession.getOK("/config/server/caches/project_list");
-    CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
-
-    r = adminRestSession.postOK("/config/server/caches/", new PostCaches.Input(FLUSH_ALL));
-    r.consume();
-
-    r = adminRestSession.getOK("/config/server/caches/project_list");
-    cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(cacheInfo.entries.mem).isNull();
-  }
-
-  @Test
-  public void flushAll_Forbidden() throws Exception {
-    userRestSession
-        .post("/config/server/caches/", new PostCaches.Input(FLUSH_ALL))
-        .assertForbidden();
-  }
-
-  @Test
-  public void flushAll_BadRequest() throws Exception {
-    adminRestSession
-        .post("/config/server/caches/", new PostCaches.Input(FLUSH_ALL, Arrays.asList("projects")))
-        .assertBadRequest();
-  }
-
-  @Test
-  public void flush() throws Exception {
-    RestResponse r = adminRestSession.getOK("/config/server/caches/project_list");
-    CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
-
-    r = adminRestSession.getOK("/config/server/caches/projects");
-    cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 1);
-
-    r =
-        adminRestSession.postOK(
-            "/config/server/caches/",
-            new PostCaches.Input(FLUSH, Arrays.asList("accounts", "project_list")));
-    r.consume();
-
-    r = adminRestSession.getOK("/config/server/caches/project_list");
-    cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(cacheInfo.entries.mem).isNull();
-
-    r = adminRestSession.getOK("/config/server/caches/projects");
-    cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 1);
-  }
-
-  @Test
-  public void flush_Forbidden() throws Exception {
-    userRestSession
-        .post("/config/server/caches/", new PostCaches.Input(FLUSH, Arrays.asList("projects")))
-        .assertForbidden();
-  }
-
-  @Test
-  public void flush_BadRequest() throws Exception {
-    adminRestSession.post("/config/server/caches/", new PostCaches.Input(FLUSH)).assertBadRequest();
-  }
-
-  @Test
-  public void flush_UnprocessableEntity() throws Exception {
-    RestResponse r = adminRestSession.getOK("/config/server/caches/projects");
-    CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
-
-    r =
-        adminRestSession.post(
-            "/config/server/caches/",
-            new PostCaches.Input(FLUSH, Arrays.asList("projects", "unprocessable")));
-    r.assertUnprocessableEntity();
-    r.consume();
-
-    r = adminRestSession.getOK("/config/server/caches/projects");
-    cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
-  }
-
-  @Test
-  public void flushWebSessions_Forbidden() throws Exception {
-    allowGlobalCapabilities(
-        REGISTERED_USERS, GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
-    try {
-      RestResponse r =
-          userRestSession.postOK(
-              "/config/server/caches/", new PostCaches.Input(FLUSH, Arrays.asList("projects")));
-      r.consume();
-
-      userRestSession
-          .post(
-              "/config/server/caches/", new PostCaches.Input(FLUSH, Arrays.asList("web_sessions")))
-          .assertForbidden();
-    } finally {
-      removeGlobalCapabilities(
-          REGISTERED_USERS, GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
deleted file mode 100644
index dea9174..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.config;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.server.config.ConfirmEmail;
-import com.google.gerrit.server.mail.EmailTokenVerifier;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gwtjsonrpc.server.SignedToken;
-import com.google.inject.Inject;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Test;
-
-public class ConfirmEmailIT extends AbstractDaemonTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    Config cfg = new Config();
-    cfg.setString("auth", null, "registerEmailPrivateKey", SignedToken.generateRandomKey());
-    return cfg;
-  }
-
-  @Inject private EmailTokenVerifier emailTokenVerifier;
-
-  @Test
-  public void confirm() throws Exception {
-    ConfirmEmail.Input in = new ConfirmEmail.Input();
-    in.token = emailTokenVerifier.encode(admin.getId(), "new.mail@example.com");
-    adminRestSession.put("/config/server/email.confirm", in).assertNoContent();
-  }
-
-  @Test
-  public void confirmForOtherUser_UnprocessableEntity() throws Exception {
-    ConfirmEmail.Input in = new ConfirmEmail.Input();
-    in.token = emailTokenVerifier.encode(user.getId(), "new.mail@example.com");
-    adminRestSession.put("/config/server/email.confirm", in).assertUnprocessableEntity();
-  }
-
-  @Test
-  public void confirmInvalidToken_UnprocessableEntity() throws Exception {
-    ConfirmEmail.Input in = new ConfirmEmail.Input();
-    in.token = "invalidToken";
-    adminRestSession.put("/config/server/email.confirm", in).assertUnprocessableEntity();
-  }
-
-  @Test
-  public void confirmAlreadyInUse_UnprocessableEntity() throws Exception {
-    ConfirmEmail.Input in = new ConfirmEmail.Input();
-    in.token = emailTokenVerifier.encode(admin.getId(), user.email);
-    adminRestSession.put("/config/server/email.confirm", in).assertUnprocessableEntity();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
deleted file mode 100644
index b586ab2..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.config;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.config.ListCaches.CacheInfo;
-import com.google.gerrit.server.group.InternalGroup;
-import org.junit.Test;
-
-public class FlushCacheIT extends AbstractDaemonTest {
-
-  @Test
-  public void flushCache() throws Exception {
-    InternalGroup group = groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-    assertWithMessage("Precondition: The group 'Administrators' was loaded by the group cache")
-        .that(group)
-        .isNotNull();
-
-    RestResponse r = adminRestSession.get("/config/server/caches/groups_byname");
-    CacheInfo result = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(result.entries.mem).isGreaterThan((long) 0);
-
-    r = adminRestSession.post("/config/server/caches/groups_byname/flush");
-    r.assertOK();
-    r.consume();
-
-    r = adminRestSession.get("/config/server/caches/groups_byname");
-    result = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(result.entries.mem).isNull();
-  }
-
-  @Test
-  public void flushCache_Forbidden() throws Exception {
-    userRestSession.post("/config/server/caches/accounts/flush").assertForbidden();
-  }
-
-  @Test
-  public void flushCache_NotFound() throws Exception {
-    adminRestSession.post("/config/server/caches/nonExisting/flush").assertNotFound();
-  }
-
-  @Test
-  public void flushCacheWithGerritPrefix() throws Exception {
-    adminRestSession.post("/config/server/caches/gerrit-accounts/flush").assertOK();
-  }
-
-  @Test
-  public void flushWebSessionsCache() throws Exception {
-    adminRestSession.post("/config/server/caches/web_sessions/flush").assertOK();
-  }
-
-  @Test
-  public void flushWebSessionsCache_Forbidden() throws Exception {
-    allowGlobalCapabilities(
-        REGISTERED_USERS, GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
-    try {
-      RestResponse r = userRestSession.post("/config/server/caches/accounts/flush");
-      r.assertOK();
-      r.consume();
-
-      userRestSession.post("/config/server/caches/web_sessions/flush").assertForbidden();
-    } finally {
-      removeGlobalCapabilities(
-          REGISTERED_USERS, GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
deleted file mode 100644
index fe600cc..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.config;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.config.ListCaches.CacheInfo;
-import com.google.gerrit.server.config.ListCaches.CacheType;
-import org.junit.Test;
-
-public class GetCacheIT extends AbstractDaemonTest {
-
-  @Test
-  public void getCache() throws Exception {
-    RestResponse r = adminRestSession.get("/config/server/caches/accounts");
-    r.assertOK();
-    CacheInfo result = newGson().fromJson(r.getReader(), CacheInfo.class);
-
-    assertThat(result.name).isEqualTo("accounts");
-    assertThat(result.type).isEqualTo(CacheType.MEM);
-    assertThat(result.entries.mem).isAtLeast(1L);
-    assertThat(result.averageGet).isNotNull();
-    assertThat(result.averageGet).endsWith("s");
-    assertThat(result.entries.disk).isNull();
-    assertThat(result.entries.space).isNull();
-    assertThat(result.hitRatio.mem).isAtLeast(0);
-    assertThat(result.hitRatio.mem).isAtMost(100);
-    assertThat(result.hitRatio.disk).isNull();
-
-    userRestSession.get("/config/server/version").consume();
-    r = adminRestSession.get("/config/server/caches/accounts");
-    r.assertOK();
-    result = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(result.entries.mem).isEqualTo(2);
-  }
-
-  @Test
-  public void getCache_Forbidden() throws Exception {
-    userRestSession.get("/config/server/caches/accounts").assertForbidden();
-  }
-
-  @Test
-  public void getCache_NotFound() throws Exception {
-    adminRestSession.get("/config/server/caches/nonExisting").assertNotFound();
-  }
-
-  @Test
-  public void getCacheWithGerritPrefix() throws Exception {
-    adminRestSession.get("/config/server/caches/gerrit-accounts").assertOK();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
deleted file mode 100644
index 900b4be..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.config;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.config.ListTasks.TaskInfo;
-import com.google.gson.reflect.TypeToken;
-import java.util.List;
-import org.junit.Test;
-
-public class GetTaskIT extends AbstractDaemonTest {
-
-  @Test
-  public void getTask() throws Exception {
-    RestResponse r = adminRestSession.get("/config/server/tasks/" + getLogFileCompressorTaskId());
-    r.assertOK();
-    TaskInfo info = newGson().fromJson(r.getReader(), new TypeToken<TaskInfo>() {}.getType());
-    assertThat(info.id).isNotNull();
-    Long.parseLong(info.id, 16);
-    assertThat(info.command).isEqualTo("Log File Compressor");
-    assertThat(info.startTime).isNotNull();
-  }
-
-  @Test
-  public void getTask_NotFound() throws Exception {
-    userRestSession.get("/config/server/tasks/" + getLogFileCompressorTaskId()).assertNotFound();
-  }
-
-  private String getLogFileCompressorTaskId() throws Exception {
-    RestResponse r = adminRestSession.get("/config/server/tasks/");
-    List<TaskInfo> result =
-        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
-    r.consume();
-    for (TaskInfo info : result) {
-      if ("Log File Compressor".equals(info.command)) {
-        return info.id;
-      }
-    }
-    return null;
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
deleted file mode 100644
index 7cd9584..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.config;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.config.ListTasks.TaskInfo;
-import com.google.gson.reflect.TypeToken;
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import org.junit.Test;
-
-public class KillTaskIT extends AbstractDaemonTest {
-
-  private void killTask() throws Exception {
-    RestResponse r = adminRestSession.get("/config/server/tasks/");
-    List<TaskInfo> result =
-        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
-    r.consume();
-
-    Optional<String> id =
-        result
-            .stream()
-            .filter(t -> "Log File Compressor".equals(t.command))
-            .map(t -> t.id)
-            .findFirst();
-    assertThat(id).isPresent();
-
-    r = adminRestSession.delete("/config/server/tasks/" + id.get());
-    r.assertNoContent();
-    r.consume();
-
-    r = adminRestSession.get("/config/server/tasks/");
-    result = newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
-    r.consume();
-    Set<String> ids = result.stream().map(t -> t.id).collect(toSet());
-    assertThat(ids).doesNotContain(id.get());
-  }
-
-  private void killTask_NotFound() throws Exception {
-    RestResponse r = adminRestSession.get("/config/server/tasks/");
-    List<TaskInfo> result =
-        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
-    r.consume();
-    assertThat(result.size()).isGreaterThan(0);
-
-    userRestSession.delete("/config/server/tasks/" + result.get(0).id).assertNotFound();
-  }
-
-  @Test
-  public void killTaskTests_inOrder() throws Exception {
-    // As killTask() changes the state of the server, we want to test it last
-    killTask_NotFound();
-    killTask();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
deleted file mode 100644
index 4d48bf4..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.config;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.collect.Ordering;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.config.ListCaches.CacheInfo;
-import com.google.gerrit.server.config.ListCaches.CacheType;
-import com.google.gson.reflect.TypeToken;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.util.Base64;
-import org.junit.Test;
-
-public class ListCachesIT extends AbstractDaemonTest {
-
-  @Test
-  public void listCaches() throws Exception {
-    RestResponse r = adminRestSession.get("/config/server/caches/");
-    r.assertOK();
-    Map<String, CacheInfo> result =
-        newGson().fromJson(r.getReader(), new TypeToken<Map<String, CacheInfo>>() {}.getType());
-
-    assertThat(result).containsKey("accounts");
-    CacheInfo accountsCacheInfo = result.get("accounts");
-    assertThat(accountsCacheInfo.type).isEqualTo(CacheType.MEM);
-    assertThat(accountsCacheInfo.entries.mem).isAtLeast(1L);
-    assertThat(accountsCacheInfo.averageGet).isNotNull();
-    assertThat(accountsCacheInfo.averageGet).endsWith("s");
-    assertThat(accountsCacheInfo.entries.disk).isNull();
-    assertThat(accountsCacheInfo.entries.space).isNull();
-    assertThat(accountsCacheInfo.hitRatio.mem).isAtLeast(0);
-    assertThat(accountsCacheInfo.hitRatio.mem).isAtMost(100);
-    assertThat(accountsCacheInfo.hitRatio.disk).isNull();
-
-    userRestSession.get("/config/server/version").consume();
-    r = adminRestSession.get("/config/server/caches/");
-    r.assertOK();
-    result =
-        newGson().fromJson(r.getReader(), new TypeToken<Map<String, CacheInfo>>() {}.getType());
-    assertThat(result.get("accounts").entries.mem).isEqualTo(2);
-  }
-
-  @Test
-  public void listCaches_Forbidden() throws Exception {
-    userRestSession.get("/config/server/caches/").assertForbidden();
-  }
-
-  @Test
-  public void listCacheNames() throws Exception {
-    RestResponse r = adminRestSession.get("/config/server/caches/?format=LIST");
-    r.assertOK();
-    List<String> result =
-        newGson().fromJson(r.getReader(), new TypeToken<List<String>>() {}.getType());
-    assertThat(result).contains("accounts");
-    assertThat(result).contains("projects");
-    assertThat(Ordering.natural().isOrdered(result)).isTrue();
-  }
-
-  @Test
-  public void listCacheNamesTextList() throws Exception {
-    RestResponse r = adminRestSession.get("/config/server/caches/?format=TEXT_LIST");
-    r.assertOK();
-    String result = new String(Base64.decode(r.getEntityContent()), UTF_8.name());
-    List<String> list = Arrays.asList(result.split("\n"));
-    assertThat(list).contains("accounts");
-    assertThat(list).contains("projects");
-    assertThat(Ordering.natural().isOrdered(list)).isTrue();
-  }
-
-  @Test
-  public void listCaches_BadRequest() throws Exception {
-    adminRestSession.get("/config/server/caches/?format=NONSENSE").assertBadRequest();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
deleted file mode 100644
index ee6411a..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.config;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.config.ListTasks.TaskInfo;
-import com.google.gson.reflect.TypeToken;
-import java.util.List;
-import org.junit.Test;
-
-public class ListTasksIT extends AbstractDaemonTest {
-
-  @Test
-  public void listTasks() throws Exception {
-    RestResponse r = adminRestSession.get("/config/server/tasks/");
-    r.assertOK();
-    List<TaskInfo> result =
-        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
-    assertThat(result).isNotEmpty();
-    boolean foundLogFileCompressorTask = false;
-    for (TaskInfo info : result) {
-      if ("Log File Compressor".equals(info.command)) {
-        foundLogFileCompressorTask = true;
-      }
-      assertThat(info.id).isNotNull();
-      Long.parseLong(info.id, 16);
-      assertThat(info.command).isNotNull();
-      assertThat(info.startTime).isNotNull();
-    }
-    assertThat(foundLogFileCompressorTask).isTrue();
-  }
-
-  @Test
-  public void listTasksWithoutViewQueueCapability() throws Exception {
-    RestResponse r = userRestSession.get("/config/server/tasks/");
-    r.assertOK();
-    List<TaskInfo> result =
-        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
-
-    assertThat(result).isEmpty();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
deleted file mode 100644
index 8ec145f..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ /dev/null
@@ -1,211 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.config;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.extensions.common.InstallPluginInput;
-import com.google.gerrit.extensions.common.ServerInfo;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import org.junit.Test;
-
-@NoHttpd
-public class ServerInfoIT extends AbstractDaemonTest {
-  private static final byte[] JS_PLUGIN_CONTENT =
-      "Gerrit.install(function(self){});\n".getBytes(UTF_8);
-
-  @Test
-  // accounts
-  @GerritConfig(name = "accounts.visibility", value = "VISIBLE_GROUP")
-
-  // auth
-  @GerritConfig(name = "auth.type", value = "HTTP")
-  @GerritConfig(name = "auth.contributorAgreements", value = "true")
-  @GerritConfig(name = "auth.loginUrl", value = "https://example.com/login")
-  @GerritConfig(name = "auth.loginText", value = "LOGIN")
-  @GerritConfig(name = "auth.switchAccountUrl", value = "https://example.com/switch")
-
-  // auth fields ignored when auth == HTTP
-  @GerritConfig(name = "auth.registerUrl", value = "https://example.com/register")
-  @GerritConfig(name = "auth.registerText", value = "REGISTER")
-  @GerritConfig(name = "auth.editFullNameUrl", value = "https://example.com/editname")
-  @GerritConfig(name = "auth.httpPasswordUrl", value = "https://example.com/password")
-
-  // change
-  @GerritConfig(name = "change.allowDrafts", value = "false")
-  @GerritConfig(name = "change.largeChange", value = "300")
-  @GerritConfig(name = "change.replyTooltip", value = "Publish votes and draft comments")
-  @GerritConfig(name = "change.replyLabel", value = "Vote")
-  @GerritConfig(name = "change.updateDelay", value = "50s")
-
-  // download
-  @GerritConfig(
-    name = "download.archive",
-    values = {"tar", "tbz2", "tgz", "txz"}
-  )
-
-  // gerrit
-  @GerritConfig(name = "gerrit.allProjects", value = "Root")
-  @GerritConfig(name = "gerrit.allUsers", value = "Users")
-  @GerritConfig(name = "gerrit.enableGwtUi", value = "true")
-  @GerritConfig(name = "gerrit.reportBugText", value = "REPORT BUG")
-  @GerritConfig(name = "gerrit.reportBugUrl", value = "https://example.com/report")
-
-  // suggest
-  @GerritConfig(name = "suggest.from", value = "3")
-
-  // user
-  @GerritConfig(name = "user.anonymousCoward", value = "Unnamed User")
-  public void serverConfig() throws Exception {
-    ServerInfo i = gApi.config().server().getInfo();
-
-    // accounts
-    assertThat(i.accounts.visibility).isEqualTo(AccountVisibility.VISIBLE_GROUP);
-
-    // auth
-    assertThat(i.auth.authType).isEqualTo(AuthType.HTTP);
-    assertThat(i.auth.editableAccountFields)
-        .containsExactly(AccountFieldName.REGISTER_NEW_EMAIL, AccountFieldName.FULL_NAME);
-    assertThat(i.auth.useContributorAgreements).isTrue();
-    assertThat(i.auth.loginUrl).isEqualTo("https://example.com/login");
-    assertThat(i.auth.loginText).isEqualTo("LOGIN");
-    assertThat(i.auth.switchAccountUrl).isEqualTo("https://example.com/switch");
-    assertThat(i.auth.registerUrl).isNull();
-    assertThat(i.auth.registerText).isNull();
-    assertThat(i.auth.editFullNameUrl).isNull();
-    assertThat(i.auth.httpPasswordUrl).isNull();
-
-    // change
-    assertThat(i.change.allowDrafts).isNull();
-    assertThat(i.change.largeChange).isEqualTo(300);
-    assertThat(i.change.replyTooltip).startsWith("Publish votes and draft comments");
-    assertThat(i.change.replyLabel).isEqualTo("Vote\u2026");
-    assertThat(i.change.updateDelay).isEqualTo(50);
-
-    // download
-    assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
-    assertThat(i.download.schemes).isEmpty();
-
-    // gerrit
-    assertThat(i.gerrit.allProjects).isEqualTo("Root");
-    assertThat(i.gerrit.allUsers).isEqualTo("Users");
-    assertThat(i.gerrit.reportBugUrl).isEqualTo("https://example.com/report");
-    assertThat(i.gerrit.reportBugText).isEqualTo("REPORT BUG");
-
-    // Acceptance tests force --headless even when UIs are specified in config.
-    assertThat(i.gerrit.webUis).isEmpty();
-
-    // plugin
-    assertThat(i.plugin.jsResourcePaths).isEmpty();
-
-    // sshd
-    assertThat(i.sshd).isNotNull();
-
-    // suggest
-    assertThat(i.suggest.from).isEqualTo(3);
-
-    // user
-    assertThat(i.user.anonymousCowardName).isEqualTo("Unnamed User");
-
-    // notedb
-    notesMigration.setReadChanges(true);
-    assertThat(gApi.config().server().getInfo().noteDbEnabled).isTrue();
-    notesMigration.setReadChanges(false);
-    assertThat(gApi.config().server().getInfo().noteDbEnabled).isNull();
-  }
-
-  @Test
-  @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
-  public void serverConfigWithPlugin() throws Exception {
-    ServerInfo i = gApi.config().server().getInfo();
-    assertThat(i.plugin.jsResourcePaths).isEmpty();
-
-    InstallPluginInput input = new InstallPluginInput();
-    input.raw = RawInputUtil.create(JS_PLUGIN_CONTENT);
-    gApi.plugins().install("js-plugin-1.js", input);
-
-    i = gApi.config().server().getInfo();
-    assertThat(i.plugin.jsResourcePaths).hasSize(1);
-  }
-
-  @Test
-  public void serverConfigWithDefaults() throws Exception {
-    ServerInfo i = gApi.config().server().getInfo();
-
-    // auth
-    assertThat(i.auth.authType).isEqualTo(AuthType.OPENID);
-    assertThat(i.auth.editableAccountFields)
-        .containsExactly(
-            AccountFieldName.REGISTER_NEW_EMAIL,
-            AccountFieldName.FULL_NAME,
-            AccountFieldName.USER_NAME);
-    assertThat(i.auth.useContributorAgreements).isNull();
-    assertThat(i.auth.loginUrl).isNull();
-    assertThat(i.auth.loginText).isNull();
-    assertThat(i.auth.switchAccountUrl).isNull();
-    assertThat(i.auth.registerUrl).isNull();
-    assertThat(i.auth.registerText).isNull();
-    assertThat(i.auth.editFullNameUrl).isNull();
-    assertThat(i.auth.httpPasswordUrl).isNull();
-
-    // change
-    assertThat(i.change.allowDrafts).isTrue();
-    assertThat(i.change.largeChange).isEqualTo(500);
-    assertThat(i.change.replyTooltip).startsWith("Reply and score");
-    assertThat(i.change.replyLabel).isEqualTo("Reply\u2026");
-    assertThat(i.change.updateDelay).isEqualTo(300);
-
-    // download
-    assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
-    assertThat(i.download.schemes).isEmpty();
-
-    // gerrit
-    assertThat(i.gerrit.allProjects).isEqualTo(AllProjectsNameProvider.DEFAULT);
-    assertThat(i.gerrit.allUsers).isEqualTo(AllUsersNameProvider.DEFAULT);
-    assertThat(i.gerrit.reportBugUrl).isNull();
-    assertThat(i.gerrit.reportBugText).isNull();
-
-    // plugin
-    assertThat(i.plugin.jsResourcePaths).isEmpty();
-
-    // sshd
-    assertThat(i.sshd).isNotNull();
-
-    // suggest
-    assertThat(i.suggest.from).isEqualTo(0);
-
-    // user
-    assertThat(i.user.anonymousCowardName).isEqualTo(AnonymousCowardNameProvider.DEFAULT);
-  }
-
-  @Test
-  @GerritConfig(name = "auth.contributorAgreements", value = "true")
-  public void anonymousAccess() throws Exception {
-    configureContributorAgreement(true);
-
-    setApiUserAnonymous();
-    gApi.config().server().getInfo();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUILD
deleted file mode 100644
index b3672ee..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUILD
+++ /dev/null
@@ -1,7 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "rest_group",
-    labels = ["rest"],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/GroupsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/GroupsIT.java
deleted file mode 100644
index e153e561..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/GroupsIT.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License
-
-package com.google.gerrit.acceptance.rest.group;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import org.junit.Test;
-
-public class GroupsIT extends AbstractDaemonTest {
-  @Test
-  public void invalidQueryOptions() throws Exception {
-    RestResponse r = adminRestSession.put("/groups/?query=foo&query2=bar");
-    r.assertBadRequest();
-    assertThat(r.getEntityContent())
-        .isEqualTo("\"query\" and \"query2\" options are mutually exclusive");
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java
deleted file mode 100644
index f67012a..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ /dev/null
@@ -1,589 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.google.gerrit.acceptance.rest.project;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
-import static org.junit.Assert.fail;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.access.AccessSectionInfo;
-import com.google.gerrit.extensions.api.access.PermissionInfo;
-import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
-import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
-import com.google.gerrit.extensions.api.access.ProjectAccessInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.BranchInfo;
-import com.google.gerrit.extensions.api.projects.ProjectApi;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import java.util.HashMap;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Before;
-import org.junit.Test;
-
-public class AccessIT extends AbstractDaemonTest {
-
-  private static final String PROJECT_NAME = "newProject";
-
-  private static final String REFS_ALL = Constants.R_REFS + "*";
-  private static final String REFS_HEADS = Constants.R_HEADS + "*";
-
-  private static final String LABEL_CODE_REVIEW = "Code-Review";
-
-  private String newProjectName;
-  private ProjectApi pApi;
-
-  @Before
-  public void setUp() throws Exception {
-    newProjectName = createProject(PROJECT_NAME).get();
-    pApi = gApi.projects().name(newProjectName);
-  }
-
-  @Test
-  public void getDefaultInheritance() throws Exception {
-    String inheritedName = pApi.access().inheritsFrom.name;
-    assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
-  }
-
-  @Test
-  public void addAccessSection() throws Exception {
-    Project.NameKey p = new Project.NameKey(newProjectName);
-    RevCommit initialHead = getRemoteHead(p, RefNames.REFS_CONFIG);
-
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi.access(accessInput);
-
-    assertThat(pApi.access().local).isEqualTo(accessInput.add);
-
-    RevCommit updatedHead = getRemoteHead(p, RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(
-        p.get(), RefNames.REFS_CONFIG, null, initialHead, initialHead, updatedHead);
-  }
-
-  @Test
-  public void createAccessChange() throws Exception {
-    // User can see the branch
-    setApiUser(user);
-    gApi.projects().name(newProjectName).branch("refs/heads/master").get();
-
-    ProjectAccessInput accessInput = newProjectAccessInput();
-
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    // Deny read to registered users.
-    PermissionInfo read = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    read.exclusive = true;
-    accessSection.permissions.put(Permission.READ, read);
-    accessInput.add.put(REFS_HEADS, accessSection);
-
-    setApiUser(user);
-    ChangeInfo out = pApi.accessChange(accessInput);
-
-    assertThat(out.project).isEqualTo(newProjectName);
-    assertThat(out.branch).isEqualTo(RefNames.REFS_CONFIG);
-    assertThat(out.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(out.submitted).isNull();
-
-    setApiUser(admin);
-
-    ChangeInfo c = gApi.changes().id(out._number).get(MESSAGES);
-    assertThat(c.messages.stream().map(m -> m.message)).containsExactly("Uploaded patch set 1");
-
-    ReviewInput reviewIn = new ReviewInput();
-    reviewIn.label("Code-Review", (short) 2);
-    gApi.changes().id(out._number).current().review(reviewIn);
-    gApi.changes().id(out._number).current().submit();
-
-    // check that the change took effect.
-    setApiUser(user);
-    try {
-      BranchInfo info = gApi.projects().name(newProjectName).branch("refs/heads/master").get();
-      fail("wanted failure, got " + newGson().toJson(info));
-    } catch (ResourceNotFoundException e) {
-      // OK.
-    }
-
-    // Restore.
-    accessInput.add.clear();
-    accessInput.remove.put(REFS_HEADS, accessSection);
-    setApiUser(user);
-
-    pApi.accessChange(accessInput);
-
-    setApiUser(admin);
-    out = pApi.accessChange(accessInput);
-
-    gApi.changes().id(out._number).current().review(reviewIn);
-    gApi.changes().id(out._number).current().submit();
-
-    // Now it works again.
-    setApiUser(user);
-    gApi.projects().name(newProjectName).branch("refs/heads/master").get();
-  }
-
-  @Test
-  public void removePermission() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi.access(accessInput);
-
-    // Remove specific permission
-    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
-    accessSectionToRemove.permissions.put(
-        Permission.LABEL + LABEL_CODE_REVIEW, newPermissionInfo());
-    ProjectAccessInput removal = newProjectAccessInput();
-    removal.remove.put(REFS_HEADS, accessSectionToRemove);
-    pApi.access(removal);
-
-    // Remove locally
-    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LABEL_CODE_REVIEW);
-
-    // Check
-    assertThat(pApi.access().local).isEqualTo(accessInput.add);
-  }
-
-  @Test
-  public void removePermissionRule() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi.access(accessInput);
-
-    // Remove specific permission rule
-    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
-    PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LABEL_CODE_REVIEW;
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionToRemove.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
-    ProjectAccessInput removal = newProjectAccessInput();
-    removal.remove.put(REFS_HEADS, accessSectionToRemove);
-    pApi.access(removal);
-
-    // Remove locally
-    accessInput
-        .add
-        .get(REFS_HEADS)
-        .permissions
-        .get(Permission.LABEL + LABEL_CODE_REVIEW)
-        .rules
-        .remove(SystemGroupBackend.REGISTERED_USERS.get());
-
-    // Check
-    assertThat(pApi.access().local).isEqualTo(accessInput.add);
-  }
-
-  @Test
-  public void removePermissionRulesAndCleanupEmptyEntries() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi.access(accessInput);
-
-    // Remove specific permission rules
-    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
-    PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LABEL_CODE_REVIEW;
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSectionToRemove.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
-    ProjectAccessInput removal = newProjectAccessInput();
-    removal.remove.put(REFS_HEADS, accessSectionToRemove);
-    pApi.access(removal);
-
-    // Remove locally
-    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LABEL_CODE_REVIEW);
-
-    // Check
-    assertThat(pApi.access().local).isEqualTo(accessInput.add);
-  }
-
-  @Test
-  public void getPermissionsWithDisallowedUser() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
-
-    // Disallow READ
-    accessInput.add.put(REFS_ALL, accessSectionInfo);
-    pApi.access(accessInput);
-
-    setApiUser(user);
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(newProjectName).access();
-  }
-
-  @Test
-  public void setPermissionsWithDisallowedUser() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
-
-    // Disallow READ
-    accessInput.add.put(REFS_ALL, accessSectionInfo);
-    pApi.access(accessInput);
-
-    // Create a change to apply
-    ProjectAccessInput accessInfoToApply = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfoToApply = createDefaultAccessSectionInfo();
-    accessInfoToApply.add.put(REFS_HEADS, accessSectionInfoToApply);
-
-    setApiUser(user);
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(newProjectName).access();
-  }
-
-  @Test
-  public void permissionsGroupMap() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSection.permissions.put(Permission.PUSH, push);
-
-    PermissionInfo read = newPermissionInfo();
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
-    accessSection.permissions.put(Permission.READ, read);
-
-    accessInput.add.put(REFS_ALL, accessSection);
-    ProjectAccessInfo result = pApi.access(accessInput);
-    assertThat(result.groups.keySet())
-        .containsExactly(
-            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
-
-    // Check the name, which is what the UI cares about; exhaustive
-    // coverage of GroupInfo should be in groups REST API tests.
-    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).name)
-        .isEqualTo("Project Owners");
-    // Strip the ID, since it is in the key.
-    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).id).isNull();
-
-    // Get call returns groups too.
-    ProjectAccessInfo loggedInResult = pApi.access();
-    assertThat(loggedInResult.groups.keySet())
-        .containsExactly(
-            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
-    assertThat(loggedInResult.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).name)
-        .isEqualTo("Project Owners");
-    assertThat(loggedInResult.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).id).isNull();
-
-    // PROJECT_OWNERS is invisible to anonymous user, so we strip it.
-    setApiUserAnonymous();
-    ProjectAccessInfo anonResult = pApi.access();
-    assertThat(anonResult.groups.keySet())
-        .containsExactly(SystemGroupBackend.ANONYMOUS_USERS.get());
-  }
-
-  @Test
-  public void updateParentAsUser() throws Exception {
-    // Create child
-    String newParentProjectName = createProject(PROJECT_NAME + "PA").get();
-
-    // Set new parent
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    accessInput.parent = newParentProjectName;
-
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage("administrate server not permitted");
-    gApi.projects().name(newProjectName).access(accessInput);
-  }
-
-  @Test
-  public void updateParentAsAdministrator() throws Exception {
-    // Create parent
-    String newParentProjectName = createProject(PROJECT_NAME + "PA").get();
-
-    // Set new parent
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    accessInput.parent = newParentProjectName;
-
-    gApi.projects().name(newProjectName).access(accessInput);
-
-    assertThat(pApi.access().inheritsFrom.name).isEqualTo(newParentProjectName);
-  }
-
-  @Test
-  public void addGlobalCapabilityAsUser() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-  }
-
-  @Test
-  public void addGlobalCapabilityAsAdmin() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    ProjectAccessInfo updatedAccessSectionInfo =
-        gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThat(
-            updatedAccessSectionInfo
-                .local
-                .get(AccessSection.GLOBAL_CAPABILITIES)
-                .permissions
-                .keySet())
-        .containsAllIn(accessSectionInfo.permissions.keySet());
-  }
-
-  @Test
-  public void addGlobalCapabilityForNonRootProject() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    exception.expect(BadRequestException.class);
-    pApi.access(accessInput);
-  }
-
-  @Test
-  public void addNonGlobalCapabilityToGlobalCapabilities() throws Exception {
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-    PermissionInfo permissionInfo = newPermissionInfo();
-    permissionInfo.rules.put(adminGroup.getGroupUUID().get(), null);
-    accessSectionInfo.permissions.put(Permission.PUSH, permissionInfo);
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    exception.expect(BadRequestException.class);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-  }
-
-  @Test
-  public void removeGlobalCapabilityAsUser() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-  }
-
-  @Test
-  public void removeGlobalCapabilityAsAdmin() throws Exception {
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-    PermissionInfo permissionInfo = newPermissionInfo();
-    permissionInfo.rules.put(adminGroup.getGroupUUID().get(), null);
-    accessSectionInfo.permissions.put(GlobalCapability.ACCESS_DATABASE, permissionInfo);
-
-    // Add and validate first as removing existing privileges such as
-    // administrateServer would break upcoming tests
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    ProjectAccessInfo updatedProjectAccessInfo =
-        gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThat(
-            updatedProjectAccessInfo
-                .local
-                .get(AccessSection.GLOBAL_CAPABILITIES)
-                .permissions
-                .keySet())
-        .containsAllIn(accessSectionInfo.permissions.keySet());
-
-    // Remove
-    accessInput.add.clear();
-    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    updatedProjectAccessInfo = gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThat(
-            updatedProjectAccessInfo
-                .local
-                .get(AccessSection.GLOBAL_CAPABILITIES)
-                .permissions
-                .keySet())
-        .containsNoneIn(accessSectionInfo.permissions.keySet());
-  }
-
-  @Test
-  public void unknownPermissionRemainsUnchanged() throws Exception {
-    String access = "access";
-    String unknownPermission = "unknownPermission";
-    String registeredUsers = "group Registered Users";
-    String refsFor = "refs/for/*";
-    // Clone repository to forcefully add permission
-    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
-
-    // Fetch permission ref
-    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
-    allProjectsRepo.reset("cfg");
-
-    // Load current permissions
-    String config =
-        gApi.projects()
-            .name(allProjects.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file("project.config")
-            .asString();
-
-    // Append and push unknown permission
-    Config cfg = new Config();
-    cfg.fromText(config);
-    cfg.setString(access, refsFor, unknownPermission, registeredUsers);
-    config = cfg.toText();
-    PushOneCommit push =
-        pushFactory.create(
-            db, admin.getIdent(), allProjectsRepo, "Subject", "project.config", config);
-    push.to(RefNames.REFS_CONFIG).assertOkStatus();
-
-    // Verify that unknownPermission is present
-    config =
-        gApi.projects()
-            .name(allProjects.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file("project.config")
-            .asString();
-    cfg.fromText(config);
-    assertThat(cfg.getString(access, refsFor, unknownPermission)).isEqualTo(registeredUsers);
-
-    // Make permission change through API
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-    accessInput.add.put(refsFor, accessSectionInfo);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-    accessInput.add.clear();
-    accessInput.remove.put(refsFor, accessSectionInfo);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Verify that unknownPermission is still present
-    config =
-        gApi.projects()
-            .name(allProjects.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file("project.config")
-            .asString();
-    cfg.fromText(config);
-    assertThat(cfg.getString(access, refsFor, unknownPermission)).isEqualTo(registeredUsers);
-  }
-
-  private ProjectAccessInput newProjectAccessInput() {
-    ProjectAccessInput p = new ProjectAccessInput();
-    p.add = new HashMap<>();
-    p.remove = new HashMap<>();
-    return p;
-  }
-
-  private PermissionInfo newPermissionInfo() {
-    PermissionInfo p = new PermissionInfo(null, null);
-    p.rules = new HashMap<>();
-    return p;
-  }
-
-  private AccessSectionInfo newAccessSectionInfo() {
-    AccessSectionInfo a = new AccessSectionInfo();
-    a.permissions = new HashMap<>();
-    return a;
-  }
-
-  private AccessSectionInfo createDefaultAccessSectionInfo() {
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSection.permissions.put(Permission.PUSH, push);
-
-    PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LABEL_CODE_REVIEW;
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    pri.max = 1;
-    pri.min = -1;
-    codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSection.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
-
-    return accessSection;
-  }
-
-  private AccessSectionInfo createDefaultGlobalCapabilitiesAccessSectionInfo() {
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo email = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    email.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSection.permissions.put(GlobalCapability.EMAIL_REVIEWERS, email);
-
-    return accessSection;
-  }
-
-  private AccessSectionInfo createAccessSectionInfoDenyAll() {
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo read = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
-    accessSection.permissions.put(Permission.READ, read);
-
-    return accessSection;
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD
deleted file mode 100644
index fbe5d80..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD
+++ /dev/null
@@ -1,49 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "rest_project",
-    labels = ["rest"],
-    deps = [
-        ":project",
-        ":push_tag_util",
-        ":refassert",
-    ],
-)
-
-java_library(
-    name = "refassert",
-    srcs = [
-        "RefAssert.java",
-    ],
-    deps = [
-        "//gerrit-extension-api:api",
-        "//gerrit-server:server",
-        "//lib:truth",
-    ],
-)
-
-java_library(
-    name = "project",
-    srcs = [
-        "ProjectAssert.java",
-    ],
-    deps = [
-        "//gerrit-extension-api:api",
-        "//gerrit-reviewdb:server",
-        "//gerrit-server:server",
-        "//lib:gwtorm",
-        "//lib:truth",
-    ],
-)
-
-java_library(
-    name = "push_tag_util",
-    testonly = 1,
-    srcs = [
-        "AbstractPushTag.java",
-    ],
-    deps = [
-        "//gerrit-acceptance-tests:lib",
-    ],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
deleted file mode 100644
index 90d51e0..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.project;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
-import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.project.BanCommit;
-import com.google.gerrit.server.project.BanCommit.BanResultInfo;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.RemoteRefUpdate;
-import org.junit.Test;
-
-public class BanCommitIT extends AbstractDaemonTest {
-
-  @Test
-  public void banCommit() throws Exception {
-    RevCommit c = commitBuilder().add("a.txt", "some content").create();
-
-    RestResponse r =
-        adminRestSession.put(
-            "/projects/" + project.get() + "/ban/", BanCommit.Input.fromCommits(c.name()));
-    r.assertOK();
-    BanResultInfo info = newGson().fromJson(r.getReader(), BanResultInfo.class);
-    assertThat(Iterables.getOnlyElement(info.newlyBanned)).isEqualTo(c.name());
-    assertThat(info.alreadyBanned).isNull();
-    assertThat(info.ignored).isNull();
-
-    RemoteRefUpdate u =
-        pushHead(testRepo, "refs/heads/master", false).getRemoteUpdate("refs/heads/master");
-    assertThat(u).isNotNull();
-    assertThat(u.getStatus()).isEqualTo(REJECTED_OTHER_REASON);
-    assertThat(u.getMessage()).startsWith("contains banned commit");
-  }
-
-  @Test
-  public void banAlreadyBannedCommit() throws Exception {
-    RestResponse r =
-        adminRestSession.put(
-            "/projects/" + project.get() + "/ban/",
-            BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
-    r.consume();
-
-    r =
-        adminRestSession.put(
-            "/projects/" + project.get() + "/ban/",
-            BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
-    r.assertOK();
-    BanResultInfo info = newGson().fromJson(r.getReader(), BanResultInfo.class);
-    assertThat(Iterables.getOnlyElement(info.alreadyBanned))
-        .isEqualTo("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96");
-    assertThat(info.newlyBanned).isNull();
-    assertThat(info.ignored).isNull();
-  }
-
-  @Test
-  public void banCommit_Forbidden() throws Exception {
-    userRestSession
-        .put(
-            "/projects/" + project.get() + "/ban/",
-            BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"))
-        .assertForbidden();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
deleted file mode 100644
index 766089b..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ /dev/null
@@ -1,111 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.project;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-
-import 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.BranchApi;
-import com.google.gerrit.extensions.api.projects.BranchInfo;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
-import org.eclipse.jgit.lib.Constants;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class CreateBranchIT extends AbstractDaemonTest {
-  private Branch.NameKey branch;
-
-  @Before
-  public void setUp() throws Exception {
-    branch = new Branch.NameKey(project, "test");
-  }
-
-  @Test
-  public void createBranch_Forbidden() throws Exception {
-    setApiUser(user);
-    assertCreateFails(AuthException.class, "create not permitted for refs/heads/test");
-  }
-
-  @Test
-  public void createBranchByAdmin() throws Exception {
-    assertCreateSucceeds();
-  }
-
-  @Test
-  public void branchAlreadyExists_Conflict() throws Exception {
-    assertCreateSucceeds();
-    assertCreateFails(ResourceConflictException.class);
-  }
-
-  @Test
-  public void createBranchByProjectOwner() throws Exception {
-    grantOwner();
-    setApiUser(user);
-    assertCreateSucceeds();
-  }
-
-  @Test
-  public void createBranchByAdminCreateReferenceBlocked_Forbidden() throws Exception {
-    blockCreateReference();
-    assertCreateFails(AuthException.class, "create not permitted for refs/heads/test");
-  }
-
-  @Test
-  public void createBranchByProjectOwnerCreateReferenceBlocked_Forbidden() throws Exception {
-    grantOwner();
-    blockCreateReference();
-    setApiUser(user);
-    assertCreateFails(AuthException.class, "create not permitted for refs/heads/test");
-  }
-
-  private void blockCreateReference() throws Exception {
-    block("refs/*", Permission.CREATE, ANONYMOUS_USERS);
-  }
-
-  private void grantOwner() throws Exception {
-    allow("refs/*", Permission.OWNER, REGISTERED_USERS);
-  }
-
-  private BranchApi branch() throws Exception {
-    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
-  }
-
-  private void assertCreateSucceeds() throws Exception {
-    BranchInfo created = branch().create(new BranchInput()).get();
-    assertThat(created.ref).isEqualTo(Constants.R_HEADS + branch.getShortName());
-  }
-
-  private void assertCreateFails(Class<? extends RestApiException> errType, String errMsg)
-      throws Exception {
-    exception.expect(errType);
-    if (errMsg != null) {
-      exception.expectMessage(errMsg);
-    }
-    branch().create(new BranchInput());
-  }
-
-  private void assertCreateFails(Class<? extends RestApiException> errType) throws Exception {
-    assertCreateFails(errType, null);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
deleted file mode 100644
index 0409fbc..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ /dev/null
@@ -1,329 +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 static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectInfo;
-import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectOwners;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
-import com.google.common.net.HttpHeaders;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.UseLocalDisk;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectState;
-import java.util.Collections;
-import java.util.Set;
-import org.apache.http.HttpStatus;
-import org.apache.http.message.BasicHeader;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.TreeWalk;
-import org.junit.Test;
-
-public class CreateProjectIT extends AbstractDaemonTest {
-  @Test
-  public void createProjectHttp() throws Exception {
-    String newProjectName = name("newProject");
-    RestResponse r = adminRestSession.put("/projects/" + newProjectName);
-    r.assertCreated();
-    ProjectInfo p = newGson().fromJson(r.getReader(), ProjectInfo.class);
-    assertThat(p.name).isEqualTo(newProjectName);
-
-    // Check that we populate the label data in the HTTP path. See GetProjectIT#getProject
-    // for more extensive coverage of the LabelTypeInfo.
-    assertThat(p.labels).hasSize(1);
-
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
-    assertThat(projectState).isNotNull();
-    assertProjectInfo(projectState.getProject(), p);
-    assertHead(newProjectName, "refs/heads/master");
-  }
-
-  @Test
-  public void createProjectHttpWhenProjectAlreadyExists_Conflict() throws Exception {
-    adminRestSession.put("/projects/" + allProjects.get()).assertConflict();
-  }
-
-  @Test
-  public void createProjectHttpWhenProjectAlreadyExists_PreconditionFailed() throws Exception {
-    adminRestSession
-        .putWithHeader(
-            "/projects/" + allProjects.get(), new BasicHeader(HttpHeaders.IF_NONE_MATCH, "*"))
-        .assertPreconditionFailed();
-  }
-
-  @Test
-  @UseLocalDisk
-  public void createProjectHttpWithUnreasonableName_BadRequest() throws Exception {
-    ImmutableList<String> forbiddenStrings =
-        ImmutableList.of(
-            "/../", "/./", "//", ".git/", "?", "%", "*", ":", "<", ">", "|", "$", "/+", "~");
-    for (String s : forbiddenStrings) {
-      String projectName = name("invalid" + s + "name");
-      assertWithMessage("Expected status code for " + projectName + " to be 400.")
-          .that(adminRestSession.put("/projects/" + Url.encode(projectName)).getStatusCode())
-          .isEqualTo(HttpStatus.SC_BAD_REQUEST);
-    }
-  }
-
-  @Test
-  public void createProjectHttpWithNameMismatch_BadRequest() throws Exception {
-    ProjectInput in = new ProjectInput();
-    in.name = name("otherName");
-    adminRestSession.put("/projects/" + name("someName"), in).assertBadRequest();
-  }
-
-  @Test
-  public void createProjectHttpWithInvalidRefName_BadRequest() throws Exception {
-    ProjectInput in = new ProjectInput();
-    in.branches = Collections.singletonList(name("invalid ref name"));
-    adminRestSession.put("/projects/" + name("newProject"), in).assertBadRequest();
-  }
-
-  @Test
-  public void createProject() throws Exception {
-    String newProjectName = name("newProject");
-    ProjectInfo p = gApi.projects().create(newProjectName).get();
-    assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
-    assertThat(projectState).isNotNull();
-    assertProjectInfo(projectState.getProject(), p);
-    assertHead(newProjectName, "refs/heads/master");
-  }
-
-  @Test
-  public void createProjectWithGitSuffix() throws Exception {
-    String newProjectName = name("newProject");
-    ProjectInfo p = gApi.projects().create(newProjectName + ".git").get();
-    assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
-    assertThat(projectState).isNotNull();
-    assertProjectInfo(projectState.getProject(), p);
-    assertHead(newProjectName, "refs/heads/master");
-  }
-
-  @Test
-  public void createProjectWithProperties() throws Exception {
-    String newProjectName = name("newProject");
-    ProjectInput in = new ProjectInput();
-    in.name = newProjectName;
-    in.description = "Test description";
-    in.submitType = SubmitType.CHERRY_PICK;
-    in.useContributorAgreements = InheritableBoolean.TRUE;
-    in.useSignedOffBy = InheritableBoolean.TRUE;
-    in.useContentMerge = InheritableBoolean.TRUE;
-    in.requireChangeId = InheritableBoolean.TRUE;
-    ProjectInfo p = gApi.projects().create(in).get();
-    assertThat(p.name).isEqualTo(newProjectName);
-    Project project = projectCache.get(new Project.NameKey(newProjectName)).getProject();
-    assertProjectInfo(project, p);
-    assertThat(project.getDescription()).isEqualTo(in.description);
-    assertThat(project.getSubmitType()).isEqualTo(in.submitType);
-    assertThat(project.getUseContributorAgreements()).isEqualTo(in.useContributorAgreements);
-    assertThat(project.getUseSignedOffBy()).isEqualTo(in.useSignedOffBy);
-    assertThat(project.getUseContentMerge()).isEqualTo(in.useContentMerge);
-    assertThat(project.getRequireChangeID()).isEqualTo(in.requireChangeId);
-  }
-
-  @Test
-  public void createChildProject() throws Exception {
-    String parentName = name("parent");
-    ProjectInput in = new ProjectInput();
-    in.name = parentName;
-    gApi.projects().create(in);
-
-    String childName = name("child");
-    in = new ProjectInput();
-    in.name = childName;
-    in.parent = parentName;
-    gApi.projects().create(in);
-    Project project = projectCache.get(new Project.NameKey(childName)).getProject();
-    assertThat(project.getParentName()).isEqualTo(in.parent);
-  }
-
-  @Test
-  public void createChildProjectUnderNonExistingParent_UnprocessableEntity() throws Exception {
-    ProjectInput in = new ProjectInput();
-    in.name = name("newProjectName");
-    in.parent = "non-existing-project";
-    assertCreateFails(in, UnprocessableEntityException.class);
-  }
-
-  @Test
-  public void createProjectWithOwner() throws Exception {
-    String newProjectName = name("newProject");
-    ProjectInput in = new ProjectInput();
-    in.name = newProjectName;
-    in.owners = Lists.newArrayListWithCapacity(3);
-    in.owners.add("Anonymous Users"); // by name
-    in.owners.add(SystemGroupBackend.REGISTERED_USERS.get()); // by UUID
-    in.owners.add(
-        Integer.toString(
-            groupCache
-                .get(new AccountGroup.NameKey("Administrators"))
-                .orElse(null)
-                .getId()
-                .get())); // by ID
-    gApi.projects().create(in);
-    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
-    Set<AccountGroup.UUID> expectedOwnerIds = Sets.newHashSetWithExpectedSize(3);
-    expectedOwnerIds.add(SystemGroupBackend.ANONYMOUS_USERS);
-    expectedOwnerIds.add(SystemGroupBackend.REGISTERED_USERS);
-    expectedOwnerIds.add(groupUuid("Administrators"));
-    assertProjectOwners(expectedOwnerIds, projectState);
-  }
-
-  @Test
-  public void createProjectWithNonExistingOwner_UnprocessableEntity() throws Exception {
-    ProjectInput in = new ProjectInput();
-    in.name = name("newProjectName");
-    in.owners = Collections.singletonList("non-existing-group");
-    assertCreateFails(in, UnprocessableEntityException.class);
-  }
-
-  @Test
-  public void createPermissionOnlyProject() throws Exception {
-    String newProjectName = name("newProject");
-    ProjectInput in = new ProjectInput();
-    in.name = newProjectName;
-    in.permissionsOnly = true;
-    gApi.projects().create(in);
-    assertHead(newProjectName, RefNames.REFS_CONFIG);
-  }
-
-  @Test
-  public void createProjectWithEmptyCommit() throws Exception {
-    String newProjectName = name("newProject");
-    ProjectInput in = new ProjectInput();
-    in.name = newProjectName;
-    in.createEmptyCommit = true;
-    gApi.projects().create(in);
-    assertEmptyCommit(newProjectName, "refs/heads/master");
-  }
-
-  @Test
-  public void createProjectWithBranches() throws Exception {
-    String newProjectName = name("newProject");
-    ProjectInput in = new ProjectInput();
-    in.name = newProjectName;
-    in.createEmptyCommit = true;
-    in.branches = Lists.newArrayListWithCapacity(3);
-    in.branches.add("refs/heads/test");
-    in.branches.add("refs/heads/master");
-    in.branches.add("release"); // without 'refs/heads' prefix
-    gApi.projects().create(in);
-    assertHead(newProjectName, "refs/heads/test");
-    assertEmptyCommit(newProjectName, "refs/heads/test", "refs/heads/master", "refs/heads/release");
-  }
-
-  @Test
-  public void createProjectWithCapability() throws Exception {
-    allowGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
-    try {
-      setApiUser(user);
-      ProjectInput in = new ProjectInput();
-      in.name = name("newProject");
-      ProjectInfo p = gApi.projects().create(in).get();
-      assertThat(p.name).isEqualTo(in.name);
-    } finally {
-      removeGlobalCapabilities(
-          SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
-    }
-  }
-
-  @Test
-  public void createProjectWithoutCapability_Forbidden() throws Exception {
-    setApiUser(user);
-    ProjectInput in = new ProjectInput();
-    in.name = name("newProject");
-    assertCreateFails(in, AuthException.class);
-  }
-
-  @Test
-  public void createProjectWhenProjectAlreadyExists_Conflict() throws Exception {
-    ProjectInput in = new ProjectInput();
-    in.name = allProjects.get();
-    assertCreateFails(in, ResourceConflictException.class);
-  }
-
-  @Test
-  public void createProjectWithCreateProjectCapabilityAndParentNotVisible() throws Exception {
-    Project parent = projectCache.get(allProjects).getProject();
-    parent.setState(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
-    allowGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
-    try {
-      setApiUser(user);
-      ProjectInput in = new ProjectInput();
-      in.name = name("newProject");
-      ProjectInfo p = gApi.projects().create(in).get();
-      assertThat(p.name).isEqualTo(in.name);
-    } finally {
-      parent.setState(com.google.gerrit.extensions.client.ProjectState.ACTIVE);
-      removeGlobalCapabilities(
-          SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
-    }
-  }
-
-  private AccountGroup.UUID groupUuid(String groupName) {
-    return groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null).getGroupUUID();
-  }
-
-  private void assertHead(String projectName, String expectedRef) throws Exception {
-    try (Repository repo = repoManager.openRepository(new Project.NameKey(projectName))) {
-      assertThat(repo.exactRef(Constants.HEAD).getTarget().getName()).isEqualTo(expectedRef);
-    }
-  }
-
-  private void assertEmptyCommit(String projectName, String... refs) throws Exception {
-    Project.NameKey projectKey = new Project.NameKey(projectName);
-    try (Repository repo = repoManager.openRepository(projectKey);
-        RevWalk rw = new RevWalk(repo);
-        TreeWalk tw = new TreeWalk(rw.getObjectReader())) {
-      for (String ref : refs) {
-        RevCommit commit = rw.lookupCommit(repo.exactRef(ref).getObjectId());
-        rw.parseBody(commit);
-        tw.addTree(commit.getTree());
-        assertThat(tw.next()).isFalse();
-        tw.reset();
-      }
-    }
-  }
-
-  private void assertCreateFails(ProjectInput in, Class<? extends RestApiException> errType)
-      throws Exception {
-    exception.expect(errType);
-    gApi.projects().create(in);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
deleted file mode 100644
index ce30cd5..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ /dev/null
@@ -1,176 +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 static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static org.eclipse.jgit.lib.Constants.R_HEADS;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.projects.BranchApi;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.RefNames;
-import org.junit.Before;
-import org.junit.Test;
-
-public class DeleteBranchIT extends AbstractDaemonTest {
-
-  private Branch.NameKey testBranch;
-
-  @Before
-  public void setUp() throws Exception {
-    project = createProject(name("p"));
-    testBranch = new Branch.NameKey(project, "test");
-    branch(testBranch).create(new BranchInput());
-  }
-
-  @Test
-  public void deleteBranch_Forbidden() throws Exception {
-    setApiUser(user);
-    assertDeleteForbidden(testBranch);
-  }
-
-  @Test
-  public void deleteBranchByAdmin() throws Exception {
-    assertDeleteSucceeds(testBranch);
-  }
-
-  @Test
-  public void deleteBranchByProjectOwner() throws Exception {
-    grantOwner();
-    setApiUser(user);
-    assertDeleteSucceeds(testBranch);
-  }
-
-  @Test
-  public void deleteBranchByAdminForcePushBlocked() throws Exception {
-    blockForcePush();
-    assertDeleteSucceeds(testBranch);
-  }
-
-  @Test
-  public void deleteBranchByProjectOwnerForcePushBlocked_Forbidden() throws Exception {
-    grantOwner();
-    blockForcePush();
-    setApiUser(user);
-    assertDeleteForbidden(testBranch);
-  }
-
-  @Test
-  public void deleteBranchByUserWithForcePushPermission() throws Exception {
-    grantForcePush();
-    setApiUser(user);
-    assertDeleteSucceeds(testBranch);
-  }
-
-  @Test
-  public void deleteBranchByUserWithDeletePermission() throws Exception {
-    grantDelete();
-    setApiUser(user);
-    assertDeleteSucceeds(testBranch);
-  }
-
-  @Test
-  public void deleteBranchByRestWithoutRefsHeadsPrefix() throws Exception {
-    grantDelete();
-    String ref = testBranch.getShortName();
-    assertThat(ref).doesNotMatch(R_HEADS);
-    assertDeleteByRestSucceeds(testBranch, ref);
-  }
-
-  @Test
-  public void deleteBranchByRestWithFullName() throws Exception {
-    grantDelete();
-    assertDeleteByRestSucceeds(testBranch, testBranch.get());
-  }
-
-  @Test
-  public void deleteBranchByRestFailsWithUnencodedFullName() throws Exception {
-    grantDelete();
-    RestResponse r =
-        userRestSession.delete("/projects/" + project.get() + "/branches/" + testBranch.get());
-    r.assertNotFound();
-    branch(testBranch).get();
-  }
-
-  @Test
-  public void deleteMetaBranch() throws Exception {
-    String metaRef = RefNames.REFS_META + "foo";
-    allow(metaRef, Permission.CREATE, REGISTERED_USERS);
-    allow(metaRef, Permission.PUSH, REGISTERED_USERS);
-
-    Branch.NameKey metaBranch = new Branch.NameKey(project, metaRef);
-    branch(metaBranch).create(new BranchInput());
-
-    grantDelete();
-    assertDeleteByRestSucceeds(metaBranch, metaRef);
-  }
-
-  private void blockForcePush() throws Exception {
-    block("refs/heads/*", Permission.PUSH, ANONYMOUS_USERS).setForce(true);
-  }
-
-  private void grantForcePush() throws Exception {
-    grant(project, "refs/heads/*", Permission.PUSH, true, ANONYMOUS_USERS);
-  }
-
-  private void grantDelete() throws Exception {
-    allow("refs/*", Permission.DELETE, ANONYMOUS_USERS);
-  }
-
-  private void grantOwner() throws Exception {
-    allow("refs/*", Permission.OWNER, REGISTERED_USERS);
-  }
-
-  private BranchApi branch(Branch.NameKey branch) throws Exception {
-    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
-  }
-
-  private void assertDeleteByRestSucceeds(Branch.NameKey branch, String ref) throws Exception {
-    RestResponse r =
-        userRestSession.delete(
-            "/projects/"
-                + IdString.fromDecoded(project.get()).encoded()
-                + "/branches/"
-                + IdString.fromDecoded(ref).encoded());
-    r.assertNoContent();
-    exception.expect(ResourceNotFoundException.class);
-    branch(branch).get();
-  }
-
-  private void assertDeleteSucceeds(Branch.NameKey branch) throws Exception {
-    assertThat(branch(branch).get().canDelete).isTrue();
-    String branchRev = branch(branch).get().revision;
-    branch(branch).delete();
-    eventRecorder.assertRefUpdatedEvents(
-        project.get(), branch.get(), null, branchRev, branchRev, null);
-    exception.expect(ResourceNotFoundException.class);
-    branch(branch).get();
-  }
-
-  private void assertDeleteForbidden(Branch.NameKey branch) throws Exception {
-    assertThat(branch(branch).get().canDelete).isNull();
-    exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
-    branch(branch).delete();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
deleted file mode 100644
index 73b20aa..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
+++ /dev/null
@@ -1,196 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.project;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.util.stream.Collectors.toList;
-import static org.eclipse.jgit.lib.Constants.R_HEADS;
-import static org.eclipse.jgit.lib.Constants.R_REFS;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
-import com.google.gerrit.extensions.api.projects.ProjectApi;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.RefNames;
-import java.util.HashMap;
-import java.util.List;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class DeleteBranchesIT extends AbstractDaemonTest {
-  private static final ImmutableList<String> BRANCHES =
-      ImmutableList.of("refs/heads/test-1", "refs/heads/test-2", "test-3", "refs/meta/foo");
-
-  @Before
-  public void setUp() throws Exception {
-    allow("refs/*", Permission.CREATE, REGISTERED_USERS);
-    allow("refs/*", Permission.PUSH, REGISTERED_USERS);
-    for (String name : BRANCHES) {
-      project().branch(name).create(new BranchInput());
-    }
-    assertBranches(BRANCHES);
-  }
-
-  @Test
-  public void deleteBranches() throws Exception {
-    HashMap<String, RevCommit> initialRevisions = initialRevisions(BRANCHES);
-    DeleteBranchesInput input = new DeleteBranchesInput();
-    input.branches = BRANCHES;
-    project().deleteBranches(input);
-    assertBranchesDeleted(BRANCHES);
-    assertRefUpdatedEvents(initialRevisions);
-  }
-
-  @Test
-  public void deleteBranchesForbidden() throws Exception {
-    DeleteBranchesInput input = new DeleteBranchesInput();
-    input.branches = BRANCHES;
-    setApiUser(user);
-    try {
-      project().deleteBranches(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e).hasMessageThat().isEqualTo(errorMessageForBranches(BRANCHES));
-    }
-    setApiUser(admin);
-    assertBranches(BRANCHES);
-  }
-
-  @Test
-  public void deleteBranchesNotFound() throws Exception {
-    DeleteBranchesInput input = new DeleteBranchesInput();
-    List<String> branches = Lists.newArrayList(BRANCHES);
-    branches.add("refs/heads/does-not-exist");
-    input.branches = branches;
-    try {
-      project().deleteBranches(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
-    }
-    assertBranchesDeleted(BRANCHES);
-  }
-
-  @Test
-  public void deleteBranchesNotFoundContinue() throws Exception {
-    // If it fails on the first branch in the input, it should still
-    // continue to process the remaining branches.
-    DeleteBranchesInput input = new DeleteBranchesInput();
-    List<String> branches = Lists.newArrayList("refs/heads/does-not-exist");
-    branches.addAll(BRANCHES);
-    input.branches = branches;
-    try {
-      project().deleteBranches(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
-    }
-    assertBranchesDeleted(BRANCHES);
-  }
-
-  @Test
-  public void missingInput() throws Exception {
-    DeleteBranchesInput input = null;
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("branches must be specified");
-    project().deleteBranches(input);
-  }
-
-  @Test
-  public void missingBranchList() throws Exception {
-    DeleteBranchesInput input = new DeleteBranchesInput();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("branches must be specified");
-    project().deleteBranches(input);
-  }
-
-  @Test
-  public void emptyBranchList() throws Exception {
-    DeleteBranchesInput input = new DeleteBranchesInput();
-    input.branches = Lists.newArrayList();
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("branches must be specified");
-    project().deleteBranches(input);
-  }
-
-  private String errorMessageForBranches(List<String> branches) {
-    StringBuilder message = new StringBuilder();
-    for (String branch : branches) {
-      message
-          .append("Cannot delete ")
-          .append(prefixRef(branch))
-          .append(": it doesn't exist or you do not have permission ")
-          .append("to delete it\n");
-    }
-    return message.toString();
-  }
-
-  private HashMap<String, RevCommit> initialRevisions(List<String> branches) throws Exception {
-    HashMap<String, RevCommit> result = new HashMap<>();
-    for (String branch : branches) {
-      result.put(branch, getRemoteHead(project, branch));
-    }
-    return result;
-  }
-
-  private void assertRefUpdatedEvents(HashMap<String, RevCommit> revisions) throws Exception {
-    for (String branch : revisions.keySet()) {
-      RevCommit revision = revisions.get(branch);
-      eventRecorder.assertRefUpdatedEvents(
-          project.get(), prefixRef(branch), null, revision, revision, null);
-    }
-  }
-
-  private String prefixRef(String ref) {
-    return ref.startsWith(R_REFS) ? ref : R_HEADS + ref;
-  }
-
-  private ProjectApi project() throws Exception {
-    return gApi.projects().name(project.get());
-  }
-
-  private void assertBranches(List<String> branches) throws Exception {
-    List<String> expected = Lists.newArrayList("HEAD", RefNames.REFS_CONFIG, "refs/heads/master");
-    expected.addAll(branches.stream().map(b -> prefixRef(b)).collect(toList()));
-    try (Repository repo = repoManager.openRepository(project)) {
-      for (String branch : expected) {
-        assertThat(repo.exactRef(branch)).isNotNull();
-      }
-    }
-  }
-
-  private void assertBranchesDeleted(List<String> branches) throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      for (String branch : branches) {
-        assertThat(repo.exactRef(branch)).isNull();
-      }
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
deleted file mode 100644
index 8f24609..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
+++ /dev/null
@@ -1,160 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.project;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.util.stream.Collectors.toList;
-import static org.eclipse.jgit.lib.Constants.R_TAGS;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
-import com.google.gerrit.extensions.api.projects.ProjectApi;
-import com.google.gerrit.extensions.api.projects.TagInfo;
-import com.google.gerrit.extensions.api.projects.TagInput;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import java.util.HashMap;
-import java.util.List;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class DeleteTagsIT extends AbstractDaemonTest {
-  private static final ImmutableList<String> TAGS =
-      ImmutableList.of("refs/tags/test-1", "refs/tags/test-2", "refs/tags/test-3", "test-4");
-
-  @Before
-  public void setUp() throws Exception {
-    for (String name : TAGS) {
-      project().tag(name).create(new TagInput());
-    }
-    assertTags(TAGS);
-  }
-
-  @Test
-  public void deleteTags() throws Exception {
-    HashMap<String, RevCommit> initialRevisions = initialRevisions(TAGS);
-    DeleteTagsInput input = new DeleteTagsInput();
-    input.tags = TAGS;
-    project().deleteTags(input);
-    assertTagsDeleted();
-    assertRefUpdatedEvents(initialRevisions);
-  }
-
-  @Test
-  public void deleteTagsForbidden() throws Exception {
-    DeleteTagsInput input = new DeleteTagsInput();
-    input.tags = TAGS;
-    setApiUser(user);
-    try {
-      project().deleteTags(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e).hasMessageThat().isEqualTo(errorMessageForTags(TAGS));
-    }
-    setApiUser(admin);
-    assertTags(TAGS);
-  }
-
-  @Test
-  public void deleteTagsNotFound() throws Exception {
-    DeleteTagsInput input = new DeleteTagsInput();
-    List<String> tags = Lists.newArrayList(TAGS);
-    tags.add("refs/tags/does-not-exist");
-    input.tags = tags;
-    try {
-      project().deleteTags(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(errorMessageForTags(ImmutableList.of("refs/tags/does-not-exist")));
-    }
-    assertTagsDeleted();
-  }
-
-  @Test
-  public void deleteTagsNotFoundContinue() throws Exception {
-    // If it fails on the first tag in the input, it should still
-    // continue to process the remaining tags.
-    DeleteTagsInput input = new DeleteTagsInput();
-    List<String> tags = Lists.newArrayList("refs/tags/does-not-exist");
-    tags.addAll(TAGS);
-    input.tags = tags;
-    try {
-      project().deleteTags(input);
-      fail("Expected ResourceConflictException");
-    } catch (ResourceConflictException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(errorMessageForTags(ImmutableList.of("refs/tags/does-not-exist")));
-    }
-    assertTagsDeleted();
-  }
-
-  private String errorMessageForTags(List<String> tags) {
-    StringBuilder message = new StringBuilder();
-    for (String tag : tags) {
-      message
-          .append("Cannot delete ")
-          .append(prefixRef(tag))
-          .append(": it doesn't exist or you do not have permission ")
-          .append("to delete it\n");
-    }
-    return message.toString();
-  }
-
-  private HashMap<String, RevCommit> initialRevisions(List<String> tags) throws Exception {
-    HashMap<String, RevCommit> result = new HashMap<>();
-    for (String tag : tags) {
-      String ref = prefixRef(tag);
-      result.put(ref, getRemoteHead(project, ref));
-    }
-    return result;
-  }
-
-  private void assertRefUpdatedEvents(HashMap<String, RevCommit> revisions) throws Exception {
-    for (String tag : revisions.keySet()) {
-      RevCommit revision = revisions.get(prefixRef(tag));
-      eventRecorder.assertRefUpdatedEvents(
-          project.get(), prefixRef(tag), null, revision, revision, null);
-    }
-  }
-
-  private String prefixRef(String ref) {
-    return ref.startsWith(R_TAGS) ? ref : R_TAGS + ref;
-  }
-
-  private ProjectApi project() throws Exception {
-    return gApi.projects().name(project.get());
-  }
-
-  private void assertTags(List<String> expected) throws Exception {
-    List<TagInfo> actualTags = project().tags().get();
-    Iterable<String> actualNames = Iterables.transform(actualTags, b -> b.ref);
-    assertThat(actualNames)
-        .containsExactlyElementsIn(expected.stream().map(t -> prefixRef(t)).collect(toList()))
-        .inOrder();
-  }
-
-  private void assertTagsDeleted() throws Exception {
-    assertTags(ImmutableList.<String>of());
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
deleted file mode 100644
index b62fd68..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ /dev/null
@@ -1,185 +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.gerrit.acceptance.rest.project.RefAssert.assertRefNames;
-import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefs;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.extensions.api.projects.BranchInfo;
-import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.RefNames;
-import org.junit.Test;
-
-@NoHttpd
-public class ListBranchesIT extends AbstractDaemonTest {
-  @Test
-  public void listBranchesOfNonExistingProject_NotFound() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name("non-existing").branches().get();
-  }
-
-  @Test
-  public void listBranchesOfNonVisibleProject_NotFound() throws Exception {
-    blockRead("refs/*");
-    setApiUser(user);
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(project.get()).branches().get();
-  }
-
-  @Test
-  @TestProjectInput(createEmptyCommit = false)
-  public void listBranchesOfEmptyProject() throws Exception {
-    assertRefs(
-        ImmutableList.of(branch("HEAD", null, false), branch(RefNames.REFS_CONFIG, null, false)),
-        list().get());
-  }
-
-  @Test
-  public void listBranches() throws Exception {
-    String master = pushTo("refs/heads/master").getCommit().name();
-    String dev = pushTo("refs/heads/dev").getCommit().name();
-    assertRefs(
-        ImmutableList.of(
-            branch("HEAD", "master", false),
-            branch(RefNames.REFS_CONFIG, null, false),
-            branch("refs/heads/dev", dev, true),
-            branch("refs/heads/master", master, false)),
-        list().get());
-  }
-
-  @Test
-  public void listBranchesSomeHidden() throws Exception {
-    blockRead("refs/heads/dev");
-    String master = pushTo("refs/heads/master").getCommit().name();
-    pushTo("refs/heads/dev");
-    setApiUser(user);
-    // refs/meta/config is hidden since user is no project owner
-    assertRefs(
-        ImmutableList.of(
-            branch("HEAD", "master", false), branch("refs/heads/master", master, false)),
-        list().get());
-  }
-
-  @Test
-  public void listBranchesHeadHidden() throws Exception {
-    blockRead("refs/heads/master");
-    pushTo("refs/heads/master");
-    String dev = pushTo("refs/heads/dev").getCommit().name();
-    setApiUser(user);
-    // refs/meta/config is hidden since user is no project owner
-    assertRefs(ImmutableList.of(branch("refs/heads/dev", dev, false)), list().get());
-  }
-
-  @Test
-  public void listBranchesUsingPagination() throws Exception {
-    pushTo("refs/heads/master");
-    pushTo("refs/heads/someBranch1");
-    pushTo("refs/heads/someBranch2");
-    pushTo("refs/heads/someBranch3");
-
-    // Using only limit.
-    assertRefNames(
-        ImmutableList.of(
-            "HEAD", RefNames.REFS_CONFIG, "refs/heads/master", "refs/heads/someBranch1"),
-        list().withLimit(4).get());
-
-    // Limit higher than total number of branches.
-    assertRefNames(
-        ImmutableList.of(
-            "HEAD",
-            RefNames.REFS_CONFIG,
-            "refs/heads/master",
-            "refs/heads/someBranch1",
-            "refs/heads/someBranch2",
-            "refs/heads/someBranch3"),
-        list().withLimit(25).get());
-
-    // Using start only.
-    assertRefNames(
-        ImmutableList.of(
-            "refs/heads/master",
-            "refs/heads/someBranch1",
-            "refs/heads/someBranch2",
-            "refs/heads/someBranch3"),
-        list().withStart(2).get());
-
-    // Skip more branches than the number of available branches.
-    assertRefNames(ImmutableList.<String>of(), list().withStart(7).get());
-
-    // Ssing start and limit.
-    assertRefNames(
-        ImmutableList.of("refs/heads/master", "refs/heads/someBranch1"),
-        list().withStart(2).withLimit(2).get());
-  }
-
-  @Test
-  public void listBranchesUsingFilter() throws Exception {
-    pushTo("refs/heads/master");
-    pushTo("refs/heads/someBranch1");
-    pushTo("refs/heads/someBranch2");
-    pushTo("refs/heads/someBranch3");
-
-    // Using substring.
-    assertRefNames(
-        ImmutableList.of(
-            "refs/heads/someBranch1", "refs/heads/someBranch2", "refs/heads/someBranch3"),
-        list().withSubstring("some").get());
-
-    assertRefNames(
-        ImmutableList.of(
-            "refs/heads/someBranch1", "refs/heads/someBranch2", "refs/heads/someBranch3"),
-        list().withSubstring("Branch").get());
-
-    assertRefNames(
-        ImmutableList.of(
-            "refs/heads/someBranch1", "refs/heads/someBranch2", "refs/heads/someBranch3"),
-        list().withSubstring("somebranch").get());
-
-    // Using regex.
-    assertRefNames(ImmutableList.of("refs/heads/master"), list().withRegex(".*ast.*r").get());
-    assertRefNames(ImmutableList.of(), list().withRegex(".*AST.*R").get());
-
-    // Conflicting options
-    assertBadRequest(list().withSubstring("somebranch").withRegex(".*ast.*r"));
-  }
-
-  private ListRefsRequest<BranchInfo> list() throws Exception {
-    return gApi.projects().name(project.get()).branches();
-  }
-
-  private static BranchInfo branch(String ref, String revision, boolean canDelete) {
-    BranchInfo info = new BranchInfo();
-    info.ref = ref;
-    info.revision = revision;
-    info.canDelete = canDelete ? true : null;
-    return info;
-  }
-
-  private void assertBadRequest(ListRefsRequest<BranchInfo> req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException e) {
-      // Expected
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
deleted file mode 100644
index 8bfb646..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ /dev/null
@@ -1,247 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.project;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.Sandboxed;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.projects.ConfigInfo;
-import com.google.gerrit.extensions.api.projects.ConfigInput;
-import com.google.gerrit.extensions.api.projects.Projects.ListRequest;
-import com.google.gerrit.extensions.api.projects.Projects.ListRequest.FilterType;
-import com.google.gerrit.extensions.client.ProjectState;
-import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.Util;
-import com.google.inject.Inject;
-import java.util.List;
-import java.util.Map;
-import org.junit.Test;
-
-@NoHttpd
-@Sandboxed
-public class ListProjectsIT extends AbstractDaemonTest {
-
-  @Inject private AllUsersName allUsers;
-
-  @Test
-  public void listProjects() throws Exception {
-    Project.NameKey someProject = createProject("some-project");
-    assertThatNameList(filter(gApi.projects().list().get()))
-        .containsExactly(allProjects, allUsers, project, someProject)
-        .inOrder();
-  }
-
-  @Test
-  public void listProjectsFiltersInvisibleProjects() throws Exception {
-    setApiUser(user);
-    assertThatNameList(gApi.projects().list().get()).contains(project);
-
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
-    saveProjectConfig(project, cfg);
-
-    assertThatNameList(filter(gApi.projects().list().get())).doesNotContain(project);
-  }
-
-  @Test
-  public void listProjectsWithBranch() throws Exception {
-    Map<String, ProjectInfo> result = gApi.projects().list().addShowBranch("master").getAsMap();
-    assertThat(result).containsKey(project.get());
-    ProjectInfo info = result.get(project.get());
-    assertThat(info.branches).isNotNull();
-    assertThat(info.branches).hasSize(1);
-    assertThat(info.branches.get("master")).isNotNull();
-  }
-
-  @Test
-  @TestProjectInput(description = "Description of some-project")
-  public void listProjectWithDescription() throws Exception {
-    // description not be included in the results by default.
-    Map<String, ProjectInfo> result = gApi.projects().list().getAsMap();
-    assertThat(result).containsKey(project.get());
-    assertThat(result.get(project.get()).description).isNull();
-
-    result = gApi.projects().list().withDescription(true).getAsMap();
-    assertThat(result).containsKey(project.get());
-    assertThat(result.get(project.get()).description).isEqualTo("Description of some-project");
-  }
-
-  @Test
-  public void listProjectsWithLimit() throws Exception {
-    for (int i = 0; i < 5; i++) {
-      createProject("someProject" + i);
-    }
-
-    String p = name("");
-    // 5, plus p which was automatically created.
-    int n = 6;
-    for (int i = 1; i <= n + 2; i++) {
-      assertThatNameList(gApi.projects().list().withPrefix(p).withLimit(i).get())
-          .hasSize(Math.min(i, n));
-    }
-  }
-
-  @Test
-  public void listProjectsWithPrefix() throws Exception {
-    Project.NameKey someProject = createProject("some-project");
-    Project.NameKey someOtherProject = createProject("some-other-project");
-    createProject("project-awesome");
-
-    String p = name("some");
-    assertBadRequest(gApi.projects().list().withPrefix(p).withRegex(".*"));
-    assertBadRequest(gApi.projects().list().withPrefix(p).withSubstring(p));
-    assertThatNameList(filter(gApi.projects().list().withPrefix(p).get()))
-        .containsExactly(someOtherProject, someProject)
-        .inOrder();
-    p = name("SOME");
-    assertThatNameList(filter(gApi.projects().list().withPrefix(p).get())).isEmpty();
-  }
-
-  @Test
-  public void listProjectsWithRegex() throws Exception {
-    Project.NameKey someProject = createProject("some-project");
-    Project.NameKey someOtherProject = createProject("some-other-project");
-    Project.NameKey projectAwesome = createProject("project-awesome");
-
-    assertBadRequest(gApi.projects().list().withRegex("[.*"));
-    assertBadRequest(gApi.projects().list().withRegex(".*").withPrefix("p"));
-    assertBadRequest(gApi.projects().list().withRegex(".*").withSubstring("p"));
-
-    assertThatNameList(filter(gApi.projects().list().withRegex(".*some").get()))
-        .containsExactly(projectAwesome);
-    String r = name("some-project$").replace(".", "\\.");
-    assertThatNameList(filter(gApi.projects().list().withRegex(r).get()))
-        .containsExactly(someProject);
-    assertThatNameList(filter(gApi.projects().list().withRegex(".*").get()))
-        .containsExactly(
-            allProjects, allUsers, project, projectAwesome, someOtherProject, someProject)
-        .inOrder();
-  }
-
-  @Test
-  public void listProjectsWithStart() throws Exception {
-    for (int i = 0; i < 5; i++) {
-      createProject(new Project.NameKey("someProject" + i).get());
-    }
-
-    String p = name("");
-    List<ProjectInfo> all = gApi.projects().list().withPrefix(p).get();
-    // 5, plus p which was automatically created.
-    int n = 6;
-    assertThat(all).hasSize(n);
-    assertThatNameList(gApi.projects().list().withPrefix(p).withStart(n - 1).get())
-        .containsExactly(new Project.NameKey(Iterables.getLast(all).name));
-  }
-
-  @Test
-  public void listProjectsWithSubstring() throws Exception {
-    Project.NameKey someProject = createProject("some-project");
-    Project.NameKey someOtherProject = createProject("some-other-project");
-    Project.NameKey projectAwesome = createProject("project-awesome");
-
-    assertBadRequest(gApi.projects().list().withSubstring("some").withRegex(".*"));
-    assertBadRequest(gApi.projects().list().withSubstring("some").withPrefix("some"));
-    assertThatNameList(filter(gApi.projects().list().withSubstring("some").get()))
-        .containsExactly(projectAwesome, someOtherProject, someProject)
-        .inOrder();
-    assertThatNameList(filter(gApi.projects().list().withSubstring("SOME").get()))
-        .containsExactly(projectAwesome, someOtherProject, someProject)
-        .inOrder();
-  }
-
-  @Test
-  public void listProjectsWithTree() throws Exception {
-    Project.NameKey someParentProject = createProject("some-parent-project");
-    Project.NameKey someChildProject = createProject("some-child-project", someParentProject);
-
-    Map<String, ProjectInfo> result = gApi.projects().list().withTree(true).getAsMap();
-    assertThat(result).containsKey(someChildProject.get());
-    assertThat(result.get(someChildProject.get()).parent).isEqualTo(someParentProject.get());
-  }
-
-  @Test
-  public void listProjectWithType() throws Exception {
-    Map<String, ProjectInfo> result =
-        gApi.projects().list().withType(FilterType.PERMISSIONS).getAsMap();
-    assertThat(result).hasSize(1);
-    assertThat(result).containsKey(allProjects.get());
-
-    assertThatNameList(filter(gApi.projects().list().withType(FilterType.ALL).get()))
-        .containsExactly(allProjects, allUsers, project)
-        .inOrder();
-  }
-
-  @Test
-  public void listWithHiddenProject() throws Exception {
-    Project.NameKey hidden = createProject("project-to-hide");
-
-    // The project is included because it was not hidden yet
-    assertThatNameList(gApi.projects().list().get())
-        .containsExactly(allProjects, allUsers, project, hidden)
-        .inOrder();
-
-    // Hide the project
-    ConfigInput input = new ConfigInput();
-    input.state = ProjectState.HIDDEN;
-    ConfigInfo info = gApi.projects().name(hidden.get()).config(input);
-    assertThat(info.state).isEqualTo(input.state);
-
-    // Project is still accessible directly
-    gApi.projects().name(hidden.get()).get();
-
-    // But is not included in the list
-    assertThatNameList(gApi.projects().list().get())
-        .containsExactly(allProjects, allUsers, project)
-        .inOrder();
-
-    // ALL filter applies to type, and doesn't include hidden state
-    assertThatNameList(gApi.projects().list().withType(FilterType.ALL).get())
-        .containsExactly(allProjects, allUsers, project)
-        .inOrder();
-  }
-
-  private void assertBadRequest(ListRequest req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException expected) {
-      // Expected.
-    }
-  }
-
-  private Iterable<ProjectInfo> filter(Iterable<ProjectInfo> infos) {
-    String prefix = name("");
-    return Iterables.filter(
-        infos,
-        p -> {
-          return p.name != null
-              && (p.name.equals(allProjects.get())
-                  || p.name.equals(allUsers.get())
-                  || p.name.startsWith(prefix));
-        });
-  }
-}
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/rest/project/TagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
deleted file mode 100644
index ed791a2..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ /dev/null
@@ -1,363 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.project;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static org.eclipse.jgit.lib.Constants.R_TAGS;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
-import com.google.gerrit.extensions.api.projects.TagApi;
-import com.google.gerrit.extensions.api.projects.TagInfo;
-import com.google.gerrit.extensions.api.projects.TagInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import java.util.List;
-import org.junit.Test;
-
-@NoHttpd
-public class TagsIT extends AbstractDaemonTest {
-  private static final List<String> testTags =
-      ImmutableList.of("tag-A", "tag-B", "tag-C", "tag-D", "tag-E", "tag-F", "tag-G", "tag-H");
-
-  private static final String SIGNED_ANNOTATION =
-      "annotation\n"
-          + "-----BEGIN PGP SIGNATURE-----\n"
-          + "Version: GnuPG v1\n"
-          + "\n"
-          + "iQEcBAABAgAGBQJVeGg5AAoJEPfTicJkUdPkUggH/RKAeI9/i/LduuiqrL/SSdIa\n"
-          + "9tYaSqJKLbXz63M/AW4Sp+4u+dVCQvnAt/a35CVEnpZz6hN4Kn/tiswOWVJf4CO7\n"
-          + "htNubGs5ZMwvD6sLYqKAnrM3WxV/2TbbjzjZW6Jkidz3jz/WRT4SmjGYiEO7aA+V\n"
-          + "4ZdIS9f7sW5VsHHYlNThCA7vH8Uu48bUovFXyQlPTX0pToSgrWV3JnTxDNxfn3iG\n"
-          + "IL0zTY/qwVCdXgFownLcs6J050xrrBWIKqfcWr3u4D2aCLyR0v+S/KArr7ulZygY\n"
-          + "+SOklImn8TAZiNxhWtA6ens66IiammUkZYFv7SSzoPLFZT4dC84SmGPWgf94NoQ=\n"
-          + "=XFeC\n"
-          + "-----END PGP SIGNATURE-----";
-
-  @Test
-  public void listTagsOfNonExistingProject() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name("does-not-exist").tags().get();
-  }
-
-  @Test
-  public void getTagOfNonExistingProject() throws Exception {
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name("does-not-exist").tag("tag").get();
-  }
-
-  @Test
-  public void listTagsOfNonVisibleProject() throws Exception {
-    blockRead("refs/*");
-    setApiUser(user);
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(project.get()).tags().get();
-  }
-
-  @Test
-  public void getTagOfNonVisibleProject() throws Exception {
-    blockRead("refs/*");
-    exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(project.get()).tag("tag").get();
-  }
-
-  @Test
-  public void listTags() throws Exception {
-    createTags();
-
-    // No options
-    List<TagInfo> result = getTags().get();
-    assertTagList(FluentIterable.from(testTags), result);
-
-    // With start option
-    result = getTags().withStart(1).get();
-    assertTagList(FluentIterable.from(testTags).skip(1), result);
-
-    // With limit option
-    int limit = testTags.size() - 1;
-    result = getTags().withLimit(limit).get();
-    assertTagList(FluentIterable.from(testTags).limit(limit), result);
-
-    // With both start and limit
-    limit = testTags.size() - 3;
-    result = getTags().withStart(1).withLimit(limit).get();
-    assertTagList(FluentIterable.from(testTags).skip(1).limit(limit), result);
-
-    // With regular expression filter
-    result = getTags().withRegex("^tag-[C|D]$").get();
-    assertTagList(FluentIterable.from(ImmutableList.of("tag-C", "tag-D")), result);
-
-    result = getTags().withRegex("^tag-[c|d]$").get();
-    assertTagList(FluentIterable.from(ImmutableList.of()), result);
-
-    // With substring filter
-    result = getTags().withSubstring("tag-").get();
-    assertTagList(FluentIterable.from(testTags), result);
-    result = getTags().withSubstring("ag-B").get();
-    assertTagList(FluentIterable.from(ImmutableList.of("tag-B")), result);
-
-    // With conflicting options
-    assertBadRequest(getTags().withSubstring("ag-B").withRegex("^tag-[c|d]$"));
-  }
-
-  @Test
-  public void listTagsOfNonVisibleBranch() throws Exception {
-    grantTagPermissions();
-
-    PushOneCommit push1 = pushFactory.create(db, admin.getIdent(), testRepo);
-    PushOneCommit.Result r1 = push1.to("refs/heads/master");
-    r1.assertOkStatus();
-    TagInput tag1 = new TagInput();
-    tag1.ref = "v1.0";
-    tag1.revision = r1.getCommit().getName();
-    TagInfo result = tag(tag1.ref).create(tag1).get();
-    assertThat(result.ref).isEqualTo(R_TAGS + tag1.ref);
-    assertThat(result.revision).isEqualTo(tag1.revision);
-
-    pushTo("refs/heads/hidden");
-    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo);
-    PushOneCommit.Result r2 = push2.to("refs/heads/hidden");
-    r2.assertOkStatus();
-
-    TagInput tag2 = new TagInput();
-    tag2.ref = "v2.0";
-    tag2.revision = r2.getCommit().getName();
-    result = tag(tag2.ref).create(tag2).get();
-    assertThat(result.ref).isEqualTo(R_TAGS + tag2.ref);
-    assertThat(result.revision).isEqualTo(tag2.revision);
-
-    List<TagInfo> tags = getTags().get();
-    assertThat(tags).hasSize(2);
-    assertThat(tags.get(0).ref).isEqualTo(R_TAGS + tag1.ref);
-    assertThat(tags.get(0).revision).isEqualTo(tag1.revision);
-    assertThat(tags.get(1).ref).isEqualTo(R_TAGS + tag2.ref);
-    assertThat(tags.get(1).revision).isEqualTo(tag2.revision);
-
-    blockRead("refs/heads/hidden");
-    tags = getTags().get();
-    assertThat(tags).hasSize(1);
-    assertThat(tags.get(0).ref).isEqualTo(R_TAGS + tag1.ref);
-    assertThat(tags.get(0).revision).isEqualTo(tag1.revision);
-  }
-
-  @Test
-  public void lightweightTag() throws Exception {
-    grantTagPermissions();
-
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    PushOneCommit.Result r = push.to("refs/heads/master");
-    r.assertOkStatus();
-
-    TagInput input = new TagInput();
-    input.ref = "v1.0";
-    input.revision = r.getCommit().getName();
-
-    TagInfo result = tag(input.ref).create(input).get();
-    assertThat(result.ref).isEqualTo(R_TAGS + input.ref);
-    assertThat(result.revision).isEqualTo(input.revision);
-    assertThat(result.canDelete).isTrue();
-
-    input.ref = "refs/tags/v2.0";
-    result = tag(input.ref).create(input).get();
-    assertThat(result.ref).isEqualTo(input.ref);
-    assertThat(result.revision).isEqualTo(input.revision);
-    assertThat(result.canDelete).isTrue();
-
-    setApiUser(user);
-    result = tag(input.ref).get();
-    assertThat(result.canDelete).isNull();
-
-    eventRecorder.assertRefUpdatedEvents(project.get(), result.ref, null, result.revision);
-  }
-
-  @Test
-  public void annotatedTag() throws Exception {
-    grantTagPermissions();
-
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    PushOneCommit.Result r = push.to("refs/heads/master");
-    r.assertOkStatus();
-
-    TagInput input = new TagInput();
-    input.ref = "v1.0";
-    input.revision = r.getCommit().getName();
-    input.message = "annotation message";
-
-    TagInfo result = tag(input.ref).create(input).get();
-    assertThat(result.ref).isEqualTo(R_TAGS + input.ref);
-    assertThat(result.object).isEqualTo(input.revision);
-    assertThat(result.message).isEqualTo(input.message);
-    assertThat(result.tagger.name).isEqualTo(admin.fullName);
-    assertThat(result.tagger.email).isEqualTo(admin.email);
-
-    eventRecorder.assertRefUpdatedEvents(project.get(), result.ref, null, result.revision);
-
-    // A second tag pushed on the same ref should have the same ref
-    TagInput input2 = new TagInput();
-    input2.ref = "refs/tags/v2.0";
-    input2.revision = input.revision;
-    input2.message = "second annotation message";
-    TagInfo result2 = tag(input2.ref).create(input2).get();
-    assertThat(result2.ref).isEqualTo(input2.ref);
-    assertThat(result2.object).isEqualTo(input2.revision);
-    assertThat(result2.message).isEqualTo(input2.message);
-    assertThat(result2.tagger.name).isEqualTo(admin.fullName);
-    assertThat(result2.tagger.email).isEqualTo(admin.email);
-
-    eventRecorder.assertRefUpdatedEvents(project.get(), result2.ref, null, result2.revision);
-  }
-
-  @Test
-  public void createExistingTag() throws Exception {
-    grantTagPermissions();
-
-    TagInput input = new TagInput();
-    input.ref = "test";
-    TagInfo result = tag(input.ref).create(input).get();
-    assertThat(result.ref).isEqualTo(R_TAGS + "test");
-
-    input.ref = "refs/tags/test";
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("tag \"" + R_TAGS + "test\" already exists");
-    tag(input.ref).create(input);
-  }
-
-  @Test
-  public void createTagNotAllowed() throws Exception {
-    block(R_TAGS + "*", Permission.CREATE, REGISTERED_USERS);
-    TagInput input = new TagInput();
-    input.ref = "test";
-    exception.expect(AuthException.class);
-    exception.expectMessage("create not permitted");
-    tag(input.ref).create(input);
-  }
-
-  @Test
-  public void createAnnotatedTagNotAllowed() throws Exception {
-    block(R_TAGS + "*", Permission.CREATE_TAG, REGISTERED_USERS);
-    TagInput input = new TagInput();
-    input.ref = "test";
-    input.message = "annotation";
-    exception.expect(AuthException.class);
-    exception.expectMessage("Cannot create annotated tag \"" + R_TAGS + "test\"");
-    tag(input.ref).create(input);
-  }
-
-  @Test
-  public void createSignedTagNotSupported() throws Exception {
-    TagInput input = new TagInput();
-    input.ref = "test";
-    input.message = SIGNED_ANNOTATION;
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("Cannot create signed tag \"" + R_TAGS + "test\"");
-    tag(input.ref).create(input);
-  }
-
-  @Test
-  public void mismatchedInput() throws Exception {
-    TagInput input = new TagInput();
-    input.ref = "test";
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("ref must match URL");
-    tag("TEST").create(input);
-  }
-
-  @Test
-  public void invalidTagName() throws Exception {
-    grantTagPermissions();
-
-    TagInput input = new TagInput();
-    input.ref = "refs/heads/test";
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid tag name \"" + input.ref + "\"");
-    tag(input.ref).create(input);
-  }
-
-  @Test
-  public void invalidTagNameOnlySlashes() throws Exception {
-    grantTagPermissions();
-
-    TagInput input = new TagInput();
-    input.ref = "//";
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid tag name \"refs/tags/\"");
-    tag(input.ref).create(input);
-  }
-
-  @Test
-  public void invalidBaseRevision() throws Exception {
-    grantTagPermissions();
-
-    TagInput input = new TagInput();
-    input.ref = "test";
-    input.revision = "abcdefg";
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Invalid base revision");
-    tag(input.ref).create(input);
-  }
-
-  private void assertTagList(FluentIterable<String> expected, List<TagInfo> actual)
-      throws Exception {
-    assertThat(actual).hasSize(expected.size());
-    for (int i = 0; i < expected.size(); i++) {
-      assertThat(actual.get(i).ref).isEqualTo(R_TAGS + expected.get(i));
-    }
-  }
-
-  private void createTags() throws Exception {
-    grantTagPermissions();
-
-    String revision = pushTo("refs/heads/master").getCommit().name();
-    TagInput input = new TagInput();
-    input.revision = revision;
-
-    for (String tagname : testTags) {
-      TagInfo result = tag(tagname).create(input).get();
-      assertThat(result.revision).isEqualTo(input.revision);
-      assertThat(result.ref).isEqualTo(R_TAGS + tagname);
-    }
-  }
-
-  private ListRefsRequest<TagInfo> getTags() throws Exception {
-    return gApi.projects().name(project.get()).tags();
-  }
-
-  private TagApi tag(String tagname) throws Exception {
-    return gApi.projects().name(project.get()).tag(tagname);
-  }
-
-  private void assertBadRequest(ListRefsRequest<TagInfo> req) throws Exception {
-    try {
-      req.get();
-      fail("Expected BadRequestException");
-    } catch (BadRequestException e) {
-      // Expected
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/BUILD
deleted file mode 100644
index f47ac46..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/BUILD
+++ /dev/null
@@ -1,7 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "rest_revision",
-    labels = ["rest"],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUILD
deleted file mode 100644
index ac32b02..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUILD
+++ /dev/null
@@ -1,7 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "server_change",
-    labels = ["server"],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
deleted file mode 100644
index b95b5f6..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ /dev/null
@@ -1,1185 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
-import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
-import static java.util.stream.Collectors.groupingBy;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Function;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
-import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
-import com.google.gerrit.extensions.client.Comment;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.ChangesCollection;
-import com.google.gerrit.server.change.PostReview;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.notedb.ChangeNoteUtil;
-import com.google.gerrit.server.notedb.DeleteCommentRewriter;
-import com.google.gerrit.testutil.FakeEmailSender;
-import com.google.gerrit.testutil.FakeEmailSender.Message;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Optional;
-import java.util.function.Supplier;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class CommentsIT extends AbstractDaemonTest {
-
-  @Inject private Provider<ChangesCollection> changes;
-
-  @Inject private Provider<PostReview> postReview;
-
-  @Inject private FakeEmailSender email;
-
-  @Inject private ChangeNoteUtil noteUtil;
-
-  private final Integer[] lines = {0, 1};
-
-  @Before
-  public void setUp() {
-    setApiUser(user);
-  }
-
-  @Test
-  public void getNonExistingComment() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    String revId = r.getCommit().getName();
-    exception.expect(ResourceNotFoundException.class);
-    getPublishedComment(changeId, revId, "non-existing");
-  }
-
-  @Test
-  public void createDraft() throws Exception {
-    for (Integer line : lines) {
-      PushOneCommit.Result r = createChange();
-      String changeId = r.getChangeId();
-      String revId = r.getCommit().getName();
-      String path = "file1";
-      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
-      addDraft(changeId, revId, comment);
-      Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
-      assertThat(result).hasSize(1);
-      CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
-    }
-  }
-
-  @Test
-  public void createDraftOnMergeCommitChange() throws Exception {
-    for (Integer line : lines) {
-      PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
-      String changeId = r.getChangeId();
-      String revId = r.getCommit().getName();
-      String path = "file1";
-      DraftInput c1 = newDraft(path, Side.REVISION, line, "ps-1");
-      DraftInput c2 = newDraft(path, Side.PARENT, line, "auto-merge of ps-1");
-      DraftInput c3 = newDraftOnParent(path, 1, line, "parent-1 of ps-1");
-      DraftInput c4 = newDraftOnParent(path, 2, line, "parent-2 of ps-1");
-      addDraft(changeId, revId, c1);
-      addDraft(changeId, revId, c2);
-      addDraft(changeId, revId, c3);
-      addDraft(changeId, revId, c4);
-      Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
-      assertThat(result).hasSize(1);
-      assertThat(Lists.transform(result.get(path), infoToDraft(path)))
-          .containsExactly(c1, c2, c3, c4);
-    }
-  }
-
-  @Test
-  public void postComment() throws Exception {
-    for (Integer line : lines) {
-      String file = "file";
-      String contents = "contents " + line;
-      PushOneCommit push =
-          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
-      PushOneCommit.Result r = push.to("refs/for/master");
-      String changeId = r.getChangeId();
-      String revId = r.getCommit().getName();
-      ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
-      input.comments = new HashMap<>();
-      input.comments.put(comment.path, Lists.newArrayList(comment));
-      revision(r).review(input);
-      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
-      assertThat(result).isNotEmpty();
-      CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertThat(comment).isEqualTo(infoToInput(file).apply(actual));
-      assertThat(comment)
-          .isEqualTo(infoToInput(file).apply(getPublishedComment(changeId, revId, actual.id)));
-    }
-  }
-
-  @Test
-  public void postCommentWithReply() throws Exception {
-    for (Integer line : lines) {
-      String file = "file";
-      String contents = "contents " + line;
-      PushOneCommit push =
-          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
-      PushOneCommit.Result r = push.to("refs/for/master");
-      String changeId = r.getChangeId();
-      String revId = r.getCommit().getName();
-      ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
-      input.comments = new HashMap<>();
-      input.comments.put(comment.path, Lists.newArrayList(comment));
-      revision(r).review(input);
-      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
-      CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
-
-      input = new ReviewInput();
-      comment = newComment(file, Side.REVISION, line, "comment 1 reply", false);
-      comment.inReplyTo = actual.id;
-      input.comments = new HashMap<>();
-      input.comments.put(comment.path, Lists.newArrayList(comment));
-      revision(r).review(input);
-      result = getPublishedComments(changeId, revId);
-      actual = result.get(comment.path).get(1);
-      assertThat(comment).isEqualTo(infoToInput(file).apply(actual));
-      assertThat(comment)
-          .isEqualTo(infoToInput(file).apply(getPublishedComment(changeId, revId, actual.id)));
-    }
-  }
-
-  @Test
-  public void postCommentWithUnresolved() throws Exception {
-    for (Integer line : lines) {
-      String file = "file";
-      String contents = "contents " + line;
-      PushOneCommit push =
-          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
-      PushOneCommit.Result r = push.to("refs/for/master");
-      String changeId = r.getChangeId();
-      String revId = r.getCommit().getName();
-      ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", true);
-      input.comments = new HashMap<>();
-      input.comments.put(comment.path, Lists.newArrayList(comment));
-      revision(r).review(input);
-      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
-      assertThat(result).isNotEmpty();
-      CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertThat(comment).isEqualTo(infoToInput(file).apply(actual));
-      assertThat(comment)
-          .isEqualTo(infoToInput(file).apply(getPublishedComment(changeId, revId, actual.id)));
-    }
-  }
-
-  @Test
-  public void postCommentOnMergeCommitChange() throws Exception {
-    for (Integer line : lines) {
-      String file = "foo";
-      PushOneCommit.Result r = createMergeCommitChange("refs/for/master", file);
-      String changeId = r.getChangeId();
-      String revId = r.getCommit().getName();
-      ReviewInput input = new ReviewInput();
-      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1", false);
-      CommentInput c2 = newComment(file, Side.PARENT, line, "auto-merge of ps-1", false);
-      CommentInput c3 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
-      CommentInput c4 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
-      input.comments = new HashMap<>();
-      input.comments.put(file, ImmutableList.of(c1, c2, c3, c4));
-      revision(r).review(input);
-      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
-      assertThat(result).isNotEmpty();
-      assertThat(Lists.transform(result.get(file), infoToInput(file)))
-          .containsExactly(c1, c2, c3, c4);
-    }
-
-    // for the commit message comments on the auto-merge are not possible
-    for (Integer line : lines) {
-      String file = Patch.COMMIT_MSG;
-      PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
-      String changeId = r.getChangeId();
-      String revId = r.getCommit().getName();
-      ReviewInput input = new ReviewInput();
-      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1", false);
-      CommentInput c2 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
-      CommentInput c3 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
-      input.comments = new HashMap<>();
-      input.comments.put(file, ImmutableList.of(c1, c2, c3));
-      revision(r).review(input);
-      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
-      assertThat(result).isNotEmpty();
-      assertThat(Lists.transform(result.get(file), infoToInput(file))).containsExactly(c1, c2, c3);
-    }
-  }
-
-  @Test
-  public void postCommentOnCommitMessageOnAutoMerge() throws Exception {
-    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
-    ReviewInput input = new ReviewInput();
-    CommentInput c = newComment(Patch.COMMIT_MSG, Side.PARENT, 0, "comment on auto-merge", false);
-    input.comments = new HashMap<>();
-    input.comments.put(Patch.COMMIT_MSG, ImmutableList.of(c));
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("cannot comment on " + Patch.COMMIT_MSG + " on auto-merge");
-    revision(r).review(input);
-  }
-
-  @Test
-  public void listComments() throws Exception {
-    String file = "file";
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, "contents");
-    PushOneCommit.Result r = push.to("refs/for/master");
-    String changeId = r.getChangeId();
-    String revId = r.getCommit().getName();
-    assertThat(getPublishedComments(changeId, revId)).isEmpty();
-
-    List<CommentInput> expectedComments = new ArrayList<>();
-    for (Integer line : lines) {
-      ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment " + line, false);
-      expectedComments.add(comment);
-      input.comments = new HashMap<>();
-      input.comments.put(comment.path, Lists.newArrayList(comment));
-      revision(r).review(input);
-    }
-
-    Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
-    assertThat(result).isNotEmpty();
-    List<CommentInfo> actualComments = result.get(file);
-    assertThat(Lists.transform(actualComments, infoToInput(file)))
-        .containsExactlyElementsIn(expectedComments);
-  }
-
-  @Test
-  public void putDraft() throws Exception {
-    for (Integer line : lines) {
-      PushOneCommit.Result r = createChange();
-      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
-      String changeId = r.getChangeId();
-      String revId = r.getCommit().getName();
-      String path = "file1";
-      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
-      addDraft(changeId, revId, comment);
-      Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
-      CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
-      String uuid = actual.id;
-      comment.message = "updated comment 1";
-      updateDraft(changeId, revId, comment, uuid);
-      result = getDraftComments(changeId, revId);
-      actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
-
-      // Posting a draft comment doesn't cause lastUpdatedOn to change.
-      assertThat(r.getChange().change().getLastUpdatedOn()).isEqualTo(origLastUpdated);
-    }
-  }
-
-  @Test
-  public void listDrafts() throws Exception {
-    String file = "file";
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    String revId = r.getCommit().getName();
-    assertThat(getDraftComments(changeId, revId)).isEmpty();
-
-    List<DraftInput> expectedDrafts = new ArrayList<>();
-    for (Integer line : lines) {
-      DraftInput comment = newDraft(file, Side.REVISION, line, "comment " + line);
-      expectedDrafts.add(comment);
-      addDraft(changeId, revId, comment);
-    }
-
-    Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
-    assertThat(result).isNotEmpty();
-    List<CommentInfo> actualComments = result.get(file);
-    assertThat(Lists.transform(actualComments, infoToDraft(file)))
-        .containsExactlyElementsIn(expectedDrafts);
-  }
-
-  @Test
-  public void getDraft() throws Exception {
-    for (Integer line : lines) {
-      PushOneCommit.Result r = createChange();
-      String changeId = r.getChangeId();
-      String revId = r.getCommit().getName();
-      String path = "file1";
-      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
-      CommentInfo returned = addDraft(changeId, revId, comment);
-      CommentInfo actual = getDraftComment(changeId, revId, returned.id);
-      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
-    }
-  }
-
-  @Test
-  public void deleteDraft() throws Exception {
-    for (Integer line : lines) {
-      PushOneCommit.Result r = createChange();
-      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
-      String changeId = r.getChangeId();
-      String revId = r.getCommit().getName();
-      DraftInput draft = newDraft("file1", Side.REVISION, line, "comment 1");
-      CommentInfo returned = addDraft(changeId, revId, draft);
-      deleteDraft(changeId, revId, returned.id);
-      Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
-      assertThat(drafts).isEmpty();
-
-      // Deleting a draft comment doesn't cause lastUpdatedOn to change.
-      assertThat(r.getChange().change().getLastUpdatedOn()).isEqualTo(origLastUpdated);
-    }
-  }
-
-  @Test
-  public void insertCommentsWithHistoricTimestamp() throws Exception {
-    Timestamp timestamp = new Timestamp(0);
-    for (Integer line : lines) {
-      String file = "file";
-      String contents = "contents " + line;
-      PushOneCommit push =
-          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
-      PushOneCommit.Result r = push.to("refs/for/master");
-      String changeId = r.getChangeId();
-      String revId = r.getCommit().getName();
-      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
-
-      ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
-      comment.updated = timestamp;
-      input.comments = new HashMap<>();
-      input.comments.put(comment.path, Lists.newArrayList(comment));
-      ChangeResource changeRsrc =
-          changes.get().parse(TopLevelResource.INSTANCE, IdString.fromDecoded(changeId));
-      RevisionResource revRsrc = revisions.parse(changeRsrc, IdString.fromDecoded(revId));
-      postReview.get().apply(batchUpdateFactory, revRsrc, input, timestamp);
-      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
-      assertThat(result).isNotEmpty();
-      CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
-      CommentInput ci = infoToInput(file).apply(actual);
-      ci.updated = comment.updated;
-      assertThat(comment).isEqualTo(ci);
-      assertThat(actual.updated).isEqualTo(gApi.changes().id(r.getChangeId()).info().created);
-
-      // Updating historic comments doesn't cause lastUpdatedOn to regress.
-      assertThat(r.getChange().change().getLastUpdatedOn()).isEqualTo(origLastUpdated);
-    }
-  }
-
-  @Test
-  public void addDuplicateComments() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    String changeId = r1.getChangeId();
-    String revId = r1.getCommit().getName();
-    addComment(r1, "nit: trailing whitespace");
-    addComment(r1, "nit: trailing whitespace");
-    Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
-    assertThat(result.get(FILE_NAME)).hasSize(2);
-    addComment(r1, "nit: trailing whitespace", true, false, null);
-    result = getPublishedComments(changeId, revId);
-    assertThat(result.get(FILE_NAME)).hasSize(2);
-
-    PushOneCommit.Result r2 =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "content")
-            .to("refs/for/master");
-    changeId = r2.getChangeId();
-    revId = r2.getCommit().getName();
-    addComment(r2, "nit: trailing whitespace", true, false, null);
-    result = getPublishedComments(changeId, revId);
-    assertThat(result.get(FILE_NAME)).hasSize(1);
-  }
-
-  @Test
-  public void listChangeDrafts() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-
-    PushOneCommit.Result r2 =
-        pushFactory
-            .create(
-                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
-            .to("refs/for/master");
-
-    setApiUser(admin);
-    addDraft(
-        r1.getChangeId(),
-        r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
-    addDraft(
-        r2.getChangeId(),
-        r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "typo: content"));
-
-    setApiUser(user);
-    addDraft(
-        r2.getChangeId(),
-        r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "+1, please fix"));
-
-    setApiUser(admin);
-    Map<String, List<CommentInfo>> actual = gApi.changes().id(r1.getChangeId()).drafts();
-    assertThat(actual.keySet()).containsExactly(FILE_NAME);
-    List<CommentInfo> comments = actual.get(FILE_NAME);
-    assertThat(comments).hasSize(2);
-
-    CommentInfo c1 = comments.get(0);
-    assertThat(c1.author).isNull();
-    assertThat(c1.patchSet).isEqualTo(1);
-    assertThat(c1.message).isEqualTo("nit: trailing whitespace");
-    assertThat(c1.side).isNull();
-    assertThat(c1.line).isEqualTo(1);
-
-    CommentInfo c2 = comments.get(1);
-    assertThat(c2.author).isNull();
-    assertThat(c2.patchSet).isEqualTo(2);
-    assertThat(c2.message).isEqualTo("typo: content");
-    assertThat(c2.side).isNull();
-    assertThat(c2.line).isEqualTo(1);
-  }
-
-  @Test
-  public void listChangeComments() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-
-    PushOneCommit.Result r2 =
-        pushFactory
-            .create(
-                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new cntent", r1.getChangeId())
-            .to("refs/for/master");
-
-    addComment(r1, "nit: trailing whitespace");
-    addComment(r2, "typo: content");
-
-    Map<String, List<CommentInfo>> actual = gApi.changes().id(r2.getChangeId()).comments();
-    assertThat(actual.keySet()).containsExactly(FILE_NAME);
-
-    List<CommentInfo> comments = actual.get(FILE_NAME);
-    assertThat(comments).hasSize(2);
-
-    CommentInfo c1 = comments.get(0);
-    assertThat(c1.author._accountId).isEqualTo(user.getId().get());
-    assertThat(c1.patchSet).isEqualTo(1);
-    assertThat(c1.message).isEqualTo("nit: trailing whitespace");
-    assertThat(c1.side).isNull();
-    assertThat(c1.line).isEqualTo(1);
-
-    CommentInfo c2 = comments.get(1);
-    assertThat(c2.author._accountId).isEqualTo(user.getId().get());
-    assertThat(c2.patchSet).isEqualTo(2);
-    assertThat(c2.message).isEqualTo("typo: content");
-    assertThat(c2.side).isNull();
-    assertThat(c2.line).isEqualTo(1);
-  }
-
-  @Test
-  public void listChangeWithDrafts() throws Exception {
-    for (Integer line : lines) {
-      PushOneCommit.Result r = createChange();
-      String changeId = r.getChangeId();
-      String revId = r.getCommit().getName();
-      DraftInput comment = newDraft("file1", Side.REVISION, line, "comment 1");
-      addDraft(changeId, revId, comment);
-      assertThat(gApi.changes().query("change:" + changeId + " has:draft").get()).hasSize(1);
-    }
-  }
-
-  @Test
-  public void publishCommentsAllRevisions() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-
-    PushOneCommit.Result r2 =
-        pushFactory
-            .create(
-                db,
-                admin.getIdent(),
-                testRepo,
-                SUBJECT,
-                FILE_NAME,
-                "new\ncntent\n",
-                r1.getChangeId())
-            .to("refs/for/master");
-
-    addDraft(
-        r1.getChangeId(),
-        r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
-    addDraft(
-        r1.getChangeId(),
-        r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, 2, "what happened to this?"));
-    addDraft(
-        r2.getChangeId(),
-        r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "join lines"));
-    addDraft(
-        r2.getChangeId(),
-        r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 2, "typo: content"));
-    addDraft(
-        r2.getChangeId(),
-        r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, 1, "comment 1 on base"));
-    addDraft(
-        r2.getChangeId(),
-        r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, 2, "comment 2 on base"));
-
-    PushOneCommit.Result other = createChange();
-    // Drafts on other changes aren't returned.
-    addDraft(
-        other.getChangeId(),
-        other.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "unrelated comment"));
-
-    setApiUser(admin);
-    // Drafts by other users aren't returned.
-    addDraft(
-        r2.getChangeId(), r2.getCommit().getName(), newDraft(FILE_NAME, Side.REVISION, 2, "oops"));
-    setApiUser(user);
-
-    ReviewInput reviewInput = new ReviewInput();
-    reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
-    reviewInput.message = "comments";
-    gApi.changes().id(r2.getChangeId()).current().review(reviewInput);
-
-    assertThat(gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).drafts())
-        .isEmpty();
-    Map<String, List<CommentInfo>> ps1Map =
-        gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).comments();
-    assertThat(ps1Map.keySet()).containsExactly(FILE_NAME);
-    List<CommentInfo> ps1List = ps1Map.get(FILE_NAME);
-    assertThat(ps1List).hasSize(2);
-    assertThat(ps1List.get(0).message).isEqualTo("what happened to this?");
-    assertThat(ps1List.get(0).side).isEqualTo(Side.PARENT);
-    assertThat(ps1List.get(1).message).isEqualTo("nit: trailing whitespace");
-    assertThat(ps1List.get(1).side).isNull();
-
-    assertThat(gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).drafts())
-        .isEmpty();
-    Map<String, List<CommentInfo>> ps2Map =
-        gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).comments();
-    assertThat(ps2Map.keySet()).containsExactly(FILE_NAME);
-    List<CommentInfo> ps2List = ps2Map.get(FILE_NAME);
-    assertThat(ps2List).hasSize(4);
-    assertThat(ps2List.get(0).message).isEqualTo("comment 1 on base");
-    assertThat(ps2List.get(1).message).isEqualTo("comment 2 on base");
-    assertThat(ps2List.get(2).message).isEqualTo("join lines");
-    assertThat(ps2List.get(3).message).isEqualTo("typo: content");
-
-    List<Message> messages = email.getMessages(r2.getChangeId(), "comment");
-    assertThat(messages).hasSize(1);
-    String url = canonicalWebUrl.get();
-    int c = r1.getChange().getId().get();
-    assertThat(extractComments(messages.get(0).body()))
-        .isEqualTo(
-            "Patch Set 2:\n"
-                + "\n"
-                + "(6 comments)\n"
-                + "\n"
-                + "comments\n"
-                + "\n"
-                + url
-                + "#/c/"
-                + c
-                + "/1/a.txt\n"
-                + "File a.txt:\n"
-                + "\n"
-                + url
-                + "#/c/"
-                + c
-                + "/1/a.txt@a2\n"
-                + "PS1, Line 2: \n"
-                + "what happened to this?\n"
-                + "\n"
-                + "\n"
-                + url
-                + "#/c/"
-                + c
-                + "/1/a.txt@1\n"
-                + "PS1, Line 1: ew\n"
-                + "nit: trailing whitespace\n"
-                + "\n"
-                + "\n"
-                + url
-                + "#/c/"
-                + c
-                + "/2/a.txt\n"
-                + "File a.txt:\n"
-                + "\n"
-                + url
-                + "#/c/"
-                + c
-                + "/2/a.txt@a1\n"
-                + "PS2, Line 1: \n"
-                + "comment 1 on base\n"
-                + "\n"
-                + "\n"
-                + url
-                + "#/c/"
-                + c
-                + "/2/a.txt@a2\n"
-                + "PS2, Line 2: \n"
-                + "comment 2 on base\n"
-                + "\n"
-                + "\n"
-                + url
-                + "#/c/"
-                + c
-                + "/2/a.txt@1\n"
-                + "PS2, Line 1: ew\n"
-                + "join lines\n"
-                + "\n"
-                + "\n"
-                + url
-                + "#/c/"
-                + c
-                + "/2/a.txt@2\n"
-                + "PS2, Line 2: nten\n"
-                + "typo: content\n"
-                + "\n"
-                + "\n");
-  }
-
-  @Test
-  public void commentTags() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    CommentInput pub = new CommentInput();
-    pub.line = 1;
-    pub.message = "published comment";
-    pub.path = FILE_NAME;
-    ReviewInput rin = newInput(pub);
-    rin.tag = "tag1";
-    gApi.changes().id(r.getChangeId()).current().review(rin);
-
-    List<CommentInfo> comments = gApi.changes().id(r.getChangeId()).current().commentsAsList();
-    assertThat(comments).hasSize(1);
-    assertThat(comments.get(0).tag).isEqualTo("tag1");
-
-    DraftInput draft = new DraftInput();
-    draft.line = 2;
-    draft.message = "draft comment";
-    draft.path = FILE_NAME;
-    draft.tag = "tag2";
-    addDraft(r.getChangeId(), r.getCommit().name(), draft);
-
-    List<CommentInfo> drafts = gApi.changes().id(r.getChangeId()).current().draftsAsList();
-    assertThat(drafts).hasSize(1);
-    assertThat(drafts.get(0).tag).isEqualTo("tag2");
-  }
-
-  @Test
-  public void queryChangesWithUnresolvedCommentCount() throws Exception {
-    // PS1 has three comments in three different threads, PS2 has one comment in one thread.
-    PushOneCommit.Result result = createChange("change 1", FILE_NAME, "content 1");
-    String changeId1 = result.getChangeId();
-    addComment(result, "comment 1", false, true, null);
-    addComment(result, "comment 2", false, null, null);
-    addComment(result, "comment 3", false, false, null);
-    PushOneCommit.Result result2 = amendChange(changeId1);
-    addComment(result2, "comment4", false, true, null);
-
-    // Change2 has two comments in one thread, the first is unresolved and the second is resolved.
-    result = createChange("change 2", FILE_NAME, "content 2");
-    String changeId2 = result.getChangeId();
-    addComment(result, "comment 1", false, true, null);
-    Map<String, List<CommentInfo>> comments =
-        getPublishedComments(changeId2, result.getCommit().name());
-    assertThat(comments).hasSize(1);
-    assertThat(comments.get(FILE_NAME)).hasSize(1);
-    addComment(result, "comment 2", false, false, comments.get(FILE_NAME).get(0).id);
-
-    // Change3 has two comments in one thread, the first is resolved, the second is unresolved.
-    result = createChange("change 3", FILE_NAME, "content 3");
-    String changeId3 = result.getChangeId();
-    addComment(result, "comment 1", false, false, null);
-    comments = getPublishedComments(result.getChangeId(), result.getCommit().name());
-    assertThat(comments).hasSize(1);
-    assertThat(comments.get(FILE_NAME)).hasSize(1);
-    addComment(result, "comment 2", false, true, comments.get(FILE_NAME).get(0).id);
-
-    AcceptanceTestRequestScope.Context ctx = disableDb();
-    try {
-      ChangeInfo changeInfo1 = Iterables.getOnlyElement(query(changeId1));
-      ChangeInfo changeInfo2 = Iterables.getOnlyElement(query(changeId2));
-      ChangeInfo changeInfo3 = Iterables.getOnlyElement(query(changeId3));
-      assertThat(changeInfo1.unresolvedCommentCount).isEqualTo(2);
-      assertThat(changeInfo2.unresolvedCommentCount).isEqualTo(0);
-      assertThat(changeInfo3.unresolvedCommentCount).isEqualTo(1);
-    } finally {
-      enableDb(ctx);
-    }
-  }
-
-  @Test
-  public void deleteCommentCannotBeAppliedByUser() throws Exception {
-    PushOneCommit.Result result = createChange();
-    CommentInput targetComment = addComment(result.getChangeId(), "My password: abc123");
-
-    Map<String, List<CommentInfo>> commentsMap =
-        getPublishedComments(result.getChangeId(), result.getCommit().name());
-
-    assertThat(commentsMap.size()).isEqualTo(1);
-    assertThat(commentsMap.get(FILE_NAME)).hasSize(1);
-
-    String uuid = commentsMap.get(targetComment.path).get(0).id;
-    DeleteCommentInput input = new DeleteCommentInput("contains confidential information");
-
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    gApi.changes().id(result.getChangeId()).current().comment(uuid).delete(input);
-  }
-
-  @Test
-  public void deleteCommentByRewritingCommitHistory() throws Exception {
-    // Creates the following commit history on the meta branch of the test change. Then tries to
-    // delete the comments one by one, which will rewrite most of the commits on the 'meta' branch.
-    // Commits will be rewritten N times for N added comments. After each deletion, the meta branch
-    // should keep its previous state except that the target comment's message should be updated.
-
-    // 1st commit: Create PS1.
-    PushOneCommit.Result result1 = createChange(SUBJECT, "a.txt", "a");
-    Change.Id id = result1.getChange().getId();
-    String changeId = result1.getChangeId();
-    String ps1 = result1.getCommit().name();
-
-    // 2nd commit: Add (c1) to PS1.
-    CommentInput c1 = newComment("a.txt", "comment 1");
-    addComments(changeId, ps1, c1);
-
-    // 3rd commit: Add (c2, c3) to PS1.
-    CommentInput c2 = newComment("a.txt", "comment 2");
-    CommentInput c3 = newComment("a.txt", "comment 3");
-    addComments(changeId, ps1, c2, c3);
-
-    // 4th commit: Add (c4) to PS1.
-    CommentInput c4 = newComment("a.txt", "comment 4");
-    addComments(changeId, ps1, c4);
-
-    // 5th commit: Create PS2.
-    PushOneCommit.Result result2 = amendChange(changeId, "refs/for/master", "b.txt", "b");
-    String ps2 = result2.getCommit().name();
-
-    // 6th commit: Add (c5) to PS1.
-    CommentInput c5 = newComment("a.txt", "comment 5");
-    addComments(changeId, ps1, c5);
-
-    // 7th commit: Add (c6) to PS2.
-    CommentInput c6 = newComment("b.txt", "comment 6");
-    addComments(changeId, ps2, c6);
-
-    // 8th commit: Create PS3.
-    PushOneCommit.Result result3 = amendChange(changeId);
-    String ps3 = result3.getCommit().name();
-
-    // 9th commit: Create PS4.
-    PushOneCommit.Result result4 = amendChange(changeId, "refs/for/master", "c.txt", "c");
-    String ps4 = result4.getCommit().name();
-
-    // 10th commit: Add (c7, c8) to PS4.
-    CommentInput c7 = newComment("c.txt", "comment 7");
-    CommentInput c8 = newComment("b.txt", "comment 8");
-    addComments(changeId, ps4, c7, c8);
-
-    // 11th commit: Add (c9) to PS2.
-    CommentInput c9 = newComment("b.txt", "comment 9");
-    addComments(changeId, ps2, c9);
-
-    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(changeId);
-    assertThat(commentsBeforeDelete).hasSize(9);
-    // PS1 has comments [c1, c2, c3, c4, c5].
-    assertThat(getRevisionComments(changeId, ps1)).hasSize(5);
-    // PS2 has comments [c6, c9].
-    assertThat(getRevisionComments(changeId, ps2)).hasSize(2);
-    // PS3 has no comment.
-    assertThat(getRevisionComments(changeId, ps3)).hasSize(0);
-    // PS4 has comments [c7, c8].
-    assertThat(getRevisionComments(changeId, ps4)).hasSize(2);
-
-    setApiUser(admin);
-    for (int i = 0; i < commentsBeforeDelete.size(); i++) {
-      List<RevCommit> commitsBeforeDelete = new ArrayList<>();
-      if (notesMigration.commitChangeWrites()) {
-        commitsBeforeDelete = getCommits(id);
-      }
-
-      CommentInfo comment = commentsBeforeDelete.get(i);
-      String uuid = comment.id;
-      int patchSet = comment.patchSet;
-      // 'oldComment' has some fields unset compared with 'comment'.
-      CommentInfo oldComment = gApi.changes().id(changeId).revision(patchSet).comment(uuid).get();
-
-      DeleteCommentInput input = new DeleteCommentInput("delete comment " + uuid);
-      CommentInfo updatedComment =
-          gApi.changes().id(changeId).revision(patchSet).comment(uuid).delete(input);
-
-      String expectedMsg =
-          String.format("Comment removed by: %s; Reason: %s", admin.fullName, input.reason);
-      assertThat(updatedComment.message).isEqualTo(expectedMsg);
-      oldComment.message = expectedMsg;
-      assertThat(updatedComment).isEqualTo(oldComment);
-
-      // Check the NoteDb state after the deletion.
-      if (notesMigration.commitChangeWrites()) {
-        assertMetaBranchCommitsAfterRewriting(commitsBeforeDelete, id, uuid, expectedMsg);
-      }
-
-      comment.message = expectedMsg;
-      commentsBeforeDelete.set(i, comment);
-      List<CommentInfo> commentsAfterDelete = getChangeSortedComments(changeId);
-      assertThat(commentsAfterDelete).isEqualTo(commentsBeforeDelete);
-    }
-
-    // Make sure that comments can still be added correctly.
-    CommentInput c10 = newComment("a.txt", "comment 10");
-    CommentInput c11 = newComment("b.txt", "comment 11");
-    CommentInput c12 = newComment("a.txt", "comment 12");
-    CommentInput c13 = newComment("c.txt", "comment 13");
-    addComments(changeId, ps1, c10);
-    addComments(changeId, ps2, c11);
-    addComments(changeId, ps3, c12);
-    addComments(changeId, ps4, c13);
-
-    assertThat(getChangeSortedComments(changeId)).hasSize(13);
-    assertThat(getRevisionComments(changeId, ps1)).hasSize(6);
-    assertThat(getRevisionComments(changeId, ps2)).hasSize(3);
-    assertThat(getRevisionComments(changeId, ps3)).hasSize(1);
-    assertThat(getRevisionComments(changeId, ps4)).hasSize(3);
-  }
-
-  @Test
-  public void deleteOneCommentMultipleTimes() throws Exception {
-    PushOneCommit.Result result = createChange();
-    Change.Id id = result.getChange().getId();
-    String changeId = result.getChangeId();
-    String ps1 = result.getCommit().name();
-
-    CommentInput c1 = newComment(FILE_NAME, "comment 1");
-    CommentInput c2 = newComment(FILE_NAME, "comment 2");
-    CommentInput c3 = newComment(FILE_NAME, "comment 3");
-    addComments(changeId, ps1, c1);
-    addComments(changeId, ps1, c2);
-    addComments(changeId, ps1, c3);
-
-    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(changeId);
-    assertThat(commentsBeforeDelete).hasSize(3);
-    Optional<CommentInfo> targetComment =
-        commentsBeforeDelete.stream().filter(c -> c.message.equals("comment 2")).findFirst();
-    assertThat(targetComment).isPresent();
-    String uuid = targetComment.get().id;
-    CommentInfo oldComment = gApi.changes().id(changeId).revision(ps1).comment(uuid).get();
-
-    List<RevCommit> commitsBeforeDelete = new ArrayList<>();
-    if (notesMigration.commitChangeWrites()) {
-      commitsBeforeDelete = getCommits(id);
-    }
-
-    setApiUser(admin);
-    for (int i = 0; i < 3; i++) {
-      DeleteCommentInput input = new DeleteCommentInput("delete comment 2, iteration: " + i);
-      gApi.changes().id(changeId).revision(ps1).comment(uuid).delete(input);
-    }
-
-    CommentInfo updatedComment = gApi.changes().id(changeId).revision(ps1).comment(uuid).get();
-    String expectedMsg =
-        String.format(
-            "Comment removed by: %s; Reason: %s", admin.fullName, "delete comment 2, iteration: 2");
-    assertThat(updatedComment.message).isEqualTo(expectedMsg);
-    oldComment.message = expectedMsg;
-    assertThat(updatedComment).isEqualTo(oldComment);
-
-    if (notesMigration.commitChangeWrites()) {
-      assertMetaBranchCommitsAfterRewriting(commitsBeforeDelete, id, uuid, expectedMsg);
-    }
-    assertThat(getChangeSortedComments(changeId)).hasSize(3);
-  }
-
-  private List<CommentInfo> getChangeSortedComments(String changeId) throws Exception {
-    List<CommentInfo> comments = new ArrayList<>();
-    Map<String, List<CommentInfo>> commentsMap = getPublishedComments(changeId);
-    for (Entry<String, List<CommentInfo>> e : commentsMap.entrySet()) {
-      for (CommentInfo c : e.getValue()) {
-        c.path = e.getKey(); // Set the comment's path field.
-        comments.add(c);
-      }
-    }
-    comments.sort(Comparator.comparing(c -> c.id));
-    return comments;
-  }
-
-  private List<CommentInfo> getRevisionComments(String changeId, String revId) throws Exception {
-    return getPublishedComments(changeId, revId)
-        .values()
-        .stream()
-        .flatMap(List::stream)
-        .collect(toList());
-  }
-
-  private CommentInput addComment(String changeId, String message) throws Exception {
-    ReviewInput input = new ReviewInput();
-    CommentInput comment = newComment(FILE_NAME, Side.REVISION, 0, message, false);
-    input.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
-    gApi.changes().id(changeId).current().review(input);
-    return comment;
-  }
-
-  private void addComments(String changeId, String revision, CommentInput... commentInputs)
-      throws Exception {
-    ReviewInput input = new ReviewInput();
-    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
-    gApi.changes().id(changeId).revision(revision).review(input);
-  }
-
-  private List<RevCommit> getCommits(Change.Id changeId) throws IOException {
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk revWalk = new RevWalk(repo)) {
-      Ref metaRef = repo.exactRef(RefNames.changeMetaRef(changeId));
-      revWalk.markStart(revWalk.parseCommit(metaRef.getObjectId()));
-      return Lists.newArrayList(revWalk);
-    }
-  }
-
-  /**
-   * All the commits, which contain the target comment before, should still contain the comment with
-   * the updated message. All the other metas of the commits should be exactly the same.
-   */
-  private void assertMetaBranchCommitsAfterRewriting(
-      List<RevCommit> beforeDelete,
-      Change.Id changeId,
-      String targetCommentUuid,
-      String expectedMessage)
-      throws Exception {
-    List<RevCommit> afterDelete = getCommits(changeId);
-    assertThat(afterDelete).hasSize(beforeDelete.size());
-
-    try (Repository repo = repoManager.openRepository(project);
-        ObjectReader reader = repo.newObjectReader()) {
-      for (int i = 0; i < beforeDelete.size(); i++) {
-        RevCommit commitBefore = beforeDelete.get(i);
-        RevCommit commitAfter = afterDelete.get(i);
-
-        Map<String, com.google.gerrit.reviewdb.client.Comment> commentMapBefore =
-            DeleteCommentRewriter.getPublishedComments(
-                noteUtil, changeId, reader, NoteMap.read(reader, commitBefore));
-        Map<String, com.google.gerrit.reviewdb.client.Comment> commentMapAfter =
-            DeleteCommentRewriter.getPublishedComments(
-                noteUtil, changeId, reader, NoteMap.read(reader, commitAfter));
-
-        if (commentMapBefore.containsKey(targetCommentUuid)) {
-          assertThat(commentMapAfter).containsKey(targetCommentUuid);
-          com.google.gerrit.reviewdb.client.Comment comment =
-              commentMapAfter.get(targetCommentUuid);
-          assertThat(comment.message).isEqualTo(expectedMessage);
-          comment.message = commentMapBefore.get(targetCommentUuid).message;
-          commentMapAfter.put(targetCommentUuid, comment);
-          assertThat(commentMapAfter).isEqualTo(commentMapBefore);
-        } else {
-          assertThat(commentMapAfter).doesNotContainKey(targetCommentUuid);
-        }
-
-        // Other metas should be exactly the same.
-        assertThat(commitAfter.getFullMessage()).isEqualTo(commitBefore.getFullMessage());
-        assertThat(commitAfter.getCommitterIdent()).isEqualTo(commitBefore.getCommitterIdent());
-        assertThat(commitAfter.getAuthorIdent()).isEqualTo(commitBefore.getAuthorIdent());
-        assertThat(commitAfter.getEncoding()).isEqualTo(commitBefore.getEncoding());
-        assertThat(commitAfter.getEncodingName()).isEqualTo(commitBefore.getEncodingName());
-      }
-    }
-  }
-
-  private static String extractComments(String msg) {
-    // Extract lines between start "....." and end "-- ".
-    Pattern p = Pattern.compile(".*[.]{5}\n+(.*)\\n+-- \n.*", Pattern.DOTALL);
-    Matcher m = p.matcher(msg);
-    return m.matches() ? m.group(1) : msg;
-  }
-
-  private ReviewInput newInput(CommentInput c) {
-    ReviewInput in = new ReviewInput();
-    in.comments = new HashMap<>();
-    in.comments.put(c.path, Lists.newArrayList(c));
-    return in;
-  }
-
-  private void addComment(PushOneCommit.Result r, String message) throws Exception {
-    addComment(r, message, false, false, null);
-  }
-
-  private void addComment(
-      PushOneCommit.Result r,
-      String message,
-      boolean omitDuplicateComments,
-      Boolean unresolved,
-      String inReplyTo)
-      throws Exception {
-    CommentInput c = new CommentInput();
-    c.line = 1;
-    c.message = message;
-    c.path = FILE_NAME;
-    c.unresolved = unresolved;
-    c.inReplyTo = inReplyTo;
-    ReviewInput in = newInput(c);
-    in.omitDuplicateComments = omitDuplicateComments;
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
-  }
-
-  private CommentInfo addDraft(String changeId, String revId, DraftInput in) throws Exception {
-    return gApi.changes().id(changeId).revision(revId).createDraft(in).get();
-  }
-
-  private void updateDraft(String changeId, String revId, DraftInput in, String uuid)
-      throws Exception {
-    gApi.changes().id(changeId).revision(revId).draft(uuid).update(in);
-  }
-
-  private void deleteDraft(String changeId, String revId, String uuid) throws Exception {
-    gApi.changes().id(changeId).revision(revId).draft(uuid).delete();
-  }
-
-  private CommentInfo getPublishedComment(String changeId, String revId, String uuid)
-      throws Exception {
-    return gApi.changes().id(changeId).revision(revId).comment(uuid).get();
-  }
-
-  private Map<String, List<CommentInfo>> getPublishedComments(String changeId, String revId)
-      throws Exception {
-    return gApi.changes().id(changeId).revision(revId).comments();
-  }
-
-  private Map<String, List<CommentInfo>> getDraftComments(String changeId, String revId)
-      throws Exception {
-    return gApi.changes().id(changeId).revision(revId).drafts();
-  }
-
-  private Map<String, List<CommentInfo>> getPublishedComments(String changeId) throws Exception {
-    return gApi.changes().id(changeId).comments();
-  }
-
-  private CommentInfo getDraftComment(String changeId, String revId, String uuid) throws Exception {
-    return gApi.changes().id(changeId).revision(revId).draft(uuid).get();
-  }
-
-  private static CommentInput newComment(String file, String message) {
-    return newComment(file, Side.REVISION, 0, message, false);
-  }
-
-  private static CommentInput newComment(
-      String path, Side side, int line, String message, Boolean unresolved) {
-    CommentInput c = new CommentInput();
-    return populate(c, path, side, null, line, message, unresolved);
-  }
-
-  private static CommentInput newCommentOnParent(
-      String path, int parent, int line, String message) {
-    CommentInput c = new CommentInput();
-    return populate(c, path, Side.PARENT, Integer.valueOf(parent), line, message, false);
-  }
-
-  private DraftInput newDraft(String path, Side side, int line, String message) {
-    DraftInput d = new DraftInput();
-    return populate(d, path, side, null, line, message, false);
-  }
-
-  private DraftInput newDraftOnParent(String path, int parent, int line, String message) {
-    DraftInput d = new DraftInput();
-    return populate(d, path, Side.PARENT, Integer.valueOf(parent), line, message, false);
-  }
-
-  private static <C extends Comment> C populate(
-      C c, String path, Side side, Integer parent, int line, String message, Boolean unresolved) {
-    c.path = path;
-    c.side = side;
-    c.parent = parent;
-    c.line = line != 0 ? line : null;
-    c.message = message;
-    c.unresolved = unresolved;
-    if (line != 0) {
-      Comment.Range range = new Comment.Range();
-      range.startLine = line;
-      range.startCharacter = 1;
-      range.endLine = line;
-      range.endCharacter = 5;
-      c.range = range;
-    }
-    return c;
-  }
-
-  private static Function<CommentInfo, CommentInput> infoToInput(String path) {
-    return infoToInput(path, CommentInput::new);
-  }
-
-  private static Function<CommentInfo, DraftInput> infoToDraft(String path) {
-    return infoToInput(path, DraftInput::new);
-  }
-
-  private static <I extends Comment> Function<CommentInfo, I> infoToInput(
-      String path, Supplier<I> supplier) {
-    return info -> {
-      I i = supplier.get();
-      i.path = path;
-      copy(info, i);
-      return i;
-    };
-  }
-
-  private static void copy(Comment from, Comment to) {
-    to.side = from.side == null ? Side.REVISION : from.side;
-    to.parent = from.parent;
-    to.line = from.line;
-    to.message = from.message;
-    to.range = from.range;
-    to.unresolved = from.unresolved;
-    to.inReplyTo = from.inReplyTo;
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
deleted file mode 100644
index ed64ce0..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ /dev/null
@@ -1,964 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.change;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIXED;
-import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIX_FAILED;
-import static com.google.gerrit.testutil.TestChanges.newPatchSet;
-import static java.util.Collections.singleton;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.FixInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ProblemInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.change.ChangeInserter;
-import com.google.gerrit.server.change.ConsistencyChecker;
-import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.notedb.ChangeNoteUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.gerrit.testutil.NoteDbMode;
-import com.google.gerrit.testutil.TestChanges;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class ConsistencyCheckerIT extends AbstractDaemonTest {
-  @Inject private ChangeNotes.Factory changeNotesFactory;
-
-  @Inject private Provider<ConsistencyChecker> checkerProvider;
-
-  @Inject private IdentifiedUser.GenericFactory userFactory;
-
-  @Inject private ChangeInserter.Factory changeInserterFactory;
-
-  @Inject private PatchSetInserter.Factory patchSetInserterFactory;
-
-  @Inject private ChangeNoteUtil noteUtil;
-
-  @Inject @AnonymousCowardName private String anonymousCowardName;
-
-  @Inject private Sequences sequences;
-
-  @Inject private AccountsUpdate.Server accountsUpdate;
-
-  private RevCommit tip;
-  private Account.Id adminId;
-  private ConsistencyChecker checker;
-
-  private void assumeNoteDbDisabled() {
-    assume().that(notesMigration.readChanges()).isFalse();
-    assume().that(NoteDbMode.get()).isNotEqualTo(NoteDbMode.CHECK);
-  }
-
-  @Before
-  public void setUp() throws Exception {
-    // Ignore client clone of project; repurpose as server-side TestRepository.
-    testRepo = new TestRepository<>((InMemoryRepository) repoManager.openRepository(project));
-    tip =
-        testRepo.getRevWalk().parseCommit(testRepo.getRepository().exactRef("HEAD").getObjectId());
-    adminId = admin.getId();
-    checker = checkerProvider.get();
-  }
-
-  @Test
-  public void validNewChange() throws Exception {
-    assertNoProblems(insertChange(), null);
-  }
-
-  @Test
-  public void validMergedChange() throws Exception {
-    ChangeNotes notes = mergeChange(incrementPatchSet(insertChange()));
-    assertNoProblems(notes, null);
-  }
-
-  @Test
-  public void missingOwner() throws Exception {
-    TestAccount owner = accountCreator.create("missing");
-    ChangeNotes notes = insertChange(owner);
-    accountsUpdate.create().deleteByKey(owner.getId());
-
-    assertProblems(notes, null, problem("Missing change owner: " + owner.getId()));
-  }
-
-  @Test
-  public void missingRepo() throws Exception {
-    // NoteDb can't have a change without a repo.
-    assumeNoteDbDisabled();
-
-    ChangeNotes notes = insertChange();
-    Project.NameKey name = notes.getProjectName();
-    ((InMemoryRepositoryManager) repoManager).deleteRepository(name);
-    assertThat(checker.check(notes, null).problems())
-        .containsExactly(problem("Destination repository not found: " + name));
-  }
-
-  @Test
-  public void invalidRevision() throws Exception {
-    // NoteDb always parses the revision when inserting a patch set, so we can't
-    // create an invalid patch set.
-    assumeNoteDbDisabled();
-
-    ChangeNotes notes = insertChange();
-    PatchSet ps =
-        newPatchSet(
-            notes.getChange().currentPatchSetId(),
-            "fooooooooooooooooooooooooooooooooooooooo",
-            adminId);
-    db.patchSets().update(singleton(ps));
-
-    assertProblems(
-        notes,
-        null,
-        problem("Invalid revision on patch set 1: fooooooooooooooooooooooooooooooooooooooo"));
-  }
-
-  // No test for ref existing but object missing; InMemoryRepository won't let
-  // us do such a thing.
-
-  @Test
-  public void patchSetObjectAndRefMissing() throws Exception {
-    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    ChangeNotes notes = insertChange();
-    PatchSet ps = insertMissingPatchSet(notes, rev);
-    notes = reload(notes);
-    assertProblems(
-        notes,
-        null,
-        problem("Ref missing: " + ps.getId().toRefName()),
-        problem("Object missing: patch set 2: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-  }
-
-  @Test
-  public void patchSetObjectAndRefMissingWithFix() throws Exception {
-    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    ChangeNotes notes = insertChange();
-    PatchSet ps = insertMissingPatchSet(notes, rev);
-    notes = reload(notes);
-
-    String refName = ps.getId().toRefName();
-    assertProblems(
-        notes,
-        new FixInput(),
-        problem("Ref missing: " + refName),
-        problem("Object missing: patch set 2: " + rev));
-  }
-
-  @Test
-  public void patchSetRefMissing() throws Exception {
-    ChangeNotes notes = insertChange();
-    testRepo.update(
-        "refs/other/foo", ObjectId.fromString(psUtil.current(db, notes).getRevision().get()));
-    String refName = notes.getChange().currentPatchSetId().toRefName();
-    deleteRef(refName);
-
-    assertProblems(notes, null, problem("Ref missing: " + refName));
-  }
-
-  @Test
-  public void patchSetRefMissingWithFix() throws Exception {
-    ChangeNotes notes = insertChange();
-    String rev = psUtil.current(db, notes).getRevision().get();
-    testRepo.update("refs/other/foo", ObjectId.fromString(rev));
-    String refName = notes.getChange().currentPatchSetId().toRefName();
-    deleteRef(refName);
-
-    assertProblems(
-        notes, new FixInput(), problem("Ref missing: " + refName, FIXED, "Repaired patch set ref"));
-    assertThat(testRepo.getRepository().exactRef(refName).getObjectId().name()).isEqualTo(rev);
-  }
-
-  @Test
-  public void patchSetObjectAndRefMissingWithDeletingPatchSet() throws Exception {
-    ChangeNotes notes = insertChange();
-    PatchSet ps1 = psUtil.current(db, notes);
-
-    String rev2 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    PatchSet ps2 = insertMissingPatchSet(notes, rev2);
-    notes = reload(notes);
-
-    FixInput fix = new FixInput();
-    fix.deletePatchSetIfCommitMissing = true;
-    assertProblems(
-        notes,
-        fix,
-        problem("Ref missing: " + ps2.getId().toRefName()),
-        problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"));
-
-    notes = reload(notes);
-    assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(1);
-    assertThat(psUtil.get(db, notes, ps1.getId())).isNotNull();
-    assertThat(psUtil.get(db, notes, ps2.getId())).isNull();
-  }
-
-  @Test
-  public void patchSetMultipleObjectsMissingWithDeletingPatchSets() throws Exception {
-    ChangeNotes notes = insertChange();
-    PatchSet ps1 = psUtil.current(db, notes);
-
-    String rev2 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    PatchSet ps2 = insertMissingPatchSet(notes, rev2);
-
-    notes = incrementPatchSet(reload(notes));
-    PatchSet ps3 = psUtil.current(db, notes);
-
-    String rev4 = "c0ffeeeec0ffeeeec0ffeeeec0ffeeeec0ffeeee";
-    PatchSet ps4 = insertMissingPatchSet(notes, rev4);
-    notes = reload(notes);
-
-    FixInput fix = new FixInput();
-    fix.deletePatchSetIfCommitMissing = true;
-    assertProblems(
-        notes,
-        fix,
-        problem("Ref missing: " + ps2.getId().toRefName()),
-        problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"),
-        problem("Ref missing: " + ps4.getId().toRefName()),
-        problem("Object missing: patch set 4: " + rev4, FIXED, "Deleted patch set"));
-
-    notes = reload(notes);
-    assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(3);
-    assertThat(psUtil.get(db, notes, ps1.getId())).isNotNull();
-    assertThat(psUtil.get(db, notes, ps2.getId())).isNull();
-    assertThat(psUtil.get(db, notes, ps3.getId())).isNotNull();
-    assertThat(psUtil.get(db, notes, ps4.getId())).isNull();
-  }
-
-  @Test
-  public void onlyPatchSetObjectMissingWithFix() throws Exception {
-    Change c = TestChanges.newChange(project, admin.getId(), sequences.nextChangeId());
-
-    // Set review started, mimicking Schema_153, so tests pass with NoteDbMode.CHECK.
-    c.setReviewStarted(true);
-
-    PatchSet.Id psId = c.currentPatchSetId();
-    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    PatchSet ps = newPatchSet(psId, rev, adminId);
-
-    if (notesMigration.changePrimaryStorage() == PrimaryStorage.REVIEW_DB) {
-      db.changes().insert(singleton(c));
-      db.patchSets().insert(singleton(ps));
-    }
-    addNoteDbCommit(
-        c.getId(),
-        "Create change\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Branch: "
-            + c.getDest().get()
-            + "\n"
-            + "Change-id: "
-            + c.getKey().get()
-            + "\n"
-            + "Subject: Bogus subject\n"
-            + "Commit: "
-            + rev
-            + "\n"
-            + "Groups: "
-            + rev
-            + "\n");
-    indexer.index(db, c.getProject(), c.getId());
-    ChangeNotes notes = changeNotesFactory.create(db, c.getProject(), c.getId());
-
-    FixInput fix = new FixInput();
-    fix.deletePatchSetIfCommitMissing = true;
-    assertProblems(
-        notes,
-        fix,
-        problem("Ref missing: " + ps.getId().toRefName()),
-        problem(
-            "Object missing: patch set 1: " + rev,
-            FIX_FAILED,
-            "Cannot delete patch set; no patch sets would remain"));
-
-    notes = reload(notes);
-    assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(1);
-    assertThat(psUtil.current(db, notes)).isNotNull();
-  }
-
-  @Test
-  public void currentPatchSetMissing() throws Exception {
-    // NoteDb can't create a change without a patch set.
-    assumeNoteDbDisabled();
-
-    ChangeNotes notes = insertChange();
-    db.patchSets().deleteKeys(singleton(notes.getChange().currentPatchSetId()));
-    assertProblems(notes, null, problem("Current patch set 1 not found"));
-  }
-
-  @Test
-  public void duplicatePatchSetRevisions() throws Exception {
-    ChangeNotes notes = insertChange();
-    PatchSet ps1 = psUtil.current(db, notes);
-    String rev = ps1.getRevision().get();
-
-    notes = incrementPatchSet(notes, testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
-
-    assertProblems(notes, null, problem("Multiple patch sets pointing to " + rev + ": [1, 2]"));
-  }
-
-  @Test
-  public void missingDestRef() throws Exception {
-    ChangeNotes notes = insertChange();
-
-    String ref = "refs/heads/master";
-    // Detach head so we're allowed to delete ref.
-    testRepo.reset(testRepo.getRepository().exactRef(ref).getObjectId());
-    RefUpdate ru = testRepo.getRepository().updateRef(ref);
-    ru.setForceUpdate(true);
-    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
-
-    assertProblems(notes, null, problem("Destination ref not found (may be new branch): " + ref));
-  }
-
-  @Test
-  public void mergedChangeIsNotMerged() throws Exception {
-    ChangeNotes notes = insertChange();
-
-    try (BatchUpdate bu = newUpdate(adminId)) {
-      bu.addOp(
-          notes.getChangeId(),
-          new BatchUpdateOp() {
-            @Override
-            public boolean updateChange(ChangeContext ctx) throws OrmException {
-              ctx.getChange().setStatus(Change.Status.MERGED);
-              ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
-              return true;
-            }
-          });
-      bu.execute();
-    }
-    notes = reload(notes);
-
-    String rev = psUtil.current(db, notes).getRevision().get();
-    ObjectId tip = getDestRef(notes);
-    assertProblems(
-        notes,
-        null,
-        problem(
-            "Patch set 1 ("
-                + rev
-                + ") is not merged into destination ref"
-                + " refs/heads/master ("
-                + tip.name()
-                + "), but change status is MERGED"));
-  }
-
-  @Test
-  public void newChangeIsMerged() throws Exception {
-    ChangeNotes notes = insertChange();
-    String rev = psUtil.current(db, notes).getRevision().get();
-    testRepo
-        .branch(notes.getChange().getDest().get())
-        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
-
-    assertProblems(
-        notes,
-        null,
-        problem(
-            "Patch set 1 ("
-                + rev
-                + ") is merged into destination ref"
-                + " refs/heads/master ("
-                + rev
-                + "), but change status is NEW"));
-  }
-
-  @Test
-  public void newChangeIsMergedWithFix() throws Exception {
-    ChangeNotes notes = insertChange();
-    String rev = psUtil.current(db, notes).getRevision().get();
-    testRepo
-        .branch(notes.getChange().getDest().get())
-        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
-
-    assertProblems(
-        notes,
-        new FixInput(),
-        problem(
-            "Patch set 1 ("
-                + rev
-                + ") is merged into destination ref"
-                + " refs/heads/master ("
-                + rev
-                + "), but change status is NEW",
-            FIXED,
-            "Marked change as merged"));
-
-    notes = reload(notes);
-    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertNoProblems(notes, null);
-  }
-
-  @Test
-  public void extensionApiReturnsUpdatedValueAfterFix() throws Exception {
-    ChangeNotes notes = insertChange();
-    String rev = psUtil.current(db, notes).getRevision().get();
-    testRepo
-        .branch(notes.getChange().getDest().get())
-        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
-
-    ChangeInfo info = gApi.changes().id(notes.getChangeId().get()).info();
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-
-    info = gApi.changes().id(notes.getChangeId().get()).check(new FixInput());
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-  }
-
-  @Test
-  public void expectedMergedCommitIsLatestPatchSet() throws Exception {
-    ChangeNotes notes = insertChange();
-    String rev = psUtil.current(db, notes).getRevision().get();
-    testRepo
-        .branch(notes.getChange().getDest().get())
-        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
-
-    FixInput fix = new FixInput();
-    fix.expectMergedAs = rev;
-    assertProblems(
-        notes,
-        fix,
-        problem(
-            "Patch set 1 ("
-                + rev
-                + ") is merged into destination ref"
-                + " refs/heads/master ("
-                + rev
-                + "), but change status is NEW",
-            FIXED,
-            "Marked change as merged"));
-
-    notes = reload(notes);
-    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertNoProblems(notes, null);
-  }
-
-  @Test
-  public void expectedMergedCommitNotMergedIntoDestination() throws Exception {
-    ChangeNotes notes = insertChange();
-    String rev = psUtil.current(db, notes).getRevision().get();
-    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
-    testRepo.branch(notes.getChange().getDest().get()).update(commit);
-
-    FixInput fix = new FixInput();
-    RevCommit other = testRepo.commit().message(commit.getFullMessage()).create();
-    fix.expectMergedAs = other.name();
-    assertProblems(
-        notes,
-        fix,
-        problem(
-            "Expected merged commit "
-                + other.name()
-                + " is not merged into destination ref refs/heads/master"
-                + " ("
-                + commit.name()
-                + ")"));
-  }
-
-  @Test
-  public void createNewPatchSetForExpectedMergeCommitWithNoChangeId() throws Exception {
-    ChangeNotes notes = insertChange();
-    String dest = notes.getChange().getDest().get();
-    String rev = psUtil.current(db, notes).getRevision().get();
-    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
-
-    RevCommit mergedAs =
-        testRepo.commit().parent(commit.getParent(0)).message(commit.getShortMessage()).create();
-    testRepo.getRevWalk().parseBody(mergedAs);
-    assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID)).isEmpty();
-    testRepo.update(dest, mergedAs);
-
-    assertNoProblems(notes, null);
-
-    FixInput fix = new FixInput();
-    fix.expectMergedAs = mergedAs.name();
-    assertProblems(
-        notes,
-        fix,
-        problem(
-            "No patch set found for merged commit " + mergedAs.name(),
-            FIXED,
-            "Marked change as merged"),
-        problem(
-            "Expected merged commit " + mergedAs.name() + " has no associated patch set",
-            FIXED,
-            "Inserted as patch set 2"));
-
-    notes = reload(notes);
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
-    assertThat(psUtil.get(db, notes, psId2).getRevision().get()).isEqualTo(mergedAs.name());
-
-    assertNoProblems(notes, null);
-  }
-
-  @Test
-  public void createNewPatchSetForExpectedMergeCommitWithChangeId() throws Exception {
-    ChangeNotes notes = insertChange();
-    String dest = notes.getChange().getDest().get();
-    String rev = psUtil.current(db, notes).getRevision().get();
-    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
-
-    RevCommit mergedAs =
-        testRepo
-            .commit()
-            .parent(commit.getParent(0))
-            .message(
-                commit.getShortMessage()
-                    + "\n"
-                    + "\n"
-                    + "Change-Id: "
-                    + notes.getChange().getKey().get()
-                    + "\n")
-            .create();
-    testRepo.getRevWalk().parseBody(mergedAs);
-    assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID))
-        .containsExactly(notes.getChange().getKey().get());
-    testRepo.update(dest, mergedAs);
-
-    assertNoProblems(notes, null);
-
-    FixInput fix = new FixInput();
-    fix.expectMergedAs = mergedAs.name();
-    assertProblems(
-        notes,
-        fix,
-        problem(
-            "No patch set found for merged commit " + mergedAs.name(),
-            FIXED,
-            "Marked change as merged"),
-        problem(
-            "Expected merged commit " + mergedAs.name() + " has no associated patch set",
-            FIXED,
-            "Inserted as patch set 2"));
-
-    notes = reload(notes);
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
-    assertThat(psUtil.get(db, notes, psId2).getRevision().get()).isEqualTo(mergedAs.name());
-
-    assertNoProblems(notes, null);
-  }
-
-  @Test
-  public void expectedMergedCommitIsOldPatchSetOfSameChange() throws Exception {
-    ChangeNotes notes = insertChange();
-    PatchSet ps1 = psUtil.current(db, notes);
-    String rev1 = ps1.getRevision().get();
-    notes = incrementPatchSet(notes);
-    PatchSet ps2 = psUtil.current(db, notes);
-    testRepo
-        .branch(notes.getChange().getDest().get())
-        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev1)));
-
-    FixInput fix = new FixInput();
-    fix.expectMergedAs = rev1;
-    assertProblems(
-        notes,
-        fix,
-        problem("No patch set found for merged commit " + rev1, FIXED, "Marked change as merged"),
-        problem(
-            "Expected merge commit "
-                + rev1
-                + " corresponds to patch set 1,"
-                + " not the current patch set 2",
-            FIXED,
-            "Deleted patch set"),
-        problem(
-            "Expected merge commit "
-                + rev1
-                + " corresponds to patch set 1,"
-                + " not the current patch set 2",
-            FIXED,
-            "Inserted as patch set 3"));
-
-    notes = reload(notes);
-    PatchSet.Id psId3 = new PatchSet.Id(notes.getChangeId(), 3);
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId3);
-    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(ps2.getId(), psId3);
-    assertThat(psUtil.get(db, notes, psId3).getRevision().get()).isEqualTo(rev1);
-  }
-
-  @Test
-  public void expectedMergedCommitIsDanglingPatchSetOlderThanCurrent() throws Exception {
-    ChangeNotes notes = insertChange();
-    PatchSet ps1 = psUtil.current(db, notes);
-
-    // Create dangling ref so next ID in the database becomes 3.
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
-    RevCommit commit2 = patchSetCommit(psId2);
-    String rev2 = commit2.name();
-    testRepo.branch(psId2.toRefName()).update(commit2);
-
-    notes = incrementPatchSet(notes);
-    PatchSet ps3 = psUtil.current(db, notes);
-    assertThat(ps3.getId().get()).isEqualTo(3);
-
-    testRepo
-        .branch(notes.getChange().getDest().get())
-        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
-
-    FixInput fix = new FixInput();
-    fix.expectMergedAs = rev2;
-    assertProblems(
-        notes,
-        fix,
-        problem("No patch set found for merged commit " + rev2, FIXED, "Marked change as merged"),
-        problem(
-            "Expected merge commit "
-                + rev2
-                + " corresponds to patch set 2,"
-                + " not the current patch set 3",
-            FIXED,
-            "Deleted patch set"),
-        problem(
-            "Expected merge commit "
-                + rev2
-                + " corresponds to patch set 2,"
-                + " not the current patch set 3",
-            FIXED,
-            "Inserted as patch set 4"));
-
-    notes = reload(notes);
-    PatchSet.Id psId4 = new PatchSet.Id(notes.getChangeId(), 4);
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId4);
-    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertThat(psUtil.byChangeAsMap(db, notes).keySet())
-        .containsExactly(ps1.getId(), ps3.getId(), psId4);
-    assertThat(psUtil.get(db, notes, psId4).getRevision().get()).isEqualTo(rev2);
-  }
-
-  @Test
-  public void expectedMergedCommitIsDanglingPatchSetNewerThanCurrent() throws Exception {
-    ChangeNotes notes = insertChange();
-    PatchSet ps1 = psUtil.current(db, notes);
-
-    // Create dangling ref with no patch set.
-    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
-    RevCommit commit2 = patchSetCommit(psId2);
-    String rev2 = commit2.name();
-    testRepo.branch(psId2.toRefName()).update(commit2);
-
-    testRepo
-        .branch(notes.getChange().getDest().get())
-        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
-
-    FixInput fix = new FixInput();
-    fix.expectMergedAs = rev2;
-    assertProblems(
-        notes,
-        fix,
-        problem("No patch set found for merged commit " + rev2, FIXED, "Marked change as merged"),
-        problem(
-            "Expected merge commit "
-                + rev2
-                + " corresponds to patch set 2,"
-                + " not the current patch set 1",
-            FIXED,
-            "Inserted as patch set 2"));
-
-    notes = reload(notes);
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
-    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(ps1.getId(), psId2);
-    assertThat(psUtil.get(db, notes, psId2).getRevision().get()).isEqualTo(rev2);
-  }
-
-  @Test
-  public void expectedMergedCommitWithMismatchedChangeId() throws Exception {
-    ChangeNotes notes = insertChange();
-    String dest = notes.getChange().getDest().get();
-    RevCommit parent = testRepo.branch(dest).commit().message("parent").create();
-    String rev = psUtil.current(db, notes).getRevision().get();
-    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
-    testRepo.branch(dest).update(commit);
-
-    String badId = "I0000000000000000000000000000000000000000";
-    RevCommit mergedAs =
-        testRepo
-            .commit()
-            .parent(parent)
-            .message(commit.getShortMessage() + "\n\nChange-Id: " + badId + "\n")
-            .create();
-    testRepo.getRevWalk().parseBody(mergedAs);
-    assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID)).containsExactly(badId);
-    testRepo.update(dest, mergedAs);
-
-    assertNoProblems(notes, null);
-
-    FixInput fix = new FixInput();
-    fix.expectMergedAs = mergedAs.name();
-    assertProblems(
-        notes,
-        fix,
-        problem(
-            "Expected merged commit "
-                + mergedAs.name()
-                + " has Change-Id: "
-                + badId
-                + ", but expected "
-                + notes.getChange().getKey().get()));
-  }
-
-  @Test
-  public void expectedMergedCommitMatchesMultiplePatchSets() throws Exception {
-    ChangeNotes notes1 = insertChange();
-    PatchSet.Id psId1 = psUtil.current(db, notes1).getId();
-    String dest = notes1.getChange().getDest().get();
-    String rev = psUtil.current(db, notes1).getRevision().get();
-    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
-    testRepo.branch(dest).update(commit);
-
-    ChangeNotes notes2 = insertChange();
-    notes2 = incrementPatchSet(notes2, commit);
-    PatchSet.Id psId2 = psUtil.current(db, notes2).getId();
-
-    ChangeNotes notes3 = insertChange();
-    notes3 = incrementPatchSet(notes3, commit);
-    PatchSet.Id psId3 = psUtil.current(db, notes3).getId();
-
-    FixInput fix = new FixInput();
-    fix.expectMergedAs = commit.name();
-    assertProblems(
-        notes1,
-        fix,
-        problem(
-            "Multiple patch sets for expected merged commit "
-                + commit.name()
-                + ": ["
-                + psId1
-                + ", "
-                + psId2
-                + ", "
-                + psId3
-                + "]"));
-  }
-
-  private BatchUpdate newUpdate(Account.Id owner) {
-    return batchUpdateFactory.create(db, project, userFactory.create(owner), TimeUtil.nowTs());
-  }
-
-  private ChangeNotes insertChange() throws Exception {
-    return insertChange(admin);
-  }
-
-  private ChangeNotes insertChange(TestAccount owner) throws Exception {
-    return insertChange(owner, "refs/heads/master");
-  }
-
-  private ChangeNotes insertChange(TestAccount owner, String dest) throws Exception {
-    Change.Id id = new Change.Id(sequences.nextChangeId());
-    ChangeInserter ins;
-    try (BatchUpdate bu = newUpdate(owner.getId())) {
-      RevCommit commit = patchSetCommit(new PatchSet.Id(id, 1));
-      ins =
-          changeInserterFactory
-              .create(id, commit, dest)
-              .setValidate(false)
-              .setNotify(NotifyHandling.NONE)
-              .setFireRevisionCreated(false)
-              .setSendMail(false);
-      bu.insertChange(ins).execute();
-    }
-    return changeNotesFactory.create(db, project, ins.getChange().getId());
-  }
-
-  private PatchSet.Id nextPatchSetId(ChangeNotes notes) throws Exception {
-    return ChangeUtil.nextPatchSetId(
-        testRepo.getRepository(), notes.getChange().currentPatchSetId());
-  }
-
-  private ChangeNotes incrementPatchSet(ChangeNotes notes) throws Exception {
-    return incrementPatchSet(notes, patchSetCommit(nextPatchSetId(notes)));
-  }
-
-  private ChangeNotes incrementPatchSet(ChangeNotes notes, RevCommit commit) throws Exception {
-    PatchSetInserter ins;
-    try (BatchUpdate bu = newUpdate(notes.getChange().getOwner())) {
-      ins =
-          patchSetInserterFactory
-              .create(notes, nextPatchSetId(notes), commit)
-              .setValidate(false)
-              .setFireRevisionCreated(false)
-              .setNotify(NotifyHandling.NONE);
-      bu.addOp(notes.getChangeId(), ins).execute();
-    }
-    return reload(notes);
-  }
-
-  private ChangeNotes reload(ChangeNotes notes) throws Exception {
-    return changeNotesFactory.create(db, notes.getChange().getProject(), notes.getChangeId());
-  }
-
-  private RevCommit patchSetCommit(PatchSet.Id psId) throws Exception {
-    RevCommit c = testRepo.commit().parent(tip).message("Change " + psId).create();
-    return testRepo.parseBody(c);
-  }
-
-  private PatchSet insertMissingPatchSet(ChangeNotes notes, String rev) throws Exception {
-    // Don't use BatchUpdate since we're manually updating the meta ref rather
-    // than using ChangeUpdate.
-    String subject = "Subject for missing commit";
-    Change c = new Change(notes.getChange());
-    PatchSet.Id psId = nextPatchSetId(notes);
-    c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
-    PatchSet ps = newPatchSet(psId, rev, adminId);
-
-    if (PrimaryStorage.of(c) == PrimaryStorage.REVIEW_DB) {
-      db.patchSets().insert(singleton(ps));
-      db.changes().update(singleton(c));
-    }
-
-    addNoteDbCommit(
-        c.getId(),
-        "Update patch set "
-            + psId.get()
-            + "\n"
-            + "\n"
-            + "Patch-set: "
-            + psId.get()
-            + "\n"
-            + "Commit: "
-            + rev
-            + "\n"
-            + "Subject: "
-            + subject
-            + "\n");
-    indexer.index(db, c.getProject(), c.getId());
-
-    return ps;
-  }
-
-  private void deleteRef(String refName) throws Exception {
-    RefUpdate ru = testRepo.getRepository().updateRef(refName, true);
-    ru.setForceUpdate(true);
-    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
-  }
-
-  private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
-    if (!notesMigration.commitChangeWrites()) {
-      return;
-    }
-    PersonIdent committer = serverIdent.get();
-    PersonIdent author =
-        noteUtil.newIdent(
-            accountCache.get(admin.getId()).getAccount(),
-            committer.getWhen(),
-            committer,
-            anonymousCowardName);
-    testRepo
-        .branch(RefNames.changeMetaRef(id))
-        .commit()
-        .author(author)
-        .committer(committer)
-        .message(commitMessage)
-        .create();
-  }
-
-  private ObjectId getDestRef(ChangeNotes notes) throws Exception {
-    return testRepo.getRepository().exactRef(notes.getChange().getDest().get()).getObjectId();
-  }
-
-  private ChangeNotes mergeChange(ChangeNotes notes) throws Exception {
-    final ObjectId oldId = getDestRef(notes);
-    final ObjectId newId = ObjectId.fromString(psUtil.current(db, notes).getRevision().get());
-    final String dest = notes.getChange().getDest().get();
-
-    try (BatchUpdate bu = newUpdate(adminId)) {
-      bu.addOp(
-          notes.getChangeId(),
-          new BatchUpdateOp() {
-            @Override
-            public void updateRepo(RepoContext ctx) throws IOException {
-              ctx.addRefUpdate(oldId, newId, dest);
-            }
-
-            @Override
-            public boolean updateChange(ChangeContext ctx) throws OrmException {
-              ctx.getChange().setStatus(Change.Status.MERGED);
-              ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
-              return true;
-            }
-          });
-      bu.execute();
-    }
-    return reload(notes);
-  }
-
-  private static ProblemInfo problem(String message) {
-    ProblemInfo p = new ProblemInfo();
-    p.message = message;
-    return p;
-  }
-
-  private static ProblemInfo problem(String message, ProblemInfo.Status status, String outcome) {
-    ProblemInfo p = problem(message);
-    p.status = checkNotNull(status);
-    p.outcome = checkNotNull(outcome);
-    return p;
-  }
-
-  private void assertProblems(
-      ChangeNotes notes, @Nullable FixInput fix, ProblemInfo first, ProblemInfo... rest)
-      throws Exception {
-    List<ProblemInfo> expected = new ArrayList<>(1 + rest.length);
-    expected.add(first);
-    expected.addAll(Arrays.asList(rest));
-    assertThat(checker.check(notes, fix).problems()).containsExactlyElementsIn(expected).inOrder();
-  }
-
-  private void assertNoProblems(ChangeNotes notes, @Nullable FixInput fix) throws Exception {
-    assertThat(checker.check(notes, fix).problems()).isEmpty();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
deleted file mode 100644
index d4019ec34..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ /dev/null
@@ -1,613 +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.server.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
-import static com.google.gerrit.extensions.common.EditInfoSubject.assertThat;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.EditInfo;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.change.ChangesCollection;
-import com.google.gerrit.server.change.GetRelated.ChangeAndCommit;
-import com.google.gerrit.server.change.GetRelated.RelatedInfo;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.util.List;
-import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class GetRelatedIT extends AbstractDaemonTest {
-  private String systemTimeZone;
-
-  @Before
-  public void setTimeForTesting() {
-    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
-  }
-
-  @Inject private ChangesCollection changes;
-
-  @Test
-  public void getRelatedNoResult() throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    assertRelated(push.to("refs/for/master").getPatchSetId());
-  }
-
-  @Test
-  public void getRelatedLinear() throws Exception {
-    // 1,1---2,1
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
-
-    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
-      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
-    }
-  }
-
-  @Test
-  public void getRelatedLinearSeparatePushes() throws Exception {
-    // 1,1---2,1
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-
-    testRepo.reset(c1_1);
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    String oldETag = changes.parse(ps1_1.getParentKey()).getETag();
-
-    testRepo.reset(c2_1);
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
-
-    // Push of change 2 should not affect groups (or anything else) of change 1.
-    assertThat(changes.parse(ps1_1.getParentKey()).getETag()).isEqualTo(oldETag);
-
-    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
-      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
-    }
-  }
-
-  @Test
-  public void getRelatedReorder() throws Exception {
-    // 1,1---2,1
-    //
-    // 2,2---1,2
-
-    // Create two commits and push.
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
-
-    // Swap the order of commits and push again.
-    testRepo.reset("HEAD~2");
-    RevCommit c2_2 = testRepo.cherryPick(c2_1);
-    RevCommit c1_2 = testRepo.cherryPick(c1_1);
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_2 = getPatchSetId(c1_1);
-    PatchSet.Id ps2_2 = getPatchSetId(c2_1);
-
-    for (PatchSet.Id ps : ImmutableList.of(ps2_2, ps1_2)) {
-      assertRelated(ps, changeAndCommit(ps1_2, c1_2, 2), changeAndCommit(ps2_2, c2_2, 2));
-    }
-
-    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
-      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 2), changeAndCommit(ps1_1, c1_1, 2));
-    }
-  }
-
-  @Test
-  public void getRelatedAmendParentChange() throws Exception {
-    // 1,1---2,1
-    //
-    // 1,2
-
-    // Create two commits and push.
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
-
-    // Amend parent change and push.
-    testRepo.reset("HEAD~1");
-    RevCommit c1_2 = amendBuilder().add("c.txt", "2").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_2 = getPatchSetId(c1_2);
-
-    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
-      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 2));
-    }
-
-    assertRelated(ps1_2, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_2, c1_2, 2));
-  }
-
-  @Test
-  public void getRelatedReorderAndExtend() throws Exception {
-    // 1,1---2,1
-    //
-    // 2,2---1,2---3,1
-
-    // Create two commits and push.
-    ObjectId initial = repo().exactRef("HEAD").getObjectId();
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
-
-    // Swap the order of commits, create a new commit on top, and push again.
-    testRepo.reset(initial);
-    RevCommit c2_2 = testRepo.cherryPick(c2_1);
-    RevCommit c1_2 = testRepo.cherryPick(c1_1);
-    RevCommit c3_1 = commitBuilder().add("c.txt", "3").message("subject: 3").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_2 = getPatchSetId(c1_1);
-    PatchSet.Id ps2_2 = getPatchSetId(c2_1);
-    PatchSet.Id ps3_1 = getPatchSetId(c3_1);
-
-    for (PatchSet.Id ps : ImmutableList.of(ps3_1, ps2_2, ps1_2)) {
-      assertRelated(
-          ps,
-          changeAndCommit(ps3_1, c3_1, 1),
-          changeAndCommit(ps1_2, c1_2, 2),
-          changeAndCommit(ps2_2, c2_2, 2));
-    }
-
-    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
-      assertRelated(
-          ps,
-          changeAndCommit(ps3_1, c3_1, 1),
-          changeAndCommit(ps2_1, c2_1, 2),
-          changeAndCommit(ps1_1, c1_1, 2));
-    }
-  }
-
-  @Test
-  public void getRelatedReworkSeries() throws Exception {
-    // 1,1---2,1---3,1
-    //
-    // 1,2---2,2---3,2
-
-    // Create three commits and push.
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2_1 = commitBuilder().add("b.txt", "1").message("subject: 2").create();
-    RevCommit c3_1 = commitBuilder().add("b.txt", "1").message("subject: 3").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
-    PatchSet.Id ps3_1 = getPatchSetId(c3_1);
-
-    // Amend all changes change and push.
-    testRepo.reset(c1_1);
-    RevCommit c1_2 = amendBuilder().add("a.txt", "2").create();
-    RevCommit c2_2 =
-        commitBuilder().add("b.txt", "2").message(parseBody(c2_1).getFullMessage()).create();
-    RevCommit c3_2 =
-        commitBuilder().add("b.txt", "3").message(parseBody(c3_1).getFullMessage()).create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_2 = getPatchSetId(c1_2);
-    PatchSet.Id ps2_2 = getPatchSetId(c2_2);
-    PatchSet.Id ps3_2 = getPatchSetId(c3_2);
-
-    for (PatchSet.Id ps : ImmutableList.of(ps1_1, ps2_1, ps3_1)) {
-      assertRelated(
-          ps,
-          changeAndCommit(ps3_1, c3_1, 2),
-          changeAndCommit(ps2_1, c2_1, 2),
-          changeAndCommit(ps1_1, c1_1, 2));
-    }
-
-    for (PatchSet.Id ps : ImmutableList.of(ps1_2, ps2_2, ps3_2)) {
-      assertRelated(
-          ps,
-          changeAndCommit(ps3_2, c3_2, 2),
-          changeAndCommit(ps2_2, c2_2, 2),
-          changeAndCommit(ps1_2, c1_2, 2));
-    }
-  }
-
-  @Test
-  public void getRelatedReworkThenExtendInTheMiddleOfSeries() throws Exception {
-    // 1,1---2,1---3,1
-    //
-    // 1,2---2,2---3,2
-    //   \---4,1
-
-    // Create three commits and push.
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2_1 = commitBuilder().add("b.txt", "1").message("subject: 2").create();
-    RevCommit c3_1 = commitBuilder().add("b.txt", "1").message("subject: 3").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
-    PatchSet.Id ps3_1 = getPatchSetId(c3_1);
-
-    // Amend all changes change and push.
-    testRepo.reset(c1_1);
-    RevCommit c1_2 = amendBuilder().add("a.txt", "2").create();
-    RevCommit c2_2 =
-        commitBuilder().add("b.txt", "2").message(parseBody(c2_1).getFullMessage()).create();
-    RevCommit c3_2 =
-        commitBuilder().add("b.txt", "3").message(parseBody(c3_1).getFullMessage()).create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_2 = getPatchSetId(c1_2);
-    PatchSet.Id ps2_2 = getPatchSetId(c2_2);
-    PatchSet.Id ps3_2 = getPatchSetId(c3_2);
-
-    // Add one more commit 4,1 based on 1,2.
-    testRepo.reset(c1_2);
-    RevCommit c4_1 = commitBuilder().add("d.txt", "4").message("subject: 4").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps4_1 = getPatchSetId(c4_1);
-
-    // 1,1 is related indirectly to 4,1.
-    assertRelated(
-        ps1_1,
-        changeAndCommit(ps4_1, c4_1, 1),
-        changeAndCommit(ps3_1, c3_1, 2),
-        changeAndCommit(ps2_1, c2_1, 2),
-        changeAndCommit(ps1_1, c1_1, 2));
-
-    // 2,1 and 3,1 don't include 4,1 since we don't walk forward after walking
-    // backward.
-    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps3_1)) {
-      assertRelated(
-          ps,
-          changeAndCommit(ps3_1, c3_1, 2),
-          changeAndCommit(ps2_1, c2_1, 2),
-          changeAndCommit(ps1_1, c1_1, 2));
-    }
-
-    // 1,2 is related directly to 4,1, and the 2-3 parallel branch stays intact.
-    assertRelated(
-        ps1_2,
-        changeAndCommit(ps4_1, c4_1, 1),
-        changeAndCommit(ps3_2, c3_2, 2),
-        changeAndCommit(ps2_2, c2_2, 2),
-        changeAndCommit(ps1_2, c1_2, 2));
-
-    // 4,1 is only related to 1,2, since we don't walk forward after walking
-    // backward.
-    assertRelated(ps4_1, changeAndCommit(ps4_1, c4_1, 1), changeAndCommit(ps1_2, c1_2, 2));
-
-    // 2,2 and 3,2 don't include 4,1 since we don't walk forward after walking
-    // backward.
-    for (PatchSet.Id ps : ImmutableList.of(ps2_2, ps3_2)) {
-      assertRelated(
-          ps,
-          changeAndCommit(ps3_2, c3_2, 2),
-          changeAndCommit(ps2_2, c2_2, 2),
-          changeAndCommit(ps1_2, c1_2, 2));
-    }
-  }
-
-  @Test
-  public void getRelatedCrissCrossDependency() throws Exception {
-    // 1,1---2,1---3,2
-    //
-    // 1,2---2,2---3,1
-
-    // Create two commits and push.
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
-
-    // Amend both changes change and push.
-    testRepo.reset(c1_1);
-    RevCommit c1_2 = amendBuilder().add("a.txt", "2").create();
-    RevCommit c2_2 =
-        commitBuilder().add("b.txt", "2").message(parseBody(c2_1).getFullMessage()).create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_2 = getPatchSetId(c1_2);
-    PatchSet.Id ps2_2 = getPatchSetId(c2_2);
-
-    // PS 3,1 depends on 2,2.
-    RevCommit c3_1 = commitBuilder().add("c.txt", "1").message("subject: 3").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps3_1 = getPatchSetId(c3_1);
-
-    // PS 3,2 depends on 2,1.
-    testRepo.reset(c2_1);
-    RevCommit c3_2 =
-        commitBuilder().add("c.txt", "2").message(parseBody(c3_1).getFullMessage()).create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps3_2 = getPatchSetId(c3_2);
-
-    for (PatchSet.Id ps : ImmutableList.of(ps1_1, ps2_1, ps3_2)) {
-      assertRelated(
-          ps,
-          changeAndCommit(ps3_2, c3_2, 2),
-          changeAndCommit(ps2_1, c2_1, 2),
-          changeAndCommit(ps1_1, c1_1, 2));
-    }
-
-    for (PatchSet.Id ps : ImmutableList.of(ps1_2, ps2_2, ps3_1)) {
-      assertRelated(
-          ps,
-          changeAndCommit(ps3_1, c3_1, 2),
-          changeAndCommit(ps2_2, c2_2, 2),
-          changeAndCommit(ps1_2, c1_2, 2));
-    }
-  }
-
-  @Test
-  public void getRelatedParallelDescendentBranches() throws Exception {
-    // 1,1---2,1---3,1
-    //   \---4,1---5,1
-    //    \--6,1---7,1
-
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    RevCommit c3_1 = commitBuilder().add("c.txt", "3").message("subject: 3").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
-    PatchSet.Id ps3_1 = getPatchSetId(c3_1);
-
-    testRepo.reset(c1_1);
-    RevCommit c4_1 = commitBuilder().add("d.txt", "4").message("subject: 4").create();
-    RevCommit c5_1 = commitBuilder().add("e.txt", "5").message("subject: 5").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps4_1 = getPatchSetId(c4_1);
-    PatchSet.Id ps5_1 = getPatchSetId(c5_1);
-
-    testRepo.reset(c1_1);
-    RevCommit c6_1 = commitBuilder().add("f.txt", "6").message("subject: 6").create();
-    RevCommit c7_1 = commitBuilder().add("g.txt", "7").message("subject: 7").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id ps6_1 = getPatchSetId(c6_1);
-    PatchSet.Id ps7_1 = getPatchSetId(c7_1);
-
-    // All changes are related to 1,1, keeping each of the parallel branches
-    // intact.
-    assertRelated(
-        ps1_1,
-        changeAndCommit(ps7_1, c7_1, 1),
-        changeAndCommit(ps6_1, c6_1, 1),
-        changeAndCommit(ps5_1, c5_1, 1),
-        changeAndCommit(ps4_1, c4_1, 1),
-        changeAndCommit(ps3_1, c3_1, 1),
-        changeAndCommit(ps2_1, c2_1, 1),
-        changeAndCommit(ps1_1, c1_1, 1));
-
-    // The 2-3 branch is only related back to 1, not the other branches.
-    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps3_1)) {
-      assertRelated(
-          ps,
-          changeAndCommit(ps3_1, c3_1, 1),
-          changeAndCommit(ps2_1, c2_1, 1),
-          changeAndCommit(ps1_1, c1_1, 1));
-    }
-
-    // The 4-5 branch is only related back to 1, not the other branches.
-    for (PatchSet.Id ps : ImmutableList.of(ps4_1, ps5_1)) {
-      assertRelated(
-          ps,
-          changeAndCommit(ps5_1, c5_1, 1),
-          changeAndCommit(ps4_1, c4_1, 1),
-          changeAndCommit(ps1_1, c1_1, 1));
-    }
-
-    // The 6-7 branch is only related back to 1, not the other branches.
-    for (PatchSet.Id ps : ImmutableList.of(ps6_1, ps7_1)) {
-      assertRelated(
-          ps,
-          changeAndCommit(ps7_1, c7_1, 1),
-          changeAndCommit(ps6_1, c6_1, 1),
-          changeAndCommit(ps1_1, c1_1, 1));
-    }
-  }
-
-  @Test
-  public void getRelatedEdit() throws Exception {
-    // 1,1---2,1---3,1
-    //   \---2,E---/
-
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    RevCommit c3_1 = commitBuilder().add("c.txt", "3").message("subject: 3").create();
-    pushHead(testRepo, "refs/for/master", false);
-
-    Change ch2 = getChange(c2_1).change();
-    String changeId2 = ch2.getKey().get();
-    gApi.changes().id(changeId2).edit().create();
-    gApi.changes().id(changeId2).edit().modifyFile("a.txt", RawInputUtil.create(new byte[] {'a'}));
-    Optional<EditInfo> edit = getEdit(changeId2);
-    assertThat(edit).isPresent();
-    ObjectId editRev = ObjectId.fromString(edit.get().commit.commit);
-
-    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
-    PatchSet.Id ps2_edit = new PatchSet.Id(ch2.getId(), 0);
-    PatchSet.Id ps3_1 = getPatchSetId(c3_1);
-
-    for (PatchSet.Id ps : ImmutableList.of(ps1_1, ps2_1, ps3_1)) {
-      assertRelated(
-          ps,
-          changeAndCommit(ps3_1, c3_1, 1),
-          changeAndCommit(ps2_1, c2_1, 1),
-          changeAndCommit(ps1_1, c1_1, 1));
-    }
-
-    assertRelated(
-        ps2_edit,
-        changeAndCommit(ps3_1, c3_1, 1),
-        changeAndCommit(new PatchSet.Id(ch2.getId(), 0), editRev, 1),
-        changeAndCommit(ps1_1, c1_1, 1));
-  }
-
-  @Test
-  public void pushNewPatchSetWhenParentHasNullGroup() throws Exception {
-    // 1,1---2,1
-    //   \---2,2
-
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id psId1_1 = getPatchSetId(c1_1);
-    PatchSet.Id psId2_1 = getPatchSetId(c2_1);
-
-    for (PatchSet.Id psId : ImmutableList.of(psId1_1, psId2_1)) {
-      assertRelated(psId, changeAndCommit(psId2_1, c2_1, 1), changeAndCommit(psId1_1, c1_1, 1));
-    }
-
-    // Pretend PS1,1 was pushed before the groups field was added.
-    clearGroups(psId1_1);
-    indexer.index(changeDataFactory.create(db, project, psId1_1.getParentKey()));
-
-    // PS1,1 has no groups, so disappeared from related changes.
-    assertRelated(psId2_1);
-
-    RevCommit c2_2 = testRepo.amend(c2_1).add("c.txt", "2").create();
-    testRepo.reset(c2_2);
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id psId2_2 = getPatchSetId(c2_2);
-
-    // Push updated the group for PS1,1, so it shows up in related changes even
-    // though a new patch set was not pushed.
-    assertRelated(psId2_2, changeAndCommit(psId2_2, c2_2, 2), changeAndCommit(psId1_1, c1_1, 1));
-  }
-
-  @Test
-  @GerritConfig(name = "index.autoReindexIfStale", value = "false")
-  public void getRelatedForStaleChange() throws Exception {
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-
-    RevCommit c2_1 = commitBuilder().add("b.txt", "1").message("subject: 1").create();
-    pushHead(testRepo, "refs/for/master", false);
-
-    RevCommit c2_2 = testRepo.amend(c2_1).add("b.txt", "2").create();
-    testRepo.reset(c2_2);
-
-    disableChangeIndexWrites();
-    try {
-      pushHead(testRepo, "refs/for/master", false);
-    } finally {
-      enableChangeIndexWrites();
-    }
-
-    PatchSet.Id psId1_1 = getPatchSetId(c1_1);
-    PatchSet.Id psId2_1 = getPatchSetId(c2_1);
-    PatchSet.Id psId2_2 = new PatchSet.Id(psId2_1.changeId, psId2_1.get() + 1);
-
-    assertRelated(psId2_2, changeAndCommit(psId2_2, c2_2, 2), changeAndCommit(psId1_1, c1_1, 1));
-  }
-
-  private List<ChangeAndCommit> getRelated(PatchSet.Id ps) throws Exception {
-    return getRelated(ps.getParentKey(), ps.get());
-  }
-
-  private List<ChangeAndCommit> getRelated(Change.Id changeId, int ps) throws Exception {
-    String url = String.format("/changes/%d/revisions/%d/related", changeId.get(), ps);
-    RestResponse r = adminRestSession.get(url);
-    r.assertOK();
-    return newGson().fromJson(r.getReader(), RelatedInfo.class).changes;
-  }
-
-  private RevCommit parseBody(RevCommit c) throws Exception {
-    testRepo.getRevWalk().parseBody(c);
-    return c;
-  }
-
-  private PatchSet.Id getPatchSetId(ObjectId c) throws Exception {
-    return getChange(c).change().currentPatchSetId();
-  }
-
-  private ChangeData getChange(ObjectId c) throws Exception {
-    return Iterables.getOnlyElement(queryProvider.get().byCommit(c));
-  }
-
-  private ChangeAndCommit changeAndCommit(
-      PatchSet.Id psId, ObjectId commitId, int currentRevisionNum) {
-    ChangeAndCommit result = new ChangeAndCommit();
-    result.project = project.get();
-    result._changeNumber = psId.getParentKey().get();
-    result.commit = new CommitInfo();
-    result.commit.commit = commitId.name();
-    result._revisionNumber = psId.get();
-    result._currentRevisionNumber = currentRevisionNum;
-    result.status = "NEW";
-    return result;
-  }
-
-  private void clearGroups(PatchSet.Id psId) throws Exception {
-    try (BatchUpdate bu = batchUpdateFactory.create(db, project, user(user), TimeUtil.nowTs())) {
-      bu.addOp(
-          psId.getParentKey(),
-          new BatchUpdateOp() {
-            @Override
-            public boolean updateChange(ChangeContext ctx) throws OrmException {
-              PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-              psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, ImmutableList.<String>of());
-              ctx.dontBumpLastUpdatedOn();
-              return true;
-            }
-          });
-      bu.execute();
-    }
-  }
-
-  private void assertRelated(PatchSet.Id psId, ChangeAndCommit... expected) throws Exception {
-    List<ChangeAndCommit> actual = getRelated(psId);
-    assertThat(actual).named("related to " + psId).hasSize(expected.length);
-    for (int i = 0; i < actual.size(); i++) {
-      String name = "index " + i + " related to " + psId;
-      ChangeAndCommit a = actual.get(i);
-      ChangeAndCommit e = expected[i];
-      assertThat(a.project).named("project of " + name).isEqualTo(e.project);
-      assertThat(a._changeNumber).named("change ID of " + name).isEqualTo(e._changeNumber);
-      // Don't bother checking changeId; assume _changeNumber is sufficient.
-      assertThat(a._revisionNumber).named("revision of " + name).isEqualTo(e._revisionNumber);
-      assertThat(a.commit.commit).named("commit of " + name).isEqualTo(e.commit.commit);
-      assertThat(a._currentRevisionNumber)
-          .named("current revision of " + name)
-          .isEqualTo(e._currentRevisionNumber);
-    }
-  }
-}
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
deleted file mode 100644
index 49588e7..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ /dev/null
@@ -1,262 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
-import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.FileInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.testutil.ConfigSuite;
-import java.util.EnumSet;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Test;
-
-public class SubmittedTogetherIT extends AbstractDaemonTest {
-  @ConfigSuite.Config
-  public static Config submitWholeTopicEnabled() {
-    return submitWholeTopicEnabledConfig();
-  }
-
-  @Test
-  public void doesNotIncludeCurrentFiles() throws Exception {
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    String id2 = getChangeId(c2_1);
-    pushHead(testRepo, "refs/for/master", false);
-
-    SubmittedTogetherInfo info =
-        gApi.changes().id(id2).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
-    assertThat(info.changes).hasSize(2);
-    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();
-  }
-
-  @Test
-  public void returnsCurrentFilesIfOptionRequested() throws Exception {
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    String id2 = getChangeId(c2_1);
-    pushHead(testRepo, "refs/for/master", false);
-
-    SubmittedTogetherInfo info =
-        gApi.changes()
-            .id(id2)
-            .submittedTogether(
-                EnumSet.of(ListChangesOption.CURRENT_FILES), EnumSet.of(NON_VISIBLE_CHANGES));
-    assertThat(info.changes).hasSize(2);
-    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");
-    assertThat(file).isNotNull();
-    assertThat(file.status).isEqualTo('A');
-  }
-
-  @Test
-  public void returnsAncestors() throws Exception {
-    // Create two commits and push.
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    String id1 = getChangeId(c1_1);
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    String id2 = getChangeId(c2_1);
-    pushHead(testRepo, "refs/for/master", false);
-
-    assertSubmittedTogether(id1);
-    assertSubmittedTogether(id2, id2, id1);
-  }
-
-  @Test
-  public void anonymousAncestors() throws Exception {
-    RevCommit a = commitBuilder().add("a", "1").message("change 1").create();
-    RevCommit b = commitBuilder().add("b", "1").message("change 2").create();
-    pushHead(testRepo, "refs/for/master", false);
-
-    setApiUserAnonymous();
-    assertSubmittedTogether(getChangeId(a));
-    assertSubmittedTogether(getChangeId(b), getChangeId(b), getChangeId(a));
-  }
-
-  @Test
-  public void respectsWholeTopicAndAncestors() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    // Create two independent commits and push.
-    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("connectingTopic"), false);
-
-    if (isSubmitWholeTopicEnabled()) {
-      assertSubmittedTogether(id1, id2, id1);
-      assertSubmittedTogether(id2, id2, id1);
-    } else {
-      assertSubmittedTogether(id1);
-      assertSubmittedTogether(id2);
-    }
-  }
-
-  @Test
-  public void anonymousWholeTopic() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    RevCommit a = commitBuilder().add("a", "1").message("change 1").create();
-    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
-    String id1 = getChangeId(a);
-
-    testRepo.reset(initialHead);
-    RevCommit b = commitBuilder().add("b", "1").message("change 2").create();
-    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
-    String id2 = getChangeId(b);
-
-    setApiUserAnonymous();
-    if (isSubmitWholeTopicEnabled()) {
-      assertSubmittedTogether(id1, id2, id1);
-      assertSubmittedTogether(id2, id2, id1);
-    } else {
-      assertSubmittedTogether(id1);
-      assertSubmittedTogether(id2);
-    }
-  }
-
-  @Test
-  public void 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);
-
-    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("connectingTopic"), false);
-
-    RevCommit c3_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    String id3 = getChangeId(c3_1);
-    pushHead(testRepo, "refs/for/master/" + name("unrelated-topic"), false);
-
-    if (isSubmitWholeTopicEnabled()) {
-      assertSubmittedTogether(id1, id2, id1);
-      assertSubmittedTogether(id2, id2, id1);
-      assertSubmittedTogether(id3, id3, id2, id1);
-    } else {
-      assertSubmittedTogether(id1);
-      assertSubmittedTogether(id2);
-      assertSubmittedTogether(id3, id3, id2);
-    }
-  }
-
-  @Test
-  public void newBranchTwoChangesTogether() throws Exception {
-    Project.NameKey p1 = createProject("a-new-project", null, false);
-    TestRepository<?> repo1 = cloneProject(p1);
-
-    RevCommit c1 =
-        repo1
-            .branch("HEAD")
-            .commit()
-            .insertChangeId()
-            .add("a.txt", "1")
-            .message("subject: 1")
-            .create();
-    String id1 = GitUtil.getChangeId(repo1, c1).get();
-    pushHead(repo1, "refs/for/master", false);
-
-    RevCommit c2 =
-        repo1
-            .branch("HEAD")
-            .commit()
-            .insertChangeId()
-            .add("b.txt", "2")
-            .message("subject: 2")
-            .create();
-    String id2 = GitUtil.getChangeId(repo1, c2).get();
-    pushHead(repo1, "refs/for/master", false);
-    assertSubmittedTogether(id1);
-    assertSubmittedTogether(id2, id2, id1);
-  }
-
-  @Test
-  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
-  public void testCherryPickWithoutAncestors() throws Exception {
-    // Create two commits and push.
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    String id1 = getChangeId(c1_1);
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    String id2 = getChangeId(c2_1);
-    pushHead(testRepo, "refs/for/master", false);
-
-    assertSubmittedTogether(id1);
-    assertSubmittedTogether(id2);
-  }
-
-  @Test
-  public void submissionIdSavedOnMergeInOneProject() throws Exception {
-    // Create two commits and push.
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    String id1 = getChangeId(c1_1);
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    String id2 = getChangeId(c2_1);
-    pushHead(testRepo, "refs/for/master", false);
-
-    assertSubmittedTogether(id1);
-    assertSubmittedTogether(id2, id2, id1);
-
-    approve(id1);
-    approve(id2);
-    submit(id2);
-    assertMerged(id1);
-    assertMerged(id2);
-
-    // Prior to submission this was empty, but the post-merge value is what was
-    // actually submitted.
-    assertSubmittedTogether(id1, id2, id1);
-
-    assertSubmittedTogether(id2, id2, id1);
-  }
-
-  private String getChangeId(RevCommit c) throws Exception {
-    return GitUtil.getChangeId(testRepo, c).get();
-  }
-
-  private void submit(String changeId) throws Exception {
-    gApi.changes().id(changeId).current().submit();
-  }
-
-  private void assertMerged(String changeId) throws Exception {
-    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUILD
deleted file mode 100644
index 3804bea..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUILD
+++ /dev/null
@@ -1,7 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "server_event",
-    labels = ["server"],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
deleted file mode 100644
index 56c55e4..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ /dev/null
@@ -1,259 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.event;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.project.Util.category;
-import static com.google.gerrit.server.project.Util.value;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.events.CommentAddedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.Util;
-import com.google.inject.Inject;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class CommentAddedEventIT extends AbstractDaemonTest {
-
-  @Inject private DynamicSet<CommentAddedListener> source;
-
-  private final LabelType label =
-      category("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
-
-  private final LabelType pLabel =
-      category("CustomLabel2", value(1, "Positive"), value(0, "No score"));
-
-  private RegistrationHandle eventListenerRegistration;
-  private CommentAddedListener.Event lastCommentAddedEvent;
-
-  @Before
-  public void setUp() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-    Util.allow(cfg, Permission.forLabel(label.getName()), -1, 1, anonymousUsers, "refs/heads/*");
-    Util.allow(cfg, Permission.forLabel(pLabel.getName()), 0, 1, anonymousUsers, "refs/heads/*");
-    saveProjectConfig(project, cfg);
-
-    eventListenerRegistration =
-        source.add(
-            new CommentAddedListener() {
-              @Override
-              public void onCommentAdded(Event event) {
-                lastCommentAddedEvent = event;
-              }
-            });
-  }
-
-  @After
-  public void cleanup() {
-    eventListenerRegistration.remove();
-  }
-
-  private void saveLabelConfig() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().put(label.getName(), label);
-    cfg.getLabelSections().put(pLabel.getName(), pLabel);
-    saveProjectConfig(project, cfg);
-  }
-
-  /* Need to lookup info for the label under test since there can be multiple
-   * labels defined.  By default Gerrit already has a Code-Review label.
-   */
-  private ApprovalValues getApprovalValues(LabelType label) {
-    ApprovalValues res = new ApprovalValues();
-    ApprovalInfo info = lastCommentAddedEvent.getApprovals().get(label.getName());
-    if (info != null) {
-      res.value = info.value;
-    }
-    info = lastCommentAddedEvent.getOldApprovals().get(label.getName());
-    if (info != null) {
-      res.oldValue = info.value;
-    }
-    return res;
-  }
-
-  @Test
-  public void newChangeWithVote() throws Exception {
-    saveLabelConfig();
-
-    // push a new change with -1 vote
-    PushOneCommit.Result r = createChange();
-    ReviewInput reviewInput = new ReviewInput().label(label.getName(), (short) -1);
-    revision(r).review(reviewInput);
-    ApprovalValues attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isEqualTo(0);
-    assertThat(attr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
-  }
-
-  @Test
-  public void newPatchSetWithVote() throws Exception {
-    saveLabelConfig();
-
-    // push a new change
-    PushOneCommit.Result r = createChange();
-    ReviewInput reviewInput = new ReviewInput().message(label.getName());
-    revision(r).review(reviewInput);
-
-    // push a new revision with +1 vote
-    ChangeInfo c = get(r.getChangeId());
-    r = amendChange(c.changeId);
-    reviewInput = new ReviewInput().label(label.getName(), (short) 1);
-    revision(r).review(reviewInput);
-    ApprovalValues attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isEqualTo(0);
-    assertThat(attr.value).isEqualTo(1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 2: %s+1", label.getName()));
-  }
-
-  @Test
-  public void reviewChange() throws Exception {
-    saveLabelConfig();
-
-    // push a change
-    PushOneCommit.Result r = createChange();
-
-    // review with message only, do not apply votes
-    ReviewInput reviewInput = new ReviewInput().message(label.getName());
-    revision(r).review(reviewInput);
-    // reply message only so vote is shown as 0
-    ApprovalValues attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isNull();
-    assertThat(attr.value).isEqualTo(0);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1:\n\n%s", label.getName()));
-
-    // transition from un-voted to -1 vote
-    reviewInput = new ReviewInput().label(label.getName(), -1);
-    revision(r).review(reviewInput);
-    attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isEqualTo(0);
-    assertThat(attr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
-
-    // transition vote from -1 to 0
-    reviewInput = new ReviewInput().label(label.getName(), 0);
-    revision(r).review(reviewInput);
-    attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isEqualTo(-1);
-    assertThat(attr.value).isEqualTo(0);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1: -%s", label.getName()));
-
-    // transition vote from 0 to 1
-    reviewInput = new ReviewInput().label(label.getName(), 1);
-    revision(r).review(reviewInput);
-    attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isEqualTo(0);
-    assertThat(attr.value).isEqualTo(1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1: %s+1", label.getName()));
-
-    // transition vote from 1 to -1
-    reviewInput = new ReviewInput().label(label.getName(), -1);
-    revision(r).review(reviewInput);
-    attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isEqualTo(1);
-    assertThat(attr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
-
-    // review with message only, do not apply votes
-    reviewInput = new ReviewInput().message(label.getName());
-    revision(r).review(reviewInput);
-    attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isNull(); // no vote change so not included
-    assertThat(attr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1:\n\n%s", label.getName()));
-  }
-
-  @Test
-  public void reviewChange_MultipleVotes() throws Exception {
-    saveLabelConfig();
-    PushOneCommit.Result r = createChange();
-    ReviewInput reviewInput = new ReviewInput().label(label.getName(), -1);
-    reviewInput.message = label.getName();
-    revision(r).review(reviewInput);
-
-    ChangeInfo c = get(r.getChangeId());
-    LabelInfo q = c.labels.get(label.getName());
-    assertThat(q.all).hasSize(1);
-    ApprovalValues labelAttr = getApprovalValues(label);
-    assertThat(labelAttr.oldValue).isEqualTo(0);
-    assertThat(labelAttr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1: %s-1\n\n%s", label.getName(), label.getName()));
-
-    // there should be 3 approval labels (label, pLabel, and CRVV)
-    assertThat(lastCommentAddedEvent.getApprovals()).hasSize(3);
-
-    // check the approvals that were not voted on
-    ApprovalValues pLabelAttr = getApprovalValues(pLabel);
-    assertThat(pLabelAttr.oldValue).isNull();
-    assertThat(pLabelAttr.value).isEqualTo(0);
-
-    LabelType crLabel = LabelType.withDefaultValues("Code-Review");
-    ApprovalValues crlAttr = getApprovalValues(crLabel);
-    assertThat(crlAttr.oldValue).isNull();
-    assertThat(crlAttr.value).isEqualTo(0);
-
-    // update pLabel approval
-    reviewInput = new ReviewInput().label(pLabel.getName(), 1);
-    reviewInput.message = pLabel.getName();
-    revision(r).review(reviewInput);
-
-    c = get(r.getChangeId());
-    q = c.labels.get(label.getName());
-    assertThat(q.all).hasSize(1);
-    pLabelAttr = getApprovalValues(pLabel);
-    assertThat(pLabelAttr.oldValue).isEqualTo(0);
-    assertThat(pLabelAttr.value).isEqualTo(1);
-    assertThat(lastCommentAddedEvent.getComment())
-        .isEqualTo(String.format("Patch Set 1: %s+1\n\n%s", pLabel.getName(), pLabel.getName()));
-
-    // check the approvals that were not voted on
-    labelAttr = getApprovalValues(label);
-    assertThat(labelAttr.oldValue).isNull();
-    assertThat(labelAttr.value).isEqualTo(-1);
-
-    crlAttr = getApprovalValues(crLabel);
-    assertThat(crlAttr.oldValue).isNull();
-    assertThat(crlAttr.value).isEqualTo(0);
-  }
-
-  private static class ApprovalValues {
-    Integer value;
-    Integer oldValue;
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
deleted file mode 100644
index 6f4bdab..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
+++ /dev/null
@@ -1,147 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.mail;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
-import com.google.gerrit.extensions.client.Comment;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.server.mail.receive.MailMessage;
-import java.util.HashMap;
-import org.joda.time.DateTime;
-import org.junit.Ignore;
-
-@Ignore
-public class AbstractMailIT extends AbstractDaemonTest {
-
-  protected MailMessage.Builder messageBuilderWithDefaultFields() {
-    MailMessage.Builder b = MailMessage.builder();
-    b.id("some id");
-    b.from(user.emailAddress);
-    b.addTo(user.emailAddress); // Not evaluated
-    b.subject("");
-    b.dateReceived(new DateTime());
-    return b;
-  }
-
-  protected String createChangeWithReview() throws Exception {
-    return createChangeWithReview(admin);
-  }
-
-  protected String createChangeWithReview(TestAccount reviewer) throws Exception {
-    // Create change
-    String file = "gerrit-server/test.txt";
-    String contents = "contents \nlorem \nipsum \nlorem";
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
-    PushOneCommit.Result r = push.to("refs/for/master");
-    String changeId = r.getChangeId();
-
-    // Review it
-    setApiUser(reviewer);
-    ReviewInput input = new ReviewInput();
-    input.message = "I have two comments";
-    input.comments = new HashMap<>();
-    CommentInput c1 = newComment(file, Side.REVISION, 0, "comment on file");
-    CommentInput c2 = newComment(file, Side.REVISION, 2, "inline comment");
-    input.comments.put(c1.path, ImmutableList.of(c1, c2));
-    revision(r).review(input);
-    return changeId;
-  }
-
-  protected static CommentInput newComment(String path, Side side, int line, String message) {
-    CommentInput c = new CommentInput();
-    c.path = path;
-    c.side = side;
-    c.line = line != 0 ? line : null;
-    c.message = message;
-    if (line != 0) {
-      Comment.Range range = new Comment.Range();
-      range.startLine = line;
-      range.startCharacter = 1;
-      range.endLine = line;
-      range.endCharacter = 5;
-      c.range = range;
-    }
-    return c;
-  }
-
-  /**
-   * Create a plaintext message body with the specified comments.
-   *
-   * @param changeMessage
-   * @param c1 Comment in reply to first inline comment.
-   * @param f1 Comment on file one.
-   * @param fc1 Comment in reply to a comment of file 1.
-   * @return A string with all inline comments and the original quoted email.
-   */
-  protected static String newPlaintextBody(
-      String changeURL, String changeMessage, String c1, String f1, String fc1) {
-    return (changeMessage == null ? "" : changeMessage + "\n")
-        + "> Foo Bar has posted comments on this change. (  \n"
-        + "> "
-        + changeURL
-        + " )\n"
-        + "> \n"
-        + "> Change subject: Test change\n"
-        + "> ...............................................................\n"
-        + "> \n"
-        + "> \n"
-        + "> Patch Set 1: Code-Review+1\n"
-        + "> \n"
-        + "> (3 comments)\n"
-        + "> \n"
-        + "> "
-        + changeURL
-        + "/gerrit-server/test.txt\n"
-        + "> File  \n"
-        + "> gerrit-server/test.txt:\n"
-        + (f1 == null ? "" : f1 + "\n")
-        + "> \n"
-        + "> Patch Set #4:\n"
-        + "> "
-        + changeURL
-        + "/gerrit-server/test.txt\n"
-        + "> \n"
-        + "> Some comment"
-        + "> \n"
-        + (fc1 == null ? "" : fc1 + "\n")
-        + "> "
-        + changeURL
-        + "/gerrit-server/test.txt@2\n"
-        + "> PS1, Line 2: throw new Exception(\"Object has unsupported: \" +\n"
-        + ">               :             entry.getValue() +\n"
-        + ">               :             \" must be java.util.Date\");\n"
-        + "> Should entry.getKey() be included in this message?\n"
-        + "> \n"
-        + (c1 == null ? "" : c1 + "\n")
-        + "> \n";
-  }
-
-  protected static String textFooterForChange(int changeNumber, String timestamp) {
-    return "Gerrit-Change-Number: "
-        + changeNumber
-        + "\n"
-        + "Gerrit-PatchSet: 1\n"
-        + "Gerrit-MessageType: comment\n"
-        + "Gerrit-Comment-Date: "
-        + timestamp
-        + "\n";
-  }
-}
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
deleted file mode 100644
index 71a6135..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD
+++ /dev/null
@@ -1,24 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-DEPS = [
-    "//lib/greenmail",
-    "//lib/joda:joda-time",
-    "//lib/mail",
-]
-
-acceptance_tests(
-    srcs = glob(
-        ["*IT.java"],
-        exclude = ["AbstractMailIT.java"],
-    ),
-    group = "server_mail",
-    labels = ["server"],
-    deps = [":util"] + DEPS,
-)
-
-java_library(
-    name = "util",
-    testonly = 1,
-    srcs = ["AbstractMailIT.java"],
-    deps = ["//gerrit-acceptance-tests:lib"] + DEPS,
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
deleted file mode 100644
index 8aaac5a..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ /dev/null
@@ -1,2577 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.mail;
-
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.extensions.api.changes.NotifyHandling.ALL;
-import static com.google.gerrit.extensions.api.changes.NotifyHandling.NONE;
-import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER;
-import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER_REVIEWERS;
-import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
-import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.ENABLED;
-import static com.google.gerrit.server.account.WatchConfig.NotifyType.ABANDONED_CHANGES;
-import static com.google.gerrit.server.account.WatchConfig.NotifyType.ALL_COMMENTS;
-import static com.google.gerrit.server.account.WatchConfig.NotifyType.NEW_CHANGES;
-import static com.google.gerrit.server.account.WatchConfig.NotifyType.NEW_PATCHSETS;
-import static com.google.gerrit.server.account.WatchConfig.NotifyType.SUBMITTED_CHANGES;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.truth.Truth;
-import com.google.gerrit.acceptance.AbstractNotificationTest;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.AbandonInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
-import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
-import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.server.change.PostReview;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.Util;
-import org.junit.Before;
-import org.junit.Test;
-
-public class ChangeNotificationsIT extends AbstractNotificationTest {
-  /*
-   * Set up for extra standard test accounts and permissions.
-   */
-  private TestAccount other;
-  private TestAccount extraReviewer;
-  private TestAccount extraCcer;
-
-  @Before
-  public void createExtraAccounts() throws Exception {
-    extraReviewer =
-        accountCreator.create("extraReviewer", "extraReviewer@example.com", "extraReviewer");
-    extraCcer = accountCreator.create("extraCcer", "extraCcer@example.com", "extraCcer");
-    other = accountCreator.create("other", "other@example.com", "other");
-  }
-
-  @Before
-  public void grantPermissions() throws Exception {
-    grant(project, "refs/*", Permission.FORGE_COMMITTER, false, REGISTERED_USERS);
-    grant(project, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
-    grant(project, "refs/heads/master", Permission.ABANDON, false, REGISTERED_USERS);
-    ProjectConfig cfg = projectCache.get(project).getConfig();
-    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
-  }
-
-  /*
-   * AbandonedSender tests.
-   */
-
-  @Test
-  public void abandonReviewableChangeByOwner() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    abandon(sc.changeId, sc.owner);
-    assertThat(sender)
-        .sent("abandon", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ABANDONED_CHANGES)
-        .noOneElse();
-  }
-
-  @Test
-  public void abandonReviewableChangeByOwnerCcingSelf() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    abandon(sc.changeId, sc.owner, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("abandon", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ABANDONED_CHANGES)
-        .noOneElse();
-  }
-
-  @Test
-  public void abandonReviewableChangeByOther() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
-    abandon(sc.changeId, other);
-    assertThat(sender)
-        .sent("abandon", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ABANDONED_CHANGES)
-        .noOneElse();
-  }
-
-  @Test
-  public void abandonReviewableChangeByOtherCcingSelf() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
-    abandon(sc.changeId, other, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("abandon", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer, other)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ABANDONED_CHANGES)
-        .noOneElse();
-  }
-
-  @Test
-  public void abandonReviewableChangeNotifyOwnersReviewers() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    abandon(sc.changeId, sc.owner, OWNER_REVIEWERS);
-    assertThat(sender)
-        .sent("abandon", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void abandonReviewableChangeNotifyOwner() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    abandon(sc.changeId, sc.owner, OWNER);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void abandonReviewableChangeNotifyOwnerCcingSelf() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    abandon(sc.changeId, sc.owner, CC_ON_OWN_COMMENTS, OWNER);
-    // Self-CC applies *after* need for sending notification is determined.
-    // Since there are no recipients before including the user taking action,
-    // there should no notification sent.
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void abandonReviewableChangeByOtherCcingSelfNotifyOwner() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
-    abandon(sc.changeId, other, CC_ON_OWN_COMMENTS, OWNER);
-    assertThat(sender).sent("abandon", sc).to(sc.owner).cc(other).noOneElse();
-  }
-
-  @Test
-  public void abandonReviewableChangeNotifyNone() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    abandon(sc.changeId, sc.owner, NONE);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void abandonReviewableChangeNotifyNoneCcingSelf() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    abandon(sc.changeId, sc.owner, CC_ON_OWN_COMMENTS, NONE);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void abandonReviewableWipChange() throws Exception {
-    StagedChange sc = stageReviewableWipChange();
-    abandon(sc.changeId, sc.owner);
-    assertThat(sender)
-        .sent("abandon", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ABANDONED_CHANGES)
-        .noOneElse();
-  }
-
-  @Test
-  public void abandonWipChange() throws Exception {
-    StagedChange sc = stageWipChange();
-    abandon(sc.changeId, sc.owner);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void abandonWipChangeNotifyAll() throws Exception {
-    StagedChange sc = stageWipChange();
-    abandon(sc.changeId, sc.owner, ALL);
-    assertThat(sender)
-        .sent("abandon", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ABANDONED_CHANGES)
-        .noOneElse();
-  }
-
-  private void abandon(String changeId, TestAccount by) throws Exception {
-    abandon(changeId, by, ENABLED);
-  }
-
-  private void abandon(String changeId, TestAccount by, EmailStrategy emailStrategy)
-      throws Exception {
-    abandon(changeId, by, emailStrategy, null);
-  }
-
-  private void abandon(String changeId, TestAccount by, @Nullable NotifyHandling notify)
-      throws Exception {
-    abandon(changeId, by, ENABLED, notify);
-  }
-
-  private void abandon(
-      String changeId, TestAccount by, EmailStrategy emailStrategy, @Nullable NotifyHandling notify)
-      throws Exception {
-    setEmailStrategy(by, emailStrategy);
-    setApiUser(by);
-    AbandonInput in = new AbandonInput();
-    if (notify != null) {
-      in.notify = notify;
-    }
-    gApi.changes().id(changeId).abandon(in);
-  }
-
-  /*
-   * AddReviewerSender tests.
-   */
-
-  private void addReviewerToReviewableChangeInReviewDb(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeInReviewDbSingly() throws Exception {
-    addReviewerToReviewableChangeInReviewDb(singly());
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeInReviewDbBatch() throws Exception {
-    addReviewerToReviewableChangeInReviewDb(batch());
-  }
-
-  private void addReviewerToReviewableChangeInNoteDb(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
-    // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(sc.reviewer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeInNoteDbSingly() throws Exception {
-    addReviewerToReviewableChangeInNoteDb(singly());
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeInNoteDbBatch() throws Exception {
-    addReviewerToReviewableChangeInNoteDb(batch());
-  }
-
-  private void addReviewerToReviewableChangeByOwnerCcingSelfInNoteDb(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, CC_ON_OWN_COMMENTS, null);
-    // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(sc.owner, sc.reviewer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeByOwnerCcingSelfInNoteDbSingly() throws Exception {
-    addReviewerToReviewableChangeByOwnerCcingSelfInNoteDb(singly());
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeByOwnerCcingSelfInNoteDbBatch() throws Exception {
-    addReviewerToReviewableChangeByOwnerCcingSelfInNoteDb(batch());
-  }
-
-  private void addReviewerToReviewableChangeByOtherInNoteDb(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
-    StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, other, reviewer.email);
-    // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(sc.owner, sc.reviewer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeByOtherInNoteDbSingly() throws Exception {
-    addReviewerToReviewableChangeByOtherInNoteDb(singly());
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeByOtherInNoteDbBatch() throws Exception {
-    addReviewerToReviewableChangeByOtherInNoteDb(batch());
-  }
-
-  private void addReviewerToReviewableChangeByOtherCcingSelfInNoteDb(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
-    StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, other, reviewer.email, CC_ON_OWN_COMMENTS, null);
-    // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(sc.owner, sc.reviewer, other)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeByOtherCcingSelfInNoteDbSingly() throws Exception {
-    addReviewerToReviewableChangeByOtherCcingSelfInNoteDb(singly());
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeByOtherCcingSelfInNoteDbBatch() throws Exception {
-    addReviewerToReviewableChangeByOtherCcingSelfInNoteDb(batch());
-  }
-
-  private void addReviewerByEmailToReviewableChangeInReviewDb(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    String email = "addedbyemail@example.com";
-    StagedChange sc = stageReviewableChange();
-    addReviewer(adder, sc.changeId, sc.owner, email);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void addReviewerByEmailToReviewableChangeInReviewDbSingly() throws Exception {
-    addReviewerByEmailToReviewableChangeInReviewDb(singly());
-  }
-
-  @Test
-  public void addReviewerByEmailToReviewableChangeInReviewDbBatch() throws Exception {
-    addReviewerByEmailToReviewableChangeInReviewDb(batch());
-  }
-
-  private void addReviewerByEmailToReviewableChangeInNoteDb(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    String email = "addedbyemail@example.com";
-    StagedChange sc = stageReviewableChange();
-    addReviewer(adder, sc.changeId, sc.owner, email);
-    // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(email)
-        .cc(sc.reviewer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void addReviewerByEmailToReviewableChangeInNoteDbSingly() throws Exception {
-    addReviewerByEmailToReviewableChangeInNoteDb(singly());
-  }
-
-  @Test
-  public void addReviewerByEmailToReviewableChangeInNoteDbBatch() throws Exception {
-    addReviewerByEmailToReviewableChangeInNoteDb(batch());
-  }
-
-  private void addReviewerToWipChange(Adder adder) throws Exception {
-    StagedChange sc = stageWipChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void addReviewerToWipChangeSingly() throws Exception {
-    addReviewerToWipChange(singly());
-  }
-
-  @Test
-  public void addReviewerToWipChangeBatch() throws Exception {
-    addReviewerToWipChange(batch());
-  }
-
-  private void addReviewerToReviewableWipChange(Adder adder) throws Exception {
-    StagedChange sc = stageReviewableWipChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void addReviewerToReviewableWipChangeSingly() throws Exception {
-    addReviewerToReviewableWipChange(singly());
-  }
-
-  @Test
-  public void addReviewerToReviewableWipChangeBatch() throws Exception {
-    addReviewerToReviewableWipChange(batch());
-  }
-
-  private void addReviewerToWipChangeInNoteDbNotifyAll(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageWipChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, NotifyHandling.ALL);
-    // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(sc.reviewer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void addReviewerToWipChangeInNoteDbNotifyAllSingly() throws Exception {
-    addReviewerToWipChangeInNoteDbNotifyAll(singly());
-  }
-
-  @Test
-  public void addReviewerToWipChangeInNoteDbNotifyAllBatch() throws Exception {
-    addReviewerToWipChangeInNoteDbNotifyAll(batch());
-  }
-
-  private void addReviewerToWipChangeInReviewDbNotifyAll(Adder adder) throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageWipChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, NotifyHandling.ALL);
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void addReviewerToWipChangeInReviewDbNotifyAllSingly() throws Exception {
-    addReviewerToWipChangeInReviewDbNotifyAll(singly());
-  }
-
-  @Test
-  public void addReviewerToWipChangeInReviewDbNotifyAllBatch() throws Exception {
-    addReviewerToWipChangeInReviewDbNotifyAll(batch());
-  }
-
-  private void addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewers(Adder adder)
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, OWNER_REVIEWERS);
-    // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(sc.reviewer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewersSingly() throws Exception {
-    addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewers(singly());
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewersBatch() throws Exception {
-    addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewers(batch());
-  }
-
-  private void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwner(Adder adder)
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, CC_ON_OWN_COMMENTS, OWNER);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwnerSingly()
-      throws Exception {
-    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwner(singly());
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwnerBatch()
-      throws Exception {
-    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwner(batch());
-  }
-
-  private void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNone(Adder adder)
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
-    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, CC_ON_OWN_COMMENTS, NONE);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNoneSingly()
-      throws Exception {
-    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNone(singly());
-  }
-
-  @Test
-  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNoneBatch()
-      throws Exception {
-    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNone(batch());
-  }
-
-  private interface Adder {
-    void addReviewer(String changeId, String reviewer, @Nullable NotifyHandling notify)
-        throws Exception;
-  }
-
-  private Adder singly() {
-    return (String changeId, String reviewer, @Nullable NotifyHandling notify) -> {
-      AddReviewerInput in = new AddReviewerInput();
-      in.reviewer = reviewer;
-      if (notify != null) {
-        in.notify = notify;
-      }
-      gApi.changes().id(changeId).addReviewer(in);
-    };
-  }
-
-  private Adder batch() {
-    return (String changeId, String reviewer, @Nullable NotifyHandling notify) -> {
-      ReviewInput in = ReviewInput.noScore();
-      in.reviewer(reviewer);
-      if (notify != null) {
-        in.notify = notify;
-      }
-      gApi.changes().id(changeId).revision("current").review(in);
-    };
-  }
-
-  private void addReviewer(Adder adder, String changeId, TestAccount by, String reviewer)
-      throws Exception {
-    addReviewer(adder, changeId, by, reviewer, ENABLED, null);
-  }
-
-  private void addReviewer(
-      Adder adder, String changeId, TestAccount by, String reviewer, NotifyHandling notify)
-      throws Exception {
-    addReviewer(adder, changeId, by, reviewer, ENABLED, notify);
-  }
-
-  private void addReviewer(
-      Adder adder,
-      String changeId,
-      TestAccount by,
-      String reviewer,
-      EmailStrategy emailStrategy,
-      @Nullable NotifyHandling notify)
-      throws Exception {
-    setEmailStrategy(by, emailStrategy);
-    setApiUser(by);
-    adder.addReviewer(changeId, reviewer, notify);
-  }
-
-  /*
-   * CommentSender tests.
-   */
-
-  @Test
-  public void commentOnReviewableChangeByOwner() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    review(sc.owner, sc.changeId, ENABLED);
-    assertThat(sender)
-        .sent("comment", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void commentOnReviewableChangeByReviewer() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    review(sc.reviewer, sc.changeId, ENABLED);
-    assertThat(sender)
-        .sent("comment", sc)
-        .to(sc.owner)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void commentOnReviewableChangeByOwnerCcingSelf() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    review(sc.owner, sc.changeId, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("comment", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void commentOnReviewableChangeByReviewerCcingSelf() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    review(sc.reviewer, sc.changeId, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("comment", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void commentOnReviewableChangeByOther() throws Exception {
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
-    StagedChange sc = stageReviewableChange();
-    review(other, sc.changeId, ENABLED);
-    assertThat(sender)
-        .sent("comment", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void commentOnReviewableChangeByOtherCcingSelf() throws Exception {
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
-    StagedChange sc = stageReviewableChange();
-    review(other, sc.changeId, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("comment", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer, other)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void commentOnReviewableChangeByOwnerNotifyOwnerReviewers() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    review(sc.owner, sc.changeId, ENABLED, OWNER_REVIEWERS);
-    assertThat(sender)
-        .sent("comment", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void commentOnReviewableChangeByOwnerNotifyOwner() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    review(sc.owner, sc.changeId, ENABLED, OWNER);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void commentOnReviewableChangeByOwnerCcingSelfNotifyOwner() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
-    review(sc.owner, sc.changeId, ENABLED, OWNER);
-    assertThat(sender).notSent(); // TODO(logan): Why not send to owner?
-  }
-
-  @Test
-  public void commentOnReviewableChangeByOwnerNotifyNone() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    review(sc.owner, sc.changeId, ENABLED, NONE);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void commentOnReviewableChangeByOwnerCcingSelfNotifyNone() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
-    review(sc.owner, sc.changeId, ENABLED, NONE);
-    assertThat(sender).notSent(); // TODO(logan): Why not send to owner?
-  }
-
-  @Test
-  public void commentOnReviewableChangeByBot() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    TestAccount bot = sc.testAccount("bot");
-    review(bot, sc.changeId, ENABLED, null, "autogenerated:bot");
-    assertThat(sender)
-        .sent("comment", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void commentOnWipChangeByOwner() throws Exception {
-    StagedChange sc = stageWipChange();
-    review(sc.owner, sc.changeId, ENABLED);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void commentOnWipChangeByOwnerCcingSelf() throws Exception {
-    StagedChange sc = stageWipChange();
-    review(sc.owner, sc.changeId, CC_ON_OWN_COMMENTS);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void commentOnWipChangeByOwnerNotifyAll() throws Exception {
-    StagedChange sc = stageWipChange();
-    review(sc.owner, sc.changeId, ENABLED, ALL);
-    assertThat(sender)
-        .sent("comment", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void commentOnWipChangeByBot() throws Exception {
-    StagedChange sc = stageWipChange();
-    TestAccount bot = sc.testAccount("bot");
-    review(bot, sc.changeId, ENABLED, null, "autogenerated:tag");
-    assertThat(sender).sent("comment", sc).to(sc.owner).noOneElse();
-  }
-
-  @Test
-  public void commentOnReviewableWipChangeByBot() throws Exception {
-    StagedChange sc = stageReviewableWipChange();
-    TestAccount bot = sc.testAccount("bot");
-    review(bot, sc.changeId, ENABLED, null, "autogenerated:tag");
-    assertThat(sender).sent("comment", sc).to(sc.owner).noOneElse();
-  }
-
-  @Test
-  public void commentOnReviewableWipChangeByBotNotifyAll() throws Exception {
-    StagedChange sc = stageWipChange();
-    TestAccount bot = sc.testAccount("bot");
-    review(bot, sc.changeId, ENABLED, ALL, "tag");
-    assertThat(sender)
-        .sent("comment", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void commentOnReviewableWipChangeByOwner() throws Exception {
-    StagedChange sc = stageReviewableWipChange();
-    review(sc.owner, sc.changeId, ENABLED);
-    assertThat(sender)
-        .sent("comment", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void noCommentAndSetWorkInProgress() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void commentAndSetWorkInProgress() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    ReviewInput in = ReviewInput.noScore().message("ok").setWorkInProgress(true);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
-    assertThat(sender)
-        .sent("comment", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void commentOnWipChangeAndStartReview() throws Exception {
-    StagedChange sc = stageWipChange();
-    ReviewInput in = ReviewInput.noScore().message("ok").setWorkInProgress(false);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
-    assertThat(sender)
-        .sent("comment", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void addReviewerOnWipChangeAndStartReview() throws Exception {
-    StagedChange sc = stageWipChange();
-    ReviewInput in = ReviewInput.noScore().reviewer(other.email).setWorkInProgress(false);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
-    assertThat(sender)
-        .sent("comment", sc)
-        .cc(sc.reviewer, sc.ccer, other)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void startReviewMessageNotRepeated() throws Exception {
-    // TODO(logan): Remove this test check once PolyGerrit workaround is rolled back.
-    StagedChange sc = stageWipChange();
-    ReviewInput in =
-        ReviewInput.noScore().message(PostReview.START_REVIEW_MESSAGE).setWorkInProgress(false);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
-    Truth.assertThat(sender.getMessages()).isNotEmpty();
-    String body = sender.getMessages().get(0).body();
-    int idx = body.indexOf(PostReview.START_REVIEW_MESSAGE);
-    Truth.assertThat(idx).isAtLeast(0);
-    Truth.assertThat(body.indexOf(PostReview.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
-  }
-
-  private void review(TestAccount account, String changeId, EmailStrategy strategy)
-      throws Exception {
-    review(account, changeId, strategy, null);
-  }
-
-  private void review(
-      TestAccount account, String changeId, EmailStrategy strategy, @Nullable NotifyHandling notify)
-      throws Exception {
-    review(account, changeId, strategy, notify, null);
-  }
-
-  private void review(
-      TestAccount account,
-      String changeId,
-      EmailStrategy strategy,
-      @Nullable NotifyHandling notify,
-      @Nullable String tag)
-      throws Exception {
-    setEmailStrategy(account, strategy);
-    ReviewInput in = ReviewInput.recommend();
-    in.notify = notify;
-    in.tag = tag;
-    gApi.changes().id(changeId).revision("current").review(in);
-  }
-
-  /*
-   * CreateChangeSender tests.
-   */
-
-  @Test
-  public void createReviewableChange() throws Exception {
-    StagedPreChange spc = stagePreChange("refs/for/master");
-    assertThat(sender)
-        .sent("newchange", spc)
-        .to(spc.watchingProjectOwner)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void createWipChange() throws Exception {
-    stagePreChange("refs/for/master%wip");
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void createReviewableChangeWithNotifyOwnerReviewers() throws Exception {
-    stagePreChange("refs/for/master%notify=OWNER_REVIEWERS");
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void createReviewableChangeWithNotifyOwner() throws Exception {
-    stagePreChange("refs/for/master%notify=OWNER");
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void createReviewableChangeWithNotifyNone() throws Exception {
-    stagePreChange("refs/for/master%notify=OWNER");
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void createWipChangeWithNotifyAll() throws Exception {
-    StagedPreChange spc = stagePreChange("refs/for/master%wip,notify=ALL");
-    assertThat(sender)
-        .sent("newchange", spc)
-        .to(spc.watchingProjectOwner)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void createReviewableChangeWithReviewersAndCcs() throws Exception {
-    // TODO(logan): Support reviewers/CCs-by-email via push option.
-    StagedPreChange spc =
-        stagePreChange(
-            "refs/for/master",
-            users -> ImmutableList.of("r=" + users.reviewer.username, "cc=" + users.ccer.username));
-    assertThat(sender)
-        .sent("newchange", spc)
-        .to(spc.reviewer, spc.watchingProjectOwner)
-        .cc(spc.ccer)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  /*
-   * DeleteReviewerSender tests.
-   */
-
-  @Test
-  public void deleteReviewerFromReviewableChange() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    setApiUser(sc.owner);
-    removeReviewer(sc, extraReviewer);
-    assertThat(sender)
-        .sent("deleteReviewer", sc)
-        .to(extraReviewer)
-        .cc(extraCcer, sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void deleteReviewerFromReviewableChangeByOwnerCcingSelf() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    setEmailStrategy(sc.owner, EmailStrategy.CC_ON_OWN_COMMENTS);
-    removeReviewer(sc, extraReviewer);
-    assertThat(sender)
-        .sent("deleteReviewer", sc)
-        .to(sc.owner, extraReviewer)
-        .cc(extraCcer, sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void deleteReviewerFromReviewableChangeByAdmin() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    setApiUser(admin);
-    removeReviewer(sc, extraReviewer);
-    assertThat(sender)
-        .sent("deleteReviewer", sc)
-        .to(sc.owner, extraReviewer)
-        .cc(extraCcer, sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void deleteReviewerFromReviewableChangeByAdminCcingSelf() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    setEmailStrategy(admin, EmailStrategy.CC_ON_OWN_COMMENTS);
-    setApiUser(admin);
-    removeReviewer(sc, extraReviewer);
-    assertThat(sender)
-        .sent("deleteReviewer", sc)
-        .to(sc.owner, extraReviewer)
-        .cc(admin, extraCcer, sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void deleteCcerFromReviewableChange() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    setApiUser(sc.owner);
-    removeReviewer(sc, extraCcer);
-    assertThat(sender)
-        .sent("deleteReviewer", sc)
-        .to(extraCcer)
-        .cc(sc.reviewer, sc.ccer, extraReviewer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void deleteReviewerFromReviewableChangeNotifyOwnerReviewers() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    setApiUser(sc.owner);
-    removeReviewer(sc, extraReviewer, NotifyHandling.OWNER_REVIEWERS);
-    assertThat(sender)
-        .sent("deleteReviewer", sc)
-        .to(extraReviewer)
-        .cc(extraCcer, sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void deleteReviewerFromReviewableChangeNotifyOwner() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    removeReviewer(sc, extraReviewer, NotifyHandling.OWNER);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void deleteReviewerFromReviewableChangeByOwnerCcingSelfNotifyOwner() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    setEmailStrategy(sc.owner, EmailStrategy.CC_ON_OWN_COMMENTS);
-    removeReviewer(sc, extraReviewer, NotifyHandling.OWNER);
-    assertThat(sender).sent("deleteReviewer", sc).to(sc.owner, extraReviewer).noOneElse();
-  }
-
-  @Test
-  public void deleteReviewerFromReviewableChangeNotifyNone() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    removeReviewer(sc, extraReviewer, NotifyHandling.NONE);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void deleteReviewerFromReviewableChangeByOwnerCcingSelfNotifyNone() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    setEmailStrategy(sc.owner, EmailStrategy.CC_ON_OWN_COMMENTS);
-    removeReviewer(sc, extraReviewer, NotifyHandling.NONE);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void deleteReviewerFromReviewableWipChange() throws Exception {
-    StagedChange sc = stageReviewableWipChangeWithExtraReviewer();
-    removeReviewer(sc, extraReviewer);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void deleteReviewerFromWipChange() throws Exception {
-    StagedChange sc = stageWipChangeWithExtraReviewer();
-    removeReviewer(sc, extraReviewer);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void deleteReviewerFromWipChangeNotifyAll() throws Exception {
-    StagedChange sc = stageWipChangeWithExtraReviewer();
-    setApiUser(sc.owner);
-    removeReviewer(sc, extraReviewer, NotifyHandling.ALL);
-    assertThat(sender)
-        .sent("deleteReviewer", sc)
-        .to(extraReviewer)
-        .cc(extraCcer, sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void deleteReviewerWithApprovalFromWipChange() throws Exception {
-    StagedChange sc = stageWipChangeWithExtraReviewer();
-    recommend(sc, extraReviewer);
-    setApiUser(sc.owner);
-    removeReviewer(sc, extraReviewer);
-    assertThat(sender).sent("deleteReviewer", sc).to(extraReviewer).noOneElse();
-  }
-
-  @Test
-  public void deleteReviewerWithApprovalFromWipChangeNotifyOwner() throws Exception {
-    StagedChange sc = stageWipChangeWithExtraReviewer();
-    recommend(sc, extraReviewer);
-    removeReviewer(sc, extraReviewer, NotifyHandling.OWNER);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void deleteReviewerByEmailFromWipChangeInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageWipChangeWithExtraReviewer();
-    gApi.changes().id(sc.changeId).reviewer(sc.reviewerByEmail).remove();
-    assertThat(sender).notSent();
-  }
-
-  private void recommend(StagedChange sc, TestAccount by) throws Exception {
-    setApiUser(by);
-    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.recommend());
-  }
-
-  private interface Stager {
-    StagedChange stage() throws Exception;
-  }
-
-  private StagedChange stageChangeWithExtraReviewer(Stager stager) throws Exception {
-    StagedChange sc = stager.stage();
-    ReviewInput in =
-        ReviewInput.noScore()
-            .reviewer(extraReviewer.email)
-            .reviewer(extraCcer.email, ReviewerState.CC, false);
-    setApiUser(extraReviewer);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
-    return sc;
-  }
-
-  private StagedChange stageReviewableChangeWithExtraReviewer() throws Exception {
-    return stageChangeWithExtraReviewer(this::stageReviewableChange);
-  }
-
-  private StagedChange stageReviewableWipChangeWithExtraReviewer() throws Exception {
-    return stageChangeWithExtraReviewer(this::stageReviewableWipChange);
-  }
-
-  private StagedChange stageWipChangeWithExtraReviewer() throws Exception {
-    return stageChangeWithExtraReviewer(this::stageWipChange);
-  }
-
-  private void removeReviewer(StagedChange sc, TestAccount account) throws Exception {
-    sender.clear();
-    gApi.changes().id(sc.changeId).reviewer(account.email).remove();
-  }
-
-  private void removeReviewer(StagedChange sc, TestAccount account, NotifyHandling notify)
-      throws Exception {
-    sender.clear();
-    DeleteReviewerInput in = new DeleteReviewerInput();
-    in.notify = notify;
-    gApi.changes().id(sc.changeId).reviewer(account.email).remove(in);
-  }
-
-  /*
-   * DeleteVoteSender tests.
-   */
-
-  @Test
-  public void deleteVoteFromReviewableChange() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    recommend(sc, extraReviewer);
-    setApiUser(sc.owner);
-    deleteVote(sc, extraReviewer);
-    assertThat(sender)
-        .sent("deleteVote", sc)
-        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void deleteVoteFromReviewableChangeWithSelfCc() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    recommend(sc, extraReviewer);
-    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
-    setApiUser(sc.owner);
-    deleteVote(sc, extraReviewer);
-    assertThat(sender)
-        .sent("deleteVote", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void deleteVoteFromReviewableChangeByAdmin() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    recommend(sc, extraReviewer);
-    setApiUser(admin);
-    deleteVote(sc, extraReviewer);
-    assertThat(sender)
-        .sent("deleteVote", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void deleteVoteFromReviewableChangeByAdminCcingSelf() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    recommend(sc, extraReviewer);
-    setEmailStrategy(admin, EmailStrategy.CC_ON_OWN_COMMENTS);
-    setApiUser(admin);
-    deleteVote(sc, extraReviewer);
-    assertThat(sender)
-        .sent("deleteVote", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer, admin, extraReviewer, extraCcer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void deleteVoteFromReviewableChangeNotifyOwnerReviewers() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    recommend(sc, extraReviewer);
-    setApiUser(sc.owner);
-    deleteVote(sc, extraReviewer, NotifyHandling.OWNER_REVIEWERS);
-    assertThat(sender)
-        .sent("deleteVote", sc)
-        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void deleteVoteFromReviewableChangeNotifyOwnerReviewersWithSelfCc() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    recommend(sc, extraReviewer);
-    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
-    setApiUser(sc.owner);
-    deleteVote(sc, extraReviewer, NotifyHandling.OWNER_REVIEWERS);
-    assertThat(sender)
-        .sent("deleteVote", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void deleteVoteFromReviewableChangeNotifyOwner() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    recommend(sc, extraReviewer);
-    setApiUser(admin);
-    deleteVote(sc, extraReviewer, NotifyHandling.OWNER);
-    assertThat(sender).sent("deleteVote", sc).to(sc.owner).noOneElse();
-  }
-
-  @Test
-  public void deleteVoteFromReviewableChangeNotifyNone() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    recommend(sc, extraReviewer);
-    setApiUser(sc.owner);
-    deleteVote(sc, extraReviewer, NotifyHandling.NONE);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void deleteVoteFromReviewableChangeNotifyNoneWithSelfCc() throws Exception {
-    StagedChange sc = stageReviewableChangeWithExtraReviewer();
-    recommend(sc, extraReviewer);
-    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
-    setApiUser(sc.owner);
-    deleteVote(sc, extraReviewer, NotifyHandling.NONE);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void deleteVoteFromReviewableWipChange() throws Exception {
-    StagedChange sc = stageReviewableWipChangeWithExtraReviewer();
-    recommend(sc, extraReviewer);
-    setApiUser(sc.owner);
-    deleteVote(sc, extraReviewer);
-    assertThat(sender)
-        .sent("deleteVote", sc)
-        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void deleteVoteFromWipChange() throws Exception {
-    StagedChange sc = stageWipChangeWithExtraReviewer();
-    recommend(sc, extraReviewer);
-    setApiUser(sc.owner);
-    deleteVote(sc, extraReviewer);
-    assertThat(sender)
-        .sent("deleteVote", sc)
-        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  private void deleteVote(StagedChange sc, TestAccount account) throws Exception {
-    sender.clear();
-    gApi.changes().id(sc.changeId).reviewer(account.email).deleteVote("Code-Review");
-  }
-
-  private void deleteVote(StagedChange sc, TestAccount account, NotifyHandling notify)
-      throws Exception {
-    sender.clear();
-    DeleteVoteInput in = new DeleteVoteInput();
-    in.label = "Code-Review";
-    in.notify = notify;
-    gApi.changes().id(sc.changeId).reviewer(account.email).deleteVote(in);
-  }
-
-  /*
-   * MergedSender tests.
-   */
-
-  @Test
-  public void mergeByOwner() throws Exception {
-    StagedChange sc = stageChangeReadyForMerge();
-    merge(sc.changeId, sc.owner);
-    assertThat(sender)
-        .sent("merged", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
-        .noOneElse();
-  }
-
-  @Test
-  public void mergeByOwnerCcingSelf() throws Exception {
-    StagedChange sc = stageChangeReadyForMerge();
-    merge(sc.changeId, sc.owner, EmailStrategy.CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("merged", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
-        .noOneElse();
-  }
-
-  @Test
-  public void mergeByReviewer() throws Exception {
-    StagedChange sc = stageChangeReadyForMerge();
-    merge(sc.changeId, sc.reviewer);
-    assertThat(sender)
-        .sent("merged", sc)
-        .to(sc.owner)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
-        .noOneElse();
-  }
-
-  @Test
-  public void mergeByReviewerCcingSelf() throws Exception {
-    StagedChange sc = stageChangeReadyForMerge();
-    merge(sc.changeId, sc.reviewer, EmailStrategy.CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("merged", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
-        .noOneElse();
-  }
-
-  @Test
-  public void mergeByOtherNotifyOwnerReviewers() throws Exception {
-    StagedChange sc = stageChangeReadyForMerge();
-    merge(sc.changeId, other, OWNER_REVIEWERS);
-    assertThat(sender)
-        .sent("merged", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void mergeByOtherNotifyOwner() throws Exception {
-    StagedChange sc = stageChangeReadyForMerge();
-    merge(sc.changeId, other, OWNER);
-    assertThat(sender).sent("merged", sc).to(sc.owner).noOneElse();
-  }
-
-  @Test
-  public void mergeByOtherCcingSelfNotifyOwner() throws Exception {
-    StagedChange sc = stageChangeReadyForMerge();
-    setEmailStrategy(other, EmailStrategy.CC_ON_OWN_COMMENTS);
-    merge(sc.changeId, other, OWNER);
-    assertThat(sender).sent("merged", sc).to(sc.owner).noOneElse();
-  }
-
-  @Test
-  public void mergeByOtherNotifyNone() throws Exception {
-    StagedChange sc = stageChangeReadyForMerge();
-    merge(sc.changeId, other, NONE);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void mergeByOtherCcingSelfNotifyNone() throws Exception {
-    StagedChange sc = stageChangeReadyForMerge();
-    setEmailStrategy(other, EmailStrategy.CC_ON_OWN_COMMENTS);
-    merge(sc.changeId, other, NONE);
-    assertThat(sender).notSent();
-  }
-
-  private void merge(String changeId, TestAccount by) throws Exception {
-    merge(changeId, by, ENABLED);
-  }
-
-  private void merge(String changeId, TestAccount by, EmailStrategy emailStrategy)
-      throws Exception {
-    setEmailStrategy(by, emailStrategy);
-    setApiUser(by);
-    gApi.changes().id(changeId).revision("current").submit();
-  }
-
-  private void merge(String changeId, TestAccount by, NotifyHandling notify) throws Exception {
-    merge(changeId, by, ENABLED, notify);
-  }
-
-  private void merge(
-      String changeId, TestAccount by, EmailStrategy emailStrategy, NotifyHandling notify)
-      throws Exception {
-    setEmailStrategy(by, emailStrategy);
-    setApiUser(by);
-    SubmitInput in = new SubmitInput();
-    in.notify = notify;
-    gApi.changes().id(changeId).revision("current").submit(in);
-  }
-
-  private StagedChange stageChangeReadyForMerge() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    setApiUser(sc.reviewer);
-    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.approve());
-    sender.clear();
-    return sc;
-  }
-
-  /*
-   * ReplacePatchSetSender tests.
-   */
-
-  @Test
-  public void newPatchSetByOwnerOnReviewableChangeInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master", sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOwnerOnReviewableChangeInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master", sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master", other);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
-        .to(sc.reviewer, other)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master", other);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .notTo(sc.owner) // TODO(logan): This email shouldn't come from the owner.
-        .to(sc.reviewer, sc.ccer, other)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master", other, EmailStrategy.CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
-        .to(sc.reviewer, other)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master", other, EmailStrategy.CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
-        .to(sc.reviewer, sc.ccer, other)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeNotifyOwnerReviewersInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
-        .to(sc.reviewer)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeNotifyOwnerReviewersInReviewDb()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
-        .to(sc.reviewer, sc.ccer)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInNoteDb()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other, EmailStrategy.CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
-        .to(sc.reviewer)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInReviewDb()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other, EmailStrategy.CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer)
-        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeNotifyOwner() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master%notify=OWNER", other);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyOwner() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master%notify=OWNER", other, EmailStrategy.CC_ON_OWN_COMMENTS);
-    // TODO(logan): This email shouldn't come from the owner, and that's why
-    // no email is currently sent (owner isn't CCing self).
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeNotifyNone() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master%notify=NONE", other);
-    // TODO(logan): This email shouldn't come from the owner, and that's why
-    // no email is currently sent (owner isn't CCing self).
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyNone() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master%notify=NONE", other, EmailStrategy.CC_ON_OWN_COMMENTS);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetByOwnerOnReviewableChangeToWip() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    pushTo(sc, "refs/for/master%wip", sc.owner);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetOnWipChange() throws Exception {
-    StagedChange sc = stageWipChange();
-    pushTo(sc, "refs/for/master%wip", sc.owner);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetOnWipChangeNotifyAllInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageWipChange();
-    pushTo(sc, "refs/for/master%wip,notify=ALL", sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetOnWipChangeNotifyAllInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageWipChange();
-    pushTo(sc, "refs/for/master%wip,notify=ALL", sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetOnWipChangeToReadyInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageWipChange();
-    pushTo(sc, "refs/for/master%ready", sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetOnWipChangeToReadyInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageWipChange();
-    pushTo(sc, "refs/for/master%ready", sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void newPatchSetOnReviewableWipChange() throws Exception {
-    StagedChange sc = stageReviewableWipChange();
-    pushTo(sc, "refs/for/master%wip", sc.owner);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetOnReviewableChangeAddingReviewerInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    TestAccount newReviewer = sc.testAccount("newReviewer");
-    pushTo(sc, "refs/for/master%r=" + newReviewer.username, sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, newReviewer)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetOnReviewableChangeAddingReviewerInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    TestAccount newReviewer = sc.testAccount("newReviewer");
-    pushTo(sc, "refs/for/master%r=" + newReviewer.username, sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer, newReviewer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetOnWipChangeAddingReviewer() throws Exception {
-    StagedChange sc = stageWipChange();
-    TestAccount newReviewer = sc.testAccount("newReviewer");
-    pushTo(sc, "refs/for/master%r=" + newReviewer.username, sc.owner);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetOnWipChangeAddingReviewerNotifyAllInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageWipChange();
-    TestAccount newReviewer = sc.testAccount("newReviewer");
-    pushTo(sc, "refs/for/master%notify=ALL,r=" + newReviewer.username, sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, newReviewer)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetOnWipChangeAddingReviewerNotifyAllInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageWipChange();
-    TestAccount newReviewer = sc.testAccount("newReviewer");
-    pushTo(sc, "refs/for/master%notify=ALL,r=" + newReviewer.username, sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer, newReviewer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetOnWipChangeSettingReadyInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageWipChange();
-    pushTo(sc, "refs/for/master%ready", sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void newPatchSetOnWipChangeSettingReadyInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageWipChange();
-    pushTo(sc, "refs/for/master%ready", sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-    assertThat(sender).notSent();
-  }
-
-  private void pushTo(StagedChange sc, String ref, TestAccount by) throws Exception {
-    pushTo(sc, ref, by, ENABLED);
-  }
-
-  private void pushTo(StagedChange sc, String ref, TestAccount by, EmailStrategy emailStrategy)
-      throws Exception {
-    setEmailStrategy(by, emailStrategy);
-    pushFactory.create(db, by.getIdent(), sc.repo, sc.changeId).to(ref).assertOkStatus();
-  }
-
-  @Test
-  public void editCommitMessageEditByOwnerOnReviewableChangeInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageEditByOwnerOnReviewableChangeInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, sc.owner);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageEditByOtherOnReviewableChangeInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.owner, sc.reviewer)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageEditByOtherOnReviewableChangeInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.owner, sc.reviewer, sc.ccer)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.owner, sc.reviewer, other)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.owner, sc.reviewer, sc.ccer, other)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnReviewableChangeNotifyOwnerReviewersInNoteDb()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other, OWNER_REVIEWERS);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.owner, sc.reviewer)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnReviewableChangeNotifyOwnerReviewersInReviewDb()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other, OWNER_REVIEWERS);
-    assertThat(sender).sent("newpatchset", sc).to(sc.owner, sc.reviewer, sc.ccer).noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInNoteDb()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other, OWNER_REVIEWERS, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.owner, sc.reviewer)
-        .cc(sc.ccer, other)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInReviewDb()
-      throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other, OWNER_REVIEWERS, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.owner, sc.reviewer, sc.ccer)
-        .cc(other)
-        .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnReviewableChangeNotifyOwner() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other, OWNER);
-    assertThat(sender).sent("newpatchset", sc).to(sc.owner).noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyOwner() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other, OWNER, CC_ON_OWN_COMMENTS);
-    assertThat(sender).sent("newpatchset", sc).to(sc.owner).cc(other).noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnReviewableChangeNotifyNone() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other, NONE);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyNone() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    editCommitMessage(sc, other, NONE, CC_ON_OWN_COMMENTS);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void editCommitMessageOnWipChange() throws Exception {
-    StagedChange sc = stageWipChange();
-    editCommitMessage(sc, sc.owner);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnWipChange() throws Exception {
-    StagedChange sc = stageWipChange();
-    editCommitMessage(sc, other);
-    assertThat(sender).sent("newpatchset", sc).to(sc.owner).noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageByOtherOnWipChangeSelfCc() throws Exception {
-    StagedChange sc = stageWipChange();
-    editCommitMessage(sc, other, CC_ON_OWN_COMMENTS);
-    assertThat(sender).sent("newpatchset", sc).to(sc.owner).cc(other).noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageOnWipChangeNotifyAllInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageWipChange();
-    editCommitMessage(sc, sc.owner, ALL);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer)
-        .cc(sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  @Test
-  public void editCommitMessageOnWipChangeNotifyAllInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageWipChange();
-    editCommitMessage(sc, sc.owner, ALL);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.reviewer, sc.ccer)
-        .bcc(sc.starrer)
-        .bcc(NEW_PATCHSETS)
-        .noOneElse();
-  }
-
-  private void editCommitMessage(StagedChange sc, TestAccount by) throws Exception {
-    editCommitMessage(sc, by, null, ENABLED);
-  }
-
-  private void editCommitMessage(StagedChange sc, TestAccount by, @Nullable NotifyHandling notify)
-      throws Exception {
-    editCommitMessage(sc, by, notify, ENABLED);
-  }
-
-  private void editCommitMessage(StagedChange sc, TestAccount by, EmailStrategy emailStrategy)
-      throws Exception {
-    editCommitMessage(sc, by, null, emailStrategy);
-  }
-
-  private void editCommitMessage(
-      StagedChange sc, TestAccount by, @Nullable NotifyHandling notify, EmailStrategy emailStrategy)
-      throws Exception {
-    setEmailStrategy(by, emailStrategy);
-    CommitInfo commit = gApi.changes().id(sc.changeId).revision("current").commit(false);
-    CommitMessageInput in = new CommitMessageInput();
-    in.message = "update\n" + commit.message;
-    in.notify = notify;
-    gApi.changes().id(sc.changeId).setMessage(in);
-  }
-
-  /*
-   * RestoredSender tests.
-   */
-
-  @Test
-  public void restoreReviewableChange() throws Exception {
-    StagedChange sc = stageAbandonedReviewableChange();
-    restore(sc.changeId, sc.owner);
-    assertThat(sender)
-        .sent("restore", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void restoreReviewableWipChange() throws Exception {
-    StagedChange sc = stageAbandonedReviewableWipChange();
-    restore(sc.changeId, sc.owner);
-    assertThat(sender)
-        .sent("restore", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void restoreWipChange() throws Exception {
-    StagedChange sc = stageAbandonedWipChange();
-    restore(sc.changeId, sc.owner);
-    assertThat(sender)
-        .sent("restore", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void restoreReviewableChangeByAdmin() throws Exception {
-    StagedChange sc = stageAbandonedReviewableChange();
-    restore(sc.changeId, admin);
-    assertThat(sender)
-        .sent("restore", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void restoreReviewableChangeByOwnerCcingSelf() throws Exception {
-    StagedChange sc = stageAbandonedReviewableChange();
-    restore(sc.changeId, sc.owner, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("restore", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void restoreReviewableChangeByAdminCcingSelf() throws Exception {
-    StagedChange sc = stageAbandonedReviewableChange();
-    restore(sc.changeId, admin, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("restore", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer, admin)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  private void restore(String changeId, TestAccount by) throws Exception {
-    restore(changeId, by, ENABLED);
-  }
-
-  private void restore(String changeId, TestAccount by, EmailStrategy emailStrategy)
-      throws Exception {
-    setEmailStrategy(by, emailStrategy);
-    setApiUser(by);
-    gApi.changes().id(changeId).restore();
-  }
-
-  /*
-   * RevertedSender tests.
-   */
-
-  @Test
-  public void revertChangeByOwnerInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageChange();
-    revert(sc, sc.owner);
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-
-    assertThat(sender)
-        .sent("revert", sc)
-        .cc(sc.reviewer, sc.ccer, admin)
-        .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-  }
-
-  @Test
-  public void revertChangeByOwnerInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageChange();
-    revert(sc, sc.owner);
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(sc.reviewer, sc.watchingProjectOwner, admin)
-        .cc(sc.ccer)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-
-    assertThat(sender)
-        .sent("revert", sc)
-        .cc(sc.reviewer, sc.ccer, admin)
-        .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-  }
-
-  @Test
-  public void revertChangeByOwnerCcingSelfInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageChange();
-    revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
-        .cc(sc.owner)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-
-    assertThat(sender)
-        .sent("revert", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer, admin)
-        .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-  }
-
-  @Test
-  public void revertChangeByOwnerCcingSelfInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageChange();
-    revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(sc.reviewer, sc.watchingProjectOwner, admin)
-        .cc(sc.owner, sc.ccer)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-
-    assertThat(sender)
-        .sent("revert", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer, admin)
-        .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-  }
-
-  @Test
-  public void revertChangeByOtherInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageChange();
-    revert(sc, other);
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(sc.owner, sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-
-    assertThat(sender)
-        .sent("revert", sc)
-        .cc(sc.owner, sc.reviewer, sc.ccer, admin)
-        .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-  }
-
-  @Test
-  public void revertChangeByOtherInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageChange();
-    revert(sc, other);
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(sc.owner, sc.reviewer, sc.watchingProjectOwner, admin)
-        .cc(sc.ccer)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-
-    assertThat(sender)
-        .sent("revert", sc)
-        .cc(sc.owner, sc.reviewer, sc.ccer, admin)
-        .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-  }
-
-  @Test
-  public void revertChangeByOtherCcingSelfInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageChange();
-    revert(sc, other, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(sc.owner, sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
-        .cc(other)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-
-    assertThat(sender)
-        .sent("revert", sc)
-        .to(other)
-        .cc(sc.owner, sc.reviewer, sc.ccer, admin)
-        .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-  }
-
-  @Test
-  public void revertChangeByOtherCcingSelfInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageChange();
-    revert(sc, other, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(sc.owner, sc.reviewer, sc.watchingProjectOwner, admin)
-        .cc(sc.ccer, other)
-        .bcc(NEW_CHANGES, NEW_PATCHSETS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-
-    assertThat(sender)
-        .sent("revert", sc)
-        .to(other)
-        .cc(sc.owner, sc.reviewer, sc.ccer, admin)
-        .bcc(ALL_COMMENTS)
-        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
-  }
-
-  private StagedChange stageChange() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    setApiUser(admin);
-    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.approve());
-    gApi.changes().id(sc.changeId).revision("current").submit();
-    sender.clear();
-    return sc;
-  }
-
-  private void revert(StagedChange sc, TestAccount by) throws Exception {
-    revert(sc, by, ENABLED);
-  }
-
-  private void revert(StagedChange sc, TestAccount by, EmailStrategy emailStrategy)
-      throws Exception {
-    setEmailStrategy(by, emailStrategy);
-    setApiUser(by);
-    gApi.changes().id(sc.changeId).revert();
-  }
-
-  /*
-   * SetAssigneeSender tests.
-   */
-
-  @Test
-  public void setAssigneeOnReviewableChange() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    assign(sc, sc.owner, sc.assignee);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
-        .to(sc.assignee)
-        .noOneElse();
-  }
-
-  @Test
-  public void setAssigneeOnReviewableChangeByOwnerCcingSelf() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    assign(sc, sc.owner, sc.assignee, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(sc.owner)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
-        .to(sc.assignee)
-        .noOneElse();
-  }
-
-  @Test
-  public void setAssigneeOnReviewableChangeByAdmin() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    assign(sc, admin, sc.assignee);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
-        .to(sc.assignee)
-        .noOneElse();
-  }
-
-  @Test
-  public void setAssigneeOnReviewableChangeByAdminCcingSelf() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    assign(sc, admin, sc.assignee, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(admin)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
-        .to(sc.assignee)
-        .noOneElse();
-  }
-
-  @Test
-  public void setAssigneeToSelfOnReviewableChangeInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    assign(sc, sc.owner, sc.owner);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
-        .noOneElse();
-  }
-
-  @Test
-  public void setAssigneeToSelfOnReviewableChangeInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    assign(sc, sc.owner, sc.owner);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void changeAssigneeOnReviewableChange() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
-    assign(sc, sc.owner, other);
-    sender.clear();
-    assign(sc, sc.owner, sc.assignee);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
-        .to(sc.assignee)
-        .noOneElse();
-  }
-
-  @Test
-  public void changeAssigneeToSelfOnReviewableChangeInNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    StagedChange sc = stageReviewableChange();
-    assign(sc, sc.owner, sc.assignee);
-    sender.clear();
-    assign(sc, sc.owner, sc.owner);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
-        .noOneElse();
-  }
-
-  @Test
-  public void changeAssigneeToSelfOnReviewableChangeInReviewDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    StagedChange sc = stageReviewableChange();
-    assign(sc, sc.owner, sc.assignee);
-    sender.clear();
-    assign(sc, sc.owner, sc.owner);
-    assertThat(sender).notSent();
-  }
-
-  @Test
-  public void setAssigneeOnReviewableWipChange() throws Exception {
-    StagedChange sc = stageReviewableWipChange();
-    assign(sc, sc.owner, sc.assignee);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
-        .to(sc.assignee)
-        .noOneElse();
-  }
-
-  @Test
-  public void setAssigneeOnWipChange() throws Exception {
-    StagedChange sc = stageWipChange();
-    assign(sc, sc.owner, sc.assignee);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
-        .to(sc.assignee)
-        .noOneElse();
-  }
-
-  private void assign(StagedChange sc, TestAccount by, TestAccount to) throws Exception {
-    assign(sc, by, to, ENABLED);
-  }
-
-  private void assign(StagedChange sc, TestAccount by, TestAccount to, EmailStrategy emailStrategy)
-      throws Exception {
-    setEmailStrategy(by, emailStrategy);
-    setApiUser(by);
-    AssigneeInput in = new AssigneeInput();
-    in.assignee = to.email;
-    gApi.changes().id(sc.changeId).setAssignee(in);
-  }
-
-  /*
-   * Start review and WIP tests.
-   */
-
-  @Test
-  public void startReviewOnWipChange() throws Exception {
-    StagedChange sc = stageWipChange();
-    startReview(sc);
-    assertThat(sender)
-        .sent("comment", sc)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void startReviewOnWipChangeCcingSelf() throws Exception {
-    StagedChange sc = stageWipChange();
-    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
-    startReview(sc);
-    assertThat(sender)
-        .sent("comment", sc)
-        .to(sc.owner)
-        .cc(sc.reviewer, sc.ccer)
-        .cc(sc.reviewerByEmail, sc.ccerByEmail)
-        .bcc(sc.starrer)
-        .bcc(ALL_COMMENTS)
-        .noOneElse();
-  }
-
-  @Test
-  public void setWorkInProgress() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    gApi.changes().id(sc.changeId).setWorkInProgress();
-    assertThat(sender).notSent();
-  }
-
-  private void startReview(StagedChange sc) throws Exception {
-    setApiUser(sc.owner);
-    gApi.changes().id(sc.changeId).setReadyForReview();
-    // PolyGerrit current immediately follows up with a review.
-    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.noScore());
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java
deleted file mode 100644
index f25223c..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java
+++ /dev/null
@@ -1,102 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.mail;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.server.mail.receive.MailReceiver;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.inject.Inject;
-import com.icegreen.greenmail.junit.GreenMailRule;
-import com.icegreen.greenmail.user.GreenMailUser;
-import com.icegreen.greenmail.util.GreenMail;
-import com.icegreen.greenmail.util.GreenMailUtil;
-import com.icegreen.greenmail.util.ServerSetupTest;
-import javax.mail.internet.MimeMessage;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-@NoHttpd
-@RunWith(ConfigSuite.class)
-public class MailIT extends AbstractDaemonTest {
-  private static final String RECEIVEEMAIL = "receiveemail";
-  private static final String HOST = "localhost";
-  private static final String USERNAME = "user@domain.com";
-  private static final String PASSWORD = "password";
-
-  @Inject private MailReceiver mailReceiver;
-
-  @Inject private GreenMail greenMail;
-
-  @Rule
-  public final GreenMailRule mockPop3Server = new GreenMailRule(ServerSetupTest.SMTP_POP3_IMAP);
-
-  @ConfigSuite.Default
-  public static Config pop3Config() {
-    Config cfg = new Config();
-    cfg.setString(RECEIVEEMAIL, null, "host", HOST);
-    cfg.setString(RECEIVEEMAIL, null, "port", "3110");
-    cfg.setString(RECEIVEEMAIL, null, "username", USERNAME);
-    cfg.setString(RECEIVEEMAIL, null, "password", PASSWORD);
-    cfg.setString(RECEIVEEMAIL, null, "protocol", "POP3");
-    cfg.setString(RECEIVEEMAIL, null, "fetchInterval", Integer.toString(Integer.MAX_VALUE));
-    return cfg;
-  }
-
-  @ConfigSuite.Config
-  public static Config imapConfig() {
-    Config cfg = new Config();
-    cfg.setString(RECEIVEEMAIL, null, "host", HOST);
-    cfg.setString(RECEIVEEMAIL, null, "port", "3143");
-    cfg.setString(RECEIVEEMAIL, null, "username", USERNAME);
-    cfg.setString(RECEIVEEMAIL, null, "password", PASSWORD);
-    cfg.setString(RECEIVEEMAIL, null, "protocol", "IMAP");
-    cfg.setString(RECEIVEEMAIL, null, "fetchInterval", Integer.toString(Integer.MAX_VALUE));
-    return cfg;
-  }
-
-  @Test
-  public void doesNotDeleteMessageNotMarkedForDeletion() throws Exception {
-    GreenMailUser user = mockPop3Server.setUser(USERNAME, USERNAME, PASSWORD);
-    user.deliver(createSimpleMessage());
-    assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(1);
-    // Let Gerrit handle emails
-    mailReceiver.handleEmails(false);
-    // Check that the message is still present
-    assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(1);
-  }
-
-  @Test
-  public void deletesMessageMarkedForDeletion() throws Exception {
-    GreenMailUser user = mockPop3Server.setUser(USERNAME, USERNAME, PASSWORD);
-    user.deliver(createSimpleMessage());
-    assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(1);
-    // Mark the message for deletion
-    mailReceiver.requestDeletion(mockPop3Server.getReceivedMessages()[0].getMessageID());
-    // Let Gerrit handle emails
-    mailReceiver.handleEmails(false);
-    // Check that the message was deleted
-    assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(0);
-  }
-
-  private MimeMessage createSimpleMessage() {
-    return GreenMailUtil.createTextEmail(
-        USERNAME, "from@localhost.com", "subject", "body", greenMail.getImap().getServerSetup());
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
deleted file mode 100644
index 7cef8e7..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
+++ /dev/null
@@ -1,162 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.mail;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.server.mail.MailUtil;
-import com.google.gerrit.server.mail.send.EmailHeader;
-import com.google.gerrit.testutil.FakeEmailSender;
-import com.google.gerrit.testutil.TestTimeUtil;
-import java.sql.Timestamp;
-import java.time.ZoneId;
-import java.time.ZonedDateTime;
-import java.util.Collection;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-/** Tests the presence of required metadata in email headers, text and html. */
-public class MailMetadataIT extends AbstractDaemonTest {
-  private String systemTimeZone;
-
-  @Before
-  public void setTimeForTesting() {
-    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
-  }
-
-  @Test
-  public void metadataOnNewChange() throws Exception {
-    PushOneCommit.Result newChange = createChange();
-    gApi.changes().id(newChange.getChangeId()).addReviewer(user.getId().toString());
-
-    List<FakeEmailSender.Message> emails = sender.getMessages();
-    assertThat(emails).hasSize(1);
-    FakeEmailSender.Message message = emails.get(0);
-
-    String changeURL = "<" + canonicalWebUrl.get() + newChange.getChange().getId().get() + ">";
-
-    Map<String, Object> expectedHeaders = new HashMap<>();
-    expectedHeaders.put("Gerrit-PatchSet", "1");
-    expectedHeaders.put(
-        "Gerrit-Change-Number", String.valueOf(newChange.getChange().getId().get()));
-    expectedHeaders.put("Gerrit-MessageType", "newchange");
-    expectedHeaders.put("Gerrit-Commit", newChange.getCommit().getId().name());
-    expectedHeaders.put("Gerrit-ChangeURL", changeURL);
-
-    assertHeaders(message.headers(), expectedHeaders);
-
-    // Remove metadata that is not present in email
-    expectedHeaders.remove("Gerrit-ChangeURL");
-    expectedHeaders.remove("Gerrit-Commit");
-    assertTextFooter(message.body(), expectedHeaders);
-  }
-
-  @Test
-  public void metadataOnNewComment() throws Exception {
-    PushOneCommit.Result newChange = createChange();
-    gApi.changes().id(newChange.getChangeId()).addReviewer(user.getId().toString());
-    sender.clear();
-
-    // Review change
-    ReviewInput input = new ReviewInput();
-    input.message = "Test";
-    revision(newChange).review(input);
-    setApiUser(user);
-    Collection<ChangeMessageInfo> result =
-        gApi.changes().id(newChange.getChangeId()).get().messages;
-    assertThat(result).isNotEmpty();
-
-    List<FakeEmailSender.Message> emails = sender.getMessages();
-    assertThat(emails).hasSize(1);
-    FakeEmailSender.Message message = emails.get(0);
-
-    String changeURL = "<" + canonicalWebUrl.get() + newChange.getChange().getId().get() + ">";
-    Map<String, Object> expectedHeaders = new HashMap<>();
-    expectedHeaders.put("Gerrit-PatchSet", "1");
-    expectedHeaders.put(
-        "Gerrit-Change-Number", String.valueOf(newChange.getChange().getId().get()));
-    expectedHeaders.put("Gerrit-MessageType", "comment");
-    expectedHeaders.put("Gerrit-Commit", newChange.getCommit().getId().name());
-    expectedHeaders.put("Gerrit-ChangeURL", changeURL);
-    expectedHeaders.put("Gerrit-Comment-Date", Iterables.getLast(result).date);
-
-    assertHeaders(message.headers(), expectedHeaders);
-
-    // Remove metadata that is not present in email
-    expectedHeaders.remove("Gerrit-ChangeURL");
-    expectedHeaders.remove("Gerrit-Commit");
-    assertTextFooter(message.body(), expectedHeaders);
-  }
-
-  private static void assertHeaders(Map<String, EmailHeader> have, Map<String, Object> want)
-      throws Exception {
-    for (Map.Entry<String, Object> entry : want.entrySet()) {
-      if (entry.getValue() instanceof String) {
-        assertThat(have)
-            .containsEntry(
-                "X-" + entry.getKey(), new EmailHeader.String((String) entry.getValue()));
-      } else if (entry.getValue() instanceof Date) {
-        assertThat(have)
-            .containsEntry("X-" + entry.getKey(), new EmailHeader.Date((Date) entry.getValue()));
-      } else {
-        throw new Exception(
-            "Object has unsupported type: "
-                + entry.getValue().getClass().getName()
-                + " must be java.util.Date or java.lang.String for key "
-                + entry.getKey());
-      }
-    }
-  }
-
-  private static void assertTextFooter(String body, Map<String, Object> want) throws Exception {
-    for (Map.Entry<String, Object> entry : want.entrySet()) {
-      if (entry.getValue() instanceof String) {
-        assertThat(body).contains(entry.getKey() + ": " + entry.getValue());
-      } else if (entry.getValue() instanceof Timestamp) {
-        assertThat(body)
-            .contains(
-                entry.getKey()
-                    + ": "
-                    + MailUtil.rfcDateformatter.format(
-                        ZonedDateTime.ofInstant(
-                            ((Timestamp) entry.getValue()).toInstant(), ZoneId.of("UTC"))));
-      } else {
-        throw new Exception(
-            "Object has unsupported type: "
-                + entry.getValue().getClass().getName()
-                + " must be java.util.Date or java.lang.String for key "
-                + entry.getKey());
-      }
-    }
-  }
-}
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
deleted file mode 100644
index 43f046a..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.mail;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.server.mail.send.EmailHeader;
-import java.util.Map;
-import org.junit.Test;
-
-public class MailSenderIT extends AbstractMailIT {
-
-  @Test
-  @GerritConfig(name = "sendemail.replyToAddress", value = "custom@gerritcodereview.com")
-  @GerritConfig(name = "receiveemail.protocol", value = "POP3")
-  public void outgoingMailHasCustomReplyToHeader() throws Exception {
-    createChangeWithReview(user);
-    // 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");
-  }
-
-  @Test
-  public void outgoingMailHasUserEmailInReplyToHeader() throws Exception {
-    createChangeWithReview(user);
-    // 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);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
deleted file mode 100644
index 8485012..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.mail;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
-import com.google.gerrit.testutil.FakeEmailSender;
-import org.junit.Test;
-
-public class NotificationMailFormatIT extends AbstractDaemonTest {
-
-  @Test
-  public void userReceivesPlaintextEmail() throws Exception {
-    // Set user preference to receive only plaintext content
-    GeneralPreferencesInfo i = new GeneralPreferencesInfo();
-    i.emailFormat = EmailFormat.PLAINTEXT;
-    gApi.accounts().id(admin.getId().toString()).setPreferences(i);
-
-    // Create change as admin and review as user
-    PushOneCommit.Result r = createChange();
-    setApiUser(user);
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
-
-    // Check that admin has received only plaintext content
-    assertThat(sender.getMessages()).hasSize(1);
-    FakeEmailSender.Message m = sender.getMessages().get(0);
-    assertThat(m.body()).isNotNull();
-    assertThat(m.htmlBody()).isNull();
-    assertMailReplyTo(m, admin.email);
-    assertMailReplyTo(m, user.email);
-
-    // Reset user preference
-    setApiUser(admin);
-    i.emailFormat = EmailFormat.HTML_PLAINTEXT;
-    gApi.accounts().id(admin.getId().toString()).setPreferences(i);
-  }
-
-  @Test
-  public void userReceivesHtmlAndPlaintextEmail() throws Exception {
-    // Create change as admin and review as user
-    PushOneCommit.Result r = createChange();
-    setApiUser(user);
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
-
-    // Check that admin has received both HTML and plaintext content
-    assertThat(sender.getMessages()).hasSize(1);
-    FakeEmailSender.Message m = sender.getMessages().get(0);
-    assertThat(m.body()).isNotNull();
-    assertThat(m.htmlBody()).isNotNull();
-    assertMailReplyTo(m, admin.email);
-    assertMailReplyTo(m, user.email);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUILD
deleted file mode 100644
index d314f16..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUILD
+++ /dev/null
@@ -1,10 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "server_notedb",
-    labels = [
-        "notedb",
-        "server",
-    ],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
deleted file mode 100644
index ab06671..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
+++ /dev/null
@@ -1,1478 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.concurrent.TimeUnit.DAYS;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.toList;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.change.PostReview;
-import com.google.gerrit.server.change.Rebuild;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.RepoRefCache;
-import com.google.gerrit.server.notedb.ChangeBundle;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
-import com.google.gerrit.server.project.Util;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.NoteDbChecker;
-import com.google.gerrit.testutil.NoteDbMode;
-import com.google.gerrit.testutil.TestChanges;
-import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.Timestamp;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.concurrent.TimeUnit;
-import org.apache.http.Header;
-import org.apache.http.message.BasicHeader;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class ChangeRebuilderIT extends AbstractDaemonTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    Config cfg = new Config();
-    cfg.setBoolean("noteDb", null, "testRebuilderWrapper", true);
-
-    // Disable async reindex-if-stale check after index update. This avoids
-    // unintentional auto-rebuilding of the change in NoteDb during the read
-    // path of the reindex-if-stale check. For the purposes of this test, we
-    // want precise control over when auto-rebuilding happens.
-    cfg.setBoolean("index", null, "autoReindexIfStale", false);
-
-    // setNotesMigration tries to keep IDs in sync between ReviewDb and NoteDb, which is behavior
-    // unique to this test. This gets prohibitively slow if we use the default sequence gap.
-    cfg.setInt("noteDb", "changes", "initialSequenceGap", 0);
-
-    return cfg;
-  }
-
-  @Inject private AllUsersName allUsers;
-
-  @Inject private NoteDbChecker checker;
-
-  @Inject private Rebuild rebuildHandler;
-
-  @Inject private Provider<ReviewDb> dbProvider;
-
-  @Inject private CommentsUtil commentsUtil;
-
-  @Inject private Provider<PostReview> postReview;
-
-  @Inject private TestChangeRebuilderWrapper rebuilderWrapper;
-
-  @Inject private Sequences seq;
-
-  @Inject private ChangeBundleReader bundleReader;
-
-  @Inject private PatchSetInfoFactory patchSetInfoFactory;
-
-  @Before
-  public void setUp() throws Exception {
-    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.OFF);
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-    setNotesMigration(false, false);
-  }
-
-  @After
-  public void tearDown() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @SuppressWarnings("deprecation")
-  private void setNotesMigration(boolean writeChanges, boolean readChanges) throws Exception {
-    notesMigration.setWriteChanges(writeChanges);
-    notesMigration.setReadChanges(readChanges);
-    db = atrScope.reopenDb().getReviewDbProvider().get();
-
-    if (notesMigration.readChangeSequence()) {
-      // Copy next ReviewDb ID to NoteDb.
-      seq.getChangeIdRepoSequence().set(db.nextChangeId());
-    } else {
-      // Copy next NoteDb ID to ReviewDb.
-      while (db.nextChangeId() < seq.getChangeIdRepoSequence().next()) {}
-    }
-  }
-
-  @Test
-  public void changeFields() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    gApi.changes().id(id.get()).topic(name("a-topic"));
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void patchSets() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    r = amendChange(r.getChangeId());
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void publishedComment() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putComment(user, id, 1, "comment", null);
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void publishedCommentAndReply() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putComment(user, id, 1, "comment", null);
-    Map<String, List<CommentInfo>> comments = getPublishedComments(id);
-    String parentUuid = comments.get("a.txt").get(0).id;
-    putComment(user, id, 1, "comment", parentUuid);
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void patchSetWithNullGroups() throws Exception {
-    Timestamp ts = TimeUtil.nowTs();
-    Change c = TestChanges.newChange(project, user.getId(), seq.nextChangeId());
-    c.setCreatedOn(ts);
-    c.setLastUpdatedOn(ts);
-    c.setReviewStarted(true);
-    PatchSet ps =
-        TestChanges.newPatchSet(
-            c.currentPatchSetId(), "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", user.getId());
-    ps.setCreatedOn(ts);
-    db.changes().insert(Collections.singleton(c));
-    db.patchSets().insert(Collections.singleton(ps));
-
-    assertThat(ps.getGroups()).isEmpty();
-    checker.rebuildAndCheckChanges(c.getId());
-  }
-
-  @Test
-  public void draftComment() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment", null);
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void draftAndPublishedComment() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "draft comment", null);
-    putComment(user, id, 1, "published comment", null);
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void publishDraftComment() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "draft comment", null);
-    publishDrafts(user, id);
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void nullAccountId() throws Exception {
-    PushOneCommit.Result r = createChange();
-    PatchSet.Id psId = r.getPatchSetId();
-    Change.Id id = psId.getParentKey();
-
-    // Events need to be otherwise identical for the account ID to be compared.
-    ChangeMessage msg1 = insertMessage(id, psId, user.getId(), TimeUtil.nowTs(), "message 1");
-    insertMessage(id, psId, null, msg1.getWrittenOn(), "message 2");
-
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void nullPatchSetId() throws Exception {
-    PushOneCommit.Result r = createChange();
-    PatchSet.Id psId1 = r.getPatchSetId();
-    Change.Id id = psId1.getParentKey();
-
-    // Events need to be otherwise identical for the PatchSet.ID to be compared.
-    ChangeMessage msg1 = insertMessage(id, null, user.getId(), TimeUtil.nowTs(), "message 1");
-    insertMessage(id, null, user.getId(), msg1.getWrittenOn(), "message 2");
-
-    PatchSet.Id psId2 = amendChange(r.getChangeId()).getPatchSetId();
-
-    ChangeMessage msg3 = insertMessage(id, null, user.getId(), TimeUtil.nowTs(), "message 3");
-    insertMessage(id, null, user.getId(), msg3.getWrittenOn(), "message 4");
-
-    checker.rebuildAndCheckChanges(id);
-
-    setNotesMigration(true, true);
-
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    Map<String, PatchSet.Id> psIds = new HashMap<>();
-    for (ChangeMessage msg : notes.getChangeMessages()) {
-      PatchSet.Id psId = msg.getPatchSetId();
-      assertThat(psId).named("patchset for " + msg).isNotNull();
-      psIds.put(msg.getMessage(), psId);
-    }
-    // Patch set IDs were replaced during conversion process.
-    assertThat(psIds).containsEntry("message 1", psId1);
-    assertThat(psIds).containsEntry("message 2", psId1);
-    assertThat(psIds).containsEntry("message 3", psId2);
-    assertThat(psIds).containsEntry("message 4", psId2);
-  }
-
-  @Test
-  public void noWriteToNewRef() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    checker.assertNoChangeRef(project, id);
-
-    setNotesMigration(true, false);
-    gApi.changes().id(id.get()).topic(name("a-topic"));
-
-    // First write doesn't create the ref, but rebuilding works.
-    checker.assertNoChangeRef(project, id);
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isNull();
-    checker.rebuildAndCheckChanges(id);
-
-    // Now that there is a ref, writes are "turned on" for this change, and
-    // NoteDb stays up to date without explicit rebuilding.
-    gApi.changes().id(id.get()).topic(name("new-topic"));
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isNotNull();
-    checker.checkChanges(id);
-  }
-
-  @Test
-  public void restApiNotFoundWhenNoteDbDisabled() throws Exception {
-    PushOneCommit.Result r = createChange();
-    exception.expect(ResourceNotFoundException.class);
-    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Rebuild.Input());
-  }
-
-  @Test
-  public void rebuildViaRestApi() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    setNotesMigration(true, false);
-
-    checker.assertNoChangeRef(project, id);
-    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Rebuild.Input());
-    checker.checkChanges(id);
-  }
-
-  @Test
-  public void writeToNewRefForNewChange() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    Change.Id id1 = r1.getPatchSetId().getParentKey();
-
-    setNotesMigration(true, false);
-    gApi.changes().id(id1.get()).topic(name("a-topic"));
-    PushOneCommit.Result r2 = createChange();
-    Change.Id id2 = r2.getPatchSetId().getParentKey();
-
-    // Second change was created after NoteDb writes were turned on, so it was
-    // allowed to write to a new ref.
-    checker.checkChanges(id2);
-
-    // First change was created before NoteDb writes were turned on, so its meta
-    // ref doesn't exist until a manual rebuild.
-    checker.assertNoChangeRef(project, id1);
-    checker.rebuildAndCheckChanges(id1);
-  }
-
-  @Test
-  public void noteDbChangeState() throws Exception {
-    setNotesMigration(true, true);
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-
-    ObjectId changeMetaId = getMetaRef(project, changeMetaRef(id));
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(changeMetaId.name());
-
-    putDraft(user, id, 1, "comment by user", null);
-    ObjectId userDraftsId = getMetaRef(allUsers, refsDraftComments(id, user.getId()));
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState())
-        .isEqualTo(changeMetaId.name() + "," + user.getId() + "=" + userDraftsId.name());
-
-    putDraft(admin, id, 2, "comment by admin", null);
-    ObjectId adminDraftsId = getMetaRef(allUsers, refsDraftComments(id, admin.getId()));
-    assertThat(admin.getId().get()).isLessThan(user.getId().get());
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState())
-        .isEqualTo(
-            changeMetaId.name()
-                + ","
-                + admin.getId()
-                + "="
-                + adminDraftsId.name()
-                + ","
-                + user.getId()
-                + "="
-                + userDraftsId.name());
-
-    putDraft(admin, id, 2, "revised comment by admin", null);
-    adminDraftsId = getMetaRef(allUsers, refsDraftComments(id, admin.getId()));
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState())
-        .isEqualTo(
-            changeMetaId.name()
-                + ","
-                + admin.getId()
-                + "="
-                + adminDraftsId.name()
-                + ","
-                + user.getId()
-                + "="
-                + userDraftsId.name());
-  }
-
-  @Test
-  public void rebuildAutomaticallyWhenChangeOutOfDate() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    assertChangeUpToDate(true, id);
-
-    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
-    setNotesMigration(false, false);
-    gApi.changes().id(id.get()).topic(name("a-topic"));
-    setInvalidNoteDbState(id);
-    assertChangeUpToDate(false, id);
-
-    // On next NoteDb read, the change is transparently rebuilt.
-    setNotesMigration(true, true);
-    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
-    assertChangeUpToDate(true, id);
-
-    // Check that the bundles are equal.
-    ChangeBundle actual =
-        ChangeBundle.fromNotes(commentsUtil, notesFactory.create(dbProvider.get(), project, id));
-    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
-    assertThat(actual.differencesFrom(expected)).isEmpty();
-  }
-
-  @Test
-  public void rebuildAutomaticallyWithinBatchUpdate() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    final Change.Id id = r.getPatchSetId().getParentKey();
-    assertChangeUpToDate(true, id);
-
-    // Update ReviewDb and NoteDb, then revert the corresponding NoteDb change
-    // to simulate it failing.
-    NoteDbChangeState oldState = NoteDbChangeState.parse(getUnwrappedDb().changes().get(id));
-    String topic = name("a-topic");
-    gApi.changes().id(id.get()).topic(topic);
-    try (Repository repo = repoManager.openRepository(project)) {
-      new TestRepository<>(repo).update(RefNames.changeMetaRef(id), oldState.getChangeMetaId());
-    }
-    assertChangeUpToDate(false, id);
-
-    // Next NoteDb read comes inside the transaction started by BatchUpdate. In
-    // reality this could be caused by a failed update happening between when
-    // the change is parsed by ChangesCollection and when the BatchUpdate
-    // executes. We simulate it here by using BatchUpdate directly and not going
-    // through an API handler.
-    final String msg = "message from BatchUpdate";
-    try (BatchUpdate bu =
-        batchUpdateFactory.create(
-            db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs())) {
-      bu.addOp(
-          id,
-          new BatchUpdateOp() {
-            @Override
-            public boolean updateChange(ChangeContext ctx) throws OrmException {
-              PatchSet.Id psId = ctx.getChange().currentPatchSetId();
-              ChangeMessage cm =
-                  new ChangeMessage(
-                      new ChangeMessage.Key(id, ChangeUtil.messageUuid()),
-                      ctx.getAccountId(),
-                      ctx.getWhen(),
-                      psId);
-              cm.setMessage(msg);
-              ctx.getDb().changeMessages().insert(Collections.singleton(cm));
-              ctx.getUpdate(psId).setChangeMessage(msg);
-              return true;
-            }
-          });
-      try {
-        bu.execute();
-        fail("expected update to fail");
-      } catch (UpdateException e) {
-        assertThat(e.getMessage()).contains("cannot copy ChangeNotesState");
-      }
-    }
-
-    // TODO(dborowitz): Re-enable these assertions once we fix auto-rebuilding
-    // in the BatchUpdate path.
-    // As an implementation detail, change wasn't actually rebuilt inside the
-    // BatchUpdate transaction, but it was rebuilt during read for the
-    // subsequent reindex. Thus it's impossible to actually observe an
-    // out-of-date state in the caller.
-    // assertChangeUpToDate(true, id);
-
-    // Check that the bundles are equal.
-    // ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
-    // ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
-    // ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
-    // assertThat(actual.differencesFrom(expected)).isEmpty();
-    // assertThat(
-    //        Iterables.transform(
-    //            notes.getChangeMessages(),
-    //            ChangeMessage::getMessage))
-    //    .contains(msg);
-    // assertThat(actual.getChange().getTopic()).isEqualTo(topic);
-  }
-
-  @Test
-  public void rebuildIgnoresErrorIfChangeIsUpToDateAfter() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    assertChangeUpToDate(true, id);
-
-    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
-    setNotesMigration(false, false);
-    gApi.changes().id(id.get()).topic(name("a-topic"));
-    setInvalidNoteDbState(id);
-    assertChangeUpToDate(false, id);
-
-    // Force the next rebuild attempt to fail but also rebuild the change in the
-    // background.
-    rebuilderWrapper.stealNextUpdate();
-    setNotesMigration(true, true);
-    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
-    assertChangeUpToDate(true, id);
-
-    // Check that the bundles are equal.
-    ChangeBundle actual =
-        ChangeBundle.fromNotes(commentsUtil, notesFactory.create(dbProvider.get(), project, id));
-    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
-    assertThat(actual.differencesFrom(expected)).isEmpty();
-  }
-
-  @Test
-  public void rebuildReturnsCorrectResultEvenIfSavingToNoteDbFailed() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    assertChangeUpToDate(true, id);
-    ObjectId oldMetaId = getMetaRef(project, changeMetaRef(id));
-
-    // Make a ReviewDb change behind NoteDb's back.
-    setNotesMigration(false, false);
-    gApi.changes().id(id.get()).topic(name("a-topic"));
-    setInvalidNoteDbState(id);
-    assertChangeUpToDate(false, id);
-    assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId);
-
-    // Force the next rebuild attempt to fail.
-    rebuilderWrapper.failNextUpdate();
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
-
-    // Not up to date, but the actual returned state matches anyway.
-    assertChangeUpToDate(false, id);
-    assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId);
-    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
-    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
-    assertThat(actual.differencesFrom(expected)).isEmpty();
-    assertChangeUpToDate(false, id);
-
-    // Another rebuild attempt succeeds
-    notesFactory.create(dbProvider.get(), project, id);
-    assertThat(getMetaRef(project, changeMetaRef(id))).isNotEqualTo(oldMetaId);
-    assertChangeUpToDate(true, id);
-  }
-
-  @Test
-  public void rebuildReturnsDraftResultWhenRebuildingInChangeNotesFails() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment by user", null);
-    assertChangeUpToDate(true, id);
-
-    ObjectId oldMetaId = getMetaRef(allUsers, refsDraftComments(id, user.getId()));
-
-    // Add a draft behind NoteDb's back.
-    setNotesMigration(false, false);
-    putDraft(user, id, 1, "second comment by user", null);
-    setInvalidNoteDbState(id);
-    assertDraftsUpToDate(false, id, user);
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
-
-    // Force the next rebuild attempt to fail (in ChangeNotes).
-    rebuilderWrapper.failNextUpdate();
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
-    notes.getDraftComments(user.getId());
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
-
-    // Not up to date, but the actual returned state matches anyway.
-    assertDraftsUpToDate(false, id, user);
-    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
-    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
-    assertThat(actual.differencesFrom(expected)).isEmpty();
-
-    // Another rebuild attempt succeeds
-    notesFactory.create(dbProvider.get(), project, id);
-    assertChangeUpToDate(true, id);
-    assertDraftsUpToDate(true, id, user);
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isNotEqualTo(oldMetaId);
-  }
-
-  @Test
-  public void rebuildReturnsDraftResultWhenRebuildingInDraftCommentNotesFails() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment by user", null);
-    assertChangeUpToDate(true, id);
-
-    ObjectId oldMetaId = getMetaRef(allUsers, refsDraftComments(id, user.getId()));
-
-    // Add a draft behind NoteDb's back.
-    setNotesMigration(false, false);
-    putDraft(user, id, 1, "second comment by user", null);
-
-    ReviewDb db = getUnwrappedDb();
-    Change c = db.changes().get(id);
-    // Leave change meta ID alone so DraftCommentNotes does the rebuild.
-    ObjectId badSha = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    NoteDbChangeState bogusState =
-        new NoteDbChangeState(
-            id,
-            PrimaryStorage.REVIEW_DB,
-            Optional.of(
-                NoteDbChangeState.RefState.create(
-                    NoteDbChangeState.parse(c).getChangeMetaId(),
-                    ImmutableMap.of(user.getId(), badSha))),
-            Optional.empty());
-    c.setNoteDbState(bogusState.toString());
-    db.changes().update(Collections.singleton(c));
-
-    assertDraftsUpToDate(false, id, user);
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
-
-    // Force the next rebuild attempt to fail (in DraftCommentNotes).
-    rebuilderWrapper.failNextUpdate();
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
-    notes.getDraftComments(user.getId());
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
-
-    // Not up to date, but the actual returned state matches anyway.
-    assertChangeUpToDate(true, id);
-    assertDraftsUpToDate(false, id, user);
-    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
-    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
-    assertThat(actual.differencesFrom(expected)).isEmpty();
-
-    // Another rebuild attempt succeeds
-    notesFactory.create(dbProvider.get(), project, id).getDraftComments(user.getId());
-    assertChangeUpToDate(true, id);
-    assertDraftsUpToDate(true, id, user);
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isNotEqualTo(oldMetaId);
-  }
-
-  @Test
-  public void rebuildAutomaticallyWhenDraftsOutOfDate() throws Exception {
-    setNotesMigration(true, true);
-    setApiUser(user);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment", null);
-    assertDraftsUpToDate(true, id, user);
-
-    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
-    setNotesMigration(false, false);
-    putDraft(user, id, 1, "comment", null);
-    setInvalidNoteDbState(id);
-    assertDraftsUpToDate(false, id, user);
-
-    // On next NoteDb read, the drafts are transparently rebuilt.
-    setNotesMigration(true, true);
-    assertThat(gApi.changes().id(id.get()).current().drafts()).containsKey(PushOneCommit.FILE_NAME);
-    assertDraftsUpToDate(true, id, user);
-  }
-
-  @Test
-  public void pushCert() throws Exception {
-    // We don't have the code in our test harness to do signed pushes, so just
-    // use a hard-coded cert. This cert was actually generated by C git 2.2.0
-    // (albeit not for sending to Gerrit).
-    String cert =
-        "certificate version 0.1\n"
-            + "pusher Dave Borowitz <dborowitz@google.com> 1433954361 -0700\n"
-            + "pushee git://localhost/repo.git\n"
-            + "nonce 1433954361-bde756572d665bba81d8\n"
-            + "\n"
-            + "0000000000000000000000000000000000000000"
-            + "b981a177396fb47345b7df3e4d3f854c6bea7"
-            + "s/heads/master\n"
-            + "-----BEGIN PGP SIGNATURE-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "iQEcBAABAgAGBQJVeGg5AAoJEPfTicJkUdPkUggH/RKAeI9/i/LduuiqrL/SSdIa\n"
-            + "9tYaSqJKLbXz63M/AW4Sp+4u+dVCQvnAt/a35CVEnpZz6hN4Kn/tiswOWVJf4CO7\n"
-            + "htNubGs5ZMwvD6sLYqKAnrM3WxV/2TbbjzjZW6Jkidz3jz/WRT4SmjGYiEO7aA+V\n"
-            + "4ZdIS9f7sW5VsHHYlNThCA7vH8Uu48bUovFXyQlPTX0pToSgrWV3JnTxDNxfn3iG\n"
-            + "IL0zTY/qwVCdXgFownLcs6J050xrrBWIKqfcWr3u4D2aCLyR0v+S/KArr7ulZygY\n"
-            + "+SOklImn8TAZiNxhWtA6ens66IiammUkZYFv7SSzoPLFZT4dC84SmGPWgf94NoQ=\n"
-            + "=XFeC\n"
-            + "-----END PGP SIGNATURE-----\n";
-
-    PushOneCommit.Result r = createChange();
-    PatchSet.Id psId = r.getPatchSetId();
-    Change.Id id = psId.getParentKey();
-
-    PatchSet ps = db.patchSets().get(psId);
-    ps.setPushCertificate(cert);
-    db.patchSets().update(Collections.singleton(ps));
-    indexer.index(db, project, id);
-
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void emptyTopic() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    Change c = db.changes().get(id);
-    assertThat(c.getTopic()).isNull();
-    c.setTopic("");
-    db.changes().update(Collections.singleton(c));
-
-    checker.rebuildAndCheckChanges(id);
-
-    setNotesMigration(true, true);
-
-    // Rebuild and check was successful, but NoteDb doesn't support storing an
-    // empty topic, so it comes out as null.
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(notes.getChange().getTopic()).isNull();
-  }
-
-  @Test
-  public void commentBeforeFirstPatchSet() throws Exception {
-    PushOneCommit.Result r = createChange();
-    PatchSet.Id psId = r.getPatchSetId();
-    Change.Id id = psId.getParentKey();
-
-    Change c = db.changes().get(id);
-    c.setCreatedOn(new Timestamp(c.getCreatedOn().getTime() - 5000));
-    db.changes().update(Collections.singleton(c));
-    indexer.index(db, project, id);
-
-    ReviewInput rin = new ReviewInput();
-    rin.message = "comment";
-
-    Timestamp ts = new Timestamp(c.getCreatedOn().getTime() + 2000);
-    assertThat(ts).isGreaterThan(c.getCreatedOn());
-    assertThat(ts).isLessThan(db.patchSets().get(psId).getCreatedOn());
-    RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId());
-    postReview.get().apply(batchUpdateFactory, revRsrc, rin, ts);
-
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void commentPredatingChangeBySomeoneOtherThanOwner() throws Exception {
-    PushOneCommit.Result r = createChange();
-    PatchSet.Id psId = r.getPatchSetId();
-    Change.Id id = psId.getParentKey();
-    Change c = db.changes().get(id);
-
-    ReviewInput rin = new ReviewInput();
-    rin.message = "comment";
-
-    Timestamp ts = new Timestamp(c.getCreatedOn().getTime() - 10000);
-    RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId());
-    setApiUser(user);
-    postReview.get().apply(batchUpdateFactory, revRsrc, rin, ts);
-
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void noteDbUsesOriginalSubjectFromPatchSetAndIgnoresChangeField() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String orig = r.getChange().change().getSubject();
-    r =
-        pushFactory
-            .create(
-                db,
-                admin.getIdent(),
-                testRepo,
-                orig + " v2",
-                PushOneCommit.FILE_NAME,
-                "new contents",
-                r.getChangeId())
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    PatchSet.Id psId = r.getPatchSetId();
-    Change.Id id = psId.getParentKey();
-    Change c = db.changes().get(id);
-
-    c.setCurrentPatchSet(psId, c.getSubject(), "Bogus original subject");
-    db.changes().update(Collections.singleton(c));
-
-    checker.rebuildAndCheckChanges(id);
-
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    Change nc = notes.getChange();
-    assertThat(nc.getSubject()).isEqualTo(c.getSubject());
-    assertThat(nc.getSubject()).isEqualTo(orig + " v2");
-    assertThat(nc.getOriginalSubject()).isNotEqualTo(c.getOriginalSubject());
-    assertThat(nc.getOriginalSubject()).isEqualTo(orig);
-  }
-
-  @Test
-  public void ignorePatchLineCommentsOnPatchSet0() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change change = r.getChange().change();
-    Change.Id id = change.getId();
-
-    PatchLineComment comment =
-        new PatchLineComment(
-            new PatchLineComment.Key(
-                new Patch.Key(new PatchSet.Id(id, 0), PushOneCommit.FILE_NAME), "uuid"),
-            0,
-            user.getId(),
-            null,
-            TimeUtil.nowTs());
-    comment.setSide((short) 1);
-    comment.setMessage("message");
-    comment.setStatus(PatchLineComment.Status.PUBLISHED);
-    db.patchComments().insert(Collections.singleton(comment));
-    indexer.index(db, change.getProject(), id);
-
-    checker.rebuildAndCheckChanges(id);
-
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(notes.getComments()).isEmpty();
-  }
-
-  @Test
-  public void leadingSpacesInSubject() throws Exception {
-    String subj = "   " + PushOneCommit.SUBJECT;
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            subj,
-            PushOneCommit.FILE_NAME,
-            PushOneCommit.FILE_CONTENT);
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    Change change = r.getChange().change();
-    assertThat(change.getSubject()).isEqualTo(subj);
-    Change.Id id = r.getPatchSetId().getParentKey();
-
-    checker.rebuildAndCheckChanges(id);
-
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(notes.getChange().getSubject()).isNotEqualTo(subj);
-    assertThat(notes.getChange().getSubject()).isEqualTo(PushOneCommit.SUBJECT);
-  }
-
-  @Test
-  public void allTimestampsExceptUpdatedAreEqualDueToBadMigration() throws Exception {
-    // https://bugs.chromium.org/p/gerrit/issues/detail?id=7397
-    PushOneCommit.Result r = createChange();
-    Change c = r.getChange().change();
-    Change.Id id = c.getId();
-    Timestamp ts = TimeUtil.nowTs();
-    Timestamp origUpdated = c.getLastUpdatedOn();
-
-    c.setCreatedOn(ts);
-    assertThat(c.getCreatedOn()).isGreaterThan(c.getLastUpdatedOn());
-    db.changes().update(Collections.singleton(c));
-
-    List<ChangeMessage> cm = db.changeMessages().byChange(id).toList();
-    cm.forEach(m -> m.setWrittenOn(ts));
-    db.changeMessages().update(cm);
-
-    List<PatchSet> ps = db.patchSets().byChange(id).toList();
-    ps.forEach(p -> p.setCreatedOn(ts));
-    db.patchSets().update(ps);
-
-    List<PatchSetApproval> psa = db.patchSetApprovals().byChange(id).toList();
-    psa.forEach(p -> p.setGranted(ts));
-    db.patchSetApprovals().update(psa);
-
-    List<PatchLineComment> plc = db.patchComments().byChange(id).toList();
-    plc.forEach(p -> p.setWrittenOn(ts));
-    db.patchComments().update(plc);
-
-    checker.rebuildAndCheckChanges(id);
-
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(notes.getChange().getCreatedOn()).isEqualTo(origUpdated);
-    assertThat(notes.getChange().getLastUpdatedOn()).isAtLeast(origUpdated);
-    assertThat(notes.getPatchSets().get(new PatchSet.Id(id, 1)).getCreatedOn())
-        .isEqualTo(origUpdated);
-  }
-
-  @Test
-  public void createWithAutoRebuildingDisabled() throws Exception {
-    ReviewDb oldDb = db;
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    ChangeNotes oldNotes = notesFactory.create(db, project, id);
-
-    // Make a ReviewDb change behind NoteDb's back.
-    Change c = oldDb.changes().get(id);
-    assertThat(c.getTopic()).isNull();
-    String topic = name("a-topic");
-    c.setTopic(topic);
-    oldDb.changes().update(Collections.singleton(c));
-
-    c = oldDb.changes().get(c.getId());
-    ChangeNotes newNotes = notesFactory.createWithAutoRebuildingDisabled(c, null);
-    assertThat(newNotes.getChange().getTopic()).isNotEqualTo(topic);
-    assertThat(newNotes.getChange().getTopic()).isEqualTo(oldNotes.getChange().getTopic());
-  }
-
-  @Test
-  public void rebuildDeletesOldDraftRefs() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment", null);
-
-    Account.Id otherAccountId = new Account.Id(user.getId().get() + 1234);
-    String otherDraftRef = refsDraftComments(id, otherAccountId);
-
-    try (Repository repo = repoManager.openRepository(allUsers);
-        ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId sha = ins.insert(OBJ_BLOB, "garbage data".getBytes(UTF_8));
-      ins.flush();
-      RefUpdate ru = repo.updateRef(otherDraftRef);
-      ru.setExpectedOldObjectId(ObjectId.zeroId());
-      ru.setNewObjectId(sha);
-      assertThat(ru.update()).isEqualTo(RefUpdate.Result.NEW);
-    }
-
-    checker.rebuildAndCheckChanges(id);
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(otherDraftRef)).isNull();
-    }
-  }
-
-  @Test
-  public void failWhenWritesDisabled() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    assertChangeUpToDate(true, id);
-    assertThat(gApi.changes().id(id.get()).info().topic).isNull();
-
-    // Turning off writes causes failure.
-    setNotesMigration(false, true);
-    try {
-      gApi.changes().id(id.get()).topic(name("a-topic"));
-      fail("Expected write to fail");
-    } catch (RestApiException e) {
-      assertChangesReadOnly(e);
-    }
-
-    // Update was not written.
-    assertThat(gApi.changes().id(id.get()).info().topic).isNull();
-    assertChangeUpToDate(true, id);
-  }
-
-  @Test
-  public void rebuildWhenWritesDisabledWorksButDoesNotWrite() throws Exception {
-    setNotesMigration(true, true);
-
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    assertChangeUpToDate(true, id);
-
-    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
-    setNotesMigration(false, false);
-    gApi.changes().id(id.get()).topic(name("a-topic"));
-    setInvalidNoteDbState(id);
-    assertChangeUpToDate(false, id);
-
-    // On next NoteDb read, change is rebuilt in-memory but not stored.
-    setNotesMigration(false, true);
-    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
-    assertChangeUpToDate(false, id);
-
-    // Attempting to write directly causes failure.
-    try {
-      gApi.changes().id(id.get()).topic(name("other-topic"));
-      fail("Expected write to fail");
-    } catch (RestApiException e) {
-      assertChangesReadOnly(e);
-    }
-
-    // Update was not written.
-    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
-    assertChangeUpToDate(false, id);
-  }
-
-  @Test
-  public void rebuildChangeWithNoPatchSets() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    db.changes().beginTransaction(id);
-    try {
-      db.patchSets().delete(db.patchSets().byChange(id));
-      db.commit();
-    } finally {
-      db.rollback();
-    }
-
-    exception.expect(NoPatchSetsException.class);
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void rebuildEntitiesCreatedByImpersonation() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    PatchSet.Id psId = new PatchSet.Id(id, 1);
-    String prefix = "/changes/" + id + "/revisions/current/";
-
-    // For each of the entities that have a real user field, create one entity
-    // without impersonation and one with.
-    CommentInput ci = new CommentInput();
-    ci.path = Patch.COMMIT_MSG;
-    ci.side = Side.REVISION;
-    ci.line = 1;
-    ci.message = "comment without impersonation";
-    ReviewInput ri = new ReviewInput();
-    ri.label("Code-Review", -1);
-    ri.message = "message without impersonation";
-    ri.drafts = DraftHandling.KEEP;
-    ri.comments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
-    userRestSession.post(prefix + "review", ri).assertOK();
-
-    DraftInput di = new DraftInput();
-    di.path = Patch.COMMIT_MSG;
-    di.side = Side.REVISION;
-    di.line = 1;
-    di.message = "draft without impersonation";
-    userRestSession.put(prefix + "drafts", di).assertCreated();
-
-    allowRunAs();
-    try {
-      Header runAs = new BasicHeader("X-Gerrit-RunAs", user.id.toString());
-      ci.message = "comment with impersonation";
-      ri.message = "message with impersonation";
-      ri.label("Code-Review", 1);
-      adminRestSession.postWithHeader(prefix + "review", ri, runAs).assertOK();
-
-      di.message = "draft with impersonation";
-      adminRestSession.putWithHeader(prefix + "drafts", runAs, di).assertCreated();
-    } finally {
-      removeRunAs();
-    }
-
-    List<ChangeMessage> msgs =
-        Ordering.natural()
-            .onResultOf(ChangeMessage::getWrittenOn)
-            .sortedCopy(db.changeMessages().byChange(id));
-    assertThat(msgs).hasSize(3);
-    assertThat(msgs.get(1).getMessage()).endsWith("message without impersonation");
-    assertThat(msgs.get(1).getAuthor()).isEqualTo(user.id);
-    assertThat(msgs.get(1).getRealAuthor()).isEqualTo(user.id);
-    assertThat(msgs.get(2).getMessage()).endsWith("message with impersonation");
-    assertThat(msgs.get(2).getAuthor()).isEqualTo(user.id);
-    assertThat(msgs.get(2).getRealAuthor()).isEqualTo(admin.id);
-
-    List<PatchSetApproval> psas = db.patchSetApprovals().byChange(id).toList();
-    assertThat(psas).hasSize(1);
-    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(0).getValue()).isEqualTo(1);
-    assertThat(psas.get(0).getAccountId()).isEqualTo(user.id);
-    assertThat(psas.get(0).getRealAccountId()).isEqualTo(admin.id);
-
-    Ordering<PatchLineComment> commentOrder =
-        Ordering.natural().onResultOf(PatchLineComment::getWrittenOn);
-    List<PatchLineComment> drafts =
-        commentOrder.sortedCopy(db.patchComments().draftByPatchSetAuthor(psId, user.id));
-    assertThat(drafts).hasSize(2);
-    assertThat(drafts.get(0).getMessage()).isEqualTo("draft without impersonation");
-    assertThat(drafts.get(0).getAuthor()).isEqualTo(user.id);
-    assertThat(drafts.get(0).getRealAuthor()).isEqualTo(user.id);
-    assertThat(drafts.get(1).getMessage()).isEqualTo("draft with impersonation");
-    assertThat(drafts.get(1).getAuthor()).isEqualTo(user.id);
-    assertThat(drafts.get(1).getRealAuthor()).isEqualTo(admin.id);
-
-    List<PatchLineComment> pub =
-        commentOrder.sortedCopy(db.patchComments().publishedByPatchSet(psId));
-    assertThat(pub).hasSize(2);
-    assertThat(pub.get(0).getMessage()).isEqualTo("comment without impersonation");
-    assertThat(pub.get(0).getAuthor()).isEqualTo(user.id);
-    assertThat(pub.get(0).getRealAuthor()).isEqualTo(user.id);
-    assertThat(pub.get(1).getMessage()).isEqualTo("comment with impersonation");
-    assertThat(pub.get(1).getAuthor()).isEqualTo(user.id);
-    assertThat(pub.get(1).getRealAuthor()).isEqualTo(admin.id);
-  }
-
-  @Test
-  public void laterEventsDependingOnEarlierPatchSetDontIntefereWithOtherPatchSets()
-      throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    ChangeData cd = r1.getChange();
-    Change.Id id = cd.getId();
-    amendChange(cd.change().getKey().get());
-    TestTimeUtil.incrementClock(90, TimeUnit.DAYS);
-
-    ReviewInput rin = ReviewInput.approve();
-    rin.message = "Some very late message on PS1";
-    gApi.changes().id(id.get()).revision(1).review(rin);
-
-    checker.rebuildAndCheckChanges(id);
-  }
-
-  @Test
-  public void ignoreChangeMessageBeyondCurrentPatchSet() throws Exception {
-    PushOneCommit.Result r = createChange();
-    PatchSet.Id psId1 = r.getPatchSetId();
-    Change.Id id = psId1.getParentKey();
-    gApi.changes().id(id.get()).current().review(ReviewInput.recommend());
-
-    r = amendChange(r.getChangeId());
-    PatchSet.Id psId2 = r.getPatchSetId();
-
-    assertThat(db.patchSets().byChange(id)).hasSize(2);
-    assertThat(db.changeMessages().byPatchSet(psId2)).hasSize(1);
-    db.patchSets().deleteKeys(Collections.singleton(psId2));
-
-    checker.rebuildAndCheckChanges(psId2.getParentKey());
-    setNotesMigration(true, true);
-
-    ChangeData cd = changeDataFactory.create(db, project, id);
-    assertThat(cd.change().currentPatchSetId()).isEqualTo(psId1);
-    assertThat(cd.patchSets().stream().map(ps -> ps.getId()).collect(toList()))
-        .containsExactly(psId1);
-    PatchSet ps = cd.currentPatchSet();
-    assertThat(ps).isNotNull();
-    assertThat(ps.getId()).isEqualTo(psId1);
-  }
-
-  @Test
-  public void highestNumberedPatchSetIsNotCurrent() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PatchSet.Id psId1 = r1.getPatchSetId();
-    Change.Id id = psId1.getParentKey();
-    PushOneCommit.Result r2 = amendChange(r1.getChangeId());
-    PatchSet.Id psId2 = r2.getPatchSetId();
-
-    try (BatchUpdate bu =
-        batchUpdateFactory.create(
-            db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs())) {
-      bu.addOp(
-          id,
-          new BatchUpdateOp() {
-            @Override
-            public boolean updateChange(ChangeContext ctx)
-                throws PatchSetInfoNotAvailableException {
-              ctx.getChange()
-                  .setCurrentPatchSet(patchSetInfoFactory.get(ctx.getDb(), ctx.getNotes(), psId1));
-              return true;
-            }
-          });
-      bu.execute();
-    }
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(psId1, psId2);
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId1);
-
-    assertThat(db.changes().get(id).currentPatchSetId()).isEqualTo(psId1);
-
-    checker.rebuildAndCheckChanges(id);
-    setNotesMigration(true, true);
-
-    notes = notesFactory.create(db, project, id);
-    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(psId1, psId2);
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId1);
-  }
-
-  @Test
-  public void resolveCommentsInheritsValueFromParentWhenUnspecified() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment", true);
-    putDraft(user, id, 1, "newComment", null);
-
-    Map<String, List<CommentInfo>> comments = gApi.changes().id(id.get()).current().drafts();
-    for (List<CommentInfo> cList : comments.values()) {
-      for (CommentInfo ci : cList) {
-        assertThat(ci.unresolved).isEqualTo(true);
-      }
-    }
-  }
-
-  @Test
-  public void rebuilderRespectsReadOnlyInNoteDbChangeState() throws Exception {
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-    PushOneCommit.Result r = createChange();
-    PatchSet.Id psId1 = r.getPatchSetId();
-    Change.Id id = psId1.getParentKey();
-
-    checker.rebuildAndCheckChanges(id);
-    setNotesMigration(true, true);
-
-    ReviewDb db = getUnwrappedDb();
-    Change c = db.changes().get(id);
-    NoteDbChangeState state = NoteDbChangeState.parse(c);
-    Timestamp until = new Timestamp(TimeUtil.nowMs() + MILLISECONDS.convert(1, DAYS));
-    state = state.withReadOnlyUntil(until);
-    c.setNoteDbState(state.toString());
-    db.changes().update(Collections.singleton(c));
-
-    try {
-      rebuilderWrapper.rebuild(db, id);
-      assert_().fail("expected rebuild to fail");
-    } catch (OrmRuntimeException e) {
-      assertThat(e.getMessage()).contains("read-only until");
-    }
-
-    TestTimeUtil.setClock(new Timestamp(until.getTime() + MILLISECONDS.convert(1, SECONDS)));
-    rebuilderWrapper.rebuild(db, id);
-  }
-
-  @Test
-  public void commitWithCrLineEndings() throws Exception {
-    PushOneCommit.Result r =
-        createChange("Subject\r\rBody\r", PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT);
-    Change c = r.getChange().change();
-
-    // This assertion demonstrates an arguable bug in JGit's commit subject
-    // parsing, and shows how this kind of data might have gotten into
-    // ReviewDb. If that bug ever gets fixed upstream, this assert may start
-    // failing. If that happens, this test can be rewritten to directly set the
-    // subject field in ReviewDb.
-    assertThat(c.getSubject()).isEqualTo("Subject\r\rBody");
-
-    checker.rebuildAndCheckChanges(c.getId());
-  }
-
-  @Test
-  public void patchSetsOutOfOrder() throws Exception {
-    String id = createChange().getChangeId();
-    amendChange(id);
-    PushOneCommit.Result r = amendChange(id);
-
-    ChangeData cd = r.getChange();
-    PatchSet.Id psId3 = cd.change().currentPatchSetId();
-    assertThat(psId3.get()).isEqualTo(3);
-
-    PatchSet ps1 = db.patchSets().get(new PatchSet.Id(cd.getId(), 1));
-    PatchSet ps3 = db.patchSets().get(psId3);
-    assertThat(ps1.getCreatedOn()).isLessThan(ps3.getCreatedOn());
-
-    // Simulate an old Gerrit bug by setting the created timestamp of the latest
-    // patch set ID to the timestamp of PS1.
-    ps3.setCreatedOn(ps1.getCreatedOn());
-    db.patchSets().update(Collections.singleton(ps3));
-
-    checker.rebuildAndCheckChanges(cd.getId());
-
-    setNotesMigration(true, true);
-    cd = changeDataFactory.create(db, project, cd.getId());
-    assertThat(cd.change().currentPatchSetId()).isEqualTo(psId3);
-
-    List<PatchSet> patchSets = ImmutableList.copyOf(cd.patchSets());
-    assertThat(patchSets).hasSize(3);
-
-    PatchSet newPs1 = patchSets.get(0);
-    assertThat(newPs1.getId()).isEqualTo(ps1.getId());
-    assertThat(newPs1.getCreatedOn()).isEqualTo(ps1.getCreatedOn());
-
-    PatchSet newPs2 = patchSets.get(1);
-    assertThat(newPs2.getCreatedOn()).isGreaterThan(newPs1.getCreatedOn());
-
-    PatchSet newPs3 = patchSets.get(2);
-    assertThat(newPs3.getId()).isEqualTo(ps3.getId());
-    // Migrated with a newer timestamp than the original, to preserve ordering.
-    assertThat(newPs3.getCreatedOn()).isAtLeast(newPs2.getCreatedOn());
-    assertThat(newPs3.getCreatedOn()).isGreaterThan(ps1.getCreatedOn());
-  }
-
-  @Test
-  public void ignoreNoteDbStateWithNoCorrespondingRefWhenWritesAndReadsDisabled() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    ReviewDb db = getUnwrappedDb();
-    Change c = db.changes().get(id);
-    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    db.changes().update(Collections.singleton(c));
-    c = db.changes().get(id);
-
-    String refName = RefNames.changeMetaRef(id);
-    assertThat(getMetaRef(project, refName)).isNull();
-
-    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
-    assertThat(notes.getChange().getRowVersion()).isEqualTo(c.getRowVersion());
-
-    notes = notesFactory.createChecked(dbProvider.get(), project, id);
-    assertThat(notes.getChange().getRowVersion()).isEqualTo(c.getRowVersion());
-
-    assertThat(getMetaRef(project, refName)).isNull();
-  }
-
-  @Test
-  public void autoRebuildMissingRefWriteOnly() throws Exception {
-    setNotesMigration(true, false);
-    testAutoRebuildMissingRef();
-  }
-
-  @Test
-  public void autoRebuildMissingRefReadWrite() throws Exception {
-    setNotesMigration(true, true);
-    testAutoRebuildMissingRef();
-  }
-
-  private void testAutoRebuildMissingRef() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    assertChangeUpToDate(true, id);
-    notesFactory.createChecked(db, project, id);
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      RefUpdate ru = repo.updateRef(RefNames.changeMetaRef(id));
-      ru.setForceUpdate(true);
-      assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
-    }
-    assertChangeUpToDate(false, id);
-
-    notesFactory.createChecked(db, project, id);
-    assertChangeUpToDate(true, id);
-  }
-
-  private void assertChangesReadOnly(RestApiException e) throws Exception {
-    Throwable cause = e.getCause();
-    assertThat(cause).isInstanceOf(UpdateException.class);
-    assertThat(cause.getCause()).isInstanceOf(OrmException.class);
-    assertThat(cause.getCause()).hasMessageThat().isEqualTo(NoteDbUpdateManager.CHANGES_READ_ONLY);
-  }
-
-  private void setInvalidNoteDbState(Change.Id id) throws Exception {
-    ReviewDb db = getUnwrappedDb();
-    Change c = db.changes().get(id);
-    // In reality we would have NoteDb writes enabled, which would write a real
-    // state into this field. For tests however, we turn NoteDb writes off, so
-    // just use a dummy state to force ChangeNotes to view the notes as
-    // out-of-date.
-    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    db.changes().update(Collections.singleton(c));
-  }
-
-  private void assertChangeUpToDate(boolean expected, Change.Id id) throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      Change c = getUnwrappedDb().changes().get(id);
-      assertThat(c).isNotNull();
-      assertThat(c.getNoteDbState()).isNotNull();
-      NoteDbChangeState state = NoteDbChangeState.parse(c);
-      assertThat(state).isNotNull();
-      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-      assertThat(state.isChangeUpToDate(new RepoRefCache(repo))).isEqualTo(expected);
-    }
-  }
-
-  private void assertDraftsUpToDate(boolean expected, Change.Id changeId, TestAccount account)
-      throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      Change c = getUnwrappedDb().changes().get(changeId);
-      assertThat(c).isNotNull();
-      assertThat(c.getNoteDbState()).isNotNull();
-      NoteDbChangeState state = NoteDbChangeState.parse(c);
-      assertThat(state.areDraftsUpToDate(new RepoRefCache(repo), account.getId()))
-          .isEqualTo(expected);
-    }
-  }
-
-  private ObjectId getMetaRef(Project.NameKey p, String name) throws Exception {
-    try (Repository repo = repoManager.openRepository(p)) {
-      Ref ref = repo.exactRef(name);
-      return ref != null ? ref.getObjectId() : null;
-    }
-  }
-
-  private void putDraft(TestAccount account, Change.Id id, int line, String msg, Boolean unresolved)
-      throws Exception {
-    DraftInput in = new DraftInput();
-    in.line = line;
-    in.message = msg;
-    in.path = PushOneCommit.FILE_NAME;
-    in.unresolved = unresolved;
-    AcceptanceTestRequestScope.Context old = setApiUser(account);
-    try {
-      gApi.changes().id(id.get()).current().createDraft(in);
-    } finally {
-      atrScope.set(old);
-    }
-  }
-
-  private void putComment(TestAccount account, Change.Id id, int line, String msg, String inReplyTo)
-      throws Exception {
-    CommentInput in = new CommentInput();
-    in.line = line;
-    in.message = msg;
-    in.inReplyTo = inReplyTo;
-    ReviewInput rin = new ReviewInput();
-    rin.comments = new HashMap<>();
-    rin.comments.put(PushOneCommit.FILE_NAME, ImmutableList.of(in));
-    rin.drafts = ReviewInput.DraftHandling.KEEP;
-    AcceptanceTestRequestScope.Context old = setApiUser(account);
-    try {
-      gApi.changes().id(id.get()).current().review(rin);
-    } finally {
-      atrScope.set(old);
-    }
-  }
-
-  private void publishDrafts(TestAccount account, Change.Id id) throws Exception {
-    ReviewInput rin = new ReviewInput();
-    rin.drafts = ReviewInput.DraftHandling.PUBLISH_ALL_REVISIONS;
-    AcceptanceTestRequestScope.Context old = setApiUser(account);
-    try {
-      gApi.changes().id(id.get()).current().review(rin);
-    } finally {
-      atrScope.set(old);
-    }
-  }
-
-  private ChangeMessage insertMessage(
-      Change.Id id, PatchSet.Id psId, Account.Id author, Timestamp ts, String message)
-      throws Exception {
-    ChangeMessage msg =
-        new ChangeMessage(new ChangeMessage.Key(id, ChangeUtil.messageUuid()), author, ts, psId);
-    msg.setMessage(message);
-    db.changeMessages().insert(Collections.singleton(msg));
-
-    Change c = db.changes().get(id);
-    if (ts.compareTo(c.getLastUpdatedOn()) > 0) {
-      c.setLastUpdatedOn(ts);
-      db.changes().update(Collections.singleton(c));
-    }
-
-    return msg;
-  }
-
-  private ReviewDb getUnwrappedDb() {
-    ReviewDb db = dbProvider.get();
-    return ReviewDbUtil.unwrapDb(db);
-  }
-
-  private void allowRunAs() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    Util.allow(
-        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
-    saveProjectConfig(allProjects, cfg);
-  }
-
-  private void removeRunAs() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    Util.remove(
-        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
-    saveProjectConfig(allProjects, cfg);
-  }
-
-  private Map<String, List<CommentInfo>> getPublishedComments(Change.Id id) throws Exception {
-    return gApi.changes().id(id.get()).current().comments();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
deleted file mode 100644
index f7204f3..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
+++ /dev/null
@@ -1,327 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Streams;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateListener;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-import java.util.concurrent.Callable;
-import java.util.concurrent.atomic.AtomicInteger;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevSort;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.Before;
-import org.junit.Test;
-
-public class NoteDbOnlyIT extends AbstractDaemonTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    Config cfg = new Config();
-    // Avoid spurious timeouts during intentional retries due to overloaded test machines.
-    cfg.setString("noteDb", null, "retryTimeout", Integer.MAX_VALUE + "s");
-    return cfg;
-  }
-
-  @Inject private RetryHelper retryHelper;
-
-  @Before
-  public void setUp() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-  }
-
-  @Test
-  public void updateChangeFailureRollsBackRefUpdate() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    String master = "refs/heads/master";
-    String backup = "refs/backup/master";
-    ObjectId master1 = getRef(master).get();
-    assertThat(getRef(backup)).isEmpty();
-
-    // Toy op that copies the value of refs/heads/master to refs/backup/master.
-    BatchUpdateOp backupMasterOp =
-        new BatchUpdateOp() {
-          ObjectId newId;
-
-          @Override
-          public void updateRepo(RepoContext ctx) throws IOException {
-            ObjectId oldId = ctx.getRepoView().getRef(backup).orElse(ObjectId.zeroId());
-            newId = ctx.getRepoView().getRef(master).get();
-            ctx.addRefUpdate(oldId, newId, backup);
-          }
-
-          @Override
-          public boolean updateChange(ChangeContext ctx) {
-            ctx.getUpdate(ctx.getChange().currentPatchSetId())
-                .setChangeMessage("Backed up master branch to " + newId.name());
-            return true;
-          }
-        };
-
-    try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
-      bu.addOp(id, backupMasterOp);
-      bu.execute();
-    }
-
-    // Ensure backupMasterOp worked.
-    assertThat(getRef(backup)).hasValue(master1);
-    assertThat(getMessages(id)).contains("Backed up master branch to " + master1.name());
-
-    // Advance master by submitting the change.
-    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
-    gApi.changes().id(id.get()).current().submit();
-    ObjectId master2 = getRef(master).get();
-    assertThat(master2).isNotEqualTo(master1);
-    int msgCount = getMessages(id).size();
-
-    try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
-      // This time, we attempt to back up master, but we fail during updateChange.
-      bu.addOp(id, backupMasterOp);
-      String msg = "Change is bad";
-      bu.addOp(
-          id,
-          new BatchUpdateOp() {
-            @Override
-            public boolean updateChange(ChangeContext ctx) throws ResourceConflictException {
-              throw new ResourceConflictException(msg);
-            }
-          });
-      try {
-        bu.execute();
-        assert_().fail("expected ResourceConflictException");
-      } catch (ResourceConflictException e) {
-        assertThat(e).hasMessageThat().isEqualTo(msg);
-      }
-    }
-
-    // If updateChange hadn't failed, backup would have been updated to master2.
-    assertThat(getRef(backup)).hasValue(master1);
-    assertThat(getMessages(id)).hasSize(msgCount);
-  }
-
-  @Test
-  public void retryOnLockFailureWithAtomicUpdates() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    String master = "refs/heads/master";
-    ObjectId initial;
-    try (Repository repo = repoManager.openRepository(project)) {
-      ensureAtomicTransactions(repo);
-      initial = repo.exactRef(master).getObjectId();
-    }
-
-    AtomicInteger updateRepoCalledCount = new AtomicInteger();
-    AtomicInteger updateChangeCalledCount = new AtomicInteger();
-    AtomicInteger afterUpdateReposCalledCount = new AtomicInteger();
-
-    String result =
-        retryHelper.execute(
-            batchUpdateFactory -> {
-              try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
-                bu.addOp(
-                    id,
-                    new UpdateRefAndAddMessageOp(updateRepoCalledCount, updateChangeCalledCount));
-                bu.execute(new ConcurrentWritingListener(afterUpdateReposCalledCount));
-              }
-              return "Done";
-            });
-
-    assertThat(result).isEqualTo("Done");
-    assertThat(updateRepoCalledCount.get()).isEqualTo(2);
-    assertThat(afterUpdateReposCalledCount.get()).isEqualTo(2);
-    assertThat(updateChangeCalledCount.get()).isEqualTo(2);
-
-    List<String> messages = getMessages(id);
-    assertThat(Iterables.getLast(messages)).isEqualTo(UpdateRefAndAddMessageOp.CHANGE_MESSAGE);
-    assertThat(Collections.frequency(messages, UpdateRefAndAddMessageOp.CHANGE_MESSAGE))
-        .isEqualTo(1);
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      // Op lost the race, so the other writer's commit happened first. Then op retried and wrote
-      // its commit with the other writer's commit as parent.
-      assertThat(commitMessages(repo, initial, repo.exactRef(master).getObjectId()))
-          .containsExactly(
-              ConcurrentWritingListener.MSG_PREFIX + "1", UpdateRefAndAddMessageOp.COMMIT_MESSAGE)
-          .inOrder();
-    }
-  }
-
-  @Test
-  public void missingChange() throws Exception {
-    Change.Id changeId = new Change.Id(1234567);
-    assertNoSuchChangeException(() -> notesFactory.create(db, project, changeId));
-    assertNoSuchChangeException(() -> notesFactory.createChecked(db, project, changeId));
-  }
-
-  private void assertNoSuchChangeException(Callable<?> callable) throws Exception {
-    try {
-      callable.call();
-      assert_().fail("expected NoSuchChangeException");
-    } catch (NoSuchChangeException e) {
-      // Expected.
-    }
-  }
-
-  private class ConcurrentWritingListener implements BatchUpdateListener {
-    static final String MSG_PREFIX = "Other writer ";
-
-    private final AtomicInteger calledCount;
-
-    private ConcurrentWritingListener(AtomicInteger calledCount) {
-      this.calledCount = calledCount;
-    }
-
-    @Override
-    public void afterUpdateRepos() throws Exception {
-      // Reopen repo and update ref, to simulate a concurrent write in another
-      // thread. Only do this the first time the listener is called.
-      if (calledCount.getAndIncrement() > 0) {
-        return;
-      }
-      try (Repository repo = repoManager.openRepository(project);
-          RevWalk rw = new RevWalk(repo);
-          ObjectInserter ins = repo.newObjectInserter()) {
-        String master = "refs/heads/master";
-        ObjectId oldId = repo.exactRef(master).getObjectId();
-        ObjectId newId = newCommit(rw, ins, oldId, MSG_PREFIX + calledCount.get());
-        ins.flush();
-        RefUpdate ru = repo.updateRef(master);
-        ru.setExpectedOldObjectId(oldId);
-        ru.setNewObjectId(newId);
-        assertThat(ru.update(rw)).isEqualTo(RefUpdate.Result.FAST_FORWARD);
-      }
-    }
-  }
-
-  private class UpdateRefAndAddMessageOp implements BatchUpdateOp {
-    static final String COMMIT_MESSAGE = "A commit";
-    static final String CHANGE_MESSAGE = "A change message";
-
-    private final AtomicInteger updateRepoCalledCount;
-    private final AtomicInteger updateChangeCalledCount;
-
-    private UpdateRefAndAddMessageOp(
-        AtomicInteger updateRepoCalledCount, AtomicInteger updateChangeCalledCount) {
-      this.updateRepoCalledCount = updateRepoCalledCount;
-      this.updateChangeCalledCount = updateChangeCalledCount;
-    }
-
-    @Override
-    public void updateRepo(RepoContext ctx) throws Exception {
-      String master = "refs/heads/master";
-      ObjectId oldId = ctx.getRepoView().getRef(master).get();
-      ObjectId newId = newCommit(ctx.getRevWalk(), ctx.getInserter(), oldId, COMMIT_MESSAGE);
-      ctx.addRefUpdate(oldId, newId, master);
-      updateRepoCalledCount.incrementAndGet();
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws Exception {
-      ctx.getUpdate(ctx.getChange().currentPatchSetId()).setChangeMessage(CHANGE_MESSAGE);
-      updateChangeCalledCount.incrementAndGet();
-      return true;
-    }
-  }
-
-  private ObjectId newCommit(RevWalk rw, ObjectInserter ins, ObjectId parent, String msg)
-      throws IOException {
-    PersonIdent ident = serverIdent.get();
-    CommitBuilder cb = new CommitBuilder();
-    cb.setParentId(parent);
-    cb.setTreeId(rw.parseCommit(parent).getTree());
-    cb.setMessage(msg);
-    cb.setAuthor(ident);
-    cb.setCommitter(ident);
-    return ins.insert(Constants.OBJ_COMMIT, cb.build());
-  }
-
-  private BatchUpdate newBatchUpdate(BatchUpdate.Factory buf) {
-    return buf.create(db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs());
-  }
-
-  private Optional<ObjectId> getRef(String name) throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      return Optional.ofNullable(repo.exactRef(name)).map(Ref::getObjectId);
-    }
-  }
-
-  private List<String> getMessages(Change.Id id) throws Exception {
-    return gApi.changes()
-        .id(id.get())
-        .get(MESSAGES)
-        .messages
-        .stream()
-        .map(m -> m.message)
-        .collect(toList());
-  }
-
-  private static List<String> commitMessages(
-      Repository repo, ObjectId fromExclusive, ObjectId toInclusive) throws Exception {
-    try (RevWalk rw = new RevWalk(repo)) {
-      rw.markStart(rw.parseCommit(toInclusive));
-      rw.markUninteresting(rw.parseCommit(fromExclusive));
-      rw.sort(RevSort.REVERSE);
-      rw.setRetainBody(true);
-      return Streams.stream(rw).map(c -> c.getShortMessage()).collect(toList());
-    }
-  }
-
-  private void ensureAtomicTransactions(Repository repo) throws Exception {
-    if (repo instanceof InMemoryRepository) {
-      ((InMemoryRepository) repo).setPerformsAtomicTransactions(true);
-    } else {
-      assertThat(repo.getRefDatabase().performsAtomicTransactions())
-          .named("performsAtomicTransactions on %s", repo)
-          .isTrue();
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
deleted file mode 100644
index d81bf6b..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
+++ /dev/null
@@ -1,536 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.formatTime;
-import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
-import static java.util.concurrent.TimeUnit.DAYS;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.toList;
-
-import com.github.rholder.retry.Retryer;
-import com.github.rholder.retry.RetryerBuilder;
-import com.github.rholder.retry.StopStrategies;
-import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.RepoRefCache;
-import com.google.gerrit.server.notedb.ChangeBundle;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.PrimaryStorageMigrator;
-import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.NoteDbMode;
-import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.inject.Inject;
-import com.google.inject.util.Providers;
-import java.sql.Timestamp;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class NoteDbPrimaryIT extends AbstractDaemonTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    Config cfg = new Config();
-    cfg.setString("notedb", null, "concurrentWriterTimeout", "0s");
-    cfg.setString("notedb", null, "primaryStorageMigrationTimeout", "1d");
-    cfg.setBoolean("noteDb", null, "testRebuilderWrapper", true);
-    return cfg;
-  }
-
-  @Inject private AllUsersName allUsers;
-  @Inject private ChangeBundleReader bundleReader;
-  @Inject private CommentsUtil commentsUtil;
-  @Inject private TestChangeRebuilderWrapper rebuilderWrapper;
-  @Inject private ChangeNotes.Factory changeNotesFactory;
-  @Inject private ChangeUpdate.Factory updateFactory;
-  @Inject private InternalUser.Factory internalUserFactory;
-  @Inject private RetryHelper retryHelper;
-
-  private PrimaryStorageMigrator migrator;
-
-  @Before
-  public void setUp() throws Exception {
-    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.READ_WRITE);
-    db = ReviewDbUtil.unwrapDb(db);
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-    migrator = newMigrator(null);
-  }
-
-  private PrimaryStorageMigrator newMigrator(
-      @Nullable Retryer<NoteDbChangeState> ensureRebuiltRetryer) {
-    return new PrimaryStorageMigrator(
-        cfg,
-        Providers.of(db),
-        repoManager,
-        allUsers,
-        rebuilderWrapper,
-        ensureRebuiltRetryer,
-        changeNotesFactory,
-        queryProvider,
-        updateFactory,
-        internalUserFactory,
-        retryHelper);
-  }
-
-  @After
-  public void tearDown() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @Test
-  public void updateChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    setNoteDbPrimary(id);
-
-    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
-    gApi.changes().id(id.get()).current().submit();
-
-    ChangeInfo info = gApi.changes().id(id.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-    ApprovalInfo approval = Iterables.getOnlyElement(info.labels.get("Code-Review").all);
-    assertThat(approval._accountId).isEqualTo(admin.id.get());
-    assertThat(approval.value).isEqualTo(2);
-    assertThat(info.messages).hasSize(3);
-    assertThat(Iterables.getLast(info.messages).message)
-        .isEqualTo("Change has been successfully merged by " + admin.fullName);
-
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertThat(notes.getChange().getNoteDbState())
-        .isEqualTo(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-
-    // Writes weren't reflected in ReviewDb.
-    assertThat(db.changes().get(id).getStatus()).isEqualTo(Change.Status.NEW);
-    assertThat(db.patchSetApprovals().byChange(id)).isEmpty();
-    assertThat(db.changeMessages().byChange(id)).hasSize(1);
-  }
-
-  @Test
-  public void deleteDraftComment() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    setNoteDbPrimary(id);
-
-    DraftInput din = new DraftInput();
-    din.path = PushOneCommit.FILE_NAME;
-    din.line = 1;
-    din.message = "A comment";
-    gApi.changes().id(id.get()).current().createDraft(din);
-
-    CommentInfo di =
-        Iterables.getOnlyElement(
-            gApi.changes().id(id.get()).current().drafts().get(PushOneCommit.FILE_NAME));
-    assertThat(di.message).isEqualTo(din.message);
-
-    assertThat(db.patchComments().draftByChangeFileAuthor(id, din.path, admin.id)).isEmpty();
-
-    gApi.changes().id(id.get()).current().draft(di.id).delete();
-    assertThat(gApi.changes().id(id.get()).current().drafts()).isEmpty();
-  }
-
-  @Test
-  public void deleteVote() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    setNoteDbPrimary(id);
-
-    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
-    List<ApprovalInfo> approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
-    assertThat(approvals).hasSize(1);
-    assertThat(approvals.get(0).value).isEqualTo(2);
-
-    gApi.changes().id(id.get()).reviewer(admin.id.toString()).deleteVote("Code-Review");
-
-    approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
-    assertThat(approvals).hasSize(1);
-    assertThat(approvals.get(0).value).isEqualTo(0);
-  }
-
-  @Test
-  public void deleteVoteViaReview() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    setNoteDbPrimary(id);
-
-    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
-    List<ApprovalInfo> approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
-    assertThat(approvals).hasSize(1);
-    assertThat(approvals.get(0).value).isEqualTo(2);
-
-    gApi.changes().id(id.get()).current().review(ReviewInput.noScore());
-
-    approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
-    assertThat(approvals).hasSize(1);
-    assertThat(approvals.get(0).value).isEqualTo(0);
-  }
-
-  @Test
-  public void deleteReviewer() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    setNoteDbPrimary(id);
-
-    gApi.changes().id(id.get()).addReviewer(user.id.toString());
-    assertThat(getReviewers(id)).containsExactly(user.id);
-    gApi.changes().id(id.get()).reviewer(user.id.toString()).remove();
-    assertThat(getReviewers(id)).isEmpty();
-  }
-
-  @Test
-  public void readOnlyReviewDb() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    testReadOnly(id);
-  }
-
-  @Test
-  public void readOnlyNoteDb() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    setNoteDbPrimary(id);
-    testReadOnly(id);
-  }
-
-  private void testReadOnly(Change.Id id) throws Exception {
-    Timestamp before = TimeUtil.nowTs();
-    Timestamp until = new Timestamp(before.getTime() + 1000 * 3600);
-
-    // Set read-only.
-    Change c = db.changes().get(id);
-    assertThat(c).named("change " + id).isNotNull();
-    NoteDbChangeState state = NoteDbChangeState.parse(c);
-    state = state.withReadOnlyUntil(until);
-    c.setNoteDbState(state.toString());
-    db.changes().update(Collections.singleton(c));
-
-    assertThat(gApi.changes().id(id.get()).get().subject).isEqualTo(PushOneCommit.SUBJECT);
-    assertThat(gApi.changes().id(id.get()).get().topic).isNull();
-    try {
-      gApi.changes().id(id.get()).topic("a-topic");
-      assert_().fail("expected read-only exception");
-    } catch (RestApiException e) {
-      Optional<Throwable> oe =
-          Throwables.getCausalChain(e)
-              .stream()
-              .filter(x -> x instanceof OrmRuntimeException)
-              .findFirst();
-      assertThat(oe).named("OrmRuntimeException in causal chain of " + e).isPresent();
-      assertThat(oe.get().getMessage()).contains("read-only");
-    }
-    assertThat(gApi.changes().id(id.get()).get().topic).isNull();
-
-    TestTimeUtil.setClock(new Timestamp(until.getTime() + 1000));
-    assertThat(gApi.changes().id(id.get()).get().subject).isEqualTo(PushOneCommit.SUBJECT);
-    gApi.changes().id(id.get()).topic("a-topic");
-    assertThat(gApi.changes().id(id.get()).get().topic).isEqualTo("a-topic");
-  }
-
-  @Test
-  public void migrateToNoteDb() throws Exception {
-    testMigrateToNoteDb(createChange().getChange().getId());
-  }
-
-  @Test
-  public void migrateToNoteDbWithRebuildingFirst() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    Change c = db.changes().get(id);
-    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    db.changes().update(Collections.singleton(c));
-    testMigrateToNoteDb(id);
-  }
-
-  private void testMigrateToNoteDb(Change.Id id) throws Exception {
-    assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.REVIEW_DB);
-    migrator.migrateToNoteDbPrimary(id);
-    assertNoteDbPrimary(id);
-
-    gApi.changes().id(id.get()).topic("a-topic");
-    assertThat(gApi.changes().id(id.get()).get().topic).isEqualTo("a-topic");
-    assertThat(db.changes().get(id).getTopic()).isNull();
-  }
-
-  @Test
-  public void migrateToNoteDbFailsRebuildingOnceAndRetries() throws Exception {
-    Change.Id id = createChange().getChange().getId();
-
-    Change c = db.changes().get(id);
-    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    db.changes().update(Collections.singleton(c));
-    rebuilderWrapper.failNextUpdate();
-
-    migrator =
-        newMigrator(
-            RetryerBuilder.<NoteDbChangeState>newBuilder()
-                .retryIfException()
-                .withStopStrategy(StopStrategies.neverStop())
-                .build());
-    migrator.migrateToNoteDbPrimary(id);
-    assertNoteDbPrimary(id);
-  }
-
-  @Test
-  public void migrateToNoteDbFailsRebuildingAndStops() throws Exception {
-    Change.Id id = createChange().getChange().getId();
-
-    Change c = db.changes().get(id);
-    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    db.changes().update(Collections.singleton(c));
-    rebuilderWrapper.failNextUpdate();
-
-    migrator =
-        newMigrator(
-            RetryerBuilder.<NoteDbChangeState>newBuilder()
-                .retryIfException()
-                .withStopStrategy(StopStrategies.stopAfterAttempt(1))
-                .build());
-    exception.expect(OrmException.class);
-    exception.expectMessage("Retrying failed");
-    migrator.migrateToNoteDbPrimary(id);
-  }
-
-  @Test
-  public void migrateToNoteDbMissingOldState() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    Change c = db.changes().get(id);
-    c.setNoteDbState(null);
-    db.changes().update(Collections.singleton(c));
-
-    exception.expect(OrmRuntimeException.class);
-    exception.expectMessage("no note_db_state");
-    migrator.migrateToNoteDbPrimary(id);
-  }
-
-  @Test
-  public void migrateToNoteDbLeaseExpires() throws Exception {
-    TestTimeUtil.resetWithClockStep(2, DAYS);
-    exception.expect(OrmRuntimeException.class);
-    exception.expectMessage("read-only lease");
-    migrator.migrateToNoteDbPrimary(createChange().getChange().getId());
-  }
-
-  @Test
-  public void migrateToNoteDbAlreadyReadOnly() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    Change c = db.changes().get(id);
-    NoteDbChangeState state = NoteDbChangeState.parse(c);
-    Timestamp until = new Timestamp(TimeUtil.nowMs() + MILLISECONDS.convert(1, DAYS));
-    state = state.withReadOnlyUntil(until);
-    c.setNoteDbState(state.toString());
-    db.changes().update(Collections.singleton(c));
-
-    exception.expect(OrmRuntimeException.class);
-    exception.expectMessage("read-only until " + until);
-    migrator.migrateToNoteDbPrimary(id);
-  }
-
-  @Test
-  public void migrateToNoteDbAlreadyMigrated() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.REVIEW_DB);
-    migrator.migrateToNoteDbPrimary(id);
-    assertNoteDbPrimary(id);
-
-    migrator.migrateToNoteDbPrimary(id);
-    assertNoteDbPrimary(id);
-  }
-
-  @Test
-  public void rebuildReviewDb() throws Exception {
-    Change c = createChange().getChange().change();
-    Change.Id id = c.getId();
-
-    CommentInput cin = new CommentInput();
-    cin.line = 1;
-    cin.message = "Published comment";
-    ReviewInput rin = ReviewInput.approve();
-    rin.comments = ImmutableMap.of(PushOneCommit.FILE_NAME, ImmutableList.of(cin));
-    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
-
-    DraftInput din = new DraftInput();
-    din.path = PushOneCommit.FILE_NAME;
-    din.line = 1;
-    din.message = "Draft comment";
-    gApi.changes().id(id.get()).current().createDraft(din);
-    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
-    gApi.changes().id(id.get()).current().createDraft(din);
-
-    assertThat(db.changeMessages().byChange(id)).isNotEmpty();
-    assertThat(db.patchSets().byChange(id)).isNotEmpty();
-    assertThat(db.patchSetApprovals().byChange(id)).isNotEmpty();
-    assertThat(db.patchComments().byChange(id)).isNotEmpty();
-
-    ChangeBundle noteDbBundle =
-        ChangeBundle.fromNotes(commentsUtil, notesFactory.create(db, project, id));
-
-    setNoteDbPrimary(id);
-
-    db.changeMessages().delete(db.changeMessages().byChange(id));
-    db.patchSets().delete(db.patchSets().byChange(id));
-    db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
-    db.patchComments().delete(db.patchComments().byChange(id));
-    ChangeMessage bogusMessage =
-        ChangeMessagesUtil.newMessage(
-            c.currentPatchSetId(),
-            identifiedUserFactory.create(admin.getId()),
-            TimeUtil.nowTs(),
-            "some message",
-            null);
-    db.changeMessages().insert(Collections.singleton(bogusMessage));
-
-    rebuilderWrapper.rebuildReviewDb(db, project, id);
-
-    assertThat(db.changeMessages().byChange(id)).isNotEmpty();
-    assertThat(db.patchSets().byChange(id)).isNotEmpty();
-    assertThat(db.patchSetApprovals().byChange(id)).isNotEmpty();
-    assertThat(db.patchComments().byChange(id)).isNotEmpty();
-
-    ChangeBundle reviewDbBundle = bundleReader.fromReviewDb(ReviewDbUtil.unwrapDb(db), id);
-    assertThat(reviewDbBundle.differencesFrom(noteDbBundle)).isEmpty();
-  }
-
-  @Test
-  public void rebuildReviewDbRequiresNoteDbPrimary() throws Exception {
-    Change.Id id = createChange().getChange().getId();
-
-    exception.expect(OrmException.class);
-    exception.expectMessage("primary storage of " + id + " is REVIEW_DB");
-    rebuilderWrapper.rebuildReviewDb(db, project, id);
-  }
-
-  @Test
-  public void migrateBackToReviewDbPrimary() throws Exception {
-    Change c = createChange().getChange().change();
-    Change.Id id = c.getId();
-
-    migrator.migrateToNoteDbPrimary(id);
-    assertNoteDbPrimary(id);
-
-    gApi.changes().id(id.get()).topic("new-topic");
-    assertThat(gApi.changes().id(id.get()).topic()).isEqualTo("new-topic");
-    assertThat(db.changes().get(id).getTopic()).isNotEqualTo("new-topic");
-
-    migrator.migrateToReviewDbPrimary(id, null);
-    ObjectId metaId;
-    try (Repository repo = repoManager.openRepository(c.getProject());
-        RevWalk rw = new RevWalk(repo)) {
-      metaId = repo.exactRef(RefNames.changeMetaRef(id)).getObjectId();
-      RevCommit commit = rw.parseCommit(metaId);
-      rw.parseBody(commit);
-      assertThat(commit.getFullMessage())
-          .contains("Read-only-until: " + formatTime(serverIdent.get(), new Timestamp(0)));
-    }
-    NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
-    assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-    assertThat(state.getChangeMetaId()).isEqualTo(metaId);
-    assertThat(gApi.changes().id(id.get()).topic()).isEqualTo("new-topic");
-    assertThat(db.changes().get(id).getTopic()).isEqualTo("new-topic");
-
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(notes.getRevision()).isEqualTo(metaId); // No rebuilding, change was up to date.
-    assertThat(notes.getReadOnlyUntil()).isNotNull();
-
-    gApi.changes().id(id.get()).topic("reviewdb-topic");
-    assertThat(db.changes().get(id).getTopic()).isEqualTo("reviewdb-topic");
-  }
-
-  private void setNoteDbPrimary(Change.Id id) throws Exception {
-    Change c = db.changes().get(id);
-    assertThat(c).named("change " + id).isNotNull();
-    NoteDbChangeState state = NoteDbChangeState.parse(c);
-    assertThat(state.getPrimaryStorage()).named("storage of " + id).isEqualTo(REVIEW_DB);
-
-    try (Repository changeRepo = repoManager.openRepository(c.getProject());
-        Repository allUsersRepo = repoManager.openRepository(allUsers)) {
-      assertThat(state.isUpToDate(new RepoRefCache(changeRepo), new RepoRefCache(allUsersRepo)))
-          .named("change " + id + " up to date")
-          .isTrue();
-    }
-
-    c.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-    db.changes().update(Collections.singleton(c));
-  }
-
-  private void assertNoteDbPrimary(Change.Id id) throws Exception {
-    assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.NOTE_DB);
-  }
-
-  private List<Account.Id> getReviewers(Change.Id id) throws Exception {
-    return gApi.changes()
-        .id(id.get())
-        .get()
-        .reviewers
-        .values()
-        .stream()
-        .flatMap(Collection::stream)
-        .map(a -> new Account.Id(a._accountId))
-        .collect(toList());
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
deleted file mode 100644
index 6b14263..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
+++ /dev/null
@@ -1,566 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.server.notedb.NotesMigrationState.NOTE_DB;
-import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_NO_SEQUENCE;
-import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY;
-import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY;
-import static com.google.gerrit.server.notedb.NotesMigrationState.REVIEW_DB;
-import static com.google.gerrit.server.notedb.NotesMigrationState.WRITE;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.easymock.EasyMock.createStrictMock;
-import static org.easymock.EasyMock.expectLastCall;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.verify;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.Sandboxed;
-import com.google.gerrit.acceptance.UseLocalDisk;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
-import com.google.gerrit.server.notedb.NotesMigrationState;
-import com.google.gerrit.server.notedb.rebuild.MigrationException;
-import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
-import com.google.gerrit.server.notedb.rebuild.NotesMigrationStateListener;
-import com.google.gerrit.server.schema.ReviewDbFactory;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.NoteDbMode;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.nio.file.FileVisitResult;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.SimpleFileVisitor;
-import java.nio.file.attribute.BasicFileAttributes;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Set;
-import java.util.SortedSet;
-import java.util.TreeSet;
-import org.eclipse.jgit.internal.storage.file.FileRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-@Sandboxed
-@UseLocalDisk
-@NoHttpd
-public class OnlineNoteDbMigrationIT extends AbstractDaemonTest {
-  private static final String INVALID_STATE = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    Config cfg = new Config();
-    cfg.setInt("noteDb", "changes", "sequenceBatchSize", 10);
-    cfg.setInt("noteDb", "changes", "initialSequenceGap", 500);
-    return cfg;
-  }
-
-  // Tests in this class are generally interested in the actual ReviewDb contents, but the shifting
-  // migration state may result in various kinds of wrappers showing up unexpectedly.
-  @Inject @ReviewDbFactory private SchemaFactory<ReviewDb> schemaFactory;
-
-  @Inject private SitePaths sitePaths;
-  @Inject private Provider<NoteDbMigrator.Builder> migratorBuilderProvider;
-  @Inject private Sequences sequences;
-  @Inject private DynamicSet<NotesMigrationStateListener> listeners;
-
-  private FileBasedConfig noteDbConfig;
-  private List<RegistrationHandle> addedListeners;
-
-  @Before
-  public void setUp() throws Exception {
-    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.OFF);
-    // Unlike in the running server, for tests, we don't stack notedb.config on gerrit.config.
-    noteDbConfig = new FileBasedConfig(sitePaths.notedb_config.toFile(), FS.detect());
-    assertNotesMigrationState(REVIEW_DB, false, false);
-    addedListeners = new ArrayList<>();
-  }
-
-  @After
-  public void tearDown() throws Exception {
-    if (addedListeners != null) {
-      addedListeners.forEach(RegistrationHandle::remove);
-      addedListeners = null;
-    }
-  }
-
-  @Test
-  public void preconditionsFail() throws Exception {
-    List<Change.Id> cs = ImmutableList.of(new Change.Id(1));
-    List<Project.NameKey> ps = ImmutableList.of(new Project.NameKey("p"));
-    assertMigrationException(
-        "Cannot rebuild without noteDb.changes.write=true", b -> b, NoteDbMigrator::rebuild);
-    assertMigrationException(
-        "Cannot set both changes and projects", b -> b.setChanges(cs).setProjects(ps), m -> {});
-    assertMigrationException(
-        "Cannot set changes or projects during full migration",
-        b -> b.setChanges(cs),
-        NoteDbMigrator::migrate);
-    assertMigrationException(
-        "Cannot set changes or projects during full migration",
-        b -> b.setProjects(ps),
-        NoteDbMigrator::migrate);
-
-    setNotesMigrationState(READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY);
-    assertMigrationException(
-        "Migration has already progressed past the endpoint of the \"trial mode\" state",
-        b -> b.setTrialMode(true),
-        NoteDbMigrator::migrate);
-
-    setNotesMigrationState(READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY);
-    assertMigrationException(
-        "Cannot force rebuild changes; NoteDb is already the primary storage for some changes",
-        b -> b.setForceRebuild(true),
-        NoteDbMigrator::migrate);
-  }
-
-  @Test
-  @GerritConfig(name = "noteDb.changes.initialSequenceGap", value = "-7")
-  public void initialSequenceGapMustBeNonNegative() throws Exception {
-    setNotesMigrationState(READ_WRITE_NO_SEQUENCE);
-    assertMigrationException("Sequence gap must be non-negative: -7", b -> b, m -> {});
-  }
-
-  @Test
-  public void rebuildOneChangeTrialModeAndForceRebuild() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    migrate(b -> b.setTrialMode(true));
-    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, false, true);
-
-    ObjectId oldMetaId;
-    try (Repository repo = repoManager.openRepository(project);
-        ReviewDb db = schemaFactory.open()) {
-      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
-      assertThat(ref).isNotNull();
-      oldMetaId = ref.getObjectId();
-
-      Change c = db.changes().get(id);
-      assertThat(c).isNotNull();
-      NoteDbChangeState state = NoteDbChangeState.parse(c);
-      assertThat(state).isNotNull();
-      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-      assertThat(state.getRefState()).hasValue(RefState.create(oldMetaId, ImmutableMap.of()));
-
-      // Force change to be out of date, and change topic so it will get rebuilt as something other
-      // than oldMetaId.
-      c.setNoteDbState(INVALID_STATE);
-      c.setTopic(name("a-new-topic"));
-      db.changes().update(ImmutableList.of(c));
-    }
-
-    migrate(b -> b.setTrialMode(true));
-    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, false, true);
-
-    try (Repository repo = repoManager.openRepository(project);
-        ReviewDb db = schemaFactory.open()) {
-      // Change is out of date, but was not rebuilt without forceRebuild.
-      assertThat(repo.exactRef(RefNames.changeMetaRef(id)).getObjectId()).isEqualTo(oldMetaId);
-      Change c = db.changes().get(id);
-      assertThat(c.getNoteDbState()).isEqualTo(INVALID_STATE);
-    }
-
-    migrate(b -> b.setTrialMode(true).setForceRebuild(true));
-    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, false, true);
-
-    try (Repository repo = repoManager.openRepository(project);
-        ReviewDb db = schemaFactory.open()) {
-      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
-      assertThat(ref).isNotNull();
-      ObjectId newMetaId = ref.getObjectId();
-      assertThat(newMetaId).isNotEqualTo(oldMetaId);
-
-      NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
-      assertThat(state).isNotNull();
-      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-      assertThat(state.getRefState()).hasValue(RefState.create(newMetaId, ImmutableMap.of()));
-    }
-  }
-
-  @Test
-  public void autoMigrateTrialMode() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    migrate(b -> b.setAutoMigrate(true).setTrialMode(true).setStopAtStateForTesting(WRITE));
-    assertNotesMigrationState(WRITE, true, true);
-
-    migrate(b -> b);
-    // autoMigrate is still enabled so that we can continue the migration by only unsetting trial.
-    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, true, true);
-
-    ObjectId metaId;
-    try (Repository repo = repoManager.openRepository(project);
-        ReviewDb db = schemaFactory.open()) {
-      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
-      assertThat(ref).isNotNull();
-      metaId = ref.getObjectId();
-      NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
-      assertThat(state).isNotNull();
-      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-      assertThat(state.getRefState()).hasValue(RefState.create(metaId, ImmutableMap.of()));
-    }
-
-    // Unset trial mode and the next migration runs to completion.
-    noteDbConfig.load();
-    NoteDbMigrator.setTrialMode(noteDbConfig, false);
-    noteDbConfig.save();
-
-    migrate(b -> b);
-    assertNotesMigrationState(NOTE_DB, false, false);
-
-    try (Repository repo = repoManager.openRepository(project);
-        ReviewDb db = schemaFactory.open()) {
-      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
-      assertThat(ref).isNotNull();
-      assertThat(ref.getObjectId()).isEqualTo(metaId);
-      NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
-      assertThat(state).isNotNull();
-      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.NOTE_DB);
-    }
-  }
-
-  @Test
-  public void rebuildSubsetOfChanges() throws Exception {
-    setNotesMigrationState(WRITE);
-
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-    Change.Id id1 = r1.getChange().getId();
-    Change.Id id2 = r2.getChange().getId();
-
-    try (ReviewDb db = schemaFactory.open()) {
-      Change c1 = db.changes().get(id1);
-      c1.setNoteDbState(INVALID_STATE);
-      Change c2 = db.changes().get(id2);
-      c2.setNoteDbState(INVALID_STATE);
-      db.changes().update(ImmutableList.of(c1, c2));
-    }
-
-    migrate(b -> b.setChanges(ImmutableList.of(id2)), NoteDbMigrator::rebuild);
-
-    try (ReviewDb db = schemaFactory.open()) {
-      NoteDbChangeState s1 = NoteDbChangeState.parse(db.changes().get(id1));
-      assertThat(s1.getChangeMetaId().name()).isEqualTo(INVALID_STATE);
-
-      NoteDbChangeState s2 = NoteDbChangeState.parse(db.changes().get(id2));
-      assertThat(s2.getChangeMetaId().name()).isNotEqualTo(INVALID_STATE);
-    }
-  }
-
-  @Test
-  public void rebuildSubsetOfProjects() throws Exception {
-    setNotesMigrationState(WRITE);
-
-    Project.NameKey p2 = createProject("project2");
-    TestRepository<?> tr2 = cloneProject(p2, admin);
-
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = pushFactory.create(db, admin.getIdent(), tr2).to("refs/for/master");
-    Change.Id id1 = r1.getChange().getId();
-    Change.Id id2 = r2.getChange().getId();
-
-    String invalidState = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    try (ReviewDb db = schemaFactory.open()) {
-      Change c1 = db.changes().get(id1);
-      c1.setNoteDbState(invalidState);
-      Change c2 = db.changes().get(id2);
-      c2.setNoteDbState(invalidState);
-      db.changes().update(ImmutableList.of(c1, c2));
-    }
-
-    migrate(b -> b.setProjects(ImmutableList.of(p2)), NoteDbMigrator::rebuild);
-
-    try (ReviewDb db = schemaFactory.open()) {
-      NoteDbChangeState s1 = NoteDbChangeState.parse(db.changes().get(id1));
-      assertThat(s1.getChangeMetaId().name()).isEqualTo(invalidState);
-
-      NoteDbChangeState s2 = NoteDbChangeState.parse(db.changes().get(id2));
-      assertThat(s2.getChangeMetaId().name()).isNotEqualTo(invalidState);
-    }
-  }
-
-  @Test
-  public void enableSequencesNoGap() throws Exception {
-    testEnableSequences(0, 2, "12");
-  }
-
-  @Test
-  public void enableSequencesWithGap() throws Exception {
-    testEnableSequences(-1, 502, "512");
-  }
-
-  private void testEnableSequences(int builderOption, int expectedFirstId, String expectedRefValue)
-      throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-    assertThat(id.get()).isEqualTo(1);
-
-    migrate(
-        b ->
-            b.setSequenceGap(builderOption)
-                .setStopAtStateForTesting(READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY));
-
-    assertThat(sequences.nextChangeId()).isEqualTo(expectedFirstId);
-    assertThat(sequences.nextChangeId()).isEqualTo(expectedFirstId + 1);
-
-    try (Repository repo = repoManager.openRepository(allProjects);
-        ObjectReader reader = repo.newObjectReader()) {
-      Ref ref = repo.exactRef("refs/sequences/changes");
-      assertThat(ref).isNotNull();
-      ObjectLoader loader = reader.open(ref.getObjectId());
-      assertThat(loader.getType()).isEqualTo(Constants.OBJ_BLOB);
-      // Acquired a block of 10 to serve the first nextChangeId call after migration.
-      assertThat(new String(loader.getCachedBytes(), UTF_8)).isEqualTo(expectedRefValue);
-    }
-
-    try (ReviewDb db = schemaFactory.open()) {
-      // Underlying, unused ReviewDb is still on its own sequence.
-      @SuppressWarnings("deprecation")
-      int nextFromReviewDb = db.nextChangeId();
-      assertThat(nextFromReviewDb).isEqualTo(3);
-    }
-  }
-
-  @Test
-  public void fullMigrationSameThread() throws Exception {
-    testFullMigration(1);
-  }
-
-  @Test
-  public void fullMigrationMultipleThreads() throws Exception {
-    testFullMigration(2);
-  }
-
-  private void testFullMigration(int threads) throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-    Change.Id id1 = r1.getChange().getId();
-    Change.Id id2 = r2.getChange().getId();
-
-    Set<String> objectFiles = getObjectFiles(project);
-    assertThat(objectFiles).isNotEmpty();
-
-    migrate(b -> b.setThreads(threads));
-
-    assertNotesMigrationState(NOTE_DB, false, false);
-    assertThat(sequences.nextChangeId()).isEqualTo(503);
-    assertThat(getObjectFiles(project)).containsExactlyElementsIn(objectFiles);
-
-    ObjectId oldMetaId = null;
-    int rowVersion = 0;
-    try (ReviewDb db = schemaFactory.open();
-        Repository repo = repoManager.openRepository(project)) {
-      for (Change.Id id : ImmutableList.of(id1, id2)) {
-        String refName = RefNames.changeMetaRef(id);
-        Ref ref = repo.exactRef(refName);
-        assertThat(ref).named(refName).isNotNull();
-
-        Change c = db.changes().get(id);
-        assertThat(c.getTopic()).named("topic of change %s", id).isNull();
-        NoteDbChangeState s = NoteDbChangeState.parse(c);
-        assertThat(s.getPrimaryStorage())
-            .named("primary storage of change %s", id)
-            .isEqualTo(PrimaryStorage.NOTE_DB);
-        assertThat(s.getRefState()).named("ref state of change %s").isEmpty();
-
-        if (id.equals(id1)) {
-          oldMetaId = ref.getObjectId();
-          rowVersion = c.getRowVersion();
-        }
-      }
-    }
-
-    // Do not open a new context, to simulate races with other threads that opened a context earlier
-    // in the migration process; this needs to work.
-    gApi.changes().id(id1.get()).topic(name("a-topic"));
-
-    // Of course, it should also work with a new context.
-    resetCurrentApiUser();
-    gApi.changes().id(id1.get()).topic(name("another-topic"));
-
-    try (ReviewDb db = schemaFactory.open();
-        Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef(RefNames.changeMetaRef(id1)).getObjectId()).isNotEqualTo(oldMetaId);
-
-      Change c = db.changes().get(id1);
-      assertThat(c.getTopic()).isNull();
-      assertThat(c.getRowVersion()).isEqualTo(rowVersion);
-    }
-  }
-
-  @Test
-  public void autoMigrationConfig() throws Exception {
-    createChange();
-
-    migrate(b -> b.setStopAtStateForTesting(WRITE));
-    assertNotesMigrationState(WRITE, false, false);
-
-    migrate(b -> b.setAutoMigrate(true).setStopAtStateForTesting(READ_WRITE_NO_SEQUENCE));
-    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, true, false);
-
-    migrate(b -> b);
-    assertNotesMigrationState(NOTE_DB, false, false);
-  }
-
-  @Test
-  public void notesMigrationStateListener() throws Exception {
-    NotesMigrationStateListener listener = createStrictMock(NotesMigrationStateListener.class);
-    listener.preStateChange(REVIEW_DB, WRITE);
-    expectLastCall();
-    listener.preStateChange(WRITE, READ_WRITE_NO_SEQUENCE);
-    expectLastCall();
-    listener.preStateChange(READ_WRITE_NO_SEQUENCE, READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY);
-    expectLastCall();
-    listener.preStateChange(
-        READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY, READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY);
-    listener.preStateChange(READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY, NOTE_DB);
-    expectLastCall();
-    replay(listener);
-    addListener(listener);
-
-    createChange();
-    migrate(b -> b);
-    assertNotesMigrationState(NOTE_DB, false, false);
-    verify(listener);
-  }
-
-  @Test
-  public void notesMigrationStateListenerFails() throws Exception {
-    NotesMigrationStateListener listener = createStrictMock(NotesMigrationStateListener.class);
-    listener.preStateChange(REVIEW_DB, WRITE);
-    expectLastCall();
-    listener.preStateChange(WRITE, READ_WRITE_NO_SEQUENCE);
-    IOException listenerException = new IOException("Listener failed");
-    expectLastCall().andThrow(listenerException);
-    replay(listener);
-    addListener(listener);
-
-    createChange();
-    try {
-      migrate(b -> b);
-      assert_().fail("expected IOException");
-    } catch (IOException e) {
-      assertThat(e).isSameAs(listenerException);
-    }
-    assertNotesMigrationState(WRITE, false, false);
-    verify(listener);
-  }
-
-  private void assertNotesMigrationState(
-      NotesMigrationState expected, boolean autoMigrate, boolean trialMode) throws Exception {
-    assertThat(NotesMigrationState.forNotesMigration(notesMigration)).hasValue(expected);
-    noteDbConfig.load();
-    assertThat(NotesMigrationState.forConfig(noteDbConfig)).hasValue(expected);
-    assertThat(NoteDbMigrator.getAutoMigrate(noteDbConfig))
-        .named("noteDb.changes.autoMigrate")
-        .isEqualTo(autoMigrate);
-    assertThat(NoteDbMigrator.getTrialMode(noteDbConfig))
-        .named("noteDb.changes.trial")
-        .isEqualTo(trialMode);
-  }
-
-  private void setNotesMigrationState(NotesMigrationState state) throws Exception {
-    noteDbConfig.load();
-    state.setConfigValues(noteDbConfig);
-    noteDbConfig.save();
-    notesMigration.setFrom(state);
-  }
-
-  @FunctionalInterface
-  interface PrepareBuilder {
-    NoteDbMigrator.Builder prepare(NoteDbMigrator.Builder b) throws Exception;
-  }
-
-  @FunctionalInterface
-  interface RunMigration {
-    void run(NoteDbMigrator m) throws Exception;
-  }
-
-  private void migrate(PrepareBuilder b) throws Exception {
-    migrate(b, NoteDbMigrator::migrate);
-  }
-
-  private void migrate(PrepareBuilder b, RunMigration m) throws Exception {
-    try (NoteDbMigrator migrator = b.prepare(migratorBuilderProvider.get()).build()) {
-      m.run(migrator);
-    }
-  }
-
-  private void assertMigrationException(
-      String expectMessageContains, PrepareBuilder b, RunMigration m) throws Exception {
-    try {
-      migrate(b, m);
-    } catch (MigrationException e) {
-      assertThat(e).hasMessageThat().contains(expectMessageContains);
-    }
-  }
-
-  private void addListener(NotesMigrationStateListener listener) {
-    addedListeners.add(listeners.add(listener));
-  }
-
-  private SortedSet<String> getObjectFiles(Project.NameKey project) throws Exception {
-    SortedSet<String> files = new TreeSet<>();
-    try (Repository repo = repoManager.openRepository(project)) {
-      Files.walkFileTree(
-          ((FileRepository) repo).getObjectDatabase().getDirectory().toPath(),
-          new SimpleFileVisitor<Path>() {
-            @Override
-            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
-              String name = file.getFileName().toString();
-              if (!attrs.isDirectory() && !name.endsWith(".pack") && !name.endsWith(".idx")) {
-                files.add(name);
-              }
-              return FileVisitResult.CONTINUE;
-            }
-          });
-    }
-    return files;
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ReflogIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
deleted file mode 100644
index 312974f..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.UseLocalDisk;
-import com.google.gerrit.reviewdb.client.Change;
-import java.io.File;
-import org.eclipse.jgit.lib.ReflogEntry;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
-import org.junit.Test;
-
-@UseLocalDisk
-public class ReflogIT extends AbstractDaemonTest {
-  @Before
-  public void setUp() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-  }
-
-  @Test
-  public void guessRestApiInReflog() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    try (Repository repo = repoManager.openRepository(r.getChange().project())) {
-      File log = new File(repo.getDirectory(), "logs/" + changeMetaRef(id));
-      if (!log.exists()) {
-        log.getParentFile().mkdirs();
-        assertThat(log.createNewFile()).isTrue();
-      }
-
-      gApi.changes().id(id.get()).topic("foo");
-      ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
-      assertThat(last).named("last RefLogEntry").isNotNull();
-      assertThat(last.getComment()).isEqualTo("change.PutTopic");
-    }
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUILD
deleted file mode 100644
index 622caf7..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUILD
+++ /dev/null
@@ -1,7 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "server_project",
-    labels = ["server"],
-)
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
deleted file mode 100644
index 38ff3c7..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ /dev/null
@@ -1,202 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.project;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.project.Util.category;
-import static com.google.gerrit.server.project.Util.value;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.events.CommentAddedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.Util;
-import com.google.inject.Inject;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class CustomLabelIT extends AbstractDaemonTest {
-
-  @Inject private DynamicSet<CommentAddedListener> source;
-
-  private final LabelType label =
-      category("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
-
-  private final LabelType P = category("CustomLabel2", value(1, "Positive"), value(0, "No score"));
-
-  private RegistrationHandle eventListenerRegistration;
-  private CommentAddedListener.Event lastCommentAddedEvent;
-
-  @Before
-  public void setUp() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-    Util.allow(cfg, Permission.forLabel(label.getName()), -1, 1, anonymousUsers, "refs/heads/*");
-    Util.allow(cfg, Permission.forLabel(P.getName()), 0, 1, anonymousUsers, "refs/heads/*");
-    saveProjectConfig(project, cfg);
-
-    eventListenerRegistration =
-        source.add(
-            new CommentAddedListener() {
-              @Override
-              public void onCommentAdded(Event event) {
-                lastCommentAddedEvent = event;
-              }
-            });
-  }
-
-  @After
-  public void cleanup() {
-    eventListenerRegistration.remove();
-    db.close();
-  }
-
-  @Test
-  public void customLabelNoOp_NegativeVoteNotBlock() throws Exception {
-    label.setFunctionName("NoOp");
-    saveLabelConfig();
-    PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
-    ChangeInfo c = get(r.getChangeId());
-    LabelInfo q = c.labels.get(label.getName());
-    assertThat(q.all).hasSize(1);
-    assertThat(q.rejected).isNotNull();
-    assertThat(q.blocking).isNull();
-  }
-
-  @Test
-  public void customLabelNoBlock_NegativeVoteNotBlock() throws Exception {
-    label.setFunctionName("NoBlock");
-    saveLabelConfig();
-    PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
-    ChangeInfo c = get(r.getChangeId());
-    LabelInfo q = c.labels.get(label.getName());
-    assertThat(q.all).hasSize(1);
-    assertThat(q.rejected).isNotNull();
-    assertThat(q.blocking).isNull();
-  }
-
-  @Test
-  public void customLabelMaxNoBlock_NegativeVoteNotBlock() throws Exception {
-    label.setFunctionName("MaxNoBlock");
-    saveLabelConfig();
-    PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
-    ChangeInfo c = get(r.getChangeId());
-    LabelInfo q = c.labels.get(label.getName());
-    assertThat(q.all).hasSize(1);
-    assertThat(q.rejected).isNotNull();
-    assertThat(q.blocking).isNull();
-  }
-
-  @Test
-  public void customLabelAnyWithBlock_NegativeVoteBlock() throws Exception {
-    label.setFunctionName("AnyWithBlock");
-    saveLabelConfig();
-    PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
-    ChangeInfo c = get(r.getChangeId());
-    LabelInfo q = c.labels.get(label.getName());
-    assertThat(q.all).hasSize(1);
-    assertThat(q.disliked).isNull();
-    assertThat(q.rejected).isNotNull();
-    assertThat(q.blocking).isTrue();
-  }
-
-  @Test
-  public void customLabelAnyWithBlock_Addreviewer_ZeroVote() throws Exception {
-    P.setFunctionName("AnyWithBlock");
-    saveLabelConfig();
-    PushOneCommit.Result r = createChange();
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    ReviewInput input = new ReviewInput().label(P.getName(), 0);
-    input.message = "foo";
-
-    revision(r).review(input);
-    ChangeInfo c = get(r.getChangeId());
-    LabelInfo q = c.labels.get(P.getName());
-    assertThat(q.all).hasSize(2);
-    assertThat(q.disliked).isNull();
-    assertThat(q.rejected).isNull();
-    assertThat(q.blocking).isNull();
-    assertThat(lastCommentAddedEvent.getComment()).isEqualTo("Patch Set 1:\n\n" + input.message);
-  }
-
-  @Test
-  public void customLabelMaxWithBlock_NegativeVoteBlock() throws Exception {
-    saveLabelConfig();
-    PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
-    ChangeInfo c = get(r.getChangeId());
-    LabelInfo q = c.labels.get(label.getName());
-    assertThat(q.all).hasSize(1);
-    assertThat(q.disliked).isNull();
-    assertThat(q.rejected).isNotNull();
-    assertThat(q.blocking).isTrue();
-  }
-
-  @Test
-  public void customLabel_DisallowPostSubmit() throws Exception {
-    label.setFunctionName("NoOp");
-    label.setAllowPostSubmit(false);
-    P.setFunctionName("NoOp");
-    saveLabelConfig();
-
-    PushOneCommit.Result r = createChange();
-    revision(r).review(ReviewInput.approve());
-    revision(r).submit();
-
-    ChangeInfo info = get(r.getChangeId(), ListChangesOption.DETAILED_LABELS);
-    assertPermitted(info, "Code-Review", 2);
-    assertPermitted(info, P.getName(), 0, 1);
-    assertPermitted(info, label.getName());
-
-    ReviewInput in = new ReviewInput();
-    in.label(P.getName(), P.getMax().getValue());
-    revision(r).review(in);
-
-    in = new ReviewInput();
-    in.label(label.getName(), label.getMax().getValue());
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Voting on labels disallowed after submit: " + label.getName());
-    revision(r).review(in);
-  }
-
-  private void saveLabelConfig() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().put(label.getName(), label);
-    cfg.getLabelSections().put(P.getName(), P);
-    saveProjectConfig(project, cfg);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
deleted file mode 100644
index bc82e8d..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ /dev/null
@@ -1,591 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.project;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.StarredChangesUtil.IGNORE_LABEL;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.Sandboxed;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.StarsInput;
-import com.google.gerrit.extensions.common.GroupInfo;
-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.account.WatchConfig;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
-import com.google.gerrit.server.git.NotifyConfig;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.testutil.FakeEmailSender.Message;
-import com.google.inject.Inject;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.junit.Test;
-
-@NoHttpd
-@Sandboxed
-public class ProjectWatchIT extends AbstractDaemonTest {
-  @Inject private WatchConfig.Accessor watchConfig;
-
-  @Test
-  public void newPatchSetsNotifyConfig() throws Exception {
-    Address addr = new Address("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
-    nc.setName("new-patch-set");
-    nc.setHeader(NotifyConfig.Header.CC);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
-    nc.setFilter("message:sekret");
-
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.putNotifyConfig("watch", nc);
-    saveProjectConfig(project, cfg);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "original subject", "a", "a1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    r =
-        pushFactory
-            .create(
-                db, admin.getIdent(), testRepo, "super sekret subject", "a", "a2", r.getChangeId())
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    r =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "back to original subject", "a", "a3")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(addr);
-    assertThat(m.body()).contains("Change subject: super sekret subject\n");
-    assertThat(m.body()).contains("Gerrit-PatchSet: 2\n");
-  }
-
-  @Test
-  public void noNotificationForPrivateChangesForWatchersInNotifyConfig() throws Exception {
-    Address addr = new Address("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
-    nc.setName("team");
-    nc.setHeader(NotifyConfig.Header.TO);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
-
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.putNotifyConfig("team", nc);
-    saveProjectConfig(project, cfg);
-
-    sender.clear();
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "private change", "a", "a1")
-            .to("refs/for/master%private");
-    r.assertOkStatus();
-
-    assertThat(sender.getMessages()).isEmpty();
-
-    setApiUser(admin);
-    ReviewInput in = new ReviewInput();
-    in.message = "comment";
-    gApi.changes().id(r.getChangeId()).current().review(in);
-
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void noNotificationForChangeThatIsTurnedPrivateForWatchersInNotifyConfig()
-      throws Exception {
-    Address addr = new Address("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
-    nc.setName("team");
-    nc.setHeader(NotifyConfig.Header.TO);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
-
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.putNotifyConfig("team", nc);
-    saveProjectConfig(project, cfg);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "subject", "a", "a1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    sender.clear();
-
-    r =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
-            .to("refs/for/master%private");
-    r.assertOkStatus();
-
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void noNotificationForWipChangesForWatchersInNotifyConfig() throws Exception {
-    Address addr = new Address("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
-    nc.setName("team");
-    nc.setHeader(NotifyConfig.Header.TO);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
-
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.putNotifyConfig("team", nc);
-    saveProjectConfig(project, cfg);
-
-    sender.clear();
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "wip change", "a", "a1")
-            .to("refs/for/master%wip");
-    r.assertOkStatus();
-
-    assertThat(sender.getMessages()).isEmpty();
-
-    setApiUser(admin);
-    ReviewInput in = new ReviewInput();
-    in.message = "comment";
-    gApi.changes().id(r.getChangeId()).current().review(in);
-
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void noNotificationForChangeThatIsTurnedWipForWatchersInNotifyConfig() throws Exception {
-    Address addr = new Address("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
-    nc.setName("team");
-    nc.setHeader(NotifyConfig.Header.TO);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
-
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.putNotifyConfig("team", nc);
-    saveProjectConfig(project, cfg);
-
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "subject", "a", "a1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    sender.clear();
-
-    r =
-        pushFactory
-            .create(db, admin.getIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
-            .to("refs/for/master%wip");
-    r.assertOkStatus();
-
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void watchProject() throws Exception {
-    // watch project
-    String watchedProject = createProject("watchedProject").get();
-    setApiUser(user);
-    watch(watchedProject);
-
-    // push a change to watched project -> should trigger email notification
-    setApiUser(admin);
-    TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a", "a1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // push a change to non-watched project -> should not trigger email
-    // notification
-    String notWatchedProject = createProject("otherProject").get();
-    TestRepository<InMemoryRepository> notWatchedRepo =
-        cloneProject(new Project.NameKey(notWatchedProject), admin);
-    r =
-        pushFactory
-            .create(db, admin.getIdent(), notWatchedRepo, "DONT_TRIGGER", "a", "a1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // assert email notification
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    assertThat(m.body()).contains("Change subject: TRIGGER\n");
-    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
-  }
-
-  @Test
-  public void watchFile() throws Exception {
-    String watchedProject = createProject("watchedProject").get();
-    String otherWatchedProject = createProject("otherWatchedProject").get();
-    setApiUser(user);
-
-    // watch file in project as user
-    watch(watchedProject, "file:a.txt");
-
-    // watch other project as user
-    watch(otherWatchedProject);
-
-    // push a change to watched file -> should trigger email notification for
-    // user
-    setApiUser(admin);
-    TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a.txt", "a1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // assert email notification for user
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    assertThat(m.body()).contains("Change subject: TRIGGER\n");
-    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
-    sender.clear();
-
-    // watch project as user2
-    TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2");
-    setApiUser(user2);
-    watch(watchedProject);
-
-    // push a change to non-watched file -> should not trigger email
-    // notification for user, only for user2
-    r =
-        pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "TRIGGER_USER2", "b.txt", "b1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // assert email notification
-    messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user2.emailAddress);
-    assertThat(m.body()).contains("Change subject: TRIGGER_USER2\n");
-    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
-  }
-
-  @Test
-  public void watchKeyword() throws Exception {
-    String watchedProject = createProject("watchedProject").get();
-    setApiUser(user);
-
-    // watch keyword in project as user
-    watch(watchedProject, "multimaster");
-
-    // push a change with keyword -> should trigger email notification
-    setApiUser(admin);
-    TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "Document multimaster setup", "a.txt", "a1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // assert email notification for user
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    assertThat(m.body()).contains("Change subject: Document multimaster setup\n");
-    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
-    sender.clear();
-
-    // push a change without keyword -> should not trigger email notification
-    r =
-        pushFactory
-            .create(
-                db, admin.getIdent(), watchedRepo, "Cleanup cache implementation", "b.txt", "b1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // assert email notification
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void watchAllProjects() throws Exception {
-    String anyProject = createProject("anyProject").get();
-    setApiUser(user);
-
-    // watch the All-Projects project to watch all projects
-    watch(allProjects.get());
-
-    // push a change to any project -> should trigger email notification
-    setApiUser(admin);
-    TestRepository<InMemoryRepository> anyRepo =
-        cloneProject(new Project.NameKey(anyProject), admin);
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), anyRepo, "TRIGGER", "a", "a1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // assert email notification
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    assertThat(m.body()).contains("Change subject: TRIGGER\n");
-    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
-  }
-
-  @Test
-  public void watchFileAllProjects() throws Exception {
-    String anyProject = createProject("anyProject").get();
-    setApiUser(user);
-
-    // watch file in All-Projects project as user to watch the file in all
-    // projects
-    watch(allProjects.get(), "file:a.txt");
-
-    // push a change to watched file in any project -> should trigger email
-    // notification for user
-    setApiUser(admin);
-    TestRepository<InMemoryRepository> anyRepo =
-        cloneProject(new Project.NameKey(anyProject), admin);
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), anyRepo, "TRIGGER", "a.txt", "a1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // assert email notification for user
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    assertThat(m.body()).contains("Change subject: TRIGGER\n");
-    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
-    sender.clear();
-
-    // watch project as user2
-    TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2");
-    setApiUser(user2);
-    watch(anyProject);
-
-    // push a change to non-watched file in any project -> should not trigger
-    // email notification for user, only for user2
-    r =
-        pushFactory
-            .create(db, admin.getIdent(), anyRepo, "TRIGGER_USER2", "b.txt", "b1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // assert email notification
-    messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user2.emailAddress);
-    assertThat(m.body()).contains("Change subject: TRIGGER_USER2\n");
-    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
-  }
-
-  @Test
-  public void watchKeywordAllProjects() throws Exception {
-    String anyProject = createProject("anyProject").get();
-    setApiUser(user);
-
-    // watch keyword in project as user
-    watch(allProjects.get(), "multimaster");
-
-    // push a change with keyword to any project -> should trigger email
-    // notification
-    setApiUser(admin);
-    TestRepository<InMemoryRepository> anyRepo =
-        cloneProject(new Project.NameKey(anyProject), admin);
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), anyRepo, "Document multimaster setup", "a.txt", "a1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // assert email notification for user
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    assertThat(m.body()).contains("Change subject: Document multimaster setup\n");
-    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
-    sender.clear();
-
-    // push a change without keyword to any project -> should not trigger email
-    // notification
-    r =
-        pushFactory
-            .create(db, admin.getIdent(), anyRepo, "Cleanup cache implementation", "b.txt", "b1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // assert email notification
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void watchProjectNoNotificationForIgnoredChange() throws Exception {
-    // watch project
-    String watchedProject = createProject("watchedProject").get();
-    setApiUser(user);
-    watch(watchedProject);
-
-    // push a change to watched project
-    setApiUser(admin);
-    TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "ignored change", "a", "a1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // ignore the change
-    setApiUser(user);
-    gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
-
-    sender.clear();
-
-    // post a comment -> should not trigger email notification since user ignored the change
-    setApiUser(admin);
-    ReviewInput in = new ReviewInput();
-    in.message = "comment";
-    gApi.changes().id(r.getChangeId()).current().review(in);
-
-    // assert email notification
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void deleteAllProjectWatches() throws Exception {
-    Map<ProjectWatchKey, Set<NotifyType>> watches = new HashMap<>();
-    watches.put(ProjectWatchKey.create(project, "*"), ImmutableSet.of(NotifyType.ALL));
-    watchConfig.upsertProjectWatches(admin.getId(), watches);
-    assertThat(watchConfig.getProjectWatches(admin.getId())).isNotEmpty();
-
-    watchConfig.deleteAllProjectWatches(admin.getId());
-    assertThat(watchConfig.getProjectWatches(admin.getId())).isEmpty();
-  }
-
-  @Test
-  public void deleteAllProjectWatchesIfWatchConfigIsTheOnlyFileInUserBranch() throws Exception {
-    // Create account that has no files in its refs/users/ branch.
-    Account.Id id = accountCreator.create().id;
-
-    // Add a project watch so that a watch.config file in the refs/users/ branch is created.
-    Map<ProjectWatchKey, Set<NotifyType>> watches = new HashMap<>();
-    watches.put(ProjectWatchKey.create(project, "*"), ImmutableSet.of(NotifyType.ALL));
-    watchConfig.upsertProjectWatches(id, watches);
-    assertThat(watchConfig.getProjectWatches(id)).isNotEmpty();
-
-    // Delete all project watches so that the watch.config file in the refs/users/ branch is
-    // deleted.
-    watchConfig.deleteAllProjectWatches(id);
-    assertThat(watchConfig.getProjectWatches(id)).isEmpty();
-  }
-
-  @Test
-  public void watchProjectNoNotificationForPrivateChange() throws Exception {
-    // watch project
-    String watchedProject = createProject("watchedProject").get();
-    setApiUser(user);
-    watch(watchedProject);
-
-    // push a private change to watched project -> should not trigger email notification
-    setApiUser(admin);
-    TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "private change", "a", "a1")
-            .to("refs/for/master%private");
-    r.assertOkStatus();
-
-    // assert email notification
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void watchProjectNotifyOnPrivateChange() throws Exception {
-    String watchedProject = createProject("watchedProject").get();
-
-    // create group that can view all private changes
-    GroupInfo groupThatCanViewPrivateChanges =
-        gApi.groups().create("groupThatCanViewPrivateChanges").get();
-    grant(
-        new Project.NameKey(watchedProject),
-        "refs/*",
-        Permission.VIEW_PRIVATE_CHANGES,
-        false,
-        new AccountGroup.UUID(groupThatCanViewPrivateChanges.id));
-
-    // watch project as user that can't view private changes
-    setApiUser(user);
-    watch(watchedProject);
-
-    // watch project as user that can view all private change
-    TestAccount userThatCanViewPrivateChanges =
-        accountCreator.create(
-            "user2", "user2@test.com", "User2", groupThatCanViewPrivateChanges.name);
-    setApiUser(userThatCanViewPrivateChanges);
-    watch(watchedProject);
-
-    // push a private change to watched project -> should trigger email notification for
-    // userThatCanViewPrivateChanges, but not for user
-    setApiUser(admin);
-    TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a", "a1")
-            .to("refs/for/master%private");
-    r.assertOkStatus();
-
-    // assert email notification
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(userThatCanViewPrivateChanges.emailAddress);
-    assertThat(m.body()).contains("Change subject: TRIGGER\n");
-    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
deleted file mode 100644
index 96852b3..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.ssh;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit.Result;
-import com.google.gerrit.acceptance.UseSsh;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Locale;
-import org.junit.Test;
-
-@NoHttpd
-@UseSsh
-public class AbandonRestoreIT extends AbstractDaemonTest {
-
-  @Test
-  public void withMessage() throws Exception {
-    Result result = createChange();
-    String commit = result.getCommit().name();
-    executeCmd(commit, "abandon", "'abandon it'");
-    executeCmd(commit, "restore", "'restore it'");
-    assertChangeMessages(
-        result.getChangeId(),
-        ImmutableList.of(
-            "Uploaded patch set 1.", "Abandoned\n\nabandon it", "Restored\n\nrestore it"));
-  }
-
-  @Test
-  public void withoutMessage() throws Exception {
-    Result result = createChange();
-    String commit = result.getCommit().name();
-    executeCmd(commit, "abandon", null);
-    executeCmd(commit, "restore", null);
-    assertChangeMessages(
-        result.getChangeId(), ImmutableList.of("Uploaded patch set 1.", "Abandoned", "Restored"));
-  }
-
-  private void executeCmd(String commit, String op, String message) throws Exception {
-    StringBuilder command =
-        new StringBuilder("gerrit review ").append(commit).append(" --").append(op);
-    if (message != null) {
-      command.append(" --message ").append(message);
-    }
-    String response = adminSshSession.exec(command.toString());
-    assertWithMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isFalse();
-    assertThat(response.toLowerCase(Locale.US)).doesNotContain("error");
-  }
-
-  private void assertChangeMessages(String changeId, List<String> expected) throws Exception {
-    ChangeInfo c = get(changeId);
-    Iterable<ChangeMessageInfo> messages = c.messages;
-    assertThat(messages).isNotNull();
-    assertThat(messages).hasSize(expected.size());
-    List<String> actual = new ArrayList<>();
-    for (ChangeMessageInfo info : messages) {
-      actual.add(info.message);
-    }
-    assertThat(actual).containsExactlyElementsIn(expected);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BUILD
deleted file mode 100644
index 91d8d71..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BUILD
+++ /dev/null
@@ -1,8 +0,0 @@
-load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
-
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "ssh",
-    labels = ["ssh"],
-    deps = ["//lib/commons:compress"],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
deleted file mode 100644
index bcdc866..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
+++ /dev/null
@@ -1,142 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.ssh;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-
-import com.google.common.base.Splitter;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.UseSsh;
-import com.google.gerrit.testutil.NoteDbMode;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.InputStream;
-import java.util.Set;
-import java.util.TreeSet;
-import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
-import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream;
-import org.eclipse.jgit.transport.PacketLineIn;
-import org.eclipse.jgit.transport.PacketLineOut;
-import org.eclipse.jgit.util.IO;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-@UseSsh
-public class UploadArchiveIT extends AbstractDaemonTest {
-
-  @Before
-  public void setUp() {
-    // There is some Guice request scoping problem preventing this test from
-    // passing in CHECK mode.
-    assume().that(NoteDbMode.get()).isNotEqualTo(NoteDbMode.CHECK);
-  }
-
-  @Test
-  @GerritConfig(name = "download.archive", value = "off")
-  public void archiveFeatureOff() throws Exception {
-    archiveNotPermitted();
-  }
-
-  @Test
-  @GerritConfig(
-    name = "download.archive",
-    values = {"tar", "tbz2", "tgz", "txz"}
-  )
-  public void zipFormatDisabled() throws Exception {
-    archiveNotPermitted();
-  }
-
-  @Test
-  public void zipFormat() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String abbreviated = r.getCommit().abbreviate(8).name();
-    String c = command(r, abbreviated);
-
-    InputStream out =
-        adminSshSession.exec2("git-upload-archive " + project.get(), argumentsToInputStream(c));
-
-    // Wrap with PacketLineIn to read ACK bytes from output stream
-    PacketLineIn in = new PacketLineIn(out);
-    String tmp = in.readString();
-    assertThat(tmp).isEqualTo("ACK");
-    tmp = in.readString();
-
-    // Skip length (4 bytes) + 1 byte
-    // to position the output stream to the raw zip stream
-    byte[] buffer = new byte[5];
-    IO.readFully(out, buffer, 0, 5);
-    Set<String> entryNames = new TreeSet<>();
-    try (ZipArchiveInputStream zip = new ZipArchiveInputStream(out)) {
-      ZipArchiveEntry zipEntry = zip.getNextZipEntry();
-      while (zipEntry != null) {
-        String name = zipEntry.getName();
-        entryNames.add(name);
-        zipEntry = zip.getNextZipEntry();
-      }
-    }
-
-    assertThat(entryNames)
-        .containsExactly(
-            String.format("%s/", abbreviated),
-            String.format("%s/%s", abbreviated, PushOneCommit.FILE_NAME))
-        .inOrder();
-  }
-
-  private String command(PushOneCommit.Result r, String abbreviated) {
-    String c =
-        "-f=zip "
-            + "-9 "
-            + "--prefix="
-            + abbreviated
-            + "/ "
-            + r.getCommit().name()
-            + " "
-            + PushOneCommit.FILE_NAME;
-    return c;
-  }
-
-  private void archiveNotPermitted() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String abbreviated = r.getCommit().abbreviate(8).name();
-    String c = command(r, abbreviated);
-
-    InputStream out =
-        adminSshSession.exec2("git-upload-archive " + project.get(), argumentsToInputStream(c));
-
-    // Wrap with PacketLineIn to read ACK bytes from output stream
-    PacketLineIn in = new PacketLineIn(out);
-    String tmp = in.readString();
-    assertThat(tmp).isEqualTo("ACK");
-    tmp = in.readString();
-    tmp = in.readString();
-    tmp = tmp.substring(1);
-    assertThat(tmp).isEqualTo("fatal: upload-archive not permitted");
-  }
-
-  private InputStream argumentsToInputStream(String c) throws Exception {
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    PacketLineOut pctOut = new PacketLineOut(out);
-    for (String arg : Splitter.on(' ').split(c)) {
-      pctOut.writeString("argument " + arg);
-    }
-    pctOut.end();
-    return new ByteArrayInputStream(out.toByteArray());
-  }
-}
diff --git a/gerrit-acceptance-tests/tests.bzl b/gerrit-acceptance-tests/tests.bzl
deleted file mode 100644
index 594e583..0000000
--- a/gerrit-acceptance-tests/tests.bzl
+++ /dev/null
@@ -1,21 +0,0 @@
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-def acceptance_tests(
-    group,
-    deps = [],
-    labels = [],
-    vm_args = ['-Xmx256m'],
-    **kwargs):
-  junit_tests(
-    name = group,
-    deps = deps + [
-      '//gerrit-acceptance-tests:lib',
-    ],
-    tags = labels + [
-      'acceptance',
-      'slow',
-    ],
-    size = "large",
-    jvm_flags = vm_args,
-    **kwargs
-  )
diff --git a/gerrit-cache-h2/BUILD b/gerrit-cache-h2/BUILD
deleted file mode 100644
index 45cf416..0000000
--- a/gerrit-cache-h2/BUILD
+++ /dev/null
@@ -1,30 +0,0 @@
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-java_library(
-    name = "cache-h2",
-    srcs = glob(["src/main/java/**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-common:server",
-        "//gerrit-extension-api:api",
-        "//gerrit-server:server",
-        "//lib:guava",
-        "//lib:h2",
-        "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/log:api",
-    ],
-)
-
-junit_tests(
-    name = "tests",
-    srcs = glob(["src/test/java/**/*.java"]),
-    deps = [
-        ":cache-h2",
-        "//gerrit-server:server",
-        "//lib:guava",
-        "//lib:h2",
-        "//lib:junit",
-        "//lib/guice",
-    ],
-)
diff --git a/gerrit-common/BUILD b/gerrit-common/BUILD
deleted file mode 100644
index 4389080..0000000
--- a/gerrit-common/BUILD
+++ /dev/null
@@ -1,86 +0,0 @@
-load("//tools/bzl:gwt.bzl", "gwt_module")
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-SRC = "src/main/java/com/google/gerrit/"
-
-ANNOTATIONS = [
-    SRC + x
-    for x in [
-        "common/Nullable.java",
-        "common/audit/Audit.java",
-        "common/auth/SignInRequired.java",
-    ]
-]
-
-java_library(
-    name = "annotations",
-    srcs = ANNOTATIONS,
-    visibility = ["//visibility:public"],
-)
-
-gwt_module(
-    name = "client",
-    srcs = glob([SRC + "common/**/*.java"]),
-    exported_deps = [
-        "//gerrit-extension-api:api",
-        "//gerrit-prettify:client",
-        "//lib:guava",
-        "//lib:gwtorm_client",
-        "//lib:servlet-api-3_1",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/joda:joda-time",
-        "//lib/log:api",
-    ],
-    gwt_xml = SRC + "Common.gwt.xml",
-    visibility = ["//visibility:public"],
-)
-
-java_library(
-    name = "server",
-    srcs = glob(
-        [SRC + "common/**/*.java"],
-        exclude = ANNOTATIONS,
-    ),
-    visibility = ["//visibility:public"],
-    deps = [
-        ":annotations",
-        "//gerrit-extension-api:api",
-        "//gerrit-patch-jgit:server",
-        "//gerrit-prettify:server",
-        "//gerrit-reviewdb:server",
-        "//lib:guava",
-        "//lib:gwtjsonrpc",
-        "//lib:gwtorm",
-        "//lib:servlet-api-3_1",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/joda:joda-time",
-        "//lib/log:api",
-    ],
-)
-
-TEST = "src/test/java/com/google/gerrit/common/"
-
-AUTO_VALUE_TEST_SRCS = [TEST + "AutoValueTest.java"]
-
-junit_tests(
-    name = "client_tests",
-    srcs = glob(
-        ["src/test/java/**/*.java"],
-        exclude = AUTO_VALUE_TEST_SRCS,
-    ),
-    deps = [
-        ":client",
-        "//lib:guava",
-        "//lib:junit",
-        "//lib:truth",
-    ],
-)
-
-junit_tests(
-    name = "auto_value_tests",
-    srcs = AUTO_VALUE_TEST_SRCS,
-    deps = [
-        "//lib:truth",
-        "//lib/auto:auto-value",
-    ],
-)
diff --git a/gerrit-common/src/main/java/com/google/gerrit/Common.gwt.xml b/gerrit-common/src/main/java/com/google/gerrit/Common.gwt.xml
deleted file mode 100644
index 80bd2cb..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/Common.gwt.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-<!--
- Copyright (C) 2009 The Android Open Source Project
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<module>
-  <inherits name='com.google.gerrit.reviewdb.ReviewDB' />
-  <inherits name='com.google.gwtjsonrpc.GWTJSONRPC'/>
-  <inherits name="com.google.gwt.logging.Logging"/>
-  <source path='common' />
-</module>
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
deleted file mode 100644
index a8e40c6..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java
+++ /dev/null
@@ -1,37 +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.common;
-
-import com.google.common.annotations.GwtIncompatible;
-import java.sql.Timestamp;
-import org.joda.time.DateTimeUtils;
-
-/** Static utility methods for dealing with dates and times. */
-@GwtIncompatible("Unemulated org.joda.time.DateTimeUtils")
-public class TimeUtil {
-  public static long nowMs() {
-    return DateTimeUtils.currentTimeMillis();
-  }
-
-  public static Timestamp nowTs() {
-    return new Timestamp(nowMs());
-  }
-
-  public static Timestamp roundToSecond(Timestamp t) {
-    return new Timestamp((t.getTime() / 1000) * 1000);
-  }
-
-  private TimeUtil() {}
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountInfo.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountInfo.java
deleted file mode 100644
index 788a26d..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountInfo.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.reviewdb.client.Account;
-
-/** Summary information about an {@link Account}, for simple tabular displays. */
-public class AccountInfo {
-  protected Account.Id id;
-  protected String fullName;
-  protected String preferredEmail;
-  protected String username;
-
-  protected AccountInfo() {}
-
-  /**
-   * Create an 'Anonymous Coward' account info, when only the id is known.
-   *
-   * <p>This constructor should only be a last-ditch effort, when the usual account lookup has
-   * failed and a stale account id has been discovered in the data store.
-   */
-  public AccountInfo(Account.Id id) {
-    this.id = id;
-  }
-
-  /**
-   * Create an account description from a real data store record.
-   *
-   * @param a the data store record holding the specific account details.
-   */
-  public AccountInfo(Account a) {
-    id = a.getId();
-    fullName = a.getFullName();
-    preferredEmail = a.getPreferredEmail();
-    username = a.getUserName();
-  }
-
-  /** @return the unique local id of the account */
-  public Account.Id getId() {
-    return id;
-  }
-
-  public void setFullName(String n) {
-    fullName = n;
-  }
-
-  /** @return the full name of the account holder; null if not supplied */
-  public String getFullName() {
-    return fullName;
-  }
-
-  /** @return the email address of the account holder; null if not supplied */
-  public String getPreferredEmail() {
-    return preferredEmail;
-  }
-
-  public void setPreferredEmail(String email) {
-    preferredEmail = email;
-  }
-
-  /** @return the username of the account holder */
-  public String getUsername() {
-    return username;
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AgreementInfo.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/AgreementInfo.java
deleted file mode 100644
index 4fb4053..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/AgreementInfo.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import java.util.List;
-import java.util.Map;
-
-public class AgreementInfo {
-  public List<String> accepted;
-  public Map<String, ContributorAgreement> agreements;
-
-  public AgreementInfo() {}
-
-  public void setAccepted(List<String> a) {
-    accepted = a;
-  }
-
-  public void setAgreements(Map<String, ContributorAgreement> a) {
-    agreements = a;
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java
deleted file mode 100644
index c915cb9..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import java.sql.Timestamp;
-
-/** Group methods exposed by the GroupBackend. */
-public class GroupDescription {
-  /** The Basic information required to be exposed by any Group. */
-  public interface Basic {
-    /** @return the non-null UUID of the group. */
-    AccountGroup.UUID getGroupUUID();
-
-    /** @return the non-null name of the group. */
-    String getName();
-
-    /**
-     * @return optional email address to send to the group's members. If provided, Gerrit will use
-     *     this email address to send change notifications to the group.
-     */
-    @Nullable
-    String getEmailAddress();
-
-    /**
-     * @return optional URL to information about the group. Typically a URL to a web page that
-     *     permits users to apply to join the group, or manage their membership.
-     */
-    @Nullable
-    String getUrl();
-  }
-
-  /** The extended information exposed by internal groups. */
-  public interface Internal extends Basic {
-
-    AccountGroup.Id getId();
-
-    @Nullable
-    String getDescription();
-
-    AccountGroup.UUID getOwnerGroupUUID();
-
-    boolean isVisibleToAll();
-
-    Timestamp getCreatedOn();
-  }
-
-  private GroupDescription() {}
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java
deleted file mode 100644
index 25493e8..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import java.sql.Timestamp;
-
-/** Utility class for building GroupDescription objects. */
-public class GroupDescriptions {
-
-  public static GroupDescription.Internal forAccountGroup(AccountGroup group) {
-    return new GroupDescription.Internal() {
-      @Override
-      public AccountGroup.UUID getGroupUUID() {
-        return group.getGroupUUID();
-      }
-
-      @Override
-      public String getName() {
-        return group.getName();
-      }
-
-      @Override
-      @Nullable
-      public String getEmailAddress() {
-        return null;
-      }
-
-      @Override
-      public String getUrl() {
-        return "#" + PageLinks.toGroup(getGroupUUID());
-      }
-
-      @Override
-      public AccountGroup.Id getId() {
-        return group.getId();
-      }
-
-      @Override
-      @Nullable
-      public String getDescription() {
-        return group.getDescription();
-      }
-
-      @Override
-      public AccountGroup.UUID getOwnerGroupUUID() {
-        return group.getOwnerGroupUUID();
-      }
-
-      @Override
-      public boolean isVisibleToAll() {
-        return group.isVisibleToAll();
-      }
-
-      @Override
-      public Timestamp getCreatedOn() {
-        return group.getCreatedOn();
-      }
-    };
-  }
-
-  private GroupDescriptions() {}
-}
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
deleted file mode 100644
index c90e1fd..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelType.java
+++ /dev/null
@@ -1,325 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-public class LabelType {
-  public static final boolean DEF_ALLOW_POST_SUBMIT = true;
-  public static final boolean DEF_CAN_OVERRIDE = true;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
-  public static final boolean DEF_COPY_MAX_SCORE = false;
-  public static final boolean DEF_COPY_MIN_SCORE = false;
-
-  public static LabelType withDefaultValues(String name) {
-    checkName(name);
-    List<LabelValue> values = new ArrayList<>(2);
-    values.add(new LabelValue((short) 0, "Rejected"));
-    values.add(new LabelValue((short) 1, "Approved"));
-    return new LabelType(name, values);
-  }
-
-  public static String checkName(String name) {
-    checkNameInternal(name);
-    if ("SUBM".equals(name)) {
-      throw new IllegalArgumentException("Reserved label name \"" + name + "\"");
-    }
-    return name;
-  }
-
-  public static String checkNameInternal(String name) {
-    if (name == null || name.isEmpty()) {
-      throw new IllegalArgumentException("Empty label name");
-    }
-    for (int i = 0; i < name.length(); i++) {
-      char c = name.charAt(i);
-      if ((i == 0 && c == '-')
-          || !((c >= 'a' && c <= 'z')
-              || (c >= 'A' && c <= 'Z')
-              || (c >= '0' && c <= '9')
-              || c == '-')) {
-        throw new IllegalArgumentException("Illegal label name \"" + name + "\"");
-      }
-    }
-    return name;
-  }
-
-  private static List<LabelValue> sortValues(List<LabelValue> values) {
-    values = new ArrayList<>(values);
-    if (values.size() <= 1) {
-      return Collections.unmodifiableList(values);
-    }
-    Collections.sort(
-        values,
-        new Comparator<LabelValue>() {
-          @Override
-          public int compare(LabelValue o1, LabelValue o2) {
-            return o1.getValue() - o2.getValue();
-          }
-        });
-    short min = values.get(0).getValue();
-    short max = values.get(values.size() - 1).getValue();
-    short v = min;
-    short i = 0;
-    List<LabelValue> result = new ArrayList<>(max - min + 1);
-    // Fill in any missing values with empty text.
-    while (i < values.size()) {
-      while (v < values.get(i).getValue()) {
-        result.add(new LabelValue(v++, ""));
-      }
-      v++;
-      result.add(values.get(i++));
-    }
-    return Collections.unmodifiableList(result);
-  }
-
-  protected String name;
-
-  protected String functionName;
-  protected boolean copyMinScore;
-  protected boolean copyMaxScore;
-  protected boolean copyAllScoresOnMergeFirstParentUpdate;
-  protected boolean copyAllScoresOnTrivialRebase;
-  protected boolean copyAllScoresIfNoCodeChange;
-  protected boolean copyAllScoresIfNoChange;
-  protected boolean allowPostSubmit;
-  protected short defaultValue;
-
-  protected List<LabelValue> values;
-  protected short maxNegative;
-  protected short maxPositive;
-
-  private transient boolean canOverride;
-  private transient List<String> refPatterns;
-  private transient List<Integer> intList;
-  private transient Map<Short, LabelValue> byValue;
-
-  protected LabelType() {}
-
-  public LabelType(String name, List<LabelValue> valueList) {
-    this.name = checkName(name);
-    canOverride = true;
-    values = sortValues(valueList);
-    defaultValue = 0;
-
-    functionName = "MaxWithBlock";
-
-    maxNegative = Short.MIN_VALUE;
-    maxPositive = Short.MAX_VALUE;
-    if (values.size() > 0) {
-      if (values.get(0).getValue() < 0) {
-        maxNegative = values.get(0).getValue();
-      }
-      if (values.get(values.size() - 1).getValue() > 0) {
-        maxPositive = values.get(values.size() - 1).getValue();
-      }
-    }
-    setCanOverride(DEF_CAN_OVERRIDE);
-    setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
-    setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
-    setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
-    setCopyAllScoresOnMergeFirstParentUpdate(DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
-    setCopyMaxScore(DEF_COPY_MAX_SCORE);
-    setCopyMinScore(DEF_COPY_MIN_SCORE);
-    setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT);
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public boolean matches(PatchSetApproval psa) {
-    return psa.getLabelId().get().equalsIgnoreCase(name);
-  }
-
-  public String getFunctionName() {
-    return functionName;
-  }
-
-  public void setFunctionName(String functionName) {
-    this.functionName = functionName;
-  }
-
-  public boolean canOverride() {
-    return canOverride;
-  }
-
-  public List<String> getRefPatterns() {
-    return refPatterns;
-  }
-
-  public void setCanOverride(boolean canOverride) {
-    this.canOverride = canOverride;
-  }
-
-  public boolean allowPostSubmit() {
-    return allowPostSubmit;
-  }
-
-  public void setAllowPostSubmit(boolean allowPostSubmit) {
-    this.allowPostSubmit = allowPostSubmit;
-  }
-
-  public void setRefPatterns(List<String> refPatterns) {
-    this.refPatterns = refPatterns;
-  }
-
-  public List<LabelValue> getValues() {
-    return values;
-  }
-
-  public LabelValue getMin() {
-    if (values.isEmpty()) {
-      return null;
-    }
-    return values.get(0);
-  }
-
-  public LabelValue getMax() {
-    if (values.isEmpty()) {
-      return null;
-    }
-    return values.get(values.size() - 1);
-  }
-
-  public short getDefaultValue() {
-    return defaultValue;
-  }
-
-  public void setDefaultValue(short defaultValue) {
-    this.defaultValue = defaultValue;
-  }
-
-  public boolean isCopyMinScore() {
-    return copyMinScore;
-  }
-
-  public void setCopyMinScore(boolean copyMinScore) {
-    this.copyMinScore = copyMinScore;
-  }
-
-  public boolean isCopyMaxScore() {
-    return copyMaxScore;
-  }
-
-  public void setCopyMaxScore(boolean copyMaxScore) {
-    this.copyMaxScore = copyMaxScore;
-  }
-
-  public boolean isCopyAllScoresOnMergeFirstParentUpdate() {
-    return copyAllScoresOnMergeFirstParentUpdate;
-  }
-
-  public void setCopyAllScoresOnMergeFirstParentUpdate(
-      boolean copyAllScoresOnMergeFirstParentUpdate) {
-    this.copyAllScoresOnMergeFirstParentUpdate = copyAllScoresOnMergeFirstParentUpdate;
-  }
-
-  public boolean isCopyAllScoresOnTrivialRebase() {
-    return copyAllScoresOnTrivialRebase;
-  }
-
-  public void setCopyAllScoresOnTrivialRebase(boolean copyAllScoresOnTrivialRebase) {
-    this.copyAllScoresOnTrivialRebase = copyAllScoresOnTrivialRebase;
-  }
-
-  public boolean isCopyAllScoresIfNoCodeChange() {
-    return copyAllScoresIfNoCodeChange;
-  }
-
-  public void setCopyAllScoresIfNoCodeChange(boolean copyAllScoresIfNoCodeChange) {
-    this.copyAllScoresIfNoCodeChange = copyAllScoresIfNoCodeChange;
-  }
-
-  public boolean isCopyAllScoresIfNoChange() {
-    return copyAllScoresIfNoChange;
-  }
-
-  public void setCopyAllScoresIfNoChange(boolean copyAllScoresIfNoChange) {
-    this.copyAllScoresIfNoChange = copyAllScoresIfNoChange;
-  }
-
-  public boolean isMaxNegative(PatchSetApproval ca) {
-    return maxNegative == ca.getValue();
-  }
-
-  public boolean isMaxPositive(PatchSetApproval ca) {
-    return maxPositive == ca.getValue();
-  }
-
-  public LabelValue getValue(short value) {
-    initByValue();
-    return byValue.get(value);
-  }
-
-  public LabelValue getValue(PatchSetApproval ca) {
-    initByValue();
-    return byValue.get(ca.getValue());
-  }
-
-  private void initByValue() {
-    if (byValue == null) {
-      byValue = new HashMap<>();
-      for (LabelValue v : values) {
-        byValue.put(v.getValue(), v);
-      }
-    }
-  }
-
-  public List<Integer> getValuesAsList() {
-    if (intList == null) {
-      intList = new ArrayList<>(values.size());
-      for (LabelValue v : values) {
-        intList.add(Integer.valueOf(v.getValue()));
-      }
-      Collections.sort(intList);
-      Collections.reverse(intList);
-    }
-    return intList;
-  }
-
-  public LabelId getLabelId() {
-    return new LabelId(name);
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder sb = new StringBuilder(name).append('[');
-    LabelValue min = getMin();
-    LabelValue max = getMax();
-    if (min != null && max != null) {
-      sb.append(
-          new PermissionRange(Permission.forLabel(name), min.getValue(), max.getValue())
-              .toString()
-              .trim());
-    } else if (min != null) {
-      sb.append(min.formatValue().trim());
-    } else if (max != null) {
-      sb.append(max.formatValue().trim());
-    }
-    sb.append(']');
-    return sb.toString();
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitTypeRecord.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitTypeRecord.java
deleted file mode 100644
index a01d83d..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitTypeRecord.java
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.extensions.client.SubmitType;
-
-/** Describes the submit type for a change. */
-public class SubmitTypeRecord {
-  public enum Status {
-    /** The type was computed successfully */
-    OK,
-
-    /**
-     * An internal server error occurred preventing computation.
-     *
-     * <p>Additional detail may be available in {@link SubmitTypeRecord#errorMessage}
-     */
-    RULE_ERROR
-  }
-
-  public static SubmitTypeRecord OK(SubmitType type) {
-    return new SubmitTypeRecord(Status.OK, type, null);
-  }
-
-  public static SubmitTypeRecord error(String err) {
-    return new SubmitTypeRecord(SubmitTypeRecord.Status.RULE_ERROR, null, err);
-  }
-
-  /** Status enum value of the record. */
-  public final Status status;
-
-  /** Submit type of the record; never null if {@link #status} is {@code OK}. */
-  public final SubmitType type;
-
-  /** Submit type of the record; always null if {@link #status} is {@code OK}. */
-  public final String errorMessage;
-
-  private SubmitTypeRecord(Status status, SubmitType type, String errorMessage) {
-    this.status = status;
-    this.type = type;
-    this.errorMessage = errorMessage;
-  }
-
-  public boolean isOk() {
-    return status == Status.OK;
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append(status);
-    if (status == Status.RULE_ERROR && errorMessage != null) {
-      sb.append('(').append(errorMessage).append(")");
-    }
-    if (type != null) {
-      sb.append('[');
-      sb.append(type.name());
-      sb.append(']');
-    }
-    return sb.toString();
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidQueryException.java b/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidQueryException.java
deleted file mode 100644
index 4a66a416a..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidQueryException.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.errors;
-
-/** Error indicating the query cannot be executed. */
-public class InvalidQueryException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public InvalidQueryException(String message, String query) {
-    super("Invalid query: " + query + "\n\n" + message);
-  }
-}
diff --git a/gerrit-elasticsearch/BUILD b/gerrit-elasticsearch/BUILD
deleted file mode 100644
index fb86aaf..0000000
--- a/gerrit-elasticsearch/BUILD
+++ /dev/null
@@ -1,69 +0,0 @@
-java_library(
-    name = "elasticsearch",
-    srcs = glob(["src/main/java/**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-extension-api:api",
-        "//gerrit-index:index",
-        "//gerrit-index:query_exception",
-        "//gerrit-reviewdb:server",
-        "//gerrit-server:server",
-        "//lib:gson",
-        "//lib:guava",
-        "//lib:gwtorm",
-        "//lib:protobuf",
-        "//lib/commons:codec",
-        "//lib/commons:lang",
-        "//lib/elasticsearch",
-        "//lib/elasticsearch:jest",
-        "//lib/elasticsearch:jest-common",
-        "//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",
-    ],
-)
-
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-java_library(
-    name = "elasticsearch_test_utils",
-    testonly = 1,
-    srcs = glob(["src/test/java/**/ElasticTestUtils.java"]),
-    deps = [
-        ":elasticsearch",
-        "//gerrit-extension-api:api",
-        "//gerrit-index:index",
-        "//gerrit-reviewdb:server",
-        "//gerrit-server:server",
-        "//lib:gson",
-        "//lib:guava",
-        "//lib:junit",
-        "//lib:truth",
-        "//lib/elasticsearch",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
-    ],
-)
-
-junit_tests(
-    name = "elasticsearch_tests",
-    size = "large",
-    srcs = glob(["src/test/java/**/*Test.java"]),
-    tags = [
-        "elastic",
-    ],
-    deps = [
-        ":elasticsearch",
-        ":elasticsearch_test_utils",
-        "//gerrit-server:query_tests_code",
-        "//gerrit-server:server",
-        "//gerrit-server:testutil",
-        "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
-    ],
-)
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
deleted file mode 100644
index 8f0ea8f..0000000
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ /dev/null
@@ -1,174 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
-import static java.util.stream.Collectors.toList;
-import static org.apache.commons.codec.binary.Base64.decodeBase64;
-import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Streams;
-import com.google.gerrit.index.Index;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.Schema.Values;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonObject;
-import com.google.gwtorm.protobuf.ProtobufCodec;
-import io.searchbox.client.JestResult;
-import io.searchbox.client.http.JestHttpClient;
-import io.searchbox.core.Bulk;
-import io.searchbox.core.Delete;
-import io.searchbox.indices.CreateIndex;
-import io.searchbox.indices.DeleteIndex;
-import io.searchbox.indices.IndicesExists;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.lib.Config;
-import org.elasticsearch.common.xcontent.XContentBuilder;
-
-abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
-  protected static <T> List<T> decodeProtos(
-      JsonObject doc, String fieldName, ProtobufCodec<T> codec) {
-    JsonArray field = doc.getAsJsonArray(fieldName);
-    if (field == null) {
-      return null;
-    }
-    return FluentIterable.from(field)
-        .transform(i -> codec.decode(decodeBase64(i.toString())))
-        .toList();
-  }
-
-  private final Schema<V> schema;
-  private final SitePaths sitePaths;
-
-  protected final String indexName;
-  protected final JestHttpClient client;
-  protected final Gson gson;
-  protected final ElasticQueryBuilder queryBuilder;
-
-  AbstractElasticIndex(
-      @GerritServerConfig Config cfg,
-      SitePaths sitePaths,
-      Schema<V> schema,
-      JestClientBuilder clientBuilder,
-      String indexName) {
-    this.sitePaths = sitePaths;
-    this.schema = schema;
-    this.gson = new GsonBuilder().setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
-    this.queryBuilder = new ElasticQueryBuilder();
-    this.indexName =
-        String.format(
-            "%s%s%04d",
-            Strings.nullToEmpty(cfg.getString("elasticsearch", null, "prefix")),
-            indexName,
-            schema.getVersion());
-    this.client = clientBuilder.build();
-  }
-
-  @Override
-  public Schema<V> getSchema() {
-    return schema;
-  }
-
-  @Override
-  public void close() {
-    client.shutdownClient();
-  }
-
-  @Override
-  public void markReady(boolean ready) throws IOException {
-    IndexUtils.setReady(sitePaths, indexName, schema.getVersion(), ready);
-  }
-
-  @Override
-  public void delete(K c) throws IOException {
-    Bulk bulk = addActions(new Bulk.Builder(), c).refresh(true).build();
-    JestResult result = client.execute(bulk);
-    if (!result.isSucceeded()) {
-      throw new IOException(
-          String.format(
-              "Failed to delete change %s in index %s: %s",
-              c, indexName, result.getErrorMessage()));
-    }
-  }
-
-  @Override
-  public void deleteAll() throws IOException {
-    // Delete the index, if it exists.
-    JestResult result = client.execute(new IndicesExists.Builder(indexName).build());
-    if (result.isSucceeded()) {
-      result = client.execute(new DeleteIndex.Builder(indexName).build());
-      if (!result.isSucceeded()) {
-        throw new IOException(
-            String.format("Failed to delete index %s: %s", indexName, result.getErrorMessage()));
-      }
-    }
-
-    // Recreate the index.
-    result = client.execute(new CreateIndex.Builder(indexName).settings(getMappings()).build());
-    if (!result.isSucceeded()) {
-      String error =
-          String.format("Failed to create index %s: %s", indexName, result.getErrorMessage());
-      throw new IOException(error);
-    }
-  }
-
-  protected abstract Bulk.Builder addActions(Bulk.Builder builder, K c);
-
-  protected abstract String getMappings();
-
-  protected abstract String getId(V v);
-
-  protected Delete delete(String type, K c) {
-    String id = c.toString();
-    return new Delete.Builder(id).index(indexName).type(type).build();
-  }
-
-  protected io.searchbox.core.Index insert(String type, V v) throws IOException {
-    String id = getId(v);
-    String doc = toDoc(v);
-    return new io.searchbox.core.Index.Builder(doc).index(indexName).type(type).id(id).build();
-  }
-
-  private static boolean shouldAddElement(Object element) {
-    return !(element instanceof String) || !((String) element).isEmpty();
-  }
-
-  private String toDoc(V v) throws IOException {
-    XContentBuilder builder = jsonBuilder().startObject();
-    for (Values<V> values : schema.buildFields(v)) {
-      String name = values.getField().getName();
-      if (values.getField().isRepeatable()) {
-        builder.field(
-            name,
-            Streams.stream(values.getValues()).filter(e -> shouldAddElement(e)).collect(toList()));
-      } else {
-        Object element = Iterables.getOnlyElement(values.getValues(), "");
-        if (shouldAddElement(element)) {
-          builder.field(name, element);
-        }
-      }
-    }
-    return builder.endObject().string();
-  }
-}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
deleted file mode 100644
index 18eb660..0000000
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ /dev/null
@@ -1,219 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import static com.google.gerrit.server.index.account.AccountField.ID;
-
-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.Account;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.account.AccountField;
-import com.google.gerrit.server.index.account.AccountIndex;
-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 ElasticAccountIndex extends AbstractElasticIndex<Account.Id, AccountState>
-    implements AccountIndex {
-  static class AccountMapping {
-    MappingProperties accounts;
-
-    AccountMapping(Schema<AccountState> schema) {
-      this.accounts = ElasticMapping.createMapping(schema);
-    }
-  }
-
-  static final String ACCOUNTS = "accounts";
-  static final String ACCOUNTS_PREFIX = ACCOUNTS + "_";
-
-  private static final Logger log = LoggerFactory.getLogger(ElasticAccountIndex.class);
-
-  private final AccountMapping mapping;
-  private final Provider<AccountCache> accountCache;
-
-  @Inject
-  ElasticAccountIndex(
-      @GerritServerConfig Config cfg,
-      SitePaths sitePaths,
-      Provider<AccountCache> accountCache,
-      JestClientBuilder clientBuilder,
-      @Assisted Schema<AccountState> schema) {
-    super(cfg, sitePaths, schema, clientBuilder, ACCOUNTS_PREFIX);
-    this.accountCache = accountCache;
-    this.mapping = new AccountMapping(schema);
-  }
-
-  @Override
-  public void replace(AccountState as) throws IOException {
-    Bulk bulk =
-        new Bulk.Builder()
-            .defaultIndex(indexName)
-            .defaultType(ACCOUNTS)
-            .addAction(insert(ACCOUNTS, as))
-            .refresh(true)
-            .build();
-    JestResult result = client.execute(bulk);
-    if (!result.isSucceeded()) {
-      throw new IOException(
-          String.format(
-              "Failed to replace account %s in index %s: %s",
-              as.getAccount().getId(), indexName, result.getErrorMessage()));
-    }
-  }
-
-  @Override
-  public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
-      throws QueryParseException {
-    return new QuerySource(p, opts);
-  }
-
-  @Override
-  protected Builder addActions(Builder builder, Account.Id c) {
-    return builder.addAction(delete(ACCOUNTS, c));
-  }
-
-  @Override
-  protected String getMappings() {
-    ImmutableMap<String, AccountMapping> mappings = ImmutableMap.of("mappings", mapping);
-    return gson.toJson(mappings);
-  }
-
-  @Override
-  protected String getId(AccountState as) {
-    return as.getAccount().getId().toString();
-  }
-
-  private class QuerySource implements DataSource<AccountState> {
-    private final Search search;
-    private final Set<String> fields;
-
-    QuerySource(Predicate<AccountState> p, QueryOptions opts) throws QueryParseException {
-      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
-      fields = IndexUtils.accountFields(opts);
-      SearchSourceBuilder searchSource =
-          new SearchSourceBuilder()
-              .query(qb)
-              .from(opts.start())
-              .size(opts.limit())
-              .fields(Lists.newArrayList(fields));
-
-      Sort sort = new Sort(AccountField.ID.getName(), Sorting.ASC);
-      sort.setIgnoreUnmapped();
-
-      search =
-          new Search.Builder(searchSource.toString())
-              .addType(ACCOUNTS)
-              .addIndex(indexName)
-              .addSort(ImmutableList.of(sort))
-              .build();
-    }
-
-    @Override
-    public int getCardinality() {
-      return 10;
-    }
-
-    @Override
-    public ResultSet<AccountState> read() throws OrmException {
-      try {
-        List<AccountState> 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(toAccountState(json.get(i)));
-            }
-          }
-        } else {
-          log.error(result.getErrorMessage());
-        }
-        final List<AccountState> r = Collections.unmodifiableList(results);
-        return new ResultSet<AccountState>() {
-          @Override
-          public Iterator<AccountState> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<AccountState> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
-      } catch (IOException e) {
-        throw new OrmException(e);
-      }
-    }
-
-    @Override
-    public String toString() {
-      return search.toString();
-    }
-
-    private AccountState toAccountState(JsonElement json) {
-      JsonElement source = json.getAsJsonObject().get("_source");
-      if (source == null) {
-        source = json.getAsJsonObject().get("fields");
-      }
-
-      Account.Id id = new Account.Id(source.getAsJsonObject().get(ID.getName()).getAsInt());
-      // Use the AccountCache rather than depending on any stored fields in the
-      // document (of which there shouldn't be any). The most expensive part to
-      // compute anyway is the effective group IDs, and we don't have a good way
-      // to reindex when those change.
-      return accountCache.get().get(id);
-    }
-  }
-}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
deleted file mode 100644
index b99f296..0000000
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ /dev/null
@@ -1,426 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.index.change.ChangeField.APPROVAL_CODEC;
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE_CODEC;
-import static com.google.gerrit.server.index.change.ChangeField.PATCH_SET_CODEC;
-import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
-import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.apache.commons.codec.binary.Base64.decodeBase64;
-
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
-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.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Id;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ReviewerByEmailSet;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.ChangeIndexRewriter;
-import com.google.gerrit.server.project.SubmitRuleOptions;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeDataSource;
-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.apache.commons.codec.binary.Base64;
-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;
-
-/** Secondary index implementation using Elasticsearch. */
-class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
-    implements ChangeIndex {
-  private static final Logger log = LoggerFactory.getLogger(ElasticChangeIndex.class);
-
-  static class ChangeMapping {
-    MappingProperties openChanges;
-    MappingProperties closedChanges;
-
-    ChangeMapping(Schema<ChangeData> schema) {
-      MappingProperties mapping = ElasticMapping.createMapping(schema);
-      this.openChanges = mapping;
-      this.closedChanges = mapping;
-    }
-  }
-
-  static final String CHANGES_PREFIX = "changes_";
-  static final String OPEN_CHANGES = "open_changes";
-  static final String CLOSED_CHANGES = "closed_changes";
-
-  private final ChangeMapping mapping;
-  private final Provider<ReviewDb> db;
-  private final ChangeData.Factory changeDataFactory;
-
-  @Inject
-  ElasticChangeIndex(
-      @GerritServerConfig Config cfg,
-      Provider<ReviewDb> db,
-      ChangeData.Factory changeDataFactory,
-      SitePaths sitePaths,
-      JestClientBuilder clientBuilder,
-      @Assisted Schema<ChangeData> schema) {
-    super(cfg, sitePaths, schema, clientBuilder, CHANGES_PREFIX);
-    this.db = db;
-    this.changeDataFactory = changeDataFactory;
-    mapping = new ChangeMapping(schema);
-  }
-
-  @Override
-  public void replace(ChangeData cd) throws IOException {
-    String deleteIndex;
-    String insertIndex;
-
-    try {
-      if (cd.change().getStatus().isOpen()) {
-        insertIndex = OPEN_CHANGES;
-        deleteIndex = CLOSED_CHANGES;
-      } else {
-        insertIndex = CLOSED_CHANGES;
-        deleteIndex = OPEN_CHANGES;
-      }
-    } catch (OrmException e) {
-      throw new IOException(e);
-    }
-
-    Bulk bulk =
-        new Bulk.Builder()
-            .defaultIndex(indexName)
-            .defaultType("changes")
-            .addAction(insert(insertIndex, cd))
-            .addAction(delete(deleteIndex, cd.getId()))
-            .refresh(true)
-            .build();
-    JestResult result = client.execute(bulk);
-    if (!result.isSucceeded()) {
-      throw new IOException(
-          String.format(
-              "Failed to replace change %s in index %s: %s",
-              cd.getId(), indexName, result.getErrorMessage()));
-    }
-  }
-
-  @Override
-  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
-      throws QueryParseException {
-    Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
-    List<String> indexes = Lists.newArrayListWithCapacity(2);
-    if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
-      indexes.add(OPEN_CHANGES);
-    }
-    if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
-      indexes.add(CLOSED_CHANGES);
-    }
-    return new QuerySource(indexes, p, opts);
-  }
-
-  @Override
-  protected Builder addActions(Builder builder, Id c) {
-    return builder.addAction(delete(OPEN_CHANGES, c)).addAction(delete(OPEN_CHANGES, c));
-  }
-
-  @Override
-  protected String getMappings() {
-    return gson.toJson(ImmutableMap.of("mappings", mapping));
-  }
-
-  @Override
-  protected String getId(ChangeData cd) {
-    return cd.getId().toString();
-  }
-
-  private class QuerySource implements ChangeDataSource {
-    private final Search search;
-    private final Set<String> fields;
-
-    QuerySource(List<String> types, Predicate<ChangeData> p, QueryOptions opts)
-        throws QueryParseException {
-      List<Sort> sorts =
-          ImmutableList.of(
-              new Sort(ChangeField.UPDATED.getName(), Sorting.DESC),
-              new Sort(ChangeField.LEGACY_ID.getName(), Sorting.DESC));
-      for (Sort sort : sorts) {
-        sort.setIgnoreUnmapped();
-      }
-      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
-      fields = IndexUtils.changeFields(opts);
-      SearchSourceBuilder searchSource =
-          new SearchSourceBuilder()
-              .query(qb)
-              .from(opts.start())
-              .size(opts.limit())
-              .fields(Lists.newArrayList(fields));
-
-      search =
-          new Search.Builder(searchSource.toString())
-              .addType(types)
-              .addSort(sorts)
-              .addIndex(indexName)
-              .build();
-    }
-
-    @Override
-    public int getCardinality() {
-      return 10;
-    }
-
-    @Override
-    public ResultSet<ChangeData> read() throws OrmException {
-      try {
-        List<ChangeData> 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(toChangeData(json.get(i)));
-            }
-          }
-        } else {
-          log.error(result.getErrorMessage());
-        }
-        final List<ChangeData> r = Collections.unmodifiableList(results);
-        return new ResultSet<ChangeData>() {
-          @Override
-          public Iterator<ChangeData> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<ChangeData> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
-      } catch (IOException e) {
-        throw new OrmException(e);
-      }
-    }
-
-    @Override
-    public boolean hasChange() {
-      return false;
-    }
-
-    @Override
-    public String toString() {
-      return search.toString();
-    }
-
-    private ChangeData toChangeData(JsonElement json) {
-      JsonElement sourceElement = json.getAsJsonObject().get("_source");
-      if (sourceElement == null) {
-        sourceElement = json.getAsJsonObject().get("fields");
-      }
-      JsonObject source = sourceElement.getAsJsonObject();
-      JsonElement c = source.get(ChangeField.CHANGE.getName());
-
-      if (c == null) {
-        int id = source.get(ChangeField.LEGACY_ID.getName()).getAsInt();
-        // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
-        String projectName = checkNotNull(source.get(ChangeField.PROJECT.getName()).getAsString());
-        return changeDataFactory.create(
-            db.get(), new Project.NameKey(projectName), new Change.Id(id));
-      }
-
-      ChangeData cd =
-          changeDataFactory.create(
-              db.get(), CHANGE_CODEC.decode(Base64.decodeBase64(c.getAsString())));
-
-      // Patch sets.
-      cd.setPatchSets(decodeProtos(source, ChangeField.PATCH_SET.getName(), PATCH_SET_CODEC));
-
-      // Approvals.
-      if (source.get(ChangeField.APPROVAL.getName()) != null) {
-        cd.setCurrentApprovals(
-            decodeProtos(source, ChangeField.APPROVAL.getName(), APPROVAL_CODEC));
-      } else if (fields.contains(ChangeField.APPROVAL.getName())) {
-        cd.setCurrentApprovals(Collections.emptyList());
-      }
-
-      JsonElement addedElement = source.get(ChangeField.ADDED.getName());
-      JsonElement deletedElement = source.get(ChangeField.DELETED.getName());
-      if (addedElement != null && deletedElement != null) {
-        // Changed lines.
-        int added = addedElement.getAsInt();
-        int deleted = deletedElement.getAsInt();
-        if (added != 0 && deleted != 0) {
-          cd.setChangedLines(added, deleted);
-        }
-      }
-
-      // Mergeable.
-      JsonElement mergeableElement = source.get(ChangeField.MERGEABLE.getName());
-      if (mergeableElement != null) {
-        String mergeable = mergeableElement.getAsString();
-        if ("1".equals(mergeable)) {
-          cd.setMergeable(true);
-        } else if ("0".equals(mergeable)) {
-          cd.setMergeable(false);
-        }
-      }
-
-      // Reviewed-by.
-      if (source.get(ChangeField.REVIEWEDBY.getName()) != null) {
-        JsonArray reviewedBy = source.get(ChangeField.REVIEWEDBY.getName()).getAsJsonArray();
-        if (reviewedBy.size() > 0) {
-          Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
-          for (int i = 0; i < reviewedBy.size(); i++) {
-            int aId = reviewedBy.get(i).getAsInt();
-            if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
-              break;
-            }
-            accounts.add(new Account.Id(aId));
-          }
-          cd.setReviewedBy(accounts);
-        }
-      } else if (fields.contains(ChangeField.REVIEWEDBY.getName())) {
-        cd.setReviewedBy(Collections.emptySet());
-      }
-
-      if (source.get(ChangeField.REVIEWER.getName()) != null) {
-        cd.setReviewers(
-            ChangeField.parseReviewerFieldValues(
-                FluentIterable.from(source.get(ChangeField.REVIEWER.getName()).getAsJsonArray())
-                    .transform(JsonElement::getAsString)));
-      } else if (fields.contains(ChangeField.REVIEWER.getName())) {
-        cd.setReviewers(ReviewerSet.empty());
-      }
-
-      if (source.get(ChangeField.REVIEWER_BY_EMAIL.getName()) != null) {
-        cd.setReviewersByEmail(
-            ChangeField.parseReviewerByEmailFieldValues(
-                FluentIterable.from(
-                        source.get(ChangeField.REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
-                    .transform(JsonElement::getAsString)));
-      } else if (fields.contains(ChangeField.REVIEWER_BY_EMAIL.getName())) {
-        cd.setReviewersByEmail(ReviewerByEmailSet.empty());
-      }
-
-      if (source.get(ChangeField.PENDING_REVIEWER.getName()) != null) {
-        cd.setPendingReviewers(
-            ChangeField.parseReviewerFieldValues(
-                FluentIterable.from(
-                        source.get(ChangeField.PENDING_REVIEWER.getName()).getAsJsonArray())
-                    .transform(JsonElement::getAsString)));
-      } else if (fields.contains(ChangeField.PENDING_REVIEWER.getName())) {
-        cd.setPendingReviewers(ReviewerSet.empty());
-      }
-
-      if (source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()) != null) {
-        cd.setPendingReviewersByEmail(
-            ChangeField.parseReviewerByEmailFieldValues(
-                FluentIterable.from(
-                        source
-                            .get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName())
-                            .getAsJsonArray())
-                    .transform(JsonElement::getAsString)));
-      } else if (fields.contains(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName())) {
-        cd.setPendingReviewersByEmail(ReviewerByEmailSet.empty());
-      }
-      decodeSubmitRecords(
-          source,
-          ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
-          ChangeField.SUBMIT_RULE_OPTIONS_STRICT,
-          cd);
-      decodeSubmitRecords(
-          source,
-          ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName(),
-          ChangeField.SUBMIT_RULE_OPTIONS_LENIENT,
-          cd);
-      decodeUnresolvedCommentCount(source, ChangeField.UNRESOLVED_COMMENT_COUNT.getName(), cd);
-
-      if (fields.contains(ChangeField.REF_STATE.getName())) {
-        cd.setRefStates(getByteArray(source, ChangeField.REF_STATE.getName()));
-      }
-      if (fields.contains(ChangeField.REF_STATE_PATTERN.getName())) {
-        cd.setRefStatePatterns(getByteArray(source, ChangeField.REF_STATE_PATTERN.getName()));
-      }
-
-      return cd;
-    }
-
-    private Iterable<byte[]> getByteArray(JsonObject source, String name) {
-      JsonElement element = source.get(name);
-      return element != null
-          ? Iterables.transform(element.getAsJsonArray(), e -> Base64.decodeBase64(e.getAsString()))
-          : Collections.emptyList();
-    }
-
-    private void decodeSubmitRecords(
-        JsonObject doc, String fieldName, SubmitRuleOptions opts, ChangeData out) {
-      JsonArray records = doc.getAsJsonArray(fieldName);
-      if (records == null) {
-        return;
-      }
-      ChangeField.parseSubmitRecords(
-          FluentIterable.from(records)
-              .transform(i -> new String(decodeBase64(i.toString()), UTF_8))
-              .toList(),
-          opts,
-          out);
-    }
-
-    private void decodeUnresolvedCommentCount(JsonObject doc, String fieldName, ChangeData out) {
-      JsonElement count = doc.get(fieldName);
-      if (count == null) {
-        return;
-      }
-      out.setUnresolvedCommentCount(count.getAsInt());
-    }
-  }
-}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
deleted file mode 100644
index 6ca4ad5..0000000
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ /dev/null
@@ -1,219 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.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.AccountGroup;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.group.GroupField;
-import com.google.gerrit.server.index.group.GroupIndex;
-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.Optional;
-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 ElasticGroupIndex extends AbstractElasticIndex<AccountGroup.UUID, InternalGroup>
-    implements GroupIndex {
-  static class GroupMapping {
-    MappingProperties groups;
-
-    GroupMapping(Schema<InternalGroup> schema) {
-      this.groups = ElasticMapping.createMapping(schema);
-    }
-  }
-
-  static final String GROUPS = "groups";
-  static final String GROUPS_PREFIX = GROUPS + "_";
-
-  private static final Logger log = LoggerFactory.getLogger(ElasticGroupIndex.class);
-
-  private final GroupMapping mapping;
-  private final Provider<GroupCache> groupCache;
-
-  @Inject
-  ElasticGroupIndex(
-      @GerritServerConfig Config cfg,
-      SitePaths sitePaths,
-      Provider<GroupCache> groupCache,
-      JestClientBuilder clientBuilder,
-      @Assisted Schema<InternalGroup> schema) {
-    super(cfg, sitePaths, schema, clientBuilder, GROUPS_PREFIX);
-    this.groupCache = groupCache;
-    this.mapping = new GroupMapping(schema);
-  }
-
-  @Override
-  public void replace(InternalGroup group) throws IOException {
-    Bulk bulk =
-        new Bulk.Builder()
-            .defaultIndex(indexName)
-            .defaultType(GROUPS)
-            .addAction(insert(GROUPS, group))
-            .refresh(true)
-            .build();
-    JestResult result = client.execute(bulk);
-    if (!result.isSucceeded()) {
-      throw new IOException(
-          String.format(
-              "Failed to replace group %s in index %s: %s",
-              group.getGroupUUID().get(), indexName, result.getErrorMessage()));
-    }
-  }
-
-  @Override
-  public DataSource<InternalGroup> getSource(Predicate<InternalGroup> p, QueryOptions opts)
-      throws QueryParseException {
-    return new QuerySource(p, opts);
-  }
-
-  @Override
-  protected Builder addActions(Builder builder, AccountGroup.UUID c) {
-    return builder.addAction(delete(GROUPS, c));
-  }
-
-  @Override
-  protected String getMappings() {
-    ImmutableMap<String, GroupMapping> mappings = ImmutableMap.of("mappings", mapping);
-    return gson.toJson(mappings);
-  }
-
-  @Override
-  protected String getId(InternalGroup group) {
-    return group.getGroupUUID().get();
-  }
-
-  private class QuerySource implements DataSource<InternalGroup> {
-    private final Search search;
-    private final Set<String> fields;
-
-    QuerySource(Predicate<InternalGroup> p, QueryOptions opts) throws QueryParseException {
-      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
-      fields = IndexUtils.groupFields(opts);
-      SearchSourceBuilder searchSource =
-          new SearchSourceBuilder()
-              .query(qb)
-              .from(opts.start())
-              .size(opts.limit())
-              .fields(Lists.newArrayList(fields));
-
-      Sort sort = new Sort(GroupField.UUID.getName(), Sorting.ASC);
-      sort.setIgnoreUnmapped();
-
-      search =
-          new Search.Builder(searchSource.toString())
-              .addType(GROUPS)
-              .addIndex(indexName)
-              .addSort(ImmutableList.of(sort))
-              .build();
-    }
-
-    @Override
-    public int getCardinality() {
-      return 10;
-    }
-
-    @Override
-    public ResultSet<InternalGroup> read() throws OrmException {
-      try {
-        List<InternalGroup> 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++) {
-              Optional<InternalGroup> internalGroup = toInternalGroup(json.get(i));
-              internalGroup.ifPresent(results::add);
-            }
-          }
-        } else {
-          log.error(result.getErrorMessage());
-        }
-        final List<InternalGroup> r = Collections.unmodifiableList(results);
-        return new ResultSet<InternalGroup>() {
-          @Override
-          public Iterator<InternalGroup> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<InternalGroup> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
-      } catch (IOException e) {
-        throw new OrmException(e);
-      }
-    }
-
-    @Override
-    public String toString() {
-      return search.toString();
-    }
-
-    private Optional<InternalGroup> toInternalGroup(JsonElement json) {
-      JsonElement source = json.getAsJsonObject().get("_source");
-      if (source == null) {
-        source = json.getAsJsonObject().get("fields");
-      }
-
-      AccountGroup.UUID uuid =
-          new AccountGroup.UUID(
-              source.getAsJsonObject().get(GroupField.UUID.getName()).getAsString());
-      // Use the GroupCache rather than depending on any stored fields in the
-      // document (of which there shouldn't be any).
-      return groupCache.get().get(uuid);
-    }
-  }
-}
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
deleted file mode 100644
index 7868443..0000000
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.OnlineUpgrader;
-import com.google.gerrit.server.index.SingleVersionModule;
-import com.google.gerrit.server.index.VersionManager;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.inject.AbstractModule;
-import com.google.inject.Provides;
-import com.google.inject.Singleton;
-import com.google.inject.assistedinject.FactoryModuleBuilder;
-import java.util.Map;
-import org.eclipse.jgit.lib.Config;
-
-public class ElasticIndexModule extends AbstractModule {
-  public static ElasticIndexModule singleVersionWithExplicitVersions(
-      Map<String, Integer> versions, int threads) {
-    return new ElasticIndexModule(versions, threads, false);
-  }
-
-  public static ElasticIndexModule latestVersionWithOnlineUpgrade() {
-    return new ElasticIndexModule(null, 0, true);
-  }
-
-  public static ElasticIndexModule latestVersionWithoutOnlineUpgrade() {
-    return new ElasticIndexModule(null, 0, false);
-  }
-
-  private final Map<String, Integer> singleVersions;
-  private final int threads;
-  private final boolean onlineUpgrade;
-
-  private ElasticIndexModule(
-      Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade) {
-    if (singleVersions != null) {
-      checkArgument(!onlineUpgrade, "online upgrade is incompatible with single version map");
-    }
-    this.singleVersions = singleVersions;
-    this.threads = threads;
-    this.onlineUpgrade = onlineUpgrade;
-  }
-
-  @Override
-  protected void configure() {
-    install(
-        new FactoryModuleBuilder()
-            .implement(AccountIndex.class, ElasticAccountIndex.class)
-            .build(AccountIndex.Factory.class));
-    install(
-        new FactoryModuleBuilder()
-            .implement(ChangeIndex.class, ElasticChangeIndex.class)
-            .build(ChangeIndex.Factory.class));
-    install(
-        new FactoryModuleBuilder()
-            .implement(GroupIndex.class, ElasticGroupIndex.class)
-            .build(GroupIndex.Factory.class));
-
-    install(new IndexModule(threads));
-    if (singleVersions == null) {
-      install(new MultiVersionModule());
-    } else {
-      install(new SingleVersionModule(singleVersions));
-    }
-  }
-
-  @Provides
-  @Singleton
-  IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
-    return IndexConfig.fromConfig(cfg).separateChangeSubIndexes(true).build();
-  }
-
-  private class MultiVersionModule extends LifecycleModule {
-    @Override
-    public void configure() {
-      bind(VersionManager.class).to(ElasticVersionManager.class);
-      listener().to(ElasticVersionManager.class);
-      if (onlineUpgrade) {
-        listener().to(OnlineUpgrader.class);
-      }
-    }
-  }
-}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
deleted file mode 100644
index b7a317f..0000000
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import java.util.concurrent.ExecutionException;
-import org.eclipse.jgit.lib.Config;
-import org.junit.After;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticQueryAccountsTest extends AbstractQueryAccountsTest {
-  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/ElasticQueryChangesTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
deleted file mode 100644
index beb7aa9..0000000
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import java.util.concurrent.ExecutionException;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.junit.After;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-import org.junit.Test;
-
-public class ElasticQueryChangesTest extends AbstractQueryChangesTest {
-  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);
-  }
-
-  @After
-  public void cleanupIndex() {
-    if (nodeInfo != null) {
-      ElasticTestUtils.deleteAllIndexes(nodeInfo);
-      ElasticTestUtils.createAllIndexes(nodeInfo);
-    }
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (nodeInfo != null) {
-      nodeInfo.node.close();
-      nodeInfo.elasticDir.delete();
-      nodeInfo = null;
-    }
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
-  }
-
-  @Test
-  public void byOwnerInvalidQuery() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    insert(repo, newChange(repo), userId);
-    String nameEmail = user.asIdentifiedUser().getNameEmail();
-    assertQuery("owner: \"" + nameEmail + "\"\\");
-  }
-}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
deleted file mode 100644
index 2c479f1..0000000
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import java.util.concurrent.ExecutionException;
-import org.eclipse.jgit.lib.Config;
-import org.junit.After;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticQueryGroupsTest extends AbstractQueryGroupsTest {
-  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
deleted file mode 100644
index fac10eb..0000000
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ /dev/null
@@ -1,187 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.elasticsearch.ElasticAccountIndex.ACCOUNTS_PREFIX;
-import static com.google.gerrit.elasticsearch.ElasticChangeIndex.CHANGES_PREFIX;
-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 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.index.Schema;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.index.IndexModule.IndexType;
-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.query.change.ChangeData;
-import com.google.gson.FieldNamingPolicy;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import java.io.File;
-import java.nio.file.Path;
-import java.util.Iterator;
-import java.util.Map;
-import java.util.concurrent.ExecutionException;
-import org.eclipse.jgit.lib.Config;
-import org.elasticsearch.action.admin.cluster.node.info.NodesInfoRequest;
-import org.elasticsearch.common.settings.Settings;
-import org.elasticsearch.node.Node;
-import org.elasticsearch.node.NodeBuilder;
-
-final class ElasticTestUtils {
-  static final Gson gson =
-      new GsonBuilder()
-          .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
-          .create();
-
-  static class ElasticNodeInfo {
-    final Node node;
-    final String port;
-    final File elasticDir;
-
-    private ElasticNodeInfo(Node node, File rootDir, String port) {
-      this.node = node;
-      this.port = port;
-      this.elasticDir = rootDir;
-    }
-  }
-
-  static void configure(Config config, String port) {
-    config.setEnum("index", null, "type", IndexType.ELASTICSEARCH);
-    config.setString("elasticsearch", "test", "protocol", "http");
-    config.setString("elasticsearch", "test", "hostname", "localhost");
-    config.setString("elasticsearch", "test", "port", port);
-  }
-
-  static ElasticNodeInfo startElasticsearchNode() throws InterruptedException, ExecutionException {
-    File elasticDir = Files.createTempDir();
-    Path elasticDirPath = elasticDir.toPath();
-    Settings settings =
-        Settings.settingsBuilder()
-            .put("cluster.name", "gerrit")
-            .put("node.name", "Gerrit Elasticsearch Test Node")
-            .put("node.local", true)
-            .put("discovery.zen.ping.multicast.enabled", false)
-            .put("index.store.fs.memory.enabled", true)
-            .put("index.gateway.type", "none")
-            .put("index.max_result_window", Integer.MAX_VALUE)
-            .put("gateway.type", "default")
-            .put("http.port", 0)
-            .put("discovery.zen.ping.unicast.hosts", "[\"localhost\"]")
-            .put("path.home", elasticDirPath.toAbsolutePath())
-            .put("path.data", elasticDirPath.resolve("data").toAbsolutePath())
-            .put("path.work", elasticDirPath.resolve("work").toAbsolutePath())
-            .put("path.logs", elasticDirPath.resolve("logs").toAbsolutePath())
-            .put("transport.tcp.connect_timeout", "60s")
-            .build();
-
-    // Start the node
-    Node node = NodeBuilder.nodeBuilder().settings(settings).node();
-
-    // Wait for it to be ready
-    node.client().admin().cluster().prepareHealth().setWaitForYellowStatus().execute().actionGet();
-
-    assertThat(node.isClosed()).isFalse();
-    return new ElasticNodeInfo(node, elasticDir, getHttpPort(node));
-  }
-
-  static void deleteAllIndexes(ElasticNodeInfo nodeInfo) {
-    nodeInfo.node.client().admin().indices().prepareDelete("_all").execute();
-  }
-
-  static class NodeInfo {
-    String httpAddress;
-  }
-
-  static class Info {
-    Map<String, NodeInfo> nodes;
-  }
-
-  static void createAllIndexes(ElasticNodeInfo nodeInfo) {
-    Schema<ChangeData> changeSchema = ChangeSchemaDefinitions.INSTANCE.getLatest();
-    ChangeMapping openChangesMapping = new ChangeMapping(changeSchema);
-    ChangeMapping closedChangesMapping = new ChangeMapping(changeSchema);
-    openChangesMapping.closedChanges = null;
-    closedChangesMapping.openChanges = null;
-    nodeInfo
-        .node
-        .client()
-        .admin()
-        .indices()
-        .prepareCreate(String.format("%s%04d", CHANGES_PREFIX, changeSchema.getVersion()))
-        .addMapping(OPEN_CHANGES, gson.toJson(openChangesMapping))
-        .addMapping(CLOSED_CHANGES, gson.toJson(closedChangesMapping))
-        .execute()
-        .actionGet();
-
-    Schema<AccountState> accountSchema = AccountSchemaDefinitions.INSTANCE.getLatest();
-    AccountMapping accountMapping = new AccountMapping(accountSchema);
-    nodeInfo
-        .node
-        .client()
-        .admin()
-        .indices()
-        .prepareCreate(String.format("%s%04d", ACCOUNTS_PREFIX, accountSchema.getVersion()))
-        .addMapping(ElasticAccountIndex.ACCOUNTS, gson.toJson(accountMapping))
-        .execute()
-        .actionGet();
-
-    Schema<InternalGroup> groupSchema = GroupSchemaDefinitions.INSTANCE.getLatest();
-    GroupMapping groupMapping = new GroupMapping(groupSchema);
-    nodeInfo
-        .node
-        .client()
-        .admin()
-        .indices()
-        .prepareCreate(String.format("%s%04d", GROUPS_PREFIX, groupSchema.getVersion()))
-        .addMapping(ElasticGroupIndex.GROUPS, gson.toJson(groupMapping))
-        .execute()
-        .actionGet();
-  }
-
-  private static String getHttpPort(Node node) throws InterruptedException, ExecutionException {
-    String nodes =
-        node.client().admin().cluster().nodesInfo(new NodesInfoRequest("*")).get().toString();
-    Gson gson =
-        new GsonBuilder()
-            .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
-            .create();
-    Info info = gson.fromJson(nodes, Info.class);
-    if (info.nodes == null || info.nodes.size() != 1) {
-      throw new RuntimeException("Cannot extract local Elasticsearch http port");
-    }
-    Iterator<NodeInfo> values = info.nodes.values().iterator();
-    String httpAddress = values.next().httpAddress;
-    if (Strings.isNullOrEmpty(httpAddress)) {
-      throw new RuntimeException("Cannot extract local Elasticsearch http port");
-    }
-    if (httpAddress.indexOf(':') < 0) {
-      throw new RuntimeException("Seems that port is not included in Elasticsearch http_address");
-    }
-    return httpAddress.substring(httpAddress.indexOf(':') + 1, httpAddress.length());
-  }
-
-  private ElasticTestUtils() {
-    // hide default constructor
-  }
-}
diff --git a/gerrit-extension-api/BUILD b/gerrit-extension-api/BUILD
deleted file mode 100644
index 2c59108..0000000
--- a/gerrit-extension-api/BUILD
+++ /dev/null
@@ -1,75 +0,0 @@
-load("//lib:guava.bzl", "GUAVA_DOC_URL")
-load("//lib/jgit:jgit.bzl", "JGIT_DOC_URL")
-load("//tools/bzl:gwt.bzl", "gwt_module")
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-SRC = "src/main/java/com/google/gerrit/extensions/"
-
-SRCS = glob([SRC + "**/*.java"])
-
-EXT_API_SRCS = glob([SRC + "client/*.java"])
-
-gwt_module(
-    name = "client",
-    srcs = EXT_API_SRCS,
-    gwt_xml = SRC + "Extensions.gwt.xml",
-    visibility = ["//visibility:public"],
-)
-
-java_binary(
-    name = "extension-api",
-    main_class = "Dummy",
-    visibility = ["//visibility:public"],
-    runtime_deps = [":lib"],
-)
-
-java_library(
-    name = "lib",
-    visibility = ["//visibility:public"],
-    exports = [
-        ":api",
-        "//lib:guava",
-        "//lib:servlet-api-3_1",
-        "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-        "//lib/guice:guice-servlet",
-    ],
-)
-
-#TODO(davido): There is no provided_deps argument to java_library rule
-java_library(
-    name = "api",
-    srcs = glob([SRC + "**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-common:annotations",
-        "//lib:guava",
-        "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-    ],
-)
-
-junit_tests(
-    name = "api_tests",
-    srcs = glob(["src/test/java/**/*Test.java"]),
-    deps = [
-        ":api",
-        "//gerrit-test-util:test_util",
-        "//lib:truth",
-        "//lib/guice",
-    ],
-)
-
-load("//tools/bzl:javadoc.bzl", "java_doc")
-
-java_doc(
-    name = "extension-api-javadoc",
-    external_docs = [
-        JGIT_DOC_URL,
-        GUAVA_DOC_URL,
-    ],
-    libs = [":api"],
-    pkgs = ["com.google.gerrit.extensions"],
-    title = "Gerrit Review Extension API Documentation",
-    visibility = ["//visibility:public"],
-)
diff --git a/gerrit-extension-api/pom.xml b/gerrit-extension-api/pom.xml
deleted file mode 100644
index f3644dd..0000000
--- a/gerrit-extension-api/pom.xml
+++ /dev/null
@@ -1,92 +0,0 @@
-<project>
-  <modelVersion>4.0.0</modelVersion>
-  <groupId>com.google.gerrit</groupId>
-  <artifactId>gerrit-extension-api</artifactId>
-  <version>2.15-rc2</version>
-  <packaging>jar</packaging>
-  <name>Gerrit Code Review - Extension API</name>
-  <description>API for Gerrit Extensions</description>
-  <url>https://www.gerritcodereview.com/</url>
-
-  <licenses>
-    <license>
-      <name>The Apache Software License, Version 2.0</name>
-      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
-      <distribution>repo</distribution>
-    </license>
-  </licenses>
-
-  <scm>
-    <url>https://gerrit.googlesource.com/gerrit</url>
-    <connection>https://gerrit.googlesource.com/gerrit</connection>
-  </scm>
-
-  <developers>
-    <developer>
-      <name>Alice Kober-Sotzek</name>
-    </developer>
-    <developer>
-      <name>Andrew Bonventre</name>
-    </developer>
-    <developer>
-      <name>Becky Siegel</name>
-    </developer>
-    <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
-      <name>David Ostrovsky</name>
-    </developer>
-    <developer>
-      <name>David Pursehouse</name>
-    </developer>
-    <developer>
-      <name>Edwin Kempin</name>
-    </developer>
-    <developer>
-      <name>Hugo Arès</name>
-    </developer>
-    <developer>
-      <name>Kasper Nilsson</name>
-    </developer>
-    <developer>
-      <name>Logan Hanks</name>
-    </developer>
-    <developer>
-      <name>Luca Milanesio</name>
-    </developer>
-    <developer>
-      <name>Martin Fick</name>
-    </developer>
-    <developer>
-      <name>Patrick Hiesel</name>
-    </developer>
-    <developer>
-      <name>Saša Živkov</name>
-    </developer>
-    <developer>
-      <name>Shawn Pearce</name>
-    </developer>
-    <developer>
-      <name>Viktar Donich</name>
-    </developer>
-    <developer>
-      <name>Wyatt Allen</name>
-    </developer>
-  </developers>
-
-  <mailingLists>
-    <mailingList>
-      <name>Repo and Gerrit Discussion</name>
-      <post>repo-discuss@googlegroups.com</post>
-      <subscribe>https://groups.google.com/forum/#!forum/repo-discuss</subscribe>
-      <unsubscribe>https://groups.google.com/forum/#!forum/repo-discuss</unsubscribe>
-      <archive>https://groups.google.com/forum/#!forum/repo-discuss</archive>
-    </mailingList>
-  </mailingLists>
-
-  <issueManagement>
-    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
-    <system>Gerrit Issue Tracker</system>
-  </issueManagement>
-</project>
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java
deleted file mode 100644
index e92d229..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java
+++ /dev/null
@@ -1,251 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.api.accounts;
-
-import com.google.gerrit.extensions.client.ListAccountsOption;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import java.util.Arrays;
-import java.util.EnumSet;
-import java.util.List;
-
-public interface Accounts {
-  /**
-   * Look up an account by ID.
-   *
-   * <p><strong>Note:</strong> This method eagerly reads the account. Methods that mutate the
-   * account do not necessarily re-read the account. Therefore, calling a getter method on an
-   * instance after calling a mutation method on that same instance is not guaranteed to reflect the
-   * mutation. It is not recommended to store references to {@code AccountApi} instances.
-   *
-   * @param id any identifier supported by the REST API, including numeric ID, email, or username.
-   * @return API for accessing the account.
-   * @throws RestApiException if an error occurred.
-   */
-  AccountApi id(String id) throws RestApiException;
-
-  /** @see #id(String) */
-  AccountApi id(int id) throws RestApiException;
-
-  /**
-   * Look up the account of the current in-scope user.
-   *
-   * @see #id(String)
-   */
-  AccountApi self() throws RestApiException;
-
-  /** Create a new account with the given username and default options. */
-  AccountApi create(String username) throws RestApiException;
-
-  /** Create a new account. */
-  AccountApi create(AccountInput input) throws RestApiException;
-
-  /**
-   * Suggest users for a given query.
-   *
-   * <p>Example code: {@code suggestAccounts().withQuery("Reviewer").withLimit(5).get()}
-   *
-   * @return API for setting parameters and getting result.
-   */
-  SuggestAccountsRequest suggestAccounts() throws RestApiException;
-
-  /**
-   * Suggest users for a given query.
-   *
-   * <p>Shortcut API for {@code suggestAccounts().withQuery(String)}.
-   *
-   * @see #suggestAccounts()
-   */
-  SuggestAccountsRequest suggestAccounts(String query) throws RestApiException;
-
-  /**
-   * Query users.
-   *
-   * <p>Example code: {@code query().withQuery("name:John email:example.com").withLimit(5).get()}
-   *
-   * @return API for setting parameters and getting result.
-   */
-  QueryRequest query() throws RestApiException;
-
-  /**
-   * Query users.
-   *
-   * <p>Shortcut API for {@code query().withQuery(String)}.
-   *
-   * @see #query()
-   */
-  QueryRequest query(String query) throws RestApiException;
-
-  /**
-   * API for setting parameters and getting result. Used for {@code suggestAccounts()}.
-   *
-   * @see #suggestAccounts()
-   */
-  abstract class SuggestAccountsRequest {
-    private String query;
-    private int limit;
-
-    /** Execute query and return a list of accounts. */
-    public abstract List<AccountInfo> get() throws RestApiException;
-
-    /**
-     * Set query.
-     *
-     * @param query needs to be in human-readable form.
-     */
-    public SuggestAccountsRequest withQuery(String query) {
-      this.query = query;
-      return this;
-    }
-
-    /**
-     * Set limit for returned list of accounts. Optional; server-default is used when not provided.
-     */
-    public SuggestAccountsRequest withLimit(int limit) {
-      this.limit = limit;
-      return this;
-    }
-
-    public String getQuery() {
-      return query;
-    }
-
-    public int getLimit() {
-      return limit;
-    }
-  }
-
-  /**
-   * API for setting parameters and getting result. Used for {@code query()}.
-   *
-   * @see #query()
-   */
-  abstract class QueryRequest {
-    private String query;
-    private int limit;
-    private int start;
-    private EnumSet<ListAccountsOption> options = EnumSet.noneOf(ListAccountsOption.class);
-
-    /** Execute query and return a list of accounts. */
-    public abstract List<AccountInfo> get() throws RestApiException;
-
-    /**
-     * Set query.
-     *
-     * @param query needs to be in human-readable form.
-     */
-    public QueryRequest withQuery(String query) {
-      this.query = query;
-      return this;
-    }
-
-    /**
-     * Set limit for returned list of accounts. Optional; server-default is used when not provided.
-     */
-    public QueryRequest withLimit(int limit) {
-      this.limit = limit;
-      return this;
-    }
-
-    /** Set number of accounts to skip. Optional; no accounts are skipped when not provided. */
-    public QueryRequest withStart(int start) {
-      this.start = start;
-      return this;
-    }
-
-    public QueryRequest withOption(ListAccountsOption options) {
-      this.options.add(options);
-      return this;
-    }
-
-    public QueryRequest withOptions(ListAccountsOption... options) {
-      this.options.addAll(Arrays.asList(options));
-      return this;
-    }
-
-    public QueryRequest withOptions(EnumSet<ListAccountsOption> options) {
-      this.options = options;
-      return this;
-    }
-
-    public String getQuery() {
-      return query;
-    }
-
-    public int getLimit() {
-      return limit;
-    }
-
-    public int getStart() {
-      return start;
-    }
-
-    public EnumSet<ListAccountsOption> getOptions() {
-      return options;
-    }
-  }
-
-  /**
-   * A default implementation which allows source compatibility when adding new methods to the
-   * interface.
-   */
-  class NotImplemented implements Accounts {
-    @Override
-    public AccountApi id(String id) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public AccountApi id(int id) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public AccountApi self() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public AccountApi create(String username) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public AccountApi create(AccountInput input) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public SuggestAccountsRequest suggestAccounts() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public SuggestAccountsRequest suggestAccounts(String query) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public QueryRequest query() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public QueryRequest query(String query) throws RestApiException {
-      throw new NotImplementedException();
-    }
-  }
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
deleted file mode 100644
index 2c945be..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ /dev/null
@@ -1,187 +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.extensions.api.changes;
-
-import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-
-import com.google.gerrit.extensions.client.Comment;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.FixSuggestionInfo;
-import com.google.gerrit.extensions.restapi.DefaultInput;
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-/** Input passed to {@code POST /changes/[id]/revisions/[id]/review}. */
-public class ReviewInput {
-  @DefaultInput public String message;
-
-  public String tag;
-
-  public Map<String, Short> labels;
-  public Map<String, List<CommentInput>> comments;
-  public Map<String, List<RobotCommentInput>> robotComments;
-
-  /**
-   * If true require all labels to be within the user's permitted ranges based on access controls,
-   * attempting to use a label not granted to the user will fail the entire modify operation early.
-   * If false the operation will execute anyway, but the proposed labels given by the user will be
-   * modified to be the "best" value allowed by the access controls, or ignored if the label does
-   * not exist.
-   */
-  public boolean strictLabels = true;
-
-  /**
-   * How to process draft comments already in the database that were not also described in this
-   * input request.
-   *
-   * <p>Defaults to DELETE, unless {@link #onBehalfOf} is set, in which case it defaults to KEEP and
-   * any other value is disallowed.
-   */
-  public DraftHandling drafts;
-
-  /** Who to send email notifications to after review is stored. */
-  public NotifyHandling notify;
-
-  public Map<RecipientType, NotifyInfo> notifyDetails;
-
-  /** If true check to make sure that the comments being posted aren't already present. */
-  public boolean omitDuplicateComments;
-
-  /**
-   * Account ID, name, email address or username of another user. The review will be posted/updated
-   * on behalf of this named user instead of the caller. Caller must have the labelAs-$NAME
-   * permission granted for each label that appears in {@link #labels}. This is in addition to the
-   * named user also needing to have permission to use the labels.
-   *
-   * <p>{@link #strictLabels} impacts how labels is processed for the named user, not the caller.
-   */
-  public String onBehalfOf;
-
-  /** Reviewers that should be added to this change. */
-  public List<AddReviewerInput> reviewers;
-
-  /**
-   * If true mark the change as work in progress. It is an error for both {@link #workInProgress}
-   * and {@link #ready} to be true.
-   */
-  public boolean workInProgress;
-
-  /**
-   * If true mark the change as ready for review. It is an error for both {@link #workInProgress}
-   * and {@link #ready} to be true.
-   */
-  public boolean ready;
-
-  public enum DraftHandling {
-    /** Delete pending drafts on this revision only. */
-    DELETE,
-
-    /** Publish pending drafts on this revision only. */
-    PUBLISH,
-
-    /** Leave pending drafts alone. */
-    KEEP,
-
-    /** Publish pending drafts on all revisions. */
-    PUBLISH_ALL_REVISIONS
-  }
-
-  public static class CommentInput extends Comment {}
-
-  public static class RobotCommentInput extends CommentInput {
-    public String robotId;
-    public String robotRunId;
-    public String url;
-    public Map<String, String> properties;
-    public List<FixSuggestionInfo> fixSuggestions;
-  }
-
-  public ReviewInput message(String msg) {
-    message = msg != null && !msg.isEmpty() ? msg : null;
-    return this;
-  }
-
-  public ReviewInput label(String name, short value) {
-    if (name == null || name.isEmpty()) {
-      throw new IllegalArgumentException();
-    }
-    if (labels == null) {
-      labels = new LinkedHashMap<>(4);
-    }
-    labels.put(name, value);
-    return this;
-  }
-
-  public ReviewInput label(String name, int value) {
-    if (value < Short.MIN_VALUE || value > Short.MAX_VALUE) {
-      throw new IllegalArgumentException();
-    }
-    return label(name, (short) value);
-  }
-
-  public ReviewInput label(String name) {
-    return label(name, (short) 1);
-  }
-
-  public ReviewInput reviewer(String reviewer) {
-    return reviewer(reviewer, REVIEWER, false);
-  }
-
-  public ReviewInput reviewer(String reviewer, ReviewerState state, boolean confirmed) {
-    AddReviewerInput input = new AddReviewerInput();
-    input.reviewer = reviewer;
-    input.state = state;
-    input.confirmed = confirmed;
-    if (reviewers == null) {
-      reviewers = new ArrayList<>();
-    }
-    reviewers.add(input);
-    return this;
-  }
-
-  public ReviewInput setWorkInProgress(boolean workInProgress) {
-    this.workInProgress = workInProgress;
-    ready = !workInProgress;
-    return this;
-  }
-
-  public ReviewInput setReady(boolean ready) {
-    this.ready = ready;
-    workInProgress = !ready;
-    return this;
-  }
-
-  public static ReviewInput recommend() {
-    return new ReviewInput().label("Code-Review", 1);
-  }
-
-  public static ReviewInput dislike() {
-    return new ReviewInput().label("Code-Review", -1);
-  }
-
-  public static ReviewInput noScore() {
-    return new ReviewInput().label("Code-Review", 0);
-  }
-
-  public static ReviewInput approve() {
-    return new ReviewInput().label("Code-Review", 2);
-  }
-
-  public static ReviewInput reject() {
-    return new ReviewInput().label("Code-Review", -2);
-  }
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
deleted file mode 100644
index e44eb28..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.api.config;
-
-import java.util.List;
-import java.util.Objects;
-
-public class ConsistencyCheckInfo {
-  public CheckAccountsResultInfo checkAccountsResult;
-  public CheckAccountExternalIdsResultInfo checkAccountExternalIdsResult;
-
-  public static class CheckAccountsResultInfo {
-    public List<ConsistencyProblemInfo> problems;
-
-    public CheckAccountsResultInfo(List<ConsistencyProblemInfo> problems) {
-      this.problems = problems;
-    }
-  }
-
-  public static class CheckAccountExternalIdsResultInfo {
-    public List<ConsistencyProblemInfo> problems;
-
-    public CheckAccountExternalIdsResultInfo(List<ConsistencyProblemInfo> problems) {
-      this.problems = problems;
-    }
-  }
-
-  public static class ConsistencyProblemInfo {
-    public enum Status {
-      ERROR,
-      WARNING,
-    }
-
-    public final Status status;
-    public final String message;
-
-    public ConsistencyProblemInfo(Status status, String message) {
-      this.status = status;
-      this.message = message;
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      if (o instanceof ConsistencyProblemInfo) {
-        ConsistencyProblemInfo other = ((ConsistencyProblemInfo) o);
-        return Objects.equals(status, other.status) && Objects.equals(message, other.message);
-      }
-      return false;
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hash(status, message);
-    }
-
-    @Override
-    public String toString() {
-      return status.name() + ": " + message;
-    }
-  }
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java
deleted file mode 100644
index f3d927e..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.api.config;
-
-public class ConsistencyCheckInput {
-  public CheckAccountsInput checkAccounts;
-  public CheckAccountExternalIdsInput checkAccountExternalIds;
-
-  public static class CheckAccountsInput {}
-
-  public static class CheckAccountExternalIdsInput {}
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/Groups.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/Groups.java
deleted file mode 100644
index 567d9ba..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/Groups.java
+++ /dev/null
@@ -1,313 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.api.groups;
-
-import com.google.gerrit.extensions.client.ListGroupsOption;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.List;
-import java.util.Map;
-
-public interface Groups {
-  /**
-   * Look up a group by ID.
-   *
-   * <p><strong>Note:</strong> This method eagerly reads the group. Methods that mutate the group do
-   * not necessarily re-read the group. Therefore, calling a getter method on an instance after
-   * calling a mutation method on that same instance is not guaranteed to reflect the mutation. It
-   * is not recommended to store references to {@code groupApi} instances.
-   *
-   * @param id any identifier supported by the REST API, including group name or UUID.
-   * @return API for accessing the group.
-   * @throws RestApiException if an error occurred.
-   */
-  GroupApi id(String id) throws RestApiException;
-
-  /** Create a new group with the given name and default options. */
-  GroupApi create(String name) throws RestApiException;
-
-  /** Create a new group. */
-  GroupApi create(GroupInput input) throws RestApiException;
-
-  /** @return new request for listing groups. */
-  ListRequest list();
-
-  /**
-   * Query groups.
-   *
-   * <p>Example code: {@code query().withQuery("inname:test").withLimit(10).get()}
-   *
-   * @return API for setting parameters and getting result.
-   */
-  QueryRequest query();
-
-  /**
-   * Query groups.
-   *
-   * <p>Shortcut API for {@code query().withQuery(String)}.
-   *
-   * @see #query()
-   */
-  QueryRequest query(String query);
-
-  abstract class ListRequest {
-    private final EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
-    private final List<String> projects = new ArrayList<>();
-    private final List<String> groups = new ArrayList<>();
-
-    private boolean visibleToAll;
-    private String user;
-    private boolean owned;
-    private int limit;
-    private int start;
-    private String substring;
-    private String suggest;
-    private String regex;
-
-    public List<GroupInfo> get() throws RestApiException {
-      Map<String, GroupInfo> map = getAsMap();
-      List<GroupInfo> result = new ArrayList<>(map.size());
-      for (Map.Entry<String, GroupInfo> e : map.entrySet()) {
-        // ListGroups "helpfully" nulls out names when converting to a map.
-        e.getValue().name = e.getKey();
-        result.add(e.getValue());
-      }
-      return Collections.unmodifiableList(result);
-    }
-
-    public abstract Map<String, GroupInfo> getAsMap() throws RestApiException;
-
-    public ListRequest addOption(ListGroupsOption option) {
-      options.add(option);
-      return this;
-    }
-
-    public ListRequest addOptions(ListGroupsOption... options) {
-      return addOptions(Arrays.asList(options));
-    }
-
-    public ListRequest addOptions(Iterable<ListGroupsOption> options) {
-      for (ListGroupsOption option : options) {
-        this.options.add(option);
-      }
-      return this;
-    }
-
-    public ListRequest withProject(String project) {
-      projects.add(project);
-      return this;
-    }
-
-    public ListRequest addGroup(String uuid) {
-      groups.add(uuid);
-      return this;
-    }
-
-    public ListRequest withVisibleToAll(boolean visible) {
-      visibleToAll = visible;
-      return this;
-    }
-
-    public ListRequest withUser(String user) {
-      this.user = user;
-      return this;
-    }
-
-    public ListRequest withOwned(boolean owned) {
-      this.owned = owned;
-      return this;
-    }
-
-    public ListRequest withLimit(int limit) {
-      this.limit = limit;
-      return this;
-    }
-
-    public ListRequest withStart(int start) {
-      this.start = start;
-      return this;
-    }
-
-    public ListRequest withSubstring(String substring) {
-      this.substring = substring;
-      return this;
-    }
-
-    public ListRequest withRegex(String regex) {
-      this.regex = regex;
-      return this;
-    }
-
-    public ListRequest withSuggest(String suggest) {
-      this.suggest = suggest;
-      return this;
-    }
-
-    public EnumSet<ListGroupsOption> getOptions() {
-      return options;
-    }
-
-    public List<String> getProjects() {
-      return Collections.unmodifiableList(projects);
-    }
-
-    public List<String> getGroups() {
-      return Collections.unmodifiableList(groups);
-    }
-
-    public boolean getVisibleToAll() {
-      return visibleToAll;
-    }
-
-    public String getUser() {
-      return user;
-    }
-
-    public boolean getOwned() {
-      return owned;
-    }
-
-    public int getLimit() {
-      return limit;
-    }
-
-    public int getStart() {
-      return start;
-    }
-
-    public String getSubstring() {
-      return substring;
-    }
-
-    public String getRegex() {
-      return regex;
-    }
-
-    public String getSuggest() {
-      return suggest;
-    }
-  }
-
-  /**
-   * API for setting parameters and getting result. Used for {@code query()}.
-   *
-   * @see #query()
-   */
-  abstract class QueryRequest {
-    private String query;
-    private int limit;
-    private int start;
-    private EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
-
-    /** Execute query and returns the matched groups as list. */
-    public abstract List<GroupInfo> 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 groups. Optional; server-default is used when not provided.
-     */
-    public QueryRequest withLimit(int limit) {
-      this.limit = limit;
-      return this;
-    }
-
-    /** Set number of groups to skip. Optional; no groups are skipped when not provided. */
-    public QueryRequest withStart(int start) {
-      this.start = start;
-      return this;
-    }
-
-    public QueryRequest withOption(ListGroupsOption options) {
-      this.options.add(options);
-      return this;
-    }
-
-    public QueryRequest withOptions(ListGroupsOption... options) {
-      this.options.addAll(Arrays.asList(options));
-      return this;
-    }
-
-    public QueryRequest withOptions(EnumSet<ListGroupsOption> options) {
-      this.options = options;
-      return this;
-    }
-
-    public String getQuery() {
-      return query;
-    }
-
-    public int getLimit() {
-      return limit;
-    }
-
-    public int getStart() {
-      return start;
-    }
-
-    public EnumSet<ListGroupsOption> getOptions() {
-      return options;
-    }
-  }
-
-  /**
-   * A default implementation which allows source compatibility when adding new methods to the
-   * interface.
-   */
-  class NotImplemented implements Groups {
-    @Override
-    public GroupApi id(String id) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public GroupApi create(String name) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public GroupApi create(GroupInput input) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ListRequest list() {
-      throw new NotImplementedException();
-    }
-
-    @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/api/projects/ConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
deleted file mode 100644
index 443e2064..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.api.projects;
-
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ProjectState;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ActionInfo;
-import java.util.List;
-import java.util.Map;
-
-public class ConfigInfo {
-  public String description;
-  public InheritedBooleanInfo useContributorAgreements;
-  public InheritedBooleanInfo useContentMerge;
-  public InheritedBooleanInfo useSignedOffBy;
-  public InheritedBooleanInfo createNewChangeForAllNotInTarget;
-  public InheritedBooleanInfo requireChangeId;
-  public InheritedBooleanInfo enableSignedPush;
-  public InheritedBooleanInfo requireSignedPush;
-  public InheritedBooleanInfo rejectImplicitMerges;
-  public InheritedBooleanInfo privateByDefault;
-  public InheritedBooleanInfo enableReviewerByEmail;
-  public InheritedBooleanInfo matchAuthorToCommitterDate;
-  public MaxObjectSizeLimitInfo maxObjectSizeLimit;
-  public SubmitType submitType;
-  public ProjectState state;
-  public Map<String, Map<String, ConfigParameterInfo>> pluginConfig;
-  public Map<String, ActionInfo> actions;
-
-  public Map<String, CommentLinkInfo> commentlinks;
-  public ThemeInfo theme;
-
-  public Map<String, List<String>> extensionPanelNames;
-
-  public static class InheritedBooleanInfo {
-    public Boolean value;
-    public InheritableBoolean configuredValue;
-    public Boolean inheritedValue;
-  }
-
-  public static class MaxObjectSizeLimitInfo {
-    public String value;
-    public String configuredValue;
-    public String inheritedValue;
-  }
-
-  public static class ConfigParameterInfo {
-    public String displayName;
-    public String description;
-    public String warning;
-    public ProjectConfigEntryType type;
-    public String value;
-    public Boolean editable;
-    public Boolean inheritable;
-    public String configuredValue;
-    public String inheritedValue;
-    public List<String> permittedValues;
-    public List<String> values;
-  }
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
deleted file mode 100644
index 0c1cec4..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.api.projects;
-
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ProjectState;
-import com.google.gerrit.extensions.client.SubmitType;
-import java.util.Map;
-
-public class ConfigInput {
-  public String description;
-  public InheritableBoolean useContributorAgreements;
-  public InheritableBoolean useContentMerge;
-  public InheritableBoolean useSignedOffBy;
-  public InheritableBoolean createNewChangeForAllNotInTarget;
-  public InheritableBoolean requireChangeId;
-  public InheritableBoolean enableSignedPush;
-  public InheritableBoolean requireSignedPush;
-  public InheritableBoolean rejectImplicitMerges;
-  public InheritableBoolean privateByDefault;
-  public InheritableBoolean enableReviewerByEmail;
-  public InheritableBoolean matchAuthorToCommitterDate;
-  public String maxObjectSizeLimit;
-  public SubmitType submitType;
-  public ProjectState state;
-  public Map<String, Map<String, ConfigValue>> pluginConfigValues;
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
deleted file mode 100644
index 322b076..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.api.projects;
-
-import com.google.gerrit.extensions.restapi.DefaultInput;
-
-public class DescriptionInput {
-  @DefaultInput public String description;
-  public String commitMessage;
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
deleted file mode 100644
index 8320ef7..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ /dev/null
@@ -1,308 +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.extensions.api.projects;
-
-import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
-import com.google.gerrit.extensions.api.access.ProjectAccessInput;
-import com.google.gerrit.extensions.api.config.AccessCheckInfo;
-import com.google.gerrit.extensions.api.config.AccessCheckInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import java.util.List;
-
-public interface ProjectApi {
-  ProjectApi create() throws RestApiException;
-
-  ProjectApi create(ProjectInput in) throws RestApiException;
-
-  ProjectInfo get() throws RestApiException;
-
-  String description() throws RestApiException;
-
-  void description(DescriptionInput in) throws RestApiException;
-
-  ProjectAccessInfo access() throws RestApiException;
-
-  ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException;
-
-  ChangeInfo accessChange(ProjectAccessInput p) throws RestApiException;
-
-  AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException;
-
-  ConfigInfo config() throws RestApiException;
-
-  ConfigInfo config(ConfigInput in) throws RestApiException;
-
-  ListRefsRequest<BranchInfo> branches();
-
-  ListRefsRequest<TagInfo> tags();
-
-  void deleteBranches(DeleteBranchesInput in) throws RestApiException;
-
-  void deleteTags(DeleteTagsInput in) throws RestApiException;
-
-  abstract class ListRefsRequest<T extends RefInfo> {
-    protected int limit;
-    protected int start;
-    protected String substring;
-    protected String regex;
-
-    public abstract List<T> get() throws RestApiException;
-
-    public ListRefsRequest<T> withLimit(int limit) {
-      this.limit = limit;
-      return this;
-    }
-
-    public ListRefsRequest<T> withStart(int start) {
-      this.start = start;
-      return this;
-    }
-
-    public ListRefsRequest<T> withSubstring(String substring) {
-      this.substring = substring;
-      return this;
-    }
-
-    public ListRefsRequest<T> withRegex(String regex) {
-      this.regex = regex;
-      return this;
-    }
-
-    public int getLimit() {
-      return limit;
-    }
-
-    public int getStart() {
-      return start;
-    }
-
-    public String getSubstring() {
-      return substring;
-    }
-
-    public String getRegex() {
-      return regex;
-    }
-  }
-
-  List<ProjectInfo> children() throws RestApiException;
-
-  List<ProjectInfo> children(boolean recursive) throws RestApiException;
-
-  ChildProjectApi child(String name) throws RestApiException;
-
-  /**
-   * Look up a branch by refname.
-   *
-   * <p><strong>Note:</strong> This method eagerly reads the branch. Methods that mutate the branch
-   * do not necessarily re-read the branch. Therefore, calling a getter method on an instance after
-   * calling a mutation method on that same instance is not guaranteed to reflect the mutation. It
-   * is not recommended to store references to {@code BranchApi} instances.
-   *
-   * @param ref branch name, with or without "refs/heads/" prefix.
-   * @throws RestApiException if a problem occurred reading the project.
-   * @return API for accessing the branch.
-   */
-  BranchApi branch(String ref) throws RestApiException;
-
-  /**
-   * Look up a tag by refname.
-   *
-   * <p>
-   *
-   * @param ref tag name, with or without "refs/tags/" prefix.
-   * @throws RestApiException if a problem occurred reading the project.
-   * @return API for accessing the tag.
-   */
-  TagApi tag(String ref) throws RestApiException;
-
-  /**
-   * Lookup a commit by its {@code ObjectId} string.
-   *
-   * @param commit the {@code ObjectId} string.
-   * @return API for accessing the commit.
-   */
-  CommitApi commit(String commit) throws RestApiException;
-
-  /**
-   * Lookup a dashboard by its name.
-   *
-   * @param name the name.
-   * @return API for accessing the dashboard.
-   */
-  DashboardApi dashboard(String name) throws RestApiException;
-
-  /**
-   * Get the project's default dashboard.
-   *
-   * @return API for accessing the dashboard.
-   */
-  DashboardApi defaultDashboard() throws RestApiException;
-
-  /**
-   * Set the project's default dashboard.
-   *
-   * @param name the dashboard to set as default.
-   */
-  void defaultDashboard(String name) throws RestApiException;
-
-  /** Remove the project's default dashboard. */
-  void removeDefaultDashboard() throws RestApiException;
-
-  abstract class ListDashboardsRequest {
-    public abstract List<DashboardInfo> get() throws RestApiException;
-  }
-
-  ListDashboardsRequest dashboards() throws RestApiException;
-
-  /**
-   * A default implementation which allows source compatibility when adding new methods to the
-   * interface.
-   */
-  class NotImplemented implements ProjectApi {
-    @Override
-    public ProjectApi create() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ProjectApi create(ProjectInput in) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ProjectInfo get() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public String description() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ProjectAccessInfo access() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ChangeInfo accessChange(ProjectAccessInput input) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ConfigInfo config() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ConfigInfo config(ConfigInput in) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public void description(DescriptionInput in) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ListRefsRequest<BranchInfo> branches() {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ListRefsRequest<TagInfo> tags() {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public List<ProjectInfo> children() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public List<ProjectInfo> children(boolean recursive) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ChildProjectApi child(String name) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public BranchApi branch(String ref) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public TagApi tag(String ref) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public void deleteBranches(DeleteBranchesInput in) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public void deleteTags(DeleteTagsInput in) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public CommitApi commit(String commit) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public DashboardApi dashboard(String name) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public DashboardApi defaultDashboard() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ListDashboardsRequest dashboards() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public void defaultDashboard(String name) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public void removeDefaultDashboard() throws RestApiException {
-      throw new NotImplementedException();
-    }
-  }
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
deleted file mode 100644
index 612c49c..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.api.projects;
-
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.SubmitType;
-import java.util.List;
-import java.util.Map;
-
-public class ProjectInput {
-  public String name;
-  public String parent;
-  public String description;
-  public boolean permissionsOnly;
-  public boolean createEmptyCommit;
-  public SubmitType submitType;
-  public List<String> branches;
-  public List<String> owners;
-  public InheritableBoolean useContributorAgreements;
-  public InheritableBoolean useSignedOffBy;
-  public InheritableBoolean useContentMerge;
-  public InheritableBoolean requireChangeId;
-  public InheritableBoolean createNewChangeForAllNotInTarget;
-  public String maxObjectSizeLimit;
-  public Map<String, Map<String, ConfigValue>> pluginConfigValues;
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java
deleted file mode 100644
index e4a659c..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java
+++ /dev/null
@@ -1,199 +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.extensions.api.projects;
-
-import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.SortedMap;
-
-public interface Projects {
-  /**
-   * Look up a project by name.
-   *
-   * <p><strong>Note:</strong> This method eagerly reads the project. Methods that mutate the
-   * project do not necessarily re-read the project. Therefore, calling a getter method on an
-   * instance after calling a mutation method on that same instance is not guaranteed to reflect the
-   * mutation. It is not recommended to store references to {@code ProjectApi} instances.
-   *
-   * @param name project name.
-   * @return API for accessing the project.
-   * @throws RestApiException if an error occurred.
-   */
-  ProjectApi name(String name) throws RestApiException;
-
-  /**
-   * Create a project using the default configuration.
-   *
-   * @param name project name.
-   * @return API for accessing the newly-created project.
-   * @throws RestApiException if an error occurred.
-   */
-  ProjectApi create(String name) throws RestApiException;
-
-  /**
-   * Create a project.
-   *
-   * @param in project creation input; name must be set.
-   * @return API for accessing the newly-created project.
-   * @throws RestApiException if an error occurred.
-   */
-  ProjectApi create(ProjectInput in) throws RestApiException;
-
-  ListRequest list();
-
-  abstract class ListRequest {
-    public enum FilterType {
-      CODE,
-      PARENT_CANDIDATES,
-      PERMISSIONS,
-      ALL
-    }
-
-    private final List<String> branches = new ArrayList<>();
-    private boolean description;
-    private String prefix;
-    private String substring;
-    private String regex;
-    private int limit;
-    private int start;
-    private boolean showTree;
-    private FilterType type = FilterType.ALL;
-
-    public List<ProjectInfo> get() throws RestApiException {
-      Map<String, ProjectInfo> map = getAsMap();
-      List<ProjectInfo> result = new ArrayList<>(map.size());
-      for (Map.Entry<String, ProjectInfo> e : map.entrySet()) {
-        // ListProjects "helpfully" nulls out names when converting to a map.
-        e.getValue().name = e.getKey();
-        result.add(e.getValue());
-      }
-      return Collections.unmodifiableList(result);
-    }
-
-    public abstract SortedMap<String, ProjectInfo> getAsMap() throws RestApiException;
-
-    public ListRequest withDescription(boolean description) {
-      this.description = description;
-      return this;
-    }
-
-    public ListRequest withPrefix(String prefix) {
-      this.prefix = prefix;
-      return this;
-    }
-
-    public ListRequest withSubstring(String substring) {
-      this.substring = substring;
-      return this;
-    }
-
-    public ListRequest withRegex(String regex) {
-      this.regex = regex;
-      return this;
-    }
-
-    public ListRequest withLimit(int limit) {
-      this.limit = limit;
-      return this;
-    }
-
-    public ListRequest withStart(int start) {
-      this.start = start;
-      return this;
-    }
-
-    public ListRequest addShowBranch(String branch) {
-      branches.add(branch);
-      return this;
-    }
-
-    public ListRequest withTree(boolean show) {
-      showTree = show;
-      return this;
-    }
-
-    public ListRequest withType(FilterType type) {
-      this.type = type != null ? type : FilterType.ALL;
-      return this;
-    }
-
-    public boolean getDescription() {
-      return description;
-    }
-
-    public String getPrefix() {
-      return prefix;
-    }
-
-    public String getSubstring() {
-      return substring;
-    }
-
-    public String getRegex() {
-      return regex;
-    }
-
-    public int getLimit() {
-      return limit;
-    }
-
-    public int getStart() {
-      return start;
-    }
-
-    public List<String> getBranches() {
-      return Collections.unmodifiableList(branches);
-    }
-
-    public boolean getShowTree() {
-      return showTree;
-    }
-
-    public FilterType getFilterType() {
-      return type;
-    }
-  }
-
-  /**
-   * A default implementation which allows source compatibility when adding new methods to the
-   * interface.
-   */
-  class NotImplemented implements Projects {
-    @Override
-    public ProjectApi name(String name) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ProjectApi create(ProjectInput in) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ProjectApi create(String name) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public ListRequest list() {
-      throw new NotImplementedException();
-    }
-  }
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInfo.java
deleted file mode 100644
index 99fc6ec..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInfo.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.api.projects;
-
-import com.google.gerrit.extensions.common.GitPerson;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import java.util.List;
-
-public class TagInfo extends RefInfo {
-  public String object;
-  public String message;
-  public GitPerson tagger;
-  public List<WebLinkInfo> webLinks;
-
-  public TagInfo(String ref, String revision, Boolean canDelete, List<WebLinkInfo> webLinks) {
-    this.ref = ref;
-    this.revision = revision;
-    this.canDelete = canDelete;
-    this.webLinks = webLinks;
-  }
-
-  public TagInfo(
-      String ref,
-      String revision,
-      String object,
-      String message,
-      GitPerson tagger,
-      Boolean canDelete,
-      List<WebLinkInfo> webLinks) {
-    this(ref, revision, canDelete, webLinks);
-    this.object = object;
-    this.message = message;
-    this.tagger = tagger;
-    this.webLinks = webLinks;
-  }
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectState.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectState.java
deleted file mode 100644
index e5bc194..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectState.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.client;
-
-public enum ProjectState {
-  ACTIVE(true, true),
-  READ_ONLY(true, false),
-  HIDDEN(false, false);
-
-  private final boolean permitsRead;
-  private final boolean permitsWrite;
-
-  ProjectState(boolean permitsRead, boolean permitsWrite) {
-    this.permitsRead = permitsRead;
-    this.permitsWrite = permitsWrite;
-  }
-
-  public boolean permitsRead() {
-    return permitsRead;
-  }
-
-  public boolean permitsWrite() {
-    return permitsWrite;
-  }
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java
deleted file mode 100644
index b52e89a..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.client;
-
-public enum SubmitType {
-  FAST_FORWARD_ONLY,
-  MERGE_IF_NECESSARY,
-  REBASE_IF_NECESSARY,
-  REBASE_ALWAYS,
-  MERGE_ALWAYS,
-  CHERRY_PICK
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
deleted file mode 100644
index b710121..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-public class ChangeConfigInfo {
-  public Boolean allowBlame;
-  public Boolean showAssigneeInChangesTable;
-  public Boolean allowDrafts;
-  public int largeChange;
-  public String replyLabel;
-  public String replyTooltip;
-  public int updateDelay;
-  public Boolean submitWholeTopic;
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommitInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommitInfo.java
deleted file mode 100644
index a4e4071..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommitInfo.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import java.util.List;
-
-public class CommitInfo {
-  public String commit;
-  public List<CommitInfo> parents;
-  public GitPerson author;
-  public GitPerson committer;
-  public String subject;
-  public String message;
-  public List<WebLinkInfo> webLinks;
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GitPerson.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GitPerson.java
deleted file mode 100644
index 9853417..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GitPerson.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import java.sql.Timestamp;
-
-public class GitPerson {
-  public String name;
-  public String email;
-  public Timestamp date;
-  public int tz;
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergePatchSetInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
deleted file mode 100644
index 263b6c4..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-public class MergePatchSetInput {
-  public String subject;
-  public boolean inheritParent;
-  public MergeInput merge;
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java
deleted file mode 100644
index 4dd8f02..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import com.google.gerrit.extensions.webui.WebLink.Target;
-
-public class WebLinkInfo {
-  public String name;
-  public String imageUrl;
-  public String url;
-  public String target;
-
-  public WebLinkInfo(String name, String imageUrl, String url, String target) {
-    this.name = name;
-    this.imageUrl = imageUrl;
-    this.url = url;
-    this.target = target;
-  }
-
-  public WebLinkInfo(String name, String imageUrl, String url) {
-    this(name, imageUrl, url, Target.SELF);
-  }
-}
diff --git a/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/client/RangeTest.java b/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/client/RangeTest.java
deleted file mode 100644
index 9695933..0000000
--- a/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/client/RangeTest.java
+++ /dev/null
@@ -1,110 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.client;
-
-import static com.google.gerrit.extensions.client.RangeSubject.assertThat;
-
-import org.junit.Test;
-
-public class RangeTest {
-
-  @Test
-  public void rangeOverMultipleLinesWithSmallerEndCharacterIsValid() {
-    Comment.Range range = createRange(13, 31, 19, 10);
-    assertThat(range).isValid();
-  }
-
-  @Test
-  public void rangeInOneLineIsValid() {
-    Comment.Range range = createRange(13, 2, 13, 10);
-    assertThat(range).isValid();
-  }
-
-  @Test
-  public void startPositionEqualToEndPositionIsValidRange() {
-    Comment.Range range = createRange(13, 11, 13, 11);
-    assertThat(range).isValid();
-  }
-
-  @Test
-  public void negativeStartLineResultsInInvalidRange() {
-    Comment.Range range = createRange(-1, 2, 19, 10);
-    assertThat(range).isInvalid();
-  }
-
-  @Test
-  public void negativeEndLineResultsInInvalidRange() {
-    Comment.Range range = createRange(13, 2, -1, 10);
-    assertThat(range).isInvalid();
-  }
-
-  @Test
-  public void negativeStartCharacterResultsInInvalidRange() {
-    Comment.Range range = createRange(13, -1, 19, 10);
-    assertThat(range).isInvalid();
-  }
-
-  @Test
-  public void negativeEndCharacterResultsInInvalidRange() {
-    Comment.Range range = createRange(13, 2, 19, -1);
-    assertThat(range).isInvalid();
-  }
-
-  @Test
-  public void zeroStartLineResultsInInvalidRange() {
-    Comment.Range range = createRange(0, 2, 19, 10);
-    assertThat(range).isInvalid();
-  }
-
-  @Test
-  public void zeroEndLineResultsInInvalidRange() {
-    Comment.Range range = createRange(13, 2, 0, 10);
-    assertThat(range).isInvalid();
-  }
-
-  @Test
-  public void zeroStartCharacterResultsInValidRange() {
-    Comment.Range range = createRange(13, 0, 19, 10);
-    assertThat(range).isValid();
-  }
-
-  @Test
-  public void zeroEndCharacterResultsInValidRange() {
-    Comment.Range range = createRange(13, 31, 19, 0);
-    assertThat(range).isValid();
-  }
-
-  @Test
-  public void startLineGreaterThanEndLineResultsInInvalidRange() {
-    Comment.Range range = createRange(20, 2, 19, 10);
-    assertThat(range).isInvalid();
-  }
-
-  @Test
-  public void startCharGreaterThanEndCharForSameLineResultsInInvalidRange() {
-    Comment.Range range = createRange(13, 11, 13, 10);
-    assertThat(range).isInvalid();
-  }
-
-  private Comment.Range createRange(
-      int startLine, int startCharacter, int endLine, int endCharacter) {
-    Comment.Range range = new Comment.Range();
-    range.startLine = startLine;
-    range.startCharacter = startCharacter;
-    range.endLine = endLine;
-    range.endCharacter = endCharacter;
-    return range;
-  }
-}
diff --git a/gerrit-gpg/BUILD b/gerrit-gpg/BUILD
deleted file mode 100644
index 680071f..0000000
--- a/gerrit-gpg/BUILD
+++ /dev/null
@@ -1,59 +0,0 @@
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-DEPS = [
-    "//gerrit-common:server",
-    "//gerrit-extension-api:api",
-    "//gerrit-reviewdb:server",
-    "//gerrit-server:server",
-    "//lib:guava",
-    "//lib:gwtorm",
-    "//lib/guice:guice",
-    "//lib/guice:guice-assistedinject",
-    "//lib/guice:guice-servlet",
-    "//lib/jgit/org.eclipse.jgit:jgit",
-    "//lib/log:api",
-]
-
-java_library(
-    name = "gpg",
-    srcs = glob(["src/main/java/**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = DEPS + [
-        "//lib/bouncycastle:bcpg-neverlink",
-        "//lib/bouncycastle:bcprov-neverlink",
-    ],
-)
-
-TESTUTIL_SRCS = glob(["src/test/**/testutil/**/*.java"])
-
-java_library(
-    name = "testutil",
-    testonly = 1,
-    srcs = TESTUTIL_SRCS,
-    visibility = ["//visibility:public"],
-    deps = DEPS + [
-        "//lib/bouncycastle:bcpg-neverlink",
-        "//lib/bouncycastle:bcprov-neverlink",
-        ":gpg",
-    ],
-)
-
-junit_tests(
-    name = "gpg_tests",
-    srcs = glob(
-        ["src/test/java/**/*.java"],
-        exclude = TESTUTIL_SRCS,
-    ),
-    visibility = ["//visibility:public"],
-    deps = DEPS + [
-        ":gpg",
-        ":testutil",
-        "//gerrit-cache-h2:cache-h2",
-        "//gerrit-lucene:lucene",
-        "//gerrit-server:testutil",
-        "//lib:truth",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
-        "//lib/bouncycastle:bcpg",
-        "//lib/bouncycastle:bcprov",
-    ],
-)
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushModule.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushModule.java
deleted file mode 100644
index c32e1df..0000000
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushModule.java
+++ /dev/null
@@ -1,160 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.gpg;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.EnableSignedPush;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.ReceivePackInitializer;
-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;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Random;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.PreReceiveHook;
-import org.eclipse.jgit.transport.PreReceiveHookChain;
-import org.eclipse.jgit.transport.ReceivePack;
-import org.eclipse.jgit.transport.SignedPushConfig;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-class SignedPushModule extends AbstractModule {
-  private static final Logger log = LoggerFactory.getLogger(SignedPushModule.class);
-
-  @Override
-  protected void configure() {
-    if (!BouncyCastleUtil.havePGP()) {
-      throw new ProvisionException("Bouncy Castle PGP not installed");
-    }
-    bind(PublicKeyStore.class).toProvider(StoreProvider.class);
-    DynamicSet.bind(binder(), ReceivePackInitializer.class).to(Initializer.class);
-  }
-
-  @Singleton
-  private static class Initializer implements ReceivePackInitializer {
-    private final SignedPushConfig signedPushConfig;
-    private final SignedPushPreReceiveHook hook;
-    private final ProjectCache projectCache;
-
-    @Inject
-    Initializer(
-        @GerritServerConfig Config cfg,
-        @EnableSignedPush boolean enableSignedPush,
-        SignedPushPreReceiveHook hook,
-        ProjectCache projectCache) {
-      this.hook = hook;
-      this.projectCache = projectCache;
-
-      if (enableSignedPush) {
-        String seed = cfg.getString("receive", null, "certNonceSeed");
-        if (Strings.isNullOrEmpty(seed)) {
-          seed = randomString(64);
-        }
-        signedPushConfig = new SignedPushConfig();
-        signedPushConfig.setCertNonceSeed(seed);
-        signedPushConfig.setCertNonceSlopLimit(
-            cfg.getInt("receive", null, "certNonceSlop", 5 * 60));
-      } else {
-        signedPushConfig = null;
-      }
-    }
-
-    @Override
-    public void init(Project.NameKey project, ReceivePack rp) {
-      ProjectState ps = projectCache.get(project);
-      if (!ps.isEnableSignedPush()) {
-        rp.setSignedPushConfig(null);
-        return;
-      } else if (signedPushConfig == null) {
-        log.error(
-            "receive.enableSignedPush is true for project {} but"
-                + " false in gerrit.config, so signed push verification is"
-                + " disabled",
-            project.get());
-        rp.setSignedPushConfig(null);
-        return;
-      }
-      rp.setSignedPushConfig(signedPushConfig);
-
-      List<PreReceiveHook> hooks = new ArrayList<>(3);
-      if (ps.isRequireSignedPush()) {
-        hooks.add(SignedPushPreReceiveHook.Required.INSTANCE);
-      }
-      hooks.add(hook);
-      hooks.add(rp.getPreReceiveHook());
-      rp.setPreReceiveHook(PreReceiveHookChain.newChain(hooks));
-    }
-  }
-
-  @Singleton
-  private static class StoreProvider implements Provider<PublicKeyStore> {
-    private final GitRepositoryManager repoManager;
-    private final AllUsersName allUsers;
-
-    @Inject
-    StoreProvider(GitRepositoryManager repoManager, AllUsersName allUsers) {
-      this.repoManager = repoManager;
-      this.allUsers = allUsers;
-    }
-
-    @Override
-    public PublicKeyStore get() {
-      final Repository repo;
-      try {
-        repo = repoManager.openRepository(allUsers);
-      } catch (IOException e) {
-        throw new ProvisionException("Cannot open " + allUsers, e);
-      }
-      return new PublicKeyStore(repo) {
-        @Override
-        public void close() {
-          try {
-            super.close();
-          } finally {
-            repo.close();
-          }
-        }
-      };
-    }
-  }
-
-  private static String randomString(int len) {
-    Random random;
-    try {
-      random = SecureRandom.getInstance("SHA1PRNG");
-    } catch (NoSuchAlgorithmException e) {
-      throw new IllegalStateException(e);
-    }
-    StringBuilder sb = new StringBuilder(len);
-    for (int i = 0; i < len; i++) {
-      sb.append((char) random.nextInt());
-    }
-    return sb.toString();
-  }
-}
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
deleted file mode 100644
index 49c7f67..0000000
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ /dev/null
@@ -1,113 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.gpg.api;
-
-import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
-import com.google.gerrit.extensions.common.GpgKeyInfo;
-import com.google.gerrit.extensions.common.PushCertificateInfo;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.gpg.GerritPushCertificateChecker;
-import com.google.gerrit.gpg.PushCertificateChecker;
-import com.google.gerrit.gpg.server.GpgKeys;
-import com.google.gerrit.gpg.server.PostGpgKeys;
-import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.api.accounts.GpgApiAdapter;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.List;
-import java.util.Map;
-import org.bouncycastle.openpgp.PGPException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.transport.PushCertificate;
-import org.eclipse.jgit.transport.PushCertificateParser;
-
-public class GpgApiAdapterImpl implements GpgApiAdapter {
-  private final Provider<PostGpgKeys> postGpgKeys;
-  private final Provider<GpgKeys> gpgKeys;
-  private final GpgKeyApiImpl.Factory gpgKeyApiFactory;
-  private final GerritPushCertificateChecker.Factory pushCertCheckerFactory;
-
-  @Inject
-  GpgApiAdapterImpl(
-      Provider<PostGpgKeys> postGpgKeys,
-      Provider<GpgKeys> gpgKeys,
-      GpgKeyApiImpl.Factory gpgKeyApiFactory,
-      GerritPushCertificateChecker.Factory pushCertCheckerFactory) {
-    this.postGpgKeys = postGpgKeys;
-    this.gpgKeys = gpgKeys;
-    this.gpgKeyApiFactory = gpgKeyApiFactory;
-    this.pushCertCheckerFactory = pushCertCheckerFactory;
-  }
-
-  @Override
-  public boolean isEnabled() {
-    return true;
-  }
-
-  @Override
-  public Map<String, GpgKeyInfo> listGpgKeys(AccountResource account)
-      throws RestApiException, GpgException {
-    try {
-      return gpgKeys.get().list().apply(account);
-    } catch (OrmException | PGPException | IOException e) {
-      throw new GpgException(e);
-    }
-  }
-
-  @Override
-  public Map<String, GpgKeyInfo> putGpgKeys(
-      AccountResource account, List<String> add, List<String> delete)
-      throws RestApiException, GpgException {
-    PostGpgKeys.Input in = new PostGpgKeys.Input();
-    in.add = add;
-    in.delete = delete;
-    try {
-      return postGpgKeys.get().apply(account, in);
-    } catch (PGPException | OrmException | IOException | ConfigInvalidException e) {
-      throw new GpgException(e);
-    }
-  }
-
-  @Override
-  public GpgKeyApi gpgKey(AccountResource account, IdString idStr)
-      throws RestApiException, GpgException {
-    try {
-      return gpgKeyApiFactory.create(gpgKeys.get().parse(account, idStr));
-    } catch (PGPException | OrmException | IOException e) {
-      throw new GpgException(e);
-    }
-  }
-
-  @Override
-  public PushCertificateInfo checkPushCertificate(String certStr, IdentifiedUser expectedUser)
-      throws GpgException {
-    try {
-      PushCertificate cert = PushCertificateParser.fromString(certStr);
-      PushCertificateChecker.Result result =
-          pushCertCheckerFactory.create(expectedUser).setCheckNonce(false).check(cert);
-      PushCertificateInfo info = new PushCertificateInfo();
-      info.certificate = certStr;
-      info.key = GpgKeys.toJson(result.getPublicKey(), result.getCheckResult());
-      return info;
-    } catch (IOException e) {
-      throw new GpgException(e);
-    }
-  }
-}
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiModule.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiModule.java
deleted file mode 100644
index f7102d8..0000000
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiModule.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.gpg.api;
-
-import static com.google.gerrit.gpg.server.GpgKey.GPG_KEY_KIND;
-import static com.google.gerrit.server.account.AccountResource.ACCOUNT_KIND;
-
-import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
-import com.google.gerrit.extensions.common.GpgKeyInfo;
-import com.google.gerrit.extensions.common.PushCertificateInfo;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.gpg.server.DeleteGpgKey;
-import com.google.gerrit.gpg.server.GpgKeys;
-import com.google.gerrit.gpg.server.PostGpgKeys;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.api.accounts.GpgApiAdapter;
-import java.util.List;
-import java.util.Map;
-
-public class GpgApiModule extends RestApiModule {
-  private final boolean enabled;
-
-  public GpgApiModule(boolean enabled) {
-    this.enabled = enabled;
-  }
-
-  @Override
-  protected void configure() {
-    if (!enabled) {
-      bind(GpgApiAdapter.class).to(NoGpgApi.class);
-      return;
-    }
-    bind(GpgApiAdapter.class).to(GpgApiAdapterImpl.class);
-    factory(GpgKeyApiImpl.Factory.class);
-
-    DynamicMap.mapOf(binder(), GPG_KEY_KIND);
-
-    child(ACCOUNT_KIND, "gpgkeys").to(GpgKeys.class);
-    post(ACCOUNT_KIND, "gpgkeys").to(PostGpgKeys.class);
-    get(GPG_KEY_KIND).to(GpgKeys.Get.class);
-    delete(GPG_KEY_KIND).to(DeleteGpgKey.class);
-  }
-
-  private static class NoGpgApi implements GpgApiAdapter {
-    private static final String MSG = "GPG key APIs disabled";
-
-    @Override
-    public boolean isEnabled() {
-      return false;
-    }
-
-    @Override
-    public Map<String, GpgKeyInfo> listGpgKeys(AccountResource account) {
-      throw new NotImplementedException(MSG);
-    }
-
-    @Override
-    public Map<String, GpgKeyInfo> putGpgKeys(
-        AccountResource account, List<String> add, List<String> delete) {
-      throw new NotImplementedException(MSG);
-    }
-
-    @Override
-    public GpgKeyApi gpgKey(AccountResource account, IdString idStr) {
-      throw new NotImplementedException(MSG);
-    }
-
-    @Override
-    public PushCertificateInfo checkPushCertificate(String certStr, IdentifiedUser expectedUser) {
-      throw new NotImplementedException(MSG);
-    }
-  }
-}
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
deleted file mode 100644
index 14a4c6d..0000000
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.gpg.api;
-
-import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
-import com.google.gerrit.extensions.common.GpgKeyInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.gpg.server.DeleteGpgKey;
-import com.google.gerrit.gpg.server.GpgKey;
-import com.google.gerrit.gpg.server.GpgKeys;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import org.bouncycastle.openpgp.PGPException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-public class GpgKeyApiImpl implements GpgKeyApi {
-  public interface Factory {
-    GpgKeyApiImpl create(GpgKey rsrc);
-  }
-
-  private final GpgKeys.Get get;
-  private final DeleteGpgKey delete;
-  private final GpgKey rsrc;
-
-  @Inject
-  GpgKeyApiImpl(GpgKeys.Get get, DeleteGpgKey delete, @Assisted GpgKey rsrc) {
-    this.get = get;
-    this.delete = delete;
-    this.rsrc = rsrc;
-  }
-
-  @Override
-  public GpgKeyInfo get() throws RestApiException {
-    try {
-      return get.apply(rsrc);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot get GPG key", e);
-    }
-  }
-
-  @Override
-  public void delete() throws RestApiException {
-    try {
-      delete.apply(rsrc, new DeleteGpgKey.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
deleted file mode 100644
index baf5a58..0000000
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.gpg.server;
-
-import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
-
-import com.google.common.io.BaseEncoding;
-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;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import org.bouncycastle.openpgp.PGPException;
-import org.bouncycastle.openpgp.PGPPublicKey;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.PersonIdent;
-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;
-  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
-
-  @Inject
-  DeleteGpgKey(
-      @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      Provider<PublicKeyStore> storeProvider,
-      ExternalIdsUpdate.User externalIdsUpdateFactory) {
-    this.serverIdent = serverIdent;
-    this.storeProvider = storeProvider;
-    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
-  }
-
-  @Override
-  public Response<?> apply(GpgKey rsrc, Input input)
-      throws ResourceConflictException, PGPException, OrmException, IOException,
-          ConfigInvalidException {
-    PGPPublicKey key = rsrc.getKeyRing().getPublicKey();
-    externalIdsUpdateFactory
-        .create()
-        .delete(
-            rsrc.getUser().getAccountId(),
-            ExternalId.Key.create(
-                SCHEME_GPGKEY, BaseEncoding.base16().encode(key.getFingerprint())));
-
-    try (PublicKeyStore store = storeProvider.get()) {
-      store.remove(rsrc.getKeyRing().getPublicKey().getFingerprint());
-
-      CommitBuilder cb = new CommitBuilder();
-      PersonIdent committer = serverIdent.get();
-      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
-      cb.setCommitter(committer);
-      cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
-
-      RefUpdate.Result saveResult = store.save(cb);
-      switch (saveResult) {
-        case NO_CHANGE:
-        case FAST_FORWARD:
-          break;
-        case FORCED:
-        case IO_FAILURE:
-        case LOCK_FAILURE:
-        case NEW:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case REJECTED_CURRENT_BRANCH:
-        case RENAMED:
-        case REJECTED_MISSING_OBJECT:
-        case REJECTED_OTHER_REASON:
-        default:
-          throw new ResourceConflictException("Failed to delete public key: " + saveResult);
-      }
-    }
-    return Response.none();
-  }
-}
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
deleted file mode 100644
index 303499e..0000000
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ /dev/null
@@ -1,251 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.gpg.server;
-
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.collect.ImmutableList;
-import com.google.common.io.BaseEncoding;
-import com.google.gerrit.extensions.common.GpgKeyInfo;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.gpg.BouncyCastleUtil;
-import com.google.gerrit.gpg.CheckResult;
-import com.google.gerrit.gpg.Fingerprint;
-import com.google.gerrit.gpg.GerritPublicKeyChecker;
-import com.google.gerrit.gpg.PublicKeyChecker;
-import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.Map;
-import org.bouncycastle.bcpg.ArmoredOutputStream;
-import org.bouncycastle.openpgp.PGPException;
-import org.bouncycastle.openpgp.PGPPublicKey;
-import org.bouncycastle.openpgp.PGPPublicKeyRing;
-import org.eclipse.jgit.util.NB;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class GpgKeys implements ChildCollection<AccountResource, GpgKey> {
-  private static final Logger log = LoggerFactory.getLogger(GpgKeys.class);
-
-  public static final String MIME_TYPE = "application/pgp-keys";
-
-  private final DynamicMap<RestView<GpgKey>> views;
-  private final Provider<CurrentUser> self;
-  private final Provider<PublicKeyStore> storeProvider;
-  private final GerritPublicKeyChecker.Factory checkerFactory;
-  private final ExternalIds externalIds;
-
-  @Inject
-  GpgKeys(
-      DynamicMap<RestView<GpgKey>> views,
-      Provider<CurrentUser> self,
-      Provider<PublicKeyStore> storeProvider,
-      GerritPublicKeyChecker.Factory checkerFactory,
-      ExternalIds externalIds) {
-    this.views = views;
-    this.self = self;
-    this.storeProvider = storeProvider;
-    this.checkerFactory = checkerFactory;
-    this.externalIds = externalIds;
-  }
-
-  @Override
-  public ListGpgKeys list() throws ResourceNotFoundException, AuthException {
-    return new ListGpgKeys();
-  }
-
-  @Override
-  public GpgKey parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, PGPException, OrmException, IOException {
-    checkVisible(self, parent);
-    String str = CharMatcher.whitespace().removeFrom(id.get()).toUpperCase();
-    if ((str.length() != 8 && str.length() != 40)
-        || !CharMatcher.anyOf("0123456789ABCDEF").matchesAllOf(str)) {
-      throw new ResourceNotFoundException(id);
-    }
-
-    byte[] fp = parseFingerprint(id.get(), getGpgExtIds(parent));
-    try (PublicKeyStore store = storeProvider.get()) {
-      long keyId = keyId(fp);
-      for (PGPPublicKeyRing keyRing : store.get(keyId)) {
-        PGPPublicKey key = keyRing.getPublicKey();
-        if (Arrays.equals(key.getFingerprint(), fp)) {
-          return new GpgKey(parent.getUser(), keyRing);
-        }
-      }
-    }
-
-    throw new ResourceNotFoundException(id);
-  }
-
-  static byte[] parseFingerprint(String str, Iterable<ExternalId> existingExtIds)
-      throws ResourceNotFoundException {
-    str = CharMatcher.whitespace().removeFrom(str).toUpperCase();
-    if ((str.length() != 8 && str.length() != 40)
-        || !CharMatcher.anyOf("0123456789ABCDEF").matchesAllOf(str)) {
-      throw new ResourceNotFoundException(str);
-    }
-    byte[] fp = null;
-    for (ExternalId extId : existingExtIds) {
-      String fpStr = extId.key().id();
-      if (!fpStr.endsWith(str)) {
-        continue;
-      } else if (fp != null) {
-        throw new ResourceNotFoundException("Multiple keys found for " + str);
-      }
-      fp = BaseEncoding.base16().decode(fpStr);
-      if (str.length() == 40) {
-        break;
-      }
-    }
-    if (fp == null) {
-      throw new ResourceNotFoundException(str);
-    }
-    return fp;
-  }
-
-  @Override
-  public DynamicMap<RestView<GpgKey>> views() {
-    return views;
-  }
-
-  public class ListGpgKeys implements RestReadView<AccountResource> {
-    @Override
-    public Map<String, GpgKeyInfo> apply(AccountResource rsrc)
-        throws OrmException, PGPException, IOException, ResourceNotFoundException {
-      checkVisible(self, rsrc);
-      Map<String, GpgKeyInfo> keys = new HashMap<>();
-      try (PublicKeyStore store = storeProvider.get()) {
-        for (ExternalId extId : getGpgExtIds(rsrc)) {
-          String fpStr = extId.key().id();
-          byte[] fp = BaseEncoding.base16().decode(fpStr);
-          boolean found = false;
-          for (PGPPublicKeyRing keyRing : store.get(keyId(fp))) {
-            if (Arrays.equals(keyRing.getPublicKey().getFingerprint(), fp)) {
-              found = true;
-              GpgKeyInfo info =
-                  toJson(
-                      keyRing.getPublicKey(), checkerFactory.create(rsrc.getUser(), store), store);
-              keys.put(info.id, info);
-              info.id = null;
-              break;
-            }
-          }
-          if (!found) {
-            log.warn("No public key stored for fingerprint {}", Fingerprint.toString(fp));
-          }
-        }
-      }
-      return keys;
-    }
-  }
-
-  @Singleton
-  public static class Get implements RestReadView<GpgKey> {
-    private final Provider<PublicKeyStore> storeProvider;
-    private final GerritPublicKeyChecker.Factory checkerFactory;
-
-    @Inject
-    Get(Provider<PublicKeyStore> storeProvider, GerritPublicKeyChecker.Factory checkerFactory) {
-      this.storeProvider = storeProvider;
-      this.checkerFactory = checkerFactory;
-    }
-
-    @Override
-    public GpgKeyInfo apply(GpgKey rsrc) throws IOException {
-      try (PublicKeyStore store = storeProvider.get()) {
-        return toJson(
-            rsrc.getKeyRing().getPublicKey(),
-            checkerFactory.create().setExpectedUser(rsrc.getUser()),
-            store);
-      }
-    }
-  }
-
-  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws IOException {
-    return externalIds.byAccount(rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
-  }
-
-  private static long keyId(byte[] fp) {
-    return NB.decodeInt64(fp, fp.length - 8);
-  }
-
-  static void checkVisible(Provider<CurrentUser> self, AccountResource rsrc)
-      throws ResourceNotFoundException {
-    if (!BouncyCastleUtil.havePGP()) {
-      throw new ResourceNotFoundException("GPG not enabled");
-    }
-    if (self.get() != rsrc.getUser()) {
-      throw new ResourceNotFoundException();
-    }
-  }
-
-  public static GpgKeyInfo toJson(PGPPublicKey key, CheckResult checkResult) throws IOException {
-    GpgKeyInfo info = new GpgKeyInfo();
-
-    if (key != null) {
-      info.id = PublicKeyStore.keyIdToString(key.getKeyID());
-      info.fingerprint = Fingerprint.toString(key.getFingerprint());
-      Iterator<String> userIds = key.getUserIDs();
-      info.userIds = ImmutableList.copyOf(userIds);
-
-      try (ByteArrayOutputStream out = new ByteArrayOutputStream(4096);
-          ArmoredOutputStream aout = new ArmoredOutputStream(out)) {
-        // This is not exactly the key stored in the store, but is equivalent. In
-        // particular, it will have a Bouncy Castle version string. The armored
-        // stream reader in PublicKeyStore doesn't give us an easy way to extract
-        // the original ASCII armor.
-        key.encode(aout);
-        info.key = new String(out.toByteArray(), UTF_8);
-      }
-    }
-
-    info.status = checkResult.getStatus();
-    info.problems = checkResult.getProblems();
-
-    return info;
-  }
-
-  static GpgKeyInfo toJson(PGPPublicKey key, PublicKeyChecker checker, PublicKeyStore store)
-      throws IOException {
-    return toJson(key, checker.setStore(store).check(key));
-  }
-
-  public static void toJson(GpgKeyInfo info, CheckResult checkResult) {
-    info.status = checkResult.getStatus();
-    info.problems = checkResult.getProblems();
-  }
-}
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
deleted file mode 100644
index d725e72..0000000
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ /dev/null
@@ -1,296 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.gpg.server;
-
-import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Joiner;
-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;
-import com.google.common.io.BaseEncoding;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.common.GpgKeyInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.gpg.CheckResult;
-import com.google.gerrit.gpg.Fingerprint;
-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;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
-import com.google.gerrit.server.mail.send.AddKeySender;
-import com.google.gerrit.server.query.account.InternalAccountQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.bouncycastle.bcpg.ArmoredInputStream;
-import org.bouncycastle.openpgp.PGPException;
-import org.bouncycastle.openpgp.PGPPublicKey;
-import org.bouncycastle.openpgp.PGPPublicKeyRing;
-import org.bouncycastle.openpgp.PGPRuntimeOperationException;
-import org.bouncycastle.openpgp.bc.BcPGPObjectFactory;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class PostGpgKeys implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-    public List<String> add;
-    public List<String> delete;
-  }
-
-  private final Logger log = LoggerFactory.getLogger(getClass());
-  private final Provider<PersonIdent> serverIdent;
-  private final Provider<CurrentUser> self;
-  private final Provider<PublicKeyStore> storeProvider;
-  private final GerritPublicKeyChecker.Factory checkerFactory;
-  private final AddKeySender.Factory addKeyFactory;
-  private final Provider<InternalAccountQuery> accountQueryProvider;
-  private final ExternalIds externalIds;
-  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
-
-  @Inject
-  PostGpgKeys(
-      @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      Provider<CurrentUser> self,
-      Provider<PublicKeyStore> storeProvider,
-      GerritPublicKeyChecker.Factory checkerFactory,
-      AddKeySender.Factory addKeyFactory,
-      Provider<InternalAccountQuery> accountQueryProvider,
-      ExternalIds externalIds,
-      ExternalIdsUpdate.User externalIdsUpdateFactory) {
-    this.serverIdent = serverIdent;
-    this.self = self;
-    this.storeProvider = storeProvider;
-    this.checkerFactory = checkerFactory;
-    this.addKeyFactory = addKeyFactory;
-    this.accountQueryProvider = accountQueryProvider;
-    this.externalIds = externalIds;
-    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
-  }
-
-  @Override
-  public Map<String, GpgKeyInfo> apply(AccountResource rsrc, Input input)
-      throws ResourceNotFoundException, BadRequestException, ResourceConflictException,
-          PGPException, OrmException, IOException, ConfigInvalidException {
-    GpgKeys.checkVisible(self, rsrc);
-
-    Collection<ExternalId> existingExtIds =
-        externalIds.byAccount(rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
-    try (PublicKeyStore store = storeProvider.get()) {
-      Set<Fingerprint> toRemove = readKeysToRemove(input, existingExtIds);
-      List<PGPPublicKeyRing> newKeys = readKeysToAdd(input, toRemove);
-      List<ExternalId> newExtIds = new ArrayList<>(existingExtIds.size());
-
-      for (PGPPublicKeyRing keyRing : newKeys) {
-        PGPPublicKey key = keyRing.getPublicKey();
-        ExternalId.Key extIdKey = toExtIdKey(key.getFingerprint());
-        Account account = getAccountByExternalId(extIdKey);
-        if (account != null) {
-          if (!account.getId().equals(rsrc.getUser().getAccountId())) {
-            throw new ResourceConflictException("GPG key already associated with another account");
-          }
-        } else {
-          newExtIds.add(ExternalId.create(extIdKey, rsrc.getUser().getAccountId()));
-        }
-      }
-
-      storeKeys(rsrc, newKeys, toRemove);
-
-      List<ExternalId.Key> extIdKeysToRemove =
-          toRemove.stream().map(fp -> toExtIdKey(fp.get())).collect(toList());
-      externalIdsUpdateFactory
-          .create()
-          .replace(rsrc.getUser().getAccountId(), extIdKeysToRemove, newExtIds);
-      return toJson(newKeys, toRemove, store, rsrc.getUser());
-    }
-  }
-
-  private Set<Fingerprint> readKeysToRemove(Input input, Collection<ExternalId> existingExtIds) {
-    if (input.delete == null || input.delete.isEmpty()) {
-      return ImmutableSet.of();
-    }
-    Set<Fingerprint> fingerprints = Sets.newHashSetWithExpectedSize(input.delete.size());
-    for (String id : input.delete) {
-      try {
-        fingerprints.add(new Fingerprint(GpgKeys.parseFingerprint(id, existingExtIds)));
-      } catch (ResourceNotFoundException e) {
-        // Skip removal.
-      }
-    }
-    return fingerprints;
-  }
-
-  private List<PGPPublicKeyRing> readKeysToAdd(Input input, Set<Fingerprint> toRemove)
-      throws BadRequestException, IOException {
-    if (input.add == null || input.add.isEmpty()) {
-      return ImmutableList.of();
-    }
-    List<PGPPublicKeyRing> keyRings = new ArrayList<>(input.add.size());
-    for (String armored : input.add) {
-      try (InputStream in = new ByteArrayInputStream(armored.getBytes(UTF_8));
-          ArmoredInputStream ain = new ArmoredInputStream(in)) {
-        @SuppressWarnings("unchecked")
-        List<Object> objs = Lists.newArrayList(new BcPGPObjectFactory(ain));
-        if (objs.size() != 1 || !(objs.get(0) instanceof PGPPublicKeyRing)) {
-          throw new BadRequestException("Expected exactly one PUBLIC KEY BLOCK");
-        }
-        PGPPublicKeyRing keyRing = (PGPPublicKeyRing) objs.get(0);
-        if (toRemove.contains(new Fingerprint(keyRing.getPublicKey().getFingerprint()))) {
-          throw new BadRequestException(
-              "Cannot both add and delete key: " + keyToString(keyRing.getPublicKey()));
-        }
-        keyRings.add(keyRing);
-      } catch (PGPRuntimeOperationException e) {
-        throw new BadRequestException("Failed to parse GPG keys", e);
-      }
-    }
-    return keyRings;
-  }
-
-  private void storeKeys(
-      AccountResource rsrc, List<PGPPublicKeyRing> keyRings, Set<Fingerprint> toRemove)
-      throws BadRequestException, ResourceConflictException, PGPException, IOException {
-    try (PublicKeyStore store = storeProvider.get()) {
-      List<String> addedKeys = new ArrayList<>();
-      for (PGPPublicKeyRing keyRing : keyRings) {
-        PGPPublicKey key = keyRing.getPublicKey();
-        // Don't check web of trust; admins can fill in certifications later.
-        CheckResult result = checkerFactory.create(rsrc.getUser(), store).disableTrust().check(key);
-        if (!result.isOk()) {
-          throw new BadRequestException(
-              String.format(
-                  "Problems with public key %s:\n%s",
-                  keyToString(key), Joiner.on('\n').join(result.getProblems())));
-        }
-        addedKeys.add(PublicKeyStore.keyToString(key));
-        store.add(keyRing);
-      }
-      for (Fingerprint fp : toRemove) {
-        store.remove(fp.get());
-      }
-      CommitBuilder cb = new CommitBuilder();
-      PersonIdent committer = serverIdent.get();
-      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
-      cb.setCommitter(committer);
-
-      RefUpdate.Result saveResult = store.save(cb);
-      switch (saveResult) {
-        case NEW:
-        case FAST_FORWARD:
-        case FORCED:
-          try {
-            addKeyFactory.create(rsrc.getUser(), addedKeys).send();
-          } catch (EmailException e) {
-            log.error(
-                "Cannot send GPG key added message to "
-                    + rsrc.getUser().getAccount().getPreferredEmail(),
-                e);
-          }
-          break;
-        case NO_CHANGE:
-          break;
-        case IO_FAILURE:
-        case LOCK_FAILURE:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case REJECTED_CURRENT_BRANCH:
-        case RENAMED:
-        case REJECTED_MISSING_OBJECT:
-        case REJECTED_OTHER_REASON:
-        default:
-          // TODO(dborowitz): Backoff and retry on LOCK_FAILURE.
-          throw new ResourceConflictException("Failed to save public keys: " + saveResult);
-      }
-    }
-  }
-
-  private ExternalId.Key toExtIdKey(byte[] fp) {
-    return ExternalId.Key.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(fp));
-  }
-
-  private Account getAccountByExternalId(ExternalId.Key extIdKey) throws OrmException {
-    List<AccountState> accountStates = accountQueryProvider.get().byExternalId(extIdKey);
-
-    if (accountStates.isEmpty()) {
-      return null;
-    }
-
-    if (accountStates.size() > 1) {
-      StringBuilder msg = new StringBuilder();
-      msg.append("GPG key ").append(extIdKey.get()).append(" associated with multiple accounts: ");
-      Joiner.on(", ")
-          .appendTo(msg, Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
-      log.error(msg.toString());
-      throw new IllegalStateException(msg.toString());
-    }
-
-    return accountStates.get(0).getAccount();
-  }
-
-  private Map<String, GpgKeyInfo> toJson(
-      Collection<PGPPublicKeyRing> keys,
-      Set<Fingerprint> deleted,
-      PublicKeyStore store,
-      IdentifiedUser user)
-      throws IOException {
-    // Unlike when storing keys, include web-of-trust checks when producing
-    // result JSON, so the user at least knows of any issues.
-    PublicKeyChecker checker = checkerFactory.create(user, store);
-    Map<String, GpgKeyInfo> infos = Maps.newHashMapWithExpectedSize(keys.size() + deleted.size());
-    for (PGPPublicKeyRing keyRing : keys) {
-      PGPPublicKey key = keyRing.getPublicKey();
-      CheckResult result = checker.check(key);
-      GpgKeyInfo info = GpgKeys.toJson(key, result);
-      infos.put(info.id, info);
-      info.id = null;
-    }
-    for (Fingerprint fp : deleted) {
-      infos.put(keyIdToString(fp.getId()), new GpgKeyInfo());
-    }
-    return infos;
-  }
-}
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
deleted file mode 100644
index 07a4fe3..0000000
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
+++ /dev/null
@@ -1,433 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.gpg;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.gpg.GerritPublicKeyChecker.toExtIdKey;
-import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
-import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithSecondUserId;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyA;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyB;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyC;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyD;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyE;
-import static org.eclipse.jgit.lib.RefUpdate.Result.FAST_FORWARD;
-import static org.eclipse.jgit.lib.RefUpdate.Result.FORCED;
-import static org.eclipse.jgit.lib.RefUpdate.Result.NEW;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterators;
-import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
-import com.google.gerrit.gpg.testutil.TestKey;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
-import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testutil.InMemoryDatabase;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.gerrit.testutil.NoteDbMode;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Provider;
-import com.google.inject.util.Providers;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import org.bouncycastle.openpgp.PGPPublicKey;
-import org.bouncycastle.openpgp.PGPPublicKeyRing;
-import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.PushCertificateIdent;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-/** Unit tests for {@link GerritPublicKeyChecker}. */
-public class GerritPublicKeyCheckerTest {
-  @Inject private AccountsUpdate.Server accountsUpdate;
-
-  @Inject private AccountManager accountManager;
-
-  @Inject private GerritPublicKeyChecker.Factory checkerFactory;
-
-  @Inject private IdentifiedUser.GenericFactory userFactory;
-
-  @Inject private InMemoryDatabase schemaFactory;
-
-  @Inject private SchemaCreator schemaCreator;
-
-  @Inject private ThreadLocalRequestContext requestContext;
-
-  @Inject private ExternalIdsUpdate.Server externalIdsUpdateFactory;
-
-  private LifecycleManager lifecycle;
-  private ReviewDb db;
-  private Account.Id userId;
-  private IdentifiedUser user;
-  private Repository storeRepo;
-  private PublicKeyStore store;
-
-  @Before
-  public void setUpInjector() throws Exception {
-    Config cfg = InMemoryModule.newDefaultConfig();
-    cfg.setInt("receive", null, "maxTrustDepth", 2);
-    cfg.setStringList(
-        "receive",
-        null,
-        "trustedKey",
-        ImmutableList.of(
-            Fingerprint.toString(keyB().getPublicKey().getFingerprint()),
-            Fingerprint.toString(keyD().getPublicKey().getFingerprint())));
-    Injector injector =
-        Guice.createInjector(new InMemoryModule(cfg, NoteDbMode.newNotesMigrationFromEnv()));
-
-    lifecycle = new LifecycleManager();
-    lifecycle.add(injector);
-    injector.injectMembers(this);
-    lifecycle.start();
-
-    db = schemaFactory.open();
-    schemaCreator.create(db);
-    userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
-    // Note: does not match any key in TestKeys.
-    accountsUpdate.create().update(userId, a -> a.setPreferredEmail("user@example.com"));
-    user = reloadUser();
-
-    requestContext.setContext(
-        new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return user;
-          }
-
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return Providers.of(db);
-          }
-        });
-
-    storeRepo = new InMemoryRepository(new DfsRepositoryDescription("repo"));
-    store = new PublicKeyStore(storeRepo);
-  }
-
-  @After
-  public void tearDown() throws Exception {
-    store.close();
-    storeRepo.close();
-  }
-
-  private IdentifiedUser addUser(String name) throws Exception {
-    AuthRequest req = AuthRequest.forUser(name);
-    Account.Id id = accountManager.authenticate(req).getAccountId();
-    return userFactory.create(id);
-  }
-
-  private IdentifiedUser reloadUser() {
-    user = userFactory.create(userId);
-    return user;
-  }
-
-  @After
-  public void tearDownInjector() {
-    if (lifecycle != null) {
-      lifecycle.stop();
-    }
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(schemaFactory);
-  }
-
-  @Test
-  public void defaultGpgCertificationMatchesEmail() throws Exception {
-    TestKey key = validKeyWithSecondUserId();
-    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
-    assertProblems(
-        checker.check(key.getPublicKey()),
-        Status.BAD,
-        "Key must contain a valid certification for one of the following "
-            + "identities:\n"
-            + "  gerrit:user\n"
-            + "  username:user");
-
-    addExternalId("test", "test", "test5@example.com");
-    checker = checkerFactory.create(user, store).disableTrust();
-    assertNoProblems(checker.check(key.getPublicKey()));
-  }
-
-  @Test
-  public void defaultGpgCertificationDoesNotMatchEmail() throws Exception {
-    addExternalId("test", "test", "nobody@example.com");
-    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
-    assertProblems(
-        checker.check(validKeyWithSecondUserId().getPublicKey()),
-        Status.BAD,
-        "Key must contain a valid certification for one of the following "
-            + "identities:\n"
-            + "  gerrit:user\n"
-            + "  nobody@example.com\n"
-            + "  test:test\n"
-            + "  username:user");
-  }
-
-  @Test
-  public void manualCertificationMatchesExternalId() throws Exception {
-    addExternalId("foo", "myId", null);
-    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
-    assertNoProblems(checker.check(validKeyWithSecondUserId().getPublicKey()));
-  }
-
-  @Test
-  public void manualCertificationDoesNotMatchExternalId() throws Exception {
-    addExternalId("foo", "otherId", null);
-    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
-    assertProblems(
-        checker.check(validKeyWithSecondUserId().getPublicKey()),
-        Status.BAD,
-        "Key must contain a valid certification for one of the following "
-            + "identities:\n"
-            + "  foo:otherId\n"
-            + "  gerrit:user\n"
-            + "  username:user");
-  }
-
-  @Test
-  public void noExternalIds() throws Exception {
-    ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
-    externalIdsUpdate.deleteAll(user.getAccountId());
-    reloadUser();
-
-    TestKey key = validKeyWithSecondUserId();
-    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
-    assertProblems(
-        checker.check(key.getPublicKey()),
-        Status.BAD,
-        "No identities found for user; check http://test/#/settings/web-identities");
-
-    checker = checkerFactory.create().setStore(store).disableTrust();
-    assertProblems(
-        checker.check(key.getPublicKey()), Status.BAD, "Key is not associated with any users");
-    externalIdsUpdate.insert(
-        ExternalId.create(toExtIdKey(key.getPublicKey()), user.getAccountId()));
-    reloadUser();
-    assertProblems(checker.check(key.getPublicKey()), Status.BAD, "No identities found for user");
-  }
-
-  @Test
-  public void checkValidTrustChainAndCorrectExternalIds() throws Exception {
-    // A---Bx
-    //  \
-    //   \---C---D
-    //        \
-    //         \---Ex
-    //
-    // The server ultimately trusts B and D.
-    // D and E trust C to be a valid introducer of depth 2.
-    IdentifiedUser userB = addUser("userB");
-    TestKey keyA = add(keyA(), user);
-    TestKey keyB = add(keyB(), userB);
-    add(keyC(), addUser("userC"));
-    add(keyD(), addUser("userD"));
-    add(keyE(), addUser("userE"));
-
-    // Checker for A, checking A.
-    PublicKeyChecker checkerA = checkerFactory.create(user, store);
-    assertNoProblems(checkerA.check(keyA.getPublicKey()));
-
-    // Checker for B, checking B. Trust chain and IDs are correct, so the only
-    // problem is with the key itself.
-    PublicKeyChecker checkerB = checkerFactory.create(userB, store);
-    assertProblems(checkerB.check(keyB.getPublicKey()), Status.BAD, "Key is expired");
-  }
-
-  @Test
-  public void checkWithValidKeyButWrongExpectedUserInChecker() throws Exception {
-    // A---Bx
-    //  \
-    //   \---C---D
-    //        \
-    //         \---Ex
-    //
-    // The server ultimately trusts B and D.
-    // D and E trust C to be a valid introducer of depth 2.
-    IdentifiedUser userB = addUser("userB");
-    TestKey keyA = add(keyA(), user);
-    TestKey keyB = add(keyB(), userB);
-    add(keyC(), addUser("userC"));
-    add(keyD(), addUser("userD"));
-    add(keyE(), addUser("userE"));
-
-    // Checker for A, checking B.
-    PublicKeyChecker checkerA = checkerFactory.create(user, store);
-    assertProblems(
-        checkerA.check(keyB.getPublicKey()),
-        Status.BAD,
-        "Key is expired",
-        "Key must contain a valid certification for one of the following"
-            + " identities:\n"
-            + "  gerrit:user\n"
-            + "  mailto:testa@example.com\n"
-            + "  testa@example.com\n"
-            + "  username:user");
-
-    // Checker for B, checking A.
-    PublicKeyChecker checkerB = checkerFactory.create(userB, store);
-    assertProblems(
-        checkerB.check(keyA.getPublicKey()),
-        Status.BAD,
-        "Key must contain a valid certification for one of the following"
-            + " identities:\n"
-            + "  gerrit:userB\n"
-            + "  mailto:testb@example.com\n"
-            + "  testb@example.com\n"
-            + "  username:userB");
-  }
-
-  @Test
-  public void checkTrustChainWithExpiredKey() throws Exception {
-    // A---Bx
-    //
-    // The server ultimately trusts B.
-    TestKey keyA = add(keyA(), user);
-    TestKey keyB = add(keyB(), addUser("userB"));
-
-    PublicKeyChecker checker = checkerFactory.create(user, store);
-    assertProblems(
-        checker.check(keyA.getPublicKey()),
-        Status.OK,
-        "No path to a trusted key",
-        "Certification by "
-            + keyToString(keyB.getPublicKey())
-            + " is valid, but key is not trusted",
-        "Key D24FE467 used for certification is not in store");
-  }
-
-  @Test
-  public void checkTrustChainUsingCheckerWithoutExpectedKey() throws Exception {
-    // A---Bx
-    //  \
-    //   \---C---D
-    //        \
-    //         \---Ex
-    //
-    // The server ultimately trusts B and D.
-    // D and E trust C to be a valid introducer of depth 2.
-    TestKey keyA = add(keyA(), user);
-    TestKey keyB = add(keyB(), addUser("userB"));
-    TestKey keyC = add(keyC(), addUser("userC"));
-    TestKey keyD = add(keyD(), addUser("userD"));
-    TestKey keyE = add(keyE(), addUser("userE"));
-
-    // This checker can check any key, so the only problems come from issues
-    // with the keys themselves, not having invalid user IDs.
-    PublicKeyChecker checker = checkerFactory.create().setStore(store);
-    assertNoProblems(checker.check(keyA.getPublicKey()));
-    assertProblems(checker.check(keyB.getPublicKey()), Status.BAD, "Key is expired");
-    assertNoProblems(checker.check(keyC.getPublicKey()));
-    assertNoProblems(checker.check(keyD.getPublicKey()));
-    assertProblems(
-        checker.check(keyE.getPublicKey()),
-        Status.BAD,
-        "Key is expired",
-        "No path to a trusted key");
-  }
-
-  @Test
-  public void keyLaterInTrustChainMissingUserId() throws Exception {
-    // A---Bx
-    //  \
-    //   \---C
-    //
-    // The server ultimately trusts B.
-    // C signed A's key but is not in the store.
-    TestKey keyA = add(keyA(), user);
-
-    PGPPublicKeyRing keyRingB = keyB().getPublicKeyRing();
-    PGPPublicKey keyB = keyRingB.getPublicKey();
-    keyB = PGPPublicKey.removeCertification(keyB, keyB.getUserIDs().next());
-    keyRingB = PGPPublicKeyRing.insertPublicKey(keyRingB, keyB);
-    add(keyRingB, addUser("userB"));
-
-    PublicKeyChecker checkerA = checkerFactory.create(user, store);
-    assertProblems(
-        checkerA.check(keyA.getPublicKey()),
-        Status.OK,
-        "No path to a trusted key",
-        "Certification by " + keyToString(keyB) + " is valid, but key is not trusted",
-        "Key D24FE467 used for certification is not in store");
-  }
-
-  private void add(PGPPublicKeyRing kr, IdentifiedUser user) throws Exception {
-    Account.Id id = user.getAccountId();
-    List<ExternalId> newExtIds = new ArrayList<>(2);
-    newExtIds.add(ExternalId.create(toExtIdKey(kr.getPublicKey()), id));
-
-    String userId = Iterators.getOnlyElement(kr.getPublicKey().getUserIDs(), null);
-    if (userId != null) {
-      String email = PushCertificateIdent.parse(userId).getEmailAddress();
-      assertThat(email).contains("@");
-      newExtIds.add(ExternalId.createEmail(id, email));
-    }
-
-    store.add(kr);
-    PersonIdent ident = new PersonIdent("A U Thor", "author@example.com");
-    CommitBuilder cb = new CommitBuilder();
-    cb.setAuthor(ident);
-    cb.setCommitter(ident);
-    assertThat(store.save(cb)).isAnyOf(NEW, FAST_FORWARD, FORCED);
-
-    externalIdsUpdateFactory.create().insert(newExtIds);
-  }
-
-  private TestKey add(TestKey k, IdentifiedUser user) throws Exception {
-    add(k.getPublicKeyRing(), user);
-    return k;
-  }
-
-  private void assertProblems(
-      CheckResult result, Status expectedStatus, String first, String... rest) throws Exception {
-    List<String> expectedProblems = new ArrayList<>();
-    expectedProblems.add(first);
-    expectedProblems.addAll(Arrays.asList(rest));
-    assertThat(result.getStatus()).isEqualTo(expectedStatus);
-    assertThat(result.getProblems()).containsExactlyElementsIn(expectedProblems).inOrder();
-  }
-
-  private void assertNoProblems(CheckResult result) {
-    assertThat(result.getStatus()).isEqualTo(Status.TRUSTED);
-    assertThat(result.getProblems()).isEmpty();
-  }
-
-  private void addExternalId(String scheme, String id, String email) throws Exception {
-    externalIdsUpdateFactory
-        .create()
-        .insert(ExternalId.createWithEmail(scheme, id, user.getAccountId(), email));
-    reloadUser();
-  }
-}
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyCheckerTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyCheckerTest.java
deleted file mode 100644
index 04ed1de..0000000
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyCheckerTest.java
+++ /dev/null
@@ -1,377 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.gpg;
-
-import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
-import static com.google.gerrit.gpg.testutil.TestKeys.expiredKey;
-import static com.google.gerrit.gpg.testutil.TestKeys.keyRevokedByExpiredKeyAfterExpiration;
-import static com.google.gerrit.gpg.testutil.TestKeys.keyRevokedByExpiredKeyBeforeExpiration;
-import static com.google.gerrit.gpg.testutil.TestKeys.revokedCompromisedKey;
-import static com.google.gerrit.gpg.testutil.TestKeys.revokedNoLongerUsedKey;
-import static com.google.gerrit.gpg.testutil.TestKeys.selfRevokedKey;
-import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithExpiration;
-import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithoutExpiration;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyA;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyB;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyC;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyD;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyE;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyF;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyG;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyH;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyI;
-import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyJ;
-import static org.bouncycastle.bcpg.SignatureSubpacketTags.REVOCATION_KEY;
-import static org.bouncycastle.openpgp.PGPSignature.DIRECT_KEY;
-import static org.junit.Assert.assertEquals;
-
-import com.google.gerrit.gpg.testutil.TestKey;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import org.bouncycastle.openpgp.PGPPublicKey;
-import org.bouncycastle.openpgp.PGPPublicKeyRing;
-import org.bouncycastle.openpgp.PGPSignature;
-import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.ExpectedException;
-
-public class PublicKeyCheckerTest {
-  @Rule public ExpectedException thrown = ExpectedException.none();
-
-  private InMemoryRepository repo;
-  private PublicKeyStore store;
-
-  @Before
-  public void setUp() {
-    repo = new InMemoryRepository(new DfsRepositoryDescription("repo"));
-    store = new PublicKeyStore(repo);
-  }
-
-  @After
-  public void tearDown() {
-    if (store != null) {
-      store.close();
-      store = null;
-    }
-    if (repo != null) {
-      repo.close();
-      repo = null;
-    }
-  }
-
-  @Test
-  public void validKey() throws Exception {
-    assertNoProblems(validKeyWithoutExpiration());
-  }
-
-  @Test
-  public void keyExpiringInFuture() throws Exception {
-    TestKey k = validKeyWithExpiration();
-
-    PublicKeyChecker checker = new PublicKeyChecker().setStore(store);
-    assertNoProblems(checker, k);
-
-    checker.setEffectiveTime(parseDate("2015-07-10 12:00:00 -0400"));
-    assertNoProblems(checker, k);
-
-    checker.setEffectiveTime(parseDate("2075-07-10 12:00:00 -0400"));
-    assertProblems(checker, k, "Key is expired");
-  }
-
-  @Test
-  public void expiredKeyIsExpired() throws Exception {
-    assertProblems(expiredKey(), "Key is expired");
-  }
-
-  @Test
-  public void selfRevokedKeyIsRevoked() throws Exception {
-    assertProblems(selfRevokedKey(), "Key is revoked (key material has been compromised)");
-  }
-
-  // Test keys specific to this test are at the bottom of this class. Each test
-  // has a diagram of the trust network, where:
-  //  - The notation M---N indicates N trusts M.
-  //  - An 'x' indicates the key is expired.
-
-  @Test
-  public void trustValidPathLength2() throws Exception {
-    // A---Bx
-    //  \
-    //   \---C---D
-    //        \
-    //         \---Ex
-    //
-    // D and E trust C to be a valid introducer of depth 2.
-    TestKey ka = add(keyA());
-    TestKey kb = add(keyB());
-    TestKey kc = add(keyC());
-    TestKey kd = add(keyD());
-    TestKey ke = add(keyE());
-    save();
-
-    PublicKeyChecker checker = newChecker(2, kb, kd);
-    assertNoProblems(checker, ka);
-    assertProblems(checker, kb, "Key is expired");
-    assertNoProblems(checker, kc);
-    assertNoProblems(checker, kd);
-    assertProblems(checker, ke, "Key is expired", "No path to a trusted key");
-  }
-
-  @Test
-  public void trustValidPathLength1() throws Exception {
-    // A---Bx
-    //  \
-    //   \---C---D
-    //        \
-    //         \---Ex
-    //
-    // D and E trust C to be a valid introducer of depth 2.
-    TestKey ka = add(keyA());
-    TestKey kb = add(keyB());
-    TestKey kc = add(keyC());
-    TestKey kd = add(keyD());
-    add(keyE());
-    save();
-
-    PublicKeyChecker checker = newChecker(1, kd);
-    assertProblems(checker, ka, "No path to a trusted key", notTrusted(kb), notTrusted(kc));
-  }
-
-  @Test
-  public void trustCycle() throws Exception {
-    // F---G---F, in a cycle.
-    TestKey kf = add(keyF());
-    TestKey kg = add(keyG());
-    save();
-
-    PublicKeyChecker checker = newChecker(10, keyA());
-    assertProblems(checker, kf, "No path to a trusted key", notTrusted(kg));
-    assertProblems(checker, kg, "No path to a trusted key", notTrusted(kf));
-  }
-
-  @Test
-  public void trustInsufficientDepthInSignature() throws Exception {
-    // H---I---J, but J is only trusted to length 1.
-    TestKey kh = add(keyH());
-    TestKey ki = add(keyI());
-    add(keyJ());
-    save();
-
-    PublicKeyChecker checker = newChecker(10, keyJ());
-
-    // J trusts I to a depth of 1, so I itself is valid, but I's certification
-    // of K is not valid.
-    assertNoProblems(checker, ki);
-    assertProblems(checker, kh, "No path to a trusted key", notTrusted(ki));
-  }
-
-  @Test
-  public void revokedKeyDueToCompromise() throws Exception {
-    TestKey k = add(revokedCompromisedKey());
-    add(validKeyWithoutExpiration());
-    save();
-
-    assertProblems(k, "Key is revoked (key material has been compromised): test6 compromised");
-
-    PGPPublicKeyRing kr = removeRevokers(k.getPublicKeyRing());
-    store.add(kr);
-    save();
-
-    // Key no longer specified as revoker.
-    assertNoProblems(kr.getPublicKey());
-  }
-
-  @Test
-  public void revokedKeyDueToCompromiseRevokesKeyRetroactively() throws Exception {
-    TestKey k = add(revokedCompromisedKey());
-    add(validKeyWithoutExpiration());
-    save();
-
-    String problem = "Key is revoked (key material has been compromised): test6 compromised";
-    assertProblems(k, problem);
-
-    SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
-    PublicKeyChecker checker =
-        new PublicKeyChecker().setStore(store).setEffectiveTime(df.parse("2010-01-01 12:00:00"));
-    assertProblems(checker, k, problem);
-  }
-
-  @Test
-  public void revokedByKeyNotPresentInStore() throws Exception {
-    TestKey k = add(revokedCompromisedKey());
-    save();
-
-    assertProblems(k, "Key is revoked (key material has been compromised): test6 compromised");
-  }
-
-  @Test
-  public void revokedKeyDueToNoLongerBeingUsed() throws Exception {
-    TestKey k = add(revokedNoLongerUsedKey());
-    add(validKeyWithoutExpiration());
-    save();
-
-    assertProblems(k, "Key is revoked (retired and no longer valid): test7 not used");
-  }
-
-  @Test
-  public void revokedKeyDueToNoLongerBeingUsedDoesNotRevokeKeyRetroactively() throws Exception {
-    TestKey k = add(revokedNoLongerUsedKey());
-    add(validKeyWithoutExpiration());
-    save();
-
-    assertProblems(k, "Key is revoked (retired and no longer valid): test7 not used");
-
-    PublicKeyChecker checker =
-        new PublicKeyChecker()
-            .setStore(store)
-            .setEffectiveTime(parseDate("2010-01-01 12:00:00 -0400"));
-    assertNoProblems(checker, k);
-  }
-
-  @Test
-  public void keyRevokedByExpiredKeyAfterExpirationIsNotRevoked() throws Exception {
-    TestKey k = add(keyRevokedByExpiredKeyAfterExpiration());
-    add(expiredKey());
-    save();
-
-    PublicKeyChecker checker = new PublicKeyChecker().setStore(store);
-    assertNoProblems(checker, k);
-  }
-
-  @Test
-  public void keyRevokedByExpiredKeyBeforeExpirationIsRevoked() throws Exception {
-    TestKey k = add(keyRevokedByExpiredKeyBeforeExpiration());
-    add(expiredKey());
-    save();
-
-    PublicKeyChecker checker = new PublicKeyChecker().setStore(store);
-    assertProblems(checker, k, "Key is revoked (retired and no longer valid): test9 not used");
-
-    // Set time between key creation and revocation.
-    checker.setEffectiveTime(parseDate("2005-08-01 13:00:00 -0400"));
-    assertNoProblems(checker, k);
-  }
-
-  private PGPPublicKeyRing removeRevokers(PGPPublicKeyRing kr) {
-    PGPPublicKey k = kr.getPublicKey();
-    @SuppressWarnings("unchecked")
-    Iterator<PGPSignature> sigs = k.getSignaturesOfType(DIRECT_KEY);
-    while (sigs.hasNext()) {
-      PGPSignature sig = sigs.next();
-      if (sig.getHashedSubPackets().hasSubpacket(REVOCATION_KEY)) {
-        k = PGPPublicKey.removeCertification(k, sig);
-      }
-    }
-    return PGPPublicKeyRing.insertPublicKey(kr, k);
-  }
-
-  private PublicKeyChecker newChecker(int maxTrustDepth, TestKey... trusted) {
-    Map<Long, Fingerprint> fps = new HashMap<>();
-    for (TestKey k : trusted) {
-      Fingerprint fp = new Fingerprint(k.getPublicKey().getFingerprint());
-      fps.put(fp.getId(), fp);
-    }
-    return new PublicKeyChecker().enableTrust(maxTrustDepth, fps).setStore(store);
-  }
-
-  private TestKey add(TestKey k) {
-    store.add(k.getPublicKeyRing());
-    return k;
-  }
-
-  private void save() throws Exception {
-    PersonIdent ident = new PersonIdent("A U Thor", "author@example.com");
-    CommitBuilder cb = new CommitBuilder();
-    cb.setAuthor(ident);
-    cb.setCommitter(ident);
-    RefUpdate.Result result = store.save(cb);
-    switch (result) {
-      case NEW:
-      case FAST_FORWARD:
-      case FORCED:
-        break;
-      case IO_FAILURE:
-      case LOCK_FAILURE:
-      case NOT_ATTEMPTED:
-      case NO_CHANGE:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case RENAMED:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      default:
-        throw new AssertionError(result);
-    }
-  }
-
-  private void assertProblems(PublicKeyChecker checker, TestKey k, String first, String... rest) {
-    CheckResult result = checker.setStore(store).check(k.getPublicKey());
-    assertEquals(list(first, rest), result.getProblems());
-  }
-
-  private void assertNoProblems(PublicKeyChecker checker, TestKey k) {
-    CheckResult result = checker.setStore(store).check(k.getPublicKey());
-    assertEquals(Collections.emptyList(), result.getProblems());
-  }
-
-  private void assertProblems(TestKey tk, String first, String... rest) {
-    assertProblems(tk.getPublicKey(), first, rest);
-  }
-
-  private void assertNoProblems(TestKey tk) {
-    assertNoProblems(tk.getPublicKey());
-  }
-
-  private void assertProblems(PGPPublicKey k, String first, String... rest) {
-    CheckResult result = new PublicKeyChecker().setStore(store).check(k);
-    assertEquals(list(first, rest), result.getProblems());
-  }
-
-  private void assertNoProblems(PGPPublicKey k) {
-    CheckResult result = new PublicKeyChecker().setStore(store).check(k);
-    assertEquals(Collections.emptyList(), result.getProblems());
-  }
-
-  private static String notTrusted(TestKey k) {
-    return "Certification by "
-        + keyToString(k.getPublicKey())
-        + " is valid, but key is not trusted";
-  }
-
-  private static Date parseDate(String str) throws Exception {
-    return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").parse(str);
-  }
-
-  private static List<String> list(String first, String[] rest) {
-    List<String> all = new ArrayList<>();
-    all.add(first);
-    all.addAll(Arrays.asList(rest));
-    return all;
-  }
-}
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyStoreTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyStoreTest.java
deleted file mode 100644
index 94eff06..0000000
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyStoreTest.java
+++ /dev/null
@@ -1,250 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.gpg;
-
-import static com.google.gerrit.gpg.PublicKeyStore.REFS_GPG_KEYS;
-import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-import static com.google.gerrit.gpg.PublicKeyStore.keyObjectId;
-import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
-import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithExpiration;
-import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithSecondUserId;
-import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithoutExpiration;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-
-import com.google.gerrit.gpg.testutil.TestKey;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Set;
-import java.util.TreeSet;
-import org.bouncycastle.openpgp.PGPPublicKey;
-import org.bouncycastle.openpgp.PGPPublicKeyRing;
-import org.bouncycastle.openpgp.PGPPublicKeyRingCollection;
-import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.Before;
-import org.junit.Test;
-
-public class PublicKeyStoreTest {
-  private TestRepository<?> tr;
-  private PublicKeyStore store;
-
-  @Before
-  public void setUp() throws Exception {
-    tr = new TestRepository<>(new InMemoryRepository(new DfsRepositoryDescription("pubkeys")));
-    store = new PublicKeyStore(tr.getRepository());
-  }
-
-  @Test
-  public void testKeyIdToString() throws Exception {
-    PGPPublicKey key = validKeyWithoutExpiration().getPublicKey();
-    assertEquals("46328A8C", keyIdToString(key.getKeyID()));
-  }
-
-  @Test
-  public void testKeyToString() throws Exception {
-    PGPPublicKey key = validKeyWithoutExpiration().getPublicKey();
-    assertEquals(
-        "46328A8C Testuser One <test1@example.com>"
-            + " (04AE A7ED 2F82 1133 E5B1  28D1 ED06 25DC 4632 8A8C)",
-        keyToString(key));
-  }
-
-  @Test
-  public void testKeyObjectId() throws Exception {
-    PGPPublicKey key = validKeyWithoutExpiration().getPublicKey();
-    String objId = keyObjectId(key.getKeyID()).name();
-    assertEquals("ed0625dc46328a8c000000000000000000000000", objId);
-    assertEquals(keyIdToString(key.getKeyID()).toLowerCase(), objId.substring(8, 16));
-  }
-
-  @Test
-  public void get() throws Exception {
-    TestKey key1 = validKeyWithoutExpiration();
-    tr.branch(REFS_GPG_KEYS)
-        .commit()
-        .add(keyObjectId(key1.getKeyId()).name(), key1.getPublicKeyArmored())
-        .create();
-    TestKey key2 = validKeyWithExpiration();
-    tr.branch(REFS_GPG_KEYS)
-        .commit()
-        .add(keyObjectId(key2.getKeyId()).name(), key2.getPublicKeyArmored())
-        .create();
-
-    assertKeys(key1.getKeyId(), key1);
-    assertKeys(key2.getKeyId(), key2);
-  }
-
-  @Test
-  public void getMultiple() throws Exception {
-    TestKey key1 = validKeyWithoutExpiration();
-    TestKey key2 = validKeyWithExpiration();
-    tr.branch(REFS_GPG_KEYS)
-        .commit()
-        .add(
-            keyObjectId(key1.getKeyId()).name(),
-            key1.getPublicKeyArmored()
-                // Mismatched for this key ID, but we can still read it out.
-                + key2.getPublicKeyArmored())
-        .create();
-    assertKeys(key1.getKeyId(), key1, key2);
-  }
-
-  @Test
-  public void save() throws Exception {
-    TestKey key1 = validKeyWithoutExpiration();
-    TestKey key2 = validKeyWithExpiration();
-    store.add(key1.getPublicKeyRing());
-    store.add(key2.getPublicKeyRing());
-
-    assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
-
-    assertKeys(key1.getKeyId(), key1);
-    assertKeys(key2.getKeyId(), key2);
-  }
-
-  @Test
-  public void saveAppendsToExistingList() throws Exception {
-    TestKey key1 = validKeyWithoutExpiration();
-    TestKey key2 = validKeyWithExpiration();
-    tr.branch(REFS_GPG_KEYS)
-        .commit()
-        // Mismatched for this key ID, but we can still read it out.
-        .add(keyObjectId(key1.getKeyId()).name(), key2.getPublicKeyArmored())
-        .create();
-
-    store.add(key1.getPublicKeyRing());
-    assertEquals(RefUpdate.Result.FAST_FORWARD, store.save(newCommitBuilder()));
-
-    assertKeys(key1.getKeyId(), key1, key2);
-
-    try (ObjectReader reader = tr.getRepository().newObjectReader();
-        RevWalk rw = new RevWalk(reader)) {
-      NoteMap notes =
-          NoteMap.read(
-              reader,
-              tr.getRevWalk()
-                  .parseCommit(tr.getRepository().exactRef(REFS_GPG_KEYS).getObjectId()));
-      String contents =
-          new String(reader.open(notes.get(keyObjectId(key1.getKeyId()))).getBytes(), UTF_8);
-      String header = "-----BEGIN PGP PUBLIC KEY BLOCK-----";
-      int i1 = contents.indexOf(header);
-      assertTrue(i1 >= 0);
-      int i2 = contents.indexOf(header, i1 + header.length());
-      assertTrue(i2 >= 0);
-    }
-  }
-
-  @Test
-  public void updateExisting() throws Exception {
-    TestKey key5 = validKeyWithSecondUserId();
-    PGPPublicKeyRing keyRing = key5.getPublicKeyRing();
-    PGPPublicKey key = keyRing.getPublicKey();
-    store.add(keyRing);
-    assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
-
-    assertUserIds(
-        store.get(key5.getKeyId()).iterator().next(),
-        "Testuser Five <test5@example.com>",
-        "foo:myId");
-
-    keyRing = PGPPublicKeyRing.removePublicKey(keyRing, key);
-    key = PGPPublicKey.removeCertification(key, "foo:myId");
-    keyRing = PGPPublicKeyRing.insertPublicKey(keyRing, key);
-    store.add(keyRing);
-    assertEquals(RefUpdate.Result.FAST_FORWARD, store.save(newCommitBuilder()));
-
-    Iterator<PGPPublicKeyRing> keyRings = store.get(key.getKeyID()).iterator();
-    keyRing = keyRings.next();
-    assertFalse(keyRings.hasNext());
-    assertUserIds(keyRing, "Testuser Five <test5@example.com>");
-  }
-
-  @Test
-  public void remove() throws Exception {
-    TestKey key1 = validKeyWithoutExpiration();
-    store.add(key1.getPublicKeyRing());
-    assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
-    assertKeys(key1.getKeyId(), key1);
-
-    store.remove(key1.getPublicKey().getFingerprint());
-    assertEquals(RefUpdate.Result.FAST_FORWARD, store.save(newCommitBuilder()));
-    assertKeys(key1.getKeyId());
-  }
-
-  @Test
-  public void removeNonexisting() throws Exception {
-    TestKey key1 = validKeyWithoutExpiration();
-    store.add(key1.getPublicKeyRing());
-    assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
-
-    TestKey key2 = validKeyWithExpiration();
-    store.remove(key2.getPublicKey().getFingerprint());
-    assertEquals(RefUpdate.Result.NO_CHANGE, store.save(newCommitBuilder()));
-    assertKeys(key1.getKeyId(), key1);
-  }
-
-  @Test
-  public void addThenRemove() throws Exception {
-    TestKey key1 = validKeyWithoutExpiration();
-    store.add(key1.getPublicKeyRing());
-    store.remove(key1.getPublicKey().getFingerprint());
-    assertEquals(RefUpdate.Result.NO_CHANGE, store.save(newCommitBuilder()));
-    assertKeys(key1.getKeyId());
-  }
-
-  private void assertKeys(long keyId, TestKey... expected) throws Exception {
-    Set<String> expectedStrings = new TreeSet<>();
-    for (TestKey k : expected) {
-      expectedStrings.add(keyToString(k.getPublicKey()));
-    }
-    PGPPublicKeyRingCollection actual = store.get(keyId);
-    Set<String> actualStrings = new TreeSet<>();
-    for (PGPPublicKeyRing k : actual) {
-      actualStrings.add(keyToString(k.getPublicKey()));
-    }
-    assertEquals(expectedStrings, actualStrings);
-  }
-
-  private void assertUserIds(PGPPublicKeyRing keyRing, String... expected) throws Exception {
-    List<String> actual = new ArrayList<>();
-    Iterator<String> userIds =
-        store.get(keyRing.getPublicKey().getKeyID()).iterator().next().getPublicKey().getUserIDs();
-    while (userIds.hasNext()) {
-      actual.add(userIds.next());
-    }
-
-    assertEquals(Arrays.asList(expected), actual);
-  }
-
-  private CommitBuilder newCommitBuilder() {
-    CommitBuilder cb = new CommitBuilder();
-    PersonIdent ident = new PersonIdent("A U Thor", "author@example.com");
-    cb.setAuthor(ident);
-    cb.setCommitter(ident);
-    return cb;
-  }
-}
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PushCertificateCheckerTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PushCertificateCheckerTest.java
deleted file mode 100644
index 8b7900d..0000000
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PushCertificateCheckerTest.java
+++ /dev/null
@@ -1,204 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.gpg;
-
-import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
-import static com.google.gerrit.gpg.testutil.TestKeys.expiredKey;
-import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithExpiration;
-import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithoutExpiration;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.junit.Assert.assertEquals;
-
-import com.google.gerrit.gpg.testutil.TestKey;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
-import org.bouncycastle.bcpg.ArmoredOutputStream;
-import org.bouncycastle.bcpg.BCPGOutputStream;
-import org.bouncycastle.openpgp.PGPSignature;
-import org.bouncycastle.openpgp.PGPSignatureGenerator;
-import org.bouncycastle.openpgp.PGPSignatureSubpacketGenerator;
-import org.bouncycastle.openpgp.PGPUtil;
-import org.bouncycastle.openpgp.operator.bc.BcPGPContentSignerBuilder;
-import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.PushCertificate;
-import org.eclipse.jgit.transport.PushCertificateIdent;
-import org.eclipse.jgit.transport.PushCertificateParser;
-import org.eclipse.jgit.transport.SignedPushConfig;
-import org.junit.Before;
-import org.junit.Test;
-
-public class PushCertificateCheckerTest {
-  private InMemoryRepository repo;
-  private PublicKeyStore store;
-  private SignedPushConfig signedPushConfig;
-  private PushCertificateChecker checker;
-
-  @Before
-  public void setUp() throws Exception {
-    TestKey key1 = validKeyWithoutExpiration();
-    TestKey key3 = expiredKey();
-    repo = new InMemoryRepository(new DfsRepositoryDescription("repo"));
-    store = new PublicKeyStore(repo);
-    store.add(key1.getPublicKeyRing());
-    store.add(key3.getPublicKeyRing());
-
-    PersonIdent ident = new PersonIdent("A U Thor", "author@example.com");
-    CommitBuilder cb = new CommitBuilder();
-    cb.setAuthor(ident);
-    cb.setCommitter(ident);
-    assertEquals(RefUpdate.Result.NEW, store.save(cb));
-
-    signedPushConfig = new SignedPushConfig();
-    signedPushConfig.setCertNonceSeed("sekret");
-    signedPushConfig.setCertNonceSlopLimit(60 * 24);
-    checker = newChecker(true);
-  }
-
-  private PushCertificateChecker newChecker(boolean checkNonce) {
-    PublicKeyChecker keyChecker = new PublicKeyChecker().setStore(store);
-    return new PushCertificateChecker(keyChecker) {
-      @Override
-      protected Repository getRepository() {
-        return repo;
-      }
-
-      @Override
-      protected boolean shouldClose(Repository repo) {
-        return false;
-      }
-    }.setCheckNonce(checkNonce);
-  }
-
-  @Test
-  public void validCert() throws Exception {
-    PushCertificate cert = newSignedCert(validNonce(), validKeyWithoutExpiration());
-    assertNoProblems(cert);
-  }
-
-  @Test
-  public void invalidNonce() throws Exception {
-    PushCertificate cert = newSignedCert("invalid-nonce", validKeyWithoutExpiration());
-    assertProblems(cert, "Invalid nonce");
-  }
-
-  @Test
-  public void invalidNonceNotChecked() throws Exception {
-    checker = newChecker(false);
-    PushCertificate cert = newSignedCert("invalid-nonce", validKeyWithoutExpiration());
-    assertNoProblems(cert);
-  }
-
-  @Test
-  public void missingKey() throws Exception {
-    TestKey key2 = validKeyWithExpiration();
-    PushCertificate cert = newSignedCert(validNonce(), key2);
-    assertProblems(cert, "No public keys found for key ID " + keyIdToString(key2.getKeyId()));
-  }
-
-  @Test
-  public void invalidKey() throws Exception {
-    TestKey key3 = expiredKey();
-    PushCertificate cert = newSignedCert(validNonce(), key3);
-    assertProblems(
-        cert, "Invalid public key " + keyToString(key3.getPublicKey()) + ":\n  Key is expired");
-  }
-
-  @Test
-  public void signatureByExpiredKeyBeforeExpiration() throws Exception {
-    TestKey key3 = expiredKey();
-    Date now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").parse("2005-07-10 12:00:00 -0400");
-    PushCertificate cert = newSignedCert(validNonce(), key3, now);
-    assertNoProblems(cert);
-  }
-
-  private String validNonce() {
-    return signedPushConfig
-        .getNonceGenerator()
-        .createNonce(repo, System.currentTimeMillis() / 1000);
-  }
-
-  private PushCertificate newSignedCert(String nonce, TestKey signingKey) throws Exception {
-    return newSignedCert(nonce, signingKey, null);
-  }
-
-  private PushCertificate newSignedCert(String nonce, TestKey signingKey, Date now)
-      throws Exception {
-    PushCertificateIdent ident =
-        new PushCertificateIdent(signingKey.getFirstUserId(), System.currentTimeMillis(), -7 * 60);
-    String payload =
-        "certificate version 0.1\n"
-            + "pusher "
-            + ident.getRaw()
-            + "\n"
-            + "pushee test://localhost/repo.git\n"
-            + "nonce "
-            + nonce
-            + "\n"
-            + "\n"
-            + "0000000000000000000000000000000000000000"
-            + " deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
-            + " refs/heads/master\n";
-    PGPSignatureGenerator gen =
-        new PGPSignatureGenerator(
-            new BcPGPContentSignerBuilder(signingKey.getPublicKey().getAlgorithm(), PGPUtil.SHA1));
-
-    if (now != null) {
-      PGPSignatureSubpacketGenerator subGen = new PGPSignatureSubpacketGenerator();
-      subGen.setSignatureCreationTime(false, now);
-      gen.setHashedSubpackets(subGen.generate());
-    }
-
-    gen.init(PGPSignature.BINARY_DOCUMENT, signingKey.getPrivateKey());
-    gen.update(payload.getBytes(UTF_8));
-    PGPSignature sig = gen.generate();
-
-    ByteArrayOutputStream bout = new ByteArrayOutputStream();
-    try (BCPGOutputStream out = new BCPGOutputStream(new ArmoredOutputStream(bout))) {
-      sig.encode(out);
-    }
-
-    String cert = payload + new String(bout.toByteArray(), UTF_8);
-    Reader reader = new InputStreamReader(new ByteArrayInputStream(cert.getBytes(UTF_8)));
-    PushCertificateParser parser = new PushCertificateParser(repo, signedPushConfig);
-    return parser.parse(reader);
-  }
-
-  private void assertProblems(PushCertificate cert, String first, String... rest) throws Exception {
-    List<String> expected = new ArrayList<>();
-    expected.add(first);
-    expected.addAll(Arrays.asList(rest));
-    CheckResult result = checker.check(cert).getCheckResult();
-    assertEquals(expected, result.getProblems());
-  }
-
-  private void assertNoProblems(PushCertificate cert) {
-    CheckResult result = checker.check(cert).getCheckResult();
-    assertEquals(Collections.emptyList(), result.getProblems());
-  }
-}
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestKey.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestKey.java
deleted file mode 100644
index b2ef65d..0000000
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestKey.java
+++ /dev/null
@@ -1,94 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.gpg.testutil;
-
-import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import org.bouncycastle.bcpg.ArmoredInputStream;
-import org.bouncycastle.openpgp.PGPException;
-import org.bouncycastle.openpgp.PGPPrivateKey;
-import org.bouncycastle.openpgp.PGPPublicKey;
-import org.bouncycastle.openpgp.PGPPublicKeyRing;
-import org.bouncycastle.openpgp.PGPSecretKey;
-import org.bouncycastle.openpgp.PGPSecretKeyRing;
-import org.bouncycastle.openpgp.operator.bc.BcKeyFingerprintCalculator;
-import org.bouncycastle.openpgp.operator.bc.BcPBESecretKeyDecryptorBuilder;
-import org.bouncycastle.openpgp.operator.bc.BcPGPDigestCalculatorProvider;
-import org.eclipse.jgit.lib.Constants;
-
-public class TestKey {
-  private final String pubArmored;
-  private final String secArmored;
-  private final PGPPublicKeyRing pubRing;
-  private final PGPSecretKeyRing secRing;
-
-  public TestKey(String pubArmored, String secArmored) {
-    this.pubArmored = pubArmored;
-    this.secArmored = secArmored;
-    BcKeyFingerprintCalculator fc = new BcKeyFingerprintCalculator();
-    try {
-      this.pubRing = new PGPPublicKeyRing(newStream(pubArmored), fc);
-      this.secRing = new PGPSecretKeyRing(newStream(secArmored), fc);
-    } catch (PGPException | IOException e) {
-      throw new AssertionError(e);
-    }
-  }
-
-  public String getPublicKeyArmored() {
-    return pubArmored;
-  }
-
-  public String getSecretKeyArmored() {
-    return secArmored;
-  }
-
-  public PGPPublicKeyRing getPublicKeyRing() {
-    return pubRing;
-  }
-
-  public PGPPublicKey getPublicKey() {
-    return pubRing.getPublicKey();
-  }
-
-  public PGPSecretKey getSecretKey() {
-    return secRing.getSecretKey();
-  }
-
-  public long getKeyId() {
-    return getPublicKey().getKeyID();
-  }
-
-  public String getKeyIdString() {
-    return keyIdToString(getPublicKey().getKeyID());
-  }
-
-  public String getFirstUserId() {
-    return getPublicKey().getUserIDs().next();
-  }
-
-  public PGPPrivateKey getPrivateKey() throws PGPException {
-    return getSecretKey()
-        .extractPrivateKey(
-            new BcPBESecretKeyDecryptorBuilder(new BcPGPDigestCalculatorProvider())
-                // All test keys have no passphrase.
-                .build(new char[0]));
-  }
-
-  private static ArmoredInputStream newStream(String armored) throws IOException {
-    return new ArmoredInputStream(new ByteArrayInputStream(Constants.encode(armored)));
-  }
-}
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestKeys.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestKeys.java
deleted file mode 100644
index 82d7ada..0000000
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestKeys.java
+++ /dev/null
@@ -1,1032 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.gpg.testutil;
-
-import com.google.common.collect.ImmutableList;
-
-/** Common test keys used by a variety of tests. */
-public class TestKeys {
-  public static ImmutableList<TestKey> allValidKeys() {
-    return ImmutableList.of(
-        validKeyWithoutExpiration(), validKeyWithExpiration(), validKeyWithSecondUserId());
-  }
-
-  /**
-   * A valid key with no expiration.
-   *
-   * <pre>
-   * pub   2048R/46328A8C 2015-07-08
-   *       Key fingerprint = 04AE A7ED 2F82 1133 E5B1  28D1 ED06 25DC 4632 8A8C
-   * uid                  Testuser One &lt;test1@example.com&gt;
-   * sub   2048R/F0AF69C0 2015-07-08
-   * </pre>
-   */
-  public static TestKey validKeyWithoutExpiration() {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBFWdTIkBCADOaygDKjLuRX6LXAvBAYB91cmTf1MSlmEy+qsG3c9ijjQixPkr\n"
-            + "atdYkocrrT2S0R9UGjksTOI2WN5S0lQfLA1RSk63KURQE+OF+IfNqdD6nQdLBs1w\n"
-            + "va+GDj/uvuI05I0oXf/M7POdFphutrS4EUDBnFPj6ns/0C2sTRTxliD+Y9Y9a84V\n"
-            + "DfVVUbJB6wc3LP3L6ImT+cSM7dLq3hZHya+9FNeYPmPYnBrkJyqf2NDd38Sddsro\n"
-            + "7smw/GgCZHnnuVNS4C7NsHr6900VKC+JDtdx+fqptixcAEJWiGoQfWqU+hYmia3p\n"
-            + "9+Xw02+3FcjOT6ONUCmHX+xlz0pXW4iIYlPpABEBAAG0IFRlc3R1c2VyIE9uZSA8\n"
-            + "dGVzdDFAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJVnUyJAhsDBgsJCAcDAgYVCAIJ\n"
-            + "CgsEFgIDAQIeAQIXgAAKCRDtBiXcRjKKjHblB/9RaFO5+GTDIphAL/aVj2u+d8Lq\n"
-            + "yUpBrDp3P06QDGpKGFMAovBuh+NLH76VKNIzQLQC8rdTj651fLcLMuJ1enQ3Rblg\n"
-            + "RKr1oc+wqqtFHr4QyOQjE/N3C9GQjEzfqn4qnp5KtZxYFnlvU5NGehid7M1HTZMx\n"
-            + "jRcHbM9KQnsE5Z4fh4wmN5ynG+5nbaF4O9otPOpFzYRvIhxFmHscWyOgRaMZiYEX\n"
-            + "7Qkzze+scAlc9E/EWRJQIFcxnxV/SYIT4qCTT1g2aKA8OCBO/ZTOleH8SzvTODjy\n"
-            + "W0lGHnh/ZqH6XGVcGUaJZZ2uHTck1+czuVVShNcXPW1W20T6E9UqzHbJHN0guQEN\n"
-            + "BFWdTIkBCACoLVdPr3gpQwzI+2NGXjdtoyqYoPlgfeyI2M1XQD/7+rLZTbi14ZjN\n"
-            + "vYkS/+/oGtVEmiYOiAVTwmkjCYkKGDgNcCiJVekiPAN6JryVv488wRc999b5LpFE\n"
-            + "fhLGwI0YxjcS4KFFnpMC3wSb6tJUnHRLVoE5d8icdiaOpgYdp7uqWkSx2oxqHgIb\n"
-            + "nuyrk3ydEcS4ZeGD+w+taIxMc9F1DS9kiXALD7xWgUkmqZLEQoNgF6KlwCHXRd3m\n"
-            + "rBCo97sE95yKcq98ZMIWuQtTcEccZsN/6jlsei+9RI0tqs+FbZnIFm/go9zk11Vl\n"
-            + "IQ9QFSj6ruqoKrYvNZuDDLD1lHvZPD4/ABEBAAGJAR8EGAECAAkFAlWdTIkCGwwA\n"
-            + "CgkQ7QYl3EYyiox+HAf/Z/OCQO3jxALAcn3oUb1g/IlHm6qZv7RJOFUsj/16fGiF\n"
-            + "rRTP15zMXzyqV+L/LGV/owvOsdD/o7boZz4C/U98COx0Nl1jOrmPATOl+xqsgpEj\n"
-            + "Fhk+eAR7exO2XxW+u2g4cYoSMosIOX5w1GrdsxQeaZDwiSJMEOR2cVLs3YI19Ci/\n"
-            + "FuzActZ0wJNk0nlNF6l8CAbzwN6pM9OIc/iBIwDjz92KUco0NF8XKZnxqhH4wfHB\n"
-            + "PGkTx8RwOvELUTDMtvYnG5R0QtND0RbOnmp4ZVZmeOjKSLo1mZliUZB1H2PPSxrA\n"
-            + "0oLr8+wLntz1SU7uS4ddvhSQW+j2M/0pa352KUwmrw==\n"
-            + "=o/aU\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBFWdTIkBCADOaygDKjLuRX6LXAvBAYB91cmTf1MSlmEy+qsG3c9ijjQixPkr\n"
-            + "atdYkocrrT2S0R9UGjksTOI2WN5S0lQfLA1RSk63KURQE+OF+IfNqdD6nQdLBs1w\n"
-            + "va+GDj/uvuI05I0oXf/M7POdFphutrS4EUDBnFPj6ns/0C2sTRTxliD+Y9Y9a84V\n"
-            + "DfVVUbJB6wc3LP3L6ImT+cSM7dLq3hZHya+9FNeYPmPYnBrkJyqf2NDd38Sddsro\n"
-            + "7smw/GgCZHnnuVNS4C7NsHr6900VKC+JDtdx+fqptixcAEJWiGoQfWqU+hYmia3p\n"
-            + "9+Xw02+3FcjOT6ONUCmHX+xlz0pXW4iIYlPpABEBAAEAB/wLoOXEJ+Buo+OZHjpb\n"
-            + "SSZf8GdGs+mOJoKbSJvR6zT/rFsrikUvOPmgt8B9qWjKmJVXO5L09+/Wd/MuX0L1\n"
-            + "7plhdvowP1bl2/j5VyLvZx2qwKXkiCGStFzrBGp9nKtJp4Z8O69pb//ZXaiAtDJC\n"
-            + "HFa1kYT4VgFTevrXtg/z/C0np4Yjx0mZpw4nfISEeHCiYCyRa/B8R1+Pc4uIcoSo\n"
-            + "G3aq6Ow9m/LGvw0MRO5qHvqoF41TLPQpGKjKEsCBKHF1qh0tOOUHnLGrvbmdFnGr\n"
-            + "UXJpRkLdRTnj8ufvA4XVZhImzL+lD+ALtjlV14xh8nsNKYL42880GFl5Cl0OtBcE\n"
-            + "lgQBBADPJ6kHdvUYOe0zugRdukBSYLkZcYwRiphom7dZuavYICIu6B14ljEONzVD\n"
-            + "mPhi2lDOawZOURKwYd9S4K11XWLsTYe7XEwkc+1Fpvu4L/JqnJTTnnvbx05ZsqD5\n"
-            + "j9tybPlrTuLrf2ctfcC03Z55wfo6azsbf89yrr6QX0+l9dlkYQQA/xcMdQJ0Z5vm\n"
-            + "kvyaCPsQzJc/8noVO9PMv7xJm14gJWK7Px3y2eBidzpCbVVFnGWW6CPb3qKerB5U\n"
-            + "pwcF4gCFWyP9C2YtnB0hgqixIPfR+UO8gpqdY6MP8NPspoXouffRn+Zic/P6Cxje\n"
-            + "/MGxNQBeRtqb2IGh1xZ8v/8tmmmxHIkEAP74HkGETcXmlj3/6RlwTBUAovPARSn7\n"
-            + "LDtOCPezg6mQmble1BvnTnAwOHKJVqjx+3qsGqMe8OGGXAxZPSU1xSmOShBFrpDp\n"
-            + "xArE67arE17pT1lyD/gmHRuqnNMvgRrwz1mDm3G2ohWkCVixEiB+8vPQfbZrJBgQ\n"
-            + "WxOF4RCo2WWyRKa0IFRlc3R1c2VyIE9uZSA8dGVzdDFAZXhhbXBsZS5jb20+iQE4\n"
-            + "BBMBAgAiBQJVnUyJAhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRDtBiXc\n"
-            + "RjKKjHblB/9RaFO5+GTDIphAL/aVj2u+d8LqyUpBrDp3P06QDGpKGFMAovBuh+NL\n"
-            + "H76VKNIzQLQC8rdTj651fLcLMuJ1enQ3RblgRKr1oc+wqqtFHr4QyOQjE/N3C9GQ\n"
-            + "jEzfqn4qnp5KtZxYFnlvU5NGehid7M1HTZMxjRcHbM9KQnsE5Z4fh4wmN5ynG+5n\n"
-            + "baF4O9otPOpFzYRvIhxFmHscWyOgRaMZiYEX7Qkzze+scAlc9E/EWRJQIFcxnxV/\n"
-            + "SYIT4qCTT1g2aKA8OCBO/ZTOleH8SzvTODjyW0lGHnh/ZqH6XGVcGUaJZZ2uHTck\n"
-            + "1+czuVVShNcXPW1W20T6E9UqzHbJHN0gnQOYBFWdTIkBCACoLVdPr3gpQwzI+2NG\n"
-            + "XjdtoyqYoPlgfeyI2M1XQD/7+rLZTbi14ZjNvYkS/+/oGtVEmiYOiAVTwmkjCYkK\n"
-            + "GDgNcCiJVekiPAN6JryVv488wRc999b5LpFEfhLGwI0YxjcS4KFFnpMC3wSb6tJU\n"
-            + "nHRLVoE5d8icdiaOpgYdp7uqWkSx2oxqHgIbnuyrk3ydEcS4ZeGD+w+taIxMc9F1\n"
-            + "DS9kiXALD7xWgUkmqZLEQoNgF6KlwCHXRd3mrBCo97sE95yKcq98ZMIWuQtTcEcc\n"
-            + "ZsN/6jlsei+9RI0tqs+FbZnIFm/go9zk11VlIQ9QFSj6ruqoKrYvNZuDDLD1lHvZ\n"
-            + "PD4/ABEBAAEAB/4kQnJauehcbRpqktjaqSGmP9HFSp+50CyZbLUJJM8m0uyQsZMr\n"
-            + "k9JQOZc+Q3RERNTKj7m41Fbhsj7c0Qd856/eJdp3kdBME0hko8lxN/X4EWGjeLYe\n"
-            + "z41+iPgfZhCF0Oa66TecPQ5RRihGPaDPoVPpkmMWMt9L7KVviBg1eJ6bobVIY5hu\n"
-            + "a7KFJHZQcCI1OvdJ0cx89KDSbnH8iMM6Kmw1bE3D2FEaWctuKLBo5PNRgyTJvdBd\n"
-            + "PSf56/Rc6csPqmOntQi2Yn8n47eCOTclHNuygSTJeHPpymVuWbhMq6fhJat/xA+V\n"
-            + "kyT8I2c45RQb0dKId+wEytjbKw8AI6Q3GXqhBADOhsr9M+JWc4MpD43mCDZACN4v\n"
-            + "RBRxSrJvO/V6HqQPmKYRmr9Gk3vxgF0zCf5zB1QeBiXpTpShxV87RIbUYReOyavp\n"
-            + "87zH6/SkRxQJiBEpQh5Fu5CoAaxGOivxbPqdWHrBY6jvqkrRoMPNiFJ6/ty5w9jx\n"
-            + "i9kGm9PelQGu2SdLNwQA0HbGo8sC8h5TSTEDCkFHRYzVYONx+32AlkCsJX9mEt0E\n"
-            + "nG8d97Ay24JsbnuXSq04FJrqzjOVyHLUffpXnAGELJZVNCIparSyqIaj43UG/oPc\n"
-            + "ICPmR7zI9G49ICUPSzI7+S2+BwjbiHRQcP0zmxbH92G4abYwKfk7dsDpGyVM+TkD\n"
-            + "/2nUiV0CRqnGipeiLWNjW/Md0ufkwqBvCWxrtxj0rQCyvBOVg3B6DocVNzgOOYa1\n"
-            + "ji3We5A9mSP40JBmMfk2veFrDdsGn4G+OpzMxKQtNfYemqjALfZ2zTdax0mXPXy6\n"
-            + "Gl0jUgSGrxGm8QnRLsrRx7G7ZKnvkcS+YsdQ8dbtzvJtQfiJAR8EGAECAAkFAlWd\n"
-            + "TIkCGwwACgkQ7QYl3EYyiox+HAf/Z/OCQO3jxALAcn3oUb1g/IlHm6qZv7RJOFUs\n"
-            + "j/16fGiFrRTP15zMXzyqV+L/LGV/owvOsdD/o7boZz4C/U98COx0Nl1jOrmPATOl\n"
-            + "+xqsgpEjFhk+eAR7exO2XxW+u2g4cYoSMosIOX5w1GrdsxQeaZDwiSJMEOR2cVLs\n"
-            + "3YI19Ci/FuzActZ0wJNk0nlNF6l8CAbzwN6pM9OIc/iBIwDjz92KUco0NF8XKZnx\n"
-            + "qhH4wfHBPGkTx8RwOvELUTDMtvYnG5R0QtND0RbOnmp4ZVZmeOjKSLo1mZliUZB1\n"
-            + "H2PPSxrA0oLr8+wLntz1SU7uS4ddvhSQW+j2M/0pa352KUwmrw==\n"
-            + "=MuAn\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * A valid key expiring in 2065.
-   *
-   * <pre>
-   * pub   2048R/378A0AED 2015-07-08 [expires: 2065-06-25]
-   *       Key fingerprint = C378 369A CBCD 34CC 138D  90B1 4531 1A6F 378A 0AED
-   * uid                  Testuser Two &lt;test2@example.com&gt;
-   * sub   2048R/46D4F204 2015-07-08 [expires: 2065-06-25]
-   * </pre>
-   */
-  public static final TestKey validKeyWithExpiration() {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBFWdTP8BCADRxNpasIv0jtNXTK6VYIS2VJ2Xk0ZD6gtxeoXCpjQ+TsB9fxh3\n"
-            + "vAMPt2Zu5LqoGwygKOJj1zquG8xk7GUCCHJk3+qG8xxB1xGtSz2vLyfRm7fOZmHj\n"
-            + "3W/C/25lynSPDrfvcwvwA4PN8iP5EWbWU10L6WOZGMwwwtVDUSEouSOw2LEepxLV\n"
-            + "rkKuZcyHaivheDbUlZliwe9rGXd4hh1h4qyNQWG3q+ytlL28sVkOzUh6IMBTvqhe\n"
-            + "IRsvxvaVSLV8jRVKfUTqw0g57ft4ZD2/L46yUTXzr9aUCBjTNxvWLlyboqql/D8P\n"
-            + "inp51h3cvAg7NW5RdG1GEYmylH8SygT5utPxABEBAAG0IFRlc3R1c2VyIFR3byA8\n"
-            + "dGVzdDJAZXhhbXBsZS5jb20+iQE+BBMBAgAoBQJVnUz/AhsDBQld/A8ABgsJCAcD\n"
-            + "AgYVCAIJCgsEFgIDAQIeAQIXgAAKCRBFMRpvN4oK7UZqCACWwQL/YvBK4b0m+R0d\n"
-            + "UdvAXeBx7DwOAnAodis9ZVqChb7RxcZQxF1Ti9mtCBPPQGuEs5wE2Ocrrq+L13r6\n"
-            + "bgW+1WOB1tZSDVxwL1PnZFw/SyADRIDCZrOHiAkp82UnZwWAkk39GzNJtt1wTYDZ\n"
-            + "FMTFUr2SPscXk1k7muS+ZfEFwNPD4tODo/poJKDYEJ80Z5UXXFQLDtsfdeIXMFIT\n"
-            + "449CYoq8XBMBfvyWl/LLpw0r3JI6pV/YdH3Oeuz8XkkEVzRxaxB6Zmeo5jSwjR/T\n"
-            + "8TKDGwwiuwiiT3SfkFSVdcjKulRuXSRNs1Ouf7/UC3cq4bG2WXWa85X1+HQRm7iu\n"
-            + "RHSOuQENBFWdTP8BCADhhGxAA0pX5yBHwIgM1j0gw2h5nSsopDrO6t/sbRUcNxnR\n"
-            + "tBScgKZnP0sjRTYEUIwmZuseHMBohtVCuMaDt06qyZDvDk/98j3AeE5t2dgFnOIe\n"
-            + "qCrm/6aejbFcQOpxe6U29KJRCAxuwNtB15X1VH1Kj7B0gRSTu13n/5sUsi2lunoZ\n"
-            + "oIvpIe9tZH4aXitCY2MCQH+hTyCyNBzlEa44kWz6LxUsPdo7I6rXkTr6Ot7wQh+9\n"
-            + "7HCe042GIq65h0apgujyjhJidjch5ur1mngaSNSEyvbji2MGC+cd3wAIstG5a7xP\n"
-            + "d9MncY5Q/eH+hn96694k5bckottSyGm/3f2Ihfj1ABEBAAGJASUEGAECAA8FAlWd\n"
-            + "TP8CGwwFCV38DwAACgkQRTEabzeKCu1FNwgAif4eK2v7R3QubL2S6wmb1nsgRMgV\n"
-            + "YoxGBeUk2EK6WZ5IPor93ySd0ixRVNMRmJ8BLH3EMjZQTzkDG+BH6zFyxo6lLHw9\n"
-            + "NxQjI06tqQWgyyK0mEweVwB/zqtxiB4lNUpsNbqOZWnBJ3d6o1SsnD2Q3uwvP5fb\n"
-            + "fSIgdmUk3c0VMdgA+KzWjPD/PJIPujE+ckHhjn5cbDNw35/FuyhkLJfqlOG7SPvM\n"
-            + "NmCdJ1Pcqju9t7sf6b0BGPDOCL4gpuWKK7HJz9WxngNb3FSziLbyPLk13ynADO+v\n"
-            + "EOR44LPyXE9kVxPusazsXlt9ayTOhELhwzw7sGFFu8E17Cpn7GnVj3tN9A==\n"
-            + "=1e/A\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBFWdTP8BCADRxNpasIv0jtNXTK6VYIS2VJ2Xk0ZD6gtxeoXCpjQ+TsB9fxh3\n"
-            + "vAMPt2Zu5LqoGwygKOJj1zquG8xk7GUCCHJk3+qG8xxB1xGtSz2vLyfRm7fOZmHj\n"
-            + "3W/C/25lynSPDrfvcwvwA4PN8iP5EWbWU10L6WOZGMwwwtVDUSEouSOw2LEepxLV\n"
-            + "rkKuZcyHaivheDbUlZliwe9rGXd4hh1h4qyNQWG3q+ytlL28sVkOzUh6IMBTvqhe\n"
-            + "IRsvxvaVSLV8jRVKfUTqw0g57ft4ZD2/L46yUTXzr9aUCBjTNxvWLlyboqql/D8P\n"
-            + "inp51h3cvAg7NW5RdG1GEYmylH8SygT5utPxABEBAAEAB/0WW33OVqzEBwj9b/3X\n"
-            + "i+75I/Gb+yVtDZ/km2NwSJie33PirE4mTNKitTBkt1oxmphw5Yqji4gEkI/rXcqy\n"
-            + "OcY/fCIZ+gVT+yE2MCPF7Se4Tnl7tSvPxoUn6mOQ09AygyYVjlSCY02EAL/WxwUH\n"
-            + "6OCs6VYlNiBlPg7O2vHGzlzAd1aMmlG3ytlhb0SIbilaJn/wlQ2SEGySjIAP1qRH\n"
-            + "UXsTfW7oAjdqAY1CbCWg/0FnMBF+DnChH634dbLrS2OefcB70l61trEfRcHbMNTv\n"
-            + "9nVxDDCpaIdxsOfgWpe0GMG1qddRAxBIOVjNUFOL22xEFyaXnt/uagUtKQ7yejci\n"
-            + "bgTFBADcuhsfQaBX1G095iG2qr8Rx2T5GqNf9oZA+rbweWegqIH7MUXHI1KKwwJx\n"
-            + "C+rR5AgnxTSP614XI/AWB/txdelm8z0jLobpS6B1vzM2vRQ7hpwjJ3UvUkoQ5uYL\n"
-            + "DjaBqQi0w1cPJA79H0Yujc1zgdhATymz0uDL1BC2bHLIMuhelwQA80p07G1w8HLQ\n"
-            + "bTdgNwtDBMKIw39/ZyQy8ppxmpD4J6zf25r95g3er0r+njrHsa+72LnvexbedpKA\n"
-            + "4eiDJPN+l5jJOEWfL2WtGcqJ01bdFBPcl73tuwDJJtieUlKZH0jRjykuuUX8F+tJ\n"
-            + "yrmVoIGtawoeLKq3hMMOK4xi+sh3OrcD+wXIU24eO3YfUde5bhyaQplNMU5smIU0\n"
-            + "+looOEmFsZcTONgoN+FKrnm2TY9d4FHZ+QgtnksWHmmLxQJPtp9rHJ5BgdxMBPcK\n"
-            + "3w5GXRuWlOmqmnAb6vp0Q0yzVDLKCcwba0S23m3tbjZsLDcI7MG/knsp9gtL676D\n"
-            + "AsrpeF2+Apj0OwG0IFRlc3R1c2VyIFR3byA8dGVzdDJAZXhhbXBsZS5jb20+iQE+\n"
-            + "BBMBAgAoBQJVnUz/AhsDBQld/A8ABgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAK\n"
-            + "CRBFMRpvN4oK7UZqCACWwQL/YvBK4b0m+R0dUdvAXeBx7DwOAnAodis9ZVqChb7R\n"
-            + "xcZQxF1Ti9mtCBPPQGuEs5wE2Ocrrq+L13r6bgW+1WOB1tZSDVxwL1PnZFw/SyAD\n"
-            + "RIDCZrOHiAkp82UnZwWAkk39GzNJtt1wTYDZFMTFUr2SPscXk1k7muS+ZfEFwNPD\n"
-            + "4tODo/poJKDYEJ80Z5UXXFQLDtsfdeIXMFIT449CYoq8XBMBfvyWl/LLpw0r3JI6\n"
-            + "pV/YdH3Oeuz8XkkEVzRxaxB6Zmeo5jSwjR/T8TKDGwwiuwiiT3SfkFSVdcjKulRu\n"
-            + "XSRNs1Ouf7/UC3cq4bG2WXWa85X1+HQRm7iuRHSOnQOYBFWdTP8BCADhhGxAA0pX\n"
-            + "5yBHwIgM1j0gw2h5nSsopDrO6t/sbRUcNxnRtBScgKZnP0sjRTYEUIwmZuseHMBo\n"
-            + "htVCuMaDt06qyZDvDk/98j3AeE5t2dgFnOIeqCrm/6aejbFcQOpxe6U29KJRCAxu\n"
-            + "wNtB15X1VH1Kj7B0gRSTu13n/5sUsi2lunoZoIvpIe9tZH4aXitCY2MCQH+hTyCy\n"
-            + "NBzlEa44kWz6LxUsPdo7I6rXkTr6Ot7wQh+97HCe042GIq65h0apgujyjhJidjch\n"
-            + "5ur1mngaSNSEyvbji2MGC+cd3wAIstG5a7xPd9MncY5Q/eH+hn96694k5bckottS\n"
-            + "yGm/3f2Ihfj1ABEBAAEAB/wP5H+mcTTrhe+57sEHuo9bQDocG+3fMtesHlRCept6\n"
-            + "vg1VQG4Va2GOtCCs7yMz4aNGz4jxOdB7bUkZJyFiRehG0+ahWi5b9JbSegf46Nm2\n"
-            + "54vt4icH2WtaEB04JaD/91k4yrunnzwVEAVDmhhIzjf4KbEjPLeBA7rF7zb0Gexq\n"
-            + "mdxEGO/6KdeQ6KOxkpWEqIIdl/mAGsYCprHeKL/XL+KXYr92nEbUcltmt59TTnoo\n"
-            + "00BQCPuHCdpcUd5nuaxpCZLM+BEpxtj0sinz0ofuWU9RI4K00R01MKXWMucdOhTZ\n"
-            + "kUy5dMx8wA07xbjkE/nH86N76Mty133OB7G3lBBDfO4PBADulfLzbjXUnS1kTKeP\n"
-            + "j/HF1E9qafzTDS/QD55OVajDq66A6zaOazKbURHNZmIqpLO4715+iNtrZQUEP3e1\n"
-            + "mwngeizvAv9luA9kJ1YDTCfsS5H5cYzavhfwuqBu7fQBm/PQqZplQuPCxgXEIBaY\n"
-            + "M0uvR0I/FSwFrepRN2IA6dAkrwQA8fpJEg8C9OLFzDf0rxV3eWwEelemN4E50Obu\n"
-            + "nxtg9IJWZ+QIWkRVLJ8if5+p85s2ieCw8hzEF0FyNfWUnfW5eoN4/j50loR4EbZS\n"
-            + "qOpUJGwr8ezyQN8PpduDOe9OQnUYAv9FY9Rk46L4937GDF2w5gdxyNdKO8yG+Z3A\n"
-            + "6/0DLZsEAOQsRUXIl1XLjkdugfFQ8V9Fv3AYWJt+8zknwcQ+Z3uOtyY2muCi9hX2\n"
-            + "BtuPojjwmN6x8wntMaUkzYHVSdz/cdx+na7VNS2kZHfnECWZGR6IHyRTJN5612yi\n"
-            + "e4MIdTE+BgL1HPq+VIPlMBehEksC5qM0WSq8baMsacGMYeAL8ntoRuyJASUEGAEC\n"
-            + "AA8FAlWdTP8CGwwFCV38DwAACgkQRTEabzeKCu1FNwgAif4eK2v7R3QubL2S6wmb\n"
-            + "1nsgRMgVYoxGBeUk2EK6WZ5IPor93ySd0ixRVNMRmJ8BLH3EMjZQTzkDG+BH6zFy\n"
-            + "xo6lLHw9NxQjI06tqQWgyyK0mEweVwB/zqtxiB4lNUpsNbqOZWnBJ3d6o1SsnD2Q\n"
-            + "3uwvP5fbfSIgdmUk3c0VMdgA+KzWjPD/PJIPujE+ckHhjn5cbDNw35/FuyhkLJfq\n"
-            + "lOG7SPvMNmCdJ1Pcqju9t7sf6b0BGPDOCL4gpuWKK7HJz9WxngNb3FSziLbyPLk1\n"
-            + "3ynADO+vEOR44LPyXE9kVxPusazsXlt9ayTOhELhwzw7sGFFu8E17Cpn7GnVj3tN\n"
-            + "9A==\n"
-            + "=qbV3\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * A key that expired in 2006.
-   *
-   * <pre>
-   * pub   2048R/17DE1ACD 2005-07-08 [expired: 2006-07-08]
-   *       Key fingerprint = 1D9E EB79 DD38 B049 939D  9CAF 3CEC 781B 17DE 1ACD
-   * uid                  Testuser Three &lt;test3@example.com&gt;
-   * </pre>
-   */
-  public static final TestKey expiredKey() {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBELOp0UBCACxholOPWuKhK+TYb88nvLUSCMvTLIFEpb5u3Eavr0wiluEzq6H\n"
-            + "55nswAD3dQm8DWxA7yUlEYjPr5btpw7V9441bb1+qtgZMJ10RTdEb/WjyctdGA99\n"
-            + "uOKBEarWbt8W+w6lyJ9NXy5bS/x5EwHHfoTFp4ff6ffHI5hbx1a00K8oxmitgd0X\n"
-            + "Mx86UmauFNJYupZOZG9gEcP4RbRp7e2pm4Jy1WLEOeg9Fdgm5e5Hj2nMkCSZ9BKV\n"
-            + "cxuOllSVzM/Zp0/4+RS9R57jKo3/V74Whwh9yQNgL9UxdNk7L0eGqvaT3EjXxjOc\n"
-            + "RCeJiucGN/0W2iq+V01/QGspp4SKtAogWBozABEBAAG0IlRlc3R1c2VyIFRocmVl\n"
-            + "IDx0ZXN0M0BleGFtcGxlLmNvbT6JAT4EEwECACgFAkLOp0UCGwMFCQHhM4AGCwkI\n"
-            + "BwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEDzseBsX3hrNYg0H/2CMm5/JDQNSuRFC\n"
-            + "ECWLrcOeimuvwbmkonNzOkvKbGXl73GStISAksRWAHBQED1rEPC0NkFCDeVZO7df\n"
-            + "SYLlsqKwV6uSh05Ra0F5XeniC12YpAyzoQyCGRS2wLaS822j0zUPXA8XLaO2blCu\n"
-            + "R+8sNu/oecMRcFK4S9NaApi3vdqBNhLiN1/Lpqn1LfB8uIO+eaUf4PmCWbaPgzSk\n"
-            + "qcPfKZmocNXdgLV5Q80n3hc2y2nrl+vDW2M+eVZuDHAok2BOD9uGKFfLAbaXLbX5\n"
-            + "btBW2L0UHtoEyiqkRfD6lX2laSLQmA6+eup7e4GS+s0vXBuVh8XEYddV6Yjt8H7/\n"
-            + "2thO41K5AQ0EQs6nRQEIAM/833UHK1DuFlOm7/n18dRMvs7BkXvg+hPquKWMG3be\n"
-            + "eE4sh1NG5DbRCdo6iacZLarWr3FDz7J9+wswRhtHCh3pGHEuaJk52vRjQxlkNh5F\n"
-            + "p5u2R4WF546bWqX45xPdLfHVTPyWB9q7aVxE+6Q+MHa6lMoyTVnTVCOy3nshiihw\n"
-            + "dxLsxaga+QmaL0bAR+dRcO6ucj7TDQXz1AJAVp26c0LXV9iErhFuuybUZKT0a9Aj\n"
-            + "FoumMZ6l+k30sSdjSjpBMsNvPos0dTPPRXUMu77o5sj+pHa4o8WctgB3o7BHQELp\n"
-            + "KgujZ2sKC9Nm395u6Q4cqUWihzb/Y7rIRuNHJarI7vUAEQEAAYkBJQQYAQIADwUC\n"
-            + "Qs6nRQIbDAUJAeEzgAAKCRA87HgbF94azRiBB/4vAyOOjUjK3lDWjHGs7mvEWJI/\n"
-            + "1MeLlGPswCSInJBa+HMiMI4tzq+hu5ejGThojNbmnL96GdzfDkMlP4Feyxb2rjtb\n"
-            + "NrD/R5tlXHmjX/QLzep4LCeMziP80fu8qUeiOej/Ecdny0w365PlMdt10RaYR8VE\n"
-            + "ZX/DAie6JfElnfQcG5q8TIOH3i71qxV+kIoPqKWfQ0MXrNEJ3BYFfDGdUt8U1Kq9\n"
-            + "OuIHVRgGS7mMSyjgNqqp7MBeMY+PFFZaZel5yoYVjb9d3L8XvVv2eoa/jPj5FUEU\n"
-            + "kE9uxNmwaD1PiV8DvBTYI+eQL4qzfu+3NTG2SfgQYtj5oiGHw8aL3U6QHDJb\n"
-            + "=d/Xp\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBELOp0UBCACxholOPWuKhK+TYb88nvLUSCMvTLIFEpb5u3Eavr0wiluEzq6H\n"
-            + "55nswAD3dQm8DWxA7yUlEYjPr5btpw7V9441bb1+qtgZMJ10RTdEb/WjyctdGA99\n"
-            + "uOKBEarWbt8W+w6lyJ9NXy5bS/x5EwHHfoTFp4ff6ffHI5hbx1a00K8oxmitgd0X\n"
-            + "Mx86UmauFNJYupZOZG9gEcP4RbRp7e2pm4Jy1WLEOeg9Fdgm5e5Hj2nMkCSZ9BKV\n"
-            + "cxuOllSVzM/Zp0/4+RS9R57jKo3/V74Whwh9yQNgL9UxdNk7L0eGqvaT3EjXxjOc\n"
-            + "RCeJiucGN/0W2iq+V01/QGspp4SKtAogWBozABEBAAEAB/4hGI3ckkLMTjRVa7G1\n"
-            + "YYSv4sr8dHXz0CVpZXKOo+Stef3Z4pZTK/BcXOdROvaXooD+EheAs6Yn4fpnT+/K\n"
-            + "IB7ZAx6C0OL8vz17gbPuBFltMZ/COUwaCi/gFCUfWQgqRp/SdHaOfCIuTxpAkDSS\n"
-            + "tpmWJ8eDDSFudMpgweb+SrF9DkCwp+FgUbzDRzO1aqzuu8PGihCHQt/pkhNHQ63/\n"
-            + "srDDqk6lIxxZHhv9+ucr3plDuijkvAa5/QDudQlucKDLtTPSD40UcqYnpg/V/RJU\n"
-            + "eBK0ZXmCIHpG9beHW/xdlwrK3eY4Z2sVDMm9TeeHmRYOCr5wQCyeLpMdAt0Ijk6a\n"
-            + "nINhBADI2lRodgnLvUKbOvVocz8WQjG1IXlL8iXSNuuHONijPXZiWh7XdkNxr9fm\n"
-            + "jRqzvZzYsWGT6MnirX2eXaEWJsWJHxTxJuiuOk0V/iGnV/d+jFduoKXNmB5k/ZB3\n"
-            + "6zySi7+STKNyIvnMATVsRoI/cNUwfmx53m6trFg581CnSiA82QQA4kSPw9OXmTKj\n"
-            + "ctlHrWsapWu+66pDVZw62lW6lvrd7t+m8liNb6VJuTnwIKVXJOQtUo1+GSMs0+YK\n"
-            + "wnd9FGq4jT8l0qBO4K/8B1HxppLC2S0ntC+CusxWMUDbdC2xg+G2W3oLwq3iamgz\n"
-            + "LvPTy1Pzs9PqDd6FXIdzieFy6J8W1+sEAKS3vjh7Z/PIVULZhdaohAd5Igd67S/Z\n"
-            + "BMWYNbBuJTnnb7DiOllLZSd2lR7IAKPKsUd6UY8uskOxI81hI116zNx17mIGFIIq\n"
-            + "DdDgRbvzMNEgNlOxg/BD01kXOS4fhnT2F6ca3VGTgUtOdcdF3M9MtePWQLBzEDPz\n"
-            + "8nx3O20HDupuQmG0IlRlc3R1c2VyIFRocmVlIDx0ZXN0M0BleGFtcGxlLmNvbT6J\n"
-            + "AT4EEwECACgFAkLOp0UCGwMFCQHhM4AGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheA\n"
-            + "AAoJEDzseBsX3hrNYg0H/2CMm5/JDQNSuRFCECWLrcOeimuvwbmkonNzOkvKbGXl\n"
-            + "73GStISAksRWAHBQED1rEPC0NkFCDeVZO7dfSYLlsqKwV6uSh05Ra0F5XeniC12Y\n"
-            + "pAyzoQyCGRS2wLaS822j0zUPXA8XLaO2blCuR+8sNu/oecMRcFK4S9NaApi3vdqB\n"
-            + "NhLiN1/Lpqn1LfB8uIO+eaUf4PmCWbaPgzSkqcPfKZmocNXdgLV5Q80n3hc2y2nr\n"
-            + "l+vDW2M+eVZuDHAok2BOD9uGKFfLAbaXLbX5btBW2L0UHtoEyiqkRfD6lX2laSLQ\n"
-            + "mA6+eup7e4GS+s0vXBuVh8XEYddV6Yjt8H7/2thO41KdA5gEQs6nRQEIAM/833UH\n"
-            + "K1DuFlOm7/n18dRMvs7BkXvg+hPquKWMG3beeE4sh1NG5DbRCdo6iacZLarWr3FD\n"
-            + "z7J9+wswRhtHCh3pGHEuaJk52vRjQxlkNh5Fp5u2R4WF546bWqX45xPdLfHVTPyW\n"
-            + "B9q7aVxE+6Q+MHa6lMoyTVnTVCOy3nshiihwdxLsxaga+QmaL0bAR+dRcO6ucj7T\n"
-            + "DQXz1AJAVp26c0LXV9iErhFuuybUZKT0a9AjFoumMZ6l+k30sSdjSjpBMsNvPos0\n"
-            + "dTPPRXUMu77o5sj+pHa4o8WctgB3o7BHQELpKgujZ2sKC9Nm395u6Q4cqUWihzb/\n"
-            + "Y7rIRuNHJarI7vUAEQEAAQAH+gNBKDf7FDzwdM37Sz8Ej7OsPcIbekzPcOpV3mzM\n"
-            + "u/NIuOY0QSvW7KRE8hwFlXjVZocJU/Z4Qqw+12pN55LusiRUrOq8eKuJIbl4QikI\n"
-            + "Dea8XUqM+CKJPV3YZXs6YVdIuzrRBSLgsB/Glff5JlzkEjsRYVmmnto8edETL/MK\n"
-            + "S9ClJqQiFKE4b01+Eh9oB/DfxzsiEf/a+rdRnWRh/jtpEwgeXcfmjhf+0zrzChu2\n"
-            + "ylQQ5QOuwQNKJP6DvRu/W5pOaKH9tPDR31SccDJDdnDUzBD7oSsXl06DcfMNEa8q\n"
-            + "PaNHLDDRNnqTEhwYSJ4r2emDFMxg7Kky+aatUNjAYk9vkgMEANnvumgr6/KCLWKc\n"
-            + "D3fZE09N7BveGBBDQBYNGPFtx60WbKrSY3e2RSfgWbyEXkzwm1VlB2869T1we0rL\n"
-            + "z6eV/TK5rrJQxJFHZ/anMxbQY0sCiOgqi6PKT03RTpA2N803hTym+oypy+5T6BFM\n"
-            + "rtjXvwIZN/BgAE2JjA70crTAd1mvBAD0UFNAU9oE7K7sgDbni4EhxmDyaviBHfxV\n"
-            + "PJP1ICUXAcEzAsz2T/L5TqZUD+LfYIkbf8wk2/mPZFfrCrQgCrzWn7KV1SHXkhf4\n"
-            + "4Sg6Y6p0g0Jl3mWRPiQ6ALlOVQIkp5V8z4b0hTF2c4oct1Pzaeq+ZkahyvrhW06P\n"
-            + "iaucRZb+mwP/aVTpkd4n/FyKCcbf9/KniYJ+Ou1OunsBQr/jzN+r0PKCb8l/ksig\n"
-            + "i/M0NGetemq9CxYsJDAyJs1aO4SWgx5LbfcMmyXDuJ3sL0ztFLOES31Mih3ZJebg\n"
-            + "xPpj2bB/67i2zeYRcjxQ116y23gOa2TWM8EE4TW7F/mQjw4fIPJ93ClBMIkBJQQY\n"
-            + "AQIADwUCQs6nRQIbDAUJAeEzgAAKCRA87HgbF94azRiBB/4vAyOOjUjK3lDWjHGs\n"
-            + "7mvEWJI/1MeLlGPswCSInJBa+HMiMI4tzq+hu5ejGThojNbmnL96GdzfDkMlP4Fe\n"
-            + "yxb2rjtbNrD/R5tlXHmjX/QLzep4LCeMziP80fu8qUeiOej/Ecdny0w365PlMdt1\n"
-            + "0RaYR8VEZX/DAie6JfElnfQcG5q8TIOH3i71qxV+kIoPqKWfQ0MXrNEJ3BYFfDGd\n"
-            + "Ut8U1Kq9OuIHVRgGS7mMSyjgNqqp7MBeMY+PFFZaZel5yoYVjb9d3L8XvVv2eoa/\n"
-            + "jPj5FUEUkE9uxNmwaD1PiV8DvBTYI+eQL4qzfu+3NTG2SfgQYtj5oiGHw8aL3U6Q\n"
-            + "HDJb\n"
-            + "=RrXv\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * A self-revoked key with no expiration.
-   *
-   * <pre>
-   * pub   2048R/7CA87821 2015-07-08 [revoked: 2015-07-08]
-   *       Key fingerprint = E328 CAB1 1F7E B1BC 1451  ABA5 0855 2A17 7CA8 7821
-   * uid                  Testuser Four &lt;test4@example.com&gt;
-   * </pre>
-   */
-  public static final TestKey selfRevokedKey() {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBFWdTZwBCAC1jukp5mlitfq2sAmdtx1s1VbWh+buDbBY2kWcxbbssczozFUP\n"
-            + "Ii67wPwjRbn3GM5+jY3GMsqKIrdyDlxeTxGWoU/qa2YkCQzgFGD/XJBqkVpP6osm\n"
-            + "qFYSP0xST1iBkatkMHq5KMjrX2q2bGVLlchLF9eHrWSefMcfff1Vs/Y8F2RCo38y\n"
-            + "gH88mbcvgyC+zq6Q2T3h5RiLK2IaZDNsn3uUoIMYHxI6oYtXXMSXRJlLJvamXVrB\n"
-            + "7QAq8L8cNikJjZMz+bHtLtGDyVVp9tqo4yvMrHe6BYmBUte3tPYQlDVdEo7UqepR\n"
-            + "uT7JbBOGBoD+9ngDrDggPUBAoa0h3VNOTyoDABEBAAGJAR8EIAECAAkFAlWdVXkC\n"
-            + "HQIACgkQCFUqF3yoeCH4lgf/aBdTYqnwL1lreHbQaUXI0/B2zlMuoptoi/x+xjIB\n"
-            + "7RszzaN3w0n4/87kUN2koNtgNymv2ccKTR1PiX+obscJhsWzNbz3/Cjtr/IpEQRd\n"
-            + "E6qRptHDk0U2cHW4BYDSltndOktICdhWCWYLDxJHGjdyXqqqdEEFJ24u2fUJ3yF3\n"
-            + "NF2Bxa6llrmLb2fVeVYBzQSztQopKRWP9nt3ySoeJQqRWjNBN2j7cC93nrLHZTvB\n"
-            + "L/sWuTq5ecbXeeNVzxoBd21jmGrIUPNwGdDKdbTB0CjpLpVHOTwGByeRKQXhMlQB\n"
-            + "pK96wUpxxtShtOjNjN1s9GEyLHwDiHSuHNYs/AxxFzf9nbQhVGVzdHVzZXIgRm91\n"
-            + "ciA8dGVzdDRAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJVnU2cAhsDBgsJCAcDAgYV\n"
-            + "CAIJCgsEFgIDAQIeAQIXgAAKCRAIVSoXfKh4IXsHCACSm9RIdxxqibAaxh+nm6w5\n"
-            + "F5a6Hju5cdmkk9albDoQYh2eM8E5NdDq+r0qSSe2+ujDaQ4C95DZNJQESvIcHHHb\n"
-            + "9AECrBfS8Yk86rX8hxVeYQczMkB9LdBHximTSoOr8L/eAxBE/VXDwust6EAe6Q1A\n"
-            + "a3tlTTvCfcmw4PipvtP7F6UzFaq+QU6fvARpBATOcvVc2JU4JQOrxuNEQ2PKrSti\n"
-            + "75S5mnVWm0pRebM+EorWBtlA0eOAeLNqCp87UwLdvUyOTRZT4DJ51eTxfrFADXrI\n"
-            + "9/ejs3/YxCPYxaPicAlcldduuajU/s+9ifrUn0Npg2ILl8mQkNzqeerlBeecUV4E\n"
-            + "uQENBFWdTZwBCADEOsK+mFQ/2uds9znkmAqrk24waVBpyPGrTTXtXX0dKhtQAsh6\n"
-            + "QkZGkjLTnKxEsa9syqVckw+1JtCh44SP1gjqDUoShpBz5wIuksZ7q96Hx+F0TVG/\n"
-            + "njS6GrWvwKhL2Lb9hYfdlrZiYtOOi0iiOzud25H/Ms15kC8tuQm7NWtANJJF4Sxo\n"
-            + "Bxor6L/F4zunEkTL0L9/dp4qVrw23fJVKE38cSdxjB0u1qSDzLV/u0QJqlYxJAiE\n"
-            + "ciwQN2uVnTY1/XSpouMy6LvbYU7B2uU/WohNmH3RiN/fQ6jJm4x+fCZ8+zqXMiZn\n"
-            + "G2fPkwmxxK9cl64YnNGcTwsVt6BMbCHk9jHxABEBAAGJAR8EGAECAAkFAlWdTZwC\n"
-            + "GwwACgkQCFUqF3yoeCGOdwf/TmoxH3pFBm/MDhY5Ct5FO0KvsgQk2ZgDa68HyQ8j\n"
-            + "QYi1FUCtyDjsxf5KTfyvzpzcTpS7cyOwcJNtTj6UixwATkcivvYWYoOXghAsTo4f\n"
-            + "1+j/x6ECq1+nYE6NpcAN7VRJpYMk2UO2qlhHCesTPGzsHchL7mwiYdhGrdiWGTpd\n"
-            + "KI9WfOYDZZ9ZSw/QINJUyTRxrDnauOvVbhbAXc7jdKCkRQRZpsNlF//1Stg6nstj\n"
-            + "FJ7SrjVdsMJNlihT6fG5ujmrty1/6b1VCLkIQfW5cWvzRzTBFytq7i4PVKh3u7Oz\n"
-            + "tt9lf8s50zt2uBE/AKMkyE6IJLsBWpJPk7iFKkHGDx044Q==\n"
-            + "=477N\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBFWdTZwBCAC1jukp5mlitfq2sAmdtx1s1VbWh+buDbBY2kWcxbbssczozFUP\n"
-            + "Ii67wPwjRbn3GM5+jY3GMsqKIrdyDlxeTxGWoU/qa2YkCQzgFGD/XJBqkVpP6osm\n"
-            + "qFYSP0xST1iBkatkMHq5KMjrX2q2bGVLlchLF9eHrWSefMcfff1Vs/Y8F2RCo38y\n"
-            + "gH88mbcvgyC+zq6Q2T3h5RiLK2IaZDNsn3uUoIMYHxI6oYtXXMSXRJlLJvamXVrB\n"
-            + "7QAq8L8cNikJjZMz+bHtLtGDyVVp9tqo4yvMrHe6BYmBUte3tPYQlDVdEo7UqepR\n"
-            + "uT7JbBOGBoD+9ngDrDggPUBAoa0h3VNOTyoDABEBAAEAB/4jqeZoOiACaV/Nygeh\n"
-            + "iOpJSiDsNDbrFRpKYdnhwT69APIQ2q5sshi+/dopbZVpkeBiIJk0UR7TAp3JVEPV\n"
-            + "rK92SMqjcCRYuMRkMeyZzMt7e4DjiN17ov6BSBjMZFSs4vnpTNKWk4ngHlaebe15\n"
-            + "6vq0sYK/XpKQxU7yAzQjxR190P/F+QEL98zVG/9uqM8PupfdSm4Smp2cIpfta+JD\n"
-            + "mO23HC6jAEm2RFwklovzgK3rbIjyiMuowIkAKx5xxRvpxMHf1l566b9zJrRi0xau\n"
-            + "vp4J/lnBJtTMzCbsaaFxhrj23xvTXaWR+UkaGPCv7wheXQ9K7NAHwmH8YrR+cZx7\n"
-            + "KbDlBADUTHZ+OhNslx/rkjRWrFuK9p49x7qxQc26kcqlGPbW6KOAMdUpwneQbhCG\n"
-            + "a36E/GAZgsgQ4SUqn37EVCtd2Y9Dp0inPAujcZXSwgDHev6ea7fzbxT9KLtEgvQN\n"
-            + "0vrFJDCPIt0wzGqNDw4wgFjF2rAafBO//Wu5K5QLW4hfzSguRQQA2u6DpVja/FYY\n"
-            + "UHVh2HLiB8th4T+qogOsBe5mKEsGRPXtAh7QzJu36C4PJyHeNlmlMx+15cCFnovj\n"
-            + "6cLpGn6ZP4okLyq2+VsW7wh/Vir+UZHoAO/cZRlOc1PsaQconcxxq30SsbaRQrAd\n"
-            + "YargKlXU7HMFiK34nkidBV6vVW0+P6cD/jYRInM983KXqX5bYvqsM1Zyvvlu6otD\n"
-            + "nG0F/nQYT7oaKKR46quDa+xHMxK8/Vu1+TabzY8XapnoYFaFvrl/d2rUBEZSoury\n"
-            + "z2yfTyeomft9MGGQsCGAJ95bVDT+jBoohnYwfwdC7HG3qk0aK/TxFyUqvMOX7SFe\n"
-            + "YT55n3HlD9InST+0IVRlc3R1c2VyIEZvdXIgPHRlc3Q0QGV4YW1wbGUuY29tPokB\n"
-            + "OAQTAQIAIgUCVZ1NnAIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQCFUq\n"
-            + "F3yoeCF7BwgAkpvUSHccaomwGsYfp5usOReWuh47uXHZpJPWpWw6EGIdnjPBOTXQ\n"
-            + "6vq9Kkkntvrow2kOAveQ2TSUBEryHBxx2/QBAqwX0vGJPOq1/IcVXmEHMzJAfS3Q\n"
-            + "R8Ypk0qDq/C/3gMQRP1Vw8LrLehAHukNQGt7ZU07wn3JsOD4qb7T+xelMxWqvkFO\n"
-            + "n7wEaQQEznL1XNiVOCUDq8bjRENjyq0rYu+UuZp1VptKUXmzPhKK1gbZQNHjgHiz\n"
-            + "agqfO1MC3b1Mjk0WU+AyedXk8X6xQA16yPf3o7N/2MQj2MWj4nAJXJXXbrmo1P7P\n"
-            + "vYn61J9DaYNiC5fJkJDc6nnq5QXnnFFeBJ0DmARVnU2cAQgAxDrCvphUP9rnbPc5\n"
-            + "5JgKq5NuMGlQacjxq0017V19HSobUALIekJGRpIy05ysRLGvbMqlXJMPtSbQoeOE\n"
-            + "j9YI6g1KEoaQc+cCLpLGe6veh8fhdE1Rv540uhq1r8CoS9i2/YWH3Za2YmLTjotI\n"
-            + "ojs7nduR/zLNeZAvLbkJuzVrQDSSReEsaAcaK+i/xeM7pxJEy9C/f3aeKla8Nt3y\n"
-            + "VShN/HEncYwdLtakg8y1f7tECapWMSQIhHIsEDdrlZ02Nf10qaLjMui722FOwdrl\n"
-            + "P1qITZh90Yjf30OoyZuMfnwmfPs6lzImZxtnz5MJscSvXJeuGJzRnE8LFbegTGwh\n"
-            + "5PYx8QARAQABAAf8CeTumd6jbN7USXXDyQdzjkguR6mfwN29dcY8YF4U52oOm3+w\n"
-            + "bR23XmqTvoDJXONatZEYOm093wP4hBktP3Vq2KZX5Ew9r2JoBUIoWOcHHvCQqSUW\n"
-            + "6KMJBJNBMv3zXnOscmcPvTgStS5HfYn/XRLAhEqkd2ov2x/OiS8p0vM0F7YYSOdu\n"
-            + "X6/nHeBCM5QSJl00kgcaeQYdIGL0bPv9DnoeAC2/yITEvtvs+MHZ7FjH8A45QjWn\n"
-            + "DwfVoLg7WOc3wJtqJ55/r/2pylrWz0YYM8s6I3gbDilCF+Wb8tEIOaWJEwY73J1/\n"
-            + "KQG5qlO3/hBlO80DtzNmi3ylRUuzGhTxQfvemwQA3EuZ+E48LJ3dwtdJhh5mFlWI\n"
-            + "Ket21e5v1mqMxuLhf5/2CYcifM08u3EsEUdIr7egF25Sea8otqmCYcG8FuB37VY/\n"
-            + "Hd4G/+YVVaaAB8EU6u64YfSswhzr0R2qWVLtkJr0EAephzdPdoUEtKDSdTxnXiDV\n"
-            + "3vSqLWtZekScLa979uMEAOQIodJwxSvveKQWILjK67ZJr56X8YQZWA6rFsr1xMY0\n"
-            + "N0GH+5k0k+tr4wT3H9uk9ZM1Z11G3c01mhzCNg5roFoKtftKUZRKxmbfjjDmAofl\n"
-            + "bA6EZ0WHLdOwDLLTuXK09IsjjSHq0YHOxIlgFzIreuoxtz27bEEGhVFQg7xb0Lgb\n"
-            + "A/9LP8i32L7/CHsuN0q4YjhJkkaB6JWUQMFqWwoAXALG3rnw/CGRYHmHpiAuSeHR\n"
-            + "dSlZzndVi5poNC/d27msTx7ZuWlN7nOyywHBCTWV/nstm2I9rDhrHK7Axgq0Vv0y\n"
-            + "bWAurUmEgDJHU3ZpsNVt4e30FooXIDLR4cnpRM7tILv39D4giQEfBBgBAgAJBQJV\n"
-            + "nU2cAhsMAAoJEAhVKhd8qHghjncH/05qMR96RQZvzA4WOQreRTtCr7IEJNmYA2uv\n"
-            + "B8kPI0GItRVArcg47MX+Sk38r86c3E6Uu3MjsHCTbU4+lIscAE5HIr72FmKDl4IQ\n"
-            + "LE6OH9fo/8ehAqtfp2BOjaXADe1USaWDJNlDtqpYRwnrEzxs7B3IS+5sImHYRq3Y\n"
-            + "lhk6XSiPVnzmA2WfWUsP0CDSVMk0caw52rjr1W4WwF3O43SgpEUEWabDZRf/9UrY\n"
-            + "Op7LYxSe0q41XbDCTZYoU+nxubo5q7ctf+m9VQi5CEH1uXFr80c0wRcrau4uD1So\n"
-            + "d7uzs7bfZX/LOdM7drgRPwCjJMhOiCS7AVqST5O4hSpBxg8dOOE=\n"
-            + "=5aNq\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * A key with an additional user ID.
-   *
-   * <pre>
-   * pub   2048R/98C51DBF 2015-07-30
-   *       Key fingerprint = 42B3 294D 1924 D7EB AF4A  A99F 5024 BB44 98C5 1DBF
-   * uid                  foo:myId
-   * uid                  Testuser Five <test5@example.com>
-   * sub   2048R/C781A9E3 2015-07-30
-   * </pre>
-   */
-  public static TestKey validKeyWithSecondUserId() {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBFW6jd4BCACrf+BZS3lntuWq2DWPOG07/BUWhx3RSoiS3JBuKEDmlsjswKcp\n"
-            + "JHT+p2tqH52XbujMlzNjAQcZjJwfOMt6Fg7zd3F8cwYQdCE/W5dpMs/mqdeEz6GL\n"
-            + "VJDZ0Y5wwz54ZQHp91Xq6uejxt5qffeTQk5cToQZ0RVx3iwBc+2P3iYJqMFmJzj8\n"
-            + "djabEoF4D50iI5tY8moE83VcXJ5Y4xn+5Z5AThmlfrMP6gIdG0b4lEe1tsnJC6AG\n"
-            + "GUU6VkzK6E1Tp93Y0brtWpJKi9Gt6eUqvWhZtPEdFVCFbLTpezUdRFEuaFbGg5pn\n"
-            + "9K/DceahFmquDJOHVgawt6erlq/ie7QEEld/ABEBAAG0IVRlc3R1c2VyIEZpdmUg\n"
-            + "PHRlc3Q1QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCVbqN3gIbAwYLCQgHAwIGFQgC\n"
-            + "CQoLBBYCAwECHgECF4AACgkQUCS7RJjFHb+/MAf9FKZatGcuOIoYqwGQQneyc63v\n"
-            + "3H/PyhvYF1nuKNftmhqIiUHec9RaUHQkgam6LRoonkDfIpNlQVRv2XBV2VOAOFVO\n"
-            + "RyQ/Tv7/xtpqGZqivV0yn2ZXbCceA627Vz7gP4gkO0ZJ0JsYJTc/5wO+nVG5Lohu\n"
-            + "/zdUofEbFAvcXs+Z1uXnUDdeGn47Lf1xZ2XOHOI0aQW4DdNaFoAd+AOTe0W3iB6W\n"
-            + "paCIGno69CyNHNnWjJCSD33oLVaXyvbgw5UoyITvSqRnPyLGIc6dsqDLT59ok0Fk\n"
-            + "t4jtiGu9aze4n59GbtSjmWQgzbLCQWhK9K7UCcSLYNKXVyMha2WapBO156V027QI\n"
-            + "Zm9vOm15SWSJATgEEwECACIFAlW6jwYCGwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4B\n"
-            + "AheAAAoJEFAku0SYxR2/zZUH/1BwPsResHLDSmo6UdQyQGxvV0NcwBqGAPSLHr+S\n"
-            + "PHEaHEIYvOywNfWXquYrECa/5iIrXuTQmCH0q8WRcz1UapDCeD8Ui82r+3O8m6gk\n"
-            + "hIR5VAeza+x/fGWhG342PvtpDU7JycDA3KMCTWtcAM89tFhffzuEQ3f5p5cMTtZk\n"
-            + "/23iegXbHd61vojYO17QYEj+qp9l0VNiyFymPL3qr5bVj/xn/mXFj+asj0L2ypIj\n"
-            + "zC36FkhzW5EX2xgV9Cl9zu7kLMTm+yM+jxbMLskYkG8z/D+xBQsoX8tEIPlxHLhB\n"
-            + "miEmVuZrp91ArRMWa3B7PYz7hQzs+M/bxKXcmWxacggTOvy5AQ0EVbqN3gEIAOlq\n"
-            + "mwdiXW0BQP/iQvIweP1taNypAvdjI2fpnXkUfBT5X/+E/RjYOHQEAzy8nEkS+Y0l\n"
-            + "MLwKt3S0IVRvdeXxlpL6Tl+P8DkcD5H+uvACrg9rtgbbNSoQtc9/3bknG9hea6xi\n"
-            + "6SBH1k9Y2RInIrwWslfKmuNkyZVhxPKypasBsvyhOWLlpCngGiCa74KJ1th1WKa2\n"
-            + "aaDqcbieBTc1mtsXR6kBhJZqK+JYBoHriUQMs7nyXxn2qyv6Lehs/tHlrBZ7j16S\n"
-            + "faQzYoBi1edVrpFr/CuGk6RNKxG9vi/uAA9q2cLCMjjyfMH4g0G2l0HuDPQLA9wi\n"
-            + "BfusEC+OceaeFKtS9ykAEQEAAYkBHwQYAQIACQUCVbqN3gIbDAAKCRBQJLtEmMUd\n"
-            + "vw/DB/9Qx9m1eSdddqz/fk16wJf7Ncr2teVvdQOjRf/qo43KDKxEzeepjgypG1br\n"
-            + "St7U4/MlPygJLBDB4pXp0kaKt+S/aqLpEGSGzQ1FysM8oY6K0e1Kbf6nMaQS8ATG\n"
-            + "aD377FrUJ42NV4JS+NGlwaM9PhpRVm5n8iCzRs9HtlTyfCBkNGDjGOSdWcah2m6T\n"
-            + "fEQdD+XVDN1ZC8zAnc8FW28YOTeTjX079okP6ZCjLJ16VZ7eiHFkrNbS9Dl4SPNK\n"
-            + "eElvsZLBaf8t4RQXFFKwRq4BW+zS8zm9E2H6bZ9yGrmgIREzyRPpwU98g8yrabu0\n"
-            + "54w16Vp/SVViJs7nTMSug0WREyd2\n"
-            + "=ldwB\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBFW6jd4BCACrf+BZS3lntuWq2DWPOG07/BUWhx3RSoiS3JBuKEDmlsjswKcp\n"
-            + "JHT+p2tqH52XbujMlzNjAQcZjJwfOMt6Fg7zd3F8cwYQdCE/W5dpMs/mqdeEz6GL\n"
-            + "VJDZ0Y5wwz54ZQHp91Xq6uejxt5qffeTQk5cToQZ0RVx3iwBc+2P3iYJqMFmJzj8\n"
-            + "djabEoF4D50iI5tY8moE83VcXJ5Y4xn+5Z5AThmlfrMP6gIdG0b4lEe1tsnJC6AG\n"
-            + "GUU6VkzK6E1Tp93Y0brtWpJKi9Gt6eUqvWhZtPEdFVCFbLTpezUdRFEuaFbGg5pn\n"
-            + "9K/DceahFmquDJOHVgawt6erlq/ie7QEEld/ABEBAAEAB/9MIlrQiWb+Gf3fWFh+\n"
-            + "mkg0Bva9p4IfNX1n5S7hGFGnjGzqXaRX6W1e16gh1qM5ZO1IVh9j5kLmnrt4SNhb\n"
-            + "/Irqnq3s14trpoJUBC81bm9JMUESHrLSjdo4OIWJncOP4xd0bG7h+SKYXGLE1+Me\n"
-            + "pqLu65RNebqRcFYM1xAxfCdaxatcz+LrW5ZX+6T/Gh/VCHRkkzzVIZO1dDBbyU2C\n"
-            + "JrNcfHSvNrjzfqYHtwfsk/lwcuY9pqkYcuwZ2IM+iWKit+WyCR2BzOpG/Sva1t8b\n"
-            + "7B7ituQCFMCv5IiaAoaSKX/t/0ucWCoT1ttih8LdwgEE0kgij/ZUfRxCiL9HmtLy\n"
-            + "ad9BBADBGYWv6NiTQiBG7+MZ+twCjlSL7vq8iENhQYZShGHF9z+ju7m8U1dteLny\n"
-            + "pC3NcNfCgWyy+8lRn1e6Oe6m7xL83LL3HJT5nIy9mpsCw/TIrrkzkoE+VpkEIL/o\n"
-            + "Yeoxauah4SU7laVD29aAQZ3TqwSwx0sJwPjsj73WjjqtzJfFkQQA410ghqMbQZN1\n"
-            + "yJzXgVAj162ZwTi961N5iYmqTiBtqGz1UfaNBJWdJMkCmhMTsiOtm1h4zUQRuEH+\n"
-            + "yq1xhKOGf15dB/cLSMj2KpVVlvgLoVmYDugSER8Q23juilY7iaf0bqo9q1sTHpn9\n"
-            + "O7Oin/9J3sz+ic45vDh4aa74sOzfhA8EAJwAFEWLrGSxtnYJR5vQNstHIH1wtQ5G\n"
-            + "ZUZ57y9CbDkKrfCQvd0JOBjfUDz+N8qiamNIqfhQBtlhIDYgtswiG+iGP/2G0l6S\n"
-            + "j9DHNe2CYPUKgy+zQiRnyNGE2XUfcE+HuNDfu3AryPqaD8vLLw8TnsAgis3bRGg+\n"
-            + "hhrAC1NyKfDXTg20IVRlc3R1c2VyIEZpdmUgPHRlc3Q1QGV4YW1wbGUuY29tPokB\n"
-            + "OAQTAQIAIgUCVbqN3gIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQUCS7\n"
-            + "RJjFHb+/MAf9FKZatGcuOIoYqwGQQneyc63v3H/PyhvYF1nuKNftmhqIiUHec9Ra\n"
-            + "UHQkgam6LRoonkDfIpNlQVRv2XBV2VOAOFVORyQ/Tv7/xtpqGZqivV0yn2ZXbCce\n"
-            + "A627Vz7gP4gkO0ZJ0JsYJTc/5wO+nVG5Lohu/zdUofEbFAvcXs+Z1uXnUDdeGn47\n"
-            + "Lf1xZ2XOHOI0aQW4DdNaFoAd+AOTe0W3iB6WpaCIGno69CyNHNnWjJCSD33oLVaX\n"
-            + "yvbgw5UoyITvSqRnPyLGIc6dsqDLT59ok0Fkt4jtiGu9aze4n59GbtSjmWQgzbLC\n"
-            + "QWhK9K7UCcSLYNKXVyMha2WapBO156V0250DmARVuo3eAQgA6WqbB2JdbQFA/+JC\n"
-            + "8jB4/W1o3KkC92MjZ+mdeRR8FPlf/4T9GNg4dAQDPLycSRL5jSUwvAq3dLQhVG91\n"
-            + "5fGWkvpOX4/wORwPkf668AKuD2u2Bts1KhC1z3/duScb2F5rrGLpIEfWT1jZEici\n"
-            + "vBayV8qa42TJlWHE8rKlqwGy/KE5YuWkKeAaIJrvgonW2HVYprZpoOpxuJ4FNzWa\n"
-            + "2xdHqQGElmor4lgGgeuJRAyzufJfGfarK/ot6Gz+0eWsFnuPXpJ9pDNigGLV51Wu\n"
-            + "kWv8K4aTpE0rEb2+L+4AD2rZwsIyOPJ8wfiDQbaXQe4M9AsD3CIF+6wQL45x5p4U\n"
-            + "q1L3KQARAQABAAf8C+2DsJPpPEnFHY5dZ2zssd6mbihA2414YLYCcw6F7Lh1nGQa\n"
-            + "XuulruAJnk/xGJbco8bTv7g4ecE+tsbfWnnG/QnHeYCsgO6bKRXATcWFSYpyidUn\n"
-            + "2VdzQwBAv1ZtSNhCXlPLn/erzvA2X4QadUwfnvbehWJAHt8ZJmHUr3FtyRUHEdCK\n"
-            + "2EXsBWnzPCcqHZOMvcbSINSqBFGzVXkOZsMFvPTNIUYRHz8NbJT/OPiOmyBshXpS\n"
-            + "t8w3QqZhBcTT3NZo3kgxN1RygaTa10ytB2cxTCVuD8hmUBaV9gakdfMYkVJds7/T\n"
-            + "ZY3It68F0vitBnqpppZQ+NFgr/vwVg0p3gbmAQQA79zsWPvyIqYvyJhmiKvLIpev\n"
-            + "569ho8tC9xx+IZ5WnjN8ZADlb9brAdA9cqGfBgZkpZUhngCRVOYUIco+m2NYkEJm\n"
-            + "BsSTTM77dqU55DRloJ3FtBwCPXHkwg9P/FHMMYYGyLpQTSB92hXk8yomo+ozX7kx\n"
-            + "DtUHZIrir/rr0lQe+GkEAPkep9V5jBmfHMArnfji7Nfb1/ZjrSAaK+rtqczgm+6j\n"
-            + "ubY/0DpM/6gm+/8X27WFw2m45ncH3qNvOe4Qm40EmgmHkXsdQyU0Fv7uXc9nBYoo\n"
-            + "G6s7DWLY4VAqWwPsvbqgpSp/qdGn9nlcJjjY1HtfU7HM3xysT7TJ2YVhYHlJdjDB\n"
-            + "A/0alBcYtHvaCJaRLWX4UiashbfETWAf/4oHlERjkXj64qOdsGnD6CD99t9x91Ue\n"
-            + "pClPsLDFvY8/HxWX7STA9pQZAa2ZdJd8b58Rgy9TBShw2mbz2S6Cbw77pP/WEjtJ\n"
-            + "pJuS2gDp70H01fYRaw7YH32CfUr1VeEv7hTjk/SNVteIZkkOiQEfBBgBAgAJBQJV\n"
-            + "uo3eAhsMAAoJEFAku0SYxR2/D8MH/1DH2bV5J112rP9+TXrAl/s1yva15W91A6NF\n"
-            + "/+qjjcoMrETN56mODKkbVutK3tTj8yU/KAksEMHilenSRoq35L9qoukQZIbNDUXK\n"
-            + "wzyhjorR7Upt/qcxpBLwBMZoPfvsWtQnjY1XglL40aXBoz0+GlFWbmfyILNGz0e2\n"
-            + "VPJ8IGQ0YOMY5J1ZxqHabpN8RB0P5dUM3VkLzMCdzwVbbxg5N5ONfTv2iQ/pkKMs\n"
-            + "nXpVnt6IcWSs1tL0OXhI80p4SW+xksFp/y3hFBcUUrBGrgFb7NLzOb0TYfptn3Ia\n"
-            + "uaAhETPJE+nBT3yDzKtpu7TnjDXpWn9JVWImzudMxK6DRZETJ3Y=\n"
-            + "=uND5\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * A key revoked by a valid key, due to key compromise.
-   *
-   * <p>Revoked by {@link #validKeyWithoutExpiration()}.
-   *
-   * <pre>
-   * pub   2048R/3434B39F 2015-10-20 [revoked: 2015-10-20]
-   *       Key fingerprint = 931F 047D 7D01 DDEF 367A  8D90 8C4F D28E 3434 B39F
-   * uid                  Testuser Six &lt;test6@example.com&gt;
-   * </pre>
-   */
-  public static TestKey revokedCompromisedKey() throws Exception {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBFYmpXkBCACqaLz51DcWQmfOJnat9iHSySfSHwbKfVvoN43Ba2cf/D/PadRc\n"
-            + "HLgc+91k2yk1kV1LnMdvUGj5zZ84ZqrQx3f1WeItnzZpqxtmQS/GSxCp9EY/s7w6\n"
-            + "5i86R/k9Tzgvk0B7dKZJXbM/OWxxDkkxHWE3Un9wreX7bDU5b9D2knHRiNFqH9ZJ\n"
-            + "KqDIFZqH9WTUxNZcHz20sTCRIMfvsAwf2vRU5N5xTu4Mbk6JFc7BAj7h1f/mYEPo\n"
-            + "CTyB1jV/DSDVdn1FjJVocSg6W/CvsYF9hKFYjJHl4VXdePTpnOjHhJLL0QWk0TMe\n"
-            + "xYeUi/xDr5DeMxTmi7F7BFaQEF+KmUM46e+9ABEBAAGJATAEIAECABoFAlYmq1gT\n"
-            + "HQJ0ZXN0NiBjb21wcm9taXNlZAAKCRDtBiXcRjKKjIm6B/9YwkyG4w+9KUNESywM\n"
-            + "bxC2WWGWrFcQGoKxixzt0uT251UY8qxa1IED0wnLsIQmffTQcnrK3B9svd4HhQlk\n"
-            + "pheKQ3w5iluLeGmGljhDBdAVyS07jYoFUGTXjwzPAgJ3Dxzul8Q8Zj+fOmRcfsP9\n"
-            + "72kl6g2yEEbevnydWIiOj/vWHVLFb54G8bwXTNwH/FXQsHuPYxXZifwyDwdwEQMq\n"
-            + "0VTZcrukgeJ+VbSSuq+uX4I3+kJw5hL49KYAQltQBmTo3yhuY/Q+LkgcBv/umtY/\n"
-            + "DrUqSCBV1bTnfq5SfaObkUu22HWjrtSFSjnXYyh+wyTG3AXG3N9VPrjGQIJIW1j6\n"
-            + "9QM0iQE3BB8BAgAhBQJWJqYUFwyAAQSup+0vghEz5bEo0e0GJdxGMoqMAgcAAAoJ\n"
-            + "EIxP0o40NLOfYd4H/3GpfxfJ+nMzBChn1JqFrKOqqYiOO4sUwubXzpRO33V2jUrU\n"
-            + "V75PTWG/6NlgDbPfKFcU0qZud6M2EQxSS9/I20i/MpRB7qJnWMM/6HxdMDJ0o/pN\n"
-            + "4ImIGj38QTIWx0DS9n3bwlcobl7ZlM8g2N1kv5jQPEuurffeJRS4ny4pEvCCm2IS\n"
-            + "SGOuB0DVtYHGDrJLQ0k4mDkEJuU8fP5un8mN8I8eAINlsTFpsTswMXMiptZTm5SI\n"
-            + "5QZlG3m5MvvckngYdhynvCWc6JHGt1EHXlI4A5Qetr/4FbNE4uYcEEhyzBy4WQfi\n"
-            + "QCPiIzzm3O4cMnr9N+5HzYqRhu2OveYm86G2Rxq0IFRlc3R1c2VyIFNpeCA8dGVz\n"
-            + "dDZAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJWJqV5AhsDBgsJCAcDAgYVCAIJCgsE\n"
-            + "FgIDAQIeAQIXgAAKCRCMT9KONDSzn2XtB/4wl4ctc3cW9Fwp17cktFi6md8fjRiR\n"
-            + "wE/ruVKIKmAHzeMLBoZn4LZVunyNCRGLZfP+MUs4JhLkp8ioTzUB7xPl9k94FXel\n"
-            + "bObn9F0T7htjFLiFAOMeykneylk2kalTt6IBKtaOPn+V6onBwO+YHbwt+xLMhAWj\n"
-            + "Z/WA0TIC1RIukdzWErhd+9lG8B9kupGC5bPo/AgCPoajPhS1qLrth+lCsNJXT/Rt\n"
-            + "k6Jx5omypxMXPzgzNtULMFONszaRnHnrCHQg/yJZDCw3ffW5ShfyfWdFM65jgEKo\n"
-            + "nMKLzy9XV+BM6IJQlgHCBAP8WHKSf4qMG4/hEWLrwA/bTQ7w0DSV88msuQENBFYm\n"
-            + "pXkBCACzIMFDC6kcV58uvF3XwOrS3DmKNPDNzO/4Ay/iOxZbm+9NP8QWEEm+AzCt\n"
-            + "ZMfYdZ8C3DjuzxkhcacI/E5agZICds6bs0+VS7VKEeNYp/UrTF9pkZNXseCrJPgr\n"
-            + "U31eoGVc5bE5c0TGLhAjbMKtR5LZFMpAXgpA7hXJSSuAXGs8gjkJkYSJYnJwIOyd\n"
-            + "xOi5jmnE/U5QuMjBG0bwxFXxkaDa5mcebJ/6C8mgkKyATbQkCe7YJGl1JLK4vY28\n"
-            + "ybSMhMDtZiwgvKzd+HcQr+xUQvmgSMApJaMxKPHRA1IrP/STXUEAjcGfk/HCz/0j\n"
-            + "7mJG2cvCxeOMAmp/pTzhSoXiqUNlABEBAAGJAR8EGAECAAkFAlYmpXkCGwwACgkQ\n"
-            + "jE/SjjQ0s5/kVAf/QvHOhuoBSlSxPcgvnvCl8V3zbNR1P9lgjYGwMsvLhwCT7Wvm\n"
-            + "mkUKvtT913uER93N8xJD2svGhKabpiPj9/eo0p3p64dicijsP1UQfpmWKPa/V9sv\n"
-            + "zep08cpDl/eczSiLqgcTXCoZeewWXoQGqqoXnwa4lwQv4Zvj7TTCN2wRzoGwbRcm\n"
-            + "G2hmc27uOwA+hXbF+bLe6HOZR/7U93j8a22g2X9OgST/QCsLgyiUSw3YYaEan9tn\n"
-            + "wuEgAEY/rchOvgeXe5Sl0wTFLHH6OS4BBGgc1LRKnSCM2dgZqvhOOxOvuuieBWY6\n"
-            + "tULvIEIjNNP8Qizfc4u2O8h7HP2b3yYSrp9MMQ==\n"
-            + "=Dxr7\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBFYmpXkBCACqaLz51DcWQmfOJnat9iHSySfSHwbKfVvoN43Ba2cf/D/PadRc\n"
-            + "HLgc+91k2yk1kV1LnMdvUGj5zZ84ZqrQx3f1WeItnzZpqxtmQS/GSxCp9EY/s7w6\n"
-            + "5i86R/k9Tzgvk0B7dKZJXbM/OWxxDkkxHWE3Un9wreX7bDU5b9D2knHRiNFqH9ZJ\n"
-            + "KqDIFZqH9WTUxNZcHz20sTCRIMfvsAwf2vRU5N5xTu4Mbk6JFc7BAj7h1f/mYEPo\n"
-            + "CTyB1jV/DSDVdn1FjJVocSg6W/CvsYF9hKFYjJHl4VXdePTpnOjHhJLL0QWk0TMe\n"
-            + "xYeUi/xDr5DeMxTmi7F7BFaQEF+KmUM46e+9ABEBAAEAB/wOspbuA1A3AsY6QRYG\n"
-            + "Xg6/w+rD1Do9N7+4ESaQUqej2hlU1d9jjHSSx2RqgP6WaLG/xkdrQeez9/iuICjG\n"
-            + "dhXSGw0He05xobjswl2RAENxLSjr8KAhAl57a97C23TQoaYzn7WB6Wt+3gCM5bsJ\n"
-            + "WevbHinwuYb2/ve+OvcudSYM+Nhtpv0DoTaizhi9wzc3g/XLbturlpdCffbw4y+h\n"
-            + "gBPd/t3cc/0Ams8Wi2RlmDOoe73ls23nBHcNomgydyIYBn7U5Z3v3YkPNp9VBiXx\n"
-            + "rC4mDtB1ugucMhqjRNAYqinaLP35CiBTU/IB0WLu7ZyytnjY5frly1ShAG8wFL0B\n"
-            + "MOMxBADJjGy1NwGSd/7eMeYyYThyhXDxo5so91/O1+RLnSUVv/Nz6VOPp2TtuVN5\n"
-            + "uTJkpSXtUFyWbf8mkQiFz4++vHW5E/Q6+KomXRalK7JeBzeFMtax64ykQHID9cSu\n"
-            + "TaSHBhOEEeZZuf6BlulYEJEBHYK6EFlPJn+cpZtTFaqDoKh22QQA2HKjfyeppNre\n"
-            + "WRFJ9h1x1hBlSRR+XIPYmDmZUjL37jQUlw8iF+txPclfyNBw2I2Om+Jhcf25peOx\n"
-            + "ow4yvjt8r3qDjNhI2zLE9u4zrQ9xU8CUingT0t4k3NO2vigpKlmp1/w2IHSMctry\n"
-            + "v1v3+BAS8qGIYDY1lgI7QBvle5hxGYUD/00zMyHOIgYg/cM5sR0qafesoj9kRff5\n"
-            + "UMnSy1dw+pGMv6GqKGbcZDoC060hUO9GhQRPZXF8PlYzD30lOLS2Uw4mPXjOmQVv\n"
-            + "lDiyl/vLkfkVfP/alYH0FW6mErDrjtHhrZewqDm3iPLGMVGfGCJsL+N37VBSe+jr\n"
-            + "4rZCnjk/Jo5JRoKJATcEHwECACEFAlYmphQXDIABBK6n7S+CETPlsSjR7QYl3EYy\n"
-            + "iowCBwAACgkQjE/SjjQ0s59h3gf/cal/F8n6czMEKGfUmoWso6qpiI47ixTC5tfO\n"
-            + "lE7fdXaNStRXvk9NYb/o2WANs98oVxTSpm53ozYRDFJL38jbSL8ylEHuomdYwz/o\n"
-            + "fF0wMnSj+k3giYgaPfxBMhbHQNL2fdvCVyhuXtmUzyDY3WS/mNA8S66t994lFLif\n"
-            + "LikS8IKbYhJIY64HQNW1gcYOsktDSTiYOQQm5Tx8/m6fyY3wjx4Ag2WxMWmxOzAx\n"
-            + "cyKm1lOblIjlBmUbebky+9ySeBh2HKe8JZzokca3UQdeUjgDlB62v/gVs0Ti5hwQ\n"
-            + "SHLMHLhZB+JAI+IjPObc7hwyev037kfNipGG7Y695ibzobZHGrQgVGVzdHVzZXIg\n"
-            + "U2l4IDx0ZXN0NkBleGFtcGxlLmNvbT6JATgEEwECACIFAlYmpXkCGwMGCwkIBwMC\n"
-            + "BhUIAgkKCwQWAgMBAh4BAheAAAoJEIxP0o40NLOfZe0H/jCXhy1zdxb0XCnXtyS0\n"
-            + "WLqZ3x+NGJHAT+u5UogqYAfN4wsGhmfgtlW6fI0JEYtl8/4xSzgmEuSnyKhPNQHv\n"
-            + "E+X2T3gVd6Vs5uf0XRPuG2MUuIUA4x7KSd7KWTaRqVO3ogEq1o4+f5XqicHA75gd\n"
-            + "vC37EsyEBaNn9YDRMgLVEi6R3NYSuF372UbwH2S6kYLls+j8CAI+hqM+FLWouu2H\n"
-            + "6UKw0ldP9G2TonHmibKnExc/ODM21QswU42zNpGceesIdCD/IlkMLDd99blKF/J9\n"
-            + "Z0UzrmOAQqicwovPL1dX4EzoglCWAcIEA/xYcpJ/iowbj+ERYuvAD9tNDvDQNJXz\n"
-            + "yaydA5gEVialeQEIALMgwUMLqRxXny68XdfA6tLcOYo08M3M7/gDL+I7Flub700/\n"
-            + "xBYQSb4DMK1kx9h1nwLcOO7PGSFxpwj8TlqBkgJ2zpuzT5VLtUoR41in9StMX2mR\n"
-            + "k1ex4Ksk+CtTfV6gZVzlsTlzRMYuECNswq1HktkUykBeCkDuFclJK4BcazyCOQmR\n"
-            + "hIlicnAg7J3E6LmOacT9TlC4yMEbRvDEVfGRoNrmZx5sn/oLyaCQrIBNtCQJ7tgk\n"
-            + "aXUksri9jbzJtIyEwO1mLCC8rN34dxCv7FRC+aBIwCklozEo8dEDUis/9JNdQQCN\n"
-            + "wZ+T8cLP/SPuYkbZy8LF44wCan+lPOFKheKpQ2UAEQEAAQAH/A1Os+Tb9yiGnuoN\n"
-            + "LuiSKa/YEgNBOxmC7dnuPK6xJpBQNZc200WzWJMf8AwVpl4foNxIyYb+Rjbsl1Ts\n"
-            + "z5JcOWFq+57oE5O7D+EMkqf5tFZO4nC4kqprac41HSW02mW/A0DDRKcIt/WEIwlK\n"
-            + "sWzHmjJ736moAtl/holRYQS0ePgB8bUPDQcFovH6X3SUxlPGTYD1DEX+WNvYRk3r\n"
-            + "pa9YXH65qbG9CEJIFTmwZIRDl+CBtBlN/fKadyMJr9fXtv7Fu9hNsK1K1pUtLqCa\n"
-            + "nc22Zak+o+LCPlZ8vmw/UmOGtp2iZlEragmh2rOywp0dHF7gsdlgoafQf8Q4NIag\n"
-            + "TFyHf1kEAMSOKUUwLBEmPnDVfoEOt5spQLVtlF8sh/Okk9zVazWmw0n/b1Ef72z6\n"
-            + "EZqCW9/XhH5pXfKJeV+08hroHI6a5UESa7/xOIx50TaQdRqjwGciMnH2LJcpIU/L\n"
-            + "f0cGXcnTLKt4Z2GeSPKFTj4VzwmwH5F/RYdc5eiVb7VNoy9DC5RZBADpTVH5pklS\n"
-            + "44VDJIcwSNy1LBEU3oj+Nu+sufCimJ5B7HLokoJtm6q8VQRga5hN1TZkdQcLy+b2\n"
-            + "wzxHYoIsIsYFfG/mqLZ3LJNDFqze1/Kj987DYSUGeNYexMN2Fkzbo35Jf0cpOiao\n"
-            + "390JFOS7qecUak5/yJ/V4xy8/nds37617QP9GWlFBykDoESBC2AIz8wXcpUBVNeH\n"
-            + "BNSthmC+PJPhsS6jTQuipqtXUZBgZBrMHp/bA8gTOkI4rPXycH3+ACbuQMAjbFny\n"
-            + "Kt69lPHD8VWw/82E4EY2J9LmHli+2BcATz89ouC4kqC5zF90qJseviSZPihpnFxA\n"
-            + "1UqMU2ZjsPb4CM9C/YkBHwQYAQIACQUCVialeQIbDAAKCRCMT9KONDSzn+RUB/9C\n"
-            + "8c6G6gFKVLE9yC+e8KXxXfNs1HU/2WCNgbAyy8uHAJPta+aaRQq+1P3Xe4RH3c3z\n"
-            + "EkPay8aEppumI+P396jSnenrh2JyKOw/VRB+mZYo9r9X2y/N6nTxykOX95zNKIuq\n"
-            + "BxNcKhl57BZehAaqqhefBriXBC/hm+PtNMI3bBHOgbBtFyYbaGZzbu47AD6FdsX5\n"
-            + "st7oc5lH/tT3ePxrbaDZf06BJP9AKwuDKJRLDdhhoRqf22fC4SAARj+tyE6+B5d7\n"
-            + "lKXTBMUscfo5LgEEaBzUtEqdIIzZ2Bmq+E47E6+66J4FZjq1Qu8gQiM00/xCLN9z\n"
-            + "i7Y7yHsc/ZvfJhKun0wx\n"
-            + "=M/kw\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * A key revoked by a valid key, due to no longer being used.
-   *
-   * <p>Revoked by {@link #validKeyWithoutExpiration()}.
-   *
-   * <pre>
-   * pub   2048R/3D6C52D0 2015-10-20 [revoked: 2015-10-20]
-   *       Key fingerprint = 32DB 6C31 2ED7 A98D 11B2  43EA FAD2 ABE2 3D6C 52D0
-   * uid                  Testuser Seven &lt;test7@example.com&gt;
-   * </pre>
-   */
-  public static TestKey revokedNoLongerUsedKey() throws Exception {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBFYmq3EBCAC9ssY6QhFsnZqKEPlQrx8Zomblj8qV93/B448isOT2L6OVY7UC\n"
-            + "kKPj6afW5UDkYeyZSmLZfTrpePcbAB8FB3uvd/AS9mHC+6zuBlwlkl9xIXlwUXQP\n"
-            + "KER4LKYNTP21AM+/vTJm4+u26tlZECIZlez31KEeqM30EAm+/pO8VkEp8+1ImfLv\n"
-            + "otndIjMoq9gxJvn6KZeexJT2eKCsSa20vVsmAhuFjLZitU3lEjIROfDiyHUZ2cZ+\n"
-            + "qynfppJCKlHJRu/T9L/yxDFVUFDFSajNzSfjG1g3FEveDITyAhRetVfZbhyJptnV\n"
-            + "jfiHSQkLamPsBmMoKfP+aO5SfsTHTJvxgLUdABEBAAGJAS0EIAECABcFAlYmq8AQ\n"
-            + "HQN0ZXN0NyBub3QgdXNlZAAKCRDtBiXcRjKKjPKqB/sF+ypJZaZ5M4jFdoH/YA3s\n"
-            + "4+VkA/NbLKcrlMI0lbnIrax02jdyTo7rBUJfTwuBs5QeQ25+VfaBcz9fWSv4Z8Bk\n"
-            + "9+w61bQZLQkExZ9W7hnhaapyR0aT0rY48KGtHOPNoMQu9Si+RnRiI024jMUUjrau\n"
-            + "w/exgCteY261VtCPRgyZOlpbX43rsBhF8ott0ZzSfLwaNTHhsjFsD1uH6TSFO8La\n"
-            + "/H1nO31sORlY3+rCGiQVuYIJD1qI7bEjDHYO0nq/f7JjfYKmVBg9grwLsX3h1qZ2\n"
-            + "L3Yz+0eCi7/6T/Sm7PavQ+EGL7+WBXX3qJpwc+EFNHs6VxQp86k6csba0c5mNcaQ\n"
-            + "iQE3BB8BAgAhBQJWJqusFwyAAQSup+0vghEz5bEo0e0GJdxGMoqMAgcAAAoJEPrS\n"
-            + "q+I9bFLQ2BYH/jm+t7pZuv8WqZdb8FiBa9CFfhcSKjYarMHjBw7GxWZJMd5VR4DC\n"
-            + "r4T/ZSAGRKBRKQ2uXrkm9H0NPDp0c/UKCHtQMFDnqTk7B63mwSR1d7W0qaRPXYQ1\n"
-            + "bbatnzkEDOj0e+rX6aiqVRMo/q6uMNUFl6UMrUZPSNB5PVRQWPnQ7K11mw3vg0e5\n"
-            + "ycqJbyFvER6EtyDUXGBo8a5/4bK8VBNBMTAIy6GeGpeSM5b7cpQk7/j4dXugCJAV\n"
-            + "fhFNUOgLduoIKM4u+VcFjk3Km/YxOtGi1dLqCbTX/0LiCRA9mgQpyNVyA+Sm48LM\n"
-            + "LUkbcrN/F3SHX1ao/5lm19r8Biu1ziQnLgC0IlRlc3R1c2VyIFNldmVuIDx0ZXN0\n"
-            + "N0BleGFtcGxlLmNvbT6JATgEEwECACIFAlYmq3ECGwMGCwkIBwMCBhUIAgkKCwQW\n"
-            + "AgMBAh4BAheAAAoJEPrSq+I9bFLQvjQH/0K7aBsGU2U/rm4I+u+uPa6BnFTYQJqg\n"
-            + "034pwdD0WfM3M/XgVh7ERjnR9ZViCMVej+K3kW5d2DNaXu5vVpcD6L6jjWwiJHBw\n"
-            + "LIcmpqQrL0TdoCr4F4FKQnBbcH1fNvP8A/hLDHB3k3ERPvEFIo1AkVuK4s/v7yZY\n"
-            + "HAowX0r4ok4ndu/wAc0HI1FkApkAfh18JDTuui53dkKhnkDp7Xnfm/ElAZYjB7Se\n"
-            + "ivxOD9vdhViWSx1VhttPZo5hSyJrEYaJ5u9hsXNUN85DxgLqCmS1v8n3pN1lVY/Q\n"
-            + "TYXtgocakQgHGEG0Tl6a3xpNkn9ihnyCr80mHCxXTyUUBGfygccelB+5AQ0EViar\n"
-            + "cQEIAKxwXb6HGV9QjepADyWW7GMxc2JVZ7pZM2sdf8wrgnQqV2G1rc9gAgwTX4jt\n"
-            + "OY0vSKT1vBq09ZXS3qpYHi/Wwft0KkaX/a7e6vKabDSfhilxC2LuGz2+56f6UOzj\n"
-            + "ggwf5k4LFTQvkDUZumwPjoeC2hqQO3Q/9PW39C6GnvsCr5L0MRdO3PbVJM7lJaOk\n"
-            + "MbGwgysErWgiZXKlxMpIvffIsLC4BAxnjXaCy6zHuBcPMPaRMs7sDRBzeuTV2wnX\n"
-            + "Sd+IXZgdpd1hF7VkuXenzwOqvBGS66C3ILW0ZTFaOtgrloIkTvtYEcJFWvxqWl2F\n"
-            + "+JQ5V6eu2aJ3HIGyr9L1R8MUA6EAEQEAAYkBHwQYAQIACQUCViarcQIbDAAKCRD6\n"
-            + "0qviPWxS0M0PB/9Rbk4/pNW67+uE1lwtaIG7uFiMbJqu8jK6MkD8GdayflroWEZA\n"
-            + "x0Xow9HL8UaRfeRPTZMrDRpjl+fJIXT5qnlB0FPmzSXAKr3piC8migBcbp5m6hWh\n"
-            + "c3ScAqWOeMt9j0TTWHh4hKS8Q+lK392ht65cI/kpFhxm9EEaXmajplNL/2G3PVrl\n"
-            + "fFUgCdOn2DYdVSgJsfBhkcoiy17G3vqtb+We6ulhziae4SIrkUSqdYmRjiFyvqZz\n"
-            + "tmMEoF6CQNCUb1NK0TsSDeIdDacYjUwyq0Qj6TaXrWcbC3kW0GtWoFTNIiX4q9bN\n"
-            + "+B6paw/s8P7XCWznTBRdlFWWgrhcpzQ8fefC\n"
-            + "=CHer\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBFYmq3EBCAC9ssY6QhFsnZqKEPlQrx8Zomblj8qV93/B448isOT2L6OVY7UC\n"
-            + "kKPj6afW5UDkYeyZSmLZfTrpePcbAB8FB3uvd/AS9mHC+6zuBlwlkl9xIXlwUXQP\n"
-            + "KER4LKYNTP21AM+/vTJm4+u26tlZECIZlez31KEeqM30EAm+/pO8VkEp8+1ImfLv\n"
-            + "otndIjMoq9gxJvn6KZeexJT2eKCsSa20vVsmAhuFjLZitU3lEjIROfDiyHUZ2cZ+\n"
-            + "qynfppJCKlHJRu/T9L/yxDFVUFDFSajNzSfjG1g3FEveDITyAhRetVfZbhyJptnV\n"
-            + "jfiHSQkLamPsBmMoKfP+aO5SfsTHTJvxgLUdABEBAAEAB/9AdCtFJSidcolNKwpC\n"
-            + "/1V+VL9IdYxcWx02CDccjuUkvrgCrL+WcQW2jS/hZMChOKJ2zR78DcBEDr1LF8Xy\n"
-            + "ZAIC8yoHj15VLUUrFM8fVvYFzt1fq9VWxxRIjscW0teLNgzgdYzYB84RtwcFa2Vi\n"
-            + "sx2ycTUTYUClEgP1uLMCtX3rnibJh4vR+lVgnDtKSoh4CLAlW6grAAVdw5sSuV7Q\n"
-            + "i9EJcPezGw1RvBU5PooqNDG6kyw/QqsAS4q3WP4uVJKK1e7S9oqXFEN8k/zfllI0\n"
-            + "SSkoyP2flzz71rJF/wQMfJ8uf/CelKXd+gPO4FbCWiZSTLe20JR23qiOyvZkfCwg\n"
-            + "eFmzBADIJUzspDrg5yaqE+HMc8U3O9G9FHoDSweZTbhiq3aK0BqMAn34u0ps6chy\n"
-            + "VMO6aPWVzgcSHNfTlzpjuN9lwDoimYBH5vZa1HlCHt5eeqTORixkxSerOmILabTi\n"
-            + "QWq5JPdJwYZiSvK45G5k3G37RTd6/QyhTlRYXj59RXYajrYngwQA8qMZRkRYcTop\n"
-            + "aG+5M0x44k6NgIyH7Ap+2vRPpDdUlHs+z+6iRvoutkSfKHeZUYBQjgt+tScfn1hM\n"
-            + "BRB+x146ecmSVh/Dh8yu6uCrhitFlKpyJqNptZo5o+sH41zjefpMd/bc8rtHTw3n\n"
-            + "GiFl57ZbXbze2O8UimUVgRI2DtOebt8EAJHM/8vZahzF0chzL4sNVAb8FcNYxAyn\n"
-            + "95VpnWeAtKX7f0bqUvIN4BNV++o6JdMNvBoYEQpKeQIda7QM59hNiS8f/bxkRikF\n"
-            + "OiHB5YGy2zRX5T1G5rVQ0YqrOu959eEwdGZmOQ8GOqq5B/NoHXUtotV6SGE3R+Tl\n"
-            + "grlV4U5/PT0fM3KJATcEHwECACEFAlYmq6wXDIABBK6n7S+CETPlsSjR7QYl3EYy\n"
-            + "iowCBwAACgkQ+tKr4j1sUtDYFgf+Ob63ulm6/xapl1vwWIFr0IV+FxIqNhqsweMH\n"
-            + "DsbFZkkx3lVHgMKvhP9lIAZEoFEpDa5euSb0fQ08OnRz9QoIe1AwUOepOTsHrebB\n"
-            + "JHV3tbSppE9dhDVttq2fOQQM6PR76tfpqKpVEyj+rq4w1QWXpQytRk9I0Hk9VFBY\n"
-            + "+dDsrXWbDe+DR7nJyolvIW8RHoS3INRcYGjxrn/hsrxUE0ExMAjLoZ4al5Izlvty\n"
-            + "lCTv+Ph1e6AIkBV+EU1Q6At26ggozi75VwWOTcqb9jE60aLV0uoJtNf/QuIJED2a\n"
-            + "BCnI1XID5KbjwswtSRtys38XdIdfVqj/mWbX2vwGK7XOJCcuALQiVGVzdHVzZXIg\n"
-            + "U2V2ZW4gPHRlc3Q3QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCViarcQIbAwYLCQgH\n"
-            + "AwIGFQgCCQoLBBYCAwECHgECF4AACgkQ+tKr4j1sUtC+NAf/QrtoGwZTZT+ubgj6\n"
-            + "7649roGcVNhAmqDTfinB0PRZ8zcz9eBWHsRGOdH1lWIIxV6P4reRbl3YM1pe7m9W\n"
-            + "lwPovqONbCIkcHAshyampCsvRN2gKvgXgUpCcFtwfV828/wD+EsMcHeTcRE+8QUi\n"
-            + "jUCRW4riz+/vJlgcCjBfSviiTid27/ABzQcjUWQCmQB+HXwkNO66Lnd2QqGeQOnt\n"
-            + "ed+b8SUBliMHtJ6K/E4P292FWJZLHVWG209mjmFLImsRhonm72Gxc1Q3zkPGAuoK\n"
-            + "ZLW/yfek3WVVj9BNhe2ChxqRCAcYQbROXprfGk2Sf2KGfIKvzSYcLFdPJRQEZ/KB\n"
-            + "xx6UH50DmARWJqtxAQgArHBdvocZX1CN6kAPJZbsYzFzYlVnulkzax1/zCuCdCpX\n"
-            + "YbWtz2ACDBNfiO05jS9IpPW8GrT1ldLeqlgeL9bB+3QqRpf9rt7q8ppsNJ+GKXEL\n"
-            + "Yu4bPb7np/pQ7OOCDB/mTgsVNC+QNRm6bA+Oh4LaGpA7dD/09bf0Loae+wKvkvQx\n"
-            + "F07c9tUkzuUlo6QxsbCDKwStaCJlcqXEyki998iwsLgEDGeNdoLLrMe4Fw8w9pEy\n"
-            + "zuwNEHN65NXbCddJ34hdmB2l3WEXtWS5d6fPA6q8EZLroLcgtbRlMVo62CuWgiRO\n"
-            + "+1gRwkVa/GpaXYX4lDlXp67ZonccgbKv0vVHwxQDoQARAQABAAf5Ae8xa1mPns1E\n"
-            + "B5yCrvzDl79Dw0F1rED46IWIW/ghpVTzmFHV6ngcvcRFM5TZquxHXSuxLv7YVxRq\n"
-            + "UVszXNJaEwyJYYkDRwAS1E2IKN+gknwapm2eWkchySAajUsQt+XEYHFpDPtQRlA3\n"
-            + "Z6PrCOPJDOLmT9Zcf0R6KurGrhvTGrZkKU6ZCFqZWETfZy5cPfq2qxtw3YEUI+eT\n"
-            + "09AgMmPJ9nDPI3cA69tvy/phVFgpglsS76qgd6uFJ5kcDoIB+YepmJoHnzJeowYt\n"
-            + "lvnmmyGqmVS/KCgvILaD0c73Dp2X0BN64hSZHa3nUU67WbKJzo2OXr+yr0hvofcf\n"
-            + "8vhKJe5+2wQAy+rRKSAOPaFiKT8ZenRucx1pTJLoB8JdediOdR4dtXB2Z59Ze7N3\n"
-            + "sedfrJn1ao+jJEpnKeudlDq7oa9THd7ZojN4gBF/lz0duzfertuQ/MrHaTPeK8YI\n"
-            + "dEPg3SgYVOLDBptaKmo0xr2f6aslGLPHgxCgzOcLuuUNGKJSigZvhdMEANh7VKsX\n"
-            + "nb5shZh+KRET84us/uu74q4iIfc8Q10oXuN9+IPlqfAIclo4uMhvo5rtI9ApFtxs\n"
-            + "oZzqqc+gt+OAbn/fHeb61eT36BA+r61Ka+erxkpWU5r1BPVIqq+biTY/HHchqroJ\n"
-            + "aw81qWudO9h5a0yP1alDiBSwhZWIMCKzp6Q7A/472amrSzgs7u8ToQ/2THDxaMf3\n"
-            + "Se0HgMrIT1/+5es2CWiEoZGSZTXlimDYXJULu/DFC7ia7kXOLrMsO85bEi7SHagA\n"
-            + "eO+mAw3xP3OuNkZDt9x4qtal28fNIz22DH5qg2wtsGdCWXz5C6OdcrtQ736kNxa2\n"
-            + "5QemZ/0VWxHPnvXz40RtiQEfBBgBAgAJBQJWJqtxAhsMAAoJEPrSq+I9bFLQzQ8H\n"
-            + "/1FuTj+k1brv64TWXC1ogbu4WIxsmq7yMroyQPwZ1rJ+WuhYRkDHRejD0cvxRpF9\n"
-            + "5E9NkysNGmOX58khdPmqeUHQU+bNJcAqvemILyaKAFxunmbqFaFzdJwCpY54y32P\n"
-            + "RNNYeHiEpLxD6Urf3aG3rlwj+SkWHGb0QRpeZqOmU0v/Ybc9WuV8VSAJ06fYNh1V\n"
-            + "KAmx8GGRyiLLXsbe+q1v5Z7q6WHOJp7hIiuRRKp1iZGOIXK+pnO2YwSgXoJA0JRv\n"
-            + "U0rROxIN4h0NpxiNTDKrRCPpNpetZxsLeRbQa1agVM0iJfir1s34HqlrD+zw/tcJ\n"
-            + "bOdMFF2UVZaCuFynNDx958I=\n"
-            + "=aoJv\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * Key revoked by an expired key, after that key's expiration.
-   *
-   * <p>Revoked by {@link #expiredKey()}.
-   *
-   * <pre>
-   * pub   2048R/78BF7D7E 2005-08-01 [revoked: 2015-10-20]
-   *       Key fingerprint = 916F AB22 5BE7 7585 F59A  994C 001A DF8B 78BF 7D7E
-   * uid                  Testuser Eight &lt;test8@example.com&gt;
-   * </pre>
-   */
-  public static TestKey keyRevokedByExpiredKeyAfterExpiration() throws Exception {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBELuRwABCAC56yhFKybBtuKT4nyb7RdLE98pZR54aGjcDcKH3VKVyBF8Z4Kx\n"
-            + "ptd7Sre0mLPCQiNWVOmCT+JG7GKVE6YeFmyXDUnhX9w4+HAeDEh23S4u9JvwWaF+\n"
-            + "wlJ6jLq/oe5gdT1F6Y2yqNpQ6CztOw52Ko9KSYz7/1zBMPcCkl/4k15ee4iebVdq\n"
-            + "c7qT5Qt49Poiozh0DI5prPQ624uckHkz2mXshjWQVuHWwrkIkCJZ2I/KQN2kBjKw\n"
-            + "/ALxumaWmiB9lQ0nIwLuGzHCh0Xg5RxuCrK8fJp47Aza3ikVuYlNzSxhJVav3OtK\n"
-            + "gftBihQXUlY3Uy/4QTCeH/BdVs5OALtXL3VhABEBAAGJAS0EIAECABcFAlYmr4kQ\n"
-            + "HQN0ZXN0OCBub3QgdXNlZAAKCRA87HgbF94azQJ5B/0TeQk7TSChNp+NqCKPTuw0\n"
-            + "wpflDyc+5ru/Gcs4r358cWzgiLUb3M0Q1+M8CF13BFQdrxT05vjheI9o5PCn3b//\n"
-            + "AHV8m+QFSnRi2J3QslbvuOqOnipz7vc7lyZ7q1sWNC33YN+ZcGZiMuu5HJi9iadf\n"
-            + "ZL7AdInpUb4Zb+XKphbMokDcN3yw7rqSMMcx+rKytUAqUnt9qvaSLrIH/zeazxlp\n"
-            + "YG4jaN53WPfLCcGG+Rw56mW+eCQD2rmzaNHCw8Qr+19sokXLB7OML+rd1wNwZT4q\n"
-            + "stWnL+nOj8ZkbFV0w3zClDYaARr7H+vTckwVStyDVRbnpRitSAtJwbRDzZBaS4Vx\n"
-            + "iQE3BB8BAgAhBQJC7lUQFwyAAR2e63ndOLBJk52crzzseBsX3hrNAgcAAAoJEAAa\n"
-            + "34t4v31+AS4H/0x3Y9E3q9DR5FCuYTXG4BHyrALo2WKoP0CfUWL98Fw9Txl0hF+9\n"
-            + "5wriNlnmd2zvM0quHs78x4/xehQO88cw0lqPx3RARq/ju5/VbOjoNlcHvfGYZiEd\n"
-            + "yWOwHu7O8sZrenFDjeDglD6NArrjncOcC51XIPSSTLvVQpSauQ1FS4tan5Q4aWMb\n"
-            + "s4DzE+Vqu2xMkO/X9toYAZKzyWP29OckpouMbt3GUnS6/o0A8Z7jVX+XOIk3XolP\n"
-            + "Li9tzTQB12Xl23mgFvearDoguR2Bu2SbmTJtdiXz8L3S54kGvxVqak5uOP2dagzU\n"
-            + "vBiqR4SVoAdGoXt6TI6mpA+qdYmPMG8v21S0IlRlc3R1c2VyIEVpZ2h0IDx0ZXN0\n"
-            + "OEBleGFtcGxlLmNvbT6JATgEEwECACIFAkLuRwACGwMGCwkIBwMCBhUIAgkKCwQW\n"
-            + "AgMBAh4BAheAAAoJEAAa34t4v31+8/sIAIuqd+dU8k9c5VQ12k7IfZGGYQHF2Mk/\n"
-            + "8FNuP7hFP/VOXBK3QIxIfGEOHbDX6uIxudYMaDmn2UJbdIqJd8NuQByh1gqXdX/x\n"
-            + "nteUa+4e7U6uTjkp/Ij5UzRed8suINA3NzVOy6qwCu3DTOXIZcjiOZtOA5GTqG6Z\n"
-            + "naDP0hwDssJp+LXIYTJgsvneJQFGSdQhhJSv19oV0JPSbb6Zc7gEIHtPcaJHjuZQ\n"
-            + "Ev+TRcRrI9HPTF0MvgOYgIDo2sbcSFV+8moKsHMC+j1Hmuuqgm/1yKGIZrt0V75s\n"
-            + "D9HYu0tiS3+Wlsry3y1hg/2XBQbwgh6sT/jWkpWar7+uzNxO5GdFYrC5AQ0EQu5H\n"
-            + "AAEIALPFTedbfyK+9B35Uo9cPsmFa3mT3qp/bAQtnOjiTTTiIO3tu0ALnaBjf6On\n"
-            + "fAV1HmGz6hRMRK4LGyHkNTaGDNNPoXO7+t9DWycSHmsCL5d5zp7VevQE8MPR8zHK\n"
-            + "Il2YQlCzdy5TWSUhunKd4guDNZ9GiOS6NQ9feYZ9DQ1kzC8nnu7jLkR2zNT02sYU\n"
-            + "kuOCZUktQhVNszUlavdIFjvToZo3RPcdb/E3kTTy2R9xi89AXjWZf3lSAZe3igkL\n"
-            + "jhwsd+u3RRx0ptOJym7zYl5ZdUZk4QrS7FPI6zEBpjawbS4/r6uEW89P3QAkanDI\n"
-            + "ridIAZP8awLZU3uSPtMwPIJpao0AEQEAAYkBHwQYAQIACQUCQu5HAAIbDAAKCRAA\n"
-            + "Gt+LeL99fqpHB/wOXhdMNtgeVW38bLk8YhcEB23FW6fDjFjBJb9m/yqRTh5CIeG2\n"
-            + "bm29ofT4PTamPb8Gt+YuDLnQQ3K2jURakxNDcYwiurvR/oHVdxsBRU7Px7UPeZk3\n"
-            + "BG5VnIJRT198dF7MWFJ+x5wHbNXwM8DDvUwTjXLH/TlGl1XIheSTHCYd9Pra4ejE\n"
-            + "ockkrDaZlPCQdTwY+P7K2ieb5tsqNpJkQeBrglF2bemY/CtQHnM9qwa6ZJqkyYNR\n"
-            + "F1nkSYn36BPuNpytYw1CaQV9GbePugPHtshECLwA160QzqISQUcJlKXttUqUGnoO\n"
-            + "0d0PyzZT3676mQwmFoebMR9vACAeHjvDxD4F\n"
-            + "=ihWb\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBELuRwABCAC56yhFKybBtuKT4nyb7RdLE98pZR54aGjcDcKH3VKVyBF8Z4Kx\n"
-            + "ptd7Sre0mLPCQiNWVOmCT+JG7GKVE6YeFmyXDUnhX9w4+HAeDEh23S4u9JvwWaF+\n"
-            + "wlJ6jLq/oe5gdT1F6Y2yqNpQ6CztOw52Ko9KSYz7/1zBMPcCkl/4k15ee4iebVdq\n"
-            + "c7qT5Qt49Poiozh0DI5prPQ624uckHkz2mXshjWQVuHWwrkIkCJZ2I/KQN2kBjKw\n"
-            + "/ALxumaWmiB9lQ0nIwLuGzHCh0Xg5RxuCrK8fJp47Aza3ikVuYlNzSxhJVav3OtK\n"
-            + "gftBihQXUlY3Uy/4QTCeH/BdVs5OALtXL3VhABEBAAEAB/wLr88oGuxsoqIHRQZL\n"
-            + "eGm9jc4aQGmcDMcjpwdGilhrwyfrO6f84hWbQdD+rJcnI8hsH7oOd5ZMGkWfpJyt\n"
-            + "eUAh9iNB5ChYGfDVSLUg6KojqDtprj6vNMihvLkr/OI6xL/hZksikwfnLFMPpgXU\n"
-            + "knwPocQ3nn+egsUSL7CR8/SLiIm4MC0brer6jhDxB5LKweExNlfTe4c0MDeYTsWt\n"
-            + "0WGzNPlvRZQXRotJzqemt3wdNZXUnCKR0n7pSQ8EhZr2O6NXr+mUgp6PIOE/3un2\n"
-            + "YGiBEf5uy3qEFe7FjEGIHz+Z3ySRdUDfHOk82TKAzynoJIxRUvLIYVNw4eFB3l5U\n"
-            + "s1w5BADUzfciG7RVLa8UFKJfqQ/5M06QmdS1v1/hMQXg38+3vKe8RgfSSnMJ08Sc\n"
-            + "eAEsmugwpNXAxgRKHcmWzN3NMBHhE3KiyiogWaMGqmSo6swFpu0+dwMvZSxMlfD+\n"
-            + "ka/BWt8YsUdrqW06ow39aTgCV+icbNRV81C7NKe7u0X1JDx2CQQA36gbdo62h/Wd\n"
-            + "gJI8kdz/se3xrt8x6RoWvOnWPNmsZR5XkDqAMTL1dWiEEA/dQTphMcgAe9z3WaP+\n"
-            + "F1TPAfounbiurGCcS3kxJ5tY7ojyU7nYz4DA/V2OU0C/LUoLXhttG5HM+m/i3qn4\n"
-            + "K9bBoWIQY1ijliS7cTSwNqd6IHaQGpkEAMnp5GwSGhY+kUuLw06hmH4xnsuf6agz\n"
-            + "AfhbPylB2nf/ZaX6dt6/mFEAkvQNahcoWEskfS3LGCD8jHm8PvF8K0mciXPDweq2\n"
-            + "gW3/irE0RXNwn3Oa222VSvcgUlocBm9InkfvpFXh20OYFe3dFH7uYkwUqIHJeXjw\n"
-            + "TjpXUX/vC5QJQOyJATcEHwECACEFAkLuVRAXDIABHZ7red04sEmTnZyvPOx4Gxfe\n"
-            + "Gs0CBwAACgkQABrfi3i/fX4BLgf/THdj0Ter0NHkUK5hNcbgEfKsAujZYqg/QJ9R\n"
-            + "Yv3wXD1PGXSEX73nCuI2WeZ3bO8zSq4ezvzHj/F6FA7zxzDSWo/HdEBGr+O7n9Vs\n"
-            + "6Og2Vwe98ZhmIR3JY7Ae7s7yxmt6cUON4OCUPo0CuuOdw5wLnVcg9JJMu9VClJq5\n"
-            + "DUVLi1qflDhpYxuzgPMT5Wq7bEyQ79f22hgBkrPJY/b05ySmi4xu3cZSdLr+jQDx\n"
-            + "nuNVf5c4iTdeiU8uL23NNAHXZeXbeaAW95qsOiC5HYG7ZJuZMm12JfPwvdLniQa/\n"
-            + "FWpqTm44/Z1qDNS8GKpHhJWgB0ahe3pMjqakD6p1iY8wby/bVLQiVGVzdHVzZXIg\n"
-            + "RWlnaHQgPHRlc3Q4QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCQu5HAAIbAwYLCQgH\n"
-            + "AwIGFQgCCQoLBBYCAwECHgECF4AACgkQABrfi3i/fX7z+wgAi6p351TyT1zlVDXa\n"
-            + "Tsh9kYZhAcXYyT/wU24/uEU/9U5cErdAjEh8YQ4dsNfq4jG51gxoOafZQlt0iol3\n"
-            + "w25AHKHWCpd1f/Ge15Rr7h7tTq5OOSn8iPlTNF53yy4g0Dc3NU7LqrAK7cNM5chl\n"
-            + "yOI5m04DkZOobpmdoM/SHAOywmn4tchhMmCy+d4lAUZJ1CGElK/X2hXQk9Jtvplz\n"
-            + "uAQge09xokeO5lAS/5NFxGsj0c9MXQy+A5iAgOjaxtxIVX7yagqwcwL6PUea66qC\n"
-            + "b/XIoYhmu3RXvmwP0di7S2JLf5aWyvLfLWGD/ZcFBvCCHqxP+NaSlZqvv67M3E7k\n"
-            + "Z0VisJ0DmARC7kcAAQgAs8VN51t/Ir70HflSj1w+yYVreZPeqn9sBC2c6OJNNOIg\n"
-            + "7e27QAudoGN/o6d8BXUeYbPqFExErgsbIeQ1NoYM00+hc7v630NbJxIeawIvl3nO\n"
-            + "ntV69ATww9HzMcoiXZhCULN3LlNZJSG6cp3iC4M1n0aI5Lo1D195hn0NDWTMLyee\n"
-            + "7uMuRHbM1PTaxhSS44JlSS1CFU2zNSVq90gWO9OhmjdE9x1v8TeRNPLZH3GLz0Be\n"
-            + "NZl/eVIBl7eKCQuOHCx367dFHHSm04nKbvNiXll1RmThCtLsU8jrMQGmNrBtLj+v\n"
-            + "q4Rbz0/dACRqcMiuJ0gBk/xrAtlTe5I+0zA8gmlqjQARAQABAAf+JNVkZOcGYaQm\n"
-            + "eI3BMMaBxuCjaMG3ec+p3iFKaR0VHKTIgneXSkQXA+nfGTUT4DpjAznN2GLYH6D+\n"
-            + "6i7MCGPm9NT4C7KUcHJoltTLjrlf7vVyNHEhRCZO/pBh9+2mpO6xh799x+wj88u5\n"
-            + "XAqlah50OjJFkjfk70VsrPWqWvgwLejkaQpGbE+pdL+vjy+ol5FHzidzmJvsXDR1\n"
-            + "I1as0vBu5g2XPpexyVanmHJglZdZX07OPYQBhxQKuPXT/2/IRnXsXEpitk4IyJT0\n"
-            + "U5D/iedEUldhBByep1lBcJnAap0CP7iuu2CYhRp6V2wVvdweNPng5Eo7f7LNyjnX\n"
-            + "UMAeaeCjAQQA1A0iKtg3Grxc9+lpFl1znc2/kO3p6ixM13uUvci+yGFNJJninnxo\n"
-            + "99KXEzqqVD0zerjiyyegQmzpITE/+hFIOJZInxEH08WQwZstV/KYeRSJkXf0Um48\n"
-            + "E+Zrh8fpJVW1w3ZCw9Ee2yE6fEhAA4w66+50pM+vBXanWOrG1HDrkxEEANkHc2Rz\n"
-            + "YJsO4v63xo/7/njLSQ31miOglb99ACKBA0Yl/jvj2KqLcomKILqvK3DKP+BHNq86\n"
-            + "LUBUglyKjKuj0wkSWT0tCnfgLzysUpowcoyFhJ36KzAz8hjqIn3TQpMF21HvkZdG\n"
-            + "Mtkcyhu5UDvbfOuWOBaKIeNQWCWv1rNzMme9A/9zU1+esEhKwGWEqa3/B/Te/xQh\n"
-            + "alk180n74sTZid6lXD8o8cEei0CUq7zBSV0P8v6kk8PP9/XyLRl3Rqa95fESUWrL\n"
-            + "xD6TBY1JlHBZS+N6rN/7Ilf5EXSELmnbDFsVxkNGp4elKxajvZxC6uEWYBu62AYy\n"
-            + "wS0dj8mZR3faCEps90YXiQEfBBgBAgAJBQJC7kcAAhsMAAoJEAAa34t4v31+qkcH\n"
-            + "/A5eF0w22B5VbfxsuTxiFwQHbcVbp8OMWMElv2b/KpFOHkIh4bZubb2h9Pg9NqY9\n"
-            + "vwa35i4MudBDcraNRFqTE0NxjCK6u9H+gdV3GwFFTs/HtQ95mTcEblWcglFPX3x0\n"
-            + "XsxYUn7HnAds1fAzwMO9TBONcsf9OUaXVciF5JMcJh30+trh6MShySSsNpmU8JB1\n"
-            + "PBj4/sraJ5vm2yo2kmRB4GuCUXZt6Zj8K1Aecz2rBrpkmqTJg1EXWeRJiffoE+42\n"
-            + "nK1jDUJpBX0Zt4+6A8e2yEQIvADXrRDOohJBRwmUpe21SpQaeg7R3Q/LNlPfrvqZ\n"
-            + "DCYWh5sxH28AIB4eO8PEPgU=\n"
-            + "=cSfw\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * Key revoked by an expired key, before that key's expiration.
-   *
-   * <p>Revoked by {@link #expiredKey()}.
-   *
-   * <pre>
-   * pub   2048R/C43BF2E1 2005-08-01 [revoked: 2005-08-01]
-   *       Key fingerprint = 916D 6AD6 36A5 CBA6 B5A6  7274 6040 8661 C43B F2E1
-   * uid                  Testuser Nine &lt;test9@example.com&gt;
-   * </pre>
-   */
-  public static TestKey keyRevokedByExpiredKeyBeforeExpiration() throws Exception {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBELuRwABCADnf2z5dqp3BMFlpd6iUs5dhROrslfzswak1LmbGirK2IPIl4NX\n"
-            + "arAi76xXK9BcF/Cqcj/X/WqFKBd/qMGxwdvwbSN6PVBP6T1jvuVgrPTjd4x5xPUD\n"
-            + "xZ5VPy9hgQXs+1mugTkHYVTU8GI1eGpZ8Oj3PJIgVyqGxGkjWmcz5APbVIRan6L1\n"
-            + "482bZTidH9Nd9YnYlXNgiJcaOPAVBwO/j/myocQCIohvIo4IT8vc/ODhRgfwA0gD\n"
-            + "GVK+tXwT4f4x3qjG/YRpOOZZjBS09B/gJ9QfEnR6WNxg/Tm3T0uipoISOhR+cP/V\n"
-            + "e5o/73SM+w+WlILk/xpbbOfyCxD4Q3lb8EZFABEBAAGJAS0EIAECABcFAkLuYyAQ\n"
-            + "HQN0ZXN0OSBub3QgdXNlZAAKCRA87HgbF94azV2BB/9Rc1j3XOxKbDyUFAORAGnE\n"
-            + "ezQtpOmQhaSUhFC35GFOdTg4eX53FTFSXLJQleTVzvE+eVkQI5tvUZ+SqHoyjnhU\n"
-            + "DpWlmfRUQy4GTUjUTkpFOK07TVTjhUQwaAxN13UZgByopVKc7hLf+uh1xkRJIqAJ\n"
-            + "Tx6LIFZiSIGwStDO6TJlhl1e8h45J3rAV4N+DsGpMy9S4uYOU7erJDupdXK739/l\n"
-            + "VBsP2SeT85iuAv+4A9Jq3+iq+cjK9q3QZCw1O6iI2v3seAWCI6HH3tVw4THr+M6T\n"
-            + "EdTGmyESjdAl+f7/uK0QNfqIMpvUf+AvMakrLi7WOeDs8mpUIjonpeQVLfz6I0Zo\n"
-            + "iQE3BB8BAgAhBQJC7lUQFwyAAR2e63ndOLBJk52crzzseBsX3hrNAgcAAAoJEGBA\n"
-            + "hmHEO/LhHjUH/R/7+iNBLAfKYbpprkWy/8eXVEJhxfh6DI/ppsKLIA+687gX74R9\n"
-            + "6CM5k6fZDjeND26ZEA0rDZmYrbnGUfsu55aeM0/+jiSOZJ2uTlrLXiHMurbNY0pT\n"
-            + "xv215muhumPBzuL1jsAK2Kc/4oE7Z46jaStsPCvDOcx9PW76wR8/uCPvHVz5H/A7\n"
-            + "3erXAloC43jupXwZB32VZq8L0kZNVfuEsjHUcu3GUoZdGfTb4/Qq5a1FK+CGhwWC\n"
-            + "OwpUWZEIUImwUv4FNE4iNFYEHaHLU9fotmIxIkH8TC4NcO+GvkEyMyJ6NVkBBDP2\n"
-            + "EarncWAJxDBlx1CO4ET+/ULvzDnAcYuTc6G0IVRlc3R1c2VyIE5pbmUgPHRlc3Q5\n"
-            + "QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCQu5HAAIbAwYLCQgHAwIGFQgCCQoLBBYC\n"
-            + "AwECHgECF4AACgkQYECGYcQ78uG78ggA1TjeOZtaXjXNG8Bx2sl4W+ypylWWB6yc\n"
-            + "IeR0suLhVlisZ33yOtV4MsvZw0TJNyYmFXiskPTyOcP8RJjS+a41IHc33i13MUnN\n"
-            + "RI5cqhqsWRhf9chlm7XqXtqv57IjojG9vgSUeZdXSTMdHIDDHAjJ/ryBXflzprSw\n"
-            + "2Sab8OXjLkyo9z6ZytFyfXSc8TNiWU6Duollh/bWIsgPETIe2wGn8LcFiVMfPpsI\n"
-            + "RhkphOdTJb+W/zQwLHUcS22A4xsJtBxIXTH/QSG3lAaw8IRbl25EIpaEAF+gExCr\n"
-            + "QM0haAVMmGgYYWpMHXrDhB7ff3kAiqD2qmhSySA6NLmTO+6qGPYJg7kBDQRC7kcA\n"
-            + "AQgA2wqE3DypQhTcYl26dXc9DZzABRQa6KFRqQbhmUBz95cQpAamQjrwOyl2fg84\n"
-            + "b9o9t+DuZcdLzLF/gPVSznOcNUV9mJNdLAxBPPOMUrP/+Snb83FkNpCscrXhIqSf\n"
-            + "BU5D+FOb3bEI2WTJ7lLe8oCrWPE3JIDVCrpAWgZk9puAk1Z7ZFaHsS6ezsZP0YIM\n"
-            + "qTWdoX0zHMPMnr9GG08c0mniXtvfcgtOCeIRU4WZws28sGYCoLeQXsHVDal+gcLp\n"
-            + "1enPh6dfEWBJuhhBBajzm53fzV2a7khEdffggVVylHPLpvms2nIqoearDQtVNpSK\n"
-            + "uhNiykJSMIUn/Y6g5LMySmL+MwARAQABiQEfBBgBAgAJBQJC7kcAAhsMAAoJEGBA\n"
-            + "hmHEO/LhdwcH/0wAxT1NGaR2boMjpTouVUcnEcEzHc0dSwuu+06mLRggSdAfBC8C\n"
-            + "9fdlAYHQ5tp1sRuPwLfQZjo8wLxJ+wLASnIPLaGrtpEHkIKvDwHqwkOXvXeGD/Bh\n"
-            + "40NbJUa7Ec3Jpo+FPFlM8hDsUyHf8IhUAdRd4d+znOVEaZ6S7c1RrtoVTUqzi59n\n"
-            + "nC6ZewL/Jp+znKZlMTM3X1onAGhd+/XdrS52LM8pE3xRjbTLTYWcjnjyLbm0yoO8\n"
-            + "G3yCfIibAaII4a/jGON2X9ZUwaFNIqJ4iIc8Nme86rD/flXsu6Zv+NXVQWylrIG/\n"
-            + "REW68wsnWjwTtrPG8bqo6cCsOzqGYVt81eU=\n"
-            + "=FnZg\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBELuRwABCADnf2z5dqp3BMFlpd6iUs5dhROrslfzswak1LmbGirK2IPIl4NX\n"
-            + "arAi76xXK9BcF/Cqcj/X/WqFKBd/qMGxwdvwbSN6PVBP6T1jvuVgrPTjd4x5xPUD\n"
-            + "xZ5VPy9hgQXs+1mugTkHYVTU8GI1eGpZ8Oj3PJIgVyqGxGkjWmcz5APbVIRan6L1\n"
-            + "482bZTidH9Nd9YnYlXNgiJcaOPAVBwO/j/myocQCIohvIo4IT8vc/ODhRgfwA0gD\n"
-            + "GVK+tXwT4f4x3qjG/YRpOOZZjBS09B/gJ9QfEnR6WNxg/Tm3T0uipoISOhR+cP/V\n"
-            + "e5o/73SM+w+WlILk/xpbbOfyCxD4Q3lb8EZFABEBAAEAB/9GTcWLkUU9tf0B4LjX\n"
-            + "NSyk7ChIKXZadVEcN9pSR0Udq1mCTrk9kBID2iPNqWmyvjaBnQbUkoqJ+93/EAIa\n"
-            + "+NPRlWOD2SEN07ioFS5WCNCqUAEibfU2+woVu4WpJ+TjzoWy4F2wZxe7P3Gj6Xjq\n"
-            + "7aXih8uc9Lveh8GiUe8rrCCbt+BH1RzuV/khZw+2ZDPMCx7yfcfKobc3NWx75WLh\n"
-            + "pki512fawSC6eJHRI50ilPrqAmmhcccfwPji9P+oPj2S6wlhe5kp3R5yU85fWy3b\n"
-            + "C8AtLTfZIn4v6NAtBaurGEjRjzeNEGMJHxnRPWvFc4iD+xvPg6SNPJM/bbTE+yZ3\n"
-            + "16W1BADxjAQLMuGpemaVmOpZ3K02hcNjwniEK2QPp11BnfoQCIwegON+sUD/6AuZ\n"
-            + "S1vOVvS3//eGbPaMM45FK/SQAVHpC9IOL4Tql0C8B6csRhFL824yPfc3WDb4kayQ\n"
-            + "T5oLjlJ0W2r7tWcBcREEzZT6gNi4KI7C4oFF6tU9lsQJuQyAbwQA9Vl6VW/7oG0W\n"
-            + "CC+lcHJc+4rxUB3yak7d4mEccTNb+crOBRH/7dKZOe7A6Fz+ra++MmucDUzsAx0K\n"
-            + "MGT9Xoi5+CBBaNr+Y2lB9fF20N7eRNzQ3Xrz2OPl4cmU4gfECTZ1vZaKlmB+Vt8C\n"
-            + "E/nn49QGRI+BNBOdW+2aEpPoENczFosEAJXi5Cn2l0jOswDD7FU2PER1wfVY629i\n"
-            + "bICunudOSo64GKQslKkQWktc57DgdOQnH15qW1nVO7Z4H0GBxjSTRCu7Z7q08/qM\n"
-            + "ueWIvJ85HcFhOCl+vITOn0fZV0p8/IwsWz8G9h5bb2QgMAwDSdhnLuK/cXaGM09w\n"
-            + "n6k8O2rCvDtXRjqJATcEHwECACEFAkLuVRAXDIABHZ7red04sEmTnZyvPOx4Gxfe\n"
-            + "Gs0CBwAACgkQYECGYcQ78uEeNQf9H/v6I0EsB8phummuRbL/x5dUQmHF+HoMj+mm\n"
-            + "wosgD7rzuBfvhH3oIzmTp9kON40PbpkQDSsNmZitucZR+y7nlp4zT/6OJI5kna5O\n"
-            + "WsteIcy6ts1jSlPG/bXma6G6Y8HO4vWOwArYpz/igTtnjqNpK2w8K8M5zH09bvrB\n"
-            + "Hz+4I+8dXPkf8Dvd6tcCWgLjeO6lfBkHfZVmrwvSRk1V+4SyMdRy7cZShl0Z9Nvj\n"
-            + "9CrlrUUr4IaHBYI7ClRZkQhQibBS/gU0TiI0VgQdoctT1+i2YjEiQfxMLg1w74a+\n"
-            + "QTIzIno1WQEEM/YRqudxYAnEMGXHUI7gRP79Qu/MOcBxi5NzobQhVGVzdHVzZXIg\n"
-            + "TmluZSA8dGVzdDlAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJC7kcAAhsDBgsJCAcD\n"
-            + "AgYVCAIJCgsEFgIDAQIeAQIXgAAKCRBgQIZhxDvy4bvyCADVON45m1peNc0bwHHa\n"
-            + "yXhb7KnKVZYHrJwh5HSy4uFWWKxnffI61Xgyy9nDRMk3JiYVeKyQ9PI5w/xEmNL5\n"
-            + "rjUgdzfeLXcxSc1EjlyqGqxZGF/1yGWbtepe2q/nsiOiMb2+BJR5l1dJMx0cgMMc\n"
-            + "CMn+vIFd+XOmtLDZJpvw5eMuTKj3PpnK0XJ9dJzxM2JZToO6iWWH9tYiyA8RMh7b\n"
-            + "AafwtwWJUx8+mwhGGSmE51Mlv5b/NDAsdRxLbYDjGwm0HEhdMf9BIbeUBrDwhFuX\n"
-            + "bkQiloQAX6ATEKtAzSFoBUyYaBhhakwdesOEHt9/eQCKoPaqaFLJIDo0uZM77qoY\n"
-            + "9gmDnQOYBELuRwABCADbCoTcPKlCFNxiXbp1dz0NnMAFFBrooVGpBuGZQHP3lxCk\n"
-            + "BqZCOvA7KXZ+Dzhv2j234O5lx0vMsX+A9VLOc5w1RX2Yk10sDEE884xSs//5Kdvz\n"
-            + "cWQ2kKxyteEipJ8FTkP4U5vdsQjZZMnuUt7ygKtY8TckgNUKukBaBmT2m4CTVntk\n"
-            + "VoexLp7Oxk/RggypNZ2hfTMcw8yev0YbTxzSaeJe299yC04J4hFThZnCzbywZgKg\n"
-            + "t5BewdUNqX6BwunV6c+Hp18RYEm6GEEFqPObnd/NXZruSER19+CBVXKUc8um+aza\n"
-            + "ciqh5qsNC1U2lIq6E2LKQlIwhSf9jqDkszJKYv4zABEBAAEAB/0c76POOw6aazUT\n"
-            + "TZHUnhQ+WHHJefbKuoeWI7w+dD7y+02NzaRoZW7XnJ+fAZW8Dlb5k/O1FayUIEgE\n"
-            + "GjnT336dpE4g5NQkfdifG7Fy5NKGRkWx6viJI3g/OHsYX3+ebNDFMmO0gq7067/9\n"
-            + "WuHsTpvUMRwkF1zi1j4AETjZ7IBXdjuSCSu8OhEwr3d+WXibEmY5ec/d24l/APJx\n"
-            + "c3RMHw9PiDQeAKrByS6N10/yFgRpnouVx3wC7zFmhVewNV476Nyg34OvRoc+lCtk\n"
-            + "ixKdua6KuUJzGRWxgw+q2JD4goXxe0v2qU2KSU63gOYi0kg9tpwpn98lDNQykgmJ\n"
-            + "aQYdNIZJBADdlbkg9qbH1DREs7UF4jXN/SoYRbTh9639GfA4zkbfPmh/RmVIIEKd\n"
-            + "QN7qWK/Xy1bUS9vDzRfFgmoYGtqMmygOOFsVtfm8Y18lSXopN/3vhtai+dn+04Ef\n"
-            + "dl1irmGvm3p7y9Jh3s6uYTEJok0MywA7qBHvgSTVtc1PcZc6j6Bz1QQA/Q+nqyZY\n"
-            + "fLimt4KVYO1y6kSHgEqzggLTxyfGMW5RplTA0V1zCwjM6S+QWNqRxVNdB9Kkzn+S\n"
-            + "YDKHLYs8lXO2zvf8Yk9M7glgqvT4rJ51Zn2rc6lg1YUwFBXup5idTsuZwtqkvvKJ\n"
-            + "eS7L3cSBCqJMRjk47Y3V8zkrrN/HcYmyFecD/A+HPf4eSweUS025Bb+eCk4gTHbR\n"
-            + "uwmnKq7npk2XY4m0A/QdYF9dEWlpadsAr+ZwNQB3f21nQgKG0BudfL4FmpeW9RMt\n"
-            + "35aSIaV7RkxYOt5HEvjFRvLbeL1YYaj+D0dvz8SP1AUPvpWIVlQ03OjRlPyrPW50\n"
-            + "LoqyP8PTb6svnHvmQseJAR8EGAECAAkFAkLuRwACGwwACgkQYECGYcQ78uF3Bwf/\n"
-            + "TADFPU0ZpHZugyOlOi5VRycRwTMdzR1LC677TqYtGCBJ0B8ELwL192UBgdDm2nWx\n"
-            + "G4/At9BmOjzAvEn7AsBKcg8toau2kQeQgq8PAerCQ5e9d4YP8GHjQ1slRrsRzcmm\n"
-            + "j4U8WUzyEOxTId/wiFQB1F3h37Oc5URpnpLtzVGu2hVNSrOLn2ecLpl7Av8mn7Oc\n"
-            + "pmUxMzdfWicAaF379d2tLnYszykTfFGNtMtNhZyOePItubTKg7wbfIJ8iJsBogjh\n"
-            + "r+MY43Zf1lTBoU0ioniIhzw2Z7zqsP9+Vey7pm/41dVBbKWsgb9ERbrzCydaPBO2\n"
-            + "s8bxuqjpwKw7OoZhW3zV5Q==\n"
-            + "=JxsF\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-}
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestTrustKeys.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestTrustKeys.java
deleted file mode 100644
index a469075..0000000
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestTrustKeys.java
+++ /dev/null
@@ -1,1039 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.gpg.testutil;
-
-/**
- * Test keys specific to web-of-trust checks.
- *
- * <p>In the following diagrams, the notation <code>M---N</code> indicates N trusts M, and an 'x'
- * indicates the key is expired.
- *
- * <p>
- *
- * <pre>
- *  A---Bx
- *   \
- *    \---C---D
- *         \
- *          \---Ex
- *
- *  D and E trust C to be a valid introducer of depth 2.
- *
- * F---G---F, in a cycle.
- *
- * H---I---J, but J is only trusted to length 1.
- * </pre>
- */
-public class TestTrustKeys {
-  /**
-   * pub 2048R/9FD0D396 2010-08-29 Key fingerprint = E401 17FC 4BF4 17BD 8F93 DEB1 D25A D07A 9FD0
-   * D396 uid Testuser A &lt;testa@example.com&gt; sub 2048R/F5C099DB 2010-08-29
-   */
-  public static TestKey keyA() throws Exception {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBEx6npoBCACp0vHePNPeLzm0HM35i70bRChyZXu/ARxOHZHNbh6hWJkq5saI\n"
-            + "zuzZoaqXAr3xZwHftTULlkgoJIt40x6VCT8EBnUHTkOqoHTsFnXg2kNuhFvmn0OX\n"
-            + "7RQFlR1SGoQG4fEy6t/GlOwEknpNSIMLkbDMP2FzEkLVWtlIe2hqqawVIgqzyO3k\n"
-            + "HaQxW8gWyyTx0qeSdSi4DyFZIzdyu/aZa7sj/MhO3DB3UwubW6yE+PMcAVrJD+0d\n"
-            + "EToMT7i8Erncc+xEzuXAoQUHaQfOXV4DG5qSgVpKaLxJ/ABWUri0eMPhj0cT4iDx\n"
-            + "eNTL7cZ4h72B1uJs8byDN74PHrypNiVE+IRHABEBAAG0HlRlc3R1c2VyIEEgPHRl\n"
-            + "c3RhQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTHqemgIbAwYLCQgHAwIGFQgCCQoL\n"
-            + "BBYCAwECHgECF4AACgkQ0lrQep/Q05ZxMAf+OoRzXWbGfv7kZb7xdrVyAUTAV4bU\n"
-            + "UvLoJZUIQ1ckPBcty2LUvY7l9efgp3c57nvTD6U98dVnsKfaW4PT0CRXlpl1IFyh\n"
-            + "kgbInFS5rO+cJMQn1KyC+FfiwyGNii630SwiHyWRG5+XQ6Iptx9JELwWUMCLJxFp\n"
-            + "B8DZQKlNnvdl+YUgEeQOkWTXfTSaBATdXHiZhskiumnTOGO24jSg8CrZc5O/n6fC\n"
-            + "CgEsAFWL7fnO0ii6EW1JH5btLHPxL9QI+5DJIypgOhGI1lqZW9KrpfmJ3w6N1Gek\n"
-            + "GBda98DmzxxxZ9iyq1cELAAiQMjkvws67cOs/hwXNn9YaK74dzhb49MLGIkBIAQQ\n"
-            + "AQIACgUCTHqf0QMFAXgACgkQV2Bph7AH1JCO/Qf+PBJqeWS7p32+K5r1cA7AeCB2\n"
-            + "pcHs78wLjnSxuimf0l+JItb9JQAKjzcdZTKVGkUivkq3zhsPCCtssgSav2wlG59F\n"
-            + "TaqtpGOxvGjc8TKWHW1TrPhV86wh0yUempKTMWfdZ0RAJVG3krAj60bzUsQNK41/\n"
-            + "0EZi4JI+sm/TRlwQcmEzdaGxhFSJqiJyaBWbPL8AQNA2iRyjMKNeGCrgapEl2IkW\n"
-            + "2ST+/yUPI/485LS0uU1+TLB+NhiJ6j5PoiVqYD+ul8WJ+cy1vvcp1GCQpbRv1yXY\n"
-            + "4GB1mw0JPIinVE1q+eKKQxN38zARPqyupiIuBQaqX9NCHCAdNtFc3kJQ7Nm83YkB\n"
-            + "IAQQAQIACgUCTHqkCwMFAXgACgkQZB8Rk9JP5GfGVQgArMBVQo3AD56p4g5A+DRA\n"
-            + "h0KdQMt4hs/dl+2GLAi+nK0wwuHrHvr9kcZNiQNMtu+YiwvxMpJ/JvXRwOp4wbEx\n"
-            + "6P6Uzp18R2sqbV4agnL5tXFZXfsa3OR2NLm56Ox1ReHnZtAcC6qa1nHqt9z2sTt1\n"
-            + "vh7IfK8GDU/3M3z4XBXPpmpZPAczqujuO/yshz84O6oc3noXfRUJRklbkhNC3WyS\n"
-            + "u5+3nupq4GwIYehQQpxBTD9xXj4hl3KfUnctg/MkgUGweEK3oZ22kObTLJttTP9t\n"
-            + "9q/hLkVyDtFhGorcsYbNZyupm3xhddzYovkReePwOO4WA7VeRqRdiYDU1UjIKvv4\n"
-            + "TrkBDQRMep6aAQgA3NQtBhS8yiEGN8rT4hGtuuprVd5jQVprLz4ImcI2+Gt71+CR\n"
-            + "gv/BZ0zzFp3VPjTGRusungJYkKKOGpEpERiqEG1X/ZyL7EzoyT+iKIMDsVJgmyDN\n"
-            + "cryHTejlKA8Z6GQ1hPlOIws22oLq5zQXxD9pzMDuabHl/s/bYlU5qXc7LhxdtrmT\n"
-            + "b2uBP9a+eneWKrz8OfgtS5m9DgqJ6Bjl0TvbeVJgKHX42pqzJlBTCn3hJjJosy8x\n"
-            + "4qTbqMraENnl9y+qynM7atoHX6TPWsD7vWtWvi+FA5OWGEe3rof8o/sJSj05DQUn\n"
-            + "i8mmSiCYW/tUklPPXOvPRP0GZ/GhBzIUtE3jBwARAQABiQEfBBgBAgAJBQJMep6a\n"
-            + "AhsMAAoJENJa0Hqf0NOW/gkH/jm9FL+S53NjrthdbNjffryhp7KhTmYAsRk3Hc3X\n"
-            + "4TBj3upecarJynpvsz5HlLi/OxDRR6L2yfjKk6/2iKAbV56mdnnu5xG3TG8++naL\n"
-            + "7n/s9TGBhgknb6+vGhSMZ/1dpQ6wkiyuEmgKJo8DzHAh3k3VATHiBeSD7fNSsgtK\n"
-            + "gzK0hi53IFRFDDPYiCca+SS6/pA2zF56JWGETiIa8rSHIQaK4hNJ38vgKOZM80vQ\n"
-            + "fp+CxvJkYY71Yc94oQByaQzrXod7xnukp5SXe/N3BYTFCWoaSTRUI/THRywWwKqa\n"
-            + "rUsttYrqs/EQSy0X3kZ7CAm04uzA8csNyxapEVRvJxbrt5I=\n"
-            + "=DAMW\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBEx6npoBCACp0vHePNPeLzm0HM35i70bRChyZXu/ARxOHZHNbh6hWJkq5saI\n"
-            + "zuzZoaqXAr3xZwHftTULlkgoJIt40x6VCT8EBnUHTkOqoHTsFnXg2kNuhFvmn0OX\n"
-            + "7RQFlR1SGoQG4fEy6t/GlOwEknpNSIMLkbDMP2FzEkLVWtlIe2hqqawVIgqzyO3k\n"
-            + "HaQxW8gWyyTx0qeSdSi4DyFZIzdyu/aZa7sj/MhO3DB3UwubW6yE+PMcAVrJD+0d\n"
-            + "EToMT7i8Erncc+xEzuXAoQUHaQfOXV4DG5qSgVpKaLxJ/ABWUri0eMPhj0cT4iDx\n"
-            + "eNTL7cZ4h72B1uJs8byDN74PHrypNiVE+IRHABEBAAEAB/9BbaG9Bz9zd0tqjrx2\n"
-            + "u/VQR3qz1FCQXtuqZu8RMC+B5zIf2si71clf8c7ZHnfSxWZt65Ez1SMYwDeyBdje\n"
-            + "/7B1Gw3Ekk00tFxHx0GEL2NSdZE4sbynkHIp0nD4/HlIc41rmh08E405F7wiAWFn\n"
-            + "uCpfDr47SNpR/A4BxHYOvi8r9pBxn/fXiHluqYROit0Z4tfKDCvQ47k+wqVD5nOt\n"
-            + "BEbHDfEwUMibgTuJ1qPyHf6HDlSdTQSfYV8QW1/UbHWus9QikfjGfLJpX0Rv3UG+\n"
-            + "WXHmowpRDVixj74UQCYXQ/AZi/OBlcS8PRY6EZV4RLyEWlZrdzKViNLOTUbJNHvA\n"
-            + "ZAQVBADQND7CIO6z4k8e9Z8Lf4iLWP9iIbH9R7ArTZr2mX1vkwp+sk0BNQurL/BQ\n"
-            + "jUHOJZnouwkc+C3pQi/JvGvAe1fLHPA0+NKe/tcuDXMk+L1HH6XmDgKtByac41AR\n"
-            + "txxqhaECNeK9OKXAXaEvenkGFMcqQV3QMiF2q5VlmFxSSXydEwQA0M8tCowz0iZF\n"
-            + "i3fGuuZDTN3Ut4u6Uf9FiLcR4ye2Aa5ppO8vlNjObNqpHz0UqdDjB+e3O/n7BUx3\n"
-            + "A5PRZNQvcMbhgr2U3zjWvFMHS3YuxbuIaZ1Vj69vpOAGkUc98v4i0/3Lk7Lijpto\n"
-            + "n40S0eCVo+eccHA4HRvS5XSdNGHVJn0EAMzfBt3DalOlHm+PrAiZdVdp5IfbJwJv\n"
-            + "xkyI++0p4VaYTZhOxjswTs6vgv30FBmHAlx1FzoUOKLaOhxPyLgamFd9YG+ab4DK\n"
-            + "chc4TxIj3kkx3/m6JufW8DWhKyAJNZ/MW+Iqop5pUIeTbOBlNyaflK+XxjkP71rP\n"
-            + "2gZx4pjYjK5EPDy0HlRlc3R1c2VyIEEgPHRlc3RhQGV4YW1wbGUuY29tPokBOAQT\n"
-            + "AQIAIgUCTHqemgIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ0lrQep/Q\n"
-            + "05ZxMAf+OoRzXWbGfv7kZb7xdrVyAUTAV4bUUvLoJZUIQ1ckPBcty2LUvY7l9efg\n"
-            + "p3c57nvTD6U98dVnsKfaW4PT0CRXlpl1IFyhkgbInFS5rO+cJMQn1KyC+FfiwyGN\n"
-            + "ii630SwiHyWRG5+XQ6Iptx9JELwWUMCLJxFpB8DZQKlNnvdl+YUgEeQOkWTXfTSa\n"
-            + "BATdXHiZhskiumnTOGO24jSg8CrZc5O/n6fCCgEsAFWL7fnO0ii6EW1JH5btLHPx\n"
-            + "L9QI+5DJIypgOhGI1lqZW9KrpfmJ3w6N1GekGBda98DmzxxxZ9iyq1cELAAiQMjk\n"
-            + "vws67cOs/hwXNn9YaK74dzhb49MLGJ0DmARMep6aAQgA3NQtBhS8yiEGN8rT4hGt\n"
-            + "uuprVd5jQVprLz4ImcI2+Gt71+CRgv/BZ0zzFp3VPjTGRusungJYkKKOGpEpERiq\n"
-            + "EG1X/ZyL7EzoyT+iKIMDsVJgmyDNcryHTejlKA8Z6GQ1hPlOIws22oLq5zQXxD9p\n"
-            + "zMDuabHl/s/bYlU5qXc7LhxdtrmTb2uBP9a+eneWKrz8OfgtS5m9DgqJ6Bjl0Tvb\n"
-            + "eVJgKHX42pqzJlBTCn3hJjJosy8x4qTbqMraENnl9y+qynM7atoHX6TPWsD7vWtW\n"
-            + "vi+FA5OWGEe3rof8o/sJSj05DQUni8mmSiCYW/tUklPPXOvPRP0GZ/GhBzIUtE3j\n"
-            + "BwARAQABAAf+KQOPSS3Y0oHHsd0N9VLrPWgEf3JKZPzyI1gWKNiVdRYhbjrbS8VM\n"
-            + "mm8ERxMRY/hRSyKrCdXNtS87zVtgkThPfbWRPh0xL7YpFhenena63Ng78RPqlIDH\n"
-            + "cITs6r/DRBI4jnXvOTr/+R2Pm1llgKF2ePzsSt0rpmPcjyrdBsiKSUnLGxm4tGtW\n"
-            + "wVoEjy3+MRN2ULyTO8Pe4URKTtUkkb23iuQuJZy+k+SfH+H0/3oEb8ERRE3UXNG7\n"
-            + "BIbaj71nsx8+H8+x8ffRm1s5Unn86AJ418oEhxNzQk59NnrrlJ4HH9NNbjjzI3JE\n"
-            + "intSQKhFJsvMARdzX062yartQtnm1v6jwQQA65rpMMHCoh9pxvL6yagw3WjQLEPw\n"
-            + "vOGpD9ossBvcv/SfAe7SgJsx6J6X0IIW6EKIjyRhWTIfK/rVR0cmUFTGStib+y22\n"
-            + "BPcQmt/Oiw9rdUfOmDrnosPC0SB+19tKw1v1AfW5swpJnGBCkGz9UfX4Fr/eTS3e\n"
-            + "2KaMq+r1KALSUVkEAO/x0SWOiBRH3X1ETNE9nLTP6u2W3TAvrd+dXyP7JjXWZPB8\n"
-            + "NOwT7qidvUlhTbxdR7xWNI1W924Ywwgs43cAPGyq95pjdzhvi0Xxab7124UK+MS3\n"
-            + "V4WBvjOYYW8pkdMOydRLETXSkco2mDCRTiVKe3Zi7p+lKlVJj4xrFUPUnetfBADH\n"
-            + "EPwYeeZ8sQnW644J75eoph2e5KLRJaOy5GMPRLNmq+ODtJxdoIGpfQnEA35nSlze\n"
-            + "Ea+1UvLBlWyF+p08bNfnXHp3j5ugucAYbVEs4ptUwTB3vFt7eJ8rkx9GYcuBFiwm\n"
-            + "H47rg7QmS1mWDLyX6v2pI9brsb1SCgBL+oi9CyjypkjqiQEfBBgBAgAJBQJMep6a\n"
-            + "AhsMAAoJENJa0Hqf0NOW/gkH/jm9FL+S53NjrthdbNjffryhp7KhTmYAsRk3Hc3X\n"
-            + "4TBj3upecarJynpvsz5HlLi/OxDRR6L2yfjKk6/2iKAbV56mdnnu5xG3TG8++naL\n"
-            + "7n/s9TGBhgknb6+vGhSMZ/1dpQ6wkiyuEmgKJo8DzHAh3k3VATHiBeSD7fNSsgtK\n"
-            + "gzK0hi53IFRFDDPYiCca+SS6/pA2zF56JWGETiIa8rSHIQaK4hNJ38vgKOZM80vQ\n"
-            + "fp+CxvJkYY71Yc94oQByaQzrXod7xnukp5SXe/N3BYTFCWoaSTRUI/THRywWwKqa\n"
-            + "rUsttYrqs/EQSy0X3kZ7CAm04uzA8csNyxapEVRvJxbrt5I=\n"
-            + "=FLdD\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * pub 2048R/B007D490 2010-08-29 [expired: 2011-08-29] Key fingerprint = 355D 5B98 FECE 6199 83CD
-   * C91D 5760 6987 B007 D490 uid Testuser B &lt;testb@example.com&gt;
-   */
-  public static TestKey keyB() throws Exception {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBEx6ntMBCADG7j/nuI+VvvNbPY9nnfLrAc3KTj0Z+DMxxUMYoZNLjTw1szQ0\n"
-            + "PuKKACiiSA9Oyj4R0aIhWdIR9iYxp6gQdje3yewzoqMwE+t5onYDpdX9QDFXyEzF\n"
-            + "UPWCjA7OSji1G6fyWakiYxKseqyRXOdHXI5TqMikBalmSpwwvmik0cfRGO+l6qvM\n"
-            + "mVJlcn6mkZB0d8WOPV8j8rFxmVSPn9SVP9L8HaFWv1uI9EY3zXbfNeDNgNeTWIMY\n"
-            + "75saINBA2LALBQ54u52GoSbaR8ukZYAjjkif3WIFI8B9xREwjUBLFy3E357aGyLZ\n"
-            + "jE8nsmPk4MDxDaeDNoSHJjcxtDWQJBub3u1zABEBAAG0HlRlc3R1c2VyIEIgPHRl\n"
-            + "c3RiQGV4YW1wbGUuY29tPokBPgQTAQIAKAUCTHqe0wIbAwUJAeEzgAYLCQgHAwIG\n"
-            + "FQgCCQoLBBYCAwECHgECF4AACgkQV2Bph7AH1JD0nQf/Vm+/Mvl99/y3Qw10S6et\n"
-            + "H6NYWDUeAKXe9mfXBJ39HdtlF50jZ5NzSwksAOSQtQZJ3tQQeElXB29cZDvAscva\n"
-            + "RiTtt+KUxDZSYbEHrC0EO7w0Wi5ltwaWdXnoitMOgPZ/grL7UpUbL8rB1evfLbhm\n"
-            + "AqC/6kgHuXeY/7EAzwU3o0wKbmfx1sh8AyQSi4unUwIDCV1RIAP0+ZfJSg0WwGoS\n"
-            + "JB5+lKajtIE6kMn9m8CWM66/zxSCY3XLcoXvjVxCYPwwgYSyje8dDxxOI+x7uj2I\n"
-            + "IjM5RHQ9hTsR7NQ9JUTFmpKZlcdah93NZLKJAFLUtOPjMa5d5t2O0ZOxZ5ftlhHp\n"
-            + "Q7kBDQRMep7TAQgAwOuLBXnACIsd879ld/vLcn8umpKV8MIUjrqOMjR0rNKpCUDw\n"
-            + "LxL4uVh3q/ksESHnQPPqxFYkgeA66SYrx4jwZjbZ5vv9BW99LHe8lSahqrJA9A9g\n"
-            + "5iw5hH+2ZWrGlu3P65UdQUJW+JaDx1IIBt3BbmdGDuKF/ESsy9qxEKq7tKqHI2JL\n"
-            + "Ed+6OIwWblU7ZogfiNpgZJ0lapxTe84mGsD0TowGTu5re/8wIJf1f2q4PuG+L9OZ\n"
-            + "0ZD5i9s1MAxdw4OD+705owPCQnqsr18nH9aUBHWJn9NCXb3jL7QGaId84Yq8SRlK\n"
-            + "wHSRtHLLJoowJ5fXw5UbZcUtRUergxFRwae87wARAQABiQElBBgBAgAPBQJMep7T\n"
-            + "AhsMBQkB4TOAAAoJEFdgaYewB9SQMbsH/iu1HY7OMJxd8EkfxairRNec/v9uEvYQ\n"
-            + "XqfEPw/Hihdef1TY8vB69ymAPd89e1PRDj1m+0/RivO045qFP7lbWMkjKeR9dXXe\n"
-            + "UzIEsTUJ1CNnA7C3fo11NBVg59E0d84bMKQx7n4AZkljgKFKghUb6OJZiWRdh+8W\n"
-            + "0I95JI2R7nMYw3L8/sSGxt+Vjhs9acB1DldbyYbJitYA4fhVZQH9zgeuhQqdCULQ\n"
-            + "ZexpkQqvG0o4iJKO4yeJNHdeM+NwH38wXfzydtEv6Dxz/YZSTwt08p97l6DQ//H7\n"
-            + "wek1LcqeX47YFa9Ftns8Y8fjh4S8Kyi1F6BhZKbsdDqg2hA+0AFv7LA=\n"
-            + "=tmW1\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBEx6ntMBCADG7j/nuI+VvvNbPY9nnfLrAc3KTj0Z+DMxxUMYoZNLjTw1szQ0\n"
-            + "PuKKACiiSA9Oyj4R0aIhWdIR9iYxp6gQdje3yewzoqMwE+t5onYDpdX9QDFXyEzF\n"
-            + "UPWCjA7OSji1G6fyWakiYxKseqyRXOdHXI5TqMikBalmSpwwvmik0cfRGO+l6qvM\n"
-            + "mVJlcn6mkZB0d8WOPV8j8rFxmVSPn9SVP9L8HaFWv1uI9EY3zXbfNeDNgNeTWIMY\n"
-            + "75saINBA2LALBQ54u52GoSbaR8ukZYAjjkif3WIFI8B9xREwjUBLFy3E357aGyLZ\n"
-            + "jE8nsmPk4MDxDaeDNoSHJjcxtDWQJBub3u1zABEBAAEAB/wPPV1Om92pc9F3jJsZ\n"
-            + "2F3YZxukLfjnA76tnMEWd/pYGrUhdV3AdY4r/aB0njSeApxdXRlLQ3L2cUxdGCJQ\n"
-            + "mzM1ies7IXCC/w5WaShwAG+zpmFL/5+cq3vDc9tb2Q/IasVOVFQYEE2el7SfW5Cp\n"
-            + "mjZFGR8V1wvdNvC0Q0IHrmfdECYSeftzZBEj7CcoGc2pF5zpCG0XQxq7K6cEeSf5\n"
-            + "TKf//UVHgyBCIso6mzgP5k6DGw2d64843CPhhlHEbirUu/wNnbm1SqJ5xFL2VatH\n"
-            + "w7ij4V/hbgnP0GQkbY5+p/PU74P7fx/Ee8D8mF2HmEKRy6ZQY/SAnrjsAURBYR5S\n"
-            + "GF5RBADfhOYEgseWr81lq6Y1oM4YQz+pXRIZk34BagOJsL767B7+uwhvmxBJKIOS\n"
-            + "nRIxfV8GlvT22hrbqsRRyusoIlo2ZUat94IMAL6Oqm6VFm71PT3z9+ukWK43FIXf\n"
-            + "Bsz4swSV001398e3jpSizI6fGW7LRxvnua+NPN+xJLmDVcsPvwQA49ajm48NorD9\n"
-            + "bIWG87+2ScNTVOnHKryR+/LrGWA0f3G6LUsHZPKHNBdFZ4yza2QtEKw95L3K9D4y\n"
-            + "jIeKGwSRYJPb5oh5tSge58pxwP88eI9J4dL+XF1nsG0vYF9B41+qG1TCsPyUJTp6\n"
-            + "ry7NAgWrbpsZpjB0yJ1kFva3iS/hD00EAMu66p1CtsosoDHhekvRZp8a3svd+8uf\n"
-            + "YEKkEKXZuNNmJJktJBSA2FK1RKl9bV8wuG0Pi1/k39egLO3QTjruWUbSggT+aibR\n"
-            + "RW3hU7G+Z5IBOU3p+kTFLat6+TBg0XhCjJ+Eq366nZy1QIfqTCixIaDwrutZd6DC\n"
-            + "BXOjdoG6ZvLcQia0HlRlc3R1c2VyIEIgPHRlc3RiQGV4YW1wbGUuY29tPokBPgQT\n"
-            + "AQIAKAUCTHqe0wIbAwUJAeEzgAYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ\n"
-            + "V2Bph7AH1JD0nQf/Vm+/Mvl99/y3Qw10S6etH6NYWDUeAKXe9mfXBJ39HdtlF50j\n"
-            + "Z5NzSwksAOSQtQZJ3tQQeElXB29cZDvAscvaRiTtt+KUxDZSYbEHrC0EO7w0Wi5l\n"
-            + "twaWdXnoitMOgPZ/grL7UpUbL8rB1evfLbhmAqC/6kgHuXeY/7EAzwU3o0wKbmfx\n"
-            + "1sh8AyQSi4unUwIDCV1RIAP0+ZfJSg0WwGoSJB5+lKajtIE6kMn9m8CWM66/zxSC\n"
-            + "Y3XLcoXvjVxCYPwwgYSyje8dDxxOI+x7uj2IIjM5RHQ9hTsR7NQ9JUTFmpKZlcda\n"
-            + "h93NZLKJAFLUtOPjMa5d5t2O0ZOxZ5ftlhHpQ50DmARMep7TAQgAwOuLBXnACIsd\n"
-            + "879ld/vLcn8umpKV8MIUjrqOMjR0rNKpCUDwLxL4uVh3q/ksESHnQPPqxFYkgeA6\n"
-            + "6SYrx4jwZjbZ5vv9BW99LHe8lSahqrJA9A9g5iw5hH+2ZWrGlu3P65UdQUJW+JaD\n"
-            + "x1IIBt3BbmdGDuKF/ESsy9qxEKq7tKqHI2JLEd+6OIwWblU7ZogfiNpgZJ0lapxT\n"
-            + "e84mGsD0TowGTu5re/8wIJf1f2q4PuG+L9OZ0ZD5i9s1MAxdw4OD+705owPCQnqs\n"
-            + "r18nH9aUBHWJn9NCXb3jL7QGaId84Yq8SRlKwHSRtHLLJoowJ5fXw5UbZcUtRUer\n"
-            + "gxFRwae87wARAQABAAf8DAVBKsyswfuFGMB2vpSiVxaEnV3/2LoHFOOb45XwJSqV\n"
-            + "HL3+mThJ5iaUglMqw0CFC7+HA8fIS41grlFSDgNC02OcjS9rUxDg0En/pp17Gks0\n"
-            + "D+D7bSwZQ1+/yi7ug836lBe89GmBSMj8GgnK9T6RBGOL8nZ72b2ftK4CNWMmAfo4\n"
-            + "NZUy+rnnziV5WoYrkFZhl3dMMd3nITILBy9eYUoiKJl8O1b8amhrNkB/PEMAV7jc\n"
-            + "260XEQ9fgzMMe5/oT8pzIOGyrB+QO5rMu9pGVJ1qeMzTiZjjHXE2CEaEbvEk0F4l\n"
-            + "6w2gp5C6O5GoMpCOPwCy7dOYX5ETdO4Ppjnrob2XEQQAwus5q+EFoBVG8vfEf56x\n"
-            + "czkC15+0VcMe/IM8l/ur/oF1NUlAnPCq7WfgdELvGNszW7R+A625yXJJf7LJE/y/\n"
-            + "5GUGHAK60FUa0ElbVEn0A6kDcvll0dM6rKPQvFguaFpBKXre6k17cdOrf9hasfJk\n"
-            + "+lzaHlh9hJgoM30pAwG4+n8EAP1f+TEkEfVFo4Uy84eO6xVkYVndopDU1gCpfW1a\n"
-            + "84SA2PNjU3vkdIoFsEvOmf1xlfYeDYn37dikFPEZDsHBUzELDMewAXRgmVvnMJrj\n"
-            + "8Zq4FbEQSVjyz3qJOGk5V999qqoVMRXdnlQs5IXgZauPsnIqi5TRQZOMhbaiOVBO\n"
-            + "kqWRBAC9FhxypA3t9j1zGTFDppWmcBxpVzGGsgmzGO+WTVyk6szbZgTsf2+R+gTJ\n"
-            + "ZKVVzE6Mu+iZmPbrn/x7LWzKJuavRz0xSrvCYbIxYyheFz5LOPFHLF181h1g79gY\n"
-            + "E5Tz7uwu3jIldM7rY5RhxS6V5GGDVSfA+/Dsk6Iaujs6Hs7y30C0iQElBBgBAgAP\n"
-            + "BQJMep7TAhsMBQkB4TOAAAoJEFdgaYewB9SQMbsH/iu1HY7OMJxd8EkfxairRNec\n"
-            + "/v9uEvYQXqfEPw/Hihdef1TY8vB69ymAPd89e1PRDj1m+0/RivO045qFP7lbWMkj\n"
-            + "KeR9dXXeUzIEsTUJ1CNnA7C3fo11NBVg59E0d84bMKQx7n4AZkljgKFKghUb6OJZ\n"
-            + "iWRdh+8W0I95JI2R7nMYw3L8/sSGxt+Vjhs9acB1DldbyYbJitYA4fhVZQH9zgeu\n"
-            + "hQqdCULQZexpkQqvG0o4iJKO4yeJNHdeM+NwH38wXfzydtEv6Dxz/YZSTwt08p97\n"
-            + "l6DQ//H7wek1LcqeX47YFa9Ftns8Y8fjh4S8Kyi1F6BhZKbsdDqg2hA+0AFv7LA=\n"
-            + "=uFLT\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * pub 2048R/D24FE467 2010-08-29 Key fingerprint = 6C21 10AC F4FC 1C7B F270 C00E 641F 1193 D24F
-   * E467 uid Testuser C &lt;testc@example.com&gt; sub 2048R/DBECD4FA 2010-08-29
-   */
-  public static TestKey keyC() throws Exception {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBEx6nuMBCADd077pyfsDGbGhHh+7xzipWihMJRrzQnpbSeVJIxA/Js+Z2MW8\n"
-            + "9J98AgnjONjGVlLqtp11O8Bp9xgdoGYWvFl2CrooQrCe+70JORHE30MJT+61mgLQ\n"
-            + "jm9l2WmIIcuzNwoTOKqWlXuaRIKddXMVbwr++Enl/9znx81FCf1KioDijeeHzVZb\n"
-            + "IjELLCtLlhwhGlYNy6LfNhSY+rNHOomIM9CUXkGZU7JvTe3M1plUzYYIFu3tttZI\n"
-            + "b6e1FSfR60yZ/f88fLacloc3fSrPWA261R/gHuFfLCdTt/I3EcYE+x33LZnSSOgz\n"
-            + "v/JtAuFlCaF/oNRTJHeRbALeri+FxBYule15ABEBAAG0HlRlc3R1c2VyIEMgPHRl\n"
-            + "c3RjQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTHqe4wIbAwYLCQgHAwIGFQgCCQoL\n"
-            + "BBYCAwECHgECF4AACgkQZB8Rk9JP5GcEIgf/cMvYBwH8ENrWec366Txaaeh/TO6n\n"
-            + "4v4P2LUR4/hcrNpHx3+9ikznkyF/b8OCsOE+KstvOO6i9vuRGVBPmfoALVv8iCGs\n"
-            + "5MXZJskjACXOqQav0I7ZY5rDJxuOKq6DrxtpHNxK8n0D1PEZllyk/OZVBAcjL2vu\n"
-            + "WC6ujP3jbMKaV0+heFqOVIghQjdA4McLH2u1XLOGEZdp7hLfmTnClmfzbnslFBSQ\n"
-            + "xU2g3jCq2k2zAPhn+jOGCL0987QGj1e6pHRXdUxcfnLRyNadRied0HO/clIb8vdt\n"
-            + "UaexujHjgg+1KDxj4PBAftN2lRtnnsSG9z4T31aTFz5YVG+pq8UXk9ohCokBIAQQ\n"
-            + "AQIACgUCTHqkKQMFAngACgkQqZHi1Q/dNnexiQf/ba9LcR76+tVvos1cxrGO3VkD\n"
-            + "3R1pvIWsb37/NTypWCvrFhsy4OUEy3bVCfJcqfwdY3Q2XixB9kuKo3qCSom1EjGg\n"
-            + "Qhr5ZsrB3qYqaa6S0AeVusmIwArEr9uuMUDjXhKlUALDX8HfXWGy2UmjNJkkT8Jm\n"
-            + "GtISS4KOfXUuZY04DttvbukEnyxAiLU9V0BnzrI9DARh0gEjqjUZAVyP5lOXJJxt\n"
-            + "sau95mOe8E61GELXPkxDLrnCboX7ys2OxcFO6S7q1xJPkki2SVq0y0k5oY/3jktw\n"
-            + "jO8uC3n7NiyW+BYJK6+zj3u3iA+o0YGm+i6F7aneJEaJrFqRj9L1vbojvuH0cYkB\n"
-            + "IAQQAQIACgUCTHqkOwMFAngACgkQOwm5f0tDh+7dSQf+PnEUftNSOuLVLoJ+2tyD\n"
-            + "DPJpcLIavNCyNR3hCGL86NXRUxOrmYgDVVv8pJuYB6aUTm69rFFZlzNwqQN5pBiX\n"
-            + "Zr3NM1jgJT6gKfXddcg1p/X2S9+xn4RN92R0fn0kEjM65fpE1Do+YWHOuHDZEOrx\n"
-            + "L8OaSo8lr19+r27fn09/HBhz2lOyTYzsdTjHeWdxPVQ3JNiVX11k7iKsttdYtM/V\n"
-            + "mAHzzd54Kvt5So/2qLIAcfSmUe9DQAdmcEcJQpQ2veND9uwccX7tH0cH4n9Cp16o\n"
-            + "quJ2pxWzOvKR3zxSw+cRxyIS4VjT6k+UsG3Lw55QZgdb5IEaJfezPj+tOhQlQz0f\n"
-            + "VrkBDQRMep7jAQgAw+67ahlOGnkF6mTtmg6MOGzAbRQ11MNrORnNtGOccNgtlgrO\n"
-            + "Y8TBqw1HkJ56v26E1FxfRh69CUGkYVXx0tMw0QbI+unX35ce5hJD4aWa8bOA1vfw\n"
-            + "474p/NpI+czWsFvcdOu5K6xIGXHShaQQyf2FQ9QeIFrU60qfaBL5jzuLyujCACqU\n"
-            + "46QGgBgeUjaT54LjrWSdn/Jtsbpv0MPv3Ea8fMdtSMkkBsDkF55jaJDFYq+xbs+e\n"
-            + "IKBjTwtSvrUisnLAC0Z9YY21GXGI3DGYqpVXz+Fe5xMTX1a6K3VKEmxmX2m/ebhm\n"
-            + "1p6EqjAJguOjJbJJQHKHMOol0zU6ANB6SgP26wARAQABiQEfBBgBAgAJBQJMep7j\n"
-            + "AhsMAAoJEGQfEZPST+Rn7AcH/32HACPLdxINsi8OSWa8OccMG5XEUvHTZjmdwVT2\n"
-            + "czMss8nwgifU9D4hEVRu1MWpiyxUgegW94wuSh4PWIVOVd18PmzAYc73aYgonakb\n"
-            + "M+MDIqGVvAH8QtHo79sqZ9vrkQaQXB3Y8cq+WxDQZyl8KLXP2icmq1Rl6Q6+i9oS\n"
-            + "pFe88Wr0cGaTblkfDbbWcih3C6tKAfcFwLLg8u4jYfXjZg/E9eAJf0dIFcQSQoHd\n"
-            + "O8hVXaZwx/rYXA8UFwAuROo2nu6SIof1lrH92p+now95d5zUZ5BYnKVd3uXsln0j\n"
-            + "z585UPQKS2J8PUy9IirmahgTyEYFwO64kZ2B4hYOE2g+rYw=\n"
-            + "=LtMR\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBEx6nuMBCADd077pyfsDGbGhHh+7xzipWihMJRrzQnpbSeVJIxA/Js+Z2MW8\n"
-            + "9J98AgnjONjGVlLqtp11O8Bp9xgdoGYWvFl2CrooQrCe+70JORHE30MJT+61mgLQ\n"
-            + "jm9l2WmIIcuzNwoTOKqWlXuaRIKddXMVbwr++Enl/9znx81FCf1KioDijeeHzVZb\n"
-            + "IjELLCtLlhwhGlYNy6LfNhSY+rNHOomIM9CUXkGZU7JvTe3M1plUzYYIFu3tttZI\n"
-            + "b6e1FSfR60yZ/f88fLacloc3fSrPWA261R/gHuFfLCdTt/I3EcYE+x33LZnSSOgz\n"
-            + "v/JtAuFlCaF/oNRTJHeRbALeri+FxBYule15ABEBAAEAB/sFPLoJDG1eV5QpqEZf\n"
-            + "m/QMOTOn8ZJ9xraQvXFvV7zgVXxJBvTLMbuACrnHnoiCrULS+w8Dt66Nfz7s4yQJ\n"
-            + "5SDtFX2AlMDVWL7wBEPgF1UpN6ox1CzSa6HOaygaUFGeKHO20WDjV4HmBLhQkKIa\n"
-            + "vKbghHA/4Nm1s1z3BHB8GtdGZ1VHc+s1DhPK5w+WHqYpLYjpNmI9yJg3gclEqEG9\n"
-            + "XzBqTZm9mPJRBdDMOD0xLa4nUD3Dkrjimqod3X7EuXE6sT2DuGVa1nuynk/8gIyO\n"
-            + "uS6crY7YJzEQUtQJ2n3y/h+QnZFo9UFuIVpgsxhBDsCnYNFWNR91Q0IM6PohHvqx\n"
-            + "BtFhBADsax1Bc0obP+bIkeAXltGlUYqm3bjOgVZ87XR0qe4TGwXGe8T1Yjfc8rj0\n"
-            + "cfBYCud201r/05CgchojMnTWlFLg308bSIZ9YvN3oOVay8nZ7h62dUIs45zebw3R\n"
-            + "SHwvjE5Sm/VWIdLrUUW1aGfk/VPudNMMMu2C64ev8DF/iwYjoQQA8DM+9oPvFJPA\n"
-            + "kLYg71tP2iIE5GbFqkiIEx59eQUxTsn6ubEfREjI99QliAdcKbyRHc3jc68NopLB\n"
-            + "41L7ny0j6VKuEszOYhhQ0qQK/jlI461aG14qHAylhuQTLrjpsUPE+WelBm9bxli0\n"
-            + "gA8F81WLOvJ2HzuMYVrj3tjGl3AHetkEAI77VKxGCGRzK63qBnmLwQEvqbphpgxH\n"
-            + "ANNAsg5HuWtDUgk85t2nrIgL1kfhu++CfP9duN/qU4dw/bgJaKOamWTfLBwST8qe\n"
-            + "3F8omovi1vLzHVpmvQp6Ly4wggJ4Gl/n0DNFopKw20V8ZTiRYtuLS43H7VsczE+8\n"
-            + "NKjy01EgHDMAP8O0HlRlc3R1c2VyIEMgPHRlc3RjQGV4YW1wbGUuY29tPokBOAQT\n"
-            + "AQIAIgUCTHqe4wIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQZB8Rk9JP\n"
-            + "5GcEIgf/cMvYBwH8ENrWec366Txaaeh/TO6n4v4P2LUR4/hcrNpHx3+9ikznkyF/\n"
-            + "b8OCsOE+KstvOO6i9vuRGVBPmfoALVv8iCGs5MXZJskjACXOqQav0I7ZY5rDJxuO\n"
-            + "Kq6DrxtpHNxK8n0D1PEZllyk/OZVBAcjL2vuWC6ujP3jbMKaV0+heFqOVIghQjdA\n"
-            + "4McLH2u1XLOGEZdp7hLfmTnClmfzbnslFBSQxU2g3jCq2k2zAPhn+jOGCL0987QG\n"
-            + "j1e6pHRXdUxcfnLRyNadRied0HO/clIb8vdtUaexujHjgg+1KDxj4PBAftN2lRtn\n"
-            + "nsSG9z4T31aTFz5YVG+pq8UXk9ohCp0DmARMep7jAQgAw+67ahlOGnkF6mTtmg6M\n"
-            + "OGzAbRQ11MNrORnNtGOccNgtlgrOY8TBqw1HkJ56v26E1FxfRh69CUGkYVXx0tMw\n"
-            + "0QbI+unX35ce5hJD4aWa8bOA1vfw474p/NpI+czWsFvcdOu5K6xIGXHShaQQyf2F\n"
-            + "Q9QeIFrU60qfaBL5jzuLyujCACqU46QGgBgeUjaT54LjrWSdn/Jtsbpv0MPv3Ea8\n"
-            + "fMdtSMkkBsDkF55jaJDFYq+xbs+eIKBjTwtSvrUisnLAC0Z9YY21GXGI3DGYqpVX\n"
-            + "z+Fe5xMTX1a6K3VKEmxmX2m/ebhm1p6EqjAJguOjJbJJQHKHMOol0zU6ANB6SgP2\n"
-            + "6wARAQABAAf9HIsMy8S/92SmE018vQgILrgjwursz1Vgq22HkBNALm2acSnwgzbz\n"
-            + "V8M+0mH5U9ClPSKae+aXzLS+s7IHi++u7uSO0YQmKgZ5PonD+ygFoyxumo0oOfqc\n"
-            + "DJ/oKFaforWJ2jv05S3bRbRVN5l9G0/5jWC7ZXnrXBOqQUkdCLFjXhMPq3zg2Yy3\n"
-            + "XSU83dVteOtrYRZqv33umZNCdk44z6kQOvh9tgSCL/aZ3d7AqjRK99I/IYY1IuVN\n"
-            + "qreFriVcJ0EzlnbPCnva+ReWAd2zt5VEClGu9J0CVnHmZNlwfmbFSiUN1hiMonkr\n"
-            + "sFImlw3adfJ7dsi/GzCC4147ep6jXw7QwQQAzwkeRWR9xc3ndrnXqUbQmgQkAD3D\n"
-            + "p2cwPygyLr0UDBDVX0z+8GKeBhNs3KIFXwUs6GxmDodHh0t4HUJeVLs7ur5ZATqo\n"
-            + "Bx50cSUOoaeSHRFVwicdJRtVgTTQ4UwwmKcLLJe2fWv6hnmyInK7Lp8ThLGQgqo8\n"
-            + "UWg3cdfzCvhKSvsEAPJFYhsFA/E92xUpzP8oYs3AA4mUXB+F0eObe9gqv8lAE6SX\n"
-            + "gB5kWhcd+MGddUGJuJV2LRrgOx3nXu3m3n35AH6iAY4Qi9URPzi/K659oefUU1c5\n"
-            + "BFArHX9bN1k1cOvH28tpQ38eAxaMygLqyR5Q5VbtZ5tYqLKCvHVs3I8lekDRA/4i\n"
-            + "e0vlu34qenppPANPm+Vq/7cSlG3XY4ioxwC/j6Y+92u90DXbbGatOg1SqGSwn1VP\n"
-            + "S034m7bDCNoWOXL0yAcbXrLZV74AyfvVOYOs/WtehehzWeTQRT5lkxX5+xGc1/h6\n"
-            + "9HQvsKKnUK8n1oc5aM5xzRVkU9+kcmqYqXqyOHnIbDbPiQEfBBgBAgAJBQJMep7j\n"
-            + "AhsMAAoJEGQfEZPST+Rn7AcH/32HACPLdxINsi8OSWa8OccMG5XEUvHTZjmdwVT2\n"
-            + "czMss8nwgifU9D4hEVRu1MWpiyxUgegW94wuSh4PWIVOVd18PmzAYc73aYgonakb\n"
-            + "M+MDIqGVvAH8QtHo79sqZ9vrkQaQXB3Y8cq+WxDQZyl8KLXP2icmq1Rl6Q6+i9oS\n"
-            + "pFe88Wr0cGaTblkfDbbWcih3C6tKAfcFwLLg8u4jYfXjZg/E9eAJf0dIFcQSQoHd\n"
-            + "O8hVXaZwx/rYXA8UFwAuROo2nu6SIof1lrH92p+now95d5zUZ5BYnKVd3uXsln0j\n"
-            + "z585UPQKS2J8PUy9IirmahgTyEYFwO64kZ2B4hYOE2g+rYw=\n"
-            + "=5pIh\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * pub 2048R/0FDD3677 2010-08-29 Key fingerprint = C96C 5E9D 669C 448A D1B9 BEB5 A991 E2D5 0FDD
-   * 3677 uid Testuser D &lt;testd@example.com&gt; sub 2048R/CAB81AE0 2010-08-29
-   */
-  public static TestKey keyD() throws Exception {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBEx6nwkBCADuztv2tGhjPljwW46qEhth7ZnkdhYXuctZ6lNQuy5LMaEECE3C\n"
-            + "jvVKY+nBrgsLY2Trts+q+mdooBWvxy/qe5PAQTcPR83KjVS4fYwNMBgeRxBEZAZg\n"
-            + "DFwRRCsRrHost+cMgtzLocQ+vL3+9yTRAIe/WmYwbEDXg/c9JSC7kQbZqaAaOshO\n"
-            + "cIOyeB8/QoYee0fEnBzHMmcd0SB1YpwIvRG6v61lXmgpQ9CbovvXO6ZZyEyCX784\n"
-            + "9xprzqP1y03DPrbhuhBAY8EMf3KGJA1dEcU4+lbGEgmlOe2YSbWoLs7mRLFcq5xx\n"
-            + "JroYMtvXF04k4ZHNZAnT3IZc+lJyCqOp4vXpABEBAAG0HlRlc3R1c2VyIEQgPHRl\n"
-            + "c3RkQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTHqfCQIbAwYLCQgHAwIGFQgCCQoL\n"
-            + "BBYCAwECHgECF4AACgkQqZHi1Q/dNne/0wgApuPzh4J8p2quCK1ScsJHlgGRojGq\n"
-            + "IDPhZFtPn0p2IAkqr5sAhvZAjd3u9A2DqQ7pwOX7gnGRE7dSrK69IAjfbRMc5k16\n"
-            + "aBK2ADq2YgPEmTToots1A0Tj+LaCFOXYUtEkgAC+RfFIkCdt8z86GIr0kg19Q/vY\n"
-            + "I/LtvThAk28D8yIfDnW49Mc4GGq+qvrOytBaGu3dzW0mjYWGEyl0fdSjNqtKyWN7\n"
-            + "Qw70Kqysaoy1KiPRAgwiPQfMCEx6pVaXuAfgRKaJ18kCNOldpajLgQv6yeY7mhgu\n"
-            + "Q3Qe7xQlAtVObxskcTH2CWggl2dPqSMNieLK0g/ER8PIReGDCBXNSJ4qYbkBDQRM\n"
-            + "ep8JAQgAw/o1nhJPLGlIfEMzOGU0Jjj+DwEyB3QIEEc+WKRvgtGsJ4cbZdaGWBJq\n"
-            + "jSo7e9XC9jA2ih0+Gld0vWV7S0LZ84xXxQeadC+AZBFR+b9ga4aUFIji8Tdi2dWX\n"
-            + "QmY76hHIaF8rs6aJB7lRig735VRLxVHOb194t9KLUzZiEKqd71BvLQyuLqAfTEsT\n"
-            + "GRHgmydaxZbGXz+Z57jbQgm11CQEHX1dtS8uqWb64xrV5GAeuEhRj4R6Yiy7OPNi\n"
-            + "xXHxryH2Jd34pA0cGHYVcTgVjXuZ9FFP2SnXuxABONGAIaJuqg7ozYBa2kOdr0DN\n"
-            + "5Pxy5ocR7R2ZoN0pYD5+Cc7oGHjuCQARAQABiQEfBBgBAgAJBQJMep8JAhsMAAoJ\n"
-            + "EKmR4tUP3TZ369QIAKPlfX2TUfhP3otYiaa24zBJ/cvGljGiSfX0KrausBHH161j\n"
-            + "lraJfLzpe7vSOZhwZwgIY/eKoErAkJwVnX1+dLuOcHaqRDi5gnLqa6Yg9a2LWb4z\n"
-            + "rvgsvbiNUs1o9htOcvcpv7e3UUUcRa8lO+aNkO+VoI6DI8RJ3wIfJayboePRXdfr\n"
-            + "8g9of0jSdIOzlaaBPxA2wYSWXm4kv7QXzZooxuGqhn0+JKuq2+oO9y5QUig+c3oG\n"
-            + "a5mpVblmv5ZL6Gc36kCbeEC8j6JkNT4wnceQwpNUNYtPU186cjy3rAD4C58w0Uvp\n"
-            + "HZZSTc0syLOShQr//We39LUNaX6WF3NmyF8K/OM=\n"
-            + "=YDhQ\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBEx6nwkBCADuztv2tGhjPljwW46qEhth7ZnkdhYXuctZ6lNQuy5LMaEECE3C\n"
-            + "jvVKY+nBrgsLY2Trts+q+mdooBWvxy/qe5PAQTcPR83KjVS4fYwNMBgeRxBEZAZg\n"
-            + "DFwRRCsRrHost+cMgtzLocQ+vL3+9yTRAIe/WmYwbEDXg/c9JSC7kQbZqaAaOshO\n"
-            + "cIOyeB8/QoYee0fEnBzHMmcd0SB1YpwIvRG6v61lXmgpQ9CbovvXO6ZZyEyCX784\n"
-            + "9xprzqP1y03DPrbhuhBAY8EMf3KGJA1dEcU4+lbGEgmlOe2YSbWoLs7mRLFcq5xx\n"
-            + "JroYMtvXF04k4ZHNZAnT3IZc+lJyCqOp4vXpABEBAAEAB/0Yf+FiLHz/HYDbW9FF\n"
-            + "kmj7wXgFz7WRho6dsWQNxr5HmZZWxxFPMgJpONnc9GGOsApFAnLIrDraqX3AFFPO\n"
-            + "nxH36djfuPKcYqZ77Olm2vXGeWzqT0a2KN5zKQawH/1CxDUwe+Zx/60V8KAfXbSJ\n"
-            + "up+ymnAcbKa0VYYSYFI82/KTdthJ1jFMNtXkaLskpM8TrDBCgd38m8Dpb5GCrDVY\n"
-            + "faZgkHokTTrvaTcx7ebGOxlOcbfzOPMJyFiz6lHf4JGr5ZVQXymaAG18kRDFxXHm\n"
-            + "AskOJIxnMdcy2IzNximht2CIgRuGznyPoeh/j8KFONKIKf3N6dVfV12uIvGOVV+D\n"
-            + "/ZQZBAD2dennp3Z4IsOWkgHTG3bloOVcIY5n+WvliQY/5G3psKdKeaGZxt6MhMSj\n"
-            + "sJEiUgveYTt5PxvQc5jmFEyjEQJmDAHo3RbycdFVvICrKIhKFyIlcVFCOSwDvLAW\n"
-            + "aZhu/m47jGnnYZ+bDzZl4X8L7Zu8e3TStEiVhjYTRqJfdEdMVQQA+A0ehIhIa1mJ\n"
-            + "ytGKWQVxn9BwKTP583vf2qPzul7yDEsYdGfoA0QGUicVwV4NNK3vK3FQM9MBSevp\n"
-            + "JFpxh2bRS/tgd5tFDyRqekTcagMqTxnJoIpCPUvj5D+WXsS1Kwrcm7OpWoNHOcjD\n"
-            + "Hbhk/966QALO+T6BTVLx32/72jtQ10UD/RsqQfRDzlQUOd6ZYOlH5qCb1+f8f3qJ\n"
-            + "yUmudrmjj8unBK3QbBVrxZ1h9AyaI5evFmsMlLKdTp0y49CmrSQmgEnUYzvBDjse\n"
-            + "/jYanpRKnt69HeZFilHLIF+HBbQfSM66UVXVoJSNTJIsncVa0IcGoZTpCUVOng3/\n"
-            + "MLfW4sh9NX1yRIi0HlRlc3R1c2VyIEQgPHRlc3RkQGV4YW1wbGUuY29tPokBOAQT\n"
-            + "AQIAIgUCTHqfCQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQqZHi1Q/d\n"
-            + "Nne/0wgApuPzh4J8p2quCK1ScsJHlgGRojGqIDPhZFtPn0p2IAkqr5sAhvZAjd3u\n"
-            + "9A2DqQ7pwOX7gnGRE7dSrK69IAjfbRMc5k16aBK2ADq2YgPEmTToots1A0Tj+LaC\n"
-            + "FOXYUtEkgAC+RfFIkCdt8z86GIr0kg19Q/vYI/LtvThAk28D8yIfDnW49Mc4GGq+\n"
-            + "qvrOytBaGu3dzW0mjYWGEyl0fdSjNqtKyWN7Qw70Kqysaoy1KiPRAgwiPQfMCEx6\n"
-            + "pVaXuAfgRKaJ18kCNOldpajLgQv6yeY7mhguQ3Qe7xQlAtVObxskcTH2CWggl2dP\n"
-            + "qSMNieLK0g/ER8PIReGDCBXNSJ4qYZ0DmARMep8JAQgAw/o1nhJPLGlIfEMzOGU0\n"
-            + "Jjj+DwEyB3QIEEc+WKRvgtGsJ4cbZdaGWBJqjSo7e9XC9jA2ih0+Gld0vWV7S0LZ\n"
-            + "84xXxQeadC+AZBFR+b9ga4aUFIji8Tdi2dWXQmY76hHIaF8rs6aJB7lRig735VRL\n"
-            + "xVHOb194t9KLUzZiEKqd71BvLQyuLqAfTEsTGRHgmydaxZbGXz+Z57jbQgm11CQE\n"
-            + "HX1dtS8uqWb64xrV5GAeuEhRj4R6Yiy7OPNixXHxryH2Jd34pA0cGHYVcTgVjXuZ\n"
-            + "9FFP2SnXuxABONGAIaJuqg7ozYBa2kOdr0DN5Pxy5ocR7R2ZoN0pYD5+Cc7oGHju\n"
-            + "CQARAQABAAf/QiN/k9y+/pB7h4BQWXCCNIIYb6zqGuzUSdYZWuYHwiEL1f05SFmp\n"
-            + "VjDE5+ZAU+8U0Gv+BAeRbWdlfQOyI/ioQJL1DggeXqanUF4uCbjGDBPLhtCZsmmM\n"
-            + "QVLdrOl+v+SHe33e7E7AQSyQMaUSkUEtHycYIasZPQRfw9H/L3u9OEWXkMUbPso5\n"
-            + "L0A0StkcsM1isYfC8ApnF4zSTWHO9uqnc+qE4qChCqsGvaSIyLKEpVe4F0vEkbrq\n"
-            + "3usVp3cxJd9apN+JjMoC9dHJcQahgfJZ1jzgJ3rueRxrGZV+keo8VmyrDGFCerX9\n"
-            + "6Ke3RPMHN/evCHyPMtHC82QKYuy4ZTvldwQAyzbNKIIpNjyHRc/hXLMBUtnW0VYS\n"
-            + "dELA1VBMmT/d6Xx6pI9gg9HCjDx+DuQRych7ShxrYLL1pNQD8jwEJhZIeUpSgIFD\n"
-            + "BXdwkiGbmdrU5N0tBhxp8kRcqcGbL68zC9S0X2hNju6Dxu9hbG8ZAdYaCdAavVy0\n"
-            + "O6E66+T0cLRBinsEAPbiL/0rpV15DdITwD3hvzhYDyURE+yxQZe9ngS1uoui3mGn\n"
-            + "bLc/L/nbHf2Z91ViSsUaqJjpb2/eDsJtGJ9pFlFLTndujkA62CktJytD9DIYLlYD\n"
-            + "huXlsKvZkNZEZNDKLC5Tg8YR/28Opz0/ZFzfVuJAQqg7+iWkxklG3SvN71RLA/9x\n"
-            + "wun1AEw6tLJ2R2j8+yXIt8UaWExqAviT/JgZELVXdCTqcYuOmktsM2z+2D+OyUtP\n"
-            + "7+Yyz7MGQKMAU+V/1uOK4YqwUJrcGy501o9Of+xm+5DASsK1oM5e9sBdmNewdLHL\n"
-            + "ZJEllURrEC6zCE/4zzs7qUfakH4l4ZJgjRL6va+ED0HfiQEfBBgBAgAJBQJMep8J\n"
-            + "AhsMAAoJEKmR4tUP3TZ369QIAKPlfX2TUfhP3otYiaa24zBJ/cvGljGiSfX0Krau\n"
-            + "sBHH161jlraJfLzpe7vSOZhwZwgIY/eKoErAkJwVnX1+dLuOcHaqRDi5gnLqa6Yg\n"
-            + "9a2LWb4zrvgsvbiNUs1o9htOcvcpv7e3UUUcRa8lO+aNkO+VoI6DI8RJ3wIfJayb\n"
-            + "oePRXdfr8g9of0jSdIOzlaaBPxA2wYSWXm4kv7QXzZooxuGqhn0+JKuq2+oO9y5Q\n"
-            + "Uig+c3oGa5mpVblmv5ZL6Gc36kCbeEC8j6JkNT4wnceQwpNUNYtPU186cjy3rAD4\n"
-            + "C58w0UvpHZZSTc0syLOShQr//We39LUNaX6WF3NmyF8K/OM=\n"
-            + "=e1xT\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * pub 2048R/4B4387EE 2010-08-29 [expired: 2011-08-29] Key fingerprint = F01D 677C 8BDB 854E 1054
-   * 406E 3B09 B97F 4B43 87EE uid Testuser E &lt;teste@example.com&gt;
-   */
-  public static TestKey keyE() throws Exception {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBEx6nxoBCADjYOWOFa7ZBJpRuNspRoXBTK0LiK5zqN894b87LgIYEgUM6q5J\n"
-            + "yLNo43x7V+ow1/7BEq0JUAMSQ3uRn2jqXiJskSXvwlFYcTVFb0gY09CSD0ptHvda\n"
-            + "zqYOuM/MU1l9jqmlM+pDw/z0pLTKYmAHi6pKJ64pqccMHPUZHpLywyzSNX+JM86I\n"
-            + "K5KAsyGArtgpT9vfci3idNeXjhMR8rfLPDFbdGvGFOZrYv0cfgTbBpVEWeHjs2FR\n"
-            + "4vHG133AdjdZcvA9Y9VW34ZLeiyBEeFix7+HPVS82rko2kQxZu1UZRu340maKDAo\n"
-            + "+UVirgo0FQ8nNUR+c9oNKgiZtO39IAPJv/WZABEBAAG0HlRlc3R1c2VyIEUgPHRl\n"
-            + "c3RlQGV4YW1wbGUuY29tPokBPgQTAQIAKAUCTHqfGgIbAwUJAeEzgAYLCQgHAwIG\n"
-            + "FQgCCQoLBBYCAwECHgECF4AACgkQOwm5f0tDh+6Fowf9FZgntlW4qc7BHe8zYJ0q\n"
-            + "zoLZrHwCFcaeO3kz53y5Lz3+plMuqVDjoQDOt8DxsPHrXWKiu0qBTjZ28ztN3ef6\n"
-            + "f0MpguTGclvFroevUct0xiyox5r1DfMT8JRvqsojE1XPscR2zJzIgEg3OCPuksT9\n"
-            + "EsHsF+/3RBbsXbQgDpW38g0GzIJI4AiQ/yvG2ON9awN2kzIWoBkthVCGy54lCTGj\n"
-            + "yPhatE7Zu2ABNcerIDstupWww2Psec6pGbPPci8ojc90fzalk3UMXcXHD7m8cTJS\n"
-            + "kgHScOzTElIQqOA1+w6uiHy2oAn+qW7534j6p9Tj+DrSIzUXBedGjXZevaKaurVy\n"
-            + "KLkBDQRMep8aAQgAn5r6toYnEzwDeig8r+t89vqOFtohYcmtyeLeTiTTdAz/xWBW\n"
-            + "HUlqV8sglQ9aINpGtBf37v13RhtU3WkUv8cZMQoRM8P2H3cKDNwkucFO6uKSEQO5\n"
-            + "FdzTm4C4WaoE7QiTRbiekwh7O54mz4Wup6LHuEFQEcSpdRUp8w/qaJIHG9EJad1q\n"
-            + "UEsKNnITW+mWHY3+ccK1hgqPwOPqO3/8QtaipekKOYAtOb+57c1jtDFBZnYIkant\n"
-            + "oKs+kRw0DykXFTyFOMYqaleBMcVG+u7ljwAq18L8Ev+qVIpBIZ5eQ5+6p1w9B69h\n"
-            + "RH0Ebn50ebpoqKOXhN4/bu/wq596y0o4xDB0GQARAQABiQElBBgBAgAPBQJMep8a\n"
-            + "AhsMBQkB4TOAAAoJEDsJuX9LQ4fu0/wH/35/22xina8ktbvGV/kB0pH2LBqeXN/b\n"
-            + "CLdA+CDzfwMDzqG0kU39EJ3Fbux7fj4uMaeiYfbO9U85+NOuDmeH41B2dM9S1AzE\n"
-            + "H+/OiCp/Zf1fdd1qXhsA4Xe5vc/VD9oso9OrZK5CM5u0TPmYFijfVDPNgag6mPnD\n"
-            + "zd8JCsuEj4VEy6NF1KcoCc8edQ8AZ4L6ZQ6qiV24gxLnh8xImVr5YjBKDUCdrl79\n"
-            + "0u4wekfgapSx9Sw9Ycz5dFOL07OOHPiKZwUG0f8td6oJX4Ddxset5JAm1pPcLQHR\n"
-            + "6PRx0hI/Tz7rsAI6O37/BEM15+MVGIgOSLL/SRIpOa0L8qmuUhhS6Bg=\n"
-            + "=uA5x\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBEx6nxoBCADjYOWOFa7ZBJpRuNspRoXBTK0LiK5zqN894b87LgIYEgUM6q5J\n"
-            + "yLNo43x7V+ow1/7BEq0JUAMSQ3uRn2jqXiJskSXvwlFYcTVFb0gY09CSD0ptHvda\n"
-            + "zqYOuM/MU1l9jqmlM+pDw/z0pLTKYmAHi6pKJ64pqccMHPUZHpLywyzSNX+JM86I\n"
-            + "K5KAsyGArtgpT9vfci3idNeXjhMR8rfLPDFbdGvGFOZrYv0cfgTbBpVEWeHjs2FR\n"
-            + "4vHG133AdjdZcvA9Y9VW34ZLeiyBEeFix7+HPVS82rko2kQxZu1UZRu340maKDAo\n"
-            + "+UVirgo0FQ8nNUR+c9oNKgiZtO39IAPJv/WZABEBAAEAB/4xKKzYqDVyM/2NN5Mi\n"
-            + "fF3EqegruzRESzlgrqLij5LiU1sGLOLbjunC/pPWMu6t+rTYV0pT3hmb5D0eAcH0\n"
-            + "EcANiuAR0wg1P9yNk36Z54mLWoTzzKMb3dunCSvb+BU8AREKZ4v5dLEGz2lK7DPo\n"
-            + "zbhWaffMiClBpC0VbjfFBo91LrVUVnhRglBYKdPLQm/Lhw5cNCYOw194ZturO+cC\n"
-            + "iQZhGSy52HMoMs4Wr470CeFZvvWaiDCirVLcj4UhMsVANFKsahMARm9c+QrGrkRP\n"
-            + "+654f8M9ptapcQYpGOMmaeZVnpocONXOTkiJd7Hhr4PRUY+QS8C8F0LbmL2ERQbL\n"
-            + "F65RBADkIelztY/8Xy2S0jsW7+xF2ziz9riOR87G6b0wrXDdFz4GHPzLvwsdXOeN\n"
-            + "cODic14d9bf5jtXr9hgbAzx55ANDjOl3jK5qil8Z9qwsrNK9Mz0wT1acQXBwf/5D\n"
-            + "hI/whBK1FsH7Y+wdX64XA3EXmclxB8GZf1JsGXF3jNH30vyS7QQA/ydoMMw8ja9L\n"
-            + "j6MxHtVHcE4A4j6tFljLDuf8icOwwNUfb7SsHTDjUI2+30ZJOv+qISrthsASCSj3\n"
-            + "AN87CGdVR62Xe923DNdW8/moKKDILNaESyOi27qhI5qWrVRgNB5QwbQcSoClUxbj\n"
-            + "V7YZSfrZkiI+GE1gh1QPMOVyCUmqu90D+wc0x0wUj8emX/4xbbujOa5RAvNcNvnD\n"
-            + "mOB2CfPWD10TEeOOlHBhuoy2/GdIl76W0szJaxnzcV82VArllSciCBzpSfkExDZ6\n"
-            + "08hA8GpOsuOmAAPwXWZsb8YZbJeM0ULMgUCGHgvUj1/pGsCVA6c7sPAdkCfAFlmO\n"
-            + "smC9bvpS2VHZPuG0HlRlc3R1c2VyIEUgPHRlc3RlQGV4YW1wbGUuY29tPokBPgQT\n"
-            + "AQIAKAUCTHqfGgIbAwUJAeEzgAYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ\n"
-            + "Owm5f0tDh+6Fowf9FZgntlW4qc7BHe8zYJ0qzoLZrHwCFcaeO3kz53y5Lz3+plMu\n"
-            + "qVDjoQDOt8DxsPHrXWKiu0qBTjZ28ztN3ef6f0MpguTGclvFroevUct0xiyox5r1\n"
-            + "DfMT8JRvqsojE1XPscR2zJzIgEg3OCPuksT9EsHsF+/3RBbsXbQgDpW38g0GzIJI\n"
-            + "4AiQ/yvG2ON9awN2kzIWoBkthVCGy54lCTGjyPhatE7Zu2ABNcerIDstupWww2Ps\n"
-            + "ec6pGbPPci8ojc90fzalk3UMXcXHD7m8cTJSkgHScOzTElIQqOA1+w6uiHy2oAn+\n"
-            + "qW7534j6p9Tj+DrSIzUXBedGjXZevaKaurVyKJ0DmARMep8aAQgAn5r6toYnEzwD\n"
-            + "eig8r+t89vqOFtohYcmtyeLeTiTTdAz/xWBWHUlqV8sglQ9aINpGtBf37v13RhtU\n"
-            + "3WkUv8cZMQoRM8P2H3cKDNwkucFO6uKSEQO5FdzTm4C4WaoE7QiTRbiekwh7O54m\n"
-            + "z4Wup6LHuEFQEcSpdRUp8w/qaJIHG9EJad1qUEsKNnITW+mWHY3+ccK1hgqPwOPq\n"
-            + "O3/8QtaipekKOYAtOb+57c1jtDFBZnYIkantoKs+kRw0DykXFTyFOMYqaleBMcVG\n"
-            + "+u7ljwAq18L8Ev+qVIpBIZ5eQ5+6p1w9B69hRH0Ebn50ebpoqKOXhN4/bu/wq596\n"
-            + "y0o4xDB0GQARAQABAAf7Bk9bQCIXo2QJAyhaFd5qh10qhu7CyRnvG/8zKMW98mWd\n"
-            + "KxF+9hNz99qZBCuiNZBLoU0dST6OG6By/3nrDxXxAgZS3cgOj/nl1NJTRWDGHPUu\n"
-            + "LywFgj7Dwu8Y2rqlDTX8lJIS+t8n+BhtkmDHoesGmFtErh8nT/CxQuHLM60qSMgv\n"
-            + "6mSmtOkM+2KfiA5z2o1fDWXjDieW+hdgDPxkaB835wfuDn/Dsn1ch1XHON0xSyTo\n"
-            + "+c35nFXoK1pAXaoalAxZNxcXCAM3NhU37Ih4GejM0K7sSgK72HmgxtNYF77DrTIM\n"
-            + "m5+3960ri1JUuEaJ7ZcqbpKxy/GDldNCYBTx07QMzQQAyYQ+ujT9Pj8zfp1jMLRs\n"
-            + "Xn9GsvYawjo+AIZuHeUmmIXfEoyNmsEUoGHnz9ROLnJzanW5XEStiTys8tHJPIkz\n"
-            + "zL0Ce0oUF93ln0z/jQBIKaSzYB7PMmYCd7ueF94aKqAOrQ/QBb+6JsVjGAtLUoTv\n"
-            + "ey09hGYMogiBV1r0MB2Rsa8EAMrB5VKVQF6+q0XuP6ljFQRaumi4lH7PoQ65E7UD\n"
-            + "6YpyQpLBOE7dV+fHizdUuwsD/wyAOu0EskV1ZLXvXzyk10r3PRoFdpHOvijwZBGt\n"
-            + "jiOiVvK1vkQKDMBczOe74+DaknKn6HzgCsXmLgfk+P8BtLOJnCYsbS9IbnImy2vi\n"
-            + "aJC3A/9wOOK+po8C7JPHVIEfxbe7nwHOoi/h7T4uPrlq/gcQRquqGhQ16nDGYZvX\n"
-            + "ny9aPQ3NcvDR69RM2AaXav03bHVxfhVEyGjP5jLZz7956e4LlnKrsuEhDLfiv30i\n"
-            + "qCC7zNHNA99s5u25vt8AuPVVHfSQ++jifabfv5lU4FHqmK8/4EAoiQElBBgBAgAP\n"
-            + "BQJMep8aAhsMBQkB4TOAAAoJEDsJuX9LQ4fu0/wH/35/22xina8ktbvGV/kB0pH2\n"
-            + "LBqeXN/bCLdA+CDzfwMDzqG0kU39EJ3Fbux7fj4uMaeiYfbO9U85+NOuDmeH41B2\n"
-            + "dM9S1AzEH+/OiCp/Zf1fdd1qXhsA4Xe5vc/VD9oso9OrZK5CM5u0TPmYFijfVDPN\n"
-            + "gag6mPnDzd8JCsuEj4VEy6NF1KcoCc8edQ8AZ4L6ZQ6qiV24gxLnh8xImVr5YjBK\n"
-            + "DUCdrl790u4wekfgapSx9Sw9Ycz5dFOL07OOHPiKZwUG0f8td6oJX4Ddxset5JAm\n"
-            + "1pPcLQHR6PRx0hI/Tz7rsAI6O37/BEM15+MVGIgOSLL/SRIpOa0L8qmuUhhS6Bg=\n"
-            + "=HTKj\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * pub 2048R/31FA48C4 2010-09-01 Key fingerprint = 85CE F045 8113 42DA 14A4 42AA 4A9F AC70 31FA
-   * 48C4 uid Testuser F &lt;testf@example.com&gt; sub 2048R/50FF7D5C 2010-09-01
-   */
-  public static TestKey keyF() throws Exception {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBEx+aQkBCADycHmQ8wC4GzaDIvw4uv6/HiTWOUmuLcT06PpsvNBdR2sQ6Vyy\n"
-            + "w1SAnaPmskdgxE7TXpDrwIWPmIkg8KzSfPAap6qZy5zyE1ABQa9yD9v6wsew+lM5\n"
-            + "3UdBO6HQodpWSJMbeR48mUQ96z72B7Lb2GvhFLxvcn5od9jQhbQXfb2k67l33hgR\n"
-            + "D427lxXa+qnmL9pMRGhRw6QATFX+icHxsPfpKnzuk0aY3feJm4+jr4RgHP4djH3i\n"
-            + "NZbv3ibZ24Dj3CK07PwbhqUhZwMqueWbo3ChYjLkRGT/UosNTN0EbHjqBMl4N9OT\n"
-            + "Pl2CM6kuzuaLz3ZeAf48B29GX4rAXfuJTKBbABEBAAG0HlRlc3R1c2VyIEYgPHRl\n"
-            + "c3RmQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pCQIbAwYLCQgHAwIGFQgCCQoL\n"
-            + "BBYCAwECHgECF4AACgkQSp+scDH6SMRfqggAh//U/l4JuwFgWx14mo0SB9YWU81L\n"
-            + "EgUYUd2MUzvX4p/HIFQa0c7stj68Za40+O0tG/J0RCMNb7piM9JFii+MQZzOVuza\n"
-            + "4bbO59D9qboc7Anvx9hGlfIdinT+n5rwX9kZvD2D7GMskm8ZgovkvNwNKcW+5W/4\n"
-            + "ciWqCJKE/Fp9XsooJgN94pJfgDQ2WBL5KDx1aGt4wZXhH2Atl6a6oVZJIH4SaizD\n"
-            + "jau7F4vc7hBfbcDhxFcrVX1QMpzpl352cIx6KVw4FRWvQ8VKkga4JiQwosfvCT2Z\n"
-            + "pdMwy3cARynv8BWLc4Uexf88QIeClP9ZhoVeMqvHMfUb3d6Q5362VdZqI4kBIAQQ\n"
-            + "AQIACgUCTH5xcgMFCngACgkQiptSk+LTK6UqsAgAlsEmzC3Xxv4o5ui95AFbWZGi\n"
-            + "es5rI9WoW2P+6OqVUy1E8+5HdlJ8wUbU1H7JAdFTjY9rH3vKXCXsTetF4z0cupER\n"
-            + "Rkx06M9/jl5OSw8i9bPNNJFobHwiiNO00ctC1tT5oUVXVsfPQHlEbMofv8jehfgC\n"
-            + "gMqH/ve/aafKFfYCZkNHugRgLzxeDpXp3IdyXoSAFGiULnGvMDN7n61QOvEYOw2Z\n"
-            + "i63ql+bL2oj4G+/bNOkdYkuIBN4F/P45P7xy80MSOvkMH7IG/aFTKMNQGWSykKwI\n"
-            + "FRkC+y+F5Oqf/WD30GvbSA7q013sb6nHYvsaHS/48cgIJ5TSVd0LTlrF9uv43bkB\n"
-            + "DQRMfmkJAQgAzc1uAF4x16Cx4GtHI0Hvm+v7bUEUtBw2XzyOKu883XC5JmGcY18y\n"
-            + "YItRpchAtmacDpu0/2925/mWF7aS9RMgSYI/1D9LaTeimISM3iGFY35kt78NGZwJ\n"
-            + "DeCPJPI1sbOU0njfrCPTbOQuRDJ6evaBNX9HYArSEp0ygruJdOUYgnepCt4A7W95\n"
-            + "EKp9KPo7XV1K8y86vrKbgpJ+NnEi7dzMqVxnhO4wAWqb6HYcKLrEc2gVnLtzHkBl\n"
-            + "Y/6dOP15jgQKql1/yQIXae/WGT24n/VeaKqrbSmDNkhW5eW5o1Bkgy/M98oNHXd0\n"
-            + "nVrT8Lyf6un5TwMy+vk0l5AjMMtIZKS0GQARAQABiQEfBBgBAgAJBQJMfmkJAhsM\n"
-            + "AAoJEEqfrHAx+kjEvDAH/iO6BHQfFa+kqjfYD3NE+FNosXv3jiXOU7SCD2MG3AwD\n"
-            + "YqM+v1n4UvvMLLdEbtboht1Btys1vuyNM3RAmR45oh9Dfuc4SKtVzSCkKs85jNvH\n"
-            + "7Ik8gxZ9ARzJbawNzTLFyLwDdcdX42Umuvh49Pn7Nc7FDYcZLffEcTh9sZ7KyxLY\n"
-            + "qcjtnblx5oOQnYnpBbM61GvgNXC8Z+g9fg0oHRouKXKE/HDKbsN0siEf9XJFJTKd\n"
-            + "Eg1NgoyKWdaV4+pU/fTzZUvvDqOSRx8he5w64dvW9o7WdARq/3vPvHgy0O8fMTSI\n"
-            + "tmcHxCU8l0jptJz181N36Uhmjyc9oC4dn9ceSn6VDbg=\n"
-            + "=WDx2\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBEx+aQkBCADycHmQ8wC4GzaDIvw4uv6/HiTWOUmuLcT06PpsvNBdR2sQ6Vyy\n"
-            + "w1SAnaPmskdgxE7TXpDrwIWPmIkg8KzSfPAap6qZy5zyE1ABQa9yD9v6wsew+lM5\n"
-            + "3UdBO6HQodpWSJMbeR48mUQ96z72B7Lb2GvhFLxvcn5od9jQhbQXfb2k67l33hgR\n"
-            + "D427lxXa+qnmL9pMRGhRw6QATFX+icHxsPfpKnzuk0aY3feJm4+jr4RgHP4djH3i\n"
-            + "NZbv3ibZ24Dj3CK07PwbhqUhZwMqueWbo3ChYjLkRGT/UosNTN0EbHjqBMl4N9OT\n"
-            + "Pl2CM6kuzuaLz3ZeAf48B29GX4rAXfuJTKBbABEBAAEAB/4vTP+C5s5snS6ZDlHc\n"
-            + "datvOV/hhgLYn2huiigV4A7dLCp4/bbOz+pkP51zTLQ9bn+coLYwsPq+Bfo3OY3W\n"
-            + "cXbdFHpmEEJaPqdc32ZuICcAuVEBuA1V3FTjJtHO5U02iWleMlbSZurYE9ZQZTch\n"
-            + "yotdulB7hACivENKh9OXw7ok+1GZVvBGA8tpIwzLZo0Pkb2lDQHaL0GXAjlMNzwg\n"
-            + "cCPFtzjNu6K4g58nuYrjGiE+yWPMJgfo4fTGXcapqXgvh1tKIVxwr2YQSyEOqfMH\n"
-            + "8EwgBj5NPwv0UXAivQUkTaguUJXrlJLtS3mp45nCEAlGT4PNoMyPdvPEf62gND7C\n"
-            + "y9K1BAD493ADPAx9pWCSQI9wp4ARUelTzwHgZ6fRVIzmwO6MuZN1PrtiOLCwY5Jw\n"
-            + "r+97VvMmem7Ya3khP4vz0IiN7p1oCR5nJazk2eRaQNuim0aB0lqrTsli8OXtBlgQ\n"
-            + "5WtLcRi5798Jw8coczc5OftZKhu1SbQZ1VdDdmTbMTAsSRtMjQQA+UnU6FYJZBjE\n"
-            + "NHNheV6+k45HXHubcCm4Ka3kJK88zbZzyt+nrBLEtElosxDCqT8WbiAH7qmpnd/r\n"
-            + "ly7ryIX08etuWVYnx0Xa02cKQ6TzNcbxijeGQYGHIE0RK29nRo8zRWVmbCydqJz1\n"
-            + "5cHgcvoTu7DWWjM5QEZlLPQytJeAyocEAM6AiWDXYVZVnCB9w0wwK/9cX0v3tfYv\n"
-            + "QrJZCT3/YKxJWnMZ+LgHYO0w1B0YwGEeVTnmXODDy5mRh9lxV1aZnwKCwMR1tXTx\n"
-            + "G1potBR0GJxI2xpMb/MJPxeJCAZPu8NncRpl/8v0stiGnkpYCNR/k3JV5jEXq0u6\n"
-            + "4pDSzRGehOHnOqu0HlRlc3R1c2VyIEYgPHRlc3RmQGV4YW1wbGUuY29tPokBOAQT\n"
-            + "AQIAIgUCTH5pCQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQSp+scDH6\n"
-            + "SMRfqggAh//U/l4JuwFgWx14mo0SB9YWU81LEgUYUd2MUzvX4p/HIFQa0c7stj68\n"
-            + "Za40+O0tG/J0RCMNb7piM9JFii+MQZzOVuza4bbO59D9qboc7Anvx9hGlfIdinT+\n"
-            + "n5rwX9kZvD2D7GMskm8ZgovkvNwNKcW+5W/4ciWqCJKE/Fp9XsooJgN94pJfgDQ2\n"
-            + "WBL5KDx1aGt4wZXhH2Atl6a6oVZJIH4SaizDjau7F4vc7hBfbcDhxFcrVX1QMpzp\n"
-            + "l352cIx6KVw4FRWvQ8VKkga4JiQwosfvCT2ZpdMwy3cARynv8BWLc4Uexf88QIeC\n"
-            + "lP9ZhoVeMqvHMfUb3d6Q5362VdZqI50DmARMfmkJAQgAzc1uAF4x16Cx4GtHI0Hv\n"
-            + "m+v7bUEUtBw2XzyOKu883XC5JmGcY18yYItRpchAtmacDpu0/2925/mWF7aS9RMg\n"
-            + "SYI/1D9LaTeimISM3iGFY35kt78NGZwJDeCPJPI1sbOU0njfrCPTbOQuRDJ6evaB\n"
-            + "NX9HYArSEp0ygruJdOUYgnepCt4A7W95EKp9KPo7XV1K8y86vrKbgpJ+NnEi7dzM\n"
-            + "qVxnhO4wAWqb6HYcKLrEc2gVnLtzHkBlY/6dOP15jgQKql1/yQIXae/WGT24n/Ve\n"
-            + "aKqrbSmDNkhW5eW5o1Bkgy/M98oNHXd0nVrT8Lyf6un5TwMy+vk0l5AjMMtIZKS0\n"
-            + "GQARAQABAAf/T22JFmhESUnSTOBqeK+Sd/WIOJ7lDCxVScVXwzdJINfIBYmnr2yG\n"
-            + "x18NuHOEkkEg2rx6ixksZZRcurMynZZvoB8+Xj69bpLT1JRXv8VlM0SNP6NjPW6M\n"
-            + "ygfQhzxZv8ck2WRgQxIin8SjHJv0zG9F5+1DEUyrzhZQb8dMYkqm/nbZ1FDnMu4F\n"
-            + "1qUZxKx0hU70tAXfywtpH9NQs8jwenUjiXA00k6A48BF7gartYtcGnEG9mk+Z+lh\n"
-            + "/uD+z5j3/ym9XqOJPpFIWhMYTLueSD5yrCT34VdIc1xBOjjtxBsCCbgSFZaewCpB\n"
-            + "5usRr2I4+CK3vbAMny5Hk+/RYZdFQkCA5wQA2JusdhwqPjfzxtcxz13Vu1ZzKR41\n"
-            + "kkno/boGh5afBlf7kL/5FXDhGVVvHMvXtQntU1kHgOcE8b2Jfy38gNGkd3TAh4Oj\n"
-            + "fLavcYyn+9tEkjRVdOeU0P9fszDA1cW5Gjuv6GkbCUSQrv68TKp/mWiTlYm+FT3a\n"
-            + "RSIz2gEyOZNkTzsEAPM6sU/VOwpJ2ppOa5+290sptjSbRNYjKlQ66nHZnbafzLz5\n"
-            + "tKpRc0BzG/N2lXwlVl5+3oXSSSbWhJscA8EFwSnAx8Id10zW5NAEfxNuqxxEXlJg\n"
-            + "kOhqwJ1JMz32xlZFRZYxSdXSycYrX/AhV7I7RQxgC48X9udMb8LIXYq0lzy7A/9p\n"
-            + "Skd2Me9JotuTN3OaR42hXozLx+yERBBEWuI3WXovWRD8b8gCfWL3P40d2UVnjFmP\n"
-            + "TZ8p9aHAd2srWgaPSZaSsHtIyI6dQGScMEOKEaCJxYvF/wuvx/MABDatcaJhMaAc\n"
-            + "W/0w+gb8Lr2hbuRhBSP754V3Amma6LxsmLRAwB6ioT7NiQEfBBgBAgAJBQJMfmkJ\n"
-            + "AhsMAAoJEEqfrHAx+kjEvDAH/iO6BHQfFa+kqjfYD3NE+FNosXv3jiXOU7SCD2MG\n"
-            + "3AwDYqM+v1n4UvvMLLdEbtboht1Btys1vuyNM3RAmR45oh9Dfuc4SKtVzSCkKs85\n"
-            + "jNvH7Ik8gxZ9ARzJbawNzTLFyLwDdcdX42Umuvh49Pn7Nc7FDYcZLffEcTh9sZ7K\n"
-            + "yxLYqcjtnblx5oOQnYnpBbM61GvgNXC8Z+g9fg0oHRouKXKE/HDKbsN0siEf9XJF\n"
-            + "JTKdEg1NgoyKWdaV4+pU/fTzZUvvDqOSRx8he5w64dvW9o7WdARq/3vPvHgy0O8f\n"
-            + "MTSItmcHxCU8l0jptJz181N36Uhmjyc9oC4dn9ceSn6VDbg=\n"
-            + "=ZLpl\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * pub 2048R/E2D32BA5 2010-09-01 Key fingerprint = CB2B 665B 88DA D56A 7009 C15D 8A9B 5293 E2D3
-   * 2BA5 uid Testuser G &lt;testg@example.com&gt; sub 2048R/829DAE8D 2010-09-01
-   */
-  public static TestKey keyG() throws Exception {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBEx+aRYBCAC77YjBScTjRFHtZvk0yyAy8KAXopbCdMBQs7S7iidFMMxhs0Uu\n"
-            + "D7GeleyVusLFJfEM0Ul0b0pLgfJx9j3cot4BTl71OqnawHp4ktuqFyTjhhYy8kBe\n"
-            + "4mliNP36WW7fYXh+f5SZqDQ6rgyoJCOmiUlosb6CM2yUPH3oDtOKg/9Z0iMUcXfQ\n"
-            + "y+bxRKSQmDtiSIS7hwUZmQoo30iAZNygMBLnYyVau3YFan+xyBMCFLa2/pfE0qaU\n"
-            + "QMy67XP8uP7DXlepfc4Lk/qa/2WnAqmuTT2ty9MG+X8M8LuiPuMWfOEx8ICUWB9s\n"
-            + "kCCMWCagS7EUIPhp6AOqjMqEWGOyLmclkGCJABEBAAG0HlRlc3R1c2VyIEcgPHRl\n"
-            + "c3RnQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pFgIbAwYLCQgHAwIGFQgCCQoL\n"
-            + "BBYCAwECHgECF4AACgkQiptSk+LTK6VSwQf/WnIYkLZoARZIUfH61EDlkUPv8+6G\n"
-            + "1YY3YgFFMjeOKybu47eU3QtATEaKHphvKqFtxdNyEtmti1Zx7Cq2LzReY1KoQQ5E\n"
-            + "OlKeyxVmXAuAqoRWesxuG318rVTrozCqSdKPCHLcC26M5sO+Gd2sKbA4DjoSyfrE\n"
-            + "zEOVS1NA9dtZ7WBMXr8gjH//ob7dvuptSAlADaLYYaJugcmbzkRGRbfiCQHqv30I\n"
-            + "+81d7RAeSx8XS38YEWm2IvBLpiS/d7A/2AQ25SHxf+QMMWt83+uOuEVa9rEOraid\n"
-            + "ZC6T8vnSRu1TKkX/60LnJvAw9tigmedi21O6Gpz3H3uGyjuk9o18+m8dJokBIAQQ\n"
-            + "AQIACgUCTH5xfAMFCngACgkQSp+scDH6SMT42gf9H7K0jp6PF1vD5t90bcjtnP/t\n"
-            + "CkOXgfL3lJK/l0KMkoDzyO5z898PP8IAnAj1veJ2fNPsRP903/3K8kd9/31kBriC\n"
-            + "poTVPWBmeLut16TgSDxAQPDLsBPcKe2VadhszOQwhfmdsUlCXwXcwbiAjweXwKh+\n"
-            + "00UoW1GLnPw0T387ttCjHsLe972SVUPFxb6NUkA7val62qxDKg+6MRcf6tDs8sN8\n"
-            + "orhYgh9VJcI3Iw8qK1wHI0CenNie0U5xEkZ5U6W4lfhnL5sggjoAeVeAVLiQ4eiP\n"
-            + "sFrq4TOYq9qfuThYiRaSuTLXzuWG5NVs7NyXxOGFSkwzXrQsBo+LuPwjSCERLbkB\n"
-            + "DQRMfmkWAQgA1O0I9vfZNSRuYTx++SkJccXXqL4neVWEnQ4Ws9tzfSG0Rch3Gb/d\n"
-            + "+ckDtJhlQOdaayTVX7h5k8tTGx0myg6OjG2UM6i+aTgFAzwGnBh/N3p5tTaJhRCF\n"
-            + "x1IapX0N7ijq6rQPPCISc3CUZhCVBTnp5dk3c0/hNxsyYXlI1AwuoMabygzTFN/c\n"
-            + "b1bXp0UTTVrdN+Sj5hHVDvpxyaljLa77I0V+lI3bCil9VhQ9h/TP4C2iK3ZdXOMb\n"
-            + "uW7ANhd+I9LWulmExZIiD9RIsHvB3bDu32g1847uT+DUynKETbZWlZS0Q93Aly1N\n"
-            + "lBIkvOCVCBt+VatzZ8oBV8vbk5R41W1HywARAQABiQEfBBgBAgAJBQJMfmkWAhsM\n"
-            + "AAoJEIqbUpPi0yul/doH+wR+o6UCdD6OZxGMx7d0a7yDJqQFkFf2DRsJvY2suug0\n"
-            + "CMJZRWiA+hIin5P6Brn/eb5nTdWgzlrHxkvb68YkevHALdOvmrYNQFXbb9uWGgEf\n"
-            + "3qERdI8ayJsSTqYsTqyuh9YVz21kADxTHN3JkJ4evjHpyz0Xbtq+oDADg+uswj1b\n"
-            + "ihHthFif54vNMEIW9rX9T7ufhXKamr4LuGwKTPTxV8gEPW4h4ZoQwFKV2qOjR+su\n"
-            + "tHnuXVL24kTnv8CHXUVzJXVTNz7i7fAJTgWc9drH6Ktp3XHfLDBwzT5/5ZhyxGJk\n"
-            + "Qq2Jm/Q8mNkXi34H2DeQ3VPtjtMLr9JR9pf6ivmvUag=\n"
-            + "=34GE\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOXBEx+aRYBCAC77YjBScTjRFHtZvk0yyAy8KAXopbCdMBQs7S7iidFMMxhs0Uu\n"
-            + "D7GeleyVusLFJfEM0Ul0b0pLgfJx9j3cot4BTl71OqnawHp4ktuqFyTjhhYy8kBe\n"
-            + "4mliNP36WW7fYXh+f5SZqDQ6rgyoJCOmiUlosb6CM2yUPH3oDtOKg/9Z0iMUcXfQ\n"
-            + "y+bxRKSQmDtiSIS7hwUZmQoo30iAZNygMBLnYyVau3YFan+xyBMCFLa2/pfE0qaU\n"
-            + "QMy67XP8uP7DXlepfc4Lk/qa/2WnAqmuTT2ty9MG+X8M8LuiPuMWfOEx8ICUWB9s\n"
-            + "kCCMWCagS7EUIPhp6AOqjMqEWGOyLmclkGCJABEBAAEAB/QJiwZmylg1MkL2y0Pc\n"
-            + "anQ4If//M0J0nXkmn/mNjHZyDQhT7caVkDZ01ygsck9xs3uKKxaP0xbyvqaRIvAB\n"
-            + "REQBzPkFevUlJqERfmOpP4OgCi8WZzbdmqG/WvGKxP/cWBbGVbQ2GVSNpkj+QNeO\n"
-            + "nWoc5unFstbQsEG0hww2/Hz7EppYoBvDrDLY1EPKzr0r6sk1O5gk3VWOqMEJVCh+\n"
-            + "K7EV4pPGmzMrfZQ0jSwRpr0HhzzhDYR7+QUbxr4OS5PoSJDFh0+A5kqFagyupe7A\n"
-            + "96L3Lh7wJBQJsOe5xjOu3lkFp+3vU+Mq7VzO9Fnp9BCwjb4mEjI39bJdGeeOVCWR\n"
-            + "sYEEAMjmftMhIHrjGRlbZVrLcZY8Du4CFQqImb2Tluo/6siIEurVp4F2swZFm7fw\n"
-            + "B2v09GGJ6zKpauJuxlbwo3CFnxbk24W39F/SixZLggLPtNOXdSrLIQrQ1AXu5ucQ\n"
-            + "oCnXS5FaVkD3Rtd53hSMIf2xJiSRKGp/1X9hga/phScud7URBADveDh1oEmwl3gc\n"
-            + "gorhABLYV7cPrARteQRV13tYWcuAZ6WjqNlbbW2mzBE7KTh4bgTzIX0uQ6SZ7bPl\n"
-            + "RmuKQHrdOO9vFGiSf3zDnIg8fhqSyy2SNrC/e7teuaguGCrg5GrP5izBAsiwvXbt\n"
-            + "ST3OG7c8Ky717JGTiUeTJoe4IaET+QP/SB4uQzVTrbXjBNtq1KqL/CT7l2ABnXsn\n"
-            + "psaVwHOMmY/wP+PiazMEDvLInDAu7R8oLNGqYR+7UYmYeAGmWgrc0L3yFVC01tTG\n"
-            + "bk7Yt/V5KRKVO2I9x+2CP0v0EqW4BNOJzbx5TJ5lBFLMTvbviOdsoDXw0S98HIHB\n"
-            + "T1bFFmhVeulCDLQeVGVzdHVzZXIgRyA8dGVzdGdAZXhhbXBsZS5jb20+iQE4BBMB\n"
-            + "AgAiBQJMfmkWAhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRCKm1KT4tMr\n"
-            + "pVLBB/9achiQtmgBFkhR8frUQOWRQ+/z7obVhjdiAUUyN44rJu7jt5TdC0BMRooe\n"
-            + "mG8qoW3F03IS2a2LVnHsKrYvNF5jUqhBDkQ6Up7LFWZcC4CqhFZ6zG4bfXytVOuj\n"
-            + "MKpJ0o8IctwLbozmw74Z3awpsDgOOhLJ+sTMQ5VLU0D121ntYExevyCMf/+hvt2+\n"
-            + "6m1ICUANothhom6ByZvOREZFt+IJAeq/fQj7zV3tEB5LHxdLfxgRabYi8EumJL93\n"
-            + "sD/YBDblIfF/5Awxa3zf6464RVr2sQ6tqJ1kLpPy+dJG7VMqRf/rQucm8DD22KCZ\n"
-            + "52LbU7oanPcfe4bKO6T2jXz6bx0mnQOYBEx+aRYBCADU7Qj299k1JG5hPH75KQlx\n"
-            + "xdeovid5VYSdDhaz23N9IbRFyHcZv935yQO0mGVA51prJNVfuHmTy1MbHSbKDo6M\n"
-            + "bZQzqL5pOAUDPAacGH83enm1NomFEIXHUhqlfQ3uKOrqtA88IhJzcJRmEJUFOenl\n"
-            + "2TdzT+E3GzJheUjUDC6gxpvKDNMU39xvVtenRRNNWt035KPmEdUO+nHJqWMtrvsj\n"
-            + "RX6UjdsKKX1WFD2H9M/gLaIrdl1c4xu5bsA2F34j0ta6WYTFkiIP1Eiwe8HdsO7f\n"
-            + "aDXzju5P4NTKcoRNtlaVlLRD3cCXLU2UEiS84JUIG35Vq3NnygFXy9uTlHjVbUfL\n"
-            + "ABEBAAEAB/48KLaaNJ+xhJgNMA797crF0uyiOAumG/PqfeMLMQs5xQ6OktuXsl6Q\n"
-            + "pus9mLsu8c7Zq9//efsbt1xFMmDVwPQkmAdB60DVMKc16T1C2CcFcTy25vBG4Mqz\n"
-            + "bK6rqCAJ9JSe+H2/cy78X8gF6FR6VAkSUGN62IxcyfnbkW1yv/hiowZ5pQpGVjBH\n"
-            + "sjfu+6HGZhdJIyzrjnVjTJhXNCodtKq1lQGuL2t3ZB6osOXEsFtsI6lQF2s6QZZd\n"
-            + "MUOpSO+X1Rb5TCpWpR/Yj43sH6Tq7LZWEml9fV4wKe2PQWmFW+L8eZCwbYEz6GgZ\n"
-            + "w2pMoMxxOZJsOMOq4LFs4r9qaNQI+sU1BADZhx42JjqBIUsq0OhQcCizjCbPURNw\n"
-            + "7HRfPV8SQkldzmccVzGwFIKQqAVglNdT9AQefUQzx84CRqmWaROXaypkulOB79gM\n"
-            + "R/C/aXOdWz9/dGJ9fT/gcgq1vg9zt7dPE5QIYlhmNdfQPt6R50bUTXe22N2UYL98\n"
-            + "n1pQrhAdlsbT3QQA+pWPXQE4k3Hm7pwCycM2d4TmOIfB6YiaxjMNsZiepV4bqWPX\n"
-            + "iaHh0gw1f8Av6zmMncQELKRspA8Zrj3ZzB/OvNwfpgpqmjS0LyH4u8fGttm7y3In\n"
-            + "/NxZO33omf5vdB2yptzE6DegtsvS94ux6zp01SuzgCXjQbiSjb/VDL0/A8cD/1sQ\n"
-            + "PQGP1yrhn8aX/HAxgJv8cdI6ZnrSUW+G8RnhX281dl5a9so8APchhqeXspYFX6DJ\n"
-            + "Br6MqNkX69a7jthdLZCxaa3hGInr+A/nPVkNEHhjQ8a/kI+28ChRWndofme10hje\n"
-            + "QISFfGuMf6ULK9uo4d1MzGlstfcNRecizfniKby3SBmJAR8EGAECAAkFAkx+aRYC\n"
-            + "GwwACgkQiptSk+LTK6X92gf7BH6jpQJ0Po5nEYzHt3RrvIMmpAWQV/YNGwm9jay6\n"
-            + "6DQIwllFaID6EiKfk/oGuf95vmdN1aDOWsfGS9vrxiR68cAt06+atg1AVdtv25Ya\n"
-            + "AR/eoRF0jxrImxJOpixOrK6H1hXPbWQAPFMc3cmQnh6+MenLPRdu2r6gMAOD66zC\n"
-            + "PVuKEe2EWJ/ni80wQhb2tf1Pu5+Fcpqavgu4bApM9PFXyAQ9biHhmhDAUpXao6NH\n"
-            + "6y60ee5dUvbiROe/wIddRXMldVM3PuLt8AlOBZz12sfoq2ndcd8sMHDNPn/lmHLE\n"
-            + "YmRCrYmb9DyY2ReLfgfYN5DdU+2O0wuv0lH2l/qK+a9RqA==\n"
-            + "=T1WV\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * pub 2048R/080E5723 2010-09-01 Key fingerprint = 2957 ABE4 937D A84A 2E5D 31DB 65C4 33C4 080E
-   * 5723 uid Testuser H &lt;testh@example.com&gt; sub 2048R/68C7C262 2010-09-01
-   */
-  public static TestKey keyH() throws Exception {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBEx+aSUBCADzpZ1h9awUQR1ChzrMhtoE1ltyTlJpS1G5HFEov9QNxVDTjpB8\n"
-            + "PMdb20NNdk/7g6E+ETpCBJGPoC4/TPFDiqe+UI7cRrRZJVbInkCbflYycLTUt9qW\n"
-            + "5c7IuyZA1+cSYaKp3jYccFZfIWvfTWDLWyUozTs9t1TsI28s3r5fBPvrZ+F1nYv/\n"
-            + "xpSkx3Zsxnn7QJTnd63rZdp0RdfJmF2rXERwR6XVtuLj5CqrFoLxy6OrSOl4am4J\n"
-            + "C2HRWhskB21LpdRtloY8bz0DOn6W6JUFRmSxQ1kbPClOXaiNhzMI0fD/KFnHImgR\n"
-            + "IKbbQyHHsKHBjNyn+zTIm5zUL6JMZMf9PoSZABEBAAG0HlRlc3R1c2VyIEggPHRl\n"
-            + "c3RoQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pJQIbAwYLCQgHAwIGFQgCCQoL\n"
-            + "BBYCAwECHgECF4AACgkQZcQzxAgOVyORcAf/QaHVlyhlBnU4edujW2uG/PFrZvwK\n"
-            + "fqOKW0QqQ7kVN8okKhnFv4y11IwLIzL9mOLYe2+Zyv3I3bz4X8Xw+MsBF6sMWLLf\n"
-            + "9ieu4Wz/5ScVu0PxY36kgV0AQRiLXk802Vk4t9jElCp9qx/dDln7f3879LLb3wNt\n"
-            + "fajne8EH0hjR4E3joPoG+IXSvSzWcPoZTmAZOKHPcRS8iqy0Ao8/UuQWYCedI/4R\n"
-            + "S1IJaByk8mmkMkqqV0kuPyDkvGpqhfh9zFYh97LuKcJktRTEBp3YMvuGcBDBwofG\n"
-            + "vYIVEMr7Ci5rowRQO/sxJfI1zNSWterWC46v6tOb9IvenOgP0/dQxlU82YkBIAQQ\n"
-            + "AQIACgUCTH5xmAMFAXgACgkQ0CLaOl6a7dCYuQf/V2i3Ih5Dqze0Rz5zoTD56/J7\n"
-            + "0SA4/SFm5eDUirY5B9BohkyxoMVG04uyjUmVs62ree7N0IASmeiF/wkBUZ/r/rr/\n"
-            + "0ntGj43y+1JpuSEohZOfgZJryDKRqyVWhRbeBj0g/SzxIQ1lEt2iHFvdSlfFVd+a\n"
-            + "SH1uDDjT/ZATKfAXcgeajUirWorJRaldue7O4oFe67fMLy36ewvpaMVZ+SpxH4CC\n"
-            + "Owq4Ls3dIAg2C5GQK8G0G7FwT1M26EPg66C79EGYkaxprgrilWE6l7QHc484TY1L\n"
-            + "ys04qKoPRnBinmrRxgRyyimvDN/+nd1jdM6nMe1gVLL3s5Vgo0fJMwNhDZMtdrkB\n"
-            + "DQRMfmklAQgAyajPVMt+OXO1ow7xzb0aZYNa5Xdv+w50JzVeWI0boPOuOmq6RCc1\n"
-            + "3NhOmBzx3CKH6zbSRoLBCZWM3cs1EQbl+8noaxq6YQVWiaROX8U7CThYA50jONP/\n"
-            + "qEk655QFsP8Bq96Z5AT/MflxEMayOtQywUFREF4/olhXvJOdurZfQPGnIis35NUc\n"
-            + "IaubI+gGVsluqWBohLOgqzyF7GMlv+Y2JZE5JKGSTO7ZosyI+OCNdZ6X2CJdDPZ1\n"
-            + "325QHYkmqiMJtb73AYTXurL7NNTxdxQVOnfvwXXW4mgHwPEHr8PU30+2xgo1ktrr\n"
-            + "rpFsd0o2UFhybTe7w1z2sAO1gP5s1bbGlwARAQABiQEfBBgBAgAJBQJMfmklAhsM\n"
-            + "AAoJEGXEM8QIDlcjqkQIAI78nwAgO5EgrUDoFikH6d36Kie9SHleaYcSX2c95Vqc\n"
-            + "umuiSAhaulGX0gM/jwvZkoawSyWIq+O2sPSc9F7VzdYdEnWVj2J5BpVx83TRPrTu\n"
-            + "72tsJ97op6JZz+Q8HwTLYJBmyW3/TEKh+iRL9CBtfTVywodZa58j41vCkx37NFPw\n"
-            + "plglT/Se1/US1rWYTH3Kfqo5zNARLUYzAdcxEpjwXWOvqnybn86KfMwqiOunz8eq\n"
-            + "MnTQYECfUrhX2WrbEAjCSc6/LfrTv/S+cO0rvulO/R97gG99pZdWSUjZypU5KLbp\n"
-            + "MBh0qq2wQxO2iagNXE6ms3kV/XihvCpXo9RArmldmW0=\n"
-            + "=lddL\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBEx+aSUBCADzpZ1h9awUQR1ChzrMhtoE1ltyTlJpS1G5HFEov9QNxVDTjpB8\n"
-            + "PMdb20NNdk/7g6E+ETpCBJGPoC4/TPFDiqe+UI7cRrRZJVbInkCbflYycLTUt9qW\n"
-            + "5c7IuyZA1+cSYaKp3jYccFZfIWvfTWDLWyUozTs9t1TsI28s3r5fBPvrZ+F1nYv/\n"
-            + "xpSkx3Zsxnn7QJTnd63rZdp0RdfJmF2rXERwR6XVtuLj5CqrFoLxy6OrSOl4am4J\n"
-            + "C2HRWhskB21LpdRtloY8bz0DOn6W6JUFRmSxQ1kbPClOXaiNhzMI0fD/KFnHImgR\n"
-            + "IKbbQyHHsKHBjNyn+zTIm5zUL6JMZMf9PoSZABEBAAEAB/wPPOigp4d9VcwxbLkz\n"
-            + "8OwiONDLz5OuY6hHCjsWMBcgTFqffI9TQc7bExW8ur1KVuNm+RdaaSQ8ZhF2YobF\n"
-            + "SV7v02R36NEfMStiDSmvv+E+stdQZXY9kT5TRgcgr5ATUXllo9DhCvKP7Qxs0Q9Q\n"
-            + "cJEcoedGVxiv0xCBLyYbVbm2sW+GJYjq0R5loaOy/Swbt5vOKQsajU8iyA4czSE8\n"
-            + "Ryr63OtwZ1TZsxekj//HKcngnptYY/FT5TPe4uzw8g1tJTIg/OZXrm8CahWzpfE3\n"
-            + "q8lGafhd0GjLftA9ffIHF0cAUs7HklMrgIKGdVPXfQmPzqDpmH5FO2y6QmqTG0v6\n"
-            + "JYW9BAD4Iobwh80MT3JZhJ0jGYMdi07cRyFN+hRwVKgNcBTdx3QGpGJatcyumD0C\n"
-            + "Yn/aXAn+XUkewSgYhdj9sSRodnWGoavdWELxUQkktsdiFg2/rnqmpqRXTGfR/tDh\n"
-            + "ohD2JaPrsavmUF6ShT3stGp8nUN+n6Bhd+QosaCZm5TC1CtA7QQA+16rrNNdP8XN\n"
-            + "MvpQRqJM5ljH0haqR/yD8vdCCZjk23hBk3YsXwSrhSbPzMeZC2FcDqkQTraTxrSG\n"
-            + "U0+xK3NjKKtbzCjQFH4cy4zdNMUX04OWopLGOEnnvTYukGtXT4lZQ9qm8ZBPh5a4\n"
-            + "cXfWy3ovjvRbxUuFOWm0gOfIoRcuWN0D/isTjqPmjihCuWkKTfa3xoq+dD7ynYhg\n"
-            + "Yu3UKfCqbNVor59ZrB4AkQiaVIDLKim3E1XDMS+IukmTuNVXpJeqK32tAYbEduHM\n"
-            + "7kwEq7SgVh34QvryKjCC/EUkDcjSQ+xlUaKl8QKYOdwtH97zZYK6QixB4uNQ6CuM\n"
-            + "75dqTZ6iQw7jQA+0HlRlc3R1c2VyIEggPHRlc3RoQGV4YW1wbGUuY29tPokBOAQT\n"
-            + "AQIAIgUCTH5pJQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQZcQzxAgO\n"
-            + "VyORcAf/QaHVlyhlBnU4edujW2uG/PFrZvwKfqOKW0QqQ7kVN8okKhnFv4y11IwL\n"
-            + "IzL9mOLYe2+Zyv3I3bz4X8Xw+MsBF6sMWLLf9ieu4Wz/5ScVu0PxY36kgV0AQRiL\n"
-            + "Xk802Vk4t9jElCp9qx/dDln7f3879LLb3wNtfajne8EH0hjR4E3joPoG+IXSvSzW\n"
-            + "cPoZTmAZOKHPcRS8iqy0Ao8/UuQWYCedI/4RS1IJaByk8mmkMkqqV0kuPyDkvGpq\n"
-            + "hfh9zFYh97LuKcJktRTEBp3YMvuGcBDBwofGvYIVEMr7Ci5rowRQO/sxJfI1zNSW\n"
-            + "terWC46v6tOb9IvenOgP0/dQxlU82Z0DmARMfmklAQgAyajPVMt+OXO1ow7xzb0a\n"
-            + "ZYNa5Xdv+w50JzVeWI0boPOuOmq6RCc13NhOmBzx3CKH6zbSRoLBCZWM3cs1EQbl\n"
-            + "+8noaxq6YQVWiaROX8U7CThYA50jONP/qEk655QFsP8Bq96Z5AT/MflxEMayOtQy\n"
-            + "wUFREF4/olhXvJOdurZfQPGnIis35NUcIaubI+gGVsluqWBohLOgqzyF7GMlv+Y2\n"
-            + "JZE5JKGSTO7ZosyI+OCNdZ6X2CJdDPZ1325QHYkmqiMJtb73AYTXurL7NNTxdxQV\n"
-            + "OnfvwXXW4mgHwPEHr8PU30+2xgo1ktrrrpFsd0o2UFhybTe7w1z2sAO1gP5s1bbG\n"
-            + "lwARAQABAAf8C3vFcrqz0Wm5ajOrqV+fZTB5uJ94jP9htengGYLPk/bMcR8qxD7H\n"
-            + "XnAi6Z6cV0DQJKDWkJVZkMYnY2ny96lA53mz9oVrH6NCLkxg+istFXVT7cDBBLdt\n"
-            + "05N3+z/+ovmiirr+YHG4Zowh2Ca4d4kl6sNhbmEvlnsZY++0B7Hi8ru2KgFBag2g\n"
-            + "wDmeVt2+ANJNfJ4uIHUEG+sDSDL4+rxQlBTMhxfVY5+zjbvzPlTf2jyAgDa5zGN2\n"
-            + "vRjB33Z0lbdZTeW7HsJcDsXaS77lKnQeWMmHSvpOXvFSIjnrWpxcMpg8hGY5e5UC\n"
-            + "zLCk+nucY/Od1NbtFYu/e7fl9/n3YnT7AQQA0v/t43Ut3go9vRlb47NN/KpJYL1N\n"
-            + "hh9F/SRzFwWxS+79CiZkf/bgmdJe4XkkS7QJMv+nXhtcko/gfzoaCrvIWIAyvhYa\n"
-            + "7tEbqH+iZ0eaLrQf7bu89Jmp2UNRT1EHLzm38eJ8gg7eNu+SjIhs3wART1KB7GvT\n"
-            + "YmpN5caJA2t2OaEEAPSq7CbvlPDc0qomQSs+NrDnhAv89mQEeksZRmhVa0o4Z7EO\n"
-            + "84DzM+Vxho5fn9h0LtxthhuKWKT8uYN/Qu4Y42cKQuRgMx09+GGwc4GWSC6gJPeP\n"
-            + "oKVJCdZx0l9u8fWQb37gnyH34WDxPvdQx3e4iw/dvruNzu17zmPndkdcyEU3BACD\n"
-            + "yXo21SEflFcfrO16VsITXWc9yweKTSD8Mq7wg2GG6eJPopgtwCLZSlYjnehxD2w2\n"
-            + "38lyr6jGPyITvalVwH6R//676Q2osbQ948Dv2ZcxaTlyla4RyY6E33hsnV9m8ZmM\n"
-            + "PUoNJvFSkKCuPy1N5zaYgUAPKwbEkc3qG+bZm+x2WU2biQEfBBgBAgAJBQJMfmkl\n"
-            + "AhsMAAoJEGXEM8QIDlcjqkQIAI78nwAgO5EgrUDoFikH6d36Kie9SHleaYcSX2c9\n"
-            + "5VqcumuiSAhaulGX0gM/jwvZkoawSyWIq+O2sPSc9F7VzdYdEnWVj2J5BpVx83TR\n"
-            + "PrTu72tsJ97op6JZz+Q8HwTLYJBmyW3/TEKh+iRL9CBtfTVywodZa58j41vCkx37\n"
-            + "NFPwplglT/Se1/US1rWYTH3Kfqo5zNARLUYzAdcxEpjwXWOvqnybn86KfMwqiOun\n"
-            + "z8eqMnTQYECfUrhX2WrbEAjCSc6/LfrTv/S+cO0rvulO/R97gG99pZdWSUjZypU5\n"
-            + "KLbpMBh0qq2wQxO2iagNXE6ms3kV/XihvCpXo9RArmldmW0=\n"
-            + "=voB9\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * pub 2048R/5E9AEDD0 2010-09-01 Key fingerprint = 818D 5D0B 4AE2 A4FE A4C3 C44D D022 DA3A 5E9A
-   * EDD0 uid Testuser I &lt;testi@example.com&gt; sub 2048R/0884E452 2010-09-01
-   */
-  public static TestKey keyI() throws Exception {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBEx+aTEBCAC6dFperkew4ZowIfEyAjScjPBggcbw5XUXxLCF0nBRjWH+HvuI\n"
-            + "CGwznRyeuTiy5yyB9/CcvTLTkEs8qIyJUJoikm7QpaVVL6imVq1HD1xcOJpV1FV1\n"
-            + "eFu562xCRDUqD6KQf54N04V9TMDyubhPkQYbx1H2gq+uBEo9d1w6AsSMgaUn3xH/\n"
-            + "xYe+INxcP6jFT2OKc36x+8ipP6pc8Hba1X90JwadOcJlwEyJfJKs7hYHTaYn+I6+\n"
-            + "4w0Y//WebhT4ocsYIiOYrENQUcic+vL3fkwwJCloyDBCGxr7w7Gn4Pe3peTCl4Sp\n"
-            + "vIIoYnHPW4h3Nyh8qAlBNDw7dCPS9LP7wRdNABEBAAG0HlRlc3R1c2VyIEkgPHRl\n"
-            + "c3RpQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pMQIbAwYLCQgHAwIGFQgCCQoL\n"
-            + "BBYCAwECHgECF4AACgkQ0CLaOl6a7dAjNQf/fLmGeKgaesawP53UeioQ8hgDEFKP\n"
-            + "BddNQP248NpReZ1rg3h8Q21PQJVKrtDYn94WJi5NTqUtk9rtx9SiqKaEc3wzIpLc\n"
-            + "nIYrgGLWot5nq+5V1nY9t9QAiJJDrmm2/3tX+jTWW6CpuLih7IsD+gJmpZkY6PfM\n"
-            + "T+teKEeh5E1XBbu10fwDwMJta+043/TiljInjER1f/b41EnSjI6YXFnxnyiLeDgD\n"
-            + "A1QIIzB/W2ccGqphzJriDETDJhKFZIeqvjylZofgCLyMRSyZtsu+b4pfBK3hMpu5\n"
-            + "aaYylaM1BWOpAiqUmGUKqxN/o9EGx4wvsMxK6xgiZe5UdQPaoDcFCsEMg4kBIAQQ\n"
-            + "AQIACgUCTH5xrAMFAXgACgkQoTk8RsLmoZiu2Af8D4PnyWkosYYkcmU4T7CvIHGW\n"
-            + "Qnx4KsnYWaAqYrYrorL6R+f8SZ5caGwj05UOvHnqx/Ij0a1Zv4MpEuzB0se1XkyQ\n"
-            + "eCLdAIKVodfiepsCHyqW6/mc9LV2qKS1HF5x5LwDkI1atOuPt/O14fch4E0beTbl\n"
-            + "FXzGo7YdpH8RunV8l+i3FxxTcUtUkij3Ro4EMwVF/6YG8gBOd08GxWspEQWBH3GK\n"
-            + "k7Repj4IPwXCoEfU1H+XJNPaM5cnt+L87QfbhNOWmHmWhhrOmZg160joODON8w8x\n"
-            + "j3gma9Cp6luPDEQC3XnsEup3BdCdIciG5JS6JA/2GDeulg+eS4x9Xkmmp6nzObkB\n"
-            + "DQRMfmkxAQgAxeT+bUBbADga+lYtkmtYVbuG7uWjwdg9TR6qWKD7n37mcu6OgNNl\n"
-            + "rPaHoClvOL20fcArZ8wT/FbjvDI6ZHn22YA19OvAR+Eqmf3D7qTmebchnCu955Pk\n"
-            + "X7AOOpKfX48qoYq8BoskZDnbFidm5YKfIin3CNDdlQbd3na+ihGCuv0KoGzefuAH\n"
-            + "cITeYEUESh7HLzQ9/pMES9eCgdTEkwYD5NJjfkLnj2kZtDsSiNnENZ0TIlyKOBMn\n"
-            + "ixgsARDjLrkqyTg79thWALiqVBXUKn2NBtMkK5xTDc/7q3nIw4InYMIrLtntSu1w\n"
-            + "pn1gXbdg1HFl5BgqEB9Fp0k02YvrSiiVswARAQABiQEfBBgBAgAJBQJMfmkxAhsM\n"
-            + "AAoJENAi2jpemu3QFPoH/1ynX1j1QWL8TfJFPoB3vXivwGURs3J7LsywHTRjpQVQ\n"
-            + "vxQvKTzB1+woUxtEbdjKgMbvY/ShHSlEZKVV9l3ZihrNewHA1GMHrDtBGXcNRP9B\n"
-            + "RfJHTrDzjUxrEEwu4QIq71o4tS89NvQmlYYi7O4ThtVB4hYSwl436+vAT9ybIQkU\n"
-            + "OjCkYrKye6JHs1K4BnVuWcOVujQwW4H8QFbddcWF49uN6DSqrwDFsjFog6wL7u6V\n"
-            + "UL5upRBP/RZWA4HKJVF2tS0Ptr6xLTmf4Tp5n10CGFYkPcRp9biVyeVRJBW4uZf0\n"
-            + "EDsn9J5rNG0pWtgnhAEi6smoT4fADTOzpOovUiTSQhQ=\n"
-            + "=SiG3\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBEx+aTEBCAC6dFperkew4ZowIfEyAjScjPBggcbw5XUXxLCF0nBRjWH+HvuI\n"
-            + "CGwznRyeuTiy5yyB9/CcvTLTkEs8qIyJUJoikm7QpaVVL6imVq1HD1xcOJpV1FV1\n"
-            + "eFu562xCRDUqD6KQf54N04V9TMDyubhPkQYbx1H2gq+uBEo9d1w6AsSMgaUn3xH/\n"
-            + "xYe+INxcP6jFT2OKc36x+8ipP6pc8Hba1X90JwadOcJlwEyJfJKs7hYHTaYn+I6+\n"
-            + "4w0Y//WebhT4ocsYIiOYrENQUcic+vL3fkwwJCloyDBCGxr7w7Gn4Pe3peTCl4Sp\n"
-            + "vIIoYnHPW4h3Nyh8qAlBNDw7dCPS9LP7wRdNABEBAAEAB/oCD6EKLvjXgItlqdm/\n"
-            + "X+OWMYHDCtuRCMW7+2gEw/TxfLeGJaOHWxAouwUIArEEb/hjdaRfIg4wdJUxmyPX\n"
-            + "WyNqUdupkjdXNa7RNaesIi0ilrdZOn7NlHWJCCXwKt2R0jd2p8PDED6CWaE1+76I\n"
-            + "/IuwOHDTD8MABke3KvHDXMxjzdeuRbm670Aqz6zTVY+BZG1GH63Ef5JEyezMgAU5\n"
-            + "42+v+OgD0W0/jCxF7jt2ddP9QiOzu0q65mI4qlOuSebxjH8P7ye0LU9EuWVgAcwc\n"
-            + "YJh2lk3eH8bCWTwlIHj4+8MYgY5i510I5xfY3sWuylw/qtFP9vYjisrysadcUExc\n"
-            + "QUxFBADXQSCmvtgRoSLiGfQv2y2qInx67eJw8pUXFEIJKdOFOhX4vogT9qPWQAms\n"
-            + "/vSshcsAPgpZJZ8MNeGpMGLAGm8y4D2zWWd9YLNmVXsPu7EyrDpXlKHCFnsQfOGN\n"
-            + "c5j8u4CHBn1cS/Yk53S+6Yge2jvnOjVNFmxB0ocs0Y5zbdTJYwQA3b+hQebH7NNr\n"
-            + "FlPwthRZS0TiX5+qkE9tE/0mpRrUN3iS9bnF0IXRmHFp7Hz+EsVbA2Re2A5HIHnQ\n"
-            + "/BSpAsSHRhjU3MH4gzwfg9W43eZGVfofSY6IlUCIcd1bGjSAjJgmfhjU7ofS59i/\n"
-            + "DjzP1jBfXdjOEUQULTkXjHPqO7j4048D/jqMwZNY3AawTMjqKr9nGK49aWv/OVdy\n"
-            + "6xGn4dRJNk3gnnIvjAEFy5+HHbUCJ2lA3X2AssQ9tvbuyDnoSL5/G+zEYtyRuAC5\n"
-            + "9TLQQRmy4qjsYC5TwfoUwFbgqRsmGUcjj2wtE+gb1S8P/zudYrEqOD3K60Y5qXcn\n"
-            + "S3PHgJ++5TzFQba0HlRlc3R1c2VyIEkgPHRlc3RpQGV4YW1wbGUuY29tPokBOAQT\n"
-            + "AQIAIgUCTH5pMQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ0CLaOl6a\n"
-            + "7dAjNQf/fLmGeKgaesawP53UeioQ8hgDEFKPBddNQP248NpReZ1rg3h8Q21PQJVK\n"
-            + "rtDYn94WJi5NTqUtk9rtx9SiqKaEc3wzIpLcnIYrgGLWot5nq+5V1nY9t9QAiJJD\n"
-            + "rmm2/3tX+jTWW6CpuLih7IsD+gJmpZkY6PfMT+teKEeh5E1XBbu10fwDwMJta+04\n"
-            + "3/TiljInjER1f/b41EnSjI6YXFnxnyiLeDgDA1QIIzB/W2ccGqphzJriDETDJhKF\n"
-            + "ZIeqvjylZofgCLyMRSyZtsu+b4pfBK3hMpu5aaYylaM1BWOpAiqUmGUKqxN/o9EG\n"
-            + "x4wvsMxK6xgiZe5UdQPaoDcFCsEMg50DmARMfmkxAQgAxeT+bUBbADga+lYtkmtY\n"
-            + "VbuG7uWjwdg9TR6qWKD7n37mcu6OgNNlrPaHoClvOL20fcArZ8wT/FbjvDI6ZHn2\n"
-            + "2YA19OvAR+Eqmf3D7qTmebchnCu955PkX7AOOpKfX48qoYq8BoskZDnbFidm5YKf\n"
-            + "Iin3CNDdlQbd3na+ihGCuv0KoGzefuAHcITeYEUESh7HLzQ9/pMES9eCgdTEkwYD\n"
-            + "5NJjfkLnj2kZtDsSiNnENZ0TIlyKOBMnixgsARDjLrkqyTg79thWALiqVBXUKn2N\n"
-            + "BtMkK5xTDc/7q3nIw4InYMIrLtntSu1wpn1gXbdg1HFl5BgqEB9Fp0k02YvrSiiV\n"
-            + "swARAQABAAf/VXp4O5CUvh9956vZu2kKmt2Jhx9CALT6pZkdU3MVvOr/d517iEHH\n"
-            + "pVJHevLqy8OFdtvO4+LOryyI6f14I3ZbHc+3frdmMqYb1LA8NZScyO5FYkOyn5jO\n"
-            + "CFbvjnVOyeP5MhXO6bSoX3JuI7+ZPoGRYxxlTDWLwJdatoDsBI9TvJhVekyAchTH\n"
-            + "Tyt3NQIvLXqHvKU/8WAgclBKeL/y/idep1BrJ4cIJ+EFp0agEG0WpRRUAYjwfE3P\n"
-            + "aSEV0NOoB8rapPW3XuEjO+ZTht+NYvqgPIdTjwXZGFPYnwvEuz772Th4pO3o/PdF\n"
-            + "2cljvRn3qo+lSVnJ0Ki2pb+LukJSIdfHgQQA1DBdm29a/3dBla2y6wxlSXW/3WBp\n"
-            + "51Vpd8SBuwdVrNNQMwPmf1L93YskJnUKSTo7MwgrYZFWf7QzgfD/cHXr8QK2C1TP\n"
-            + "czUC0/uFCm8pPQoOt/osp3PjDAzGgUAMFXCgLtb04P2JqbFvtse5oTFWrKqmscTG\n"
-            + "KnEBkzfgy37U0iMEAO7BEgXCYvqyztHmQATqJfbpxgQGqk738UW6qWwG8mK6aT5V\n"
-            + "OidZvrWqJ3WeIKmEhoJlY2Ky1ZTuJfeQuVucqzNWlZy2yzDijs+t3v4pFGajv4nV\n"
-            + "ivGvlb/O/QoHBuF/9K36lIIqcZstfa2UIYRqkkdEz2JHWJsr81VvCw2Gb38xA/sG\n"
-            + "hqErrIgSBPRCJObM/gb9rJ6dbA5SNY5trc778EjS1myhyPhGOaOmYbdQMONUqLo2\n"
-            + "q1UZo1G7oaI1Um9v5MXN1yZNX/kvx1TMldZEEixrhCIob81eXSpEUfs+Mz2RqvqT\n"
-            + "YsYquYQNPrPXWZQwTJV6fpsBQUMeE/pmlisaSAijHkXPiQEfBBgBAgAJBQJMfmkx\n"
-            + "AhsMAAoJENAi2jpemu3QFPoH/1ynX1j1QWL8TfJFPoB3vXivwGURs3J7LsywHTRj\n"
-            + "pQVQvxQvKTzB1+woUxtEbdjKgMbvY/ShHSlEZKVV9l3ZihrNewHA1GMHrDtBGXcN\n"
-            + "RP9BRfJHTrDzjUxrEEwu4QIq71o4tS89NvQmlYYi7O4ThtVB4hYSwl436+vAT9yb\n"
-            + "IQkUOjCkYrKye6JHs1K4BnVuWcOVujQwW4H8QFbddcWF49uN6DSqrwDFsjFog6wL\n"
-            + "7u6VUL5upRBP/RZWA4HKJVF2tS0Ptr6xLTmf4Tp5n10CGFYkPcRp9biVyeVRJBW4\n"
-            + "uZf0EDsn9J5rNG0pWtgnhAEi6smoT4fADTOzpOovUiTSQhQ=\n"
-            + "=RcWw\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  /**
-   * pub 2048R/C2E6A198 2010-09-01 Key fingerprint = 83AB CE4D 6845 D6DA F7FB AA47 A139 3C46 C2E6
-   * A198 uid Testuser J &lt;testj@example.com&gt; sub 2048R/863E8ABF 2010-09-01
-   */
-  public static TestKey keyJ() throws Exception {
-    return new TestKey(
-        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "mQENBEx+aUgBCADczNio9UWnUggUZkdqJye57497oD9vNo9rmtR+i1TkMpVeWaMH\n"
-            + "UWrm1twzIPV9D4lAWLJoG2cYF6nXG1JxsKc9mOIZ6O1WfsopMUU0p+EfU8H/cvdM\n"
-            + "/iccYS6OnNL4/xR1R7hlA4+b/jaOZfzdS3i5jwOf+TtCk7c5qOuFhraiVQ9H1G86\n"
-            + "+LsiWVeXEFc/FXxnESRmbaZFNJrAdJl23eKXRC6az0S5FwMVBvUhRpLwIGDbVT/0\n"
-            + "/QwaNUOq3bYwudPREFLg/1HtBuxNhRdV6mCrit+tsPan9o0/WtsHuq8n4/pqOKBc\n"
-            + "RRmOIQR9SEohE2TuVT3XVFpMXa4a4CBuNXjTABEBAAG0HlRlc3R1c2VyIEogPHRl\n"
-            + "c3RqQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pSAIbAwYLCQgHAwIGFQgCCQoL\n"
-            + "BBYCAwECHgECF4AACgkQoTk8RsLmoZi0BggAlnbCwmwaLwcpU9YcOE9/8KF56dIs\n"
-            + "XhdxzqdP91UmhVT0df1OBhgTqFkKprBLCT+B9yBClsnyXMatkvuhQG6C7lw9toMO\n"
-            + "TITRPZoFJe3Ezi+HRRPqAPubIcSgeILuilvFhkoUOgoC1ubmVPgcGBLb8tdvI3bA\n"
-            + "svq+n2jaYUlgL5N6ZNRNakc07e8vH5SeKiD8ZntJlTU49fkxzlawtDaI3+GhyUiB\n"
-            + "0Ah8pl143DFNAq8CfvQCPKwX4WFPkEflh0LlgaEPJUZ/H6KxKXXF8SC9cD2VIii8\n"
-            + "Yrue8y9T+j5y699A0GCptb1IKrgxbfhgD//3g3l1eXsEwn2cwFNCt7pZFLkBDQRM\n"
-            + "fmlIAQgA3E2pM6oDJGgfxbqSfykuRtTbiAi7JEd1DNvEe6gJ7qkBLM4ipILBD0qR\n"
-            + "qCwL37E4/3nMsZjA7GIFLQj2DrFW3aEEKwR/zdh7R67lo9CunCY+FPWTuOkCG8Sh\n"
-            + "3RLpbAV6I61NG/wDznW30vmKNJDgPpkzYj8u0T4MtpywEgxTxCqWZKCufWDRfNAy\n"
-            + "IBLt+piG+bcYKfw9pS8PvXPQMNIi4U2pu3hb/BHC3Y1A8FVpEe4CFV7rWb/K2Ydx\n"
-            + "eBxwwxm9sBxF+vhlI+ZEeb9JxGH6jYlc6twD4e6p3KqusAKLKiLsS5uLQnpMGGZ8\n"
-            + "vcpTSfyHjG2QHc3qG9S/yDCZjhhe2QARAQABiQEfBBgBAgAJBQJMfmlIAhsMAAoJ\n"
-            + "EKE5PEbC5qGYClMIANTdZ+/g/FPl1Lm0tO1CSnHVHekeGNA9n3L6SGiSZQJjEDo0\n"
-            + "gsye5xgxh5JGKf7CqbEFfeLC9Jx5W5EN4dVFudncIlC/SutfRzdt5W8CLXMl0c41\n"
-            + "5FmtpWNStk3MglkcjE5PrRRrSiRc45S0e2kIPb8eiVKg98/rCToC9+Qn3pMi/fcM\n"
-            + "LVpYE+dhvB5EhOSwBWWgvWXzeLhv5CnBKxH0ItHhNwvt8qPOHgQAJKPc6dV888xn\n"
-            + "Sew62LFefHPnGTHLP8RRgVIvZBG5IoovxSz89QGHQZiC4xv00I7zNwmtr6eEcl+y\n"
-            + "BkUK9QWITEBHUDqR+cbVa2dRr3fUHwRP7G2G+ow=\n"
-            + "=ucAX\n"
-            + "-----END PGP PUBLIC KEY BLOCK-----\n",
-        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "lQOYBEx+aUgBCADczNio9UWnUggUZkdqJye57497oD9vNo9rmtR+i1TkMpVeWaMH\n"
-            + "UWrm1twzIPV9D4lAWLJoG2cYF6nXG1JxsKc9mOIZ6O1WfsopMUU0p+EfU8H/cvdM\n"
-            + "/iccYS6OnNL4/xR1R7hlA4+b/jaOZfzdS3i5jwOf+TtCk7c5qOuFhraiVQ9H1G86\n"
-            + "+LsiWVeXEFc/FXxnESRmbaZFNJrAdJl23eKXRC6az0S5FwMVBvUhRpLwIGDbVT/0\n"
-            + "/QwaNUOq3bYwudPREFLg/1HtBuxNhRdV6mCrit+tsPan9o0/WtsHuq8n4/pqOKBc\n"
-            + "RRmOIQR9SEohE2TuVT3XVFpMXa4a4CBuNXjTABEBAAEAB/9sW1MQR53xKP6yFCeD\n"
-            + "3sdOJlSB1PiMeXgU1JznpTT58CEBdnfdRYVy14qkxM30m8U9gMm88YW8exBscgoZ\n"
-            + "pRnNztNW58phokNPx9AwsRp3p0ETPbZDYI6NDNwuPKQEchn2HEZPvFmjsjPP2hkn\n"
-            + "+Lu8RIUA4uzEFX3bnBxJIP1L2AztqyTgHDfXS4/nqerO/cheXhN7j1TUyRO4hinp\n"
-            + "C3WXaxm2kpQXFP2ktq2eu7YPFoW6I6HzHVDN2Z7fD/NzfmR2h4gcIaSDEjIs893N\n"
-            + "b3hsYiOTYwVFX9TBWLr9rSWyrjR4sWelFuMZpjQ53qq+rBm/+8knoNtoWgZFhbR0\n"
-            + "WJyRBADlBuX8kveqLl31QShgw+6TwTHXI40GiCA6DHwZiTstOO6d2KDNq2nHdtuo\n"
-            + "HBvSKYP4a2na39JKb7YfuSMg16QvxQNd7BQWz+NzbGLQEGuX455OD3TE74ZfVElo\n"
-            + "2H/i51hSjOdWihJVNBGlcDYPgb7oLLTbPdKXxptRM1+wrk2//QQA9s3pw2O3lSbV\n"
-            + "U8JyL/FhdyhDvRDuiNBPnB4O/Ynnzz8YSFwSdSE/u8FpguFWdh+UdSrdwE+Ux8kj\n"
-            + "W/miXaqTxUeKnpzOkiO5O2fLvAeriO3rU9KfBER03+NJo4weSorLXzeU4SWkw63N\n"
-            + "OiY3fc67Nj+l8qi1tmoEJyHUomuy7Q8EAOfBvMzGsQQJ12k+4gOSXN9DTWUa85P6\n"
-            + "IphFHC2cpTDy30IRR55sI6Mf3GpC+KzxEyw7WXjlTensEJAHMpyVVRhv6uF0eMaY\n"
-            + "+QGS+vyCgtUfGIwM5Teu6NjeqyShJDTC8qnM+75JgCNu6gZ2F2iTeY+tM3zE1auq\n"
-            + "po1pUACVm7qwR6u0HlRlc3R1c2VyIEogPHRlc3RqQGV4YW1wbGUuY29tPokBOAQT\n"
-            + "AQIAIgUCTH5pSAIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQoTk8RsLm\n"
-            + "oZi0BggAlnbCwmwaLwcpU9YcOE9/8KF56dIsXhdxzqdP91UmhVT0df1OBhgTqFkK\n"
-            + "prBLCT+B9yBClsnyXMatkvuhQG6C7lw9toMOTITRPZoFJe3Ezi+HRRPqAPubIcSg\n"
-            + "eILuilvFhkoUOgoC1ubmVPgcGBLb8tdvI3bAsvq+n2jaYUlgL5N6ZNRNakc07e8v\n"
-            + "H5SeKiD8ZntJlTU49fkxzlawtDaI3+GhyUiB0Ah8pl143DFNAq8CfvQCPKwX4WFP\n"
-            + "kEflh0LlgaEPJUZ/H6KxKXXF8SC9cD2VIii8Yrue8y9T+j5y699A0GCptb1IKrgx\n"
-            + "bfhgD//3g3l1eXsEwn2cwFNCt7pZFJ0DmARMfmlIAQgA3E2pM6oDJGgfxbqSfyku\n"
-            + "RtTbiAi7JEd1DNvEe6gJ7qkBLM4ipILBD0qRqCwL37E4/3nMsZjA7GIFLQj2DrFW\n"
-            + "3aEEKwR/zdh7R67lo9CunCY+FPWTuOkCG8Sh3RLpbAV6I61NG/wDznW30vmKNJDg\n"
-            + "PpkzYj8u0T4MtpywEgxTxCqWZKCufWDRfNAyIBLt+piG+bcYKfw9pS8PvXPQMNIi\n"
-            + "4U2pu3hb/BHC3Y1A8FVpEe4CFV7rWb/K2YdxeBxwwxm9sBxF+vhlI+ZEeb9JxGH6\n"
-            + "jYlc6twD4e6p3KqusAKLKiLsS5uLQnpMGGZ8vcpTSfyHjG2QHc3qG9S/yDCZjhhe\n"
-            + "2QARAQABAAf7BUTPxk/u/vi935DpBXoXRKHZnLM3bFuIexCGQ74rQqR2qazUMH8o\n"
-            + "SFEsaBJpm2WyR47J5WqSHNi5SxPT2AUdNFeh/39hxY61Q6SuBFED+WMRbHrKbURR\n"
-            + "WjPiFuwus02eAkAYFWfBFY0n9/BcAhicQa90MTRj+RZb/EHa+GDdbgDatpwEK22z\n"
-            + "pPb3t/D2TC7ModizelngBN7bdp4Vqna/vMLhsiE+FqL+Ob0KiLkDxtcjZljc9xLK\n"
-            + "B7ZuGH/AZfhF08OAxUcsJdu5cF3viBT+HeSI4OUvdfxPFX98U/SFfuW4mPdHPEI9\n"
-            + "438pdjDUIpJFtcnROtZdS2o6C9ohHa5BUwQA52P8AKKRfg7LpaFMvtKkNORnscac\n"
-            + "1qvXLqAXaMeSsvyU5o1GNvSgbhFzDcXbAFJcXdOo2XgT7JzW/6v1uW9AuQPAkYhr\n"
-            + "ep0uE3mewlzWHZR41MQRaMGN4l80RN6ju4c/Ei+OMHYp2DUfZFDBXbxwWpN8tNoR\n"
-            + "S1X+rOL5RsQgkrcEAPO7zthR+GQnIgJC3c9Las9JkPywCxddjoWZoyt6yITVjIso\n"
-            + "IGD0SJppAkOS3Vdb+raydLuN7HmbpPFnvzyc+RdSt+YCGUObrHb/z9MfahzDNG3S\n"
-            + "VwUQEIl+L6glhwscQOCz80MCcYMFMk4TiankvChRFF5Wil//8QnaonH4bcrvA/46\n"
-            + "VB+ZaEdR+Z8IkYIf7oHLJNEwaH+kRTBQ2x5F9Gnwr9SL6AXAkNkvYD4in/+Bw35r\n"
-            + "o9zGirQQvNrvH3JlZ5PWp1/9rRl2Tefaaf8P2ij/Ky2poBLAhPwK56JXHLt5v+BZ\n"
-            + "mQwhY+teJnbfCwiiS0OeWtpVY/tDVU7wYOd2RIhVfkUziQEfBBgBAgAJBQJMfmlI\n"
-            + "AhsMAAoJEKE5PEbC5qGYClMIANTdZ+/g/FPl1Lm0tO1CSnHVHekeGNA9n3L6SGiS\n"
-            + "ZQJjEDo0gsye5xgxh5JGKf7CqbEFfeLC9Jx5W5EN4dVFudncIlC/SutfRzdt5W8C\n"
-            + "LXMl0c415FmtpWNStk3MglkcjE5PrRRrSiRc45S0e2kIPb8eiVKg98/rCToC9+Qn\n"
-            + "3pMi/fcMLVpYE+dhvB5EhOSwBWWgvWXzeLhv5CnBKxH0ItHhNwvt8qPOHgQAJKPc\n"
-            + "6dV888xnSew62LFefHPnGTHLP8RRgVIvZBG5IoovxSz89QGHQZiC4xv00I7zNwmt\n"
-            + "r6eEcl+yBkUK9QWITEBHUDqR+cbVa2dRr3fUHwRP7G2G+ow=\n"
-            + "=NiQI\n"
-            + "-----END PGP PRIVATE KEY BLOCK-----\n");
-  }
-
-  private TestTrustKeys() {}
-}
diff --git a/gerrit-gwtdebug/BUILD b/gerrit-gwtdebug/BUILD
index 115c6b9..b4cd663 100644
--- a/gerrit-gwtdebug/BUILD
+++ b/gerrit-gwtdebug/BUILD
@@ -3,10 +3,9 @@
     srcs = glob(["src/main/java/**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//gerrit-pgm:daemon",
-        "//gerrit-pgm:pgm",
-        "//gerrit-pgm:util",
-        "//gerrit-util-cli:cli",
+        "//java/com/google/gerrit/pgm",
+        "//java/com/google/gerrit/pgm/util",
+        "//java/com/google/gerrit/util/cli",
         "//lib/gwt:dev",
         "//lib/jetty:server",
         "//lib/jetty:servlet",
diff --git a/gerrit-gwtexpui/BUILD b/gerrit-gwtexpui/BUILD
deleted file mode 100644
index a9a2e48..0000000
--- a/gerrit-gwtexpui/BUILD
+++ /dev/null
@@ -1,118 +0,0 @@
-load("//tools/bzl:gwt.bzl", "gwt_module")
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-SRC = "src/main/java/com/google/gwtexpui/"
-
-gwt_module(
-    name = "Clippy",
-    srcs = glob([SRC + "clippy/client/*.java"]),
-    data = [
-        "//lib:LICENSE-clippy",
-        "//lib:LICENSE-silk_icons",
-    ],
-    gwt_xml = SRC + "clippy/Clippy.gwt.xml",
-    resources = [
-        SRC + "clippy/client/clippy.css",
-        SRC + "clippy/client/clippy.swf",
-        SRC + "clippy/client/page_white_copy.png",
-        SRC + "clippy/client/CopyableLabelText.properties",
-    ],
-    visibility = ["//visibility:public"],
-    deps = [
-        ":SafeHtml",
-        ":UserAgent",
-        "//lib/gwt:user-neverlink",
-    ],
-)
-
-java_library(
-    name = "CSS",
-    srcs = glob([SRC + "css/rebind/*.java"]),
-    resources = [SRC + "css/CSS.gwt.xml"],
-    visibility = ["//visibility:public"],
-    deps = ["//lib/gwt:dev"],
-)
-
-gwt_module(
-    name = "GlobalKey",
-    srcs = glob([SRC + "globalkey/client/*.java"]),
-    gwt_xml = SRC + "globalkey/GlobalKey.gwt.xml",
-    resources = [
-        SRC + "globalkey/client/KeyConstants.properties",
-        SRC + "globalkey/client/key.css",
-    ],
-    visibility = ["//visibility:public"],
-    deps = [
-        ":SafeHtml",
-        ":UserAgent",
-        "//lib/gwt:user",
-    ],
-)
-
-java_library(
-    name = "linker_server",
-    srcs = glob([SRC + "linker/server/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = ["//lib:servlet-api-3_1"],
-)
-
-gwt_module(
-    name = "Progress",
-    srcs = glob([SRC + "progress/client/*.java"]),
-    gwt_xml = SRC + "progress/Progress.gwt.xml",
-    resources = [SRC + "progress/client/progress.css"],
-    visibility = ["//visibility:public"],
-    deps = ["//lib/gwt:user"],
-)
-
-gwt_module(
-    name = "SafeHtml",
-    srcs = glob([SRC + "safehtml/client/*.java"]),
-    gwt_xml = SRC + "safehtml/SafeHtml.gwt.xml",
-    resources = [SRC + "safehtml/client/safehtml.css"],
-    visibility = ["//visibility:public"],
-    deps = ["//lib/gwt:user"],
-)
-
-junit_tests(
-    name = "SafeHtml_tests",
-    srcs = glob([
-        "src/test/java/com/google/gwtexpui/safehtml/client/**/*.java",
-    ]),
-    deps = [
-        ":SafeHtml",
-        "//lib:truth",
-        "//lib/gwt:dev",
-        "//lib/gwt:user",
-    ],
-)
-
-gwt_module(
-    name = "UserAgent",
-    srcs = glob([SRC + "user/client/*.java"]),
-    gwt_xml = SRC + "user/User.gwt.xml",
-    resources = [SRC + "user/client/tooltip.css"],
-    visibility = ["//visibility:public"],
-    deps = ["//lib/gwt:user"],
-)
-
-java_library(
-    name = "server",
-    srcs = glob([SRC + "server/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = ["//lib:servlet-api-3_1"],
-)
-
-java_library(
-    name = "client-src-lib",
-    srcs = [],
-    resources = glob(
-        [SRC + n for n in [
-            "clippy/**/*",
-            "globalkey/**/*",
-            "safehtml/**/*",
-            "user/**/*",
-        ]],
-    ),
-    visibility = ["//visibility:public"],
-)
diff --git a/gerrit-gwtexpui/COPYING b/gerrit-gwtexpui/COPYING
deleted file mode 100644
index d645695..0000000
--- a/gerrit-gwtexpui/COPYING
+++ /dev/null
@@ -1,202 +0,0 @@
-
-                                 Apache License
-                           Version 2.0, January 2004
-                        http://www.apache.org/licenses/
-
-   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
-   1. Definitions.
-
-      "License" shall mean the terms and conditions for use, reproduction,
-      and distribution as defined by Sections 1 through 9 of this document.
-
-      "Licensor" shall mean the copyright owner or entity authorized by
-      the copyright owner that is granting the License.
-
-      "Legal Entity" shall mean the union of the acting entity and all
-      other entities that control, are controlled by, or are under common
-      control with that entity. For the purposes of this definition,
-      "control" means (i) the power, direct or indirect, to cause the
-      direction or management of such entity, whether by contract or
-      otherwise, or (ii) ownership of fifty percent (50%) or more of the
-      outstanding shares, or (iii) beneficial ownership of such entity.
-
-      "You" (or "Your") shall mean an individual or Legal Entity
-      exercising permissions granted by this License.
-
-      "Source" form shall mean the preferred form for making modifications,
-      including but not limited to software source code, documentation
-      source, and configuration files.
-
-      "Object" form shall mean any form resulting from mechanical
-      transformation or translation of a Source form, including but
-      not limited to compiled object code, generated documentation,
-      and conversions to other media types.
-
-      "Work" shall mean the work of authorship, whether in Source or
-      Object form, made available under the License, as indicated by a
-      copyright notice that is included in or attached to the work
-      (an example is provided in the Appendix below).
-
-      "Derivative Works" shall mean any work, whether in Source or Object
-      form, that is based on (or derived from) the Work and for which the
-      editorial revisions, annotations, elaborations, or other modifications
-      represent, as a whole, an original work of authorship. For the purposes
-      of this License, Derivative Works shall not include works that remain
-      separable from, or merely link (or bind by name) to the interfaces of,
-      the Work and Derivative Works thereof.
-
-      "Contribution" shall mean any work of authorship, including
-      the original version of the Work and any modifications or additions
-      to that Work or Derivative Works thereof, that is intentionally
-      submitted to Licensor for inclusion in the Work by the copyright owner
-      or by an individual or Legal Entity authorized to submit on behalf of
-      the copyright owner. For the purposes of this definition, "submitted"
-      means any form of electronic, verbal, or written communication sent
-      to the Licensor or its representatives, including but not limited to
-      communication on electronic mailing lists, source code control systems,
-      and issue tracking systems that are managed by, or on behalf of, the
-      Licensor for the purpose of discussing and improving the Work, but
-      excluding communication that is conspicuously marked or otherwise
-      designated in writing by the copyright owner as "Not a Contribution."
-
-      "Contributor" shall mean Licensor and any individual or Legal Entity
-      on behalf of whom a Contribution has been received by Licensor and
-      subsequently incorporated within the Work.
-
-   2. Grant of Copyright License. Subject to the terms and conditions of
-      this License, each Contributor hereby grants to You a perpetual,
-      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-      copyright license to reproduce, prepare Derivative Works of,
-      publicly display, publicly perform, sublicense, and distribute the
-      Work and such Derivative Works in Source or Object form.
-
-   3. Grant of Patent License. Subject to the terms and conditions of
-      this License, each Contributor hereby grants to You a perpetual,
-      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-      (except as stated in this section) patent license to make, have made,
-      use, offer to sell, sell, import, and otherwise transfer the Work,
-      where such license applies only to those patent claims licensable
-      by such Contributor that are necessarily infringed by their
-      Contribution(s) alone or by combination of their Contribution(s)
-      with the Work to which such Contribution(s) was submitted. If You
-      institute patent litigation against any entity (including a
-      cross-claim or counterclaim in a lawsuit) alleging that the Work
-      or a Contribution incorporated within the Work constitutes direct
-      or contributory patent infringement, then any patent licenses
-      granted to You under this License for that Work shall terminate
-      as of the date such litigation is filed.
-
-   4. Redistribution. You may reproduce and distribute copies of the
-      Work or Derivative Works thereof in any medium, with or without
-      modifications, and in Source or Object form, provided that You
-      meet the following conditions:
-
-      (a) You must give any other recipients of the Work or
-          Derivative Works a copy of this License; and
-
-      (b) You must cause any modified files to carry prominent notices
-          stating that You changed the files; and
-
-      (c) You must retain, in the Source form of any Derivative Works
-          that You distribute, all copyright, patent, trademark, and
-          attribution notices from the Source form of the Work,
-          excluding those notices that do not pertain to any part of
-          the Derivative Works; and
-
-      (d) If the Work includes a "NOTICE" text file as part of its
-          distribution, then any Derivative Works that You distribute must
-          include a readable copy of the attribution notices contained
-          within such NOTICE file, excluding those notices that do not
-          pertain to any part of the Derivative Works, in at least one
-          of the following places: within a NOTICE text file distributed
-          as part of the Derivative Works; within the Source form or
-          documentation, if provided along with the Derivative Works; or,
-          within a display generated by the Derivative Works, if and
-          wherever such third-party notices normally appear. The contents
-          of the NOTICE file are for informational purposes only and
-          do not modify the License. You may add Your own attribution
-          notices within Derivative Works that You distribute, alongside
-          or as an addendum to the NOTICE text from the Work, provided
-          that such additional attribution notices cannot be construed
-          as modifying the License.
-
-      You may add Your own copyright statement to Your modifications and
-      may provide additional or different license terms and conditions
-      for use, reproduction, or distribution of Your modifications, or
-      for any such Derivative Works as a whole, provided Your use,
-      reproduction, and distribution of the Work otherwise complies with
-      the conditions stated in this License.
-
-   5. Submission of Contributions. Unless You explicitly state otherwise,
-      any Contribution intentionally submitted for inclusion in the Work
-      by You to the Licensor shall be under the terms and conditions of
-      this License, without any additional terms or conditions.
-      Notwithstanding the above, nothing herein shall supersede or modify
-      the terms of any separate license agreement you may have executed
-      with Licensor regarding such Contributions.
-
-   6. Trademarks. This License does not grant permission to use the trade
-      names, trademarks, service marks, or product names of the Licensor,
-      except as required for reasonable and customary use in describing the
-      origin of the Work and reproducing the content of the NOTICE file.
-
-   7. Disclaimer of Warranty. Unless required by applicable law or
-      agreed to in writing, Licensor provides the Work (and each
-      Contributor provides its Contributions) on an "AS IS" BASIS,
-      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-      implied, including, without limitation, any warranties or conditions
-      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
-      PARTICULAR PURPOSE. You are solely responsible for determining the
-      appropriateness of using or redistributing the Work and assume any
-      risks associated with Your exercise of permissions under this License.
-
-   8. Limitation of Liability. In no event and under no legal theory,
-      whether in tort (including negligence), contract, or otherwise,
-      unless required by applicable law (such as deliberate and grossly
-      negligent acts) or agreed to in writing, shall any Contributor be
-      liable to You for damages, including any direct, indirect, special,
-      incidental, or consequential damages of any character arising as a
-      result of this License or out of the use or inability to use the
-      Work (including but not limited to damages for loss of goodwill,
-      work stoppage, computer failure or malfunction, or any and all
-      other commercial damages or losses), even if such Contributor
-      has been advised of the possibility of such damages.
-
-   9. Accepting Warranty or Additional Liability. While redistributing
-      the Work or Derivative Works thereof, You may choose to offer,
-      and charge a fee for, acceptance of support, warranty, indemnity,
-      or other liability obligations and/or rights consistent with this
-      License. However, in accepting such obligations, You may act only
-      on Your own behalf and on Your sole responsibility, not on behalf
-      of any other Contributor, and only if You agree to indemnify,
-      defend, and hold each Contributor harmless for any liability
-      incurred by, or claims asserted against, such Contributor by reason
-      of your accepting any such warranty or additional liability.
-
-   END OF TERMS AND CONDITIONS
-
-   APPENDIX: How to apply the Apache License to your work.
-
-      To apply the Apache License to your work, attach the following
-      boilerplate notice, with the fields enclosed by brackets "[]"
-      replaced with your own identifying information. (Don't include
-      the brackets!)  The text should be enclosed in the appropriate
-      comment syntax for the file format. We also recommend that a
-      file or class name and description of purpose be included on the
-      same "printed page" as the copyright notice for easier
-      identification within third-party archives.
-
-   Copyright [yyyy] [name of copyright owner]
-
-   Licensed under the Apache License, Version 2.0 (the "License");
-   you may not use this file except in compliance with the License.
-   You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-   Unless required by applicable law or agreed to in writing, software
-   distributed under the License is distributed on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   See the License for the specific language governing permissions and
-   limitations under the License.
diff --git a/gerrit-gwtui-common/BUILD b/gerrit-gwtui-common/BUILD
index 46262d6..46019ab 100644
--- a/gerrit-gwtui-common/BUILD
+++ b/gerrit-gwtui-common/BUILD
@@ -3,12 +3,12 @@
 load("//tools/bzl:gwt.bzl", "gwt_module")
 
 EXPORTED_DEPS = [
-    "//gerrit-common:client",
-    "//gerrit-gwtexpui:Clippy",
-    "//gerrit-gwtexpui:GlobalKey",
-    "//gerrit-gwtexpui:Progress",
-    "//gerrit-gwtexpui:SafeHtml",
-    "//gerrit-gwtexpui:UserAgent",
+    "//java/com/google/gerrit/common:client",
+    "//java/com/google/gwtexpui/clippy",
+    "//java/com/google/gwtexpui/globalkey",
+    "//java/com/google/gwtexpui/progress",
+    "//java/com/google/gwtexpui/safehtml",
+    "//java/com/google/gwtexpui/user:agent",
 ]
 
 DEPS = ["//lib/gwt:user-neverlink"]
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/GerritGwtUICommon.gwt.xml b/gerrit-gwtui-common/src/main/java/com/google/gerrit/GerritGwtUICommon.gwt.xml
index c01dea1..dc478fc 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/GerritGwtUICommon.gwt.xml
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/GerritGwtUICommon.gwt.xml
@@ -15,7 +15,7 @@
 -->
 <module>
   <inherits name='org.eclipse.jgit.JGit'/>
-  <inherits name='com.google.gerrit.Common'/>
+  <inherits name='com.google.gerrit.common.Common'/>
   <inherits name='com.google.gerrit.extensions.Extensions'/>
   <inherits name='com.google.gerrit.prettify.PrettyFormatter'/>
   <inherits name='com.google.gwtexpui.clippy.Clippy'/>
diff --git a/gerrit-gwtui/BUILD b/gerrit-gwtui/BUILD
index ff1b862..a6c9763 100644
--- a/gerrit-gwtui/BUILD
+++ b/gerrit-gwtui/BUILD
@@ -31,8 +31,8 @@
     visibility = ["//visibility:public"],
     deps = [
         ":ui_module",
-        "//gerrit-common:client",
-        "//gerrit-extension-api:client",
+        "//java/com/google/gerrit/common:client",
+        "//java/com/google/gerrit/extensions:client",
         "//lib:junit",
         "//lib:truth",
         "//lib/gwt:dev",
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
index 7307264..74fcdc2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
@@ -83,17 +83,6 @@
     return createAccountFormatter().name(info);
   }
 
-  public static AccountInfo asInfo(com.google.gerrit.common.data.AccountInfo acct) {
-    if (acct == null) {
-      return AccountInfo.create(0, null, null, null);
-    }
-    return AccountInfo.create(
-        acct.getId() != null ? acct.getId().get() : 0,
-        acct.getFullName(),
-        acct.getPreferredEmail(),
-        acct.getUsername());
-  }
-
   private static AccountFormatter createAccountFormatter() {
     return new AccountFormatter(Gerrit.info().user().anonymousCowardName());
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
index e02c4e0..6138e8b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
@@ -561,6 +561,12 @@
     if (Location.getPath().endsWith("/") && tokens[0].startsWith("/")) {
       tokens[0] = tokens[0].substring(1);
     }
+    if (tokens[0].startsWith("projects/") && tokens[0].contains(",dashboards/")) {
+      // Rewrite project dashboard URIs to a new format, because otherwise
+      // "/projects/..." would be served as an API request.
+      tokens[0] = "p/" + tokens[0].substring("projects/".length());
+      tokens[0] = tokens[0].replace(",dashboards/", "/+/dashboard/");
+    }
     builder.setPath(Location.getPath() + tokens[0]);
     if (tokens.length == 2) {
       builder.setHash(tokens[1]);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
index b556519..c0947a8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
@@ -133,6 +133,8 @@
 
   String headingProjectSubmitType();
 
+  String projectSubmitType_INHERIT();
+
   String projectSubmitType_FAST_FORWARD_ONLY();
 
   String projectSubmitType_MERGE_ALWAYS();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
index 62f3778..8d6878f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
@@ -57,6 +57,7 @@
 headingAuditLog = Audit Log
 
 headingProjectSubmitType = Submit Type
+projectSubmitType_INHERIT = Inherit
 projectSubmitType_FAST_FORWARD_ONLY = Fast Forward Only
 projectSubmitType_MERGE_IF_NECESSARY = Merge if Necessary
 projectSubmitType_REBASE_ALWAYS = Rebase Always
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
index 4e94250..64e147d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.client.projects.ConfigInfo.ConfigParameterInfo;
 import com.google.gerrit.client.projects.ConfigInfo.ConfigParameterValue;
 import com.google.gerrit.client.projects.ConfigInfo.InheritedBooleanInfo;
+import com.google.gerrit.client.projects.ConfigInfo.SubmitTypeInfo;
 import com.google.gerrit.client.projects.ProjectApi;
 import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.GerritCallback;
@@ -335,13 +336,15 @@
     grid.addHtml(AdminConstants.I.useSignedOffBy(), signedOffBy);
   }
 
-  private void setSubmitType(SubmitType newSubmitType) {
+  private void setSubmitType(SubmitTypeInfo newSubmitType) {
     int index = -1;
-    if (submitType != null) {
+    if (newSubmitType != null) {
       for (int i = 0; i < submitType.getItemCount(); i++) {
-        if (newSubmitType.name().equals(submitType.getValue(i))) {
+        if (submitType.getValue(i).equals(SubmitType.INHERIT.name())) {
+          submitType.setItemText(i, getInheritString(newSubmitType));
+        }
+        if (newSubmitType.configuredValue().name().equals(submitType.getValue(i))) {
           index = i;
-          break;
         }
       }
       submitType.setSelectedIndex(index);
@@ -349,6 +352,13 @@
     }
   }
 
+  private static String getInheritString(SubmitTypeInfo submitType) {
+    return Util.toLongString(SubmitType.INHERIT)
+        + " ("
+        + Util.toLongString(submitType.inheritedValue())
+        + ")";
+  }
+
   private void setState(ProjectState newState) {
     if (state != null) {
       for (int i = 0; i < state.getItemCount(); i++) {
@@ -419,7 +429,7 @@
     setBool(privateByDefault, result.privateByDefault());
     setBool(enableReviewerByEmail, result.enableReviewerByEmail());
     setBool(matchAuthorToCommitterDate, result.matchAuthorToCommitterDate());
-    setSubmitType(result.submitType());
+    setSubmitType(result.defaultSubmitType());
     setState(result.state());
     maxObjectSizeLimit.setText(result.maxObjectSizeLimit().configuredValue());
     if (result.maxObjectSizeLimit().inheritedValue() != null) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java
index 2e4926d..bbc8a1d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java
@@ -35,6 +35,8 @@
       return "";
     }
     switch (type) {
+      case INHERIT:
+        return AdminConstants.I.projectSubmitType_INHERIT();
       case FAST_FORWARD_ONLY:
         return AdminConstants.I.projectSubmitType_FAST_FORWARD_ONLY();
       case MERGE_IF_NECESSARY:
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
index 039948d..b57545b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.client.changes.ChangeApi;
 import com.google.gerrit.client.changes.ChangeList;
 import com.google.gerrit.client.changes.CommentInfo;
+import com.google.gerrit.client.changes.QueryScreen;
 import com.google.gerrit.client.changes.RevisionInfoCache;
 import com.google.gerrit.client.changes.StarredChanges;
 import com.google.gerrit.client.changes.Util;
@@ -282,11 +283,46 @@
   @Override
   protected void onLoad() {
     super.onLoad();
+    loadChangeScreen();
+  }
+
+  private void loadChangeScreen() {
+    if (project == null) {
+      // Load the project if it is not already present. This is the case when the user used a URL
+      // that doesn't include the project. Setting it here will rewrite the URL token to include the
+      // project (visible to the user) and all future API calls made from the change screen will use
+      // project/+/changeId to identify the change.
+      String query = "change:" + changeId.get();
+      ChangeList.query(
+          query,
+          Collections.emptySet(),
+          new AsyncCallback<ChangeList>() {
+            @Override
+            public void onSuccess(ChangeList result) {
+              if (result.length() == 0) {
+                Gerrit.display(getToken(), new NotFoundScreen());
+              } else if (result.length() > 1) {
+                Gerrit.display(PageLinks.toChangeQuery(query), QueryScreen.forQuery(query));
+              } else {
+                // Initialize current screen with newly obtained project
+                project = result.get(0).projectNameKey();
+                loadChangeScreen();
+              }
+            }
+
+            @Override
+            public void onFailure(Throwable caught) {
+              GerritCallback.showFailure(caught);
+            }
+          });
+
+      return;
+    }
     CallbackGroup group = new CallbackGroup();
     if (Gerrit.isSignedIn()) {
       ChangeList.query(
           "change:" + changeId.get() + " has:draft",
-          Collections.<ListChangesOption>emptySet(),
+          Collections.emptySet(),
           group.add(
               new AsyncCallback<ChangeList>() {
                 @Override
@@ -318,15 +354,6 @@
               @Override
               public void onSuccess(ChangeInfo info) {
                 info.init();
-                if (project == null) {
-                  // Update Project when the first API call succeeded if it wasn't already present.
-                  // This is the case when the user used a URL that doesn't include the project.
-                  // Setting it here will rewrite the URL token to include the project (visible to
-                  // the user) and all future API calls made from the change screen will use
-                  // project/+/changeId to identify the change.
-                  project = info.projectNameKey();
-                }
-
                 initCurrentRevision(info);
                 final RevisionInfo rev = info.revision(revision);
                 CallbackGroup group = new CallbackGroup();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java
index 113651b..f851d5e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java
@@ -60,7 +60,6 @@
 
   private native void init() /*-{
     this.labels = {};
-    this.strict_labels = true;
   }-*/;
 
   public final native void prePost() /*-{
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
index b8effdf..f670ac7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
@@ -70,6 +70,8 @@
     return SubmitType.valueOf(submitTypeRaw());
   }
 
+  public final native SubmitTypeInfo defaultSubmitType() /*-{ return this.default_submit_type; }-*/;
+
   public final native NativeMap<NativeMap<ConfigParameterInfo>> pluginConfig()
       /*-{ return this.plugin_config || {}; }-*/ ;
 
@@ -232,4 +234,26 @@
 
     protected ConfigParameterValue() {}
   }
+
+  public static class SubmitTypeInfo extends JavaScriptObject {
+    public final SubmitType value() {
+      return SubmitType.valueOf(valueRaw());
+    }
+
+    public final SubmitType configuredValue() {
+      return SubmitType.valueOf(configuredValueRaw());
+    }
+
+    public final SubmitType inheritedValue() {
+      return SubmitType.valueOf(inheritedValueRaw());
+    }
+
+    private final native String valueRaw() /*-{ return this.value; }-*/;
+
+    private final native String configuredValueRaw() /*-{ return this.configured_value; }-*/;
+
+    private final native String inheritedValueRaw() /*-{ return this.inherited_value; }-*/;
+
+    protected SubmitTypeInfo() {}
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
index 3766dd9..66afdb2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
@@ -176,7 +176,9 @@
     in.setRejectImplicitMerges(rejectImplicitMerges);
     in.setPrivateByDefault(privateByDefault);
     in.setMaxObjectSizeLimit(maxObjectSizeLimit);
-    in.setSubmitType(submitType);
+    if (submitType != null) {
+      in.setSubmitType(submitType);
+    }
     in.setState(state);
     in.setPluginConfigValues(pluginConfigValues);
     in.setEnableReviewerByEmail(enableReviewerByEmail);
diff --git a/gerrit-httpd/BUILD b/gerrit-httpd/BUILD
deleted file mode 100644
index dbca10c..0000000
--- a/gerrit-httpd/BUILD
+++ /dev/null
@@ -1,82 +0,0 @@
-package(
-    default_visibility = ["//visibility:public"],
-)
-
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-SRCS = glob(
-    ["src/main/java/**/*.java"],
-)
-
-RESOURCES = glob(["src/main/resources/**/*"])
-
-java_library(
-    name = "httpd",
-    srcs = SRCS,
-    resources = RESOURCES,
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-common:annotations",
-        "//gerrit-common:server",
-        "//gerrit-extension-api:api",
-        "//gerrit-gwtexpui:linker_server",
-        "//gerrit-gwtexpui:server",
-        "//gerrit-index:query_exception",
-        "//gerrit-launcher:launcher",
-        "//gerrit-patch-jgit:server",
-        "//gerrit-prettify:server",
-        "//gerrit-reviewdb:server",
-        "//gerrit-server:metrics",
-        "//gerrit-server:receive",
-        "//gerrit-server:server",
-        "//gerrit-util-cli:cli",
-        "//gerrit-util-http:http",
-        "//lib:args4j",
-        "//lib:gson",
-        "//lib:guava",
-        "//lib:gwtjsonrpc",
-        "//lib:gwtorm",
-        "//lib:jsch",
-        "//lib:mime-util",
-        "//lib:servlet-api-3_1",
-        "//lib:soy",
-        "//lib/auto:auto-value",
-        "//lib/commons:codec",
-        "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-        "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/log:api",
-        "//lib/lucene:lucene-core-and-backward-codecs",
-    ],
-)
-
-junit_tests(
-    name = "httpd_tests",
-    srcs = glob(["src/test/java/**/*.java"]),
-    deps = [
-        ":httpd",
-        "//gerrit-common:annotations",
-        "//gerrit-common:server",
-        "//gerrit-extension-api:api",
-        "//gerrit-reviewdb:server",
-        "//gerrit-server:server",
-        "//gerrit-util-http:http",
-        "//gerrit-util-http:testutil",
-        "//lib:gson",
-        "//lib:guava",
-        "//lib:gwtorm",
-        "//lib:jimfs",
-        "//lib:junit",
-        "//lib:servlet-api-3_1-without-neverlink",
-        "//lib:soy",
-        "//lib:truth",
-        "//lib/easymock",
-        "//lib/guice",
-        "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
-        "//lib/joda:joda-time",
-    ],
-)
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
deleted file mode 100644
index 329beab..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ /dev/null
@@ -1,416 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd;
-
-import com.google.common.cache.Cache;
-import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.Capable;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.AccessPath;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.TransferConfig;
-import com.google.gerrit.server.git.UploadPackInitializer;
-import com.google.gerrit.server.git.VisibleRefFilter;
-import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
-import com.google.gerrit.server.git.validators.UploadValidators;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.inject.AbstractModule;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.name.Named;
-import java.io.IOException;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-import javax.servlet.Filter;
-import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.http.server.GitServlet;
-import org.eclipse.jgit.http.server.GitSmartHttpTools;
-import org.eclipse.jgit.http.server.ServletUtils;
-import org.eclipse.jgit.http.server.resolver.AsIsFileService;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.PostUploadHook;
-import org.eclipse.jgit.transport.PostUploadHookChain;
-import org.eclipse.jgit.transport.PreUploadHook;
-import org.eclipse.jgit.transport.PreUploadHookChain;
-import org.eclipse.jgit.transport.ReceivePack;
-import org.eclipse.jgit.transport.ServiceMayNotContinueException;
-import org.eclipse.jgit.transport.UploadPack;
-import org.eclipse.jgit.transport.resolver.ReceivePackFactory;
-import org.eclipse.jgit.transport.resolver.RepositoryResolver;
-import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
-import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
-import org.eclipse.jgit.transport.resolver.UploadPackFactory;
-
-/** Serves Git repositories over HTTP. */
-@Singleton
-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_ARC = AsyncReceiveCommits.class.getName();
-  private static final String ID_CACHE = "adv_bases";
-
-  public static final String URL_REGEX;
-
-  static {
-    StringBuilder url = new StringBuilder();
-    url.append("^(?:/a)?(?:/p/|/)(.*/(?:info/refs");
-    for (String name : GitSmartHttpTools.VALID_SERVICES) {
-      url.append('|').append(name);
-    }
-    url.append("))$");
-    URL_REGEX = url.toString();
-  }
-
-  static class Module extends AbstractModule {
-
-    private final boolean enableReceive;
-
-    Module(boolean enableReceive) {
-      this.enableReceive = enableReceive;
-    }
-
-    @Override
-    protected void configure() {
-      bind(Resolver.class);
-      bind(UploadFactory.class);
-      bind(UploadFilter.class);
-      bind(new TypeLiteral<ReceivePackFactory<HttpServletRequest>>() {})
-          .to(enableReceive ? ReceiveFactory.class : DisabledReceiveFactory.class);
-      bind(ReceiveFilter.class);
-      install(
-          new CacheModule() {
-            @Override
-            protected void configure() {
-              cache(ID_CACHE, AdvertisedObjectsCacheKey.class, new TypeLiteral<Set<ObjectId>>() {})
-                  .maximumWeight(4096)
-                  .expireAfterWrite(10, TimeUnit.MINUTES);
-            }
-          });
-    }
-  }
-
-  @Inject
-  GitOverHttpServlet(
-      Resolver resolver,
-      UploadFactory upload,
-      UploadFilter uploadFilter,
-      ReceivePackFactory<HttpServletRequest> receive,
-      ReceiveFilter receiveFilter) {
-    setRepositoryResolver(resolver);
-    setAsIsFileService(AsIsFileService.DISABLED);
-
-    setUploadPackFactory(upload);
-    addUploadPackFilter(uploadFilter);
-
-    setReceivePackFactory(receive);
-    addReceivePackFilter(receiveFilter);
-  }
-
-  static class Resolver implements RepositoryResolver<HttpServletRequest> {
-    private final GitRepositoryManager manager;
-    private final PermissionBackend permissionBackend;
-    private final Provider<CurrentUser> userProvider;
-    private final ProjectControl.GenericFactory projectControlFactory;
-
-    @Inject
-    Resolver(
-        GitRepositoryManager manager,
-        PermissionBackend permissionBackend,
-        Provider<CurrentUser> userProvider,
-        ProjectControl.GenericFactory projectControlFactory) {
-      this.manager = manager;
-      this.permissionBackend = permissionBackend;
-      this.userProvider = userProvider;
-      this.projectControlFactory = projectControlFactory;
-    }
-
-    @Override
-    public Repository open(HttpServletRequest req, String projectName)
-        throws RepositoryNotFoundException, ServiceNotAuthorizedException,
-            ServiceNotEnabledException, ServiceMayNotContinueException {
-      while (projectName.endsWith("/")) {
-        projectName = projectName.substring(0, projectName.length() - 1);
-      }
-
-      if (projectName.endsWith(".git")) {
-        // Be nice and drop the trailing ".git" suffix, which we never keep
-        // in our database, but clients might mistakenly provide anyway.
-        //
-        projectName = projectName.substring(0, projectName.length() - 4);
-        while (projectName.endsWith("/")) {
-          projectName = projectName.substring(0, projectName.length() - 1);
-        }
-      }
-
-      CurrentUser user = userProvider.get();
-      user.setAccessPath(AccessPath.GIT);
-
-      try {
-        Project.NameKey nameKey = new Project.NameKey(projectName);
-        ProjectControl pc;
-        try {
-          pc = projectControlFactory.controlFor(nameKey, user);
-        } catch (NoSuchProjectException err) {
-          throw new RepositoryNotFoundException(projectName);
-        }
-        req.setAttribute(ATT_CONTROL, pc);
-
-        try {
-          permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
-        } catch (AuthException e) {
-          if (user instanceof AnonymousUser) {
-            throw new ServiceNotAuthorizedException();
-          }
-          throw new ServiceNotEnabledException(e.getMessage());
-        }
-
-        return manager.openRepository(nameKey);
-      } catch (IOException | PermissionBackendException err) {
-        throw new ServiceMayNotContinueException(projectName + " unavailable", err);
-      }
-    }
-  }
-
-  static class UploadFactory implements UploadPackFactory<HttpServletRequest> {
-    private final TransferConfig config;
-    private final DynamicSet<PreUploadHook> preUploadHooks;
-    private final DynamicSet<PostUploadHook> postUploadHooks;
-    private final DynamicSet<UploadPackInitializer> uploadPackInitializers;
-
-    @Inject
-    UploadFactory(
-        TransferConfig tc,
-        DynamicSet<PreUploadHook> preUploadHooks,
-        DynamicSet<PostUploadHook> postUploadHooks,
-        DynamicSet<UploadPackInitializer> uploadPackInitializers) {
-      this.config = tc;
-      this.preUploadHooks = preUploadHooks;
-      this.postUploadHooks = postUploadHooks;
-      this.uploadPackInitializers = uploadPackInitializers;
-    }
-
-    @Override
-    public UploadPack create(HttpServletRequest req, Repository repo) {
-      UploadPack up = new UploadPack(repo);
-      up.setPackConfig(config.getPackConfig());
-      up.setTimeout(config.getTimeout());
-      up.setPreUploadHook(PreUploadHookChain.newChain(Lists.newArrayList(preUploadHooks)));
-      up.setPostUploadHook(PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
-      ProjectControl pc = (ProjectControl) req.getAttribute(ATT_CONTROL);
-      for (UploadPackInitializer initializer : uploadPackInitializers) {
-        initializer.init(pc.getProject().getNameKey(), up);
-      }
-      return up;
-    }
-  }
-
-  static class UploadFilter implements Filter {
-    private final VisibleRefFilter.Factory refFilterFactory;
-    private final UploadValidators.Factory uploadValidatorsFactory;
-    private final PermissionBackend permissionBackend;
-
-    @Inject
-    UploadFilter(
-        VisibleRefFilter.Factory refFilterFactory,
-        UploadValidators.Factory uploadValidatorsFactory,
-        PermissionBackend permissionBackend) {
-      this.refFilterFactory = refFilterFactory;
-      this.uploadValidatorsFactory = uploadValidatorsFactory;
-      this.permissionBackend = permissionBackend;
-    }
-
-    @Override
-    public void doFilter(ServletRequest request, ServletResponse response, FilterChain next)
-        throws IOException, ServletException {
-      // The Resolver above already checked READ access for us.
-      Repository repo = ServletUtils.getRepository(request);
-      ProjectControl pc = (ProjectControl) request.getAttribute(ATT_CONTROL);
-      UploadPack up = (UploadPack) request.getAttribute(ServletUtils.ATTRIBUTE_HANDLER);
-
-      try {
-        permissionBackend
-            .user(pc.getUser())
-            .project(pc.getProject().getNameKey())
-            .check(ProjectPermission.RUN_UPLOAD_PACK);
-      } catch (AuthException e) {
-        GitSmartHttpTools.sendError(
-            (HttpServletRequest) request,
-            (HttpServletResponse) response,
-            HttpServletResponse.SC_FORBIDDEN,
-            "upload-pack not permitted on this server");
-        return;
-      } catch (PermissionBackendException e) {
-        throw new ServletException(e);
-      }
-      // We use getRemoteHost() here instead of getRemoteAddr() because REMOTE_ADDR
-      // may have been overridden by a proxy server -- we'll try to avoid this.
-      UploadValidators uploadValidators =
-          uploadValidatorsFactory.create(pc.getProject(), repo, request.getRemoteHost());
-      up.setPreUploadHook(
-          PreUploadHookChain.newChain(Lists.newArrayList(up.getPreUploadHook(), uploadValidators)));
-      up.setAdvertiseRefsHook(refFilterFactory.create(pc.getProjectState(), repo));
-
-      next.doFilter(request, response);
-    }
-
-    @Override
-    public void init(FilterConfig config) {}
-
-    @Override
-    public void destroy() {}
-  }
-
-  static class ReceiveFactory implements ReceivePackFactory<HttpServletRequest> {
-    private final AsyncReceiveCommits.Factory factory;
-
-    @Inject
-    ReceiveFactory(AsyncReceiveCommits.Factory factory) {
-      this.factory = factory;
-    }
-
-    @Override
-    public ReceivePack create(HttpServletRequest req, Repository db)
-        throws ServiceNotAuthorizedException {
-      final ProjectControl pc = (ProjectControl) req.getAttribute(ATT_CONTROL);
-
-      if (!(pc.getUser().isIdentifiedUser())) {
-        // Anonymous users are not permitted to push.
-        throw new ServiceNotAuthorizedException();
-      }
-
-      AsyncReceiveCommits arc = factory.create(pc, db, null, ImmutableSetMultimap.of());
-      ReceivePack rp = arc.getReceivePack();
-      req.setAttribute(ATT_ARC, arc);
-      return rp;
-    }
-  }
-
-  static class DisabledReceiveFactory implements ReceivePackFactory<HttpServletRequest> {
-    @Override
-    public ReceivePack create(HttpServletRequest req, Repository db)
-        throws ServiceNotEnabledException {
-      throw new ServiceNotEnabledException();
-    }
-  }
-
-  static class ReceiveFilter implements Filter {
-    private final Cache<AdvertisedObjectsCacheKey, Set<ObjectId>> cache;
-    private final PermissionBackend permissionBackend;
-
-    @Inject
-    ReceiveFilter(
-        @Named(ID_CACHE) Cache<AdvertisedObjectsCacheKey, Set<ObjectId>> cache,
-        PermissionBackend permissionBackend) {
-      this.cache = cache;
-      this.permissionBackend = permissionBackend;
-    }
-
-    @Override
-    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
-        throws IOException, ServletException {
-      boolean isGet = "GET".equalsIgnoreCase(((HttpServletRequest) request).getMethod());
-
-      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();
-
-      try {
-        permissionBackend
-            .user(pc.getUser())
-            .project(pc.getProject().getNameKey())
-            .check(ProjectPermission.RUN_RECEIVE_PACK);
-      } catch (AuthException e) {
-        GitSmartHttpTools.sendError(
-            (HttpServletRequest) request,
-            (HttpServletResponse) response,
-            HttpServletResponse.SC_FORBIDDEN,
-            "receive-pack not permitted on this server");
-        return;
-      } catch (PermissionBackendException e) {
-        throw new RuntimeException(e);
-      }
-
-      Capable s = arc.canUpload();
-      if (s != Capable.OK) {
-        GitSmartHttpTools.sendError(
-            (HttpServletRequest) request,
-            (HttpServletResponse) response,
-            HttpServletResponse.SC_FORBIDDEN,
-            "\n" + s.getMessage());
-        return;
-      }
-
-      if (!rp.isCheckReferencedObjectsAreReachable()) {
-        chain.doFilter(request, response);
-        return;
-      }
-
-      if (!(pc.getUser().isIdentifiedUser())) {
-        chain.doFilter(request, response);
-        return;
-      }
-
-      AdvertisedObjectsCacheKey cacheKey =
-          AdvertisedObjectsCacheKey.create(pc.getUser().getAccountId(), projectName);
-
-      if (isGet) {
-        cache.invalidate(cacheKey);
-      } else {
-        Set<ObjectId> ids = cache.getIfPresent(cacheKey);
-        if (ids != null) {
-          rp.getAdvertisedObjects().addAll(ids);
-          cache.invalidate(cacheKey);
-        }
-      }
-
-      chain.doFilter(request, response);
-
-      if (isGet) {
-        cache.put(cacheKey, Collections.unmodifiableSet(new HashSet<>(rp.getAdvertisedObjects())));
-      }
-    }
-
-    @Override
-    public void init(FilterConfig arg0) {}
-
-    @Override
-    public void destroy() {}
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpLogoutServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpLogoutServlet.java
deleted file mode 100644
index eb77a30..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpLogoutServlet.java
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.audit.AuditEvent;
-import com.google.gerrit.audit.AuditService;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-@Singleton
-public class HttpLogoutServlet extends HttpServlet {
-  private static final long serialVersionUID = 1L;
-
-  private final DynamicItem<WebSession> webSession;
-  private final Provider<String> urlProvider;
-  private final String logoutUrl;
-  private final AuditService audit;
-
-  @Inject
-  protected HttpLogoutServlet(
-      AuthConfig authConfig,
-      DynamicItem<WebSession> webSession,
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      AuditService audit) {
-    this.webSession = webSession;
-    this.urlProvider = urlProvider;
-    this.logoutUrl = authConfig.getLogoutURL();
-    this.audit = audit;
-  }
-
-  protected void doLogout(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-    webSession.get().logout();
-    if (logoutUrl != null) {
-      rsp.sendRedirect(logoutUrl);
-    } else {
-      String url = urlProvider.get();
-      if (Strings.isNullOrEmpty(url)) {
-        url = req.getContextPath();
-      }
-      if (Strings.isNullOrEmpty(url)) {
-        url = "/";
-      }
-      if (!url.endsWith("/")) {
-        url += "/";
-      }
-      rsp.sendRedirect(url);
-    }
-  }
-
-  @Override
-  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-
-    final String sid = webSession.get().getSessionId();
-    final CurrentUser currentUser = webSession.get().getUser();
-    final String what = "sign out";
-    final long when = TimeUtil.nowMs();
-
-    try {
-      doLogout(req, rsp);
-    } finally {
-      audit.dispatch(new AuditEvent(sid, currentUser, what, when, null, null));
-    }
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
deleted file mode 100644
index 3a43e24..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
+++ /dev/null
@@ -1,90 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd;
-
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
-import javax.servlet.http.HttpServletResponse;
-import javax.servlet.http.HttpServletResponseWrapper;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * HttpServletResponse wrapper to allow response status code override.
- *
- * <p>Differently from the normal HttpServletResponse, this class allows multiple filters to
- * override the response http status code.
- */
-public class HttpServletResponseRecorder extends HttpServletResponseWrapper {
-  private static final Logger log = LoggerFactory.getLogger(HttpServletResponseWrapper.class);
-  private static final String LOCATION_HEADER = "Location";
-
-  private int status;
-  private String statusMsg = "";
-  private Map<String, String> headers = new HashMap<>();
-
-  /**
-   * Constructs a response recorder wrapping the given response.
-   *
-   * @param response the response to be wrapped
-   */
-  public HttpServletResponseRecorder(HttpServletResponse response) {
-    super(response);
-  }
-
-  @Override
-  public void sendError(int sc) throws IOException {
-    this.status = sc;
-  }
-
-  @Override
-  public void sendError(int sc, String msg) throws IOException {
-    this.status = sc;
-    this.statusMsg = msg;
-  }
-
-  @Override
-  public void sendRedirect(String location) throws IOException {
-    this.status = SC_MOVED_TEMPORARILY;
-    setHeader(LOCATION_HEADER, location);
-  }
-
-  @Override
-  public void setHeader(String name, String value) {
-    super.setHeader(name, value);
-    headers.put(name, value);
-  }
-
-  @SuppressWarnings("all")
-  // @Override is omitted for backwards compatibility with servlet-api 2.5
-  // TODO: Remove @SuppressWarnings and add @Override when Google upgrades
-  //       to servlet-api 3.1
-  public int getStatus() {
-    return status;
-  }
-
-  void play() throws IOException {
-    if (status != 0) {
-      log.debug("Replaying {} {}", status, statusMsg);
-
-      if (status == SC_MOVED_TEMPORARILY) {
-        super.sendRedirect(headers.get(LOCATION_HEADER));
-      } else {
-        super.sendError(status, statusMsg);
-      }
-    }
-  }
-}
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
deleted file mode 100644
index 8ceb50a..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ /dev/null
@@ -1,243 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.AccessPath;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountException;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.AuthenticationFailedException;
-import com.google.gerrit.server.auth.NoSuchUserException;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Locale;
-import javax.servlet.Filter;
-import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.servlet.http.HttpServletResponseWrapper;
-import org.apache.commons.codec.binary.Base64;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Authenticates the current user by HTTP basic authentication.
- *
- * <p>The current HTTP request is authenticated by looking up the username and password from the
- * Base64 encoded Authorization header and validating them against any username/password configured
- * authentication system in Gerrit. This filter is intended only to protect the {@link
- * GitOverHttpServlet} and its handled URLs, which provide remote repository access over HTTP.
- *
- * @see <a href="http://www.ietf.org/rfc/rfc2617.txt">RFC 2617</a>
- */
-@Singleton
-class ProjectBasicAuthFilter implements Filter {
-  private static final Logger log = LoggerFactory.getLogger(ProjectBasicAuthFilter.class);
-
-  public static final String REALM_NAME = "Gerrit Code Review";
-  private static final String AUTHORIZATION = "Authorization";
-  private static final String LIT_BASIC = "Basic ";
-
-  private final DynamicItem<WebSession> session;
-  private final AccountCache accountCache;
-  private final AccountManager accountManager;
-  private final AuthConfig authConfig;
-
-  @Inject
-  ProjectBasicAuthFilter(
-      DynamicItem<WebSession> session,
-      AccountCache accountCache,
-      AccountManager accountManager,
-      AuthConfig authConfig) {
-    this.session = session;
-    this.accountCache = accountCache;
-    this.accountManager = accountManager;
-    this.authConfig = authConfig;
-  }
-
-  @Override
-  public void init(FilterConfig config) {}
-
-  @Override
-  public void destroy() {}
-
-  @Override
-  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
-      throws IOException, ServletException {
-    HttpServletRequest req = (HttpServletRequest) request;
-    Response rsp = new Response((HttpServletResponse) response);
-
-    if (verify(req, rsp)) {
-      chain.doFilter(req, rsp);
-    }
-  }
-
-  private boolean verify(HttpServletRequest req, Response rsp) throws IOException {
-    final String hdr = req.getHeader(AUTHORIZATION);
-    if (hdr == null || !hdr.startsWith(LIT_BASIC)) {
-      // Allow an anonymous connection through, or it might be using a
-      // session cookie instead of basic authentication.
-      return true;
-    }
-
-    final byte[] decoded = Base64.decodeBase64(hdr.substring(LIT_BASIC.length()));
-    String usernamePassword = new String(decoded, encoding(req));
-    int splitPos = usernamePassword.indexOf(':');
-    if (splitPos < 1) {
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
-    }
-
-    String username = usernamePassword.substring(0, splitPos);
-    String password = usernamePassword.substring(splitPos + 1);
-    if (Strings.isNullOrEmpty(password)) {
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
-    }
-    if (authConfig.isUserNameToLowerCase()) {
-      username = username.toLowerCase(Locale.US);
-    }
-
-    final AccountState who = accountCache.getByUsername(username);
-    if (who == null || !who.getAccount().isActive()) {
-      log.warn(
-          "Authentication failed for "
-              + username
-              + ": account inactive or not provisioned in Gerrit");
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
-    }
-
-    GitBasicAuthPolicy gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
-    if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP
-        || gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP) {
-      if (who.checkPassword(password, username)) {
-        return succeedAuthentication(who);
-      }
-    }
-
-    if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP) {
-      return failAuthentication(rsp, username);
-    }
-
-    AuthRequest whoAuth = AuthRequest.forUser(username);
-    whoAuth.setPassword(password);
-
-    try {
-      AuthResult whoAuthResult = accountManager.authenticate(whoAuth);
-      setUserIdentified(whoAuthResult.getAccountId());
-      return true;
-    } catch (NoSuchUserException e) {
-      if (who.checkPassword(password, who.getUserName())) {
-        return succeedAuthentication(who);
-      }
-      log.warn("Authentication failed for " + username, e);
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
-    } catch (AuthenticationFailedException e) {
-      log.warn("Authentication failed for " + username + ": " + e.getMessage());
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
-    } catch (AccountException e) {
-      log.warn("Authentication failed for " + username, e);
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
-    }
-  }
-
-  private boolean succeedAuthentication(AccountState who) {
-    setUserIdentified(who.getAccount().getId());
-    return true;
-  }
-
-  private boolean failAuthentication(Response rsp, String username) throws IOException {
-    log.warn(
-        "Authentication failed for {}: password does not match the one stored in Gerrit", username);
-    rsp.sendError(SC_UNAUTHORIZED);
-    return false;
-  }
-
-  private void setUserIdentified(Account.Id id) {
-    WebSession ws = session.get();
-    ws.setUserAccountId(id);
-    ws.setAccessPathOk(AccessPath.GIT, true);
-    ws.setAccessPathOk(AccessPath.REST_API, true);
-  }
-
-  private String encoding(HttpServletRequest req) {
-    return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
-  }
-
-  static class Response extends HttpServletResponseWrapper {
-    private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
-
-    Response(HttpServletResponse rsp) {
-      super(rsp);
-    }
-
-    private void status(int sc) {
-      if (sc == SC_UNAUTHORIZED) {
-        StringBuilder v = new StringBuilder();
-        v.append(LIT_BASIC);
-        v.append("realm=\"").append(REALM_NAME).append("\"");
-        setHeader(WWW_AUTHENTICATE, v.toString());
-      } else if (containsHeader(WWW_AUTHENTICATE)) {
-        setHeader(WWW_AUTHENTICATE, null);
-      }
-    }
-
-    @Override
-    public void sendError(int sc, String msg) throws IOException {
-      status(sc);
-      super.sendError(sc, msg);
-    }
-
-    @Override
-    public void sendError(int sc) throws IOException {
-      status(sc);
-      super.sendError(sc);
-    }
-
-    @Override
-    @Deprecated
-    public void setStatus(int sc, String sm) {
-      status(sc);
-      super.setStatus(sc, sm);
-    }
-
-    @Override
-    public void setStatus(int sc) {
-      status(sc);
-      super.setStatus(sc);
-    }
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
deleted file mode 100644
index 1f21da2..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
+++ /dev/null
@@ -1,339 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicMap.Entry;
-import com.google.gerrit.server.AccessPath;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountException;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.io.UnsupportedEncodingException;
-import java.net.URLDecoder;
-import java.util.Locale;
-import java.util.NoSuchElementException;
-import javax.servlet.Filter;
-import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
-import javax.servlet.http.Cookie;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.servlet.http.HttpServletResponseWrapper;
-import org.apache.commons.codec.binary.Base64;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Authenticates the current user with an OAuth2 server.
- *
- * @see <a href="https://tools.ietf.org/rfc/rfc6750.txt">RFC 6750</a>
- */
-@Singleton
-class ProjectOAuthFilter implements Filter {
-
-  private static final Logger log = LoggerFactory.getLogger(ProjectOAuthFilter.class);
-
-  private static final String REALM_NAME = "Gerrit Code Review";
-  private static final String AUTHORIZATION = "Authorization";
-  private static final String BASIC = "Basic ";
-  private static final String GIT_COOKIE_PREFIX = "git-";
-
-  private final DynamicItem<WebSession> session;
-  private final DynamicMap<OAuthLoginProvider> loginProviders;
-  private final AccountCache accountCache;
-  private final AccountManager accountManager;
-  private final String gitOAuthProvider;
-  private final boolean userNameToLowerCase;
-
-  private String defaultAuthPlugin;
-  private String defaultAuthProvider;
-
-  @Inject
-  ProjectOAuthFilter(
-      DynamicItem<WebSession> session,
-      DynamicMap<OAuthLoginProvider> pluginsProvider,
-      AccountCache accountCache,
-      AccountManager accountManager,
-      @GerritServerConfig Config gerritConfig) {
-    this.session = session;
-    this.loginProviders = pluginsProvider;
-    this.accountCache = accountCache;
-    this.accountManager = accountManager;
-    this.gitOAuthProvider = gerritConfig.getString("auth", null, "gitOAuthProvider");
-    this.userNameToLowerCase = gerritConfig.getBoolean("auth", null, "userNameToLowerCase", false);
-  }
-
-  @Override
-  public void init(FilterConfig config) throws ServletException {
-    if (Strings.isNullOrEmpty(gitOAuthProvider)) {
-      pickOnlyProvider();
-    } else {
-      pickConfiguredProvider();
-    }
-  }
-
-  @Override
-  public void destroy() {}
-
-  @Override
-  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
-      throws IOException, ServletException {
-    HttpServletRequest req = (HttpServletRequest) request;
-    Response rsp = new Response((HttpServletResponse) response);
-    if (verify(req, rsp)) {
-      chain.doFilter(req, rsp);
-    }
-  }
-
-  private boolean verify(HttpServletRequest req, Response rsp) throws IOException {
-    AuthInfo authInfo = null;
-
-    // first check if there is a BASIC authentication header
-    String hdr = req.getHeader(AUTHORIZATION);
-    if (hdr != null && hdr.startsWith(BASIC)) {
-      authInfo = extractAuthInfo(hdr, encoding(req));
-      if (authInfo == null) {
-        rsp.sendError(SC_UNAUTHORIZED);
-        return false;
-      }
-    } else {
-      // if there is no BASIC authentication header, check if there is
-      // a cookie starting with the prefix "git-"
-      Cookie cookie = findGitCookie(req);
-      if (cookie != null) {
-        authInfo = extractAuthInfo(cookie);
-        if (authInfo == null) {
-          rsp.sendError(SC_UNAUTHORIZED);
-          return false;
-        }
-      } else {
-        // if there is no authentication information at all, it might be
-        // an anonymous connection, or there might be a session cookie
-        return true;
-      }
-    }
-
-    // if there is authentication information but no secret => 401
-    if (Strings.isNullOrEmpty(authInfo.tokenOrSecret)) {
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
-    }
-
-    AccountState who = accountCache.getByUsername(authInfo.username);
-    if (who == null || !who.getAccount().isActive()) {
-      log.warn(
-          "Authentication failed for "
-              + authInfo.username
-              + ": account inactive or not provisioned in Gerrit");
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
-    }
-
-    AuthRequest authRequest = AuthRequest.forExternalUser(authInfo.username);
-    authRequest.setEmailAddress(who.getAccount().getPreferredEmail());
-    authRequest.setDisplayName(who.getAccount().getFullName());
-    authRequest.setPassword(authInfo.tokenOrSecret);
-    authRequest.setAuthPlugin(authInfo.pluginName);
-    authRequest.setAuthProvider(authInfo.exportName);
-
-    try {
-      AuthResult authResult = accountManager.authenticate(authRequest);
-      WebSession ws = session.get();
-      ws.setUserAccountId(authResult.getAccountId());
-      ws.setAccessPathOk(AccessPath.GIT, true);
-      ws.setAccessPathOk(AccessPath.REST_API, true);
-      return true;
-    } catch (AccountException e) {
-      log.warn("Authentication failed for " + authInfo.username, e);
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
-    }
-  }
-
-  /**
-   * Picks the only installed OAuth provider. If there is a multiude of providers available, the
-   * actual provider must be determined from the authentication request.
-   *
-   * @throws ServletException if there is no {@code OAuthLoginProvider} installed at all.
-   */
-  private void pickOnlyProvider() throws ServletException {
-    try {
-      Entry<OAuthLoginProvider> loginProvider = Iterables.getOnlyElement(loginProviders);
-      defaultAuthPlugin = loginProvider.getPluginName();
-      defaultAuthProvider = loginProvider.getExportName();
-    } catch (NoSuchElementException e) {
-      throw new ServletException("No OAuth login provider installed");
-    } catch (IllegalArgumentException e) {
-      // multiple providers found => do not pick any
-    }
-  }
-
-  /**
-   * Picks the {@code OAuthLoginProvider} configured with <tt>auth.gitOAuthProvider</tt>.
-   *
-   * @throws ServletException if the configured provider was not found.
-   */
-  private void pickConfiguredProvider() throws ServletException {
-    int splitPos = gitOAuthProvider.lastIndexOf(':');
-    if (splitPos < 1 || splitPos == gitOAuthProvider.length() - 1) {
-      // no colon at all or leading/trailing colon: malformed providerId
-      throw new ServletException(
-          "OAuth login provider configuration is"
-              + " invalid: Must be of the form pluginName:providerName");
-    }
-    defaultAuthPlugin = gitOAuthProvider.substring(0, splitPos);
-    defaultAuthProvider = gitOAuthProvider.substring(splitPos + 1);
-    OAuthLoginProvider provider = loginProviders.get(defaultAuthPlugin, defaultAuthProvider);
-    if (provider == null) {
-      throw new ServletException(
-          "Configured OAuth login provider " + gitOAuthProvider + " wasn't installed");
-    }
-  }
-
-  private AuthInfo extractAuthInfo(String hdr, String encoding)
-      throws UnsupportedEncodingException {
-    byte[] decoded = Base64.decodeBase64(hdr.substring(BASIC.length()));
-    String usernamePassword = new String(decoded, encoding);
-    int splitPos = usernamePassword.indexOf(':');
-    if (splitPos < 1 || splitPos == usernamePassword.length() - 1) {
-      return null;
-    }
-    return new AuthInfo(
-        usernamePassword.substring(0, splitPos),
-        usernamePassword.substring(splitPos + 1),
-        defaultAuthPlugin,
-        defaultAuthProvider);
-  }
-
-  private AuthInfo extractAuthInfo(Cookie cookie) throws UnsupportedEncodingException {
-    String username =
-        URLDecoder.decode(cookie.getName().substring(GIT_COOKIE_PREFIX.length()), UTF_8.name());
-    String value = cookie.getValue();
-    int splitPos = value.lastIndexOf('@');
-    if (splitPos < 1 || splitPos == value.length() - 1) {
-      // no providerId in the cookie value => assume default provider
-      // note: a leading/trailing at sign is considered to belong to
-      // the access token rather than being a separator
-      return new AuthInfo(username, cookie.getValue(), defaultAuthPlugin, defaultAuthProvider);
-    }
-    String token = value.substring(0, splitPos);
-    String providerId = value.substring(splitPos + 1);
-    splitPos = providerId.lastIndexOf(':');
-    if (splitPos < 1 || splitPos == providerId.length() - 1) {
-      // no colon at all or leading/trailing colon: malformed providerId
-      return null;
-    }
-    String pluginName = providerId.substring(0, splitPos);
-    String exportName = providerId.substring(splitPos + 1);
-    OAuthLoginProvider provider = loginProviders.get(pluginName, exportName);
-    if (provider == null) {
-      return null;
-    }
-    return new AuthInfo(username, token, pluginName, exportName);
-  }
-
-  private static String encoding(HttpServletRequest req) {
-    return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
-  }
-
-  private static Cookie findGitCookie(HttpServletRequest req) {
-    Cookie[] cookies = req.getCookies();
-    if (cookies != null) {
-      for (Cookie cookie : cookies) {
-        if (cookie.getName().startsWith(GIT_COOKIE_PREFIX)) {
-          return cookie;
-        }
-      }
-    }
-    return null;
-  }
-
-  private class AuthInfo {
-    private final String username;
-    private final String tokenOrSecret;
-    private final String pluginName;
-    private final String exportName;
-
-    private AuthInfo(String username, String tokenOrSecret, String pluginName, String exportName) {
-      this.username = userNameToLowerCase ? username.toLowerCase(Locale.US) : username;
-      this.tokenOrSecret = tokenOrSecret;
-      this.pluginName = pluginName;
-      this.exportName = exportName;
-    }
-  }
-
-  private static class Response extends HttpServletResponseWrapper {
-    private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
-
-    Response(HttpServletResponse rsp) {
-      super(rsp);
-    }
-
-    private void status(int sc) {
-      if (sc == SC_UNAUTHORIZED) {
-        StringBuilder v = new StringBuilder();
-        v.append(BASIC);
-        v.append("realm=\"").append(REALM_NAME).append("\"");
-        setHeader(WWW_AUTHENTICATE, v.toString());
-      } else if (containsHeader(WWW_AUTHENTICATE)) {
-        setHeader(WWW_AUTHENTICATE, null);
-      }
-    }
-
-    @Override
-    public void sendError(int sc, String msg) throws IOException {
-      status(sc);
-      super.sendError(sc, msg);
-    }
-
-    @Override
-    public void sendError(int sc) throws IOException {
-      status(sc);
-      super.sendError(sc);
-    }
-
-    @Override
-    @Deprecated
-    public void setStatus(int sc, String sm) {
-      status(sc);
-      super.setStatus(sc, sm);
-    }
-
-    @Override
-    public void setStatus(int sc) {
-      status(sc);
-      super.setStatus(sc);
-    }
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
deleted file mode 100644
index 1f095e0..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ /dev/null
@@ -1,259 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.auth.become;
-
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
-
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.httpd.HtmlDomUtil;
-import com.google.gerrit.httpd.LoginUrlToken;
-import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.httpd.template.SiteHeaderFooter;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountException;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.query.account.InternalAccountQuery;
-import com.google.gwtexpui.server.CacheHeaders;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.io.Writer;
-import java.util.List;
-import java.util.Optional;
-import java.util.UUID;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.w3c.dom.Document;
-import org.w3c.dom.Element;
-
-@SuppressWarnings("serial")
-@Singleton
-class BecomeAnyAccountLoginServlet extends HttpServlet {
-  private final DynamicItem<WebSession> webSession;
-  private final SchemaFactory<ReviewDb> schema;
-  private final Accounts accounts;
-  private final AccountCache accountCache;
-  private final AccountManager accountManager;
-  private final SiteHeaderFooter headers;
-  private final Provider<InternalAccountQuery> queryProvider;
-
-  @Inject
-  BecomeAnyAccountLoginServlet(
-      DynamicItem<WebSession> ws,
-      SchemaFactory<ReviewDb> sf,
-      Accounts a,
-      AccountCache ac,
-      AccountManager am,
-      SiteHeaderFooter shf,
-      Provider<InternalAccountQuery> qp) {
-    webSession = ws;
-    schema = sf;
-    accounts = a;
-    accountCache = ac;
-    accountManager = am;
-    headers = shf;
-    queryProvider = qp;
-  }
-
-  @Override
-  protected void doGet(HttpServletRequest req, HttpServletResponse rsp)
-      throws IOException, ServletException {
-    doPost(req, rsp);
-  }
-
-  @Override
-  protected void doPost(HttpServletRequest req, HttpServletResponse rsp)
-      throws IOException, ServletException {
-    CacheHeaders.setNotCacheable(rsp);
-
-    final AuthResult res;
-    if ("create_account".equals(req.getParameter("action"))) {
-      res = create();
-
-    } else if (req.getParameter("user_name") != null) {
-      res = byUserName(req.getParameter("user_name"));
-
-    } else if (req.getParameter("preferred_email") != null) {
-      res = byPreferredEmail(req.getParameter("preferred_email"));
-
-    } else if (req.getParameter("account_id") != null) {
-      res = byAccountId(req.getParameter("account_id"));
-
-    } else {
-      byte[] raw;
-      try {
-        raw = prepareHtmlOutput();
-      } catch (OrmException e) {
-        throw new ServletException(e);
-      }
-      rsp.setContentType("text/html");
-      rsp.setCharacterEncoding(HtmlDomUtil.ENC.name());
-      rsp.setContentLength(raw.length);
-      try (OutputStream out = rsp.getOutputStream()) {
-        out.write(raw);
-      }
-      return;
-    }
-
-    if (res != null) {
-      webSession.get().login(res, false);
-      final StringBuilder rdr = new StringBuilder();
-      rdr.append(req.getContextPath());
-      rdr.append("/");
-
-      if (res.isNew()) {
-        rdr.append('#' + PageLinks.REGISTER);
-      } else {
-        rdr.append(LoginUrlToken.getToken(req));
-      }
-      rsp.sendRedirect(rdr.toString());
-
-    } else {
-      rsp.setContentType("text/html");
-      rsp.setCharacterEncoding(HtmlDomUtil.ENC.name());
-      try (Writer out = rsp.getWriter()) {
-        out.write("<html>");
-        out.write("<body>");
-        out.write("<h1>Account Not Found</h1>");
-        out.write("</body>");
-        out.write("</html>");
-      }
-    }
-  }
-
-  private byte[] prepareHtmlOutput() throws IOException, OrmException {
-    final String pageName = "BecomeAnyAccount.html";
-    Document doc = headers.parse(getClass(), pageName);
-    if (doc == null) {
-      throw new FileNotFoundException("No " + pageName + " in webapp");
-    }
-
-    Element userlistElement = HtmlDomUtil.find(doc, "userlist");
-    try (ReviewDb db = schema.open()) {
-      for (Account.Id accountId : accounts.firstNIds(100)) {
-        Account a = accountCache.get(accountId).getAccount();
-        String displayName;
-        if (a.getUserName() != null) {
-          displayName = a.getUserName();
-        } else if (a.getFullName() != null && !a.getFullName().isEmpty()) {
-          displayName = a.getFullName();
-        } else if (a.getPreferredEmail() != null) {
-          displayName = a.getPreferredEmail();
-        } else {
-          displayName = accountId.toString();
-        }
-
-        Element linkElement = doc.createElement("a");
-        linkElement.setAttribute("href", "?account_id=" + a.getId().toString());
-        linkElement.setTextContent(displayName);
-        userlistElement.appendChild(linkElement);
-        userlistElement.appendChild(doc.createElement("br"));
-      }
-    }
-
-    return HtmlDomUtil.toUTF8(doc);
-  }
-
-  private AuthResult auth(Account account) {
-    if (account != null) {
-      return new AuthResult(account.getId(), null, false);
-    }
-    return null;
-  }
-
-  private AuthResult auth(Account.Id account) {
-    if (account != null) {
-      return new AuthResult(account, null, false);
-    }
-    return null;
-  }
-
-  private AuthResult byUserName(String userName) {
-    try {
-      List<AccountState> accountStates =
-          queryProvider.get().byExternalId(SCHEME_USERNAME, userName);
-      if (accountStates.isEmpty()) {
-        getServletContext().log("No accounts with username " + userName + " found");
-        return null;
-      }
-      if (accountStates.size() > 1) {
-        getServletContext().log("Multiple accounts with username " + userName + " found");
-        return null;
-      }
-      return auth(accountStates.get(0).getAccount().getId());
-    } catch (OrmException e) {
-      getServletContext().log("cannot query account index", e);
-      return null;
-    }
-  }
-
-  private AuthResult byPreferredEmail(String email) {
-    try (ReviewDb db = schema.open()) {
-      Optional<Account> match =
-          queryProvider
-              .get()
-              .byPreferredEmail(email)
-              .stream()
-              .map(AccountState::getAccount)
-              .findFirst();
-      return match.isPresent() ? auth(match.get()) : null;
-    } catch (OrmException e) {
-      getServletContext().log("cannot query database", e);
-      return null;
-    }
-  }
-
-  private AuthResult byAccountId(String idStr) {
-    final Account.Id id;
-    try {
-      id = Account.Id.parse(idStr);
-    } catch (NumberFormatException nfe) {
-      return null;
-    }
-    try {
-      return auth(accounts.get(id));
-    } catch (IOException | ConfigInvalidException e) {
-      getServletContext().log("cannot query database", e);
-      return null;
-    }
-  }
-
-  private AuthResult create() throws IOException {
-    try {
-      return accountManager.authenticate(
-          new AuthRequest(ExternalId.Key.create(SCHEME_UUID, UUID.randomUUID().toString())));
-    } catch (AccountException e) {
-      getServletContext().log("cannot create new account", e);
-      return null;
-    }
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
deleted file mode 100644
index 5719fe4..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ /dev/null
@@ -1,745 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.plugins;
-
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
-import static com.google.common.net.HttpHeaders.ORIGIN;
-import static com.google.common.net.HttpHeaders.VARY;
-import static com.google.gerrit.common.FileUtil.lastModified;
-import static com.google.gerrit.server.plugins.PluginEntry.ATTR_CHARACTER_ENCODING;
-import static com.google.gerrit.server.plugins.PluginEntry.ATTR_CONTENT_TYPE;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Joiner;
-import com.google.common.base.Splitter;
-import com.google.common.base.Strings;
-import com.google.common.cache.Cache;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.io.ByteStreams;
-import com.google.common.net.HttpHeaders;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.httpd.resources.Resource;
-import com.google.gerrit.httpd.resources.ResourceKey;
-import com.google.gerrit.httpd.resources.SmallResource;
-import com.google.gerrit.httpd.restapi.RestApiServlet;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.documentation.MarkdownFormatter;
-import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
-import com.google.gerrit.server.plugins.Plugin;
-import com.google.gerrit.server.plugins.Plugin.ApiType;
-import com.google.gerrit.server.plugins.PluginContentScanner;
-import com.google.gerrit.server.plugins.PluginEntry;
-import com.google.gerrit.server.plugins.PluginsCollection;
-import com.google.gerrit.server.plugins.ReloadPluginListener;
-import com.google.gerrit.server.plugins.StartPluginListener;
-import com.google.gerrit.server.ssh.SshInfo;
-import com.google.gerrit.util.http.RequestUtil;
-import com.google.gwtexpui.server.CacheHeaders;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.name.Named;
-import com.google.inject.servlet.GuiceFilter;
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.OutputStream;
-import java.io.UnsupportedEncodingException;
-import java.nio.charset.Charset;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Optional;
-import java.util.concurrent.ConcurrentMap;
-import java.util.function.Predicate;
-import java.util.jar.Attributes;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import javax.servlet.FilterChain;
-import javax.servlet.ServletConfig;
-import javax.servlet.ServletContext;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.util.IO;
-import org.eclipse.jgit.util.RawParseUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-class HttpPluginServlet extends HttpServlet implements StartPluginListener, ReloadPluginListener {
-  private static final int SMALL_RESOURCE = 128 * 1024;
-  private static final long serialVersionUID = 1L;
-  private static final Logger log = LoggerFactory.getLogger(HttpPluginServlet.class);
-
-  private final MimeUtilFileTypeRegistry mimeUtil;
-  private final Provider<String> webUrl;
-  private final Cache<ResourceKey, Resource> resourceCache;
-  private final String sshHost;
-  private final int sshPort;
-  private final RestApiServlet managerApi;
-
-  private List<Plugin> pending = new ArrayList<>();
-  private ContextMapper wrapper;
-  private final ConcurrentMap<String, PluginHolder> plugins = Maps.newConcurrentMap();
-  private final Pattern allowOrigin;
-
-  @Inject
-  HttpPluginServlet(
-      MimeUtilFileTypeRegistry mimeUtil,
-      @CanonicalWebUrl Provider<String> webUrl,
-      @Named(HttpPluginModule.PLUGIN_RESOURCES) Cache<ResourceKey, Resource> cache,
-      SshInfo sshInfo,
-      RestApiServlet.Globals globals,
-      PluginsCollection plugins,
-      @GerritServerConfig Config cfg) {
-    this.mimeUtil = mimeUtil;
-    this.webUrl = webUrl;
-    this.resourceCache = cache;
-    this.managerApi = new RestApiServlet(globals, plugins);
-
-    String sshHost = "review.example.com";
-    int sshPort = 29418;
-    if (!sshInfo.getHostKeys().isEmpty()) {
-      String host = sshInfo.getHostKeys().get(0).getHost();
-      int c = host.lastIndexOf(':');
-      if (0 <= c) {
-        sshHost = host.substring(0, c);
-        sshPort = Integer.parseInt(host.substring(c + 1));
-      } else {
-        sshHost = host;
-        sshPort = 22;
-      }
-    }
-    this.sshHost = sshHost;
-    this.sshPort = sshPort;
-    this.allowOrigin = makeAllowOrigin(cfg);
-  }
-
-  @Override
-  public synchronized void init(ServletConfig config) throws ServletException {
-    super.init(config);
-
-    wrapper = new ContextMapper(config.getServletContext().getContextPath());
-    for (Plugin plugin : pending) {
-      install(plugin);
-    }
-    pending = null;
-  }
-
-  @Override
-  public synchronized void onStartPlugin(Plugin plugin) {
-    if (pending != null) {
-      pending.add(plugin);
-    } else {
-      install(plugin);
-    }
-  }
-
-  @Override
-  public void onReloadPlugin(Plugin oldPlugin, Plugin newPlugin) {
-    install(newPlugin);
-  }
-
-  private void install(Plugin plugin) {
-    GuiceFilter filter = load(plugin);
-    final String name = plugin.getName();
-    final PluginHolder holder = new PluginHolder(plugin, filter);
-    plugin.add(
-        new RegistrationHandle() {
-          @Override
-          public void remove() {
-            plugins.remove(name, holder);
-          }
-        });
-    plugins.put(name, holder);
-  }
-
-  private GuiceFilter load(Plugin plugin) {
-    if (plugin.getHttpInjector() != null) {
-      final String name = plugin.getName();
-      final GuiceFilter filter;
-      try {
-        filter = plugin.getHttpInjector().getInstance(GuiceFilter.class);
-      } catch (RuntimeException e) {
-        log.warn(String.format("Plugin %s cannot load GuiceFilter", name), e);
-        return null;
-      }
-
-      try {
-        ServletContext ctx = PluginServletContext.create(plugin, wrapper.getFullPath(name));
-        filter.init(new WrappedFilterConfig(ctx));
-      } catch (ServletException e) {
-        log.warn(String.format("Plugin %s failed to initialize HTTP", name), e);
-        return null;
-      }
-
-      plugin.add(
-          new RegistrationHandle() {
-            @Override
-            public void remove() {
-              filter.destroy();
-            }
-          });
-      return filter;
-    }
-    return null;
-  }
-
-  @Override
-  public void service(HttpServletRequest req, HttpServletResponse res)
-      throws IOException, ServletException {
-    List<String> parts =
-        Lists.newArrayList(
-            Splitter.on('/')
-                .limit(3)
-                .omitEmptyStrings()
-                .split(Strings.nullToEmpty(RequestUtil.getEncodedPathInfo(req))));
-
-    if (isApiCall(req, parts)) {
-      managerApi.service(req, res);
-      return;
-    }
-
-    String name = parts.get(0);
-    final PluginHolder holder = plugins.get(name);
-    if (holder == null) {
-      CacheHeaders.setNotCacheable(res);
-      res.sendError(HttpServletResponse.SC_NOT_FOUND);
-      return;
-    }
-
-    HttpServletRequest wr = wrapper.create(req, name);
-    FilterChain chain =
-        new FilterChain() {
-          @Override
-          public void doFilter(ServletRequest req, ServletResponse res) throws IOException {
-            onDefault(holder, (HttpServletRequest) req, (HttpServletResponse) res);
-          }
-        };
-    if (holder.filter != null) {
-      holder.filter.doFilter(wr, res, chain);
-    } else {
-      chain.doFilter(wr, res);
-    }
-  }
-
-  private static boolean isApiCall(HttpServletRequest req, List<String> parts) {
-    String method = req.getMethod();
-    int cnt = parts.size();
-    return cnt == 0
-        || (cnt == 1 && ("PUT".equals(method) || "DELETE".equals(method)))
-        || (cnt == 2 && parts.get(1).startsWith("gerrit~"));
-  }
-
-  private void onDefault(PluginHolder holder, HttpServletRequest req, HttpServletResponse res)
-      throws IOException {
-    if (!"GET".equals(req.getMethod()) && !"HEAD".equals(req.getMethod())) {
-      CacheHeaders.setNotCacheable(res);
-      res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
-      return;
-    }
-
-    String pathInfo = RequestUtil.getEncodedPathInfo(req);
-    if (pathInfo.length() < 1) {
-      Resource.NOT_FOUND.send(req, res);
-      return;
-    }
-
-    checkCors(req, res);
-
-    String file = pathInfo.substring(1);
-    PluginResourceKey key = PluginResourceKey.create(holder.plugin, file);
-    Resource rsc = resourceCache.getIfPresent(key);
-    if (rsc != null && req.getHeader(HttpHeaders.IF_MODIFIED_SINCE) == null) {
-      rsc.send(req, res);
-      return;
-    }
-
-    String uri = req.getRequestURI();
-    if ("".equals(file)) {
-      res.sendRedirect(uri + holder.docPrefix + "index.html");
-      return;
-    }
-
-    if (file.startsWith(holder.staticPrefix)) {
-      if (holder.plugin.getApiType() == ApiType.JS) {
-        sendJsPlugin(holder.plugin, key, req, res);
-      } else {
-        PluginContentScanner scanner = holder.plugin.getContentScanner();
-        Optional<PluginEntry> entry = scanner.getEntry(file);
-        if (entry.isPresent()) {
-          if (hasUpToDateCachedResource(rsc, entry.get().getTime())) {
-            rsc.send(req, res);
-          } else {
-            sendResource(scanner, entry.get(), key, res);
-          }
-        } else {
-          resourceCache.put(key, Resource.NOT_FOUND);
-          Resource.NOT_FOUND.send(req, res);
-        }
-      }
-    } else if (file.equals(holder.docPrefix.substring(0, holder.docPrefix.length() - 1))) {
-      res.sendRedirect(uri + "/index.html");
-    } else if (file.startsWith(holder.docPrefix) && file.endsWith("/")) {
-      res.sendRedirect(uri + "index.html");
-    } else if (file.startsWith(holder.docPrefix)) {
-      PluginContentScanner scanner = holder.plugin.getContentScanner();
-      Optional<PluginEntry> entry = scanner.getEntry(file);
-      if (!entry.isPresent()) {
-        entry = findSource(scanner, file);
-      }
-      if (!entry.isPresent() && file.endsWith("/index.html")) {
-        String pfx = file.substring(0, file.length() - "index.html".length());
-        long pluginLastModified = lastModified(holder.plugin.getSrcFile());
-        if (hasUpToDateCachedResource(rsc, pluginLastModified)) {
-          rsc.send(req, res);
-        } else {
-          sendAutoIndex(scanner, pfx, holder.plugin.getName(), key, res, pluginLastModified);
-        }
-      } else if (entry.isPresent() && entry.get().getName().endsWith(".md")) {
-        if (hasUpToDateCachedResource(rsc, entry.get().getTime())) {
-          rsc.send(req, res);
-        } else {
-          sendMarkdownAsHtml(scanner, entry.get(), holder.plugin.getName(), key, res);
-        }
-      } else if (entry.isPresent()) {
-        if (hasUpToDateCachedResource(rsc, entry.get().getTime())) {
-          rsc.send(req, res);
-        } else {
-          sendResource(scanner, entry.get(), key, res);
-        }
-      } else {
-        resourceCache.put(key, Resource.NOT_FOUND);
-        Resource.NOT_FOUND.send(req, res);
-      }
-    } else {
-      resourceCache.put(key, Resource.NOT_FOUND);
-      Resource.NOT_FOUND.send(req, res);
-    }
-  }
-
-  private static Pattern makeAllowOrigin(Config cfg) {
-    String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
-    if (allow.length > 0) {
-      return Pattern.compile(Joiner.on('|').join(allow));
-    }
-    return null;
-  }
-
-  private void checkCors(HttpServletRequest req, HttpServletResponse res) {
-    String origin = req.getHeader(ORIGIN);
-    if (!Strings.isNullOrEmpty(origin) && isOriginAllowed(origin)) {
-      res.addHeader(VARY, ORIGIN);
-      setCorsHeaders(res, origin);
-    }
-  }
-
-  private void setCorsHeaders(HttpServletResponse res, String origin) {
-    res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
-    res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
-    res.setHeader(ACCESS_CONTROL_ALLOW_METHODS, "GET, HEAD");
-  }
-
-  private boolean isOriginAllowed(String origin) {
-    return allowOrigin == null || allowOrigin.matcher(origin).matches();
-  }
-
-  private boolean hasUpToDateCachedResource(Resource cachedResource, long lastUpdateTime) {
-    return cachedResource != null && cachedResource.isUnchanged(lastUpdateTime);
-  }
-
-  private void appendEntriesSection(
-      PluginContentScanner scanner,
-      List<PluginEntry> entries,
-      String sectionTitle,
-      StringBuilder md,
-      String prefix,
-      int nameOffset)
-      throws IOException {
-    if (!entries.isEmpty()) {
-      md.append("## ").append(sectionTitle).append(" ##\n");
-      for (PluginEntry entry : entries) {
-        String rsrc = entry.getName().substring(prefix.length());
-        String entryTitle;
-        if (rsrc.endsWith(".html")) {
-          entryTitle = rsrc.substring(nameOffset, rsrc.length() - 5).replace('-', ' ');
-        } else if (rsrc.endsWith(".md")) {
-          entryTitle = extractTitleFromMarkdown(scanner, entry);
-          if (Strings.isNullOrEmpty(entryTitle)) {
-            entryTitle = rsrc.substring(nameOffset, rsrc.length() - 3).replace('-', ' ');
-          }
-        } else {
-          entryTitle = rsrc.substring(nameOffset).replace('-', ' ');
-        }
-        md.append(String.format("* [%s](%s)\n", entryTitle, rsrc));
-      }
-      md.append("\n");
-    }
-  }
-
-  private void sendAutoIndex(
-      PluginContentScanner scanner,
-      final String prefix,
-      final String pluginName,
-      PluginResourceKey cacheKey,
-      HttpServletResponse res,
-      long lastModifiedTime)
-      throws IOException {
-    List<PluginEntry> cmds = new ArrayList<>();
-    List<PluginEntry> servlets = new ArrayList<>();
-    List<PluginEntry> restApis = new ArrayList<>();
-    List<PluginEntry> docs = new ArrayList<>();
-    PluginEntry about = null;
-
-    Predicate<PluginEntry> filter =
-        entry -> {
-          String name = entry.getName();
-          Optional<Long> size = entry.getSize();
-          if (name.startsWith(prefix)
-              && (name.endsWith(".md") || name.endsWith(".html"))
-              && size.isPresent()) {
-            if (size.get() <= 0 || size.get() > SMALL_RESOURCE) {
-              log.warn(
-                  String.format(
-                      "Plugin %s: %s omitted from document index. "
-                          + "Size %d out of range (0,%d).",
-                      pluginName, name.substring(prefix.length()), size.get(), SMALL_RESOURCE));
-              return false;
-            }
-            return true;
-          }
-          return false;
-        };
-
-    List<PluginEntry> entries =
-        Collections.list(scanner.entries()).stream().filter(filter).collect(toList());
-    for (PluginEntry entry : entries) {
-      String name = entry.getName().substring(prefix.length());
-      if (name.startsWith("cmd-")) {
-        cmds.add(entry);
-      } else if (name.startsWith("servlet-")) {
-        servlets.add(entry);
-      } else if (name.startsWith("rest-api-")) {
-        restApis.add(entry);
-      } else if (name.startsWith("about.")) {
-        if (about == null) {
-          about = entry;
-        } else {
-          log.warn(
-              String.format(
-                  "Plugin %s: Multiple 'about' documents found; using %s",
-                  pluginName, about.getName().substring(prefix.length())));
-        }
-      } else {
-        docs.add(entry);
-      }
-    }
-
-    Collections.sort(cmds, PluginEntry.COMPARATOR_BY_NAME);
-    Collections.sort(docs, PluginEntry.COMPARATOR_BY_NAME);
-
-    StringBuilder md = new StringBuilder();
-    md.append(String.format("# Plugin %s #\n", pluginName));
-    md.append("\n");
-    appendPluginInfoTable(md, scanner.getManifest().getMainAttributes());
-
-    if (about != null) {
-      InputStreamReader isr = new InputStreamReader(scanner.getInputStream(about), UTF_8);
-      StringBuilder aboutContent = new StringBuilder();
-      try (BufferedReader reader = new BufferedReader(isr)) {
-        String line;
-        while ((line = reader.readLine()) != null) {
-          line = line.trim();
-          if (line.isEmpty()) {
-            aboutContent.append("\n");
-          } else {
-            aboutContent.append(line).append("\n");
-          }
-        }
-      }
-
-      // Only append the About section if there was anything in it
-      if (aboutContent.toString().trim().length() > 0) {
-        md.append("## About ##\n");
-        md.append("\n").append(aboutContent);
-      }
-    }
-
-    appendEntriesSection(scanner, docs, "Documentation", md, prefix, 0);
-    appendEntriesSection(scanner, servlets, "Servlets", md, prefix, "servlet-".length());
-    appendEntriesSection(scanner, restApis, "REST APIs", md, prefix, "rest-api-".length());
-    appendEntriesSection(scanner, cmds, "Commands", md, prefix, "cmd-".length());
-
-    sendMarkdownAsHtml(md.toString(), pluginName, cacheKey, res, lastModifiedTime);
-  }
-
-  private void sendMarkdownAsHtml(
-      String md,
-      String pluginName,
-      PluginResourceKey cacheKey,
-      HttpServletResponse res,
-      long lastModifiedTime)
-      throws UnsupportedEncodingException, IOException {
-    Map<String, String> macros = new HashMap<>();
-    macros.put("PLUGIN", pluginName);
-    macros.put("SSH_HOST", sshHost);
-    macros.put("SSH_PORT", "" + sshPort);
-    String url = webUrl.get();
-    if (Strings.isNullOrEmpty(url)) {
-      url = "http://review.example.com/";
-    }
-    macros.put("URL", url);
-
-    Matcher m = Pattern.compile("(\\\\)?@([A-Z_]+)@").matcher(md);
-    StringBuffer sb = new StringBuffer();
-    while (m.find()) {
-      String key = m.group(2);
-      String val = macros.get(key);
-      if (m.group(1) != null) {
-        m.appendReplacement(sb, "@" + key + "@");
-      } else if (val != null) {
-        m.appendReplacement(sb, val);
-      } else {
-        m.appendReplacement(sb, "@" + key + "@");
-      }
-    }
-    m.appendTail(sb);
-
-    byte[] html = new MarkdownFormatter().markdownToDocHtml(sb.toString(), UTF_8.name());
-    resourceCache.put(
-        cacheKey,
-        new SmallResource(html)
-            .setContentType("text/html")
-            .setCharacterEncoding(UTF_8.name())
-            .setLastModified(lastModifiedTime));
-    res.setContentType("text/html");
-    res.setCharacterEncoding(UTF_8.name());
-    res.setContentLength(html.length);
-    res.setDateHeader("Last-Modified", lastModifiedTime);
-    res.getOutputStream().write(html);
-  }
-
-  private static void appendPluginInfoTable(StringBuilder html, Attributes main) {
-    if (main != null) {
-      String t = main.getValue(Attributes.Name.IMPLEMENTATION_TITLE);
-      String n = main.getValue(Attributes.Name.IMPLEMENTATION_VENDOR);
-      String v = main.getValue(Attributes.Name.IMPLEMENTATION_VERSION);
-      String a = main.getValue("Gerrit-ApiVersion");
-
-      html.append("<table class=\"plugin_info\">");
-      if (!Strings.isNullOrEmpty(t)) {
-        html.append("<tr><th>Name</th><td>").append(t).append("</td></tr>\n");
-      }
-      if (!Strings.isNullOrEmpty(n)) {
-        html.append("<tr><th>Vendor</th><td>").append(n).append("</td></tr>\n");
-      }
-      if (!Strings.isNullOrEmpty(v)) {
-        html.append("<tr><th>Version</th><td>").append(v).append("</td></tr>\n");
-      }
-      if (!Strings.isNullOrEmpty(a)) {
-        html.append("<tr><th>API Version</th><td>").append(a).append("</td></tr>\n");
-      }
-      html.append("</table>\n");
-    }
-  }
-
-  private static String extractTitleFromMarkdown(PluginContentScanner scanner, PluginEntry entry)
-      throws IOException {
-    String charEnc = null;
-    Map<Object, String> atts = entry.getAttrs();
-    if (atts != null) {
-      charEnc = Strings.emptyToNull(atts.get(ATTR_CHARACTER_ENCODING));
-    }
-    if (charEnc == null) {
-      charEnc = UTF_8.name();
-    }
-    return new MarkdownFormatter()
-        .extractTitleFromMarkdown(readWholeEntry(scanner, entry), charEnc);
-  }
-
-  private static Optional<PluginEntry> findSource(PluginContentScanner scanner, String file)
-      throws IOException {
-    if (file.endsWith(".html")) {
-      int d = file.lastIndexOf('.');
-      return scanner.getEntry(file.substring(0, d) + ".md");
-    }
-    return Optional.empty();
-  }
-
-  private void sendMarkdownAsHtml(
-      PluginContentScanner scanner,
-      PluginEntry entry,
-      String pluginName,
-      PluginResourceKey key,
-      HttpServletResponse res)
-      throws IOException {
-    byte[] rawmd = readWholeEntry(scanner, entry);
-    String encoding = null;
-    Map<Object, String> atts = entry.getAttrs();
-    if (atts != null) {
-      encoding = Strings.emptyToNull(atts.get(ATTR_CHARACTER_ENCODING));
-    }
-
-    String txtmd =
-        RawParseUtils.decode(Charset.forName(encoding != null ? encoding : UTF_8.name()), rawmd);
-    long time = entry.getTime();
-    if (0 < time) {
-      res.setDateHeader("Last-Modified", time);
-    }
-    sendMarkdownAsHtml(txtmd, pluginName, key, res, time);
-  }
-
-  private void sendResource(
-      PluginContentScanner scanner,
-      PluginEntry entry,
-      PluginResourceKey key,
-      HttpServletResponse res)
-      throws IOException {
-    byte[] data = null;
-    Optional<Long> size = entry.getSize();
-    if (size.isPresent() && size.get() <= SMALL_RESOURCE) {
-      data = readWholeEntry(scanner, entry);
-    }
-
-    String contentType = null;
-    String charEnc = null;
-    Map<Object, String> atts = entry.getAttrs();
-    if (atts != null) {
-      contentType = Strings.emptyToNull(atts.get(ATTR_CONTENT_TYPE));
-      charEnc = Strings.emptyToNull(atts.get(ATTR_CHARACTER_ENCODING));
-    }
-    if (contentType == null) {
-      contentType = mimeUtil.getMimeType(entry.getName(), data).toString();
-      if ("application/octet-stream".equals(contentType) && entry.getName().endsWith(".js")) {
-        contentType = "application/javascript";
-      } else if ("application/x-pointplus".equals(contentType)
-          && entry.getName().endsWith(".css")) {
-        contentType = "text/css";
-      }
-    }
-
-    long time = entry.getTime();
-    if (0 < time) {
-      res.setDateHeader("Last-Modified", time);
-    }
-    if (size.isPresent()) {
-      res.setHeader("Content-Length", size.get().toString());
-    }
-    res.setContentType(contentType);
-    if (charEnc != null) {
-      res.setCharacterEncoding(charEnc);
-    }
-    if (data != null) {
-      resourceCache.put(
-          key,
-          new SmallResource(data)
-              .setContentType(contentType)
-              .setCharacterEncoding(charEnc)
-              .setLastModified(time));
-      res.getOutputStream().write(data);
-    } else {
-      writeToResponse(res, scanner.getInputStream(entry));
-    }
-  }
-
-  private void sendJsPlugin(
-      Plugin plugin, PluginResourceKey key, HttpServletRequest req, HttpServletResponse res)
-      throws IOException {
-    Path path = plugin.getSrcFile();
-    if (req.getRequestURI().endsWith(getJsPluginPath(plugin)) && Files.exists(path)) {
-      res.setHeader("Content-Length", Long.toString(Files.size(path)));
-      if (path.toString().toLowerCase(Locale.US).endsWith(".html")) {
-        res.setContentType("text/html");
-      } else {
-        res.setContentType("application/javascript");
-      }
-      writeToResponse(res, Files.newInputStream(path));
-    } else {
-      resourceCache.put(key, Resource.NOT_FOUND);
-      Resource.NOT_FOUND.send(req, res);
-    }
-  }
-
-  private static String getJsPluginPath(Plugin plugin) {
-    return String.format(
-        "/plugins/%s/static/%s", plugin.getName(), plugin.getSrcFile().getFileName());
-  }
-
-  private void writeToResponse(HttpServletResponse res, InputStream inputStream)
-      throws IOException {
-    try (OutputStream out = res.getOutputStream();
-        InputStream in = inputStream) {
-      ByteStreams.copy(in, out);
-    }
-  }
-
-  private static byte[] readWholeEntry(PluginContentScanner scanner, PluginEntry entry)
-      throws IOException {
-    try (InputStream in = scanner.getInputStream(entry)) {
-      return IO.readWholeStream(in, entry.getSize().get().intValue()).array();
-    }
-  }
-
-  private static class PluginHolder {
-    final Plugin plugin;
-    final GuiceFilter filter;
-    final String staticPrefix;
-    final String docPrefix;
-
-    PluginHolder(Plugin plugin, GuiceFilter filter) {
-      this.plugin = plugin;
-      this.filter = filter;
-      this.staticPrefix = getPrefix(plugin, "Gerrit-HttpStaticPrefix", "static/");
-      this.docPrefix = getPrefix(plugin, "Gerrit-HttpDocumentationPrefix", "Documentation/");
-    }
-
-    private static String getPrefix(Plugin plugin, String attr, String def) {
-      Path path = plugin.getSrcFile();
-      PluginContentScanner scanner = plugin.getContentScanner();
-      if (path == null || scanner == PluginContentScanner.EMPTY) {
-        return def;
-      }
-      try {
-        String prefix = scanner.getManifest().getMainAttributes().getValue(attr);
-        if (prefix != null) {
-          return CharMatcher.is('/').trimFrom(prefix) + "/";
-        }
-        return def;
-      } catch (IOException e) {
-        log.warn(
-            String.format("Error getting %s for plugin %s, using default", attr, plugin.getName()),
-            e);
-        return null;
-      }
-    }
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
deleted file mode 100644
index 51340ae..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
+++ /dev/null
@@ -1,408 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import static com.google.gerrit.common.FileUtil.lastModified;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Strings;
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-import com.google.common.primitives.Bytes;
-import com.google.gerrit.common.Version;
-import com.google.gerrit.common.data.HostPageData;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.systemstatus.MessageOfTheDay;
-import com.google.gerrit.extensions.webui.WebUiPlugin;
-import com.google.gerrit.httpd.HtmlDomUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.account.GetDiffPreferences;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtexpui.server.CacheHeaders;
-import com.google.gwtjsonrpc.server.JsonServlet;
-import com.google.gwtjsonrpc.server.RPCServletUtils;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.io.StringWriter;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-import javax.servlet.ServletContext;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.w3c.dom.Document;
-import org.w3c.dom.Element;
-import org.w3c.dom.Node;
-
-/** Sends the Gerrit host page to clients. */
-@SuppressWarnings("serial")
-@Singleton
-public class HostPageServlet extends HttpServlet {
-  private static final Logger log = LoggerFactory.getLogger(HostPageServlet.class);
-
-  private static final String HPD_ID = "gerrit_hostpagedata";
-  private static final int DEFAULT_JS_LOAD_TIMEOUT = 5000;
-
-  private final Provider<CurrentUser> currentUser;
-  private final DynamicSet<WebUiPlugin> plugins;
-  private final DynamicSet<MessageOfTheDay> messages;
-  private final HostPageData.Theme signedOutTheme;
-  private final HostPageData.Theme signedInTheme;
-  private final SitePaths site;
-  private final Document template;
-  private final String noCacheName;
-  private final boolean refreshHeaderFooter;
-  private final SiteStaticDirectoryServlet staticServlet;
-  private final boolean isNoteDbEnabled;
-  private final Integer pluginsLoadTimeout;
-  private final boolean canLoadInIFrame;
-  private final GetDiffPreferences getDiff;
-  private volatile Page page;
-
-  @Inject
-  HostPageServlet(
-      Provider<CurrentUser> cu,
-      SitePaths sp,
-      ThemeFactory themeFactory,
-      ServletContext servletContext,
-      DynamicSet<WebUiPlugin> webUiPlugins,
-      DynamicSet<MessageOfTheDay> motd,
-      @GerritServerConfig Config cfg,
-      SiteStaticDirectoryServlet ss,
-      NotesMigration migration,
-      GetDiffPreferences diffPref)
-      throws IOException, ServletException {
-    currentUser = cu;
-    plugins = webUiPlugins;
-    messages = motd;
-    signedOutTheme = themeFactory.getSignedOutTheme();
-    signedInTheme = themeFactory.getSignedInTheme();
-    site = sp;
-    refreshHeaderFooter = cfg.getBoolean("site", "refreshHeaderFooter", true);
-    staticServlet = ss;
-    isNoteDbEnabled = migration.readChanges();
-    pluginsLoadTimeout = getPluginsLoadTimeout(cfg);
-    canLoadInIFrame = cfg.getBoolean("gerrit", "canLoadInIFrame", false);
-    getDiff = diffPref;
-
-    String pageName = "HostPage.html";
-    template = HtmlDomUtil.parseFile(getClass(), pageName);
-    if (template == null) {
-      throw new FileNotFoundException("No " + pageName + " in webapp");
-    }
-
-    if (HtmlDomUtil.find(template, "gerrit_module") == null) {
-      throw new ServletException("No gerrit_module in " + pageName);
-    }
-    if (HtmlDomUtil.find(template, HPD_ID) == null) {
-      throw new ServletException("No " + HPD_ID + " in " + pageName);
-    }
-
-    String src = "gerrit_ui/gerrit_ui.nocache.js";
-    try (InputStream in = servletContext.getResourceAsStream("/" + src)) {
-      if (in != null) {
-        Hasher md = Hashing.murmur3_128().newHasher();
-        byte[] buf = new byte[1024];
-        int n;
-        while ((n = in.read(buf)) > 0) {
-          md.putBytes(buf, 0, n);
-        }
-        src += "?content=" + md.hash().toString();
-      } else {
-        log.debug("No " + src + " in webapp root; keeping noncache.js URL");
-      }
-    } catch (IOException e) {
-      throw new IOException("Failed reading " + src, e);
-    }
-
-    noCacheName = src;
-    page = new Page();
-  }
-
-  private static int getPluginsLoadTimeout(Config cfg) {
-    long cfgValue =
-        ConfigUtil.getTimeUnit(
-            cfg, "plugins", null, "jsLoadTimeout", DEFAULT_JS_LOAD_TIMEOUT, TimeUnit.MILLISECONDS);
-    if (cfgValue < 0) {
-      return 0;
-    }
-    return (int) cfgValue;
-  }
-
-  private void json(Object data, StringWriter w) {
-    JsonServlet.defaultGsonBuilder().create().toJson(data, w);
-  }
-
-  private Page get() {
-    Page p = page;
-    try {
-      if (refreshHeaderFooter && p.isStale()) {
-        p = new Page();
-        page = p;
-      }
-    } catch (IOException e) {
-      log.error("Cannot refresh site header/footer", e);
-    }
-    return p;
-  }
-
-  @Override
-  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-    Page.Content page = select(req);
-    StringWriter w = new StringWriter();
-    CurrentUser user = currentUser.get();
-    if (user.isIdentifiedUser()) {
-      w.write(HPD_ID + ".accountDiffPref=");
-      json(getDiffPreferences(user.asIdentifiedUser()), w);
-      w.write(";");
-
-      w.write(HPD_ID + ".theme=");
-      json(signedInTheme, w);
-      w.write(";");
-    } else {
-      w.write(HPD_ID + ".theme=");
-      json(signedOutTheme, w);
-      w.write(";");
-    }
-    plugins(w);
-    messages(w);
-
-    byte[] hpd = w.toString().getBytes(UTF_8);
-    byte[] raw = Bytes.concat(page.part1, hpd, page.part2);
-    byte[] tosend;
-    if (RPCServletUtils.acceptsGzipEncoding(req)) {
-      rsp.setHeader("Content-Encoding", "gzip");
-      tosend = HtmlDomUtil.compress(raw);
-    } else {
-      tosend = raw;
-    }
-
-    CacheHeaders.setNotCacheable(rsp);
-    rsp.setContentType("text/html");
-    rsp.setCharacterEncoding(HtmlDomUtil.ENC.name());
-    rsp.setContentLength(tosend.length);
-    try (OutputStream out = rsp.getOutputStream()) {
-      out.write(tosend);
-    }
-  }
-
-  private DiffPreferencesInfo getDiffPreferences(IdentifiedUser user) {
-    try {
-      return getDiff.apply(new AccountResource(user));
-    } catch (AuthException | ConfigInvalidException | IOException | PermissionBackendException e) {
-      log.warn("Cannot query account diff preferences", e);
-    }
-    return DiffPreferencesInfo.defaults();
-  }
-
-  private void plugins(StringWriter w) {
-    List<String> urls = new ArrayList<>();
-    for (WebUiPlugin u : plugins) {
-      urls.add(String.format("plugins/%s/%s", u.getPluginName(), u.getJavaScriptResourcePath()));
-    }
-    if (!urls.isEmpty()) {
-      w.write(HPD_ID + ".plugins=");
-      json(urls, w);
-      w.write(";");
-    }
-  }
-
-  private void messages(StringWriter w) {
-    List<HostPageData.Message> list = new ArrayList<>(2);
-    for (MessageOfTheDay motd : messages) {
-      String html = motd.getHtmlMessage();
-      if (!Strings.isNullOrEmpty(html)) {
-        HostPageData.Message m = new HostPageData.Message();
-        m.id = motd.getMessageId();
-        m.redisplay = motd.getRedisplay();
-        m.html = html;
-        list.add(m);
-      }
-    }
-    if (!list.isEmpty()) {
-      w.write(HPD_ID + ".messages=");
-      json(list, w);
-      w.write(";");
-    }
-  }
-
-  private Page.Content select(HttpServletRequest req) {
-    Page pg = get();
-    if ("1".equals(req.getParameter("dbg"))) {
-      return pg.debug;
-    }
-    return pg.opt;
-  }
-
-  private void insertETags(Element e) {
-    if ("img".equalsIgnoreCase(e.getTagName()) || "script".equalsIgnoreCase(e.getTagName())) {
-      String src = e.getAttribute("src");
-      if (src != null && src.startsWith("static/")) {
-        String name = src.substring("static/".length());
-        ResourceServlet.Resource r = staticServlet.getResource(name);
-        if (r != null) {
-          e.setAttribute("src", src + "?e=" + r.etag);
-        }
-      }
-    }
-
-    for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling()) {
-      if (n instanceof Element) {
-        insertETags((Element) n);
-      }
-    }
-  }
-
-  private static class FileInfo {
-    private final Path path;
-    private final long time;
-
-    FileInfo(Path p) {
-      path = p;
-      time = lastModified(path);
-    }
-
-    boolean isStale() {
-      return time != lastModified(path);
-    }
-  }
-
-  private class Page {
-    private final FileInfo css;
-    private final FileInfo header;
-    private final FileInfo footer;
-    private final Content opt;
-    private final Content debug;
-
-    Page() throws IOException {
-      Document hostDoc = HtmlDomUtil.clone(template);
-
-      css = injectCssFile(hostDoc, "gerrit_sitecss", site.site_css);
-      header = injectXmlFile(hostDoc, "gerrit_header", site.site_header);
-      footer = injectXmlFile(hostDoc, "gerrit_footer", site.site_footer);
-
-      HostPageData pageData = new HostPageData();
-      pageData.version = Version.getVersion();
-      pageData.isNoteDbEnabled = isNoteDbEnabled;
-      pageData.pluginsLoadTimeout = pluginsLoadTimeout;
-      pageData.canLoadInIFrame = canLoadInIFrame;
-
-      StringWriter w = new StringWriter();
-      w.write("var " + HPD_ID + "=");
-      json(pageData, w);
-      w.write(";");
-
-      Element data = HtmlDomUtil.find(hostDoc, HPD_ID);
-      asScript(data);
-      data.appendChild(hostDoc.createTextNode(w.toString()));
-      data.appendChild(hostDoc.createComment(HPD_ID));
-
-      Element nocache = HtmlDomUtil.find(hostDoc, "gerrit_module");
-      asScript(nocache);
-      nocache.removeAttribute("id");
-      nocache.setAttribute("src", noCacheName);
-      opt = new Content(hostDoc);
-
-      nocache.setAttribute("src", "gerrit_ui/dbg_gerrit_ui.nocache.js");
-      debug = new Content(hostDoc);
-    }
-
-    boolean isStale() {
-      return css.isStale() || header.isStale() || footer.isStale();
-    }
-
-    private void asScript(Element scriptNode) {
-      scriptNode.setAttribute("type", "text/javascript");
-      scriptNode.setAttribute("language", "javascript");
-    }
-
-    class Content {
-      final byte[] part1;
-      final byte[] part2;
-
-      Content(Document hostDoc) throws IOException {
-        String raw = HtmlDomUtil.toString(hostDoc);
-        int p = raw.indexOf("<!--" + HPD_ID);
-        if (p < 0) {
-          throw new IOException("No tag in transformed host page HTML");
-        }
-        part1 = raw.substring(0, p).getBytes(UTF_8);
-        part2 = raw.substring(raw.indexOf('>', p) + 1).getBytes(UTF_8);
-      }
-    }
-
-    private FileInfo injectCssFile(Document hostDoc, String id, Path src) throws IOException {
-      FileInfo info = new FileInfo(src);
-      Element banner = HtmlDomUtil.find(hostDoc, id);
-      if (banner == null) {
-        return info;
-      }
-
-      while (banner.getFirstChild() != null) {
-        banner.removeChild(banner.getFirstChild());
-      }
-
-      String css = HtmlDomUtil.readFile(src.getParent(), src.getFileName().toString());
-      if (css == null) {
-        return info;
-      }
-
-      banner.appendChild(hostDoc.createCDATASection("\n" + css + "\n"));
-      return info;
-    }
-
-    private FileInfo injectXmlFile(Document hostDoc, String id, Path src) throws IOException {
-      FileInfo info = new FileInfo(src);
-      Element banner = HtmlDomUtil.find(hostDoc, id);
-      if (banner == null) {
-        return info;
-      }
-
-      while (banner.getFirstChild() != null) {
-        banner.removeChild(banner.getFirstChild());
-      }
-
-      Document html = HtmlDomUtil.parseFile(src);
-      if (html == null) {
-        return info;
-      }
-
-      Element content = html.getDocumentElement();
-      insertETags(content);
-      banner.appendChild(hostDoc.importNode(content, true));
-      return info;
-    }
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/IndexServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/IndexServlet.java
deleted file mode 100644
index db0212e..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static javax.servlet.http.HttpServletResponse.SC_OK;
-
-import com.google.common.base.Strings;
-import com.google.common.io.Resources;
-import com.google.gerrit.common.Nullable;
-import com.google.template.soy.SoyFileSet;
-import com.google.template.soy.data.SanitizedContent;
-import com.google.template.soy.data.SoyMapData;
-import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
-import com.google.template.soy.tofu.SoyTofu;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.net.URI;
-import java.net.URISyntaxException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-public class IndexServlet extends HttpServlet {
-  private static final long serialVersionUID = 1L;
-  protected final byte[] indexSource;
-
-  IndexServlet(String canonicalURL, @Nullable String cdnPath) throws URISyntaxException {
-    String resourcePath = "com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy";
-    SoyFileSet.Builder builder = SoyFileSet.builder();
-    builder.add(Resources.getResource(resourcePath));
-    SoyTofu.Renderer renderer =
-        builder
-            .build()
-            .compileToTofu()
-            .newRenderer("com.google.gerrit.httpd.raw.Index")
-            .setContentKind(SanitizedContent.ContentKind.HTML)
-            .setData(getTemplateData(canonicalURL, cdnPath));
-    indexSource = renderer.render().getBytes(UTF_8);
-  }
-
-  @Override
-  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-    rsp.setCharacterEncoding(UTF_8.name());
-    rsp.setContentType("text/html");
-    rsp.setStatus(SC_OK);
-    try (OutputStream w = rsp.getOutputStream()) {
-      w.write(indexSource);
-    }
-  }
-
-  static String computeCanonicalPath(String canonicalURL) throws URISyntaxException {
-    if (Strings.isNullOrEmpty(canonicalURL)) {
-      return "";
-    }
-
-    // If we serving from a sub-directory rather than root, determine the path
-    // from the cannonical web URL.
-    URI uri = new URI(canonicalURL);
-    return uri.getPath().replaceAll("/$", "");
-  }
-
-  static SoyMapData getTemplateData(String canonicalURL, String cdnPath) throws URISyntaxException {
-    String canonicalPath = computeCanonicalPath(canonicalURL);
-
-    String staticPath = "";
-    if (cdnPath != null) {
-      staticPath = cdnPath;
-    } else if (canonicalPath != null) {
-      staticPath = canonicalPath;
-    }
-
-    // The resource path must be typed as safe for use in a script src.
-    // TODO(wyatta): Upgrade this to use an appropriate safe URL type.
-    SanitizedContent sanitizedStaticPath =
-        UnsafeSanitizedContentOrdainer.ordainAsSafe(
-            staticPath, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
-
-    return new SoyMapData(
-        "canonicalPath", canonicalPath,
-        "staticResourcePath", sanitizedStaticPath);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java
deleted file mode 100644
index 87cb328..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ /dev/null
@@ -1,600 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-import static java.nio.file.Files.exists;
-import static java.nio.file.Files.isReadable;
-
-import com.google.common.cache.Cache;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.client.UiType;
-import com.google.gerrit.httpd.XsrfCookieFilter;
-import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
-import com.google.gerrit.launcher.GerritLauncher;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.GerritOptions;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import com.google.inject.Key;
-import com.google.inject.Provides;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import com.google.inject.name.Named;
-import com.google.inject.name.Names;
-import com.google.inject.servlet.ServletModule;
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.net.URISyntaxException;
-import java.nio.file.FileSystem;
-import java.nio.file.Path;
-import javax.servlet.Filter;
-import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
-import javax.servlet.http.Cookie;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletRequestWrapper;
-import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class StaticModule extends ServletModule {
-  private static final Logger log = LoggerFactory.getLogger(StaticModule.class);
-
-  public static final String CACHE = "static_content";
-  public static final String GERRIT_UI_COOKIE = "GERRIT_UI";
-
-  /**
-   * Paths at which we should serve the main PolyGerrit application {@code index.html}.
-   *
-   * <p>Supports {@code "/*"} as a trailing wildcard.
-   */
-  public static final ImmutableList<String> POLYGERRIT_INDEX_PATHS =
-      ImmutableList.of("/", "/c/*", "/q/*", "/x/*", "/admin/*", "/dashboard/*", "/settings/*");
-  // TODO(dborowitz): These fragments conflict with the REST API
-  // namespace, so they will need to use a different path.
-  //"/groups/*",
-  //"/projects/*");
-  //
-
-  /**
-   * Paths that should be treated as static assets when serving PolyGerrit.
-   *
-   * <p>Supports {@code "/*"} as a trailing wildcard.
-   */
-  private static final ImmutableList<String> POLYGERRIT_ASSET_PATHS =
-      ImmutableList.of(
-          "/behaviors/*",
-          "/bower_components/*",
-          "/elements/*",
-          "/fonts/*",
-          "/scripts/*",
-          "/styles/*");
-
-  private static final String DOC_SERVLET = "DocServlet";
-  private static final String FAVICON_SERVLET = "FaviconServlet";
-  private static final String GWT_UI_SERVLET = "GwtUiServlet";
-  private static final String POLYGERRIT_INDEX_SERVLET = "PolyGerritUiIndexServlet";
-  private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet";
-
-  private static final int GERRIT_UI_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
-
-  private final GerritOptions options;
-  private Paths paths;
-
-  @Inject
-  public StaticModule(GerritOptions options) {
-    this.options = options;
-  }
-
-  @Provides
-  @Singleton
-  private Paths getPaths() {
-    if (paths == null) {
-      paths = new Paths(options);
-    }
-    return paths;
-  }
-
-  @Override
-  protected void configureServlets() {
-    serveRegex("^/Documentation/(.+)$").with(named(DOC_SERVLET));
-    serve("/static/*").with(SiteStaticDirectoryServlet.class);
-    install(
-        new CacheModule() {
-          @Override
-          protected void configure() {
-            cache(CACHE, Path.class, Resource.class)
-                .maximumWeight(1 << 20)
-                .weigher(ResourceServlet.Weigher.class);
-          }
-        });
-    if (!options.headless()) {
-      install(new CoreStaticModule());
-    }
-    if (options.enablePolyGerrit()) {
-      install(new PolyGerritModule());
-    }
-    if (options.enableGwtUi()) {
-      install(new GwtUiModule());
-    }
-  }
-
-  @Provides
-  @Singleton
-  @Named(DOC_SERVLET)
-  HttpServlet getDocServlet(@Named(CACHE) Cache<Path, Resource> cache) {
-    Paths p = getPaths();
-    if (p.warFs != null) {
-      return new WarDocServlet(cache, p.warFs);
-    } else if (p.unpackedWar != null && !p.isDev()) {
-      return new DirectoryDocServlet(cache, p.unpackedWar);
-    } else {
-      return new HttpServlet() {
-        private static final long serialVersionUID = 1L;
-
-        @Override
-        protected void service(HttpServletRequest req, HttpServletResponse resp)
-            throws IOException {
-          resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-        }
-      };
-    }
-  }
-
-  private class CoreStaticModule extends ServletModule {
-    @Override
-    public void configureServlets() {
-      serve("/robots.txt").with(named(ROBOTS_TXT_SERVLET));
-      serve("/favicon.ico").with(named(FAVICON_SERVLET));
-    }
-
-    @Provides
-    @Singleton
-    @Named(ROBOTS_TXT_SERVLET)
-    HttpServlet getRobotsTxtServlet(
-        @GerritServerConfig Config cfg,
-        SitePaths sitePaths,
-        @Named(CACHE) Cache<Path, Resource> cache) {
-      Path configPath = sitePaths.resolve(cfg.getString("httpd", null, "robotsFile"));
-      if (configPath != null) {
-        if (exists(configPath) && isReadable(configPath)) {
-          return new SingleFileServlet(cache, configPath, true);
-        }
-        log.warn("Cannot read httpd.robotsFile, using default");
-      }
-      Paths p = getPaths();
-      if (p.warFs != null) {
-        return new SingleFileServlet(cache, p.warFs.getPath("/robots.txt"), false);
-      }
-      return new SingleFileServlet(cache, webappSourcePath("robots.txt"), true);
-    }
-
-    @Provides
-    @Singleton
-    @Named(FAVICON_SERVLET)
-    HttpServlet getFaviconServlet(@Named(CACHE) Cache<Path, Resource> cache) {
-      Paths p = getPaths();
-      if (p.warFs != null) {
-        return new SingleFileServlet(cache, p.warFs.getPath("/favicon.ico"), false);
-      }
-      return new SingleFileServlet(cache, webappSourcePath("favicon.ico"), true);
-    }
-
-    private Path webappSourcePath(String name) {
-      Paths p = getPaths();
-      if (p.unpackedWar != null) {
-        return p.unpackedWar.resolve(name);
-      }
-      return p.sourceRoot.resolve("gerrit-war/src/main/webapp/" + name);
-    }
-  }
-
-  private class GwtUiModule extends ServletModule {
-    @Override
-    public void configureServlets() {
-      serveRegex("^/gerrit_ui/(?!rpc/)(.*)$")
-          .with(Key.get(HttpServlet.class, Names.named(GWT_UI_SERVLET)));
-      Paths p = getPaths();
-      if (p.isDev()) {
-        filter("/").through(new RecompileGwtUiFilter(p.builder, p.unpackedWar));
-      }
-    }
-
-    @Provides
-    @Singleton
-    @Named(GWT_UI_SERVLET)
-    HttpServlet getGwtUiServlet(@Named(CACHE) Cache<Path, Resource> cache) throws IOException {
-      Paths p = getPaths();
-      if (p.warFs != null) {
-        return new WarGwtUiServlet(cache, p.warFs);
-      }
-      return new DirectoryGwtUiServlet(cache, p.unpackedWar, p.isDev());
-    }
-  }
-
-  private class PolyGerritModule extends ServletModule {
-    @Override
-    public void configureServlets() {
-      for (String p : POLYGERRIT_INDEX_PATHS) {
-        // Skip XsrfCookieFilter for /, since that is already done in the GWT UI
-        // path (UrlModule).
-        if (!p.equals("/")) {
-          filter(p).through(XsrfCookieFilter.class);
-        }
-      }
-      filter("/*").through(PolyGerritFilter.class);
-    }
-
-    @Provides
-    @Singleton
-    @Named(POLYGERRIT_INDEX_SERVLET)
-    HttpServlet getPolyGerritUiIndexServlet(
-        @CanonicalWebUrl @Nullable String canonicalUrl, @GerritServerConfig Config cfg)
-        throws URISyntaxException {
-      String cdnPath = cfg.getString("gerrit", null, "cdnPath");
-      return new IndexServlet(canonicalUrl, cdnPath);
-    }
-
-    @Provides
-    @Singleton
-    PolyGerritUiServlet getPolyGerritUiServlet(@Named(CACHE) Cache<Path, Resource> cache) {
-      return new PolyGerritUiServlet(cache, polyGerritBasePath());
-    }
-
-    @Provides
-    @Singleton
-    BowerComponentsDevServlet getBowerComponentsServlet(@Named(CACHE) Cache<Path, Resource> cache)
-        throws IOException {
-      return getPaths().isDev() ? new BowerComponentsDevServlet(cache, getPaths().builder) : null;
-    }
-
-    @Provides
-    @Singleton
-    FontsDevServlet getFontsServlet(@Named(CACHE) Cache<Path, Resource> cache) throws IOException {
-      return getPaths().isDev() ? new FontsDevServlet(cache, getPaths().builder) : null;
-    }
-
-    private Path polyGerritBasePath() {
-      Paths p = getPaths();
-      if (options.forcePolyGerritDev()) {
-        checkArgument(
-            p.sourceRoot != null, "no source root directory found for PolyGerrit developer mode");
-      }
-
-      if (p.isDev()) {
-        return p.sourceRoot.resolve("polygerrit-ui").resolve("app");
-      }
-
-      return p.warFs != null
-          ? p.warFs.getPath("/polygerrit_ui")
-          : p.unpackedWar.resolve("polygerrit_ui");
-    }
-  }
-
-  private static class Paths {
-    private final FileSystem warFs;
-    private final BazelBuild builder;
-    private final Path sourceRoot;
-    private final Path unpackedWar;
-    private final boolean development;
-
-    private Paths(GerritOptions options) {
-      try {
-        File launcherLoadedFrom = getLauncherLoadedFrom();
-        if (launcherLoadedFrom != null && launcherLoadedFrom.getName().endsWith(".jar")) {
-          // Special case: unpacked war archive deployed in container.
-          // The path is something like:
-          // <container>/<gerrit>/WEB-INF/lib/launcher.jar
-          // Switch to exploded war case with <container>/webapp>/<gerrit>
-          // root directory
-          warFs = null;
-          unpackedWar =
-              java.nio.file.Paths.get(
-                  launcherLoadedFrom.getParentFile().getParentFile().getParentFile().toURI());
-          sourceRoot = null;
-          development = false;
-          builder = null;
-          return;
-        }
-        warFs = getDistributionArchive(launcherLoadedFrom);
-        if (warFs == null) {
-          unpackedWar = makeWarTempDir();
-          development = true;
-        } else if (options.forcePolyGerritDev()) {
-          unpackedWar = null;
-          development = true;
-        } else {
-          unpackedWar = null;
-          development = false;
-          sourceRoot = null;
-          builder = null;
-          return;
-        }
-      } catch (IOException e) {
-        throw new ProvisionException("Error initializing static content paths", e);
-      }
-
-      sourceRoot = getSourceRootOrNull();
-      builder = new BazelBuild(sourceRoot);
-    }
-
-    private static Path getSourceRootOrNull() {
-      try {
-        return GerritLauncher.resolveInSourceRoot(".");
-      } catch (FileNotFoundException e) {
-        return null;
-      }
-    }
-
-    private FileSystem getDistributionArchive(File war) throws IOException {
-      if (war == null) {
-        return null;
-      }
-      return GerritLauncher.getZipFileSystem(war.toPath());
-    }
-
-    private File getLauncherLoadedFrom() {
-      File war;
-      try {
-        war = GerritLauncher.getDistributionArchive();
-      } catch (IOException e) {
-        if ((e instanceof FileNotFoundException)
-            && GerritLauncher.NOT_ARCHIVED.equals(e.getMessage())) {
-          return null;
-        }
-        ProvisionException pe = new ProvisionException("Error reading gerrit.war");
-        pe.initCause(e);
-        throw pe;
-      }
-      return war;
-    }
-
-    private boolean isDev() {
-      return development;
-    }
-
-    private Path makeWarTempDir() {
-      // Obtain our local temporary directory, but it comes back as a file
-      // so we have to switch it to be a directory post creation.
-      //
-      try {
-        File dstwar = GerritLauncher.createTempFile("gerrit_", "war");
-        if (!dstwar.delete() || !dstwar.mkdir()) {
-          throw new IOException("Cannot mkdir " + dstwar.getAbsolutePath());
-        }
-
-        // Jetty normally refuses to serve out of a symlinked directory, as
-        // a security feature. Try to resolve out any symlinks in the path.
-        //
-        try {
-          return dstwar.getCanonicalFile().toPath();
-        } catch (IOException e) {
-          return dstwar.getAbsoluteFile().toPath();
-        }
-      } catch (IOException e) {
-        ProvisionException pe = new ProvisionException("Cannot create war tempdir");
-        pe.initCause(e);
-        throw pe;
-      }
-    }
-  }
-
-  private static Key<HttpServlet> named(String name) {
-    return Key.get(HttpServlet.class, Names.named(name));
-  }
-
-  @Singleton
-  private static class PolyGerritFilter implements Filter {
-    private final GerritOptions options;
-    private final Paths paths;
-    private final HttpServlet polyGerritIndex;
-    private final PolyGerritUiServlet polygerritUI;
-    private final BowerComponentsDevServlet bowerComponentServlet;
-    private final FontsDevServlet fontServlet;
-
-    @Inject
-    PolyGerritFilter(
-        GerritOptions options,
-        Paths paths,
-        @Named(POLYGERRIT_INDEX_SERVLET) HttpServlet polyGerritIndex,
-        PolyGerritUiServlet polygerritUI,
-        @Nullable BowerComponentsDevServlet bowerComponentServlet,
-        @Nullable FontsDevServlet fontServlet) {
-      this.paths = paths;
-      this.options = options;
-      this.polyGerritIndex = polyGerritIndex;
-      this.polygerritUI = polygerritUI;
-      this.bowerComponentServlet = bowerComponentServlet;
-      this.fontServlet = fontServlet;
-      checkState(
-          options.enablePolyGerrit(), "can't install PolyGerritFilter when PolyGerrit is disabled");
-    }
-
-    @Override
-    public void init(FilterConfig filterConfig) throws ServletException {}
-
-    @Override
-    public void destroy() {}
-
-    @Override
-    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
-        throws IOException, ServletException {
-      HttpServletRequest req = (HttpServletRequest) request;
-      HttpServletResponse res = (HttpServletResponse) response;
-      if (handlePolyGerritParam(req, res)) {
-        return;
-      }
-      if (!isPolyGerritEnabled(req)) {
-        chain.doFilter(req, res);
-        return;
-      }
-
-      GuiceFilterRequestWrapper reqWrapper = new GuiceFilterRequestWrapper(req);
-      String path = pathInfo(req);
-
-      // Special case assets during development that are built by Buck and not
-      // served out of the source tree.
-      //
-      // In the war case, these are either inlined by vulcanize, or live under
-      // /polygerrit_ui in the war file, so we can just treat them as normal
-      // assets.
-      if (paths.isDev()) {
-        if (path.startsWith("/bower_components/")) {
-          bowerComponentServlet.service(reqWrapper, res);
-          return;
-        } else if (path.startsWith("/fonts/")) {
-          fontServlet.service(reqWrapper, res);
-          return;
-        }
-      }
-
-      if (isPolyGerritIndex(path)) {
-        polyGerritIndex.service(reqWrapper, res);
-        return;
-      }
-      if (isPolyGerritAsset(path)) {
-        polygerritUI.service(reqWrapper, res);
-        return;
-      }
-
-      chain.doFilter(req, res);
-    }
-
-    private static String pathInfo(HttpServletRequest req) {
-      String uri = req.getRequestURI();
-      String ctx = req.getContextPath();
-      return uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri;
-    }
-
-    private boolean handlePolyGerritParam(HttpServletRequest req, HttpServletResponse res)
-        throws IOException {
-      if (!options.enableGwtUi() || !"GET".equals(req.getMethod())) {
-        return false;
-      }
-      boolean redirect = false;
-      String param = req.getParameter("polygerrit");
-      if ("1".equals(param)) {
-        setPolyGerritCookie(req, res, UiType.POLYGERRIT);
-        redirect = true;
-      } else if ("0".equals(param)) {
-        setPolyGerritCookie(req, res, UiType.GWT);
-        redirect = true;
-      }
-      if (redirect) {
-        // Strip polygerrit param from URL. This actually strips all params,
-        // which is a similar behavior to the JS PolyGerrit redirector code.
-        // Stripping just one param is frustratingly difficult without the use
-        // of Apache httpclient, which is a dep we don't want here:
-        // https://gerrit-review.googlesource.com/#/c/57570/57/gerrit-httpd/BUCK@32
-        res.sendRedirect(req.getRequestURL().toString());
-      }
-      return redirect;
-    }
-
-    private boolean isPolyGerritEnabled(HttpServletRequest req) {
-      return !options.enableGwtUi() || isPolyGerritCookie(req);
-    }
-
-    private boolean isPolyGerritCookie(HttpServletRequest req) {
-      UiType type = options.defaultUi();
-      Cookie[] all = req.getCookies();
-      if (all != null) {
-        for (Cookie c : all) {
-          if (GERRIT_UI_COOKIE.equals(c.getName())) {
-            UiType t = UiType.parse(c.getValue());
-            if (t != null) {
-              type = t;
-              break;
-            }
-          }
-        }
-      }
-      return type == UiType.POLYGERRIT;
-    }
-
-    private void setPolyGerritCookie(HttpServletRequest req, HttpServletResponse res, UiType pref) {
-      // Only actually set a cookie if both UIs are enabled in the server;
-      // otherwise clear it.
-      Cookie cookie = new Cookie(GERRIT_UI_COOKIE, pref.name());
-      if (options.enablePolyGerrit() && options.enableGwtUi()) {
-        cookie.setPath("/");
-        cookie.setSecure(isSecure(req));
-        cookie.setMaxAge(GERRIT_UI_COOKIE_MAX_AGE);
-      } else {
-        cookie.setValue("");
-        cookie.setMaxAge(0);
-      }
-      res.addCookie(cookie);
-    }
-
-    private static boolean isSecure(HttpServletRequest req) {
-      return req.isSecure() || "https".equals(req.getScheme());
-    }
-
-    private static boolean isPolyGerritAsset(String path) {
-      return matchPath(POLYGERRIT_ASSET_PATHS, path);
-    }
-
-    private static boolean isPolyGerritIndex(String path) {
-      return matchPath(POLYGERRIT_INDEX_PATHS, path);
-    }
-
-    private static boolean matchPath(Iterable<String> paths, String path) {
-      for (String p : paths) {
-        if (p.endsWith("/*")) {
-          if (path.regionMatches(0, p, 0, p.length() - 1)) {
-            return true;
-          }
-        } else if (p.equals(path)) {
-          return true;
-        }
-      }
-      return false;
-    }
-  }
-
-  private static class GuiceFilterRequestWrapper extends HttpServletRequestWrapper {
-    GuiceFilterRequestWrapper(HttpServletRequest req) {
-      super(req);
-    }
-
-    @Override
-    public String getPathInfo() {
-      String uri = getRequestURI();
-      String ctx = getContextPath();
-      // This is a workaround for long standing guice filter bug:
-      // https://github.com/google/guice/issues/807
-      String res = uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri;
-
-      // Match the logic in the ResourceServlet, that re-add "/"
-      // for null path info
-      if ("/".equals(res)) {
-        return null;
-      }
-      return res;
-    }
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccessRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccessRestApiServlet.java
deleted file mode 100644
index 0d1e53c..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccessRestApiServlet.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.restapi;
-
-import com.google.gerrit.server.access.AccessCollection;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class AccessRestApiServlet extends RestApiServlet {
-  private static final long serialVersionUID = 1L;
-
-  @Inject
-  AccessRestApiServlet(RestApiServlet.Globals globals, Provider<AccessCollection> access) {
-    super(globals, access);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccountsRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccountsRestApiServlet.java
deleted file mode 100644
index ee57000..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccountsRestApiServlet.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.restapi;
-
-import com.google.gerrit.server.account.AccountsCollection;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class AccountsRestApiServlet extends RestApiServlet {
-  private static final long serialVersionUID = 1L;
-
-  @Inject
-  AccountsRestApiServlet(RestApiServlet.Globals globals, Provider<AccountsCollection> accounts) {
-    super(globals, accounts);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ChangesRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ChangesRestApiServlet.java
deleted file mode 100644
index ccafc6d..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ChangesRestApiServlet.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.restapi;
-
-import com.google.gerrit.server.change.ChangesCollection;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class ChangesRestApiServlet extends RestApiServlet {
-  private static final long serialVersionUID = 1L;
-
-  @Inject
-  ChangesRestApiServlet(RestApiServlet.Globals globals, Provider<ChangesCollection> changes) {
-    super(globals, changes);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java
deleted file mode 100644
index 87df4cf..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.restapi;
-
-import com.google.gerrit.server.config.ConfigCollection;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class ConfigRestApiServlet extends RestApiServlet {
-  private static final long serialVersionUID = 1L;
-
-  @Inject
-  ConfigRestApiServlet(
-      RestApiServlet.Globals globals, Provider<ConfigCollection> configCollection) {
-    super(globals, configCollection);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/GroupsRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/GroupsRestApiServlet.java
deleted file mode 100644
index 5c7502f..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/GroupsRestApiServlet.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.restapi;
-
-import com.google.gerrit.server.group.GroupsCollection;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GroupsRestApiServlet extends RestApiServlet {
-  private static final long serialVersionUID = 1L;
-
-  @Inject
-  GroupsRestApiServlet(RestApiServlet.Globals globals, Provider<GroupsCollection> groups) {
-    super(globals, groups);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ProjectsRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ProjectsRestApiServlet.java
deleted file mode 100644
index f34608a..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ProjectsRestApiServlet.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.restapi;
-
-import com.google.gerrit.server.project.ProjectsCollection;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class ProjectsRestApiServlet extends RestApiServlet {
-  private static final long serialVersionUID = 1L;
-
-  @Inject
-  ProjectsRestApiServlet(RestApiServlet.Globals globals, Provider<ProjectsCollection> projects) {
-    super(globals, projects);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
deleted file mode 100644
index d1e4e889..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ /dev/null
@@ -1,1313 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-// WARNING: NoteDbUpdateManager cares about the package name RestApiServlet lives in.
-package com.google.gerrit.httpd.restapi;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_MAX_AGE;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
-import static com.google.common.net.HttpHeaders.AUTHORIZATION;
-import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
-import static com.google.common.net.HttpHeaders.ORIGIN;
-import static com.google.common.net.HttpHeaders.VARY;
-import static java.math.RoundingMode.CEILING;
-import static java.nio.charset.StandardCharsets.ISO_8859_1;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.joining;
-import static javax.servlet.http.HttpServletResponse.SC_ACCEPTED;
-import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
-import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
-import static javax.servlet.http.HttpServletResponse.SC_CREATED;
-import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
-import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
-import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
-import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
-import static javax.servlet.http.HttpServletResponse.SC_OK;
-import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Joiner;
-import com.google.common.base.Splitter;
-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.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.io.BaseEncoding;
-import com.google.common.io.CountingOutputStream;
-import com.google.common.math.IntMath;
-import com.google.common.net.HttpHeaders;
-import com.google.gerrit.audit.AuditService;
-import com.google.gerrit.audit.ExtendedHttpAuditEvent;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AcceptsDelete;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.CacheControl;
-import com.google.gerrit.extensions.restapi.DefaultInput;
-import com.google.gerrit.extensions.restapi.ETagView;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.NeedsParams;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.PreconditionFailedException;
-import com.google.gerrit.extensions.restapi.RawInput;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestCollection;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.httpd.restapi.ParameterParser.QueryParams;
-import com.google.gerrit.server.AccessPath;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.OptionUtil;
-import com.google.gerrit.server.OutputFormat;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.util.http.RequestUtil;
-import com.google.gson.ExclusionStrategy;
-import com.google.gson.FieldAttributes;
-import com.google.gson.FieldNamingPolicy;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonParseException;
-import com.google.gson.JsonPrimitive;
-import com.google.gson.stream.JsonReader;
-import com.google.gson.stream.JsonToken;
-import com.google.gson.stream.JsonWriter;
-import com.google.gson.stream.MalformedJsonException;
-import com.google.gwtexpui.server.CacheHeaders;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.TypeLiteral;
-import com.google.inject.util.Providers;
-import java.io.BufferedReader;
-import java.io.BufferedWriter;
-import java.io.EOFException;
-import java.io.FilterOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.Writer;
-import java.lang.reflect.Constructor;
-import java.lang.reflect.Field;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.ParameterizedType;
-import java.lang.reflect.Type;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
-import java.util.TreeMap;
-import java.util.concurrent.TimeUnit;
-import java.util.regex.Pattern;
-import java.util.stream.Stream;
-import java.util.zip.GZIPOutputStream;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletRequestWrapper;
-import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.http.server.ServletUtils;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.util.TemporaryBuffer;
-import org.eclipse.jgit.util.TemporaryBuffer.Heap;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class RestApiServlet extends HttpServlet {
-  private static final long serialVersionUID = 1L;
-  private static final Logger log = LoggerFactory.getLogger(RestApiServlet.class);
-
-  /** MIME type used for a JSON response body. */
-  private static final String JSON_TYPE = "application/json";
-
-  private static final String FORM_TYPE = "application/x-www-form-urlencoded";
-
-  // HTTP 422 Unprocessable Entity.
-  // TODO: Remove when HttpServletResponse.SC_UNPROCESSABLE_ENTITY is available
-  private static final int SC_UNPROCESSABLE_ENTITY = 422;
-  private static final String X_REQUESTED_WITH = "X-Requested-With";
-  private static final String X_GERRIT_AUTH = "X-Gerrit-Auth";
-  static final ImmutableSet<String> ALLOWED_CORS_METHODS =
-      ImmutableSet.of("GET", "HEAD", "POST", "PUT", "DELETE");
-  private static final ImmutableSet<String> ALLOWED_CORS_REQUEST_HEADERS =
-      Stream.of(AUTHORIZATION, CONTENT_TYPE, X_GERRIT_AUTH, X_REQUESTED_WITH)
-          .map(s -> s.toLowerCase(Locale.US))
-          .collect(ImmutableSet.toImmutableSet());
-
-  public static final String XD_AUTHORIZATION = "access_token";
-  public static final String XD_CONTENT_TYPE = "$ct";
-  public static final String XD_METHOD = "$m";
-
-  private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
-
-  /**
-   * Garbage prefix inserted before JSON output to prevent XSSI.
-   *
-   * <p>This prefix is ")]}'\n" and is designed to prevent a web browser from executing the response
-   * body if the resource URI were to be referenced using a &lt;script src="...&gt; HTML tag from
-   * another web site. Clients using the HTTP interface will need to always strip the first line of
-   * response data to remove this magic header.
-   */
-  public static final byte[] JSON_MAGIC;
-
-  static {
-    JSON_MAGIC = ")]}'\n".getBytes(UTF_8);
-  }
-
-  public static class Globals {
-    final Provider<CurrentUser> currentUser;
-    final DynamicItem<WebSession> webSession;
-    final Provider<ParameterParser> paramParser;
-    final PermissionBackend permissionBackend;
-    final AuditService auditService;
-    final RestApiMetrics metrics;
-    final Pattern allowOrigin;
-
-    @Inject
-    Globals(
-        Provider<CurrentUser> currentUser,
-        DynamicItem<WebSession> webSession,
-        Provider<ParameterParser> paramParser,
-        PermissionBackend permissionBackend,
-        AuditService auditService,
-        RestApiMetrics metrics,
-        @GerritServerConfig Config cfg) {
-      this.currentUser = currentUser;
-      this.webSession = webSession;
-      this.paramParser = paramParser;
-      this.permissionBackend = permissionBackend;
-      this.auditService = auditService;
-      this.metrics = metrics;
-      allowOrigin = makeAllowOrigin(cfg);
-    }
-
-    private static Pattern makeAllowOrigin(Config cfg) {
-      String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
-      if (allow.length > 0) {
-        return Pattern.compile(Joiner.on('|').join(allow));
-      }
-      return null;
-    }
-  }
-
-  private final Globals globals;
-  private final Provider<RestCollection<RestResource, RestResource>> members;
-
-  public RestApiServlet(
-      Globals globals, RestCollection<? extends RestResource, ? extends RestResource> members) {
-    this(globals, Providers.of(members));
-  }
-
-  public RestApiServlet(
-      Globals globals,
-      Provider<? extends RestCollection<? extends RestResource, ? extends RestResource>> members) {
-    @SuppressWarnings("unchecked")
-    Provider<RestCollection<RestResource, RestResource>> n =
-        (Provider<RestCollection<RestResource, RestResource>>) checkNotNull((Object) members);
-    this.globals = globals;
-    this.members = n;
-  }
-
-  @Override
-  protected final void service(HttpServletRequest req, HttpServletResponse res)
-      throws ServletException, IOException {
-    final long startNanos = System.nanoTime();
-    long auditStartTs = TimeUtil.nowMs();
-    res.setHeader("Content-Disposition", "attachment");
-    res.setHeader("X-Content-Type-Options", "nosniff");
-    int status = SC_OK;
-    long responseBytes = -1;
-    Object result = null;
-    QueryParams qp = null;
-    Object inputRequestBody = null;
-    RestResource rsrc = TopLevelResource.INSTANCE;
-    ViewData viewData = null;
-
-    try {
-      if (isCorsPreflight(req)) {
-        doCorsPreflight(req, res);
-        return;
-      }
-
-      qp = ParameterParser.getQueryParams(req);
-      checkCors(req, res, qp.hasXdOverride());
-      if (qp.hasXdOverride()) {
-        req = applyXdOverrides(req, qp);
-      }
-      checkUserSession(req);
-
-      List<IdString> path = splitPath(req);
-      RestCollection<RestResource, RestResource> rc = members.get();
-      globals
-          .permissionBackend
-          .user(globals.currentUser)
-          .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
-
-      viewData = new ViewData(null, null);
-
-      if (path.isEmpty()) {
-        if (rc instanceof NeedsParams) {
-          ((NeedsParams) rc).setParams(qp.params());
-        }
-
-        if (isRead(req)) {
-          viewData = new ViewData(null, rc.list());
-        } else if (rc instanceof AcceptsPost && "POST".equals(req.getMethod())) {
-          @SuppressWarnings("unchecked")
-          AcceptsPost<RestResource> ac = (AcceptsPost<RestResource>) rc;
-          viewData = new ViewData(null, ac.post(rsrc));
-        } else {
-          throw new MethodNotAllowedException();
-        }
-      } else {
-        IdString id = path.remove(0);
-        try {
-          rsrc = rc.parse(rsrc, id);
-          if (path.isEmpty()) {
-            checkPreconditions(req);
-          }
-        } catch (ResourceNotFoundException e) {
-          if (rc instanceof AcceptsCreate
-              && path.isEmpty()
-              && ("POST".equals(req.getMethod()) || "PUT".equals(req.getMethod()))) {
-            @SuppressWarnings("unchecked")
-            AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) rc;
-            viewData = new ViewData(null, ac.create(rsrc, id));
-            status = SC_CREATED;
-          } else {
-            throw e;
-          }
-        }
-        if (viewData.view == null) {
-          viewData = view(rsrc, rc, req.getMethod(), path);
-        }
-      }
-      checkRequiresCapability(viewData);
-
-      while (viewData.view instanceof RestCollection<?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestCollection<RestResource, RestResource> c =
-            (RestCollection<RestResource, RestResource>) viewData.view;
-
-        if (path.isEmpty()) {
-          if (isRead(req)) {
-            viewData = new ViewData(null, c.list());
-          } else if (c instanceof AcceptsPost && "POST".equals(req.getMethod())) {
-            @SuppressWarnings("unchecked")
-            AcceptsPost<RestResource> ac = (AcceptsPost<RestResource>) c;
-            viewData = new ViewData(null, ac.post(rsrc));
-          } else if (c instanceof AcceptsDelete && "DELETE".equals(req.getMethod())) {
-            @SuppressWarnings("unchecked")
-            AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c;
-            viewData = new ViewData(null, ac.delete(rsrc, null));
-          } else {
-            throw new MethodNotAllowedException();
-          }
-          break;
-        }
-        IdString id = path.remove(0);
-        try {
-          rsrc = c.parse(rsrc, id);
-          checkPreconditions(req);
-          viewData = new ViewData(null, null);
-        } catch (ResourceNotFoundException e) {
-          if (c instanceof AcceptsCreate
-              && path.isEmpty()
-              && ("POST".equals(req.getMethod()) || "PUT".equals(req.getMethod()))) {
-            @SuppressWarnings("unchecked")
-            AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) c;
-            viewData = new ViewData(viewData.pluginName, ac.create(rsrc, id));
-            status = SC_CREATED;
-          } else if (c instanceof AcceptsDelete
-              && path.isEmpty()
-              && "DELETE".equals(req.getMethod())) {
-            @SuppressWarnings("unchecked")
-            AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c;
-            viewData = new ViewData(viewData.pluginName, ac.delete(rsrc, id));
-            status = SC_NO_CONTENT;
-          } else {
-            throw e;
-          }
-        }
-        if (viewData.view == null) {
-          viewData = view(rsrc, c, req.getMethod(), path);
-        }
-        checkRequiresCapability(viewData);
-      }
-
-      if (notModified(req, rsrc, viewData.view)) {
-        res.sendError(SC_NOT_MODIFIED);
-        return;
-      }
-
-      if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
-        return;
-      }
-
-      if (viewData.view instanceof RestReadView<?> && isRead(req)) {
-        result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
-      } else if (viewData.view instanceof RestModifyView<?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestModifyView<RestResource, Object> m =
-            (RestModifyView<RestResource, Object>) viewData.view;
-
-        Type type = inputType(m);
-        inputRequestBody = parseRequest(req, type);
-        result = m.apply(rsrc, inputRequestBody);
-        consumeRawInputRequestBody(req, type);
-      } else {
-        throw new ResourceNotFoundException();
-      }
-
-      if (result instanceof Response) {
-        @SuppressWarnings("rawtypes")
-        Response<?> r = (Response) result;
-        status = r.statusCode();
-        configureCaching(req, res, rsrc, viewData.view, r.caching());
-      } else if (result instanceof Response.Redirect) {
-        CacheHeaders.setNotCacheable(res);
-        res.sendRedirect(((Response.Redirect) result).location());
-        return;
-      } else if (result instanceof Response.Accepted) {
-        CacheHeaders.setNotCacheable(res);
-        res.setStatus(SC_ACCEPTED);
-        res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location());
-        return;
-      } else {
-        CacheHeaders.setNotCacheable(res);
-      }
-      res.setStatus(status);
-
-      if (result != Response.none()) {
-        result = Response.unwrap(result);
-        if (result instanceof BinaryResult) {
-          responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
-        } else {
-          responseBytes = replyJson(req, res, qp.config(), result);
-        }
-      }
-    } catch (MalformedJsonException e) {
-      responseBytes =
-          replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
-    } catch (JsonParseException e) {
-      responseBytes =
-          replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
-    } catch (BadRequestException e) {
-      responseBytes =
-          replyError(
-              req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
-    } catch (AuthException e) {
-      responseBytes =
-          replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
-    } catch (AmbiguousViewException e) {
-      responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
-    } catch (ResourceNotFoundException e) {
-      responseBytes =
-          replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e);
-    } catch (MethodNotAllowedException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_METHOD_NOT_ALLOWED,
-              messageOr(e, "Method Not Allowed"),
-              e.caching(),
-              e);
-    } catch (ResourceConflictException e) {
-      responseBytes =
-          replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e);
-    } catch (PreconditionFailedException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_PRECONDITION_FAILED,
-              messageOr(e, "Precondition Failed"),
-              e.caching(),
-              e);
-    } catch (UnprocessableEntityException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_UNPROCESSABLE_ENTITY,
-              messageOr(e, "Unprocessable Entity"),
-              e.caching(),
-              e);
-    } catch (NotImplementedException e) {
-      responseBytes =
-          replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
-    } catch (Exception e) {
-      status = SC_INTERNAL_SERVER_ERROR;
-      responseBytes = handleException(e, req, res);
-    } finally {
-      String metric =
-          viewData != null && viewData.view != null ? globals.metrics.view(viewData) : "_unknown";
-      globals.metrics.count.increment(metric);
-      if (status >= SC_BAD_REQUEST) {
-        globals.metrics.errorCount.increment(metric, status);
-      }
-      if (responseBytes != -1) {
-        globals.metrics.responseBytes.record(metric, responseBytes);
-      }
-      globals.metrics.serverLatency.record(
-          metric, System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
-      globals.auditService.dispatch(
-          new ExtendedHttpAuditEvent(
-              globals.webSession.get().getSessionId(),
-              globals.currentUser.get(),
-              req,
-              auditStartTs,
-              qp != null ? qp.params() : ImmutableListMultimap.of(),
-              inputRequestBody,
-              status,
-              result,
-              rsrc,
-              viewData == null ? null : viewData.view));
-    }
-  }
-
-  private static HttpServletRequest applyXdOverrides(HttpServletRequest req, QueryParams qp)
-      throws BadRequestException {
-    if (!"POST".equals(req.getMethod())) {
-      throw new BadRequestException("POST required");
-    }
-
-    String method = qp.xdMethod();
-    String contentType = qp.xdContentType();
-    if (method.equals("POST") || method.equals("PUT")) {
-      if (!"text/plain".equals(req.getContentType())) {
-        throw new BadRequestException("invalid " + CONTENT_TYPE);
-      } else if (Strings.isNullOrEmpty(contentType)) {
-        throw new BadRequestException(XD_CONTENT_TYPE + " required");
-      }
-    }
-
-    return new HttpServletRequestWrapper(req) {
-      @Override
-      public String getMethod() {
-        return method;
-      }
-
-      @Override
-      public String getContentType() {
-        return contentType;
-      }
-    };
-  }
-
-  private void checkCors(HttpServletRequest req, HttpServletResponse res, boolean isXd)
-      throws BadRequestException {
-    String origin = req.getHeader(ORIGIN);
-    if (isXd) {
-      // Cross-domain, non-preflighted requests must come from an approved origin.
-      if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
-        throw new BadRequestException("origin not allowed");
-      }
-      res.addHeader(VARY, ORIGIN);
-      res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
-      res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
-    } else if (!Strings.isNullOrEmpty(origin)) {
-      // All other requests must be processed, but conditionally set CORS headers.
-      if (globals.allowOrigin != null) {
-        res.addHeader(VARY, ORIGIN);
-      }
-      if (isOriginAllowed(origin)) {
-        setCorsHeaders(res, origin);
-      }
-    }
-  }
-
-  private static boolean isCorsPreflight(HttpServletRequest req) {
-    return "OPTIONS".equals(req.getMethod())
-        && !Strings.isNullOrEmpty(req.getHeader(ORIGIN))
-        && !Strings.isNullOrEmpty(req.getHeader(ACCESS_CONTROL_REQUEST_METHOD));
-  }
-
-  private void doCorsPreflight(HttpServletRequest req, HttpServletResponse res)
-      throws BadRequestException {
-    CacheHeaders.setNotCacheable(res);
-    setHeaderList(
-        res,
-        VARY,
-        ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS));
-
-    String origin = req.getHeader(ORIGIN);
-    if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
-      throw new BadRequestException("CORS not allowed");
-    }
-
-    String method = req.getHeader(ACCESS_CONTROL_REQUEST_METHOD);
-    if (!ALLOWED_CORS_METHODS.contains(method)) {
-      throw new BadRequestException(method + " not allowed in CORS");
-    }
-
-    String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS);
-    if (headers != null) {
-      for (String reqHdr : Splitter.on(',').trimResults().split(headers)) {
-        if (!ALLOWED_CORS_REQUEST_HEADERS.contains(reqHdr.toLowerCase(Locale.US))) {
-          throw new BadRequestException(reqHdr + " not allowed in CORS");
-        }
-      }
-    }
-
-    res.setStatus(SC_OK);
-    setCorsHeaders(res, origin);
-    res.setContentType("text/plain");
-    res.setContentLength(0);
-  }
-
-  private static void setCorsHeaders(HttpServletResponse res, String origin) {
-    res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
-    res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
-    res.setHeader(ACCESS_CONTROL_MAX_AGE, "600");
-    setHeaderList(
-        res,
-        ACCESS_CONTROL_ALLOW_METHODS,
-        Iterables.concat(ALLOWED_CORS_METHODS, ImmutableList.of("OPTIONS")));
-    setHeaderList(res, ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_REQUEST_HEADERS);
-  }
-
-  private static void setHeaderList(HttpServletResponse res, String name, Iterable<String> values) {
-    res.setHeader(name, Joiner.on(", ").join(values));
-  }
-
-  private boolean isOriginAllowed(String origin) {
-    return globals.allowOrigin != null && globals.allowOrigin.matcher(origin).matches();
-  }
-
-  private static String messageOr(Throwable t, String defaultMessage) {
-    if (!Strings.isNullOrEmpty(t.getMessage())) {
-      return t.getMessage();
-    }
-    return defaultMessage;
-  }
-
-  @SuppressWarnings({"unchecked", "rawtypes"})
-  private static boolean notModified(
-      HttpServletRequest req, RestResource rsrc, RestView<RestResource> view) {
-    if (!isRead(req)) {
-      return false;
-    }
-
-    if (view instanceof ETagView) {
-      String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
-      if (have != null) {
-        return have.equals(((ETagView) view).getETag(rsrc));
-      }
-    }
-
-    if (rsrc instanceof RestResource.HasETag) {
-      String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
-      if (have != null) {
-        return have.equals(((RestResource.HasETag) rsrc).getETag());
-      }
-    }
-
-    if (rsrc instanceof RestResource.HasLastModified) {
-      Timestamp m = ((RestResource.HasLastModified) rsrc).getLastModified();
-      long d = req.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE);
-
-      // HTTP times are in seconds, database may have millisecond precision.
-      return d / 1000L == m.getTime() / 1000L;
-    }
-    return false;
-  }
-
-  private static <R extends RestResource> void configureCaching(
-      HttpServletRequest req, HttpServletResponse res, R rsrc, RestView<R> view, CacheControl c) {
-    if (isRead(req)) {
-      switch (c.getType()) {
-        case NONE:
-        default:
-          CacheHeaders.setNotCacheable(res);
-          break;
-        case PRIVATE:
-          addResourceStateHeaders(res, rsrc, view);
-          CacheHeaders.setCacheablePrivate(res, c.getAge(), c.getUnit(), c.isMustRevalidate());
-          break;
-        case PUBLIC:
-          addResourceStateHeaders(res, rsrc, view);
-          CacheHeaders.setCacheable(req, res, c.getAge(), c.getUnit(), c.isMustRevalidate());
-          break;
-      }
-    } else {
-      CacheHeaders.setNotCacheable(res);
-    }
-  }
-
-  private static <R extends RestResource> void addResourceStateHeaders(
-      HttpServletResponse res, R rsrc, RestView<R> view) {
-    if (view instanceof ETagView) {
-      res.setHeader(HttpHeaders.ETAG, ((ETagView<R>) view).getETag(rsrc));
-    } else if (rsrc instanceof RestResource.HasETag) {
-      res.setHeader(HttpHeaders.ETAG, ((RestResource.HasETag) rsrc).getETag());
-    }
-    if (rsrc instanceof RestResource.HasLastModified) {
-      res.setDateHeader(
-          HttpHeaders.LAST_MODIFIED,
-          ((RestResource.HasLastModified) rsrc).getLastModified().getTime());
-    }
-  }
-
-  private void checkPreconditions(HttpServletRequest req) throws PreconditionFailedException {
-    if ("*".equals(req.getHeader(HttpHeaders.IF_NONE_MATCH))) {
-      throw new PreconditionFailedException("Resource already exists");
-    }
-  }
-
-  private static Type inputType(RestModifyView<RestResource, Object> m) {
-    // MyModifyView implements RestModifyView<SomeResource, MyInput>
-    TypeLiteral<?> typeLiteral = TypeLiteral.get(m.getClass());
-
-    // RestModifyView<SomeResource, MyInput>
-    // This is smart enough to resolve even when there are intervening subclasses, even if they have
-    // reordered type arguments.
-    TypeLiteral<?> supertypeLiteral = typeLiteral.getSupertype(RestModifyView.class);
-
-    Type supertype = supertypeLiteral.getType();
-    checkState(
-        supertype instanceof ParameterizedType,
-        "supertype of %s is not parameterized: %s",
-        typeLiteral,
-        supertypeLiteral);
-    return ((ParameterizedType) supertype).getActualTypeArguments()[1];
-  }
-
-  private Object parseRequest(HttpServletRequest req, Type type)
-      throws IOException, BadRequestException, SecurityException, IllegalArgumentException,
-          NoSuchMethodException, IllegalAccessException, InstantiationException,
-          InvocationTargetException, MethodNotAllowedException {
-    // HTTP/1.1 requires consuming the request body before writing non-error response (less than
-    // 400). Consume the request body for all but raw input request types here.
-    if (isType(JSON_TYPE, req.getContentType())) {
-      try (BufferedReader br = req.getReader();
-          JsonReader json = new JsonReader(br)) {
-        try {
-          json.setLenient(true);
-
-          JsonToken first;
-          try {
-            first = json.peek();
-          } catch (EOFException e) {
-            throw new BadRequestException("Expected JSON object");
-          }
-          if (first == JsonToken.STRING) {
-            return parseString(json.nextString(), type);
-          }
-          return OutputFormat.JSON.newGson().fromJson(json, type);
-        } finally {
-          // Reader.close won't consume the rest of the input. Explicitly consume the request body.
-          br.skip(Long.MAX_VALUE);
-        }
-      }
-    } else if (rawInputRequest(req, type)) {
-      return parseRawInput(req, type);
-    } else if ("DELETE".equals(req.getMethod()) && hasNoBody(req)) {
-      return null;
-    } else if (hasNoBody(req)) {
-      return createInstance(type);
-    } else if (isType("text/plain", req.getContentType())) {
-      try (BufferedReader br = req.getReader()) {
-        char[] tmp = new char[256];
-        StringBuilder sb = new StringBuilder();
-        int n;
-        while (0 < (n = br.read(tmp))) {
-          sb.append(tmp, 0, n);
-        }
-        return parseString(sb.toString(), type);
-      }
-    } else if ("POST".equals(req.getMethod()) && isType(FORM_TYPE, req.getContentType())) {
-      return OutputFormat.JSON.newGson().fromJson(ParameterParser.formToJson(req), type);
-    } else {
-      throw new BadRequestException("Expected Content-Type: " + JSON_TYPE);
-    }
-  }
-
-  private void consumeRawInputRequestBody(HttpServletRequest req, Type type) throws IOException {
-    if (rawInputRequest(req, type)) {
-      try (InputStream is = req.getInputStream()) {
-        ServletUtils.consumeRequestBody(is);
-      }
-    }
-  }
-
-  private static boolean rawInputRequest(HttpServletRequest req, Type type) {
-    String method = req.getMethod();
-    return ("PUT".equals(method) || "POST".equals(method)) && acceptsRawInput(type);
-  }
-
-  private static boolean hasNoBody(HttpServletRequest req) {
-    int len = req.getContentLength();
-    String type = req.getContentType();
-    return (len <= 0 && type == null) || (len == 0 && isType(FORM_TYPE, type));
-  }
-
-  @SuppressWarnings("rawtypes")
-  private static boolean acceptsRawInput(Type type) {
-    if (type instanceof Class) {
-      for (Field f : ((Class) type).getDeclaredFields()) {
-        if (f.getType() == RawInput.class) {
-          return true;
-        }
-      }
-    }
-    return false;
-  }
-
-  private Object parseRawInput(HttpServletRequest req, Type type)
-      throws SecurityException, NoSuchMethodException, IllegalArgumentException,
-          InstantiationException, IllegalAccessException, InvocationTargetException,
-          MethodNotAllowedException {
-    Object obj = createInstance(type);
-    for (Field f : obj.getClass().getDeclaredFields()) {
-      if (f.getType() == RawInput.class) {
-        f.setAccessible(true);
-        f.set(obj, RawInputUtil.create(req));
-        return obj;
-      }
-    }
-    throw new MethodNotAllowedException();
-  }
-
-  private Object parseString(String value, Type type)
-      throws BadRequestException, SecurityException, NoSuchMethodException,
-          IllegalArgumentException, IllegalAccessException, InstantiationException,
-          InvocationTargetException {
-    if (type == String.class) {
-      return value;
-    }
-
-    Object obj = createInstance(type);
-    Field[] fields = obj.getClass().getDeclaredFields();
-    if (fields.length == 0 && Strings.isNullOrEmpty(value)) {
-      return obj;
-    }
-    for (Field f : fields) {
-      if (f.getAnnotation(DefaultInput.class) != null && f.getType() == String.class) {
-        f.setAccessible(true);
-        f.set(obj, value);
-        return obj;
-      }
-    }
-    throw new BadRequestException("Expected JSON object");
-  }
-
-  private static Object createInstance(Type type)
-      throws NoSuchMethodException, InstantiationException, IllegalAccessException,
-          InvocationTargetException {
-    if (type instanceof Class) {
-      @SuppressWarnings("unchecked")
-      Class<Object> clazz = (Class<Object>) type;
-      Constructor<Object> c = clazz.getDeclaredConstructor();
-      c.setAccessible(true);
-      return c.newInstance();
-    }
-    throw new InstantiationException("Cannot make " + type);
-  }
-
-  public static long replyJson(
-      @Nullable HttpServletRequest req,
-      HttpServletResponse res,
-      ListMultimap<String, String> config,
-      Object result)
-      throws IOException {
-    TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, Integer.MAX_VALUE);
-    buf.write(JSON_MAGIC);
-    Writer w = new BufferedWriter(new OutputStreamWriter(buf, UTF_8));
-    Gson gson = newGson(config, req);
-    if (result instanceof JsonElement) {
-      gson.toJson((JsonElement) result, w);
-    } else {
-      gson.toJson(result, w);
-    }
-    w.write('\n');
-    w.flush();
-    return replyBinaryResult(
-        req, res, asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8));
-  }
-
-  private static Gson newGson(
-      ListMultimap<String, String> config, @Nullable HttpServletRequest req) {
-    GsonBuilder gb = OutputFormat.JSON_COMPACT.newGsonBuilder();
-
-    enablePrettyPrint(gb, config, req);
-    enablePartialGetFields(gb, config);
-
-    return gb.create();
-  }
-
-  private static void enablePrettyPrint(
-      GsonBuilder gb, ListMultimap<String, String> config, @Nullable HttpServletRequest req) {
-    String pp = Iterables.getFirst(config.get("pp"), null);
-    if (pp == null) {
-      pp = Iterables.getFirst(config.get("prettyPrint"), null);
-      if (pp == null && req != null) {
-        pp = acceptsJson(req) ? "0" : "1";
-      }
-    }
-    if ("1".equals(pp) || "true".equals(pp)) {
-      gb.setPrettyPrinting();
-    }
-  }
-
-  private static void enablePartialGetFields(GsonBuilder gb, ListMultimap<String, String> config) {
-    final Set<String> want = new HashSet<>();
-    for (String p : config.get("fields")) {
-      Iterables.addAll(want, OptionUtil.splitOptionValue(p));
-    }
-    if (!want.isEmpty()) {
-      gb.addSerializationExclusionStrategy(
-          new ExclusionStrategy() {
-            private final Map<String, String> names = new HashMap<>();
-
-            @Override
-            public boolean shouldSkipField(FieldAttributes field) {
-              String name = names.get(field.getName());
-              if (name == null) {
-                // Names are supplied by Gson in terms of Java source.
-                // Translate and cache the JSON lower_case_style used.
-                try {
-                  name =
-                      FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES.translateName( //
-                          field.getDeclaringClass().getDeclaredField(field.getName()));
-                  names.put(field.getName(), name);
-                } catch (SecurityException e) {
-                  return true;
-                } catch (NoSuchFieldException e) {
-                  return true;
-                }
-              }
-              return !want.contains(name);
-            }
-
-            @Override
-            public boolean shouldSkipClass(Class<?> clazz) {
-              return false;
-            }
-          });
-    }
-  }
-
-  @SuppressWarnings("resource")
-  static long replyBinaryResult(
-      @Nullable HttpServletRequest req, HttpServletResponse res, BinaryResult bin)
-      throws IOException {
-    final BinaryResult appResult = bin;
-    try {
-      if (bin.getAttachmentName() != null) {
-        res.setHeader(
-            "Content-Disposition", "attachment; filename=\"" + bin.getAttachmentName() + "\"");
-      }
-      if (bin.isBase64()) {
-        if (req != null && JSON_TYPE.equals(req.getHeader(HttpHeaders.ACCEPT))) {
-          bin = stackJsonString(res, bin);
-        } else {
-          bin = stackBase64(res, bin);
-        }
-      }
-      if (bin.canGzip() && acceptsGzip(req)) {
-        bin = stackGzip(res, bin);
-      }
-
-      res.setContentType(bin.getContentType());
-      long len = bin.getContentLength();
-      if (0 <= len && len < Integer.MAX_VALUE) {
-        res.setContentLength((int) len);
-      } else if (0 <= len) {
-        res.setHeader("Content-Length", Long.toString(len));
-      }
-
-      if (req == null || !"HEAD".equals(req.getMethod())) {
-        try (CountingOutputStream dst = new CountingOutputStream(res.getOutputStream())) {
-          bin.writeTo(dst);
-          return dst.getCount();
-        }
-      }
-      return 0;
-    } finally {
-      appResult.close();
-    }
-  }
-
-  private static BinaryResult stackJsonString(HttpServletResponse res, BinaryResult src)
-      throws IOException {
-    TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, Integer.MAX_VALUE);
-    buf.write(JSON_MAGIC);
-    try (Writer w = new BufferedWriter(new OutputStreamWriter(buf, UTF_8));
-        JsonWriter json = new JsonWriter(w)) {
-      json.setLenient(true);
-      json.setHtmlSafe(true);
-      json.value(src.asString());
-      w.write('\n');
-    }
-    res.setHeader("X-FYI-Content-Encoding", "json");
-    res.setHeader("X-FYI-Content-Type", src.getContentType());
-    return asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8);
-  }
-
-  private static BinaryResult stackBase64(HttpServletResponse res, BinaryResult src)
-      throws IOException {
-    BinaryResult b64;
-    long len = src.getContentLength();
-    if (0 <= len && len <= (7 << 20)) {
-      b64 = base64(src);
-    } else {
-      b64 =
-          new BinaryResult() {
-            @Override
-            public void writeTo(OutputStream out) throws IOException {
-              try (OutputStreamWriter w =
-                      new OutputStreamWriter(
-                          new FilterOutputStream(out) {
-                            @Override
-                            public void close() {
-                              // Do not close out, but only w and e.
-                            }
-                          },
-                          ISO_8859_1);
-                  OutputStream e = BaseEncoding.base64().encodingStream(w)) {
-                src.writeTo(e);
-              }
-            }
-          };
-    }
-    res.setHeader("X-FYI-Content-Encoding", "base64");
-    res.setHeader("X-FYI-Content-Type", src.getContentType());
-    return b64.setContentType("text/plain").setCharacterEncoding(ISO_8859_1);
-  }
-
-  private static BinaryResult stackGzip(HttpServletResponse res, BinaryResult src)
-      throws IOException {
-    BinaryResult gz;
-    long len = src.getContentLength();
-    if (len < 256) {
-      return src; // Do not compress very small payloads.
-    } else if (len <= (10 << 20)) {
-      gz = compress(src);
-      if (len <= gz.getContentLength()) {
-        return src;
-      }
-    } else {
-      gz =
-          new BinaryResult() {
-            @Override
-            public void writeTo(OutputStream out) throws IOException {
-              GZIPOutputStream gz = new GZIPOutputStream(out);
-              src.writeTo(gz);
-              gz.finish();
-              gz.flush();
-            }
-          };
-    }
-    res.setHeader("Content-Encoding", "gzip");
-    return gz.setContentType(src.getContentType());
-  }
-
-  private ViewData view(
-      RestResource rsrc,
-      RestCollection<RestResource, RestResource> rc,
-      String method,
-      List<IdString> path)
-      throws AmbiguousViewException, RestApiException {
-    DynamicMap<RestView<RestResource>> views = rc.views();
-    final IdString projection = path.isEmpty() ? IdString.fromUrl("/") : path.remove(0);
-    if (!path.isEmpty()) {
-      // If there are path components still remaining after this projection
-      // is chosen, look for the projection based upon GET as the method as
-      // the client thinks it is a nested collection.
-      method = "GET";
-    } else if ("HEAD".equals(method)) {
-      method = "GET";
-    }
-
-    List<String> p = splitProjection(projection);
-    if (p.size() == 2) {
-      String viewname = p.get(1);
-      if (Strings.isNullOrEmpty(viewname)) {
-        viewname = "/";
-      }
-      RestView<RestResource> view = views.get(p.get(0), method + "." + viewname);
-      if (view != null) {
-        return new ViewData(p.get(0), view);
-      }
-      view = views.get(p.get(0), "GET." + viewname);
-      if (view != null) {
-        if (view instanceof AcceptsPost && "POST".equals(method)) {
-          @SuppressWarnings("unchecked")
-          AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) view;
-          return new ViewData(p.get(0), ap.post(rsrc));
-        }
-      }
-      throw new ResourceNotFoundException(projection);
-    }
-
-    String name = method + "." + p.get(0);
-    RestView<RestResource> core = views.get("gerrit", name);
-    if (core != null) {
-      return new ViewData(null, core);
-    }
-    core = views.get("gerrit", "GET." + p.get(0));
-    if (core instanceof AcceptsPost && "POST".equals(method)) {
-      @SuppressWarnings("unchecked")
-      AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) core;
-      return new ViewData(null, ap.post(rsrc));
-    }
-
-    Map<String, RestView<RestResource>> r = new TreeMap<>();
-    for (String plugin : views.plugins()) {
-      RestView<RestResource> action = views.get(plugin, name);
-      if (action != null) {
-        r.put(plugin, action);
-      }
-    }
-
-    if (r.size() == 1) {
-      Map.Entry<String, RestView<RestResource>> entry = Iterables.getOnlyElement(r.entrySet());
-      return new ViewData(entry.getKey(), entry.getValue());
-    } else if (r.isEmpty()) {
-      throw new ResourceNotFoundException(projection);
-    } else {
-      throw new AmbiguousViewException(
-          String.format(
-              "Projection %s is ambiguous: %s",
-              name, r.keySet().stream().map(in -> in + "~" + projection).collect(joining(", "))));
-    }
-  }
-
-  private static List<IdString> splitPath(HttpServletRequest req) {
-    String path = RequestUtil.getEncodedPathInfo(req);
-    if (Strings.isNullOrEmpty(path)) {
-      return Collections.emptyList();
-    }
-    List<IdString> out = new ArrayList<>();
-    for (String p : Splitter.on('/').split(path)) {
-      out.add(IdString.fromUrl(p));
-    }
-    if (out.size() > 0 && out.get(out.size() - 1).isEmpty()) {
-      out.remove(out.size() - 1);
-    }
-    return out;
-  }
-
-  private static List<String> splitProjection(IdString projection) {
-    List<String> p = Lists.newArrayListWithCapacity(2);
-    Iterables.addAll(p, Splitter.on('~').limit(2).split(projection.get()));
-    return p;
-  }
-
-  private void checkUserSession(HttpServletRequest req) throws AuthException {
-    CurrentUser user = globals.currentUser.get();
-    if (isRead(req)) {
-      user.setAccessPath(AccessPath.REST_API);
-    } else if (user instanceof AnonymousUser) {
-      throw new AuthException("Authentication required");
-    } else if (!globals.webSession.get().isAccessPathOk(AccessPath.REST_API)) {
-      throw new AuthException(
-          "Invalid authentication method. In order to authenticate, "
-              + "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
-    }
-    if (user.isIdentifiedUser()) {
-      user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId());
-    }
-  }
-
-  private static boolean isRead(HttpServletRequest req) {
-    return "GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod());
-  }
-
-  private void checkRequiresCapability(ViewData d)
-      throws AuthException, PermissionBackendException {
-    globals
-        .permissionBackend
-        .user(globals.currentUser)
-        .checkAny(GlobalPermission.fromAnnotation(d.pluginName, d.view.getClass()));
-  }
-
-  private static long handleException(
-      Throwable err, HttpServletRequest req, HttpServletResponse res) throws IOException {
-    String uri = req.getRequestURI();
-    if (!Strings.isNullOrEmpty(req.getQueryString())) {
-      uri += "?" + req.getQueryString();
-    }
-    log.error(String.format("Error in %s %s", req.getMethod(), uri), err);
-
-    if (!res.isCommitted()) {
-      res.reset();
-      return replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error", err);
-    }
-    return 0;
-  }
-
-  public static long replyError(
-      HttpServletRequest req,
-      HttpServletResponse res,
-      int statusCode,
-      String msg,
-      @Nullable Throwable err)
-      throws IOException {
-    return replyError(req, res, statusCode, msg, CacheControl.NONE, err);
-  }
-
-  public static long replyError(
-      HttpServletRequest req,
-      HttpServletResponse res,
-      int statusCode,
-      String msg,
-      CacheControl c,
-      @Nullable Throwable err)
-      throws IOException {
-    if (err != null) {
-      RequestUtil.setErrorTraceAttribute(req, err);
-    }
-    configureCaching(req, res, null, null, c);
-    res.setStatus(statusCode);
-    return replyText(req, res, msg);
-  }
-
-  static long replyText(@Nullable HttpServletRequest req, HttpServletResponse res, String text)
-      throws IOException {
-    if ((req == null || isRead(req)) && isMaybeHTML(text)) {
-      return replyJson(req, res, ImmutableListMultimap.of("pp", "0"), new JsonPrimitive(text));
-    }
-    if (!text.endsWith("\n")) {
-      text += "\n";
-    }
-    return replyBinaryResult(req, res, BinaryResult.create(text).setContentType("text/plain"));
-  }
-
-  private static boolean isMaybeHTML(String text) {
-    return CharMatcher.anyOf("<&").matchesAnyOf(text);
-  }
-
-  private static boolean acceptsJson(HttpServletRequest req) {
-    return req != null && isType(JSON_TYPE, req.getHeader(HttpHeaders.ACCEPT));
-  }
-
-  private static boolean acceptsGzip(HttpServletRequest req) {
-    if (req != null) {
-      String accepts = req.getHeader(HttpHeaders.ACCEPT_ENCODING);
-      return accepts != null && accepts.contains("gzip");
-    }
-    return false;
-  }
-
-  private static boolean isType(String expect, String given) {
-    if (given == null) {
-      return false;
-    } else if (expect.equals(given)) {
-      return true;
-    } else if (given.startsWith(expect + ",")) {
-      return true;
-    }
-    for (String p : given.split("[ ,;][ ,;]*")) {
-      if (expect.equals(p)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  private static int base64MaxSize(long n) {
-    return 4 * IntMath.divide((int) n, 3, CEILING);
-  }
-
-  private static BinaryResult base64(BinaryResult bin) throws IOException {
-    int maxSize = base64MaxSize(bin.getContentLength());
-    int estSize = Math.min(base64MaxSize(HEAP_EST_SIZE), maxSize);
-    TemporaryBuffer.Heap buf = heap(estSize, maxSize);
-    try (OutputStream encoded =
-        BaseEncoding.base64().encodingStream(new OutputStreamWriter(buf, ISO_8859_1))) {
-      bin.writeTo(encoded);
-    }
-    return asBinaryResult(buf);
-  }
-
-  private static BinaryResult compress(BinaryResult bin) throws IOException {
-    TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, 20 << 20);
-    try (GZIPOutputStream gz = new GZIPOutputStream(buf)) {
-      bin.writeTo(gz);
-    }
-    return asBinaryResult(buf).setContentType(bin.getContentType());
-  }
-
-  @SuppressWarnings("resource")
-  private static BinaryResult asBinaryResult(TemporaryBuffer.Heap buf) {
-    return new BinaryResult() {
-      @Override
-      public void writeTo(OutputStream os) throws IOException {
-        buf.writeTo(os, null);
-      }
-    }.setContentLength(buf.length());
-  }
-
-  private static Heap heap(int est, int max) {
-    return new TemporaryBuffer.Heap(est, max);
-  }
-
-  @SuppressWarnings("serial")
-  private static class AmbiguousViewException extends Exception {
-    AmbiguousViewException(String message) {
-      super(message);
-    }
-  }
-
-  static class ViewData {
-    String pluginName;
-    RestView<RestResource> view;
-
-    ViewData(String pluginName, RestView<RestResource> view) {
-      this.pluginName = pluginName;
-      this.view = view;
-    }
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java
deleted file mode 100644
index 9e0e8f6..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java
+++ /dev/null
@@ -1,139 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc;
-
-import com.google.gerrit.common.errors.InvalidQueryException;
-import com.google.gerrit.common.errors.NoSuchEntityException;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.inject.Provider;
-import java.io.IOException;
-
-/** Support for services which require a {@link ReviewDb} instance. */
-public class BaseServiceImplementation {
-  private final Provider<ReviewDb> schema;
-  private final Provider<? extends CurrentUser> currentUser;
-
-  protected BaseServiceImplementation(
-      final Provider<ReviewDb> schema, Provider<? extends CurrentUser> currentUser) {
-    this.schema = schema;
-    this.currentUser = currentUser;
-  }
-
-  protected Account.Id getAccountId() {
-    CurrentUser u = currentUser.get();
-    return u.isIdentifiedUser() ? u.getAccountId() : null;
-  }
-
-  protected CurrentUser getUser() {
-    return currentUser.get();
-  }
-
-  protected ReviewDb getDb() {
-    return schema.get();
-  }
-
-  /**
-   * Executes {@code action.run} with an active ReviewDb connection.
-   *
-   * <p>A database handle is automatically opened and closed around the action's {@link
-   * Action#run(ReviewDb)} method. OrmExceptions are caught and passed into the onFailure method of
-   * the callback.
-   *
-   * @param <T> type of result the callback expects.
-   * @param callback the callback that will receive the result.
-   * @param action the action logic to perform.
-   */
-  protected <T> void run(AsyncCallback<T> callback, Action<T> action) {
-    try {
-      final T r = action.run(schema.get());
-      if (r != null) {
-        callback.onSuccess(r);
-      }
-    } catch (InvalidQueryException e) {
-      callback.onFailure(e);
-    } catch (NoSuchProjectException e) {
-      if (e.getMessage() != null) {
-        callback.onFailure(new NoSuchEntityException(e.getMessage()));
-      } else {
-        callback.onFailure(new NoSuchEntityException());
-      }
-    } catch (NoSuchGroupException e) {
-      callback.onFailure(new NoSuchEntityException());
-    } catch (OrmRuntimeException e) {
-      Exception ex = e;
-      if (e.getCause() instanceof OrmException) {
-        ex = (OrmException) e.getCause();
-      }
-      handleOrmException(callback, ex);
-    } catch (OrmException e) {
-      handleOrmException(callback, e);
-    } catch (IOException e) {
-      callback.onFailure(e);
-    } catch (Failure e) {
-      if (e.getCause() instanceof NoSuchProjectException
-          || e.getCause() instanceof NoSuchChangeException) {
-        callback.onFailure(new NoSuchEntityException());
-
-      } else {
-        callback.onFailure(e.getCause());
-      }
-    }
-  }
-
-  private static <T> void handleOrmException(AsyncCallback<T> callback, Exception e) {
-    if (e.getCause() instanceof Failure) {
-      callback.onFailure(e.getCause().getCause());
-    } else if (e.getCause() instanceof NoSuchEntityException) {
-      callback.onFailure(e.getCause());
-    } else {
-      callback.onFailure(e);
-    }
-  }
-
-  /** Exception whose cause is passed into onFailure. */
-  public static class Failure extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    public Failure(Throwable why) {
-      super(why);
-    }
-  }
-
-  /** Arbitrary action to run with a database connection. */
-  public interface Action<T> {
-    /**
-     * Perform this action, returning the onSuccess value.
-     *
-     * @param db an open database handle to be used by this connection.
-     * @return he value to pass to {@link AsyncCallback#onSuccess(Object)}.
-     * @throws OrmException any schema based action failed.
-     * @throws Failure cause is given to {@link AsyncCallback#onFailure(Throwable)}.
-     * @throws NoSuchProjectException
-     * @throws NoSuchGroupException
-     * @throws InvalidQueryException
-     */
-    T run(ReviewDb db)
-        throws OrmException, Failure, NoSuchProjectException, NoSuchGroupException,
-            InvalidQueryException, IOException;
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java
deleted file mode 100644
index 178cda9..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java
+++ /dev/null
@@ -1,288 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc;
-
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.audit.AuditService;
-import com.google.gerrit.audit.RpcAuditEvent;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.audit.Audit;
-import com.google.gerrit.common.auth.SignInRequired;
-import com.google.gerrit.common.errors.NotSignedInException;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.server.AccessPath;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gson.GsonBuilder;
-import com.google.gwtjsonrpc.common.RemoteJsonService;
-import com.google.gwtjsonrpc.server.ActiveCall;
-import com.google.gwtjsonrpc.server.JsonServlet;
-import com.google.gwtjsonrpc.server.MethodHandle;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.lang.reflect.Field;
-import java.lang.reflect.Method;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Base JSON servlet to ensure the current user is not forged. */
-@SuppressWarnings("serial")
-final class GerritJsonServlet extends JsonServlet<GerritJsonServlet.GerritCall> {
-  private static final Logger log = LoggerFactory.getLogger(GerritJsonServlet.class);
-  private static final ThreadLocal<GerritCall> currentCall = new ThreadLocal<>();
-  private static final ThreadLocal<MethodHandle> currentMethod = new ThreadLocal<>();
-  private final DynamicItem<WebSession> session;
-  private final RemoteJsonService service;
-  private final AuditService audit;
-
-  @Inject
-  GerritJsonServlet(final DynamicItem<WebSession> w, RemoteJsonService s, AuditService a) {
-    session = w;
-    service = s;
-    audit = a;
-  }
-
-  @Override
-  protected GerritCall createActiveCall(final HttpServletRequest req, HttpServletResponse rsp) {
-    final GerritCall call = new GerritCall(session.get(), req, new AuditedHttpServletResponse(rsp));
-    currentCall.set(call);
-    return call;
-  }
-
-  @Override
-  protected GsonBuilder createGsonBuilder() {
-    return gerritDefaultGsonBuilder();
-  }
-
-  private static GsonBuilder gerritDefaultGsonBuilder() {
-    final GsonBuilder g = defaultGsonBuilder();
-
-    g.registerTypeAdapter(
-        org.eclipse.jgit.diff.Edit.class, new org.eclipse.jgit.diff.EditDeserializer());
-
-    return g;
-  }
-
-  @Override
-  protected void preInvoke(GerritCall call) {
-    super.preInvoke(call);
-
-    if (call.isComplete()) {
-      return;
-    }
-
-    if (call.getMethod().getAnnotation(SignInRequired.class) != null) {
-      // If SignInRequired is set on this method we must have both a
-      // valid XSRF token *and* have the user signed in. Doing these
-      // checks also validates that they agree on the user identity.
-      //
-      if (!call.requireXsrfValid() || !session.get().isSignedIn()) {
-        call.onFailure(new NotSignedInException());
-      }
-    }
-  }
-
-  @Override
-  protected Object createServiceHandle() {
-    return service;
-  }
-
-  @Override
-  protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
-    try {
-      super.service(req, resp);
-    } finally {
-      audit();
-      currentCall.set(null);
-    }
-  }
-
-  private void audit() {
-    try {
-      GerritCall call = currentCall.get();
-      MethodHandle method = call.getMethod();
-      if (method == null) {
-        return;
-      }
-      Audit note = method.getAnnotation(Audit.class);
-      if (note != null) {
-        String sid = call.getWebSession().getSessionId();
-        CurrentUser username = call.getWebSession().getUser();
-        ListMultimap<String, ?> args = extractParams(note, call);
-        String what = extractWhat(note, call);
-        Object result = call.getResult();
-
-        audit.dispatch(
-            new RpcAuditEvent(
-                sid,
-                username,
-                what,
-                call.getWhen(),
-                args,
-                call.getHttpServletRequest().getMethod(),
-                call.getHttpServletRequest().getMethod(),
-                ((AuditedHttpServletResponse) (call.getHttpServletResponse())).getStatus(),
-                result));
-      }
-    } catch (Throwable all) {
-      log.error("Unable to log the call", all);
-    }
-  }
-
-  private ListMultimap<String, ?> extractParams(Audit note, GerritCall call) {
-    ListMultimap<String, Object> args = MultimapBuilder.hashKeys().arrayListValues().build();
-
-    Object[] params = call.getParams();
-    for (int i = 0; i < params.length; i++) {
-      args.put("$" + i, params[i]);
-    }
-
-    for (int idx : note.obfuscate()) {
-      args.removeAll("$" + idx);
-      args.put("$" + idx, "*****");
-    }
-    return args;
-  }
-
-  private String extractWhat(Audit note, GerritCall call) {
-    Class<?> methodClass = call.getMethodClass();
-    String methodClassName = methodClass != null ? methodClass.getName() : "<UNKNOWN_CLASS>";
-    methodClassName = methodClassName.substring(methodClassName.lastIndexOf(".") + 1);
-    String what = note.action();
-    if (what.length() == 0) {
-      what = call.getMethod().getName();
-    }
-
-    return methodClassName + "." + what;
-  }
-
-  static class GerritCall extends ActiveCall {
-    private final WebSession session;
-    private final long when;
-    private static final Field resultField;
-    private static final Field methodField;
-
-    // Needed to allow access to non-public result field in GWT/JSON-RPC
-    static {
-      resultField = getPrivateField(ActiveCall.class, "result");
-      methodField = getPrivateField(MethodHandle.class, "method");
-    }
-
-    private static Field getPrivateField(Class<?> clazz, String fieldName) {
-      Field declaredField = null;
-      try {
-        declaredField = clazz.getDeclaredField(fieldName);
-        declaredField.setAccessible(true);
-      } catch (Exception e) {
-        log.error("Unable to expose RPS/JSON result field");
-      }
-      return declaredField;
-    }
-
-    // Surrogate of the missing getMethodClass() in GWT/JSON-RPC
-    public Class<?> getMethodClass() {
-      if (methodField == null) {
-        return null;
-      }
-
-      try {
-        Method method = (Method) methodField.get(this.getMethod());
-        return method.getDeclaringClass();
-      } catch (IllegalArgumentException e) {
-        log.error("Cannot access result field");
-      } catch (IllegalAccessException e) {
-        log.error("No permissions to access result field");
-      }
-
-      return null;
-    }
-
-    // Surrogate of the missing getResult() in GWT/JSON-RPC
-    public Object getResult() {
-      if (resultField == null) {
-        return null;
-      }
-
-      try {
-        return resultField.get(this);
-      } catch (IllegalArgumentException e) {
-        log.error("Cannot access result field");
-      } catch (IllegalAccessException e) {
-        log.error("No permissions to access result field");
-      }
-
-      return null;
-    }
-
-    GerritCall(WebSession session, HttpServletRequest i, HttpServletResponse o) {
-      super(i, o);
-      this.session = session;
-      this.when = TimeUtil.nowMs();
-    }
-
-    @Override
-    public MethodHandle getMethod() {
-      if (currentMethod.get() == null) {
-        return super.getMethod();
-      }
-      return currentMethod.get();
-    }
-
-    @Override
-    public void onFailure(Throwable error) {
-      if (error instanceof IllegalArgumentException || error instanceof IllegalStateException) {
-        super.onFailure(error);
-      } else if (error instanceof OrmException || error instanceof RuntimeException) {
-        onInternalFailure(error);
-      } else {
-        super.onFailure(error);
-      }
-    }
-
-    @Override
-    public boolean xsrfValidate() {
-      final String keyIn = getXsrfKeyIn();
-      if (keyIn == null || "".equals(keyIn)) {
-        // Anonymous requests don't need XSRF protection, they shouldn't
-        // be able to cause critical state changes.
-        //
-        return !session.isSignedIn();
-
-      } else if (session.isSignedIn() && session.isValidXGerritAuth(keyIn)) {
-        // The session must exist, and must be using this token.
-        //
-        session.getUser().setAccessPath(AccessPath.JSON_RPC);
-        return true;
-      }
-      return false;
-    }
-
-    public WebSession getWebSession() {
-      return session;
-    }
-
-    public long getWhen() {
-      return when;
-    }
-
-    public long getElapsed() {
-      return TimeUtil.nowMs() - when;
-    }
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/Handler.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/Handler.java
deleted file mode 100644
index b932169..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/Handler.java
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc;
-
-import com.google.gerrit.common.errors.NoSuchEntityException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.NoSuchRefException;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.VoidResult;
-import com.google.gwtorm.server.OrmException;
-import java.util.concurrent.Callable;
-
-/**
- * Base class for RPC service implementations.
- *
- * <p>Typically an RPC service implementation will extend this class and use Guice injection to
- * manage its state. For example:
- *
- * <pre>
- *   class Foo extends Handler&lt;Result&gt; {
- *     interface Factory {
- *       Foo create(... args ...);
- *     }
- *     &#064;Inject
- *     Foo(state, @Assisted args) { ... }
- *     Result get() throws Exception { ... }
- *   }
- * </pre>
- *
- * @param <T> type of result for {@link AsyncCallback#onSuccess(Object)} if the operation completed
- *     successfully.
- */
-public abstract class Handler<T> implements Callable<T> {
-  public static <T> Handler<T> wrap(Callable<T> r) {
-    return new Handler<T>() {
-      @Override
-      public T call() throws Exception {
-        return r.call();
-      }
-    };
-  }
-
-  /**
-   * Run the operation and pass the result to the callback.
-   *
-   * @param callback callback to receive the result of {@link #call()}.
-   */
-  public final void to(AsyncCallback<T> callback) {
-    try {
-      final T r = call();
-      if (r != null) {
-        callback.onSuccess(r);
-      }
-    } catch (NoSuchProjectException | NoSuchChangeException | NoSuchRefException e) {
-      callback.onFailure(new NoSuchEntityException());
-
-    } catch (OrmException e) {
-      if (e.getCause() instanceof BaseServiceImplementation.Failure) {
-        callback.onFailure(e.getCause().getCause());
-
-      } else if (e.getCause() instanceof NoSuchEntityException) {
-        callback.onFailure(e.getCause());
-
-      } else {
-        callback.onFailure(e);
-      }
-    } catch (BaseServiceImplementation.Failure e) {
-      callback.onFailure(e.getCause());
-    } catch (Exception e) {
-      callback.onFailure(e);
-    }
-  }
-
-  /**
-   * Compute the operation result.
-   *
-   * @return the result of the operation. Return {@link VoidResult#INSTANCE} if there is no
-   *     meaningful return value for the operation.
-   * @throws Exception the operation failed. The caller will log the exception and the stack trace,
-   *     if it is worth logging on the server side.
-   */
-  @Override
-  public abstract T call() throws Exception;
-}
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
deleted file mode 100644
index 2adf029..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
+++ /dev/null
@@ -1,110 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.project;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.ProjectAccess;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-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.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;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
-
-class ChangeProjectAccess extends ProjectAccessHandler<ProjectAccess> {
-  interface Factory {
-    ChangeProjectAccess create(
-        @Assisted("projectName") Project.NameKey projectName,
-        @Nullable @Assisted ObjectId base,
-        @Assisted List<AccessSection> sectionList,
-        @Nullable @Assisted("parentProjectName") Project.NameKey parentProjectName,
-        @Nullable @Assisted String message);
-  }
-
-  private final GitReferenceUpdated gitRefUpdated;
-  private final ProjectAccessFactory.Factory projectAccessFactory;
-  private final ProjectCache projectCache;
-
-  @Inject
-  ChangeProjectAccess(
-      ProjectAccessFactory.Factory projectAccessFactory,
-      ProjectControl.Factory projectControlFactory,
-      ProjectCache projectCache,
-      GroupBackend groupBackend,
-      MetaDataUpdate.User metaDataUpdateFactory,
-      AllProjectsName allProjects,
-      Provider<SetParent> setParent,
-      GitReferenceUpdated gitRefUpdated,
-      ContributorAgreementsChecker contributorAgreements,
-      @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,
-        projectName,
-        base,
-        sectionList,
-        parentProjectName,
-        message,
-        contributorAgreements,
-        true);
-    this.projectAccessFactory = projectAccessFactory;
-    this.projectCache = projectCache;
-    this.gitRefUpdated = gitRefUpdated;
-  }
-
-  @Override
-  protected ProjectAccess updateProjectConfig(
-      ProjectControl projectControl,
-      ProjectConfig config,
-      MetaDataUpdate md,
-      boolean parentProjectUpdate)
-      throws IOException, NoSuchProjectException, ConfigInvalidException,
-          PermissionBackendException {
-    RevCommit commit = config.commit(md);
-
-    gitRefUpdated.fire(
-        config.getProject().getNameKey(),
-        RefNames.REFS_CONFIG,
-        base,
-        commit.getId(),
-        projectControl.getUser().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
deleted file mode 100644
index 4cd6fa0..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
+++ /dev/null
@@ -1,283 +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.httpd.rpc.project;
-
-import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
-import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
-import static com.google.gerrit.server.permissions.RefPermission.READ;
-
-import com.google.common.collect.Maps;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupInfo;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.ProjectAccess;
-import com.google.gerrit.common.data.RefConfigSection;
-import com.google.gerrit.common.data.WebLinkInfoCommon;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.httpd.rpc.Handler;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.WebLinks;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-class ProjectAccessFactory extends Handler<ProjectAccess> {
-  interface Factory {
-    ProjectAccessFactory create(@Assisted Project.NameKey name);
-  }
-
-  private final GroupBackend groupBackend;
-  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;
-
-  private final Project.NameKey projectName;
-  private WebLinks webLinks;
-
-  @Inject
-  ProjectAccessFactory(
-      GroupBackend groupBackend,
-      ProjectCache projectCache,
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> user,
-      ProjectControl.GenericFactory projectControlFactory,
-      GroupControl.Factory groupControlFactory,
-      MetaDataUpdate.Server metaDataUpdateFactory,
-      AllProjectsName allProjectsName,
-      WebLinks webLinks,
-      @Assisted final Project.NameKey name) {
-    this.groupBackend = groupBackend;
-    this.projectCache = projectCache;
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-    this.projectControlFactory = projectControlFactory;
-    this.groupControlFactory = groupControlFactory;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.allProjectsName = allProjectsName;
-    this.webLinks = webLinks;
-
-    this.projectName = name;
-  }
-
-  @Override
-  public ProjectAccess call()
-      throws NoSuchProjectException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    ProjectControl pc = checkProjectControl();
-
-    // Load the current configuration from the repository, ensuring its the most
-    // recent version available. If it differs from what was in the project
-    // state, force a cache flush now.
-    //
-    ProjectConfig config;
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
-      config = ProjectConfig.read(md);
-      if (config.updateGroupNames(groupBackend)) {
-        md.setMessage("Update group names\n");
-        config.commit(md);
-        projectCache.evict(config.getProject());
-        pc = checkProjectControl();
-      } else if (config.getRevision() != null
-          && !config.getRevision().equals(pc.getProjectState().getConfig().getRevision())) {
-        projectCache.evict(config.getProject());
-        pc = checkProjectControl();
-      }
-    }
-
-    List<AccessSection> local = new ArrayList<>();
-    Set<String> ownerOf = new HashSet<>();
-    Map<AccountGroup.UUID, Boolean> visibleGroups = new HashMap<>();
-    PermissionBackend.ForProject perm = permissionBackend.user(user).project(projectName);
-    boolean checkReadConfig = check(perm, RefNames.REFS_CONFIG, READ);
-
-    for (AccessSection section : config.getAccessSections()) {
-      String name = section.getName();
-      if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
-        if (pc.isOwner()) {
-          local.add(section);
-          ownerOf.add(name);
-
-        } else if (checkReadConfig) {
-          local.add(section);
-        }
-
-      } else if (RefConfigSection.isValid(name)) {
-        if (pc.controlForRef(name).isOwner()) {
-          local.add(section);
-          ownerOf.add(name);
-
-        } else if (checkReadConfig) {
-          local.add(section);
-
-        } else if (check(perm, name, READ)) {
-          // Filter the section to only add rules describing groups that
-          // are visible to the current-user. This includes any group the
-          // user is a member of, as well as groups they own or that
-          // are visible to all users.
-
-          AccessSection dst = null;
-          for (Permission srcPerm : section.getPermissions()) {
-            Permission dstPerm = null;
-
-            for (PermissionRule srcRule : srcPerm.getRules()) {
-              AccountGroup.UUID group = srcRule.getGroup().getUUID();
-              if (group == null) {
-                continue;
-              }
-
-              Boolean canSeeGroup = visibleGroups.get(group);
-              if (canSeeGroup == null) {
-                try {
-                  canSeeGroup = groupControlFactory.controlFor(group).isVisible();
-                } catch (NoSuchGroupException e) {
-                  canSeeGroup = Boolean.FALSE;
-                }
-                visibleGroups.put(group, canSeeGroup);
-              }
-
-              if (canSeeGroup) {
-                if (dstPerm == null) {
-                  if (dst == null) {
-                    dst = new AccessSection(name);
-                    local.add(dst);
-                  }
-                  dstPerm = dst.getPermission(srcPerm.getName(), true);
-                }
-                dstPerm.add(srcRule);
-              }
-            }
-          }
-        }
-      }
-    }
-
-    if (ownerOf.isEmpty() && isAdmin()) {
-      // Special case: If the section list is empty, this project has no current
-      // access control information. Fall back to site administrators.
-      ownerOf.add(AccessSection.ALL);
-    }
-
-    final ProjectAccess detail = new ProjectAccess();
-    detail.setProjectName(projectName);
-
-    if (config.getRevision() != null) {
-      detail.setRevision(config.getRevision().name());
-    }
-
-    detail.setInheritsFrom(config.getProject().getParent(allProjectsName));
-
-    if (projectName.equals(allProjectsName)
-        && permissionBackend.user(user).testOrFalse(ADMINISTRATE_SERVER)) {
-      ownerOf.add(AccessSection.GLOBAL_CAPABILITIES);
-    }
-
-    detail.setLocal(local);
-    detail.setOwnerOf(ownerOf);
-    detail.setCanUpload(
-        pc.isOwner()
-            || (checkReadConfig && perm.ref(RefNames.REFS_CONFIG).testOrFalse(CREATE_CHANGE)));
-    detail.setConfigVisible(pc.isOwner() || checkReadConfig);
-    detail.setGroupInfo(buildGroupInfo(local));
-    detail.setLabelTypes(pc.getProjectState().getLabelTypes());
-    detail.setFileHistoryLinks(getConfigFileLogLinks(projectName.get()));
-    return detail;
-  }
-
-  private List<WebLinkInfoCommon> getConfigFileLogLinks(String projectName) {
-    List<WebLinkInfoCommon> links =
-        webLinks.getFileHistoryLinks(
-            projectName, RefNames.REFS_CONFIG, ProjectConfig.PROJECT_CONFIG);
-    return links.isEmpty() ? null : links;
-  }
-
-  private Map<AccountGroup.UUID, GroupInfo> buildGroupInfo(List<AccessSection> local) {
-    Map<AccountGroup.UUID, GroupInfo> infos = new HashMap<>();
-    for (AccessSection section : local) {
-      for (Permission permission : section.getPermissions()) {
-        for (PermissionRule rule : permission.getRules()) {
-          if (rule.getGroup() != null) {
-            AccountGroup.UUID uuid = rule.getGroup().getUUID();
-            if (uuid != null && !infos.containsKey(uuid)) {
-              GroupDescription.Basic group = groupBackend.get(uuid);
-              infos.put(uuid, group != null ? new GroupInfo(group) : null);
-            }
-          }
-        }
-      }
-    }
-    return Maps.filterEntries(infos, in -> in.getValue() != null);
-  }
-
-  private ProjectControl checkProjectControl()
-      throws NoSuchProjectException, IOException, PermissionBackendException {
-    ProjectControl pc = projectControlFactory.controlFor(projectName, user.get());
-    try {
-      permissionBackend.user(user).project(projectName).check(ProjectPermission.ACCESS);
-    } catch (AuthException e) {
-      throw new NoSuchProjectException(projectName);
-    }
-    return pc;
-  }
-
-  private static boolean check(PermissionBackend.ForProject ctx, String ref, RefPermission perm)
-      throws PermissionBackendException {
-    try {
-      ctx.ref(ref).check(perm);
-      return true;
-    } catch (AuthException denied) {
-      return false;
-    }
-  }
-
-  private boolean isAdmin() throws PermissionBackendException {
-    try {
-      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
deleted file mode 100644
index 3fa05ab..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
+++ /dev/null
@@ -1,222 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.project;
-
-import static com.google.gerrit.common.ProjectAccessUtil.mergeSections;
-
-import com.google.common.base.MoreObjects;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.errors.InvalidNameException;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.common.errors.PermissionDeniedException;
-import com.google.gerrit.common.errors.UpdateParentFailedException;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.httpd.rpc.Handler;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.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.PermissionBackendException;
-import com.google.gerrit.server.project.ContributorAgreementsChecker;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.RefPattern;
-import com.google.gerrit.server.project.SetParent;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
-
-public abstract class ProjectAccessHandler<T> extends Handler<T> {
-
-  private final ProjectControl.Factory projectControlFactory;
-  protected final GroupBackend groupBackend;
-  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 Project.NameKey parentProjectName;
-  protected String message;
-  private boolean checkIfOwner;
-
-  protected ProjectAccessHandler(
-      ProjectControl.Factory projectControlFactory,
-      GroupBackend groupBackend,
-      MetaDataUpdate.User metaDataUpdateFactory,
-      AllProjectsName allProjects,
-      Provider<SetParent> setParent,
-      Project.NameKey projectName,
-      ObjectId base,
-      List<AccessSection> sectionList,
-      Project.NameKey parentProjectName,
-      String message,
-      ContributorAgreementsChecker contributorAgreements,
-      boolean checkIfOwner) {
-    this.projectControlFactory = projectControlFactory;
-    this.groupBackend = groupBackend;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.allProjects = allProjects;
-    this.setParent = setParent;
-
-    this.projectName = projectName;
-    this.base = base;
-    this.sectionList = sectionList;
-    this.parentProjectName = parentProjectName;
-    this.message = message;
-    this.contributorAgreements = contributorAgreements;
-    this.checkIfOwner = checkIfOwner;
-  }
-
-  @Override
-  public final T call()
-      throws NoSuchProjectException, IOException, ConfigInvalidException, InvalidNameException,
-          NoSuchGroupException, OrmException, UpdateParentFailedException,
-          PermissionDeniedException, PermissionBackendException {
-    final ProjectControl projectControl = projectControlFactory.controlFor(projectName);
-
-    try {
-      contributorAgreements.check(projectName, projectControl.getUser());
-    } catch (AuthException e) {
-      throw new PermissionDeniedException(e.getMessage());
-    }
-
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
-      ProjectConfig config = ProjectConfig.read(md, base);
-      Set<String> toDelete = scanSectionNames(config);
-
-      for (AccessSection section : mergeSections(sectionList)) {
-        String name = section.getName();
-
-        if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
-          if (checkIfOwner && !projectControl.isOwner()) {
-            continue;
-          }
-          replace(config, toDelete, section);
-
-        } else if (AccessSection.isValid(name)) {
-          if (checkIfOwner && !projectControl.controlForRef(name).isOwner()) {
-            continue;
-          }
-
-          RefPattern.validate(name);
-
-          replace(config, toDelete, section);
-        }
-      }
-
-      for (String name : toDelete) {
-        if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
-          if (!checkIfOwner || projectControl.isOwner()) {
-            config.remove(config.getAccessSection(name));
-          }
-
-        } else if (!checkIfOwner || projectControl.controlForRef(name).isOwner()) {
-          config.remove(config.getAccessSection(name));
-        }
-      }
-
-      boolean parentProjectUpdate = false;
-      if (!config.getProject().getNameKey().equals(allProjects)
-          && !config.getProject().getParent(allProjects).equals(parentProjectName)) {
-        parentProjectUpdate = true;
-        try {
-          setParent
-              .get()
-              .validateParentUpdate(
-                  projectControl.getProject().getNameKey(),
-                  projectControl.getUser().asIdentifiedUser(),
-                  MoreObjects.firstNonNull(parentProjectName, allProjects).get(),
-                  checkIfOwner);
-        } catch (AuthException e) {
-          throw new UpdateParentFailedException(
-              "You are not allowed to change the parent project since you are "
-                  + "not an administrator. You may save the modifications for review "
-                  + "so that an administrator can approve them.",
-              e);
-        } catch (ResourceConflictException | UnprocessableEntityException e) {
-          throw new UpdateParentFailedException(e.getMessage(), e);
-        }
-        config.getProject().setParentName(parentProjectName);
-      }
-
-      if (message != null && !message.isEmpty()) {
-        if (!message.endsWith("\n")) {
-          message += "\n";
-        }
-        md.setMessage(message);
-      } else {
-        md.setMessage("Modify access rules\n");
-      }
-
-      return updateProjectConfig(projectControl, config, md, parentProjectUpdate);
-    } catch (RepositoryNotFoundException notFound) {
-      throw new NoSuchProjectException(projectName);
-    }
-  }
-
-  protected abstract T updateProjectConfig(
-      ProjectControl projectControl,
-      ProjectConfig config,
-      MetaDataUpdate md,
-      boolean parentProjectUpdate)
-      throws IOException, NoSuchProjectException, ConfigInvalidException, OrmException,
-          PermissionDeniedException, PermissionBackendException;
-
-  private void replace(ProjectConfig config, Set<String> toDelete, AccessSection section)
-      throws NoSuchGroupException {
-    for (Permission permission : section.getPermissions()) {
-      for (PermissionRule rule : permission.getRules()) {
-        lookupGroup(rule);
-      }
-    }
-    config.replace(section);
-    toDelete.remove(section.getName());
-  }
-
-  private static Set<String> scanSectionNames(ProjectConfig config) {
-    Set<String> names = new HashSet<>();
-    for (AccessSection section : config.getAccessSections()) {
-      names.add(section.getName());
-    }
-    return names;
-  }
-
-  private void lookupGroup(PermissionRule rule) throws NoSuchGroupException {
-    GroupReference ref = rule.getGroup();
-    if (ref.getUUID() == null) {
-      final GroupReference group = GroupBackends.findBestSuggestion(groupBackend, ref.getName());
-      if (group == null) {
-        throw new NoSuchGroupException(ref.getName());
-      }
-      ref.setUUID(group.getUUID());
-    }
-  }
-}
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
deleted file mode 100644
index f27b9d3..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
+++ /dev/null
@@ -1,226 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.project;
-
-import com.google.common.base.Throwables;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.errors.PermissionDeniedException;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.change.ChangeInserter;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.ChangesCollection;
-import com.google.gerrit.server.change.PostReviewers;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.ContributorAgreementsChecker;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.SetParent;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-public class ReviewProjectAccess extends ProjectAccessHandler<Change.Id> {
-  interface Factory {
-    ReviewProjectAccess create(
-        @Assisted("projectName") Project.NameKey projectName,
-        @Nullable @Assisted ObjectId base,
-        @Assisted List<AccessSection> sectionList,
-        @Nullable @Assisted("parentProjectName") Project.NameKey parentProjectName,
-        @Nullable @Assisted String message);
-  }
-
-  private final ReviewDb db;
-  private final PermissionBackend permissionBackend;
-  private final Sequences seq;
-  private final Provider<PostReviewers> reviewersProvider;
-  private final ProjectCache projectCache;
-  private final ChangesCollection changes;
-  private final ChangeInserter.Factory changeInserterFactory;
-  private final BatchUpdate.Factory updateFactory;
-
-  @Inject
-  ReviewProjectAccess(
-      final ProjectControl.Factory projectControlFactory,
-      PermissionBackend permissionBackend,
-      GroupBackend groupBackend,
-      MetaDataUpdate.User metaDataUpdateFactory,
-      ReviewDb db,
-      Provider<PostReviewers> reviewersProvider,
-      ProjectCache projectCache,
-      AllProjectsName allProjects,
-      ChangesCollection changes,
-      ChangeInserter.Factory changeInserterFactory,
-      BatchUpdate.Factory updateFactory,
-      Provider<SetParent> setParent,
-      Sequences seq,
-      ContributorAgreementsChecker contributorAgreements,
-      @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,
-        projectName,
-        base,
-        sectionList,
-        parentProjectName,
-        message,
-        contributorAgreements,
-        false);
-    this.db = db;
-    this.permissionBackend = permissionBackend;
-    this.seq = seq;
-    this.reviewersProvider = reviewersProvider;
-    this.projectCache = projectCache;
-    this.changes = changes;
-    this.changeInserterFactory = changeInserterFactory;
-    this.updateFactory = updateFactory;
-  }
-
-  // TODO(dborowitz): Hack MetaDataUpdate so it can be created within a BatchUpdate and we can avoid
-  // calling setUpdateRef(false).
-  @SuppressWarnings("deprecation")
-  @Override
-  protected Change.Id updateProjectConfig(
-      ProjectControl projectControl,
-      ProjectConfig config,
-      MetaDataUpdate md,
-      boolean parentProjectUpdate)
-      throws IOException, OrmException, PermissionDeniedException, PermissionBackendException {
-    PermissionBackend.ForRef metaRef =
-        permissionBackend
-            .user(projectControl.getUser())
-            .project(projectControl.getProject().getNameKey())
-            .ref(RefNames.REFS_CONFIG);
-    try {
-      metaRef.check(RefPermission.READ);
-    } catch (AuthException denied) {
-      throw new PermissionDeniedException(RefNames.REFS_CONFIG + " not visible");
-    }
-    if (!projectControl.isOwner()) {
-      try {
-        metaRef.check(RefPermission.CREATE_CHANGE);
-      } catch (AuthException denied) {
-        throw new PermissionDeniedException("cannot create change for " + RefNames.REFS_CONFIG);
-      }
-    }
-
-    md.setInsertChangeId(true);
-    Change.Id changeId = new Change.Id(seq.nextChangeId());
-    RevCommit commit =
-        config.commitToNewRef(
-            md, new PatchSet.Id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
-    if (commit.getId().equals(base)) {
-      return null;
-    }
-
-    try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
-        ObjectReader objReader = objInserter.newReader();
-        RevWalk rw = new RevWalk(objReader);
-        BatchUpdate bu =
-            updateFactory.create(
-                db, config.getProject().getNameKey(), projectControl.getUser(), TimeUtil.nowTs())) {
-      bu.setRepository(md.getRepository(), rw, objInserter);
-      bu.insertChange(
-          changeInserterFactory
-              .create(changeId, commit, RefNames.REFS_CONFIG)
-              .setValidate(false)
-              .setUpdateRef(false)); // Created by commitToNewRef.
-      bu.execute();
-    } catch (UpdateException | RestApiException e) {
-      throw new IOException(e);
-    }
-
-    ChangeResource rsrc;
-    try {
-      rsrc = changes.parse(changeId);
-    } catch (ResourceNotFoundException e) {
-      throw new IOException(e);
-    }
-    addProjectOwnersAsReviewers(rsrc);
-    if (parentProjectUpdate) {
-      addAdministratorsAsReviewers(rsrc);
-    }
-    return changeId;
-  }
-
-  private void addProjectOwnersAsReviewers(ChangeResource rsrc) {
-    final String projectOwners = groupBackend.get(SystemGroupBackend.PROJECT_OWNERS).getName();
-    try {
-      AddReviewerInput input = new AddReviewerInput();
-      input.reviewer = projectOwners;
-      reviewersProvider.get().apply(rsrc, input);
-    } catch (Exception e) {
-      // one of the owner groups is not visible to the user and this it why it
-      // can't be added as reviewer
-      Throwables.throwIfUnchecked(e);
-    }
-  }
-
-  private void addAdministratorsAsReviewers(ChangeResource rsrc) {
-    List<PermissionRule> adminRules =
-        projectCache
-            .getAllProjects()
-            .getConfig()
-            .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
-            .getPermission(GlobalCapability.ADMINISTRATE_SERVER)
-            .getRules();
-    for (PermissionRule r : adminRules) {
-      try {
-        AddReviewerInput input = new AddReviewerInput();
-        input.reviewer = r.getGroup().getUUID().get();
-        reviewersProvider.get().apply(rsrc, input);
-      } catch (Exception e) {
-        // ignore
-        Throwables.throwIfUnchecked(e);
-      }
-    }
-  }
-}
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
deleted file mode 100644
index 51c60af..0000000
--- a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.httpd.raw}
-
-/**
- * @param canonicalPath
- * @param staticResourcePath
- * @param? versionInfo
- */
-{template .Index autoescape="strict" kind="html"}
-  <!DOCTYPE html>{\n}
-  <html lang="en">{\n}
-  <meta charset="utf-8">{\n}
-  <meta name="description" content="Gerrit Code Review">{\n}
-  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">{\n}
-
-  {if $canonicalPath != '' or $versionInfo}
-    <script>
-      {if $canonicalPath != ''}window.CANONICAL_PATH = '{$canonicalPath}';{/if}
-      {if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
-    </script>{\n}
-  {/if}
-
-  <link rel="icon" type="image/x-icon" href="{$canonicalPath}/favicon.ico">{\n}
-
-  // RobotoMono fonts are used in styles/fonts.css
-  // @see https://github.com/w3c/preload/issues/32 regarding crossorigin
-  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin>{\n}
-  <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
-  <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
-  <script src="{$staticResourcePath}/bower_components/webcomponentsjs/webcomponents-lite.js"></script>{\n}
-  // Content between webcomponents-lite and the load of the main app element
-  // run before polymer-resin is installed so may have security consequences.
-  // Contact your local security engineer if you have any questions, and
-  // CC them on any changes that load content before gr-app.html.
-  //
-  // github.com/Polymer/polymer-resin/blob/master/getting-started.md#integrating
-  <link rel="preload" href="{$staticResourcePath}/elements/gr-app.js" as="script" crossorigin="anonymous">{\n}
-  <link rel="import" href="{$staticResourcePath}/elements/gr-app.html">{\n}
-
-  <body unresolved>{\n}
-  <gr-app id="app"></gr-app>{\n}
-{/template}
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/IndexServletTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/IndexServletTest.java
deleted file mode 100644
index d106eec..0000000
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.template.soy.data.SoyMapData;
-import java.net.URISyntaxException;
-import org.junit.Test;
-
-public class IndexServletTest {
-  class TestIndexServlet extends IndexServlet {
-    private static final long serialVersionUID = 1L;
-
-    TestIndexServlet(String canonicalURL, String cdnPath) throws URISyntaxException {
-      super(canonicalURL, cdnPath);
-    }
-
-    String getIndexSource() {
-      return new String(indexSource);
-    }
-  }
-
-  @Test
-  public void noPathAndNoCDN() throws URISyntaxException {
-    SoyMapData data = IndexServlet.getTemplateData("http://example.com/", null);
-    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("");
-    assertThat(data.getSingle("staticResourcePath").stringValue()).isEqualTo("");
-  }
-
-  @Test
-  public void pathAndNoCDN() throws URISyntaxException {
-    SoyMapData data = IndexServlet.getTemplateData("http://example.com/gerrit/", null);
-    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("/gerrit");
-    assertThat(data.getSingle("staticResourcePath").stringValue()).isEqualTo("/gerrit");
-  }
-
-  @Test
-  public void noPathAndCDN() throws URISyntaxException {
-    SoyMapData data =
-        IndexServlet.getTemplateData("http://example.com/", "http://my-cdn.com/foo/bar/");
-    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("");
-    assertThat(data.getSingle("staticResourcePath").stringValue())
-        .isEqualTo("http://my-cdn.com/foo/bar/");
-  }
-
-  @Test
-  public void pathAndCDN() throws URISyntaxException {
-    SoyMapData data =
-        IndexServlet.getTemplateData("http://example.com/gerrit", "http://my-cdn.com/foo/bar/");
-    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("/gerrit");
-    assertThat(data.getSingle("staticResourcePath").stringValue())
-        .isEqualTo("http://my-cdn.com/foo/bar/");
-  }
-
-  @Test
-  public void renderTemplate() throws URISyntaxException {
-    String testCanonicalUrl = "foo-url";
-    String testCdnPath = "bar-cdn";
-    TestIndexServlet servlet = new TestIndexServlet(testCanonicalUrl, testCdnPath);
-    String output = servlet.getIndexSource();
-    assertThat(output).contains("<!DOCTYPE html>");
-    assertThat(output).contains("window.CANONICAL_PATH = '" + testCanonicalUrl);
-    assertThat(output).contains("<link rel=\"preload\" href=\"" + testCdnPath);
-  }
-}
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
deleted file mode 100644
index 18256c6..0000000
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
+++ /dev/null
@@ -1,370 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
-import static javax.servlet.http.HttpServletResponse.SC_OK;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Strings;
-import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
-import com.google.common.collect.Lists;
-import com.google.common.io.ByteStreams;
-import com.google.common.jimfs.Configuration;
-import com.google.common.jimfs.Jimfs;
-import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
-import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
-import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
-import java.io.ByteArrayInputStream;
-import java.io.InputStream;
-import java.nio.file.FileSystem;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.attribute.FileTime;
-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;
-
-public class ResourceServletTest {
-  private static Cache<Path, Resource> newCache(int size) {
-    return CacheBuilder.newBuilder().maximumSize(size).recordStats().build();
-  }
-
-  private static class Servlet extends ResourceServlet {
-    private static final long serialVersionUID = 1L;
-
-    private final FileSystem fs;
-
-    private Servlet(FileSystem fs, Cache<Path, Resource> cache, boolean refresh) {
-      super(cache, refresh);
-      this.fs = fs;
-    }
-
-    private Servlet(
-        FileSystem fs, Cache<Path, Resource> cache, boolean refresh, boolean cacheOnClient) {
-      super(cache, refresh, cacheOnClient);
-      this.fs = fs;
-    }
-
-    private Servlet(
-        FileSystem fs, Cache<Path, Resource> cache, boolean refresh, int cacheFileSizeLimitBytes) {
-      super(cache, refresh, true, cacheFileSizeLimitBytes);
-      this.fs = fs;
-    }
-
-    private Servlet(
-        FileSystem fs,
-        Cache<Path, Resource> cache,
-        boolean refresh,
-        boolean cacheOnClient,
-        int cacheFileSizeLimitBytes) {
-      super(cache, refresh, cacheOnClient, cacheFileSizeLimitBytes);
-      this.fs = fs;
-    }
-
-    @Override
-    protected Path getResourcePath(String pathInfo) {
-      return fs.getPath("/" + CharMatcher.is('/').trimLeadingFrom(pathInfo));
-    }
-  }
-
-  private FileSystem fs;
-  private AtomicLong ts;
-
-  @Before
-  public void setUp() {
-    fs = Jimfs.newFileSystem(Configuration.unix());
-    ts = new AtomicLong(ISODateTimeFormat.dateTime().parseMillis("2010-01-30T12:00:00.000-08:00"));
-  }
-
-  @Test
-  public void notFoundWithoutRefresh() throws Exception {
-    Cache<Path, Resource> cache = newCache(1);
-    Servlet servlet = new Servlet(fs, cache, false);
-
-    FakeHttpServletResponse res = new FakeHttpServletResponse();
-    servlet.doGet(request("/notfound"), res);
-    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
-    assertNotCacheable(res);
-    assertCacheHits(cache, 0, 1);
-
-    res = new FakeHttpServletResponse();
-    servlet.doGet(request("/notfound"), res);
-    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
-    assertNotCacheable(res);
-    assertCacheHits(cache, 1, 1);
-  }
-
-  @Test
-  public void notFoundWithRefresh() throws Exception {
-    Cache<Path, Resource> cache = newCache(1);
-    Servlet servlet = new Servlet(fs, cache, true);
-
-    FakeHttpServletResponse res = new FakeHttpServletResponse();
-    servlet.doGet(request("/notfound"), res);
-    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
-    assertNotCacheable(res);
-    assertCacheHits(cache, 0, 1);
-
-    res = new FakeHttpServletResponse();
-    servlet.doGet(request("/notfound"), res);
-    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
-    assertNotCacheable(res);
-    assertCacheHits(cache, 1, 1);
-  }
-
-  @Test
-  public void smallFileWithRefresh() throws Exception {
-    Cache<Path, Resource> cache = newCache(1);
-    Servlet servlet = new Servlet(fs, cache, true);
-
-    writeFile("/foo", "foo1");
-    FakeHttpServletResponse res = new FakeHttpServletResponse();
-    servlet.doGet(request("/foo"), res);
-    assertThat(res.getStatus()).isEqualTo(SC_OK);
-    assertThat(res.getActualBodyString()).isEqualTo("foo1");
-    assertCacheable(res, true);
-    assertHasETag(res);
-    // Miss on getIfPresent, miss on get.
-    assertCacheHits(cache, 0, 2);
-
-    res = new FakeHttpServletResponse();
-    servlet.doGet(request("/foo"), res);
-    assertThat(res.getStatus()).isEqualTo(SC_OK);
-    assertThat(res.getActualBodyString()).isEqualTo("foo1");
-    assertCacheable(res, true);
-    assertHasETag(res);
-    assertCacheHits(cache, 1, 2);
-
-    writeFile("/foo", "foo2");
-    res = new FakeHttpServletResponse();
-    servlet.doGet(request("/foo"), res);
-    assertThat(res.getStatus()).isEqualTo(SC_OK);
-    assertThat(res.getActualBodyString()).isEqualTo("foo2");
-    assertCacheable(res, true);
-    assertHasETag(res);
-    // Hit, invalidate, miss.
-    assertCacheHits(cache, 2, 3);
-  }
-
-  @Test
-  public void smallFileWithoutClientCache() throws Exception {
-    Cache<Path, Resource> cache = newCache(1);
-    Servlet servlet = new Servlet(fs, cache, false, false);
-
-    writeFile("/foo", "foo1");
-    FakeHttpServletResponse res = new FakeHttpServletResponse();
-    servlet.doGet(request("/foo"), res);
-    assertThat(res.getStatus()).isEqualTo(SC_OK);
-    assertThat(res.getActualBodyString()).isEqualTo("foo1");
-    assertNotCacheable(res);
-
-    // Miss on getIfPresent, miss on get.
-    assertCacheHits(cache, 0, 2);
-
-    res = new FakeHttpServletResponse();
-    servlet.doGet(request("/foo"), res);
-    assertThat(res.getStatus()).isEqualTo(SC_OK);
-    assertThat(res.getActualBodyString()).isEqualTo("foo1");
-    assertNotCacheable(res);
-    assertCacheHits(cache, 1, 2);
-
-    writeFile("/foo", "foo2");
-    res = new FakeHttpServletResponse();
-    servlet.doGet(request("/foo"), res);
-    assertThat(res.getStatus()).isEqualTo(SC_OK);
-    assertThat(res.getActualBodyString()).isEqualTo("foo1");
-    assertNotCacheable(res);
-    assertCacheHits(cache, 2, 2);
-  }
-
-  @Test
-  public void smallFileWithoutRefresh() throws Exception {
-    Cache<Path, Resource> cache = newCache(1);
-    Servlet servlet = new Servlet(fs, cache, false);
-
-    writeFile("/foo", "foo1");
-    FakeHttpServletResponse res = new FakeHttpServletResponse();
-    servlet.doGet(request("/foo"), res);
-    assertThat(res.getStatus()).isEqualTo(SC_OK);
-    assertThat(res.getActualBodyString()).isEqualTo("foo1");
-    assertCacheable(res, false);
-    assertHasETag(res);
-    // Miss on getIfPresent, miss on get.
-    assertCacheHits(cache, 0, 2);
-
-    res = new FakeHttpServletResponse();
-    servlet.doGet(request("/foo"), res);
-    assertThat(res.getStatus()).isEqualTo(SC_OK);
-    assertThat(res.getActualBodyString()).isEqualTo("foo1");
-    assertCacheable(res, false);
-    assertHasETag(res);
-    assertCacheHits(cache, 1, 2);
-
-    writeFile("/foo", "foo2");
-    res = new FakeHttpServletResponse();
-    servlet.doGet(request("/foo"), res);
-    assertThat(res.getStatus()).isEqualTo(SC_OK);
-    assertThat(res.getActualBodyString()).isEqualTo("foo1");
-    assertCacheable(res, false);
-    assertHasETag(res);
-    assertCacheHits(cache, 2, 2);
-  }
-
-  @Test
-  public void verySmallFileDoesntBotherWithGzip() throws Exception {
-    Cache<Path, Resource> cache = newCache(1);
-    Servlet servlet = new Servlet(fs, cache, true);
-    writeFile("/foo", "foo1");
-
-    FakeHttpServletRequest req = request("/foo").addHeader("Accept-Encoding", "gzip");
-    FakeHttpServletResponse res = new FakeHttpServletResponse();
-    servlet.doGet(req, res);
-    assertThat(res.getStatus()).isEqualTo(SC_OK);
-    assertThat(res.getHeader("Content-Encoding")).isNull();
-    assertThat(res.getActualBodyString()).isEqualTo("foo1");
-    assertHasETag(res);
-    assertCacheable(res, true);
-  }
-
-  @Test
-  public void smallFileWithGzip() throws Exception {
-    Cache<Path, Resource> cache = newCache(1);
-    Servlet servlet = new Servlet(fs, cache, true);
-    String content = Strings.repeat("a", 100);
-    writeFile("/foo", content);
-
-    FakeHttpServletRequest req = request("/foo").addHeader("Accept-Encoding", "gzip");
-    FakeHttpServletResponse res = new FakeHttpServletResponse();
-    servlet.doGet(req, res);
-    assertThat(res.getStatus()).isEqualTo(SC_OK);
-    assertThat(res.getHeader("Content-Encoding")).isEqualTo("gzip");
-    assertThat(gunzip(res.getActualBody())).isEqualTo(content);
-    assertHasETag(res);
-    assertCacheable(res, true);
-  }
-
-  @Test
-  public void largeFileBypassesCacheRegardlessOfRefreshParamter() throws Exception {
-    for (boolean refresh : Lists.newArrayList(true, false)) {
-      Cache<Path, Resource> cache = newCache(1);
-      Servlet servlet = new Servlet(fs, cache, refresh, 3);
-
-      writeFile("/foo", "foo1");
-      FakeHttpServletResponse res = new FakeHttpServletResponse();
-      servlet.doGet(request("/foo"), res);
-      assertThat(res.getStatus()).isEqualTo(SC_OK);
-      assertThat(res.getActualBodyString()).isEqualTo("foo1");
-      assertThat(res.getHeader("Last-Modified")).isNotNull();
-      assertCacheable(res, refresh);
-      assertHasLastModified(res);
-      assertCacheHits(cache, 0, 1);
-
-      writeFile("/foo", "foo1");
-      res = new FakeHttpServletResponse();
-      servlet.doGet(request("/foo"), res);
-      assertThat(res.getStatus()).isEqualTo(SC_OK);
-      assertThat(res.getActualBodyString()).isEqualTo("foo1");
-      assertThat(res.getHeader("Last-Modified")).isNotNull();
-      assertCacheable(res, refresh);
-      assertHasLastModified(res);
-      assertCacheHits(cache, 0, 2);
-
-      writeFile("/foo", "foo2");
-      res = new FakeHttpServletResponse();
-      servlet.doGet(request("/foo"), res);
-      assertThat(res.getStatus()).isEqualTo(SC_OK);
-      assertThat(res.getActualBodyString()).isEqualTo("foo2");
-      assertThat(res.getHeader("Last-Modified")).isNotNull();
-      assertCacheable(res, refresh);
-      assertHasLastModified(res);
-      assertCacheHits(cache, 0, 3);
-    }
-  }
-
-  @Test
-  public void largeFileWithGzip() throws Exception {
-    Cache<Path, Resource> cache = newCache(1);
-    Servlet servlet = new Servlet(fs, cache, true, 3);
-    String content = Strings.repeat("a", 100);
-    writeFile("/foo", content);
-
-    FakeHttpServletRequest req = request("/foo").addHeader("Accept-Encoding", "gzip");
-    FakeHttpServletResponse res = new FakeHttpServletResponse();
-    servlet.doGet(req, res);
-    assertThat(res.getStatus()).isEqualTo(SC_OK);
-    assertThat(res.getHeader("Content-Encoding")).isEqualTo("gzip");
-    assertThat(gunzip(res.getActualBody())).isEqualTo(content);
-    assertHasLastModified(res);
-    assertCacheable(res, true);
-  }
-
-  // TODO(dborowitz): Check MIME type.
-  // TODO(dborowitz): Test that JS is not gzipped.
-  // TODO(dborowitz): Test ?e parameter.
-  // TODO(dborowitz): Test If-None-Match behavior.
-  // TODO(dborowitz): Test If-Modified-Since behavior.
-
-  private void writeFile(String path, String content) throws Exception {
-    Files.write(fs.getPath(path), content.getBytes(UTF_8));
-    Files.setLastModifiedTime(fs.getPath(path), FileTime.fromMillis(ts.getAndIncrement()));
-  }
-
-  private static void assertCacheHits(Cache<?, ?> cache, int hits, int misses) {
-    assertThat(cache.stats().hitCount()).named("hits").isEqualTo(hits);
-    assertThat(cache.stats().missCount()).named("misses").isEqualTo(misses);
-  }
-
-  private static void assertCacheable(FakeHttpServletResponse res, boolean revalidate) {
-    String header = res.getHeader("Cache-Control").toLowerCase();
-    assertThat(header).contains("public");
-    if (revalidate) {
-      assertThat(header).contains("must-revalidate");
-    } else {
-      assertThat(header).doesNotContain("must-revalidate");
-    }
-  }
-
-  private static void assertHasLastModified(FakeHttpServletResponse res) {
-    assertThat(res.getHeader("Last-Modified")).isNotNull();
-    assertThat(res.getHeader("ETag")).isNull();
-  }
-
-  private static void assertHasETag(FakeHttpServletResponse res) {
-    assertThat(res.getHeader("ETag")).isNotNull();
-    assertThat(res.getHeader("Last-Modified")).isNull();
-  }
-
-  private static void assertNotCacheable(FakeHttpServletResponse res) {
-    assertThat(res.getHeader("Cache-Control")).contains("no-cache");
-    assertThat(res.getHeader("ETag")).isNull();
-    assertThat(res.getHeader("Last-Modified")).isNull();
-  }
-
-  private static FakeHttpServletRequest request(String path) {
-    return new FakeHttpServletRequest().setPathInfo(path);
-  }
-
-  private static String gunzip(byte[] data) throws Exception {
-    try (InputStream in = new GZIPInputStream(new ByteArrayInputStream(data))) {
-      return new String(ByteStreams.toByteArray(in), UTF_8);
-    }
-  }
-}
diff --git a/gerrit-index/BUILD b/gerrit-index/BUILD
deleted file mode 100644
index 11c4f08..0000000
--- a/gerrit-index/BUILD
+++ /dev/null
@@ -1,77 +0,0 @@
-load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-QUERY_PARSE_EXCEPTION_SRCS = [
-    "src/main/java/com/google/gerrit/index/query/QueryParseException.java",
-    "src/main/java/com/google/gerrit/index/query/QueryRequiresAuthException.java",
-]
-
-java_library(
-    name = "query_exception",
-    srcs = QUERY_PARSE_EXCEPTION_SRCS,
-    visibility = ["//visibility:public"],
-)
-
-genrule2(
-    name = "query_antlr",
-    srcs = ["src/main/antlr3/com/google/gerrit/index/query/Query.g"],
-    outs = ["query_antlr.srcjar"],
-    cmd = " && ".join([
-        "$(location //lib/antlr:antlr-tool) -o $$TMP $<",
-        "cd $$TMP",
-        "$$ROOT/$(location @bazel_tools//tools/zip:zipper) cC $$ROOT/$@ $$(find *)",
-    ]),
-    tools = [
-        "//lib/antlr:antlr-tool",
-        "@bazel_tools//tools/zip:zipper",
-    ],
-)
-
-java_library(
-    name = "query_parser",
-    srcs = [":query_antlr"],
-    visibility = ["//gerrit-plugin-api:__pkg__"],
-    deps = [
-        ":query_exception",
-        "//lib/antlr:java_runtime",
-    ],
-)
-
-java_library(
-    name = "index",
-    srcs = glob(
-        ["src/main/java/**/*.java"],
-        exclude = QUERY_PARSE_EXCEPTION_SRCS,
-    ),
-    visibility = ["//visibility:public"],
-    deps = [
-        ":query_exception",
-        ":query_parser",
-        "//gerrit-common:annotations",
-        "//gerrit-extension-api:api",
-        "//gerrit-server:metrics",
-        "//lib:guava",
-        "//lib:gwtjsonrpc",
-        "//lib:gwtorm",
-        "//lib/antlr:java_runtime",
-        "//lib/auto:auto-value",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/log:api",
-    ],
-)
-
-junit_tests(
-    name = "index_tests",
-    size = "small",
-    srcs = glob(["src/test/java/**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        ":index",
-        ":query_exception",
-        ":query_parser",
-        "//lib:junit",
-        "//lib:truth",
-        "//lib/antlr:java_runtime",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-    ],
-)
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/Index.java b/gerrit-index/src/main/java/com/google/gerrit/index/Index.java
deleted file mode 100644
index 34f7d33..0000000
--- a/gerrit-index/src/main/java/com/google/gerrit/index/Index.java
+++ /dev/null
@@ -1,130 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.index;
-
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import java.util.List;
-import java.util.Optional;
-
-/**
- * Secondary index implementation for arbitrary documents.
- *
- * <p>Documents are inserted into the index and are queried by converting special {@link
- * com.google.gerrit.index.query.Predicate} instances into index-aware predicates that use the index
- * search results as a source.
- *
- * <p>Implementations must be thread-safe and should batch inserts/updates where appropriate.
- */
-public interface Index<K, V> {
-  /** @return the schema version used by this index. */
-  Schema<V> getSchema();
-
-  /** Close this index. */
-  void close();
-
-  /**
-   * Update a document in the index.
-   *
-   * <p>Semantically equivalent to deleting the document and reinserting it with new field values. A
-   * document that does not already exist is created. Results may not be immediately visible to
-   * searchers, but should be visible within a reasonable amount of time.
-   *
-   * @param obj document object
-   * @throws IOException
-   */
-  void replace(V obj) throws IOException;
-
-  /**
-   * Delete a document from the index by key.
-   *
-   * @param key document key
-   * @throws IOException
-   */
-  void delete(K key) throws IOException;
-
-  /**
-   * Delete all documents from the index.
-   *
-   * @throws IOException
-   */
-  void deleteAll() throws IOException;
-
-  /**
-   * Convert the given operator predicate into a source searching the index and returning only the
-   * documents matching that predicate.
-   *
-   * <p>This method may be called multiple times for variations on the same predicate or multiple
-   * predicate subtrees in the course of processing a single query, so it should not have any side
-   * effects (e.g. starting a search in the background).
-   *
-   * @param p the predicate to match. Must be a tree containing only AND, OR, or NOT predicates as
-   *     internal nodes, and {@link IndexPredicate}s as leaves.
-   * @param opts query options not implied by the predicate, such as start and limit.
-   * @return a source of documents matching the predicate, returned in a defined order depending on
-   *     the type of documents.
-   * @throws QueryParseException if the predicate could not be converted to an indexed data source.
-   */
-  DataSource<V> getSource(Predicate<V> p, QueryOptions opts) throws QueryParseException;
-
-  /**
-   * Get a single document from the index.
-   *
-   * @param key document key.
-   * @param opts query options. Options that do not make sense in the context of a single document,
-   *     such as start, will be ignored.
-   * @return a single document if present.
-   * @throws IOException
-   */
-  default Optional<V> get(K key, QueryOptions opts) throws IOException {
-    opts = opts.withStart(0).withLimit(2);
-    List<V> results;
-    try {
-      results = getSource(keyPredicate(key), opts).read().toList();
-    } catch (QueryParseException e) {
-      throw new IOException("Unexpected QueryParseException during get()", e);
-    } catch (OrmException e) {
-      throw new IOException(e);
-    }
-    switch (results.size()) {
-      case 0:
-        return Optional.empty();
-      case 1:
-        return Optional.of(results.get(0));
-      default:
-        throw new IOException("Multiple results found in index for key " + key + ": " + results);
-    }
-  }
-
-  /**
-   * Get a predicate that looks up a single document by key.
-   *
-   * @param key document key.
-   * @return a single predicate.
-   */
-  Predicate<V> keyPredicate(K key);
-
-  /**
-   * Mark whether this index is up-to-date and ready to serve reads.
-   *
-   * @param ready whether the index is ready
-   * @throws IOException
-   */
-  void markReady(boolean ready) throws IOException;
-}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/IndexCollection.java b/gerrit-index/src/main/java/com/google/gerrit/index/IndexCollection.java
deleted file mode 100644
index 2837f7e..0000000
--- a/gerrit-index/src/main/java/com/google/gerrit/index/IndexCollection.java
+++ /dev/null
@@ -1,102 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.index;
-
-import com.google.common.collect.Lists;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.concurrent.atomic.AtomicReference;
-
-/** Dynamic pointers to the index versions used for searching and writing. */
-public abstract class IndexCollection<K, V, I extends Index<K, V>> implements LifecycleListener {
-  private final CopyOnWriteArrayList<I> writeIndexes;
-  private final AtomicReference<I> searchIndex;
-
-  protected IndexCollection() {
-    this.writeIndexes = Lists.newCopyOnWriteArrayList();
-    this.searchIndex = new AtomicReference<>();
-  }
-
-  /** @return the current search index version. */
-  public I getSearchIndex() {
-    return searchIndex.get();
-  }
-
-  public void setSearchIndex(I index) {
-    I old = searchIndex.getAndSet(index);
-    if (old != null && old != index && !writeIndexes.contains(old)) {
-      old.close();
-    }
-  }
-
-  public Collection<I> getWriteIndexes() {
-    return Collections.unmodifiableCollection(writeIndexes);
-  }
-
-  public synchronized I addWriteIndex(I index) {
-    int version = index.getSchema().getVersion();
-    for (int i = 0; i < writeIndexes.size(); i++) {
-      if (writeIndexes.get(i).getSchema().getVersion() == version) {
-        return writeIndexes.set(i, index);
-      }
-    }
-    writeIndexes.add(index);
-    return null;
-  }
-
-  public synchronized void removeWriteIndex(int version) {
-    int removeIndex = -1;
-    for (int i = 0; i < writeIndexes.size(); i++) {
-      if (writeIndexes.get(i).getSchema().getVersion() == version) {
-        removeIndex = i;
-        break;
-      }
-    }
-    if (removeIndex >= 0) {
-      try {
-        writeIndexes.get(removeIndex).close();
-      } finally {
-        writeIndexes.remove(removeIndex);
-      }
-    }
-  }
-
-  public I getWriteIndex(int version) {
-    for (I i : writeIndexes) {
-      if (i.getSchema().getVersion() == version) {
-        return i;
-      }
-    }
-    return null;
-  }
-
-  @Override
-  public void start() {}
-
-  @Override
-  public void stop() {
-    I read = searchIndex.get();
-    if (read != null) {
-      read.close();
-    }
-    for (I write : writeIndexes) {
-      if (write != read) {
-        write.close();
-      }
-    }
-  }
-}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/IndexConfig.java b/gerrit-index/src/main/java/com/google/gerrit/index/IndexConfig.java
deleted file mode 100644
index b53b59b..0000000
--- a/gerrit-index/src/main/java/com/google/gerrit/index/IndexConfig.java
+++ /dev/null
@@ -1,112 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.index;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.auto.value.AutoValue;
-import java.util.function.IntConsumer;
-import org.eclipse.jgit.lib.Config;
-
-/**
- * Implementation-specific configuration for secondary indexes.
- *
- * <p>Contains configuration that is tied to a specific index implementation but is otherwise
- * global, i.e. not tied to a specific {@link Index} and schema version.
- */
-@AutoValue
-public abstract class IndexConfig {
-  private static final int DEFAULT_MAX_TERMS = 1024;
-
-  public static IndexConfig createDefault() {
-    return builder().build();
-  }
-
-  public static Builder fromConfig(Config cfg) {
-    Builder b = builder();
-    setIfPresent(cfg, "maxLimit", b::maxLimit);
-    setIfPresent(cfg, "maxPages", b::maxPages);
-    setIfPresent(cfg, "maxTerms", b::maxTerms);
-    return b;
-  }
-
-  private static void setIfPresent(Config cfg, String name, IntConsumer setter) {
-    int n = cfg.getInt("index", null, name, 0);
-    if (n != 0) {
-      setter.accept(n);
-    }
-  }
-
-  public static Builder builder() {
-    return new AutoValue_IndexConfig.Builder()
-        .maxLimit(Integer.MAX_VALUE)
-        .maxPages(Integer.MAX_VALUE)
-        .maxTerms(DEFAULT_MAX_TERMS)
-        .separateChangeSubIndexes(false);
-  }
-
-  @AutoValue.Builder
-  public abstract static class Builder {
-    public abstract Builder maxLimit(int maxLimit);
-
-    abstract int maxLimit();
-
-    public abstract Builder maxPages(int maxPages);
-
-    abstract int maxPages();
-
-    public abstract Builder maxTerms(int maxTerms);
-
-    abstract int maxTerms();
-
-    public abstract Builder separateChangeSubIndexes(boolean separate);
-
-    abstract IndexConfig autoBuild();
-
-    public IndexConfig build() {
-      IndexConfig cfg = autoBuild();
-      checkLimit(cfg.maxLimit(), "maxLimit");
-      checkLimit(cfg.maxPages(), "maxPages");
-      checkLimit(cfg.maxTerms(), "maxTerms");
-      return cfg;
-    }
-  }
-
-  private static void checkLimit(int limit, String name) {
-    checkArgument(limit > 0, "%s must be positive: %s", name, limit);
-  }
-
-  /**
-   * @return maximum limit supported by the underlying index, or limited for performance reasons.
-   */
-  public abstract int maxLimit();
-
-  /**
-   * @return maximum number of pages (limit / start) supported by the underlying index, or limited
-   *     for performance reasons.
-   */
-  public abstract int maxPages();
-
-  /**
-   * @return maximum number of total index query terms supported by the underlying index, or limited
-   *     for performance reasons.
-   */
-  public abstract int maxTerms();
-
-  /**
-   * @return whether different subsets of changes may be stored in different physical sub-indexes.
-   */
-  public abstract boolean separateChangeSubIndexes();
-}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/IndexedQuery.java b/gerrit-index/src/main/java/com/google/gerrit/index/IndexedQuery.java
deleted file mode 100644
index 050b4a9..0000000
--- a/gerrit-index/src/main/java/com/google/gerrit/index/IndexedQuery.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.index;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Paginated;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import java.util.Collection;
-import java.util.List;
-
-/**
- * Wrapper combining an {@link IndexPredicate} together with a {@link DataSource} that returns
- * matching results from the index.
- *
- * <p>Appropriate to return as the rootmost predicate that can be processed using the secondary
- * index; such predicates must also implement {@link DataSource} to be chosen by the query
- * processor.
- *
- * @param <I> The type of the IDs by which the entities are stored in the index.
- * @param <T> The type of the entities that are stored in the index.
- */
-public class IndexedQuery<I, T> extends Predicate<T> implements DataSource<T>, Paginated<T> {
-  protected final Index<I, T> index;
-
-  private QueryOptions opts;
-  private final Predicate<T> pred;
-  protected DataSource<T> source;
-
-  public IndexedQuery(Index<I, T> index, Predicate<T> pred, QueryOptions opts)
-      throws QueryParseException {
-    this.index = index;
-    this.opts = opts;
-    this.pred = pred;
-    this.source = index.getSource(pred, this.opts);
-  }
-
-  @Override
-  public int getChildCount() {
-    return 1;
-  }
-
-  @Override
-  public Predicate<T> getChild(int i) {
-    if (i == 0) {
-      return pred;
-    }
-    throw new ArrayIndexOutOfBoundsException(i);
-  }
-
-  @Override
-  public List<Predicate<T>> getChildren() {
-    return ImmutableList.of(pred);
-  }
-
-  @Override
-  public QueryOptions getOptions() {
-    return opts;
-  }
-
-  @Override
-  public int getCardinality() {
-    return source != null ? source.getCardinality() : opts.limit();
-  }
-
-  @Override
-  public ResultSet<T> read() throws OrmException {
-    return source.read();
-  }
-
-  @Override
-  public ResultSet<T> restart(int start) throws OrmException {
-    opts = opts.withStart(start);
-    try {
-      source = index.getSource(pred, opts);
-    } catch (QueryParseException e) {
-      // Don't need to show this exception to the user; the only thing that
-      // changed about pred was its start, and any other QPEs that might happen
-      // should have already thrown from the constructor.
-      throw new OrmException(e);
-    }
-    // Don't convert start to a limit, since the caller of this method (see
-    // AndSource) has calculated the actual number to skip.
-    return read();
-  }
-
-  @Override
-  public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
-    return this;
-  }
-
-  @Override
-  public int hashCode() {
-    return pred.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object other) {
-    if (other == null || getClass() != other.getClass()) {
-      return false;
-    }
-    IndexedQuery<?, ?> o = (IndexedQuery<?, ?>) other;
-    return pred.equals(o.pred) && opts.equals(o.opts);
-  }
-
-  @Override
-  public String toString() {
-    return MoreObjects.toStringHelper("index").add("p", pred).add("opts", opts).toString();
-  }
-}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/QueryOptions.java b/gerrit-index/src/main/java/com/google/gerrit/index/QueryOptions.java
deleted file mode 100644
index b57fb5f..0000000
--- a/gerrit-index/src/main/java/com/google/gerrit/index/QueryOptions.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.index;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.primitives.Ints;
-import java.util.Set;
-
-@AutoValue
-public abstract class QueryOptions {
-  public static QueryOptions create(IndexConfig config, int start, int limit, Set<String> fields) {
-    checkArgument(start >= 0, "start must be nonnegative: %s", start);
-    checkArgument(limit > 0, "limit must be positive: %s", limit);
-    return new AutoValue_QueryOptions(config, start, limit, ImmutableSet.copyOf(fields));
-  }
-
-  public QueryOptions convertForBackend() {
-    // Increase the limit rather than skipping, since we don't know how many
-    // skipped results would have been filtered out by the enclosing AndSource.
-    int backendLimit = config().maxLimit();
-    int limit = Ints.saturatedCast((long) limit() + start());
-    limit = Math.min(limit, backendLimit);
-    return create(config(), 0, limit, fields());
-  }
-
-  public abstract IndexConfig config();
-
-  public abstract int start();
-
-  public abstract int limit();
-
-  public abstract ImmutableSet<String> fields();
-
-  public QueryOptions withLimit(int newLimit) {
-    return create(config(), start(), newLimit, fields());
-  }
-
-  public QueryOptions withStart(int newStart) {
-    return create(config(), newStart, limit(), fields());
-  }
-}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/AndSource.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/AndSource.java
deleted file mode 100644
index 16620b3..0000000
--- a/gerrit-index/src/main/java/com/google/gerrit/index/query/AndSource.java
+++ /dev/null
@@ -1,200 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.index.query;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.base.Throwables;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.gwtorm.server.ListResultSet;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.gwtorm.server.ResultSet;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-
-public class AndSource<T> extends AndPredicate<T>
-    implements DataSource<T>, Comparator<Predicate<T>> {
-  protected final DataSource<T> source;
-
-  private final IsVisibleToPredicate<T> isVisibleToPredicate;
-  private final int start;
-  private final int cardinality;
-
-  public AndSource(Collection<? extends Predicate<T>> that) {
-    this(that, null, 0);
-  }
-
-  public AndSource(Predicate<T> that, IsVisibleToPredicate<T> isVisibleToPredicate) {
-    this(that, isVisibleToPredicate, 0);
-  }
-
-  public AndSource(Predicate<T> that, IsVisibleToPredicate<T> isVisibleToPredicate, int start) {
-    this(ImmutableList.of(that), isVisibleToPredicate, start);
-  }
-
-  public AndSource(
-      Collection<? extends Predicate<T>> that,
-      IsVisibleToPredicate<T> isVisibleToPredicate,
-      int start) {
-    super(that);
-    checkArgument(start >= 0, "negative start: %s", start);
-    this.isVisibleToPredicate = isVisibleToPredicate;
-    this.start = start;
-
-    int c = Integer.MAX_VALUE;
-    DataSource<T> s = null;
-    int minCost = Integer.MAX_VALUE;
-    for (Predicate<T> p : sort(getChildren())) {
-      if (p instanceof DataSource) {
-        c = Math.min(c, ((DataSource<?>) p).getCardinality());
-
-        int cost = p.estimateCost();
-        if (cost < minCost) {
-          s = toDataSource(p);
-          minCost = cost;
-        }
-      }
-    }
-    this.source = s;
-    this.cardinality = c;
-  }
-
-  @Override
-  public ResultSet<T> read() throws OrmException {
-    try {
-      return readImpl();
-    } catch (OrmRuntimeException err) {
-      if (err.getCause() != null) {
-        Throwables.throwIfInstanceOf(err.getCause(), OrmException.class);
-      }
-      throw new OrmException(err);
-    }
-  }
-
-  private ResultSet<T> readImpl() throws OrmException {
-    if (source == null) {
-      throw new OrmException("No DataSource: " + this);
-    }
-    List<T> r = new ArrayList<>();
-    T last = null;
-    int nextStart = 0;
-    boolean skipped = false;
-    for (T data : buffer(source.read())) {
-      if (!isMatchable() || match(data)) {
-        r.add(data);
-      } else {
-        skipped = true;
-      }
-      last = data;
-      nextStart++;
-    }
-
-    if (skipped && last != null && source instanceof Paginated) {
-      // If our source is a paginated source and we skipped at
-      // least one of its results, we may not have filled the full
-      // limit the caller wants.  Restart the source and continue.
-      //
-      @SuppressWarnings("unchecked")
-      Paginated<T> p = (Paginated<T>) source;
-      while (skipped && r.size() < p.getOptions().limit() + start) {
-        skipped = false;
-        ResultSet<T> next = p.restart(nextStart);
-
-        for (T data : buffer(next)) {
-          if (match(data)) {
-            r.add(data);
-          } else {
-            skipped = true;
-          }
-          nextStart++;
-        }
-      }
-    }
-
-    if (start >= r.size()) {
-      r = ImmutableList.of();
-    } else if (start > 0) {
-      r = ImmutableList.copyOf(r.subList(start, r.size()));
-    }
-    return new ListResultSet<>(r);
-  }
-
-  @Override
-  public boolean isMatchable() {
-    return isVisibleToPredicate != null || super.isMatchable();
-  }
-
-  @Override
-  public boolean match(T object) throws OrmException {
-    if (isVisibleToPredicate != null && !isVisibleToPredicate.match(object)) {
-      return false;
-    }
-
-    if (super.isMatchable() && !super.match(object)) {
-      return false;
-    }
-
-    return true;
-  }
-
-  private Iterable<T> buffer(ResultSet<T> scanner) {
-    return FluentIterable.from(Iterables.partition(scanner, 50))
-        .transformAndConcat(this::transformBuffer);
-  }
-
-  protected List<T> transformBuffer(List<T> buffer) throws OrmRuntimeException {
-    return buffer;
-  }
-
-  @Override
-  public int getCardinality() {
-    return cardinality;
-  }
-
-  private List<Predicate<T>> sort(Collection<? extends Predicate<T>> that) {
-    List<Predicate<T>> r = new ArrayList<>(that);
-    Collections.sort(r, this);
-    return r;
-  }
-
-  @Override
-  public int compare(Predicate<T> a, Predicate<T> b) {
-    int ai = a instanceof DataSource ? 0 : 1;
-    int bi = b instanceof DataSource ? 0 : 1;
-    int cmp = ai - bi;
-
-    if (cmp == 0) {
-      cmp = a.estimateCost() - b.estimateCost();
-    }
-
-    if (cmp == 0 && a instanceof DataSource && b instanceof DataSource) {
-      DataSource<?> as = (DataSource<?>) a;
-      DataSource<?> bs = (DataSource<?>) b;
-      cmp = as.getCardinality() - bs.getCardinality();
-    }
-    return cmp;
-  }
-
-  @SuppressWarnings("unchecked")
-  private DataSource<T> toDataSource(Predicate<T> pred) {
-    return (DataSource<T>) pred;
-  }
-}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/DataSource.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/DataSource.java
deleted file mode 100644
index 77dcca2..0000000
--- a/gerrit-index/src/main/java/com/google/gerrit/index/query/DataSource.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.index.query;
-
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-
-public interface DataSource<T> {
-  /** @return an estimate of the number of results from {@link #read()}. */
-  int getCardinality();
-
-  /** @return read from the database and return the results. */
-  ResultSet<T> read() throws OrmException;
-}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/InternalQuery.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/InternalQuery.java
deleted file mode 100644
index 0f8948b..0000000
--- a/gerrit-index/src/main/java/com/google/gerrit/index/query/InternalQuery.java
+++ /dev/null
@@ -1,102 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.index.query;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Lists;
-import com.google.gerrit.index.Index;
-import com.google.gerrit.index.IndexCollection;
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.Schema;
-import com.google.gwtorm.server.OrmException;
-import java.util.List;
-import java.util.Set;
-
-/**
- * Execute a single query over a secondary index, for use by Gerrit internals.
- *
- * <p>By default, visibility of returned entities is not enforced (unlike in {@link
- * QueryProcessor}). The methods in this class are not typically used by user-facing paths, but
- * rather by internal callers that need to process all matching results.
- *
- * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
- * holding on to a single instance.
- */
-public class InternalQuery<T> {
-  private final QueryProcessor<T> queryProcessor;
-  private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
-
-  protected final IndexConfig indexConfig;
-
-  protected InternalQuery(
-      QueryProcessor<T> queryProcessor,
-      IndexCollection<?, T, ? extends Index<?, T>> indexes,
-      IndexConfig indexConfig) {
-    this.queryProcessor = queryProcessor.enforceVisibility(false);
-    this.indexes = indexes;
-    this.indexConfig = indexConfig;
-  }
-
-  public InternalQuery<T> setLimit(int n) {
-    queryProcessor.setUserProvidedLimit(n);
-    return this;
-  }
-
-  public InternalQuery<T> enforceVisibility(boolean enforce) {
-    queryProcessor.enforceVisibility(enforce);
-    return this;
-  }
-
-  public InternalQuery<T> setRequestedFields(Set<String> fields) {
-    queryProcessor.setRequestedFields(fields);
-    return this;
-  }
-
-  public InternalQuery<T> noFields() {
-    queryProcessor.setRequestedFields(ImmutableSet.<String>of());
-    return this;
-  }
-
-  public List<T> query(Predicate<T> p) throws OrmException {
-    try {
-      return queryProcessor.query(p).entities();
-    } catch (QueryParseException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  /**
-   * Run multiple queries in parallel.
-   *
-   * <p>If a limit was specified using {@link #setLimit(int)}, that limit is applied to each query
-   * independently.
-   *
-   * @param queries list of queries.
-   * @return results of the queries, one list of results per input query, in the same order as the
-   *     input.
-   */
-  public List<List<T>> query(List<Predicate<T>> queries) throws OrmException {
-    try {
-      return Lists.transform(queryProcessor.query(queries), QueryResult::entities);
-    } catch (QueryParseException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  protected Schema<T> schema() {
-    Index<?, T> index = indexes != null ? indexes.getSearchIndex() : null;
-    return index != null ? index.getSchema() : null;
-  }
-}
diff --git a/gerrit-launcher/BUILD b/gerrit-launcher/BUILD
deleted file mode 100644
index 33b779e..0000000
--- a/gerrit-launcher/BUILD
+++ /dev/null
@@ -1,19 +0,0 @@
-# NOTE: GerritLauncher must be a single, self-contained class. Do not add any
-# additional srcs or deps to this rule.
-java_library(
-    name = "launcher",
-    srcs = ["src/main/java/com/google/gerrit/launcher/GerritLauncher.java"],
-    resources = [":workspace-root.txt"],
-    visibility = ["//visibility:public"],
-)
-
-# The root of the workspace is non-hermetic, but we need it for
-# on-the-fly GWT recompiles and PolyGerrit updates.
-genrule(
-    name = "gen_root",
-    outs = ["workspace-root.txt"],
-    cmd = ("cat bazel-out/stable-status.txt | " +
-           "grep STABLE_WORKSPACE_ROOT | cut -d ' ' -f 2 > $@"),
-    stamp = 1,
-    visibility = ["//visibility:public"],
-)
diff --git a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
deleted file mode 100644
index 072d1ed..0000000
--- a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
+++ /dev/null
@@ -1,693 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.launcher;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.concurrent.TimeUnit.DAYS;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.lang.reflect.Modifier;
-import java.net.JarURLConnection;
-import java.net.MalformedURLException;
-import java.net.URI;
-import java.net.URL;
-import java.net.URLClassLoader;
-import java.nio.file.FileSystem;
-import java.nio.file.FileSystems;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.security.CodeSource;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Enumeration;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Scanner;
-import java.util.SortedMap;
-import java.util.TreeMap;
-import java.util.jar.Attributes;
-import java.util.jar.JarFile;
-import java.util.jar.Manifest;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipFile;
-
-/** Main class for a JAR file to run code from "WEB-INF/lib". */
-public final class GerritLauncher {
-  private static final String PKG = "com.google.gerrit.pgm";
-  public static final String NOT_ARCHIVED = "NOT_ARCHIVED";
-
-  private static ClassLoader daemonClassLoader;
-
-  public static void main(String[] argv) throws Exception {
-    System.exit(mainImpl(argv));
-  }
-
-  public static int mainImpl(String[] argv) throws Exception {
-    if (argv.length == 0) {
-      File me;
-      try {
-        me = getDistributionArchive();
-      } catch (FileNotFoundException e) {
-        me = null;
-      }
-
-      String jar = me != null ? me.getName() : "gerrit.war";
-      System.err.println("Gerrit Code Review " + getVersion(me));
-      System.err.println("usage: java -jar " + jar + " command [ARG ...]");
-      System.err.println();
-      System.err.println("The most commonly used commands are:");
-      System.err.println("  init            Initialize a Gerrit installation");
-      System.err.println("  reindex         Rebuild the secondary index");
-      System.err.println("  daemon          Run the Gerrit network daemons");
-      System.err.println("  gsql            Run the interactive query console");
-      System.err.println("  version         Display the build version number");
-      System.err.println("  passwd          Set or change password in secure.config");
-
-      System.err.println();
-      System.err.println("  ls              List files available for cat");
-      System.err.println("  cat FILE        Display a file from the archive");
-      System.err.println();
-      return 1;
-    }
-
-    // Special cases, a few global options actually are programs.
-    //
-    if ("-v".equals(argv[0]) || "--version".equals(argv[0])) {
-      argv[0] = "version";
-    } else if ("-p".equals(argv[0]) || "--cat".equals(argv[0])) {
-      argv[0] = "cat";
-    } else if ("-l".equals(argv[0]) || "--ls".equals(argv[0])) {
-      argv[0] = "ls";
-    }
-
-    // Run the application class
-    //
-    final ClassLoader cl = libClassLoader(isProlog(programClassName(argv[0])));
-    Thread.currentThread().setContextClassLoader(cl);
-    return invokeProgram(cl, argv);
-  }
-
-  public static void daemonStart(String[] argv) throws Exception {
-    if (daemonClassLoader != null) {
-      throw new IllegalStateException("daemonStart can be called only once per JVM instance");
-    }
-    final ClassLoader cl = libClassLoader(false);
-    Thread.currentThread().setContextClassLoader(cl);
-
-    daemonClassLoader = cl;
-
-    String[] daemonArgv = new String[argv.length + 1];
-    daemonArgv[0] = "daemon";
-    for (int i = 0; i < argv.length; i++) {
-      daemonArgv[i + 1] = argv[i];
-    }
-    int res = invokeProgram(cl, daemonArgv);
-    if (res != 0) {
-      throw new Exception("Unexpected return value: " + res);
-    }
-  }
-
-  public static void daemonStop(String[] argv) throws Exception {
-    if (daemonClassLoader == null) {
-      throw new IllegalStateException("daemonStop can be called only after call to daemonStop");
-    }
-    String[] daemonArgv = new String[argv.length + 2];
-    daemonArgv[0] = "daemon";
-    daemonArgv[1] = "--stop-only";
-    for (int i = 0; i < argv.length; i++) {
-      daemonArgv[i + 2] = argv[i];
-    }
-    int res = invokeProgram(daemonClassLoader, daemonArgv);
-    if (res != 0) {
-      throw new Exception("Unexpected return value: " + res);
-    }
-  }
-
-  private static boolean isProlog(String cn) {
-    return "PrologShell".equals(cn) || "Rulec".equals(cn);
-  }
-
-  private static String getVersion(File me) {
-    if (me == null) {
-      return "";
-    }
-
-    try (JarFile jar = new JarFile(me)) {
-      Manifest mf = jar.getManifest();
-      Attributes att = mf.getMainAttributes();
-      String val = att.getValue(Attributes.Name.IMPLEMENTATION_VERSION);
-      return val != null ? val : "";
-    } catch (IOException e) {
-      return "";
-    }
-  }
-
-  private static int invokeProgram(ClassLoader loader, String[] origArgv) throws Exception {
-    String name = origArgv[0];
-    final String[] argv = new String[origArgv.length - 1];
-    System.arraycopy(origArgv, 1, argv, 0, argv.length);
-
-    Class<?> clazz;
-    try {
-      try {
-        String cn = programClassName(name);
-        clazz = Class.forName(PKG + "." + cn, true, loader);
-      } catch (ClassNotFoundException cnfe) {
-        if (name.equals(name.toLowerCase())) {
-          clazz = Class.forName(PKG + "." + name, true, loader);
-        } else {
-          throw cnfe;
-        }
-      }
-    } catch (ClassNotFoundException cnfe) {
-      System.err.println("fatal: unknown command " + name);
-      System.err.println("      (no " + PKG + "." + name + ")");
-      return 1;
-    }
-
-    final Method main;
-    try {
-      main = clazz.getMethod("main", argv.getClass());
-    } catch (SecurityException | NoSuchMethodException e) {
-      System.err.println("fatal: unknown command " + name);
-      return 1;
-    }
-
-    final Object res;
-    try {
-      if ((main.getModifiers() & Modifier.STATIC) == Modifier.STATIC) {
-        res = main.invoke(null, new Object[] {argv});
-      } else {
-        res =
-            main.invoke(clazz.getConstructor(new Class<?>[] {}).newInstance(), new Object[] {argv});
-      }
-    } catch (InvocationTargetException ite) {
-      if (ite.getCause() instanceof Exception) {
-        throw (Exception) ite.getCause();
-      } else if (ite.getCause() instanceof Error) {
-        throw (Error) ite.getCause();
-      } else {
-        throw ite;
-      }
-    }
-    if (res instanceof Number) {
-      return ((Number) res).intValue();
-    }
-    return 0;
-  }
-
-  private static String programClassName(String cn) {
-    if (cn.equals(cn.toLowerCase())) {
-      StringBuilder buf = new StringBuilder();
-      buf.append(Character.toUpperCase(cn.charAt(0)));
-      for (int i = 1; i < cn.length(); i++) {
-        if (cn.charAt(i) == '-' && i + 1 < cn.length()) {
-          i++;
-          buf.append(Character.toUpperCase(cn.charAt(i)));
-        } else {
-          buf.append(cn.charAt(i));
-        }
-      }
-      return buf.toString();
-    }
-    return cn;
-  }
-
-  private static ClassLoader libClassLoader(boolean prologCompiler) throws IOException {
-    final File path;
-    try {
-      path = getDistributionArchive();
-    } catch (FileNotFoundException e) {
-      if (NOT_ARCHIVED.equals(e.getMessage())) {
-        return useDevClasspath();
-      }
-      throw e;
-    }
-
-    final SortedMap<String, URL> jars = new TreeMap<>();
-    try (ZipFile zf = new ZipFile(path)) {
-      final Enumeration<? extends ZipEntry> e = zf.entries();
-      while (e.hasMoreElements()) {
-        final ZipEntry ze = e.nextElement();
-        if (ze.isDirectory()) {
-          continue;
-        }
-
-        String name = ze.getName();
-        if (name.startsWith("WEB-INF/lib/")) {
-          extractJar(zf, ze, jars);
-        } else if (name.startsWith("WEB-INF/pgm-lib/")) {
-          // Some Prolog tools are restricted.
-          if (prologCompiler || !name.startsWith("WEB-INF/pgm-lib/prolog-")) {
-            extractJar(zf, ze, jars);
-          }
-        }
-      }
-    } catch (IOException e) {
-      throw new IOException("Cannot obtain libraries from " + path, e);
-    }
-
-    if (jars.isEmpty()) {
-      return GerritLauncher.class.getClassLoader();
-    }
-
-    // The extension API needs to be its own ClassLoader, along
-    // with a few of its dependencies. Try to construct this first.
-    List<URL> extapi = new ArrayList<>();
-    move(jars, "gerrit-extension-api-", extapi);
-    move(jars, "guice-", extapi);
-    move(jars, "javax.inject-1.jar", extapi);
-    move(jars, "aopalliance-1.0.jar", extapi);
-    move(jars, "guice-servlet-", extapi);
-    move(jars, "tomcat-servlet-api-", extapi);
-
-    ClassLoader parent = ClassLoader.getSystemClassLoader();
-    if (!extapi.isEmpty()) {
-      parent = new URLClassLoader(extapi.toArray(new URL[extapi.size()]), parent);
-    }
-    return new URLClassLoader(jars.values().toArray(new URL[jars.size()]), parent);
-  }
-
-  private static void extractJar(ZipFile zf, ZipEntry ze, SortedMap<String, URL> jars)
-      throws IOException {
-    File tmp = createTempFile(safeName(ze), ".jar");
-    try (OutputStream out = Files.newOutputStream(tmp.toPath());
-        InputStream in = zf.getInputStream(ze)) {
-      byte[] buf = new byte[4096];
-      int n;
-      while ((n = in.read(buf, 0, buf.length)) > 0) {
-        out.write(buf, 0, n);
-      }
-    }
-
-    String name = ze.getName();
-    jars.put(name.substring(name.lastIndexOf('/'), name.length()), tmp.toURI().toURL());
-  }
-
-  private static void move(SortedMap<String, URL> jars, String prefix, List<URL> extapi) {
-    SortedMap<String, URL> matches = jars.tailMap(prefix);
-    if (!matches.isEmpty()) {
-      String first = matches.firstKey();
-      if (first.startsWith(prefix)) {
-        extapi.add(jars.remove(first));
-      }
-    }
-  }
-
-  private static String safeName(ZipEntry ze) {
-    // Try to derive the name of the temporary file so it
-    // doesn't completely suck. Best if we can make it
-    // match the name it was in the archive.
-    //
-    String name = ze.getName();
-    if (name.contains("/")) {
-      name = name.substring(name.lastIndexOf('/') + 1);
-    }
-    if (name.contains(".")) {
-      name = name.substring(0, name.lastIndexOf('.'));
-    }
-    if (name.isEmpty()) {
-      name = "code";
-    }
-    return name;
-  }
-
-  private static volatile File myArchive;
-  private static volatile File myHome;
-
-  private static final Map<Path, FileSystem> zipFileSystems = new HashMap<>();
-
-  /**
-   * Locate the JAR/WAR file we were launched from.
-   *
-   * @return local path of the Gerrit WAR file.
-   * @throws FileNotFoundException if the code cannot guess the location.
-   */
-  public static File getDistributionArchive() throws FileNotFoundException, IOException {
-    File result = myArchive;
-    if (result == null) {
-      synchronized (GerritLauncher.class) {
-        result = myArchive;
-        if (result != null) {
-          return result;
-        }
-        result = locateMyArchive();
-        myArchive = result;
-      }
-    }
-    return result;
-  }
-
-  public static synchronized FileSystem getZipFileSystem(Path zip) throws IOException {
-    // FileSystems canonicalizes the path, so we should too.
-    zip = zip.toRealPath();
-    FileSystem zipFs = zipFileSystems.get(zip);
-    if (zipFs == null) {
-      zipFs = newZipFileSystem(zip);
-      zipFileSystems.put(zip, zipFs);
-    }
-    return zipFs;
-  }
-
-  public static FileSystem newZipFileSystem(Path zip) throws IOException {
-    return FileSystems.newFileSystem(
-        URI.create("jar:" + zip.toUri()), Collections.<String, String>emptyMap());
-  }
-
-  private static File locateMyArchive() throws FileNotFoundException {
-    final ClassLoader myCL = GerritLauncher.class.getClassLoader();
-    final String myName = GerritLauncher.class.getName().replace('.', '/') + ".class";
-
-    final URL myClazz = myCL.getResource(myName);
-    if (myClazz == null) {
-      throw new FileNotFoundException("Cannot find JAR: no " + myName);
-    }
-
-    // ZipFile may have the path of our JAR hiding within itself.
-    //
-    try {
-      JarFile jar = ((JarURLConnection) myClazz.openConnection()).getJarFile();
-      File path = new File(jar.getName());
-      if (path.isFile()) {
-        return path;
-      }
-    } catch (Exception e) {
-      // Nope, that didn't work. Try a different method.
-      //
-    }
-
-    // Maybe this is a local class file, running under a debugger?
-    //
-    if ("file".equals(myClazz.getProtocol())) {
-      final File path = new File(myClazz.getPath());
-      if (path.isFile() && path.getParentFile().isDirectory()) {
-        throw new FileNotFoundException(NOT_ARCHIVED);
-      }
-    }
-
-    // The CodeSource might be able to give us the source as a stream.
-    // If so, copy it to a local file so we have random access to it.
-    //
-    final CodeSource src = GerritLauncher.class.getProtectionDomain().getCodeSource();
-    if (src != null) {
-      try (InputStream in = src.getLocation().openStream()) {
-        final File tmp = createTempFile("gerrit_", ".zip");
-        try (OutputStream out = Files.newOutputStream(tmp.toPath())) {
-          final byte[] buf = new byte[4096];
-          int n;
-          while ((n = in.read(buf, 0, buf.length)) > 0) {
-            out.write(buf, 0, n);
-          }
-        }
-        return tmp;
-      } catch (IOException e) {
-        // Nope, that didn't work.
-        //
-      }
-    }
-
-    throw new FileNotFoundException("Cannot find local copy of JAR");
-  }
-
-  private static boolean temporaryDirectoryFound;
-  private static File temporaryDirectory;
-
-  /**
-   * Creates a temporary file within the application's unpack location.
-   *
-   * <p>The launcher unpacks the nested JAR files into a temporary directory, allowing the classes
-   * to be loaded from local disk with standard Java APIs. This method constructs a new temporary
-   * file in the same directory.
-   *
-   * <p>The method first tries to create {@code prefix + suffix} within the directory under the
-   * assumption that a given {@code prefix + suffix} combination is made at most once per JVM
-   * execution. If this fails (e.g. the named file already exists) a mangled unique name is used and
-   * returned instead, with the unique string appearing between the prefix and suffix.
-   *
-   * <p>Files created by this method will be automatically deleted by the JVM when it terminates. If
-   * the returned file is converted into a directory by the caller, the caller must arrange for the
-   * contents to be deleted before the directory is.
-   *
-   * <p>If supported by the underlying operating system, the temporary directory which contains
-   * these temporary files is accessible only by the user running the JVM.
-   *
-   * @param prefix prefix of the file name.
-   * @param suffix suffix of the file name.
-   * @return the path of the temporary file. The returned object exists in the filesystem as a file;
-   *     caller may need to delete and recreate as a directory if a directory was preferred.
-   * @throws IOException the file could not be created.
-   */
-  public static synchronized File createTempFile(String prefix, String suffix) throws IOException {
-    if (!temporaryDirectoryFound) {
-      final File d = File.createTempFile("gerrit_", "_app", tmproot());
-      if (d.delete() && d.mkdir()) {
-        // Try to lock the directory down to be accessible by us.
-        // We first have to remove all permissions, then add back
-        // only the owner permissions.
-        //
-        d.setWritable(false, false /* all */);
-        d.setReadable(false, false /* all */);
-        d.setExecutable(false, false /* all */);
-
-        d.setWritable(true, true /* owner only */);
-        d.setReadable(true, true /* owner only */);
-        d.setExecutable(true, true /* owner only */);
-
-        d.deleteOnExit();
-        temporaryDirectory = d;
-      }
-      temporaryDirectoryFound = true;
-    }
-
-    if (temporaryDirectory != null) {
-      // If we have a private directory and this name has not yet
-      // been used within the private directory, create it as-is.
-      //
-      final File tmp = new File(temporaryDirectory, prefix + suffix);
-      if (tmp.createNewFile()) {
-        tmp.deleteOnExit();
-        return tmp;
-      }
-    }
-
-    if (!prefix.endsWith("_")) {
-      prefix += "_";
-    }
-
-    final File tmp = File.createTempFile(prefix, suffix, temporaryDirectory);
-    tmp.deleteOnExit();
-    return tmp;
-  }
-
-  /**
-   * Provide path to a working directory
-   *
-   * @return local path of the working directory or null if cannot be determined
-   */
-  public static File getHomeDirectory() {
-    if (myHome == null) {
-      myHome = locateHomeDirectory();
-    }
-    return myHome;
-  }
-
-  private static File tmproot() {
-    File tmp;
-    String gerritTemp = System.getenv("GERRIT_TMP");
-    if (gerritTemp != null && gerritTemp.length() > 0) {
-      tmp = new File(gerritTemp);
-    } else {
-      tmp = new File(getHomeDirectory(), "tmp");
-    }
-    if (!tmp.exists() && !tmp.mkdirs()) {
-      System.err.println("warning: cannot create " + tmp.getAbsolutePath());
-      System.err.println("warning: using system temporary directory instead");
-      return null;
-    }
-
-    // Try to clean up any stale empty directories. Assume any empty
-    // directory that is older than 7 days is one of these dead ones
-    // that we can clean up.
-    //
-    final File[] tmpEntries = tmp.listFiles();
-    if (tmpEntries != null) {
-      final long now = System.currentTimeMillis();
-      final long expired = now - MILLISECONDS.convert(7, DAYS);
-      for (File tmpEntry : tmpEntries) {
-        if (tmpEntry.isDirectory() && tmpEntry.lastModified() < expired) {
-          final String[] all = tmpEntry.list();
-          if (all == null || all.length == 0) {
-            tmpEntry.delete();
-          }
-        }
-      }
-    }
-
-    try {
-      return tmp.getCanonicalFile();
-    } catch (IOException e) {
-      return tmp;
-    }
-  }
-
-  private static File locateHomeDirectory() {
-    // Try to find the user's home directory. If we can't find it
-    // return null so the JVM's default temporary directory is used
-    // instead. This is probably /tmp or /var/tmp.
-    //
-    String userHome = System.getProperty("user.home");
-    if (userHome == null || "".equals(userHome)) {
-      userHome = System.getenv("HOME");
-      if (userHome == null || "".equals(userHome)) {
-        System.err.println("warning: cannot determine home directory");
-        System.err.println("warning: using system temporary directory instead");
-        return null;
-      }
-    }
-
-    // Ensure the home directory exists. If it doesn't, try to make it.
-    //
-    final File home = new File(userHome);
-    if (!home.exists()) {
-      if (home.mkdirs()) {
-        System.err.println("warning: created " + home.getAbsolutePath());
-      } else {
-        System.err.println("warning: " + home.getAbsolutePath() + " not found");
-        System.err.println("warning: using system temporary directory instead");
-        return null;
-      }
-    }
-
-    // Use $HOME/.gerritcodereview/tmp for our temporary file area.
-    //
-    final File gerrithome = new File(home, ".gerritcodereview");
-    if (!gerrithome.exists() && !gerrithome.mkdirs()) {
-      System.err.println("warning: cannot create " + gerrithome.getAbsolutePath());
-      System.err.println("warning: using system temporary directory instead");
-      return null;
-    }
-    try {
-      return gerrithome.getCanonicalFile();
-    } catch (IOException e) {
-      return gerrithome;
-    }
-  }
-
-  /**
-   * Locate the path of the {@code eclipse-out} directory in a source tree.
-   *
-   * @return local path of the {@code eclipse-out} directory in a source tree.
-   * @throws FileNotFoundException if the directory cannot be found.
-   */
-  public static Path getDeveloperEclipseOut() throws FileNotFoundException {
-    return resolveInSourceRoot("eclipse-out");
-  }
-
-  static final String SOURCE_ROOT_RESOURCE = "/gerrit-launcher/workspace-root.txt";
-
-  /**
-   * Locate a path in the source tree.
-   *
-   * @return local path of the {@code name} directory in a source tree.
-   * @throws FileNotFoundException if the directory cannot be found.
-   */
-  public static Path resolveInSourceRoot(String name) throws FileNotFoundException {
-
-    // Find ourselves in the classpath, as a loose class file or jar.
-    Class<GerritLauncher> self = GerritLauncher.class;
-
-    // If the build system provides us with a source root, use that.
-    try (InputStream stream = self.getResourceAsStream(SOURCE_ROOT_RESOURCE)) {
-      if (stream != null) {
-        try (Scanner scan = new Scanner(stream, UTF_8.name()).useDelimiter("\n")) {
-          if (scan.hasNext()) {
-            Path p = Paths.get(scan.next());
-            if (!Files.exists(p)) {
-              throw new FileNotFoundException("source root not found: " + p);
-            }
-            return p;
-          }
-        }
-      }
-    } catch (IOException e) {
-      // not Bazel, then.
-    }
-
-    URL u = self.getResource(self.getSimpleName() + ".class");
-    if (u == null) {
-      throw new FileNotFoundException("Cannot find class " + self.getName());
-    } else if ("jar".equals(u.getProtocol())) {
-      String p = u.getPath();
-      try {
-        u = new URL(p.substring(0, p.indexOf('!')));
-      } catch (MalformedURLException e) {
-        FileNotFoundException fnfe = new FileNotFoundException("Not a valid jar file: " + u);
-        fnfe.initCause(e);
-        throw fnfe;
-      }
-    }
-    if (!"file".equals(u.getProtocol())) {
-      throw new FileNotFoundException("Cannot extract path from " + u);
-    }
-
-    // Pop up to the top-level source folder by looking for .buckconfig.
-    Path dir = Paths.get(u.getPath());
-    while (!Files.isRegularFile(dir.resolve("WORKSPACE"))) {
-      Path parent = dir.getParent();
-      if (parent == null) {
-        throw new FileNotFoundException("Cannot find source root from " + u);
-      }
-      dir = parent;
-    }
-
-    Path ret = dir.resolve(name);
-    if (!Files.exists(ret)) {
-      throw new FileNotFoundException(name + " not found in source root " + dir);
-    }
-    return ret;
-  }
-
-  private static ClassLoader useDevClasspath() throws MalformedURLException, FileNotFoundException {
-    Path out = resolveInSourceRoot("eclipse-out");
-    List<URL> dirs = new ArrayList<>();
-    dirs.add(out.resolve("classes").toUri().toURL());
-    ClassLoader cl = GerritLauncher.class.getClassLoader();
-    for (URL u : ((URLClassLoader) cl).getURLs()) {
-      if (includeJar(u)) {
-        dirs.add(u);
-      }
-    }
-    return new URLClassLoader(
-        dirs.toArray(new URL[dirs.size()]), ClassLoader.getSystemClassLoader().getParent());
-  }
-
-  private static boolean includeJar(URL u) {
-    String path = u.getPath();
-    return path.endsWith(".jar")
-        && !path.endsWith("-src.jar")
-        && !path.contains("/buck-out/gen/lib/gwt/");
-  }
-
-  private GerritLauncher() {}
-}
diff --git a/gerrit-lucene/BUILD b/gerrit-lucene/BUILD
deleted file mode 100644
index aae5000..0000000
--- a/gerrit-lucene/BUILD
+++ /dev/null
@@ -1,46 +0,0 @@
-QUERY_BUILDER = [
-    "src/main/java/com/google/gerrit/lucene/QueryBuilder.java",
-]
-
-java_library(
-    name = "query_builder",
-    srcs = QUERY_BUILDER,
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-index:index",
-        "//gerrit-index:query_exception",
-        "//gerrit-reviewdb:server",
-        "//gerrit-server:server",
-        "//lib:guava",
-        "//lib:gwtorm",
-        "//lib/lucene:lucene-core-and-backward-codecs",
-    ],
-)
-
-java_library(
-    name = "lucene",
-    srcs = glob(
-        ["src/main/java/**/*.java"],
-        exclude = QUERY_BUILDER,
-    ),
-    visibility = ["//visibility:public"],
-    deps = [
-        ":query_builder",
-        "//gerrit-common:annotations",
-        "//gerrit-common:server",
-        "//gerrit-extension-api:api",
-        "//gerrit-index:index",
-        "//gerrit-index:query_exception",
-        "//gerrit-reviewdb:server",
-        "//gerrit-server:server",
-        "//lib:guava",
-        "//lib:gwtorm",
-        "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/log:api",
-        "//lib/lucene:lucene-analyzers-common",
-        "//lib/lucene:lucene-core-and-backward-codecs",
-        "//lib/lucene:lucene-misc",
-    ],
-)
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
deleted file mode 100644
index 9d474dd..0000000
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ /dev/null
@@ -1,419 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.lucene;
-
-import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-
-import com.google.common.base.Joiner;
-import com.google.common.collect.Sets;
-import com.google.common.util.concurrent.AbstractFuture;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
-import com.google.common.util.concurrent.ThreadFactoryBuilder;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.FieldType;
-import com.google.gerrit.index.Index;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.Schema.Values;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Executor;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-import org.apache.lucene.document.Document;
-import org.apache.lucene.document.Field;
-import org.apache.lucene.document.Field.Store;
-import org.apache.lucene.document.IntField;
-import org.apache.lucene.document.LongField;
-import org.apache.lucene.document.StoredField;
-import org.apache.lucene.document.StringField;
-import org.apache.lucene.document.TextField;
-import org.apache.lucene.index.IndexWriter;
-import org.apache.lucene.index.Term;
-import org.apache.lucene.index.TrackingIndexWriter;
-import org.apache.lucene.search.ControlledRealTimeReopenThread;
-import org.apache.lucene.search.IndexSearcher;
-import org.apache.lucene.search.ReferenceManager;
-import org.apache.lucene.search.ReferenceManager.RefreshListener;
-import org.apache.lucene.search.SearcherFactory;
-import org.apache.lucene.store.AlreadyClosedException;
-import org.apache.lucene.store.Directory;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Basic Lucene index implementation. */
-public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
-  private static final Logger log = LoggerFactory.getLogger(AbstractLuceneIndex.class);
-
-  static String sortFieldName(FieldDef<?, ?> f) {
-    return f.getName() + "_SORT";
-  }
-
-  private final Schema<V> schema;
-  private final SitePaths sitePaths;
-  private final Directory dir;
-  private final String name;
-  private final ListeningExecutorService writerThread;
-  private final TrackingIndexWriter writer;
-  private final ReferenceManager<IndexSearcher> searcherManager;
-  private final ControlledRealTimeReopenThread<IndexSearcher> reopenThread;
-  private final Set<NrtFuture> notDoneNrtFutures;
-  private ScheduledThreadPoolExecutor autoCommitExecutor;
-
-  AbstractLuceneIndex(
-      Schema<V> schema,
-      SitePaths sitePaths,
-      Directory dir,
-      String name,
-      String subIndex,
-      GerritIndexWriterConfig writerConfig,
-      SearcherFactory searcherFactory)
-      throws IOException {
-    this.schema = schema;
-    this.sitePaths = sitePaths;
-    this.dir = dir;
-    this.name = name;
-    String index = Joiner.on('_').skipNulls().join(name, subIndex);
-    IndexWriter delegateWriter;
-    long commitPeriod = writerConfig.getCommitWithinMs();
-
-    if (commitPeriod < 0) {
-      delegateWriter = new AutoCommitWriter(dir, writerConfig.getLuceneConfig());
-    } else if (commitPeriod == 0) {
-      delegateWriter = new AutoCommitWriter(dir, writerConfig.getLuceneConfig(), true);
-    } else {
-      final AutoCommitWriter autoCommitWriter =
-          new AutoCommitWriter(dir, writerConfig.getLuceneConfig());
-      delegateWriter = autoCommitWriter;
-
-      autoCommitExecutor =
-          new ScheduledThreadPoolExecutor(
-              1,
-              new ThreadFactoryBuilder()
-                  .setNameFormat(index + " Commit-%d")
-                  .setDaemon(true)
-                  .build());
-      @SuppressWarnings("unused") // Error handling within Runnable.
-      Future<?> possiblyIgnoredError =
-          autoCommitExecutor.scheduleAtFixedRate(
-              () -> {
-                try {
-                  if (autoCommitWriter.hasUncommittedChanges()) {
-                    autoCommitWriter.manualFlush();
-                    autoCommitWriter.commit();
-                  }
-                } catch (IOException e) {
-                  log.error("Error committing " + index + " Lucene index", e);
-                } catch (OutOfMemoryError e) {
-                  log.error("Error committing " + index + " Lucene index", e);
-                  try {
-                    autoCommitWriter.close();
-                  } catch (IOException e2) {
-                    log.error(
-                        "SEVERE: Error closing "
-                            + index
-                            + " Lucene index after OOM;"
-                            + " index may be corrupted.",
-                        e);
-                  }
-                }
-              },
-              commitPeriod,
-              commitPeriod,
-              MILLISECONDS);
-    }
-    writer = new TrackingIndexWriter(delegateWriter);
-    searcherManager = new WrappableSearcherManager(writer.getIndexWriter(), true, searcherFactory);
-
-    notDoneNrtFutures = Sets.newConcurrentHashSet();
-
-    writerThread =
-        MoreExecutors.listeningDecorator(
-            Executors.newFixedThreadPool(
-                1,
-                new ThreadFactoryBuilder()
-                    .setNameFormat(index + " Write-%d")
-                    .setDaemon(true)
-                    .build()));
-
-    reopenThread =
-        new ControlledRealTimeReopenThread<>(
-            writer,
-            searcherManager,
-            0.500 /* maximum stale age (seconds) */,
-            0.010 /* minimum stale age (seconds) */);
-    reopenThread.setName(index + " NRT");
-    reopenThread.setPriority(
-        Math.min(Thread.currentThread().getPriority() + 2, Thread.MAX_PRIORITY));
-    reopenThread.setDaemon(true);
-
-    // This must be added after the reopen thread is created. The reopen thread
-    // adds its own listener which copies its internally last-refreshed
-    // generation to the searching generation. removeIfDone() depends on the
-    // searching generation being up to date when calling
-    // reopenThread.waitForGeneration(gen, 0), therefore the reopen thread's
-    // internal listener needs to be called first.
-    // TODO(dborowitz): This may have been fixed by
-    // http://issues.apache.org/jira/browse/LUCENE-5461
-    searcherManager.addListener(
-        new RefreshListener() {
-          @Override
-          public void beforeRefresh() throws IOException {}
-
-          @Override
-          public void afterRefresh(boolean didRefresh) throws IOException {
-            for (NrtFuture f : notDoneNrtFutures) {
-              f.removeIfDone();
-            }
-          }
-        });
-
-    reopenThread.start();
-  }
-
-  @Override
-  public void markReady(boolean ready) throws IOException {
-    IndexUtils.setReady(sitePaths, name, schema.getVersion(), ready);
-  }
-
-  @Override
-  public void close() {
-    if (autoCommitExecutor != null) {
-      autoCommitExecutor.shutdown();
-    }
-
-    writerThread.shutdown();
-    try {
-      if (!writerThread.awaitTermination(5, TimeUnit.SECONDS)) {
-        log.warn("shutting down " + name + " index with pending Lucene writes");
-      }
-    } catch (InterruptedException e) {
-      log.warn("interrupted waiting for pending Lucene writes of " + name + " index", e);
-    }
-    reopenThread.close();
-
-    // Closing the reopen thread sets its generation to Long.MAX_VALUE, but we
-    // still need to refresh the searcher manager to let pending NrtFutures
-    // know.
-    //
-    // Any futures created after this method (which may happen due to undefined
-    // shutdown ordering behavior) will finish immediately, even though they may
-    // not have flushed.
-    try {
-      searcherManager.maybeRefreshBlocking();
-    } catch (IOException e) {
-      log.warn("error finishing pending Lucene writes", e);
-    }
-
-    try {
-      writer.getIndexWriter().close();
-    } catch (AlreadyClosedException e) {
-      // Ignore.
-    } catch (IOException e) {
-      log.warn("error closing Lucene writer", e);
-    }
-    try {
-      dir.close();
-    } catch (IOException e) {
-      log.warn("error closing Lucene directory", e);
-    }
-  }
-
-  ListenableFuture<?> insert(Document doc) {
-    return submit(() -> writer.addDocument(doc));
-  }
-
-  ListenableFuture<?> replace(Term term, Document doc) {
-    return submit(() -> writer.updateDocument(term, doc));
-  }
-
-  ListenableFuture<?> delete(Term term) {
-    return submit(() -> writer.deleteDocuments(term));
-  }
-
-  private ListenableFuture<?> submit(Callable<Long> task) {
-    ListenableFuture<Long> future = Futures.nonCancellationPropagating(writerThread.submit(task));
-    return Futures.transformAsync(
-        future,
-        gen -> {
-          // Tell the reopen thread a future is waiting on this
-          // generation so it uses the min stale time when refreshing.
-          reopenThread.waitForGeneration(gen, 0);
-          return new NrtFuture(gen);
-        },
-        directExecutor());
-  }
-
-  @Override
-  public void deleteAll() throws IOException {
-    writer.deleteAll();
-  }
-
-  public TrackingIndexWriter getWriter() {
-    return writer;
-  }
-
-  IndexSearcher acquire() throws IOException {
-    return searcherManager.acquire();
-  }
-
-  void release(IndexSearcher searcher) throws IOException {
-    searcherManager.release(searcher);
-  }
-
-  Document toDocument(V obj) {
-    Document result = new Document();
-    for (Values<V> vs : schema.buildFields(obj)) {
-      if (vs.getValues() != null) {
-        add(result, vs);
-      }
-    }
-    return result;
-  }
-
-  void add(Document doc, Values<V> values) {
-    String name = values.getField().getName();
-    FieldType<?> type = values.getField().getType();
-    Store store = store(values.getField());
-
-    if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
-      for (Object value : values.getValues()) {
-        doc.add(new IntField(name, (Integer) value, store));
-      }
-    } else if (type == FieldType.LONG) {
-      for (Object value : values.getValues()) {
-        doc.add(new LongField(name, (Long) value, store));
-      }
-    } else if (type == FieldType.TIMESTAMP) {
-      for (Object value : values.getValues()) {
-        doc.add(new LongField(name, ((Timestamp) value).getTime(), store));
-      }
-    } else if (type == FieldType.EXACT || type == FieldType.PREFIX) {
-      for (Object value : values.getValues()) {
-        doc.add(new StringField(name, (String) value, store));
-      }
-    } else if (type == FieldType.FULL_TEXT) {
-      for (Object value : values.getValues()) {
-        doc.add(new TextField(name, (String) value, store));
-      }
-    } else if (type == FieldType.STORED_ONLY) {
-      for (Object value : values.getValues()) {
-        doc.add(new StoredField(name, (byte[]) value));
-      }
-    } else {
-      throw FieldType.badFieldType(type);
-    }
-  }
-
-  private static Field.Store store(FieldDef<?, ?> f) {
-    return f.isStored() ? Field.Store.YES : Field.Store.NO;
-  }
-
-  private final class NrtFuture extends AbstractFuture<Void> {
-    private final long gen;
-
-    NrtFuture(long gen) {
-      this.gen = gen;
-    }
-
-    @Override
-    public Void get() throws InterruptedException, ExecutionException {
-      if (!isDone()) {
-        reopenThread.waitForGeneration(gen);
-        set(null);
-      }
-      return super.get();
-    }
-
-    @Override
-    public Void get(long timeout, TimeUnit unit)
-        throws InterruptedException, TimeoutException, ExecutionException {
-      if (!isDone()) {
-        if (!reopenThread.waitForGeneration(gen, (int) unit.toMillis(timeout))) {
-          throw new TimeoutException();
-        }
-        set(null);
-      }
-      return super.get(timeout, unit);
-    }
-
-    @Override
-    public boolean isDone() {
-      if (super.isDone()) {
-        return true;
-      } else if (isGenAvailableNowForCurrentSearcher()) {
-        set(null);
-        return true;
-      } else if (!reopenThread.isAlive()) {
-        setException(new IllegalStateException("NRT thread is dead"));
-        return true;
-      }
-      return false;
-    }
-
-    @Override
-    public void addListener(Runnable listener, Executor executor) {
-      if (isGenAvailableNowForCurrentSearcher() && !isCancelled()) {
-        set(null);
-      } else if (!isDone()) {
-        notDoneNrtFutures.add(this);
-      }
-      super.addListener(listener, executor);
-    }
-
-    @Override
-    public boolean cancel(boolean mayInterruptIfRunning) {
-      boolean result = super.cancel(mayInterruptIfRunning);
-      if (result) {
-        notDoneNrtFutures.remove(this);
-      }
-      return result;
-    }
-
-    void removeIfDone() {
-      if (isGenAvailableNowForCurrentSearcher()) {
-        notDoneNrtFutures.remove(this);
-        if (!isCancelled()) {
-          set(null);
-        }
-      }
-    }
-
-    private boolean isGenAvailableNowForCurrentSearcher() {
-      try {
-        return reopenThread.waitForGeneration(gen, 0);
-      } catch (InterruptedException e) {
-        log.warn("Interrupted waiting for searcher generation", e);
-        return false;
-      }
-    }
-  }
-
-  @Override
-  public Schema<V> getSchema() {
-    return schema;
-  }
-}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ChangeSubIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ChangeSubIndex.java
deleted file mode 100644
index 126c79f..0000000
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ /dev/null
@@ -1,101 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.lucene;
-
-import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.lucene.LuceneChangeIndex.ID_SORT_FIELD;
-import static com.google.gerrit.lucene.LuceneChangeIndex.UPDATED_SORT_FIELD;
-import static com.google.gerrit.server.index.change.ChangeSchemaDefinitions.NAME;
-
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.Schema.Values;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.sql.Timestamp;
-import org.apache.lucene.document.Document;
-import org.apache.lucene.document.NumericDocValuesField;
-import org.apache.lucene.search.SearcherFactory;
-import org.apache.lucene.store.Directory;
-import org.apache.lucene.store.FSDirectory;
-
-public class ChangeSubIndex extends AbstractLuceneIndex<Change.Id, ChangeData>
-    implements ChangeIndex {
-  ChangeSubIndex(
-      Schema<ChangeData> schema,
-      SitePaths sitePaths,
-      Path path,
-      GerritIndexWriterConfig writerConfig,
-      SearcherFactory searcherFactory)
-      throws IOException {
-    this(
-        schema,
-        sitePaths,
-        FSDirectory.open(path),
-        path.getFileName().toString(),
-        writerConfig,
-        searcherFactory);
-  }
-
-  ChangeSubIndex(
-      Schema<ChangeData> schema,
-      SitePaths sitePaths,
-      Directory dir,
-      String subIndex,
-      GerritIndexWriterConfig writerConfig,
-      SearcherFactory searcherFactory)
-      throws IOException {
-    super(schema, sitePaths, dir, NAME, subIndex, writerConfig, searcherFactory);
-  }
-
-  @Override
-  public void replace(ChangeData obj) throws IOException {
-    throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
-  }
-
-  @Override
-  public void delete(Change.Id key) throws IOException {
-    throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
-  }
-
-  @Override
-  public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts)
-      throws QueryParseException {
-    throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
-  }
-
-  @Override
-  void add(Document doc, Values<ChangeData> values) {
-    // Add separate DocValues fields for those fields needed for sorting.
-    FieldDef<ChangeData, ?> f = values.getField();
-    if (f == ChangeField.LEGACY_ID) {
-      int v = (Integer) getOnlyElement(values.getValues());
-      doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
-    } else if (f == ChangeField.UPDATED) {
-      long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
-      doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
-    }
-    super.add(doc, values);
-  }
-}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
deleted file mode 100644
index 7a4cd40..0000000
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ /dev/null
@@ -1,207 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.lucene;
-
-import static com.google.gerrit.server.index.account.AccountField.ID;
-
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.account.AccountIndex;
-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 LuceneAccountIndex extends AbstractLuceneIndex<Account.Id, AccountState>
-    implements AccountIndex {
-  private static final Logger log = LoggerFactory.getLogger(LuceneAccountIndex.class);
-
-  private static final String ACCOUNTS = "accounts";
-
-  private static final String ID_SORT_FIELD = sortFieldName(ID);
-
-  private static Term idTerm(AccountState as) {
-    return idTerm(as.getAccount().getId());
-  }
-
-  private static Term idTerm(Account.Id id) {
-    return QueryBuilder.intTerm(ID.getName(), id.get());
-  }
-
-  private final GerritIndexWriterConfig indexWriterConfig;
-  private final QueryBuilder<AccountState> queryBuilder;
-  private final Provider<AccountCache> accountCache;
-
-  private static Directory dir(Schema<AccountState> schema, Config cfg, SitePaths sitePaths)
-      throws IOException {
-    if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      return new RAMDirectory();
-    }
-    Path indexDir = LuceneVersionManager.getDir(sitePaths, ACCOUNTS, schema);
-    return FSDirectory.open(indexDir);
-  }
-
-  @Inject
-  LuceneAccountIndex(
-      @GerritServerConfig Config cfg,
-      SitePaths sitePaths,
-      Provider<AccountCache> accountCache,
-      @Assisted Schema<AccountState> schema)
-      throws IOException {
-    super(
-        schema,
-        sitePaths,
-        dir(schema, cfg, sitePaths),
-        ACCOUNTS,
-        null,
-        new GerritIndexWriterConfig(cfg, ACCOUNTS),
-        new SearcherFactory());
-    this.accountCache = accountCache;
-
-    indexWriterConfig = new GerritIndexWriterConfig(cfg, ACCOUNTS);
-    queryBuilder = new QueryBuilder<>(schema, indexWriterConfig.getAnalyzer());
-  }
-
-  @Override
-  public void replace(AccountState as) throws IOException {
-    try {
-      replace(idTerm(as), toDocument(as)).get();
-    } catch (ExecutionException | InterruptedException e) {
-      throw new IOException(e);
-    }
-  }
-
-  @Override
-  public void delete(Account.Id key) throws IOException {
-    try {
-      delete(idTerm(key)).get();
-    } catch (ExecutionException | InterruptedException e) {
-      throw new IOException(e);
-    }
-  }
-
-  @Override
-  public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
-      throws QueryParseException {
-    return new QuerySource(
-        opts,
-        queryBuilder.toQuery(p),
-        new Sort(new SortField(ID_SORT_FIELD, SortField.Type.LONG, true)));
-  }
-
-  private class QuerySource implements DataSource<AccountState> {
-    private final QueryOptions opts;
-    private final Query query;
-    private final Sort sort;
-
-    private QuerySource(QueryOptions opts, Query query, Sort sort) {
-      this.opts = opts;
-      this.query = query;
-      this.sort = sort;
-    }
-
-    @Override
-    public int getCardinality() {
-      // TODO(dborowitz): In contrast to the comment in
-      // LuceneChangeIndex.QuerySource#getCardinality, at this point I actually
-      // think we might just want to remove getCardinality.
-      return 10;
-    }
-
-    @Override
-    public ResultSet<AccountState> read() throws OrmException {
-      IndexSearcher searcher = null;
-      try {
-        searcher = acquire();
-        int realLimit = opts.start() + opts.limit();
-        TopFieldDocs docs = searcher.search(query, realLimit, sort);
-        List<AccountState> result = new ArrayList<>(docs.scoreDocs.length);
-        for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
-          ScoreDoc sd = docs.scoreDocs[i];
-          Document doc = searcher.doc(sd.doc, IndexUtils.accountFields(opts));
-          result.add(toAccountState(doc));
-        }
-        final List<AccountState> r = Collections.unmodifiableList(result);
-        return new ResultSet<AccountState>() {
-          @Override
-          public Iterator<AccountState> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<AccountState> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
-      } catch (IOException e) {
-        throw new OrmException(e);
-      } finally {
-        if (searcher != null) {
-          try {
-            release(searcher);
-          } catch (IOException e) {
-            log.warn("cannot release Lucene searcher", e);
-          }
-        }
-      }
-    }
-  }
-
-  private AccountState toAccountState(Document doc) {
-    Account.Id id = new Account.Id(doc.getField(ID.getName()).numericValue().intValue());
-    // Use the AccountCache rather than depending on any stored fields in the
-    // document (of which there shouldn't be any). The most expensive part to
-    // compute anyway is the effective group IDs, and we don't have a good way
-    // to reindex when those change.
-    return accountCache.get().get(id);
-  }
-}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
deleted file mode 100644
index 2912733..0000000
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ /dev/null
@@ -1,629 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.lucene;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
-import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
-import static com.google.gerrit.server.index.change.ChangeField.APPROVAL_CODEC;
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE_CODEC;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
-import static com.google.gerrit.server.index.change.ChangeField.PATCH_SET_CODEC;
-import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
-import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
-import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Throwables;
-import com.google.common.collect.Collections2;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Sets;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.ChangeIndexRewriter;
-import com.google.gerrit.server.project.SubmitRuleOptions;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gwtorm.protobuf.ProtobufCodec;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Future;
-import org.apache.lucene.document.Document;
-import org.apache.lucene.index.IndexWriter;
-import org.apache.lucene.index.IndexableField;
-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.SearcherManager;
-import org.apache.lucene.search.Sort;
-import org.apache.lucene.search.SortField;
-import org.apache.lucene.search.TopDocs;
-import org.apache.lucene.search.TopFieldDocs;
-import org.apache.lucene.store.RAMDirectory;
-import org.apache.lucene.util.BytesRef;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Secondary index implementation using Apache Lucene.
- *
- * <p>Writes are managed using a single {@link IndexWriter} per process, committed aggressively.
- * Reads use {@link SearcherManager} and periodically refresh, though there may be some lag between
- * a committed write and it showing up to other threads' searchers.
- */
-public class LuceneChangeIndex implements ChangeIndex {
-  private static final Logger log = LoggerFactory.getLogger(LuceneChangeIndex.class);
-
-  static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
-  static final String ID_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID);
-
-  private static final String CHANGES = "changes";
-  private static final String CHANGES_OPEN = "open";
-  private static final String CHANGES_CLOSED = "closed";
-  private static final String ADDED_FIELD = ChangeField.ADDED.getName();
-  private static final String APPROVAL_FIELD = ChangeField.APPROVAL.getName();
-  private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
-  private static final String DELETED_FIELD = ChangeField.DELETED.getName();
-  private static final String MERGEABLE_FIELD = ChangeField.MERGEABLE.getName();
-  private static final String PATCH_SET_FIELD = ChangeField.PATCH_SET.getName();
-  private static final String PENDING_REVIEWER_FIELD = ChangeField.PENDING_REVIEWER.getName();
-  private static final String PENDING_REVIEWER_BY_EMAIL_FIELD =
-      ChangeField.PENDING_REVIEWER_BY_EMAIL.getName();
-  private static final String REF_STATE_FIELD = ChangeField.REF_STATE.getName();
-  private static final String REF_STATE_PATTERN_FIELD = ChangeField.REF_STATE_PATTERN.getName();
-  private static final String REVIEWEDBY_FIELD = ChangeField.REVIEWEDBY.getName();
-  private static final String REVIEWER_FIELD = ChangeField.REVIEWER.getName();
-  private static final String REVIEWER_BY_EMAIL_FIELD = ChangeField.REVIEWER_BY_EMAIL.getName();
-  private static final String HASHTAG_FIELD = ChangeField.HASHTAG_CASE_AWARE.getName();
-  private static final String STAR_FIELD = ChangeField.STAR.getName();
-  private static final String SUBMIT_RECORD_LENIENT_FIELD =
-      ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName();
-  private static final String SUBMIT_RECORD_STRICT_FIELD =
-      ChangeField.STORED_SUBMIT_RECORD_STRICT.getName();
-  private static final String UNRESOLVED_COMMENT_COUNT_FIELD =
-      ChangeField.UNRESOLVED_COMMENT_COUNT.getName();
-
-  static Term idTerm(ChangeData cd) {
-    return QueryBuilder.intTerm(LEGACY_ID.getName(), cd.getId().get());
-  }
-
-  static Term idTerm(Change.Id id) {
-    return QueryBuilder.intTerm(LEGACY_ID.getName(), id.get());
-  }
-
-  private final ListeningExecutorService executor;
-  private final Provider<ReviewDb> db;
-  private final ChangeData.Factory changeDataFactory;
-  private final Schema<ChangeData> schema;
-  private final QueryBuilder<ChangeData> queryBuilder;
-  private final ChangeSubIndex openIndex;
-  private final ChangeSubIndex closedIndex;
-
-  @Inject
-  LuceneChangeIndex(
-      @GerritServerConfig Config cfg,
-      SitePaths sitePaths,
-      @IndexExecutor(INTERACTIVE) ListeningExecutorService executor,
-      Provider<ReviewDb> db,
-      ChangeData.Factory changeDataFactory,
-      @Assisted Schema<ChangeData> schema)
-      throws IOException {
-    this.executor = executor;
-    this.db = db;
-    this.changeDataFactory = changeDataFactory;
-    this.schema = schema;
-
-    GerritIndexWriterConfig openConfig = new GerritIndexWriterConfig(cfg, "changes_open");
-    GerritIndexWriterConfig closedConfig = new GerritIndexWriterConfig(cfg, "changes_closed");
-
-    queryBuilder = new QueryBuilder<>(schema, openConfig.getAnalyzer());
-
-    SearcherFactory searcherFactory = new SearcherFactory();
-    if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      openIndex =
-          new ChangeSubIndex(
-              schema, sitePaths, new RAMDirectory(), "ramOpen", openConfig, searcherFactory);
-      closedIndex =
-          new ChangeSubIndex(
-              schema, sitePaths, new RAMDirectory(), "ramClosed", closedConfig, searcherFactory);
-    } else {
-      Path dir = LuceneVersionManager.getDir(sitePaths, CHANGES, schema);
-      openIndex =
-          new ChangeSubIndex(
-              schema, sitePaths, dir.resolve(CHANGES_OPEN), openConfig, searcherFactory);
-      closedIndex =
-          new ChangeSubIndex(
-              schema, sitePaths, dir.resolve(CHANGES_CLOSED), closedConfig, searcherFactory);
-    }
-  }
-
-  @Override
-  public void close() {
-    try {
-      openIndex.close();
-    } finally {
-      closedIndex.close();
-    }
-  }
-
-  @Override
-  public Schema<ChangeData> getSchema() {
-    return schema;
-  }
-
-  @Override
-  public void replace(ChangeData cd) throws IOException {
-    Term id = LuceneChangeIndex.idTerm(cd);
-    // toDocument is essentially static and doesn't depend on the specific
-    // sub-index, so just pick one.
-    Document doc = openIndex.toDocument(cd);
-    try {
-      if (cd.change().getStatus().isOpen()) {
-        Futures.allAsList(closedIndex.delete(id), openIndex.replace(id, doc)).get();
-      } else {
-        Futures.allAsList(openIndex.delete(id), closedIndex.replace(id, doc)).get();
-      }
-    } catch (OrmException | ExecutionException | InterruptedException e) {
-      throw new IOException(e);
-    }
-  }
-
-  @Override
-  public void delete(Change.Id id) throws IOException {
-    Term idTerm = LuceneChangeIndex.idTerm(id);
-    try {
-      Futures.allAsList(openIndex.delete(idTerm), closedIndex.delete(idTerm)).get();
-    } catch (ExecutionException | InterruptedException e) {
-      throw new IOException(e);
-    }
-  }
-
-  @Override
-  public void deleteAll() throws IOException {
-    openIndex.deleteAll();
-    closedIndex.deleteAll();
-  }
-
-  @Override
-  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
-      throws QueryParseException {
-    Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
-    List<ChangeSubIndex> indexes = new ArrayList<>(2);
-    if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
-      indexes.add(openIndex);
-    }
-    if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
-      indexes.add(closedIndex);
-    }
-    return new QuerySource(indexes, p, opts, getSort());
-  }
-
-  @Override
-  public void markReady(boolean ready) throws IOException {
-    // Arbitrary done on open index, as ready bit is set
-    // per index and not sub index
-    openIndex.markReady(ready);
-  }
-
-  private Sort getSort() {
-    return new Sort(
-        new SortField(UPDATED_SORT_FIELD, SortField.Type.LONG, true),
-        new SortField(ID_SORT_FIELD, SortField.Type.LONG, true));
-  }
-
-  public ChangeSubIndex getClosedChangesIndex() {
-    return closedIndex;
-  }
-
-  private class QuerySource implements ChangeDataSource {
-    private final List<ChangeSubIndex> indexes;
-    private final Predicate<ChangeData> predicate;
-    private final Query query;
-    private final QueryOptions opts;
-    private final Sort sort;
-
-    private QuerySource(
-        List<ChangeSubIndex> indexes, Predicate<ChangeData> predicate, QueryOptions opts, Sort sort)
-        throws QueryParseException {
-      this.indexes = indexes;
-      this.predicate = predicate;
-      this.query = checkNotNull(queryBuilder.toQuery(predicate), "null query from Lucene");
-      this.opts = opts;
-      this.sort = sort;
-    }
-
-    @Override
-    public int getCardinality() {
-      return 10; // TODO(dborowitz): estimate from Lucene?
-    }
-
-    @Override
-    public boolean hasChange() {
-      return false;
-    }
-
-    @Override
-    public String toString() {
-      return predicate.toString();
-    }
-
-    @Override
-    public ResultSet<ChangeData> read() throws OrmException {
-      if (Thread.interrupted()) {
-        Thread.currentThread().interrupt();
-        throw new OrmException("interrupted");
-      }
-
-      final Set<String> fields = IndexUtils.changeFields(opts);
-      return new ChangeDataResults(
-          executor.submit(
-              new Callable<List<Document>>() {
-                @Override
-                public List<Document> call() throws IOException {
-                  return doRead(fields);
-                }
-
-                @Override
-                public String toString() {
-                  return predicate.toString();
-                }
-              }),
-          fields);
-    }
-
-    private List<Document> doRead(Set<String> fields) throws IOException {
-      IndexSearcher[] searchers = new IndexSearcher[indexes.size()];
-      try {
-        int realLimit = opts.start() + opts.limit();
-        if (Integer.MAX_VALUE - opts.limit() < opts.start()) {
-          realLimit = Integer.MAX_VALUE;
-        }
-        TopFieldDocs[] hits = new TopFieldDocs[indexes.size()];
-        for (int i = 0; i < indexes.size(); i++) {
-          searchers[i] = indexes.get(i).acquire();
-          hits[i] = searchers[i].search(query, realLimit, sort);
-        }
-        TopDocs docs = TopDocs.merge(sort, realLimit, hits);
-
-        List<Document> result = new ArrayList<>(docs.scoreDocs.length);
-        for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
-          ScoreDoc sd = docs.scoreDocs[i];
-          result.add(searchers[sd.shardIndex].doc(sd.doc, fields));
-        }
-        return result;
-      } finally {
-        for (int i = 0; i < indexes.size(); i++) {
-          if (searchers[i] != null) {
-            try {
-              indexes.get(i).release(searchers[i]);
-            } catch (IOException e) {
-              log.warn("cannot release Lucene searcher", e);
-            }
-          }
-        }
-      }
-    }
-  }
-
-  private class ChangeDataResults implements ResultSet<ChangeData> {
-    private final Future<List<Document>> future;
-    private final Set<String> fields;
-
-    ChangeDataResults(Future<List<Document>> future, Set<String> fields) {
-      this.future = future;
-      this.fields = fields;
-    }
-
-    @Override
-    public Iterator<ChangeData> iterator() {
-      return toList().iterator();
-    }
-
-    @Override
-    public List<ChangeData> toList() {
-      try {
-        List<Document> docs = future.get();
-        List<ChangeData> result = new ArrayList<>(docs.size());
-        String idFieldName = LEGACY_ID.getName();
-        for (Document doc : docs) {
-          result.add(toChangeData(fields(doc, fields), fields, idFieldName));
-        }
-        return result;
-      } catch (InterruptedException e) {
-        close();
-        throw new OrmRuntimeException(e);
-      } catch (ExecutionException e) {
-        Throwables.throwIfUnchecked(e.getCause());
-        throw new OrmRuntimeException(e.getCause());
-      }
-    }
-
-    @Override
-    public void close() {
-      future.cancel(false /* do not interrupt Lucene */);
-    }
-  }
-
-  private static ListMultimap<String, IndexableField> fields(Document doc, Set<String> fields) {
-    ListMultimap<String, IndexableField> stored =
-        MultimapBuilder.hashKeys(fields.size()).arrayListValues(4).build();
-    for (IndexableField f : doc) {
-      String name = f.name();
-      if (fields.contains(name)) {
-        stored.put(name, f);
-      }
-    }
-    return stored;
-  }
-
-  private ChangeData toChangeData(
-      ListMultimap<String, IndexableField> doc, Set<String> fields, String idFieldName) {
-    ChangeData cd;
-    // Either change or the ID field was guaranteed to be included in the call
-    // to fields() above.
-    IndexableField cb = Iterables.getFirst(doc.get(CHANGE_FIELD), null);
-    if (cb != null) {
-      BytesRef proto = cb.binaryValue();
-      cd =
-          changeDataFactory.create(
-              db.get(), CHANGE_CODEC.decode(proto.bytes, proto.offset, proto.length));
-    } else {
-      IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
-      Change.Id id = new Change.Id(f.numericValue().intValue());
-      // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
-      IndexableField project = doc.get(PROJECT.getName()).iterator().next();
-      cd = changeDataFactory.create(db.get(), new Project.NameKey(project.stringValue()), id);
-    }
-
-    if (fields.contains(PATCH_SET_FIELD)) {
-      decodePatchSets(doc, cd);
-    }
-    if (fields.contains(APPROVAL_FIELD)) {
-      decodeApprovals(doc, cd);
-    }
-    if (fields.contains(ADDED_FIELD) && fields.contains(DELETED_FIELD)) {
-      decodeChangedLines(doc, cd);
-    }
-    if (fields.contains(MERGEABLE_FIELD)) {
-      decodeMergeable(doc, cd);
-    }
-    if (fields.contains(REVIEWEDBY_FIELD)) {
-      decodeReviewedBy(doc, cd);
-    }
-    if (fields.contains(HASHTAG_FIELD)) {
-      decodeHashtags(doc, cd);
-    }
-    if (fields.contains(STAR_FIELD)) {
-      decodeStar(doc, cd);
-    }
-    if (fields.contains(REVIEWER_FIELD)) {
-      decodeReviewers(doc, cd);
-    }
-    if (fields.contains(REVIEWER_BY_EMAIL_FIELD)) {
-      decodeReviewersByEmail(doc, cd);
-    }
-    if (fields.contains(PENDING_REVIEWER_FIELD)) {
-      decodePendingReviewers(doc, cd);
-    }
-    if (fields.contains(PENDING_REVIEWER_BY_EMAIL_FIELD)) {
-      decodePendingReviewersByEmail(doc, cd);
-    }
-    decodeSubmitRecords(
-        doc, SUBMIT_RECORD_STRICT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
-    decodeSubmitRecords(
-        doc, SUBMIT_RECORD_LENIENT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_LENIENT, cd);
-    if (fields.contains(REF_STATE_FIELD)) {
-      decodeRefStates(doc, cd);
-    }
-    if (fields.contains(REF_STATE_PATTERN_FIELD)) {
-      decodeRefStatePatterns(doc, cd);
-    }
-
-    decodeUnresolvedCommentCount(doc, cd);
-    return cd;
-  }
-
-  private void decodePatchSets(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    List<PatchSet> patchSets = decodeProtos(doc, PATCH_SET_FIELD, PATCH_SET_CODEC);
-    if (!patchSets.isEmpty()) {
-      // Will be an empty list for schemas prior to when this field was stored;
-      // this cannot be valid since a change needs at least one patch set.
-      cd.setPatchSets(patchSets);
-    }
-  }
-
-  private void decodeApprovals(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setCurrentApprovals(decodeProtos(doc, APPROVAL_FIELD, APPROVAL_CODEC));
-  }
-
-  private void decodeChangedLines(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    IndexableField added = Iterables.getFirst(doc.get(ADDED_FIELD), null);
-    IndexableField deleted = Iterables.getFirst(doc.get(DELETED_FIELD), null);
-    if (added != null && deleted != null) {
-      cd.setChangedLines(added.numericValue().intValue(), deleted.numericValue().intValue());
-    } else {
-      // No ChangedLines stored, likely due to failure during reindexing, for
-      // example due to LargeObjectException. But we know the field was
-      // requested, so update ChangeData to prevent callers from trying to
-      // lazily load it, as that would probably also fail.
-      cd.setNoChangedLines();
-    }
-  }
-
-  private void decodeMergeable(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    IndexableField f = Iterables.getFirst(doc.get(MERGEABLE_FIELD), null);
-    if (f != null) {
-      String mergeable = f.stringValue();
-      if ("1".equals(mergeable)) {
-        cd.setMergeable(true);
-      } else if ("0".equals(mergeable)) {
-        cd.setMergeable(false);
-      }
-    }
-  }
-
-  private void decodeReviewedBy(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    Collection<IndexableField> reviewedBy = doc.get(REVIEWEDBY_FIELD);
-    if (reviewedBy.size() > 0) {
-      Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
-      for (IndexableField r : reviewedBy) {
-        int id = r.numericValue().intValue();
-        if (reviewedBy.size() == 1 && id == ChangeField.NOT_REVIEWED) {
-          break;
-        }
-        accounts.add(new Account.Id(id));
-      }
-      cd.setReviewedBy(accounts);
-    }
-  }
-
-  private void decodeHashtags(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    Collection<IndexableField> hashtag = doc.get(HASHTAG_FIELD);
-    Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtag.size());
-    for (IndexableField r : hashtag) {
-      hashtags.add(r.binaryValue().utf8ToString());
-    }
-    cd.setHashtags(hashtags);
-  }
-
-  private void decodeStar(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    Collection<IndexableField> star = doc.get(STAR_FIELD);
-    ListMultimap<Account.Id, String> stars = MultimapBuilder.hashKeys().arrayListValues().build();
-    for (IndexableField r : star) {
-      StarredChangesUtil.StarField starField = StarredChangesUtil.StarField.parse(r.stringValue());
-      if (starField != null) {
-        stars.put(starField.accountId(), starField.label());
-      }
-    }
-    cd.setStars(stars);
-  }
-
-  private void decodeReviewers(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setReviewers(
-        ChangeField.parseReviewerFieldValues(
-            FluentIterable.from(doc.get(REVIEWER_FIELD)).transform(IndexableField::stringValue)));
-  }
-
-  private void decodeReviewersByEmail(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setReviewersByEmail(
-        ChangeField.parseReviewerByEmailFieldValues(
-            FluentIterable.from(doc.get(REVIEWER_BY_EMAIL_FIELD))
-                .transform(IndexableField::stringValue)));
-  }
-
-  private void decodePendingReviewers(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setPendingReviewers(
-        ChangeField.parseReviewerFieldValues(
-            FluentIterable.from(doc.get(PENDING_REVIEWER_FIELD))
-                .transform(IndexableField::stringValue)));
-  }
-
-  private void decodePendingReviewersByEmail(
-      ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setPendingReviewersByEmail(
-        ChangeField.parseReviewerByEmailFieldValues(
-            FluentIterable.from(doc.get(PENDING_REVIEWER_BY_EMAIL_FIELD))
-                .transform(IndexableField::stringValue)));
-  }
-
-  private void decodeSubmitRecords(
-      ListMultimap<String, IndexableField> doc,
-      String field,
-      SubmitRuleOptions opts,
-      ChangeData cd) {
-    ChangeField.parseSubmitRecords(
-        Collections2.transform(doc.get(field), f -> f.binaryValue().utf8ToString()), opts, cd);
-  }
-
-  private void decodeRefStates(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setRefStates(copyAsBytes(doc.get(REF_STATE_FIELD)));
-  }
-
-  private void decodeRefStatePatterns(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setRefStatePatterns(copyAsBytes(doc.get(REF_STATE_PATTERN_FIELD)));
-  }
-
-  private void decodeUnresolvedCommentCount(
-      ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    IndexableField f = Iterables.getFirst(doc.get(UNRESOLVED_COMMENT_COUNT_FIELD), null);
-    if (f != null && f.numericValue() != null) {
-      cd.setUnresolvedCommentCount(f.numericValue().intValue());
-    }
-  }
-
-  private static <T> List<T> decodeProtos(
-      ListMultimap<String, IndexableField> doc, String fieldName, ProtobufCodec<T> codec) {
-    Collection<IndexableField> fields = doc.get(fieldName);
-    if (fields.isEmpty()) {
-      return Collections.emptyList();
-    }
-
-    List<T> result = new ArrayList<>(fields.size());
-    for (IndexableField f : fields) {
-      BytesRef r = f.binaryValue();
-      result.add(codec.decode(r.bytes, r.offset, r.length));
-    }
-    return result;
-  }
-
-  private static List<byte[]> copyAsBytes(Collection<IndexableField> fields) {
-    return fields
-        .stream()
-        .map(
-            f -> {
-              BytesRef ref = f.binaryValue();
-              byte[] b = new byte[ref.length];
-              System.arraycopy(ref.bytes, ref.offset, b, 0, ref.length);
-              return b;
-            })
-        .collect(toList());
-  }
-}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneGroupIndex.java
deleted file mode 100644
index 32870cb..0000000
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ /dev/null
@@ -1,204 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.lucene;
-
-import static com.google.gerrit.server.index.group.GroupField.UUID;
-
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.group.GroupIndex;
-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.Optional;
-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 LuceneGroupIndex extends AbstractLuceneIndex<AccountGroup.UUID, InternalGroup>
-    implements GroupIndex {
-  private static final Logger log = LoggerFactory.getLogger(LuceneGroupIndex.class);
-
-  private static final String GROUPS = "groups";
-
-  private static final String UUID_SORT_FIELD = sortFieldName(UUID);
-
-  private static Term idTerm(InternalGroup group) {
-    return idTerm(group.getGroupUUID());
-  }
-
-  private static Term idTerm(AccountGroup.UUID uuid) {
-    return QueryBuilder.stringTerm(UUID.getName(), uuid.get());
-  }
-
-  private final GerritIndexWriterConfig indexWriterConfig;
-  private final QueryBuilder<InternalGroup> queryBuilder;
-  private final Provider<GroupCache> groupCache;
-
-  private static Directory dir(Schema<?> schema, Config cfg, SitePaths sitePaths)
-      throws IOException {
-    if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      return new RAMDirectory();
-    }
-    Path indexDir = LuceneVersionManager.getDir(sitePaths, GROUPS, schema);
-    return FSDirectory.open(indexDir);
-  }
-
-  @Inject
-  LuceneGroupIndex(
-      @GerritServerConfig Config cfg,
-      SitePaths sitePaths,
-      Provider<GroupCache> groupCache,
-      @Assisted Schema<InternalGroup> schema)
-      throws IOException {
-    super(
-        schema,
-        sitePaths,
-        dir(schema, cfg, sitePaths),
-        GROUPS,
-        null,
-        new GerritIndexWriterConfig(cfg, GROUPS),
-        new SearcherFactory());
-    this.groupCache = groupCache;
-
-    indexWriterConfig = new GerritIndexWriterConfig(cfg, GROUPS);
-    queryBuilder = new QueryBuilder<>(schema, indexWriterConfig.getAnalyzer());
-  }
-
-  @Override
-  public void replace(InternalGroup group) throws IOException {
-    try {
-      replace(idTerm(group), toDocument(group)).get();
-    } catch (ExecutionException | InterruptedException e) {
-      throw new IOException(e);
-    }
-  }
-
-  @Override
-  public void delete(AccountGroup.UUID key) throws IOException {
-    try {
-      delete(idTerm(key)).get();
-    } catch (ExecutionException | InterruptedException e) {
-      throw new IOException(e);
-    }
-  }
-
-  @Override
-  public DataSource<InternalGroup> getSource(Predicate<InternalGroup> p, QueryOptions opts)
-      throws QueryParseException {
-    return new QuerySource(
-        opts,
-        queryBuilder.toQuery(p),
-        new Sort(new SortField(UUID_SORT_FIELD, SortField.Type.STRING, false)));
-  }
-
-  private class QuerySource implements DataSource<InternalGroup> {
-    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<InternalGroup> read() throws OrmException {
-      IndexSearcher searcher = null;
-      try {
-        searcher = acquire();
-        int realLimit = opts.start() + opts.limit();
-        TopFieldDocs docs = searcher.search(query, realLimit, sort);
-        List<InternalGroup> result = new ArrayList<>(docs.scoreDocs.length);
-        for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
-          ScoreDoc sd = docs.scoreDocs[i];
-          Document doc = searcher.doc(sd.doc, IndexUtils.groupFields(opts));
-          Optional<InternalGroup> internalGroup = toInternalGroup(doc);
-          internalGroup.ifPresent(result::add);
-        }
-        final List<InternalGroup> r = Collections.unmodifiableList(result);
-        return new ResultSet<InternalGroup>() {
-          @Override
-          public Iterator<InternalGroup> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<InternalGroup> 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 Optional<InternalGroup> toInternalGroup(Document doc) {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(doc.getField(UUID.getName()).stringValue());
-    // Use the GroupCache rather than depending on any stored fields in the
-    // document (of which there shouldn't be any).
-    return groupCache.get().get(uuid);
-  }
-}
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
deleted file mode 100644
index d738540..0000000
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ /dev/null
@@ -1,115 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.lucene;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.OnlineUpgrader;
-import com.google.gerrit.server.index.SingleVersionModule;
-import com.google.gerrit.server.index.VersionManager;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.inject.AbstractModule;
-import com.google.inject.Provides;
-import com.google.inject.Singleton;
-import com.google.inject.assistedinject.FactoryModuleBuilder;
-import java.util.Map;
-import org.apache.lucene.search.BooleanQuery;
-import org.eclipse.jgit.lib.Config;
-
-public class LuceneIndexModule extends AbstractModule {
-  public static LuceneIndexModule singleVersionAllLatest(int threads) {
-    return new LuceneIndexModule(ImmutableMap.<String, Integer>of(), threads, false);
-  }
-
-  public static LuceneIndexModule singleVersionWithExplicitVersions(
-      Map<String, Integer> versions, int threads) {
-    return new LuceneIndexModule(versions, threads, false);
-  }
-
-  public static LuceneIndexModule latestVersionWithOnlineUpgrade() {
-    return new LuceneIndexModule(null, 0, true);
-  }
-
-  public static LuceneIndexModule latestVersionWithoutOnlineUpgrade() {
-    return new LuceneIndexModule(null, 0, false);
-  }
-
-  static boolean isInMemoryTest(Config cfg) {
-    return cfg.getBoolean("index", "lucene", "testInmemory", false);
-  }
-
-  private final Map<String, Integer> singleVersions;
-  private final int threads;
-  private final boolean onlineUpgrade;
-
-  private LuceneIndexModule(
-      Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade) {
-    if (singleVersions != null) {
-      checkArgument(!onlineUpgrade, "online upgrade is incompatible with single version map");
-    }
-    this.singleVersions = singleVersions;
-    this.threads = threads;
-    this.onlineUpgrade = onlineUpgrade;
-  }
-
-  @Override
-  protected void configure() {
-    install(
-        new FactoryModuleBuilder()
-            .implement(AccountIndex.class, LuceneAccountIndex.class)
-            .build(AccountIndex.Factory.class));
-    install(
-        new FactoryModuleBuilder()
-            .implement(ChangeIndex.class, LuceneChangeIndex.class)
-            .build(ChangeIndex.Factory.class));
-    install(
-        new FactoryModuleBuilder()
-            .implement(GroupIndex.class, LuceneGroupIndex.class)
-            .build(GroupIndex.Factory.class));
-
-    install(new IndexModule(threads));
-    if (singleVersions == null) {
-      install(new MultiVersionModule());
-    } else {
-      install(new SingleVersionModule(singleVersions));
-    }
-  }
-
-  @Provides
-  @Singleton
-  IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
-    BooleanQuery.setMaxClauseCount(
-        cfg.getInt("index", "maxTerms", BooleanQuery.getMaxClauseCount()));
-    return IndexConfig.fromConfig(cfg).separateChangeSubIndexes(true).build();
-  }
-
-  private class MultiVersionModule extends LifecycleModule {
-    @Override
-    public void configure() {
-      bind(VersionManager.class).to(LuceneVersionManager.class);
-      listener().to(LuceneVersionManager.class);
-      if (onlineUpgrade) {
-        listener().to(OnlineUpgrader.class);
-      }
-    }
-  }
-}
diff --git a/gerrit-main/BUILD b/gerrit-main/BUILD
deleted file mode 100644
index 243a70b..0000000
--- a/gerrit-main/BUILD
+++ /dev/null
@@ -1,13 +0,0 @@
-java_binary(
-    name = "main_bin",
-    main_class = "Main",
-    visibility = ["//visibility:public"],
-    runtime_deps = [":main_lib"],
-)
-
-java_library(
-    name = "main_lib",
-    srcs = ["src/main/java/Main.java"],
-    visibility = ["//visibility:public"],
-    deps = ["//gerrit-launcher:launcher"],
-)
diff --git a/gerrit-oauth/BUILD b/gerrit-oauth/BUILD
deleted file mode 100644
index 0ef89c0..0000000
--- a/gerrit-oauth/BUILD
+++ /dev/null
@@ -1,28 +0,0 @@
-SRCS = glob(
-    ["src/main/java/**/*.java"],
-)
-
-RESOURCES = glob(["src/main/resources/**/*"])
-
-java_library(
-    name = "oauth",
-    srcs = SRCS,
-    resources = RESOURCES,
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-common:annotations",
-        "//gerrit-extension-api:api",
-        "//gerrit-httpd:httpd",
-        "//gerrit-reviewdb:server",
-        "//gerrit-server:server",
-        "//lib:gson",
-        "//lib:guava",
-        "//lib:gwtorm",
-        "//lib:servlet-api-3_1",
-        "//lib/commons:codec",
-        "//lib/guice",
-        "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/log:api",
-    ],
-)
diff --git a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthLogoutServlet.java b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthLogoutServlet.java
deleted file mode 100644
index 126a1d7..0000000
--- a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthLogoutServlet.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.auth.oauth;
-
-import com.google.gerrit.audit.AuditService;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.httpd.HttpLogoutServlet;
-import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-@Singleton
-class OAuthLogoutServlet extends HttpLogoutServlet {
-  private static final long serialVersionUID = 1L;
-
-  private final Provider<OAuthSession> oauthSession;
-
-  @Inject
-  OAuthLogoutServlet(
-      AuthConfig authConfig,
-      DynamicItem<WebSession> webSession,
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      AuditService audit,
-      Provider<OAuthSession> oauthSession) {
-    super(authConfig, webSession, urlProvider, audit);
-    this.oauthSession = oauthSession;
-  }
-
-  @Override
-  protected void doLogout(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-    super.doLogout(req, rsp);
-    if (req.getSession(false) != null) {
-      oauthSession.get().logout();
-    }
-  }
-}
diff --git a/gerrit-openid/BUILD b/gerrit-openid/BUILD
deleted file mode 100644
index 7b0d2b1..0000000
--- a/gerrit-openid/BUILD
+++ /dev/null
@@ -1,25 +0,0 @@
-java_library(
-    name = "openid",
-    srcs = glob(["src/main/java/**/*.java"]),
-    resources = glob(["src/main/resources/**/*"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        # We want all these deps to be provided_deps
-        "//gerrit-common:annotations",
-        "//gerrit-common:server",
-        "//gerrit-extension-api:api",
-        "//gerrit-gwtexpui:server",
-        "//gerrit-httpd:httpd",
-        "//gerrit-reviewdb:server",
-        "//gerrit-server:server",
-        "//lib:guava",
-        "//lib:gwtorm",
-        "//lib:servlet-api-3_1",
-        "//lib/commons:codec",
-        "//lib/guice",
-        "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/log:api",
-        "//lib/openid:consumer",
-    ],
-)
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthOverOpenIDLogoutServlet.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthOverOpenIDLogoutServlet.java
deleted file mode 100644
index eecfb7f..0000000
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthOverOpenIDLogoutServlet.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.auth.openid;
-
-import com.google.gerrit.audit.AuditService;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.httpd.HttpLogoutServlet;
-import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-@Singleton
-class OAuthOverOpenIDLogoutServlet extends HttpLogoutServlet {
-  private static final long serialVersionUID = 1L;
-
-  private final Provider<OAuthSessionOverOpenID> oauthSession;
-
-  @Inject
-  OAuthOverOpenIDLogoutServlet(
-      AuthConfig authConfig,
-      DynamicItem<WebSession> webSession,
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      AuditService audit,
-      Provider<OAuthSessionOverOpenID> oauthSession) {
-    super(authConfig, webSession, urlProvider, audit);
-    this.oauthSession = oauthSession;
-  }
-
-  @Override
-  protected void doLogout(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-    super.doLogout(req, rsp);
-    if (req.getSession(false) != null) {
-      oauthSession.get().logout();
-    }
-  }
-}
diff --git a/gerrit-patch-commonsnet/BUILD b/gerrit-patch-commonsnet/BUILD
deleted file mode 100644
index 7524bfe..0000000
--- a/gerrit-patch-commonsnet/BUILD
+++ /dev/null
@@ -1,11 +0,0 @@
-java_library(
-    name = "commons-net",
-    srcs = glob(["src/main/java/org/apache/commons/net/**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-util-ssl:ssl",
-        "//lib/commons:codec",
-        "//lib/commons:net",
-        "//lib/log:api",
-    ],
-)
diff --git a/gerrit-patch-jgit/BUILD b/gerrit-patch-jgit/BUILD
deleted file mode 100644
index 1a8fcd4..0000000
--- a/gerrit-patch-jgit/BUILD
+++ /dev/null
@@ -1,67 +0,0 @@
-load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/bzl:gwt.bzl", "gwt_module")
-
-SRC = "src/main/java/org/eclipse/jgit/"
-
-gwt_module(
-    name = "client",
-    srcs = [
-        SRC + "diff/Edit_JsonSerializer.java",
-        SRC + "diff/ReplaceEdit.java",
-    ],
-    gwt_xml = SRC + "JGit.gwt.xml",
-    visibility = ["//visibility:public"],
-    deps = [
-        ":Edit",
-        "//lib:gwtjsonrpc",
-        "//lib/gwt:user",
-    ],
-)
-
-gwt_module(
-    name = "Edit",
-    srcs = [":jgit_edit_src"],
-    visibility = ["//visibility:public"],
-)
-
-genrule2(
-    name = "jgit_edit_src",
-    outs = ["edit.srcjar"],
-    cmd = " && ".join([
-        "unzip -qd $$TMP $(location //lib/jgit/org.eclipse.jgit:jgit-source) " +
-        "org/eclipse/jgit/diff/Edit.java",
-        "cd $$TMP",
-        "zip -Dq $$ROOT/$@ org/eclipse/jgit/diff/Edit.java",
-    ]),
-    tools = ["//lib/jgit/org.eclipse.jgit:jgit-source"],
-)
-
-java_library(
-    name = "server",
-    srcs = [
-        SRC + x
-        for x in [
-            "diff/EditDeserializer.java",
-            "diff/ReplaceEdit.java",
-            "internal/storage/file/WindowCacheStatAccessor.java",
-            "lib/ObjectIdSerialization.java",
-        ]
-    ],
-    visibility = ["//visibility:public"],
-    deps = [
-        "//lib:gson",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-    ],
-)
-
-java_test(
-    name = "jgit_patch_tests",
-    srcs = glob(["src/test/java/**/*.java"]),
-    test_class = "org.eclipse.jgit.diff.EditDeserializerTest",
-    visibility = ["//visibility:public"],
-    deps = [
-        ":server",
-        "//lib:junit",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-    ],
-)
diff --git a/gerrit-pgm/BUILD b/gerrit-pgm/BUILD
deleted file mode 100644
index 1fd3165..0000000
--- a/gerrit-pgm/BUILD
+++ /dev/null
@@ -1,181 +0,0 @@
-load("//tools/bzl:java.bzl", "java_library2")
-load("//tools/bzl:junit.bzl", "junit_tests")
-load("//tools/bzl:license.bzl", "license_test")
-
-SRCS = "src/main/java/com/google/gerrit/pgm/"
-
-RSRCS = "src/main/resources/com/google/gerrit/pgm/"
-
-INIT_API_SRCS = glob([SRCS + "init/api/*.java"])
-
-BASE_JETTY_DEPS = [
-    "//gerrit-common:annotations",
-    "//gerrit-common:server",
-    "//gerrit-extension-api:api",
-    "//gerrit-gwtexpui:linker_server",
-    "//gerrit-gwtexpui:server",
-    "//gerrit-httpd:httpd",
-    "//gerrit-server:server",
-    "//gerrit-sshd:sshd",
-    "//lib:guava",
-    "//lib/guice:guice",
-    "//lib/guice:guice-assistedinject",
-    "//lib/guice:guice-servlet",
-    "//lib/jgit/org.eclipse.jgit:jgit",
-    "//lib/joda:joda-time",
-    "//lib/log:api",
-    "//lib/log:log4j",
-]
-
-DEPS = BASE_JETTY_DEPS + [
-    "//gerrit-reviewdb:server",
-    "//gerrit-server:metrics",
-    "//gerrit-server:module",
-    "//gerrit-server:receive",
-    "//lib:gwtorm",
-    "//lib/log:jsonevent-layout",
-]
-
-java_library(
-    name = "init-api",
-    srcs = INIT_API_SRCS,
-    visibility = ["//visibility:public"],
-    deps = DEPS,
-)
-
-java_library(
-    name = "init",
-    srcs = glob([SRCS + "init/**/*.java"]),
-    resources = glob([RSRCS + "init/*"]),
-    visibility = ["//visibility:public"],
-    deps = DEPS + [
-        ":init-api",
-        ":util",
-        "//gerrit-index:index",
-        "//gerrit-elasticsearch:elasticsearch",
-        "//gerrit-launcher:launcher",  # We want this dep to be provided_deps
-        "//gerrit-lucene:lucene",
-        "//lib:args4j",
-        "//lib:derby",
-        "//lib:gwtjsonrpc",
-        "//lib:h2",
-        "//lib/commons:validator",
-        "//lib/mina:sshd",
-    ],
-)
-
-REST_UTIL_DEPS = [
-    "//gerrit-cache-h2:cache-h2",
-    "//gerrit-util-cli:cli",
-    "//lib:args4j",
-    "//lib/commons:dbcp",
-]
-
-java_library(
-    name = "util",
-    visibility = ["//visibility:public"],
-    exports = [":util-nodep"],
-    runtime_deps = DEPS + REST_UTIL_DEPS,
-)
-
-java_library(
-    name = "util-nodep",
-    srcs = glob([SRCS + "util/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = DEPS + REST_UTIL_DEPS,  #  We want all these deps to be provided_deps
-)
-
-JETTY_DEPS = [
-    "//lib/jetty:jmx",
-    "//lib/jetty:server",
-    "//lib/jetty:servlet",
-]
-
-java_library(
-    name = "http",
-    visibility = ["//visibility:public"],
-    exports = [":http-jetty"],
-    runtime_deps = DEPS + JETTY_DEPS,
-)
-
-java_library(
-    name = "http-jetty",
-    srcs = glob([SRCS + "http/jetty/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = JETTY_DEPS + BASE_JETTY_DEPS + [
-        # We want all these deps to be provided_deps
-        "//gerrit-launcher:launcher",
-        "//gerrit-reviewdb:client",
-        "//lib:servlet-api-3_1",
-    ],
-)
-
-REST_PGM_DEPS = [
-    ":http",
-    ":init",
-    ":init-api",
-    ":util",
-    "//gerrit-cache-h2:cache-h2",
-    "//gerrit-elasticsearch:elasticsearch",
-    "//gerrit-gpg:gpg",
-    "//gerrit-index:index",
-    "//gerrit-lucene:lucene",
-    "//gerrit-oauth:oauth",
-    "//gerrit-openid:openid",
-    "//lib:args4j",
-    "//lib:protobuf",
-    "//lib:servlet-api-3_1-without-neverlink",
-    "//lib/prolog:cafeteria",
-    "//lib/prolog:compiler",
-    "//lib/prolog:runtime",
-]
-
-java_library(
-    name = "pgm",
-    resources = glob([RSRCS + "*"]),
-    visibility = ["//visibility:public"],
-    runtime_deps = DEPS + REST_PGM_DEPS + [
-        ":daemon",
-    ],
-)
-
-# no transitive deps, used for gerrit-acceptance-framework
-java_library(
-    name = "daemon",
-    srcs = glob([
-        SRCS + "*.java",
-        SRCS + "rules/*.java",
-    ]),
-    resources = glob([RSRCS + "*"]),
-    visibility = ["//visibility:public"],
-    deps = DEPS + REST_PGM_DEPS + [
-        # We want all these deps to be provided_deps
-        "//gerrit-launcher:launcher",
-        "//lib/auto:auto-value",
-    ],
-)
-
-junit_tests(
-    name = "pgm_tests",
-    srcs = glob(["src/test/java/**/*.java"]),
-    deps = [
-        ":http-jetty",
-        ":init",
-        ":init-api",
-        ":pgm",
-        "//gerrit-common:server",
-        "//gerrit-server:server",
-        "//lib:guava",
-        "//lib:junit",
-        "//lib:truth",
-        "//lib/easymock",
-        "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/jgit/org.eclipse.jgit.junit:junit",
-    ],
-)
-
-license_test(
-    name = "pgm_license_test",
-    target = ":pgm",
-)
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
deleted file mode 100644
index 6b5c157..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
+++ /dev/null
@@ -1,593 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.MoreObjects;
-import com.google.gerrit.common.EventBroker;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.elasticsearch.ElasticIndexModule;
-import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.gpg.GpgModule;
-import com.google.gerrit.httpd.AllRequestFilter;
-import com.google.gerrit.httpd.GetUserFilter;
-import com.google.gerrit.httpd.GitOverHttpModule;
-import com.google.gerrit.httpd.H2CacheBasedWebSession;
-import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
-import com.google.gerrit.httpd.RequestContextFilter;
-import com.google.gerrit.httpd.RequestMetricsFilter;
-import com.google.gerrit.httpd.RequireSslFilter;
-import com.google.gerrit.httpd.WebModule;
-import com.google.gerrit.httpd.WebSshGlueModule;
-import com.google.gerrit.httpd.auth.oauth.OAuthModule;
-import com.google.gerrit.httpd.auth.openid.OpenIdModule;
-import com.google.gerrit.httpd.plugins.HttpPluginModule;
-import com.google.gerrit.httpd.raw.StaticModule;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.lucene.LuceneIndexModule;
-import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
-import com.google.gerrit.pgm.http.jetty.JettyEnv;
-import com.google.gerrit.pgm.http.jetty.JettyModule;
-import com.google.gerrit.pgm.http.jetty.ProjectQoSFilter;
-import com.google.gerrit.pgm.util.ErrorLogFile;
-import com.google.gerrit.pgm.util.LogFileCompressor;
-import com.google.gerrit.pgm.util.RuntimeShutdown;
-import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.server.LibModuleLoader;
-import com.google.gerrit.server.StartupChecks;
-import com.google.gerrit.server.account.AccountDeactivator;
-import com.google.gerrit.server.account.InternalAccountDirectory;
-import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
-import com.google.gerrit.server.change.ChangeCleanupRunner;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.config.AuthConfigModule;
-import com.google.gerrit.server.config.CanonicalWebUrlModule;
-import com.google.gerrit.server.config.CanonicalWebUrlProvider;
-import com.google.gerrit.server.config.DownloadConfig;
-import com.google.gerrit.server.config.GerritGlobalModule;
-import com.google.gerrit.server.config.GerritOptions;
-import com.google.gerrit.server.config.GerritServerConfig;
-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.SearchingChangeCacheImpl;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.git.receive.ReceiveCommitsExecutorModule;
-import com.google.gerrit.server.index.DummyIndexModule;
-import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.IndexModule.IndexType;
-import com.google.gerrit.server.index.VersionManager;
-import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
-import com.google.gerrit.server.mail.receive.MailReceiver;
-import com.google.gerrit.server.mail.send.SmtpEmailSender;
-import com.google.gerrit.server.mime.MimeUtil2Module;
-import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
-import com.google.gerrit.server.notedb.rebuild.OnlineNoteDbMigrator;
-import com.google.gerrit.server.patch.DiffExecutorModule;
-import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
-import com.google.gerrit.server.plugins.PluginModule;
-import com.google.gerrit.server.plugins.PluginRestApiModule;
-import com.google.gerrit.server.project.DefaultPermissionBackendModule;
-import com.google.gerrit.server.schema.DataSourceProvider;
-import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
-import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
-import com.google.gerrit.server.schema.SchemaVersionCheck;
-import com.google.gerrit.server.securestore.DefaultSecureStore;
-import com.google.gerrit.server.securestore.SecureStore;
-import com.google.gerrit.server.securestore.SecureStoreClassName;
-import com.google.gerrit.server.securestore.SecureStoreProvider;
-import com.google.gerrit.server.ssh.NoSshKeyCache;
-import com.google.gerrit.server.ssh.NoSshModule;
-import com.google.gerrit.server.ssh.SshAddressesModule;
-import com.google.gerrit.sshd.SshHostKeyModule;
-import com.google.gerrit.sshd.SshKeyCacheImpl;
-import com.google.gerrit.sshd.SshModule;
-import com.google.gerrit.sshd.commands.DefaultCommandModule;
-import com.google.gerrit.sshd.commands.IndexCommandsModule;
-import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand;
-import com.google.inject.AbstractModule;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import com.google.inject.Key;
-import com.google.inject.Module;
-import com.google.inject.Provider;
-import com.google.inject.Stage;
-import java.io.IOException;
-import java.lang.Thread.UncaughtExceptionHandler;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import javax.servlet.http.HttpServletRequest;
-import org.eclipse.jgit.lib.Config;
-import org.kohsuke.args4j.Option;
-import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Run SSH daemon portions of Gerrit. */
-public class Daemon extends SiteProgram {
-  private static final Logger log = LoggerFactory.getLogger(Daemon.class);
-
-  @Option(name = "--enable-httpd", usage = "Enable the internal HTTP daemon")
-  private Boolean httpd;
-
-  @Option(name = "--disable-httpd", usage = "Disable the internal HTTP daemon")
-  void setDisableHttpd(@SuppressWarnings("unused") boolean arg) {
-    httpd = false;
-  }
-
-  @Option(name = "--enable-sshd", usage = "Enable the internal SSH daemon")
-  private boolean sshd = true;
-
-  @Option(name = "--disable-sshd", usage = "Disable the internal SSH daemon")
-  void setDisableSshd(@SuppressWarnings("unused") boolean arg) {
-    sshd = false;
-  }
-
-  @Option(name = "--slave", usage = "Support fetch only")
-  private boolean slave;
-
-  @Option(name = "--console-log", usage = "Log to console (not $site_path/logs)")
-  private boolean consoleLog;
-
-  @Option(name = "-s", usage = "Start interactive shell")
-  private boolean inspector;
-
-  @Option(name = "--run-id", usage = "Cookie to store in $site_path/logs/gerrit.run")
-  private String runId;
-
-  @Option(name = "--headless", usage = "Don't start the UI frontend")
-  private boolean headless;
-
-  @Option(name = "--polygerrit-dev", usage = "Force PolyGerrit UI for development")
-  private boolean polyGerritDev;
-
-  @Option(
-    name = "--init",
-    aliases = {"-i"},
-    usage = "Init site before starting the daemon"
-  )
-  private boolean doInit;
-
-  @Option(name = "--stop-only", usage = "Stop the daemon", hidden = true)
-  private boolean stopOnly;
-
-  @Option(
-    name = "--migrate-to-note-db",
-    usage = "Automatically migrate changes to NoteDb",
-    handler = ExplicitBooleanOptionHandler.class
-  )
-  private boolean migrateToNoteDb;
-
-  @Option(name = "--trial", usage = "(With --migrate-to-note-db) " + MigrateToNoteDb.TRIAL_USAGE)
-  private boolean trial;
-
-  private final LifecycleManager manager = new LifecycleManager();
-  private Injector dbInjector;
-  private Injector cfgInjector;
-  private Config config;
-  private Injector sysInjector;
-  private Injector sshInjector;
-  private Injector webInjector;
-  private Injector httpdInjector;
-  private Path runFile;
-  private boolean inMemoryTest;
-  private AbstractModule luceneModule;
-  private Module emailModule;
-  private Module testSysModule;
-
-  private Runnable serverStarted;
-  private IndexType indexType;
-
-  public Daemon() {}
-
-  @VisibleForTesting
-  public Daemon(Runnable serverStarted, Path sitePath) {
-    super(sitePath);
-    this.serverStarted = serverStarted;
-  }
-
-  @VisibleForTesting
-  public void setEnableSshd(boolean enable) {
-    sshd = enable;
-  }
-
-  @VisibleForTesting
-  public boolean getEnableSshd() {
-    return sshd;
-  }
-
-  public void setEnableHttpd(boolean enable) {
-    httpd = enable;
-  }
-
-  @Override
-  public int run() throws Exception {
-    if (stopOnly) {
-      RuntimeShutdown.manualShutdown();
-      return 0;
-    }
-    if (doInit) {
-      try {
-        new Init(getSitePath()).run();
-      } catch (Exception e) {
-        throw die("Init failed", e);
-      }
-    }
-    mustHaveValidSite();
-    Thread.setDefaultUncaughtExceptionHandler(
-        new UncaughtExceptionHandler() {
-          @Override
-          public void uncaughtException(Thread t, Throwable e) {
-            log.error("Thread " + t.getName() + " threw exception", e);
-          }
-        });
-
-    if (runId != null) {
-      runFile = getSitePath().resolve("logs").resolve("gerrit.run");
-    }
-
-    if (httpd == null) {
-      httpd = !slave;
-    }
-
-    if (!httpd && !sshd) {
-      throw die("No services enabled, nothing to do");
-    }
-
-    try {
-      start();
-      RuntimeShutdown.add(
-          () -> {
-            log.info("caught shutdown, cleaning up");
-            stop();
-          });
-
-      log.info("Gerrit Code Review " + myVersion() + " ready");
-      if (runId != null) {
-        try {
-          Files.write(runFile, (runId + "\n").getBytes(UTF_8));
-          runFile.toFile().setReadable(true, false);
-        } catch (IOException err) {
-          log.warn("Cannot write --run-id to " + runFile, err);
-        }
-      }
-
-      if (serverStarted != null) {
-        serverStarted.run();
-      }
-
-      if (inspector) {
-        JythonShell shell = new JythonShell();
-        shell.set("m", manager);
-        shell.set("ds", dbInjector.getInstance(DataSourceProvider.class));
-        shell.set("schk", dbInjector.getInstance(SchemaVersionCheck.class));
-        shell.set("d", this);
-        shell.run();
-      } else {
-        RuntimeShutdown.waitFor();
-      }
-      return 0;
-    } catch (Throwable err) {
-      log.error("Unable to start daemon", err);
-      return 1;
-    }
-  }
-
-  @VisibleForTesting
-  public LifecycleManager getLifecycleManager() {
-    return manager;
-  }
-
-  @VisibleForTesting
-  public void setDatabaseForTesting(List<Module> modules) {
-    dbInjector = Guice.createInjector(Stage.PRODUCTION, modules);
-    inMemoryTest = true;
-    headless = true;
-  }
-
-  @VisibleForTesting
-  public void setEmailModuleForTesting(Module module) {
-    emailModule = module;
-  }
-
-  @VisibleForTesting
-  public void setLuceneModule(LuceneIndexModule m) {
-    luceneModule = m;
-    inMemoryTest = true;
-  }
-
-  @VisibleForTesting
-  public void setAdditionalSysModuleForTesting(@Nullable Module m) {
-    testSysModule = m;
-  }
-
-  @VisibleForTesting
-  public void start() throws IOException {
-    if (dbInjector == null) {
-      dbInjector = createDbInjector(true /* enableMetrics */, MULTI_USER);
-    }
-    cfgInjector = createCfgInjector();
-    config = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    if (!slave) {
-      initIndexType();
-    }
-    sysInjector = createSysInjector();
-    sysInjector.getInstance(PluginGuiceEnvironment.class).setDbCfgInjector(dbInjector, cfgInjector);
-    manager.add(dbInjector, cfgInjector, sysInjector);
-
-    if (!consoleLog) {
-      manager.add(ErrorLogFile.start(getSitePath(), config));
-    }
-
-    sshd &= !sshdOff();
-    if (sshd) {
-      initSshd();
-    }
-
-    if (MoreObjects.firstNonNull(httpd, true)) {
-      initHttpd();
-    }
-
-    manager.start();
-  }
-
-  @VisibleForTesting
-  public void stop() {
-    if (runId != null) {
-      try {
-        Files.delete(runFile);
-      } catch (IOException err) {
-        log.warn("failed to delete " + runFile, err);
-      }
-    }
-    manager.stop();
-  }
-
-  private boolean sshdOff() {
-    return new SshAddressesModule().getListenAddresses(config).isEmpty();
-  }
-
-  private String myVersion() {
-    return com.google.gerrit.common.Version.getVersion();
-  }
-
-  private Injector createCfgInjector() {
-    final List<Module> modules = new ArrayList<>();
-    modules.add(new AuthConfigModule());
-    return dbInjector.createChildInjector(modules);
-  }
-
-  private Injector createSysInjector() {
-    final List<Module> modules = new ArrayList<>();
-    modules.add(SchemaVersionCheck.module());
-    modules.add(new DropWizardMetricMaker.RestModule());
-    modules.add(new LogFileCompressor.Module());
-
-    // Plugin module needs to be inserted *before* the index module.
-    // There is the concept of LifecycleModule, in Gerrit's own extension
-    // to Guice, which has these:
-    //  listener().to(SomeClassImplementingLifecycleListener.class);
-    // and the start() methods of each such listener are executed in the
-    // order they are declared.
-    // Makes sure that PluginLoader.start() is executed before the
-    // LuceneIndexModule.start() so that plugins get loaded and the respective
-    // Guice modules installed so that the on-line reindexing will happen
-    // with the proper classes (e.g. group backends, custom Prolog
-    // predicates) and the associated rules ready to be evaluated.
-    modules.add(new PluginModule());
-
-    // Index module shutdown must happen before work queue shutdown, otherwise
-    // work queue can get stuck waiting on index futures that will never return.
-    modules.add(createIndexModule());
-
-    modules.add(new WorkQueue.Module());
-    modules.add(new StreamEventsApiListener.Module());
-    modules.add(new EventBroker.Module());
-    modules.add(
-        inMemoryTest
-            ? new InMemoryAccountPatchReviewStore.Module()
-            : new JdbcAccountPatchReviewStore.Module(config));
-    modules.add(new ReceiveCommitsExecutorModule());
-    modules.add(new DiffExecutorModule());
-    modules.add(new MimeUtil2Module());
-    modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
-    modules.add(new SearchingChangeCacheImpl.Module(slave));
-    modules.add(new InternalAccountDirectory.Module());
-    modules.add(new DefaultPermissionBackendModule());
-    modules.add(new DefaultCacheFactory.Module());
-    modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
-    if (emailModule != null) {
-      modules.add(emailModule);
-    } else {
-      modules.add(new SmtpEmailSender.Module());
-    }
-    modules.add(new SignedTokenEmailTokenVerifier.Module());
-    modules.add(new PluginRestApiModule());
-    modules.add(new RestCacheAdminModule());
-    modules.add(new GpgModule(config));
-    modules.add(new StartupChecks.Module());
-    if (MoreObjects.firstNonNull(httpd, true)) {
-      modules.add(
-          new CanonicalWebUrlModule() {
-            @Override
-            protected Class<? extends Provider<String>> provider() {
-              return HttpCanonicalWebUrlProvider.class;
-            }
-          });
-    } else {
-      modules.add(
-          new CanonicalWebUrlModule() {
-            @Override
-            protected Class<? extends Provider<String>> provider() {
-              return CanonicalWebUrlProvider.class;
-            }
-          });
-    }
-    if (sshd) {
-      modules.add(SshKeyCacheImpl.module());
-    } else {
-      modules.add(NoSshKeyCache.module());
-    }
-    modules.add(
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            bind(GerritOptions.class)
-                .toInstance(new GerritOptions(config, headless, slave, polyGerritDev));
-            if (inMemoryTest) {
-              bind(String.class)
-                  .annotatedWith(SecureStoreClassName.class)
-                  .toInstance(DefaultSecureStore.class.getName());
-              bind(SecureStore.class).toProvider(SecureStoreProvider.class);
-            }
-          }
-        });
-    modules.add(new GarbageCollectionModule());
-    if (!slave) {
-      modules.add(new AccountDeactivator.Module());
-      modules.add(new ChangeCleanupRunner.Module());
-    }
-    modules.addAll(LibModuleLoader.loadModules(cfgInjector));
-    if (migrateToNoteDb()) {
-      modules.add(new OnlineNoteDbMigrator.Module(trial));
-    }
-    if (testSysModule != null) {
-      modules.add(testSysModule);
-    }
-    return cfgInjector.createChildInjector(modules);
-  }
-
-  private boolean migrateToNoteDb() {
-    return migrateToNoteDb || NoteDbMigrator.getAutoMigrate(checkNotNull(config));
-  }
-
-  private Module createIndexModule() {
-    if (slave) {
-      return new DummyIndexModule();
-    }
-    if (luceneModule != null) {
-      return luceneModule;
-    }
-    boolean onlineUpgrade =
-        VersionManager.getOnlineUpgrade(config)
-            // Schema upgrade is handled by OnlineNoteDbMigrator in this case.
-            && !migrateToNoteDb();
-    switch (indexType) {
-      case LUCENE:
-        return onlineUpgrade
-            ? LuceneIndexModule.latestVersionWithOnlineUpgrade()
-            : LuceneIndexModule.latestVersionWithoutOnlineUpgrade();
-      case ELASTICSEARCH:
-        return onlineUpgrade
-            ? ElasticIndexModule.latestVersionWithOnlineUpgrade()
-            : ElasticIndexModule.latestVersionWithoutOnlineUpgrade();
-      default:
-        throw new IllegalStateException("unsupported index.type = " + indexType);
-    }
-  }
-
-  private void initIndexType() {
-    indexType = IndexModule.getIndexType(cfgInjector);
-    switch (indexType) {
-      case LUCENE:
-      case ELASTICSEARCH:
-        break;
-      default:
-        throw new IllegalStateException("unsupported index.type = " + indexType);
-    }
-  }
-
-  private void initSshd() {
-    sshInjector = createSshInjector();
-    sysInjector.getInstance(PluginGuiceEnvironment.class).setSshInjector(sshInjector);
-    manager.add(sshInjector);
-  }
-
-  private Injector createSshInjector() {
-    final List<Module> modules = new ArrayList<>();
-    modules.add(sysInjector.getInstance(SshModule.class));
-    if (!inMemoryTest) {
-      modules.add(new SshHostKeyModule());
-    }
-    modules.add(
-        new DefaultCommandModule(
-            slave,
-            sysInjector.getInstance(DownloadConfig.class),
-            sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
-    if (!slave && indexType == IndexType.LUCENE) {
-      modules.add(new IndexCommandsModule());
-    }
-    return sysInjector.createChildInjector(modules);
-  }
-
-  private void initHttpd() {
-    webInjector = createWebInjector();
-
-    sysInjector.getInstance(PluginGuiceEnvironment.class).setHttpInjector(webInjector);
-
-    sysInjector
-        .getInstance(HttpCanonicalWebUrlProvider.class)
-        .setHttpServletRequest(webInjector.getProvider(HttpServletRequest.class));
-
-    httpdInjector = createHttpdInjector();
-    manager.add(webInjector, httpdInjector);
-  }
-
-  private Injector createWebInjector() {
-    final List<Module> modules = new ArrayList<>();
-    if (sshd) {
-      modules.add(new ProjectQoSFilter.Module());
-    }
-    modules.add(RequestContextFilter.module());
-    modules.add(AllRequestFilter.module());
-    modules.add(RequestMetricsFilter.module());
-    modules.add(H2CacheBasedWebSession.module());
-    modules.add(sysInjector.getInstance(GitOverHttpModule.class));
-    modules.add(sysInjector.getInstance(WebModule.class));
-    modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
-    modules.add(new HttpPluginModule());
-    if (sshd) {
-      modules.add(sshInjector.getInstance(WebSshGlueModule.class));
-    } else {
-      modules.add(new NoSshModule());
-    }
-
-    AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
-    if (authConfig.getAuthType() == AuthType.OPENID
-        || authConfig.getAuthType() == AuthType.OPENID_SSO) {
-      modules.add(new OpenIdModule());
-    } else if (authConfig.getAuthType() == AuthType.OAUTH) {
-      modules.add(new OAuthModule());
-    }
-    modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
-
-    // StaticModule contains a "/*" wildcard, place it last.
-    modules.add(sysInjector.getInstance(StaticModule.class));
-
-    return sysInjector.createChildInjector(modules);
-  }
-
-  private Injector createHttpdInjector() {
-    final List<Module> modules = new ArrayList<>();
-    modules.add(new JettyModule(new JettyEnv(webInjector)));
-    return webInjector.createChildInjector(modules);
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
deleted file mode 100644
index 385f198..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ /dev/null
@@ -1,109 +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.pgm;
-
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
-import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
-
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.account.externalids.ExternalIdsBatchUpdate;
-import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
-import com.google.gerrit.server.schema.SchemaVersionCheck;
-import com.google.inject.AbstractModule;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import java.util.Collection;
-import java.util.Locale;
-import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.TextProgressMonitor;
-
-/** Converts the local username for all accounts to lower case */
-public class LocalUsernamesToLowerCase extends SiteProgram {
-  private final LifecycleManager manager = new LifecycleManager();
-  private final TextProgressMonitor monitor = new TextProgressMonitor();
-
-  @Inject private ExternalIds externalIds;
-
-  @Inject private ExternalIdsBatchUpdate externalIdsBatchUpdate;
-
-  @Override
-  public int run() throws Exception {
-    Injector dbInjector = createDbInjector(MULTI_USER);
-    manager.add(dbInjector, dbInjector.createChildInjector(SchemaVersionCheck.module()));
-    manager.start();
-    dbInjector
-        .createChildInjector(
-            new AbstractModule() {
-              @Override
-              protected void configure() {
-                // The LocalUsernamesToLowerCase program needs to access all external IDs only
-                // once to update them. After the update they are not accessed again. Hence the
-                // LocalUsernamesToLowerCase program doesn't benefit from caching external IDs and
-                // the external ID cache can be disabled.
-                install(DisabledExternalIdCache.module());
-              }
-            })
-        .injectMembers(this);
-
-    Collection<ExternalId> todo = externalIds.all();
-    monitor.beginTask("Converting local usernames", todo.size());
-
-    for (ExternalId extId : todo) {
-      convertLocalUserToLowerCase(extId);
-      monitor.update(1);
-    }
-
-    externalIdsBatchUpdate.commit("Convert local usernames to lower case");
-    monitor.endTask();
-
-    int exitCode = reindexAccounts();
-    manager.stop();
-    return exitCode;
-  }
-
-  private void convertLocalUserToLowerCase(ExternalId extId) {
-    if (extId.isScheme(SCHEME_GERRIT)) {
-      String localUser = extId.key().id();
-      String localUserLowerCase = localUser.toLowerCase(Locale.US);
-      if (!localUser.equals(localUserLowerCase)) {
-        ExternalId extIdLowerCase =
-            ExternalId.create(
-                SCHEME_GERRIT,
-                localUserLowerCase,
-                extId.accountId(),
-                extId.email(),
-                extId.password());
-        externalIdsBatchUpdate.replace(extId, extIdLowerCase);
-      }
-    }
-  }
-
-  private int reindexAccounts() throws Exception {
-    monitor.beginTask("Reindex accounts", ProgressMonitor.UNKNOWN);
-    String[] reindexArgs = {
-      "--site-path", getSitePath().toString(), "--index", AccountSchemaDefinitions.NAME
-    };
-    System.out.println("Migration complete, reindexing accounts with:");
-    System.out.println("  reindex " + String.join(" ", reindexArgs));
-    Reindex reindexPgm = new Reindex();
-    int exitCode = reindexPgm.main(reindexArgs);
-    monitor.endTask();
-    return exitCode;
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateToNoteDb.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateToNoteDb.java
deleted file mode 100644
index c5444528..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateToNoteDb.java
+++ /dev/null
@@ -1,204 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.elasticsearch.ElasticIndexModule;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.lucene.LuceneIndexModule;
-import com.google.gerrit.pgm.util.BatchProgramModule;
-import com.google.gerrit.pgm.util.RuntimeShutdown;
-import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.pgm.util.ThreadLimiter;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
-import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Module;
-import com.google.inject.Provider;
-import java.util.ArrayList;
-import java.util.List;
-import org.kohsuke.args4j.Option;
-import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler;
-
-public class MigrateToNoteDb extends SiteProgram {
-  static final String TRIAL_USAGE =
-      "Trial mode: migrate changes and turn on reading from NoteDb, but leave ReviewDb as the"
-          + " source of truth";
-
-  @Option(name = "--threads", usage = "Number of threads to use for rebuilding NoteDb")
-  private int threads = Runtime.getRuntime().availableProcessors();
-
-  @Option(
-    name = "--project",
-    usage =
-        "Only rebuild these projects, do no other migration; incompatible with --change;"
-            + " recommended for debugging only"
-  )
-  private List<String> projects = new ArrayList<>();
-
-  @Option(
-    name = "--change",
-    usage =
-        "Only rebuild these changes, do no other migration; incompatible with --project;"
-            + " recommended for debugging only"
-  )
-  private List<Integer> changes = new ArrayList<>();
-
-  @Option(
-    name = "--force",
-    usage =
-        "Force rebuilding changes where ReviewDb is still the source of truth, even if they"
-            + " were previously migrated"
-  )
-  private boolean force;
-
-  @Option(name = "--trial", usage = TRIAL_USAGE)
-  private boolean trial;
-
-  @Option(
-    name = "--sequence-gap",
-    usage =
-        "gap in change sequence numbers between last ReviewDb number and first NoteDb number;"
-            + " negative indicates using the value of noteDb.changes.initialSequenceGap (default"
-            + " 1000)"
-  )
-  private int sequenceGap;
-
-  @Option(
-    name = "--reindex",
-    usage = "Reindex all changes after migration; defaults to false in trial mode, true otherwise",
-    handler = ExplicitBooleanOptionHandler.class
-  )
-  private Boolean reindex;
-
-  private Injector dbInjector;
-  private Injector sysInjector;
-  private LifecycleManager dbManager;
-  private LifecycleManager sysManager;
-
-  @Inject private Provider<NoteDbMigrator.Builder> migratorBuilderProvider;
-
-  @Override
-  public int run() throws Exception {
-    RuntimeShutdown.add(this::stop);
-    try {
-      mustHaveValidSite();
-      dbInjector = createDbInjector(MULTI_USER);
-      threads = ThreadLimiter.limitThreads(dbInjector, threads);
-
-      dbManager = new LifecycleManager();
-      dbManager.add(dbInjector);
-      dbManager.start();
-
-      sysInjector = createSysInjector();
-      sysInjector.injectMembers(this);
-      sysManager = new LifecycleManager();
-      sysManager.add(sysInjector);
-      sysManager.start();
-
-      try (NoteDbMigrator migrator =
-          migratorBuilderProvider
-              .get()
-              .setThreads(threads)
-              .setProgressOut(System.err)
-              .setProjects(projects.stream().map(Project.NameKey::new).collect(toList()))
-              .setChanges(changes.stream().map(Change.Id::new).collect(toList()))
-              .setTrialMode(trial)
-              .setForceRebuild(force)
-              .setSequenceGap(sequenceGap)
-              .build()) {
-        if (!projects.isEmpty() || !changes.isEmpty()) {
-          migrator.rebuild();
-        } else {
-          migrator.migrate();
-        }
-      }
-    } finally {
-      stop();
-    }
-
-    boolean reindex = firstNonNull(this.reindex, !trial);
-    if (!reindex) {
-      return 0;
-    }
-    // Reindex all indices, to save the user from having to run yet another program by hand while
-    // their server is offline.
-    List<String> reindexArgs =
-        ImmutableList.of(
-            "--site-path",
-            getSitePath().toString(),
-            "--threads",
-            Integer.toString(threads),
-            "--index",
-            ChangeSchemaDefinitions.NAME);
-    System.out.println("Migration complete, reindexing changes with:");
-    System.out.println("  reindex " + reindexArgs.stream().collect(joining(" ")));
-    Reindex reindexPgm = new Reindex();
-    return reindexPgm.main(reindexArgs.stream().toArray(String[]::new));
-  }
-
-  private Injector createSysInjector() {
-    return dbInjector.createChildInjector(
-        new FactoryModule() {
-          @Override
-          public void configure() {
-            install(dbInjector.getInstance(BatchProgramModule.class));
-            bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
-            install(getIndexModule());
-            factory(ChangeResource.Factory.class);
-          }
-        });
-  }
-
-  private Module getIndexModule() {
-    switch (IndexModule.getIndexType(dbInjector)) {
-      case LUCENE:
-        return LuceneIndexModule.singleVersionWithExplicitVersions(ImmutableMap.of(), threads);
-      case ELASTICSEARCH:
-        return ElasticIndexModule.singleVersionWithExplicitVersions(ImmutableMap.of(), threads);
-      default:
-        throw new IllegalStateException("unsupported index.type");
-    }
-  }
-
-  private void stop() {
-    try {
-      LifecycleManager m = sysManager;
-      sysManager = null;
-      if (m != null) {
-        m.stop();
-      }
-    } finally {
-      LifecycleManager m = dbManager;
-      dbManager = null;
-      if (m != null) {
-        m.stop();
-      }
-    }
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
deleted file mode 100644
index 35beaeb..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
+++ /dev/null
@@ -1,245 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.http.jetty;
-
-import static com.google.gerrit.server.config.ConfigUtil.getTimeUnit;
-import static com.google.inject.Scopes.SINGLETON;
-import static java.util.concurrent.TimeUnit.MINUTES;
-import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
-
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountLimits;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.QueueProvider;
-import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
-import com.google.gerrit.sshd.CommandExecutorQueueProvider;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.servlet.ServletModule;
-import java.io.IOException;
-import java.util.concurrent.Future;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import javax.servlet.Filter;
-import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
-import javax.servlet.ServletContext;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jetty.continuation.Continuation;
-import org.eclipse.jetty.continuation.ContinuationListener;
-import org.eclipse.jetty.continuation.ContinuationSupport;
-import org.eclipse.jgit.lib.Config;
-
-/**
- * Use Jetty continuations to defer execution until threads are available.
- *
- * <p>We actually schedule a task into the same execution queue as the SSH daemon uses for command
- * execution, and then park the web request in a continuation until an execution thread is
- * available. This ensures that the overall JVM process doesn't exceed the configured limit on
- * concurrent Git requests.
- *
- * <p>During Git request execution however we have to use the Jetty service thread, not the thread
- * from the SSH execution queue. Trying to complete the request on the SSH execution queue caused
- * Jetty's HTTP parser to crash, so we instead block the SSH execution queue thread and ask Jetty to
- * resume processing on the web service thread.
- */
-@Singleton
-public class ProjectQoSFilter implements Filter {
-  private static final String ATT_SPACE = ProjectQoSFilter.class.getName();
-  private static final String TASK = ATT_SPACE + "/TASK";
-  private static final String CANCEL = ATT_SPACE + "/CANCEL";
-
-  private static final String FILTER_RE = "^/(.*)/(git-upload-pack|git-receive-pack)$";
-  private static final Pattern URI_PATTERN = Pattern.compile(FILTER_RE);
-
-  public static class Module extends ServletModule {
-    @Override
-    protected void configureServlets() {
-      bind(QueueProvider.class).to(CommandExecutorQueueProvider.class).in(SINGLETON);
-      filterRegex(FILTER_RE).through(ProjectQoSFilter.class);
-    }
-  }
-
-  private final AccountLimits.Factory limitsFactory;
-  private final Provider<CurrentUser> user;
-  private final QueueProvider queue;
-  private final ServletContext context;
-  private final long maxWait;
-
-  @Inject
-  ProjectQoSFilter(
-      AccountLimits.Factory limitsFactory,
-      Provider<CurrentUser> user,
-      QueueProvider queue,
-      ServletContext context,
-      @GerritServerConfig Config cfg) {
-    this.limitsFactory = limitsFactory;
-    this.user = user;
-    this.queue = queue;
-    this.context = context;
-    this.maxWait = MINUTES.toMillis(getTimeUnit(cfg, "httpd", null, "maxwait", 5, MINUTES));
-  }
-
-  @Override
-  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
-      throws IOException, ServletException {
-    final HttpServletRequest req = (HttpServletRequest) request;
-    final HttpServletResponse rsp = (HttpServletResponse) response;
-    final Continuation cont = ContinuationSupport.getContinuation(req);
-
-    if (cont.isInitial()) {
-      TaskThunk task = new TaskThunk(cont, req);
-      if (maxWait > 0) {
-        cont.setTimeout(maxWait);
-      }
-      cont.suspend(rsp);
-      cont.setAttribute(TASK, task);
-
-      Future<?> f = getExecutor().submit(task);
-      cont.addContinuationListener(new Listener(f));
-    } else if (cont.isExpired()) {
-      rsp.sendError(SC_SERVICE_UNAVAILABLE);
-
-    } else if (cont.isResumed() && cont.getAttribute(CANCEL) == Boolean.TRUE) {
-      rsp.sendError(SC_SERVICE_UNAVAILABLE);
-
-    } else if (cont.isResumed()) {
-      TaskThunk task = (TaskThunk) cont.getAttribute(TASK);
-      try {
-        task.begin(Thread.currentThread());
-        chain.doFilter(req, rsp);
-      } finally {
-        task.end();
-        Thread.interrupted();
-      }
-
-    } else {
-      context.log("Unexpected QoS continuation state, aborting request");
-      rsp.sendError(SC_SERVICE_UNAVAILABLE);
-    }
-  }
-
-  private ScheduledThreadPoolExecutor getExecutor() {
-    QueueProvider.QueueType qt = limitsFactory.create(user.get()).getQueueType();
-    return queue.getQueue(qt);
-  }
-
-  @Override
-  public void init(FilterConfig config) {}
-
-  @Override
-  public void destroy() {}
-
-  private final class Listener implements ContinuationListener {
-    final Future<?> future;
-
-    Listener(Future<?> future) {
-      this.future = future;
-    }
-
-    @Override
-    public void onComplete(Continuation self) {}
-
-    @Override
-    public void onTimeout(Continuation self) {
-      future.cancel(true);
-    }
-  }
-
-  private final class TaskThunk implements CancelableRunnable {
-    private final Continuation cont;
-    private final String name;
-    private final Object lock = new Object();
-    private boolean done;
-    private Thread worker;
-
-    TaskThunk(Continuation cont, HttpServletRequest req) {
-      this.cont = cont;
-      this.name = generateName(req);
-    }
-
-    @Override
-    public void run() {
-      cont.resume();
-
-      synchronized (lock) {
-        while (!done) {
-          try {
-            lock.wait();
-          } catch (InterruptedException e) {
-            if (worker != null) {
-              worker.interrupt();
-            } else {
-              break;
-            }
-          }
-        }
-      }
-    }
-
-    void begin(Thread thread) {
-      synchronized (lock) {
-        worker = thread;
-      }
-    }
-
-    void end() {
-      synchronized (lock) {
-        worker = null;
-        done = true;
-        lock.notifyAll();
-      }
-    }
-
-    @Override
-    public void cancel() {
-      cont.setAttribute(CANCEL, Boolean.TRUE);
-      cont.resume();
-    }
-
-    @Override
-    public String toString() {
-      return name;
-    }
-
-    private String generateName(HttpServletRequest req) {
-      String userName = "";
-
-      CurrentUser who = user.get();
-      if (who.isIdentifiedUser()) {
-        String name = who.asIdentifiedUser().getUserName();
-        if (name != null && !name.isEmpty()) {
-          userName = " (" + name + ")";
-        }
-      }
-
-      String uri = req.getServletPath();
-      Matcher m = URI_PATTERN.matcher(uri);
-      if (m.matches()) {
-        String path = m.group(1);
-        String cmd = m.group(2);
-        return cmd + " " + path + userName;
-      }
-
-      return req.getMethod() + " " + uri + userName;
-    }
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AccountsOnInit.java
deleted file mode 100644
index 2beb50a..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ /dev/null
@@ -1,130 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
-import com.google.gerrit.pgm.init.api.InitFlags;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.GerritPersonIdentProvider;
-import com.google.gerrit.server.account.AccountConfig;
-import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Path;
-import org.eclipse.jgit.dircache.DirCache;
-import org.eclipse.jgit.dircache.DirCacheEditor;
-import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
-import org.eclipse.jgit.dircache.DirCacheEntry;
-import org.eclipse.jgit.internal.storage.file.FileRepository;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.FileMode;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.RepositoryCache.FileKey;
-import org.eclipse.jgit.util.FS;
-
-public class AccountsOnInit {
-  private final InitFlags flags;
-  private final SitePaths site;
-  private final String allUsers;
-
-  @Inject
-  public AccountsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
-    this.flags = flags;
-    this.site = site;
-    this.allUsers = allUsers.get();
-  }
-
-  public void insert(Account account) throws IOException {
-    File path = getPath();
-    if (path != null) {
-      try (Repository repo = new FileRepository(path);
-          ObjectInserter oi = repo.newObjectInserter()) {
-        PersonIdent ident =
-            new PersonIdent(
-                new GerritPersonIdentProvider(flags.cfg).get(), account.getRegisteredOn());
-
-        Config accountConfig = new Config();
-        AccountConfig.writeToConfig(account, accountConfig);
-
-        DirCache newTree = DirCache.newInCore();
-        DirCacheEditor editor = newTree.editor();
-        final ObjectId blobId =
-            oi.insert(Constants.OBJ_BLOB, accountConfig.toText().getBytes(UTF_8));
-        editor.add(
-            new PathEdit(AccountConfig.ACCOUNT_CONFIG) {
-              @Override
-              public void apply(DirCacheEntry ent) {
-                ent.setFileMode(FileMode.REGULAR_FILE);
-                ent.setObjectId(blobId);
-              }
-            });
-        editor.finish();
-
-        ObjectId treeId = newTree.writeTree(oi);
-
-        CommitBuilder cb = new CommitBuilder();
-        cb.setTreeId(treeId);
-        cb.setCommitter(ident);
-        cb.setAuthor(ident);
-        cb.setMessage("Create Account");
-        ObjectId id = oi.insert(cb);
-        oi.flush();
-
-        String refName = RefNames.refsUsers(account.getId());
-        RefUpdate ru = repo.updateRef(refName);
-        ru.setExpectedOldObjectId(ObjectId.zeroId());
-        ru.setNewObjectId(id);
-        ru.setRefLogIdent(ident);
-        ru.setRefLogMessage("Create Account", false);
-        Result result = ru.update();
-        if (result != Result.NEW) {
-          throw new IOException(
-              String.format("Failed to update ref %s: %s", refName, result.name()));
-        }
-        account.setMetaId(id.name());
-      }
-    }
-  }
-
-  public boolean hasAnyAccount() throws IOException {
-    File path = getPath();
-    if (path == null) {
-      return false;
-    }
-
-    try (Repository repo = new FileRepository(path)) {
-      return Accounts.hasAnyAccount(repo);
-    }
-  }
-
-  private File getPath() {
-    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
-    checkArgument(basePath != null, "gerrit.basePath must be configured");
-    return FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
deleted file mode 100644
index ab491f7c..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ /dev/null
@@ -1,94 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
-import com.google.gerrit.pgm.init.api.InitFlags;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.GerritPersonIdentProvider;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdReader;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.Collection;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.file.FileRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.RepositoryCache.FileKey;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.FS;
-
-public class ExternalIdsOnInit {
-  private final InitFlags flags;
-  private final SitePaths site;
-  private final String allUsers;
-
-  @Inject
-  public ExternalIdsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
-    this.flags = flags;
-    this.site = site;
-    this.allUsers = allUsers.get();
-  }
-
-  public synchronized void insert(String commitMessage, Collection<ExternalId> extIds)
-      throws OrmException, IOException, ConfigInvalidException {
-
-    File path = getPath();
-    if (path != null) {
-      try (Repository repo = new FileRepository(path);
-          RevWalk rw = new RevWalk(repo);
-          ObjectInserter ins = repo.newObjectInserter()) {
-        ObjectId rev = ExternalIdReader.readRevision(repo);
-
-        NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-        for (ExternalId extId : extIds) {
-          ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
-        }
-
-        PersonIdent serverIdent = new GerritPersonIdentProvider(flags.cfg).get();
-        ExternalIdsUpdate.commit(
-            new Project.NameKey(allUsers),
-            repo,
-            rw,
-            ins,
-            rev,
-            noteMap,
-            commitMessage,
-            serverIdent,
-            serverIdent,
-            null,
-            GitReferenceUpdated.DISABLED);
-      }
-    }
-  }
-
-  private File getPath() {
-    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
-    if (basePath == null) {
-      throw new IllegalStateException("gerrit.basePath must be configured");
-    }
-    return FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/GroupsOnInit.java
deleted file mode 100644
index 4923fab..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ /dev/null
@@ -1,109 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gerrit.reviewdb.client.AccountGroupName;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
-import java.util.List;
-
-/**
- * A database accessor for calls related to groups.
- *
- * <p>All calls which read or write group related details to the database <strong>during
- * init</strong> (either ReviewDb or NoteDb) are gathered here. For non-init cases, use {@code
- * Groups} or {@code GroupsUpdate} instead.
- *
- * <p>All methods of this class refer to <em>internal</em> groups.
- */
-public class GroupsOnInit {
-
-  /**
-   * Returns the {@code AccountGroup} for the specified name.
-   *
-   * @param db the {@code ReviewDb} instance to use for lookups
-   * @param groupName the name of the group
-   * @return the {@code AccountGroup} which has the specified name
-   * @throws OrmException if the group couldn't be retrieved from ReviewDb
-   * @throws NoSuchGroupException if a group with such a name doesn't exist
-   */
-  public AccountGroup getExistingGroup(ReviewDb db, AccountGroup.NameKey groupName)
-      throws OrmException, NoSuchGroupException {
-    AccountGroupName accountGroupName = db.accountGroupNames().get(groupName);
-    if (accountGroupName == null) {
-      throw new NoSuchGroupException(groupName.toString());
-    }
-
-    AccountGroup.Id groupId = accountGroupName.getId();
-    AccountGroup group = db.accountGroups().get(groupId);
-    if (group == null) {
-      throw new NoSuchGroupException(groupName.toString());
-    }
-    return group;
-  }
-
-  /**
-   * Adds an account as member to a group. The account is only added as a new member if it isn't
-   * already a member of the group.
-   *
-   * <p><strong>Note</strong>: This method doesn't check whether the account exists! It also doesn't
-   * update the account index!
-   *
-   * @param db the {@code ReviewDb} instance to update
-   * @param groupUuid the UUID of the group
-   * @param accountId the ID of the account to add
-   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
-   * @throws NoSuchGroupException if the specified group doesn't exist
-   */
-  public void addGroupMember(ReviewDb db, AccountGroup.UUID groupUuid, Account.Id accountId)
-      throws OrmException, NoSuchGroupException {
-    AccountGroup group = getExistingGroup(db, groupUuid);
-    AccountGroup.Id groupId = group.getId();
-
-    if (isMember(db, groupId, accountId)) {
-      return;
-    }
-
-    db.accountGroupMembers()
-        .insert(
-            ImmutableList.of(
-                new AccountGroupMember(new AccountGroupMember.Key(accountId, groupId))));
-  }
-
-  private static AccountGroup getExistingGroup(ReviewDb db, AccountGroup.UUID groupUuid)
-      throws OrmException, NoSuchGroupException {
-    List<AccountGroup> accountGroups = db.accountGroups().byUUID(groupUuid).toList();
-    if (accountGroups.size() == 1) {
-      return Iterables.getOnlyElement(accountGroups);
-    } else if (accountGroups.isEmpty()) {
-      throw new NoSuchGroupException(groupUuid);
-    } else {
-      throw new OrmDuplicateKeyException("Duplicate group UUID " + groupUuid);
-    }
-  }
-
-  private static boolean isMember(ReviewDb db, AccountGroup.Id groupId, Account.Id accountId)
-      throws OrmException {
-    AccountGroupMember.Key key = new AccountGroupMember.Key(accountId, groupId);
-    return db.accountGroupMembers().get(key) != null;
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
deleted file mode 100644
index 9a81c52..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ /dev/null
@@ -1,203 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
-import com.google.gerrit.pgm.init.api.ConsoleUI;
-import com.google.gerrit.pgm.init.api.InitFlags;
-import com.google.gerrit.pgm.init.api.InitStep;
-import com.google.gerrit.pgm.init.api.SequencesOnInit;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gerrit.server.index.account.AccountIndexCollection;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.gerrit.server.index.group.GroupIndexCollection;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import org.apache.commons.validator.routines.EmailValidator;
-
-public class InitAdminUser implements InitStep {
-  private final InitFlags flags;
-  private final ConsoleUI ui;
-  private final AllUsersNameOnInitProvider allUsers;
-  private final AccountsOnInit accounts;
-  private final VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory;
-  private final ExternalIdsOnInit externalIds;
-  private final SequencesOnInit sequencesOnInit;
-  private final GroupsOnInit groupsOnInit;
-  private SchemaFactory<ReviewDb> dbFactory;
-  private AccountIndexCollection accountIndexCollection;
-  private GroupIndexCollection groupIndexCollection;
-
-  @Inject
-  InitAdminUser(
-      InitFlags flags,
-      ConsoleUI ui,
-      AllUsersNameOnInitProvider allUsers,
-      AccountsOnInit accounts,
-      VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory,
-      ExternalIdsOnInit externalIds,
-      SequencesOnInit sequencesOnInit,
-      GroupsOnInit groupsOnInit) {
-    this.flags = flags;
-    this.ui = ui;
-    this.allUsers = allUsers;
-    this.accounts = accounts;
-    this.authorizedKeysFactory = authorizedKeysFactory;
-    this.externalIds = externalIds;
-    this.sequencesOnInit = sequencesOnInit;
-    this.groupsOnInit = groupsOnInit;
-  }
-
-  @Override
-  public void run() {}
-
-  @Inject(optional = true)
-  void set(SchemaFactory<ReviewDb> dbFactory) {
-    this.dbFactory = dbFactory;
-  }
-
-  @Inject(optional = true)
-  void set(AccountIndexCollection accountIndexCollection) {
-    this.accountIndexCollection = accountIndexCollection;
-  }
-
-  @Inject(optional = true)
-  void set(GroupIndexCollection groupIndexCollection) {
-    this.groupIndexCollection = groupIndexCollection;
-  }
-
-  @Override
-  public void postRun() throws Exception {
-    AuthType authType = flags.cfg.getEnum(AuthType.values(), "auth", null, "type", null);
-    if (authType != AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT) {
-      return;
-    }
-
-    try (ReviewDb db = dbFactory.open()) {
-      if (!accounts.hasAnyAccount()) {
-        ui.header("Gerrit Administrator");
-        if (ui.yesno(true, "Create administrator user")) {
-          Account.Id id = new Account.Id(sequencesOnInit.nextAccountId(db));
-          String username = ui.readString("admin", "username");
-          String name = ui.readString("Administrator", "name");
-          String httpPassword = ui.readString("secret", "HTTP password");
-          AccountSshKey sshKey = readSshKey(id);
-          String email = readEmail(sshKey);
-
-          List<ExternalId> extIds = new ArrayList<>(2);
-          extIds.add(ExternalId.createUsername(username, id, httpPassword));
-
-          if (email != null) {
-            extIds.add(ExternalId.createEmail(id, email));
-          }
-          externalIds.insert("Add external IDs for initial admin user", extIds);
-
-          Account a = new Account(id, TimeUtil.nowTs());
-          a.setFullName(name);
-          a.setPreferredEmail(email);
-          accounts.insert(a);
-
-          AccountGroup adminGroup =
-              groupsOnInit.getExistingGroup(db, new AccountGroup.NameKey("Administrators"));
-          groupsOnInit.addGroupMember(db, adminGroup.getGroupUUID(), id);
-
-          if (sshKey != null) {
-            VersionedAuthorizedKeysOnInit authorizedKeys = authorizedKeysFactory.create(id).load();
-            authorizedKeys.addKey(sshKey.getSshPublicKey());
-            authorizedKeys.save("Add SSH key for initial admin user\n");
-          }
-
-          AccountState as =
-              new AccountState(
-                  new AllUsersName(allUsers.get()),
-                  a,
-                  Collections.singleton(adminGroup.getGroupUUID()),
-                  extIds,
-                  new HashMap<>());
-          for (AccountIndex accountIndex : accountIndexCollection.getWriteIndexes()) {
-            accountIndex.replace(as);
-          }
-
-          InternalGroup adminInternalGroup =
-              InternalGroup.create(adminGroup, ImmutableSet.of(id), ImmutableSet.of());
-          for (GroupIndex groupIndex : groupIndexCollection.getWriteIndexes()) {
-            groupIndex.replace(adminInternalGroup);
-          }
-        }
-      }
-    }
-  }
-
-  private String readEmail(AccountSshKey sshKey) {
-    String defaultEmail = "admin@example.com";
-    if (sshKey != null && sshKey.getComment() != null) {
-      String c = sshKey.getComment().trim();
-      if (EmailValidator.getInstance().isValid(c)) {
-        defaultEmail = c;
-      }
-    }
-    return readEmail(defaultEmail);
-  }
-
-  private String readEmail(String defaultEmail) {
-    String email = ui.readString(defaultEmail, "email");
-    if (email != null && !EmailValidator.getInstance().isValid(email)) {
-      ui.message("error: invalid email address\n");
-      return readEmail(defaultEmail);
-    }
-    return email;
-  }
-
-  private AccountSshKey readSshKey(Account.Id id) throws IOException {
-    String defaultPublicSshKeyFile = "";
-    Path defaultPublicSshKeyPath = Paths.get(System.getProperty("user.home"), ".ssh", "id_rsa.pub");
-    if (Files.exists(defaultPublicSshKeyPath)) {
-      defaultPublicSshKeyFile = defaultPublicSshKeyPath.toString();
-    }
-    String publicSshKeyFile = ui.readString(defaultPublicSshKeyFile, "public SSH key file");
-    return !Strings.isNullOrEmpty(publicSshKeyFile) ? createSshKey(id, publicSshKeyFile) : null;
-  }
-
-  private AccountSshKey createSshKey(Account.Id id, String keyFile) throws IOException {
-    Path p = Paths.get(keyFile);
-    if (!Files.exists(p)) {
-      throw new IOException(String.format("Cannot add public SSH key: %s is not a file", keyFile));
-    }
-    String content = new String(Files.readAllBytes(p), UTF_8);
-    return new AccountSshKey(new AccountSshKey.Id(id, 1), content);
-  }
-}
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
deleted file mode 100644
index 60fd60f..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitLabels.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import com.google.gerrit.pgm.init.api.AllProjectsConfig;
-import com.google.gerrit.pgm.init.api.ConsoleUI;
-import com.google.gerrit.pgm.init.api.InitStep;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Arrays;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class InitLabels implements InitStep {
-  private static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
-  private static final String KEY_LABEL = "label";
-  private static final String KEY_FUNCTION = "function";
-  private static final String KEY_VALUE = "value";
-  private static final String LABEL_VERIFIED = "Verified";
-
-  private final ConsoleUI ui;
-  private final AllProjectsConfig allProjectsConfig;
-
-  private boolean installVerified;
-
-  @Inject
-  InitLabels(ConsoleUI ui, AllProjectsConfig allProjectsConfig) {
-    this.ui = ui;
-    this.allProjectsConfig = allProjectsConfig;
-  }
-
-  @Override
-  public void run() throws Exception {
-    Config cfg = allProjectsConfig.load().getConfig();
-    if (cfg == null || !cfg.getSubsections(KEY_LABEL).contains(LABEL_VERIFIED)) {
-      ui.header("Review Labels");
-      installVerified = ui.yesno(false, "Install Verified label");
-    }
-  }
-
-  @Override
-  public void postRun() throws Exception {
-    Config cfg = allProjectsConfig.load().getConfig();
-    if (installVerified) {
-      cfg.setString(KEY_LABEL, LABEL_VERIFIED, KEY_FUNCTION, "MaxWithBlock");
-      cfg.setStringList(
-          KEY_LABEL,
-          LABEL_VERIFIED,
-          KEY_VALUE,
-          Arrays.asList(new String[] {"-1 Fails", " 0 No score", "+1 Verified"}));
-      cfg.setBoolean(KEY_LABEL, LABEL_VERIFIED, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, true);
-      allProjectsConfig.save("Configure 'Verified' label");
-    }
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java
deleted file mode 100644
index 0cad722..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java
+++ /dev/null
@@ -1,251 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import static com.google.gerrit.pgm.init.api.InitUtil.hostname;
-import static java.nio.file.Files.exists;
-
-import com.google.gerrit.pgm.init.api.ConsoleUI;
-import com.google.gerrit.pgm.init.api.InitStep;
-import com.google.gerrit.pgm.init.api.Section;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.util.HostPlatform;
-import com.google.gerrit.server.util.SocketUtil;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.lang.ProcessBuilder.Redirect;
-import java.net.InetSocketAddress;
-
-/** Initialize the {@code sshd} configuration section. */
-@Singleton
-class InitSshd implements InitStep {
-  private final ConsoleUI ui;
-  private final SitePaths site;
-  private final Section sshd;
-  private final StaleLibraryRemover remover;
-
-  @Inject
-  InitSshd(ConsoleUI ui, SitePaths site, Section.Factory sections, StaleLibraryRemover remover) {
-    this.ui = ui;
-    this.site = site;
-    this.sshd = sections.get("sshd", null);
-    this.remover = remover;
-  }
-
-  @Override
-  public void run() throws Exception {
-    ui.header("SSH Daemon");
-
-    String hostname = "*";
-    int port = 29418;
-    String listenAddress = sshd.get("listenAddress");
-    if (isOff(listenAddress)) {
-      hostname = "off";
-    } else if (listenAddress != null && !listenAddress.isEmpty()) {
-      final InetSocketAddress addr = SocketUtil.parse(listenAddress, port);
-      hostname = SocketUtil.hostname(addr);
-      port = addr.getPort();
-    }
-
-    hostname = ui.readString(hostname, "Listen on address");
-    if (isOff(hostname)) {
-      sshd.set("listenAddress", "off");
-      return;
-    }
-
-    port = ui.readInt(port, "Listen on port");
-    sshd.set("listenAddress", SocketUtil.format(hostname, port));
-
-    generateSshHostKeys();
-    remover.remove("bc(pg|pkix|prov)-.*[.]jar");
-  }
-
-  private static boolean isOff(String listenHostname) {
-    return "off".equalsIgnoreCase(listenHostname)
-        || "none".equalsIgnoreCase(listenHostname)
-        || "no".equalsIgnoreCase(listenHostname);
-  }
-
-  private void generateSshHostKeys() throws InterruptedException, IOException {
-    if (!exists(site.ssh_key)
-        && (!exists(site.ssh_rsa)
-            || !exists(site.ssh_dsa)
-            || !exists(site.ssh_ed25519)
-            || !exists(site.ssh_ecdsa_256)
-            || !exists(site.ssh_ecdsa_384)
-            || !exists(site.ssh_ecdsa_521))) {
-      System.err.print("Generating SSH host key ...");
-      System.err.flush();
-
-      // Generate the SSH daemon host key using ssh-keygen.
-      //
-      final String comment = "gerrit-code-review@" + hostname();
-
-      // Workaround for JDK-6518827 - zero-length argument ignored on Win32
-      String emptyPassphraseArg = HostPlatform.isWin32() ? "\"\"" : "";
-      if (!exists(site.ssh_rsa)) {
-        System.err.print(" rsa...");
-        System.err.flush();
-        new ProcessBuilder(
-                "ssh-keygen",
-                "-q" /* quiet */,
-                "-t",
-                "rsa",
-                "-P",
-                emptyPassphraseArg,
-                "-C",
-                comment,
-                "-f",
-                site.ssh_rsa.toAbsolutePath().toString())
-            .redirectError(Redirect.INHERIT)
-            .redirectOutput(Redirect.INHERIT)
-            .start()
-            .waitFor();
-      }
-
-      if (!exists(site.ssh_dsa)) {
-        System.err.print(" dsa...");
-        System.err.flush();
-        new ProcessBuilder(
-                "ssh-keygen",
-                "-q" /* quiet */,
-                "-t",
-                "dsa",
-                "-P",
-                emptyPassphraseArg,
-                "-C",
-                comment,
-                "-f",
-                site.ssh_dsa.toAbsolutePath().toString())
-            .redirectError(Redirect.INHERIT)
-            .redirectOutput(Redirect.INHERIT)
-            .start()
-            .waitFor();
-      }
-
-      if (!exists(site.ssh_ed25519)) {
-        System.err.print(" ed25519...");
-        System.err.flush();
-        try {
-          new ProcessBuilder(
-                  "ssh-keygen",
-                  "-q" /* quiet */,
-                  "-t",
-                  "ed25519",
-                  "-P",
-                  emptyPassphraseArg,
-                  "-C",
-                  comment,
-                  "-f",
-                  site.ssh_ed25519.toAbsolutePath().toString())
-              .redirectError(Redirect.INHERIT)
-              .redirectOutput(Redirect.INHERIT)
-              .start()
-              .waitFor();
-        } catch (Exception e) {
-          // continue since older hosts won't be able to generate ed25519 keys.
-          System.err.print(" Failed to generate ed25519 key, continuing...");
-          System.err.flush();
-        }
-      }
-
-      if (!exists(site.ssh_ecdsa_256)) {
-        System.err.print(" ecdsa 256...");
-        System.err.flush();
-        try {
-          new ProcessBuilder(
-                  "ssh-keygen",
-                  "-q" /* quiet */,
-                  "-t",
-                  "ecdsa",
-                  "-b",
-                  "256",
-                  "-P",
-                  emptyPassphraseArg,
-                  "-C",
-                  comment,
-                  "-f",
-                  site.ssh_ecdsa_256.toAbsolutePath().toString())
-              .redirectError(Redirect.INHERIT)
-              .redirectOutput(Redirect.INHERIT)
-              .start()
-              .waitFor();
-        } catch (Exception e) {
-          // continue since older hosts won't be able to generate ecdsa keys.
-          System.err.print(" Failed to generate ecdsa 256 key, continuing...");
-          System.err.flush();
-        }
-      }
-
-      if (!exists(site.ssh_ecdsa_384)) {
-        System.err.print(" ecdsa 384...");
-        System.err.flush();
-        try {
-          new ProcessBuilder(
-                  "ssh-keygen",
-                  "-q" /* quiet */,
-                  "-t",
-                  "ecdsa",
-                  "-b",
-                  "384",
-                  "-P",
-                  emptyPassphraseArg,
-                  "-C",
-                  comment,
-                  "-f",
-                  site.ssh_ecdsa_384.toAbsolutePath().toString())
-              .redirectError(Redirect.INHERIT)
-              .redirectOutput(Redirect.INHERIT)
-              .start()
-              .waitFor();
-        } catch (Exception e) {
-          // continue since older hosts won't be able to generate ecdsa keys.
-          System.err.print(" Failed to generate ecdsa 384 key, continuing...");
-          System.err.flush();
-        }
-      }
-
-      if (!exists(site.ssh_ecdsa_521)) {
-        System.err.print(" ecdsa 521...");
-        System.err.flush();
-        try {
-          new ProcessBuilder(
-                  "ssh-keygen",
-                  "-q" /* quiet */,
-                  "-t",
-                  "ecdsa",
-                  "-b",
-                  "521",
-                  "-P",
-                  emptyPassphraseArg,
-                  "-C",
-                  comment,
-                  "-f",
-                  site.ssh_ecdsa_521.toAbsolutePath().toString())
-              .redirectError(Redirect.INHERIT)
-              .redirectOutput(Redirect.INHERIT)
-              .start()
-              .waitFor();
-        } catch (Exception e) {
-          // continue since older hosts won't be able to generate ecdsa keys.
-          System.err.print(" Failed to generate ecdsa 521 key, continuing...");
-          System.err.flush();
-        }
-      }
-      System.err.println(" done");
-    }
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
deleted file mode 100644
index 32d8dd8..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ /dev/null
@@ -1,176 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.util;
-
-import static com.google.inject.Scopes.SINGLETON;
-
-import com.google.common.cache.Cache;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
-import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.rules.PrologModule;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountCacheImpl;
-import com.google.gerrit.server.account.AccountVisibilityProvider;
-import com.google.gerrit.server.account.CapabilityCollection;
-import com.google.gerrit.server.account.FakeRealm;
-import com.google.gerrit.server.account.GroupCacheImpl;
-import com.google.gerrit.server.account.GroupIncludeCacheImpl;
-import com.google.gerrit.server.account.Realm;
-import com.google.gerrit.server.account.externalids.ExternalIdModule;
-import com.google.gerrit.server.cache.CacheRemovalListener;
-import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
-import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.change.ChangeKindCacheImpl;
-import com.google.gerrit.server.change.MergeabilityCacheImpl;
-import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.change.RebaseChangeOp;
-import com.google.gerrit.server.config.AdministrateServerGroups;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.CanonicalWebUrlProvider;
-import com.google.gerrit.server.config.DisableReverseDnsLookup;
-import com.google.gerrit.server.config.DisableReverseDnsLookupProvider;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.GitReceivePackGroups;
-import com.google.gerrit.server.config.GitUploadPackGroups;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
-import com.google.gerrit.server.git.TagCache;
-import com.google.gerrit.server.git.VisibleRefFilter;
-import com.google.gerrit.server.git.receive.ReceiveCommitsExecutorModule;
-import com.google.gerrit.server.group.GroupModule;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
-import com.google.gerrit.server.notedb.NoteDbModule;
-import com.google.gerrit.server.patch.DiffExecutorModule;
-import com.google.gerrit.server.patch.PatchListCacheImpl;
-import com.google.gerrit.server.project.CommentLinkProvider;
-import com.google.gerrit.server.project.CommitResource;
-import com.google.gerrit.server.project.DefaultPermissionBackendModule;
-import com.google.gerrit.server.project.ProjectCacheImpl;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.SectionSortCache;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryProcessor;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.TypeLiteral;
-import com.google.inject.util.Providers;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.lib.Config;
-
-/**
- * Module for programs that perform batch operations on a site.
- *
- * <p>Any program that requires this module likely also requires using {@link ThreadLimiter} to
- * limit the number of threads accessing the database concurrently.
- */
-public class BatchProgramModule extends FactoryModule {
-  private final Config cfg;
-  private final Module reviewDbModule;
-
-  @Inject
-  BatchProgramModule(@GerritServerConfig Config cfg, PerThreadReviewDbModule reviewDbModule) {
-    this.cfg = cfg;
-    this.reviewDbModule = reviewDbModule;
-  }
-
-  @SuppressWarnings("rawtypes")
-  @Override
-  protected void configure() {
-    install(reviewDbModule);
-    install(new DiffExecutorModule());
-    install(new ReceiveCommitsExecutorModule());
-    install(BatchUpdate.module());
-    install(PatchListCacheImpl.module());
-
-    // Plugins are not loaded and we're just running through each change
-    // once, so don't worry about cache removal.
-    bind(new TypeLiteral<DynamicSet<CacheRemovalListener>>() {})
-        .toInstance(DynamicSet.<CacheRemovalListener>emptySet());
-    bind(new TypeLiteral<DynamicMap<Cache<?, ?>>>() {})
-        .toInstance(DynamicMap.<Cache<?, ?>>emptyMap());
-    bind(new TypeLiteral<List<CommentLinkInfo>>() {})
-        .toProvider(CommentLinkProvider.class)
-        .in(SINGLETON);
-    bind(new TypeLiteral<DynamicMap<ChangeQueryProcessor.ChangeAttributeFactory>>() {})
-        .toInstance(DynamicMap.<ChangeQueryProcessor.ChangeAttributeFactory>emptyMap());
-    bind(new TypeLiteral<DynamicMap<RestView<CommitResource>>>() {})
-        .toInstance(DynamicMap.<RestView<CommitResource>>emptyMap());
-    bind(String.class)
-        .annotatedWith(CanonicalWebUrl.class)
-        .toProvider(CanonicalWebUrlProvider.class);
-    bind(Boolean.class)
-        .annotatedWith(DisableReverseDnsLookup.class)
-        .toProvider(DisableReverseDnsLookupProvider.class)
-        .in(SINGLETON);
-    bind(Realm.class).to(FakeRealm.class);
-    bind(IdentifiedUser.class).toProvider(Providers.<IdentifiedUser>of(null));
-    bind(ReplacePatchSetSender.Factory.class)
-        .toProvider(Providers.<ReplacePatchSetSender.Factory>of(null));
-    bind(CurrentUser.class).to(IdentifiedUser.class);
-    factory(MergeUtil.Factory.class);
-    factory(PatchSetInserter.Factory.class);
-    factory(RebaseChangeOp.Factory.class);
-    factory(VisibleRefFilter.Factory.class);
-
-    // As Reindex is a batch program, don't assume the index is available for
-    // the change cache.
-    bind(SearchingChangeCacheImpl.class).toProvider(Providers.<SearchingChangeCacheImpl>of(null));
-
-    bind(new TypeLiteral<ImmutableSet<GroupReference>>() {})
-        .annotatedWith(AdministrateServerGroups.class)
-        .toInstance(ImmutableSet.<GroupReference>of());
-    bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
-        .annotatedWith(GitUploadPackGroups.class)
-        .toInstance(Collections.<AccountGroup.UUID>emptySet());
-    bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
-        .annotatedWith(GitReceivePackGroups.class)
-        .toInstance(Collections.<AccountGroup.UUID>emptySet());
-
-    install(new BatchGitModule());
-    install(new DefaultPermissionBackendModule());
-    install(new DefaultCacheFactory.Module());
-    install(new ExternalIdModule());
-    install(new GroupModule());
-    install(new NoteDbModule(cfg));
-    install(new PrologModule());
-    install(AccountCacheImpl.module());
-    install(GroupCacheImpl.module());
-    install(GroupIncludeCacheImpl.module());
-    install(ProjectCacheImpl.module());
-    install(SectionSortCache.module());
-    install(ChangeKindCacheImpl.module());
-    install(MergeabilityCacheImpl.module());
-    install(TagCache.module());
-    factory(CapabilityCollection.Factory.class);
-    factory(ChangeData.AssistedFactory.class);
-    factory(ProjectState.Factory.class);
-    factory(SubmitRuleEvaluator.Factory.class);
-
-    bind(ChangeJson.Factory.class).toProvider(Providers.<ChangeJson.Factory>of(null));
-    bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ErrorLogFile.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ErrorLogFile.java
deleted file mode 100644
index afb2fb4..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ErrorLogFile.java
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.util;
-
-import com.google.gerrit.common.FileUtil;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.util.SystemLog;
-import java.io.IOException;
-import java.nio.file.Path;
-import net.logstash.log4j.JSONEventLayoutV1;
-import org.apache.log4j.ConsoleAppender;
-import org.apache.log4j.Level;
-import org.apache.log4j.LogManager;
-import org.apache.log4j.Logger;
-import org.apache.log4j.PatternLayout;
-import org.eclipse.jgit.lib.Config;
-
-public class ErrorLogFile {
-  static final String LOG_NAME = "error_log";
-  static final String JSON_SUFFIX = ".json";
-
-  public static void errorOnlyConsole() {
-    LogManager.resetConfiguration();
-
-    final PatternLayout layout = new PatternLayout();
-    layout.setConversionPattern("%-5p %c %x: %m%n");
-
-    final ConsoleAppender dst = new ConsoleAppender();
-    dst.setLayout(layout);
-    dst.setTarget("System.err");
-    dst.setThreshold(Level.ERROR);
-    dst.activateOptions();
-
-    final Logger root = LogManager.getRootLogger();
-    root.removeAllAppenders();
-    root.addAppender(dst);
-  }
-
-  public static LifecycleListener start(Path sitePath, Config config) throws IOException {
-    Path logdir =
-        FileUtil.mkdirsOrDie(new SitePaths(sitePath).logs_dir, "Cannot create log directory");
-    if (SystemLog.shouldConfigure()) {
-      initLogSystem(logdir, config);
-    }
-
-    return new LifecycleListener() {
-      @Override
-      public void start() {}
-
-      @Override
-      public void stop() {
-        LogManager.shutdown();
-      }
-    };
-  }
-
-  private static void initLogSystem(Path logdir, Config config) {
-    final Logger root = LogManager.getRootLogger();
-    root.removeAllAppenders();
-
-    boolean json = config.getBoolean("log", "jsonLogging", false);
-    boolean text = config.getBoolean("log", "textLogging", true) || !json;
-
-    if (text) {
-      root.addAppender(
-          SystemLog.createAppender(
-              logdir, LOG_NAME, new PatternLayout("[%d] [%t] %-5p %c %x: %m%n")));
-    }
-
-    if (json) {
-      root.addAppender(
-          SystemLog.createAppender(logdir, LOG_NAME + JSON_SUFFIX, new JSONEventLayoutV1()));
-    }
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java
deleted file mode 100644
index 5885ab5..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java
+++ /dev/null
@@ -1,165 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.util;
-
-import static java.util.concurrent.TimeUnit.HOURS;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-
-import com.google.common.io.ByteStreams;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.nio.file.DirectoryStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.time.LocalDateTime;
-import java.time.ZoneId;
-import java.time.temporal.ChronoUnit;
-import java.util.concurrent.Future;
-import java.util.zip.GZIPOutputStream;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Compresses the old error logs. */
-public class LogFileCompressor implements Runnable {
-  private static final Logger log = LoggerFactory.getLogger(LogFileCompressor.class);
-
-  public static class Module extends LifecycleModule {
-    @Override
-    protected void configure() {
-      listener().to(Lifecycle.class);
-    }
-  }
-
-  static class Lifecycle implements LifecycleListener {
-    private final WorkQueue queue;
-    private final LogFileCompressor compressor;
-
-    @Inject
-    Lifecycle(WorkQueue queue, LogFileCompressor compressor) {
-      this.queue = queue;
-      this.compressor = compressor;
-    }
-
-    @Override
-    public void start() {
-      //compress log once and then schedule compression every day at 11:00pm
-      queue.getDefaultQueue().execute(compressor);
-      ZoneId zone = ZoneId.systemDefault();
-      LocalDateTime now = LocalDateTime.now(zone);
-      long milliSecondsUntil11pm =
-          now.until(now.withHour(23).withMinute(0).withSecond(0).withNano(0), ChronoUnit.MILLIS);
-      @SuppressWarnings("unused")
-      Future<?> possiblyIgnoredError =
-          queue
-              .getDefaultQueue()
-              .scheduleAtFixedRate(
-                  compressor, milliSecondsUntil11pm, HOURS.toMillis(24), MILLISECONDS);
-    }
-
-    @Override
-    public void stop() {}
-  }
-
-  private final Path logs_dir;
-
-  @Inject
-  LogFileCompressor(SitePaths site) {
-    logs_dir = resolve(site.logs_dir);
-  }
-
-  private static Path resolve(Path p) {
-    try {
-      return p.toRealPath().normalize();
-    } catch (IOException e) {
-      return p.toAbsolutePath().normalize();
-    }
-  }
-
-  @Override
-  public void run() {
-    try {
-      if (!Files.isDirectory(logs_dir)) {
-        return;
-      }
-      try (DirectoryStream<Path> list = Files.newDirectoryStream(logs_dir)) {
-        for (Path entry : list) {
-          if (!isLive(entry) && !isCompressed(entry) && isLogFile(entry)) {
-            compress(entry);
-          }
-        }
-      } catch (IOException e) {
-        log.error("Error listing logs to compress in " + logs_dir, e);
-      }
-    } catch (Exception e) {
-      log.error("Failed to compress log files: " + e.getMessage(), e);
-    }
-  }
-
-  private boolean isLive(Path entry) {
-    String name = entry.getFileName().toString();
-    return name.endsWith("_log")
-        || name.endsWith(".log")
-        || name.endsWith(".run")
-        || name.endsWith(".pid")
-        || name.endsWith(".json");
-  }
-
-  private boolean isCompressed(Path entry) {
-    String name = entry.getFileName().toString();
-    return name.endsWith(".gz") //
-        || name.endsWith(".zip") //
-        || name.endsWith(".bz2");
-  }
-
-  private boolean isLogFile(Path entry) {
-    return Files.isRegularFile(entry);
-  }
-
-  private void compress(Path src) {
-    Path dst = src.resolveSibling(src.getFileName() + ".gz");
-    Path tmp = src.resolveSibling(".tmp." + src.getFileName());
-    try {
-      try (InputStream in = Files.newInputStream(src);
-          OutputStream out = new GZIPOutputStream(Files.newOutputStream(tmp))) {
-        ByteStreams.copy(in, out);
-      }
-      tmp.toFile().setReadOnly();
-      try {
-        Files.move(tmp, dst);
-      } catch (IOException e) {
-        throw new IOException("Cannot rename " + tmp + " to " + dst, e);
-      }
-      Files.delete(src);
-    } catch (IOException e) {
-      log.error("Cannot compress " + src, e);
-      try {
-        Files.deleteIfExists(tmp);
-      } catch (IOException e2) {
-        log.warn("Failed to delete temporary log file " + tmp, e2);
-      }
-    }
-  }
-
-  @Override
-  public String toString() {
-    return "Log File Compressor";
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
deleted file mode 100644
index b59e085..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ /dev/null
@@ -1,266 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.util;
-
-import static com.google.gerrit.server.config.GerritServerConfigModule.getSecureStoreClassName;
-import static com.google.inject.Scopes.SINGLETON;
-import static com.google.inject.Stage.PRODUCTION;
-
-import com.google.gerrit.common.Die;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.metrics.DisabledMetricMaker;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.GerritServerConfigModule;
-import com.google.gerrit.server.config.SitePath;
-import com.google.gerrit.server.git.GitRepositoryManagerModule;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.schema.DataSourceModule;
-import com.google.gerrit.server.schema.DataSourceProvider;
-import com.google.gerrit.server.schema.DataSourceType;
-import com.google.gerrit.server.schema.DatabaseModule;
-import com.google.gerrit.server.schema.SchemaModule;
-import com.google.gerrit.server.securestore.SecureStoreClassName;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.AbstractModule;
-import com.google.inject.Binding;
-import com.google.inject.CreationException;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import com.google.inject.Key;
-import com.google.inject.Module;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import com.google.inject.TypeLiteral;
-import com.google.inject.name.Named;
-import com.google.inject.name.Names;
-import com.google.inject.spi.Message;
-import com.google.inject.util.Providers;
-import java.lang.annotation.Annotation;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.sql.Connection;
-import java.sql.SQLException;
-import java.util.ArrayList;
-import java.util.List;
-import javax.sql.DataSource;
-import org.eclipse.jgit.lib.Config;
-import org.kohsuke.args4j.Option;
-
-public abstract class SiteProgram extends AbstractProgram {
-  @Option(
-    name = "--site-path",
-    aliases = {"-d"},
-    usage = "Local directory containing site data"
-  )
-  private void setSitePath(String path) {
-    sitePath = Paths.get(path);
-  }
-
-  protected Provider<DataSource> dsProvider;
-
-  private Path sitePath = Paths.get(".");
-
-  protected SiteProgram() {}
-
-  protected SiteProgram(Path sitePath) {
-    this.sitePath = sitePath;
-  }
-
-  protected SiteProgram(Path sitePath, Provider<DataSource> dsProvider) {
-    this.sitePath = sitePath;
-    this.dsProvider = dsProvider;
-  }
-
-  /** @return the site path specified on the command line. */
-  protected Path getSitePath() {
-    return sitePath;
-  }
-
-  /** Ensures we are running inside of a valid site, otherwise throws a Die. */
-  protected void mustHaveValidSite() throws Die {
-    if (!Files.exists(sitePath.resolve("etc").resolve("gerrit.config"))) {
-      throw die("not a Gerrit site: '" + getSitePath() + "'\nPerhaps you need to run init first?");
-    }
-  }
-
-  /** @return provides database connectivity and site path. */
-  protected Injector createDbInjector(DataSourceProvider.Context context) {
-    return createDbInjector(false, context);
-  }
-
-  /** @return provides database connectivity and site path. */
-  protected Injector createDbInjector(boolean enableMetrics, DataSourceProvider.Context context) {
-    Path sitePath = getSitePath();
-    List<Module> modules = new ArrayList<>();
-
-    Module sitePathModule =
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
-            bind(String.class)
-                .annotatedWith(SecureStoreClassName.class)
-                .toProvider(Providers.of(getConfiguredSecureStoreClass()));
-          }
-        };
-    modules.add(sitePathModule);
-
-    if (enableMetrics) {
-      modules.add(new DropWizardMetricMaker.ApiModule());
-    } else {
-      modules.add(
-          new AbstractModule() {
-            @Override
-            protected void configure() {
-              bind(MetricMaker.class).to(DisabledMetricMaker.class);
-            }
-          });
-    }
-
-    modules.add(
-        new LifecycleModule() {
-          @Override
-          protected void configure() {
-            bind(DataSourceProvider.Context.class).toInstance(context);
-            if (dsProvider != null) {
-              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
-                  .toProvider(dsProvider)
-                  .in(SINGLETON);
-              if (LifecycleListener.class.isAssignableFrom(dsProvider.getClass())) {
-                listener().toInstance((LifecycleListener) dsProvider);
-              }
-            } else {
-              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
-                  .toProvider(SiteLibraryBasedDataSourceProvider.class)
-                  .in(SINGLETON);
-              listener().to(SiteLibraryBasedDataSourceProvider.class);
-            }
-          }
-        });
-    Module configModule = new GerritServerConfigModule();
-    modules.add(configModule);
-    Injector cfgInjector = Guice.createInjector(sitePathModule, configModule);
-    Config cfg = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    String dbType;
-    if (dsProvider != null) {
-      dbType = getDbType(dsProvider);
-    } else {
-      dbType = cfg.getString("database", null, "type");
-    }
-
-    if (dbType == null) {
-      throw new ProvisionException("database.type must be defined");
-    }
-
-    DataSourceType dst =
-        Guice.createInjector(new DataSourceModule(), configModule, sitePathModule)
-            .getInstance(Key.get(DataSourceType.class, Names.named(dbType.toLowerCase())));
-
-    modules.add(
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            bind(DataSourceType.class).toInstance(dst);
-          }
-        });
-    modules.add(new DatabaseModule());
-    modules.add(new SchemaModule());
-    modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
-    modules.add(new NotesMigration.Module());
-
-    try {
-      return Guice.createInjector(PRODUCTION, modules);
-    } catch (CreationException ce) {
-      Message first = ce.getErrorMessages().iterator().next();
-      Throwable why = first.getCause();
-
-      if (why instanceof SQLException) {
-        throw die("Cannot connect to SQL database", why);
-      }
-      if (why instanceof OrmException
-          && why.getCause() != null
-          && "Unable to determine driver URL".equals(why.getMessage())) {
-        why = why.getCause();
-        if (isCannotCreatePoolException(why)) {
-          throw die("Cannot connect to SQL database", why.getCause());
-        }
-        throw die("Cannot connect to SQL database", why);
-      }
-
-      StringBuilder buf = new StringBuilder();
-      if (why != null) {
-        buf.append(why.getMessage());
-        why = why.getCause();
-      } else {
-        buf.append(first.getMessage());
-      }
-      while (why != null) {
-        buf.append("\n  caused by ");
-        buf.append(why.toString());
-        why = why.getCause();
-      }
-      throw die(buf.toString(), new RuntimeException("DbInjector failed", ce));
-    }
-  }
-
-  protected final String getConfiguredSecureStoreClass() {
-    return getSecureStoreClassName(sitePath);
-  }
-
-  private String getDbType(Provider<DataSource> dsProvider) {
-    String dbProductName;
-    try (Connection conn = dsProvider.get().getConnection()) {
-      dbProductName = conn.getMetaData().getDatabaseProductName().toLowerCase();
-    } catch (SQLException e) {
-      throw new RuntimeException(e);
-    }
-
-    List<Module> modules = new ArrayList<>();
-    modules.add(
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            bind(Path.class).annotatedWith(SitePath.class).toInstance(getSitePath());
-          }
-        });
-    modules.add(new GerritServerConfigModule());
-    modules.add(new DataSourceModule());
-    Injector i = Guice.createInjector(modules);
-    List<Binding<DataSourceType>> dsTypeBindings =
-        i.findBindingsByType(new TypeLiteral<DataSourceType>() {});
-    for (Binding<DataSourceType> binding : dsTypeBindings) {
-      Annotation annotation = binding.getKey().getAnnotation();
-      if (annotation instanceof Named) {
-        if (((Named) annotation).value().toLowerCase().contains(dbProductName)) {
-          return ((Named) annotation).value();
-        }
-      }
-    }
-    throw new IllegalStateException(
-        String.format(
-            "Cannot guess database type from the database product name '%s'", dbProductName));
-  }
-
-  @SuppressWarnings("deprecation")
-  private static boolean isCannotCreatePoolException(Throwable why) {
-    return why instanceof org.apache.commons.dbcp.SQLNestedException
-        && why.getCause() != null
-        && why.getMessage().startsWith("Cannot create PoolableConnectionFactory");
-  }
-}
diff --git a/gerrit-plugin-api/BUILD b/gerrit-plugin-api/BUILD
deleted file mode 100644
index fe9ce19..0000000
--- a/gerrit-plugin-api/BUILD
+++ /dev/null
@@ -1,109 +0,0 @@
-PLUGIN_API = [
-    "//gerrit-httpd:httpd",
-    "//gerrit-pgm:init-api",
-    "//gerrit-server:server",
-    "//gerrit-sshd:sshd",
-]
-
-EXPORTS = [
-    "//gerrit-index:index",
-    "//gerrit-index:query_exception",
-    "//gerrit-index:query_parser",
-    "//gerrit-common:annotations",
-    "//gerrit-common:server",
-    "//gerrit-extension-api:api",
-    "//gerrit-gwtexpui:server",
-    "//gerrit-server:metrics",
-    "//gerrit-reviewdb:server",
-    "//gerrit-server:prolog-common",
-    "//lib/commons:dbcp",
-    "//lib/commons:lang",
-    "//lib/commons:lang3",
-    "//lib/dropwizard:dropwizard-core",
-    "//lib/guice:guice",
-    "//lib/guice:guice-assistedinject",
-    "//lib/guice:guice-servlet",
-    "//lib/guice:javax-inject",
-    "//lib/guice:multibindings",
-    "//lib/httpcomponents:httpclient",
-    "//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",
-    "//lib/ow2:ow2-asm",
-    "//lib/ow2:ow2-asm-analysis",
-    "//lib/ow2:ow2-asm-commons",
-    "//lib/ow2:ow2-asm-util",
-    "//lib:args4j",
-    "//lib:blame-cache",
-    "//lib:guava",
-    "//lib:guava-retrying",
-    "//lib:gson",
-    "//lib:gwtorm",
-    "//lib:icu4j",
-    "//lib:jsch",
-    "//lib:mime-util",
-    "//lib:protobuf",
-    "//lib:servlet-api-3_1-without-neverlink",
-    "//lib:soy",
-    "//lib:velocity",
-]
-
-java_binary(
-    name = "plugin-api",
-    main_class = "Dummy",
-    visibility = ["//visibility:public"],
-    runtime_deps = [":lib"],
-)
-
-java_library(
-    name = "lib",
-    visibility = ["//visibility:public"],
-    exports = PLUGIN_API + EXPORTS,
-)
-
-java_library(
-    name = "lib-neverlink",
-    neverlink = 1,
-    visibility = ["//visibility:public"],
-    exports = PLUGIN_API + EXPORTS,
-)
-
-java_binary(
-    name = "plugin-api-sources",
-    main_class = "Dummy",
-    visibility = ["//visibility:public"],
-    runtime_deps = [
-        "//gerrit-common:libannotations-src.jar",
-        "//gerrit-extension-api:libapi-src.jar",
-        "//gerrit-gwtexpui:libserver-src.jar",
-        "//gerrit-httpd:libhttpd-src.jar",
-        "//gerrit-index:libquery_exception-src.jar",
-        "//gerrit-index:libquery_parser-src.jar",
-        "//gerrit-pgm:libinit-api-src.jar",
-        "//gerrit-reviewdb:libserver-src.jar",
-        "//gerrit-server:libserver-src.jar",
-        "//gerrit-sshd:libsshd-src.jar",
-    ],
-)
-
-load("//tools/bzl:javadoc.bzl", "java_doc")
-
-java_doc(
-    name = "plugin-api-javadoc",
-    libs = PLUGIN_API + [
-        "//gerrit-index:query_exception",
-        "//gerrit-index:query_parser",
-        "//gerrit-common:annotations",
-        "//gerrit-common:server",
-        "//gerrit-extension-api:api",
-        "//gerrit-gwtexpui:server",
-        "//gerrit-reviewdb:server",
-    ],
-    pkgs = ["com.google.gerrit"],
-    title = "Gerrit Review Plugin API Documentation",
-    visibility = ["//visibility:public"],
-)
diff --git a/gerrit-plugin-api/pom.xml b/gerrit-plugin-api/pom.xml
deleted file mode 100644
index c9082a2..0000000
--- a/gerrit-plugin-api/pom.xml
+++ /dev/null
@@ -1,86 +0,0 @@
-<project>
-  <modelVersion>4.0.0</modelVersion>
-  <groupId>com.google.gerrit</groupId>
-  <artifactId>gerrit-plugin-api</artifactId>
-  <version>2.15-rc2</version>
-  <packaging>jar</packaging>
-  <name>Gerrit Code Review - Plugin API</name>
-  <description>API for Gerrit Plugins</description>
-  <url>https://www.gerritcodereview.com/</url>
-
-  <licenses>
-    <license>
-      <name>The Apache Software License, Version 2.0</name>
-      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
-      <distribution>repo</distribution>
-    </license>
-  </licenses>
-
-  <scm>
-    <url>https://gerrit.googlesource.com/gerrit</url>
-    <connection>https://gerrit.googlesource.com/gerrit</connection>
-  </scm>
-
-  <developers>
-    <developer>
-      <name>Alice Kober-Sotzek</name>
-    </developer>
-    <developer>
-      <name>Andrew Bonventre</name>
-    </developer>
-    <developer>
-      <name>Becky Siegel</name>
-    </developer>
-    <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
-      <name>David Ostrovsky</name>
-    </developer>
-    <developer>
-      <name>David Pursehouse</name>
-    </developer>
-    <developer>
-      <name>Edwin Kempin</name>
-    </developer>
-    <developer>
-      <name>Hugo Arès</name>
-    </developer>
-    <developer>
-      <name>Kasper Nilsson</name>
-    </developer>
-    <developer>
-      <name>Logan Hanks</name>
-    </developer>
-    <developer>
-      <name>Martin Fick</name>
-    </developer>
-    <developer>
-      <name>Saša Živkov</name>
-    </developer>
-    <developer>
-      <name>Shawn Pearce</name>
-    </developer>
-    <developer>
-      <name>Viktar Donich</name>
-    </developer>
-    <developer>
-      <name>Wyatt Allen</name>
-    </developer>
-  </developers>
-
-  <mailingLists>
-    <mailingList>
-      <name>Repo and Gerrit Discussion</name>
-      <post>repo-discuss@googlegroups.com</post>
-      <subscribe>https://groups.google.com/forum/#!forum/repo-discuss</subscribe>
-      <unsubscribe>https://groups.google.com/forum/#!forum/repo-discuss</unsubscribe>
-      <archive>https://groups.google.com/forum/#!forum/repo-discuss</archive>
-    </mailingList>
-  </mailingLists>
-
-  <issueManagement>
-    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
-    <system>Gerrit Issue Tracker</system>
-  </issueManagement>
-</project>
diff --git a/gerrit-plugin-gwtui/BUILD b/gerrit-plugin-gwtui/BUILD
index f896050..3f066c7 100644
--- a/gerrit-plugin-gwtui/BUILD
+++ b/gerrit-plugin-gwtui/BUILD
@@ -21,18 +21,17 @@
     exported_deps = ["//gerrit-gwtui-common:client-lib"],
     resources = glob(["src/main/**/*"]),
     deps = DEPS + [
-        "//gerrit-common:libclient-src.jar",
-        "//gerrit-extension-api:libclient-src.jar",
-        "//gerrit-gwtexpui:libClippy-src.jar",
-        "//gerrit-gwtexpui:libGlobalKey-src.jar",
-        "//gerrit-gwtexpui:libProgress-src.jar",
-        "//gerrit-gwtexpui:libSafeHtml-src.jar",
-        "//gerrit-gwtexpui:libUserAgent-src.jar",
+        "//java/org/eclipse/jgit:libclient-src.jar",
+        "//java/org/eclipse/jgit:libEdit-src.jar",
+        "//java/com/google/gerrit/common:libclient-src.jar",
+        "//java/com/google/gwtexpui/clippy:libclippy-src.jar",
+        "//java/com/google/gwtexpui/globalkey:libglobalkey-src.jar",
+        "//java/com/google/gwtexpui/progress:libprogress-src.jar",
+        "//java/com/google/gwtexpui/safehtml:libsafehtml-src.jar",
+        "//java/com/google/gwtexpui/user:libagent-src.jar",
         "//gerrit-gwtui-common:libclient-src.jar",
-        "//gerrit-patch-jgit:libclient-src.jar",
-        "//gerrit-patch-jgit:libEdit-src.jar",
-        "//gerrit-prettify:libclient-src.jar",
-        "//gerrit-reviewdb:libclient-src.jar",
+        "//java/com/google/gerrit/prettify:libclient-src.jar",
+        "//java/com/google/gerrit/reviewdb:libclient-src.jar",
         "//lib/gwt:dev-neverlink",
     ],
 )
@@ -51,8 +50,12 @@
     main_class = "Dummy",
     runtime_deps = [
         ":libgwtui-api-lib-src.jar",
-        "//gerrit-gwtexpui:client-src-lib",
         "//gerrit-gwtui-common:libclient-lib-src.jar",
+        "//java/com/google/gwtexpui/clippy:libclippy-src.jar",
+        "//java/com/google/gwtexpui/globalkey:libglobalkey-src.jar",
+        "//java/com/google/gwtexpui/progress:libprogress-src.jar",
+        "//java/com/google/gwtexpui/safehtml:libsafehtml-src.jar",
+        "//java/com/google/gwtexpui/user:libagent-src.jar",
     ],
 )
 
@@ -66,8 +69,8 @@
         "//lib:gwtorm_client",
         "//lib/gwt:dev",
         "//gerrit-gwtui-common:client-lib",
-        "//gerrit-common:client",
-        "//gerrit-reviewdb:client",
+        "//java/com/google/gerrit/common:client",
+        "//java/com/google/gerrit/reviewdb:client",
     ],
     pkgs = [
         "com.google.gerrit.plugin",
diff --git a/gerrit-plugin-gwtui/pom.xml b/gerrit-plugin-gwtui/pom.xml
deleted file mode 100644
index a1bd1c6..0000000
--- a/gerrit-plugin-gwtui/pom.xml
+++ /dev/null
@@ -1,86 +0,0 @@
-<project>
-  <modelVersion>4.0.0</modelVersion>
-  <groupId>com.google.gerrit</groupId>
-  <artifactId>gerrit-plugin-gwtui</artifactId>
-  <version>2.15-rc2</version>
-  <packaging>jar</packaging>
-  <name>Gerrit Code Review - Plugin GWT UI</name>
-  <description>Common Classes for Gerrit GWT UI Plugins</description>
-  <url>https://www.gerritcodereview.com/</url>
-
-  <licenses>
-    <license>
-      <name>The Apache Software License, Version 2.0</name>
-      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
-      <distribution>repo</distribution>
-    </license>
-  </licenses>
-
-  <scm>
-    <url>https://gerrit.googlesource.com/gerrit</url>
-    <connection>https://gerrit.googlesource.com/gerrit</connection>
-  </scm>
-
-  <developers>
-    <developer>
-      <name>Alice Kober-Sotzek</name>
-    </developer>
-    <developer>
-      <name>Andrew Bonventre</name>
-    </developer>
-    <developer>
-      <name>Becky Siegel</name>
-    </developer>
-    <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
-      <name>David Ostrovsky</name>
-    </developer>
-    <developer>
-      <name>David Pursehouse</name>
-    </developer>
-    <developer>
-      <name>Edwin Kempin</name>
-    </developer>
-    <developer>
-      <name>Hugo Arès</name>
-    </developer>
-    <developer>
-      <name>Kasper Nilsson</name>
-    </developer>
-    <developer>
-      <name>Logan Hanks</name>
-    </developer>
-    <developer>
-      <name>Martin Fick</name>
-    </developer>
-    <developer>
-      <name>Saša Živkov</name>
-    </developer>
-    <developer>
-      <name>Shawn Pearce</name>
-    </developer>
-    <developer>
-      <name>Viktar Donich</name>
-    </developer>
-    <developer>
-      <name>Wyatt Allen</name>
-    </developer>
-  </developers>
-
-  <mailingLists>
-    <mailingList>
-      <name>Repo and Gerrit Discussion</name>
-      <post>repo-discuss@googlegroups.com</post>
-      <subscribe>https://groups.google.com/forum/#!forum/repo-discuss</subscribe>
-      <unsubscribe>https://groups.google.com/forum/#!forum/repo-discuss</unsubscribe>
-      <archive>https://groups.google.com/forum/#!forum/repo-discuss</archive>
-    </mailingList>
-  </mailingLists>
-
-  <issueManagement>
-    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
-    <system>Gerrit Issue Tracker</system>
-  </issueManagement>
-</project>
diff --git a/gerrit-prettify/BUILD b/gerrit-prettify/BUILD
deleted file mode 100644
index 18180b3..0000000
--- a/gerrit-prettify/BUILD
+++ /dev/null
@@ -1,40 +0,0 @@
-load("//tools/bzl:gwt.bzl", "gwt_module")
-
-SRC = "src/main/java/com/google/gerrit/prettify/"
-
-gwt_module(
-    name = "client",
-    srcs = glob([
-        SRC + "common/**/*.java",
-    ]),
-    exported_deps = [
-        "//gerrit-extension-api:client",
-        "//gerrit-gwtexpui:SafeHtml",
-        "//gerrit-patch-jgit:Edit",
-        "//gerrit-patch-jgit:client",
-        "//gerrit-reviewdb:client",
-        "//lib:gwtjsonrpc",
-        "//lib:gwtjsonrpc_src",
-    ],
-    gwt_xml = SRC + "PrettyFormatter.gwt.xml",
-    visibility = ["//visibility:public"],
-    deps = ["//lib/gwt:user-neverlink"],
-)
-
-java_library(
-    name = "server",
-    srcs = glob([SRC + "common/**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-patch-jgit:server",
-        "//gerrit-reviewdb:server",
-        "//lib:guava",
-        "//lib:gwtjsonrpc",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-    ],
-)
-
-exports_files([
-    "src/main/resources/com/google/gerrit/prettify/client/prettify.css",
-    "src/main/resources/com/google/gerrit/prettify/client/prettify.js",
-])
diff --git a/gerrit-reviewdb/BUILD b/gerrit-reviewdb/BUILD
deleted file mode 100644
index 98af668..0000000
--- a/gerrit-reviewdb/BUILD
+++ /dev/null
@@ -1,45 +0,0 @@
-package(
-    default_visibility = ["//visibility:public"],
-)
-
-load("//tools/bzl:gwt.bzl", "gwt_module")
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-SRC = "src/main/java/com/google/gerrit/reviewdb/"
-
-TESTS = "src/test/java/com/google/gerrit/reviewdb/"
-
-gwt_module(
-    name = "client",
-    srcs = glob([SRC + "client/**/*.java"]),
-    gwt_xml = SRC + "ReviewDB.gwt.xml",
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-extension-api:client",
-        "//lib:gwtorm_client",
-        "//lib:gwtorm_client_src",
-    ],
-)
-
-java_library(
-    name = "server",
-    srcs = glob([SRC + "**/*.java"]),
-    resources = glob(["src/main/resources/**/*"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-extension-api:api",
-        "//lib:guava",
-        "//lib:gwtorm",
-    ],
-)
-
-junit_tests(
-    name = "client_tests",
-    srcs = glob([TESTS + "client/**/*.java"]),
-    deps = [
-        ":client",
-        "//gerrit-server:testutil",
-        "//lib:gwtorm",
-        "//lib:truth",
-    ],
-)
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
deleted file mode 100644
index b7506e5..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
+++ /dev/null
@@ -1,334 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_DRAFT_COMMENTS;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_STARRED_CHANGES;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS;
-
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.IntKey;
-import java.sql.Timestamp;
-
-/**
- * Information about a single user.
- *
- * <p>A user may have multiple identities they can use to login to Gerrit (see ExternalId), but in
- * such cases they always map back to a single Account entity.
- *
- * <p>Entities "owned" by an Account (that is, their primary key contains the {@link Account.Id} key
- * as part of their key structure):
- *
- * <ul>
- *   <li>ExternalId: OpenID identities and email addresses known to be registered to this user.
- *       Multiple records can exist when the user has more than one public identity, such as a work
- *       and a personal email address.
- *   <li>{@link AccountGroupMember}: membership of the user in a specific human managed {@link
- *       AccountGroup}. Multiple records can exist when the user is a member of more than one group.
- *   <li>{@link AccountSshKey}: user's public SSH keys, for authentication through the internal SSH
- *       daemon. One record per SSH key uploaded by the user, keys are checked in random order until
- *       a match is found.
- *   <li>{@link DiffPreferencesInfo}: user's preferences for rendering side-to-side and unified diff
- * </ul>
- */
-public final class Account {
-  public static final String USER_NAME_PATTERN_FIRST = "[a-zA-Z0-9]";
-  public static final String USER_NAME_PATTERN_REST = "[a-zA-Z0-9._@-]";
-  public static final String USER_NAME_PATTERN_LAST = "[a-zA-Z0-9]";
-
-  /** Regular expression that {@link #userName} must match. */
-  public static final String USER_NAME_PATTERN =
-      "^"
-          + //
-          "("
-          + //
-          USER_NAME_PATTERN_FIRST
-          + //
-          USER_NAME_PATTERN_REST
-          + "*"
-          + //
-          USER_NAME_PATTERN_LAST
-          + //
-          "|"
-          + //
-          USER_NAME_PATTERN_FIRST
-          + //
-          ")"
-          + //
-          "$";
-
-  /** Key local to Gerrit to identify a user. */
-  public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected int id;
-
-    protected Id() {}
-
-    public Id(int id) {
-      this.id = id;
-    }
-
-    @Override
-    public int get() {
-      return id;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      id = newValue;
-    }
-
-    /** Parse an Account.Id out of a string representation. */
-    public static Id parse(String str) {
-      Id r = new Id();
-      r.fromString(str);
-      return r;
-    }
-
-    public static Id fromRef(String name) {
-      if (name == null) {
-        return null;
-      }
-      if (name.startsWith(REFS_USERS)) {
-        return fromRefPart(name.substring(REFS_USERS.length()));
-      } else if (name.startsWith(REFS_DRAFT_COMMENTS)) {
-        return parseAfterShardedRefPart(name.substring(REFS_DRAFT_COMMENTS.length()));
-      } else if (name.startsWith(REFS_STARRED_CHANGES)) {
-        return parseAfterShardedRefPart(name.substring(REFS_STARRED_CHANGES.length()));
-      }
-      return null;
-    }
-
-    /**
-     * Parse an Account.Id out of a part of a ref-name.
-     *
-     * @param name a ref name with the following syntax: {@code "34/1234..."}. We assume that the
-     *     caller has trimmed any prefix.
-     */
-    public static Id fromRefPart(String name) {
-      Integer id = RefNames.parseShardedRefPart(name);
-      return id != null ? new Account.Id(id) : null;
-    }
-
-    public static Id parseAfterShardedRefPart(String name) {
-      Integer id = RefNames.parseAfterShardedRefPart(name);
-      return id != null ? new Account.Id(id) : null;
-    }
-
-    /**
-     * Parse an Account.Id out of the last part of a ref name.
-     *
-     * <p>The input is a ref name of the form {@code ".../1234"}, where the suffix is a non-sharded
-     * account ID. Ref names using a sharded ID should use {@link #fromRefPart(String)} instead for
-     * greater safety.
-     *
-     * @param name ref name
-     * @return account ID, or null if not numeric.
-     */
-    public static Id fromRefSuffix(String name) {
-      Integer id = RefNames.parseRefSuffix(name);
-      return id != null ? new Account.Id(id) : null;
-    }
-  }
-
-  @Column(id = 1)
-  protected Id accountId;
-
-  /** Date and time the user registered with the review server. */
-  @Column(id = 2)
-  protected Timestamp registeredOn;
-
-  /** Full name of the user ("Given-name Surname" style). */
-  @Column(id = 3, notNull = false)
-  protected String fullName;
-
-  /** Email address the user prefers to be contacted through. */
-  @Column(id = 4, notNull = false)
-  protected String preferredEmail;
-
-  // DELETED: id = 5 (contactFiledOn)
-
-  // DELETED: id = 6 (generalPreferences)
-
-  /**
-   * Is this user inactive? This is used to avoid showing some users (eg. former employees) in
-   * auto-suggest.
-   */
-  @Column(id = 7)
-  protected boolean inactive;
-
-  /** The user-settable status of this account (e.g. busy, OOO, available) */
-  @Column(id = 8, notNull = false)
-  protected String status;
-
-  /** <i>computed</i> the username selected from the identities. */
-  protected String userName;
-
-  /** <i>stored in git, used for caching</i> the user's preferences. */
-  private GeneralPreferencesInfo generalPreferences;
-
-  /**
-   * ID of the user branch from which the account was read, {@code null} if the account was read
-   * from ReviewDb.
-   */
-  private String metaId;
-
-  protected Account() {}
-
-  /**
-   * Create a new account.
-   *
-   * @param newId unique id, see {@link com.google.gerrit.server.Sequences#nextAccountId()}.
-   * @param registeredOn when the account was registered.
-   */
-  public Account(Account.Id newId, Timestamp registeredOn) {
-    this.accountId = newId;
-    this.registeredOn = registeredOn;
-  }
-
-  /** Get local id of this account, to link with in other entities */
-  public Account.Id getId() {
-    return accountId;
-  }
-
-  /** Get the full name of the user ("Given-name Surname" style). */
-  public String getFullName() {
-    return fullName;
-  }
-
-  /** Set the full name of the user ("Given-name Surname" style). */
-  public void setFullName(String name) {
-    if (name != null && !name.trim().isEmpty()) {
-      fullName = name.trim();
-    } else {
-      fullName = null;
-    }
-  }
-
-  /** Email address the user prefers to be contacted through. */
-  public String getPreferredEmail() {
-    return preferredEmail;
-  }
-
-  /** Set the email address the user prefers to be contacted through. */
-  public void setPreferredEmail(String addr) {
-    preferredEmail = addr;
-  }
-
-  /**
-   * Formats an account name.
-   *
-   * <p>If the account has a full name, it returns only the full name. Otherwise it returns a longer
-   * form that includes the email address.
-   */
-  public String getName(String anonymousCowardName) {
-    if (fullName != null) {
-      return fullName;
-    }
-    if (preferredEmail != null) {
-      return preferredEmail;
-    }
-    return getNameEmail(anonymousCowardName);
-  }
-
-  /**
-   * Get the name and email address.
-   *
-   * <p>Example output:
-   *
-   * <ul>
-   *   <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated
-   *   <li>{@code A U. Thor (12)}: missing email address
-   *   <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name
-   *   <li>{@code Anonymous Coward (12)}: missing name and email address
-   * </ul>
-   */
-  public String getNameEmail(String anonymousCowardName) {
-    String name = fullName != null ? fullName : anonymousCowardName;
-    StringBuilder b = new StringBuilder();
-    b.append(name);
-    if (preferredEmail != null) {
-      b.append(" <");
-      b.append(preferredEmail);
-      b.append(">");
-    } else if (accountId != null) {
-      b.append(" (");
-      b.append(accountId.get());
-      b.append(")");
-    }
-    return b.toString();
-  }
-
-  /** Get the date and time the user first registered. */
-  public Timestamp getRegisteredOn() {
-    return registeredOn;
-  }
-
-  public GeneralPreferencesInfo getGeneralPreferencesInfo() {
-    return generalPreferences;
-  }
-
-  public void setGeneralPreferences(GeneralPreferencesInfo p) {
-    generalPreferences = p;
-  }
-
-  public String getMetaId() {
-    return metaId;
-  }
-
-  public void setMetaId(String metaId) {
-    this.metaId = metaId;
-  }
-
-  public boolean isActive() {
-    return !inactive;
-  }
-
-  public void setActive(boolean active) {
-    inactive = !active;
-  }
-
-  public String getStatus() {
-    return status;
-  }
-
-  public void setStatus(String status) {
-    this.status = status;
-  }
-
-  /** @return the computed user name for this account */
-  public String getUserName() {
-    return userName;
-  }
-
-  /** Update the computed user name property. */
-  public void setUserName(String userName) {
-    this.userName = userName;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    return o instanceof Account && ((Account) o).getId().equals(getId());
-  }
-
-  @Override
-  public int hashCode() {
-    return getId().get();
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroup.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroup.java
deleted file mode 100644
index 74dadc5..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroup.java
+++ /dev/null
@@ -1,234 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.IntKey;
-import com.google.gwtorm.client.StringKey;
-import java.sql.Timestamp;
-
-/** Named group of one or more accounts, typically used for access controls. */
-public final class AccountGroup {
-  /**
-   * Time when the audit subsystem was implemented, used as the default value for {@link #createdOn}
-   * when one couldn't be determined from the audit log.
-   */
-  // Can't use Instant here because GWT. This is verified against a readable time in the tests,
-  // which don't need to compile under GWT.
-  private static final long AUDIT_CREATION_INSTANT_MS = 1244489460000L;
-
-  public static Timestamp auditCreationInstantTs() {
-    return new Timestamp(AUDIT_CREATION_INSTANT_MS);
-  }
-
-  /** Group name key */
-  public static class NameKey extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected String name;
-
-    protected NameKey() {}
-
-    public NameKey(String n) {
-      name = n;
-    }
-
-    @Override
-    public String get() {
-      return name;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      name = newValue;
-    }
-  }
-
-  /** Globally unique identifier. */
-  public static class UUID extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected String uuid;
-
-    protected UUID() {}
-
-    public UUID(String n) {
-      uuid = n;
-    }
-
-    @Override
-    public String get() {
-      return uuid;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      uuid = newValue;
-    }
-
-    /** Parse an AccountGroup.UUID out of a string representation. */
-    public static UUID parse(String str) {
-      final UUID r = new UUID();
-      r.fromString(str);
-      return r;
-    }
-  }
-
-  /** @return true if the UUID is for a group managed within Gerrit. */
-  public static boolean isInternalGroup(AccountGroup.UUID uuid) {
-    return uuid.get().matches("^[0-9a-f]{40}$");
-  }
-
-  /** Synthetic key to link to within the database */
-  public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected int id;
-
-    protected Id() {}
-
-    public Id(int id) {
-      this.id = id;
-    }
-
-    @Override
-    public int get() {
-      return id;
-    }
-
-    @Override
-    protected void set(int newValue) {
-      id = newValue;
-    }
-
-    /** Parse an AccountGroup.Id out of a string representation. */
-    public static Id parse(String str) {
-      final Id r = new Id();
-      r.fromString(str);
-      return r;
-    }
-  }
-
-  /** Unique name of this group within the system. */
-  @Column(id = 1)
-  protected NameKey name;
-
-  /** Unique identity, to link entities as {@link #name} can change. */
-  @Column(id = 2)
-  protected Id groupId;
-
-  // DELETED: id = 3 (ownerGroupId)
-
-  /** A textual description of the group's purpose. */
-  @Column(id = 4, length = Integer.MAX_VALUE, notNull = false)
-  protected String description;
-
-  // DELETED: id = 5 (groupType)
-  // DELETED: id = 6 (externalName)
-
-  @Column(id = 7)
-  protected boolean visibleToAll;
-
-  // DELETED: id = 8 (emailOnlyAuthors)
-
-  /** Globally unique identifier name for this group. */
-  @Column(id = 9)
-  protected UUID groupUUID;
-
-  /**
-   * Identity of the group whose members can manage this group.
-   *
-   * <p>This can be a self-reference to indicate the group's members manage itself.
-   */
-  @Column(id = 10)
-  protected UUID ownerGroupUUID;
-
-  @Column(id = 11, notNull = false)
-  protected Timestamp createdOn;
-
-  protected AccountGroup() {}
-
-  public AccountGroup(
-      AccountGroup.NameKey newName,
-      AccountGroup.Id newId,
-      AccountGroup.UUID uuid,
-      Timestamp createdOn) {
-    name = newName;
-    groupId = newId;
-    visibleToAll = false;
-    groupUUID = uuid;
-    ownerGroupUUID = groupUUID;
-    this.createdOn = createdOn;
-  }
-
-  public AccountGroup.Id getId() {
-    return groupId;
-  }
-
-  public String getName() {
-    return name.get();
-  }
-
-  public AccountGroup.NameKey getNameKey() {
-    return name;
-  }
-
-  public void setNameKey(AccountGroup.NameKey nameKey) {
-    name = nameKey;
-  }
-
-  public String getDescription() {
-    return description;
-  }
-
-  public void setDescription(String d) {
-    description = d;
-  }
-
-  public AccountGroup.UUID getOwnerGroupUUID() {
-    return ownerGroupUUID;
-  }
-
-  public void setOwnerGroupUUID(AccountGroup.UUID uuid) {
-    ownerGroupUUID = uuid;
-  }
-
-  public void setVisibleToAll(boolean visibleToAll) {
-    this.visibleToAll = visibleToAll;
-  }
-
-  public boolean isVisibleToAll() {
-    return visibleToAll;
-  }
-
-  public AccountGroup.UUID getGroupUUID() {
-    return groupUUID;
-  }
-
-  public void setGroupUUID(AccountGroup.UUID uuid) {
-    groupUUID = uuid;
-  }
-
-  public Timestamp getCreatedOn() {
-    return createdOn != null ? createdOn : auditCreationInstantTs();
-  }
-
-  public void setCreatedOn(Timestamp createdOn) {
-    this.createdOn = createdOn;
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupById.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
deleted file mode 100644
index 99ff35be..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.CompoundKey;
-
-/** Membership of an {@link AccountGroup} in an {@link AccountGroup}. */
-public final class AccountGroupById {
-  public static class Key extends CompoundKey<AccountGroup.Id> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected AccountGroup.Id groupId;
-
-    @Column(id = 2)
-    protected AccountGroup.UUID includeUUID;
-
-    protected Key() {
-      groupId = new AccountGroup.Id();
-      includeUUID = new AccountGroup.UUID();
-    }
-
-    public Key(AccountGroup.Id g, AccountGroup.UUID u) {
-      groupId = g;
-      includeUUID = u;
-    }
-
-    @Override
-    public AccountGroup.Id getParentKey() {
-      return groupId;
-    }
-
-    public AccountGroup.Id getGroupId() {
-      return groupId;
-    }
-
-    public AccountGroup.UUID getIncludeUUID() {
-      return includeUUID;
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {includeUUID};
-    }
-  }
-
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
-
-  protected AccountGroupById() {}
-
-  public AccountGroupById(AccountGroupById.Key k) {
-    key = k;
-  }
-
-  public AccountGroupById.Key getKey() {
-    return key;
-  }
-
-  public AccountGroup.Id getGroupId() {
-    return key.groupId;
-  }
-
-  public AccountGroup.UUID getIncludeUUID() {
-    return key.includeUUID;
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
deleted file mode 100644
index a127a70..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
+++ /dev/null
@@ -1,110 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.CompoundKey;
-import java.sql.Timestamp;
-
-/** Inclusion of an {@link AccountGroup} in another {@link AccountGroup}. */
-public final class AccountGroupByIdAud {
-  public static class Key extends CompoundKey<AccountGroup.Id> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected AccountGroup.Id groupId;
-
-    @Column(id = 2)
-    protected AccountGroup.UUID includeUUID;
-
-    @Column(id = 3)
-    protected Timestamp addedOn;
-
-    protected Key() {
-      groupId = new AccountGroup.Id();
-      includeUUID = new AccountGroup.UUID();
-    }
-
-    public Key(AccountGroup.Id g, AccountGroup.UUID u, Timestamp t) {
-      groupId = g;
-      includeUUID = u;
-      addedOn = t;
-    }
-
-    @Override
-    public AccountGroup.Id getParentKey() {
-      return groupId;
-    }
-
-    public AccountGroup.UUID getIncludeUUID() {
-      return includeUUID;
-    }
-
-    public Timestamp getAddedOn() {
-      return addedOn;
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {includeUUID};
-    }
-  }
-
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
-
-  @Column(id = 2)
-  protected Account.Id addedBy;
-
-  @Column(id = 3, notNull = false)
-  protected Account.Id removedBy;
-
-  @Column(id = 4, notNull = false)
-  protected Timestamp removedOn;
-
-  protected AccountGroupByIdAud() {}
-
-  public AccountGroupByIdAud(final AccountGroupById m, Account.Id adder, Timestamp when) {
-    final AccountGroup.Id group = m.getGroupId();
-    final AccountGroup.UUID include = m.getIncludeUUID();
-    key = new AccountGroupByIdAud.Key(group, include, when);
-    addedBy = adder;
-  }
-
-  public AccountGroupByIdAud.Key getKey() {
-    return key;
-  }
-
-  public boolean isActive() {
-    return removedOn == null;
-  }
-
-  public void removed(Account.Id deleter, Timestamp when) {
-    removedBy = deleter;
-    removedOn = when;
-  }
-
-  public Account.Id getAddedBy() {
-    return addedBy;
-  }
-
-  public Account.Id getRemovedBy() {
-    return removedBy;
-  }
-
-  public Timestamp getRemovedOn() {
-    return removedOn;
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
deleted file mode 100644
index ce5b347..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.CompoundKey;
-
-/** Membership of an {@link Account} in an {@link AccountGroup}. */
-public final class AccountGroupMember {
-  public static class Key extends CompoundKey<Account.Id> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected Account.Id accountId;
-
-    @Column(id = 2)
-    protected AccountGroup.Id groupId;
-
-    protected Key() {
-      accountId = new Account.Id();
-      groupId = new AccountGroup.Id();
-    }
-
-    public Key(Account.Id a, AccountGroup.Id g) {
-      accountId = a;
-      groupId = g;
-    }
-
-    @Override
-    public Account.Id getParentKey() {
-      return accountId;
-    }
-
-    public AccountGroup.Id getAccountGroupId() {
-      return groupId;
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {groupId};
-    }
-  }
-
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
-
-  protected AccountGroupMember() {}
-
-  public AccountGroupMember(AccountGroupMember.Key k) {
-    key = k;
-  }
-
-  public AccountGroupMember.Key getKey() {
-    return key;
-  }
-
-  public Account.Id getAccountId() {
-    return key.accountId;
-  }
-
-  public AccountGroup.Id getAccountGroupId() {
-    return key.groupId;
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
deleted file mode 100644
index da19351..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
+++ /dev/null
@@ -1,115 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.CompoundKey;
-import java.sql.Timestamp;
-
-/** Membership of an {@link Account} in an {@link AccountGroup}. */
-public final class AccountGroupMemberAudit {
-  public static class Key extends CompoundKey<Account.Id> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected Account.Id accountId;
-
-    @Column(id = 2)
-    protected AccountGroup.Id groupId;
-
-    @Column(id = 3)
-    protected Timestamp addedOn;
-
-    protected Key() {
-      accountId = new Account.Id();
-      groupId = new AccountGroup.Id();
-    }
-
-    public Key(Account.Id a, AccountGroup.Id g, Timestamp t) {
-      accountId = a;
-      groupId = g;
-      addedOn = t;
-    }
-
-    @Override
-    public Account.Id getParentKey() {
-      return accountId;
-    }
-
-    public AccountGroup.Id getGroupId() {
-      return groupId;
-    }
-
-    public Timestamp getAddedOn() {
-      return addedOn;
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {groupId};
-    }
-  }
-
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
-
-  @Column(id = 2)
-  protected Account.Id addedBy;
-
-  @Column(id = 3, notNull = false)
-  protected Account.Id removedBy;
-
-  @Column(id = 4, notNull = false)
-  protected Timestamp removedOn;
-
-  protected AccountGroupMemberAudit() {}
-
-  public AccountGroupMemberAudit(final AccountGroupMember m, Account.Id adder, Timestamp addedOn) {
-    final Account.Id who = m.getAccountId();
-    final AccountGroup.Id group = m.getAccountGroupId();
-    key = new AccountGroupMemberAudit.Key(who, group, addedOn);
-    addedBy = adder;
-  }
-
-  public AccountGroupMemberAudit.Key getKey() {
-    return key;
-  }
-
-  public boolean isActive() {
-    return removedOn == null;
-  }
-
-  public void removed(Account.Id deleter, Timestamp when) {
-    removedBy = deleter;
-    removedOn = when;
-  }
-
-  public void removedLegacy() {
-    removedBy = addedBy;
-    removedOn = key.addedOn;
-  }
-
-  public Account.Id getAddedBy() {
-    return addedBy;
-  }
-
-  public Account.Id getRemovedBy() {
-    return removedBy;
-  }
-
-  public Timestamp getRemovedOn() {
-    return removedOn;
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
deleted file mode 100644
index 4b3c652..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
+++ /dev/null
@@ -1,331 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import java.sql.Timestamp;
-import java.util.Comparator;
-import java.util.Objects;
-
-/**
- * This class represents inline comments in NoteDb. This means it determines the JSON format for
- * inline comments in the revision notes that NoteDb uses to persist inline comments.
- *
- * <p>Changing fields in this class changes the storage format of inline comments in NoteDb and may
- * require a corresponding data migration (adding new optional fields is generally okay).
- *
- * <p>{@link PatchLineComment} also represents inline comments, but in ReviewDb. There are a few
- * notable differences:
- *
- * <ul>
- *   <li>PatchLineComment knows the comment status (published or draft). For comments in NoteDb the
- *       status is determined by the branch in which they are stored (published comments are stored
- *       in the change meta ref; draft comments are store in refs/draft-comments branches in
- *       All-Users). Hence Comment doesn't need to contain the status, but the status is implicitly
- *       known by where the comments are read from.
- *   <li>PatchLineComment knows the change ID. For comments in NoteDb, the change ID is determined
- *       by the branch in which they are stored (the ref name contains the change ID). Hence Comment
- *       doesn't need to contain the change ID, but the change ID is implicitly known by where the
- *       comments are read from.
- * </ul>
- *
- * <p>For all utility classes and middle layer functionality using Comment over PatchLineComment is
- * preferred, as PatchLineComment will go away together with ReviewDb. This means Comment should be
- * used everywhere and only for storing inline comment in ReviewDb a conversion to PatchLineComment
- * is done. Converting Comments to PatchLineComments and vice verse is done by
- * CommentsUtil#toPatchLineComments(Change.Id, PatchLineComment.Status, Iterable) and
- * CommentsUtil#toComments(String, Iterable).
- */
-public class Comment {
-  public static class Key {
-    public String uuid;
-    public String filename;
-    public int patchSetId;
-
-    public Key(Key k) {
-      this(k.uuid, k.filename, k.patchSetId);
-    }
-
-    public Key(String uuid, String filename, int patchSetId) {
-      this.uuid = uuid;
-      this.filename = filename;
-      this.patchSetId = patchSetId;
-    }
-
-    @Override
-    public String toString() {
-      return new StringBuilder()
-          .append("Comment.Key{")
-          .append("uuid=")
-          .append(uuid)
-          .append(',')
-          .append("filename=")
-          .append(filename)
-          .append(',')
-          .append("patchSetId=")
-          .append(patchSetId)
-          .append('}')
-          .toString();
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      if (o instanceof Key) {
-        Key k = (Key) o;
-        return Objects.equals(uuid, k.uuid)
-            && Objects.equals(filename, k.filename)
-            && Objects.equals(patchSetId, k.patchSetId);
-      }
-      return false;
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hash(uuid, filename, patchSetId);
-    }
-  }
-
-  public static class Identity {
-    int id;
-
-    public Identity(Account.Id id) {
-      this.id = id.get();
-    }
-
-    public Account.Id getId() {
-      return new Account.Id(id);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      if (o instanceof Identity) {
-        return Objects.equals(id, ((Identity) o).id);
-      }
-      return false;
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hash(id);
-    }
-
-    @Override
-    public String toString() {
-      return new StringBuilder()
-          .append("Comment.Identity{")
-          .append("id=")
-          .append(id)
-          .append('}')
-          .toString();
-    }
-  }
-
-  public static class Range implements Comparable<Range> {
-    private static final Comparator<Range> RANGE_COMPARATOR =
-        Comparator.<Range>comparingInt(range -> range.startLine)
-            .thenComparingInt(range -> range.startChar)
-            .thenComparingInt(range -> range.endLine)
-            .thenComparingInt(range -> range.endChar);
-
-    public int startLine; // 1-based, inclusive
-    public int startChar; // 0-based, inclusive
-    public int endLine; // 1-based, exclusive
-    public int endChar; // 0-based, exclusive
-
-    public Range(Range r) {
-      this(r.startLine, r.startChar, r.endLine, r.endChar);
-    }
-
-    public Range(com.google.gerrit.extensions.client.Comment.Range r) {
-      this(r.startLine, r.startCharacter, r.endLine, r.endCharacter);
-    }
-
-    public Range(int startLine, int startChar, int endLine, int endChar) {
-      this.startLine = startLine;
-      this.startChar = startChar;
-      this.endLine = endLine;
-      this.endChar = endChar;
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      if (o instanceof Range) {
-        Range r = (Range) o;
-        return Objects.equals(startLine, r.startLine)
-            && Objects.equals(startChar, r.startChar)
-            && Objects.equals(endLine, r.endLine)
-            && Objects.equals(endChar, r.endChar);
-      }
-      return false;
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hash(startLine, startChar, endLine, endChar);
-    }
-
-    @Override
-    public String toString() {
-      return new StringBuilder()
-          .append("Comment.Range{")
-          .append("startLine=")
-          .append(startLine)
-          .append(',')
-          .append("startChar=")
-          .append(startChar)
-          .append(',')
-          .append("endLine=")
-          .append(endLine)
-          .append(',')
-          .append("endChar=")
-          .append(endChar)
-          .append('}')
-          .toString();
-    }
-
-    @Override
-    public int compareTo(Range otherRange) {
-      return RANGE_COMPARATOR.compare(this, otherRange);
-    }
-  }
-
-  public Key key;
-  public int lineNbr;
-  public Identity author;
-  protected Identity realAuthor;
-  public Timestamp writtenOn;
-  public short side;
-  public String message;
-  public String parentUuid;
-  public Range range;
-  public String tag;
-  public String revId;
-  public String serverId;
-  public boolean unresolved;
-
-  public Comment(Comment c) {
-    this(
-        new Key(c.key),
-        c.author.getId(),
-        new Timestamp(c.writtenOn.getTime()),
-        c.side,
-        c.message,
-        c.serverId,
-        c.unresolved);
-    this.lineNbr = c.lineNbr;
-    this.realAuthor = c.realAuthor;
-    this.range = c.range != null ? new Range(c.range) : null;
-    this.tag = c.tag;
-    this.revId = c.revId;
-    this.unresolved = c.unresolved;
-  }
-
-  public Comment(
-      Key key,
-      Account.Id author,
-      Timestamp writtenOn,
-      short side,
-      String message,
-      String serverId,
-      boolean unresolved) {
-    this.key = key;
-    this.author = new Comment.Identity(author);
-    this.realAuthor = this.author;
-    this.writtenOn = writtenOn;
-    this.side = side;
-    this.message = message;
-    this.serverId = serverId;
-    this.unresolved = unresolved;
-  }
-
-  public void setLineNbrAndRange(
-      Integer lineNbr, com.google.gerrit.extensions.client.Comment.Range range) {
-    this.lineNbr = lineNbr != null ? lineNbr : range != null ? range.endLine : 0;
-    if (range != null) {
-      this.range = new Comment.Range(range);
-    }
-  }
-
-  public void setRange(CommentRange range) {
-    this.range = range != null ? range.asCommentRange() : null;
-  }
-
-  public void setRevId(RevId revId) {
-    this.revId = revId != null ? revId.get() : null;
-  }
-
-  public void setRealAuthor(Account.Id id) {
-    realAuthor = id != null && id.get() != author.id ? new Comment.Identity(id) : null;
-  }
-
-  public Identity getRealAuthor() {
-    return realAuthor != null ? realAuthor : author;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (o instanceof Comment) {
-      return Objects.equals(key, ((Comment) o).key);
-    }
-    return false;
-  }
-
-  @Override
-  public int hashCode() {
-    return key.hashCode();
-  }
-
-  @Override
-  public String toString() {
-    return new StringBuilder()
-        .append("Comment{")
-        .append("key=")
-        .append(key)
-        .append(',')
-        .append("lineNbr=")
-        .append(lineNbr)
-        .append(',')
-        .append("author=")
-        .append(author.getId().get())
-        .append(',')
-        .append("realAuthor=")
-        .append(realAuthor != null ? realAuthor.getId().get() : "")
-        .append(',')
-        .append("writtenOn=")
-        .append(writtenOn.toString())
-        .append(',')
-        .append("side=")
-        .append(side)
-        .append(',')
-        .append("message=")
-        .append(Objects.toString(message, ""))
-        .append(',')
-        .append("parentUuid=")
-        .append(Objects.toString(parentUuid, ""))
-        .append(',')
-        .append("range=")
-        .append(Objects.toString(range, ""))
-        .append(',')
-        .append("revId=")
-        .append(revId != null ? revId : "")
-        .append(',')
-        .append("tag=")
-        .append(Objects.toString(tag, ""))
-        .append(',')
-        .append("unresolved=")
-        .append(unresolved)
-        .append('}')
-        .toString();
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
deleted file mode 100644
index e756ce5..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
+++ /dev/null
@@ -1,333 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ProjectState;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.StringKey;
-
-/** Projects match a source code repository managed by Gerrit */
-public final class Project {
-  /** Project name key */
-  public static class NameKey extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected String name;
-
-    protected NameKey() {}
-
-    public NameKey(String n) {
-      name = n;
-    }
-
-    @Override
-    public String get() {
-      return name;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      name = newValue;
-    }
-
-    @Override
-    public int hashCode() {
-      return get().hashCode();
-    }
-
-    @Override
-    public boolean equals(Object b) {
-      if (b instanceof NameKey) {
-        return get().equals(((NameKey) b).get());
-      }
-      return false;
-    }
-
-    /** Parse a Project.NameKey out of a string representation. */
-    public static NameKey parse(String str) {
-      final NameKey r = new NameKey();
-      r.fromString(str);
-      return r;
-    }
-
-    public static String asStringOrNull(NameKey key) {
-      return key == null ? null : key.get();
-    }
-  }
-
-  protected NameKey name;
-
-  protected String description;
-
-  protected InheritableBoolean useContributorAgreements;
-
-  protected InheritableBoolean useSignedOffBy;
-
-  protected SubmitType submitType;
-
-  protected ProjectState state;
-
-  protected NameKey parent;
-
-  protected InheritableBoolean requireChangeID;
-
-  protected String maxObjectSizeLimit;
-
-  protected InheritableBoolean useContentMerge;
-
-  protected String defaultDashboardId;
-
-  protected String localDefaultDashboardId;
-
-  protected String themeName;
-
-  protected InheritableBoolean createNewChangeForAllNotInTarget;
-
-  protected InheritableBoolean enableSignedPush;
-  protected InheritableBoolean requireSignedPush;
-
-  protected InheritableBoolean rejectImplicitMerges;
-  protected InheritableBoolean privateByDefault;
-
-  protected InheritableBoolean enableReviewerByEmail;
-
-  protected InheritableBoolean matchAuthorToCommitterDate;
-
-  protected Project() {}
-
-  public Project(Project.NameKey nameKey) {
-    name = nameKey;
-    submitType = SubmitType.MERGE_IF_NECESSARY;
-    state = ProjectState.ACTIVE;
-    useContributorAgreements = InheritableBoolean.INHERIT;
-    useSignedOffBy = InheritableBoolean.INHERIT;
-    requireChangeID = InheritableBoolean.INHERIT;
-    useContentMerge = InheritableBoolean.INHERIT;
-    createNewChangeForAllNotInTarget = InheritableBoolean.INHERIT;
-    enableSignedPush = InheritableBoolean.INHERIT;
-    requireSignedPush = InheritableBoolean.INHERIT;
-    privateByDefault = InheritableBoolean.INHERIT;
-    enableReviewerByEmail = InheritableBoolean.INHERIT;
-    matchAuthorToCommitterDate = InheritableBoolean.INHERIT;
-  }
-
-  public Project.NameKey getNameKey() {
-    return name;
-  }
-
-  public String getName() {
-    return name != null ? name.get() : null;
-  }
-
-  public String getDescription() {
-    return description;
-  }
-
-  public void setDescription(String d) {
-    description = d;
-  }
-
-  public InheritableBoolean getUseContributorAgreements() {
-    return useContributorAgreements;
-  }
-
-  public InheritableBoolean getUseSignedOffBy() {
-    return useSignedOffBy;
-  }
-
-  public InheritableBoolean getUseContentMerge() {
-    return useContentMerge;
-  }
-
-  public InheritableBoolean getRequireChangeID() {
-    return requireChangeID;
-  }
-
-  public String getMaxObjectSizeLimit() {
-    return maxObjectSizeLimit;
-  }
-
-  public InheritableBoolean getRejectImplicitMerges() {
-    return rejectImplicitMerges;
-  }
-
-  public InheritableBoolean getPrivateByDefault() {
-    return privateByDefault;
-  }
-
-  public void setPrivateByDefault(InheritableBoolean privateByDefault) {
-    this.privateByDefault = privateByDefault;
-  }
-
-  public InheritableBoolean getEnableReviewerByEmail() {
-    return enableReviewerByEmail;
-  }
-
-  public void setEnableReviewerByEmail(InheritableBoolean enable) {
-    enableReviewerByEmail = enable;
-  }
-
-  public InheritableBoolean getMatchAuthorToCommitterDate() {
-    return matchAuthorToCommitterDate;
-  }
-
-  public void setMatchAuthorToCommitterDate(InheritableBoolean match) {
-    matchAuthorToCommitterDate = match;
-  }
-
-  public void setUseContributorAgreements(InheritableBoolean u) {
-    useContributorAgreements = u;
-  }
-
-  public void setUseSignedOffBy(InheritableBoolean sbo) {
-    useSignedOffBy = sbo;
-  }
-
-  public void setUseContentMerge(InheritableBoolean cm) {
-    useContentMerge = cm;
-  }
-
-  public void setRequireChangeID(InheritableBoolean cid) {
-    requireChangeID = cid;
-  }
-
-  public InheritableBoolean getCreateNewChangeForAllNotInTarget() {
-    return createNewChangeForAllNotInTarget;
-  }
-
-  public void setCreateNewChangeForAllNotInTarget(InheritableBoolean useAllNotInTarget) {
-    this.createNewChangeForAllNotInTarget = useAllNotInTarget;
-  }
-
-  public InheritableBoolean getEnableSignedPush() {
-    return enableSignedPush;
-  }
-
-  public void setEnableSignedPush(InheritableBoolean enable) {
-    enableSignedPush = enable;
-  }
-
-  public InheritableBoolean getRequireSignedPush() {
-    return requireSignedPush;
-  }
-
-  public void setRequireSignedPush(InheritableBoolean require) {
-    requireSignedPush = require;
-  }
-
-  public void setMaxObjectSizeLimit(String limit) {
-    maxObjectSizeLimit = limit;
-  }
-
-  public void setRejectImplicitMerges(InheritableBoolean check) {
-    rejectImplicitMerges = check;
-  }
-
-  public SubmitType getSubmitType() {
-    return submitType;
-  }
-
-  public void setSubmitType(SubmitType type) {
-    submitType = type;
-  }
-
-  public ProjectState getState() {
-    return state;
-  }
-
-  public void setState(ProjectState newState) {
-    state = newState;
-  }
-
-  public String getDefaultDashboard() {
-    return defaultDashboardId;
-  }
-
-  public void setDefaultDashboard(String defaultDashboardId) {
-    this.defaultDashboardId = defaultDashboardId;
-  }
-
-  public String getLocalDefaultDashboard() {
-    return localDefaultDashboardId;
-  }
-
-  public void setLocalDefaultDashboard(String localDefaultDashboardId) {
-    this.localDefaultDashboardId = localDefaultDashboardId;
-  }
-
-  public String getThemeName() {
-    return themeName;
-  }
-
-  public void setThemeName(String themeName) {
-    this.themeName = themeName;
-  }
-
-  public void copySettingsFrom(Project update) {
-    description = update.description;
-    useContributorAgreements = update.useContributorAgreements;
-    useSignedOffBy = update.useSignedOffBy;
-    useContentMerge = update.useContentMerge;
-    requireChangeID = update.requireChangeID;
-    submitType = update.submitType;
-    state = update.state;
-    maxObjectSizeLimit = update.maxObjectSizeLimit;
-    createNewChangeForAllNotInTarget = update.createNewChangeForAllNotInTarget;
-  }
-
-  /**
-   * Returns the name key of the parent project.
-   *
-   * @return name key of the parent project, {@code null} if this project is the wild project,
-   *     {@code null} or the name key of the wild project if this project is a direct child of the
-   *     wild project
-   */
-  public Project.NameKey getParent() {
-    return parent;
-  }
-
-  /**
-   * Returns the name key of the parent project.
-   *
-   * @param allProjectsName name key of the wild project
-   * @return name key of the parent project, {@code null} if this project is the wild project
-   */
-  public Project.NameKey getParent(Project.NameKey allProjectsName) {
-    if (parent != null) {
-      return parent;
-    }
-
-    if (name.equals(allProjectsName)) {
-      return null;
-    }
-
-    return allProjectsName;
-  }
-
-  public String getParentName() {
-    return parent != null ? parent.get() : null;
-  }
-
-  public void setParentName(String n) {
-    parent = n != null ? new NameKey(n) : null;
-  }
-
-  public void setParentName(NameKey n) {
-    parent = n;
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
deleted file mode 100644
index 89de9dc..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ /dev/null
@@ -1,362 +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.reviewdb.client;
-
-/** Constants and utilities for Gerrit-specific ref names. */
-public class RefNames {
-  public static final String HEAD = "HEAD";
-
-  public static final String REFS = "refs/";
-
-  public static final String REFS_HEADS = "refs/heads/";
-
-  public static final String REFS_TAGS = "refs/tags/";
-
-  public static final String REFS_CHANGES = "refs/changes/";
-
-  public static final String REFS_META = "refs/meta/";
-
-  /** Note tree listing commits we refuse {@code refs/meta/reject-commits} */
-  public static final String REFS_REJECT_COMMITS = "refs/meta/reject-commits";
-
-  /** Configuration settings for a project {@code refs/meta/config} */
-  public static final String REFS_CONFIG = "refs/meta/config";
-
-  /** Note tree listing external IDs */
-  public static final String REFS_EXTERNAL_IDS = "refs/meta/external-ids";
-
-  /** Magic user branch in All-Users {@code refs/users/self} */
-  public static final String REFS_USERS_SELF = "refs/users/self";
-
-  /** Default user preference settings */
-  public static final String REFS_USERS_DEFAULT = RefNames.REFS_USERS + "default";
-
-  /** Configurations of project-specific dashboards (canned search queries). */
-  public static final String REFS_DASHBOARDS = "refs/meta/dashboards/";
-
-  /** Sequence counters in NoteDb. */
-  public static final String REFS_SEQUENCES = "refs/sequences/";
-
-  /**
-   * Prefix applied to merge commit base nodes.
-   *
-   * <p>References in this directory should take the form {@code refs/cache-automerge/xx/yyyy...}
-   * where xx is the first two digits of the merge commit's object name, and yyyyy... is the
-   * remaining 38. The reference should point to a treeish that is the automatic merge result of the
-   * merge commit's parents.
-   */
-  public static final String REFS_CACHE_AUTOMERGE = "refs/cache-automerge/";
-
-  /** Suffix of a meta ref in the NoteDb. */
-  public static final String META_SUFFIX = "/meta";
-
-  /** Suffix of a ref that stores robot comments in the NoteDb. */
-  public static final String ROBOT_COMMENTS_SUFFIX = "/robot-comments";
-
-  public static final String EDIT_PREFIX = "edit-";
-
-  /*
-   * The following refs contain an account ID and should be visible only to that account.
-   *
-   * Parsing the account ID from the ref is implemented in Account.Id#fromRef(String). This ensures
-   * that VisibleRefFilter hides those refs from other users.
-   *
-   * This applies to:
-   * - User branches (e.g. 'refs/users/23/1011123')
-   * - Draft comment refs (e.g. 'refs/draft-comments/73/67473/1011123')
-   * - Starred changes refs (e.g. 'refs/starred-changes/73/67473/1011123')
-   */
-
-  /** Preference settings for a user {@code refs/users} */
-  public static final String REFS_USERS = "refs/users/";
-
-  /** Draft inline comments of a user on a change */
-  public static final String REFS_DRAFT_COMMENTS = "refs/draft-comments/";
-
-  /** A change starred by a user */
-  public static final String REFS_STARRED_CHANGES = "refs/starred-changes/";
-
-  public static String fullName(String ref) {
-    return (ref.startsWith(REFS) || ref.equals(HEAD)) ? ref : REFS_HEADS + ref;
-  }
-
-  public static final String shortName(String ref) {
-    if (ref.startsWith(REFS_HEADS)) {
-      return ref.substring(REFS_HEADS.length());
-    } else if (ref.startsWith(REFS_TAGS)) {
-      return ref.substring(REFS_TAGS.length());
-    }
-    return ref;
-  }
-
-  public static String changeMetaRef(Change.Id id) {
-    StringBuilder r = newStringBuilder().append(REFS_CHANGES);
-    return shard(id.get(), r).append(META_SUFFIX).toString();
-  }
-
-  public static String robotCommentsRef(Change.Id id) {
-    StringBuilder r = newStringBuilder().append(REFS_CHANGES);
-    return shard(id.get(), r).append(ROBOT_COMMENTS_SUFFIX).toString();
-  }
-
-  public static boolean isNoteDbMetaRef(String ref) {
-    if (ref.startsWith(REFS_CHANGES)
-        && (ref.endsWith(META_SUFFIX) || ref.endsWith(ROBOT_COMMENTS_SUFFIX))) {
-      return true;
-    }
-    if (ref.startsWith(REFS_DRAFT_COMMENTS) || ref.startsWith(REFS_STARRED_CHANGES)) {
-      return true;
-    }
-    return false;
-  }
-
-  public static String refsUsers(Account.Id accountId) {
-    StringBuilder r = newStringBuilder().append(REFS_USERS);
-    return shard(accountId.get(), r).toString();
-  }
-
-  public static String refsDraftComments(Change.Id changeId, Account.Id accountId) {
-    return buildRefsPrefix(REFS_DRAFT_COMMENTS, changeId.get()).append(accountId.get()).toString();
-  }
-
-  public static String refsDraftCommentsPrefix(Change.Id changeId) {
-    return buildRefsPrefix(REFS_DRAFT_COMMENTS, changeId.get()).toString();
-  }
-
-  public static String refsStarredChanges(Change.Id changeId, Account.Id accountId) {
-    return buildRefsPrefix(REFS_STARRED_CHANGES, changeId.get()).append(accountId.get()).toString();
-  }
-
-  public static String refsStarredChangesPrefix(Change.Id changeId) {
-    return buildRefsPrefix(REFS_STARRED_CHANGES, changeId.get()).toString();
-  }
-
-  private static StringBuilder buildRefsPrefix(String prefix, int id) {
-    StringBuilder r = newStringBuilder().append(prefix);
-    return shard(id, r).append('/');
-  }
-
-  public static String refsCacheAutomerge(String hash) {
-    return REFS_CACHE_AUTOMERGE + hash.substring(0, 2) + '/' + hash.substring(2);
-  }
-
-  public static String shard(int id) {
-    if (id < 0) {
-      return null;
-    }
-    return shard(id, newStringBuilder()).toString();
-  }
-
-  private static StringBuilder shard(int id, StringBuilder sb) {
-    int n = id % 100;
-    if (n < 10) {
-      sb.append('0');
-    }
-    sb.append(n);
-    sb.append('/');
-    sb.append(id);
-    return sb;
-  }
-
-  /**
-   * Returns reference for this change edit with sharded user and change number:
-   * refs/users/UU/UUUU/edit-CCCC/P.
-   *
-   * @param accountId account id
-   * @param changeId change number
-   * @param psId patch set number
-   * @return reference for this change edit
-   */
-  public static String refsEdit(Account.Id accountId, Change.Id changeId, PatchSet.Id psId) {
-    return refsEditPrefix(accountId, changeId) + psId.get();
-  }
-
-  /**
-   * Returns reference prefix for this change edit with sharded user and change number:
-   * refs/users/UU/UUUU/edit-CCCC/.
-   *
-   * @param accountId account id
-   * @param changeId change number
-   * @return reference prefix for this change edit
-   */
-  public static String refsEditPrefix(Account.Id accountId, Change.Id changeId) {
-    return refsEditPrefix(accountId) + changeId.get() + '/';
-  }
-
-  public static String refsEditPrefix(Account.Id accountId) {
-    return refsUsers(accountId) + '/' + EDIT_PREFIX;
-  }
-
-  public static boolean isRefsEdit(String ref) {
-    return ref != null && ref.startsWith(REFS_USERS) && ref.contains(EDIT_PREFIX);
-  }
-
-  public static boolean isRefsUsers(String ref) {
-    return ref.startsWith(REFS_USERS);
-  }
-
-  static Integer parseShardedRefPart(String name) {
-    if (name == null) {
-      return null;
-    }
-
-    String[] parts = name.split("/");
-    int n = parts.length;
-    if (n < 2) {
-      return null;
-    }
-
-    // Last 2 digits.
-    int le;
-    for (le = 0; le < parts[0].length(); le++) {
-      if (!Character.isDigit(parts[0].charAt(le))) {
-        return null;
-      }
-    }
-    if (le != 2) {
-      return null;
-    }
-
-    // Full ID.
-    int ie;
-    for (ie = 0; ie < parts[1].length(); ie++) {
-      if (!Character.isDigit(parts[1].charAt(ie))) {
-        if (ie == 0) {
-          return null;
-        }
-        break;
-      }
-    }
-
-    int shard = Integer.parseInt(parts[0]);
-    int id = Integer.parseInt(parts[1].substring(0, ie));
-
-    if (id % 100 != shard) {
-      return null;
-    }
-    return id;
-  }
-
-  /**
-   * Skips a sharded ref part at the beginning of the name.
-   *
-   * <p>E.g.: "01/1" -> "", "01/1/" -> "/", "01/1/2" -> "/2", "01/1-edit" -> "-edit"
-   *
-   * @param name ref part name
-   * @return the rest of the name, {@code null} if the ref name part doesn't start with a valid
-   *     sharded ID
-   */
-  static String skipShardedRefPart(String name) {
-    if (name == null) {
-      return null;
-    }
-
-    String[] parts = name.split("/");
-    int n = parts.length;
-    if (n < 2) {
-      return null;
-    }
-
-    // Last 2 digits.
-    int le;
-    for (le = 0; le < parts[0].length(); le++) {
-      if (!Character.isDigit(parts[0].charAt(le))) {
-        return null;
-      }
-    }
-    if (le != 2) {
-      return null;
-    }
-
-    // Full ID.
-    int ie;
-    for (ie = 0; ie < parts[1].length(); ie++) {
-      if (!Character.isDigit(parts[1].charAt(ie))) {
-        if (ie == 0) {
-          return null;
-        }
-        break;
-      }
-    }
-
-    int shard = Integer.parseInt(parts[0]);
-    int id = Integer.parseInt(parts[1].substring(0, ie));
-
-    if (id % 100 != shard) {
-      return null;
-    }
-
-    return name.substring(2 + 1 + ie); // 2 for the length of the shard, 1 for the '/'
-  }
-
-  /**
-   * Parses an ID that follows a sharded ref part at the beginning of the name.
-   *
-   * <p>E.g.: "01/1/2" -> 2, "01/1/2/4" -> 2, ""01/1/2-edit" -> 2
-   *
-   * @param name ref part name
-   * @return ID that follows the sharded ref part at the beginning of the name, {@code null} if the
-   *     ref name part doesn't start with a valid sharded ID or if no valid ID follows the sharded
-   *     ref part
-   */
-  static Integer parseAfterShardedRefPart(String name) {
-    String rest = skipShardedRefPart(name);
-    if (rest == null || !rest.startsWith("/")) {
-      return null;
-    }
-
-    rest = rest.substring(1);
-
-    int ie;
-    for (ie = 0; ie < rest.length(); ie++) {
-      if (!Character.isDigit(rest.charAt(ie))) {
-        break;
-      }
-    }
-    if (ie == 0) {
-      return null;
-    }
-    return Integer.parseInt(rest.substring(0, ie));
-  }
-
-  static Integer parseRefSuffix(String name) {
-    if (name == null) {
-      return null;
-    }
-    int i = name.length();
-    while (i > 0) {
-      char c = name.charAt(i - 1);
-      if (c == '/') {
-        break;
-      } else if (!Character.isDigit(c)) {
-        return null;
-      }
-      i--;
-    }
-    if (i == 0) {
-      return null;
-    }
-    return Integer.valueOf(name.substring(i, name.length()));
-  }
-
-  private static StringBuilder newStringBuilder() {
-    // Many refname types in this file are always are longer than the default of 16 chars, so
-    // presize StringBuilders larger by default. This hurts readability less than accurate
-    // calculations would, at a negligible cost to memory overhead.
-    return new StringBuilder(64);
-  }
-
-  private RefNames() {}
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
deleted file mode 100644
index 04567bc..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
+++ /dev/null
@@ -1,130 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.SystemConfig;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.Relation;
-import com.google.gwtorm.server.Schema;
-import com.google.gwtorm.server.Sequence;
-
-/**
- * The review service database schema.
- *
- * <p>Root entities that are at the top level of some important data graph:
- *
- * <ul>
- *   <li>{@link Account}: Per-user account registration, preferences, identity.
- *   <li>{@link Change}: All review information about a single proposed change.
- *   <li>{@link SystemConfig}: Server-wide settings, managed by administrator.
- * </ul>
- */
-public interface ReviewDb extends Schema {
-  /* If you change anything, update SchemaVersion.C to use a new version. */
-
-  @Relation(id = 1)
-  SchemaVersionAccess schemaVersion();
-
-  @Relation(id = 2)
-  SystemConfigAccess systemConfig();
-
-  // Deleted @Relation(id = 3)
-
-  // Deleted @Relation(id = 4)
-
-  // Deleted @Relation(id = 6)
-
-  // Deleted @Relation(id = 7)
-
-  // Deleted @Relation(id = 8)
-
-  @Relation(id = 10)
-  AccountGroupAccess accountGroups();
-
-  @Relation(id = 11)
-  AccountGroupNameAccess accountGroupNames();
-
-  @Relation(id = 12)
-  AccountGroupMemberAccess accountGroupMembers();
-
-  @Relation(id = 13)
-  AccountGroupMemberAuditAccess accountGroupMembersAudit();
-
-  // Deleted @Relation(id = 17)
-
-  // Deleted @Relation(id = 18)
-
-  // Deleted @Relation(id = 19)
-
-  // Deleted @Relation(id = 20)
-
-  @Relation(id = 21)
-  ChangeAccess changes();
-
-  @Relation(id = 22)
-  PatchSetApprovalAccess patchSetApprovals();
-
-  @Relation(id = 23)
-  ChangeMessageAccess changeMessages();
-
-  @Relation(id = 24)
-  PatchSetAccess patchSets();
-
-  // Deleted @Relation(id = 25)
-
-  @Relation(id = 26)
-  PatchLineCommentAccess patchComments();
-
-  // Deleted @Relation(id = 28)
-
-  @Relation(id = 29)
-  AccountGroupByIdAccess accountGroupById();
-
-  @Relation(id = 30)
-  AccountGroupByIdAudAccess accountGroupByIdAud();
-
-  int FIRST_ACCOUNT_ID = 1000000;
-
-  /**
-   * Next unique id for a {@link Account}.
-   *
-   * @deprecated use {@link com.google.gerrit.server.Sequences#nextAccountId()}.
-   */
-  @Sequence(startWith = FIRST_ACCOUNT_ID)
-  @Deprecated
-  int nextAccountId() throws OrmException;
-
-  /** Next unique id for a {@link AccountGroup}. */
-  @Sequence
-  int nextAccountGroupId() throws OrmException;
-
-  int FIRST_CHANGE_ID = 1;
-
-  /**
-   * Next unique id for a {@link Change}.
-   *
-   * @deprecated use {@link com.google.gerrit.server.Sequences#nextChangeId()}.
-   */
-  @Sequence(startWith = FIRST_CHANGE_ID)
-  @Deprecated
-  int nextChangeId() throws OrmException;
-
-  default boolean changesTablesEnabled() {
-    return true;
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
deleted file mode 100644
index bb31b1c..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import com.google.common.collect.Ordering;
-import com.google.gwtorm.client.IntKey;
-
-/** Static utilities for ReviewDb types. */
-public class ReviewDbUtil {
-  private static final Ordering<? extends IntKey<?>> INT_KEY_ORDERING =
-      Ordering.natural().nullsFirst().<IntKey<?>>onResultOf(IntKey::get).nullsFirst();
-
-  /**
-   * Null-safe ordering over arbitrary subclass of {@code IntKey}.
-   *
-   * <p>In some cases, {@code Comparator.comparing(Change.Id::get)} may be shorter and cleaner.
-   * However, this method may be preferable in some cases:
-   *
-   * <ul>
-   *   <li>This ordering is null-safe over both input and the result of {@link IntKey#get()}; {@code
-   *       comparing} is only a good idea if all inputs are obviously non-null.
-   *   <li>{@code intKeyOrdering().sortedCopy(iterable)} is shorter than the stream equivalent.
-   *   <li>Creating derived comparators may be more readable with {@link Ordering} method chaining
-   *       rather than static {@code Comparator} methods.
-   * </ul>
-   */
-  @SuppressWarnings("unchecked")
-  public static <K extends IntKey<?>> Ordering<K> intKeyOrdering() {
-    return (Ordering<K>) INT_KEY_ORDERING;
-  }
-
-  public static ReviewDb unwrapDb(ReviewDb db) {
-    if (db instanceof DisallowReadFromChangesReviewDbWrapper) {
-      return ((DisallowReadFromChangesReviewDbWrapper) db).unsafeGetDelegate();
-    }
-    return db;
-  }
-
-  private ReviewDbUtil() {}
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
deleted file mode 100644
index 29b4be3..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
+++ /dev/null
@@ -1,681 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.AtomicUpdate;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.gwtorm.server.StatementExecutor;
-import java.util.Map;
-
-public class ReviewDbWrapper implements ReviewDb {
-  protected final ReviewDb delegate;
-
-  protected ReviewDbWrapper(ReviewDb delegate) {
-    this.delegate = checkNotNull(delegate);
-  }
-
-  @Override
-  public void commit() throws OrmException {
-    delegate.commit();
-  }
-
-  @Override
-  public void rollback() throws OrmException {
-    delegate.rollback();
-  }
-
-  @Override
-  public void updateSchema(StatementExecutor e) throws OrmException {
-    delegate.updateSchema(e);
-  }
-
-  @Override
-  public void pruneSchema(StatementExecutor e) throws OrmException {
-    delegate.pruneSchema(e);
-  }
-
-  @Override
-  public Access<?, ?>[] allRelations() {
-    return delegate.allRelations();
-  }
-
-  @Override
-  public void close() {
-    delegate.close();
-  }
-
-  @Override
-  public SchemaVersionAccess schemaVersion() {
-    return delegate.schemaVersion();
-  }
-
-  @Override
-  public SystemConfigAccess systemConfig() {
-    return delegate.systemConfig();
-  }
-
-  @Override
-  public AccountGroupAccess accountGroups() {
-    return delegate.accountGroups();
-  }
-
-  @Override
-  public AccountGroupNameAccess accountGroupNames() {
-    return delegate.accountGroupNames();
-  }
-
-  @Override
-  public AccountGroupMemberAccess accountGroupMembers() {
-    return delegate.accountGroupMembers();
-  }
-
-  @Override
-  public AccountGroupMemberAuditAccess accountGroupMembersAudit() {
-    return delegate.accountGroupMembersAudit();
-  }
-
-  @Override
-  public ChangeAccess changes() {
-    return delegate.changes();
-  }
-
-  @Override
-  public PatchSetApprovalAccess patchSetApprovals() {
-    return delegate.patchSetApprovals();
-  }
-
-  @Override
-  public ChangeMessageAccess changeMessages() {
-    return delegate.changeMessages();
-  }
-
-  @Override
-  public PatchSetAccess patchSets() {
-    return delegate.patchSets();
-  }
-
-  @Override
-  public PatchLineCommentAccess patchComments() {
-    return delegate.patchComments();
-  }
-
-  @Override
-  public AccountGroupByIdAccess accountGroupById() {
-    return delegate.accountGroupById();
-  }
-
-  @Override
-  public AccountGroupByIdAudAccess accountGroupByIdAud() {
-    return delegate.accountGroupByIdAud();
-  }
-
-  @Override
-  @SuppressWarnings("deprecation")
-  public int nextAccountId() throws OrmException {
-    return delegate.nextAccountId();
-  }
-
-  @Override
-  public int nextAccountGroupId() throws OrmException {
-    return delegate.nextAccountGroupId();
-  }
-
-  @Override
-  @SuppressWarnings("deprecation")
-  public int nextChangeId() throws OrmException {
-    return delegate.nextChangeId();
-  }
-
-  @Override
-  public boolean changesTablesEnabled() {
-    return delegate.changesTablesEnabled();
-  }
-
-  public static class ChangeAccessWrapper implements ChangeAccess {
-    protected final ChangeAccess delegate;
-
-    protected ChangeAccessWrapper(ChangeAccess delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<Change> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public Change.Id primaryKey(Change entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<Change.Id, Change> toMap(Iterable<Change> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<Change, OrmException> getAsync(
-        Change.Id key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<Change> get(Iterable<Change.Id> keys) throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<Change> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<Change> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<Change> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<Change.Id> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<Change> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(Change.Id key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public Change atomicUpdate(Change.Id key, AtomicUpdate<Change> update) throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public Change get(Change.Id id) throws OrmException {
-      return delegate.get(id);
-    }
-
-    @Override
-    public ResultSet<Change> all() throws OrmException {
-      return delegate.all();
-    }
-  }
-
-  public static class PatchSetApprovalAccessWrapper implements PatchSetApprovalAccess {
-    protected final PatchSetApprovalAccess delegate;
-
-    protected PatchSetApprovalAccessWrapper(PatchSetApprovalAccess delegate) {
-      this.delegate = delegate;
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public PatchSetApproval.Key primaryKey(PatchSetApproval entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<PatchSetApproval.Key, PatchSetApproval> toMap(Iterable<PatchSetApproval> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<PatchSetApproval, OrmException> getAsync(
-        PatchSetApproval.Key key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> get(Iterable<PatchSetApproval.Key> keys)
-        throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<PatchSetApproval> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<PatchSetApproval> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<PatchSetApproval> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<PatchSetApproval.Key> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<PatchSetApproval> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(PatchSetApproval.Key key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public PatchSetApproval atomicUpdate(
-        PatchSetApproval.Key key, AtomicUpdate<PatchSetApproval> update) throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public PatchSetApproval get(PatchSetApproval.Key key) throws OrmException {
-      return delegate.get(key);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byChange(Change.Id id) throws OrmException {
-      return delegate.byChange(id);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id) throws OrmException {
-      return delegate.byPatchSet(id);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byPatchSetUser(PatchSet.Id patchSet, Account.Id account)
-        throws OrmException {
-      return delegate.byPatchSetUser(patchSet, account);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> all() throws OrmException {
-      return delegate.all();
-    }
-  }
-
-  public static class ChangeMessageAccessWrapper implements ChangeMessageAccess {
-    protected final ChangeMessageAccess delegate;
-
-    protected ChangeMessageAccessWrapper(ChangeMessageAccess delegate) {
-      this.delegate = delegate;
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public ChangeMessage.Key primaryKey(ChangeMessage entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<ChangeMessage.Key, ChangeMessage> toMap(Iterable<ChangeMessage> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<ChangeMessage, OrmException> getAsync(
-        ChangeMessage.Key key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> get(Iterable<ChangeMessage.Key> keys) throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<ChangeMessage> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<ChangeMessage> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<ChangeMessage> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<ChangeMessage.Key> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<ChangeMessage> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(ChangeMessage.Key key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public ChangeMessage atomicUpdate(ChangeMessage.Key key, AtomicUpdate<ChangeMessage> update)
-        throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public ChangeMessage get(ChangeMessage.Key id) throws OrmException {
-      return delegate.get(id);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> byChange(Change.Id id) throws OrmException {
-      return delegate.byChange(id);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id) throws OrmException {
-      return delegate.byPatchSet(id);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> all() throws OrmException {
-      return delegate.all();
-    }
-  }
-
-  public static class PatchSetAccessWrapper implements PatchSetAccess {
-    protected final PatchSetAccess delegate;
-
-    protected PatchSetAccessWrapper(PatchSetAccess delegate) {
-      this.delegate = delegate;
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<PatchSet> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public PatchSet.Id primaryKey(PatchSet entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<PatchSet.Id, PatchSet> toMap(Iterable<PatchSet> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<PatchSet, OrmException> getAsync(
-        PatchSet.Id key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<PatchSet> get(Iterable<PatchSet.Id> keys) throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<PatchSet> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<PatchSet> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<PatchSet> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<PatchSet.Id> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<PatchSet> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(PatchSet.Id key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public PatchSet atomicUpdate(PatchSet.Id key, AtomicUpdate<PatchSet> update)
-        throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public PatchSet get(PatchSet.Id id) throws OrmException {
-      return delegate.get(id);
-    }
-
-    @Override
-    public ResultSet<PatchSet> byChange(Change.Id id) throws OrmException {
-      return delegate.byChange(id);
-    }
-
-    @Override
-    public ResultSet<PatchSet> all() throws OrmException {
-      return delegate.all();
-    }
-  }
-
-  public static class PatchLineCommentAccessWrapper implements PatchLineCommentAccess {
-    protected PatchLineCommentAccess delegate;
-
-    protected PatchLineCommentAccessWrapper(PatchLineCommentAccess delegate) {
-      this.delegate = delegate;
-    }
-
-    @Override
-    public String getRelationName() {
-      return delegate.getRelationName();
-    }
-
-    @Override
-    public int getRelationID() {
-      return delegate.getRelationID();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> iterateAllEntities() throws OrmException {
-      return delegate.iterateAllEntities();
-    }
-
-    @Override
-    public PatchLineComment.Key primaryKey(PatchLineComment entity) {
-      return delegate.primaryKey(entity);
-    }
-
-    @Override
-    public Map<PatchLineComment.Key, PatchLineComment> toMap(Iterable<PatchLineComment> c) {
-      return delegate.toMap(c);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public com.google.common.util.concurrent.CheckedFuture<PatchLineComment, OrmException> getAsync(
-        PatchLineComment.Key key) {
-      return delegate.getAsync(key);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> get(Iterable<PatchLineComment.Key> keys)
-        throws OrmException {
-      return delegate.get(keys);
-    }
-
-    @Override
-    public void insert(Iterable<PatchLineComment> instances) throws OrmException {
-      delegate.insert(instances);
-    }
-
-    @Override
-    public void update(Iterable<PatchLineComment> instances) throws OrmException {
-      delegate.update(instances);
-    }
-
-    @Override
-    public void upsert(Iterable<PatchLineComment> instances) throws OrmException {
-      delegate.upsert(instances);
-    }
-
-    @Override
-    public void deleteKeys(Iterable<PatchLineComment.Key> keys) throws OrmException {
-      delegate.deleteKeys(keys);
-    }
-
-    @Override
-    public void delete(Iterable<PatchLineComment> instances) throws OrmException {
-      delegate.delete(instances);
-    }
-
-    @Override
-    public void beginTransaction(PatchLineComment.Key key) throws OrmException {
-      delegate.beginTransaction(key);
-    }
-
-    @Override
-    public PatchLineComment atomicUpdate(
-        PatchLineComment.Key key, AtomicUpdate<PatchLineComment> update) throws OrmException {
-      return delegate.atomicUpdate(key, update);
-    }
-
-    @Override
-    public PatchLineComment get(PatchLineComment.Key id) throws OrmException {
-      return delegate.get(id);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> byChange(Change.Id id) throws OrmException {
-      return delegate.byChange(id);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id) throws OrmException {
-      return delegate.byPatchSet(id);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id, String file)
-        throws OrmException {
-      return delegate.publishedByChangeFile(id, file);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> publishedByPatchSet(PatchSet.Id patchset)
-        throws OrmException {
-      return delegate.publishedByPatchSet(patchset);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByPatchSetAuthor(
-        PatchSet.Id patchset, Account.Id author) throws OrmException {
-      return delegate.draftByPatchSetAuthor(patchset, author);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByChangeFileAuthor(
-        Change.Id id, String file, Account.Id author) throws OrmException {
-      return delegate.draftByChangeFileAuthor(id, file, author);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByAuthor(Account.Id author) throws OrmException {
-      return delegate.draftByAuthor(author);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> all() throws OrmException {
-      return delegate.all();
-    }
-  }
-}
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountGroupTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountGroupTest.java
deleted file mode 100644
index 02b6dd8..0000000
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountGroupTest.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.time.LocalDateTime;
-import java.time.Month;
-import java.time.ZoneOffset;
-import org.junit.Test;
-
-public class AccountGroupTest {
-  @Test
-  public void auditCreationInstant() {
-    Instant instant = LocalDateTime.of(2009, Month.JUNE, 8, 19, 31).toInstant(ZoneOffset.UTC);
-    assertThat(AccountGroup.auditCreationInstantTs()).isEqualTo(Timestamp.from(instant));
-  }
-}
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
deleted file mode 100644
index a0a806f..0000000
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.testutil.GerritBaseTests;
-import java.util.HashMap;
-import java.util.Map;
-import org.junit.Test;
-
-public class PatchSetApprovalTest extends GerritBaseTests {
-  @Test
-  public void keyEquality() {
-    PatchSetApproval.Key k1 =
-        new PatchSetApproval.Key(
-            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("My-Label"));
-    PatchSetApproval.Key k2 =
-        new PatchSetApproval.Key(
-            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("My-Label"));
-    PatchSetApproval.Key k3 =
-        new PatchSetApproval.Key(
-            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("Other-Label"));
-
-    assertThat(k2).isEqualTo(k1);
-    assertThat(k3).isNotEqualTo(k1);
-    assertThat(k2.hashCode()).isEqualTo(k1.hashCode());
-    assertThat(k3.hashCode()).isNotEqualTo(k1.hashCode());
-
-    Map<PatchSetApproval.Key, String> map = new HashMap<>();
-    map.put(k1, "k1");
-    map.put(k2, "k2");
-    map.put(k3, "k3");
-    assertThat(map).containsKey(k1);
-    assertThat(map).containsKey(k2);
-    assertThat(map).containsKey(k3);
-    assertThat(map).containsEntry(k1, "k2");
-    assertThat(map).containsEntry(k2, "k2");
-    assertThat(map).containsEntry(k3, "k3");
-  }
-}
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
deleted file mode 100644
index 7044547..0000000
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
+++ /dev/null
@@ -1,204 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.reviewdb.client.RefNames.parseAfterShardedRefPart;
-import static com.google.gerrit.reviewdb.client.RefNames.parseRefSuffix;
-import static com.google.gerrit.reviewdb.client.RefNames.parseShardedRefPart;
-import static com.google.gerrit.reviewdb.client.RefNames.skipShardedRefPart;
-
-import org.junit.Test;
-
-public class RefNamesTest {
-  private final Account.Id accountId = new Account.Id(1011123);
-  private final Change.Id changeId = new Change.Id(67473);
-  private final PatchSet.Id psId = new PatchSet.Id(changeId, 42);
-
-  @Test
-  public void fullName() throws Exception {
-    assertThat(RefNames.fullName(RefNames.REFS_CONFIG)).isEqualTo(RefNames.REFS_CONFIG);
-    assertThat(RefNames.fullName("refs/heads/master")).isEqualTo("refs/heads/master");
-    assertThat(RefNames.fullName("master")).isEqualTo("refs/heads/master");
-    assertThat(RefNames.fullName("refs/tags/v1.0")).isEqualTo("refs/tags/v1.0");
-    assertThat(RefNames.fullName("HEAD")).isEqualTo("HEAD");
-  }
-
-  @Test
-  public void changeRefs() throws Exception {
-    String changeMetaRef = RefNames.changeMetaRef(changeId);
-    assertThat(changeMetaRef).isEqualTo("refs/changes/73/67473/meta");
-    assertThat(RefNames.isNoteDbMetaRef(changeMetaRef)).isTrue();
-
-    String robotCommentsRef = RefNames.robotCommentsRef(changeId);
-    assertThat(robotCommentsRef).isEqualTo("refs/changes/73/67473/robot-comments");
-    assertThat(RefNames.isNoteDbMetaRef(robotCommentsRef)).isTrue();
-  }
-
-  @Test
-  public void refsUsers() throws Exception {
-    assertThat(RefNames.refsUsers(accountId)).isEqualTo("refs/users/23/1011123");
-  }
-
-  @Test
-  public void refsDraftComments() throws Exception {
-    assertThat(RefNames.refsDraftComments(changeId, accountId))
-        .isEqualTo("refs/draft-comments/73/67473/1011123");
-  }
-
-  @Test
-  public void refsDraftCommentsPrefix() throws Exception {
-    assertThat(RefNames.refsDraftCommentsPrefix(changeId))
-        .isEqualTo("refs/draft-comments/73/67473/");
-  }
-
-  @Test
-  public void refsStarredChanges() throws Exception {
-    assertThat(RefNames.refsStarredChanges(changeId, accountId))
-        .isEqualTo("refs/starred-changes/73/67473/1011123");
-  }
-
-  @Test
-  public void refsStarredChangesPrefix() throws Exception {
-    assertThat(RefNames.refsStarredChangesPrefix(changeId))
-        .isEqualTo("refs/starred-changes/73/67473/");
-  }
-
-  @Test
-  public void refsEdit() throws Exception {
-    assertThat(RefNames.refsEdit(accountId, changeId, psId))
-        .isEqualTo("refs/users/23/1011123/edit-67473/42");
-  }
-
-  @Test
-  public void isRefsEdit() throws Exception {
-    assertThat(RefNames.isRefsEdit("refs/users/23/1011123/edit-67473/42")).isTrue();
-
-    // user ref, but no edit ref
-    assertThat(RefNames.isRefsEdit("refs/users/23/1011123")).isFalse();
-
-    // other ref
-    assertThat(RefNames.isRefsEdit("refs/heads/master")).isFalse();
-  }
-
-  @Test
-  public void isRefsUsers() throws Exception {
-    assertThat(RefNames.isRefsUsers("refs/users/23/1011123")).isTrue();
-    assertThat(RefNames.isRefsUsers("refs/users/default")).isTrue();
-    assertThat(RefNames.isRefsUsers("refs/users/23/1011123/edit-67473/42")).isTrue();
-
-    assertThat(RefNames.isRefsUsers("refs/heads/master")).isFalse();
-  }
-
-  @Test
-  public void parseShardedRefsPart() throws Exception {
-    assertThat(parseShardedRefPart("01/1")).isEqualTo(1);
-    assertThat(parseShardedRefPart("01/1-drafts")).isEqualTo(1);
-    assertThat(parseShardedRefPart("01/1-drafts/2")).isEqualTo(1);
-
-    assertThat(parseShardedRefPart(null)).isNull();
-    assertThat(parseShardedRefPart("")).isNull();
-
-    // Prefix not stripped.
-    assertThat(parseShardedRefPart("refs/users/01/1")).isNull();
-
-    // Invalid characters.
-    assertThat(parseShardedRefPart("01a/1")).isNull();
-    assertThat(parseShardedRefPart("01/a1")).isNull();
-
-    // Mismatched shard.
-    assertThat(parseShardedRefPart("01/23")).isNull();
-
-    // Shard too short.
-    assertThat(parseShardedRefPart("1/1")).isNull();
-  }
-
-  @Test
-  public void skipShardedRefsPart() throws Exception {
-    assertThat(skipShardedRefPart("01/1")).isEqualTo("");
-    assertThat(skipShardedRefPart("01/1/")).isEqualTo("/");
-    assertThat(skipShardedRefPart("01/1/2")).isEqualTo("/2");
-    assertThat(skipShardedRefPart("01/1-edit")).isEqualTo("-edit");
-
-    assertThat(skipShardedRefPart(null)).isNull();
-    assertThat(skipShardedRefPart("")).isNull();
-
-    // Prefix not stripped.
-    assertThat(skipShardedRefPart("refs/draft-comments/01/1/2")).isNull();
-
-    // Invalid characters.
-    assertThat(skipShardedRefPart("01a/1/2")).isNull();
-    assertThat(skipShardedRefPart("01a/a1/2")).isNull();
-
-    // Mismatched shard.
-    assertThat(skipShardedRefPart("01/23/2")).isNull();
-
-    // Shard too short.
-    assertThat(skipShardedRefPart("1/1")).isNull();
-  }
-
-  @Test
-  public void parseAfterShardedRefsPart() throws Exception {
-    assertThat(parseAfterShardedRefPart("01/1/2")).isEqualTo(2);
-    assertThat(parseAfterShardedRefPart("01/1/2/4")).isEqualTo(2);
-    assertThat(parseAfterShardedRefPart("01/1/2-edit")).isEqualTo(2);
-
-    assertThat(parseAfterShardedRefPart(null)).isNull();
-    assertThat(parseAfterShardedRefPart("")).isNull();
-
-    // No ID after sharded ref part
-    assertThat(parseAfterShardedRefPart("01/1")).isNull();
-    assertThat(parseAfterShardedRefPart("01/1/")).isNull();
-    assertThat(parseAfterShardedRefPart("01/1/a")).isNull();
-
-    // Prefix not stripped.
-    assertThat(parseAfterShardedRefPart("refs/draft-comments/01/1/2")).isNull();
-
-    // Invalid characters.
-    assertThat(parseAfterShardedRefPart("01a/1/2")).isNull();
-    assertThat(parseAfterShardedRefPart("01a/a1/2")).isNull();
-
-    // Mismatched shard.
-    assertThat(parseAfterShardedRefPart("01/23/2")).isNull();
-
-    // Shard too short.
-    assertThat(parseAfterShardedRefPart("1/1")).isNull();
-  }
-
-  @Test
-  public void testParseRefSuffix() throws Exception {
-    assertThat(parseRefSuffix("1/2/34")).isEqualTo(34);
-    assertThat(parseRefSuffix("/34")).isEqualTo(34);
-
-    assertThat(parseRefSuffix(null)).isNull();
-    assertThat(parseRefSuffix("")).isNull();
-    assertThat(parseRefSuffix("34")).isNull();
-    assertThat(parseRefSuffix("12/ab")).isNull();
-    assertThat(parseRefSuffix("12/a4")).isNull();
-    assertThat(parseRefSuffix("12/4a")).isNull();
-    assertThat(parseRefSuffix("a4")).isNull();
-    assertThat(parseRefSuffix("4a")).isNull();
-  }
-
-  @Test
-  public void shard() throws Exception {
-    assertThat(RefNames.shard(1011123)).isEqualTo("23/1011123");
-    assertThat(RefNames.shard(537)).isEqualTo("37/537");
-    assertThat(RefNames.shard(12)).isEqualTo("12/12");
-    assertThat(RefNames.shard(0)).isEqualTo("00/0");
-    assertThat(RefNames.shard(1)).isEqualTo("01/1");
-    assertThat(RefNames.shard(-1)).isNull();
-  }
-}
diff --git a/gerrit-server/BUILD b/gerrit-server/BUILD
deleted file mode 100644
index e124e89..0000000
--- a/gerrit-server/BUILD
+++ /dev/null
@@ -1,351 +0,0 @@
-load("//lib/prolog:prolog.bzl", "prolog_cafe_library")
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-CONSTANTS_SRC = [
-    "src/main/java/com/google/gerrit/server/documentation/Constants.java",
-]
-
-GERRIT_GLOBAL_MODULE_SRC = [
-    "src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java",
-]
-
-# Non-recursive glob; dropwizard implementation is in a subpackage.
-METRICS_SRCS = glob(["src/main/java/com/google/gerrit/metrics/*.java"])
-
-RECEIVE_SRCS = glob(["src/main/java/com/google/gerrit/server/git/receive/**/*.java"])
-
-SRCS = glob(
-    ["src/main/java/**/*.java"],
-    exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC + METRICS_SRCS + RECEIVE_SRCS,
-)
-
-RESOURCES = glob(["src/main/resources/**/*"])
-
-java_library(
-    name = "constants",
-    srcs = CONSTANTS_SRC,
-    visibility = ["//visibility:public"],
-)
-
-prolog_cafe_library(
-    name = "prolog-common",
-    srcs = ["src/main/prolog/gerrit_common.pl"],
-    visibility = ["//visibility:public"],
-    deps = [":server"],
-)
-
-# Giant kitchen-sink target.
-#
-# The only reason this hasn't been split up further is because we have too many
-# tangled dependencies (and Guice unfortunately makes it quite easy to get into
-# this state). Which means if you see an opportunity to split something off, you
-# should seize it.
-java_library(
-    name = "server",
-    srcs = SRCS,
-    resources = RESOURCES,
-    visibility = ["//visibility:public"],
-    deps = [
-        ":constants",
-        ":metrics",
-        "//gerrit-common:annotations",
-        "//gerrit-common:server",
-        "//gerrit-extension-api:api",
-        "//gerrit-index:index",
-        "//gerrit-index:query_exception",
-        "//gerrit-patch-commonsnet:commons-net",
-        "//gerrit-patch-jgit:server",
-        "//gerrit-prettify:server",
-        "//gerrit-reviewdb:server",
-        "//gerrit-util-cli:cli",
-        "//gerrit-util-ssl:ssl",
-        "//lib:args4j",
-        "//lib:automaton",
-        "//lib:blame-cache",
-        "//lib:grappa",
-        "//lib:gson",
-        "//lib:guava",
-        "//lib:guava-retrying",
-        "//lib:gwtjsonrpc",
-        "//lib:gwtorm",
-        "//lib:jsch",
-        "//lib:juniversalchardet",
-        "//lib:mime-util",
-        "//lib:pegdown",
-        "//lib:protobuf",
-        "//lib:servlet-api-3_1",
-        "//lib:soy",
-        "//lib:tukaani-xz",
-        "//lib:velocity",
-        "//lib/auto:auto-value",
-        "//lib/bouncycastle:bcpkix-neverlink",
-        "//lib/bouncycastle:bcprov-neverlink",
-        "//lib/commons:codec",
-        "//lib/commons:compress",
-        "//lib/commons:dbcp",
-        "//lib/commons:lang",
-        "//lib/commons:net",
-        "//lib/commons:validator",
-        "//lib/dropwizard:dropwizard-core",
-        "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-        "//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",
-        "//lib/log:log4j",
-        "//lib/lucene:lucene-analyzers-common",
-        "//lib/lucene:lucene-core-and-backward-codecs",
-        "//lib/lucene:lucene-queryparser",
-        "//lib/mime4j:core",
-        "//lib/mime4j:dom",
-        "//lib/ow2:ow2-asm",
-        "//lib/ow2:ow2-asm-tree",
-        "//lib/ow2:ow2-asm-util",
-        "//lib/prolog:runtime",
-    ],
-)
-
-# Large modules that import things from all across the server package
-# hierarchy, so they need lots of dependencies.
-java_library(
-    name = "module",
-    srcs = GERRIT_GLOBAL_MODULE_SRC,
-    visibility = ["//visibility:public"],
-    deps = [
-        ":receive",
-        ":server",
-        "//gerrit-extension-api:api",
-        "//lib:blame-cache",
-        "//lib:guava",
-        "//lib:soy",
-        "//lib:velocity",
-        "//lib/guice",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-    ],
-)
-
-java_library(
-    name = "receive",
-    srcs = RECEIVE_SRCS,
-    visibility = ["//visibility:public"],
-    deps = [
-        ":server",
-        "//gerrit-common:annotations",
-        "//gerrit-common:server",
-        "//gerrit-extension-api:api",
-        "//gerrit-reviewdb:server",
-        "//gerrit-util-cli:cli",
-        "//lib:args4j",
-        "//lib:guava",
-        "//lib:gwtorm",
-        "//lib/auto:auto-value",
-        "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/log:api",
-    ],
-)
-
-# TODO(dborowitz): Move to a different top-level directory to avoid inbound
-# dependencies on gerrit-server.
-java_library(
-    name = "metrics",
-    srcs = METRICS_SRCS,
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-extension-api:api",
-        "//lib:guava",
-    ],
-)
-
-TESTUTIL_DEPS = [
-    ":metrics",
-    ":module",
-    ":server",
-    "//gerrit-common:annotations",
-    "//gerrit-common:server",
-    "//gerrit-cache-h2:cache-h2",
-    "//gerrit-extension-api:api",
-    "//gerrit-gpg:gpg",
-    "//gerrit-index:index",
-    "//gerrit-lucene:lucene",
-    "//gerrit-reviewdb:server",
-    "//lib:gwtorm",
-    "//lib:h2",
-    "//lib:truth",
-    "//lib/guice:guice",
-    "//lib/guice:guice-servlet",
-    "//lib/jgit/org.eclipse.jgit:jgit",
-    "//lib/jgit/org.eclipse.jgit.junit:junit",
-    "//lib/joda:joda-time",
-    "//lib/log:api",
-    "//lib/log:impl_log4j",
-    "//lib/log:log4j",
-]
-
-TESTUTIL = glob([
-    "src/test/java/com/google/gerrit/testutil/**/*.java",
-    "src/test/java/com/google/gerrit/server/project/Util.java",
-])
-
-java_library(
-    name = "testutil",
-    testonly = 1,
-    srcs = TESTUTIL,
-    visibility = ["//visibility:public"],
-    exports = [
-        "//lib/easymock",
-        "//lib/powermock:powermock-api-easymock",
-        "//lib/powermock:powermock-api-support",
-        "//lib/powermock:powermock-core",
-        "//lib/powermock:powermock-module-junit4",
-        "//lib/powermock:powermock-module-junit4-common",
-    ],
-    deps = TESTUTIL_DEPS + [
-        "//gerrit-pgm:init",
-        "//lib/auto:auto-value",
-        "//lib/easymock:easymock",
-        "//lib/powermock:powermock-api-easymock",
-        "//lib/powermock:powermock-api-support",
-        "//lib/powermock:powermock-core",
-        "//lib/powermock:powermock-module-junit4",
-        "//lib/powermock:powermock-module-junit4-common",
-    ],
-)
-
-CUSTOM_TRUTH_SUBJECTS = glob([
-    "src/test/java/com/google/gerrit/server/**/*Subject.java",
-])
-
-java_library(
-    name = "custom-truth-subjects",
-    testonly = 1,
-    srcs = CUSTOM_TRUTH_SUBJECTS,
-    deps = [
-        ":server",
-        "//gerrit-extension-api:api",
-        "//gerrit-test-util:test_util",
-        "//lib:truth",
-    ],
-)
-
-PROLOG_TEST_CASE = [
-    "src/test/java/com/google/gerrit/rules/PrologTestCase.java",
-]
-
-PROLOG_TESTS = glob(
-    ["src/test/java/com/google/gerrit/rules/**/*.java"],
-    exclude = PROLOG_TEST_CASE,
-)
-
-java_library(
-    name = "prolog_test_case",
-    testonly = 1,
-    srcs = PROLOG_TEST_CASE,
-    deps = [
-        ":server",
-        ":testutil",
-        "//gerrit-common:server",
-        "//gerrit-extension-api:api",
-        "//lib:guava",
-        "//lib:junit",
-        "//lib:truth",
-        "//lib/guice",
-        "//lib/prolog:runtime",
-    ],
-)
-
-junit_tests(
-    name = "prolog_tests",
-    srcs = PROLOG_TESTS,
-    resources = glob(["src/test/resources/com/google/gerrit/rules/**/*"]),
-    deps = TESTUTIL_DEPS + [
-        ":prolog-common",
-        ":prolog_test_case",
-        ":testutil",
-        "//lib/prolog:runtime",
-    ],
-)
-
-QUERY_TESTS = glob(
-    ["src/test/java/com/google/gerrit/server/query/**/*.java"],
-)
-
-java_library(
-    name = "query_tests_code",
-    testonly = 1,
-    srcs = QUERY_TESTS,
-    visibility = ["//visibility:public"],
-    deps = TESTUTIL_DEPS + [
-        ":prolog-common",
-        ":testutil",
-    ],
-)
-
-junit_tests(
-    name = "query_tests",
-    size = "large",
-    srcs = QUERY_TESTS,
-    visibility = ["//visibility:public"],
-    deps = TESTUTIL_DEPS + [
-        ":prolog-common",
-        ":testutil",
-    ],
-)
-
-junit_tests(
-    name = "server_tests",
-    size = "large",
-    srcs = glob(
-        ["src/test/java/**/*.java"],
-        exclude = TESTUTIL + CUSTOM_TRUTH_SUBJECTS + PROLOG_TESTS + PROLOG_TEST_CASE + QUERY_TESTS,
-    ),
-    resources = glob(["src/test/resources/com/google/gerrit/server/**/*"]),
-    visibility = ["//visibility:public"],
-    deps = TESTUTIL_DEPS + [
-        ":custom-truth-subjects",
-        ":prolog-common",
-        ":testutil",
-        "//gerrit-index:query_exception",
-        "//gerrit-patch-jgit:server",
-        "//gerrit-test-util:test_util",
-        "//lib:args4j",
-        "//lib:grappa",
-        "//lib:gson",
-        "//lib:guava",
-        "//lib:guava-retrying",
-        "//lib:protobuf",
-        "//lib:truth-java8-extension",
-        "//lib/bouncycastle:bcprov",
-        "//lib/bouncycastle:bcpkix",
-        "//lib/dropwizard:dropwizard-core",
-        "//lib/guice:guice-assistedinject",
-        "//lib/prolog:runtime",
-        "//lib/commons:codec",
-    ],
-)
-
-junit_tests(
-    name = "testutil_test",
-    size = "small",
-    srcs = [
-        "src/test/java/com/google/gerrit/testutil/IndexVersionsTest.java",
-    ],
-    visibility = ["//visibility:public"],
-    deps = TESTUTIL_DEPS + [
-        ":testutil",
-    ],
-)
-
-load("//tools/bzl:javadoc.bzl", "java_doc")
-
-java_doc(
-    name = "doc",
-    libs = [":server"],
-    pkgs = ["com.google.gerrit"],
-    title = "Gerrit Review Server Documentation",
-)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditEvent.java b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditEvent.java
deleted file mode 100644
index 7a4e683..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditEvent.java
+++ /dev/null
@@ -1,107 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.audit;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Preconditions;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.server.CurrentUser;
-
-public class AuditEvent {
-
-  public static final String UNKNOWN_SESSION_ID = "000000000000000000000000000";
-  protected static final ImmutableListMultimap<String, ?> EMPTY_PARAMS = ImmutableListMultimap.of();
-
-  public final String sessionId;
-  public final CurrentUser who;
-  public final long when;
-  public final String what;
-  public final ListMultimap<String, ?> params;
-  public final Object result;
-  public final long timeAtStart;
-  public final long elapsed;
-  public final UUID uuid;
-
-  @AutoValue
-  public abstract static class UUID {
-    private static UUID create() {
-      return new AutoValue_AuditEvent_UUID(
-          String.format("audit:%s", java.util.UUID.randomUUID().toString()));
-    }
-
-    public abstract String uuid();
-  }
-
-  /**
-   * Creates a new audit event with results
-   *
-   * @param sessionId session id the event belongs to
-   * @param who principal that has generated the event
-   * @param what object of the event
-   * @param when time-stamp of when the event started
-   * @param params parameters of the event
-   * @param result result of the event
-   */
-  public AuditEvent(
-      String sessionId,
-      CurrentUser who,
-      String what,
-      long when,
-      ListMultimap<String, ?> params,
-      Object result) {
-    Preconditions.checkNotNull(what, "what is a mandatory not null param !");
-
-    this.sessionId = MoreObjects.firstNonNull(sessionId, UNKNOWN_SESSION_ID);
-    this.who = who;
-    this.what = what;
-    this.when = when;
-    this.timeAtStart = this.when;
-    this.params = MoreObjects.firstNonNull(params, EMPTY_PARAMS);
-    this.uuid = UUID.create();
-    this.result = result;
-    this.elapsed = TimeUtil.nowMs() - timeAtStart;
-  }
-
-  @Override
-  public int hashCode() {
-    return uuid.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object obj) {
-    if (this == obj) {
-      return true;
-    }
-    if (obj == null) {
-      return false;
-    }
-    if (getClass() != obj.getClass()) {
-      return false;
-    }
-
-    AuditEvent other = (AuditEvent) obj;
-    return this.uuid.equals(other.uuid);
-  }
-
-  @Override
-  public String toString() {
-    return String.format(
-        "AuditEvent UUID:%s, SID:%s, TS:%d, who:%s, what:%s",
-        uuid.uuid(), sessionId, when, who, what);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditListener.java b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditListener.java
deleted file mode 100644
index 8eb8ed4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditListener.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.audit;
-
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
-@ExtensionPoint
-public interface AuditListener {
-
-  void onAuditableAction(AuditEvent action);
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditModule.java b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditModule.java
deleted file mode 100644
index aedb8a7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditModule.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.audit;
-
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.inject.AbstractModule;
-
-public class AuditModule extends AbstractModule {
-
-  @Override
-  protected void configure() {
-    DynamicSet.setOf(binder(), AuditListener.class);
-    DynamicSet.setOf(binder(), GroupMemberAuditListener.class);
-    bind(AuditService.class);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditService.java b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditService.java
deleted file mode 100644
index cc29559..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditService.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.audit;
-
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Collection;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class AuditService {
-  private static final Logger log = LoggerFactory.getLogger(AuditService.class);
-
-  private final DynamicSet<AuditListener> auditListeners;
-  private final DynamicSet<GroupMemberAuditListener> groupMemberAuditListeners;
-
-  @Inject
-  public AuditService(
-      DynamicSet<AuditListener> auditListeners,
-      DynamicSet<GroupMemberAuditListener> groupMemberAuditListeners) {
-    this.auditListeners = auditListeners;
-    this.groupMemberAuditListeners = groupMemberAuditListeners;
-  }
-
-  public void dispatch(AuditEvent action) {
-    for (AuditListener auditListener : auditListeners) {
-      auditListener.onAuditableAction(action);
-    }
-  }
-
-  public void dispatchAddAccountsToGroup(Account.Id actor, Collection<AccountGroupMember> added) {
-    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
-      try {
-        auditListener.onAddAccountsToGroup(actor, added);
-      } catch (RuntimeException e) {
-        log.error("failed to log add accounts to group event", e);
-      }
-    }
-  }
-
-  public void dispatchDeleteAccountsFromGroup(
-      Account.Id actor, Collection<AccountGroupMember> removed) {
-    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
-      try {
-        auditListener.onDeleteAccountsFromGroup(actor, removed);
-      } catch (RuntimeException e) {
-        log.error("failed to log delete accounts from group event", e);
-      }
-    }
-  }
-
-  public void dispatchAddGroupsToGroup(Account.Id actor, Collection<AccountGroupById> added) {
-    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
-      try {
-        auditListener.onAddGroupsToGroup(actor, added);
-      } catch (RuntimeException e) {
-        log.error("failed to log add groups to group event", e);
-      }
-    }
-  }
-
-  public void dispatchDeleteGroupsFromGroup(
-      Account.Id actor, Collection<AccountGroupById> removed) {
-    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
-      try {
-        auditListener.onDeleteGroupsFromGroup(actor, removed);
-      } catch (RuntimeException e) {
-        log.error("failed to log delete groups from group event", e);
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/ExtendedHttpAuditEvent.java b/gerrit-server/src/main/java/com/google/gerrit/audit/ExtendedHttpAuditEvent.java
deleted file mode 100644
index 4db8a51..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/ExtendedHttpAuditEvent.java
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.audit;
-
-import com.google.common.base.Preconditions;
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.CurrentUser;
-import javax.servlet.http.HttpServletRequest;
-
-/** Extended audit event. Adds request, resource and view data to HttpAuditEvent. */
-public class ExtendedHttpAuditEvent extends HttpAuditEvent {
-  public final HttpServletRequest httpRequest;
-  public final RestResource resource;
-  public final RestView<? extends RestResource> view;
-
-  /**
-   * Creates a new audit event with results
-   *
-   * @param sessionId session id the event belongs to
-   * @param who principal that has generated the event
-   * @param httpRequest the HttpServletRequest
-   * @param when time-stamp of when the event started
-   * @param params parameters of the event
-   * @param input input
-   * @param status HTTP status
-   * @param result result of the event
-   * @param resource REST resource data
-   * @param view view rendering object
-   */
-  public ExtendedHttpAuditEvent(
-      String sessionId,
-      CurrentUser who,
-      HttpServletRequest httpRequest,
-      long when,
-      ListMultimap<String, ?> params,
-      Object input,
-      int status,
-      Object result,
-      RestResource resource,
-      RestView<RestResource> view) {
-    super(
-        sessionId,
-        who,
-        httpRequest.getRequestURI(),
-        when,
-        params,
-        httpRequest.getMethod(),
-        input,
-        status,
-        result);
-    this.httpRequest = Preconditions.checkNotNull(httpRequest);
-    this.resource = resource;
-    this.view = view;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/GroupMemberAuditListener.java b/gerrit-server/src/main/java/com/google/gerrit/audit/GroupMemberAuditListener.java
deleted file mode 100644
index 0878499..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/GroupMemberAuditListener.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.audit;
-
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import java.util.Collection;
-
-@ExtensionPoint
-public interface GroupMemberAuditListener {
-
-  void onAddAccountsToGroup(Account.Id actor, Collection<AccountGroupMember> added);
-
-  void onDeleteAccountsFromGroup(Account.Id actor, Collection<AccountGroupMember> removed);
-
-  void onAddGroupsToGroup(Account.Id actor, Collection<AccountGroupById> added);
-
-  void onDeleteGroupsFromGroup(Account.Id actor, Collection<AccountGroupById> deleted);
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/HttpAuditEvent.java b/gerrit-server/src/main/java/com/google/gerrit/audit/HttpAuditEvent.java
deleted file mode 100644
index cd19606..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/HttpAuditEvent.java
+++ /dev/null
@@ -1,52 +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.audit;
-
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.server.CurrentUser;
-
-public class HttpAuditEvent extends AuditEvent {
-  public final String httpMethod;
-  public final int httpStatus;
-  public final Object input;
-
-  /**
-   * Creates a new audit event with results
-   *
-   * @param sessionId session id the event belongs to
-   * @param who principal that has generated the event
-   * @param what object of the event
-   * @param when time-stamp of when the event started
-   * @param params parameters of the event
-   * @param httpMethod HTTP method
-   * @param input input
-   * @param status HTTP status
-   * @param result result of the event
-   */
-  public HttpAuditEvent(
-      String sessionId,
-      CurrentUser who,
-      String what,
-      long when,
-      ListMultimap<String, ?> params,
-      String httpMethod,
-      Object input,
-      int status,
-      Object result) {
-    super(sessionId, who, what, when, params, result);
-    this.httpMethod = httpMethod;
-    this.input = input;
-    this.httpStatus = status;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/RpcAuditEvent.java b/gerrit-server/src/main/java/com/google/gerrit/audit/RpcAuditEvent.java
deleted file mode 100644
index f6b955c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/RpcAuditEvent.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.audit;
-
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.server.CurrentUser;
-
-public class RpcAuditEvent extends HttpAuditEvent {
-
-  /**
-   * Creates a new audit event with results
-   *
-   * @param sessionId session id the event belongs to
-   * @param who principal that has generated the event
-   * @param what object of the event
-   * @param when time-stamp of when the event started
-   * @param params parameters of the event
-   * @param httpMethod HTTP method
-   * @param input input
-   * @param status HTTP status
-   * @param result result of the event
-   */
-  public RpcAuditEvent(
-      String sessionId,
-      CurrentUser who,
-      String what,
-      long when,
-      ListMultimap<String, ?> params,
-      String httpMethod,
-      Object input,
-      int status,
-      Object result) {
-    super(sessionId, who, what, when, params, httpMethod, input, status, result);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/SshAuditEvent.java b/gerrit-server/src/main/java/com/google/gerrit/audit/SshAuditEvent.java
deleted file mode 100644
index 98cba09..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/SshAuditEvent.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.audit;
-
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.server.CurrentUser;
-
-public class SshAuditEvent extends AuditEvent {
-
-  public SshAuditEvent(
-      String sessionId,
-      CurrentUser who,
-      String what,
-      long when,
-      ListMultimap<String, ?> params,
-      Object result) {
-    super(sessionId, who, what, when, params, result);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
deleted file mode 100644
index c58b723..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
+++ /dev/null
@@ -1,213 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common;
-
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.events.ChangeEvent;
-import com.google.gerrit.server.events.Event;
-import com.google.gerrit.server.events.ProjectEvent;
-import com.google.gerrit.server.events.RefEvent;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Distributes Events to listeners if they are allowed to see them */
-@Singleton
-public class EventBroker implements EventDispatcher {
-  private static final Logger log = LoggerFactory.getLogger(EventBroker.class);
-
-  public static class Module extends LifecycleModule {
-    @Override
-    protected void configure() {
-      DynamicItem.itemOf(binder(), EventDispatcher.class);
-      DynamicItem.bind(binder(), EventDispatcher.class).to(EventBroker.class);
-    }
-  }
-
-  /** Listeners to receive changes as they happen (limited by visibility of user). */
-  protected final DynamicSet<UserScopedEventListener> listeners;
-
-  /** Listeners to receive all changes as they happen. */
-  protected final DynamicSet<EventListener> unrestrictedListeners;
-
-  private final PermissionBackend permissionBackend;
-  protected final ProjectCache projectCache;
-
-  protected final ChangeNotes.Factory notesFactory;
-
-  protected final Provider<ReviewDb> dbProvider;
-
-  @Inject
-  public EventBroker(
-      DynamicSet<UserScopedEventListener> listeners,
-      DynamicSet<EventListener> unrestrictedListeners,
-      PermissionBackend permissionBackend,
-      ProjectCache projectCache,
-      ChangeNotes.Factory notesFactory,
-      Provider<ReviewDb> dbProvider) {
-    this.listeners = listeners;
-    this.unrestrictedListeners = unrestrictedListeners;
-    this.permissionBackend = permissionBackend;
-    this.projectCache = projectCache;
-    this.notesFactory = notesFactory;
-    this.dbProvider = dbProvider;
-  }
-
-  @Override
-  public void postEvent(Change change, ChangeEvent event)
-      throws OrmException, PermissionBackendException {
-    fireEvent(change, event);
-  }
-
-  @Override
-  public void postEvent(Branch.NameKey branchName, RefEvent event)
-      throws PermissionBackendException {
-    fireEvent(branchName, event);
-  }
-
-  @Override
-  public void postEvent(Project.NameKey projectName, ProjectEvent event) {
-    fireEvent(projectName, event);
-  }
-
-  @Override
-  public void postEvent(Event event) throws OrmException, PermissionBackendException {
-    fireEvent(event);
-  }
-
-  protected void fireEventForUnrestrictedListeners(Event event) {
-    for (EventListener listener : unrestrictedListeners) {
-      listener.onEvent(event);
-    }
-  }
-
-  protected void fireEvent(Change change, ChangeEvent event)
-      throws OrmException, PermissionBackendException {
-    for (UserScopedEventListener listener : listeners) {
-      if (isVisibleTo(change, listener.getUser())) {
-        listener.onEvent(event);
-      }
-    }
-    fireEventForUnrestrictedListeners(event);
-  }
-
-  protected void fireEvent(Project.NameKey project, ProjectEvent event) {
-    for (UserScopedEventListener listener : listeners) {
-      if (isVisibleTo(project, listener.getUser())) {
-        listener.onEvent(event);
-      }
-    }
-    fireEventForUnrestrictedListeners(event);
-  }
-
-  protected void fireEvent(Branch.NameKey branchName, RefEvent event)
-      throws PermissionBackendException {
-    for (UserScopedEventListener listener : listeners) {
-      if (isVisibleTo(branchName, listener.getUser())) {
-        listener.onEvent(event);
-      }
-    }
-    fireEventForUnrestrictedListeners(event);
-  }
-
-  protected void fireEvent(Event event) throws OrmException, PermissionBackendException {
-    for (UserScopedEventListener listener : listeners) {
-      if (isVisibleTo(event, listener.getUser())) {
-        listener.onEvent(event);
-      }
-    }
-    fireEventForUnrestrictedListeners(event);
-  }
-
-  protected boolean isVisibleTo(Project.NameKey project, CurrentUser user) {
-    try {
-      permissionBackend.user(user).project(project).check(ProjectPermission.ACCESS);
-      return true;
-    } catch (AuthException | PermissionBackendException e) {
-      return false;
-    }
-  }
-
-  protected boolean isVisibleTo(Change change, CurrentUser user)
-      throws OrmException, PermissionBackendException {
-    if (change == null) {
-      return false;
-    }
-    ProjectState pe = projectCache.get(change.getProject());
-    if (pe == null) {
-      return false;
-    }
-    ReviewDb db = dbProvider.get();
-    return permissionBackend
-        .user(user)
-        .change(notesFactory.createChecked(db, change))
-        .database(db)
-        .test(ChangePermission.READ);
-  }
-
-  protected boolean isVisibleTo(Branch.NameKey branchName, CurrentUser user)
-      throws PermissionBackendException {
-    ProjectState pe = projectCache.get(branchName.getParentKey());
-    if (pe == null) {
-      return false;
-    }
-    return permissionBackend.user(user).ref(branchName).test(RefPermission.READ);
-  }
-
-  protected boolean isVisibleTo(Event event, CurrentUser user)
-      throws OrmException, PermissionBackendException {
-    if (event instanceof RefEvent) {
-      RefEvent refEvent = (RefEvent) event;
-      String ref = refEvent.getRefName();
-      if (PatchSet.isChangeRef(ref)) {
-        Change.Id cid = PatchSet.Id.fromRef(ref).getParentKey();
-        try {
-          Change change =
-              notesFactory
-                  .createChecked(dbProvider.get(), refEvent.getProjectNameKey(), cid)
-                  .getChange();
-          return isVisibleTo(change, user);
-        } catch (NoSuchChangeException e) {
-          log.debug("Change {} cannot be found, falling back on ref visibility check", cid.id);
-        }
-      }
-      return isVisibleTo(refEvent.getBranchNameKey(), user);
-    } else if (event instanceof ProjectEvent) {
-      return isVisibleTo(((ProjectEvent) event).getProjectNameKey(), user);
-    }
-    return true;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
deleted file mode 100644
index bfc7973..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common;
-
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.events.ChangeEvent;
-import com.google.gerrit.server.events.Event;
-import com.google.gerrit.server.events.ProjectEvent;
-import com.google.gerrit.server.events.RefEvent;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-
-/** Interface for posting (dispatching) Events */
-public interface EventDispatcher {
-  /**
-   * Post a stream event that is related to a change
-   *
-   * @param change The change that the event is related to
-   * @param event The event to post
-   * @throws OrmException on failure to post the event due to DB error
-   * @throws PermissionBackendException on failure of permission checks
-   */
-  void postEvent(Change change, ChangeEvent event) throws OrmException, PermissionBackendException;
-
-  /**
-   * Post a stream event that is related to a branch
-   *
-   * @param branchName The branch that the event is related to
-   * @param event The event to post
-   * @throws PermissionBackendException on failure of permission checks
-   */
-  void postEvent(Branch.NameKey branchName, RefEvent event) throws PermissionBackendException;
-
-  /**
-   * Post a stream event that is related to a project.
-   *
-   * @param projectName The project that the event is related to.
-   * @param event The event to post.
-   */
-  void postEvent(Project.NameKey projectName, ProjectEvent event);
-
-  /**
-   * Post a stream event generically.
-   *
-   * <p>If you are creating a RefEvent or ChangeEvent from scratch, it is more efficient to use the
-   * specific postEvent methods for those use cases.
-   *
-   * @param event The event to post.
-   * @throws OrmException on failure to post the event due to DB error
-   * @throws PermissionBackendException on failure of permission checks
-   */
-  void postEvent(Event event) throws OrmException, PermissionBackendException;
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java
deleted file mode 100644
index 6cfc5eb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common;
-
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.server.events.Event;
-
-/**
- * Allows to listen to events without user visibility restrictions. To listen to events visible to a
- * specific user, use {@link UserScopedEventListener}.
- */
-@ExtensionPoint
-public interface EventListener {
-  void onEvent(Event event);
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/FooterConstants.java b/gerrit-server/src/main/java/com/google/gerrit/common/FooterConstants.java
deleted file mode 100644
index 3ec809c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/common/FooterConstants.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common;
-
-import org.eclipse.jgit.revwalk.FooterKey;
-
-public class FooterConstants {
-  /** The change ID as used to track patch sets. */
-  public static final FooterKey CHANGE_ID = new FooterKey("Change-Id");
-
-  /** The footer telling us who reviewed the change. */
-  public static final FooterKey REVIEWED_BY = new FooterKey("Reviewed-by");
-
-  /** The footer telling us the URL where the review took place. */
-  public static final FooterKey REVIEWED_ON = new FooterKey("Reviewed-on");
-
-  /** The footer telling us who tested the change. */
-  public static final FooterKey TESTED_BY = new FooterKey("Tested-by");
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/UserScopedEventListener.java b/gerrit-server/src/main/java/com/google/gerrit/common/UserScopedEventListener.java
deleted file mode 100644
index 3216bac..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/common/UserScopedEventListener.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.google.gerrit.common;
-
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.server.CurrentUser;
-
-/**
- * Allows to listen to events visible to the specified user. To listen to events without user
- * visibility restrictions, use {@link EventListener}.
- */
-@ExtensionPoint
-public interface UserScopedEventListener extends EventListener {
-  CurrentUser getUser();
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/Version.java b/gerrit-server/src/main/java/com/google/gerrit/common/Version.java
deleted file mode 100644
index f5a63af..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/common/Version.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class Version {
-  private static final Logger log = LoggerFactory.getLogger(Version.class);
-  private static final String version;
-
-  public static String getVersion() {
-    return version;
-  }
-
-  static {
-    version = loadVersion();
-  }
-
-  private static String loadVersion() {
-    try (InputStream in = Version.class.getResourceAsStream("Version")) {
-      if (in == null) {
-        return "(dev)";
-      }
-      try (BufferedReader r = new BufferedReader(new InputStreamReader(in, UTF_8))) {
-        String vs = r.readLine();
-        if (vs != null && vs.startsWith("v")) {
-          vs = vs.substring(1);
-        }
-        if (vs != null && vs.isEmpty()) {
-          vs = null;
-        }
-        return vs;
-      }
-    } catch (IOException e) {
-      log.error(e.getMessage(), e);
-      return "(unknown version)";
-    }
-  }
-
-  private Version() {}
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateClassLoader.java b/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateClassLoader.java
deleted file mode 100644
index 3478694..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateClassLoader.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.rules;
-
-import com.google.common.collect.LinkedHashMultimap;
-import com.google.common.collect.SetMultimap;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import java.util.Collection;
-
-/** Loads the classes for Prolog predicates. */
-public class PredicateClassLoader extends ClassLoader {
-
-  private final SetMultimap<String, ClassLoader> packageClassLoaderMap =
-      LinkedHashMultimap.create();
-
-  public PredicateClassLoader(
-      final DynamicSet<PredicateProvider> predicateProviders, ClassLoader parent) {
-    super(parent);
-
-    for (PredicateProvider predicateProvider : predicateProviders) {
-      for (String pkg : predicateProvider.getPackages()) {
-        packageClassLoaderMap.put(pkg, predicateProvider.getClass().getClassLoader());
-      }
-    }
-  }
-
-  @Override
-  protected Class<?> findClass(String className) throws ClassNotFoundException {
-    final Collection<ClassLoader> classLoaders =
-        packageClassLoaderMap.get(getPackageName(className));
-    for (ClassLoader cl : classLoaders) {
-      try {
-        return Class.forName(className, true, cl);
-      } catch (ClassNotFoundException e) {
-        // ignore
-      }
-    }
-    throw new ClassNotFoundException(className);
-  }
-
-  private static String getPackageName(String className) {
-    final int pos = className.lastIndexOf('.');
-    if (pos < 0) {
-      return "";
-    }
-    return className.substring(0, pos);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java b/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java
deleted file mode 100644
index c64bc92..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.google.gerrit.rules;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.googlecode.prolog_cafe.lang.Predicate;
-
-/**
- * Provides additional packages that contain Prolog predicates that should be made available in the
- * Prolog environment. The predicates can e.g. be used in the project submit rules.
- *
- * <p>Each Java class defining a Prolog predicate must be in one of the provided packages and its
- * name must apply to the 'PRED_[functor]_[arity]' format. In addition it must extend {@link
- * Predicate}.
- */
-@ExtensionPoint
-public interface PredicateProvider {
-  /** Return set of packages that contain Prolog predicates */
-  ImmutableSet<String> getPackages();
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java b/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
deleted file mode 100644
index 36cb4cc..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
+++ /dev/null
@@ -1,244 +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.rules;
-
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.assistedinject.Assisted;
-import com.googlecode.prolog_cafe.lang.BufferingPrologControl;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.PredicateEncoder;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Per-thread Prolog interpreter.
- *
- * <p>This class is not thread safe.
- *
- * <p>A single copy of the Prolog interpreter, for the current thread.
- */
-public class PrologEnvironment extends BufferingPrologControl {
-  private static final Logger log = LoggerFactory.getLogger(PrologEnvironment.class);
-
-  public interface Factory {
-    /**
-     * Construct a new Prolog interpreter.
-     *
-     * @param src the machine to template the new environment from.
-     * @return the new interpreter.
-     */
-    PrologEnvironment create(PrologMachineCopy src);
-  }
-
-  private final Args args;
-  private final Map<StoredValue<Object>, Object> storedValues;
-  private List<Runnable> cleanup;
-
-  @Inject
-  PrologEnvironment(Args a, @Assisted PrologMachineCopy src) {
-    super(src);
-    setEnabled(EnumSet.allOf(Prolog.Feature.class), false);
-    args = a;
-    storedValues = new HashMap<>();
-    cleanup = new LinkedList<>();
-  }
-
-  public Args getArgs() {
-    return args;
-  }
-
-  @Override
-  public void setPredicate(Predicate goal) {
-    super.setPredicate(goal);
-    setReductionLimit(args.reductionLimit(goal));
-  }
-
-  /**
-   * Lookup a stored value in the interpreter's hash manager.
-   *
-   * @param <T> type of stored Java object.
-   * @param sv unique key.
-   * @return the value; null if not stored.
-   */
-  @SuppressWarnings("unchecked")
-  public <T> T get(StoredValue<T> sv) {
-    return (T) storedValues.get(sv);
-  }
-
-  /**
-   * Set a stored value on the interpreter's hash manager.
-   *
-   * @param <T> type of stored Java object.
-   * @param sv unique key.
-   * @param obj the value to store under {@code sv}.
-   */
-  @SuppressWarnings("unchecked")
-  public <T> void set(StoredValue<T> sv, T obj) {
-    storedValues.put((StoredValue<Object>) sv, obj);
-  }
-
-  /**
-   * Copy the stored values from another interpreter to this one. Also gets the cleanup from the
-   * child interpreter
-   */
-  public void copyStoredValues(PrologEnvironment child) {
-    storedValues.putAll(child.storedValues);
-    setCleanup(child.cleanup);
-  }
-
-  /**
-   * Assign the environment a cleanup list (in order to use a centralized list) If this
-   * enivronment's list is non-empty, append its cleanup tasks to the assigning list.
-   */
-  public void setCleanup(List<Runnable> newCleanupList) {
-    newCleanupList.addAll(cleanup);
-    cleanup = newCleanupList;
-  }
-
-  /**
-   * Adds cleanup task to run when close() is called
-   *
-   * @param task is run when close() is called
-   */
-  public void addToCleanup(Runnable task) {
-    cleanup.add(task);
-  }
-
-  /** Release resources stored in interpreter's hash manager. */
-  public void close() {
-    for (Iterator<Runnable> i = cleanup.iterator(); i.hasNext(); ) {
-      try {
-        i.next().run();
-      } catch (Throwable err) {
-        log.error("Failed to execute cleanup for PrologEnvironment", err);
-      }
-      i.remove();
-    }
-  }
-
-  @Singleton
-  public static class Args {
-    private static final Class<Predicate> CONSULT_STREAM_2;
-
-    static {
-      try {
-        @SuppressWarnings("unchecked")
-        Class<Predicate> c =
-            (Class<Predicate>)
-                Class.forName(
-                    PredicateEncoder.encode(Prolog.BUILTIN, "consult_stream", 2),
-                    false,
-                    RulesCache.class.getClassLoader());
-        CONSULT_STREAM_2 = c;
-      } catch (ClassNotFoundException e) {
-        throw new LinkageError("cannot find predicate consult_stream", e);
-      }
-    }
-
-    private final ProjectCache projectCache;
-    private final PermissionBackend permissionBackend;
-    private final GitRepositoryManager repositoryManager;
-    private final PatchListCache patchListCache;
-    private final PatchSetInfoFactory patchSetInfoFactory;
-    private final IdentifiedUser.GenericFactory userFactory;
-    private final Provider<AnonymousUser> anonymousUser;
-    private final int reductionLimit;
-    private final int compileLimit;
-
-    @Inject
-    Args(
-        ProjectCache projectCache,
-        PermissionBackend permissionBackend,
-        GitRepositoryManager repositoryManager,
-        PatchListCache patchListCache,
-        PatchSetInfoFactory patchSetInfoFactory,
-        IdentifiedUser.GenericFactory userFactory,
-        Provider<AnonymousUser> anonymousUser,
-        @GerritServerConfig Config config) {
-      this.projectCache = projectCache;
-      this.permissionBackend = permissionBackend;
-      this.repositoryManager = repositoryManager;
-      this.patchListCache = patchListCache;
-      this.patchSetInfoFactory = patchSetInfoFactory;
-      this.userFactory = userFactory;
-      this.anonymousUser = anonymousUser;
-
-      int limit = config.getInt("rules", null, "reductionLimit", 100000);
-      reductionLimit = limit <= 0 ? Integer.MAX_VALUE : limit;
-
-      limit =
-          config.getInt(
-              "rules",
-              null,
-              "compileReductionLimit",
-              (int) Math.min(10L * limit, Integer.MAX_VALUE));
-      compileLimit = limit <= 0 ? Integer.MAX_VALUE : limit;
-    }
-
-    private int reductionLimit(Predicate goal) {
-      if (goal.getClass() == CONSULT_STREAM_2) {
-        return compileLimit;
-      }
-      return reductionLimit;
-    }
-
-    public ProjectCache getProjectCache() {
-      return projectCache;
-    }
-
-    public PermissionBackend getPermissionBackend() {
-      return permissionBackend;
-    }
-
-    public GitRepositoryManager getGitRepositoryManager() {
-      return repositoryManager;
-    }
-
-    public PatchListCache getPatchListCache() {
-      return patchListCache;
-    }
-
-    public PatchSetInfoFactory getPatchSetInfoFactory() {
-      return patchSetInfoFactory;
-    }
-
-    public IdentifiedUser.GenericFactory getUserFactory() {
-      return userFactory;
-    }
-
-    public AnonymousUser getAnonymousUser() {
-      return anonymousUser.get();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/PrologModule.java b/gerrit-server/src/main/java/com/google/gerrit/rules/PrologModule.java
deleted file mode 100644
index 7ed048b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/PrologModule.java
+++ /dev/null
@@ -1,34 +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.rules;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.extensions.registration.DynamicSet;
-
-public class PrologModule extends FactoryModule {
-  @Override
-  protected void configure() {
-    install(new EnvironmentModule());
-    bind(PrologEnvironment.Args.class);
-  }
-
-  static class EnvironmentModule extends FactoryModule {
-    @Override
-    protected void configure() {
-      DynamicSet.setOf(binder(), PredicateProvider.class);
-      factory(PrologEnvironment.Factory.class);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java b/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java
deleted file mode 100644
index 9ab0dd6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java
+++ /dev/null
@@ -1,293 +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.rules;
-
-import static com.googlecode.prolog_cafe.lang.PrologMachineCopy.save;
-
-import com.google.common.base.Joiner;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import com.googlecode.prolog_cafe.exceptions.CompileException;
-import com.googlecode.prolog_cafe.exceptions.SyntaxException;
-import com.googlecode.prolog_cafe.exceptions.TermException;
-import com.googlecode.prolog_cafe.lang.BufferingPrologControl;
-import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
-import com.googlecode.prolog_cafe.lang.ListTerm;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.PrologClassLoader;
-import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
-import com.googlecode.prolog_cafe.lang.StructureTerm;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-import java.io.IOException;
-import java.io.PushbackReader;
-import java.io.Reader;
-import java.io.StringReader;
-import java.lang.ref.Reference;
-import java.lang.ref.ReferenceQueue;
-import java.lang.ref.WeakReference;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.net.URLClassLoader;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.errors.LargeObjectException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.util.RawParseUtils;
-
-/**
- * Manages a cache of compiled Prolog rules.
- *
- * <p>Rules are loaded from the {@code site_path/cache/rules/rules-SHA1.jar}, where {@code SHA1} is
- * the SHA1 of the Prolog {@code rules.pl} in a project's {@link RefNames#REFS_CONFIG} branch.
- */
-@Singleton
-public class RulesCache {
-  private static final ImmutableList<String> PACKAGE_LIST =
-      ImmutableList.of(Prolog.BUILTIN, "gerrit");
-
-  private static final class MachineRef extends WeakReference<PrologMachineCopy> {
-    final ObjectId key;
-
-    MachineRef(ObjectId key, PrologMachineCopy pcm, ReferenceQueue<PrologMachineCopy> queue) {
-      super(pcm, queue);
-      this.key = key;
-    }
-  }
-
-  private final boolean enableProjectRules;
-  private final int maxDbSize;
-  private final int maxSrcBytes;
-  private final Path cacheDir;
-  private final Path rulesDir;
-  private final GitRepositoryManager gitMgr;
-  private final DynamicSet<PredicateProvider> predicateProviders;
-  private final ClassLoader systemLoader;
-  private final PrologMachineCopy defaultMachine;
-  private final Map<ObjectId, MachineRef> machineCache = new HashMap<>();
-  private final ReferenceQueue<PrologMachineCopy> dead = new ReferenceQueue<>();
-
-  @Inject
-  protected RulesCache(
-      @GerritServerConfig Config config,
-      SitePaths site,
-      GitRepositoryManager gm,
-      DynamicSet<PredicateProvider> predicateProviders) {
-    maxDbSize = config.getInt("rules", null, "maxPrologDatabaseSize", 256);
-    maxSrcBytes = config.getInt("rules", null, "maxSourceBytes", 128 << 10);
-    enableProjectRules = config.getBoolean("rules", null, "enable", true) && maxSrcBytes > 0;
-    cacheDir = site.resolve(config.getString("cache", null, "directory"));
-    rulesDir = cacheDir != null ? cacheDir.resolve("rules") : null;
-    gitMgr = gm;
-    this.predicateProviders = predicateProviders;
-
-    systemLoader = getClass().getClassLoader();
-    defaultMachine = save(newEmptyMachine(systemLoader));
-  }
-
-  public boolean isProjectRulesEnabled() {
-    return enableProjectRules;
-  }
-
-  /**
-   * Locate a cached Prolog machine state, or create one if not available.
-   *
-   * @return a Prolog machine, after loading the specified rules.
-   * @throws CompileException the machine cannot be created.
-   */
-  public synchronized PrologMachineCopy loadMachine(Project.NameKey project, ObjectId rulesId)
-      throws CompileException {
-    if (!enableProjectRules || project == null || rulesId == null) {
-      return defaultMachine;
-    }
-
-    Reference<? extends PrologMachineCopy> ref = machineCache.get(rulesId);
-    if (ref != null) {
-      PrologMachineCopy pmc = ref.get();
-      if (pmc != null) {
-        return pmc;
-      }
-
-      machineCache.remove(rulesId);
-      ref.enqueue();
-    }
-
-    gc();
-
-    PrologMachineCopy pcm = createMachine(project, rulesId);
-    MachineRef newRef = new MachineRef(rulesId, pcm, dead);
-    machineCache.put(rulesId, newRef);
-    return pcm;
-  }
-
-  public PrologMachineCopy loadMachine(String name, Reader in) throws CompileException {
-    PrologMachineCopy pmc = consultRules(name, in);
-    if (pmc == null) {
-      throw new CompileException("Cannot consult rules from the stream " + name);
-    }
-    return pmc;
-  }
-
-  private void gc() {
-    Reference<?> ref;
-    while ((ref = dead.poll()) != null) {
-      ObjectId key = ((MachineRef) ref).key;
-      if (machineCache.get(key) == ref) {
-        machineCache.remove(key);
-      }
-    }
-  }
-
-  private PrologMachineCopy createMachine(Project.NameKey project, ObjectId rulesId)
-      throws CompileException {
-    // If the rules are available as a complied JAR on local disk, prefer
-    // that over dynamic consult as the bytecode will be faster.
-    //
-    if (rulesDir != null) {
-      Path jarPath = rulesDir.resolve("rules-" + rulesId.getName() + ".jar");
-      if (Files.isRegularFile(jarPath)) {
-        URL[] cp = new URL[] {toURL(jarPath)};
-        return save(newEmptyMachine(new URLClassLoader(cp, systemLoader)));
-      }
-    }
-
-    // Dynamically consult the rules into the machine's internal database.
-    //
-    String rules = read(project, rulesId);
-    PrologMachineCopy pmc = consultRules("rules.pl", new StringReader(rules));
-    if (pmc == null) {
-      throw new CompileException("Cannot consult rules of " + project);
-    }
-    return pmc;
-  }
-
-  private PrologMachineCopy consultRules(String name, Reader rules) throws CompileException {
-    BufferingPrologControl ctl = newEmptyMachine(systemLoader);
-    PushbackReader in = new PushbackReader(rules, Prolog.PUSHBACK_SIZE);
-    try {
-      if (!ctl.execute(
-          Prolog.BUILTIN, "consult_stream", SymbolTerm.intern(name), new JavaObjectTerm(in))) {
-        return null;
-      }
-    } catch (SyntaxException e) {
-      throw new CompileException(e.toString(), e);
-    } catch (TermException e) {
-      Term m = e.getMessageTerm();
-      if (m instanceof StructureTerm && "syntax_error".equals(m.name()) && m.arity() >= 1) {
-        StringBuilder msg = new StringBuilder();
-        if (m.arg(0) instanceof ListTerm) {
-          msg.append(Joiner.on(' ').join(((ListTerm) m.arg(0)).toJava()));
-        } else {
-          msg.append(m.arg(0).toString());
-        }
-        if (m.arity() == 2 && m.arg(1) instanceof StructureTerm && "at".equals(m.arg(1).name())) {
-          Term at = m.arg(1).arg(0).dereference();
-          if (at instanceof ListTerm) {
-            msg.append(" at: ");
-            msg.append(prettyProlog(at));
-          }
-        }
-        throw new CompileException(msg.toString(), e);
-      }
-      throw new CompileException("Error while consulting rules from " + name, e);
-    } catch (RuntimeException e) {
-      throw new CompileException("Error while consulting rules from " + name, e);
-    }
-    return save(ctl);
-  }
-
-  private static String prettyProlog(Term at) {
-    StringBuilder b = new StringBuilder();
-    for (Object o : ((ListTerm) at).toJava()) {
-      if (o instanceof Term) {
-        Term t = (Term) o;
-        if (!(t instanceof StructureTerm)) {
-          b.append(t.toString()).append(' ');
-          continue;
-        }
-        switch (t.name()) {
-          case "atom":
-            SymbolTerm atom = (SymbolTerm) t.arg(0);
-            b.append(atom.toString());
-            break;
-          case "var":
-            b.append(t.arg(0).toString());
-            break;
-        }
-      } else {
-        b.append(o);
-      }
-    }
-    return b.toString().trim();
-  }
-
-  private String read(Project.NameKey project, ObjectId rulesId) throws CompileException {
-    try (Repository git = gitMgr.openRepository(project)) {
-      try {
-        ObjectLoader ldr = git.open(rulesId, Constants.OBJ_BLOB);
-        byte[] raw = ldr.getCachedBytes(maxSrcBytes);
-        return RawParseUtils.decode(raw);
-      } catch (LargeObjectException e) {
-        throw new CompileException("rules of " + project + " are too large", e);
-      } catch (RuntimeException | IOException e) {
-        throw new CompileException("Cannot load rules of " + project, e);
-      }
-    } catch (IOException e) {
-      throw new CompileException("Cannot open repository " + project, e);
-    }
-  }
-
-  private BufferingPrologControl newEmptyMachine(ClassLoader cl) {
-    BufferingPrologControl ctl = new BufferingPrologControl();
-    ctl.setMaxDatabaseSize(maxDbSize);
-    ctl.setPrologClassLoader(
-        new PrologClassLoader(new PredicateClassLoader(predicateProviders, cl)));
-    ctl.setEnabled(EnumSet.allOf(Prolog.Feature.class), false);
-
-    List<String> packages = new ArrayList<>();
-    packages.addAll(PACKAGE_LIST);
-    for (PredicateProvider predicateProvider : predicateProviders) {
-      packages.addAll(predicateProvider.getPackages());
-    }
-
-    // Bootstrap the interpreter and ensure there is clean state.
-    ctl.initialize(packages.toArray(new String[packages.size()]));
-    return ctl;
-  }
-
-  private static URL toURL(Path jarPath) throws CompileException {
-    try {
-      return jarPath.toUri().toURL();
-    } catch (MalformedURLException e) {
-      throw new CompileException("Cannot create URL for " + jarPath, e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValue.java b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValue.java
deleted file mode 100644
index 461f3ab..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValue.java
+++ /dev/null
@@ -1,94 +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.rules;
-
-import com.googlecode.prolog_cafe.exceptions.SystemException;
-import com.googlecode.prolog_cafe.lang.Prolog;
-
-/**
- * Defines a value cached in a {@link PrologEnvironment}.
- *
- * @see StoredValues
- */
-public class StoredValue<T> {
-  /** Construct a new unique key that does not match any other key. */
-  public static <T> StoredValue<T> create() {
-    return new StoredValue<>();
-  }
-
-  /** Construct a key based on a Java Class object, useful for singletons. */
-  public static <T> StoredValue<T> create(Class<T> clazz) {
-    return new StoredValue<>(clazz);
-  }
-
-  private final Object key;
-
-  /**
-   * Initialize a stored value key using any Java Object.
-   *
-   * @param key unique identity of the stored value. This will be the hash key in the Prolog
-   *     Environments's hash map.
-   */
-  public StoredValue(Object key) {
-    this.key = key;
-  }
-
-  /** Initializes a stored value key with a new unique key. */
-  public StoredValue() {
-    key = this;
-  }
-
-  /** Look up the value in the engine, or return null. */
-  public T getOrNull(Prolog engine) {
-    return get((PrologEnvironment) engine.control);
-  }
-  /** Get the value from the engine, or throw SystemException. */
-  public T get(Prolog engine) {
-    T obj = getOrNull(engine);
-    if (obj == null) {
-      //unless createValue() is overridden, will return null
-      obj = createValue(engine);
-      if (obj == null) {
-        throw new SystemException("No " + key + " available");
-      }
-      set(engine, obj);
-    }
-    return obj;
-  }
-
-  public void set(Prolog engine, T obj) {
-    set((PrologEnvironment) engine.control, obj);
-  }
-
-  /** Perform {@link #getOrNull(Prolog)} on the environment's interpreter. */
-  public T get(PrologEnvironment env) {
-    return env.get(this);
-  }
-
-  /** Set the value into the environment's interpreter. */
-  public void set(PrologEnvironment env, T obj) {
-    env.set(this, obj);
-  }
-
-  /**
-   * Creates a value to store, returns null by default.
-   *
-   * @param engine Prolog engine.
-   * @return new value.
-   */
-  protected T createValue(Prolog engine) {
-    return null;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
deleted file mode 100644
index 89fedda..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
+++ /dev/null
@@ -1,162 +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.rules;
-
-import static com.google.gerrit.rules.StoredValue.create;
-
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.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.Accounts;
-import com.google.gerrit.server.account.Emails;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListKey;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.googlecode.prolog_cafe.exceptions.SystemException;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-
-public final class StoredValues {
-  public static final StoredValue<Accounts> ACCOUNTS = create(Accounts.class);
-  public static final StoredValue<AccountCache> ACCOUNT_CACHE = create(AccountCache.class);
-  public static final StoredValue<Emails> EMAILS = create(Emails.class);
-  public static final StoredValue<ReviewDb> REVIEW_DB = create(ReviewDb.class);
-  public static final StoredValue<ChangeData> CHANGE_DATA = create(ChangeData.class);
-  public static final StoredValue<CurrentUser> CURRENT_USER = create(CurrentUser.class);
-  public static final StoredValue<ProjectState> PROJECT_STATE = create(ProjectState.class);
-
-  public static Change getChange(Prolog engine) throws SystemException {
-    ChangeData cd = CHANGE_DATA.get(engine);
-    try {
-      return cd.change();
-    } catch (OrmException e) {
-      throw new SystemException("Cannot load change " + cd.getId());
-    }
-  }
-
-  public static PatchSet getPatchSet(Prolog engine) throws SystemException {
-    ChangeData cd = CHANGE_DATA.get(engine);
-    try {
-      return cd.currentPatchSet();
-    } catch (OrmException e) {
-      throw new SystemException(e.getMessage());
-    }
-  }
-
-  public static final StoredValue<PatchSetInfo> PATCH_SET_INFO =
-      new StoredValue<PatchSetInfo>() {
-        @Override
-        public PatchSetInfo createValue(Prolog engine) {
-          Change change = getChange(engine);
-          PatchSet ps = getPatchSet(engine);
-          PrologEnvironment env = (PrologEnvironment) engine.control;
-          PatchSetInfoFactory patchInfoFactory = env.getArgs().getPatchSetInfoFactory();
-          try {
-            return patchInfoFactory.get(change.getProject(), ps);
-          } catch (PatchSetInfoNotAvailableException e) {
-            throw new SystemException(e.getMessage());
-          }
-        }
-      };
-
-  public static final StoredValue<PatchList> PATCH_LIST =
-      new StoredValue<PatchList>() {
-        @Override
-        public PatchList createValue(Prolog engine) {
-          PrologEnvironment env = (PrologEnvironment) engine.control;
-          PatchSet ps = getPatchSet(engine);
-          PatchListCache plCache = env.getArgs().getPatchListCache();
-          Change change = getChange(engine);
-          Project.NameKey project = change.getProject();
-          ObjectId b = ObjectId.fromString(ps.getRevision().get());
-          Whitespace ws = Whitespace.IGNORE_NONE;
-          PatchListKey plKey = PatchListKey.againstDefaultBase(b, ws);
-          PatchList patchList;
-          try {
-            patchList = plCache.get(plKey, project);
-          } catch (PatchListNotAvailableException e) {
-            throw new SystemException("Cannot create " + plKey);
-          }
-          return patchList;
-        }
-      };
-
-  public static final StoredValue<Repository> REPOSITORY =
-      new StoredValue<Repository>() {
-        @Override
-        public Repository createValue(Prolog engine) {
-          PrologEnvironment env = (PrologEnvironment) engine.control;
-          GitRepositoryManager gitMgr = env.getArgs().getGitRepositoryManager();
-          Change change = getChange(engine);
-          Project.NameKey projectKey = change.getProject();
-          Repository repo;
-          try {
-            repo = gitMgr.openRepository(projectKey);
-          } catch (IOException e) {
-            throw new SystemException(e.getMessage());
-          }
-          env.addToCleanup(repo::close);
-          return repo;
-        }
-      };
-
-  public static final StoredValue<PermissionBackend> PERMISSION_BACKEND =
-      new StoredValue<PermissionBackend>() {
-        @Override
-        protected PermissionBackend createValue(Prolog engine) {
-          PrologEnvironment env = (PrologEnvironment) engine.control;
-          return env.getArgs().getPermissionBackend();
-        }
-      };
-
-  public static final StoredValue<AnonymousUser> ANONYMOUS_USER =
-      new StoredValue<AnonymousUser>() {
-        @Override
-        protected AnonymousUser createValue(Prolog engine) {
-          PrologEnvironment env = (PrologEnvironment) engine.control;
-          return env.getArgs().getAnonymousUser();
-        }
-      };
-
-  public static final StoredValue<Map<Account.Id, IdentifiedUser>> USERS =
-      new StoredValue<Map<Account.Id, IdentifiedUser>>() {
-        @Override
-        protected Map<Account.Id, IdentifiedUser> createValue(Prolog engine) {
-          return new HashMap<>();
-        }
-      };
-
-  private StoredValues() {}
-}
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
deleted file mode 100644
index 6971b48..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
+++ /dev/null
@@ -1,265 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.common.collect.HashBasedTable;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Table;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.extensions.client.ChangeKind;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.change.ChangeKindCache;
-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;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.TreeMap;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * Copies approvals between patch sets.
- *
- * <p>The result of a copy may either be stored, as when stamping approvals in the database at
- * submit time, or refreshed on demand, as when reading approvals from the NoteDb.
- */
-@Singleton
-public class ApprovalCopier {
-  private final ProjectCache projectCache;
-  private final ChangeKindCache changeKindCache;
-  private final LabelNormalizer labelNormalizer;
-  private final ChangeData.Factory changeDataFactory;
-  private final PatchSetUtil psUtil;
-
-  @Inject
-  ApprovalCopier(
-      ProjectCache projectCache,
-      ChangeKindCache changeKindCache,
-      LabelNormalizer labelNormalizer,
-      ChangeData.Factory changeDataFactory,
-      PatchSetUtil psUtil) {
-    this.projectCache = projectCache;
-    this.changeKindCache = changeKindCache;
-    this.labelNormalizer = labelNormalizer;
-    this.changeDataFactory = changeDataFactory;
-    this.psUtil = psUtil;
-  }
-
-  /**
-   * Apply approval copy settings from prior PatchSets to a new PatchSet.
-   *
-   * @param db review database.
-   * @param notes change notes for user uploading PatchSet
-   * @param user user uploading PatchSet
-   * @param ps new PatchSet
-   * @param rw open walk that can read the patch set commit; null to open the repo on demand.
-   * @param repoConfig repo config used for change kind detection; null to read from repo on demand.
-   * @throws OrmException
-   */
-  public void copyInReviewDb(
-      ReviewDb db,
-      ChangeNotes notes,
-      CurrentUser user,
-      PatchSet ps,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig)
-      throws OrmException {
-    copyInReviewDb(db, notes, user, ps, rw, repoConfig, Collections.emptyList());
-  }
-
-  /**
-   * Apply approval copy settings from prior PatchSets to a new PatchSet.
-   *
-   * @param db review database.
-   * @param notes change notes for user uploading PatchSet
-   * @param user user uploading PatchSet
-   * @param ps new PatchSet
-   * @param rw open walk that can read the patch set commit; null to open the repo on demand.
-   * @param repoConfig repo config used for change kind detection; null to read from repo on demand.
-   * @param dontCopy PatchSetApprovals indicating which (account, label) pairs should not be copied
-   * @throws OrmException
-   */
-  public void copyInReviewDb(
-      ReviewDb db,
-      ChangeNotes notes,
-      CurrentUser user,
-      PatchSet ps,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig,
-      Iterable<PatchSetApproval> dontCopy)
-      throws OrmException {
-    if (PrimaryStorage.of(notes.getChange()) == PrimaryStorage.REVIEW_DB) {
-      db.patchSetApprovals().insert(getForPatchSet(db, notes, user, ps, rw, repoConfig, dontCopy));
-    }
-  }
-
-  Iterable<PatchSetApproval> getForPatchSet(
-      ReviewDb db,
-      ChangeNotes notes,
-      CurrentUser user,
-      PatchSet.Id psId,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig)
-      throws OrmException {
-    return getForPatchSet(
-        db, notes, user, psId, rw, repoConfig, Collections.<PatchSetApproval>emptyList());
-  }
-
-  Iterable<PatchSetApproval> getForPatchSet(
-      ReviewDb db,
-      ChangeNotes notes,
-      CurrentUser user,
-      PatchSet.Id psId,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig,
-      Iterable<PatchSetApproval> dontCopy)
-      throws OrmException {
-    PatchSet ps = psUtil.get(db, notes, psId);
-    if (ps == null) {
-      return Collections.emptyList();
-    }
-    return getForPatchSet(db, notes, user, ps, rw, repoConfig, dontCopy);
-  }
-
-  private Iterable<PatchSetApproval> getForPatchSet(
-      ReviewDb db,
-      ChangeNotes notes,
-      CurrentUser user,
-      PatchSet ps,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig,
-      Iterable<PatchSetApproval> dontCopy)
-      throws OrmException {
-    checkNotNull(ps, "ps should not be null");
-    ChangeData cd = changeDataFactory.create(db, notes);
-    try {
-      ProjectState project = projectCache.checkedGet(cd.change().getDest().getParentKey());
-      ListMultimap<PatchSet.Id, PatchSetApproval> all = cd.approvals();
-      checkNotNull(all, "all should not be null");
-
-      Table<String, Account.Id, PatchSetApproval> wontCopy = HashBasedTable.create();
-      for (PatchSetApproval psa : dontCopy) {
-        wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
-      }
-
-      Table<String, Account.Id, PatchSetApproval> byUser = HashBasedTable.create();
-      for (PatchSetApproval psa : all.get(ps.getId())) {
-        if (!wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
-          byUser.put(psa.getLabel(), psa.getAccountId(), psa);
-        }
-      }
-
-      TreeMap<Integer, PatchSet> patchSets = getPatchSets(cd);
-
-      // Walk patch sets strictly less than current in descending order.
-      Collection<PatchSet> allPrior =
-          patchSets.descendingMap().tailMap(ps.getId().get(), false).values();
-      for (PatchSet priorPs : allPrior) {
-        List<PatchSetApproval> priorApprovals = all.get(priorPs.getId());
-        if (priorApprovals.isEmpty()) {
-          continue;
-        }
-
-        ChangeKind kind =
-            changeKindCache.getChangeKind(
-                project.getNameKey(),
-                rw,
-                repoConfig,
-                ObjectId.fromString(priorPs.getRevision().get()),
-                ObjectId.fromString(ps.getRevision().get()));
-
-        for (PatchSetApproval psa : priorApprovals) {
-          if (wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
-            continue;
-          }
-          if (byUser.contains(psa.getLabel(), psa.getAccountId())) {
-            continue;
-          }
-          if (!canCopy(project, psa, ps.getId(), kind)) {
-            wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
-            continue;
-          }
-          byUser.put(psa.getLabel(), psa.getAccountId(), copy(psa, ps.getId()));
-        }
-      }
-      return labelNormalizer.normalize(notes, user, byUser.values()).getNormalized();
-    } catch (IOException | PermissionBackendException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  private static TreeMap<Integer, PatchSet> getPatchSets(ChangeData cd) throws OrmException {
-    Collection<PatchSet> patchSets = cd.patchSets();
-    TreeMap<Integer, PatchSet> result = new TreeMap<>();
-    for (PatchSet ps : patchSets) {
-      result.put(ps.getId().get(), ps);
-    }
-    return result;
-  }
-
-  private static boolean canCopy(
-      ProjectState project, PatchSetApproval psa, PatchSet.Id psId, ChangeKind kind) {
-    int n = psa.getKey().getParentKey().get();
-    checkArgument(n != psId.get());
-    LabelType type = project.getLabelTypes().byLabel(psa.getLabelId());
-    if (type == null) {
-      return false;
-    } else if ((type.isCopyMinScore() && type.isMaxNegative(psa))
-        || (type.isCopyMaxScore() && type.isMaxPositive(psa))) {
-      return true;
-    }
-    switch (kind) {
-      case MERGE_FIRST_PARENT_UPDATE:
-        return type.isCopyAllScoresOnMergeFirstParentUpdate();
-      case NO_CODE_CHANGE:
-        return type.isCopyAllScoresIfNoCodeChange();
-      case TRIVIAL_REBASE:
-        return type.isCopyAllScoresOnTrivialRebase();
-      case NO_CHANGE:
-        return type.isCopyAllScoresIfNoChange()
-            || type.isCopyAllScoresOnTrivialRebase()
-            || type.isCopyAllScoresOnMergeFirstParentUpdate()
-            || type.isCopyAllScoresIfNoCodeChange();
-      case REWORK:
-      default:
-        return false;
-    }
-  }
-
-  private static PatchSetApproval copy(PatchSetApproval src, PatchSet.Id psId) {
-    if (src.getKey().getParentKey().equals(psId)) {
-      return src;
-    }
-    return new PatchSetApproval(psId, src);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
deleted file mode 100644
index 0b0a855..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
+++ /dev/null
@@ -1,196 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import com.google.common.base.Throwables;
-import com.google.common.cache.Cache;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Sets;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.change.ChangeTriplet;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.name.Named;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-
-@Singleton
-public class ChangeFinder {
-  private static final String CACHE_NAME = "changeid_project";
-
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        cache(CACHE_NAME, Change.Id.class, String.class).maximumWeight(1024);
-      }
-    };
-  }
-
-  private final IndexConfig indexConfig;
-  private final Cache<Change.Id, String> changeIdProjectCache;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final Provider<ReviewDb> reviewDb;
-  private final ChangeNotes.Factory changeNotesFactory;
-
-  @Inject
-  ChangeFinder(
-      IndexConfig indexConfig,
-      @Named(CACHE_NAME) Cache<Change.Id, String> changeIdProjectCache,
-      Provider<InternalChangeQuery> queryProvider,
-      Provider<ReviewDb> reviewDb,
-      ChangeNotes.Factory changeNotesFactory) {
-    this.indexConfig = indexConfig;
-    this.changeIdProjectCache = changeIdProjectCache;
-    this.queryProvider = queryProvider;
-    this.reviewDb = reviewDb;
-    this.changeNotesFactory = changeNotesFactory;
-  }
-
-  /**
-   * Find changes matching the given identifier.
-   *
-   * @param id change identifier, either a numeric ID, a Change-Id, or project~branch~id triplet.
-   * @return possibly-empty list of notes for all matching changes; may or may not be visible.
-   * @throws OrmException if an error occurred querying the database.
-   */
-  public List<ChangeNotes> find(String id) throws OrmException {
-    if (id.isEmpty()) {
-      return Collections.emptyList();
-    }
-
-    int z = id.lastIndexOf('~');
-    int y = id.lastIndexOf('~', z - 1);
-    if (y < 0 && z > 0) {
-      // Try project~numericChangeId
-      Integer n = Ints.tryParse(id.substring(z + 1));
-      if (n != null) {
-        return fromProjectNumber(id.substring(0, z), n.intValue());
-      }
-    }
-
-    if (y < 0 && z < 0) {
-      // Try numeric changeId
-      Integer n = Ints.tryParse(id);
-      if (n != null) {
-        return find(new Change.Id(n));
-      }
-    }
-
-    // Use the index to search for changes, but don't return any stored fields,
-    // to force rereading in case the index is stale.
-    InternalChangeQuery query = queryProvider.get().noFields();
-
-    // Try commit hash
-    if (id.matches("^([0-9a-fA-F]{" + RevId.ABBREV_LEN + "," + RevId.LEN + "})$")) {
-      return asChangeNotes(query.byCommit(id));
-    }
-
-    if (y > 0 && z > 0) {
-      // Try change triplet (project~branch~Ihash...)
-      Optional<ChangeTriplet> triplet = ChangeTriplet.parse(id, y, z);
-      if (triplet.isPresent()) {
-        ChangeTriplet t = triplet.get();
-        return asChangeNotes(query.byBranchKey(t.branch(), t.id()));
-      }
-    }
-
-    // Try isolated Ihash... format ("Change-Id: Ihash").
-    return asChangeNotes(query.byKeyPrefix(id));
-  }
-
-  private List<ChangeNotes> fromProjectNumber(String project, int changeNumber)
-      throws OrmException {
-    Change.Id cId = new Change.Id(changeNumber);
-    try {
-      return ImmutableList.of(
-          changeNotesFactory.createChecked(reviewDb.get(), Project.NameKey.parse(project), cId));
-    } catch (NoSuchChangeException e) {
-      return Collections.emptyList();
-    } catch (OrmException e) {
-      // Distinguish between a RepositoryNotFoundException (project argument invalid) and
-      // other OrmExceptions (failure in the persistence layer).
-      if (Throwables.getRootCause(e) instanceof RepositoryNotFoundException) {
-        return Collections.emptyList();
-      }
-      throw e;
-    }
-  }
-
-  public ChangeNotes findOne(Change.Id id) throws OrmException {
-    List<ChangeNotes> notes = find(id);
-    if (notes.size() != 1) {
-      throw new NoSuchChangeException(id);
-    }
-    return notes.get(0);
-  }
-
-  public List<ChangeNotes> find(Change.Id id) throws OrmException {
-    String project = changeIdProjectCache.getIfPresent(id);
-    if (project != null) {
-      return fromProjectNumber(project, id.get());
-    }
-
-    // Use the index to search for changes, but don't return any stored fields,
-    // to force rereading in case the index is stale.
-    InternalChangeQuery query = queryProvider.get().noFields();
-    List<ChangeData> r = query.byLegacyChangeId(id);
-    if (r.size() == 1) {
-      changeIdProjectCache.put(id, r.get(0).project().get());
-    }
-    return asChangeNotes(r);
-  }
-
-  private List<ChangeNotes> asChangeNotes(List<ChangeData> cds) throws OrmException {
-    List<ChangeNotes> notes = new ArrayList<>(cds.size());
-    if (!indexConfig.separateChangeSubIndexes()) {
-      for (ChangeData cd : cds) {
-        notes.add(cd.notes());
-      }
-      return notes;
-    }
-
-    // If an index implementation uses separate non-atomic subindexes, it's possible to temporarily
-    // observe a change as present in both subindexes, if this search is concurrent with a write.
-    // Dedup to avoid confusing the caller. We can choose an arbitrary ChangeData instance because
-    // the index results have no stored fields, so the data is already reloaded. (It's also possible
-    // that a change might appear in zero subindexes, but there's nothing we can do here to help
-    // this case.)
-    Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(cds.size());
-    for (ChangeData cd : cds) {
-      if (seen.add(cd.getId())) {
-        notes.add(cd.notes());
-      }
-    }
-    return notes;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CmdLineParserModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/CmdLineParserModule.java
deleted file mode 100644
index 63f7202..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CmdLineParserModule.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.args4j.AccountGroupIdHandler;
-import com.google.gerrit.server.args4j.AccountGroupUUIDHandler;
-import com.google.gerrit.server.args4j.AccountIdHandler;
-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.SocketAddressHandler;
-import com.google.gerrit.server.args4j.TimestampHandler;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.util.cli.CmdLineParser;
-import com.google.gerrit.util.cli.OptionHandlerUtil;
-import com.google.gerrit.util.cli.OptionHandlers;
-import java.net.SocketAddress;
-import java.sql.Timestamp;
-import org.eclipse.jgit.lib.ObjectId;
-import org.kohsuke.args4j.spi.OptionHandler;
-
-public class CmdLineParserModule extends FactoryModule {
-  public CmdLineParserModule() {}
-
-  @Override
-  protected void configure() {
-    factory(CmdLineParser.Factory.class);
-    bind(OptionHandlers.class);
-
-    registerOptionHandler(Account.Id.class, AccountIdHandler.class);
-    registerOptionHandler(AccountGroup.Id.class, AccountGroupIdHandler.class);
-    registerOptionHandler(AccountGroup.UUID.class, AccountGroupUUIDHandler.class);
-    registerOptionHandler(Change.Id.class, ChangeIdHandler.class);
-    registerOptionHandler(ObjectId.class, ObjectIdHandler.class);
-    registerOptionHandler(PatchSet.Id.class, PatchSetIdHandler.class);
-    registerOptionHandler(ProjectControl.class, ProjectControlHandler.class);
-    registerOptionHandler(SocketAddress.class, SocketAddressHandler.class);
-    registerOptionHandler(Timestamp.class, TimestampHandler.class);
-  }
-
-  private <T> void registerOptionHandler(Class<T> type, Class<? extends OptionHandler<T>> impl) {
-    install(OptionHandlerUtil.moduleFor(type, impl));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
deleted file mode 100644
index 9e2a9ea..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
+++ /dev/null
@@ -1,517 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupMembership;
-import com.google.gerrit.server.account.ListGroupMembership;
-import com.google.gerrit.server.account.Realm;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.DisableReverseDnsLookup;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.inject.Inject;
-import com.google.inject.OutOfScopeException;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import com.google.inject.util.Providers;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
-import java.net.MalformedURLException;
-import java.net.SocketAddress;
-import java.net.URL;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Set;
-import java.util.TimeZone;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.util.SystemReader;
-
-/** An authenticated user. */
-public class IdentifiedUser extends CurrentUser {
-  /** Create an IdentifiedUser, ignoring any per-request state. */
-  @Singleton
-  public static class GenericFactory {
-    private final AuthConfig authConfig;
-    private final Realm realm;
-    private final String anonymousCowardName;
-    private final Provider<String> canonicalUrl;
-    private final AccountCache accountCache;
-    private final GroupBackend groupBackend;
-    private final Boolean disableReverseDnsLookup;
-
-    @Inject
-    public GenericFactory(
-        AuthConfig authConfig,
-        Realm realm,
-        @AnonymousCowardName String anonymousCowardName,
-        @CanonicalWebUrl Provider<String> canonicalUrl,
-        @DisableReverseDnsLookup Boolean disableReverseDnsLookup,
-        AccountCache accountCache,
-        GroupBackend groupBackend) {
-      this.authConfig = authConfig;
-      this.realm = realm;
-      this.anonymousCowardName = anonymousCowardName;
-      this.canonicalUrl = canonicalUrl;
-      this.accountCache = accountCache;
-      this.groupBackend = groupBackend;
-      this.disableReverseDnsLookup = disableReverseDnsLookup;
-    }
-
-    public IdentifiedUser create(AccountState state) {
-      return new IdentifiedUser(
-          authConfig,
-          realm,
-          anonymousCowardName,
-          canonicalUrl,
-          accountCache,
-          groupBackend,
-          disableReverseDnsLookup,
-          Providers.of((SocketAddress) null),
-          state,
-          null);
-    }
-
-    public IdentifiedUser create(Account.Id id) {
-      return create((SocketAddress) null, id);
-    }
-
-    public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
-      return runAs(remotePeer, id, null);
-    }
-
-    public IdentifiedUser runAs(
-        SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
-      return new IdentifiedUser(
-          authConfig,
-          realm,
-          anonymousCowardName,
-          canonicalUrl,
-          accountCache,
-          groupBackend,
-          disableReverseDnsLookup,
-          Providers.of(remotePeer),
-          id,
-          caller);
-    }
-  }
-
-  /**
-   * Create an IdentifiedUser, relying on current request state.
-   *
-   * <p>Can only be used from within a module that has defined request scoped {@code @RemotePeer
-   * SocketAddress} and {@code ReviewDb} providers.
-   */
-  @Singleton
-  public static class RequestFactory {
-    private final AuthConfig authConfig;
-    private final Realm realm;
-    private final String anonymousCowardName;
-    private final Provider<String> canonicalUrl;
-    private final AccountCache accountCache;
-    private final GroupBackend groupBackend;
-    private final Boolean disableReverseDnsLookup;
-    private final Provider<SocketAddress> remotePeerProvider;
-
-    @Inject
-    RequestFactory(
-        AuthConfig authConfig,
-        Realm realm,
-        @AnonymousCowardName String anonymousCowardName,
-        @CanonicalWebUrl Provider<String> canonicalUrl,
-        AccountCache accountCache,
-        GroupBackend groupBackend,
-        @DisableReverseDnsLookup Boolean disableReverseDnsLookup,
-        @RemotePeer Provider<SocketAddress> remotePeerProvider) {
-      this.authConfig = authConfig;
-      this.realm = realm;
-      this.anonymousCowardName = anonymousCowardName;
-      this.canonicalUrl = canonicalUrl;
-      this.accountCache = accountCache;
-      this.groupBackend = groupBackend;
-      this.disableReverseDnsLookup = disableReverseDnsLookup;
-      this.remotePeerProvider = remotePeerProvider;
-    }
-
-    public IdentifiedUser create(Account.Id id) {
-      return new IdentifiedUser(
-          authConfig,
-          realm,
-          anonymousCowardName,
-          canonicalUrl,
-          accountCache,
-          groupBackend,
-          disableReverseDnsLookup,
-          remotePeerProvider,
-          id,
-          null);
-    }
-
-    public IdentifiedUser runAs(Account.Id id, CurrentUser caller) {
-      return new IdentifiedUser(
-          authConfig,
-          realm,
-          anonymousCowardName,
-          canonicalUrl,
-          accountCache,
-          groupBackend,
-          disableReverseDnsLookup,
-          remotePeerProvider,
-          id,
-          caller);
-    }
-  }
-
-  private static final GroupMembership registeredGroups =
-      new ListGroupMembership(
-          ImmutableSet.of(SystemGroupBackend.ANONYMOUS_USERS, SystemGroupBackend.REGISTERED_USERS));
-
-  private final Provider<String> canonicalUrl;
-  private final AccountCache accountCache;
-  private final AuthConfig authConfig;
-  private final Realm realm;
-  private final GroupBackend groupBackend;
-  private final String anonymousCowardName;
-  private final Boolean disableReverseDnsLookup;
-  private final Set<String> validEmails = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
-
-  private final Provider<SocketAddress> remotePeerProvider;
-  private final Account.Id accountId;
-
-  private AccountState state;
-  private boolean loadedAllEmails;
-  private Set<String> invalidEmails;
-  private GroupMembership effectiveGroups;
-  private CurrentUser realUser;
-  private Map<PropertyKey<Object>, Object> properties;
-
-  private IdentifiedUser(
-      AuthConfig authConfig,
-      Realm realm,
-      String anonymousCowardName,
-      Provider<String> canonicalUrl,
-      AccountCache accountCache,
-      GroupBackend groupBackend,
-      Boolean disableReverseDnsLookup,
-      @Nullable Provider<SocketAddress> remotePeerProvider,
-      AccountState state,
-      @Nullable CurrentUser realUser) {
-    this(
-        authConfig,
-        realm,
-        anonymousCowardName,
-        canonicalUrl,
-        accountCache,
-        groupBackend,
-        disableReverseDnsLookup,
-        remotePeerProvider,
-        state.getAccount().getId(),
-        realUser);
-    this.state = state;
-  }
-
-  private IdentifiedUser(
-      AuthConfig authConfig,
-      Realm realm,
-      String anonymousCowardName,
-      Provider<String> canonicalUrl,
-      AccountCache accountCache,
-      GroupBackend groupBackend,
-      Boolean disableReverseDnsLookup,
-      @Nullable Provider<SocketAddress> remotePeerProvider,
-      Account.Id id,
-      @Nullable CurrentUser realUser) {
-    this.canonicalUrl = canonicalUrl;
-    this.accountCache = accountCache;
-    this.groupBackend = groupBackend;
-    this.authConfig = authConfig;
-    this.realm = realm;
-    this.anonymousCowardName = anonymousCowardName;
-    this.disableReverseDnsLookup = disableReverseDnsLookup;
-    this.remotePeerProvider = remotePeerProvider;
-    this.accountId = id;
-    this.realUser = realUser != null ? realUser : this;
-  }
-
-  @Override
-  public CurrentUser getRealUser() {
-    return realUser;
-  }
-
-  @Override
-  public boolean isImpersonating() {
-    if (realUser == this) {
-      return false;
-    }
-    if (realUser.isIdentifiedUser()) {
-      if (realUser.getAccountId().equals(getAccountId())) {
-        // Impersonating another copy of this user is allowed.
-        return false;
-      }
-    }
-    return true;
-  }
-
-  public AccountState state() {
-    if (state == null) {
-      state = accountCache.get(getAccountId());
-    }
-    return state;
-  }
-
-  @Override
-  public IdentifiedUser asIdentifiedUser() {
-    return this;
-  }
-
-  @Override
-  public Account.Id getAccountId() {
-    return accountId;
-  }
-
-  /** @return the user's user name; null if one has not been selected/assigned. */
-  @Override
-  public String getUserName() {
-    return state().getUserName();
-  }
-
-  public Account getAccount() {
-    return state().getAccount();
-  }
-
-  public boolean hasEmailAddress(String email) {
-    if (validEmails.contains(email)) {
-      return true;
-    } else if (invalidEmails != null && invalidEmails.contains(email)) {
-      return false;
-    } else if (realm.hasEmailAddress(this, email)) {
-      validEmails.add(email);
-      return true;
-    } else if (invalidEmails == null) {
-      invalidEmails = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
-    }
-    invalidEmails.add(email);
-    return false;
-  }
-
-  public Set<String> getEmailAddresses() {
-    if (!loadedAllEmails) {
-      validEmails.addAll(realm.getEmailAddresses(this));
-      loadedAllEmails = true;
-    }
-    return validEmails;
-  }
-
-  public String getName() {
-    return getAccount().getName(anonymousCowardName);
-  }
-
-  public String getNameEmail() {
-    return getAccount().getNameEmail(anonymousCowardName);
-  }
-
-  @Override
-  public GroupMembership getEffectiveGroups() {
-    if (effectiveGroups == null) {
-      if (authConfig.isIdentityTrustable(state().getExternalIds())) {
-        effectiveGroups = groupBackend.membershipsOf(this);
-      } else {
-        effectiveGroups = registeredGroups;
-      }
-    }
-    return effectiveGroups;
-  }
-
-  public PersonIdent newRefLogIdent() {
-    return newRefLogIdent(new Date(), TimeZone.getDefault());
-  }
-
-  public PersonIdent newRefLogIdent(Date when, TimeZone tz) {
-    final Account ua = getAccount();
-
-    String name = ua.getFullName();
-    if (name == null || name.isEmpty()) {
-      name = ua.getPreferredEmail();
-    }
-    if (name == null || name.isEmpty()) {
-      name = anonymousCowardName;
-    }
-
-    String user = getUserName();
-    if (user == null) {
-      user = "";
-    }
-    user = user + "|account-" + ua.getId().toString();
-
-    return new PersonIdent(name, user + "@" + guessHost(), when, tz);
-  }
-
-  public PersonIdent newCommitterIdent(Date when, TimeZone tz) {
-    final Account ua = getAccount();
-    String name = ua.getFullName();
-    String email = ua.getPreferredEmail();
-
-    if (email == null || email.isEmpty()) {
-      // No preferred email is configured. Use a generic identity so we
-      // don't leak an address the user may have given us, but doesn't
-      // necessarily want to publish through Git records.
-      //
-      String user = getUserName();
-      if (user == null || user.isEmpty()) {
-        user = "account-" + ua.getId().toString();
-      }
-
-      String host;
-      if (canonicalUrl.get() != null) {
-        try {
-          host = new URL(canonicalUrl.get()).getHost();
-        } catch (MalformedURLException e) {
-          host = SystemReader.getInstance().getHostname();
-        }
-      } else {
-        host = SystemReader.getInstance().getHostname();
-      }
-
-      email = user + "@" + host;
-    }
-
-    if (name == null || name.isEmpty()) {
-      final int at = email.indexOf('@');
-      if (0 < at) {
-        name = email.substring(0, at);
-      } else {
-        name = anonymousCowardName;
-      }
-    }
-
-    return new PersonIdent(name, email, when, tz);
-  }
-
-  @Override
-  public String toString() {
-    return "IdentifiedUser[account " + getAccountId() + "]";
-  }
-
-  /** Check if user is the IdentifiedUser */
-  @Override
-  public boolean isIdentifiedUser() {
-    return true;
-  }
-
-  @Override
-  @Nullable
-  public synchronized <T> T get(PropertyKey<T> key) {
-    if (properties != null) {
-      @SuppressWarnings("unchecked")
-      T value = (T) properties.get(key);
-      return value;
-    }
-    return null;
-  }
-
-  /**
-   * Store a property for later retrieval.
-   *
-   * @param key unique property key.
-   * @param value value to store; or {@code null} to clear the value.
-   */
-  @Override
-  public synchronized <T> void put(PropertyKey<T> key, @Nullable T value) {
-    if (properties == null) {
-      if (value == null) {
-        return;
-      }
-      properties = new HashMap<>();
-    }
-
-    @SuppressWarnings("unchecked")
-    PropertyKey<Object> k = (PropertyKey<Object>) key;
-    if (value != null) {
-      properties.put(k, value);
-    } else {
-      properties.remove(k);
-    }
-  }
-
-  /**
-   * Returns a materialized copy of the user with all dependencies.
-   *
-   * <p>Invoke all providers and factories of dependent objects and store the references to a copy
-   * of the current identified user.
-   *
-   * @return copy of the identified user
-   */
-  public IdentifiedUser materializedCopy() {
-    Provider<SocketAddress> remotePeer;
-    try {
-      remotePeer = Providers.of(remotePeerProvider.get());
-    } catch (OutOfScopeException | ProvisionException e) {
-      remotePeer =
-          new Provider<SocketAddress>() {
-            @Override
-            public SocketAddress get() {
-              throw e;
-            }
-          };
-    }
-    return new IdentifiedUser(
-        authConfig,
-        realm,
-        anonymousCowardName,
-        Providers.of(canonicalUrl.get()),
-        accountCache,
-        groupBackend,
-        disableReverseDnsLookup,
-        remotePeer,
-        state,
-        realUser);
-  }
-
-  private String guessHost() {
-    String host = null;
-    SocketAddress remotePeer = null;
-    try {
-      remotePeer = remotePeerProvider.get();
-    } catch (OutOfScopeException | ProvisionException e) {
-      // Leave null.
-    }
-    if (remotePeer instanceof InetSocketAddress) {
-      InetSocketAddress sa = (InetSocketAddress) remotePeer;
-      InetAddress in = sa.getAddress();
-      host = in != null ? getHost(in) : sa.getHostName();
-    }
-    if (Strings.isNullOrEmpty(host)) {
-      return "unknown";
-    }
-    return host;
-  }
-
-  private String getHost(InetAddress in) {
-    if (Boolean.FALSE.equals(disableReverseDnsLookup)) {
-      return in.getCanonicalHostName();
-    }
-    return in.getHostAddress();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
deleted file mode 100644
index 2427d30..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
+++ /dev/null
@@ -1,289 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.AccountDirectory.FillOptions;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.change.ReviewerSuggestion;
-import com.google.gerrit.server.change.SuggestReviewers;
-import com.google.gerrit.server.change.SuggestedReviewer;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import java.util.stream.Stream;
-import org.apache.commons.lang.mutable.MutableDouble;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ReviewerRecommender {
-  private static final Logger log = LoggerFactory.getLogger(ReviewerRecommender.class);
-  private static final double BASE_REVIEWER_WEIGHT = 10;
-  private static final double BASE_OWNER_WEIGHT = 1;
-  private static final double BASE_COMMENT_WEIGHT = 0.5;
-  private static final double[] WEIGHTS =
-      new double[] {
-        BASE_REVIEWER_WEIGHT, BASE_OWNER_WEIGHT, BASE_COMMENT_WEIGHT,
-      };
-  private static final long PLUGIN_QUERY_TIMEOUT = 500; // ms
-
-  private final ChangeQueryBuilder changeQueryBuilder;
-  private final Config config;
-  private final DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final WorkQueue workQueue;
-  private final Provider<ReviewDb> dbProvider;
-  private final ApprovalsUtil approvalsUtil;
-
-  @Inject
-  ReviewerRecommender(
-      ChangeQueryBuilder changeQueryBuilder,
-      DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap,
-      Provider<InternalChangeQuery> queryProvider,
-      WorkQueue workQueue,
-      Provider<ReviewDb> dbProvider,
-      ApprovalsUtil approvalsUtil,
-      @GerritServerConfig Config config) {
-    Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS);
-    fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
-    this.changeQueryBuilder = changeQueryBuilder;
-    this.config = config;
-    this.queryProvider = queryProvider;
-    this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap;
-    this.workQueue = workQueue;
-    this.dbProvider = dbProvider;
-    this.approvalsUtil = approvalsUtil;
-  }
-
-  public List<Account.Id> suggestReviewers(
-      ChangeNotes changeNotes,
-      SuggestReviewers suggestReviewers,
-      ProjectState projectState,
-      List<Account.Id> candidateList)
-      throws OrmException, IOException, ConfigInvalidException {
-    String query = suggestReviewers.getQuery();
-    double baseWeight = config.getInt("addReviewer", "baseWeight", 1);
-
-    Map<Account.Id, MutableDouble> reviewerScores;
-    if (Strings.isNullOrEmpty(query)) {
-      reviewerScores = baseRankingForEmptyQuery(baseWeight);
-    } else {
-      reviewerScores = baseRankingForCandidateList(candidateList, projectState, baseWeight);
-    }
-
-    // Send the query along with a candidate list to all plugins and merge the
-    // results. Plugins don't necessarily need to use the candidates list, they
-    // can also return non-candidate account ids.
-    List<Callable<Set<SuggestedReviewer>>> tasks =
-        new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
-    List<Double> weights = new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
-
-    for (DynamicMap.Entry<ReviewerSuggestion> plugin : reviewerSuggestionPluginMap) {
-      tasks.add(
-          () ->
-              plugin
-                  .getProvider()
-                  .get()
-                  .suggestReviewers(
-                      projectState.getNameKey(),
-                      changeNotes.getChangeId(),
-                      query,
-                      reviewerScores.keySet()));
-      String pluginWeight =
-          config.getString(
-              "addReviewer", plugin.getPluginName() + "-" + plugin.getExportName(), "weight");
-      if (Strings.isNullOrEmpty(pluginWeight)) {
-        pluginWeight = "1";
-      }
-      try {
-        weights.add(Double.parseDouble(pluginWeight));
-      } catch (NumberFormatException e) {
-        log.error(
-            "Exception while parsing weight for "
-                + plugin.getPluginName()
-                + "-"
-                + plugin.getExportName(),
-            e);
-        weights.add(1d);
-      }
-    }
-
-    try {
-      List<Future<Set<SuggestedReviewer>>> futures =
-          workQueue.getDefaultQueue().invokeAll(tasks, PLUGIN_QUERY_TIMEOUT, TimeUnit.MILLISECONDS);
-      Iterator<Double> weightIterator = weights.iterator();
-      for (Future<Set<SuggestedReviewer>> f : futures) {
-        double weight = weightIterator.next();
-        for (SuggestedReviewer s : f.get()) {
-          if (reviewerScores.containsKey(s.account)) {
-            reviewerScores.get(s.account).add(s.score * weight);
-          } else {
-            reviewerScores.put(s.account, new MutableDouble(s.score * weight));
-          }
-        }
-      }
-    } catch (ExecutionException | InterruptedException e) {
-      log.error("Exception while suggesting reviewers", e);
-      return ImmutableList.of();
-    }
-
-    if (changeNotes != null) {
-      // Remove change owner
-      reviewerScores.remove(changeNotes.getChange().getOwner());
-
-      // Remove existing reviewers
-      reviewerScores
-          .keySet()
-          .removeAll(approvalsUtil.getReviewers(dbProvider.get(), changeNotes).byState(REVIEWER));
-    }
-
-    // Sort results
-    Stream<Entry<Account.Id, MutableDouble>> sorted =
-        reviewerScores
-            .entrySet()
-            .stream()
-            .sorted(Collections.reverseOrder(Map.Entry.comparingByValue()));
-    List<Account.Id> sortedSuggestions = sorted.map(Map.Entry::getKey).collect(toList());
-    return sortedSuggestions;
-  }
-
-  private Map<Account.Id, MutableDouble> baseRankingForEmptyQuery(double baseWeight)
-      throws OrmException, IOException, ConfigInvalidException {
-    // Get the user's last 25 changes, check approvals
-    try {
-      List<ChangeData> result =
-          queryProvider
-              .get()
-              .setLimit(25)
-              .setRequestedFields(ImmutableSet.of(ChangeField.APPROVAL.getName()))
-              .query(changeQueryBuilder.owner("self"));
-      Map<Account.Id, MutableDouble> suggestions = new HashMap<>();
-      for (ChangeData cd : result) {
-        for (PatchSetApproval approval : cd.currentApprovals()) {
-          Account.Id id = approval.getAccountId();
-          if (suggestions.containsKey(id)) {
-            suggestions.get(id).add(baseWeight);
-          } else {
-            suggestions.put(id, new MutableDouble(baseWeight));
-          }
-        }
-      }
-      return suggestions;
-    } catch (QueryParseException e) {
-      // Unhandled, because owner:self will never provoke a QueryParseException
-      log.error("Exception while suggesting reviewers", e);
-      return ImmutableMap.of();
-    }
-  }
-
-  private Map<Account.Id, MutableDouble> baseRankingForCandidateList(
-      List<Account.Id> candidates, ProjectState projectState, double baseWeight)
-      throws OrmException, IOException, ConfigInvalidException {
-    // Get each reviewer's activity based on number of applied labels
-    // (weighted 10d), number of comments (weighted 0.5d) and number of owned
-    // changes (weighted 1d).
-    Map<Account.Id, MutableDouble> reviewers = new LinkedHashMap<>();
-    if (candidates.size() == 0) {
-      return reviewers;
-    }
-    List<Predicate<ChangeData>> predicates = new ArrayList<>();
-    for (Account.Id id : candidates) {
-      try {
-        Predicate<ChangeData> projectQuery = changeQueryBuilder.project(projectState.getName());
-
-        // Get all labels for this project and create a compound OR query to
-        // fetch all changes where users have applied one of these labels
-        List<LabelType> labelTypes = projectState.getLabelTypes().getLabelTypes();
-        List<Predicate<ChangeData>> labelPredicates = new ArrayList<>(labelTypes.size());
-        for (LabelType type : labelTypes) {
-          labelPredicates.add(changeQueryBuilder.label(type.getName() + ",user=" + id));
-        }
-        Predicate<ChangeData> reviewerQuery =
-            Predicate.and(projectQuery, Predicate.or(labelPredicates));
-
-        Predicate<ChangeData> ownerQuery =
-            Predicate.and(projectQuery, changeQueryBuilder.owner(id.toString()));
-        Predicate<ChangeData> commentedByQuery =
-            Predicate.and(projectQuery, changeQueryBuilder.commentby(id.toString()));
-
-        predicates.add(reviewerQuery);
-        predicates.add(ownerQuery);
-        predicates.add(commentedByQuery);
-        reviewers.put(id, new MutableDouble());
-      } catch (QueryParseException e) {
-        // Unhandled: If an exception is thrown, we won't increase the
-        // candidates's score
-        log.error("Exception while suggesting reviewers", e);
-      }
-    }
-
-    List<List<ChangeData>> result =
-        queryProvider.get().setLimit(25).setRequestedFields(ImmutableSet.of()).query(predicates);
-
-    Iterator<List<ChangeData>> queryResultIterator = result.iterator();
-    Iterator<Account.Id> reviewersIterator = reviewers.keySet().iterator();
-
-    int i = 0;
-    Account.Id currentId = null;
-    while (queryResultIterator.hasNext()) {
-      List<ChangeData> currentResult = queryResultIterator.next();
-      if (i % WEIGHTS.length == 0) {
-        currentId = reviewersIterator.next();
-      }
-
-      reviewers.get(currentId).add(WEIGHTS[i % WEIGHTS.length] * baseWeight * currentResult.size());
-      i++;
-    }
-    return reviewers;
-  }
-}
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
deleted file mode 100644
index ee25d54..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
+++ /dev/null
@@ -1,341 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.GroupBaseInfo;
-import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.index.query.QueryResult;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Description.Units;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.Timer0;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.account.AccountDirectory.FillOptions;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupMembers;
-import com.google.gerrit.server.change.PostReviewers;
-import com.google.gerrit.server.change.SuggestReviewers;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.account.AccountPredicates;
-import com.google.gerrit.server.query.account.AccountQueryBuilder;
-import com.google.gerrit.server.query.account.AccountQueryProcessor;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.List;
-import java.util.Objects;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-public class ReviewersUtil {
-  @Singleton
-  private static class Metrics {
-    final Timer0 queryAccountsLatency;
-    final Timer0 recommendAccountsLatency;
-    final Timer0 loadAccountsLatency;
-    final Timer0 queryGroupsLatency;
-    final Timer0 filterVisibility;
-
-    @Inject
-    Metrics(MetricMaker metricMaker) {
-      queryAccountsLatency =
-          metricMaker.newTimer(
-              "reviewer_suggestion/query_accounts",
-              new Description("Latency for querying accounts for reviewer suggestion")
-                  .setCumulative()
-                  .setUnit(Units.MILLISECONDS));
-      recommendAccountsLatency =
-          metricMaker.newTimer(
-              "reviewer_suggestion/recommend_accounts",
-              new Description("Latency for recommending accounts for reviewer suggestion")
-                  .setCumulative()
-                  .setUnit(Units.MILLISECONDS));
-      loadAccountsLatency =
-          metricMaker.newTimer(
-              "reviewer_suggestion/load_accounts",
-              new Description("Latency for loading accounts for reviewer suggestion")
-                  .setCumulative()
-                  .setUnit(Units.MILLISECONDS));
-      queryGroupsLatency =
-          metricMaker.newTimer(
-              "reviewer_suggestion/query_groups",
-              new Description("Latency for querying groups for reviewer suggestion")
-                  .setCumulative()
-                  .setUnit(Units.MILLISECONDS));
-      filterVisibility =
-          metricMaker.newTimer(
-              "reviewer_suggestion/filter_visibility",
-              new Description("Latency for removing users that can't see the change")
-                  .setCumulative()
-                  .setUnit(Units.MILLISECONDS));
-    }
-  }
-
-  // Generate a candidate list at 2x the size of what the user wants to see to
-  // give the ranking algorithm a good set of candidates it can work with
-  private static final int CANDIDATE_LIST_MULTIPLIER = 2;
-
-  private final AccountLoader accountLoader;
-  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 ReviewerRecommender reviewerRecommender;
-  private final Metrics metrics;
-
-  @Inject
-  ReviewersUtil(
-      AccountLoader.Factory accountLoaderFactory,
-      AccountQueryBuilder accountQueryBuilder,
-      Provider<AccountQueryProcessor> queryProvider,
-      GroupBackend groupBackend,
-      GroupMembers.Factory groupMembersFactory,
-      Provider<CurrentUser> currentUser,
-      ReviewerRecommender reviewerRecommender,
-      Metrics metrics) {
-    Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS);
-    fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
-    this.accountLoader = accountLoaderFactory.create(fillOptions);
-    this.accountQueryBuilder = accountQueryBuilder;
-    this.queryProvider = queryProvider;
-    this.currentUser = currentUser;
-    this.groupBackend = groupBackend;
-    this.groupMembersFactory = groupMembersFactory;
-    this.reviewerRecommender = reviewerRecommender;
-    this.metrics = metrics;
-  }
-
-  public interface VisibilityControl {
-    boolean isVisibleTo(Account.Id account) throws OrmException;
-  }
-
-  public List<SuggestedReviewerInfo> suggestReviewers(
-      ChangeNotes changeNotes,
-      SuggestReviewers suggestReviewers,
-      ProjectState projectState,
-      VisibilityControl visibilityControl,
-      boolean excludeGroups)
-      throws IOException, OrmException, ConfigInvalidException {
-    String query = suggestReviewers.getQuery();
-    int limit = suggestReviewers.getLimit();
-
-    if (!suggestReviewers.getSuggestAccounts()) {
-      return Collections.emptyList();
-    }
-
-    List<Account.Id> candidateList = new ArrayList<>();
-    if (!Strings.isNullOrEmpty(query)) {
-      candidateList = suggestAccounts(suggestReviewers);
-    }
-
-    List<Account.Id> sortedRecommendations =
-        recommendAccounts(changeNotes, suggestReviewers, projectState, candidateList);
-
-    // Filter accounts by visibility and enforce limit
-    List<Account.Id> filteredRecommendations = new ArrayList<>();
-    try (Timer0.Context ctx = metrics.filterVisibility.start()) {
-      for (Account.Id reviewer : sortedRecommendations) {
-        if (filteredRecommendations.size() >= limit) {
-          break;
-        }
-        if (visibilityControl.isVisibleTo(reviewer)) {
-          filteredRecommendations.add(reviewer);
-        }
-      }
-    }
-
-    List<SuggestedReviewerInfo> suggestedReviewer = loadAccounts(filteredRecommendations);
-    if (!excludeGroups && suggestedReviewer.size() < limit && !Strings.isNullOrEmpty(query)) {
-      // Add groups at the end as individual accounts are usually more
-      // important.
-      suggestedReviewer.addAll(
-          suggestAccountGroups(
-              suggestReviewers, projectState, visibilityControl, limit - suggestedReviewer.size()));
-    }
-
-    if (suggestedReviewer.size() <= limit) {
-      return suggestedReviewer;
-    }
-    return suggestedReviewer.subList(0, limit);
-  }
-
-  private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers) throws OrmException {
-    try (Timer0.Context ctx = metrics.queryAccountsLatency.start()) {
-      try {
-        QueryResult<AccountState> result =
-            queryProvider
-                .get()
-                .setUserProvidedLimit(suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER)
-                .query(
-                    AccountPredicates.andActive(
-                        accountQueryBuilder.defaultQuery(suggestReviewers.getQuery())));
-        return result.entities().stream().map(a -> a.getAccount().getId()).collect(toList());
-      } catch (QueryParseException e) {
-        return ImmutableList.of();
-      }
-    }
-  }
-
-  private List<Account.Id> recommendAccounts(
-      ChangeNotes changeNotes,
-      SuggestReviewers suggestReviewers,
-      ProjectState projectState,
-      List<Account.Id> candidateList)
-      throws OrmException, IOException, ConfigInvalidException {
-    try (Timer0.Context ctx = metrics.recommendAccountsLatency.start()) {
-      return reviewerRecommender.suggestReviewers(
-          changeNotes, suggestReviewers, projectState, candidateList);
-    }
-  }
-
-  private List<SuggestedReviewerInfo> loadAccounts(List<Account.Id> accountIds)
-      throws OrmException {
-    try (Timer0.Context ctx = metrics.loadAccountsLatency.start()) {
-      List<SuggestedReviewerInfo> reviewer =
-          accountIds
-              .stream()
-              .map(accountLoader::get)
-              .filter(Objects::nonNull)
-              .map(
-                  a -> {
-                    SuggestedReviewerInfo info = new SuggestedReviewerInfo();
-                    info.account = a;
-                    info.count = 1;
-                    return info;
-                  })
-              .collect(toList());
-      accountLoader.fill();
-      return reviewer;
-    }
-  }
-
-  private List<SuggestedReviewerInfo> suggestAccountGroups(
-      SuggestReviewers suggestReviewers,
-      ProjectState projectState,
-      VisibilityControl visibilityControl,
-      int limit)
-      throws OrmException, IOException {
-    try (Timer0.Context ctx = metrics.queryGroupsLatency.start()) {
-      List<SuggestedReviewerInfo> groups = new ArrayList<>();
-      for (GroupReference g : suggestAccountGroups(suggestReviewers, projectState)) {
-        GroupAsReviewer result =
-            suggestGroupAsReviewer(
-                suggestReviewers, projectState.getProject(), g, visibilityControl);
-        if (result.allowed || result.allowedWithConfirmation) {
-          GroupBaseInfo info = new GroupBaseInfo();
-          info.id = Url.encode(g.getUUID().get());
-          info.name = g.getName();
-          SuggestedReviewerInfo suggestedReviewerInfo = new SuggestedReviewerInfo();
-          suggestedReviewerInfo.group = info;
-          suggestedReviewerInfo.count = result.size;
-          if (result.allowedWithConfirmation) {
-            suggestedReviewerInfo.confirm = true;
-          }
-          groups.add(suggestedReviewerInfo);
-          if (groups.size() >= limit) {
-            break;
-          }
-        }
-      }
-      return groups;
-    }
-  }
-
-  private List<GroupReference> suggestAccountGroups(
-      SuggestReviewers suggestReviewers, ProjectState projectState) {
-    return Lists.newArrayList(
-        Iterables.limit(
-            groupBackend.suggest(suggestReviewers.getQuery(), projectState),
-            suggestReviewers.getLimit()));
-  }
-
-  private static class GroupAsReviewer {
-    boolean allowed;
-    boolean allowedWithConfirmation;
-    int size;
-  }
-
-  private GroupAsReviewer suggestGroupAsReviewer(
-      SuggestReviewers suggestReviewers,
-      Project project,
-      GroupReference group,
-      VisibilityControl visibilityControl)
-      throws OrmException, IOException {
-    GroupAsReviewer result = new GroupAsReviewer();
-    int maxAllowed = suggestReviewers.getMaxAllowed();
-    int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
-
-    if (!PostReviewers.isLegalReviewerGroup(group.getUUID())) {
-      return result;
-    }
-
-    try {
-      Set<Account> members =
-          groupMembersFactory
-              .create(currentUser.get())
-              .listAccounts(group.getUUID(), project.getNameKey());
-
-      if (members.isEmpty()) {
-        return result;
-      }
-
-      result.size = members.size();
-      if (maxAllowed > 0 && result.size > maxAllowed) {
-        return result;
-      }
-
-      boolean needsConfirmation = result.size > maxAllowedWithoutConfirmation;
-
-      // require that at least one member in the group can see the change
-      for (Account account : members) {
-        if (visibilityControl.isVisibleTo(account.getId())) {
-          if (needsConfirmation) {
-            result.allowedWithConfirmation = true;
-          } else {
-            result.allowed = true;
-          }
-          return result;
-        }
-      }
-    } catch (NoSuchGroupException e) {
-      return result;
-    } catch (NoSuchProjectException e) {
-      return result;
-    }
-
-    return result;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java b/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
deleted file mode 100644
index 930f3f3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
+++ /dev/null
@@ -1,146 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Description.Units;
-import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.Timer2;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.RepoSequence;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.List;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class Sequences {
-  public static final String NAME_ACCOUNTS = "accounts";
-  public static final String NAME_CHANGES = "changes";
-
-  public static int getChangeSequenceGap(Config cfg) {
-    return cfg.getInt("noteDb", "changes", "initialSequenceGap", 1000);
-  }
-
-  private enum SequenceType {
-    ACCOUNTS,
-    CHANGES;
-  }
-
-  private final Provider<ReviewDb> db;
-  private final NotesMigration migration;
-  private final RepoSequence accountSeq;
-  private final RepoSequence changeSeq;
-  private final Timer2<SequenceType, Boolean> nextIdLatency;
-
-  @Inject
-  Sequences(
-      @GerritServerConfig Config cfg,
-      Provider<ReviewDb> db,
-      NotesMigration migration,
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllProjectsName allProjects,
-      AllUsersName allUsers,
-      MetricMaker metrics) {
-    this.db = db;
-    this.migration = migration;
-
-    int accountBatchSize = cfg.getInt("noteDb", "accounts", "sequenceBatchSize", 1);
-    accountSeq =
-        new RepoSequence(
-            repoManager,
-            gitRefUpdated,
-            allUsers,
-            NAME_ACCOUNTS,
-            () -> ReviewDb.FIRST_ACCOUNT_ID,
-            accountBatchSize);
-
-    int gap = getChangeSequenceGap(cfg);
-    @SuppressWarnings("deprecation")
-    RepoSequence.Seed changeSeed = () -> db.get().nextChangeId() + gap;
-    int changeBatchSize = cfg.getInt("noteDb", "changes", "sequenceBatchSize", 20);
-    changeSeq =
-        new RepoSequence(
-            repoManager, gitRefUpdated, allProjects, NAME_CHANGES, changeSeed, changeBatchSize);
-
-    nextIdLatency =
-        metrics.newTimer(
-            "sequence/next_id_latency",
-            new Description("Latency of requesting IDs from repo sequences")
-                .setCumulative()
-                .setUnit(Units.MILLISECONDS),
-            Field.ofEnum(SequenceType.class, "sequence"),
-            Field.ofBoolean("multiple"));
-  }
-
-  public int nextAccountId() throws OrmException {
-    try (Timer2.Context timer = nextIdLatency.start(SequenceType.ACCOUNTS, false)) {
-      return accountSeq.next();
-    }
-  }
-
-  public int nextChangeId() throws OrmException {
-    if (!migration.readChangeSequence()) {
-      return nextChangeId(db.get());
-    }
-    try (Timer2.Context timer = nextIdLatency.start(SequenceType.CHANGES, false)) {
-      return changeSeq.next();
-    }
-  }
-
-  public ImmutableList<Integer> nextChangeIds(int count) throws OrmException {
-    if (migration.readChangeSequence()) {
-      try (Timer2.Context timer = nextIdLatency.start(SequenceType.CHANGES, count > 1)) {
-        return changeSeq.next(count);
-      }
-    }
-
-    if (count == 0) {
-      return ImmutableList.of();
-    }
-    checkArgument(count > 0, "count is negative: %s", count);
-    List<Integer> ids = new ArrayList<>(count);
-    ReviewDb db = this.db.get();
-    for (int i = 0; i < count; i++) {
-      ids.add(nextChangeId(db));
-    }
-    return ImmutableList.copyOf(ids);
-  }
-
-  @VisibleForTesting
-  public RepoSequence getChangeIdRepoSequence() {
-    return changeSeq;
-  }
-
-  @SuppressWarnings("deprecation")
-  private static int nextChangeId(ReviewDb db) throws OrmException {
-    return db.nextChangeId();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
deleted file mode 100644
index 12bd8ff..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
+++ /dev/null
@@ -1,506 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Joiner;
-import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSortedSet;
-import com.google.common.collect.Sets;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import java.util.SortedSet;
-import java.util.TreeSet;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class StarredChangesUtil {
-  @AutoValue
-  public abstract static class StarField {
-    private static final String SEPARATOR = ":";
-
-    public static StarField parse(String s) {
-      int p = s.indexOf(SEPARATOR);
-      if (p >= 0) {
-        Integer id = Ints.tryParse(s.substring(0, p));
-        if (id == null) {
-          return null;
-        }
-        Account.Id accountId = new Account.Id(id);
-        String label = s.substring(p + 1);
-        return create(accountId, label);
-      }
-      return null;
-    }
-
-    public static StarField create(Account.Id accountId, String label) {
-      return new AutoValue_StarredChangesUtil_StarField(accountId, label);
-    }
-
-    public abstract Account.Id accountId();
-
-    public abstract String label();
-
-    @Override
-    public String toString() {
-      return accountId() + SEPARATOR + label();
-    }
-  }
-
-  @AutoValue
-  public abstract static class StarRef {
-    private static final StarRef MISSING =
-        new AutoValue_StarredChangesUtil_StarRef(null, ImmutableSortedSet.of());
-
-    private static StarRef create(Ref ref, Iterable<String> labels) {
-      return new AutoValue_StarredChangesUtil_StarRef(
-          checkNotNull(ref), ImmutableSortedSet.copyOf(labels));
-    }
-
-    @Nullable
-    public abstract Ref ref();
-
-    public abstract ImmutableSortedSet<String> labels();
-
-    public ObjectId objectId() {
-      return ref() != null ? ref().getObjectId() : ObjectId.zeroId();
-    }
-  }
-
-  public static class IllegalLabelException extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    IllegalLabelException(String message) {
-      super(message);
-    }
-  }
-
-  public static class InvalidLabelsException extends IllegalLabelException {
-    private static final long serialVersionUID = 1L;
-
-    InvalidLabelsException(Set<String> invalidLabels) {
-      super(String.format("invalid labels: %s", Joiner.on(", ").join(invalidLabels)));
-    }
-  }
-
-  public static class MutuallyExclusiveLabelsException extends IllegalLabelException {
-    private static final long serialVersionUID = 1L;
-
-    MutuallyExclusiveLabelsException(String label1, String label2) {
-      super(
-          String.format(
-              "The labels %s and %s are mutually exclusive. Only one of them can be set.",
-              label1, label2));
-    }
-  }
-
-  private static final Logger log = LoggerFactory.getLogger(StarredChangesUtil.class);
-
-  public static final String DEFAULT_LABEL = "star";
-  public static final String IGNORE_LABEL = "ignore";
-  public static final String REVIEWED_LABEL = "reviewed";
-  public static final String UNREVIEWED_LABEL = "unreviewed";
-  public static final ImmutableSortedSet<String> DEFAULT_LABELS =
-      ImmutableSortedSet.of(DEFAULT_LABEL);
-
-  private final GitRepositoryManager repoManager;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final AllUsersName allUsers;
-  private final Provider<ReviewDb> dbProvider;
-  private final PersonIdent serverIdent;
-  private final ChangeIndexer indexer;
-  private final Provider<InternalChangeQuery> queryProvider;
-
-  @Inject
-  StarredChangesUtil(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllUsersName allUsers,
-      Provider<ReviewDb> dbProvider,
-      @GerritPersonIdent PersonIdent serverIdent,
-      ChangeIndexer indexer,
-      Provider<InternalChangeQuery> queryProvider) {
-    this.repoManager = repoManager;
-    this.gitRefUpdated = gitRefUpdated;
-    this.allUsers = allUsers;
-    this.dbProvider = dbProvider;
-    this.serverIdent = serverIdent;
-    this.indexer = indexer;
-    this.queryProvider = queryProvider;
-  }
-
-  public ImmutableSortedSet<String> getLabels(Account.Id accountId, Change.Id changeId)
-      throws OrmException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)).labels();
-    } catch (IOException e) {
-      throw new OrmException(
-          String.format(
-              "Reading stars from change %d for account %d failed",
-              changeId.get(), accountId.get()),
-          e);
-    }
-  }
-
-  public ImmutableSortedSet<String> star(
-      Account.Id accountId,
-      Project.NameKey project,
-      Change.Id changeId,
-      Set<String> labelsToAdd,
-      Set<String> labelsToRemove)
-      throws OrmException, IllegalLabelException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      String refName = RefNames.refsStarredChanges(changeId, accountId);
-      StarRef old = readLabels(repo, refName);
-
-      Set<String> labels = new HashSet<>(old.labels());
-      if (labelsToAdd != null) {
-        labels.addAll(labelsToAdd);
-      }
-      if (labelsToRemove != null) {
-        labels.removeAll(labelsToRemove);
-      }
-
-      if (labels.isEmpty()) {
-        deleteRef(repo, refName, old.objectId());
-      } else {
-        checkMutuallyExclusiveLabels(labels);
-        updateLabels(repo, refName, old.objectId(), labels);
-      }
-
-      indexer.index(dbProvider.get(), project, changeId);
-      return ImmutableSortedSet.copyOf(labels);
-    } catch (IOException e) {
-      throw new OrmException(
-          String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
-          e);
-    }
-  }
-
-  public void unstarAll(Project.NameKey project, Change.Id changeId) throws OrmException {
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo)) {
-      BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
-      batchUpdate.setAllowNonFastForwards(true);
-      batchUpdate.setRefLogIdent(serverIdent);
-      batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
-      for (Account.Id accountId : byChangeFromIndex(changeId).keySet()) {
-        String refName = RefNames.refsStarredChanges(changeId, accountId);
-        Ref ref = repo.getRefDatabase().getRef(refName);
-        batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), refName));
-      }
-      batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
-      for (ReceiveCommand command : batchUpdate.getCommands()) {
-        if (command.getResult() != ReceiveCommand.Result.OK) {
-          throw new IOException(
-              String.format(
-                  "Unstar change %d failed, ref %s could not be deleted: %s",
-                  changeId.get(), command.getRefName(), command.getResult()));
-        }
-      }
-      indexer.index(dbProvider.get(), project, changeId);
-    } catch (IOException e) {
-      throw new OrmException(String.format("Unstar change %d failed", changeId.get()), e);
-    }
-  }
-
-  public ImmutableMap<Account.Id, StarRef> byChange(Change.Id changeId) throws OrmException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      ImmutableMap.Builder<Account.Id, StarRef> builder = ImmutableMap.builder();
-      for (String refPart : getRefNames(repo, RefNames.refsStarredChangesPrefix(changeId))) {
-        Integer id = Ints.tryParse(refPart);
-        if (id == null) {
-          continue;
-        }
-        Account.Id accountId = new Account.Id(id);
-        builder.put(accountId, readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
-      }
-      return builder.build();
-    } catch (IOException e) {
-      throw new OrmException(
-          String.format("Get accounts that starred change %d failed", changeId.get()), e);
-    }
-  }
-
-  public ImmutableListMultimap<Account.Id, String> byChangeFromIndex(Change.Id changeId)
-      throws OrmException {
-    Set<String> fields = ImmutableSet.of(ChangeField.ID.getName(), ChangeField.STAR.getName());
-    List<ChangeData> changeData =
-        queryProvider.get().setRequestedFields(fields).byLegacyChangeId(changeId);
-    if (changeData.size() != 1) {
-      throw new NoSuchChangeException(changeId);
-    }
-    return changeData.get(0).stars();
-  }
-
-  private static Set<String> getRefNames(Repository repo, String prefix) throws IOException {
-    RefDatabase refDb = repo.getRefDatabase();
-    return refDb.getRefs(prefix).keySet();
-  }
-
-  public ObjectId getObjectId(Account.Id accountId, Change.Id changeId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      Ref ref = repo.exactRef(RefNames.refsStarredChanges(changeId, accountId));
-      return ref != null ? ref.getObjectId() : ObjectId.zeroId();
-    } catch (IOException e) {
-      log.error(
-          String.format(
-              "Getting star object ID for account %d on change %d failed",
-              accountId.get(), changeId.get()),
-          e);
-      return ObjectId.zeroId();
-    }
-  }
-
-  public void ignore(ChangeResource rsrc) throws OrmException, IllegalLabelException {
-    star(
-        rsrc.getUser().asIdentifiedUser().getAccountId(),
-        rsrc.getProject(),
-        rsrc.getChange().getId(),
-        ImmutableSet.of(IGNORE_LABEL),
-        ImmutableSet.of());
-  }
-
-  public void unignore(ChangeResource rsrc) throws OrmException, IllegalLabelException {
-    star(
-        rsrc.getUser().asIdentifiedUser().getAccountId(),
-        rsrc.getProject(),
-        rsrc.getChange().getId(),
-        ImmutableSet.of(),
-        ImmutableSet.of(IGNORE_LABEL));
-  }
-
-  public boolean isIgnoredBy(Change.Id changeId, Account.Id accountId) throws OrmException {
-    return getLabels(accountId, changeId).contains(IGNORE_LABEL);
-  }
-
-  public boolean isIgnored(ChangeResource rsrc) throws OrmException {
-    return isIgnoredBy(rsrc.getChange().getId(), rsrc.getUser().asIdentifiedUser().getAccountId());
-  }
-
-  private static String getReviewedLabel(Change change) {
-    return getReviewedLabel(change.currentPatchSetId().get());
-  }
-
-  private static String getReviewedLabel(int ps) {
-    return REVIEWED_LABEL + "/" + ps;
-  }
-
-  private static String getUnreviewedLabel(Change change) {
-    return getUnreviewedLabel(change.currentPatchSetId().get());
-  }
-
-  private static String getUnreviewedLabel(int ps) {
-    return UNREVIEWED_LABEL + "/" + ps;
-  }
-
-  public void markAsReviewed(ChangeResource rsrc) throws OrmException, IllegalLabelException {
-    star(
-        rsrc.getUser().asIdentifiedUser().getAccountId(),
-        rsrc.getProject(),
-        rsrc.getChange().getId(),
-        ImmutableSet.of(getReviewedLabel(rsrc.getChange())),
-        ImmutableSet.of(getUnreviewedLabel(rsrc.getChange())));
-  }
-
-  public void markAsUnreviewed(ChangeResource rsrc) throws OrmException, IllegalLabelException {
-    star(
-        rsrc.getUser().asIdentifiedUser().getAccountId(),
-        rsrc.getProject(),
-        rsrc.getChange().getId(),
-        ImmutableSet.of(getUnreviewedLabel(rsrc.getChange())),
-        ImmutableSet.of(getReviewedLabel(rsrc.getChange())));
-  }
-
-  public static StarRef readLabels(Repository repo, String refName) throws IOException {
-    Ref ref = repo.exactRef(refName);
-    if (ref == null) {
-      return StarRef.MISSING;
-    }
-
-    try (ObjectReader reader = repo.newObjectReader()) {
-      ObjectLoader obj = reader.open(ref.getObjectId(), Constants.OBJ_BLOB);
-      return StarRef.create(
-          ref,
-          Splitter.on(CharMatcher.whitespace())
-              .omitEmptyStrings()
-              .split(new String(obj.getCachedBytes(Integer.MAX_VALUE), UTF_8)));
-    }
-  }
-
-  public static ObjectId writeLabels(Repository repo, Collection<String> labels)
-      throws IOException, InvalidLabelsException {
-    validateLabels(labels);
-    try (ObjectInserter oi = repo.newObjectInserter()) {
-      ObjectId id =
-          oi.insert(
-              Constants.OBJ_BLOB,
-              labels.stream().sorted().distinct().collect(joining("\n")).getBytes(UTF_8));
-      oi.flush();
-      return id;
-    }
-  }
-
-  private static void checkMutuallyExclusiveLabels(Set<String> labels)
-      throws MutuallyExclusiveLabelsException {
-    if (labels.containsAll(ImmutableSet.of(DEFAULT_LABEL, IGNORE_LABEL))) {
-      throw new MutuallyExclusiveLabelsException(DEFAULT_LABEL, IGNORE_LABEL);
-    }
-
-    Set<Integer> reviewedPatchSets = getStarredPatchSets(labels, REVIEWED_LABEL);
-    Set<Integer> unreviewedPatchSets = getStarredPatchSets(labels, UNREVIEWED_LABEL);
-    Optional<Integer> ps =
-        Sets.intersection(reviewedPatchSets, unreviewedPatchSets).stream().findFirst();
-    if (ps.isPresent()) {
-      throw new MutuallyExclusiveLabelsException(
-          getReviewedLabel(ps.get()), getUnreviewedLabel(ps.get()));
-    }
-  }
-
-  public static Set<Integer> getStarredPatchSets(Set<String> labels, String label) {
-    return labels
-        .stream()
-        .filter(l -> l.startsWith(label))
-        .filter(l -> Ints.tryParse(l.substring(label.length() + 1)) != null)
-        .map(l -> Integer.valueOf(l.substring(label.length() + 1)))
-        .collect(toSet());
-  }
-
-  private static void validateLabels(Collection<String> labels) throws InvalidLabelsException {
-    if (labels == null) {
-      return;
-    }
-
-    SortedSet<String> invalidLabels = new TreeSet<>();
-    for (String label : labels) {
-      if (CharMatcher.whitespace().matchesAnyOf(label)) {
-        invalidLabels.add(label);
-      }
-    }
-    if (!invalidLabels.isEmpty()) {
-      throw new InvalidLabelsException(invalidLabels);
-    }
-  }
-
-  private void updateLabels(
-      Repository repo, String refName, ObjectId oldObjectId, Collection<String> labels)
-      throws IOException, OrmException, InvalidLabelsException {
-    try (RevWalk rw = new RevWalk(repo)) {
-      RefUpdate u = repo.updateRef(refName);
-      u.setExpectedOldObjectId(oldObjectId);
-      u.setForceUpdate(true);
-      u.setNewObjectId(writeLabels(repo, labels));
-      u.setRefLogIdent(serverIdent);
-      u.setRefLogMessage("Update star labels", true);
-      RefUpdate.Result result = u.update(rw);
-      switch (result) {
-        case NEW:
-        case FORCED:
-        case NO_CHANGE:
-        case FAST_FORWARD:
-          gitRefUpdated.fire(allUsers, u, null);
-          return;
-        case IO_FAILURE:
-        case LOCK_FAILURE:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case REJECTED_CURRENT_BRANCH:
-        case RENAMED:
-        case REJECTED_MISSING_OBJECT:
-        case REJECTED_OTHER_REASON:
-        default:
-          throw new OrmException(
-              String.format("Update star labels on ref %s failed: %s", refName, result.name()));
-      }
-    }
-  }
-
-  private void deleteRef(Repository repo, String refName, ObjectId oldObjectId)
-      throws IOException, OrmException {
-    RefUpdate u = repo.updateRef(refName);
-    u.setForceUpdate(true);
-    u.setExpectedOldObjectId(oldObjectId);
-    u.setRefLogIdent(serverIdent);
-    u.setRefLogMessage("Unstar change", true);
-    RefUpdate.Result result = u.delete();
-    switch (result) {
-      case FORCED:
-        gitRefUpdated.fire(allUsers, u, null);
-        return;
-      case NEW:
-      case NO_CHANGE:
-      case FAST_FORWARD:
-      case IO_FAILURE:
-      case LOCK_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case RENAMED:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      default:
-        throw new OrmException(
-            String.format("Delete star ref %s failed: %s", refName, result.name()));
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java
deleted file mode 100644
index 891dec2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-public class StringUtil {
-  /**
-   * An array of the string representations that should be used in place of the non-printable
-   * characters in the beginning of the ASCII table when escaping a string. The index of each
-   * element in the array corresponds to its ASCII value, i.e. the string representation of ASCII 0
-   * is found in the first element of this array.
-   */
-  private static final String[] NON_PRINTABLE_CHARS = {
-    "\\x00", "\\x01", "\\x02", "\\x03", "\\x04", "\\x05", "\\x06", "\\a",
-    "\\b", "\\t", "\\n", "\\v", "\\f", "\\r", "\\x0e", "\\x0f",
-    "\\x10", "\\x11", "\\x12", "\\x13", "\\x14", "\\x15", "\\x16", "\\x17",
-    "\\x18", "\\x19", "\\x1a", "\\x1b", "\\x1c", "\\x1d", "\\x1e", "\\x1f",
-  };
-
-  /**
-   * Escapes the input string so that all non-printable characters (0x00-0x1f) are represented as a
-   * hex escape (\x00, \x01, ...) or as a C-style escape sequence (\a, \b, \t, \n, \v, \f, or \r).
-   * Backslashes in the input string are doubled (\\).
-   */
-  public static String escapeString(String str) {
-    // Allocate a buffer big enough to cover the case with a string needed
-    // very excessive escaping without having to reallocate the buffer.
-    final StringBuilder result = new StringBuilder(3 * str.length());
-
-    for (int i = 0; i < str.length(); i++) {
-      char c = str.charAt(i);
-      if (c < NON_PRINTABLE_CHARS.length) {
-        result.append(NON_PRINTABLE_CHARS[c]);
-      } else if (c == '\\') {
-        result.append("\\\\");
-      } else {
-        result.append(c);
-      }
-    }
-    return result.toString();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/access/AccessCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/access/AccessCollection.java
deleted file mode 100644
index 2e90889..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/access/AccessCollection.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.access;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestCollection;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class AccessCollection implements RestCollection<TopLevelResource, AccessResource> {
-  private final Provider<ListAccess> list;
-  private final DynamicMap<RestView<AccessResource>> views;
-
-  @Inject
-  AccessCollection(Provider<ListAccess> list, DynamicMap<RestView<AccessResource>> views) {
-    this.list = list;
-    this.views = views;
-  }
-
-  @Override
-  public RestView<TopLevelResource> list() {
-    return list.get();
-  }
-
-  @Override
-  public AccessResource parse(TopLevelResource parent, IdString id)
-      throws ResourceNotFoundException {
-    throw new ResourceNotFoundException(id);
-  }
-
-  @Override
-  public DynamicMap<RestView<AccessResource>> views() {
-    return views;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/access/AccessResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/access/AccessResource.java
deleted file mode 100644
index 22888b8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/access/AccessResource.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.access;
-
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.inject.TypeLiteral;
-
-public class AccessResource implements RestResource {
-  public static final TypeLiteral<RestView<AccessResource>> ACCESS_KIND =
-      new TypeLiteral<RestView<AccessResource>>() {};
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java
deleted file mode 100644
index 99e6a9f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.access;
-
-import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.GetAccess;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
-import org.kohsuke.args4j.Option;
-
-public class ListAccess implements RestReadView<TopLevelResource> {
-
-  @Option(
-    name = "--project",
-    aliases = {"-p"},
-    metaVar = "PROJECT",
-    usage = "projects for which the access rights should be returned"
-  )
-  private List<String> projects = new ArrayList<>();
-
-  private final GetAccess getAccess;
-
-  @Inject
-  public ListAccess(GetAccess getAccess) {
-    this.getAccess = getAccess;
-  }
-
-  @Override
-  public Map<String, ProjectAccessInfo> apply(TopLevelResource resource)
-      throws ResourceNotFoundException, ResourceConflictException, IOException,
-          PermissionBackendException, OrmException {
-    Map<String, ProjectAccessInfo> access = new TreeMap<>();
-    for (String p : projects) {
-      access.put(p, getAccess.apply(new Project.NameKey(p)));
-    }
-    return access;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/access/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/access/Module.java
deleted file mode 100644
index cd0d334..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/access/Module.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.access;
-
-import static com.google.gerrit.server.access.AccessResource.ACCESS_KIND;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.RestApiModule;
-
-public class Module extends RestApiModule {
-  @Override
-  protected void configure() {
-    bind(AccessCollection.class);
-
-    DynamicMap.mapOf(binder(), ACCESS_KIND);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
deleted file mode 100644
index d062842..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ /dev/null
@@ -1,240 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Streams;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.group.Groups;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.index.account.AccountIndexer;
-import com.google.gerrit.server.index.group.GroupField;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.gerrit.server.index.group.GroupIndexCollection;
-import com.google.gerrit.server.query.group.InternalGroupQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.name.Named;
-import java.io.IOException;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.ExecutionException;
-import java.util.stream.Stream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Caches important (but small) account state to avoid database hits. */
-@Singleton
-public class AccountCacheImpl implements AccountCache {
-  private static final Logger log = LoggerFactory.getLogger(AccountCacheImpl.class);
-
-  private static final String BYID_NAME = "accounts";
-
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        cache(BYID_NAME, Account.Id.class, new TypeLiteral<Optional<AccountState>>() {})
-            .loader(ByIdLoader.class);
-
-        bind(AccountCacheImpl.class);
-        bind(AccountCache.class).to(AccountCacheImpl.class);
-      }
-    };
-  }
-
-  private final AllUsersName allUsersName;
-  private final ExternalIds externalIds;
-  private final LoadingCache<Account.Id, Optional<AccountState>> byId;
-  private final Provider<AccountIndexer> indexer;
-
-  @Inject
-  AccountCacheImpl(
-      AllUsersName allUsersName,
-      ExternalIds externalIds,
-      @Named(BYID_NAME) LoadingCache<Account.Id, Optional<AccountState>> byId,
-      Provider<AccountIndexer> indexer) {
-    this.allUsersName = allUsersName;
-    this.externalIds = externalIds;
-    this.byId = byId;
-    this.indexer = indexer;
-  }
-
-  @Override
-  public AccountState get(Account.Id accountId) {
-    try {
-      return byId.get(accountId).orElse(missing(accountId));
-    } catch (ExecutionException e) {
-      log.warn("Cannot load AccountState for " + accountId, e);
-      return missing(accountId);
-    }
-  }
-
-  @Override
-  @Nullable
-  public AccountState getOrNull(Account.Id accountId) {
-    try {
-      return byId.get(accountId).orElse(null);
-    } catch (ExecutionException e) {
-      log.warn("Cannot load AccountState for ID " + accountId, e);
-      return null;
-    }
-  }
-
-  @Override
-  public AccountState getByUsername(String username) {
-    try {
-      ExternalId extId = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
-      if (extId == null) {
-        return null;
-      }
-      return getOrNull(extId.accountId());
-    } catch (IOException | ConfigInvalidException e) {
-      log.warn("Cannot load AccountState for username " + username, e);
-      return null;
-    }
-  }
-
-  @Override
-  public void evict(Account.Id accountId) throws IOException {
-    if (accountId != null) {
-      byId.invalidate(accountId);
-      indexer.get().index(accountId);
-    }
-  }
-
-  @Override
-  public void evictAllNoReindex() {
-    byId.invalidateAll();
-  }
-
-  private AccountState missing(Account.Id accountId) {
-    Account account = new Account(accountId, TimeUtil.nowTs());
-    account.setActive(false);
-    Set<AccountGroup.UUID> anon = ImmutableSet.of();
-    return new AccountState(
-        allUsersName,
-        account,
-        anon,
-        Collections.emptySet(),
-        new HashMap<ProjectWatchKey, Set<NotifyType>>());
-  }
-
-  static class ByIdLoader extends CacheLoader<Account.Id, Optional<AccountState>> {
-    private final SchemaFactory<ReviewDb> schema;
-    private final AllUsersName allUsersName;
-    private final Accounts accounts;
-    private final Provider<GroupIndex> groupIndexProvider;
-    private final Provider<InternalGroupQuery> groupQueryProvider;
-    private final GroupCache groupCache;
-    private final GeneralPreferencesLoader loader;
-    private final Provider<WatchConfig.Accessor> watchConfig;
-    private final ExternalIds externalIds;
-
-    @Inject
-    ByIdLoader(
-        SchemaFactory<ReviewDb> sf,
-        AllUsersName allUsersName,
-        Accounts accounts,
-        GroupIndexCollection groupIndexCollection,
-        Provider<InternalGroupQuery> groupQueryProvider,
-        GroupCache groupCache,
-        GeneralPreferencesLoader loader,
-        Provider<WatchConfig.Accessor> watchConfig,
-        ExternalIds externalIds) {
-      this.schema = sf;
-      this.allUsersName = allUsersName;
-      this.accounts = accounts;
-      this.groupIndexProvider = groupIndexCollection::getSearchIndex;
-      this.groupQueryProvider = groupQueryProvider;
-      this.groupCache = groupCache;
-      this.loader = loader;
-      this.watchConfig = watchConfig;
-      this.externalIds = externalIds;
-    }
-
-    @Override
-    public Optional<AccountState> load(Account.Id key) throws Exception {
-      try (ReviewDb db = schema.open()) {
-        return load(db, key);
-      }
-    }
-
-    private Optional<AccountState> load(ReviewDb db, Account.Id who)
-        throws OrmException, IOException, ConfigInvalidException {
-      Account account = accounts.get(who);
-      if (account == null) {
-        return Optional.empty();
-      }
-
-      Set<AccountGroup.UUID> internalGroups = getGroupsWithMember(db, who);
-
-      try {
-        account.setGeneralPreferences(loader.load(who));
-      } catch (IOException | ConfigInvalidException e) {
-        log.warn("Cannot load GeneralPreferences for " + who + " (using default)", e);
-        account.setGeneralPreferences(GeneralPreferencesInfo.defaults());
-      }
-
-      return Optional.of(
-          new AccountState(
-              allUsersName,
-              account,
-              internalGroups,
-              externalIds.byAccount(who),
-              watchConfig.get().getProjectWatches(who)));
-    }
-
-    private ImmutableSet<AccountGroup.UUID> getGroupsWithMember(ReviewDb db, Account.Id memberId)
-        throws OrmException {
-      Stream<InternalGroup> internalGroupStream;
-      if (groupIndexProvider.get() != null
-          && groupIndexProvider.get().getSchema().hasField(GroupField.MEMBER)) {
-        internalGroupStream = groupQueryProvider.get().byMember(memberId).stream();
-      } else {
-        internalGroupStream =
-            Groups.getGroupsWithMemberFromReviewDb(db, memberId)
-                .map(groupCache::get)
-                .flatMap(Streams::stream);
-      }
-
-      return internalGroupStream.map(InternalGroup::getGroupUUID).collect(toImmutableSet());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountConfig.java
deleted file mode 100644
index f44aa0e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountConfig.java
+++ /dev/null
@@ -1,274 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.server.git.VersionedMetaData;
-import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevSort;
-
-/**
- * ‘account.config’ file in the user branch in the All-Users repository that contains the properties
- * of the account.
- *
- * <p>The 'account.config' file is a git config file that has one 'account' section with the
- * properties of the account:
- *
- * <pre>
- *   [account]
- *     active = false
- *     fullName = John Doe
- *     preferredEmail = john.doe@foo.com
- *     status = Overloaded with reviews
- * </pre>
- *
- * <p>All keys are optional. This means 'account.config' may not exist on the user branch if no
- * properties are set.
- *
- * <p>Not setting a key and setting a key to an empty string are treated the same way and result in
- * a {@code null} value.
- *
- * <p>If no value for 'active' is specified, by default the account is considered as active.
- *
- * <p>The commit date of the first commit on the user branch is used as registration date of the
- * account. The first commit may be an empty commit (if no properties were set and 'account.config'
- * doesn't exist).
- */
-public class AccountConfig extends VersionedMetaData implements ValidationError.Sink {
-  public static final String ACCOUNT_CONFIG = "account.config";
-  public static final String ACCOUNT = "account";
-  public static final String KEY_ACTIVE = "active";
-  public static final String KEY_FULL_NAME = "fullName";
-  public static final String KEY_PREFERRED_EMAIL = "preferredEmail";
-  public static final String KEY_STATUS = "status";
-
-  @Nullable private final OutgoingEmailValidator emailValidator;
-  private final Account.Id accountId;
-  private final String ref;
-
-  private boolean isLoaded;
-  private Account account;
-  private Timestamp registeredOn;
-  private List<ValidationError> validationErrors;
-
-  public AccountConfig(@Nullable OutgoingEmailValidator emailValidator, Account.Id accountId) {
-    this.emailValidator = emailValidator;
-    this.accountId = accountId;
-    this.ref = RefNames.refsUsers(accountId);
-  }
-
-  @Override
-  protected String getRefName() {
-    return ref;
-  }
-
-  /**
-   * Get the loaded account.
-   *
-   * @return loaded account.
-   * @throws IllegalStateException if the account was not loaded yet
-   */
-  public Account getAccount() {
-    checkLoaded();
-    return account;
-  }
-
-  /**
-   * Sets the account. This means the loaded account will be overwritten with the given account.
-   *
-   * <p>Changing the registration date of an account is not supported.
-   *
-   * @param account account that should be set
-   * @throws IllegalStateException if the account was not loaded yet
-   */
-  public void setAccount(Account account) {
-    checkLoaded();
-    this.account = account;
-    this.registeredOn = account.getRegisteredOn();
-  }
-
-  /**
-   * Creates a new account.
-   *
-   * @return the new account
-   * @throws OrmDuplicateKeyException if the user branch already exists
-   */
-  public Account getNewAccount() throws OrmDuplicateKeyException {
-    checkLoaded();
-    if (revision != null) {
-      throw new OrmDuplicateKeyException(String.format("account %s already exists", accountId));
-    }
-    this.registeredOn = TimeUtil.nowTs();
-    this.account = new Account(accountId, registeredOn);
-    return account;
-  }
-
-  @Override
-  protected void onLoad() throws IOException, ConfigInvalidException {
-    if (revision != null) {
-      rw.markStart(revision);
-      rw.sort(RevSort.REVERSE);
-      registeredOn = new Timestamp(rw.next().getCommitTime() * 1000L);
-
-      Config cfg = readConfig(ACCOUNT_CONFIG);
-
-      account = parse(cfg);
-      account.setMetaId(revision.name());
-    }
-
-    isLoaded = true;
-  }
-
-  private Account parse(Config cfg) {
-    Account account = new Account(accountId, registeredOn);
-    account.setActive(cfg.getBoolean(ACCOUNT, null, KEY_ACTIVE, true));
-    account.setFullName(get(cfg, KEY_FULL_NAME));
-
-    String preferredEmail = get(cfg, KEY_PREFERRED_EMAIL);
-    account.setPreferredEmail(preferredEmail);
-    if (emailValidator != null && !emailValidator.isValid(preferredEmail)) {
-      error(
-          new ValidationError(
-              ACCOUNT_CONFIG, String.format("Invalid preferred email: %s", preferredEmail)));
-    }
-
-    account.setStatus(get(cfg, KEY_STATUS));
-    return account;
-  }
-
-  @Override
-  public RevCommit commit(MetaDataUpdate update) throws IOException {
-    RevCommit c = super.commit(update);
-    account.setMetaId(c.name());
-    return c;
-  }
-
-  @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
-    checkLoaded();
-
-    if (revision != null) {
-      commit.setMessage("Update account\n");
-    } else if (account != null) {
-      commit.setMessage("Create account\n");
-      commit.setAuthor(new PersonIdent(commit.getAuthor(), registeredOn));
-      commit.setCommitter(new PersonIdent(commit.getCommitter(), registeredOn));
-    }
-
-    Config cfg = readConfig(ACCOUNT_CONFIG);
-    writeToConfig(account, cfg);
-    saveConfig(ACCOUNT_CONFIG, cfg);
-    return true;
-  }
-
-  public static void writeToConfig(Account account, Config cfg) {
-    setActive(cfg, account.isActive());
-    set(cfg, KEY_FULL_NAME, account.getFullName());
-    set(cfg, KEY_PREFERRED_EMAIL, account.getPreferredEmail());
-    set(cfg, KEY_STATUS, account.getStatus());
-  }
-
-  /**
-   * Sets/Unsets {@code account.active} in the given config.
-   *
-   * <p>{@code account.active} is set to {@code false} if the account is inactive.
-   *
-   * <p>If the account is active {@code account.active} is unset since {@code true} is the default
-   * if this field is missing.
-   *
-   * @param cfg the config
-   * @param value whether the account is active
-   */
-  private static void setActive(Config cfg, boolean value) {
-    if (!value) {
-      cfg.setBoolean(ACCOUNT, null, KEY_ACTIVE, false);
-    } else {
-      cfg.unset(ACCOUNT, null, KEY_ACTIVE);
-    }
-  }
-
-  /**
-   * Sets/Unsets the given key in the given config.
-   *
-   * <p>The key unset if the value is {@code null}.
-   *
-   * @param cfg the config
-   * @param key the key
-   * @param value the value
-   */
-  private static void set(Config cfg, String key, String value) {
-    if (!Strings.isNullOrEmpty(value)) {
-      cfg.setString(ACCOUNT, null, key, value);
-    } else {
-      cfg.unset(ACCOUNT, null, key);
-    }
-  }
-
-  /**
-   * Gets the given key from the given config.
-   *
-   * <p>Empty values are returned as {@code null}
-   *
-   * @param cfg the config
-   * @param key the key
-   * @return the value, {@code null} if key was not set or key was set to empty string
-   */
-  private static String get(Config cfg, String key) {
-    return Strings.emptyToNull(cfg.getString(ACCOUNT, null, key));
-  }
-
-  private void checkLoaded() {
-    checkState(isLoaded, "account not loaded yet");
-  }
-
-  /**
-   * Get the validation errors, if any were discovered during load.
-   *
-   * @return list of errors; empty list if there are no errors.
-   */
-  public List<ValidationError> getValidationErrors() {
-    if (validationErrors != null) {
-      return ImmutableList.copyOf(validationErrors);
-    }
-    return ImmutableList.of();
-  }
-
-  @Override
-  public void error(ValidationError error) {
-    if (validationErrors == null) {
-      validationErrors = new ArrayList<>(4);
-    }
-    validationErrors.add(error);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
deleted file mode 100644
index d9f6c93..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
+++ /dev/null
@@ -1,506 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.errors.NameAlreadyUsedException;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
-import com.google.gerrit.server.auth.NoSuchUserException;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.group.GroupsUpdate;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Optional;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.function.Consumer;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Tracks authentication related details for user accounts. */
-@Singleton
-public class AccountManager {
-  private static final Logger log = LoggerFactory.getLogger(AccountManager.class);
-
-  private final SchemaFactory<ReviewDb> schema;
-  private final Sequences sequences;
-  private final Accounts accounts;
-  private final AccountsUpdate.Server accountsUpdateFactory;
-  private final AccountCache byIdCache;
-  private final Realm realm;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final ChangeUserName.Factory changeUserNameFactory;
-  private final ProjectCache projectCache;
-  private final AtomicBoolean awaitsFirstAccountCheck;
-  private final ExternalIds externalIds;
-  private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
-  private final GroupsUpdate.Factory groupsUpdateFactory;
-  private final boolean autoUpdateAccountActiveStatus;
-  private final SetInactiveFlag setInactiveFlag;
-
-  @Inject
-  AccountManager(
-      SchemaFactory<ReviewDb> schema,
-      Sequences sequences,
-      @GerritServerConfig Config cfg,
-      Accounts accounts,
-      AccountsUpdate.Server accountsUpdateFactory,
-      AccountCache byIdCache,
-      Realm accountMapper,
-      IdentifiedUser.GenericFactory userFactory,
-      ChangeUserName.Factory changeUserNameFactory,
-      ProjectCache projectCache,
-      ExternalIds externalIds,
-      ExternalIdsUpdate.Server externalIdsUpdateFactory,
-      GroupsUpdate.Factory groupsUpdateFactory,
-      SetInactiveFlag setInactiveFlag) {
-    this.schema = schema;
-    this.sequences = sequences;
-    this.accounts = accounts;
-    this.accountsUpdateFactory = accountsUpdateFactory;
-    this.byIdCache = byIdCache;
-    this.realm = accountMapper;
-    this.userFactory = userFactory;
-    this.changeUserNameFactory = changeUserNameFactory;
-    this.projectCache = projectCache;
-    this.awaitsFirstAccountCheck =
-        new AtomicBoolean(cfg.getBoolean("capability", "makeFirstUserAdmin", true));
-    this.externalIds = externalIds;
-    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
-    this.groupsUpdateFactory = groupsUpdateFactory;
-    this.autoUpdateAccountActiveStatus =
-        cfg.getBoolean("auth", "autoUpdateAccountActiveStatus", false);
-    this.setInactiveFlag = setInactiveFlag;
-  }
-
-  /** @return user identified by this external identity string */
-  public Optional<Account.Id> lookup(String externalId) throws AccountException {
-    try {
-      ExternalId extId = externalIds.get(ExternalId.Key.parse(externalId));
-      return extId != null ? Optional.of(extId.accountId()) : Optional.empty();
-    } catch (IOException | ConfigInvalidException e) {
-      throw new AccountException("Cannot lookup account " + externalId, e);
-    }
-  }
-
-  /**
-   * Authenticate the user, potentially creating a new account if they are new.
-   *
-   * @param who identity of the user, with any details we received about them.
-   * @return the result of authenticating the user.
-   * @throws AccountException the account does not exist, and cannot be created, or exists, but
-   *     cannot be located, is unable to be activated or deactivated, or is inactive, or cannot be
-   *     added to the admin group (only for the first account).
-   */
-  public AuthResult authenticate(AuthRequest who) throws AccountException, IOException {
-    try {
-      who = realm.authenticate(who);
-    } catch (NoSuchUserException e) {
-      deactivateAccountIfItExists(who);
-      throw e;
-    }
-    try {
-      try (ReviewDb db = schema.open()) {
-        ExternalId id = externalIds.get(who.getExternalIdKey());
-        if (id == null) {
-          // New account, automatically create and return.
-          //
-          return create(db, who);
-        }
-
-        // Account exists
-        Account act = updateAccountActiveStatus(who, byIdCache.get(id.accountId()).getAccount());
-        if (!act.isActive()) {
-          throw new AccountException("Authentication error, account inactive");
-        }
-
-        // return the identity to the caller.
-        update(who, id);
-        return new AuthResult(id.accountId(), who.getExternalIdKey(), false);
-      }
-    } catch (OrmException | ConfigInvalidException e) {
-      throw new AccountException("Authentication error", e);
-    }
-  }
-
-  private void deactivateAccountIfItExists(AuthRequest authRequest) {
-    if (!shouldUpdateActiveStatus(authRequest)) {
-      return;
-    }
-    try {
-      ExternalId id = externalIds.get(authRequest.getExternalIdKey());
-      if (id == null) {
-        return;
-      }
-      setInactiveFlag.deactivate(id.accountId());
-    } catch (Exception e) {
-      log.error("Unable to deactivate account " + authRequest.getUserName(), e);
-    }
-  }
-
-  private Account updateAccountActiveStatus(AuthRequest authRequest, Account account)
-      throws AccountException {
-    if (!shouldUpdateActiveStatus(authRequest) || authRequest.isActive() == account.isActive()) {
-      return account;
-    }
-
-    if (authRequest.isActive()) {
-      try {
-        setInactiveFlag.activate(account.getId());
-      } catch (Exception e) {
-        throw new AccountException("Unable to activate account " + account.getId(), e);
-      }
-    } else {
-      try {
-        setInactiveFlag.deactivate(account.getId());
-      } catch (Exception e) {
-        throw new AccountException("Unable to deactivate account " + account.getId(), e);
-      }
-    }
-    return byIdCache.get(account.getId()).getAccount();
-  }
-
-  private boolean shouldUpdateActiveStatus(AuthRequest authRequest) {
-    return autoUpdateAccountActiveStatus && authRequest.authProvidesAccountActiveStatus();
-  }
-
-  private void update(AuthRequest who, ExternalId extId)
-      throws OrmException, IOException, ConfigInvalidException {
-    IdentifiedUser user = userFactory.create(extId.accountId());
-    List<Consumer<Account>> accountUpdates = new ArrayList<>();
-
-    // If the email address was modified by the authentication provider,
-    // update our records to match the changed email.
-    //
-    String newEmail = who.getEmailAddress();
-    String oldEmail = extId.email();
-    if (newEmail != null && !newEmail.equals(oldEmail)) {
-      if (oldEmail != null && oldEmail.equals(user.getAccount().getPreferredEmail())) {
-        accountUpdates.add(a -> a.setPreferredEmail(newEmail));
-      }
-
-      externalIdsUpdateFactory
-          .create()
-          .replace(
-              extId, ExternalId.create(extId.key(), extId.accountId(), newEmail, extId.password()));
-    }
-
-    if (!realm.allowsEdit(AccountFieldName.FULL_NAME)
-        && !Strings.isNullOrEmpty(who.getDisplayName())
-        && !eq(user.getAccount().getFullName(), who.getDisplayName())) {
-      accountUpdates.add(a -> a.setFullName(who.getDisplayName()));
-    }
-
-    if (!realm.allowsEdit(AccountFieldName.USER_NAME)
-        && who.getUserName() != null
-        && !eq(user.getUserName(), who.getUserName())) {
-      log.warn(
-          String.format(
-              "Not changing already set username %s to %s", user.getUserName(), who.getUserName()));
-    }
-
-    if (!accountUpdates.isEmpty()) {
-      Account account = accountsUpdateFactory.create().update(user.getAccountId(), accountUpdates);
-      if (account == null) {
-        throw new OrmException("Account " + user.getAccountId() + " has been deleted");
-      }
-    }
-  }
-
-  private static boolean eq(String a, String b) {
-    return (a == null && b == null) || (a != null && a.equals(b));
-  }
-
-  private AuthResult create(ReviewDb db, AuthRequest who)
-      throws OrmException, AccountException, IOException, ConfigInvalidException {
-    Account.Id newId = new Account.Id(sequences.nextAccountId());
-
-    ExternalId extId =
-        ExternalId.createWithEmail(who.getExternalIdKey(), newId, who.getEmailAddress());
-
-    boolean isFirstAccount = awaitsFirstAccountCheck.getAndSet(false) && !accounts.hasAnyAccount();
-
-    Account account;
-    try {
-      AccountsUpdate accountsUpdate = accountsUpdateFactory.create();
-      account =
-          accountsUpdate.insert(
-              newId,
-              a -> {
-                a.setFullName(who.getDisplayName());
-                a.setPreferredEmail(extId.email());
-              });
-
-      ExternalId existingExtId = externalIds.get(extId.key());
-      if (existingExtId != null && !existingExtId.accountId().equals(extId.accountId())) {
-        // external ID is assigned to another account, do not overwrite
-        accountsUpdate.delete(account);
-        throw new AccountException(
-            "Cannot assign external ID \""
-                + extId.key().get()
-                + "\" to account "
-                + newId
-                + "; external ID already in use.");
-      }
-      externalIdsUpdateFactory.create().upsert(extId);
-    } finally {
-      // If adding the account failed, it may be that it actually was the
-      // first account. So we reset the 'check for first account'-guard, as
-      // otherwise the first account would not get administration permissions.
-      awaitsFirstAccountCheck.set(isFirstAccount);
-    }
-
-    IdentifiedUser user = userFactory.create(newId);
-
-    if (isFirstAccount) {
-      // This is the first user account on our site. Assume this user
-      // is going to be the site's administrator and just make them that
-      // to bootstrap the authentication database.
-      //
-      Permission admin =
-          projectCache
-              .getAllProjects()
-              .getConfig()
-              .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
-              .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
-
-      AccountGroup.UUID uuid = admin.getRules().get(0).getGroup().getUUID();
-      // The user initiated this request by logging in. -> Attribute all modifications to that user.
-      GroupsUpdate groupsUpdate = groupsUpdateFactory.create(user);
-      try {
-        groupsUpdate.addGroupMember(db, uuid, newId);
-      } catch (NoSuchGroupException e) {
-        throw new AccountException(String.format("Group %s not found", uuid));
-      }
-    }
-
-    if (who.getUserName() != null) {
-      // Only set if the name hasn't been used yet, but was given to us.
-      //
-      try {
-        changeUserNameFactory.create(user, who.getUserName()).call();
-      } catch (NameAlreadyUsedException e) {
-        String message =
-            "Cannot assign user name \""
-                + who.getUserName()
-                + "\" to account "
-                + newId
-                + "; name already in use.";
-        handleSettingUserNameFailure(account, extId, message, e, false);
-      } catch (InvalidUserNameException e) {
-        String message =
-            "Cannot assign user name \""
-                + who.getUserName()
-                + "\" to account "
-                + newId
-                + "; name does not conform.";
-        handleSettingUserNameFailure(account, extId, message, e, false);
-      } catch (OrmException e) {
-        String message = "Cannot assign user name";
-        handleSettingUserNameFailure(account, extId, message, e, true);
-      }
-    }
-
-    realm.onCreateAccount(who, account);
-    return new AuthResult(newId, extId.key(), true);
-  }
-
-  /**
-   * This method handles an exception that occurred during the setting of the user name for a newly
-   * created account. If the realm does not allow the user to set a user name manually this method
-   * deletes the newly created account and throws an {@link AccountUserNameException}. In any case
-   * the error message is logged.
-   *
-   * @param account the newly created account
-   * @param extId the newly created external id
-   * @param errorMessage the error message
-   * @param e the exception that occurred during the setting of the user name for the new account
-   * @param logException flag that decides whether the exception should be included into the log
-   * @throws AccountUserNameException thrown if the realm does not allow the user to manually set
-   *     the user name
-   * @throws OrmException thrown if cleaning the database failed
-   */
-  private void handleSettingUserNameFailure(
-      Account account, ExternalId extId, String errorMessage, Exception e, boolean logException)
-      throws AccountUserNameException, OrmException, IOException, ConfigInvalidException {
-    if (logException) {
-      log.error(errorMessage, e);
-    } else {
-      log.error(errorMessage);
-    }
-    if (!realm.allowsEdit(AccountFieldName.USER_NAME)) {
-      // setting the given user name has failed, but the realm does not
-      // allow the user to manually set a user name,
-      // this means we would end with an account without user name
-      // (without 'username:<USERNAME>' external ID),
-      // such an account cannot be used for uploading changes,
-      // this is why the best we can do here is to fail early and cleanup
-      // the database
-      accountsUpdateFactory.create().delete(account);
-      externalIdsUpdateFactory.create().delete(extId);
-      throw new AccountUserNameException(errorMessage, e);
-    }
-  }
-
-  /**
-   * Link another authentication identity to an existing account.
-   *
-   * @param to account to link the identity onto.
-   * @param who the additional identity.
-   * @return the result of linking the identity to the user.
-   * @throws AccountException the identity belongs to a different account, or it cannot be linked at
-   *     this time.
-   */
-  public AuthResult link(Account.Id to, AuthRequest who)
-      throws AccountException, OrmException, IOException, ConfigInvalidException {
-    ExternalId extId = externalIds.get(who.getExternalIdKey());
-    if (extId != null) {
-      if (!extId.accountId().equals(to)) {
-        throw new AccountException(
-            "Identity '" + extId.key().get() + "' in use by another account");
-      }
-      update(who, extId);
-    } else {
-      externalIdsUpdateFactory
-          .create()
-          .insert(ExternalId.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress()));
-
-      if (who.getEmailAddress() != null) {
-        accountsUpdateFactory
-            .create()
-            .update(
-                to,
-                a -> {
-                  if (a.getPreferredEmail() == null) {
-                    a.setPreferredEmail(who.getEmailAddress());
-                  }
-                });
-      }
-    }
-
-    return new AuthResult(to, who.getExternalIdKey(), false);
-  }
-
-  /**
-   * Update the link to another unique authentication identity to an existing account.
-   *
-   * <p>Existing external identities with the same scheme will be removed and replaced with the new
-   * one.
-   *
-   * @param to account to link the identity onto.
-   * @param who the additional identity.
-   * @return the result of linking the identity to the user.
-   * @throws OrmException
-   * @throws AccountException the identity belongs to a different account, or it cannot be linked at
-   *     this time.
-   */
-  public AuthResult updateLink(Account.Id to, AuthRequest who)
-      throws OrmException, AccountException, IOException, ConfigInvalidException {
-    Collection<ExternalId> filteredExtIdsByScheme =
-        externalIds.byAccount(to, who.getExternalIdKey().scheme());
-
-    if (!filteredExtIdsByScheme.isEmpty()
-        && (filteredExtIdsByScheme.size() > 1
-            || !filteredExtIdsByScheme
-                .stream()
-                .filter(e -> e.key().equals(who.getExternalIdKey()))
-                .findAny()
-                .isPresent())) {
-      externalIdsUpdateFactory.create().delete(filteredExtIdsByScheme);
-    }
-    return link(to, who);
-  }
-
-  /**
-   * Unlink an external identity from an existing account.
-   *
-   * @param from account to unlink the external identity from
-   * @param extIdKey the key of the external ID that should be deleted
-   * @throws AccountException the identity belongs to a different account, or the identity was not
-   *     found
-   */
-  public void unlink(Account.Id from, ExternalId.Key extIdKey)
-      throws AccountException, OrmException, IOException, ConfigInvalidException {
-    unlink(from, ImmutableList.of(extIdKey));
-  }
-
-  /**
-   * Unlink an external identities from an existing account.
-   *
-   * @param from account to unlink the external identity from
-   * @param extIdKeys the keys of the external IDs that should be deleted
-   * @throws AccountException any of the identity belongs to a different account, or any of the
-   *     identity was not found
-   */
-  public void unlink(Account.Id from, Collection<ExternalId.Key> extIdKeys)
-      throws AccountException, OrmException, IOException, ConfigInvalidException {
-    if (extIdKeys.isEmpty()) {
-      return;
-    }
-
-    List<ExternalId> extIds = new ArrayList<>(extIdKeys.size());
-    for (ExternalId.Key extIdKey : extIdKeys) {
-      ExternalId extId = externalIds.get(extIdKey);
-      if (extId != null) {
-        if (!extId.accountId().equals(from)) {
-          throw new AccountException("Identity '" + extIdKey.get() + "' in use by another account");
-        }
-        extIds.add(extId);
-      } else {
-        throw new AccountException("Identity '" + extIdKey.get() + "' not found");
-      }
-    }
-
-    externalIdsUpdateFactory.create().delete(extIds);
-
-    if (extIds.stream().anyMatch(e -> e.email() != null)) {
-      accountsUpdateFactory
-          .create()
-          .update(
-              from,
-              a -> {
-                if (a.getPreferredEmail() != null) {
-                  for (ExternalId extId : extIds) {
-                    if (a.getPreferredEmail().equals(extId.email())) {
-                      a.setPreferredEmail(null);
-                      break;
-                    }
-                  }
-                }
-              });
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
deleted file mode 100644
index dd523a9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
+++ /dev/null
@@ -1,198 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-
-import com.google.common.base.Function;
-import com.google.common.base.Strings;
-import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.CurrentUser.PropertyKey;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.config.AllUsersName;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import org.apache.commons.codec.DecoderException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class AccountState {
-  private static final Logger logger = LoggerFactory.getLogger(AccountState.class);
-
-  public static final Function<AccountState, Account.Id> ACCOUNT_ID_FUNCTION =
-      a -> a.getAccount().getId();
-
-  private final AllUsersName allUsersName;
-  private final Account account;
-  private final Set<AccountGroup.UUID> internalGroups;
-  private final Collection<ExternalId> externalIds;
-  private final Map<ProjectWatchKey, Set<NotifyType>> projectWatches;
-  private Cache<IdentifiedUser.PropertyKey<Object>, Object> properties;
-
-  public AccountState(
-      AllUsersName allUsersName,
-      Account account,
-      Set<AccountGroup.UUID> actualGroups,
-      Collection<ExternalId> externalIds,
-      Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
-    this.allUsersName = allUsersName;
-    this.account = account;
-    this.internalGroups = actualGroups;
-    this.externalIds = externalIds;
-    this.projectWatches = projectWatches;
-    this.account.setUserName(getUserName(externalIds));
-  }
-
-  public AllUsersName getAllUsersNameForIndexing() {
-    return allUsersName;
-  }
-
-  /** Get the cached account metadata. */
-  public Account getAccount() {
-    return account;
-  }
-
-  /**
-   * Get the username, if one has been declared for this user.
-   *
-   * <p>The username is the {@link ExternalId} using the scheme {@link ExternalId#SCHEME_USERNAME}.
-   */
-  public String getUserName() {
-    return account.getUserName();
-  }
-
-  public boolean checkPassword(String password, String username) {
-    if (password == null) {
-      return false;
-    }
-    for (ExternalId id : getExternalIds()) {
-      // Only process the "username:$USER" entry, which is unique.
-      if (!id.isScheme(SCHEME_USERNAME) || !username.equals(id.key().id())) {
-        continue;
-      }
-
-      String hashedStr = id.password();
-      if (!Strings.isNullOrEmpty(hashedStr)) {
-        try {
-          return HashedPassword.decode(hashedStr).checkPassword(password);
-        } catch (DecoderException e) {
-          logger.error(
-              String.format("DecoderException for user %s: %s ", username, e.getMessage()));
-          return false;
-        }
-      }
-    }
-    return false;
-  }
-
-  /** The external identities that identify the account holder. */
-  public Collection<ExternalId> getExternalIds() {
-    return externalIds;
-  }
-
-  /** The project watches of the account. */
-  public Map<ProjectWatchKey, Set<NotifyType>> getProjectWatches() {
-    return projectWatches;
-  }
-
-  /** The set of groups maintained directly within the Gerrit database. */
-  public Set<AccountGroup.UUID> getInternalGroups() {
-    return internalGroups;
-  }
-
-  public static String getUserName(Collection<ExternalId> ids) {
-    for (ExternalId extId : ids) {
-      if (extId.isScheme(SCHEME_USERNAME)) {
-        return extId.key().id();
-      }
-    }
-    return null;
-  }
-
-  public static Set<String> getEmails(Collection<ExternalId> ids) {
-    Set<String> emails = new HashSet<>();
-    for (ExternalId extId : ids) {
-      if (extId.isScheme(SCHEME_MAILTO)) {
-        emails.add(extId.key().id());
-      }
-    }
-    return emails;
-  }
-
-  /**
-   * Lookup a previously stored property.
-   *
-   * <p>All properties are automatically cleared when the account cache invalidates the {@code
-   * AccountState}. This method is thread-safe.
-   *
-   * @param key unique property key.
-   * @return previously stored value, or {@code null}.
-   */
-  @Nullable
-  public <T> T get(PropertyKey<T> key) {
-    Cache<PropertyKey<Object>, Object> p = properties(false);
-    if (p != null) {
-      @SuppressWarnings("unchecked")
-      T value = (T) p.getIfPresent(key);
-      return value;
-    }
-    return null;
-  }
-
-  /**
-   * Store a property for later retrieval.
-   *
-   * <p>This method is thread-safe.
-   *
-   * @param key unique property key.
-   * @param value value to store; or {@code null} to clear the value.
-   */
-  public <T> void put(PropertyKey<T> key, @Nullable T value) {
-    Cache<PropertyKey<Object>, Object> p = properties(value != null);
-    if (p != null) {
-      @SuppressWarnings("unchecked")
-      PropertyKey<Object> k = (PropertyKey<Object>) key;
-      if (value != null) {
-        p.put(k, value);
-      } else {
-        p.invalidate(k);
-      }
-    }
-  }
-
-  private synchronized Cache<PropertyKey<Object>, Object> properties(boolean allocate) {
-    if (properties == null && allocate) {
-      properties =
-          CacheBuilder.newBuilder()
-              .concurrencyLevel(1)
-              .initialCapacity(16)
-              // Use weakKeys to ensure plugins that garbage collect will also
-              // eventually release data held in any still live AccountState.
-              .weakKeys()
-              .build();
-    }
-    return properties;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountUserNameException.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountUserNameException.java
deleted file mode 100644
index f1a2555..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountUserNameException.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-/**
- * Thrown by {@link AccountManager} if the user name for a newly created account could not be set
- * and the realm does not allow the user to set a user name manually.
- */
-public class AccountUserNameException extends AccountException {
-  private static final long serialVersionUID = 1L;
-
-  public AccountUserNameException(String message, Throwable why) {
-    super(message, why);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Accounts.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Accounts.java
deleted file mode 100644
index a9428f0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Accounts.java
+++ /dev/null
@@ -1,151 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Objects;
-import java.util.Set;
-import java.util.stream.Stream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Class to access accounts. */
-@Singleton
-public class Accounts {
-  private static final Logger log = LoggerFactory.getLogger(Accounts.class);
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final OutgoingEmailValidator emailValidator;
-
-  @Inject
-  Accounts(
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      OutgoingEmailValidator emailValidator) {
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.emailValidator = emailValidator;
-  }
-
-  public Account get(Account.Id accountId) throws IOException, ConfigInvalidException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return read(repo, accountId);
-    }
-  }
-
-  public List<Account> get(Collection<Account.Id> accountIds)
-      throws IOException, ConfigInvalidException {
-    List<Account> accounts = new ArrayList<>(accountIds.size());
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      for (Account.Id accountId : accountIds) {
-        accounts.add(read(repo, accountId));
-      }
-    }
-    return accounts;
-  }
-
-  /**
-   * Returns all accounts.
-   *
-   * @return all accounts
-   */
-  public List<Account> all() throws IOException {
-    Set<Account.Id> accountIds = allIds();
-    List<Account> accounts = new ArrayList<>(accountIds.size());
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      for (Account.Id accountId : accountIds) {
-        try {
-          accounts.add(read(repo, accountId));
-        } catch (Exception e) {
-          log.error(String.format("Ignoring invalid account %s", accountId.get()), e);
-        }
-      }
-    }
-    return accounts;
-  }
-
-  /**
-   * Returns all account IDs.
-   *
-   * @return all account IDs
-   */
-  public Set<Account.Id> allIds() throws IOException {
-    return readUserRefs().collect(toSet());
-  }
-
-  /**
-   * Returns the first n account IDs.
-   *
-   * @param n the number of account IDs that should be returned
-   * @return first n account IDs
-   */
-  public List<Account.Id> firstNIds(int n) throws IOException {
-    return readUserRefs().sorted(comparing(id -> id.get())).limit(n).collect(toList());
-  }
-
-  /**
-   * Checks if any account exists.
-   *
-   * @return {@code true} if at least one account exists, otherwise {@code false}
-   */
-  public boolean hasAnyAccount() throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return hasAnyAccount(repo);
-    }
-  }
-
-  public static boolean hasAnyAccount(Repository repo) throws IOException {
-    return readUserRefs(repo).findAny().isPresent();
-  }
-
-  private Stream<Account.Id> readUserRefs() throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return readUserRefs(repo);
-    }
-  }
-
-  private Account read(Repository allUsersRepository, Account.Id accountId)
-      throws IOException, ConfigInvalidException {
-    AccountConfig accountConfig = new AccountConfig(emailValidator, accountId);
-    accountConfig.load(allUsersRepository);
-    return accountConfig.getAccount();
-  }
-
-  public static Stream<Account.Id> readUserRefs(Repository repo) throws IOException {
-    return repo.getRefDatabase()
-        .getRefs(RefNames.REFS_USERS)
-        .values()
-        .stream()
-        .map(r -> Account.Id.fromRef(r.getName()))
-        .filter(Objects::nonNull);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
deleted file mode 100644
index 19a8259..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
+++ /dev/null
@@ -1,165 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestCollection;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class AccountsCollection
-    implements RestCollection<TopLevelResource, AccountResource>, AcceptsCreate<TopLevelResource> {
-  private final Provider<CurrentUser> self;
-  private final AccountResolver resolver;
-  private final AccountControl.Factory accountControlFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final Provider<QueryAccounts> list;
-  private final DynamicMap<RestView<AccountResource>> views;
-  private final CreateAccount.Factory createAccountFactory;
-
-  @Inject
-  AccountsCollection(
-      Provider<CurrentUser> self,
-      AccountResolver resolver,
-      AccountControl.Factory accountControlFactory,
-      IdentifiedUser.GenericFactory userFactory,
-      Provider<QueryAccounts> list,
-      DynamicMap<RestView<AccountResource>> views,
-      CreateAccount.Factory createAccountFactory) {
-    this.self = self;
-    this.resolver = resolver;
-    this.accountControlFactory = accountControlFactory;
-    this.userFactory = userFactory;
-    this.list = list;
-    this.views = views;
-    this.createAccountFactory = createAccountFactory;
-  }
-
-  @Override
-  public AccountResource parse(TopLevelResource root, IdString id)
-      throws ResourceNotFoundException, AuthException, OrmException, IOException,
-          ConfigInvalidException {
-    IdentifiedUser user = parseId(id.get());
-    if (user == null) {
-      throw new ResourceNotFoundException(id);
-    } else if (!accountControlFactory.get().canSee(user.getAccount())) {
-      throw new ResourceNotFoundException(id);
-    }
-    return new AccountResource(user);
-  }
-
-  /**
-   * Parses a account ID from a request body and returns the user.
-   *
-   * @param id ID of the account, can be a string of the format "{@code Full Name
-   *     <email@example.com>}", just the email address, a full name if it is unique, an account ID,
-   *     a user name or "{@code self}" for the calling user
-   * @return the user, never null.
-   * @throws UnprocessableEntityException thrown if the account ID cannot be resolved or if the
-   *     account is not visible to the calling user
-   */
-  public IdentifiedUser parse(String id)
-      throws AuthException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException {
-    return parseOnBehalfOf(null, id);
-  }
-
-  /**
-   * Parses an account ID and returns the user without making any permission check whether the
-   * current user can see the account.
-   *
-   * @param id ID of the account, can be a string of the format "{@code Full Name
-   *     <email@example.com>}", just the email address, a full name if it is unique, an account ID,
-   *     a user name or "{@code self}" for the calling user
-   * @return the user, null if no user is found for the given account ID
-   * @throws AuthException thrown if 'self' is used as account ID and the current user is not
-   *     authenticated
-   * @throws OrmException
-   * @throws ConfigInvalidException
-   * @throws IOException
-   */
-  public IdentifiedUser parseId(String id)
-      throws AuthException, OrmException, IOException, ConfigInvalidException {
-    return parseIdOnBehalfOf(null, id);
-  }
-
-  /**
-   * Like {@link #parse(String)}, but also sets the {@link CurrentUser#getRealUser()} on the result.
-   */
-  public IdentifiedUser parseOnBehalfOf(@Nullable CurrentUser caller, String id)
-      throws AuthException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException {
-    IdentifiedUser user = parseIdOnBehalfOf(caller, id);
-    if (user == null) {
-      throw new UnprocessableEntityException(String.format("Account Not Found: %s", id));
-    } else if (!accountControlFactory.get().canSee(user.getAccount())) {
-      throw new UnprocessableEntityException(String.format("Account Not Found: %s", id));
-    }
-    return user;
-  }
-
-  private IdentifiedUser parseIdOnBehalfOf(@Nullable CurrentUser caller, String id)
-      throws AuthException, OrmException, IOException, ConfigInvalidException {
-    if (id.equals("self")) {
-      CurrentUser user = self.get();
-      if (user.isIdentifiedUser()) {
-        return user.asIdentifiedUser();
-      } else if (user instanceof AnonymousUser) {
-        throw new AuthException("Authentication required");
-      } else {
-        return null;
-      }
-    }
-
-    Account match = resolver.find(id);
-    if (match == null) {
-      return null;
-    }
-    CurrentUser realUser = caller != null ? caller.getRealUser() : null;
-    return userFactory.runAs(null, match.getId(), realUser);
-  }
-
-  @Override
-  public RestView<TopLevelResource> list() throws ResourceNotFoundException {
-    return list.get();
-  }
-
-  @Override
-  public DynamicMap<RestView<AccountResource>> views() {
-    return views;
-  }
-
-  @Override
-  public CreateAccount create(TopLevelResource parent, IdString username) {
-    return createAccountFactory.create(username.get());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java
deleted file mode 100644
index 0085303..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
-@Singleton
-public class AccountsConsistencyChecker {
-  private final Accounts accounts;
-  private final ExternalIds externalIds;
-
-  @Inject
-  AccountsConsistencyChecker(Accounts accounts, ExternalIds externalIds) {
-    this.accounts = accounts;
-    this.externalIds = externalIds;
-  }
-
-  public List<ConsistencyProblemInfo> check() throws IOException {
-    List<ConsistencyProblemInfo> problems = new ArrayList<>();
-
-    for (Account account : accounts.all()) {
-      if (account.getPreferredEmail() != null) {
-        if (!externalIds
-            .byAccount(account.getId())
-            .stream()
-            .anyMatch(e -> account.getPreferredEmail().equals(e.email()))) {
-          addError(
-              String.format(
-                  "Account '%s' has no external ID for its preferred email '%s'",
-                  account.getId().get(), account.getPreferredEmail()),
-              problems);
-        }
-      }
-    }
-
-    return problems;
-  }
-
-  private static void addError(String error, List<ConsistencyProblemInfo> problems) {
-    problems.add(new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, error));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsUpdate.java
deleted file mode 100644
index 6f11015..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ /dev/null
@@ -1,356 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
-import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import java.util.function.Consumer;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
-import org.eclipse.jgit.lib.Repository;
-
-/**
- * Updates accounts.
- *
- * <p>The account updates are written to NoteDb.
- *
- * <p>In NoteDb accounts are represented as user branches in the All-Users repository. Optionally a
- * user branch can contain a 'account.config' file that stores account properties, such as full
- * name, preferred email, status and the active flag. The timestamp of the first commit on a user
- * branch denotes the registration date. The initial commit on the user branch may be empty (since
- * having an 'account.config' is optional). See {@link AccountConfig} for details of the
- * 'account.config' file format.
- *
- * <p>On updating accounts the accounts are evicted from the account cache and thus reindexed. The
- * eviction from the account cache is done by the {@link ReindexAfterRefUpdate} class which receives
- * the event about updating the user branch that is triggered by this class.
- */
-@Singleton
-public class AccountsUpdate {
-  /**
-   * Factory to create an AccountsUpdate instance for updating accounts by the Gerrit server.
-   *
-   * <p>The Gerrit server identity will be used as author and committer for all commits that update
-   * the accounts.
-   */
-  @Singleton
-  public static class Server {
-    private final GitRepositoryManager repoManager;
-    private final GitReferenceUpdated gitRefUpdated;
-    private final AllUsersName allUsersName;
-    private final OutgoingEmailValidator emailValidator;
-    private final Provider<PersonIdent> serverIdent;
-    private final Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
-
-    @Inject
-    public Server(
-        GitRepositoryManager repoManager,
-        GitReferenceUpdated gitRefUpdated,
-        AllUsersName allUsersName,
-        OutgoingEmailValidator emailValidator,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent,
-        Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory) {
-      this.repoManager = repoManager;
-      this.gitRefUpdated = gitRefUpdated;
-      this.allUsersName = allUsersName;
-      this.emailValidator = emailValidator;
-      this.serverIdent = serverIdent;
-      this.metaDataUpdateServerFactory = metaDataUpdateServerFactory;
-    }
-
-    public AccountsUpdate create() {
-      PersonIdent i = serverIdent.get();
-      return new AccountsUpdate(
-          repoManager,
-          gitRefUpdated,
-          null,
-          allUsersName,
-          emailValidator,
-          i,
-          () -> metaDataUpdateServerFactory.get().create(allUsersName));
-    }
-  }
-
-  /**
-   * Factory to create an AccountsUpdate instance for updating accounts by the current user.
-   *
-   * <p>The identity of the current user will be used as author for all commits that update the
-   * accounts. The Gerrit server identity will be used as committer.
-   */
-  @Singleton
-  public static class User {
-    private final GitRepositoryManager repoManager;
-    private final GitReferenceUpdated gitRefUpdated;
-    private final AllUsersName allUsersName;
-    private final OutgoingEmailValidator emailValidator;
-    private final Provider<PersonIdent> serverIdent;
-    private final Provider<IdentifiedUser> identifiedUser;
-    private final Provider<MetaDataUpdate.User> metaDataUpdateUserFactory;
-
-    @Inject
-    public User(
-        GitRepositoryManager repoManager,
-        GitReferenceUpdated gitRefUpdated,
-        AllUsersName allUsersName,
-        OutgoingEmailValidator emailValidator,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent,
-        Provider<IdentifiedUser> identifiedUser,
-        Provider<MetaDataUpdate.User> metaDataUpdateUserFactory) {
-      this.repoManager = repoManager;
-      this.gitRefUpdated = gitRefUpdated;
-      this.allUsersName = allUsersName;
-      this.serverIdent = serverIdent;
-      this.emailValidator = emailValidator;
-      this.identifiedUser = identifiedUser;
-      this.metaDataUpdateUserFactory = metaDataUpdateUserFactory;
-    }
-
-    public AccountsUpdate create() {
-      IdentifiedUser user = identifiedUser.get();
-      PersonIdent i = serverIdent.get();
-      return new AccountsUpdate(
-          repoManager,
-          gitRefUpdated,
-          user,
-          allUsersName,
-          emailValidator,
-          createPersonIdent(i, user),
-          () -> metaDataUpdateUserFactory.get().create(allUsersName));
-    }
-
-    private PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
-      return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
-    }
-  }
-
-  private final GitRepositoryManager repoManager;
-  private final GitReferenceUpdated gitRefUpdated;
-  @Nullable private final IdentifiedUser currentUser;
-  private final AllUsersName allUsersName;
-  private final OutgoingEmailValidator emailValidator;
-  private final PersonIdent committerIdent;
-  private final MetaDataUpdateFactory metaDataUpdateFactory;
-
-  private AccountsUpdate(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      @Nullable IdentifiedUser currentUser,
-      AllUsersName allUsersName,
-      OutgoingEmailValidator emailValidator,
-      PersonIdent committerIdent,
-      MetaDataUpdateFactory metaDataUpdateFactory) {
-    this.repoManager = checkNotNull(repoManager, "repoManager");
-    this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
-    this.currentUser = currentUser;
-    this.allUsersName = checkNotNull(allUsersName, "allUsersName");
-    this.emailValidator = checkNotNull(emailValidator, "emailValidator");
-    this.committerIdent = checkNotNull(committerIdent, "committerIdent");
-    this.metaDataUpdateFactory = checkNotNull(metaDataUpdateFactory, "metaDataUpdateFactory");
-  }
-
-  /**
-   * Inserts a new account.
-   *
-   * @param accountId ID of the new account
-   * @param init consumer to populate the new account
-   * @return the newly created account
-   * @throws OrmDuplicateKeyException if the account already exists
-   * @throws IOException if updating the user branch fails
-   * @throws ConfigInvalidException if any of the account fields has an invalid value
-   */
-  public Account insert(Account.Id accountId, Consumer<Account> init)
-      throws OrmDuplicateKeyException, IOException, ConfigInvalidException {
-    AccountConfig accountConfig = read(accountId);
-    Account account = accountConfig.getNewAccount();
-    init.accept(account);
-
-    // Create in NoteDb
-    commitNew(accountConfig);
-    return account;
-  }
-
-  /**
-   * Gets the account and updates it atomically.
-   *
-   * <p>Changing the registration date of an account is not supported.
-   *
-   * @param accountId ID of the account
-   * @param consumer consumer to update the account, only invoked if the account exists
-   * @return the updated account, {@code null} if the account doesn't exist
-   * @throws IOException if updating the user branch fails
-   * @throws ConfigInvalidException if any of the account fields has an invalid value
-   */
-  public Account update(Account.Id accountId, Consumer<Account> consumer)
-      throws IOException, ConfigInvalidException {
-    return update(accountId, ImmutableList.of(consumer));
-  }
-
-  /**
-   * Gets the account and updates it atomically.
-   *
-   * <p>Changing the registration date of an account is not supported.
-   *
-   * @param accountId ID of the account
-   * @param consumers consumers to update the account, only invoked if the account exists
-   * @return the updated account, {@code null} if the account doesn't exist
-   * @throws IOException if updating the user branch fails
-   * @throws ConfigInvalidException if any of the account fields has an invalid value
-   */
-  public Account update(Account.Id accountId, List<Consumer<Account>> consumers)
-      throws IOException, ConfigInvalidException {
-    if (consumers.isEmpty()) {
-      return null;
-    }
-
-    AccountConfig accountConfig = read(accountId);
-    Account account = accountConfig.getAccount();
-    if (account != null) {
-      consumers.stream().forEach(c -> c.accept(account));
-      commit(accountConfig);
-    }
-
-    return account;
-  }
-
-  /**
-   * Replaces the account.
-   *
-   * <p>The existing account with the same account ID is overwritten by the given account. Choosing
-   * to overwrite an account means that any updates that were done to the account by a racing
-   * request after the account was read are lost. Updates are also lost if the account was read from
-   * a stale account index. This is why using {@link
-   * #update(com.google.gerrit.reviewdb.client.Account.Id, Consumer)} to do an atomic update is
-   * always preferred.
-   *
-   * <p>Changing the registration date of an account is not supported.
-   *
-   * @param account the new account
-   * @throws IOException if updating the user branch fails
-   * @throws ConfigInvalidException if any of the account fields has an invalid value
-   * @see #update(com.google.gerrit.reviewdb.client.Account.Id, Consumer)
-   */
-  public void replace(Account account) throws IOException, ConfigInvalidException {
-    AccountConfig accountConfig = read(account.getId());
-    accountConfig.setAccount(account);
-    commit(accountConfig);
-  }
-
-  /**
-   * Deletes the account.
-   *
-   * @param account the account that should be deleted
-   * @throws IOException if updating the user branch fails
-   */
-  public void delete(Account account) throws IOException {
-    deleteByKey(account.getId());
-  }
-
-  /**
-   * Deletes the account.
-   *
-   * @param accountId the ID of the account that should be deleted
-   * @throws IOException if updating the user branch fails
-   */
-  public void deleteByKey(Account.Id accountId) throws IOException {
-    deleteUserBranch(accountId);
-  }
-
-  private void deleteUserBranch(Account.Id accountId) throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      deleteUserBranch(repo, allUsersName, gitRefUpdated, currentUser, committerIdent, accountId);
-    }
-  }
-
-  public static void deleteUserBranch(
-      Repository repo,
-      Project.NameKey project,
-      GitReferenceUpdated gitRefUpdated,
-      @Nullable IdentifiedUser user,
-      PersonIdent refLogIdent,
-      Account.Id accountId)
-      throws IOException {
-    String refName = RefNames.refsUsers(accountId);
-    Ref ref = repo.exactRef(refName);
-    if (ref == null) {
-      return;
-    }
-
-    RefUpdate ru = repo.updateRef(refName);
-    ru.setExpectedOldObjectId(ref.getObjectId());
-    ru.setNewObjectId(ObjectId.zeroId());
-    ru.setForceUpdate(true);
-    ru.setRefLogIdent(refLogIdent);
-    ru.setRefLogMessage("Delete Account", true);
-    Result result = ru.delete();
-    if (result != Result.FORCED) {
-      throw new IOException(String.format("Failed to delete ref %s: %s", refName, result.name()));
-    }
-    gitRefUpdated.fire(project, ru, user != null ? user.getAccount() : null);
-  }
-
-  private AccountConfig read(Account.Id accountId) throws IOException, ConfigInvalidException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      AccountConfig accountConfig = new AccountConfig(emailValidator, accountId);
-      accountConfig.load(repo);
-      return accountConfig;
-    }
-  }
-
-  private void commitNew(AccountConfig accountConfig) throws IOException {
-    // When creating a new account we must allow empty commits so that the user branch gets created
-    // with an empty commit when no account properties are set and hence no 'account.config' file
-    // will be created.
-    commit(accountConfig, true);
-  }
-
-  private void commit(AccountConfig accountConfig) throws IOException {
-    commit(accountConfig, false);
-  }
-
-  private void commit(AccountConfig accountConfig, boolean allowEmptyCommit) throws IOException {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create()) {
-      md.setAllowEmpty(allowEmptyCommit);
-      accountConfig.commit(md);
-    }
-  }
-
-  @FunctionalInterface
-  private static interface MetaDataUpdateFactory {
-    MetaDataUpdate create() throws IOException;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
deleted file mode 100644
index 1c5495f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
+++ /dev/null
@@ -1,119 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.io.ByteSource;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.common.errors.InvalidSshKeyException;
-import com.google.gerrit.extensions.common.SshKeyInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RawInput;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gerrit.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;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.ssh.SshKeyCache;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.io.InputStream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class AddSshKey implements RestModifyView<AccountResource, Input> {
-  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;
-  private final SshKeyCache sshKeyCache;
-  private final AddKeySender.Factory addKeyFactory;
-
-  @Inject
-  AddSshKey(
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend,
-      VersionedAuthorizedKeys.Accessor authorizedKeys,
-      SshKeyCache sshKeyCache,
-      AddKeySender.Factory addKeyFactory) {
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-    this.authorizedKeys = authorizedKeys;
-    this.sshKeyCache = sshKeyCache;
-    this.addKeyFactory = addKeyFactory;
-  }
-
-  @Override
-  public Response<SshKeyInfo> apply(AccountResource rsrc, Input input)
-      throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
-    }
-    return apply(rsrc.getUser(), input);
-  }
-
-  public Response<SshKeyInfo> apply(IdentifiedUser user, Input input)
-      throws BadRequestException, IOException, ConfigInvalidException {
-    if (input == null) {
-      input = new Input();
-    }
-    if (input.raw == null) {
-      throw new BadRequestException("SSH public key missing");
-    }
-
-    final RawInput rawKey = input.raw;
-    String sshPublicKey =
-        new ByteSource() {
-          @Override
-          public InputStream openStream() throws IOException {
-            return rawKey.getInputStream();
-          }
-        }.asCharSource(UTF_8).read();
-
-    try {
-      AccountSshKey sshKey = authorizedKeys.addKey(user.getAccountId(), sshPublicKey);
-
-      try {
-        addKeyFactory.create(user, sshKey).send();
-      } catch (EmailException e) {
-        log.error(
-            "Cannot send SSH key added message to " + user.getAccount().getPreferredEmail(), e);
-      }
-
-      sshKeyCache.evict(user.getUserName());
-      return Response.<SshKeyInfo>created(GetSshKeys.newSshKeyInfo(sshKey));
-    } catch (InvalidSshKeyException e) {
-      throw new BadRequestException(e.getMessage());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java
deleted file mode 100644
index 08eecd7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java
+++ /dev/null
@@ -1,98 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
-import com.google.gerrit.extensions.api.access.PluginPermission;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountResource.Capability;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-class Capabilities implements ChildCollection<AccountResource, AccountResource.Capability> {
-  private final Provider<CurrentUser> self;
-  private final PermissionBackend permissionBackend;
-  private final DynamicMap<RestView<AccountResource.Capability>> views;
-  private final Provider<GetCapabilities> get;
-
-  @Inject
-  Capabilities(
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend,
-      DynamicMap<RestView<AccountResource.Capability>> views,
-      Provider<GetCapabilities> get) {
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-    this.views = views;
-    this.get = get;
-  }
-
-  @Override
-  public GetCapabilities list() throws ResourceNotFoundException {
-    return get.get();
-  }
-
-  @Override
-  public Capability parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, AuthException, PermissionBackendException {
-    IdentifiedUser target = parent.getUser();
-    if (self.get() != target) {
-      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
-    }
-
-    GlobalOrPluginPermission perm = parse(id);
-    if (permissionBackend.user(target).test(perm)) {
-      return new AccountResource.Capability(target, perm.permissionName());
-    }
-    throw new ResourceNotFoundException(id);
-  }
-
-  private GlobalOrPluginPermission parse(IdString id) throws ResourceNotFoundException {
-    String name = id.get();
-    GlobalOrPluginPermission perm = GlobalPermission.byName(name);
-    if (perm != null) {
-      return perm;
-    }
-
-    int dash = name.lastIndexOf('-');
-    if (dash < 0) {
-      throw new ResourceNotFoundException(id);
-    }
-
-    String pluginName = name.substring(0, dash);
-    String capability = name.substring(dash + 1);
-    if (pluginName.isEmpty() || capability.isEmpty()) {
-      throw new ResourceNotFoundException(id);
-    }
-    return new PluginPermission(pluginName, capability);
-  }
-
-  @Override
-  public DynamicMap<RestView<Capability>> views() {
-    return views;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityCollection.java
deleted file mode 100644
index 05d771e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityCollection.java
+++ /dev/null
@@ -1,151 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.server.config.AdministrateServerGroups;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-/** Caches active {@link GlobalCapability} set for a site. */
-public class CapabilityCollection {
-  public interface Factory {
-    CapabilityCollection create(@Nullable AccessSection section);
-  }
-
-  private final SystemGroupBackend systemGroupBackend;
-  private final ImmutableMap<String, ImmutableList<PermissionRule>> permissions;
-
-  public final ImmutableList<PermissionRule> administrateServer;
-  public final ImmutableList<PermissionRule> batchChangesLimit;
-  public final ImmutableList<PermissionRule> emailReviewers;
-  public final ImmutableList<PermissionRule> priority;
-  public final ImmutableList<PermissionRule> queryLimit;
-
-  @Inject
-  CapabilityCollection(
-      SystemGroupBackend systemGroupBackend,
-      @AdministrateServerGroups ImmutableSet<GroupReference> admins,
-      @Assisted @Nullable AccessSection section) {
-    this.systemGroupBackend = systemGroupBackend;
-
-    if (section == null) {
-      section = new AccessSection(AccessSection.GLOBAL_CAPABILITIES);
-    }
-
-    Map<String, List<PermissionRule>> tmp = new HashMap<>();
-    for (Permission permission : section.getPermissions()) {
-      for (PermissionRule rule : permission.getRules()) {
-        if (!permission.getName().equals(GlobalCapability.EMAIL_REVIEWERS)
-            && rule.getAction() == PermissionRule.Action.DENY) {
-          continue;
-        }
-
-        List<PermissionRule> r = tmp.get(permission.getName());
-        if (r == null) {
-          r = new ArrayList<>(2);
-          tmp.put(permission.getName(), r);
-        }
-        r.add(rule);
-      }
-    }
-    configureDefaults(tmp, section);
-    if (!tmp.containsKey(GlobalCapability.ADMINISTRATE_SERVER) && !admins.isEmpty()) {
-      tmp.put(GlobalCapability.ADMINISTRATE_SERVER, ImmutableList.<PermissionRule>of());
-    }
-
-    ImmutableMap.Builder<String, ImmutableList<PermissionRule>> m = ImmutableMap.builder();
-    for (Map.Entry<String, List<PermissionRule>> e : tmp.entrySet()) {
-      List<PermissionRule> rules = e.getValue();
-      if (GlobalCapability.ADMINISTRATE_SERVER.equals(e.getKey())) {
-        rules = mergeAdmin(admins, rules);
-      }
-      m.put(e.getKey(), ImmutableList.copyOf(rules));
-    }
-    permissions = m.build();
-
-    administrateServer = getPermission(GlobalCapability.ADMINISTRATE_SERVER);
-    batchChangesLimit = getPermission(GlobalCapability.BATCH_CHANGES_LIMIT);
-    emailReviewers = getPermission(GlobalCapability.EMAIL_REVIEWERS);
-    priority = getPermission(GlobalCapability.PRIORITY);
-    queryLimit = getPermission(GlobalCapability.QUERY_LIMIT);
-  }
-
-  private static List<PermissionRule> mergeAdmin(
-      Set<GroupReference> admins, List<PermissionRule> rules) {
-    if (admins.isEmpty()) {
-      return rules;
-    }
-
-    List<PermissionRule> r = new ArrayList<>(admins.size() + rules.size());
-    for (GroupReference g : admins) {
-      r.add(new PermissionRule(g));
-    }
-    for (PermissionRule rule : rules) {
-      if (!admins.contains(rule.getGroup())) {
-        r.add(rule);
-      }
-    }
-    return r;
-  }
-
-  public ImmutableList<PermissionRule> getPermission(String permissionName) {
-    ImmutableList<PermissionRule> r = permissions.get(permissionName);
-    return r != null ? r : ImmutableList.<PermissionRule>of();
-  }
-
-  private void configureDefaults(Map<String, List<PermissionRule>> out, AccessSection section) {
-    configureDefault(
-        out,
-        section,
-        GlobalCapability.QUERY_LIMIT,
-        systemGroupBackend.getGroup(SystemGroupBackend.ANONYMOUS_USERS));
-  }
-
-  private static void configureDefault(
-      Map<String, List<PermissionRule>> out,
-      AccessSection section,
-      String capName,
-      GroupReference group) {
-    if (doesNotDeclare(section, capName)) {
-      PermissionRange.WithDefaults range = GlobalCapability.getRange(capName);
-      if (range != null) {
-        PermissionRule rule = new PermissionRule(group);
-        rule.setRange(range.getDefaultMin(), range.getDefaultMax());
-        out.put(capName, Collections.singletonList(rule));
-      }
-    }
-  }
-
-  private static boolean doesNotDeclare(AccessSection section, String capName) {
-    return section.getPermission(capName) == null;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
deleted file mode 100644
index 3d1a5f6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
+++ /dev/null
@@ -1,118 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.errors.NameAlreadyUsedException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
-import com.google.gerrit.server.ssh.SshKeyCache;
-import com.google.gwtjsonrpc.common.VoidResult;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.concurrent.Callable;
-import java.util.regex.Pattern;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-/** Operation to change the username of an account. */
-public class ChangeUserName implements Callable<VoidResult> {
-  public static final String USERNAME_CANNOT_BE_CHANGED = "Username cannot be changed.";
-
-  private static final Pattern USER_NAME_PATTERN = Pattern.compile(Account.USER_NAME_PATTERN);
-
-  /** Generic factory to change any user's username. */
-  public interface Factory {
-    ChangeUserName create(IdentifiedUser user, String newUsername);
-  }
-
-  private final SshKeyCache sshKeyCache;
-  private final ExternalIds externalIds;
-  private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
-
-  private final IdentifiedUser user;
-  private final String newUsername;
-
-  @Inject
-  ChangeUserName(
-      SshKeyCache sshKeyCache,
-      ExternalIds externalIds,
-      ExternalIdsUpdate.Server externalIdsUpdateFactory,
-      @Assisted IdentifiedUser user,
-      @Nullable @Assisted String newUsername) {
-    this.sshKeyCache = sshKeyCache;
-    this.externalIds = externalIds;
-    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
-    this.user = user;
-    this.newUsername = newUsername;
-  }
-
-  @Override
-  public VoidResult call()
-      throws OrmException, NameAlreadyUsedException, InvalidUserNameException, IOException,
-          ConfigInvalidException {
-    Collection<ExternalId> old = externalIds.byAccount(user.getAccountId(), SCHEME_USERNAME);
-    if (!old.isEmpty()) {
-      throw new IllegalStateException(USERNAME_CANNOT_BE_CHANGED);
-    }
-
-    ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
-    if (newUsername != null && !newUsername.isEmpty()) {
-      if (!USER_NAME_PATTERN.matcher(newUsername).matches()) {
-        throw new InvalidUserNameException();
-      }
-
-      ExternalId.Key key = ExternalId.Key.create(SCHEME_USERNAME, newUsername);
-      try {
-        String password = null;
-        for (ExternalId i : old) {
-          if (i.password() != null) {
-            password = i.password();
-          }
-        }
-        externalIdsUpdate.insert(ExternalId.create(key, user.getAccountId(), null, password));
-      } catch (OrmDuplicateKeyException dupeErr) {
-        // If we are using this identity, don't report the exception.
-        //
-        ExternalId other = externalIds.get(key);
-        if (other != null && other.accountId().equals(user.getAccountId())) {
-          return VoidResult.INSTANCE;
-        }
-
-        // Otherwise, someone else has this identity.
-        //
-        throw new NameAlreadyUsedException(newUsername);
-      }
-    }
-
-    // If we have any older user names, remove them.
-    //
-    externalIdsUpdate.delete(old);
-    for (ExternalId extId : old) {
-      sshKeyCache.evict(extId.key().id());
-    }
-
-    sshKeyCache.evict(newUsername);
-    return VoidResult.INSTANCE;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
deleted file mode 100644
index 11b7bd8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
+++ /dev/null
@@ -1,214 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.InvalidSshKeyException;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.api.accounts.AccountInput;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
-import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
-import com.google.gerrit.server.group.GroupsCollection;
-import com.google.gerrit.server.group.GroupsUpdate;
-import com.google.gerrit.server.group.UserInitiated;
-import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.gerrit.server.ssh.SshKeyCache;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@RequiresCapability(GlobalCapability.CREATE_ACCOUNT)
-public class CreateAccount implements RestModifyView<TopLevelResource, AccountInput> {
-  public interface Factory {
-    CreateAccount create(String username);
-  }
-
-  private final ReviewDb db;
-  private final Sequences seq;
-  private final GroupsCollection groupsCollection;
-  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
-  private final SshKeyCache sshKeyCache;
-  private final AccountsUpdate.User accountsUpdate;
-  private final AccountLoader.Factory infoLoader;
-  private final DynamicSet<AccountExternalIdCreator> externalIdCreators;
-  private final ExternalIds externalIds;
-  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
-  private final Provider<GroupsUpdate> groupsUpdate;
-  private final OutgoingEmailValidator validator;
-  private final String username;
-
-  @Inject
-  CreateAccount(
-      ReviewDb db,
-      Sequences seq,
-      GroupsCollection groupsCollection,
-      VersionedAuthorizedKeys.Accessor authorizedKeys,
-      SshKeyCache sshKeyCache,
-      AccountsUpdate.User accountsUpdate,
-      AccountLoader.Factory infoLoader,
-      DynamicSet<AccountExternalIdCreator> externalIdCreators,
-      ExternalIds externalIds,
-      ExternalIdsUpdate.User externalIdsUpdateFactory,
-      @UserInitiated Provider<GroupsUpdate> groupsUpdate,
-      OutgoingEmailValidator validator,
-      @Assisted String username) {
-    this.db = db;
-    this.seq = seq;
-    this.groupsCollection = groupsCollection;
-    this.authorizedKeys = authorizedKeys;
-    this.sshKeyCache = sshKeyCache;
-    this.accountsUpdate = accountsUpdate;
-    this.infoLoader = infoLoader;
-    this.externalIdCreators = externalIdCreators;
-    this.externalIds = externalIds;
-    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
-    this.groupsUpdate = groupsUpdate;
-    this.validator = validator;
-    this.username = username;
-  }
-
-  @Override
-  public Response<AccountInfo> apply(TopLevelResource rsrc, @Nullable AccountInput input)
-      throws BadRequestException, ResourceConflictException, UnprocessableEntityException,
-          OrmException, IOException, ConfigInvalidException {
-    return apply(input != null ? input : new AccountInput());
-  }
-
-  public Response<AccountInfo> apply(AccountInput input)
-      throws BadRequestException, ResourceConflictException, UnprocessableEntityException,
-          OrmException, IOException, ConfigInvalidException {
-    if (input.username != null && !username.equals(input.username)) {
-      throw new BadRequestException("username must match URL");
-    }
-
-    if (!username.matches(Account.USER_NAME_PATTERN)) {
-      throw new BadRequestException(
-          "Username '" + username + "' must contain only letters, numbers, _, - or .");
-    }
-
-    Set<AccountGroup.UUID> groups = parseGroups(input.groups);
-
-    Account.Id id = new Account.Id(seq.nextAccountId());
-
-    ExternalId extUser = ExternalId.createUsername(username, id, input.httpPassword);
-    if (externalIds.get(extUser.key()) != null) {
-      throw new ResourceConflictException("username '" + username + "' already exists");
-    }
-    if (input.email != null) {
-      if (externalIds.get(ExternalId.Key.create(SCHEME_MAILTO, input.email)) != null) {
-        throw new UnprocessableEntityException("email '" + input.email + "' already exists");
-      }
-      if (!validator.isValid(input.email)) {
-        throw new BadRequestException("invalid email address");
-      }
-    }
-
-    List<ExternalId> extIds = new ArrayList<>();
-    extIds.add(extUser);
-    for (AccountExternalIdCreator c : externalIdCreators) {
-      extIds.addAll(c.create(id, username, input.email));
-    }
-
-    ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
-    try {
-      externalIdsUpdate.insert(extIds);
-    } catch (OrmDuplicateKeyException duplicateKey) {
-      throw new ResourceConflictException("username '" + username + "' already exists");
-    }
-
-    if (input.email != null) {
-      try {
-        externalIdsUpdate.insert(ExternalId.createEmail(id, input.email));
-      } catch (OrmDuplicateKeyException duplicateKey) {
-        try {
-          externalIdsUpdate.delete(extUser);
-        } catch (IOException | ConfigInvalidException cleanupError) {
-          // Ignored
-        }
-        throw new UnprocessableEntityException("email '" + input.email + "' already exists");
-      }
-    }
-
-    accountsUpdate
-        .create()
-        .insert(
-            id,
-            a -> {
-              a.setFullName(input.name);
-              a.setPreferredEmail(input.email);
-            });
-
-    for (AccountGroup.UUID groupUuid : groups) {
-      try {
-        groupsUpdate.get().addGroupMember(db, groupUuid, id);
-      } catch (NoSuchGroupException e) {
-        throw new UnprocessableEntityException(String.format("Group %s not found", groupUuid));
-      }
-    }
-
-    if (input.sshKey != null) {
-      try {
-        authorizedKeys.addKey(id, input.sshKey);
-        sshKeyCache.evict(username);
-      } catch (InvalidSshKeyException e) {
-        throw new BadRequestException(e.getMessage());
-      }
-    }
-
-    AccountLoader loader = infoLoader.create(true);
-    AccountInfo info = loader.get(id);
-    loader.fill();
-    return Response.created(info);
-  }
-
-  private Set<AccountGroup.UUID> parseGroups(List<String> groups)
-      throws UnprocessableEntityException {
-    Set<AccountGroup.UUID> groupUuids = new HashSet<>();
-    if (groups != null) {
-      for (String g : groups) {
-        GroupDescription.Internal internalGroup = groupsCollection.parseInternal(g);
-        groupUuids.add(internalGroup.getGroupUUID());
-      }
-    }
-    return groupUuids;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
deleted file mode 100644
index dd02b0b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
+++ /dev/null
@@ -1,153 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.extensions.client.AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT;
-
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.api.accounts.EmailInput;
-import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gerrit.extensions.common.EmailInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class CreateEmail implements RestModifyView<AccountResource, EmailInput> {
-  private static final Logger log = LoggerFactory.getLogger(CreateEmail.class);
-
-  public interface Factory {
-    CreateEmail create(String email);
-  }
-
-  private final Provider<CurrentUser> self;
-  private final Realm realm;
-  private final PermissionBackend permissionBackend;
-  private final AccountManager accountManager;
-  private final RegisterNewEmailSender.Factory registerNewEmailFactory;
-  private final PutPreferred putPreferred;
-  private final OutgoingEmailValidator validator;
-  private final String email;
-  private final boolean isDevMode;
-
-  @Inject
-  CreateEmail(
-      Provider<CurrentUser> self,
-      Realm realm,
-      PermissionBackend permissionBackend,
-      AuthConfig authConfig,
-      AccountManager accountManager,
-      RegisterNewEmailSender.Factory registerNewEmailFactory,
-      PutPreferred putPreferred,
-      OutgoingEmailValidator validator,
-      @Assisted String email) {
-    this.self = self;
-    this.realm = realm;
-    this.permissionBackend = permissionBackend;
-    this.accountManager = accountManager;
-    this.registerNewEmailFactory = registerNewEmailFactory;
-    this.putPreferred = putPreferred;
-    this.validator = validator;
-    this.email = email != null ? email.trim() : null;
-    this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
-  }
-
-  @Override
-  public Response<EmailInfo> apply(AccountResource rsrc, EmailInput input)
-      throws AuthException, BadRequestException, ResourceConflictException,
-          ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
-          IOException, ConfigInvalidException, PermissionBackendException {
-    if (input == null) {
-      input = new EmailInput();
-    }
-
-    if (self.get() != rsrc.getUser() || input.noConfirmation) {
-      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
-    }
-
-    if (!realm.allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL)) {
-      throw new MethodNotAllowedException("realm does not allow adding emails");
-    }
-
-    return apply(rsrc.getUser(), input);
-  }
-
-  /** To be used from plugins that want to create emails without permission checks. */
-  public Response<EmailInfo> apply(IdentifiedUser user, EmailInput input)
-      throws AuthException, BadRequestException, ResourceConflictException,
-          ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
-          IOException, ConfigInvalidException, PermissionBackendException {
-    if (input == null) {
-      input = new EmailInput();
-    }
-
-    if (input.email != null && !email.equals(input.email)) {
-      throw new BadRequestException("email address must match URL");
-    }
-
-    if (!validator.isValid(email)) {
-      throw new BadRequestException("invalid email address");
-    }
-
-    EmailInfo info = new EmailInfo();
-    info.email = email;
-    if (input.noConfirmation || isDevMode) {
-      if (isDevMode) {
-        log.warn("skipping email validation in developer mode");
-      }
-      try {
-        accountManager.link(user.getAccountId(), AuthRequest.forEmail(email));
-      } catch (AccountException e) {
-        throw new ResourceConflictException(e.getMessage());
-      }
-      if (input.preferred) {
-        putPreferred.apply(new AccountResource.Email(user, email), null);
-        info.preferred = true;
-      }
-    } else {
-      try {
-        RegisterNewEmailSender sender = registerNewEmailFactory.create(email);
-        if (!sender.isAllowed()) {
-          throw new MethodNotAllowedException("Not allowed to add email address " + email);
-        }
-        sender.send();
-        info.pendingConfirmation = true;
-      } catch (EmailException | RuntimeException e) {
-        log.error("Cannot send email verification message to " + email, e);
-        throw e;
-      }
-    }
-    return Response.created(info);
-  }
-}
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
deleted file mode 100644
index 43669c0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-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;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
-@Singleton
-public class DeleteActive implements RestModifyView<AccountResource, Input> {
-  public static class Input {}
-
-  private final Provider<IdentifiedUser> self;
-  private final SetInactiveFlag setInactiveFlag;
-
-  @Inject
-  DeleteActive(SetInactiveFlag setInactiveFlag, Provider<IdentifiedUser> self) {
-    this.setInactiveFlag = setInactiveFlag;
-    this.self = self;
-  }
-
-  @Override
-  public Response<?> apply(AccountResource rsrc, Input input)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
-    if (self.get() == rsrc.getUser()) {
-      throw new ResourceConflictException("cannot deactivate own account");
-    }
-    return setInactiveFlag.deactivate(rsrc.getUser().getAccountId());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
deleted file mode 100644
index aec3a14..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
+++ /dev/null
@@ -1,102 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static java.util.stream.Collectors.toSet;
-
-import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.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;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class DeleteEmail implements RestModifyView<AccountResource.Email, Input> {
-  public static class Input {}
-
-  private final Provider<CurrentUser> self;
-  private final Realm realm;
-  private final PermissionBackend permissionBackend;
-  private final AccountManager accountManager;
-  private final ExternalIds externalIds;
-
-  @Inject
-  DeleteEmail(
-      Provider<CurrentUser> self,
-      Realm realm,
-      PermissionBackend permissionBackend,
-      AccountManager accountManager,
-      ExternalIds externalIds) {
-    this.self = self;
-    this.realm = realm;
-    this.permissionBackend = permissionBackend;
-    this.accountManager = accountManager;
-    this.externalIds = externalIds;
-  }
-
-  @Override
-  public Response<?> apply(AccountResource.Email rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, ResourceConflictException,
-          MethodNotAllowedException, OrmException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
-    }
-    return apply(rsrc.getUser(), rsrc.getEmail());
-  }
-
-  public Response<?> apply(IdentifiedUser user, String email)
-      throws ResourceNotFoundException, ResourceConflictException, MethodNotAllowedException,
-          OrmException, IOException, ConfigInvalidException {
-    if (!realm.allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL)) {
-      throw new MethodNotAllowedException("realm does not allow deleting emails");
-    }
-
-    Set<ExternalId> extIds =
-        externalIds
-            .byAccount(user.getAccountId())
-            .stream()
-            .filter(e -> email.equals(e.email()))
-            .collect(toSet());
-    if (extIds.isEmpty()) {
-      throw new ResourceNotFoundException(email);
-    }
-
-    try {
-      accountManager.unlink(
-          user.getAccountId(), extIds.stream().map(e -> e.key()).collect(toSet()));
-    } catch (AccountException e) {
-      throw new ResourceConflictException(e.getMessage());
-    }
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
deleted file mode 100644
index 72c1a41..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-import static java.util.stream.Collectors.toMap;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class DeleteExternalIds implements RestModifyView<AccountResource, List<String>> {
-  private final PermissionBackend permissionBackend;
-  private final AccountManager accountManager;
-  private final ExternalIds externalIds;
-  private final Provider<CurrentUser> self;
-
-  @Inject
-  DeleteExternalIds(
-      PermissionBackend permissionBackend,
-      AccountManager accountManager,
-      ExternalIds externalIds,
-      Provider<CurrentUser> self) {
-    this.permissionBackend = permissionBackend;
-    this.accountManager = accountManager;
-    this.externalIds = externalIds;
-    this.self = self;
-  }
-
-  @Override
-  public Response<?> apply(AccountResource resource, List<String> extIds)
-      throws RestApiException, IOException, OrmException, ConfigInvalidException,
-          PermissionBackendException {
-    if (self.get() != resource.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.ACCESS_DATABASE);
-    }
-
-    if (extIds == null || extIds.size() == 0) {
-      throw new BadRequestException("external IDs are required");
-    }
-
-    Map<ExternalId.Key, ExternalId> externalIdMap =
-        externalIds
-            .byAccount(resource.getUser().getAccountId())
-            .stream()
-            .collect(toMap(i -> i.key(), i -> i));
-
-    List<ExternalId> toDelete = new ArrayList<>();
-    ExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
-    for (String externalIdStr : extIds) {
-      ExternalId id = externalIdMap.get(ExternalId.Key.parse(externalIdStr));
-
-      if (id == null) {
-        throw new UnprocessableEntityException(
-            String.format("External id %s does not exist", externalIdStr));
-      }
-
-      if ((!id.isScheme(SCHEME_USERNAME))
-          && ((last == null) || (!last.get().equals(id.key().get())))) {
-        toDelete.add(id);
-      } else {
-        throw new ResourceConflictException(
-            String.format("External id %s cannot be deleted", externalIdStr));
-      }
-    }
-
-    try {
-      accountManager.unlink(
-          resource.getUser().getAccountId(), toDelete.stream().map(e -> e.key()).collect(toSet()));
-    } catch (AccountException e) {
-      throw new ResourceConflictException(e.getMessage());
-    }
-
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
deleted file mode 100644
index f1ecd29..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.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;
-import com.google.gerrit.server.ssh.SshKeyCache;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-
-@Singleton
-public class DeleteSshKey implements RestModifyView<AccountResource.SshKey, Input> {
-  public static class Input {}
-
-  private final Provider<CurrentUser> self;
-  private final PermissionBackend permissionBackend;
-  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
-  private final SshKeyCache sshKeyCache;
-
-  @Inject
-  DeleteSshKey(
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend,
-      VersionedAuthorizedKeys.Accessor authorizedKeys,
-      SshKeyCache sshKeyCache) {
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-    this.authorizedKeys = authorizedKeys;
-    this.sshKeyCache = sshKeyCache;
-  }
-
-  @Override
-  public Response<?> apply(AccountResource.SshKey rsrc, Input input)
-      throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException, PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
-    }
-
-    authorizedKeys.deleteKey(rsrc.getUser().getAccountId(), rsrc.getSshKey().getKey().get());
-    sshKeyCache.evict(rsrc.getUser().getUserName());
-
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
deleted file mode 100644
index ffb405c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.gerrit.extensions.client.ProjectWatchInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import java.util.Objects;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class DeleteWatchedProjects
-    implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
-  private final Provider<IdentifiedUser> self;
-  private final PermissionBackend permissionBackend;
-  private final AccountCache accountCache;
-  private final WatchConfig.Accessor watchConfig;
-
-  @Inject
-  DeleteWatchedProjects(
-      Provider<IdentifiedUser> self,
-      PermissionBackend permissionBackend,
-      AccountCache accountCache,
-      WatchConfig.Accessor watchConfig) {
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-    this.accountCache = accountCache;
-    this.watchConfig = watchConfig;
-  }
-
-  @Override
-  public Response<?> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
-      throws AuthException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException, PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
-    }
-    if (input == null) {
-      return Response.none();
-    }
-
-    Account.Id accountId = rsrc.getUser().getAccountId();
-    watchConfig.deleteProjectWatches(
-        accountId,
-        input
-            .stream()
-            .filter(Objects::nonNull)
-            .map(w -> ProjectWatchKey.create(new Project.NameKey(w.project), w.filter))
-            .collect(toList()));
-    accountCache.evict(accountId);
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailsCollection.java
deleted file mode 100644
index c8c1db8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailsCollection.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountResource.Email;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class EmailsCollection
-    implements ChildCollection<AccountResource, AccountResource.Email>,
-        AcceptsCreate<AccountResource> {
-  private final DynamicMap<RestView<AccountResource.Email>> views;
-  private final GetEmails list;
-  private final Provider<CurrentUser> self;
-  private final PermissionBackend permissionBackend;
-  private final CreateEmail.Factory createEmailFactory;
-
-  @Inject
-  EmailsCollection(
-      DynamicMap<RestView<AccountResource.Email>> views,
-      GetEmails list,
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend,
-      CreateEmail.Factory createEmailFactory) {
-    this.views = views;
-    this.list = list;
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-    this.createEmailFactory = createEmailFactory;
-  }
-
-  @Override
-  public RestView<AccountResource> list() {
-    return list;
-  }
-
-  @Override
-  public AccountResource.Email parse(AccountResource rsrc, IdString id)
-      throws ResourceNotFoundException, PermissionBackendException, AuthException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
-    }
-
-    if ("preferred".equals(id.get())) {
-      String email = rsrc.getUser().getAccount().getPreferredEmail();
-      if (Strings.isNullOrEmpty(email)) {
-        throw new ResourceNotFoundException(id);
-      }
-      return new AccountResource.Email(rsrc.getUser(), email);
-    } else if (rsrc.getUser().hasEmailAddress(id.get())) {
-      return new AccountResource.Email(rsrc.getUser(), id.get());
-    } else {
-      throw new ResourceNotFoundException(id);
-    }
-  }
-
-  @Override
-  public DynamicMap<RestView<Email>> views() {
-    return views;
-  }
-
-  @Override
-  public CreateEmail create(AccountResource parent, IdString email) {
-    return createEmailFactory.create(email.get());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java
deleted file mode 100644
index 8043773..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java
+++ /dev/null
@@ -1,198 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.config.ConfigUtil.loadSection;
-import static com.google.gerrit.server.config.ConfigUtil.skipField;
-import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
-import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_MATCH;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_TOKEN;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
-import static com.google.gerrit.server.git.UserConfigSections.URL_ALIAS;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.MenuItem;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UserConfigSections;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.lang.reflect.Field;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class GeneralPreferencesLoader {
-  private static final Logger log = LoggerFactory.getLogger(GeneralPreferencesLoader.class);
-
-  private final GitRepositoryManager gitMgr;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  public GeneralPreferencesLoader(GitRepositoryManager gitMgr, AllUsersName allUsersName) {
-    this.gitMgr = gitMgr;
-    this.allUsersName = allUsersName;
-  }
-
-  public GeneralPreferencesInfo load(Account.Id id)
-      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
-    return read(id, null);
-  }
-
-  public GeneralPreferencesInfo merge(Account.Id id, GeneralPreferencesInfo in)
-      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
-    return read(id, in);
-  }
-
-  private GeneralPreferencesInfo read(Account.Id id, GeneralPreferencesInfo in)
-      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
-    try (Repository allUsers = gitMgr.openRepository(allUsersName)) {
-      // Load all users default prefs
-      VersionedAccountPreferences dp = VersionedAccountPreferences.forDefault();
-      dp.load(allUsers);
-
-      // Load user prefs
-      VersionedAccountPreferences p = VersionedAccountPreferences.forUser(id);
-      p.load(allUsers);
-      GeneralPreferencesInfo r =
-          loadSection(
-              p.getConfig(),
-              UserConfigSections.GENERAL,
-              null,
-              new GeneralPreferencesInfo(),
-              readDefaultsFromGit(dp.getConfig(), in),
-              in);
-      loadChangeTableColumns(r, p, dp);
-      return loadMyMenusAndUrlAliases(r, p, dp);
-    }
-  }
-
-  public GeneralPreferencesInfo readDefaultsFromGit(Repository git, GeneralPreferencesInfo in)
-      throws ConfigInvalidException, IOException {
-    VersionedAccountPreferences dp = VersionedAccountPreferences.forDefault();
-    dp.load(git);
-    return readDefaultsFromGit(dp.getConfig(), in);
-  }
-
-  private GeneralPreferencesInfo readDefaultsFromGit(Config config, GeneralPreferencesInfo in)
-      throws ConfigInvalidException {
-    GeneralPreferencesInfo allUserPrefs = new GeneralPreferencesInfo();
-    loadSection(
-        config,
-        UserConfigSections.GENERAL,
-        null,
-        allUserPrefs,
-        GeneralPreferencesInfo.defaults(),
-        in);
-    return updateDefaults(allUserPrefs);
-  }
-
-  private GeneralPreferencesInfo updateDefaults(GeneralPreferencesInfo input) {
-    GeneralPreferencesInfo result = GeneralPreferencesInfo.defaults();
-    try {
-      for (Field field : input.getClass().getDeclaredFields()) {
-        if (skipField(field)) {
-          continue;
-        }
-        Object newVal = field.get(input);
-        if (newVal != null) {
-          field.set(result, newVal);
-        }
-      }
-    } catch (IllegalAccessException e) {
-      log.error("Cannot get default general preferences from " + allUsersName.get(), e);
-      return GeneralPreferencesInfo.defaults();
-    }
-    return result;
-  }
-
-  public GeneralPreferencesInfo loadMyMenusAndUrlAliases(
-      GeneralPreferencesInfo r, VersionedAccountPreferences v, VersionedAccountPreferences d) {
-    r.my = my(v);
-    if (r.my.isEmpty() && !v.isDefaults()) {
-      r.my = my(d);
-    }
-    if (r.my.isEmpty()) {
-      r.my.add(new MenuItem("Changes", "#/dashboard/self", null));
-      r.my.add(new MenuItem("Draft Comments", "#/q/has:draft", null));
-      r.my.add(new MenuItem("Edits", "#/q/has:edit", null));
-      r.my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open", null));
-      r.my.add(new MenuItem("Starred Changes", "#/q/is:starred", null));
-      r.my.add(new MenuItem("Groups", "#/groups/self", null));
-    }
-
-    r.urlAliases = urlAliases(v);
-    if (r.urlAliases == null && !v.isDefaults()) {
-      r.urlAliases = urlAliases(d);
-    }
-    return r;
-  }
-
-  private static List<MenuItem> my(VersionedAccountPreferences v) {
-    List<MenuItem> my = new ArrayList<>();
-    Config cfg = v.getConfig();
-    for (String subsection : cfg.getSubsections(UserConfigSections.MY)) {
-      String url = my(cfg, subsection, KEY_URL, "#/");
-      String target = my(cfg, subsection, KEY_TARGET, url.startsWith("#") ? null : "_blank");
-      my.add(new MenuItem(subsection, url, target, my(cfg, subsection, KEY_ID, null)));
-    }
-    return my;
-  }
-
-  private static String my(Config cfg, String subsection, String key, String defaultValue) {
-    String val = cfg.getString(UserConfigSections.MY, subsection, key);
-    return !Strings.isNullOrEmpty(val) ? val : defaultValue;
-  }
-
-  public GeneralPreferencesInfo loadChangeTableColumns(
-      GeneralPreferencesInfo r, VersionedAccountPreferences v, VersionedAccountPreferences d) {
-    r.changeTable = changeTable(v);
-
-    if (r.changeTable.isEmpty() && !v.isDefaults()) {
-      r.changeTable = changeTable(d);
-    }
-    return r;
-  }
-
-  private static List<String> changeTable(VersionedAccountPreferences v) {
-    return Lists.newArrayList(v.getConfig().getStringList(CHANGE_TABLE, null, CHANGE_TABLE_COLUMN));
-  }
-
-  private static Map<String, String> urlAliases(VersionedAccountPreferences v) {
-    HashMap<String, String> urlAliases = new HashMap<>();
-    Config cfg = v.getConfig();
-    for (String subsection : cfg.getSubsections(URL_ALIAS)) {
-      urlAliases.put(
-          cfg.getString(URL_ALIAS, subsection, KEY_MATCH),
-          cfg.getString(URL_ALIAS, subsection, KEY_TOKEN));
-    }
-    return !urlAliases.isEmpty() ? urlAliases : null;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAccount.java
deleted file mode 100644
index 05f8300..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAccount.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetAccount implements RestReadView<AccountResource> {
-  private final AccountLoader.Factory infoFactory;
-
-  @Inject
-  GetAccount(AccountLoader.Factory infoFactory) {
-    this.infoFactory = infoFactory;
-  }
-
-  @Override
-  public AccountInfo apply(AccountResource rsrc) throws OrmException {
-    AccountLoader loader = infoFactory.create(true);
-    AccountInfo info = loader.get(rsrc.getUser().getAccountId());
-    loader.fill();
-    return info;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetActive.java
deleted file mode 100644
index 9864b45..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetActive.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetActive implements RestReadView<AccountResource> {
-  @Override
-  public Response<String> apply(AccountResource rsrc) {
-    if (rsrc.getUser().getAccount().isActive()) {
-      return Response.ok("ok");
-    }
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAgreements.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAgreements.java
deleted file mode 100644
index dfbde96..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAgreements.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.extensions.common.AgreementInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.AgreementJson;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class GetAgreements implements RestReadView<AccountResource> {
-  private static final Logger log = LoggerFactory.getLogger(GetAgreements.class);
-
-  private final Provider<CurrentUser> self;
-  private final ProjectCache projectCache;
-  private final AgreementJson agreementJson;
-  private final boolean agreementsEnabled;
-
-  @Inject
-  GetAgreements(
-      Provider<CurrentUser> self,
-      ProjectCache projectCache,
-      AgreementJson agreementJson,
-      @GerritServerConfig Config config) {
-    this.self = self;
-    this.projectCache = projectCache;
-    this.agreementJson = agreementJson;
-    this.agreementsEnabled = config.getBoolean("auth", "contributorAgreements", false);
-  }
-
-  @Override
-  public List<AgreementInfo> apply(AccountResource resource) throws RestApiException {
-    if (!agreementsEnabled) {
-      throw new MethodNotAllowedException("contributor agreements disabled");
-    }
-
-    if (!self.get().isIdentifiedUser()) {
-      throw new AuthException("not allowed to get contributor agreements");
-    }
-
-    IdentifiedUser user = self.get().asIdentifiedUser();
-    if (user != resource.getUser()) {
-      throw new AuthException("not allowed to get contributor agreements");
-    }
-
-    List<AgreementInfo> results = new ArrayList<>();
-    Collection<ContributorAgreement> cas =
-        projectCache.getAllProjects().getConfig().getContributorAgreements();
-    for (ContributorAgreement ca : cas) {
-      List<AccountGroup.UUID> groupIds = new ArrayList<>();
-      for (PermissionRule rule : ca.getAccepted()) {
-        if ((rule.getAction() == Action.ALLOW) && (rule.getGroup() != null)) {
-          if (rule.getGroup().getUUID() != null) {
-            groupIds.add(rule.getGroup().getUUID());
-          } else {
-            log.warn(
-                "group \""
-                    + rule.getGroup().getName()
-                    + "\" does not "
-                    + "exist, referenced in CLA \""
-                    + ca.getName()
-                    + "\"");
-          }
-        }
-      }
-
-      if (user.getEffectiveGroups().containsAnyOf(groupIds)) {
-        results.add(agreementJson.format(ca));
-      }
-    }
-    return results;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java
deleted file mode 100644
index 0818a0e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.CacheControl;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.avatar.AvatarProvider;
-import com.google.inject.Inject;
-import java.util.concurrent.TimeUnit;
-import org.kohsuke.args4j.Option;
-
-public class GetAvatar implements RestReadView<AccountResource> {
-  private final DynamicItem<AvatarProvider> avatarProvider;
-
-  private int size;
-
-  @Option(
-    name = "--size",
-    aliases = {"-s"},
-    usage = "recommended size in pixels, height and width"
-  )
-  public void setSize(int s) {
-    size = s;
-  }
-
-  @Inject
-  GetAvatar(DynamicItem<AvatarProvider> avatarProvider) {
-    this.avatarProvider = avatarProvider;
-  }
-
-  @Override
-  public Response.Redirect apply(AccountResource rsrc) throws ResourceNotFoundException {
-    AvatarProvider impl = avatarProvider.get();
-    if (impl == null) {
-      throw (new ResourceNotFoundException()).caching(CacheControl.PUBLIC(1, TimeUnit.DAYS));
-    }
-
-    String url = impl.getUrl(rsrc.getUser(), size);
-    if (Strings.isNullOrEmpty(url)) {
-      throw (new ResourceNotFoundException()).caching(CacheControl.PUBLIC(1, TimeUnit.HOURS));
-    }
-    return Response.redirect(url);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatarChangeUrl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatarChangeUrl.java
deleted file mode 100644
index d340772..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatarChangeUrl.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.avatar.AvatarProvider;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetAvatarChangeUrl implements RestReadView<AccountResource> {
-  private final DynamicItem<AvatarProvider> avatarProvider;
-
-  @Inject
-  GetAvatarChangeUrl(DynamicItem<AvatarProvider> avatarProvider) {
-    this.avatarProvider = avatarProvider;
-  }
-
-  @Override
-  public String apply(AccountResource rsrc) throws ResourceNotFoundException {
-    AvatarProvider impl = avatarProvider.get();
-    if (impl == null) {
-      throw new ResourceNotFoundException();
-    }
-
-    String url = impl.getChangeAvatarUrl(rsrc.getUser());
-    if (Strings.isNullOrEmpty(url)) {
-      throw new ResourceNotFoundException();
-    }
-    return url;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
deleted file mode 100644
index 616ea79..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
+++ /dev/null
@@ -1,164 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.common.data.GlobalCapability.PRIORITY;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
-import com.google.gerrit.extensions.api.access.PluginPermission;
-import com.google.gerrit.extensions.config.CapabilityDefinition;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.OptionUtil;
-import com.google.gerrit.server.OutputFormat;
-import com.google.gerrit.server.account.AccountResource.Capability;
-import com.google.gerrit.server.git.QueueProvider;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gson.reflect.TypeToken;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.Set;
-import org.kohsuke.args4j.Option;
-
-class GetCapabilities implements RestReadView<AccountResource> {
-  @Option(name = "-q", metaVar = "CAP", usage = "Capability to inspect")
-  void addQuery(String name) {
-    if (query == null) {
-      query = new HashSet<>();
-    }
-    Iterables.addAll(query, OptionUtil.splitOptionValue(name));
-  }
-
-  private Set<String> query;
-
-  private final PermissionBackend permissionBackend;
-  private final AccountLimits.Factory limitsFactory;
-  private final Provider<CurrentUser> self;
-  private final DynamicMap<CapabilityDefinition> pluginCapabilities;
-
-  @Inject
-  GetCapabilities(
-      PermissionBackend permissionBackend,
-      AccountLimits.Factory limitsFactory,
-      Provider<CurrentUser> self,
-      DynamicMap<CapabilityDefinition> pluginCapabilities) {
-    this.permissionBackend = permissionBackend;
-    this.limitsFactory = limitsFactory;
-    this.self = self;
-    this.pluginCapabilities = pluginCapabilities;
-  }
-
-  @Override
-  public Object apply(AccountResource rsrc) throws AuthException, PermissionBackendException {
-    PermissionBackend.WithUser perm = permissionBackend.user(self);
-    if (self.get() != rsrc.getUser()) {
-      perm.check(GlobalPermission.ADMINISTRATE_SERVER);
-      perm = permissionBackend.user(rsrc.getUser());
-    }
-
-    Map<String, Object> have = new LinkedHashMap<>();
-    for (GlobalOrPluginPermission p : perm.test(permissionsToTest())) {
-      have.put(p.permissionName(), true);
-    }
-
-    AccountLimits limits = limitsFactory.create(rsrc.getUser());
-    addRanges(have, limits);
-    addPriority(have, limits);
-
-    return OutputFormat.JSON
-        .newGson()
-        .toJsonTree(have, new TypeToken<Map<String, Object>>() {}.getType());
-  }
-
-  private Set<GlobalOrPluginPermission> permissionsToTest() {
-    Set<GlobalOrPluginPermission> toTest = new HashSet<>();
-    for (GlobalPermission p : GlobalPermission.values()) {
-      if (want(p.permissionName())) {
-        toTest.add(p);
-      }
-    }
-
-    for (String pluginName : pluginCapabilities.plugins()) {
-      for (String capability : pluginCapabilities.byPlugin(pluginName).keySet()) {
-        PluginPermission p = new PluginPermission(pluginName, capability);
-        if (want(p.permissionName())) {
-          toTest.add(p);
-        }
-      }
-    }
-    return toTest;
-  }
-
-  private boolean want(String name) {
-    return query == null || query.contains(name.toLowerCase());
-  }
-
-  private void addRanges(Map<String, Object> have, AccountLimits limits) {
-    for (String name : GlobalCapability.getRangeNames()) {
-      if (want(name) && limits.hasExplicitRange(name)) {
-        have.put(name, new Range(limits.getRange(name)));
-      }
-    }
-  }
-
-  private void addPriority(Map<String, Object> have, AccountLimits limits) {
-    QueueProvider.QueueType queue = limits.getQueueType();
-    if (queue != QueueProvider.QueueType.INTERACTIVE
-        || (query != null && query.contains(PRIORITY))) {
-      have.put(PRIORITY, queue);
-    }
-  }
-
-  private static class Range {
-    private transient PermissionRange range;
-
-    @SuppressWarnings("unused")
-    private int min;
-
-    @SuppressWarnings("unused")
-    private int max;
-
-    Range(PermissionRange r) {
-      range = r;
-      min = r.getMin();
-      max = r.getMax();
-    }
-
-    @Override
-    public String toString() {
-      return range.toString();
-    }
-  }
-
-  @Singleton
-  static class CheckOne implements RestReadView<AccountResource.Capability> {
-    @Override
-    public BinaryResult apply(Capability resource) {
-      return BinaryResult.create("ok\n");
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java
deleted file mode 100644
index 30eb377..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.base.Throwables;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountDirectory.DirectoryException;
-import com.google.gerrit.server.account.AccountDirectory.FillOptions;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.sql.Timestamp;
-import java.util.Collections;
-import java.util.EnumSet;
-
-@Singleton
-public class GetDetail implements RestReadView<AccountResource> {
-
-  private final InternalAccountDirectory directory;
-
-  @Inject
-  public GetDetail(InternalAccountDirectory directory) {
-    this.directory = directory;
-  }
-
-  @Override
-  public AccountDetailInfo apply(AccountResource rsrc) throws OrmException {
-    Account a = rsrc.getUser().getAccount();
-    AccountDetailInfo info = new AccountDetailInfo(a.getId().get());
-    info.registeredOn = a.getRegisteredOn();
-    info.inactive = !a.isActive() ? true : null;
-    try {
-      directory.fillAccountInfo(Collections.singleton(info), EnumSet.allOf(FillOptions.class));
-    } catch (DirectoryException e) {
-      Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
-      throw new OrmException(e);
-    }
-    return info;
-  }
-
-  public static class AccountDetailInfo extends AccountInfo {
-    public Timestamp registeredOn;
-    public Boolean inactive;
-
-    public AccountDetailInfo(Integer id) {
-      super(id);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
deleted file mode 100644
index 8215c6b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
+++ /dev/null
@@ -1,120 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.config.ConfigUtil.loadSection;
-import static com.google.gerrit.server.config.ConfigUtil.skipField;
-
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UserConfigSections;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.lang.reflect.Field;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class GetDiffPreferences implements RestReadView<AccountResource> {
-  private static final Logger log = LoggerFactory.getLogger(GetDiffPreferences.class);
-
-  private final Provider<CurrentUser> self;
-  private final Provider<AllUsersName> allUsersName;
-  private final PermissionBackend permissionBackend;
-  private final GitRepositoryManager gitMgr;
-
-  @Inject
-  GetDiffPreferences(
-      Provider<CurrentUser> self,
-      Provider<AllUsersName> allUsersName,
-      PermissionBackend permissionBackend,
-      GitRepositoryManager gitMgr) {
-    this.self = self;
-    this.allUsersName = allUsersName;
-    this.permissionBackend = permissionBackend;
-    this.gitMgr = gitMgr;
-  }
-
-  @Override
-  public DiffPreferencesInfo apply(AccountResource rsrc)
-      throws AuthException, ConfigInvalidException, IOException, PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
-    }
-
-    Account.Id id = rsrc.getUser().getAccountId();
-    return readFromGit(id, gitMgr, allUsersName.get(), null);
-  }
-
-  static DiffPreferencesInfo readFromGit(
-      Account.Id id, GitRepositoryManager gitMgr, AllUsersName allUsersName, DiffPreferencesInfo in)
-      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
-    try (Repository git = gitMgr.openRepository(allUsersName)) {
-      VersionedAccountPreferences p = VersionedAccountPreferences.forUser(id);
-      p.load(git);
-      DiffPreferencesInfo prefs = new DiffPreferencesInfo();
-      loadSection(
-          p.getConfig(), UserConfigSections.DIFF, null, prefs, readDefaultsFromGit(git, in), in);
-      return prefs;
-    }
-  }
-
-  static DiffPreferencesInfo readDefaultsFromGit(Repository git, DiffPreferencesInfo in)
-      throws ConfigInvalidException, IOException {
-    VersionedAccountPreferences dp = VersionedAccountPreferences.forDefault();
-    dp.load(git);
-    DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
-    loadSection(
-        dp.getConfig(),
-        UserConfigSections.DIFF,
-        null,
-        allUserPrefs,
-        DiffPreferencesInfo.defaults(),
-        in);
-    return updateDefaults(allUserPrefs);
-  }
-
-  private static DiffPreferencesInfo updateDefaults(DiffPreferencesInfo input) {
-    DiffPreferencesInfo result = DiffPreferencesInfo.defaults();
-    try {
-      for (Field field : input.getClass().getDeclaredFields()) {
-        if (skipField(field)) {
-          continue;
-        }
-        Object newVal = field.get(input);
-        if (newVal != null) {
-          field.set(result, newVal);
-        }
-      }
-    } catch (IllegalAccessException e) {
-      log.warn("Cannot get default diff preferences from All-Users", e);
-      return DiffPreferencesInfo.defaults();
-    }
-    return result;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
deleted file mode 100644
index bb207f0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
+++ /dev/null
@@ -1,83 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.config.ConfigUtil.loadSection;
-
-import com.google.gerrit.extensions.client.EditPreferencesInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UserConfigSections;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Repository;
-
-@Singleton
-public class GetEditPreferences implements RestReadView<AccountResource> {
-  private final Provider<CurrentUser> self;
-  private final PermissionBackend permissionBackend;
-  private final AllUsersName allUsersName;
-  private final GitRepositoryManager gitMgr;
-
-  @Inject
-  GetEditPreferences(
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend,
-      AllUsersName allUsersName,
-      GitRepositoryManager gitMgr) {
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-    this.allUsersName = allUsersName;
-    this.gitMgr = gitMgr;
-  }
-
-  @Override
-  public EditPreferencesInfo apply(AccountResource rsrc)
-      throws AuthException, IOException, ConfigInvalidException, PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
-    }
-
-    return readFromGit(rsrc.getUser().getAccountId(), gitMgr, allUsersName, null);
-  }
-
-  static EditPreferencesInfo readFromGit(
-      Account.Id id, GitRepositoryManager gitMgr, AllUsersName allUsersName, EditPreferencesInfo in)
-      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
-    try (Repository git = gitMgr.openRepository(allUsersName)) {
-      VersionedAccountPreferences p = VersionedAccountPreferences.forUser(id);
-      p.load(git);
-
-      return loadSection(
-          p.getConfig(),
-          UserConfigSections.EDIT,
-          null,
-          new EditPreferencesInfo(),
-          EditPreferencesInfo.defaults(),
-          in);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmail.java
deleted file mode 100644
index 82e0944..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmail.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.common.EmailInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetEmail implements RestReadView<AccountResource.Email> {
-  @Inject
-  public GetEmail() {}
-
-  @Override
-  public EmailInfo apply(AccountResource.Email rsrc) {
-    EmailInfo e = new EmailInfo();
-    e.email = rsrc.getEmail();
-    e.preferred(rsrc.getUser().getAccount().getPreferredEmail());
-    return e;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmails.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmails.java
deleted file mode 100644
index 184780f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmails.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.common.EmailInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-
-@Singleton
-public class GetEmails implements RestReadView<AccountResource> {
-
-  @Override
-  public List<EmailInfo> apply(AccountResource rsrc) {
-    List<EmailInfo> emails = new ArrayList<>();
-    for (String email : rsrc.getUser().getEmailAddresses()) {
-      if (email != null) {
-        EmailInfo e = new EmailInfo();
-        e.email = email;
-        e.preferred(rsrc.getUser().getAccount().getPreferredEmail());
-        emails.add(e);
-      }
-    }
-    Collections.sort(
-        emails,
-        new Comparator<EmailInfo>() {
-          @Override
-          public int compare(EmailInfo a, EmailInfo b) {
-            return a.email.compareTo(b.email);
-          }
-        });
-    return emails;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
deleted file mode 100644
index 709bfc3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
-import com.google.gerrit.extensions.common.AccountExternalIdInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-
-@Singleton
-public class GetExternalIds implements RestReadView<AccountResource> {
-  private final PermissionBackend permissionBackend;
-  private final ExternalIds externalIds;
-  private final Provider<CurrentUser> self;
-  private final AuthConfig authConfig;
-
-  @Inject
-  GetExternalIds(
-      PermissionBackend permissionBackend,
-      ExternalIds externalIds,
-      Provider<CurrentUser> self,
-      AuthConfig authConfig) {
-    this.permissionBackend = permissionBackend;
-    this.externalIds = externalIds;
-    this.self = self;
-    this.authConfig = authConfig;
-  }
-
-  @Override
-  public List<AccountExternalIdInfo> apply(AccountResource resource)
-      throws RestApiException, IOException, OrmException, PermissionBackendException {
-    if (self.get() != resource.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.ACCESS_DATABASE);
-    }
-
-    Collection<ExternalId> ids = externalIds.byAccount(resource.getUser().getAccountId());
-    if (ids.isEmpty()) {
-      return ImmutableList.of();
-    }
-    List<AccountExternalIdInfo> result = Lists.newArrayListWithCapacity(ids.size());
-    for (ExternalId id : ids) {
-      AccountExternalIdInfo info = new AccountExternalIdInfo();
-      info.identity = id.key().get();
-      info.emailAddress = id.email();
-      info.trusted = toBoolean(authConfig.isIdentityTrustable(Collections.singleton(id)));
-      // The identity can be deleted only if its not the one used to
-      // establish this web session, and if only if an identity was
-      // actually used to establish this web session.
-      if (!id.isScheme(SCHEME_USERNAME)) {
-        ExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
-        info.canDelete = toBoolean(last == null || !last.get().equals(info.identity));
-      }
-      result.add(info);
-    }
-    return result;
-  }
-
-  private static Boolean toBoolean(boolean v) {
-    return v ? v : null;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetGroups.java
deleted file mode 100644
index 757cb44d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetGroups.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.group.GroupJson;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.List;
-
-@Singleton
-public class GetGroups implements RestReadView<AccountResource> {
-  private final GroupControl.Factory groupControlFactory;
-  private final GroupJson json;
-
-  @Inject
-  GetGroups(GroupControl.Factory groupControlFactory, GroupJson json) {
-    this.groupControlFactory = groupControlFactory;
-    this.json = json;
-  }
-
-  @Override
-  public List<GroupInfo> apply(AccountResource resource) throws OrmException {
-    IdentifiedUser user = resource.getUser();
-    Account.Id userId = user.getAccountId();
-    List<GroupInfo> groups = new ArrayList<>();
-    for (AccountGroup.UUID uuid : user.getEffectiveGroups().getKnownGroups()) {
-      GroupControl ctl;
-      try {
-        ctl = groupControlFactory.controlFor(uuid);
-      } catch (NoSuchGroupException e) {
-        continue;
-      }
-      if (ctl.isVisible() && ctl.canSeeMember(userId)) {
-        groups.add(json.format(ctl.getGroup()));
-      }
-    }
-    return groups;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetName.java
deleted file mode 100644
index 7add77a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetName.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetName implements RestReadView<AccountResource> {
-  @Override
-  public String apply(AccountResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getUser().getAccount().getFullName());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java
deleted file mode 100644
index 587f268..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java
+++ /dev/null
@@ -1,96 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.auth.oauth.OAuthToken;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.net.URI;
-import java.net.URISyntaxException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-class GetOAuthToken implements RestReadView<AccountResource> {
-
-  private static final String BEARER_TYPE = "bearer";
-  private static final Logger log = LoggerFactory.getLogger(GetOAuthToken.class);
-
-  private final Provider<CurrentUser> self;
-  private final OAuthTokenCache tokenCache;
-  private final Provider<String> canonicalWebUrlProvider;
-
-  @Inject
-  GetOAuthToken(
-      Provider<CurrentUser> self,
-      OAuthTokenCache tokenCache,
-      @CanonicalWebUrl Provider<String> urlProvider) {
-    this.self = self;
-    this.tokenCache = tokenCache;
-    this.canonicalWebUrlProvider = urlProvider;
-  }
-
-  @Override
-  public OAuthTokenInfo apply(AccountResource rsrc)
-      throws AuthException, ResourceNotFoundException {
-    if (self.get() != rsrc.getUser()) {
-      throw new AuthException("not allowed to get access token");
-    }
-    Account a = rsrc.getUser().getAccount();
-    OAuthToken accessToken = tokenCache.get(a.getId());
-    if (accessToken == null) {
-      throw new ResourceNotFoundException();
-    }
-    OAuthTokenInfo accessTokenInfo = new OAuthTokenInfo();
-    accessTokenInfo.username = a.getUserName();
-    accessTokenInfo.resourceHost = getHostName(canonicalWebUrlProvider.get());
-    accessTokenInfo.accessToken = accessToken.getToken();
-    accessTokenInfo.providerId = accessToken.getProviderId();
-    accessTokenInfo.expiresAt = Long.toString(accessToken.getExpiresAt());
-    accessTokenInfo.type = BEARER_TYPE;
-    return accessTokenInfo;
-  }
-
-  private static String getHostName(String canonicalWebUrl) {
-    if (canonicalWebUrl == null) {
-      log.error("No canonicalWebUrl defined in gerrit.config, OAuth may not work properly");
-      return null;
-    }
-
-    try {
-      return new URI(canonicalWebUrl).getHost();
-    } catch (URISyntaxException e) {
-      log.error("Invalid canonicalWebUrl '" + canonicalWebUrl + "'", e);
-      return null;
-    }
-  }
-
-  public static class OAuthTokenInfo {
-    public String username;
-    public String resourceHost;
-    public String accessToken;
-    public String providerId;
-    public String expiresAt;
-    public String type;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
deleted file mode 100644
index 3ebf864..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetPreferences implements RestReadView<AccountResource> {
-  private final Provider<CurrentUser> self;
-  private final PermissionBackend permissionBackend;
-  private final AccountCache accountCache;
-
-  @Inject
-  GetPreferences(
-      Provider<CurrentUser> self, PermissionBackend permissionBackend, AccountCache accountCache) {
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-    this.accountCache = accountCache;
-  }
-
-  @Override
-  public GeneralPreferencesInfo apply(AccountResource rsrc)
-      throws AuthException, PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
-    }
-
-    Account.Id id = rsrc.getUser().getAccountId();
-    return accountCache.get(id).getAccount().getGeneralPreferencesInfo();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKey.java
deleted file mode 100644
index ee75432..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKey.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.common.SshKeyInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.AccountResource.SshKey;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetSshKey implements RestReadView<AccountResource.SshKey> {
-
-  @Override
-  public SshKeyInfo apply(SshKey rsrc) {
-    return GetSshKeys.newSshKeyInfo(rsrc.getSshKey());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
deleted file mode 100644
index 9f5b9d5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.gerrit.extensions.common.SshKeyInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-
-@Singleton
-public class GetSshKeys implements RestReadView<AccountResource> {
-
-  private final Provider<CurrentUser> self;
-  private final PermissionBackend permissionBackend;
-  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
-
-  @Inject
-  GetSshKeys(
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend,
-      VersionedAuthorizedKeys.Accessor authorizedKeys) {
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-    this.authorizedKeys = authorizedKeys;
-  }
-
-  @Override
-  public List<SshKeyInfo> apply(AccountResource rsrc)
-      throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException, PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
-    }
-    return apply(rsrc.getUser());
-  }
-
-  public List<SshKeyInfo> apply(IdentifiedUser user)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    return Lists.transform(authorizedKeys.getKeys(user.getAccountId()), GetSshKeys::newSshKeyInfo);
-  }
-
-  public static SshKeyInfo newSshKeyInfo(AccountSshKey sshKey) {
-    SshKeyInfo info = new SshKeyInfo();
-    info.seq = sshKey.getKey().get();
-    info.sshPublicKey = sshKey.getSshPublicKey();
-    info.encodedKey = sshKey.getEncodedKey();
-    info.algorithm = sshKey.getAlgorithm();
-    info.comment = Strings.emptyToNull(sshKey.getComment());
-    info.valid = sshKey.isValid();
-    return info;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetStatus.java
deleted file mode 100644
index 5d57c4c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetStatus.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetStatus implements RestReadView<AccountResource> {
-  @Override
-  public String apply(AccountResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getUser().getAccount().getStatus());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetUsername.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetUsername.java
deleted file mode 100644
index 6541f55..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetUsername.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetUsername implements RestReadView<AccountResource> {
-  @Inject
-  public GetUsername() {}
-
-  @Override
-  public String apply(AccountResource rsrc) throws AuthException, ResourceNotFoundException {
-    String username = rsrc.getUser().getAccount().getUserName();
-    if (username == null) {
-      throw new ResourceNotFoundException();
-    }
-    return username;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
deleted file mode 100644
index c2c0547..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ComparisonChain;
-import com.google.gerrit.extensions.client.ProjectWatchInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class GetWatchedProjects implements RestReadView<AccountResource> {
-  private final PermissionBackend permissionBackend;
-  private final Provider<IdentifiedUser> self;
-  private final WatchConfig.Accessor watchConfig;
-
-  @Inject
-  public GetWatchedProjects(
-      PermissionBackend permissionBackend,
-      Provider<IdentifiedUser> self,
-      WatchConfig.Accessor watchConfig) {
-    this.permissionBackend = permissionBackend;
-    this.self = self;
-    this.watchConfig = watchConfig;
-  }
-
-  @Override
-  public List<ProjectWatchInfo> apply(AccountResource rsrc)
-      throws OrmException, AuthException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
-    }
-
-    Account.Id accountId = rsrc.getUser().getAccountId();
-    List<ProjectWatchInfo> projectWatchInfos = new ArrayList<>();
-    for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e :
-        watchConfig.getProjectWatches(accountId).entrySet()) {
-      ProjectWatchInfo pwi = new ProjectWatchInfo();
-      pwi.filter = e.getKey().filter();
-      pwi.project = e.getKey().project().get();
-      pwi.notifyAbandonedChanges = toBoolean(e.getValue().contains(NotifyType.ABANDONED_CHANGES));
-      pwi.notifyNewChanges = toBoolean(e.getValue().contains(NotifyType.NEW_CHANGES));
-      pwi.notifyNewPatchSets = toBoolean(e.getValue().contains(NotifyType.NEW_PATCHSETS));
-      pwi.notifySubmittedChanges = toBoolean(e.getValue().contains(NotifyType.SUBMITTED_CHANGES));
-      pwi.notifyAllComments = toBoolean(e.getValue().contains(NotifyType.ALL_COMMENTS));
-      projectWatchInfos.add(pwi);
-    }
-    Collections.sort(
-        projectWatchInfos,
-        new Comparator<ProjectWatchInfo>() {
-          @Override
-          public int compare(ProjectWatchInfo pwi1, ProjectWatchInfo pwi2) {
-            return ComparisonChain.start()
-                .compare(pwi1.project, pwi2.project)
-                .compare(Strings.nullToEmpty(pwi1.filter), Strings.nullToEmpty(pwi2.filter))
-                .result();
-          }
-        });
-    return projectWatchInfos;
-  }
-
-  private static Boolean toBoolean(boolean value) {
-    return value ? true : null;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java
deleted file mode 100644
index d985426..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.group.InternalGroup;
-import java.io.IOException;
-import java.util.Optional;
-
-/** Tracks group objects in memory for efficient access. */
-public interface GroupCache {
-  /**
-   * Looks up an internal group by its ID.
-   *
-   * @param groupId the ID of the internal group
-   * @return an {@code Optional} of the internal group, or an empty {@code Optional} if no internal
-   *     group with this ID exists on this server or an error occurred during lookup
-   */
-  Optional<InternalGroup> get(AccountGroup.Id groupId);
-
-  /**
-   * Looks up an internal group by its name.
-   *
-   * @param name the name of the internal group
-   * @return an {@code Optional} of the internal group, or an empty {@code Optional} if no internal
-   *     group with this name exists on this server or an error occurred during lookup
-   */
-  Optional<InternalGroup> get(AccountGroup.NameKey name);
-
-  /**
-   * Looks up an internal group by its UUID.
-   *
-   * @param groupUuid the UUID of the internal group
-   * @return an {@code Optional} of the internal group, or an empty {@code Optional} if no internal
-   *     group with this UUID exists on this server or an error occurred during lookup
-   */
-  Optional<InternalGroup> get(AccountGroup.UUID groupUuid);
-
-  /** Notify the cache that a new group was constructed. */
-  void onCreateGroup(AccountGroup group) throws IOException;
-
-  void evict(AccountGroup.UUID groupUuid, AccountGroup.Id groupId, AccountGroup.NameKey groupName)
-      throws IOException;
-
-  void evictAfterRename(AccountGroup.NameKey oldName) throws IOException;
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
deleted file mode 100644
index f1112de..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ /dev/null
@@ -1,212 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.group.Groups;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.index.group.GroupIndexCollection;
-import com.google.gerrit.server.index.group.GroupIndexer;
-import com.google.gerrit.server.query.group.InternalGroupQuery;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.name.Named;
-import java.io.IOException;
-import java.util.Optional;
-import java.util.concurrent.ExecutionException;
-import java.util.function.BooleanSupplier;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Tracks group objects in memory for efficient access. */
-@Singleton
-public class GroupCacheImpl implements GroupCache {
-  private static final Logger log = LoggerFactory.getLogger(GroupCacheImpl.class);
-
-  private static final String BYID_NAME = "groups";
-  private static final String BYNAME_NAME = "groups_byname";
-  private static final String BYUUID_NAME = "groups_byuuid";
-
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        cache(BYID_NAME, AccountGroup.Id.class, new TypeLiteral<Optional<InternalGroup>>() {})
-            .loader(ByIdLoader.class);
-
-        cache(BYNAME_NAME, String.class, new TypeLiteral<Optional<InternalGroup>>() {})
-            .loader(ByNameLoader.class);
-
-        cache(BYUUID_NAME, String.class, new TypeLiteral<Optional<InternalGroup>>() {})
-            .loader(ByUUIDLoader.class);
-
-        bind(GroupCacheImpl.class);
-        bind(GroupCache.class).to(GroupCacheImpl.class);
-      }
-    };
-  }
-
-  private final LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId;
-  private final LoadingCache<String, Optional<InternalGroup>> byName;
-  private final LoadingCache<String, Optional<InternalGroup>> byUUID;
-  private final Provider<GroupIndexer> indexer;
-
-  @Inject
-  GroupCacheImpl(
-      @Named(BYID_NAME) LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId,
-      @Named(BYNAME_NAME) LoadingCache<String, Optional<InternalGroup>> byName,
-      @Named(BYUUID_NAME) LoadingCache<String, Optional<InternalGroup>> byUUID,
-      Provider<GroupIndexer> indexer) {
-    this.byId = byId;
-    this.byName = byName;
-    this.byUUID = byUUID;
-    this.indexer = indexer;
-  }
-
-  @Override
-  public Optional<InternalGroup> get(AccountGroup.Id groupId) {
-    try {
-      return byId.get(groupId);
-    } catch (ExecutionException e) {
-      log.warn("Cannot load group " + groupId, e);
-      return Optional.empty();
-    }
-  }
-
-  @Override
-  public void evict(
-      AccountGroup.UUID groupUuid, AccountGroup.Id groupId, AccountGroup.NameKey groupName)
-      throws IOException {
-    if (groupId != null) {
-      byId.invalidate(groupId);
-    }
-    if (groupName != null) {
-      byName.invalidate(groupName.get());
-    }
-    if (groupUuid != null) {
-      byUUID.invalidate(groupUuid.get());
-    }
-    indexer.get().index(groupUuid);
-  }
-
-  @Override
-  public void evictAfterRename(AccountGroup.NameKey oldName) throws IOException {
-    if (oldName != null) {
-      byName.invalidate(oldName.get());
-    }
-  }
-
-  @Override
-  public Optional<InternalGroup> get(AccountGroup.NameKey name) {
-    if (name == null) {
-      return Optional.empty();
-    }
-    try {
-      return byName.get(name.get());
-    } catch (ExecutionException e) {
-      log.warn(String.format("Cannot look up group %s by name", name.get()), e);
-      return Optional.empty();
-    }
-  }
-
-  @Override
-  public Optional<InternalGroup> get(AccountGroup.UUID groupUuid) {
-    if (groupUuid == null) {
-      return Optional.empty();
-    }
-
-    try {
-      return byUUID.get(groupUuid.get());
-    } catch (ExecutionException e) {
-      log.warn(String.format("Cannot look up group %s by uuid", groupUuid.get()), e);
-      return Optional.empty();
-    }
-  }
-
-  @Override
-  public void onCreateGroup(AccountGroup group) throws IOException {
-    indexer.get().index(group.getGroupUUID());
-  }
-
-  static class ByIdLoader extends CacheLoader<AccountGroup.Id, Optional<InternalGroup>> {
-    private final SchemaFactory<ReviewDb> schema;
-    private final Groups groups;
-    private final BooleanSupplier hasGroupIndex;
-    private final Provider<InternalGroupQuery> groupQueryProvider;
-
-    @Inject
-    ByIdLoader(
-        SchemaFactory<ReviewDb> schema,
-        Groups groups,
-        GroupIndexCollection groupIndexCollection,
-        Provider<InternalGroupQuery> groupQueryProvider) {
-      this.schema = schema;
-      this.groups = groups;
-      hasGroupIndex = () -> groupIndexCollection.getSearchIndex() != null;
-      this.groupQueryProvider = groupQueryProvider;
-    }
-
-    @Override
-    public Optional<InternalGroup> load(AccountGroup.Id key) throws Exception {
-      if (hasGroupIndex.getAsBoolean()) {
-        return groupQueryProvider.get().byId(key);
-      }
-
-      try (ReviewDb db = schema.open()) {
-        return groups.getGroup(db, key);
-      }
-    }
-  }
-
-  static class ByNameLoader extends CacheLoader<String, Optional<InternalGroup>> {
-    private final Provider<InternalGroupQuery> groupQueryProvider;
-
-    @Inject
-    ByNameLoader(Provider<InternalGroupQuery> groupQueryProvider) {
-      this.groupQueryProvider = groupQueryProvider;
-    }
-
-    @Override
-    public Optional<InternalGroup> load(String name) throws Exception {
-      return groupQueryProvider.get().byName(new AccountGroup.NameKey(name));
-    }
-  }
-
-  static class ByUUIDLoader extends CacheLoader<String, Optional<InternalGroup>> {
-    private final SchemaFactory<ReviewDb> schema;
-    private final Groups groups;
-
-    @Inject
-    ByUUIDLoader(SchemaFactory<ReviewDb> sf, Groups groups) {
-      schema = sf;
-      this.groups = groups;
-    }
-
-    @Override
-    public Optional<InternalGroup> load(String uuid) throws Exception {
-      try (ReviewDb db = schema.open()) {
-        return groups.getGroup(db, new AccountGroup.UUID(uuid));
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
deleted file mode 100644
index 020a04d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
+++ /dev/null
@@ -1,203 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupDescriptions;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.InternalGroupDescription;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.Optional;
-
-/** Access control management for a group of accounts managed in Gerrit. */
-public class GroupControl {
-
-  @Singleton
-  public static class GenericFactory {
-    private final PermissionBackend permissionBackend;
-    private final GroupBackend groupBackend;
-
-    @Inject
-    GenericFactory(PermissionBackend permissionBackend, GroupBackend gb) {
-      this.permissionBackend = permissionBackend;
-      groupBackend = gb;
-    }
-
-    public GroupControl controlFor(CurrentUser who, AccountGroup.UUID groupId)
-        throws NoSuchGroupException {
-      GroupDescription.Basic group = groupBackend.get(groupId);
-      if (group == null) {
-        throw new NoSuchGroupException(groupId);
-      }
-      return new GroupControl(who, group, permissionBackend, groupBackend);
-    }
-  }
-
-  public static class Factory {
-    private final PermissionBackend permissionBackend;
-    private final GroupCache groupCache;
-    private final Provider<CurrentUser> user;
-    private final GroupBackend groupBackend;
-
-    @Inject
-    Factory(
-        PermissionBackend permissionBackend,
-        GroupCache gc,
-        Provider<CurrentUser> cu,
-        GroupBackend gb) {
-      this.permissionBackend = permissionBackend;
-      groupCache = gc;
-      user = cu;
-      groupBackend = gb;
-    }
-
-    public GroupControl controlFor(AccountGroup.Id groupId) throws NoSuchGroupException {
-      Optional<InternalGroup> group = groupCache.get(groupId);
-      return group
-          .map(InternalGroupDescription::new)
-          .map(this::controlFor)
-          .orElseThrow(() -> new NoSuchGroupException(groupId));
-    }
-
-    public GroupControl controlFor(AccountGroup.UUID groupId) throws NoSuchGroupException {
-      final GroupDescription.Basic group = groupBackend.get(groupId);
-      if (group == null) {
-        throw new NoSuchGroupException(groupId);
-      }
-      return controlFor(group);
-    }
-
-    public GroupControl controlFor(AccountGroup group) {
-      return controlFor(GroupDescriptions.forAccountGroup(group));
-    }
-
-    public GroupControl controlFor(GroupDescription.Basic group) {
-      return new GroupControl(user.get(), group, permissionBackend, groupBackend);
-    }
-
-    public GroupControl validateFor(AccountGroup.UUID groupUUID) throws NoSuchGroupException {
-      final GroupControl c = controlFor(groupUUID);
-      if (!c.isVisible()) {
-        throw new NoSuchGroupException(groupUUID);
-      }
-      return c;
-    }
-  }
-
-  private final CurrentUser user;
-  private final GroupDescription.Basic group;
-  private Boolean isOwner;
-  private final PermissionBackend.WithUser perm;
-  private final GroupBackend groupBackend;
-
-  GroupControl(
-      CurrentUser who,
-      GroupDescription.Basic gd,
-      PermissionBackend permissionBackend,
-      GroupBackend gb) {
-    user = who;
-    group = gd;
-    this.perm = permissionBackend.user(user);
-    groupBackend = gb;
-  }
-
-  public GroupDescription.Basic getGroup() {
-    return group;
-  }
-
-  public CurrentUser getUser() {
-    return user;
-  }
-
-  /** Can this user see this group exists? */
-  public boolean isVisible() {
-    /* Check for canAdministrateServer may seem redundant, but allows
-     * for visibility of all groups that are not an internal group to
-     * server administrators.
-     */
-    return user.isInternalUser()
-        || groupBackend.isVisibleToAll(group.getGroupUUID())
-        || user.getEffectiveGroups().contains(group.getGroupUUID())
-        || isOwner()
-        || canAdministrateServer();
-  }
-
-  public boolean isOwner() {
-    if (isOwner != null) {
-      return isOwner;
-    }
-
-    if (group instanceof GroupDescription.Internal) {
-      AccountGroup.UUID ownerUUID = ((GroupDescription.Internal) group).getOwnerGroupUUID();
-      isOwner = getUser().getEffectiveGroups().contains(ownerUUID) || canAdministrateServer();
-    } else {
-      isOwner = false;
-    }
-    return isOwner;
-  }
-
-  private boolean canAdministrateServer() {
-    try {
-      perm.check(GlobalPermission.ADMINISTRATE_SERVER);
-      return true;
-    } catch (AuthException | PermissionBackendException denied) {
-      return false;
-    }
-  }
-
-  public boolean canAddMember() {
-    return isOwner();
-  }
-
-  public boolean canRemoveMember() {
-    return isOwner();
-  }
-
-  public boolean canSeeMember(Account.Id id) {
-    if (user.isIdentifiedUser() && user.getAccountId().equals(id)) {
-      return true;
-    }
-    return canSeeMembers();
-  }
-
-  public boolean canAddGroup() {
-    return isOwner();
-  }
-
-  public boolean canRemoveGroup() {
-    return isOwner();
-  }
-
-  public boolean canSeeGroup() {
-    return canSeeMembers();
-  }
-
-  private boolean canSeeMembers() {
-    if (group instanceof GroupDescription.Internal) {
-      return ((GroupDescription.Internal) group).isVisibleToAll() || isOwner();
-    }
-    return false;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
deleted file mode 100644
index c702aef..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import java.util.Collection;
-
-/** Tracks group inclusions in memory for efficient access. */
-public interface GroupIncludeCache {
-  /** @return groups directly a member of the passed group. */
-  Collection<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID group);
-
-  /** @return any groups the passed group belongs to. */
-  Collection<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId);
-
-  /** @return set of any UUIDs that are not internal groups. */
-  Collection<AccountGroup.UUID> allExternalMembers();
-
-  void evictSubgroupsOf(AccountGroup.UUID groupId);
-
-  void evictParentGroupsOf(AccountGroup.UUID groupId);
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
deleted file mode 100644
index 23e87e3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ /dev/null
@@ -1,220 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Streams;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.group.Groups;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.index.group.GroupField;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.gerrit.server.index.group.GroupIndexCollection;
-import com.google.gerrit.server.query.group.InternalGroupQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.name.Named;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.concurrent.ExecutionException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Tracks group inclusions in memory for efficient access. */
-@Singleton
-public class GroupIncludeCacheImpl implements GroupIncludeCache {
-  private static final Logger log = LoggerFactory.getLogger(GroupIncludeCacheImpl.class);
-  private static final String PARENT_GROUPS_NAME = "groups_byinclude";
-  private static final String SUBGROUPS_NAME = "groups_members";
-  private static final String EXTERNAL_NAME = "groups_external";
-
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        cache(
-                PARENT_GROUPS_NAME,
-                AccountGroup.UUID.class,
-                new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
-            .loader(ParentGroupsLoader.class);
-
-        cache(
-                SUBGROUPS_NAME,
-                AccountGroup.UUID.class,
-                new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
-            .loader(SubgroupsLoader.class);
-
-        cache(EXTERNAL_NAME, String.class, new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
-            .loader(AllExternalLoader.class);
-
-        bind(GroupIncludeCacheImpl.class);
-        bind(GroupIncludeCache.class).to(GroupIncludeCacheImpl.class);
-      }
-    };
-  }
-
-  private final LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> subgroups;
-  private final LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> parentGroups;
-  private final LoadingCache<String, ImmutableList<AccountGroup.UUID>> external;
-
-  @Inject
-  GroupIncludeCacheImpl(
-      @Named(SUBGROUPS_NAME)
-          LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> subgroups,
-      @Named(PARENT_GROUPS_NAME)
-          LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> parentGroups,
-      @Named(EXTERNAL_NAME) LoadingCache<String, ImmutableList<AccountGroup.UUID>> external) {
-    this.subgroups = subgroups;
-    this.parentGroups = parentGroups;
-    this.external = external;
-  }
-
-  @Override
-  public Collection<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID groupId) {
-    try {
-      return subgroups.get(groupId);
-    } catch (ExecutionException e) {
-      log.warn("Cannot load members of group", e);
-      return Collections.emptySet();
-    }
-  }
-
-  @Override
-  public Collection<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId) {
-    try {
-      return parentGroups.get(groupId);
-    } catch (ExecutionException e) {
-      log.warn("Cannot load included groups", e);
-      return Collections.emptySet();
-    }
-  }
-
-  @Override
-  public void evictSubgroupsOf(AccountGroup.UUID groupId) {
-    if (groupId != null) {
-      subgroups.invalidate(groupId);
-    }
-  }
-
-  @Override
-  public void evictParentGroupsOf(AccountGroup.UUID groupId) {
-    if (groupId != null) {
-      parentGroups.invalidate(groupId);
-
-      if (!AccountGroup.isInternalGroup(groupId)) {
-        external.invalidate(EXTERNAL_NAME);
-      }
-    }
-  }
-
-  @Override
-  public Collection<AccountGroup.UUID> allExternalMembers() {
-    try {
-      return external.get(EXTERNAL_NAME);
-    } catch (ExecutionException e) {
-      log.warn("Cannot load set of non-internal groups", e);
-      return ImmutableList.of();
-    }
-  }
-
-  static class SubgroupsLoader
-      extends CacheLoader<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> {
-    private final SchemaFactory<ReviewDb> schema;
-    private final Groups groups;
-
-    @Inject
-    SubgroupsLoader(SchemaFactory<ReviewDb> sf, Groups groups) {
-      schema = sf;
-      this.groups = groups;
-    }
-
-    @Override
-    public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key)
-        throws OrmException, NoSuchGroupException {
-      try (ReviewDb db = schema.open()) {
-        return groups.getSubgroups(db, key).collect(toImmutableList());
-      }
-    }
-  }
-
-  static class ParentGroupsLoader
-      extends CacheLoader<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> {
-    private final SchemaFactory<ReviewDb> schema;
-    private final Provider<GroupIndex> groupIndexProvider;
-    private final Provider<InternalGroupQuery> groupQueryProvider;
-    private final GroupCache groupCache;
-
-    @Inject
-    ParentGroupsLoader(
-        SchemaFactory<ReviewDb> sf,
-        GroupIndexCollection groupIndexCollection,
-        Provider<InternalGroupQuery> groupQueryProvider,
-        GroupCache groupCache) {
-      schema = sf;
-      this.groupIndexProvider = groupIndexCollection::getSearchIndex;
-      this.groupQueryProvider = groupQueryProvider;
-      this.groupCache = groupCache;
-    }
-
-    @Override
-    public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) throws OrmException {
-      if (groupIndexProvider.get().getSchema().hasField(GroupField.SUBGROUP)) {
-        return groupQueryProvider
-            .get()
-            .bySubgroup(key)
-            .stream()
-            .map(InternalGroup::getGroupUUID)
-            .collect(toImmutableList());
-      }
-      try (ReviewDb db = schema.open()) {
-        return Groups.getParentGroupsFromReviewDb(db, key)
-            .map(groupCache::get)
-            .flatMap(Streams::stream)
-            .map(InternalGroup::getGroupUUID)
-            .collect(toImmutableList());
-      }
-    }
-  }
-
-  static class AllExternalLoader extends CacheLoader<String, ImmutableList<AccountGroup.UUID>> {
-    private final SchemaFactory<ReviewDb> schema;
-    private final Groups groups;
-
-    @Inject
-    AllExternalLoader(SchemaFactory<ReviewDb> sf, Groups groups) {
-      schema = sf;
-      this.groups = groups;
-    }
-
-    @Override
-    public ImmutableList<AccountGroup.UUID> load(String key) throws Exception {
-      try (ReviewDb db = schema.open()) {
-        return groups.getExternalGroups(db).collect(toImmutableList());
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java
deleted file mode 100644
index 4dc960d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java
+++ /dev/null
@@ -1,129 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
-
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.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.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;
-import java.util.Optional;
-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;
-
-  @Inject
-  GroupMembers(
-      GroupCache groupCache,
-      GroupControl.Factory groupControlFactory,
-      AccountCache accountCache,
-      ProjectControl.GenericFactory projectControl,
-      @Assisted CurrentUser currentUser) {
-    this.groupCache = groupCache;
-    this.groupControlFactory = groupControlFactory;
-    this.accountCache = accountCache;
-    this.projectControl = projectControl;
-    this.currentUser = currentUser;
-  }
-
-  public Set<Account> listAccounts(AccountGroup.UUID groupUUID, Project.NameKey project)
-      throws NoSuchGroupException, NoSuchProjectException, OrmException, IOException {
-    return listAccounts(groupUUID, project, new HashSet<AccountGroup.UUID>());
-  }
-
-  private Set<Account> listAccounts(
-      final AccountGroup.UUID groupUUID,
-      final Project.NameKey project,
-      final Set<AccountGroup.UUID> seen)
-      throws NoSuchGroupException, OrmException, NoSuchProjectException, IOException {
-    if (SystemGroupBackend.PROJECT_OWNERS.equals(groupUUID)) {
-      return getProjectOwners(project, seen);
-    }
-    Optional<InternalGroup> group = groupCache.get(groupUUID);
-    if (group.isPresent()) {
-      return getGroupMembers(group.get(), project, seen);
-    }
-    return Collections.emptySet();
-  }
-
-  private Set<Account> getProjectOwners(final Project.NameKey project, Set<AccountGroup.UUID> seen)
-      throws NoSuchProjectException, NoSuchGroupException, OrmException, IOException {
-    seen.add(SystemGroupBackend.PROJECT_OWNERS);
-    if (project == null) {
-      return Collections.emptySet();
-    }
-
-    final Iterable<AccountGroup.UUID> ownerGroups =
-        projectControl.controlFor(project, currentUser).getProjectState().getAllOwners();
-
-    final HashSet<Account> projectOwners = new HashSet<>();
-    for (AccountGroup.UUID ownerGroup : ownerGroups) {
-      if (!seen.contains(ownerGroup)) {
-        projectOwners.addAll(listAccounts(ownerGroup, project, seen));
-      }
-    }
-    return projectOwners;
-  }
-
-  private Set<Account> getGroupMembers(
-      InternalGroup group, Project.NameKey project, Set<AccountGroup.UUID> seen)
-      throws NoSuchGroupException, OrmException, NoSuchProjectException, IOException {
-    seen.add(group.getGroupUUID());
-    GroupControl groupControl = groupControlFactory.controlFor(new InternalGroupDescription(group));
-
-    Set<Account> directMembers =
-        group
-            .getMembers()
-            .stream()
-            .filter(groupControl::canSeeMember)
-            .map(accountCache::get)
-            .map(AccountState::getAccount)
-            .collect(toImmutableSet());
-
-    Set<Account> indirectMembers = new HashSet<>();
-    if (groupControl.canSeeGroup()) {
-      for (AccountGroup.UUID subgroupUuid : group.getSubgroups()) {
-        if (!seen.contains(subgroupUuid)) {
-          indirectMembers.addAll(listAccounts(subgroupUuid, project, seen));
-        }
-      }
-    }
-
-    return Sets.union(directMembers, indirectMembers);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupUUID.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupUUID.java
deleted file mode 100644
index 45c7052..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupUUID.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import java.security.MessageDigest;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-
-public class GroupUUID {
-  public static AccountGroup.UUID make(String groupName, PersonIdent creator) {
-    MessageDigest md = Constants.newMessageDigest();
-    md.update(Constants.encode("group " + groupName + "\n"));
-    md.update(Constants.encode("creator " + creator.toExternalString() + "\n"));
-    return new AccountGroup.UUID(ObjectId.fromRaw(md.digest()).name());
-  }
-
-  private GroupUUID() {}
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java
deleted file mode 100644
index ae28e1c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java
+++ /dev/null
@@ -1,157 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-
-/**
- * Group membership checker for the internal group system.
- *
- * <p>Groups the user is directly a member of are pulled from the in-memory AccountCache by way of
- * the IdentifiedUser. Transitive group memberhips are resolved on demand starting from the
- * requested group and looking for a path to a group the user is a member of. Other group backends
- * are supported by recursively invoking the universal GroupMembership.
- */
-public class IncludingGroupMembership implements GroupMembership {
-  public interface Factory {
-    IncludingGroupMembership create(IdentifiedUser user);
-  }
-
-  private final GroupCache groupCache;
-  private final GroupIncludeCache includeCache;
-  private final IdentifiedUser user;
-  private final Map<AccountGroup.UUID, Boolean> memberOf;
-  private Set<AccountGroup.UUID> knownGroups;
-
-  @Inject
-  IncludingGroupMembership(
-      GroupCache groupCache, GroupIncludeCache includeCache, @Assisted IdentifiedUser user) {
-    this.groupCache = groupCache;
-    this.includeCache = includeCache;
-    this.user = user;
-
-    Set<AccountGroup.UUID> groups = user.state().getInternalGroups();
-    memberOf = new ConcurrentHashMap<>(groups.size());
-    for (AccountGroup.UUID g : groups) {
-      memberOf.put(g, true);
-    }
-  }
-
-  @Override
-  public boolean contains(AccountGroup.UUID id) {
-    if (id == null) {
-      return false;
-    }
-
-    Boolean b = memberOf.get(id);
-    return b != null ? b : containsAnyOf(ImmutableSet.of(id));
-  }
-
-  @Override
-  public boolean containsAnyOf(Iterable<AccountGroup.UUID> queryIds) {
-    // Prefer lookup of a cached result over expanding includes.
-    boolean tryExpanding = false;
-    for (AccountGroup.UUID id : queryIds) {
-      Boolean b = memberOf.get(id);
-      if (b == null) {
-        tryExpanding = true;
-      } else if (b) {
-        return true;
-      }
-    }
-
-    if (tryExpanding) {
-      for (AccountGroup.UUID id : queryIds) {
-        if (memberOf.containsKey(id)) {
-          // Membership was earlier proven to be false.
-          continue;
-        }
-
-        memberOf.put(id, false);
-        Optional<InternalGroup> group = groupCache.get(id);
-        if (!group.isPresent()) {
-          continue;
-        }
-        if (search(group.get().getSubgroups())) {
-          memberOf.put(id, true);
-          return true;
-        }
-      }
-    }
-
-    return false;
-  }
-
-  @Override
-  public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> groupIds) {
-    Set<AccountGroup.UUID> r = new HashSet<>();
-    for (AccountGroup.UUID id : groupIds) {
-      if (contains(id)) {
-        r.add(id);
-      }
-    }
-    return r;
-  }
-
-  private boolean search(Iterable<AccountGroup.UUID> ids) {
-    return user.getEffectiveGroups().containsAnyOf(ids);
-  }
-
-  private ImmutableSet<AccountGroup.UUID> computeKnownGroups() {
-    GroupMembership membership = user.getEffectiveGroups();
-    Set<AccountGroup.UUID> direct = user.state().getInternalGroups();
-    Set<AccountGroup.UUID> r = Sets.newHashSet(direct);
-    r.remove(null);
-
-    List<AccountGroup.UUID> q = Lists.newArrayList(r);
-    for (AccountGroup.UUID g : membership.intersection(includeCache.allExternalMembers())) {
-      if (g != null && r.add(g)) {
-        q.add(g);
-      }
-    }
-
-    while (!q.isEmpty()) {
-      AccountGroup.UUID id = q.remove(q.size() - 1);
-      for (AccountGroup.UUID g : includeCache.parentGroupsOf(id)) {
-        if (g != null && r.add(g)) {
-          q.add(g);
-          memberOf.put(g, true);
-        }
-      }
-    }
-    return ImmutableSet.copyOf(r);
-  }
-
-  @Override
-  public Set<AccountGroup.UUID> getKnownGroups() {
-    if (knownGroups == null) {
-      knownGroups = computeKnownGroups();
-    }
-    return knownGroups;
-  }
-}
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
deleted file mode 100644
index ecc6b8c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
+++ /dev/null
@@ -1,57 +0,0 @@
-//Copyright (C) 2016 The Android Open Source Project
-//
-//Licensed under the Apache License, Version 2.0 (the "License");
-//you may not use this file except in compliance with the License.
-//You may obtain a copy of the License at
-//
-//http://www.apache.org/licenses/LICENSE-2.0
-//
-//Unless required by applicable law or agreed to in writing, software
-//distributed under the License is distributed on an "AS IS" BASIS,
-//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//See the License for the specific language governing permissions and
-//limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import 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;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class Index implements RestModifyView<AccountResource, Input> {
-  public static class Input {}
-
-  private final AccountCache accountCache;
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> self;
-
-  @Inject
-  Index(
-      AccountCache accountCache, PermissionBackend permissionBackend, Provider<CurrentUser> self) {
-    this.accountCache = accountCache;
-    this.permissionBackend = permissionBackend;
-    this.self = self;
-  }
-
-  @Override
-  public Response<?> apply(AccountResource rsrc, Input input)
-      throws IOException, AuthException, PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
-    }
-
-    // evicting the account from the cache, reindexes the account
-    accountCache.evict(rsrc.getUser().getAccountId());
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
deleted file mode 100644
index 3f4fee9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ /dev/null
@@ -1,105 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.group.Groups;
-import com.google.gerrit.server.group.InternalGroupDescription;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Collection;
-import org.eclipse.jgit.lib.ObjectId;
-
-/** Implementation of GroupBackend for the internal group system. */
-@Singleton
-public class InternalGroupBackend implements GroupBackend {
-  private final GroupControl.Factory groupControlFactory;
-  private final GroupCache groupCache;
-  private final Groups groups;
-  private final SchemaFactory<ReviewDb> schema;
-  private final IncludingGroupMembership.Factory groupMembershipFactory;
-
-  @Inject
-  InternalGroupBackend(
-      GroupControl.Factory groupControlFactory,
-      GroupCache groupCache,
-      Groups groups,
-      SchemaFactory<ReviewDb> schema,
-      IncludingGroupMembership.Factory groupMembershipFactory) {
-    this.groupControlFactory = groupControlFactory;
-    this.groupCache = groupCache;
-    this.groups = groups;
-    this.schema = schema;
-    this.groupMembershipFactory = groupMembershipFactory;
-  }
-
-  @Override
-  public boolean handles(AccountGroup.UUID uuid) {
-    // See AccountGroup.isInternalGroup
-    return ObjectId.isId(uuid.get()); // [0-9a-f]{40};
-  }
-
-  @Override
-  public GroupDescription.Internal get(AccountGroup.UUID uuid) {
-    if (!handles(uuid)) {
-      return null;
-    }
-
-    return groupCache.get(uuid).map(InternalGroupDescription::new).orElse(null);
-  }
-
-  @Override
-  public Collection<GroupReference> suggest(String name, ProjectState project) {
-    try (ReviewDb db = schema.open()) {
-      return groups
-          .getAll(db)
-          .filter(group -> startsWithIgnoreCase(group, name))
-          .filter(this::isVisible)
-          .map(GroupReference::forGroup)
-          .collect(toList());
-    } catch (OrmException e) {
-      return ImmutableList.of();
-    }
-  }
-
-  private static boolean startsWithIgnoreCase(AccountGroup group, String name) {
-    return group.getName().regionMatches(true, 0, name, 0, name.length());
-  }
-
-  private boolean isVisible(AccountGroup group) {
-    return groupControlFactory.controlFor(group).isVisible();
-  }
-
-  @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
-    return groupMembershipFactory.create(user);
-  }
-
-  @Override
-  public boolean isVisibleToAll(AccountGroup.UUID uuid) {
-    GroupDescription.Internal g = get(uuid);
-    return g != null && g.isVisibleToAll();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
deleted file mode 100644
index 44060be..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.account.AccountResource.ACCOUNT_KIND;
-import static com.google.gerrit.server.account.AccountResource.CAPABILITY_KIND;
-import static com.google.gerrit.server.account.AccountResource.EMAIL_KIND;
-import static com.google.gerrit.server.account.AccountResource.SSH_KEY_KIND;
-import static com.google.gerrit.server.account.AccountResource.STARRED_CHANGE_KIND;
-import static com.google.gerrit.server.account.AccountResource.Star.STAR_KIND;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.RestApiModule;
-
-public class Module extends RestApiModule {
-  @Override
-  protected void configure() {
-    bind(AccountsCollection.class);
-    bind(Capabilities.class);
-
-    DynamicMap.mapOf(binder(), ACCOUNT_KIND);
-    DynamicMap.mapOf(binder(), CAPABILITY_KIND);
-    DynamicMap.mapOf(binder(), EMAIL_KIND);
-    DynamicMap.mapOf(binder(), SSH_KEY_KIND);
-    DynamicMap.mapOf(binder(), STARRED_CHANGE_KIND);
-    DynamicMap.mapOf(binder(), STAR_KIND);
-
-    put(ACCOUNT_KIND).to(PutAccount.class);
-    get(ACCOUNT_KIND).to(GetAccount.class);
-    get(ACCOUNT_KIND, "detail").to(GetDetail.class);
-    post(ACCOUNT_KIND, "index").to(Index.class);
-    get(ACCOUNT_KIND, "name").to(GetName.class);
-    put(ACCOUNT_KIND, "name").to(PutName.class);
-    delete(ACCOUNT_KIND, "name").to(PutName.class);
-    get(ACCOUNT_KIND, "status").to(GetStatus.class);
-    put(ACCOUNT_KIND, "status").to(PutStatus.class);
-    get(ACCOUNT_KIND, "username").to(GetUsername.class);
-    put(ACCOUNT_KIND, "username").to(PutUsername.class);
-    get(ACCOUNT_KIND, "active").to(GetActive.class);
-    put(ACCOUNT_KIND, "active").to(PutActive.class);
-    delete(ACCOUNT_KIND, "active").to(DeleteActive.class);
-    child(ACCOUNT_KIND, "emails").to(EmailsCollection.class);
-    get(EMAIL_KIND).to(GetEmail.class);
-    put(EMAIL_KIND).to(PutEmail.class);
-    delete(EMAIL_KIND).to(DeleteEmail.class);
-    put(EMAIL_KIND, "preferred").to(PutPreferred.class);
-    put(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
-    delete(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
-    child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class);
-    post(ACCOUNT_KIND, "sshkeys").to(AddSshKey.class);
-    get(ACCOUNT_KIND, "watched.projects").to(GetWatchedProjects.class);
-    post(ACCOUNT_KIND, "watched.projects").to(PostWatchedProjects.class);
-    post(ACCOUNT_KIND, "watched.projects:delete").to(DeleteWatchedProjects.class);
-
-    get(SSH_KEY_KIND).to(GetSshKey.class);
-    delete(SSH_KEY_KIND).to(DeleteSshKey.class);
-
-    get(ACCOUNT_KIND, "oauthtoken").to(GetOAuthToken.class);
-
-    get(ACCOUNT_KIND, "avatar").to(GetAvatar.class);
-    get(ACCOUNT_KIND, "avatar.change.url").to(GetAvatarChangeUrl.class);
-
-    child(ACCOUNT_KIND, "capabilities").to(Capabilities.class);
-
-    get(ACCOUNT_KIND, "groups").to(GetGroups.class);
-    get(ACCOUNT_KIND, "preferences").to(GetPreferences.class);
-    put(ACCOUNT_KIND, "preferences").to(SetPreferences.class);
-    get(ACCOUNT_KIND, "preferences.diff").to(GetDiffPreferences.class);
-    put(ACCOUNT_KIND, "preferences.diff").to(SetDiffPreferences.class);
-    get(ACCOUNT_KIND, "preferences.edit").to(GetEditPreferences.class);
-    put(ACCOUNT_KIND, "preferences.edit").to(SetEditPreferences.class);
-    get(CAPABILITY_KIND).to(GetCapabilities.CheckOne.class);
-
-    get(ACCOUNT_KIND, "agreements").to(GetAgreements.class);
-    put(ACCOUNT_KIND, "agreements").to(PutAgreement.class);
-
-    child(ACCOUNT_KIND, "starred.changes").to(StarredChanges.class);
-    put(STARRED_CHANGE_KIND).to(StarredChanges.Put.class);
-    delete(STARRED_CHANGE_KIND).to(StarredChanges.Delete.class);
-    bind(StarredChanges.Create.class);
-
-    child(ACCOUNT_KIND, "stars.changes").to(Stars.class);
-    get(STAR_KIND).to(Stars.Get.class);
-    post(STAR_KIND).to(Stars.Post.class);
-
-    get(ACCOUNT_KIND, "external.ids").to(GetExternalIds.class);
-    post(ACCOUNT_KIND, "external.ids:delete").to(DeleteExternalIds.class);
-
-    factory(CreateAccount.Factory.class);
-    factory(CreateEmail.Factory.class);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
deleted file mode 100644
index 38887f6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
+++ /dev/null
@@ -1,128 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.client.ProjectWatchInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectsCollection;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class PostWatchedProjects
-    implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
-  private final Provider<IdentifiedUser> self;
-  private final PermissionBackend permissionBackend;
-  private final GetWatchedProjects getWatchedProjects;
-  private final ProjectsCollection projectsCollection;
-  private final AccountCache accountCache;
-  private final WatchConfig.Accessor watchConfig;
-
-  @Inject
-  public PostWatchedProjects(
-      Provider<IdentifiedUser> self,
-      PermissionBackend permissionBackend,
-      GetWatchedProjects getWatchedProjects,
-      ProjectsCollection projectsCollection,
-      AccountCache accountCache,
-      WatchConfig.Accessor watchConfig) {
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-    this.getWatchedProjects = getWatchedProjects;
-    this.projectsCollection = projectsCollection;
-    this.accountCache = accountCache;
-    this.watchConfig = watchConfig;
-  }
-
-  @Override
-  public List<ProjectWatchInfo> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
-      throws OrmException, RestApiException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
-    }
-
-    Account.Id accountId = rsrc.getUser().getAccountId();
-    watchConfig.upsertProjectWatches(accountId, asMap(input));
-    accountCache.evict(accountId);
-    return getWatchedProjects.apply(rsrc);
-  }
-
-  private Map<ProjectWatchKey, Set<NotifyType>> asMap(List<ProjectWatchInfo> input)
-      throws BadRequestException, UnprocessableEntityException, IOException,
-          PermissionBackendException {
-    Map<ProjectWatchKey, Set<NotifyType>> m = new HashMap<>();
-    for (ProjectWatchInfo info : input) {
-      if (info.project == null) {
-        throw new BadRequestException("project name must be specified");
-      }
-
-      ProjectWatchKey key =
-          ProjectWatchKey.create(projectsCollection.parse(info.project).getNameKey(), info.filter);
-      if (m.containsKey(key)) {
-        throw new BadRequestException(
-            "duplicate entry for project " + format(info.project, info.filter));
-      }
-
-      Set<NotifyType> notifyValues = EnumSet.noneOf(NotifyType.class);
-      if (toBoolean(info.notifyAbandonedChanges)) {
-        notifyValues.add(NotifyType.ABANDONED_CHANGES);
-      }
-      if (toBoolean(info.notifyAllComments)) {
-        notifyValues.add(NotifyType.ALL_COMMENTS);
-      }
-      if (toBoolean(info.notifyNewChanges)) {
-        notifyValues.add(NotifyType.NEW_CHANGES);
-      }
-      if (toBoolean(info.notifyNewPatchSets)) {
-        notifyValues.add(NotifyType.NEW_PATCHSETS);
-      }
-      if (toBoolean(info.notifySubmittedChanges)) {
-        notifyValues.add(NotifyType.SUBMITTED_CHANGES);
-      }
-
-      m.put(key, notifyValues);
-    }
-    return m;
-  }
-
-  private boolean toBoolean(Boolean b) {
-    return b == null ? false : b;
-  }
-
-  private static String format(String project, String filter) {
-    return project
-        + (filter != null && !WatchConfig.FILTER_ALL.equals(filter) ? " and filter " + filter : "");
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java
deleted file mode 100644
index da5a58f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.api.accounts.AccountInput;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class PutAccount implements RestModifyView<AccountResource, AccountInput> {
-  @Override
-  public Response<AccountInfo> apply(AccountResource resource, AccountInput input)
-      throws ResourceConflictException {
-    throw new ResourceConflictException("account exists");
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
deleted file mode 100644
index 7ce2ea8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-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;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
-@Singleton
-public class PutActive implements RestModifyView<AccountResource, Input> {
-  public static class Input {}
-
-  private final SetInactiveFlag setInactiveFlag;
-
-  @Inject
-  PutActive(SetInactiveFlag setInactiveFlag) {
-    this.setInactiveFlag = setInactiveFlag;
-  }
-
-  @Override
-  public Response<String> apply(AccountResource rsrc, Input input)
-      throws ResourceNotFoundException, OrmException, IOException, ConfigInvalidException {
-    return setInactiveFlag.activate(rsrc.getUser().getAccountId());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java
deleted file mode 100644
index f5b2e6e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.AgreementInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.AgreementSignup;
-import com.google.gerrit.server.group.AddMembers;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class PutAgreement implements RestModifyView<AccountResource, AgreementInput> {
-  private final ProjectCache projectCache;
-  private final Provider<IdentifiedUser> self;
-  private final AgreementSignup agreementSignup;
-  private final AddMembers addMembers;
-  private final boolean agreementsEnabled;
-
-  @Inject
-  PutAgreement(
-      ProjectCache projectCache,
-      Provider<IdentifiedUser> self,
-      AgreementSignup agreementSignup,
-      AddMembers addMembers,
-      @GerritServerConfig Config config) {
-    this.projectCache = projectCache;
-    this.self = self;
-    this.agreementSignup = agreementSignup;
-    this.addMembers = addMembers;
-    this.agreementsEnabled = config.getBoolean("auth", "contributorAgreements", false);
-  }
-
-  @Override
-  public Response<String> apply(AccountResource resource, AgreementInput input)
-      throws IOException, OrmException, RestApiException {
-    if (!agreementsEnabled) {
-      throw new MethodNotAllowedException("contributor agreements disabled");
-    }
-
-    if (self.get() != resource.getUser()) {
-      throw new AuthException("not allowed to enter contributor agreement");
-    }
-
-    String agreementName = Strings.nullToEmpty(input.name);
-    ContributorAgreement ca =
-        projectCache.getAllProjects().getConfig().getContributorAgreement(agreementName);
-    if (ca == null) {
-      throw new UnprocessableEntityException("contributor agreement not found");
-    }
-
-    if (ca.getAutoVerify() == null) {
-      throw new BadRequestException("cannot enter a non-autoVerify agreement");
-    }
-
-    AccountGroup.UUID uuid = ca.getAutoVerify().getUUID();
-    if (uuid == null) {
-      throw new ResourceConflictException("autoverify group uuid not found");
-    }
-
-    Account account = self.get().getAccount();
-    try {
-      addMembers.addMembers(uuid, ImmutableList.of(account.getId()));
-    } catch (NoSuchGroupException e) {
-      throw new ResourceConflictException("autoverify group not found");
-    }
-    agreementSignup.fire(account, agreementName);
-
-    return Response.ok(agreementName);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutEmail.java
deleted file mode 100644
index acdbbf4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutEmail.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.api.accounts.EmailInput;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class PutEmail implements RestModifyView<AccountResource.Email, EmailInput> {
-  @Override
-  public Response<?> apply(AccountResource.Email rsrc, EmailInput input)
-      throws ResourceConflictException {
-    throw new ResourceConflictException("email exists");
-  }
-}
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
deleted file mode 100644
index e00f6b3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
+++ /dev/null
@@ -1,135 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.PutHttpPassword.Input;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
-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;
-  }
-
-  private static final int LEN = 31;
-  private static final SecureRandom rng;
-
-  static {
-    try {
-      rng = SecureRandom.getInstance("SHA1PRNG");
-    } catch (NoSuchAlgorithmException e) {
-      throw new RuntimeException("Cannot create RNG for password generator", e);
-    }
-  }
-
-  private final Provider<CurrentUser> self;
-  private final PermissionBackend permissionBackend;
-  private final ExternalIds externalIds;
-  private final ExternalIdsUpdate.User externalIdsUpdate;
-
-  @Inject
-  PutHttpPassword(
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend,
-      ExternalIds externalIds,
-      ExternalIdsUpdate.User externalIdsUpdate) {
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-    this.externalIds = externalIds;
-    this.externalIdsUpdate = externalIdsUpdate;
-  }
-
-  @Override
-  public Response<String> apply(AccountResource rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, ResourceConflictException, OrmException,
-          IOException, ConfigInvalidException, PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
-    }
-
-    if (input == null) {
-      input = new Input();
-    }
-    input.httpPassword = Strings.emptyToNull(input.httpPassword);
-
-    String newPassword;
-    if (input.generate) {
-      newPassword = generate();
-    } else if (input.httpPassword == null) {
-      newPassword = null;
-    } else {
-      // Only administrators can explicitly set the password.
-      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
-      newPassword = input.httpPassword;
-    }
-    return apply(rsrc.getUser(), newPassword);
-  }
-
-  public Response<String> apply(IdentifiedUser user, String newPassword)
-      throws ResourceNotFoundException, ResourceConflictException, OrmException, IOException,
-          ConfigInvalidException {
-    if (user.getUserName() == null) {
-      throw new ResourceConflictException("username must be set");
-    }
-
-    ExternalId extId = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, user.getUserName()));
-    if (extId == null) {
-      throw new ResourceNotFoundException();
-    }
-    ExternalId newExtId =
-        ExternalId.createWithPassword(extId.key(), extId.accountId(), extId.email(), newPassword);
-    externalIdsUpdate.create().upsert(newExtId);
-
-    return Strings.isNullOrEmpty(newPassword) ? Response.<String>none() : Response.ok(newPassword);
-  }
-
-  public static String generate() {
-    byte[] rand = new byte[LEN];
-    rng.nextBytes(rand);
-
-    byte[] enc = Base64.encodeBase64(rand, false);
-    StringBuilder r = new StringBuilder(enc.length);
-    for (int i = 0; i < enc.length; i++) {
-      if (enc[i] == '=') {
-        break;
-      }
-      r.append((char) enc[i]);
-    }
-    return r.toString();
-  }
-}
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
deleted file mode 100644
index 7537230..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.base.Strings;
-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.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.PutName.Input;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class PutName implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-    @DefaultInput public String name;
-  }
-
-  private final Provider<CurrentUser> self;
-  private final Realm realm;
-  private final PermissionBackend permissionBackend;
-  private final AccountsUpdate.Server accountsUpdate;
-
-  @Inject
-  PutName(
-      Provider<CurrentUser> self,
-      Realm realm,
-      PermissionBackend permissionBackend,
-      AccountsUpdate.Server accountsUpdate) {
-    this.self = self;
-    this.realm = realm;
-    this.permissionBackend = permissionBackend;
-    this.accountsUpdate = accountsUpdate;
-  }
-
-  @Override
-  public Response<String> apply(AccountResource rsrc, Input input)
-      throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
-          IOException, PermissionBackendException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
-    }
-    return apply(rsrc.getUser(), input);
-  }
-
-  public Response<String> apply(IdentifiedUser user, Input input)
-      throws MethodNotAllowedException, ResourceNotFoundException, IOException,
-          ConfigInvalidException {
-    if (input == null) {
-      input = new Input();
-    }
-
-    if (!realm.allowsEdit(AccountFieldName.FULL_NAME)) {
-      throw new MethodNotAllowedException("realm does not allow editing name");
-    }
-
-    String newName = input.name;
-    Account account =
-        accountsUpdate.create().update(user.getAccountId(), a -> a.setFullName(newName));
-    if (account == null) {
-      throw new ResourceNotFoundException("account not found");
-    }
-    return Strings.isNullOrEmpty(account.getFullName())
-        ? Response.none()
-        : Response.ok(account.getFullName());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
deleted file mode 100644
index b3f8fc5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
+++ /dev/null
@@ -1,84 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.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.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;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.concurrent.atomic.AtomicBoolean;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class PutPreferred implements RestModifyView<AccountResource.Email, Input> {
-  static class Input {}
-
-  private final Provider<CurrentUser> self;
-  private final PermissionBackend permissionBackend;
-  private final AccountsUpdate.Server accountsUpdate;
-
-  @Inject
-  PutPreferred(
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend,
-      AccountsUpdate.Server accountsUpdate) {
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-    this.accountsUpdate = accountsUpdate;
-  }
-
-  @Override
-  public Response<String> apply(AccountResource.Email rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, OrmException, IOException,
-          PermissionBackendException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
-    }
-    return apply(rsrc.getUser(), rsrc.getEmail());
-  }
-
-  public Response<String> apply(IdentifiedUser user, String email)
-      throws ResourceNotFoundException, IOException, ConfigInvalidException {
-    AtomicBoolean alreadyPreferred = new AtomicBoolean(false);
-    Account account =
-        accountsUpdate
-            .create()
-            .update(
-                user.getAccountId(),
-                a -> {
-                  if (email.equals(a.getPreferredEmail())) {
-                    alreadyPreferred.set(true);
-                  } else {
-                    a.setPreferredEmail(email);
-                  }
-                });
-    if (account == null) {
-      throw new ResourceNotFoundException("account not found");
-    }
-    return alreadyPreferred.get() ? Response.ok("") : Response.created("");
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java
deleted file mode 100644
index 1df67c3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.base.Strings;
-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;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class PutStatus implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-    @DefaultInput String status;
-
-    public Input(String status) {
-      this.status = status;
-    }
-
-    public Input() {}
-  }
-
-  private final Provider<CurrentUser> self;
-  private final PermissionBackend permissionBackend;
-  private final AccountsUpdate.Server accountsUpdate;
-
-  @Inject
-  PutStatus(
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend,
-      AccountsUpdate.Server accountsUpdate) {
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-    this.accountsUpdate = accountsUpdate;
-  }
-
-  @Override
-  public Response<String> apply(AccountResource rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, OrmException, IOException,
-          PermissionBackendException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
-    }
-    return apply(rsrc.getUser(), input);
-  }
-
-  public Response<String> apply(IdentifiedUser user, Input input)
-      throws ResourceNotFoundException, IOException, ConfigInvalidException {
-    if (input == null) {
-      input = new Input();
-    }
-
-    String newStatus = input.status;
-    Account account =
-        accountsUpdate
-            .create()
-            .update(user.getAccountId(), a -> a.setStatus(Strings.nullToEmpty(newStatus)));
-    if (account == null) {
-      throw new ResourceNotFoundException("account not found");
-    }
-    return Strings.isNullOrEmpty(account.getStatus())
-        ? Response.none()
-        : Response.ok(account.getStatus());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
deleted file mode 100644
index a73bdd9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.common.errors.NameAlreadyUsedException;
-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;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class PutUsername implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-    @DefaultInput public String username;
-  }
-
-  private final Provider<CurrentUser> self;
-  private final ChangeUserName.Factory changeUserNameFactory;
-  private final PermissionBackend permissionBackend;
-  private final Realm realm;
-
-  @Inject
-  PutUsername(
-      Provider<CurrentUser> self,
-      ChangeUserName.Factory changeUserNameFactory,
-      PermissionBackend permissionBackend,
-      Realm realm) {
-    this.self = self;
-    this.changeUserNameFactory = changeUserNameFactory;
-    this.permissionBackend = permissionBackend;
-    this.realm = realm;
-  }
-
-  @Override
-  public String apply(AccountResource rsrc, Input input)
-      throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
-          ResourceConflictException, OrmException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
-    }
-
-    if (!realm.allowsEdit(AccountFieldName.USER_NAME)) {
-      throw new MethodNotAllowedException("realm does not allow editing username");
-    }
-
-    if (input == null) {
-      input = new Input();
-    }
-
-    try {
-      changeUserNameFactory.create(rsrc.getUser(), input.username).call();
-    } catch (IllegalStateException e) {
-      if (ChangeUserName.USERNAME_CANNOT_BE_CHANGED.equals(e.getMessage())) {
-        throw new MethodNotAllowedException(e.getMessage());
-      }
-      throw e;
-    } catch (InvalidUserNameException e) {
-      throw new UnprocessableEntityException("invalid username");
-    } catch (NameAlreadyUsedException e) {
-      throw new ResourceConflictException("username already used");
-    }
-
-    return input.username;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/QueryAccounts.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/QueryAccounts.java
deleted file mode 100644
index e6ac0f6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/QueryAccounts.java
+++ /dev/null
@@ -1,211 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.extensions.client.ListAccountsOption;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.index.query.QueryResult;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountDirectory.FillOptions;
-import com.google.gerrit.server.api.accounts.AccountInfoComparator;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.query.account.AccountPredicates;
-import com.google.gerrit.server.query.account.AccountQueryBuilder;
-import com.google.gerrit.server.query.account.AccountQueryProcessor;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.lib.Config;
-import org.kohsuke.args4j.Option;
-
-public class QueryAccounts implements RestReadView<TopLevelResource> {
-  private static final int MAX_SUGGEST_RESULTS = 100;
-
-  private final AccountLoader.Factory accountLoaderFactory;
-  private final AccountQueryBuilder queryBuilder;
-  private final AccountQueryProcessor queryProcessor;
-  private final boolean suggestConfig;
-  private final int suggestFrom;
-
-  private AccountLoader accountLoader;
-  private boolean suggest;
-  private int suggestLimit = 10;
-  private String query;
-  private Integer start;
-  private EnumSet<ListAccountsOption> options;
-
-  @Option(name = "--suggest", metaVar = "SUGGEST", usage = "suggest users")
-  public void setSuggest(boolean suggest) {
-    this.suggest = suggest;
-  }
-
-  @Option(
-    name = "--limit",
-    aliases = {"-n"},
-    metaVar = "CNT",
-    usage = "maximum number of users to return"
-  )
-  public void setLimit(int n) {
-    queryProcessor.setUserProvidedLimit(n);
-
-    if (n < 0) {
-      suggestLimit = 10;
-    } else if (n == 0) {
-      suggestLimit = MAX_SUGGEST_RESULTS;
-    } else {
-      suggestLimit = Math.min(n, MAX_SUGGEST_RESULTS);
-    }
-  }
-
-  @Option(name = "-o", usage = "Output options per account")
-  public void addOption(ListAccountsOption o) {
-    options.add(o);
-  }
-
-  @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListAccountsOption.fromBits(Integer.parseInt(hex, 16)));
-  }
-
-  @Option(
-    name = "--query",
-    aliases = {"-q"},
-    metaVar = "QUERY",
-    usage = "match users"
-  )
-  public void setQuery(String query) {
-    this.query = query;
-  }
-
-  @Option(
-    name = "--start",
-    aliases = {"-S"},
-    metaVar = "CNT",
-    usage = "Number of accounts to skip"
-  )
-  public void setStart(int start) {
-    this.start = start;
-  }
-
-  @Inject
-  QueryAccounts(
-      AccountLoader.Factory accountLoaderFactory,
-      AccountQueryBuilder queryBuilder,
-      AccountQueryProcessor queryProcessor,
-      @GerritServerConfig Config cfg) {
-    this.accountLoaderFactory = accountLoaderFactory;
-    this.queryBuilder = queryBuilder;
-    this.queryProcessor = queryProcessor;
-    this.suggestFrom = cfg.getInt("suggest", null, "from", 0);
-    this.options = EnumSet.noneOf(ListAccountsOption.class);
-
-    if ("off".equalsIgnoreCase(cfg.getString("suggest", null, "accounts"))) {
-      suggestConfig = false;
-    } else {
-      boolean suggest;
-      try {
-        AccountVisibility av = cfg.getEnum("suggest", null, "accounts", AccountVisibility.ALL);
-        suggest = (av != AccountVisibility.NONE);
-      } catch (IllegalArgumentException err) {
-        suggest = cfg.getBoolean("suggest", null, "accounts", true);
-      }
-      this.suggestConfig = suggest;
-    }
-  }
-
-  @Override
-  public List<AccountInfo> apply(TopLevelResource rsrc)
-      throws OrmException, BadRequestException, MethodNotAllowedException {
-    if (Strings.isNullOrEmpty(query)) {
-      throw new BadRequestException("missing query field");
-    }
-
-    if (suggest && (!suggestConfig || query.length() < suggestFrom)) {
-      return Collections.emptyList();
-    }
-
-    Set<FillOptions> fillOptions = EnumSet.of(FillOptions.ID);
-    if (options.contains(ListAccountsOption.DETAILS)) {
-      fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
-    }
-    if (options.contains(ListAccountsOption.ALL_EMAILS)) {
-      fillOptions.add(FillOptions.EMAIL);
-      fillOptions.add(FillOptions.SECONDARY_EMAILS);
-    }
-    if (suggest) {
-      fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
-      fillOptions.add(FillOptions.EMAIL);
-      fillOptions.add(FillOptions.SECONDARY_EMAILS);
-    }
-    accountLoader = accountLoaderFactory.create(fillOptions);
-
-    if (queryProcessor.isDisabled()) {
-      throw new MethodNotAllowedException("query disabled");
-    }
-
-    if (start != null) {
-      queryProcessor.setStart(start);
-    }
-
-    Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>();
-    try {
-      Predicate<AccountState> queryPred;
-      if (suggest) {
-        queryPred = queryBuilder.defaultQuery(query);
-        queryProcessor.setUserProvidedLimit(suggestLimit);
-      } else {
-        queryPred = queryBuilder.parse(query);
-      }
-      if (!AccountPredicates.hasActive(queryPred)) {
-        // if neither 'is:active' nor 'is:inactive' appears in the query only
-        // active accounts should be queried
-        queryPred = AccountPredicates.andActive(queryPred);
-      }
-      QueryResult<AccountState> result = queryProcessor.query(queryPred);
-      for (AccountState accountState : result.entities()) {
-        Account.Id id = accountState.getAccount().getId();
-        matches.put(id, accountLoader.get(id));
-      }
-
-      accountLoader.fill();
-
-      List<AccountInfo> sorted =
-          AccountInfoComparator.ORDER_NULLS_LAST.sortedCopy(matches.values());
-      if (!sorted.isEmpty() && result.more()) {
-        sorted.get(sorted.size() - 1)._moreAccounts = true;
-      }
-      return sorted;
-    } catch (QueryParseException e) {
-      if (suggest) {
-        return ImmutableList.of();
-      }
-      throw new BadRequestException(e.getMessage());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
deleted file mode 100644
index 88e9e20..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.account.GetDiffPreferences.readDefaultsFromGit;
-import static com.google.gerrit.server.account.GetDiffPreferences.readFromGit;
-import static com.google.gerrit.server.config.ConfigUtil.loadSection;
-import static com.google.gerrit.server.config.ConfigUtil.storeSection;
-
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.UserConfigSections;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-
-@Singleton
-public class SetDiffPreferences implements RestModifyView<AccountResource, DiffPreferencesInfo> {
-  private final Provider<CurrentUser> self;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final AllUsersName allUsersName;
-  private final PermissionBackend permissionBackend;
-  private final GitRepositoryManager gitMgr;
-
-  @Inject
-  SetDiffPreferences(
-      Provider<CurrentUser> self,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      AllUsersName allUsersName,
-      PermissionBackend permissionBackend,
-      GitRepositoryManager gitMgr) {
-    this.self = self;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.allUsersName = allUsersName;
-    this.permissionBackend = permissionBackend;
-    this.gitMgr = gitMgr;
-  }
-
-  @Override
-  public DiffPreferencesInfo apply(AccountResource rsrc, DiffPreferencesInfo in)
-      throws AuthException, BadRequestException, ConfigInvalidException,
-          RepositoryNotFoundException, IOException, PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
-    }
-
-    if (in == null) {
-      throw new BadRequestException("input must be provided");
-    }
-
-    Account.Id id = rsrc.getUser().getAccountId();
-    return writeToGit(readFromGit(id, gitMgr, allUsersName, in), id);
-  }
-
-  private DiffPreferencesInfo writeToGit(DiffPreferencesInfo in, Account.Id userId)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    DiffPreferencesInfo out = new DiffPreferencesInfo();
-    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
-      DiffPreferencesInfo allUserPrefs = readDefaultsFromGit(md.getRepository(), null);
-      VersionedAccountPreferences prefs = VersionedAccountPreferences.forUser(userId);
-      prefs.load(md);
-      storeSection(prefs.getConfig(), UserConfigSections.DIFF, null, in, allUserPrefs);
-      prefs.commit(md);
-      loadSection(prefs.getConfig(), UserConfigSections.DIFF, null, out, allUserPrefs, null);
-    }
-    return out;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
deleted file mode 100644
index 53285db..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
+++ /dev/null
@@ -1,102 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.account.GetEditPreferences.readFromGit;
-import static com.google.gerrit.server.config.ConfigUtil.loadSection;
-import static com.google.gerrit.server.config.ConfigUtil.storeSection;
-
-import com.google.gerrit.extensions.client.EditPreferencesInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.UserConfigSections;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-
-@Singleton
-public class SetEditPreferences implements RestModifyView<AccountResource, EditPreferencesInfo> {
-
-  private final Provider<CurrentUser> self;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final PermissionBackend permissionBackend;
-  private final GitRepositoryManager gitMgr;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  SetEditPreferences(
-      Provider<CurrentUser> self,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      PermissionBackend permissionBackend,
-      GitRepositoryManager gitMgr,
-      AllUsersName allUsersName) {
-    this.self = self;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.permissionBackend = permissionBackend;
-    this.gitMgr = gitMgr;
-    this.allUsersName = allUsersName;
-  }
-
-  @Override
-  public EditPreferencesInfo apply(AccountResource rsrc, EditPreferencesInfo in)
-      throws AuthException, BadRequestException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException, PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
-    }
-
-    if (in == null) {
-      throw new BadRequestException("input must be provided");
-    }
-
-    Account.Id accountId = rsrc.getUser().getAccountId();
-
-    VersionedAccountPreferences prefs;
-    EditPreferencesInfo out = new EditPreferencesInfo();
-    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
-      prefs = VersionedAccountPreferences.forUser(accountId);
-      prefs.load(md);
-      storeSection(
-          prefs.getConfig(),
-          UserConfigSections.EDIT,
-          null,
-          readFromGit(accountId, gitMgr, allUsersName, in),
-          EditPreferencesInfo.defaults());
-      prefs.commit(md);
-      out =
-          loadSection(
-              prefs.getConfig(),
-              UserConfigSections.EDIT,
-              null,
-              out,
-              EditPreferencesInfo.defaults(),
-              null);
-    }
-
-    return out;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetInactiveFlag.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetInactiveFlag.java
deleted file mode 100644
index 6e12c3e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetInactiveFlag.java
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.concurrent.atomic.AtomicBoolean;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class SetInactiveFlag {
-
-  private final AccountsUpdate.Server accountsUpdate;
-
-  @Inject
-  SetInactiveFlag(AccountsUpdate.Server accountsUpdate) {
-    this.accountsUpdate = accountsUpdate;
-  }
-
-  public Response<?> deactivate(Account.Id accountId)
-      throws RestApiException, IOException, ConfigInvalidException {
-    AtomicBoolean alreadyInactive = new AtomicBoolean(false);
-    Account account =
-        accountsUpdate
-            .create()
-            .update(
-                accountId,
-                a -> {
-                  if (!a.isActive()) {
-                    alreadyInactive.set(true);
-                  } else {
-                    a.setActive(false);
-                  }
-                });
-    if (account == null) {
-      throw new ResourceNotFoundException("account not found");
-    }
-    if (alreadyInactive.get()) {
-      throw new ResourceConflictException("account not active");
-    }
-    return Response.none();
-  }
-
-  public Response<String> activate(Account.Id accountId)
-      throws ResourceNotFoundException, IOException, ConfigInvalidException {
-    AtomicBoolean alreadyActive = new AtomicBoolean(false);
-    Account account =
-        accountsUpdate
-            .create()
-            .update(
-                accountId,
-                a -> {
-                  if (a.isActive()) {
-                    alreadyActive.set(true);
-                  } else {
-                    a.setActive(true);
-                  }
-                });
-    if (account == null) {
-      throw new ResourceNotFoundException("account not found");
-    }
-    return alreadyActive.get() ? Response.ok("") : Response.created("");
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
deleted file mode 100644
index d25a5a7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
+++ /dev/null
@@ -1,201 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.config.ConfigUtil.storeSection;
-import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_MATCH;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_TOKEN;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
-import static com.google.gerrit.server.git.UserConfigSections.URL_ALIAS;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.MenuItem;
-import com.google.gerrit.extensions.config.DownloadScheme;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.UserConfigSections;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class SetPreferences implements RestModifyView<AccountResource, GeneralPreferencesInfo> {
-  private final Provider<CurrentUser> self;
-  private final AccountCache cache;
-  private final PermissionBackend permissionBackend;
-  private final GeneralPreferencesLoader loader;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final AllUsersName allUsersName;
-  private final DynamicMap<DownloadScheme> downloadSchemes;
-
-  @Inject
-  SetPreferences(
-      Provider<CurrentUser> self,
-      AccountCache cache,
-      PermissionBackend permissionBackend,
-      GeneralPreferencesLoader loader,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      AllUsersName allUsersName,
-      DynamicMap<DownloadScheme> downloadSchemes) {
-    this.self = self;
-    this.loader = loader;
-    this.cache = cache;
-    this.permissionBackend = permissionBackend;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.allUsersName = allUsersName;
-    this.downloadSchemes = downloadSchemes;
-  }
-
-  @Override
-  public GeneralPreferencesInfo apply(AccountResource rsrc, GeneralPreferencesInfo i)
-      throws AuthException, BadRequestException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
-    }
-
-    checkDownloadScheme(i.downloadScheme);
-    Account.Id id = rsrc.getUser().getAccountId();
-    GeneralPreferencesInfo n = loader.merge(id, i);
-
-    n.changeTable = i.changeTable;
-    n.my = i.my;
-    n.urlAliases = i.urlAliases;
-
-    writeToGit(id, n);
-
-    return cache.get(id).getAccount().getGeneralPreferencesInfo();
-  }
-
-  private void writeToGit(Account.Id id, GeneralPreferencesInfo i)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException, BadRequestException {
-    VersionedAccountPreferences prefs;
-    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
-      prefs = VersionedAccountPreferences.forUser(id);
-      prefs.load(md);
-
-      storeSection(
-          prefs.getConfig(),
-          UserConfigSections.GENERAL,
-          null,
-          i,
-          loader.readDefaultsFromGit(md.getRepository(), null));
-
-      storeMyChangeTableColumns(prefs, i.changeTable);
-      storeMyMenus(prefs, i.my);
-      storeUrlAliases(prefs, i.urlAliases);
-      prefs.commit(md);
-      cache.evict(id);
-    }
-  }
-
-  public static void storeMyMenus(VersionedAccountPreferences prefs, List<MenuItem> my)
-      throws BadRequestException {
-    Config cfg = prefs.getConfig();
-    if (my != null) {
-      unsetSection(cfg, UserConfigSections.MY);
-      for (MenuItem item : my) {
-        checkRequiredMenuItemField(item.name, "name");
-        checkRequiredMenuItemField(item.url, "URL");
-
-        set(cfg, item.name, KEY_URL, item.url);
-        set(cfg, item.name, KEY_TARGET, item.target);
-        set(cfg, item.name, KEY_ID, item.id);
-      }
-    }
-  }
-
-  public static void storeMyChangeTableColumns(
-      VersionedAccountPreferences prefs, List<String> changeTable) {
-    Config cfg = prefs.getConfig();
-    if (changeTable != null) {
-      unsetSection(cfg, UserConfigSections.CHANGE_TABLE);
-      cfg.setStringList(UserConfigSections.CHANGE_TABLE, null, CHANGE_TABLE_COLUMN, changeTable);
-    }
-  }
-
-  private static void set(Config cfg, String section, String key, @Nullable String val) {
-    if (val == null || val.trim().isEmpty()) {
-      cfg.unset(UserConfigSections.MY, section.trim(), key);
-    } else {
-      cfg.setString(UserConfigSections.MY, section.trim(), key, val.trim());
-    }
-  }
-
-  private static void unsetSection(Config cfg, String section) {
-    cfg.unsetSection(section, null);
-    for (String subsection : cfg.getSubsections(section)) {
-      cfg.unsetSection(section, subsection);
-    }
-  }
-
-  public static void storeUrlAliases(
-      VersionedAccountPreferences prefs, Map<String, String> urlAliases) {
-    if (urlAliases != null) {
-      Config cfg = prefs.getConfig();
-      for (String subsection : cfg.getSubsections(URL_ALIAS)) {
-        cfg.unsetSection(URL_ALIAS, subsection);
-      }
-
-      int i = 1;
-      for (Entry<String, String> e : urlAliases.entrySet()) {
-        cfg.setString(URL_ALIAS, URL_ALIAS + i, KEY_MATCH, e.getKey());
-        cfg.setString(URL_ALIAS, URL_ALIAS + i, KEY_TOKEN, e.getValue());
-        i++;
-      }
-    }
-  }
-
-  private static void checkRequiredMenuItemField(String value, String name)
-      throws BadRequestException {
-    if (value == null || value.trim().isEmpty()) {
-      throw new BadRequestException(name + " for menu item is required");
-    }
-  }
-
-  private void checkDownloadScheme(String downloadScheme) throws BadRequestException {
-    if (Strings.isNullOrEmpty(downloadScheme)) {
-      return;
-    }
-
-    for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
-      if (e.getExportName().equals(downloadScheme) && e.getProvider().get().isEnabled()) {
-        return;
-      }
-    }
-    throw new BadRequestException("Unsupported download scheme: " + downloadScheme);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
deleted file mode 100644
index 70c02a1..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
+++ /dev/null
@@ -1,96 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class SshKeys implements ChildCollection<AccountResource, AccountResource.SshKey> {
-  private final DynamicMap<RestView<AccountResource.SshKey>> views;
-  private final GetSshKeys list;
-  private final Provider<CurrentUser> self;
-  private final PermissionBackend permissionBackend;
-  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
-
-  @Inject
-  SshKeys(
-      DynamicMap<RestView<AccountResource.SshKey>> views,
-      GetSshKeys list,
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend,
-      VersionedAuthorizedKeys.Accessor authorizedKeys) {
-    this.views = views;
-    this.list = list;
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-    this.authorizedKeys = authorizedKeys;
-  }
-
-  @Override
-  public RestView<AccountResource> list() {
-    return list;
-  }
-
-  @Override
-  public AccountResource.SshKey parse(AccountResource rsrc, IdString id)
-      throws ResourceNotFoundException, OrmException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    if (self.get() != rsrc.getUser()) {
-      try {
-        permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
-      } catch (AuthException e) {
-        // If lacking MODIFY_ACCOUNT claim the resource does not exist.
-        throw new ResourceNotFoundException();
-      }
-    }
-    return parse(rsrc.getUser(), id);
-  }
-
-  public AccountResource.SshKey parse(IdentifiedUser user, IdString id)
-      throws ResourceNotFoundException, IOException, ConfigInvalidException {
-    try {
-      int seq = Integer.parseInt(id.get(), 10);
-      AccountSshKey sshKey = authorizedKeys.getKey(user.getAccountId(), seq);
-      if (sshKey == null) {
-        throw new ResourceNotFoundException(id);
-      }
-      return new AccountResource.SshKey(user, sshKey);
-    } catch (NumberFormatException e) {
-      throw new ResourceNotFoundException(id);
-    }
-  }
-
-  @Override
-  public DynamicMap<RestView<AccountResource.SshKey>> views() {
-    return views;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
deleted file mode 100644
index ad73a69..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
+++ /dev/null
@@ -1,205 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.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.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.StarredChangesUtil.MutuallyExclusiveLabelsException;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.ChangesCollection;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.QueryChanges;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class StarredChanges
-    implements ChildCollection<AccountResource, AccountResource.StarredChange>,
-        AcceptsCreate<AccountResource> {
-  private static final Logger log = LoggerFactory.getLogger(StarredChanges.class);
-
-  private final ChangesCollection changes;
-  private final DynamicMap<RestView<AccountResource.StarredChange>> views;
-  private final Provider<Create> createProvider;
-  private final StarredChangesUtil starredChangesUtil;
-
-  @Inject
-  StarredChanges(
-      ChangesCollection changes,
-      DynamicMap<RestView<AccountResource.StarredChange>> views,
-      Provider<Create> createProvider,
-      StarredChangesUtil starredChangesUtil) {
-    this.changes = changes;
-    this.views = views;
-    this.createProvider = createProvider;
-    this.starredChangesUtil = starredChangesUtil;
-  }
-
-  @Override
-  public AccountResource.StarredChange parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, OrmException, PermissionBackendException {
-    IdentifiedUser user = parent.getUser();
-    ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
-    if (starredChangesUtil
-        .getLabels(user.getAccountId(), change.getId())
-        .contains(StarredChangesUtil.DEFAULT_LABEL)) {
-      return new AccountResource.StarredChange(user, change);
-    }
-    throw new ResourceNotFoundException(id);
-  }
-
-  @Override
-  public DynamicMap<RestView<AccountResource.StarredChange>> views() {
-    return views;
-  }
-
-  @Override
-  public RestView<AccountResource> list() throws ResourceNotFoundException {
-    return new RestReadView<AccountResource>() {
-      @Override
-      public Object apply(AccountResource self)
-          throws BadRequestException, AuthException, OrmException {
-        QueryChanges query = changes.list();
-        query.addQuery("starredby:" + self.getUser().getAccountId().get());
-        return query.apply(TopLevelResource.INSTANCE);
-      }
-    };
-  }
-
-  @Override
-  public RestModifyView<AccountResource, EmptyInput> create(AccountResource parent, IdString id)
-      throws UnprocessableEntityException {
-    try {
-      return createProvider.get().setChange(changes.parse(TopLevelResource.INSTANCE, id));
-    } catch (ResourceNotFoundException e) {
-      throw new UnprocessableEntityException(String.format("change %s not found", id.get()));
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("cannot resolve change", e);
-      throw new UnprocessableEntityException("internal server error");
-    }
-  }
-
-  @Singleton
-  public static class Create implements RestModifyView<AccountResource, EmptyInput> {
-    private final Provider<CurrentUser> self;
-    private final StarredChangesUtil starredChangesUtil;
-    private ChangeResource change;
-
-    @Inject
-    Create(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
-      this.self = self;
-      this.starredChangesUtil = starredChangesUtil;
-    }
-
-    public Create setChange(ChangeResource change) {
-      this.change = change;
-      return this;
-    }
-
-    @Override
-    public Response<?> apply(AccountResource rsrc, EmptyInput in)
-        throws RestApiException, OrmException, IOException {
-      if (self.get() != rsrc.getUser()) {
-        throw new AuthException("not allowed to add starred change");
-      }
-      try {
-        starredChangesUtil.star(
-            self.get().getAccountId(),
-            change.getProject(),
-            change.getId(),
-            StarredChangesUtil.DEFAULT_LABELS,
-            null);
-      } catch (MutuallyExclusiveLabelsException e) {
-        throw new ResourceConflictException(e.getMessage());
-      } catch (IllegalLabelException e) {
-        throw new BadRequestException(e.getMessage());
-      } catch (OrmDuplicateKeyException e) {
-        return Response.none();
-      }
-      return Response.none();
-    }
-  }
-
-  @Singleton
-  static class Put implements RestModifyView<AccountResource.StarredChange, EmptyInput> {
-    private final Provider<CurrentUser> self;
-
-    @Inject
-    Put(Provider<CurrentUser> self) {
-      this.self = self;
-    }
-
-    @Override
-    public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
-        throws AuthException {
-      if (self.get() != rsrc.getUser()) {
-        throw new AuthException("not allowed update starred changes");
-      }
-      return Response.none();
-    }
-  }
-
-  @Singleton
-  public static class Delete implements RestModifyView<AccountResource.StarredChange, EmptyInput> {
-    private final Provider<CurrentUser> self;
-    private final StarredChangesUtil starredChangesUtil;
-
-    @Inject
-    Delete(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
-      this.self = self;
-      this.starredChangesUtil = starredChangesUtil;
-    }
-
-    @Override
-    public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
-        throws AuthException, OrmException, IOException, IllegalLabelException {
-      if (self.get() != rsrc.getUser()) {
-        throw new AuthException("not allowed remove starred change");
-      }
-      starredChangesUtil.star(
-          self.get().getAccountId(),
-          rsrc.getChange().getProject(),
-          rsrc.getChange().getId(),
-          null,
-          StarredChangesUtil.DEFAULT_LABELS);
-      return Response.none();
-    }
-  }
-
-  public static class EmptyInput {}
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Stars.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Stars.java
deleted file mode 100644
index 860f396..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Stars.java
+++ /dev/null
@@ -1,159 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.api.changes.StarsInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.account.AccountResource.Star;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.ChangesCollection;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.QueryChanges;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.Collection;
-import java.util.List;
-import java.util.Set;
-import java.util.SortedSet;
-
-@Singleton
-public class Stars implements ChildCollection<AccountResource, AccountResource.Star> {
-
-  private final ChangesCollection changes;
-  private final ListStarredChanges listStarredChanges;
-  private final StarredChangesUtil starredChangesUtil;
-  private final DynamicMap<RestView<AccountResource.Star>> views;
-
-  @Inject
-  Stars(
-      ChangesCollection changes,
-      ListStarredChanges listStarredChanges,
-      StarredChangesUtil starredChangesUtil,
-      DynamicMap<RestView<AccountResource.Star>> views) {
-    this.changes = changes;
-    this.listStarredChanges = listStarredChanges;
-    this.starredChangesUtil = starredChangesUtil;
-    this.views = views;
-  }
-
-  @Override
-  public Star parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, OrmException, PermissionBackendException {
-    IdentifiedUser user = parent.getUser();
-    ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
-    Set<String> labels = starredChangesUtil.getLabels(user.getAccountId(), change.getId());
-    return new AccountResource.Star(user, change, labels);
-  }
-
-  @Override
-  public DynamicMap<RestView<Star>> views() {
-    return views;
-  }
-
-  @Override
-  public ListStarredChanges list() {
-    return listStarredChanges;
-  }
-
-  @Singleton
-  public static class ListStarredChanges implements RestReadView<AccountResource> {
-    private final Provider<CurrentUser> self;
-    private final ChangesCollection changes;
-
-    @Inject
-    ListStarredChanges(Provider<CurrentUser> self, ChangesCollection changes) {
-      this.self = self;
-      this.changes = changes;
-    }
-
-    @Override
-    @SuppressWarnings("unchecked")
-    public List<ChangeInfo> apply(AccountResource rsrc)
-        throws BadRequestException, AuthException, OrmException {
-      if (self.get() != rsrc.getUser()) {
-        throw new AuthException("not allowed to list stars of another account");
-      }
-      QueryChanges query = changes.list();
-      query.addQuery("has:stars");
-      return (List<ChangeInfo>) query.apply(TopLevelResource.INSTANCE);
-    }
-  }
-
-  @Singleton
-  public static class Get implements RestReadView<AccountResource.Star> {
-    private final Provider<CurrentUser> self;
-    private final StarredChangesUtil starredChangesUtil;
-
-    @Inject
-    Get(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
-      this.self = self;
-      this.starredChangesUtil = starredChangesUtil;
-    }
-
-    @Override
-    public SortedSet<String> apply(AccountResource.Star rsrc) throws AuthException, OrmException {
-      if (self.get() != rsrc.getUser()) {
-        throw new AuthException("not allowed to get stars of another account");
-      }
-      return starredChangesUtil.getLabels(self.get().getAccountId(), rsrc.getChange().getId());
-    }
-  }
-
-  @Singleton
-  public static class Post implements RestModifyView<AccountResource.Star, StarsInput> {
-    private final Provider<CurrentUser> self;
-    private final StarredChangesUtil starredChangesUtil;
-
-    @Inject
-    Post(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
-      this.self = self;
-      this.starredChangesUtil = starredChangesUtil;
-    }
-
-    @Override
-    public Collection<String> apply(AccountResource.Star rsrc, StarsInput in)
-        throws AuthException, BadRequestException, OrmException {
-      if (self.get() != rsrc.getUser()) {
-        throw new AuthException("not allowed to update stars of another account");
-      }
-      try {
-        return starredChangesUtil.star(
-            self.get().getAccountId(),
-            rsrc.getChange().getProject(),
-            rsrc.getChange().getId(),
-            in.add,
-            in.remove);
-      } catch (IllegalLabelException e) {
-        throw new BadRequestException(e.getMessage());
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
deleted file mode 100644
index 667ca37..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
+++ /dev/null
@@ -1,378 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Enums;
-import com.google.common.base.Joiner;
-import com.google.common.base.Splitter;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.server.git.VersionedMetaData;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
-
-/**
- * ‘watch.config’ file in the user branch in the All-Users repository that contains the watch
- * configuration of the user.
- *
- * <p>The 'watch.config' file is a git config file that has one 'project' section for all project
- * watches of a project.
- *
- * <p>The project name is used as subsection name and the filters with the notify types that decide
- * for which events email notifications should be sent are represented as 'notify' values in the
- * subsection. A 'notify' value is formatted as {@code <filter>
- * [<comma-separated-list-of-notify-types>]}:
- *
- * <pre>
- *   [project "foo"]
- *     notify = * [ALL_COMMENTS]
- *     notify = branch:master [ALL_COMMENTS, NEW_PATCHSETS]
- *     notify = branch:master owner:self [SUBMITTED_CHANGES]
- * </pre>
- *
- * <p>If two notify values in the same subsection have the same filter they are merged on the next
- * save, taking the union of the notify types.
- *
- * <p>For watch configurations that notify on no event the list of notify types is empty:
- *
- * <pre>
- *   [project "foo"]
- *     notify = branch:master []
- * </pre>
- *
- * <p>Unknown notify types are ignored and removed on save.
- */
-public class WatchConfig extends VersionedMetaData implements ValidationError.Sink {
-  @Singleton
-  public static class Accessor {
-    private final GitRepositoryManager repoManager;
-    private final AllUsersName allUsersName;
-    private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-    private final IdentifiedUser.GenericFactory userFactory;
-
-    @Inject
-    Accessor(
-        GitRepositoryManager repoManager,
-        AllUsersName allUsersName,
-        Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-        IdentifiedUser.GenericFactory userFactory) {
-      this.repoManager = repoManager;
-      this.allUsersName = allUsersName;
-      this.metaDataUpdateFactory = metaDataUpdateFactory;
-      this.userFactory = userFactory;
-    }
-
-    public Map<ProjectWatchKey, Set<NotifyType>> getProjectWatches(Account.Id accountId)
-        throws IOException, ConfigInvalidException {
-      try (Repository git = repoManager.openRepository(allUsersName)) {
-        WatchConfig watchConfig = new WatchConfig(accountId);
-        watchConfig.load(git);
-        return watchConfig.getProjectWatches();
-      }
-    }
-
-    public synchronized void upsertProjectWatches(
-        Account.Id accountId, Map<ProjectWatchKey, Set<NotifyType>> newProjectWatches)
-        throws IOException, ConfigInvalidException {
-      WatchConfig watchConfig = read(accountId);
-      Map<ProjectWatchKey, Set<NotifyType>> projectWatches = watchConfig.getProjectWatches();
-      projectWatches.putAll(newProjectWatches);
-      commit(watchConfig);
-    }
-
-    public synchronized void deleteProjectWatches(
-        Account.Id accountId, Collection<ProjectWatchKey> projectWatchKeys)
-        throws IOException, ConfigInvalidException {
-      WatchConfig watchConfig = read(accountId);
-      Map<ProjectWatchKey, Set<NotifyType>> projectWatches = watchConfig.getProjectWatches();
-      boolean commit = false;
-      for (ProjectWatchKey key : projectWatchKeys) {
-        if (projectWatches.remove(key) != null) {
-          commit = true;
-        }
-      }
-      if (commit) {
-        commit(watchConfig);
-      }
-    }
-
-    public synchronized void deleteAllProjectWatches(Account.Id accountId)
-        throws IOException, ConfigInvalidException {
-      WatchConfig watchConfig = read(accountId);
-      boolean commit = false;
-      if (!watchConfig.getProjectWatches().isEmpty()) {
-        watchConfig.getProjectWatches().clear();
-        commit = true;
-      }
-      if (commit) {
-        commit(watchConfig);
-      }
-    }
-
-    private WatchConfig read(Account.Id accountId) throws IOException, ConfigInvalidException {
-      try (Repository git = repoManager.openRepository(allUsersName)) {
-        WatchConfig watchConfig = new WatchConfig(accountId);
-        watchConfig.load(git);
-        return watchConfig;
-      }
-    }
-
-    private void commit(WatchConfig watchConfig) throws IOException {
-      try (MetaDataUpdate md =
-          metaDataUpdateFactory
-              .get()
-              .create(allUsersName, userFactory.create(watchConfig.accountId))) {
-        watchConfig.commit(md);
-      }
-    }
-  }
-
-  @AutoValue
-  public abstract static class ProjectWatchKey {
-    public static ProjectWatchKey create(Project.NameKey project, @Nullable String filter) {
-      return new AutoValue_WatchConfig_ProjectWatchKey(project, Strings.emptyToNull(filter));
-    }
-
-    public abstract Project.NameKey project();
-
-    public abstract @Nullable String filter();
-  }
-
-  public enum NotifyType {
-    // sort by name, except 'ALL' which should stay last
-    ABANDONED_CHANGES,
-    ALL_COMMENTS,
-    NEW_CHANGES,
-    NEW_PATCHSETS,
-    SUBMITTED_CHANGES,
-
-    ALL
-  }
-
-  public static final String FILTER_ALL = "*";
-
-  public static final String WATCH_CONFIG = "watch.config";
-  public static final String PROJECT = "project";
-  public static final String KEY_NOTIFY = "notify";
-
-  private final Account.Id accountId;
-  private final String ref;
-
-  private Map<ProjectWatchKey, Set<NotifyType>> projectWatches;
-  private List<ValidationError> validationErrors;
-
-  public WatchConfig(Account.Id accountId) {
-    this.accountId = accountId;
-    this.ref = RefNames.refsUsers(accountId);
-  }
-
-  @Override
-  protected String getRefName() {
-    return ref;
-  }
-
-  @Override
-  protected void onLoad() throws IOException, ConfigInvalidException {
-    Config cfg = readConfig(WATCH_CONFIG);
-    projectWatches = parse(accountId, cfg, this);
-  }
-
-  @VisibleForTesting
-  public static Map<ProjectWatchKey, Set<NotifyType>> parse(
-      Account.Id accountId, Config cfg, ValidationError.Sink validationErrorSink) {
-    Map<ProjectWatchKey, Set<NotifyType>> projectWatches = new HashMap<>();
-    for (String projectName : cfg.getSubsections(PROJECT)) {
-      String[] notifyValues = cfg.getStringList(PROJECT, projectName, KEY_NOTIFY);
-      for (String nv : notifyValues) {
-        if (Strings.isNullOrEmpty(nv)) {
-          continue;
-        }
-
-        NotifyValue notifyValue =
-            NotifyValue.parse(accountId, projectName, nv, validationErrorSink);
-        if (notifyValue == null) {
-          continue;
-        }
-
-        ProjectWatchKey key =
-            ProjectWatchKey.create(new Project.NameKey(projectName), notifyValue.filter());
-        if (!projectWatches.containsKey(key)) {
-          projectWatches.put(key, EnumSet.noneOf(NotifyType.class));
-        }
-        projectWatches.get(key).addAll(notifyValue.notifyTypes());
-      }
-    }
-    return projectWatches;
-  }
-
-  Map<ProjectWatchKey, Set<NotifyType>> getProjectWatches() {
-    checkLoaded();
-    return projectWatches;
-  }
-
-  public void setProjectWatches(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
-    this.projectWatches = projectWatches;
-  }
-
-  @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
-    checkLoaded();
-
-    if (Strings.isNullOrEmpty(commit.getMessage())) {
-      commit.setMessage("Updated watch configuration\n");
-    }
-
-    Config cfg = readConfig(WATCH_CONFIG);
-
-    for (String projectName : cfg.getSubsections(PROJECT)) {
-      cfg.unsetSection(PROJECT, projectName);
-    }
-
-    ListMultimap<String, String> notifyValuesByProject =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-    for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : projectWatches.entrySet()) {
-      NotifyValue notifyValue = NotifyValue.create(e.getKey().filter(), e.getValue());
-      notifyValuesByProject.put(e.getKey().project().get(), notifyValue.toString());
-    }
-
-    for (Map.Entry<String, Collection<String>> e : notifyValuesByProject.asMap().entrySet()) {
-      cfg.setStringList(PROJECT, e.getKey(), KEY_NOTIFY, new ArrayList<>(e.getValue()));
-    }
-
-    saveConfig(WATCH_CONFIG, cfg);
-    return true;
-  }
-
-  private void checkLoaded() {
-    checkState(projectWatches != null, "project watches not loaded yet");
-  }
-
-  @Override
-  public void error(ValidationError error) {
-    if (validationErrors == null) {
-      validationErrors = new ArrayList<>(4);
-    }
-    validationErrors.add(error);
-  }
-
-  /**
-   * Get the validation errors, if any were discovered during load.
-   *
-   * @return list of errors; empty list if there are no errors.
-   */
-  public List<ValidationError> getValidationErrors() {
-    if (validationErrors != null) {
-      return ImmutableList.copyOf(validationErrors);
-    }
-    return ImmutableList.of();
-  }
-
-  @AutoValue
-  public abstract static class NotifyValue {
-    public static NotifyValue parse(
-        Account.Id accountId,
-        String project,
-        String notifyValue,
-        ValidationError.Sink validationErrorSink) {
-      notifyValue = notifyValue.trim();
-      int i = notifyValue.lastIndexOf('[');
-      if (i < 0 || notifyValue.charAt(notifyValue.length() - 1) != ']') {
-        validationErrorSink.error(
-            new ValidationError(
-                WATCH_CONFIG,
-                String.format(
-                    "Invalid project watch of account %d for project %s: %s",
-                    accountId.get(), project, notifyValue)));
-        return null;
-      }
-      String filter = notifyValue.substring(0, i).trim();
-      if (filter.isEmpty() || FILTER_ALL.equals(filter)) {
-        filter = null;
-      }
-
-      Set<NotifyType> notifyTypes = EnumSet.noneOf(NotifyType.class);
-      if (i + 1 < notifyValue.length() - 2) {
-        for (String nt :
-            Splitter.on(',')
-                .trimResults()
-                .splitToList(notifyValue.substring(i + 1, notifyValue.length() - 1))) {
-          NotifyType notifyType = Enums.getIfPresent(NotifyType.class, nt).orNull();
-          if (notifyType == null) {
-            validationErrorSink.error(
-                new ValidationError(
-                    WATCH_CONFIG,
-                    String.format(
-                        "Invalid notify type %s in project watch "
-                            + "of account %d for project %s: %s",
-                        nt, accountId.get(), project, notifyValue)));
-            continue;
-          }
-          notifyTypes.add(notifyType);
-        }
-      }
-      return create(filter, notifyTypes);
-    }
-
-    public static NotifyValue create(@Nullable String filter, Set<NotifyType> notifyTypes) {
-      return new AutoValue_WatchConfig_NotifyValue(
-          Strings.emptyToNull(filter), Sets.immutableEnumSet(notifyTypes));
-    }
-
-    public abstract @Nullable String filter();
-
-    public abstract ImmutableSet<NotifyType> notifyTypes();
-
-    @Override
-    public String toString() {
-      List<NotifyType> notifyTypes = new ArrayList<>(notifyTypes());
-      StringBuilder notifyValue = new StringBuilder();
-      notifyValue.append(firstNonNull(filter(), FILTER_ALL)).append(" [");
-      Joiner.on(", ").appendTo(notifyValue, notifyTypes);
-      notifyValue.append("]");
-      return notifyValue.toString();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
deleted file mode 100644
index 1033641..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account.externalids;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSetMultimap;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.inject.AbstractModule;
-import com.google.inject.Module;
-import java.io.IOException;
-import java.util.Collection;
-import org.eclipse.jgit.lib.ObjectId;
-
-public class DisabledExternalIdCache implements ExternalIdCache {
-  public static Module module() {
-    return new AbstractModule() {
-
-      @Override
-      protected void configure() {
-        bind(ExternalIdCache.class).to(DisabledExternalIdCache.class);
-      }
-    };
-  }
-
-  @Override
-  public void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
-
-  @Override
-  public void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
-
-  @Override
-  public void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Account.Id accountId,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd) {}
-
-  @Override
-  public void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd) {}
-
-  @Override
-  public void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
-
-  @Override
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId) {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
-    throw new UnsupportedOperationException();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
deleted file mode 100644
index ad119ca..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ /dev/null
@@ -1,376 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account.externalids;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Strings;
-import com.google.common.collect.Iterables;
-import com.google.common.hash.Hashing;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.HashedPassword;
-import java.io.Serializable;
-import java.util.Objects;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-
-@AutoValue
-public abstract class ExternalId implements Serializable {
-  private static final long serialVersionUID = 1L;
-
-  private static final String EXTERNAL_ID_SECTION = "externalId";
-  private static final String ACCOUNT_ID_KEY = "accountId";
-  private static final String EMAIL_KEY = "email";
-  private static final String PASSWORD_KEY = "password";
-
-  /**
-   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link
-   * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
-   *
-   * <p>The name {@code gerrit:} was a very poor choice.
-   */
-  public static final String SCHEME_GERRIT = "gerrit";
-
-  /** Scheme used for randomly created identities constructed by a UUID. */
-  public static final String SCHEME_UUID = "uuid";
-
-  /** Scheme used to represent only an email address. */
-  public static final String SCHEME_MAILTO = "mailto";
-
-  /** Scheme for the username used to authenticate an account, e.g. over SSH. */
-  public static final String SCHEME_USERNAME = "username";
-
-  /** Scheme used for GPG public keys. */
-  public static final String SCHEME_GPGKEY = "gpgkey";
-
-  /** Scheme for external auth used during authentication, e.g. OAuth Token */
-  public static final String SCHEME_EXTERNAL = "external";
-
-  @AutoValue
-  public abstract static class Key implements Serializable {
-    private static final long serialVersionUID = 1L;
-
-    public static Key create(@Nullable String scheme, String id) {
-      return new AutoValue_ExternalId_Key(Strings.emptyToNull(scheme), id);
-    }
-
-    /**
-     * Parses an external ID key from a string in the format "scheme:id" or "id".
-     *
-     * @return the parsed external ID key
-     */
-    public static Key parse(String externalId) {
-      int c = externalId.indexOf(':');
-      if (c < 1 || c >= externalId.length() - 1) {
-        return create(null, externalId);
-      }
-      return create(externalId.substring(0, c), externalId.substring(c + 1));
-    }
-
-    public abstract @Nullable String scheme();
-
-    public abstract String id();
-
-    public boolean isScheme(String scheme) {
-      return scheme.equals(scheme());
-    }
-
-    /**
-     * Returns the SHA1 of the external ID that is used as note ID in the refs/meta/external-ids
-     * notes branch.
-     */
-    @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
-    public ObjectId sha1() {
-      return ObjectId.fromRaw(Hashing.sha1().hashString(get(), UTF_8).asBytes());
-    }
-
-    /**
-     * Exports this external ID key as string with the format "scheme:id", or "id" if scheme is
-     * null.
-     *
-     * <p>This string representation is used as subsection name in the Git config file that stores
-     * the external ID.
-     */
-    public String get() {
-      if (scheme() != null) {
-        return scheme() + ":" + id();
-      }
-      return id();
-    }
-
-    @Override
-    public String toString() {
-      return get();
-    }
-  }
-
-  public static ExternalId create(String scheme, String id, Account.Id accountId) {
-    return create(Key.create(scheme, id), accountId, null, null);
-  }
-
-  public static ExternalId create(
-      String scheme,
-      String id,
-      Account.Id accountId,
-      @Nullable String email,
-      @Nullable String hashedPassword) {
-    return create(Key.create(scheme, id), accountId, email, hashedPassword);
-  }
-
-  public static ExternalId create(Key key, Account.Id accountId) {
-    return create(key, accountId, null, null);
-  }
-
-  public static ExternalId create(
-      Key key, Account.Id accountId, @Nullable String email, @Nullable String hashedPassword) {
-    return create(
-        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), null);
-  }
-
-  public static ExternalId createWithPassword(
-      Key key, Account.Id accountId, @Nullable String email, String plainPassword) {
-    plainPassword = Strings.emptyToNull(plainPassword);
-    String hashedPassword =
-        plainPassword != null ? HashedPassword.fromPassword(plainPassword).encode() : null;
-    return create(key, accountId, email, hashedPassword);
-  }
-
-  public static ExternalId createUsername(String id, Account.Id accountId, String plainPassword) {
-    return createWithPassword(Key.create(SCHEME_USERNAME, id), accountId, null, plainPassword);
-  }
-
-  public static ExternalId createWithEmail(
-      String scheme, String id, Account.Id accountId, @Nullable String email) {
-    return createWithEmail(Key.create(scheme, id), accountId, email);
-  }
-
-  public static ExternalId createWithEmail(Key key, Account.Id accountId, @Nullable String email) {
-    return create(key, accountId, Strings.emptyToNull(email), null);
-  }
-
-  public static ExternalId createEmail(Account.Id accountId, String email) {
-    return createWithEmail(SCHEME_MAILTO, email, accountId, checkNotNull(email));
-  }
-
-  static ExternalId create(ExternalId extId, @Nullable ObjectId blobId) {
-    return new AutoValue_ExternalId(
-        extId.key(), extId.accountId(), extId.email(), extId.password(), blobId);
-  }
-
-  @VisibleForTesting
-  public static ExternalId create(
-      Key key,
-      Account.Id accountId,
-      @Nullable String email,
-      @Nullable String hashedPassword,
-      @Nullable ObjectId blobId) {
-    return new AutoValue_ExternalId(
-        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), blobId);
-  }
-
-  /**
-   * Parses an external ID from a byte array that contain the external ID as an Git config file
-   * text.
-   *
-   * <p>The Git config must have exactly one externalId subsection with an accountId and optionally
-   * email and password:
-   *
-   * <pre>
-   * [externalId "username:jdoe"]
-   *   accountId = 1003407
-   *   email = jdoe@example.com
-   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
-   * </pre>
-   */
-  public static ExternalId parse(String noteId, byte[] raw, ObjectId blobId)
-      throws ConfigInvalidException {
-    checkNotNull(blobId);
-
-    Config externalIdConfig = new Config();
-    try {
-      externalIdConfig.fromText(new String(raw, UTF_8));
-    } catch (ConfigInvalidException e) {
-      throw invalidConfig(noteId, e.getMessage());
-    }
-
-    Set<String> externalIdKeys = externalIdConfig.getSubsections(EXTERNAL_ID_SECTION);
-    if (externalIdKeys.size() != 1) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Expected exactly 1 '%s' section, found %d",
-              EXTERNAL_ID_SECTION, externalIdKeys.size()));
-    }
-
-    String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
-    Key externalIdKey = Key.parse(externalIdKeyStr);
-    if (externalIdKey == null) {
-      throw invalidConfig(noteId, String.format("External ID %s is invalid", externalIdKeyStr));
-    }
-
-    if (!externalIdKey.sha1().getName().equals(noteId)) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
-    }
-
-    String email = externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, EMAIL_KEY);
-    String password =
-        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, PASSWORD_KEY);
-    int accountId = readAccountId(noteId, externalIdConfig, externalIdKeyStr);
-
-    return create(
-        externalIdKey,
-        new Account.Id(accountId),
-        Strings.emptyToNull(email),
-        Strings.emptyToNull(password),
-        blobId);
-  }
-
-  private static int readAccountId(String noteId, Config externalIdConfig, String externalIdKeyStr)
-      throws ConfigInvalidException {
-    String accountIdStr =
-        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY);
-    if (accountIdStr == null) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Value for '%s.%s.%s' is missing, expected account ID",
-              EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
-    }
-
-    try {
-      int accountId =
-          externalIdConfig.getInt(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY, -1);
-      if (accountId < 0) {
-        throw invalidConfig(
-            noteId,
-            String.format(
-                "Value %s for '%s.%s.%s' is invalid, expected account ID",
-                accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
-      }
-      return accountId;
-    } catch (IllegalArgumentException e) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Value %s for '%s.%s.%s' is invalid, expected account ID",
-              accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
-    }
-  }
-
-  private static ConfigInvalidException invalidConfig(String noteId, String message) {
-    return new ConfigInvalidException(
-        String.format("Invalid external ID config for note '%s': %s", noteId, message));
-  }
-
-  public abstract Key key();
-
-  public abstract Account.Id accountId();
-
-  public abstract @Nullable String email();
-
-  public abstract @Nullable String password();
-
-  /**
-   * ID of the note blob in the external IDs branch that stores this external ID. {@code null} if
-   * the external ID was created in code and is not yet stored in Git.
-   */
-  public abstract @Nullable ObjectId blobId();
-
-  public void checkThatBlobIdIsSet() {
-    checkState(blobId() != null, "No blob ID set for external ID %s", key().get());
-  }
-
-  public boolean isScheme(String scheme) {
-    return key().isScheme(scheme);
-  }
-
-  public byte[] toByteArray() {
-    checkState(blobId() != null, "Missing blobId in external ID %s", key().get());
-    byte[] b = new byte[2 * Constants.OBJECT_ID_STRING_LENGTH + 1];
-    key().sha1().copyTo(b, 0);
-    b[Constants.OBJECT_ID_STRING_LENGTH] = ':';
-    blobId().copyTo(b, Constants.OBJECT_ID_STRING_LENGTH + 1);
-    return b;
-  }
-
-  /**
-   * For checking if two external IDs are equals the blobId is excluded and external IDs that have
-   * different blob IDs but identical other fields are considered equal. This way an external ID
-   * that was loaded from Git can be equal with an external ID that was created from code.
-   */
-  @Override
-  public boolean equals(Object obj) {
-    if (!(obj instanceof ExternalId)) {
-      return false;
-    }
-    ExternalId o = (ExternalId) obj;
-    return Objects.equals(key(), o.key())
-        && Objects.equals(accountId(), o.accountId())
-        && Objects.equals(email(), o.email())
-        && Objects.equals(password(), o.password());
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(key(), accountId(), email(), password());
-  }
-
-  /**
-   * Exports this external ID as Git config file text.
-   *
-   * <p>The Git config has exactly one externalId subsection with an accountId and optionally email
-   * and password:
-   *
-   * <pre>
-   * [externalId "username:jdoe"]
-   *   accountId = 1003407
-   *   email = jdoe@example.com
-   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
-   * </pre>
-   */
-  @Override
-  public String toString() {
-    Config c = new Config();
-    writeToConfig(c);
-    return c.toText();
-  }
-
-  public void writeToConfig(Config c) {
-    String externalIdKey = key().get();
-    // Do not use c.setInt(...) to write the account ID because c.setInt(...) persists integers
-    // that can be expressed in KiB as a unit strings, e.g. "1024000" is stored as "100k". Using
-    // c.setString(...) ensures that account IDs are human readable.
-    c.setString(
-        EXTERNAL_ID_SECTION, externalIdKey, ACCOUNT_ID_KEY, Integer.toString(accountId().get()));
-    if (email() != null) {
-      c.setString(EXTERNAL_ID_SECTION, externalIdKey, EMAIL_KEY, email());
-    }
-    if (password() != null) {
-      c.setString(EXTERNAL_ID_SECTION, externalIdKey, PASSWORD_KEY, password());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
deleted file mode 100644
index d928e15..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account.externalids;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSetMultimap;
-import com.google.gerrit.reviewdb.client.Account;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
-import org.eclipse.jgit.lib.ObjectId;
-
-/**
- * Caches external IDs of all accounts.
- *
- * <p>On each cache access the SHA1 of the refs/meta/external-ids branch is read to verify that the
- * cache is up to date.
- */
-interface ExternalIdCache {
-  void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
-      throws IOException;
-
-  void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
-      throws IOException;
-
-  void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Account.Id accountId,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd)
-      throws IOException;
-
-  void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd)
-      throws IOException;
-
-  void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
-      throws IOException;
-
-  ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
-
-  ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException;
-
-  ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException;
-
-  ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException;
-
-  default ImmutableSet<ExternalId> byEmail(String email) throws IOException {
-    return byEmails(email).get(email);
-  }
-
-  default void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId extId)
-      throws IOException {
-    onCreate(oldNotesRev, newNotesRev, Collections.singleton(extId));
-  }
-
-  default void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId extId)
-      throws IOException {
-    onRemove(oldNotesRev, newNotesRev, Collections.singleton(extId));
-  }
-
-  default void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId updatedExtId)
-      throws IOException {
-    onUpdate(oldNotesRev, newNotesRev, Collections.singleton(updatedExtId));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
deleted file mode 100644
index 311e70f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ /dev/null
@@ -1,264 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account.externalids;
-
-import static com.google.common.collect.ImmutableSetMultimap.toImmutableSetMultimap;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.Strings;
-import com.google.common.cache.CacheBuilder;
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.common.collect.Collections2;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Multimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReentrantLock;
-import java.util.function.Consumer;
-import org.eclipse.jgit.lib.ObjectId;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Caches external IDs of all accounts. The external IDs are always loaded from NoteDb. */
-@Singleton
-class ExternalIdCacheImpl implements ExternalIdCache {
-  private static final Logger log = LoggerFactory.getLogger(ExternalIdCacheImpl.class);
-
-  private final LoadingCache<ObjectId, AllExternalIds> extIdsByAccount;
-  private final ExternalIdReader externalIdReader;
-  private final Lock lock;
-
-  @Inject
-  ExternalIdCacheImpl(ExternalIdReader externalIdReader) {
-    this.extIdsByAccount =
-        CacheBuilder.newBuilder()
-            // The cached data is potentially pretty large and we are always only interested
-            // in the latest value, hence the maximum cache size is set to 1.
-            // This can lead to extra cache loads in case of the following race:
-            // 1. thread 1 reads the notes ref at revision A
-            // 2. thread 2 updates the notes ref to revision B and stores the derived value
-            //    for B in the cache
-            // 3. thread 1 attempts to read the data for revision A from the cache, and misses
-            // 4. later threads attempt to read at B
-            // In this race unneeded reloads are done in step 3 (reload from revision A) and
-            // step 4 (reload from revision B, because the value for revision B was lost when the
-            // reload from revision A was done, since the cache can hold only one entry).
-            // These reloads could be avoided by increasing the cache size to 2. However the race
-            // window between reading the ref and looking it up in the cache is small so that
-            // it's rare that this race happens. Therefore it's not worth to double the memory
-            // usage of this cache, just to avoid this.
-            .maximumSize(1)
-            .build(new Loader(externalIdReader));
-    this.externalIdReader = externalIdReader;
-    this.lock = new ReentrantLock(true /* fair */);
-  }
-
-  @Override
-  public void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extIds)
-      throws IOException {
-    updateCache(
-        oldNotesRev,
-        newNotesRev,
-        m -> {
-          for (ExternalId extId : extIds) {
-            extId.checkThatBlobIdIsSet();
-            m.put(extId.accountId(), extId);
-          }
-        });
-  }
-
-  @Override
-  public void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extIds)
-      throws IOException {
-    updateCache(
-        oldNotesRev,
-        newNotesRev,
-        m -> {
-          for (ExternalId extId : extIds) {
-            m.remove(extId.accountId(), extId);
-          }
-        });
-  }
-
-  @Override
-  public void onUpdate(
-      ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> updatedExtIds)
-      throws IOException {
-    updateCache(
-        oldNotesRev,
-        newNotesRev,
-        m -> {
-          removeKeys(m.values(), updatedExtIds.stream().map(e -> e.key()).collect(toSet()));
-          for (ExternalId updatedExtId : updatedExtIds) {
-            updatedExtId.checkThatBlobIdIsSet();
-            m.put(updatedExtId.accountId(), updatedExtId);
-          }
-        });
-  }
-
-  @Override
-  public void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Account.Id accountId,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd)
-      throws IOException {
-    ExternalIdsUpdate.checkSameAccount(Iterables.concat(toRemove, toAdd), accountId);
-
-    updateCache(
-        oldNotesRev,
-        newNotesRev,
-        m -> {
-          for (ExternalId extId : toRemove) {
-            m.remove(extId.accountId(), extId);
-          }
-          for (ExternalId extId : toAdd) {
-            extId.checkThatBlobIdIsSet();
-            m.put(extId.accountId(), extId);
-          }
-        });
-  }
-
-  @Override
-  public void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd)
-      throws IOException {
-    updateCache(
-        oldNotesRev,
-        newNotesRev,
-        m -> {
-          for (ExternalId extId : toRemove) {
-            m.remove(extId.accountId(), extId);
-          }
-          for (ExternalId extId : toAdd) {
-            extId.checkThatBlobIdIsSet();
-            m.put(extId.accountId(), extId);
-          }
-        });
-  }
-
-  @Override
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException {
-    return get().byAccount().get(accountId);
-  }
-
-  @Override
-  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
-    return get().byAccount();
-  }
-
-  @Override
-  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
-    AllExternalIds allExternalIds = get();
-    ImmutableSetMultimap.Builder<String, ExternalId> byEmails = ImmutableSetMultimap.builder();
-    for (String email : emails) {
-      byEmails.putAll(email, allExternalIds.byEmail().get(email));
-    }
-    return byEmails.build();
-  }
-
-  @Override
-  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
-    return get().byEmail();
-  }
-
-  private AllExternalIds get() throws IOException {
-    try {
-      return extIdsByAccount.get(externalIdReader.readRevision());
-    } catch (ExecutionException e) {
-      throw new IOException("Cannot load external ids", e);
-    }
-  }
-
-  private void updateCache(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Consumer<Multimap<Account.Id, ExternalId>> update) {
-    lock.lock();
-    try {
-      ListMultimap<Account.Id, ExternalId> m;
-      if (!ObjectId.zeroId().equals(oldNotesRev)) {
-        m =
-            MultimapBuilder.hashKeys()
-                .arrayListValues()
-                .build(extIdsByAccount.get(oldNotesRev).byAccount());
-      } else {
-        m = MultimapBuilder.hashKeys().arrayListValues().build();
-      }
-      update.accept(m);
-      extIdsByAccount.put(newNotesRev, AllExternalIds.create(m));
-    } catch (ExecutionException e) {
-      log.warn("Cannot update external IDs", e);
-    } finally {
-      lock.unlock();
-    }
-  }
-
-  private static void removeKeys(Collection<ExternalId> ids, Collection<ExternalId.Key> toRemove) {
-    Collections2.transform(ids, e -> e.key()).removeAll(toRemove);
-  }
-
-  private static class Loader extends CacheLoader<ObjectId, AllExternalIds> {
-    private final ExternalIdReader externalIdReader;
-
-    Loader(ExternalIdReader externalIdReader) {
-      this.externalIdReader = externalIdReader;
-    }
-
-    @Override
-    public AllExternalIds load(ObjectId notesRev) throws Exception {
-      Multimap<Account.Id, ExternalId> extIdsByAccount =
-          MultimapBuilder.hashKeys().arrayListValues().build();
-      for (ExternalId extId : externalIdReader.all(notesRev)) {
-        extId.checkThatBlobIdIsSet();
-        extIdsByAccount.put(extId.accountId(), extId);
-      }
-      return AllExternalIds.create(extIdsByAccount);
-    }
-  }
-
-  @AutoValue
-  abstract static class AllExternalIds {
-    static AllExternalIds create(Multimap<Account.Id, ExternalId> byAccount) {
-      ImmutableSetMultimap<String, ExternalId> byEmail =
-          byAccount
-              .values()
-              .stream()
-              .filter(e -> !Strings.isNullOrEmpty(e.email()))
-              .collect(toImmutableSetMultimap(ExternalId::email, e -> e));
-      return new AutoValue_ExternalIdCacheImpl_AllExternalIds(
-          ImmutableSetMultimap.copyOf(byAccount), byEmail);
-    }
-
-    public abstract ImmutableSetMultimap<Account.Id, ExternalId> byAccount();
-
-    public abstract ImmutableSetMultimap<String, ExternalId> byEmail();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
deleted file mode 100644
index bf78b13..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ /dev/null
@@ -1,199 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account.externalids;
-
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Description.Units;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.Timer0;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.HashSet;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.Note;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Class to read external IDs from NoteDb.
- *
- * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
- * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
- * is a git config file that contains an external ID. It has exactly one externalId subsection with
- * an accountId and optionally email and password:
- *
- * <pre>
- * [externalId "username:jdoe"]
- *   accountId = 1003407
- *   email = jdoe@example.com
- *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
- * </pre>
- */
-@Singleton
-public class ExternalIdReader {
-  private static final Logger log = LoggerFactory.getLogger(ExternalIdReader.class);
-
-  public static final int MAX_NOTE_SZ = 1 << 19;
-
-  public static ObjectId readRevision(Repository repo) throws IOException {
-    Ref ref = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
-    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
-  }
-
-  public static NoteMap readNoteMap(RevWalk rw, ObjectId rev) throws IOException {
-    if (!rev.equals(ObjectId.zeroId())) {
-      return NoteMap.read(rw.getObjectReader(), rw.parseCommit(rev));
-    }
-    return NoteMap.newEmptyMap();
-  }
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private boolean failOnLoad = false;
-  private final Timer0 readAllLatency;
-
-  @Inject
-  ExternalIdReader(
-      GitRepositoryManager repoManager, AllUsersName allUsersName, MetricMaker metricMaker) {
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.readAllLatency =
-        metricMaker.newTimer(
-            "notedb/read_all_external_ids_latency",
-            new Description("Latency for reading all external IDs from NoteDb.")
-                .setCumulative()
-                .setUnit(Units.MILLISECONDS));
-  }
-
-  @VisibleForTesting
-  public void setFailOnLoad(boolean failOnLoad) {
-    this.failOnLoad = failOnLoad;
-  }
-
-  ObjectId readRevision() throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return readRevision(repo);
-    }
-  }
-
-  /** Reads and returns all external IDs. */
-  Set<ExternalId> all() throws IOException {
-    checkReadEnabled();
-
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return all(repo, readRevision(repo));
-    }
-  }
-
-  /**
-   * Reads and returns all external IDs from the specified revision of the refs/meta/external-ids
-   * branch.
-   */
-  Set<ExternalId> all(ObjectId rev) throws IOException {
-    checkReadEnabled();
-
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return all(repo, rev);
-    }
-  }
-
-  /** Reads and returns all external IDs. */
-  private Set<ExternalId> all(Repository repo, ObjectId rev) throws IOException {
-    if (rev.equals(ObjectId.zeroId())) {
-      return ImmutableSet.of();
-    }
-
-    try (Timer0.Context ctx = readAllLatency.start();
-        RevWalk rw = new RevWalk(repo)) {
-      NoteMap noteMap = readNoteMap(rw, rev);
-      Set<ExternalId> extIds = new HashSet<>();
-      for (Note note : noteMap) {
-        byte[] raw =
-            rw.getObjectReader().open(note.getData(), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-        try {
-          extIds.add(ExternalId.parse(note.getName(), raw, note.getData()));
-        } catch (Exception e) {
-          log.error(String.format("Ignoring invalid external ID note %s", note.getName()), e);
-        }
-      }
-      return extIds;
-    }
-  }
-
-  /** Reads and returns the specified external ID. */
-  @Nullable
-  ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
-    checkReadEnabled();
-
-    try (Repository repo = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId rev = readRevision(repo);
-      if (rev.equals(ObjectId.zeroId())) {
-        return null;
-      }
-
-      return parse(key, rw, rev);
-    }
-  }
-
-  /** Reads and returns the specified external ID from the given revision. */
-  @Nullable
-  ExternalId get(ExternalId.Key key, ObjectId rev) throws IOException, ConfigInvalidException {
-    checkReadEnabled();
-
-    if (rev.equals(ObjectId.zeroId())) {
-      return null;
-    }
-
-    try (Repository repo = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(repo)) {
-      return parse(key, rw, rev);
-    }
-  }
-
-  private static ExternalId parse(ExternalId.Key key, RevWalk rw, ObjectId rev)
-      throws IOException, ConfigInvalidException {
-    NoteMap noteMap = readNoteMap(rw, rev);
-    ObjectId noteId = key.sha1();
-    if (!noteMap.contains(noteId)) {
-      return null;
-    }
-
-    ObjectId noteData = noteMap.get(noteId);
-    byte[] raw = rw.getObjectReader().open(noteData, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-    return ExternalId.parse(noteId.name(), raw, noteData);
-  }
-
-  private void checkReadEnabled() throws IOException {
-    if (failOnLoad) {
-      throw new IOException("Reading from external IDs is disabled");
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java
deleted file mode 100644
index 35eb6d4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java
+++ /dev/null
@@ -1,122 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account.externalids;
-
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.collect.ImmutableSetMultimap;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-
-/**
- * Class to access external IDs.
- *
- * <p>The external IDs are either read from NoteDb or retrieved from the cache.
- */
-@Singleton
-public class ExternalIds {
-  private final ExternalIdReader externalIdReader;
-  private final ExternalIdCache externalIdCache;
-
-  @Inject
-  public ExternalIds(ExternalIdReader externalIdReader, ExternalIdCache externalIdCache) {
-    this.externalIdReader = externalIdReader;
-    this.externalIdCache = externalIdCache;
-  }
-
-  /** Returns all external IDs. */
-  public Set<ExternalId> all() throws IOException {
-    return externalIdReader.all();
-  }
-
-  /** Returns all external IDs from the specified revision of the refs/meta/external-ids branch. */
-  public Set<ExternalId> all(ObjectId rev) throws IOException {
-    return externalIdReader.all(rev);
-  }
-
-  /** Returns the specified external ID. */
-  @Nullable
-  public ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
-    return externalIdReader.get(key);
-  }
-
-  /** Returns the specified external ID from the given revision. */
-  @Nullable
-  public ExternalId get(ExternalId.Key key, ObjectId rev)
-      throws IOException, ConfigInvalidException {
-    return externalIdReader.get(key, rev);
-  }
-
-  /** Returns the external IDs of the specified account. */
-  public Set<ExternalId> byAccount(Account.Id accountId) throws IOException {
-    return externalIdCache.byAccount(accountId);
-  }
-
-  /** Returns the external IDs of the specified account that have the given scheme. */
-  public Set<ExternalId> byAccount(Account.Id accountId, String scheme) throws IOException {
-    return byAccount(accountId).stream().filter(e -> e.key().isScheme(scheme)).collect(toSet());
-  }
-
-  /** Returns all external IDs by account. */
-  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
-    return externalIdCache.allByAccount();
-  }
-
-  /**
-   * Returns the external ID with the given email.
-   *
-   * <p>Each email should belong to a single external ID only. This means if more than one external
-   * ID is returned there is an inconsistency in the external IDs.
-   *
-   * <p>The external IDs are retrieved from the external ID cache. Each access to the external ID
-   * cache requires reading the SHA1 of the refs/meta/external-ids branch. If external IDs for
-   * multiple emails are needed it is more efficient to use {@link #byEmails(String...)} as this
-   * method reads the SHA1 of the refs/meta/external-ids branch only once (and not once per email).
-   *
-   * @see #byEmails(String...)
-   */
-  public Set<ExternalId> byEmail(String email) throws IOException {
-    return externalIdCache.byEmail(email);
-  }
-
-  /**
-   * Returns the external IDs for the given emails.
-   *
-   * <p>Each email should belong to a single external ID only. This means if more than one external
-   * ID for an email is returned there is an inconsistency in the external IDs.
-   *
-   * <p>The external IDs are retrieved from the external ID cache. Each access to the external ID
-   * cache requires reading the SHA1 of the refs/meta/external-ids branch. If external IDs for
-   * multiple emails are needed it is more efficient to use this method instead of {@link
-   * #byEmail(String)} as this method reads the SHA1 of the refs/meta/external-ids branch only once
-   * (and not once per email).
-   *
-   * @see #byEmail(String)
-   */
-  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
-    return externalIdCache.byEmails(emails);
-  }
-
-  /** Returns all external IDs by email. */
-  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
-    return externalIdCache.allByEmail();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
deleted file mode 100644
index 8e5582c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
+++ /dev/null
@@ -1,129 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account.externalids;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.HashSet;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * This class allows to do batch updates to external IDs.
- *
- * <p>For NoteDb all updates will result in a single commit to the refs/meta/external-ids branch.
- * This means callers can prepare many updates by invoking {@link #replace(ExternalId, ExternalId)}
- * multiple times and when {@link ExternalIdsBatchUpdate#commit(String)} is invoked a single NoteDb
- * commit is created that contains all the prepared updates.
- */
-public class ExternalIdsBatchUpdate {
-  private final GitRepositoryManager repoManager;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverIdent;
-  private final ExternalIdCache externalIdCache;
-  private final Set<ExternalId> toAdd = new HashSet<>();
-  private final Set<ExternalId> toDelete = new HashSet<>();
-
-  @Inject
-  public ExternalIdsBatchUpdate(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverIdent,
-      ExternalIdCache externalIdCache) {
-    this.repoManager = repoManager;
-    this.gitRefUpdated = gitRefUpdated;
-    this.allUsersName = allUsersName;
-    this.serverIdent = serverIdent;
-    this.externalIdCache = externalIdCache;
-  }
-
-  /**
-   * Adds an external ID replacement to the batch.
-   *
-   * <p>The actual replacement is only done when {@link #commit(String)} is invoked.
-   */
-  public void replace(ExternalId extIdToDelete, ExternalId extIdToAdd) {
-    ExternalIdsUpdate.checkSameAccount(ImmutableSet.of(extIdToDelete, extIdToAdd));
-    toAdd.add(extIdToAdd);
-    toDelete.add(extIdToDelete);
-  }
-
-  /**
-   * Commits this batch.
-   *
-   * <p>This means external ID replacements which were prepared by invoking {@link
-   * #replace(ExternalId, ExternalId)} are now executed. Deletion of external IDs is done before
-   * adding the new external IDs. This means if an external ID is specified for deletion and an
-   * external ID with the same key is specified to be added, the old external ID with that key is
-   * deleted first and then the new external ID is added (so the external ID for that key is
-   * replaced).
-   *
-   * <p>For NoteDb a single commit is created that contains all the external ID updates.
-   */
-  public void commit(String commitMessage)
-      throws IOException, OrmException, ConfigInvalidException {
-    if (toDelete.isEmpty() && toAdd.isEmpty()) {
-      return;
-    }
-
-    try (Repository repo = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(repo);
-        ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId rev = ExternalIdReader.readRevision(repo);
-
-      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-
-      for (ExternalId extId : toDelete) {
-        ExternalIdsUpdate.remove(rw, noteMap, extId);
-      }
-
-      for (ExternalId extId : toAdd) {
-        ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
-      }
-
-      ObjectId newRev =
-          ExternalIdsUpdate.commit(
-              allUsersName,
-              repo,
-              rw,
-              ins,
-              rev,
-              noteMap,
-              commitMessage,
-              serverIdent,
-              serverIdent,
-              null,
-              gitRefUpdated);
-      externalIdCache.onReplace(rev, newRev, toDelete, toAdd);
-    }
-
-    toAdd.clear();
-    toDelete.clear();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
deleted file mode 100644
index 5dbde8e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
+++ /dev/null
@@ -1,155 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account.externalids;
-
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-import static java.util.stream.Collectors.joining;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.HashedPassword;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import org.apache.commons.codec.DecoderException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.Note;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-@Singleton
-public class ExternalIdsConsistencyChecker {
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsers;
-  private final AccountCache accountCache;
-  private final OutgoingEmailValidator validator;
-
-  @Inject
-  ExternalIdsConsistencyChecker(
-      GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      AccountCache accountCache,
-      OutgoingEmailValidator validator) {
-    this.repoManager = repoManager;
-    this.allUsers = allUsers;
-    this.accountCache = accountCache;
-    this.validator = validator;
-  }
-
-  public List<ConsistencyProblemInfo> check() throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(repo, ExternalIdReader.readRevision(repo));
-    }
-  }
-
-  public List<ConsistencyProblemInfo> check(ObjectId rev) throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(repo, rev);
-    }
-  }
-
-  private List<ConsistencyProblemInfo> check(Repository repo, ObjectId commit) throws IOException {
-    List<ConsistencyProblemInfo> problems = new ArrayList<>();
-
-    ListMultimap<String, ExternalId.Key> emails =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-
-    try (RevWalk rw = new RevWalk(repo)) {
-      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, commit);
-      for (Note note : noteMap) {
-        byte[] raw =
-            rw.getObjectReader()
-                .open(note.getData(), OBJ_BLOB)
-                .getCachedBytes(ExternalIdReader.MAX_NOTE_SZ);
-        try {
-          ExternalId extId = ExternalId.parse(note.getName(), raw, note.getData());
-          problems.addAll(validateExternalId(extId));
-
-          if (extId.email() != null) {
-            emails.put(extId.email(), extId.key());
-          }
-        } catch (ConfigInvalidException e) {
-          addError(String.format(e.getMessage()), problems);
-        }
-      }
-    }
-
-    emails
-        .asMap()
-        .entrySet()
-        .stream()
-        .filter(e -> e.getValue().size() > 1)
-        .forEach(
-            e ->
-                addError(
-                    String.format(
-                        "Email '%s' is not unique, it's used by the following external IDs: %s",
-                        e.getKey(),
-                        e.getValue()
-                            .stream()
-                            .map(k -> "'" + k.get() + "'")
-                            .sorted()
-                            .collect(joining(", "))),
-                    problems));
-
-    return problems;
-  }
-
-  private List<ConsistencyProblemInfo> validateExternalId(ExternalId extId) {
-    List<ConsistencyProblemInfo> problems = new ArrayList<>();
-
-    if (accountCache.getOrNull(extId.accountId()) == null) {
-      addError(
-          String.format(
-              "External ID '%s' belongs to account that doesn't exist: %s",
-              extId.key().get(), extId.accountId().get()),
-          problems);
-    }
-
-    if (extId.email() != null && !validator.isValid(extId.email())) {
-      addError(
-          String.format(
-              "External ID '%s' has an invalid email: %s", extId.key().get(), extId.email()),
-          problems);
-    }
-
-    if (extId.password() != null && extId.isScheme(SCHEME_USERNAME)) {
-      try {
-        HashedPassword.decode(extId.password());
-      } catch (DecoderException e) {
-        addError(
-            String.format(
-                "External ID '%s' has an invalid password: %s", extId.key().get(), e.getMessage()),
-            problems);
-      }
-    }
-
-    return problems;
-  }
-
-  private static void addError(String error, List<ConsistencyProblemInfo> problems) {
-    problems.add(new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, error));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
deleted file mode 100644
index db37147..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
+++ /dev/null
@@ -1,940 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account.externalids;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.account.externalids.ExternalIdReader.MAX_NOTE_SZ;
-import static com.google.gerrit.server.account.externalids.ExternalIdReader.readNoteMap;
-import static com.google.gerrit.server.account.externalids.ExternalIdReader.readRevision;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toSet;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
-
-import com.github.rholder.retry.RetryException;
-import com.github.rholder.retry.Retryer;
-import com.github.rholder.retry.RetryerBuilder;
-import com.github.rholder.retry.StopStrategies;
-import com.github.rholder.retry.WaitStrategies;
-import com.google.auto.value.AutoValue;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Streams;
-import com.google.common.util.concurrent.Runnables;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.metrics.Counter0;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LockFailureException;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import java.util.stream.Stream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * Updates externalIds in ReviewDb and NoteDb.
- *
- * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
- * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
- * is a git config file that contains an external ID. It has exactly one externalId subsection with
- * an accountId and optionally email and password:
- *
- * <pre>
- * [externalId "username:jdoe"]
- *   accountId = 1003407
- *   email = jdoe@example.com
- *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
- * </pre>
- *
- * For NoteDb each method call results in one commit on refs/meta/external-ids branch.
- *
- * <p>On updating external IDs this class takes care to evict affected accounts from the account
- * cache and thus triggers reindex for them.
- */
-public class ExternalIdsUpdate {
-  private static final String COMMIT_MSG = "Update external IDs";
-
-  /**
-   * Factory to create an ExternalIdsUpdate instance for updating external IDs by the Gerrit server.
-   *
-   * <p>The Gerrit server identity will be used as author and committer for all commits that update
-   * the external IDs.
-   */
-  @Singleton
-  public static class Server {
-    private final GitRepositoryManager repoManager;
-    private final AccountCache accountCache;
-    private final AllUsersName allUsersName;
-    private final MetricMaker metricMaker;
-    private final ExternalIds externalIds;
-    private final ExternalIdCache externalIdCache;
-    private final Provider<PersonIdent> serverIdent;
-    private final GitReferenceUpdated gitRefUpdated;
-
-    @Inject
-    public Server(
-        GitRepositoryManager repoManager,
-        AccountCache accountCache,
-        AllUsersName allUsersName,
-        MetricMaker metricMaker,
-        ExternalIds externalIds,
-        ExternalIdCache externalIdCache,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent,
-        GitReferenceUpdated gitRefUpdated) {
-      this.repoManager = repoManager;
-      this.accountCache = accountCache;
-      this.allUsersName = allUsersName;
-      this.metricMaker = metricMaker;
-      this.externalIds = externalIds;
-      this.externalIdCache = externalIdCache;
-      this.serverIdent = serverIdent;
-      this.gitRefUpdated = gitRefUpdated;
-    }
-
-    public ExternalIdsUpdate create() {
-      PersonIdent i = serverIdent.get();
-      return new ExternalIdsUpdate(
-          repoManager,
-          accountCache,
-          allUsersName,
-          metricMaker,
-          externalIds,
-          externalIdCache,
-          i,
-          i,
-          null,
-          gitRefUpdated);
-    }
-  }
-
-  /**
-   * Factory to create an ExternalIdsUpdate instance for updating external IDs by the Gerrit server.
-   *
-   * <p>Using this class no reindex will be performed for the affected accounts and they will also
-   * not be evicted from the account cache.
-   *
-   * <p>The Gerrit server identity will be used as author and committer for all commits that update
-   * the external IDs.
-   */
-  @Singleton
-  public static class ServerNoReindex {
-    private final GitRepositoryManager repoManager;
-    private final AllUsersName allUsersName;
-    private final MetricMaker metricMaker;
-    private final ExternalIds externalIds;
-    private final ExternalIdCache externalIdCache;
-    private final Provider<PersonIdent> serverIdent;
-    private final GitReferenceUpdated gitRefUpdated;
-
-    @Inject
-    public ServerNoReindex(
-        GitRepositoryManager repoManager,
-        AllUsersName allUsersName,
-        MetricMaker metricMaker,
-        ExternalIds externalIds,
-        ExternalIdCache externalIdCache,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent,
-        GitReferenceUpdated gitRefUpdated) {
-      this.repoManager = repoManager;
-      this.allUsersName = allUsersName;
-      this.metricMaker = metricMaker;
-      this.externalIds = externalIds;
-      this.externalIdCache = externalIdCache;
-      this.serverIdent = serverIdent;
-      this.gitRefUpdated = gitRefUpdated;
-    }
-
-    public ExternalIdsUpdate create() {
-      PersonIdent i = serverIdent.get();
-      return new ExternalIdsUpdate(
-          repoManager,
-          null,
-          allUsersName,
-          metricMaker,
-          externalIds,
-          externalIdCache,
-          i,
-          i,
-          null,
-          gitRefUpdated);
-    }
-  }
-
-  /**
-   * Factory to create an ExternalIdsUpdate instance for updating external IDs by the current user.
-   *
-   * <p>The identity of the current user will be used as author for all commits that update the
-   * external IDs. The Gerrit server identity will be used as committer.
-   */
-  @Singleton
-  public static class User {
-    private final GitRepositoryManager repoManager;
-    private final AccountCache accountCache;
-    private final AllUsersName allUsersName;
-    private final MetricMaker metricMaker;
-    private final ExternalIds externalIds;
-    private final ExternalIdCache externalIdCache;
-    private final Provider<PersonIdent> serverIdent;
-    private final Provider<IdentifiedUser> identifiedUser;
-    private final GitReferenceUpdated gitRefUpdated;
-
-    @Inject
-    public User(
-        GitRepositoryManager repoManager,
-        AccountCache accountCache,
-        AllUsersName allUsersName,
-        MetricMaker metricMaker,
-        ExternalIds externalIds,
-        ExternalIdCache externalIdCache,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent,
-        Provider<IdentifiedUser> identifiedUser,
-        GitReferenceUpdated gitRefUpdated) {
-      this.repoManager = repoManager;
-      this.accountCache = accountCache;
-      this.allUsersName = allUsersName;
-      this.metricMaker = metricMaker;
-      this.externalIds = externalIds;
-      this.externalIdCache = externalIdCache;
-      this.serverIdent = serverIdent;
-      this.identifiedUser = identifiedUser;
-      this.gitRefUpdated = gitRefUpdated;
-    }
-
-    public ExternalIdsUpdate create() {
-      IdentifiedUser user = identifiedUser.get();
-      PersonIdent i = serverIdent.get();
-      return new ExternalIdsUpdate(
-          repoManager,
-          accountCache,
-          allUsersName,
-          metricMaker,
-          externalIds,
-          externalIdCache,
-          createPersonIdent(i, user),
-          i,
-          user,
-          gitRefUpdated);
-    }
-
-    private PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
-      return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
-    }
-  }
-
-  @VisibleForTesting
-  public static RetryerBuilder<RefsMetaExternalIdsUpdate> retryerBuilder() {
-    return RetryerBuilder.<RefsMetaExternalIdsUpdate>newBuilder()
-        .retryIfException(e -> e instanceof LockFailureException)
-        .withWaitStrategy(
-            WaitStrategies.join(
-                WaitStrategies.exponentialWait(2, TimeUnit.SECONDS),
-                WaitStrategies.randomWait(50, TimeUnit.MILLISECONDS)))
-        .withStopStrategy(StopStrategies.stopAfterDelay(10, TimeUnit.SECONDS));
-  }
-
-  private static final Retryer<RefsMetaExternalIdsUpdate> RETRYER = retryerBuilder().build();
-
-  private final GitRepositoryManager repoManager;
-  @Nullable private final AccountCache accountCache;
-  private final AllUsersName allUsersName;
-  private final ExternalIds externalIds;
-  private final ExternalIdCache externalIdCache;
-  private final PersonIdent committerIdent;
-  private final PersonIdent authorIdent;
-  @Nullable private final IdentifiedUser currentUser;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final Runnable afterReadRevision;
-  private final Retryer<RefsMetaExternalIdsUpdate> retryer;
-  private final Counter0 updateCount;
-
-  private ExternalIdsUpdate(
-      GitRepositoryManager repoManager,
-      @Nullable AccountCache accountCache,
-      AllUsersName allUsersName,
-      MetricMaker metricMaker,
-      ExternalIds externalIds,
-      ExternalIdCache externalIdCache,
-      PersonIdent committerIdent,
-      PersonIdent authorIdent,
-      @Nullable IdentifiedUser currentUser,
-      GitReferenceUpdated gitRefUpdated) {
-    this(
-        repoManager,
-        accountCache,
-        allUsersName,
-        metricMaker,
-        externalIds,
-        externalIdCache,
-        committerIdent,
-        authorIdent,
-        currentUser,
-        gitRefUpdated,
-        Runnables.doNothing(),
-        RETRYER);
-  }
-
-  @VisibleForTesting
-  public ExternalIdsUpdate(
-      GitRepositoryManager repoManager,
-      @Nullable AccountCache accountCache,
-      AllUsersName allUsersName,
-      MetricMaker metricMaker,
-      ExternalIds externalIds,
-      ExternalIdCache externalIdCache,
-      PersonIdent committerIdent,
-      PersonIdent authorIdent,
-      @Nullable IdentifiedUser currentUser,
-      GitReferenceUpdated gitRefUpdated,
-      Runnable afterReadRevision,
-      Retryer<RefsMetaExternalIdsUpdate> retryer) {
-    this.repoManager = checkNotNull(repoManager, "repoManager");
-    this.accountCache = accountCache;
-    this.allUsersName = checkNotNull(allUsersName, "allUsersName");
-    this.committerIdent = checkNotNull(committerIdent, "committerIdent");
-    this.externalIds = checkNotNull(externalIds, "externalIds");
-    this.externalIdCache = checkNotNull(externalIdCache, "externalIdCache");
-    this.authorIdent = checkNotNull(authorIdent, "authorIdent");
-    this.currentUser = currentUser;
-    this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
-    this.afterReadRevision = checkNotNull(afterReadRevision, "afterReadRevision");
-    this.retryer = checkNotNull(retryer, "retryer");
-    this.updateCount =
-        metricMaker.newCounter(
-            "notedb/external_id_update_count",
-            new Description("Total number of external ID updates.").setRate().setUnit("updates"));
-  }
-
-  /**
-   * Inserts a new external ID.
-   *
-   * <p>If the external ID already exists, the insert fails with {@link OrmDuplicateKeyException}.
-   */
-  public void insert(ExternalId extId) throws IOException, ConfigInvalidException, OrmException {
-    insert(Collections.singleton(extId));
-  }
-
-  /**
-   * Inserts new external IDs.
-   *
-   * <p>If any of the external ID already exists, the insert fails with {@link
-   * OrmDuplicateKeyException}.
-   */
-  public void insert(Collection<ExternalId> extIds)
-      throws IOException, ConfigInvalidException, OrmException {
-    RefsMetaExternalIdsUpdate u =
-        updateNoteMap(
-            o -> {
-              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
-              for (ExternalId extId : extIds) {
-                ExternalId insertedExtId = insert(o.rw(), o.ins(), o.noteMap(), extId);
-                updatedExtIds.onUpdate(insertedExtId);
-              }
-              return updatedExtIds;
-            });
-    externalIdCache.onCreate(u.oldRev(), u.newRev(), u.updatedExtIds().getUpdated());
-    evictAccounts(u);
-  }
-
-  /**
-   * Inserts or updates an external ID.
-   *
-   * <p>If the external ID already exists, it is overwritten, otherwise it is inserted.
-   */
-  public void upsert(ExternalId extId) throws IOException, ConfigInvalidException, OrmException {
-    upsert(Collections.singleton(extId));
-  }
-
-  /**
-   * Inserts or updates external IDs.
-   *
-   * <p>If any of the external IDs already exists, it is overwritten. New external IDs are inserted.
-   */
-  public void upsert(Collection<ExternalId> extIds)
-      throws IOException, ConfigInvalidException, OrmException {
-    RefsMetaExternalIdsUpdate u =
-        updateNoteMap(
-            o -> {
-              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
-              for (ExternalId extId : extIds) {
-                ExternalId updatedExtId = upsert(o.rw(), o.ins(), o.noteMap(), extId);
-                updatedExtIds.onUpdate(updatedExtId);
-              }
-              return updatedExtIds;
-            });
-    externalIdCache.onUpdate(u.oldRev(), u.newRev(), u.updatedExtIds().getUpdated());
-    evictAccounts(u);
-  }
-
-  /**
-   * Deletes an external ID.
-   *
-   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
-   *     key, but otherwise doesn't match the specified external ID.
-   */
-  public void delete(ExternalId extId) throws IOException, ConfigInvalidException, OrmException {
-    delete(Collections.singleton(extId));
-  }
-
-  /**
-   * Deletes external IDs.
-   *
-   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
-   *     key as any of the external IDs that should be deleted, but otherwise doesn't match the that
-   *     external ID.
-   */
-  public void delete(Collection<ExternalId> extIds)
-      throws IOException, ConfigInvalidException, OrmException {
-    RefsMetaExternalIdsUpdate u =
-        updateNoteMap(
-            o -> {
-              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
-              for (ExternalId extId : extIds) {
-                ExternalId removedExtId = remove(o.rw(), o.noteMap(), extId);
-                updatedExtIds.onRemove(removedExtId);
-              }
-              return updatedExtIds;
-            });
-    externalIdCache.onRemove(u.oldRev(), u.newRev(), u.updatedExtIds().getRemoved());
-    evictAccounts(u);
-  }
-
-  /**
-   * Delete an external ID by key.
-   *
-   * @throws IllegalStateException is thrown if the external ID does not belong to the specified
-   *     account.
-   */
-  public void delete(Account.Id accountId, ExternalId.Key extIdKey)
-      throws IOException, ConfigInvalidException, OrmException {
-    delete(accountId, Collections.singleton(extIdKey));
-  }
-
-  /**
-   * Delete external IDs by external ID key.
-   *
-   * @throws IllegalStateException is thrown if any of the external IDs does not belong to the
-   *     specified account.
-   */
-  public void delete(Account.Id accountId, Collection<ExternalId.Key> extIdKeys)
-      throws IOException, ConfigInvalidException, OrmException {
-    RefsMetaExternalIdsUpdate u =
-        updateNoteMap(
-            o -> {
-              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
-              for (ExternalId.Key extIdKey : extIdKeys) {
-                ExternalId removedExtId = remove(o.rw(), o.noteMap(), extIdKey, accountId);
-                updatedExtIds.onRemove(removedExtId);
-              }
-              return updatedExtIds;
-            });
-    externalIdCache.onRemove(u.oldRev(), u.newRev(), u.updatedExtIds().getRemoved());
-    evictAccount(accountId);
-  }
-
-  /**
-   * Delete external IDs by external ID key.
-   *
-   * <p>The external IDs are deleted regardless of which account they belong to.
-   */
-  public void deleteByKeys(Collection<ExternalId.Key> extIdKeys)
-      throws IOException, ConfigInvalidException, OrmException {
-    RefsMetaExternalIdsUpdate u =
-        updateNoteMap(
-            o -> {
-              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
-              for (ExternalId.Key extIdKey : extIdKeys) {
-                ExternalId extId = remove(o.rw(), o.noteMap(), extIdKey, null);
-                updatedExtIds.onRemove(extId);
-              }
-              return updatedExtIds;
-            });
-    externalIdCache.onRemove(u.oldRev(), u.newRev(), u.updatedExtIds().getRemoved());
-    evictAccounts(u);
-  }
-
-  /** Deletes all external IDs of the specified account. */
-  public void deleteAll(Account.Id accountId)
-      throws IOException, ConfigInvalidException, OrmException {
-    delete(externalIds.byAccount(accountId));
-  }
-
-  /**
-   * Replaces external IDs for an account by external ID keys.
-   *
-   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
-   * external ID key is specified for deletion and an external ID with the same key is specified to
-   * be added, the old external ID with that key is deleted first and then the new external ID is
-   * added (so the external ID for that key is replaced).
-   *
-   * @throws IllegalStateException is thrown if any of the specified external IDs does not belong to
-   *     the specified account.
-   */
-  public void replace(
-      Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
-      throws IOException, ConfigInvalidException, OrmException {
-    checkSameAccount(toAdd, accountId);
-
-    RefsMetaExternalIdsUpdate u =
-        updateNoteMap(
-            o -> {
-              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
-              for (ExternalId.Key extIdKey : toDelete) {
-                ExternalId removedExtId = remove(o.rw(), o.noteMap(), extIdKey, accountId);
-                updatedExtIds.onRemove(removedExtId);
-              }
-
-              for (ExternalId extId : toAdd) {
-                ExternalId insertedExtId = insert(o.rw(), o.ins(), o.noteMap(), extId);
-                updatedExtIds.onUpdate(insertedExtId);
-              }
-              return updatedExtIds;
-            });
-    externalIdCache.onReplace(
-        u.oldRev(),
-        u.newRev(),
-        accountId,
-        u.updatedExtIds().getRemoved(),
-        u.updatedExtIds().getUpdated());
-    evictAccount(accountId);
-  }
-
-  /**
-   * Replaces external IDs for an account by external ID keys.
-   *
-   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
-   * external ID key is specified for deletion and an external ID with the same key is specified to
-   * be added, the old external ID with that key is deleted first and then the new external ID is
-   * added (so the external ID for that key is replaced).
-   *
-   * <p>The external IDs are replaced regardless of which account they belong to.
-   */
-  public void replaceByKeys(Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
-      throws IOException, ConfigInvalidException, OrmException {
-    RefsMetaExternalIdsUpdate u =
-        updateNoteMap(
-            o -> {
-              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
-              for (ExternalId.Key extIdKey : toDelete) {
-                ExternalId removedExtId = remove(o.rw(), o.noteMap(), extIdKey, null);
-                updatedExtIds.onRemove(removedExtId);
-              }
-
-              for (ExternalId extId : toAdd) {
-                ExternalId insertedExtId = insert(o.rw(), o.ins(), o.noteMap(), extId);
-                updatedExtIds.onUpdate(insertedExtId);
-              }
-              return updatedExtIds;
-            });
-    externalIdCache.onReplace(
-        u.oldRev(), u.newRev(), u.updatedExtIds().getRemoved(), u.updatedExtIds().getUpdated());
-    evictAccounts(u);
-  }
-
-  /**
-   * Replaces an external ID.
-   *
-   * @throws IllegalStateException is thrown if the specified external IDs belong to different
-   *     accounts.
-   */
-  public void replace(ExternalId toDelete, ExternalId toAdd)
-      throws IOException, ConfigInvalidException, OrmException {
-    replace(Collections.singleton(toDelete), Collections.singleton(toAdd));
-  }
-
-  /**
-   * Replaces external IDs.
-   *
-   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
-   * external ID is specified for deletion and an external ID with the same key is specified to be
-   * added, the old external ID with that key is deleted first and then the new external ID is added
-   * (so the external ID for that key is replaced).
-   *
-   * @throws IllegalStateException is thrown if the specified external IDs belong to different
-   *     accounts.
-   */
-  public void replace(Collection<ExternalId> toDelete, Collection<ExternalId> toAdd)
-      throws IOException, ConfigInvalidException, OrmException {
-    Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd));
-    if (accountId == null) {
-      // toDelete and toAdd are empty -> nothing to do
-      return;
-    }
-
-    replace(accountId, toDelete.stream().map(e -> e.key()).collect(toSet()), toAdd);
-  }
-
-  /**
-   * Checks that all specified external IDs belong to the same account.
-   *
-   * @return the ID of the account to which all specified external IDs belong.
-   */
-  public static Account.Id checkSameAccount(Iterable<ExternalId> extIds) {
-    return checkSameAccount(extIds, null);
-  }
-
-  /**
-   * Checks that all specified external IDs belong to specified account. If no account is specified
-   * it is checked that all specified external IDs belong to the same account.
-   *
-   * @return the ID of the account to which all specified external IDs belong.
-   */
-  public static Account.Id checkSameAccount(
-      Iterable<ExternalId> extIds, @Nullable Account.Id accountId) {
-    for (ExternalId extId : extIds) {
-      if (accountId == null) {
-        accountId = extId.accountId();
-        continue;
-      }
-      checkState(
-          accountId.equals(extId.accountId()),
-          "external id %s belongs to account %s, expected account %s",
-          extId.key().get(),
-          extId.accountId().get(),
-          accountId.get());
-    }
-    return accountId;
-  }
-
-  /**
-   * Inserts a new external ID and sets it in the note map.
-   *
-   * <p>If the external ID already exists, the insert fails with {@link OrmDuplicateKeyException}.
-   */
-  public static ExternalId insert(RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
-      throws OrmDuplicateKeyException, ConfigInvalidException, IOException {
-    if (noteMap.contains(extId.key().sha1())) {
-      throw new OrmDuplicateKeyException(
-          String.format("external id %s already exists", extId.key().get()));
-    }
-    return upsert(rw, ins, noteMap, extId);
-  }
-
-  /**
-   * Insert or updates an new external ID and sets it in the note map.
-   *
-   * <p>If the external ID already exists it is overwritten.
-   */
-  public static ExternalId upsert(RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
-      throws IOException, ConfigInvalidException {
-    ObjectId noteId = extId.key().sha1();
-    Config c = new Config();
-    if (noteMap.contains(extId.key().sha1())) {
-      byte[] raw =
-          rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-      try {
-        c.fromText(new String(raw, UTF_8));
-      } catch (ConfigInvalidException e) {
-        throw new ConfigInvalidException(
-            String.format("Invalid external id config for note %s: %s", noteId, e.getMessage()));
-      }
-    }
-    extId.writeToConfig(c);
-    byte[] raw = c.toText().getBytes(UTF_8);
-    ObjectId noteData = ins.insert(OBJ_BLOB, raw);
-    noteMap.set(noteId, noteData);
-    return ExternalId.create(extId, noteData);
-  }
-
-  /**
-   * Removes an external ID from the note map.
-   *
-   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
-   *     key, but otherwise doesn't match the specified external ID.
-   */
-  public static ExternalId remove(RevWalk rw, NoteMap noteMap, ExternalId extId)
-      throws IOException, ConfigInvalidException {
-    ObjectId noteId = extId.key().sha1();
-    if (!noteMap.contains(noteId)) {
-      return null;
-    }
-
-    ObjectId noteData = noteMap.get(noteId);
-    byte[] raw = rw.getObjectReader().open(noteData, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-    ExternalId actualExtId = ExternalId.parse(noteId.name(), raw, noteData);
-    checkState(
-        extId.equals(actualExtId),
-        "external id %s should be removed, but it's not matching the actual external id %s",
-        extId.toString(),
-        actualExtId.toString());
-    noteMap.remove(noteId);
-    return actualExtId;
-  }
-
-  /**
-   * Removes an external ID from the note map by external ID key.
-   *
-   * @throws IllegalStateException is thrown if an expected account ID is provided and an external
-   *     ID with the specified key exists, but belongs to another account.
-   * @return the external ID that was removed, {@code null} if no external ID with the specified key
-   *     exists
-   */
-  private static ExternalId remove(
-      RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
-      throws IOException, ConfigInvalidException {
-    ObjectId noteId = extIdKey.sha1();
-    if (!noteMap.contains(noteId)) {
-      return null;
-    }
-
-    ObjectId noteData = noteMap.get(noteId);
-    byte[] raw = rw.getObjectReader().open(noteData, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-    ExternalId extId = ExternalId.parse(noteId.name(), raw, noteData);
-    if (expectedAccountId != null) {
-      checkState(
-          expectedAccountId.equals(extId.accountId()),
-          "external id %s should be removed for account %s,"
-              + " but external id belongs to account %s",
-          extIdKey.get(),
-          expectedAccountId.get(),
-          extId.accountId().get());
-    }
-    noteMap.remove(noteId);
-    return extId;
-  }
-
-  private RefsMetaExternalIdsUpdate updateNoteMap(ExternalIdUpdater updater)
-      throws IOException, ConfigInvalidException, OrmException {
-    try {
-      return retryer.call(
-          () -> {
-            try (Repository repo = repoManager.openRepository(allUsersName);
-                ObjectInserter ins = repo.newObjectInserter()) {
-              ObjectId rev = readRevision(repo);
-
-              afterReadRevision.run();
-
-              try (RevWalk rw = new RevWalk(repo)) {
-                NoteMap noteMap = readNoteMap(rw, rev);
-                UpdatedExternalIds updatedExtIds =
-                    updater.update(OpenRepo.create(repo, rw, ins, noteMap));
-
-                return commit(repo, rw, ins, rev, noteMap, updatedExtIds);
-              }
-            }
-          });
-    } catch (ExecutionException | RetryException e) {
-      if (e.getCause() != null) {
-        Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
-        Throwables.throwIfInstanceOf(e.getCause(), ConfigInvalidException.class);
-        Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
-      }
-      throw new OrmException(e);
-    }
-  }
-
-  private RefsMetaExternalIdsUpdate commit(
-      Repository repo,
-      RevWalk rw,
-      ObjectInserter ins,
-      ObjectId rev,
-      NoteMap noteMap,
-      UpdatedExternalIds updatedExtIds)
-      throws IOException {
-    ObjectId newRev =
-        commit(
-            allUsersName,
-            repo,
-            rw,
-            ins,
-            rev,
-            noteMap,
-            COMMIT_MSG,
-            committerIdent,
-            authorIdent,
-            currentUser,
-            gitRefUpdated);
-    updateCount.increment();
-    return RefsMetaExternalIdsUpdate.create(rev, newRev, updatedExtIds);
-  }
-
-  /** Commits updates to the external IDs. */
-  public static ObjectId commit(
-      Project.NameKey project,
-      Repository repo,
-      RevWalk rw,
-      ObjectInserter ins,
-      ObjectId rev,
-      NoteMap noteMap,
-      String commitMessage,
-      PersonIdent committerIdent,
-      PersonIdent authorIdent,
-      @Nullable IdentifiedUser user,
-      GitReferenceUpdated gitRefUpdated)
-      throws IOException {
-    CommitBuilder cb = new CommitBuilder();
-    cb.setMessage(commitMessage);
-    cb.setTreeId(noteMap.writeTree(ins));
-    cb.setAuthor(authorIdent);
-    cb.setCommitter(committerIdent);
-    if (!rev.equals(ObjectId.zeroId())) {
-      cb.setParentId(rev);
-    } else {
-      cb.setParentIds(); // Ref is currently nonexistent, commit has no parents.
-    }
-    if (cb.getTreeId() == null) {
-      if (rev.equals(ObjectId.zeroId())) {
-        cb.setTreeId(emptyTree(ins)); // No parent, assume empty tree.
-      } else {
-        RevCommit p = rw.parseCommit(rev);
-        cb.setTreeId(p.getTree()); // Copy tree from parent.
-      }
-    }
-    ObjectId commitId = ins.insert(cb);
-    ins.flush();
-
-    RefUpdate u = repo.updateRef(RefNames.REFS_EXTERNAL_IDS);
-    u.setRefLogIdent(committerIdent);
-    u.setRefLogMessage("Update external IDs", false);
-    u.setExpectedOldObjectId(rev);
-    u.setNewObjectId(commitId);
-    RefUpdate.Result res = u.update();
-    switch (res) {
-      case NEW:
-      case FAST_FORWARD:
-      case NO_CHANGE:
-      case RENAMED:
-      case FORCED:
-        break;
-      case LOCK_FAILURE:
-        throw new LockFailureException("Updating external IDs failed with " + res, u);
-      case IO_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      default:
-        throw new IOException("Updating external IDs failed with " + res);
-    }
-    gitRefUpdated.fire(project, u, user != null ? user.getAccount() : null);
-    return rw.parseCommit(commitId);
-  }
-
-  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
-    return ins.insert(OBJ_TREE, new byte[] {});
-  }
-
-  private void evictAccount(Account.Id accountId) throws IOException {
-    if (accountCache != null) {
-      accountCache.evict(accountId);
-    }
-  }
-
-  private void evictAccounts(RefsMetaExternalIdsUpdate u) throws IOException {
-    if (accountCache != null) {
-      for (Account.Id id : u.updatedExtIds().all().map(ExternalId::accountId).collect(toSet())) {
-        accountCache.evict(id);
-      }
-    }
-  }
-
-  @FunctionalInterface
-  private static interface ExternalIdUpdater {
-    UpdatedExternalIds update(OpenRepo openRepo)
-        throws IOException, ConfigInvalidException, OrmException;
-  }
-
-  @AutoValue
-  abstract static class OpenRepo {
-    static OpenRepo create(Repository repo, RevWalk rw, ObjectInserter ins, NoteMap noteMap) {
-      return new AutoValue_ExternalIdsUpdate_OpenRepo(repo, rw, ins, noteMap);
-    }
-
-    abstract Repository repo();
-
-    abstract RevWalk rw();
-
-    abstract ObjectInserter ins();
-
-    abstract NoteMap noteMap();
-  }
-
-  @VisibleForTesting
-  @AutoValue
-  public abstract static class RefsMetaExternalIdsUpdate {
-    static RefsMetaExternalIdsUpdate create(
-        ObjectId oldRev, ObjectId newRev, UpdatedExternalIds updatedExtIds) {
-      return new AutoValue_ExternalIdsUpdate_RefsMetaExternalIdsUpdate(
-          oldRev, newRev, updatedExtIds);
-    }
-
-    abstract ObjectId oldRev();
-
-    abstract ObjectId newRev();
-
-    abstract UpdatedExternalIds updatedExtIds();
-  }
-
-  public static class UpdatedExternalIds {
-    private Set<ExternalId> updated = new HashSet<>();
-    private Set<ExternalId> removed = new HashSet<>();
-
-    public void onUpdate(ExternalId extId) {
-      if (extId != null) {
-        updated.add(extId);
-      }
-    }
-
-    public void onRemove(ExternalId extId) {
-      if (extId != null) {
-        removed.add(extId);
-      }
-    }
-
-    public Set<ExternalId> getUpdated() {
-      return ImmutableSet.copyOf(updated);
-    }
-
-    public Set<ExternalId> getRemoved() {
-      return ImmutableSet.copyOf(removed);
-    }
-
-    public Stream<ExternalId> all() {
-      return Streams.concat(removed.stream(), updated.stream());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/Module.java
deleted file mode 100644
index 6214129..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/Module.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api;
-
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.inject.AbstractModule;
-
-public class Module extends AbstractModule {
-  @Override
-  protected void configure() {
-    bind(GerritApi.class).to(GerritApiImpl.class);
-
-    install(new com.google.gerrit.server.api.accounts.Module());
-    install(new com.google.gerrit.server.api.changes.Module());
-    install(new com.google.gerrit.server.api.config.Module());
-    install(new com.google.gerrit.server.api.groups.Module());
-    install(new com.google.gerrit.server.api.projects.Module());
-  }
-}
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
deleted file mode 100644
index f8539d9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ /dev/null
@@ -1,516 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.accounts;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-import static javax.servlet.http.HttpServletResponse.SC_OK;
-
-import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.extensions.api.accounts.AccountApi;
-import com.google.gerrit.extensions.api.accounts.EmailInput;
-import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
-import com.google.gerrit.extensions.api.changes.StarsInput;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.EditPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.ProjectWatchInfo;
-import com.google.gerrit.extensions.common.AccountExternalIdInfo;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.AgreementInfo;
-import com.google.gerrit.extensions.common.AgreementInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.EmailInfo;
-import com.google.gerrit.extensions.common.GpgKeyInfo;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.common.SshKeyInfo;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.account.AddSshKey;
-import com.google.gerrit.server.account.CreateEmail;
-import com.google.gerrit.server.account.DeleteActive;
-import com.google.gerrit.server.account.DeleteEmail;
-import com.google.gerrit.server.account.DeleteExternalIds;
-import com.google.gerrit.server.account.DeleteSshKey;
-import com.google.gerrit.server.account.DeleteWatchedProjects;
-import com.google.gerrit.server.account.GetActive;
-import com.google.gerrit.server.account.GetAgreements;
-import com.google.gerrit.server.account.GetAvatar;
-import com.google.gerrit.server.account.GetDiffPreferences;
-import com.google.gerrit.server.account.GetEditPreferences;
-import com.google.gerrit.server.account.GetEmails;
-import com.google.gerrit.server.account.GetExternalIds;
-import com.google.gerrit.server.account.GetGroups;
-import com.google.gerrit.server.account.GetPreferences;
-import com.google.gerrit.server.account.GetSshKeys;
-import com.google.gerrit.server.account.GetWatchedProjects;
-import com.google.gerrit.server.account.Index;
-import com.google.gerrit.server.account.PostWatchedProjects;
-import com.google.gerrit.server.account.PutActive;
-import com.google.gerrit.server.account.PutAgreement;
-import com.google.gerrit.server.account.PutStatus;
-import com.google.gerrit.server.account.SetDiffPreferences;
-import com.google.gerrit.server.account.SetEditPreferences;
-import com.google.gerrit.server.account.SetPreferences;
-import com.google.gerrit.server.account.SshKeys;
-import com.google.gerrit.server.account.StarredChanges;
-import com.google.gerrit.server.account.Stars;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.ChangesCollection;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.List;
-import java.util.Map;
-import java.util.SortedSet;
-
-public class AccountApiImpl implements AccountApi {
-  interface Factory {
-    AccountApiImpl create(AccountResource account);
-  }
-
-  private final AccountResource account;
-  private final ChangesCollection changes;
-  private final AccountLoader.Factory accountLoaderFactory;
-  private final GetAvatar getAvatar;
-  private final GetPreferences getPreferences;
-  private final SetPreferences setPreferences;
-  private final GetDiffPreferences getDiffPreferences;
-  private final SetDiffPreferences setDiffPreferences;
-  private final GetEditPreferences getEditPreferences;
-  private final SetEditPreferences setEditPreferences;
-  private final GetWatchedProjects getWatchedProjects;
-  private final PostWatchedProjects postWatchedProjects;
-  private final DeleteWatchedProjects deleteWatchedProjects;
-  private final StarredChanges.Create starredChangesCreate;
-  private final StarredChanges.Delete starredChangesDelete;
-  private final Stars stars;
-  private final Stars.Get starsGet;
-  private final Stars.Post starsPost;
-  private final GetEmails getEmails;
-  private final CreateEmail.Factory createEmailFactory;
-  private final DeleteEmail deleteEmail;
-  private final GpgApiAdapter gpgApiAdapter;
-  private final GetSshKeys getSshKeys;
-  private final AddSshKey addSshKey;
-  private final DeleteSshKey deleteSshKey;
-  private final SshKeys sshKeys;
-  private final GetAgreements getAgreements;
-  private final PutAgreement putAgreement;
-  private final GetActive getActive;
-  private final PutActive putActive;
-  private final DeleteActive deleteActive;
-  private final Index index;
-  private final GetExternalIds getExternalIds;
-  private final DeleteExternalIds deleteExternalIds;
-  private final PutStatus putStatus;
-  private final GetGroups getGroups;
-
-  @Inject
-  AccountApiImpl(
-      AccountLoader.Factory ailf,
-      ChangesCollection changes,
-      GetAvatar getAvatar,
-      GetPreferences getPreferences,
-      SetPreferences setPreferences,
-      GetDiffPreferences getDiffPreferences,
-      SetDiffPreferences setDiffPreferences,
-      GetEditPreferences getEditPreferences,
-      SetEditPreferences setEditPreferences,
-      GetWatchedProjects getWatchedProjects,
-      PostWatchedProjects postWatchedProjects,
-      DeleteWatchedProjects deleteWatchedProjects,
-      StarredChanges.Create starredChangesCreate,
-      StarredChanges.Delete starredChangesDelete,
-      Stars stars,
-      Stars.Get starsGet,
-      Stars.Post starsPost,
-      GetEmails getEmails,
-      CreateEmail.Factory createEmailFactory,
-      DeleteEmail deleteEmail,
-      GpgApiAdapter gpgApiAdapter,
-      GetSshKeys getSshKeys,
-      AddSshKey addSshKey,
-      DeleteSshKey deleteSshKey,
-      SshKeys sshKeys,
-      GetAgreements getAgreements,
-      PutAgreement putAgreement,
-      GetActive getActive,
-      PutActive putActive,
-      DeleteActive deleteActive,
-      Index index,
-      GetExternalIds getExternalIds,
-      DeleteExternalIds deleteExternalIds,
-      PutStatus putStatus,
-      GetGroups getGroups,
-      @Assisted AccountResource account) {
-    this.account = account;
-    this.accountLoaderFactory = ailf;
-    this.changes = changes;
-    this.getAvatar = getAvatar;
-    this.getPreferences = getPreferences;
-    this.setPreferences = setPreferences;
-    this.getDiffPreferences = getDiffPreferences;
-    this.setDiffPreferences = setDiffPreferences;
-    this.getEditPreferences = getEditPreferences;
-    this.setEditPreferences = setEditPreferences;
-    this.getWatchedProjects = getWatchedProjects;
-    this.postWatchedProjects = postWatchedProjects;
-    this.deleteWatchedProjects = deleteWatchedProjects;
-    this.starredChangesCreate = starredChangesCreate;
-    this.starredChangesDelete = starredChangesDelete;
-    this.stars = stars;
-    this.starsGet = starsGet;
-    this.starsPost = starsPost;
-    this.getEmails = getEmails;
-    this.createEmailFactory = createEmailFactory;
-    this.deleteEmail = deleteEmail;
-    this.getSshKeys = getSshKeys;
-    this.addSshKey = addSshKey;
-    this.deleteSshKey = deleteSshKey;
-    this.sshKeys = sshKeys;
-    this.gpgApiAdapter = gpgApiAdapter;
-    this.getAgreements = getAgreements;
-    this.putAgreement = putAgreement;
-    this.getActive = getActive;
-    this.putActive = putActive;
-    this.deleteActive = deleteActive;
-    this.index = index;
-    this.getExternalIds = getExternalIds;
-    this.deleteExternalIds = deleteExternalIds;
-    this.putStatus = putStatus;
-    this.getGroups = getGroups;
-  }
-
-  @Override
-  public com.google.gerrit.extensions.common.AccountInfo get() throws RestApiException {
-    AccountLoader accountLoader = accountLoaderFactory.create(true);
-    try {
-      AccountInfo ai = accountLoader.get(account.getUser().getAccountId());
-      accountLoader.fill();
-      return ai;
-    } catch (Exception e) {
-      throw asRestApiException("Cannot parse change", e);
-    }
-  }
-
-  @Override
-  public boolean getActive() throws RestApiException {
-    Response<String> result = getActive.apply(account);
-    return result.statusCode() == SC_OK && result.value().equals("ok");
-  }
-
-  @Override
-  public void setActive(boolean active) throws RestApiException {
-    try {
-      if (active) {
-        putActive.apply(account, new PutActive.Input());
-      } else {
-        deleteActive.apply(account, new DeleteActive.Input());
-      }
-    } catch (Exception e) {
-      throw asRestApiException("Cannot set active", e);
-    }
-  }
-
-  @Override
-  public String getAvatarUrl(int size) throws RestApiException {
-    getAvatar.setSize(size);
-    return getAvatar.apply(account).location();
-  }
-
-  @Override
-  public GeneralPreferencesInfo getPreferences() throws RestApiException {
-    try {
-      return getPreferences.apply(account);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get preferences", e);
-    }
-  }
-
-  @Override
-  public GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in) throws RestApiException {
-    try {
-      return setPreferences.apply(account, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot set preferences", e);
-    }
-  }
-
-  @Override
-  public DiffPreferencesInfo getDiffPreferences() throws RestApiException {
-    try {
-      return getDiffPreferences.apply(account);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot query diff preferences", e);
-    }
-  }
-
-  @Override
-  public DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in) throws RestApiException {
-    try {
-      return setDiffPreferences.apply(account, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot set diff preferences", e);
-    }
-  }
-
-  @Override
-  public EditPreferencesInfo getEditPreferences() throws RestApiException {
-    try {
-      return getEditPreferences.apply(account);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot query edit preferences", e);
-    }
-  }
-
-  @Override
-  public EditPreferencesInfo setEditPreferences(EditPreferencesInfo in) throws RestApiException {
-    try {
-      return setEditPreferences.apply(account, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot set edit preferences", e);
-    }
-  }
-
-  @Override
-  public List<ProjectWatchInfo> getWatchedProjects() throws RestApiException {
-    try {
-      return getWatchedProjects.apply(account);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get watched projects", e);
-    }
-  }
-
-  @Override
-  public List<ProjectWatchInfo> setWatchedProjects(List<ProjectWatchInfo> in)
-      throws RestApiException {
-    try {
-      return postWatchedProjects.apply(account, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot update watched projects", e);
-    }
-  }
-
-  @Override
-  public void deleteWatchedProjects(List<ProjectWatchInfo> in) throws RestApiException {
-    try {
-      deleteWatchedProjects.apply(account, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete watched projects", e);
-    }
-  }
-
-  @Override
-  public void starChange(String changeId) throws RestApiException {
-    try {
-      ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
-      starredChangesCreate.setChange(rsrc);
-      starredChangesCreate.apply(account, new StarredChanges.EmptyInput());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot star change", e);
-    }
-  }
-
-  @Override
-  public void unstarChange(String changeId) throws RestApiException {
-    try {
-      ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
-      AccountResource.StarredChange starredChange =
-          new AccountResource.StarredChange(account.getUser(), rsrc);
-      starredChangesDelete.apply(starredChange, new StarredChanges.EmptyInput());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot unstar change", e);
-    }
-  }
-
-  @Override
-  public void setStars(String changeId, StarsInput input) throws RestApiException {
-    try {
-      AccountResource.Star rsrc = stars.parse(account, IdString.fromUrl(changeId));
-      starsPost.apply(rsrc, input);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot post stars", e);
-    }
-  }
-
-  @Override
-  public SortedSet<String> getStars(String changeId) throws RestApiException {
-    try {
-      AccountResource.Star rsrc = stars.parse(account, IdString.fromUrl(changeId));
-      return starsGet.apply(rsrc);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get stars", e);
-    }
-  }
-
-  @Override
-  public List<ChangeInfo> getStarredChanges() throws RestApiException {
-    try {
-      return stars.list().apply(account);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get starred changes", e);
-    }
-  }
-
-  @Override
-  public List<GroupInfo> getGroups() throws RestApiException {
-    try {
-      return getGroups.apply(account);
-    } catch (OrmException e) {
-      throw asRestApiException("Cannot get groups", e);
-    }
-  }
-
-  @Override
-  public List<EmailInfo> getEmails() {
-    return getEmails.apply(account);
-  }
-
-  @Override
-  public void addEmail(EmailInput input) throws RestApiException {
-    AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), input.email);
-    try {
-      createEmailFactory.create(input.email).apply(rsrc, input);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot add email", e);
-    }
-  }
-
-  @Override
-  public void deleteEmail(String email) throws RestApiException {
-    AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), email);
-    try {
-      deleteEmail.apply(rsrc, null);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete email", e);
-    }
-  }
-
-  @Override
-  public void setStatus(String status) throws RestApiException {
-    PutStatus.Input in = new PutStatus.Input(status);
-    try {
-      putStatus.apply(account, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot set status", e);
-    }
-  }
-
-  @Override
-  public List<SshKeyInfo> listSshKeys() throws RestApiException {
-    try {
-      return getSshKeys.apply(account);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot list SSH keys", e);
-    }
-  }
-
-  @Override
-  public SshKeyInfo addSshKey(String key) throws RestApiException {
-    AddSshKey.Input in = new AddSshKey.Input();
-    in.raw = RawInputUtil.create(key);
-    try {
-      return addSshKey.apply(account, in).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot add SSH key", e);
-    }
-  }
-
-  @Override
-  public void deleteSshKey(int seq) throws RestApiException {
-    try {
-      AccountResource.SshKey sshKeyRes =
-          sshKeys.parse(account, IdString.fromDecoded(Integer.toString(seq)));
-      deleteSshKey.apply(sshKeyRes, null);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete SSH key", e);
-    }
-  }
-
-  @Override
-  public Map<String, GpgKeyInfo> listGpgKeys() throws RestApiException {
-    try {
-      return gpgApiAdapter.listGpgKeys(account);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot list GPG keys", e);
-    }
-  }
-
-  @Override
-  public Map<String, GpgKeyInfo> putGpgKeys(List<String> add, List<String> delete)
-      throws RestApiException {
-    try {
-      return gpgApiAdapter.putGpgKeys(account, add, delete);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot add GPG key", e);
-    }
-  }
-
-  @Override
-  public GpgKeyApi gpgKey(String id) throws RestApiException {
-    try {
-      return gpgApiAdapter.gpgKey(account, IdString.fromDecoded(id));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get PGP key", e);
-    }
-  }
-
-  @Override
-  public List<AgreementInfo> listAgreements() throws RestApiException {
-    return getAgreements.apply(account);
-  }
-
-  @Override
-  public void signAgreement(String agreementName) throws RestApiException {
-    try {
-      AgreementInput input = new AgreementInput();
-      input.name = agreementName;
-      putAgreement.apply(account, input);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot sign agreement", e);
-    }
-  }
-
-  @Override
-  public void index() throws RestApiException {
-    try {
-      index.apply(account, new Index.Input());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot index account", e);
-    }
-  }
-
-  @Override
-  public List<AccountExternalIdInfo> getExternalIds() throws RestApiException {
-    try {
-      return getExternalIds.apply(account);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get external IDs", e);
-    }
-  }
-
-  @Override
-  public void deleteExternalIds(List<String> externalIds) throws RestApiException {
-    try {
-      deleteExternalIds.apply(account, externalIds);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete external IDs", e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
deleted file mode 100644
index 2f8dee6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.accounts;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import java.util.List;
-
-public interface AccountExternalIdCreator {
-
-  /**
-   * Returns additional external identifiers to assign to a given user when creating an account.
-   *
-   * @param id the identifier of the account.
-   * @param username the name of the user.
-   * @param email an optional email address to assign to the external identifiers, or {@code null}.
-   * @return a list of external identifiers, or an empty list.
-   */
-  List<ExternalId> create(Account.Id id, String username, String email);
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java
deleted file mode 100644
index 7c468fc..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.accounts;
-
-import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.extensions.common.AccountInfo;
-import java.util.Comparator;
-
-public class AccountInfoComparator extends Ordering<AccountInfo>
-    implements Comparator<AccountInfo> {
-  public static final AccountInfoComparator ORDER_NULLS_FIRST = new AccountInfoComparator();
-  public static final AccountInfoComparator ORDER_NULLS_LAST =
-      new AccountInfoComparator().setNullsLast();
-
-  private boolean nullsLast;
-
-  private AccountInfoComparator() {}
-
-  private AccountInfoComparator setNullsLast() {
-    this.nullsLast = true;
-    return this;
-  }
-
-  @Override
-  public int compare(AccountInfo a, AccountInfo b) {
-    return ComparisonChain.start()
-        .compare(a.name, b.name, createOrdering())
-        .compare(a.email, b.email, createOrdering())
-        .compare(a._accountId, b._accountId, createOrdering())
-        .result();
-  }
-
-  private <S extends Comparable<?>> Ordering<S> createOrdering() {
-    if (nullsLast) {
-      return Ordering.natural().nullsLast();
-    }
-    return Ordering.natural().nullsFirst();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
deleted file mode 100644
index 5257aec..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
+++ /dev/null
@@ -1,167 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.accounts;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.extensions.api.accounts.AccountApi;
-import com.google.gerrit.extensions.api.accounts.AccountInput;
-import com.google.gerrit.extensions.api.accounts.Accounts;
-import com.google.gerrit.extensions.client.ListAccountsOption;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.account.AccountsCollection;
-import com.google.gerrit.server.account.CreateAccount;
-import com.google.gerrit.server.account.QueryAccounts;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.List;
-
-@Singleton
-public class AccountsImpl implements Accounts {
-  private final AccountsCollection accounts;
-  private final AccountApiImpl.Factory api;
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> self;
-  private final CreateAccount.Factory createAccount;
-  private final Provider<QueryAccounts> queryAccountsProvider;
-
-  @Inject
-  AccountsImpl(
-      AccountsCollection accounts,
-      AccountApiImpl.Factory api,
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> self,
-      CreateAccount.Factory createAccount,
-      Provider<QueryAccounts> queryAccountsProvider) {
-    this.accounts = accounts;
-    this.api = api;
-    this.permissionBackend = permissionBackend;
-    this.self = self;
-    this.createAccount = createAccount;
-    this.queryAccountsProvider = queryAccountsProvider;
-  }
-
-  @Override
-  public AccountApi id(String id) throws RestApiException {
-    try {
-      return api.create(accounts.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(id)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot parse change", e);
-    }
-  }
-
-  @Override
-  public AccountApi id(int id) throws RestApiException {
-    return id(String.valueOf(id));
-  }
-
-  @Override
-  public AccountApi self() throws RestApiException {
-    if (!self.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-    return api.create(new AccountResource(self.get().asIdentifiedUser()));
-  }
-
-  @Override
-  public AccountApi create(String username) throws RestApiException {
-    AccountInput in = new AccountInput();
-    in.username = username;
-    return create(in);
-  }
-
-  @Override
-  public AccountApi create(AccountInput in) throws RestApiException {
-    if (checkNotNull(in, "AccountInput").username == null) {
-      throw new BadRequestException("AccountInput must specify username");
-    }
-    try {
-      CreateAccount impl = createAccount.create(in.username);
-      permissionBackend.user(self).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
-      AccountInfo info = impl.apply(TopLevelResource.INSTANCE, in).value();
-      return id(info._accountId);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot create account " + in.username, e);
-    }
-  }
-
-  @Override
-  public SuggestAccountsRequest suggestAccounts() throws RestApiException {
-    return new SuggestAccountsRequest() {
-      @Override
-      public List<AccountInfo> get() throws RestApiException {
-        return AccountsImpl.this.suggestAccounts(this);
-      }
-    };
-  }
-
-  @Override
-  public SuggestAccountsRequest suggestAccounts(String query) throws RestApiException {
-    return suggestAccounts().withQuery(query);
-  }
-
-  private List<AccountInfo> suggestAccounts(SuggestAccountsRequest r) throws RestApiException {
-    try {
-      QueryAccounts myQueryAccounts = queryAccountsProvider.get();
-      myQueryAccounts.setSuggest(true);
-      myQueryAccounts.setQuery(r.getQuery());
-      myQueryAccounts.setLimit(r.getLimit());
-      return myQueryAccounts.apply(TopLevelResource.INSTANCE);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve suggested accounts", e);
-    }
-  }
-
-  @Override
-  public QueryRequest query() throws RestApiException {
-    return new QueryRequest() {
-      @Override
-      public List<AccountInfo> get() throws RestApiException {
-        return AccountsImpl.this.query(this);
-      }
-    };
-  }
-
-  @Override
-  public QueryRequest query(String query) throws RestApiException {
-    return query().withQuery(query);
-  }
-
-  private List<AccountInfo> query(QueryRequest r) throws RestApiException {
-    try {
-      QueryAccounts myQueryAccounts = queryAccountsProvider.get();
-      myQueryAccounts.setQuery(r.getQuery());
-      myQueryAccounts.setLimit(r.getLimit());
-      myQueryAccounts.setStart(r.getStart());
-      for (ListAccountsOption option : r.getOptions()) {
-        myQueryAccounts.addOption(option);
-      }
-      return myQueryAccounts.apply(TopLevelResource.INSTANCE);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve suggested accounts", e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgApiAdapter.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgApiAdapter.java
deleted file mode 100644
index 7def6fa..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgApiAdapter.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.accounts;
-
-import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
-import com.google.gerrit.extensions.common.GpgKeyInfo;
-import com.google.gerrit.extensions.common.PushCertificateInfo;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountResource;
-import java.util.List;
-import java.util.Map;
-
-public interface GpgApiAdapter {
-  boolean isEnabled();
-
-  Map<String, GpgKeyInfo> listGpgKeys(AccountResource account)
-      throws RestApiException, GpgException;
-
-  Map<String, GpgKeyInfo> putGpgKeys(AccountResource account, List<String> add, List<String> delete)
-      throws RestApiException, GpgException;
-
-  GpgKeyApi gpgKey(AccountResource account, IdString idStr) throws RestApiException, GpgException;
-
-  PushCertificateInfo checkPushCertificate(String certStr, IdentifiedUser expectedUser)
-      throws GpgException;
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
deleted file mode 100644
index 0fba74a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ /dev/null
@@ -1,708 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.changes;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.api.changes.AbandonInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
-import com.google.gerrit.extensions.api.changes.ChangeApi;
-import com.google.gerrit.extensions.api.changes.ChangeEditApi;
-import com.google.gerrit.extensions.api.changes.Changes;
-import com.google.gerrit.extensions.api.changes.FixInput;
-import com.google.gerrit.extensions.api.changes.HashtagsInput;
-import com.google.gerrit.extensions.api.changes.IncludedInInfo;
-import com.google.gerrit.extensions.api.changes.MoveInput;
-import com.google.gerrit.extensions.api.changes.RebaseInput;
-import com.google.gerrit.extensions.api.changes.RestoreInput;
-import com.google.gerrit.extensions.api.changes.RevertInput;
-import com.google.gerrit.extensions.api.changes.ReviewerApi;
-import com.google.gerrit.extensions.api.changes.RevisionApi;
-import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
-import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.extensions.common.EditInfo;
-import com.google.gerrit.extensions.common.MergePatchSetInput;
-import com.google.gerrit.extensions.common.PureRevertInfo;
-import com.google.gerrit.extensions.common.RobotCommentInfo;
-import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.change.Abandon;
-import com.google.gerrit.server.change.ChangeIncludedIn;
-import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.Check;
-import com.google.gerrit.server.change.CreateMergePatchSet;
-import com.google.gerrit.server.change.DeleteAssignee;
-import com.google.gerrit.server.change.DeleteChange;
-import com.google.gerrit.server.change.DeletePrivate;
-import com.google.gerrit.server.change.GetAssignee;
-import com.google.gerrit.server.change.GetHashtags;
-import com.google.gerrit.server.change.GetPastAssignees;
-import com.google.gerrit.server.change.GetPureRevert;
-import com.google.gerrit.server.change.GetTopic;
-import com.google.gerrit.server.change.Ignore;
-import com.google.gerrit.server.change.Index;
-import com.google.gerrit.server.change.ListChangeComments;
-import com.google.gerrit.server.change.ListChangeDrafts;
-import com.google.gerrit.server.change.ListChangeRobotComments;
-import com.google.gerrit.server.change.MarkAsReviewed;
-import com.google.gerrit.server.change.MarkAsUnreviewed;
-import com.google.gerrit.server.change.Move;
-import com.google.gerrit.server.change.PostHashtags;
-import com.google.gerrit.server.change.PostPrivate;
-import com.google.gerrit.server.change.PostReviewers;
-import com.google.gerrit.server.change.PutAssignee;
-import com.google.gerrit.server.change.PutMessage;
-import com.google.gerrit.server.change.PutTopic;
-import com.google.gerrit.server.change.Rebase;
-import com.google.gerrit.server.change.Restore;
-import com.google.gerrit.server.change.Revert;
-import com.google.gerrit.server.change.Reviewers;
-import com.google.gerrit.server.change.Revisions;
-import com.google.gerrit.server.change.SetPrivateOp;
-import com.google.gerrit.server.change.SetReadyForReview;
-import com.google.gerrit.server.change.SetWorkInProgress;
-import com.google.gerrit.server.change.SubmittedTogether;
-import com.google.gerrit.server.change.SuggestChangeReviewers;
-import com.google.gerrit.server.change.Unignore;
-import com.google.gerrit.server.change.WorkInProgressOp;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.util.EnumSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-class ChangeApiImpl implements ChangeApi {
-  interface Factory {
-    ChangeApiImpl create(ChangeResource change);
-  }
-
-  private final Changes changeApi;
-  private final Reviewers reviewers;
-  private final Revisions revisions;
-  private final ReviewerApiImpl.Factory reviewerApi;
-  private final RevisionApiImpl.Factory revisionApi;
-  private final SuggestChangeReviewers suggestReviewers;
-  private final ChangeResource change;
-  private final Abandon abandon;
-  private final Revert revert;
-  private final Restore restore;
-  private final CreateMergePatchSet updateByMerge;
-  private final Provider<SubmittedTogether> submittedTogether;
-  private final Rebase.CurrentRevision rebase;
-  private final DeleteChange deleteChange;
-  private final GetTopic getTopic;
-  private final PutTopic putTopic;
-  private final ChangeIncludedIn includedIn;
-  private final PostReviewers postReviewers;
-  private final ChangeJson.Factory changeJson;
-  private final PostHashtags postHashtags;
-  private final GetHashtags getHashtags;
-  private final PutAssignee putAssignee;
-  private final GetAssignee getAssignee;
-  private final GetPastAssignees getPastAssignees;
-  private final DeleteAssignee deleteAssignee;
-  private final ListChangeComments listComments;
-  private final ListChangeRobotComments listChangeRobotComments;
-  private final ListChangeDrafts listDrafts;
-  private final ChangeEditApiImpl.Factory changeEditApi;
-  private final Check check;
-  private final Index index;
-  private final Move move;
-  private final PostPrivate postPrivate;
-  private final DeletePrivate deletePrivate;
-  private final Ignore ignore;
-  private final Unignore unignore;
-  private final MarkAsReviewed markAsReviewed;
-  private final MarkAsUnreviewed markAsUnreviewed;
-  private final SetWorkInProgress setWip;
-  private final SetReadyForReview setReady;
-  private final PutMessage putMessage;
-  private final GetPureRevert getPureRevert;
-  private final StarredChangesUtil stars;
-
-  @Inject
-  ChangeApiImpl(
-      Changes changeApi,
-      Reviewers reviewers,
-      Revisions revisions,
-      ReviewerApiImpl.Factory reviewerApi,
-      RevisionApiImpl.Factory revisionApi,
-      SuggestChangeReviewers suggestReviewers,
-      Abandon abandon,
-      Revert revert,
-      Restore restore,
-      CreateMergePatchSet updateByMerge,
-      Provider<SubmittedTogether> submittedTogether,
-      Rebase.CurrentRevision rebase,
-      DeleteChange deleteChange,
-      GetTopic getTopic,
-      PutTopic putTopic,
-      ChangeIncludedIn includedIn,
-      PostReviewers postReviewers,
-      ChangeJson.Factory changeJson,
-      PostHashtags postHashtags,
-      GetHashtags getHashtags,
-      PutAssignee putAssignee,
-      GetAssignee getAssignee,
-      GetPastAssignees getPastAssignees,
-      DeleteAssignee deleteAssignee,
-      ListChangeComments listComments,
-      ListChangeRobotComments listChangeRobotComments,
-      ListChangeDrafts listDrafts,
-      ChangeEditApiImpl.Factory changeEditApi,
-      Check check,
-      Index index,
-      Move move,
-      PostPrivate postPrivate,
-      DeletePrivate deletePrivate,
-      Ignore ignore,
-      Unignore unignore,
-      MarkAsReviewed markAsReviewed,
-      MarkAsUnreviewed markAsUnreviewed,
-      SetWorkInProgress setWip,
-      SetReadyForReview setReady,
-      PutMessage putMessage,
-      GetPureRevert getPureRevert,
-      StarredChangesUtil stars,
-      @Assisted ChangeResource change) {
-    this.changeApi = changeApi;
-    this.revert = revert;
-    this.reviewers = reviewers;
-    this.revisions = revisions;
-    this.reviewerApi = reviewerApi;
-    this.revisionApi = revisionApi;
-    this.suggestReviewers = suggestReviewers;
-    this.abandon = abandon;
-    this.restore = restore;
-    this.updateByMerge = updateByMerge;
-    this.submittedTogether = submittedTogether;
-    this.rebase = rebase;
-    this.deleteChange = deleteChange;
-    this.getTopic = getTopic;
-    this.putTopic = putTopic;
-    this.includedIn = includedIn;
-    this.postReviewers = postReviewers;
-    this.changeJson = changeJson;
-    this.postHashtags = postHashtags;
-    this.getHashtags = getHashtags;
-    this.putAssignee = putAssignee;
-    this.getAssignee = getAssignee;
-    this.getPastAssignees = getPastAssignees;
-    this.deleteAssignee = deleteAssignee;
-    this.listComments = listComments;
-    this.listChangeRobotComments = listChangeRobotComments;
-    this.listDrafts = listDrafts;
-    this.changeEditApi = changeEditApi;
-    this.check = check;
-    this.index = index;
-    this.move = move;
-    this.postPrivate = postPrivate;
-    this.deletePrivate = deletePrivate;
-    this.ignore = ignore;
-    this.unignore = unignore;
-    this.markAsReviewed = markAsReviewed;
-    this.markAsUnreviewed = markAsUnreviewed;
-    this.setWip = setWip;
-    this.setReady = setReady;
-    this.putMessage = putMessage;
-    this.getPureRevert = getPureRevert;
-    this.stars = stars;
-    this.change = change;
-  }
-
-  @Override
-  public String id() {
-    return Integer.toString(change.getId().get());
-  }
-
-  @Override
-  public RevisionApi current() throws RestApiException {
-    return revision("current");
-  }
-
-  @Override
-  public RevisionApi revision(int id) throws RestApiException {
-    return revision(String.valueOf(id));
-  }
-
-  @Override
-  public RevisionApi revision(String id) throws RestApiException {
-    try {
-      return revisionApi.create(revisions.parse(change, IdString.fromDecoded(id)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot parse revision", e);
-    }
-  }
-
-  @Override
-  public ReviewerApi reviewer(String id) throws RestApiException {
-    try {
-      return reviewerApi.create(reviewers.parse(change, IdString.fromDecoded(id)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot parse reviewer", e);
-    }
-  }
-
-  @Override
-  public void abandon() throws RestApiException {
-    abandon(new AbandonInput());
-  }
-
-  @Override
-  public void abandon(AbandonInput in) throws RestApiException {
-    try {
-      abandon.apply(change, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot abandon change", e);
-    }
-  }
-
-  @Override
-  public void restore() throws RestApiException {
-    restore(new RestoreInput());
-  }
-
-  @Override
-  public void restore(RestoreInput in) throws RestApiException {
-    try {
-      restore.apply(change, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot restore change", e);
-    }
-  }
-
-  @Override
-  public void move(String destination) throws RestApiException {
-    MoveInput in = new MoveInput();
-    in.destinationBranch = destination;
-    move(in);
-  }
-
-  @Override
-  public void move(MoveInput in) throws RestApiException {
-    try {
-      move.apply(change, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot move change", e);
-    }
-  }
-
-  @Override
-  public void setPrivate(boolean value, @Nullable String message) throws RestApiException {
-    try {
-      SetPrivateOp.Input input = new SetPrivateOp.Input(message);
-      if (value) {
-        postPrivate.apply(change, input);
-      } else {
-        deletePrivate.apply(change, input);
-      }
-    } catch (Exception e) {
-      throw asRestApiException("Cannot change private status", e);
-    }
-  }
-
-  @Override
-  public void setWorkInProgress(String message) throws RestApiException {
-    try {
-      setWip.apply(change, new WorkInProgressOp.Input(message));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot set work in progress state", e);
-    }
-  }
-
-  @Override
-  public void setReadyForReview(String message) throws RestApiException {
-    try {
-      setReady.apply(change, new WorkInProgressOp.Input(message));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot set ready for review state", e);
-    }
-  }
-
-  @Override
-  public ChangeApi revert() throws RestApiException {
-    return revert(new RevertInput());
-  }
-
-  @Override
-  public ChangeApi revert(RevertInput in) throws RestApiException {
-    try {
-      return changeApi.id(revert.apply(change, in)._number);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot revert change", e);
-    }
-  }
-
-  @Override
-  public ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException {
-    try {
-      return updateByMerge.apply(change, in).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot update change by merge", e);
-    }
-  }
-
-  @Override
-  public List<ChangeInfo> submittedTogether() throws RestApiException {
-    SubmittedTogetherInfo info =
-        submittedTogether(
-            EnumSet.noneOf(ListChangesOption.class), EnumSet.noneOf(SubmittedTogetherOption.class));
-    return info.changes;
-  }
-
-  @Override
-  public SubmittedTogetherInfo submittedTogether(EnumSet<SubmittedTogetherOption> options)
-      throws RestApiException {
-    return submittedTogether(EnumSet.noneOf(ListChangesOption.class), options);
-  }
-
-  @Override
-  public SubmittedTogetherInfo submittedTogether(
-      EnumSet<ListChangesOption> listOptions, EnumSet<SubmittedTogetherOption> submitOptions)
-      throws RestApiException {
-    try {
-      return submittedTogether
-          .get()
-          .addListChangesOption(listOptions)
-          .addSubmittedTogetherOption(submitOptions)
-          .applyInfo(change);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot query submittedTogether", e);
-    }
-  }
-
-  @Deprecated
-  @Override
-  public void publish() throws RestApiException {
-    throw new UnsupportedOperationException("draft workflow is discontinued");
-  }
-
-  @Override
-  public void rebase() throws RestApiException {
-    rebase(new RebaseInput());
-  }
-
-  @Override
-  public void rebase(RebaseInput in) throws RestApiException {
-    try {
-      rebase.apply(change, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot rebase change", e);
-    }
-  }
-
-  @Override
-  public void delete() throws RestApiException {
-    try {
-      deleteChange.apply(change, null);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete change", e);
-    }
-  }
-
-  @Override
-  public String topic() throws RestApiException {
-    return getTopic.apply(change);
-  }
-
-  @Override
-  public void topic(String topic) throws RestApiException {
-    PutTopic.Input in = new PutTopic.Input();
-    in.topic = topic;
-    try {
-      putTopic.apply(change, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot set topic", e);
-    }
-  }
-
-  @Override
-  public IncludedInInfo includedIn() throws RestApiException {
-    try {
-      return includedIn.apply(change);
-    } catch (Exception e) {
-      throw asRestApiException("Could not extract IncludedIn data", e);
-    }
-  }
-
-  @Override
-  public AddReviewerResult addReviewer(String reviewer) throws RestApiException {
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = reviewer;
-    return addReviewer(in);
-  }
-
-  @Override
-  public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
-    try {
-      return postReviewers.apply(change, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot add change reviewer", e);
-    }
-  }
-
-  @Override
-  public SuggestedReviewersRequest suggestReviewers() throws RestApiException {
-    return new SuggestedReviewersRequest() {
-      @Override
-      public List<SuggestedReviewerInfo> get() throws RestApiException {
-        return ChangeApiImpl.this.suggestReviewers(this);
-      }
-    };
-  }
-
-  @Override
-  public SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException {
-    return suggestReviewers().withQuery(query);
-  }
-
-  private List<SuggestedReviewerInfo> suggestReviewers(SuggestedReviewersRequest r)
-      throws RestApiException {
-    try {
-      suggestReviewers.setQuery(r.getQuery());
-      suggestReviewers.setLimit(r.getLimit());
-      return suggestReviewers.apply(change);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve suggested reviewers", e);
-    }
-  }
-
-  @Override
-  public ChangeInfo get(EnumSet<ListChangesOption> s) throws RestApiException {
-    try {
-      return changeJson.create(s).format(change);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve change", e);
-    }
-  }
-
-  @Override
-  public ChangeInfo get() throws RestApiException {
-    return get(EnumSet.complementOf(EnumSet.of(ListChangesOption.CHECK)));
-  }
-
-  @Override
-  public EditInfo getEdit() throws RestApiException {
-    return edit().get().orElse(null);
-  }
-
-  @Override
-  public ChangeEditApi edit() throws RestApiException {
-    return changeEditApi.create(change);
-  }
-
-  @Override
-  public void setMessage(String msg) throws RestApiException {
-    CommitMessageInput in = new CommitMessageInput();
-    in.message = msg;
-    setMessage(in);
-  }
-
-  @Override
-  public void setMessage(CommitMessageInput in) throws RestApiException {
-    try {
-      putMessage.apply(change, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot edit commit message", e);
-    }
-  }
-
-  @Override
-  public ChangeInfo info() throws RestApiException {
-    return get(EnumSet.noneOf(ListChangesOption.class));
-  }
-
-  @Override
-  public void setHashtags(HashtagsInput input) throws RestApiException {
-    try {
-      postHashtags.apply(change, input);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot post hashtags", e);
-    }
-  }
-
-  @Override
-  public Set<String> getHashtags() throws RestApiException {
-    try {
-      return getHashtags.apply(change).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get hashtags", e);
-    }
-  }
-
-  @Override
-  public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
-    try {
-      return putAssignee.apply(change, input);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot set assignee", e);
-    }
-  }
-
-  @Override
-  public AccountInfo getAssignee() throws RestApiException {
-    try {
-      Response<AccountInfo> r = getAssignee.apply(change);
-      return r.isNone() ? null : r.value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get assignee", e);
-    }
-  }
-
-  @Override
-  public List<AccountInfo> getPastAssignees() throws RestApiException {
-    try {
-      return getPastAssignees.apply(change).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get past assignees", e);
-    }
-  }
-
-  @Override
-  public AccountInfo deleteAssignee() throws RestApiException {
-    try {
-      Response<AccountInfo> r = deleteAssignee.apply(change, null);
-      return r.isNone() ? null : r.value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete assignee", e);
-    }
-  }
-
-  @Override
-  public Map<String, List<CommentInfo>> comments() throws RestApiException {
-    try {
-      return listComments.apply(change);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get comments", e);
-    }
-  }
-
-  @Override
-  public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
-    try {
-      return listChangeRobotComments.apply(change);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get robot comments", e);
-    }
-  }
-
-  @Override
-  public Map<String, List<CommentInfo>> drafts() throws RestApiException {
-    try {
-      return listDrafts.apply(change);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get drafts", e);
-    }
-  }
-
-  @Override
-  public ChangeInfo check() throws RestApiException {
-    try {
-      return check.apply(change).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot check change", e);
-    }
-  }
-
-  @Override
-  public ChangeInfo check(FixInput fix) throws RestApiException {
-    try {
-      // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
-      // ConsistencyChecker.
-      return check.apply(change, fix).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot check change", e);
-    }
-  }
-
-  @Override
-  public void index() throws RestApiException {
-    try {
-      index.apply(change, new Index.Input());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot index change", e);
-    }
-  }
-
-  @Override
-  public void ignore(boolean ignore) throws RestApiException {
-    // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
-    // StarredChangesUtil.
-    try {
-      if (ignore) {
-        this.ignore.apply(change, new Ignore.Input());
-      } else {
-        unignore.apply(change, new Unignore.Input());
-      }
-    } catch (OrmException | IllegalLabelException e) {
-      throw asRestApiException("Cannot ignore change", e);
-    }
-  }
-
-  @Override
-  public boolean ignored() throws RestApiException {
-    try {
-      return stars.isIgnored(change);
-    } catch (OrmException e) {
-      throw asRestApiException("Cannot check if ignored", e);
-    }
-  }
-
-  @Override
-  public void markAsReviewed(boolean reviewed) throws RestApiException {
-    // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
-    // StarredChangesUtil.
-    try {
-      if (reviewed) {
-        markAsReviewed.apply(change, new MarkAsReviewed.Input());
-      } else {
-        markAsUnreviewed.apply(change, new MarkAsUnreviewed.Input());
-      }
-    } catch (OrmException | IllegalLabelException e) {
-      throw asRestApiException(
-          "Cannot mark change as " + (reviewed ? "reviewed" : "unreviewed"), e);
-    }
-  }
-
-  @Override
-  public PureRevertInfo pureRevert() throws RestApiException {
-    return pureRevert(null);
-  }
-
-  @Override
-  public PureRevertInfo pureRevert(@Nullable String claimedOriginal) throws RestApiException {
-    try {
-      return getPureRevert.setClaimedOriginal(claimedOriginal).apply(change);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot compute pure revert", e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
deleted file mode 100644
index d1b57e6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
+++ /dev/null
@@ -1,216 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.changes;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.extensions.api.changes.ChangeEditApi;
-import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
-import com.google.gerrit.extensions.common.EditInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RawInput;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.change.ChangeEditResource;
-import com.google.gerrit.server.change.ChangeEdits;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.DeleteChangeEdit;
-import com.google.gerrit.server.change.PublishChangeEdit;
-import com.google.gerrit.server.change.RebaseChangeEdit;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.Optional;
-
-public class ChangeEditApiImpl implements ChangeEditApi {
-  interface Factory {
-    ChangeEditApiImpl create(ChangeResource changeResource);
-  }
-
-  private final ChangeEdits.Detail editDetail;
-  private final ChangeEdits.Post changeEditsPost;
-  private final DeleteChangeEdit deleteChangeEdit;
-  private final RebaseChangeEdit.Rebase rebaseChangeEdit;
-  private final PublishChangeEdit.Publish publishChangeEdit;
-  private final ChangeEdits.Get changeEditsGet;
-  private final ChangeEdits.Put changeEditsPut;
-  private final ChangeEdits.DeleteContent changeEditDeleteContent;
-  private final ChangeEdits.GetMessage getChangeEditCommitMessage;
-  private final ChangeEdits.EditMessage modifyChangeEditCommitMessage;
-  private final ChangeEdits changeEdits;
-  private final ChangeResource changeResource;
-
-  @Inject
-  public ChangeEditApiImpl(
-      ChangeEdits.Detail editDetail,
-      ChangeEdits.Post changeEditsPost,
-      DeleteChangeEdit deleteChangeEdit,
-      RebaseChangeEdit.Rebase rebaseChangeEdit,
-      PublishChangeEdit.Publish publishChangeEdit,
-      ChangeEdits.Get changeEditsGet,
-      ChangeEdits.Put changeEditsPut,
-      ChangeEdits.DeleteContent changeEditDeleteContent,
-      ChangeEdits.GetMessage getChangeEditCommitMessage,
-      ChangeEdits.EditMessage modifyChangeEditCommitMessage,
-      ChangeEdits changeEdits,
-      @Assisted ChangeResource changeResource) {
-    this.editDetail = editDetail;
-    this.changeEditsPost = changeEditsPost;
-    this.deleteChangeEdit = deleteChangeEdit;
-    this.rebaseChangeEdit = rebaseChangeEdit;
-    this.publishChangeEdit = publishChangeEdit;
-    this.changeEditsGet = changeEditsGet;
-    this.changeEditsPut = changeEditsPut;
-    this.changeEditDeleteContent = changeEditDeleteContent;
-    this.getChangeEditCommitMessage = getChangeEditCommitMessage;
-    this.modifyChangeEditCommitMessage = modifyChangeEditCommitMessage;
-    this.changeEdits = changeEdits;
-    this.changeResource = changeResource;
-  }
-
-  @Override
-  public Optional<EditInfo> get() throws RestApiException {
-    try {
-      Response<EditInfo> edit = editDetail.apply(changeResource);
-      return edit.isNone() ? Optional.empty() : Optional.of(edit.value());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve change edit", e);
-    }
-  }
-
-  @Override
-  public void create() throws RestApiException {
-    try {
-      changeEditsPost.apply(changeResource, null);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot create change edit", e);
-    }
-  }
-
-  @Override
-  public void delete() throws RestApiException {
-    try {
-      deleteChangeEdit.apply(changeResource, new DeleteChangeEdit.Input());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete change edit", e);
-    }
-  }
-
-  @Override
-  public void rebase() throws RestApiException {
-    try {
-      rebaseChangeEdit.apply(changeResource, null);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot rebase change edit", e);
-    }
-  }
-
-  @Override
-  public void publish() throws RestApiException {
-    publish(null);
-  }
-
-  @Override
-  public void publish(PublishChangeEditInput publishChangeEditInput) throws RestApiException {
-    try {
-      publishChangeEdit.apply(changeResource, publishChangeEditInput);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot publish change edit", e);
-    }
-  }
-
-  @Override
-  public Optional<BinaryResult> getFile(String filePath) throws RestApiException {
-    try {
-      ChangeEditResource changeEditResource = getChangeEditResource(filePath);
-      Response<BinaryResult> fileResponse = changeEditsGet.apply(changeEditResource);
-      return fileResponse.isNone() ? Optional.empty() : Optional.of(fileResponse.value());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve file of change edit", e);
-    }
-  }
-
-  @Override
-  public void renameFile(String oldFilePath, String newFilePath) throws RestApiException {
-    try {
-      ChangeEdits.Post.Input renameInput = new ChangeEdits.Post.Input();
-      renameInput.oldPath = oldFilePath;
-      renameInput.newPath = newFilePath;
-      changeEditsPost.apply(changeResource, renameInput);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot rename file of change edit", e);
-    }
-  }
-
-  @Override
-  public void restoreFile(String filePath) throws RestApiException {
-    try {
-      ChangeEdits.Post.Input restoreInput = new ChangeEdits.Post.Input();
-      restoreInput.restorePath = filePath;
-      changeEditsPost.apply(changeResource, restoreInput);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot restore file of change edit", e);
-    }
-  }
-
-  @Override
-  public void modifyFile(String filePath, RawInput newContent) throws RestApiException {
-    try {
-      changeEditsPut.apply(changeResource, filePath, newContent);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot modify file of change edit", e);
-    }
-  }
-
-  @Override
-  public void deleteFile(String filePath) throws RestApiException {
-    try {
-      changeEditDeleteContent.apply(changeResource, filePath);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete file of change edit", e);
-    }
-  }
-
-  @Override
-  public String getCommitMessage() throws RestApiException {
-    try {
-      try (BinaryResult binaryResult = getChangeEditCommitMessage.apply(changeResource)) {
-        return binaryResult.asString();
-      }
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get commit message of change edit", e);
-    }
-  }
-
-  @Override
-  public void modifyCommitMessage(String newCommitMessage) throws RestApiException {
-    ChangeEdits.EditMessage.Input input = new ChangeEdits.EditMessage.Input();
-    input.message = newCommitMessage;
-    try {
-      modifyChangeEditCommitMessage.apply(changeResource, input);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot modify commit message of change edit", e);
-    }
-  }
-
-  private ChangeEditResource getChangeEditResource(String filePath)
-      throws ResourceNotFoundException, AuthException, IOException, OrmException {
-    return changeEdits.parse(changeResource, IdString.fromDecoded(filePath));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
deleted file mode 100644
index cc39883..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.changes;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.common.base.Joiner;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.extensions.api.changes.ChangeApi;
-import com.google.gerrit.extensions.api.changes.Changes;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.change.ChangesCollection;
-import com.google.gerrit.server.change.CreateChange;
-import com.google.gerrit.server.query.change.QueryChanges;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.List;
-
-@Singleton
-class ChangesImpl implements Changes {
-  private final ChangesCollection changes;
-  private final ChangeApiImpl.Factory api;
-  private final CreateChange createChange;
-  private final Provider<QueryChanges> queryProvider;
-
-  @Inject
-  ChangesImpl(
-      ChangesCollection changes,
-      ChangeApiImpl.Factory api,
-      CreateChange createChange,
-      Provider<QueryChanges> queryProvider) {
-    this.changes = changes;
-    this.api = api;
-    this.createChange = createChange;
-    this.queryProvider = queryProvider;
-  }
-
-  @Override
-  public ChangeApi id(int id) throws RestApiException {
-    return id(String.valueOf(id));
-  }
-
-  @Override
-  public ChangeApi id(String project, String branch, String id) throws RestApiException {
-    return id(
-        Joiner.on('~')
-            .join(ImmutableList.of(Url.encode(project), Url.encode(branch), Url.encode(id))));
-  }
-
-  @Override
-  public ChangeApi id(String id) throws RestApiException {
-    try {
-      return api.create(changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(id)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot parse change", e);
-    }
-  }
-
-  @Override
-  public ChangeApi id(String project, int id) throws RestApiException {
-    return id(
-        Joiner.on('~').join(ImmutableList.of(Url.encode(project), Url.encode(String.valueOf(id)))));
-  }
-
-  @Override
-  public ChangeApi create(ChangeInput in) throws RestApiException {
-    try {
-      ChangeInfo out = createChange.apply(TopLevelResource.INSTANCE, in).value();
-      return api.create(changes.parse(new Change.Id(out._number)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot create change", e);
-    }
-  }
-
-  @Override
-  public QueryRequest query() {
-    return new QueryRequest() {
-      @Override
-      public List<ChangeInfo> get() throws RestApiException {
-        return ChangesImpl.this.get(this);
-      }
-    };
-  }
-
-  @Override
-  public QueryRequest query(String query) {
-    return query().withQuery(query);
-  }
-
-  private List<ChangeInfo> get(QueryRequest q) throws RestApiException {
-    QueryChanges qc = queryProvider.get();
-    if (q.getQuery() != null) {
-      qc.addQuery(q.getQuery());
-    }
-    qc.setLimit(q.getLimit());
-    qc.setStart(q.getStart());
-    for (ListChangesOption option : q.getOptions()) {
-      qc.addOption(option);
-    }
-
-    try {
-      List<?> result = qc.apply(TopLevelResource.INSTANCE);
-      if (result.isEmpty()) {
-        return ImmutableList.of();
-      }
-
-      // Check type safety of result; the extension API should be safer than the
-      // REST API in this case, since it's intended to be used in Java.
-      Object first = checkNotNull(result.iterator().next());
-      checkState(first instanceof ChangeInfo);
-      @SuppressWarnings("unchecked")
-      List<ChangeInfo> infos = (List<ChangeInfo>) result;
-
-      return ImmutableList.copyOf(infos);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot query changes", e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
deleted file mode 100644
index 6a2501e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.changes;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.extensions.api.changes.CommentApi;
-import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.change.CommentResource;
-import com.google.gerrit.server.change.DeleteComment;
-import com.google.gerrit.server.change.GetComment;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-class CommentApiImpl implements CommentApi {
-  interface Factory {
-    CommentApiImpl create(CommentResource c);
-  }
-
-  private final GetComment getComment;
-  private final DeleteComment deleteComment;
-  private final CommentResource comment;
-
-  @Inject
-  CommentApiImpl(
-      GetComment getComment, DeleteComment deleteComment, @Assisted CommentResource comment) {
-    this.getComment = getComment;
-    this.deleteComment = deleteComment;
-    this.comment = comment;
-  }
-
-  @Override
-  public CommentInfo get() throws RestApiException {
-    try {
-      return getComment.apply(comment);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve comment", e);
-    }
-  }
-
-  @Override
-  public CommentInfo delete(DeleteCommentInput input) throws RestApiException {
-    try {
-      return deleteComment.apply(comment, input);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete comment", e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
deleted file mode 100644
index eada51b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.changes;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
-import com.google.gerrit.extensions.api.changes.DraftApi;
-import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.change.DeleteDraftComment;
-import com.google.gerrit.server.change.DraftCommentResource;
-import com.google.gerrit.server.change.GetDraftComment;
-import com.google.gerrit.server.change.PutDraftComment;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-class DraftApiImpl implements DraftApi {
-  interface Factory {
-    DraftApiImpl create(DraftCommentResource d);
-  }
-
-  private final DeleteDraftComment deleteDraft;
-  private final GetDraftComment getDraft;
-  private final PutDraftComment putDraft;
-  private final DraftCommentResource draft;
-
-  @Inject
-  DraftApiImpl(
-      DeleteDraftComment deleteDraft,
-      GetDraftComment getDraft,
-      PutDraftComment putDraft,
-      @Assisted DraftCommentResource draft) {
-    this.deleteDraft = deleteDraft;
-    this.getDraft = getDraft;
-    this.putDraft = putDraft;
-    this.draft = draft;
-  }
-
-  @Override
-  public CommentInfo get() throws RestApiException {
-    try {
-      return getDraft.apply(draft);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve draft", e);
-    }
-  }
-
-  @Override
-  public CommentInfo update(DraftInput in) throws RestApiException {
-    try {
-      return putDraft.apply(draft, in).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot update draft", e);
-    }
-  }
-
-  @Override
-  public void delete() throws RestApiException {
-    try {
-      deleteDraft.apply(draft, null);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete draft", e);
-    }
-  }
-
-  @Override
-  public CommentInfo delete(DeleteCommentInput input) {
-    throw new NotImplementedException();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
deleted file mode 100644
index f51cdac..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
+++ /dev/null
@@ -1,111 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.changes;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.extensions.api.changes.FileApi;
-import com.google.gerrit.extensions.common.DiffInfo;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.change.FileResource;
-import com.google.gerrit.server.change.GetContent;
-import com.google.gerrit.server.change.GetDiff;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-class FileApiImpl implements FileApi {
-  interface Factory {
-    FileApiImpl create(FileResource r);
-  }
-
-  private final GetContent getContent;
-  private final GetDiff getDiff;
-  private final FileResource file;
-
-  @Inject
-  FileApiImpl(GetContent getContent, GetDiff getDiff, @Assisted FileResource file) {
-    this.getContent = getContent;
-    this.getDiff = getDiff;
-    this.file = file;
-  }
-
-  @Override
-  public BinaryResult content() throws RestApiException {
-    try {
-      return getContent.apply(file);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve file content", e);
-    }
-  }
-
-  @Override
-  public DiffInfo diff() throws RestApiException {
-    try {
-      return getDiff.apply(file).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve diff", e);
-    }
-  }
-
-  @Override
-  public DiffInfo diff(String base) throws RestApiException {
-    try {
-      return getDiff.setBase(base).apply(file).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve diff", e);
-    }
-  }
-
-  @Override
-  public DiffInfo diff(int parent) throws RestApiException {
-    try {
-      return getDiff.setParent(parent).apply(file).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve diff", e);
-    }
-  }
-
-  @Override
-  public DiffRequest diffRequest() {
-    return new DiffRequest() {
-      @Override
-      public DiffInfo get() throws RestApiException {
-        return FileApiImpl.this.get(this);
-      }
-    };
-  }
-
-  private DiffInfo get(DiffRequest r) throws RestApiException {
-    if (r.getBase() != null) {
-      getDiff.setBase(r.getBase());
-    }
-    if (r.getContext() != null) {
-      getDiff.setContext(r.getContext());
-    }
-    if (r.getIntraline() != null) {
-      getDiff.setIntraline(r.getIntraline());
-    }
-    if (r.getWhitespace() != null) {
-      getDiff.setWhitespace(r.getWhitespace());
-    }
-    r.getParent().ifPresent(getDiff::setParent);
-    try {
-      return getDiff.apply(file).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve diff", e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
deleted file mode 100644
index 2f8b7d8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
+++ /dev/null
@@ -1,94 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.changes;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
-import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
-import com.google.gerrit.extensions.api.changes.ReviewerApi;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.change.DeleteReviewer;
-import com.google.gerrit.server.change.DeleteVote;
-import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.change.VoteResource;
-import com.google.gerrit.server.change.Votes;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Map;
-
-public class ReviewerApiImpl implements ReviewerApi {
-  interface Factory {
-    ReviewerApiImpl create(ReviewerResource r);
-  }
-
-  private final ReviewerResource reviewer;
-  private final Votes.List listVotes;
-  private final DeleteVote deleteVote;
-  private final DeleteReviewer deleteReviewer;
-
-  @Inject
-  ReviewerApiImpl(
-      Votes.List listVotes,
-      DeleteVote deleteVote,
-      DeleteReviewer deleteReviewer,
-      @Assisted ReviewerResource reviewer) {
-    this.listVotes = listVotes;
-    this.deleteVote = deleteVote;
-    this.deleteReviewer = deleteReviewer;
-    this.reviewer = reviewer;
-  }
-
-  @Override
-  public Map<String, Short> votes() throws RestApiException {
-    try {
-      return listVotes.apply(reviewer);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot list votes", e);
-    }
-  }
-
-  @Override
-  public void deleteVote(String label) throws RestApiException {
-    try {
-      deleteVote.apply(new VoteResource(reviewer, label), null);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete vote", e);
-    }
-  }
-
-  @Override
-  public void deleteVote(DeleteVoteInput input) throws RestApiException {
-    try {
-      deleteVote.apply(new VoteResource(reviewer, input.label), input);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete vote", e);
-    }
-  }
-
-  @Override
-  public void remove() throws RestApiException {
-    remove(new DeleteReviewerInput());
-  }
-
-  @Override
-  public void remove(DeleteReviewerInput input) throws RestApiException {
-    try {
-      deleteReviewer.apply(reviewer, input);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot remove reviewer", e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
deleted file mode 100644
index 65bbc47..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ /dev/null
@@ -1,586 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.changes;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.extensions.api.changes.ChangeApi;
-import com.google.gerrit.extensions.api.changes.Changes;
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.api.changes.CommentApi;
-import com.google.gerrit.extensions.api.changes.DraftApi;
-import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.api.changes.FileApi;
-import com.google.gerrit.extensions.api.changes.RebaseInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewResult;
-import com.google.gerrit.extensions.api.changes.RevisionApi;
-import com.google.gerrit.extensions.api.changes.RevisionReviewerApi;
-import com.google.gerrit.extensions.api.changes.RobotCommentApi;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.EditInfo;
-import com.google.gerrit.extensions.common.FileInfo;
-import com.google.gerrit.extensions.common.MergeableInfo;
-import com.google.gerrit.extensions.common.RobotCommentInfo;
-import com.google.gerrit.extensions.common.TestSubmitRuleInput;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.change.ApplyFix;
-import com.google.gerrit.server.change.CherryPick;
-import com.google.gerrit.server.change.Comments;
-import com.google.gerrit.server.change.CreateDraftComment;
-import com.google.gerrit.server.change.DraftComments;
-import com.google.gerrit.server.change.FileResource;
-import com.google.gerrit.server.change.Files;
-import com.google.gerrit.server.change.Fixes;
-import com.google.gerrit.server.change.GetCommit;
-import com.google.gerrit.server.change.GetDescription;
-import com.google.gerrit.server.change.GetMergeList;
-import com.google.gerrit.server.change.GetPatch;
-import com.google.gerrit.server.change.GetRevisionActions;
-import com.google.gerrit.server.change.ListRevisionComments;
-import com.google.gerrit.server.change.ListRevisionDrafts;
-import com.google.gerrit.server.change.ListRobotComments;
-import com.google.gerrit.server.change.Mergeable;
-import com.google.gerrit.server.change.PostReview;
-import com.google.gerrit.server.change.PreviewSubmit;
-import com.google.gerrit.server.change.PutDescription;
-import com.google.gerrit.server.change.Rebase;
-import com.google.gerrit.server.change.RebaseUtil;
-import com.google.gerrit.server.change.Reviewed;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.change.RevisionReviewers;
-import com.google.gerrit.server.change.RobotComments;
-import com.google.gerrit.server.change.Submit;
-import com.google.gerrit.server.change.TestSubmitType;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-class RevisionApiImpl implements RevisionApi {
-  interface Factory {
-    RevisionApiImpl create(RevisionResource r);
-  }
-
-  private final GitRepositoryManager repoManager;
-  private final Changes changes;
-  private final RevisionReviewers revisionReviewers;
-  private final RevisionReviewerApiImpl.Factory revisionReviewerApi;
-  private final CherryPick cherryPick;
-  private final Rebase rebase;
-  private final RebaseUtil rebaseUtil;
-  private final Submit submit;
-  private final PreviewSubmit submitPreview;
-  private final Reviewed.PutReviewed putReviewed;
-  private final Reviewed.DeleteReviewed deleteReviewed;
-  private final RevisionResource revision;
-  private final Files files;
-  private final Files.ListFiles listFiles;
-  private final GetCommit getCommit;
-  private final GetPatch getPatch;
-  private final PostReview review;
-  private final Mergeable mergeable;
-  private final FileApiImpl.Factory fileApi;
-  private final ListRevisionComments listComments;
-  private final ListRobotComments listRobotComments;
-  private final ApplyFix applyFix;
-  private final Fixes fixes;
-  private final ListRevisionDrafts listDrafts;
-  private final CreateDraftComment createDraft;
-  private final DraftComments drafts;
-  private final DraftApiImpl.Factory draftFactory;
-  private final Comments comments;
-  private final CommentApiImpl.Factory commentFactory;
-  private final RobotComments robotComments;
-  private final RobotCommentApiImpl.Factory robotCommentFactory;
-  private final GetRevisionActions revisionActions;
-  private final TestSubmitType testSubmitType;
-  private final TestSubmitType.Get getSubmitType;
-  private final Provider<GetMergeList> getMergeList;
-  private final PutDescription putDescription;
-  private final GetDescription getDescription;
-
-  @Inject
-  RevisionApiImpl(
-      GitRepositoryManager repoManager,
-      Changes changes,
-      RevisionReviewers revisionReviewers,
-      RevisionReviewerApiImpl.Factory revisionReviewerApi,
-      CherryPick cherryPick,
-      Rebase rebase,
-      RebaseUtil rebaseUtil,
-      Submit submit,
-      PreviewSubmit submitPreview,
-      Reviewed.PutReviewed putReviewed,
-      Reviewed.DeleteReviewed deleteReviewed,
-      Files files,
-      Files.ListFiles listFiles,
-      GetCommit getCommit,
-      GetPatch getPatch,
-      PostReview review,
-      Mergeable mergeable,
-      FileApiImpl.Factory fileApi,
-      ListRevisionComments listComments,
-      ListRobotComments listRobotComments,
-      ApplyFix applyFix,
-      Fixes fixes,
-      ListRevisionDrafts listDrafts,
-      CreateDraftComment createDraft,
-      DraftComments drafts,
-      DraftApiImpl.Factory draftFactory,
-      Comments comments,
-      CommentApiImpl.Factory commentFactory,
-      RobotComments robotComments,
-      RobotCommentApiImpl.Factory robotCommentFactory,
-      GetRevisionActions revisionActions,
-      TestSubmitType testSubmitType,
-      TestSubmitType.Get getSubmitType,
-      Provider<GetMergeList> getMergeList,
-      PutDescription putDescription,
-      GetDescription getDescription,
-      @Assisted RevisionResource r) {
-    this.repoManager = repoManager;
-    this.changes = changes;
-    this.revisionReviewers = revisionReviewers;
-    this.revisionReviewerApi = revisionReviewerApi;
-    this.cherryPick = cherryPick;
-    this.rebase = rebase;
-    this.rebaseUtil = rebaseUtil;
-    this.review = review;
-    this.submit = submit;
-    this.submitPreview = submitPreview;
-    this.files = files;
-    this.putReviewed = putReviewed;
-    this.deleteReviewed = deleteReviewed;
-    this.listFiles = listFiles;
-    this.getCommit = getCommit;
-    this.getPatch = getPatch;
-    this.mergeable = mergeable;
-    this.fileApi = fileApi;
-    this.listComments = listComments;
-    this.robotComments = robotComments;
-    this.listRobotComments = listRobotComments;
-    this.applyFix = applyFix;
-    this.fixes = fixes;
-    this.listDrafts = listDrafts;
-    this.createDraft = createDraft;
-    this.drafts = drafts;
-    this.draftFactory = draftFactory;
-    this.comments = comments;
-    this.commentFactory = commentFactory;
-    this.robotCommentFactory = robotCommentFactory;
-    this.revisionActions = revisionActions;
-    this.testSubmitType = testSubmitType;
-    this.getSubmitType = getSubmitType;
-    this.getMergeList = getMergeList;
-    this.putDescription = putDescription;
-    this.getDescription = getDescription;
-    this.revision = r;
-  }
-
-  @Override
-  public ReviewResult review(ReviewInput in) throws RestApiException {
-    try {
-      return review.apply(revision, in).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot post review", e);
-    }
-  }
-
-  @Override
-  public void submit() throws RestApiException {
-    SubmitInput in = new SubmitInput();
-    submit(in);
-  }
-
-  @Override
-  public void submit(SubmitInput in) throws RestApiException {
-    try {
-      submit.apply(revision, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot submit change", e);
-    }
-  }
-
-  @Override
-  public BinaryResult submitPreview() throws RestApiException {
-    return submitPreview("zip");
-  }
-
-  @Override
-  public BinaryResult submitPreview(String format) throws RestApiException {
-    try {
-      submitPreview.setFormat(format);
-      return submitPreview.apply(revision);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get submit preview", e);
-    }
-  }
-
-  @Override
-  public void publish() throws RestApiException {
-    throw new UnsupportedOperationException("draft workflow is discontinued");
-  }
-
-  @Override
-  public void delete() throws RestApiException {
-    throw new UnsupportedOperationException("draft workflow is discontinued");
-  }
-
-  @Override
-  public ChangeApi rebase() throws RestApiException {
-    RebaseInput in = new RebaseInput();
-    return rebase(in);
-  }
-
-  @Override
-  public ChangeApi rebase(RebaseInput in) throws RestApiException {
-    try {
-      return changes.id(rebase.apply(revision, in)._number);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot rebase ps", e);
-    }
-  }
-
-  @Override
-  public boolean canRebase() throws RestApiException {
-    try (Repository repo = repoManager.openRepository(revision.getProject());
-        RevWalk rw = new RevWalk(repo)) {
-      return rebaseUtil.canRebase(revision.getPatchSet(), revision.getChange().getDest(), repo, rw);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot check if rebase is possible", e);
-    }
-  }
-
-  @Override
-  public ChangeApi cherryPick(CherryPickInput in) throws RestApiException {
-    try {
-      return changes.id(cherryPick.apply(revision, in)._number);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot cherry pick", e);
-    }
-  }
-
-  @Override
-  public RevisionReviewerApi reviewer(String id) throws RestApiException {
-    try {
-      return revisionReviewerApi.create(
-          revisionReviewers.parse(revision, IdString.fromDecoded(id)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot parse reviewer", e);
-    }
-  }
-
-  @Override
-  public void setReviewed(String path, boolean reviewed) throws RestApiException {
-    try {
-      RestModifyView<FileResource, Reviewed.Input> view;
-      if (reviewed) {
-        view = putReviewed;
-      } else {
-        view = deleteReviewed;
-      }
-      view.apply(files.parse(revision, IdString.fromDecoded(path)), new Reviewed.Input());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot update reviewed flag", e);
-    }
-  }
-
-  @SuppressWarnings("unchecked")
-  @Override
-  public Set<String> reviewed() throws RestApiException {
-    try {
-      return ImmutableSet.copyOf(
-          (Iterable<String>) listFiles.setReviewed(true).apply(revision).value());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot list reviewed files", e);
-    }
-  }
-
-  @Override
-  public MergeableInfo mergeable() throws RestApiException {
-    try {
-      return mergeable.apply(revision);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot check mergeability", e);
-    }
-  }
-
-  @Override
-  public MergeableInfo mergeableOtherBranches() throws RestApiException {
-    try {
-      mergeable.setOtherBranches(true);
-      return mergeable.apply(revision);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot check mergeability", e);
-    }
-  }
-
-  @SuppressWarnings("unchecked")
-  @Override
-  public Map<String, FileInfo> files() throws RestApiException {
-    try {
-      return (Map<String, FileInfo>) listFiles.apply(revision).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve files", e);
-    }
-  }
-
-  @SuppressWarnings("unchecked")
-  @Override
-  public Map<String, FileInfo> files(String base) throws RestApiException {
-    try {
-      return (Map<String, FileInfo>) listFiles.setBase(base).apply(revision).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve files", e);
-    }
-  }
-
-  @SuppressWarnings("unchecked")
-  @Override
-  public Map<String, FileInfo> files(int parentNum) throws RestApiException {
-    try {
-      return (Map<String, FileInfo>) listFiles.setParent(parentNum).apply(revision).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve files", e);
-    }
-  }
-
-  @SuppressWarnings("unchecked")
-  @Override
-  public List<String> queryFiles(String query) throws RestApiException {
-    try {
-      checkArgument(query != null, "no query provided");
-      return (List<String>) listFiles.setQuery(query).apply(revision).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve files", e);
-    }
-  }
-
-  @Override
-  public FileApi file(String path) {
-    return fileApi.create(files.parse(revision, IdString.fromDecoded(path)));
-  }
-
-  @Override
-  public CommitInfo commit(boolean addLinks) throws RestApiException {
-    try {
-      return getCommit.setAddLinks(addLinks).apply(revision).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve commit", e);
-    }
-  }
-
-  @Override
-  public Map<String, List<CommentInfo>> comments() throws RestApiException {
-    try {
-      return listComments.apply(revision);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve comments", e);
-    }
-  }
-
-  @Override
-  public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
-    try {
-      return listRobotComments.apply(revision);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve robot comments", e);
-    }
-  }
-
-  @Override
-  public List<CommentInfo> commentsAsList() throws RestApiException {
-    try {
-      return listComments.getComments(revision);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve comments", e);
-    }
-  }
-
-  @Override
-  public Map<String, List<CommentInfo>> drafts() throws RestApiException {
-    try {
-      return listDrafts.apply(revision);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve drafts", e);
-    }
-  }
-
-  @Override
-  public List<RobotCommentInfo> robotCommentsAsList() throws RestApiException {
-    try {
-      return listRobotComments.getComments(revision);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve robot comments", e);
-    }
-  }
-
-  @Override
-  public EditInfo applyFix(String fixId) throws RestApiException {
-    try {
-      return applyFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot apply fix", e);
-    }
-  }
-
-  @Override
-  public List<CommentInfo> draftsAsList() throws RestApiException {
-    try {
-      return listDrafts.getComments(revision);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve drafts", e);
-    }
-  }
-
-  @Override
-  public DraftApi draft(String id) throws RestApiException {
-    try {
-      return draftFactory.create(drafts.parse(revision, IdString.fromDecoded(id)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve draft", e);
-    }
-  }
-
-  @Override
-  public DraftApi createDraft(DraftInput in) throws RestApiException {
-    try {
-      String id = createDraft.apply(revision, in).value().id;
-      // Reread change to pick up new notes refs.
-      return changes
-          .id(revision.getChange().getId().get())
-          .revision(revision.getPatchSet().getId().get())
-          .draft(id);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot create draft", e);
-    }
-  }
-
-  @Override
-  public CommentApi comment(String id) throws RestApiException {
-    try {
-      return commentFactory.create(comments.parse(revision, IdString.fromDecoded(id)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve comment", e);
-    }
-  }
-
-  @Override
-  public RobotCommentApi robotComment(String id) throws RestApiException {
-    try {
-      return robotCommentFactory.create(robotComments.parse(revision, IdString.fromDecoded(id)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve robot comment", e);
-    }
-  }
-
-  @Override
-  public BinaryResult patch() throws RestApiException {
-    try {
-      return getPatch.apply(revision);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get patch", e);
-    }
-  }
-
-  @Override
-  public BinaryResult patch(String path) throws RestApiException {
-    try {
-      return getPatch.setPath(path).apply(revision);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get patch", e);
-    }
-  }
-
-  @Override
-  public Map<String, ActionInfo> actions() throws RestApiException {
-    try {
-      return revisionActions.apply(revision).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get actions", e);
-    }
-  }
-
-  @Override
-  public SubmitType submitType() throws RestApiException {
-    try {
-      return getSubmitType.apply(revision);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get submit type", e);
-    }
-  }
-
-  @Override
-  public SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException {
-    try {
-      return testSubmitType.apply(revision, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot test submit type", e);
-    }
-  }
-
-  @Override
-  public MergeListRequest getMergeList() throws RestApiException {
-    return new MergeListRequest() {
-      @Override
-      public List<CommitInfo> get() throws RestApiException {
-        try {
-          GetMergeList gml = getMergeList.get();
-          gml.setUninterestingParent(getUninterestingParent());
-          gml.setAddLinks(getAddLinks());
-          return gml.apply(revision).value();
-        } catch (Exception e) {
-          throw asRestApiException("Cannot get merge list", e);
-        }
-      }
-    };
-  }
-
-  @Override
-  public void description(String description) throws RestApiException {
-    PutDescription.Input in = new PutDescription.Input();
-    in.description = description;
-    try {
-      putDescription.apply(revision, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot set description", e);
-    }
-  }
-
-  @Override
-  public String description() throws RestApiException {
-    return getDescription.apply(revision);
-  }
-
-  @Override
-  public String etag() throws RestApiException {
-    return revisionActions.getETag(revision);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
deleted file mode 100644
index 60dc1d2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.changes;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
-import com.google.gerrit.extensions.api.changes.RevisionReviewerApi;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.change.DeleteVote;
-import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.change.VoteResource;
-import com.google.gerrit.server.change.Votes;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Map;
-
-public class RevisionReviewerApiImpl implements RevisionReviewerApi {
-  interface Factory {
-    RevisionReviewerApiImpl create(ReviewerResource r);
-  }
-
-  private final ReviewerResource reviewer;
-  private final Votes.List listVotes;
-  private final DeleteVote deleteVote;
-
-  @Inject
-  RevisionReviewerApiImpl(
-      Votes.List listVotes, DeleteVote deleteVote, @Assisted ReviewerResource reviewer) {
-    this.listVotes = listVotes;
-    this.deleteVote = deleteVote;
-    this.reviewer = reviewer;
-  }
-
-  @Override
-  public Map<String, Short> votes() throws RestApiException {
-    try {
-      return listVotes.apply(reviewer);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot list votes", e);
-    }
-  }
-
-  @Override
-  public void deleteVote(String label) throws RestApiException {
-    try {
-      deleteVote.apply(new VoteResource(reviewer, label), null);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete vote", e);
-    }
-  }
-
-  @Override
-  public void deleteVote(DeleteVoteInput input) throws RestApiException {
-    try {
-      deleteVote.apply(new VoteResource(reviewer, input.label), input);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete vote", e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
deleted file mode 100644
index b19939b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.changes;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.extensions.api.changes.RobotCommentApi;
-import com.google.gerrit.extensions.common.RobotCommentInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.change.GetRobotComment;
-import com.google.gerrit.server.change.RobotCommentResource;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-public class RobotCommentApiImpl implements RobotCommentApi {
-  interface Factory {
-    RobotCommentApiImpl create(RobotCommentResource c);
-  }
-
-  private final GetRobotComment getComment;
-  private final RobotCommentResource comment;
-
-  @Inject
-  RobotCommentApiImpl(GetRobotComment getComment, @Assisted RobotCommentResource comment) {
-    this.getComment = getComment;
-    this.comment = comment;
-  }
-
-  @Override
-  public RobotCommentInfo get() throws RestApiException {
-    try {
-      return getComment.apply(comment);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve robot comment", e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
deleted file mode 100644
index 2148d97..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ /dev/null
@@ -1,123 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.config;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.common.Version;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
-import com.google.gerrit.extensions.api.config.Server;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.common.ServerInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.config.CheckConsistency;
-import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.config.GetDiffPreferences;
-import com.google.gerrit.server.config.GetPreferences;
-import com.google.gerrit.server.config.GetServerInfo;
-import com.google.gerrit.server.config.SetDiffPreferences;
-import com.google.gerrit.server.config.SetPreferences;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class ServerImpl implements Server {
-  private final GetPreferences getPreferences;
-  private final SetPreferences setPreferences;
-  private final GetDiffPreferences getDiffPreferences;
-  private final SetDiffPreferences setDiffPreferences;
-  private final GetServerInfo getServerInfo;
-  private final Provider<CheckConsistency> checkConsistency;
-
-  @Inject
-  ServerImpl(
-      GetPreferences getPreferences,
-      SetPreferences setPreferences,
-      GetDiffPreferences getDiffPreferences,
-      SetDiffPreferences setDiffPreferences,
-      GetServerInfo getServerInfo,
-      Provider<CheckConsistency> checkConsistency) {
-    this.getPreferences = getPreferences;
-    this.setPreferences = setPreferences;
-    this.getDiffPreferences = getDiffPreferences;
-    this.setDiffPreferences = setDiffPreferences;
-    this.getServerInfo = getServerInfo;
-    this.checkConsistency = checkConsistency;
-  }
-
-  @Override
-  public String getVersion() throws RestApiException {
-    return Version.getVersion();
-  }
-
-  @Override
-  public ServerInfo getInfo() throws RestApiException {
-    try {
-      return getServerInfo.apply(new ConfigResource());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get server info", e);
-    }
-  }
-
-  @Override
-  public GeneralPreferencesInfo getDefaultPreferences() throws RestApiException {
-    try {
-      return getPreferences.apply(new ConfigResource());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get default general preferences", e);
-    }
-  }
-
-  @Override
-  public GeneralPreferencesInfo setDefaultPreferences(GeneralPreferencesInfo in)
-      throws RestApiException {
-    try {
-      return setPreferences.apply(new ConfigResource(), in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot set default general preferences", e);
-    }
-  }
-
-  @Override
-  public DiffPreferencesInfo getDefaultDiffPreferences() throws RestApiException {
-    try {
-      return getDiffPreferences.apply(new ConfigResource());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get default diff preferences", e);
-    }
-  }
-
-  @Override
-  public DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in)
-      throws RestApiException {
-    try {
-      return setDiffPreferences.apply(new ConfigResource(), in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot set default diff preferences", e);
-    }
-  }
-
-  @Override
-  public ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException {
-    try {
-      return checkConsistency.get().apply(new ConfigResource(), in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot check consistency", e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
deleted file mode 100644
index 42213f7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ /dev/null
@@ -1,277 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.groups;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.extensions.api.groups.GroupApi;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.common.GroupOptionsInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.group.AddMembers;
-import com.google.gerrit.server.group.AddSubgroups;
-import com.google.gerrit.server.group.DeleteMembers;
-import com.google.gerrit.server.group.DeleteSubgroups;
-import com.google.gerrit.server.group.GetAuditLog;
-import com.google.gerrit.server.group.GetDescription;
-import com.google.gerrit.server.group.GetDetail;
-import com.google.gerrit.server.group.GetGroup;
-import com.google.gerrit.server.group.GetName;
-import com.google.gerrit.server.group.GetOptions;
-import com.google.gerrit.server.group.GetOwner;
-import com.google.gerrit.server.group.GroupResource;
-import com.google.gerrit.server.group.Index;
-import com.google.gerrit.server.group.ListMembers;
-import com.google.gerrit.server.group.ListSubgroups;
-import com.google.gerrit.server.group.PutDescription;
-import com.google.gerrit.server.group.PutName;
-import com.google.gerrit.server.group.PutOptions;
-import com.google.gerrit.server.group.PutOwner;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Arrays;
-import java.util.List;
-
-class GroupApiImpl implements GroupApi {
-  interface Factory {
-    GroupApiImpl create(GroupResource rsrc);
-  }
-
-  private final GetGroup getGroup;
-  private final GetDetail getDetail;
-  private final GetName getName;
-  private final PutName putName;
-  private final GetOwner getOwner;
-  private final PutOwner putOwner;
-  private final GetDescription getDescription;
-  private final PutDescription putDescription;
-  private final GetOptions getOptions;
-  private final PutOptions putOptions;
-  private final ListMembers listMembers;
-  private final AddMembers addMembers;
-  private final DeleteMembers deleteMembers;
-  private final ListSubgroups listSubgroups;
-  private final AddSubgroups addSubgroups;
-  private final DeleteSubgroups deleteSubgroups;
-  private final GetAuditLog getAuditLog;
-  private final GroupResource rsrc;
-  private final Index index;
-
-  @Inject
-  GroupApiImpl(
-      GetGroup getGroup,
-      GetDetail getDetail,
-      GetName getName,
-      PutName putName,
-      GetOwner getOwner,
-      PutOwner putOwner,
-      GetDescription getDescription,
-      PutDescription putDescription,
-      GetOptions getOptions,
-      PutOptions putOptions,
-      ListMembers listMembers,
-      AddMembers addMembers,
-      DeleteMembers deleteMembers,
-      ListSubgroups listSubgroups,
-      AddSubgroups addSubgroups,
-      DeleteSubgroups deleteSubgroups,
-      GetAuditLog getAuditLog,
-      Index index,
-      @Assisted GroupResource rsrc) {
-    this.getGroup = getGroup;
-    this.getDetail = getDetail;
-    this.getName = getName;
-    this.putName = putName;
-    this.getOwner = getOwner;
-    this.putOwner = putOwner;
-    this.getDescription = getDescription;
-    this.putDescription = putDescription;
-    this.getOptions = getOptions;
-    this.putOptions = putOptions;
-    this.listMembers = listMembers;
-    this.addMembers = addMembers;
-    this.deleteMembers = deleteMembers;
-    this.listSubgroups = listSubgroups;
-    this.addSubgroups = addSubgroups;
-    this.deleteSubgroups = deleteSubgroups;
-    this.getAuditLog = getAuditLog;
-    this.index = index;
-    this.rsrc = rsrc;
-  }
-
-  @Override
-  public GroupInfo get() throws RestApiException {
-    try {
-      return getGroup.apply(rsrc);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve group", e);
-    }
-  }
-
-  @Override
-  public GroupInfo detail() throws RestApiException {
-    try {
-      return getDetail.apply(rsrc);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve group", e);
-    }
-  }
-
-  @Override
-  public String name() throws RestApiException {
-    return getName.apply(rsrc);
-  }
-
-  @Override
-  public void name(String name) throws RestApiException {
-    PutName.Input in = new PutName.Input();
-    in.name = name;
-    try {
-      putName.apply(rsrc, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot put group name", e);
-    }
-  }
-
-  @Override
-  public GroupInfo owner() throws RestApiException {
-    try {
-      return getOwner.apply(rsrc);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get group owner", e);
-    }
-  }
-
-  @Override
-  public void owner(String owner) throws RestApiException {
-    PutOwner.Input in = new PutOwner.Input();
-    in.owner = owner;
-    try {
-      putOwner.apply(rsrc, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot put group owner", e);
-    }
-  }
-
-  @Override
-  public String description() throws RestApiException {
-    return getDescription.apply(rsrc);
-  }
-
-  @Override
-  public void description(String description) throws RestApiException {
-    PutDescription.Input in = new PutDescription.Input();
-    in.description = description;
-    try {
-      putDescription.apply(rsrc, in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot put group description", e);
-    }
-  }
-
-  @Override
-  public GroupOptionsInfo options() throws RestApiException {
-    return getOptions.apply(rsrc);
-  }
-
-  @Override
-  public void options(GroupOptionsInfo options) throws RestApiException {
-    try {
-      putOptions.apply(rsrc, options);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot put group options", e);
-    }
-  }
-
-  @Override
-  public List<AccountInfo> members() throws RestApiException {
-    return members(false);
-  }
-
-  @Override
-  public List<AccountInfo> members(boolean recursive) throws RestApiException {
-    listMembers.setRecursive(recursive);
-    try {
-      return listMembers.apply(rsrc);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot list group members", e);
-    }
-  }
-
-  @Override
-  public void addMembers(String... members) throws RestApiException {
-    try {
-      addMembers.apply(rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot add group members", e);
-    }
-  }
-
-  @Override
-  public void removeMembers(String... members) throws RestApiException {
-    try {
-      deleteMembers.apply(rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot remove group members", e);
-    }
-  }
-
-  @Override
-  public List<GroupInfo> includedGroups() throws RestApiException {
-    try {
-      return listSubgroups.apply(rsrc);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot list subgroups", e);
-    }
-  }
-
-  @Override
-  public void addGroups(String... groups) throws RestApiException {
-    try {
-      addSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(Arrays.asList(groups)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot add subgroups", e);
-    }
-  }
-
-  @Override
-  public void removeGroups(String... groups) throws RestApiException {
-    try {
-      deleteSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(Arrays.asList(groups)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot remove subgroups", e);
-    }
-  }
-
-  @Override
-  public List<? extends GroupAuditEventInfo> auditLog() throws RestApiException {
-    try {
-      return getAuditLog.apply(rsrc);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get audit log", e);
-    }
-  }
-
-  @Override
-  public void index() throws RestApiException {
-    try {
-      index.apply(rsrc, new Index.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
deleted file mode 100644
index e1e72ba..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
+++ /dev/null
@@ -1,184 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.groups;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.extensions.api.groups.GroupApi;
-import com.google.gerrit.extensions.api.groups.GroupInput;
-import com.google.gerrit.extensions.api.groups.Groups;
-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.IdString;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountsCollection;
-import com.google.gerrit.server.group.CreateGroup;
-import com.google.gerrit.server.group.GroupsCollection;
-import com.google.gerrit.server.group.ListGroups;
-import com.google.gerrit.server.group.QueryGroups;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.project.ProjectsCollection;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.List;
-import java.util.SortedMap;
-
-@Singleton
-class GroupsImpl implements Groups {
-  private final AccountsCollection accounts;
-  private final GroupsCollection groups;
-  private final ProjectsCollection projects;
-  private final Provider<ListGroups> listGroups;
-  private final Provider<QueryGroups> queryGroups;
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final CreateGroup.Factory createGroup;
-  private final GroupApiImpl.Factory api;
-
-  @Inject
-  GroupsImpl(
-      AccountsCollection accounts,
-      GroupsCollection groups,
-      ProjectsCollection projects,
-      Provider<ListGroups> listGroups,
-      Provider<QueryGroups> queryGroups,
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      CreateGroup.Factory createGroup,
-      GroupApiImpl.Factory api) {
-    this.accounts = accounts;
-    this.groups = groups;
-    this.projects = projects;
-    this.listGroups = listGroups;
-    this.queryGroups = queryGroups;
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.createGroup = createGroup;
-    this.api = api;
-  }
-
-  @Override
-  public GroupApi id(String id) throws RestApiException {
-    return api.create(groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(id)));
-  }
-
-  @Override
-  public GroupApi create(String name) throws RestApiException {
-    GroupInput in = new GroupInput();
-    in.name = name;
-    return create(in);
-  }
-
-  @Override
-  public GroupApi create(GroupInput in) throws RestApiException {
-    if (checkNotNull(in, "GroupInput").name == null) {
-      throw new BadRequestException("GroupInput must specify name");
-    }
-    try {
-      CreateGroup impl = createGroup.create(in.name);
-      permissionBackend.user(user).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
-      GroupInfo info = impl.apply(TopLevelResource.INSTANCE, in);
-      return id(info.id);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot create group " + in.name, e);
-    }
-  }
-
-  @Override
-  public ListRequest list() {
-    return new ListRequest() {
-      @Override
-      public SortedMap<String, GroupInfo> getAsMap() throws RestApiException {
-        return list(this);
-      }
-    };
-  }
-
-  private SortedMap<String, GroupInfo> list(ListRequest req) throws RestApiException {
-    TopLevelResource tlr = TopLevelResource.INSTANCE;
-    ListGroups list = listGroups.get();
-    list.setOptions(req.getOptions());
-
-    for (String project : req.getProjects()) {
-      try {
-        list.addProject(projects.parse(tlr, IdString.fromDecoded(project)).getControl());
-      } catch (Exception e) {
-        throw asRestApiException("Error looking up project " + project, e);
-      }
-    }
-
-    for (String group : req.getGroups()) {
-      list.addGroup(groups.parse(group).getGroupUUID());
-    }
-
-    list.setVisibleToAll(req.getVisibleToAll());
-
-    if (req.getUser() != null) {
-      try {
-        list.setUser(accounts.parse(req.getUser()).getAccountId());
-      } catch (Exception e) {
-        throw asRestApiException("Error looking up user " + req.getUser(), e);
-      }
-    }
-
-    list.setOwned(req.getOwned());
-    list.setLimit(req.getLimit());
-    list.setStart(req.getStart());
-    list.setMatchSubstring(req.getSubstring());
-    list.setMatchRegex(req.getRegex());
-    list.setSuggest(req.getSuggest());
-    try {
-      return list.apply(tlr);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot list groups", e);
-    }
-  }
-
-  @Override
-  public QueryRequest query() {
-    return new QueryRequest() {
-      @Override
-      public List<GroupInfo> get() throws RestApiException {
-        return GroupsImpl.this.query(this);
-      }
-    };
-  }
-
-  @Override
-  public QueryRequest query(String query) {
-    return query().withQuery(query);
-  }
-
-  private List<GroupInfo> query(QueryRequest r) throws RestApiException {
-    try {
-      QueryGroups myQueryGroups = queryGroups.get();
-      myQueryGroups.setQuery(r.getQuery());
-      myQueryGroups.setLimit(r.getLimit());
-      myQueryGroups.setStart(r.getStart());
-      for (ListGroupsOption option : r.getOptions()) {
-        myQueryGroups.addOption(option);
-      }
-      return myQueryGroups.apply(TopLevelResource.INSTANCE);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot query groups", e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
deleted file mode 100644
index 2fc2e50..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.plugins;
-
-import com.google.gerrit.extensions.api.plugins.PluginApi;
-import com.google.gerrit.extensions.common.PluginInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.plugins.DisablePlugin;
-import com.google.gerrit.server.plugins.EnablePlugin;
-import com.google.gerrit.server.plugins.GetStatus;
-import com.google.gerrit.server.plugins.PluginResource;
-import com.google.gerrit.server.plugins.ReloadPlugin;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-public class PluginApiImpl implements PluginApi {
-  public interface Factory {
-    PluginApiImpl create(PluginResource resource);
-  }
-
-  private final GetStatus getStatus;
-  private final EnablePlugin enable;
-  private final DisablePlugin disable;
-  private final ReloadPlugin reload;
-  private final PluginResource resource;
-
-  @Inject
-  PluginApiImpl(
-      GetStatus getStatus,
-      EnablePlugin enable,
-      DisablePlugin disable,
-      ReloadPlugin reload,
-      @Assisted PluginResource resource) {
-    this.getStatus = getStatus;
-    this.enable = enable;
-    this.disable = disable;
-    this.reload = reload;
-    this.resource = resource;
-  }
-
-  @Override
-  public PluginInfo get() throws RestApiException {
-    return getStatus.apply(resource);
-  }
-
-  @Override
-  public void enable() throws RestApiException {
-    enable.apply(resource, new EnablePlugin.Input());
-  }
-
-  @Override
-  public void disable() throws RestApiException {
-    disable.apply(resource, new DisablePlugin.Input());
-  }
-
-  @Override
-  public void reload() throws RestApiException {
-    reload.apply(resource, new ReloadPlugin.Input());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
deleted file mode 100644
index 642791a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ /dev/null
@@ -1,130 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.projects;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.extensions.api.projects.BranchApi;
-import com.google.gerrit.extensions.api.projects.BranchInfo;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.BranchResource;
-import com.google.gerrit.server.project.BranchesCollection;
-import com.google.gerrit.server.project.CreateBranch;
-import com.google.gerrit.server.project.DeleteBranch;
-import com.google.gerrit.server.project.FileResource;
-import com.google.gerrit.server.project.FilesCollection;
-import com.google.gerrit.server.project.GetBranch;
-import com.google.gerrit.server.project.GetContent;
-import com.google.gerrit.server.project.GetReflog;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.List;
-
-public class BranchApiImpl implements BranchApi {
-  interface Factory {
-    BranchApiImpl create(ProjectResource project, String ref);
-  }
-
-  private final BranchesCollection branches;
-  private final CreateBranch.Factory createBranchFactory;
-  private final DeleteBranch deleteBranch;
-  private final FilesCollection filesCollection;
-  private final GetBranch getBranch;
-  private final GetContent getContent;
-  private final GetReflog getReflog;
-  private final String ref;
-  private final ProjectResource project;
-
-  @Inject
-  BranchApiImpl(
-      BranchesCollection branches,
-      CreateBranch.Factory createBranchFactory,
-      DeleteBranch deleteBranch,
-      FilesCollection filesCollection,
-      GetBranch getBranch,
-      GetContent getContent,
-      GetReflog getReflog,
-      @Assisted ProjectResource project,
-      @Assisted String ref) {
-    this.branches = branches;
-    this.createBranchFactory = createBranchFactory;
-    this.deleteBranch = deleteBranch;
-    this.filesCollection = filesCollection;
-    this.getBranch = getBranch;
-    this.getContent = getContent;
-    this.getReflog = getReflog;
-    this.project = project;
-    this.ref = ref;
-  }
-
-  @Override
-  public BranchApi create(BranchInput input) throws RestApiException {
-    try {
-      createBranchFactory.create(ref).apply(project, input);
-      return this;
-    } catch (Exception e) {
-      throw asRestApiException("Cannot create branch", e);
-    }
-  }
-
-  @Override
-  public BranchInfo get() throws RestApiException {
-    try {
-      return getBranch.apply(resource());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot read branch", e);
-    }
-  }
-
-  @Override
-  public void delete() throws RestApiException {
-    try {
-      deleteBranch.apply(resource(), new DeleteBranch.Input());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete branch", e);
-    }
-  }
-
-  @Override
-  public BinaryResult file(String path) throws RestApiException {
-    try {
-      FileResource resource = filesCollection.parse(resource(), IdString.fromDecoded(path));
-      return getContent.apply(resource);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve file", e);
-    }
-  }
-
-  @Override
-  public List<ReflogEntryInfo> reflog() throws RestApiException {
-    try {
-      return getReflog.apply(resource());
-    } catch (IOException | PermissionBackendException e) {
-      throw new RestApiException("Cannot retrieve reflog", e);
-    }
-  }
-
-  private BranchResource resource()
-      throws RestApiException, IOException, PermissionBackendException {
-    return branches.parse(project, IdString.fromDecoded(ref));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
deleted file mode 100644
index 1595682..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.projects;
-
-import com.google.gerrit.extensions.api.projects.ChildProjectApi;
-import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.project.ChildProjectResource;
-import com.google.gerrit.server.project.GetChildProject;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-public class ChildProjectApiImpl implements ChildProjectApi {
-  interface Factory {
-    ChildProjectApiImpl create(ChildProjectResource rsrc);
-  }
-
-  private final GetChildProject getChildProject;
-  private final ChildProjectResource rsrc;
-
-  @Inject
-  ChildProjectApiImpl(GetChildProject getChildProject, @Assisted ChildProjectResource rsrc) {
-    this.getChildProject = getChildProject;
-    this.rsrc = rsrc;
-  }
-
-  @Override
-  public ProjectInfo get() throws RestApiException {
-    return get(false);
-  }
-
-  @Override
-  public ProjectInfo get(boolean recursive) throws RestApiException {
-    getChildProject.setRecursive(recursive);
-    return getChildProject.apply(rsrc);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
deleted file mode 100644
index cbdd03d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.projects;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.extensions.api.changes.ChangeApi;
-import com.google.gerrit.extensions.api.changes.Changes;
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.api.projects.CommitApi;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.change.CherryPickCommit;
-import com.google.gerrit.server.project.CommitResource;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-public class CommitApiImpl implements CommitApi {
-  public interface Factory {
-    CommitApiImpl create(CommitResource r);
-  }
-
-  private final Changes changes;
-  private final CherryPickCommit cherryPickCommit;
-  private final CommitResource commitResource;
-
-  @Inject
-  CommitApiImpl(
-      Changes changes, CherryPickCommit cherryPickCommit, @Assisted CommitResource commitResource) {
-    this.changes = changes;
-    this.cherryPickCommit = cherryPickCommit;
-    this.commitResource = commitResource;
-  }
-
-  @Override
-  public ChangeApi cherryPick(CherryPickInput input) throws RestApiException {
-    try {
-      return changes.id(cherryPickCommit.apply(commitResource, input)._number);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot cherry pick", e);
-    }
-  }
-}
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
deleted file mode 100644
index 0d4afd6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
+++ /dev/null
@@ -1,94 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.projects;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.api.projects.DashboardApi;
-import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.common.SetDashboardInput;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.DashboardResource;
-import com.google.gerrit.server.project.DashboardsCollection;
-import com.google.gerrit.server.project.GetDashboard;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.project.SetDashboard;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-public class DashboardApiImpl implements DashboardApi {
-  interface Factory {
-    DashboardApiImpl create(ProjectResource project, String id);
-  }
-
-  private final DashboardsCollection dashboards;
-  private final Provider<GetDashboard> get;
-  private final SetDashboard set;
-  private final ProjectResource project;
-  private final String id;
-
-  @Inject
-  DashboardApiImpl(
-      DashboardsCollection dashboards,
-      Provider<GetDashboard> get,
-      SetDashboard set,
-      @Assisted ProjectResource project,
-      @Assisted @Nullable String id) {
-    this.dashboards = dashboards;
-    this.get = get;
-    this.set = set;
-    this.project = project;
-    this.id = id;
-  }
-
-  @Override
-  public DashboardInfo get() throws RestApiException {
-    return get(false);
-  }
-
-  @Override
-  public DashboardInfo get(boolean inherited) throws RestApiException {
-    try {
-      return get.get().setInherited(inherited).apply(resource());
-    } catch (IOException | PermissionBackendException | ConfigInvalidException e) {
-      throw asRestApiException("Cannot read dashboard", e);
-    }
-  }
-
-  @Override
-  public void setDefault() throws RestApiException {
-    SetDashboardInput input = new SetDashboardInput();
-    input.id = id;
-    try {
-      set.apply(DashboardResource.projectDefault(project.getControl()), input);
-    } catch (Exception e) {
-      String msg = String.format("Cannot %s default dashboard", id != null ? "set" : "remove");
-      throw asRestApiException(msg, e);
-    }
-  }
-
-  private DashboardResource resource()
-      throws ResourceNotFoundException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    return dashboards.parse(project, IdString.fromDecoded(id));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
deleted file mode 100644
index 9fd4d48..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ /dev/null
@@ -1,533 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.projects;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-import static com.google.gerrit.server.project.DashboardsCollection.DEFAULT_DASHBOARD_NAME;
-import static java.util.stream.Collectors.toList;
-
-import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
-import com.google.gerrit.extensions.api.access.ProjectAccessInput;
-import com.google.gerrit.extensions.api.config.AccessCheckInfo;
-import com.google.gerrit.extensions.api.config.AccessCheckInput;
-import com.google.gerrit.extensions.api.projects.BranchApi;
-import com.google.gerrit.extensions.api.projects.BranchInfo;
-import com.google.gerrit.extensions.api.projects.ChildProjectApi;
-import com.google.gerrit.extensions.api.projects.CommitApi;
-import com.google.gerrit.extensions.api.projects.ConfigInfo;
-import com.google.gerrit.extensions.api.projects.ConfigInput;
-import com.google.gerrit.extensions.api.projects.DashboardApi;
-import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
-import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
-import com.google.gerrit.extensions.api.projects.DescriptionInput;
-import com.google.gerrit.extensions.api.projects.ProjectApi;
-import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.api.projects.TagApi;
-import com.google.gerrit.extensions.api.projects.TagInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.project.CheckAccess;
-import com.google.gerrit.server.project.ChildProjectsCollection;
-import com.google.gerrit.server.project.CommitsCollection;
-import com.google.gerrit.server.project.CreateAccessChange;
-import com.google.gerrit.server.project.CreateProject;
-import com.google.gerrit.server.project.DeleteBranches;
-import com.google.gerrit.server.project.DeleteTags;
-import com.google.gerrit.server.project.GetAccess;
-import com.google.gerrit.server.project.GetConfig;
-import com.google.gerrit.server.project.GetDescription;
-import com.google.gerrit.server.project.ListBranches;
-import com.google.gerrit.server.project.ListChildProjects;
-import com.google.gerrit.server.project.ListDashboards;
-import com.google.gerrit.server.project.ListTags;
-import com.google.gerrit.server.project.ProjectJson;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.project.ProjectsCollection;
-import com.google.gerrit.server.project.PutConfig;
-import com.google.gerrit.server.project.PutDescription;
-import com.google.gerrit.server.project.SetAccess;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.util.Collections;
-import java.util.List;
-
-public class ProjectApiImpl implements ProjectApi {
-  interface Factory {
-    ProjectApiImpl create(ProjectResource project);
-
-    ProjectApiImpl create(String name);
-  }
-
-  private final CurrentUser user;
-  private final PermissionBackend permissionBackend;
-  private final CreateProject.Factory createProjectFactory;
-  private final ProjectApiImpl.Factory projectApi;
-  private final ProjectsCollection projects;
-  private final GetDescription getDescription;
-  private final PutDescription putDescription;
-  private final ChildProjectApiImpl.Factory childApi;
-  private final ChildProjectsCollection children;
-  private final ProjectResource project;
-  private final ProjectJson projectJson;
-  private final String name;
-  private final BranchApiImpl.Factory branchApi;
-  private final TagApiImpl.Factory tagApi;
-  private final GetAccess getAccess;
-  private final SetAccess setAccess;
-  private final CreateAccessChange createAccessChange;
-  private final GetConfig getConfig;
-  private final PutConfig putConfig;
-  private final Provider<ListBranches> listBranches;
-  private final Provider<ListTags> listTags;
-  private final DeleteBranches deleteBranches;
-  private final DeleteTags deleteTags;
-  private final CommitsCollection commitsCollection;
-  private final CommitApiImpl.Factory commitApi;
-  private final DashboardApiImpl.Factory dashboardApi;
-  private final CheckAccess checkAccess;
-  private final Provider<ListDashboards> listDashboards;
-
-  @AssistedInject
-  ProjectApiImpl(
-      CurrentUser user,
-      PermissionBackend permissionBackend,
-      CreateProject.Factory createProjectFactory,
-      ProjectApiImpl.Factory projectApi,
-      ProjectsCollection projects,
-      GetDescription getDescription,
-      PutDescription putDescription,
-      ChildProjectApiImpl.Factory childApi,
-      ChildProjectsCollection children,
-      ProjectJson projectJson,
-      BranchApiImpl.Factory branchApiFactory,
-      TagApiImpl.Factory tagApiFactory,
-      GetAccess getAccess,
-      SetAccess setAccess,
-      CreateAccessChange createAccessChange,
-      GetConfig getConfig,
-      PutConfig putConfig,
-      Provider<ListBranches> listBranches,
-      Provider<ListTags> listTags,
-      DeleteBranches deleteBranches,
-      DeleteTags deleteTags,
-      CommitsCollection commitsCollection,
-      CommitApiImpl.Factory commitApi,
-      DashboardApiImpl.Factory dashboardApi,
-      CheckAccess checkAccess,
-      Provider<ListDashboards> listDashboards,
-      @Assisted ProjectResource project) {
-    this(
-        user,
-        permissionBackend,
-        createProjectFactory,
-        projectApi,
-        projects,
-        getDescription,
-        putDescription,
-        childApi,
-        children,
-        projectJson,
-        branchApiFactory,
-        tagApiFactory,
-        getAccess,
-        setAccess,
-        createAccessChange,
-        getConfig,
-        putConfig,
-        listBranches,
-        listTags,
-        deleteBranches,
-        deleteTags,
-        project,
-        commitsCollection,
-        commitApi,
-        dashboardApi,
-        checkAccess,
-        listDashboards,
-        null);
-  }
-
-  @AssistedInject
-  ProjectApiImpl(
-      CurrentUser user,
-      PermissionBackend permissionBackend,
-      CreateProject.Factory createProjectFactory,
-      ProjectApiImpl.Factory projectApi,
-      ProjectsCollection projects,
-      GetDescription getDescription,
-      PutDescription putDescription,
-      ChildProjectApiImpl.Factory childApi,
-      ChildProjectsCollection children,
-      ProjectJson projectJson,
-      BranchApiImpl.Factory branchApiFactory,
-      TagApiImpl.Factory tagApiFactory,
-      GetAccess getAccess,
-      SetAccess setAccess,
-      CreateAccessChange createAccessChange,
-      GetConfig getConfig,
-      PutConfig putConfig,
-      Provider<ListBranches> listBranches,
-      Provider<ListTags> listTags,
-      DeleteBranches deleteBranches,
-      DeleteTags deleteTags,
-      CommitsCollection commitsCollection,
-      CommitApiImpl.Factory commitApi,
-      DashboardApiImpl.Factory dashboardApi,
-      CheckAccess checkAccess,
-      Provider<ListDashboards> listDashboards,
-      @Assisted String name) {
-    this(
-        user,
-        permissionBackend,
-        createProjectFactory,
-        projectApi,
-        projects,
-        getDescription,
-        putDescription,
-        childApi,
-        children,
-        projectJson,
-        branchApiFactory,
-        tagApiFactory,
-        getAccess,
-        setAccess,
-        createAccessChange,
-        getConfig,
-        putConfig,
-        listBranches,
-        listTags,
-        deleteBranches,
-        deleteTags,
-        null,
-        commitsCollection,
-        commitApi,
-        dashboardApi,
-        checkAccess,
-        listDashboards,
-        name);
-  }
-
-  private ProjectApiImpl(
-      CurrentUser user,
-      PermissionBackend permissionBackend,
-      CreateProject.Factory createProjectFactory,
-      ProjectApiImpl.Factory projectApi,
-      ProjectsCollection projects,
-      GetDescription getDescription,
-      PutDescription putDescription,
-      ChildProjectApiImpl.Factory childApi,
-      ChildProjectsCollection children,
-      ProjectJson projectJson,
-      BranchApiImpl.Factory branchApiFactory,
-      TagApiImpl.Factory tagApiFactory,
-      GetAccess getAccess,
-      SetAccess setAccess,
-      CreateAccessChange createAccessChange,
-      GetConfig getConfig,
-      PutConfig putConfig,
-      Provider<ListBranches> listBranches,
-      Provider<ListTags> listTags,
-      DeleteBranches deleteBranches,
-      DeleteTags deleteTags,
-      ProjectResource project,
-      CommitsCollection commitsCollection,
-      CommitApiImpl.Factory commitApi,
-      DashboardApiImpl.Factory dashboardApi,
-      CheckAccess checkAccess,
-      Provider<ListDashboards> listDashboards,
-      String name) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.createProjectFactory = createProjectFactory;
-    this.projectApi = projectApi;
-    this.projects = projects;
-    this.getDescription = getDescription;
-    this.putDescription = putDescription;
-    this.childApi = childApi;
-    this.children = children;
-    this.projectJson = projectJson;
-    this.project = project;
-    this.branchApi = branchApiFactory;
-    this.tagApi = tagApiFactory;
-    this.getAccess = getAccess;
-    this.setAccess = setAccess;
-    this.getConfig = getConfig;
-    this.putConfig = putConfig;
-    this.listBranches = listBranches;
-    this.listTags = listTags;
-    this.deleteBranches = deleteBranches;
-    this.deleteTags = deleteTags;
-    this.commitsCollection = commitsCollection;
-    this.commitApi = commitApi;
-    this.createAccessChange = createAccessChange;
-    this.dashboardApi = dashboardApi;
-    this.checkAccess = checkAccess;
-    this.listDashboards = listDashboards;
-    this.name = name;
-  }
-
-  @Override
-  public ProjectApi create() throws RestApiException {
-    return create(new ProjectInput());
-  }
-
-  @Override
-  public ProjectApi create(ProjectInput in) throws RestApiException {
-    try {
-      if (name == null) {
-        throw new ResourceConflictException("Project already exists");
-      }
-      if (in.name != null && !name.equals(in.name)) {
-        throw new BadRequestException("name must match input.name");
-      }
-      CreateProject impl = createProjectFactory.create(name);
-      permissionBackend.user(user).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
-      impl.apply(TopLevelResource.INSTANCE, in);
-      return projectApi.create(projects.parse(name));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot create project: " + e.getMessage(), e);
-    }
-  }
-
-  @Override
-  public ProjectInfo get() throws RestApiException {
-    if (project == null) {
-      throw new ResourceNotFoundException(name);
-    }
-    return projectJson.format(project.getProjectState());
-  }
-
-  @Override
-  public String description() throws RestApiException {
-    return getDescription.apply(checkExists());
-  }
-
-  @Override
-  public ProjectAccessInfo access() throws RestApiException {
-    try {
-      return getAccess.apply(checkExists());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get access rights", e);
-    }
-  }
-
-  @Override
-  public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
-    try {
-      return checkAccess.apply(checkExists(), in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot check access rights", e);
-    }
-  }
-
-  @Override
-  public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
-    try {
-      return setAccess.apply(checkExists(), p);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot put access rights", e);
-    }
-  }
-
-  @Override
-  public ChangeInfo accessChange(ProjectAccessInput p) throws RestApiException {
-    try {
-      return createAccessChange.apply(checkExists(), p).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot put access right change", e);
-    }
-  }
-
-  @Override
-  public void description(DescriptionInput in) throws RestApiException {
-    try {
-      putDescription.apply(checkExists(), in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot put project description", e);
-    }
-  }
-
-  @Override
-  public ConfigInfo config() throws RestApiException {
-    return getConfig.apply(checkExists());
-  }
-
-  @Override
-  public ConfigInfo config(ConfigInput in) throws RestApiException {
-    return putConfig.apply(checkExists(), in);
-  }
-
-  @Override
-  public ListRefsRequest<BranchInfo> branches() {
-    return new ListRefsRequest<BranchInfo>() {
-      @Override
-      public List<BranchInfo> get() throws RestApiException {
-        try {
-          return listBranches.get().request(this).apply(checkExists());
-        } catch (Exception e) {
-          throw asRestApiException("Cannot list branches", e);
-        }
-      }
-    };
-  }
-
-  @Override
-  public ListRefsRequest<TagInfo> tags() {
-    return new ListRefsRequest<TagInfo>() {
-      @Override
-      public List<TagInfo> get() throws RestApiException {
-        try {
-          return listTags.get().request(this).apply(checkExists());
-        } catch (Exception e) {
-          throw asRestApiException("Cannot list tags", e);
-        }
-      }
-    };
-  }
-
-  @Override
-  public List<ProjectInfo> children() throws RestApiException {
-    return children(false);
-  }
-
-  @Override
-  public List<ProjectInfo> children(boolean recursive) throws RestApiException {
-    ListChildProjects list = children.list();
-    list.setRecursive(recursive);
-    try {
-      return list.apply(checkExists());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot list children", e);
-    }
-  }
-
-  @Override
-  public ChildProjectApi child(String name) throws RestApiException {
-    try {
-      return childApi.create(children.parse(checkExists(), IdString.fromDecoded(name)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot parse child project", e);
-    }
-  }
-
-  @Override
-  public BranchApi branch(String ref) throws ResourceNotFoundException {
-    return branchApi.create(checkExists(), ref);
-  }
-
-  @Override
-  public TagApi tag(String ref) throws ResourceNotFoundException {
-    return tagApi.create(checkExists(), ref);
-  }
-
-  @Override
-  public void deleteBranches(DeleteBranchesInput in) throws RestApiException {
-    try {
-      deleteBranches.apply(checkExists(), in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete branches", e);
-    }
-  }
-
-  @Override
-  public void deleteTags(DeleteTagsInput in) throws RestApiException {
-    try {
-      deleteTags.apply(checkExists(), in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete tags", e);
-    }
-  }
-
-  @Override
-  public CommitApi commit(String commit) throws RestApiException {
-    try {
-      return commitApi.create(commitsCollection.parse(checkExists(), IdString.fromDecoded(commit)));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot parse commit", e);
-    }
-  }
-
-  @Override
-  public DashboardApi dashboard(String name) throws RestApiException {
-    try {
-      return dashboardApi.create(checkExists(), name);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot parse dashboard", e);
-    }
-  }
-
-  @Override
-  public DashboardApi defaultDashboard() throws RestApiException {
-    return dashboard(DEFAULT_DASHBOARD_NAME);
-  }
-
-  @Override
-  public void defaultDashboard(String name) throws RestApiException {
-    try {
-      dashboardApi.create(checkExists(), name).setDefault();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot set default dashboard", e);
-    }
-  }
-
-  @Override
-  public void removeDefaultDashboard() throws RestApiException {
-    try {
-      dashboardApi.create(checkExists(), null).setDefault();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot remove default dashboard", e);
-    }
-  }
-
-  @Override
-  public ListDashboardsRequest dashboards() throws RestApiException {
-    return new ListDashboardsRequest() {
-      @Override
-      public List<DashboardInfo> get() throws RestApiException {
-        try {
-          List<?> r = listDashboards.get().apply(checkExists());
-          if (r.isEmpty()) {
-            return Collections.emptyList();
-          }
-          if (r.get(0) instanceof DashboardInfo) {
-            return r.stream().map(i -> (DashboardInfo) i).collect(toList());
-          }
-          throw new NotImplementedException("list with inheritance");
-        } catch (Exception e) {
-          throw asRestApiException("Cannot list dashboards", e);
-        }
-      }
-    };
-  }
-
-  private ProjectResource checkExists() throws ResourceNotFoundException {
-    if (project == null) {
-      throw new ResourceNotFoundException(name);
-    }
-    return project;
-  }
-}
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
deleted file mode 100644
index 702a7e9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.projects;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.extensions.api.projects.ProjectApi;
-import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.api.projects.Projects;
-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.UnprocessableEntityException;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ListProjects;
-import com.google.gerrit.server.project.ListProjects.FilterType;
-import com.google.gerrit.server.project.ProjectsCollection;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.SortedMap;
-
-@Singleton
-class ProjectsImpl implements Projects {
-  private final ProjectsCollection projects;
-  private final ProjectApiImpl.Factory api;
-  private final Provider<ListProjects> listProvider;
-
-  @Inject
-  ProjectsImpl(
-      ProjectsCollection projects,
-      ProjectApiImpl.Factory api,
-      Provider<ListProjects> listProvider) {
-    this.projects = projects;
-    this.api = api;
-    this.listProvider = listProvider;
-  }
-
-  @Override
-  public ProjectApi name(String name) throws RestApiException {
-    try {
-      return api.create(projects.parse(name));
-    } catch (UnprocessableEntityException e) {
-      return api.create(name);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve project", e);
-    }
-  }
-
-  @Override
-  public ProjectApi create(String name) throws RestApiException {
-    ProjectInput in = new ProjectInput();
-    in.name = name;
-    return create(in);
-  }
-
-  @Override
-  public ProjectApi create(ProjectInput in) throws RestApiException {
-    if (in.name == null) {
-      throw new BadRequestException("input.name is required");
-    }
-    return name(in.name).create(in);
-  }
-
-  @Override
-  public ListRequest list() {
-    return new ListRequest() {
-      @Override
-      public SortedMap<String, ProjectInfo> getAsMap() throws RestApiException {
-        try {
-          return list(this);
-        } catch (Exception e) {
-          throw asRestApiException("project list unavailable", e);
-        }
-      }
-    };
-  }
-
-  private SortedMap<String, ProjectInfo> list(ListRequest request)
-      throws RestApiException, PermissionBackendException {
-    ListProjects lp = listProvider.get();
-    lp.setShowDescription(request.getDescription());
-    lp.setLimit(request.getLimit());
-    lp.setStart(request.getStart());
-    lp.setMatchPrefix(request.getPrefix());
-
-    lp.setMatchSubstring(request.getSubstring());
-    lp.setMatchRegex(request.getRegex());
-    lp.setShowTree(request.getShowTree());
-    for (String branch : request.getBranches()) {
-      lp.addShowBranch(branch);
-    }
-
-    FilterType type;
-    switch (request.getFilterType()) {
-      case ALL:
-        type = FilterType.ALL;
-        break;
-      case CODE:
-        type = FilterType.CODE;
-        break;
-      case PARENT_CANDIDATES:
-        type = FilterType.PARENT_CANDIDATES;
-        break;
-      case PERMISSIONS:
-        type = FilterType.PERMISSIONS;
-        break;
-      default:
-        throw new BadRequestException("Unknown filter type: " + request.getFilterType());
-    }
-    lp.setFilterType(type);
-
-    return lp.apply();
-  }
-}
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
deleted file mode 100644
index 283d117..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.api.projects;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
-import com.google.gerrit.extensions.api.projects.TagApi;
-import com.google.gerrit.extensions.api.projects.TagInfo;
-import com.google.gerrit.extensions.api.projects.TagInput;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.project.CreateTag;
-import com.google.gerrit.server.project.DeleteTag;
-import com.google.gerrit.server.project.ListTags;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.project.TagResource;
-import com.google.gerrit.server.project.TagsCollection;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-
-public class TagApiImpl implements TagApi {
-  interface Factory {
-    TagApiImpl create(ProjectResource project, String ref);
-  }
-
-  private final ListTags listTags;
-  private final CreateTag.Factory createTagFactory;
-  private final DeleteTag deleteTag;
-  private final TagsCollection tags;
-  private final String ref;
-  private final ProjectResource project;
-
-  @Inject
-  TagApiImpl(
-      ListTags listTags,
-      CreateTag.Factory createTagFactory,
-      DeleteTag deleteTag,
-      TagsCollection tags,
-      @Assisted ProjectResource project,
-      @Assisted String ref) {
-    this.listTags = listTags;
-    this.createTagFactory = createTagFactory;
-    this.deleteTag = deleteTag;
-    this.tags = tags;
-    this.project = project;
-    this.ref = ref;
-  }
-
-  @Override
-  public TagApi create(TagInput input) throws RestApiException {
-    try {
-      createTagFactory.create(ref).apply(project, input);
-      return this;
-    } catch (Exception e) {
-      throw asRestApiException("Cannot create tag", e);
-    }
-  }
-
-  @Override
-  public TagInfo get() throws RestApiException {
-    try {
-      return listTags.get(project, IdString.fromDecoded(ref));
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get tag", e);
-    }
-  }
-
-  @Override
-  public void delete() throws RestApiException {
-    try {
-      deleteTag.apply(resource(), new DeleteTag.Input());
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete tag", e);
-    }
-  }
-
-  private TagResource resource() throws RestApiException, IOException {
-    return tags.parse(project, IdString.fromDecoded(ref));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
deleted file mode 100644
index 1823527..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
+++ /dev/null
@@ -1,101 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.args4j;
-
-import com.google.gerrit.common.ProjectUtil;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import org.kohsuke.args4j.CmdLineException;
-import org.kohsuke.args4j.CmdLineParser;
-import org.kohsuke.args4j.OptionDef;
-import org.kohsuke.args4j.spi.OptionHandler;
-import org.kohsuke.args4j.spi.Parameters;
-import org.kohsuke.args4j.spi.Setter;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ProjectControlHandler extends OptionHandler<ProjectControl> {
-  private static final Logger log = LoggerFactory.getLogger(ProjectControlHandler.class);
-
-  private final ProjectControl.GenericFactory projectControlFactory;
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> user;
-
-  @Inject
-  public ProjectControlHandler(
-      ProjectControl.GenericFactory projectControlFactory,
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> user,
-      @Assisted final CmdLineParser parser,
-      @Assisted final OptionDef option,
-      @Assisted final Setter<ProjectControl> setter) {
-    super(parser, option, setter);
-    this.projectControlFactory = projectControlFactory;
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-  }
-
-  @Override
-  public final int parseArguments(Parameters params) throws CmdLineException {
-    String projectName = params.getParameter(0);
-
-    while (projectName.endsWith("/")) {
-      projectName = projectName.substring(0, projectName.length() - 1);
-    }
-
-    while (projectName.startsWith("/")) {
-      // Be nice and drop the leading "/" if supplied by an absolute path.
-      // We don't have a file system hierarchy, just a flat namespace in
-      // the database's Project entities. We never encode these with a
-      // leading '/' but users might accidentally include them in Git URLs.
-      //
-      projectName = projectName.substring(1);
-    }
-
-    String nameWithoutSuffix = ProjectUtil.stripGitSuffix(projectName);
-    Project.NameKey nameKey = new Project.NameKey(nameWithoutSuffix);
-
-    ProjectControl control;
-    try {
-      control = projectControlFactory.controlFor(nameKey, user.get());
-      permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
-    } catch (AuthException e) {
-      throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
-    } catch (NoSuchProjectException e) {
-      throw new CmdLineException(owner, e.getMessage());
-    } catch (PermissionBackendException | IOException e) {
-      log.warn("Cannot load project " + nameWithoutSuffix, e);
-      throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
-    }
-
-    setter.addValue(control);
-    return 1;
-  }
-
-  @Override
-  public final String getDefaultMetaVariable() {
-    return "PROJECT";
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
deleted file mode 100644
index c9d016d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
+++ /dev/null
@@ -1,210 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.AbandonInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.AbandonOp;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class Abandon extends RetryingRestModifyView<ChangeResource, AbandonInput, ChangeInfo>
-    implements UiAction<ChangeResource> {
-  private final Provider<ReviewDb> dbProvider;
-  private final ChangeJson.Factory json;
-  private final AbandonOp.Factory abandonOpFactory;
-  private final NotifyUtil notifyUtil;
-
-  @Inject
-  Abandon(
-      Provider<ReviewDb> dbProvider,
-      ChangeJson.Factory json,
-      RetryHelper retryHelper,
-      AbandonOp.Factory abandonOpFactory,
-      NotifyUtil notifyUtil) {
-    super(retryHelper);
-    this.dbProvider = dbProvider;
-    this.json = json;
-    this.abandonOpFactory = abandonOpFactory;
-    this.notifyUtil = notifyUtil;
-  }
-
-  @Override
-  protected ChangeInfo applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource req, AbandonInput input)
-      throws RestApiException, UpdateException, OrmException, PermissionBackendException,
-          IOException, ConfigInvalidException {
-    req.permissions().database(dbProvider).check(ChangePermission.ABANDON);
-
-    NotifyHandling notify = input.notify == null ? defaultNotify(req.getChange()) : input.notify;
-    Change change =
-        abandon(
-            updateFactory,
-            req.getNotes(),
-            req.getUser(),
-            input.message,
-            notify,
-            notifyUtil.resolveAccounts(input.notifyDetails));
-    return json.noOptions().format(change);
-  }
-
-  private NotifyHandling defaultNotify(Change change) {
-    return change.hasReviewStarted() ? NotifyHandling.ALL : NotifyHandling.OWNER;
-  }
-
-  public Change abandon(BatchUpdate.Factory updateFactory, ChangeNotes notes, CurrentUser user)
-      throws RestApiException, UpdateException {
-    return abandon(
-        updateFactory,
-        notes,
-        user,
-        "",
-        defaultNotify(notes.getChange()),
-        ImmutableListMultimap.of());
-  }
-
-  public Change abandon(
-      BatchUpdate.Factory updateFactory, ChangeNotes notes, CurrentUser user, String msgTxt)
-      throws RestApiException, UpdateException {
-    return abandon(
-        updateFactory,
-        notes,
-        user,
-        msgTxt,
-        defaultNotify(notes.getChange()),
-        ImmutableListMultimap.of());
-  }
-
-  public Change abandon(
-      BatchUpdate.Factory updateFactory,
-      ChangeNotes notes,
-      CurrentUser user,
-      String msgTxt,
-      NotifyHandling notifyHandling,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify)
-      throws RestApiException, UpdateException {
-    Account account = user.isIdentifiedUser() ? user.asIdentifiedUser().getAccount() : null;
-    AbandonOp op = abandonOpFactory.create(account, msgTxt, notifyHandling, accountsToNotify);
-    try (BatchUpdate u =
-        updateFactory.create(dbProvider.get(), notes.getProjectName(), user, TimeUtil.nowTs())) {
-      u.addOp(notes.getChangeId(), op).execute();
-    }
-    return op.getChange();
-  }
-
-  /**
-   * If an extension has more than one changes to abandon that belong to the same project, they
-   * should use the batch instead of abandoning one by one.
-   *
-   * <p>It's the caller's responsibility to ensure that all jobs inside the same batch have the
-   * matching project from its ChangeData. Violations will result in a ResourceConflictException.
-   */
-  public void batchAbandon(
-      BatchUpdate.Factory updateFactory,
-      Project.NameKey project,
-      CurrentUser user,
-      Collection<ChangeData> changes,
-      String msgTxt,
-      NotifyHandling notifyHandling,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify)
-      throws RestApiException, UpdateException {
-    if (changes.isEmpty()) {
-      return;
-    }
-    Account account = user.isIdentifiedUser() ? user.asIdentifiedUser().getAccount() : null;
-    try (BatchUpdate u = updateFactory.create(dbProvider.get(), project, user, TimeUtil.nowTs())) {
-      for (ChangeData change : changes) {
-        if (!project.equals(change.project())) {
-          throw new ResourceConflictException(
-              String.format(
-                  "Project name \"%s\" doesn't match \"%s\"",
-                  change.project().get(), project.get()));
-        }
-        u.addOp(
-            change.getId(),
-            abandonOpFactory.create(account, msgTxt, notifyHandling, accountsToNotify));
-      }
-      u.execute();
-    }
-  }
-
-  public void batchAbandon(
-      BatchUpdate.Factory updateFactory,
-      Project.NameKey project,
-      CurrentUser user,
-      Collection<ChangeData> changes,
-      String msgTxt)
-      throws RestApiException, UpdateException {
-    batchAbandon(
-        updateFactory,
-        project,
-        user,
-        changes,
-        msgTxt,
-        NotifyHandling.ALL,
-        ImmutableListMultimap.of());
-  }
-
-  public void batchAbandon(
-      BatchUpdate.Factory updateFactory,
-      Project.NameKey project,
-      CurrentUser user,
-      Collection<ChangeData> changes)
-      throws RestApiException, UpdateException {
-    batchAbandon(
-        updateFactory, project, user, changes, "", NotifyHandling.ALL, ImmutableListMultimap.of());
-  }
-
-  @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
-    Change change = rsrc.getChange();
-    return new UiAction.Description()
-        .setLabel("Abandon")
-        .setTitle("Abandon the change")
-        .setVisible(
-            and(
-                change.getStatus().isOpen(),
-                rsrc.permissions().database(dbProvider).testCond(ChangePermission.ABANDON)));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
deleted file mode 100644
index 3239813..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
+++ /dev/null
@@ -1,128 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.config.ChangeCleanupConfig;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.ChangeQueryProcessor;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class AbandonUtil {
-  private static final Logger log = LoggerFactory.getLogger(AbandonUtil.class);
-
-  private final ChangeCleanupConfig cfg;
-  private final Provider<ChangeQueryProcessor> queryProvider;
-  private final ChangeQueryBuilder queryBuilder;
-  private final Abandon abandon;
-  private final InternalUser internalUser;
-
-  @Inject
-  AbandonUtil(
-      ChangeCleanupConfig cfg,
-      InternalUser.Factory internalUserFactory,
-      Provider<ChangeQueryProcessor> queryProvider,
-      ChangeQueryBuilder queryBuilder,
-      Abandon abandon) {
-    this.cfg = cfg;
-    this.queryProvider = queryProvider;
-    this.queryBuilder = queryBuilder;
-    this.abandon = abandon;
-    internalUser = internalUserFactory.create();
-  }
-
-  public void abandonInactiveOpenChanges(BatchUpdate.Factory updateFactory) {
-    if (cfg.getAbandonAfter() <= 0) {
-      return;
-    }
-
-    try {
-      String query =
-          "status:new age:" + TimeUnit.MILLISECONDS.toMinutes(cfg.getAbandonAfter()) + "m";
-      if (!cfg.getAbandonIfMergeable()) {
-        query += " -is:mergeable";
-      }
-
-      List<ChangeData> changesToAbandon =
-          queryProvider.get().enforceVisibility(false).query(queryBuilder.parse(query)).entities();
-      ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
-          ImmutableListMultimap.builder();
-      for (ChangeData cd : changesToAbandon) {
-        builder.put(cd.project(), cd);
-      }
-
-      int count = 0;
-      ListMultimap<Project.NameKey, ChangeData> abandons = builder.build();
-      String message = cfg.getAbandonMessage();
-      for (Project.NameKey project : abandons.keySet()) {
-        Collection<ChangeData> changes = getValidChanges(abandons.get(project), query);
-        try {
-          abandon.batchAbandon(updateFactory, project, internalUser, changes, message);
-          count += changes.size();
-        } catch (Throwable e) {
-          StringBuilder msg = new StringBuilder("Failed to auto-abandon inactive change(s):");
-          for (ChangeData change : changes) {
-            msg.append(" ").append(change.getId().get());
-          }
-          msg.append(".");
-          log.error(msg.toString(), e);
-        }
-      }
-      log.info(String.format("Auto-Abandoned %d of %d changes.", count, changesToAbandon.size()));
-    } catch (QueryParseException | OrmException e) {
-      log.error("Failed to query inactive open changes for auto-abandoning.", e);
-    }
-  }
-
-  private Collection<ChangeData> getValidChanges(Collection<ChangeData> changes, String query)
-      throws OrmException, QueryParseException {
-    Collection<ChangeData> validChanges = new ArrayList<>();
-    for (ChangeData cd : changes) {
-      String newQuery = query + " change:" + cd.getId();
-      List<ChangeData> changesToAbandon =
-          queryProvider
-              .get()
-              .enforceVisibility(false)
-              .query(queryBuilder.parse(newQuery))
-              .entities();
-      if (!changesToAbandon.isEmpty()) {
-        validChanges.add(cd);
-      } else {
-        log.debug(
-            "Change data with id \"{}\" does not satisfy the query \"{}\""
-                + " any more, hence skipping it in clean up",
-            cd.getId(),
-            query);
-      }
-    }
-    return validChanges;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
deleted file mode 100644
index b7a6e82..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
+++ /dev/null
@@ -1,102 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwtorm.server.OrmException;
-import java.util.Collection;
-import java.util.Optional;
-
-/**
- * Store for reviewed flags on changes.
- *
- * <p>A reviewed flag is a tuple of (patch set ID, file, account ID) and records whether the user
- * has reviewed a file in a patch set. Each user can easily have thousands of reviewed flags and the
- * number of reviewed flags is growing without bound. The store must be able handle this data volume
- * efficiently.
- *
- * <p>For a multi-master setup the store must replicate the data between the masters.
- */
-public interface AccountPatchReviewStore {
-
-  /** Represents patch set id with reviewed files. */
-  @AutoValue
-  abstract class PatchSetWithReviewedFiles {
-    abstract PatchSet.Id patchSetId();
-
-    abstract ImmutableSet<String> files();
-
-    public static PatchSetWithReviewedFiles create(PatchSet.Id id, ImmutableSet<String> files) {
-      return new AutoValue_AccountPatchReviewStore_PatchSetWithReviewedFiles(id, files);
-    }
-  }
-
-  /**
-   * Marks the given file in the given patch set as reviewed by the given user.
-   *
-   * @param psId patch set ID
-   * @param accountId account ID of the user
-   * @param path file path
-   * @return {@code true} if the reviewed flag was updated, {@code false} if the reviewed flag was
-   *     already set
-   * @throws OrmException thrown if updating the reviewed flag failed
-   */
-  boolean markReviewed(PatchSet.Id psId, Account.Id accountId, String path) throws OrmException;
-
-  /**
-   * Marks the given files in the given patch set as reviewed by the given user.
-   *
-   * @param psId patch set ID
-   * @param accountId account ID of the user
-   * @param paths file paths
-   * @throws OrmException thrown if updating the reviewed flag failed
-   */
-  void markReviewed(PatchSet.Id psId, Account.Id accountId, Collection<String> paths)
-      throws OrmException;
-
-  /**
-   * Clears the reviewed flag for the given file in the given patch set for the given user.
-   *
-   * @param psId patch set ID
-   * @param accountId account ID of the user
-   * @param path file path
-   * @throws OrmException thrown if clearing the reviewed flag failed
-   */
-  void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path) throws OrmException;
-
-  /**
-   * Clears the reviewed flags for all files in the given patch set for all users.
-   *
-   * @param psId patch set ID
-   * @throws OrmException thrown if clearing the reviewed flags failed
-   */
-  void clearReviewed(PatchSet.Id psId) throws OrmException;
-
-  /**
-   * Find the latest patch set, that is smaller or equals to the given patch set, where at least,
-   * one file has been reviewed by the given user.
-   *
-   * @param psId patch set ID
-   * @param accountId account ID of the user
-   * @return optionally, all files the have been reviewed by the given user that belong to the patch
-   *     set that is smaller or equals to the given patch set
-   * @throws OrmException thrown if accessing the reviewed flags failed
-   */
-  Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId)
-      throws OrmException;
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/AllowedFormats.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/AllowedFormats.java
deleted file mode 100644
index 20e586f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/AllowedFormats.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Sets;
-import com.google.gerrit.server.config.DownloadConfig;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Set;
-
-@Singleton
-public class AllowedFormats {
-  final ImmutableMap<String, ArchiveFormat> extensions;
-  final ImmutableSet<ArchiveFormat> allowed;
-
-  @Inject
-  AllowedFormats(DownloadConfig cfg) {
-    Map<String, ArchiveFormat> exts = new HashMap<>();
-    for (ArchiveFormat format : cfg.getArchiveFormats()) {
-      for (String ext : format.getSuffixes()) {
-        exts.put(ext, format);
-      }
-      exts.put(format.name().toLowerCase(), format);
-    }
-    extensions = ImmutableMap.copyOf(exts);
-
-    // Zip is not supported because it may be interpreted by a Java plugin as a
-    // valid JAR file, whose code would have access to cookies on the domain.
-    allowed =
-        Sets.immutableEnumSet(
-            Iterables.filter(cfg.getArchiveFormats(), f -> f != ArchiveFormat.ZIP));
-  }
-
-  public Set<ArchiveFormat> getAllowed() {
-    return allowed;
-  }
-
-  public ImmutableMap<String, ArchiveFormat> getExtensions() {
-    return extensions;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ApplyFix.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ApplyFix.java
deleted file mode 100644
index fa26eec..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ApplyFix.java
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.EditInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.edit.ChangeEditJson;
-import com.google.gerrit.server.edit.ChangeEditModifier;
-import com.google.gerrit.server.edit.tree.TreeModification;
-import com.google.gerrit.server.fixes.FixReplacementInterpreter;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-
-@Singleton
-public class ApplyFix implements RestModifyView<FixResource, Void> {
-
-  private final GitRepositoryManager gitRepositoryManager;
-  private final FixReplacementInterpreter fixReplacementInterpreter;
-  private final ChangeEditModifier changeEditModifier;
-  private final ChangeEditJson changeEditJson;
-  private final ProjectCache projectCache;
-
-  @Inject
-  public ApplyFix(
-      GitRepositoryManager gitRepositoryManager,
-      FixReplacementInterpreter fixReplacementInterpreter,
-      ChangeEditModifier changeEditModifier,
-      ChangeEditJson changeEditJson,
-      ProjectCache projectCache) {
-    this.gitRepositoryManager = gitRepositoryManager;
-    this.fixReplacementInterpreter = fixReplacementInterpreter;
-    this.changeEditModifier = changeEditModifier;
-    this.changeEditJson = changeEditJson;
-    this.projectCache = projectCache;
-  }
-
-  @Override
-  public Response<EditInfo> apply(FixResource fixResource, Void nothing)
-      throws AuthException, OrmException, ResourceConflictException, IOException,
-          ResourceNotFoundException, PermissionBackendException {
-    RevisionResource revisionResource = fixResource.getRevisionResource();
-    Project.NameKey project = revisionResource.getProject();
-    ProjectState projectState = projectCache.checkedGet(project);
-    PatchSet patchSet = revisionResource.getPatchSet();
-    ObjectId patchSetCommitId = ObjectId.fromString(patchSet.getRevision().get());
-
-    try (Repository repository = gitRepositoryManager.openRepository(project)) {
-      List<TreeModification> treeModifications =
-          fixReplacementInterpreter.toTreeModifications(
-              repository, projectState, patchSetCommitId, fixResource.getFixReplacements());
-      ChangeEdit changeEdit =
-          changeEditModifier.combineWithModifiedPatchSetTree(
-              repository, revisionResource.getNotes(), patchSet, treeModifications);
-      return Response.ok(changeEditJson.toEditInfo(changeEdit, false));
-    } catch (InvalidChangeOperationException e) {
-      throw new ResourceConflictException(e.getMessage());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java
deleted file mode 100644
index 3fefcd4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright 2013 Google Inc. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import java.io.Closeable;
-import java.io.IOException;
-import java.io.OutputStream;
-import org.apache.commons.compress.archivers.ArchiveOutputStream;
-import org.eclipse.jgit.api.ArchiveCommand;
-import org.eclipse.jgit.api.ArchiveCommand.Format;
-import org.eclipse.jgit.archive.TarFormat;
-import org.eclipse.jgit.archive.Tbz2Format;
-import org.eclipse.jgit.archive.TgzFormat;
-import org.eclipse.jgit.archive.TxzFormat;
-import org.eclipse.jgit.archive.ZipFormat;
-import org.eclipse.jgit.lib.FileMode;
-import org.eclipse.jgit.lib.ObjectLoader;
-
-public enum ArchiveFormat {
-  TGZ("application/x-gzip", new TgzFormat()),
-  TAR("application/x-tar", new TarFormat()),
-  TBZ2("application/x-bzip2", new Tbz2Format()),
-  TXZ("application/x-xz", new TxzFormat()),
-  ZIP("application/x-zip", new ZipFormat());
-
-  private final ArchiveCommand.Format<?> format;
-  private final String mimeType;
-
-  ArchiveFormat(String mimeType, ArchiveCommand.Format<?> format) {
-    this.format = format;
-    this.mimeType = mimeType;
-    ArchiveCommand.registerFormat(name(), format);
-  }
-
-  public String getShortName() {
-    return name().toLowerCase();
-  }
-
-  String getMimeType() {
-    return mimeType;
-  }
-
-  String getDefaultSuffix() {
-    return getSuffixes().iterator().next();
-  }
-
-  Iterable<String> getSuffixes() {
-    return format.suffixes();
-  }
-
-  public ArchiveOutputStream createArchiveOutputStream(OutputStream o) throws IOException {
-    return (ArchiveOutputStream) this.format.createArchiveOutputStream(o);
-  }
-
-  public <T extends Closeable> void putEntry(T out, String path, byte[] data) throws IOException {
-    @SuppressWarnings("unchecked")
-    ArchiveCommand.Format<T> fmt = (Format<T>) format;
-    fmt.putEntry(
-        out,
-        null,
-        path,
-        FileMode.REGULAR_FILE,
-        new ObjectLoader.SmallObject(FileMode.TYPE_FILE, data));
-  }
-}
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
deleted file mode 100644
index 18d3482..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
+++ /dev/null
@@ -1,521 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.common.DiffWebLinkInfo;
-import com.google.gerrit.extensions.common.EditInfo;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AcceptsDelete;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.DefaultInput;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RawInput;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.WebLinks;
-import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.edit.ChangeEditJson;
-import com.google.gerrit.server.edit.ChangeEditModifier;
-import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gerrit.server.edit.UnchangedCommitMessageException;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.List;
-import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.kohsuke.args4j.Option;
-
-@Singleton
-public class ChangeEdits
-    implements ChildCollection<ChangeResource, ChangeEditResource>,
-        AcceptsCreate<ChangeResource>,
-        AcceptsPost<ChangeResource>,
-        AcceptsDelete<ChangeResource> {
-  private final DynamicMap<RestView<ChangeEditResource>> views;
-  private final Create.Factory createFactory;
-  private final DeleteFile.Factory deleteFileFactory;
-  private final Provider<Detail> detail;
-  private final ChangeEditUtil editUtil;
-  private final Post post;
-
-  @Inject
-  ChangeEdits(
-      DynamicMap<RestView<ChangeEditResource>> views,
-      Create.Factory createFactory,
-      Provider<Detail> detail,
-      ChangeEditUtil editUtil,
-      Post post,
-      DeleteFile.Factory deleteFileFactory) {
-    this.views = views;
-    this.createFactory = createFactory;
-    this.detail = detail;
-    this.editUtil = editUtil;
-    this.post = post;
-    this.deleteFileFactory = deleteFileFactory;
-  }
-
-  @Override
-  public DynamicMap<RestView<ChangeEditResource>> views() {
-    return views;
-  }
-
-  @Override
-  public RestView<ChangeResource> list() {
-    return detail.get();
-  }
-
-  @Override
-  public ChangeEditResource parse(ChangeResource rsrc, IdString id)
-      throws ResourceNotFoundException, AuthException, IOException, OrmException {
-    Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
-    if (!edit.isPresent()) {
-      throw new ResourceNotFoundException(id);
-    }
-    return new ChangeEditResource(rsrc, edit.get(), id.get());
-  }
-
-  @Override
-  public Create create(ChangeResource parent, IdString id) throws RestApiException {
-    return createFactory.create(id.get());
-  }
-
-  @Override
-  public Post post(ChangeResource parent) throws RestApiException {
-    return post;
-  }
-
-  /**
-   * Create handler that is activated when collection element is accessed but doesn't exist, e. g.
-   * PUT request with a path was called but change edit wasn't created yet. Change edit is created
-   * and PUT handler is called.
-   */
-  @Override
-  public DeleteFile delete(ChangeResource parent, IdString id) throws RestApiException {
-    // It's safe to assume that id can never be null, because
-    // otherwise we would end up in dedicated endpoint for
-    // deleting of change edits and not a file in change edit
-    return deleteFileFactory.create(id.get());
-  }
-
-  public static class Create implements RestModifyView<ChangeResource, Put.Input> {
-
-    interface Factory {
-      Create create(String path);
-    }
-
-    private final Put putEdit;
-    private final String path;
-
-    @Inject
-    Create(Put putEdit, @Assisted String path) {
-      this.putEdit = putEdit;
-      this.path = path;
-    }
-
-    @Override
-    public Response<?> apply(ChangeResource resource, Put.Input input)
-        throws AuthException, ResourceConflictException, IOException, OrmException,
-            PermissionBackendException {
-      putEdit.apply(resource, path, input.content);
-      return Response.none();
-    }
-  }
-
-  public static class DeleteFile implements RestModifyView<ChangeResource, DeleteFile.Input> {
-    public static class Input {}
-
-    interface Factory {
-      DeleteFile create(String path);
-    }
-
-    private final DeleteContent deleteContent;
-    private final String path;
-
-    @Inject
-    DeleteFile(DeleteContent deleteContent, @Assisted String path) {
-      this.deleteContent = deleteContent;
-      this.path = path;
-    }
-
-    @Override
-    public Response<?> apply(ChangeResource rsrc, DeleteFile.Input in)
-        throws IOException, AuthException, ResourceConflictException, OrmException,
-            PermissionBackendException {
-      return deleteContent.apply(rsrc, path);
-    }
-  }
-
-  // TODO(davido): Turn the boolean options to ChangeEditOption enum,
-  // like it's already the case for ListChangesOption/ListGroupsOption
-  public static class Detail implements RestReadView<ChangeResource> {
-    private final ChangeEditUtil editUtil;
-    private final ChangeEditJson editJson;
-    private final FileInfoJson fileInfoJson;
-    private final Revisions revisions;
-
-    @Option(name = "--base", metaVar = "revision-id")
-    String base;
-
-    @Option(name = "--list")
-    boolean list;
-
-    @Option(name = "--download-commands")
-    boolean downloadCommands;
-
-    @Inject
-    Detail(
-        ChangeEditUtil editUtil,
-        ChangeEditJson editJson,
-        FileInfoJson fileInfoJson,
-        Revisions revisions) {
-      this.editJson = editJson;
-      this.editUtil = editUtil;
-      this.fileInfoJson = fileInfoJson;
-      this.revisions = revisions;
-    }
-
-    @Override
-    public Response<EditInfo> apply(ChangeResource rsrc)
-        throws AuthException, IOException, ResourceNotFoundException, OrmException {
-      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
-      if (!edit.isPresent()) {
-        return Response.none();
-      }
-
-      EditInfo editInfo = editJson.toEditInfo(edit.get(), downloadCommands);
-      if (list) {
-        PatchSet basePatchSet = null;
-        if (base != null) {
-          RevisionResource baseResource = revisions.parse(rsrc, IdString.fromDecoded(base));
-          basePatchSet = baseResource.getPatchSet();
-        }
-        try {
-          editInfo.files =
-              fileInfoJson.toFileInfoMap(
-                  rsrc.getChange(), edit.get().getEditCommit(), basePatchSet);
-        } catch (PatchListNotAvailableException e) {
-          throw new ResourceNotFoundException(e.getMessage());
-        }
-      }
-      return Response.ok(editInfo);
-    }
-  }
-
-  /**
-   * Post to edit collection resource. Two different operations are supported:
-   *
-   * <ul>
-   *   <li>Create non existing change edit
-   *   <li>Restore path in existing change edit
-   * </ul>
-   *
-   * The combination of two operations in one request is supported.
-   */
-  @Singleton
-  public static class Post implements RestModifyView<ChangeResource, Post.Input> {
-    public static class Input {
-      public String restorePath;
-      public String oldPath;
-      public String newPath;
-    }
-
-    private final ChangeEditModifier editModifier;
-    private final GitRepositoryManager repositoryManager;
-
-    @Inject
-    Post(ChangeEditModifier editModifier, GitRepositoryManager repositoryManager) {
-      this.editModifier = editModifier;
-      this.repositoryManager = repositoryManager;
-    }
-
-    @Override
-    public Response<?> apply(ChangeResource resource, Post.Input input)
-        throws AuthException, IOException, ResourceConflictException, OrmException,
-            PermissionBackendException {
-      Project.NameKey project = resource.getProject();
-      try (Repository repository = repositoryManager.openRepository(project)) {
-        if (isRestoreFile(input)) {
-          editModifier.restoreFile(repository, resource.getNotes(), input.restorePath);
-        } else if (isRenameFile(input)) {
-          editModifier.renameFile(repository, resource.getNotes(), input.oldPath, input.newPath);
-        } else {
-          editModifier.createEdit(repository, resource.getNotes());
-        }
-      } catch (InvalidChangeOperationException e) {
-        throw new ResourceConflictException(e.getMessage());
-      }
-      return Response.none();
-    }
-
-    private static boolean isRestoreFile(Input input) {
-      return input != null && !Strings.isNullOrEmpty(input.restorePath);
-    }
-
-    private static boolean isRenameFile(Input input) {
-      return input != null
-          && !Strings.isNullOrEmpty(input.oldPath)
-          && !Strings.isNullOrEmpty(input.newPath);
-    }
-  }
-
-  /** Put handler that is activated when PUT request is called on collection element. */
-  @Singleton
-  public static class Put implements RestModifyView<ChangeEditResource, Put.Input> {
-    public static class Input {
-      @DefaultInput public RawInput content;
-    }
-
-    private final ChangeEditModifier editModifier;
-    private final GitRepositoryManager repositoryManager;
-
-    @Inject
-    Put(ChangeEditModifier editModifier, GitRepositoryManager repositoryManager) {
-      this.editModifier = editModifier;
-      this.repositoryManager = repositoryManager;
-    }
-
-    @Override
-    public Response<?> apply(ChangeEditResource rsrc, Input input)
-        throws AuthException, ResourceConflictException, IOException, OrmException,
-            PermissionBackendException {
-      return apply(rsrc.getChangeResource(), rsrc.getPath(), input.content);
-    }
-
-    public Response<?> apply(ChangeResource rsrc, String path, RawInput newContent)
-        throws ResourceConflictException, AuthException, IOException, OrmException,
-            PermissionBackendException {
-      if (Strings.isNullOrEmpty(path) || path.charAt(0) == '/') {
-        throw new ResourceConflictException("Invalid path: " + path);
-      }
-
-      try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
-        editModifier.modifyFile(repository, rsrc.getNotes(), path, newContent);
-      } catch (InvalidChangeOperationException e) {
-        throw new ResourceConflictException(e.getMessage());
-      }
-      return Response.none();
-    }
-  }
-
-  /**
-   * Handler to delete a file.
-   *
-   * <p>This deletes the file from the repository completely. This is not the same as reverting or
-   * restoring a file to its previous contents.
-   */
-  @Singleton
-  public static class DeleteContent
-      implements RestModifyView<ChangeEditResource, DeleteContent.Input> {
-    public static class Input {}
-
-    private final ChangeEditModifier editModifier;
-    private final GitRepositoryManager repositoryManager;
-
-    @Inject
-    DeleteContent(ChangeEditModifier editModifier, GitRepositoryManager repositoryManager) {
-      this.editModifier = editModifier;
-      this.repositoryManager = repositoryManager;
-    }
-
-    @Override
-    public Response<?> apply(ChangeEditResource rsrc, DeleteContent.Input input)
-        throws AuthException, ResourceConflictException, OrmException, IOException,
-            PermissionBackendException {
-      return apply(rsrc.getChangeResource(), rsrc.getPath());
-    }
-
-    public Response<?> apply(ChangeResource rsrc, String filePath)
-        throws AuthException, IOException, OrmException, ResourceConflictException,
-            PermissionBackendException {
-      try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
-        editModifier.deleteFile(repository, rsrc.getNotes(), filePath);
-      } catch (InvalidChangeOperationException e) {
-        throw new ResourceConflictException(e.getMessage());
-      }
-      return Response.none();
-    }
-  }
-
-  public static class Get implements RestReadView<ChangeEditResource> {
-    private final FileContentUtil fileContentUtil;
-    private final ProjectCache projectCache;
-
-    @Option(
-      name = "--base",
-      aliases = {"-b"},
-      usage = "whether to load the content on the base revision instead of the change edit"
-    )
-    private boolean base;
-
-    @Inject
-    Get(FileContentUtil fileContentUtil, ProjectCache projectCache) {
-      this.fileContentUtil = fileContentUtil;
-      this.projectCache = projectCache;
-    }
-
-    @Override
-    public Response<BinaryResult> apply(ChangeEditResource rsrc) throws IOException {
-      try {
-        ChangeEdit edit = rsrc.getChangeEdit();
-        return Response.ok(
-            fileContentUtil.getContent(
-                projectCache.checkedGet(rsrc.getChangeResource().getProject()),
-                base
-                    ? ObjectId.fromString(edit.getBasePatchSet().getRevision().get())
-                    : edit.getEditCommit(),
-                rsrc.getPath(),
-                null));
-      } catch (ResourceNotFoundException | BadRequestException e) {
-        return Response.none();
-      }
-    }
-  }
-
-  @Singleton
-  public static class GetMeta implements RestReadView<ChangeEditResource> {
-    private final WebLinks webLinks;
-
-    @Inject
-    GetMeta(WebLinks webLinks) {
-      this.webLinks = webLinks;
-    }
-
-    @Override
-    public FileInfo apply(ChangeEditResource rsrc) {
-      FileInfo r = new FileInfo();
-      ChangeEdit edit = rsrc.getChangeEdit();
-      Change change = edit.getChange();
-      List<DiffWebLinkInfo> links =
-          webLinks.getDiffLinks(
-              change.getProject().get(),
-              change.getChangeId(),
-              edit.getBasePatchSet().getPatchSetId(),
-              edit.getBasePatchSet().getRefName(),
-              rsrc.getPath(),
-              0,
-              edit.getRefName(),
-              rsrc.getPath());
-      r.webLinks = links.isEmpty() ? null : links;
-      return r;
-    }
-
-    public static class FileInfo {
-      public List<DiffWebLinkInfo> webLinks;
-    }
-  }
-
-  @Singleton
-  public static class EditMessage implements RestModifyView<ChangeResource, EditMessage.Input> {
-    public static class Input {
-      @DefaultInput public String message;
-    }
-
-    private final ChangeEditModifier editModifier;
-    private final GitRepositoryManager repositoryManager;
-
-    @Inject
-    EditMessage(ChangeEditModifier editModifier, GitRepositoryManager repositoryManager) {
-      this.editModifier = editModifier;
-      this.repositoryManager = repositoryManager;
-    }
-
-    @Override
-    public Object apply(ChangeResource rsrc, Input input)
-        throws AuthException, IOException, BadRequestException, ResourceConflictException,
-            OrmException, PermissionBackendException {
-      if (input == null || Strings.isNullOrEmpty(input.message)) {
-        throw new BadRequestException("commit message must be provided");
-      }
-
-      Project.NameKey project = rsrc.getProject();
-      try (Repository repository = repositoryManager.openRepository(project)) {
-        editModifier.modifyMessage(repository, rsrc.getNotes(), input.message);
-      } catch (UnchangedCommitMessageException e) {
-        throw new ResourceConflictException(e.getMessage());
-      }
-
-      return Response.none();
-    }
-  }
-
-  public static class GetMessage implements RestReadView<ChangeResource> {
-    private final GitRepositoryManager repoManager;
-    private final ChangeEditUtil editUtil;
-
-    @Option(
-      name = "--base",
-      aliases = {"-b"},
-      usage = "whether to load the message on the base revision instead of the change edit"
-    )
-    private boolean base;
-
-    @Inject
-    GetMessage(GitRepositoryManager repoManager, ChangeEditUtil editUtil) {
-      this.repoManager = repoManager;
-      this.editUtil = editUtil;
-    }
-
-    @Override
-    public BinaryResult apply(ChangeResource rsrc)
-        throws AuthException, IOException, ResourceNotFoundException, OrmException {
-      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
-      String msg;
-      if (edit.isPresent()) {
-        if (base) {
-          try (Repository repo = repoManager.openRepository(rsrc.getProject());
-              RevWalk rw = new RevWalk(repo)) {
-            RevCommit commit =
-                rw.parseCommit(
-                    ObjectId.fromString(edit.get().getBasePatchSet().getRevision().get()));
-            msg = commit.getFullMessage();
-          }
-        } else {
-          msg = edit.get().getEditCommit().getFullMessage();
-        }
-
-        return BinaryResult.create(msg)
-            .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
-            .base64();
-      }
-      throw new ResourceNotFoundException();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeIncludedIn.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeIncludedIn.java
deleted file mode 100644
index 47f5a16..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeIncludedIn.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.api.changes.IncludedInInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class ChangeIncludedIn implements RestReadView<ChangeResource> {
-  private Provider<ReviewDb> db;
-  private PatchSetUtil psUtil;
-  private IncludedIn includedIn;
-
-  @Inject
-  ChangeIncludedIn(Provider<ReviewDb> db, PatchSetUtil psUtil, IncludedIn includedIn) {
-    this.db = db;
-    this.psUtil = psUtil;
-    this.includedIn = includedIn;
-  }
-
-  @Override
-  public IncludedInInfo apply(ChangeResource rsrc)
-      throws RestApiException, OrmException, IOException {
-    PatchSet ps = psUtil.current(db.get(), rsrc.getNotes());
-    return includedIn.apply(rsrc.getProject(), ps.getRevision().get());
-  }
-}
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
deleted file mode 100644
index 8dc53bc..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
+++ /dev/null
@@ -1,1478 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.extensions.client.ListChangesOption.ALL_COMMITS;
-import static com.google.gerrit.extensions.client.ListChangesOption.ALL_FILES;
-import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
-import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
-import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
-import static com.google.gerrit.extensions.client.ListChangesOption.COMMIT_FOOTERS;
-import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
-import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
-import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_FILES;
-import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.extensions.client.ListChangesOption.DOWNLOAD_COMMANDS;
-import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
-import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
-import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
-import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
-import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
-import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
-import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
-import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
-import static com.google.gerrit.server.CommonConverters.toGitPerson;
-import static java.util.stream.Collectors.toList;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.Joiner;
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Throwables;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.HashBasedTable;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.LinkedHashMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
-import com.google.common.collect.Sets;
-import com.google.common.collect.Table;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitTypeRecord;
-import com.google.gerrit.extensions.api.changes.FixInput;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.FetchInfo;
-import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.ProblemInfo;
-import com.google.gerrit.extensions.common.PushCertificateInfo;
-import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.common.TrackingIdInfo;
-import com.google.gerrit.extensions.common.VotingRangeInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.config.DownloadCommand;
-import com.google.gerrit.extensions.config.DownloadScheme;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.index.query.QueryResult;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.ReviewerByEmailSet;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.WebLinks;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.api.accounts.AccountInfoComparator;
-import com.google.gerrit.server.api.accounts.GpgApiAdapter;
-import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.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.ProjectCache;
-import com.google.gerrit.server.project.RemoveReviewerControl;
-import com.google.gerrit.server.project.SubmitRuleOptions;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
-import com.google.gerrit.server.query.change.PluginDefinedAttributesFactory;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.TreeMap;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ChangeJson {
-  private static final Logger log = LoggerFactory.getLogger(ChangeJson.class);
-
-  // Submit rule options in this class should always use fastEvalLabels for
-  // efficiency reasons. Callers that care about submittability after taking
-  // vote squashing into account should be looking at the submit action.
-  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
-      ChangeField.SUBMIT_RULE_OPTIONS_LENIENT.toBuilder().fastEvalLabels(true).build();
-
-  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
-      ChangeField.SUBMIT_RULE_OPTIONS_STRICT.toBuilder().fastEvalLabels(true).build();
-
-  public static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
-      ImmutableSet.of(
-          ALL_COMMITS,
-          ALL_REVISIONS,
-          CHANGE_ACTIONS,
-          CHECK,
-          COMMIT_FOOTERS,
-          CURRENT_ACTIONS,
-          CURRENT_COMMIT,
-          MESSAGES);
-
-  @Singleton
-  public static class Factory {
-    private final AssistedFactory factory;
-
-    @Inject
-    Factory(AssistedFactory factory) {
-      this.factory = factory;
-    }
-
-    public ChangeJson noOptions() {
-      return create(ImmutableSet.of());
-    }
-
-    public ChangeJson create(Iterable<ListChangesOption> options) {
-      return factory.create(options);
-    }
-
-    public ChangeJson create(ListChangesOption first, ListChangesOption... rest) {
-      return create(Sets.immutableEnumSet(first, rest));
-    }
-  }
-
-  public interface AssistedFactory {
-    ChangeJson create(Iterable<ListChangesOption> options);
-  }
-
-  private final Provider<ReviewDb> db;
-  private final Provider<CurrentUser> userProvider;
-  private final AnonymousUser anonymous;
-  private final PermissionBackend permissionBackend;
-  private final GitRepositoryManager repoManager;
-  private final ProjectCache projectCache;
-  private final MergeUtil.Factory mergeUtilFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final ChangeData.Factory changeDataFactory;
-  private final FileInfoJson fileInfoJson;
-  private final AccountLoader.Factory accountLoaderFactory;
-  private final DynamicMap<DownloadScheme> downloadSchemes;
-  private final DynamicMap<DownloadCommand> downloadCommands;
-  private final WebLinks webLinks;
-  private final ImmutableSet<ListChangesOption> options;
-  private final ChangeMessagesUtil cmUtil;
-  private final Provider<ConsistencyChecker> checkerProvider;
-  private final ActionJson actionJson;
-  private final GpgApiAdapter gpgApi;
-  private final ChangeNotes.Factory notesFactory;
-  private final ChangeResource.Factory changeResourceFactory;
-  private final ChangeKindCache changeKindCache;
-  private final ChangeIndexCollection indexes;
-  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;
-  private PluginDefinedAttributesFactory pluginDefinedAttributesFactory;
-
-  @Inject
-  ChangeJson(
-      Provider<ReviewDb> db,
-      Provider<CurrentUser> user,
-      AnonymousUser au,
-      PermissionBackend permissionBackend,
-      GitRepositoryManager repoManager,
-      ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
-      IdentifiedUser.GenericFactory uf,
-      ChangeData.Factory cdf,
-      FileInfoJson fileInfoJson,
-      AccountLoader.Factory ailf,
-      DynamicMap<DownloadScheme> downloadSchemes,
-      DynamicMap<DownloadCommand> downloadCommands,
-      WebLinks webLinks,
-      ChangeMessagesUtil cmUtil,
-      Provider<ConsistencyChecker> checkerProvider,
-      ActionJson actionJson,
-      GpgApiAdapter gpgApi,
-      ChangeNotes.Factory notesFactory,
-      ChangeResource.Factory changeResourceFactory,
-      ChangeKindCache changeKindCache,
-      ChangeIndexCollection indexes,
-      ApprovalsUtil approvalsUtil,
-      RemoveReviewerControl removeReviewerControl,
-      TrackingFooters trackingFooters,
-      ChangeControl.GenericFactory changeControlFactory,
-      @Assisted Iterable<ListChangesOption> options) {
-    this.db = db;
-    this.userProvider = user;
-    this.anonymous = au;
-    this.changeDataFactory = cdf;
-    this.permissionBackend = permissionBackend;
-    this.repoManager = repoManager;
-    this.userFactory = uf;
-    this.projectCache = projectCache;
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.fileInfoJson = fileInfoJson;
-    this.accountLoaderFactory = ailf;
-    this.downloadSchemes = downloadSchemes;
-    this.downloadCommands = downloadCommands;
-    this.webLinks = webLinks;
-    this.cmUtil = cmUtil;
-    this.checkerProvider = checkerProvider;
-    this.actionJson = actionJson;
-    this.gpgApi = gpgApi;
-    this.notesFactory = notesFactory;
-    this.changeResourceFactory = changeResourceFactory;
-    this.changeKindCache = changeKindCache;
-    this.indexes = indexes;
-    this.approvalsUtil = approvalsUtil;
-    this.removeReviewerControl = removeReviewerControl;
-    this.changeControlFactory = changeControlFactory;
-    this.options = Sets.immutableEnumSet(options);
-    this.trackingFooters = trackingFooters;
-  }
-
-  public ChangeJson lazyLoad(boolean load) {
-    lazyLoad = load;
-    return this;
-  }
-
-  public ChangeJson fix(FixInput fix) {
-    this.fix = fix;
-    return this;
-  }
-
-  public void setPluginDefinedAttributesFactory(PluginDefinedAttributesFactory pluginsFactory) {
-    this.pluginDefinedAttributesFactory = pluginsFactory;
-  }
-
-  public ChangeInfo format(ChangeResource rsrc) throws OrmException {
-    return format(changeDataFactory.create(db.get(), rsrc.getNotes()));
-  }
-
-  public ChangeInfo format(Change change) throws OrmException {
-    return format(changeDataFactory.create(db.get(), change));
-  }
-
-  public ChangeInfo format(Project.NameKey project, Change.Id id) throws OrmException {
-    ChangeNotes notes;
-    try {
-      notes = notesFactory.createChecked(db.get(), project, id);
-    } catch (OrmException e) {
-      if (!has(CHECK)) {
-        throw e;
-      }
-      return checkOnly(changeDataFactory.create(db.get(), project, id));
-    }
-    return format(changeDataFactory.create(db.get(), notes));
-  }
-
-  public ChangeInfo format(ChangeData cd) throws OrmException {
-    return format(cd, Optional.empty(), true);
-  }
-
-  private ChangeInfo format(
-      ChangeData cd, Optional<PatchSet.Id> limitToPsId, boolean fillAccountLoader)
-      throws OrmException {
-    try {
-      if (fillAccountLoader) {
-        accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-        ChangeInfo res = toChangeInfo(cd, limitToPsId);
-        accountLoader.fill();
-        return res;
-      }
-      return toChangeInfo(cd, limitToPsId);
-    } catch (PatchListNotAvailableException
-        | GpgException
-        | OrmException
-        | IOException
-        | PermissionBackendException
-        | RuntimeException e) {
-      if (!has(CHECK)) {
-        Throwables.throwIfInstanceOf(e, OrmException.class);
-        throw new OrmException(e);
-      }
-      return checkOnly(cd);
-    }
-  }
-
-  public ChangeInfo format(RevisionResource rsrc) throws OrmException {
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
-    return format(cd, Optional.of(rsrc.getPatchSet().getId()), true);
-  }
-
-  public List<List<ChangeInfo>> formatQueryResults(List<QueryResult<ChangeData>> in)
-      throws OrmException {
-    accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    ensureLoaded(FluentIterable.from(in).transformAndConcat(QueryResult::entities));
-
-    List<List<ChangeInfo>> res = Lists.newArrayListWithCapacity(in.size());
-    Map<Change.Id, ChangeInfo> out = new HashMap<>();
-    for (QueryResult<ChangeData> r : in) {
-      List<ChangeInfo> infos = toChangeInfo(out, r.entities());
-      if (!infos.isEmpty() && r.more()) {
-        infos.get(infos.size() - 1)._moreChanges = true;
-      }
-      res.add(infos);
-    }
-    accountLoader.fill();
-    return res;
-  }
-
-  public List<ChangeInfo> formatChangeDatas(Collection<ChangeData> in) throws OrmException {
-    accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    ensureLoaded(in);
-    List<ChangeInfo> out = new ArrayList<>(in.size());
-    for (ChangeData cd : in) {
-      out.add(format(cd));
-    }
-    accountLoader.fill();
-    return out;
-  }
-
-  private void ensureLoaded(Iterable<ChangeData> all) throws OrmException {
-    if (lazyLoad) {
-      ChangeData.ensureChangeLoaded(all);
-      if (has(ALL_REVISIONS)) {
-        ChangeData.ensureAllPatchSetsLoaded(all);
-      } else if (has(CURRENT_REVISION) || has(MESSAGES)) {
-        ChangeData.ensureCurrentPatchSetLoaded(all);
-      }
-      if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
-        ChangeData.ensureReviewedByLoadedForOpenChanges(all);
-      }
-      ChangeData.ensureCurrentApprovalsLoaded(all);
-    } else {
-      for (ChangeData cd : all) {
-        cd.setLazyLoad(false);
-      }
-    }
-  }
-
-  private boolean has(ListChangesOption option) {
-    return options.contains(option);
-  }
-
-  private List<ChangeInfo> toChangeInfo(Map<Change.Id, ChangeInfo> out, List<ChangeData> changes) {
-    List<ChangeInfo> info = Lists.newArrayListWithCapacity(changes.size());
-    for (ChangeData cd : changes) {
-      ChangeInfo i = out.get(cd.getId());
-      if (i == null) {
-        try {
-          i = toChangeInfo(cd, Optional.empty());
-        } catch (PatchListNotAvailableException
-            | GpgException
-            | OrmException
-            | IOException
-            | PermissionBackendException
-            | RuntimeException e) {
-          if (has(CHECK)) {
-            i = checkOnly(cd);
-          } else if (e instanceof NoSuchChangeException) {
-            log.info(
-                "NoSuchChangeException: Omitting corrupt change "
-                    + cd.getId()
-                    + " from results. Seems to be stale in the index.");
-            continue;
-          } else {
-            log.warn("Omitting corrupt change " + cd.getId() + " from results", e);
-            continue;
-          }
-        }
-        out.put(cd.getId(), i);
-      }
-      info.add(i);
-    }
-    return info;
-  }
-
-  private ChangeInfo checkOnly(ChangeData cd) {
-    ChangeNotes notes;
-    try {
-      notes = cd.notes();
-    } catch (OrmException e) {
-      String msg = "Error loading change";
-      log.warn(msg + " " + cd.getId(), e);
-      ChangeInfo info = new ChangeInfo();
-      info._number = cd.getId().get();
-      ProblemInfo p = new ProblemInfo();
-      p.message = msg;
-      info.problems = Lists.newArrayList(p);
-      return info;
-    }
-
-    ConsistencyChecker.Result result = checkerProvider.get().check(notes, fix);
-    ChangeInfo info;
-    Change c = result.change();
-    if (c != null) {
-      info = new ChangeInfo();
-      info.project = c.getProject().get();
-      info.branch = c.getDest().getShortName();
-      info.topic = c.getTopic();
-      info.changeId = c.getKey().get();
-      info.subject = c.getSubject();
-      info.status = c.getStatus().asChangeStatus();
-      info.owner = new AccountInfo(c.getOwner().get());
-      info.created = c.getCreatedOn();
-      info.updated = c.getLastUpdatedOn();
-      info._number = c.getId().get();
-      info.problems = result.problems();
-      info.isPrivate = c.isPrivate() ? true : null;
-      info.workInProgress = c.isWorkInProgress() ? true : null;
-      info.hasReviewStarted = c.hasReviewStarted();
-      finish(info);
-    } else {
-      info = new ChangeInfo();
-      info._number = result.id().get();
-      info.problems = result.problems();
-    }
-    return info;
-  }
-
-  private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
-          PermissionBackendException {
-    ChangeInfo out = new ChangeInfo();
-    CurrentUser user = userProvider.get();
-
-    if (has(CHECK)) {
-      out.problems = checkerProvider.get().check(cd.notes(), fix).problems();
-      // If any problems were fixed, the ChangeData needs to be reloaded.
-      for (ProblemInfo p : out.problems) {
-        if (p.status == ProblemInfo.Status.FIXED) {
-          cd = changeDataFactory.create(cd.db(), cd.project(), cd.getId());
-          break;
-        }
-      }
-    }
-
-    PermissionBackend.WithUser withUser = permissionBackend.user(user).database(db);
-    PermissionBackend.ForChange perm =
-        lazyLoad
-            ? withUser.change(cd)
-            : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
-    Change in = cd.change();
-    out.project = in.getProject().get();
-    out.branch = in.getDest().getShortName();
-    out.topic = in.getTopic();
-    if (indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE)) {
-      if (in.getAssignee() != null) {
-        out.assignee = accountLoader.get(in.getAssignee());
-      }
-    }
-    out.hashtags = cd.hashtags();
-    out.changeId = in.getKey().get();
-    if (in.getStatus().isOpen()) {
-      SubmitTypeRecord str = cd.submitTypeRecord();
-      if (str.isOk()) {
-        out.submitType = str.type;
-      }
-      out.mergeable = cd.isMergeable();
-      if (has(SUBMITTABLE)) {
-        out.submittable = submittable(cd);
-      }
-    }
-    Optional<ChangedLines> changedLines = cd.changedLines();
-    if (changedLines.isPresent()) {
-      out.insertions = changedLines.get().insertions;
-      out.deletions = changedLines.get().deletions;
-    }
-    out.isPrivate = in.isPrivate() ? true : null;
-    out.workInProgress = in.isWorkInProgress() ? true : null;
-    out.hasReviewStarted = in.hasReviewStarted();
-    out.subject = in.getSubject();
-    out.status = in.getStatus().asChangeStatus();
-    out.owner = accountLoader.get(in.getOwner());
-    out.created = in.getCreatedOn();
-    out.updated = in.getLastUpdatedOn();
-    out._number = in.getId().get();
-    out.unresolvedCommentCount = cd.unresolvedCommentCount();
-
-    if (user.isIdentifiedUser()) {
-      Collection<String> stars = cd.stars(user.getAccountId());
-      out.starred = stars.contains(StarredChangesUtil.DEFAULT_LABEL) ? true : null;
-      if (!stars.isEmpty()) {
-        out.stars = stars;
-      }
-    }
-
-    if (in.getStatus().isOpen() && has(REVIEWED) && user.isIdentifiedUser()) {
-      out.reviewed = cd.isReviewedBy(user.getAccountId()) ? true : null;
-    }
-
-    out.labels = labelsFor(perm, cd, has(LABELS), has(DETAILED_LABELS));
-
-    if (out.labels != null && has(DETAILED_LABELS)) {
-      // If limited to specific patch sets but not the current patch set, don't
-      // list permitted labels, since users can't vote on those patch sets.
-      if (user.isIdentifiedUser()
-          && (!limitToPsId.isPresent() || limitToPsId.get().equals(in.currentPatchSetId()))) {
-        out.permittedLabels =
-            cd.change().getStatus() != Change.Status.ABANDONED
-                ? permittedLabels(perm, cd)
-                : ImmutableMap.of();
-      }
-
-      out.reviewers = reviewerMap(cd.reviewers(), cd.reviewersByEmail(), false);
-      out.pendingReviewers = reviewerMap(cd.pendingReviewers(), cd.pendingReviewersByEmail(), true);
-      out.removableReviewers = removableReviewers(cd, out);
-    }
-
-    setSubmitter(cd, out);
-    out.plugins =
-        pluginDefinedAttributesFactory != null ? pluginDefinedAttributesFactory.create(cd) : null;
-    out.revertOf = cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null;
-
-    if (has(REVIEWER_UPDATES)) {
-      out.reviewerUpdates = reviewerUpdates(cd);
-    }
-
-    boolean needMessages = has(MESSAGES);
-    boolean needRevisions = has(ALL_REVISIONS) || has(CURRENT_REVISION) || limitToPsId.isPresent();
-    Map<PatchSet.Id, PatchSet> src;
-    if (needMessages || needRevisions) {
-      src = loadPatchSets(cd, limitToPsId);
-    } else {
-      src = null;
-    }
-
-    ChangeControl ctl = null;
-    if (needMessages || needRevisions) {
-      ctl = changeControlFactory.controlFor(db.get(), cd.change(), userProvider.get());
-    }
-    if (needMessages) {
-      out.messages = messages(ctl, 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);
-      if (out.revisions != null) {
-        for (Map.Entry<String, RevisionInfo> entry : out.revisions.entrySet()) {
-          if (entry.getValue().isCurrent) {
-            out.currentRevision = entry.getKey();
-            break;
-          }
-        }
-      }
-    }
-
-    if (has(CURRENT_ACTIONS) || has(CHANGE_ACTIONS)) {
-      actionJson.addChangeActions(out, cd.notes());
-    }
-
-    if (has(TRACKING_IDS)) {
-      ListMultimap<String, String> set = trackingFooters.extract(cd.commitFooters());
-      out.trackingIds =
-          set.entries()
-              .stream()
-              .map(e -> new TrackingIdInfo(e.getKey(), e.getValue()))
-              .collect(toList());
-    }
-
-    return out;
-  }
-
-  private Map<ReviewerState, Collection<AccountInfo>> reviewerMap(
-      ReviewerSet reviewers, ReviewerByEmailSet reviewersByEmail, boolean includeRemoved) {
-    Map<ReviewerState, Collection<AccountInfo>> reviewerMap = new HashMap<>();
-    for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
-      if (!includeRemoved && state == ReviewerStateInternal.REMOVED) {
-        continue;
-      }
-      Collection<AccountInfo> reviewersByState = toAccountInfo(reviewers.byState(state));
-      reviewersByState.addAll(toAccountInfoByEmail(reviewersByEmail.byState(state)));
-      if (!reviewersByState.isEmpty()) {
-        reviewerMap.put(state.asReviewerState(), reviewersByState);
-      }
-    }
-    return reviewerMap;
-  }
-
-  private Collection<ReviewerUpdateInfo> reviewerUpdates(ChangeData cd) throws OrmException {
-    List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
-    List<ReviewerUpdateInfo> result = new ArrayList<>(reviewerUpdates.size());
-    for (ReviewerStatusUpdate c : reviewerUpdates) {
-      ReviewerUpdateInfo change = new ReviewerUpdateInfo();
-      change.updated = c.date();
-      change.state = c.state().asReviewerState();
-      change.updatedBy = accountLoader.get(c.updatedBy());
-      change.reviewer = accountLoader.get(c.reviewer());
-      result.add(change);
-    }
-    return result;
-  }
-
-  private boolean submittable(ChangeData cd) throws OrmException {
-    return SubmitRecord.findOkRecord(cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)).isPresent();
-  }
-
-  private List<SubmitRecord> submitRecords(ChangeData cd) throws OrmException {
-    return cd.submitRecords(SUBMIT_RULE_OPTIONS_LENIENT);
-  }
-
-  private Map<String, LabelInfo> labelsFor(
-      PermissionBackend.ForChange perm, ChangeData cd, boolean standard, boolean detailed)
-      throws OrmException, PermissionBackendException {
-    if (!standard && !detailed) {
-      return null;
-    }
-
-    LabelTypes labelTypes = cd.getLabelTypes();
-    Map<String, LabelWithStatus> withStatus =
-        cd.change().getStatus() == Change.Status.MERGED
-            ? labelsForSubmittedChange(perm, cd, labelTypes, standard, detailed)
-            : labelsForUnsubmittedChange(perm, cd, labelTypes, standard, detailed);
-    return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
-  }
-
-  private Map<String, LabelWithStatus> labelsForUnsubmittedChange(
-      PermissionBackend.ForChange perm,
-      ChangeData cd,
-      LabelTypes labelTypes,
-      boolean standard,
-      boolean detailed)
-      throws OrmException, PermissionBackendException {
-    Map<String, LabelWithStatus> labels = initLabels(cd, labelTypes, standard);
-    if (detailed) {
-      setAllApprovals(perm, cd, labels);
-    }
-    for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-      LabelType type = labelTypes.byLabel(e.getKey());
-      if (type == null) {
-        continue;
-      }
-      if (standard) {
-        for (PatchSetApproval psa : cd.currentApprovals()) {
-          if (type.matches(psa)) {
-            short val = psa.getValue();
-            Account.Id accountId = psa.getAccountId();
-            setLabelScores(type, e.getValue(), val, accountId);
-          }
-        }
-      }
-      if (detailed) {
-        setLabelValues(type, e.getValue());
-      }
-    }
-    return labels;
-  }
-
-  private Map<String, LabelWithStatus> initLabels(
-      ChangeData cd, LabelTypes labelTypes, boolean standard) throws OrmException {
-    Map<String, LabelWithStatus> labels = new TreeMap<>(labelTypes.nameComparator());
-    for (SubmitRecord rec : submitRecords(cd)) {
-      if (rec.labels == null) {
-        continue;
-      }
-      for (SubmitRecord.Label r : rec.labels) {
-        LabelWithStatus p = labels.get(r.label);
-        if (p == null || p.status().compareTo(r.status) < 0) {
-          LabelInfo n = new LabelInfo();
-          if (standard) {
-            switch (r.status) {
-              case OK:
-                n.approved = accountLoader.get(r.appliedBy);
-                break;
-              case REJECT:
-                n.rejected = accountLoader.get(r.appliedBy);
-                n.blocking = true;
-                break;
-              case IMPOSSIBLE:
-              case MAY:
-              case NEED:
-              default:
-                break;
-            }
-          }
-
-          n.optional = r.status == SubmitRecord.Label.Status.MAY ? true : null;
-          labels.put(r.label, LabelWithStatus.create(n, r.status));
-        }
-      }
-    }
-    return labels;
-  }
-
-  private void setLabelScores(
-      LabelType type, LabelWithStatus l, short score, Account.Id accountId) {
-    if (l.label().approved != null || l.label().rejected != null) {
-      return;
-    }
-
-    if (type.getMin() == null || type.getMax() == null) {
-      // Can't set score for unknown or misconfigured type.
-      return;
-    }
-
-    if (score != 0) {
-      if (score == type.getMin().getValue()) {
-        l.label().rejected = accountLoader.get(accountId);
-      } else if (score == type.getMax().getValue()) {
-        l.label().approved = accountLoader.get(accountId);
-      } else if (score < 0) {
-        l.label().disliked = accountLoader.get(accountId);
-        l.label().value = score;
-      } else if (score > 0 && l.label().disliked == null) {
-        l.label().recommended = accountLoader.get(accountId);
-        l.label().value = score;
-      }
-    }
-  }
-
-  private void setAllApprovals(
-      PermissionBackend.ForChange basePerm, ChangeData cd, Map<String, LabelWithStatus> labels)
-      throws OrmException, PermissionBackendException {
-    Change.Status status = cd.change().getStatus();
-    checkState(
-        status != Change.Status.MERGED, "should not call setAllApprovals on %s change", status);
-
-    // Include a user in the output for this label if either:
-    //  - They are an explicit reviewer.
-    //  - They ever voted on this change.
-    Set<Account.Id> allUsers = new HashSet<>();
-    allUsers.addAll(cd.reviewers().byState(ReviewerStateInternal.REVIEWER));
-    for (PatchSetApproval psa : cd.approvals().values()) {
-      allUsers.add(psa.getAccountId());
-    }
-
-    Table<Account.Id, String, PatchSetApproval> current =
-        HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size());
-    for (PatchSetApproval psa : cd.currentApprovals()) {
-      current.put(psa.getAccountId(), psa.getLabel(), psa);
-    }
-
-    LabelTypes labelTypes = cd.getLabelTypes();
-    for (Account.Id accountId : allUsers) {
-      PermissionBackend.ForChange perm = basePerm.user(userFactory.create(accountId));
-      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
-      for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-        LabelType lt = labelTypes.byLabel(e.getKey());
-        if (lt == null) {
-          // Ignore submit record for undefined label; likely the submit rule
-          // author didn't intend for the label to show up in the table.
-          continue;
-        }
-        Integer value;
-        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.getName(), null);
-        String tag = null;
-        Timestamp date = null;
-        PatchSetApproval psa = current.get(accountId, lt.getName());
-        if (psa != null) {
-          value = Integer.valueOf(psa.getValue());
-          if (value == 0) {
-            // This may be a dummy approval that was inserted when the reviewer
-            // was added. Explicitly check whether the user can vote on this
-            // label.
-            value = perm.test(new LabelPermission(lt)) ? 0 : null;
-          }
-          tag = psa.getTag();
-          date = psa.getGranted();
-          if (psa.isPostSubmit()) {
-            log.warn("unexpected post-submit approval on open change: {}", psa);
-          }
-        } else {
-          // Either the user cannot vote on this label, or they were added as a
-          // reviewer but have not responded yet. Explicitly check whether the
-          // user can vote on this label.
-          value = perm.test(new LabelPermission(lt)) ? 0 : null;
-        }
-        addApproval(
-            e.getValue().label(), approvalInfo(accountId, value, permittedVotingRange, tag, date));
-      }
-    }
-  }
-
-  private Map<String, VotingRangeInfo> getPermittedVotingRanges(
-      Map<String, Collection<String>> permittedLabels) {
-    Map<String, VotingRangeInfo> permittedVotingRanges =
-        Maps.newHashMapWithExpectedSize(permittedLabels.size());
-    for (String label : permittedLabels.keySet()) {
-      List<Integer> permittedVotingRange =
-          permittedLabels
-              .get(label)
-              .stream()
-              .map(this::parseRangeValue)
-              .filter(java.util.Objects::nonNull)
-              .sorted()
-              .collect(toList());
-
-      if (permittedVotingRange.isEmpty()) {
-        permittedVotingRanges.put(label, null);
-      } else {
-        int minPermittedValue = permittedVotingRange.get(0);
-        int maxPermittedValue = Iterables.getLast(permittedVotingRange);
-        permittedVotingRanges.put(label, new VotingRangeInfo(minPermittedValue, maxPermittedValue));
-      }
-    }
-    return permittedVotingRanges;
-  }
-
-  private Integer parseRangeValue(String value) {
-    if (value.startsWith("+")) {
-      value = value.substring(1);
-    } else if (value.startsWith(" ")) {
-      value = value.trim();
-    }
-    return Ints.tryParse(value);
-  }
-
-  private void setSubmitter(ChangeData cd, ChangeInfo out) throws OrmException {
-    Optional<PatchSetApproval> s = cd.getSubmitApproval();
-    if (!s.isPresent()) {
-      return;
-    }
-    out.submitted = s.get().getGranted();
-    out.submitter = accountLoader.get(s.get().getAccountId());
-  }
-
-  private Map<String, LabelWithStatus> labelsForSubmittedChange(
-      PermissionBackend.ForChange basePerm,
-      ChangeData cd,
-      LabelTypes labelTypes,
-      boolean standard,
-      boolean detailed)
-      throws OrmException, PermissionBackendException {
-    Set<Account.Id> allUsers = new HashSet<>();
-    if (detailed) {
-      // Users expect to see all reviewers on closed changes, even if they
-      // didn't vote on the latest patch set. If we don't need detailed labels,
-      // we aren't including 0 votes for all users below, so we can just look at
-      // the latest patch set (in the next loop).
-      for (PatchSetApproval psa : cd.approvals().values()) {
-        allUsers.add(psa.getAccountId());
-      }
-    }
-
-    Set<String> labelNames = new HashSet<>();
-    SetMultimap<Account.Id, PatchSetApproval> current =
-        MultimapBuilder.hashKeys().hashSetValues().build();
-    for (PatchSetApproval a : cd.currentApprovals()) {
-      allUsers.add(a.getAccountId());
-      LabelType type = labelTypes.byLabel(a.getLabelId());
-      if (type != null) {
-        labelNames.add(type.getName());
-        // Not worth the effort to distinguish between votable/non-votable for 0
-        // values on closed changes, since they can't vote anyway.
-        current.put(a.getAccountId(), a);
-      }
-    }
-
-    // Since voting on merged changes is allowed all labels which apply to
-    // the change must be returned. All applying labels can be retrieved from
-    // the submit records, which is what initLabels does.
-    // It's not possible to only compute the labels based on the approvals
-    // since merged changes may not have approvals for all labels (e.g. if not
-    // all labels are required for submit or if the change was auto-closed due
-    // to direct push or if new labels were defined after the change was
-    // merged).
-    Map<String, LabelWithStatus> labels;
-    labels = initLabels(cd, labelTypes, standard);
-
-    // Also include all labels for which approvals exists. E.g. there can be
-    // approvals for labels that are ignored by a Prolog submit rule and hence
-    // it wouldn't be included in the submit records.
-    for (String name : labelNames) {
-      if (!labels.containsKey(name)) {
-        labels.put(name, LabelWithStatus.create(new LabelInfo(), null));
-      }
-    }
-
-    if (detailed) {
-      labels
-          .entrySet()
-          .stream()
-          .filter(e -> labelTypes.byLabel(e.getKey()) != null)
-          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()), e.getValue()));
-    }
-
-    for (Account.Id accountId : allUsers) {
-      Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
-      Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
-      if (detailed) {
-        PermissionBackend.ForChange perm = basePerm.user(userFactory.create(accountId));
-        pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
-        for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
-          ApprovalInfo ai = approvalInfo(accountId, 0, null, null, null);
-          byLabel.put(entry.getKey(), ai);
-          addApproval(entry.getValue().label(), ai);
-        }
-      }
-      for (PatchSetApproval psa : current.get(accountId)) {
-        LabelType type = labelTypes.byLabel(psa.getLabelId());
-        if (type == null) {
-          continue;
-        }
-
-        short val = psa.getValue();
-        ApprovalInfo info = byLabel.get(type.getName());
-        if (info != null) {
-          info.value = Integer.valueOf(val);
-          info.permittedVotingRange = pvr.getOrDefault(type.getName(), null);
-          info.date = psa.getGranted();
-          info.tag = psa.getTag();
-          if (psa.isPostSubmit()) {
-            info.postSubmit = true;
-          }
-        }
-        if (!standard) {
-          continue;
-        }
-
-        setLabelScores(type, labels.get(type.getName()), val, accountId);
-      }
-    }
-    return labels;
-  }
-
-  private ApprovalInfo approvalInfo(
-      Account.Id id,
-      Integer value,
-      VotingRangeInfo permittedVotingRange,
-      String tag,
-      Timestamp date) {
-    ApprovalInfo ai = getApprovalInfo(id, value, permittedVotingRange, tag, date);
-    accountLoader.put(ai);
-    return ai;
-  }
-
-  public static ApprovalInfo getApprovalInfo(
-      Account.Id id,
-      Integer value,
-      VotingRangeInfo permittedVotingRange,
-      String tag,
-      Timestamp date) {
-    ApprovalInfo ai = new ApprovalInfo(id.get());
-    ai.value = value;
-    ai.permittedVotingRange = permittedVotingRange;
-    ai.date = date;
-    ai.tag = tag;
-    return ai;
-  }
-
-  private static boolean isOnlyZero(Collection<String> values) {
-    return values.isEmpty() || (values.size() == 1 && values.contains(" 0"));
-  }
-
-  private void setLabelValues(LabelType type, LabelWithStatus l) {
-    l.label().defaultValue = type.getDefaultValue();
-    l.label().values = new LinkedHashMap<>();
-    for (LabelValue v : type.getValues()) {
-      l.label().values.put(v.formatValue(), v.getText());
-    }
-    if (isOnlyZero(l.label().values.keySet())) {
-      l.label().values = null;
-    }
-  }
-
-  private Map<String, Collection<String>> permittedLabels(
-      PermissionBackend.ForChange perm, ChangeData cd)
-      throws OrmException, PermissionBackendException {
-    boolean isMerged = cd.change().getStatus() == Change.Status.MERGED;
-    LabelTypes labelTypes = cd.getLabelTypes();
-    Map<String, LabelType> toCheck = new HashMap<>();
-    for (SubmitRecord rec : submitRecords(cd)) {
-      if (rec.labels != null) {
-        for (SubmitRecord.Label r : rec.labels) {
-          LabelType type = labelTypes.byLabel(r.label);
-          if (type != null && (!isMerged || type.allowPostSubmit())) {
-            toCheck.put(type.getName(), type);
-          }
-        }
-      }
-    }
-
-    Map<String, Short> labels = null;
-    Set<LabelPermission.WithValue> can = perm.testLabels(toCheck.values());
-    SetMultimap<String, String> permitted = LinkedHashMultimap.create();
-    for (SubmitRecord rec : submitRecords(cd)) {
-      if (rec.labels == null) {
-        continue;
-      }
-      for (SubmitRecord.Label r : rec.labels) {
-        LabelType type = labelTypes.byLabel(r.label);
-        if (type == null || (isMerged && !type.allowPostSubmit())) {
-          continue;
-        }
-
-        for (LabelValue v : type.getValues()) {
-          boolean ok = can.contains(new LabelPermission.WithValue(type, v));
-          if (isMerged) {
-            if (labels == null) {
-              labels = currentLabels(perm, cd);
-            }
-            short prev = labels.getOrDefault(type.getName(), (short) 0);
-            ok &= v.getValue() >= prev;
-          }
-          if (ok) {
-            permitted.put(r.label, v.formatValue());
-          }
-        }
-      }
-    }
-
-    List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
-    for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
-      if (isOnlyZero(e.getValue())) {
-        toClear.add(e.getKey());
-      }
-    }
-    for (String label : toClear) {
-      permitted.removeAll(label);
-    }
-    return permitted.asMap();
-  }
-
-  private Map<String, Short> currentLabels(PermissionBackend.ForChange perm, ChangeData cd)
-      throws OrmException {
-    IdentifiedUser user = perm.user().asIdentifiedUser();
-    Map<String, Short> result = new HashMap<>();
-    for (PatchSetApproval psa :
-        approvalsUtil.byPatchSetUser(
-            db.get(),
-            lazyLoad ? cd.notes() : notesFactory.createFromIndexedChange(cd.change()),
-            user,
-            cd.change().currentPatchSetId(),
-            user.getAccountId(),
-            null,
-            null)) {
-      result.put(psa.getLabel(), psa.getValue());
-    }
-    return result;
-  }
-
-  private Collection<ChangeMessageInfo> messages(ChangeControl ctl, ChangeData cd)
-      throws OrmException {
-    List<ChangeMessage> messages = cmUtil.byChange(db.get(), cd.notes());
-    if (messages.isEmpty()) {
-      return Collections.emptyList();
-    }
-
-    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);
-      }
-    }
-    return result;
-  }
-
-  private Collection<AccountInfo> removableReviewers(ChangeData cd, ChangeInfo out)
-      throws PermissionBackendException, NoSuchChangeException, OrmException {
-    // Although this is called removableReviewers, this method also determines
-    // which CCs are removable.
-    //
-    // For reviewers, we need to look at each approval, because the reviewer
-    // should only be considered removable if *all* of their approvals can be
-    // removed. First, add all reviewers with *any* removable approval to the
-    // "removable" set. Along the way, if we encounter a non-removable approval,
-    // add the reviewer to the "fixed" set. Before we return, remove all members
-    // of "fixed" from "removable", because not all of their approvals can be
-    // removed.
-    Collection<LabelInfo> labels = out.labels.values();
-    Set<Account.Id> fixed = Sets.newHashSetWithExpectedSize(labels.size());
-    Set<Account.Id> removable = Sets.newHashSetWithExpectedSize(labels.size());
-    for (LabelInfo label : labels) {
-      if (label.all == null) {
-        continue;
-      }
-      for (ApprovalInfo ai : label.all) {
-        Account.Id id = new Account.Id(ai._accountId);
-
-        if (removeReviewerControl.testRemoveReviewer(
-            cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
-          removable.add(id);
-        } else {
-          fixed.add(id);
-        }
-      }
-    }
-
-    // CCs are simpler than reviewers. They are removable if the ChangeControl
-    // would permit a non-negative approval by that account to be removed, in
-    // which case add them to removable. We don't need to add unremovable CCs to
-    // "fixed" because we only visit each CC once here.
-    Collection<AccountInfo> ccs = out.reviewers.get(ReviewerState.CC);
-    if (ccs != null) {
-      for (AccountInfo ai : ccs) {
-        if (ai._accountId != null) {
-          Account.Id id = new Account.Id(ai._accountId);
-          if (removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
-            removable.add(id);
-          }
-        }
-      }
-    }
-
-    // Subtract any reviewers with non-removable approvals from the "removable"
-    // set. This also subtracts any CCs that for some reason also hold
-    // unremovable approvals.
-    removable.removeAll(fixed);
-
-    List<AccountInfo> result = Lists.newArrayListWithCapacity(removable.size());
-    for (Account.Id id : removable) {
-      result.add(accountLoader.get(id));
-    }
-    // Reviewers added by email are always removable
-    for (Collection<AccountInfo> infos : out.reviewers.values()) {
-      for (AccountInfo info : infos) {
-        if (info._accountId == null) {
-          result.add(info);
-        }
-      }
-    }
-    return result;
-  }
-
-  private Collection<AccountInfo> toAccountInfo(Collection<Account.Id> accounts) {
-    return accounts
-        .stream()
-        .map(accountLoader::get)
-        .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
-        .collect(toList());
-  }
-
-  private Collection<AccountInfo> toAccountInfoByEmail(Collection<Address> addresses) {
-    return addresses
-        .stream()
-        .map(a -> new AccountInfo(a.getName(), a.getEmail()))
-        .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
-        .collect(toList());
-  }
-
-  @Nullable
-  private Repository openRepoIfNecessary(Project.NameKey project) throws IOException {
-    if (has(ALL_COMMITS) || has(CURRENT_COMMIT) || has(COMMIT_FOOTERS)) {
-      return repoManager.openRepository(project);
-    }
-    return null;
-  }
-
-  @Nullable
-  private RevWalk newRevWalk(@Nullable Repository repo) {
-    return repo != null ? new RevWalk(repo) : null;
-  }
-
-  private Map<String, RevisionInfo> revisions(
-      ChangeControl ctl,
-      ChangeData cd,
-      Map<PatchSet.Id, PatchSet> map,
-      Optional<PatchSet.Id> limitToPsId,
-      ChangeInfo changeInfo)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
-    Map<String, RevisionInfo> res = new LinkedHashMap<>();
-    try (Repository repo = openRepoIfNecessary(cd.project());
-        RevWalk rw = newRevWalk(repo)) {
-      for (PatchSet in : map.values()) {
-        PatchSet.Id id = in.getId();
-        boolean want = false;
-        if (has(ALL_REVISIONS)) {
-          want = true;
-        } else if (limitToPsId.isPresent()) {
-          want = id.equals(limitToPsId.get());
-        } else {
-          want = id.equals(cd.change().currentPatchSetId());
-        }
-        if (want && ctl.isVisible(db.get())) {
-          res.put(in.getRevision().get(), toRevisionInfo(cd, in, repo, rw, false, changeInfo));
-        }
-      }
-      return res;
-    }
-  }
-
-  private Map<PatchSet.Id, PatchSet> loadPatchSets(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
-      throws OrmException {
-    Collection<PatchSet> src;
-    if (has(ALL_REVISIONS) || has(MESSAGES)) {
-      src = cd.patchSets();
-    } else {
-      PatchSet ps;
-      if (limitToPsId.isPresent()) {
-        ps = cd.patchSet(limitToPsId.get());
-        if (ps == null) {
-          throw new OrmException("missing patch set " + limitToPsId.get());
-        }
-      } else {
-        ps = cd.currentPatchSet();
-        if (ps == null) {
-          throw new OrmException("missing current patch set for change " + cd.getId());
-        }
-      }
-      src = Collections.singletonList(ps);
-    }
-    Map<PatchSet.Id, PatchSet> map = Maps.newHashMapWithExpectedSize(src.size());
-    for (PatchSet patchSet : src) {
-      map.put(patchSet.getId(), patchSet);
-    }
-    return map;
-  }
-
-  public RevisionInfo getRevisionInfo(ChangeData cd, PatchSet in)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
-    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);
-      accountLoader.fill();
-      return rev;
-    }
-  }
-
-  private RevisionInfo toRevisionInfo(
-      ChangeData cd,
-      PatchSet in,
-      @Nullable Repository repo,
-      @Nullable RevWalk rw,
-      boolean fillCommit,
-      @Nullable ChangeInfo changeInfo)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
-    Change c = cd.change();
-    RevisionInfo out = new RevisionInfo();
-    out.isCurrent = in.getId().equals(c.currentPatchSetId());
-    out._number = in.getId().get();
-    out.ref = in.getRefName();
-    out.created = in.getCreatedOn();
-    out.uploader = accountLoader.get(in.getUploader());
-    out.fetch = makeFetchMap(cd, in);
-    out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
-    out.description = in.getDescription();
-
-    boolean setCommit = has(ALL_COMMITS) || (out.isCurrent && has(CURRENT_COMMIT));
-    boolean addFooters = out.isCurrent && has(COMMIT_FOOTERS);
-    if (setCommit || addFooters) {
-      checkState(rw != null);
-      checkState(repo != null);
-      Project.NameKey project = c.getProject();
-      String rev = in.getRevision().get();
-      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
-      rw.parseBody(commit);
-      if (setCommit) {
-        out.commit = toCommit(project, rw, commit, has(WEB_LINKS), fillCommit);
-      }
-      if (addFooters) {
-        Ref ref = repo.exactRef(cd.change().getDest().get());
-        RevCommit mergeTip = null;
-        if (ref != null) {
-          mergeTip = rw.parseCommit(ref.getObjectId());
-          rw.parseBody(mergeTip);
-        }
-        out.commitWithFooters =
-            mergeUtilFactory
-                .create(projectCache.get(project))
-                .createCommitMessageOnSubmit(
-                    commit, mergeTip, cd.notes(), userProvider.get(), in.getId());
-      }
-    }
-
-    if (has(ALL_FILES) || (out.isCurrent && has(CURRENT_FILES))) {
-      out.files = fileInfoJson.toFileInfoMap(c, in);
-      out.files.remove(Patch.COMMIT_MSG);
-      out.files.remove(Patch.MERGE_LIST);
-    }
-
-    if (out.isCurrent && has(CURRENT_ACTIONS) && userProvider.get().isIdentifiedUser()) {
-
-      actionJson.addRevisionActions(
-          changeInfo,
-          out,
-          new RevisionResource(changeResourceFactory.create(cd.notes(), userProvider.get()), in));
-    }
-
-    if (gpgApi.isEnabled() && has(PUSH_CERTIFICATES)) {
-      if (in.getPushCertificate() != null) {
-        out.pushCertificate =
-            gpgApi.checkPushCertificate(
-                in.getPushCertificate(), userFactory.create(in.getUploader()));
-      } else {
-        out.pushCertificate = new PushCertificateInfo();
-      }
-    }
-
-    return out;
-  }
-
-  CommitInfo toCommit(
-      Project.NameKey project, RevWalk rw, RevCommit commit, boolean addLinks, boolean fillCommit)
-      throws IOException {
-    CommitInfo info = new CommitInfo();
-    if (fillCommit) {
-      info.commit = commit.name();
-    }
-    info.parents = new ArrayList<>(commit.getParentCount());
-    info.author = toGitPerson(commit.getAuthorIdent());
-    info.committer = toGitPerson(commit.getCommitterIdent());
-    info.subject = commit.getShortMessage();
-    info.message = commit.getFullMessage();
-
-    if (addLinks) {
-      List<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
-      info.webLinks = links.isEmpty() ? null : links;
-    }
-
-    for (RevCommit parent : commit.getParents()) {
-      rw.parseBody(parent);
-      CommitInfo i = new CommitInfo();
-      i.commit = parent.name();
-      i.subject = parent.getShortMessage();
-      if (addLinks) {
-        List<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
-        i.webLinks = parentLinks.isEmpty() ? null : parentLinks;
-      }
-      info.parents.add(i);
-    }
-    return info;
-  }
-
-  private Map<String, FetchInfo> makeFetchMap(ChangeData cd, PatchSet in) throws OrmException {
-    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();
-      if (!scheme.isEnabled()
-          || (scheme.isAuthRequired() && !userProvider.get().isIdentifiedUser())) {
-        continue;
-      }
-
-      if (!scheme.isAuthSupported() && !ctl.isVisible(db.get())) {
-        continue;
-      }
-
-      String projectName = cd.project().get();
-      String url = scheme.getUrl(projectName);
-      String refName = in.getRefName();
-      FetchInfo fetchInfo = new FetchInfo(url, refName);
-      r.put(schemeName, fetchInfo);
-
-      if (has(DOWNLOAD_COMMANDS)) {
-        populateFetchMap(scheme, downloadCommands, projectName, refName, fetchInfo);
-      }
-    }
-
-    return r;
-  }
-
-  public static void populateFetchMap(
-      DownloadScheme scheme,
-      DynamicMap<DownloadCommand> commands,
-      String projectName,
-      String refName,
-      FetchInfo fetchInfo) {
-    for (DynamicMap.Entry<DownloadCommand> e2 : commands) {
-      String commandName = e2.getExportName();
-      DownloadCommand command = e2.getProvider().get();
-      String c = command.getCommand(scheme, projectName, refName);
-      if (c != null) {
-        addCommand(fetchInfo, commandName, c);
-      }
-    }
-  }
-
-  private static void addCommand(FetchInfo fetchInfo, String commandName, String c) {
-    if (fetchInfo.commands == null) {
-      fetchInfo.commands = new TreeMap<>();
-    }
-    fetchInfo.commands.put(commandName, c);
-  }
-
-  static void finish(ChangeInfo info) {
-    info.id =
-        Joiner.on('~')
-            .join(Url.encode(info.project), Url.encode(info.branch), Url.encode(info.changeId));
-  }
-
-  private static void addApproval(LabelInfo label, ApprovalInfo approval) {
-    if (label.all == null) {
-      label.all = new ArrayList<>();
-    }
-    label.all.add(approval);
-  }
-
-  @AutoValue
-  abstract static class LabelWithStatus {
-    private static LabelWithStatus create(LabelInfo label, SubmitRecord.Label.Status status) {
-      return new AutoValue_ChangeJson_LabelWithStatus(label, status);
-    }
-
-    abstract LabelInfo label();
-
-    @Nullable
-    abstract SubmitRecord.Label.Status status();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
deleted file mode 100644
index 4166bf7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
+++ /dev/null
@@ -1,218 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestResource.HasETag;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.TypeLiteral;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.HashSet;
-import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ChangeResource implements RestResource, HasETag {
-  private static final Logger log = LoggerFactory.getLogger(ChangeResource.class);
-
-  /**
-   * JSON format version number for ETag computations.
-   *
-   * <p>Should be bumped on any JSON format change (new fields, etc.) so that otherwise unmodified
-   * changes get new ETags.
-   */
-  public static final int JSON_FORMAT_VERSION = 1;
-
-  public static final TypeLiteral<RestView<ChangeResource>> CHANGE_KIND =
-      new TypeLiteral<RestView<ChangeResource>>() {};
-
-  public interface Factory {
-    ChangeResource create(ChangeNotes notes, CurrentUser user);
-  }
-
-  private static final String ZERO_ID_STRING = ObjectId.zeroId().name();
-
-  private final Provider<ReviewDb> db;
-  private final AccountCache accountCache;
-  private final ApprovalsUtil approvalUtil;
-  private final PatchSetUtil patchSetUtil;
-  private final PermissionBackend permissionBackend;
-  private final StarredChangesUtil starredChangesUtil;
-  private final ProjectCache projectCache;
-  private final ChangeNotes notes;
-  private final CurrentUser user;
-
-  @Inject
-  ChangeResource(
-      Provider<ReviewDb> db,
-      AccountCache accountCache,
-      ApprovalsUtil approvalUtil,
-      PatchSetUtil patchSetUtil,
-      PermissionBackend permissionBackend,
-      StarredChangesUtil starredChangesUtil,
-      ProjectCache projectCache,
-      @Assisted ChangeNotes notes,
-      @Assisted CurrentUser user) {
-    this.db = db;
-    this.accountCache = accountCache;
-    this.approvalUtil = approvalUtil;
-    this.patchSetUtil = patchSetUtil;
-    this.permissionBackend = permissionBackend;
-    this.starredChangesUtil = starredChangesUtil;
-    this.projectCache = projectCache;
-    this.notes = notes;
-    this.user = user;
-  }
-
-  public PermissionBackend.ForChange permissions() {
-    return permissionBackend.user(user).change(notes);
-  }
-
-  public CurrentUser getUser() {
-    return user;
-  }
-
-  public Change.Id getId() {
-    return notes.getChangeId();
-  }
-
-  /** @return true if {@link #getUser()} is the change's owner. */
-  public boolean isUserOwner() {
-    Account.Id owner = getChange().getOwner();
-    return user.isIdentifiedUser() && user.asIdentifiedUser().getAccountId().equals(owner);
-  }
-
-  public Change getChange() {
-    return notes.getChange();
-  }
-
-  public Project.NameKey getProject() {
-    return getChange().getProject();
-  }
-
-  public ChangeNotes getNotes() {
-    return notes;
-  }
-
-  // This includes all information relevant for ETag computation
-  // unrelated to the UI.
-  public void prepareETag(Hasher h, CurrentUser user) {
-    h.putInt(JSON_FORMAT_VERSION)
-        .putLong(getChange().getLastUpdatedOn().getTime())
-        .putInt(getChange().getRowVersion())
-        .putInt(user.isIdentifiedUser() ? user.getAccountId().get() : 0);
-
-    if (user.isIdentifiedUser()) {
-      for (AccountGroup.UUID uuid : user.getEffectiveGroups().getKnownGroups()) {
-        h.putBytes(uuid.get().getBytes(UTF_8));
-      }
-    }
-
-    byte[] buf = new byte[20];
-    Set<Account.Id> accounts = new HashSet<>();
-    accounts.add(getChange().getOwner());
-    if (getChange().getAssignee() != null) {
-      accounts.add(getChange().getAssignee());
-    }
-    try {
-      patchSetUtil
-          .byChange(db.get(), notes)
-          .stream()
-          .map(ps -> ps.getUploader())
-          .forEach(accounts::add);
-
-      // It's intentional to include the states for *all* reviewers into the ETag computation.
-      // We need the states of all current reviewers and CCs because they are part of ChangeInfo.
-      // Including removed reviewers is a cheap way of making sure that the states of accounts that
-      // posted a message on the change are included. Loading all change messages to find the exact
-      // set of accounts that posted a message is too expensive. However everyone who posts a
-      // message is automatically added as reviewer. Hence if we include removed reviewers we can
-      // be sure that we have all accounts that posted messages on the change.
-      accounts.addAll(approvalUtil.getReviewers(db.get(), notes).all());
-    } catch (OrmException e) {
-      // This ETag will be invalidated if it loads next time.
-    }
-    accounts.stream().forEach(a -> hashAccount(h, accountCache.get(a), buf));
-
-    ObjectId noteId;
-    try {
-      noteId = notes.loadRevision();
-    } catch (OrmException e) {
-      noteId = null; // This ETag will be invalidated if it loads next time.
-    }
-    hashObjectId(h, noteId, buf);
-    // TODO(dborowitz): Include more NoteDb and other related refs, e.g. drafts
-    // and edits.
-
-    Iterable<ProjectState> projectStateTree;
-    try {
-      projectStateTree = projectCache.checkedGet(getProject()).tree();
-    } catch (IOException e) {
-      log.error(String.format("could not load project %s while computing etag", getProject()));
-      projectStateTree = ImmutableList.of();
-    }
-
-    for (ProjectState p : projectStateTree) {
-      hashObjectId(h, p.getConfig().getRevision(), buf);
-    }
-  }
-
-  @Override
-  public String getETag() {
-    Hasher h = Hashing.murmur3_128().newHasher();
-    if (user.isIdentifiedUser()) {
-      h.putString(starredChangesUtil.getObjectId(user.getAccountId(), getId()).name(), UTF_8);
-    }
-    prepareETag(h, user);
-    return h.hash().toString();
-  }
-
-  private void hashObjectId(Hasher h, ObjectId id, byte[] buf) {
-    MoreObjects.firstNonNull(id, ObjectId.zeroId()).copyRawTo(buf, 0);
-    h.putBytes(buf);
-  }
-
-  private void hashAccount(Hasher h, AccountState accountState, byte[] buf) {
-    h.putString(
-        MoreObjects.firstNonNull(accountState.getAccount().getMetaId(), ZERO_ID_STRING), UTF_8);
-    accountState.getExternalIds().stream().forEach(e -> hashObjectId(h, e.blobId(), buf));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
deleted file mode 100644
index 805512e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
+++ /dev/null
@@ -1,131 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestCollection;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeFinder;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.QueryChanges;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.List;
-
-@Singleton
-public class ChangesCollection
-    implements RestCollection<TopLevelResource, ChangeResource>, AcceptsPost<TopLevelResource> {
-  private final Provider<ReviewDb> db;
-  private final Provider<CurrentUser> user;
-  private final Provider<QueryChanges> queryFactory;
-  private final DynamicMap<RestView<ChangeResource>> views;
-  private final ChangeFinder changeFinder;
-  private final CreateChange createChange;
-  private final ChangeResource.Factory changeResourceFactory;
-  private final PermissionBackend permissionBackend;
-
-  @Inject
-  ChangesCollection(
-      Provider<ReviewDb> db,
-      Provider<CurrentUser> user,
-      Provider<QueryChanges> queryFactory,
-      DynamicMap<RestView<ChangeResource>> views,
-      ChangeFinder changeFinder,
-      CreateChange createChange,
-      ChangeResource.Factory changeResourceFactory,
-      PermissionBackend permissionBackend) {
-    this.db = db;
-    this.user = user;
-    this.queryFactory = queryFactory;
-    this.views = views;
-    this.changeFinder = changeFinder;
-    this.createChange = createChange;
-    this.changeResourceFactory = changeResourceFactory;
-    this.permissionBackend = permissionBackend;
-  }
-
-  @Override
-  public QueryChanges list() {
-    return queryFactory.get();
-  }
-
-  @Override
-  public DynamicMap<RestView<ChangeResource>> views() {
-    return views;
-  }
-
-  @Override
-  public ChangeResource parse(TopLevelResource root, IdString id)
-      throws ResourceNotFoundException, OrmException, PermissionBackendException {
-    List<ChangeNotes> notes = changeFinder.find(id.encoded());
-    if (notes.isEmpty()) {
-      throw new ResourceNotFoundException(id);
-    } else if (notes.size() != 1) {
-      throw new ResourceNotFoundException("Multiple changes found for " + id);
-    }
-
-    ChangeNotes change = notes.get(0);
-    if (!canRead(change)) {
-      throw new ResourceNotFoundException(id);
-    }
-    return changeResourceFactory.create(change, user.get());
-  }
-
-  public ChangeResource parse(Change.Id id)
-      throws ResourceNotFoundException, OrmException, PermissionBackendException {
-    List<ChangeNotes> notes = changeFinder.find(id);
-    if (notes.isEmpty()) {
-      throw new ResourceNotFoundException(toIdString(id));
-    } else if (notes.size() != 1) {
-      throw new ResourceNotFoundException("Multiple changes found for " + id);
-    }
-
-    ChangeNotes change = notes.get(0);
-    if (!canRead(change)) {
-      throw new ResourceNotFoundException(toIdString(id));
-    }
-    return changeResourceFactory.create(change, user.get());
-  }
-
-  private static IdString toIdString(Change.Id id) {
-    return IdString.fromDecoded(id.toString());
-  }
-
-  public ChangeResource parse(ChangeNotes notes, CurrentUser user) {
-    return changeResourceFactory.create(notes, user);
-  }
-
-  @Override
-  public CreateChange post(TopLevelResource parent) throws RestApiException {
-    return createChange;
-  }
-
-  private boolean canRead(ChangeNotes notes) throws PermissionBackendException {
-    return permissionBackend.user(user).change(notes).database(db).test(ChangePermission.READ);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
deleted file mode 100644
index 157928b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import 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.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-
-public class Check
-    implements RestReadView<ChangeResource>, RestModifyView<ChangeResource, FixInput> {
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> user;
-  private final ChangeJson.Factory jsonFactory;
-  private final ProjectControl.GenericFactory projectControlFactory;
-
-  @Inject
-  Check(
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> user,
-      ChangeJson.Factory json,
-      ProjectControl.GenericFactory projectControlFactory) {
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-    this.jsonFactory = json;
-    this.projectControlFactory = projectControlFactory;
-  }
-
-  @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc) throws RestApiException, OrmException {
-    return Response.withMustRevalidate(newChangeJson().format(rsrc));
-  }
-
-  @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc, FixInput input)
-      throws RestApiException, OrmException, PermissionBackendException, NoSuchProjectException,
-          IOException {
-    if (!rsrc.isUserOwner()
-        && !projectControlFactory.controlFor(rsrc.getProject(), rsrc.getUser()).isOwner()) {
-      permissionBackend.user(user).check(GlobalPermission.MAINTAIN_SERVER);
-    }
-    return Response.withMustRevalidate(newChangeJson().fix(input).format(rsrc));
-  }
-
-  private ChangeJson newChangeJson() {
-    return jsonFactory.create(ListChangesOption.CHECK);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
deleted file mode 100644
index 7fffd3a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
+++ /dev/null
@@ -1,125 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.ContributorAgreementsChecker;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class CherryPick
-    extends RetryingRestModifyView<RevisionResource, CherryPickInput, ChangeInfo>
-    implements UiAction<RevisionResource> {
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> user;
-  private final CherryPickChange cherryPickChange;
-  private final ChangeJson.Factory json;
-  private final ContributorAgreementsChecker contributorAgreements;
-
-  @Inject
-  CherryPick(
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> user,
-      RetryHelper retryHelper,
-      CherryPickChange cherryPickChange,
-      ChangeJson.Factory json,
-      ContributorAgreementsChecker contributorAgreements) {
-    super(retryHelper);
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-    this.cherryPickChange = cherryPickChange;
-    this.json = json;
-    this.contributorAgreements = contributorAgreements;
-  }
-
-  @Override
-  public ChangeInfo applyImpl(
-      BatchUpdate.Factory updateFactory, RevisionResource rsrc, CherryPickInput input)
-      throws OrmException, IOException, UpdateException, RestApiException,
-          PermissionBackendException, ConfigInvalidException, NoSuchProjectException {
-    input.parent = input.parent == null ? 1 : input.parent;
-    if (input.message == null || input.message.trim().isEmpty()) {
-      throw new BadRequestException("message must be non-empty");
-    } else if (input.destination == null || input.destination.trim().isEmpty()) {
-      throw new BadRequestException("destination must be non-empty");
-    }
-
-    String refName = RefNames.fullName(input.destination);
-    contributorAgreements.check(rsrc.getProject(), rsrc.getUser());
-
-    permissionBackend
-        .user(user)
-        .project(rsrc.getChange().getProject())
-        .ref(refName)
-        .check(RefPermission.CREATE_CHANGE);
-
-    try {
-      Change.Id cherryPickedChangeId =
-          cherryPickChange.cherryPick(
-              updateFactory,
-              rsrc.getChange(),
-              rsrc.getPatchSet(),
-              input,
-              new Branch.NameKey(rsrc.getProject(), refName));
-      return json.noOptions().format(rsrc.getProject(), cherryPickedChangeId);
-    } catch (InvalidChangeOperationException e) {
-      throw new BadRequestException(e.getMessage());
-    } catch (IntegrationException | NoSuchChangeException e) {
-      throw new ResourceConflictException(e.getMessage());
-    }
-  }
-
-  @Override
-  public UiAction.Description getDescription(RevisionResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Cherry Pick")
-        .setTitle("Cherry pick change to a different branch")
-        .setVisible(
-            and(
-                rsrc.isCurrent(),
-                permissionBackend
-                    .user(user)
-                    .project(rsrc.getProject())
-                    .testCond(ProjectPermission.CREATE_CHANGE)));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
deleted file mode 100644
index 4f03f37..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
+++ /dev/null
@@ -1,414 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MergeConflictException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.MergeIdenticalTreeException;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-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.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import java.util.TimeZone;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.InvalidObjectIdException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.ChangeIdUtil;
-
-@Singleton
-public class CherryPickChange {
-
-  private final Provider<ReviewDb> dbProvider;
-  private final Sequences seq;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final GitRepositoryManager gitManager;
-  private final TimeZone serverTimeZone;
-  private final Provider<IdentifiedUser> user;
-  private final ChangeInserter.Factory changeInserterFactory;
-  private final PatchSetInserter.Factory patchSetInserterFactory;
-  private final MergeUtil.Factory mergeUtilFactory;
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final ProjectControl.GenericFactory projectControlFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final ChangeMessagesUtil changeMessagesUtil;
-  private final NotifyUtil notifyUtil;
-
-  @Inject
-  CherryPickChange(
-      Provider<ReviewDb> dbProvider,
-      Sequences seq,
-      Provider<InternalChangeQuery> queryProvider,
-      @GerritPersonIdent PersonIdent myIdent,
-      GitRepositoryManager gitManager,
-      Provider<IdentifiedUser> user,
-      ChangeInserter.Factory changeInserterFactory,
-      PatchSetInserter.Factory patchSetInserterFactory,
-      MergeUtil.Factory mergeUtilFactory,
-      ChangeNotes.Factory changeNotesFactory,
-      ProjectControl.GenericFactory projectControlFactory,
-      ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil changeMessagesUtil,
-      NotifyUtil notifyUtil) {
-    this.dbProvider = dbProvider;
-    this.seq = seq;
-    this.queryProvider = queryProvider;
-    this.gitManager = gitManager;
-    this.serverTimeZone = myIdent.getTimeZone();
-    this.user = user;
-    this.changeInserterFactory = changeInserterFactory;
-    this.patchSetInserterFactory = patchSetInserterFactory;
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.changeNotesFactory = changeNotesFactory;
-    this.projectControlFactory = projectControlFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.changeMessagesUtil = changeMessagesUtil;
-    this.notifyUtil = notifyUtil;
-  }
-
-  public Change.Id cherryPick(
-      BatchUpdate.Factory batchUpdateFactory,
-      Change change,
-      PatchSet patch,
-      CherryPickInput input,
-      Branch.NameKey dest)
-      throws OrmException, IOException, InvalidChangeOperationException, IntegrationException,
-          UpdateException, RestApiException, ConfigInvalidException, NoSuchProjectException {
-    return cherryPick(
-        batchUpdateFactory,
-        change,
-        patch.getId(),
-        change.getProject(),
-        ObjectId.fromString(patch.getRevision().get()),
-        input,
-        dest);
-  }
-
-  public Change.Id cherryPick(
-      BatchUpdate.Factory batchUpdateFactory,
-      @Nullable Change sourceChange,
-      @Nullable PatchSet.Id sourcePatchId,
-      Project.NameKey project,
-      ObjectId sourceCommit,
-      CherryPickInput input,
-      Branch.NameKey dest)
-      throws OrmException, IOException, InvalidChangeOperationException, IntegrationException,
-          UpdateException, RestApiException, ConfigInvalidException, NoSuchProjectException {
-
-    IdentifiedUser identifiedUser = user.get();
-    try (Repository git = gitManager.openRepository(project);
-        // This inserter and revwalk *must* be passed to any BatchUpdates
-        // created later on, to ensure the cherry-picked commit is flushed
-        // before patch sets are updated.
-        ObjectInserter oi = git.newObjectInserter();
-        ObjectReader reader = oi.newReader();
-        CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) {
-      Ref destRef = git.getRefDatabase().exactRef(dest.get());
-      if (destRef == null) {
-        throw new InvalidChangeOperationException(
-            String.format("Branch %s does not exist.", dest.get()));
-      }
-
-      RevCommit baseCommit = getBaseCommit(destRef, project.get(), revWalk, input.base);
-
-      CodeReviewCommit commitToCherryPick = revWalk.parseCommit(sourceCommit);
-
-      if (input.parent <= 0 || input.parent > commitToCherryPick.getParentCount()) {
-        throw new InvalidChangeOperationException(
-            String.format(
-                "Cherry Pick: Parent %s does not exist. Please specify a parent in"
-                    + " range [1, %s].",
-                input.parent, commitToCherryPick.getParentCount()));
-      }
-
-      Timestamp now = TimeUtil.nowTs();
-      PersonIdent committerIdent = identifiedUser.newCommitterIdent(now, serverTimeZone);
-
-      final ObjectId computedChangeId =
-          ChangeIdUtil.computeChangeId(
-              commitToCherryPick.getTree(),
-              baseCommit,
-              commitToCherryPick.getAuthorIdent(),
-              committerIdent,
-              input.message);
-      String commitMessage = ChangeIdUtil.insertId(input.message, computedChangeId).trim() + '\n';
-
-      CodeReviewCommit cherryPickCommit;
-      ProjectControl projectControl =
-          projectControlFactory.controlFor(dest.getParentKey(), identifiedUser);
-      try {
-        ProjectState projectState = projectControl.getProjectState();
-        cherryPickCommit =
-            mergeUtilFactory
-                .create(projectState)
-                .createCherryPickFromCommit(
-                    oi,
-                    git.getConfig(),
-                    baseCommit,
-                    commitToCherryPick,
-                    committerIdent,
-                    commitMessage,
-                    revWalk,
-                    input.parent - 1,
-                    false);
-
-        Change.Key changeKey;
-        final List<String> idList = cherryPickCommit.getFooterLines(FooterConstants.CHANGE_ID);
-        if (!idList.isEmpty()) {
-          final String idStr = idList.get(idList.size() - 1).trim();
-          changeKey = new Change.Key(idStr);
-        } else {
-          changeKey = new Change.Key("I" + computedChangeId.name());
-        }
-
-        Branch.NameKey newDest = new Branch.NameKey(project, destRef.getName());
-        List<ChangeData> destChanges =
-            queryProvider.get().setLimit(2).byBranchKey(newDest, changeKey);
-        if (destChanges.size() > 1) {
-          throw new InvalidChangeOperationException(
-              "Several changes with key "
-                  + changeKey
-                  + " reside on the same branch. "
-                  + "Cannot create a new patch set.");
-        }
-        try (BatchUpdate bu =
-            batchUpdateFactory.create(dbProvider.get(), project, identifiedUser, now)) {
-          bu.setRepository(git, revWalk, oi);
-          Change.Id result;
-          if (destChanges.size() == 1) {
-            // The change key exists on the destination branch. The cherry pick
-            // will be added as a new patch set.
-            result = insertPatchSet(bu, git, destChanges.get(0).notes(), cherryPickCommit, input);
-          } else {
-            // Change key not found on destination branch. We can create a new
-            // change.
-            String newTopic = null;
-            if (sourceChange != null && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
-              newTopic = sourceChange.getTopic() + "-" + newDest.getShortName();
-            }
-            result =
-                createNewChange(
-                    bu, cherryPickCommit, dest.get(), newTopic, sourceChange, sourceCommit, input);
-
-            if (sourceChange != null && sourcePatchId != null) {
-              bu.addOp(
-                  sourceChange.getId(),
-                  new AddMessageToSourceChangeOp(
-                      changeMessagesUtil, sourcePatchId, dest.getShortName(), cherryPickCommit));
-            }
-          }
-          bu.execute();
-          return result;
-        }
-      } catch (MergeIdenticalTreeException | MergeConflictException e) {
-        throw new IntegrationException("Cherry pick failed: " + e.getMessage());
-      }
-    }
-  }
-
-  private RevCommit getBaseCommit(Ref destRef, String project, RevWalk revWalk, String base)
-      throws RestApiException, IOException, OrmException {
-    RevCommit destRefTip = revWalk.parseCommit(destRef.getObjectId());
-    // The tip commit of the destination ref is the default base for the newly created change.
-    if (Strings.isNullOrEmpty(base)) {
-      return destRefTip;
-    }
-
-    ObjectId baseObjectId;
-    try {
-      baseObjectId = ObjectId.fromString(base);
-    } catch (InvalidObjectIdException e) {
-      throw new BadRequestException(String.format("Base %s doesn't represent a valid SHA-1", base));
-    }
-
-    RevCommit baseCommit = revWalk.parseCommit(baseObjectId);
-    InternalChangeQuery changeQuery = queryProvider.get();
-    changeQuery.enforceVisibility(true);
-    List<ChangeData> changeDatas = changeQuery.byBranchCommit(project, destRef.getName(), base);
-
-    if (changeDatas.isEmpty()) {
-      if (revWalk.isMergedInto(baseCommit, destRefTip)) {
-        // The base commit is a merged commit with no change associated.
-        return baseCommit;
-      }
-      throw new UnprocessableEntityException(
-          String.format("Commit %s does not exist on branch %s", base, destRef.getName()));
-    } else if (changeDatas.size() != 1) {
-      throw new ResourceConflictException("Multiple changes found for commit " + base);
-    }
-
-    Change change = changeDatas.get(0).change();
-    Change.Status status = change.getStatus();
-    if (status == Status.NEW || status == Status.MERGED) {
-      // The base commit is a valid change revision.
-      return baseCommit;
-    }
-
-    throw new ResourceConflictException(
-        String.format(
-            "Change %s with commit %s is %s", change.getChangeId(), base, status.asChangeStatus()));
-  }
-
-  private Change.Id insertPatchSet(
-      BatchUpdate bu,
-      Repository git,
-      ChangeNotes destNotes,
-      CodeReviewCommit cherryPickCommit,
-      CherryPickInput input)
-      throws IOException, OrmException, BadRequestException, ConfigInvalidException {
-    Change destChange = destNotes.getChange();
-    PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
-    PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, cherryPickCommit);
-    inserter
-        .setMessage("Uploaded patch set " + inserter.getPatchSetId().get() + ".")
-        .setNotify(input.notify)
-        .setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
-    bu.addOp(destChange.getId(), inserter);
-    return destChange.getId();
-  }
-
-  private Change.Id createNewChange(
-      BatchUpdate bu,
-      CodeReviewCommit cherryPickCommit,
-      String refName,
-      String topic,
-      @Nullable Change sourceChange,
-      ObjectId sourceCommit,
-      CherryPickInput input)
-      throws OrmException, IOException, BadRequestException, ConfigInvalidException {
-    Change.Id changeId = new Change.Id(seq.nextChangeId());
-    ChangeInserter ins = changeInserterFactory.create(changeId, cherryPickCommit, refName);
-    Branch.NameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
-    ins.setMessage(messageForDestinationChange(ins.getPatchSetId(), sourceBranch, sourceCommit))
-        .setTopic(topic)
-        .setNotify(input.notify)
-        .setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
-    if (input.keepReviewers && sourceChange != null) {
-      ReviewerSet reviewerSet =
-          approvalsUtil.getReviewers(
-              dbProvider.get(), changeNotesFactory.createChecked(dbProvider.get(), sourceChange));
-      Set<Account.Id> reviewers =
-          new HashSet<>(reviewerSet.byState(ReviewerStateInternal.REVIEWER));
-      reviewers.add(sourceChange.getOwner());
-      reviewers.remove(user.get().getAccountId());
-      Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
-      ccs.remove(user.get().getAccountId());
-      ins.setReviewers(reviewers).setExtraCC(ccs);
-    }
-    bu.insertChange(ins);
-    return changeId;
-  }
-
-  private static class AddMessageToSourceChangeOp implements BatchUpdateOp {
-    private final ChangeMessagesUtil cmUtil;
-    private final PatchSet.Id psId;
-    private final String destBranch;
-    private final ObjectId cherryPickCommit;
-
-    private AddMessageToSourceChangeOp(
-        ChangeMessagesUtil cmUtil, PatchSet.Id psId, String destBranch, ObjectId cherryPickCommit) {
-      this.cmUtil = cmUtil;
-      this.psId = psId;
-      this.destBranch = destBranch;
-      this.cherryPickCommit = cherryPickCommit;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException {
-      StringBuilder sb =
-          new StringBuilder("Patch Set ")
-              .append(psId.get())
-              .append(": Cherry Picked")
-              .append("\n\n")
-              .append("This patchset was cherry picked to branch ")
-              .append(destBranch)
-              .append(" as commit ")
-              .append(cherryPickCommit.name());
-      ChangeMessage changeMessage =
-          ChangeMessagesUtil.newMessage(
-              psId,
-              ctx.getUser(),
-              ctx.getWhen(),
-              sb.toString(),
-              ChangeMessagesUtil.TAG_CHERRY_PICK_CHANGE);
-      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
-      return true;
-    }
-  }
-
-  private String messageForDestinationChange(
-      PatchSet.Id patchSetId, Branch.NameKey sourceBranch, ObjectId sourceCommit) {
-    StringBuilder stringBuilder = new StringBuilder("Patch Set ").append(patchSetId.get());
-
-    if (sourceBranch != null) {
-      stringBuilder.append(": Cherry Picked from branch ").append(sourceBranch.getShortName());
-    } else {
-      stringBuilder.append(": Cherry Picked from commit ").append(sourceCommit.getName());
-    }
-
-    return stringBuilder.append(".").toString();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java
deleted file mode 100644
index 4980975..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java
+++ /dev/null
@@ -1,114 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.CommitResource;
-import com.google.gerrit.server.project.ContributorAgreementsChecker;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.revwalk.RevCommit;
-
-@Singleton
-public class CherryPickCommit
-    extends RetryingRestModifyView<CommitResource, CherryPickInput, ChangeInfo> {
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> user;
-  private final CherryPickChange cherryPickChange;
-  private final ChangeJson.Factory json;
-  private final ContributorAgreementsChecker contributorAgreements;
-
-  @Inject
-  CherryPickCommit(
-      RetryHelper retryHelper,
-      Provider<CurrentUser> user,
-      CherryPickChange cherryPickChange,
-      ChangeJson.Factory json,
-      PermissionBackend permissionBackend,
-      ContributorAgreementsChecker contributorAgreements) {
-    super(retryHelper);
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-    this.cherryPickChange = cherryPickChange;
-    this.json = json;
-    this.contributorAgreements = contributorAgreements;
-  }
-
-  @Override
-  public ChangeInfo applyImpl(
-      BatchUpdate.Factory updateFactory, CommitResource rsrc, CherryPickInput input)
-      throws OrmException, IOException, UpdateException, RestApiException,
-          PermissionBackendException, ConfigInvalidException, NoSuchProjectException {
-    RevCommit commit = rsrc.getCommit();
-    String message = Strings.nullToEmpty(input.message).trim();
-    input.message = message.isEmpty() ? commit.getFullMessage() : message;
-    String destination = Strings.nullToEmpty(input.destination).trim();
-    input.parent = input.parent == null ? 1 : input.parent;
-    Project.NameKey projectName = rsrc.getProjectState().getNameKey();
-
-    if (destination.isEmpty()) {
-      throw new BadRequestException("destination must be non-empty");
-    }
-
-    String refName = RefNames.fullName(destination);
-    contributorAgreements.check(projectName, user.get());
-    permissionBackend
-        .user(user)
-        .project(projectName)
-        .ref(refName)
-        .check(RefPermission.CREATE_CHANGE);
-
-    try {
-      Change.Id cherryPickedChangeId =
-          cherryPickChange.cherryPick(
-              updateFactory,
-              null,
-              null,
-              projectName,
-              commit,
-              input,
-              new Branch.NameKey(rsrc.getProjectState().getNameKey(), refName));
-      return json.noOptions().format(projectName, cherryPickedChangeId);
-    } catch (InvalidChangeOperationException e) {
-      throw new BadRequestException(e.getMessage());
-    } catch (IntegrationException e) {
-      throw new ResourceConflictException(e.getMessage());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
deleted file mode 100644
index 0ebd84b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
+++ /dev/null
@@ -1,216 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.server.CommentsUtil.COMMENT_INFO_ORDER;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.client.Comment.Range;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.FixReplacementInfo;
-import com.google.gerrit.extensions.common.FixSuggestionInfo;
-import com.google.gerrit.extensions.common.RobotCommentInfo;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.FixReplacement;
-import com.google.gerrit.reviewdb.client.FixSuggestion;
-import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
-
-class CommentJson {
-
-  private final AccountLoader.Factory accountLoaderFactory;
-
-  private boolean fillAccounts = true;
-  private boolean fillPatchSet;
-
-  @Inject
-  CommentJson(AccountLoader.Factory accountLoaderFactory) {
-    this.accountLoaderFactory = accountLoaderFactory;
-  }
-
-  CommentJson setFillAccounts(boolean fillAccounts) {
-    this.fillAccounts = fillAccounts;
-    return this;
-  }
-
-  CommentJson setFillPatchSet(boolean fillPatchSet) {
-    this.fillPatchSet = fillPatchSet;
-    return this;
-  }
-
-  public CommentFormatter newCommentFormatter() {
-    return new CommentFormatter();
-  }
-
-  public RobotCommentFormatter newRobotCommentFormatter() {
-    return new RobotCommentFormatter();
-  }
-
-  private abstract class BaseCommentFormatter<F extends Comment, T extends CommentInfo> {
-    public T format(F comment) throws OrmException {
-      AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
-      T info = toInfo(comment, loader);
-      if (loader != null) {
-        loader.fill();
-      }
-      return info;
-    }
-
-    public Map<String, List<T>> format(Iterable<F> comments) throws OrmException {
-      AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
-
-      Map<String, List<T>> out = new TreeMap<>();
-
-      for (F c : comments) {
-        T o = toInfo(c, loader);
-        List<T> list = out.get(o.path);
-        if (list == null) {
-          list = new ArrayList<>();
-          out.put(o.path, list);
-        }
-        o.path = null;
-        list.add(o);
-      }
-
-      for (List<T> list : out.values()) {
-        Collections.sort(list, COMMENT_INFO_ORDER);
-      }
-
-      if (loader != null) {
-        loader.fill();
-      }
-      return out;
-    }
-
-    public List<T> formatAsList(Iterable<F> comments) throws OrmException {
-      AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
-
-      List<T> out =
-          FluentIterable.from(comments)
-              .transform(c -> toInfo(c, loader))
-              .toSortedList(COMMENT_INFO_ORDER);
-
-      if (loader != null) {
-        loader.fill();
-      }
-      return out;
-    }
-
-    protected abstract T toInfo(F comment, AccountLoader loader);
-
-    protected void fillCommentInfo(Comment c, CommentInfo r, AccountLoader loader) {
-      if (fillPatchSet) {
-        r.patchSet = c.key.patchSetId;
-      }
-      r.id = Url.encode(c.key.uuid);
-      r.path = c.key.filename;
-      if (c.side <= 0) {
-        r.side = Side.PARENT;
-        if (c.side < 0) {
-          r.parent = -c.side;
-        }
-      }
-      if (c.lineNbr > 0) {
-        r.line = c.lineNbr;
-      }
-      r.inReplyTo = Url.encode(c.parentUuid);
-      r.message = Strings.emptyToNull(c.message);
-      r.updated = c.writtenOn;
-      r.range = toRange(c.range);
-      r.tag = c.tag;
-      r.unresolved = c.unresolved;
-      if (loader != null) {
-        r.author = loader.get(c.author.getId());
-      }
-    }
-
-    protected Range toRange(Comment.Range commentRange) {
-      Range range = null;
-      if (commentRange != null) {
-        range = new Range();
-        range.startLine = commentRange.startLine;
-        range.startCharacter = commentRange.startChar;
-        range.endLine = commentRange.endLine;
-        range.endCharacter = commentRange.endChar;
-      }
-      return range;
-    }
-  }
-
-  class CommentFormatter extends BaseCommentFormatter<Comment, CommentInfo> {
-    @Override
-    protected CommentInfo toInfo(Comment c, AccountLoader loader) {
-      CommentInfo ci = new CommentInfo();
-      fillCommentInfo(c, ci, loader);
-      return ci;
-    }
-
-    private CommentFormatter() {}
-  }
-
-  class RobotCommentFormatter extends BaseCommentFormatter<RobotComment, RobotCommentInfo> {
-    @Override
-    protected RobotCommentInfo toInfo(RobotComment c, AccountLoader loader) {
-      RobotCommentInfo rci = new RobotCommentInfo();
-      rci.robotId = c.robotId;
-      rci.robotRunId = c.robotRunId;
-      rci.url = c.url;
-      rci.properties = c.properties;
-      rci.fixSuggestions = toFixSuggestionInfos(c.fixSuggestions);
-      fillCommentInfo(c, rci, loader);
-      return rci;
-    }
-
-    private List<FixSuggestionInfo> toFixSuggestionInfos(
-        @Nullable List<FixSuggestion> fixSuggestions) {
-      if (fixSuggestions == null || fixSuggestions.isEmpty()) {
-        return null;
-      }
-
-      return fixSuggestions.stream().map(this::toFixSuggestionInfo).collect(toList());
-    }
-
-    private FixSuggestionInfo toFixSuggestionInfo(FixSuggestion fixSuggestion) {
-      FixSuggestionInfo fixSuggestionInfo = new FixSuggestionInfo();
-      fixSuggestionInfo.fixId = fixSuggestion.fixId;
-      fixSuggestionInfo.description = fixSuggestion.description;
-      fixSuggestionInfo.replacements =
-          fixSuggestion.replacements.stream().map(this::toFixReplacementInfo).collect(toList());
-      return fixSuggestionInfo;
-    }
-
-    private FixReplacementInfo toFixReplacementInfo(FixReplacement fixReplacement) {
-      FixReplacementInfo fixReplacementInfo = new FixReplacementInfo();
-      fixReplacementInfo.path = fixReplacement.path;
-      fixReplacementInfo.range = toRange(fixReplacement.range);
-      fixReplacementInfo.replacement = fixReplacement.replacement;
-      return fixReplacementInfo;
-    }
-
-    private RobotCommentFormatter() {}
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java
deleted file mode 100644
index f7fc576..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.inject.TypeLiteral;
-
-public class CommentResource implements RestResource {
-  public static final TypeLiteral<RestView<CommentResource>> COMMENT_KIND =
-      new TypeLiteral<RestView<CommentResource>>() {};
-
-  private final RevisionResource rev;
-  private final Comment comment;
-
-  public CommentResource(RevisionResource rev, Comment c) {
-    this.rev = rev;
-    this.comment = c;
-  }
-
-  public PatchSet getPatchSet() {
-    return rev.getPatchSet();
-  }
-
-  Comment getComment() {
-    return comment;
-  }
-
-  String getId() {
-    return comment.key.uuid;
-  }
-
-  Account.Id getAuthorId() {
-    return comment.author.getId();
-  }
-
-  RevisionResource getRevisionResource() {
-    return rev;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Comments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Comments.java
deleted file mode 100644
index 935aa4e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Comments.java
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class Comments implements ChildCollection<RevisionResource, CommentResource> {
-  private final DynamicMap<RestView<CommentResource>> views;
-  private final ListRevisionComments list;
-  private final Provider<ReviewDb> dbProvider;
-  private final CommentsUtil commentsUtil;
-
-  @Inject
-  Comments(
-      DynamicMap<RestView<CommentResource>> views,
-      ListRevisionComments list,
-      Provider<ReviewDb> dbProvider,
-      CommentsUtil commentsUtil) {
-    this.views = views;
-    this.list = list;
-    this.dbProvider = dbProvider;
-    this.commentsUtil = commentsUtil;
-  }
-
-  @Override
-  public DynamicMap<RestView<CommentResource>> views() {
-    return views;
-  }
-
-  @Override
-  public ListRevisionComments list() {
-    return list;
-  }
-
-  @Override
-  public CommentResource parse(RevisionResource rev, IdString id)
-      throws ResourceNotFoundException, OrmException {
-    String uuid = id.get();
-    ChangeNotes notes = rev.getNotes();
-
-    for (Comment c :
-        commentsUtil.publishedByPatchSet(dbProvider.get(), notes, rev.getPatchSet().getId())) {
-      if (uuid.equals(c.key.uuid)) {
-        return new CommentResource(rev, c);
-      }
-    }
-    throw new ResourceNotFoundException(id);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
deleted file mode 100644
index a149935..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ /dev/null
@@ -1,785 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
-import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
-import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.Collections2;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
-import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.FixInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.common.ProblemInfo;
-import com.google.gerrit.extensions.common.ProblemInfo.Status;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.PatchSetState;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Checks changes for various kinds of inconsistency and corruption.
- *
- * <p>A single instance may be reused for checking multiple changes, but not concurrently.
- */
-public class ConsistencyChecker {
-  private static final Logger log = LoggerFactory.getLogger(ConsistencyChecker.class);
-
-  @AutoValue
-  public abstract static class Result {
-    private static Result create(ChangeNotes notes, List<ProblemInfo> problems) {
-      return new AutoValue_ConsistencyChecker_Result(
-          notes.getChangeId(), notes.getChange(), problems);
-    }
-
-    public abstract Change.Id id();
-
-    @Nullable
-    public abstract Change change();
-
-    public abstract List<ProblemInfo> problems();
-  }
-
-  private final ChangeNotes.Factory notesFactory;
-  private final Accounts accounts;
-  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
-  private final GitRepositoryManager repoManager;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final PatchSetInserter.Factory patchSetInserterFactory;
-  private final PatchSetUtil psUtil;
-  private final Provider<CurrentUser> user;
-  private final Provider<PersonIdent> serverIdent;
-  private final Provider<ReviewDb> db;
-  private final RetryHelper retryHelper;
-
-  private BatchUpdate.Factory updateFactory;
-  private FixInput fix;
-  private ChangeNotes notes;
-  private Repository repo;
-  private RevWalk rw;
-  private ObjectInserter oi;
-
-  private RevCommit tip;
-  private SetMultimap<ObjectId, PatchSet> patchSetsBySha;
-  private PatchSet currPs;
-  private RevCommit currPsCommit;
-
-  private List<ProblemInfo> problems;
-
-  @Inject
-  ConsistencyChecker(
-      @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      ChangeNotes.Factory notesFactory,
-      Accounts accounts,
-      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
-      GitRepositoryManager repoManager,
-      PatchSetInfoFactory patchSetInfoFactory,
-      PatchSetInserter.Factory patchSetInserterFactory,
-      PatchSetUtil psUtil,
-      Provider<CurrentUser> user,
-      Provider<ReviewDb> db,
-      RetryHelper retryHelper) {
-    this.accounts = accounts;
-    this.accountPatchReviewStore = accountPatchReviewStore;
-    this.db = db;
-    this.notesFactory = notesFactory;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.patchSetInserterFactory = patchSetInserterFactory;
-    this.psUtil = psUtil;
-    this.repoManager = repoManager;
-    this.retryHelper = retryHelper;
-    this.serverIdent = serverIdent;
-    this.user = user;
-    reset();
-  }
-
-  private void reset() {
-    updateFactory = null;
-    notes = null;
-    repo = null;
-    rw = null;
-    problems = new ArrayList<>();
-  }
-
-  private Change change() {
-    return notes.getChange();
-  }
-
-  public Result check(ChangeNotes notes, @Nullable FixInput f) {
-    checkNotNull(notes);
-    try {
-      return retryHelper.execute(
-          buf -> {
-            try {
-              reset();
-              this.updateFactory = buf;
-              this.notes = notes;
-              fix = f;
-              checkImpl();
-              return result();
-            } finally {
-              if (rw != null) {
-                rw.getObjectReader().close();
-                rw.close();
-                oi.close();
-              }
-              if (repo != null) {
-                repo.close();
-              }
-            }
-          });
-    } catch (RestApiException e) {
-      return logAndReturnOneProblem(e, notes, "Error checking change: " + e.getMessage());
-    } catch (UpdateException e) {
-      return logAndReturnOneProblem(e, notes, "Error checking change");
-    }
-  }
-
-  private Result logAndReturnOneProblem(Exception e, ChangeNotes notes, String problem) {
-    log.warn("Error checking change " + notes.getChangeId(), e);
-    return Result.create(notes, ImmutableList.of(problem(problem)));
-  }
-
-  private void checkImpl() {
-    checkOwner();
-    checkCurrentPatchSetEntity();
-
-    // All checks that require the repo.
-    if (!openRepo()) {
-      return;
-    }
-    if (!checkPatchSets()) {
-      return;
-    }
-    checkMerged();
-  }
-
-  private void checkOwner() {
-    try {
-      if (accounts.get(change().getOwner()) == null) {
-        problem("Missing change owner: " + change().getOwner());
-      }
-    } catch (IOException | ConfigInvalidException e) {
-      error("Failed to look up owner", e);
-    }
-  }
-
-  private void checkCurrentPatchSetEntity() {
-    try {
-      currPs = psUtil.current(db.get(), notes);
-      if (currPs == null) {
-        problem(
-            String.format("Current patch set %d not found", change().currentPatchSetId().get()));
-      }
-    } catch (OrmException e) {
-      error("Failed to look up current patch set", e);
-    }
-  }
-
-  private boolean openRepo() {
-    Project.NameKey project = change().getDest().getParentKey();
-    try {
-      repo = repoManager.openRepository(project);
-      oi = repo.newObjectInserter();
-      rw = new RevWalk(oi.newReader());
-      return true;
-    } catch (RepositoryNotFoundException e) {
-      return error("Destination repository not found: " + project, e);
-    } catch (IOException e) {
-      return error("Failed to open repository: " + project, e);
-    }
-  }
-
-  private boolean checkPatchSets() {
-    List<PatchSet> all;
-    try {
-      // Iterate in descending order.
-      all = PS_ID_ORDER.sortedCopy(psUtil.byChange(db.get(), notes));
-    } catch (OrmException e) {
-      return error("Failed to look up patch sets", e);
-    }
-    patchSetsBySha = MultimapBuilder.hashKeys(all.size()).treeSetValues(PS_ID_ORDER).build();
-
-    Map<String, Ref> refs;
-    try {
-      refs =
-          repo.getRefDatabase()
-              .exactRef(all.stream().map(ps -> ps.getId().toRefName()).toArray(String[]::new));
-    } catch (IOException e) {
-      error("error reading refs", e);
-      refs = Collections.emptyMap();
-    }
-
-    List<DeletePatchSetFromDbOp> deletePatchSetOps = new ArrayList<>();
-    for (PatchSet ps : all) {
-      // Check revision format.
-      int psNum = ps.getId().get();
-      String refName = ps.getId().toRefName();
-      ObjectId objId = parseObjectId(ps.getRevision().get(), "patch set " + psNum);
-      if (objId == null) {
-        continue;
-      }
-      patchSetsBySha.put(objId, ps);
-
-      // Check ref existence.
-      ProblemInfo refProblem = null;
-      Ref ref = refs.get(refName);
-      if (ref == null) {
-        refProblem = problem("Ref missing: " + refName);
-      } else if (!objId.equals(ref.getObjectId())) {
-        String actual = ref.getObjectId() != null ? ref.getObjectId().name() : "null";
-        refProblem =
-            problem(
-                String.format(
-                    "Expected %s to point to %s, found %s", ref.getName(), objId.name(), actual));
-      }
-
-      // Check object existence.
-      RevCommit psCommit = parseCommit(objId, String.format("patch set %d", psNum));
-      if (psCommit == null) {
-        if (fix != null && fix.deletePatchSetIfCommitMissing) {
-          deletePatchSetOps.add(new DeletePatchSetFromDbOp(lastProblem(), ps.getId()));
-        }
-        continue;
-      } else if (refProblem != null && fix != null) {
-        fixPatchSetRef(refProblem, ps);
-      }
-      if (ps.getId().equals(change().currentPatchSetId())) {
-        currPsCommit = psCommit;
-      }
-    }
-
-    // Delete any bad patch sets found above, in a single update.
-    deletePatchSets(deletePatchSetOps);
-
-    // Check for duplicates.
-    for (Map.Entry<ObjectId, Collection<PatchSet>> e : patchSetsBySha.asMap().entrySet()) {
-      if (e.getValue().size() > 1) {
-        problem(
-            String.format(
-                "Multiple patch sets pointing to %s: %s",
-                e.getKey().name(), Collections2.transform(e.getValue(), PatchSet::getPatchSetId)));
-      }
-    }
-
-    return currPs != null && currPsCommit != null;
-  }
-
-  private void checkMerged() {
-    String refName = change().getDest().get();
-    Ref dest;
-    try {
-      dest = repo.getRefDatabase().exactRef(refName);
-    } catch (IOException e) {
-      problem("Failed to look up destination ref: " + refName);
-      return;
-    }
-    if (dest == null) {
-      problem("Destination ref not found (may be new branch): " + refName);
-      return;
-    }
-    tip = parseCommit(dest.getObjectId(), "destination ref " + refName);
-    if (tip == null) {
-      return;
-    }
-
-    if (fix != null && fix.expectMergedAs != null) {
-      checkExpectMergedAs();
-    } else {
-      boolean merged;
-      try {
-        merged = rw.isMergedInto(currPsCommit, tip);
-      } catch (IOException e) {
-        problem("Error checking whether patch set " + currPs.getId().get() + " is merged");
-        return;
-      }
-      checkMergedBitMatchesStatus(currPs.getId(), currPsCommit, merged);
-    }
-  }
-
-  private ProblemInfo wrongChangeStatus(PatchSet.Id psId, RevCommit commit) {
-    String refName = change().getDest().get();
-    return problem(
-        String.format(
-            "Patch set %d (%s) is merged into destination ref %s (%s), but change"
-                + " status is %s",
-            psId.get(), commit.name(), refName, tip.name(), change().getStatus()));
-  }
-
-  private void checkMergedBitMatchesStatus(PatchSet.Id psId, RevCommit commit, boolean merged) {
-    String refName = change().getDest().get();
-    if (merged && change().getStatus() != Change.Status.MERGED) {
-      ProblemInfo p = wrongChangeStatus(psId, commit);
-      if (fix != null) {
-        fixMerged(p);
-      }
-    } else if (!merged && change().getStatus() == Change.Status.MERGED) {
-      problem(
-          String.format(
-              "Patch set %d (%s) is not merged into"
-                  + " destination ref %s (%s), but change status is %s",
-              currPs.getId().get(), commit.name(), refName, tip.name(), change().getStatus()));
-    }
-  }
-
-  private void checkExpectMergedAs() {
-    ObjectId objId = parseObjectId(fix.expectMergedAs, "expected merged commit");
-    RevCommit commit = parseCommit(objId, "expected merged commit");
-    if (commit == null) {
-      return;
-    }
-
-    try {
-      if (!rw.isMergedInto(commit, tip)) {
-        problem(
-            String.format(
-                "Expected merged commit %s is not merged into destination ref %s (%s)",
-                commit.name(), change().getDest().get(), tip.name()));
-        return;
-      }
-
-      List<PatchSet.Id> thisCommitPsIds = new ArrayList<>();
-      for (Ref ref : repo.getRefDatabase().getRefs(REFS_CHANGES).values()) {
-        if (!ref.getObjectId().equals(commit)) {
-          continue;
-        }
-        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-        if (psId == null) {
-          continue;
-        }
-        try {
-          Change c =
-              notesFactory
-                  .createChecked(db.get(), change().getProject(), psId.getParentKey())
-                  .getChange();
-          if (!c.getDest().equals(change().getDest())) {
-            continue;
-          }
-        } catch (OrmException e) {
-          warn(e);
-          // Include this patch set; should cause an error below, which is good.
-        }
-        thisCommitPsIds.add(psId);
-      }
-      switch (thisCommitPsIds.size()) {
-        case 0:
-          // No patch set for this commit; insert one.
-          rw.parseBody(commit);
-          String changeId =
-              Iterables.getFirst(commit.getFooterLines(FooterConstants.CHANGE_ID), null);
-          // Missing Change-Id footer is ok, but mismatched is not.
-          if (changeId != null && !changeId.equals(change().getKey().get())) {
-            problem(
-                String.format(
-                    "Expected merged commit %s has Change-Id: %s, but expected %s",
-                    commit.name(), changeId, change().getKey().get()));
-            return;
-          }
-          insertMergedPatchSet(commit, null, false);
-          break;
-
-        case 1:
-          // Existing patch set ref pointing to this commit.
-          PatchSet.Id id = thisCommitPsIds.get(0);
-          if (id.equals(change().currentPatchSetId())) {
-            // If it's the current patch set, we can just fix the status.
-            fixMerged(wrongChangeStatus(id, commit));
-          } else if (id.get() > change().currentPatchSetId().get()) {
-            // If it's newer than the current patch set, reuse this patch set
-            // ID when inserting a new merged patch set.
-            insertMergedPatchSet(commit, id, true);
-          } else {
-            // If it's older than the current patch set, just delete the old
-            // ref, and use a new ID when inserting a new merged patch set.
-            insertMergedPatchSet(commit, id, false);
-          }
-          break;
-
-        default:
-          problem(
-              String.format(
-                  "Multiple patch sets for expected merged commit %s: %s",
-                  commit.name(), intKeyOrdering().sortedCopy(thisCommitPsIds)));
-          break;
-      }
-    } catch (IOException e) {
-      error("Error looking up expected merged commit " + fix.expectMergedAs, e);
-    }
-  }
-
-  private void insertMergedPatchSet(
-      final RevCommit commit, @Nullable PatchSet.Id psIdToDelete, boolean reuseOldPsId) {
-    ProblemInfo notFound = problem("No patch set found for merged commit " + commit.name());
-    if (!user.get().isIdentifiedUser()) {
-      notFound.status = Status.FIX_FAILED;
-      notFound.outcome = "Must be called by an identified user to insert new patch set";
-      return;
-    }
-    ProblemInfo insertPatchSetProblem;
-    ProblemInfo deleteOldPatchSetProblem;
-
-    if (psIdToDelete == null) {
-      insertPatchSetProblem =
-          problem(
-              String.format(
-                  "Expected merged commit %s has no associated patch set", commit.name()));
-      deleteOldPatchSetProblem = null;
-    } else {
-      String msg =
-          String.format(
-              "Expected merge commit %s corresponds to patch set %s,"
-                  + " not the current patch set %s",
-              commit.name(), psIdToDelete.get(), change().currentPatchSetId().get());
-      // Maybe an identical problem, but different fix.
-      deleteOldPatchSetProblem = reuseOldPsId ? null : problem(msg);
-      insertPatchSetProblem = problem(msg);
-    }
-
-    List<ProblemInfo> currProblems = new ArrayList<>(3);
-    currProblems.add(notFound);
-    if (deleteOldPatchSetProblem != null) {
-      currProblems.add(insertPatchSetProblem);
-    }
-    currProblems.add(insertPatchSetProblem);
-
-    try {
-      PatchSet.Id psId =
-          (psIdToDelete != null && reuseOldPsId)
-              ? psIdToDelete
-              : ChangeUtil.nextPatchSetId(repo, change().currentPatchSetId());
-      PatchSetInserter inserter = patchSetInserterFactory.create(notes, psId, commit);
-      try (BatchUpdate bu = newBatchUpdate()) {
-        bu.setRepository(repo, rw, oi);
-
-        if (psIdToDelete != null) {
-          // Delete the given patch set ref. If reuseOldPsId is true,
-          // PatchSetInserter will reinsert the same ref, making it a no-op.
-          bu.addOp(
-              notes.getChangeId(),
-              new BatchUpdateOp() {
-                @Override
-                public void updateRepo(RepoContext ctx) throws IOException {
-                  ctx.addRefUpdate(commit, ObjectId.zeroId(), psIdToDelete.toRefName());
-                }
-              });
-          if (!reuseOldPsId) {
-            bu.addOp(
-                notes.getChangeId(),
-                new DeletePatchSetFromDbOp(checkNotNull(deleteOldPatchSetProblem), psIdToDelete));
-          }
-        }
-
-        bu.addOp(
-            notes.getChangeId(),
-            inserter
-                .setValidate(false)
-                .setFireRevisionCreated(false)
-                .setNotify(NotifyHandling.NONE)
-                .setAllowClosed(true)
-                .setMessage("Patch set for merged commit inserted by consistency checker"));
-        bu.addOp(notes.getChangeId(), new FixMergedOp(notFound));
-        bu.execute();
-      }
-      notes = notesFactory.createChecked(db.get(), inserter.getChange());
-      insertPatchSetProblem.status = Status.FIXED;
-      insertPatchSetProblem.outcome = "Inserted as patch set " + psId.get();
-    } catch (OrmException | IOException | UpdateException | RestApiException e) {
-      warn(e);
-      for (ProblemInfo pi : currProblems) {
-        pi.status = Status.FIX_FAILED;
-        pi.outcome = "Error inserting merged patch set";
-      }
-      return;
-    }
-  }
-
-  private static class FixMergedOp implements BatchUpdateOp {
-    private final ProblemInfo p;
-
-    private FixMergedOp(ProblemInfo p) {
-      this.p = p;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException {
-      ctx.getChange().setStatus(Change.Status.MERGED);
-      ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
-      p.status = Status.FIXED;
-      p.outcome = "Marked change as merged";
-      return true;
-    }
-  }
-
-  private void fixMerged(ProblemInfo p) {
-    try (BatchUpdate bu = newBatchUpdate()) {
-      bu.setRepository(repo, rw, oi);
-      bu.addOp(notes.getChangeId(), new FixMergedOp(p));
-      bu.execute();
-    } catch (UpdateException | RestApiException e) {
-      log.warn("Error marking " + notes.getChangeId() + "as merged", e);
-      p.status = Status.FIX_FAILED;
-      p.outcome = "Error updating status to merged";
-    }
-  }
-
-  private BatchUpdate newBatchUpdate() {
-    return updateFactory.create(db.get(), change().getProject(), user.get(), TimeUtil.nowTs());
-  }
-
-  private void fixPatchSetRef(ProblemInfo p, PatchSet ps) {
-    try {
-      RefUpdate ru = repo.updateRef(ps.getId().toRefName());
-      ru.setForceUpdate(true);
-      ru.setNewObjectId(ObjectId.fromString(ps.getRevision().get()));
-      ru.setRefLogIdent(newRefLogIdent());
-      ru.setRefLogMessage("Repair patch set ref", true);
-      RefUpdate.Result result = ru.update();
-      switch (result) {
-        case NEW:
-        case FORCED:
-        case FAST_FORWARD:
-        case NO_CHANGE:
-          p.status = Status.FIXED;
-          p.outcome = "Repaired patch set ref";
-          return;
-        case IO_FAILURE:
-        case LOCK_FAILURE:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case REJECTED_CURRENT_BRANCH:
-        case RENAMED:
-        case REJECTED_MISSING_OBJECT:
-        case REJECTED_OTHER_REASON:
-        default:
-          p.status = Status.FIX_FAILED;
-          p.outcome = "Failed to update patch set ref: " + result;
-          return;
-      }
-    } catch (IOException e) {
-      String msg = "Error fixing patch set ref";
-      log.warn(msg + ' ' + ps.getId().toRefName(), e);
-      p.status = Status.FIX_FAILED;
-      p.outcome = msg;
-    }
-  }
-
-  private void deletePatchSets(List<DeletePatchSetFromDbOp> ops) {
-    try (BatchUpdate bu = newBatchUpdate()) {
-      bu.setRepository(repo, rw, oi);
-      for (DeletePatchSetFromDbOp op : ops) {
-        checkArgument(op.psId.getParentKey().equals(notes.getChangeId()));
-        bu.addOp(notes.getChangeId(), op);
-      }
-      bu.addOp(notes.getChangeId(), new UpdateCurrentPatchSetOp(ops));
-      bu.execute();
-    } catch (NoPatchSetsWouldRemainException e) {
-      for (DeletePatchSetFromDbOp op : ops) {
-        op.p.status = Status.FIX_FAILED;
-        op.p.outcome = e.getMessage();
-      }
-    } catch (UpdateException | RestApiException e) {
-      String msg = "Error deleting patch set";
-      log.warn(msg + " of change " + ops.get(0).psId.getParentKey(), e);
-      for (DeletePatchSetFromDbOp op : ops) {
-        // Overwrite existing statuses that were set before the transaction was
-        // rolled back.
-        op.p.status = Status.FIX_FAILED;
-        op.p.outcome = msg;
-      }
-    }
-  }
-
-  private class DeletePatchSetFromDbOp implements BatchUpdateOp {
-    private final ProblemInfo p;
-    private final PatchSet.Id psId;
-
-    private DeletePatchSetFromDbOp(ProblemInfo p, PatchSet.Id psId) {
-      this.p = p;
-      this.psId = psId;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws OrmException, PatchSetInfoNotAvailableException {
-      // Delete dangling key references.
-      ReviewDb db = DeleteChangeOp.unwrap(ctx.getDb());
-      accountPatchReviewStore.get().clearReviewed(psId);
-      db.changeMessages().delete(db.changeMessages().byChange(psId.getParentKey()));
-      db.patchSetApprovals().delete(db.patchSetApprovals().byPatchSet(psId));
-      db.patchComments().delete(db.patchComments().byPatchSet(psId));
-      db.patchSets().deleteKeys(Collections.singleton(psId));
-
-      // NoteDb requires no additional fiddling; setting the state to deleted is
-      // sufficient to filter everything else out.
-      ctx.getUpdate(psId).setPatchSetState(PatchSetState.DELETED);
-
-      p.status = Status.FIXED;
-      p.outcome = "Deleted patch set";
-      return true;
-    }
-  }
-
-  private static class NoPatchSetsWouldRemainException extends RestApiException {
-    private static final long serialVersionUID = 1L;
-
-    private NoPatchSetsWouldRemainException() {
-      super("Cannot delete patch set; no patch sets would remain");
-    }
-  }
-
-  private class UpdateCurrentPatchSetOp implements BatchUpdateOp {
-    private final Set<PatchSet.Id> toDelete;
-
-    private UpdateCurrentPatchSetOp(List<DeletePatchSetFromDbOp> deleteOps) {
-      toDelete = new HashSet<>();
-      for (DeletePatchSetFromDbOp op : deleteOps) {
-        toDelete.add(op.psId);
-      }
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws OrmException, PatchSetInfoNotAvailableException, NoPatchSetsWouldRemainException {
-      if (!toDelete.contains(ctx.getChange().currentPatchSetId())) {
-        return false;
-      }
-      Set<PatchSet.Id> all = new HashSet<>();
-      // Doesn't make any assumptions about the order in which deletes happen
-      // and whether they are seen by this op; we are already given the full set
-      // of patch sets that will eventually be deleted in this update.
-      for (PatchSet ps : psUtil.byChange(ctx.getDb(), ctx.getNotes())) {
-        if (!toDelete.contains(ps.getId())) {
-          all.add(ps.getId());
-        }
-      }
-      if (all.isEmpty()) {
-        throw new NoPatchSetsWouldRemainException();
-      }
-      PatchSet.Id latest = ReviewDbUtil.intKeyOrdering().max(all);
-      ctx.getChange()
-          .setCurrentPatchSet(patchSetInfoFactory.get(ctx.getDb(), ctx.getNotes(), latest));
-      return true;
-    }
-  }
-
-  private PersonIdent newRefLogIdent() {
-    CurrentUser u = user.get();
-    if (u.isIdentifiedUser()) {
-      return u.asIdentifiedUser().newRefLogIdent();
-    }
-    return serverIdent.get();
-  }
-
-  private ObjectId parseObjectId(String objIdStr, String desc) {
-    try {
-      return ObjectId.fromString(objIdStr);
-    } catch (IllegalArgumentException e) {
-      problem(String.format("Invalid revision on %s: %s", desc, objIdStr));
-      return null;
-    }
-  }
-
-  private RevCommit parseCommit(ObjectId objId, String desc) {
-    try {
-      return rw.parseCommit(objId);
-    } catch (MissingObjectException e) {
-      problem(String.format("Object missing: %s: %s", desc, objId.name()));
-    } catch (IncorrectObjectTypeException e) {
-      problem(String.format("Not a commit: %s: %s", desc, objId.name()));
-    } catch (IOException e) {
-      problem(String.format("Failed to look up: %s: %s", desc, objId.name()));
-    }
-    return null;
-  }
-
-  private ProblemInfo problem(String msg) {
-    ProblemInfo p = new ProblemInfo();
-    p.message = checkNotNull(msg);
-    problems.add(p);
-    return p;
-  }
-
-  private ProblemInfo lastProblem() {
-    return problems.get(problems.size() - 1);
-  }
-
-  private boolean error(String msg, Throwable t) {
-    problem(msg);
-    // TODO(dborowitz): Expose stack trace to administrators.
-    warn(t);
-    return false;
-  }
-
-  private void warn(Throwable t) {
-    log.warn("Error in consistency check of change " + notes.getChangeId(), t);
-  }
-
-  private Result result() {
-    return Result.create(notes, problems);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
deleted file mode 100644
index ba8701c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
+++ /dev/null
@@ -1,345 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.extensions.common.MergeInput;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeFinder;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.CommitsCollection;
-import com.google.gerrit.server.project.ContributorAgreementsChecker;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.ProjectsCollection;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.io.UnsupportedEncodingException;
-import java.sql.Timestamp;
-import java.util.Collections;
-import java.util.List;
-import java.util.TimeZone;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.TreeFormatter;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.ChangeIdUtil;
-
-@Singleton
-public class CreateChange
-    extends RetryingRestModifyView<TopLevelResource, ChangeInput, Response<ChangeInfo>> {
-  private final String anonymousCowardName;
-  private final Provider<ReviewDb> db;
-  private final GitRepositoryManager gitManager;
-  private final AccountCache accountCache;
-  private final Sequences seq;
-  private final TimeZone serverTimeZone;
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> user;
-  private final ProjectsCollection projectsCollection;
-  private final CommitsCollection commits;
-  private final ChangeInserter.Factory changeInserterFactory;
-  private final ChangeJson.Factory jsonFactory;
-  private final ChangeFinder changeFinder;
-  private final PatchSetUtil psUtil;
-  private final MergeUtil.Factory mergeUtilFactory;
-  private final SubmitType submitType;
-  private final NotifyUtil notifyUtil;
-  private final ContributorAgreementsChecker contributorAgreements;
-
-  @Inject
-  CreateChange(
-      @AnonymousCowardName String anonymousCowardName,
-      Provider<ReviewDb> db,
-      GitRepositoryManager gitManager,
-      AccountCache accountCache,
-      Sequences seq,
-      @GerritPersonIdent PersonIdent myIdent,
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> user,
-      ProjectsCollection projectsCollection,
-      CommitsCollection commits,
-      ChangeInserter.Factory changeInserterFactory,
-      ChangeJson.Factory json,
-      ChangeFinder changeFinder,
-      RetryHelper retryHelper,
-      PatchSetUtil psUtil,
-      @GerritServerConfig Config config,
-      MergeUtil.Factory mergeUtilFactory,
-      NotifyUtil notifyUtil,
-      ContributorAgreementsChecker contributorAgreements) {
-    super(retryHelper);
-    this.anonymousCowardName = anonymousCowardName;
-    this.db = db;
-    this.gitManager = gitManager;
-    this.accountCache = accountCache;
-    this.seq = seq;
-    this.serverTimeZone = myIdent.getTimeZone();
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-    this.projectsCollection = projectsCollection;
-    this.commits = commits;
-    this.changeInserterFactory = changeInserterFactory;
-    this.jsonFactory = json;
-    this.changeFinder = changeFinder;
-    this.psUtil = psUtil;
-    this.submitType = config.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.notifyUtil = notifyUtil;
-    this.contributorAgreements = contributorAgreements;
-  }
-
-  @Override
-  protected Response<ChangeInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, TopLevelResource parent, ChangeInput input)
-      throws OrmException, IOException, InvalidChangeOperationException, RestApiException,
-          UpdateException, PermissionBackendException, ConfigInvalidException {
-    if (Strings.isNullOrEmpty(input.project)) {
-      throw new BadRequestException("project must be non-empty");
-    }
-
-    if (Strings.isNullOrEmpty(input.branch)) {
-      throw new BadRequestException("branch must be non-empty");
-    }
-
-    if (Strings.isNullOrEmpty(input.subject)) {
-      throw new BadRequestException("commit message must be non-empty");
-    }
-
-    if (input.status != null) {
-      if (input.status != ChangeStatus.NEW) {
-        throw new BadRequestException("unsupported change status");
-      }
-    }
-
-    ProjectResource rsrc = projectsCollection.parse(input.project);
-    contributorAgreements.check(rsrc.getNameKey(), rsrc.getUser());
-
-    Project.NameKey project = rsrc.getNameKey();
-    String refName = RefNames.fullName(input.branch);
-    permissionBackend.user(user).project(project).ref(refName).check(RefPermission.CREATE_CHANGE);
-
-    try (Repository git = gitManager.openRepository(project);
-        ObjectInserter oi = git.newObjectInserter();
-        ObjectReader reader = oi.newReader();
-        RevWalk rw = new RevWalk(reader)) {
-      ObjectId parentCommit;
-      List<String> groups;
-      if (input.baseChange != null) {
-        List<ChangeNotes> notes = changeFinder.find(input.baseChange);
-        if (notes.size() != 1) {
-          throw new UnprocessableEntityException("Base change not found: " + input.baseChange);
-        }
-        ChangeNotes change = Iterables.getOnlyElement(notes);
-        if (!permissionBackend.user(user).change(change).database(db).test(ChangePermission.READ)) {
-          throw new UnprocessableEntityException("Base change not found: " + input.baseChange);
-        }
-        PatchSet ps = psUtil.current(db.get(), change);
-        parentCommit = ObjectId.fromString(ps.getRevision().get());
-        groups = ps.getGroups();
-      } else {
-        Ref destRef = git.getRefDatabase().exactRef(refName);
-        if (destRef != null) {
-          if (Boolean.TRUE.equals(input.newBranch)) {
-            throw new ResourceConflictException(
-                String.format("Branch %s already exists.", refName));
-          }
-          parentCommit = destRef.getObjectId();
-        } else {
-          if (Boolean.TRUE.equals(input.newBranch)) {
-            parentCommit = null;
-          } else {
-            throw new UnprocessableEntityException(
-                String.format("Branch %s does not exist.", refName));
-          }
-        }
-        groups = Collections.emptyList();
-      }
-      RevCommit mergeTip = parentCommit == null ? null : rw.parseCommit(parentCommit);
-
-      Timestamp now = TimeUtil.nowTs();
-      IdentifiedUser me = user.get().asIdentifiedUser();
-      PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
-      AccountState account = accountCache.get(me.getAccountId());
-      GeneralPreferencesInfo info = account.getAccount().getGeneralPreferencesInfo();
-
-      ObjectId treeId = mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree();
-      ObjectId id = ChangeIdUtil.computeChangeId(treeId, mergeTip, author, author, input.subject);
-      String commitMessage = ChangeIdUtil.insertId(input.subject, id);
-      if (Boolean.TRUE.equals(info.signedOffBy)) {
-        commitMessage +=
-            String.format(
-                "%s%s", SIGNED_OFF_BY_TAG, account.getAccount().getNameEmail(anonymousCowardName));
-      }
-
-      RevCommit c;
-      if (input.merge != null) {
-        // create a merge commit
-        if (!(submitType.equals(SubmitType.MERGE_ALWAYS)
-            || submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
-          throw new BadRequestException("Submit type: " + submitType + " is not supported");
-        }
-        c =
-            newMergeCommit(
-                git, oi, rw, rsrc.getProjectState(), mergeTip, input.merge, author, commitMessage);
-      } else {
-        // create an empty commit
-        c = newCommit(oi, rw, author, mergeTip, commitMessage);
-      }
-
-      boolean privateByDefault = rsrc.getProjectState().isPrivateByDefault();
-      Change.Id changeId = new Change.Id(seq.nextChangeId());
-      ChangeInserter ins = changeInserterFactory.create(changeId, c, refName);
-      ins.setMessage(String.format("Uploaded patch set %s.", ins.getPatchSetId().get()));
-      String topic = input.topic;
-      if (topic != null) {
-        topic = Strings.emptyToNull(topic.trim());
-      }
-      ins.setTopic(topic);
-      ins.setPrivate(input.isPrivate == null ? privateByDefault : input.isPrivate);
-      ins.setWorkInProgress(input.workInProgress != null && input.workInProgress);
-      ins.setGroups(groups);
-      ins.setNotify(input.notify);
-      ins.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
-      try (BatchUpdate bu = updateFactory.create(db.get(), project, me, now)) {
-        bu.setRepository(git, rw, oi);
-        bu.insertChange(ins);
-        bu.execute();
-      }
-      ChangeJson json = jsonFactory.noOptions();
-      return Response.created(json.format(ins.getChange()));
-    } catch (IllegalArgumentException e) {
-      throw new BadRequestException(e.getMessage());
-    }
-  }
-
-  private static RevCommit newCommit(
-      ObjectInserter oi,
-      RevWalk rw,
-      PersonIdent authorIdent,
-      RevCommit mergeTip,
-      String commitMessage)
-      throws IOException {
-    CommitBuilder commit = new CommitBuilder();
-    if (mergeTip == null) {
-      commit.setTreeId(emptyTreeId(oi));
-    } else {
-      commit.setTreeId(mergeTip.getTree().getId());
-      commit.setParentId(mergeTip);
-    }
-    commit.setAuthor(authorIdent);
-    commit.setCommitter(authorIdent);
-    commit.setMessage(commitMessage);
-    return rw.parseCommit(insert(oi, commit));
-  }
-
-  private RevCommit newMergeCommit(
-      Repository repo,
-      ObjectInserter oi,
-      RevWalk rw,
-      ProjectState projectState,
-      RevCommit mergeTip,
-      MergeInput merge,
-      PersonIdent authorIdent,
-      String commitMessage)
-      throws RestApiException, IOException {
-    if (Strings.isNullOrEmpty(merge.source)) {
-      throw new BadRequestException("merge.source must be non-empty");
-    }
-
-    RevCommit sourceCommit = MergeUtil.resolveCommit(repo, rw, merge.source);
-    if (!commits.canRead(projectState, repo, sourceCommit)) {
-      throw new BadRequestException("do not have read permission for: " + merge.source);
-    }
-
-    MergeUtil mergeUtil = mergeUtilFactory.create(projectState);
-    // default merge strategy from project settings
-    String mergeStrategy =
-        MoreObjects.firstNonNull(
-            Strings.emptyToNull(merge.strategy), mergeUtil.mergeStrategyName());
-
-    return MergeUtil.createMergeCommit(
-        oi,
-        repo.getConfig(),
-        mergeTip,
-        sourceCommit,
-        mergeStrategy,
-        authorIdent,
-        commitMessage,
-        rw);
-  }
-
-  private static ObjectId insert(ObjectInserter inserter, CommitBuilder commit)
-      throws IOException, UnsupportedEncodingException {
-    ObjectId id = inserter.insert(commit);
-    inserter.flush();
-    return id;
-  }
-
-  private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
-    return inserter.insert(new TreeFormatter());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
deleted file mode 100644
index 002c8b7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
+++ /dev/null
@@ -1,131 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.Collections;
-
-@Singleton
-public class CreateDraftComment
-    extends RetryingRestModifyView<RevisionResource, DraftInput, Response<CommentInfo>> {
-  private final Provider<ReviewDb> db;
-  private final Provider<CommentJson> commentJson;
-  private final CommentsUtil commentsUtil;
-  private final PatchSetUtil psUtil;
-  private final PatchListCache patchListCache;
-
-  @Inject
-  CreateDraftComment(
-      Provider<ReviewDb> db,
-      RetryHelper retryHelper,
-      Provider<CommentJson> commentJson,
-      CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache) {
-    super(retryHelper);
-    this.db = db;
-    this.commentJson = commentJson;
-    this.commentsUtil = commentsUtil;
-    this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
-  }
-
-  @Override
-  protected Response<CommentInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, RevisionResource rsrc, DraftInput in)
-      throws RestApiException, UpdateException, OrmException {
-    if (Strings.isNullOrEmpty(in.path)) {
-      throw new BadRequestException("path must be non-empty");
-    } else if (in.message == null || in.message.trim().isEmpty()) {
-      throw new BadRequestException("message must be non-empty");
-    } else if (in.line != null && in.line < 0) {
-      throw new BadRequestException("line must be >= 0");
-    } else if (in.line != null && in.range != null && in.line != in.range.endLine) {
-      throw new BadRequestException("range endLine must be on the same line as the comment");
-    }
-
-    try (BatchUpdate bu =
-        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getPatchSet().getId(), in);
-      bu.addOp(rsrc.getChange().getId(), op);
-      bu.execute();
-      return Response.created(
-          commentJson.get().setFillAccounts(false).newCommentFormatter().format(op.comment));
-    }
-  }
-
-  private class Op implements BatchUpdateOp {
-    private final PatchSet.Id psId;
-    private final DraftInput in;
-
-    private Comment comment;
-
-    private Op(PatchSet.Id psId, DraftInput in) {
-      this.psId = psId;
-      this.in = in;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, OrmException, UnprocessableEntityException {
-      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-      if (ps == null) {
-        throw new ResourceNotFoundException("patch set not found: " + psId);
-      }
-      String parentUuid = Url.decode(in.inReplyTo);
-
-      comment =
-          commentsUtil.newComment(
-              ctx, in.path, ps.getId(), in.side(), in.message.trim(), in.unresolved, parentUuid);
-      comment.setLineNbrAndRange(in.line, in.range);
-      comment.tag = in.tag;
-
-      setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
-
-      commentsUtil.putComments(
-          ctx.getDb(), ctx.getUpdate(psId), Status.DRAFT, Collections.singleton(comment));
-      ctx.dontBumpLastUpdatedOn();
-      return true;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
deleted file mode 100644
index 0425e53..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
+++ /dev/null
@@ -1,220 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.MergeInput;
-import com.google.gerrit.extensions.common.MergePatchSetInput;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MergeConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeIdenticalTreeException;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.CommitsCollection;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.TimeZone;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.ChangeIdUtil;
-
-@Singleton
-public class CreateMergePatchSet
-    extends RetryingRestModifyView<ChangeResource, MergePatchSetInput, Response<ChangeInfo>> {
-  private final Provider<ReviewDb> db;
-  private final GitRepositoryManager gitManager;
-  private final CommitsCollection commits;
-  private final TimeZone serverTimeZone;
-  private final Provider<CurrentUser> user;
-  private final ChangeJson.Factory jsonFactory;
-  private final PatchSetUtil psUtil;
-  private final MergeUtil.Factory mergeUtilFactory;
-  private final PatchSetInserter.Factory patchSetInserterFactory;
-  private final ProjectCache projectCache;
-
-  @Inject
-  CreateMergePatchSet(
-      Provider<ReviewDb> db,
-      GitRepositoryManager gitManager,
-      CommitsCollection commits,
-      @GerritPersonIdent PersonIdent myIdent,
-      Provider<CurrentUser> user,
-      ChangeJson.Factory json,
-      PatchSetUtil psUtil,
-      MergeUtil.Factory mergeUtilFactory,
-      RetryHelper retryHelper,
-      PatchSetInserter.Factory patchSetInserterFactory,
-      ProjectCache projectCache) {
-    super(retryHelper);
-    this.db = db;
-    this.gitManager = gitManager;
-    this.commits = commits;
-    this.serverTimeZone = myIdent.getTimeZone();
-    this.user = user;
-    this.jsonFactory = json;
-    this.psUtil = psUtil;
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.patchSetInserterFactory = patchSetInserterFactory;
-    this.projectCache = projectCache;
-  }
-
-  @Override
-  protected Response<ChangeInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, MergePatchSetInput in)
-      throws OrmException, IOException, InvalidChangeOperationException, RestApiException,
-          UpdateException, PermissionBackendException {
-    rsrc.permissions().database(db).check(ChangePermission.ADD_PATCH_SET);
-
-    MergeInput merge = in.merge;
-    if (merge == null || Strings.isNullOrEmpty(merge.source)) {
-      throw new BadRequestException("merge.source must be non-empty");
-    }
-
-    PatchSet ps = psUtil.current(db.get(), rsrc.getNotes());
-    ProjectState projectState = projectCache.checkedGet(rsrc.getProject());
-    Change change = rsrc.getChange();
-    Project.NameKey project = change.getProject();
-    Branch.NameKey dest = change.getDest();
-    try (Repository git = gitManager.openRepository(project);
-        ObjectInserter oi = git.newObjectInserter();
-        ObjectReader reader = oi.newReader();
-        RevWalk rw = new RevWalk(reader)) {
-
-      RevCommit sourceCommit = MergeUtil.resolveCommit(git, rw, merge.source);
-      if (!commits.canRead(projectState, git, sourceCommit)) {
-        throw new ResourceNotFoundException(
-            "cannot find source commit: " + merge.source + " to merge.");
-      }
-
-      RevCommit currentPsCommit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
-      Timestamp now = TimeUtil.nowTs();
-      IdentifiedUser me = user.get().asIdentifiedUser();
-      PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
-      RevCommit newCommit =
-          createMergeCommit(
-              in,
-              projectState,
-              dest,
-              git,
-              oi,
-              rw,
-              currentPsCommit,
-              sourceCommit,
-              author,
-              ObjectId.fromString(change.getKey().get().substring(1)));
-
-      PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.getId());
-      PatchSetInserter psInserter =
-          patchSetInserterFactory.create(rsrc.getNotes(), nextPsId, newCommit);
-      try (BatchUpdate bu = updateFactory.create(db.get(), project, me, now)) {
-        bu.setRepository(git, rw, oi);
-        bu.addOp(
-            rsrc.getId(),
-            psInserter
-                .setMessage("Uploaded patch set " + nextPsId.get() + ".")
-                .setNotify(NotifyHandling.NONE)
-                .setCheckAddPatchSetPermission(false)
-                .setNotify(NotifyHandling.NONE));
-        bu.execute();
-      }
-
-      ChangeJson json = jsonFactory.create(ListChangesOption.CURRENT_REVISION);
-      return Response.ok(json.format(psInserter.getChange()));
-    }
-  }
-
-  private RevCommit createMergeCommit(
-      MergePatchSetInput in,
-      ProjectState projectState,
-      Branch.NameKey dest,
-      Repository git,
-      ObjectInserter oi,
-      RevWalk rw,
-      RevCommit currentPsCommit,
-      RevCommit sourceCommit,
-      PersonIdent author,
-      ObjectId changeId)
-      throws ResourceNotFoundException, MergeIdenticalTreeException, MergeConflictException,
-          IOException {
-
-    ObjectId parentCommit;
-    if (in.inheritParent) {
-      // inherit first parent from previous patch set
-      parentCommit = currentPsCommit.getParent(0);
-    } else {
-      // get the current branch tip of destination branch
-      Ref destRef = git.getRefDatabase().exactRef(dest.get());
-      if (destRef != null) {
-        parentCommit = destRef.getObjectId();
-      } else {
-        throw new ResourceNotFoundException("cannot find destination branch");
-      }
-    }
-    RevCommit mergeTip = rw.parseCommit(parentCommit);
-
-    String commitMsg;
-    if (Strings.emptyToNull(in.subject) != null) {
-      commitMsg = ChangeIdUtil.insertId(in.subject, changeId);
-    } else {
-      // reuse previous patch set commit message
-      commitMsg = currentPsCommit.getFullMessage();
-    }
-
-    String mergeStrategy =
-        MoreObjects.firstNonNull(
-            Strings.emptyToNull(in.merge.strategy),
-            mergeUtilFactory.create(projectState).mergeStrategyName());
-
-    return MergeUtil.createMergeCommit(
-        oi, git.getConfig(), mergeTip, sourceCommit, mergeStrategy, author, commitMsg, rw);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
deleted file mode 100644
index d3feb31..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
+++ /dev/null
@@ -1,131 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.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;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class DeleteAssignee
-    extends RetryingRestModifyView<ChangeResource, Input, Response<AccountInfo>> {
-  public static class Input {}
-
-  private final ChangeMessagesUtil cmUtil;
-  private final Provider<ReviewDb> db;
-  private final AssigneeChanged assigneeChanged;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final AccountLoader.Factory accountLoaderFactory;
-
-  @Inject
-  DeleteAssignee(
-      RetryHelper retryHelper,
-      ChangeMessagesUtil cmUtil,
-      Provider<ReviewDb> db,
-      AssigneeChanged assigneeChanged,
-      IdentifiedUser.GenericFactory userFactory,
-      AccountLoader.Factory accountLoaderFactory) {
-    super(retryHelper);
-    this.cmUtil = cmUtil;
-    this.db = db;
-    this.assigneeChanged = assigneeChanged;
-    this.userFactory = userFactory;
-    this.accountLoaderFactory = accountLoaderFactory;
-  }
-
-  @Override
-  protected Response<AccountInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
-      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
-    rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
-
-    try (BatchUpdate bu =
-        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      Op op = new Op();
-      bu.addOp(rsrc.getChange().getId(), op);
-      bu.execute();
-      Account.Id deletedAssignee = op.getDeletedAssignee();
-      return deletedAssignee == null
-          ? Response.none()
-          : Response.ok(accountLoaderFactory.create(true).fillOne(deletedAssignee));
-    }
-  }
-
-  private class Op implements BatchUpdateOp {
-    private Change change;
-    private Account deletedAssignee;
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws RestApiException, OrmException {
-      change = ctx.getChange();
-      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-      Account.Id currentAssigneeId = change.getAssignee();
-      if (currentAssigneeId == null) {
-        return false;
-      }
-      IdentifiedUser deletedAssigneeUser = userFactory.create(currentAssigneeId);
-      deletedAssignee = deletedAssigneeUser.getAccount();
-      // noteDb
-      update.removeAssignee();
-      // reviewDb
-      change.setAssignee(null);
-      addMessage(ctx, update, deletedAssigneeUser);
-      return true;
-    }
-
-    public Account.Id getDeletedAssignee() {
-      return deletedAssignee != null ? deletedAssignee.getId() : null;
-    }
-
-    private void addMessage(ChangeContext ctx, ChangeUpdate update, IdentifiedUser deletedAssignee)
-        throws OrmException {
-      ChangeMessage cmsg =
-          ChangeMessagesUtil.newMessage(
-              ctx,
-              "Assignee deleted: " + deletedAssignee.getNameEmail(),
-              ChangeMessagesUtil.TAG_DELETE_ASSIGNEE);
-      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
-    }
-
-    @Override
-    public void postUpdate(Context ctx) throws OrmException {
-      assigneeChanged.fire(change, ctx.getAccount(), deletedAssignee, ctx.getWhen());
-    }
-  }
-}
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
deleted file mode 100644
index af26e8a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.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;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.Order;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@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;
-
-  @Inject
-  public DeleteChange(
-      Provider<ReviewDb> db, RetryHelper retryHelper, Provider<DeleteChangeOp> opProvider) {
-    super(retryHelper);
-    this.db = db;
-    this.opProvider = opProvider;
-  }
-
-  @Override
-  protected Response<?> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
-      throws RestApiException, UpdateException, PermissionBackendException {
-    if (rsrc.getChange().getStatus() == Change.Status.MERGED) {
-      throw new MethodNotAllowedException("delete not permitted");
-    }
-    rsrc.permissions().database(db).check(ChangePermission.DELETE);
-
-    try (BatchUpdate bu =
-        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      Change.Id id = rsrc.getChange().getId();
-      bu.setOrder(Order.DB_BEFORE_REPO);
-      bu.addOp(id, opProvider.get());
-      bu.execute();
-    }
-    return Response.none();
-  }
-
-  @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
-    Change.Status status = rsrc.getChange().getStatus();
-    PermissionBackend.ForChange perm = rsrc.permissions().database(db);
-    return new UiAction.Description()
-        .setLabel("Delete")
-        .setTitle("Delete change " + rsrc.getId())
-        .setVisible(and(couldDeleteWhenIn(status), perm.testCond(ChangePermission.DELETE)));
-  }
-
-  private boolean couldDeleteWhenIn(Change.Status status) {
-    switch (status) {
-      case NEW:
-      case ABANDONED:
-        // New or abandoned changes can be deleted with the right permissions.
-        return true;
-
-      case MERGED:
-        // Merged changes should never be deleted.
-        return false;
-    }
-    return false;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
deleted file mode 100644
index e2e3920..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-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;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Optional;
-
-@Singleton
-public class DeleteChangeEdit implements RestModifyView<ChangeResource, Input> {
-  public static class Input {}
-
-  private final ChangeEditUtil editUtil;
-
-  @Inject
-  DeleteChangeEdit(ChangeEditUtil editUtil) {
-    this.editUtil = editUtil;
-  }
-
-  @Override
-  public Response<?> apply(ChangeResource rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, IOException, OrmException {
-    Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
-    if (edit.isPresent()) {
-      editUtil.delete(edit.get());
-    } else {
-      throw new ResourceNotFoundException();
-    }
-
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
deleted file mode 100644
index 8df6e59..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ /dev/null
@@ -1,152 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.BatchUpdateReviewDb;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Order;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Map;
-import java.util.Optional;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-class DeleteChangeOp implements BatchUpdateOp {
-  static boolean allowDrafts(Config cfg) {
-    return cfg.getBoolean("change", "allowDrafts", true);
-  }
-
-  static ReviewDb unwrap(ReviewDb db) {
-    // This is special. We want to delete exactly the rows that are present in
-    // the database, even when reading everything else from NoteDb, so we need
-    // to bypass the write-only wrapper.
-    if (db instanceof BatchUpdateReviewDb) {
-      db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
-    }
-    return ReviewDbUtil.unwrapDb(db);
-  }
-
-  private final PatchSetUtil psUtil;
-  private final StarredChangesUtil starredChangesUtil;
-  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
-
-  private Change.Id id;
-
-  @Inject
-  DeleteChangeOp(
-      PatchSetUtil psUtil,
-      StarredChangesUtil starredChangesUtil,
-      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
-    this.psUtil = psUtil;
-    this.starredChangesUtil = starredChangesUtil;
-    this.accountPatchReviewStore = accountPatchReviewStore;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx)
-      throws RestApiException, OrmException, IOException, NoSuchChangeException {
-    checkState(
-        ctx.getOrder() == Order.DB_BEFORE_REPO, "must use DeleteChangeOp with DB_BEFORE_REPO");
-    checkState(id == null, "cannot reuse DeleteChangeOp");
-
-    id = ctx.getChange().getId();
-    Collection<PatchSet> patchSets = psUtil.byChange(ctx.getDb(), ctx.getNotes());
-
-    ensureDeletable(ctx, id, patchSets);
-    // Cleaning up is only possible as long as the change and its elements are
-    // still part of the database.
-    cleanUpReferences(ctx, id, patchSets);
-    deleteChangeElementsFromDb(ctx, id);
-
-    ctx.deleteChange();
-    return true;
-  }
-
-  private void ensureDeletable(ChangeContext ctx, Change.Id id, Collection<PatchSet> patchSets)
-      throws ResourceConflictException, MethodNotAllowedException, IOException {
-    Change.Status status = ctx.getChange().getStatus();
-    if (status == Change.Status.MERGED) {
-      throw new MethodNotAllowedException("Deleting merged change " + id + " is not allowed");
-    }
-    for (PatchSet patchSet : patchSets) {
-      if (isPatchSetMerged(ctx, patchSet)) {
-        throw new ResourceConflictException(
-            String.format(
-                "Cannot delete change %s: patch set %s is already merged",
-                id, patchSet.getPatchSetId()));
-      }
-    }
-  }
-
-  private boolean isPatchSetMerged(ChangeContext ctx, PatchSet patchSet) throws IOException {
-    Optional<ObjectId> destId = ctx.getRepoView().getRef(ctx.getChange().getDest().get());
-    if (!destId.isPresent()) {
-      return false;
-    }
-
-    RevWalk revWalk = ctx.getRevWalk();
-    ObjectId objectId = ObjectId.fromString(patchSet.getRevision().get());
-    return revWalk.isMergedInto(revWalk.parseCommit(objectId), revWalk.parseCommit(destId.get()));
-  }
-
-  private void deleteChangeElementsFromDb(ChangeContext ctx, Change.Id id) throws OrmException {
-    // Only delete from ReviewDb here; deletion from NoteDb is handled in
-    // BatchUpdate.
-    ReviewDb db = unwrap(ctx.getDb());
-    db.patchComments().delete(db.patchComments().byChange(id));
-    db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
-    db.patchSets().delete(db.patchSets().byChange(id));
-    db.changeMessages().delete(db.changeMessages().byChange(id));
-  }
-
-  private void cleanUpReferences(ChangeContext ctx, Change.Id id, Collection<PatchSet> patchSets)
-      throws OrmException, NoSuchChangeException {
-    for (PatchSet ps : patchSets) {
-      accountPatchReviewStore.get().clearReviewed(ps.getId());
-    }
-
-    // Non-atomic operation on Accounts table; not much we can do to make it
-    // atomic.
-    starredChangesUtil.unstarAll(ctx.getChange().getProject(), id);
-  }
-
-  @Override
-  public void updateRepo(RepoContext ctx) throws IOException {
-    String prefix = new PatchSet.Id(id, 1).toRefName();
-    prefix = prefix.substring(0, prefix.length() - 1);
-    for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(prefix).entrySet()) {
-      ctx.addRefUpdate(e.getValue(), ObjectId.zeroId(), prefix + e.getKey());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteComment.java
deleted file mode 100644
index 17665b0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteComment.java
+++ /dev/null
@@ -1,136 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import java.util.Optional;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class DeleteComment
-    extends RetryingRestModifyView<CommentResource, DeleteCommentInput, CommentInfo> {
-
-  private final Provider<CurrentUser> userProvider;
-  private final Provider<ReviewDb> dbProvider;
-  private final PermissionBackend permissionBackend;
-  private final CommentsUtil commentsUtil;
-  private final Provider<CommentJson> commentJson;
-  private final ChangeNotes.Factory notesFactory;
-
-  @Inject
-  public DeleteComment(
-      Provider<CurrentUser> userProvider,
-      Provider<ReviewDb> dbProvider,
-      PermissionBackend permissionBackend,
-      RetryHelper retryHelper,
-      CommentsUtil commentsUtil,
-      Provider<CommentJson> commentJson,
-      ChangeNotes.Factory notesFactory) {
-    super(retryHelper);
-    this.userProvider = userProvider;
-    this.dbProvider = dbProvider;
-    this.permissionBackend = permissionBackend;
-    this.commentsUtil = commentsUtil;
-    this.commentJson = commentJson;
-    this.notesFactory = notesFactory;
-  }
-
-  @Override
-  public CommentInfo applyImpl(
-      BatchUpdate.Factory batchUpdateFactory, CommentResource rsrc, DeleteCommentInput input)
-      throws RestApiException, IOException, ConfigInvalidException, OrmException,
-          PermissionBackendException, UpdateException {
-    CurrentUser user = userProvider.get();
-    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-
-    String newMessage = getCommentNewMessage(user.asIdentifiedUser().getName(), input.reason);
-    DeleteCommentOp deleteCommentOp = new DeleteCommentOp(rsrc, newMessage);
-    try (BatchUpdate batchUpdate =
-        batchUpdateFactory.create(
-            dbProvider.get(), rsrc.getRevisionResource().getProject(), user, TimeUtil.nowTs())) {
-      batchUpdate.addOp(rsrc.getRevisionResource().getChange().getId(), deleteCommentOp).execute();
-    }
-
-    ChangeNotes updatedNotes =
-        notesFactory.createChecked(rsrc.getRevisionResource().getChange().getId());
-    List<Comment> changeComments = commentsUtil.publishedByChange(dbProvider.get(), updatedNotes);
-    Optional<Comment> updatedComment =
-        changeComments.stream().filter(c -> c.key.equals(rsrc.getComment().key)).findFirst();
-    if (!updatedComment.isPresent()) {
-      // This should not happen as this endpoint should not remove the whole comment.
-      throw new ResourceNotFoundException("comment not found: " + rsrc.getComment().key);
-    }
-
-    return commentJson.get().newCommentFormatter().format(updatedComment.get());
-  }
-
-  private static String getCommentNewMessage(String name, String reason) {
-    StringBuilder stringBuilder = new StringBuilder("Comment removed by: ").append(name);
-    if (!Strings.isNullOrEmpty(reason)) {
-      stringBuilder.append("; Reason: ").append(reason);
-    }
-    return stringBuilder.toString();
-  }
-
-  private class DeleteCommentOp implements BatchUpdateOp {
-    private final CommentResource rsrc;
-    private final String newMessage;
-
-    DeleteCommentOp(CommentResource rsrc, String newMessage) {
-      this.rsrc = rsrc;
-      this.newMessage = newMessage;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceConflictException, OrmException, ResourceNotFoundException {
-      PatchSet.Id psId = ctx.getChange().currentPatchSetId();
-      commentsUtil.deleteCommentByRewritingHistory(
-          ctx.getDb(),
-          ctx.getUpdate(psId),
-          rsrc.getComment().key,
-          rsrc.getPatchSet().getId(),
-          newMessage);
-      return true;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
deleted file mode 100644
index 68db189..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.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;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.Collections;
-import java.util.Optional;
-
-@Singleton
-public class DeleteDraftComment
-    extends RetryingRestModifyView<DraftCommentResource, Input, Response<CommentInfo>> {
-  static class Input {}
-
-  private final Provider<ReviewDb> db;
-  private final CommentsUtil commentsUtil;
-  private final PatchSetUtil psUtil;
-  private final PatchListCache patchListCache;
-
-  @Inject
-  DeleteDraftComment(
-      Provider<ReviewDb> db,
-      CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      RetryHelper retryHelper,
-      PatchListCache patchListCache) {
-    super(retryHelper);
-    this.db = db;
-    this.commentsUtil = commentsUtil;
-    this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
-  }
-
-  @Override
-  protected Response<CommentInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, DraftCommentResource rsrc, Input input)
-      throws RestApiException, UpdateException {
-    try (BatchUpdate bu =
-        updateFactory.create(
-            db.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getComment().key);
-      bu.addOp(rsrc.getChange().getId(), op);
-      bu.execute();
-    }
-    return Response.none();
-  }
-
-  private class Op implements BatchUpdateOp {
-    private final Comment.Key key;
-
-    private Op(Comment.Key key) {
-      this.key = key;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException, OrmException {
-      Optional<Comment> maybeComment =
-          commentsUtil.getDraft(ctx.getDb(), ctx.getNotes(), ctx.getIdentifiedUser(), key);
-      if (!maybeComment.isPresent()) {
-        return false; // Nothing to do.
-      }
-      PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), key.patchSetId);
-      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-      if (ps == null) {
-        throw new ResourceNotFoundException("patch set not found: " + psId);
-      }
-      Comment c = maybeComment.get();
-      setCommentRevId(c, patchListCache, ctx.getChange(), ps);
-      commentsUtil.deleteComments(ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(c));
-      ctx.dontBumpLastUpdatedOn();
-      return true;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java
deleted file mode 100644
index ba5403a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.conditions.BooleanCondition;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class DeletePrivate
-    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, Response<String>> {
-  private final ChangeMessagesUtil cmUtil;
-  private final Provider<ReviewDb> dbProvider;
-  private final PermissionBackend permissionBackend;
-  private final SetPrivateOp.Factory setPrivateOpFactory;
-
-  @Inject
-  DeletePrivate(
-      Provider<ReviewDb> dbProvider,
-      RetryHelper retryHelper,
-      ChangeMessagesUtil cmUtil,
-      PermissionBackend permissionBackend,
-      SetPrivateOp.Factory setPrivateOpFactory) {
-    super(retryHelper);
-    this.dbProvider = dbProvider;
-    this.cmUtil = cmUtil;
-    this.permissionBackend = permissionBackend;
-    this.setPrivateOpFactory = setPrivateOpFactory;
-  }
-
-  @Override
-  protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, SetPrivateOp.Input input)
-      throws RestApiException, UpdateException {
-    if (!canDeletePrivate(rsrc).value()) {
-      throw new AuthException("not allowed to unmark private");
-    }
-
-    if (!rsrc.getChange().isPrivate()) {
-      throw new ResourceConflictException("change is not private");
-    }
-
-    SetPrivateOp op = setPrivateOpFactory.create(cmUtil, false, input);
-    try (BatchUpdate u =
-        updateFactory.create(
-            dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      u.addOp(rsrc.getId(), op).execute();
-    }
-
-    return Response.none();
-  }
-
-  protected BooleanCondition canDeletePrivate(ChangeResource rsrc) {
-    PermissionBackend.WithUser user = permissionBackend.user(rsrc.getUser());
-    return or(rsrc.isUserOwner(), user.testCond(GlobalPermission.ADMINISTRATE_SERVER));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivateByPost.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivateByPost.java
deleted file mode 100644
index a392492..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivateByPost.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class DeletePrivateByPost extends DeletePrivate implements UiAction<ChangeResource> {
-  @Inject
-  DeletePrivateByPost(
-      Provider<ReviewDb> dbProvider,
-      RetryHelper retryHelper,
-      ChangeMessagesUtil cmUtil,
-      PermissionBackend permissionBackend,
-      SetPrivateOp.Factory setPrivateOpFactory) {
-    super(dbProvider, retryHelper, cmUtil, permissionBackend, setPrivateOpFactory);
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Unmark private")
-        .setTitle("Unmark change as private")
-        .setVisible(and(rsrc.getChange().isPrivate(), canDeletePrivate(rsrc)));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
deleted file mode 100644
index c2bcd69..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class DeleteReviewer
-    extends RetryingRestModifyView<ReviewerResource, DeleteReviewerInput, Response<?>> {
-
-  private final Provider<ReviewDb> dbProvider;
-  private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
-  private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
-
-  @Inject
-  DeleteReviewer(
-      Provider<ReviewDb> dbProvider,
-      RetryHelper retryHelper,
-      DeleteReviewerOp.Factory deleteReviewerOpFactory,
-      DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory) {
-    super(retryHelper);
-    this.dbProvider = dbProvider;
-    this.deleteReviewerOpFactory = deleteReviewerOpFactory;
-    this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
-  }
-
-  @Override
-  protected Response<?> applyImpl(
-      BatchUpdate.Factory updateFactory, ReviewerResource rsrc, DeleteReviewerInput input)
-      throws RestApiException, UpdateException {
-    if (input == null) {
-      input = new DeleteReviewerInput();
-    }
-
-    try (BatchUpdate bu =
-        updateFactory.create(
-            dbProvider.get(),
-            rsrc.getChangeResource().getProject(),
-            rsrc.getChangeResource().getUser(),
-            TimeUtil.nowTs())) {
-      BatchUpdateOp op;
-      if (rsrc.isByEmail()) {
-        op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail(), input);
-      } else {
-        op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().getAccount(), input);
-      }
-      bu.addOp(rsrc.getChange().getId(), op);
-      bu.execute();
-    }
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
deleted file mode 100644
index 341ad4a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ /dev/null
@@ -1,105 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Collections;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class DeleteReviewerByEmailOp implements BatchUpdateOp {
-  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
-
-  public interface Factory {
-    DeleteReviewerByEmailOp create(Address reviewer, DeleteReviewerInput input);
-  }
-
-  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
-  private final NotifyUtil notifyUtil;
-  private final Address reviewer;
-  private final DeleteReviewerInput input;
-
-  private ChangeMessage changeMessage;
-  private Change change;
-
-  @Inject
-  DeleteReviewerByEmailOp(
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
-      NotifyUtil notifyUtil,
-      @Assisted Address reviewer,
-      @Assisted DeleteReviewerInput input) {
-    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
-    this.notifyUtil = notifyUtil;
-    this.reviewer = reviewer;
-    this.input = input;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx) throws OrmException {
-    change = ctx.getChange();
-    PatchSet.Id psId = ctx.getChange().currentPatchSetId();
-    String msg = "Removed reviewer " + reviewer;
-    changeMessage =
-        new ChangeMessage(
-            new ChangeMessage.Key(change.getId(), ChangeUtil.messageUuid()),
-            ctx.getAccountId(),
-            ctx.getWhen(),
-            psId);
-    changeMessage.setMessage(msg);
-
-    ctx.getUpdate(psId).setChangeMessage(msg);
-    ctx.getUpdate(psId).removeReviewerByEmail(reviewer);
-    return true;
-  }
-
-  @Override
-  public void postUpdate(Context ctx) {
-    if (input.notify == null) {
-      if (change.isWorkInProgress()) {
-        input.notify = NotifyHandling.NONE;
-      } else {
-        input.notify = NotifyHandling.ALL;
-      }
-    }
-    if (!NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
-      return;
-    }
-    try {
-      DeleteReviewerSender cm =
-          deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
-      cm.setFrom(ctx.getAccountId());
-      cm.addReviewersByEmail(Collections.singleton(reviewer));
-      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-      cm.setNotify(input.notify);
-      cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
-      cm.send();
-    } catch (Exception err) {
-      log.error("Cannot email update for change " + change.getId(), err);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java
deleted file mode 100644
index 2d318e9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ /dev/null
@@ -1,252 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.extensions.events.ReviewerDeleted;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.RemoveReviewerControl;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.BatchUpdateReviewDb;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class DeleteReviewerOp implements BatchUpdateOp {
-  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
-
-  public interface Factory {
-    DeleteReviewerOp create(Account reviewerAccount, DeleteReviewerInput input);
-  }
-
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final ChangeMessagesUtil cmUtil;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final ReviewerDeleted reviewerDeleted;
-  private final Provider<IdentifiedUser> user;
-  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
-  private final NotesMigration migration;
-  private final NotifyUtil notifyUtil;
-  private final RemoveReviewerControl removeReviewerControl;
-  private final ProjectCache projectCache;
-
-  private final Account reviewer;
-  private final DeleteReviewerInput input;
-
-  ChangeMessage changeMessage;
-  Change currChange;
-  PatchSet currPs;
-  Map<String, Short> newApprovals = new HashMap<>();
-  Map<String, Short> oldApprovals = new HashMap<>();
-
-  @Inject
-  DeleteReviewerOp(
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      ChangeMessagesUtil cmUtil,
-      IdentifiedUser.GenericFactory userFactory,
-      ReviewerDeleted reviewerDeleted,
-      Provider<IdentifiedUser> user,
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
-      NotesMigration migration,
-      NotifyUtil notifyUtil,
-      RemoveReviewerControl removeReviewerControl,
-      ProjectCache projectCache,
-      @Assisted Account reviewerAccount,
-      @Assisted DeleteReviewerInput input) {
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.cmUtil = cmUtil;
-    this.userFactory = userFactory;
-    this.reviewerDeleted = reviewerDeleted;
-    this.user = user;
-    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
-    this.migration = migration;
-    this.notifyUtil = notifyUtil;
-    this.removeReviewerControl = removeReviewerControl;
-    this.projectCache = projectCache;
-    this.reviewer = reviewerAccount;
-    this.input = input;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx)
-      throws AuthException, ResourceNotFoundException, OrmException, PermissionBackendException,
-          IOException {
-    Account.Id reviewerId = reviewer.getId();
-    // Check of removing this reviewer (even if there is no vote processed by the loop below) is OK
-    removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), reviewerId);
-
-    if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all().contains(reviewerId)) {
-      throw new ResourceNotFoundException();
-    }
-    currChange = ctx.getChange();
-    currPs = psUtil.current(ctx.getDb(), ctx.getNotes());
-
-    LabelTypes labelTypes =
-        projectCache.checkedGet(ctx.getProject()).getLabelTypes(ctx.getNotes(), ctx.getUser());
-    // removing a reviewer will remove all her votes
-    for (LabelType lt : labelTypes.getLabelTypes()) {
-      newApprovals.put(lt.getName(), (short) 0);
-    }
-
-    StringBuilder msg = new StringBuilder();
-    msg.append("Removed reviewer " + reviewer.getFullName());
-    StringBuilder removedVotesMsg = new StringBuilder();
-    removedVotesMsg.append(" with the following votes:\n\n");
-    List<PatchSetApproval> del = new ArrayList<>();
-    boolean votesRemoved = false;
-    for (PatchSetApproval a : approvals(ctx, reviewerId)) {
-      // Check if removing this vote is OK
-      removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
-      del.add(a);
-      if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
-        oldApprovals.put(a.getLabel(), a.getValue());
-        removedVotesMsg
-            .append("* ")
-            .append(a.getLabel())
-            .append(formatLabelValue(a.getValue()))
-            .append(" by ")
-            .append(userFactory.create(a.getAccountId()).getNameEmail())
-            .append("\n");
-        votesRemoved = true;
-      }
-    }
-
-    if (votesRemoved) {
-      msg.append(removedVotesMsg);
-    } else {
-      msg.append(".");
-    }
-    ctx.getDb().patchSetApprovals().delete(del);
-    ChangeUpdate update = ctx.getUpdate(currPs.getId());
-    update.removeReviewer(reviewerId);
-
-    changeMessage =
-        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
-    cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
-
-    return true;
-  }
-
-  @Override
-  public void postUpdate(Context ctx) {
-    if (input.notify == null) {
-      if (currChange.isWorkInProgress()) {
-        input.notify = oldApprovals.isEmpty() ? NotifyHandling.NONE : NotifyHandling.OWNER;
-      } else {
-        input.notify = NotifyHandling.ALL;
-      }
-    }
-    if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
-      emailReviewers(ctx.getProject(), currChange, changeMessage);
-    }
-    reviewerDeleted.fire(
-        currChange,
-        currPs,
-        reviewer,
-        ctx.getAccount(),
-        changeMessage.getMessage(),
-        newApprovals,
-        oldApprovals,
-        input.notify,
-        ctx.getWhen());
-  }
-
-  private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId)
-      throws OrmException {
-    Change.Id changeId = ctx.getNotes().getChangeId();
-    Iterable<PatchSetApproval> approvals;
-    PrimaryStorage r = PrimaryStorage.of(ctx.getChange());
-
-    if (migration.readChanges() && r == PrimaryStorage.REVIEW_DB) {
-      // Because NoteDb and ReviewDb have different semantics for zero-value
-      // approvals, we must fall back to ReviewDb as the source of truth here.
-      ReviewDb db = ctx.getDb();
-
-      if (db instanceof BatchUpdateReviewDb) {
-        db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
-      }
-      db = ReviewDbUtil.unwrapDb(db);
-      approvals = db.patchSetApprovals().byChange(changeId);
-    } else {
-      approvals = approvalsUtil.byChange(ctx.getDb(), ctx.getNotes()).values();
-    }
-
-    return Iterables.filter(approvals, psa -> accountId.equals(psa.getAccountId()));
-  }
-
-  private String formatLabelValue(short value) {
-    if (value > 0) {
-      return "+" + value;
-    }
-    return Short.toString(value);
-  }
-
-  private void emailReviewers(
-      Project.NameKey projectName, Change change, ChangeMessage changeMessage) {
-    Account.Id userId = user.get().getAccountId();
-    if (userId.equals(reviewer.getId())) {
-      // The user knows they removed themselves, don't bother emailing them.
-      return;
-    }
-    try {
-      DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
-      cm.setFrom(userId);
-      cm.addReviewers(Collections.singleton(reviewer.getId()));
-      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-      cm.setNotify(input.notify);
-      cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
-      cm.send();
-    } catch (Exception err) {
-      log.error("Cannot email update for change " + change.getId(), err);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
deleted file mode 100644
index 10164ce..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
+++ /dev/null
@@ -1,263 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.extensions.events.VoteDeleted;
-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.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.RemoveReviewerControl;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.LabelVote;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class DeleteVote extends RetryingRestModifyView<VoteResource, DeleteVoteInput, Response<?>> {
-  private static final Logger log = LoggerFactory.getLogger(DeleteVote.class);
-
-  private final Provider<ReviewDb> db;
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final ChangeMessagesUtil cmUtil;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final VoteDeleted voteDeleted;
-  private final DeleteVoteSender.Factory deleteVoteSenderFactory;
-  private final NotifyUtil notifyUtil;
-  private final RemoveReviewerControl removeReviewerControl;
-  private final ProjectCache projectCache;
-
-  @Inject
-  DeleteVote(
-      Provider<ReviewDb> db,
-      RetryHelper retryHelper,
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      ChangeMessagesUtil cmUtil,
-      IdentifiedUser.GenericFactory userFactory,
-      VoteDeleted voteDeleted,
-      DeleteVoteSender.Factory deleteVoteSenderFactory,
-      NotifyUtil notifyUtil,
-      RemoveReviewerControl removeReviewerControl,
-      ProjectCache projectCache) {
-    super(retryHelper);
-    this.db = db;
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.cmUtil = cmUtil;
-    this.userFactory = userFactory;
-    this.voteDeleted = voteDeleted;
-    this.deleteVoteSenderFactory = deleteVoteSenderFactory;
-    this.notifyUtil = notifyUtil;
-    this.removeReviewerControl = removeReviewerControl;
-    this.projectCache = projectCache;
-  }
-
-  @Override
-  protected Response<?> applyImpl(
-      BatchUpdate.Factory updateFactory, VoteResource rsrc, DeleteVoteInput input)
-      throws RestApiException, UpdateException, IOException {
-    if (input == null) {
-      input = new DeleteVoteInput();
-    }
-    if (input.label != null && !rsrc.getLabel().equals(input.label)) {
-      throw new BadRequestException("label must match URL");
-    }
-    if (input.notify == null) {
-      input.notify = NotifyHandling.ALL;
-    }
-    ReviewerResource r = rsrc.getReviewer();
-    Change change = r.getChange();
-
-    if (r.getRevisionResource() != null && !r.getRevisionResource().isCurrent()) {
-      throw new MethodNotAllowedException("Cannot delete vote on non-current patch set");
-    }
-
-    try (BatchUpdate bu =
-        updateFactory.create(
-            db.get(), change.getProject(), r.getChangeResource().getUser(), TimeUtil.nowTs())) {
-      bu.addOp(
-          change.getId(),
-          new Op(
-              projectCache.checkedGet(r.getChange().getProject()),
-              r.getReviewerUser().getAccount(),
-              rsrc.getLabel(),
-              input));
-      bu.execute();
-    }
-
-    return Response.none();
-  }
-
-  private class Op implements BatchUpdateOp {
-    private final ProjectState projectState;
-    private final Account account;
-    private final String label;
-    private final DeleteVoteInput input;
-
-    private ChangeMessage changeMessage;
-    private Change change;
-    private PatchSet ps;
-    private Map<String, Short> newApprovals = new HashMap<>();
-    private Map<String, Short> oldApprovals = new HashMap<>();
-
-    private Op(ProjectState projectState, Account account, String label, DeleteVoteInput input) {
-      this.projectState = projectState;
-      this.account = account;
-      this.label = label;
-      this.input = input;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws OrmException, AuthException, ResourceNotFoundException, IOException,
-            PermissionBackendException {
-      change = ctx.getChange();
-      PatchSet.Id psId = change.currentPatchSetId();
-      ps = psUtil.current(db.get(), ctx.getNotes());
-
-      boolean found = false;
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
-
-      for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(
-              ctx.getDb(),
-              ctx.getNotes(),
-              ctx.getUser(),
-              psId,
-              account.getId(),
-              ctx.getRevWalk(),
-              ctx.getRepoView().getConfig())) {
-        if (labelTypes.byLabel(a.getLabelId()) == null) {
-          continue; // Ignore undefined labels.
-        } else if (!a.getLabel().equals(label)) {
-          // Populate map for non-matching labels, needed by VoteDeleted.
-          newApprovals.put(a.getLabel(), a.getValue());
-          continue;
-        } else {
-          try {
-            removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
-          } catch (AuthException e) {
-            throw new AuthException("delete vote not permitted", e);
-          }
-        }
-        // Set the approval to 0 if vote is being removed.
-        newApprovals.put(a.getLabel(), (short) 0);
-        found = true;
-
-        // Set old value, as required by VoteDeleted.
-        oldApprovals.put(a.getLabel(), a.getValue());
-        break;
-      }
-      if (!found) {
-        throw new ResourceNotFoundException();
-      }
-
-      ctx.getUpdate(psId).removeApprovalFor(account.getId(), label);
-      ctx.getDb().patchSetApprovals().upsert(Collections.singleton(deletedApproval(ctx)));
-
-      StringBuilder msg = new StringBuilder();
-      msg.append("Removed ");
-      LabelVote.appendTo(msg, label, checkNotNull(oldApprovals.get(label)));
-      msg.append(" by ").append(userFactory.create(account.getId()).getNameEmail()).append("\n");
-      changeMessage =
-          ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
-      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
-
-      return true;
-    }
-
-    private PatchSetApproval deletedApproval(ChangeContext ctx) {
-      // Set the effective user to the account we're trying to remove, and don't
-      // set the real user; this preserves the calling user as the NoteDb
-      // committer.
-      return new PatchSetApproval(
-          new PatchSetApproval.Key(ps.getId(), account.getId(), new LabelId(label)),
-          (short) 0,
-          ctx.getWhen());
-    }
-
-    @Override
-    public void postUpdate(Context ctx) {
-      if (changeMessage == null) {
-        return;
-      }
-
-      IdentifiedUser user = ctx.getIdentifiedUser();
-      if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
-        try {
-          ReplyToChangeSender cm = deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
-          cm.setFrom(user.getAccountId());
-          cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
-          cm.setNotify(input.notify);
-          cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
-          cm.send();
-        } catch (Exception e) {
-          log.error("Cannot email update for change " + change.getId(), e);
-        }
-      }
-
-      voteDeleted.fire(
-          change,
-          ps,
-          account,
-          newApprovals,
-          oldApprovals,
-          input.notify,
-          changeMessage.getMessage(),
-          user.getAccount(),
-          ctx.getWhen());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DownloadContent.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DownloadContent.java
deleted file mode 100644
index 311a25c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DownloadContent.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.kohsuke.args4j.Option;
-
-public class DownloadContent implements RestReadView<FileResource> {
-  private final FileContentUtil fileContentUtil;
-  private final ProjectCache projectCache;
-
-  @Option(name = "--parent")
-  private Integer parent;
-
-  @Inject
-  DownloadContent(FileContentUtil fileContentUtil, ProjectCache projectCache) {
-    this.fileContentUtil = fileContentUtil;
-    this.projectCache = projectCache;
-  }
-
-  @Override
-  public BinaryResult apply(FileResource rsrc)
-      throws ResourceNotFoundException, IOException, NoSuchChangeException, OrmException {
-    String path = rsrc.getPatchKey().get();
-    RevisionResource rev = rsrc.getRevision();
-    ObjectId revstr = ObjectId.fromString(rev.getPatchSet().getRevision().get());
-    return fileContentUtil.downloadContent(
-        projectCache.checkedGet(rev.getProject()), revstr, path, parent);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java
deleted file mode 100644
index 0b1b15d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.CurrentUser;
-import com.google.inject.TypeLiteral;
-
-public class DraftCommentResource implements RestResource {
-  public static final TypeLiteral<RestView<DraftCommentResource>> DRAFT_COMMENT_KIND =
-      new TypeLiteral<RestView<DraftCommentResource>>() {};
-
-  private final RevisionResource rev;
-  private final Comment comment;
-
-  public DraftCommentResource(RevisionResource rev, Comment c) {
-    this.rev = rev;
-    this.comment = c;
-  }
-
-  public CurrentUser getUser() {
-    return rev.getUser();
-  }
-
-  public Change getChange() {
-    return rev.getChange();
-  }
-
-  public PatchSet getPatchSet() {
-    return rev.getPatchSet();
-  }
-
-  Comment getComment() {
-    return comment;
-  }
-
-  String getId() {
-    return comment.key.uuid;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftComments.java
deleted file mode 100644
index 4befc5b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftComments.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class DraftComments implements ChildCollection<RevisionResource, DraftCommentResource> {
-  private final DynamicMap<RestView<DraftCommentResource>> views;
-  private final Provider<CurrentUser> user;
-  private final ListRevisionDrafts list;
-  private final Provider<ReviewDb> dbProvider;
-  private final CommentsUtil commentsUtil;
-
-  @Inject
-  DraftComments(
-      DynamicMap<RestView<DraftCommentResource>> views,
-      Provider<CurrentUser> user,
-      ListRevisionDrafts list,
-      Provider<ReviewDb> dbProvider,
-      CommentsUtil commentsUtil) {
-    this.views = views;
-    this.user = user;
-    this.list = list;
-    this.dbProvider = dbProvider;
-    this.commentsUtil = commentsUtil;
-  }
-
-  @Override
-  public DynamicMap<RestView<DraftCommentResource>> views() {
-    return views;
-  }
-
-  @Override
-  public ListRevisionDrafts list() throws AuthException {
-    checkIdentifiedUser();
-    return list;
-  }
-
-  @Override
-  public DraftCommentResource parse(RevisionResource rev, IdString id)
-      throws ResourceNotFoundException, OrmException, AuthException {
-    checkIdentifiedUser();
-    String uuid = id.get();
-    for (Comment c :
-        commentsUtil.draftByPatchSetAuthor(
-            dbProvider.get(), rev.getPatchSet().getId(), rev.getAccountId(), rev.getNotes())) {
-      if (uuid.equals(c.key.uuid)) {
-        return new DraftCommentResource(rev, c);
-      }
-    }
-    throw new ResourceNotFoundException(id);
-  }
-
-  private void checkIdentifiedUser() throws AuthException {
-    if (!(user.get().isIdentifiedUser())) {
-      throw new AuthException("drafts only available to authenticated users");
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
deleted file mode 100644
index 6ccd460..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.extensions.common.FileInfo;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListEntry;
-import com.google.gerrit.server.patch.PatchListKey;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Map;
-import java.util.TreeMap;
-import org.eclipse.jgit.lib.ObjectId;
-
-@Singleton
-public class FileInfoJson {
-  private final PatchListCache patchListCache;
-
-  @Inject
-  FileInfoJson(PatchListCache patchListCache) {
-    this.patchListCache = patchListCache;
-  }
-
-  Map<String, FileInfo> toFileInfoMap(Change change, PatchSet patchSet)
-      throws PatchListNotAvailableException {
-    return toFileInfoMap(change, patchSet.getRevision(), null);
-  }
-
-  Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, @Nullable PatchSet base)
-      throws PatchListNotAvailableException {
-    ObjectId objectId = ObjectId.fromString(revision.get());
-    return toFileInfoMap(change, objectId, base);
-  }
-
-  Map<String, FileInfo> toFileInfoMap(Change change, ObjectId objectId, @Nullable PatchSet base)
-      throws PatchListNotAvailableException {
-    ObjectId a = (base == null) ? null : ObjectId.fromString(base.getRevision().get());
-    return toFileInfoMap(change, PatchListKey.againstCommit(a, objectId, Whitespace.IGNORE_NONE));
-  }
-
-  Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, int parent)
-      throws PatchListNotAvailableException {
-    ObjectId b = ObjectId.fromString(revision.get());
-    return toFileInfoMap(
-        change, PatchListKey.againstParentNum(parent + 1, b, Whitespace.IGNORE_NONE));
-  }
-
-  private Map<String, FileInfo> toFileInfoMap(Change change, PatchListKey key)
-      throws PatchListNotAvailableException {
-    PatchList list = patchListCache.get(key, change.getProject());
-
-    Map<String, FileInfo> files = new TreeMap<>();
-    for (PatchListEntry e : list.getPatches()) {
-      FileInfo d = new FileInfo();
-      d.status =
-          e.getChangeType() != Patch.ChangeType.MODIFIED ? e.getChangeType().getCode() : null;
-      d.oldPath = e.getOldName();
-      d.sizeDelta = e.getSizeDelta();
-      d.size = e.getSize();
-      if (e.getPatchType() == Patch.PatchType.BINARY) {
-        d.binary = true;
-      } else {
-        d.linesInserted = e.getInsertions() > 0 ? e.getInsertions() : null;
-        d.linesDeleted = e.getDeletions() > 0 ? e.getDeletions() : null;
-      }
-
-      FileInfo o = files.put(e.getNewName(), d);
-      if (o != null) {
-        // This should only happen on a delete-add break created by JGit
-        // when the file was rewritten and too little content survived. Write
-        // a single record with data from both sides.
-        d.status = Patch.ChangeType.REWRITE.getCode();
-        d.sizeDelta = o.sizeDelta;
-        d.size = o.size;
-        if (o.binary != null && o.binary) {
-          d.binary = true;
-        }
-        if (o.linesInserted != null) {
-          d.linesInserted = o.linesInserted;
-        }
-        if (o.linesDeleted != null) {
-          d.linesDeleted = o.linesDeleted;
-        }
-      }
-    }
-    return files;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileResource.java
deleted file mode 100644
index ca47fb9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileResource.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.inject.TypeLiteral;
-
-public class FileResource implements RestResource {
-  public static final TypeLiteral<RestView<FileResource>> FILE_KIND =
-      new TypeLiteral<RestView<FileResource>>() {};
-
-  private final RevisionResource rev;
-  private final Patch.Key key;
-
-  public FileResource(RevisionResource rev, String name) {
-    this.rev = rev;
-    this.key = new Patch.Key(rev.getPatchSet().getId(), name);
-  }
-
-  public Patch.Key getPatchKey() {
-    return key;
-  }
-
-  public boolean isCacheable() {
-    return rev.isCacheable();
-  }
-
-  Account.Id getAccountId() {
-    return rev.getAccountId();
-  }
-
-  public RevisionResource getRevision() {
-    return rev;
-  }
-}
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
deleted file mode 100644
index 21da0b8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
+++ /dev/null
@@ -1,344 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.collect.Lists;
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-import com.google.gerrit.extensions.common.FileInfo;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.CacheControl;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.ETagView;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.change.AccountPatchReviewStore.PatchSetWithReviewedFiles;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListKey;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.TreeWalk;
-import org.eclipse.jgit.treewalk.filter.PathFilterGroup;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class Files implements ChildCollection<RevisionResource, FileResource> {
-  private final DynamicMap<RestView<FileResource>> views;
-  private final Provider<ListFiles> list;
-
-  @Inject
-  Files(DynamicMap<RestView<FileResource>> views, Provider<ListFiles> list) {
-    this.views = views;
-    this.list = list;
-  }
-
-  @Override
-  public DynamicMap<RestView<FileResource>> views() {
-    return views;
-  }
-
-  @Override
-  public RestView<RevisionResource> list() throws AuthException {
-    return list.get();
-  }
-
-  @Override
-  public FileResource parse(RevisionResource rev, IdString id) {
-    return new FileResource(rev, id.get());
-  }
-
-  public static final class ListFiles implements ETagView<RevisionResource> {
-    private static final Logger log = LoggerFactory.getLogger(ListFiles.class);
-
-    @Option(name = "--base", metaVar = "revision-id")
-    String base;
-
-    @Option(name = "--parent", metaVar = "parent-number")
-    int parentNum;
-
-    @Option(name = "--reviewed")
-    boolean reviewed;
-
-    @Option(name = "-q")
-    String query;
-
-    private final Provider<ReviewDb> db;
-    private final Provider<CurrentUser> self;
-    private final FileInfoJson fileInfoJson;
-    private final Revisions revisions;
-    private final GitRepositoryManager gitManager;
-    private final PatchListCache patchListCache;
-    private final PatchSetUtil psUtil;
-    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
-
-    @Inject
-    ListFiles(
-        Provider<ReviewDb> db,
-        Provider<CurrentUser> self,
-        FileInfoJson fileInfoJson,
-        Revisions revisions,
-        GitRepositoryManager gitManager,
-        PatchListCache patchListCache,
-        PatchSetUtil psUtil,
-        DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
-      this.db = db;
-      this.self = self;
-      this.fileInfoJson = fileInfoJson;
-      this.revisions = revisions;
-      this.gitManager = gitManager;
-      this.patchListCache = patchListCache;
-      this.psUtil = psUtil;
-      this.accountPatchReviewStore = accountPatchReviewStore;
-    }
-
-    public ListFiles setReviewed(boolean r) {
-      this.reviewed = r;
-      return this;
-    }
-
-    @Override
-    public Response<?> apply(RevisionResource resource)
-        throws AuthException, BadRequestException, ResourceNotFoundException, OrmException,
-            RepositoryNotFoundException, IOException, PatchListNotAvailableException {
-      checkOptions();
-      if (reviewed) {
-        return Response.ok(reviewed(resource));
-      } else if (query != null) {
-        return Response.ok(query(resource));
-      }
-
-      Response<Map<String, FileInfo>> r;
-      if (base != null) {
-        RevisionResource baseResource =
-            revisions.parse(resource.getChangeResource(), IdString.fromDecoded(base));
-        r =
-            Response.ok(
-                fileInfoJson.toFileInfoMap(
-                    resource.getChange(),
-                    resource.getPatchSet().getRevision(),
-                    baseResource.getPatchSet()));
-      } else if (parentNum > 0) {
-        r =
-            Response.ok(
-                fileInfoJson.toFileInfoMap(
-                    resource.getChange(), resource.getPatchSet().getRevision(), parentNum - 1));
-      } else {
-        r = Response.ok(fileInfoJson.toFileInfoMap(resource.getChange(), resource.getPatchSet()));
-      }
-
-      if (resource.isCacheable()) {
-        r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
-      }
-      return r;
-    }
-
-    private void checkOptions() throws BadRequestException {
-      int supplied = 0;
-      if (base != null) {
-        supplied++;
-      }
-      if (parentNum > 0) {
-        supplied++;
-      }
-      if (reviewed) {
-        supplied++;
-      }
-      if (query != null) {
-        supplied++;
-      }
-      if (supplied > 1) {
-        throw new BadRequestException("cannot combine base, parent, reviewed, query");
-      }
-    }
-
-    private List<String> query(RevisionResource resource)
-        throws RepositoryNotFoundException, IOException {
-      Project.NameKey project = resource.getChange().getProject();
-      try (Repository git = gitManager.openRepository(project);
-          ObjectReader or = git.newObjectReader();
-          RevWalk rw = new RevWalk(or);
-          TreeWalk tw = new TreeWalk(or)) {
-        RevCommit c =
-            rw.parseCommit(ObjectId.fromString(resource.getPatchSet().getRevision().get()));
-
-        tw.addTree(c.getTree());
-        tw.setRecursive(true);
-        List<String> paths = new ArrayList<>();
-        while (tw.next() && paths.size() < 20) {
-          String s = tw.getPathString();
-          if (s.contains(query)) {
-            paths.add(s);
-          }
-        }
-        return paths;
-      }
-    }
-
-    private Collection<String> reviewed(RevisionResource resource)
-        throws AuthException, OrmException {
-      CurrentUser user = self.get();
-      if (!(user.isIdentifiedUser())) {
-        throw new AuthException("Authentication required");
-      }
-
-      Account.Id userId = user.getAccountId();
-      PatchSet patchSetId = resource.getPatchSet();
-      Optional<PatchSetWithReviewedFiles> o =
-          accountPatchReviewStore.get().findReviewed(patchSetId.getId(), userId);
-
-      if (o.isPresent()) {
-        PatchSetWithReviewedFiles res = o.get();
-        if (res.patchSetId().equals(patchSetId.getId())) {
-          return res.files();
-        }
-
-        try {
-          return copy(res.files(), res.patchSetId(), resource, userId);
-        } catch (IOException | PatchListNotAvailableException e) {
-          log.warn("Cannot copy patch review flags", e);
-        }
-      }
-
-      return Collections.emptyList();
-    }
-
-    private List<String> copy(
-        Set<String> paths, PatchSet.Id old, RevisionResource resource, Account.Id userId)
-        throws IOException, PatchListNotAvailableException, OrmException {
-      Project.NameKey project = resource.getChange().getProject();
-      try (Repository git = gitManager.openRepository(project);
-          ObjectReader reader = git.newObjectReader();
-          RevWalk rw = new RevWalk(reader);
-          TreeWalk tw = new TreeWalk(reader)) {
-        Change change = resource.getChange();
-        PatchSet patchSet = psUtil.get(db.get(), resource.getNotes(), old);
-        if (patchSet == null) {
-          throw new PatchListNotAvailableException(
-              String.format(
-                  "patch set %s of change %s not found", old.get(), change.getId().get()));
-        }
-
-        PatchList oldList = patchListCache.get(change, patchSet);
-
-        PatchList curList = patchListCache.get(change, resource.getPatchSet());
-
-        int sz = paths.size();
-        List<String> pathList = Lists.newArrayListWithCapacity(sz);
-
-        tw.setFilter(PathFilterGroup.createFromStrings(paths));
-        tw.setRecursive(true);
-        int o = tw.addTree(rw.parseCommit(oldList.getNewId()).getTree());
-        int c = tw.addTree(rw.parseCommit(curList.getNewId()).getTree());
-
-        int op = -1;
-        if (oldList.getOldId() != null) {
-          op = tw.addTree(rw.parseTree(oldList.getOldId()));
-        }
-
-        int cp = -1;
-        if (curList.getOldId() != null) {
-          cp = tw.addTree(rw.parseTree(curList.getOldId()));
-        }
-
-        while (tw.next()) {
-          String path = tw.getPathString();
-          if (tw.getRawMode(o) != 0
-              && tw.getRawMode(c) != 0
-              && tw.idEqual(o, c)
-              && paths.contains(path)) {
-            // File exists in previously reviewed oldList and in curList.
-            // File content is identical.
-            pathList.add(path);
-          } else if (op >= 0
-              && cp >= 0
-              && tw.getRawMode(o) == 0
-              && tw.getRawMode(c) == 0
-              && tw.getRawMode(op) != 0
-              && tw.getRawMode(cp) != 0
-              && tw.idEqual(op, cp)
-              && paths.contains(path)) {
-            // File was deleted in previously reviewed oldList and curList.
-            // File exists in ancestor of oldList and curList.
-            // File content is identical in ancestors.
-            pathList.add(path);
-          }
-        }
-        accountPatchReviewStore
-            .get()
-            .markReviewed(resource.getPatchSet().getId(), userId, pathList);
-        return pathList;
-      }
-    }
-
-    public ListFiles setQuery(String query) {
-      this.query = query;
-      return this;
-    }
-
-    public ListFiles setBase(String base) {
-      this.base = base;
-      return this;
-    }
-
-    public ListFiles setParent(int parentNum) {
-      this.parentNum = parentNum;
-      return this;
-    }
-
-    @Override
-    public String getETag(RevisionResource resource) {
-      Hasher h = Hashing.murmur3_128().newHasher();
-      resource.prepareETag(h, resource.getUser());
-      // File list comes from the PatchListCache, so any change to the key or value should
-      // invalidate ETag.
-      h.putLong(PatchListKey.serialVersionUID);
-      return h.hash().toString();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Fixes.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Fixes.java
deleted file mode 100644
index af9f60a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Fixes.java
+++ /dev/null
@@ -1,71 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.FixSuggestion;
-import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.List;
-import java.util.Objects;
-
-@Singleton
-public class Fixes implements ChildCollection<RevisionResource, FixResource> {
-
-  private final DynamicMap<RestView<FixResource>> views;
-  private final CommentsUtil commentsUtil;
-
-  @Inject
-  Fixes(DynamicMap<RestView<FixResource>> views, CommentsUtil commentsUtil) {
-    this.views = views;
-    this.commentsUtil = commentsUtil;
-  }
-
-  @Override
-  public RestView<RevisionResource> list() throws ResourceNotFoundException {
-    throw new ResourceNotFoundException();
-  }
-
-  @Override
-  public FixResource parse(RevisionResource revisionResource, IdString id)
-      throws ResourceNotFoundException, OrmException {
-    String fixId = id.get();
-    ChangeNotes changeNotes = revisionResource.getNotes();
-
-    List<RobotComment> robotComments =
-        commentsUtil.robotCommentsByPatchSet(changeNotes, revisionResource.getPatchSet().getId());
-    for (RobotComment robotComment : robotComments) {
-      for (FixSuggestion fixSuggestion : robotComment.fixSuggestions) {
-        if (Objects.equals(fixId, fixSuggestion.fixId)) {
-          return new FixResource(revisionResource, fixSuggestion.replacements);
-        }
-      }
-    }
-    throw new ResourceNotFoundException(id);
-  }
-
-  @Override
-  public DynamicMap<RestView<FixResource>> views() {
-    return views;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java
deleted file mode 100644
index 7269a60..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java
+++ /dev/null
@@ -1,107 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.io.OutputStream;
-import org.eclipse.jgit.api.ArchiveCommand;
-import org.eclipse.jgit.api.errors.GitAPIException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.kohsuke.args4j.Option;
-
-public class GetArchive implements RestReadView<RevisionResource> {
-  private final GitRepositoryManager repoManager;
-  private final AllowedFormats allowedFormats;
-
-  @Option(name = "--format")
-  private String format;
-
-  @Inject
-  GetArchive(GitRepositoryManager repoManager, AllowedFormats allowedFormats) {
-    this.repoManager = repoManager;
-    this.allowedFormats = allowedFormats;
-  }
-
-  @Override
-  public BinaryResult apply(RevisionResource rsrc)
-      throws BadRequestException, IOException, MethodNotAllowedException {
-    if (Strings.isNullOrEmpty(format)) {
-      throw new BadRequestException("format is not specified");
-    }
-    final ArchiveFormat f = allowedFormats.extensions.get("." + format);
-    if (f == null) {
-      throw new BadRequestException("unknown archive format");
-    }
-    if (f == ArchiveFormat.ZIP) {
-      throw new MethodNotAllowedException("zip format is disabled");
-    }
-    boolean close = true;
-    final Repository repo = repoManager.openRepository(rsrc.getProject());
-    try {
-      final RevCommit commit;
-      String name;
-      try (RevWalk rw = new RevWalk(repo)) {
-        commit = rw.parseCommit(ObjectId.fromString(rsrc.getPatchSet().getRevision().get()));
-        name = name(f, rw, commit);
-      }
-
-      BinaryResult bin =
-          new BinaryResult() {
-            @Override
-            public void writeTo(OutputStream out) throws IOException {
-              try {
-                new ArchiveCommand(repo)
-                    .setFormat(f.name())
-                    .setTree(commit.getTree())
-                    .setOutputStream(out)
-                    .call();
-              } catch (GitAPIException e) {
-                throw new IOException(e);
-              }
-            }
-
-            @Override
-            public void close() throws IOException {
-              repo.close();
-            }
-          };
-
-      bin.disableGzip().setContentType(f.getMimeType()).setAttachmentName(name);
-
-      close = false;
-      return bin;
-    } finally {
-      if (close) {
-        repo.close();
-      }
-    }
-  }
-
-  private static String name(ArchiveFormat format, RevWalk rw, RevCommit commit)
-      throws IOException {
-    return String.format(
-        "%s%s", rw.getObjectReader().abbreviate(commit, 7).name(), format.getDefaultSuffix());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetAssignee.java
deleted file mode 100644
index d491b91..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetAssignee.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Optional;
-
-@Singleton
-public class GetAssignee implements RestReadView<ChangeResource> {
-  private final AccountLoader.Factory accountLoaderFactory;
-
-  @Inject
-  GetAssignee(AccountLoader.Factory accountLoaderFactory) {
-    this.accountLoaderFactory = accountLoaderFactory;
-  }
-
-  @Override
-  public Response<AccountInfo> apply(ChangeResource rsrc) throws OrmException {
-    Optional<Account.Id> assignee = Optional.ofNullable(rsrc.getChange().getAssignee());
-    if (assignee.isPresent()) {
-      return Response.ok(accountLoaderFactory.create(true).fillOne(assignee.get()));
-    }
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetBlame.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetBlame.java
deleted file mode 100644
index 4702b5a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetBlame.java
+++ /dev/null
@@ -1,170 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.extensions.common.BlameInfo;
-import com.google.gerrit.extensions.common.RangeInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.CacheControl;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.patch.AutoMerger;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gitiles.blame.cache.BlameCache;
-import com.google.gitiles.blame.cache.Region;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.kohsuke.args4j.Option;
-
-public class GetBlame implements RestReadView<FileResource> {
-
-  private final GitRepositoryManager repoManager;
-  private final BlameCache blameCache;
-  private final boolean allowBlame;
-  private final ThreeWayMergeStrategy mergeStrategy;
-  private final AutoMerger autoMerger;
-
-  @Option(
-    name = "--base",
-    aliases = {"-b"},
-    usage =
-        "whether to load the blame of the base revision (the direct"
-            + " parent of the change) instead of the change"
-  )
-  private boolean base;
-
-  @Inject
-  GetBlame(
-      GitRepositoryManager repoManager,
-      BlameCache blameCache,
-      @GerritServerConfig Config cfg,
-      AutoMerger autoMerger) {
-    this.repoManager = repoManager;
-    this.blameCache = blameCache;
-    this.mergeStrategy = MergeUtil.getMergeStrategy(cfg);
-    this.autoMerger = autoMerger;
-    allowBlame = cfg.getBoolean("change", "allowBlame", true);
-  }
-
-  @Override
-  public Response<List<BlameInfo>> apply(FileResource resource)
-      throws RestApiException, OrmException, IOException, InvalidChangeOperationException {
-    if (!allowBlame) {
-      throw new BadRequestException("blame is disabled");
-    }
-
-    Project.NameKey project = resource.getRevision().getChange().getProject();
-    try (Repository repository = repoManager.openRepository(project);
-        ObjectInserter ins = repository.newObjectInserter();
-        ObjectReader reader = ins.newReader();
-        RevWalk revWalk = new RevWalk(reader)) {
-      String refName =
-          resource.getRevision().getEdit().isPresent()
-              ? resource.getRevision().getEdit().get().getRefName()
-              : resource.getRevision().getPatchSet().getRefName();
-
-      Ref ref = repository.findRef(refName);
-      if (ref == null) {
-        throw new ResourceNotFoundException("unknown ref " + refName);
-      }
-      ObjectId objectId = ref.getObjectId();
-      RevCommit revCommit = revWalk.parseCommit(objectId);
-      RevCommit[] parents = revCommit.getParents();
-
-      String path = resource.getPatchKey().getFileName();
-
-      List<BlameInfo> result;
-      if (!base) {
-        result = blame(revCommit, path, repository, revWalk);
-
-      } else if (parents.length == 0) {
-        throw new ResourceNotFoundException("Initial commit doesn't have base");
-
-      } else if (parents.length == 1) {
-        result = blame(parents[0], path, repository, revWalk);
-
-      } else if (parents.length == 2) {
-        ObjectId automerge = autoMerger.merge(repository, revWalk, ins, revCommit, mergeStrategy);
-        result = blame(automerge, path, repository, revWalk);
-
-      } else {
-        throw new ResourceNotFoundException(
-            "Cannot generate blame for merge commit with more than 2 parents");
-      }
-
-      Response<List<BlameInfo>> r = Response.ok(result);
-      if (resource.isCacheable()) {
-        r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
-      }
-      return r;
-    }
-  }
-
-  private List<BlameInfo> blame(ObjectId id, String path, Repository repository, RevWalk revWalk)
-      throws IOException {
-    ListMultimap<BlameInfo, RangeInfo> ranges =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-    List<BlameInfo> result = new ArrayList<>();
-    if (blameCache.findLastCommit(repository, id, path) == null) {
-      return result;
-    }
-
-    List<Region> blameRegions = blameCache.get(repository, id, path);
-    int from = 1;
-    for (Region region : blameRegions) {
-      RevCommit commit = revWalk.parseCommit(region.getSourceCommit());
-      BlameInfo blameInfo = toBlameInfo(commit, region.getSourceAuthor());
-      ranges.put(blameInfo, new RangeInfo(from, from + region.getCount() - 1));
-      from += region.getCount();
-    }
-
-    for (BlameInfo key : ranges.keySet()) {
-      key.ranges = ranges.get(key);
-      result.add(key);
-    }
-    return result;
-  }
-
-  private static BlameInfo toBlameInfo(RevCommit commit, PersonIdent sourceAuthor) {
-    BlameInfo blameInfo = new BlameInfo();
-    blameInfo.author = sourceAuthor.getName();
-    blameInfo.id = commit.getName();
-    blameInfo.commitMsg = commit.getFullMessage();
-    blameInfo.time = commit.getCommitTime();
-    return blameInfo;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetChange.java
deleted file mode 100644
index 22b0b1c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetChange.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.util.EnumSet;
-import org.kohsuke.args4j.Option;
-
-public class GetChange implements RestReadView<ChangeResource> {
-  private final ChangeJson.Factory json;
-  private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
-
-  @Option(name = "-o", usage = "Output options")
-  void addOption(ListChangesOption o) {
-    options.add(o);
-  }
-
-  @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListChangesOption.fromBits(Integer.parseInt(hex, 16)));
-  }
-
-  @Inject
-  GetChange(ChangeJson.Factory json) {
-    this.json = json;
-  }
-
-  @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc) throws OrmException {
-    return Response.withMustRevalidate(json.create(options).format(rsrc));
-  }
-
-  Response<ChangeInfo> apply(RevisionResource rsrc) throws OrmException {
-    return Response.withMustRevalidate(json.create(options).format(rsrc));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java
deleted file mode 100644
index d601737..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetComment implements RestReadView<CommentResource> {
-
-  private final Provider<CommentJson> commentJson;
-
-  @Inject
-  GetComment(Provider<CommentJson> commentJson) {
-    this.commentJson = commentJson;
-  }
-
-  @Override
-  public CommentInfo apply(CommentResource rsrc) throws OrmException {
-    return commentJson.get().newCommentFormatter().format(rsrc.getComment());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java
deleted file mode 100644
index 694379e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.restapi.CacheControl;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.kohsuke.args4j.Option;
-
-public class GetCommit implements RestReadView<RevisionResource> {
-  private final GitRepositoryManager repoManager;
-  private final ChangeJson.Factory json;
-
-  private boolean addLinks;
-
-  @Inject
-  GetCommit(GitRepositoryManager repoManager, ChangeJson.Factory json) {
-    this.repoManager = repoManager;
-    this.json = json;
-  }
-
-  @Option(name = "--links", usage = "Include weblinks")
-  public GetCommit setAddLinks(boolean addLinks) {
-    this.addLinks = addLinks;
-    return this;
-  }
-
-  @Override
-  public Response<CommitInfo> apply(RevisionResource rsrc) throws IOException {
-    Project.NameKey p = rsrc.getChange().getProject();
-    try (Repository repo = repoManager.openRepository(p);
-        RevWalk rw = new RevWalk(repo)) {
-      String rev = rsrc.getPatchSet().getRevision().get();
-      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
-      rw.parseBody(commit);
-      CommitInfo info = json.noOptions().toCommit(rsrc.getProject(), rw, commit, addLinks, true);
-      Response<CommitInfo> r = Response.ok(info);
-      if (rsrc.isCacheable()) {
-        r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
-      }
-      return r;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
deleted file mode 100644
index f6b24b8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
+++ /dev/null
@@ -1,123 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.ComparisonType;
-import com.google.gerrit.server.patch.Text;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.kohsuke.args4j.Option;
-
-public class GetContent implements RestReadView<FileResource> {
-  private final Provider<ReviewDb> db;
-  private final GitRepositoryManager gitManager;
-  private final PatchSetUtil psUtil;
-  private final FileContentUtil fileContentUtil;
-  private final ProjectCache projectCache;
-
-  @Option(name = "--parent")
-  private Integer parent;
-
-  @Inject
-  GetContent(
-      Provider<ReviewDb> db,
-      GitRepositoryManager gitManager,
-      PatchSetUtil psUtil,
-      FileContentUtil fileContentUtil,
-      ProjectCache projectCache) {
-    this.db = db;
-    this.gitManager = gitManager;
-    this.psUtil = psUtil;
-    this.fileContentUtil = fileContentUtil;
-    this.projectCache = projectCache;
-  }
-
-  @Override
-  public BinaryResult apply(FileResource rsrc)
-      throws ResourceNotFoundException, IOException, BadRequestException, OrmException {
-    String path = rsrc.getPatchKey().get();
-    if (Patch.COMMIT_MSG.equals(path)) {
-      String msg = getMessage(rsrc.getRevision().getChangeResource().getNotes());
-      return BinaryResult.create(msg)
-          .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
-          .base64();
-    } else if (Patch.MERGE_LIST.equals(path)) {
-      byte[] mergeList = getMergeList(rsrc.getRevision().getChangeResource().getNotes());
-      return BinaryResult.create(mergeList)
-          .setContentType(FileContentUtil.TEXT_X_GERRIT_MERGE_LIST)
-          .base64();
-    }
-    return fileContentUtil.getContent(
-        projectCache.checkedGet(rsrc.getRevision().getProject()),
-        ObjectId.fromString(rsrc.getRevision().getPatchSet().getRevision().get()),
-        path,
-        parent);
-  }
-
-  private String getMessage(ChangeNotes notes) throws OrmException, IOException {
-    Change.Id changeId = notes.getChangeId();
-    PatchSet ps = psUtil.current(db.get(), notes);
-    if (ps == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-
-    try (Repository git = gitManager.openRepository(notes.getProjectName());
-        RevWalk revWalk = new RevWalk(git)) {
-      RevCommit commit = revWalk.parseCommit(ObjectId.fromString(ps.getRevision().get()));
-      return commit.getFullMessage();
-    } catch (RepositoryNotFoundException e) {
-      throw new NoSuchChangeException(changeId, e);
-    }
-  }
-
-  private byte[] getMergeList(ChangeNotes notes) throws OrmException, IOException {
-    Change.Id changeId = notes.getChangeId();
-    PatchSet ps = psUtil.current(db.get(), notes);
-    if (ps == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-
-    try (Repository git = gitManager.openRepository(notes.getProjectName());
-        RevWalk revWalk = new RevWalk(git)) {
-      return Text.forMergeList(
-              ComparisonType.againstAutoMerge(),
-              revWalk.getObjectReader(),
-              ObjectId.fromString(ps.getRevision().get()))
-          .getContent();
-    } catch (RepositoryNotFoundException e) {
-      throw new NoSuchChangeException(changeId, e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDescription.java
deleted file mode 100644
index b8a34d2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDescription.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetDescription implements RestReadView<RevisionResource> {
-  @Override
-  public String apply(RevisionResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getPatchSet().getDescription());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java
deleted file mode 100644
index 8213193..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import org.kohsuke.args4j.Option;
-
-public class GetDetail implements RestReadView<ChangeResource> {
-  private final GetChange delegate;
-
-  @Option(name = "-o", usage = "Output options")
-  void addOption(ListChangesOption o) {
-    delegate.addOption(o);
-  }
-
-  @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    delegate.setOptionFlagsHex(hex);
-  }
-
-  @Inject
-  GetDetail(GetChange delegate) {
-    this.delegate = delegate;
-    delegate.addOption(ListChangesOption.LABELS);
-    delegate.addOption(ListChangesOption.DETAILED_LABELS);
-    delegate.addOption(ListChangesOption.DETAILED_ACCOUNTS);
-    delegate.addOption(ListChangesOption.MESSAGES);
-    delegate.addOption(ListChangesOption.REVIEWER_UPDATES);
-  }
-
-  @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc) throws OrmException {
-    return delegate.apply(rsrc);
-  }
-}
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
deleted file mode 100644
index c91748c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
+++ /dev/null
@@ -1,453 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.gerrit.common.data.PatchScript;
-import com.google.gerrit.common.data.PatchScript.DisplayMethod;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.extensions.common.ChangeType;
-import com.google.gerrit.extensions.common.DiffInfo;
-import com.google.gerrit.extensions.common.DiffInfo.ContentEntry;
-import com.google.gerrit.extensions.common.DiffInfo.FileMeta;
-import com.google.gerrit.extensions.common.DiffInfo.IntraLineStatus;
-import com.google.gerrit.extensions.common.DiffWebLinkInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.CacheControl;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.prettify.common.SparseFileContent;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.WebLinks;
-import com.google.gerrit.server.git.LargeObjectException;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchScriptFactory;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.List;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.diff.ReplaceEdit;
-import org.kohsuke.args4j.CmdLineException;
-import org.kohsuke.args4j.CmdLineParser;
-import org.kohsuke.args4j.NamedOptionDef;
-import org.kohsuke.args4j.Option;
-import org.kohsuke.args4j.OptionDef;
-import org.kohsuke.args4j.spi.OptionHandler;
-import org.kohsuke.args4j.spi.Parameters;
-import org.kohsuke.args4j.spi.Setter;
-
-public class GetDiff implements RestReadView<FileResource> {
-  private static final ImmutableMap<Patch.ChangeType, ChangeType> CHANGE_TYPE =
-      Maps.immutableEnumMap(
-          new ImmutableMap.Builder<Patch.ChangeType, ChangeType>()
-              .put(Patch.ChangeType.ADDED, ChangeType.ADDED)
-              .put(Patch.ChangeType.MODIFIED, ChangeType.MODIFIED)
-              .put(Patch.ChangeType.DELETED, ChangeType.DELETED)
-              .put(Patch.ChangeType.RENAMED, ChangeType.RENAMED)
-              .put(Patch.ChangeType.COPIED, ChangeType.COPIED)
-              .put(Patch.ChangeType.REWRITE, ChangeType.REWRITE)
-              .build());
-
-  private final ProjectCache projectCache;
-  private final PatchScriptFactory.Factory patchScriptFactoryFactory;
-  private final Revisions revisions;
-  private final WebLinks webLinks;
-
-  @Option(name = "--base", metaVar = "REVISION")
-  String base;
-
-  @Option(name = "--parent", metaVar = "parent-number")
-  int parentNum;
-
-  @Deprecated
-  @Option(name = "--ignore-whitespace")
-  IgnoreWhitespace ignoreWhitespace;
-
-  @Option(name = "--whitespace")
-  Whitespace whitespace;
-
-  @Option(name = "--context", handler = ContextOptionHandler.class)
-  int context = DiffPreferencesInfo.DEFAULT_CONTEXT;
-
-  @Option(name = "--intraline")
-  boolean intraline;
-
-  @Option(name = "--weblinks-only")
-  boolean webLinksOnly;
-
-  @Inject
-  GetDiff(
-      ProjectCache projectCache,
-      PatchScriptFactory.Factory patchScriptFactoryFactory,
-      Revisions revisions,
-      WebLinks webLinks) {
-    this.projectCache = projectCache;
-    this.patchScriptFactoryFactory = patchScriptFactoryFactory;
-    this.revisions = revisions;
-    this.webLinks = webLinks;
-  }
-
-  @Override
-  public Response<DiffInfo> apply(FileResource resource)
-      throws ResourceConflictException, ResourceNotFoundException, OrmException, AuthException,
-          InvalidChangeOperationException, IOException {
-    DiffPreferencesInfo prefs = new DiffPreferencesInfo();
-    if (whitespace != null) {
-      prefs.ignoreWhitespace = whitespace;
-    } else if (ignoreWhitespace != null) {
-      prefs.ignoreWhitespace = ignoreWhitespace.whitespace;
-    } else {
-      prefs.ignoreWhitespace = Whitespace.IGNORE_LEADING_AND_TRAILING;
-    }
-    prefs.context = context;
-    prefs.intralineDifference = intraline;
-
-    PatchScriptFactory psf;
-    PatchSet basePatchSet = null;
-    PatchSet.Id pId = resource.getPatchKey().getParentKey();
-    String fileName = resource.getPatchKey().getFileName();
-    ChangeNotes notes = resource.getRevision().getNotes();
-    if (base != null) {
-      RevisionResource baseResource =
-          revisions.parse(resource.getRevision().getChangeResource(), IdString.fromDecoded(base));
-      basePatchSet = baseResource.getPatchSet();
-      psf = patchScriptFactoryFactory.create(notes, fileName, basePatchSet.getId(), pId, prefs);
-    } else if (parentNum > 0) {
-      psf = patchScriptFactoryFactory.create(notes, fileName, parentNum - 1, pId, prefs);
-    } else {
-      psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs);
-    }
-
-    try {
-      psf.setLoadHistory(false);
-      psf.setLoadComments(context != DiffPreferencesInfo.WHOLE_FILE_CONTEXT);
-      PatchScript ps = psf.call();
-      Content content = new Content(ps);
-      Set<Edit> editsDueToRebase = ps.getEditsDueToRebase();
-      for (Edit edit : ps.getEdits()) {
-        if (edit.getType() == Edit.Type.EMPTY) {
-          continue;
-        }
-        content.addCommon(edit.getBeginA());
-
-        checkState(
-            content.nextA == edit.getBeginA(),
-            "nextA = %s; want %s",
-            content.nextA,
-            edit.getBeginA());
-        checkState(
-            content.nextB == edit.getBeginB(),
-            "nextB = %s; want %s",
-            content.nextB,
-            edit.getBeginB());
-        switch (edit.getType()) {
-          case DELETE:
-          case INSERT:
-          case REPLACE:
-            List<Edit> internalEdit =
-                edit instanceof ReplaceEdit ? ((ReplaceEdit) edit).getInternalEdits() : null;
-            boolean dueToRebase = editsDueToRebase.contains(edit);
-            content.addDiff(edit.getEndA(), edit.getEndB(), internalEdit, dueToRebase);
-            break;
-          case EMPTY:
-          default:
-            throw new IllegalStateException();
-        }
-      }
-      content.addCommon(ps.getA().size());
-
-      ProjectState state = projectCache.get(resource.getRevision().getChange().getProject());
-
-      DiffInfo result = new DiffInfo();
-      String revA = basePatchSet != null ? basePatchSet.getRefName() : content.commitIdA;
-      String revB =
-          resource.getRevision().getEdit().isPresent()
-              ? resource.getRevision().getEdit().get().getRefName()
-              : resource.getRevision().getPatchSet().getRefName();
-
-      List<DiffWebLinkInfo> links =
-          webLinks.getDiffLinks(
-              state.getName(),
-              resource.getPatchKey().getParentKey().getParentKey().get(),
-              basePatchSet != null ? basePatchSet.getId().get() : null,
-              revA,
-              MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
-              resource.getPatchKey().getParentKey().get(),
-              revB,
-              ps.getNewName());
-      result.webLinks = links.isEmpty() ? null : links;
-
-      if (!webLinksOnly) {
-        if (ps.isBinary()) {
-          result.binary = true;
-        }
-        if (ps.getDisplayMethodA() != DisplayMethod.NONE) {
-          result.metaA = new FileMeta();
-          result.metaA.name = MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName());
-          result.metaA.contentType =
-              FileContentUtil.resolveContentType(
-                  state, result.metaA.name, ps.getFileModeA(), ps.getMimeTypeA());
-          result.metaA.lines = ps.getA().size();
-          result.metaA.webLinks = getFileWebLinks(state.getProject(), revA, result.metaA.name);
-          result.metaA.commitId = content.commitIdA;
-        }
-
-        if (ps.getDisplayMethodB() != DisplayMethod.NONE) {
-          result.metaB = new FileMeta();
-          result.metaB.name = ps.getNewName();
-          result.metaB.contentType =
-              FileContentUtil.resolveContentType(
-                  state, result.metaB.name, ps.getFileModeB(), ps.getMimeTypeB());
-          result.metaB.lines = ps.getB().size();
-          result.metaB.webLinks = getFileWebLinks(state.getProject(), revB, result.metaB.name);
-          result.metaB.commitId = content.commitIdB;
-        }
-
-        if (intraline) {
-          if (ps.hasIntralineTimeout()) {
-            result.intralineStatus = IntraLineStatus.TIMEOUT;
-          } else if (ps.hasIntralineFailure()) {
-            result.intralineStatus = IntraLineStatus.FAILURE;
-          } else {
-            result.intralineStatus = IntraLineStatus.OK;
-          }
-        }
-
-        result.changeType = CHANGE_TYPE.get(ps.getChangeType());
-        if (result.changeType == null) {
-          throw new IllegalStateException("unknown change type: " + ps.getChangeType());
-        }
-
-        if (ps.getPatchHeader().size() > 0) {
-          result.diffHeader = ps.getPatchHeader();
-        }
-        result.content = content.lines;
-      }
-
-      Response<DiffInfo> r = Response.ok(result);
-      if (resource.isCacheable()) {
-        r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
-      }
-      return r;
-    } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(e.getMessage(), e);
-    } catch (LargeObjectException e) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    }
-  }
-
-  private List<WebLinkInfo> getFileWebLinks(Project project, String rev, String file) {
-    List<WebLinkInfo> links = webLinks.getFileLinks(project.getName(), rev, file);
-    return links.isEmpty() ? null : links;
-  }
-
-  public GetDiff setBase(String base) {
-    this.base = base;
-    return this;
-  }
-
-  public GetDiff setParent(int parentNum) {
-    this.parentNum = parentNum;
-    return this;
-  }
-
-  public GetDiff setContext(int context) {
-    this.context = context;
-    return this;
-  }
-
-  public GetDiff setIntraline(boolean intraline) {
-    this.intraline = intraline;
-    return this;
-  }
-
-  public GetDiff setWhitespace(Whitespace whitespace) {
-    this.whitespace = whitespace;
-    return this;
-  }
-
-  private static class Content {
-    final List<ContentEntry> lines;
-    final SparseFileContent fileA;
-    final SparseFileContent fileB;
-    final boolean ignoreWS;
-    final String commitIdA;
-    final String commitIdB;
-
-    int nextA;
-    int nextB;
-
-    Content(PatchScript ps) {
-      lines = Lists.newArrayListWithExpectedSize(ps.getEdits().size() + 2);
-      fileA = ps.getA();
-      fileB = ps.getB();
-      ignoreWS = ps.isIgnoreWhitespace();
-      commitIdA = ps.getCommitIdA();
-      commitIdB = ps.getCommitIdB();
-    }
-
-    void addCommon(int end) {
-      end = Math.min(end, fileA.size());
-      if (nextA >= end) {
-        return;
-      }
-
-      while (nextA < end) {
-        if (!fileA.contains(nextA)) {
-          int endRegion = Math.min(end, nextA == 0 ? fileA.first() : fileA.next(nextA - 1));
-          int len = endRegion - nextA;
-          entry().skip = len;
-          nextA = endRegion;
-          nextB += len;
-          continue;
-        }
-
-        ContentEntry e = null;
-        for (int i = nextA; i == nextA && i < end; i = fileA.next(i), nextA++, nextB++) {
-          if (ignoreWS && fileB.contains(nextB)) {
-            if (e == null || e.common == null) {
-              e = entry();
-              e.a = Lists.newArrayListWithCapacity(end - nextA);
-              e.b = Lists.newArrayListWithCapacity(end - nextA);
-              e.common = true;
-            }
-            e.a.add(fileA.get(nextA));
-            e.b.add(fileB.get(nextB));
-          } else {
-            if (e == null || e.common != null) {
-              e = entry();
-              e.ab = Lists.newArrayListWithCapacity(end - nextA);
-            }
-            e.ab.add(fileA.get(nextA));
-          }
-        }
-      }
-    }
-
-    void addDiff(int endA, int endB, List<Edit> internalEdit, boolean dueToRebase) {
-      int lenA = endA - nextA;
-      int lenB = endB - nextB;
-      checkState(lenA > 0 || lenB > 0);
-
-      ContentEntry e = entry();
-      if (lenA > 0) {
-        e.a = Lists.newArrayListWithCapacity(lenA);
-        for (; nextA < endA; nextA++) {
-          e.a.add(fileA.get(nextA));
-        }
-      }
-      if (lenB > 0) {
-        e.b = Lists.newArrayListWithCapacity(lenB);
-        for (; nextB < endB; nextB++) {
-          e.b.add(fileB.get(nextB));
-        }
-      }
-      if (internalEdit != null && !internalEdit.isEmpty()) {
-        e.editA = Lists.newArrayListWithCapacity(internalEdit.size() * 2);
-        e.editB = Lists.newArrayListWithCapacity(internalEdit.size() * 2);
-        int lastA = 0;
-        int lastB = 0;
-        for (Edit edit : internalEdit) {
-          if (edit.getBeginA() != edit.getEndA()) {
-            e.editA.add(
-                ImmutableList.of(edit.getBeginA() - lastA, edit.getEndA() - edit.getBeginA()));
-            lastA = edit.getEndA();
-          }
-          if (edit.getBeginB() != edit.getEndB()) {
-            e.editB.add(
-                ImmutableList.of(edit.getBeginB() - lastB, edit.getEndB() - edit.getBeginB()));
-            lastB = edit.getEndB();
-          }
-        }
-      }
-      e.dueToRebase = dueToRebase ? true : null;
-    }
-
-    private ContentEntry entry() {
-      ContentEntry e = new ContentEntry();
-      lines.add(e);
-      return e;
-    }
-  }
-
-  @Deprecated
-  enum IgnoreWhitespace {
-    NONE(DiffPreferencesInfo.Whitespace.IGNORE_NONE),
-    TRAILING(DiffPreferencesInfo.Whitespace.IGNORE_TRAILING),
-    CHANGED(DiffPreferencesInfo.Whitespace.IGNORE_LEADING_AND_TRAILING),
-    ALL(DiffPreferencesInfo.Whitespace.IGNORE_ALL);
-
-    private final DiffPreferencesInfo.Whitespace whitespace;
-
-    IgnoreWhitespace(DiffPreferencesInfo.Whitespace whitespace) {
-      this.whitespace = whitespace;
-    }
-  }
-
-  public static class ContextOptionHandler extends OptionHandler<Short> {
-    public ContextOptionHandler(CmdLineParser parser, OptionDef option, Setter<Short> setter) {
-      super(parser, option, setter);
-    }
-
-    @Override
-    public final int parseArguments(Parameters params) throws CmdLineException {
-      final String value = params.getParameter(0);
-      short context;
-      if ("all".equalsIgnoreCase(value)) {
-        context = DiffPreferencesInfo.WHOLE_FILE_CONTEXT;
-      } else {
-        try {
-          context = Short.parseShort(value, 10);
-          if (context < 0) {
-            throw new NumberFormatException();
-          }
-        } catch (NumberFormatException e) {
-          throw new CmdLineException(
-              owner,
-              String.format(
-                  "\"%s\" is not a valid value for \"%s\"",
-                  value, ((NamedOptionDef) option).name()));
-        }
-      }
-      setter.addValue(context);
-      return 1;
-    }
-
-    @Override
-    public final String getDefaultMetaVariable() {
-      return "ALL|# LINES";
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java
deleted file mode 100644
index a380ce3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetDraftComment implements RestReadView<DraftCommentResource> {
-
-  private final Provider<CommentJson> commentJson;
-
-  @Inject
-  GetDraftComment(Provider<CommentJson> commentJson) {
-    this.commentJson = commentJson;
-  }
-
-  @Override
-  public CommentInfo apply(DraftCommentResource rsrc) throws OrmException {
-    return commentJson.get().newCommentFormatter().format(rsrc.getComment());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetHashtags.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetHashtags.java
deleted file mode 100644
index c285734..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetHashtags.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collections;
-import java.util.Set;
-
-@Singleton
-public class GetHashtags implements RestReadView<ChangeResource> {
-  @Override
-  public Response<Set<String>> apply(ChangeResource req)
-      throws AuthException, OrmException, IOException, BadRequestException {
-    ChangeNotes notes = req.getNotes().load();
-    Set<String> hashtags = notes.getHashtags();
-    if (hashtags == null) {
-      hashtags = Collections.emptySet();
-    }
-    return Response.ok(hashtags);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetMergeList.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetMergeList.java
deleted file mode 100644
index 88677d6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetMergeList.java
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.CacheControl;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.MergeListBuilder;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.kohsuke.args4j.Option;
-
-public class GetMergeList implements RestReadView<RevisionResource> {
-  private final GitRepositoryManager repoManager;
-  private final ChangeJson.Factory json;
-
-  @Option(name = "--parent", usage = "Uninteresting parent (1-based, default = 1)")
-  private int uninterestingParent = 1;
-
-  @Option(name = "--links", usage = "Include weblinks")
-  private boolean addLinks;
-
-  @Inject
-  GetMergeList(GitRepositoryManager repoManager, ChangeJson.Factory json) {
-    this.repoManager = repoManager;
-    this.json = json;
-  }
-
-  public void setUninterestingParent(int uninterestingParent) {
-    this.uninterestingParent = uninterestingParent;
-  }
-
-  public void setAddLinks(boolean addLinks) {
-    this.addLinks = addLinks;
-  }
-
-  @Override
-  public Response<List<CommitInfo>> apply(RevisionResource rsrc)
-      throws BadRequestException, IOException {
-    Project.NameKey p = rsrc.getChange().getProject();
-    try (Repository repo = repoManager.openRepository(p);
-        RevWalk rw = new RevWalk(repo)) {
-      String rev = rsrc.getPatchSet().getRevision().get();
-      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
-      rw.parseBody(commit);
-
-      if (uninterestingParent < 1 || uninterestingParent > commit.getParentCount()) {
-        throw new BadRequestException("No such parent: " + uninterestingParent);
-      }
-
-      if (commit.getParentCount() < 2) {
-        return createResponse(rsrc, ImmutableList.<CommitInfo>of());
-      }
-
-      List<RevCommit> commits = MergeListBuilder.build(rw, commit, uninterestingParent);
-      List<CommitInfo> result = new ArrayList<>(commits.size());
-      ChangeJson changeJson = json.noOptions();
-      for (RevCommit c : commits) {
-        result.add(changeJson.toCommit(rsrc.getProject(), rw, c, addLinks, true));
-      }
-      return createResponse(rsrc, result);
-    }
-  }
-
-  private static Response<List<CommitInfo>> createResponse(
-      RevisionResource rsrc, List<CommitInfo> result) {
-    Response<List<CommitInfo>> r = Response.ok(result);
-    if (rsrc.isCacheable()) {
-      r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
-    }
-    return r;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPastAssignees.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPastAssignees.java
deleted file mode 100644
index 76114ac..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPastAssignees.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-
-@Singleton
-public class GetPastAssignees implements RestReadView<ChangeResource> {
-  private final AccountLoader.Factory accountLoaderFactory;
-
-  @Inject
-  GetPastAssignees(AccountLoader.Factory accountLoaderFactory) {
-    this.accountLoaderFactory = accountLoaderFactory;
-  }
-
-  @Override
-  public Response<List<AccountInfo>> apply(ChangeResource rsrc) throws OrmException {
-
-    Set<Account.Id> pastAssignees = rsrc.getNotes().load().getPastAssignees();
-    if (pastAssignees == null) {
-      return Response.ok(Collections.emptyList());
-    }
-
-    AccountLoader accountLoader = accountLoaderFactory.create(true);
-    List<AccountInfo> infos = pastAssignees.stream().map(accountLoader::get).collect(toList());
-    accountLoader.fill();
-    return Response.ok(infos);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java
deleted file mode 100644
index b59c17c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java
+++ /dev/null
@@ -1,194 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.text.SimpleDateFormat;
-import java.util.Calendar;
-import java.util.Locale;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipOutputStream;
-import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.lib.AbbreviatedObjectId;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.filter.PathFilter;
-import org.kohsuke.args4j.Option;
-
-public class GetPatch implements RestReadView<RevisionResource> {
-  private final GitRepositoryManager repoManager;
-
-  private final String FILE_NOT_FOUND = "File not found: %s.";
-
-  @Option(name = "--zip")
-  private boolean zip;
-
-  @Option(name = "--download")
-  private boolean download;
-
-  @Option(name = "--path")
-  private String path;
-
-  @Inject
-  GetPatch(GitRepositoryManager repoManager) {
-    this.repoManager = repoManager;
-  }
-
-  @Override
-  public BinaryResult apply(RevisionResource rsrc)
-      throws ResourceConflictException, IOException, ResourceNotFoundException {
-    final Repository repo = repoManager.openRepository(rsrc.getProject());
-    boolean close = true;
-    try {
-      final RevWalk rw = new RevWalk(repo);
-      try {
-        final RevCommit commit =
-            rw.parseCommit(ObjectId.fromString(rsrc.getPatchSet().getRevision().get()));
-        RevCommit[] parents = commit.getParents();
-        if (parents.length > 1) {
-          throw new ResourceConflictException("Revision has more than 1 parent.");
-        } else if (parents.length == 0) {
-          throw new ResourceConflictException("Revision has no parent.");
-        }
-        final RevCommit base = parents[0];
-        rw.parseBody(base);
-
-        BinaryResult bin =
-            new BinaryResult() {
-              @Override
-              public void writeTo(OutputStream out) throws IOException {
-                if (zip) {
-                  ZipOutputStream zos = new ZipOutputStream(out);
-                  ZipEntry e = new ZipEntry(fileName(rw, commit));
-                  e.setTime(commit.getCommitTime() * 1000L);
-                  zos.putNextEntry(e);
-                  format(zos);
-                  zos.closeEntry();
-                  zos.finish();
-                } else {
-                  format(out);
-                }
-              }
-
-              private void format(OutputStream out) throws IOException {
-                // Only add header if no path is specified
-                if (path == null) {
-                  out.write(formatEmailHeader(commit).getBytes(UTF_8));
-                }
-                try (DiffFormatter fmt = new DiffFormatter(out)) {
-                  fmt.setRepository(repo);
-                  if (path != null) {
-                    fmt.setPathFilter(PathFilter.create(path));
-                  }
-                  fmt.format(base.getTree(), commit.getTree());
-                  fmt.flush();
-                }
-              }
-
-              @Override
-              public void close() throws IOException {
-                rw.close();
-                repo.close();
-              }
-            };
-
-        if (path != null && bin.asString().isEmpty()) {
-          throw new ResourceNotFoundException(String.format(FILE_NOT_FOUND, path));
-        }
-
-        if (zip) {
-          bin.disableGzip()
-              .setContentType("application/zip")
-              .setAttachmentName(fileName(rw, commit) + ".zip");
-        } else {
-          bin.base64()
-              .setContentType("application/mbox")
-              .setAttachmentName(download ? fileName(rw, commit) + ".base64" : null);
-        }
-
-        close = false;
-        return bin;
-      } finally {
-        if (close) {
-          rw.close();
-        }
-      }
-    } finally {
-      if (close) {
-        repo.close();
-      }
-    }
-  }
-
-  public GetPatch setPath(String path) {
-    this.path = path;
-    return this;
-  }
-
-  private static String formatEmailHeader(RevCommit commit) {
-    StringBuilder b = new StringBuilder();
-    PersonIdent author = commit.getAuthorIdent();
-    String subject = commit.getShortMessage();
-    String msg = commit.getFullMessage().substring(subject.length());
-    if (msg.startsWith("\n\n")) {
-      msg = msg.substring(2);
-    }
-    b.append("From ")
-        .append(commit.getName())
-        .append(' ')
-        .append(
-            "Mon Sep 17 00:00:00 2001\n") // Fixed timestamp to match output of C Git's format-patch
-        .append("From: ")
-        .append(author.getName())
-        .append(" <")
-        .append(author.getEmailAddress())
-        .append(">\n")
-        .append("Date: ")
-        .append(formatDate(author))
-        .append('\n')
-        .append("Subject: [PATCH] ")
-        .append(subject)
-        .append('\n')
-        .append('\n')
-        .append(msg);
-    if (!msg.endsWith("\n")) {
-      b.append('\n');
-    }
-    return b.append("---\n\n").toString();
-  }
-
-  private static String formatDate(PersonIdent author) {
-    SimpleDateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
-    df.setCalendar(Calendar.getInstance(author.getTimeZone(), Locale.US));
-    return df.format(author.getWhen());
-  }
-
-  private static String fileName(RevWalk rw, RevCommit commit) throws IOException {
-    AbbreviatedObjectId id = rw.getObjectReader().abbreviate(commit, 7);
-    return id.name() + ".diff";
-  }
-}
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
deleted file mode 100644
index 6002f75..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPureRevert.java
+++ /dev/null
@@ -1,162 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.common.PureRevertInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.diff.DiffEntry;
-import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.errors.InvalidObjectIdException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.ThreeWayMerger;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.kohsuke.args4j.Option;
-
-public class GetPureRevert implements RestReadView<ChangeResource> {
-  private final MergeUtil.Factory mergeUtilFactory;
-  private final GitRepositoryManager repoManager;
-  private final ProjectCache projectCache;
-  private final ChangeNotes.Factory notesFactory;
-  private final Provider<ReviewDb> dbProvider;
-  private final PatchSetUtil psUtil;
-  private final ChangeControl.GenericFactory changeControlFactory;
-
-  @Option(
-    name = "--claimed-original",
-    aliases = {"-o"},
-    usage = "SHA1 (40 digit hex) of the original commit"
-  )
-  @Nullable
-  private String claimedOriginal;
-
-  @Inject
-  GetPureRevert(
-      MergeUtil.Factory mergeUtilFactory,
-      GitRepositoryManager repoManager,
-      ProjectCache projectCache,
-      ChangeNotes.Factory notesFactory,
-      Provider<ReviewDb> dbProvider,
-      PatchSetUtil psUtil,
-      ChangeControl.GenericFactory changeControlFactory) {
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.repoManager = repoManager;
-    this.projectCache = projectCache;
-    this.notesFactory = notesFactory;
-    this.dbProvider = dbProvider;
-    this.psUtil = psUtil;
-    this.changeControlFactory = changeControlFactory;
-  }
-
-  @Override
-  public PureRevertInfo apply(ChangeResource rsrc)
-      throws ResourceConflictException, IOException, BadRequestException, OrmException,
-          AuthException {
-    PatchSet currentPatchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
-    if (currentPatchSet == null) {
-      throw new ResourceConflictException("current revision is missing");
-    } else if (!changeControlFactory
-        .controlFor(rsrc.getNotes(), rsrc.getUser())
-        .isVisible(dbProvider.get())) {
-      throw new AuthException("current revision not accessible");
-    }
-    return getPureRevert(rsrc.getNotes());
-  }
-
-  public PureRevertInfo getPureRevert(ChangeNotes notes)
-      throws OrmException, IOException, BadRequestException, ResourceConflictException {
-    PatchSet currentPatchSet = psUtil.current(dbProvider.get(), notes);
-    if (currentPatchSet == null) {
-      throw new ResourceConflictException("current revision is missing");
-    }
-
-    if (claimedOriginal == null) {
-      if (notes.getChange().getRevertOf() == null) {
-        throw new BadRequestException("no ID was provided and change isn't a revert");
-      }
-      PatchSet ps =
-          psUtil.current(
-              dbProvider.get(),
-              notesFactory.createChecked(
-                  dbProvider.get(), notes.getProjectName(), notes.getChange().getRevertOf()));
-      claimedOriginal = ps.getRevision().get();
-    }
-
-    try (Repository repo = repoManager.openRepository(notes.getProjectName());
-        ObjectInserter oi = repo.newObjectInserter();
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit claimedOriginalCommit;
-      try {
-        claimedOriginalCommit = rw.parseCommit(ObjectId.fromString(claimedOriginal));
-      } catch (InvalidObjectIdException | MissingObjectException e) {
-        throw new BadRequestException("invalid object ID");
-      }
-      if (claimedOriginalCommit.getParentCount() == 0) {
-        throw new BadRequestException("can't check against initial commit");
-      }
-      RevCommit claimedRevertCommit =
-          rw.parseCommit(ObjectId.fromString(currentPatchSet.getRevision().get()));
-      if (claimedRevertCommit.getParentCount() == 0) {
-        throw new BadRequestException("claimed revert has no parents");
-      }
-      // Rebase claimed revert onto claimed original
-      ThreeWayMerger merger =
-          mergeUtilFactory
-              .create(projectCache.checkedGet(notes.getProjectName()))
-              .newThreeWayMerger(oi, repo.getConfig());
-      merger.setBase(claimedRevertCommit.getParent(0));
-      merger.merge(claimedRevertCommit, claimedOriginalCommit);
-      if (merger.getResultTreeId() == null) {
-        // Merge conflict during rebase
-        return new PureRevertInfo(false);
-      }
-
-      // Any differences between claimed original's parent and the rebase result indicate that the
-      // claimedRevert is not a pure revert but made content changes
-      try (DiffFormatter df = new DiffFormatter(new ByteArrayOutputStream())) {
-        df.setRepository(repo);
-        List<DiffEntry> entries =
-            df.scan(claimedOriginalCommit.getParent(0), merger.getResultTreeId());
-        return new PureRevertInfo(entries.isEmpty());
-      }
-    }
-  }
-
-  public GetPureRevert setClaimedOriginal(String claimedOriginal) {
-    this.claimedOriginal = claimedOriginal;
-    return this;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
deleted file mode 100644
index a6583b1..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
+++ /dev/null
@@ -1,203 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommonConverters;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.change.RelatedChangesSorter.PatchSetData;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.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.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.revwalk.RevCommit;
-
-@Singleton
-public class GetRelated implements RestReadView<RevisionResource> {
-  private final Provider<ReviewDb> db;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final PatchSetUtil psUtil;
-  private final RelatedChangesSorter sorter;
-
-  @Inject
-  GetRelated(
-      Provider<ReviewDb> db,
-      Provider<InternalChangeQuery> queryProvider,
-      PatchSetUtil psUtil,
-      RelatedChangesSorter sorter) {
-    this.db = db;
-    this.queryProvider = queryProvider;
-    this.psUtil = psUtil;
-    this.sorter = sorter;
-  }
-
-  @Override
-  public RelatedInfo apply(RevisionResource rsrc)
-      throws RepositoryNotFoundException, IOException, OrmException, NoSuchProjectException {
-    RelatedInfo relatedInfo = new RelatedInfo();
-    relatedInfo.changes = getRelated(rsrc);
-    return relatedInfo;
-  }
-
-  private List<ChangeAndCommit> getRelated(RevisionResource rsrc)
-      throws OrmException, IOException, NoSuchProjectException {
-    Set<String> groups = getAllGroups(rsrc.getNotes());
-    if (groups.isEmpty()) {
-      return Collections.emptyList();
-    }
-
-    List<ChangeData> cds =
-        queryProvider
-            .get()
-            .enforceVisibility(true)
-            .byProjectGroups(rsrc.getChange().getProject(), groups);
-    if (cds.isEmpty()) {
-      return Collections.emptyList();
-    }
-    if (cds.size() == 1 && cds.get(0).getId().equals(rsrc.getChange().getId())) {
-      return Collections.emptyList();
-    }
-    List<ChangeAndCommit> result = new ArrayList<>(cds.size());
-
-    boolean isEdit = rsrc.getEdit().isPresent();
-    PatchSet basePs = isEdit ? rsrc.getEdit().get().getBasePatchSet() : rsrc.getPatchSet();
-
-    reloadChangeIfStale(cds, basePs);
-
-    for (PatchSetData d : sorter.sort(cds, basePs, rsrc.getUser())) {
-      PatchSet ps = d.patchSet();
-      RevCommit commit;
-      if (isEdit && ps.getId().equals(basePs.getId())) {
-        // Replace base of an edit with the edit itself.
-        ps = rsrc.getPatchSet();
-        commit = rsrc.getEdit().get().getEditCommit();
-      } else {
-        commit = d.commit();
-      }
-      result.add(new ChangeAndCommit(rsrc.getProject(), d.data().change(), ps, commit));
-    }
-
-    if (result.size() == 1) {
-      ChangeAndCommit r = result.get(0);
-      if (r.commit != null && r.commit.commit.equals(rsrc.getPatchSet().getRevision().get())) {
-        return Collections.emptyList();
-      }
-    }
-    return result;
-  }
-
-  private Set<String> getAllGroups(ChangeNotes notes) throws OrmException {
-    Set<String> result = new HashSet<>();
-    for (PatchSet ps : psUtil.byChange(db.get(), notes)) {
-      result.addAll(ps.getGroups());
-    }
-    return result;
-  }
-
-  private void reloadChangeIfStale(List<ChangeData> cds, PatchSet wantedPs) throws OrmException {
-    for (ChangeData cd : cds) {
-      if (cd.getId().equals(wantedPs.getId().getParentKey())) {
-        if (cd.patchSet(wantedPs.getId()) == null) {
-          cd.reloadChange();
-        }
-      }
-    }
-  }
-
-  public static class RelatedInfo {
-    public List<ChangeAndCommit> changes;
-  }
-
-  public static class ChangeAndCommit {
-    public String project;
-    public String changeId;
-    public CommitInfo commit;
-    public Integer _changeNumber;
-    public Integer _revisionNumber;
-    public Integer _currentRevisionNumber;
-    public String status;
-
-    public ChangeAndCommit() {}
-
-    ChangeAndCommit(
-        Project.NameKey project, @Nullable Change change, @Nullable PatchSet ps, RevCommit c) {
-      this.project = project.get();
-
-      if (change != null) {
-        changeId = change.getKey().get();
-        _changeNumber = change.getChangeId();
-        _revisionNumber = ps != null ? ps.getPatchSetId() : null;
-        PatchSet.Id curr = change.currentPatchSetId();
-        _currentRevisionNumber = curr != null ? curr.get() : null;
-        status = change.getStatus().asChangeStatus().toString();
-      }
-
-      commit = new CommitInfo();
-      commit.commit = c.name();
-      commit.parents = Lists.newArrayListWithCapacity(c.getParentCount());
-      for (int i = 0; i < c.getParentCount(); i++) {
-        CommitInfo p = new CommitInfo();
-        p.commit = c.getParent(i).name();
-        commit.parents.add(p);
-      }
-      commit.author = CommonConverters.toGitPerson(c.getAuthorIdent());
-      commit.subject = c.getShortMessage();
-    }
-
-    @Override
-    public String toString() {
-      return MoreObjects.toStringHelper(this)
-          .add("project", project)
-          .add("changeId", changeId)
-          .add("commit", toString(commit))
-          .add("_changeNumber", _changeNumber)
-          .add("_revisionNumber", _revisionNumber)
-          .add("_currentRevisionNumber", _currentRevisionNumber)
-          .add("status", status)
-          .toString();
-    }
-
-    private static String toString(CommitInfo commit) {
-      return MoreObjects.toStringHelper(commit)
-          .add("commit", commit.commit)
-          .add("parent", commit.parents)
-          .add("author", commit.author)
-          .add("committer", commit.committer)
-          .add("subject", commit.subject)
-          .add("message", commit.message)
-          .add("webLinks", commit.webLinks)
-          .toString();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReview.java
deleted file mode 100644
index f379d83..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReview.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetReview implements RestReadView<RevisionResource> {
-  private final GetChange delegate;
-
-  @Inject
-  GetReview(GetChange delegate) {
-    this.delegate = delegate;
-    delegate.addOption(ListChangesOption.DETAILED_LABELS);
-    delegate.addOption(ListChangesOption.DETAILED_ACCOUNTS);
-  }
-
-  @Override
-  public Response<ChangeInfo> apply(RevisionResource rsrc) throws OrmException {
-    return delegate.apply(rsrc);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
deleted file mode 100644
index db9af1d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.api.changes.ReviewerInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.List;
-
-@Singleton
-public class GetReviewer implements RestReadView<ReviewerResource> {
-  private final ReviewerJson json;
-
-  @Inject
-  GetReviewer(ReviewerJson json) {
-    this.json = json;
-  }
-
-  @Override
-  public List<ReviewerInfo> apply(ReviewerResource rsrc)
-      throws OrmException, PermissionBackendException {
-    return json.format(rsrc);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
deleted file mode 100644
index 2a7bd4b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
+++ /dev/null
@@ -1,83 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.extensions.restapi.ETagView;
-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.config.GerritServerConfig;
-import com.google.gerrit.server.git.ChangeSet;
-import com.google.gerrit.server.git.MergeSuperSet;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Map;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class GetRevisionActions implements ETagView<RevisionResource> {
-  private final ActionJson delegate;
-  private final Config config;
-  private final Provider<ReviewDb> dbProvider;
-  private final Provider<MergeSuperSet> mergeSuperSet;
-  private final ChangeResource.Factory changeResourceFactory;
-
-  @Inject
-  GetRevisionActions(
-      ActionJson delegate,
-      Provider<ReviewDb> dbProvider,
-      Provider<MergeSuperSet> mergeSuperSet,
-      ChangeResource.Factory changeResourceFactory,
-      @GerritServerConfig Config config) {
-    this.delegate = delegate;
-    this.dbProvider = dbProvider;
-    this.mergeSuperSet = mergeSuperSet;
-    this.changeResourceFactory = changeResourceFactory;
-    this.config = config;
-  }
-
-  @Override
-  public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) throws OrmException {
-    return Response.withMustRevalidate(delegate.format(rsrc));
-  }
-
-  @Override
-  public String getETag(RevisionResource rsrc) {
-    Hasher h = Hashing.murmur3_128().newHasher();
-    CurrentUser user = rsrc.getUser();
-    try {
-      rsrc.getChangeResource().prepareETag(h, user);
-      h.putBoolean(Submit.wholeTopicEnabled(config));
-      ReviewDb db = dbProvider.get();
-      ChangeSet cs = mergeSuperSet.get().completeChangeSet(db, rsrc.getChange(), user);
-      for (ChangeData cd : cs.changes()) {
-        changeResourceFactory.create(cd.notes(), user).prepareETag(h, user);
-      }
-      h.putBoolean(cs.furtherHiddenChanges());
-    } catch (IOException | OrmException | PermissionBackendException e) {
-      throw new OrmRuntimeException(e);
-    }
-    return h.hash().toString();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRobotComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRobotComment.java
deleted file mode 100644
index d4d53ad..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRobotComment.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.RobotCommentInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetRobotComment implements RestReadView<RobotCommentResource> {
-
-  private final Provider<CommentJson> commentJson;
-
-  @Inject
-  GetRobotComment(Provider<CommentJson> commentJson) {
-    this.commentJson = commentJson;
-  }
-
-  @Override
-  public RobotCommentInfo apply(RobotCommentResource rsrc) throws OrmException {
-    return commentJson.get().newRobotCommentFormatter().format(rsrc.getComment());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetTopic.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetTopic.java
deleted file mode 100644
index 0746588..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetTopic.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetTopic implements RestReadView<ChangeResource> {
-  @Override
-  public String apply(ChangeResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getChange().getTopic());
-  }
-}
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
deleted file mode 100644
index 46dabdf..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java
+++ /dev/null
@@ -1,83 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.StarredChangesUtil.MutuallyExclusiveLabelsException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class Ignore
-    implements RestModifyView<ChangeResource, Ignore.Input>, UiAction<ChangeResource> {
-  private static final Logger log = LoggerFactory.getLogger(Ignore.class);
-
-  public static class Input {}
-
-  private final StarredChangesUtil stars;
-
-  @Inject
-  Ignore(StarredChangesUtil stars) {
-    this.stars = stars;
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Ignore")
-        .setTitle("Ignore the change")
-        .setVisible(canIgnore(rsrc));
-  }
-
-  @Override
-  public Response<String> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, OrmException, IllegalLabelException {
-    try {
-      if (rsrc.isUserOwner()) {
-        throw new BadRequestException("cannot ignore own change");
-      }
-
-      if (!isIgnored(rsrc)) {
-        stars.ignore(rsrc);
-      }
-      return Response.ok("");
-    } catch (MutuallyExclusiveLabelsException e) {
-      throw new ResourceConflictException(e.getMessage());
-    }
-  }
-
-  private boolean canIgnore(ChangeResource rsrc) {
-    return !rsrc.isUserOwner() && !isIgnored(rsrc);
-  }
-
-  private boolean isIgnored(ChangeResource rsrc) {
-    try {
-      return stars.isIgnored(rsrc);
-    } catch (OrmException e) {
-      log.error("failed to check ignored star", e);
-    }
-    return false;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
deleted file mode 100644
index 7c4d158..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import 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;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class Index extends RetryingRestModifyView<ChangeResource, Input, Response<?>> {
-  public static class Input {}
-
-  private final Provider<ReviewDb> db;
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> user;
-  private final ChangeIndexer indexer;
-
-  @Inject
-  Index(
-      Provider<ReviewDb> db,
-      RetryHelper retryHelper,
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> user,
-      ChangeIndexer indexer) {
-    super(retryHelper);
-    this.db = db;
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-    this.indexer = indexer;
-  }
-
-  @Override
-  protected Response<?> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
-      throws IOException, AuthException, OrmException, PermissionBackendException {
-    permissionBackend.user(user).check(GlobalPermission.MAINTAIN_SERVER);
-    indexer.index(db.get(), rsrc.getChange());
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/LimitedByteArrayOutputStream.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/LimitedByteArrayOutputStream.java
deleted file mode 100644
index facc03c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/LimitedByteArrayOutputStream.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.OutputStream;
-
-class LimitedByteArrayOutputStream extends OutputStream {
-
-  private final int maxSize;
-  private final ByteArrayOutputStream buffer;
-
-  /**
-   * Constructs a LimitedByteArrayOutputStream, which stores output in memory up to a certain
-   * specified size. When the output exceeds the specified size a LimitExceededException is thrown.
-   *
-   * @param max the maximum size in bytes which may be stored.
-   * @param initial the initial size. It must be smaller than the max size.
-   */
-  LimitedByteArrayOutputStream(int max, int initial) {
-    checkArgument(initial <= max);
-    maxSize = max;
-    buffer = new ByteArrayOutputStream(initial);
-  }
-
-  private void checkOversize(int additionalSize) throws IOException {
-    if (buffer.size() + additionalSize > maxSize) {
-      throw new LimitExceededException();
-    }
-  }
-
-  @Override
-  public void write(int b) throws IOException {
-    checkOversize(1);
-    buffer.write(b);
-  }
-
-  @Override
-  public void write(byte[] b, int off, int len) throws IOException {
-    checkOversize(len);
-    buffer.write(b, off, len);
-  }
-
-  /** @return a newly allocated byte array with contents of the buffer. */
-  public byte[] toByteArray() {
-    return buffer.toByteArray();
-  }
-
-  static class LimitExceededException extends IOException {
-    private static final long serialVersionUID = 1L;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java
deleted file mode 100644
index 0048657..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-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.util.List;
-import java.util.Map;
-
-@Singleton
-public class ListChangeComments implements RestReadView<ChangeResource> {
-  private final Provider<ReviewDb> db;
-  private final ChangeData.Factory changeDataFactory;
-  private final Provider<CommentJson> commentJson;
-  private final CommentsUtil commentsUtil;
-
-  @Inject
-  ListChangeComments(
-      Provider<ReviewDb> db,
-      ChangeData.Factory changeDataFactory,
-      Provider<CommentJson> commentJson,
-      CommentsUtil commentsUtil) {
-    this.db = db;
-    this.changeDataFactory = changeDataFactory;
-    this.commentJson = commentJson;
-    this.commentsUtil = commentsUtil;
-  }
-
-  @Override
-  public Map<String, List<CommentInfo>> apply(ChangeResource rsrc)
-      throws AuthException, OrmException {
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
-    return commentJson
-        .get()
-        .setFillAccounts(true)
-        .setFillPatchSet(true)
-        .newCommentFormatter()
-        .format(commentsUtil.publishedByChange(db.get(), cd.notes()));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java
deleted file mode 100644
index 02713de..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-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.util.List;
-import java.util.Map;
-
-@Singleton
-public class ListChangeDrafts implements RestReadView<ChangeResource> {
-  private final Provider<ReviewDb> db;
-  private final ChangeData.Factory changeDataFactory;
-  private final Provider<CommentJson> commentJson;
-  private final CommentsUtil commentsUtil;
-
-  @Inject
-  ListChangeDrafts(
-      Provider<ReviewDb> db,
-      ChangeData.Factory changeDataFactory,
-      Provider<CommentJson> commentJson,
-      CommentsUtil commentsUtil) {
-    this.db = db;
-    this.changeDataFactory = changeDataFactory;
-    this.commentJson = commentJson;
-    this.commentsUtil = commentsUtil;
-  }
-
-  @Override
-  public Map<String, List<CommentInfo>> apply(ChangeResource rsrc)
-      throws AuthException, OrmException {
-    if (!rsrc.getUser().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
-    List<Comment> drafts =
-        commentsUtil.draftByChangeAuthor(db.get(), cd.notes(), rsrc.getUser().getAccountId());
-    return commentJson
-        .get()
-        .setFillAccounts(false)
-        .setFillPatchSet(true)
-        .newCommentFormatter()
-        .format(drafts);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeRobotComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeRobotComments.java
deleted file mode 100644
index fff7f82..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeRobotComments.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.RobotCommentInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.util.List;
-import java.util.Map;
-
-public class ListChangeRobotComments implements RestReadView<ChangeResource> {
-  private final Provider<ReviewDb> db;
-  private final ChangeData.Factory changeDataFactory;
-  private final Provider<CommentJson> commentJson;
-  private final CommentsUtil commentsUtil;
-
-  @Inject
-  ListChangeRobotComments(
-      Provider<ReviewDb> db,
-      ChangeData.Factory changeDataFactory,
-      Provider<CommentJson> commentJson,
-      CommentsUtil commentsUtil) {
-    this.db = db;
-    this.changeDataFactory = changeDataFactory;
-    this.commentJson = commentJson;
-    this.commentsUtil = commentsUtil;
-  }
-
-  @Override
-  public Map<String, List<RobotCommentInfo>> apply(ChangeResource rsrc)
-      throws AuthException, OrmException {
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
-    return commentJson
-        .get()
-        .setFillAccounts(true)
-        .setFillPatchSet(true)
-        .newRobotCommentFormatter()
-        .format(commentsUtil.robotCommentsByChange(cd.notes()));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
deleted file mode 100644
index ba2a10b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.api.changes.ReviewerInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-@Singleton
-class ListReviewers implements RestReadView<ChangeResource> {
-  private final Provider<ReviewDb> dbProvider;
-  private final ApprovalsUtil approvalsUtil;
-  private final ReviewerJson json;
-  private final ReviewerResource.Factory resourceFactory;
-
-  @Inject
-  ListReviewers(
-      Provider<ReviewDb> dbProvider,
-      ApprovalsUtil approvalsUtil,
-      ReviewerResource.Factory resourceFactory,
-      ReviewerJson json) {
-    this.dbProvider = dbProvider;
-    this.approvalsUtil = approvalsUtil;
-    this.resourceFactory = resourceFactory;
-    this.json = json;
-  }
-
-  @Override
-  public List<ReviewerInfo> apply(ChangeResource rsrc)
-      throws OrmException, PermissionBackendException {
-    Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
-    ReviewDb db = dbProvider.get();
-    for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
-      if (!reviewers.containsKey(accountId.toString())) {
-        reviewers.put(accountId.toString(), resourceFactory.create(rsrc, accountId));
-      }
-    }
-    for (Address adr : rsrc.getNotes().getReviewersByEmail().all()) {
-      if (!reviewers.containsKey(adr.toString())) {
-        reviewers.put(adr.toString(), new ReviewerResource(rsrc, adr));
-      }
-    }
-    return json.format(reviewers.values());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionComments.java
deleted file mode 100644
index 037a856..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionComments.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class ListRevisionComments extends ListRevisionDrafts {
-  @Inject
-  ListRevisionComments(
-      Provider<ReviewDb> db, Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
-    super(db, commentJson, commentsUtil);
-  }
-
-  @Override
-  protected boolean includeAuthorInfo() {
-    return true;
-  }
-
-  @Override
-  protected Iterable<Comment> listComments(RevisionResource rsrc) throws OrmException {
-    ChangeNotes notes = rsrc.getNotes();
-    return commentsUtil.publishedByPatchSet(db.get(), notes, rsrc.getPatchSet().getId());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java
deleted file mode 100644
index 0463601..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-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.Map;
-
-@Singleton
-public class ListRevisionDrafts implements RestReadView<RevisionResource> {
-  protected final Provider<ReviewDb> db;
-  protected final Provider<CommentJson> commentJson;
-  protected final CommentsUtil commentsUtil;
-
-  @Inject
-  ListRevisionDrafts(
-      Provider<ReviewDb> db, Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
-    this.db = db;
-    this.commentJson = commentJson;
-    this.commentsUtil = commentsUtil;
-  }
-
-  protected Iterable<Comment> listComments(RevisionResource rsrc) throws OrmException {
-    return commentsUtil.draftByPatchSetAuthor(
-        db.get(), rsrc.getPatchSet().getId(), rsrc.getAccountId(), rsrc.getNotes());
-  }
-
-  protected boolean includeAuthorInfo() {
-    return false;
-  }
-
-  @Override
-  public Map<String, List<CommentInfo>> apply(RevisionResource rsrc) throws OrmException {
-    return commentJson
-        .get()
-        .setFillAccounts(includeAuthorInfo())
-        .newCommentFormatter()
-        .format(listComments(rsrc));
-  }
-
-  public List<CommentInfo> getComments(RevisionResource rsrc) throws OrmException {
-    return commentJson
-        .get()
-        .setFillAccounts(includeAuthorInfo())
-        .newCommentFormatter()
-        .formatAsList(listComments(rsrc));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
deleted file mode 100644
index 6d9dc79..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.api.changes.ReviewerInfo;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-@Singleton
-class ListRevisionReviewers implements RestReadView<RevisionResource> {
-  private final Provider<ReviewDb> dbProvider;
-  private final ApprovalsUtil approvalsUtil;
-  private final ReviewerJson json;
-  private final ReviewerResource.Factory resourceFactory;
-
-  @Inject
-  ListRevisionReviewers(
-      Provider<ReviewDb> dbProvider,
-      ApprovalsUtil approvalsUtil,
-      ReviewerResource.Factory resourceFactory,
-      ReviewerJson json) {
-    this.dbProvider = dbProvider;
-    this.approvalsUtil = approvalsUtil;
-    this.resourceFactory = resourceFactory;
-    this.json = json;
-  }
-
-  @Override
-  public List<ReviewerInfo> apply(RevisionResource rsrc)
-      throws OrmException, MethodNotAllowedException, PermissionBackendException {
-    if (!rsrc.isCurrent()) {
-      throw new MethodNotAllowedException("Cannot list reviewers on non-current patch set");
-    }
-
-    Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
-    ReviewDb db = dbProvider.get();
-    for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
-      if (!reviewers.containsKey(accountId.toString())) {
-        reviewers.put(accountId.toString(), resourceFactory.create(rsrc, accountId));
-      }
-    }
-    for (Address address : rsrc.getNotes().getReviewersByEmail().all()) {
-      if (!reviewers.containsKey(address.toString())) {
-        reviewers.put(address.toString(), new ReviewerResource(rsrc, address));
-      }
-    }
-    return json.format(reviewers.values());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRobotComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRobotComments.java
deleted file mode 100644
index de2b91a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRobotComments.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.RobotCommentInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-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.Map;
-
-@Singleton
-public class ListRobotComments implements RestReadView<RevisionResource> {
-  protected final Provider<ReviewDb> db;
-  protected final Provider<CommentJson> commentJson;
-  protected final CommentsUtil commentsUtil;
-
-  @Inject
-  ListRobotComments(
-      Provider<ReviewDb> db, Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
-    this.db = db;
-    this.commentJson = commentJson;
-    this.commentsUtil = commentsUtil;
-  }
-
-  @Override
-  public Map<String, List<RobotCommentInfo>> apply(RevisionResource rsrc) throws OrmException {
-    return commentJson
-        .get()
-        .setFillAccounts(true)
-        .newRobotCommentFormatter()
-        .format(listComments(rsrc));
-  }
-
-  public List<RobotCommentInfo> getComments(RevisionResource rsrc) throws OrmException {
-    return commentJson
-        .get()
-        .setFillAccounts(true)
-        .newRobotCommentFormatter()
-        .formatAsList(listComments(rsrc));
-  }
-
-  private Iterable<RobotComment> listComments(RevisionResource rsrc) throws OrmException {
-    return commentsUtil.robotCommentsByPatchSet(rsrc.getNotes(), rsrc.getPatchSet().getId());
-  }
-}
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
deleted file mode 100644
index 265b2b0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsReviewed.java
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class MarkAsReviewed
-    implements RestModifyView<ChangeResource, MarkAsReviewed.Input>, UiAction<ChangeResource> {
-  private static final Logger log = LoggerFactory.getLogger(MarkAsReviewed.class);
-
-  public static class Input {}
-
-  private final Provider<ReviewDb> dbProvider;
-  private final ChangeData.Factory changeDataFactory;
-  private final StarredChangesUtil stars;
-
-  @Inject
-  MarkAsReviewed(
-      Provider<ReviewDb> dbProvider,
-      ChangeData.Factory changeDataFactory,
-      StarredChangesUtil stars) {
-    this.dbProvider = dbProvider;
-    this.changeDataFactory = changeDataFactory;
-    this.stars = stars;
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Mark Reviewed")
-        .setTitle("Mark the change as reviewed to unhighlight it in the dashboard")
-        .setVisible(!isReviewed(rsrc));
-  }
-
-  @Override
-  public Response<String> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, OrmException, IllegalLabelException {
-    stars.markAsReviewed(rsrc);
-    return Response.ok("");
-  }
-
-  private boolean isReviewed(ChangeResource rsrc) {
-    try {
-      return changeDataFactory
-          .create(dbProvider.get(), rsrc.getNotes())
-          .isReviewedBy(rsrc.getUser().asIdentifiedUser().getAccountId());
-    } catch (OrmException e) {
-      log.error("failed to check if change is reviewed", e);
-    }
-    return false;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsUnreviewed.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsUnreviewed.java
deleted file mode 100644
index 6de84ee..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsUnreviewed.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class MarkAsUnreviewed
-    implements RestModifyView<ChangeResource, MarkAsUnreviewed.Input>, UiAction<ChangeResource> {
-  private static final Logger log = LoggerFactory.getLogger(MarkAsUnreviewed.class);
-
-  public static class Input {}
-
-  private final Provider<ReviewDb> dbProvider;
-  private final ChangeData.Factory changeDataFactory;
-  private final StarredChangesUtil stars;
-
-  @Inject
-  MarkAsUnreviewed(
-      Provider<ReviewDb> dbProvider,
-      ChangeData.Factory changeDataFactory,
-      StarredChangesUtil stars) {
-    this.dbProvider = dbProvider;
-    this.changeDataFactory = changeDataFactory;
-    this.stars = stars;
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Mark Unreviewed")
-        .setTitle("Mark the change as unreviewed to highlight it in the dashboard")
-        .setVisible(isReviewed(rsrc));
-  }
-
-  @Override
-  public Response<String> apply(ChangeResource rsrc, Input input)
-      throws OrmException, IllegalLabelException {
-    stars.markAsUnreviewed(rsrc);
-    return Response.ok("");
-  }
-
-  private boolean isReviewed(ChangeResource rsrc) {
-    try {
-      return changeDataFactory
-          .create(dbProvider.get(), rsrc.getNotes())
-          .isReviewedBy(rsrc.getUser().asIdentifiedUser().getAccountId());
-    } catch (OrmException e) {
-      log.error("failed to check if change is reviewed", e);
-    }
-    return false;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
deleted file mode 100644
index 119051e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ /dev/null
@@ -1,233 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.ioutil.BasicSerialization.readString;
-import static com.google.gerrit.server.ioutil.BasicSerialization.writeString;
-import static org.eclipse.jgit.lib.ObjectIdSerialization.readNotNull;
-import static org.eclipse.jgit.lib.ObjectIdSerialization.writeNotNull;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.cache.Cache;
-import com.google.common.cache.Weigher;
-import com.google.common.collect.ImmutableBiMap;
-import com.google.common.util.concurrent.UncheckedExecutionException;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
-import com.google.gerrit.server.git.strategy.SubmitDryRun;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Singleton;
-import com.google.inject.name.Named;
-import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
-import java.io.Serializable;
-import java.util.Arrays;
-import java.util.Objects;
-import java.util.Set;
-import java.util.concurrent.ExecutionException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class MergeabilityCacheImpl implements MergeabilityCache {
-  private static final Logger log = LoggerFactory.getLogger(MergeabilityCacheImpl.class);
-
-  private static final String CACHE_NAME = "mergeability";
-
-  public static final ImmutableBiMap<SubmitType, Character> SUBMIT_TYPES =
-      new ImmutableBiMap.Builder<SubmitType, Character>()
-          .put(SubmitType.FAST_FORWARD_ONLY, 'F')
-          .put(SubmitType.MERGE_IF_NECESSARY, 'M')
-          .put(SubmitType.REBASE_ALWAYS, 'P')
-          .put(SubmitType.REBASE_IF_NECESSARY, 'R')
-          .put(SubmitType.MERGE_ALWAYS, 'A')
-          .put(SubmitType.CHERRY_PICK, 'C')
-          .build();
-
-  static {
-    checkState(
-        SUBMIT_TYPES.size() == SubmitType.values().length,
-        "SubmitType <-> char BiMap needs updating");
-  }
-
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        persist(CACHE_NAME, EntryKey.class, Boolean.class)
-            .maximumWeight(1 << 20)
-            .weigher(MergeabilityWeigher.class);
-        bind(MergeabilityCache.class).to(MergeabilityCacheImpl.class);
-      }
-    };
-  }
-
-  public static ObjectId toId(Ref ref) {
-    return ref != null && ref.getObjectId() != null ? ref.getObjectId() : ObjectId.zeroId();
-  }
-
-  public static class EntryKey implements Serializable {
-    private static final long serialVersionUID = 1L;
-
-    private ObjectId commit;
-    private ObjectId into;
-    private SubmitType submitType;
-    private String mergeStrategy;
-
-    public EntryKey(ObjectId commit, ObjectId into, SubmitType submitType, String mergeStrategy) {
-      this.commit = checkNotNull(commit, "commit");
-      this.into = checkNotNull(into, "into");
-      this.submitType = checkNotNull(submitType, "submitType");
-      this.mergeStrategy = checkNotNull(mergeStrategy, "mergeStrategy");
-    }
-
-    public ObjectId getCommit() {
-      return commit;
-    }
-
-    public ObjectId getInto() {
-      return into;
-    }
-
-    public SubmitType getSubmitType() {
-      return submitType;
-    }
-
-    public String getMergeStrategy() {
-      return mergeStrategy;
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      if (o instanceof EntryKey) {
-        EntryKey k = (EntryKey) o;
-        return commit.equals(k.commit)
-            && into.equals(k.into)
-            && submitType == k.submitType
-            && mergeStrategy.equals(k.mergeStrategy);
-      }
-      return false;
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hash(commit, into, submitType, mergeStrategy);
-    }
-
-    @Override
-    public String toString() {
-      return MoreObjects.toStringHelper(this)
-          .add("commit", commit.name())
-          .add("into", into.name())
-          .addValue(submitType)
-          .addValue(mergeStrategy)
-          .toString();
-    }
-
-    private void writeObject(ObjectOutputStream out) throws IOException {
-      writeNotNull(out, commit);
-      writeNotNull(out, into);
-      Character c = SUBMIT_TYPES.get(submitType);
-      if (c == null) {
-        throw new IOException("Invalid submit type: " + submitType);
-      }
-      out.writeChar(c);
-      writeString(out, mergeStrategy);
-    }
-
-    private void readObject(ObjectInputStream in) throws IOException {
-      commit = readNotNull(in);
-      into = readNotNull(in);
-      char t = in.readChar();
-      submitType = SUBMIT_TYPES.inverse().get(t);
-      if (submitType == null) {
-        throw new IOException("Invalid submit type code: " + t);
-      }
-      mergeStrategy = readString(in);
-    }
-  }
-
-  public static class MergeabilityWeigher implements Weigher<EntryKey, Boolean> {
-    @Override
-    public int weigh(EntryKey k, Boolean v) {
-      return 16
-          + 2 * (16 + 20)
-          + 3 * 8 // Size of EntryKey, 64-bit JVM.
-          + 8; // Size of Boolean.
-    }
-  }
-
-  private final SubmitDryRun submitDryRun;
-  private final Cache<EntryKey, Boolean> cache;
-
-  @Inject
-  MergeabilityCacheImpl(
-      SubmitDryRun submitDryRun, @Named(CACHE_NAME) Cache<EntryKey, Boolean> cache) {
-    this.submitDryRun = submitDryRun;
-    this.cache = cache;
-  }
-
-  @Override
-  public boolean get(
-      ObjectId commit,
-      Ref intoRef,
-      SubmitType submitType,
-      String mergeStrategy,
-      Branch.NameKey dest,
-      Repository repo) {
-    ObjectId into = intoRef != null ? intoRef.getObjectId() : ObjectId.zeroId();
-    EntryKey key = new EntryKey(commit, into, submitType, mergeStrategy);
-    try {
-      return cache.get(
-          key,
-          () -> {
-            if (key.into.equals(ObjectId.zeroId())) {
-              return true; // Assume yes on new branch.
-            }
-            try (CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-              Set<RevCommit> accepted = SubmitDryRun.getAlreadyAccepted(repo, rw);
-              accepted.add(rw.parseCommit(key.into));
-              accepted.addAll(Arrays.asList(rw.parseCommit(key.commit).getParents()));
-              return submitDryRun.run(
-                  key.submitType, repo, rw, dest, key.into, key.commit, accepted);
-            }
-          });
-    } catch (ExecutionException | UncheckedExecutionException e) {
-      log.error(
-          String.format(
-              "Error checking mergeability of %s into %s (%s)",
-              key.commit.name(), key.into.name(), key.submitType.name()),
-          e.getCause());
-      return false;
-    }
-  }
-
-  @Override
-  public Boolean getIfPresent(
-      ObjectId commit, Ref intoRef, SubmitType submitType, String mergeStrategy) {
-    return cache.getIfPresent(new EntryKey(commit, toId(intoRef), submitType, mergeStrategy));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
deleted file mode 100644
index d1c085a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
+++ /dev/null
@@ -1,209 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.common.data.SubmitTypeRecord;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.MergeableInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.BranchOrderSection;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Map;
-import java.util.Objects;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class Mergeable implements RestReadView<RevisionResource> {
-  private static final Logger log = LoggerFactory.getLogger(Mergeable.class);
-
-  @Option(
-    name = "--other-branches",
-    aliases = {"-o"},
-    usage = "test mergeability for other branches too"
-  )
-  private boolean otherBranches;
-
-  private final GitRepositoryManager gitManager;
-  private final ProjectCache projectCache;
-  private final MergeUtil.Factory mergeUtilFactory;
-  private final ChangeData.Factory changeDataFactory;
-  private final Provider<ReviewDb> db;
-  private final ChangeIndexer indexer;
-  private final MergeabilityCache cache;
-  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
-
-  @Inject
-  Mergeable(
-      GitRepositoryManager gitManager,
-      ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
-      ChangeData.Factory changeDataFactory,
-      Provider<ReviewDb> db,
-      ChangeIndexer indexer,
-      MergeabilityCache cache,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
-    this.gitManager = gitManager;
-    this.projectCache = projectCache;
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.changeDataFactory = changeDataFactory;
-    this.db = db;
-    this.indexer = indexer;
-    this.cache = cache;
-    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
-  }
-
-  public void setOtherBranches(boolean otherBranches) {
-    this.otherBranches = otherBranches;
-  }
-
-  @Override
-  public MergeableInfo apply(RevisionResource resource)
-      throws AuthException, ResourceConflictException, BadRequestException, OrmException,
-          IOException {
-    Change change = resource.getChange();
-    PatchSet ps = resource.getPatchSet();
-    MergeableInfo result = new MergeableInfo();
-
-    if (!change.getStatus().isOpen()) {
-      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-    } else if (!ps.getId().equals(change.currentPatchSetId())) {
-      // Only the current revision is mergeable. Others always fail.
-      return result;
-    }
-
-    ChangeData cd = changeDataFactory.create(db.get(), resource.getNotes());
-    result.submitType = getSubmitType(resource.getUser(), cd, ps);
-
-    try (Repository git = gitManager.openRepository(change.getProject())) {
-      ObjectId commit = toId(ps);
-      Ref ref = git.getRefDatabase().exactRef(change.getDest().get());
-      ProjectState projectState = projectCache.get(change.getProject());
-      String strategy = mergeUtilFactory.create(projectState).mergeStrategyName();
-      result.strategy = strategy;
-      result.mergeable = isMergable(git, change, commit, ref, result.submitType, strategy);
-
-      if (otherBranches) {
-        result.mergeableInto = new ArrayList<>();
-        BranchOrderSection branchOrder = projectState.getBranchOrderSection();
-        if (branchOrder != null) {
-          int prefixLen = Constants.R_HEADS.length();
-          String[] names = branchOrder.getMoreStable(ref.getName());
-          Map<String, Ref> refs = git.getRefDatabase().exactRef(names);
-          for (String n : names) {
-            Ref other = refs.get(n);
-            if (other == null) {
-              continue;
-            }
-            if (cache.get(commit, other, SubmitType.CHERRY_PICK, strategy, change.getDest(), git)) {
-              result.mergeableInto.add(other.getName().substring(prefixLen));
-            }
-          }
-        }
-      }
-    }
-    return result;
-  }
-
-  private SubmitType getSubmitType(CurrentUser user, ChangeData cd, PatchSet patchSet)
-      throws OrmException {
-    SubmitTypeRecord rec =
-        submitRuleEvaluatorFactory.create(user, cd).setPatchSet(patchSet).getSubmitType();
-    if (rec.status != SubmitTypeRecord.Status.OK) {
-      throw new OrmException("Submit type rule failed: " + rec);
-    }
-    return rec.type;
-  }
-
-  private boolean isMergable(
-      Repository git,
-      Change change,
-      ObjectId commit,
-      Ref ref,
-      SubmitType submitType,
-      String strategy)
-      throws IOException, OrmException {
-    if (commit == null) {
-      return false;
-    }
-
-    Boolean old = cache.getIfPresent(commit, ref, submitType, strategy);
-    if (old != null) {
-      return old;
-    }
-    return refresh(change, commit, ref, submitType, strategy, git, old);
-  }
-
-  private static ObjectId toId(PatchSet ps) {
-    try {
-      return ObjectId.fromString(ps.getRevision().get());
-    } catch (IllegalArgumentException e) {
-      log.error("Invalid revision on patch set " + ps);
-      return null;
-    }
-  }
-
-  private boolean refresh(
-      final Change change,
-      ObjectId commit,
-      final Ref ref,
-      SubmitType type,
-      String strategy,
-      Repository git,
-      Boolean old)
-      throws OrmException, IOException {
-    final boolean mergeable = cache.get(commit, ref, type, strategy, change.getDest(), git);
-    if (!Objects.equals(mergeable, old)) {
-      invalidateETag(change.getId(), db.get());
-      indexer.index(db.get(), change);
-    }
-    return mergeable;
-  }
-
-  private static void invalidateETag(Change.Id id, ReviewDb db) throws OrmException {
-    // Empty update of Change to bump rowVersion, changing its ETag.
-    // TODO(dborowitz): Include cache info in ETag somehow instead.
-    db = ReviewDbUtil.unwrapDb(db);
-    Change c = db.changes().get(id);
-    if (c != null) {
-      db.changes().update(Collections.singleton(c));
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
deleted file mode 100644
index b4f71af..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
+++ /dev/null
@@ -1,183 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.server.change.ChangeEditResource.CHANGE_EDIT_KIND;
-import static com.google.gerrit.server.change.ChangeResource.CHANGE_KIND;
-import static com.google.gerrit.server.change.CommentResource.COMMENT_KIND;
-import static com.google.gerrit.server.change.DraftCommentResource.DRAFT_COMMENT_KIND;
-import static com.google.gerrit.server.change.FileResource.FILE_KIND;
-import static com.google.gerrit.server.change.FixResource.FIX_KIND;
-import static com.google.gerrit.server.change.ReviewerResource.REVIEWER_KIND;
-import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
-import static com.google.gerrit.server.change.RobotCommentResource.ROBOT_COMMENT_KIND;
-import static com.google.gerrit.server.change.VoteResource.VOTE_KIND;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.change.Reviewed.DeleteReviewed;
-import com.google.gerrit.server.change.Reviewed.PutReviewed;
-
-public class Module extends RestApiModule {
-  @Override
-  protected void configure() {
-    bind(ChangesCollection.class);
-    bind(Revisions.class);
-    bind(Reviewers.class);
-    bind(RevisionReviewers.class);
-    bind(DraftComments.class);
-    bind(Comments.class);
-    bind(RobotComments.class);
-    bind(Fixes.class);
-    bind(Files.class);
-    bind(Votes.class);
-
-    DynamicMap.mapOf(binder(), CHANGE_KIND);
-    DynamicMap.mapOf(binder(), COMMENT_KIND);
-    DynamicMap.mapOf(binder(), ROBOT_COMMENT_KIND);
-    DynamicMap.mapOf(binder(), FIX_KIND);
-    DynamicMap.mapOf(binder(), DRAFT_COMMENT_KIND);
-    DynamicMap.mapOf(binder(), FILE_KIND);
-    DynamicMap.mapOf(binder(), REVIEWER_KIND);
-    DynamicMap.mapOf(binder(), REVISION_KIND);
-    DynamicMap.mapOf(binder(), CHANGE_EDIT_KIND);
-    DynamicMap.mapOf(binder(), VOTE_KIND);
-
-    get(CHANGE_KIND).to(GetChange.class);
-    post(CHANGE_KIND, "merge").to(CreateMergePatchSet.class);
-    get(CHANGE_KIND, "detail").to(GetDetail.class);
-    get(CHANGE_KIND, "topic").to(GetTopic.class);
-    get(CHANGE_KIND, "in").to(ChangeIncludedIn.class);
-    get(CHANGE_KIND, "assignee").to(GetAssignee.class);
-    get(CHANGE_KIND, "past_assignees").to(GetPastAssignees.class);
-    put(CHANGE_KIND, "assignee").to(PutAssignee.class);
-    delete(CHANGE_KIND, "assignee").to(DeleteAssignee.class);
-    get(CHANGE_KIND, "hashtags").to(GetHashtags.class);
-    get(CHANGE_KIND, "comments").to(ListChangeComments.class);
-    get(CHANGE_KIND, "robotcomments").to(ListChangeRobotComments.class);
-    get(CHANGE_KIND, "drafts").to(ListChangeDrafts.class);
-    get(CHANGE_KIND, "check").to(Check.class);
-    get(CHANGE_KIND, "pure_revert").to(GetPureRevert.class);
-    post(CHANGE_KIND, "check").to(Check.class);
-    put(CHANGE_KIND, "topic").to(PutTopic.class);
-    delete(CHANGE_KIND, "topic").to(PutTopic.class);
-    delete(CHANGE_KIND).to(DeleteChange.class);
-    post(CHANGE_KIND, "abandon").to(Abandon.class);
-    post(CHANGE_KIND, "hashtags").to(PostHashtags.class);
-    post(CHANGE_KIND, "restore").to(Restore.class);
-    post(CHANGE_KIND, "revert").to(Revert.class);
-    post(CHANGE_KIND, "submit").to(Submit.CurrentRevision.class);
-    get(CHANGE_KIND, "submitted_together").to(SubmittedTogether.class);
-    post(CHANGE_KIND, "rebase").to(Rebase.CurrentRevision.class);
-    post(CHANGE_KIND, "index").to(Index.class);
-    post(CHANGE_KIND, "rebuild.notedb").to(Rebuild.class);
-    post(CHANGE_KIND, "move").to(Move.class);
-    post(CHANGE_KIND, "private").to(PostPrivate.class);
-    post(CHANGE_KIND, "private.delete").to(DeletePrivateByPost.class);
-    delete(CHANGE_KIND, "private").to(DeletePrivate.class);
-    put(CHANGE_KIND, "ignore").to(Ignore.class);
-    put(CHANGE_KIND, "unignore").to(Unignore.class);
-    put(CHANGE_KIND, "reviewed").to(MarkAsReviewed.class);
-    put(CHANGE_KIND, "unreviewed").to(MarkAsUnreviewed.class);
-    post(CHANGE_KIND, "wip").to(SetWorkInProgress.class);
-    post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
-    put(CHANGE_KIND, "message").to(PutMessage.class);
-
-    post(CHANGE_KIND, "reviewers").to(PostReviewers.class);
-    get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
-    child(CHANGE_KIND, "reviewers").to(Reviewers.class);
-    get(REVIEWER_KIND).to(GetReviewer.class);
-    delete(REVIEWER_KIND).to(DeleteReviewer.class);
-    post(REVIEWER_KIND, "delete").to(DeleteReviewer.class);
-    child(REVIEWER_KIND, "votes").to(Votes.class);
-    delete(VOTE_KIND).to(DeleteVote.class);
-    post(VOTE_KIND, "delete").to(DeleteVote.class);
-
-    child(CHANGE_KIND, "revisions").to(Revisions.class);
-    get(REVISION_KIND, "actions").to(GetRevisionActions.class);
-    post(REVISION_KIND, "cherrypick").to(CherryPick.class);
-    get(REVISION_KIND, "commit").to(GetCommit.class);
-    get(REVISION_KIND, "mergeable").to(Mergeable.class);
-    get(REVISION_KIND, "related").to(GetRelated.class);
-    get(REVISION_KIND, "review").to(GetReview.class);
-    post(REVISION_KIND, "review").to(PostReview.class);
-    get(REVISION_KIND, "preview_submit").to(PreviewSubmit.class);
-    post(REVISION_KIND, "submit").to(Submit.class);
-    post(REVISION_KIND, "rebase").to(Rebase.class);
-    put(REVISION_KIND, "description").to(PutDescription.class);
-    get(REVISION_KIND, "description").to(GetDescription.class);
-    get(REVISION_KIND, "patch").to(GetPatch.class);
-    get(REVISION_KIND, "submit_type").to(TestSubmitType.Get.class);
-    post(REVISION_KIND, "test.submit_rule").to(TestSubmitRule.class);
-    post(REVISION_KIND, "test.submit_type").to(TestSubmitType.class);
-    get(REVISION_KIND, "archive").to(GetArchive.class);
-    get(REVISION_KIND, "mergelist").to(GetMergeList.class);
-
-    child(REVISION_KIND, "reviewers").to(RevisionReviewers.class);
-
-    child(REVISION_KIND, "drafts").to(DraftComments.class);
-    put(REVISION_KIND, "drafts").to(CreateDraftComment.class);
-    get(DRAFT_COMMENT_KIND).to(GetDraftComment.class);
-    put(DRAFT_COMMENT_KIND).to(PutDraftComment.class);
-    delete(DRAFT_COMMENT_KIND).to(DeleteDraftComment.class);
-
-    child(REVISION_KIND, "comments").to(Comments.class);
-    get(COMMENT_KIND).to(GetComment.class);
-    delete(COMMENT_KIND).to(DeleteComment.class);
-    post(COMMENT_KIND, "delete").to(DeleteComment.class);
-
-    child(REVISION_KIND, "robotcomments").to(RobotComments.class);
-    get(ROBOT_COMMENT_KIND).to(GetRobotComment.class);
-    child(REVISION_KIND, "fixes").to(Fixes.class);
-    post(FIX_KIND, "apply").to(ApplyFix.class);
-
-    child(REVISION_KIND, "files").to(Files.class);
-    put(FILE_KIND, "reviewed").to(PutReviewed.class);
-    delete(FILE_KIND, "reviewed").to(DeleteReviewed.class);
-    get(FILE_KIND, "content").to(GetContent.class);
-    get(FILE_KIND, "download").to(DownloadContent.class);
-    get(FILE_KIND, "diff").to(GetDiff.class);
-    get(FILE_KIND, "blame").to(GetBlame.class);
-
-    child(CHANGE_KIND, "edit").to(ChangeEdits.class);
-    delete(CHANGE_KIND, "edit").to(DeleteChangeEdit.class);
-    child(CHANGE_KIND, "edit:publish").to(PublishChangeEdit.class);
-    child(CHANGE_KIND, "edit:rebase").to(RebaseChangeEdit.class);
-    put(CHANGE_KIND, "edit:message").to(ChangeEdits.EditMessage.class);
-    get(CHANGE_KIND, "edit:message").to(ChangeEdits.GetMessage.class);
-    put(CHANGE_EDIT_KIND, "/").to(ChangeEdits.Put.class);
-    delete(CHANGE_EDIT_KIND).to(ChangeEdits.DeleteContent.class);
-    get(CHANGE_EDIT_KIND, "/").to(ChangeEdits.Get.class);
-    get(CHANGE_EDIT_KIND, "meta").to(ChangeEdits.GetMeta.class);
-
-    factory(AccountLoader.Factory.class);
-    factory(ChangeEdits.Create.Factory.class);
-    factory(ChangeEdits.DeleteFile.Factory.class);
-    factory(ChangeInserter.Factory.class);
-    factory(ChangeResource.Factory.class);
-    factory(DeleteReviewerByEmailOp.Factory.class);
-    factory(DeleteReviewerOp.Factory.class);
-    factory(EmailReviewComments.Factory.class);
-    factory(PatchSetInserter.Factory.class);
-    factory(PostReviewersOp.Factory.class);
-    factory(RebaseChangeOp.Factory.class);
-    factory(ReviewerResource.Factory.class);
-    factory(SetAssigneeOp.Factory.class);
-    factory(SetHashtagsOp.Factory.class);
-    factory(SetPrivateOp.Factory.class);
-    factory(WorkInProgressOp.Factory.class);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
deleted file mode 100644
index 2f3855c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
+++ /dev/null
@@ -1,228 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-import static com.google.gerrit.server.permissions.ChangePermission.ABANDON;
-import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
-import static com.google.gerrit.server.query.change.ChangeData.asChanges;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.MoveInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.git.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.query.change.InternalChangeQuery;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-@Singleton
-public class Move extends RetryingRestModifyView<ChangeResource, MoveInput, ChangeInfo>
-    implements UiAction<ChangeResource> {
-  private final PermissionBackend permissionBackend;
-  private final Provider<ReviewDb> dbProvider;
-  private final ChangeJson.Factory json;
-  private final GitRepositoryManager repoManager;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final ChangeMessagesUtil cmUtil;
-  private final PatchSetUtil psUtil;
-
-  @Inject
-  Move(
-      PermissionBackend permissionBackend,
-      Provider<ReviewDb> dbProvider,
-      ChangeJson.Factory json,
-      GitRepositoryManager repoManager,
-      Provider<InternalChangeQuery> queryProvider,
-      ChangeMessagesUtil cmUtil,
-      RetryHelper retryHelper,
-      PatchSetUtil psUtil) {
-    super(retryHelper);
-    this.permissionBackend = permissionBackend;
-    this.dbProvider = dbProvider;
-    this.json = json;
-    this.repoManager = repoManager;
-    this.queryProvider = queryProvider;
-    this.cmUtil = cmUtil;
-    this.psUtil = psUtil;
-  }
-
-  @Override
-  protected ChangeInfo applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, MoveInput input)
-      throws RestApiException, OrmException, UpdateException, PermissionBackendException {
-    Change change = rsrc.getChange();
-    Project.NameKey project = rsrc.getProject();
-    IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
-    input.destinationBranch = RefNames.fullName(input.destinationBranch);
-
-    if (change.getStatus().isClosed()) {
-      throw new ResourceConflictException("Change is " + ChangeUtil.status(change));
-    }
-
-    Branch.NameKey newDest = new Branch.NameKey(project, input.destinationBranch);
-    if (change.getDest().equals(newDest)) {
-      throw new ResourceConflictException("Change is already destined for the specified branch");
-    }
-
-    // Move requires abandoning this change, and creating a new change.
-    try {
-      rsrc.permissions().database(dbProvider).check(ABANDON);
-      permissionBackend.user(caller).database(dbProvider).ref(newDest).check(CREATE_CHANGE);
-    } catch (AuthException denied) {
-      throw new AuthException("move not permitted", denied);
-    }
-
-    try (BatchUpdate u =
-        updateFactory.create(dbProvider.get(), project, caller, TimeUtil.nowTs())) {
-      u.addOp(change.getId(), new Op(input));
-      u.execute();
-    }
-    return json.noOptions().format(project, rsrc.getId());
-  }
-
-  private class Op implements BatchUpdateOp {
-    private final MoveInput input;
-
-    private Change change;
-    private Branch.NameKey newDestKey;
-
-    Op(MoveInput input) {
-      this.input = input;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws OrmException, ResourceConflictException, RepositoryNotFoundException, IOException {
-      change = ctx.getChange();
-      if (change.getStatus() != Status.NEW) {
-        throw new ResourceConflictException("Change is " + ChangeUtil.status(change));
-      }
-
-      Project.NameKey projectKey = change.getProject();
-      newDestKey = new Branch.NameKey(projectKey, input.destinationBranch);
-      Branch.NameKey changePrevDest = change.getDest();
-      if (changePrevDest.equals(newDestKey)) {
-        throw new ResourceConflictException("Change is already destined for the specified branch");
-      }
-
-      final PatchSet.Id patchSetId = change.currentPatchSetId();
-      try (Repository repo = repoManager.openRepository(projectKey);
-          RevWalk revWalk = new RevWalk(repo)) {
-        RevCommit currPatchsetRevCommit =
-            revWalk.parseCommit(
-                ObjectId.fromString(
-                    psUtil.current(ctx.getDb(), ctx.getNotes()).getRevision().get()));
-        if (currPatchsetRevCommit.getParentCount() > 1) {
-          throw new ResourceConflictException("Merge commit cannot be moved");
-        }
-
-        ObjectId refId = repo.resolve(input.destinationBranch);
-        // Check if destination ref exists in project repo
-        if (refId == null) {
-          throw new ResourceConflictException(
-              "Destination " + input.destinationBranch + " not found in the project");
-        }
-        RevCommit refCommit = revWalk.parseCommit(refId);
-        if (revWalk.isMergedInto(currPatchsetRevCommit, refCommit)) {
-          throw new ResourceConflictException(
-              "Current patchset revision is reachable from tip of " + input.destinationBranch);
-        }
-      }
-
-      Change.Key changeKey = change.getKey();
-      if (!asChanges(queryProvider.get().byBranchKey(newDestKey, changeKey)).isEmpty()) {
-        throw new ResourceConflictException(
-            "Destination "
-                + newDestKey.getShortName()
-                + " has a different change with same change key "
-                + changeKey);
-      }
-
-      if (!change.currentPatchSetId().equals(patchSetId)) {
-        throw new ResourceConflictException("Patch set is not current");
-      }
-
-      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-      update.setBranch(newDestKey.get());
-      change.setDest(newDestKey);
-
-      StringBuilder msgBuf = new StringBuilder();
-      msgBuf.append("Change destination moved from ");
-      msgBuf.append(changePrevDest.getShortName());
-      msgBuf.append(" to ");
-      msgBuf.append(newDestKey.getShortName());
-      if (!Strings.isNullOrEmpty(input.message)) {
-        msgBuf.append("\n\n");
-        msgBuf.append(input.message);
-      }
-      ChangeMessage cmsg =
-          ChangeMessagesUtil.newMessage(ctx, msgBuf.toString(), ChangeMessagesUtil.TAG_MOVE);
-      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
-
-      return true;
-    }
-  }
-
-  @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
-    Change change = rsrc.getChange();
-    return new UiAction.Description()
-        .setLabel("Move Change")
-        .setTitle("Move change to a different branch")
-        .setVisible(
-            and(
-                change.getStatus().isOpen(),
-                and(
-                    permissionBackend
-                        .user(rsrc.getUser())
-                        .ref(change.getDest())
-                        .testCond(CREATE_CHANGE),
-                    rsrc.permissions().database(dbProvider).testCond(ABANDON))));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
deleted file mode 100644
index d298730..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ /dev/null
@@ -1,353 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-
-import com.google.common.base.Preconditions;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalCopier;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.events.CommitReceivedEvent;
-import com.google.gerrit.server.extensions.events.RevisionCreated;
-import com.google.gerrit.server.git.validators.CommitValidationException;
-import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.ssh.NoSshInfo;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.Collections;
-import java.util.List;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class PatchSetInserter implements BatchUpdateOp {
-  private static final Logger log = LoggerFactory.getLogger(PatchSetInserter.class);
-
-  public interface Factory {
-    PatchSetInserter create(ChangeNotes notes, PatchSet.Id psId, ObjectId commitId);
-  }
-
-  // Injected fields.
-  private final PermissionBackend permissionBackend;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final CommitValidators.Factory commitValidatorsFactory;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
-  private final ProjectCache projectCache;
-  private final RevisionCreated revisionCreated;
-  private final ApprovalsUtil approvalsUtil;
-  private final ApprovalCopier approvalCopier;
-  private final ChangeMessagesUtil cmUtil;
-  private final PatchSetUtil psUtil;
-
-  // Assisted-injected fields.
-  private final PatchSet.Id psId;
-  private final ObjectId commitId;
-  // Read prior to running the batch update, so must only be used during
-  // updateRepo; updateChange and later must use the notes from the
-  // ChangeContext.
-  private final ChangeNotes origNotes;
-
-  // Fields exposed as setters.
-  private String message;
-  private String description;
-  private boolean validate = true;
-  private boolean checkAddPatchSetPermission = true;
-  private List<String> groups = Collections.emptyList();
-  private boolean fireRevisionCreated = true;
-  private NotifyHandling notify = NotifyHandling.ALL;
-  private ListMultimap<RecipientType, Account.Id> accountsToNotify = ImmutableListMultimap.of();
-  private boolean allowClosed;
-  private boolean copyApprovals = true;
-
-  // Fields set during some phase of BatchUpdate.Op.
-  private Change change;
-  private PatchSet patchSet;
-  private PatchSetInfo patchSetInfo;
-  private ChangeMessage changeMessage;
-  private ReviewerSet oldReviewers;
-
-  @Inject
-  public PatchSetInserter(
-      PermissionBackend permissionBackend,
-      ApprovalsUtil approvalsUtil,
-      ApprovalCopier approvalCopier,
-      ChangeMessagesUtil cmUtil,
-      PatchSetInfoFactory patchSetInfoFactory,
-      CommitValidators.Factory commitValidatorsFactory,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
-      PatchSetUtil psUtil,
-      RevisionCreated revisionCreated,
-      ProjectCache projectCache,
-      @Assisted ChangeNotes notes,
-      @Assisted PatchSet.Id psId,
-      @Assisted ObjectId commitId) {
-    this.permissionBackend = permissionBackend;
-    this.approvalsUtil = approvalsUtil;
-    this.approvalCopier = approvalCopier;
-    this.cmUtil = cmUtil;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.commitValidatorsFactory = commitValidatorsFactory;
-    this.replacePatchSetFactory = replacePatchSetFactory;
-    this.psUtil = psUtil;
-    this.revisionCreated = revisionCreated;
-    this.projectCache = projectCache;
-
-    this.origNotes = notes;
-    this.psId = psId;
-    this.commitId = commitId.copy();
-  }
-
-  public PatchSet.Id getPatchSetId() {
-    return psId;
-  }
-
-  public PatchSetInserter setMessage(String message) {
-    this.message = message;
-    return this;
-  }
-
-  public PatchSetInserter setDescription(String description) {
-    this.description = description;
-    return this;
-  }
-
-  public PatchSetInserter setValidate(boolean validate) {
-    this.validate = validate;
-    return this;
-  }
-
-  public PatchSetInserter setCheckAddPatchSetPermission(boolean checkAddPatchSetPermission) {
-    this.checkAddPatchSetPermission = checkAddPatchSetPermission;
-    return this;
-  }
-
-  public PatchSetInserter setGroups(List<String> groups) {
-    checkNotNull(groups, "groups may not be null");
-    this.groups = groups;
-    return this;
-  }
-
-  public PatchSetInserter setFireRevisionCreated(boolean fireRevisionCreated) {
-    this.fireRevisionCreated = fireRevisionCreated;
-    return this;
-  }
-
-  public PatchSetInserter setNotify(NotifyHandling notify) {
-    this.notify = Preconditions.checkNotNull(notify);
-    return this;
-  }
-
-  public PatchSetInserter setAccountsToNotify(
-      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    this.accountsToNotify = checkNotNull(accountsToNotify);
-    return this;
-  }
-
-  public PatchSetInserter setAllowClosed(boolean allowClosed) {
-    this.allowClosed = allowClosed;
-    return this;
-  }
-
-  public PatchSetInserter setCopyApprovals(boolean copyApprovals) {
-    this.copyApprovals = copyApprovals;
-    return this;
-  }
-
-  public Change getChange() {
-    checkState(change != null, "getChange() only valid after executing update");
-    return change;
-  }
-
-  public PatchSet getPatchSet() {
-    checkState(patchSet != null, "getPatchSet() only valid after executing update");
-    return patchSet;
-  }
-
-  @Override
-  public void updateRepo(RepoContext ctx)
-      throws AuthException, ResourceConflictException, IOException, OrmException,
-          PermissionBackendException {
-    validate(ctx);
-    ctx.addRefUpdate(ObjectId.zeroId(), commitId, getPatchSetId().toRefName());
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx)
-      throws ResourceConflictException, OrmException, IOException {
-    ReviewDb db = ctx.getDb();
-
-    change = ctx.getChange();
-    ChangeUpdate update = ctx.getUpdate(psId);
-    update.setSubjectForCommit("Create patch set " + psId.get());
-
-    if (!change.getStatus().isOpen() && !allowClosed) {
-      throw new ResourceConflictException(
-          String.format(
-              "Cannot create new patch set of change %s because it is %s",
-              change.getId(), ChangeUtil.status(change)));
-    }
-
-    List<String> newGroups = groups;
-    if (newGroups.isEmpty()) {
-      PatchSet prevPs = psUtil.current(db, ctx.getNotes());
-      if (prevPs != null) {
-        newGroups = prevPs.getGroups();
-      }
-    }
-    patchSet =
-        psUtil.insert(
-            db,
-            ctx.getRevWalk(),
-            ctx.getUpdate(psId),
-            psId,
-            commitId,
-            newGroups,
-            null,
-            description);
-
-    if (notify != NotifyHandling.NONE) {
-      oldReviewers = approvalsUtil.getReviewers(db, ctx.getNotes());
-    }
-
-    if (message != null) {
-      changeMessage =
-          ChangeMessagesUtil.newMessage(
-              patchSet.getId(),
-              ctx.getUser(),
-              ctx.getWhen(),
-              message,
-              ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
-      changeMessage.setMessage(message);
-    }
-
-    patchSetInfo =
-        patchSetInfoFactory.get(ctx.getRevWalk(), ctx.getRevWalk().parseCommit(commitId), psId);
-    if (!allowClosed) {
-      change.setStatus(Change.Status.NEW);
-    }
-    change.setCurrentPatchSet(patchSetInfo);
-    if (copyApprovals) {
-      approvalCopier.copyInReviewDb(
-          db,
-          ctx.getNotes(),
-          ctx.getUser(),
-          patchSet,
-          ctx.getRevWalk(),
-          ctx.getRepoView().getConfig());
-    }
-    if (changeMessage != null) {
-      cmUtil.addChangeMessage(db, update, changeMessage);
-    }
-    return true;
-  }
-
-  @Override
-  public void postUpdate(Context ctx) throws OrmException {
-    if (notify != NotifyHandling.NONE || !accountsToNotify.isEmpty()) {
-      try {
-        ReplacePatchSetSender cm = replacePatchSetFactory.create(ctx.getProject(), change.getId());
-        cm.setFrom(ctx.getAccountId());
-        cm.setPatchSet(patchSet, patchSetInfo);
-        cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
-        cm.addReviewers(oldReviewers.byState(REVIEWER));
-        cm.addExtraCC(oldReviewers.byState(CC));
-        cm.setNotify(notify);
-        cm.setAccountsToNotify(accountsToNotify);
-        cm.send();
-      } catch (Exception err) {
-        log.error("Cannot send email for new patch set on change " + change.getId(), err);
-      }
-    }
-
-    if (fireRevisionCreated) {
-      revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
-    }
-  }
-
-  private void validate(RepoContext ctx)
-      throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
-    if (checkAddPatchSetPermission) {
-      permissionBackend
-          .user(ctx.getUser())
-          .database(ctx.getDb())
-          .change(origNotes)
-          .check(ChangePermission.ADD_PATCH_SET);
-    }
-    if (!validate) {
-      return;
-    }
-
-    PermissionBackend.ForRef perm =
-        permissionBackend.user(ctx.getUser()).ref(origNotes.getChange().getDest());
-
-    String refName = getPatchSetId().toRefName();
-    try (CommitReceivedEvent event =
-        new CommitReceivedEvent(
-            new ReceiveCommand(
-                ObjectId.zeroId(),
-                commitId,
-                refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
-            projectCache.checkedGet(origNotes.getProjectName()).getProject(),
-            origNotes.getChange().getDest().get(),
-            ctx.getRevWalk().getObjectReader(),
-            commitId,
-            ctx.getIdentifiedUser())) {
-      commitValidatorsFactory
-          .forGerritCommits(
-              perm,
-              origNotes.getChange().getDest(),
-              ctx.getIdentifiedUser(),
-              new NoSshInfo(),
-              ctx.getRevWalk())
-          .validate(event);
-    } catch (CommitValidationException e) {
-      throw new ResourceConflictException(e.getFullMessage());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
deleted file mode 100644
index 1ff0fdd..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.collect.ImmutableSortedSet;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.HashtagsInput;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class PostHashtags
-    extends RetryingRestModifyView<
-        ChangeResource, HashtagsInput, Response<ImmutableSortedSet<String>>>
-    implements UiAction<ChangeResource> {
-  private final Provider<ReviewDb> db;
-  private final SetHashtagsOp.Factory hashtagsFactory;
-
-  @Inject
-  PostHashtags(
-      Provider<ReviewDb> db, RetryHelper retryHelper, SetHashtagsOp.Factory hashtagsFactory) {
-    super(retryHelper);
-    this.db = db;
-    this.hashtagsFactory = hashtagsFactory;
-  }
-
-  @Override
-  protected Response<ImmutableSortedSet<String>> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource req, HashtagsInput input)
-      throws RestApiException, UpdateException, PermissionBackendException {
-    req.permissions().check(ChangePermission.EDIT_HASHTAGS);
-
-    try (BatchUpdate bu =
-        updateFactory.create(
-            db.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
-      SetHashtagsOp op = hashtagsFactory.create(input);
-      bu.addOp(req.getId(), op);
-      bu.execute();
-      return Response.<ImmutableSortedSet<String>>ok(op.getUpdatedHashtags());
-    }
-  }
-
-  @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Edit Hashtags")
-        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_HASHTAGS));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostPrivate.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostPrivate.java
deleted file mode 100644
index 307d6df..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostPrivate.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.conditions.BooleanCondition;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class PostPrivate
-    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, Response<String>>
-    implements UiAction<ChangeResource> {
-  private final ChangeMessagesUtil cmUtil;
-  private final Provider<ReviewDb> dbProvider;
-  private final PermissionBackend permissionBackend;
-  private final SetPrivateOp.Factory setPrivateOpFactory;
-
-  @Inject
-  PostPrivate(
-      Provider<ReviewDb> dbProvider,
-      RetryHelper retryHelper,
-      ChangeMessagesUtil cmUtil,
-      PermissionBackend permissionBackend,
-      SetPrivateOp.Factory setPrivateOpFactory) {
-    super(retryHelper);
-    this.dbProvider = dbProvider;
-    this.cmUtil = cmUtil;
-    this.permissionBackend = permissionBackend;
-    this.setPrivateOpFactory = setPrivateOpFactory;
-  }
-
-  @Override
-  public Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, SetPrivateOp.Input input)
-      throws RestApiException, UpdateException {
-    if (!canSetPrivate(rsrc).value()) {
-      throw new AuthException("not allowed to mark private");
-    }
-
-    if (rsrc.getChange().isPrivate()) {
-      return Response.ok("");
-    }
-
-    SetPrivateOp op = setPrivateOpFactory.create(cmUtil, true, input);
-    try (BatchUpdate u =
-        updateFactory.create(
-            dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      u.addOp(rsrc.getId(), op).execute();
-    }
-
-    return Response.created("");
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    Change change = rsrc.getChange();
-    return new UiAction.Description()
-        .setLabel("Mark private")
-        .setTitle("Mark change as private")
-        .setVisible(and(!change.isPrivate(), canSetPrivate(rsrc)));
-  }
-
-  private BooleanCondition canSetPrivate(ChangeResource rsrc) {
-    PermissionBackend.WithUser user = permissionBackend.user(rsrc.getUser());
-    return or(
-        rsrc.isUserOwner() && rsrc.getChange().getStatus() != Change.Status.MERGED,
-        user.testCond(GlobalPermission.ADMINISTRATE_SERVER));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
deleted file mode 100644
index 58634a5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
+++ /dev/null
@@ -1,1376 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.groupingBy;
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
-import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Ordering;
-import com.google.common.hash.HashCode;
-import com.google.common.hash.Hashing;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
-import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
-import com.google.gerrit.extensions.api.changes.ReviewResult;
-import com.google.gerrit.extensions.api.changes.ReviewerInfo;
-import com.google.gerrit.extensions.client.Comment.Range;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.FixReplacementInfo;
-import com.google.gerrit.extensions.common.FixSuggestionInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.FixReplacement;
-import com.google.gerrit.reviewdb.client.FixSuggestion;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.RobotComment;
-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.CommentsUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.OutputFormat;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.account.AccountsCollection;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.CommentAdded;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.patch.DiffSummary;
-import com.google.gerrit.server.patch.DiffSummaryKey;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListKey;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.LabelPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.LabelVote;
-import com.google.gson.Gson;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.OptionalInt;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-
-@Singleton
-public class PostReview
-    extends RetryingRestModifyView<RevisionResource, ReviewInput, Response<ReviewResult>> {
-  public static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
-  public static final String ERROR_ONLY_OWNER_CAN_MODIFY_WORK_IN_PROGRESS =
-      "only change owner can specify work_in_progress or ready";
-  public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
-      "work_in_progress and ready are mutually exclusive";
-
-  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
-
-  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
-  private static final int DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES = 1024 * 1024;
-
-  private final Provider<ReviewDb> db;
-  private final ChangeResource.Factory changeResourceFactory;
-  private final ChangeData.Factory changeDataFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final ChangeMessagesUtil cmUtil;
-  private final CommentsUtil commentsUtil;
-  private final PatchSetUtil psUtil;
-  private final PatchListCache patchListCache;
-  private final AccountsCollection accounts;
-  private final EmailReviewComments.Factory email;
-  private final CommentAdded commentAdded;
-  private final PostReviewers postReviewers;
-  private final NotesMigration migration;
-  private final NotifyUtil notifyUtil;
-  private final Config gerritConfig;
-  private final WorkInProgressOp.Factory workInProgressOpFactory;
-  private final ProjectCache projectCache;
-
-  @Inject
-  PostReview(
-      Provider<ReviewDb> db,
-      RetryHelper retryHelper,
-      ChangeResource.Factory changeResourceFactory,
-      ChangeData.Factory changeDataFactory,
-      ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil cmUtil,
-      CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache,
-      AccountsCollection accounts,
-      EmailReviewComments.Factory email,
-      CommentAdded commentAdded,
-      PostReviewers postReviewers,
-      NotesMigration migration,
-      NotifyUtil notifyUtil,
-      @GerritServerConfig Config gerritConfig,
-      WorkInProgressOp.Factory workInProgressOpFactory,
-      ProjectCache projectCache) {
-    super(retryHelper);
-    this.db = db;
-    this.changeResourceFactory = changeResourceFactory;
-    this.changeDataFactory = changeDataFactory;
-    this.commentsUtil = commentsUtil;
-    this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
-    this.approvalsUtil = approvalsUtil;
-    this.cmUtil = cmUtil;
-    this.accounts = accounts;
-    this.email = email;
-    this.commentAdded = commentAdded;
-    this.postReviewers = postReviewers;
-    this.migration = migration;
-    this.notifyUtil = notifyUtil;
-    this.gerritConfig = gerritConfig;
-    this.workInProgressOpFactory = workInProgressOpFactory;
-    this.projectCache = projectCache;
-  }
-
-  @Override
-  protected Response<ReviewResult> applyImpl(
-      BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input)
-      throws RestApiException, UpdateException, OrmException, IOException,
-          PermissionBackendException, ConfigInvalidException, PatchListNotAvailableException {
-    return apply(updateFactory, revision, input, TimeUtil.nowTs());
-  }
-
-  public Response<ReviewResult> apply(
-      BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input, Timestamp ts)
-      throws RestApiException, UpdateException, OrmException, IOException,
-          PermissionBackendException, ConfigInvalidException, PatchListNotAvailableException {
-    // Respect timestamp, but truncate at change created-on time.
-    ts = Ordering.natural().max(ts, revision.getChange().getCreatedOn());
-    if (revision.getEdit().isPresent()) {
-      throw new ResourceConflictException("cannot post review on edit");
-    }
-    ProjectState projectState = projectCache.checkedGet(revision.getProject());
-    LabelTypes labelTypes = projectState.getLabelTypes(revision.getNotes(), revision.getUser());
-    if (input.onBehalfOf != null) {
-      revision = onBehalfOf(revision, labelTypes, input);
-    } else if (input.drafts == null) {
-      input.drafts = DraftHandling.DELETE;
-    }
-    if (input.labels != null) {
-      checkLabels(revision, labelTypes, input.strictLabels, input.labels);
-    }
-    if (input.comments != null) {
-      cleanUpComments(input.comments);
-      checkComments(revision, input.comments);
-    }
-    if (input.robotComments != null) {
-      if (!migration.readChanges()) {
-        throw new MethodNotAllowedException("robot comments not supported");
-      }
-      checkRobotComments(revision, input.robotComments);
-    }
-
-    NotifyHandling reviewerNotify = input.notify;
-    if (input.notify == null) {
-      input.notify = defaultNotify(revision.getChange(), input);
-    }
-
-    ListMultimap<RecipientType, Account.Id> accountsToNotify =
-        notifyUtil.resolveAccounts(input.notifyDetails);
-
-    Map<String, AddReviewerResult> reviewerJsonResults = null;
-    List<PostReviewers.Addition> reviewerResults = Lists.newArrayList();
-    boolean hasError = false;
-    boolean confirm = false;
-    if (input.reviewers != null) {
-      reviewerJsonResults = Maps.newHashMap();
-      for (AddReviewerInput reviewerInput : input.reviewers) {
-        // Prevent notifications because setting reviewers is batched.
-        reviewerInput.notify = NotifyHandling.NONE;
-
-        PostReviewers.Addition result =
-            postReviewers.prepareApplication(revision.getChangeResource(), reviewerInput, true);
-        reviewerJsonResults.put(reviewerInput.reviewer, result.result);
-        if (result.result.error != null) {
-          hasError = true;
-          continue;
-        }
-        if (result.result.confirm != null) {
-          confirm = true;
-          continue;
-        }
-        reviewerResults.add(result);
-      }
-    }
-
-    ReviewResult output = new ReviewResult();
-    output.reviewers = reviewerJsonResults;
-    if (hasError || confirm) {
-      output.error = ERROR_ADDING_REVIEWER;
-      return Response.withStatusCode(SC_BAD_REQUEST, output);
-    }
-    output.labels = input.labels;
-
-    try (BatchUpdate bu =
-        updateFactory.create(db.get(), revision.getChange().getProject(), revision.getUser(), ts)) {
-      Account.Id id = revision.getUser().getAccountId();
-      boolean ccOrReviewer = false;
-      if (input.labels != null && !input.labels.isEmpty()) {
-        ccOrReviewer = input.labels.values().stream().filter(v -> v != 0).findFirst().isPresent();
-      }
-
-      if (!ccOrReviewer) {
-        // Check if user was already CCed or reviewing prior to this review.
-        ReviewerSet currentReviewers =
-            approvalsUtil.getReviewers(db.get(), revision.getChangeResource().getNotes());
-        ccOrReviewer = currentReviewers.all().contains(id);
-      }
-
-      // Apply reviewer changes first. Revision emails should be sent to the
-      // updated set of reviewers. Also keep track of whether the user added
-      // themselves as a reviewer or to the CC list.
-      for (PostReviewers.Addition reviewerResult : reviewerResults) {
-        bu.addOp(revision.getChange().getId(), reviewerResult.op);
-        if (!ccOrReviewer && reviewerResult.result.reviewers != null) {
-          for (ReviewerInfo reviewerInfo : reviewerResult.result.reviewers) {
-            if (Objects.equals(id.get(), reviewerInfo._accountId)) {
-              ccOrReviewer = true;
-              break;
-            }
-          }
-        }
-        if (!ccOrReviewer && reviewerResult.result.ccs != null) {
-          for (AccountInfo accountInfo : reviewerResult.result.ccs) {
-            if (Objects.equals(id.get(), accountInfo._accountId)) {
-              ccOrReviewer = true;
-              break;
-            }
-          }
-        }
-      }
-
-      if (!ccOrReviewer) {
-        // User posting this review isn't currently in the reviewer or CC list,
-        // isn't being explicitly added, and isn't voting on any label.
-        // Automatically CC them on this change so they receive replies.
-        PostReviewers.Addition selfAddition =
-            postReviewers.ccCurrentUser(revision.getUser(), revision);
-        bu.addOp(revision.getChange().getId(), selfAddition.op);
-      }
-
-      // Add WorkInProgressOp if requested.
-      if (input.ready || input.workInProgress) {
-        if (input.ready && input.workInProgress) {
-          output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
-          return Response.withStatusCode(SC_BAD_REQUEST, output);
-        }
-        if (!revision.getChange().getOwner().equals(revision.getUser().getAccountId())) {
-          output.error = ERROR_ONLY_OWNER_CAN_MODIFY_WORK_IN_PROGRESS;
-          return Response.withStatusCode(SC_BAD_REQUEST, output);
-        }
-        if (input.ready) {
-          output.ready = true;
-        }
-
-        // Suppress notifications in WorkInProgressOp, we'll take care of
-        // them in this endpoint.
-        WorkInProgressOp.Input wipIn = new WorkInProgressOp.Input();
-        wipIn.notify = NotifyHandling.NONE;
-        bu.addOp(
-            revision.getChange().getId(),
-            workInProgressOpFactory.create(input.workInProgress, wipIn));
-      }
-
-      // Add the review op.
-      bu.addOp(
-          revision.getChange().getId(),
-          new Op(projectState, revision.getPatchSet().getId(), input, accountsToNotify));
-
-      bu.execute();
-
-      for (PostReviewers.Addition reviewerResult : reviewerResults) {
-        reviewerResult.gatherResults();
-      }
-
-      emailReviewers(revision.getChange(), reviewerResults, reviewerNotify, accountsToNotify);
-    }
-
-    return Response.ok(output);
-  }
-
-  private NotifyHandling defaultNotify(Change c, ReviewInput in) {
-    boolean workInProgress = c.isWorkInProgress();
-    if (in.workInProgress) {
-      workInProgress = true;
-    }
-    if (in.ready) {
-      workInProgress = false;
-    }
-
-    if (ChangeMessagesUtil.isAutogenerated(in.tag)) {
-      // Autogenerated comments default to lower notify levels.
-      return workInProgress ? NotifyHandling.OWNER : NotifyHandling.OWNER_REVIEWERS;
-    }
-
-    if (workInProgress && !c.hasReviewStarted()) {
-      // If review hasn't started we want to minimize recipients, no matter who
-      // the author is.
-      return NotifyHandling.OWNER;
-    }
-
-    return NotifyHandling.ALL;
-  }
-
-  private void emailReviewers(
-      Change change,
-      List<PostReviewers.Addition> reviewerAdditions,
-      @Nullable NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    List<Account.Id> to = new ArrayList<>();
-    List<Account.Id> cc = new ArrayList<>();
-    List<Address> toByEmail = new ArrayList<>();
-    List<Address> ccByEmail = new ArrayList<>();
-    for (PostReviewers.Addition addition : reviewerAdditions) {
-      if (addition.state == ReviewerState.REVIEWER) {
-        to.addAll(addition.reviewers);
-        toByEmail.addAll(addition.reviewersByEmail);
-      } else if (addition.state == ReviewerState.CC) {
-        cc.addAll(addition.reviewers);
-        ccByEmail.addAll(addition.reviewersByEmail);
-      }
-    }
-    if (reviewerAdditions.size() > 0) {
-      reviewerAdditions
-          .get(0)
-          .op
-          .emailReviewers(change, to, cc, toByEmail, ccByEmail, notify, accountsToNotify);
-    }
-  }
-
-  private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in)
-      throws BadRequestException, AuthException, UnprocessableEntityException, OrmException,
-          PermissionBackendException, IOException, ConfigInvalidException {
-    if (in.labels == null || in.labels.isEmpty()) {
-      throw new AuthException(
-          String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
-    }
-    if (in.drafts == null) {
-      in.drafts = DraftHandling.KEEP;
-    }
-    if (in.drafts != DraftHandling.KEEP) {
-      throw new AuthException("not allowed to modify other user's drafts");
-    }
-
-    CurrentUser caller = rev.getUser();
-    PermissionBackend.ForChange perm = rev.permissions().database(db);
-    Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
-    while (itr.hasNext()) {
-      Map.Entry<String, Short> ent = itr.next();
-      LabelType type = labelTypes.byLabel(ent.getKey());
-      if (type == null && in.strictLabels) {
-        throw new BadRequestException(
-            String.format("label \"%s\" is not a configured label", ent.getKey()));
-      } else if (type == null) {
-        itr.remove();
-        continue;
-      }
-
-      if (!caller.isInternalUser()) {
-        try {
-          perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type, ent.getValue()));
-        } catch (AuthException e) {
-          throw new AuthException(
-              String.format(
-                  "not permitted to modify label \"%s\" on behalf of \"%s\"",
-                  type.getName(), in.onBehalfOf));
-        }
-      }
-    }
-    if (in.labels.isEmpty()) {
-      throw new AuthException(
-          String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
-    }
-
-    IdentifiedUser reviewer = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
-    try {
-      perm.user(reviewer).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new UnprocessableEntityException(
-          String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()));
-    }
-
-    return new RevisionResource(
-        changeResourceFactory.create(rev.getNotes(), reviewer), rev.getPatchSet());
-  }
-
-  private void checkLabels(
-      RevisionResource rsrc, LabelTypes labelTypes, boolean strict, Map<String, Short> labels)
-      throws BadRequestException, AuthException, PermissionBackendException {
-    PermissionBackend.ForChange perm = rsrc.permissions();
-    Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
-    while (itr.hasNext()) {
-      Map.Entry<String, Short> ent = itr.next();
-      LabelType lt = 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;
-      }
-
-      if (ent.getValue() == null || ent.getValue() == 0) {
-        // Always permit 0, even if it is not within range.
-        // Later null/0 will be deleted and revoke the label.
-        continue;
-      }
-
-      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;
-      }
-
-      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));
-      }
-    }
-  }
-
-  private static <T extends CommentInput> void cleanUpComments(
-      Map<String, List<T>> commentsPerPath) {
-    Iterator<List<T>> mapValueIterator = commentsPerPath.values().iterator();
-    while (mapValueIterator.hasNext()) {
-      List<T> comments = mapValueIterator.next();
-      if (comments == null) {
-        mapValueIterator.remove();
-        continue;
-      }
-
-      cleanUpComments(comments);
-      if (comments.isEmpty()) {
-        mapValueIterator.remove();
-      }
-    }
-  }
-
-  private static <T extends CommentInput> void cleanUpComments(List<T> comments) {
-    Iterator<T> commentsIterator = comments.iterator();
-    while (commentsIterator.hasNext()) {
-      T comment = commentsIterator.next();
-      if (comment == null) {
-        commentsIterator.remove();
-        continue;
-      }
-
-      comment.message = Strings.nullToEmpty(comment.message).trim();
-      if (comment.message.isEmpty()) {
-        commentsIterator.remove();
-      }
-    }
-  }
-
-  private <T extends CommentInput> void checkComments(
-      RevisionResource revision, Map<String, List<T>> commentsPerPath)
-      throws BadRequestException, PatchListNotAvailableException {
-    Set<String> revisionFilePaths = getAffectedFilePaths(revision);
-    for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
-      String path = entry.getKey();
-      PatchSet.Id patchSetId = revision.getChange().currentPatchSetId();
-      ensurePathRefersToAvailableOrMagicFile(path, revisionFilePaths, patchSetId);
-
-      List<T> comments = entry.getValue();
-      for (T comment : comments) {
-        ensureLineIsNonNegative(comment.line, path);
-        ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
-        ensureRangeIsValid(path, comment.range);
-      }
-    }
-  }
-
-  private Set<String> getAffectedFilePaths(RevisionResource revision)
-      throws PatchListNotAvailableException {
-    ObjectId newId = ObjectId.fromString(revision.getPatchSet().getRevision().get());
-    DiffSummaryKey key =
-        DiffSummaryKey.fromPatchListKey(
-            PatchListKey.againstDefaultBase(newId, Whitespace.IGNORE_NONE));
-    DiffSummary ds = patchListCache.getDiffSummary(key, revision.getProject());
-    return new HashSet<>(ds.getPaths());
-  }
-
-  private static void ensurePathRefersToAvailableOrMagicFile(
-      String path, Set<String> availableFilePaths, PatchSet.Id patchSetId)
-      throws BadRequestException {
-    if (!availableFilePaths.contains(path) && !Patch.isMagic(path)) {
-      throw new BadRequestException(
-          String.format("file %s not found in revision %s", path, patchSetId));
-    }
-  }
-
-  private static void ensureLineIsNonNegative(Integer line, String path)
-      throws BadRequestException {
-    if (line != null && line < 0) {
-      throw new BadRequestException(
-          String.format("negative line number %d not allowed on %s", line, path));
-    }
-  }
-
-  private static <T extends CommentInput> void ensureCommentNotOnMagicFilesOfAutoMerge(
-      String path, T comment) throws BadRequestException {
-    if (Patch.isMagic(path) && comment.side == Side.PARENT && comment.parent == null) {
-      throw new BadRequestException(String.format("cannot comment on %s on auto-merge", path));
-    }
-  }
-
-  private void checkRobotComments(
-      RevisionResource revision, Map<String, List<RobotCommentInput>> in)
-      throws BadRequestException, PatchListNotAvailableException {
-    cleanUpComments(in);
-    for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) {
-      String commentPath = e.getKey();
-      for (RobotCommentInput c : e.getValue()) {
-        ensureSizeOfJsonInputIsWithinBounds(c);
-        ensureRobotIdIsSet(c.robotId, commentPath);
-        ensureRobotRunIdIsSet(c.robotRunId, commentPath);
-        ensureFixSuggestionsAreAddable(c.fixSuggestions, commentPath);
-      }
-    }
-    checkComments(revision, in);
-  }
-
-  private void ensureSizeOfJsonInputIsWithinBounds(RobotCommentInput robotCommentInput)
-      throws BadRequestException {
-    OptionalInt robotCommentSizeLimit = getRobotCommentSizeLimit();
-    if (robotCommentSizeLimit.isPresent()) {
-      int sizeLimit = robotCommentSizeLimit.getAsInt();
-      byte[] robotCommentBytes = GSON.toJson(robotCommentInput).getBytes(StandardCharsets.UTF_8);
-      int robotCommentSize = robotCommentBytes.length;
-      if (robotCommentSize > sizeLimit) {
-        throw new BadRequestException(
-            String.format(
-                "Size %d (bytes) of robot comment is greater than limit %d (bytes)",
-                robotCommentSize, sizeLimit));
-      }
-    }
-  }
-
-  private OptionalInt getRobotCommentSizeLimit() {
-    int robotCommentSizeLimit =
-        gerritConfig.getInt(
-            "change", "robotCommentSizeLimit", DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES);
-    if (robotCommentSizeLimit <= 0) {
-      return OptionalInt.empty();
-    }
-    return OptionalInt.of(robotCommentSizeLimit);
-  }
-
-  private static void ensureRobotIdIsSet(String robotId, String commentPath)
-      throws BadRequestException {
-    if (robotId == null) {
-      throw new BadRequestException(
-          String.format("robotId is missing for robot comment on %s", commentPath));
-    }
-  }
-
-  private static void ensureRobotRunIdIsSet(String robotRunId, String commentPath)
-      throws BadRequestException {
-    if (robotRunId == null) {
-      throw new BadRequestException(
-          String.format("robotRunId is missing for robot comment on %s", commentPath));
-    }
-  }
-
-  private static void ensureFixSuggestionsAreAddable(
-      List<FixSuggestionInfo> fixSuggestionInfos, String commentPath) throws BadRequestException {
-    if (fixSuggestionInfos == null) {
-      return;
-    }
-
-    for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
-      ensureDescriptionIsSet(commentPath, fixSuggestionInfo.description);
-      ensureFixReplacementsAreAddable(commentPath, fixSuggestionInfo.replacements);
-    }
-  }
-
-  private static void ensureDescriptionIsSet(String commentPath, String description)
-      throws BadRequestException {
-    if (description == null) {
-      throw new BadRequestException(
-          String.format(
-              "A description is required for the suggested fix of the robot comment on %s",
-              commentPath));
-    }
-  }
-
-  private static void ensureFixReplacementsAreAddable(
-      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
-    ensureReplacementsArePresent(commentPath, fixReplacementInfos);
-
-    for (FixReplacementInfo fixReplacementInfo : fixReplacementInfos) {
-      ensureReplacementPathIsSet(commentPath, fixReplacementInfo.path);
-      ensureRangeIsSet(commentPath, fixReplacementInfo.range);
-      ensureRangeIsValid(commentPath, fixReplacementInfo.range);
-      ensureReplacementStringIsSet(commentPath, fixReplacementInfo.replacement);
-    }
-
-    Map<String, List<FixReplacementInfo>> replacementsPerFilePath =
-        fixReplacementInfos.stream().collect(groupingBy(fixReplacement -> fixReplacement.path));
-    for (List<FixReplacementInfo> sameFileReplacements : replacementsPerFilePath.values()) {
-      ensureRangesDoNotOverlap(commentPath, sameFileReplacements);
-    }
-  }
-
-  private static void ensureReplacementsArePresent(
-      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
-    if (fixReplacementInfos == null || fixReplacementInfos.isEmpty()) {
-      throw new BadRequestException(
-          String.format(
-              "At least one replacement is "
-                  + "required for the suggested fix of the robot comment on %s",
-              commentPath));
-    }
-  }
-
-  private static void ensureReplacementPathIsSet(String commentPath, String replacementPath)
-      throws BadRequestException {
-    if (replacementPath == null) {
-      throw new BadRequestException(
-          String.format(
-              "A file path must be given for the replacement of the robot comment on %s",
-              commentPath));
-    }
-  }
-
-  private static void ensureRangeIsSet(String commentPath, Range range) throws BadRequestException {
-    if (range == null) {
-      throw new BadRequestException(
-          String.format(
-              "A range must be given for the replacement of the robot comment on %s", commentPath));
-    }
-  }
-
-  private static void ensureRangeIsValid(String commentPath, Range range)
-      throws BadRequestException {
-    if (range == null) {
-      return;
-    }
-    if (!range.isValid()) {
-      throw new BadRequestException(
-          String.format(
-              "Range (%s:%s - %s:%s) is not valid for the comment on %s",
-              range.startLine,
-              range.startCharacter,
-              range.endLine,
-              range.endCharacter,
-              commentPath));
-    }
-  }
-
-  private static void ensureReplacementStringIsSet(String commentPath, String replacement)
-      throws BadRequestException {
-    if (replacement == null) {
-      throw new BadRequestException(
-          String.format(
-              "A content for replacement "
-                  + "must be indicated for the replacement of the robot comment on %s",
-              commentPath));
-    }
-  }
-
-  private static void ensureRangesDoNotOverlap(
-      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
-    List<Range> sortedRanges =
-        fixReplacementInfos
-            .stream()
-            .map(fixReplacementInfo -> fixReplacementInfo.range)
-            .sorted()
-            .collect(toList());
-
-    int previousEndLine = 0;
-    int previousOffset = -1;
-    for (Range range : sortedRanges) {
-      if (range.startLine < previousEndLine
-          || (range.startLine == previousEndLine && range.startCharacter < previousOffset)) {
-        throw new BadRequestException(
-            String.format("Replacements overlap for the robot comment on %s", commentPath));
-      }
-      previousEndLine = range.endLine;
-      previousOffset = range.endCharacter;
-    }
-  }
-
-  /** Used to compare Comments with CommentInput comments. */
-  @AutoValue
-  abstract static class CommentSetEntry {
-    private static CommentSetEntry create(
-        String filename,
-        int patchSetId,
-        Integer line,
-        Side side,
-        HashCode message,
-        Comment.Range range) {
-      return new AutoValue_PostReview_CommentSetEntry(
-          filename, patchSetId, line, side, message, range);
-    }
-
-    public static CommentSetEntry create(Comment comment) {
-      return create(
-          comment.key.filename,
-          comment.key.patchSetId,
-          comment.lineNbr,
-          Side.fromShort(comment.side),
-          Hashing.murmur3_128().hashString(comment.message, UTF_8),
-          comment.range);
-    }
-
-    abstract String filename();
-
-    abstract int patchSetId();
-
-    @Nullable
-    abstract Integer line();
-
-    abstract Side side();
-
-    abstract HashCode message();
-
-    @Nullable
-    abstract Comment.Range range();
-  }
-
-  private class Op implements BatchUpdateOp {
-    private final ProjectState projectState;
-    private final PatchSet.Id psId;
-    private final ReviewInput in;
-    private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
-
-    private IdentifiedUser user;
-    private ChangeNotes notes;
-    private PatchSet ps;
-    private ChangeMessage message;
-    private List<Comment> comments = new ArrayList<>();
-    private List<LabelVote> labelDelta = new ArrayList<>();
-    private Map<String, Short> approvals = new HashMap<>();
-    private Map<String, Short> oldApprovals = new HashMap<>();
-
-    private Op(
-        ProjectState projectState,
-        PatchSet.Id psId,
-        ReviewInput in,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-      this.projectState = projectState;
-      this.psId = psId;
-      this.in = in;
-      this.accountsToNotify = checkNotNull(accountsToNotify);
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws OrmException, ResourceConflictException, UnprocessableEntityException, IOException {
-      user = ctx.getIdentifiedUser();
-      notes = ctx.getNotes();
-      ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-      boolean dirty = false;
-      dirty |= insertComments(ctx);
-      dirty |= insertRobotComments(ctx);
-      dirty |= updateLabels(projectState, ctx);
-      dirty |= insertMessage(ctx);
-      return dirty;
-    }
-
-    @Override
-    public void postUpdate(Context ctx) throws OrmException {
-      if (message == null) {
-        return;
-      }
-      if (in.notify.compareTo(NotifyHandling.NONE) > 0 || !accountsToNotify.isEmpty()) {
-        email
-            .create(
-                in.notify,
-                accountsToNotify,
-                notes,
-                ps,
-                user,
-                message,
-                comments,
-                in.message,
-                labelDelta)
-            .sendAsync();
-      }
-      commentAdded.fire(
-          notes.getChange(),
-          ps,
-          user.getAccount(),
-          message.getMessage(),
-          approvals,
-          oldApprovals,
-          ctx.getWhen());
-    }
-
-    private boolean insertComments(ChangeContext ctx)
-        throws OrmException, UnprocessableEntityException {
-      Map<String, List<CommentInput>> map = in.comments;
-      if (map == null) {
-        map = Collections.emptyMap();
-      }
-
-      Map<String, Comment> drafts = Collections.emptyMap();
-      if (!map.isEmpty() || in.drafts != DraftHandling.KEEP) {
-        if (in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS) {
-          drafts = changeDrafts(ctx);
-        } else {
-          drafts = patchSetDrafts(ctx);
-        }
-      }
-
-      List<Comment> toDel = new ArrayList<>();
-      List<Comment> toPublish = new ArrayList<>();
-
-      Set<CommentSetEntry> existingIds =
-          in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
-
-      for (Map.Entry<String, List<CommentInput>> ent : map.entrySet()) {
-        String path = ent.getKey();
-        for (CommentInput c : ent.getValue()) {
-          String parent = Url.decode(c.inReplyTo);
-          Comment e = drafts.remove(Url.decode(c.id));
-          if (e == null) {
-            e = commentsUtil.newComment(ctx, path, psId, c.side(), c.message, c.unresolved, parent);
-          } else {
-            e.writtenOn = ctx.getWhen();
-            e.side = c.side();
-            e.message = c.message;
-          }
-
-          setCommentRevId(e, patchListCache, ctx.getChange(), ps);
-          e.setLineNbrAndRange(c.line, c.range);
-          e.tag = in.tag;
-
-          if (existingIds.contains(CommentSetEntry.create(e))) {
-            continue;
-          }
-          toPublish.add(e);
-        }
-      }
-
-      switch (in.drafts) {
-        case KEEP:
-        default:
-          break;
-        case DELETE:
-          toDel.addAll(drafts.values());
-          break;
-        case PUBLISH:
-        case PUBLISH_ALL_REVISIONS:
-          commentsUtil.publish(ctx, psId, drafts.values(), in.tag);
-          comments.addAll(drafts.values());
-          break;
-      }
-      ChangeUpdate u = ctx.getUpdate(psId);
-      commentsUtil.deleteComments(ctx.getDb(), u, toDel);
-      commentsUtil.putComments(ctx.getDb(), u, Status.PUBLISHED, toPublish);
-      comments.addAll(toPublish);
-      return !toDel.isEmpty() || !toPublish.isEmpty();
-    }
-
-    private boolean insertRobotComments(ChangeContext ctx) throws OrmException {
-      if (in.robotComments == null) {
-        return false;
-      }
-
-      List<RobotComment> newRobotComments = getNewRobotComments(ctx);
-      commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
-      comments.addAll(newRobotComments);
-      return !newRobotComments.isEmpty();
-    }
-
-    private List<RobotComment> getNewRobotComments(ChangeContext ctx) throws OrmException {
-      List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
-
-      Set<CommentSetEntry> existingIds =
-          in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
-
-      for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
-        String path = ent.getKey();
-        for (RobotCommentInput c : ent.getValue()) {
-          RobotComment e = createRobotCommentFromInput(ctx, path, c);
-          if (existingIds.contains(CommentSetEntry.create(e))) {
-            continue;
-          }
-          toAdd.add(e);
-        }
-      }
-      return toAdd;
-    }
-
-    private RobotComment createRobotCommentFromInput(
-        ChangeContext ctx, String path, RobotCommentInput robotCommentInput) throws OrmException {
-      RobotComment robotComment =
-          commentsUtil.newRobotComment(
-              ctx,
-              path,
-              psId,
-              robotCommentInput.side(),
-              robotCommentInput.message,
-              robotCommentInput.robotId,
-              robotCommentInput.robotRunId);
-      robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
-      robotComment.url = robotCommentInput.url;
-      robotComment.properties = robotCommentInput.properties;
-      robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
-      robotComment.tag = in.tag;
-      setCommentRevId(robotComment, patchListCache, ctx.getChange(), ps);
-      robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
-      return robotComment;
-    }
-
-    private List<FixSuggestion> createFixSuggestionsFromInput(
-        List<FixSuggestionInfo> fixSuggestionInfos) {
-      if (fixSuggestionInfos == null) {
-        return Collections.emptyList();
-      }
-
-      List<FixSuggestion> fixSuggestions = new ArrayList<>(fixSuggestionInfos.size());
-      for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
-        fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
-      }
-      return fixSuggestions;
-    }
-
-    private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
-      List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
-      String fixId = ChangeUtil.messageUuid();
-      return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
-    }
-
-    private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
-      return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
-    }
-
-    private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
-      Comment.Range range = new Comment.Range(fixReplacementInfo.range);
-      return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
-    }
-
-    private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) throws OrmException {
-      return commentsUtil
-          .publishedByChange(ctx.getDb(), ctx.getNotes())
-          .stream()
-          .map(CommentSetEntry::create)
-          .collect(toSet());
-    }
-
-    private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) throws OrmException {
-      return commentsUtil
-          .robotCommentsByChange(ctx.getNotes())
-          .stream()
-          .map(CommentSetEntry::create)
-          .collect(toSet());
-    }
-
-    private Map<String, Comment> changeDrafts(ChangeContext ctx) throws OrmException {
-      Map<String, Comment> drafts = new HashMap<>();
-      for (Comment c :
-          commentsUtil.draftByChangeAuthor(ctx.getDb(), ctx.getNotes(), user.getAccountId())) {
-        c.tag = in.tag;
-        drafts.put(c.key.uuid, c);
-      }
-      return drafts;
-    }
-
-    private Map<String, Comment> patchSetDrafts(ChangeContext ctx) throws OrmException {
-      Map<String, Comment> drafts = new HashMap<>();
-      for (Comment c :
-          commentsUtil.draftByPatchSetAuthor(
-              ctx.getDb(), psId, user.getAccountId(), ctx.getNotes())) {
-        drafts.put(c.key.uuid, c);
-      }
-      return drafts;
-    }
-
-    private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
-      Map<String, Short> labels = new HashMap<>();
-      for (PatchSetApproval psa : patchsetApprovals) {
-        labels.put(psa.getLabel(), psa.getValue());
-      }
-      return labels;
-    }
-
-    private Map<String, Short> getAllApprovals(
-        LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
-      Map<String, Short> allApprovals = new HashMap<>();
-      for (LabelType lt : labelTypes.getLabelTypes()) {
-        allApprovals.put(lt.getName(), (short) 0);
-      }
-      // set approvals to existing votes
-      if (current != null) {
-        allApprovals.putAll(current);
-      }
-      // set approvals to new votes
-      if (input != null) {
-        allApprovals.putAll(input);
-      }
-      return allApprovals;
-    }
-
-    private Map<String, Short> getPreviousApprovals(
-        Map<String, Short> allApprovals, Map<String, Short> current) {
-      Map<String, Short> previous = new HashMap<>();
-      for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
-        // assume vote is 0 if there is no vote
-        if (!current.containsKey(approval.getKey())) {
-          previous.put(approval.getKey(), (short) 0);
-        } else {
-          previous.put(approval.getKey(), current.get(approval.getKey()));
-        }
-      }
-      return previous;
-    }
-
-    private boolean isReviewer(ChangeContext ctx) throws OrmException {
-      if (ctx.getAccountId().equals(ctx.getChange().getOwner())) {
-        return true;
-      }
-      ChangeData cd = changeDataFactory.create(db.get(), ctx.getNotes());
-      ReviewerSet reviewers = cd.reviewers();
-      if (reviewers.byState(REVIEWER).contains(ctx.getAccountId())) {
-        return true;
-      }
-      return false;
-    }
-
-    private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
-        throws OrmException, ResourceConflictException, IOException {
-      Map<String, Short> inLabels =
-          MoreObjects.firstNonNull(in.labels, Collections.<String, Short>emptyMap());
-
-      // If no labels were modified and change is closed, abort early.
-      // This avoids trying to record a modified label caused by a user
-      // losing access to a label after the change was submitted.
-      if (inLabels.isEmpty() && ctx.getChange().getStatus().isClosed()) {
-        return false;
-      }
-
-      List<PatchSetApproval> del = new ArrayList<>();
-      List<PatchSetApproval> ups = new ArrayList<>();
-      Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
-      Map<String, Short> allApprovals =
-          getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
-      Map<String, Short> previous =
-          getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
-
-      ChangeUpdate update = ctx.getUpdate(psId);
-      for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
-        String name = ent.getKey();
-        LabelType lt = checkNotNull(labelTypes.byLabel(name), name);
-
-        PatchSetApproval c = current.remove(lt.getName());
-        String normName = lt.getName();
-        approvals.put(normName, (short) 0);
-        if (ent.getValue() == null || ent.getValue() == 0) {
-          // User requested delete of this label.
-          oldApprovals.put(normName, null);
-          if (c != null) {
-            if (c.getValue() != 0) {
-              addLabelDelta(normName, (short) 0);
-              oldApprovals.put(normName, previous.get(normName));
-            }
-            del.add(c);
-            update.putApproval(normName, (short) 0);
-          }
-        } else if (c != null && c.getValue() != ent.getValue()) {
-          c.setValue(ent.getValue());
-          c.setGranted(ctx.getWhen());
-          c.setTag(in.tag);
-          ctx.getUser().updateRealAccountId(c::setRealAccountId);
-          ups.add(c);
-          addLabelDelta(normName, c.getValue());
-          oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.getValue());
-          update.putApproval(normName, ent.getValue());
-        } else if (c != null && c.getValue() == ent.getValue()) {
-          current.put(normName, c);
-          oldApprovals.put(normName, null);
-          approvals.put(normName, c.getValue());
-        } else if (c == null) {
-          c = ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen());
-          c.setTag(in.tag);
-          c.setGranted(ctx.getWhen());
-          ups.add(c);
-          addLabelDelta(normName, c.getValue());
-          oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.getValue());
-          update.putReviewer(user.getAccountId(), REVIEWER);
-          update.putApproval(normName, ent.getValue());
-        }
-      }
-
-      validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
-
-      // Return early if user is not a reviewer and not posting any labels.
-      // This allows us to preserve their CC status.
-      if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
-        return false;
-      }
-
-      forceCallerAsReviewer(projectState, ctx, current, ups, del);
-      ctx.getDb().patchSetApprovals().delete(del);
-      ctx.getDb().patchSetApprovals().upsert(ups);
-      return !del.isEmpty() || !ups.isEmpty();
-    }
-
-    private void validatePostSubmitLabels(
-        ChangeContext ctx,
-        LabelTypes labelTypes,
-        Map<String, Short> previous,
-        List<PatchSetApproval> ups,
-        List<PatchSetApproval> del)
-        throws ResourceConflictException {
-      if (ctx.getChange().getStatus().isOpen()) {
-        return; // Not closed, nothing to validate.
-      } else if (del.isEmpty() && ups.isEmpty()) {
-        return; // No new votes.
-      } else if (ctx.getChange().getStatus() != Change.Status.MERGED) {
-        throw new ResourceConflictException("change is closed");
-      }
-
-      // Disallow reducing votes on any labels post-submit. This assumes the
-      // high values were broadly necessary to submit, so reducing them would
-      // make it possible to take a merged change and make it no longer
-      // submittable.
-      List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
-      List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
-
-      for (PatchSetApproval psa : del) {
-        LabelType lt = checkNotNull(labelTypes.byLabel(psa.getLabel()));
-        String normName = lt.getName();
-        if (!lt.allowPostSubmit()) {
-          disallowed.add(normName);
-        }
-        Short prev = previous.get(normName);
-        if (prev != null && prev != 0) {
-          reduced.add(psa);
-        }
-      }
-
-      for (PatchSetApproval psa : ups) {
-        LabelType lt = checkNotNull(labelTypes.byLabel(psa.getLabel()));
-        String normName = lt.getName();
-        if (!lt.allowPostSubmit()) {
-          disallowed.add(normName);
-        }
-        Short prev = previous.get(normName);
-        if (prev == null) {
-          continue;
-        }
-        checkState(prev != psa.getValue()); // Should be filtered out above.
-        if (prev > psa.getValue()) {
-          reduced.add(psa);
-        } else {
-          // Set postSubmit bit in ReviewDb; not required for NoteDb, which sets
-          // it automatically.
-          psa.setPostSubmit(true);
-        }
-      }
-
-      if (!disallowed.isEmpty()) {
-        throw new ResourceConflictException(
-            "Voting on labels disallowed after submit: "
-                + disallowed.stream().distinct().sorted().collect(joining(", ")));
-      }
-      if (!reduced.isEmpty()) {
-        throw new ResourceConflictException(
-            "Cannot reduce vote on labels for closed change: "
-                + reduced
-                    .stream()
-                    .map(p -> p.getLabel())
-                    .distinct()
-                    .sorted()
-                    .collect(joining(", ")));
-      }
-    }
-
-    private void forceCallerAsReviewer(
-        ProjectState projectState,
-        ChangeContext ctx,
-        Map<String, PatchSetApproval> current,
-        List<PatchSetApproval> ups,
-        List<PatchSetApproval> del) {
-      if (current.isEmpty() && ups.isEmpty()) {
-        // TODO Find another way to link reviewers to changes.
-        if (del.isEmpty()) {
-          // If no existing label is being set to 0, hack in the caller
-          // as a reviewer by picking the first server-wide LabelType.
-          LabelId labelId =
-              projectState
-                  .getLabelTypes(ctx.getNotes(), ctx.getUser())
-                  .getLabelTypes()
-                  .get(0)
-                  .getLabelId();
-          PatchSetApproval c = ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen());
-          c.setTag(in.tag);
-          c.setGranted(ctx.getWhen());
-          ups.add(c);
-        } else {
-          // Pick a random label that is about to be deleted and keep it.
-          Iterator<PatchSetApproval> i = del.iterator();
-          PatchSetApproval c = i.next();
-          c.setValue((short) 0);
-          c.setGranted(ctx.getWhen());
-          i.remove();
-          ups.add(c);
-        }
-      }
-      ctx.getUpdate(ctx.getChange().currentPatchSetId()).putReviewer(user.getAccountId(), REVIEWER);
-    }
-
-    private Map<String, PatchSetApproval> scanLabels(
-        ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del)
-        throws OrmException, IOException {
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
-      Map<String, PatchSetApproval> current = new HashMap<>();
-
-      for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(
-              ctx.getDb(),
-              ctx.getNotes(),
-              ctx.getUser(),
-              psId,
-              user.getAccountId(),
-              ctx.getRevWalk(),
-              ctx.getRepoView().getConfig())) {
-        if (a.isLegacySubmit()) {
-          continue;
-        }
-
-        LabelType lt = labelTypes.byLabel(a.getLabelId());
-        if (lt != null) {
-          current.put(lt.getName(), a);
-        } else {
-          del.add(a);
-        }
-      }
-      return current;
-    }
-
-    private boolean insertMessage(ChangeContext ctx) throws OrmException {
-      String msg = Strings.nullToEmpty(in.message).trim();
-
-      StringBuilder buf = new StringBuilder();
-      for (LabelVote d : labelDelta) {
-        buf.append(" ").append(d.format());
-      }
-      if (comments.size() == 1) {
-        buf.append("\n\n(1 comment)");
-      } else if (comments.size() > 1) {
-        buf.append(String.format("\n\n(%d comments)", comments.size()));
-      }
-      if (!msg.isEmpty()) {
-        buf.append("\n\n").append(msg);
-      } else if (in.ready) {
-        buf.append("\n\n" + START_REVIEW_MESSAGE);
-      }
-      if (buf.length() == 0) {
-        return false;
-      }
-
-      message =
-          ChangeMessagesUtil.newMessage(
-              psId, user, ctx.getWhen(), "Patch Set " + psId.get() + ":" + buf, in.tag);
-      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), message);
-      return true;
-    }
-
-    private void addLabelDelta(String name, short value) {
-      labelDelta.add(LabelVote.create(name, value));
-    }
-  }
-}
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
deleted file mode 100644
index f642aa4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
+++ /dev/null
@@ -1,479 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.extensions.client.ReviewerState.CC;
-import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.ReviewerInfo;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-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.AccountLoader;
-import com.google.gerrit.server.account.AccountsCollection;
-import com.google.gerrit.server.account.GroupMembers;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.group.GroupsCollection;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.text.MessageFormat;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class PostReviewers
-    extends RetryingRestModifyView<ChangeResource, AddReviewerInput, AddReviewerResult> {
-
-  public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
-  public static final int DEFAULT_MAX_REVIEWERS = 20;
-
-  private final AccountsCollection accounts;
-  private final ReviewerResource.Factory reviewerFactory;
-  private final PermissionBackend permissionBackend;
-
-  private final GroupsCollection groupsCollection;
-  private final GroupMembers.Factory groupMembersFactory;
-  private final AccountLoader.Factory accountLoaderFactory;
-  private final Provider<ReviewDb> dbProvider;
-  private final ChangeData.Factory changeDataFactory;
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
-  private final Config cfg;
-  private final ReviewerJson json;
-  private final NotesMigration migration;
-  private final NotifyUtil notifyUtil;
-  private final ProjectCache projectCache;
-  private final Provider<AnonymousUser> anonymousProvider;
-  private final PostReviewersOp.Factory postReviewersOpFactory;
-  private final OutgoingEmailValidator validator;
-
-  @Inject
-  PostReviewers(
-      AccountsCollection accounts,
-      ReviewerResource.Factory reviewerFactory,
-      PermissionBackend permissionBackend,
-      GroupsCollection groupsCollection,
-      GroupMembers.Factory groupMembersFactory,
-      AccountLoader.Factory accountLoaderFactory,
-      Provider<ReviewDb> db,
-      ChangeData.Factory changeDataFactory,
-      RetryHelper retryHelper,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      @GerritServerConfig Config cfg,
-      ReviewerJson json,
-      NotesMigration migration,
-      NotifyUtil notifyUtil,
-      ProjectCache projectCache,
-      Provider<AnonymousUser> anonymousProvider,
-      PostReviewersOp.Factory postReviewersOpFactory,
-      OutgoingEmailValidator validator) {
-    super(retryHelper);
-    this.accounts = accounts;
-    this.reviewerFactory = reviewerFactory;
-    this.permissionBackend = permissionBackend;
-    this.groupsCollection = groupsCollection;
-    this.groupMembersFactory = groupMembersFactory;
-    this.accountLoaderFactory = accountLoaderFactory;
-    this.dbProvider = db;
-    this.changeDataFactory = changeDataFactory;
-    this.identifiedUserFactory = identifiedUserFactory;
-    this.cfg = cfg;
-    this.json = json;
-    this.migration = migration;
-    this.notifyUtil = notifyUtil;
-    this.projectCache = projectCache;
-    this.anonymousProvider = anonymousProvider;
-    this.postReviewersOpFactory = postReviewersOpFactory;
-    this.validator = validator;
-  }
-
-  @Override
-  protected AddReviewerResult applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, AddReviewerInput input)
-      throws IOException, OrmException, RestApiException, UpdateException,
-          PermissionBackendException, ConfigInvalidException {
-    if (input.reviewer == null) {
-      throw new BadRequestException("missing reviewer field");
-    }
-
-    Addition addition = prepareApplication(rsrc, input, true);
-    if (addition.op == null) {
-      return addition.result;
-    }
-    try (BatchUpdate bu =
-        updateFactory.create(
-            dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      Change.Id id = rsrc.getChange().getId();
-      bu.addOp(id, addition.op);
-      bu.execute();
-      addition.gatherResults();
-    }
-    return addition.result;
-  }
-
-  public Addition prepareApplication(
-      ChangeResource rsrc, AddReviewerInput input, boolean allowGroup)
-      throws OrmException, IOException, PermissionBackendException, ConfigInvalidException {
-    String reviewer = input.reviewer;
-    ReviewerState state = input.state();
-    NotifyHandling notify = input.notify;
-    ListMultimap<RecipientType, Account.Id> accountsToNotify = null;
-    try {
-      accountsToNotify = notifyUtil.resolveAccounts(input.notifyDetails);
-    } catch (BadRequestException e) {
-      return fail(reviewer, e.getMessage());
-    }
-    boolean confirmed = input.confirmed();
-    boolean allowByEmail = projectCache.checkedGet(rsrc.getProject()).isEnableReviewerByEmail();
-
-    Addition byAccountId =
-        addByAccountId(reviewer, rsrc, state, notify, accountsToNotify, allowGroup, allowByEmail);
-    if (byAccountId != null) {
-      return byAccountId;
-    }
-
-    Addition wholeGroup =
-        addWholeGroup(
-            reviewer, rsrc, state, notify, accountsToNotify, confirmed, allowGroup, allowByEmail);
-    if (wholeGroup != null) {
-      return wholeGroup;
-    }
-
-    return addByEmail(reviewer, rsrc, state, notify, accountsToNotify);
-  }
-
-  Addition ccCurrentUser(CurrentUser user, RevisionResource revision) {
-    return new Addition(
-        user.getUserName(),
-        revision.getChangeResource(),
-        ImmutableSet.of(user.getAccountId()),
-        null,
-        CC,
-        NotifyHandling.NONE,
-        ImmutableListMultimap.of());
-  }
-
-  @Nullable
-  private Addition addByAccountId(
-      String reviewer,
-      ChangeResource rsrc,
-      ReviewerState state,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify,
-      boolean allowGroup,
-      boolean allowByEmail)
-      throws OrmException, PermissionBackendException, IOException, ConfigInvalidException {
-    Account.Id accountId = null;
-    try {
-      accountId = accounts.parse(reviewer).getAccountId();
-    } catch (UnprocessableEntityException | AuthException e) {
-      // AuthException won't occur since the user is authenticated at this point.
-      if (!allowGroup && !allowByEmail) {
-        // Only return failure if we aren't going to try other interpretations.
-        return fail(
-            reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
-      }
-      return null;
-    }
-
-    ReviewerResource rrsrc = reviewerFactory.create(rsrc, accountId);
-    Account member = rrsrc.getReviewerUser().getAccount();
-    PermissionBackend.ForRef perm =
-        permissionBackend.user(rrsrc.getReviewerUser()).ref(rrsrc.getChange().getDest());
-    if (isValidReviewer(member, perm)) {
-      return new Addition(
-          reviewer, rsrc, ImmutableSet.of(member.getId()), null, state, notify, accountsToNotify);
-    }
-    if (!member.isActive()) {
-      if (allowByEmail && state == CC) {
-        return null;
-      }
-      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInactive, reviewer));
-    }
-    return fail(
-        reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
-  }
-
-  @Nullable
-  private Addition addWholeGroup(
-      String reviewer,
-      ChangeResource rsrc,
-      ReviewerState state,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify,
-      boolean confirmed,
-      boolean allowGroup,
-      boolean allowByEmail)
-      throws OrmException, IOException, PermissionBackendException {
-    if (!allowGroup) {
-      return null;
-    }
-
-    GroupDescription.Basic group = null;
-    try {
-      group = groupsCollection.parseInternal(reviewer);
-    } catch (UnprocessableEntityException e) {
-      if (!allowByEmail) {
-        return fail(
-            reviewer,
-            MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, reviewer));
-      }
-      return null;
-    }
-
-    if (!isLegalReviewerGroup(group.getGroupUUID())) {
-      return fail(
-          reviewer, MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
-    }
-
-    Set<Account.Id> reviewers = new HashSet<>();
-    Set<Account> members;
-    try {
-      members =
-          groupMembersFactory
-              .create(rsrc.getUser())
-              .listAccounts(group.getGroupUUID(), rsrc.getProject());
-    } catch (NoSuchGroupException e) {
-      return fail(
-          reviewer,
-          MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, group.getName()));
-    } catch (NoSuchProjectException e) {
-      return fail(reviewer, e.getMessage());
-    }
-
-    // if maxAllowed is set to 0, it is allowed to add any number of
-    // reviewers
-    int maxAllowed = cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
-    if (maxAllowed > 0 && members.size() > maxAllowed) {
-      return fail(
-          reviewer,
-          MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
-    }
-
-    // if maxWithoutCheck is set to 0, we never ask for confirmation
-    int maxWithoutConfirmation =
-        cfg.getInt("addreviewer", "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
-    if (!confirmed && maxWithoutConfirmation > 0 && members.size() > maxWithoutConfirmation) {
-      return fail(
-          reviewer,
-          true,
-          MessageFormat.format(
-              ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
-    }
-
-    PermissionBackend.ForRef perm =
-        permissionBackend.user(rsrc.getUser()).ref(rsrc.getChange().getDest());
-    for (Account member : members) {
-      if (isValidReviewer(member, perm)) {
-        reviewers.add(member.getId());
-      }
-    }
-
-    return new Addition(reviewer, rsrc, reviewers, null, state, notify, accountsToNotify);
-  }
-
-  @Nullable
-  private Addition addByEmail(
-      String reviewer,
-      ChangeResource rsrc,
-      ReviewerState state,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify)
-      throws PermissionBackendException {
-    if (!permissionBackend
-        .user(anonymousProvider)
-        .change(rsrc.getNotes())
-        .database(dbProvider)
-        .test(ChangePermission.READ)) {
-      return fail(
-          reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
-    }
-    if (!migration.readChanges()) {
-      // addByEmail depends on NoteDb.
-      return fail(
-          reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
-    }
-    Address adr = Address.tryParse(reviewer);
-    if (adr == null || !validator.isValid(adr.getEmail())) {
-      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInvalid, reviewer));
-    }
-    return new Addition(
-        reviewer, rsrc, null, ImmutableList.of(adr), state, notify, accountsToNotify);
-  }
-
-  private boolean isValidReviewer(Account member, PermissionBackend.ForRef perm)
-      throws PermissionBackendException {
-    if (member.isActive()) {
-      IdentifiedUser user = identifiedUserFactory.create(member.getId());
-      // Does not account for draft status as a user might want to let a
-      // reviewer see a draft.
-      try {
-        perm.user(user).check(RefPermission.READ);
-        return true;
-      } catch (AuthException e) {
-        return false;
-      }
-    }
-    return false;
-  }
-
-  private Addition fail(String reviewer, String error) {
-    return fail(reviewer, false, error);
-  }
-
-  private Addition fail(String reviewer, boolean confirm, String error) {
-    Addition addition = new Addition(reviewer);
-    addition.result.confirm = confirm ? true : null;
-    addition.result.error = error;
-    return addition;
-  }
-
-  public class Addition {
-    final AddReviewerResult result;
-    final PostReviewersOp op;
-    final Set<Account.Id> reviewers;
-    final Collection<Address> reviewersByEmail;
-    final ReviewerState state;
-    final ChangeNotes notes;
-    final IdentifiedUser caller;
-
-    Addition(String reviewer) {
-      result = new AddReviewerResult(reviewer);
-      op = null;
-      reviewers = ImmutableSet.of();
-      reviewersByEmail = ImmutableSet.of();
-      state = REVIEWER;
-      notes = null;
-      caller = null;
-    }
-
-    protected Addition(
-        String reviewer,
-        ChangeResource rsrc,
-        @Nullable Set<Account.Id> reviewers,
-        @Nullable Collection<Address> reviewersByEmail,
-        ReviewerState state,
-        @Nullable NotifyHandling notify,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-      checkArgument(
-          reviewers != null || reviewersByEmail != null,
-          "must have either reviewers or reviewersByEmail");
-
-      result = new AddReviewerResult(reviewer);
-      this.reviewers = reviewers == null ? ImmutableSet.of() : reviewers;
-      this.reviewersByEmail = reviewersByEmail == null ? ImmutableList.of() : reviewersByEmail;
-      this.state = state;
-      notes = rsrc.getNotes();
-      caller = rsrc.getUser().asIdentifiedUser();
-      op =
-          postReviewersOpFactory.create(
-              rsrc, this.reviewers, this.reviewersByEmail, state, notify, accountsToNotify);
-    }
-
-    void gatherResults() throws OrmException, PermissionBackendException {
-      if (notes == null || caller == null) {
-        // When notes or caller is missing this is likely just carrying an error message
-        // in the contained AddReviewerResult.
-        return;
-      }
-
-      ChangeData cd = changeDataFactory.create(dbProvider.get(), notes);
-      PermissionBackend.ForChange perm =
-          permissionBackend.user(caller).database(dbProvider).change(cd);
-
-      // Generate result details and fill AccountLoader. This occurs outside
-      // the Op because the accounts are in a different table.
-      PostReviewersOp.Result opResult = op.getResult();
-      if (migration.readChanges() && state == CC) {
-        result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
-        for (Account.Id accountId : opResult.addedCCs()) {
-          IdentifiedUser u = identifiedUserFactory.create(accountId);
-          result.ccs.add(json.format(caller, new ReviewerInfo(accountId.get()), perm.user(u), cd));
-        }
-        accountLoaderFactory.create(true).fill(result.ccs);
-        for (Address a : reviewersByEmail) {
-          result.ccs.add(new AccountInfo(a.getName(), a.getEmail()));
-        }
-      } else {
-        result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
-        for (PatchSetApproval psa : opResult.addedReviewers()) {
-          // New reviewers have value 0, don't bother normalizing.
-          IdentifiedUser u = identifiedUserFactory.create(psa.getAccountId());
-          result.reviewers.add(
-              json.format(
-                  caller,
-                  new ReviewerInfo(psa.getAccountId().get()),
-                  perm.user(u),
-                  cd,
-                  ImmutableList.of(psa)));
-        }
-        accountLoaderFactory.create(true).fill(result.reviewers);
-        for (Address a : reviewersByEmail) {
-          result.reviewers.add(ReviewerInfo.byEmail(a.getName(), a.getEmail()));
-        }
-      }
-    }
-  }
-
-  public static boolean isLegalReviewerGroup(AccountGroup.UUID groupUUID) {
-    return !SystemGroupBackend.isSystemGroup(groupUUID);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewersOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewersOp.java
deleted file mode 100644
index aed6cd0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewersOp.java
+++ /dev/null
@@ -1,267 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.extensions.client.ReviewerState.CC;
-import static java.util.stream.Collectors.toList;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.extensions.events.ReviewerAdded;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.AddReviewerSender;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Set;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class PostReviewersOp implements BatchUpdateOp {
-  private static final Logger log = LoggerFactory.getLogger(PostReviewersOp.class);
-
-  public interface Factory {
-    PostReviewersOp create(
-        ChangeResource rsrc,
-        Set<Account.Id> reviewers,
-        Collection<Address> reviewersByEmail,
-        ReviewerState state,
-        @Nullable NotifyHandling notify,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify);
-  }
-
-  @AutoValue
-  public abstract static class Result {
-    public abstract ImmutableList<PatchSetApproval> addedReviewers();
-
-    public abstract ImmutableList<Account.Id> addedCCs();
-
-    static Builder builder() {
-      return new AutoValue_PostReviewersOp_Result.Builder();
-    }
-
-    @AutoValue.Builder
-    abstract static class Builder {
-      abstract Builder setAddedReviewers(ImmutableList<PatchSetApproval> addedReviewers);
-
-      abstract Builder setAddedCCs(ImmutableList<Account.Id> addedCCs);
-
-      abstract Result build();
-    }
-  }
-
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final ReviewerAdded reviewerAdded;
-  private final AccountCache accountCache;
-  private final ProjectCache projectCache;
-  private final AddReviewerSender.Factory addReviewerSenderFactory;
-  private final NotesMigration migration;
-  private final Provider<IdentifiedUser> user;
-  private final Provider<ReviewDb> dbProvider;
-  private final ChangeResource rsrc;
-  private final Set<Account.Id> reviewers;
-  private final Collection<Address> reviewersByEmail;
-  private final ReviewerState state;
-  private final NotifyHandling notify;
-  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
-
-  private List<PatchSetApproval> addedReviewers = new ArrayList<>();
-  private Collection<Account.Id> addedCCs = new ArrayList<>();
-  private Collection<Address> addedCCsByEmail = new ArrayList<>();
-  private PatchSet patchSet;
-  private Result opResult;
-
-  @Inject
-  PostReviewersOp(
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      ReviewerAdded reviewerAdded,
-      AccountCache accountCache,
-      ProjectCache projectCache,
-      AddReviewerSender.Factory addReviewerSenderFactory,
-      NotesMigration migration,
-      Provider<IdentifiedUser> user,
-      Provider<ReviewDb> dbProvider,
-      @Assisted ChangeResource rsrc,
-      @Assisted Set<Account.Id> reviewers,
-      @Assisted Collection<Address> reviewersByEmail,
-      @Assisted ReviewerState state,
-      @Assisted @Nullable NotifyHandling notify,
-      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.reviewerAdded = reviewerAdded;
-    this.accountCache = accountCache;
-    this.projectCache = projectCache;
-    this.addReviewerSenderFactory = addReviewerSenderFactory;
-    this.migration = migration;
-    this.user = user;
-    this.dbProvider = dbProvider;
-
-    this.rsrc = rsrc;
-    this.reviewers = reviewers;
-    this.reviewersByEmail = reviewersByEmail;
-    this.state = state;
-    this.notify = notify;
-    this.accountsToNotify = accountsToNotify;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx)
-      throws RestApiException, OrmException, IOException {
-    if (!reviewers.isEmpty()) {
-      if (migration.readChanges() && state == CC) {
-        addedCCs =
-            approvalsUtil.addCcs(
-                ctx.getNotes(), ctx.getUpdate(ctx.getChange().currentPatchSetId()), reviewers);
-        if (addedCCs.isEmpty()) {
-          return false;
-        }
-      } else {
-        addedReviewers =
-            approvalsUtil.addReviewers(
-                ctx.getDb(),
-                ctx.getNotes(),
-                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
-                projectCache
-                    .checkedGet(rsrc.getProject())
-                    .getLabelTypes(rsrc.getChange().getDest(), ctx.getUser()),
-                rsrc.getChange(),
-                reviewers);
-        if (addedReviewers.isEmpty()) {
-          return false;
-        }
-      }
-    }
-
-    for (Address a : reviewersByEmail) {
-      ctx.getUpdate(ctx.getChange().currentPatchSetId())
-          .putReviewerByEmail(a, ReviewerStateInternal.fromReviewerState(state));
-    }
-
-    patchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
-    return true;
-  }
-
-  @Override
-  public void postUpdate(Context ctx) throws Exception {
-    opResult =
-        Result.builder()
-            .setAddedReviewers(ImmutableList.copyOf(addedReviewers))
-            .setAddedCCs(ImmutableList.copyOf(addedCCs))
-            .build();
-    emailReviewers(
-        rsrc.getChange(),
-        Lists.transform(addedReviewers, r -> r.getAccountId()),
-        addedCCs == null ? ImmutableList.of() : addedCCs,
-        reviewersByEmail,
-        addedCCsByEmail,
-        notify,
-        accountsToNotify);
-    if (!addedReviewers.isEmpty()) {
-      List<Account> reviewers =
-          addedReviewers
-              .stream()
-              .map(r -> accountCache.get(r.getAccountId()).getAccount())
-              .collect(toList());
-      reviewerAdded.fire(rsrc.getChange(), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
-    }
-  }
-
-  public void emailReviewers(
-      Change change,
-      Collection<Account.Id> added,
-      Collection<Account.Id> copied,
-      Collection<Address> addedByEmail,
-      Collection<Address> copiedByEmail,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    if (added.isEmpty() && copied.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
-      return;
-    }
-
-    // Email the reviewers
-    //
-    // The user knows they added themselves, don't bother emailing them.
-    List<Account.Id> toMail = Lists.newArrayListWithCapacity(added.size());
-    Account.Id userId = user.get().getAccountId();
-    for (Account.Id id : added) {
-      if (!id.equals(userId)) {
-        toMail.add(id);
-      }
-    }
-    List<Account.Id> toCopy = Lists.newArrayListWithCapacity(copied.size());
-    for (Account.Id id : copied) {
-      if (!id.equals(userId)) {
-        toCopy.add(id);
-      }
-    }
-    if (toMail.isEmpty() && toCopy.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
-      return;
-    }
-
-    try {
-      AddReviewerSender cm = addReviewerSenderFactory.create(change.getProject(), change.getId());
-      // Default to silent operation on WIP changes.
-      NotifyHandling defaultNotifyHandling =
-          change.isWorkInProgress() ? NotifyHandling.NONE : NotifyHandling.ALL;
-      cm.setNotify(MoreObjects.firstNonNull(notify, defaultNotifyHandling));
-      cm.setAccountsToNotify(accountsToNotify);
-      cm.setFrom(userId);
-      cm.addReviewers(toMail);
-      cm.addReviewersByEmail(addedByEmail);
-      cm.addExtraCC(toCopy);
-      cm.addExtraCCByEmail(copiedByEmail);
-      cm.send();
-    } catch (Exception err) {
-      log.error("Cannot send email to new reviewers of change " + change.getId(), err);
-    }
-  }
-
-  public Result getResult() {
-    checkState(opResult != null, "Batch update wasn't executed yet");
-    return opResult;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
deleted file mode 100644
index 3c83f81..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
+++ /dev/null
@@ -1,191 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.PreconditionFailedException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.change.LimitedByteArrayOutputStream.LimitExceededException;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.MergeOp;
-import com.google.gerrit.server.git.MergeOpRepoManager;
-import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.util.Collection;
-import org.apache.commons.compress.archivers.ArchiveOutputStream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.storage.pack.PackConfig;
-import org.eclipse.jgit.transport.BundleWriter;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.kohsuke.args4j.Option;
-
-@Singleton
-public class PreviewSubmit implements RestReadView<RevisionResource> {
-  private static final int MAX_DEFAULT_BUNDLE_SIZE = 100 * 1024 * 1024;
-
-  private final Provider<ReviewDb> dbProvider;
-  private final Provider<MergeOp> mergeOpProvider;
-  private final AllowedFormats allowedFormats;
-  private int maxBundleSize;
-  private String format;
-
-  @Option(name = "--format")
-  public void setFormat(String f) {
-    this.format = f;
-  }
-
-  @Inject
-  PreviewSubmit(
-      Provider<ReviewDb> dbProvider,
-      Provider<MergeOp> mergeOpProvider,
-      AllowedFormats allowedFormats,
-      @GerritServerConfig Config cfg) {
-    this.dbProvider = dbProvider;
-    this.mergeOpProvider = mergeOpProvider;
-    this.allowedFormats = allowedFormats;
-    this.maxBundleSize = cfg.getInt("download", "maxBundleSize", MAX_DEFAULT_BUNDLE_SIZE);
-  }
-
-  @Override
-  public BinaryResult apply(RevisionResource rsrc)
-      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    if (Strings.isNullOrEmpty(format)) {
-      throw new BadRequestException("format is not specified");
-    }
-    ArchiveFormat f = allowedFormats.extensions.get("." + format);
-    if (f == null && format.equals("tgz")) {
-      // Always allow tgz, even when the allowedFormats doesn't contain it.
-      // Then we allow at least one format even if the list of allowed
-      // formats is empty.
-      f = ArchiveFormat.TGZ;
-    }
-    if (f == null) {
-      throw new BadRequestException("unknown archive format");
-    }
-
-    Change change = rsrc.getChange();
-    if (!change.getStatus().isOpen()) {
-      throw new PreconditionFailedException("change is " + ChangeUtil.status(change));
-    }
-    if (!rsrc.getUser().isIdentifiedUser()) {
-      throw new MethodNotAllowedException("Anonymous users cannot submit");
-    }
-
-    return getBundles(rsrc, f);
-  }
-
-  private BinaryResult getBundles(RevisionResource rsrc, ArchiveFormat f)
-      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    ReviewDb db = dbProvider.get();
-    IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
-    Change change = rsrc.getChange();
-
-    @SuppressWarnings("resource") // Returned BinaryResult takes ownership and handles closing.
-    MergeOp op = mergeOpProvider.get();
-    try {
-      op.merge(db, change, caller, false, new SubmitInput(), true);
-      BinaryResult bin = new SubmitPreviewResult(op, f, maxBundleSize);
-      bin.disableGzip()
-          .setContentType(f.getMimeType())
-          .setAttachmentName("submit-preview-" + change.getChangeId() + "." + format);
-      return bin;
-    } catch (OrmException
-        | RestApiException
-        | UpdateException
-        | IOException
-        | ConfigInvalidException
-        | RuntimeException
-        | PermissionBackendException e) {
-      op.close();
-      throw e;
-    }
-  }
-
-  private static class SubmitPreviewResult extends BinaryResult {
-
-    private final MergeOp mergeOp;
-    private final ArchiveFormat archiveFormat;
-    private final int maxBundleSize;
-
-    private SubmitPreviewResult(MergeOp mergeOp, ArchiveFormat archiveFormat, int maxBundleSize) {
-      this.mergeOp = mergeOp;
-      this.archiveFormat = archiveFormat;
-      this.maxBundleSize = maxBundleSize;
-    }
-
-    @Override
-    public void writeTo(OutputStream out) throws IOException {
-      try (ArchiveOutputStream aos = archiveFormat.createArchiveOutputStream(out)) {
-        MergeOpRepoManager orm = mergeOp.getMergeOpRepoManager();
-        for (Project.NameKey p : mergeOp.getAllProjects()) {
-          OpenRepo or = orm.getRepo(p);
-          BundleWriter bw = new BundleWriter(or.getCodeReviewRevWalk().getObjectReader());
-          bw.setObjectCountCallback(null);
-          bw.setPackConfig(new PackConfig(or.getRepo()));
-          Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates().values();
-          for (ReceiveCommand r : refs) {
-            bw.include(r.getRefName(), r.getNewId());
-            ObjectId oldId = r.getOldId();
-            if (!oldId.equals(ObjectId.zeroId())
-                // Probably the client doesn't already have NoteDb data.
-                && !RefNames.isNoteDbMetaRef(r.getRefName())) {
-              bw.assume(or.getCodeReviewRevWalk().parseCommit(oldId));
-            }
-          }
-          LimitedByteArrayOutputStream bos = new LimitedByteArrayOutputStream(maxBundleSize, 1024);
-          bw.writeBundle(NullProgressMonitor.INSTANCE, bos);
-          // This naming scheme cannot produce directory/file conflicts
-          // as no projects contains ".git/":
-          String path = p.get() + ".git";
-          archiveFormat.putEntry(aos, path, bos.toByteArray());
-        }
-      } catch (LimitExceededException e) {
-        throw new NotImplementedException("The bundle is too big to generate at the server");
-      } catch (NoSuchProjectException e) {
-        throw new IOException(e);
-      }
-    }
-
-    @Override
-    public void close() throws IOException {
-      mergeOp.close();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
deleted file mode 100644
index c4e2f3b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
+++ /dev/null
@@ -1,117 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gerrit.server.project.ContributorAgreementsChecker;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Optional;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class PublishChangeEdit
-    implements ChildCollection<ChangeResource, ChangeEditResource>, AcceptsPost<ChangeResource> {
-
-  private final Publish publish;
-
-  @Inject
-  PublishChangeEdit(Publish publish) {
-    this.publish = publish;
-  }
-
-  @Override
-  public DynamicMap<RestView<ChangeEditResource>> views() {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public RestView<ChangeResource> list() {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public ChangeEditResource parse(ChangeResource parent, IdString id) {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public Publish post(ChangeResource parent) throws RestApiException {
-    return publish;
-  }
-
-  @Singleton
-  public static class Publish
-      extends RetryingRestModifyView<ChangeResource, PublishChangeEditInput, Response<?>> {
-
-    private final ChangeEditUtil editUtil;
-    private final NotifyUtil notifyUtil;
-    private final ContributorAgreementsChecker contributorAgreementsChecker;
-
-    @Inject
-    Publish(
-        RetryHelper retryHelper,
-        ChangeEditUtil editUtil,
-        NotifyUtil notifyUtil,
-        ContributorAgreementsChecker contributorAgreementsChecker) {
-      super(retryHelper);
-      this.editUtil = editUtil;
-      this.notifyUtil = notifyUtil;
-      this.contributorAgreementsChecker = contributorAgreementsChecker;
-    }
-
-    @Override
-    protected Response<?> applyImpl(
-        BatchUpdate.Factory updateFactory, ChangeResource rsrc, PublishChangeEditInput in)
-        throws IOException, OrmException, RestApiException, UpdateException, ConfigInvalidException,
-            NoSuchProjectException {
-      contributorAgreementsChecker.check(rsrc.getProject(), rsrc.getUser());
-      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
-      if (!edit.isPresent()) {
-        throw new ResourceConflictException(
-            String.format("no edit exists for change %s", rsrc.getChange().getChangeId()));
-      }
-      if (in == null) {
-        in = new PublishChangeEditInput();
-      }
-      editUtil.publish(
-          updateFactory,
-          rsrc.getNotes(),
-          rsrc.getUser(),
-          edit.get(),
-          in.notify,
-          notifyUtil.resolveAccounts(in.notifyDetails));
-      return Response.none();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
deleted file mode 100644
index d53c85c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
+++ /dev/null
@@ -1,125 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.AccountsCollection;
-import com.google.gerrit.server.change.PostReviewers.Addition;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class PutAssignee extends RetryingRestModifyView<ChangeResource, AssigneeInput, AccountInfo>
-    implements UiAction<ChangeResource> {
-
-  private final AccountsCollection accounts;
-  private final SetAssigneeOp.Factory assigneeFactory;
-  private final Provider<ReviewDb> db;
-  private final PostReviewers postReviewers;
-  private final AccountLoader.Factory accountLoaderFactory;
-
-  @Inject
-  PutAssignee(
-      AccountsCollection accounts,
-      SetAssigneeOp.Factory assigneeFactory,
-      RetryHelper retryHelper,
-      Provider<ReviewDb> db,
-      PostReviewers postReviewers,
-      AccountLoader.Factory accountLoaderFactory) {
-    super(retryHelper);
-    this.accounts = accounts;
-    this.assigneeFactory = assigneeFactory;
-    this.db = db;
-    this.postReviewers = postReviewers;
-    this.accountLoaderFactory = accountLoaderFactory;
-  }
-
-  @Override
-  protected AccountInfo applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, AssigneeInput input)
-      throws RestApiException, UpdateException, OrmException, IOException,
-          PermissionBackendException, ConfigInvalidException {
-    rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
-
-    input.assignee = Strings.nullToEmpty(input.assignee).trim();
-    if (input.assignee.isEmpty()) {
-      throw new BadRequestException("missing assignee field");
-    }
-
-    IdentifiedUser assignee = accounts.parse(input.assignee);
-    if (!assignee.getAccount().isActive()) {
-      throw new UnprocessableEntityException(input.assignee + " is not active");
-    }
-    try {
-      rsrc.permissions().database(db).user(assignee).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new AuthException("read not permitted for " + input.assignee);
-    }
-
-    try (BatchUpdate bu =
-        updateFactory.create(
-            db.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      SetAssigneeOp op = assigneeFactory.create(assignee);
-      bu.addOp(rsrc.getId(), op);
-
-      PostReviewers.Addition reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
-      bu.addOp(rsrc.getId(), reviewersAddition.op);
-
-      bu.execute();
-      return accountLoaderFactory.create(true).fillOne(assignee.getAccountId());
-    }
-  }
-
-  private Addition addAssigneeAsCC(ChangeResource rsrc, String assignee)
-      throws OrmException, IOException, PermissionBackendException, ConfigInvalidException {
-    AddReviewerInput reviewerInput = new AddReviewerInput();
-    reviewerInput.reviewer = assignee;
-    reviewerInput.state = ReviewerState.CC;
-    reviewerInput.confirmed = true;
-    reviewerInput.notify = NotifyHandling.NONE;
-    return postReviewers.prepareApplication(rsrc, reviewerInput, false);
-  }
-
-  @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Edit Assignee")
-        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_ASSIGNEE));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
deleted file mode 100644
index 4c9cf23..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
-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;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.Collections;
-
-@Singleton
-public class PutDescription
-    extends RetryingRestModifyView<RevisionResource, PutDescription.Input, 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,
-      ChangeMessagesUtil cmUtil,
-      RetryHelper retryHelper,
-      PatchSetUtil psUtil) {
-    super(retryHelper);
-    this.dbProvider = dbProvider;
-    this.cmUtil = cmUtil;
-    this.psUtil = psUtil;
-  }
-
-  @Override
-  protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, RevisionResource rsrc, Input input)
-      throws UpdateException, RestApiException, PermissionBackendException {
-    rsrc.permissions().check(ChangePermission.EDIT_DESCRIPTION);
-
-    Op op = new Op(input != null ? input : new Input(), rsrc.getPatchSet().getId());
-    try (BatchUpdate u =
-        updateFactory.create(
-            dbProvider.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      u.addOp(rsrc.getChange().getId(), op);
-      u.execute();
-    }
-    return Strings.isNullOrEmpty(op.newDescription)
-        ? Response.none()
-        : Response.ok(op.newDescription);
-  }
-
-  private class Op implements BatchUpdateOp {
-    private final Input input;
-    private final PatchSet.Id psId;
-
-    private String oldDescription;
-    private String newDescription;
-
-    Op(Input input, PatchSet.Id psId) {
-      this.input = input;
-      this.psId = psId;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException {
-      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-      ChangeUpdate update = ctx.getUpdate(psId);
-      newDescription = Strings.nullToEmpty(input.description);
-      oldDescription = Strings.nullToEmpty(ps.getDescription());
-      if (oldDescription.equals(newDescription)) {
-        return false;
-      }
-      String summary;
-      if (oldDescription.isEmpty()) {
-        summary = "Description set to \"" + newDescription + "\"";
-      } else if (newDescription.isEmpty()) {
-        summary = "Description \"" + oldDescription + "\" removed";
-      } else {
-        summary = "Description changed to \"" + newDescription + "\"";
-      }
-
-      ps.setDescription(newDescription);
-      update.setPsDescription(newDescription);
-
-      ctx.getDb().patchSets().update(Collections.singleton(ps));
-
-      ChangeMessage cmsg =
-          ChangeMessagesUtil.newMessage(
-              psId, ctx.getUser(), ctx.getWhen(), summary, ChangeMessagesUtil.TAG_SET_DESCRIPTION);
-      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
-      return true;
-    }
-  }
-
-  @Override
-  public UiAction.Description getDescription(RevisionResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Edit Description")
-        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_DESCRIPTION));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
deleted file mode 100644
index c5693c6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
+++ /dev/null
@@ -1,173 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.sql.Timestamp;
-import java.util.Collections;
-import java.util.Optional;
-
-@Singleton
-public class PutDraftComment
-    extends RetryingRestModifyView<DraftCommentResource, DraftInput, Response<CommentInfo>> {
-
-  private final Provider<ReviewDb> db;
-  private final DeleteDraftComment delete;
-  private final CommentsUtil commentsUtil;
-  private final PatchSetUtil psUtil;
-  private final Provider<CommentJson> commentJson;
-  private final PatchListCache patchListCache;
-
-  @Inject
-  PutDraftComment(
-      Provider<ReviewDb> db,
-      DeleteDraftComment delete,
-      CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      RetryHelper retryHelper,
-      Provider<CommentJson> commentJson,
-      PatchListCache patchListCache) {
-    super(retryHelper);
-    this.db = db;
-    this.delete = delete;
-    this.commentsUtil = commentsUtil;
-    this.psUtil = psUtil;
-    this.commentJson = commentJson;
-    this.patchListCache = patchListCache;
-  }
-
-  @Override
-  protected Response<CommentInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, DraftCommentResource rsrc, DraftInput in)
-      throws RestApiException, UpdateException, OrmException {
-    if (in == null || in.message == null || in.message.trim().isEmpty()) {
-      return delete.applyImpl(updateFactory, rsrc, null);
-    } else if (in.id != null && !rsrc.getId().equals(in.id)) {
-      throw new BadRequestException("id must match URL");
-    } else if (in.line != null && in.line < 0) {
-      throw new BadRequestException("line must be >= 0");
-    } else if (in.line != null && in.range != null && in.line != in.range.endLine) {
-      throw new BadRequestException("range endLine must be on the same line as the comment");
-    }
-
-    try (BatchUpdate bu =
-        updateFactory.create(
-            db.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getComment().key, in);
-      bu.addOp(rsrc.getChange().getId(), op);
-      bu.execute();
-      return Response.ok(
-          commentJson.get().setFillAccounts(false).newCommentFormatter().format(op.comment));
-    }
-  }
-
-  private class Op implements BatchUpdateOp {
-    private final Comment.Key key;
-    private final DraftInput in;
-
-    private Comment comment;
-
-    private Op(Comment.Key key, DraftInput in) {
-      this.key = key;
-      this.in = in;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException, OrmException {
-      Optional<Comment> maybeComment =
-          commentsUtil.getDraft(ctx.getDb(), ctx.getNotes(), ctx.getIdentifiedUser(), key);
-      if (!maybeComment.isPresent()) {
-        // Disappeared out from under us. Can't easily fall back to insert,
-        // because the input might be missing required fields. Just give up.
-        throw new ResourceNotFoundException("comment not found: " + key);
-      }
-      Comment origComment = maybeComment.get();
-      comment = new Comment(origComment);
-      // Copy constructor preserved old real author; replace with current real
-      // user.
-      ctx.getUser().updateRealAccountId(comment::setRealAuthor);
-
-      PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), origComment.key.patchSetId);
-      ChangeUpdate update = ctx.getUpdate(psId);
-
-      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-      if (ps == null) {
-        throw new ResourceNotFoundException("patch set not found: " + psId);
-      }
-      if (in.path != null && !in.path.equals(origComment.key.filename)) {
-        // Updating the path alters the primary key, which isn't possible.
-        // Delete then recreate the comment instead of an update.
-
-        commentsUtil.deleteComments(ctx.getDb(), update, Collections.singleton(origComment));
-        comment.key.filename = in.path;
-      }
-      setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
-      commentsUtil.putComments(
-          ctx.getDb(),
-          update,
-          Status.DRAFT,
-          Collections.singleton(update(comment, in, ctx.getWhen())));
-      ctx.dontBumpLastUpdatedOn();
-      return true;
-    }
-  }
-
-  private static Comment update(Comment e, DraftInput in, Timestamp when) {
-    if (in.side != null) {
-      e.side = in.side();
-    }
-    if (in.inReplyTo != null) {
-      e.parentUuid = Url.decode(in.inReplyTo);
-    }
-    e.setLineNbrAndRange(in.line, in.range);
-    e.message = in.message.trim();
-    e.writtenOn = when;
-    if (in.tag != null) {
-      // TODO(dborowitz): Can we support changing tags via PUT?
-      e.tag = in.tag;
-    }
-    if (in.unresolved != null) {
-      e.unresolved = in.unresolved;
-    }
-    return e;
-  }
-}
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
deleted file mode 100644
index b79ff85..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutMessage.java
+++ /dev/null
@@ -1,222 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.edit.UnchangedCommitMessageException;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.CommitMessageUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.List;
-import java.util.TimeZone;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-@Singleton
-public class PutMessage
-    extends RetryingRestModifyView<ChangeResource, CommitMessageInput, Response<?>> {
-
-  private final GitRepositoryManager repositoryManager;
-  private final Provider<CurrentUser> currentUserProvider;
-  private final Provider<ReviewDb> db;
-  private final TimeZone tz;
-  private final PatchSetInserter.Factory psInserterFactory;
-  private final PermissionBackend permissionBackend;
-  private final PatchSetUtil psUtil;
-  private final NotifyUtil notifyUtil;
-  private final ProjectCache projectCache;
-  private final ChangeControl.GenericFactory changeControlFactory;
-
-  @Inject
-  PutMessage(
-      RetryHelper retryHelper,
-      GitRepositoryManager repositoryManager,
-      Provider<CurrentUser> currentUserProvider,
-      Provider<ReviewDb> db,
-      PatchSetInserter.Factory psInserterFactory,
-      PermissionBackend permissionBackend,
-      @GerritPersonIdent PersonIdent gerritIdent,
-      PatchSetUtil psUtil,
-      NotifyUtil notifyUtil,
-      ProjectCache projectCache,
-      ChangeControl.GenericFactory changeControlFactory) {
-    super(retryHelper);
-    this.repositoryManager = repositoryManager;
-    this.currentUserProvider = currentUserProvider;
-    this.db = db;
-    this.psInserterFactory = psInserterFactory;
-    this.tz = gerritIdent.getTimeZone();
-    this.permissionBackend = permissionBackend;
-    this.psUtil = psUtil;
-    this.notifyUtil = notifyUtil;
-    this.projectCache = projectCache;
-    this.changeControlFactory = changeControlFactory;
-  }
-
-  @Override
-  protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource resource, CommitMessageInput input)
-      throws IOException, UnchangedCommitMessageException, RestApiException, UpdateException,
-          PermissionBackendException, OrmException, ConfigInvalidException {
-    PatchSet ps = psUtil.current(db.get(), resource.getNotes());
-    if (ps == null) {
-      throw new ResourceConflictException("current revision is missing");
-    } else if (!changeControlFactory
-        .controlFor(resource.getNotes(), resource.getUser())
-        .isVisible(db.get())) {
-      throw new AuthException("current revision not accessible");
-    }
-
-    if (input == null) {
-      throw new BadRequestException("input cannot be null");
-    }
-    String sanitizedCommitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(input.message);
-
-    ensureCanEditCommitMessage(resource.getNotes());
-    ensureChangeIdIsCorrect(
-        projectCache.checkedGet(resource.getProject()).isRequireChangeID(),
-        resource.getChange().getKey().get(),
-        sanitizedCommitMessage);
-
-    NotifyHandling notify = input.notify;
-    if (notify == null) {
-      notify = resource.getChange().isWorkInProgress() ? NotifyHandling.OWNER : NotifyHandling.ALL;
-    }
-
-    try (Repository repository = repositoryManager.openRepository(resource.getProject());
-        RevWalk revWalk = new RevWalk(repository);
-        ObjectInserter objectInserter = repository.newObjectInserter()) {
-      RevCommit patchSetCommit = revWalk.parseCommit(ObjectId.fromString(ps.getRevision().get()));
-
-      String currentCommitMessage = patchSetCommit.getFullMessage();
-      if (input.message.equals(currentCommitMessage)) {
-        throw new ResourceConflictException("new and existing commit message are the same");
-      }
-
-      Timestamp ts = TimeUtil.nowTs();
-      try (BatchUpdate bu =
-          updateFactory.create(
-              db.get(), resource.getChange().getProject(), currentUserProvider.get(), ts)) {
-        // Ensure that BatchUpdate will update the same repo
-        bu.setRepository(repository, new RevWalk(objectInserter.newReader()), objectInserter);
-
-        PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.getId());
-        ObjectId newCommit =
-            createCommit(objectInserter, patchSetCommit, sanitizedCommitMessage, ts);
-        PatchSetInserter inserter = psInserterFactory.create(resource.getNotes(), psId, newCommit);
-        inserter.setMessage(
-            String.format("Patch Set %s: Commit message was updated.", psId.getId()));
-        inserter.setDescription("Edit commit message");
-        inserter.setNotify(notify);
-        inserter.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
-        bu.addOp(resource.getChange().getId(), inserter);
-        bu.execute();
-      }
-    }
-    return Response.ok("ok");
-  }
-
-  private ObjectId createCommit(
-      ObjectInserter objectInserter,
-      RevCommit basePatchSetCommit,
-      String commitMessage,
-      Timestamp timestamp)
-      throws IOException {
-    CommitBuilder builder = new CommitBuilder();
-    builder.setTreeId(basePatchSetCommit.getTree());
-    builder.setParentIds(basePatchSetCommit.getParents());
-    builder.setAuthor(basePatchSetCommit.getAuthorIdent());
-    builder.setCommitter(
-        currentUserProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, tz));
-    builder.setMessage(commitMessage);
-    ObjectId newCommitId = objectInserter.insert(builder);
-    objectInserter.flush();
-    return newCommitId;
-  }
-
-  private void ensureCanEditCommitMessage(ChangeNotes changeNotes)
-      throws AuthException, PermissionBackendException {
-    if (!currentUserProvider.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-    try {
-      permissionBackend
-          .user(currentUserProvider.get())
-          .database(db.get())
-          .change(changeNotes)
-          .check(ChangePermission.ADD_PATCH_SET);
-    } catch (AuthException denied) {
-      throw new AuthException("modifying commit message not permitted", denied);
-    }
-  }
-
-  private static void ensureChangeIdIsCorrect(
-      boolean requireChangeId, String currentChangeId, String newCommitMessage)
-      throws ResourceConflictException, BadRequestException {
-    RevCommit revCommit =
-        RevCommit.parse(
-            Constants.encode("tree " + ObjectId.zeroId().name() + "\n\n" + newCommitMessage));
-
-    // Check that the commit message without footers is not empty
-    CommitMessageUtil.checkAndSanitizeCommitMessage(revCommit.getShortMessage());
-
-    List<String> changeIdFooters = revCommit.getFooterLines(FooterConstants.CHANGE_ID);
-    if (requireChangeId && changeIdFooters.isEmpty()) {
-      throw new ResourceConflictException("missing Change-Id footer");
-    }
-    if (!changeIdFooters.isEmpty() && !changeIdFooters.get(0).equals(currentChangeId)) {
-      throw new ResourceConflictException("wrong Change-Id footer");
-    }
-    if (changeIdFooters.size() > 1) {
-      throw new ResourceConflictException("multiple Change-Id footers");
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
deleted file mode 100644
index 8b5608b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
+++ /dev/null
@@ -1,143 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
-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;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.change.PutTopic.Input;
-import com.google.gerrit.server.extensions.events.TopicEdited;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class PutTopic extends RetryingRestModifyView<ChangeResource, Input, 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,
-      ChangeMessagesUtil cmUtil,
-      RetryHelper retryHelper,
-      TopicEdited topicEdited) {
-    super(retryHelper);
-    this.dbProvider = dbProvider;
-    this.cmUtil = cmUtil;
-    this.topicEdited = topicEdited;
-  }
-
-  @Override
-  protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource req, Input input)
-      throws UpdateException, RestApiException, PermissionBackendException {
-    req.permissions().check(ChangePermission.EDIT_TOPIC_NAME);
-
-    if (input != null
-        && input.topic != null
-        && input.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
-      throw new BadRequestException(
-          String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
-    }
-
-    Op op = new Op(input != null ? input : new Input());
-    try (BatchUpdate u =
-        updateFactory.create(
-            dbProvider.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
-      u.addOp(req.getId(), op);
-      u.execute();
-    }
-    return Strings.isNullOrEmpty(op.newTopicName) ? Response.none() : Response.ok(op.newTopicName);
-  }
-
-  private class Op implements BatchUpdateOp {
-    private final Input input;
-
-    private Change change;
-    private String oldTopicName;
-    private String newTopicName;
-
-    Op(Input input) {
-      this.input = input;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException {
-      change = ctx.getChange();
-      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-      newTopicName = Strings.nullToEmpty(input.topic);
-      oldTopicName = Strings.nullToEmpty(change.getTopic());
-      if (oldTopicName.equals(newTopicName)) {
-        return false;
-      }
-      String summary;
-      if (oldTopicName.isEmpty()) {
-        summary = "Topic set to " + newTopicName;
-      } else if (newTopicName.isEmpty()) {
-        summary = "Topic " + oldTopicName + " removed";
-      } else {
-        summary = String.format("Topic changed from %s to %s", oldTopicName, newTopicName);
-      }
-      change.setTopic(Strings.emptyToNull(newTopicName));
-      update.setTopic(change.getTopic());
-
-      ChangeMessage cmsg =
-          ChangeMessagesUtil.newMessage(ctx, summary, ChangeMessagesUtil.TAG_SET_TOPIC);
-      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
-      return true;
-    }
-
-    @Override
-    public void postUpdate(Context ctx) {
-      if (change != null) {
-        topicEdited.fire(change, ctx.getAccount(), oldTopicName, ctx.getWhen());
-      }
-    }
-  }
-
-  @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Edit Topic")
-        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_TOPIC_NAME));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
deleted file mode 100644
index 7d92973..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
+++ /dev/null
@@ -1,261 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.api.changes.RebaseInput;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.conditions.BooleanCondition;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.client.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.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;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class Rebase extends RetryingRestModifyView<RevisionResource, RebaseInput, ChangeInfo>
-    implements RestModifyView<RevisionResource, RebaseInput>, UiAction<RevisionResource> {
-  private static final Logger log = LoggerFactory.getLogger(Rebase.class);
-  private static final ImmutableSet<ListChangesOption> OPTIONS =
-      Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
-
-  private final GitRepositoryManager repoManager;
-  private final RebaseChangeOp.Factory rebaseFactory;
-  private final RebaseUtil rebaseUtil;
-  private final ChangeJson.Factory json;
-  private final Provider<ReviewDb> dbProvider;
-  private final Provider<CurrentUser> userProvider;
-  private final ChangeControl.GenericFactory changeControlFactory;
-
-  @Inject
-  public Rebase(
-      RetryHelper retryHelper,
-      GitRepositoryManager repoManager,
-      RebaseChangeOp.Factory rebaseFactory,
-      RebaseUtil rebaseUtil,
-      ChangeJson.Factory json,
-      Provider<ReviewDb> dbProvider,
-      Provider<CurrentUser> userProvider,
-      ChangeControl.GenericFactory changeControlFactory) {
-    super(retryHelper);
-    this.repoManager = repoManager;
-    this.rebaseFactory = rebaseFactory;
-    this.rebaseUtil = rebaseUtil;
-    this.json = json;
-    this.dbProvider = dbProvider;
-    this.userProvider = userProvider;
-    this.changeControlFactory = changeControlFactory;
-  }
-
-  @Override
-  protected ChangeInfo applyImpl(
-      BatchUpdate.Factory updateFactory, RevisionResource rsrc, RebaseInput input)
-      throws EmailException, OrmException, UpdateException, RestApiException, IOException,
-          NoSuchChangeException, PermissionBackendException {
-    rsrc.permissions().database(dbProvider).check(ChangePermission.REBASE);
-
-    Change change = rsrc.getChange();
-    try (Repository repo = repoManager.openRepository(change.getProject());
-        ObjectInserter oi = repo.newObjectInserter();
-        ObjectReader reader = oi.newReader();
-        RevWalk rw = new RevWalk(reader);
-        BatchUpdate bu =
-            updateFactory.create(
-                dbProvider.get(), change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      if (!change.getStatus().isOpen()) {
-        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-      } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
-        throw new ResourceConflictException(
-            "cannot rebase merge commits or commit with no ancestor");
-      }
-      bu.setRepository(repo, rw, oi);
-      bu.addOp(
-          change.getId(),
-          rebaseFactory
-              .create(rsrc.getNotes(), rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
-              .setForceContentMerge(true)
-              .setFireRevisionCreated(true));
-      bu.execute();
-    }
-    return json.create(OPTIONS).format(change.getProject(), change.getId());
-  }
-
-  private ObjectId findBaseRev(
-      Repository repo, RevWalk rw, RevisionResource rsrc, RebaseInput input)
-      throws RestApiException, OrmException, IOException, NoSuchChangeException {
-    Branch.NameKey destRefKey = rsrc.getChange().getDest();
-    if (input == null || input.base == null) {
-      return rebaseUtil.findBaseRevision(rsrc.getPatchSet(), destRefKey, repo, rw);
-    }
-
-    Change change = rsrc.getChange();
-    String str = input.base.trim();
-    if (str.equals("")) {
-      // Remove existing dependency to other patch set.
-      Ref destRef = repo.exactRef(destRefKey.get());
-      if (destRef == null) {
-        throw new ResourceConflictException(
-            "can't rebase onto tip of branch " + destRefKey.get() + "; branch doesn't exist");
-      }
-      return destRef.getObjectId();
-    }
-
-    @SuppressWarnings("resource")
-    ReviewDb db = dbProvider.get();
-    Base base = rebaseUtil.parseBase(rsrc, str);
-    if (base == null) {
-      throw new ResourceConflictException("base revision is missing: " + str);
-    }
-    PatchSet.Id baseId = base.patchSet().getId();
-    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())) {
-      throw new ResourceConflictException("cannot rebase change onto itself");
-    }
-
-    Change baseChange = base.notes().getChange();
-    if (!baseChange.getProject().equals(change.getProject())) {
-      throw new ResourceConflictException(
-          "base change is in wrong project: " + baseChange.getProject());
-    } else if (!baseChange.getDest().equals(change.getDest())) {
-      throw new ResourceConflictException(
-          "base change is targeting wrong branch: " + baseChange.getDest());
-    } else if (baseChange.getStatus() == Status.ABANDONED) {
-      throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
-    } else if (isMergedInto(rw, rsrc.getPatchSet(), base.patchSet())) {
-      throw new ResourceConflictException(
-          "base change "
-              + baseChange.getKey()
-              + " is a descendant of the current change - recursion not allowed");
-    }
-    return ObjectId.fromString(base.patchSet().getRevision().get());
-  }
-
-  private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
-    ObjectId baseId = ObjectId.fromString(base.getRevision().get());
-    ObjectId tipId = ObjectId.fromString(tip.getRevision().get());
-    return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
-  }
-
-  private boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
-    // Prevent rebase of exotic changes (merge commit, no ancestor).
-    RevCommit c = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
-    return c.getParentCount() == 1;
-  }
-
-  @Override
-  public UiAction.Description getDescription(RevisionResource resource) {
-    PatchSet patchSet = resource.getPatchSet();
-    Change change = resource.getChange();
-    Branch.NameKey dest = change.getDest();
-    boolean visible = change.getStatus().isOpen() && resource.isCurrent();
-    boolean enabled = false;
-
-    if (visible) {
-      try (Repository repo = repoManager.openRepository(dest.getParentKey());
-          RevWalk rw = new RevWalk(repo)) {
-        visible = hasOneParent(rw, resource.getPatchSet());
-        if (visible) {
-          enabled = rebaseUtil.canRebase(patchSet, dest, repo, rw);
-        }
-      } catch (IOException e) {
-        log.error("Failed to check if patch set can be rebased: " + resource.getPatchSet(), e);
-        visible = false;
-      }
-    }
-
-    BooleanCondition permissionCond =
-        resource.permissions().database(dbProvider).testCond(ChangePermission.REBASE);
-    return new UiAction.Description()
-        .setLabel("Rebase")
-        .setTitle("Rebase onto tip of branch or parent change")
-        .setVisible(and(visible, permissionCond))
-        .setEnabled(and(enabled, permissionCond));
-  }
-
-  public static class CurrentRevision
-      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) {
-      super(retryHelper);
-      this.psUtil = psUtil;
-      this.rebase = rebase;
-      this.changeControlFactory = changeControlFactory;
-    }
-
-    @Override
-    protected ChangeInfo applyImpl(
-        BatchUpdate.Factory updateFactory, ChangeResource rsrc, RebaseInput input)
-        throws EmailException, OrmException, UpdateException, RestApiException, IOException,
-            PermissionBackendException {
-      PatchSet ps = psUtil.current(rebase.dbProvider.get(), rsrc.getNotes());
-      if (ps == null) {
-        throw new ResourceConflictException("current revision is missing");
-      } else if (!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
deleted file mode 100644
index 38a695a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
+++ /dev/null
@@ -1,96 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.edit.ChangeEditModifier;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.lib.Repository;
-
-@Singleton
-public class RebaseChangeEdit
-    implements ChildCollection<ChangeResource, ChangeEditResource>, AcceptsPost<ChangeResource> {
-
-  private final Rebase rebase;
-
-  @Inject
-  RebaseChangeEdit(Rebase rebase) {
-    this.rebase = rebase;
-  }
-
-  @Override
-  public DynamicMap<RestView<ChangeEditResource>> views() {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public RestView<ChangeResource> list() {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public ChangeEditResource parse(ChangeResource parent, IdString id) {
-    throw new NotImplementedException();
-  }
-
-  @Override
-  public Rebase post(ChangeResource parent) throws RestApiException {
-    return rebase;
-  }
-
-  @Singleton
-  public static class Rebase implements RestModifyView<ChangeResource, Rebase.Input> {
-    public static class Input {}
-
-    private final GitRepositoryManager repositoryManager;
-    private final ChangeEditModifier editModifier;
-
-    @Inject
-    Rebase(GitRepositoryManager repositoryManager, ChangeEditModifier editModifier) {
-      this.repositoryManager = repositoryManager;
-      this.editModifier = editModifier;
-    }
-
-    @Override
-    public Response<?> apply(ChangeResource rsrc, Rebase.Input in)
-        throws AuthException, ResourceConflictException, IOException, OrmException,
-            PermissionBackendException {
-      Project.NameKey project = rsrc.getProject();
-      try (Repository repository = repositoryManager.openRepository(project)) {
-        editModifier.rebaseEdit(repository, rsrc.getNotes());
-      } catch (InvalidChangeOperationException e) {
-        throw new ResourceConflictException(e.getMessage());
-      }
-      return Response.none();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java
deleted file mode 100644
index fdb1cfc..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java
+++ /dev/null
@@ -1,208 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Utility methods related to rebasing changes. */
-public class RebaseUtil {
-  private static final Logger log = LoggerFactory.getLogger(RebaseUtil.class);
-
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final ChangeNotes.Factory notesFactory;
-  private final Provider<ReviewDb> dbProvider;
-  private final PatchSetUtil psUtil;
-
-  @Inject
-  RebaseUtil(
-      Provider<InternalChangeQuery> queryProvider,
-      ChangeNotes.Factory notesFactory,
-      Provider<ReviewDb> dbProvider,
-      PatchSetUtil psUtil) {
-    this.queryProvider = queryProvider;
-    this.notesFactory = notesFactory;
-    this.dbProvider = dbProvider;
-    this.psUtil = psUtil;
-  }
-
-  public boolean canRebase(PatchSet patchSet, Branch.NameKey dest, Repository git, RevWalk rw) {
-    try {
-      findBaseRevision(patchSet, dest, git, rw);
-      return true;
-    } catch (RestApiException e) {
-      return false;
-    } catch (OrmException | IOException e) {
-      log.warn(
-          String.format(
-              "Error checking if patch set %s on %s can be rebased", patchSet.getId(), dest),
-          e);
-      return false;
-    }
-  }
-
-  @AutoValue
-  abstract static class Base {
-    private static Base create(ChangeNotes notes, PatchSet ps) {
-      if (notes == null) {
-        return null;
-      }
-      return new AutoValue_RebaseUtil_Base(notes, ps);
-    }
-
-    abstract ChangeNotes notes();
-
-    abstract PatchSet patchSet();
-  }
-
-  Base parseBase(RevisionResource rsrc, String base) throws OrmException {
-    ReviewDb db = dbProvider.get();
-
-    // Try parsing the base as a ref string.
-    PatchSet.Id basePatchSetId = PatchSet.Id.fromRef(base);
-    if (basePatchSetId != null) {
-      Change.Id baseChangeId = basePatchSetId.getParentKey();
-      ChangeNotes baseNotes = notesFor(rsrc, baseChangeId);
-      if (baseNotes != null) {
-        return Base.create(
-            notesFor(rsrc, basePatchSetId.getParentKey()),
-            psUtil.get(db, baseNotes, basePatchSetId));
-      }
-    }
-
-    // Try parsing base as a change number (assume current patch set).
-    Integer baseChangeId = Ints.tryParse(base);
-    if (baseChangeId != null) {
-      ChangeNotes baseNotes = notesFor(rsrc, new Change.Id(baseChangeId));
-      if (baseNotes != null) {
-        return Base.create(baseNotes, psUtil.current(db, baseNotes));
-      }
-    }
-
-    // Try parsing as SHA-1.
-    Base ret = null;
-    for (ChangeData cd : queryProvider.get().byProjectCommit(rsrc.getProject(), base)) {
-      for (PatchSet ps : cd.patchSets()) {
-        if (!ps.getRevision().matches(base)) {
-          continue;
-        }
-        if (ret == null || ret.patchSet().getId().get() < ps.getId().get()) {
-          ret = Base.create(cd.notes(), ps);
-        }
-      }
-    }
-    return ret;
-  }
-
-  private ChangeNotes notesFor(RevisionResource rsrc, Change.Id id) throws OrmException {
-    if (rsrc.getChange().getId().equals(id)) {
-      return rsrc.getNotes();
-    }
-    return notesFactory.createChecked(dbProvider.get(), rsrc.getProject(), id);
-  }
-
-  /**
-   * Find the commit onto which a patch set should be rebased.
-   *
-   * <p>This is defined as the latest patch set of the change corresponding to this commit's parent,
-   * or the destination branch tip in the case where the parent's change is merged.
-   *
-   * @param patchSet patch set for which the new base commit should be found.
-   * @param destBranch the destination branch.
-   * @param git the repository.
-   * @param rw the RevWalk.
-   * @return the commit onto which the patch set should be rebased.
-   * @throws RestApiException if rebase is not possible.
-   * @throws IOException if accessing the repository fails.
-   * @throws OrmException if accessing the database fails.
-   */
-  ObjectId findBaseRevision(
-      PatchSet patchSet, Branch.NameKey destBranch, Repository git, RevWalk rw)
-      throws RestApiException, IOException, OrmException {
-    String baseRev = null;
-    RevCommit commit = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
-
-    if (commit.getParentCount() > 1) {
-      throw new UnprocessableEntityException("Cannot rebase a change with multiple parents.");
-    } else if (commit.getParentCount() == 0) {
-      throw new UnprocessableEntityException(
-          "Cannot rebase a change without any parents (is this the initial commit?).");
-    }
-
-    RevId parentRev = new RevId(commit.getParent(0).name());
-
-    CHANGES:
-    for (ChangeData cd : queryProvider.get().byBranchCommit(destBranch, parentRev.get())) {
-      for (PatchSet depPatchSet : cd.patchSets()) {
-        if (!depPatchSet.getRevision().equals(parentRev)) {
-          continue;
-        }
-        Change depChange = cd.change();
-        if (depChange.getStatus() == Status.ABANDONED) {
-          throw new ResourceConflictException(
-              "Cannot rebase a change with an abandoned parent: " + depChange.getKey());
-        }
-
-        if (depChange.getStatus().isOpen()) {
-          if (depPatchSet.getId().equals(depChange.currentPatchSetId())) {
-            throw new ResourceConflictException(
-                "Change is already based on the latest patch set of the dependent change.");
-          }
-          baseRev = cd.currentPatchSet().getRevision().get();
-        }
-        break CHANGES;
-      }
-    }
-
-    if (baseRev == null) {
-      // We are dependent on a merged PatchSet or have no PatchSet
-      // dependencies at all.
-      Ref destRef = git.getRefDatabase().exactRef(destBranch.get());
-      if (destRef == null) {
-        throw new UnprocessableEntityException(
-            "The destination branch does not exist: " + destBranch.get());
-      }
-      baseRev = destRef.getObjectId().getName();
-      if (baseRev.equals(parentRev.get())) {
-        throw new ResourceConflictException("Change is already up to date.");
-      }
-    }
-    return ObjectId.fromString(baseRev);
-  }
-}
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
deleted file mode 100644
index 682b45f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static java.util.stream.Collectors.joining;
-
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.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;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class Rebuild implements RestModifyView<ChangeResource, Input> {
-  public static class Input {}
-
-  private final Provider<ReviewDb> db;
-  private final NotesMigration migration;
-  private final ChangeRebuilder rebuilder;
-  private final ChangeBundleReader bundleReader;
-  private final CommentsUtil commentsUtil;
-  private final ChangeNotes.Factory notesFactory;
-
-  @Inject
-  Rebuild(
-      Provider<ReviewDb> db,
-      NotesMigration migration,
-      ChangeRebuilder rebuilder,
-      ChangeBundleReader bundleReader,
-      CommentsUtil commentsUtil,
-      ChangeNotes.Factory notesFactory) {
-    this.db = db;
-    this.migration = migration;
-    this.rebuilder = rebuilder;
-    this.bundleReader = bundleReader;
-    this.commentsUtil = commentsUtil;
-    this.notesFactory = notesFactory;
-  }
-
-  @Override
-  public BinaryResult apply(ChangeResource rsrc, Input input)
-      throws ResourceNotFoundException, IOException, OrmException, ConfigInvalidException {
-    if (!migration.commitChangeWrites()) {
-      throw new ResourceNotFoundException();
-    }
-    if (!migration.readChanges()) {
-      // ChangeBundle#fromNotes currently doesn't work if reading isn't enabled,
-      // so don't attempt a diff.
-      rebuild(rsrc);
-      return BinaryResult.create("Rebuilt change successfully");
-    }
-
-    // Not the same transaction as the rebuild, so may result in spurious diffs
-    // in the case of races. This should be easy enough to detect by rerunning.
-    ChangeBundle reviewDbBundle =
-        bundleReader.fromReviewDb(ReviewDbUtil.unwrapDb(db.get()), rsrc.getId());
-    rebuild(rsrc);
-    ChangeNotes notes = notesFactory.create(db.get(), rsrc.getChange().getProject(), rsrc.getId());
-    ChangeBundle noteDbBundle = ChangeBundle.fromNotes(commentsUtil, notes);
-    List<String> diffs = reviewDbBundle.differencesFrom(noteDbBundle);
-    if (diffs.isEmpty()) {
-      return BinaryResult.create("No differences between ReviewDb and NoteDb");
-    }
-    return BinaryResult.create(
-        diffs.stream().collect(joining("\n", "Differences between ReviewDb and NoteDb:\n", "\n")));
-  }
-
-  private void rebuild(ChangeResource rsrc)
-      throws ResourceNotFoundException, OrmException, IOException {
-    try {
-      rebuilder.rebuild(db.get(), rsrc.getId());
-    } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(IdString.fromDecoded(rsrc.getId().toString()));
-    }
-  }
-}
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
deleted file mode 100644
index 86d6e81..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ /dev/null
@@ -1,260 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Deque;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-@Singleton
-class RelatedChangesSorter {
-  private final GitRepositoryManager repoManager;
-  private final ProjectControl.GenericFactory projectControlFactory;
-
-  @Inject
-  RelatedChangesSorter(
-      GitRepositoryManager repoManager, ProjectControl.GenericFactory projectControlFactory) {
-    this.repoManager = repoManager;
-    this.projectControlFactory = projectControlFactory;
-  }
-
-  public List<PatchSetData> sort(List<ChangeData> in, PatchSet startPs, CurrentUser user)
-      throws OrmException, IOException, NoSuchProjectException {
-    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);
-
-    // Map of patch set -> immediate parent.
-    ListMultimap<PatchSetData, PatchSetData> parents =
-        MultimapBuilder.hashKeys(in.size()).arrayListValues(3).build();
-    // Map of patch set -> immediate children.
-    ListMultimap<PatchSetData, PatchSetData> children =
-        MultimapBuilder.hashKeys(in.size()).arrayListValues(3).build();
-    // All other patch sets of the same change as startPs.
-    List<PatchSetData> otherPatchSetsOfStart = new ArrayList<>();
-
-    for (ChangeData cd : in) {
-      for (PatchSet ps : cd.patchSets()) {
-        PatchSetData thisPsd = checkNotNull(byId.get(ps.getRevision().get()));
-        if (cd.getId().equals(start.id()) && !ps.getId().equals(start.psId())) {
-          otherPatchSetsOfStart.add(thisPsd);
-        }
-        for (RevCommit p : thisPsd.commit().getParents()) {
-          PatchSetData parentPsd = byId.get(p.name());
-          if (parentPsd != null) {
-            parents.put(thisPsd, parentPsd);
-            children.put(parentPsd, thisPsd);
-          }
-        }
-      }
-    }
-
-    Collection<PatchSetData> ancestors = walkAncestors(ctl, parents, start);
-    List<PatchSetData> descendants =
-        walkDescendants(ctl, children, start, otherPatchSetsOfStart, ancestors);
-    List<PatchSetData> result = new ArrayList<>(ancestors.size() + descendants.size() - 1);
-    result.addAll(Lists.reverse(descendants));
-    result.addAll(ancestors);
-    return result;
-  }
-
-  private Map<String, PatchSetData> collectById(List<ChangeData> in)
-      throws OrmException, IOException {
-    Project.NameKey project = in.get(0).change().getProject();
-    Map<String, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      rw.setRetainBody(true);
-      for (ChangeData cd : in) {
-        checkArgument(
-            cd.change().getProject().equals(project),
-            "Expected change %s in project %s, found %s",
-            cd.getId(),
-            project,
-            cd.change().getProject());
-        for (PatchSet ps : cd.patchSets()) {
-          String id = ps.getRevision().get();
-          RevCommit c = rw.parseCommit(ObjectId.fromString(id));
-          PatchSetData psd = PatchSetData.create(cd, ps, c);
-          result.put(id, psd);
-        }
-      }
-    }
-    return result;
-  }
-
-  private static Collection<PatchSetData> walkAncestors(
-      ProjectControl ctl, ListMultimap<PatchSetData, PatchSetData> parents, PatchSetData start)
-      throws OrmException {
-    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)) {
-        continue;
-      }
-      result.add(psd);
-      pending.addAll(Lists.reverse(parents.get(psd)));
-    }
-    return result;
-  }
-
-  private static List<PatchSetData> walkDescendants(
-      ProjectControl ctl,
-      ListMultimap<PatchSetData, PatchSetData> children,
-      PatchSetData start,
-      List<PatchSetData> otherPatchSetsOfStart,
-      Iterable<PatchSetData> ancestors)
-      throws OrmException {
-    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));
-    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));
-    return result;
-  }
-
-  private static void addAllChangeIds(
-      Collection<Change.Id> changeIds, Iterable<PatchSetData> psds) {
-    for (PatchSetData psd : psds) {
-      changeIds.add(psd.id());
-    }
-  }
-
-  private static List<PatchSetData> walkDescendentsImpl(
-      ProjectControl ctl,
-      Set<Change.Id> alreadyEmittedChanges,
-      ListMultimap<PatchSetData, PatchSetData> children,
-      List<PatchSetData> start)
-      throws OrmException {
-    if (start.isEmpty()) {
-      return ImmutableList.of();
-    }
-    Map<Change.Id, PatchSet.Id> maxPatchSetIds = new HashMap<>();
-    Set<PatchSetData> seen = new HashSet<>();
-    List<PatchSetData> allPatchSets = new ArrayList<>();
-    Deque<PatchSetData> pending = new ArrayDeque<>();
-    pending.addAll(start);
-    while (!pending.isEmpty()) {
-      PatchSetData psd = pending.remove();
-      if (seen.contains(psd) || !isVisible(psd, ctl)) {
-        continue;
-      }
-      seen.add(psd);
-      if (!alreadyEmittedChanges.contains(psd.id())) {
-        // Don't emit anything for changes that were previously emitted, even
-        // though different patch sets might show up later. However, do
-        // continue walking through them for the purposes of finding indirect
-        // descendants.
-        PatchSet.Id oldMax = maxPatchSetIds.get(psd.id());
-        if (oldMax == null || psd.psId().get() > oldMax.get()) {
-          maxPatchSetIds.put(psd.id(), psd.psId());
-        }
-        allPatchSets.add(psd);
-      }
-      // Depth-first search with newest children first.
-      for (PatchSetData child : children.get(psd)) {
-        pending.addFirst(child);
-      }
-    }
-
-    // If we saw the same change multiple times, prefer the latest patch set.
-    List<PatchSetData> result = new ArrayList<>(allPatchSets.size());
-    for (PatchSetData psd : allPatchSets) {
-      if (checkNotNull(maxPatchSetIds.get(psd.id())).equals(psd.psId())) {
-        result.add(psd);
-      }
-    }
-    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());
-  }
-
-  @AutoValue
-  abstract static class PatchSetData {
-    @VisibleForTesting
-    static PatchSetData create(ChangeData cd, PatchSet ps, RevCommit commit) {
-      return new AutoValue_RelatedChangesSorter_PatchSetData(cd, ps, commit);
-    }
-
-    abstract ChangeData data();
-
-    abstract PatchSet patchSet();
-
-    abstract RevCommit commit();
-
-    PatchSet.Id psId() {
-      return patchSet().getId();
-    }
-
-    Change.Id id() {
-      return psId().getParentKey();
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hash(patchSet().getId(), commit());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
deleted file mode 100644
index 05e8b4a2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
+++ /dev/null
@@ -1,163 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.RestoreInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.extensions.events.ChangeRestored;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
-import com.google.gerrit.server.mail.send.RestoredSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class Restore extends RetryingRestModifyView<ChangeResource, RestoreInput, ChangeInfo>
-    implements UiAction<ChangeResource> {
-  private static final Logger log = LoggerFactory.getLogger(Restore.class);
-
-  private final RestoredSender.Factory restoredSenderFactory;
-  private final Provider<ReviewDb> dbProvider;
-  private final ChangeJson.Factory json;
-  private final ChangeMessagesUtil cmUtil;
-  private final PatchSetUtil psUtil;
-  private final ChangeRestored changeRestored;
-
-  @Inject
-  Restore(
-      RestoredSender.Factory restoredSenderFactory,
-      Provider<ReviewDb> dbProvider,
-      ChangeJson.Factory json,
-      ChangeMessagesUtil cmUtil,
-      PatchSetUtil psUtil,
-      RetryHelper retryHelper,
-      ChangeRestored changeRestored) {
-    super(retryHelper);
-    this.restoredSenderFactory = restoredSenderFactory;
-    this.dbProvider = dbProvider;
-    this.json = json;
-    this.cmUtil = cmUtil;
-    this.psUtil = psUtil;
-    this.changeRestored = changeRestored;
-  }
-
-  @Override
-  protected ChangeInfo applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource req, RestoreInput input)
-      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
-    req.permissions().database(dbProvider).check(ChangePermission.RESTORE);
-
-    Op op = new Op(input);
-    try (BatchUpdate u =
-        updateFactory.create(
-            dbProvider.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
-      u.addOp(req.getId(), op).execute();
-    }
-    return json.noOptions().format(op.change);
-  }
-
-  private class Op implements BatchUpdateOp {
-    private final RestoreInput input;
-
-    private Change change;
-    private PatchSet patchSet;
-    private ChangeMessage message;
-
-    private Op(RestoreInput input) {
-      this.input = input;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException, ResourceConflictException {
-      change = ctx.getChange();
-      if (change == null || change.getStatus() != Status.ABANDONED) {
-        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-      }
-      PatchSet.Id psId = change.currentPatchSetId();
-      ChangeUpdate update = ctx.getUpdate(psId);
-      patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-      change.setStatus(Status.NEW);
-      change.setLastUpdatedOn(ctx.getWhen());
-      update.setStatus(change.getStatus());
-
-      message = newMessage(ctx);
-      cmUtil.addChangeMessage(ctx.getDb(), update, message);
-      return true;
-    }
-
-    private ChangeMessage newMessage(ChangeContext ctx) {
-      StringBuilder msg = new StringBuilder();
-      msg.append("Restored");
-      if (!Strings.nullToEmpty(input.message).trim().isEmpty()) {
-        msg.append("\n\n");
-        msg.append(input.message.trim());
-      }
-      return ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_RESTORE);
-    }
-
-    @Override
-    public void postUpdate(Context ctx) throws OrmException {
-      try {
-        ReplyToChangeSender cm = restoredSenderFactory.create(ctx.getProject(), change.getId());
-        cm.setFrom(ctx.getAccountId());
-        cm.setChangeMessage(message.getMessage(), ctx.getWhen());
-        cm.send();
-      } catch (Exception e) {
-        log.error("Cannot email update for change " + change.getId(), e);
-      }
-      changeRestored.fire(
-          change, patchSet, ctx.getAccount(), Strings.emptyToNull(input.message), ctx.getWhen());
-    }
-  }
-
-  @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Restore")
-        .setTitle("Restore the change")
-        .setVisible(
-            and(
-                rsrc.getChange().getStatus() == Status.ABANDONED,
-                rsrc.permissions().database(dbProvider).testCond(ChangePermission.RESTORE)));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
deleted file mode 100644
index 2dfda08..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
+++ /dev/null
@@ -1,298 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.RevertInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.extensions.events.ChangeReverted;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.mail.send.RevertedSender;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ContributorAgreementsChecker;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.text.MessageFormat;
-import java.util.HashSet;
-import java.util.Set;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.ChangeIdUtil;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class Revert extends RetryingRestModifyView<ChangeResource, RevertInput, ChangeInfo>
-    implements UiAction<ChangeResource> {
-  private static final Logger log = LoggerFactory.getLogger(Revert.class);
-
-  private final Provider<ReviewDb> db;
-  private final PermissionBackend permissionBackend;
-  private final GitRepositoryManager repoManager;
-  private final ChangeInserter.Factory changeInserterFactory;
-  private final ChangeMessagesUtil cmUtil;
-  private final Sequences seq;
-  private final PatchSetUtil psUtil;
-  private final RevertedSender.Factory revertedSenderFactory;
-  private final ChangeJson.Factory json;
-  private final PersonIdent serverIdent;
-  private final ApprovalsUtil approvalsUtil;
-  private final ChangeReverted changeReverted;
-  private final ContributorAgreementsChecker contributorAgreements;
-
-  @Inject
-  Revert(
-      Provider<ReviewDb> db,
-      PermissionBackend permissionBackend,
-      GitRepositoryManager repoManager,
-      ChangeInserter.Factory changeInserterFactory,
-      ChangeMessagesUtil cmUtil,
-      RetryHelper retryHelper,
-      Sequences seq,
-      PatchSetUtil psUtil,
-      RevertedSender.Factory revertedSenderFactory,
-      ChangeJson.Factory json,
-      @GerritPersonIdent PersonIdent serverIdent,
-      ApprovalsUtil approvalsUtil,
-      ChangeReverted changeReverted,
-      ContributorAgreementsChecker contributorAgreements) {
-    super(retryHelper);
-    this.db = db;
-    this.permissionBackend = permissionBackend;
-    this.repoManager = repoManager;
-    this.changeInserterFactory = changeInserterFactory;
-    this.cmUtil = cmUtil;
-    this.seq = seq;
-    this.psUtil = psUtil;
-    this.revertedSenderFactory = revertedSenderFactory;
-    this.json = json;
-    this.serverIdent = serverIdent;
-    this.approvalsUtil = approvalsUtil;
-    this.changeReverted = changeReverted;
-    this.contributorAgreements = contributorAgreements;
-  }
-
-  @Override
-  public ChangeInfo applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, RevertInput input)
-      throws IOException, OrmException, RestApiException, UpdateException, NoSuchChangeException,
-          PermissionBackendException, NoSuchProjectException {
-    Change change = rsrc.getChange();
-    if (change.getStatus() != Change.Status.MERGED) {
-      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-    }
-
-    contributorAgreements.check(rsrc.getProject(), rsrc.getUser());
-    permissionBackend.user(rsrc.getUser()).ref(change.getDest()).check(CREATE_CHANGE);
-
-    Change.Id revertId =
-        revert(updateFactory, rsrc.getNotes(), rsrc.getUser(), Strings.emptyToNull(input.message));
-    return json.noOptions().format(rsrc.getProject(), revertId);
-  }
-
-  private Change.Id revert(
-      BatchUpdate.Factory updateFactory, ChangeNotes notes, CurrentUser user, String message)
-      throws OrmException, IOException, RestApiException, UpdateException {
-    Change.Id changeIdToRevert = notes.getChangeId();
-    PatchSet.Id patchSetId = notes.getChange().currentPatchSetId();
-    PatchSet patch = psUtil.get(db.get(), notes, patchSetId);
-    if (patch == null) {
-      throw new ResourceNotFoundException(changeIdToRevert.toString());
-    }
-
-    Project.NameKey project = notes.getProjectName();
-    try (Repository git = repoManager.openRepository(project);
-        ObjectInserter oi = git.newObjectInserter();
-        ObjectReader reader = oi.newReader();
-        RevWalk revWalk = new RevWalk(reader)) {
-      RevCommit commitToRevert =
-          revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
-      if (commitToRevert.getParentCount() == 0) {
-        throw new ResourceConflictException("Cannot revert initial commit");
-      }
-
-      Timestamp now = TimeUtil.nowTs();
-      PersonIdent committerIdent = new PersonIdent(serverIdent, now);
-      PersonIdent authorIdent =
-          user.asIdentifiedUser().newCommitterIdent(now, committerIdent.getTimeZone());
-
-      RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
-      revWalk.parseHeaders(parentToCommitToRevert);
-
-      CommitBuilder revertCommitBuilder = new CommitBuilder();
-      revertCommitBuilder.addParentId(commitToRevert);
-      revertCommitBuilder.setTreeId(parentToCommitToRevert.getTree());
-      revertCommitBuilder.setAuthor(authorIdent);
-      revertCommitBuilder.setCommitter(authorIdent);
-
-      Change changeToRevert = notes.getChange();
-      if (message == null) {
-        message =
-            MessageFormat.format(
-                ChangeMessages.get().revertChangeDefaultMessage,
-                changeToRevert.getSubject(),
-                patch.getRevision().get());
-      }
-
-      ObjectId computedChangeId =
-          ChangeIdUtil.computeChangeId(
-              parentToCommitToRevert.getTree(),
-              commitToRevert,
-              authorIdent,
-              committerIdent,
-              message);
-      revertCommitBuilder.setMessage(ChangeIdUtil.insertId(message, computedChangeId, true));
-
-      Change.Id changeId = new Change.Id(seq.nextChangeId());
-      ObjectId id = oi.insert(revertCommitBuilder);
-      RevCommit revertCommit = revWalk.parseCommit(id);
-
-      ChangeInserter ins =
-          changeInserterFactory
-              .create(changeId, revertCommit, notes.getChange().getDest().get())
-              .setTopic(changeToRevert.getTopic());
-      ins.setMessage("Uploaded patch set 1.");
-
-      ReviewerSet reviewerSet = approvalsUtil.getReviewers(db.get(), notes);
-
-      Set<Account.Id> reviewers = new HashSet<>();
-      reviewers.add(changeToRevert.getOwner());
-      reviewers.addAll(reviewerSet.byState(ReviewerStateInternal.REVIEWER));
-      reviewers.remove(user.getAccountId());
-      ins.setReviewers(reviewers);
-
-      Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
-      ccs.remove(user.getAccountId());
-      ins.setExtraCC(ccs);
-      ins.setRevertOf(changeIdToRevert);
-
-      try (BatchUpdate bu = updateFactory.create(db.get(), project, user, now)) {
-        bu.setRepository(git, revWalk, oi);
-        bu.insertChange(ins);
-        bu.addOp(changeId, new NotifyOp(notes.getChange(), ins));
-        bu.addOp(changeToRevert.getId(), new PostRevertedMessageOp(computedChangeId));
-        bu.execute();
-      }
-      return changeId;
-    } catch (RepositoryNotFoundException e) {
-      throw new ResourceNotFoundException(changeIdToRevert.toString(), e);
-    }
-  }
-
-  @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
-    Change change = rsrc.getChange();
-    return new UiAction.Description()
-        .setLabel("Revert")
-        .setTitle("Revert the change")
-        .setVisible(
-            and(
-                change.getStatus() == Change.Status.MERGED,
-                permissionBackend
-                    .user(rsrc.getUser())
-                    .ref(change.getDest())
-                    .testCond(CREATE_CHANGE)));
-  }
-
-  private class NotifyOp implements BatchUpdateOp {
-    private final Change change;
-    private final ChangeInserter ins;
-
-    NotifyOp(Change change, ChangeInserter ins) {
-      this.change = change;
-      this.ins = ins;
-    }
-
-    @Override
-    public void postUpdate(Context ctx) throws Exception {
-      changeReverted.fire(change, ins.getChange(), ctx.getWhen());
-      Change.Id changeId = ins.getChange().getId();
-      try {
-        RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), changeId);
-        cm.setFrom(ctx.getAccountId());
-        cm.setChangeMessage(ins.getChangeMessage().getMessage(), ctx.getWhen());
-        cm.send();
-      } catch (Exception err) {
-        log.error("Cannot send email for revert change " + changeId, err);
-      }
-    }
-  }
-
-  private class PostRevertedMessageOp implements BatchUpdateOp {
-    private final ObjectId computedChangeId;
-
-    PostRevertedMessageOp(ObjectId computedChangeId) {
-      this.computedChangeId = computedChangeId;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws Exception {
-      Change change = ctx.getChange();
-      PatchSet.Id patchSetId = change.currentPatchSetId();
-      ChangeMessage changeMessage =
-          ChangeMessagesUtil.newMessage(
-              ctx,
-              "Created a revert of this change as I" + computedChangeId.name(),
-              ChangeMessagesUtil.TAG_REVERT);
-      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(patchSetId), changeMessage);
-      return true;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
deleted file mode 100644
index 0d25d35..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-public class Reviewed {
-  public static class Input {}
-
-  @Singleton
-  public static class PutReviewed implements RestModifyView<FileResource, Input> {
-    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
-
-    @Inject
-    PutReviewed(DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
-      this.accountPatchReviewStore = accountPatchReviewStore;
-    }
-
-    @Override
-    public Response<String> apply(FileResource resource, Input input) throws OrmException {
-      if (accountPatchReviewStore
-          .get()
-          .markReviewed(
-              resource.getPatchKey().getParentKey(),
-              resource.getAccountId(),
-              resource.getPatchKey().getFileName())) {
-        return Response.created("");
-      }
-      return Response.ok("");
-    }
-  }
-
-  @Singleton
-  public static class DeleteReviewed implements RestModifyView<FileResource, Input> {
-    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
-
-    @Inject
-    DeleteReviewed(DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
-      this.accountPatchReviewStore = accountPatchReviewStore;
-    }
-
-    @Override
-    public Response<?> apply(FileResource resource, Input input) throws OrmException {
-      accountPatchReviewStore
-          .get()
-          .clearReviewed(
-              resource.getPatchKey().getParentKey(),
-              resource.getAccountId(),
-              resource.getPatchKey().getFileName());
-      return Response.none();
-    }
-  }
-
-  private Reviewed() {}
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
deleted file mode 100644
index 5457142..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
+++ /dev/null
@@ -1,154 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.common.data.LabelValue.formatValue;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.extensions.api.changes.ReviewerInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.permissions.LabelPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.Collection;
-import java.util.List;
-import java.util.TreeMap;
-
-@Singleton
-public class ReviewerJson {
-  private final Provider<ReviewDb> db;
-  private final PermissionBackend permissionBackend;
-  private final ChangeData.Factory changeDataFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final AccountLoader.Factory accountLoaderFactory;
-  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
-
-  @Inject
-  ReviewerJson(
-      Provider<ReviewDb> db,
-      PermissionBackend permissionBackend,
-      ChangeData.Factory changeDataFactory,
-      ApprovalsUtil approvalsUtil,
-      AccountLoader.Factory accountLoaderFactory,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
-    this.db = db;
-    this.permissionBackend = permissionBackend;
-    this.changeDataFactory = changeDataFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.accountLoaderFactory = accountLoaderFactory;
-    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
-  }
-
-  public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs)
-      throws OrmException, PermissionBackendException {
-    List<ReviewerInfo> infos = Lists.newArrayListWithCapacity(rsrcs.size());
-    AccountLoader loader = accountLoaderFactory.create(true);
-    ChangeData cd = null;
-    for (ReviewerResource rsrc : rsrcs) {
-      if (cd == null || !cd.getId().equals(rsrc.getChangeId())) {
-        cd = changeDataFactory.create(db.get(), rsrc.getChangeResource().getNotes());
-      }
-      ReviewerInfo info =
-          format(
-              rsrc.getChangeResource().getUser(),
-              new ReviewerInfo(rsrc.getReviewerUser().getAccountId().get()),
-              permissionBackend.user(rsrc.getReviewerUser()).database(db).change(cd),
-              cd);
-      loader.put(info);
-      infos.add(info);
-    }
-    loader.fill();
-    return infos;
-  }
-
-  public List<ReviewerInfo> format(ReviewerResource rsrc)
-      throws OrmException, PermissionBackendException {
-    return format(ImmutableList.<ReviewerResource>of(rsrc));
-  }
-
-  public ReviewerInfo format(
-      CurrentUser user, ReviewerInfo out, PermissionBackend.ForChange perm, ChangeData cd)
-      throws OrmException, PermissionBackendException {
-    PatchSet.Id psId = cd.change().currentPatchSetId();
-    return format(
-        user,
-        out,
-        perm,
-        cd,
-        approvalsUtil.byPatchSetUser(
-            db.get(), cd.notes(), perm.user(), psId, new Account.Id(out._accountId), null, null));
-  }
-
-  public ReviewerInfo format(
-      CurrentUser user,
-      ReviewerInfo out,
-      PermissionBackend.ForChange perm,
-      ChangeData cd,
-      Iterable<PatchSetApproval> approvals)
-      throws OrmException, PermissionBackendException {
-    LabelTypes labelTypes = cd.getLabelTypes();
-
-    out.approvals = new TreeMap<>(labelTypes.nameComparator());
-    for (PatchSetApproval ca : approvals) {
-      LabelType at = labelTypes.byLabel(ca.getLabelId());
-      if (at != null) {
-        out.approvals.put(at.getName(), formatValue(ca.getValue()));
-      }
-    }
-
-    // Add dummy approvals for all permitted labels for the user even if they
-    // do not exist in the DB.
-    PatchSet ps = cd.currentPatchSet();
-    if (ps != null) {
-      for (SubmitRecord rec :
-          submitRuleEvaluatorFactory.create(user, cd).setFastEvalLabels(true).evaluate()) {
-        if (rec.labels == null) {
-          continue;
-        }
-        for (SubmitRecord.Label label : rec.labels) {
-          String name = label.label;
-          LabelType type = labelTypes.byLabel(name);
-          if (!out.approvals.containsKey(name)
-              && type != null
-              && perm.test(new LabelPermission(type))) {
-            out.approvals.put(name, formatValue((short) 0));
-          }
-        }
-      }
-    }
-
-    if (out.approvals.isEmpty()) {
-      out.approvals = null;
-    }
-
-    return out;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
deleted file mode 100644
index 47e25b04..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
+++ /dev/null
@@ -1,116 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.Address;
-import com.google.inject.TypeLiteral;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-
-public class ReviewerResource implements RestResource {
-  public static final TypeLiteral<RestView<ReviewerResource>> REVIEWER_KIND =
-      new TypeLiteral<RestView<ReviewerResource>>() {};
-
-  public interface Factory {
-    ReviewerResource create(ChangeResource change, Account.Id id);
-
-    ReviewerResource create(RevisionResource revision, Account.Id id);
-  }
-
-  private final ChangeResource change;
-  private final RevisionResource revision;
-  @Nullable private final IdentifiedUser user;
-  @Nullable private final Address address;
-
-  @AssistedInject
-  ReviewerResource(
-      IdentifiedUser.GenericFactory userFactory,
-      @Assisted ChangeResource change,
-      @Assisted Account.Id id) {
-    this.change = change;
-    this.user = userFactory.create(id);
-    this.revision = null;
-    this.address = null;
-  }
-
-  @AssistedInject
-  ReviewerResource(
-      IdentifiedUser.GenericFactory userFactory,
-      @Assisted RevisionResource revision,
-      @Assisted Account.Id id) {
-    this.revision = revision;
-    this.change = revision.getChangeResource();
-    this.user = userFactory.create(id);
-    this.address = null;
-  }
-
-  ReviewerResource(ChangeResource change, Address address) {
-    this.change = change;
-    this.address = address;
-    this.revision = null;
-    this.user = null;
-  }
-
-  ReviewerResource(RevisionResource revision, Address address) {
-    this.revision = revision;
-    this.change = revision.getChangeResource();
-    this.address = address;
-    this.user = null;
-  }
-
-  public ChangeResource getChangeResource() {
-    return change;
-  }
-
-  public RevisionResource getRevisionResource() {
-    return revision;
-  }
-
-  public Change.Id getChangeId() {
-    return change.getId();
-  }
-
-  public Change getChange() {
-    return change.getChange();
-  }
-
-  public IdentifiedUser getReviewerUser() {
-    checkArgument(user != null, "no user provided");
-    return user;
-  }
-
-  public Address getReviewerByEmail() {
-    checkArgument(address != null, "no address provided");
-    return address;
-  }
-
-  /**
-   * Check if this resource was constructed by email or by {@code Account.Id}.
-   *
-   * @return true if the resource was constructed by providing an {@code Address}; false if the
-   *     resource was constructed by providing an {@code Account.Id}.
-   */
-  public boolean isByEmail() {
-    return user == null;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
deleted file mode 100644
index 8794083..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
+++ /dev/null
@@ -1,102 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.account.AccountsCollection;
-import com.google.gerrit.server.mail.Address;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class Reviewers implements ChildCollection<ChangeResource, ReviewerResource> {
-  private final DynamicMap<RestView<ReviewerResource>> views;
-  private final Provider<ReviewDb> dbProvider;
-  private final ApprovalsUtil approvalsUtil;
-  private final AccountsCollection accounts;
-  private final ReviewerResource.Factory resourceFactory;
-  private final ListReviewers list;
-
-  @Inject
-  Reviewers(
-      Provider<ReviewDb> dbProvider,
-      ApprovalsUtil approvalsUtil,
-      AccountsCollection accounts,
-      ReviewerResource.Factory resourceFactory,
-      DynamicMap<RestView<ReviewerResource>> views,
-      ListReviewers list) {
-    this.dbProvider = dbProvider;
-    this.approvalsUtil = approvalsUtil;
-    this.accounts = accounts;
-    this.resourceFactory = resourceFactory;
-    this.views = views;
-    this.list = list;
-  }
-
-  @Override
-  public DynamicMap<RestView<ReviewerResource>> views() {
-    return views;
-  }
-
-  @Override
-  public RestView<ChangeResource> list() {
-    return list;
-  }
-
-  @Override
-  public ReviewerResource parse(ChangeResource rsrc, IdString id)
-      throws OrmException, ResourceNotFoundException, AuthException, IOException,
-          ConfigInvalidException {
-    Address address = Address.tryParse(id.get());
-
-    Account.Id accountId = null;
-    try {
-      accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
-    } catch (ResourceNotFoundException e) {
-      if (address == null) {
-        throw e;
-      }
-    }
-    // See if the id exists as a reviewer for this change
-    if (accountId != null && fetchAccountIds(rsrc).contains(accountId)) {
-      return resourceFactory.create(rsrc, accountId);
-    }
-
-    // See if the address exists as a reviewer on the change
-    if (address != null && rsrc.getNotes().getReviewersByEmail().all().contains(address)) {
-      return new ReviewerResource(rsrc, address);
-    }
-
-    throw new ResourceNotFoundException(id);
-  }
-
-  private Collection<Account.Id> fetchAccountIds(ChangeResource rsrc) throws OrmException {
-    return approvalsUtil.getReviewers(dbProvider.get(), rsrc.getNotes()).all();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
deleted file mode 100644
index b9b2d1d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
+++ /dev/null
@@ -1,122 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestResource.HasETag;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.inject.TypeLiteral;
-import java.util.Optional;
-
-public class RevisionResource implements RestResource, HasETag {
-  public static final TypeLiteral<RestView<RevisionResource>> REVISION_KIND =
-      new TypeLiteral<RestView<RevisionResource>>() {};
-
-  private final ChangeResource change;
-  private final PatchSet ps;
-  private final Optional<ChangeEdit> edit;
-  private boolean cacheable = true;
-
-  public RevisionResource(ChangeResource change, PatchSet ps) {
-    this(change, ps, Optional.empty());
-  }
-
-  public RevisionResource(ChangeResource change, PatchSet ps, Optional<ChangeEdit> edit) {
-    this.change = change;
-    this.ps = ps;
-    this.edit = edit;
-  }
-
-  public boolean isCacheable() {
-    return cacheable;
-  }
-
-  public PermissionBackend.ForChange permissions() {
-    return change.permissions();
-  }
-
-  public ChangeResource getChangeResource() {
-    return change;
-  }
-
-  public Change getChange() {
-    return getChangeResource().getChange();
-  }
-
-  public Project.NameKey getProject() {
-    return getChange().getProject();
-  }
-
-  public ChangeNotes getNotes() {
-    return getChangeResource().getNotes();
-  }
-
-  public PatchSet getPatchSet() {
-    return ps;
-  }
-
-  @Override
-  public String getETag() {
-    Hasher h = Hashing.murmur3_128().newHasher();
-    prepareETag(h, getUser());
-    return h.hash().toString();
-  }
-
-  void prepareETag(Hasher h, CurrentUser user) {
-    // Conservative estimate: refresh the revision if its parent change has changed, so we don't
-    // have to check whether a given modification affected this revision specifically.
-    change.prepareETag(h, user);
-  }
-
-  Account.Id getAccountId() {
-    return getUser().getAccountId();
-  }
-
-  CurrentUser getUser() {
-    return getChangeResource().getUser();
-  }
-
-  RevisionResource doNotCache() {
-    cacheable = false;
-    return this;
-  }
-
-  public Optional<ChangeEdit> getEdit() {
-    return edit;
-  }
-
-  @Override
-  public String toString() {
-    String s = ps.getId().toString();
-    if (edit.isPresent()) {
-      s = "edit:" + s;
-    }
-    return s;
-  }
-
-  public boolean isCurrent() {
-    return ps.getId().equals(getChange().currentPatchSetId());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
deleted file mode 100644
index be8bce0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.account.AccountsCollection;
-import com.google.gerrit.server.mail.Address;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class RevisionReviewers implements ChildCollection<RevisionResource, ReviewerResource> {
-  private final DynamicMap<RestView<ReviewerResource>> views;
-  private final Provider<ReviewDb> dbProvider;
-  private final ApprovalsUtil approvalsUtil;
-  private final AccountsCollection accounts;
-  private final ReviewerResource.Factory resourceFactory;
-  private final ListRevisionReviewers list;
-
-  @Inject
-  RevisionReviewers(
-      Provider<ReviewDb> dbProvider,
-      ApprovalsUtil approvalsUtil,
-      AccountsCollection accounts,
-      ReviewerResource.Factory resourceFactory,
-      DynamicMap<RestView<ReviewerResource>> views,
-      ListRevisionReviewers list) {
-    this.dbProvider = dbProvider;
-    this.approvalsUtil = approvalsUtil;
-    this.accounts = accounts;
-    this.resourceFactory = resourceFactory;
-    this.views = views;
-    this.list = list;
-  }
-
-  @Override
-  public DynamicMap<RestView<ReviewerResource>> views() {
-    return views;
-  }
-
-  @Override
-  public RestView<RevisionResource> list() {
-    return list;
-  }
-
-  @Override
-  public ReviewerResource parse(RevisionResource rsrc, IdString id)
-      throws OrmException, ResourceNotFoundException, AuthException, MethodNotAllowedException,
-          IOException, ConfigInvalidException {
-    if (!rsrc.isCurrent()) {
-      throw new MethodNotAllowedException("Cannot access on non-current patch set");
-    }
-    Address address = Address.tryParse(id.get());
-
-    Account.Id accountId = null;
-    try {
-      accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
-    } catch (ResourceNotFoundException e) {
-      if (address == null) {
-        throw e;
-      }
-    }
-    Collection<Account.Id> reviewers =
-        approvalsUtil.getReviewers(dbProvider.get(), rsrc.getNotes()).all();
-    // See if the id exists as a reviewer for this change
-    if (reviewers.contains(accountId)) {
-      return resourceFactory.create(rsrc, accountId);
-    }
-
-    // See if the address exists as a reviewer on the change
-    if (address != null && rsrc.getNotes().getReviewersByEmail().all().contains(address)) {
-      return new ReviewerResource(rsrc, address);
-    }
-
-    throw new ResourceNotFoundException(id);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
deleted file mode 100644
index ef039dd..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
+++ /dev/null
@@ -1,161 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.Joiner;
-import com.google.common.collect.Lists;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
-
-@Singleton
-public class Revisions implements ChildCollection<ChangeResource, RevisionResource> {
-  private final DynamicMap<RestView<RevisionResource>> views;
-  private final Provider<ReviewDb> dbProvider;
-  private final ChangeEditUtil editUtil;
-  private final PatchSetUtil psUtil;
-  private final ChangeControl.GenericFactory changeControlFactory;
-
-  @Inject
-  Revisions(
-      DynamicMap<RestView<RevisionResource>> views,
-      Provider<ReviewDb> dbProvider,
-      ChangeEditUtil editUtil,
-      PatchSetUtil psUtil,
-      ChangeControl.GenericFactory changeControlFactory) {
-    this.views = views;
-    this.dbProvider = dbProvider;
-    this.editUtil = editUtil;
-    this.psUtil = psUtil;
-    this.changeControlFactory = changeControlFactory;
-  }
-
-  @Override
-  public DynamicMap<RestView<RevisionResource>> views() {
-    return views;
-  }
-
-  @Override
-  public RestView<ChangeResource> list() throws ResourceNotFoundException {
-    throw new ResourceNotFoundException();
-  }
-
-  @Override
-  public RevisionResource parse(ChangeResource change, IdString id)
-      throws ResourceNotFoundException, AuthException, OrmException, IOException {
-    if (id.get().equals("current")) {
-      PatchSet ps = psUtil.current(dbProvider.get(), change.getNotes());
-      if (ps != null && visible(change)) {
-        return new RevisionResource(change, ps).doNotCache();
-      }
-      throw new ResourceNotFoundException(id);
-    }
-
-    List<RevisionResource> match = Lists.newArrayListWithExpectedSize(2);
-    for (RevisionResource rsrc : find(change, id.get())) {
-      if (visible(change)) {
-        match.add(rsrc);
-      }
-    }
-    switch (match.size()) {
-      case 0:
-        throw new ResourceNotFoundException(id);
-      case 1:
-        return match.get(0);
-      default:
-        throw new ResourceNotFoundException(
-            "Multiple patch sets for \"" + id.get() + "\": " + Joiner.on("; ").join(match));
-    }
-  }
-
-  private boolean visible(ChangeResource change) throws OrmException {
-    return changeControlFactory
-        .controlFor(change.getNotes(), change.getUser())
-        .isVisible(dbProvider.get());
-  }
-
-  private List<RevisionResource> find(ChangeResource change, String id)
-      throws OrmException, IOException, AuthException {
-    if (id.equals("0") || id.equals("edit")) {
-      return loadEdit(change, null);
-    } else if (id.length() < 6 && id.matches("^[1-9][0-9]{0,4}$")) {
-      // Legacy patch set number syntax.
-      return byLegacyPatchSetId(change, id);
-    } else if (id.length() < 4 || id.length() > RevId.LEN) {
-      // Require a minimum of 4 digits.
-      // Impossibly long identifier will never match.
-      return Collections.emptyList();
-    } else {
-      List<RevisionResource> out = new ArrayList<>();
-      for (PatchSet ps : psUtil.byChange(dbProvider.get(), change.getNotes())) {
-        if (ps.getRevision() != null && ps.getRevision().get().startsWith(id)) {
-          out.add(new RevisionResource(change, ps));
-        }
-      }
-      // Not an existing patch set on a change, but might be an edit.
-      if (out.isEmpty() && id.length() == RevId.LEN) {
-        return loadEdit(change, new RevId(id));
-      }
-      return out;
-    }
-  }
-
-  private List<RevisionResource> byLegacyPatchSetId(ChangeResource change, String id)
-      throws OrmException {
-    PatchSet ps =
-        psUtil.get(
-            dbProvider.get(),
-            change.getNotes(),
-            new PatchSet.Id(change.getId(), Integer.parseInt(id)));
-    if (ps != null) {
-      return Collections.singletonList(new RevisionResource(change, ps));
-    }
-    return Collections.emptyList();
-  }
-
-  private List<RevisionResource> loadEdit(ChangeResource change, RevId revid)
-      throws AuthException, IOException {
-    Optional<ChangeEdit> edit = editUtil.byChange(change.getNotes(), change.getUser());
-    if (edit.isPresent()) {
-      PatchSet ps = new PatchSet(new PatchSet.Id(change.getId(), 0));
-      RevId editRevId = new RevId(ObjectId.toString(edit.get().getEditCommit()));
-      ps.setRevision(editRevId);
-      if (revid == null || editRevId.equals(revid)) {
-        return Collections.singletonList(new RevisionResource(change, ps, edit));
-      }
-    }
-    return Collections.emptyList();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotCommentResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotCommentResource.java
deleted file mode 100644
index 856c777..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotCommentResource.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.inject.TypeLiteral;
-
-public class RobotCommentResource implements RestResource {
-  public static final TypeLiteral<RestView<RobotCommentResource>> ROBOT_COMMENT_KIND =
-      new TypeLiteral<RestView<RobotCommentResource>>() {};
-
-  private final RevisionResource rev;
-  private final RobotComment comment;
-
-  public RobotCommentResource(RevisionResource rev, RobotComment c) {
-    this.rev = rev;
-    this.comment = c;
-  }
-
-  public PatchSet getPatchSet() {
-    return rev.getPatchSet();
-  }
-
-  RobotComment getComment() {
-    return comment;
-  }
-
-  String getId() {
-    return comment.key.uuid;
-  }
-
-  Account.Id getAuthorId() {
-    return comment.author.getId();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotComments.java
deleted file mode 100644
index d1443af..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotComments.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class RobotComments implements ChildCollection<RevisionResource, RobotCommentResource> {
-  private final DynamicMap<RestView<RobotCommentResource>> views;
-  private final ListRobotComments list;
-  private final CommentsUtil commentsUtil;
-
-  @Inject
-  RobotComments(
-      DynamicMap<RestView<RobotCommentResource>> views,
-      ListRobotComments list,
-      CommentsUtil commentsUtil) {
-    this.views = views;
-    this.list = list;
-    this.commentsUtil = commentsUtil;
-  }
-
-  @Override
-  public DynamicMap<RestView<RobotCommentResource>> views() {
-    return views;
-  }
-
-  @Override
-  public ListRobotComments list() {
-    return list;
-  }
-
-  @Override
-  public RobotCommentResource parse(RevisionResource rev, IdString id)
-      throws ResourceNotFoundException, OrmException {
-    String uuid = id.get();
-    ChangeNotes notes = rev.getNotes();
-
-    for (RobotComment c : commentsUtil.robotCommentsByPatchSet(notes, rev.getPatchSet().getId())) {
-      if (uuid.equals(c.key.uuid)) {
-        return new RobotCommentResource(rev, c);
-      }
-    }
-    throw new ResourceNotFoundException(id);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java
deleted file mode 100644
index 9aa4636..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.extensions.events.PrivateStateChanged;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-public class SetPrivateOp implements BatchUpdateOp {
-  public static class Input {
-    String message;
-
-    public Input() {}
-
-    public Input(String message) {
-      this.message = message;
-    }
-  }
-
-  public interface Factory {
-    SetPrivateOp create(ChangeMessagesUtil cmUtil, boolean isPrivate, Input input);
-  }
-
-  private final ChangeMessagesUtil cmUtil;
-  private final boolean isPrivate;
-  private final Input input;
-  private final PrivateStateChanged privateStateChanged;
-
-  private Change change;
-
-  @Inject
-  SetPrivateOp(
-      PrivateStateChanged privateStateChanged,
-      @Assisted ChangeMessagesUtil cmUtil,
-      @Assisted boolean isPrivate,
-      @Assisted Input input) {
-    this.cmUtil = cmUtil;
-    this.isPrivate = isPrivate;
-    this.input = input;
-    this.privateStateChanged = privateStateChanged;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, OrmException {
-    change = ctx.getChange();
-    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-    change.setPrivate(isPrivate);
-    change.setLastUpdatedOn(ctx.getWhen());
-    update.setPrivate(isPrivate);
-    addMessage(ctx, update);
-    return true;
-  }
-
-  @Override
-  public void postUpdate(Context ctx) {
-    privateStateChanged.fire(change, ctx.getAccount(), ctx.getWhen());
-  }
-
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
-    Change c = ctx.getChange();
-    StringBuilder buf = new StringBuilder(c.isPrivate() ? "Set private" : "Unset private");
-
-    String m = Strings.nullToEmpty(input == null ? null : input.message).trim();
-    if (!m.isEmpty()) {
-      buf.append("\n\n");
-      buf.append(m);
-    }
-
-    ChangeMessage cmsg =
-        ChangeMessagesUtil.newMessage(
-            ctx,
-            buf.toString(),
-            c.isPrivate()
-                ? ChangeMessagesUtil.TAG_SET_PRIVATE
-                : ChangeMessagesUtil.TAG_UNSET_PRIVATE);
-    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetReadyForReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetReadyForReview.java
deleted file mode 100644
index 3d258c3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetReadyForReview.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.change.WorkInProgressOp.Input;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class SetReadyForReview extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
-    implements UiAction<ChangeResource> {
-  private final WorkInProgressOp.Factory opFactory;
-  private final Provider<ReviewDb> db;
-
-  @Inject
-  SetReadyForReview(
-      RetryHelper retryHelper, WorkInProgressOp.Factory opFactory, Provider<ReviewDb> db) {
-    super(retryHelper);
-    this.opFactory = opFactory;
-    this.db = db;
-  }
-
-  @Override
-  protected Response<?> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
-      throws RestApiException, UpdateException {
-    Change change = rsrc.getChange();
-    if (!rsrc.isUserOwner()) {
-      throw new AuthException("not allowed to set ready for review");
-    }
-
-    if (change.getStatus() != Status.NEW) {
-      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-    }
-
-    if (!change.isWorkInProgress()) {
-      throw new ResourceConflictException("change is not work in progress");
-    }
-
-    try (BatchUpdate bu =
-        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      bu.addOp(rsrc.getChange().getId(), opFactory.create(false, input));
-      bu.execute();
-      return Response.ok("");
-    }
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new Description()
-        .setLabel("Start Review")
-        .setTitle("Set Ready For Review")
-        .setVisible(
-            rsrc.isUserOwner()
-                && rsrc.getChange().getStatus() == Status.NEW
-                && rsrc.getChange().isWorkInProgress());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetWorkInProgress.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetWorkInProgress.java
deleted file mode 100644
index 565f67f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetWorkInProgress.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.change.WorkInProgressOp.Input;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class SetWorkInProgress extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
-    implements UiAction<ChangeResource> {
-  private final WorkInProgressOp.Factory opFactory;
-  private final Provider<ReviewDb> db;
-
-  @Inject
-  SetWorkInProgress(
-      WorkInProgressOp.Factory opFactory, RetryHelper retryHelper, Provider<ReviewDb> db) {
-    super(retryHelper);
-    this.opFactory = opFactory;
-    this.db = db;
-  }
-
-  @Override
-  protected Response<?> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
-      throws RestApiException, UpdateException {
-    Change change = rsrc.getChange();
-    if (!rsrc.isUserOwner()) {
-      throw new AuthException("not allowed to set work in progress");
-    }
-
-    if (change.getStatus() != Status.NEW) {
-      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-    }
-
-    if (change.isWorkInProgress()) {
-      throw new ResourceConflictException("change is already work in progress");
-    }
-
-    try (BatchUpdate bu =
-        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      bu.addOp(rsrc.getChange().getId(), opFactory.create(true, input));
-      bu.execute();
-      return Response.ok("");
-    }
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new Description()
-        .setLabel("WIP")
-        .setTitle("Set Work In Progress")
-        .setVisible(
-            rsrc.isUserOwner()
-                && rsrc.getChange().getStatus() == Status.NEW
-                && !rsrc.getChange().isWorkInProgress());
-  }
-}
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
deleted file mode 100644
index cab61b3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
+++ /dev/null
@@ -1,543 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static java.util.stream.Collectors.joining;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.ParameterizedString;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.ProjectUtil;
-import com.google.gerrit.server.account.AccountsCollection;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.ChangeSet;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeOp;
-import com.google.gerrit.server.git.MergeSuperSet;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.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;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-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.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Queue;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class Submit
-    implements RestModifyView<RevisionResource, SubmitInput>, UiAction<RevisionResource> {
-  private static final Logger log = LoggerFactory.getLogger(Submit.class);
-
-  private static final String DEFAULT_TOOLTIP = "Submit patch set ${patchSet} into ${branch}";
-  private static final String DEFAULT_TOOLTIP_ANCESTORS =
-      "Submit patch set ${patchSet} and ancestors (${submitSize} changes "
-          + "altogether) into ${branch}";
-  private static final String DEFAULT_TOPIC_TOOLTIP =
-      "Submit all ${topicSize} changes of the same topic "
-          + "(${submitSize} changes including ancestors and other "
-          + "changes related by topic)";
-  private static final String BLOCKED_SUBMIT_TOOLTIP =
-      "This change depends on other changes which are not ready";
-  private static final String BLOCKED_HIDDEN_SUBMIT_TOOLTIP =
-      "This change depends on other hidden changes which are not ready";
-  private static final String BLOCKED_WORK_IN_PROGRESS = "This change is marked work in progress";
-  private static final String CLICK_FAILURE_TOOLTIP = "Clicking the button would fail";
-  private static final String CHANGE_UNMERGEABLE = "Problems with integrating this change";
-  private static final String CHANGES_NOT_MERGEABLE = "Problems with change(s): ";
-
-  public static class Output {
-    transient Change change;
-
-    private Output(Change c) {
-      change = c;
-    }
-  }
-
-  /**
-   * Subclass of {@link SubmitInput} with special bits that may be flipped for testing purposes
-   * only.
-   */
-  @VisibleForTesting
-  public static class TestSubmitInput extends SubmitInput {
-    public boolean failAfterRefUpdates;
-
-    /**
-     * For each change being submitted, an element is removed from this queue and, if the value is
-     * true, a bogus ref update is added to the batch, in order to generate a lock failure during
-     * execution.
-     */
-    public Queue<Boolean> generateLockFailures;
-  }
-
-  private final Provider<ReviewDb> dbProvider;
-  private final GitRepositoryManager repoManager;
-  private final PermissionBackend permissionBackend;
-  private final ChangeData.Factory changeDataFactory;
-  private final ChangeMessagesUtil cmUtil;
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final Provider<MergeOp> mergeOpProvider;
-  private final Provider<MergeSuperSet> mergeSuperSet;
-  private final AccountsCollection accounts;
-  private final String label;
-  private final String labelWithParents;
-  private final ParameterizedString titlePattern;
-  private final ParameterizedString titlePatternWithAncestors;
-  private final String submitTopicLabel;
-  private final ParameterizedString submitTopicTooltip;
-  private final boolean submitWholeTopic;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final PatchSetUtil psUtil;
-
-  @Inject
-  Submit(
-      Provider<ReviewDb> dbProvider,
-      GitRepositoryManager repoManager,
-      PermissionBackend permissionBackend,
-      ChangeData.Factory changeDataFactory,
-      ChangeMessagesUtil cmUtil,
-      ChangeNotes.Factory changeNotesFactory,
-      Provider<MergeOp> mergeOpProvider,
-      Provider<MergeSuperSet> mergeSuperSet,
-      AccountsCollection accounts,
-      @GerritServerConfig Config cfg,
-      Provider<InternalChangeQuery> queryProvider,
-      PatchSetUtil psUtil) {
-    this.dbProvider = dbProvider;
-    this.repoManager = repoManager;
-    this.permissionBackend = permissionBackend;
-    this.changeDataFactory = changeDataFactory;
-    this.cmUtil = cmUtil;
-    this.changeNotesFactory = changeNotesFactory;
-    this.mergeOpProvider = mergeOpProvider;
-    this.mergeSuperSet = mergeSuperSet;
-    this.accounts = accounts;
-    this.label =
-        MoreObjects.firstNonNull(
-            Strings.emptyToNull(cfg.getString("change", null, "submitLabel")), "Submit");
-    this.labelWithParents =
-        MoreObjects.firstNonNull(
-            Strings.emptyToNull(cfg.getString("change", null, "submitLabelWithParents")),
-            "Submit including parents");
-    this.titlePattern =
-        new ParameterizedString(
-            MoreObjects.firstNonNull(
-                cfg.getString("change", null, "submitTooltip"), DEFAULT_TOOLTIP));
-    this.titlePatternWithAncestors =
-        new ParameterizedString(
-            MoreObjects.firstNonNull(
-                cfg.getString("change", null, "submitTooltipAncestors"),
-                DEFAULT_TOOLTIP_ANCESTORS));
-    submitWholeTopic = wholeTopicEnabled(cfg);
-    this.submitTopicLabel =
-        MoreObjects.firstNonNull(
-            Strings.emptyToNull(cfg.getString("change", null, "submitTopicLabel")),
-            "Submit whole topic");
-    this.submitTopicTooltip =
-        new ParameterizedString(
-            MoreObjects.firstNonNull(
-                cfg.getString("change", null, "submitTopicTooltip"), DEFAULT_TOPIC_TOOLTIP));
-    this.queryProvider = queryProvider;
-    this.psUtil = psUtil;
-  }
-
-  @Override
-  public Output apply(RevisionResource rsrc, SubmitInput input)
-      throws RestApiException, RepositoryNotFoundException, IOException, OrmException,
-          PermissionBackendException, UpdateException, ConfigInvalidException {
-    input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
-    IdentifiedUser submitter;
-    if (input.onBehalfOf != null) {
-      submitter = onBehalfOf(rsrc, input);
-    } else {
-      rsrc.permissions().check(ChangePermission.SUBMIT);
-      submitter = rsrc.getUser().asIdentifiedUser();
-    }
-
-    return new Output(mergeChange(rsrc, submitter, input));
-  }
-
-  public Change mergeChange(RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
-      throws OrmException, RestApiException, IOException, UpdateException, ConfigInvalidException,
-          PermissionBackendException {
-    Change change = rsrc.getChange();
-    if (!change.getStatus().isOpen()) {
-      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-    } else if (!ProjectUtil.branchExists(repoManager, change.getDest())) {
-      throw new ResourceConflictException(
-          String.format("destination branch \"%s\" not found.", change.getDest().get()));
-    } else if (!rsrc.getPatchSet().getId().equals(change.currentPatchSetId())) {
-      // TODO Allow submitting non-current revision by changing the current.
-      throw new ResourceConflictException(
-          String.format(
-              "revision %s is not current revision", rsrc.getPatchSet().getRevision().get()));
-    }
-
-    try (MergeOp op = mergeOpProvider.get()) {
-      ReviewDb db = dbProvider.get();
-      op.merge(db, change, submitter, true, input, false);
-      try {
-        change =
-            changeNotesFactory.createChecked(db, change.getProject(), change.getId()).getChange();
-      } catch (NoSuchChangeException e) {
-        throw new ResourceConflictException("change is deleted");
-      }
-    }
-
-    switch (change.getStatus()) {
-      case MERGED:
-        return change;
-      case NEW:
-        ChangeMessage msg = getConflictMessage(rsrc);
-        if (msg != null) {
-          throw new ResourceConflictException(msg.getMessage());
-        }
-        // $FALL-THROUGH$
-      case ABANDONED:
-      default:
-        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-    }
-  }
-
-  /**
-   * @param cd the change the user is currently looking at
-   * @param cs set of changes to be submitted at once
-   * @param user the user who is checking to submit
-   * @return a reason why any of the changes is not submittable or null
-   */
-  private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) {
-    try {
-      if (cs.furtherHiddenChanges()) {
-        return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
-      }
-      for (ChangeData c : cs.changes()) {
-        Set<ChangePermission> can =
-            permissionBackend
-                .user(user)
-                .database(dbProvider)
-                .change(c)
-                .test(EnumSet.of(ChangePermission.READ, ChangePermission.SUBMIT));
-        if (!can.contains(ChangePermission.READ)) {
-          return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
-        }
-        if (!can.contains(ChangePermission.SUBMIT)) {
-          return BLOCKED_SUBMIT_TOOLTIP;
-        }
-        if (c.change().isWorkInProgress()) {
-          return BLOCKED_WORK_IN_PROGRESS;
-        }
-        MergeOp.checkSubmitRule(c, false);
-      }
-
-      Collection<ChangeData> unmergeable = unmergeableChanges(cs);
-      if (unmergeable == null) {
-        return CLICK_FAILURE_TOOLTIP;
-      } else if (!unmergeable.isEmpty()) {
-        for (ChangeData c : unmergeable) {
-          if (c.change().getKey().equals(cd.change().getKey())) {
-            return CHANGE_UNMERGEABLE;
-          }
-        }
-        return CHANGES_NOT_MERGEABLE
-            + unmergeable.stream().map(c -> c.getId().toString()).collect(joining(", "));
-      }
-    } catch (ResourceConflictException e) {
-      return BLOCKED_SUBMIT_TOOLTIP;
-    } catch (PermissionBackendException | OrmException | IOException e) {
-      log.error("Error checking if change is submittable", e);
-      throw new OrmRuntimeException("Could not determine problems for the change", e);
-    }
-    return null;
-  }
-
-  @Override
-  public UiAction.Description getDescription(RevisionResource resource) {
-    Change change = resource.getChange();
-    if (!change.getStatus().isOpen()
-        || !resource.isCurrent()
-        || !resource.permissions().testOrFalse(ChangePermission.SUBMIT)) {
-      return null; // submit not visible
-    }
-
-    ReviewDb db = dbProvider.get();
-    ChangeData cd = changeDataFactory.create(db, resource.getNotes());
-    try {
-      MergeOp.checkSubmitRule(cd, false);
-    } catch (ResourceConflictException e) {
-      return null; // submit not visible
-    } catch (OrmException e) {
-      log.error("Error checking if change is submittable", e);
-      throw new OrmRuntimeException("Could not determine problems for the change", e);
-    }
-
-    ChangeSet cs;
-    try {
-      cs = mergeSuperSet.get().completeChangeSet(db, cd.change(), resource.getUser());
-    } catch (OrmException | IOException | PermissionBackendException e) {
-      throw new OrmRuntimeException(
-          "Could not determine complete set of changes to be submitted", e);
-    }
-
-    String topic = change.getTopic();
-    int topicSize = 0;
-    if (!Strings.isNullOrEmpty(topic)) {
-      topicSize = getChangesByTopic(topic).size();
-    }
-    boolean treatWithTopic = submitWholeTopic && !Strings.isNullOrEmpty(topic) && topicSize > 1;
-
-    String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
-
-    Boolean enabled;
-    try {
-      // Recheck mergeability rather than using value stored in the index,
-      // which may be stale.
-      // TODO(dborowitz): This is ugly; consider providing a way to not read
-      // stored fields from the index in the first place.
-      // cd.setMergeable(null);
-      // That was done in unmergeableChanges which was called by
-      // problemsForSubmittingChangeset, so now it is safe to read from
-      // the cache, as it yields the same result.
-      enabled = cd.isMergeable();
-    } catch (OrmException e) {
-      throw new OrmRuntimeException("Could not determine mergeability", e);
-    }
-
-    if (submitProblems != null) {
-      return new UiAction.Description()
-          .setLabel(treatWithTopic ? submitTopicLabel : (cs.size() > 1) ? labelWithParents : label)
-          .setTitle(submitProblems)
-          .setVisible(true)
-          .setEnabled(false);
-    }
-
-    if (treatWithTopic) {
-      Map<String, String> params =
-          ImmutableMap.of(
-              "topicSize", String.valueOf(topicSize),
-              "submitSize", String.valueOf(cs.size()));
-      return new UiAction.Description()
-          .setLabel(submitTopicLabel)
-          .setTitle(Strings.emptyToNull(submitTopicTooltip.replace(params)))
-          .setVisible(true)
-          .setEnabled(Boolean.TRUE.equals(enabled));
-    }
-    RevId revId = resource.getPatchSet().getRevision();
-    Map<String, String> params =
-        ImmutableMap.of(
-            "patchSet", String.valueOf(resource.getPatchSet().getPatchSetId()),
-            "branch", change.getDest().getShortName(),
-            "commit", ObjectId.fromString(revId.get()).abbreviate(7).name(),
-            "submitSize", String.valueOf(cs.size()));
-    ParameterizedString tp = cs.size() > 1 ? titlePatternWithAncestors : titlePattern;
-    return new UiAction.Description()
-        .setLabel(cs.size() > 1 ? labelWithParents : label)
-        .setTitle(Strings.emptyToNull(tp.replace(params)))
-        .setVisible(true)
-        .setEnabled(Boolean.TRUE.equals(enabled));
-  }
-
-  /**
-   * If the merge was attempted and it failed the system usually writes a comment as a ChangeMessage
-   * and sets status to NEW. Find the relevant message and return it.
-   */
-  public ChangeMessage getConflictMessage(RevisionResource rsrc) throws OrmException {
-    return FluentIterable.from(
-            cmUtil.byPatchSet(dbProvider.get(), rsrc.getNotes(), rsrc.getPatchSet().getId()))
-        .filter(cm -> cm.getAuthor() == null)
-        .last()
-        .orNull();
-  }
-
-  public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws OrmException, IOException {
-    Set<ChangeData> mergeabilityMap = new HashSet<>();
-    for (ChangeData change : cs.changes()) {
-      mergeabilityMap.add(change);
-    }
-
-    ListMultimap<Branch.NameKey, ChangeData> cbb = cs.changesByBranch();
-    for (Branch.NameKey branch : cbb.keySet()) {
-      Collection<ChangeData> targetBranch = cbb.get(branch);
-      HashMap<Change.Id, RevCommit> commits = findCommits(targetBranch, branch.getParentKey());
-
-      Set<ObjectId> allParents = Sets.newHashSetWithExpectedSize(cs.size());
-      for (RevCommit commit : commits.values()) {
-        for (RevCommit parent : commit.getParents()) {
-          allParents.add(parent.getId());
-        }
-      }
-
-      for (ChangeData change : targetBranch) {
-        RevCommit commit = commits.get(change.getId());
-        boolean isMergeCommit = commit.getParentCount() > 1;
-        boolean isLastInChain = !allParents.contains(commit.getId());
-
-        // Recheck mergeability rather than using value stored in the index,
-        // which may be stale.
-        // TODO(dborowitz): This is ugly; consider providing a way to not read
-        // stored fields from the index in the first place.
-        change.setMergeable(null);
-        Boolean mergeable = change.isMergeable();
-        if (mergeable == null) {
-          // Skip whole check, cannot determine if mergeable
-          return null;
-        }
-        if (mergeable) {
-          mergeabilityMap.remove(change);
-        }
-
-        if (isLastInChain && isMergeCommit && mergeable) {
-          for (ChangeData c : targetBranch) {
-            mergeabilityMap.remove(c);
-          }
-          break;
-        }
-      }
-    }
-    return mergeabilityMap;
-  }
-
-  private HashMap<Change.Id, RevCommit> findCommits(
-      Collection<ChangeData> changes, Project.NameKey project) throws IOException, OrmException {
-    HashMap<Change.Id, RevCommit> commits = new HashMap<>();
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk walk = new RevWalk(repo)) {
-      for (ChangeData change : changes) {
-        RevCommit commit =
-            walk.parseCommit(
-                ObjectId.fromString(
-                    psUtil.current(dbProvider.get(), change.notes()).getRevision().get()));
-        commits.put(change.getId(), commit);
-      }
-    }
-    return commits;
-  }
-
-  private IdentifiedUser onBehalfOf(RevisionResource rsrc, SubmitInput in)
-      throws AuthException, UnprocessableEntityException, OrmException, PermissionBackendException,
-          IOException, ConfigInvalidException {
-    PermissionBackend.ForChange perm = rsrc.permissions().database(dbProvider);
-    perm.check(ChangePermission.SUBMIT);
-    perm.check(ChangePermission.SUBMIT_AS);
-
-    CurrentUser caller = rsrc.getUser();
-    IdentifiedUser submitter = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
-    try {
-      perm.user(submitter).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new UnprocessableEntityException(
-          String.format("on_behalf_of account %s cannot see change", submitter.getAccountId()));
-    }
-    return submitter;
-  }
-
-  public static boolean wholeTopicEnabled(Config config) {
-    return config.getBoolean("change", null, "submitWholeTopic", false);
-  }
-
-  private List<ChangeData> getChangesByTopic(String topic) {
-    try {
-      return queryProvider.get().byTopicOpen(topic);
-    } catch (OrmException e) {
-      throw new OrmRuntimeException(e);
-    }
-  }
-
-  public static class CurrentRevision implements RestModifyView<ChangeResource, SubmitInput> {
-    private final Provider<ReviewDb> dbProvider;
-    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) {
-      this.dbProvider = dbProvider;
-      this.submit = submit;
-      this.json = json;
-      this.psUtil = psUtil;
-      this.changeControlFactory = changeControlFactory;
-    }
-
-    @Override
-    public ChangeInfo apply(ChangeResource rsrc, SubmitInput input)
-        throws RestApiException, RepositoryNotFoundException, IOException, OrmException,
-            PermissionBackendException, UpdateException, ConfigInvalidException {
-      PatchSet ps = psUtil.current(dbProvider.get(), rsrc.getNotes());
-      if (ps == null) {
-        throw new ResourceConflictException("current revision is missing");
-      } else if (!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);
-      return json.noOptions().format(out.change);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
deleted file mode 100644
index 98e47a9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
+++ /dev/null
@@ -1,168 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
-
-import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
-import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.change.WalkSorter.PatchSetData;
-import com.google.gerrit.server.git.ChangeSet;
-import com.google.gerrit.server.git.MergeSuperSet;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.List;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class SubmittedTogether implements RestReadView<ChangeResource> {
-  private static final Logger log = LoggerFactory.getLogger(SubmittedTogether.class);
-
-  private final EnumSet<SubmittedTogetherOption> options =
-      EnumSet.noneOf(SubmittedTogetherOption.class);
-
-  private final EnumSet<ListChangesOption> jsonOpt =
-      EnumSet.of(
-          ListChangesOption.CURRENT_REVISION,
-          ListChangesOption.CURRENT_COMMIT,
-          ListChangesOption.SUBMITTABLE);
-
-  private final ChangeJson.Factory json;
-  private final Provider<ReviewDb> dbProvider;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final Provider<MergeSuperSet> mergeSuperSet;
-  private final Provider<WalkSorter> sorter;
-
-  @Option(name = "-o", usage = "Output options")
-  void addOption(String option) {
-    for (ListChangesOption o : ListChangesOption.values()) {
-      if (o.name().equalsIgnoreCase(option)) {
-        jsonOpt.add(o);
-        return;
-      }
-    }
-
-    for (SubmittedTogetherOption o : SubmittedTogetherOption.values()) {
-      if (o.name().equalsIgnoreCase(option)) {
-        options.add(o);
-        return;
-      }
-    }
-
-    throw new IllegalArgumentException("option not recognized: " + option);
-  }
-
-  @Inject
-  SubmittedTogether(
-      ChangeJson.Factory json,
-      Provider<ReviewDb> dbProvider,
-      Provider<InternalChangeQuery> queryProvider,
-      Provider<MergeSuperSet> mergeSuperSet,
-      Provider<WalkSorter> sorter) {
-    this.json = json;
-    this.dbProvider = dbProvider;
-    this.queryProvider = queryProvider;
-    this.mergeSuperSet = mergeSuperSet;
-    this.sorter = sorter;
-  }
-
-  public SubmittedTogether addListChangesOption(EnumSet<ListChangesOption> o) {
-    jsonOpt.addAll(o);
-    return this;
-  }
-
-  public SubmittedTogether addSubmittedTogetherOption(EnumSet<SubmittedTogetherOption> o) {
-    options.addAll(o);
-    return this;
-  }
-
-  @Override
-  public Object apply(ChangeResource resource)
-      throws AuthException, BadRequestException, ResourceConflictException, IOException,
-          OrmException, PermissionBackendException {
-    SubmittedTogetherInfo info = applyInfo(resource);
-    if (options.isEmpty()) {
-      return info.changes;
-    }
-    return info;
-  }
-
-  public SubmittedTogetherInfo applyInfo(ChangeResource resource)
-      throws AuthException, IOException, OrmException, PermissionBackendException {
-    Change c = resource.getChange();
-    try {
-      List<ChangeData> cds;
-      int hidden;
-
-      if (c.getStatus().isOpen()) {
-        ChangeSet cs =
-            mergeSuperSet.get().completeChangeSet(dbProvider.get(), c, resource.getUser());
-        cds = cs.changes().asList();
-        hidden = cs.nonVisibleChanges().size();
-      } else if (c.getStatus().asChangeStatus() == ChangeStatus.MERGED) {
-        cds = queryProvider.get().bySubmissionId(c.getSubmissionId());
-        hidden = 0;
-      } else {
-        cds = Collections.emptyList();
-        hidden = 0;
-      }
-
-      if (hidden != 0 && !options.contains(NON_VISIBLE_CHANGES)) {
-        throw new AuthException("change would be submitted with a change that you cannot see");
-      }
-
-      if (cds.size() <= 1 && hidden == 0) {
-        cds = Collections.emptyList();
-      } else {
-        // Skip sorting for singleton lists, to avoid WalkSorter opening the
-        // repo just to fill out the commit field in PatchSetData.
-        cds = sort(cds);
-      }
-
-      SubmittedTogetherInfo info = new SubmittedTogetherInfo();
-      info.changes = json.create(jsonOpt).formatChangeDatas(cds);
-      info.nonVisibleChanges = hidden;
-      return info;
-    } catch (OrmException | IOException e) {
-      log.error("Error on getting a ChangeSet", e);
-      throw e;
-    }
-  }
-
-  private List<ChangeData> sort(List<ChangeData> cds) throws OrmException, IOException {
-    List<ChangeData> sorted = new ArrayList<>(cds.size());
-    for (PatchSetData psd : sorter.get().sort(cds)) {
-      sorted.add(psd.data());
-    }
-    return sorted;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
deleted file mode 100644
index fe21858..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.IdentifiedUser.GenericFactory;
-import com.google.gerrit.server.ReviewersUtil;
-import com.google.gerrit.server.ReviewersUtil.VisibilityControl;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.kohsuke.args4j.Option;
-
-public class SuggestChangeReviewers extends SuggestReviewers
-    implements RestReadView<ChangeResource> {
-
-  @Option(
-    name = "--exclude-groups",
-    aliases = {"-e"},
-    usage = "exclude groups from query"
-  )
-  boolean excludeGroups;
-
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> self;
-  private final ProjectCache projectCache;
-
-  @Inject
-  SuggestChangeReviewers(
-      AccountVisibility av,
-      GenericFactory identifiedUserFactory,
-      Provider<ReviewDb> dbProvider,
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> self,
-      @GerritServerConfig Config cfg,
-      ReviewersUtil reviewersUtil,
-      ProjectCache projectCache) {
-    super(av, identifiedUserFactory, dbProvider, cfg, reviewersUtil);
-    this.permissionBackend = permissionBackend;
-    this.self = self;
-    this.projectCache = projectCache;
-  }
-
-  @Override
-  public List<SuggestedReviewerInfo> apply(ChangeResource rsrc)
-      throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException {
-    if (!self.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-    return reviewersUtil.suggestReviewers(
-        rsrc.getNotes(),
-        this,
-        projectCache.checkedGet(rsrc.getProject()),
-        getVisibility(rsrc),
-        excludeGroups);
-  }
-
-  private VisibilityControl getVisibility(ChangeResource rsrc) {
-    // Use the destination reference, not the change, as drafts may deny
-    // anyone who is not already a reviewer.
-    // TODO(hiesel) Replace this with a check on the change resource once support for drafts was removed
-    PermissionBackend.ForRef perm = permissionBackend.user(self).ref(rsrc.getChange().getDest());
-    return new VisibilityControl() {
-      @Override
-      public boolean isVisibleTo(Account.Id account) throws OrmException {
-        IdentifiedUser who = identifiedUserFactory.create(account);
-        return perm.user(who).testOrFalse(RefPermission.READ);
-      }
-    };
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
deleted file mode 100644
index 2ed80718..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.ReviewersUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import org.eclipse.jgit.lib.Config;
-import org.kohsuke.args4j.Option;
-
-public class SuggestReviewers {
-  private static final int DEFAULT_MAX_SUGGESTED = 10;
-
-  protected final Provider<ReviewDb> dbProvider;
-  protected final IdentifiedUser.GenericFactory identifiedUserFactory;
-  protected final ReviewersUtil reviewersUtil;
-
-  private final boolean suggestAccounts;
-  private final int maxAllowed;
-  private final int maxAllowedWithoutConfirmation;
-  protected int limit;
-  protected String query;
-  protected final int maxSuggestedReviewers;
-
-  @Option(
-    name = "--limit",
-    aliases = {"-n"},
-    metaVar = "CNT",
-    usage = "maximum number of reviewers to list"
-  )
-  public void setLimit(int l) {
-    this.limit = l <= 0 ? maxSuggestedReviewers : Math.min(l, maxSuggestedReviewers);
-  }
-
-  @Option(
-    name = "--query",
-    aliases = {"-q"},
-    metaVar = "QUERY",
-    usage = "match reviewers query"
-  )
-  public void setQuery(String q) {
-    this.query = q;
-  }
-
-  public String getQuery() {
-    return query;
-  }
-
-  public boolean getSuggestAccounts() {
-    return suggestAccounts;
-  }
-
-  public int getLimit() {
-    return limit;
-  }
-
-  public int getMaxAllowed() {
-    return maxAllowed;
-  }
-
-  public int getMaxAllowedWithoutConfirmation() {
-    return maxAllowedWithoutConfirmation;
-  }
-
-  @Inject
-  public SuggestReviewers(
-      AccountVisibility av,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      Provider<ReviewDb> dbProvider,
-      @GerritServerConfig Config cfg,
-      ReviewersUtil reviewersUtil) {
-    this.dbProvider = dbProvider;
-    this.identifiedUserFactory = identifiedUserFactory;
-    this.reviewersUtil = reviewersUtil;
-    this.maxSuggestedReviewers =
-        cfg.getInt("suggest", "maxSuggestedReviewers", DEFAULT_MAX_SUGGESTED);
-    this.limit = this.maxSuggestedReviewers;
-    String suggest = cfg.getString("suggest", null, "accounts");
-    if ("OFF".equalsIgnoreCase(suggest) || "false".equalsIgnoreCase(suggest)) {
-      this.suggestAccounts = false;
-    } else {
-      this.suggestAccounts = (av != AccountVisibility.NONE);
-    }
-
-    this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed", PostReviewers.DEFAULT_MAX_REVIEWERS);
-    this.maxAllowedWithoutConfirmation =
-        cfg.getInt(
-            "addreviewer",
-            "maxWithoutConfirmation",
-            PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java
deleted file mode 100644
index 1792c83..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java
+++ /dev/null
@@ -1,154 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.TestSubmitRuleInput;
-import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.rules.RulesCache;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import org.kohsuke.args4j.Option;
-
-public class TestSubmitRule implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
-  private final Provider<ReviewDb> db;
-  private final ChangeData.Factory changeDataFactory;
-  private final RulesCache rules;
-  private final AccountLoader.Factory accountInfoFactory;
-  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
-
-  @Option(name = "--filters", usage = "impact of filters in parent projects")
-  private Filters filters = Filters.RUN;
-
-  @Inject
-  TestSubmitRule(
-      Provider<ReviewDb> db,
-      ChangeData.Factory changeDataFactory,
-      RulesCache rules,
-      AccountLoader.Factory infoFactory,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
-    this.db = db;
-    this.changeDataFactory = changeDataFactory;
-    this.rules = rules;
-    this.accountInfoFactory = infoFactory;
-    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
-  }
-
-  @Override
-  public List<Record> apply(RevisionResource rsrc, TestSubmitRuleInput input)
-      throws AuthException, OrmException {
-    if (input == null) {
-      input = new TestSubmitRuleInput();
-    }
-    if (input.rule != null && !rules.isProjectRulesEnabled()) {
-      throw new AuthException("project rules are disabled");
-    }
-    input.filters = MoreObjects.firstNonNull(input.filters, filters);
-    SubmitRuleEvaluator evaluator =
-        submitRuleEvaluatorFactory.create(
-            rsrc.getUser(), changeDataFactory.create(db.get(), rsrc.getNotes()));
-
-    List<SubmitRecord> records =
-        evaluator
-            .setPatchSet(rsrc.getPatchSet())
-            .setLogErrors(false)
-            .setSkipSubmitFilters(input.filters == Filters.SKIP)
-            .setRule(input.rule)
-            .evaluate();
-    List<Record> out = Lists.newArrayListWithCapacity(records.size());
-    AccountLoader accounts = accountInfoFactory.create(true);
-    for (SubmitRecord r : records) {
-      out.add(new Record(r, accounts));
-    }
-    if (!out.isEmpty()) {
-      out.get(0).prologReductionCount = evaluator.getReductionsConsumed();
-    }
-    accounts.fill();
-    return out;
-  }
-
-  static class Record {
-    SubmitRecord.Status status;
-    String errorMessage;
-    Map<String, AccountInfo> ok;
-    Map<String, AccountInfo> reject;
-    Map<String, None> need;
-    Map<String, AccountInfo> may;
-    Map<String, None> impossible;
-    Long prologReductionCount;
-
-    Record(SubmitRecord r, AccountLoader accounts) {
-      this.status = r.status;
-      this.errorMessage = r.errorMessage;
-
-      if (r.labels != null) {
-        for (SubmitRecord.Label n : r.labels) {
-          AccountInfo who = n.appliedBy != null ? accounts.get(n.appliedBy) : new AccountInfo(null);
-          label(n, who);
-        }
-      }
-    }
-
-    private void label(SubmitRecord.Label n, AccountInfo who) {
-      switch (n.status) {
-        case OK:
-          if (ok == null) {
-            ok = new LinkedHashMap<>();
-          }
-          ok.put(n.label, who);
-          break;
-        case REJECT:
-          if (reject == null) {
-            reject = new LinkedHashMap<>();
-          }
-          reject.put(n.label, who);
-          break;
-        case NEED:
-          if (need == null) {
-            need = new LinkedHashMap<>();
-          }
-          need.put(n.label, new None());
-          break;
-        case MAY:
-          if (may == null) {
-            may = new LinkedHashMap<>();
-          }
-          may.put(n.label, who);
-          break;
-        case IMPOSSIBLE:
-          if (impossible == null) {
-            impossible = new LinkedHashMap<>();
-          }
-          impossible.put(n.label, new None());
-          break;
-      }
-    }
-  }
-
-  static class None {}
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java
deleted file mode 100644
index ca6f9cf..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.MoreObjects;
-import com.google.gerrit.common.data.SubmitTypeRecord;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.TestSubmitRuleInput;
-import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.rules.RulesCache;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import org.kohsuke.args4j.Option;
-
-public class TestSubmitType implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
-  private final Provider<ReviewDb> db;
-  private final ChangeData.Factory changeDataFactory;
-  private final RulesCache rules;
-  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
-
-  @Option(name = "--filters", usage = "impact of filters in parent projects")
-  private Filters filters = Filters.RUN;
-
-  @Inject
-  TestSubmitType(
-      Provider<ReviewDb> db,
-      ChangeData.Factory changeDataFactory,
-      RulesCache rules,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
-    this.db = db;
-    this.changeDataFactory = changeDataFactory;
-    this.rules = rules;
-    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
-  }
-
-  @Override
-  public SubmitType apply(RevisionResource rsrc, TestSubmitRuleInput input)
-      throws AuthException, BadRequestException, OrmException {
-    if (input == null) {
-      input = new TestSubmitRuleInput();
-    }
-    if (input.rule != null && !rules.isProjectRulesEnabled()) {
-      throw new AuthException("project rules are disabled");
-    }
-    input.filters = MoreObjects.firstNonNull(input.filters, filters);
-    SubmitRuleEvaluator evaluator =
-        submitRuleEvaluatorFactory.create(
-            rsrc.getUser(), changeDataFactory.create(db.get(), rsrc.getNotes()));
-
-    SubmitTypeRecord rec =
-        evaluator
-            .setPatchSet(rsrc.getPatchSet())
-            .setLogErrors(false)
-            .setSkipSubmitFilters(input.filters == Filters.SKIP)
-            .setRule(input.rule)
-            .getSubmitType();
-    if (rec.status != SubmitTypeRecord.Status.OK) {
-      throw new BadRequestException(
-          String.format("rule %s produced invalid result: %s", evaluator.getSubmitRuleName(), rec));
-    }
-
-    return rec.type;
-  }
-
-  public static class Get implements RestReadView<RevisionResource> {
-    private final TestSubmitType test;
-
-    @Inject
-    Get(TestSubmitType test) {
-      this.test = test;
-    }
-
-    @Override
-    public SubmitType apply(RevisionResource resource)
-        throws AuthException, BadRequestException, OrmException {
-      return test.apply(resource, null);
-    }
-  }
-}
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
deleted file mode 100644
index 2bad16c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class Unignore
-    implements RestModifyView<ChangeResource, Unignore.Input>, UiAction<ChangeResource> {
-  private static final Logger log = LoggerFactory.getLogger(Unignore.class);
-
-  public static class Input {}
-
-  private final StarredChangesUtil stars;
-
-  @Inject
-  Unignore(StarredChangesUtil stars) {
-    this.stars = stars;
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Unignore")
-        .setTitle("Unignore the change")
-        .setVisible(isIgnored(rsrc));
-  }
-
-  @Override
-  public Response<String> apply(ChangeResource rsrc, Input input)
-      throws OrmException, IllegalLabelException {
-    if (isIgnored(rsrc)) {
-      stars.unignore(rsrc);
-    }
-    return Response.ok("");
-  }
-
-  private boolean isIgnored(ChangeResource rsrc) {
-    try {
-      return stars.isIgnored(rsrc);
-    } catch (OrmException e) {
-      log.error("failed to check ignored star", e);
-    }
-    return false;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/VoteResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/VoteResource.java
deleted file mode 100644
index 4dfaff0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/VoteResource.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.inject.TypeLiteral;
-
-public class VoteResource implements RestResource {
-  public static final TypeLiteral<RestView<VoteResource>> VOTE_KIND =
-      new TypeLiteral<RestView<VoteResource>>() {};
-
-  private final ReviewerResource reviewer;
-  private final String label;
-
-  public VoteResource(ReviewerResource reviewer, String label) {
-    this.reviewer = reviewer;
-    this.label = label;
-  }
-
-  public ReviewerResource getReviewer() {
-    return reviewer;
-  }
-
-  public String getLabel() {
-    return label;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
deleted file mode 100644
index c2631d5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.Map;
-import java.util.TreeMap;
-
-@Singleton
-public class Votes implements ChildCollection<ReviewerResource, VoteResource> {
-  private final DynamicMap<RestView<VoteResource>> views;
-  private final List list;
-
-  @Inject
-  Votes(DynamicMap<RestView<VoteResource>> views, List list) {
-    this.views = views;
-    this.list = list;
-  }
-
-  @Override
-  public DynamicMap<RestView<VoteResource>> views() {
-    return views;
-  }
-
-  @Override
-  public RestView<ReviewerResource> list() throws AuthException {
-    return list;
-  }
-
-  @Override
-  public VoteResource parse(ReviewerResource reviewer, IdString id)
-      throws ResourceNotFoundException, OrmException, AuthException, MethodNotAllowedException {
-    if (reviewer.getRevisionResource() != null && !reviewer.getRevisionResource().isCurrent()) {
-      throw new MethodNotAllowedException("Cannot access on non-current patch set");
-    }
-    return new VoteResource(reviewer, id.get());
-  }
-
-  @Singleton
-  public static class List implements RestReadView<ReviewerResource> {
-    private final Provider<ReviewDb> db;
-    private final ApprovalsUtil approvalsUtil;
-
-    @Inject
-    List(Provider<ReviewDb> db, ApprovalsUtil approvalsUtil) {
-      this.db = db;
-      this.approvalsUtil = approvalsUtil;
-    }
-
-    @Override
-    public Map<String, Short> apply(ReviewerResource rsrc)
-        throws OrmException, MethodNotAllowedException {
-      if (rsrc.getRevisionResource() != null && !rsrc.getRevisionResource().isCurrent()) {
-        throw new MethodNotAllowedException("Cannot list votes on non-current patch set");
-      }
-
-      Map<String, Short> votes = new TreeMap<>();
-      Iterable<PatchSetApproval> byPatchSetUser =
-          approvalsUtil.byPatchSetUser(
-              db.get(),
-              rsrc.getChangeResource().getNotes(),
-              rsrc.getChangeResource().getUser(),
-              rsrc.getChange().currentPatchSetId(),
-              rsrc.getReviewerUser().getAccountId(),
-              null,
-              null);
-      for (PatchSetApproval psa : byPatchSetUser) {
-        votes.put(psa.getLabel(), psa.getValue());
-      }
-      return votes;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/WalkSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/WalkSorter.java
deleted file mode 100644
index 56d7ec0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/WalkSorter.java
+++ /dev/null
@@ -1,269 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Deque;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevFlag;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Helper to sort {@link ChangeData}s based on {@link RevWalk} ordering.
- *
- * <p>Split changes by project, and map each change to a single commit based on the latest patch
- * set. The set of patch sets considered may be limited by calling {@link
- * #includePatchSets(Iterable)}. Perform a standard {@link RevWalk} on each project repository, do
- * an approximate topo sort, and record the order in which each change's commit is seen.
- *
- * <p>Once an order within each project is determined, groups of changes are sorted based on the
- * project name. This is slightly more stable than sorting on something like the commit or change
- * timestamp, as it will not unexpectedly reorder large groups of changes on subsequent calls if one
- * of the changes was updated.
- */
-class WalkSorter {
-  private static final Logger log = LoggerFactory.getLogger(WalkSorter.class);
-
-  private static final Ordering<List<PatchSetData>> PROJECT_LIST_SORTER =
-      Ordering.natural()
-          .nullsFirst()
-          .onResultOf(
-              (List<PatchSetData> in) -> {
-                if (in == null || in.isEmpty()) {
-                  return null;
-                }
-                try {
-                  return in.get(0).data().change().getProject();
-                } catch (OrmException e) {
-                  throw new IllegalStateException(e);
-                }
-              });
-
-  private final GitRepositoryManager repoManager;
-  private final Set<PatchSet.Id> includePatchSets;
-  private boolean retainBody;
-
-  @Inject
-  WalkSorter(GitRepositoryManager repoManager) {
-    this.repoManager = repoManager;
-    includePatchSets = new HashSet<>();
-  }
-
-  public WalkSorter includePatchSets(Iterable<PatchSet.Id> patchSets) {
-    Iterables.addAll(includePatchSets, patchSets);
-    return this;
-  }
-
-  public WalkSorter setRetainBody(boolean retainBody) {
-    this.retainBody = retainBody;
-    return this;
-  }
-
-  public Iterable<PatchSetData> sort(Iterable<ChangeData> in) throws OrmException, IOException {
-    ListMultimap<Project.NameKey, ChangeData> byProject =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-    for (ChangeData cd : in) {
-      byProject.put(cd.change().getProject(), cd);
-    }
-
-    List<List<PatchSetData>> sortedByProject = new ArrayList<>(byProject.keySet().size());
-    for (Map.Entry<Project.NameKey, Collection<ChangeData>> e : byProject.asMap().entrySet()) {
-      sortedByProject.add(sortProject(e.getKey(), e.getValue()));
-    }
-    Collections.sort(sortedByProject, PROJECT_LIST_SORTER);
-    return Iterables.concat(sortedByProject);
-  }
-
-  private List<PatchSetData> sortProject(Project.NameKey project, Collection<ChangeData> in)
-      throws OrmException, IOException {
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      rw.setRetainBody(retainBody);
-      ListMultimap<RevCommit, PatchSetData> byCommit = byCommit(rw, in);
-      if (byCommit.isEmpty()) {
-        return ImmutableList.of();
-      } else if (byCommit.size() == 1) {
-        return ImmutableList.of(byCommit.values().iterator().next());
-      }
-
-      // Walk from all patch set SHA-1s, and terminate as soon as we've found
-      // everything we're looking for. This is equivalent to just sorting the
-      // list of commits by the RevWalk's configured order.
-      //
-      // Partially topo sort the list, ensuring no parent is emitted before a
-      // direct child that is also in the input set. This preserves the stable,
-      // expected sort in the case where many commits share the same timestamp,
-      // e.g. a quick rebase. It also avoids JGit's topo sort, which slurps all
-      // interesting commits at the beginning, which is a problem since we don't
-      // know which commits to mark as uninteresting. Finding a reasonable set
-      // of commits to mark uninteresting (the "rootmost" set) is at least as
-      // difficult as just implementing this partial topo sort ourselves.
-      //
-      // (This is slightly less efficient than JGit's topo sort, which uses a
-      // private in-degree field in RevCommit rather than multimaps. We assume
-      // the input size is small enough that this is not an issue.)
-
-      Set<RevCommit> commits = byCommit.keySet();
-      ListMultimap<RevCommit, RevCommit> children = collectChildren(commits);
-      ListMultimap<RevCommit, RevCommit> pending =
-          MultimapBuilder.hashKeys().arrayListValues().build();
-      Deque<RevCommit> todo = new ArrayDeque<>();
-
-      RevFlag done = rw.newFlag("done");
-      markStart(rw, commits);
-      int expected = commits.size();
-      int found = 0;
-      RevCommit c;
-      List<PatchSetData> result = new ArrayList<>(expected);
-      while (found < expected && (c = rw.next()) != null) {
-        if (!commits.contains(c)) {
-          continue;
-        }
-        todo.clear();
-        todo.add(c);
-        int i = 0;
-        while (!todo.isEmpty()) {
-          // Sanity check: we can't pop more than N pending commits, otherwise
-          // we have an infinite loop due to programmer error or something.
-          checkState(++i <= commits.size(), "Too many pending steps while sorting %s", commits);
-          RevCommit t = todo.removeFirst();
-          if (t.has(done)) {
-            continue;
-          }
-          boolean ready = true;
-          for (RevCommit child : children.get(t)) {
-            if (!child.has(done)) {
-              pending.put(child, t);
-              ready = false;
-            }
-          }
-          if (ready) {
-            found += emit(t, byCommit, result, done);
-            todo.addAll(pending.get(t));
-          }
-        }
-      }
-      return result;
-    }
-  }
-
-  private static ListMultimap<RevCommit, RevCommit> collectChildren(Set<RevCommit> commits) {
-    ListMultimap<RevCommit, RevCommit> children =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-    for (RevCommit c : commits) {
-      for (RevCommit p : c.getParents()) {
-        if (commits.contains(p)) {
-          children.put(p, c);
-        }
-      }
-    }
-    return children;
-  }
-
-  private static int emit(
-      RevCommit c,
-      ListMultimap<RevCommit, PatchSetData> byCommit,
-      List<PatchSetData> result,
-      RevFlag done) {
-    if (c.has(done)) {
-      return 0;
-    }
-    c.add(done);
-    Collection<PatchSetData> psds = byCommit.get(c);
-    if (!psds.isEmpty()) {
-      result.addAll(psds);
-      return 1;
-    }
-    return 0;
-  }
-
-  private ListMultimap<RevCommit, PatchSetData> byCommit(RevWalk rw, Collection<ChangeData> in)
-      throws OrmException, IOException {
-    ListMultimap<RevCommit, PatchSetData> byCommit =
-        MultimapBuilder.hashKeys(in.size()).arrayListValues(1).build();
-    for (ChangeData cd : in) {
-      PatchSet maxPs = null;
-      for (PatchSet ps : cd.patchSets()) {
-        if (shouldInclude(ps) && (maxPs == null || ps.getId().get() > maxPs.getId().get())) {
-          maxPs = ps;
-        }
-      }
-      if (maxPs == null) {
-        continue; // No patch sets matched.
-      }
-      ObjectId id = ObjectId.fromString(maxPs.getRevision().get());
-      try {
-        RevCommit c = rw.parseCommit(id);
-        byCommit.put(c, PatchSetData.create(cd, maxPs, c));
-      } catch (MissingObjectException | IncorrectObjectTypeException e) {
-        log.warn("missing commit " + id.name() + " for patch set " + maxPs.getId(), e);
-      }
-    }
-    return byCommit;
-  }
-
-  private boolean shouldInclude(PatchSet ps) {
-    return includePatchSets.isEmpty() || includePatchSets.contains(ps.getId());
-  }
-
-  private static void markStart(RevWalk rw, Iterable<RevCommit> commits) throws IOException {
-    for (RevCommit c : commits) {
-      rw.markStart(c);
-    }
-  }
-
-  @AutoValue
-  abstract static class PatchSetData {
-    @VisibleForTesting
-    static PatchSetData create(ChangeData cd, PatchSet ps, RevCommit commit) {
-      return new AutoValue_WalkSorter_PatchSetData(cd, ps, commit);
-    }
-
-    abstract ChangeData data();
-
-    abstract PatchSet patchSet();
-
-    abstract RevCommit commit();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/WorkInProgressOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/WorkInProgressOp.java
deleted file mode 100644
index 43de55c3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ /dev/null
@@ -1,145 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/* Set work in progress or ready for review state on a change */
-public class WorkInProgressOp implements BatchUpdateOp {
-  public static class Input {
-    @Nullable String message;
-
-    @Nullable NotifyHandling notify;
-
-    public Input() {}
-
-    public Input(String message) {
-      this.message = message;
-    }
-  }
-
-  public interface Factory {
-    WorkInProgressOp create(boolean workInProgress, Input in);
-  }
-
-  private final ChangeMessagesUtil cmUtil;
-  private final EmailReviewComments.Factory email;
-  private final PatchSetUtil psUtil;
-  private final boolean workInProgress;
-  private final Input in;
-  private final NotifyHandling notify;
-  private final WorkInProgressStateChanged stateChanged;
-
-  private Change change;
-  private ChangeNotes notes;
-  private PatchSet ps;
-  private ChangeMessage cmsg;
-
-  @Inject
-  WorkInProgressOp(
-      ChangeMessagesUtil cmUtil,
-      EmailReviewComments.Factory email,
-      PatchSetUtil psUtil,
-      WorkInProgressStateChanged stateChanged,
-      @Assisted boolean workInProgress,
-      @Assisted Input in) {
-    this.cmUtil = cmUtil;
-    this.email = email;
-    this.psUtil = psUtil;
-    this.stateChanged = stateChanged;
-    this.workInProgress = workInProgress;
-    this.in = in;
-    notify =
-        MoreObjects.firstNonNull(
-            in.notify, workInProgress ? NotifyHandling.NONE : NotifyHandling.ALL);
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx) throws OrmException {
-    change = ctx.getChange();
-    notes = ctx.getNotes();
-    ps = psUtil.get(ctx.getDb(), ctx.getNotes(), change.currentPatchSetId());
-    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-    change.setWorkInProgress(workInProgress);
-    if (!change.hasReviewStarted() && !workInProgress) {
-      change.setReviewStarted(true);
-    }
-    change.setLastUpdatedOn(ctx.getWhen());
-    update.setWorkInProgress(workInProgress);
-    addMessage(ctx, update);
-    return true;
-  }
-
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
-    Change c = ctx.getChange();
-    StringBuilder buf =
-        new StringBuilder(c.isWorkInProgress() ? "Set Work In Progress" : "Set Ready For Review");
-
-    String m = Strings.nullToEmpty(in == null ? null : in.message).trim();
-    if (!m.isEmpty()) {
-      buf.append("\n\n");
-      buf.append(m);
-    }
-
-    cmsg =
-        ChangeMessagesUtil.newMessage(
-            ctx,
-            buf.toString(),
-            c.isWorkInProgress()
-                ? ChangeMessagesUtil.TAG_SET_WIP
-                : ChangeMessagesUtil.TAG_SET_READY);
-
-    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
-  }
-
-  @Override
-  public void postUpdate(Context ctx) {
-    stateChanged.fire(change, ctx.getAccount(), ctx.getWhen());
-    if (workInProgress || notify.ordinal() < NotifyHandling.OWNER_REVIEWERS.ordinal()) {
-      return;
-    }
-    email
-        .create(
-            notify,
-            ImmutableListMultimap.of(),
-            notes,
-            ps,
-            ctx.getIdentifiedUser(),
-            cmsg,
-            ImmutableList.of(),
-            cmsg.getMessage(),
-            ImmutableList.of())
-        .sendAsync();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AgreementJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AgreementJson.java
deleted file mode 100644
index 83eca9c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AgreementJson.java
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.AgreementInfo;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.group.GroupJson;
-import com.google.gerrit.server.group.GroupResource;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class AgreementJson {
-  private static final Logger log = LoggerFactory.getLogger(AgreementJson.class);
-
-  private final Provider<CurrentUser> self;
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
-  private final GroupControl.GenericFactory genericGroupControlFactory;
-  private final GroupJson groupJson;
-
-  @Inject
-  AgreementJson(
-      Provider<CurrentUser> self,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      GroupControl.GenericFactory genericGroupControlFactory,
-      GroupJson groupJson) {
-    this.self = self;
-    this.identifiedUserFactory = identifiedUserFactory;
-    this.genericGroupControlFactory = genericGroupControlFactory;
-    this.groupJson = groupJson;
-  }
-
-  public AgreementInfo format(ContributorAgreement ca) {
-    AgreementInfo info = new AgreementInfo();
-    info.name = ca.getName();
-    info.description = ca.getDescription();
-    info.url = ca.getAgreementUrl();
-    GroupReference autoVerifyGroup = ca.getAutoVerify();
-    if (autoVerifyGroup != null && self.get().isIdentifiedUser()) {
-      IdentifiedUser user = identifiedUserFactory.create(self.get().getAccountId());
-      try {
-        GroupControl gc = genericGroupControlFactory.controlFor(user, autoVerifyGroup.getUUID());
-        GroupResource group = new GroupResource(gc);
-        info.autoVerifyGroup = groupJson.format(group);
-      } catch (NoSuchGroupException | OrmException e) {
-        log.warn(
-            "autoverify group \""
-                + autoVerifyGroup.getName()
-                + "\" does not exist, referenced in CLA \""
-                + ca.getName()
-                + "\"");
-      }
-    }
-    return info;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardNameProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardNameProvider.java
deleted file mode 100644
index 79676f6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardNameProvider.java
+++ /dev/null
@@ -1,39 +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.config;
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import org.eclipse.jgit.lib.Config;
-
-public class AnonymousCowardNameProvider implements Provider<String> {
-  public static final String DEFAULT = "Anonymous Coward";
-
-  private final String anonymousCoward;
-
-  @Inject
-  public AnonymousCowardNameProvider(@GerritServerConfig Config cfg) {
-    String anonymousCoward = cfg.getString("user", null, "anonymousCoward");
-    if (anonymousCoward == null) {
-      anonymousCoward = DEFAULT;
-    }
-    this.anonymousCoward = anonymousCoward;
-  }
-
-  @Override
-  public String get() {
-    return anonymousCoward;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java
deleted file mode 100644
index 7ecfa63..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_CACHES;
-
-import com.google.common.cache.Cache;
-import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsPost;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@RequiresAnyCapability({VIEW_CACHES, MAINTAIN_SERVER})
-@Singleton
-public class CachesCollection
-    implements ChildCollection<ConfigResource, CacheResource>, AcceptsPost<ConfigResource> {
-
-  private final DynamicMap<RestView<CacheResource>> views;
-  private final Provider<ListCaches> list;
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> self;
-  private final DynamicMap<Cache<?, ?>> cacheMap;
-  private final PostCaches postCaches;
-
-  @Inject
-  CachesCollection(
-      DynamicMap<RestView<CacheResource>> views,
-      Provider<ListCaches> list,
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> self,
-      DynamicMap<Cache<?, ?>> cacheMap,
-      PostCaches postCaches) {
-    this.views = views;
-    this.list = list;
-    this.permissionBackend = permissionBackend;
-    this.self = self;
-    this.cacheMap = cacheMap;
-    this.postCaches = postCaches;
-  }
-
-  @Override
-  public RestView<ConfigResource> list() {
-    return list.get();
-  }
-
-  @Override
-  public CacheResource parse(ConfigResource parent, IdString id)
-      throws AuthException, ResourceNotFoundException, PermissionBackendException {
-    permissionBackend.user(self).check(GlobalPermission.VIEW_CACHES);
-
-    String cacheName = id.get();
-    String pluginName = "gerrit";
-    int i = cacheName.lastIndexOf('-');
-    if (i != -1) {
-      pluginName = cacheName.substring(0, i);
-      cacheName = cacheName.length() > i + 1 ? cacheName.substring(i + 1) : "";
-    }
-
-    Provider<Cache<?, ?>> cacheProvider = cacheMap.byPlugin(pluginName).get(cacheName);
-    if (cacheProvider == null) {
-      throw new ResourceNotFoundException(id);
-    }
-    return new CacheResource(pluginName, cacheName, cacheProvider);
-  }
-
-  @Override
-  public DynamicMap<RestView<CacheResource>> views() {
-    return views;
-  }
-
-  @Override
-  public PostCaches post(ConfigResource parent) throws RestApiException {
-    return postCaches;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CapabilitiesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CapabilitiesCollection.java
deleted file mode 100644
index 1124048..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CapabilitiesCollection.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class CapabilitiesCollection implements ChildCollection<ConfigResource, CapabilityResource> {
-  private final DynamicMap<RestView<CapabilityResource>> views;
-  private final ListCapabilities list;
-
-  @Inject
-  CapabilitiesCollection(DynamicMap<RestView<CapabilityResource>> views, ListCapabilities list) {
-    this.views = views;
-    this.list = list;
-  }
-
-  @Override
-  public RestView<ConfigResource> list() throws ResourceNotFoundException {
-    return list;
-  }
-
-  @Override
-  public CapabilityResource parse(ConfigResource parent, IdString id)
-      throws ResourceNotFoundException {
-    throw new ResourceNotFoundException(id);
-  }
-
-  @Override
-  public DynamicMap<RestView<CapabilityResource>> views() {
-    return views;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java
deleted file mode 100644
index eaf45be..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.CheckAccountExternalIdsResultInfo;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.CheckAccountsResultInfo;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountsConsistencyChecker;
-import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class CheckConsistency implements RestModifyView<ConfigResource, ConsistencyCheckInput> {
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> user;
-  private final AccountsConsistencyChecker accountsConsistencyChecker;
-  private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
-
-  @Inject
-  CheckConsistency(
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> user,
-      AccountsConsistencyChecker accountsConsistencyChecker,
-      ExternalIdsConsistencyChecker externalIdsConsistencyChecker) {
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-    this.accountsConsistencyChecker = accountsConsistencyChecker;
-    this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
-  }
-
-  @Override
-  public ConsistencyCheckInfo apply(ConfigResource resource, ConsistencyCheckInput input)
-      throws RestApiException, IOException, OrmException, PermissionBackendException {
-    permissionBackend.user(user).check(GlobalPermission.ACCESS_DATABASE);
-
-    if (input == null || (input.checkAccounts == null && input.checkAccountExternalIds == null)) {
-      throw new BadRequestException("input required");
-    }
-
-    ConsistencyCheckInfo consistencyCheckInfo = new ConsistencyCheckInfo();
-    if (input.checkAccounts != null) {
-      consistencyCheckInfo.checkAccountsResult =
-          new CheckAccountsResultInfo(accountsConsistencyChecker.check());
-    }
-    if (input.checkAccountExternalIds != null) {
-      consistencyCheckInfo.checkAccountExternalIdsResult =
-          new CheckAccountExternalIdsResultInfo(externalIdsConsistencyChecker.check());
-    }
-
-    return consistencyCheckInfo;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigCollection.java
deleted file mode 100644
index f268110..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigCollection.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestCollection;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class ConfigCollection implements RestCollection<TopLevelResource, ConfigResource> {
-  private final DynamicMap<RestView<ConfigResource>> views;
-
-  @Inject
-  ConfigCollection(DynamicMap<RestView<ConfigResource>> views) {
-    this.views = views;
-  }
-
-  @Override
-  public RestView<TopLevelResource> list() throws ResourceNotFoundException {
-    throw new ResourceNotFoundException();
-  }
-
-  @Override
-  public DynamicMap<RestView<ConfigResource>> views() {
-    return views;
-  }
-
-  @Override
-  public ConfigResource parse(TopLevelResource root, IdString id) throws ResourceNotFoundException {
-    if (id.get().equals("server")) {
-      return new ConfigResource();
-    }
-    throw new ResourceNotFoundException(id);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java
deleted file mode 100644
index 1044bbb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountException;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.config.ConfirmEmail.Input;
-import com.google.gerrit.server.mail.EmailTokenVerifier;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class ConfirmEmail implements RestModifyView<ConfigResource, Input> {
-  public static class Input {
-    @DefaultInput public String token;
-  }
-
-  private final Provider<CurrentUser> self;
-  private final EmailTokenVerifier emailTokenVerifier;
-  private final AccountManager accountManager;
-
-  @Inject
-  public ConfirmEmail(
-      Provider<CurrentUser> self,
-      EmailTokenVerifier emailTokenVerifier,
-      AccountManager accountManager) {
-    this.self = self;
-    this.emailTokenVerifier = emailTokenVerifier;
-    this.accountManager = accountManager;
-  }
-
-  @Override
-  public Response<?> apply(ConfigResource rsrc, Input input)
-      throws AuthException, UnprocessableEntityException, AccountException, OrmException,
-          IOException, ConfigInvalidException {
-    CurrentUser user = self.get();
-    if (!user.isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    if (input == null) {
-      input = new Input();
-    }
-    if (input.token == null) {
-      throw new UnprocessableEntityException("missing token");
-    }
-
-    try {
-      EmailTokenVerifier.ParsedToken token = emailTokenVerifier.decode(input.token);
-      Account.Id accId = user.getAccountId();
-      if (accId.equals(token.getAccountId())) {
-        accountManager.link(accId, token.toAuthRequest());
-        return Response.none();
-      }
-      throw new UnprocessableEntityException("invalid token");
-    } catch (EmailTokenVerifier.InvalidTokenException e) {
-      throw new UnprocessableEntityException("invalid token");
-    } catch (AccountException e) {
-      throw new UnprocessableEntityException(e.getMessage());
-    }
-  }
-}
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
deleted file mode 100644
index 29ca20f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/DeleteTask.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static com.google.gerrit.common.data.GlobalCapability.KILL_TASK;
-import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
-import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
-
-import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
-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) {
-    Task<?> task = rsrc.getTask();
-    boolean taskDeleted = task.cancel(true);
-    return taskDeleted
-        ? Response.none()
-        : Response.withStatusCode(SC_INTERNAL_SERVER_ERROR, "Unable to kill task " + task);
-  }
-}
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
deleted file mode 100644
index 366dae1..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static com.google.gerrit.common.data.GlobalCapability.FLUSH_CACHES;
-import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
-
-import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
-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;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@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";
-
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> self;
-
-  @Inject
-  public FlushCache(PermissionBackend permissionBackend, Provider<CurrentUser> self) {
-    this.permissionBackend = permissionBackend;
-    this.self = self;
-  }
-
-  @Override
-  public Response<String> apply(CacheResource rsrc, Input input)
-      throws AuthException, PermissionBackendException {
-    if (WEB_SESSIONS.equals(rsrc.getName())) {
-      permissionBackend.user(self).check(GlobalPermission.MAINTAIN_SERVER);
-    }
-
-    rsrc.getCache().invalidateAll();
-    return Response.ok("");
-  }
-}
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
deleted file mode 100644
index 469c664..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ /dev/null
@@ -1,421 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static com.google.inject.Scopes.SINGLETON;
-
-import com.google.common.cache.Cache;
-import com.google.gerrit.audit.AuditModule;
-import com.google.gerrit.common.EventListener;
-import com.google.gerrit.common.UserScopedEventListener;
-import com.google.gerrit.extensions.annotations.Exports;
-import com.google.gerrit.extensions.api.changes.ActionVisitor;
-import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
-import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
-import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
-import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.extensions.config.CapabilityDefinition;
-import com.google.gerrit.extensions.config.CloneCommand;
-import com.google.gerrit.extensions.config.DownloadCommand;
-import com.google.gerrit.extensions.config.DownloadScheme;
-import com.google.gerrit.extensions.config.ExternalIncludedIn;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.extensions.events.AccountIndexedListener;
-import com.google.gerrit.extensions.events.AgreementSignupListener;
-import com.google.gerrit.extensions.events.AssigneeChangedListener;
-import com.google.gerrit.extensions.events.ChangeAbandonedListener;
-import com.google.gerrit.extensions.events.ChangeIndexedListener;
-import com.google.gerrit.extensions.events.ChangeMergedListener;
-import com.google.gerrit.extensions.events.ChangeRestoredListener;
-import com.google.gerrit.extensions.events.ChangeRevertedListener;
-import com.google.gerrit.extensions.events.CommentAddedListener;
-import com.google.gerrit.extensions.events.GarbageCollectorListener;
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.extensions.events.GroupIndexedListener;
-import com.google.gerrit.extensions.events.HashtagsEditedListener;
-import com.google.gerrit.extensions.events.HeadUpdatedListener;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.extensions.events.NewProjectCreatedListener;
-import com.google.gerrit.extensions.events.PluginEventListener;
-import com.google.gerrit.extensions.events.PrivateStateChangedListener;
-import com.google.gerrit.extensions.events.ProjectDeletedListener;
-import com.google.gerrit.extensions.events.ReviewerAddedListener;
-import com.google.gerrit.extensions.events.ReviewerDeletedListener;
-import com.google.gerrit.extensions.events.RevisionCreatedListener;
-import com.google.gerrit.extensions.events.TopicEditedListener;
-import com.google.gerrit.extensions.events.UsageDataPublishedListener;
-import com.google.gerrit.extensions.events.VoteDeletedListener;
-import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.systemstatus.MessageOfTheDay;
-import com.google.gerrit.extensions.webui.BranchWebLink;
-import com.google.gerrit.extensions.webui.DiffWebLink;
-import com.google.gerrit.extensions.webui.FileHistoryWebLink;
-import com.google.gerrit.extensions.webui.FileWebLink;
-import com.google.gerrit.extensions.webui.ParentWebLink;
-import com.google.gerrit.extensions.webui.PatchSetWebLink;
-import com.google.gerrit.extensions.webui.ProjectWebLink;
-import com.google.gerrit.extensions.webui.TagWebLink;
-import com.google.gerrit.extensions.webui.TopMenu;
-import com.google.gerrit.extensions.webui.WebUiPlugin;
-import com.google.gerrit.rules.PrologModule;
-import com.google.gerrit.rules.RulesCache;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeFinder;
-import com.google.gerrit.server.CmdLineParserModule;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PluginUser;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.AccountCacheImpl;
-import com.google.gerrit.server.account.AccountControl;
-import com.google.gerrit.server.account.AccountDeactivator;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.account.AccountVisibilityProvider;
-import com.google.gerrit.server.account.CapabilityCollection;
-import com.google.gerrit.server.account.ChangeUserName;
-import com.google.gerrit.server.account.EmailExpander;
-import com.google.gerrit.server.account.GroupCacheImpl;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.account.GroupIncludeCacheImpl;
-import com.google.gerrit.server.account.GroupMembers;
-import com.google.gerrit.server.account.VersionedAuthorizedKeys;
-import com.google.gerrit.server.account.externalids.ExternalIdModule;
-import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
-import com.google.gerrit.server.auth.AuthBackend;
-import com.google.gerrit.server.auth.UniversalAuthBackend;
-import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
-import com.google.gerrit.server.avatar.AvatarProvider;
-import com.google.gerrit.server.cache.CacheRemovalListener;
-import com.google.gerrit.server.change.AccountPatchReviewStore;
-import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.change.ChangeKindCacheImpl;
-import com.google.gerrit.server.change.MergeabilityCacheImpl;
-import com.google.gerrit.server.change.ReviewerSuggestion;
-import com.google.gerrit.server.events.EventFactory;
-import com.google.gerrit.server.events.EventsMetrics;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.AbandonOp;
-import com.google.gerrit.server.git.ChangeMessageModifier;
-import com.google.gerrit.server.git.EmailMerge;
-import com.google.gerrit.server.git.GitModule;
-import com.google.gerrit.server.git.GitModules;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.MergedByPushOp;
-import com.google.gerrit.server.git.NotesBranchUtil;
-import com.google.gerrit.server.git.ReceivePackInitializer;
-import com.google.gerrit.server.git.TagCache;
-import com.google.gerrit.server.git.TransferConfig;
-import com.google.gerrit.server.git.VisibleRefFilter;
-import com.google.gerrit.server.git.receive.ReceiveCommitsModule;
-import com.google.gerrit.server.git.strategy.SubmitStrategy;
-import com.google.gerrit.server.git.validators.CommitValidationListener;
-import com.google.gerrit.server.git.validators.MergeValidationListener;
-import com.google.gerrit.server.git.validators.MergeValidators;
-import com.google.gerrit.server.git.validators.MergeValidators.AccountMergeValidator;
-import com.google.gerrit.server.git.validators.MergeValidators.ProjectConfigValidator;
-import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
-import com.google.gerrit.server.git.validators.OnSubmitValidators;
-import com.google.gerrit.server.git.validators.RefOperationValidationListener;
-import com.google.gerrit.server.git.validators.RefOperationValidators;
-import com.google.gerrit.server.git.validators.UploadValidationListener;
-import com.google.gerrit.server.git.validators.UploadValidators;
-import com.google.gerrit.server.group.GroupModule;
-import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
-import com.google.gerrit.server.mail.EmailModule;
-import com.google.gerrit.server.mail.ListMailFilter;
-import com.google.gerrit.server.mail.MailFilter;
-import com.google.gerrit.server.mail.send.AddKeySender;
-import com.google.gerrit.server.mail.send.AddReviewerSender;
-import com.google.gerrit.server.mail.send.CreateChangeSender;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
-import com.google.gerrit.server.mail.send.FromAddressGenerator;
-import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
-import com.google.gerrit.server.mail.send.MailSoyTofuProvider;
-import com.google.gerrit.server.mail.send.MailTemplates;
-import com.google.gerrit.server.mail.send.MergedSender;
-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;
-import com.google.gerrit.server.patch.PatchListCacheImpl;
-import com.google.gerrit.server.patch.PatchScriptFactory;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.plugins.ReloadPluginListener;
-import com.google.gerrit.server.project.AccessControlModule;
-import com.google.gerrit.server.project.CommentLinkProvider;
-import com.google.gerrit.server.project.PermissionCollection;
-import com.google.gerrit.server.project.ProjectCacheImpl;
-import com.google.gerrit.server.project.ProjectNode;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.SectionSortCache;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.ChangeQueryProcessor;
-import com.google.gerrit.server.query.change.ConflictsCacheImpl;
-import com.google.gerrit.server.ssh.SshAddressesModule;
-import com.google.gerrit.server.tools.ToolsCatalog;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.util.IdGenerator;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.server.validators.AssigneeValidationListener;
-import com.google.gerrit.server.validators.GroupCreationValidationListener;
-import com.google.gerrit.server.validators.HashtagValidationListener;
-import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
-import com.google.gerrit.server.validators.ProjectCreationValidationListener;
-import com.google.gitiles.blame.cache.BlameCache;
-import com.google.gitiles.blame.cache.BlameCacheImpl;
-import com.google.inject.Inject;
-import com.google.inject.TypeLiteral;
-import com.google.inject.internal.UniqueAnnotations;
-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;
-import org.eclipse.jgit.transport.PreUploadHook;
-
-/** Starts global state with standard dependencies. */
-public class GerritGlobalModule extends FactoryModule {
-  private final Config cfg;
-  private final AuthModule authModule;
-
-  @Inject
-  GerritGlobalModule(@GerritServerConfig Config cfg, AuthModule authModule) {
-    this.cfg = cfg;
-    this.authModule = authModule;
-  }
-
-  @Override
-  protected void configure() {
-    bind(EmailExpander.class).toProvider(EmailExpanderProvider.class).in(SINGLETON);
-
-    bind(IdGenerator.class);
-    bind(RulesCache.class);
-    bind(BlameCache.class).to(BlameCacheImpl.class);
-    bind(Sequences.class);
-    install(authModule);
-    install(AccountCacheImpl.module());
-    install(BatchUpdate.module());
-    install(ChangeKindCacheImpl.module());
-    install(ChangeFinder.module());
-    install(ConflictsCacheImpl.module());
-    install(GroupCacheImpl.module());
-    install(GroupIncludeCacheImpl.module());
-    install(MergeabilityCacheImpl.module());
-    install(PatchListCacheImpl.module());
-    install(ProjectCacheImpl.module());
-    install(SectionSortCache.module());
-    install(SubmitStrategy.module());
-    install(TagCache.module());
-    install(OAuthTokenCache.module());
-
-    install(new AccessControlModule());
-    install(new CmdLineParserModule());
-    install(new EmailModule());
-    install(new ExternalIdModule());
-    install(new GitModule());
-    install(new GroupModule());
-    install(new NoteDbModule(cfg));
-    install(new PrologModule());
-    install(new ReceiveCommitsModule());
-    install(new SshAddressesModule());
-    install(ThreadLocalRequestContext.module());
-
-    bind(AccountResolver.class);
-
-    factory(AddReviewerSender.Factory.class);
-    factory(DeleteReviewerSender.Factory.class);
-    factory(AddKeySender.Factory.class);
-    factory(CapabilityCollection.Factory.class);
-    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);
-    factory(PatchScriptFactory.Factory.class);
-    factory(PluginUser.Factory.class);
-    factory(ProjectNode.Factory.class);
-    factory(ProjectState.Factory.class);
-    factory(RegisterNewEmailSender.Factory.class);
-    factory(ReplacePatchSetSender.Factory.class);
-    factory(SetAssigneeSender.Factory.class);
-    factory(VisibleRefFilter.Factory.class);
-    bind(PermissionCollection.Factory.class);
-    bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
-    factory(ProjectOwnerGroupsProvider.Factory.class);
-    factory(SubmitRuleEvaluator.Factory.class);
-
-    bind(AuthBackend.class).to(UniversalAuthBackend.class).in(SINGLETON);
-    DynamicSet.setOf(binder(), AuthBackend.class);
-
-    bind(GroupControl.Factory.class).in(SINGLETON);
-    bind(GroupControl.GenericFactory.class).in(SINGLETON);
-
-    bind(FileTypeRegistry.class).to(MimeUtilFileTypeRegistry.class);
-    bind(ToolsCatalog.class);
-    bind(EventFactory.class);
-    bind(TransferConfig.class);
-
-    bind(GcConfig.class);
-    bind(ChangeCleanupConfig.class);
-    bind(AccountDeactivator.class);
-
-    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)
-        .annotatedWith(DisableReverseDnsLookup.class)
-        .toProvider(DisableReverseDnsLookupProvider.class)
-        .in(SINGLETON);
-
-    bind(PatchSetInfoFactory.class);
-    bind(IdentifiedUser.GenericFactory.class).in(SINGLETON);
-    bind(AccountControl.Factory.class);
-
-    install(new AuditModule());
-    bind(UiActions.class);
-    install(new com.google.gerrit.server.access.Module());
-    install(new com.google.gerrit.server.account.Module());
-    install(new com.google.gerrit.server.api.Module());
-    install(new com.google.gerrit.server.change.Module());
-    install(new com.google.gerrit.server.config.Module());
-    install(new com.google.gerrit.server.group.Module());
-    install(new com.google.gerrit.server.project.Module());
-
-    bind(GitReferenceUpdated.class);
-    DynamicMap.mapOf(binder(), new TypeLiteral<Cache<?, ?>>() {});
-    DynamicSet.setOf(binder(), CacheRemovalListener.class);
-    DynamicMap.mapOf(binder(), CapabilityDefinition.class);
-    DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
-    DynamicSet.setOf(binder(), AssigneeChangedListener.class);
-    DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
-    DynamicSet.setOf(binder(), CommentAddedListener.class);
-    DynamicSet.setOf(binder(), HashtagsEditedListener.class);
-    DynamicSet.setOf(binder(), ChangeMergedListener.class);
-    DynamicSet.setOf(binder(), ChangeRestoredListener.class);
-    DynamicSet.setOf(binder(), ChangeRevertedListener.class);
-    DynamicSet.setOf(binder(), PrivateStateChangedListener.class);
-    DynamicSet.setOf(binder(), ReviewerAddedListener.class);
-    DynamicSet.setOf(binder(), ReviewerDeletedListener.class);
-    DynamicSet.setOf(binder(), VoteDeletedListener.class);
-    DynamicSet.setOf(binder(), WorkInProgressStateChangedListener.class);
-    DynamicSet.setOf(binder(), RevisionCreatedListener.class);
-    DynamicSet.setOf(binder(), TopicEditedListener.class);
-    DynamicSet.setOf(binder(), AgreementSignupListener.class);
-    DynamicSet.setOf(binder(), PluginEventListener.class);
-    DynamicSet.setOf(binder(), ReceivePackInitializer.class);
-    DynamicSet.setOf(binder(), PostReceiveHook.class);
-    DynamicSet.setOf(binder(), PreUploadHook.class);
-    DynamicSet.setOf(binder(), PostUploadHook.class);
-    DynamicSet.setOf(binder(), AccountIndexedListener.class);
-    DynamicSet.setOf(binder(), ChangeIndexedListener.class);
-    DynamicSet.setOf(binder(), GroupIndexedListener.class);
-    DynamicSet.setOf(binder(), NewProjectCreatedListener.class);
-    DynamicSet.setOf(binder(), ProjectDeletedListener.class);
-    DynamicSet.setOf(binder(), GarbageCollectorListener.class);
-    DynamicSet.setOf(binder(), HeadUpdatedListener.class);
-    DynamicSet.setOf(binder(), UsageDataPublishedListener.class);
-    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(ReindexAfterRefUpdate.class);
-    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
-        .to(ProjectConfigEntry.UpdateChecker.class);
-    DynamicSet.setOf(binder(), EventListener.class);
-    DynamicSet.bind(binder(), EventListener.class).to(EventsMetrics.class);
-    DynamicSet.setOf(binder(), UserScopedEventListener.class);
-    DynamicSet.setOf(binder(), CommitValidationListener.class);
-    DynamicSet.setOf(binder(), ChangeMessageModifier.class);
-    DynamicSet.setOf(binder(), RefOperationValidationListener.class);
-    DynamicSet.setOf(binder(), OnSubmitValidationListener.class);
-    DynamicSet.setOf(binder(), MergeValidationListener.class);
-    DynamicSet.setOf(binder(), ProjectCreationValidationListener.class);
-    DynamicSet.setOf(binder(), GroupCreationValidationListener.class);
-    DynamicSet.setOf(binder(), HashtagValidationListener.class);
-    DynamicSet.setOf(binder(), OutgoingEmailValidationListener.class);
-    DynamicItem.itemOf(binder(), AvatarProvider.class);
-    DynamicSet.setOf(binder(), LifecycleListener.class);
-    DynamicSet.setOf(binder(), TopMenu.class);
-    DynamicSet.setOf(binder(), MessageOfTheDay.class);
-    DynamicMap.mapOf(binder(), DownloadScheme.class);
-    DynamicMap.mapOf(binder(), DownloadCommand.class);
-    DynamicMap.mapOf(binder(), CloneCommand.class);
-    DynamicMap.mapOf(binder(), ReviewerSuggestion.class);
-    DynamicSet.setOf(binder(), ExternalIncludedIn.class);
-    DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
-    DynamicSet.setOf(binder(), PatchSetWebLink.class);
-    DynamicSet.setOf(binder(), ParentWebLink.class);
-    DynamicSet.setOf(binder(), FileWebLink.class);
-    DynamicSet.setOf(binder(), FileHistoryWebLink.class);
-    DynamicSet.setOf(binder(), DiffWebLink.class);
-    DynamicSet.setOf(binder(), ProjectWebLink.class);
-    DynamicSet.setOf(binder(), BranchWebLink.class);
-    DynamicSet.setOf(binder(), TagWebLink.class);
-    DynamicMap.mapOf(binder(), OAuthLoginProvider.class);
-    DynamicItem.itemOf(binder(), OAuthTokenEncrypter.class);
-    DynamicSet.setOf(binder(), AccountExternalIdCreator.class);
-    DynamicSet.setOf(binder(), WebUiPlugin.class);
-    DynamicItem.itemOf(binder(), AccountPatchReviewStore.class);
-    DynamicSet.setOf(binder(), AssigneeValidationListener.class);
-    DynamicSet.setOf(binder(), ActionVisitor.class);
-
-    DynamicMap.mapOf(binder(), MailFilter.class);
-    bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
-
-    factory(UploadValidators.Factory.class);
-    DynamicSet.setOf(binder(), UploadValidationListener.class);
-
-    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
-    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
-    DynamicMap.mapOf(binder(), ChangeQueryProcessor.ChangeAttributeFactory.class);
-
-    install(new GitwebConfig.LegacyModule(cfg));
-
-    bind(AnonymousUser.class);
-
-    factory(AbandonOp.Factory.class);
-    factory(AccountMergeValidator.Factory.class);
-    factory(RefOperationValidators.Factory.class);
-    factory(OnSubmitValidators.Factory.class);
-    factory(MergeValidators.Factory.class);
-    factory(ProjectConfigValidator.Factory.class);
-    factory(NotesBranchUtil.Factory.class);
-    factory(MergedByPushOp.Factory.class);
-    factory(GitModules.Factory.class);
-    factory(VersionedAuthorizedKeys.Factory.class);
-
-    bind(AccountManager.class);
-    factory(ChangeUserName.Factory.class);
-
-    bind(new TypeLiteral<List<CommentLinkInfo>>() {})
-        .toProvider(CommentLinkProvider.class)
-        .in(SINGLETON);
-
-    bind(ReloadPluginListener.class)
-        .annotatedWith(UniqueAnnotations.create())
-        .to(PluginConfigFactory.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
deleted file mode 100644
index 5d88ec0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritRequestModule.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static com.google.inject.Scopes.SINGLETON;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-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. */
-public class GerritRequestModule extends FactoryModule {
-  @Override
-  protected void configure() {
-    bind(RequestCleanup.class).in(RequestScoped.class);
-    bind(RequestScopedReviewDbProvider.class);
-    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/GetCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetCache.java
deleted file mode 100644
index 53628cc..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetCache.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.config.ListCaches.CacheInfo;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetCache implements RestReadView<CacheResource> {
-
-  @Override
-  public CacheInfo apply(CacheResource rsrc) {
-    return new CacheInfo(rsrc.getName(), rsrc.getCache());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetDiffPreferences.java
deleted file mode 100644
index 8393fb4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetDiffPreferences.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.google.gerrit.server.config;
-
-import static com.google.gerrit.server.config.ConfigUtil.loadSection;
-
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.VersionedAccountPreferences;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UserConfigSections;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Repository;
-
-@Singleton
-public class GetDiffPreferences implements RestReadView<ConfigResource> {
-
-  private final AllUsersName allUsersName;
-  private final GitRepositoryManager gitManager;
-
-  @Inject
-  GetDiffPreferences(GitRepositoryManager gitManager, AllUsersName allUsersName) {
-    this.allUsersName = allUsersName;
-    this.gitManager = gitManager;
-  }
-
-  @Override
-  public DiffPreferencesInfo apply(ConfigResource configResource)
-      throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
-    return readFromGit(gitManager, allUsersName, null);
-  }
-
-  static DiffPreferencesInfo readFromGit(
-      GitRepositoryManager gitMgr, AllUsersName allUsersName, DiffPreferencesInfo in)
-      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
-    try (Repository git = gitMgr.openRepository(allUsersName)) {
-      // Load all users prefs.
-      VersionedAccountPreferences dp = VersionedAccountPreferences.forDefault();
-      dp.load(git);
-      DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
-      loadSection(
-          dp.getConfig(),
-          UserConfigSections.DIFF,
-          null,
-          allUserPrefs,
-          DiffPreferencesInfo.defaults(),
-          in);
-      return allUserPrefs;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetPreferences.java
deleted file mode 100644
index ed212f4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetPreferences.java
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static com.google.gerrit.server.config.ConfigUtil.loadSection;
-
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.GeneralPreferencesLoader;
-import com.google.gerrit.server.account.VersionedAccountPreferences;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UserConfigSections;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Repository;
-
-@Singleton
-public class GetPreferences implements RestReadView<ConfigResource> {
-  private final GeneralPreferencesLoader loader;
-  private final GitRepositoryManager gitMgr;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  public GetPreferences(
-      GeneralPreferencesLoader loader, GitRepositoryManager gitMgr, AllUsersName allUsersName) {
-    this.loader = loader;
-    this.gitMgr = gitMgr;
-    this.allUsersName = allUsersName;
-  }
-
-  @Override
-  public GeneralPreferencesInfo apply(ConfigResource rsrc)
-      throws IOException, ConfigInvalidException {
-    return readFromGit(gitMgr, loader, allUsersName, null);
-  }
-
-  static GeneralPreferencesInfo readFromGit(
-      GitRepositoryManager gitMgr,
-      GeneralPreferencesLoader loader,
-      AllUsersName allUsersName,
-      GeneralPreferencesInfo in)
-      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
-    try (Repository git = gitMgr.openRepository(allUsersName)) {
-      VersionedAccountPreferences p = VersionedAccountPreferences.forDefault();
-      p.load(git);
-
-      GeneralPreferencesInfo r =
-          loadSection(
-              p.getConfig(),
-              UserConfigSections.GENERAL,
-              null,
-              new GeneralPreferencesInfo(),
-              GeneralPreferencesInfo.defaults(),
-              in);
-
-      // TODO(davido): Maintain cache of default values in AllUsers repository
-      return loader.loadMyMenusAndUrlAliases(r, p, null);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
deleted file mode 100644
index bf10381..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
+++ /dev/null
@@ -1,390 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.extensions.client.UiType;
-import com.google.gerrit.extensions.common.AccountsInfo;
-import com.google.gerrit.extensions.common.AuthInfo;
-import com.google.gerrit.extensions.common.ChangeConfigInfo;
-import com.google.gerrit.extensions.common.DownloadInfo;
-import com.google.gerrit.extensions.common.DownloadSchemeInfo;
-import com.google.gerrit.extensions.common.GerritInfo;
-import com.google.gerrit.extensions.common.PluginConfigInfo;
-import com.google.gerrit.extensions.common.ReceiveInfo;
-import com.google.gerrit.extensions.common.ServerInfo;
-import com.google.gerrit.extensions.common.SshdInfo;
-import com.google.gerrit.extensions.common.SuggestInfo;
-import com.google.gerrit.extensions.common.UserConfigInfo;
-import com.google.gerrit.extensions.config.CloneCommand;
-import com.google.gerrit.extensions.config.DownloadCommand;
-import com.google.gerrit.extensions.config.DownloadScheme;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.webui.WebUiPlugin;
-import com.google.gerrit.server.EnableSignedPush;
-import com.google.gerrit.server.account.AccountVisibilityProvider;
-import com.google.gerrit.server.account.Realm;
-import com.google.gerrit.server.avatar.AvatarProvider;
-import com.google.gerrit.server.change.AllowedFormats;
-import com.google.gerrit.server.change.ArchiveFormat;
-import com.google.gerrit.server.change.Submit;
-import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.inject.Inject;
-import java.net.MalformedURLException;
-import java.nio.file.Files;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Optional;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.Config;
-
-public class GetServerInfo implements RestReadView<ConfigResource> {
-  private static final String URL_ALIAS = "urlAlias";
-  private static final String KEY_MATCH = "match";
-  private static final String KEY_TOKEN = "token";
-
-  private final Config config;
-  private final AccountVisibilityProvider accountVisibilityProvider;
-  private final AuthConfig authConfig;
-  private final Realm realm;
-  private final DynamicMap<DownloadScheme> downloadSchemes;
-  private final DynamicMap<DownloadCommand> downloadCommands;
-  private final DynamicMap<CloneCommand> cloneCommands;
-  private final DynamicSet<WebUiPlugin> plugins;
-  private final AllowedFormats archiveFormats;
-  private final AllProjectsName allProjectsName;
-  private final AllUsersName allUsersName;
-  private final String anonymousCowardName;
-  private final DynamicItem<AvatarProvider> avatar;
-  private final boolean enableSignedPush;
-  private final QueryDocumentationExecutor docSearcher;
-  private final NotesMigration migration;
-  private final ProjectCache projectCache;
-  private final AgreementJson agreementJson;
-  private final GerritOptions gerritOptions;
-  private final ChangeIndexCollection indexes;
-  private final SitePaths sitePaths;
-
-  @Inject
-  public GetServerInfo(
-      @GerritServerConfig Config config,
-      AccountVisibilityProvider accountVisibilityProvider,
-      AuthConfig authConfig,
-      Realm realm,
-      DynamicMap<DownloadScheme> downloadSchemes,
-      DynamicMap<DownloadCommand> downloadCommands,
-      DynamicMap<CloneCommand> cloneCommands,
-      DynamicSet<WebUiPlugin> webUiPlugins,
-      AllowedFormats archiveFormats,
-      AllProjectsName allProjectsName,
-      AllUsersName allUsersName,
-      @AnonymousCowardName String anonymousCowardName,
-      DynamicItem<AvatarProvider> avatar,
-      @EnableSignedPush boolean enableSignedPush,
-      QueryDocumentationExecutor docSearcher,
-      NotesMigration migration,
-      ProjectCache projectCache,
-      AgreementJson agreementJson,
-      GerritOptions gerritOptions,
-      ChangeIndexCollection indexes,
-      SitePaths sitePaths) {
-    this.config = config;
-    this.accountVisibilityProvider = accountVisibilityProvider;
-    this.authConfig = authConfig;
-    this.realm = realm;
-    this.downloadSchemes = downloadSchemes;
-    this.downloadCommands = downloadCommands;
-    this.cloneCommands = cloneCommands;
-    this.plugins = webUiPlugins;
-    this.archiveFormats = archiveFormats;
-    this.allProjectsName = allProjectsName;
-    this.allUsersName = allUsersName;
-    this.anonymousCowardName = anonymousCowardName;
-    this.avatar = avatar;
-    this.enableSignedPush = enableSignedPush;
-    this.docSearcher = docSearcher;
-    this.migration = migration;
-    this.projectCache = projectCache;
-    this.agreementJson = agreementJson;
-    this.gerritOptions = gerritOptions;
-    this.indexes = indexes;
-    this.sitePaths = sitePaths;
-  }
-
-  @Override
-  public ServerInfo apply(ConfigResource rsrc) throws MalformedURLException {
-    ServerInfo info = new ServerInfo();
-    info.accounts = getAccountsInfo(accountVisibilityProvider);
-    info.auth = getAuthInfo(authConfig, realm);
-    info.change = getChangeInfo(config);
-    info.download =
-        getDownloadInfo(downloadSchemes, downloadCommands, cloneCommands, archiveFormats);
-    info.gerrit = getGerritInfo(config, allProjectsName, allUsersName);
-    info.noteDbEnabled = toBoolean(isNoteDbEnabled());
-    info.plugin = getPluginInfo();
-    if (Files.exists(sitePaths.site_theme)) {
-      info.defaultTheme = "/static/" + SitePaths.THEME_FILENAME;
-    }
-    info.sshd = getSshdInfo(config);
-    info.suggest = getSuggestInfo(config);
-
-    Map<String, String> urlAliases = getUrlAliasesInfo(config);
-    info.urlAliases = !urlAliases.isEmpty() ? urlAliases : null;
-
-    info.user = getUserInfo(anonymousCowardName);
-    info.receive = getReceiveInfo();
-    return info;
-  }
-
-  private AccountsInfo getAccountsInfo(AccountVisibilityProvider accountVisibilityProvider) {
-    AccountsInfo info = new AccountsInfo();
-    info.visibility = accountVisibilityProvider.get();
-    return info;
-  }
-
-  private AuthInfo getAuthInfo(AuthConfig cfg, Realm realm) {
-    AuthInfo info = new AuthInfo();
-    info.authType = cfg.getAuthType();
-    info.useContributorAgreements = toBoolean(cfg.isUseContributorAgreements());
-    info.editableAccountFields = new ArrayList<>(realm.getEditableFields());
-    info.switchAccountUrl = cfg.getSwitchAccountUrl();
-    info.gitBasicAuthPolicy = cfg.getGitBasicAuthPolicy();
-
-    if (info.useContributorAgreements != null) {
-      Collection<ContributorAgreement> agreements =
-          projectCache.getAllProjects().getConfig().getContributorAgreements();
-      if (!agreements.isEmpty()) {
-        info.contributorAgreements = Lists.newArrayListWithCapacity(agreements.size());
-        for (ContributorAgreement agreement : agreements) {
-          info.contributorAgreements.add(agreementJson.format(agreement));
-        }
-      }
-    }
-
-    switch (info.authType) {
-      case LDAP:
-      case LDAP_BIND:
-        info.registerUrl = cfg.getRegisterUrl();
-        info.registerText = cfg.getRegisterText();
-        info.editFullNameUrl = cfg.getEditFullNameUrl();
-        break;
-
-      case CUSTOM_EXTENSION:
-        info.registerUrl = cfg.getRegisterUrl();
-        info.registerText = cfg.getRegisterText();
-        info.editFullNameUrl = cfg.getEditFullNameUrl();
-        info.httpPasswordUrl = cfg.getHttpPasswordUrl();
-        break;
-
-      case HTTP:
-      case HTTP_LDAP:
-        info.loginUrl = cfg.getLoginUrl();
-        info.loginText = cfg.getLoginText();
-        break;
-
-      case CLIENT_SSL_CERT_LDAP:
-      case DEVELOPMENT_BECOME_ANY_ACCOUNT:
-      case OAUTH:
-      case OPENID:
-      case OPENID_SSO:
-        break;
-    }
-    return info;
-  }
-
-  private ChangeConfigInfo getChangeInfo(Config cfg) {
-    ChangeConfigInfo info = new ChangeConfigInfo();
-    info.allowBlame = toBoolean(cfg.getBoolean("change", "allowBlame", true));
-    info.allowDrafts = toBoolean(cfg.getBoolean("change", "allowDrafts", true));
-    boolean hasAssigneeInIndex =
-        indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE);
-    info.showAssigneeInChangesTable =
-        toBoolean(
-            cfg.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
-    info.largeChange = cfg.getInt("change", "largeChange", 500);
-    info.replyTooltip =
-        Optional.ofNullable(cfg.getString("change", null, "replyTooltip")).orElse("Reply and score")
-            + " (Shortcut: a)";
-    info.replyLabel =
-        Optional.ofNullable(cfg.getString("change", null, "replyLabel")).orElse("Reply") + "\u2026";
-    info.updateDelay =
-        (int) ConfigUtil.getTimeUnit(cfg, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
-    info.submitWholeTopic = Submit.wholeTopicEnabled(cfg);
-    return info;
-  }
-
-  private DownloadInfo getDownloadInfo(
-      DynamicMap<DownloadScheme> downloadSchemes,
-      DynamicMap<DownloadCommand> downloadCommands,
-      DynamicMap<CloneCommand> cloneCommands,
-      AllowedFormats archiveFormats) {
-    DownloadInfo info = new DownloadInfo();
-    info.schemes = new HashMap<>();
-    for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
-      DownloadScheme scheme = e.getProvider().get();
-      if (scheme.isEnabled() && scheme.getUrl("${project}") != null) {
-        info.schemes.put(
-            e.getExportName(), getDownloadSchemeInfo(scheme, downloadCommands, cloneCommands));
-      }
-    }
-    info.archives =
-        archiveFormats.getAllowed().stream().map(ArchiveFormat::getShortName).collect(toList());
-    return info;
-  }
-
-  private DownloadSchemeInfo getDownloadSchemeInfo(
-      DownloadScheme scheme,
-      DynamicMap<DownloadCommand> downloadCommands,
-      DynamicMap<CloneCommand> cloneCommands) {
-    DownloadSchemeInfo info = new DownloadSchemeInfo();
-    info.url = scheme.getUrl("${project}");
-    info.isAuthRequired = toBoolean(scheme.isAuthRequired());
-    info.isAuthSupported = toBoolean(scheme.isAuthSupported());
-
-    info.commands = new HashMap<>();
-    for (DynamicMap.Entry<DownloadCommand> e : downloadCommands) {
-      String commandName = e.getExportName();
-      DownloadCommand command = e.getProvider().get();
-      String c = command.getCommand(scheme, "${project}", "${ref}");
-      if (c != null) {
-        info.commands.put(commandName, c);
-      }
-    }
-
-    info.cloneCommands = new HashMap<>();
-    for (DynamicMap.Entry<CloneCommand> e : cloneCommands) {
-      String commandName = e.getExportName();
-      CloneCommand command = e.getProvider().get();
-      String c = command.getCommand(scheme, "${project-path}/${project-base-name}");
-      if (c != null) {
-        c = c.replaceAll("\\$\\{project-path\\}/\\$\\{project-base-name\\}", "\\$\\{project\\}");
-        info.cloneCommands.put(commandName, c);
-      }
-    }
-
-    return info;
-  }
-
-  private GerritInfo getGerritInfo(
-      Config cfg, AllProjectsName allProjectsName, AllUsersName allUsersName) {
-    GerritInfo info = new GerritInfo();
-    info.allProjects = allProjectsName.get();
-    info.allUsers = allUsersName.get();
-    info.reportBugUrl = cfg.getString("gerrit", null, "reportBugUrl");
-    info.reportBugText = cfg.getString("gerrit", null, "reportBugText");
-    info.docUrl = getDocUrl(cfg);
-    info.docSearch = docSearcher.isAvailable();
-    info.editGpgKeys =
-        toBoolean(enableSignedPush && cfg.getBoolean("gerrit", null, "editGpgKeys", true));
-    info.webUis = EnumSet.noneOf(UiType.class);
-    if (gerritOptions.enableGwtUi()) {
-      info.webUis.add(UiType.GWT);
-    }
-    if (gerritOptions.enablePolyGerrit()) {
-      info.webUis.add(UiType.POLYGERRIT);
-    }
-    return info;
-  }
-
-  private String getDocUrl(Config cfg) {
-    String docUrl = cfg.getString("gerrit", null, "docUrl");
-    if (Strings.isNullOrEmpty(docUrl)) {
-      return null;
-    }
-    return CharMatcher.is('/').trimTrailingFrom(docUrl) + '/';
-  }
-
-  private boolean isNoteDbEnabled() {
-    return migration.readChanges();
-  }
-
-  private PluginConfigInfo getPluginInfo() {
-    PluginConfigInfo info = new PluginConfigInfo();
-    info.hasAvatars = toBoolean(avatar.get() != null);
-    info.jsResourcePaths = new ArrayList<>();
-    info.htmlResourcePaths = new ArrayList<>();
-    for (WebUiPlugin u : plugins) {
-      String path =
-          String.format("plugins/%s/%s", u.getPluginName(), u.getJavaScriptResourcePath());
-      if (path.endsWith(".html")) {
-        info.htmlResourcePaths.add(path);
-      } else {
-        info.jsResourcePaths.add(path);
-      }
-    }
-    return info;
-  }
-
-  private Map<String, String> getUrlAliasesInfo(Config cfg) {
-    Map<String, String> urlAliases = new HashMap<>();
-    for (String subsection : cfg.getSubsections(URL_ALIAS)) {
-      urlAliases.put(
-          cfg.getString(URL_ALIAS, subsection, KEY_MATCH),
-          cfg.getString(URL_ALIAS, subsection, KEY_TOKEN));
-    }
-    return urlAliases;
-  }
-
-  private SshdInfo getSshdInfo(Config cfg) {
-    String[] addr = cfg.getStringList("sshd", null, "listenAddress");
-    if (addr.length == 1 && isOff(addr[0])) {
-      return null;
-    }
-    return new SshdInfo();
-  }
-
-  private static boolean isOff(String listenHostname) {
-    return "off".equalsIgnoreCase(listenHostname)
-        || "none".equalsIgnoreCase(listenHostname)
-        || "no".equalsIgnoreCase(listenHostname);
-  }
-
-  private SuggestInfo getSuggestInfo(Config cfg) {
-    SuggestInfo info = new SuggestInfo();
-    info.from = cfg.getInt("suggest", "from", 0);
-    return info;
-  }
-
-  private UserConfigInfo getUserInfo(String anonymousCowardName) {
-    UserConfigInfo info = new UserConfigInfo();
-    info.anonymousCowardName = anonymousCowardName;
-    return info;
-  }
-
-  private ReceiveInfo getReceiveInfo() {
-    ReceiveInfo info = new ReceiveInfo();
-    info.enableSignedPush = enableSignedPush;
-    return info;
-  }
-
-  private static Boolean toBoolean(boolean v) {
-    return v ? v : null;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetSummary.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetSummary.java
deleted file mode 100644
index 82912c0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetSummary.java
+++ /dev/null
@@ -1,279 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.git.WorkQueue.Task;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.lang.management.ManagementFactory;
-import java.lang.management.OperatingSystemMXBean;
-import java.lang.management.RuntimeMXBean;
-import java.lang.management.ThreadInfo;
-import java.lang.management.ThreadMXBean;
-import java.net.InetAddress;
-import java.net.UnknownHostException;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.internal.storage.file.WindowCacheStatAccessor;
-import org.kohsuke.args4j.Option;
-
-@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
-public class GetSummary implements RestReadView<ConfigResource> {
-
-  private final WorkQueue workQueue;
-  private final Path sitePath;
-
-  @Option(name = "--gc", usage = "perform Java GC before retrieving memory stats")
-  private boolean gc;
-
-  public GetSummary setGc(boolean gc) {
-    this.gc = gc;
-    return this;
-  }
-
-  @Option(name = "--jvm", usage = "include details about the JVM")
-  private boolean jvm;
-
-  public GetSummary setJvm(boolean jvm) {
-    this.jvm = jvm;
-    return this;
-  }
-
-  @Inject
-  public GetSummary(WorkQueue workQueue, @SitePath Path sitePath) {
-    this.workQueue = workQueue;
-    this.sitePath = sitePath;
-  }
-
-  @Override
-  public SummaryInfo apply(ConfigResource rsrc) {
-    if (gc) {
-      System.gc();
-      System.runFinalization();
-      System.gc();
-    }
-
-    SummaryInfo summary = new SummaryInfo();
-    summary.taskSummary = getTaskSummary();
-    summary.memSummary = getMemSummary();
-    summary.threadSummary = getThreadSummary();
-    if (jvm) {
-      summary.jvmSummary = getJvmSummary();
-    }
-    return summary;
-  }
-
-  private TaskSummaryInfo getTaskSummary() {
-    Collection<Task<?>> pending = workQueue.getTasks();
-    int tasksTotal = pending.size();
-    int tasksRunning = 0;
-    int tasksReady = 0;
-    int tasksSleeping = 0;
-    for (Task<?> task : pending) {
-      switch (task.getState()) {
-        case RUNNING:
-          tasksRunning++;
-          break;
-        case READY:
-          tasksReady++;
-          break;
-        case SLEEPING:
-          tasksSleeping++;
-          break;
-        case CANCELLED:
-        case DONE:
-        case OTHER:
-          break;
-      }
-    }
-
-    TaskSummaryInfo taskSummary = new TaskSummaryInfo();
-    taskSummary.total = toInteger(tasksTotal);
-    taskSummary.running = toInteger(tasksRunning);
-    taskSummary.ready = toInteger(tasksReady);
-    taskSummary.sleeping = toInteger(tasksSleeping);
-    return taskSummary;
-  }
-
-  private MemSummaryInfo getMemSummary() {
-    Runtime r = Runtime.getRuntime();
-    long mMax = r.maxMemory();
-    long mFree = r.freeMemory();
-    long mTotal = r.totalMemory();
-    long mInuse = mTotal - mFree;
-
-    int jgitOpen = WindowCacheStatAccessor.getOpenFiles();
-    long jgitBytes = WindowCacheStatAccessor.getOpenBytes();
-
-    MemSummaryInfo memSummaryInfo = new MemSummaryInfo();
-    memSummaryInfo.total = bytes(mTotal);
-    memSummaryInfo.used = bytes(mInuse - jgitBytes);
-    memSummaryInfo.free = bytes(mFree);
-    memSummaryInfo.buffers = bytes(jgitBytes);
-    memSummaryInfo.max = bytes(mMax);
-    memSummaryInfo.openFiles = toInteger(jgitOpen);
-    return memSummaryInfo;
-  }
-
-  private ThreadSummaryInfo getThreadSummary() {
-    Runtime r = Runtime.getRuntime();
-    ThreadSummaryInfo threadInfo = new ThreadSummaryInfo();
-    threadInfo.cpus = r.availableProcessors();
-    threadInfo.threads = toInteger(ManagementFactory.getThreadMXBean().getThreadCount());
-
-    List<String> prefixes =
-        Arrays.asList(
-            "H2",
-            "HTTP",
-            "IntraLineDiff",
-            "ReceiveCommits",
-            "SSH git-receive-pack",
-            "SSH git-upload-pack",
-            "SSH-Interactive-Worker",
-            "SSH-Stream-Worker",
-            "SshCommandStart",
-            "sshd-SshServer");
-    String other = "Other";
-    ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
-
-    threadInfo.counts = new HashMap<>();
-    for (long id : threadMXBean.getAllThreadIds()) {
-      ThreadInfo info = threadMXBean.getThreadInfo(id);
-      if (info == null) {
-        continue;
-      }
-      String name = info.getThreadName();
-      Thread.State state = info.getThreadState();
-      String group = other;
-      for (String p : prefixes) {
-        if (name.startsWith(p)) {
-          group = p;
-          break;
-        }
-      }
-      Map<Thread.State, Integer> counts = threadInfo.counts.get(group);
-      if (counts == null) {
-        counts = new HashMap<>();
-        threadInfo.counts.put(group, counts);
-      }
-      Integer c = counts.get(state);
-      counts.put(state, c != null ? c + 1 : 1);
-    }
-
-    return threadInfo;
-  }
-
-  private JvmSummaryInfo getJvmSummary() {
-    OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
-    RuntimeMXBean runtimeBean = ManagementFactory.getRuntimeMXBean();
-
-    JvmSummaryInfo jvmSummary = new JvmSummaryInfo();
-    jvmSummary.vmVendor = runtimeBean.getVmVendor();
-    jvmSummary.vmName = runtimeBean.getVmName();
-    jvmSummary.vmVersion = runtimeBean.getVmVersion();
-    jvmSummary.osName = osBean.getName();
-    jvmSummary.osVersion = osBean.getVersion();
-    jvmSummary.osArch = osBean.getArch();
-    jvmSummary.user = System.getProperty("user.name");
-
-    try {
-      jvmSummary.host = InetAddress.getLocalHost().getHostName();
-    } catch (UnknownHostException e) {
-      // Ignored
-    }
-
-    jvmSummary.currentWorkingDirectory = path(Paths.get(".").toAbsolutePath().getParent());
-    jvmSummary.site = path(sitePath);
-    return jvmSummary;
-  }
-
-  private static Integer toInteger(int i) {
-    return i != 0 ? i : null;
-  }
-
-  private static String bytes(double value) {
-    value /= 1024;
-    String suffix = "k";
-
-    if (value > 1024) {
-      value /= 1024;
-      suffix = "m";
-    }
-    if (value > 1024) {
-      value /= 1024;
-      suffix = "g";
-    }
-    return String.format("%1$6.2f%2$s", value, suffix).trim();
-  }
-
-  private static String path(Path path) {
-    try {
-      return path.toRealPath().normalize().toString();
-    } catch (IOException err) {
-      return path.toAbsolutePath().normalize().toString();
-    }
-  }
-
-  public static class SummaryInfo {
-    public TaskSummaryInfo taskSummary;
-    public MemSummaryInfo memSummary;
-    public ThreadSummaryInfo threadSummary;
-    public JvmSummaryInfo jvmSummary;
-  }
-
-  public static class TaskSummaryInfo {
-    public Integer total;
-    public Integer running;
-    public Integer ready;
-    public Integer sleeping;
-  }
-
-  public static class MemSummaryInfo {
-    public String total;
-    public String used;
-    public String free;
-    public String buffers;
-    public String max;
-    public Integer openFiles;
-  }
-
-  public static class ThreadSummaryInfo {
-    public Integer cpus;
-    public Integer threads;
-    public Map<String, Map<Thread.State, Integer>> counts;
-  }
-
-  public static class JvmSummaryInfo {
-    public String vmVendor;
-    public String vmName;
-    public String vmVersion;
-    public String osName;
-    public String osVersion;
-    public String osArch;
-    public String user;
-    public String host;
-    public String currentWorkingDirectory;
-    public String site;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetTask.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetTask.java
deleted file mode 100644
index e4b3320..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetTask.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.config.ListTasks.TaskInfo;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetTask implements RestReadView<TaskResource> {
-
-  @Override
-  public TaskInfo apply(TaskResource rsrc) {
-    return new TaskInfo(rsrc.getTask());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetVersion.java
deleted file mode 100644
index c71cb69..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetVersion.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.gerrit.common.Version;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetVersion implements RestReadView<ConfigResource> {
-  @Override
-  public String apply(ConfigResource resource) throws ResourceNotFoundException {
-    String version = Version.getVersion();
-    if (version == null) {
-      throw new ResourceNotFoundException();
-    }
-    return version;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java
deleted file mode 100644
index d78f61d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java
+++ /dev/null
@@ -1,202 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_CACHES;
-import static com.google.gerrit.server.config.CacheResource.cacheNameOf;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Joiner;
-import com.google.common.cache.Cache;
-import com.google.common.cache.CacheStats;
-import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.cache.PersistentCache;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
-import org.kohsuke.args4j.Option;
-
-@RequiresAnyCapability({VIEW_CACHES, MAINTAIN_SERVER})
-public class ListCaches implements RestReadView<ConfigResource> {
-  private final DynamicMap<Cache<?, ?>> cacheMap;
-
-  public enum OutputFormat {
-    LIST,
-    TEXT_LIST
-  }
-
-  @Option(name = "--format", usage = "output format")
-  private OutputFormat format;
-
-  public ListCaches setFormat(OutputFormat format) {
-    this.format = format;
-    return this;
-  }
-
-  @Inject
-  public ListCaches(DynamicMap<Cache<?, ?>> cacheMap) {
-    this.cacheMap = cacheMap;
-  }
-
-  public Map<String, CacheInfo> getCacheInfos() {
-    Map<String, CacheInfo> cacheInfos = new TreeMap<>();
-    for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-      cacheInfos.put(
-          cacheNameOf(e.getPluginName(), e.getExportName()), new CacheInfo(e.getProvider().get()));
-    }
-    return cacheInfos;
-  }
-
-  @Override
-  public Object apply(ConfigResource rsrc) {
-    if (format == null) {
-      return getCacheInfos();
-    }
-    List<String> cacheNames = new ArrayList<>();
-    for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-      cacheNames.add(cacheNameOf(e.getPluginName(), e.getExportName()));
-    }
-    Collections.sort(cacheNames);
-
-    if (OutputFormat.TEXT_LIST.equals(format)) {
-      return BinaryResult.create(Joiner.on('\n').join(cacheNames))
-          .base64()
-          .setContentType("text/plain")
-          .setCharacterEncoding(UTF_8);
-    }
-    return cacheNames;
-  }
-
-  public enum CacheType {
-    MEM,
-    DISK
-  }
-
-  public static class CacheInfo {
-    public String name;
-    public CacheType type;
-    public EntriesInfo entries;
-    public String averageGet;
-    public HitRatioInfo hitRatio;
-
-    public CacheInfo(Cache<?, ?> cache) {
-      this(null, cache);
-    }
-
-    public CacheInfo(String name, Cache<?, ?> cache) {
-      this.name = name;
-
-      CacheStats stat = cache.stats();
-
-      entries = new EntriesInfo();
-      entries.setMem(cache.size());
-
-      averageGet = duration(stat.averageLoadPenalty());
-
-      hitRatio = new HitRatioInfo();
-      hitRatio.setMem(stat.hitCount(), stat.requestCount());
-
-      if (cache instanceof PersistentCache) {
-        type = CacheType.DISK;
-        PersistentCache.DiskStats diskStats = ((PersistentCache) cache).diskStats();
-        entries.setDisk(diskStats.size());
-        entries.setSpace(diskStats.space());
-        hitRatio.setDisk(diskStats.hitCount(), diskStats.requestCount());
-      } else {
-        type = CacheType.MEM;
-      }
-    }
-
-    private static String duration(double ns) {
-      if (ns < 0.5) {
-        return null;
-      }
-      String suffix = "ns";
-      if (ns >= 1000.0) {
-        ns /= 1000.0;
-        suffix = "us";
-      }
-      if (ns >= 1000.0) {
-        ns /= 1000.0;
-        suffix = "ms";
-      }
-      if (ns >= 1000.0) {
-        ns /= 1000.0;
-        suffix = "s";
-      }
-      return String.format("%4.1f%s", ns, suffix).trim();
-    }
-  }
-
-  public static class EntriesInfo {
-    public Long mem;
-    public Long disk;
-    public String space;
-
-    public void setMem(long mem) {
-      this.mem = mem != 0 ? mem : null;
-    }
-
-    public void setDisk(long disk) {
-      this.disk = disk != 0 ? disk : null;
-    }
-
-    public void setSpace(double value) {
-      space = bytes(value);
-    }
-
-    private static String bytes(double value) {
-      value /= 1024;
-      String suffix = "k";
-
-      if (value > 1024) {
-        value /= 1024;
-        suffix = "m";
-      }
-      if (value > 1024) {
-        value /= 1024;
-        suffix = "g";
-      }
-      return String.format("%1$6.2f%2$s", value, suffix).trim();
-    }
-  }
-
-  public static class HitRatioInfo {
-    public Integer mem;
-    public Integer disk;
-
-    public void setMem(long value, long total) {
-      mem = percent(value, total);
-    }
-
-    public void setDisk(long value, long total) {
-      disk = percent(value, total);
-    }
-
-    private static Integer percent(long value, long total) {
-      if (total <= 0) {
-        return null;
-      }
-      return (int) ((100 * value) / total);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCapabilities.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCapabilities.java
deleted file mode 100644
index d21b5fa..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCapabilities.java
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.common.base.CharMatcher;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.config.CapabilityDefinition;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.Map;
-import java.util.TreeMap;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** List capabilities visible to the calling user. */
-@Singleton
-public class ListCapabilities implements RestReadView<ConfigResource> {
-  private static final Logger log = LoggerFactory.getLogger(ListCapabilities.class);
-  private final DynamicMap<CapabilityDefinition> pluginCapabilities;
-
-  @Inject
-  public ListCapabilities(DynamicMap<CapabilityDefinition> pluginCapabilities) {
-    this.pluginCapabilities = pluginCapabilities;
-  }
-
-  @Override
-  public Map<String, CapabilityInfo> apply(ConfigResource resource)
-      throws IllegalAccessException, NoSuchFieldException {
-    Map<String, CapabilityInfo> output = new TreeMap<>();
-    collectCoreCapabilities(output);
-    collectPluginCapabilities(output);
-    return output;
-  }
-
-  private void collectCoreCapabilities(Map<String, CapabilityInfo> output)
-      throws IllegalAccessException, NoSuchFieldException {
-    Class<? extends CapabilityConstants> bundleClass = CapabilityConstants.get().getClass();
-    CapabilityConstants c = CapabilityConstants.get();
-    for (String id : GlobalCapability.getAllNames()) {
-      String name = (String) bundleClass.getField(id).get(c);
-      output.put(id, new CapabilityInfo(id, name));
-    }
-  }
-
-  private void collectPluginCapabilities(Map<String, CapabilityInfo> output) {
-    for (String pluginName : pluginCapabilities.plugins()) {
-      if (!isPluginNameSane(pluginName)) {
-        log.warn(
-            String.format(
-                "Plugin name %s must match [A-Za-z0-9-]+ to use capabilities;"
-                    + " rename the plugin",
-                pluginName));
-        continue;
-      }
-      for (Map.Entry<String, Provider<CapabilityDefinition>> entry :
-          pluginCapabilities.byPlugin(pluginName).entrySet()) {
-        String id = String.format("%s-%s", pluginName, entry.getKey());
-        output.put(id, new CapabilityInfo(id, entry.getValue().get().getDescription()));
-      }
-    }
-  }
-
-  private static boolean isPluginNameSane(String pluginName) {
-    return CharMatcher.javaLetterOrDigit().or(CharMatcher.is('-')).matchesAllOf(pluginName);
-  }
-
-  public static class CapabilityInfo {
-    public String id;
-    public String name;
-
-    public CapabilityInfo(String id, String name) {
-      this.id = id;
-      this.name = name;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
deleted file mode 100644
index bbda9eb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
+++ /dev/null
@@ -1,150 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.common.collect.ComparisonChain;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.TaskInfoFactory;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.git.WorkQueue.ProjectTask;
-import com.google.gerrit.server.git.WorkQueue.Task;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.util.IdGenerator;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.TimeUnit;
-
-@Singleton
-public class ListTasks implements RestReadView<ConfigResource> {
-  private final PermissionBackend permissionBackend;
-  private final WorkQueue workQueue;
-  private final Provider<CurrentUser> self;
-
-  @Inject
-  public ListTasks(
-      PermissionBackend permissionBackend, WorkQueue workQueue, Provider<CurrentUser> self) {
-    this.permissionBackend = permissionBackend;
-    this.workQueue = workQueue;
-    this.self = self;
-  }
-
-  @Override
-  public List<TaskInfo> apply(ConfigResource resource)
-      throws AuthException, PermissionBackendException {
-    CurrentUser user = self.get();
-    if (!user.isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    List<TaskInfo> allTasks = getTasks();
-    try {
-      permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
-      return allTasks;
-    } catch (AuthException e) {
-      // Fall through to filter tasks.
-    }
-
-    Map<String, Boolean> visibilityCache = new HashMap<>();
-    List<TaskInfo> visibleTasks = new ArrayList<>();
-    for (TaskInfo task : allTasks) {
-      if (task.projectName != null) {
-        Boolean visible = visibilityCache.get(task.projectName);
-        if (visible == null) {
-          try {
-            permissionBackend
-                .user(user)
-                .project(new Project.NameKey(task.projectName))
-                .check(ProjectPermission.ACCESS);
-            visible = true;
-          } catch (AuthException e) {
-            visible = false;
-          }
-          visibilityCache.put(task.projectName, visible);
-        }
-        if (visible) {
-          visibleTasks.add(task);
-        }
-      }
-    }
-    return visibleTasks;
-  }
-
-  private List<TaskInfo> getTasks() {
-    List<TaskInfo> taskInfos =
-        workQueue.getTaskInfos(
-            new TaskInfoFactory<TaskInfo>() {
-              @Override
-              public TaskInfo getTaskInfo(Task<?> task) {
-                return new TaskInfo(task);
-              }
-            });
-    Collections.sort(
-        taskInfos,
-        new Comparator<TaskInfo>() {
-          @Override
-          public int compare(TaskInfo a, TaskInfo b) {
-            return ComparisonChain.start()
-                .compare(a.state.ordinal(), b.state.ordinal())
-                .compare(a.delay, b.delay)
-                .compare(a.command, b.command)
-                .result();
-          }
-        });
-    return taskInfos;
-  }
-
-  public static class TaskInfo {
-    public String id;
-    public Task.State state;
-    public Timestamp startTime;
-    public long delay;
-    public String command;
-    public String remoteName;
-    public String projectName;
-    public String queueName;
-
-    public TaskInfo(Task<?> task) {
-      this.id = IdGenerator.format(task.getTaskId());
-      this.state = task.getState();
-      this.startTime = new Timestamp(task.getStartTime().getTime());
-      this.delay = task.getDelay(TimeUnit.MILLISECONDS);
-      this.command = task.toString();
-      this.queueName = task.getQueueName();
-
-      if (task instanceof ProjectTask) {
-        ProjectTask<?> projectTask = ((ProjectTask<?>) task);
-        Project.NameKey name = projectTask.getProjectNameKey();
-        if (name != null) {
-          this.projectName = name.get();
-        }
-        this.remoteName = projectTask.getRemoteName();
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTopMenus.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTopMenus.java
deleted file mode 100644
index a7ba938..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTopMenus.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.webui.TopMenu;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.List;
-
-@Singleton
-class ListTopMenus implements RestReadView<ConfigResource> {
-  private final DynamicSet<TopMenu> extensions;
-
-  @Inject
-  ListTopMenus(DynamicSet<TopMenu> extensions) {
-    this.extensions = extensions;
-  }
-
-  @Override
-  public List<TopMenu.MenuEntry> apply(ConfigResource resource) {
-    List<TopMenu.MenuEntry> entries = new ArrayList<>();
-    for (TopMenu extension : extensions) {
-      entries.addAll(extension.getEntries());
-    }
-    return entries;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
deleted file mode 100644
index 7bf5ad5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static com.google.gerrit.server.config.CapabilityResource.CAPABILITY_KIND;
-import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
-import static com.google.gerrit.server.config.TaskResource.TASK_KIND;
-import static com.google.gerrit.server.config.TopMenuResource.TOP_MENU_KIND;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.RestApiModule;
-
-public class Module extends RestApiModule {
-  @Override
-  protected void configure() {
-    DynamicMap.mapOf(binder(), CAPABILITY_KIND);
-    DynamicMap.mapOf(binder(), CONFIG_KIND);
-    DynamicMap.mapOf(binder(), TASK_KIND);
-    DynamicMap.mapOf(binder(), TOP_MENU_KIND);
-    child(CONFIG_KIND, "capabilities").to(CapabilitiesCollection.class);
-    child(CONFIG_KIND, "tasks").to(TasksCollection.class);
-    get(TASK_KIND).to(GetTask.class);
-    delete(TASK_KIND).to(DeleteTask.class);
-    child(CONFIG_KIND, "top-menus").to(TopMenuCollection.class);
-    get(CONFIG_KIND, "version").to(GetVersion.class);
-    get(CONFIG_KIND, "info").to(GetServerInfo.class);
-    post(CONFIG_KIND, "check.consistency").to(CheckConsistency.class);
-    get(CONFIG_KIND, "preferences").to(GetPreferences.class);
-    put(CONFIG_KIND, "preferences").to(SetPreferences.class);
-    get(CONFIG_KIND, "preferences.diff").to(GetDiffPreferences.class);
-    put(CONFIG_KIND, "preferences.diff").to(SetDiffPreferences.class);
-    put(CONFIG_KIND, "email.confirm").to(ConfirmEmail.class);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
deleted file mode 100644
index d08f0a9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
+++ /dev/null
@@ -1,131 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static com.google.gerrit.common.data.GlobalCapability.FLUSH_CACHES;
-import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
-
-import com.google.common.cache.Cache;
-import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
-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.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.config.PostCaches.Input;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.List;
-
-@RequiresAnyCapability({FLUSH_CACHES, MAINTAIN_SERVER})
-@Singleton
-public class PostCaches implements RestModifyView<ConfigResource, Input> {
-  public static class Input {
-    public Operation operation;
-    public List<String> caches;
-
-    public Input() {}
-
-    public Input(Operation op) {
-      this(op, null);
-    }
-
-    public Input(Operation op, List<String> c) {
-      operation = op;
-      caches = c;
-    }
-  }
-
-  public enum Operation {
-    FLUSH_ALL,
-    FLUSH
-  }
-
-  private final DynamicMap<Cache<?, ?>> cacheMap;
-  private final FlushCache flushCache;
-
-  @Inject
-  public PostCaches(DynamicMap<Cache<?, ?>> cacheMap, FlushCache flushCache) {
-    this.cacheMap = cacheMap;
-    this.flushCache = flushCache;
-  }
-
-  @Override
-  public Response<String> apply(ConfigResource rsrc, Input input)
-      throws AuthException, BadRequestException, UnprocessableEntityException,
-          PermissionBackendException {
-    if (input == null || input.operation == null) {
-      throw new BadRequestException("operation must be specified");
-    }
-
-    switch (input.operation) {
-      case FLUSH_ALL:
-        if (input.caches != null) {
-          throw new BadRequestException(
-              "specifying caches is not allowed for operation 'FLUSH_ALL'");
-        }
-        flushAll();
-        return Response.ok("");
-      case FLUSH:
-        if (input.caches == null || input.caches.isEmpty()) {
-          throw new BadRequestException("caches must be specified for operation 'FLUSH'");
-        }
-        flush(input.caches);
-        return Response.ok("");
-      default:
-        throw new BadRequestException("unsupported operation: " + input.operation);
-    }
-  }
-
-  private void flushAll() throws AuthException, PermissionBackendException {
-    for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-      CacheResource cacheResource =
-          new CacheResource(e.getPluginName(), e.getExportName(), e.getProvider());
-      if (FlushCache.WEB_SESSIONS.equals(cacheResource.getName())) {
-        continue;
-      }
-      flushCache.apply(cacheResource, null);
-    }
-  }
-
-  private void flush(List<String> cacheNames)
-      throws UnprocessableEntityException, AuthException, PermissionBackendException {
-    List<CacheResource> cacheResources = new ArrayList<>(cacheNames.size());
-
-    for (String n : cacheNames) {
-      String pluginName = "gerrit";
-      String cacheName = n;
-      int i = cacheName.lastIndexOf('-');
-      if (i != -1) {
-        pluginName = cacheName.substring(0, i);
-        cacheName = cacheName.length() > i + 1 ? cacheName.substring(i + 1) : "";
-      }
-
-      Cache<?, ?> cache = cacheMap.get(pluginName, cacheName);
-      if (cache != null) {
-        cacheResources.add(new CacheResource(pluginName, cacheName, cache));
-      } else {
-        throw new UnprocessableEntityException(String.format("cache %s not found", n));
-      }
-    }
-
-    for (CacheResource rsrc : cacheResources) {
-      flushCache.apply(rsrc, null);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/RestCacheAdminModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/RestCacheAdminModule.java
deleted file mode 100644
index 992c62e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/RestCacheAdminModule.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static com.google.gerrit.server.config.CacheResource.CACHE_KIND;
-import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.RestApiModule;
-
-public class RestCacheAdminModule extends RestApiModule {
-
-  @Override
-  protected void configure() {
-    DynamicMap.mapOf(binder(), CACHE_KIND);
-    child(CONFIG_KIND, "caches").to(CachesCollection.class);
-    get(CACHE_KIND).to(GetCache.class);
-    post(CACHE_KIND, "flush").to(FlushCache.class);
-    get(CONFIG_KIND, "summary").to(GetSummary.class);
-  }
-}
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
deleted file mode 100644
index 4a87474..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ScheduleConfig.java
+++ /dev/null
@@ -1,194 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.common.annotations.VisibleForTesting;
-import java.text.MessageFormat;
-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;
-
-public class ScheduleConfig {
-  private static final Logger log = LoggerFactory.getLogger(ScheduleConfig.class);
-  public static final long MISSING_CONFIG = -1L;
-  public static final long INVALID_CONFIG = -2L;
-  private static final String KEY_INTERVAL = "interval";
-  private static final String KEY_STARTTIME = "startTime";
-
-  private final Config rc;
-  private final String section;
-  private final String subsection;
-  private final String keyInterval;
-  private final String keyStartTime;
-  private final long initialDelay;
-  private final long interval;
-
-  public ScheduleConfig(Config rc, String section) {
-    this(rc, section, null);
-  }
-
-  public ScheduleConfig(Config rc, String section, String subsection) {
-    this(rc, section, subsection, DateTime.now());
-  }
-
-  public ScheduleConfig(
-      Config rc, String section, String subsection, String keyInterval, String keyStartTime) {
-    this(rc, section, subsection, keyInterval, keyStartTime, DateTime.now());
-  }
-
-  @VisibleForTesting
-  ScheduleConfig(Config rc, String section, String subsection, DateTime now) {
-    this(rc, section, subsection, KEY_INTERVAL, KEY_STARTTIME, now);
-  }
-
-  @VisibleForTesting
-  ScheduleConfig(
-      Config rc,
-      String section,
-      String subsection,
-      String keyInterval,
-      String keyStartTime,
-      DateTime now) {
-    this.rc = rc;
-    this.section = section;
-    this.subsection = subsection;
-    this.keyInterval = keyInterval;
-    this.keyStartTime = keyStartTime;
-    this.interval = interval(rc, section, subsection, keyInterval);
-    if (interval > 0) {
-      this.initialDelay = initialDelay(rc, section, subsection, keyStartTime, now, interval);
-    } else {
-      this.initialDelay = interval;
-    }
-  }
-
-  /**
-   * Milliseconds between constructor invocation and first event time.
-   *
-   * <p>If there is any lag between the constructor invocation and queuing the object into an
-   * executor the event will run later, as there is no method to adjust for the scheduling delay.
-   */
-  public long getInitialDelay() {
-    return initialDelay;
-  }
-
-  /** Number of milliseconds between events. */
-  public long getInterval() {
-    return interval;
-  }
-
-  private static long interval(Config rc, String section, String subsection, String keyInterval) {
-    long interval = MISSING_CONFIG;
-    try {
-      interval =
-          ConfigUtil.getTimeUnit(rc, section, subsection, keyInterval, -1, TimeUnit.MILLISECONDS);
-      if (interval == MISSING_CONFIG) {
-        log.info(
-            MessageFormat.format(
-                "{0} schedule parameter \"{0}.{1}\" is not configured", section, keyInterval));
-      }
-    } catch (IllegalArgumentException e) {
-      log.error(
-          MessageFormat.format("Invalid {0} schedule parameter \"{0}.{1}\"", section, keyInterval),
-          e);
-      interval = INVALID_CONFIG;
-    }
-    return interval;
-  }
-
-  private static long initialDelay(
-      Config rc,
-      String section,
-      String subsection,
-      String keyStartTime,
-      DateTime now,
-      long interval) {
-    long delay = MISSING_CONFIG;
-    String start = rc.getString(section, subsection, keyStartTime);
-    try {
-      if (start != null) {
-        DateTimeFormatter formatter;
-        MutableDateTime startTime = now.toMutableDateTime();
-        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());
-        }
-        startTime.secondOfMinute().set(0);
-        startTime.millisOfSecond().set(0);
-        long s = startTime.getMillis();
-        long n = now.getMillis();
-        delay = (s - n) % interval;
-        if (delay <= 0) {
-          delay += interval;
-        }
-      } else {
-        log.info(
-            MessageFormat.format(
-                "{0} schedule parameter \"{0}.{1}\" is not configured", section, keyStartTime));
-      }
-    } catch (IllegalArgumentException e2) {
-      log.error(
-          MessageFormat.format("Invalid {0} schedule parameter \"{0}.{1}\"", section, keyStartTime),
-          e2);
-      delay = INVALID_CONFIG;
-    }
-    return delay;
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder b = new StringBuilder();
-    b.append(formatValue(keyInterval));
-    b.append(", ");
-    b.append(formatValue(keyStartTime));
-    return b.toString();
-  }
-
-  private String formatValue(String key) {
-    StringBuilder b = new StringBuilder();
-    b.append(section);
-    if (subsection != null) {
-      b.append(".");
-      b.append(subsection);
-    }
-    b.append(".");
-    b.append(key);
-    String value = rc.getString(section, subsection, key);
-    if (value != null) {
-      b.append(" = ");
-      b.append(value);
-    } else {
-      b.append(": NA");
-    }
-    return b.toString();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/SetDiffPreferences.java
deleted file mode 100644
index 80c4625..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/SetDiffPreferences.java
+++ /dev/null
@@ -1,106 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.google.gerrit.server.config;
-
-import static com.google.gerrit.server.config.ConfigUtil.loadSection;
-import static com.google.gerrit.server.config.ConfigUtil.skipField;
-import static com.google.gerrit.server.config.ConfigUtil.storeSection;
-import static com.google.gerrit.server.config.GetDiffPreferences.readFromGit;
-
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.account.VersionedAccountPreferences;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.UserConfigSections;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.lang.reflect.Field;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@Singleton
-public class SetDiffPreferences implements RestModifyView<ConfigResource, DiffPreferencesInfo> {
-  private static final Logger log = LoggerFactory.getLogger(SetDiffPreferences.class);
-
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final AllUsersName allUsersName;
-  private final GitRepositoryManager gitManager;
-
-  @Inject
-  SetDiffPreferences(
-      GitRepositoryManager gitManager,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      AllUsersName allUsersName) {
-    this.gitManager = gitManager;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.allUsersName = allUsersName;
-  }
-
-  @Override
-  public DiffPreferencesInfo apply(ConfigResource configResource, DiffPreferencesInfo in)
-      throws BadRequestException, IOException, ConfigInvalidException {
-    if (in == null) {
-      throw new BadRequestException("input must be provided");
-    }
-    if (!hasSetFields(in)) {
-      throw new BadRequestException("unsupported option");
-    }
-    return writeToGit(readFromGit(gitManager, allUsersName, in));
-  }
-
-  private DiffPreferencesInfo writeToGit(DiffPreferencesInfo in)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    DiffPreferencesInfo out = new DiffPreferencesInfo();
-    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
-      VersionedAccountPreferences prefs = VersionedAccountPreferences.forDefault();
-      prefs.load(md);
-      DiffPreferencesInfo defaults = DiffPreferencesInfo.defaults();
-      storeSection(prefs.getConfig(), UserConfigSections.DIFF, null, in, defaults);
-      prefs.commit(md);
-      loadSection(
-          prefs.getConfig(),
-          UserConfigSections.DIFF,
-          null,
-          out,
-          DiffPreferencesInfo.defaults(),
-          null);
-    }
-    return out;
-  }
-
-  private static boolean hasSetFields(DiffPreferencesInfo in) {
-    try {
-      for (Field field : in.getClass().getDeclaredFields()) {
-        if (skipField(field)) {
-          continue;
-        }
-        if (field.get(in) != null) {
-          return true;
-        }
-      }
-    } catch (IllegalAccessException e) {
-      log.warn("Unable to verify input", e);
-    }
-    return false;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java
deleted file mode 100644
index cc96cf0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java
+++ /dev/null
@@ -1,117 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static com.google.gerrit.server.config.ConfigUtil.loadSection;
-import static com.google.gerrit.server.config.ConfigUtil.skipField;
-import static com.google.gerrit.server.config.ConfigUtil.storeSection;
-import static com.google.gerrit.server.config.GetPreferences.readFromGit;
-
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.GeneralPreferencesLoader;
-import com.google.gerrit.server.account.VersionedAccountPreferences;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.UserConfigSections;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.lang.reflect.Field;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@Singleton
-public class SetPreferences implements RestModifyView<ConfigResource, GeneralPreferencesInfo> {
-  private static final Logger log = LoggerFactory.getLogger(SetPreferences.class);
-
-  private final GeneralPreferencesLoader loader;
-  private final GitRepositoryManager gitManager;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final AllUsersName allUsersName;
-  private final AccountCache accountCache;
-
-  @Inject
-  SetPreferences(
-      GeneralPreferencesLoader loader,
-      GitRepositoryManager gitManager,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      AllUsersName allUsersName,
-      AccountCache accountCache) {
-    this.loader = loader;
-    this.gitManager = gitManager;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.allUsersName = allUsersName;
-    this.accountCache = accountCache;
-  }
-
-  @Override
-  public GeneralPreferencesInfo apply(ConfigResource rsrc, GeneralPreferencesInfo i)
-      throws BadRequestException, IOException, ConfigInvalidException {
-    if (!hasSetFields(i)) {
-      throw new BadRequestException("unsupported option");
-    }
-    return writeToGit(readFromGit(gitManager, loader, allUsersName, i));
-  }
-
-  private GeneralPreferencesInfo writeToGit(GeneralPreferencesInfo i)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException, BadRequestException {
-    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
-      VersionedAccountPreferences p = VersionedAccountPreferences.forDefault();
-      p.load(md);
-      storeSection(
-          p.getConfig(), UserConfigSections.GENERAL, null, i, GeneralPreferencesInfo.defaults());
-      com.google.gerrit.server.account.SetPreferences.storeMyMenus(p, i.my);
-      com.google.gerrit.server.account.SetPreferences.storeUrlAliases(p, i.urlAliases);
-      p.commit(md);
-
-      accountCache.evictAllNoReindex();
-
-      GeneralPreferencesInfo r =
-          loadSection(
-              p.getConfig(),
-              UserConfigSections.GENERAL,
-              null,
-              new GeneralPreferencesInfo(),
-              GeneralPreferencesInfo.defaults(),
-              null);
-      return loader.loadMyMenusAndUrlAliases(r, p, null);
-    }
-  }
-
-  private static boolean hasSetFields(GeneralPreferencesInfo in) {
-    try {
-      for (Field field : in.getClass().getDeclaredFields()) {
-        if (skipField(field)) {
-          continue;
-        }
-        if (field.get(in) != null) {
-          return true;
-        }
-      }
-    } catch (IllegalAccessException e) {
-      log.warn("Unable to verify input", e);
-    }
-    return false;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java
deleted file mode 100644
index 3748bfd..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java
+++ /dev/null
@@ -1,152 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.common.collect.Iterables;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.nio.file.DirectoryStream;
-import java.nio.file.Files;
-import java.nio.file.NoSuchFileException;
-import java.nio.file.Path;
-
-/** Important paths within a {@link SitePath}. */
-@Singleton
-public final class SitePaths {
-  public static final String CSS_FILENAME = "GerritSite.css";
-  public static final String HEADER_FILENAME = "GerritSiteHeader.html";
-  public static final String FOOTER_FILENAME = "GerritSiteFooter.html";
-  public static final String THEME_FILENAME = "gerrit-theme.html";
-
-  public final Path site_path;
-  public final Path bin_dir;
-  public final Path etc_dir;
-  public final Path lib_dir;
-  public final Path tmp_dir;
-  public final Path logs_dir;
-  public final Path plugins_dir;
-  public final Path db_dir;
-  public final Path data_dir;
-  public final Path mail_dir;
-  public final Path hooks_dir;
-  public final Path static_dir;
-  public final Path themes_dir;
-  public final Path index_dir;
-
-  public final Path gerrit_sh;
-  public final Path gerrit_service;
-  public final Path gerrit_socket;
-  public final Path gerrit_war;
-
-  public final Path gerrit_config;
-  public final Path secure_config;
-  public final Path notedb_config;
-
-  public final Path ssl_keystore;
-  public final Path ssh_key;
-  public final Path ssh_rsa;
-  public final Path ssh_dsa;
-  public final Path ssh_ecdsa_256;
-  public final Path ssh_ecdsa_384;
-  public final Path ssh_ecdsa_521;
-  public final Path ssh_ed25519;
-  public final Path peer_keys;
-
-  public final Path site_css;
-  public final Path site_header;
-  public final Path site_footer;
-  // For PolyGerrit UI only.
-  public final Path site_theme;
-  public final Path site_gitweb;
-
-  /** {@code true} if {@link #site_path} has not been initialized. */
-  public final boolean isNew;
-
-  @Inject
-  public SitePaths(@SitePath Path sitePath) throws IOException {
-    site_path = sitePath;
-    Path p = sitePath;
-
-    bin_dir = p.resolve("bin");
-    etc_dir = p.resolve("etc");
-    lib_dir = p.resolve("lib");
-    tmp_dir = p.resolve("tmp");
-    plugins_dir = p.resolve("plugins");
-    db_dir = p.resolve("db");
-    data_dir = p.resolve("data");
-    logs_dir = p.resolve("logs");
-    mail_dir = etc_dir.resolve("mail");
-    hooks_dir = p.resolve("hooks");
-    static_dir = p.resolve("static");
-    themes_dir = p.resolve("themes");
-    index_dir = p.resolve("index");
-
-    gerrit_sh = bin_dir.resolve("gerrit.sh");
-    gerrit_service = bin_dir.resolve("gerrit.service");
-    gerrit_socket = bin_dir.resolve("gerrit.socket");
-    gerrit_war = bin_dir.resolve("gerrit.war");
-
-    gerrit_config = etc_dir.resolve("gerrit.config");
-    secure_config = etc_dir.resolve("secure.config");
-    notedb_config = etc_dir.resolve("notedb.config");
-
-    ssl_keystore = etc_dir.resolve("keystore");
-    ssh_key = etc_dir.resolve("ssh_host_key");
-    ssh_rsa = etc_dir.resolve("ssh_host_rsa_key");
-    ssh_dsa = etc_dir.resolve("ssh_host_dsa_key");
-    ssh_ecdsa_256 = etc_dir.resolve("ssh_host_ecdsa_key");
-    ssh_ecdsa_384 = etc_dir.resolve("ssh_host_ecdsa_384_key");
-    ssh_ecdsa_521 = etc_dir.resolve("ssh_host_ecdsa_521_key");
-    ssh_ed25519 = etc_dir.resolve("ssh_host_ed25519_key");
-    peer_keys = etc_dir.resolve("peer_keys");
-
-    site_css = etc_dir.resolve(CSS_FILENAME);
-    site_header = etc_dir.resolve(HEADER_FILENAME);
-    site_footer = etc_dir.resolve(FOOTER_FILENAME);
-    site_gitweb = etc_dir.resolve("gitweb_config.perl");
-
-    // For PolyGerrit UI.
-    site_theme = static_dir.resolve(THEME_FILENAME);
-
-    boolean isNew;
-    try (DirectoryStream<Path> files = Files.newDirectoryStream(site_path)) {
-      isNew = Iterables.isEmpty(files);
-    } catch (NoSuchFileException e) {
-      isNew = true;
-    }
-    this.isNew = isNew;
-  }
-
-  /**
-   * Resolve an absolute or relative path.
-   *
-   * <p>Relative paths are resolved relative to the {@link #site_path}.
-   *
-   * @param path the path string to resolve. May be null.
-   * @return the resolved path; null if {@code path} was null or empty.
-   */
-  public Path resolve(String path) {
-    if (path != null && !path.isEmpty()) {
-      Path loc = site_path.resolve(path).normalize();
-      try {
-        return loc.toRealPath();
-      } catch (IOException e) {
-        return loc.toAbsolutePath();
-      }
-    }
-    return null;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java
deleted file mode 100644
index fcaee8e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java
+++ /dev/null
@@ -1,106 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.git.WorkQueue.ProjectTask;
-import com.google.gerrit.server.git.WorkQueue.Task;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class TasksCollection implements ChildCollection<ConfigResource, TaskResource> {
-  private final DynamicMap<RestView<TaskResource>> views;
-  private final ListTasks list;
-  private final WorkQueue workQueue;
-  private final Provider<CurrentUser> self;
-  private final PermissionBackend permissionBackend;
-
-  @Inject
-  TasksCollection(
-      DynamicMap<RestView<TaskResource>> views,
-      ListTasks list,
-      WorkQueue workQueue,
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend) {
-    this.views = views;
-    this.list = list;
-    this.workQueue = workQueue;
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-  }
-
-  @Override
-  public RestView<ConfigResource> list() {
-    return list;
-  }
-
-  @Override
-  public TaskResource parse(ConfigResource parent, IdString id)
-      throws ResourceNotFoundException, AuthException, PermissionBackendException {
-    CurrentUser user = self.get();
-    if (!user.isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    int taskId;
-    try {
-      taskId = (int) Long.parseLong(id.get(), 16);
-    } catch (NumberFormatException e) {
-      throw new ResourceNotFoundException(id);
-    }
-
-    Task<?> task = workQueue.getTask(taskId);
-    if (task instanceof ProjectTask) {
-      try {
-        permissionBackend
-            .user(user)
-            .project(((ProjectTask<?>) task).getProjectNameKey())
-            .check(ProjectPermission.ACCESS);
-        return new TaskResource(task);
-      } catch (AuthException e) {
-        // Fall through and try view queue permission.
-      }
-    }
-
-    if (task != null) {
-      try {
-        permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
-        return new TaskResource(task);
-      } catch (AuthException e) {
-        // Fall through and return not found.
-      }
-    }
-
-    throw new ResourceNotFoundException(id);
-  }
-
-  @Override
-  public DynamicMap<RestView<TaskResource>> views() {
-    return views;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TopMenuCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/TopMenuCollection.java
deleted file mode 100644
index 2fc2dc1..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/TopMenuCollection.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-class TopMenuCollection implements ChildCollection<ConfigResource, TopMenuResource> {
-  private final DynamicMap<RestView<TopMenuResource>> views;
-  private final ListTopMenus list;
-
-  @Inject
-  TopMenuCollection(DynamicMap<RestView<TopMenuResource>> views, ListTopMenus list) {
-    this.views = views;
-    this.list = list;
-  }
-
-  @Override
-  public RestView<ConfigResource> list() throws ResourceNotFoundException {
-    return list;
-  }
-
-  @Override
-  public TopMenuResource parse(ConfigResource parent, IdString id)
-      throws ResourceNotFoundException {
-    throw new ResourceNotFoundException(id);
-  }
-
-  @Override
-  public DynamicMap<RestView<TopMenuResource>> views() {
-    return views;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
deleted file mode 100644
index 82fa596..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ /dev/null
@@ -1,612 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.edit;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MergeConflictException;
-import com.google.gerrit.extensions.restapi.RawInput;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-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.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
-import com.google.gerrit.server.edit.tree.DeleteFileModification;
-import com.google.gerrit.server.edit.tree.RenameFileModification;
-import com.google.gerrit.server.edit.tree.RestoreFileModification;
-import com.google.gerrit.server.edit.tree.TreeCreator;
-import com.google.gerrit.server.edit.tree.TreeModification;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.util.CommitMessageUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.List;
-import java.util.Optional;
-import java.util.TimeZone;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.MergeStrategy;
-import org.eclipse.jgit.merge.ThreeWayMerger;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-/**
- * Utility functions to manipulate change edits.
- *
- * <p>This class contains methods to modify edit's content. For retrieving, publishing and deleting
- * edit see {@link ChangeEditUtil}.
- *
- * <p>
- */
-@Singleton
-public class ChangeEditModifier {
-
-  private final TimeZone tz;
-  private final ChangeIndexer indexer;
-  private final Provider<ReviewDb> reviewDb;
-  private final Provider<CurrentUser> currentUser;
-  private final PermissionBackend permissionBackend;
-  private final ChangeEditUtil changeEditUtil;
-  private final PatchSetUtil patchSetUtil;
-
-  @Inject
-  ChangeEditModifier(
-      @GerritPersonIdent PersonIdent gerritIdent,
-      ChangeIndexer indexer,
-      Provider<ReviewDb> reviewDb,
-      Provider<CurrentUser> currentUser,
-      PermissionBackend permissionBackend,
-      ChangeEditUtil changeEditUtil,
-      PatchSetUtil patchSetUtil) {
-    this.indexer = indexer;
-    this.reviewDb = reviewDb;
-    this.currentUser = currentUser;
-    this.permissionBackend = permissionBackend;
-    this.tz = gerritIdent.getTimeZone();
-    this.changeEditUtil = changeEditUtil;
-    this.patchSetUtil = patchSetUtil;
-  }
-
-  /**
-   * Creates a new change edit.
-   *
-   * @param repository the affected Git repository
-   * @param notes the {@link ChangeNotes} of the change for which the change edit should be created
-   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
-   * @throws InvalidChangeOperationException if a change edit already existed for the change
-   * @throws PermissionBackendException
-   */
-  public void createEdit(Repository repository, ChangeNotes notes)
-      throws AuthException, IOException, InvalidChangeOperationException, OrmException,
-          PermissionBackendException {
-    assertCanEdit(notes);
-
-    Optional<ChangeEdit> changeEdit = lookupChangeEdit(notes);
-    if (changeEdit.isPresent()) {
-      throw new InvalidChangeOperationException(
-          String.format("A change edit already exists for change %s", notes.getChangeId()));
-    }
-
-    PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
-    ObjectId patchSetCommitId = getPatchSetCommitId(currentPatchSet);
-    createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
-  }
-
-  /**
-   * Rebase change edit on latest patch set
-   *
-   * @param repository the affected Git repository
-   * @param notes the {@link ChangeNotes} of the change whose change edit should be rebased
-   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
-   * @throws InvalidChangeOperationException if a change edit doesn't exist for the specified
-   *     change, the change edit is already based on the latest patch set, or the change represents
-   *     the root commit
-   * @throws MergeConflictException if rebase fails due to merge conflicts
-   * @throws PermissionBackendException
-   */
-  public void rebaseEdit(Repository repository, ChangeNotes notes)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
-          MergeConflictException, PermissionBackendException {
-    assertCanEdit(notes);
-
-    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
-    if (!optionalChangeEdit.isPresent()) {
-      throw new InvalidChangeOperationException(
-          String.format("No change edit exists for change %s", notes.getChangeId()));
-    }
-    ChangeEdit changeEdit = optionalChangeEdit.get();
-
-    PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
-    if (isBasedOn(changeEdit, currentPatchSet)) {
-      throw new InvalidChangeOperationException(
-          String.format(
-              "Change edit for change %s is already based on latest patch set %s",
-              notes.getChangeId(), currentPatchSet.getId()));
-    }
-
-    rebase(repository, changeEdit, currentPatchSet);
-  }
-
-  private void rebase(Repository repository, ChangeEdit changeEdit, PatchSet currentPatchSet)
-      throws IOException, MergeConflictException, InvalidChangeOperationException, OrmException {
-    RevCommit currentEditCommit = changeEdit.getEditCommit();
-    if (currentEditCommit.getParentCount() == 0) {
-      throw new InvalidChangeOperationException(
-          "Rebase change edit against root commit not supported");
-    }
-
-    Change change = changeEdit.getChange();
-    RevCommit basePatchSetCommit = lookupCommit(repository, currentPatchSet);
-    RevTree basePatchSetTree = basePatchSetCommit.getTree();
-
-    ObjectId newTreeId = merge(repository, changeEdit, basePatchSetTree);
-    Timestamp nowTimestamp = TimeUtil.nowTs();
-    String commitMessage = currentEditCommit.getFullMessage();
-    ObjectId newEditCommitId =
-        createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
-
-    String newEditRefName = getEditRefName(change, currentPatchSet);
-    updateReferenceWithNameChange(
-        repository,
-        changeEdit.getRefName(),
-        currentEditCommit,
-        newEditRefName,
-        newEditCommitId,
-        nowTimestamp);
-    reindex(change);
-  }
-
-  /**
-   * Modifies the commit message of a change edit. If the change edit doesn't exist, a new one will
-   * be created based on the current patch set.
-   *
-   * @param repository the affected Git repository
-   * @param notes the {@link ChangeNotes} of the change whose change edit's message should be
-   *     modified
-   * @param newCommitMessage the new commit message
-   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
-   * @throws UnchangedCommitMessageException if the commit message is the same as before
-   * @throws PermissionBackendException
-   * @throws BadRequestException if the commit message is malformed
-   */
-  public void modifyMessage(Repository repository, ChangeNotes notes, String newCommitMessage)
-      throws AuthException, IOException, UnchangedCommitMessageException, OrmException,
-          PermissionBackendException, BadRequestException {
-    assertCanEdit(notes);
-    newCommitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(newCommitMessage);
-
-    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
-    PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, notes);
-    RevCommit basePatchSetCommit = lookupCommit(repository, basePatchSet);
-    RevCommit baseCommit =
-        optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(basePatchSetCommit);
-
-    String currentCommitMessage = baseCommit.getFullMessage();
-    if (newCommitMessage.equals(currentCommitMessage)) {
-      throw new UnchangedCommitMessageException();
-    }
-
-    RevTree baseTree = baseCommit.getTree();
-    Timestamp nowTimestamp = TimeUtil.nowTs();
-    ObjectId newEditCommit =
-        createCommit(repository, basePatchSetCommit, baseTree, newCommitMessage, nowTimestamp);
-
-    if (optionalChangeEdit.isPresent()) {
-      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
-    } else {
-      createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
-    }
-  }
-
-  /**
-   * Modifies the contents of a file of a change edit. If the change edit doesn't exist, a new one
-   * will be created based on the current patch set.
-   *
-   * @param repository the affected Git repository
-   * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
-   * @param filePath the path of the file whose contents should be modified
-   * @param newContent the new file content
-   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
-   * @throws InvalidChangeOperationException if the file already had the specified content
-   * @throws PermissionBackendException
-   */
-  public void modifyFile(
-      Repository repository, ChangeNotes notes, String filePath, RawInput newContent)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
-          PermissionBackendException {
-    modifyTree(repository, notes, new ChangeFileContentModification(filePath, newContent));
-  }
-
-  /**
-   * Deletes a file from the Git tree of a change edit. If the change edit doesn't exist, a new one
-   * will be created based on the current patch set.
-   *
-   * @param repository the affected Git repository
-   * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
-   * @param file path of the file which should be deleted
-   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
-   * @throws InvalidChangeOperationException if the file does not exist
-   * @throws PermissionBackendException
-   */
-  public void deleteFile(Repository repository, ChangeNotes notes, String file)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
-          PermissionBackendException {
-    modifyTree(repository, notes, new DeleteFileModification(file));
-  }
-
-  /**
-   * Renames a file of a change edit or moves it to another directory. If the change edit doesn't
-   * exist, a new one will be created based on the current patch set.
-   *
-   * @param repository the affected Git repository
-   * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
-   * @param currentFilePath the current path/name of the file
-   * @param newFilePath the desired path/name of the file
-   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
-   * @throws InvalidChangeOperationException if the file was already renamed to the specified new
-   *     name
-   * @throws PermissionBackendException
-   */
-  public void renameFile(
-      Repository repository, ChangeNotes notes, String currentFilePath, String newFilePath)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
-          PermissionBackendException {
-    modifyTree(repository, notes, new RenameFileModification(currentFilePath, newFilePath));
-  }
-
-  /**
-   * Restores a file of a change edit to the state it was in before the patch set on which the
-   * change edit is based. If the change edit doesn't exist, a new one will be created based on the
-   * current patch set.
-   *
-   * @param repository the affected Git repository
-   * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
-   * @param file the path of the file which should be restored
-   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
-   * @throws InvalidChangeOperationException if the file was already restored
-   * @throws PermissionBackendException
-   */
-  public void restoreFile(Repository repository, ChangeNotes notes, String file)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
-          PermissionBackendException {
-    modifyTree(repository, notes, new RestoreFileModification(file));
-  }
-
-  private void modifyTree(
-      Repository repository, ChangeNotes notes, TreeModification treeModification)
-      throws AuthException, IOException, OrmException, InvalidChangeOperationException,
-          PermissionBackendException {
-    assertCanEdit(notes);
-
-    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
-    PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, notes);
-    RevCommit basePatchSetCommit = lookupCommit(repository, basePatchSet);
-    RevCommit baseCommit =
-        optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(basePatchSetCommit);
-
-    ObjectId newTreeId = createNewTree(repository, baseCommit, ImmutableList.of(treeModification));
-
-    String commitMessage = baseCommit.getFullMessage();
-    Timestamp nowTimestamp = TimeUtil.nowTs();
-    ObjectId newEditCommit =
-        createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
-
-    if (optionalChangeEdit.isPresent()) {
-      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
-    } else {
-      createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
-    }
-  }
-
-  /**
-   * Applies the indicated modifications to the specified patch set. If a change edit exists and is
-   * based on the same patch set, the modified patch set tree is merged with the change edit. If the
-   * change edit doesn't exist, a new one will be created.
-   *
-   * @param repository the affected Git repository
-   * @param notes the {@link ChangeNotes} of the change to which the patch set belongs
-   * @param patchSet the {@code PatchSet} which should be modified
-   * @param treeModifications the modifications which should be applied
-   * @return the resulting {@code ChangeEdit}
-   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
-   * @throws InvalidChangeOperationException if the existing change edit is based on another patch
-   *     set or no change edit exists but the specified patch set isn't the current one
-   * @throws MergeConflictException if the modified patch set tree can't be merged with an existing
-   *     change edit
-   */
-  public ChangeEdit combineWithModifiedPatchSetTree(
-      Repository repository,
-      ChangeNotes notes,
-      PatchSet patchSet,
-      List<TreeModification> treeModifications)
-      throws AuthException, IOException, InvalidChangeOperationException, MergeConflictException,
-          OrmException, PermissionBackendException {
-    assertCanEdit(notes);
-
-    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
-    ensureAllowedPatchSet(notes, optionalChangeEdit, patchSet);
-
-    RevCommit patchSetCommit = lookupCommit(repository, patchSet);
-    ObjectId newTreeId = createNewTree(repository, patchSetCommit, treeModifications);
-
-    if (optionalChangeEdit.isPresent()) {
-      ChangeEdit changeEdit = optionalChangeEdit.get();
-      newTreeId = merge(repository, changeEdit, newTreeId);
-      if (ObjectId.equals(newTreeId, changeEdit.getEditCommit().getTree())) {
-        // Modifications are already contained in the change edit.
-        return changeEdit;
-      }
-    }
-
-    String commitMessage =
-        optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(patchSetCommit).getFullMessage();
-    Timestamp nowTimestamp = TimeUtil.nowTs();
-    ObjectId newEditCommit =
-        createCommit(repository, patchSetCommit, newTreeId, commitMessage, nowTimestamp);
-
-    if (optionalChangeEdit.isPresent()) {
-      return updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
-    }
-    return createEdit(repository, notes, patchSet, newEditCommit, nowTimestamp);
-  }
-
-  private void assertCanEdit(ChangeNotes notes) throws AuthException, PermissionBackendException {
-    if (!currentUser.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-    try {
-      permissionBackend
-          .user(currentUser)
-          .database(reviewDb)
-          .change(notes)
-          .check(ChangePermission.ADD_PATCH_SET);
-    } catch (AuthException denied) {
-      throw new AuthException("edit not permitted", denied);
-    }
-  }
-
-  private static void ensureAllowedPatchSet(
-      ChangeNotes notes, Optional<ChangeEdit> optionalChangeEdit, PatchSet patchSet)
-      throws InvalidChangeOperationException {
-    if (optionalChangeEdit.isPresent()) {
-      ChangeEdit changeEdit = optionalChangeEdit.get();
-      if (!isBasedOn(changeEdit, patchSet)) {
-        throw new InvalidChangeOperationException(
-            String.format(
-                "Only the patch set %s on which the existing change edit is based may be modified "
-                    + "(specified patch set: %s)",
-                changeEdit.getBasePatchSet().getId(), patchSet.getId()));
-      }
-    } else {
-      PatchSet.Id patchSetId = patchSet.getId();
-      PatchSet.Id currentPatchSetId = notes.getChange().currentPatchSetId();
-      if (!patchSetId.equals(currentPatchSetId)) {
-        throw new InvalidChangeOperationException(
-            String.format(
-                "A change edit may only be created for the current patch set %s (and not for %s)",
-                currentPatchSetId, patchSetId));
-      }
-    }
-  }
-
-  private Optional<ChangeEdit> lookupChangeEdit(ChangeNotes notes)
-      throws AuthException, IOException {
-    return changeEditUtil.byChange(notes);
-  }
-
-  private PatchSet getBasePatchSet(Optional<ChangeEdit> optionalChangeEdit, ChangeNotes notes)
-      throws OrmException {
-    Optional<PatchSet> editBasePatchSet = optionalChangeEdit.map(ChangeEdit::getBasePatchSet);
-    return editBasePatchSet.isPresent() ? editBasePatchSet.get() : lookupCurrentPatchSet(notes);
-  }
-
-  private PatchSet lookupCurrentPatchSet(ChangeNotes notes) throws OrmException {
-    return patchSetUtil.current(reviewDb.get(), notes);
-  }
-
-  private static boolean isBasedOn(ChangeEdit changeEdit, PatchSet patchSet) {
-    PatchSet editBasePatchSet = changeEdit.getBasePatchSet();
-    return editBasePatchSet.getId().equals(patchSet.getId());
-  }
-
-  private static RevCommit lookupCommit(Repository repository, PatchSet patchSet)
-      throws IOException {
-    ObjectId patchSetCommitId = getPatchSetCommitId(patchSet);
-    return lookupCommit(repository, patchSetCommitId);
-  }
-
-  private static RevCommit lookupCommit(Repository repository, ObjectId commitId)
-      throws IOException {
-    try (RevWalk revWalk = new RevWalk(repository)) {
-      return revWalk.parseCommit(commitId);
-    }
-  }
-
-  private static ObjectId createNewTree(
-      Repository repository, RevCommit baseCommit, List<TreeModification> treeModifications)
-      throws IOException, InvalidChangeOperationException {
-    TreeCreator treeCreator = new TreeCreator(baseCommit);
-    treeCreator.addTreeModifications(treeModifications);
-    ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
-
-    if (ObjectId.equals(newTreeId, baseCommit.getTree())) {
-      throw new InvalidChangeOperationException("no changes were made");
-    }
-    return newTreeId;
-  }
-
-  private static ObjectId merge(Repository repository, ChangeEdit changeEdit, ObjectId newTreeId)
-      throws IOException, MergeConflictException {
-    PatchSet basePatchSet = changeEdit.getBasePatchSet();
-    ObjectId basePatchSetCommitId = getPatchSetCommitId(basePatchSet);
-    ObjectId editCommitId = changeEdit.getEditCommit();
-
-    ThreeWayMerger threeWayMerger = MergeStrategy.RESOLVE.newMerger(repository, true);
-    threeWayMerger.setBase(basePatchSetCommitId);
-    boolean successful = threeWayMerger.merge(newTreeId, editCommitId);
-
-    if (!successful) {
-      throw new MergeConflictException(
-          "The existing change edit could not be merged with another tree.");
-    }
-    return threeWayMerger.getResultTreeId();
-  }
-
-  private ObjectId createCommit(
-      Repository repository,
-      RevCommit basePatchSetCommit,
-      ObjectId tree,
-      String commitMessage,
-      Timestamp timestamp)
-      throws IOException {
-    try (ObjectInserter objectInserter = repository.newObjectInserter()) {
-      CommitBuilder builder = new CommitBuilder();
-      builder.setTreeId(tree);
-      builder.setParentIds(basePatchSetCommit.getParents());
-      builder.setAuthor(basePatchSetCommit.getAuthorIdent());
-      builder.setCommitter(getCommitterIdent(timestamp));
-      builder.setMessage(commitMessage);
-      ObjectId newCommitId = objectInserter.insert(builder);
-      objectInserter.flush();
-      return newCommitId;
-    }
-  }
-
-  private PersonIdent getCommitterIdent(Timestamp commitTimestamp) {
-    IdentifiedUser user = currentUser.get().asIdentifiedUser();
-    return user.newCommitterIdent(commitTimestamp, tz);
-  }
-
-  private static ObjectId getPatchSetCommitId(PatchSet patchSet) {
-    return ObjectId.fromString(patchSet.getRevision().get());
-  }
-
-  private ChangeEdit createEdit(
-      Repository repository,
-      ChangeNotes notes,
-      PatchSet basePatchSet,
-      ObjectId newEditCommitId,
-      Timestamp timestamp)
-      throws IOException, OrmException {
-    Change change = notes.getChange();
-    String editRefName = getEditRefName(change, basePatchSet);
-    updateReference(repository, editRefName, ObjectId.zeroId(), newEditCommitId, timestamp);
-    reindex(change);
-
-    RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
-    return new ChangeEdit(change, editRefName, newEditCommit, basePatchSet);
-  }
-
-  private String getEditRefName(Change change, PatchSet basePatchSet) {
-    IdentifiedUser me = currentUser.get().asIdentifiedUser();
-    return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchSet.getId());
-  }
-
-  private ChangeEdit updateEdit(
-      Repository repository, ChangeEdit changeEdit, ObjectId newEditCommitId, Timestamp timestamp)
-      throws IOException, OrmException {
-    String editRefName = changeEdit.getRefName();
-    RevCommit currentEditCommit = changeEdit.getEditCommit();
-    updateReference(repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
-    reindex(changeEdit.getChange());
-
-    RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
-    return new ChangeEdit(
-        changeEdit.getChange(), editRefName, newEditCommit, changeEdit.getBasePatchSet());
-  }
-
-  private void updateReference(
-      Repository repository,
-      String refName,
-      ObjectId currentObjectId,
-      ObjectId targetObjectId,
-      Timestamp timestamp)
-      throws IOException {
-    RefUpdate ru = repository.updateRef(refName);
-    ru.setExpectedOldObjectId(currentObjectId);
-    ru.setNewObjectId(targetObjectId);
-    ru.setRefLogIdent(getRefLogIdent(timestamp));
-    ru.setRefLogMessage("inline edit (amend)", false);
-    ru.setForceUpdate(true);
-    try (RevWalk revWalk = new RevWalk(repository)) {
-      RefUpdate.Result res = ru.update(revWalk);
-      if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
-        throw new IOException(
-            "cannot update "
-                + ru.getName()
-                + " in "
-                + repository.getDirectory()
-                + ": "
-                + ru.getResult());
-      }
-    }
-  }
-
-  private void updateReferenceWithNameChange(
-      Repository repository,
-      String currentRefName,
-      ObjectId currentObjectId,
-      String newRefName,
-      ObjectId targetObjectId,
-      Timestamp timestamp)
-      throws IOException {
-    BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
-    batchRefUpdate.addCommand(new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
-    batchRefUpdate.addCommand(
-        new ReceiveCommand(currentObjectId, ObjectId.zeroId(), currentRefName));
-    batchRefUpdate.setRefLogMessage("rebase edit", false);
-    batchRefUpdate.setRefLogIdent(getRefLogIdent(timestamp));
-    try (RevWalk revWalk = new RevWalk(repository)) {
-      batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
-    }
-    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
-      if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        throw new IOException("failed: " + cmd);
-      }
-    }
-  }
-
-  private PersonIdent getRefLogIdent(Timestamp timestamp) {
-    IdentifiedUser user = currentUser.get().asIdentifiedUser();
-    return user.newRefLogIdent(timestamp, tz);
-  }
-
-  private void reindex(Change change) throws IOException, OrmException {
-    indexer.index(reviewDb.get(), change);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
deleted file mode 100644
index 3592be3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
+++ /dev/null
@@ -1,651 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.events;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static java.util.Comparator.comparing;
-
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.UserIdentity;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.Emails;
-import com.google.gerrit.server.change.ChangeKindCache;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.data.AccountAttribute;
-import com.google.gerrit.server.data.ApprovalAttribute;
-import com.google.gerrit.server.data.ChangeAttribute;
-import com.google.gerrit.server.data.DependencyAttribute;
-import com.google.gerrit.server.data.MessageAttribute;
-import com.google.gerrit.server.data.PatchAttribute;
-import com.google.gerrit.server.data.PatchSetAttribute;
-import com.google.gerrit.server.data.PatchSetCommentAttribute;
-import com.google.gerrit.server.data.RefUpdateAttribute;
-import com.google.gerrit.server.data.SubmitLabelAttribute;
-import com.google.gerrit.server.data.SubmitRecordAttribute;
-import com.google.gerrit.server.data.TrackingIdAttribute;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListEntry;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class EventFactory {
-  private static final Logger log = LoggerFactory.getLogger(EventFactory.class);
-
-  private final AccountCache accountCache;
-  private final Emails emails;
-  private final Provider<String> urlProvider;
-  private final PatchListCache patchListCache;
-  private final PersonIdent myIdent;
-  private final ChangeData.Factory changeDataFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final ChangeKindCache changeKindCache;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final SchemaFactory<ReviewDb> schema;
-
-  @Inject
-  EventFactory(
-      AccountCache accountCache,
-      Emails emails,
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      PatchListCache patchListCache,
-      @GerritPersonIdent PersonIdent myIdent,
-      ChangeData.Factory changeDataFactory,
-      ApprovalsUtil approvalsUtil,
-      ChangeKindCache changeKindCache,
-      Provider<InternalChangeQuery> queryProvider,
-      SchemaFactory<ReviewDb> schema) {
-    this.accountCache = accountCache;
-    this.emails = emails;
-    this.urlProvider = urlProvider;
-    this.patchListCache = patchListCache;
-    this.myIdent = myIdent;
-    this.changeDataFactory = changeDataFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.changeKindCache = changeKindCache;
-    this.queryProvider = queryProvider;
-    this.schema = schema;
-  }
-
-  /**
-   * Create a ChangeAttribute for the given change suitable for serialization to JSON.
-   *
-   * @param change
-   * @return object suitable for serialization to JSON
-   */
-  public ChangeAttribute asChangeAttribute(Change change) {
-    try (ReviewDb db = schema.open()) {
-      return asChangeAttribute(db, change);
-    } catch (OrmException e) {
-      log.error("Cannot open database connection", e);
-      return new ChangeAttribute();
-    }
-  }
-
-  /**
-   * Create a ChangeAttribute for the given change suitable for serialization to JSON.
-   *
-   * @param db Review database
-   * @param change
-   * @return object suitable for serialization to JSON
-   */
-  public ChangeAttribute asChangeAttribute(ReviewDb db, Change change) {
-    ChangeAttribute a = new ChangeAttribute();
-    a.project = change.getProject().get();
-    a.branch = change.getDest().getShortName();
-    a.topic = change.getTopic();
-    a.id = change.getKey().get();
-    a.number = change.getId().get();
-    a.subject = change.getSubject();
-    try {
-      a.commitMessage = changeDataFactory.create(db, change).commitMessage();
-    } catch (Exception e) {
-      log.error("Error while getting full commit message for change " + a.number);
-    }
-    a.url = getChangeUrl(change);
-    a.owner = asAccountAttribute(change.getOwner());
-    a.assignee = asAccountAttribute(change.getAssignee());
-    a.status = change.getStatus();
-    a.createdOn = change.getCreatedOn().getTime() / 1000L;
-    a.wip = change.isWorkInProgress() ? true : null;
-    a.isPrivate = change.isPrivate() ? true : null;
-    return a;
-  }
-
-  /**
-   * Create a RefUpdateAttribute for the given old ObjectId, new ObjectId, and branch that is
-   * suitable for serialization to JSON.
-   *
-   * @param oldId
-   * @param newId
-   * @param refName
-   * @return object suitable for serialization to JSON
-   */
-  public RefUpdateAttribute asRefUpdateAttribute(
-      ObjectId oldId, ObjectId newId, Branch.NameKey refName) {
-    RefUpdateAttribute ru = new RefUpdateAttribute();
-    ru.newRev = newId != null ? newId.getName() : ObjectId.zeroId().getName();
-    ru.oldRev = oldId != null ? oldId.getName() : ObjectId.zeroId().getName();
-    ru.project = refName.getParentKey().get();
-    ru.refName = refName.get();
-    return ru;
-  }
-
-  /**
-   * Extend the existing ChangeAttribute with additional fields.
-   *
-   * @param a
-   * @param change
-   */
-  public void extend(ChangeAttribute a, Change change) {
-    a.lastUpdated = change.getLastUpdatedOn().getTime() / 1000L;
-    a.open = change.getStatus().isOpen();
-  }
-
-  /**
-   * Add allReviewers to an existing ChangeAttribute.
-   *
-   * @param a
-   * @param notes
-   */
-  public void addAllReviewers(ReviewDb db, ChangeAttribute a, ChangeNotes notes)
-      throws OrmException {
-    Collection<Account.Id> reviewers = approvalsUtil.getReviewers(db, notes).all();
-    if (!reviewers.isEmpty()) {
-      a.allReviewers = Lists.newArrayListWithCapacity(reviewers.size());
-      for (Account.Id id : reviewers) {
-        a.allReviewers.add(asAccountAttribute(id));
-      }
-    }
-  }
-
-  /**
-   * Add submitRecords to an existing ChangeAttribute.
-   *
-   * @param ca
-   * @param submitRecords
-   */
-  public void addSubmitRecords(ChangeAttribute ca, List<SubmitRecord> submitRecords) {
-    ca.submitRecords = new ArrayList<>();
-
-    for (SubmitRecord submitRecord : submitRecords) {
-      SubmitRecordAttribute sa = new SubmitRecordAttribute();
-      sa.status = submitRecord.status.name();
-      if (submitRecord.status != SubmitRecord.Status.RULE_ERROR) {
-        addSubmitRecordLabels(submitRecord, sa);
-      }
-      ca.submitRecords.add(sa);
-    }
-    // Remove empty lists so a confusing label won't be displayed in the output.
-    if (ca.submitRecords.isEmpty()) {
-      ca.submitRecords = null;
-    }
-  }
-
-  private void addSubmitRecordLabels(SubmitRecord submitRecord, SubmitRecordAttribute sa) {
-    if (submitRecord.labels != null && !submitRecord.labels.isEmpty()) {
-      sa.labels = new ArrayList<>();
-      for (SubmitRecord.Label lbl : submitRecord.labels) {
-        SubmitLabelAttribute la = new SubmitLabelAttribute();
-        la.label = lbl.label;
-        la.status = lbl.status.name();
-        if (lbl.appliedBy != null) {
-          Account a = accountCache.get(lbl.appliedBy).getAccount();
-          la.by = asAccountAttribute(a);
-        }
-        sa.labels.add(la);
-      }
-    }
-  }
-
-  public void addDependencies(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs) {
-    if (change == null || currentPs == null) {
-      return;
-    }
-    ca.dependsOn = new ArrayList<>();
-    ca.neededBy = new ArrayList<>();
-    try {
-      addDependsOn(rw, ca, change, currentPs);
-      addNeededBy(rw, ca, change, currentPs);
-    } catch (OrmException | IOException e) {
-      // Squash DB exceptions and leave dependency lists partially filled.
-    }
-    // Remove empty lists so a confusing label won't be displayed in the output.
-    if (ca.dependsOn.isEmpty()) {
-      ca.dependsOn = null;
-    }
-    if (ca.neededBy.isEmpty()) {
-      ca.neededBy = null;
-    }
-  }
-
-  private void addDependsOn(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs)
-      throws OrmException, IOException {
-    RevCommit commit = rw.parseCommit(ObjectId.fromString(currentPs.getRevision().get()));
-    final List<String> parentNames = new ArrayList<>(commit.getParentCount());
-    for (RevCommit p : commit.getParents()) {
-      parentNames.add(p.name());
-    }
-
-    // Find changes in this project having a patch set matching any parent of
-    // this patch set's revision.
-    for (ChangeData cd : queryProvider.get().byProjectCommits(change.getProject(), parentNames)) {
-      for (PatchSet ps : cd.patchSets()) {
-        for (String p : parentNames) {
-          if (!ps.getRevision().get().equals(p)) {
-            continue;
-          }
-          ca.dependsOn.add(newDependsOn(checkNotNull(cd.change()), ps));
-        }
-      }
-    }
-    // Sort by original parent order.
-    Collections.sort(
-        ca.dependsOn,
-        comparing(
-            (DependencyAttribute d) -> {
-              for (int i = 0; i < parentNames.size(); i++) {
-                if (parentNames.get(i).equals(d.revision)) {
-                  return i;
-                }
-              }
-              return parentNames.size() + 1;
-            }));
-  }
-
-  private void addNeededBy(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs)
-      throws OrmException, IOException {
-    if (currentPs.getGroups().isEmpty()) {
-      return;
-    }
-    String rev = currentPs.getRevision().get();
-    // Find changes in the same related group as this patch set, having a patch
-    // set whose parent matches this patch set's revision.
-    for (ChangeData cd :
-        queryProvider.get().byProjectGroups(change.getProject(), currentPs.getGroups())) {
-      PATCH_SETS:
-      for (PatchSet ps : cd.patchSets()) {
-        RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
-        for (RevCommit p : commit.getParents()) {
-          if (!p.name().equals(rev)) {
-            continue;
-          }
-          ca.neededBy.add(newNeededBy(checkNotNull(cd.change()), ps));
-          continue PATCH_SETS;
-        }
-      }
-    }
-  }
-
-  private DependencyAttribute newDependsOn(Change c, PatchSet ps) {
-    DependencyAttribute d = newDependencyAttribute(c, ps);
-    d.isCurrentPatchSet = ps.getId().equals(c.currentPatchSetId());
-    return d;
-  }
-
-  private DependencyAttribute newNeededBy(Change c, PatchSet ps) {
-    return newDependencyAttribute(c, ps);
-  }
-
-  private DependencyAttribute newDependencyAttribute(Change c, PatchSet ps) {
-    DependencyAttribute d = new DependencyAttribute();
-    d.number = c.getId().get();
-    d.id = c.getKey().toString();
-    d.revision = ps.getRevision().get();
-    d.ref = ps.getRefName();
-    return d;
-  }
-
-  public void addTrackingIds(ChangeAttribute a, ListMultimap<String, String> set) {
-    if (!set.isEmpty()) {
-      a.trackingIds = new ArrayList<>(set.size());
-      for (Map.Entry<String, Collection<String>> e : set.asMap().entrySet()) {
-        for (String id : e.getValue()) {
-          TrackingIdAttribute t = new TrackingIdAttribute();
-          t.system = e.getKey();
-          t.id = id;
-          a.trackingIds.add(t);
-        }
-      }
-    }
-  }
-
-  public void addCommitMessage(ChangeAttribute a, String commitMessage) {
-    a.commitMessage = commitMessage;
-  }
-
-  public void addPatchSets(
-      ReviewDb db,
-      RevWalk revWalk,
-      ChangeAttribute ca,
-      Collection<PatchSet> ps,
-      Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
-      LabelTypes labelTypes) {
-    addPatchSets(db, revWalk, ca, ps, approvals, false, null, labelTypes);
-  }
-
-  public void addPatchSets(
-      ReviewDb db,
-      RevWalk revWalk,
-      ChangeAttribute ca,
-      Collection<PatchSet> ps,
-      Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
-      boolean includeFiles,
-      Change change,
-      LabelTypes labelTypes) {
-    if (!ps.isEmpty()) {
-      ca.patchSets = new ArrayList<>(ps.size());
-      for (PatchSet p : ps) {
-        PatchSetAttribute psa = asPatchSetAttribute(db, revWalk, change, p);
-        if (approvals != null) {
-          addApprovals(psa, p.getId(), approvals, labelTypes);
-        }
-        ca.patchSets.add(psa);
-        if (includeFiles) {
-          addPatchSetFileNames(psa, change, p);
-        }
-      }
-    }
-  }
-
-  public void addPatchSetComments(
-      PatchSetAttribute patchSetAttribute, Collection<Comment> comments) {
-    for (Comment comment : comments) {
-      if (comment.key.patchSetId == patchSetAttribute.number) {
-        if (patchSetAttribute.comments == null) {
-          patchSetAttribute.comments = new ArrayList<>();
-        }
-        patchSetAttribute.comments.add(asPatchSetLineAttribute(comment));
-      }
-    }
-  }
-
-  public void addPatchSetFileNames(
-      PatchSetAttribute patchSetAttribute, Change change, PatchSet patchSet) {
-    try {
-      PatchList patchList = patchListCache.get(change, patchSet);
-      for (PatchListEntry patch : patchList.getPatches()) {
-        if (patchSetAttribute.files == null) {
-          patchSetAttribute.files = new ArrayList<>();
-        }
-
-        PatchAttribute p = new PatchAttribute();
-        p.file = patch.getNewName();
-        p.fileOld = patch.getOldName();
-        p.type = patch.getChangeType();
-        p.deletions -= patch.getDeletions();
-        p.insertions = patch.getInsertions();
-        patchSetAttribute.files.add(p);
-      }
-    } catch (PatchListNotAvailableException e) {
-      log.warn("Cannot get patch list", e);
-    }
-  }
-
-  public void addComments(ChangeAttribute ca, Collection<ChangeMessage> messages) {
-    if (!messages.isEmpty()) {
-      ca.comments = new ArrayList<>();
-      for (ChangeMessage message : messages) {
-        ca.comments.add(asMessageAttribute(message));
-      }
-    }
-  }
-
-  /**
-   * Create a PatchSetAttribute for the given patchset suitable for serialization to JSON.
-   *
-   * @param revWalk
-   * @param patchSet
-   * @return object suitable for serialization to JSON
-   */
-  public PatchSetAttribute asPatchSetAttribute(RevWalk revWalk, Change change, PatchSet patchSet) {
-    try (ReviewDb db = schema.open()) {
-      return asPatchSetAttribute(db, revWalk, change, patchSet);
-    } catch (OrmException e) {
-      log.error("Cannot open database connection", e);
-      return new PatchSetAttribute();
-    }
-  }
-
-  /**
-   * Create a PatchSetAttribute for the given patchset suitable for serialization to JSON.
-   *
-   * @param db Review database
-   * @param patchSet
-   * @return object suitable for serialization to JSON
-   */
-  public PatchSetAttribute asPatchSetAttribute(
-      ReviewDb db, RevWalk revWalk, Change change, PatchSet patchSet) {
-    PatchSetAttribute p = new PatchSetAttribute();
-    p.revision = patchSet.getRevision().get();
-    p.number = patchSet.getPatchSetId();
-    p.ref = patchSet.getRefName();
-    p.uploader = asAccountAttribute(patchSet.getUploader());
-    p.createdOn = patchSet.getCreatedOn().getTime() / 1000L;
-    PatchSet.Id pId = patchSet.getId();
-    try {
-      p.parents = new ArrayList<>();
-      RevCommit c = revWalk.parseCommit(ObjectId.fromString(p.revision));
-      for (RevCommit parent : c.getParents()) {
-        p.parents.add(parent.name());
-      }
-
-      UserIdentity author = toUserIdentity(c.getAuthorIdent());
-      if (author.getAccount() == null) {
-        p.author = new AccountAttribute();
-        p.author.email = author.getEmail();
-        p.author.name = author.getName();
-        p.author.username = "";
-      } else {
-        p.author = asAccountAttribute(author.getAccount());
-      }
-
-      List<Patch> list = patchListCache.get(change, patchSet).toPatchList(pId);
-      for (Patch pe : list) {
-        if (!Patch.isMagic(pe.getFileName())) {
-          p.sizeDeletions -= pe.getDeletions();
-          p.sizeInsertions += pe.getInsertions();
-        }
-      }
-      p.kind = changeKindCache.getChangeKind(db, change, patchSet);
-    } catch (IOException | OrmException e) {
-      log.error("Cannot load patch set data for " + patchSet.getId(), e);
-    } catch (PatchListNotAvailableException e) {
-      log.error(String.format("Cannot get size information for %s.", pId), e);
-    }
-    return p;
-  }
-
-  // TODO: The same method exists in PatchSetInfoFactory, find a common place
-  // for it
-  private UserIdentity toUserIdentity(PersonIdent who) throws IOException, OrmException {
-    UserIdentity u = new UserIdentity();
-    u.setName(who.getName());
-    u.setEmail(who.getEmailAddress());
-    u.setDate(new Timestamp(who.getWhen().getTime()));
-    u.setTimeZone(who.getTimeZoneOffset());
-
-    // If only one account has access to this email address, select it
-    // as the identity of the user.
-    //
-    Set<Account.Id> a = emails.getAccountFor(u.getEmail());
-    if (a.size() == 1) {
-      u.setAccount(a.iterator().next());
-    }
-
-    return u;
-  }
-
-  public void addApprovals(
-      PatchSetAttribute p,
-      PatchSet.Id id,
-      Map<PatchSet.Id, Collection<PatchSetApproval>> all,
-      LabelTypes labelTypes) {
-    Collection<PatchSetApproval> list = all.get(id);
-    if (list != null) {
-      addApprovals(p, list, labelTypes);
-    }
-  }
-
-  public void addApprovals(
-      PatchSetAttribute p, Collection<PatchSetApproval> list, LabelTypes labelTypes) {
-    if (!list.isEmpty()) {
-      p.approvals = new ArrayList<>(list.size());
-      for (PatchSetApproval a : list) {
-        if (a.getValue() != 0) {
-          p.approvals.add(asApprovalAttribute(a, labelTypes));
-        }
-      }
-      if (p.approvals.isEmpty()) {
-        p.approvals = null;
-      }
-    }
-  }
-
-  /**
-   * Create an AuthorAttribute for the given account suitable for serialization to JSON.
-   *
-   * @param id
-   * @return object suitable for serialization to JSON
-   */
-  public AccountAttribute asAccountAttribute(Account.Id id) {
-    if (id == null) {
-      return null;
-    }
-    return asAccountAttribute(accountCache.get(id).getAccount());
-  }
-
-  /**
-   * Create an AuthorAttribute for the given account suitable for serialization to JSON.
-   *
-   * @param account
-   * @return object suitable for serialization to JSON
-   */
-  public AccountAttribute asAccountAttribute(Account account) {
-    if (account == null) {
-      return null;
-    }
-
-    AccountAttribute who = new AccountAttribute();
-    who.name = account.getFullName();
-    who.email = account.getPreferredEmail();
-    who.username = account.getUserName();
-    return who;
-  }
-
-  /**
-   * Create an AuthorAttribute for the given person ident suitable for serialization to JSON.
-   *
-   * @param ident
-   * @return object suitable for serialization to JSON
-   */
-  public AccountAttribute asAccountAttribute(PersonIdent ident) {
-    AccountAttribute who = new AccountAttribute();
-    who.name = ident.getName();
-    who.email = ident.getEmailAddress();
-    return who;
-  }
-
-  /**
-   * Create an ApprovalAttribute for the given approval suitable for serialization to JSON.
-   *
-   * @param approval
-   * @param labelTypes label types for the containing project
-   * @return object suitable for serialization to JSON
-   */
-  public ApprovalAttribute asApprovalAttribute(PatchSetApproval approval, LabelTypes labelTypes) {
-    ApprovalAttribute a = new ApprovalAttribute();
-    a.type = approval.getLabelId().get();
-    a.value = Short.toString(approval.getValue());
-    a.by = asAccountAttribute(approval.getAccountId());
-    a.grantedOn = approval.getGranted().getTime() / 1000L;
-    a.oldValue = null;
-
-    LabelType lt = labelTypes.byLabel(approval.getLabelId());
-    if (lt != null) {
-      a.description = lt.getName();
-    }
-    return a;
-  }
-
-  public MessageAttribute asMessageAttribute(ChangeMessage message) {
-    MessageAttribute a = new MessageAttribute();
-    a.timestamp = message.getWrittenOn().getTime() / 1000L;
-    a.reviewer =
-        message.getAuthor() != null
-            ? asAccountAttribute(message.getAuthor())
-            : asAccountAttribute(myIdent);
-    a.message = message.getMessage();
-    return a;
-  }
-
-  public PatchSetCommentAttribute asPatchSetLineAttribute(Comment c) {
-    PatchSetCommentAttribute a = new PatchSetCommentAttribute();
-    a.reviewer = asAccountAttribute(c.author.getId());
-    a.file = c.key.filename;
-    a.line = c.lineNbr;
-    a.message = c.message;
-    return a;
-  }
-
-  /** Get a link to the change; null if the server doesn't know its own address. */
-  private String getChangeUrl(Change change) {
-    if (change != null && urlProvider.get() != null) {
-      StringBuilder r = new StringBuilder();
-      r.append(urlProvider.get());
-      r.append(change.getChangeId());
-      return r.toString();
-    }
-    return null;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventsMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventsMetrics.java
deleted file mode 100644
index fcf4a08..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventsMetrics.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.events;
-
-import com.google.gerrit.common.EventListener;
-import com.google.gerrit.metrics.Counter1;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class EventsMetrics implements EventListener {
-  private final Counter1<String> events;
-
-  @Inject
-  public EventsMetrics(MetricMaker metricMaker) {
-    events =
-        metricMaker.newCounter(
-            "events",
-            new Description("Triggered events").setRate().setUnit("triggered events"),
-            Field.ofString("type"));
-  }
-
-  @Override
-  public void onEvent(com.google.gerrit.server.events.Event event) {
-    events.increment(event.getType());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
deleted file mode 100644
index 6b0190d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ /dev/null
@@ -1,522 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.events;
-
-import com.google.common.base.Supplier;
-import com.google.common.base.Suppliers;
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.EventDispatcher;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.events.AssigneeChangedListener;
-import com.google.gerrit.extensions.events.ChangeAbandonedListener;
-import com.google.gerrit.extensions.events.ChangeMergedListener;
-import com.google.gerrit.extensions.events.ChangeRestoredListener;
-import com.google.gerrit.extensions.events.CommentAddedListener;
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.extensions.events.HashtagsEditedListener;
-import com.google.gerrit.extensions.events.NewProjectCreatedListener;
-import com.google.gerrit.extensions.events.PrivateStateChangedListener;
-import com.google.gerrit.extensions.events.ReviewerAddedListener;
-import com.google.gerrit.extensions.events.ReviewerDeletedListener;
-import com.google.gerrit.extensions.events.RevisionCreatedListener;
-import com.google.gerrit.extensions.events.TopicEditedListener;
-import com.google.gerrit.extensions.events.VoteDeletedListener;
-import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.data.AccountAttribute;
-import com.google.gerrit.server.data.ApprovalAttribute;
-import com.google.gerrit.server.data.ChangeAttribute;
-import com.google.gerrit.server.data.PatchSetAttribute;
-import com.google.gerrit.server.data.RefUpdateAttribute;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.AbstractModule;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Map.Entry;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class StreamEventsApiListener
-    implements AssigneeChangedListener,
-        ChangeAbandonedListener,
-        ChangeMergedListener,
-        ChangeRestoredListener,
-        WorkInProgressStateChangedListener,
-        PrivateStateChangedListener,
-        CommentAddedListener,
-        GitReferenceUpdatedListener,
-        HashtagsEditedListener,
-        NewProjectCreatedListener,
-        ReviewerAddedListener,
-        ReviewerDeletedListener,
-        RevisionCreatedListener,
-        TopicEditedListener,
-        VoteDeletedListener {
-  private static final Logger log = LoggerFactory.getLogger(StreamEventsApiListener.class);
-
-  public static class Module extends AbstractModule {
-    @Override
-    protected void configure() {
-      DynamicSet.bind(binder(), AssigneeChangedListener.class).to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), ChangeAbandonedListener.class).to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), ChangeMergedListener.class).to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), ChangeRestoredListener.class).to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), CommentAddedListener.class).to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
-          .to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), HashtagsEditedListener.class).to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), NewProjectCreatedListener.class).to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), PrivateStateChangedListener.class)
-          .to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), ReviewerAddedListener.class).to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), ReviewerDeletedListener.class).to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), RevisionCreatedListener.class).to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), TopicEditedListener.class).to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), VoteDeletedListener.class).to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), WorkInProgressStateChangedListener.class)
-          .to(StreamEventsApiListener.class);
-    }
-  }
-
-  private final DynamicItem<EventDispatcher> dispatcher;
-  private final Provider<ReviewDb> db;
-  private final EventFactory eventFactory;
-  private final ProjectCache projectCache;
-  private final GitRepositoryManager repoManager;
-  private final PatchSetUtil psUtil;
-  private final ChangeNotes.Factory changeNotesFactory;
-
-  @Inject
-  StreamEventsApiListener(
-      DynamicItem<EventDispatcher> dispatcher,
-      Provider<ReviewDb> db,
-      EventFactory eventFactory,
-      ProjectCache projectCache,
-      GitRepositoryManager repoManager,
-      PatchSetUtil psUtil,
-      ChangeNotes.Factory changeNotesFactory) {
-    this.dispatcher = dispatcher;
-    this.db = db;
-    this.eventFactory = eventFactory;
-    this.projectCache = projectCache;
-    this.repoManager = repoManager;
-    this.psUtil = psUtil;
-    this.changeNotesFactory = changeNotesFactory;
-  }
-
-  private ChangeNotes getNotes(ChangeInfo info) throws OrmException {
-    try {
-      return changeNotesFactory.createChecked(new Change.Id(info._number));
-    } catch (NoSuchChangeException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  private Change getChange(ChangeInfo info) throws OrmException {
-    return getNotes(info).getChange();
-  }
-
-  private PatchSet getPatchSet(ChangeNotes notes, RevisionInfo info) throws OrmException {
-    return psUtil.get(db.get(), notes, PatchSet.Id.fromRef(info.ref));
-  }
-
-  private Supplier<ChangeAttribute> changeAttributeSupplier(Change change) {
-    return Suppliers.memoize(
-        new Supplier<ChangeAttribute>() {
-          @Override
-          public ChangeAttribute get() {
-            return eventFactory.asChangeAttribute(change);
-          }
-        });
-  }
-
-  private Supplier<AccountAttribute> accountAttributeSupplier(AccountInfo account) {
-    return Suppliers.memoize(
-        new Supplier<AccountAttribute>() {
-          @Override
-          public AccountAttribute get() {
-            return account != null
-                ? eventFactory.asAccountAttribute(new Account.Id(account._accountId))
-                : null;
-          }
-        });
-  }
-
-  private Supplier<PatchSetAttribute> patchSetAttributeSupplier(
-      final Change change, PatchSet patchSet) {
-    return Suppliers.memoize(
-        new Supplier<PatchSetAttribute>() {
-          @Override
-          public PatchSetAttribute get() {
-            try (Repository repo = repoManager.openRepository(change.getProject());
-                RevWalk revWalk = new RevWalk(repo)) {
-              return eventFactory.asPatchSetAttribute(revWalk, change, patchSet);
-            } catch (IOException e) {
-              throw new RuntimeException(e);
-            }
-          }
-        });
-  }
-
-  private static Map<String, Short> convertApprovalsMap(Map<String, ApprovalInfo> approvals) {
-    Map<String, Short> result = new HashMap<>();
-    for (Entry<String, ApprovalInfo> e : approvals.entrySet()) {
-      Short value = e.getValue().value == null ? null : e.getValue().value.shortValue();
-      result.put(e.getKey(), value);
-    }
-    return result;
-  }
-
-  private ApprovalAttribute getApprovalAttribute(
-      LabelTypes labelTypes, Entry<String, Short> approval, Map<String, Short> oldApprovals) {
-    ApprovalAttribute a = new ApprovalAttribute();
-    a.type = approval.getKey();
-
-    if (oldApprovals != null && !oldApprovals.isEmpty()) {
-      if (oldApprovals.get(approval.getKey()) != null) {
-        a.oldValue = Short.toString(oldApprovals.get(approval.getKey()));
-      }
-    }
-    LabelType lt = labelTypes.byLabel(approval.getKey());
-    if (lt != null) {
-      a.description = lt.getName();
-    }
-    if (approval.getValue() != null) {
-      a.value = Short.toString(approval.getValue());
-    }
-    return a;
-  }
-
-  private Supplier<ApprovalAttribute[]> approvalsAttributeSupplier(
-      final Change change,
-      Map<String, ApprovalInfo> newApprovals,
-      final Map<String, ApprovalInfo> oldApprovals) {
-    final Map<String, Short> approvals = convertApprovalsMap(newApprovals);
-    return Suppliers.memoize(
-        new Supplier<ApprovalAttribute[]>() {
-          @Override
-          public ApprovalAttribute[] get() {
-            LabelTypes labelTypes = projectCache.get(change.getProject()).getLabelTypes();
-            if (approvals.size() > 0) {
-              ApprovalAttribute[] r = new ApprovalAttribute[approvals.size()];
-              int i = 0;
-              for (Map.Entry<String, Short> approval : approvals.entrySet()) {
-                r[i++] =
-                    getApprovalAttribute(labelTypes, approval, convertApprovalsMap(oldApprovals));
-              }
-              return r;
-            }
-            return null;
-          }
-        });
-  }
-
-  String[] hashtagArray(Collection<String> hashtags) {
-    if (hashtags != null && hashtags.size() > 0) {
-      return Sets.newHashSet(hashtags).toArray(new String[hashtags.size()]);
-    }
-    return null;
-  }
-
-  @Override
-  public void onAssigneeChanged(AssigneeChangedListener.Event ev) {
-    try {
-      Change change = getChange(ev.getChange());
-      AssigneeChangedEvent event = new AssigneeChangedEvent(change);
-
-      event.change = changeAttributeSupplier(change);
-      event.changer = accountAttributeSupplier(ev.getWho());
-      event.oldAssignee = accountAttributeSupplier(ev.getOldAssignee());
-
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("Failed to dispatch event", e);
-    }
-  }
-
-  @Override
-  public void onTopicEdited(TopicEditedListener.Event ev) {
-    try {
-      Change change = getChange(ev.getChange());
-      TopicChangedEvent event = new TopicChangedEvent(change);
-
-      event.change = changeAttributeSupplier(change);
-      event.changer = accountAttributeSupplier(ev.getWho());
-      event.oldTopic = ev.getOldTopic();
-
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("Failed to dispatch event", e);
-    }
-  }
-
-  @Override
-  public void onRevisionCreated(RevisionCreatedListener.Event ev) {
-    try {
-      ChangeNotes notes = getNotes(ev.getChange());
-      Change change = notes.getChange();
-      PatchSet patchSet = getPatchSet(notes, ev.getRevision());
-      PatchSetCreatedEvent event = new PatchSetCreatedEvent(change);
-
-      event.change = changeAttributeSupplier(change);
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
-      event.uploader = accountAttributeSupplier(ev.getWho());
-
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("Failed to dispatch event", e);
-    }
-  }
-
-  @Override
-  public void onReviewerDeleted(ReviewerDeletedListener.Event ev) {
-    try {
-      ChangeNotes notes = getNotes(ev.getChange());
-      Change change = notes.getChange();
-      ReviewerDeletedEvent event = new ReviewerDeletedEvent(change);
-      event.change = changeAttributeSupplier(change);
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
-      event.reviewer = accountAttributeSupplier(ev.getReviewer());
-      event.remover = accountAttributeSupplier(ev.getWho());
-      event.comment = ev.getComment();
-      event.approvals =
-          approvalsAttributeSupplier(change, ev.getNewApprovals(), ev.getOldApprovals());
-
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("Failed to dispatch event", e);
-    }
-  }
-
-  @Override
-  public void onReviewersAdded(ReviewerAddedListener.Event ev) {
-    try {
-      ChangeNotes notes = getNotes(ev.getChange());
-      Change change = notes.getChange();
-      ReviewerAddedEvent event = new ReviewerAddedEvent(change);
-
-      event.change = changeAttributeSupplier(change);
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
-      for (AccountInfo reviewer : ev.getReviewers()) {
-        event.reviewer = accountAttributeSupplier(reviewer);
-        dispatcher.get().postEvent(change, event);
-      }
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("Failed to dispatch event", e);
-    }
-  }
-
-  @Override
-  public void onNewProjectCreated(NewProjectCreatedListener.Event ev) {
-    ProjectCreatedEvent event = new ProjectCreatedEvent();
-    event.projectName = ev.getProjectName();
-    event.headName = ev.getHeadName();
-
-    dispatcher.get().postEvent(event.getProjectNameKey(), event);
-  }
-
-  @Override
-  public void onHashtagsEdited(HashtagsEditedListener.Event ev) {
-    try {
-      Change change = getChange(ev.getChange());
-      HashtagsChangedEvent event = new HashtagsChangedEvent(change);
-
-      event.change = changeAttributeSupplier(change);
-      event.editor = accountAttributeSupplier(ev.getWho());
-      event.hashtags = hashtagArray(ev.getHashtags());
-      event.added = hashtagArray(ev.getAddedHashtags());
-      event.removed = hashtagArray(ev.getRemovedHashtags());
-
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("Failed to dispatch event", e);
-    }
-  }
-
-  @Override
-  public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event ev) {
-    RefUpdatedEvent event = new RefUpdatedEvent();
-    if (ev.getUpdater() != null) {
-      event.submitter = accountAttributeSupplier(ev.getUpdater());
-    }
-    final Branch.NameKey refName = new Branch.NameKey(ev.getProjectName(), ev.getRefName());
-    event.refUpdate =
-        Suppliers.memoize(
-            new Supplier<RefUpdateAttribute>() {
-              @Override
-              public RefUpdateAttribute get() {
-                return eventFactory.asRefUpdateAttribute(
-                    ObjectId.fromString(ev.getOldObjectId()),
-                    ObjectId.fromString(ev.getNewObjectId()),
-                    refName);
-              }
-            });
-    try {
-      dispatcher.get().postEvent(refName, event);
-    } catch (PermissionBackendException e) {
-      log.error("error while posting event", e);
-    }
-  }
-
-  @Override
-  public void onCommentAdded(CommentAddedListener.Event ev) {
-    try {
-      ChangeNotes notes = getNotes(ev.getChange());
-      Change change = notes.getChange();
-      PatchSet ps = getPatchSet(notes, ev.getRevision());
-      CommentAddedEvent event = new CommentAddedEvent(change);
-
-      event.change = changeAttributeSupplier(change);
-      event.author = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, ps);
-      event.comment = ev.getComment();
-      event.approvals = approvalsAttributeSupplier(change, ev.getApprovals(), ev.getOldApprovals());
-
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("Failed to dispatch event", e);
-    }
-  }
-
-  @Override
-  public void onChangeRestored(ChangeRestoredListener.Event ev) {
-    try {
-      ChangeNotes notes = getNotes(ev.getChange());
-      Change change = notes.getChange();
-      ChangeRestoredEvent event = new ChangeRestoredEvent(change);
-
-      event.change = changeAttributeSupplier(change);
-      event.restorer = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
-      event.reason = ev.getReason();
-
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("Failed to dispatch event", e);
-    }
-  }
-
-  @Override
-  public void onChangeMerged(ChangeMergedListener.Event ev) {
-    try {
-      ChangeNotes notes = getNotes(ev.getChange());
-      Change change = notes.getChange();
-      ChangeMergedEvent event = new ChangeMergedEvent(change);
-
-      event.change = changeAttributeSupplier(change);
-      event.submitter = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
-      event.newRev = ev.getNewRevisionId();
-
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("Failed to dispatch event", e);
-    }
-  }
-
-  @Override
-  public void onChangeAbandoned(ChangeAbandonedListener.Event ev) {
-    try {
-      ChangeNotes notes = getNotes(ev.getChange());
-      Change change = notes.getChange();
-      ChangeAbandonedEvent event = new ChangeAbandonedEvent(change);
-
-      event.change = changeAttributeSupplier(change);
-      event.abandoner = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
-      event.reason = ev.getReason();
-
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("Failed to dispatch event", e);
-    }
-  }
-
-  @Override
-  public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event ev) {
-    try {
-      Change change = getChange(ev.getChange());
-      WorkInProgressStateChangedEvent event = new WorkInProgressStateChangedEvent(change);
-
-      event.change = changeAttributeSupplier(change);
-      event.changer = accountAttributeSupplier(ev.getWho());
-
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("Failed to dispatch event", e);
-    }
-  }
-
-  @Override
-  public void onPrivateStateChanged(PrivateStateChangedListener.Event ev) {
-    try {
-      Change change = getChange(ev.getChange());
-      PrivateStateChangedEvent event = new PrivateStateChangedEvent(change);
-
-      event.change = changeAttributeSupplier(change);
-      event.changer = accountAttributeSupplier(ev.getWho());
-
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("Failed to dispatch event", e);
-    }
-  }
-
-  @Override
-  public void onVoteDeleted(VoteDeletedListener.Event ev) {
-    try {
-      ChangeNotes notes = getNotes(ev.getChange());
-      Change change = notes.getChange();
-      VoteDeletedEvent event = new VoteDeletedEvent(change);
-
-      event.change = changeAttributeSupplier(change);
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
-      event.comment = ev.getMessage();
-      event.reviewer = accountAttributeSupplier(ev.getReviewer());
-      event.remover = accountAttributeSupplier(ev.getWho());
-      event.approvals = approvalsAttributeSupplier(change, ev.getApprovals(), ev.getOldApprovals());
-
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("Failed to dispatch event", 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
deleted file mode 100644
index f9fc60a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ /dev/null
@@ -1,98 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.extensions.events;
-
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.events.ChangeAbandonedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ChangeAbandoned {
-  private static final Logger log = LoggerFactory.getLogger(ChangeAbandoned.class);
-
-  private final DynamicSet<ChangeAbandonedListener> listeners;
-  private final EventUtil util;
-
-  @Inject
-  ChangeAbandoned(DynamicSet<ChangeAbandonedListener> listeners, EventUtil util) {
-    this.listeners = listeners;
-    this.util = util;
-  }
-
-  public void fire(
-      Change change,
-      PatchSet ps,
-      Account abandoner,
-      String reason,
-      Timestamp when,
-      NotifyHandling notifyHandling) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    try {
-      Event event =
-          new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
-              util.accountInfo(abandoner),
-              reason,
-              when,
-              notifyHandling);
-      for (ChangeAbandonedListener l : listeners) {
-        try {
-          l.onChangeAbandoned(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
-      log.error("Couldn't fire event", e);
-    }
-  }
-
-  private static class Event extends AbstractRevisionEvent
-      implements ChangeAbandonedListener.Event {
-    private final String reason;
-
-    Event(
-        ChangeInfo change,
-        RevisionInfo revision,
-        AccountInfo abandoner,
-        String reason,
-        Timestamp when,
-        NotifyHandling notifyHandling) {
-      super(change, revision, abandoner, when, notifyHandling);
-      this.reason = reason;
-    }
-
-    @Override
-    public String getReason() {
-      return reason;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
deleted file mode 100644
index feaa54a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ /dev/null
@@ -1,90 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.extensions.events;
-
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.events.ChangeMergedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ChangeMerged {
-  private static final Logger log = LoggerFactory.getLogger(ChangeMerged.class);
-
-  private final DynamicSet<ChangeMergedListener> listeners;
-  private final EventUtil util;
-
-  @Inject
-  ChangeMerged(DynamicSet<ChangeMergedListener> listeners, EventUtil util) {
-    this.listeners = listeners;
-    this.util = util;
-  }
-
-  public void fire(
-      Change change, PatchSet ps, Account merger, String newRevisionId, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    try {
-      Event event =
-          new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
-              util.accountInfo(merger),
-              newRevisionId,
-              when);
-      for (ChangeMergedListener l : listeners) {
-        try {
-          l.onChangeMerged(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
-      log.error("Couldn't fire event", e);
-    }
-  }
-
-  private static class Event extends AbstractRevisionEvent implements ChangeMergedListener.Event {
-    private final String newRevisionId;
-
-    Event(
-        ChangeInfo change,
-        RevisionInfo revision,
-        AccountInfo merger,
-        String newRevisionId,
-        Timestamp when) {
-      super(change, revision, merger, when, NotifyHandling.ALL);
-      this.newRevisionId = newRevisionId;
-    }
-
-    @Override
-    public String getNewRevisionId() {
-      return newRevisionId;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
deleted file mode 100644
index 03a6f1f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ /dev/null
@@ -1,90 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.extensions.events;
-
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.events.ChangeRestoredListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ChangeRestored {
-  private static final Logger log = LoggerFactory.getLogger(ChangeRestored.class);
-
-  private final DynamicSet<ChangeRestoredListener> listeners;
-  private final EventUtil util;
-
-  @Inject
-  ChangeRestored(DynamicSet<ChangeRestoredListener> listeners, EventUtil util) {
-    this.listeners = listeners;
-    this.util = util;
-  }
-
-  public void fire(Change change, PatchSet ps, Account restorer, String reason, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    try {
-      Event event =
-          new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
-              util.accountInfo(restorer),
-              reason,
-              when);
-      for (ChangeRestoredListener l : listeners) {
-        try {
-          l.onChangeRestored(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
-      log.error("Couldn't fire event", e);
-    }
-  }
-
-  private static class Event extends AbstractRevisionEvent implements ChangeRestoredListener.Event {
-
-    private String reason;
-
-    Event(
-        ChangeInfo change,
-        RevisionInfo revision,
-        AccountInfo restorer,
-        String reason,
-        Timestamp when) {
-      super(change, revision, restorer, when, NotifyHandling.ALL);
-      this.reason = reason;
-    }
-
-    @Override
-    public String getReason() {
-      return reason;
-    }
-  }
-}
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
deleted file mode 100644
index e76a032..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ /dev/null
@@ -1,117 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.extensions.events;
-
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.events.CommentAddedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.Map;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class CommentAdded {
-  private static final Logger log = LoggerFactory.getLogger(CommentAdded.class);
-
-  private final DynamicSet<CommentAddedListener> listeners;
-  private final EventUtil util;
-
-  @Inject
-  CommentAdded(DynamicSet<CommentAddedListener> listeners, EventUtil util) {
-    this.listeners = listeners;
-    this.util = util;
-  }
-
-  public void fire(
-      Change change,
-      PatchSet ps,
-      Account author,
-      String comment,
-      Map<String, Short> approvals,
-      Map<String, Short> oldApprovals,
-      Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    try {
-      Event event =
-          new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
-              util.accountInfo(author),
-              comment,
-              util.approvals(author, approvals, when),
-              util.approvals(author, oldApprovals, when),
-              when);
-      for (CommentAddedListener l : listeners) {
-        try {
-          l.onCommentAdded(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
-      log.error("Couldn't fire event", e);
-    }
-  }
-
-  private static class Event extends AbstractRevisionEvent implements CommentAddedListener.Event {
-
-    private final String comment;
-    private final Map<String, ApprovalInfo> approvals;
-    private final Map<String, ApprovalInfo> oldApprovals;
-
-    Event(
-        ChangeInfo change,
-        RevisionInfo revision,
-        AccountInfo author,
-        String comment,
-        Map<String, ApprovalInfo> approvals,
-        Map<String, ApprovalInfo> oldApprovals,
-        Timestamp when) {
-      super(change, revision, author, when, NotifyHandling.ALL);
-      this.comment = comment;
-      this.approvals = approvals;
-      this.oldApprovals = oldApprovals;
-    }
-
-    @Override
-    public String getComment() {
-      return comment;
-    }
-
-    @Override
-    public Map<String, ApprovalInfo> getApprovals() {
-      return approvals;
-    }
-
-    @Override
-    public Map<String, ApprovalInfo> getOldApprovals() {
-      return oldApprovals;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
deleted file mode 100644
index be308a9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ /dev/null
@@ -1,137 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.extensions.events;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.Map;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class EventUtil {
-  private static final Logger log = LoggerFactory.getLogger(EventUtil.class);
-
-  private static final ImmutableSet<ListChangesOption> CHANGE_OPTIONS;
-
-  static {
-    EnumSet<ListChangesOption> opts = EnumSet.allOf(ListChangesOption.class);
-
-    // Some options, like actions, are expensive to compute because they potentially have to walk
-    // lots of history and inspect lots of other changes.
-    opts.remove(ListChangesOption.CHANGE_ACTIONS);
-    opts.remove(ListChangesOption.CURRENT_ACTIONS);
-
-    // CHECK suppresses some exceptions on corrupt changes, which is not appropriate for passing
-    // through the event system as we would rather let them propagate.
-    opts.remove(ListChangesOption.CHECK);
-
-    CHANGE_OPTIONS = Sets.immutableEnumSet(opts);
-  }
-
-  private final ChangeData.Factory changeDataFactory;
-  private final Provider<ReviewDb> db;
-  private final ChangeJson changeJson;
-
-  @Inject
-  EventUtil(
-      ChangeJson.Factory changeJsonFactory,
-      ChangeData.Factory changeDataFactory,
-      Provider<ReviewDb> db) {
-    this.changeDataFactory = changeDataFactory;
-    this.db = db;
-    this.changeJson = changeJsonFactory.create(CHANGE_OPTIONS);
-  }
-
-  public ChangeInfo changeInfo(Change change) throws OrmException {
-    return changeJson.format(change);
-  }
-
-  public RevisionInfo revisionInfo(Project project, PatchSet ps)
-      throws OrmException, PatchListNotAvailableException, GpgException, IOException {
-    return revisionInfo(project.getNameKey(), ps);
-  }
-
-  public RevisionInfo revisionInfo(Project.NameKey project, PatchSet ps)
-      throws OrmException, PatchListNotAvailableException, GpgException, IOException {
-    ChangeData cd = changeDataFactory.create(db.get(), project, ps.getId().getParentKey());
-    return changeJson.getRevisionInfo(cd, ps);
-  }
-
-  public AccountInfo accountInfo(Account a) {
-    if (a == null || a.getId() == null) {
-      return null;
-    }
-    AccountInfo accountInfo = new AccountInfo(a.getId().get());
-    accountInfo.email = a.getPreferredEmail();
-    accountInfo.name = a.getFullName();
-    accountInfo.username = a.getUserName();
-    return accountInfo;
-  }
-
-  public Map<String, ApprovalInfo> approvals(
-      Account a, Map<String, Short> approvals, Timestamp ts) {
-    Map<String, ApprovalInfo> result = new HashMap<>();
-    for (Map.Entry<String, Short> e : approvals.entrySet()) {
-      Integer value = e.getValue() != null ? Integer.valueOf(e.getValue()) : null;
-      result.put(e.getKey(), ChangeJson.getApprovalInfo(a.getId(), value, null, null, ts));
-    }
-    return result;
-  }
-
-  public void logEventListenerError(Object event, Object listener, Exception error) {
-    if (log.isDebugEnabled()) {
-      log.debug(
-          String.format(
-              "Error in event listener %s for event %s",
-              listener.getClass().getName(), event.getClass().getName()),
-          error);
-    } else {
-      log.warn(
-          "Error in listener {} for event {}: {}",
-          listener.getClass().getName(),
-          event.getClass().getName(),
-          error.getMessage());
-    }
-  }
-
-  public static void logEventListenerError(Object listener, Exception error) {
-    if (log.isDebugEnabled()) {
-      log.debug(String.format("Error in event listener %s", listener.getClass().getName()), error);
-    } else {
-      log.warn("Error in listener {}: {}", listener.getClass().getName(), error.getMessage());
-    }
-  }
-}
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
deleted file mode 100644
index e4f8572..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.extensions.events;
-
-import com.google.common.collect.Lists;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.events.ReviewerAddedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.List;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ReviewerAdded {
-  private static final Logger log = LoggerFactory.getLogger(ReviewerAdded.class);
-
-  private final DynamicSet<ReviewerAddedListener> listeners;
-  private final EventUtil util;
-
-  @Inject
-  ReviewerAdded(DynamicSet<ReviewerAddedListener> listeners, EventUtil util) {
-    this.listeners = listeners;
-    this.util = util;
-  }
-
-  public void fire(
-      Change change, PatchSet patchSet, List<Account> reviewers, Account adder, Timestamp when) {
-    if (!listeners.iterator().hasNext() || reviewers.isEmpty()) {
-      return;
-    }
-
-    try {
-      Event event =
-          new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
-              Lists.transform(reviewers, util::accountInfo),
-              util.accountInfo(adder),
-              when);
-      for (ReviewerAddedListener l : listeners) {
-        try {
-          l.onReviewersAdded(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
-      log.error("Couldn't fire event", e);
-    }
-  }
-
-  private static class Event extends AbstractRevisionEvent implements ReviewerAddedListener.Event {
-    private final List<AccountInfo> reviewers;
-
-    Event(
-        ChangeInfo change,
-        RevisionInfo revision,
-        List<AccountInfo> reviewers,
-        AccountInfo adder,
-        Timestamp when) {
-      super(change, revision, adder, when, NotifyHandling.ALL);
-      this.reviewers = reviewers;
-    }
-
-    @Override
-    public List<AccountInfo> getReviewers() {
-      return reviewers;
-    }
-  }
-}
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
deleted file mode 100644
index 033efe2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ /dev/null
@@ -1,130 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.extensions.events;
-
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.events.ReviewerDeletedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.Map;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ReviewerDeleted {
-  private static final Logger log = LoggerFactory.getLogger(ReviewerDeleted.class);
-
-  private final DynamicSet<ReviewerDeletedListener> listeners;
-  private final EventUtil util;
-
-  @Inject
-  ReviewerDeleted(DynamicSet<ReviewerDeletedListener> listeners, EventUtil util) {
-    this.listeners = listeners;
-    this.util = util;
-  }
-
-  public void fire(
-      Change change,
-      PatchSet patchSet,
-      Account reviewer,
-      Account remover,
-      String message,
-      Map<String, Short> newApprovals,
-      Map<String, Short> oldApprovals,
-      NotifyHandling notify,
-      Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    try {
-      Event event =
-          new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
-              util.accountInfo(reviewer),
-              util.accountInfo(remover),
-              message,
-              util.approvals(reviewer, newApprovals, when),
-              util.approvals(reviewer, oldApprovals, when),
-              notify,
-              when);
-      for (ReviewerDeletedListener listener : listeners) {
-        try {
-          listener.onReviewerDeleted(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, listener, e);
-        }
-      }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
-      log.error("Couldn't fire event", e);
-    }
-  }
-
-  private static class Event extends AbstractRevisionEvent
-      implements ReviewerDeletedListener.Event {
-    private final AccountInfo reviewer;
-    private final String comment;
-    private final Map<String, ApprovalInfo> newApprovals;
-    private final Map<String, ApprovalInfo> oldApprovals;
-
-    Event(
-        ChangeInfo change,
-        RevisionInfo revision,
-        AccountInfo reviewer,
-        AccountInfo remover,
-        String comment,
-        Map<String, ApprovalInfo> newApprovals,
-        Map<String, ApprovalInfo> oldApprovals,
-        NotifyHandling notify,
-        Timestamp when) {
-      super(change, revision, remover, when, notify);
-      this.reviewer = reviewer;
-      this.comment = comment;
-      this.newApprovals = newApprovals;
-      this.oldApprovals = oldApprovals;
-    }
-
-    @Override
-    public AccountInfo getReviewer() {
-      return reviewer;
-    }
-
-    @Override
-    public String getComment() {
-      return comment;
-    }
-
-    @Override
-    public Map<String, ApprovalInfo> getNewApprovals() {
-      return newApprovals;
-    }
-
-    @Override
-    public Map<String, ApprovalInfo> getOldApprovals() {
-      return oldApprovals;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
deleted file mode 100644
index 8a781d0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ /dev/null
@@ -1,84 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.extensions.events;
-
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.events.RevisionCreatedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class RevisionCreated {
-  private static final Logger log = LoggerFactory.getLogger(RevisionCreated.class);
-
-  private final DynamicSet<RevisionCreatedListener> listeners;
-  private final EventUtil util;
-
-  @Inject
-  RevisionCreated(DynamicSet<RevisionCreatedListener> listeners, EventUtil util) {
-    this.listeners = listeners;
-    this.util = util;
-  }
-
-  public void fire(
-      Change change, PatchSet patchSet, Account uploader, Timestamp when, NotifyHandling notify) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    try {
-      Event event =
-          new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
-              util.accountInfo(uploader),
-              when,
-              notify);
-      for (RevisionCreatedListener l : listeners) {
-        try {
-          l.onRevisionCreated(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
-      log.error("Couldn't fire event", e);
-    }
-  }
-
-  private static class Event extends AbstractRevisionEvent
-      implements RevisionCreatedListener.Event {
-
-    Event(
-        ChangeInfo change,
-        RevisionInfo revision,
-        AccountInfo uploader,
-        Timestamp when,
-        NotifyHandling notify) {
-      super(change, revision, uploader, when, notify);
-    }
-  }
-}
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
deleted file mode 100644
index 71a603c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ /dev/null
@@ -1,129 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.extensions.events;
-
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.events.VoteDeletedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.Map;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class VoteDeleted {
-  private static final Logger log = LoggerFactory.getLogger(VoteDeleted.class);
-
-  private final DynamicSet<VoteDeletedListener> listeners;
-  private final EventUtil util;
-
-  @Inject
-  VoteDeleted(DynamicSet<VoteDeletedListener> listeners, EventUtil util) {
-    this.listeners = listeners;
-    this.util = util;
-  }
-
-  public void fire(
-      Change change,
-      PatchSet ps,
-      Account reviewer,
-      Map<String, Short> approvals,
-      Map<String, Short> oldApprovals,
-      NotifyHandling notify,
-      String message,
-      Account remover,
-      Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    try {
-      Event event =
-          new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
-              util.accountInfo(reviewer),
-              util.approvals(remover, approvals, when),
-              util.approvals(remover, oldApprovals, when),
-              notify,
-              message,
-              util.accountInfo(remover),
-              when);
-      for (VoteDeletedListener l : listeners) {
-        try {
-          l.onVoteDeleted(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
-      log.error("Couldn't fire event", e);
-    }
-  }
-
-  private static class Event extends AbstractRevisionEvent implements VoteDeletedListener.Event {
-    private final AccountInfo reviewer;
-    private final Map<String, ApprovalInfo> approvals;
-    private final Map<String, ApprovalInfo> oldApprovals;
-    private final String message;
-
-    Event(
-        ChangeInfo change,
-        RevisionInfo revision,
-        AccountInfo reviewer,
-        Map<String, ApprovalInfo> approvals,
-        Map<String, ApprovalInfo> oldApprovals,
-        NotifyHandling notify,
-        String message,
-        AccountInfo remover,
-        Timestamp when) {
-      super(change, revision, remover, when, notify);
-      this.reviewer = reviewer;
-      this.approvals = approvals;
-      this.oldApprovals = oldApprovals;
-      this.message = message;
-    }
-
-    @Override
-    public Map<String, ApprovalInfo> getApprovals() {
-      return approvals;
-    }
-
-    @Override
-    public Map<String, ApprovalInfo> getOldApprovals() {
-      return oldApprovals;
-    }
-
-    @Override
-    public String getMessage() {
-      return message;
-    }
-
-    @Override
-    public AccountInfo getReviewer() {
-      return reviewer;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
deleted file mode 100644
index 8298db3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
+++ /dev/null
@@ -1,139 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.extensions.events.ChangeAbandoned;
-import com.google.gerrit.server.mail.send.AbandonedSender;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class AbandonOp implements BatchUpdateOp {
-  private static final Logger log = LoggerFactory.getLogger(AbandonOp.class);
-
-  private final AbandonedSender.Factory abandonedSenderFactory;
-  private final ChangeMessagesUtil cmUtil;
-  private final PatchSetUtil psUtil;
-  private final ChangeAbandoned changeAbandoned;
-
-  private final String msgTxt;
-  private final NotifyHandling notifyHandling;
-  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
-  private final Account account;
-
-  private Change change;
-  private PatchSet patchSet;
-  private ChangeMessage message;
-
-  public interface Factory {
-    AbandonOp create(
-        @Assisted @Nullable Account account,
-        @Assisted @Nullable String msgTxt,
-        @Assisted NotifyHandling notifyHandling,
-        @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify);
-  }
-
-  @Inject
-  AbandonOp(
-      AbandonedSender.Factory abandonedSenderFactory,
-      ChangeMessagesUtil cmUtil,
-      PatchSetUtil psUtil,
-      ChangeAbandoned changeAbandoned,
-      @Assisted @Nullable Account account,
-      @Assisted @Nullable String msgTxt,
-      @Assisted NotifyHandling notifyHandling,
-      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    this.abandonedSenderFactory = abandonedSenderFactory;
-    this.cmUtil = cmUtil;
-    this.psUtil = psUtil;
-    this.changeAbandoned = changeAbandoned;
-
-    this.account = account;
-    this.msgTxt = Strings.nullToEmpty(msgTxt);
-    this.notifyHandling = notifyHandling;
-    this.accountsToNotify = accountsToNotify;
-  }
-
-  @Nullable
-  public Change getChange() {
-    return change;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx) throws OrmException, ResourceConflictException {
-    change = ctx.getChange();
-    PatchSet.Id psId = change.currentPatchSetId();
-    ChangeUpdate update = ctx.getUpdate(psId);
-    if (!change.getStatus().isOpen()) {
-      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-    }
-    patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-    change.setStatus(Change.Status.ABANDONED);
-    change.setLastUpdatedOn(ctx.getWhen());
-
-    update.setStatus(change.getStatus());
-    message = newMessage(ctx);
-    cmUtil.addChangeMessage(ctx.getDb(), update, message);
-    return true;
-  }
-
-  private ChangeMessage newMessage(ChangeContext ctx) {
-    StringBuilder msg = new StringBuilder();
-    msg.append("Abandoned");
-    if (!Strings.nullToEmpty(msgTxt).trim().isEmpty()) {
-      msg.append("\n\n");
-      msg.append(msgTxt.trim());
-    }
-
-    return ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_ABANDON);
-  }
-
-  @Override
-  public void postUpdate(Context ctx) throws OrmException {
-    try {
-      ReplyToChangeSender cm = abandonedSenderFactory.create(ctx.getProject(), change.getId());
-      if (account != null) {
-        cm.setFrom(account.getId());
-      }
-      cm.setChangeMessage(message.getMessage(), ctx.getWhen());
-      cm.setNotify(notifyHandling);
-      cm.setAccountsToNotify(accountsToNotify);
-      cm.send();
-    } catch (Exception e) {
-      log.error("Cannot email update for change " + change.getId(), e);
-    }
-    changeAbandoned.fire(change, patchSet, account, msgTxt, ctx.getWhen(), notifyHandling);
-  }
-}
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
deleted file mode 100644
index 322d158..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommit.java
+++ /dev/null
@@ -1,171 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static 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.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Date;
-import java.util.List;
-import java.util.TimeZone;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.Note;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-@Singleton
-public class BanCommit {
-  /**
-   * Loads a list of commits to reject from {@code refs/meta/reject-commits}.
-   *
-   * @param repo repository from which the rejected commits should be loaded
-   * @param walk open revwalk on repo.
-   * @return NoteMap of commits to be rejected, null if there are none.
-   * @throws IOException the map cannot be loaded.
-   */
-  public static NoteMap loadRejectCommitsMap(Repository repo, RevWalk walk) throws IOException {
-    try {
-      Ref ref = repo.getRefDatabase().exactRef(RefNames.REFS_REJECT_COMMITS);
-      if (ref == null) {
-        return NoteMap.newEmptyMap();
-      }
-
-      RevCommit map = walk.parseCommit(ref.getObjectId());
-      return NoteMap.read(walk.getObjectReader(), map);
-    } catch (IOException badMap) {
-      throw new IOException("Cannot load " + RefNames.REFS_REJECT_COMMITS, badMap);
-    }
-  }
-
-  private final Provider<IdentifiedUser> currentUser;
-  private final GitRepositoryManager repoManager;
-  private final TimeZone tz;
-  private NotesBranchUtil.Factory notesBranchUtilFactory;
-
-  @Inject
-  BanCommit(
-      Provider<IdentifiedUser> currentUser,
-      GitRepositoryManager repoManager,
-      @GerritPersonIdent PersonIdent gerritIdent,
-      NotesBranchUtil.Factory notesBranchUtilFactory) {
-    this.currentUser = currentUser;
-    this.repoManager = repoManager;
-    this.notesBranchUtilFactory = notesBranchUtilFactory;
-    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");
-    }
-
-    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()) {
-      ObjectId noteId = null;
-      for (ObjectId commitToBan : commitsToBan) {
-        try {
-          revWalk.parseCommit(commitToBan);
-        } catch (MissingObjectException e) {
-          // Ignore exception, non-existing commits can be banned.
-        } catch (IncorrectObjectTypeException e) {
-          result.notACommit(commitToBan);
-          continue;
-        }
-        if (noteId == null) {
-          noteId = createNoteContent(reason, inserter);
-        }
-        banCommitNotes.set(commitToBan, noteId);
-      }
-      NotesBranchUtil notesBranchUtil = notesBranchUtilFactory.create(project, repo, inserter);
-      NoteMap newlyCreated =
-          notesBranchUtil.commitNewNotes(
-              banCommitNotes,
-              REFS_REJECT_COMMITS,
-              createPersonIdent(),
-              buildCommitMessage(commitsToBan, reason));
-
-      for (Note n : banCommitNotes) {
-        if (newlyCreated.contains(n)) {
-          result.commitBanned(n);
-        } else {
-          result.commitAlreadyBanned(n);
-        }
-      }
-      return result;
-    }
-  }
-
-  private ObjectId createNoteContent(String reason, ObjectInserter inserter) throws IOException {
-    String noteContent = reason != null ? reason : "";
-    if (noteContent.length() > 0 && !noteContent.endsWith("\n")) {
-      noteContent = noteContent + "\n";
-    }
-    return inserter.insert(Constants.OBJ_BLOB, noteContent.getBytes(UTF_8));
-  }
-
-  private PersonIdent createPersonIdent() {
-    Date now = new Date();
-    return currentUser.get().newCommitterIdent(now, tz);
-  }
-
-  private static String buildCommitMessage(List<ObjectId> bannedCommits, String reason) {
-    final StringBuilder commitMsg = new StringBuilder();
-    commitMsg.append("Banning ");
-    commitMsg.append(bannedCommits.size());
-    commitMsg.append(" ");
-    commitMsg.append(bannedCommits.size() == 1 ? "commit" : "commits");
-    commitMsg.append("\n\n");
-    if (reason != null) {
-      commitMsg.append("Reason: ");
-      commitMsg.append(reason);
-      commitMsg.append("\n\n");
-    }
-    commitMsg.append("The following commits are banned:\n");
-    final StringBuilder commitList = new StringBuilder();
-    for (ObjectId c : bannedCommits) {
-      if (commitList.length() > 0) {
-        commitList.append(",\n");
-      }
-      commitList.append(c.getName());
-    }
-    commitMsg.append(commitList);
-    return commitMsg.toString();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionLogFile.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionLogFile.java
deleted file mode 100644
index e1f0594..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionLogFile.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.util.SystemLog;
-import com.google.inject.Inject;
-import java.nio.file.Path;
-import org.apache.log4j.LogManager;
-import org.apache.log4j.Logger;
-import org.apache.log4j.PatternLayout;
-
-public class GarbageCollectionLogFile implements LifecycleListener {
-
-  @Inject
-  public GarbageCollectionLogFile(SitePaths sitePaths) {
-    if (SystemLog.shouldConfigure()) {
-      initLogSystem(sitePaths.logs_dir);
-    }
-  }
-
-  @Override
-  public void start() {}
-
-  @Override
-  public void stop() {
-    LogManager.getLogger(GarbageCollection.LOG_NAME).removeAllAppenders();
-  }
-
-  private static void initLogSystem(Path logdir) {
-    Logger gcLogger = LogManager.getLogger(GarbageCollection.LOG_NAME);
-    gcLogger.removeAllAppenders();
-    gcLogger.addAppender(
-        SystemLog.createAppender(
-            logdir, GarbageCollection.LOG_NAME, new PatternLayout("[%d] %-5p %x: %m%n")));
-    gcLogger.setAdditivity(false);
-  }
-}
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
deleted file mode 100644
index 6a332cd..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
+++ /dev/null
@@ -1,190 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.LabelValue;
-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.
- *
- * <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.
- */
-@Singleton
-public class LabelNormalizer {
-  @AutoValue
-  public abstract static class Result {
-    @VisibleForTesting
-    static Result create(
-        List<PatchSetApproval> unchanged,
-        List<PatchSetApproval> updated,
-        List<PatchSetApproval> deleted) {
-      return new AutoValue_LabelNormalizer_Result(
-          ImmutableList.copyOf(unchanged),
-          ImmutableList.copyOf(updated),
-          ImmutableList.copyOf(deleted));
-    }
-
-    public abstract ImmutableList<PatchSetApproval> unchanged();
-
-    public abstract ImmutableList<PatchSetApproval> updated();
-
-    public abstract ImmutableList<PatchSetApproval> deleted();
-
-    public Iterable<PatchSetApproval> getNormalized() {
-      return Iterables.concat(unchanged(), updated());
-    }
-  }
-
-  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;
-    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.
-   * @throws OrmException
-   */
-  public Result normalize(ChangeNotes notes, Collection<PatchSetApproval> approvals)
-      throws OrmException, PermissionBackendException, IOException {
-    IdentifiedUser user = userFactory.create(notes.getChange().getOwner());
-    return normalize(notes, user, approvals);
-  }
-
-  /**
-   * @param notes change notes containing the given approvals.
-   * @param user current user.
-   * @param approvals list of approvals.
-   * @return copies of approvals normalized to the defined ranges for the label type and permissions
-   *     for the user. Approvals for unknown labels are not included in the output, nor are
-   *     approvals where the user has no permissions for that label.
-   */
-  public Result normalize(
-      ChangeNotes notes, CurrentUser user, Collection<PatchSetApproval> approvals)
-      throws PermissionBackendException, IOException {
-    List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
-    List<PatchSetApproval> updated = Lists.newArrayListWithCapacity(approvals.size());
-    List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
-    LabelTypes labelTypes =
-        projectCache.checkedGet(notes.getProjectName()).getLabelTypes(notes, user);
-    for (PatchSetApproval psa : approvals) {
-      Change.Id changeId = psa.getKey().getParentKey().getParentKey();
-      checkArgument(
-          changeId.equals(notes.getChangeId()),
-          "Approval %s does not match change %s",
-          psa.getKey(),
-          notes.getChange().getKey());
-      if (psa.isLegacySubmit()) {
-        unchanged.add(psa);
-        continue;
-      }
-      LabelType label = labelTypes.byLabel(psa.getLabelId());
-      if (label == null) {
-        deleted.add(psa);
-        continue;
-      }
-      PatchSetApproval copy = copy(psa);
-      applyTypeFloor(label, copy);
-      if (!applyRightFloor(notes, label, copy)) {
-        deleted.add(psa);
-      } else if (copy.getValue() != psa.getValue()) {
-        updated.add(copy);
-      } else {
-        unchanged.add(psa);
-      }
-    }
-    return Result.create(unchanged, updated, deleted);
-  }
-
-  private PatchSetApproval copy(PatchSetApproval src) {
-    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()) {
-      a.setValue(atMin.getValue());
-    }
-    LabelValue atMax = lt.getMax();
-    if (atMax != null && a.getValue() > atMax.getValue()) {
-      a.setValue(atMax.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
deleted file mode 100644
index 50f4975..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ /dev/null
@@ -1,371 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.FileVisitOption;
-import java.nio.file.FileVisitResult;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.SimpleFileVisitor;
-import java.nio.file.attribute.BasicFileAttributes;
-import java.util.Collections;
-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;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.RepositoryCache;
-import org.eclipse.jgit.lib.RepositoryCache.FileKey;
-import org.eclipse.jgit.lib.RepositoryCacheConfig;
-import org.eclipse.jgit.lib.StoredConfig;
-import org.eclipse.jgit.storage.file.WindowCacheConfig;
-import org.eclipse.jgit.util.FS;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Manages Git repositories stored on the local filesystem. */
-@Singleton
-public class LocalDiskRepositoryManager implements GitRepositoryManager {
-  private static final Logger log = LoggerFactory.getLogger(LocalDiskRepositoryManager.class);
-
-  public static class Module extends LifecycleModule {
-    @Override
-    protected void configure() {
-      listener().to(LocalDiskRepositoryManager.Lifecycle.class);
-    }
-  }
-
-  public static class Lifecycle implements LifecycleListener {
-    private final Config serverConfig;
-
-    @Inject
-    Lifecycle(@GerritServerConfig Config cfg) {
-      this.serverConfig = cfg;
-    }
-
-    @Override
-    public void start() {
-      RepositoryCacheConfig repoCacheCfg = new RepositoryCacheConfig();
-      repoCacheCfg.fromConfig(serverConfig);
-      repoCacheCfg.install();
-
-      WindowCacheConfig cfg = new WindowCacheConfig();
-      cfg.fromConfig(serverConfig);
-      if (serverConfig.getString("core", null, "streamFileThreshold") == null) {
-        long mx = Runtime.getRuntime().maxMemory();
-        int limit =
-            (int)
-                Math.min(
-                    mx / 4, // don't use more than 1/4 of the heap.
-                    2047 << 20); // cannot exceed array length
-        if ((5 << 20) < limit && limit % (1 << 20) != 0) {
-          // If the limit is at least 5 MiB but is not a whole multiple
-          // of MiB round up to the next one full megabyte. This is a very
-          // tiny memory increase in exchange for nice round units.
-          limit = ((limit / (1 << 20)) + 1) << 20;
-        }
-
-        String desc;
-        if (limit % (1 << 20) == 0) {
-          desc = String.format("%dm", limit / (1 << 20));
-        } else if (limit % (1 << 10) == 0) {
-          desc = String.format("%dk", limit / (1 << 10));
-        } else {
-          desc = String.format("%d", limit);
-        }
-        log.info(String.format("Defaulting core.streamFileThreshold to %s", desc));
-        cfg.setStreamFileThreshold(limit);
-      }
-      cfg.install();
-    }
-
-    @Override
-    public void stop() {}
-  }
-
-  private final Path basePath;
-  private final Lock namesUpdateLock;
-  private volatile SortedSet<Project.NameKey> names = new TreeSet<>();
-
-  @Inject
-  LocalDiskRepositoryManager(SitePaths site, @GerritServerConfig Config cfg) {
-    basePath = site.resolve(cfg.getString("gerrit", null, "basePath"));
-    if (basePath == null) {
-      throw new IllegalStateException("gerrit.basePath must be configured");
-    }
-
-    namesUpdateLock = new ReentrantLock(true /* fair */);
-  }
-
-  /**
-   * Return the basePath under which the specified project is stored.
-   *
-   * @param name the name of the project
-   * @return base directory
-   */
-  public Path getBasePath(Project.NameKey name) {
-    return basePath;
-  }
-
-  @Override
-  public Repository openRepository(Project.NameKey name) throws RepositoryNotFoundException {
-    return openRepository(getBasePath(name), name);
-  }
-
-  private Repository openRepository(Path path, Project.NameKey name)
-      throws RepositoryNotFoundException {
-    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);
-    try {
-      return RepositoryCache.open(loc);
-    } catch (IOException e1) {
-      final RepositoryNotFoundException e2;
-      e2 = new RepositoryNotFoundException("Cannot open repository " + name);
-      e2.initCause(e1);
-      throw e2;
-    }
-  }
-
-  @Override
-  public Repository createRepository(Project.NameKey name)
-      throws RepositoryNotFoundException, RepositoryCaseMismatchException, IOException {
-    Path path = getBasePath(name);
-    if (isUnreasonableName(name)) {
-      throw new RepositoryNotFoundException("Invalid name: " + name);
-    }
-
-    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)) {
-        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);
-    }
-
-    try {
-      Repository db = RepositoryCache.open(loc, false);
-      db.create(true /* bare */);
-
-      StoredConfig config = db.getConfig();
-      config.setBoolean(
-          ConfigConstants.CONFIG_CORE_SECTION,
-          null,
-          ConfigConstants.CONFIG_KEY_LOGALLREFUPDATES,
-          true);
-      config.save();
-
-      // JGit only writes to the reflog for refs/meta/config if the log file
-      // already exists.
-      //
-      File metaConfigLog = new File(db.getDirectory(), "logs/" + RefNames.REFS_CONFIG);
-      if (!metaConfigLog.getParentFile().mkdirs() || !metaConfigLog.createNewFile()) {
-        log.error(
-            String.format(
-                "Failed to create ref log for %s in repository %s", RefNames.REFS_CONFIG, name));
-      }
-
-      onCreateProject(name);
-
-      return db;
-    } catch (IOException e1) {
-      final RepositoryNotFoundException e2;
-      e2 = new RepositoryNotFoundException("Cannot create repository " + name);
-      e2.initCause(e1);
-      throw e2;
-    }
-  }
-
-  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();
-
-    return name.length() == 0 // no empty paths
-        || name.charAt(name.length() - 1) == '/' // no suffix
-        || name.indexOf('\\') >= 0 // no windows/dos style paths
-        || name.charAt(0) == '/' // no absolute paths
-        || new File(name).isAbsolute() // no absolute paths
-        || name.startsWith("../") // no "l../etc/passwd"
-        || name.contains("/../") // no "foo/../etc/passwd"
-        || name.contains("/./") // "foo/./foo" is insane to ask
-        || name.contains("//") // windows UNC path can be "//..."
-        || name.contains(".git/") // no path segments that end with '.git' as "foo.git/bar"
-        || name.contains("?") // common unix wildcard
-        || name.contains("%") // wildcard or string parameter
-        || name.contains("*") // wildcard
-        || name.contains(":") // Could be used for absolute paths in windows?
-        || name.contains("<") // redirect input
-        || name.contains(">") // redirect output
-        || name.contains("|") // pipe
-        || name.contains("$") // dollar sign
-        || name.contains("\r") // carriage return
-        || name.contains("/+") // delimiter in /changes/
-        || name.contains("~"); // delimiter in /changes/
-  }
-
-  @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;
-  }
-
-  protected void scanProjects(ProjectVisitor visitor) {
-    try {
-      Files.walkFileTree(
-          visitor.startFolder,
-          EnumSet.of(FileVisitOption.FOLLOW_LINKS),
-          Integer.MAX_VALUE,
-          visitor);
-    } catch (IOException e) {
-      log.error("Error walking repository tree " + visitor.startFolder.toAbsolutePath(), e);
-    }
-  }
-
-  private static Project.NameKey getProjectName(Path startFolder, Path p) {
-    String projectName = startFolder.relativize(p).toString();
-    if (File.separatorChar != '/') {
-      projectName = projectName.replace(File.separatorChar, '/');
-    }
-    if (projectName.endsWith(Constants.DOT_GIT_EXT)) {
-      int newLen = projectName.length() - Constants.DOT_GIT_EXT.length();
-      projectName = projectName.substring(0, newLen);
-    }
-    return new Project.NameKey(projectName);
-  }
-
-  protected class ProjectVisitor extends SimpleFileVisitor<Path> {
-    private final SortedSet<Project.NameKey> found = new TreeSet<>();
-    private Path startFolder;
-
-    public ProjectVisitor(Path startFolder) {
-      setStartFolder(startFolder);
-    }
-
-    public void setStartFolder(Path startFolder) {
-      this.startFolder = startFolder;
-    }
-
-    @Override
-    public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
-        throws IOException {
-      if (!dir.equals(startFolder) && isRepo(dir)) {
-        addProject(dir);
-        return FileVisitResult.SKIP_SUBTREE;
-      }
-      return FileVisitResult.CONTINUE;
-    }
-
-    @Override
-    public FileVisitResult visitFileFailed(Path file, IOException e) {
-      log.warn(e.getMessage());
-      return FileVisitResult.CONTINUE;
-    }
-
-    private boolean isRepo(Path p) {
-      String name = p.getFileName().toString();
-      return !name.equals(Constants.DOT_GIT)
-          && (name.endsWith(Constants.DOT_GIT_EXT)
-              || FileKey.isGitRepository(p.toFile(), FS.DETECTED));
-    }
-
-    private void addProject(Path p) {
-      Project.NameKey nameKey = getProjectName(startFolder, p);
-      if (getBasePath(nameKey).equals(startFolder)) {
-        if (isUnreasonableName(nameKey)) {
-          log.warn("Ignoring unreasonably named repository " + p.toAbsolutePath());
-        } else {
-          found.add(nameKey);
-        }
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
deleted file mode 100644
index b6fbbe2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
+++ /dev/null
@@ -1,945 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.toSet;
-
-import com.github.rholder.retry.Attempt;
-import com.github.rholder.retry.RetryListener;
-import com.google.auto.value.AutoValue;
-import com.google.common.base.Joiner;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitTypeRecord;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.metrics.Counter0;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.change.NotifyUtil;
-import com.google.gerrit.server.git.MergeOpRepoManager.OpenBranch;
-import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.git.strategy.SubmitStrategy;
-import com.google.gerrit.server.git.strategy.SubmitStrategyFactory;
-import com.google.gerrit.server.git.strategy.SubmitStrategyListener;
-import com.google.gerrit.server.git.validators.MergeValidationException;
-import com.google.gerrit.server.git.validators.MergeValidators;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.SubmitRuleOptions;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.RequestId;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Merges changes in submission order into a single branch.
- *
- * <p>Branches are reduced to the minimum number of heads needed to merge everything. This allows
- * commits to be entered into the queue in any order (such as ancestors before descendants) and only
- * the most recent commit on any line of development will be merged. All unmerged commits along a
- * line of development must be in the submission queue in order to merge the tip of that line.
- *
- * <p>Conflicts are handled by discarding the entire line of development and marking it as
- * conflicting, even if an earlier commit along that same line can be merged cleanly.
- */
-public class MergeOp implements AutoCloseable {
-  private static final Logger log = LoggerFactory.getLogger(MergeOp.class);
-
-  private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS = SubmitRuleOptions.defaults().build();
-  private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_ALLOW_CLOSED =
-      SUBMIT_RULE_OPTIONS.toBuilder().allowClosed(true).build();
-
-  public static class CommitStatus {
-    private final ImmutableMap<Change.Id, ChangeData> changes;
-    private final ImmutableSetMultimap<Branch.NameKey, Change.Id> byBranch;
-    private final Map<Change.Id, CodeReviewCommit> commits;
-    private final ListMultimap<Change.Id, String> problems;
-    private final boolean allowClosed;
-
-    private CommitStatus(ChangeSet cs, boolean allowClosed) throws OrmException {
-      checkArgument(
-          !cs.furtherHiddenChanges(), "CommitStatus must not be called with hidden changes");
-      changes = cs.changesById();
-      ImmutableSetMultimap.Builder<Branch.NameKey, Change.Id> bb = ImmutableSetMultimap.builder();
-      for (ChangeData cd : cs.changes()) {
-        bb.put(cd.change().getDest(), cd.getId());
-      }
-      byBranch = bb.build();
-      commits = new HashMap<>();
-      problems = MultimapBuilder.treeKeys(comparing(Change.Id::get)).arrayListValues(1).build();
-      this.allowClosed = allowClosed;
-    }
-
-    public ImmutableSet<Change.Id> getChangeIds() {
-      return changes.keySet();
-    }
-
-    public ImmutableSet<Change.Id> getChangeIds(Branch.NameKey branch) {
-      return byBranch.get(branch);
-    }
-
-    public CodeReviewCommit get(Change.Id changeId) {
-      return commits.get(changeId);
-    }
-
-    public void put(CodeReviewCommit c) {
-      commits.put(c.change().getId(), c);
-    }
-
-    public void problem(Change.Id id, String problem) {
-      problems.put(id, problem);
-    }
-
-    public void logProblem(Change.Id id, Throwable t) {
-      String msg = "Error reading change";
-      log.error(msg + " " + id, t);
-      problems.put(id, msg);
-    }
-
-    public void logProblem(Change.Id id, String msg) {
-      log.error(msg + " " + id);
-      problems.put(id, msg);
-    }
-
-    public boolean isOk() {
-      return problems.isEmpty();
-    }
-
-    public ImmutableListMultimap<Change.Id, String> getProblems() {
-      return ImmutableListMultimap.copyOf(problems);
-    }
-
-    public List<SubmitRecord> getSubmitRecords(Change.Id id) {
-      // Use the cached submit records from the original ChangeData in the input
-      // ChangeSet, which were checked earlier in the integrate process. Even in
-      // the case of a race where the submit records may have changed, it makes
-      // more sense to store the original results of the submit rule evaluator
-      // than to fail at this point.
-      //
-      // However, do NOT expose that ChangeData directly, as it is way out of
-      // date by this point.
-      ChangeData cd = checkNotNull(changes.get(id), "ChangeData for %s", id);
-      return checkNotNull(
-          cd.getSubmitRecords(submitRuleOptions(allowClosed)),
-          "getSubmitRecord only valid after submit rules are evalutated");
-    }
-
-    public void maybeFailVerbose() throws ResourceConflictException {
-      if (isOk()) {
-        return;
-      }
-      String msg =
-          "Failed to submit "
-              + changes.size()
-              + " change"
-              + (changes.size() > 1 ? "s" : "")
-              + " due to the following problems:\n";
-      List<String> ps = new ArrayList<>(problems.keySet().size());
-      for (Change.Id id : problems.keySet()) {
-        ps.add("Change " + id + ": " + Joiner.on("; ").join(problems.get(id)));
-      }
-      throw new ResourceConflictException(msg + Joiner.on('\n').join(ps));
-    }
-
-    public void maybeFail(String msgPrefix) throws ResourceConflictException {
-      if (isOk()) {
-        return;
-      }
-      StringBuilder msg = new StringBuilder(msgPrefix).append(" of change");
-      Set<Change.Id> ids = problems.keySet();
-      if (ids.size() == 1) {
-        msg.append(" ").append(ids.iterator().next());
-      } else {
-        msg.append("s ").append(Joiner.on(", ").join(ids));
-      }
-      throw new ResourceConflictException(msg.toString());
-    }
-  }
-
-  private final ChangeMessagesUtil cmUtil;
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final InternalUser.Factory internalUserFactory;
-  private final MergeSuperSet mergeSuperSet;
-  private final MergeValidators.Factory mergeValidatorsFactory;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final SubmitStrategyFactory submitStrategyFactory;
-  private final SubmoduleOp.Factory subOpFactory;
-  private final Provider<MergeOpRepoManager> ormProvider;
-  private final NotifyUtil notifyUtil;
-  private final RetryHelper retryHelper;
-
-  private Timestamp ts;
-  private RequestId submissionId;
-  private IdentifiedUser caller;
-
-  private MergeOpRepoManager orm;
-  private CommitStatus commitStatus;
-  private ReviewDb db;
-  private SubmitInput submitInput;
-  private ListMultimap<RecipientType, Account.Id> accountsToNotify;
-  private Set<Project.NameKey> allProjects;
-  private boolean dryrun;
-  private TopicMetrics topicMetrics;
-
-  @Inject
-  MergeOp(
-      ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory batchUpdateFactory,
-      InternalUser.Factory internalUserFactory,
-      MergeSuperSet mergeSuperSet,
-      MergeValidators.Factory mergeValidatorsFactory,
-      Provider<InternalChangeQuery> queryProvider,
-      SubmitStrategyFactory submitStrategyFactory,
-      SubmoduleOp.Factory subOpFactory,
-      Provider<MergeOpRepoManager> ormProvider,
-      NotifyUtil notifyUtil,
-      TopicMetrics topicMetrics,
-      RetryHelper retryHelper) {
-    this.cmUtil = cmUtil;
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.internalUserFactory = internalUserFactory;
-    this.mergeSuperSet = mergeSuperSet;
-    this.mergeValidatorsFactory = mergeValidatorsFactory;
-    this.queryProvider = queryProvider;
-    this.submitStrategyFactory = submitStrategyFactory;
-    this.subOpFactory = subOpFactory;
-    this.ormProvider = ormProvider;
-    this.notifyUtil = notifyUtil;
-    this.retryHelper = retryHelper;
-    this.topicMetrics = topicMetrics;
-  }
-
-  @Override
-  public void close() {
-    if (orm != null) {
-      orm.close();
-    }
-  }
-
-  public static void checkSubmitRule(ChangeData cd, boolean allowClosed)
-      throws ResourceConflictException, OrmException {
-    PatchSet patchSet = cd.currentPatchSet();
-    if (patchSet == null) {
-      throw new ResourceConflictException("missing current patch set for change " + cd.getId());
-    }
-    List<SubmitRecord> results = getSubmitRecords(cd, allowClosed);
-    if (SubmitRecord.findOkRecord(results).isPresent()) {
-      // Rules supplied a valid solution.
-      return;
-    } else if (results.isEmpty()) {
-      throw new IllegalStateException(
-          String.format(
-              "SubmitRuleEvaluator.evaluate for change %s returned empty list for %s in %s",
-              cd.getId(), patchSet.getId(), cd.change().getProject().get()));
-    }
-
-    for (SubmitRecord record : results) {
-      switch (record.status) {
-        case CLOSED:
-          throw new ResourceConflictException("change is closed");
-
-        case RULE_ERROR:
-          throw new ResourceConflictException("submit rule error: " + record.errorMessage);
-
-        case NOT_READY:
-          throw new ResourceConflictException(describeLabels(cd, record.labels));
-
-        case FORCED:
-        case OK:
-        default:
-          throw new IllegalStateException(
-              String.format(
-                  "Unexpected SubmitRecord status %s for %s in %s",
-                  record.status, patchSet.getId().getId(), cd.change().getProject().get()));
-      }
-    }
-    throw new IllegalStateException();
-  }
-
-  private static SubmitRuleOptions submitRuleOptions(boolean allowClosed) {
-    return allowClosed ? SUBMIT_RULE_OPTIONS_ALLOW_CLOSED : SUBMIT_RULE_OPTIONS;
-  }
-
-  private static List<SubmitRecord> getSubmitRecords(ChangeData cd, boolean allowClosed)
-      throws OrmException {
-    return cd.submitRecords(submitRuleOptions(allowClosed));
-  }
-
-  private static String describeLabels(ChangeData cd, List<SubmitRecord.Label> labels)
-      throws OrmException {
-    List<String> labelResults = new ArrayList<>();
-    for (SubmitRecord.Label lbl : labels) {
-      switch (lbl.status) {
-        case OK:
-        case MAY:
-          break;
-
-        case REJECT:
-          labelResults.add("blocked by " + lbl.label);
-          break;
-
-        case NEED:
-          labelResults.add("needs " + lbl.label);
-          break;
-
-        case IMPOSSIBLE:
-          labelResults.add("needs " + lbl.label + " (check project access)");
-          break;
-
-        default:
-          throw new IllegalStateException(
-              String.format(
-                  "Unsupported SubmitRecord.Label %s for %s in %s",
-                  lbl, cd.change().currentPatchSetId(), cd.change().getProject()));
-      }
-    }
-    return Joiner.on("; ").join(labelResults);
-  }
-
-  private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
-      throws ResourceConflictException {
-    checkArgument(
-        !cs.furtherHiddenChanges(), "checkSubmitRulesAndState called for topic with hidden change");
-    for (ChangeData cd : cs.changes()) {
-      try {
-        Change.Status status = cd.change().getStatus();
-        if (status != Change.Status.NEW) {
-          if (!(status == Change.Status.MERGED && allowMerged)) {
-            commitStatus.problem(
-                cd.getId(),
-                "Change " + cd.getId() + " is " + cd.change().getStatus().toString().toLowerCase());
-          }
-        } else if (cd.change().isWorkInProgress()) {
-          commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
-        } else {
-          checkSubmitRule(cd, allowMerged);
-        }
-      } catch (ResourceConflictException e) {
-        commitStatus.problem(cd.getId(), e.getMessage());
-      } catch (OrmException e) {
-        String msg = "Error checking submit rules for change";
-        log.warn(msg + " " + cd.getId(), e);
-        commitStatus.problem(cd.getId(), msg);
-      }
-    }
-    commitStatus.maybeFailVerbose();
-  }
-
-  private void bypassSubmitRules(ChangeSet cs, boolean allowClosed) throws OrmException {
-    checkArgument(
-        !cs.furtherHiddenChanges(), "cannot bypass submit rules for topic with hidden change");
-    for (ChangeData cd : cs.changes()) {
-      List<SubmitRecord> records = new ArrayList<>(getSubmitRecords(cd, allowClosed));
-      SubmitRecord forced = new SubmitRecord();
-      forced.status = SubmitRecord.Status.FORCED;
-      records.add(forced);
-      cd.setSubmitRecords(submitRuleOptions(allowClosed), records);
-    }
-  }
-
-  /**
-   * Merges the given change.
-   *
-   * <p>Depending on the server configuration, more changes may be affected, e.g. by submission of a
-   * topic or via superproject subscriptions. All affected changes are integrated using the projects
-   * integration strategy.
-   *
-   * @param db the review database.
-   * @param change the change to be merged.
-   * @param caller the identity of the caller
-   * @param checkSubmitRules whether the prolog submit rules should be evaluated
-   * @param submitInput parameters regarding the merge
-   * @throws OrmException an error occurred reading or writing the database.
-   * @throws RestApiException if an error occurred.
-   * @throws PermissionBackendException if permissions can't be checked
-   * @throws IOException an error occurred reading from NoteDb.
-   */
-  public void merge(
-      ReviewDb db,
-      Change change,
-      IdentifiedUser caller,
-      boolean checkSubmitRules,
-      SubmitInput submitInput,
-      boolean dryrun)
-      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    this.submitInput = submitInput;
-    this.accountsToNotify = notifyUtil.resolveAccounts(submitInput.notifyDetails);
-    this.dryrun = dryrun;
-    this.caller = caller;
-    this.ts = TimeUtil.nowTs();
-    submissionId = RequestId.forChange(change);
-    this.db = db;
-    openRepoManager();
-
-    logDebug("Beginning integration of {}", change);
-    try {
-      ChangeSet cs = mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(db, change, caller);
-      checkState(
-          cs.ids().contains(change.getId()), "change %s missing from %s", change.getId(), cs);
-      if (cs.furtherHiddenChanges()) {
-        throw new AuthException(
-            "A change to be submitted with " + change.getId() + " is not visible");
-      }
-      logDebug("Calculated to merge {}", cs);
-
-      // Count cross-project submissions outside of the retry loop. The chance of a single project
-      // failing increases with the number of projects, so the failure count would be inflated if
-      // this metric were incremented inside of integrateIntoHistory.
-      int projects = cs.projects().size();
-      if (projects > 1) {
-        topicMetrics.topicSubmissions.increment();
-      }
-
-      RetryTracker retryTracker = new RetryTracker();
-      retryHelper.execute(
-          updateFactory -> {
-            long attempt = retryTracker.lastAttemptNumber + 1;
-            boolean isRetry = attempt > 1;
-            if (isRetry) {
-              logDebug("Retrying, attempt #{}; skipping merged changes", attempt);
-              this.ts = TimeUtil.nowTs();
-              openRepoManager();
-            }
-            this.commitStatus = new CommitStatus(cs, isRetry);
-            MergeSuperSet.reloadChanges(cs);
-            if (checkSubmitRules) {
-              logDebug("Checking submit rules and state");
-              checkSubmitRulesAndState(cs, isRetry);
-            } else {
-              logDebug("Bypassing submit rules");
-              bypassSubmitRules(cs, isRetry);
-            }
-            try {
-              integrateIntoHistory(cs);
-            } catch (IntegrationException e) {
-              logError("Error from integrateIntoHistory", e);
-              throw new ResourceConflictException(e.getMessage(), e);
-            }
-            return null;
-          },
-          RetryHelper.options()
-              .listener(retryTracker)
-              // Up to the entire submit operation is retried, including possibly many projects.
-              // Multiply the timeout by the number of projects we're actually attempting to submit.
-              .timeout(retryHelper.getDefaultTimeout().multipliedBy(cs.projects().size()))
-              .build());
-
-      if (projects > 1) {
-        topicMetrics.topicSubmissionsCompleted.increment();
-      }
-    } catch (IOException e) {
-      // Anything before the merge attempt is an error
-      throw new OrmException(e);
-    }
-  }
-
-  private void openRepoManager() {
-    if (orm != null) {
-      orm.close();
-    }
-    orm = ormProvider.get();
-    orm.setContext(db, ts, caller, submissionId);
-  }
-
-  private class RetryTracker implements RetryListener {
-    long lastAttemptNumber;
-
-    @Override
-    public <V> void onRetry(Attempt<V> attempt) {
-      lastAttemptNumber = attempt.getAttemptNumber();
-    }
-  }
-
-  @Singleton
-  private static class TopicMetrics {
-    final Counter0 topicSubmissions;
-    final Counter0 topicSubmissionsCompleted;
-
-    @Inject
-    TopicMetrics(MetricMaker metrics) {
-      topicSubmissions =
-          metrics.newCounter(
-              "topic/cross_project_submit",
-              new Description("Attempts at cross project topic submission").setRate());
-      topicSubmissionsCompleted =
-          metrics.newCounter(
-              "topic/cross_project_submit_completed",
-              new Description("Cross project topic submissions that concluded successfully")
-                  .setRate());
-    }
-  }
-
-  private void integrateIntoHistory(ChangeSet cs)
-      throws IntegrationException, RestApiException, UpdateException {
-    checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
-    logDebug("Beginning merge attempt on {}", cs);
-    Map<Branch.NameKey, BranchBatch> toSubmit = new HashMap<>();
-
-    ListMultimap<Branch.NameKey, ChangeData> cbb;
-    try {
-      cbb = cs.changesByBranch();
-    } catch (OrmException e) {
-      throw new IntegrationException("Error reading changes to submit", e);
-    }
-    Set<Branch.NameKey> branches = cbb.keySet();
-
-    for (Branch.NameKey branch : branches) {
-      OpenRepo or = openRepo(branch.getParentKey());
-      if (or != null) {
-        toSubmit.put(branch, validateChangeList(or, cbb.get(branch)));
-      }
-    }
-
-    // Done checks that don't involve running submit strategies.
-    commitStatus.maybeFailVerbose();
-
-    try {
-      SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
-      List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit, submoduleOp, dryrun);
-      this.allProjects = submoduleOp.getProjectsInOrder();
-      batchUpdateFactory.execute(
-          orm.batchUpdates(allProjects),
-          new SubmitStrategyListener(submitInput, strategies, commitStatus),
-          submissionId,
-          dryrun);
-    } catch (NoSuchProjectException e) {
-      throw new ResourceNotFoundException(e.getMessage());
-    } catch (IOException | SubmoduleException e) {
-      throw new IntegrationException(e);
-    } catch (UpdateException e) {
-      if (e.getCause() instanceof LockFailureException) {
-        // Lock failures are a special case: RetryHelper depends on this specific causal chain in
-        // order to trigger a retry. The downside of throwing here is we will not get the nicer
-        // error message constructed below, in the case where this is the final attempt and the
-        // operation is not retried further. This is not a huge downside, and is hopefully so rare
-        // as to be unnoticeable, assuming RetryHelper is retrying sufficiently.
-        throw e;
-      }
-
-      // BatchUpdate may have inadvertently wrapped an IntegrationException
-      // thrown by some legacy SubmitStrategyOp code that intended the error
-      // message to be user-visible. Copy the message from the wrapped
-      // exception.
-      //
-      // If you happen across one of these, the correct fix is to convert the
-      // inner IntegrationException to a ResourceConflictException.
-      String msg;
-      if (e.getCause() instanceof IntegrationException) {
-        msg = e.getCause().getMessage();
-      } else {
-        msg = genericMergeError(cs);
-      }
-      throw new IntegrationException(msg, e);
-    }
-  }
-
-  public Set<Project.NameKey> getAllProjects() {
-    return allProjects;
-  }
-
-  public MergeOpRepoManager getMergeOpRepoManager() {
-    return orm;
-  }
-
-  private List<SubmitStrategy> getSubmitStrategies(
-      Map<Branch.NameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp, boolean dryrun)
-      throws IntegrationException, NoSuchProjectException, IOException {
-    List<SubmitStrategy> strategies = new ArrayList<>();
-    Set<Branch.NameKey> allBranches = submoduleOp.getBranchesInOrder();
-    Set<CodeReviewCommit> allCommits =
-        toSubmit.values().stream().map(BranchBatch::commits).flatMap(Set::stream).collect(toSet());
-    for (Branch.NameKey branch : allBranches) {
-      OpenRepo or = orm.getRepo(branch.getParentKey());
-      if (toSubmit.containsKey(branch)) {
-        BranchBatch submitting = toSubmit.get(branch);
-        OpenBranch ob = or.getBranch(branch);
-        checkNotNull(
-            submitting.submitType(),
-            "null submit type for %s; expected to previously fail fast",
-            submitting);
-        Set<CodeReviewCommit> commitsToSubmit = submitting.commits();
-        ob.mergeTip = new MergeTip(ob.oldTip, commitsToSubmit);
-        SubmitStrategy strategy =
-            submitStrategyFactory.create(
-                submitting.submitType(),
-                db,
-                or.rw,
-                or.canMergeFlag,
-                getAlreadyAccepted(or, ob.oldTip),
-                allCommits,
-                branch,
-                caller,
-                ob.mergeTip,
-                commitStatus,
-                submissionId,
-                submitInput,
-                accountsToNotify,
-                submoduleOp,
-                dryrun);
-        strategies.add(strategy);
-        strategy.addOps(or.getUpdate(), commitsToSubmit);
-        if (submitting.submitType().equals(SubmitType.FAST_FORWARD_ONLY)
-            && submoduleOp.hasSubscription(branch)) {
-          submoduleOp.addOp(or.getUpdate(), branch);
-        }
-      } else {
-        // no open change for this branch
-        // add submodule triggered op into BatchUpdate
-        submoduleOp.addOp(or.getUpdate(), branch);
-      }
-    }
-    return strategies;
-  }
-
-  private Set<RevCommit> getAlreadyAccepted(OpenRepo or, CodeReviewCommit branchTip)
-      throws IntegrationException {
-    Set<RevCommit> alreadyAccepted = new HashSet<>();
-
-    if (branchTip != null) {
-      alreadyAccepted.add(branchTip);
-    }
-
-    try {
-      for (Ref r : or.repo.getRefDatabase().getRefs(Constants.R_HEADS).values()) {
-        try {
-          CodeReviewCommit aac = or.rw.parseCommit(r.getObjectId());
-          if (!commitStatus.commits.values().contains(aac)) {
-            alreadyAccepted.add(aac);
-          }
-        } catch (IncorrectObjectTypeException iote) {
-          // Not a commit? Skip over it.
-        }
-      }
-    } catch (IOException e) {
-      throw new IntegrationException("Failed to determine already accepted commits.", e);
-    }
-
-    logDebug("Found {} existing heads", alreadyAccepted.size());
-    return alreadyAccepted;
-  }
-
-  @AutoValue
-  abstract static class BranchBatch {
-    @Nullable
-    abstract SubmitType submitType();
-
-    abstract Set<CodeReviewCommit> commits();
-  }
-
-  private BranchBatch validateChangeList(OpenRepo or, Collection<ChangeData> submitted)
-      throws IntegrationException {
-    logDebug("Validating {} changes", submitted.size());
-    Set<CodeReviewCommit> toSubmit = new LinkedHashSet<>(submitted.size());
-    SetMultimap<ObjectId, PatchSet.Id> revisions = getRevisions(or, submitted);
-
-    SubmitType submitType = null;
-    ChangeData choseSubmitTypeFrom = null;
-    for (ChangeData cd : submitted) {
-      Change.Id changeId = cd.getId();
-      ChangeNotes notes;
-      Change chg;
-      SubmitType st;
-      try {
-        notes = cd.notes();
-        chg = cd.change();
-        st = getSubmitType(cd);
-      } catch (OrmException e) {
-        commitStatus.logProblem(changeId, e);
-        continue;
-      }
-
-      if (st == null) {
-        commitStatus.logProblem(changeId, "No submit type for change");
-        continue;
-      }
-      if (submitType == null) {
-        submitType = st;
-        choseSubmitTypeFrom = cd;
-      } else if (st != submitType) {
-        commitStatus.problem(
-            changeId,
-            String.format(
-                "Change has submit type %s, but previously chose submit type %s "
-                    + "from change %s in the same batch",
-                st, submitType, choseSubmitTypeFrom.getId()));
-        continue;
-      }
-      if (chg.currentPatchSetId() == null) {
-        String msg = "Missing current patch set on change";
-        logError(msg + " " + changeId);
-        commitStatus.problem(changeId, msg);
-        continue;
-      }
-
-      PatchSet ps;
-      Branch.NameKey destBranch = chg.getDest();
-      try {
-        ps = cd.currentPatchSet();
-      } catch (OrmException e) {
-        commitStatus.logProblem(changeId, e);
-        continue;
-      }
-      if (ps == null || ps.getRevision() == null || ps.getRevision().get() == null) {
-        commitStatus.logProblem(changeId, "Missing patch set or revision on change");
-        continue;
-      }
-
-      String idstr = ps.getRevision().get();
-      ObjectId id;
-      try {
-        id = ObjectId.fromString(idstr);
-      } catch (IllegalArgumentException e) {
-        commitStatus.logProblem(changeId, e);
-        continue;
-      }
-
-      if (!revisions.containsEntry(id, ps.getId())) {
-        // TODO this is actually an error, the branch is gone but we
-        // want to merge the issue. We can't safely do that if the
-        // tip is not reachable.
-        //
-        commitStatus.logProblem(
-            changeId,
-            "Revision "
-                + idstr
-                + " of patch set "
-                + ps.getPatchSetId()
-                + " does not match "
-                + ps.getId().toRefName()
-                + " for change");
-        continue;
-      }
-
-      CodeReviewCommit commit;
-      try {
-        commit = or.rw.parseCommit(id);
-      } catch (IOException e) {
-        commitStatus.logProblem(changeId, e);
-        continue;
-      }
-
-      commit.setNotes(notes);
-      commit.setPatchsetId(ps.getId());
-      commitStatus.put(commit);
-
-      MergeValidators mergeValidators = mergeValidatorsFactory.create();
-      try {
-        mergeValidators.validatePreMerge(
-            or.repo, commit, or.project, destBranch, ps.getId(), caller);
-      } catch (MergeValidationException mve) {
-        commitStatus.problem(changeId, mve.getMessage());
-        continue;
-      }
-      commit.add(or.canMergeFlag);
-      toSubmit.add(commit);
-    }
-    logDebug("Submitting on this run: {}", toSubmit);
-    return new AutoValue_MergeOp_BranchBatch(submitType, toSubmit);
-  }
-
-  private SetMultimap<ObjectId, PatchSet.Id> getRevisions(OpenRepo or, Collection<ChangeData> cds)
-      throws IntegrationException {
-    try {
-      List<String> refNames = new ArrayList<>(cds.size());
-      for (ChangeData cd : cds) {
-        Change c = cd.change();
-        if (c != null) {
-          refNames.add(c.currentPatchSetId().toRefName());
-        }
-      }
-      SetMultimap<ObjectId, PatchSet.Id> revisions =
-          MultimapBuilder.hashKeys(cds.size()).hashSetValues(1).build();
-      for (Map.Entry<String, Ref> e :
-          or.repo
-              .getRefDatabase()
-              .exactRef(refNames.toArray(new String[refNames.size()]))
-              .entrySet()) {
-        revisions.put(e.getValue().getObjectId(), PatchSet.Id.fromRef(e.getKey()));
-      }
-      return revisions;
-    } catch (IOException | OrmException e) {
-      throw new IntegrationException("Failed to validate changes", e);
-    }
-  }
-
-  private SubmitType getSubmitType(ChangeData cd) throws OrmException {
-    SubmitTypeRecord str = cd.submitTypeRecord();
-    return str.isOk() ? str.type : null;
-  }
-
-  private OpenRepo openRepo(Project.NameKey project) throws IntegrationException {
-    try {
-      return orm.getRepo(project);
-    } catch (NoSuchProjectException e) {
-      logWarn("Project " + project + " no longer exists, abandoning open changes.");
-      abandonAllOpenChangeForDeletedProject(project);
-    } catch (IOException e) {
-      throw new IntegrationException("Error opening project " + project, e);
-    }
-    return null;
-  }
-
-  private void abandonAllOpenChangeForDeletedProject(Project.NameKey destProject) {
-    try {
-      for (ChangeData cd : queryProvider.get().byProjectOpen(destProject)) {
-        try (BatchUpdate bu =
-            batchUpdateFactory.create(db, destProject, internalUserFactory.create(), ts)) {
-          bu.setRequestId(submissionId);
-          bu.addOp(
-              cd.getId(),
-              new BatchUpdateOp() {
-                @Override
-                public boolean updateChange(ChangeContext ctx) throws OrmException {
-                  Change change = ctx.getChange();
-                  if (!change.getStatus().isOpen()) {
-                    return false;
-                  }
-
-                  change.setStatus(Change.Status.ABANDONED);
-
-                  ChangeMessage msg =
-                      ChangeMessagesUtil.newMessage(
-                          change.currentPatchSetId(),
-                          internalUserFactory.create(),
-                          change.getLastUpdatedOn(),
-                          ChangeMessagesUtil.TAG_MERGED,
-                          "Project was deleted.");
-                  cmUtil.addChangeMessage(
-                      ctx.getDb(), ctx.getUpdate(change.currentPatchSetId()), msg);
-
-                  return true;
-                }
-              });
-          try {
-            bu.execute();
-          } catch (UpdateException | RestApiException e) {
-            logWarn("Cannot abandon changes for deleted project " + destProject, e);
-          }
-        }
-      }
-    } catch (OrmException e) {
-      logWarn("Cannot abandon changes for deleted project " + destProject, e);
-    }
-  }
-
-  private String genericMergeError(ChangeSet cs) {
-    int c = cs.size();
-    if (c == 1) {
-      return "Error submitting change";
-    }
-    int p = cs.projects().size();
-    if (p == 1) {
-      // Fused updates: it's correct to say that none of the n changes were submitted.
-      return "Error submitting " + c + " changes";
-    }
-    // Multiple projects involved, but we don't know at this point what failed. At least give the
-    // user a heads up that some changes may be unsubmitted, even if the change screen they land on
-    // after the error message says that this particular change was submitted.
-    return "Error submitting some of the "
-        + c
-        + " changes to one or more of the "
-        + p
-        + " projects involved; some projects may have submitted successfully, but others may have"
-        + " failed";
-  }
-
-  private void logDebug(String msg, Object... args) {
-    if (log.isDebugEnabled()) {
-      log.debug(submissionId + msg, args);
-    }
-  }
-
-  private void logWarn(String msg, Throwable t) {
-    if (log.isWarnEnabled()) {
-      log.warn(submissionId + msg, t);
-    }
-  }
-
-  private void logWarn(String msg) {
-    if (log.isWarnEnabled()) {
-      log.warn(submissionId + msg);
-    }
-  }
-
-  private void logError(String msg, Throwable t) {
-    if (log.isErrorEnabled()) {
-      if (t != null) {
-        log.error(submissionId + msg, t);
-      } else {
-        log.error(submissionId + msg);
-      }
-    }
-  }
-
-  private void logError(String msg) {
-    logError(msg, null);
-  }
-}
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
deleted file mode 100644
index 4e0c3ae..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
+++ /dev/null
@@ -1,444 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.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.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;
-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.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.
- *
- * <p>This includes all parents between a change and the tip of its target branch for the
- * merging/rebasing submit strategies. For the cherry-pick strategy no additional changes are
- * included.
- *
- * <p>If change.submitWholeTopic is enabled, also all changes of the topic and their parents are
- * 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()) {
-      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();
-  }
-
-  private final ChangeData.Factory changeDataFactory;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final Provider<MergeOpRepoManager> repoManagerProvider;
-  private final PermissionBackend permissionBackend;
-  private final Config cfg;
-  private final Map<QueryKey, List<ChangeData>> queryCache;
-  private final Map<Branch.NameKey, Optional<RevCommit>> heads;
-  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
-  private final ChangeControl.GenericFactory changeControlFactory;
-  private final ProjectCache projectCache;
-
-  private MergeOpRepoManager orm;
-  private boolean closeOrm;
-
-  @Inject
-  MergeSuperSet(
-      @GerritServerConfig Config cfg,
-      ChangeData.Factory changeDataFactory,
-      Provider<InternalChangeQuery> queryProvider,
-      Provider<MergeOpRepoManager> repoManagerProvider,
-      PermissionBackend permissionBackend,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
-      ChangeControl.GenericFactory changeControlFactory,
-      ProjectCache projectCache) {
-    this.cfg = cfg;
-    this.changeDataFactory = changeDataFactory;
-    this.queryProvider = queryProvider;
-    this.repoManagerProvider = repoManagerProvider;
-    this.permissionBackend = permissionBackend;
-    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
-    this.changeControlFactory = changeControlFactory;
-    this.projectCache = projectCache;
-    queryCache = new HashMap<>();
-    heads = new HashMap<>();
-  }
-
-  public MergeSuperSet setMergeOpRepoManager(MergeOpRepoManager orm) {
-    checkState(this.orm == null);
-    this.orm = checkNotNull(orm);
-    closeOrm = false;
-    return this;
-  }
-
-  public ChangeSet completeChangeSet(ReviewDb db, Change change, CurrentUser user)
-      throws IOException, OrmException, PermissionBackendException {
-    try {
-      ChangeData cd = changeDataFactory.create(db, change.getProject(), change.getId());
-      ChangeSet cs =
-          new ChangeSet(
-              cd, permissionBackend.user(user).change(cd).database(db).test(ChangePermission.READ));
-      if (Submit.wholeTopicEnabled(cfg)) {
-        return completeChangeSetIncludingTopics(db, cs, user);
-      }
-      return completeChangeSetWithoutTopic(db, cs, user);
-    } finally {
-      if (closeOrm && orm != null) {
-        orm.close();
-        orm = null;
-      }
-    }
-  }
-
-  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
-   *
-   * <p>{@link #completeChangeSetIncludingTopics} calls this repeatedly, alternating with {@link
-   * #completeChangeSetWithoutTopic}, to discover what additional changes should be submitted with a
-   * change until the set stops growing.
-   *
-   * <p>{@code topicsSeen} and {@code visibleTopicsSeen} keep track of topics already explored to
-   * avoid wasted work.
-   *
-   * @return the resulting larger {@link ChangeSet}
-   */
-  private ChangeSet topicClosure(
-      ReviewDb db,
-      ChangeSet cs,
-      CurrentUser user,
-      Set<String> topicsSeen,
-      Set<String> visibleTopicsSeen)
-      throws OrmException, PermissionBackendException {
-    List<ChangeData> visibleChanges = new ArrayList<>();
-    List<ChangeData> nonVisibleChanges = new ArrayList<>();
-
-    for (ChangeData cd : cs.changes()) {
-      visibleChanges.add(cd);
-      String topic = cd.change().getTopic();
-      if (Strings.isNullOrEmpty(topic) || visibleTopicsSeen.contains(topic)) {
-        continue;
-      }
-      for (ChangeData topicCd : query().byTopicOpen(topic)) {
-        if (canRead(db, user, topicCd)) {
-          visibleChanges.add(topicCd);
-        } else {
-          nonVisibleChanges.add(topicCd);
-        }
-      }
-      topicsSeen.add(topic);
-      visibleTopicsSeen.add(topic);
-    }
-    for (ChangeData cd : cs.nonVisibleChanges()) {
-      nonVisibleChanges.add(cd);
-      String topic = cd.change().getTopic();
-      if (Strings.isNullOrEmpty(topic) || topicsSeen.contains(topic)) {
-        continue;
-      }
-      for (ChangeData topicCd : query().byTopicOpen(topic)) {
-        nonVisibleChanges.add(topicCd);
-      }
-      topicsSeen.add(topic);
-    }
-    return new ChangeSet(visibleChanges, nonVisibleChanges);
-  }
-
-  private ChangeSet completeChangeSetIncludingTopics(
-      ReviewDb db, ChangeSet changes, CurrentUser user)
-      throws IOException, OrmException, PermissionBackendException {
-    Set<String> topicsSeen = new HashSet<>();
-    Set<String> visibleTopicsSeen = new HashSet<>();
-    int oldSeen;
-    int seen = 0;
-
-    do {
-      oldSeen = seen;
-
-      changes = completeChangeSetWithoutTopic(db, changes, user);
-      changes = topicClosure(db, changes, user, topicsSeen, visibleTopicsSeen);
-
-      seen = topicsSeen.size() + visibleTopicsSeen.size();
-    } while (seen != oldSeen);
-    return changes;
-  }
-
-  private InternalChangeQuery query() {
-    // Request fields required for completing the ChangeSet 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 boolean canRead(ReviewDb db, CurrentUser user, ChangeData cd)
-      throws PermissionBackendException {
-    return permissionBackend.user(user).change(cd).database(db).test(ChangePermission.READ);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
deleted file mode 100644
index 9ea9dcb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
+++ /dev/null
@@ -1,880 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.common.base.Joiner;
-import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MergeConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSet.Id;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
-import com.google.gerrit.server.git.strategy.CommitMergeStatus;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Objects;
-import java.util.Set;
-import org.eclipse.jgit.errors.AmbiguousObjectException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.LargeObjectException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.NoMergeBaseException;
-import org.eclipse.jgit.errors.NoMergeBaseException.MergeBaseFailureReason;
-import org.eclipse.jgit.errors.RevisionSyntaxException;
-import org.eclipse.jgit.lib.AnyObjectId;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.MergeStrategy;
-import org.eclipse.jgit.merge.Merger;
-import org.eclipse.jgit.merge.ResolveMerger;
-import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
-import org.eclipse.jgit.merge.ThreeWayMerger;
-import org.eclipse.jgit.revwalk.FooterKey;
-import org.eclipse.jgit.revwalk.FooterLine;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevFlag;
-import org.eclipse.jgit.revwalk.RevSort;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Utility methods used during the merge process.
- *
- * <p><strong>Note:</strong> Unless otherwise specified, the methods in this class <strong>do
- * not</strong> flush {@link ObjectInserter}s. Callers that want to read back objects before
- * flushing should use {@link ObjectInserter#newReader()}. This is already the default behavior of
- * {@code BatchUpdate}.
- */
-public class MergeUtil {
-  private static final Logger log = LoggerFactory.getLogger(MergeUtil.class);
-
-  static class PluggableCommitMessageGenerator {
-    private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
-
-    @Inject
-    PluggableCommitMessageGenerator(DynamicSet<ChangeMessageModifier> changeMessageModifiers) {
-      this.changeMessageModifiers = changeMessageModifiers;
-    }
-
-    public String generate(
-        RevCommit original, RevCommit mergeTip, Branch.NameKey dest, String current) {
-      checkNotNull(original.getRawBuffer());
-      if (mergeTip != null) {
-        checkNotNull(mergeTip.getRawBuffer());
-      }
-      for (ChangeMessageModifier changeMessageModifier : changeMessageModifiers) {
-        current = changeMessageModifier.onSubmit(current, original, mergeTip, dest);
-        checkNotNull(
-            current,
-            changeMessageModifier.getClass().getName()
-                + ".OnSubmit returned null instead of new commit message");
-      }
-      return current;
-    }
-  }
-
-  private static final String R_HEADS_MASTER = Constants.R_HEADS + Constants.MASTER;
-
-  public static boolean useRecursiveMerge(Config cfg) {
-    return cfg.getBoolean("core", null, "useRecursiveMerge", true);
-  }
-
-  public static ThreeWayMergeStrategy getMergeStrategy(Config cfg) {
-    return useRecursiveMerge(cfg) ? MergeStrategy.RECURSIVE : MergeStrategy.RESOLVE;
-  }
-
-  public interface Factory {
-    MergeUtil create(ProjectState project);
-
-    MergeUtil create(ProjectState project, boolean useContentMerge);
-  }
-
-  private final Provider<ReviewDb> db;
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
-  private final Provider<String> urlProvider;
-  private final ApprovalsUtil approvalsUtil;
-  private final ProjectState project;
-  private final boolean useContentMerge;
-  private final boolean useRecursiveMerge;
-  private final PluggableCommitMessageGenerator commitMessageGenerator;
-
-  @AssistedInject
-  MergeUtil(
-      @GerritServerConfig Config serverConfig,
-      Provider<ReviewDb> db,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      ApprovalsUtil approvalsUtil,
-      PluggableCommitMessageGenerator commitMessageGenerator,
-      @Assisted ProjectState project) {
-    this(
-        serverConfig,
-        db,
-        identifiedUserFactory,
-        urlProvider,
-        approvalsUtil,
-        project,
-        commitMessageGenerator,
-        project.isUseContentMerge());
-  }
-
-  @AssistedInject
-  MergeUtil(
-      @GerritServerConfig Config serverConfig,
-      Provider<ReviewDb> db,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      ApprovalsUtil approvalsUtil,
-      @Assisted ProjectState project,
-      PluggableCommitMessageGenerator commitMessageGenerator,
-      @Assisted boolean useContentMerge) {
-    this.db = db;
-    this.identifiedUserFactory = identifiedUserFactory;
-    this.urlProvider = urlProvider;
-    this.approvalsUtil = approvalsUtil;
-    this.project = project;
-    this.useContentMerge = useContentMerge;
-    this.useRecursiveMerge = useRecursiveMerge(serverConfig);
-    this.commitMessageGenerator = commitMessageGenerator;
-  }
-
-  public CodeReviewCommit getFirstFastForward(
-      CodeReviewCommit mergeTip, RevWalk rw, List<CodeReviewCommit> toMerge)
-      throws IntegrationException {
-    for (Iterator<CodeReviewCommit> i = toMerge.iterator(); i.hasNext(); ) {
-      try {
-        final CodeReviewCommit n = i.next();
-        if (mergeTip == null || rw.isMergedInto(mergeTip, n)) {
-          i.remove();
-          return n;
-        }
-      } catch (IOException e) {
-        throw new IntegrationException("Cannot fast-forward test during merge", e);
-      }
-    }
-    return mergeTip;
-  }
-
-  public List<CodeReviewCommit> reduceToMinimalMerge(
-      MergeSorter mergeSorter, Collection<CodeReviewCommit> toSort) throws IntegrationException {
-    List<CodeReviewCommit> result = new ArrayList<>();
-    try {
-      result.addAll(mergeSorter.sort(toSort));
-    } catch (IOException e) {
-      throw new IntegrationException("Branch head sorting failed", e);
-    }
-    Collections.sort(result, CodeReviewCommit.ORDER);
-    return result;
-  }
-
-  public CodeReviewCommit createCherryPickFromCommit(
-      ObjectInserter inserter,
-      Config repoConfig,
-      RevCommit mergeTip,
-      RevCommit originalCommit,
-      PersonIdent cherryPickCommitterIdent,
-      String commitMsg,
-      CodeReviewRevWalk rw,
-      int parentIndex,
-      boolean ignoreIdenticalTree)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException,
-          MergeIdenticalTreeException, MergeConflictException {
-
-    final ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
-
-    m.setBase(originalCommit.getParent(parentIndex));
-    if (m.merge(mergeTip, originalCommit)) {
-      ObjectId tree = m.getResultTreeId();
-      if (tree.equals(mergeTip.getTree()) && !ignoreIdenticalTree) {
-        throw new MergeIdenticalTreeException("identical tree");
-      }
-
-      CommitBuilder mergeCommit = new CommitBuilder();
-      mergeCommit.setTreeId(tree);
-      mergeCommit.setParentId(mergeTip);
-      mergeCommit.setAuthor(originalCommit.getAuthorIdent());
-      mergeCommit.setCommitter(cherryPickCommitterIdent);
-      mergeCommit.setMessage(commitMsg);
-      matchAuthorToCommitterDate(project, mergeCommit);
-      return rw.parseCommit(inserter.insert(mergeCommit));
-    }
-    throw new MergeConflictException("merge conflict");
-  }
-
-  public static RevCommit createMergeCommit(
-      ObjectInserter inserter,
-      Config repoConfig,
-      RevCommit mergeTip,
-      RevCommit originalCommit,
-      String mergeStrategy,
-      PersonIdent committerIndent,
-      String commitMsg,
-      RevWalk rw)
-      throws IOException, MergeIdenticalTreeException, MergeConflictException {
-
-    if (!MergeStrategy.THEIRS.getName().equals(mergeStrategy)
-        && rw.isMergedInto(originalCommit, mergeTip)) {
-      throw new ChangeAlreadyMergedException(
-          "'" + originalCommit.getName() + "' has already been merged");
-    }
-
-    Merger m = newMerger(inserter, repoConfig, mergeStrategy);
-    if (m.merge(false, mergeTip, originalCommit)) {
-      ObjectId tree = m.getResultTreeId();
-
-      CommitBuilder mergeCommit = new CommitBuilder();
-      mergeCommit.setTreeId(tree);
-      mergeCommit.setParentIds(mergeTip, originalCommit);
-      mergeCommit.setAuthor(committerIndent);
-      mergeCommit.setCommitter(committerIndent);
-      mergeCommit.setMessage(commitMsg);
-      return rw.parseCommit(inserter.insert(mergeCommit));
-    }
-    List<String> conflicts = ImmutableList.of();
-    if (m instanceof ResolveMerger) {
-      conflicts = ((ResolveMerger) m).getUnmergedPaths();
-    }
-    throw new MergeConflictException(createConflictMessage(conflicts));
-  }
-
-  public static String createConflictMessage(List<String> conflicts) {
-    StringBuilder sb = new StringBuilder("merge conflict(s)");
-    for (String c : conflicts) {
-      sb.append('\n' + c);
-    }
-    return sb.toString();
-  }
-
-  /**
-   * Adds footers to existing commit message based on the state of the change.
-   *
-   * <p>This adds the following footers if they are missing:
-   *
-   * <ul>
-   *   <li>Reviewed-on: <i>url</i>
-   *   <li>Reviewed-by | Tested-by | <i>Other-Label-Name</i>: <i>reviewer</i>
-   *   <li>Change-Id
-   * </ul>
-   *
-   * @param n
-   * @param notes
-   * @param user
-   * @param psId
-   * @return new message
-   */
-  private String createDetailedCommitMessage(
-      RevCommit n, ChangeNotes notes, CurrentUser user, PatchSet.Id psId) {
-    Change c = notes.getChange();
-    final List<FooterLine> footers = n.getFooterLines();
-    final StringBuilder msgbuf = new StringBuilder();
-    msgbuf.append(n.getFullMessage());
-
-    if (msgbuf.length() == 0) {
-      // WTF, an empty commit message?
-      msgbuf.append("<no commit message provided>");
-    }
-    if (msgbuf.charAt(msgbuf.length() - 1) != '\n') {
-      // Missing a trailing LF? Correct it (perhaps the editor was broken).
-      msgbuf.append('\n');
-    }
-    if (footers.isEmpty()) {
-      // Doesn't end in a "Signed-off-by: ..." style line? Add another line
-      // break to start a new paragraph for the reviewed-by tag lines.
-      //
-      msgbuf.append('\n');
-    }
-
-    if (!contains(footers, FooterConstants.CHANGE_ID, c.getKey().get())) {
-      msgbuf.append(FooterConstants.CHANGE_ID.getName());
-      msgbuf.append(": ");
-      msgbuf.append(c.getKey().get());
-      msgbuf.append('\n');
-    }
-
-    final String siteUrl = urlProvider.get();
-    if (siteUrl != null) {
-      final String url = siteUrl + c.getId().get();
-      if (!contains(footers, FooterConstants.REVIEWED_ON, url)) {
-        msgbuf.append(FooterConstants.REVIEWED_ON.getName());
-        msgbuf.append(": ");
-        msgbuf.append(url);
-        msgbuf.append('\n');
-      }
-    }
-
-    PatchSetApproval submitAudit = null;
-
-    for (PatchSetApproval a : safeGetApprovals(notes, user, psId)) {
-      if (a.getValue() <= 0) {
-        // Negative votes aren't counted.
-        continue;
-      }
-
-      if (a.isLegacySubmit()) {
-        // Submit is treated specially, below (becomes committer)
-        //
-        if (submitAudit == null || a.getGranted().compareTo(submitAudit.getGranted()) > 0) {
-          submitAudit = a;
-        }
-        continue;
-      }
-
-      final Account acc = identifiedUserFactory.create(a.getAccountId()).getAccount();
-      final StringBuilder identbuf = new StringBuilder();
-      if (acc.getFullName() != null && acc.getFullName().length() > 0) {
-        if (identbuf.length() > 0) {
-          identbuf.append(' ');
-        }
-        identbuf.append(acc.getFullName());
-      }
-      if (acc.getPreferredEmail() != null && acc.getPreferredEmail().length() > 0) {
-        if (isSignedOffBy(footers, acc.getPreferredEmail())) {
-          continue;
-        }
-        if (identbuf.length() > 0) {
-          identbuf.append(' ');
-        }
-        identbuf.append('<');
-        identbuf.append(acc.getPreferredEmail());
-        identbuf.append('>');
-      }
-      if (identbuf.length() == 0) {
-        // Nothing reasonable to describe them by? Ignore them.
-        continue;
-      }
-
-      final String tag;
-      if (isCodeReview(a.getLabelId())) {
-        tag = "Reviewed-by";
-      } else if (isVerified(a.getLabelId())) {
-        tag = "Tested-by";
-      } else {
-        final LabelType lt = project.getLabelTypes().byLabel(a.getLabelId());
-        if (lt == null) {
-          continue;
-        }
-        tag = lt.getName();
-      }
-
-      if (!contains(footers, new FooterKey(tag), identbuf.toString())) {
-        msgbuf.append(tag);
-        msgbuf.append(": ");
-        msgbuf.append(identbuf);
-        msgbuf.append('\n');
-      }
-    }
-    return msgbuf.toString();
-  }
-
-  public String createCommitMessageOnSubmit(CodeReviewCommit n, RevCommit mergeTip) {
-    return createCommitMessageOnSubmit(
-        n,
-        mergeTip,
-        n.notes(),
-        identifiedUserFactory.create(n.notes().getChange().getOwner()),
-        n.getPatchsetId());
-  }
-
-  /**
-   * Creates a commit message for a change, which can be customized by plugins.
-   *
-   * <p>By default, adds footers to existing commit message based on the state of the change.
-   * Plugins implementing {@link ChangeMessageModifier} can modify the resulting commit message
-   * arbitrarily.
-   *
-   * @param n
-   * @param mergeTip
-   * @param notes
-   * @param user
-   * @param id
-   * @return new message
-   */
-  public String createCommitMessageOnSubmit(
-      RevCommit n, RevCommit mergeTip, ChangeNotes notes, CurrentUser user, Id id) {
-    return commitMessageGenerator.generate(
-        n, mergeTip, notes.getChange().getDest(), createDetailedCommitMessage(n, notes, user, id));
-  }
-
-  private static boolean isCodeReview(LabelId id) {
-    return "Code-Review".equalsIgnoreCase(id.get());
-  }
-
-  private static boolean isVerified(LabelId id) {
-    return "Verified".equalsIgnoreCase(id.get());
-  }
-
-  private Iterable<PatchSetApproval> safeGetApprovals(
-      ChangeNotes notes, CurrentUser user, PatchSet.Id psId) {
-    try {
-      return approvalsUtil.byPatchSet(db.get(), notes, user, psId, null, null);
-    } catch (OrmException e) {
-      log.error("Can't read approval records for " + psId, e);
-      return Collections.emptyList();
-    }
-  }
-
-  private static boolean contains(List<FooterLine> footers, FooterKey key, String val) {
-    for (FooterLine line : footers) {
-      if (line.matches(key) && val.equals(line.getValue())) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  private static boolean isSignedOffBy(List<FooterLine> footers, String email) {
-    for (FooterLine line : footers) {
-      if (line.matches(FooterKey.SIGNED_OFF_BY) && email.equals(line.getEmailAddress())) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  public boolean canMerge(
-      MergeSorter mergeSorter, Repository repo, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
-      throws IntegrationException {
-    if (hasMissingDependencies(mergeSorter, toMerge)) {
-      return false;
-    }
-
-    try (ObjectInserter ins = new InMemoryInserter(repo)) {
-      return newThreeWayMerger(ins, repo.getConfig()).merge(new AnyObjectId[] {mergeTip, toMerge});
-    } catch (LargeObjectException e) {
-      log.warn("Cannot merge due to LargeObjectException: " + toMerge.name());
-      return false;
-    } catch (NoMergeBaseException e) {
-      return false;
-    } catch (IOException e) {
-      throw new IntegrationException("Cannot merge " + toMerge.name(), e);
-    }
-  }
-
-  public boolean canFastForward(
-      MergeSorter mergeSorter,
-      CodeReviewCommit mergeTip,
-      CodeReviewRevWalk rw,
-      CodeReviewCommit toMerge)
-      throws IntegrationException {
-    if (hasMissingDependencies(mergeSorter, toMerge)) {
-      return false;
-    }
-
-    try {
-      return mergeTip == null
-          || rw.isMergedInto(mergeTip, toMerge)
-          || rw.isMergedInto(toMerge, mergeTip);
-    } catch (IOException e) {
-      throw new IntegrationException("Cannot fast-forward test during merge", e);
-    }
-  }
-
-  public boolean canCherryPick(
-      MergeSorter mergeSorter,
-      Repository repo,
-      CodeReviewCommit mergeTip,
-      CodeReviewRevWalk rw,
-      CodeReviewCommit toMerge)
-      throws IntegrationException {
-    if (mergeTip == null) {
-      // The branch is unborn. Fast-forward is possible.
-      //
-      return true;
-    }
-
-    if (toMerge.getParentCount() == 0) {
-      // Refuse to merge a root commit into an existing branch,
-      // we cannot obtain a delta for the cherry-pick to apply.
-      //
-      return false;
-    }
-
-    if (toMerge.getParentCount() == 1) {
-      // If there is only one parent, a cherry-pick can be done by
-      // taking the delta relative to that one parent and redoing
-      // that on the current merge tip.
-      //
-      try (ObjectInserter ins = new InMemoryInserter(repo)) {
-        ThreeWayMerger m = newThreeWayMerger(ins, repo.getConfig());
-        m.setBase(toMerge.getParent(0));
-        return m.merge(mergeTip, toMerge);
-      } catch (IOException e) {
-        throw new IntegrationException(
-            String.format(
-                "Cannot merge commit %s with mergetip %s", toMerge.name(), mergeTip.name()),
-            e);
-      }
-    }
-
-    // There are multiple parents, so this is a merge commit. We
-    // don't want to cherry-pick the merge as clients can't easily
-    // rebase their history with that merge present and replaced
-    // by an equivalent merge with a different first parent. So
-    // instead behave as though MERGE_IF_NECESSARY was configured.
-    //
-    return canFastForward(mergeSorter, mergeTip, rw, toMerge)
-        || canMerge(mergeSorter, repo, mergeTip, toMerge);
-  }
-
-  public boolean hasMissingDependencies(MergeSorter mergeSorter, CodeReviewCommit toMerge)
-      throws IntegrationException {
-    try {
-      return !mergeSorter.sort(Collections.singleton(toMerge)).contains(toMerge);
-    } catch (IOException e) {
-      throw new IntegrationException("Branch head sorting failed", e);
-    }
-  }
-
-  public CodeReviewCommit mergeOneCommit(
-      PersonIdent author,
-      PersonIdent committer,
-      CodeReviewRevWalk rw,
-      ObjectInserter inserter,
-      Config repoConfig,
-      Branch.NameKey destBranch,
-      CodeReviewCommit mergeTip,
-      CodeReviewCommit n)
-      throws IntegrationException {
-    ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
-    try {
-      if (m.merge(new AnyObjectId[] {mergeTip, n})) {
-        return writeMergeCommit(
-            author, committer, rw, inserter, destBranch, mergeTip, m.getResultTreeId(), n);
-      }
-      failed(rw, mergeTip, n, CommitMergeStatus.PATH_CONFLICT);
-    } catch (NoMergeBaseException e) {
-      try {
-        failed(rw, mergeTip, n, getCommitMergeStatus(e.getReason()));
-      } catch (IOException e2) {
-        throw new IntegrationException("Cannot merge " + n.name(), e);
-      }
-    } catch (IOException e) {
-      throw new IntegrationException("Cannot merge " + n.name(), e);
-    }
-    return mergeTip;
-  }
-
-  private static CommitMergeStatus getCommitMergeStatus(MergeBaseFailureReason reason) {
-    switch (reason) {
-      case MULTIPLE_MERGE_BASES_NOT_SUPPORTED:
-      case TOO_MANY_MERGE_BASES:
-      default:
-        return CommitMergeStatus.MANUAL_RECURSIVE_MERGE;
-      case CONFLICTS_DURING_MERGE_BASE_CALCULATION:
-        return CommitMergeStatus.PATH_CONFLICT;
-    }
-  }
-
-  private static CodeReviewCommit failed(
-      CodeReviewRevWalk rw,
-      CodeReviewCommit mergeTip,
-      CodeReviewCommit n,
-      CommitMergeStatus failure)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException {
-    rw.reset();
-    rw.markStart(n);
-    rw.markUninteresting(mergeTip);
-    CodeReviewCommit failed;
-    while ((failed = rw.next()) != null) {
-      failed.setStatusCode(failure);
-    }
-    return failed;
-  }
-
-  public CodeReviewCommit writeMergeCommit(
-      PersonIdent author,
-      PersonIdent committer,
-      CodeReviewRevWalk rw,
-      ObjectInserter inserter,
-      Branch.NameKey destBranch,
-      CodeReviewCommit mergeTip,
-      ObjectId treeId,
-      CodeReviewCommit n)
-      throws IOException, MissingObjectException, IncorrectObjectTypeException {
-    final List<CodeReviewCommit> merged = new ArrayList<>();
-    rw.reset();
-    rw.markStart(n);
-    rw.markUninteresting(mergeTip);
-    CodeReviewCommit crc;
-    while ((crc = rw.next()) != null) {
-      if (crc.getPatchsetId() != null) {
-        merged.add(crc);
-      }
-    }
-
-    StringBuilder msgbuf = new StringBuilder().append(summarize(rw, merged));
-    if (!R_HEADS_MASTER.equals(destBranch.get())) {
-      msgbuf.append(" into ");
-      msgbuf.append(destBranch.getShortName());
-    }
-
-    if (merged.size() > 1) {
-      msgbuf.append("\n\n* changes:\n");
-      for (CodeReviewCommit c : merged) {
-        rw.parseBody(c);
-        msgbuf.append("  ");
-        msgbuf.append(c.getShortMessage());
-        msgbuf.append("\n");
-      }
-    }
-
-    final CommitBuilder mergeCommit = new CommitBuilder();
-    mergeCommit.setTreeId(treeId);
-    mergeCommit.setParentIds(mergeTip, n);
-    mergeCommit.setAuthor(author);
-    mergeCommit.setCommitter(committer);
-    mergeCommit.setMessage(msgbuf.toString());
-
-    CodeReviewCommit mergeResult = rw.parseCommit(inserter.insert(mergeCommit));
-    mergeResult.setNotes(n.getNotes());
-    return mergeResult;
-  }
-
-  private String summarize(RevWalk rw, List<CodeReviewCommit> merged) throws IOException {
-    if (merged.size() == 1) {
-      CodeReviewCommit c = merged.get(0);
-      rw.parseBody(c);
-      return String.format("Merge \"%s\"", c.getShortMessage());
-    }
-
-    LinkedHashSet<String> topics = new LinkedHashSet<>(4);
-    for (CodeReviewCommit c : merged) {
-      if (!Strings.isNullOrEmpty(c.change().getTopic())) {
-        topics.add(c.change().getTopic());
-      }
-    }
-
-    if (topics.size() == 1) {
-      return String.format("Merge changes from topic \"%s\"", Iterables.getFirst(topics, null));
-    } else if (topics.size() > 1) {
-      return String.format("Merge changes from topics \"%s\"", Joiner.on("\", \"").join(topics));
-    } else {
-      return String.format(
-          "Merge changes %s%s",
-          FluentIterable.from(merged)
-              .limit(5)
-              .transform(c -> c.change().getKey().abbreviate())
-              .join(Joiner.on(',')),
-          merged.size() > 5 ? ", ..." : "");
-    }
-  }
-
-  public ThreeWayMerger newThreeWayMerger(ObjectInserter inserter, Config repoConfig) {
-    return newThreeWayMerger(inserter, repoConfig, mergeStrategyName());
-  }
-
-  public String mergeStrategyName() {
-    return mergeStrategyName(useContentMerge, useRecursiveMerge);
-  }
-
-  public static String mergeStrategyName(boolean useContentMerge, boolean useRecursiveMerge) {
-    if (useContentMerge) {
-      // Settings for this project allow us to try and automatically resolve
-      // conflicts within files if needed. Use either the old resolve merger or
-      // new recursive merger, and instruct to operate in core.
-      if (useRecursiveMerge) {
-        return MergeStrategy.RECURSIVE.getName();
-      }
-      return MergeStrategy.RESOLVE.getName();
-    }
-    // No auto conflict resolving allowed. If any of the
-    // affected files was modified, merge will fail.
-    return MergeStrategy.SIMPLE_TWO_WAY_IN_CORE.getName();
-  }
-
-  public static ThreeWayMerger newThreeWayMerger(
-      ObjectInserter inserter, Config repoConfig, String strategyName) {
-    Merger m = newMerger(inserter, repoConfig, strategyName);
-    checkArgument(
-        m instanceof ThreeWayMerger,
-        "merge strategy %s does not support three-way merging",
-        strategyName);
-    return (ThreeWayMerger) m;
-  }
-
-  public static Merger newMerger(ObjectInserter inserter, Config repoConfig, String strategyName) {
-    MergeStrategy strategy = MergeStrategy.get(strategyName);
-    checkArgument(strategy != null, "invalid merge strategy: %s", strategyName);
-    return strategy.newMerger(
-        new ObjectInserter.Filter() {
-          @Override
-          protected ObjectInserter delegate() {
-            return inserter;
-          }
-
-          @Override
-          public void flush() {}
-
-          @Override
-          public void close() {}
-        },
-        repoConfig);
-  }
-
-  public void markCleanMerges(
-      RevWalk rw, RevFlag canMergeFlag, CodeReviewCommit mergeTip, Set<RevCommit> alreadyAccepted)
-      throws IntegrationException {
-    if (mergeTip == null) {
-      // If mergeTip is null here, branchTip was null, indicating a new branch
-      // at the start of the merge process. We also elected to merge nothing,
-      // probably due to missing dependencies. Nothing was cleanly merged.
-      //
-      return;
-    }
-
-    try {
-      rw.resetRetain(canMergeFlag);
-      rw.sort(RevSort.TOPO);
-      rw.sort(RevSort.REVERSE, true);
-      rw.markStart(mergeTip);
-      for (RevCommit c : alreadyAccepted) {
-        // If branch was not created by this submit.
-        if (!Objects.equals(c, mergeTip)) {
-          rw.markUninteresting(c);
-        }
-      }
-
-      CodeReviewCommit c;
-      while ((c = (CodeReviewCommit) rw.next()) != null) {
-        if (c.getPatchsetId() != null && c.getStatusCode() == null) {
-          c.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
-        }
-      }
-    } catch (IOException e) {
-      throw new IntegrationException("Cannot mark clean merges", e);
-    }
-  }
-
-  public Set<Change.Id> findUnmergedChanges(
-      Set<Change.Id> expected,
-      CodeReviewRevWalk rw,
-      RevFlag canMergeFlag,
-      CodeReviewCommit oldTip,
-      CodeReviewCommit mergeTip,
-      Iterable<Change.Id> alreadyMerged)
-      throws IntegrationException {
-    if (mergeTip == null) {
-      return expected;
-    }
-
-    try {
-      Set<Change.Id> found = Sets.newHashSetWithExpectedSize(expected.size());
-      Iterables.addAll(found, alreadyMerged);
-      rw.resetRetain(canMergeFlag);
-      rw.sort(RevSort.TOPO);
-      rw.markStart(mergeTip);
-      if (oldTip != null) {
-        rw.markUninteresting(oldTip);
-      }
-
-      CodeReviewCommit c;
-      while ((c = rw.next()) != null) {
-        if (c.getPatchsetId() == null) {
-          continue;
-        }
-        Change.Id id = c.getPatchsetId().getParentKey();
-        if (!expected.contains(id)) {
-          continue;
-        }
-        found.add(id);
-        if (found.size() == expected.size()) {
-          return Collections.emptySet();
-        }
-      }
-      return Sets.difference(expected, found);
-    } catch (IOException e) {
-      throw new IntegrationException("Cannot check if changes were merged", e);
-    }
-  }
-
-  public static CodeReviewCommit findAnyMergedInto(
-      CodeReviewRevWalk rw, Iterable<CodeReviewCommit> commits, CodeReviewCommit tip)
-      throws IOException {
-    for (CodeReviewCommit c : commits) {
-      // TODO(dborowitz): Seems like this could get expensive for many patch
-      // sets. Is there a more efficient implementation?
-      if (rw.isMergedInto(c, tip)) {
-        return c;
-      }
-    }
-    return null;
-  }
-
-  public static RevCommit resolveCommit(Repository repo, RevWalk rw, String str)
-      throws BadRequestException, ResourceNotFoundException, IOException {
-    try {
-      ObjectId commitId = repo.resolve(str);
-      if (commitId == null) {
-        throw new BadRequestException("Cannot resolve '" + str + "' to a commit");
-      }
-      return rw.parseCommit(commitId);
-    } catch (AmbiguousObjectException | IncorrectObjectTypeException | RevisionSyntaxException e) {
-      throw new BadRequestException(e.getMessage());
-    } catch (MissingObjectException e) {
-      throw new ResourceNotFoundException(e.getMessage());
-    }
-  }
-
-  private static void matchAuthorToCommitterDate(ProjectState project, CommitBuilder commit) {
-    if (project.isMatchAuthorToCommitterDate()) {
-      commit.setAuthor(
-          new PersonIdent(
-              commit.getAuthor(),
-              commit.getCommitter().getWhen(),
-              commit.getCommitter().getTimeZone()));
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
deleted file mode 100644
index df6f5bb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
+++ /dev/null
@@ -1,70 +0,0 @@
-//Copyright (C) 2015 The Android Open Source Project
-//
-//Licensed under the Apache License, Version 2.0 (the "License");
-//you may not use this file except in compliance with the License.
-//You may obtain a copy of the License at
-//
-//http://www.apache.org/licenses/LICENSE-2.0
-//
-//Unless required by applicable law or agreed to in writing, software
-//distributed under the License is distributed on an "AS IS" BASIS,
-//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//See the License for the specific language governing permissions and
-//limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.RepositoryConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.nio.file.Path;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class MultiBaseLocalDiskRepositoryManager extends LocalDiskRepositoryManager {
-
-  public static class Module extends LifecycleModule {
-    @Override
-    protected void configure() {
-      bind(GitRepositoryManager.class).to(MultiBaseLocalDiskRepositoryManager.class);
-      listener().to(MultiBaseLocalDiskRepositoryManager.Lifecycle.class);
-    }
-  }
-
-  private final RepositoryConfig config;
-
-  @Inject
-  MultiBaseLocalDiskRepositoryManager(
-      SitePaths site, @GerritServerConfig Config cfg, RepositoryConfig config) {
-    super(site, cfg);
-    this.config = config;
-
-    for (Path alternateBasePath : config.getAllBasePaths()) {
-      checkState(
-          alternateBasePath.isAbsolute(),
-          "repository.<name>.basePath must be absolute: %s",
-          alternateBasePath);
-    }
-  }
-
-  @Override
-  public Path getBasePath(NameKey name) {
-    Path alternateBasePath = config.getBasePath(name);
-    return alternateBasePath != null ? alternateBasePath : super.getBasePath(name);
-  }
-
-  @Override
-  protected void scanProjects(ProjectVisitor visitor) {
-    super.scanProjects(visitor);
-    for (Path path : config.getAllBasePaths()) {
-      visitor.setStartFolder(path);
-      super.scanProjects(visitor);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
deleted file mode 100644
index 33f9b7d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
+++ /dev/null
@@ -1,1531 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.common.data.Permission.isPermission;
-
-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;
-import com.google.common.primitives.Shorts;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.ContributorAgreement;
-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.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.common.data.PermissionRule.Action;
-import com.google.gerrit.common.data.RefConfigSection;
-import com.google.gerrit.common.data.SubscribeSection;
-import com.google.gerrit.common.errors.InvalidNameException;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ProjectState;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.PluginConfig;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.project.CommentLinkInfoImpl;
-import com.google.gerrit.server.project.RefPattern;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Set;
-import java.util.regex.Pattern;
-import java.util.regex.PatternSyntaxException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.transport.RefSpec;
-import org.eclipse.jgit.util.StringUtils;
-
-public class ProjectConfig extends VersionedMetaData implements ValidationError.Sink {
-  public static final String COMMENTLINK = "commentlink";
-  private static final String KEY_MATCH = "match";
-  private static final String KEY_HTML = "html";
-  private static final String KEY_LINK = "link";
-  private static final String KEY_ENABLED = "enabled";
-
-  public static final String PROJECT_CONFIG = "project.config";
-
-  private static final String PROJECT = "project";
-  private static final String KEY_DESCRIPTION = "description";
-  private static final String KEY_MATCH_AUTHOR_DATE_WITH_COMMITTER_DATE =
-      "matchAuthorToCommitterDate";
-
-  public static final String ACCESS = "access";
-  private static final String KEY_INHERIT_FROM = "inheritFrom";
-  private static final String KEY_GROUP_PERMISSIONS = "exclusiveGroupPermissions";
-
-  private static final String ACCOUNTS = "accounts";
-  private static final String KEY_SAME_GROUP_VISIBILITY = "sameGroupVisibility";
-
-  private static final String BRANCH_ORDER = "branchOrder";
-  private static final String BRANCH = "branch";
-
-  private static final String CONTRIBUTOR_AGREEMENT = "contributor-agreement";
-  private static final String KEY_ACCEPTED = "accepted";
-  private static final String KEY_AUTO_VERIFY = "autoVerify";
-  private static final String KEY_AGREEMENT_URL = "agreementUrl";
-
-  private static final String NOTIFY = "notify";
-  private static final String KEY_EMAIL = "email";
-  private static final String KEY_FILTER = "filter";
-  private static final String KEY_TYPE = "type";
-  private static final String KEY_HEADER = "header";
-
-  private static final String CAPABILITY = "capability";
-
-  private static final String RECEIVE = "receive";
-  private static final String KEY_REQUIRE_SIGNED_OFF_BY = "requireSignedOffBy";
-  private static final String KEY_REQUIRE_CHANGE_ID = "requireChangeId";
-  private static final String KEY_USE_ALL_NOT_IN_TARGET = "createNewChangeForAllNotInTarget";
-  private static final String KEY_MAX_OBJECT_SIZE_LIMIT = "maxObjectSizeLimit";
-  private static final String KEY_REQUIRE_CONTRIBUTOR_AGREEMENT = "requireContributorAgreement";
-  private static final String KEY_CHECK_RECEIVED_OBJECTS = "checkReceivedObjects";
-  private static final String KEY_ENABLE_SIGNED_PUSH = "enableSignedPush";
-  private static final String KEY_REQUIRE_SIGNED_PUSH = "requireSignedPush";
-  private static final String KEY_REJECT_IMPLICIT_MERGES = "rejectImplicitMerges";
-
-  private static final String CHANGE = "change";
-  private static final String KEY_PRIVATE_BY_DEFAULT = "privateByDefault";
-
-  private static final String SUBMIT = "submit";
-  private static final String KEY_ACTION = "action";
-  private static final String KEY_MERGE_CONTENT = "mergeContent";
-  private static final String KEY_STATE = "state";
-
-  private static final String SUBSCRIBE_SECTION = "allowSuperproject";
-  private static final String SUBSCRIBE_MATCH_REFS = "matching";
-  private static final String SUBSCRIBE_MULTI_MATCH_REFS = "all";
-
-  private static final String DASHBOARD = "dashboard";
-  private static final String KEY_DEFAULT = "default";
-  private static final String KEY_LOCAL_DEFAULT = "local-default";
-
-  private static final String LABEL = "label";
-  private static final String KEY_FUNCTION = "function";
-  private static final String KEY_DEFAULT_VALUE = "defaultValue";
-  private static final String KEY_COPY_MIN_SCORE = "copyMinScore";
-  private static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
-  private static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
-  private static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
-      "copyAllScoresOnMergeFirstParentUpdate";
-  private static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE =
-      "copyAllScoresOnTrivialRebase";
-  private static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
-  private static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
-  private static final String KEY_VALUE = "value";
-  private static final String KEY_CAN_OVERRIDE = "canOverride";
-  private static final String KEY_BRANCH = "branch";
-  private static final ImmutableSet<String> LABEL_FUNCTIONS =
-      ImmutableSet.of(
-          "MaxWithBlock", "AnyWithBlock", "MaxNoBlock", "NoBlock", "NoOp", "PatchSetLock");
-
-  private static final String REVIEWER = "reviewer";
-  private static final String KEY_ENABLE_REVIEWER_BY_EMAIL = "enableByEmail";
-
-  private static final String LEGACY_PERMISSION_PUSH_TAG = "pushTag";
-  private static final String LEGACY_PERMISSION_PUSH_SIGNED_TAG = "pushSignedTag";
-
-  private static final String PLUGIN = "plugin";
-
-  private static final SubmitType DEFAULT_SUBMIT_ACTION = SubmitType.MERGE_IF_NECESSARY;
-  private static final ProjectState DEFAULT_STATE_VALUE = ProjectState.ACTIVE;
-
-  private static final String EXTENSION_PANELS = "extension-panels";
-  private static final String KEY_PANEL = "panel";
-
-  private Project.NameKey projectName;
-  private Project project;
-  private AccountsSection accountsSection;
-  private GroupList groupList;
-  private Map<String, AccessSection> accessSections;
-  private BranchOrderSection branchOrderSection;
-  private Map<String, ContributorAgreement> contributorAgreements;
-  private Map<String, NotifyConfig> notifySections;
-  private Map<String, LabelType> labelSections;
-  private ConfiguredMimeTypes mimeTypes;
-  private Map<Project.NameKey, SubscribeSection> subscribeSections;
-  private List<CommentLinkInfoImpl> commentLinkSections;
-  private List<ValidationError> validationErrors;
-  private ObjectId rulesId;
-  private long maxObjectSizeLimit;
-  private Map<String, Config> pluginConfigs;
-  private boolean checkReceivedObjects;
-  private Set<String> sectionsWithUnknownPermissions;
-  private boolean hasLegacyPermissions;
-  private Map<String, List<String>> extensionPanelSections;
-  private Map<String, GroupReference> groupsByName;
-
-  public static ProjectConfig read(MetaDataUpdate update)
-      throws IOException, ConfigInvalidException {
-    ProjectConfig r = new ProjectConfig(update.getProjectName());
-    r.load(update);
-    return r;
-  }
-
-  public static ProjectConfig read(MetaDataUpdate update, ObjectId id)
-      throws IOException, ConfigInvalidException {
-    ProjectConfig r = new ProjectConfig(update.getProjectName());
-    r.load(update, id);
-    return r;
-  }
-
-  public static CommentLinkInfoImpl buildCommentLink(Config cfg, String name, boolean allowRaw)
-      throws IllegalArgumentException {
-    String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
-    if (match != null) {
-      // Unfortunately this validation isn't entirely complete. Clients
-      // can have exceptions trying to evaluate the pattern if they don't
-      // support a token used, even if the server does support the token.
-      //
-      // At the minimum, we can trap problems related to unmatched groups.
-      Pattern.compile(match);
-    }
-
-    String link = cfg.getString(COMMENTLINK, name, KEY_LINK);
-    String html = cfg.getString(COMMENTLINK, name, KEY_HTML);
-    boolean hasHtml = !Strings.isNullOrEmpty(html);
-
-    String rawEnabled = cfg.getString(COMMENTLINK, name, KEY_ENABLED);
-    Boolean enabled;
-    if (rawEnabled != null) {
-      enabled = cfg.getBoolean(COMMENTLINK, name, KEY_ENABLED, true);
-    } else {
-      enabled = null;
-    }
-    checkArgument(allowRaw || !hasHtml, "Raw html replacement not allowed");
-
-    if (Strings.isNullOrEmpty(match)
-        && Strings.isNullOrEmpty(link)
-        && !hasHtml
-        && enabled != null) {
-      if (enabled) {
-        return new CommentLinkInfoImpl.Enabled(name);
-      }
-      return new CommentLinkInfoImpl.Disabled(name);
-    }
-    return new CommentLinkInfoImpl(name, match, link, html, enabled);
-  }
-
-  public ProjectConfig(Project.NameKey projectName) {
-    this.projectName = projectName;
-  }
-
-  public Project.NameKey getName() {
-    return projectName;
-  }
-
-  public Project getProject() {
-    return project;
-  }
-
-  public AccountsSection getAccountsSection() {
-    return accountsSection;
-  }
-
-  public Map<String, List<String>> getExtensionPanelSections() {
-    return extensionPanelSections;
-  }
-
-  public AccessSection getAccessSection(String name) {
-    return getAccessSection(name, false);
-  }
-
-  public AccessSection getAccessSection(String name, boolean create) {
-    AccessSection as = accessSections.get(name);
-    if (as == null && create) {
-      as = new AccessSection(name);
-      accessSections.put(name, as);
-    }
-    return as;
-  }
-
-  public Collection<AccessSection> getAccessSections() {
-    return sort(accessSections.values());
-  }
-
-  public BranchOrderSection getBranchOrderSection() {
-    return branchOrderSection;
-  }
-
-  public Map<Project.NameKey, SubscribeSection> getSubscribeSections() {
-    return subscribeSections;
-  }
-
-  public Collection<SubscribeSection> getSubscribeSections(Branch.NameKey branch) {
-    Collection<SubscribeSection> ret = new ArrayList<>();
-    for (SubscribeSection s : subscribeSections.values()) {
-      if (s.appliesTo(branch)) {
-        ret.add(s);
-      }
-    }
-    return ret;
-  }
-
-  public void addSubscribeSection(SubscribeSection s) {
-    subscribeSections.put(s.getProject(), s);
-  }
-
-  public void remove(AccessSection section) {
-    if (section != null) {
-      String name = section.getName();
-      if (sectionsWithUnknownPermissions.contains(name)) {
-        AccessSection a = accessSections.get(name);
-        a.setPermissions(new ArrayList<Permission>());
-      } else {
-        accessSections.remove(name);
-      }
-    }
-  }
-
-  public void remove(AccessSection section, Permission permission) {
-    if (permission == null) {
-      remove(section);
-    } else if (section != null) {
-      AccessSection a = accessSections.get(section.getName());
-      a.remove(permission);
-      if (a.getPermissions().isEmpty()) {
-        remove(a);
-      }
-    }
-  }
-
-  public void remove(AccessSection section, Permission permission, PermissionRule rule) {
-    if (rule == null) {
-      remove(section, permission);
-    } else if (section != null && permission != null) {
-      AccessSection a = accessSections.get(section.getName());
-      if (a == null) {
-        return;
-      }
-      Permission p = a.getPermission(permission.getName());
-      if (p == null) {
-        return;
-      }
-      p.remove(rule);
-      if (p.getRules().isEmpty()) {
-        a.remove(permission);
-      }
-      if (a.getPermissions().isEmpty()) {
-        remove(a);
-      }
-    }
-  }
-
-  public void replace(AccessSection section) {
-    for (Permission permission : section.getPermissions()) {
-      for (PermissionRule rule : permission.getRules()) {
-        rule.setGroup(resolve(rule.getGroup()));
-      }
-    }
-
-    accessSections.put(section.getName(), section);
-  }
-
-  public ContributorAgreement getContributorAgreement(String name) {
-    return getContributorAgreement(name, false);
-  }
-
-  public ContributorAgreement getContributorAgreement(String name, boolean create) {
-    ContributorAgreement ca = contributorAgreements.get(name);
-    if (ca == null && create) {
-      ca = new ContributorAgreement(name);
-      contributorAgreements.put(name, ca);
-    }
-    return ca;
-  }
-
-  public Collection<ContributorAgreement> getContributorAgreements() {
-    return sort(contributorAgreements.values());
-  }
-
-  public void remove(ContributorAgreement section) {
-    if (section != null) {
-      accessSections.remove(section.getName());
-    }
-  }
-
-  public void replace(ContributorAgreement section) {
-    section.setAutoVerify(resolve(section.getAutoVerify()));
-    for (PermissionRule rule : section.getAccepted()) {
-      rule.setGroup(resolve(rule.getGroup()));
-    }
-
-    contributorAgreements.put(section.getName(), section);
-  }
-
-  public Collection<NotifyConfig> getNotifyConfigs() {
-    return notifySections.values();
-  }
-
-  public void putNotifyConfig(String name, NotifyConfig nc) {
-    notifySections.put(name, nc);
-  }
-
-  public Map<String, LabelType> getLabelSections() {
-    return labelSections;
-  }
-
-  public Collection<CommentLinkInfoImpl> getCommentLinkSections() {
-    return commentLinkSections;
-  }
-
-  public ConfiguredMimeTypes getMimeTypes() {
-    return mimeTypes;
-  }
-
-  public GroupReference resolve(AccountGroup group) {
-    return resolve(GroupReference.forGroup(group));
-  }
-
-  public GroupReference resolve(GroupReference group) {
-    GroupReference groupRef = groupList.resolve(group);
-    if (groupRef != null
-        && groupRef.getUUID() != null
-        && !groupsByName.containsKey(groupRef.getName())) {
-      groupsByName.put(groupRef.getName(), groupRef);
-    }
-    return groupRef;
-  }
-
-  /** @return the group reference, if the group is used by at least one rule. */
-  public GroupReference getGroup(AccountGroup.UUID uuid) {
-    return groupList.byUUID(uuid);
-  }
-
-  /**
-   * @return the group reference corresponding to the specified group name if the group is used by
-   *     at least one rule or plugin value.
-   */
-  public GroupReference getGroup(String groupName) {
-    return groupsByName.get(groupName);
-  }
-
-  /** @return set of all groups used by this configuration. */
-  public Set<AccountGroup.UUID> getAllGroupUUIDs() {
-    return groupList.uuids();
-  }
-
-  /**
-   * @return the project's rules.pl ObjectId, if present in the branch. Null if it doesn't exist.
-   */
-  public ObjectId getRulesId() {
-    return rulesId;
-  }
-
-  /**
-   * @return the maxObjectSizeLimit for this project, if set. Zero if this project doesn't define
-   *     own maxObjectSizeLimit.
-   */
-  public long getMaxObjectSizeLimit() {
-    return maxObjectSizeLimit;
-  }
-
-  /** @return the checkReceivedObjects for this project, default is true. */
-  public boolean getCheckReceivedObjects() {
-    return checkReceivedObjects;
-  }
-
-  /**
-   * Check all GroupReferences use current group name, repairing stale ones.
-   *
-   * @param groupBackend cache to use when looking up group information by UUID.
-   * @return true if one or more group names was stale.
-   */
-  public boolean updateGroupNames(GroupBackend groupBackend) {
-    boolean dirty = false;
-    for (GroupReference ref : groupList.references()) {
-      GroupDescription.Basic g = groupBackend.get(ref.getUUID());
-      if (g != null && !g.getName().equals(ref.getName())) {
-        dirty = true;
-        ref.setName(g.getName());
-      }
-    }
-    return dirty;
-  }
-
-  /**
-   * Get the validation errors, if any were discovered during load.
-   *
-   * @return list of errors; empty list if there are no errors.
-   */
-  public List<ValidationError> getValidationErrors() {
-    if (validationErrors != null) {
-      return Collections.unmodifiableList(validationErrors);
-    }
-    return Collections.emptyList();
-  }
-
-  @Override
-  protected String getRefName() {
-    return RefNames.REFS_CONFIG;
-  }
-
-  @Override
-  protected void onLoad() throws IOException, ConfigInvalidException {
-    readGroupList();
-    groupsByName = mapGroupReferences();
-
-    rulesId = getObjectId("rules.pl");
-    Config rc = readConfig(PROJECT_CONFIG);
-    project = new Project(projectName);
-
-    Project p = project;
-    p.setDescription(rc.getString(PROJECT, null, KEY_DESCRIPTION));
-    if (p.getDescription() == null) {
-      p.setDescription("");
-    }
-
-    if (rc.getStringList(ACCESS, null, KEY_INHERIT_FROM).length > 1) {
-      // The config must not contain more than one parent to inherit from
-      // as there is no guarantee which of the parents would be used then.
-      error(new ValidationError(PROJECT_CONFIG, "Cannot inherit from multiple projects"));
-    }
-    p.setParentName(rc.getString(ACCESS, null, KEY_INHERIT_FROM));
-
-    p.setUseContributorAgreements(
-        getEnum(rc, RECEIVE, null, KEY_REQUIRE_CONTRIBUTOR_AGREEMENT, InheritableBoolean.INHERIT));
-    p.setUseSignedOffBy(
-        getEnum(rc, RECEIVE, null, KEY_REQUIRE_SIGNED_OFF_BY, InheritableBoolean.INHERIT));
-    p.setRequireChangeID(
-        getEnum(rc, RECEIVE, null, KEY_REQUIRE_CHANGE_ID, InheritableBoolean.INHERIT));
-    p.setCreateNewChangeForAllNotInTarget(
-        getEnum(rc, RECEIVE, null, KEY_USE_ALL_NOT_IN_TARGET, InheritableBoolean.INHERIT));
-    p.setEnableSignedPush(
-        getEnum(rc, RECEIVE, null, KEY_ENABLE_SIGNED_PUSH, InheritableBoolean.INHERIT));
-    p.setRequireSignedPush(
-        getEnum(rc, RECEIVE, null, KEY_REQUIRE_SIGNED_PUSH, InheritableBoolean.INHERIT));
-    p.setMaxObjectSizeLimit(rc.getString(RECEIVE, null, KEY_MAX_OBJECT_SIZE_LIMIT));
-    p.setRejectImplicitMerges(
-        getEnum(rc, RECEIVE, null, KEY_REJECT_IMPLICIT_MERGES, InheritableBoolean.INHERIT));
-
-    p.setPrivateByDefault(
-        getEnum(rc, CHANGE, null, KEY_PRIVATE_BY_DEFAULT, InheritableBoolean.INHERIT));
-
-    p.setEnableReviewerByEmail(
-        getEnum(rc, REVIEWER, null, KEY_ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.INHERIT));
-
-    p.setSubmitType(getEnum(rc, SUBMIT, null, KEY_ACTION, DEFAULT_SUBMIT_ACTION));
-    p.setUseContentMerge(getEnum(rc, SUBMIT, null, KEY_MERGE_CONTENT, InheritableBoolean.INHERIT));
-    p.setMatchAuthorToCommitterDate(
-        getEnum(
-            rc,
-            SUBMIT,
-            null,
-            KEY_MATCH_AUTHOR_DATE_WITH_COMMITTER_DATE,
-            InheritableBoolean.INHERIT));
-    p.setState(getEnum(rc, PROJECT, null, KEY_STATE, DEFAULT_STATE_VALUE));
-
-    p.setDefaultDashboard(rc.getString(DASHBOARD, null, KEY_DEFAULT));
-    p.setLocalDefaultDashboard(rc.getString(DASHBOARD, null, KEY_LOCAL_DEFAULT));
-
-    loadAccountsSection(rc);
-    loadContributorAgreements(rc);
-    loadAccessSections(rc);
-    loadBranchOrderSection(rc);
-    loadNotifySections(rc);
-    loadLabelSections(rc);
-    loadCommentLinkSections(rc);
-    loadSubscribeSections(rc);
-    mimeTypes = new ConfiguredMimeTypes(projectName.get(), rc);
-    loadPluginSections(rc);
-    loadReceiveSection(rc);
-    loadExtensionPanelSections(rc);
-  }
-
-  private void loadAccountsSection(Config rc) {
-    accountsSection = new AccountsSection();
-    accountsSection.setSameGroupVisibility(
-        loadPermissionRules(rc, ACCOUNTS, null, KEY_SAME_GROUP_VISIBILITY, groupsByName, false));
-  }
-
-  private void loadExtensionPanelSections(Config rc) {
-    Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
-    extensionPanelSections = Maps.newLinkedHashMap();
-    for (String name : rc.getSubsections(EXTENSION_PANELS)) {
-      String lower = name.toLowerCase();
-      if (lowerNames.containsKey(lower)) {
-        error(
-            new ValidationError(
-                PROJECT_CONFIG,
-                String.format(
-                    "Extension Panels \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
-      }
-      lowerNames.put(lower, name);
-      extensionPanelSections.put(
-          name,
-          new ArrayList<>(Arrays.asList(rc.getStringList(EXTENSION_PANELS, name, KEY_PANEL))));
-    }
-  }
-
-  private void loadContributorAgreements(Config rc) {
-    contributorAgreements = new HashMap<>();
-    for (String name : rc.getSubsections(CONTRIBUTOR_AGREEMENT)) {
-      ContributorAgreement ca = getContributorAgreement(name, true);
-      ca.setDescription(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_DESCRIPTION));
-      ca.setAgreementUrl(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_AGREEMENT_URL));
-      ca.setAccepted(
-          loadPermissionRules(rc, CONTRIBUTOR_AGREEMENT, name, KEY_ACCEPTED, groupsByName, false));
-
-      List<PermissionRule> rules =
-          loadPermissionRules(
-              rc, CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY, groupsByName, false);
-      if (rules.isEmpty()) {
-        ca.setAutoVerify(null);
-      } else if (rules.size() > 1) {
-        error(
-            new ValidationError(
-                PROJECT_CONFIG,
-                "Invalid rule in "
-                    + CONTRIBUTOR_AGREEMENT
-                    + "."
-                    + name
-                    + "."
-                    + KEY_AUTO_VERIFY
-                    + ": at most one group may be set"));
-      } else if (rules.get(0).getAction() != Action.ALLOW) {
-        error(
-            new ValidationError(
-                PROJECT_CONFIG,
-                "Invalid rule in "
-                    + CONTRIBUTOR_AGREEMENT
-                    + "."
-                    + name
-                    + "."
-                    + KEY_AUTO_VERIFY
-                    + ": the group must be allowed"));
-      } else {
-        ca.setAutoVerify(rules.get(0).getGroup());
-      }
-    }
-  }
-
-  /**
-   * Parses the [notify] sections out of the configuration file.
-   *
-   * <pre>
-   *   [notify "reviewers"]
-   *     email = group Reviewers
-   *     type = new_changes
-   *
-   *   [notify "dev-team"]
-   *     email = dev-team@example.com
-   *     filter = branch:master
-   *
-   *   [notify "qa"]
-   *     email = qa@example.com
-   *     filter = branch:\"^(maint|stable)-.*\"
-   *     type = submitted_changes
-   * </pre>
-   */
-  private void loadNotifySections(Config rc) {
-    notifySections = new HashMap<>();
-    for (String sectionName : rc.getSubsections(NOTIFY)) {
-      NotifyConfig n = new NotifyConfig();
-      n.setName(sectionName);
-      n.setFilter(rc.getString(NOTIFY, sectionName, KEY_FILTER));
-
-      EnumSet<NotifyType> types = EnumSet.noneOf(NotifyType.class);
-      types.addAll(ConfigUtil.getEnumList(rc, NOTIFY, sectionName, KEY_TYPE, NotifyType.ALL));
-      n.setTypes(types);
-      n.setHeader(rc.getEnum(NOTIFY, sectionName, KEY_HEADER, NotifyConfig.Header.BCC));
-
-      for (String dst : rc.getStringList(NOTIFY, sectionName, KEY_EMAIL)) {
-        String groupName = GroupReference.extractGroupName(dst);
-        if (groupName != null) {
-          GroupReference ref = groupsByName.get(groupName);
-          if (ref == null) {
-            ref = new GroupReference(null, groupName);
-            groupsByName.put(ref.getName(), ref);
-          }
-          if (ref.getUUID() != null) {
-            n.addEmail(ref);
-          } else {
-            error(
-                new ValidationError(
-                    PROJECT_CONFIG,
-                    "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
-          }
-        } else if (dst.startsWith("user ")) {
-          error(new ValidationError(PROJECT_CONFIG, dst + " not supported"));
-        } else {
-          try {
-            n.addEmail(Address.parse(dst));
-          } catch (IllegalArgumentException err) {
-            error(
-                new ValidationError(
-                    PROJECT_CONFIG,
-                    "notify section \"" + sectionName + "\" has invalid email \"" + dst + "\""));
-          }
-        }
-      }
-      notifySections.put(sectionName, n);
-    }
-  }
-
-  private void loadAccessSections(Config rc) {
-    accessSections = new HashMap<>();
-    sectionsWithUnknownPermissions = new HashSet<>();
-    for (String refName : rc.getSubsections(ACCESS)) {
-      if (RefConfigSection.isValid(refName) && isValidRegex(refName)) {
-        AccessSection as = getAccessSection(refName, true);
-
-        for (String varName : rc.getStringList(ACCESS, refName, KEY_GROUP_PERMISSIONS)) {
-          for (String n : varName.split("[, \t]{1,}")) {
-            n = convertLegacyPermission(n);
-            if (isPermission(n)) {
-              as.getPermission(n, true).setExclusiveGroup(true);
-            }
-          }
-        }
-
-        for (String varName : rc.getNames(ACCESS, refName)) {
-          String convertedName = convertLegacyPermission(varName);
-          if (isPermission(convertedName)) {
-            Permission perm = as.getPermission(convertedName, true);
-            loadPermissionRules(
-                rc,
-                ACCESS,
-                refName,
-                varName,
-                groupsByName,
-                perm,
-                Permission.hasRange(convertedName));
-          } else {
-            sectionsWithUnknownPermissions.add(as.getName());
-          }
-        }
-      }
-    }
-
-    AccessSection capability = null;
-    for (String varName : rc.getNames(CAPABILITY)) {
-      if (capability == null) {
-        capability = new AccessSection(AccessSection.GLOBAL_CAPABILITIES);
-        accessSections.put(AccessSection.GLOBAL_CAPABILITIES, capability);
-      }
-      Permission perm = capability.getPermission(varName, true);
-      loadPermissionRules(
-          rc, CAPABILITY, null, varName, groupsByName, perm, GlobalCapability.hasRange(varName));
-    }
-  }
-
-  private boolean isValidRegex(String refPattern) {
-    try {
-      RefPattern.validateRegExp(refPattern);
-    } catch (InvalidNameException e) {
-      error(new ValidationError(PROJECT_CONFIG, "Invalid ref name: " + e.getMessage()));
-      return false;
-    }
-    return true;
-  }
-
-  private void loadBranchOrderSection(Config rc) {
-    if (rc.getSections().contains(BRANCH_ORDER)) {
-      branchOrderSection = new BranchOrderSection(rc.getStringList(BRANCH_ORDER, null, BRANCH));
-    }
-  }
-
-  private List<PermissionRule> loadPermissionRules(
-      Config rc,
-      String section,
-      String subsection,
-      String varName,
-      Map<String, GroupReference> groupsByName,
-      boolean useRange) {
-    Permission perm = new Permission(varName);
-    loadPermissionRules(rc, section, subsection, varName, groupsByName, perm, useRange);
-    return perm.getRules();
-  }
-
-  private void loadPermissionRules(
-      Config rc,
-      String section,
-      String subsection,
-      String varName,
-      Map<String, GroupReference> groupsByName,
-      Permission perm,
-      boolean useRange) {
-    for (String ruleString : rc.getStringList(section, subsection, varName)) {
-      PermissionRule rule;
-      try {
-        rule = PermissionRule.fromString(ruleString, useRange);
-      } catch (IllegalArgumentException notRule) {
-        error(
-            new ValidationError(
-                PROJECT_CONFIG,
-                "Invalid rule in "
-                    + section
-                    + (subsection != null ? "." + subsection : "")
-                    + "."
-                    + varName
-                    + ": "
-                    + notRule.getMessage()));
-        continue;
-      }
-
-      GroupReference ref = groupsByName.get(rule.getGroup().getName());
-      if (ref == null) {
-        // The group wasn't mentioned in the groups table, so there is
-        // no valid UUID for it. Pool the reference anyway so at least
-        // all rules in the same file share the same GroupReference.
-        //
-        ref = rule.getGroup();
-        groupsByName.put(ref.getName(), ref);
-        error(
-            new ValidationError(
-                PROJECT_CONFIG, "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
-      }
-
-      rule.setGroup(ref);
-      perm.add(rule);
-    }
-  }
-
-  private static LabelValue parseLabelValue(String src) {
-    List<String> parts =
-        ImmutableList.copyOf(
-            Splitter.on(CharMatcher.whitespace()).omitEmptyStrings().limit(2).split(src));
-    if (parts.isEmpty()) {
-      throw new IllegalArgumentException("empty value");
-    }
-    String valueText = parts.size() > 1 ? parts.get(1) : "";
-    return new LabelValue(Shorts.checkedCast(PermissionRule.parseInt(parts.get(0))), valueText);
-  }
-
-  private void loadLabelSections(Config rc) {
-    Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
-    labelSections = new LinkedHashMap<>();
-    for (String name : rc.getSubsections(LABEL)) {
-      String lower = name.toLowerCase();
-      if (lowerNames.containsKey(lower)) {
-        error(
-            new ValidationError(
-                PROJECT_CONFIG,
-                String.format("Label \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
-      }
-      lowerNames.put(lower, name);
-
-      List<LabelValue> values = new ArrayList<>();
-      for (String value : rc.getStringList(LABEL, name, KEY_VALUE)) {
-        try {
-          values.add(parseLabelValue(value));
-        } catch (IllegalArgumentException notValue) {
-          error(
-              new ValidationError(
-                  PROJECT_CONFIG,
-                  String.format(
-                      "Invalid %s \"%s\" for label \"%s\": %s",
-                      KEY_VALUE, value, name, notValue.getMessage())));
-        }
-      }
-
-      LabelType label;
-      try {
-        label = new LabelType(name, values);
-      } catch (IllegalArgumentException badName) {
-        error(new ValidationError(PROJECT_CONFIG, String.format("Invalid label \"%s\"", name)));
-        continue;
-      }
-
-      String functionName =
-          MoreObjects.firstNonNull(rc.getString(LABEL, name, KEY_FUNCTION), "MaxWithBlock");
-      if (LABEL_FUNCTIONS.contains(functionName)) {
-        label.setFunctionName(functionName);
-      } else {
-        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);
-      }
-
-      if (!values.isEmpty()) {
-        short dv = (short) rc.getInt(LABEL, name, KEY_DEFAULT_VALUE, 0);
-        if (isInRange(dv, values)) {
-          label.setDefaultValue(dv);
-        } else {
-          error(
-              new ValidationError(
-                  PROJECT_CONFIG,
-                  String.format(
-                      "Invalid %s \"%s\" for label \"%s\"", KEY_DEFAULT_VALUE, dv, name)));
-        }
-      }
-      label.setAllowPostSubmit(
-          rc.getBoolean(LABEL, name, KEY_ALLOW_POST_SUBMIT, LabelType.DEF_ALLOW_POST_SUBMIT));
-      label.setCopyMinScore(
-          rc.getBoolean(LABEL, name, KEY_COPY_MIN_SCORE, LabelType.DEF_COPY_MIN_SCORE));
-      label.setCopyMaxScore(
-          rc.getBoolean(LABEL, name, KEY_COPY_MAX_SCORE, LabelType.DEF_COPY_MAX_SCORE));
-      label.setCopyAllScoresOnMergeFirstParentUpdate(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
-              LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE));
-      label.setCopyAllScoresOnTrivialRebase(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
-              LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE));
-      label.setCopyAllScoresIfNoCodeChange(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
-              LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE));
-      label.setCopyAllScoresIfNoChange(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
-              LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE));
-      label.setCanOverride(
-          rc.getBoolean(LABEL, name, KEY_CAN_OVERRIDE, LabelType.DEF_CAN_OVERRIDE));
-      label.setRefPatterns(getStringListOrNull(rc, LABEL, name, KEY_BRANCH));
-      labelSections.put(name, label);
-    }
-  }
-
-  private boolean isInRange(short value, List<LabelValue> labelValues) {
-    for (LabelValue lv : labelValues) {
-      if (lv.getValue() == value) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  private List<String> getStringListOrNull(
-      Config rc, String section, String subSection, String name) {
-    String[] ac = rc.getStringList(section, subSection, name);
-    return ac.length == 0 ? null : Arrays.asList(ac);
-  }
-
-  private void loadCommentLinkSections(Config rc) {
-    Set<String> subsections = rc.getSubsections(COMMENTLINK);
-    commentLinkSections = Lists.newArrayListWithCapacity(subsections.size());
-    for (String name : subsections) {
-      try {
-        commentLinkSections.add(buildCommentLink(rc, name, false));
-      } catch (PatternSyntaxException e) {
-        error(
-            new ValidationError(
-                PROJECT_CONFIG,
-                String.format(
-                    "Invalid pattern \"%s\" in commentlink.%s.match: %s",
-                    rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
-      } catch (IllegalArgumentException e) {
-        error(
-            new ValidationError(
-                PROJECT_CONFIG,
-                String.format(
-                    "Error in pattern \"%s\" in commentlink.%s.match: %s",
-                    rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
-      }
-    }
-    commentLinkSections = ImmutableList.copyOf(commentLinkSections);
-  }
-
-  private void loadSubscribeSections(Config rc) throws ConfigInvalidException {
-    Set<String> subsections = rc.getSubsections(SUBSCRIBE_SECTION);
-    subscribeSections = new HashMap<>();
-    try {
-      for (String projectName : subsections) {
-        Project.NameKey p = new Project.NameKey(projectName);
-        SubscribeSection ss = new SubscribeSection(p);
-        for (String s :
-            rc.getStringList(SUBSCRIBE_SECTION, projectName, SUBSCRIBE_MULTI_MATCH_REFS)) {
-          ss.addMultiMatchRefSpec(s);
-        }
-        for (String s : rc.getStringList(SUBSCRIBE_SECTION, projectName, SUBSCRIBE_MATCH_REFS)) {
-          ss.addMatchingRefSpec(s);
-        }
-        subscribeSections.put(p, ss);
-      }
-    } catch (IllegalArgumentException e) {
-      throw new ConfigInvalidException(e.getMessage());
-    }
-  }
-
-  private void loadReceiveSection(Config rc) {
-    checkReceivedObjects = rc.getBoolean(RECEIVE, KEY_CHECK_RECEIVED_OBJECTS, true);
-    maxObjectSizeLimit = rc.getLong(RECEIVE, null, KEY_MAX_OBJECT_SIZE_LIMIT, 0);
-  }
-
-  private void loadPluginSections(Config rc) {
-    pluginConfigs = new HashMap<>();
-    for (String plugin : rc.getSubsections(PLUGIN)) {
-      Config pluginConfig = new Config();
-      pluginConfigs.put(plugin, pluginConfig);
-      for (String name : rc.getNames(PLUGIN, plugin)) {
-        String value = rc.getString(PLUGIN, plugin, name);
-        String groupName = GroupReference.extractGroupName(value);
-        if (groupName != null) {
-          GroupReference ref = groupsByName.get(groupName);
-          if (ref == null) {
-            error(
-                new ValidationError(
-                    PROJECT_CONFIG, "group \"" + groupName + "\" not in " + GroupList.FILE_NAME));
-          }
-          rc.setString(PLUGIN, plugin, name, value);
-        }
-        pluginConfig.setStringList(
-            PLUGIN, plugin, name, Arrays.asList(rc.getStringList(PLUGIN, plugin, name)));
-      }
-    }
-  }
-
-  public PluginConfig getPluginConfig(String pluginName) {
-    Config pluginConfig = pluginConfigs.get(pluginName);
-    if (pluginConfig == null) {
-      pluginConfig = new Config();
-      pluginConfigs.put(pluginName, pluginConfig);
-    }
-    return new PluginConfig(pluginName, pluginConfig, this);
-  }
-
-  private void readGroupList() throws IOException {
-    groupList = GroupList.parse(projectName, readUTF8(GroupList.FILE_NAME), this);
-  }
-
-  private Map<String, GroupReference> mapGroupReferences() {
-    Collection<GroupReference> references = groupList.references();
-    Map<String, GroupReference> result = new HashMap<>(references.size());
-    for (GroupReference ref : references) {
-      result.put(ref.getName(), ref);
-    }
-
-    return result;
-  }
-
-  @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
-    if (commit.getMessage() == null || "".equals(commit.getMessage())) {
-      commit.setMessage("Updated project configuration\n");
-    }
-
-    Config rc = readConfig(PROJECT_CONFIG);
-    Project p = project;
-
-    if (p.getDescription() != null && !p.getDescription().isEmpty()) {
-      rc.setString(PROJECT, null, KEY_DESCRIPTION, p.getDescription());
-    } else {
-      rc.unset(PROJECT, null, KEY_DESCRIPTION);
-    }
-    set(rc, ACCESS, null, KEY_INHERIT_FROM, p.getParentName());
-    set(
-        rc,
-        RECEIVE,
-        null,
-        KEY_REQUIRE_CONTRIBUTOR_AGREEMENT,
-        p.getUseContributorAgreements(),
-        InheritableBoolean.INHERIT);
-    set(
-        rc,
-        RECEIVE,
-        null,
-        KEY_REQUIRE_SIGNED_OFF_BY,
-        p.getUseSignedOffBy(),
-        InheritableBoolean.INHERIT);
-    set(
-        rc,
-        RECEIVE,
-        null,
-        KEY_REQUIRE_CHANGE_ID,
-        p.getRequireChangeID(),
-        InheritableBoolean.INHERIT);
-    set(
-        rc,
-        RECEIVE,
-        null,
-        KEY_USE_ALL_NOT_IN_TARGET,
-        p.getCreateNewChangeForAllNotInTarget(),
-        InheritableBoolean.INHERIT);
-    set(
-        rc,
-        RECEIVE,
-        null,
-        KEY_MAX_OBJECT_SIZE_LIMIT,
-        validMaxObjectSizeLimit(p.getMaxObjectSizeLimit()));
-    set(
-        rc,
-        RECEIVE,
-        null,
-        KEY_ENABLE_SIGNED_PUSH,
-        p.getEnableSignedPush(),
-        InheritableBoolean.INHERIT);
-    set(
-        rc,
-        RECEIVE,
-        null,
-        KEY_REQUIRE_SIGNED_PUSH,
-        p.getRequireSignedPush(),
-        InheritableBoolean.INHERIT);
-    set(
-        rc,
-        RECEIVE,
-        null,
-        KEY_REJECT_IMPLICIT_MERGES,
-        p.getRejectImplicitMerges(),
-        InheritableBoolean.INHERIT);
-
-    set(
-        rc,
-        CHANGE,
-        null,
-        KEY_PRIVATE_BY_DEFAULT,
-        p.getPrivateByDefault(),
-        InheritableBoolean.INHERIT);
-
-    set(
-        rc,
-        REVIEWER,
-        null,
-        KEY_ENABLE_REVIEWER_BY_EMAIL,
-        p.getEnableReviewerByEmail(),
-        InheritableBoolean.INHERIT);
-
-    set(rc, SUBMIT, null, KEY_ACTION, p.getSubmitType(), DEFAULT_SUBMIT_ACTION);
-    set(rc, SUBMIT, null, KEY_MERGE_CONTENT, p.getUseContentMerge(), InheritableBoolean.INHERIT);
-    set(
-        rc,
-        SUBMIT,
-        null,
-        KEY_MATCH_AUTHOR_DATE_WITH_COMMITTER_DATE,
-        p.getMatchAuthorToCommitterDate(),
-        InheritableBoolean.INHERIT);
-
-    set(rc, PROJECT, null, KEY_STATE, p.getState(), DEFAULT_STATE_VALUE);
-
-    set(rc, DASHBOARD, null, KEY_DEFAULT, p.getDefaultDashboard());
-    set(rc, DASHBOARD, null, KEY_LOCAL_DEFAULT, p.getLocalDefaultDashboard());
-
-    Set<AccountGroup.UUID> keepGroups = new HashSet<>();
-    saveAccountsSection(rc, keepGroups);
-    saveContributorAgreements(rc, keepGroups);
-    saveAccessSections(rc, keepGroups);
-    saveNotifySections(rc, keepGroups);
-    savePluginSections(rc, keepGroups);
-    groupList.retainUUIDs(keepGroups);
-    saveLabelSections(rc);
-    saveSubscribeSections(rc);
-
-    saveConfig(PROJECT_CONFIG, rc);
-    saveGroupList();
-    return true;
-  }
-
-  public static String validMaxObjectSizeLimit(String value) throws ConfigInvalidException {
-    if (value == null) {
-      return null;
-    }
-    value = value.trim();
-    if (value.isEmpty()) {
-      return null;
-    }
-    Config cfg = new Config();
-    cfg.fromText("[s]\nn=" + value);
-    try {
-      long s = cfg.getLong("s", "n", 0);
-      if (s < 0) {
-        throw new ConfigInvalidException(
-            String.format(
-                "Negative value '%s' not allowed as %s", value, KEY_MAX_OBJECT_SIZE_LIMIT));
-      }
-      if (s == 0) {
-        // return null for the default so that it is not persisted
-        return null;
-      }
-      return value;
-    } catch (IllegalArgumentException e) {
-      throw new ConfigInvalidException(
-          String.format("Value '%s' not parseable as a Long", value), e);
-    }
-  }
-
-  private void saveAccountsSection(Config rc, Set<AccountGroup.UUID> keepGroups) {
-    if (accountsSection != null) {
-      rc.setStringList(
-          ACCOUNTS,
-          null,
-          KEY_SAME_GROUP_VISIBILITY,
-          ruleToStringList(accountsSection.getSameGroupVisibility(), keepGroups));
-    }
-  }
-
-  private void saveContributorAgreements(Config rc, Set<AccountGroup.UUID> keepGroups) {
-    for (ContributorAgreement ca : sort(contributorAgreements.values())) {
-      set(rc, CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_DESCRIPTION, ca.getDescription());
-      set(rc, CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_AGREEMENT_URL, ca.getAgreementUrl());
-
-      if (ca.getAutoVerify() != null) {
-        if (ca.getAutoVerify().getUUID() != null) {
-          keepGroups.add(ca.getAutoVerify().getUUID());
-        }
-        String autoVerify = new PermissionRule(ca.getAutoVerify()).asString(false);
-        set(rc, CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_AUTO_VERIFY, autoVerify);
-      } else {
-        rc.unset(CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_AUTO_VERIFY);
-      }
-
-      rc.setStringList(
-          CONTRIBUTOR_AGREEMENT,
-          ca.getName(),
-          KEY_ACCEPTED,
-          ruleToStringList(ca.getAccepted(), keepGroups));
-    }
-  }
-
-  private void saveNotifySections(Config rc, Set<AccountGroup.UUID> keepGroups) {
-    for (NotifyConfig nc : sort(notifySections.values())) {
-      List<String> email = new ArrayList<>();
-      for (GroupReference gr : nc.getGroups()) {
-        if (gr.getUUID() != null) {
-          keepGroups.add(gr.getUUID());
-        }
-        email.add(new PermissionRule(gr).asString(false));
-      }
-      Collections.sort(email);
-
-      List<String> addrs = new ArrayList<>();
-      for (Address addr : nc.getAddresses()) {
-        addrs.add(addr.toString());
-      }
-      Collections.sort(addrs);
-      email.addAll(addrs);
-
-      set(rc, NOTIFY, nc.getName(), KEY_HEADER, nc.getHeader(), NotifyConfig.Header.BCC);
-      if (email.isEmpty()) {
-        rc.unset(NOTIFY, nc.getName(), KEY_EMAIL);
-      } else {
-        rc.setStringList(NOTIFY, nc.getName(), KEY_EMAIL, email);
-      }
-
-      if (nc.getNotify().equals(EnumSet.of(NotifyType.ALL))) {
-        rc.unset(NOTIFY, nc.getName(), KEY_TYPE);
-      } else {
-        List<String> types = Lists.newArrayListWithCapacity(4);
-        for (NotifyType t : NotifyType.values()) {
-          if (nc.isNotify(t)) {
-            types.add(StringUtils.toLowerCase(t.name()));
-          }
-        }
-        rc.setStringList(NOTIFY, nc.getName(), KEY_TYPE, types);
-      }
-
-      set(rc, NOTIFY, nc.getName(), KEY_FILTER, nc.getFilter());
-    }
-  }
-
-  private List<String> ruleToStringList(
-      List<PermissionRule> list, Set<AccountGroup.UUID> keepGroups) {
-    List<String> rules = new ArrayList<>();
-    for (PermissionRule rule : sort(list)) {
-      if (rule.getGroup().getUUID() != null) {
-        keepGroups.add(rule.getGroup().getUUID());
-      }
-      rules.add(rule.asString(false));
-    }
-    return rules;
-  }
-
-  private void saveAccessSections(Config rc, Set<AccountGroup.UUID> keepGroups) {
-    AccessSection capability = accessSections.get(AccessSection.GLOBAL_CAPABILITIES);
-    if (capability != null) {
-      Set<String> have = new HashSet<>();
-      for (Permission permission : sort(capability.getPermissions())) {
-        have.add(permission.getName().toLowerCase());
-
-        boolean needRange = GlobalCapability.hasRange(permission.getName());
-        List<String> rules = new ArrayList<>();
-        for (PermissionRule rule : sort(permission.getRules())) {
-          GroupReference group = resolve(rule.getGroup());
-          if (group.getUUID() != null) {
-            keepGroups.add(group.getUUID());
-          }
-          rules.add(rule.asString(needRange));
-        }
-        rc.setStringList(CAPABILITY, null, permission.getName(), rules);
-      }
-      for (String varName : rc.getNames(CAPABILITY)) {
-        if (!have.contains(varName.toLowerCase())) {
-          rc.unset(CAPABILITY, null, varName);
-        }
-      }
-    } else {
-      rc.unsetSection(CAPABILITY, null);
-    }
-
-    for (AccessSection as : sort(accessSections.values())) {
-      String refName = as.getName();
-      if (AccessSection.GLOBAL_CAPABILITIES.equals(refName)) {
-        continue;
-      }
-
-      StringBuilder doNotInherit = new StringBuilder();
-      for (Permission perm : sort(as.getPermissions())) {
-        if (perm.getExclusiveGroup()) {
-          if (0 < doNotInherit.length()) {
-            doNotInherit.append(' ');
-          }
-          doNotInherit.append(perm.getName());
-        }
-      }
-      if (0 < doNotInherit.length()) {
-        rc.setString(ACCESS, refName, KEY_GROUP_PERMISSIONS, doNotInherit.toString());
-      } else {
-        rc.unset(ACCESS, refName, KEY_GROUP_PERMISSIONS);
-      }
-
-      Set<String> have = new HashSet<>();
-      for (Permission permission : sort(as.getPermissions())) {
-        have.add(permission.getName().toLowerCase());
-
-        boolean needRange = Permission.hasRange(permission.getName());
-        List<String> rules = new ArrayList<>();
-        for (PermissionRule rule : sort(permission.getRules())) {
-          GroupReference group = resolve(rule.getGroup());
-          if (group.getUUID() != null) {
-            keepGroups.add(group.getUUID());
-          }
-          rules.add(rule.asString(needRange));
-        }
-        rc.setStringList(ACCESS, refName, permission.getName(), rules);
-      }
-
-      for (String varName : rc.getNames(ACCESS, refName)) {
-        if (isPermission(convertLegacyPermission(varName))
-            && !have.contains(varName.toLowerCase())) {
-          rc.unset(ACCESS, refName, varName);
-        }
-      }
-    }
-
-    for (String name : rc.getSubsections(ACCESS)) {
-      if (RefConfigSection.isValid(name) && !accessSections.containsKey(name)) {
-        rc.unsetSection(ACCESS, name);
-      }
-    }
-  }
-
-  private void saveLabelSections(Config rc) {
-    List<String> existing = Lists.newArrayList(rc.getSubsections(LABEL));
-    if (!Lists.newArrayList(labelSections.keySet()).equals(existing)) {
-      // Order of sections changed, remove and rewrite them all.
-      for (String name : existing) {
-        rc.unsetSection(LABEL, name);
-      }
-    }
-
-    Set<String> toUnset = Sets.newHashSet(existing);
-    for (Map.Entry<String, LabelType> e : labelSections.entrySet()) {
-      String name = e.getKey();
-      LabelType label = e.getValue();
-      toUnset.remove(name);
-      rc.setString(LABEL, name, KEY_FUNCTION, label.getFunctionName());
-      rc.setInt(LABEL, name, KEY_DEFAULT_VALUE, label.getDefaultValue());
-
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_ALLOW_POST_SUBMIT,
-          label.allowPostSubmit(),
-          LabelType.DEF_ALLOW_POST_SUBMIT);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_MIN_SCORE,
-          label.isCopyMinScore(),
-          LabelType.DEF_COPY_MIN_SCORE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_MAX_SCORE,
-          label.isCopyMaxScore(),
-          LabelType.DEF_COPY_MAX_SCORE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
-          label.isCopyAllScoresOnTrivialRebase(),
-          LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
-          label.isCopyAllScoresIfNoCodeChange(),
-          LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
-          label.isCopyAllScoresIfNoChange(),
-          LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
-          label.isCopyAllScoresOnMergeFirstParentUpdate(),
-          LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
-      setBooleanConfigKey(
-          rc, LABEL, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
-      List<String> values = Lists.newArrayListWithCapacity(label.getValues().size());
-      for (LabelValue value : label.getValues()) {
-        values.add(value.format());
-      }
-      rc.setStringList(LABEL, name, KEY_VALUE, values);
-    }
-
-    for (String name : toUnset) {
-      rc.unsetSection(LABEL, name);
-    }
-  }
-
-  private static void setBooleanConfigKey(
-      Config rc, String section, String name, String key, boolean value, boolean defaultValue) {
-    if (value == defaultValue) {
-      rc.unset(section, name, key);
-    } else {
-      rc.setBoolean(section, name, key, value);
-    }
-  }
-
-  private void savePluginSections(Config rc, Set<AccountGroup.UUID> keepGroups) {
-    List<String> existing = Lists.newArrayList(rc.getSubsections(PLUGIN));
-    for (String name : existing) {
-      rc.unsetSection(PLUGIN, name);
-    }
-
-    for (Entry<String, Config> e : pluginConfigs.entrySet()) {
-      String plugin = e.getKey();
-      Config pluginConfig = e.getValue();
-      for (String name : pluginConfig.getNames(PLUGIN, plugin)) {
-        String value = pluginConfig.getString(PLUGIN, plugin, name);
-        String groupName = GroupReference.extractGroupName(value);
-        if (groupName != null) {
-          GroupReference ref = groupsByName.get(groupName);
-          if (ref != null && ref.getUUID() != null) {
-            keepGroups.add(ref.getUUID());
-            pluginConfig.setString(PLUGIN, plugin, name, "group " + ref.getName());
-          }
-        }
-        rc.setStringList(
-            PLUGIN, plugin, name, Arrays.asList(pluginConfig.getStringList(PLUGIN, plugin, name)));
-      }
-    }
-  }
-
-  private void saveGroupList() throws IOException {
-    saveUTF8(GroupList.FILE_NAME, groupList.asText());
-  }
-
-  private void saveSubscribeSections(Config rc) {
-    for (Project.NameKey p : subscribeSections.keySet()) {
-      SubscribeSection s = subscribeSections.get(p);
-      List<String> matchings = new ArrayList<>();
-      for (RefSpec r : s.getMatchingRefSpecs()) {
-        matchings.add(r.toString());
-      }
-      rc.setStringList(SUBSCRIBE_SECTION, p.get(), SUBSCRIBE_MATCH_REFS, matchings);
-
-      List<String> multimatchs = new ArrayList<>();
-      for (RefSpec r : s.getMultiMatchRefSpecs()) {
-        multimatchs.add(r.toString());
-      }
-      rc.setStringList(SUBSCRIBE_SECTION, p.get(), SUBSCRIBE_MULTI_MATCH_REFS, multimatchs);
-    }
-  }
-
-  private <E extends Enum<?>> E getEnum(
-      Config rc, String section, String subsection, String name, E defaultValue) {
-    try {
-      return rc.getEnum(section, subsection, name, defaultValue);
-    } catch (IllegalArgumentException err) {
-      error(new ValidationError(PROJECT_CONFIG, err.getMessage()));
-      return defaultValue;
-    }
-  }
-
-  @Override
-  public void error(ValidationError error) {
-    if (validationErrors == null) {
-      validationErrors = new ArrayList<>(4);
-    }
-    validationErrors.add(error);
-  }
-
-  private static <T extends Comparable<? super T>> List<T> sort(Collection<T> m) {
-    ArrayList<T> r = new ArrayList<>(m);
-    Collections.sort(r);
-    return r;
-  }
-
-  public boolean hasLegacyPermissions() {
-    return hasLegacyPermissions;
-  }
-
-  private String convertLegacyPermission(String permissionName) {
-    switch (permissionName) {
-      case LEGACY_PERMISSION_PUSH_TAG:
-        hasLegacyPermissions = true;
-        return Permission.CREATE_TAG;
-      case LEGACY_PERMISSION_PUSH_SIGNED_TAG:
-        hasLegacyPermissions = true;
-        return Permission.CREATE_SIGNED_TAG;
-      default:
-        return permissionName;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
deleted file mode 100644
index abce2d2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ /dev/null
@@ -1,164 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.util.ManualRequestContext;
-import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.name.Named;
-import com.google.inject.util.Providers;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.ExecutionException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class SearchingChangeCacheImpl implements GitReferenceUpdatedListener {
-  private static final Logger log = LoggerFactory.getLogger(SearchingChangeCacheImpl.class);
-  static final String ID_CACHE = "changes";
-
-  public static class Module extends CacheModule {
-    private final boolean slave;
-
-    public Module() {
-      this(false);
-    }
-
-    public Module(boolean slave) {
-      this.slave = slave;
-    }
-
-    @Override
-    protected void configure() {
-      if (slave) {
-        bind(SearchingChangeCacheImpl.class)
-            .toProvider(Providers.<SearchingChangeCacheImpl>of(null));
-      } else {
-        cache(ID_CACHE, Project.NameKey.class, new TypeLiteral<List<CachedChange>>() {})
-            .maximumWeight(0)
-            .loader(Loader.class);
-
-        bind(SearchingChangeCacheImpl.class);
-        DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
-            .to(SearchingChangeCacheImpl.class);
-      }
-    }
-  }
-
-  @AutoValue
-  abstract static class CachedChange {
-    // Subset of fields in ChangeData, specifically fields needed to serve
-    // VisibleRefFilter without touching the database. More can be added as
-    // necessary.
-    abstract Change change();
-
-    @Nullable
-    abstract ReviewerSet reviewers();
-  }
-
-  private final LoadingCache<Project.NameKey, List<CachedChange>> cache;
-  private final ChangeData.Factory changeDataFactory;
-
-  @Inject
-  SearchingChangeCacheImpl(
-      @Named(ID_CACHE) LoadingCache<Project.NameKey, List<CachedChange>> cache,
-      ChangeData.Factory changeDataFactory) {
-    this.cache = cache;
-    this.changeDataFactory = changeDataFactory;
-  }
-
-  /**
-   * Read changes for the project from the secondary index.
-   *
-   * <p>Returned changes only include the {@code Change} object (with id, branch) and the reviewers.
-   * Additional stored fields are not loaded from the index.
-   *
-   * @param db database handle to populate missing change data (probably unused).
-   * @param project project to read.
-   * @return list of known changes; empty if no changes.
-   */
-  public List<ChangeData> getChangeData(ReviewDb db, Project.NameKey project) {
-    try {
-      List<CachedChange> cached = cache.get(project);
-      List<ChangeData> cds = new ArrayList<>(cached.size());
-      for (CachedChange cc : cached) {
-        ChangeData cd = changeDataFactory.create(db, cc.change());
-        cd.setReviewers(cc.reviewers());
-        cds.add(cd);
-      }
-      return Collections.unmodifiableList(cds);
-    } catch (ExecutionException e) {
-      log.warn("Cannot fetch changes for " + project, e);
-      return Collections.emptyList();
-    }
-  }
-
-  @Override
-  public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
-    if (event.getRefName().startsWith(RefNames.REFS_CHANGES)) {
-      cache.invalidate(new Project.NameKey(event.getProjectName()));
-    }
-  }
-
-  static class Loader extends CacheLoader<Project.NameKey, List<CachedChange>> {
-    private final OneOffRequestContext requestContext;
-    private final Provider<InternalChangeQuery> queryProvider;
-
-    @Inject
-    Loader(OneOffRequestContext requestContext, Provider<InternalChangeQuery> queryProvider) {
-      this.requestContext = requestContext;
-      this.queryProvider = queryProvider;
-    }
-
-    @Override
-    public List<CachedChange> load(Project.NameKey key) throws Exception {
-      try (ManualRequestContext ctx = requestContext.open()) {
-        List<ChangeData> cds =
-            queryProvider
-                .get()
-                .setRequestedFields(
-                    ImmutableSet.of(ChangeField.CHANGE.getName(), ChangeField.REVIEWER.getName()))
-                .byProject(key);
-        List<CachedChange> result = new ArrayList<>(cds.size());
-        for (ChangeData cd : cds) {
-          result.add(
-              new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.getReviewers()));
-        }
-        return Collections.unmodifiableList(result);
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
deleted file mode 100644
index 92edc47..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
+++ /dev/null
@@ -1,549 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import com.google.common.base.MoreObjects;
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.StringReader;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
-import org.eclipse.jgit.dircache.DirCache;
-import org.eclipse.jgit.dircache.DirCacheBuilder;
-import org.eclipse.jgit.dircache.DirCacheEditor;
-import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
-import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
-import org.eclipse.jgit.dircache.DirCacheEntry;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.AnyObjectId;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.FileMode;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.treewalk.TreeWalk;
-import org.eclipse.jgit.util.ChangeIdUtil;
-import org.eclipse.jgit.util.RawParseUtils;
-
-/**
- * Support for metadata stored within a version controlled branch.
- *
- * <p>Implementors are responsible for supplying implementations of the onLoad and onSave methods to
- * read from the repository, or format an update that can later be written back to the repository.
- */
-public abstract class VersionedMetaData {
-  /**
-   * Path information that does not hold references to any repository data structures, allowing the
-   * application to retain this object for long periods of time.
-   */
-  public static class PathInfo {
-    public final FileMode fileMode;
-    public final String path;
-    public final ObjectId objectId;
-
-    protected PathInfo(TreeWalk tw) {
-      fileMode = tw.getFileMode(0);
-      path = tw.getPathString();
-      objectId = tw.getObjectId(0);
-    }
-  }
-
-  protected RevCommit revision;
-  protected RevWalk rw;
-  protected ObjectReader reader;
-  protected ObjectInserter inserter;
-  protected DirCache newTree;
-
-  /** @return name of the reference storing this configuration. */
-  protected abstract String getRefName();
-
-  /** Set up the metadata, parsing any state from the loaded revision. */
-  protected abstract void onLoad() throws IOException, ConfigInvalidException;
-
-  /**
-   * Save any changes to the metadata in a commit.
-   *
-   * @return true if the commit should proceed, false to abort.
-   * @throws IOException
-   * @throws ConfigInvalidException
-   */
-  protected abstract boolean onSave(CommitBuilder commit)
-      throws IOException, ConfigInvalidException;
-
-  /** @return revision of the metadata that was loaded. */
-  public ObjectId getRevision() {
-    return revision != null ? revision.copy() : null;
-  }
-
-  /**
-   * Load the current version from the branch.
-   *
-   * <p>The repository is not held after the call completes, allowing the application to retain this
-   * object for long periods of time.
-   *
-   * @param db repository to access.
-   * @throws IOException
-   * @throws ConfigInvalidException
-   */
-  public void load(Repository db) throws IOException, ConfigInvalidException {
-    Ref ref = db.getRefDatabase().exactRef(getRefName());
-    load(db, ref != null ? ref.getObjectId() : null);
-  }
-
-  /**
-   * Load a specific version from the repository.
-   *
-   * <p>This method is primarily useful for applying updates to a specific revision that was shown
-   * to an end-user in the user interface. If there are conflicts with another user's concurrent
-   * changes, these will be automatically detected at commit time.
-   *
-   * <p>The repository is not held after the call completes, allowing the application to retain this
-   * object for long periods of time.
-   *
-   * @param db repository to access.
-   * @param id revision to load.
-   * @throws IOException
-   * @throws ConfigInvalidException
-   */
-  public void load(Repository db, ObjectId id) throws IOException, ConfigInvalidException {
-    try (RevWalk walk = new RevWalk(db)) {
-      load(walk, id);
-    }
-  }
-
-  /**
-   * Load a specific version from an open walk.
-   *
-   * <p>This method is primarily useful for applying updates to a specific revision that was shown
-   * to an end-user in the user interface. If there are conflicts with another user's concurrent
-   * changes, these will be automatically detected at commit time.
-   *
-   * <p>The caller retains ownership of the walk and is responsible for closing it. However, this
-   * instance does not hold a reference to the walk or the repository after the call completes,
-   * allowing the application to retain this object for long periods of time.
-   *
-   * @param walk open walk to access to access.
-   * @param id revision to load.
-   * @throws IOException
-   * @throws ConfigInvalidException
-   */
-  public void load(RevWalk walk, ObjectId id) throws IOException, ConfigInvalidException {
-    this.rw = walk;
-    this.reader = walk.getObjectReader();
-    try {
-      revision = id != null ? walk.parseCommit(id) : null;
-      onLoad();
-    } finally {
-      this.rw = null;
-      this.reader = null;
-    }
-  }
-
-  public void load(MetaDataUpdate update) throws IOException, ConfigInvalidException {
-    load(update.getRepository());
-  }
-
-  public void load(MetaDataUpdate update, ObjectId id) throws IOException, ConfigInvalidException {
-    load(update.getRepository(), id);
-  }
-
-  /**
-   * Update this metadata branch, recording a new commit on its reference.
-   *
-   * @param update helper information to define the update that will occur.
-   * @return the commit that was created
-   * @throws IOException if there is a storage problem and the update cannot be executed as
-   *     requested or if it failed because of a concurrent update to the same reference
-   */
-  public RevCommit commit(MetaDataUpdate update) throws IOException {
-    BatchMetaDataUpdate batch = openUpdate(update);
-    try {
-      batch.write(update.getCommitBuilder());
-      return batch.commit();
-    } finally {
-      batch.close();
-    }
-  }
-
-  /**
-   * Creates a new commit and a new ref based on this commit.
-   *
-   * @param update helper information to define the update that will occur.
-   * @param refName name of the ref that should be created
-   * @return the commit that was created
-   * @throws IOException if there is a storage problem and the update cannot be executed as
-   *     requested or if it failed because of a concurrent update to the same reference
-   */
-  public RevCommit commitToNewRef(MetaDataUpdate update, String refName) throws IOException {
-    BatchMetaDataUpdate batch = openUpdate(update);
-    try {
-      batch.write(update.getCommitBuilder());
-      return batch.createRef(refName);
-    } finally {
-      batch.close();
-    }
-  }
-
-  public interface BatchMetaDataUpdate {
-    void write(CommitBuilder commit) throws IOException;
-
-    void write(VersionedMetaData config, CommitBuilder commit) throws IOException;
-
-    RevCommit createRef(String refName) throws IOException;
-
-    RevCommit commit() throws IOException;
-
-    RevCommit commitAt(ObjectId revision) throws IOException;
-
-    void close();
-  }
-
-  /**
-   * Open a batch of updates to the same metadata ref.
-   *
-   * <p>This allows making multiple commits to a single metadata ref, at the end of which is a
-   * single ref update. For batching together updates to multiple refs (each consisting of one or
-   * more commits against their respective refs), create the {@link MetaDataUpdate} with a {@link
-   * BatchRefUpdate}.
-   *
-   * <p>A ref update produced by this {@link BatchMetaDataUpdate} is only committed if there is no
-   * associated {@link BatchRefUpdate}. As a result, the configured ref updated event is not fired
-   * if there is an associated batch.
-   *
-   * @param update helper info about the update.
-   * @throws IOException if the update failed.
-   */
-  public BatchMetaDataUpdate openUpdate(MetaDataUpdate update) throws IOException {
-    final Repository db = update.getRepository();
-
-    reader = db.newObjectReader();
-    inserter = db.newObjectInserter();
-    final RevWalk rw = new RevWalk(reader);
-    final RevTree tree = revision != null ? rw.parseTree(revision) : null;
-    newTree = readTree(tree);
-    return new BatchMetaDataUpdate() {
-      AnyObjectId src = revision;
-      AnyObjectId srcTree = tree;
-
-      @Override
-      public void write(CommitBuilder commit) throws IOException {
-        write(VersionedMetaData.this, commit);
-      }
-
-      private boolean doSave(VersionedMetaData config, CommitBuilder commit) throws IOException {
-        DirCache nt = config.newTree;
-        ObjectReader r = config.reader;
-        ObjectInserter i = config.inserter;
-        try {
-          config.newTree = newTree;
-          config.reader = reader;
-          config.inserter = inserter;
-          return config.onSave(commit);
-        } catch (ConfigInvalidException e) {
-          throw new IOException(
-              "Cannot update " + getRefName() + " in " + db.getDirectory() + ": " + e.getMessage(),
-              e);
-        } finally {
-          config.newTree = nt;
-          config.reader = r;
-          config.inserter = i;
-        }
-      }
-
-      @Override
-      public void write(VersionedMetaData config, CommitBuilder commit) throws IOException {
-        if (!doSave(config, commit)) {
-          return;
-        }
-
-        ObjectId res = newTree.writeTree(inserter);
-        if (res.equals(srcTree) && !update.allowEmpty() && (commit.getTreeId() == null)) {
-          // If there are no changes to the content, don't create the commit.
-          return;
-        }
-
-        // If changes are made to the DirCache and those changes are written as
-        // a commit and then the tree ID is set for the CommitBuilder, then
-        // those previous DirCache changes will be ignored and the commit's
-        // tree will be replaced with the ID in the CommitBuilder. The same is
-        // true if you explicitly set tree ID in a commit and then make changes
-        // to the DirCache; that tree ID will be ignored and replaced by that of
-        // the tree for the updated DirCache.
-        if (commit.getTreeId() == null) {
-          commit.setTreeId(res);
-        } else {
-          // In this case, the caller populated the tree without using DirCache.
-          res = commit.getTreeId();
-        }
-
-        if (src != null) {
-          commit.addParentId(src);
-        }
-
-        if (update.insertChangeId()) {
-          ObjectId id =
-              ChangeIdUtil.computeChangeId(
-                  res,
-                  getRevision(),
-                  commit.getAuthor(),
-                  commit.getCommitter(),
-                  commit.getMessage());
-          commit.setMessage(ChangeIdUtil.insertId(commit.getMessage(), id));
-        }
-
-        src = inserter.insert(commit);
-        srcTree = res;
-      }
-
-      @Override
-      public RevCommit createRef(String refName) throws IOException {
-        if (Objects.equals(src, revision)) {
-          return revision;
-        }
-        return updateRef(ObjectId.zeroId(), src, refName);
-      }
-
-      @Override
-      public RevCommit commit() throws IOException {
-        return commitAt(revision);
-      }
-
-      @Override
-      public RevCommit commitAt(ObjectId expected) throws IOException {
-        if (Objects.equals(src, expected)) {
-          return revision;
-        }
-        return updateRef(MoreObjects.firstNonNull(expected, ObjectId.zeroId()), src, getRefName());
-      }
-
-      @Override
-      public void close() {
-        newTree = null;
-
-        rw.close();
-        if (inserter != null) {
-          inserter.close();
-          inserter = null;
-        }
-
-        if (reader != null) {
-          reader.close();
-          reader = null;
-        }
-      }
-
-      private RevCommit updateRef(AnyObjectId oldId, AnyObjectId newId, String refName)
-          throws IOException {
-        BatchRefUpdate bru = update.getBatch();
-        if (bru != null) {
-          bru.addCommand(new ReceiveCommand(oldId.toObjectId(), newId.toObjectId(), refName));
-          inserter.flush();
-          revision = rw.parseCommit(newId);
-          return revision;
-        }
-
-        RefUpdate ru = db.updateRef(refName);
-        ru.setExpectedOldObjectId(oldId);
-        ru.setNewObjectId(newId);
-        ru.setRefLogIdent(update.getCommitBuilder().getAuthor());
-        String message = update.getCommitBuilder().getMessage();
-        if (message == null) {
-          message = "meta data update";
-        }
-        try (BufferedReader reader = new BufferedReader(new StringReader(message))) {
-          // read the subject line and use it as reflog message
-          ru.setRefLogMessage("commit: " + reader.readLine(), true);
-        }
-        inserter.flush();
-        RefUpdate.Result result = ru.update();
-        switch (result) {
-          case NEW:
-          case FAST_FORWARD:
-            revision = rw.parseCommit(ru.getNewObjectId());
-            update.fireGitRefUpdatedEvent(ru);
-            return revision;
-          case LOCK_FAILURE:
-            throw new LockFailureException(
-                "Cannot update "
-                    + ru.getName()
-                    + " in "
-                    + db.getDirectory()
-                    + ": "
-                    + ru.getResult(),
-                ru);
-          case FORCED:
-          case IO_FAILURE:
-          case NOT_ATTEMPTED:
-          case NO_CHANGE:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case RENAMED:
-          case REJECTED_MISSING_OBJECT:
-          case REJECTED_OTHER_REASON:
-          default:
-            throw new IOException(
-                "Cannot update "
-                    + ru.getName()
-                    + " in "
-                    + db.getDirectory()
-                    + ": "
-                    + ru.getResult());
-        }
-      }
-    };
-  }
-
-  protected DirCache readTree(RevTree tree)
-      throws IOException, MissingObjectException, IncorrectObjectTypeException {
-    DirCache dc = DirCache.newInCore();
-    if (tree != null) {
-      DirCacheBuilder b = dc.builder();
-      b.addTree(new byte[0], DirCacheEntry.STAGE_0, reader, tree);
-      b.finish();
-    }
-    return dc;
-  }
-
-  protected Config readConfig(String fileName) throws IOException, ConfigInvalidException {
-    Config rc = new Config();
-    String text = readUTF8(fileName);
-    if (!text.isEmpty()) {
-      try {
-        rc.fromText(text);
-      } catch (ConfigInvalidException err) {
-        StringBuilder msg =
-            new StringBuilder("Invalid config file ")
-                .append(fileName)
-                .append(" in commit ")
-                .append(revision.name());
-        if (err.getCause() != null) {
-          msg.append(": ").append(err.getCause());
-        }
-        throw new ConfigInvalidException(msg.toString(), err);
-      }
-    }
-    return rc;
-  }
-
-  protected String readUTF8(String fileName) throws IOException {
-    byte[] raw = readFile(fileName);
-    return raw.length != 0 ? RawParseUtils.decode(raw) : "";
-  }
-
-  protected byte[] readFile(String fileName) throws IOException {
-    if (revision == null) {
-      return new byte[] {};
-    }
-
-    TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree());
-    if (tw != null) {
-      ObjectLoader obj = reader.open(tw.getObjectId(0), Constants.OBJ_BLOB);
-      return obj.getCachedBytes(Integer.MAX_VALUE);
-    }
-    return new byte[] {};
-  }
-
-  protected ObjectId getObjectId(String fileName) throws IOException {
-    if (revision == null) {
-      return null;
-    }
-
-    TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree());
-    if (tw != null) {
-      return tw.getObjectId(0);
-    }
-
-    return null;
-  }
-
-  public List<PathInfo> getPathInfos(boolean recursive) throws IOException {
-    try (TreeWalk tw = new TreeWalk(reader)) {
-      tw.addTree(revision.getTree());
-      tw.setRecursive(recursive);
-      List<PathInfo> paths = new ArrayList<>();
-      while (tw.next()) {
-        paths.add(new PathInfo(tw));
-      }
-      return paths;
-    }
-  }
-
-  protected static void set(
-      Config rc, String section, String subsection, String name, String value) {
-    if (value != null) {
-      rc.setString(section, subsection, name, value);
-    } else {
-      rc.unset(section, subsection, name);
-    }
-  }
-
-  protected static void set(
-      Config rc, String section, String subsection, String name, boolean value) {
-    if (value) {
-      rc.setBoolean(section, subsection, name, value);
-    } else {
-      rc.unset(section, subsection, name);
-    }
-  }
-
-  protected static <E extends Enum<?>> void set(
-      Config rc, String section, String subsection, String name, E value, E defaultValue) {
-    if (value != defaultValue) {
-      rc.setEnum(section, subsection, name, value);
-    } else {
-      rc.unset(section, subsection, name);
-    }
-  }
-
-  protected void saveConfig(String fileName, Config cfg) throws IOException {
-    saveUTF8(fileName, cfg.toText());
-  }
-
-  protected void saveUTF8(String fileName, String text) throws IOException {
-    saveFile(fileName, text != null ? Constants.encode(text) : null);
-  }
-
-  protected void saveFile(String fileName, byte[] raw) throws IOException {
-    DirCacheEditor editor = newTree.editor();
-    if (raw != null && 0 < raw.length) {
-      final ObjectId blobId = inserter.insert(Constants.OBJ_BLOB, raw);
-      editor.add(
-          new PathEdit(fileName) {
-            @Override
-            public void apply(DirCacheEntry ent) {
-              ent.setFileMode(FileMode.REGULAR_FILE);
-              ent.setObjectId(blobId);
-            }
-          });
-    } else {
-      editor.add(new DeletePath(fileName));
-    }
-    editor.finish();
-  }
-}
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
deleted file mode 100644
index 92da95a8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
+++ /dev/null
@@ -1,370 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CACHE_AUTOMERGE;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS_SELF;
-import static java.util.stream.Collectors.toMap;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.stream.Stream;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.SymbolicRef;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.AbstractAdvertiseRefsHook;
-import org.eclipse.jgit.transport.ServiceMayNotContinueException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class VisibleRefFilter extends AbstractAdvertiseRefsHook {
-  private static final Logger log = LoggerFactory.getLogger(VisibleRefFilter.class);
-
-  public interface Factory {
-    VisibleRefFilter create(ProjectState projectState, Repository git);
-  }
-
-  private final TagCache tagCache;
-  private final ChangeNotes.Factory changeNotesFactory;
-  @Nullable private final SearchingChangeCacheImpl changeCache;
-  private final Provider<ReviewDb> db;
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final PermissionBackend.ForProject perm;
-  private final ProjectState projectState;
-  private final Repository git;
-  private ProjectControl projectCtl;
-  private boolean showMetadata = true;
-  private String userEditPrefix;
-  private Map<Change.Id, Branch.NameKey> visibleChanges;
-
-  @Inject
-  VisibleRefFilter(
-      TagCache tagCache,
-      ChangeNotes.Factory changeNotesFactory,
-      @Nullable SearchingChangeCacheImpl changeCache,
-      Provider<ReviewDb> db,
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      @Assisted ProjectState projectState,
-      @Assisted Repository git) {
-    this.tagCache = tagCache;
-    this.changeNotesFactory = changeNotesFactory;
-    this.changeCache = changeCache;
-    this.db = db;
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.perm =
-        permissionBackend.user(user).database(db).project(projectState.getProject().getNameKey());
-    this.projectState = projectState;
-    this.git = git;
-  }
-
-  /** Show change references. Default is {@code true}. */
-  public VisibleRefFilter setShowMetadata(boolean show) {
-    showMetadata = show;
-    return this;
-  }
-
-  public Map<String, Ref> filter(Map<String, Ref> refs, boolean filterTagsSeparately) {
-    if (projectState.isAllUsers()) {
-      refs = addUsersSelfSymref(refs);
-    }
-
-    PermissionBackend.WithUser withUser = permissionBackend.user(user);
-    PermissionBackend.ForProject forProject = withUser.project(projectState.getNameKey());
-    if (!projectState.isAllUsers()) {
-      if (checkProjectPermission(forProject, ProjectPermission.READ)) {
-        return refs;
-      } else if (checkProjectPermission(forProject, ProjectPermission.READ_NO_CONFIG)) {
-        return fastHideRefsMetaConfig(refs);
-      }
-    }
-
-    Account.Id userId;
-    boolean viewMetadata;
-    if (user.get().isIdentifiedUser()) {
-      viewMetadata = withUser.testOrFalse(GlobalPermission.ACCESS_DATABASE);
-      IdentifiedUser u = user.get().asIdentifiedUser();
-      userId = u.getAccountId();
-      userEditPrefix = RefNames.refsEditPrefix(userId);
-    } else {
-      userId = null;
-      viewMetadata = false;
-    }
-
-    Map<String, Ref> result = new HashMap<>();
-    List<Ref> deferredTags = new ArrayList<>();
-
-    projectCtl = projectState.controlFor(user.get());
-    for (Ref ref : refs.values()) {
-      String name = ref.getName();
-      Change.Id changeId;
-      Account.Id accountId;
-      if (name.startsWith(REFS_CACHE_AUTOMERGE) || (!showMetadata && isMetadata(name))) {
-        continue;
-      } else if (RefNames.isRefsEdit(name)) {
-        // Edits are visible only to the owning user, if change is visible.
-        if (viewMetadata || visibleEdit(name)) {
-          result.put(name, ref);
-        }
-      } else if ((changeId = Change.Id.fromRef(name)) != null) {
-        // Change ref is visible only if the change is visible.
-        if (viewMetadata || visible(changeId)) {
-          result.put(name, ref);
-        }
-      } else if ((accountId = Account.Id.fromRef(name)) != null) {
-        // Account ref is visible only to corresponding account.
-        if (viewMetadata || (accountId.equals(userId) && canReadRef(name))) {
-          result.put(name, ref);
-        }
-      } else if (isTag(ref)) {
-        // If its a tag, consider it later.
-        if (ref.getObjectId() != null) {
-          deferredTags.add(ref);
-        }
-      } else if (name.startsWith(RefNames.REFS_SEQUENCES)) {
-        // Sequences are internal database implementation details.
-        if (viewMetadata) {
-          result.put(name, ref);
-        }
-      } else if (projectState.isAllUsers() && name.equals(RefNames.REFS_EXTERNAL_IDS)) {
-        // The notes branch with the external IDs of all users must not be exposed to normal users.
-        if (viewMetadata) {
-          result.put(name, ref);
-        }
-      } else if (canReadRef(ref.getLeaf().getName())) {
-        // Use the leaf to lookup the control data. If the reference is
-        // symbolic we want the control around the final target. If its
-        // not symbolic then getLeaf() is a no-op returning ref itself.
-        result.put(name, ref);
-      } else if (isRefsUsersSelf(ref)) {
-        // viewMetadata allows to see all account refs, hence refs/users/self should be included as
-        // well
-        if (viewMetadata) {
-          result.put(name, ref);
-        }
-      }
-    }
-
-    // If we have tags that were deferred, we need to do a revision walk
-    // to identify what tags we can actually reach, and what we cannot.
-    //
-    if (!deferredTags.isEmpty() && (!result.isEmpty() || filterTagsSeparately)) {
-      TagMatcher tags =
-          tagCache
-              .get(projectState.getNameKey())
-              .matcher(
-                  tagCache,
-                  git,
-                  filterTagsSeparately ? filter(git.getAllRefs()).values() : result.values());
-      for (Ref tag : deferredTags) {
-        if (tags.isReachable(tag)) {
-          result.put(tag.getName(), tag);
-        }
-      }
-    }
-
-    return result;
-  }
-
-  private Map<String, Ref> fastHideRefsMetaConfig(Map<String, Ref> refs) {
-    if (refs.containsKey(REFS_CONFIG) && !canReadRef(REFS_CONFIG)) {
-      Map<String, Ref> r = new HashMap<>(refs);
-      r.remove(REFS_CONFIG);
-      return r;
-    }
-    return refs;
-  }
-
-  private Map<String, Ref> addUsersSelfSymref(Map<String, Ref> refs) {
-    if (user.get().isIdentifiedUser()) {
-      Ref r = refs.get(RefNames.refsUsers(user.get().getAccountId()));
-      if (r != null) {
-        SymbolicRef s = new SymbolicRef(REFS_USERS_SELF, r);
-        refs = new HashMap<>(refs);
-        refs.put(s.getName(), s);
-      }
-    }
-    return refs;
-  }
-
-  @Override
-  protected Map<String, Ref> getAdvertisedRefs(Repository repository, RevWalk revWalk)
-      throws ServiceMayNotContinueException {
-    try {
-      return filter(repository.getRefDatabase().getRefs(RefDatabase.ALL));
-    } catch (ServiceMayNotContinueException e) {
-      throw e;
-    } catch (IOException e) {
-      ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
-      ex.initCause(e);
-      throw ex;
-    }
-  }
-
-  private Map<String, Ref> filter(Map<String, Ref> refs) {
-    return filter(refs, false);
-  }
-
-  private boolean visible(Change.Id changeId) {
-    if (visibleChanges == null) {
-      if (changeCache == null) {
-        visibleChanges = visibleChangesByScan();
-      } else {
-        visibleChanges = visibleChangesBySearch();
-      }
-    }
-    return visibleChanges.containsKey(changeId);
-  }
-
-  private boolean visibleEdit(String name) {
-    Change.Id id = Change.Id.fromEditRefPart(name);
-    // Initialize if it wasn't yet
-    if (visibleChanges == null) {
-      visible(id);
-    }
-    if (id != null) {
-      return (userEditPrefix != null && name.startsWith(userEditPrefix) && visible(id))
-          || (visibleChanges.containsKey(id)
-              && projectCtl.controlForRef(visibleChanges.get(id)).isEditVisible());
-    }
-    return false;
-  }
-
-  private Map<Change.Id, Branch.NameKey> visibleChangesBySearch() {
-    Project.NameKey project = projectState.getNameKey();
-    try {
-      Map<Change.Id, Branch.NameKey> visibleChanges = new HashMap<>();
-      for (ChangeData cd : changeCache.getChangeData(db.get(), project)) {
-        ChangeNotes notes = changeNotesFactory.createFromIndexedChange(cd.change());
-        if (perm.indexedChange(cd, notes).test(ChangePermission.READ)) {
-          visibleChanges.put(cd.getId(), cd.change().getDest());
-        }
-      }
-      return visibleChanges;
-    } catch (OrmException | PermissionBackendException e) {
-      log.error(
-          "Cannot load changes for project " + project + ", assuming no changes are visible", e);
-      return Collections.emptyMap();
-    }
-  }
-
-  private Map<Change.Id, Branch.NameKey> visibleChangesByScan() {
-    Project.NameKey p = projectState.getNameKey();
-    Stream<ChangeNotesResult> s;
-    try {
-      s = changeNotesFactory.scan(git, db.get(), p);
-    } catch (IOException e) {
-      log.error("Cannot load changes for project " + p + ", assuming no changes are visible", e);
-      return Collections.emptyMap();
-    }
-    return s.map(r -> toNotes(p, r))
-        .filter(Objects::nonNull)
-        .collect(toMap(n -> n.getChangeId(), n -> n.getChange().getDest()));
-  }
-
-  @Nullable
-  private ChangeNotes toNotes(Project.NameKey p, ChangeNotesResult r) {
-    if (r.error().isPresent()) {
-      log.warn("Failed to load change " + r.id() + " in " + p, r.error().get());
-      return null;
-    }
-    try {
-      if (perm.change(r.notes()).test(ChangePermission.READ)) {
-        return r.notes();
-      }
-    } catch (PermissionBackendException e) {
-      log.warn("Failed to check permission for " + r.id() + " in " + p, e);
-    }
-    return null;
-  }
-
-  private boolean isMetadata(String name) {
-    return name.startsWith(REFS_CHANGES) || RefNames.isRefsEdit(name);
-  }
-
-  private static boolean isTag(Ref ref) {
-    return ref.getLeaf().getName().startsWith(Constants.R_TAGS);
-  }
-
-  private static boolean isRefsUsersSelf(Ref ref) {
-    return ref.getName().startsWith(REFS_USERS_SELF);
-  }
-
-  private boolean canReadRef(String ref) {
-    try {
-      perm.ref(ref).check(RefPermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    } catch (PermissionBackendException e) {
-      log.error("unable to check permissions", e);
-      return false;
-    }
-  }
-
-  private boolean checkProjectPermission(
-      PermissionBackend.ForProject forProject, ProjectPermission perm) {
-    try {
-      forProject.check(perm);
-    } catch (AuthException e) {
-      return false;
-    } catch (PermissionBackendException e) {
-      log.error(
-          String.format(
-              "Can't check permission for user %s on project %s",
-              user.get(), projectState.getName()),
-          e);
-      return false;
-    }
-    return true;
-  }
-}
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
deleted file mode 100644
index 4afaacd..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/AllRefsWatcher.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.receive;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import java.util.Map;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.transport.AdvertiseRefsHook;
-import org.eclipse.jgit.transport.BaseReceivePack;
-import org.eclipse.jgit.transport.ServiceMayNotContinueException;
-import org.eclipse.jgit.transport.UploadPack;
-
-/**
- * Hook that scans all refs and holds onto the results reference.
- *
- * <p>This allows a caller who has an {@code AllRefsWatcher} instance to get the full map of refs in
- * the repo, even if refs are filtered by a later hook or filter.
- */
-class AllRefsWatcher implements AdvertiseRefsHook {
-  private Map<String, Ref> allRefs;
-
-  @Override
-  public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
-    allRefs = HookUtil.ensureAllRefsAdvertised(rp);
-  }
-
-  @Override
-  public void advertiseRefs(UploadPack uploadPack) {
-    throw new UnsupportedOperationException();
-  }
-
-  Map<String, Ref> getAllRefs() {
-    checkState(allRefs != null, "getAllRefs() only valid after refs were advertised");
-    return allRefs;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
deleted file mode 100644
index 22834f3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ /dev/null
@@ -1,290 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.receive;
-
-import com.google.common.collect.SetMultimap;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.Capable;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.HackPushNegotiateHook;
-import com.google.gerrit.server.git.MultiProgressMonitor;
-import com.google.gerrit.server.git.ProjectRunnable;
-import com.google.gerrit.server.git.TransferConfig;
-import com.google.gerrit.server.git.VisibleRefFilter;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ContributorAgreementsChecker;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.util.MagicBranch;
-import com.google.gerrit.server.util.RequestScopePropagator;
-import com.google.inject.Inject;
-import com.google.inject.PrivateModule;
-import com.google.inject.Provider;
-import com.google.inject.Provides;
-import com.google.inject.Singleton;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.FactoryModuleBuilder;
-import com.google.inject.name.Named;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.AdvertiseRefsHook;
-import org.eclipse.jgit.transport.AdvertiseRefsHookChain;
-import org.eclipse.jgit.transport.PreReceiveHook;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.transport.ReceiveCommand.Result;
-import org.eclipse.jgit.transport.ReceivePack;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Hook that delegates to {@link ReceiveCommits} in a worker thread. */
-public class AsyncReceiveCommits implements PreReceiveHook {
-  private static final Logger log = LoggerFactory.getLogger(AsyncReceiveCommits.class);
-
-  private static final String TIMEOUT_NAME = "ReceiveCommitsOverallTimeout";
-
-  public interface Factory {
-    AsyncReceiveCommits create(
-        ProjectControl projectControl,
-        Repository repository,
-        @Nullable MessageSender messageSender,
-        SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers);
-  }
-
-  public static class Module extends PrivateModule {
-    @Override
-    public void configure() {
-      install(new FactoryModuleBuilder().build(AsyncReceiveCommits.Factory.class));
-      expose(AsyncReceiveCommits.Factory.class);
-      // Don't expose the binding for ReceiveCommits.Factory. All callers should
-      // be using AsyncReceiveCommits.Factory instead.
-      install(new FactoryModuleBuilder().build(ReceiveCommits.Factory.class));
-    }
-
-    @Provides
-    @Singleton
-    @Named(TIMEOUT_NAME)
-    long getTimeoutMillis(@GerritServerConfig Config cfg) {
-      return ConfigUtil.getTimeUnit(
-          cfg, "receive", null, "timeout", TimeUnit.MINUTES.toMillis(4), TimeUnit.MILLISECONDS);
-    }
-  }
-
-  private class Worker implements ProjectRunnable {
-    final MultiProgressMonitor progress;
-
-    private final Collection<ReceiveCommand> commands;
-    private final ReceiveCommits rc;
-
-    private Worker(Collection<ReceiveCommand> commands) {
-      this.commands = commands;
-      rc = factory.create(projectControl, rp, allRefsWatcher, extraReviewers);
-      rc.init();
-      rc.setMessageSender(messageSender);
-      progress = new MultiProgressMonitor(new MessageSenderOutputStream(), "Processing changes");
-    }
-
-    @Override
-    public void run() {
-      rc.processCommands(commands, progress);
-    }
-
-    @Override
-    public Project.NameKey getProjectNameKey() {
-      return rc.getProject().getNameKey();
-    }
-
-    @Override
-    public String getRemoteName() {
-      return null;
-    }
-
-    @Override
-    public boolean hasCustomizedPrint() {
-      return true;
-    }
-
-    @Override
-    public String toString() {
-      return "receive-commits";
-    }
-
-    void sendMessages() {
-      rc.sendMessages();
-    }
-
-    private class MessageSenderOutputStream extends OutputStream {
-      @Override
-      public void write(int b) {
-        rc.getMessageSender().sendBytes(new byte[] {(byte) b});
-      }
-
-      @Override
-      public void write(byte[] what, int off, int len) {
-        rc.getMessageSender().sendBytes(what, off, len);
-      }
-
-      @Override
-      public void write(byte[] what) {
-        rc.getMessageSender().sendBytes(what);
-      }
-
-      @Override
-      public void flush() {
-        rc.getMessageSender().flush();
-      }
-    }
-  }
-
-  private final ReceiveCommits.Factory factory;
-  private final ReceivePack rp;
-  private final ExecutorService executor;
-  private final RequestScopePropagator scopePropagator;
-  private final ReceiveConfig receiveConfig;
-  private final ContributorAgreementsChecker contributorAgreements;
-  private final long timeoutMillis;
-  private final ProjectControl projectControl;
-  private final Repository repo;
-  private final MessageSender messageSender;
-  private final SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers;
-  private final AllRefsWatcher allRefsWatcher;
-
-  @Inject
-  AsyncReceiveCommits(
-      ReceiveCommits.Factory factory,
-      PermissionBackend permissionBackend,
-      VisibleRefFilter.Factory refFilterFactory,
-      Provider<InternalChangeQuery> queryProvider,
-      @ReceiveCommitsExecutor ExecutorService executor,
-      RequestScopePropagator scopePropagator,
-      ReceiveConfig receiveConfig,
-      TransferConfig transferConfig,
-      Provider<LazyPostReceiveHookChain> lazyPostReceive,
-      ContributorAgreementsChecker contributorAgreements,
-      @Named(TIMEOUT_NAME) long timeoutMillis,
-      @Assisted ProjectControl projectControl,
-      @Assisted Repository repo,
-      @Assisted @Nullable MessageSender messageSender,
-      @Assisted SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers)
-      throws PermissionBackendException {
-    this.factory = factory;
-    this.executor = executor;
-    this.scopePropagator = scopePropagator;
-    this.receiveConfig = receiveConfig;
-    this.contributorAgreements = contributorAgreements;
-    this.timeoutMillis = timeoutMillis;
-    this.projectControl = projectControl;
-    this.repo = repo;
-    this.messageSender = messageSender;
-    this.extraReviewers = extraReviewers;
-
-    IdentifiedUser user = projectControl.getUser().asIdentifiedUser();
-    ProjectState state = projectControl.getProjectState();
-    Project.NameKey projectName = projectControl.getProject().getNameKey();
-    rp = new ReceivePack(repo);
-    rp.setAllowCreates(true);
-    rp.setAllowDeletes(true);
-    rp.setAllowNonFastForwards(true);
-    rp.setRefLogIdent(user.newRefLogIdent());
-    rp.setTimeout(transferConfig.getTimeout());
-    rp.setMaxObjectSizeLimit(transferConfig.getEffectiveMaxObjectSizeLimit(state));
-    rp.setCheckReceivedObjects(state.getConfig().getCheckReceivedObjects());
-    rp.setRefFilter(new ReceiveRefFilter());
-    rp.setAllowPushOptions(true);
-    rp.setPreReceiveHook(this);
-    rp.setPostReceiveHook(lazyPostReceive.get());
-
-    // If the user lacks READ permission, some references may be filtered and hidden from view.
-    // Check objects mentioned inside the incoming pack file are reachable from visible refs.
-    try {
-      permissionBackend.user(user).project(projectName).check(ProjectPermission.READ);
-    } catch (AuthException e) {
-      rp.setCheckReferencedObjectsAreReachable(receiveConfig.checkReferencedObjectsAreReachable);
-    }
-
-    List<AdvertiseRefsHook> advHooks = new ArrayList<>(4);
-    allRefsWatcher = new AllRefsWatcher();
-    advHooks.add(allRefsWatcher);
-    advHooks.add(refFilterFactory.create(state, repo).setShowMetadata(false));
-    advHooks.add(new ReceiveCommitsAdvertiseRefsHook(queryProvider, projectName));
-    advHooks.add(new HackPushNegotiateHook());
-    rp.setAdvertiseRefsHook(AdvertiseRefsHookChain.newChain(advHooks));
-  }
-
-  /** Determine if the user can upload commits. */
-  public Capable canUpload() throws IOException {
-    Capable result = projectControl.canPushToAtLeastOneRef();
-    if (result != Capable.OK) {
-      return result;
-    }
-
-    try {
-      contributorAgreements.check(
-          projectControl.getProject().getNameKey(), projectControl.getUser());
-    } catch (AuthException e) {
-      return new Capable(e.getMessage());
-    }
-
-    if (receiveConfig.checkMagicRefs) {
-      return MagicBranch.checkMagicBranchRefs(repo, projectControl.getProject());
-    }
-    return Capable.OK;
-  }
-
-  @Override
-  public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
-    Worker w = new Worker(commands);
-    try {
-      w.progress.waitFor(
-          executor.submit(scopePropagator.wrap(w)), timeoutMillis, TimeUnit.MILLISECONDS);
-    } catch (ExecutionException e) {
-      log.warn(
-          String.format(
-              "Error in ReceiveCommits while processing changes for project %s",
-              projectControl.getProject().getName()),
-          e);
-      rp.sendError("internal error while processing changes");
-      // ReceiveCommits has tried its best to catch errors, so anything at this
-      // point is very bad.
-      for (ReceiveCommand c : commands) {
-        if (c.getResult() == Result.NOT_ATTEMPTED) {
-          c.setResult(Result.REJECTED_OTHER_REASON, "internal error");
-        }
-      }
-    } finally {
-      w.sendMessages();
-    }
-  }
-
-  public ReceivePack getReceivePack() {
-    return rp;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/HookUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/HookUtil.java
deleted file mode 100644
index 90b220a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/HookUtil.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.receive;
-
-import java.io.IOException;
-import java.util.Map;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.transport.BaseReceivePack;
-import org.eclipse.jgit.transport.ServiceMayNotContinueException;
-
-/** Static utilities for writing {@link ReceiveCommits}-related hooks. */
-class HookUtil {
-  /**
-   * Scan and advertise all refs in the repo if refs have not already been advertised; otherwise,
-   * just return the advertised map.
-   *
-   * @param rp receive-pack handler.
-   * @return map of refs that were advertised.
-   * @throws ServiceMayNotContinueException if a problem occurred.
-   */
-  static Map<String, Ref> ensureAllRefsAdvertised(BaseReceivePack rp)
-      throws ServiceMayNotContinueException {
-    Map<String, Ref> refs = rp.getAdvertisedRefs();
-    if (refs != null) {
-      return refs;
-    }
-    try {
-      refs = rp.getRepository().getRefDatabase().getRefs(RefDatabase.ALL);
-    } catch (ServiceMayNotContinueException e) {
-      throw e;
-    } catch (IOException e) {
-      ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
-      ex.initCause(e);
-      throw ex;
-    }
-    rp.setAdvertisedRefs(refs, rp.getAdvertisedObjects());
-    return refs;
-  }
-
-  private HookUtil() {}
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
deleted file mode 100644
index 4a0ff70..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ /dev/null
@@ -1,2985 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.receive;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
-import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
-import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
-import static com.google.gerrit.server.git.receive.ReceiveConstants.COMMAND_REJECTION_MESSAGE_FOOTER;
-import static com.google.gerrit.server.git.receive.ReceiveConstants.ONLY_OWNER_CAN_MODIFY_WIP;
-import static com.google.gerrit.server.git.receive.ReceiveConstants.PUSH_OPTION_SKIP_VALIDATION;
-import static com.google.gerrit.server.git.receive.ReceiveConstants.SAME_CHANGE_ID_IN_MULTIPLE_CHANGES;
-import static com.google.gerrit.server.git.validators.CommitValidators.NEW_PATCHSET_PATTERN;
-import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
-import static java.util.Comparator.comparingInt;
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toList;
-import static org.eclipse.jgit.lib.Constants.R_HEADS;
-import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED;
-import static org.eclipse.jgit.transport.ReceiveCommand.Result.OK;
-import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_MISSING_OBJECT;
-import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_NONFASTFORWARD;
-import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
-
-import com.google.common.base.Function;
-import com.google.common.base.Splitter;
-import com.google.common.base.Strings;
-import com.google.common.collect.BiMap;
-import com.google.common.collect.HashBiMap;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
-import com.google.common.collect.Sets;
-import com.google.common.collect.SortedSetMultimap;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.extensions.api.changes.HashtagsInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicMap.Entry;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.change.ChangeInserter;
-import com.google.gerrit.server.change.SetHashtagsOp;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.PluginConfig;
-import com.google.gerrit.server.config.ProjectConfigEntry;
-import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gerrit.server.events.CommitReceivedEvent;
-import com.google.gerrit.server.git.BanCommit;
-import com.google.gerrit.server.git.ChangeReportFormatter;
-import com.google.gerrit.server.git.GroupCollector;
-import com.google.gerrit.server.git.MergeOp;
-import com.google.gerrit.server.git.MergeOpRepoManager;
-import com.google.gerrit.server.git.MergedByPushOp;
-import com.google.gerrit.server.git.MultiProgressMonitor;
-import com.google.gerrit.server.git.MultiProgressMonitor.Task;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.ReceivePackInitializer;
-import com.google.gerrit.server.git.SubmoduleException;
-import com.google.gerrit.server.git.SubmoduleOp;
-import com.google.gerrit.server.git.TagCache;
-import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.server.git.validators.CommitValidationException;
-import com.google.gerrit.server.git.validators.CommitValidationMessage;
-import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.git.validators.RefOperationValidationException;
-import com.google.gerrit.server.git.validators.RefOperationValidators;
-import com.google.gerrit.server.git.validators.ValidationMessage;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.CreateRefControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.ssh.SshInfo;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.server.update.RepoOnlyOp;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.LabelVote;
-import com.google.gerrit.server.util.MagicBranch;
-import com.google.gerrit.server.util.RequestId;
-import com.google.gerrit.server.util.RequestScopePropagator;
-import com.google.gerrit.util.cli.CmdLineParser;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.util.Providers;
-import java.io.IOException;
-import java.io.StringWriter;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Future;
-import java.util.regex.Matcher;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.FooterLine;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevSort;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.revwalk.filter.RevFilter;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.transport.ReceiveCommand.Result;
-import org.eclipse.jgit.transport.ReceivePack;
-import org.kohsuke.args4j.CmdLineException;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Receives change upload using the Git receive-pack protocol. */
-class ReceiveCommits {
-  private static final Logger log = LoggerFactory.getLogger(ReceiveCommits.class);
-
-  private enum ReceiveError {
-    CONFIG_UPDATE(
-        "You are not allowed to perform this operation.\n"
-            + "Configuration changes can only be pushed by project owners\n"
-            + "who also have 'Push' rights on "
-            + RefNames.REFS_CONFIG),
-    UPDATE(
-        "You are not allowed to perform this operation.\n"
-            + "To push into this reference you need 'Push' rights."),
-    DELETE(
-        "You need 'Delete Reference' rights or 'Push' rights with the \n"
-            + "'Force Push' flag set to delete references."),
-    DELETE_CHANGES("Cannot delete from '" + REFS_CHANGES + "'"),
-    CODE_REVIEW(
-        "You need 'Push' rights to upload code review requests.\n"
-            + "Verify that you are pushing to the right branch.");
-
-    private final String value;
-
-    ReceiveError(String value) {
-      this.value = value;
-    }
-
-    String get() {
-      return value;
-    }
-  }
-
-  interface Factory {
-    ReceiveCommits create(
-        ProjectControl projectControl,
-        ReceivePack receivePack,
-        AllRefsWatcher allRefsWatcher,
-        SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers);
-  }
-
-  private class ReceivePackMessageSender implements MessageSender {
-    @Override
-    public void sendMessage(String what) {
-      rp.sendMessage(what);
-    }
-
-    @Override
-    public void sendError(String what) {
-      rp.sendError(what);
-    }
-
-    @Override
-    public void sendBytes(byte[] what) {
-      sendBytes(what, 0, what.length);
-    }
-
-    @Override
-    public void sendBytes(byte[] what, int off, int len) {
-      try {
-        rp.getMessageOutputStream().write(what, off, len);
-      } catch (IOException e) {
-        // Ignore write failures (matching JGit behavior).
-      }
-    }
-
-    @Override
-    public void flush() {
-      try {
-        rp.getMessageOutputStream().flush();
-      } catch (IOException e) {
-        // Ignore write failures (matching JGit behavior).
-      }
-    }
-  }
-
-  private static final Function<Exception, RestApiException> INSERT_EXCEPTION =
-      new Function<Exception, RestApiException>() {
-        @Override
-        public RestApiException apply(Exception input) {
-          if (input instanceof RestApiException) {
-            return (RestApiException) input;
-          } else if ((input instanceof ExecutionException)
-              && (input.getCause() instanceof RestApiException)) {
-            return (RestApiException) input.getCause();
-          }
-          return new RestApiException("Error inserting change/patchset", input);
-        }
-      };
-
-  // ReceiveCommits has a lot of fields, sorry. Here and in the constructor they are split up
-  // somewhat, and kept sorted lexicographically within sections, except where later assignments
-  // depend on previous ones.
-
-  // Injected fields.
-  private final AccountResolver accountResolver;
-  private final AccountsUpdate.Server accountsUpdate;
-  private final AllProjectsName allProjectsName;
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final ChangeEditUtil editUtil;
-  private final ChangeIndexer indexer;
-  private final ChangeInserter.Factory changeInserterFactory;
-  private final ChangeNotes.Factory notesFactory;
-  private final CmdLineParser.Factory optionParserFactory;
-  private final CommitValidators.Factory commitValidatorsFactory;
-  private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
-  private final DynamicSet<ReceivePackInitializer> initializers;
-  private final IdentifiedUser user;
-  private final MergedByPushOp.Factory mergedByPushOpFactory;
-  private final NotesMigration notesMigration;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final PatchSetUtil psUtil;
-  private final PermissionBackend permissionBackend;
-  private final ProjectCache projectCache;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final Provider<MergeOp> mergeOpProvider;
-  private final Provider<MergeOpRepoManager> ormProvider;
-  private final ReceiveConfig receiveConfig;
-  private final RefOperationValidators.Factory refValidatorsFactory;
-  private final ReplaceOp.Factory replaceOpFactory;
-  private final RequestScopePropagator requestScopePropagator;
-  private final ReviewDb db;
-  private final Sequences seq;
-  private final SetHashtagsOp.Factory hashtagsFactory;
-  private final SshInfo sshInfo;
-  private final SubmoduleOp.Factory subOpFactory;
-  private final TagCache tagCache;
-  private final CreateRefControl createRefControl;
-
-  // Assisted injected fields.
-  private final AllRefsWatcher allRefsWatcher;
-  private final ImmutableSetMultimap<ReviewerStateInternal, Account.Id> extraReviewers;
-  private final ProjectControl projectControl;
-  private final ReceivePack rp;
-
-  // Immutable fields derived from constructor arguments.
-  private final LabelTypes labelTypes;
-  private final NoteMap rejectCommits;
-  private final PermissionBackend.ForProject permissions;
-  private final Project project;
-  private final Repository repo;
-  private final RequestId receiveId;
-
-  // Collections populated during processing.
-  private final List<UpdateGroupsRequest> updateGroups;
-  private final List<ValidationMessage> messages;
-  private final ListMultimap<ReceiveError, String> errors;
-  private final ListMultimap<String, String> pushOptions;
-  private final Map<Change.Id, ReplaceRequest> replaceByChange;
-  private final Set<ObjectId> validCommits;
-
-  /**
-   * Actual commands to be executed, as opposed to the mix of actual and magic commands that were
-   * provided over the wire.
-   *
-   * <p>Excludes commands executed implicitly as part of other {@link BatchUpdateOp}s, such as
-   * creating patch set refs.
-   */
-  private final List<ReceiveCommand> actualCommands;
-
-  // Collections lazily populated during processing.
-  private List<CreateRequest> newChanges;
-  private ListMultimap<Change.Id, Ref> refsByChange;
-  private ListMultimap<ObjectId, Ref> refsById;
-
-  // Other settings populated during processing.
-  private MagicBranchInput magicBranch;
-  private boolean newChangeForAllNotInTarget;
-  private String setFullNameTo;
-
-  // Handles for outputting back over the wire to the end user.
-  private Task newProgress;
-  private Task replaceProgress;
-  private Task closeProgress;
-  private Task commandProgress;
-  private MessageSender messageSender;
-  private final ChangeReportFormatter changeFormatter;
-
-  @Inject
-  ReceiveCommits(
-      AccountResolver accountResolver,
-      AccountsUpdate.Server accountsUpdate,
-      AllProjectsName allProjectsName,
-      BatchUpdate.Factory batchUpdateFactory,
-      ChangeEditUtil editUtil,
-      ChangeIndexer indexer,
-      ChangeInserter.Factory changeInserterFactory,
-      ChangeNotes.Factory notesFactory,
-      CmdLineParser.Factory optionParserFactory,
-      CommitValidators.Factory commitValidatorsFactory,
-      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
-      DynamicSet<ReceivePackInitializer> initializers,
-      MergedByPushOp.Factory mergedByPushOpFactory,
-      NotesMigration notesMigration,
-      PatchSetInfoFactory patchSetInfoFactory,
-      PatchSetUtil psUtil,
-      PermissionBackend permissionBackend,
-      ProjectCache projectCache,
-      Provider<InternalChangeQuery> queryProvider,
-      Provider<MergeOp> mergeOpProvider,
-      Provider<MergeOpRepoManager> ormProvider,
-      ReceiveConfig receiveConfig,
-      RefOperationValidators.Factory refValidatorsFactory,
-      ReplaceOp.Factory replaceOpFactory,
-      RequestScopePropagator requestScopePropagator,
-      ReviewDb db,
-      Sequences seq,
-      SetHashtagsOp.Factory hashtagsFactory,
-      SshInfo sshInfo,
-      SubmoduleOp.Factory subOpFactory,
-      TagCache tagCache,
-      CreateRefControl createRefControl,
-      DynamicItem<ChangeReportFormatter> changeFormatterProvider,
-      @Assisted ProjectControl projectControl,
-      @Assisted ReceivePack rp,
-      @Assisted AllRefsWatcher allRefsWatcher,
-      @Assisted SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers)
-      throws IOException {
-    // Injected fields.
-    this.accountResolver = accountResolver;
-    this.accountsUpdate = accountsUpdate;
-    this.allProjectsName = allProjectsName;
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.changeInserterFactory = changeInserterFactory;
-    this.commitValidatorsFactory = commitValidatorsFactory;
-    this.changeFormatter = changeFormatterProvider.get();
-    this.user = projectControl.getUser().asIdentifiedUser();
-    this.db = db;
-    this.editUtil = editUtil;
-    this.hashtagsFactory = hashtagsFactory;
-    this.indexer = indexer;
-    this.initializers = initializers;
-    this.mergeOpProvider = mergeOpProvider;
-    this.mergedByPushOpFactory = mergedByPushOpFactory;
-    this.notesFactory = notesFactory;
-    this.notesMigration = notesMigration;
-    this.optionParserFactory = optionParserFactory;
-    this.ormProvider = ormProvider;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.permissionBackend = permissionBackend;
-    this.pluginConfigEntries = pluginConfigEntries;
-    this.projectCache = projectCache;
-    this.psUtil = psUtil;
-    this.queryProvider = queryProvider;
-    this.receiveConfig = receiveConfig;
-    this.refValidatorsFactory = refValidatorsFactory;
-    this.replaceOpFactory = replaceOpFactory;
-    this.requestScopePropagator = requestScopePropagator;
-    this.seq = seq;
-    this.sshInfo = sshInfo;
-    this.subOpFactory = subOpFactory;
-    this.tagCache = tagCache;
-    this.createRefControl = createRefControl;
-
-    // Assisted injected fields.
-    this.allRefsWatcher = allRefsWatcher;
-    this.extraReviewers = ImmutableSetMultimap.copyOf(extraReviewers);
-    this.projectControl = projectControl;
-    this.rp = rp;
-
-    // Immutable fields derived from constructor arguments.
-    repo = rp.getRepository();
-    project = projectControl.getProject();
-    labelTypes = projectControl.getProjectState().getLabelTypes();
-    permissions = permissionBackend.user(user).project(project.getNameKey());
-    receiveId = RequestId.forProject(project.getNameKey());
-    rejectCommits = BanCommit.loadRejectCommitsMap(rp.getRepository(), rp.getRevWalk());
-
-    // Collections populated during processing.
-    actualCommands = new ArrayList<>();
-    errors = LinkedListMultimap.create();
-    messages = new ArrayList<>();
-    pushOptions = LinkedListMultimap.create();
-    replaceByChange = new LinkedHashMap<>();
-    updateGroups = new ArrayList<>();
-    validCommits = new HashSet<>();
-
-    // Collections lazily populated during processing.
-    newChanges = Collections.emptyList();
-
-    // Other settings populated during processing.
-    newChangeForAllNotInTarget =
-        projectControl.getProjectState().isCreateNewChangeForAllNotInTarget();
-
-    // Handles for outputting back over the wire to the end user.
-    messageSender = new ReceivePackMessageSender();
-  }
-
-  void init() {
-    for (ReceivePackInitializer i : initializers) {
-      i.init(projectControl.getProject().getNameKey(), rp);
-    }
-  }
-
-  /** Set a message sender for this operation. */
-  void setMessageSender(MessageSender ms) {
-    messageSender = ms != null ? ms : new ReceivePackMessageSender();
-  }
-
-  MessageSender getMessageSender() {
-    if (messageSender == null) {
-      setMessageSender(null);
-    }
-    return messageSender;
-  }
-
-  Project getProject() {
-    return project;
-  }
-
-  private void addMessage(String message) {
-    messages.add(new CommitValidationMessage(message, false));
-  }
-
-  void addError(String error) {
-    messages.add(new CommitValidationMessage(error, true));
-  }
-
-  void sendMessages() {
-    for (ValidationMessage m : messages) {
-      if (m.isError()) {
-        messageSender.sendError(m.getMessage());
-      } else {
-        messageSender.sendMessage(m.getMessage());
-      }
-    }
-  }
-
-  void processCommands(Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
-    newProgress = progress.beginSubTask("new", UNKNOWN);
-    replaceProgress = progress.beginSubTask("updated", UNKNOWN);
-    closeProgress = progress.beginSubTask("closed", UNKNOWN);
-    commandProgress = progress.beginSubTask("refs", UNKNOWN);
-
-    try {
-      parseCommands(commands);
-    } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
-      for (ReceiveCommand cmd : actualCommands) {
-        if (cmd.getResult() == NOT_ATTEMPTED) {
-          cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
-        }
-      }
-      logError(String.format("Failed to process refs in %s", project.getName()), err);
-    }
-    if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
-      selectNewAndReplacedChangesFromMagicBranch();
-    }
-    preparePatchSetsForReplace();
-    insertChangesAndPatchSets();
-    newProgress.end();
-    replaceProgress.end();
-
-    if (!errors.isEmpty()) {
-      logDebug("Handling error conditions: {}", errors.keySet());
-      for (ReceiveError error : errors.keySet()) {
-        rp.sendMessage(buildError(error, errors.get(error)));
-      }
-      rp.sendMessage(String.format("User: %s", displayName(user)));
-      rp.sendMessage(COMMAND_REJECTION_MESSAGE_FOOTER);
-    }
-
-    Set<Branch.NameKey> branches = new HashSet<>();
-    for (ReceiveCommand c : actualCommands) {
-      // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps that
-      // should happen in this loop are things that can't happen within one BatchUpdate because they
-      // involve kicking off an additional BatchUpdate.
-      if (c.getResult() != OK) {
-        continue;
-      }
-      if (isHead(c) || isConfig(c)) {
-        switch (c.getType()) {
-          case CREATE:
-          case UPDATE:
-          case UPDATE_NONFASTFORWARD:
-            autoCloseChanges(c);
-            branches.add(new Branch.NameKey(project.getNameKey(), c.getRefName()));
-            break;
-
-          case DELETE:
-            break;
-        }
-      }
-    }
-
-    // Update superproject gitlinks if required.
-    if (!branches.isEmpty()) {
-      try (MergeOpRepoManager orm = ormProvider.get()) {
-        orm.setContext(db, TimeUtil.nowTs(), user, receiveId);
-        SubmoduleOp op = subOpFactory.create(branches, orm);
-        op.updateSuperProjects();
-      } catch (SubmoduleException e) {
-        logError("Can't update the superprojects", e);
-      }
-    }
-
-    // Update account info with details discovered during commit walking.
-    updateAccountInfo();
-
-    closeProgress.end();
-    commandProgress.end();
-    progress.end();
-    reportMessages();
-  }
-
-  private void reportMessages() {
-    List<CreateRequest> created =
-        newChanges.stream().filter(r -> r.change != null).collect(toList());
-    if (!created.isEmpty()) {
-      addMessage("");
-      addMessage("New Changes:");
-      for (CreateRequest c : created) {
-        addMessage(
-            changeFormatter.newChange(
-                ChangeReportFormatter.Input.builder().setChange(c.change).build()));
-      }
-      addMessage("");
-    }
-
-    List<ReplaceRequest> updated =
-        replaceByChange
-            .values()
-            .stream()
-            .filter(r -> !r.skip && r.inputCommand.getResult() == OK)
-            .sorted(comparingInt(r -> r.notes.getChangeId().get()))
-            .collect(toList());
-    if (!updated.isEmpty()) {
-      addMessage("");
-      addMessage("Updated Changes:");
-      boolean edit = magicBranch != null && (magicBranch.edit || magicBranch.draft);
-      Boolean isPrivate = null;
-      Boolean wip = null;
-      if (magicBranch != null) {
-        if (magicBranch.isPrivate) {
-          isPrivate = true;
-        } else if (magicBranch.removePrivate) {
-          isPrivate = false;
-        }
-        if (magicBranch.workInProgress) {
-          wip = true;
-        } else if (magicBranch.ready) {
-          wip = false;
-        }
-      }
-      for (ReplaceRequest u : updated) {
-        String subject;
-        if (edit) {
-          try {
-            subject = rp.getRevWalk().parseCommit(u.newCommitId).getShortMessage();
-          } catch (IOException e) {
-            // Log and fall back to original change subject
-            logWarn("failed to get subject for edit patch set", e);
-            subject = u.notes.getChange().getSubject();
-          }
-        } else {
-          subject = u.info.getSubject();
-        }
-
-        if (isPrivate == null) {
-          isPrivate = u.notes.getChange().isPrivate();
-        }
-        if (wip == null) {
-          wip = u.notes.getChange().isWorkInProgress();
-        }
-
-        ChangeReportFormatter.Input input =
-            ChangeReportFormatter.Input.builder()
-                .setChange(u.notes.getChange())
-                .setSubject(subject)
-                .setIsEdit(edit)
-                .setIsPrivate(isPrivate)
-                .setIsWorkInProgress(wip)
-                .build();
-        addMessage(changeFormatter.changeUpdated(input));
-      }
-      addMessage("");
-    }
-
-    // TODO(xchangcheng): remove after migrating tools which are using this magic branch.
-    if (magicBranch != null && magicBranch.publish) {
-      addMessage("Pushing to refs/publish/* is deprecated, use refs/for/* instead.");
-    }
-  }
-
-  private void insertChangesAndPatchSets() {
-    ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
-    if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
-      logWarn(
-          String.format(
-              "Skipping change updates on %s because ref update failed: %s %s",
-              project.getName(),
-              magicBranchCmd.getResult(),
-              Strings.nullToEmpty(magicBranchCmd.getMessage())));
-      return;
-    }
-
-    try (BatchUpdate bu =
-            batchUpdateFactory.create(
-                db, project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
-        ObjectInserter ins = repo.newObjectInserter();
-        ObjectReader reader = ins.newReader();
-        RevWalk rw = new RevWalk(reader)) {
-      bu.setRepository(repo, rw, ins).updateChangesInParallel();
-      bu.setRequestId(receiveId);
-      bu.setRefLogMessage("push");
-
-      logDebug("Adding {} replace requests", newChanges.size());
-      for (ReplaceRequest replace : replaceByChange.values()) {
-        replace.addOps(bu, replaceProgress);
-      }
-
-      logDebug("Adding {} create requests", newChanges.size());
-      for (CreateRequest create : newChanges) {
-        create.addOps(bu);
-      }
-
-      logDebug("Adding {} group update requests", newChanges.size());
-      updateGroups.forEach(r -> r.addOps(bu));
-
-      logDebug("Adding {} additional ref updates", actualCommands.size());
-      actualCommands.forEach(c -> bu.addRepoOnlyOp(new UpdateOneRefOp(c)));
-
-      logDebug("Executing batch");
-      try {
-        bu.execute();
-      } catch (UpdateException e) {
-        throw INSERT_EXCEPTION.apply(e);
-      }
-      if (magicBranchCmd != null) {
-        magicBranchCmd.setResult(OK);
-      }
-      for (ReplaceRequest replace : replaceByChange.values()) {
-        String rejectMessage = replace.getRejectMessage();
-        if (rejectMessage == null) {
-          if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
-            // Not necessarily the magic branch, so need to set OK on the original value.
-            replace.inputCommand.setResult(OK);
-          }
-        } else {
-          logDebug("Rejecting due to message from ReplaceOp");
-          reject(replace.inputCommand, rejectMessage);
-        }
-      }
-
-    } catch (ResourceConflictException e) {
-      addMessage(e.getMessage());
-      reject(magicBranchCmd, "conflict");
-    } catch (RestApiException | IOException err) {
-      logError("Can't insert change/patch set for " + project.getName(), err);
-      reject(magicBranchCmd, "internal server error: " + err.getMessage());
-    }
-
-    if (magicBranch != null && magicBranch.submit) {
-      try {
-        submit(newChanges, replaceByChange.values());
-      } catch (ResourceConflictException e) {
-        addMessage(e.getMessage());
-        reject(magicBranchCmd, "conflict");
-      } catch (RestApiException
-          | OrmException
-          | UpdateException
-          | IOException
-          | ConfigInvalidException
-          | PermissionBackendException e) {
-        logError("Error submitting changes to " + project.getName(), e);
-        reject(magicBranchCmd, "error during submit");
-      }
-    }
-  }
-
-  private String buildError(ReceiveError error, List<String> branches) {
-    StringBuilder sb = new StringBuilder();
-    if (branches.size() == 1) {
-      sb.append("Branch ").append(branches.get(0)).append(":\n");
-      sb.append(error.get());
-      return sb.toString();
-    }
-    sb.append("Branches");
-    String delim = " ";
-    for (String branch : branches) {
-      sb.append(delim).append(branch);
-      delim = ", ";
-    }
-    return sb.append(":\n").append(error.get()).toString();
-  }
-
-  private static String displayName(IdentifiedUser user) {
-    String displayName = user.getUserName();
-    if (displayName == null) {
-      displayName = user.getAccount().getPreferredEmail();
-    }
-    return displayName;
-  }
-
-  private void parseCommands(Collection<ReceiveCommand> commands)
-      throws PermissionBackendException, NoSuchProjectException, IOException {
-    List<String> optionList = rp.getPushOptions();
-    if (optionList != null) {
-      for (String option : optionList) {
-        int e = option.indexOf('=');
-        if (e > 0) {
-          pushOptions.put(option.substring(0, e), option.substring(e + 1));
-        } else {
-          pushOptions.put(option, "");
-        }
-      }
-    }
-
-    logDebug("Parsing {} commands", commands.size());
-    for (ReceiveCommand cmd : commands) {
-      if (cmd.getResult() != NOT_ATTEMPTED) {
-        // Already rejected by the core receive process.
-        logDebug("Already processed by core: {} {}", cmd.getResult(), cmd);
-        continue;
-      }
-
-      if (!Repository.isValidRefName(cmd.getRefName()) || cmd.getRefName().contains("//")) {
-        reject(cmd, "not valid ref");
-        continue;
-      }
-
-      if (MagicBranch.isMagicBranch(cmd.getRefName())) {
-        parseMagicBranch(cmd);
-        continue;
-      }
-
-      if (projectControl.getProjectState().isAllUsers()
-          && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
-        String newName = RefNames.refsUsers(user.getAccountId());
-        logDebug("Swapping out command for {} to {}", RefNames.REFS_USERS_SELF, newName);
-        final ReceiveCommand orgCmd = cmd;
-        cmd =
-            new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), newName, cmd.getType()) {
-              @Override
-              public void setResult(Result s, String m) {
-                super.setResult(s, m);
-                orgCmd.setResult(s, m);
-              }
-            };
-      }
-
-      Matcher m = NEW_PATCHSET_PATTERN.matcher(cmd.getRefName());
-      if (m.matches()) {
-        // The referenced change must exist and must still be open.
-        //
-        Change.Id changeId = Change.Id.parse(m.group(1));
-        parseReplaceCommand(cmd, changeId);
-        continue;
-      }
-
-      switch (cmd.getType()) {
-        case CREATE:
-          parseCreate(cmd);
-          break;
-
-        case UPDATE:
-          parseUpdate(cmd);
-          break;
-
-        case DELETE:
-          parseDelete(cmd);
-          break;
-
-        case UPDATE_NONFASTFORWARD:
-          parseRewind(cmd);
-          break;
-
-        default:
-          reject(cmd, "prohibited by Gerrit: unknown command type " + cmd.getType());
-          continue;
-      }
-
-      if (cmd.getResult() != NOT_ATTEMPTED) {
-        continue;
-      }
-
-      if (isConfig(cmd)) {
-        logDebug("Processing {} command", cmd.getRefName());
-        if (!projectControl.isOwner()) {
-          reject(cmd, "not project owner");
-          continue;
-        }
-
-        switch (cmd.getType()) {
-          case CREATE:
-          case UPDATE:
-          case UPDATE_NONFASTFORWARD:
-            try {
-              ProjectConfig cfg = new ProjectConfig(project.getNameKey());
-              cfg.load(rp.getRevWalk(), cmd.getNewId());
-              if (!cfg.getValidationErrors().isEmpty()) {
-                addError("Invalid project configuration:");
-                for (ValidationError err : cfg.getValidationErrors()) {
-                  addError("  " + err.getMessage());
-                }
-                reject(cmd, "invalid project configuration");
-                logError(
-                    "User "
-                        + user.getUserName()
-                        + " tried to push invalid project configuration "
-                        + cmd.getNewId().name()
-                        + " for "
-                        + project.getName());
-                continue;
-              }
-              Project.NameKey newParent = cfg.getProject().getParent(allProjectsName);
-              Project.NameKey oldParent = project.getParent(allProjectsName);
-              if (oldParent == null) {
-                // update of the 'All-Projects' project
-                if (newParent != null) {
-                  reject(cmd, "invalid project configuration: root project cannot have parent");
-                  continue;
-                }
-              } else {
-                if (!oldParent.equals(newParent)) {
-                  try {
-                    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-                  } catch (AuthException e) {
-                    reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
-                    continue;
-                  }
-                }
-
-                if (projectCache.get(newParent) == null) {
-                  reject(cmd, "invalid project configuration: parent does not exist");
-                  continue;
-                }
-              }
-
-              for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
-                PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName());
-                ProjectConfigEntry configEntry = e.getProvider().get();
-                String value = pluginCfg.getString(e.getExportName());
-                String oldValue =
-                    projectControl
-                        .getProjectState()
-                        .getConfig()
-                        .getPluginConfig(e.getPluginName())
-                        .getString(e.getExportName());
-                if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
-                  oldValue =
-                      Arrays.stream(
-                              projectControl
-                                  .getProjectState()
-                                  .getConfig()
-                                  .getPluginConfig(e.getPluginName())
-                                  .getStringList(e.getExportName()))
-                          .collect(joining("\n"));
-                }
-
-                if ((value == null ? oldValue != null : !value.equals(oldValue))
-                    && !configEntry.isEditable(projectControl.getProjectState())) {
-                  reject(
-                      cmd,
-                      String.format(
-                          "invalid project configuration: Not allowed to set parameter"
-                              + " '%s' of plugin '%s' on project '%s'.",
-                          e.getExportName(), e.getPluginName(), project.getName()));
-                  continue;
-                }
-
-                if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
-                    && value != null
-                    && !configEntry.getPermittedValues().contains(value)) {
-                  reject(
-                      cmd,
-                      String.format(
-                          "invalid project configuration: The value '%s' is "
-                              + "not permitted for parameter '%s' of plugin '%s'.",
-                          value, e.getExportName(), e.getPluginName()));
-                }
-              }
-            } catch (Exception e) {
-              reject(cmd, "invalid project configuration");
-              logError(
-                  "User "
-                      + user.getUserName()
-                      + " tried to push invalid project configuration "
-                      + cmd.getNewId().name()
-                      + " for "
-                      + project.getName(),
-                  e);
-              continue;
-            }
-            break;
-
-          case DELETE:
-            break;
-
-          default:
-            reject(
-                cmd,
-                "prohibited by Gerrit: don't know how to handle config update of type "
-                    + cmd.getType());
-            continue;
-        }
-      }
-    }
-  }
-
-  private void parseCreate(ReceiveCommand cmd)
-      throws PermissionBackendException, NoSuchProjectException, IOException {
-    RevObject obj;
-    try {
-      obj = rp.getRevWalk().parseAny(cmd.getNewId());
-    } catch (IOException err) {
-      logError(
-          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " creation",
-          err);
-      reject(cmd, "invalid object");
-      return;
-    }
-    logDebug("Creating {}", cmd);
-
-    if (isHead(cmd) && !isCommit(cmd)) {
-      return;
-    }
-
-    Branch.NameKey branch = new Branch.NameKey(project.getName(), cmd.getRefName());
-    try {
-      // Must pass explicit user instead of injecting a provider into CreateRefControl, since
-      // Provider<CurrentUser> within ReceiveCommits will always return anonymous.
-      createRefControl.checkCreateRef(Providers.of(user), rp.getRepository(), branch, obj);
-    } catch (AuthException denied) {
-      reject(cmd, "prohibited by Gerrit: " + denied.getMessage());
-      return;
-    }
-
-    if (!validRefOperation(cmd)) {
-      // validRefOperation sets messages, so no need to provide more feedback.
-      return;
-    }
-
-    validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
-    actualCommands.add(cmd);
-  }
-
-  private void parseUpdate(ReceiveCommand cmd) throws PermissionBackendException {
-    logDebug("Updating {}", cmd);
-    boolean ok;
-    try {
-      permissions.ref(cmd.getRefName()).check(RefPermission.UPDATE);
-      ok = true;
-    } catch (AuthException err) {
-      ok = false;
-    }
-    if (ok) {
-      if (isHead(cmd) && !isCommit(cmd)) {
-        return;
-      }
-      if (!validRefOperation(cmd)) {
-        return;
-      }
-      validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
-      actualCommands.add(cmd);
-    } else {
-      if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
-        errors.put(ReceiveError.CONFIG_UPDATE, RefNames.REFS_CONFIG);
-      } else {
-        errors.put(ReceiveError.UPDATE, cmd.getRefName());
-      }
-      reject(cmd, "prohibited by Gerrit: ref update access denied");
-    }
-  }
-
-  private boolean isCommit(ReceiveCommand cmd) {
-    RevObject obj;
-    try {
-      obj = rp.getRevWalk().parseAny(cmd.getNewId());
-    } catch (IOException err) {
-      logError("Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName(), err);
-      reject(cmd, "invalid object");
-      return false;
-    }
-
-    if (obj instanceof RevCommit) {
-      return true;
-    }
-    reject(cmd, "not a commit");
-    return false;
-  }
-
-  private void parseDelete(ReceiveCommand cmd) throws PermissionBackendException {
-    logDebug("Deleting {}", cmd);
-    if (cmd.getRefName().startsWith(REFS_CHANGES)) {
-      errors.put(ReceiveError.DELETE_CHANGES, cmd.getRefName());
-      reject(cmd, "cannot delete changes");
-    } else if (canDelete(cmd)) {
-      if (!validRefOperation(cmd)) {
-        return;
-      }
-      actualCommands.add(cmd);
-    } else if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
-      reject(cmd, "cannot delete project configuration");
-    } else {
-      errors.put(ReceiveError.DELETE, cmd.getRefName());
-      reject(cmd, "cannot delete references");
-    }
-  }
-
-  private boolean canDelete(ReceiveCommand cmd) throws PermissionBackendException {
-    try {
-      permissions.ref(cmd.getRefName()).check(RefPermission.DELETE);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
-  }
-
-  private void parseRewind(ReceiveCommand cmd) throws PermissionBackendException {
-    RevCommit newObject;
-    try {
-      newObject = rp.getRevWalk().parseCommit(cmd.getNewId());
-    } catch (IncorrectObjectTypeException notCommit) {
-      newObject = null;
-    } catch (IOException err) {
-      logError(
-          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " forced update",
-          err);
-      reject(cmd, "invalid object");
-      return;
-    }
-    logDebug("Rewinding {}", cmd);
-
-    if (newObject != null) {
-      validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
-      if (cmd.getResult() != NOT_ATTEMPTED) {
-        return;
-      }
-    }
-
-    boolean ok;
-    try {
-      permissions.ref(cmd.getRefName()).check(RefPermission.FORCE_UPDATE);
-      ok = true;
-    } catch (AuthException err) {
-      ok = false;
-    }
-    if (ok) {
-      if (!validRefOperation(cmd)) {
-        return;
-      }
-      actualCommands.add(cmd);
-    } else {
-      cmd.setResult(
-          REJECTED_NONFASTFORWARD, " need '" + PermissionRule.FORCE_PUSH + "' privilege.");
-    }
-  }
-
-  static class MagicBranchInput {
-    private static final Splitter COMMAS = Splitter.on(',').omitEmptyStrings();
-
-    final ReceiveCommand cmd;
-    final LabelTypes labelTypes;
-    final NotesMigration notesMigration;
-    private final boolean defaultPublishComments;
-    Branch.NameKey dest;
-    PermissionBackend.ForRef perm;
-    Set<Account.Id> reviewer = Sets.newLinkedHashSet();
-    Set<Account.Id> cc = Sets.newLinkedHashSet();
-    Map<String, Short> labels = new HashMap<>();
-    String message;
-    List<RevCommit> baseCommit;
-    CmdLineParser clp;
-    Set<String> hashtags = new HashSet<>();
-
-    @Option(name = "--base", metaVar = "BASE", usage = "merge base of changes")
-    List<ObjectId> base;
-
-    @Option(name = "--topic", metaVar = "NAME", usage = "attach topic to changes")
-    String topic;
-
-    @Option(
-      name = "--draft",
-      usage =
-          "Will be removed. Before that, this option will be mapped to '--private'"
-              + "for new changes and '--edit' for existing changes"
-    )
-    boolean draft;
-
-    boolean publish;
-
-    @Option(name = "--private", usage = "mark new/updated change as private")
-    boolean isPrivate;
-
-    @Option(name = "--remove-private", usage = "remove privacy flag from updated change")
-    boolean removePrivate;
-
-    @Option(
-      name = "--wip",
-      aliases = {"-work-in-progress"},
-      usage = "mark change as work in progress"
-    )
-    boolean workInProgress;
-
-    @Option(name = "--ready", usage = "mark change as ready")
-    boolean ready;
-
-    @Option(
-      name = "--edit",
-      aliases = {"-e"},
-      usage = "upload as change edit"
-    )
-    boolean edit;
-
-    @Option(name = "--submit", usage = "immediately submit the change")
-    boolean submit;
-
-    @Option(name = "--merged", usage = "create single change for a merged commit")
-    boolean merged;
-
-    @Option(name = "--publish-comments", usage = "publish all draft comments on updated changes")
-    private boolean publishComments;
-
-    @Option(
-      name = "--no-publish-comments",
-      aliases = {"--np"},
-      usage = "do not publish draft comments"
-    )
-    private boolean noPublishComments;
-
-    @Option(
-      name = "--notify",
-      usage =
-          "Notify handling that defines to whom email notifications "
-              + "should be sent. Allowed values are NONE, OWNER, "
-              + "OWNER_REVIEWERS, ALL. If not set, the default is ALL."
-    )
-    private NotifyHandling notify;
-
-    @Option(name = "--notify-to", metaVar = "USER", usage = "user that should be notified")
-    List<Account.Id> tos = new ArrayList<>();
-
-    @Option(name = "--notify-cc", metaVar = "USER", usage = "user that should be CC'd")
-    List<Account.Id> ccs = new ArrayList<>();
-
-    @Option(name = "--notify-bcc", metaVar = "USER", usage = "user that should be BCC'd")
-    List<Account.Id> bccs = new ArrayList<>();
-
-    @Option(
-      name = "--reviewer",
-      aliases = {"-r"},
-      metaVar = "EMAIL",
-      usage = "add reviewer to changes"
-    )
-    void reviewer(Account.Id id) {
-      reviewer.add(id);
-    }
-
-    @Option(name = "--cc", metaVar = "EMAIL", usage = "notify user by CC")
-    void cc(Account.Id id) {
-      cc.add(id);
-    }
-
-    @Option(
-      name = "--label",
-      aliases = {"-l"},
-      metaVar = "LABEL+VALUE",
-      usage = "label(s) to assign (defaults to +1 if no value provided"
-    )
-    void addLabel(String token) throws CmdLineException {
-      LabelVote v = LabelVote.parse(token);
-      try {
-        LabelType.checkName(v.label());
-        ApprovalsUtil.checkLabel(labelTypes, v.label(), v.value());
-      } catch (BadRequestException e) {
-        throw clp.reject(e.getMessage());
-      }
-      labels.put(v.label(), v.value());
-    }
-
-    @Option(
-      name = "--message",
-      aliases = {"-m"},
-      metaVar = "MESSAGE",
-      usage = "Comment message to apply to the review"
-    )
-    void addMessage(String token) {
-      // git push does not allow spaces in refs.
-      message = token.replace("_", " ");
-    }
-
-    @Option(
-      name = "--hashtag",
-      aliases = {"-t"},
-      metaVar = "HASHTAG",
-      usage = "add hashtag to changes"
-    )
-    void addHashtag(String token) throws CmdLineException {
-      if (!notesMigration.readChanges()) {
-        throw clp.reject("cannot add hashtags; noteDb is disabled");
-      }
-      String hashtag = cleanupHashtag(token);
-      if (!hashtag.isEmpty()) {
-        hashtags.add(hashtag);
-      }
-      // TODO(dpursehouse): validate hashtags
-    }
-
-    MagicBranchInput(
-        IdentifiedUser user,
-        ReceiveCommand cmd,
-        LabelTypes labelTypes,
-        NotesMigration notesMigration) {
-      this.cmd = cmd;
-      this.draft = cmd.getRefName().startsWith(MagicBranch.NEW_DRAFT_CHANGE);
-      this.publish = cmd.getRefName().startsWith(MagicBranch.NEW_PUBLISH_CHANGE);
-      this.labelTypes = labelTypes;
-      this.notesMigration = notesMigration;
-      GeneralPreferencesInfo prefs = user.getAccount().getGeneralPreferencesInfo();
-      this.defaultPublishComments =
-          prefs != null
-              ? firstNonNull(
-                  user.getAccount().getGeneralPreferencesInfo().publishCommentsOnPush, false)
-              : false;
-    }
-
-    MailRecipients getMailRecipients() {
-      return new MailRecipients(reviewer, cc);
-    }
-
-    ListMultimap<RecipientType, Account.Id> getAccountsToNotify() {
-      ListMultimap<RecipientType, Account.Id> accountsToNotify =
-          MultimapBuilder.hashKeys().arrayListValues().build();
-      accountsToNotify.putAll(RecipientType.TO, tos);
-      accountsToNotify.putAll(RecipientType.CC, ccs);
-      accountsToNotify.putAll(RecipientType.BCC, bccs);
-      return accountsToNotify;
-    }
-
-    boolean shouldPublishComments() {
-      if (publishComments) {
-        return true;
-      } else if (noPublishComments) {
-        return false;
-      }
-      return defaultPublishComments;
-    }
-
-    String parse(
-        CmdLineParser clp,
-        Repository repo,
-        Set<String> refs,
-        ListMultimap<String, String> pushOptions)
-        throws CmdLineException {
-      String ref = RefNames.fullName(MagicBranch.getDestBranchName(cmd.getRefName()));
-
-      ListMultimap<String, String> options = LinkedListMultimap.create(pushOptions);
-      int optionStart = ref.indexOf('%');
-      if (0 < optionStart) {
-        for (String s : COMMAS.split(ref.substring(optionStart + 1))) {
-          int e = s.indexOf('=');
-          if (0 < e) {
-            options.put(s.substring(0, e), s.substring(e + 1));
-          } else {
-            options.put(s, "");
-          }
-        }
-        ref = ref.substring(0, optionStart);
-      }
-
-      if (!options.isEmpty()) {
-        clp.parseOptionMap(options);
-      }
-
-      // Split the destination branch by branch and topic. The topic
-      // suffix is entirely optional, so it might not even exist.
-      String head = readHEAD(repo);
-      int split = ref.length();
-      for (; ; ) {
-        String name = ref.substring(0, split);
-        if (refs.contains(name) || name.equals(head)) {
-          break;
-        }
-
-        split = name.lastIndexOf('/', split - 1);
-        if (split <= Constants.R_REFS.length()) {
-          return ref;
-        }
-      }
-      if (split < ref.length()) {
-        topic = Strings.emptyToNull(ref.substring(split + 1));
-      }
-      return ref.substring(0, split);
-    }
-
-    NotifyHandling getNotify() {
-      if (notify != null) {
-        return notify;
-      }
-      if (workInProgress) {
-        return NotifyHandling.OWNER;
-      }
-      return NotifyHandling.ALL;
-    }
-
-    NotifyHandling getNotify(ChangeNotes notes) {
-      if (notify != null) {
-        return notify;
-      }
-      if (workInProgress || (!ready && notes.getChange().isWorkInProgress())) {
-        return NotifyHandling.OWNER;
-      }
-      return NotifyHandling.ALL;
-    }
-  }
-
-  /**
-   * Gets an unmodifiable view of the pushOptions.
-   *
-   * <p>The collection is empty if the client does not support push options, or if the client did
-   * not send any options.
-   *
-   * @return an unmodifiable view of pushOptions.
-   */
-  @Nullable
-  ListMultimap<String, String> getPushOptions() {
-    return ImmutableListMultimap.copyOf(pushOptions);
-  }
-
-  private void parseMagicBranch(ReceiveCommand cmd) throws PermissionBackendException {
-    // Permit exactly one new change request per push.
-    if (magicBranch != null) {
-      reject(cmd, "duplicate request");
-      return;
-    }
-
-    logDebug("Found magic branch {}", cmd.getRefName());
-    magicBranch = new MagicBranchInput(user, cmd, labelTypes, notesMigration);
-    magicBranch.reviewer.addAll(extraReviewers.get(ReviewerStateInternal.REVIEWER));
-    magicBranch.cc.addAll(extraReviewers.get(ReviewerStateInternal.CC));
-
-    String ref;
-    CmdLineParser clp = optionParserFactory.create(magicBranch);
-    magicBranch.clp = clp;
-
-    try {
-      ref = magicBranch.parse(clp, repo, rp.getAdvertisedRefs().keySet(), pushOptions);
-    } catch (CmdLineException e) {
-      if (!clp.wasHelpRequestedByOption()) {
-        logDebug("Invalid branch syntax");
-        reject(cmd, e.getMessage());
-        return;
-      }
-      ref = null; // never happen
-    }
-
-    if (magicBranch.topic != null && magicBranch.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
-      reject(
-          cmd, String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
-    }
-
-    if (clp.wasHelpRequestedByOption()) {
-      StringWriter w = new StringWriter();
-      w.write("\nHelp for refs/for/branch:\n\n");
-      clp.printUsage(w, null);
-      addMessage(w.toString());
-      reject(cmd, "see help");
-      return;
-    }
-    if (projectControl.getProjectState().isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
-      logDebug("Handling {}", RefNames.REFS_USERS_SELF);
-      ref = RefNames.refsUsers(user.getAccountId());
-    }
-    if (!rp.getAdvertisedRefs().containsKey(ref)
-        && !ref.equals(readHEAD(repo))
-        && !ref.equals(RefNames.REFS_CONFIG)) {
-      logDebug("Ref {} not found", ref);
-      if (ref.startsWith(Constants.R_HEADS)) {
-        String n = ref.substring(Constants.R_HEADS.length());
-        reject(cmd, "branch " + n + " not found");
-      } else {
-        reject(cmd, ref + " not found");
-      }
-      return;
-    }
-
-    magicBranch.dest = new Branch.NameKey(project.getNameKey(), ref);
-    magicBranch.perm = permissions.ref(ref);
-    if (!projectControl.getProject().getState().permitsWrite()) {
-      reject(cmd, "project state does not permit write");
-      return;
-    }
-
-    try {
-      magicBranch.perm.check(RefPermission.CREATE_CHANGE);
-    } catch (AuthException denied) {
-      errors.put(ReceiveError.CODE_REVIEW, ref);
-      reject(cmd, denied.getMessage());
-      return;
-    }
-
-    if (magicBranch.isPrivate && magicBranch.removePrivate) {
-      reject(cmd, "the options 'private' and 'remove-private' are mutually exclusive");
-      return;
-    }
-
-    if (magicBranch.workInProgress && magicBranch.ready) {
-      reject(cmd, "the options 'wip' and 'ready' are mutually exclusive");
-      return;
-    }
-    if (magicBranch.publishComments && magicBranch.noPublishComments) {
-      reject(
-          cmd, "the options 'publish-comments' and 'no-publish-comments' are mutually exclusive");
-      return;
-    }
-
-    if (magicBranch.submit) {
-      try {
-        permissions.ref(ref).check(RefPermission.UPDATE_BY_SUBMIT);
-      } catch (AuthException e) {
-        reject(cmd, e.getMessage());
-        return;
-      }
-    }
-
-    RevWalk walk = rp.getRevWalk();
-    RevCommit tip;
-    try {
-      tip = walk.parseCommit(magicBranch.cmd.getNewId());
-      logDebug("Tip of push: {}", tip.name());
-    } catch (IOException ex) {
-      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", ex);
-      return;
-    }
-
-    String destBranch = magicBranch.dest.get();
-    try {
-      if (magicBranch.merged) {
-        if (magicBranch.base != null) {
-          reject(cmd, "cannot use merged with base");
-          return;
-        }
-        RevCommit branchTip = readBranchTip(cmd, magicBranch.dest);
-        if (branchTip == null) {
-          return; // readBranchTip already rejected cmd.
-        }
-        if (!walk.isMergedInto(tip, branchTip)) {
-          reject(cmd, "not merged into branch");
-          return;
-        }
-      }
-
-      // If tip is a merge commit, or the root commit or
-      // if %base or %merged was specified, ignore newChangeForAllNotInTarget.
-      if (tip.getParentCount() > 1
-          || magicBranch.base != null
-          || magicBranch.merged
-          || tip.getParentCount() == 0) {
-        logDebug("Forcing newChangeForAllNotInTarget = false");
-        newChangeForAllNotInTarget = false;
-      }
-
-      if (magicBranch.base != null) {
-        logDebug("Handling %base: {}", magicBranch.base);
-        magicBranch.baseCommit = Lists.newArrayListWithCapacity(magicBranch.base.size());
-        for (ObjectId id : magicBranch.base) {
-          try {
-            magicBranch.baseCommit.add(walk.parseCommit(id));
-          } catch (IncorrectObjectTypeException notCommit) {
-            reject(cmd, "base must be a commit");
-            return;
-          } catch (MissingObjectException e) {
-            reject(cmd, "base not found");
-            return;
-          } catch (IOException e) {
-            logWarn(String.format("Project %s cannot read %s", project.getName(), id.name()), e);
-            reject(cmd, "internal server error");
-            return;
-          }
-        }
-      } else if (newChangeForAllNotInTarget) {
-        RevCommit branchTip = readBranchTip(cmd, magicBranch.dest);
-        if (branchTip == null) {
-          return; // readBranchTip already rejected cmd.
-        }
-        magicBranch.baseCommit = Collections.singletonList(branchTip);
-        logDebug("Set baseCommit = {}", magicBranch.baseCommit.get(0).name());
-      }
-    } catch (IOException ex) {
-      logWarn(
-          String.format("Error walking to %s in project %s", destBranch, project.getName()), ex);
-      reject(cmd, "internal server error");
-      return;
-    }
-
-    // Validate that the new commits are connected with the target
-    // branch.  If they aren't, we want to abort. We do this check by
-    // looking to see if we can compute a merge base between the new
-    // commits and the target branch head.
-    //
-    try {
-      Ref targetRef = rp.getAdvertisedRefs().get(magicBranch.dest.get());
-      if (targetRef == null || targetRef.getObjectId() == null) {
-        // The destination branch does not yet exist. Assume the
-        // history being sent for review will start it and thus
-        // is "connected" to the branch.
-        logDebug("Branch is unborn");
-        return;
-      }
-      RevCommit h = walk.parseCommit(targetRef.getObjectId());
-      logDebug("Current branch tip: {}", h.name());
-      RevFilter oldRevFilter = walk.getRevFilter();
-      try {
-        walk.reset();
-        walk.setRevFilter(RevFilter.MERGE_BASE);
-        walk.markStart(tip);
-        walk.markStart(h);
-        if (walk.next() == null) {
-          reject(magicBranch.cmd, "no common ancestry");
-        }
-      } finally {
-        walk.reset();
-        walk.setRevFilter(oldRevFilter);
-      }
-    } catch (IOException e) {
-      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", e);
-    }
-  }
-
-  private static String readHEAD(Repository repo) {
-    try {
-      return repo.getFullBranch();
-    } catch (IOException e) {
-      log.error("Cannot read HEAD symref", e);
-      return null;
-    }
-  }
-
-  private RevCommit readBranchTip(ReceiveCommand cmd, Branch.NameKey branch) throws IOException {
-    Ref r = allRefs().get(branch.get());
-    if (r == null) {
-      reject(cmd, branch.get() + " not found");
-      return null;
-    }
-    return rp.getRevWalk().parseCommit(r.getObjectId());
-  }
-
-  private void parseReplaceCommand(ReceiveCommand cmd, Change.Id changeId) {
-    logDebug("Parsing replace command");
-    if (cmd.getType() != ReceiveCommand.Type.CREATE) {
-      reject(cmd, "invalid usage");
-      return;
-    }
-
-    RevCommit newCommit;
-    try {
-      newCommit = rp.getRevWalk().parseCommit(cmd.getNewId());
-      logDebug("Replacing with {}", newCommit);
-    } catch (IOException e) {
-      logError("Cannot parse " + cmd.getNewId().name() + " as commit", e);
-      reject(cmd, "invalid commit");
-      return;
-    }
-
-    Change changeEnt;
-    try {
-      changeEnt = notesFactory.createChecked(db, project.getNameKey(), changeId).getChange();
-    } catch (NoSuchChangeException e) {
-      logError("Change not found " + changeId, e);
-      reject(cmd, "change " + changeId + " not found");
-      return;
-    } catch (OrmException e) {
-      logError("Cannot lookup existing change " + changeId, e);
-      reject(cmd, "database error");
-      return;
-    }
-    if (!project.getNameKey().equals(changeEnt.getProject())) {
-      reject(cmd, "change " + changeId + " does not belong to project " + project.getName());
-      return;
-    }
-
-    logDebug("Replacing change {}", changeEnt.getId());
-    requestReplace(cmd, true, changeEnt, newCommit);
-  }
-
-  private boolean requestReplace(
-      ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit) {
-    if (change.getStatus().isClosed()) {
-      reject(
-          cmd,
-          changeFormatter.changeClosed(
-              ChangeReportFormatter.Input.builder().setChange(change).build()));
-      return false;
-    }
-
-    ReplaceRequest req = new ReplaceRequest(change.getId(), newCommit, cmd, checkMergedInto);
-    if (replaceByChange.containsKey(req.ontoChange)) {
-      reject(cmd, "duplicate request");
-      return false;
-    }
-    replaceByChange.put(req.ontoChange, req);
-    return true;
-  }
-
-  private void selectNewAndReplacedChangesFromMagicBranch() {
-    logDebug("Finding new and replaced changes");
-    newChanges = new ArrayList<>();
-
-    ListMultimap<ObjectId, Ref> existing = changeRefsById();
-    GroupCollector groupCollector =
-        GroupCollector.create(changeRefsById(), db, psUtil, notesFactory, project.getNameKey());
-
-    try {
-      RevCommit start = setUpWalkForSelectingChanges();
-      if (start == null) {
-        return;
-      }
-
-      LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>();
-      Set<Change.Key> newChangeIds = new HashSet<>();
-      int maxBatchChanges = receiveConfig.getEffectiveMaxBatchChangesLimit(user);
-      int total = 0;
-      int alreadyTracked = 0;
-      boolean rejectImplicitMerges =
-          start.getParentCount() == 1
-              && projectCache.get(project.getNameKey()).isRejectImplicitMerges()
-              // Don't worry about implicit merges when creating changes for
-              // already-merged commits; they're already in history, so it's too
-              // late.
-              && !magicBranch.merged;
-      Set<RevCommit> mergedParents;
-      if (rejectImplicitMerges) {
-        mergedParents = new HashSet<>();
-      } else {
-        mergedParents = null;
-      }
-
-      for (; ; ) {
-        RevCommit c = rp.getRevWalk().next();
-        if (c == null) {
-          break;
-        }
-        total++;
-        rp.getRevWalk().parseBody(c);
-        String name = c.name();
-        groupCollector.visit(c);
-        Collection<Ref> existingRefs = existing.get(c);
-
-        if (rejectImplicitMerges) {
-          Collections.addAll(mergedParents, c.getParents());
-          mergedParents.remove(c);
-        }
-
-        boolean commitAlreadyTracked = !existingRefs.isEmpty();
-        if (commitAlreadyTracked) {
-          alreadyTracked++;
-          // Corner cases where an existing commit might need a new group:
-          // A) Existing commit has a null group; wasn't assigned during schema
-          //    upgrade, or schema upgrade is performed on a running server.
-          // B) Let A<-B<-C, then:
-          //      1. Push A to refs/heads/master
-          //      2. Push B to refs/for/master
-          //      3. Force push A~ to refs/heads/master
-          //      4. Push C to refs/for/master.
-          //      B will be in existing so we aren't replacing the patch set. It
-          //      used to have its own group, but now needs to to be changed to
-          //      A's group.
-          // C) Commit is a PatchSet of a pre-existing change uploaded with a
-          //    different target branch.
-          for (Ref ref : existingRefs) {
-            updateGroups.add(new UpdateGroupsRequest(ref, c));
-          }
-          if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
-            continue;
-          }
-        }
-
-        List<String> idList = c.getFooterLines(CHANGE_ID);
-
-        String idStr = !idList.isEmpty() ? idList.get(idList.size() - 1).trim() : null;
-
-        if (idStr != null) {
-          pending.put(c, new ChangeLookup(c, new Change.Key(idStr)));
-        } else {
-          pending.put(c, new ChangeLookup(c));
-        }
-        int n = pending.size() + newChanges.size();
-        if (maxBatchChanges != 0 && n > maxBatchChanges) {
-          logDebug("{} changes exceeds limit of {}", n, maxBatchChanges);
-          reject(
-              magicBranch.cmd,
-              "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
-          newChanges = Collections.emptyList();
-          return;
-        }
-
-        if (commitAlreadyTracked) {
-          boolean changeExistsOnDestBranch = false;
-          for (ChangeData cd : pending.get(c).destChanges) {
-            if (cd.change().getDest().equals(magicBranch.dest)) {
-              changeExistsOnDestBranch = true;
-              break;
-            }
-          }
-          if (changeExistsOnDestBranch) {
-            continue;
-          }
-
-          logDebug("Creating new change for {} even though it is already tracked", name);
-        }
-
-        if (!validCommit(rp.getRevWalk(), magicBranch.perm, magicBranch.dest, magicBranch.cmd, c)) {
-          // Not a change the user can propose? Abort as early as possible.
-          newChanges = Collections.emptyList();
-          logDebug("Aborting early due to invalid commit");
-          return;
-        }
-
-        // Don't allow merges to be uploaded in commit chain via all-not-in-target
-        if (newChangeForAllNotInTarget && c.getParentCount() > 1) {
-          reject(
-              magicBranch.cmd,
-              "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
-                  + "to override please set the base manually");
-          logDebug("Rejecting merge commit {} with newChangeForAllNotInTarget", name);
-          // TODO(dborowitz): Should we early return here?
-        }
-
-        if (idList.isEmpty()) {
-          newChanges.add(new CreateRequest(c, magicBranch.dest.get()));
-          continue;
-        }
-      }
-      logDebug(
-          "Finished initial RevWalk with {} commits total: {} already"
-              + " tracked, {} new changes with no Change-Id, and {} deferred"
-              + " lookups",
-          total,
-          alreadyTracked,
-          newChanges.size(),
-          pending.size());
-
-      if (rejectImplicitMerges) {
-        rejectImplicitMerges(mergedParents);
-      }
-
-      for (Iterator<ChangeLookup> itr = pending.values().iterator(); itr.hasNext(); ) {
-        ChangeLookup p = itr.next();
-        if (p.changeKey == null) {
-          continue;
-        }
-
-        if (newChangeIds.contains(p.changeKey)) {
-          logDebug("Multiple commits with Change-Id {}", p.changeKey);
-          reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
-          newChanges = Collections.emptyList();
-          return;
-        }
-
-        List<ChangeData> changes = p.destChanges;
-        if (changes.size() > 1) {
-          logDebug(
-              "Multiple changes in branch {} with Change-Id {}: {}",
-              magicBranch.dest,
-              p.changeKey,
-              changes.stream().map(cd -> cd.getId().toString()).collect(joining()));
-          // WTF, multiple changes in this branch have the same key?
-          // Since the commit is new, the user should recreate it with
-          // a different Change-Id. In practice, we should never see
-          // this error message as Change-Id should be unique per branch.
-          //
-          reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
-          newChanges = Collections.emptyList();
-          return;
-        }
-
-        if (changes.size() == 1) {
-          // Schedule as a replacement to this one matching change.
-          //
-
-          RevId currentPs = changes.get(0).currentPatchSet().getRevision();
-          // If Commit is already current PatchSet of target Change.
-          if (p.commit.name().equals(currentPs.get())) {
-            if (pending.size() == 1) {
-              // There are no commits left to check, all commits in pending were already
-              // current PatchSet of the corresponding target changes.
-              reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
-            } else {
-              // Commit is already current PatchSet.
-              // Remove from pending and try next commit.
-              itr.remove();
-              continue;
-            }
-          }
-          if (requestReplace(magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
-            continue;
-          }
-          newChanges = Collections.emptyList();
-          return;
-        }
-
-        if (changes.size() == 0) {
-          if (!isValidChangeId(p.changeKey.get())) {
-            reject(magicBranch.cmd, "invalid Change-Id");
-            newChanges = Collections.emptyList();
-            return;
-          }
-
-          // In case the change look up from the index failed,
-          // double check against the existing refs
-          if (foundInExistingRef(existing.get(p.commit))) {
-            if (pending.size() == 1) {
-              reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
-              newChanges = Collections.emptyList();
-              return;
-            }
-            itr.remove();
-            continue;
-          }
-          newChangeIds.add(p.changeKey);
-        }
-        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get()));
-      }
-      logDebug(
-          "Finished deferred lookups with {} updates and {} new changes",
-          replaceByChange.size(),
-          newChanges.size());
-    } catch (IOException e) {
-      // Should never happen, the core receive process would have
-      // identified the missing object earlier before we got control.
-      //
-      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", e);
-      newChanges = Collections.emptyList();
-      return;
-    } catch (OrmException e) {
-      logError("Cannot query database to locate prior changes", e);
-      reject(magicBranch.cmd, "database error");
-      newChanges = Collections.emptyList();
-      return;
-    }
-
-    if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
-      reject(magicBranch.cmd, "no new changes");
-      return;
-    }
-    if (!newChanges.isEmpty() && magicBranch.edit) {
-      reject(magicBranch.cmd, "edit is not supported for new changes");
-      return;
-    }
-
-    try {
-      SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
-      List<Integer> newIds = seq.nextChangeIds(newChanges.size());
-      for (int i = 0; i < newChanges.size(); i++) {
-        CreateRequest create = newChanges.get(i);
-        create.setChangeId(newIds.get(i));
-        create.groups = ImmutableList.copyOf(groups.get(create.commit));
-      }
-      for (ReplaceRequest replace : replaceByChange.values()) {
-        replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
-      }
-      for (UpdateGroupsRequest update : updateGroups) {
-        update.groups = ImmutableList.copyOf((groups.get(update.commit)));
-      }
-      logDebug("Finished updating groups from GroupCollector");
-    } catch (OrmException e) {
-      logError("Error collecting groups for changes", e);
-      reject(magicBranch.cmd, "internal server error");
-      return;
-    }
-  }
-
-  private boolean foundInExistingRef(Collection<Ref> existingRefs) throws OrmException {
-    for (Ref ref : existingRefs) {
-      ChangeNotes notes =
-          notesFactory.create(db, project.getNameKey(), Change.Id.fromRef(ref.getName()));
-      Change change = notes.getChange();
-      if (change.getDest().equals(magicBranch.dest)) {
-        logDebug("Found change {} from existing refs.", change.getKey());
-        // Reindex the change asynchronously, ignoring errors.
-        @SuppressWarnings("unused")
-        Future<?> possiblyIgnoredError = indexer.indexAsync(project.getNameKey(), change.getId());
-        return true;
-      }
-    }
-    return false;
-  }
-
-  private RevCommit setUpWalkForSelectingChanges() throws IOException {
-    RevWalk rw = rp.getRevWalk();
-    RevCommit start = rw.parseCommit(magicBranch.cmd.getNewId());
-
-    rw.reset();
-    rw.sort(RevSort.TOPO);
-    rw.sort(RevSort.REVERSE, true);
-    rp.getRevWalk().markStart(start);
-    if (magicBranch.baseCommit != null) {
-      markExplicitBasesUninteresting();
-    } else if (magicBranch.merged) {
-      logDebug("Marking parents of merged commit {} uninteresting", start.name());
-      for (RevCommit c : start.getParents()) {
-        rw.markUninteresting(c);
-      }
-    } else {
-      markHeadsAsUninteresting(rw, magicBranch.dest != null ? magicBranch.dest.get() : null);
-    }
-    return start;
-  }
-
-  private void markExplicitBasesUninteresting() throws IOException {
-    logDebug("Marking {} base commits uninteresting", magicBranch.baseCommit.size());
-    for (RevCommit c : magicBranch.baseCommit) {
-      rp.getRevWalk().markUninteresting(c);
-    }
-    Ref targetRef = allRefs().get(magicBranch.dest.get());
-    if (targetRef != null) {
-      logDebug(
-          "Marking target ref {} ({}) uninteresting",
-          magicBranch.dest.get(),
-          targetRef.getObjectId().name());
-      rp.getRevWalk().markUninteresting(rp.getRevWalk().parseCommit(targetRef.getObjectId()));
-    }
-  }
-
-  private void rejectImplicitMerges(Set<RevCommit> mergedParents) throws IOException {
-    if (!mergedParents.isEmpty()) {
-      Ref targetRef = allRefs().get(magicBranch.dest.get());
-      if (targetRef != null) {
-        RevWalk rw = rp.getRevWalk();
-        RevCommit tip = rw.parseCommit(targetRef.getObjectId());
-        boolean containsImplicitMerges = true;
-        for (RevCommit p : mergedParents) {
-          containsImplicitMerges &= !rw.isMergedInto(p, tip);
-        }
-
-        if (containsImplicitMerges) {
-          rw.reset();
-          for (RevCommit p : mergedParents) {
-            rw.markStart(p);
-          }
-          rw.markUninteresting(tip);
-          RevCommit c;
-          while ((c = rw.next()) != null) {
-            rw.parseBody(c);
-            messages.add(
-                new CommitValidationMessage(
-                    "ERROR: Implicit Merge of "
-                        + c.abbreviate(7).name()
-                        + " "
-                        + c.getShortMessage(),
-                    false));
-          }
-          reject(magicBranch.cmd, "implicit merges detected");
-        }
-      }
-    }
-  }
-
-  private void markHeadsAsUninteresting(RevWalk rw, @Nullable String forRef) {
-    int i = 0;
-    for (Ref ref : allRefs().values()) {
-      if ((ref.getName().startsWith(R_HEADS) || ref.getName().equals(forRef))
-          && ref.getObjectId() != null) {
-        try {
-          rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
-          i++;
-        } catch (IOException e) {
-          logWarn(String.format("Invalid ref %s in %s", ref.getName(), project.getName()), e);
-        }
-      }
-    }
-    logDebug("Marked {} heads as uninteresting", i);
-  }
-
-  private static boolean isValidChangeId(String idStr) {
-    return idStr.matches("^I[0-9a-fA-F]{40}$") && !idStr.matches("^I00*$");
-  }
-
-  private class ChangeLookup {
-    final RevCommit commit;
-    final Change.Key changeKey;
-    final List<ChangeData> destChanges;
-
-    ChangeLookup(RevCommit c, Change.Key key) throws OrmException {
-      commit = c;
-      changeKey = key;
-      destChanges = queryProvider.get().byBranchKey(magicBranch.dest, key);
-    }
-
-    ChangeLookup(RevCommit c) throws OrmException {
-      commit = c;
-      destChanges = queryProvider.get().byBranchCommit(magicBranch.dest, c.getName());
-      changeKey = null;
-    }
-  }
-
-  private class CreateRequest {
-    final RevCommit commit;
-    private final String refName;
-
-    Change.Id changeId;
-    ReceiveCommand cmd;
-    ChangeInserter ins;
-    List<String> groups = ImmutableList.of();
-
-    Change change;
-
-    CreateRequest(RevCommit commit, String refName) {
-      this.commit = commit;
-      this.refName = refName;
-    }
-
-    private void setChangeId(int id) {
-      boolean privateByDefault = projectCache.get(project.getNameKey()).isPrivateByDefault();
-
-      changeId = new Change.Id(id);
-      ins =
-          changeInserterFactory
-              .create(changeId, commit, refName)
-              .setTopic(magicBranch.topic)
-              .setPrivate(
-                  magicBranch.draft
-                      || magicBranch.isPrivate
-                      || (privateByDefault && !magicBranch.removePrivate))
-              .setWorkInProgress(magicBranch.workInProgress)
-              // Changes already validated in validateNewCommits.
-              .setValidate(false);
-
-      if (magicBranch.merged) {
-        ins.setStatus(Change.Status.MERGED);
-      }
-      cmd = new ReceiveCommand(ObjectId.zeroId(), commit, ins.getPatchSetId().toRefName());
-      if (rp.getPushCertificate() != null) {
-        ins.setPushCertificate(rp.getPushCertificate().toTextWithSignature());
-      }
-    }
-
-    private void addOps(BatchUpdate bu) throws RestApiException {
-      checkState(changeId != null, "must call setChangeId before addOps");
-      try {
-        RevWalk rw = rp.getRevWalk();
-        rw.parseBody(commit);
-        final PatchSet.Id psId = ins.setGroups(groups).getPatchSetId();
-        Account.Id me = user.getAccountId();
-        List<FooterLine> footerLines = commit.getFooterLines();
-        MailRecipients recipients = new MailRecipients();
-        Map<String, Short> approvals = new HashMap<>();
-        checkNotNull(magicBranch);
-        recipients.add(magicBranch.getMailRecipients());
-        approvals = magicBranch.labels;
-        recipients.add(getRecipientsFromFooters(accountResolver, footerLines));
-        recipients.remove(me);
-        StringBuilder msg =
-            new StringBuilder(
-                ApprovalsUtil.renderMessageWithApprovals(
-                    psId.get(), approvals, Collections.<String, PatchSetApproval>emptyMap()));
-        msg.append('.');
-        if (!Strings.isNullOrEmpty(magicBranch.message)) {
-          msg.append("\n").append(magicBranch.message);
-        }
-
-        bu.insertChange(
-            ins.setReviewers(recipients.getReviewers())
-                .setExtraCC(recipients.getCcOnly())
-                .setApprovals(approvals)
-                .setMessage(msg.toString())
-                .setNotify(magicBranch.getNotify())
-                .setAccountsToNotify(magicBranch.getAccountsToNotify())
-                .setRequestScopePropagator(requestScopePropagator)
-                .setSendMail(true)
-                .setPatchSetDescription(magicBranch.message));
-        if (!magicBranch.hashtags.isEmpty()) {
-          // Any change owner is allowed to add hashtags when creating a change.
-          bu.addOp(
-              changeId,
-              hashtagsFactory.create(new HashtagsInput(magicBranch.hashtags)).setFireEvent(false));
-        }
-        if (!Strings.isNullOrEmpty(magicBranch.topic)) {
-          bu.addOp(
-              changeId,
-              new BatchUpdateOp() {
-                @Override
-                public boolean updateChange(ChangeContext ctx) {
-                  ctx.getUpdate(psId).setTopic(magicBranch.topic);
-                  return true;
-                }
-              });
-        }
-        bu.addOp(
-            changeId,
-            new BatchUpdateOp() {
-              @Override
-              public boolean updateChange(ChangeContext ctx) {
-                change = ctx.getChange();
-                return false;
-              }
-            });
-        bu.addOp(changeId, new ChangeProgressOp(newProgress));
-      } catch (Exception e) {
-        throw INSERT_EXCEPTION.apply(e);
-      }
-    }
-  }
-
-  private void submit(Collection<CreateRequest> create, Collection<ReplaceRequest> replace)
-      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    Map<ObjectId, Change> bySha = Maps.newHashMapWithExpectedSize(create.size() + replace.size());
-    for (CreateRequest r : create) {
-      checkNotNull(r.change, "cannot submit new change %s; op may not have run", r.changeId);
-      bySha.put(r.commit, r.change);
-    }
-    for (ReplaceRequest r : replace) {
-      bySha.put(r.newCommitId, r.notes.getChange());
-    }
-    Change tipChange = bySha.get(magicBranch.cmd.getNewId());
-    checkNotNull(
-        tipChange, "tip of push does not correspond to a change; found these changes: %s", bySha);
-    logDebug(
-        "Processing submit with tip change {} ({})", tipChange.getId(), magicBranch.cmd.getNewId());
-    try (MergeOp op = mergeOpProvider.get()) {
-      op.merge(db, tipChange, user, false, new SubmitInput(), false);
-    }
-  }
-
-  private void preparePatchSetsForReplace() {
-    try {
-      readChangesForReplace();
-      for (Iterator<ReplaceRequest> itr = replaceByChange.values().iterator(); itr.hasNext(); ) {
-        ReplaceRequest req = itr.next();
-        if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
-          req.validate(false);
-          if (req.skip && req.cmd == null) {
-            itr.remove();
-          }
-        }
-      }
-    } catch (OrmException err) {
-      logError(
-          String.format(
-              "Cannot read database before replacement for project %s", project.getName()),
-          err);
-      for (ReplaceRequest req : replaceByChange.values()) {
-        if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
-          req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
-        }
-      }
-    } catch (IOException | PermissionBackendException err) {
-      logError(
-          String.format(
-              "Cannot read repository before replacement for project %s", project.getName()),
-          err);
-      for (ReplaceRequest req : replaceByChange.values()) {
-        if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
-          req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
-        }
-      }
-    }
-    logDebug("Read {} changes to replace", replaceByChange.size());
-
-    if (magicBranch != null && magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
-      // Cancel creations tied to refs/for/ or refs/drafts/ command.
-      for (ReplaceRequest req : replaceByChange.values()) {
-        if (req.inputCommand == magicBranch.cmd && req.cmd != null) {
-          req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
-        }
-      }
-      for (CreateRequest req : newChanges) {
-        req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
-      }
-    }
-  }
-
-  private void readChangesForReplace() throws OrmException {
-    Collection<ChangeNotes> allNotes =
-        notesFactory.create(
-            db, replaceByChange.values().stream().map(r -> r.ontoChange).collect(toList()));
-    for (ChangeNotes notes : allNotes) {
-      replaceByChange.get(notes.getChangeId()).notes = notes;
-    }
-  }
-
-  private class ReplaceRequest {
-    final Change.Id ontoChange;
-    final ObjectId newCommitId;
-    final ReceiveCommand inputCommand;
-    final boolean checkMergedInto;
-    ChangeNotes notes;
-    BiMap<RevCommit, PatchSet.Id> revisions;
-    PatchSet.Id psId;
-    ReceiveCommand prev;
-    ReceiveCommand cmd;
-    PatchSetInfo info;
-    boolean skip;
-    private PatchSet.Id priorPatchSet;
-    List<String> groups = ImmutableList.of();
-    private ReplaceOp replaceOp;
-
-    ReplaceRequest(
-        Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto) {
-      this.ontoChange = toChange;
-      this.newCommitId = newCommit.copy();
-      this.inputCommand = checkNotNull(cmd);
-      this.checkMergedInto = checkMergedInto;
-
-      revisions = HashBiMap.create();
-      for (Ref ref : refs(toChange)) {
-        try {
-          revisions.forcePut(
-              rp.getRevWalk().parseCommit(ref.getObjectId()), PatchSet.Id.fromRef(ref.getName()));
-        } catch (IOException err) {
-          logWarn(
-              String.format(
-                  "Project %s contains invalid change ref %s", project.getName(), ref.getName()),
-              err);
-        }
-      }
-    }
-
-    /**
-     * Validate the new patch set commit for this change.
-     *
-     * <p><strong>Side effects:</strong>
-     *
-     * <ul>
-     *   <li>May add error or warning messages to the progress monitor
-     *   <li>Will reject {@code cmd} prior to returning false
-     *   <li>May reset {@code rp.getRevWalk()}; do not call in the middle of a walk.
-     * </ul>
-     *
-     * @param autoClose whether the caller intends to auto-close the change after adding a new patch
-     *     set.
-     * @return whether the new commit is valid
-     * @throws IOException
-     * @throws OrmException
-     * @throws PermissionBackendException
-     */
-    boolean validate(boolean autoClose)
-        throws IOException, OrmException, PermissionBackendException {
-      if (!autoClose && inputCommand.getResult() != NOT_ATTEMPTED) {
-        return false;
-      } else if (notes == null) {
-        reject(inputCommand, "change " + ontoChange + " not found");
-        return false;
-      }
-
-      Change change = notes.getChange();
-      priorPatchSet = change.currentPatchSetId();
-      if (!revisions.containsValue(priorPatchSet)) {
-        reject(inputCommand, "change " + ontoChange + " missing revisions");
-        return false;
-      }
-
-      RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
-      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
-      try {
-        permissions.change(notes).database(db).check(ChangePermission.ADD_PATCH_SET);
-      } catch (AuthException no) {
-        reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
-        return false;
-      }
-
-      if (change.getStatus().isClosed()) {
-        reject(inputCommand, "change " + ontoChange + " closed");
-        return false;
-      } else if (revisions.containsKey(newCommit)) {
-        reject(inputCommand, "commit already exists (in the change)");
-        return false;
-      }
-
-      for (Ref r : rp.getRepository().getRefDatabase().getRefs("refs/changes").values()) {
-        if (r.getObjectId().equals(newCommit)) {
-          reject(inputCommand, "commit already exists (in the project)");
-          return false;
-        }
-      }
-
-      for (RevCommit prior : revisions.keySet()) {
-        // Don't allow a change to directly depend upon itself. This is a
-        // very common error due to users making a new commit rather than
-        // amending when trying to address review comments.
-        if (rp.getRevWalk().isMergedInto(prior, newCommit)) {
-          reject(inputCommand, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
-          return false;
-        }
-      }
-
-      PermissionBackend.ForRef perm = permissions.ref(change.getDest().get());
-      if (!validCommit(rp.getRevWalk(), perm, change.getDest(), inputCommand, newCommit)) {
-        return false;
-      }
-      rp.getRevWalk().parseBody(priorCommit);
-
-      // Don't allow the same tree if the commit message is unmodified
-      // or no parents were updated (rebase), else warn that only part
-      // of the commit was modified.
-      if (newCommit.getTree().equals(priorCommit.getTree())) {
-        boolean messageEq = eq(newCommit.getFullMessage(), priorCommit.getFullMessage());
-        boolean parentsEq = parentsEqual(newCommit, priorCommit);
-        boolean authorEq = authorEqual(newCommit, priorCommit);
-        ObjectReader reader = rp.getRevWalk().getObjectReader();
-
-        if (messageEq && parentsEq && authorEq && !autoClose) {
-          addMessage(
-              String.format(
-                  "(W) No changes between prior commit %s and new commit %s",
-                  reader.abbreviate(priorCommit).name(), reader.abbreviate(newCommit).name()));
-        } else {
-          StringBuilder msg = new StringBuilder();
-          msg.append("(I) ");
-          msg.append(reader.abbreviate(newCommit).name());
-          msg.append(":");
-          msg.append(" no files changed");
-          if (!authorEq) {
-            msg.append(", author changed");
-          }
-          if (!messageEq) {
-            msg.append(", message updated");
-          }
-          if (!parentsEq) {
-            msg.append(", was rebased");
-          }
-          addMessage(msg.toString());
-        }
-      }
-
-      if (magicBranch != null
-          && (magicBranch.workInProgress || magicBranch.ready)
-          && magicBranch.workInProgress != change.isWorkInProgress()
-          && !user.getAccountId().equals(change.getOwner())) {
-        reject(inputCommand, ONLY_OWNER_CAN_MODIFY_WIP);
-        return false;
-      }
-
-      if (magicBranch != null && (magicBranch.edit || magicBranch.draft)) {
-        return newEdit();
-      }
-
-      newPatchSet();
-      return true;
-    }
-
-    private boolean newEdit() {
-      psId = notes.getChange().currentPatchSetId();
-      Optional<ChangeEdit> edit = null;
-
-      try {
-        edit = editUtil.byChange(notes, user);
-      } catch (AuthException | IOException e) {
-        logError("Cannot retrieve edit", e);
-        return false;
-      }
-
-      if (edit.isPresent()) {
-        if (edit.get().getBasePatchSet().getId().equals(psId)) {
-          // replace edit
-          cmd =
-              new ReceiveCommand(edit.get().getEditCommit(), newCommitId, edit.get().getRefName());
-        } else {
-          // delete old edit ref on rebase
-          prev =
-              new ReceiveCommand(
-                  edit.get().getEditCommit(), ObjectId.zeroId(), edit.get().getRefName());
-          createEditCommand();
-        }
-      } else {
-        createEditCommand();
-      }
-
-      return true;
-    }
-
-    private void createEditCommand() {
-      // create new edit
-      cmd =
-          new ReceiveCommand(
-              ObjectId.zeroId(),
-              newCommitId,
-              RefNames.refsEdit(user.getAccountId(), notes.getChangeId(), psId));
-    }
-
-    private void newPatchSet() throws IOException, OrmException {
-      RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
-      psId =
-          ChangeUtil.nextPatchSetIdFromAllRefsMap(allRefs(), notes.getChange().currentPatchSetId());
-      info = patchSetInfoFactory.get(rp.getRevWalk(), newCommit, psId);
-      cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName());
-    }
-
-    void addOps(BatchUpdate bu, @Nullable Task progress) throws IOException {
-      if (magicBranch != null && (magicBranch.edit || magicBranch.draft)) {
-        bu.addOp(notes.getChangeId(), new ReindexOnlyOp());
-        if (prev != null) {
-          bu.addRepoOnlyOp(new UpdateOneRefOp(prev));
-        }
-        bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
-        return;
-      }
-      RevWalk rw = rp.getRevWalk();
-      // TODO(dborowitz): Move to ReplaceOp#updateRepo.
-      RevCommit newCommit = rw.parseCommit(newCommitId);
-      rw.parseBody(newCommit);
-
-      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
-      replaceOp =
-          replaceOpFactory
-              .create(
-                  projectControl,
-                  notes.getChange().getDest(),
-                  checkMergedInto,
-                  priorPatchSet,
-                  priorCommit,
-                  psId,
-                  newCommit,
-                  info,
-                  groups,
-                  magicBranch,
-                  rp.getPushCertificate())
-              .setRequestScopePropagator(requestScopePropagator);
-      bu.addOp(notes.getChangeId(), replaceOp);
-      if (progress != null) {
-        bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
-      }
-    }
-
-    String getRejectMessage() {
-      return replaceOp != null ? replaceOp.getRejectMessage() : null;
-    }
-  }
-
-  private class UpdateGroupsRequest {
-    private final PatchSet.Id psId;
-    private final RevCommit commit;
-    List<String> groups = ImmutableList.of();
-
-    UpdateGroupsRequest(Ref ref, RevCommit commit) {
-      this.psId = checkNotNull(PatchSet.Id.fromRef(ref.getName()));
-      this.commit = commit;
-    }
-
-    private void addOps(BatchUpdate bu) {
-      bu.addOp(
-          psId.getParentKey(),
-          new BatchUpdateOp() {
-            @Override
-            public boolean updateChange(ChangeContext ctx) throws OrmException {
-              PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-              List<String> oldGroups = ps.getGroups();
-              if (oldGroups == null) {
-                if (groups == null) {
-                  return false;
-                }
-              } else if (sameGroups(oldGroups, groups)) {
-                return false;
-              }
-              psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, groups);
-              return true;
-            }
-          });
-    }
-
-    private boolean sameGroups(List<String> a, List<String> b) {
-      return Sets.newHashSet(a).equals(Sets.newHashSet(b));
-    }
-  }
-
-  private class UpdateOneRefOp implements RepoOnlyOp {
-    private final ReceiveCommand cmd;
-
-    private UpdateOneRefOp(ReceiveCommand cmd) {
-      this.cmd = checkNotNull(cmd);
-    }
-
-    @Override
-    public void updateRepo(RepoContext ctx) throws IOException {
-      ctx.addRefUpdate(cmd);
-    }
-
-    @Override
-    public void postUpdate(Context ctx) {
-      String refName = cmd.getRefName();
-      if (cmd.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
-        logDebug("Updating tag cache on fast-forward of {}", cmd.getRefName());
-        tagCache.updateFastForward(project.getNameKey(), refName, cmd.getOldId(), cmd.getNewId());
-      }
-      if (isConfig(cmd)) {
-        logDebug("Reloading project in cache");
-        projectCache.evict(project);
-        ProjectState ps = projectCache.get(project.getNameKey());
-        try {
-          logDebug("Updating project description");
-          repo.setGitwebDescription(ps.getProject().getDescription());
-        } catch (IOException e) {
-          log.warn("cannot update description of " + project.getName(), e);
-        }
-      }
-    }
-  }
-
-  private static class ReindexOnlyOp implements BatchUpdateOp {
-    @Override
-    public boolean updateChange(ChangeContext ctx) {
-      // Trigger reindexing even though change isn't actually updated.
-      return true;
-    }
-  }
-
-  private List<Ref> refs(Change.Id changeId) {
-    return refsByChange().get(changeId);
-  }
-
-  private void initChangeRefMaps() {
-    if (refsByChange == null) {
-      int estRefsPerChange = 4;
-      refsById = MultimapBuilder.hashKeys().arrayListValues().build();
-      refsByChange =
-          MultimapBuilder.hashKeys(allRefs().size() / estRefsPerChange)
-              .arrayListValues(estRefsPerChange)
-              .build();
-      for (Ref ref : allRefs().values()) {
-        ObjectId obj = ref.getObjectId();
-        if (obj != null) {
-          PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-          if (psId != null) {
-            refsById.put(obj, ref);
-            refsByChange.put(psId.getParentKey(), ref);
-          }
-        }
-      }
-    }
-  }
-
-  private ListMultimap<Change.Id, Ref> refsByChange() {
-    initChangeRefMaps();
-    return refsByChange;
-  }
-
-  private ListMultimap<ObjectId, Ref> changeRefsById() {
-    initChangeRefMaps();
-    return refsById;
-  }
-
-  static boolean parentsEqual(RevCommit a, RevCommit b) {
-    if (a.getParentCount() != b.getParentCount()) {
-      return false;
-    }
-    for (int i = 0; i < a.getParentCount(); i++) {
-      if (!a.getParent(i).equals(b.getParent(i))) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  static boolean authorEqual(RevCommit a, RevCommit b) {
-    PersonIdent aAuthor = a.getAuthorIdent();
-    PersonIdent bAuthor = b.getAuthorIdent();
-
-    if (aAuthor == null && bAuthor == null) {
-      return true;
-    } else if (aAuthor == null || bAuthor == null) {
-      return false;
-    }
-
-    return eq(aAuthor.getName(), bAuthor.getName())
-        && eq(aAuthor.getEmailAddress(), bAuthor.getEmailAddress());
-  }
-
-  static boolean eq(String a, String b) {
-    if (a == null && b == null) {
-      return true;
-    } else if (a == null || b == null) {
-      return false;
-    } else {
-      return a.equals(b);
-    }
-  }
-
-  private boolean validRefOperation(ReceiveCommand cmd) {
-    RefOperationValidators refValidators = refValidatorsFactory.create(getProject(), user, cmd);
-
-    try {
-      messages.addAll(refValidators.validateForRefOperation());
-    } catch (RefOperationValidationException e) {
-      messages.addAll(Lists.newArrayList(e.getMessages()));
-      reject(cmd, e.getMessage());
-      return false;
-    }
-
-    return true;
-  }
-
-  private void validateNewCommits(Branch.NameKey branch, ReceiveCommand cmd)
-      throws PermissionBackendException {
-    PermissionBackend.ForRef perm = permissions.ref(branch.get());
-    if (!RefNames.REFS_CONFIG.equals(cmd.getRefName())
-        && !(MagicBranch.isMagicBranch(cmd.getRefName())
-            || NEW_PATCHSET_PATTERN.matcher(cmd.getRefName()).matches())
-        && pushOptions.containsKey(PUSH_OPTION_SKIP_VALIDATION)) {
-      try {
-        perm.check(RefPermission.SKIP_VALIDATION);
-        if (!Iterables.isEmpty(rejectCommits)) {
-          throw new AuthException("reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
-        }
-        logDebug("Short-circuiting new commit validation");
-      } catch (AuthException denied) {
-        reject(cmd, denied.getMessage());
-      }
-      return;
-    }
-
-    boolean missingFullName = Strings.isNullOrEmpty(user.getAccount().getFullName());
-    RevWalk walk = rp.getRevWalk();
-    walk.reset();
-    walk.sort(RevSort.NONE);
-    try {
-      RevObject parsedObject = walk.parseAny(cmd.getNewId());
-      if (!(parsedObject instanceof RevCommit)) {
-        return;
-      }
-      ListMultimap<ObjectId, Ref> existing = changeRefsById();
-      walk.markStart((RevCommit) parsedObject);
-      markHeadsAsUninteresting(walk, cmd.getRefName());
-      int limit = receiveConfig.maxBatchCommits;
-      int n = 0;
-      for (RevCommit c; (c = walk.next()) != null; ) {
-        if (++n > limit) {
-          logDebug("Number of new commits exceeds limit of {}", limit);
-          addMessage(
-              "Cannot push more than "
-                  + limit
-                  + " commits to "
-                  + branch.get()
-                  + " without "
-                  + PUSH_OPTION_SKIP_VALIDATION
-                  + " option");
-          reject(cmd, "too many commits");
-          return;
-        }
-        if (existing.keySet().contains(c)) {
-          continue;
-        } else if (!validCommit(walk, perm, branch, cmd, c)) {
-          break;
-        }
-
-        if (missingFullName && user.hasEmailAddress(c.getCommitterIdent().getEmailAddress())) {
-          logDebug("Will update full name of caller");
-          setFullNameTo = c.getCommitterIdent().getName();
-          missingFullName = false;
-        }
-      }
-      logDebug("Validated {} new commits", n);
-    } catch (IOException err) {
-      cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", err);
-    }
-  }
-
-  private boolean validCommit(
-      RevWalk rw,
-      PermissionBackend.ForRef perm,
-      Branch.NameKey branch,
-      ReceiveCommand cmd,
-      ObjectId id)
-      throws IOException {
-
-    if (validCommits.contains(id)) {
-      return true;
-    }
-
-    RevCommit c = rw.parseCommit(id);
-    rw.parseBody(c);
-
-    try (CommitReceivedEvent receiveEvent =
-        new CommitReceivedEvent(cmd, project, branch.get(), rw.getObjectReader(), c, user)) {
-      boolean isMerged =
-          magicBranch != null
-              && cmd.getRefName().equals(magicBranch.cmd.getRefName())
-              && magicBranch.merged;
-      CommitValidators validators =
-          isMerged
-              ? commitValidatorsFactory.forMergedCommits(perm, user.asIdentifiedUser())
-              : commitValidatorsFactory.forReceiveCommits(
-                  perm, branch, user.asIdentifiedUser(), sshInfo, repo, rw);
-      messages.addAll(validators.validate(receiveEvent));
-    } catch (CommitValidationException e) {
-      logDebug("Commit validation failed on {}", c.name());
-      messages.addAll(e.getMessages());
-      reject(cmd, e.getMessage());
-      return false;
-    }
-    validCommits.add(c.copy());
-    return true;
-  }
-
-  private void autoCloseChanges(ReceiveCommand cmd) {
-    logDebug("Starting auto-closing of changes");
-    String refName = cmd.getRefName();
-    checkState(
-        !MagicBranch.isMagicBranch(refName),
-        "shouldn't be auto-closing changes on magic branch %s",
-        refName);
-    // TODO(dborowitz): Combine this BatchUpdate with the main one in
-    // insertChangesAndPatchSets.
-    try (BatchUpdate bu =
-            batchUpdateFactory.create(
-                db, projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
-        ObjectInserter ins = repo.newObjectInserter();
-        ObjectReader reader = ins.newReader();
-        RevWalk rw = new RevWalk(reader)) {
-      bu.setRepository(repo, rw, ins).updateChangesInParallel();
-      bu.setRequestId(receiveId);
-      // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
-
-      RevCommit newTip = rw.parseCommit(cmd.getNewId());
-      Branch.NameKey branch = new Branch.NameKey(project.getNameKey(), refName);
-
-      rw.reset();
-      rw.markStart(newTip);
-      if (!ObjectId.zeroId().equals(cmd.getOldId())) {
-        rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
-      }
-
-      ListMultimap<ObjectId, Ref> byCommit = changeRefsById();
-      Map<Change.Key, ChangeNotes> byKey = null;
-      List<ReplaceRequest> replaceAndClose = new ArrayList<>();
-
-      int existingPatchSets = 0;
-      int newPatchSets = 0;
-      COMMIT:
-      for (RevCommit c; (c = rw.next()) != null; ) {
-        rw.parseBody(c);
-
-        for (Ref ref : byCommit.get(c.copy())) {
-          PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-          Optional<ChangeData> cd = byLegacyId(psId.getParentKey());
-          if (cd.isPresent() && cd.get().change().getDest().equals(branch)) {
-            existingPatchSets++;
-            bu.addOp(
-                psId.getParentKey(),
-                mergedByPushOpFactory.create(requestScopePropagator, psId, refName));
-            continue COMMIT;
-          }
-        }
-
-        for (String changeId : c.getFooterLines(CHANGE_ID)) {
-          if (byKey == null) {
-            byKey = openChangesByKeyByBranch(branch);
-          }
-
-          ChangeNotes onto = byKey.get(new Change.Key(changeId.trim()));
-          if (onto != null) {
-            newPatchSets++;
-            // Hold onto this until we're done with the walk, as the call to
-            // req.validate below calls isMergedInto which resets the walk.
-            ReplaceRequest req = new ReplaceRequest(onto.getChangeId(), c, cmd, false);
-            req.notes = onto;
-            replaceAndClose.add(req);
-            continue COMMIT;
-          }
-        }
-      }
-
-      for (ReplaceRequest req : replaceAndClose) {
-        Change.Id id = req.notes.getChangeId();
-        if (!req.validate(true)) {
-          logDebug("Not closing {} because validation failed", id);
-          continue;
-        }
-        req.addOps(bu, null);
-        bu.addOp(
-            id,
-            mergedByPushOpFactory
-                .create(requestScopePropagator, req.psId, refName)
-                .setPatchSetProvider(
-                    new Provider<PatchSet>() {
-                      @Override
-                      public PatchSet get() {
-                        return req.replaceOp.getPatchSet();
-                      }
-                    }));
-        bu.addOp(id, new ChangeProgressOp(closeProgress));
-      }
-
-      logDebug(
-          "Auto-closing {} changes with existing patch sets and {} with new patch sets",
-          existingPatchSets,
-          newPatchSets);
-      bu.execute();
-    } catch (RestApiException e) {
-      logError("Can't insert patchset", e);
-    } catch (IOException | OrmException | UpdateException | PermissionBackendException e) {
-      logError("Can't scan for changes to close", e);
-    }
-  }
-
-  private void updateAccountInfo() {
-    if (setFullNameTo == null) {
-      return;
-    }
-    logDebug("Updating full name of caller");
-    try {
-      Account account =
-          accountsUpdate
-              .create()
-              .update(
-                  user.getAccountId(),
-                  a -> {
-                    if (Strings.isNullOrEmpty(a.getFullName())) {
-                      a.setFullName(setFullNameTo);
-                    }
-                  });
-      if (account != null) {
-        user.getAccount().setFullName(account.getFullName());
-      }
-    } catch (IOException | ConfigInvalidException e) {
-      logWarn("Failed to update full name of caller", e);
-    }
-  }
-
-  private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(Branch.NameKey branch)
-      throws OrmException {
-    Map<Change.Key, ChangeNotes> r = new HashMap<>();
-    for (ChangeData cd : queryProvider.get().byBranchOpen(branch)) {
-      try {
-        r.put(cd.change().getKey(), cd.notes());
-      } catch (NoSuchChangeException e) {
-        //Ignore deleted change
-      }
-    }
-    return r;
-  }
-
-  private Optional<ChangeData> byLegacyId(Change.Id legacyId) throws OrmException {
-    List<ChangeData> res = queryProvider.get().byLegacyChangeId(legacyId);
-    if (res.isEmpty()) {
-      return Optional.empty();
-    }
-    return Optional.of(res.get(0));
-  }
-
-  private Map<String, Ref> allRefs() {
-    return allRefsWatcher.getAllRefs();
-  }
-
-  private void reject(@Nullable ReceiveCommand cmd, String why) {
-    if (cmd != null) {
-      cmd.setResult(REJECTED_OTHER_REASON, why);
-      commandProgress.update(1);
-    }
-  }
-
-  private static boolean isHead(ReceiveCommand cmd) {
-    return cmd.getRefName().startsWith(Constants.R_HEADS);
-  }
-
-  private static boolean isConfig(ReceiveCommand cmd) {
-    return cmd.getRefName().equals(RefNames.REFS_CONFIG);
-  }
-
-  private void logDebug(String msg, Object... args) {
-    if (log.isDebugEnabled()) {
-      log.debug(receiveId + msg, args);
-    }
-  }
-
-  private void logWarn(String msg, Throwable t) {
-    if (log.isWarnEnabled()) {
-      if (t != null) {
-        log.warn(receiveId + msg, t);
-      } else {
-        log.warn(receiveId + msg);
-      }
-    }
-  }
-
-  private void logWarn(String msg) {
-    logWarn(msg, null);
-  }
-
-  private void logError(String msg, Throwable t) {
-    if (log.isErrorEnabled()) {
-      if (t != null) {
-        log.error(receiveId + msg, t);
-      } else {
-        log.error(receiveId + msg);
-      }
-    }
-  }
-
-  private void logError(String msg) {
-    logError(msg, null);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
deleted file mode 100644
index 3645392..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
+++ /dev/null
@@ -1,136 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.receive;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.util.MagicBranch;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
-import java.util.Collections;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.transport.AdvertiseRefsHook;
-import org.eclipse.jgit.transport.BaseReceivePack;
-import org.eclipse.jgit.transport.ServiceMayNotContinueException;
-import org.eclipse.jgit.transport.UploadPack;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Exposes only the non refs/changes/ reference names. */
-public class ReceiveCommitsAdvertiseRefsHook implements AdvertiseRefsHook {
-  private static final Logger log = LoggerFactory.getLogger(ReceiveCommitsAdvertiseRefsHook.class);
-
-  @VisibleForTesting
-  @AutoValue
-  public abstract static class Result {
-    public abstract Map<String, Ref> allRefs();
-
-    public abstract Set<ObjectId> additionalHaves();
-  }
-
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final Project.NameKey projectName;
-
-  public ReceiveCommitsAdvertiseRefsHook(
-      Provider<InternalChangeQuery> queryProvider, Project.NameKey projectName) {
-    this.queryProvider = queryProvider;
-    this.projectName = projectName;
-  }
-
-  @Override
-  public void advertiseRefs(UploadPack us) {
-    throw new UnsupportedOperationException(
-        "ReceiveCommitsAdvertiseRefsHook cannot be used for UploadPack");
-  }
-
-  @Override
-  public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
-    Result r = advertiseRefs(HookUtil.ensureAllRefsAdvertised(rp));
-    rp.setAdvertisedRefs(r.allRefs(), r.additionalHaves());
-  }
-
-  @VisibleForTesting
-  public Result advertiseRefs(Map<String, Ref> oldRefs) {
-    Map<String, Ref> r = Maps.newHashMapWithExpectedSize(oldRefs.size());
-    Set<ObjectId> allPatchSets = Sets.newHashSetWithExpectedSize(oldRefs.size());
-    for (Map.Entry<String, Ref> e : oldRefs.entrySet()) {
-      String name = e.getKey();
-      if (!skip(name)) {
-        r.put(name, e.getValue());
-      }
-      if (name.startsWith(RefNames.REFS_CHANGES)) {
-        allPatchSets.add(e.getValue().getObjectId());
-      }
-    }
-    return new AutoValue_ReceiveCommitsAdvertiseRefsHook_Result(
-        r, advertiseOpenChanges(allPatchSets));
-  }
-
-  private static final ImmutableSet<String> OPEN_CHANGES_FIELDS =
-      ImmutableSet.of(
-          // Required for ChangeIsVisibleToPrdicate.
-          ChangeField.CHANGE.getName(),
-          ChangeField.REVIEWER.getName(),
-          // Required during advertiseOpenChanges.
-          ChangeField.PATCH_SET.getName());
-
-  private Set<ObjectId> advertiseOpenChanges(Set<ObjectId> allPatchSets) {
-    // Advertise some recent open changes, in case a commit is based on one.
-    int limit = 32;
-    try {
-      Set<ObjectId> r = Sets.newHashSetWithExpectedSize(limit);
-      for (ChangeData cd :
-          queryProvider
-              .get()
-              .setRequestedFields(OPEN_CHANGES_FIELDS)
-              .enforceVisibility(true)
-              .setLimit(limit)
-              .byProjectOpen(projectName)) {
-        PatchSet ps = cd.currentPatchSet();
-        if (ps != null) {
-          ObjectId id = ObjectId.fromString(ps.getRevision().get());
-          // Ensure we actually observed a patch set ref pointing to this
-          // object, in case the database is out of sync with the repo and the
-          // object doesn't actually exist.
-          if (allPatchSets.contains(id)) {
-            r.add(id);
-          }
-        }
-      }
-      return r;
-    } catch (OrmException err) {
-      log.error("Cannot list open changes of " + projectName, err);
-      return Collections.emptySet();
-    }
-  }
-
-  private static boolean skip(String name) {
-    return name.startsWith(RefNames.REFS_CHANGES)
-        || name.startsWith(RefNames.REFS_CACHE_AUTOMERGE)
-        || MagicBranch.isMagicBranch(name);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConfig.java
deleted file mode 100644
index 89158d3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConfig.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.receive;
-
-import static com.google.gerrit.common.data.GlobalCapability.BATCH_CHANGES_LIMIT;
-
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountLimits;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-class ReceiveConfig {
-  final boolean checkMagicRefs;
-  final boolean checkReferencedObjectsAreReachable;
-  final int maxBatchCommits;
-  private final int systemMaxBatchChanges;
-  private final AccountLimits.Factory limitsFactory;
-
-  @Inject
-  ReceiveConfig(@GerritServerConfig Config config, AccountLimits.Factory limitsFactory) {
-    checkMagicRefs = config.getBoolean("receive", null, "checkMagicRefs", true);
-    checkReferencedObjectsAreReachable =
-        config.getBoolean("receive", null, "checkReferencedObjectsAreReachable", true);
-    maxBatchCommits = config.getInt("receive", null, "maxBatchCommits", 10000);
-    systemMaxBatchChanges = config.getInt("receive", "maxBatchChanges", 0);
-    this.limitsFactory = limitsFactory;
-  }
-
-  public int getEffectiveMaxBatchChangesLimit(CurrentUser user) {
-    AccountLimits limits = limitsFactory.create(user);
-    if (limits.hasExplicitRange(BATCH_CHANGES_LIMIT)) {
-      return limits.getRange(BATCH_CHANGES_LIMIT).getMax();
-    }
-    return systemMaxBatchChanges;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReplaceOp.java
deleted file mode 100644
index 4455aed..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ /dev/null
@@ -1,604 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.receive;
-
-import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
-import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
-import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-import static org.eclipse.jgit.lib.Constants.R_HEADS;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.client.ChangeKind;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.server.ApprovalCopier;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.change.ChangeKindCache;
-import com.google.gerrit.server.change.EmailReviewComments;
-import com.google.gerrit.server.extensions.events.CommentAdded;
-import com.google.gerrit.server.extensions.events.RevisionCreated;
-import com.google.gerrit.server.git.MergedByPushOp;
-import com.google.gerrit.server.git.SendEmailExecutor;
-import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
-import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.server.util.RequestScopePropagator;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.util.Providers;
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.PushCertificate;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ReplaceOp implements BatchUpdateOp {
-  public interface Factory {
-    ReplaceOp create(
-        ProjectControl projectControl,
-        Branch.NameKey dest,
-        boolean checkMergedInto,
-        @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
-        @Assisted("priorCommitId") ObjectId priorCommit,
-        @Assisted("patchSetId") PatchSet.Id patchSetId,
-        @Assisted("commitId") ObjectId commitId,
-        PatchSetInfo info,
-        List<String> groups,
-        @Nullable MagicBranchInput magicBranch,
-        @Nullable PushCertificate pushCertificate);
-  }
-
-  private static final Logger log = LoggerFactory.getLogger(ReplaceOp.class);
-
-  private static final String CHANGE_IS_CLOSED = "change is closed";
-
-  private final AccountResolver accountResolver;
-  private final ApprovalCopier approvalCopier;
-  private final ApprovalsUtil approvalsUtil;
-  private final ChangeData.Factory changeDataFactory;
-  private final ChangeKindCache changeKindCache;
-  private final ChangeMessagesUtil cmUtil;
-  private final CommentsUtil commentsUtil;
-  private final EmailReviewComments.Factory emailCommentsFactory;
-  private final ExecutorService sendEmailExecutor;
-  private final RevisionCreated revisionCreated;
-  private final CommentAdded commentAdded;
-  private final MergedByPushOp.Factory mergedByPushOpFactory;
-  private final PatchSetUtil psUtil;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
-  private final ProjectCache projectCache;
-
-  private final ProjectControl projectControl;
-  private final Branch.NameKey dest;
-  private final boolean checkMergedInto;
-  private final PatchSet.Id priorPatchSetId;
-  private final ObjectId priorCommitId;
-  private final PatchSet.Id patchSetId;
-  private final ObjectId commitId;
-  private final PatchSetInfo info;
-  private final MagicBranchInput magicBranch;
-  private final PushCertificate pushCertificate;
-  private List<String> groups = ImmutableList.of();
-
-  private final Map<String, Short> approvals = new HashMap<>();
-  private final MailRecipients recipients = new MailRecipients();
-  private RevCommit commit;
-  private ReceiveCommand cmd;
-  private ChangeNotes notes;
-  private PatchSet newPatchSet;
-  private ChangeKind changeKind;
-  private ChangeMessage msg;
-  private List<Comment> comments = ImmutableList.of();
-  private String rejectMessage;
-  private MergedByPushOp mergedByPushOp;
-  private RequestScopePropagator requestScopePropagator;
-
-  @Inject
-  ReplaceOp(
-      AccountResolver accountResolver,
-      ApprovalCopier approvalCopier,
-      ApprovalsUtil approvalsUtil,
-      ChangeData.Factory changeDataFactory,
-      ChangeKindCache changeKindCache,
-      ChangeMessagesUtil cmUtil,
-      CommentsUtil commentsUtil,
-      EmailReviewComments.Factory emailCommentsFactory,
-      RevisionCreated revisionCreated,
-      CommentAdded commentAdded,
-      MergedByPushOp.Factory mergedByPushOpFactory,
-      PatchSetUtil psUtil,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
-      ProjectCache projectCache,
-      @SendEmailExecutor ExecutorService sendEmailExecutor,
-      @Assisted ProjectControl projectControl,
-      @Assisted Branch.NameKey dest,
-      @Assisted boolean checkMergedInto,
-      @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
-      @Assisted("priorCommitId") ObjectId priorCommitId,
-      @Assisted("patchSetId") PatchSet.Id patchSetId,
-      @Assisted("commitId") ObjectId commitId,
-      @Assisted PatchSetInfo info,
-      @Assisted List<String> groups,
-      @Assisted @Nullable MagicBranchInput magicBranch,
-      @Assisted @Nullable PushCertificate pushCertificate) {
-    this.accountResolver = accountResolver;
-    this.approvalCopier = approvalCopier;
-    this.approvalsUtil = approvalsUtil;
-    this.changeDataFactory = changeDataFactory;
-    this.changeKindCache = changeKindCache;
-    this.cmUtil = cmUtil;
-    this.commentsUtil = commentsUtil;
-    this.emailCommentsFactory = emailCommentsFactory;
-    this.revisionCreated = revisionCreated;
-    this.commentAdded = commentAdded;
-    this.mergedByPushOpFactory = mergedByPushOpFactory;
-    this.psUtil = psUtil;
-    this.replacePatchSetFactory = replacePatchSetFactory;
-    this.projectCache = projectCache;
-    this.sendEmailExecutor = sendEmailExecutor;
-
-    this.projectControl = projectControl;
-    this.dest = dest;
-    this.checkMergedInto = checkMergedInto;
-    this.priorPatchSetId = priorPatchSetId;
-    this.priorCommitId = priorCommitId.copy();
-    this.patchSetId = patchSetId;
-    this.commitId = commitId.copy();
-    this.info = info;
-    this.groups = groups;
-    this.magicBranch = magicBranch;
-    this.pushCertificate = pushCertificate;
-  }
-
-  @Override
-  public void updateRepo(RepoContext ctx) throws Exception {
-    commit = ctx.getRevWalk().parseCommit(commitId);
-    ctx.getRevWalk().parseBody(commit);
-    changeKind =
-        changeKindCache.getChangeKind(
-            projectControl.getProject().getNameKey(),
-            ctx.getRevWalk(),
-            ctx.getRepoView().getConfig(),
-            priorCommitId,
-            commitId);
-
-    if (checkMergedInto) {
-      String mergedInto = findMergedInto(ctx, dest.get(), commit);
-      if (mergedInto != null) {
-        mergedByPushOp =
-            mergedByPushOpFactory.create(requestScopePropagator, patchSetId, mergedInto);
-      }
-    }
-
-    cmd = new ReceiveCommand(ObjectId.zeroId(), commitId, patchSetId.toRefName());
-    ctx.addRefUpdate(cmd);
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx)
-      throws RestApiException, OrmException, IOException, PermissionBackendException {
-    notes = ctx.getNotes();
-    Change change = notes.getChange();
-    if (change == null || change.getStatus().isClosed()) {
-      rejectMessage = CHANGE_IS_CLOSED;
-      return false;
-    }
-    if (groups.isEmpty()) {
-      PatchSet prevPs = psUtil.current(ctx.getDb(), notes);
-      groups = prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of();
-    }
-
-    ChangeUpdate update = ctx.getUpdate(patchSetId);
-    update.setSubjectForCommit("Create patch set " + patchSetId.get());
-
-    String reviewMessage = null;
-    String psDescription = null;
-    if (magicBranch != null) {
-      recipients.add(magicBranch.getMailRecipients());
-      reviewMessage = magicBranch.message;
-      psDescription = magicBranch.message;
-      approvals.putAll(magicBranch.labels);
-      Set<String> hashtags = magicBranch.hashtags;
-      if (hashtags != null && !hashtags.isEmpty()) {
-        hashtags.addAll(notes.getHashtags());
-        update.setHashtags(hashtags);
-      }
-      if (magicBranch.topic != null && !magicBranch.topic.equals(ctx.getChange().getTopic())) {
-        update.setTopic(magicBranch.topic);
-      }
-      if (magicBranch.removePrivate) {
-        change.setPrivate(false);
-        update.setPrivate(false);
-      } else if (magicBranch.isPrivate) {
-        change.setPrivate(true);
-        update.setPrivate(true);
-      }
-      if (magicBranch.ready) {
-        change.setWorkInProgress(false);
-        change.setReviewStarted(true);
-        update.setWorkInProgress(false);
-      } else if (magicBranch.workInProgress) {
-        change.setWorkInProgress(true);
-        update.setWorkInProgress(true);
-      }
-      if (shouldPublishComments()) {
-        boolean workInProgress = change.isWorkInProgress();
-        if (magicBranch != null && magicBranch.workInProgress) {
-          workInProgress = true;
-        }
-        comments = publishComments(ctx, workInProgress);
-      }
-    }
-
-    newPatchSet =
-        psUtil.insert(
-            ctx.getDb(),
-            ctx.getRevWalk(),
-            update,
-            patchSetId,
-            commitId,
-            groups,
-            pushCertificate != null ? pushCertificate.toTextWithSignature() : null,
-            psDescription);
-
-    update.setPsDescription(psDescription);
-    recipients.add(getRecipientsFromFooters(accountResolver, commit.getFooterLines()));
-    recipients.remove(ctx.getAccountId());
-    ChangeData cd = changeDataFactory.create(ctx.getDb(), ctx.getNotes());
-    MailRecipients oldRecipients = getRecipientsFromReviewers(cd.reviewers());
-    Iterable<PatchSetApproval> newApprovals =
-        approvalsUtil.addApprovalsForNewPatchSet(
-            ctx.getDb(),
-            update,
-            projectControl.getProjectState().getLabelTypes(),
-            newPatchSet,
-            ctx.getUser(),
-            approvals);
-    approvalCopier.copyInReviewDb(
-        ctx.getDb(),
-        ctx.getNotes(),
-        ctx.getUser(),
-        newPatchSet,
-        ctx.getRevWalk(),
-        ctx.getRepoView().getConfig(),
-        newApprovals);
-    approvalsUtil.addReviewers(
-        ctx.getDb(),
-        update,
-        projectControl.getProjectState().getLabelTypes(),
-        change,
-        newPatchSet,
-        info,
-        recipients.getReviewers(),
-        oldRecipients.getAll());
-
-    // Check if approvals are changing in with this update. If so, add current user to reviewers.
-    // Note that this is done separately as addReviewers is filtering out the change owner as
-    // reviewer which is needed in several other code paths.
-    if (magicBranch != null && !magicBranch.labels.isEmpty()) {
-      update.putReviewer(ctx.getAccountId(), REVIEWER);
-    }
-
-    recipients.add(oldRecipients);
-
-    msg = createChangeMessage(ctx, reviewMessage);
-    cmUtil.addChangeMessage(ctx.getDb(), update, msg);
-
-    if (mergedByPushOp == null) {
-      resetChange(ctx);
-    } else {
-      mergedByPushOp.setPatchSetProvider(Providers.of(newPatchSet)).updateChange(ctx);
-    }
-
-    return true;
-  }
-
-  private ChangeMessage createChangeMessage(ChangeContext ctx, String reviewMessage)
-      throws OrmException, IOException {
-    String approvalMessage =
-        ApprovalsUtil.renderMessageWithApprovals(
-            patchSetId.get(), approvals, scanLabels(ctx, approvals));
-    String kindMessage = changeKindMessage(changeKind);
-    StringBuilder message = new StringBuilder(approvalMessage);
-    if (!Strings.isNullOrEmpty(kindMessage)) {
-      message.append(kindMessage);
-    } else {
-      message.append('.');
-    }
-    if (comments.size() == 1) {
-      message.append("\n\n(1 comment)");
-    } else if (comments.size() > 1) {
-      message.append(String.format("\n\n(%d comments)", comments.size()));
-    }
-    if (!Strings.isNullOrEmpty(reviewMessage)) {
-      message.append("\n\n").append(reviewMessage);
-    }
-    boolean workInProgress = ctx.getChange().isWorkInProgress();
-    if (magicBranch != null && magicBranch.workInProgress) {
-      workInProgress = true;
-    }
-    return ChangeMessagesUtil.newMessage(
-        patchSetId,
-        ctx.getUser(),
-        ctx.getWhen(),
-        message.toString(),
-        ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
-  }
-
-  private String changeKindMessage(ChangeKind changeKind) {
-    switch (changeKind) {
-      case MERGE_FIRST_PARENT_UPDATE:
-      case TRIVIAL_REBASE:
-      case NO_CHANGE:
-        return ": Patch Set " + priorPatchSetId.get() + " was rebased.";
-      case NO_CODE_CHANGE:
-        return ": Commit message was updated.";
-      case REWORK:
-      default:
-        return null;
-    }
-  }
-
-  private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx, Map<String, Short> approvals)
-      throws OrmException, IOException {
-    Map<String, PatchSetApproval> current = new HashMap<>();
-    // We optimize here and only retrieve current when approvals provided
-    if (!approvals.isEmpty()) {
-      for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(
-              ctx.getDb(),
-              ctx.getNotes(),
-              ctx.getUser(),
-              priorPatchSetId,
-              ctx.getAccountId(),
-              ctx.getRevWalk(),
-              ctx.getRepoView().getConfig())) {
-        if (a.isLegacySubmit()) {
-          continue;
-        }
-
-        LabelType lt = projectControl.getProjectState().getLabelTypes().byLabel(a.getLabelId());
-        if (lt != null) {
-          current.put(lt.getName(), a);
-        }
-      }
-    }
-    return current;
-  }
-
-  private void resetChange(ChangeContext ctx) {
-    Change change = ctx.getChange();
-    if (!change.currentPatchSetId().equals(priorPatchSetId)) {
-      return;
-    }
-
-    if (magicBranch != null && magicBranch.topic != null) {
-      change.setTopic(magicBranch.topic);
-    }
-    change.setStatus(Change.Status.NEW);
-    change.setCurrentPatchSet(info);
-
-    List<String> idList = commit.getFooterLines(CHANGE_ID);
-    if (idList.isEmpty()) {
-      change.setKey(new Change.Key("I" + commitId.name()));
-    } else {
-      change.setKey(new Change.Key(idList.get(idList.size() - 1).trim()));
-    }
-  }
-
-  private List<Comment> publishComments(ChangeContext ctx, boolean workInProgress)
-      throws OrmException {
-    List<Comment> comments =
-        commentsUtil.draftByChangeAuthor(ctx.getDb(), ctx.getNotes(), ctx.getUser().getAccountId());
-    commentsUtil.publish(
-        ctx, patchSetId, comments, ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
-    return comments;
-  }
-
-  @Override
-  public void postUpdate(Context ctx) throws Exception {
-    if (changeKind != ChangeKind.TRIVIAL_REBASE) {
-      // TODO(dborowitz): Merge email templates so we only have to send one.
-      Runnable e = new ReplaceEmailTask(ctx);
-      if (requestScopePropagator != null) {
-        @SuppressWarnings("unused")
-        Future<?> possiblyIgnoredError = sendEmailExecutor.submit(requestScopePropagator.wrap(e));
-      } else {
-        e.run();
-      }
-    }
-
-    NotifyHandling notify = magicBranch != null ? magicBranch.getNotify(notes) : NotifyHandling.ALL;
-
-    if (shouldPublishComments()) {
-      emailCommentsFactory
-          .create(
-              notify,
-              magicBranch != null ? magicBranch.getAccountsToNotify() : ImmutableListMultimap.of(),
-              notes,
-              newPatchSet,
-              ctx.getUser().asIdentifiedUser(),
-              msg,
-              comments,
-              msg.getMessage(),
-              ImmutableList.of()) // TODO(dborowitz): Include labels.
-          .sendAsync();
-    }
-
-    revisionCreated.fire(notes.getChange(), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
-    try {
-      fireCommentAddedEvent(ctx);
-    } catch (Exception e) {
-      log.warn("comment-added event invocation failed", e);
-    }
-    if (mergedByPushOp != null) {
-      mergedByPushOp.postUpdate(ctx);
-    }
-  }
-
-  private class ReplaceEmailTask implements Runnable {
-    private final Context ctx;
-
-    private ReplaceEmailTask(Context ctx) {
-      this.ctx = ctx;
-    }
-
-    @Override
-    public void run() {
-      try {
-        ReplacePatchSetSender cm =
-            replacePatchSetFactory.create(
-                projectControl.getProject().getNameKey(), notes.getChangeId());
-        cm.setFrom(ctx.getAccount().getId());
-        cm.setPatchSet(newPatchSet, info);
-        cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
-        if (magicBranch != null) {
-          cm.setNotify(magicBranch.getNotify(notes));
-          cm.setAccountsToNotify(magicBranch.getAccountsToNotify());
-        }
-        cm.addReviewers(recipients.getReviewers());
-        cm.addExtraCC(recipients.getCcOnly());
-        cm.send();
-      } catch (Exception e) {
-        log.error("Cannot send email for new patch set " + newPatchSet.getId(), e);
-      }
-    }
-
-    @Override
-    public String toString() {
-      return "send-email newpatchset";
-    }
-  }
-
-  private void fireCommentAddedEvent(Context ctx) throws IOException {
-    if (approvals.isEmpty()) {
-      return;
-    }
-
-    /* For labels that are not set in this operation, show the "current" value
-     * of 0, and no oldValue as the value was not modified by this operation.
-     * For labels that are set in this operation, the value was modified, so
-     * show a transition from an oldValue of 0 to the new value.
-     */
-    List<LabelType> labels =
-        projectCache
-            .checkedGet(ctx.getProject())
-            .getLabelTypes(notes, ctx.getUser())
-            .getLabelTypes();
-    Map<String, Short> allApprovals = new HashMap<>();
-    Map<String, Short> oldApprovals = new HashMap<>();
-    for (LabelType lt : labels) {
-      allApprovals.put(lt.getName(), (short) 0);
-      oldApprovals.put(lt.getName(), null);
-    }
-    for (Map.Entry<String, Short> entry : approvals.entrySet()) {
-      if (entry.getValue() != 0) {
-        allApprovals.put(entry.getKey(), entry.getValue());
-        oldApprovals.put(entry.getKey(), (short) 0);
-      }
-    }
-
-    commentAdded.fire(
-        notes.getChange(),
-        newPatchSet,
-        ctx.getAccount(),
-        null,
-        allApprovals,
-        oldApprovals,
-        ctx.getWhen());
-  }
-
-  public PatchSet getPatchSet() {
-    return newPatchSet;
-  }
-
-  public Change getChange() {
-    return notes.getChange();
-  }
-
-  public String getRejectMessage() {
-    return rejectMessage;
-  }
-
-  public ReceiveCommand getCommand() {
-    return cmd;
-  }
-
-  public ReplaceOp setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
-    this.requestScopePropagator = requestScopePropagator;
-    return this;
-  }
-
-  private static String findMergedInto(Context ctx, String first, RevCommit commit) {
-    try {
-      RevWalk rw = ctx.getRevWalk();
-      Optional<ObjectId> firstId = ctx.getRepoView().getRef(first);
-      if (firstId.isPresent() && rw.isMergedInto(commit, rw.parseCommit(firstId.get()))) {
-        return first;
-      }
-
-      for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(R_HEADS).entrySet()) {
-        if (rw.isMergedInto(commit, rw.parseCommit(e.getValue()))) {
-          return R_HEADS + e.getKey();
-        }
-      }
-      return null;
-    } catch (IOException e) {
-      log.warn("Can't check for already submitted change", e);
-      return null;
-    }
-  }
-
-  private boolean shouldPublishComments() {
-    return magicBranch != null && magicBranch.shouldPublishComments();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
deleted file mode 100644
index 77aa950..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
+++ /dev/null
@@ -1,219 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.strategy;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.git.strategy.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.extensions.restapi.MergeConflictException;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.MergeIdenticalTreeException;
-import com.google.gerrit.server.git.MergeTip;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-
-public class CherryPick extends SubmitStrategy {
-
-  CherryPick(SubmitStrategy.Arguments args) {
-    super(args);
-  }
-
-  @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
-      throws IntegrationException {
-    List<CodeReviewCommit> sorted = CodeReviewCommit.ORDER.sortedCopy(toMerge);
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
-    boolean first = true;
-    while (!sorted.isEmpty()) {
-      CodeReviewCommit n = sorted.remove(0);
-      if (first && args.mergeTip.getInitialTip() == null) {
-        ops.add(new FastForwardOp(args, n));
-      } else if (n.getParentCount() == 0) {
-        ops.add(new CherryPickRootOp(n));
-      } else if (n.getParentCount() == 1) {
-        ops.add(new CherryPickOneOp(n));
-      } else {
-        ops.add(new CherryPickMultipleParentsOp(n));
-      }
-      first = false;
-    }
-    return ops;
-  }
-
-  private class CherryPickRootOp extends SubmitStrategyOp {
-    private CherryPickRootOp(CodeReviewCommit toMerge) {
-      super(CherryPick.this.args, toMerge);
-    }
-
-    @Override
-    public void updateRepoImpl(RepoContext ctx) {
-      // Refuse to merge a root commit into an existing branch, we cannot obtain
-      // a delta for the cherry-pick to apply.
-      toMerge.setStatusCode(CommitMergeStatus.CANNOT_CHERRY_PICK_ROOT);
-    }
-  }
-
-  private class CherryPickOneOp extends SubmitStrategyOp {
-    private PatchSet.Id psId;
-    private CodeReviewCommit newCommit;
-    private PatchSetInfo patchSetInfo;
-
-    private CherryPickOneOp(CodeReviewCommit toMerge) {
-      super(CherryPick.this.args, toMerge);
-    }
-
-    @Override
-    protected void updateRepoImpl(RepoContext ctx)
-        throws IntegrationException, IOException, OrmException {
-      // If there is only one parent, a cherry-pick can be done by taking the
-      // delta relative to that one parent and redoing that on the current merge
-      // tip.
-      args.rw.parseBody(toMerge);
-      psId =
-          ChangeUtil.nextPatchSetIdFromChangeRefsMap(
-              ctx.getRepoView().getRefs(getId().toRefPrefix()),
-              toMerge.change().currentPatchSetId());
-      RevCommit mergeTip = args.mergeTip.getCurrentTip();
-      args.rw.parseBody(mergeTip);
-      String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
-
-      PersonIdent committer =
-          args.caller.newCommitterIdent(ctx.getWhen(), args.serverIdent.getTimeZone());
-      try {
-        newCommit =
-            args.mergeUtil.createCherryPickFromCommit(
-                ctx.getInserter(),
-                ctx.getRepoView().getConfig(),
-                args.mergeTip.getCurrentTip(),
-                toMerge,
-                committer,
-                cherryPickCmtMsg,
-                args.rw,
-                0,
-                false);
-      } catch (MergeConflictException mce) {
-        // Keep going in the case of a single merge failure; the goal is to
-        // cherry-pick as many commits as possible.
-        toMerge.setStatusCode(CommitMergeStatus.PATH_CONFLICT);
-        return;
-      } catch (MergeIdenticalTreeException mie) {
-        toMerge.setStatusCode(SKIPPED_IDENTICAL_TREE);
-        return;
-      }
-      // Initial copy doesn't have new patch set ID since change hasn't been
-      // updated yet.
-      newCommit = amendGitlink(newCommit);
-      newCommit.copyFrom(toMerge);
-      newCommit.setPatchsetId(psId);
-      newCommit.setStatusCode(CommitMergeStatus.CLEAN_PICK);
-      args.mergeTip.moveTipTo(newCommit, newCommit);
-      args.commitStatus.put(newCommit);
-
-      ctx.addRefUpdate(ObjectId.zeroId(), newCommit, psId.toRefName());
-      patchSetInfo = args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, psId);
-    }
-
-    @Override
-    public PatchSet updateChangeImpl(ChangeContext ctx)
-        throws OrmException, NoSuchChangeException, IOException {
-      if (newCommit == null && toMerge.getStatusCode() == SKIPPED_IDENTICAL_TREE) {
-        return null;
-      }
-      checkNotNull(
-          newCommit,
-          "no new commit produced by CherryPick of %s, expected to fail fast",
-          toMerge.change().getId());
-      PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
-      PatchSet newPs =
-          args.psUtil.insert(
-              ctx.getDb(),
-              ctx.getRevWalk(),
-              ctx.getUpdate(psId),
-              psId,
-              newCommit,
-              prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of(),
-              null,
-              null);
-      ctx.getChange().setCurrentPatchSet(patchSetInfo);
-
-      // Don't copy approvals, as this is already taken care of by
-      // SubmitStrategyOp.
-
-      newCommit.setNotes(ctx.getNotes());
-      return newPs;
-    }
-  }
-
-  private class CherryPickMultipleParentsOp extends SubmitStrategyOp {
-    private CherryPickMultipleParentsOp(CodeReviewCommit toMerge) {
-      super(CherryPick.this.args, toMerge);
-    }
-
-    @Override
-    public void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
-      if (args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)) {
-        // One or more dependencies were not met. The status was already marked
-        // on the commit so we have nothing further to perform at this time.
-        return;
-      }
-      // There are multiple parents, so this is a merge commit. We don't want
-      // to cherry-pick the merge as clients can't easily rebase their history
-      // with that merge present and replaced by an equivalent merge with a
-      // different first parent. So instead behave as though MERGE_IF_NECESSARY
-      // was configured.
-      MergeTip mergeTip = args.mergeTip;
-      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)
-          && !args.submoduleOp.hasSubscription(args.destBranch)) {
-        mergeTip.moveTipTo(toMerge, toMerge);
-      } else {
-        PersonIdent myIdent = new PersonIdent(args.serverIdent, ctx.getWhen());
-        CodeReviewCommit result =
-            args.mergeUtil.mergeOneCommit(
-                myIdent,
-                myIdent,
-                args.rw,
-                ctx.getInserter(),
-                ctx.getRepoView().getConfig(),
-                args.destBranch,
-                mergeTip.getCurrentTip(),
-                toMerge);
-        result = amendGitlink(result);
-        mergeTip.moveTipTo(result, toMerge);
-        args.mergeUtil.markCleanMerges(
-            args.rw, args.canMergeFlag, mergeTip.getCurrentTip(), args.alreadyAccepted);
-      }
-    }
-  }
-
-  static boolean dryRun(
-      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
-      throws IntegrationException {
-    return args.mergeUtil.canCherryPick(args.mergeSorter, args.repo, mergeTip, args.rw, toMerge);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java
deleted file mode 100644
index e5c253d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.strategy;
-
-/**
- * Status codes set on {@link com.google.gerrit.server.git.CodeReviewCommit}s by {@link
- * SubmitStrategy} implementations.
- */
-public enum CommitMergeStatus {
-  CLEAN_MERGE("Change has been successfully merged"),
-
-  CLEAN_PICK("Change has been successfully cherry-picked"),
-
-  CLEAN_REBASE("Change has been successfully rebased and submitted"),
-
-  ALREADY_MERGED(""),
-
-  PATH_CONFLICT(
-      "Change could not be merged due to a path conflict.\n"
-          + "\n"
-          + "Please rebase the change locally and upload the rebased commit for review."),
-
-  REBASE_MERGE_CONFLICT(
-      "Change could not be merged due to a conflict.\n"
-          + "\n"
-          + "Please rebase the change locally and upload the rebased commit for review."),
-
-  SKIPPED_IDENTICAL_TREE(
-      "Marking change merged without cherry-picking to branch, as the resulting commit would be empty."),
-
-  MISSING_DEPENDENCY(""),
-
-  MANUAL_RECURSIVE_MERGE(
-      "The change requires a local merge to resolve.\n"
-          + "\n"
-          + "Please merge (or rebase) the change locally and upload the resolution for review."),
-
-  CANNOT_CHERRY_PICK_ROOT(
-      "Cannot cherry-pick an initial commit onto an existing branch.\n"
-          + "\n"
-          + "Please merge the change locally and upload the merge commit for review."),
-
-  CANNOT_REBASE_ROOT(
-      "Cannot rebase an initial commit onto an existing branch.\n"
-          + "\n"
-          + "Please merge the change locally and upload the merge commit for review."),
-
-  NOT_FAST_FORWARD(
-      "Project policy requires all submissions to be a fast-forward.\n"
-          + "\n"
-          + "Please rebase the change locally and upload again for review.");
-
-  private final String message;
-
-  CommitMergeStatus(String message) {
-    this.message = message;
-  }
-
-  public String getMessage() {
-    return message;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java
deleted file mode 100644
index a3b10cb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.strategy;
-
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.update.RepoContext;
-
-class FastForwardOp extends SubmitStrategyOp {
-  FastForwardOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
-    super(args, toMerge);
-  }
-
-  @Override
-  protected void updateRepoImpl(RepoContext ctx) throws IntegrationException {
-    args.mergeTip.moveTipTo(toMerge, toMerge);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
deleted file mode 100644
index 3c3812d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.strategy;
-
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.update.RepoContext;
-import java.io.IOException;
-import org.eclipse.jgit.lib.PersonIdent;
-
-class MergeOneOp extends SubmitStrategyOp {
-  MergeOneOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
-    super(args, toMerge);
-  }
-
-  @Override
-  public void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
-    PersonIdent caller =
-        ctx.getIdentifiedUser()
-            .newCommitterIdent(args.serverIdent.getWhen(), args.serverIdent.getTimeZone());
-    if (args.mergeTip.getCurrentTip() == null) {
-      throw new IllegalStateException(
-          "cannot merge commit "
-              + toMerge.name()
-              + " onto a null tip; expected at least one fast-forward prior to"
-              + " this operation");
-    }
-    CodeReviewCommit merged =
-        args.mergeUtil.mergeOneCommit(
-            caller,
-            args.serverIdent,
-            args.rw,
-            ctx.getInserter(),
-            ctx.getRepoView().getConfig(),
-            args.destBranch,
-            args.mergeTip.getCurrentTip(),
-            toMerge);
-    args.mergeTip.moveTipTo(amendGitlink(merged), toMerge);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
deleted file mode 100644
index 5421254..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
+++ /dev/null
@@ -1,303 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.strategy;
-
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.git.strategy.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.extensions.restapi.MergeConflictException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.change.RebaseChangeOp;
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.MergeIdenticalTreeException;
-import com.google.gerrit.server.git.MergeTip;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-
-/** This strategy covers RebaseAlways and RebaseIfNecessary ones. */
-public class RebaseSubmitStrategy extends SubmitStrategy {
-  private final boolean rebaseAlways;
-
-  RebaseSubmitStrategy(SubmitStrategy.Arguments args, boolean rebaseAlways) {
-    super(args);
-    this.rebaseAlways = rebaseAlways;
-  }
-
-  @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
-      throws IntegrationException {
-    List<CodeReviewCommit> sorted;
-    try {
-      sorted = args.rebaseSorter.sort(toMerge);
-    } catch (IOException e) {
-      throw new IntegrationException("Commit sorting failed", e);
-    }
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
-    boolean first = true;
-
-    for (CodeReviewCommit c : sorted) {
-      if (c.getParentCount() > 1) {
-        // Since there is a merge commit, sort and prune again using
-        // MERGE_IF_NECESSARY semantics to avoid creating duplicate
-        // commits.
-        //
-        sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted);
-        break;
-      }
-    }
-
-    while (!sorted.isEmpty()) {
-      CodeReviewCommit n = sorted.remove(0);
-      if (first && args.mergeTip.getInitialTip() == null) {
-        // TODO(tandrii): Cherry-Pick strategy does this too, but it's wrong
-        // and can be fixed.
-        ops.add(new FastForwardOp(args, n));
-      } else if (n.getParentCount() == 0) {
-        ops.add(new RebaseRootOp(n));
-      } else if (n.getParentCount() == 1) {
-        ops.add(new RebaseOneOp(n));
-      } else {
-        ops.add(new RebaseMultipleParentsOp(n));
-      }
-      first = false;
-    }
-    return ops;
-  }
-
-  private class RebaseRootOp extends SubmitStrategyOp {
-    private RebaseRootOp(CodeReviewCommit toMerge) {
-      super(RebaseSubmitStrategy.this.args, toMerge);
-    }
-
-    @Override
-    public void updateRepoImpl(RepoContext ctx) {
-      // Refuse to merge a root commit into an existing branch, we cannot obtain
-      // a delta for the cherry-pick to apply.
-      toMerge.setStatusCode(CommitMergeStatus.CANNOT_REBASE_ROOT);
-    }
-  }
-
-  private class RebaseOneOp extends SubmitStrategyOp {
-    private RebaseChangeOp rebaseOp;
-    private CodeReviewCommit newCommit;
-    private PatchSet.Id newPatchSetId;
-
-    private RebaseOneOp(CodeReviewCommit toMerge) {
-      super(RebaseSubmitStrategy.this.args, toMerge);
-    }
-
-    @Override
-    public void updateRepoImpl(RepoContext ctx)
-        throws IntegrationException, InvalidChangeOperationException, RestApiException, IOException,
-            OrmException, PermissionBackendException {
-      if (args.mergeUtil.canFastForward(
-          args.mergeSorter, args.mergeTip.getCurrentTip(), args.rw, toMerge)) {
-        if (!rebaseAlways) {
-          args.mergeTip.moveTipTo(amendGitlink(toMerge), toMerge);
-          toMerge.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
-          acceptMergeTip(args.mergeTip);
-          return;
-        }
-        // RebaseAlways means we modify commit message.
-        args.rw.parseBody(toMerge);
-        newPatchSetId =
-            ChangeUtil.nextPatchSetIdFromChangeRefsMap(
-                ctx.getRepoView().getRefs(getId().toRefPrefix()),
-                toMerge.change().currentPatchSetId());
-        RevCommit mergeTip = args.mergeTip.getCurrentTip();
-        args.rw.parseBody(mergeTip);
-        String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
-        PersonIdent committer =
-            args.caller.newCommitterIdent(ctx.getWhen(), args.serverIdent.getTimeZone());
-        try {
-          newCommit =
-              args.mergeUtil.createCherryPickFromCommit(
-                  ctx.getInserter(),
-                  ctx.getRepoView().getConfig(),
-                  args.mergeTip.getCurrentTip(),
-                  toMerge,
-                  committer,
-                  cherryPickCmtMsg,
-                  args.rw,
-                  0,
-                  true);
-        } catch (MergeConflictException mce) {
-          // Unlike in Cherry-pick case, this should never happen.
-          toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
-          throw new IllegalStateException("MergeConflictException on message edit must not happen");
-        } catch (MergeIdenticalTreeException mie) {
-          // this should not happen
-          toMerge.setStatusCode(SKIPPED_IDENTICAL_TREE);
-          return;
-        }
-        ctx.addRefUpdate(ObjectId.zeroId(), newCommit, newPatchSetId.toRefName());
-      } else {
-        // Stale read of patch set is ok; see comments in RebaseChangeOp.
-        PatchSet origPs = args.psUtil.get(ctx.getDb(), toMerge.getNotes(), toMerge.getPatchsetId());
-        rebaseOp =
-            args.rebaseFactory
-                .create(toMerge.notes(), origPs, args.mergeTip.getCurrentTip())
-                .setFireRevisionCreated(false)
-                // Bypass approval copier since SubmitStrategyOp copy all approvals
-                // later anyway.
-                .setCopyApprovals(false)
-                .setValidate(false)
-                .setCheckAddPatchSetPermission(false)
-                // RebaseAlways should set always modify commit message like
-                // Cherry-Pick strategy.
-                .setDetailedCommitMessage(rebaseAlways)
-                // Do not post message after inserting new patchset because there
-                // will be one about change being merged already.
-                .setPostMessage(false)
-                .setMatchAuthorToCommitterDate(args.project.isMatchAuthorToCommitterDate());
-        try {
-          rebaseOp.updateRepo(ctx);
-        } catch (MergeConflictException | NoSuchChangeException e) {
-          toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
-          throw new IntegrationException(
-              "Cannot rebase " + toMerge.name() + ": " + e.getMessage(), e);
-        }
-        newCommit = args.rw.parseCommit(rebaseOp.getRebasedCommit());
-        newPatchSetId = rebaseOp.getPatchSetId();
-      }
-      newCommit = amendGitlink(newCommit);
-      newCommit.copyFrom(toMerge);
-      newCommit.setPatchsetId(newPatchSetId);
-      newCommit.setStatusCode(CommitMergeStatus.CLEAN_REBASE);
-      args.mergeTip.moveTipTo(newCommit, newCommit);
-      args.commitStatus.put(args.mergeTip.getCurrentTip());
-      acceptMergeTip(args.mergeTip);
-    }
-
-    @Override
-    public PatchSet updateChangeImpl(ChangeContext ctx)
-        throws NoSuchChangeException, ResourceConflictException, OrmException, IOException {
-      if (newCommit == null) {
-        checkState(!rebaseAlways, "RebaseAlways must never fast forward");
-        // otherwise, took the fast-forward option, nothing to do.
-        return null;
-      }
-
-      PatchSet newPs;
-      if (rebaseOp != null) {
-        rebaseOp.updateChange(ctx);
-        newPs = rebaseOp.getPatchSet();
-      } else {
-        // CherryPick
-        PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
-        newPs =
-            args.psUtil.insert(
-                ctx.getDb(),
-                ctx.getRevWalk(),
-                ctx.getUpdate(newPatchSetId),
-                newPatchSetId,
-                newCommit,
-                prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of(),
-                null,
-                null);
-      }
-      ctx.getChange()
-          .setCurrentPatchSet(
-              args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, newPatchSetId));
-      newCommit.setNotes(ctx.getNotes());
-      return newPs;
-    }
-
-    @Override
-    public void postUpdateImpl(Context ctx) throws OrmException {
-      if (rebaseOp != null) {
-        rebaseOp.postUpdate(ctx);
-      }
-    }
-  }
-
-  private class RebaseMultipleParentsOp extends SubmitStrategyOp {
-    private RebaseMultipleParentsOp(CodeReviewCommit toMerge) {
-      super(RebaseSubmitStrategy.this.args, toMerge);
-    }
-
-    @Override
-    public void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
-      // There are multiple parents, so this is a merge commit. We don't want
-      // to rebase the merge as clients can't easily rebase their history with
-      // that merge present and replaced by an equivalent merge with a different
-      // first parent. So instead behave as though MERGE_IF_NECESSARY was
-      // configured.
-      // TODO(tandrii): this is not in spirit of RebaseAlways strategy because
-      // the commit messages can not be modified in the process. It's also
-      // possible to implement rebasing of merge commits. E.g., the Cherry Pick
-      // REST endpoint already supports cherry-picking of merge commits.
-      // For now, users of RebaseAlways strategy for whom changed commit footers
-      // are important would be well advised to prohibit uploading patches with
-      // merge commits.
-      MergeTip mergeTip = args.mergeTip;
-      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)
-          && !args.submoduleOp.hasSubscription(args.destBranch)) {
-        mergeTip.moveTipTo(toMerge, toMerge);
-      } else {
-        PersonIdent caller =
-            ctx.getIdentifiedUser().newCommitterIdent(ctx.getWhen(), ctx.getTimeZone());
-        CodeReviewCommit newTip =
-            args.mergeUtil.mergeOneCommit(
-                caller,
-                caller,
-                args.rw,
-                ctx.getInserter(),
-                ctx.getRepoView().getConfig(),
-                args.destBranch,
-                mergeTip.getCurrentTip(),
-                toMerge);
-        mergeTip.moveTipTo(amendGitlink(newTip), toMerge);
-      }
-      args.mergeUtil.markCleanMerges(
-          args.rw, args.canMergeFlag, mergeTip.getCurrentTip(), args.alreadyAccepted);
-      acceptMergeTip(mergeTip);
-    }
-  }
-
-  private void acceptMergeTip(MergeTip mergeTip) {
-    args.alreadyAccepted.add(mergeTip.getCurrentTip());
-  }
-
-  static boolean dryRun(
-      SubmitDryRun.Arguments args,
-      Repository repo,
-      CodeReviewCommit mergeTip,
-      CodeReviewCommit toMerge)
-      throws IntegrationException {
-    // Test for merge instead of cherry pick to avoid false negatives
-    // on commit chains.
-    return !args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)
-        && args.mergeUtil.canMerge(args.mergeSorter, repo, mergeTip, toMerge);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
deleted file mode 100644
index 3a954fb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
+++ /dev/null
@@ -1,150 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.strategy;
-
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Streams;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.MergeSorter;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.Set;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevFlag;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTag;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Dry run of a submit strategy. */
-public class SubmitDryRun {
-  private static final Logger log = LoggerFactory.getLogger(SubmitDryRun.class);
-
-  static class Arguments {
-    final Repository repo;
-    final CodeReviewRevWalk rw;
-    final MergeUtil mergeUtil;
-    final MergeSorter mergeSorter;
-
-    Arguments(Repository repo, CodeReviewRevWalk rw, MergeUtil mergeUtil, MergeSorter mergeSorter) {
-      this.repo = repo;
-      this.rw = rw;
-      this.mergeUtil = mergeUtil;
-      this.mergeSorter = mergeSorter;
-    }
-  }
-
-  public static Set<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
-    return Streams.concat(
-            repo.getRefDatabase().getRefs(Constants.R_HEADS).values().stream(),
-            repo.getRefDatabase().getRefs(Constants.R_TAGS).values().stream())
-        .map(Ref::getObjectId)
-        .filter(o -> o != null)
-        .collect(toSet());
-  }
-
-  public static Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw) throws IOException {
-    Set<RevCommit> accepted = new HashSet<>();
-    addCommits(getAlreadyAccepted(repo), rw, accepted);
-    return accepted;
-  }
-
-  public static void addCommits(Iterable<ObjectId> ids, RevWalk rw, Collection<RevCommit> out)
-      throws IOException {
-    for (ObjectId id : ids) {
-      RevObject obj = rw.parseAny(id);
-      if (obj instanceof RevTag) {
-        obj = rw.peel(obj);
-      }
-      if (obj instanceof RevCommit) {
-        out.add((RevCommit) obj);
-      }
-    }
-  }
-
-  private final ProjectCache projectCache;
-  private final MergeUtil.Factory mergeUtilFactory;
-
-  @Inject
-  SubmitDryRun(ProjectCache projectCache, MergeUtil.Factory mergeUtilFactory) {
-    this.projectCache = projectCache;
-    this.mergeUtilFactory = mergeUtilFactory;
-  }
-
-  public boolean run(
-      SubmitType submitType,
-      Repository repo,
-      CodeReviewRevWalk rw,
-      Branch.NameKey destBranch,
-      ObjectId tip,
-      ObjectId toMerge,
-      Set<RevCommit> alreadyAccepted)
-      throws IntegrationException, NoSuchProjectException, IOException {
-    CodeReviewCommit tipCommit = rw.parseCommit(tip);
-    CodeReviewCommit toMergeCommit = rw.parseCommit(toMerge);
-    RevFlag canMerge = rw.newFlag("CAN_MERGE");
-    toMergeCommit.add(canMerge);
-    Arguments args =
-        new Arguments(
-            repo,
-            rw,
-            mergeUtilFactory.create(getProject(destBranch)),
-            new MergeSorter(rw, alreadyAccepted, canMerge, ImmutableSet.of(toMergeCommit)));
-
-    switch (submitType) {
-      case CHERRY_PICK:
-        return CherryPick.dryRun(args, tipCommit, toMergeCommit);
-      case FAST_FORWARD_ONLY:
-        return FastForwardOnly.dryRun(args, tipCommit, toMergeCommit);
-      case MERGE_ALWAYS:
-        return MergeAlways.dryRun(args, tipCommit, toMergeCommit);
-      case MERGE_IF_NECESSARY:
-        return MergeIfNecessary.dryRun(args, tipCommit, toMergeCommit);
-      case REBASE_IF_NECESSARY:
-        return RebaseIfNecessary.dryRun(args, repo, tipCommit, toMergeCommit);
-      case REBASE_ALWAYS:
-        return RebaseAlways.dryRun(args, repo, tipCommit, toMergeCommit);
-      default:
-        String errorMsg = "No submit strategy for: " + submitType;
-        log.error(errorMsg);
-        throw new IntegrationException(errorMsg);
-    }
-  }
-
-  private ProjectState getProject(Branch.NameKey branch) throws NoSuchProjectException {
-    ProjectState p = projectCache.get(branch.getParentKey());
-    if (p == null) {
-      throw new NoSuchProjectException(branch.getParentKey());
-    }
-    return p;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
deleted file mode 100644
index 2a22c1c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
+++ /dev/null
@@ -1,276 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.strategy;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Sets;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.change.RebaseChangeOp;
-import com.google.gerrit.server.change.Submit.TestSubmitInput;
-import com.google.gerrit.server.extensions.events.ChangeMerged;
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
-import com.google.gerrit.server.git.EmailMerge;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.LabelNormalizer;
-import com.google.gerrit.server.git.MergeOp.CommitStatus;
-import com.google.gerrit.server.git.MergeSorter;
-import com.google.gerrit.server.git.MergeTip;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.RebaseSorter;
-import com.google.gerrit.server.git.SubmoduleOp;
-import com.google.gerrit.server.git.TagCache;
-import com.google.gerrit.server.git.validators.OnSubmitValidators;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.util.RequestId;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevFlag;
-
-/**
- * Base class that submit strategies must extend.
- *
- * <p>A submit strategy for a certain {@link SubmitType} defines how the submitted commits should be
- * merged.
- */
-public abstract class SubmitStrategy {
-  public static Module module() {
-    return new FactoryModule() {
-      @Override
-      protected void configure() {
-        factory(SubmitStrategy.Arguments.Factory.class);
-      }
-    };
-  }
-
-  static class Arguments {
-    interface Factory {
-      Arguments create(
-          SubmitType submitType,
-          Branch.NameKey destBranch,
-          CommitStatus commitStatus,
-          CodeReviewRevWalk rw,
-          IdentifiedUser caller,
-          MergeTip mergeTip,
-          RevFlag canMergeFlag,
-          ReviewDb db,
-          Set<RevCommit> alreadyAccepted,
-          Set<CodeReviewCommit> incoming,
-          RequestId submissionId,
-          SubmitInput submitInput,
-          ListMultimap<RecipientType, Account.Id> accountsToNotify,
-          SubmoduleOp submoduleOp,
-          boolean dryrun);
-    }
-
-    final AccountCache accountCache;
-    final ApprovalsUtil approvalsUtil;
-    final ChangeControl.GenericFactory changeControlFactory;
-    final ChangeMerged changeMerged;
-    final ChangeMessagesUtil cmUtil;
-    final EmailMerge.Factory mergedSenderFactory;
-    final GitRepositoryManager repoManager;
-    final LabelNormalizer labelNormalizer;
-    final PatchSetInfoFactory patchSetInfoFactory;
-    final PatchSetUtil psUtil;
-    final ProjectCache projectCache;
-    final PersonIdent serverIdent;
-    final RebaseChangeOp.Factory rebaseFactory;
-    final OnSubmitValidators.Factory onSubmitValidatorsFactory;
-    final TagCache tagCache;
-    final Provider<InternalChangeQuery> queryProvider;
-
-    final Branch.NameKey destBranch;
-    final CodeReviewRevWalk rw;
-    final CommitStatus commitStatus;
-    final IdentifiedUser caller;
-    final MergeTip mergeTip;
-    final RevFlag canMergeFlag;
-    final ReviewDb db;
-    final Set<RevCommit> alreadyAccepted;
-    final RequestId submissionId;
-    final SubmitType submitType;
-    final SubmitInput submitInput;
-    final ListMultimap<RecipientType, Account.Id> accountsToNotify;
-    final SubmoduleOp submoduleOp;
-
-    final ProjectState project;
-    final MergeSorter mergeSorter;
-    final RebaseSorter rebaseSorter;
-    final MergeUtil mergeUtil;
-    final boolean dryrun;
-
-    @Inject
-    Arguments(
-        AccountCache accountCache,
-        ApprovalsUtil approvalsUtil,
-        ChangeControl.GenericFactory changeControlFactory,
-        ChangeMerged changeMerged,
-        ChangeMessagesUtil cmUtil,
-        EmailMerge.Factory mergedSenderFactory,
-        GitRepositoryManager repoManager,
-        LabelNormalizer labelNormalizer,
-        MergeUtil.Factory mergeUtilFactory,
-        PatchSetInfoFactory patchSetInfoFactory,
-        PatchSetUtil psUtil,
-        @GerritPersonIdent PersonIdent serverIdent,
-        ProjectCache projectCache,
-        RebaseChangeOp.Factory rebaseFactory,
-        OnSubmitValidators.Factory onSubmitValidatorsFactory,
-        TagCache tagCache,
-        Provider<InternalChangeQuery> queryProvider,
-        @Assisted Branch.NameKey destBranch,
-        @Assisted CommitStatus commitStatus,
-        @Assisted CodeReviewRevWalk rw,
-        @Assisted IdentifiedUser caller,
-        @Assisted MergeTip mergeTip,
-        @Assisted RevFlag canMergeFlag,
-        @Assisted ReviewDb db,
-        @Assisted Set<RevCommit> alreadyAccepted,
-        @Assisted Set<CodeReviewCommit> incoming,
-        @Assisted RequestId submissionId,
-        @Assisted SubmitType submitType,
-        @Assisted SubmitInput submitInput,
-        @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify,
-        @Assisted SubmoduleOp submoduleOp,
-        @Assisted boolean dryrun) {
-      this.accountCache = accountCache;
-      this.approvalsUtil = approvalsUtil;
-      this.changeControlFactory = changeControlFactory;
-      this.changeMerged = changeMerged;
-      this.mergedSenderFactory = mergedSenderFactory;
-      this.repoManager = repoManager;
-      this.cmUtil = cmUtil;
-      this.labelNormalizer = labelNormalizer;
-      this.patchSetInfoFactory = patchSetInfoFactory;
-      this.psUtil = psUtil;
-      this.projectCache = projectCache;
-      this.rebaseFactory = rebaseFactory;
-      this.tagCache = tagCache;
-      this.queryProvider = queryProvider;
-
-      this.serverIdent = serverIdent;
-      this.destBranch = destBranch;
-      this.commitStatus = commitStatus;
-      this.rw = rw;
-      this.caller = caller;
-      this.mergeTip = mergeTip;
-      this.canMergeFlag = canMergeFlag;
-      this.db = db;
-      this.alreadyAccepted = alreadyAccepted;
-      this.submissionId = submissionId;
-      this.submitType = submitType;
-      this.submitInput = submitInput;
-      this.accountsToNotify = accountsToNotify;
-      this.submoduleOp = submoduleOp;
-      this.dryrun = dryrun;
-
-      this.project =
-          checkNotNull(
-              projectCache.get(destBranch.getParentKey()),
-              "project not found: %s",
-              destBranch.getParentKey());
-      this.mergeSorter = new MergeSorter(rw, alreadyAccepted, canMergeFlag, incoming);
-      this.rebaseSorter =
-          new RebaseSorter(
-              rw, mergeTip.getInitialTip(), alreadyAccepted, canMergeFlag, queryProvider, incoming);
-      this.mergeUtil = mergeUtilFactory.create(project);
-      this.onSubmitValidatorsFactory = onSubmitValidatorsFactory;
-    }
-  }
-
-  final Arguments args;
-
-  SubmitStrategy(Arguments args) {
-    this.args = checkNotNull(args);
-  }
-
-  /**
-   * Add operations to a batch update that execute this submit strategy.
-   *
-   * <p>Guarantees exactly one op is added to the update for each change in the input set.
-   *
-   * @param bu batch update to add operations to.
-   * @param toMerge the set of submitted commits that should be merged using this submit strategy.
-   *     Implementations are responsible for ordering of commits, and will not modify the input in
-   *     place.
-   * @throws IntegrationException if an error occurred initializing the operations (as opposed to an
-   *     error during execution, which will be reported only when the batch update executes the
-   *     operations).
-   */
-  public final void addOps(BatchUpdate bu, Set<CodeReviewCommit> toMerge)
-      throws IntegrationException {
-    List<SubmitStrategyOp> ops = buildOps(toMerge);
-    Set<CodeReviewCommit> added = Sets.newHashSetWithExpectedSize(ops.size());
-
-    for (SubmitStrategyOp op : ops) {
-      added.add(op.getCommit());
-    }
-
-    // First add ops for any implicitly merged changes.
-    List<CodeReviewCommit> difference = new ArrayList<>(Sets.difference(toMerge, added));
-    Collections.reverse(difference);
-    for (CodeReviewCommit c : difference) {
-      Change.Id id = c.change().getId();
-      bu.addOp(id, new ImplicitIntegrateOp(args, c));
-      maybeAddTestHelperOp(bu, id);
-    }
-
-    // Then ops for explicitly merged changes
-    for (SubmitStrategyOp op : ops) {
-      bu.addOp(op.getId(), op);
-      maybeAddTestHelperOp(bu, op.getId());
-    }
-  }
-
-  private void maybeAddTestHelperOp(BatchUpdate bu, Change.Id changeId) {
-    if (args.submitInput instanceof TestSubmitInput) {
-      bu.addOp(changeId, new TestHelperOp(changeId, args));
-    }
-  }
-
-  protected abstract List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
-      throws IntegrationException;
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
deleted file mode 100644
index 7678623..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
+++ /dev/null
@@ -1,105 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.strategy;
-
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.MergeOp.CommitStatus;
-import com.google.gerrit.server.git.MergeTip;
-import com.google.gerrit.server.git.SubmoduleOp;
-import com.google.gerrit.server.util.RequestId;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Set;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevFlag;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Factory to create a {@link SubmitStrategy} for a {@link SubmitType}. */
-@Singleton
-public class SubmitStrategyFactory {
-  private static final Logger log = LoggerFactory.getLogger(SubmitStrategyFactory.class);
-
-  private final SubmitStrategy.Arguments.Factory argsFactory;
-
-  @Inject
-  SubmitStrategyFactory(SubmitStrategy.Arguments.Factory argsFactory) {
-    this.argsFactory = argsFactory;
-  }
-
-  public SubmitStrategy create(
-      SubmitType submitType,
-      ReviewDb db,
-      CodeReviewRevWalk rw,
-      RevFlag canMergeFlag,
-      Set<RevCommit> alreadyAccepted,
-      Set<CodeReviewCommit> incoming,
-      Branch.NameKey destBranch,
-      IdentifiedUser caller,
-      MergeTip mergeTip,
-      CommitStatus commitStatus,
-      RequestId submissionId,
-      SubmitInput submitInput,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify,
-      SubmoduleOp submoduleOp,
-      boolean dryrun)
-      throws IntegrationException {
-    SubmitStrategy.Arguments args =
-        argsFactory.create(
-            submitType,
-            destBranch,
-            commitStatus,
-            rw,
-            caller,
-            mergeTip,
-            canMergeFlag,
-            db,
-            alreadyAccepted,
-            incoming,
-            submissionId,
-            submitInput,
-            accountsToNotify,
-            submoduleOp,
-            dryrun);
-    switch (submitType) {
-      case CHERRY_PICK:
-        return new CherryPick(args);
-      case FAST_FORWARD_ONLY:
-        return new FastForwardOnly(args);
-      case MERGE_ALWAYS:
-        return new MergeAlways(args);
-      case MERGE_IF_NECESSARY:
-        return new MergeIfNecessary(args);
-      case REBASE_IF_NECESSARY:
-        return new RebaseIfNecessary(args);
-      case REBASE_ALWAYS:
-        return new RebaseAlways(args);
-      default:
-        String errorMsg = "No submit strategy for: " + submitType;
-        log.error(errorMsg);
-        throw new IntegrationException(errorMsg);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java
deleted file mode 100644
index 97291e5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java
+++ /dev/null
@@ -1,153 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.strategy;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.change.Submit.TestSubmitInput;
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.MergeOp.CommitStatus;
-import com.google.gerrit.server.update.BatchUpdateListener;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.revwalk.RevCommit;
-
-public class SubmitStrategyListener implements BatchUpdateListener {
-  private final Collection<SubmitStrategy> strategies;
-  private final CommitStatus commitStatus;
-  private final boolean failAfterRefUpdates;
-
-  public SubmitStrategyListener(
-      SubmitInput input, Collection<SubmitStrategy> strategies, CommitStatus commitStatus) {
-    this.strategies = strategies;
-    this.commitStatus = commitStatus;
-    if (input instanceof TestSubmitInput) {
-      failAfterRefUpdates = ((TestSubmitInput) input).failAfterRefUpdates;
-    } else {
-      failAfterRefUpdates = false;
-    }
-  }
-
-  @Override
-  public void afterUpdateRepos() throws ResourceConflictException {
-    try {
-      markCleanMerges();
-      List<Change.Id> alreadyMerged = checkCommitStatus();
-      findUnmergedChanges(alreadyMerged);
-    } catch (IntegrationException e) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    }
-  }
-
-  @Override
-  public void afterUpdateRefs() throws ResourceConflictException {
-    if (failAfterRefUpdates) {
-      throw new ResourceConflictException("Failing after ref updates");
-    }
-  }
-
-  private void findUnmergedChanges(List<Change.Id> alreadyMerged)
-      throws ResourceConflictException, IntegrationException {
-    for (SubmitStrategy strategy : strategies) {
-      if (strategy instanceof CherryPick) {
-        // Can't do this sanity check for CherryPick since:
-        // * CherryPick might have picked a subset of changes
-        // * CherryPick might have status SKIPPED_IDENTICAL_TREE
-        continue;
-      }
-      SubmitStrategy.Arguments args = strategy.args;
-      Set<Change.Id> unmerged =
-          args.mergeUtil.findUnmergedChanges(
-              args.commitStatus.getChangeIds(args.destBranch),
-              args.rw,
-              args.canMergeFlag,
-              args.mergeTip.getInitialTip(),
-              args.mergeTip.getCurrentTip(),
-              alreadyMerged);
-      for (Change.Id id : unmerged) {
-        commitStatus.problem(id, "internal error: change not reachable from new branch tip");
-      }
-    }
-    commitStatus.maybeFailVerbose();
-  }
-
-  private void markCleanMerges() throws IntegrationException {
-    for (SubmitStrategy strategy : strategies) {
-      SubmitStrategy.Arguments args = strategy.args;
-      RevCommit initialTip = args.mergeTip.getInitialTip();
-      args.mergeUtil.markCleanMerges(
-          args.rw,
-          args.canMergeFlag,
-          args.mergeTip.getCurrentTip(),
-          initialTip == null ? ImmutableSet.<RevCommit>of() : ImmutableSet.of(initialTip));
-    }
-  }
-
-  private List<Change.Id> checkCommitStatus() throws ResourceConflictException {
-    List<Change.Id> alreadyMerged = new ArrayList<>(commitStatus.getChangeIds().size());
-    for (Change.Id id : commitStatus.getChangeIds()) {
-      CodeReviewCommit commit = commitStatus.get(id);
-      CommitMergeStatus s = commit != null ? commit.getStatusCode() : null;
-      if (s == null) {
-        commitStatus.problem(id, "internal error: change not processed by merge strategy");
-        continue;
-      }
-      switch (s) {
-        case CLEAN_MERGE:
-        case CLEAN_REBASE:
-        case CLEAN_PICK:
-        case SKIPPED_IDENTICAL_TREE:
-          break; // Merge strategy accepted this change.
-
-        case ALREADY_MERGED:
-          // Already an ancestor of tip.
-          alreadyMerged.add(commit.getPatchsetId().getParentKey());
-          break;
-
-        case PATH_CONFLICT:
-        case REBASE_MERGE_CONFLICT:
-        case MANUAL_RECURSIVE_MERGE:
-        case CANNOT_CHERRY_PICK_ROOT:
-        case CANNOT_REBASE_ROOT:
-        case NOT_FAST_FORWARD:
-          // TODO(dborowitz): Reformat these messages to be more appropriate for
-          // short problem descriptions.
-          commitStatus.problem(id, CharMatcher.is('\n').collapseFrom(s.getMessage(), ' '));
-          break;
-
-        case MISSING_DEPENDENCY:
-          commitStatus.problem(id, "depends on change that was not submitted");
-          break;
-
-        default:
-          commitStatus.problem(id, "unspecified merge failure: " + s);
-          break;
-      }
-    }
-    commitStatus.maybeFailVerbose();
-    return alreadyMerged;
-  }
-
-  @Override
-  public void afterUpdateChanges() throws ResourceConflictException {
-    commitStatus.maybeFail("Error updating status");
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
deleted file mode 100644
index 152d398..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
+++ /dev/null
@@ -1,627 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.strategy;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-
-import com.google.common.base.Function;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
-import com.google.gerrit.server.git.GroupCollector;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.LabelNormalizer;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.SubmoduleException;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-abstract class SubmitStrategyOp implements BatchUpdateOp {
-  private static final Logger log = LoggerFactory.getLogger(SubmitStrategyOp.class);
-
-  protected final SubmitStrategy.Arguments args;
-  protected final CodeReviewCommit toMerge;
-
-  private ReceiveCommand command;
-  private PatchSetApproval submitter;
-  private ObjectId mergeResultRev;
-  private PatchSet mergedPatchSet;
-  private Change updatedChange;
-  private CodeReviewCommit alreadyMergedCommit;
-  private boolean changeAlreadyMerged;
-
-  protected SubmitStrategyOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
-    this.args = args;
-    this.toMerge = toMerge;
-  }
-
-  final Change.Id getId() {
-    return toMerge.change().getId();
-  }
-
-  final CodeReviewCommit getCommit() {
-    return toMerge;
-  }
-
-  protected final Branch.NameKey getDest() {
-    return toMerge.change().getDest();
-  }
-
-  protected final Project.NameKey getProject() {
-    return getDest().getParentKey();
-  }
-
-  @Override
-  public final void updateRepo(RepoContext ctx) throws Exception {
-    logDebug("{}#updateRepo for change {}", getClass().getSimpleName(), toMerge.change().getId());
-    checkState(
-        ctx.getRevWalk() == args.rw,
-        "SubmitStrategyOp requires callers to call BatchUpdate#setRepository with exactly the same"
-            + " CodeReviewRevWalk instance from the SubmitStrategy.Arguments: %s != %s",
-        ctx.getRevWalk(),
-        args.rw);
-    // Run the submit strategy implementation and record the merge tip state so
-    // we can create the ref update.
-    CodeReviewCommit tipBefore = args.mergeTip.getCurrentTip();
-    alreadyMergedCommit = getAlreadyMergedCommit(ctx);
-    if (alreadyMergedCommit == null) {
-      updateRepoImpl(ctx);
-    } else {
-      logDebug("Already merged as {}", alreadyMergedCommit.name());
-    }
-    CodeReviewCommit tipAfter = args.mergeTip.getCurrentTip();
-
-    if (Objects.equals(tipBefore, tipAfter)) {
-      logDebug("Did not move tip", getClass().getSimpleName());
-      return;
-    } else if (tipAfter == null) {
-      logDebug("No merge tip, no update to perform");
-      return;
-    }
-    logDebug("Moved tip from {} to {}", tipBefore, tipAfter);
-
-    checkProjectConfig(ctx, tipAfter);
-
-    // Needed by postUpdate, at which point mergeTip will have advanced further,
-    // so it's easier to just snapshot the command.
-    command =
-        new ReceiveCommand(firstNonNull(tipBefore, ObjectId.zeroId()), tipAfter, getDest().get());
-    ctx.addRefUpdate(command);
-    args.submoduleOp.addBranchTip(getDest(), tipAfter);
-  }
-
-  private void checkProjectConfig(RepoContext ctx, CodeReviewCommit commit)
-      throws IntegrationException {
-    String refName = getDest().get();
-    if (RefNames.REFS_CONFIG.equals(refName)) {
-      logDebug("Loading new configuration from {}", RefNames.REFS_CONFIG);
-      try {
-        ProjectConfig cfg = new ProjectConfig(getProject());
-        cfg.load(ctx.getRevWalk(), commit);
-      } catch (Exception e) {
-        throw new IntegrationException(
-            "Submit would store invalid"
-                + " project configuration "
-                + commit.name()
-                + " for "
-                + getProject(),
-            e);
-      }
-    }
-  }
-
-  private CodeReviewCommit getAlreadyMergedCommit(RepoContext ctx) throws IOException {
-    CodeReviewCommit tip = args.mergeTip.getInitialTip();
-    if (tip == null) {
-      return null;
-    }
-    CodeReviewRevWalk rw = (CodeReviewRevWalk) ctx.getRevWalk();
-    Change.Id id = getId();
-    String refPrefix = id.toRefPrefix();
-
-    Map<String, ObjectId> refs = ctx.getRepoView().getRefs(refPrefix);
-    List<CodeReviewCommit> commits = new ArrayList<>(refs.size());
-    for (Map.Entry<String, ObjectId> e : refs.entrySet()) {
-      PatchSet.Id psId = PatchSet.Id.fromRef(refPrefix + e.getKey());
-      if (psId == null) {
-        continue;
-      }
-      try {
-        CodeReviewCommit c = rw.parseCommit(e.getValue());
-        c.setPatchsetId(psId);
-        commits.add(c);
-      } catch (MissingObjectException | IncorrectObjectTypeException ex) {
-        continue; // Bogus ref, can't be merged into tip so we don't care.
-      }
-    }
-    Collections.sort(
-        commits, ReviewDbUtil.intKeyOrdering().reverse().onResultOf(c -> c.getPatchsetId()));
-    CodeReviewCommit result = MergeUtil.findAnyMergedInto(rw, commits, tip);
-    if (result == null) {
-      return null;
-    }
-
-    // Some patch set of this change is actually merged into the target
-    // branch, most likely because a previous run of MergeOp failed after
-    // updateRepo, during updateChange.
-    //
-    // Do the best we can to clean this up: mark the change as merged and set
-    // the current patch set. Don't touch the dest branch at all. This can
-    // lead to some odd situations like another change in the set merging in
-    // a different patch set of this change, but that's unavoidable at this
-    // point.  At least the change will end up in the right state.
-    //
-    // TODO(dborowitz): Consider deleting later junk patch set refs. They
-    // presumably don't have PatchSets pointing to them.
-    rw.parseBody(result);
-    result.add(args.canMergeFlag);
-    PatchSet.Id psId = result.getPatchsetId();
-    result.copyFrom(toMerge);
-    result.setPatchsetId(psId); // Got overwriten by copyFrom.
-    result.setStatusCode(CommitMergeStatus.ALREADY_MERGED);
-    args.commitStatus.put(result);
-    return result;
-  }
-
-  @Override
-  public final boolean updateChange(ChangeContext ctx) throws Exception {
-    logDebug("{}#updateChange for change {}", getClass().getSimpleName(), toMerge.change().getId());
-    toMerge.setNotes(ctx.getNotes()); // Update change and notes from ctx.
-    PatchSet.Id oldPsId = checkNotNull(toMerge.getPatchsetId());
-    PatchSet.Id newPsId;
-
-    if (ctx.getChange().getStatus() == Change.Status.MERGED) {
-      // Either another thread won a race, or we are retrying a whole topic submission after one
-      // repo failed with lock failure.
-      if (alreadyMergedCommit == null) {
-        logDebug(
-            "Change is already merged according to its status, but we were unable to find it"
-                + " merged into the current tip ({})",
-            args.mergeTip.getCurrentTip().name());
-      } else {
-        logDebug("Change is already merged");
-      }
-      changeAlreadyMerged = true;
-      return false;
-    }
-
-    if (alreadyMergedCommit != null) {
-      alreadyMergedCommit.setNotes(ctx.getNotes());
-      mergedPatchSet = getOrCreateAlreadyMergedPatchSet(ctx);
-      newPsId = mergedPatchSet.getId();
-    } else {
-      PatchSet newPatchSet = updateChangeImpl(ctx);
-      newPsId = checkNotNull(ctx.getChange().currentPatchSetId());
-      if (newPatchSet == null) {
-        checkState(
-            oldPsId.equals(newPsId),
-            "patch set advanced from %s to %s but updateChangeImpl did not"
-                + " return new patch set instance",
-            oldPsId,
-            newPsId);
-        // Ok to use stale notes to get the old patch set, which didn't change
-        // during the submit strategy.
-        mergedPatchSet =
-            checkNotNull(
-                args.psUtil.get(ctx.getDb(), ctx.getNotes(), oldPsId),
-                "missing old patch set %s",
-                oldPsId);
-      } else {
-        PatchSet.Id n = newPatchSet.getId();
-        checkState(
-            !n.equals(oldPsId) && n.equals(newPsId),
-            "current patch was %s and is now %s, but updateChangeImpl returned"
-                + " new patch set instance at %s",
-            oldPsId,
-            newPsId,
-            n);
-        mergedPatchSet = newPatchSet;
-      }
-    }
-
-    Change c = ctx.getChange();
-    Change.Id id = c.getId();
-    CodeReviewCommit commit = args.commitStatus.get(id);
-    checkNotNull(commit, "missing commit for change " + id);
-    CommitMergeStatus s = commit.getStatusCode();
-    checkNotNull(s, "status not set for change " + id + " expected to previously fail fast");
-    logDebug("Status of change {} ({}) on {}: {}", id, commit.name(), c.getDest(), s);
-    setApproval(ctx, args.caller);
-
-    mergeResultRev =
-        alreadyMergedCommit == null
-            ? args.mergeTip.getMergeResults().get(commit)
-            // Our fixup code is not smart enough to find a merge commit
-            // corresponding to the merge result. This results in a different
-            // ChangeMergedEvent in the fixup case, but we'll just live with that.
-            : alreadyMergedCommit;
-    try {
-      setMerged(ctx, message(ctx, commit, s));
-    } catch (OrmException err) {
-      String msg = "Error updating change status for " + id;
-      log.error(msg, err);
-      args.commitStatus.logProblem(id, msg);
-      // It's possible this happened before updating anything in the db, but
-      // it's hard to know for sure, so just return true below to be safe.
-    }
-    updatedChange = c;
-    return true;
-  }
-
-  private PatchSet getOrCreateAlreadyMergedPatchSet(ChangeContext ctx)
-      throws IOException, OrmException {
-    PatchSet.Id psId = alreadyMergedCommit.getPatchsetId();
-    logDebug("Fixing up already-merged patch set {}", psId);
-    PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
-    ctx.getRevWalk().parseBody(alreadyMergedCommit);
-    ctx.getChange()
-        .setCurrentPatchSet(
-            psId, alreadyMergedCommit.getShortMessage(), ctx.getChange().getOriginalSubject());
-    PatchSet existing = args.psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-    if (existing != null) {
-      logDebug("Patch set row exists, only updating change");
-      return existing;
-    }
-    // No patch set for the already merged commit, although we know it came form
-    // a patch set ref. Fix up the database. Note that this uses the current
-    // user as the uploader, which is as good a guess as any.
-    List<String> groups =
-        prevPs != null ? prevPs.getGroups() : GroupCollector.getDefaultGroups(alreadyMergedCommit);
-    return args.psUtil.insert(
-        ctx.getDb(),
-        ctx.getRevWalk(),
-        ctx.getUpdate(psId),
-        psId,
-        alreadyMergedCommit,
-        groups,
-        null,
-        null);
-  }
-
-  private void setApproval(ChangeContext ctx, IdentifiedUser user)
-      throws OrmException, IOException, PermissionBackendException {
-    Change.Id id = ctx.getChange().getId();
-    List<SubmitRecord> records = args.commitStatus.getSubmitRecords(id);
-    PatchSet.Id oldPsId = toMerge.getPatchsetId();
-    PatchSet.Id newPsId = ctx.getChange().currentPatchSetId();
-
-    logDebug("Add approval for " + id);
-    ChangeUpdate origPsUpdate = ctx.getUpdate(oldPsId);
-    origPsUpdate.putReviewer(user.getAccountId(), REVIEWER);
-    LabelNormalizer.Result normalized = approve(ctx, origPsUpdate);
-
-    ChangeUpdate newPsUpdate = ctx.getUpdate(newPsId);
-    newPsUpdate.merge(args.submissionId, records);
-    // If the submit strategy created a new revision (rebase, cherry-pick), copy
-    // approvals as well.
-    if (!newPsId.equals(oldPsId)) {
-      saveApprovals(normalized, ctx, newPsUpdate, true);
-      submitter = convertPatchSet(newPsId).apply(submitter);
-    }
-  }
-
-  private LabelNormalizer.Result approve(ChangeContext ctx, ChangeUpdate update)
-      throws OrmException, IOException, PermissionBackendException {
-    PatchSet.Id psId = update.getPatchSetId();
-    Map<PatchSetApproval.Key, PatchSetApproval> byKey = new HashMap<>();
-    for (PatchSetApproval psa :
-        args.approvalsUtil.byPatchSet(
-            ctx.getDb(),
-            ctx.getNotes(),
-            ctx.getUser(),
-            psId,
-            ctx.getRevWalk(),
-            ctx.getRepoView().getConfig())) {
-      byKey.put(psa.getKey(), psa);
-    }
-
-    submitter =
-        ApprovalsUtil.newApproval(psId, ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen());
-    byKey.put(submitter.getKey(), submitter);
-
-    // Flatten out existing approvals for this patch set based upon the current
-    // permissions. Once the change is closed the approvals are not updated at
-    // presentation view time, except for zero votes used to indicate a reviewer
-    // was added. So we need to make sure votes are accurate now. This way if
-    // permissions get modified in the future, historical records stay accurate.
-    LabelNormalizer.Result normalized =
-        args.labelNormalizer.normalize(ctx.getNotes(), ctx.getUser(), byKey.values());
-    update.putApproval(submitter.getLabel(), submitter.getValue());
-    saveApprovals(normalized, ctx, update, false);
-    return normalized;
-  }
-
-  private void saveApprovals(
-      LabelNormalizer.Result normalized,
-      ChangeContext ctx,
-      ChangeUpdate update,
-      boolean includeUnchanged)
-      throws OrmException {
-    PatchSet.Id psId = update.getPatchSetId();
-    ctx.getDb().patchSetApprovals().upsert(convertPatchSet(normalized.getNormalized(), psId));
-    ctx.getDb().patchSetApprovals().upsert(zero(convertPatchSet(normalized.deleted(), psId)));
-    for (PatchSetApproval psa : normalized.updated()) {
-      update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
-    }
-    for (PatchSetApproval psa : normalized.deleted()) {
-      update.removeApprovalFor(psa.getAccountId(), psa.getLabel());
-    }
-
-    // TODO(dborowitz): Don't use a label in NoteDb; just check when status
-    // change happened.
-    for (PatchSetApproval psa : normalized.unchanged()) {
-      if (includeUnchanged || psa.isLegacySubmit()) {
-        logDebug("Adding submit label " + psa);
-        update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
-      }
-    }
-  }
-
-  private static Function<PatchSetApproval, PatchSetApproval> convertPatchSet(
-      final PatchSet.Id psId) {
-    return psa -> {
-      if (psa.getPatchSetId().equals(psId)) {
-        return psa;
-      }
-      return new PatchSetApproval(psId, psa);
-    };
-  }
-
-  private static Iterable<PatchSetApproval> convertPatchSet(
-      Iterable<PatchSetApproval> approvals, PatchSet.Id psId) {
-    return Iterables.transform(approvals, convertPatchSet(psId));
-  }
-
-  private static Iterable<PatchSetApproval> zero(Iterable<PatchSetApproval> approvals) {
-    return Iterables.transform(
-        approvals,
-        a -> {
-          PatchSetApproval copy = new PatchSetApproval(a.getPatchSetId(), a);
-          copy.setValue((short) 0);
-          return copy;
-        });
-  }
-
-  private String getByAccountName() {
-    checkNotNull(submitter, "getByAccountName called before submitter populated");
-    Account account = args.accountCache.get(submitter.getAccountId()).getAccount();
-    if (account != null && account.getFullName() != null) {
-      return " by " + account.getFullName();
-    }
-    return "";
-  }
-
-  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s)
-      throws OrmException {
-    checkNotNull(s, "CommitMergeStatus may not be null");
-    String txt = s.getMessage();
-    if (s == CommitMergeStatus.CLEAN_MERGE) {
-      return message(ctx, commit.getPatchsetId(), txt + getByAccountName());
-    } else if (s == CommitMergeStatus.CLEAN_REBASE || s == CommitMergeStatus.CLEAN_PICK) {
-      return message(
-          ctx, commit.getPatchsetId(), txt + " as " + commit.name() + getByAccountName());
-    } else if (s == CommitMergeStatus.SKIPPED_IDENTICAL_TREE) {
-      return message(ctx, commit.getPatchsetId(), txt);
-    } else if (s == CommitMergeStatus.ALREADY_MERGED) {
-      // Best effort to mimic the message that would have happened had this
-      // succeeded the first time around.
-      switch (args.submitType) {
-        case FAST_FORWARD_ONLY:
-        case MERGE_ALWAYS:
-        case MERGE_IF_NECESSARY:
-          return message(ctx, commit, CommitMergeStatus.CLEAN_MERGE);
-        case CHERRY_PICK:
-          return message(ctx, commit, CommitMergeStatus.CLEAN_PICK);
-        case REBASE_IF_NECESSARY:
-        case REBASE_ALWAYS:
-          return message(ctx, commit, CommitMergeStatus.CLEAN_REBASE);
-        default:
-          throw new IllegalStateException(
-              "unexpected submit type "
-                  + args.submitType.toString()
-                  + " for change "
-                  + commit.change().getId());
-      }
-    } else {
-      throw new IllegalStateException(
-          "unexpected status "
-              + s
-              + " for change "
-              + commit.change().getId()
-              + "; expected to previously fail fast");
-    }
-  }
-
-  private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId, String body) {
-    return ChangeMessagesUtil.newMessage(
-        psId, ctx.getUser(), ctx.getWhen(), body, ChangeMessagesUtil.TAG_MERGED);
-  }
-
-  private void setMerged(ChangeContext ctx, ChangeMessage msg) throws OrmException {
-    Change c = ctx.getChange();
-    ReviewDb db = ctx.getDb();
-    logDebug("Setting change {} merged", c.getId());
-    c.setStatus(Change.Status.MERGED);
-    c.setSubmissionId(args.submissionId.toStringForStorage());
-
-    // TODO(dborowitz): We need to be able to change the author of the message,
-    // which is not the user from the update context. addMergedMessage was able
-    // to do this in the past.
-    if (msg != null) {
-      args.cmUtil.addChangeMessage(db, ctx.getUpdate(msg.getPatchSetId()), msg);
-    }
-  }
-
-  @Override
-  public final void postUpdate(Context ctx) throws Exception {
-    if (changeAlreadyMerged) {
-      // TODO(dborowitz): This is suboptimal behavior in the presence of retries: postUpdate steps
-      // will never get run for changes that submitted successfully on any but the final attempt.
-      // This is primarily a temporary workaround for the fact that the submitter field is not
-      // populated in the changeAlreadyMerged case.
-      //
-      // If we naively execute postUpdate even if the change is already merged when updateChange
-      // being, then we are subject to a race where postUpdate steps are run twice if two submit
-      // processes run at the same time.
-      logDebug("Skipping post-update steps for change {}", getId());
-      return;
-    }
-    postUpdateImpl(ctx);
-
-    if (command != null) {
-      args.tagCache.updateFastForward(
-          getProject(), command.getRefName(), command.getOldId(), command.getNewId());
-      // TODO(dborowitz): Move to BatchUpdate? Would also allow us to run once
-      // per project even if multiple changes to refs/meta/config are submitted.
-      if (RefNames.REFS_CONFIG.equals(getDest().get())) {
-        args.projectCache.evict(getProject());
-        ProjectState p = args.projectCache.get(getProject());
-        try (Repository git = args.repoManager.openRepository(getProject())) {
-          git.setGitwebDescription(p.getProject().getDescription());
-        } catch (IOException e) {
-          log.error("cannot update description of " + p.getName(), e);
-        }
-      }
-    }
-
-    // Assume the change must have been merged at this point, otherwise we would
-    // have failed fast in one of the other steps.
-    try {
-      args.mergedSenderFactory
-          .create(
-              ctx.getProject(),
-              getId(),
-              submitter.getAccountId(),
-              args.submitInput.notify,
-              args.accountsToNotify)
-          .sendAsync();
-    } catch (Exception e) {
-      log.error("Cannot email merged notification for " + getId(), e);
-    }
-    if (mergeResultRev != null && !args.dryrun) {
-      args.changeMerged.fire(
-          updatedChange,
-          mergedPatchSet,
-          args.accountCache.get(submitter.getAccountId()).getAccount(),
-          args.mergeTip.getCurrentTip().name(),
-          ctx.getWhen());
-    }
-  }
-
-  /**
-   * @see #updateRepo(RepoContext)
-   * @param ctx
-   */
-  protected void updateRepoImpl(RepoContext ctx) throws Exception {}
-
-  /**
-   * @see #updateChange(ChangeContext)
-   * @param ctx
-   * @return a new patch set if one was created by the submit strategy, or null if not.
-   */
-  protected PatchSet updateChangeImpl(ChangeContext ctx) throws Exception {
-    return null;
-  }
-
-  /**
-   * @see #postUpdate(Context)
-   * @param ctx
-   */
-  protected void postUpdateImpl(Context ctx) throws Exception {}
-
-  /**
-   * Amend the commit with gitlink update
-   *
-   * @param commit
-   */
-  protected CodeReviewCommit amendGitlink(CodeReviewCommit commit) throws IntegrationException {
-    if (!args.submoduleOp.hasSubscription(args.destBranch)) {
-      return commit;
-    }
-
-    // Modify the commit with gitlink update
-    try {
-      return args.submoduleOp.composeGitlinksCommit(args.destBranch, commit);
-    } catch (SubmoduleException | IOException e) {
-      throw new IntegrationException(
-          "cannot update gitlink for the commit at branch: " + args.destBranch);
-    }
-  }
-
-  protected final void logDebug(String msg, Object... args) {
-    if (log.isDebugEnabled()) {
-      log.debug(this.args.submissionId + msg, args);
-    }
-  }
-
-  protected final void logWarn(String msg, Throwable t) {
-    if (log.isWarnEnabled()) {
-      log.warn(args.submissionId + msg, t);
-    }
-  }
-
-  protected void logError(String msg, Throwable t) {
-    if (log.isErrorEnabled()) {
-      if (t != null) {
-        log.error(args.submissionId + msg, t);
-      } else {
-        log.error(args.submissionId + msg);
-      }
-    }
-  }
-
-  protected void logError(String msg) {
-    logError(msg, null);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/TestHelperOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/TestHelperOp.java
deleted file mode 100644
index 8d95045..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/TestHelperOp.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.strategy;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.change.Submit.TestSubmitInput;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.server.util.RequestId;
-import java.io.IOException;
-import java.util.Queue;
-import org.eclipse.jgit.lib.ObjectId;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-class TestHelperOp implements BatchUpdateOp {
-  private static final Logger log = LoggerFactory.getLogger(TestHelperOp.class);
-
-  private final Change.Id changeId;
-  private final TestSubmitInput input;
-  private final RequestId submissionId;
-
-  TestHelperOp(Change.Id changeId, SubmitStrategy.Arguments args) {
-    this.changeId = changeId;
-    this.input = (TestSubmitInput) args.submitInput;
-    this.submissionId = args.submissionId;
-  }
-
-  @Override
-  public void updateRepo(RepoContext ctx) throws IOException {
-    Queue<Boolean> q = input.generateLockFailures;
-    if (q != null && !q.isEmpty() && q.remove()) {
-      logDebug("Adding bogus ref update to trigger lock failure, via change {}", changeId);
-      ctx.addRefUpdate(
-          ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
-          ObjectId.zeroId(),
-          "refs/test/" + getClass().getSimpleName());
-    }
-  }
-
-  private void logDebug(String msg, Object... args) {
-    if (log.isDebugEnabled()) {
-      log.debug(submissionId + msg, args);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/AccountValidator.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/AccountValidator.java
deleted file mode 100644
index 934b63c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/AccountValidator.java
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.validators;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountConfig;
-import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-public class AccountValidator {
-
-  private final Provider<IdentifiedUser> self;
-  private final OutgoingEmailValidator emailValidator;
-
-  @Inject
-  public AccountValidator(Provider<IdentifiedUser> self, OutgoingEmailValidator emailValidator) {
-    this.self = self;
-    this.emailValidator = emailValidator;
-  }
-
-  public List<String> validate(
-      Account.Id accountId, RevWalk rw, @Nullable ObjectId oldId, ObjectId newId)
-      throws IOException {
-    Account oldAccount = null;
-    if (oldId != null && !ObjectId.zeroId().equals(oldId)) {
-      try {
-        oldAccount = loadAccount(accountId, rw, oldId);
-      } catch (ConfigInvalidException e) {
-        // ignore, maybe the new commit is repairing it now
-      }
-    }
-
-    Account newAccount;
-    try {
-      newAccount = loadAccount(accountId, rw, newId);
-    } catch (ConfigInvalidException e) {
-      return ImmutableList.of(
-          String.format(
-              "commit '%s' has an invalid '%s' file for account '%s': %s",
-              newId.name(), AccountConfig.ACCOUNT_CONFIG, accountId.get(), e.getMessage()));
-    }
-
-    List<String> messages = new ArrayList<>();
-    if (accountId.equals(self.get().getAccountId()) && !newAccount.isActive()) {
-      messages.add("cannot deactivate own account");
-    }
-
-    if (newAccount.getPreferredEmail() != null
-        && (oldAccount == null
-            || !newAccount.getPreferredEmail().equals(oldAccount.getPreferredEmail()))) {
-      if (!emailValidator.isValid(newAccount.getPreferredEmail())) {
-        messages.add(
-            String.format(
-                "invalid preferred email '%s' for account '%s'",
-                newAccount.getPreferredEmail(), accountId.get()));
-      }
-    }
-
-    return ImmutableList.copyOf(messages);
-  }
-
-  private Account loadAccount(Account.Id accountId, RevWalk rw, ObjectId commit)
-      throws IOException, ConfigInvalidException {
-    rw.reset();
-    AccountConfig accountConfig = new AccountConfig(null, accountId);
-    accountConfig.load(rw, commit);
-    return accountConfig.getAccount();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
deleted file mode 100644
index f12ba37..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ /dev/null
@@ -1,838 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.validators;
-
-import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.WatchConfig;
-import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.events.CommitReceivedEvent;
-import com.google.gerrit.server.git.BanCommit;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.ssh.SshInfo;
-import com.google.gerrit.server.util.MagicBranch;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import com.jcraft.jsch.HostKey;
-import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.regex.Pattern;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.FooterKey;
-import org.eclipse.jgit.revwalk.FooterLine;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.SystemReader;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class CommitValidators {
-  private static final Logger log = LoggerFactory.getLogger(CommitValidators.class);
-
-  public static final Pattern NEW_PATCHSET_PATTERN =
-      Pattern.compile("^" + REFS_CHANGES + "(?:[0-9][0-9]/)?([1-9][0-9]*)(?:/new)?$");
-
-  @Singleton
-  public static class Factory {
-    private final PersonIdent gerritIdent;
-    private final String canonicalWebUrl;
-    private final DynamicSet<CommitValidationListener> pluginValidators;
-    private final AllUsersName allUsers;
-    private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
-    private final AccountValidator accountValidator;
-    private final String installCommitMsgHookCommand;
-    private final ProjectCache projectCache;
-
-    @Inject
-    Factory(
-        @GerritPersonIdent PersonIdent gerritIdent,
-        @CanonicalWebUrl @Nullable String canonicalWebUrl,
-        @GerritServerConfig Config cfg,
-        DynamicSet<CommitValidationListener> pluginValidators,
-        AllUsersName allUsers,
-        ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
-        AccountValidator accountValidator,
-        ProjectCache projectCache) {
-      this.gerritIdent = gerritIdent;
-      this.canonicalWebUrl = canonicalWebUrl;
-      this.pluginValidators = pluginValidators;
-      this.allUsers = allUsers;
-      this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
-      this.accountValidator = accountValidator;
-      this.installCommitMsgHookCommand =
-          cfg != null ? cfg.getString("gerrit", null, "installCommitMsgHookCommand") : null;
-      this.projectCache = projectCache;
-    }
-
-    public CommitValidators forReceiveCommits(
-        PermissionBackend.ForRef perm,
-        Branch.NameKey branch,
-        IdentifiedUser user,
-        SshInfo sshInfo,
-        Repository repo,
-        RevWalk rw)
-        throws IOException {
-      NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw);
-      ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
-      return new CommitValidators(
-          ImmutableList.of(
-              new UploadMergesPermissionValidator(perm),
-              new AmendedGerritMergeCommitValidationListener(perm, gerritIdent),
-              new AuthorUploaderValidator(user, perm, canonicalWebUrl),
-              new CommitterUploaderValidator(user, perm, canonicalWebUrl),
-              new SignedOffByValidator(user, perm, projectState),
-              new ChangeIdValidator(
-                  projectState, user, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
-              new ConfigValidator(branch, user, rw, allUsers),
-              new BannedCommitsValidator(rejectCommits),
-              new PluginCommitValidationListener(pluginValidators),
-              new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker),
-              new AccountCommitValidator(allUsers, accountValidator)));
-    }
-
-    public CommitValidators forGerritCommits(
-        PermissionBackend.ForRef perm,
-        Branch.NameKey branch,
-        IdentifiedUser user,
-        SshInfo sshInfo,
-        RevWalk rw)
-        throws IOException {
-      return new CommitValidators(
-          ImmutableList.of(
-              new UploadMergesPermissionValidator(perm),
-              new AmendedGerritMergeCommitValidationListener(perm, gerritIdent),
-              new AuthorUploaderValidator(user, perm, canonicalWebUrl),
-              new SignedOffByValidator(user, perm, projectCache.checkedGet(branch.getParentKey())),
-              new ChangeIdValidator(
-                  projectCache.checkedGet(branch.getParentKey()),
-                  user,
-                  canonicalWebUrl,
-                  installCommitMsgHookCommand,
-                  sshInfo),
-              new ConfigValidator(branch, user, rw, allUsers),
-              new PluginCommitValidationListener(pluginValidators),
-              new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker),
-              new AccountCommitValidator(allUsers, accountValidator)));
-    }
-
-    public CommitValidators forMergedCommits(PermissionBackend.ForRef perm, IdentifiedUser user) {
-      // Generally only include validators that are based on permissions of the
-      // user creating a change for a merged commit; generally exclude
-      // validators that would require amending the change in order to correct.
-      //
-      // Examples:
-      //  - Change-Id and Signed-off-by can't be added to an already-merged
-      //    commit.
-      //  - If the commit is banned, we can't ban it here. In fact, creating a
-      //    review of a previously merged and recently-banned commit is a use
-      //    case for post-commit code review: so reviewers have a place to
-      //    discuss what to do about it.
-      //  - Plugin validators may do things like require certain commit message
-      //    formats, so we play it safe and exclude them.
-      return new CommitValidators(
-          ImmutableList.of(
-              new UploadMergesPermissionValidator(perm),
-              new AuthorUploaderValidator(user, perm, canonicalWebUrl),
-              new CommitterUploaderValidator(user, perm, canonicalWebUrl)));
-    }
-  }
-
-  private final List<CommitValidationListener> validators;
-
-  CommitValidators(List<CommitValidationListener> validators) {
-    this.validators = validators;
-  }
-
-  public List<CommitValidationMessage> validate(CommitReceivedEvent receiveEvent)
-      throws CommitValidationException {
-    List<CommitValidationMessage> messages = new ArrayList<>();
-    try {
-      for (CommitValidationListener commitValidator : validators) {
-        messages.addAll(commitValidator.onCommitReceived(receiveEvent));
-      }
-    } catch (CommitValidationException e) {
-      // Keep the old messages (and their order) in case of an exception
-      messages.addAll(e.getMessages());
-      throw new CommitValidationException(e.getMessage(), messages);
-    }
-    return messages;
-  }
-
-  public static class ChangeIdValidator implements CommitValidationListener {
-    private static final String CHANGE_ID_PREFIX = FooterConstants.CHANGE_ID.getName() + ":";
-    private static final String MISSING_CHANGE_ID_MSG =
-        "[%s] missing " + FooterConstants.CHANGE_ID.getName() + " in commit message footer";
-    private static final String MISSING_SUBJECT_MSG =
-        "[%s] missing subject; "
-            + FooterConstants.CHANGE_ID.getName()
-            + " must be in commit message footer";
-    private static final String MULTIPLE_CHANGE_ID_MSG =
-        "[%s] multiple " + FooterConstants.CHANGE_ID.getName() + " lines in commit message footer";
-    private static final String INVALID_CHANGE_ID_MSG =
-        "[%s] invalid "
-            + FooterConstants.CHANGE_ID.getName()
-            + " line format in commit message footer";
-    private static final Pattern CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
-
-    private final ProjectState projectState;
-    private final String canonicalWebUrl;
-    private final String installCommitMsgHookCommand;
-    private final SshInfo sshInfo;
-    private final IdentifiedUser user;
-
-    public ChangeIdValidator(
-        ProjectState projectState,
-        IdentifiedUser user,
-        String canonicalWebUrl,
-        String installCommitMsgHookCommand,
-        SshInfo sshInfo) {
-      this.projectState = projectState;
-      this.canonicalWebUrl = canonicalWebUrl;
-      this.installCommitMsgHookCommand = installCommitMsgHookCommand;
-      this.sshInfo = sshInfo;
-      this.user = user;
-    }
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
-        throws CommitValidationException {
-      if (!shouldValidateChangeId(receiveEvent)) {
-        return Collections.emptyList();
-      }
-      RevCommit commit = receiveEvent.commit;
-      List<CommitValidationMessage> messages = new ArrayList<>();
-      List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
-      String sha1 = commit.abbreviate(RevId.ABBREV_LEN).name();
-
-      if (idList.isEmpty()) {
-        if (projectState.isRequireChangeID()) {
-          String shortMsg = commit.getShortMessage();
-          if (shortMsg.startsWith(CHANGE_ID_PREFIX)
-              && CHANGE_ID
-                  .matcher(shortMsg.substring(CHANGE_ID_PREFIX.length()).trim())
-                  .matches()) {
-            String errMsg = String.format(MISSING_SUBJECT_MSG, sha1);
-            throw new CommitValidationException(errMsg);
-          }
-          String errMsg = String.format(MISSING_CHANGE_ID_MSG, sha1);
-          messages.add(getMissingChangeIdErrorMsg(errMsg, commit));
-          throw new CommitValidationException(errMsg, messages);
-        }
-      } else if (idList.size() > 1) {
-        String errMsg = String.format(MULTIPLE_CHANGE_ID_MSG, sha1);
-        throw new CommitValidationException(errMsg, messages);
-      } else {
-        String v = idList.get(idList.size() - 1).trim();
-        // Reject Change-Ids with wrong format and invalid placeholder ID from
-        // Egit (I0000000000000000000000000000000000000000).
-        if (!CHANGE_ID.matcher(v).matches() || v.matches("^I00*$")) {
-          String errMsg = String.format(INVALID_CHANGE_ID_MSG, sha1);
-          messages.add(getMissingChangeIdErrorMsg(errMsg, receiveEvent.commit));
-          throw new CommitValidationException(errMsg, messages);
-        }
-      }
-      return Collections.emptyList();
-    }
-
-    private static boolean shouldValidateChangeId(CommitReceivedEvent event) {
-      return MagicBranch.isMagicBranch(event.command.getRefName())
-          || NEW_PATCHSET_PATTERN.matcher(event.command.getRefName()).matches();
-    }
-
-    private CommitValidationMessage getMissingChangeIdErrorMsg(String errMsg, RevCommit c) {
-      StringBuilder sb = new StringBuilder();
-      sb.append("ERROR: ").append(errMsg);
-
-      if (c.getFullMessage().indexOf(CHANGE_ID_PREFIX) >= 0) {
-        String[] lines = c.getFullMessage().trim().split("\n");
-        String lastLine = lines.length > 0 ? lines[lines.length - 1] : "";
-
-        if (lastLine.indexOf(CHANGE_ID_PREFIX) == -1) {
-          sb.append('\n');
-          sb.append('\n');
-          sb.append("Hint: A potential ");
-          sb.append(FooterConstants.CHANGE_ID.getName());
-          sb.append("Change-Id was found, but it was not in the ");
-          sb.append("footer (last paragraph) of the commit message.");
-        }
-      }
-      sb.append('\n');
-      sb.append('\n');
-      sb.append("Hint: To automatically insert ");
-      sb.append(FooterConstants.CHANGE_ID.getName());
-      sb.append(", install the hook:\n");
-      sb.append(getCommitMessageHookInstallationHint());
-      sb.append('\n');
-      sb.append("And then amend the commit:\n");
-      sb.append("  git commit --amend\n");
-
-      return new CommitValidationMessage(sb.toString(), false);
-    }
-
-    private String getCommitMessageHookInstallationHint() {
-      if (installCommitMsgHookCommand != null) {
-        return installCommitMsgHookCommand;
-      }
-      final List<HostKey> hostKeys = sshInfo.getHostKeys();
-
-      // If there are no SSH keys, the commit-msg hook must be installed via
-      // HTTP(S)
-      if (hostKeys.isEmpty()) {
-        String p = "${gitdir}/hooks/commit-msg";
-        return String.format(
-            "  gitdir=$(git rev-parse --git-dir); curl -o %s %s/tools/hooks/commit-msg ; chmod +x %s",
-            p, getGerritUrl(canonicalWebUrl), p);
-      }
-
-      // SSH keys exist, so the hook can be installed with scp.
-      String sshHost;
-      int sshPort;
-      String host = hostKeys.get(0).getHost();
-      int c = host.lastIndexOf(':');
-      if (0 <= c) {
-        if (host.startsWith("*:")) {
-          sshHost = getGerritHost(canonicalWebUrl);
-        } else {
-          sshHost = host.substring(0, c);
-        }
-        sshPort = Integer.parseInt(host.substring(c + 1));
-      } else {
-        sshHost = host;
-        sshPort = 22;
-      }
-
-      return String.format(
-          "  gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/",
-          sshPort, user.getUserName(), sshHost);
-    }
-  }
-
-  /** If this is the special project configuration branch, validate the config. */
-  public static class ConfigValidator implements CommitValidationListener {
-    private final Branch.NameKey branch;
-    private final IdentifiedUser user;
-    private final RevWalk rw;
-    private final AllUsersName allUsers;
-
-    public ConfigValidator(
-        Branch.NameKey branch, IdentifiedUser user, RevWalk rw, AllUsersName allUsers) {
-      this.branch = branch;
-      this.user = user;
-      this.rw = rw;
-      this.allUsers = allUsers;
-    }
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
-        throws CommitValidationException {
-      if (REFS_CONFIG.equals(branch.get())) {
-        List<CommitValidationMessage> messages = new ArrayList<>();
-
-        try {
-          ProjectConfig cfg = new ProjectConfig(receiveEvent.project.getNameKey());
-          cfg.load(rw, receiveEvent.command.getNewId());
-          if (!cfg.getValidationErrors().isEmpty()) {
-            addError("Invalid project configuration:", messages);
-            for (ValidationError err : cfg.getValidationErrors()) {
-              addError("  " + err.getMessage(), messages);
-            }
-            throw new ConfigInvalidException("invalid project configuration");
-          }
-        } catch (ConfigInvalidException | IOException e) {
-          log.error(
-              "User "
-                  + user.getUserName()
-                  + " tried to push an invalid project configuration "
-                  + receiveEvent.command.getNewId().name()
-                  + " for project "
-                  + receiveEvent.project,
-              e);
-          throw new CommitValidationException("invalid project configuration", messages);
-        }
-      }
-
-      if (allUsers.equals(branch.getParentKey()) && RefNames.isRefsUsers(branch.get())) {
-        List<CommitValidationMessage> messages = new ArrayList<>();
-        Account.Id accountId = Account.Id.fromRef(branch.get());
-        if (accountId != null) {
-          try {
-            WatchConfig wc = new WatchConfig(accountId);
-            wc.load(rw, receiveEvent.command.getNewId());
-            if (!wc.getValidationErrors().isEmpty()) {
-              addError("Invalid project configuration:", messages);
-              for (ValidationError err : wc.getValidationErrors()) {
-                addError("  " + err.getMessage(), messages);
-              }
-              throw new ConfigInvalidException("invalid watch configuration");
-            }
-          } catch (IOException | ConfigInvalidException e) {
-            log.error(
-                "User "
-                    + user.getUserName()
-                    + " tried to push an invalid watch configuration "
-                    + receiveEvent.command.getNewId().name()
-                    + " for account "
-                    + accountId.get(),
-                e);
-            throw new CommitValidationException("invalid watch configuration", messages);
-          }
-        }
-      }
-
-      return Collections.emptyList();
-    }
-  }
-
-  /** Require permission to upload merge commits. */
-  public static class UploadMergesPermissionValidator implements CommitValidationListener {
-    private final PermissionBackend.ForRef perm;
-
-    public UploadMergesPermissionValidator(PermissionBackend.ForRef perm) {
-      this.perm = perm;
-    }
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
-        throws CommitValidationException {
-      if (receiveEvent.commit.getParentCount() <= 1) {
-        return Collections.emptyList();
-      }
-      try {
-        perm.check(RefPermission.MERGE);
-        return Collections.emptyList();
-      } catch (AuthException e) {
-        throw new CommitValidationException("you are not allowed to upload merges");
-      } catch (PermissionBackendException e) {
-        log.error("cannot check MERGE", e);
-        throw new CommitValidationException("internal auth error");
-      }
-    }
-  }
-
-  /** Execute commit validation plug-ins */
-  public static class PluginCommitValidationListener implements CommitValidationListener {
-    private final DynamicSet<CommitValidationListener> commitValidationListeners;
-
-    public PluginCommitValidationListener(
-        final DynamicSet<CommitValidationListener> commitValidationListeners) {
-      this.commitValidationListeners = commitValidationListeners;
-    }
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
-        throws CommitValidationException {
-      List<CommitValidationMessage> messages = new ArrayList<>();
-
-      for (CommitValidationListener validator : commitValidationListeners) {
-        try {
-          messages.addAll(validator.onCommitReceived(receiveEvent));
-        } catch (CommitValidationException e) {
-          messages.addAll(e.getMessages());
-          throw new CommitValidationException(e.getMessage(), messages);
-        }
-      }
-      return messages;
-    }
-  }
-
-  public static class SignedOffByValidator implements CommitValidationListener {
-    private final IdentifiedUser user;
-    private final PermissionBackend.ForRef perm;
-    private final ProjectState state;
-
-    public SignedOffByValidator(
-        IdentifiedUser user, PermissionBackend.ForRef perm, ProjectState state) {
-      this.user = user;
-      this.perm = perm;
-      this.state = state;
-    }
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
-        throws CommitValidationException {
-      if (!state.isUseSignedOffBy()) {
-        return Collections.emptyList();
-      }
-
-      RevCommit commit = receiveEvent.commit;
-      PersonIdent committer = commit.getCommitterIdent();
-      PersonIdent author = commit.getAuthorIdent();
-
-      boolean sboAuthor = false;
-      boolean sboCommitter = false;
-      boolean sboMe = false;
-      for (FooterLine footer : commit.getFooterLines()) {
-        if (footer.matches(FooterKey.SIGNED_OFF_BY)) {
-          String e = footer.getEmailAddress();
-          if (e != null) {
-            sboAuthor |= author.getEmailAddress().equals(e);
-            sboCommitter |= committer.getEmailAddress().equals(e);
-            sboMe |= user.hasEmailAddress(e);
-          }
-        }
-      }
-      if (!sboAuthor && !sboCommitter && !sboMe) {
-        try {
-          perm.check(RefPermission.FORGE_COMMITTER);
-        } catch (AuthException denied) {
-          throw new CommitValidationException(
-              "not Signed-off-by author/committer/uploader in commit message footer");
-        } catch (PermissionBackendException e) {
-          log.error("cannot check FORGE_COMMITTER", e);
-          throw new CommitValidationException("internal auth error");
-        }
-      }
-      return Collections.emptyList();
-    }
-  }
-
-  /** Require that author matches the uploader. */
-  public static class AuthorUploaderValidator implements CommitValidationListener {
-    private final IdentifiedUser user;
-    private final PermissionBackend.ForRef perm;
-    private final String canonicalWebUrl;
-
-    public AuthorUploaderValidator(
-        IdentifiedUser user, PermissionBackend.ForRef perm, String canonicalWebUrl) {
-      this.user = user;
-      this.perm = perm;
-      this.canonicalWebUrl = canonicalWebUrl;
-    }
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
-        throws CommitValidationException {
-      PersonIdent author = receiveEvent.commit.getAuthorIdent();
-      if (user.hasEmailAddress(author.getEmailAddress())) {
-        return Collections.emptyList();
-      }
-      try {
-        perm.check(RefPermission.FORGE_AUTHOR);
-        return Collections.emptyList();
-      } catch (AuthException e) {
-        throw new CommitValidationException(
-            "invalid author",
-            invalidEmail(receiveEvent.commit, "author", author, user, canonicalWebUrl));
-      } catch (PermissionBackendException e) {
-        log.error("cannot check FORGE_AUTHOR", e);
-        throw new CommitValidationException("internal auth error");
-      }
-    }
-  }
-
-  /** Require that committer matches the uploader. */
-  public static class CommitterUploaderValidator implements CommitValidationListener {
-    private final IdentifiedUser user;
-    private final PermissionBackend.ForRef perm;
-    private final String canonicalWebUrl;
-
-    public CommitterUploaderValidator(
-        IdentifiedUser user, PermissionBackend.ForRef perm, String canonicalWebUrl) {
-      this.user = user;
-      this.perm = perm;
-      this.canonicalWebUrl = canonicalWebUrl;
-    }
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
-        throws CommitValidationException {
-      PersonIdent committer = receiveEvent.commit.getCommitterIdent();
-      if (user.hasEmailAddress(committer.getEmailAddress())) {
-        return Collections.emptyList();
-      }
-      try {
-        perm.check(RefPermission.FORGE_COMMITTER);
-        return Collections.emptyList();
-      } catch (AuthException e) {
-        throw new CommitValidationException(
-            "invalid committer",
-            invalidEmail(receiveEvent.commit, "committer", committer, user, canonicalWebUrl));
-      } catch (PermissionBackendException e) {
-        log.error("cannot check FORGE_COMMITTER", e);
-        throw new CommitValidationException("internal auth error");
-      }
-    }
-  }
-
-  /**
-   * Don't allow the user to amend a merge created by Gerrit Code Review. This seems to happen all
-   * too often, due to users not paying any attention to what they are doing.
-   */
-  public static class AmendedGerritMergeCommitValidationListener
-      implements CommitValidationListener {
-    private final PermissionBackend.ForRef perm;
-    private final PersonIdent gerritIdent;
-
-    public AmendedGerritMergeCommitValidationListener(
-        PermissionBackend.ForRef perm, PersonIdent gerritIdent) {
-      this.perm = perm;
-      this.gerritIdent = gerritIdent;
-    }
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
-        throws CommitValidationException {
-      PersonIdent author = receiveEvent.commit.getAuthorIdent();
-      if (receiveEvent.commit.getParentCount() > 1
-          && author.getName().equals(gerritIdent.getName())
-          && author.getEmailAddress().equals(gerritIdent.getEmailAddress())) {
-        try {
-          // Stop authors from amending the merge commits that Gerrit itself creates.
-          perm.check(RefPermission.FORGE_SERVER);
-        } catch (AuthException denied) {
-          throw new CommitValidationException(
-              String.format(
-                  "pushing merge commit %s by %s requires '%s' permission",
-                  receiveEvent.commit.getId(),
-                  gerritIdent.getEmailAddress(),
-                  RefPermission.FORGE_SERVER.name()));
-        } catch (PermissionBackendException e) {
-          log.error("cannot check FORGE_SERVER", e);
-          throw new CommitValidationException("internal auth error");
-        }
-      }
-      return Collections.emptyList();
-    }
-  }
-
-  /** Reject banned commits. */
-  public static class BannedCommitsValidator implements CommitValidationListener {
-    private final NoteMap rejectCommits;
-
-    public BannedCommitsValidator(NoteMap rejectCommits) {
-      this.rejectCommits = rejectCommits;
-    }
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
-        throws CommitValidationException {
-      try {
-        if (rejectCommits.contains(receiveEvent.commit)) {
-          throw new CommitValidationException(
-              "contains banned commit " + receiveEvent.commit.getName());
-        }
-        return Collections.emptyList();
-      } catch (IOException e) {
-        String m = "error checking banned commits";
-        log.warn(m, e);
-        throw new CommitValidationException(m, e);
-      }
-    }
-  }
-
-  /** Validates updates to refs/meta/external-ids. */
-  public static class ExternalIdUpdateListener implements CommitValidationListener {
-    private final AllUsersName allUsers;
-    private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
-
-    public ExternalIdUpdateListener(
-        AllUsersName allUsers, ExternalIdsConsistencyChecker externalIdsConsistencyChecker) {
-      this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
-      this.allUsers = allUsers;
-    }
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
-        throws CommitValidationException {
-      if (allUsers.equals(receiveEvent.project.getNameKey())
-          && RefNames.REFS_EXTERNAL_IDS.equals(receiveEvent.refName)) {
-        try {
-          List<ConsistencyProblemInfo> problems =
-              externalIdsConsistencyChecker.check(receiveEvent.commit);
-          List<CommitValidationMessage> msgs =
-              problems
-                  .stream()
-                  .map(
-                      p ->
-                          new CommitValidationMessage(
-                              p.message, p.status == ConsistencyProblemInfo.Status.ERROR))
-                  .collect(toList());
-          if (msgs.stream().anyMatch(m -> m.isError())) {
-            throw new CommitValidationException("invalid external IDs", msgs);
-          }
-          return msgs;
-        } catch (IOException e) {
-          String m = "error validating external IDs";
-          log.warn(m, e);
-          throw new CommitValidationException(m, e);
-        }
-      }
-      return Collections.emptyList();
-    }
-  }
-
-  /** Rejects updates to 'account.config' in user branches. */
-  public static class AccountCommitValidator implements CommitValidationListener {
-    private final AllUsersName allUsers;
-    private final AccountValidator accountValidator;
-
-    public AccountCommitValidator(AllUsersName allUsers, AccountValidator accountValidator) {
-      this.allUsers = allUsers;
-      this.accountValidator = accountValidator;
-    }
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
-        throws CommitValidationException {
-      if (!allUsers.equals(receiveEvent.project.getNameKey())) {
-        return Collections.emptyList();
-      }
-
-      if (receiveEvent.command.getRefName().startsWith(MagicBranch.NEW_CHANGE)) {
-        // no validation on push for review, will be checked on submit by
-        // MergeValidators.AccountMergeValidator
-        return Collections.emptyList();
-      }
-
-      Account.Id accountId = Account.Id.fromRef(receiveEvent.refName);
-      if (accountId == null) {
-        return Collections.emptyList();
-      }
-
-      try {
-        List<String> errorMessages =
-            accountValidator.validate(
-                accountId,
-                receiveEvent.revWalk,
-                receiveEvent.command.getOldId(),
-                receiveEvent.commit);
-        if (!errorMessages.isEmpty()) {
-          throw new CommitValidationException(
-              "invalid account configuration",
-              errorMessages
-                  .stream()
-                  .map(m -> new CommitValidationMessage(m, true))
-                  .collect(toList()));
-        }
-      } catch (IOException e) {
-        String m = String.format("Validating update for account %s failed", accountId.get());
-        log.error(m, e);
-        throw new CommitValidationException(m, e);
-      }
-      return Collections.emptyList();
-    }
-  }
-
-  private static CommitValidationMessage invalidEmail(
-      RevCommit c,
-      String type,
-      PersonIdent who,
-      IdentifiedUser currentUser,
-      String canonicalWebUrl) {
-    StringBuilder sb = new StringBuilder();
-    sb.append("\n");
-    sb.append("ERROR:  In commit ").append(c.name()).append("\n");
-    sb.append("ERROR:  ")
-        .append(type)
-        .append(" email address ")
-        .append(who.getEmailAddress())
-        .append("\n");
-    sb.append("ERROR:  does not match your user account and you have no 'forge ")
-        .append(type)
-        .append("' permission.\n");
-    sb.append("ERROR:\n");
-    if (currentUser.getEmailAddresses().isEmpty()) {
-      sb.append("ERROR:  You have not registered any email addresses.\n");
-    } else {
-      sb.append("ERROR:  The following addresses are currently registered:\n");
-      for (String address : currentUser.getEmailAddresses()) {
-        sb.append("ERROR:    ").append(address).append("\n");
-      }
-    }
-    sb.append("ERROR:\n");
-    if (canonicalWebUrl != null) {
-      sb.append("ERROR:  To register an email address, please visit:\n");
-      sb.append("ERROR:  ")
-          .append(canonicalWebUrl)
-          .append("#")
-          .append(PageLinks.SETTINGS_CONTACT)
-          .append("\n");
-    }
-    sb.append("\n");
-    return new CommitValidationMessage(sb.toString(), false);
-  }
-
-  /**
-   * Get the Gerrit URL.
-   *
-   * @return the canonical URL (with any trailing slash removed) if it is configured, otherwise fall
-   *     back to "http://hostname" where hostname is the value returned by {@link
-   *     #getGerritHost(String)}.
-   */
-  private static String getGerritUrl(String canonicalWebUrl) {
-    if (canonicalWebUrl != null) {
-      return CharMatcher.is('/').trimTrailingFrom(canonicalWebUrl);
-    }
-    return "http://" + getGerritHost(canonicalWebUrl);
-  }
-
-  /**
-   * Get the Gerrit hostname.
-   *
-   * @return the hostname from the canonical URL if it is configured, otherwise whatever the OS says
-   *     the hostname is.
-   */
-  private static String getGerritHost(String canonicalWebUrl) {
-    String host;
-    if (canonicalWebUrl != null) {
-      try {
-        host = new URL(canonicalWebUrl).getHost();
-      } catch (MalformedURLException e) {
-        host = SystemReader.getInstance().getHostname();
-      }
-    } else {
-      host = SystemReader.getInstance().getHostname();
-    }
-    return host;
-  }
-
-  private static void addError(String error, List<CommitValidationMessage> messages) {
-    messages.add(new CommitValidationMessage(error, true));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
deleted file mode 100644
index 8ccf081..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ /dev/null
@@ -1,287 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git.validators;
-
-import com.google.common.base.Joiner;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicMap.Entry;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountConfig;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.PluginConfig;
-import com.google.gerrit.server.config.ProjectConfigEntry;
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class MergeValidators {
-  private static final Logger log = LoggerFactory.getLogger(MergeValidators.class);
-
-  private final DynamicSet<MergeValidationListener> mergeValidationListeners;
-  private final ProjectConfigValidator.Factory projectConfigValidatorFactory;
-  private final AccountMergeValidator.Factory accountValidatorFactory;
-
-  public interface Factory {
-    MergeValidators create();
-  }
-
-  @Inject
-  MergeValidators(
-      DynamicSet<MergeValidationListener> mergeValidationListeners,
-      ProjectConfigValidator.Factory projectConfigValidatorFactory,
-      AccountMergeValidator.Factory accountValidatorFactory) {
-    this.mergeValidationListeners = mergeValidationListeners;
-    this.projectConfigValidatorFactory = projectConfigValidatorFactory;
-    this.accountValidatorFactory = accountValidatorFactory;
-  }
-
-  public void validatePreMerge(
-      Repository repo,
-      CodeReviewCommit commit,
-      ProjectState destProject,
-      Branch.NameKey destBranch,
-      PatchSet.Id patchSetId,
-      IdentifiedUser caller)
-      throws MergeValidationException {
-    List<MergeValidationListener> validators =
-        ImmutableList.of(
-            new PluginMergeValidationListener(mergeValidationListeners),
-            projectConfigValidatorFactory.create(),
-            accountValidatorFactory.create());
-
-    for (MergeValidationListener validator : validators) {
-      validator.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller);
-    }
-  }
-
-  public static class ProjectConfigValidator implements MergeValidationListener {
-    private static final String INVALID_CONFIG =
-        "Change contains an invalid project configuration.";
-    private static final String PARENT_NOT_FOUND =
-        "Change contains an invalid project configuration:\nParent project does not exist.";
-    private static final String PLUGIN_VALUE_NOT_EDITABLE =
-        "Change contains an invalid project configuration:\n"
-            + "One of the plugin configuration parameters is not editable.";
-    private static final String PLUGIN_VALUE_NOT_PERMITTED =
-        "Change contains an invalid project configuration:\n"
-            + "One of the plugin configuration parameters has a value that is not"
-            + " permitted.";
-    private static final String ROOT_NO_PARENT =
-        "Change contains an invalid project configuration:\n"
-            + "The root project cannot have a parent.";
-    private static final String SET_BY_ADMIN =
-        "Change contains a project configuration that changes the parent"
-            + " project.\n"
-            + "The change must be submitted by a Gerrit administrator.";
-
-    private final AllProjectsName allProjectsName;
-    private final ProjectCache projectCache;
-    private final PermissionBackend permissionBackend;
-    private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
-
-    public interface Factory {
-      ProjectConfigValidator create();
-    }
-
-    @Inject
-    public ProjectConfigValidator(
-        AllProjectsName allProjectsName,
-        ProjectCache projectCache,
-        PermissionBackend permissionBackend,
-        DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
-      this.allProjectsName = allProjectsName;
-      this.projectCache = projectCache;
-      this.permissionBackend = permissionBackend;
-      this.pluginConfigEntries = pluginConfigEntries;
-    }
-
-    @Override
-    public void onPreMerge(
-        final Repository repo,
-        final CodeReviewCommit commit,
-        final ProjectState destProject,
-        final Branch.NameKey destBranch,
-        final PatchSet.Id patchSetId,
-        IdentifiedUser caller)
-        throws MergeValidationException {
-      if (RefNames.REFS_CONFIG.equals(destBranch.get())) {
-        final Project.NameKey newParent;
-        try {
-          ProjectConfig cfg = new ProjectConfig(destProject.getNameKey());
-          cfg.load(repo, commit);
-          newParent = cfg.getProject().getParent(allProjectsName);
-          final Project.NameKey oldParent = destProject.getProject().getParent(allProjectsName);
-          if (oldParent == null) {
-            // update of the 'All-Projects' project
-            if (newParent != null) {
-              throw new MergeValidationException(ROOT_NO_PARENT);
-            }
-          } else {
-            if (!oldParent.equals(newParent)) {
-              try {
-                permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
-              } catch (AuthException e) {
-                throw new MergeValidationException(SET_BY_ADMIN);
-              } catch (PermissionBackendException e) {
-                log.warn("Cannot check ADMINISTRATE_SERVER", e);
-                throw new MergeValidationException("validation unavailable");
-              }
-
-              if (projectCache.get(newParent) == null) {
-                throw new MergeValidationException(PARENT_NOT_FOUND);
-              }
-            }
-          }
-
-          for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
-            PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName());
-            ProjectConfigEntry configEntry = e.getProvider().get();
-
-            String value = pluginCfg.getString(e.getExportName());
-            String oldValue =
-                destProject
-                    .getConfig()
-                    .getPluginConfig(e.getPluginName())
-                    .getString(e.getExportName());
-
-            if ((value == null ? oldValue != null : !value.equals(oldValue))
-                && !configEntry.isEditable(destProject)) {
-              throw new MergeValidationException(PLUGIN_VALUE_NOT_EDITABLE);
-            }
-
-            if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
-                && value != null
-                && !configEntry.getPermittedValues().contains(value)) {
-              throw new MergeValidationException(PLUGIN_VALUE_NOT_PERMITTED);
-            }
-          }
-        } catch (ConfigInvalidException | IOException e) {
-          throw new MergeValidationException(INVALID_CONFIG);
-        }
-      }
-    }
-  }
-
-  /** Execute merge validation plug-ins */
-  public static class PluginMergeValidationListener implements MergeValidationListener {
-    private final DynamicSet<MergeValidationListener> mergeValidationListeners;
-
-    public PluginMergeValidationListener(
-        DynamicSet<MergeValidationListener> mergeValidationListeners) {
-      this.mergeValidationListeners = mergeValidationListeners;
-    }
-
-    @Override
-    public void onPreMerge(
-        Repository repo,
-        CodeReviewCommit commit,
-        ProjectState destProject,
-        Branch.NameKey destBranch,
-        PatchSet.Id patchSetId,
-        IdentifiedUser caller)
-        throws MergeValidationException {
-      for (MergeValidationListener validator : mergeValidationListeners) {
-        validator.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller);
-      }
-    }
-  }
-
-  public static class AccountMergeValidator implements MergeValidationListener {
-    public interface Factory {
-      AccountMergeValidator create();
-    }
-
-    private final Provider<ReviewDb> dbProvider;
-    private final AllUsersName allUsersName;
-    private final ChangeData.Factory changeDataFactory;
-    private final AccountValidator accountValidator;
-
-    @Inject
-    public AccountMergeValidator(
-        Provider<ReviewDb> dbProvider,
-        AllUsersName allUsersName,
-        ChangeData.Factory changeDataFactory,
-        AccountValidator accountValidator) {
-      this.dbProvider = dbProvider;
-      this.allUsersName = allUsersName;
-      this.changeDataFactory = changeDataFactory;
-      this.accountValidator = accountValidator;
-    }
-
-    @Override
-    public void onPreMerge(
-        Repository repo,
-        CodeReviewCommit commit,
-        ProjectState destProject,
-        Branch.NameKey destBranch,
-        PatchSet.Id patchSetId,
-        IdentifiedUser caller)
-        throws MergeValidationException {
-      Account.Id accountId = Account.Id.fromRef(destBranch.get());
-      if (!allUsersName.equals(destProject.getNameKey()) || accountId == null) {
-        return;
-      }
-
-      ChangeData cd =
-          changeDataFactory.create(
-              dbProvider.get(), destProject.getProject().getNameKey(), patchSetId.getParentKey());
-      try {
-        if (!cd.currentFilePaths().contains(AccountConfig.ACCOUNT_CONFIG)) {
-          return;
-        }
-      } catch (IOException | OrmException e) {
-        log.error("Cannot validate account update", e);
-        throw new MergeValidationException("account validation unavailable");
-      }
-
-      try (RevWalk rw = new RevWalk(repo)) {
-        List<String> errorMessages = accountValidator.validate(accountId, rw, null, commit);
-        if (!errorMessages.isEmpty()) {
-          throw new MergeValidationException(
-              "invalid account configuration: " + Joiner.on("; ").join(errorMessages));
-        }
-      } catch (IOException e) {
-        log.error("Cannot validate account update", e);
-        throw new MergeValidationException("account validation unavailable");
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
deleted file mode 100644
index 8047a99a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ /dev/null
@@ -1,154 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.google.gerrit.server.git.validators;
-
-import com.google.common.base.Predicate;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.events.RefReceivedEvent;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.validators.ValidationException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.ArrayList;
-import java.util.List;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class RefOperationValidators {
-  private static final GetErrorMessages GET_ERRORS = new GetErrorMessages();
-  private static final Logger LOG = LoggerFactory.getLogger(RefOperationValidators.class);
-
-  public interface Factory {
-    RefOperationValidators create(Project project, IdentifiedUser user, ReceiveCommand cmd);
-  }
-
-  public static ReceiveCommand getCommand(RefUpdate update, ReceiveCommand.Type type) {
-    return new ReceiveCommand(
-        update.getExpectedOldObjectId(), update.getNewObjectId(), update.getName(), type);
-  }
-
-  private final PermissionBackend.WithUser perm;
-  private final AllUsersName allUsersName;
-  private final DynamicSet<RefOperationValidationListener> refOperationValidationListeners;
-  private final RefReceivedEvent event;
-
-  @Inject
-  RefOperationValidators(
-      PermissionBackend permissionBackend,
-      AllUsersName allUsersName,
-      DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
-      @Assisted Project project,
-      @Assisted IdentifiedUser user,
-      @Assisted ReceiveCommand cmd) {
-    this.perm = permissionBackend.user(user);
-    this.allUsersName = allUsersName;
-    this.refOperationValidationListeners = refOperationValidationListeners;
-    event = new RefReceivedEvent();
-    event.command = cmd;
-    event.project = project;
-    event.user = user;
-  }
-
-  public List<ValidationMessage> validateForRefOperation() throws RefOperationValidationException {
-    List<ValidationMessage> messages = new ArrayList<>();
-    boolean withException = false;
-    List<RefOperationValidationListener> listeners = new ArrayList<>();
-    listeners.add(new DisallowCreationAndDeletionOfUserBranches(perm, allUsersName));
-    refOperationValidationListeners.forEach(l -> listeners.add(l));
-    try {
-      for (RefOperationValidationListener listener : listeners) {
-        messages.addAll(listener.onRefOperation(event));
-      }
-    } catch (ValidationException e) {
-      messages.add(new ValidationMessage(e.getMessage(), true));
-      withException = true;
-    }
-
-    if (withException) {
-      throwException(messages, event);
-    }
-
-    return messages;
-  }
-
-  private void throwException(Iterable<ValidationMessage> messages, RefReceivedEvent event)
-      throws RefOperationValidationException {
-    Iterable<ValidationMessage> errors = Iterables.filter(messages, GET_ERRORS);
-    String header =
-        String.format(
-            "Ref \"%s\" %S in project %s validation failed",
-            event.command.getRefName(), event.command.getType(), event.project.getName());
-    LOG.error(header);
-    throw new RefOperationValidationException(header, errors);
-  }
-
-  private static class GetErrorMessages implements Predicate<ValidationMessage> {
-    @Override
-    public boolean apply(ValidationMessage input) {
-      return input.isError();
-    }
-  }
-
-  private static class DisallowCreationAndDeletionOfUserBranches
-      implements RefOperationValidationListener {
-    private final PermissionBackend.WithUser perm;
-    private final AllUsersName allUsersName;
-
-    DisallowCreationAndDeletionOfUserBranches(
-        PermissionBackend.WithUser perm, AllUsersName allUsersName) {
-      this.perm = perm;
-      this.allUsersName = allUsersName;
-    }
-
-    @Override
-    public List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
-        throws ValidationException {
-      if (refEvent.project.getNameKey().equals(allUsersName)
-          && (refEvent.command.getRefName().startsWith(RefNames.REFS_USERS)
-              && !refEvent.command.getRefName().equals(RefNames.REFS_USERS_DEFAULT))) {
-        if (refEvent.command.getType().equals(ReceiveCommand.Type.CREATE)) {
-          try {
-            perm.check(GlobalPermission.ACCESS_DATABASE);
-          } catch (AuthException | PermissionBackendException e) {
-            throw new ValidationException("Not allowed to create user branch.");
-          }
-          if (Account.Id.fromRef(refEvent.command.getRefName()) == null) {
-            throw new ValidationException(
-                String.format(
-                    "Not allowed to create non-user branch under %s.", RefNames.REFS_USERS));
-          }
-        } else if (refEvent.command.getType().equals(ReceiveCommand.Type.DELETE)) {
-          try {
-            perm.check(GlobalPermission.ACCESS_DATABASE);
-          } catch (AuthException | PermissionBackendException e) {
-            throw new ValidationException("Not allowed to delete user branch.");
-          }
-        }
-      }
-      return ImmutableList.of();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
deleted file mode 100644
index b6bcb3b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
+++ /dev/null
@@ -1,251 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountException;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.account.AccountsCollection;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.group.AddMembers.Input;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class AddMembers implements RestModifyView<GroupResource, Input> {
-  public static class Input {
-    @DefaultInput String _oneMember;
-
-    List<String> members;
-
-    public static Input fromMembers(List<String> members) {
-      Input in = new Input();
-      in.members = members;
-      return in;
-    }
-
-    static Input init(Input in) {
-      if (in == null) {
-        in = new Input();
-      }
-      if (in.members == null) {
-        in.members = Lists.newArrayListWithCapacity(1);
-      }
-      if (!Strings.isNullOrEmpty(in._oneMember)) {
-        in.members.add(in._oneMember);
-      }
-      return in;
-    }
-  }
-
-  private final AccountManager accountManager;
-  private final AuthType authType;
-  private final AccountsCollection accounts;
-  private final AccountResolver accountResolver;
-  private final AccountCache accountCache;
-  private final AccountLoader.Factory infoFactory;
-  private final Provider<ReviewDb> db;
-  private final Provider<GroupsUpdate> groupsUpdateProvider;
-
-  @Inject
-  AddMembers(
-      AccountManager accountManager,
-      AuthConfig authConfig,
-      AccountsCollection accounts,
-      AccountResolver accountResolver,
-      AccountCache accountCache,
-      AccountLoader.Factory infoFactory,
-      Provider<ReviewDb> db,
-      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
-    this.accountManager = accountManager;
-    this.authType = authConfig.getAuthType();
-    this.accounts = accounts;
-    this.accountResolver = accountResolver;
-    this.accountCache = accountCache;
-    this.infoFactory = infoFactory;
-    this.db = db;
-    this.groupsUpdateProvider = groupsUpdateProvider;
-  }
-
-  @Override
-  public List<AccountInfo> apply(GroupResource resource, Input input)
-      throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
-          IOException, ConfigInvalidException, ResourceNotFoundException {
-    GroupDescription.Internal internalGroup =
-        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-    input = Input.init(input);
-
-    GroupControl control = resource.getControl();
-    if (!control.canAddMember()) {
-      throw new AuthException("Cannot add members to group " + internalGroup.getName());
-    }
-
-    Set<Account.Id> newMemberIds = new HashSet<>();
-    for (String nameOrEmailOrId : input.members) {
-      Account a = findAccount(nameOrEmailOrId);
-      if (!a.isActive()) {
-        throw new UnprocessableEntityException(
-            String.format("Account Inactive: %s", nameOrEmailOrId));
-      }
-      newMemberIds.add(a.getId());
-    }
-
-    AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
-    try {
-      addMembers(groupUuid, newMemberIds);
-    } catch (NoSuchGroupException e) {
-      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
-    }
-    return toAccountInfoList(newMemberIds);
-  }
-
-  Account findAccount(String nameOrEmailOrId)
-      throws AuthException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException {
-    try {
-      return accounts.parse(nameOrEmailOrId).getAccount();
-    } catch (UnprocessableEntityException e) {
-      // might be because the account does not exist or because the account is
-      // not visible
-      switch (authType) {
-        case HTTP_LDAP:
-        case CLIENT_SSL_CERT_LDAP:
-        case LDAP:
-          if (accountResolver.find(nameOrEmailOrId) == null) {
-            // account does not exist, try to create it
-            Account a = createAccountByLdap(nameOrEmailOrId);
-            if (a != null) {
-              return a;
-            }
-          }
-          break;
-        case CUSTOM_EXTENSION:
-        case DEVELOPMENT_BECOME_ANY_ACCOUNT:
-        case HTTP:
-        case LDAP_BIND:
-        case OAUTH:
-        case OPENID:
-        case OPENID_SSO:
-        default:
-      }
-      throw e;
-    }
-  }
-
-  public void addMembers(AccountGroup.UUID groupUuid, Collection<? extends Account.Id> newMemberIds)
-      throws OrmException, IOException, NoSuchGroupException {
-    groupsUpdateProvider
-        .get()
-        .addGroupMembers(db.get(), groupUuid, ImmutableSet.copyOf(newMemberIds));
-  }
-
-  private Account createAccountByLdap(String user) throws IOException {
-    if (!user.matches(Account.USER_NAME_PATTERN)) {
-      return null;
-    }
-
-    try {
-      AuthRequest req = AuthRequest.forUser(user);
-      req.setSkipAuthentication(true);
-      return accountCache.get(accountManager.authenticate(req).getAccountId()).getAccount();
-    } catch (AccountException e) {
-      return null;
-    }
-  }
-
-  private List<AccountInfo> toAccountInfoList(Set<Account.Id> accountIds) throws OrmException {
-    List<AccountInfo> result = new ArrayList<>();
-    AccountLoader loader = infoFactory.create(true);
-    for (Account.Id accId : accountIds) {
-      result.add(loader.get(accId));
-    }
-    loader.fill();
-    return result;
-  }
-
-  static class PutMember implements RestModifyView<GroupResource, PutMember.Input> {
-    static class Input {}
-
-    private final AddMembers put;
-    private final String id;
-
-    PutMember(AddMembers put, String id) {
-      this.put = put;
-      this.id = id;
-    }
-
-    @Override
-    public AccountInfo apply(GroupResource resource, PutMember.Input input)
-        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
-            IOException, ConfigInvalidException {
-      AddMembers.Input in = new AddMembers.Input();
-      in._oneMember = id;
-      try {
-        List<AccountInfo> list = put.apply(resource, in);
-        if (list.size() == 1) {
-          return list.get(0);
-        }
-        throw new IllegalStateException();
-      } catch (UnprocessableEntityException e) {
-        throw new ResourceNotFoundException(id);
-      }
-    }
-  }
-
-  @Singleton
-  static class UpdateMember implements RestModifyView<MemberResource, PutMember.Input> {
-    private final GetMember get;
-
-    @Inject
-    UpdateMember(GetMember get) {
-      this.get = get;
-    }
-
-    @Override
-    public AccountInfo apply(MemberResource resource, PutMember.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
deleted file mode 100644
index 2ce168f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddSubgroups.java
+++ /dev/null
@@ -1,161 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.group.AddSubgroups.Input;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-@Singleton
-public class AddSubgroups implements RestModifyView<GroupResource, Input> {
-  public static class Input {
-    @DefaultInput String _oneGroup;
-
-    public List<String> groups;
-
-    public static Input fromGroups(List<String> groups) {
-      Input in = new Input();
-      in.groups = groups;
-      return in;
-    }
-
-    static Input init(Input in) {
-      if (in == null) {
-        in = new Input();
-      }
-      if (in.groups == null) {
-        in.groups = Lists.newArrayListWithCapacity(1);
-      }
-      if (!Strings.isNullOrEmpty(in._oneGroup)) {
-        in.groups.add(in._oneGroup);
-      }
-      return in;
-    }
-  }
-
-  private final GroupsCollection groupsCollection;
-  private final Provider<ReviewDb> db;
-  private final Provider<GroupsUpdate> groupsUpdateProvider;
-  private final GroupJson json;
-
-  @Inject
-  public AddSubgroups(
-      GroupsCollection groupsCollection,
-      Provider<ReviewDb> db,
-      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
-      GroupJson json) {
-    this.groupsCollection = groupsCollection;
-    this.db = db;
-    this.groupsUpdateProvider = groupsUpdateProvider;
-    this.json = json;
-  }
-
-  @Override
-  public List<GroupInfo> apply(GroupResource resource, Input input)
-      throws MethodNotAllowedException, AuthException, UnprocessableEntityException, OrmException,
-          ResourceNotFoundException, IOException {
-    GroupDescription.Internal group =
-        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-    input = Input.init(input);
-
-    GroupControl control = resource.getControl();
-    if (!control.canAddGroup()) {
-      throw new AuthException(String.format("Cannot add groups to group %s", group.getName()));
-    }
-
-    List<GroupInfo> result = new ArrayList<>();
-    Set<AccountGroup.UUID> subgroupUuids = new HashSet<>();
-    for (String subgroupIdentifier : input.groups) {
-      GroupDescription.Basic subgroup = groupsCollection.parse(subgroupIdentifier);
-      subgroupUuids.add(subgroup.getGroupUUID());
-      result.add(json.format(subgroup));
-    }
-
-    AccountGroup.UUID groupUuid = group.getGroupUUID();
-    try {
-      groupsUpdateProvider.get().addSubgroups(db.get(), groupUuid, subgroupUuids);
-    } catch (NoSuchGroupException e) {
-      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
-    }
-    return result;
-  }
-
-  static class PutSubgroup implements RestModifyView<GroupResource, PutSubgroup.Input> {
-    static class Input {}
-
-    private final AddSubgroups addSubgroups;
-    private final String id;
-
-    PutSubgroup(AddSubgroups addSubgroups, String id) {
-      this.addSubgroups = addSubgroups;
-      this.id = id;
-    }
-
-    @Override
-    public GroupInfo apply(GroupResource resource, Input input)
-        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
-            IOException {
-      AddSubgroups.Input in = new AddSubgroups.Input();
-      in.groups = ImmutableList.of(id);
-      try {
-        List<GroupInfo> list = addSubgroups.apply(resource, in);
-        if (list.size() == 1) {
-          return list.get(0);
-        }
-        throw new IllegalStateException();
-      } catch (UnprocessableEntityException e) {
-        throw new ResourceNotFoundException(id);
-      }
-    }
-  }
-
-  @Singleton
-  static class UpdateSubgroup implements RestModifyView<SubgroupResource, PutSubgroup.Input> {
-    private final Provider<GetSubgroup> get;
-
-    @Inject
-    UpdateSubgroup(Provider<GetSubgroup> get) {
-      this.get = get;
-    }
-
-    @Override
-    public GroupInfo apply(SubgroupResource resource, PutSubgroup.Input input) throws OrmException {
-      // Do nothing, the group is already included.
-      return get.get().apply(resource);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java
deleted file mode 100644
index e55397e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java
+++ /dev/null
@@ -1,219 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupDescriptions;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.api.groups.GroupInput;
-import com.google.gerrit.extensions.client.ListGroupsOption;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.CreateGroupArgs;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.GroupUUID;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.validators.GroupCreationValidationListener;
-import com.google.gerrit.server.validators.ValidationException;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Locale;
-import java.util.Optional;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-
-@RequiresCapability(GlobalCapability.CREATE_GROUP)
-public class CreateGroup implements RestModifyView<TopLevelResource, GroupInput> {
-  public interface Factory {
-    CreateGroup create(@Assisted String name);
-  }
-
-  private final Provider<IdentifiedUser> self;
-  private final PersonIdent serverIdent;
-  private final ReviewDb db;
-  private final Provider<GroupsUpdate> groupsUpdateProvider;
-  private final GroupCache groupCache;
-  private final GroupsCollection groups;
-  private final GroupJson json;
-  private final DynamicSet<GroupCreationValidationListener> groupCreationValidationListeners;
-  private final AddMembers addMembers;
-  private final SystemGroupBackend systemGroupBackend;
-  private final boolean defaultVisibleToAll;
-  private final String name;
-
-  @Inject
-  CreateGroup(
-      Provider<IdentifiedUser> self,
-      @GerritPersonIdent PersonIdent serverIdent,
-      ReviewDb db,
-      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
-      GroupCache groupCache,
-      GroupsCollection groups,
-      GroupJson json,
-      DynamicSet<GroupCreationValidationListener> groupCreationValidationListeners,
-      AddMembers addMembers,
-      SystemGroupBackend systemGroupBackend,
-      @GerritServerConfig Config cfg,
-      @Assisted String name) {
-    this.self = self;
-    this.serverIdent = serverIdent;
-    this.db = db;
-    this.groupsUpdateProvider = groupsUpdateProvider;
-    this.groupCache = groupCache;
-    this.groups = groups;
-    this.json = json;
-    this.groupCreationValidationListeners = groupCreationValidationListeners;
-    this.addMembers = addMembers;
-    this.systemGroupBackend = systemGroupBackend;
-    this.defaultVisibleToAll = cfg.getBoolean("groups", "newGroupsVisibleToAll", false);
-    this.name = name;
-  }
-
-  public CreateGroup addOption(ListGroupsOption o) {
-    json.addOption(o);
-    return this;
-  }
-
-  public CreateGroup addOptions(Collection<ListGroupsOption> o) {
-    json.addOptions(o);
-    return this;
-  }
-
-  @Override
-  public GroupInfo apply(TopLevelResource resource, GroupInput input)
-      throws AuthException, BadRequestException, UnprocessableEntityException,
-          ResourceConflictException, OrmException, IOException, ConfigInvalidException,
-          ResourceNotFoundException {
-    if (input == null) {
-      input = new GroupInput();
-    }
-    if (input.name != null && !name.equals(input.name)) {
-      throw new BadRequestException("name must match URL");
-    }
-
-    AccountGroup.Id ownerId = owner(input);
-    CreateGroupArgs args = new CreateGroupArgs();
-    args.setGroupName(name);
-    args.groupDescription = Strings.emptyToNull(input.description);
-    args.visibleToAll = MoreObjects.firstNonNull(input.visibleToAll, defaultVisibleToAll);
-    args.ownerGroupId = ownerId;
-    if (input.members != null && !input.members.isEmpty()) {
-      List<Account.Id> members = new ArrayList<>();
-      for (String nameOrEmailOrId : input.members) {
-        Account a = addMembers.findAccount(nameOrEmailOrId);
-        if (!a.isActive()) {
-          throw new UnprocessableEntityException(
-              String.format("Account Inactive: %s", nameOrEmailOrId));
-        }
-        members.add(a.getId());
-      }
-      args.initialMembers = members;
-    } else {
-      args.initialMembers =
-          ownerId == null
-              ? Collections.singleton(self.get().getAccountId())
-              : Collections.<Account.Id>emptySet();
-    }
-
-    for (GroupCreationValidationListener l : groupCreationValidationListeners) {
-      try {
-        l.validateNewGroup(args);
-      } catch (ValidationException e) {
-        throw new ResourceConflictException(e.getMessage(), e);
-      }
-    }
-
-    return json.format(GroupDescriptions.forAccountGroup(createGroup(args)));
-  }
-
-  private AccountGroup.Id owner(GroupInput input) throws UnprocessableEntityException {
-    if (input.ownerId != null) {
-      GroupDescription.Internal d = groups.parseInternal(Url.decode(input.ownerId));
-      return d.getId();
-    }
-    return null;
-  }
-
-  private AccountGroup createGroup(CreateGroupArgs createGroupArgs)
-      throws OrmException, ResourceConflictException, IOException {
-
-    String nameLower = createGroupArgs.getGroupName().toLowerCase(Locale.US);
-
-    for (String name : systemGroupBackend.getNames()) {
-      if (name.toLowerCase(Locale.US).equals(nameLower)) {
-        throw new ResourceConflictException("group '" + name + "' already exists");
-      }
-    }
-
-    for (String name : systemGroupBackend.getReservedNames()) {
-      if (name.toLowerCase(Locale.US).equals(nameLower)) {
-        throw new ResourceConflictException("group name '" + name + "' is reserved");
-      }
-    }
-
-    AccountGroup.Id groupId = new AccountGroup.Id(db.nextAccountGroupId());
-    AccountGroup.UUID uuid =
-        GroupUUID.make(
-            createGroupArgs.getGroupName(),
-            self.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone()));
-    AccountGroup group =
-        new AccountGroup(createGroupArgs.getGroup(), groupId, uuid, TimeUtil.nowTs());
-    group.setVisibleToAll(createGroupArgs.visibleToAll);
-    if (createGroupArgs.ownerGroupId != null) {
-      Optional<InternalGroup> ownerGroup = groupCache.get(createGroupArgs.ownerGroupId);
-      ownerGroup.map(InternalGroup::getGroupUUID).ifPresent(group::setOwnerGroupUUID);
-    }
-    if (createGroupArgs.groupDescription != null) {
-      group.setDescription(createGroupArgs.groupDescription);
-    }
-    try {
-      groupsUpdateProvider
-          .get()
-          .addGroup(db, group, ImmutableSet.copyOf(createGroupArgs.initialMembers));
-    } catch (OrmDuplicateKeyException e) {
-      throw new ResourceConflictException(
-          "group '" + createGroupArgs.getGroupName() + "' already exists");
-    }
-
-    return group;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java
deleted file mode 100644
index 5af7ebd..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java
+++ /dev/null
@@ -1,195 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.common.base.Joiner;
-import com.google.gerrit.audit.GroupMemberAuditListener;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.UniversalGroupBackend;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import java.text.MessageFormat;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import org.slf4j.Logger;
-
-class DbGroupMemberAuditListener implements GroupMemberAuditListener {
-  private static final Logger log =
-      org.slf4j.LoggerFactory.getLogger(DbGroupMemberAuditListener.class);
-
-  private final SchemaFactory<ReviewDb> schema;
-  private final AccountCache accountCache;
-  private final GroupCache groupCache;
-  private final UniversalGroupBackend groupBackend;
-
-  @Inject
-  DbGroupMemberAuditListener(
-      SchemaFactory<ReviewDb> schema,
-      AccountCache accountCache,
-      GroupCache groupCache,
-      UniversalGroupBackend groupBackend) {
-    this.schema = schema;
-    this.accountCache = accountCache;
-    this.groupCache = groupCache;
-    this.groupBackend = groupBackend;
-  }
-
-  @Override
-  public void onAddAccountsToGroup(Account.Id me, Collection<AccountGroupMember> added) {
-    List<AccountGroupMemberAudit> auditInserts = new ArrayList<>();
-    for (AccountGroupMember m : added) {
-      AccountGroupMemberAudit audit = new AccountGroupMemberAudit(m, me, TimeUtil.nowTs());
-      auditInserts.add(audit);
-    }
-    try (ReviewDb db = schema.open()) {
-      db.accountGroupMembersAudit().insert(auditInserts);
-    } catch (OrmException e) {
-      logOrmExceptionForAccounts(
-          "Cannot log add accounts to group event performed by user", me, added, e);
-    }
-  }
-
-  @Override
-  public void onDeleteAccountsFromGroup(Account.Id me, Collection<AccountGroupMember> removed) {
-    List<AccountGroupMemberAudit> auditInserts = new ArrayList<>();
-    List<AccountGroupMemberAudit> auditUpdates = new ArrayList<>();
-    try (ReviewDb db = schema.open()) {
-      for (AccountGroupMember m : removed) {
-        AccountGroupMemberAudit audit = null;
-        for (AccountGroupMemberAudit a :
-            db.accountGroupMembersAudit().byGroupAccount(m.getAccountGroupId(), m.getAccountId())) {
-          if (a.isActive()) {
-            audit = a;
-            break;
-          }
-        }
-
-        if (audit != null) {
-          audit.removed(me, TimeUtil.nowTs());
-          auditUpdates.add(audit);
-        } else {
-          audit = new AccountGroupMemberAudit(m, me, TimeUtil.nowTs());
-          audit.removedLegacy();
-          auditInserts.add(audit);
-        }
-      }
-      db.accountGroupMembersAudit().update(auditUpdates);
-      db.accountGroupMembersAudit().insert(auditInserts);
-    } catch (OrmException e) {
-      logOrmExceptionForAccounts(
-          "Cannot log delete accounts from group event performed by user", me, removed, e);
-    }
-  }
-
-  @Override
-  public void onAddGroupsToGroup(Account.Id me, Collection<AccountGroupById> added) {
-    List<AccountGroupByIdAud> includesAudit = new ArrayList<>();
-    for (AccountGroupById groupInclude : added) {
-      AccountGroupByIdAud audit = new AccountGroupByIdAud(groupInclude, me, TimeUtil.nowTs());
-      includesAudit.add(audit);
-    }
-    try (ReviewDb db = schema.open()) {
-      db.accountGroupByIdAud().insert(includesAudit);
-    } catch (OrmException e) {
-      logOrmExceptionForGroups(
-          "Cannot log add groups to group event performed by user", me, added, e);
-    }
-  }
-
-  @Override
-  public void onDeleteGroupsFromGroup(Account.Id me, Collection<AccountGroupById> removed) {
-    final List<AccountGroupByIdAud> auditUpdates = new ArrayList<>();
-    try (ReviewDb db = schema.open()) {
-      for (AccountGroupById g : removed) {
-        AccountGroupByIdAud audit = null;
-        for (AccountGroupByIdAud a :
-            db.accountGroupByIdAud().byGroupInclude(g.getGroupId(), g.getIncludeUUID())) {
-          if (a.isActive()) {
-            audit = a;
-            break;
-          }
-        }
-
-        if (audit != null) {
-          audit.removed(me, TimeUtil.nowTs());
-          auditUpdates.add(audit);
-        }
-      }
-      db.accountGroupByIdAud().update(auditUpdates);
-    } catch (OrmException e) {
-      logOrmExceptionForGroups(
-          "Cannot log delete groups from group event performed by user", me, removed, e);
-    }
-  }
-
-  private void logOrmExceptionForAccounts(
-      String header, Account.Id me, Collection<AccountGroupMember> values, OrmException e) {
-    List<String> descriptions = new ArrayList<>();
-    for (AccountGroupMember m : values) {
-      Account.Id accountId = m.getAccountId();
-      String userName = accountCache.get(accountId).getUserName();
-      AccountGroup.Id groupId = m.getAccountGroupId();
-      String groupName = getGroupName(groupId);
-
-      descriptions.add(
-          MessageFormat.format(
-              "account {0}/{1}, group {2}/{3}", accountId, userName, groupId, groupName));
-    }
-    logOrmException(header, me, descriptions, e);
-  }
-
-  private void logOrmExceptionForGroups(
-      String header, Account.Id me, Collection<AccountGroupById> values, OrmException e) {
-    List<String> descriptions = new ArrayList<>();
-    for (AccountGroupById m : values) {
-      AccountGroup.UUID groupUuid = m.getIncludeUUID();
-      String groupName = groupBackend.get(groupUuid).getName();
-      AccountGroup.Id targetGroupId = m.getGroupId();
-      String targetGroupName = getGroupName(targetGroupId);
-
-      descriptions.add(
-          MessageFormat.format(
-              "group {0}/{1}, group {2}/{3}",
-              groupUuid, groupName, targetGroupId, targetGroupName));
-    }
-    logOrmException(header, me, descriptions, e);
-  }
-
-  private String getGroupName(AccountGroup.Id groupId) {
-    return groupCache.get(groupId).map(InternalGroup::getName).orElse("Deleted group " + groupId);
-  }
-
-  private void logOrmException(String header, Account.Id me, Iterable<?> values, OrmException e) {
-    StringBuilder message = new StringBuilder(header);
-    message.append(" ");
-    message.append(me);
-    message.append("/");
-    message.append(accountCache.get(me).getUserName());
-    message.append(": ");
-    message.append(Joiner.on("; ").join(values));
-    log.error(message.toString(), e);
-  }
-}
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
deleted file mode 100644
index 1069e1c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.AccountsCollection;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.group.AddMembers.Input;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.HashSet;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class DeleteMembers implements RestModifyView<GroupResource, Input> {
-  private final AccountsCollection accounts;
-  private final Provider<ReviewDb> db;
-  private final Provider<GroupsUpdate> groupsUpdateProvider;
-
-  @Inject
-  DeleteMembers(
-      AccountsCollection accounts,
-      Provider<ReviewDb> db,
-      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
-    this.accounts = accounts;
-    this.db = db;
-    this.groupsUpdateProvider = groupsUpdateProvider;
-  }
-
-  @Override
-  public Response<?> apply(GroupResource resource, Input input)
-      throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
-          IOException, ConfigInvalidException, ResourceNotFoundException {
-    GroupDescription.Internal internalGroup =
-        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-    input = Input.init(input);
-
-    final GroupControl control = resource.getControl();
-    if (!control.canRemoveMember()) {
-      throw new AuthException("Cannot delete members from group " + internalGroup.getName());
-    }
-
-    Set<Account.Id> membersToRemove = new HashSet<>();
-    for (String nameOrEmail : input.members) {
-      Account a = accounts.parse(nameOrEmail).getAccount();
-      membersToRemove.add(a.getId());
-    }
-    AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
-    try {
-      groupsUpdateProvider.get().removeGroupMembers(db.get(), groupUuid, membersToRemove);
-    } catch (NoSuchGroupException e) {
-      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
-    }
-
-    return Response.none();
-  }
-
-  @Singleton
-  static class DeleteMember implements RestModifyView<MemberResource, DeleteMember.Input> {
-    static class Input {}
-
-    private final Provider<DeleteMembers> delete;
-
-    @Inject
-    DeleteMember(Provider<DeleteMembers> delete) {
-      this.delete = delete;
-    }
-
-    @Override
-    public Response<?> apply(MemberResource resource, Input input)
-        throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
-            IOException, ConfigInvalidException, ResourceNotFoundException {
-      AddMembers.Input in = new AddMembers.Input();
-      in._oneMember = resource.getMember().getAccountId().toString();
-      return delete.get().apply(resource, in);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteSubgroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteSubgroups.java
deleted file mode 100644
index 14df51b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteSubgroups.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.group.AddSubgroups.Input;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.HashSet;
-import java.util.Set;
-
-@Singleton
-public class DeleteSubgroups implements RestModifyView<GroupResource, Input> {
-  private final GroupsCollection groupsCollection;
-  private final Provider<ReviewDb> db;
-  private final Provider<GroupsUpdate> groupsUpdateProvider;
-
-  @Inject
-  DeleteSubgroups(
-      GroupsCollection groupsCollection,
-      Provider<ReviewDb> db,
-      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
-    this.groupsCollection = groupsCollection;
-    this.db = db;
-    this.groupsUpdateProvider = groupsUpdateProvider;
-  }
-
-  @Override
-  public Response<?> apply(GroupResource resource, Input input)
-      throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
-          ResourceNotFoundException, IOException {
-    GroupDescription.Internal internalGroup =
-        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-    input = Input.init(input);
-
-    final GroupControl control = resource.getControl();
-    if (!control.canRemoveGroup()) {
-      throw new AuthException(
-          String.format("Cannot delete groups from group %s", internalGroup.getName()));
-    }
-
-    Set<AccountGroup.UUID> subgroupsToRemove = new HashSet<>();
-    for (String subgroupIdentifier : input.groups) {
-      GroupDescription.Basic subgroup = groupsCollection.parse(subgroupIdentifier);
-      subgroupsToRemove.add(subgroup.getGroupUUID());
-    }
-
-    AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
-    try {
-      groupsUpdateProvider.get().removeSubgroups(db.get(), groupUuid, subgroupsToRemove);
-    } catch (NoSuchGroupException e) {
-      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
-    }
-
-    return Response.none();
-  }
-
-  @Singleton
-  static class DeleteSubgroup implements RestModifyView<SubgroupResource, DeleteSubgroup.Input> {
-    static class Input {}
-
-    private final Provider<DeleteSubgroups> delete;
-
-    @Inject
-    DeleteSubgroup(Provider<DeleteSubgroups> delete) {
-      this.delete = delete;
-    }
-
-    @Override
-    public Response<?> apply(SubgroupResource resource, Input input)
-        throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
-            ResourceNotFoundException, IOException {
-      AddSubgroups.Input in = new AddSubgroups.Input();
-      in.groups = ImmutableList.of(resource.getMember().get());
-      return delete.get().apply(resource, in);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java
deleted file mode 100644
index 8c94f65..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java
+++ /dev/null
@@ -1,135 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
-import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Optional;
-
-@Singleton
-public class GetAuditLog implements RestReadView<GroupResource> {
-  private final Provider<ReviewDb> db;
-  private final AccountLoader.Factory accountLoaderFactory;
-  private final GroupCache groupCache;
-  private final GroupJson groupJson;
-  private final GroupBackend groupBackend;
-
-  @Inject
-  public GetAuditLog(
-      Provider<ReviewDb> db,
-      AccountLoader.Factory accountLoaderFactory,
-      GroupCache groupCache,
-      GroupJson groupJson,
-      GroupBackend groupBackend) {
-    this.db = db;
-    this.accountLoaderFactory = accountLoaderFactory;
-    this.groupCache = groupCache;
-    this.groupJson = groupJson;
-    this.groupBackend = groupBackend;
-  }
-
-  @Override
-  public List<? extends GroupAuditEventInfo> apply(GroupResource rsrc)
-      throws AuthException, MethodNotAllowedException, OrmException {
-    GroupDescription.Internal group =
-        rsrc.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-    if (!rsrc.getControl().isOwner()) {
-      throw new AuthException("Not group owner");
-    }
-
-    AccountLoader accountLoader = accountLoaderFactory.create(true);
-
-    List<GroupAuditEventInfo> auditEvents = new ArrayList<>();
-
-    for (AccountGroupMemberAudit auditEvent :
-        db.get().accountGroupMembersAudit().byGroup(group.getId()).toList()) {
-      AccountInfo member = accountLoader.get(auditEvent.getKey().getParentKey());
-
-      auditEvents.add(
-          GroupAuditEventInfo.createAddUserEvent(
-              accountLoader.get(auditEvent.getAddedBy()),
-              auditEvent.getKey().getAddedOn(),
-              member));
-
-      if (!auditEvent.isActive()) {
-        auditEvents.add(
-            GroupAuditEventInfo.createRemoveUserEvent(
-                accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
-      }
-    }
-
-    for (AccountGroupByIdAud auditEvent :
-        db.get().accountGroupByIdAud().byGroup(group.getId()).toList()) {
-      AccountGroup.UUID includedGroupUUID = auditEvent.getKey().getIncludeUUID();
-      Optional<InternalGroup> includedGroup = groupCache.get(includedGroupUUID);
-      GroupInfo member;
-      if (includedGroup.isPresent()) {
-        member = groupJson.format(new InternalGroupDescription(includedGroup.get()));
-      } else {
-        GroupDescription.Basic groupDescription = groupBackend.get(includedGroupUUID);
-        member = new GroupInfo();
-        member.id = Url.encode(includedGroupUUID.get());
-        member.name = groupDescription.getName();
-      }
-
-      auditEvents.add(
-          GroupAuditEventInfo.createAddGroupEvent(
-              accountLoader.get(auditEvent.getAddedBy()),
-              auditEvent.getKey().getAddedOn(),
-              member));
-
-      if (!auditEvent.isActive()) {
-        auditEvents.add(
-            GroupAuditEventInfo.createRemoveGroupEvent(
-                accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
-      }
-    }
-
-    accountLoader.fill();
-
-    // sort by date in reverse order so that the newest audit event comes first
-    Collections.sort(
-        auditEvents,
-        new Comparator<GroupAuditEventInfo>() {
-          @Override
-          public int compare(GroupAuditEventInfo e1, GroupAuditEventInfo e2) {
-            return e2.date.compareTo(e1.date);
-          }
-        });
-
-    return auditEvents;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetDescription.java
deleted file mode 100644
index 0610843..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetDescription.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetDescription implements RestReadView<GroupResource> {
-  @Override
-  public String apply(GroupResource resource) throws MethodNotAllowedException {
-    GroupDescription.Internal group =
-        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-    return Strings.nullToEmpty(group.getDescription());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetDetail.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetDetail.java
deleted file mode 100644
index 47fe319..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetDetail.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.extensions.client.ListGroupsOption;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetDetail implements RestReadView<GroupResource> {
-  private final GroupJson json;
-
-  @Inject
-  GetDetail(GroupJson json) {
-    this.json = json.addOption(ListGroupsOption.MEMBERS).addOption(ListGroupsOption.INCLUDES);
-  }
-
-  @Override
-  public GroupInfo apply(GroupResource rsrc) throws OrmException {
-    return json.format(rsrc);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetGroup.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetGroup.java
deleted file mode 100644
index 03c6d6c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetGroup.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetGroup implements RestReadView<GroupResource> {
-  private final GroupJson json;
-
-  @Inject
-  GetGroup(GroupJson json) {
-    this.json = json;
-  }
-
-  @Override
-  public GroupInfo apply(GroupResource resource) throws OrmException {
-    return json.format(resource.getGroup());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetMember.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetMember.java
deleted file mode 100644
index 9d270ec..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetMember.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetMember implements RestReadView<MemberResource> {
-  private final AccountLoader.Factory infoFactory;
-
-  @Inject
-  GetMember(AccountLoader.Factory infoFactory) {
-    this.infoFactory = infoFactory;
-  }
-
-  @Override
-  public AccountInfo apply(MemberResource rsrc) throws OrmException {
-    AccountLoader loader = infoFactory.create(true);
-    AccountInfo info = loader.get(rsrc.getMember().getAccountId());
-    loader.fill();
-    return info;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetName.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetName.java
deleted file mode 100644
index ce4df2a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetName.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetName implements RestReadView<GroupResource> {
-
-  @Override
-  public String apply(GroupResource resource) {
-    return resource.getName();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetOptions.java
deleted file mode 100644
index 7b55666..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetOptions.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.extensions.common.GroupOptionsInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetOptions implements RestReadView<GroupResource> {
-
-  @Override
-  public GroupOptionsInfo apply(GroupResource resource) {
-    return GroupJson.createOptions(resource.getGroup());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetOwner.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetOwner.java
deleted file mode 100644
index 03d0788..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetOwner.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetOwner implements RestReadView<GroupResource> {
-
-  private final GroupControl.Factory controlFactory;
-  private final GroupJson json;
-
-  @Inject
-  GetOwner(GroupControl.Factory controlFactory, GroupJson json) {
-    this.controlFactory = controlFactory;
-    this.json = json;
-  }
-
-  @Override
-  public GroupInfo apply(GroupResource resource)
-      throws MethodNotAllowedException, ResourceNotFoundException, OrmException {
-    GroupDescription.Internal group =
-        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-    try {
-      GroupControl c = controlFactory.validateFor(group.getOwnerGroupUUID());
-      return json.format(c.getGroup());
-    } catch (NoSuchGroupException e) {
-      throw new ResourceNotFoundException();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetSubgroup.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetSubgroup.java
deleted file mode 100644
index a710188..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetSubgroup.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetSubgroup implements RestReadView<SubgroupResource> {
-  private final GroupJson json;
-
-  @Inject
-  GetSubgroup(GroupJson json) {
-    this.json = json;
-  }
-
-  @Override
-  public GroupInfo apply(SubgroupResource rsrc) throws OrmException {
-    return json.format(rsrc.getMemberDescription());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java
deleted file mode 100644
index 85be5c4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java
+++ /dev/null
@@ -1,140 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import static com.google.gerrit.extensions.client.ListGroupsOption.INCLUDES;
-import static com.google.gerrit.extensions.client.ListGroupsOption.MEMBERS;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.extensions.client.ListGroupsOption;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.common.GroupOptionsInfo;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.util.Collection;
-import java.util.EnumSet;
-
-public class GroupJson {
-  public static GroupOptionsInfo createOptions(GroupDescription.Basic group) {
-    GroupOptionsInfo options = new GroupOptionsInfo();
-    if (isInternalGroup(group) && ((GroupDescription.Internal) group).isVisibleToAll()) {
-      options.visibleToAll = true;
-    }
-    return options;
-  }
-
-  private final GroupBackend groupBackend;
-  private final GroupControl.Factory groupControlFactory;
-  private final Provider<ListMembers> listMembers;
-  private final Provider<ListSubgroups> listSubgroups;
-  private EnumSet<ListGroupsOption> options;
-
-  @Inject
-  GroupJson(
-      GroupBackend groupBackend,
-      GroupControl.Factory groupControlFactory,
-      Provider<ListMembers> listMembers,
-      Provider<ListSubgroups> listSubgroups) {
-    this.groupBackend = groupBackend;
-    this.groupControlFactory = groupControlFactory;
-    this.listMembers = listMembers;
-    this.listSubgroups = listSubgroups;
-
-    options = EnumSet.noneOf(ListGroupsOption.class);
-  }
-
-  public GroupJson addOption(ListGroupsOption o) {
-    options.add(o);
-    return this;
-  }
-
-  public GroupJson addOptions(Collection<ListGroupsOption> o) {
-    options.addAll(o);
-    return this;
-  }
-
-  public GroupInfo format(GroupResource rsrc) throws OrmException {
-    GroupInfo info = init(rsrc.getGroup());
-    initMembersAndSubgroups(rsrc, info);
-    return info;
-  }
-
-  public GroupInfo format(GroupDescription.Basic group) throws OrmException {
-    GroupInfo info = init(group);
-    if (options.contains(MEMBERS) || options.contains(INCLUDES)) {
-      GroupResource rsrc = new GroupResource(groupControlFactory.controlFor(group));
-      initMembersAndSubgroups(rsrc, info);
-    }
-    return info;
-  }
-
-  private GroupInfo init(GroupDescription.Basic group) {
-    GroupInfo info = new GroupInfo();
-    info.id = Url.encode(group.getGroupUUID().get());
-    info.name = Strings.emptyToNull(group.getName());
-    info.url = Strings.emptyToNull(group.getUrl());
-    info.options = createOptions(group);
-
-    if (isInternalGroup(group)) {
-      GroupDescription.Internal internalGroup = (GroupDescription.Internal) group;
-      info.description = Strings.emptyToNull(internalGroup.getDescription());
-      info.groupId = internalGroup.getId().get();
-      AccountGroup.UUID ownerGroupUUID = internalGroup.getOwnerGroupUUID();
-      if (ownerGroupUUID != null) {
-        info.ownerId = Url.encode(ownerGroupUUID.get());
-        GroupDescription.Basic o = groupBackend.get(ownerGroupUUID);
-        if (o != null) {
-          info.owner = o.getName();
-        }
-      }
-      info.createdOn = internalGroup.getCreatedOn();
-    }
-
-    return info;
-  }
-
-  private static boolean isInternalGroup(GroupDescription.Basic group) {
-    return group instanceof GroupDescription.Internal;
-  }
-
-  private GroupInfo initMembersAndSubgroups(GroupResource rsrc, GroupInfo info)
-      throws OrmException {
-    if (!rsrc.isInternalGroup()) {
-      return info;
-    }
-    try {
-      if (options.contains(MEMBERS)) {
-        info.members = listMembers.get().apply(rsrc);
-      }
-
-      if (options.contains(INCLUDES)) {
-        info.includes = listSubgroups.get().apply(rsrc);
-      }
-      return info;
-    } catch (MethodNotAllowedException e) {
-      // should never happen, this exception is only thrown if we would try to
-      // list members/includes of an external group, but in case of an external
-      // group we return before
-      throw new IllegalStateException(e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupModule.java
deleted file mode 100644
index 5939fd6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupModule.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import static com.google.inject.Scopes.SINGLETON;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.IncludingGroupMembership;
-import com.google.gerrit.server.account.InternalGroupBackend;
-import com.google.gerrit.server.account.UniversalGroupBackend;
-
-public class GroupModule extends FactoryModule {
-
-  @Override
-  protected void configure() {
-    factory(InternalUser.Factory.class);
-    factory(IncludingGroupMembership.Factory.class);
-
-    bind(GroupBackend.class).to(UniversalGroupBackend.class).in(SINGLETON);
-    DynamicSet.setOf(binder(), GroupBackend.class);
-
-    bind(InternalGroupBackend.class).in(SINGLETON);
-    DynamicSet.bind(binder(), GroupBackend.class).to(SystemGroupBackend.class);
-    DynamicSet.bind(binder(), GroupBackend.class).to(InternalGroupBackend.class);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupResource.java
deleted file mode 100644
index 44e770f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupResource.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.inject.TypeLiteral;
-import java.util.Optional;
-
-public class GroupResource implements RestResource {
-  public static final TypeLiteral<RestView<GroupResource>> GROUP_KIND =
-      new TypeLiteral<RestView<GroupResource>>() {};
-
-  private final GroupControl control;
-
-  public GroupResource(GroupControl control) {
-    this.control = control;
-  }
-
-  GroupResource(GroupResource rsrc) {
-    this.control = rsrc.getControl();
-  }
-
-  public GroupDescription.Basic getGroup() {
-    return control.getGroup();
-  }
-
-  public String getName() {
-    return getGroup().getName();
-  }
-
-  public boolean isInternalGroup() {
-    GroupDescription.Basic group = getGroup();
-    return group instanceof GroupDescription.Internal;
-  }
-
-  public Optional<GroupDescription.Internal> asInternalGroup() {
-    GroupDescription.Basic group = getGroup();
-    if (group instanceof GroupDescription.Internal) {
-      return Optional.of((GroupDescription.Internal) group);
-    }
-    return Optional.empty();
-  }
-
-  public GroupControl getControl() {
-    return control;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/Groups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/Groups.java
deleted file mode 100644
index 233f36b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/Groups.java
+++ /dev/null
@@ -1,271 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Streams;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Singleton;
-import java.util.List;
-import java.util.Optional;
-import java.util.stream.Stream;
-
-/**
- * A database accessor for read calls related to groups.
- *
- * <p>All calls which read group related details from the database (either ReviewDb or NoteDb) are
- * gathered here. Other classes should always use this class instead of accessing the database
- * directly. There are a few exceptions though: schema classes, wrapper classes, and classes
- * executed during init. The latter ones should use {@code GroupsOnInit} instead.
- *
- * <p>If not explicitly stated, all methods of this class refer to <em>internal</em> groups.
- */
-@Singleton
-public class Groups {
-
-  /**
-   * Returns the {@code AccountGroup} for the specified ID if it exists.
-   *
-   * @param db the {@code ReviewDb} instance to use for lookups
-   * @param groupId the ID of the group
-   * @return the found {@code AccountGroup} if it exists, or else an empty {@code Optional}
-   * @throws OrmException if the group couldn't be retrieved from ReviewDb
-   */
-  public Optional<InternalGroup> getGroup(ReviewDb db, AccountGroup.Id groupId)
-      throws OrmException, NoSuchGroupException {
-    Optional<AccountGroup> accountGroup = Optional.ofNullable(db.accountGroups().get(groupId));
-
-    if (!accountGroup.isPresent()) {
-      return Optional.empty();
-    }
-
-    AccountGroup.UUID groupUuid = accountGroup.get().getGroupUUID();
-    ImmutableSet<Account.Id> members = getMembers(db, groupUuid).collect(toImmutableSet());
-    ImmutableSet<AccountGroup.UUID> subgroups =
-        getSubgroups(db, groupUuid).collect(toImmutableSet());
-    return accountGroup.map(group -> InternalGroup.create(group, members, subgroups));
-  }
-
-  /**
-   * Returns the {@code InternalGroup} for the specified UUID if it exists.
-   *
-   * @param db the {@code ReviewDb} instance to use for lookups
-   * @param groupUuid the UUID of the group
-   * @return the found {@code InternalGroup} if it exists, or else an empty {@code Optional}
-   * @throws OrmDuplicateKeyException if multiple groups are found for the specified UUID
-   * @throws OrmException if the group couldn't be retrieved from ReviewDb
-   */
-  public Optional<InternalGroup> getGroup(ReviewDb db, AccountGroup.UUID groupUuid)
-      throws OrmException, NoSuchGroupException {
-    Optional<AccountGroup> accountGroup = getGroupFromReviewDb(db, groupUuid);
-
-    if (!accountGroup.isPresent()) {
-      return Optional.empty();
-    }
-
-    ImmutableSet<Account.Id> members = getMembers(db, groupUuid).collect(toImmutableSet());
-    ImmutableSet<AccountGroup.UUID> subgroups =
-        getSubgroups(db, groupUuid).collect(toImmutableSet());
-    return accountGroup.map(group -> InternalGroup.create(group, members, subgroups));
-  }
-
-  /**
-   * Returns the {@code AccountGroup} for the specified UUID.
-   *
-   * @param db the {@code ReviewDb} instance to use for lookups
-   * @param groupUuid the UUID of the group
-   * @return the {@code AccountGroup} which has the specified UUID
-   * @throws OrmDuplicateKeyException if multiple groups are found for the specified UUID
-   * @throws OrmException if the group couldn't be retrieved from ReviewDb
-   * @throws NoSuchGroupException if a group with such a UUID doesn't exist
-   */
-  static AccountGroup getExistingGroupFromReviewDb(ReviewDb db, AccountGroup.UUID groupUuid)
-      throws OrmException, NoSuchGroupException {
-    Optional<AccountGroup> group = getGroupFromReviewDb(db, groupUuid);
-    return group.orElseThrow(() -> new NoSuchGroupException(groupUuid));
-  }
-
-  /**
-   * Returns the {@code AccountGroup} for the specified UUID if it exists.
-   *
-   * @param db the {@code ReviewDb} instance to use for lookups
-   * @param groupUuid the UUID of the group
-   * @return the found {@code AccountGroup} if it exists, or else an empty {@code Optional}
-   * @throws OrmDuplicateKeyException if multiple groups are found for the specified UUID
-   * @throws OrmException if the group couldn't be retrieved from ReviewDb
-   */
-  private static Optional<AccountGroup> getGroupFromReviewDb(
-      ReviewDb db, AccountGroup.UUID groupUuid) throws OrmException {
-    List<AccountGroup> accountGroups = db.accountGroups().byUUID(groupUuid).toList();
-    if (accountGroups.size() == 1) {
-      return Optional.of(Iterables.getOnlyElement(accountGroups));
-    } else if (accountGroups.isEmpty()) {
-      return Optional.empty();
-    } else {
-      throw new OrmDuplicateKeyException("Duplicate group UUID " + groupUuid);
-    }
-  }
-
-  public Stream<AccountGroup> getAll(ReviewDb db) throws OrmException {
-    return Streams.stream(db.accountGroups().all());
-  }
-
-  /**
-   * Indicates whether the specified account is a member of the specified group.
-   *
-   * <p><strong>Note</strong>: This method doesn't check whether the account exists!
-   *
-   * @param db the {@code ReviewDb} instance to use for lookups
-   * @param groupUuid the UUID of the group
-   * @param accountId the ID of the account
-   * @return {@code true} if the account is a member of the group, or else {@code false}
-   * @throws OrmException if an error occurs while reading from ReviewDb
-   * @throws NoSuchGroupException if the specified group doesn't exist
-   */
-  public boolean isMember(ReviewDb db, AccountGroup.UUID groupUuid, Account.Id accountId)
-      throws OrmException, NoSuchGroupException {
-    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
-    AccountGroupMember.Key key = new AccountGroupMember.Key(accountId, group.getId());
-    return db.accountGroupMembers().get(key) != null;
-  }
-
-  /**
-   * Indicates whether the specified group is a subgroup of the specified parent group.
-   *
-   * <p>The parent group must be an internal group whereas the subgroup may either be an internal or
-   * an external group.
-   *
-   * <p><strong>Note</strong>: This method doesn't check whether the subgroup exists!
-   *
-   * @param db the {@code ReviewDb} instance to use for lookups
-   * @param parentGroupUuid the UUID of the parent group
-   * @param subgroupUuid the UUID of the subgroup
-   * @return {@code true} if the group is a subgroup of the other group, or else {@code false}
-   * @throws OrmException if an error occurs while reading from ReviewDb
-   * @throws NoSuchGroupException if the specified parent group doesn't exist
-   */
-  public boolean isSubgroup(
-      ReviewDb db, AccountGroup.UUID parentGroupUuid, AccountGroup.UUID subgroupUuid)
-      throws OrmException, NoSuchGroupException {
-    AccountGroup parentGroup = getExistingGroupFromReviewDb(db, parentGroupUuid);
-    AccountGroupById.Key key = new AccountGroupById.Key(parentGroup.getId(), subgroupUuid);
-    return db.accountGroupById().get(key) != null;
-  }
-
-  /**
-   * Returns the members (accounts) of a group.
-   *
-   * <p><strong>Note</strong>: This method doesn't check whether the accounts exist!
-   *
-   * @param db the {@code ReviewDb} instance to use for lookups
-   * @param groupUuid the UUID of the group
-   * @return a stream of the IDs of the members
-   * @throws OrmException if an error occurs while reading from ReviewDb
-   * @throws NoSuchGroupException if the specified group doesn't exist
-   */
-  public Stream<Account.Id> getMembers(ReviewDb db, AccountGroup.UUID groupUuid)
-      throws OrmException, NoSuchGroupException {
-    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
-    ResultSet<AccountGroupMember> accountGroupMembers =
-        db.accountGroupMembers().byGroup(group.getId());
-    return Streams.stream(accountGroupMembers).map(AccountGroupMember::getAccountId);
-  }
-
-  /**
-   * Returns the subgroups of a group.
-   *
-   * <p>This parent group must be an internal group whereas the subgroups can either be internal or
-   * external groups.
-   *
-   * <p><strong>Note</strong>: This method doesn't check whether the subgroups exist!
-   *
-   * @param db the {@code ReviewDb} instance to use for lookups
-   * @param groupUuid the UUID of the parent group
-   * @return a stream of the UUIDs of the subgroups
-   * @throws OrmException if an error occurs while reading from ReviewDb
-   * @throws NoSuchGroupException if the specified parent group doesn't exist
-   */
-  public Stream<AccountGroup.UUID> getSubgroups(ReviewDb db, AccountGroup.UUID groupUuid)
-      throws OrmException, NoSuchGroupException {
-    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
-    ResultSet<AccountGroupById> accountGroupByIds = db.accountGroupById().byGroup(group.getId());
-    return Streams.stream(accountGroupByIds).map(AccountGroupById::getIncludeUUID).distinct();
-  }
-
-  /**
-   * Returns the groups of which the specified account is a member.
-   *
-   * <p><strong>Note</strong>: This method returns an empty stream if the account doesn't exist.
-   * This method doesn't check whether the groups exist.
-   *
-   * @param db the {@code ReviewDb} instance to use for lookups
-   * @param accountId the ID of the account
-   * @return a stream of the IDs of the groups of which the account is a member
-   * @throws OrmException if an error occurs while reading from ReviewDb
-   */
-  public static Stream<AccountGroup.Id> getGroupsWithMemberFromReviewDb(
-      ReviewDb db, Account.Id accountId) throws OrmException {
-    ResultSet<AccountGroupMember> accountGroupMembers =
-        db.accountGroupMembers().byAccount(accountId);
-    return Streams.stream(accountGroupMembers).map(AccountGroupMember::getAccountGroupId);
-  }
-
-  /**
-   * Returns the parent groups of the specified (sub)group.
-   *
-   * <p>The subgroup may either be an internal or an external group whereas the returned parent
-   * groups represent only internal groups.
-   *
-   * <p><strong>Note</strong>: This method returns an empty stream if the specified group doesn't
-   * exist. This method doesn't check whether the parent groups exist.
-   *
-   * @param db the {@code ReviewDb} instance to use for lookups
-   * @param subgroupUuid the UUID of the subgroup
-   * @return a stream of the IDs of the parent groups
-   * @throws OrmException if an error occurs while reading from ReviewDb
-   */
-  public static Stream<AccountGroup.Id> getParentGroupsFromReviewDb(
-      ReviewDb db, AccountGroup.UUID subgroupUuid) throws OrmException {
-    ResultSet<AccountGroupById> accountGroupByIds =
-        db.accountGroupById().byIncludeUUID(subgroupUuid);
-    return Streams.stream(accountGroupByIds).map(AccountGroupById::getGroupId);
-  }
-
-  /**
-   * Returns all known external groups. External groups are 'known' when they are specified as a
-   * subgroup of an internal group.
-   *
-   * @param db the {@code ReviewDb} instance to use for lookups
-   * @return a stream of the UUIDs of the known external groups
-   * @throws OrmException if an error occurs while reading from ReviewDb
-   */
-  public Stream<AccountGroup.UUID> getExternalGroups(ReviewDb db) throws OrmException {
-    return Streams.stream(db.accountGroupById().all())
-        .map(AccountGroupById::getIncludeUUID)
-        .distinct()
-        .filter(groupUuid -> !AccountGroup.isInternalGroup(groupUuid));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java
deleted file mode 100644
index 4d3bd11..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java
+++ /dev/null
@@ -1,200 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.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;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.AnonymousUser;
-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.account.GroupControl;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class GroupsCollection
-    implements RestCollection<TopLevelResource, GroupResource>,
-        AcceptsCreate<TopLevelResource>,
-        NeedsParams {
-  private final DynamicMap<RestView<GroupResource>> views;
-  private final Provider<ListGroups> list;
-  private final Provider<QueryGroups> queryGroups;
-  private final CreateGroup.Factory createGroup;
-  private final GroupControl.Factory groupControlFactory;
-  private final GroupBackend groupBackend;
-  private final Provider<CurrentUser> self;
-
-  private boolean hasQuery2;
-
-  @Inject
-  GroupsCollection(
-      DynamicMap<RestView<GroupResource>> views,
-      Provider<ListGroups> list,
-      Provider<QueryGroups> queryGroups,
-      CreateGroup.Factory createGroup,
-      GroupControl.Factory groupControlFactory,
-      GroupBackend groupBackend,
-      Provider<CurrentUser> self) {
-    this.views = views;
-    this.list = list;
-    this.queryGroups = queryGroups;
-    this.createGroup = createGroup;
-    this.groupControlFactory = groupControlFactory;
-    this.groupBackend = groupBackend;
-    this.self = self;
-  }
-
-  @Override
-  public void setParams(ListMultimap<String, String> params) throws BadRequestException {
-    if (params.containsKey("query") && params.containsKey("query2")) {
-      throw new BadRequestException("\"query\" and \"query2\" options are mutually exclusive");
-    }
-
-    // The --query2 option is defined in QueryGroups
-    this.hasQuery2 = params.containsKey("query2");
-  }
-
-  @Override
-  public RestView<TopLevelResource> list() throws ResourceNotFoundException, AuthException {
-    final CurrentUser user = self.get();
-    if (user instanceof AnonymousUser) {
-      throw new AuthException("Authentication required");
-    } else if (!(user.isIdentifiedUser())) {
-      throw new ResourceNotFoundException();
-    }
-
-    if (hasQuery2) {
-      return queryGroups.get();
-    }
-
-    return list.get();
-  }
-
-  @Override
-  public GroupResource parse(TopLevelResource parent, IdString id)
-      throws AuthException, ResourceNotFoundException {
-    final CurrentUser user = self.get();
-    if (user instanceof AnonymousUser) {
-      throw new AuthException("Authentication required");
-    } else if (!(user.isIdentifiedUser())) {
-      throw new ResourceNotFoundException(id);
-    }
-
-    GroupDescription.Basic group = parseId(id.get());
-    if (group == null) {
-      throw new ResourceNotFoundException(id.get());
-    }
-    GroupControl ctl = groupControlFactory.controlFor(group);
-    if (!ctl.isVisible()) {
-      throw new ResourceNotFoundException(id);
-    }
-    return new GroupResource(ctl);
-  }
-
-  /**
-   * Parses a group ID from a request body and returns the group.
-   *
-   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
-   * @return the group
-   * @throws UnprocessableEntityException thrown if the group ID cannot be resolved or if the group
-   *     is not visible to the calling user
-   */
-  public GroupDescription.Basic parse(String id) throws UnprocessableEntityException {
-    GroupDescription.Basic group = parseId(id);
-    if (group == null || !groupControlFactory.controlFor(group).isVisible()) {
-      throw new UnprocessableEntityException(String.format("Group Not Found: %s", id));
-    }
-    return group;
-  }
-
-  /**
-   * Parses a group ID from a request body and returns the group if it is a Gerrit internal group.
-   *
-   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
-   * @return the group
-   * @throws UnprocessableEntityException thrown if the group ID cannot be resolved, if the group is
-   *     not visible to the calling user or if it's an external group
-   */
-  public GroupDescription.Internal parseInternal(String id) throws UnprocessableEntityException {
-    GroupDescription.Basic group = parse(id);
-    if (group instanceof GroupDescription.Internal) {
-      return (GroupDescription.Internal) group;
-    }
-
-    throw new UnprocessableEntityException(String.format("External Group Not Allowed: %s", id));
-  }
-
-  /**
-   * Parses a group ID and returns the group without making any permission check whether the current
-   * user can see the group.
-   *
-   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
-   * @return the group, null if no group is found for the given group ID
-   */
-  public GroupDescription.Basic parseId(String id) {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(id);
-    if (groupBackend.handles(uuid)) {
-      GroupDescription.Basic d = groupBackend.get(uuid);
-      if (d != null) {
-        return d;
-      }
-    }
-
-    // Might be a legacy AccountGroup.Id.
-    if (id.matches("^[1-9][0-9]*$")) {
-      try {
-        AccountGroup.Id legacyId = AccountGroup.Id.parse(id);
-        return groupControlFactory.controlFor(legacyId).getGroup();
-      } catch (IllegalArgumentException | NoSuchGroupException e) {
-        // Ignored
-      }
-    }
-
-    // Might be a group name, be nice and accept unique names.
-    GroupReference ref = GroupBackends.findExactSuggestion(groupBackend, id);
-    if (ref != null) {
-      GroupDescription.Basic d = groupBackend.get(ref.getUUID());
-      if (d != null) {
-        return d;
-      }
-    }
-
-    return null;
-  }
-
-  @Override
-  public CreateGroup create(TopLevelResource root, IdString name) {
-    return createGroup.create(name.get());
-  }
-
-  @Override
-  public DynamicMap<RestView<GroupResource>> views() {
-    return views;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsUpdate.java
deleted file mode 100644
index 736eeec..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsUpdate.java
+++ /dev/null
@@ -1,418 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.server.group.Groups.getExistingGroupFromReviewDb;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.audit.AuditService;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.errors.NameAlreadyUsedException;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gerrit.reviewdb.client.AccountGroupName;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.GroupIncludeCache;
-import com.google.gerrit.server.git.RenameGroupOp;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import java.util.function.Consumer;
-import org.eclipse.jgit.lib.PersonIdent;
-
-/**
- * A database accessor for write calls related to groups.
- *
- * <p>All calls which write group related details to the database (either ReviewDb or NoteDb) are
- * gathered here. Other classes should always use this class instead of accessing the database
- * directly. There are a few exceptions though: schema classes, wrapper classes, and classes
- * executed during init. The latter ones should use {@code GroupsOnInit} instead.
- *
- * <p>If not explicitly stated, all methods of this class refer to <em>internal</em> groups.
- */
-public class GroupsUpdate {
-  public interface Factory {
-    /**
-     * Creates a {@code GroupsUpdate} which uses the identity of the specified user to mark database
-     * modifications executed by it. For NoteDb, this identity is used as author and committer for
-     * all related commits.
-     *
-     * <p><strong>Note</strong>: Please use this method with care and rather consider to use the
-     * correct annotation on the provider of a {@code GroupsUpdate} instead.
-     *
-     * @param currentUser the user to which modifications should be attributed, or {@code null} if
-     *     the Gerrit server identity should be used
-     */
-    GroupsUpdate create(@Nullable IdentifiedUser currentUser);
-  }
-
-  private final Groups groups;
-  private final GroupCache groupCache;
-  private final GroupIncludeCache groupIncludeCache;
-  private final AuditService auditService;
-  private final AccountCache accountCache;
-  private final RenameGroupOp.Factory renameGroupOpFactory;
-  @Nullable private final IdentifiedUser currentUser;
-  private final PersonIdent committerIdent;
-
-  @Inject
-  GroupsUpdate(
-      Groups groups,
-      GroupCache groupCache,
-      GroupIncludeCache groupIncludeCache,
-      AuditService auditService,
-      AccountCache accountCache,
-      RenameGroupOp.Factory renameGroupOpFactory,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @Assisted @Nullable IdentifiedUser currentUser) {
-    this.groups = groups;
-    this.groupCache = groupCache;
-    this.groupIncludeCache = groupIncludeCache;
-    this.auditService = auditService;
-    this.accountCache = accountCache;
-    this.renameGroupOpFactory = renameGroupOpFactory;
-    this.currentUser = currentUser;
-    committerIdent = getCommitterIdent(serverIdent, currentUser);
-  }
-
-  private static PersonIdent getCommitterIdent(
-      PersonIdent serverIdent, @Nullable IdentifiedUser currentUser) {
-    return currentUser != null ? createPersonIdent(serverIdent, currentUser) : serverIdent;
-  }
-
-  private static PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
-    return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
-  }
-
-  /**
-   * Adds/Creates the specified group for the specified members (accounts).
-   *
-   * @param db the {@code ReviewDb} instance to update
-   * @param group the group to add
-   * @param memberIds the IDs of the accounts which should be members of the created group
-   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
-   * @throws IOException if the cache entry of one of the new members couldn't be invalidated, or
-   *     the new group couldn't be indexed
-   */
-  public void addGroup(ReviewDb db, AccountGroup group, Set<Account.Id> memberIds)
-      throws OrmException, IOException {
-    addNewGroup(db, group);
-    addNewGroupMembers(db, group, memberIds);
-    groupCache.onCreateGroup(group);
-  }
-
-  /**
-   * Adds the specified group.
-   *
-   * <p><strong>Note</strong>: This method doesn't update the index! It just adds the group to the
-   * database. Use this method with care.
-   *
-   * @param db the {@code ReviewDb} instance to update
-   * @param group the group to add
-   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
-   */
-  public static void addNewGroup(ReviewDb db, AccountGroup group) throws OrmException {
-    AccountGroupName gn = new AccountGroupName(group);
-    // first insert the group name to validate that the group name hasn't
-    // already been used to create another group
-    db.accountGroupNames().insert(ImmutableList.of(gn));
-    db.accountGroups().insert(ImmutableList.of(group));
-  }
-
-  /**
-   * Updates the specified group.
-   *
-   * @param db the {@code ReviewDb} instance to update
-   * @param groupUuid the UUID of the group to update
-   * @param groupConsumer a {@code Consumer} which performs the desired updates on the group
-   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
-   * @throws IOException if the cache entry for the group couldn't be invalidated
-   * @throws NoSuchGroupException if the specified group doesn't exist
-   */
-  public void updateGroup(
-      ReviewDb db, AccountGroup.UUID groupUuid, Consumer<AccountGroup> groupConsumer)
-      throws OrmException, IOException, NoSuchGroupException {
-    AccountGroup updatedGroup = updateGroupInDb(db, groupUuid, groupConsumer);
-    groupCache.evict(updatedGroup.getGroupUUID(), updatedGroup.getId(), updatedGroup.getNameKey());
-  }
-
-  @VisibleForTesting
-  public AccountGroup updateGroupInDb(
-      ReviewDb db, AccountGroup.UUID groupUuid, Consumer<AccountGroup> groupConsumer)
-      throws OrmException, NoSuchGroupException {
-    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
-    groupConsumer.accept(group);
-    db.accountGroups().update(ImmutableList.of(group));
-    return group;
-  }
-
-  /**
-   * Renames the specified group.
-   *
-   * @param db the {@code ReviewDb} instance to update
-   * @param groupUuid the UUID of the group to rename
-   * @param newName the new name of the group
-   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
-   * @throws IOException if the cache entry for the group couldn't be invalidated
-   * @throws NoSuchGroupException if the specified group doesn't exist
-   * @throws NameAlreadyUsedException if another group has the name {@code newName}
-   */
-  public void renameGroup(ReviewDb db, AccountGroup.UUID groupUuid, AccountGroup.NameKey newName)
-      throws OrmException, IOException, NameAlreadyUsedException, NoSuchGroupException {
-    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
-    AccountGroup.NameKey oldName = group.getNameKey();
-
-    try {
-      AccountGroupName id = new AccountGroupName(newName, group.getId());
-      db.accountGroupNames().insert(ImmutableList.of(id));
-    } catch (OrmException e) {
-      AccountGroupName other = db.accountGroupNames().get(newName);
-      if (other != null) {
-        // If we are using this identity, don't report the exception.
-        if (other.getId().equals(group.getId())) {
-          return;
-        }
-
-        // Otherwise, someone else has this identity.
-        throw new NameAlreadyUsedException("group with name " + newName + " already exists");
-      }
-      throw e;
-    }
-
-    group.setNameKey(newName);
-    db.accountGroups().update(ImmutableList.of(group));
-
-    db.accountGroupNames().deleteKeys(ImmutableList.of(oldName));
-
-    groupCache.evictAfterRename(oldName);
-    groupCache.evict(group.getGroupUUID(), group.getId(), group.getNameKey());
-
-    @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError =
-        renameGroupOpFactory
-            .create(committerIdent, groupUuid, oldName.get(), newName.get())
-            .start(0, TimeUnit.MILLISECONDS);
-  }
-
-  /**
-   * Adds an account as member to a group. The account is only added as a new member if it isn't
-   * already a member of the group.
-   *
-   * <p><strong>Note</strong>: This method doesn't check whether the account exists!
-   *
-   * @param db the {@code ReviewDb} instance to update
-   * @param groupUuid the UUID of the group
-   * @param accountId the ID of the account to add
-   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
-   * @throws IOException if the cache entry of the new member couldn't be invalidated
-   * @throws NoSuchGroupException if the specified group doesn't exist
-   */
-  public void addGroupMember(ReviewDb db, AccountGroup.UUID groupUuid, Account.Id accountId)
-      throws OrmException, IOException, NoSuchGroupException {
-    addGroupMembers(db, groupUuid, ImmutableSet.of(accountId));
-  }
-
-  /**
-   * Adds several accounts as members to a group. Only accounts which currently aren't members of
-   * the group are added.
-   *
-   * <p><strong>Note</strong>: This method doesn't check whether the accounts exist!
-   *
-   * @param db the {@code ReviewDb} instance to update
-   * @param groupUuid the UUID of the group
-   * @param accountIds a set of IDs of accounts to add
-   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
-   * @throws IOException if the group or one of the new members couldn't be indexed
-   * @throws NoSuchGroupException if the specified group doesn't exist
-   */
-  public void addGroupMembers(ReviewDb db, AccountGroup.UUID groupUuid, Set<Account.Id> accountIds)
-      throws OrmException, IOException, NoSuchGroupException {
-    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
-    Set<Account.Id> newMemberIds = new HashSet<>();
-    for (Account.Id accountId : accountIds) {
-      boolean isMember = groups.isMember(db, groupUuid, accountId);
-      if (!isMember) {
-        newMemberIds.add(accountId);
-      }
-    }
-
-    if (newMemberIds.isEmpty()) {
-      return;
-    }
-
-    addNewGroupMembers(db, group, newMemberIds);
-  }
-
-  private void addNewGroupMembers(ReviewDb db, AccountGroup group, Set<Account.Id> newMemberIds)
-      throws OrmException, IOException {
-    Set<AccountGroupMember> newMembers =
-        newMemberIds
-            .stream()
-            .map(accountId -> new AccountGroupMember.Key(accountId, group.getId()))
-            .map(AccountGroupMember::new)
-            .collect(toImmutableSet());
-
-    if (currentUser != null) {
-      auditService.dispatchAddAccountsToGroup(currentUser.getAccountId(), newMembers);
-    }
-    db.accountGroupMembers().insert(newMembers);
-    groupCache.evict(group.getGroupUUID(), group.getId(), group.getNameKey());
-    for (AccountGroupMember newMember : newMembers) {
-      accountCache.evict(newMember.getAccountId());
-    }
-  }
-
-  /**
-   * Removes several members (accounts) from a group. Only accounts which currently are members of
-   * the group are removed.
-   *
-   * @param db the {@code ReviewDb} instance to update
-   * @param groupUuid the UUID of the group
-   * @param accountIds a set of IDs of accounts to remove
-   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
-   * @throws IOException if the group or one of the removed members couldn't be indexed
-   * @throws NoSuchGroupException if the specified group doesn't exist
-   */
-  public void removeGroupMembers(
-      ReviewDb db, AccountGroup.UUID groupUuid, Set<Account.Id> accountIds)
-      throws OrmException, IOException, NoSuchGroupException {
-    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
-    AccountGroup.Id groupId = group.getId();
-    Set<AccountGroupMember> membersToRemove = new HashSet<>();
-    for (Account.Id accountId : accountIds) {
-      boolean isMember = groups.isMember(db, groupUuid, accountId);
-      if (isMember) {
-        AccountGroupMember.Key key = new AccountGroupMember.Key(accountId, groupId);
-        membersToRemove.add(new AccountGroupMember(key));
-      }
-    }
-
-    if (membersToRemove.isEmpty()) {
-      return;
-    }
-
-    if (currentUser != null) {
-      auditService.dispatchDeleteAccountsFromGroup(currentUser.getAccountId(), membersToRemove);
-    }
-    db.accountGroupMembers().delete(membersToRemove);
-    groupCache.evict(group.getGroupUUID(), group.getId(), group.getNameKey());
-    for (AccountGroupMember member : membersToRemove) {
-      accountCache.evict(member.getAccountId());
-    }
-  }
-
-  /**
-   * Adds several groups as subgroups to a group. Only groups which currently aren't subgroups of
-   * the group are added.
-   *
-   * <p>The parent group must be an internal group whereas the subgroups can either be internal or
-   * external groups.
-   *
-   * <p><strong>Note</strong>: This method doesn't check whether the subgroups exist!
-   *
-   * @param db the {@code ReviewDb} instance to update
-   * @param parentGroupUuid the UUID of the parent group
-   * @param subgroupUuids a set of IDs of the groups to add as subgroups
-   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
-   * @throws IOException if the parent group couldn't be indexed
-   * @throws NoSuchGroupException if the specified parent group doesn't exist
-   */
-  public void addSubgroups(
-      ReviewDb db, AccountGroup.UUID parentGroupUuid, Set<AccountGroup.UUID> subgroupUuids)
-      throws OrmException, NoSuchGroupException, IOException {
-    AccountGroup parentGroup = getExistingGroupFromReviewDb(db, parentGroupUuid);
-    AccountGroup.Id parentGroupId = parentGroup.getId();
-    Set<AccountGroupById> newSubgroups = new HashSet<>();
-    for (AccountGroup.UUID includedGroupUuid : subgroupUuids) {
-      boolean isSubgroup = groups.isSubgroup(db, parentGroupUuid, includedGroupUuid);
-      if (!isSubgroup) {
-        AccountGroupById.Key key = new AccountGroupById.Key(parentGroupId, includedGroupUuid);
-        newSubgroups.add(new AccountGroupById(key));
-      }
-    }
-
-    if (newSubgroups.isEmpty()) {
-      return;
-    }
-
-    if (currentUser != null) {
-      auditService.dispatchAddGroupsToGroup(currentUser.getAccountId(), newSubgroups);
-    }
-    db.accountGroupById().insert(newSubgroups);
-    groupCache.evict(parentGroup.getGroupUUID(), parentGroup.getId(), parentGroup.getNameKey());
-    for (AccountGroupById newIncludedGroup : newSubgroups) {
-      groupIncludeCache.evictParentGroupsOf(newIncludedGroup.getIncludeUUID());
-    }
-    groupIncludeCache.evictSubgroupsOf(parentGroupUuid);
-  }
-
-  /**
-   * Removes several subgroups from a parent group. Only groups which currently are subgroups of the
-   * group are removed.
-   *
-   * <p>The parent group must be an internal group whereas the subgroups can either be internal or
-   * external groups.
-   *
-   * @param db the {@code ReviewDb} instance to update
-   * @param parentGroupUuid the UUID of the parent group
-   * @param subgroupUuids a set of IDs of the subgroups to remove from the parent group
-   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
-   * @throws IOException if the parent group couldn't be indexed
-   * @throws NoSuchGroupException if the specified parent group doesn't exist
-   */
-  public void removeSubgroups(
-      ReviewDb db, AccountGroup.UUID parentGroupUuid, Set<AccountGroup.UUID> subgroupUuids)
-      throws OrmException, NoSuchGroupException, IOException {
-    AccountGroup parentGroup = getExistingGroupFromReviewDb(db, parentGroupUuid);
-    AccountGroup.Id parentGroupId = parentGroup.getId();
-    Set<AccountGroupById> subgroupsToRemove = new HashSet<>();
-    for (AccountGroup.UUID subgroupUuid : subgroupUuids) {
-      boolean isSubgroup = groups.isSubgroup(db, parentGroupUuid, subgroupUuid);
-      if (isSubgroup) {
-        AccountGroupById.Key key = new AccountGroupById.Key(parentGroupId, subgroupUuid);
-        subgroupsToRemove.add(new AccountGroupById(key));
-      }
-    }
-
-    if (subgroupsToRemove.isEmpty()) {
-      return;
-    }
-
-    if (currentUser != null) {
-      auditService.dispatchDeleteGroupsFromGroup(currentUser.getAccountId(), subgroupsToRemove);
-    }
-    db.accountGroupById().delete(subgroupsToRemove);
-    groupCache.evict(parentGroup.getGroupUUID(), parentGroup.getId(), parentGroup.getNameKey());
-    for (AccountGroupById groupToRemove : subgroupsToRemove) {
-      groupIncludeCache.evictParentGroupsOf(groupToRemove.getIncludeUUID());
-    }
-    groupIncludeCache.evictSubgroupsOf(parentGroupUuid);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/Index.java
deleted file mode 100644
index b61f954..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/Index.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-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;
-import java.util.Optional;
-
-@Singleton
-public class Index implements RestModifyView<GroupResource, Input> {
-  public static class Input {}
-
-  private final GroupCache groupCache;
-
-  @Inject
-  Index(GroupCache groupCache) {
-    this.groupCache = groupCache;
-  }
-
-  @Override
-  public Response<?> apply(GroupResource rsrc, Input input)
-      throws IOException, AuthException, UnprocessableEntityException {
-    if (!rsrc.getControl().isOwner()) {
-      throw new AuthException("not allowed to index group");
-    }
-
-    AccountGroup.UUID groupUuid = rsrc.getGroup().getGroupUUID();
-    if (!rsrc.isInternalGroup()) {
-      throw new UnprocessableEntityException(
-          String.format("External Group Not Allowed: %s", groupUuid.get()));
-    }
-
-    Optional<InternalGroup> group = groupCache.get(groupUuid);
-    // evicting the group from the cache, reindexes the group
-    if (group.isPresent()) {
-      groupCache.evict(group.get().getGroupUUID(), group.get().getId(), group.get().getNameKey());
-    }
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/InternalGroup.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/InternalGroup.java
deleted file mode 100644
index fafc591..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/InternalGroup.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import java.io.Serializable;
-import java.sql.Timestamp;
-
-@AutoValue
-public abstract class InternalGroup implements Serializable {
-  private static final long serialVersionUID = 1L;
-
-  public static InternalGroup create(
-      AccountGroup accountGroup,
-      ImmutableSet<Account.Id> members,
-      ImmutableSet<AccountGroup.UUID> subgroups) {
-    return new AutoValue_InternalGroup(
-        accountGroup.getId(),
-        accountGroup.getNameKey(),
-        accountGroup.getDescription(),
-        accountGroup.getOwnerGroupUUID(),
-        accountGroup.isVisibleToAll(),
-        accountGroup.getGroupUUID(),
-        accountGroup.getCreatedOn(),
-        members,
-        subgroups);
-  }
-
-  public abstract AccountGroup.Id getId();
-
-  public String getName() {
-    return getNameKey().get();
-  }
-
-  public abstract AccountGroup.NameKey getNameKey();
-
-  @Nullable
-  public abstract String getDescription();
-
-  public abstract AccountGroup.UUID getOwnerGroupUUID();
-
-  public abstract boolean isVisibleToAll();
-
-  public abstract AccountGroup.UUID getGroupUUID();
-
-  public abstract Timestamp getCreatedOn();
-
-  public abstract ImmutableSet<Account.Id> getMembers();
-
-  public abstract ImmutableSet<AccountGroup.UUID> getSubgroups();
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/InternalGroupDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/InternalGroupDescription.java
deleted file mode 100644
index c5df2ff..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/InternalGroupDescription.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import java.sql.Timestamp;
-
-public class InternalGroupDescription implements GroupDescription.Internal {
-
-  private final InternalGroup internalGroup;
-
-  public InternalGroupDescription(InternalGroup internalGroup) {
-    this.internalGroup = checkNotNull(internalGroup);
-  }
-
-  @Override
-  public AccountGroup.UUID getGroupUUID() {
-    return internalGroup.getGroupUUID();
-  }
-
-  @Override
-  public String getName() {
-    return internalGroup.getName();
-  }
-
-  @Nullable
-  @Override
-  public String getEmailAddress() {
-    return null;
-  }
-
-  @Nullable
-  @Override
-  public String getUrl() {
-    return "#" + PageLinks.toGroup(getGroupUUID());
-  }
-
-  @Override
-  public AccountGroup.Id getId() {
-    return internalGroup.getId();
-  }
-
-  @Override
-  @Nullable
-  public String getDescription() {
-    return internalGroup.getDescription();
-  }
-
-  @Override
-  public AccountGroup.UUID getOwnerGroupUUID() {
-    return internalGroup.getOwnerGroupUUID();
-  }
-
-  @Override
-  public boolean isVisibleToAll() {
-    return internalGroup.isVisibleToAll();
-  }
-
-  @Override
-  public Timestamp getCreatedOn() {
-    return internalGroup.getCreatedOn();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
deleted file mode 100644
index 910468f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
+++ /dev/null
@@ -1,420 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Streams;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupDescriptions;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-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.RestReadView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.account.GetGroups;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Comparator;
-import java.util.EnumSet;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Locale;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.TreeMap;
-import java.util.regex.Pattern;
-import java.util.stream.Stream;
-import org.kohsuke.args4j.Option;
-
-/** List groups visible to the calling user. */
-public class ListGroups implements RestReadView<TopLevelResource> {
-  private static final Comparator<GroupDescription.Internal> GROUP_COMPARATOR =
-      Comparator.comparing(GroupDescription.Basic::getName);
-
-  protected final GroupCache groupCache;
-
-  private final List<ProjectControl> projects = new ArrayList<>();
-  private final Set<AccountGroup.UUID> groupsToInspect = new HashSet<>();
-  private final GroupControl.Factory groupControlFactory;
-  private final GroupControl.GenericFactory genericGroupControlFactory;
-  private final Provider<IdentifiedUser> identifiedUser;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final GetGroups accountGetGroups;
-  private final GroupJson json;
-  private final GroupBackend groupBackend;
-  private final Groups groups;
-  private final Provider<ReviewDb> db;
-
-  private EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
-  private boolean visibleToAll;
-  private Account.Id user;
-  private boolean owned;
-  private int limit;
-  private int start;
-  private String matchSubstring;
-  private String matchRegex;
-  private String suggest;
-
-  @Option(
-    name = "--project",
-    aliases = {"-p"},
-    usage = "projects for which the groups should be listed"
-  )
-  public void addProject(ProjectControl project) {
-    projects.add(project);
-  }
-
-  @Option(
-    name = "--visible-to-all",
-    usage = "to list only groups that are visible to all registered users"
-  )
-  public void setVisibleToAll(boolean visibleToAll) {
-    this.visibleToAll = visibleToAll;
-  }
-
-  @Option(
-    name = "--user",
-    aliases = {"-u"},
-    usage = "user for which the groups should be listed"
-  )
-  public void setUser(Account.Id user) {
-    this.user = user;
-  }
-
-  @Option(
-    name = "--owned",
-    usage =
-        "to list only groups that are owned by the"
-            + " specified user or by the calling user if no user was specifed"
-  )
-  public void setOwned(boolean owned) {
-    this.owned = owned;
-  }
-
-  /**
-   * Add a group to inspect.
-   *
-   * @param uuid UUID of the group
-   * @deprecated use {@link #addGroup(AccountGroup.UUID)}.
-   */
-  @Deprecated
-  @Option(
-    name = "--query",
-    aliases = {"-q"},
-    usage = "group to inspect (deprecated: use --group/-g instead)"
-  )
-  void addGroup_Deprecated(AccountGroup.UUID uuid) {
-    addGroup(uuid);
-  }
-
-  @Option(
-    name = "--group",
-    aliases = {"-g"},
-    usage = "group to inspect"
-  )
-  public void addGroup(AccountGroup.UUID uuid) {
-    groupsToInspect.add(uuid);
-  }
-
-  @Option(
-    name = "--limit",
-    aliases = {"-n"},
-    metaVar = "CNT",
-    usage = "maximum number of groups to list"
-  )
-  public void setLimit(int limit) {
-    this.limit = limit;
-  }
-
-  @Option(
-    name = "--start",
-    aliases = {"-S"},
-    metaVar = "CNT",
-    usage = "number of groups to skip"
-  )
-  public void setStart(int start) {
-    this.start = start;
-  }
-
-  @Option(
-    name = "--match",
-    aliases = {"-m"},
-    metaVar = "MATCH",
-    usage = "match group substring"
-  )
-  public void setMatchSubstring(String matchSubstring) {
-    this.matchSubstring = matchSubstring;
-  }
-
-  @Option(
-    name = "--regex",
-    aliases = {"-r"},
-    metaVar = "REGEX",
-    usage = "match group regex"
-  )
-  public void setMatchRegex(String matchRegex) {
-    this.matchRegex = matchRegex;
-  }
-
-  @Option(
-    name = "--suggest",
-    aliases = {"-s"},
-    usage = "to get a suggestion of groups"
-  )
-  public void setSuggest(String suggest) {
-    this.suggest = suggest;
-  }
-
-  @Option(name = "-o", usage = "Output options per group")
-  void addOption(ListGroupsOption o) {
-    options.add(o);
-  }
-
-  @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListGroupsOption.fromBits(Integer.parseInt(hex, 16)));
-  }
-
-  @Inject
-  protected ListGroups(
-      final GroupCache groupCache,
-      final GroupControl.Factory groupControlFactory,
-      final GroupControl.GenericFactory genericGroupControlFactory,
-      final Provider<IdentifiedUser> identifiedUser,
-      final IdentifiedUser.GenericFactory userFactory,
-      final GetGroups accountGetGroups,
-      GroupJson json,
-      GroupBackend groupBackend,
-      Groups groups,
-      Provider<ReviewDb> db) {
-    this.groupCache = groupCache;
-    this.groupControlFactory = groupControlFactory;
-    this.genericGroupControlFactory = genericGroupControlFactory;
-    this.identifiedUser = identifiedUser;
-    this.userFactory = userFactory;
-    this.accountGetGroups = accountGetGroups;
-    this.json = json;
-    this.groupBackend = groupBackend;
-    this.groups = groups;
-    this.db = db;
-  }
-
-  public void setOptions(EnumSet<ListGroupsOption> options) {
-    this.options = options;
-  }
-
-  public Account.Id getUser() {
-    return user;
-  }
-
-  public List<ProjectControl> getProjects() {
-    return projects;
-  }
-
-  @Override
-  public SortedMap<String, GroupInfo> apply(TopLevelResource resource)
-      throws OrmException, BadRequestException {
-    SortedMap<String, GroupInfo> output = new TreeMap<>();
-    for (GroupInfo info : get()) {
-      output.put(MoreObjects.firstNonNull(info.name, "Group " + Url.decode(info.id)), info);
-      info.name = null;
-    }
-    return output;
-  }
-
-  public List<GroupInfo> get() throws OrmException, BadRequestException {
-    if (!Strings.isNullOrEmpty(suggest)) {
-      return suggestGroups();
-    }
-
-    if (!Strings.isNullOrEmpty(matchSubstring) && !Strings.isNullOrEmpty(matchRegex)) {
-      throw new BadRequestException("Specify one of m/r");
-    }
-
-    if (owned) {
-      return getGroupsOwnedBy(user != null ? userFactory.create(user) : identifiedUser.get());
-    }
-
-    if (user != null) {
-      return accountGetGroups.apply(new AccountResource(userFactory.create(user)));
-    }
-
-    return getAllGroups();
-  }
-
-  private List<GroupInfo> getAllGroups() throws OrmException {
-    Pattern pattern = getRegexPattern();
-    Stream<GroupDescription.Internal> existingGroups =
-        getAllExistingGroups()
-            .filter(group -> !isNotRelevant(pattern, group))
-            .sorted(GROUP_COMPARATOR)
-            .skip(start);
-    if (limit > 0) {
-      existingGroups = existingGroups.limit(limit);
-    }
-    List<GroupDescription.Internal> relevantGroups = existingGroups.collect(toImmutableList());
-    List<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(relevantGroups.size());
-    for (GroupDescription.Internal group : relevantGroups) {
-      groupInfos.add(json.addOptions(options).format(group));
-    }
-    return groupInfos;
-  }
-
-  private Stream<GroupDescription.Internal> getAllExistingGroups() throws OrmException {
-    if (!projects.isEmpty()) {
-      return projects
-          .stream()
-          .map(ProjectControl::getProjectState)
-          .map(ProjectState::getAllGroups)
-          .flatMap(Collection::stream)
-          .map(GroupReference::getUUID)
-          .distinct()
-          .map(groupCache::get)
-          .flatMap(Streams::stream)
-          .map(InternalGroupDescription::new);
-    }
-    return groups.getAll(db.get()).map(GroupDescriptions::forAccountGroup);
-  }
-
-  private List<GroupInfo> suggestGroups() throws OrmException, BadRequestException {
-    if (conflictingSuggestParameters()) {
-      throw new BadRequestException(
-          "You should only have no more than one --project and -n with --suggest");
-    }
-    List<GroupReference> groupRefs =
-        Lists.newArrayList(
-            Iterables.limit(
-                groupBackend.suggest(
-                    suggest,
-                    projects.stream().findFirst().map(pc -> pc.getProjectState()).orElse(null)),
-                limit <= 0 ? 10 : Math.min(limit, 10)));
-
-    List<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(groupRefs.size());
-    for (GroupReference ref : groupRefs) {
-      GroupDescription.Basic desc = groupBackend.get(ref.getUUID());
-      if (desc != null) {
-        groupInfos.add(json.addOptions(options).format(desc));
-      }
-    }
-    return groupInfos;
-  }
-
-  private boolean conflictingSuggestParameters() {
-    if (Strings.isNullOrEmpty(suggest)) {
-      return false;
-    }
-    if (projects.size() > 1) {
-      return true;
-    }
-    if (visibleToAll) {
-      return true;
-    }
-    if (user != null) {
-      return true;
-    }
-    if (owned) {
-      return true;
-    }
-    if (start != 0) {
-      return true;
-    }
-    if (!groupsToInspect.isEmpty()) {
-      return true;
-    }
-    if (!Strings.isNullOrEmpty(matchSubstring)) {
-      return true;
-    }
-    if (!Strings.isNullOrEmpty(matchRegex)) {
-      return true;
-    }
-    return false;
-  }
-
-  private List<GroupInfo> getGroupsOwnedBy(IdentifiedUser user) 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))
-            .sorted(GROUP_COMPARATOR)
-            .skip(start);
-    if (limit > 0) {
-      foundGroups = foundGroups.limit(limit);
-    }
-    List<GroupDescription.Internal> ownedGroups = foundGroups.collect(toImmutableList());
-    List<GroupInfo> groupInfos = new ArrayList<>(ownedGroups.size());
-    for (GroupDescription.Internal group : ownedGroups) {
-      groupInfos.add(json.addOptions(options).format(group));
-    }
-    return groupInfos;
-  }
-
-  private boolean isOwner(CurrentUser user, GroupDescription.Internal group) {
-    try {
-      return genericGroupControlFactory.controlFor(user, group.getGroupUUID()).isOwner();
-    } catch (NoSuchGroupException e) {
-      return false;
-    }
-  }
-
-  private Pattern getRegexPattern() {
-    return Strings.isNullOrEmpty(matchRegex) ? null : Pattern.compile(matchRegex);
-  }
-
-  private boolean isNotRelevant(Pattern pattern, GroupDescription.Internal group) {
-    if (!Strings.isNullOrEmpty(matchSubstring)) {
-      if (!group.getName().toLowerCase(Locale.US).contains(matchSubstring.toLowerCase(Locale.US))) {
-        return true;
-      }
-    } else if (pattern != null) {
-      if (!pattern.matcher(group.getName()).matches()) {
-        return true;
-      }
-    }
-    if (visibleToAll && !group.isVisibleToAll()) {
-      return true;
-    }
-    if (!groupsToInspect.isEmpty() && !groupsToInspect.contains(group.getGroupUUID())) {
-      return true;
-    }
-
-    GroupControl c = groupControlFactory.controlFor(group);
-    return !c.isVisible();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
deleted file mode 100644
index af988b8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
+++ /dev/null
@@ -1,107 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.api.accounts.AccountInfoComparator;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import org.kohsuke.args4j.Option;
-
-public class ListMembers implements RestReadView<GroupResource> {
-  private final GroupCache groupCache;
-  private final GroupControl.Factory groupControlFactory;
-  private final AccountLoader accountLoader;
-
-  @Option(name = "--recursive", usage = "to resolve included groups recursively")
-  private boolean recursive;
-
-  @Inject
-  protected ListMembers(
-      GroupCache groupCache,
-      GroupControl.Factory groupControlFactory,
-      AccountLoader.Factory accountLoaderFactory) {
-    this.groupCache = groupCache;
-    this.groupControlFactory = groupControlFactory;
-    this.accountLoader = accountLoaderFactory.create(true);
-  }
-
-  public ListMembers setRecursive(boolean recursive) {
-    this.recursive = recursive;
-    return this;
-  }
-
-  @Override
-  public List<AccountInfo> apply(GroupResource resource)
-      throws MethodNotAllowedException, OrmException {
-    GroupDescription.Internal group =
-        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-    return apply(group.getGroupUUID());
-  }
-
-  public List<AccountInfo> apply(AccountGroup.UUID groupId) throws OrmException {
-    Set<Account.Id> members = getMembers(groupId, new HashSet<>());
-    List<AccountInfo> memberInfos = new ArrayList<>(members.size());
-    for (Account.Id member : members) {
-      memberInfos.add(accountLoader.get(member));
-    }
-    accountLoader.fill();
-    memberInfos.sort(AccountInfoComparator.ORDER_NULLS_FIRST);
-    return memberInfos;
-  }
-
-  private Set<Account.Id> getMembers(
-      AccountGroup.UUID groupUUID, HashSet<AccountGroup.UUID> seenGroups) {
-    seenGroups.add(groupUUID);
-
-    Optional<InternalGroup> internalGroup = groupCache.get(groupUUID);
-    if (!internalGroup.isPresent()) {
-      return ImmutableSet.of();
-    }
-    InternalGroup group = internalGroup.get();
-
-    GroupControl groupControl = groupControlFactory.controlFor(new InternalGroupDescription(group));
-
-    Set<Account.Id> directMembers =
-        group.getMembers().stream().filter(groupControl::canSeeMember).collect(toImmutableSet());
-
-    Set<Account.Id> indirectMembers = new HashSet<>();
-    if (recursive && groupControl.canSeeGroup()) {
-      for (AccountGroup.UUID subgroupUuid : group.getSubgroups()) {
-        if (!seenGroups.contains(subgroupUuid)) {
-          indirectMembers.addAll(getMembers(subgroupUuid, seenGroups));
-        }
-      }
-    }
-    return Sets.union(directMembers, indirectMembers);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListSubgroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListSubgroups.java
deleted file mode 100644
index 2000d66..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListSubgroups.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import static com.google.common.base.Strings.nullToEmpty;
-
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.account.GroupIncludeCache;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import org.slf4j.Logger;
-
-@Singleton
-public class ListSubgroups implements RestReadView<GroupResource> {
-  private static final Logger log = org.slf4j.LoggerFactory.getLogger(ListSubgroups.class);
-
-  private final GroupControl.Factory controlFactory;
-  private final GroupIncludeCache groupIncludeCache;
-  private final GroupJson json;
-
-  @Inject
-  ListSubgroups(
-      GroupControl.Factory controlFactory, GroupIncludeCache groupIncludeCache, GroupJson json) {
-    this.controlFactory = controlFactory;
-    this.groupIncludeCache = groupIncludeCache;
-    this.json = json;
-  }
-
-  @Override
-  public List<GroupInfo> apply(GroupResource rsrc) throws MethodNotAllowedException, OrmException {
-    GroupDescription.Internal group =
-        rsrc.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-
-    boolean ownerOfParent = rsrc.getControl().isOwner();
-    List<GroupInfo> included = new ArrayList<>();
-    Collection<AccountGroup.UUID> subgroupUuids =
-        groupIncludeCache.subgroupsOf(group.getGroupUUID());
-    for (AccountGroup.UUID subgroupUuid : subgroupUuids) {
-      try {
-        GroupControl i = controlFactory.controlFor(subgroupUuid);
-        if (ownerOfParent || i.isVisible()) {
-          included.add(json.format(i.getGroup()));
-        }
-      } catch (NoSuchGroupException notFound) {
-        log.warn(
-            String.format(
-                "Group %s no longer available, subgroup of %s", subgroupUuid, group.getName()));
-        continue;
-      }
-    }
-    Collections.sort(
-        included,
-        new Comparator<GroupInfo>() {
-          @Override
-          public int compare(GroupInfo a, GroupInfo b) {
-            int cmp = nullToEmpty(a.name).compareTo(nullToEmpty(b.name));
-            if (cmp != 0) {
-              return cmp;
-            }
-            return nullToEmpty(a.id).compareTo(nullToEmpty(b.id));
-          }
-        });
-    return included;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/MemberResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/MemberResource.java
deleted file mode 100644
index 52a37a8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/MemberResource.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.inject.TypeLiteral;
-
-public class MemberResource extends GroupResource {
-  public static final TypeLiteral<RestView<MemberResource>> MEMBER_KIND =
-      new TypeLiteral<RestView<MemberResource>>() {};
-
-  private final IdentifiedUser user;
-
-  public MemberResource(GroupResource group, IdentifiedUser user) {
-    super(group);
-    this.user = user;
-  }
-
-  public IdentifiedUser getMember() {
-    return user;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/MembersCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/MembersCollection.java
deleted file mode 100644
index fdfb413..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/MembersCollection.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountsCollection;
-import com.google.gerrit.server.group.AddMembers.PutMember;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class MembersCollection
-    implements ChildCollection<GroupResource, MemberResource>, AcceptsCreate<GroupResource> {
-  private final DynamicMap<RestView<MemberResource>> views;
-  private final Provider<ListMembers> list;
-  private final AccountsCollection accounts;
-  private final Groups groups;
-  private final Provider<ReviewDb> db;
-  private final AddMembers put;
-
-  @Inject
-  MembersCollection(
-      DynamicMap<RestView<MemberResource>> views,
-      Provider<ListMembers> list,
-      AccountsCollection accounts,
-      Groups groups,
-      Provider<ReviewDb> db,
-      AddMembers put) {
-    this.views = views;
-    this.list = list;
-    this.accounts = accounts;
-    this.groups = groups;
-    this.db = db;
-    this.put = put;
-  }
-
-  @Override
-  public RestView<GroupResource> list() throws ResourceNotFoundException, AuthException {
-    return list.get();
-  }
-
-  @Override
-  public MemberResource parse(GroupResource parent, IdString id)
-      throws MethodNotAllowedException, AuthException, ResourceNotFoundException, OrmException,
-          IOException, ConfigInvalidException {
-    GroupDescription.Internal group =
-        parent.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-
-    IdentifiedUser user = accounts.parse(TopLevelResource.INSTANCE, id).getUser();
-    if (parent.getControl().canSeeMember(user.getAccountId()) && isMember(group, user)) {
-      return new MemberResource(parent, user);
-    }
-    throw new ResourceNotFoundException(id);
-  }
-
-  private boolean isMember(GroupDescription.Internal group, IdentifiedUser user)
-      throws OrmException, ResourceNotFoundException {
-    AccountGroup.UUID groupUuid = group.getGroupUUID();
-    try {
-      return groups.isMember(db.get(), groupUuid, user.getAccountId());
-    } catch (NoSuchGroupException e) {
-      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
-    }
-  }
-
-  @Override
-  public PutMember create(GroupResource group, IdString id) {
-    return new PutMember(put, id.get());
-  }
-
-  @Override
-  public DynamicMap<RestView<MemberResource>> views() {
-    return views;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/Module.java
deleted file mode 100644
index 5006914..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/Module.java
+++ /dev/null
@@ -1,90 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import static com.google.gerrit.server.group.GroupResource.GROUP_KIND;
-import static com.google.gerrit.server.group.MemberResource.MEMBER_KIND;
-import static com.google.gerrit.server.group.SubgroupResource.SUBGROUP_KIND;
-
-import com.google.gerrit.audit.GroupMemberAuditListener;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.group.AddMembers.UpdateMember;
-import com.google.gerrit.server.group.AddSubgroups.UpdateSubgroup;
-import com.google.gerrit.server.group.DeleteMembers.DeleteMember;
-import com.google.gerrit.server.group.DeleteSubgroups.DeleteSubgroup;
-import com.google.inject.Provides;
-
-public class Module extends RestApiModule {
-  @Override
-  protected void configure() {
-    bind(GroupsCollection.class);
-
-    DynamicMap.mapOf(binder(), GROUP_KIND);
-    DynamicMap.mapOf(binder(), MEMBER_KIND);
-    DynamicMap.mapOf(binder(), SUBGROUP_KIND);
-
-    get(GROUP_KIND).to(GetGroup.class);
-    put(GROUP_KIND).to(PutGroup.class);
-    get(GROUP_KIND, "detail").to(GetDetail.class);
-    post(GROUP_KIND, "index").to(Index.class);
-    post(GROUP_KIND, "members").to(AddMembers.class);
-    post(GROUP_KIND, "members.add").to(AddMembers.class);
-    post(GROUP_KIND, "members.delete").to(DeleteMembers.class);
-    post(GROUP_KIND, "groups").to(AddSubgroups.class);
-    post(GROUP_KIND, "groups.add").to(AddSubgroups.class);
-    post(GROUP_KIND, "groups.delete").to(DeleteSubgroups.class);
-    get(GROUP_KIND, "description").to(GetDescription.class);
-    put(GROUP_KIND, "description").to(PutDescription.class);
-    delete(GROUP_KIND, "description").to(PutDescription.class);
-    get(GROUP_KIND, "name").to(GetName.class);
-    put(GROUP_KIND, "name").to(PutName.class);
-    get(GROUP_KIND, "owner").to(GetOwner.class);
-    put(GROUP_KIND, "owner").to(PutOwner.class);
-    get(GROUP_KIND, "options").to(GetOptions.class);
-    put(GROUP_KIND, "options").to(PutOptions.class);
-    get(GROUP_KIND, "log.audit").to(GetAuditLog.class);
-
-    child(GROUP_KIND, "members").to(MembersCollection.class);
-    get(MEMBER_KIND).to(GetMember.class);
-    put(MEMBER_KIND).to(UpdateMember.class);
-    delete(MEMBER_KIND).to(DeleteMember.class);
-
-    child(GROUP_KIND, "groups").to(SubgroupsCollection.class);
-    get(SUBGROUP_KIND).to(GetSubgroup.class);
-    put(SUBGROUP_KIND).to(UpdateSubgroup.class);
-    delete(SUBGROUP_KIND).to(DeleteSubgroup.class);
-
-    factory(CreateGroup.Factory.class);
-    factory(GroupsUpdate.Factory.class);
-
-    DynamicSet.bind(binder(), GroupMemberAuditListener.class).to(DbGroupMemberAuditListener.class);
-  }
-
-  @Provides
-  @ServerInitiated
-  GroupsUpdate provideServerInitiatedGroupsUpdate(GroupsUpdate.Factory groupsUpdateFactory) {
-    return groupsUpdateFactory.create(null);
-  }
-
-  @Provides
-  @UserInitiated
-  GroupsUpdate provideUserInitiatedGroupsUpdate(
-      GroupsUpdate.Factory groupsUpdateFactory, IdentifiedUser currentUser) {
-    return groupsUpdateFactory.create(currentUser);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java
deleted file mode 100644
index 3d6feea..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-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;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Objects;
-
-@Singleton
-public class PutDescription implements RestModifyView<GroupResource, Input> {
-  public static class Input {
-    @DefaultInput public String description;
-  }
-
-  private final Provider<ReviewDb> db;
-  private final Provider<GroupsUpdate> groupsUpdateProvider;
-
-  @Inject
-  PutDescription(
-      Provider<ReviewDb> db, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
-    this.db = db;
-    this.groupsUpdateProvider = groupsUpdateProvider;
-  }
-
-  @Override
-  public Response<String> apply(GroupResource resource, Input input)
-      throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
-          IOException {
-    if (input == null) {
-      input = new Input(); // Delete would set description to null.
-    }
-
-    GroupDescription.Internal internalGroup =
-        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-    if (!resource.getControl().isOwner()) {
-      throw new AuthException("Not group owner");
-    }
-
-    String newDescription = Strings.emptyToNull(input.description);
-    if (!Objects.equals(internalGroup.getDescription(), newDescription)) {
-      AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
-      try {
-        groupsUpdateProvider
-            .get()
-            .updateGroup(db.get(), groupUuid, group -> group.setDescription(newDescription));
-      } catch (NoSuchGroupException e) {
-        throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
-      }
-    }
-
-    return Strings.isNullOrEmpty(input.description)
-        ? Response.<String>none()
-        : Response.ok(input.description);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutGroup.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutGroup.java
deleted file mode 100644
index abaa317..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutGroup.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.extensions.api.groups.GroupInput;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class PutGroup implements RestModifyView<GroupResource, GroupInput> {
-  @Override
-  public Response<?> apply(GroupResource resource, GroupInput input)
-      throws ResourceConflictException {
-    throw new ResourceConflictException("Group already exists");
-  }
-}
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
deleted file mode 100644
index 75a7eb5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NameAlreadyUsedException;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.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;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class PutName implements RestModifyView<GroupResource, Input> {
-  public static class Input {
-    @DefaultInput public String name;
-  }
-
-  private final Provider<ReviewDb> db;
-  private final Provider<GroupsUpdate> groupsUpdateProvider;
-
-  @Inject
-  PutName(Provider<ReviewDb> db, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
-    this.db = db;
-    this.groupsUpdateProvider = groupsUpdateProvider;
-  }
-
-  @Override
-  public String apply(GroupResource rsrc, Input input)
-      throws MethodNotAllowedException, AuthException, BadRequestException,
-          ResourceConflictException, ResourceNotFoundException, OrmException, IOException {
-    GroupDescription.Internal internalGroup =
-        rsrc.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-    if (!rsrc.getControl().isOwner()) {
-      throw new AuthException("Not group owner");
-    } else if (input == null || Strings.isNullOrEmpty(input.name)) {
-      throw new BadRequestException("name is required");
-    }
-    String newName = input.name.trim();
-    if (newName.isEmpty()) {
-      throw new BadRequestException("name is required");
-    }
-
-    if (internalGroup.getName().equals(newName)) {
-      return newName;
-    }
-
-    renameGroup(internalGroup, newName);
-    return newName;
-  }
-
-  private void renameGroup(GroupDescription.Internal group, String newName)
-      throws ResourceConflictException, ResourceNotFoundException, OrmException, IOException {
-    AccountGroup.UUID groupUuid = group.getGroupUUID();
-    try {
-      groupsUpdateProvider
-          .get()
-          .renameGroup(db.get(), groupUuid, new AccountGroup.NameKey(newName));
-    } catch (NoSuchGroupException e) {
-      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
-    } catch (NameAlreadyUsedException e) {
-      throw new ResourceConflictException("group with name " + newName + " already exists");
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOptions.java
deleted file mode 100644
index 1ea018f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOptions.java
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.GroupOptionsInfo;
-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.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.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class PutOptions implements RestModifyView<GroupResource, GroupOptionsInfo> {
-  private final Provider<ReviewDb> db;
-  private final Provider<GroupsUpdate> groupsUpdateProvider;
-
-  @Inject
-  PutOptions(Provider<ReviewDb> db, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
-    this.db = db;
-    this.groupsUpdateProvider = groupsUpdateProvider;
-  }
-
-  @Override
-  public GroupOptionsInfo apply(GroupResource resource, GroupOptionsInfo input)
-      throws MethodNotAllowedException, AuthException, BadRequestException,
-          ResourceNotFoundException, OrmException, IOException {
-    GroupDescription.Internal internalGroup =
-        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-    if (!resource.getControl().isOwner()) {
-      throw new AuthException("Not group owner");
-    }
-
-    if (input == null) {
-      throw new BadRequestException("options are required");
-    }
-    if (input.visibleToAll == null) {
-      input.visibleToAll = false;
-    }
-
-    if (internalGroup.isVisibleToAll() != input.visibleToAll) {
-      AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
-      try {
-        groupsUpdateProvider
-            .get()
-            .updateGroup(db.get(), groupUuid, group -> group.setVisibleToAll(input.visibleToAll));
-      } catch (NoSuchGroupException e) {
-        throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
-      }
-    }
-
-    GroupOptionsInfo options = new GroupOptionsInfo();
-    if (input.visibleToAll) {
-      options.visibleToAll = true;
-    }
-    return options;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java
deleted file mode 100644
index 20e1dbe..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-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;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class PutOwner implements RestModifyView<GroupResource, Input> {
-  public static class Input {
-    @DefaultInput public String owner;
-  }
-
-  private final GroupsCollection groupsCollection;
-  private final Provider<GroupsUpdate> groupsUpdateProvider;
-  private final Provider<ReviewDb> db;
-  private final GroupJson json;
-
-  @Inject
-  PutOwner(
-      GroupsCollection groupsCollection,
-      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
-      Provider<ReviewDb> db,
-      GroupJson json) {
-    this.groupsCollection = groupsCollection;
-    this.groupsUpdateProvider = groupsUpdateProvider;
-    this.db = db;
-    this.json = json;
-  }
-
-  @Override
-  public GroupInfo apply(GroupResource resource, Input input)
-      throws ResourceNotFoundException, MethodNotAllowedException, AuthException,
-          BadRequestException, UnprocessableEntityException, OrmException, IOException {
-    GroupDescription.Internal internalGroup =
-        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-    if (!resource.getControl().isOwner()) {
-      throw new AuthException("Not group owner");
-    }
-
-    if (input == null || Strings.isNullOrEmpty(input.owner)) {
-      throw new BadRequestException("owner is required");
-    }
-
-    GroupDescription.Basic owner = groupsCollection.parse(input.owner);
-    if (!internalGroup.getOwnerGroupUUID().equals(owner.getGroupUUID())) {
-      AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
-      try {
-        groupsUpdateProvider
-            .get()
-            .updateGroup(
-                db.get(), groupUuid, group -> group.setOwnerGroupUUID(owner.getGroupUUID()));
-      } catch (NoSuchGroupException e) {
-        throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
-      }
-    }
-    return json.format(owner);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/QueryGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/QueryGroups.java
deleted file mode 100644
index c5fd2cb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/QueryGroups.java
+++ /dev/null
@@ -1,144 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.gerrit.extensions.client.ListGroupsOption;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.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;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.EnumSet;
-import java.util.List;
-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;
-
-  private String query;
-  private int limit;
-  private int start;
-  private EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
-
-  // TODO(ekempin): --query in ListGroups is marked as deprecated, once it is
-  // removed we want to rename --query2 to --query here.
-  /** --query (-q) is already used by {@link ListGroups} */
-  @Option(
-    name = "--query2",
-    aliases = {"-q2"},
-    usage = "group query"
-  )
-  public void setQuery(String query) {
-    this.query = query;
-  }
-
-  @Option(
-    name = "--limit",
-    aliases = {"-n"},
-    metaVar = "CNT",
-    usage = "maximum number of groups to list"
-  )
-  public void setLimit(int limit) {
-    this.limit = limit;
-  }
-
-  @Option(
-    name = "--start",
-    aliases = {"-S"},
-    metaVar = "CNT",
-    usage = "number of groups to skip"
-  )
-  public void setStart(int start) {
-    this.start = start;
-  }
-
-  @Option(name = "-o", usage = "Output options per group")
-  public void addOption(ListGroupsOption o) {
-    options.add(o);
-  }
-
-  @Option(name = "-O", usage = "Output option flags, in hex")
-  public void setOptionFlagsHex(String hex) {
-    options.addAll(ListGroupsOption.fromBits(Integer.parseInt(hex, 16)));
-  }
-
-  @Inject
-  protected QueryGroups(
-      GroupIndexCollection indexes,
-      GroupQueryBuilder queryBuilder,
-      GroupQueryProcessor queryProcessor,
-      GroupJson json) {
-    this.indexes = indexes;
-    this.queryBuilder = queryBuilder;
-    this.queryProcessor = queryProcessor;
-    this.json = json;
-  }
-
-  @Override
-  public List<GroupInfo> apply(TopLevelResource resource)
-      throws BadRequestException, MethodNotAllowedException, OrmException {
-    if (Strings.isNullOrEmpty(query)) {
-      throw new BadRequestException("missing query field");
-    }
-
-    if (queryProcessor.isDisabled()) {
-      throw new MethodNotAllowedException("query disabled");
-    }
-
-    GroupIndex searchIndex = indexes.getSearchIndex();
-    if (searchIndex == null) {
-      throw new MethodNotAllowedException("no group index");
-    }
-
-    if (start != 0) {
-      queryProcessor.setStart(start);
-    }
-
-    if (limit != 0) {
-      queryProcessor.setUserProvidedLimit(limit);
-    }
-
-    try {
-      QueryResult<InternalGroup> result = queryProcessor.query(queryBuilder.parse(query));
-      List<InternalGroup> groups = result.entities();
-
-      ArrayList<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(groups.size());
-      json.addOptions(options);
-      for (InternalGroup group : groups) {
-        groupInfos.add(json.format(new InternalGroupDescription(group)));
-      }
-      if (!groupInfos.isEmpty() && result.more()) {
-        groupInfos.get(groupInfos.size() - 1)._moreGroups = true;
-      }
-      return groupInfos;
-    } catch (QueryParseException e) {
-      throw new BadRequestException(e.getMessage());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/SubgroupResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/SubgroupResource.java
deleted file mode 100644
index 50c769d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/SubgroupResource.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.inject.TypeLiteral;
-
-public class SubgroupResource extends GroupResource {
-  public static final TypeLiteral<RestView<SubgroupResource>> SUBGROUP_KIND =
-      new TypeLiteral<RestView<SubgroupResource>>() {};
-
-  private final GroupDescription.Basic member;
-
-  public SubgroupResource(GroupResource group, GroupDescription.Basic member) {
-    super(group);
-    this.member = member;
-  }
-
-  public AccountGroup.UUID getMember() {
-    return getMemberDescription().getGroupUUID();
-  }
-
-  public GroupDescription.Basic getMemberDescription() {
-    return member;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/SubgroupsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/SubgroupsCollection.java
deleted file mode 100644
index 720c6df..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/SubgroupsCollection.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.group.AddSubgroups.PutSubgroup;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class SubgroupsCollection
-    implements ChildCollection<GroupResource, SubgroupResource>, AcceptsCreate<GroupResource> {
-  private final DynamicMap<RestView<SubgroupResource>> views;
-  private final ListSubgroups list;
-  private final GroupsCollection groupsCollection;
-  private final Provider<ReviewDb> dbProvider;
-  private final Groups groups;
-  private final AddSubgroups addSubgroups;
-
-  @Inject
-  SubgroupsCollection(
-      DynamicMap<RestView<SubgroupResource>> views,
-      ListSubgroups list,
-      GroupsCollection groupsCollection,
-      Provider<ReviewDb> dbProvider,
-      Groups groups,
-      AddSubgroups addSubgroups) {
-    this.views = views;
-    this.list = list;
-    this.groupsCollection = groupsCollection;
-    this.dbProvider = dbProvider;
-    this.groups = groups;
-    this.addSubgroups = addSubgroups;
-  }
-
-  @Override
-  public RestView<GroupResource> list() {
-    return list;
-  }
-
-  @Override
-  public SubgroupResource parse(GroupResource resource, IdString id)
-      throws MethodNotAllowedException, AuthException, ResourceNotFoundException, OrmException {
-    GroupDescription.Internal parent =
-        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
-
-    GroupDescription.Basic member =
-        groupsCollection.parse(TopLevelResource.INSTANCE, id).getGroup();
-    if (resource.getControl().canSeeGroup() && isSubgroup(parent, member)) {
-      return new SubgroupResource(resource, member);
-    }
-    throw new ResourceNotFoundException(id);
-  }
-
-  private boolean isSubgroup(GroupDescription.Internal parent, GroupDescription.Basic member)
-      throws OrmException, ResourceNotFoundException {
-    try {
-      return groups.isSubgroup(dbProvider.get(), parent.getGroupUUID(), member.getGroupUUID());
-    } catch (NoSuchGroupException e) {
-      throw new ResourceNotFoundException(
-          String.format("Group %s not found", parent.getGroupUUID()));
-    }
-  }
-
-  @Override
-  public PutSubgroup create(GroupResource group, IdString id) {
-    return new PutSubgroup(addSubgroups, id.get());
-  }
-
-  @Override
-  public DynamicMap<RestView<SubgroupResource>> views() {
-    return views;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java
deleted file mode 100644
index 5d60790..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ /dev/null
@@ -1,252 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.StartupCheck;
-import com.google.gerrit.server.StartupException;
-import com.google.gerrit.server.account.AbstractGroupBackend;
-import com.google.gerrit.server.account.GroupMembership;
-import com.google.gerrit.server.account.ListGroupMembership;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.TreeMap;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class SystemGroupBackend extends AbstractGroupBackend {
-  public static final String SYSTEM_GROUP_SCHEME = "global:";
-
-  /** Common UUID assigned to the "Anonymous Users" group. */
-  public static final AccountGroup.UUID ANONYMOUS_USERS =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Anonymous-Users");
-
-  /** Common UUID assigned to the "Registered Users" group. */
-  public static final AccountGroup.UUID REGISTERED_USERS =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Registered-Users");
-
-  /** Common UUID assigned to the "Project Owners" placeholder group. */
-  public static final AccountGroup.UUID PROJECT_OWNERS =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Project-Owners");
-
-  /** Common UUID assigned to the "Change Owner" placeholder group. */
-  public static final AccountGroup.UUID CHANGE_OWNER =
-      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Change-Owner");
-
-  private static final AccountGroup.UUID[] all = {
-    ANONYMOUS_USERS, REGISTERED_USERS, PROJECT_OWNERS, CHANGE_OWNER,
-  };
-
-  public static boolean isSystemGroup(AccountGroup.UUID uuid) {
-    return uuid.get().startsWith(SYSTEM_GROUP_SCHEME);
-  }
-
-  public static boolean isAnonymousOrRegistered(GroupReference ref) {
-    return isAnonymousOrRegistered(ref.getUUID());
-  }
-
-  public static boolean isAnonymousOrRegistered(AccountGroup.UUID uuid) {
-    return ANONYMOUS_USERS.equals(uuid) || REGISTERED_USERS.equals(uuid);
-  }
-
-  private final ImmutableSet<String> reservedNames;
-  private final SortedMap<String, GroupReference> namesToGroups;
-  private final ImmutableSet<String> names;
-  private final ImmutableMap<AccountGroup.UUID, GroupReference> uuids;
-
-  @Inject
-  @VisibleForTesting
-  public SystemGroupBackend(@GerritServerConfig Config cfg) {
-    SortedMap<String, GroupReference> n = new TreeMap<>();
-    ImmutableMap.Builder<AccountGroup.UUID, GroupReference> u = ImmutableMap.builder();
-
-    ImmutableSet.Builder<String> reservedNamesBuilder = ImmutableSet.builder();
-    for (AccountGroup.UUID uuid : all) {
-      int c = uuid.get().indexOf(':');
-      String defaultName = uuid.get().substring(c + 1).replace('-', ' ');
-      reservedNamesBuilder.add(defaultName);
-      String configuredName = cfg.getString("groups", uuid.get(), "name");
-      GroupReference ref =
-          new GroupReference(uuid, MoreObjects.firstNonNull(configuredName, defaultName));
-      n.put(ref.getName().toLowerCase(Locale.US), ref);
-      u.put(ref.getUUID(), ref);
-    }
-    reservedNames = reservedNamesBuilder.build();
-    namesToGroups = Collections.unmodifiableSortedMap(n);
-    names =
-        ImmutableSet.copyOf(namesToGroups.values().stream().map(r -> r.getName()).collect(toSet()));
-    uuids = u.build();
-  }
-
-  public GroupReference getGroup(AccountGroup.UUID uuid) {
-    return checkNotNull(uuids.get(uuid), "group %s not found", uuid.get());
-  }
-
-  public Set<String> getNames() {
-    return names;
-  }
-
-  public Set<String> getReservedNames() {
-    return reservedNames;
-  }
-
-  @Override
-  public boolean handles(AccountGroup.UUID uuid) {
-    return isSystemGroup(uuid);
-  }
-
-  @Override
-  public GroupDescription.Basic get(AccountGroup.UUID uuid) {
-    final GroupReference ref = uuids.get(uuid);
-    if (ref == null) {
-      return null;
-    }
-    return new GroupDescription.Basic() {
-      @Override
-      public String getName() {
-        return ref.getName();
-      }
-
-      @Override
-      public AccountGroup.UUID getGroupUUID() {
-        return ref.getUUID();
-      }
-
-      @Override
-      public String getUrl() {
-        return null;
-      }
-
-      @Override
-      public String getEmailAddress() {
-        return null;
-      }
-    };
-  }
-
-  @Override
-  public Collection<GroupReference> suggest(String name, ProjectState project) {
-    String nameLC = name.toLowerCase(Locale.US);
-    SortedMap<String, GroupReference> matches = namesToGroups.tailMap(nameLC);
-    if (matches.isEmpty()) {
-      return Collections.emptyList();
-    }
-
-    List<GroupReference> r = new ArrayList<>(matches.size());
-    for (Map.Entry<String, GroupReference> e : matches.entrySet()) {
-      if (e.getKey().startsWith(nameLC)) {
-        r.add(e.getValue());
-      } else {
-        break;
-      }
-    }
-    return r;
-  }
-
-  @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
-    return new ListGroupMembership(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS));
-  }
-
-  public static class NameCheck implements StartupCheck {
-    private final Config cfg;
-    private final Groups groups;
-    private final SchemaFactory<ReviewDb> schema;
-
-    @Inject
-    NameCheck(@GerritServerConfig Config cfg, Groups groups, SchemaFactory<ReviewDb> schema) {
-      this.cfg = cfg;
-      this.groups = groups;
-      this.schema = schema;
-    }
-
-    @Override
-    public void check() throws StartupException {
-      Map<AccountGroup.UUID, String> configuredNames = new HashMap<>();
-      Map<String, AccountGroup.UUID> byLowerCaseConfiguredName = new HashMap<>();
-      for (AccountGroup.UUID uuid : all) {
-        String configuredName = cfg.getString("groups", uuid.get(), "name");
-        if (configuredName != null) {
-          configuredNames.put(uuid, configuredName);
-          byLowerCaseConfiguredName.put(configuredName.toLowerCase(Locale.US), uuid);
-        }
-      }
-      if (configuredNames.isEmpty()) {
-        return;
-      }
-
-      Optional<AccountGroup> conflictingGroup;
-      try (ReviewDb db = schema.open()) {
-        conflictingGroup =
-            groups
-                .getAll(db)
-                .filter(group -> hasConfiguredName(byLowerCaseConfiguredName, group))
-                .findAny();
-
-      } catch (OrmException ignored) {
-        return;
-      }
-
-      if (conflictingGroup.isPresent()) {
-        AccountGroup group = conflictingGroup.get();
-        String groupName = group.getName();
-        AccountGroup.UUID systemGroupUuid = byLowerCaseConfiguredName.get(groupName);
-        throw new StartupException(
-            getAmbiguousNameMessage(groupName, group.getGroupUUID(), systemGroupUuid));
-      }
-    }
-
-    private static boolean hasConfiguredName(
-        Map<String, AccountGroup.UUID> byLowerCaseConfiguredName, AccountGroup group) {
-      String name = group.getName().toLowerCase(Locale.US);
-      return byLowerCaseConfiguredName.keySet().contains(name);
-    }
-
-    private static String getAmbiguousNameMessage(
-        String groupName, AccountGroup.UUID groupUuid, AccountGroup.UUID systemGroupUuid) {
-      return String.format(
-          "The configured name '%s' for system group '%s' is ambiguous"
-              + " with the name '%s' of existing group '%s'."
-              + " Please remove/change the value for groups.%s.name in"
-              + " gerrit.config.",
-          groupName, systemGroupUuid.get(), groupName, groupUuid.get(), systemGroupUuid.get());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
deleted file mode 100644
index 481726b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import com.google.gerrit.index.Index;
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.DummyChangeIndex;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.AbstractModule;
-
-public class DummyIndexModule extends AbstractModule {
-  private static class DummyChangeIndexFactory implements ChangeIndex.Factory {
-    @Override
-    public ChangeIndex create(Schema<ChangeData> schema) {
-      throw new UnsupportedOperationException();
-    }
-  }
-
-  private static class DummyAccountIndexFactory implements AccountIndex.Factory {
-    @Override
-    public AccountIndex create(Schema<AccountState> schema) {
-      throw new UnsupportedOperationException();
-    }
-  }
-
-  private static class DummyGroupIndexFactory implements GroupIndex.Factory {
-    @Override
-    public GroupIndex create(Schema<InternalGroup> schema) {
-      throw new UnsupportedOperationException();
-    }
-  }
-
-  @Override
-  protected void configure() {
-    install(new IndexModule(1));
-    bind(IndexConfig.class).toInstance(IndexConfig.createDefault());
-    bind(Index.class).toInstance(new DummyChangeIndex());
-    bind(AccountIndex.Factory.class).toInstance(new DummyAccountIndexFactory());
-    bind(ChangeIndex.Factory.class).toInstance(new DummyChangeIndexFactory());
-    bind(GroupIndex.Factory.class).toInstance(new DummyGroupIndexFactory());
-  }
-}
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
deleted file mode 100644
index c14f4db..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
+++ /dev/null
@@ -1,230 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
-import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
-
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableCollection;
-import com.google.common.collect.ImmutableList;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.index.IndexDefinition;
-import com.google.gerrit.index.SchemaDefinitions;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.index.account.AccountIndexCollection;
-import com.google.gerrit.server.index.account.AccountIndexDefinition;
-import com.google.gerrit.server.index.account.AccountIndexRewriter;
-import com.google.gerrit.server.index.account.AccountIndexer;
-import com.google.gerrit.server.index.account.AccountIndexerImpl;
-import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.index.change.ChangeIndexDefinition;
-import com.google.gerrit.server.index.change.ChangeIndexRewriter;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
-import com.google.gerrit.server.index.group.GroupIndexCollection;
-import com.google.gerrit.server.index.group.GroupIndexDefinition;
-import com.google.gerrit.server.index.group.GroupIndexRewriter;
-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.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Key;
-import com.google.inject.Provides;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import java.util.Collection;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.Config;
-
-/**
- * Module for non-indexer-specific secondary index setup.
- *
- * <p>This module should not be used directly except by specific secondary indexer implementations
- * (e.g. Lucene).
- */
-public class IndexModule extends LifecycleModule {
-  public enum IndexType {
-    LUCENE,
-    ELASTICSEARCH
-  }
-
-  public static final ImmutableCollection<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
-      ImmutableList.<SchemaDefinitions<?>>of(
-          AccountSchemaDefinitions.INSTANCE,
-          ChangeSchemaDefinitions.INSTANCE,
-          GroupSchemaDefinitions.INSTANCE);
-
-  /** Type of secondary index. */
-  public static IndexType getIndexType(Injector injector) {
-    Config cfg = injector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    return cfg.getEnum("index", null, "type", IndexType.LUCENE);
-  }
-
-  private final int threads;
-  private final ListeningExecutorService interactiveExecutor;
-  private final ListeningExecutorService batchExecutor;
-  private final boolean closeExecutorsOnShutdown;
-
-  public IndexModule(int threads) {
-    this.threads = threads;
-    this.interactiveExecutor = null;
-    this.batchExecutor = null;
-    this.closeExecutorsOnShutdown = true;
-  }
-
-  public IndexModule(
-      ListeningExecutorService interactiveExecutor, ListeningExecutorService batchExecutor) {
-    this.threads = -1;
-    this.interactiveExecutor = interactiveExecutor;
-    this.batchExecutor = batchExecutor;
-    this.closeExecutorsOnShutdown = false;
-  }
-
-  @Override
-  protected void configure() {
-    bind(AccountIndexRewriter.class);
-    bind(AccountIndexCollection.class);
-    listener().to(AccountIndexCollection.class);
-    factory(AccountIndexerImpl.Factory.class);
-
-    bind(ChangeIndexRewriter.class);
-    bind(ChangeIndexCollection.class);
-    listener().to(ChangeIndexCollection.class);
-    factory(ChangeIndexer.Factory.class);
-
-    bind(GroupIndexRewriter.class);
-    bind(GroupIndexCollection.class);
-    listener().to(GroupIndexCollection.class);
-    factory(GroupIndexerImpl.Factory.class);
-
-    if (closeExecutorsOnShutdown) {
-      // The executors must be shutdown _before_ closing the indexes.
-      // On Gerrit start the LifecycleListeners are invoked in the order in which they are
-      // registered, but on shutdown of Gerrit the order is reversed. This means the
-      // LifecycleListener to shutdown the executors must be registered _after_ the
-      // LifecycleListeners that close the indexes. The closing of the indexes is done by
-      // *IndexCollection which have been registered as LifecycleListener above. The
-      // registration of the ShutdownIndexExecutors LifecycleListener must happen afterwards.
-      listener().to(ShutdownIndexExecutors.class);
-    }
-
-    DynamicSet.setOf(binder(), OnlineUpgradeListener.class);
-  }
-
-  @Provides
-  Collection<IndexDefinition<?, ?, ?>> getIndexDefinitions(
-      AccountIndexDefinition accounts, ChangeIndexDefinition changes, GroupIndexDefinition groups) {
-    Collection<IndexDefinition<?, ?, ?>> result =
-        ImmutableList.<IndexDefinition<?, ?, ?>>of(accounts, groups, changes);
-    Set<String> expected =
-        FluentIterable.from(ALL_SCHEMA_DEFS).transform(SchemaDefinitions::getName).toSet();
-    Set<String> actual = FluentIterable.from(result).transform(IndexDefinition::getName).toSet();
-    if (!expected.equals(actual)) {
-      throw new ProvisionException(
-          "need index definitions for all schemas: " + expected + " != " + actual);
-    }
-    return result;
-  }
-
-  @Provides
-  @Singleton
-  AccountIndexer getAccountIndexer(
-      AccountIndexerImpl.Factory factory, AccountIndexCollection indexes) {
-    return factory.create(indexes);
-  }
-
-  @Provides
-  @Singleton
-  ChangeIndexer getChangeIndexer(
-      @IndexExecutor(INTERACTIVE) ListeningExecutorService executor,
-      ChangeIndexer.Factory factory,
-      ChangeIndexCollection indexes) {
-    // Bind default indexer to interactive executor; callers who need a
-    // different executor can use the factory directly.
-    return factory.create(executor, indexes);
-  }
-
-  @Provides
-  @Singleton
-  GroupIndexer getGroupIndexer(GroupIndexerImpl.Factory factory, GroupIndexCollection indexes) {
-    return factory.create(indexes);
-  }
-
-  @Provides
-  @Singleton
-  @IndexExecutor(INTERACTIVE)
-  ListeningExecutorService getInteractiveIndexExecutor(
-      @GerritServerConfig Config config, WorkQueue workQueue) {
-    if (interactiveExecutor != null) {
-      return interactiveExecutor;
-    }
-    int threads = this.threads;
-    if (threads <= 0) {
-      threads = config.getInt("index", null, "threads", 0);
-    }
-    if (threads <= 0) {
-      threads = Runtime.getRuntime().availableProcessors() / 2 + 1;
-    }
-    return MoreExecutors.listeningDecorator(workQueue.createQueue(threads, "Index-Interactive"));
-  }
-
-  @Provides
-  @Singleton
-  @IndexExecutor(BATCH)
-  ListeningExecutorService getBatchIndexExecutor(
-      @GerritServerConfig Config config, WorkQueue workQueue) {
-    if (batchExecutor != null) {
-      return batchExecutor;
-    }
-    int threads = config.getInt("index", null, "batchThreads", 0);
-    if (threads <= 0) {
-      threads = Runtime.getRuntime().availableProcessors();
-    }
-    return MoreExecutors.listeningDecorator(workQueue.createQueue(threads, "Index-Batch"));
-  }
-
-  @Singleton
-  private static class ShutdownIndexExecutors implements LifecycleListener {
-    private final ListeningExecutorService interactiveExecutor;
-    private final ListeningExecutorService batchExecutor;
-
-    @Inject
-    ShutdownIndexExecutors(
-        @IndexExecutor(INTERACTIVE) ListeningExecutorService interactiveExecutor,
-        @IndexExecutor(BATCH) ListeningExecutorService batchExecutor) {
-      this.interactiveExecutor = interactiveExecutor;
-      this.batchExecutor = batchExecutor;
-    }
-
-    @Override
-    public void start() {}
-
-    @Override
-    public void stop() {
-      MoreExecutors.shutdownAndAwaitTermination(
-          interactiveExecutor, Long.MAX_VALUE, TimeUnit.SECONDS);
-      MoreExecutors.shutdownAndAwaitTermination(batchExecutor, Long.MAX_VALUE, TimeUnit.SECONDS);
-    }
-  }
-}
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
deleted file mode 100644
index ea9900b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
-import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.account.AccountField;
-import com.google.gerrit.server.index.group.GroupField;
-import com.google.gerrit.server.query.change.SingleGroupUser;
-import java.io.IOException;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-public final class IndexUtils {
-  public static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING =
-      ImmutableMap.of("_", " ", ".", " ");
-
-  public static void setReady(SitePaths sitePaths, String name, int version, boolean ready)
-      throws IOException {
-    try {
-      GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
-      cfg.setReady(name, version, ready);
-      cfg.save();
-    } catch (ConfigInvalidException e) {
-      throw new IOException(e);
-    }
-  }
-
-  public static boolean getReady(SitePaths sitePaths, String name, int version) throws IOException {
-    try {
-      GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
-      return cfg.getReady(name, version);
-    } catch (ConfigInvalidException e) {
-      throw new IOException(e);
-    }
-  }
-
-  public static Set<String> accountFields(QueryOptions opts) {
-    Set<String> fs = opts.fields();
-    return fs.contains(AccountField.ID.getName())
-        ? fs
-        : Sets.union(fs, ImmutableSet.of(AccountField.ID.getName()));
-  }
-
-  public static Set<String> changeFields(QueryOptions opts) {
-    // Ensure we request enough fields to construct a ChangeData. We need both
-    // change ID and project, which can either come via the Change field or
-    // separate fields.
-    Set<String> fs = opts.fields();
-    if (fs.contains(CHANGE.getName())) {
-      // A Change is always sufficient.
-      return fs;
-    }
-    if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID.getName())) {
-      return fs;
-    }
-    return Sets.union(fs, ImmutableSet.of(LEGACY_ID.getName(), PROJECT.getName()));
-  }
-
-  public static Set<String> groupFields(QueryOptions opts) {
-    Set<String> fs = opts.fields();
-    return fs.contains(GroupField.UUID.getName())
-        ? fs
-        : Sets.union(fs, ImmutableSet.of(GroupField.UUID.getName()));
-  }
-
-  public static String describe(CurrentUser user) {
-    if (user.isIdentifiedUser()) {
-      return user.getAccountId().toString();
-    }
-    if (user instanceof SingleGroupUser) {
-      return "group:" + user.getEffectiveGroups().getKnownGroups().iterator().next().toString();
-    }
-    return user.toString();
-  }
-
-  private IndexUtils() {
-    // hide default constructor
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/RefState.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/RefState.java
deleted file mode 100644
index b55afb6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/RefState.java
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.auto.value.AutoValue;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Project;
-import java.io.IOException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-
-@AutoValue
-public abstract class RefState {
-  public static RefState create(String ref, String sha) {
-    return new AutoValue_RefState(ref, ObjectId.fromString(sha));
-  }
-
-  public static RefState create(String ref, @Nullable ObjectId id) {
-    return new AutoValue_RefState(ref, firstNonNull(id, ObjectId.zeroId()));
-  }
-
-  public static RefState of(Ref ref) {
-    return new AutoValue_RefState(ref.getName(), ref.getObjectId());
-  }
-
-  public byte[] toByteArray(Project.NameKey project) {
-    byte[] a = (project.toString() + ':' + ref() + ':').getBytes(UTF_8);
-    byte[] b = new byte[a.length + Constants.OBJECT_ID_STRING_LENGTH];
-    System.arraycopy(a, 0, b, 0, a.length);
-    id().copyTo(b, a.length);
-    return b;
-  }
-
-  public static void check(boolean condition, String str) {
-    checkArgument(condition, "invalid RefState: %s", str);
-  }
-
-  public abstract String ref();
-
-  public abstract ObjectId id();
-
-  public boolean match(Repository repo) throws IOException {
-    Ref ref = repo.exactRef(ref());
-    ObjectId expected = ref != null ? ref.getObjectId() : ObjectId.zeroId();
-    return id().equals(expected);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
deleted file mode 100644
index 5e12c12..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
+++ /dev/null
@@ -1,149 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.account;
-
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.integer;
-import static com.google.gerrit.index.FieldDef.prefix;
-import static com.google.gerrit.index.FieldDef.storedOnly;
-import static com.google.gerrit.index.FieldDef.timestamp;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.base.Predicates;
-import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.SchemaUtil;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.index.RefState;
-import java.sql.Timestamp;
-import java.util.Collections;
-import java.util.Locale;
-import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
-
-/** Secondary index schemas for accounts. */
-public class AccountField {
-  public static final FieldDef<AccountState, Integer> ID =
-      integer("id").stored().build(a -> a.getAccount().getId().get());
-
-  public static final FieldDef<AccountState, Iterable<String>> EXTERNAL_ID =
-      exact("external_id")
-          .buildRepeatable(a -> Iterables.transform(a.getExternalIds(), id -> id.key().get()));
-
-  /** Fuzzy prefix match on name and email parts. */
-  public static final FieldDef<AccountState, Iterable<String>> NAME_PART =
-      prefix("name")
-          .buildRepeatable(
-              a -> {
-                String fullName = a.getAccount().getFullName();
-                Set<String> parts =
-                    SchemaUtil.getNameParts(
-                        fullName, Iterables.transform(a.getExternalIds(), ExternalId::email));
-
-                // Additional values not currently added by getPersonParts.
-                // TODO(dborowitz): Move to getPersonParts and remove this hack.
-                if (fullName != null) {
-                  parts.add(fullName.toLowerCase(Locale.US));
-                }
-                return parts;
-              });
-
-  public static final FieldDef<AccountState, String> FULL_NAME =
-      exact("full_name").build(a -> a.getAccount().getFullName());
-
-  public static final FieldDef<AccountState, String> ACTIVE =
-      exact("inactive").build(a -> a.getAccount().isActive() ? "1" : "0");
-
-  public static final FieldDef<AccountState, Iterable<String>> EMAIL =
-      prefix("email")
-          .buildRepeatable(
-              a ->
-                  FluentIterable.from(a.getExternalIds())
-                      .transform(ExternalId::email)
-                      .append(Collections.singleton(a.getAccount().getPreferredEmail()))
-                      .filter(Predicates.notNull())
-                      .transform(String::toLowerCase)
-                      .toSet());
-
-  public static final FieldDef<AccountState, String> PREFERRED_EMAIL =
-      prefix("preferredemail")
-          .build(
-              a -> {
-                String preferredEmail = a.getAccount().getPreferredEmail();
-                return preferredEmail != null ? preferredEmail.toLowerCase() : null;
-              });
-
-  public static final FieldDef<AccountState, String> PREFERRED_EMAIL_EXACT =
-      exact("preferredemail_exact").build(a -> a.getAccount().getPreferredEmail());
-
-  public static final FieldDef<AccountState, Timestamp> REGISTERED =
-      timestamp("registered").build(a -> a.getAccount().getRegisteredOn());
-
-  public static final FieldDef<AccountState, String> USERNAME =
-      exact("username").build(a -> Strings.nullToEmpty(a.getUserName()).toLowerCase());
-
-  public static final FieldDef<AccountState, Iterable<String>> WATCHED_PROJECT =
-      exact("watchedproject")
-          .buildRepeatable(
-              a ->
-                  FluentIterable.from(a.getProjectWatches().keySet())
-                      .transform(k -> k.project().get())
-                      .toSet());
-
-  /**
-   * All values of all refs that were used in the course of indexing this document, except the
-   * refs/meta/external-ids notes branch which is handled specially (see {@link
-   * #EXTERNAL_ID_STATE}).
-   *
-   * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
-   */
-  public static final FieldDef<AccountState, Iterable<byte[]>> REF_STATE =
-      storedOnly("ref_state")
-          .buildRepeatable(
-              a -> {
-                if (a.getAccount().getMetaId() == null) {
-                  return ImmutableList.of();
-                }
-
-                return ImmutableList.of(
-                    RefState.create(
-                            RefNames.refsUsers(a.getAccount().getId()),
-                            ObjectId.fromString(a.getAccount().getMetaId()))
-                        .toByteArray(a.getAllUsersNameForIndexing()));
-              });
-
-  /**
-   * All note values of all external IDs that were used in the course of indexing this document.
-   *
-   * <p>Emitted as UTF-8 encoded strings of the form {@code [hex sha of external ID]:[hex sha of
-   * note blob]}, or with other words {@code [note ID]:[note data ID]}.
-   */
-  public static final FieldDef<AccountState, Iterable<byte[]>> EXTERNAL_ID_STATE =
-      storedOnly("external_id_state")
-          .buildRepeatable(
-              a ->
-                  a.getExternalIds()
-                      .stream()
-                      .filter(e -> e.blobId() != null)
-                      .map(e -> e.toByteArray())
-                      .collect(toSet()));
-
-  private AccountField() {}
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
deleted file mode 100644
index 6ec1260..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.account;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.events.AccountIndexedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.index.Index;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
-
-public class AccountIndexerImpl implements AccountIndexer {
-  public interface Factory {
-    AccountIndexerImpl create(AccountIndexCollection indexes);
-
-    AccountIndexerImpl create(@Nullable AccountIndex index);
-  }
-
-  private final AccountCache byIdCache;
-  private final DynamicSet<AccountIndexedListener> indexedListener;
-  private final AccountIndexCollection indexes;
-  private final AccountIndex index;
-
-  @AssistedInject
-  AccountIndexerImpl(
-      AccountCache byIdCache,
-      DynamicSet<AccountIndexedListener> indexedListener,
-      @Assisted AccountIndexCollection indexes) {
-    this.byIdCache = byIdCache;
-    this.indexedListener = indexedListener;
-    this.indexes = indexes;
-    this.index = null;
-  }
-
-  @AssistedInject
-  AccountIndexerImpl(
-      AccountCache byIdCache,
-      DynamicSet<AccountIndexedListener> indexedListener,
-      @Assisted AccountIndex index) {
-    this.byIdCache = byIdCache;
-    this.indexedListener = indexedListener;
-    this.indexes = null;
-    this.index = index;
-  }
-
-  @Override
-  public void index(Account.Id id) throws IOException {
-    for (Index<Account.Id, AccountState> i : getWriteIndexes()) {
-      AccountState accountState = byIdCache.getOrNull(id);
-      if (accountState != null) {
-        i.replace(accountState);
-      } else {
-        i.delete(id);
-      }
-    }
-    fireAccountIndexedEvent(id.get());
-  }
-
-  private void fireAccountIndexedEvent(int id) {
-    for (AccountIndexedListener listener : indexedListener) {
-      listener.onAccountIndexed(id);
-    }
-  }
-
-  private Collection<AccountIndex> getWriteIndexes() {
-    if (indexes != null) {
-      return indexes.getWriteIndexes();
-    }
-
-    return index != null ? Collections.singleton(index) : ImmutableSet.<AccountIndex>of();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
deleted file mode 100644
index dcdf9e3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.account;
-
-import static com.google.gerrit.index.SchemaUtil.schema;
-
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.SchemaDefinitions;
-import com.google.gerrit.server.account.AccountState;
-
-public class AccountSchemaDefinitions extends SchemaDefinitions<AccountState> {
-  @Deprecated
-  static final Schema<AccountState> V4 =
-      schema(
-          AccountField.ACTIVE,
-          AccountField.EMAIL,
-          AccountField.EXTERNAL_ID,
-          AccountField.FULL_NAME,
-          AccountField.ID,
-          AccountField.NAME_PART,
-          AccountField.REGISTERED,
-          AccountField.USERNAME,
-          AccountField.WATCHED_PROJECT);
-
-  @Deprecated static final Schema<AccountState> V5 = schema(V4, AccountField.PREFERRED_EMAIL);
-
-  @Deprecated
-  static final Schema<AccountState> V6 =
-      schema(V5, AccountField.REF_STATE, AccountField.EXTERNAL_ID_STATE);
-
-  static final Schema<AccountState> V7 = schema(V6, AccountField.PREFERRED_EMAIL_EXACT);
-
-  public static final String NAME = "accounts";
-  public static final AccountSchemaDefinitions INSTANCE = new AccountSchemaDefinitions();
-
-  private AccountSchemaDefinitions() {
-    super(NAME, AccountState.class);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
deleted file mode 100644
index 1f8b540..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
+++ /dev/null
@@ -1,794 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.change;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.fullText;
-import static com.google.gerrit.index.FieldDef.intRange;
-import static com.google.gerrit.index.FieldDef.integer;
-import static com.google.gerrit.index.FieldDef.prefix;
-import static com.google.gerrit.index.FieldDef.storedOnly;
-import static com.google.gerrit.index.FieldDef.timestamp;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableTable;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Table;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.SchemaUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.OutputFormat;
-import com.google.gerrit.server.ReviewerByEmailSet;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.index.RefState;
-import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.notedb.RobotCommentNotes;
-import com.google.gerrit.server.project.SubmitRuleOptions;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.ChangeStatusPredicate;
-import com.google.gson.Gson;
-import com.google.gwtorm.protobuf.CodecFactory;
-import com.google.gwtorm.protobuf.ProtobufCodec;
-import com.google.gwtorm.server.OrmException;
-import com.google.protobuf.CodedOutputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Locale;
-import java.util.Objects;
-import java.util.Set;
-import java.util.function.Function;
-import java.util.stream.Stream;
-import org.eclipse.jgit.lib.PersonIdent;
-
-/**
- * Fields indexed on change documents.
- *
- * <p>Each field corresponds to both a field name supported by {@link ChangeQueryBuilder} for
- * querying that field, and a method on {@link ChangeData} used for populating the corresponding
- * document fields in the secondary index.
- *
- * <p>Field names are all lowercase alphanumeric plus underscore; index implementations may create
- * unambiguous derived field names containing other characters.
- */
-public class ChangeField {
-  public static final int NO_ASSIGNEE = -1;
-
-  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
-
-  /** Legacy change ID. */
-  public static final FieldDef<ChangeData, Integer> LEGACY_ID =
-      integer("legacy_id").stored().build(cd -> cd.getId().get());
-
-  /** Newer style Change-Id key. */
-  public static final FieldDef<ChangeData, String> ID =
-      prefix(ChangeQueryBuilder.FIELD_CHANGE_ID).build(changeGetter(c -> c.getKey().get()));
-
-  /** Change status string, in the same format as {@code status:}. */
-  public static final FieldDef<ChangeData, String> STATUS =
-      exact(ChangeQueryBuilder.FIELD_STATUS)
-          .build(changeGetter(c -> ChangeStatusPredicate.canonicalize(c.getStatus())));
-
-  /** Project containing the change. */
-  public static final FieldDef<ChangeData, String> PROJECT =
-      exact(ChangeQueryBuilder.FIELD_PROJECT)
-          .stored()
-          .build(changeGetter(c -> c.getProject().get()));
-
-  /** Project containing the change, as a prefix field. */
-  public static final FieldDef<ChangeData, String> PROJECTS =
-      prefix(ChangeQueryBuilder.FIELD_PROJECTS).build(changeGetter(c -> c.getProject().get()));
-
-  /** Reference (aka branch) the change will submit onto. */
-  public static final FieldDef<ChangeData, String> REF =
-      exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().get()));
-
-  /** Topic, a short annotation on the branch. */
-  public static final FieldDef<ChangeData, String> EXACT_TOPIC =
-      exact("topic4").build(ChangeField::getTopic);
-
-  /** Topic, a short annotation on the branch. */
-  public static final FieldDef<ChangeData, String> FUZZY_TOPIC =
-      fullText("topic5").build(ChangeField::getTopic);
-
-  /** Submission id assigned by MergeOp. */
-  public static final FieldDef<ChangeData, String> SUBMISSIONID =
-      exact(ChangeQueryBuilder.FIELD_SUBMISSIONID).build(changeGetter(Change::getSubmissionId));
-
-  /** Last update time since January 1, 1970. */
-  public static final FieldDef<ChangeData, Timestamp> UPDATED =
-      timestamp("updated2").stored().build(changeGetter(Change::getLastUpdatedOn));
-
-  /** List of full file paths modified in the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> PATH =
-      // Named for backwards compatibility.
-      exact(ChangeQueryBuilder.FIELD_FILE)
-          .buildRepeatable(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
-
-  public static Set<String> getFileParts(ChangeData cd) throws OrmException {
-    List<String> paths;
-    try {
-      paths = cd.currentFilePaths();
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-
-    Splitter s = Splitter.on('/').omitEmptyStrings();
-    Set<String> r = new HashSet<>();
-    for (String path : paths) {
-      for (String part : s.split(path)) {
-        r.add(part);
-      }
-    }
-    return r;
-  }
-
-  /** Hashtags tied to a change */
-  public static final FieldDef<ChangeData, Iterable<String>> HASHTAG =
-      exact(ChangeQueryBuilder.FIELD_HASHTAG)
-          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
-
-  /** Hashtags with original case. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
-      storedOnly("_hashtag")
-          .buildRepeatable(
-              cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()));
-
-  /** Components of each file path modified in the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> FILE_PART =
-      exact(ChangeQueryBuilder.FIELD_FILEPART).buildRepeatable(ChangeField::getFileParts);
-
-  /** Owner/creator of the change. */
-  public static final FieldDef<ChangeData, Integer> OWNER =
-      integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get()));
-
-  /** The user assigned to the change. */
-  public static final FieldDef<ChangeData, Integer> ASSIGNEE =
-      integer(ChangeQueryBuilder.FIELD_ASSIGNEE)
-          .build(changeGetter(c -> c.getAssignee() != null ? c.getAssignee().get() : NO_ASSIGNEE));
-
-  /** Reviewer(s) associated with the change. */
-  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
-      exact("reviewer2").stored().buildRepeatable(cd -> getReviewerFieldValues(cd.reviewers()));
-
-  /** Reviewer(s) associated with the change that do not have a gerrit account. */
-  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL =
-      exact("reviewer_by_email")
-          .stored()
-          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()));
-
-  /** Reviewer(s) modified during change's current WIP phase. */
-  public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER =
-      exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER)
-          .stored()
-          .buildRepeatable(cd -> getReviewerFieldValues(cd.pendingReviewers()));
-
-  /** Reviewer(s) by email modified during change's current WIP phase. */
-  public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL =
-      exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL)
-          .stored()
-          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()));
-
-  /** References a change that this change reverts. */
-  public static final FieldDef<ChangeData, Integer> REVERT_OF =
-      integer(ChangeQueryBuilder.FIELD_REVERTOF)
-          .build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null);
-
-  @VisibleForTesting
-  static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
-    List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
-    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> c :
-        reviewers.asTable().cellSet()) {
-      String v = getReviewerFieldValue(c.getRowKey(), c.getColumnKey());
-      r.add(v);
-      r.add(v + ',' + c.getValue().getTime());
-    }
-    return r;
-  }
-
-  public static String getReviewerFieldValue(ReviewerStateInternal state, Account.Id id) {
-    return state.toString() + ',' + id;
-  }
-
-  @VisibleForTesting
-  static List<String> getReviewerByEmailFieldValues(ReviewerByEmailSet reviewersByEmail) {
-    List<String> r = new ArrayList<>(reviewersByEmail.asTable().size() * 2);
-    for (Table.Cell<ReviewerStateInternal, Address, Timestamp> c :
-        reviewersByEmail.asTable().cellSet()) {
-      String v = getReviewerByEmailFieldValue(c.getRowKey(), c.getColumnKey());
-      r.add(v);
-      if (c.getColumnKey().getName() != null) {
-        // Add another entry without the name to provide search functionality on the email
-        Address emailOnly = new Address(c.getColumnKey().getEmail());
-        r.add(getReviewerByEmailFieldValue(c.getRowKey(), emailOnly));
-      }
-      r.add(v + ',' + c.getValue().getTime());
-    }
-    return r;
-  }
-
-  public static String getReviewerByEmailFieldValue(ReviewerStateInternal state, Address adr) {
-    return state.toString() + ',' + adr;
-  }
-
-  public static ReviewerSet parseReviewerFieldValues(Iterable<String> values) {
-    ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
-        ImmutableTable.builder();
-    for (String v : values) {
-      int f = v.indexOf(',');
-      if (f < 0) {
-        continue;
-      }
-      int l = v.lastIndexOf(',');
-      if (l == f) {
-        continue;
-      }
-      b.put(
-          ReviewerStateInternal.valueOf(v.substring(0, f)),
-          Account.Id.parse(v.substring(f + 1, l)),
-          new Timestamp(Long.valueOf(v.substring(l + 1, v.length()))));
-    }
-    return ReviewerSet.fromTable(b.build());
-  }
-
-  public static ReviewerByEmailSet parseReviewerByEmailFieldValues(Iterable<String> values) {
-    ImmutableTable.Builder<ReviewerStateInternal, Address, Timestamp> b = ImmutableTable.builder();
-    for (String v : values) {
-      int f = v.indexOf(',');
-      if (f < 0) {
-        continue;
-      }
-      int l = v.lastIndexOf(',');
-      if (l == f) {
-        continue;
-      }
-      b.put(
-          ReviewerStateInternal.valueOf(v.substring(0, f)),
-          Address.parse(v.substring(f + 1, l)),
-          new Timestamp(Long.valueOf(v.substring(l + 1, v.length()))));
-    }
-    return ReviewerByEmailSet.fromTable(b.build());
-  }
-
-  /** Commit ID of any patch set on the change, using prefix match. */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
-      prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions);
-
-  /** Commit ID of any patch set on the change, using exact match. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMIT =
-      exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT).buildRepeatable(ChangeField::getRevisions);
-
-  private static Set<String> getRevisions(ChangeData cd) throws OrmException {
-    Set<String> revisions = new HashSet<>();
-    for (PatchSet ps : cd.patchSets()) {
-      if (ps.getRevision() != null) {
-        revisions.add(ps.getRevision().get());
-      }
-    }
-    return revisions;
-  }
-
-  /** Tracking id extracted from a footer. */
-  public static final FieldDef<ChangeData, Iterable<String>> TR =
-      exact(ChangeQueryBuilder.FIELD_TR)
-          .buildRepeatable(cd -> ImmutableSet.copyOf(cd.trackingFooters().values()));
-
-  /** List of labels on the current patch set including change owner votes. */
-  public static final FieldDef<ChangeData, Iterable<String>> LABEL =
-      exact("label2").buildRepeatable(cd -> getLabels(cd, true));
-
-  private static Iterable<String> getLabels(ChangeData cd, boolean owners) throws OrmException {
-    Set<String> allApprovals = new HashSet<>();
-    Set<String> distinctApprovals = new HashSet<>();
-    for (PatchSetApproval a : cd.currentApprovals()) {
-      if (a.getValue() != 0 && !a.isLegacySubmit()) {
-        allApprovals.add(formatLabel(a.getLabel(), a.getValue(), a.getAccountId()));
-        if (owners && cd.change().getOwner().equals(a.getAccountId())) {
-          allApprovals.add(
-              formatLabel(a.getLabel(), a.getValue(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
-        }
-        distinctApprovals.add(formatLabel(a.getLabel(), a.getValue()));
-      }
-    }
-    allApprovals.addAll(distinctApprovals);
-    return allApprovals;
-  }
-
-  public static Set<String> getAuthorParts(ChangeData cd) throws OrmException, IOException {
-    return SchemaUtil.getPersonParts(cd.getAuthor());
-  }
-
-  public static Set<String> getAuthorNameAndEmail(ChangeData cd) throws OrmException, IOException {
-    return getNameAndEmail(cd.getAuthor());
-  }
-
-  public static Set<String> getCommitterParts(ChangeData cd) throws OrmException, IOException {
-    return SchemaUtil.getPersonParts(cd.getCommitter());
-  }
-
-  public static Set<String> getCommitterNameAndEmail(ChangeData cd)
-      throws OrmException, IOException {
-    return getNameAndEmail(cd.getCommitter());
-  }
-
-  private static Set<String> getNameAndEmail(PersonIdent person) {
-    if (person == null) {
-      return ImmutableSet.of();
-    }
-
-    String name = person.getName().toLowerCase(Locale.US);
-    String email = person.getEmailAddress().toLowerCase(Locale.US);
-
-    StringBuilder nameEmailBuilder = new StringBuilder();
-    PersonIdent.appendSanitized(nameEmailBuilder, name);
-    nameEmailBuilder.append(" <");
-    PersonIdent.appendSanitized(nameEmailBuilder, email);
-    nameEmailBuilder.append('>');
-
-    return ImmutableSet.of(name, email, nameEmailBuilder.toString());
-  }
-
-  /**
-   * The exact email address, or any part of the author name or email address, in the current patch
-   * set.
-   */
-  public static final FieldDef<ChangeData, Iterable<String>> AUTHOR =
-      fullText(ChangeQueryBuilder.FIELD_AUTHOR).buildRepeatable(ChangeField::getAuthorParts);
-
-  /** The exact name, email address and NameEmail of the author. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXACT_AUTHOR =
-      exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR)
-          .buildRepeatable(ChangeField::getAuthorNameAndEmail);
-
-  /**
-   * The exact email address, or any part of the committer name or email address, in the current
-   * patch set.
-   */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMITTER =
-      fullText(ChangeQueryBuilder.FIELD_COMMITTER).buildRepeatable(ChangeField::getCommitterParts);
-
-  /** The exact name, email address, and NameEmail of the committer. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMITTER =
-      exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER)
-          .buildRepeatable(ChangeField::getCommitterNameAndEmail);
-
-  public static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
-
-  /** Serialized change object, used for pre-populating results. */
-  public static final FieldDef<ChangeData, byte[]> CHANGE =
-      storedOnly("_change").build(changeGetter(CHANGE_CODEC::encodeToByteArray));
-
-  public static final ProtobufCodec<PatchSetApproval> APPROVAL_CODEC =
-      CodecFactory.encoder(PatchSetApproval.class);
-
-  /** Serialized approvals for the current patch set, used for pre-populating results. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> APPROVAL =
-      storedOnly("_approval")
-          .buildRepeatable(cd -> toProtos(APPROVAL_CODEC, cd.currentApprovals()));
-
-  public static String formatLabel(String label, int value) {
-    return formatLabel(label, value, null);
-  }
-
-  public static String formatLabel(String label, int value, Account.Id accountId) {
-    return label.toLowerCase()
-        + (value >= 0 ? "+" : "")
-        + value
-        + (accountId != null ? "," + formatAccount(accountId) : "");
-  }
-
-  private static String formatAccount(Account.Id accountId) {
-    if (ChangeQueryBuilder.OWNER_ACCOUNT_ID.equals(accountId)) {
-      return ChangeQueryBuilder.ARG_ID_OWNER;
-    }
-    return Integer.toString(accountId.get());
-  }
-
-  /** Commit message of the current patch set. */
-  public static final FieldDef<ChangeData, String> COMMIT_MESSAGE =
-      fullText(ChangeQueryBuilder.FIELD_MESSAGE).build(ChangeData::commitMessage);
-
-  /** Summary or inline comment. */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
-      fullText(ChangeQueryBuilder.FIELD_COMMENT)
-          .buildRepeatable(
-              cd ->
-                  Stream.concat(
-                          cd.publishedComments().stream().map(c -> c.message),
-                          cd.messages().stream().map(ChangeMessage::getMessage))
-                      .collect(toSet()));
-
-  /** Number of unresolved comments of the change. */
-  public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT =
-      intRange(ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT)
-          .stored()
-          .build(ChangeData::unresolvedCommentCount);
-
-  /** Whether the change is mergeable. */
-  public static final FieldDef<ChangeData, String> MERGEABLE =
-      exact(ChangeQueryBuilder.FIELD_MERGEABLE)
-          .stored()
-          .build(
-              cd -> {
-                Boolean m = cd.isMergeable();
-                if (m == null) {
-                  return null;
-                }
-                return m ? "1" : "0";
-              });
-
-  /** The number of inserted lines in this change. */
-  public static final FieldDef<ChangeData, Integer> ADDED =
-      intRange(ChangeQueryBuilder.FIELD_ADDED)
-          .stored()
-          .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null);
-
-  /** The number of deleted lines in this change. */
-  public static final FieldDef<ChangeData, Integer> DELETED =
-      intRange(ChangeQueryBuilder.FIELD_DELETED)
-          .stored()
-          .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null);
-
-  /** The total number of modified lines in this change. */
-  public static final FieldDef<ChangeData, Integer> DELTA =
-      intRange(ChangeQueryBuilder.FIELD_DELTA)
-          .build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null));
-
-  /** Determines if this change is private. */
-  public static final FieldDef<ChangeData, String> PRIVATE =
-      exact(ChangeQueryBuilder.FIELD_PRIVATE).build(cd -> cd.change().isPrivate() ? "1" : "0");
-
-  /** Determines if this change is work in progress. */
-  public static final FieldDef<ChangeData, String> WIP =
-      exact(ChangeQueryBuilder.FIELD_WIP).build(cd -> cd.change().isWorkInProgress() ? "1" : "0");
-
-  /** Determines if this change has started review. */
-  public static final FieldDef<ChangeData, String> STARTED =
-      exact(ChangeQueryBuilder.FIELD_STARTED)
-          .build(cd -> cd.change().hasReviewStarted() ? "1" : "0");
-
-  /** Users who have commented on this change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY =
-      integer(ChangeQueryBuilder.FIELD_COMMENTBY)
-          .buildRepeatable(
-              cd ->
-                  Stream.concat(
-                          cd.messages().stream().map(ChangeMessage::getAuthor),
-                          cd.publishedComments().stream().map(c -> c.author.getId()))
-                      .filter(Objects::nonNull)
-                      .map(Account.Id::get)
-                      .collect(toSet()));
-
-  /** Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt; */
-  public static final FieldDef<ChangeData, Iterable<String>> STAR =
-      exact(ChangeQueryBuilder.FIELD_STAR)
-          .stored()
-          .buildRepeatable(
-              cd ->
-                  Iterables.transform(
-                      cd.stars().entries(),
-                      e ->
-                          StarredChangesUtil.StarField.create(e.getKey(), e.getValue())
-                              .toString()));
-
-  /** Users that have starred the change with any label. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> STARBY =
-      integer(ChangeQueryBuilder.FIELD_STARBY)
-          .buildRepeatable(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get));
-
-  /** Opaque group identifiers for this change's patch sets. */
-  public static final FieldDef<ChangeData, Iterable<String>> GROUP =
-      exact(ChangeQueryBuilder.FIELD_GROUP)
-          .buildRepeatable(
-              cd ->
-                  cd.patchSets().stream().flatMap(ps -> ps.getGroups().stream()).collect(toSet()));
-
-  public static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
-      CodecFactory.encoder(PatchSet.class);
-
-  /** Serialized patch set object, used for pre-populating results. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
-      storedOnly("_patch_set").buildRepeatable(cd -> toProtos(PATCH_SET_CODEC, cd.patchSets()));
-
-  /** Users who have edits on this change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
-      integer(ChangeQueryBuilder.FIELD_EDITBY)
-          .buildRepeatable(cd -> cd.editsByUser().stream().map(Account.Id::get).collect(toSet()));
-
-  /** Users who have draft comments on this change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> DRAFTBY =
-      integer(ChangeQueryBuilder.FIELD_DRAFTBY)
-          .buildRepeatable(cd -> cd.draftsByUser().stream().map(Account.Id::get).collect(toSet()));
-
-  public static final Integer NOT_REVIEWED = -1;
-
-  /**
-   * Users the change was reviewed by since the last author update.
-   *
-   * <p>A change is considered reviewed by a user if the latest update by that user is newer than
-   * the latest update by the change author. Both top-level change messages and new patch sets are
-   * considered to be updates.
-   *
-   * <p>If the latest update is by the change owner, then the special value {@link #NOT_REVIEWED} is
-   * emitted.
-   */
-  public static final FieldDef<ChangeData, Iterable<Integer>> REVIEWEDBY =
-      integer(ChangeQueryBuilder.FIELD_REVIEWEDBY)
-          .stored()
-          .buildRepeatable(
-              cd -> {
-                Set<Account.Id> reviewedBy = cd.reviewedBy();
-                if (reviewedBy.isEmpty()) {
-                  return ImmutableSet.of(NOT_REVIEWED);
-                }
-                return reviewedBy.stream().map(Account.Id::get).collect(toList());
-              });
-
-  // Submit rule options in this class should never use fastEvalLabels. This
-  // slows down indexing slightly but produces correct search results.
-  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
-      SubmitRuleOptions.defaults().allowClosed(true).build();
-
-  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
-      SubmitRuleOptions.defaults().build();
-
-  /**
-   * JSON type for storing SubmitRecords.
-   *
-   * <p>Stored fields need to use a stable format over a long period; this type insulates the index
-   * from implementation changes in SubmitRecord itself.
-   */
-  static class StoredSubmitRecord {
-    static class StoredLabel {
-      String label;
-      SubmitRecord.Label.Status status;
-      Integer appliedBy;
-    }
-
-    SubmitRecord.Status status;
-    List<StoredLabel> labels;
-    String errorMessage;
-
-    StoredSubmitRecord(SubmitRecord rec) {
-      this.status = rec.status;
-      this.errorMessage = rec.errorMessage;
-      if (rec.labels != null) {
-        this.labels = new ArrayList<>(rec.labels.size());
-        for (SubmitRecord.Label label : rec.labels) {
-          StoredLabel sl = new StoredLabel();
-          sl.label = label.label;
-          sl.status = label.status;
-          sl.appliedBy = label.appliedBy != null ? label.appliedBy.get() : null;
-          this.labels.add(sl);
-        }
-      }
-    }
-
-    private SubmitRecord toSubmitRecord() {
-      SubmitRecord rec = new SubmitRecord();
-      rec.status = status;
-      rec.errorMessage = errorMessage;
-      if (labels != null) {
-        rec.labels = new ArrayList<>(labels.size());
-        for (StoredLabel label : labels) {
-          SubmitRecord.Label srl = new SubmitRecord.Label();
-          srl.label = label.label;
-          srl.status = label.status;
-          srl.appliedBy = label.appliedBy != null ? new Account.Id(label.appliedBy) : null;
-          rec.labels.add(srl);
-        }
-      }
-      return rec;
-    }
-  }
-
-  public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RECORD =
-      exact("submit_record").buildRepeatable(cd -> formatSubmitRecordValues(cd));
-
-  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT =
-      storedOnly("full_submit_record_strict")
-          .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT));
-
-  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_LENIENT =
-      storedOnly("full_submit_record_lenient")
-          .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT));
-
-  public static void parseSubmitRecords(
-      Collection<String> values, SubmitRuleOptions opts, ChangeData out) {
-    checkArgument(!opts.fastEvalLabels());
-    List<SubmitRecord> records = parseSubmitRecords(values);
-    if (records.isEmpty()) {
-      // Assume no values means the field is not in the index;
-      // SubmitRuleEvaluator ensures the list is non-empty.
-      return;
-    }
-    out.setSubmitRecords(opts, records);
-
-    // Cache the fastEvalLabels variant as well so it can be used by
-    // ChangeJson.
-    out.setSubmitRecords(opts.toBuilder().fastEvalLabels(true).build(), records);
-  }
-
-  @VisibleForTesting
-  static List<SubmitRecord> parseSubmitRecords(Collection<String> values) {
-    return values
-        .stream()
-        .map(v -> GSON.fromJson(v, StoredSubmitRecord.class).toSubmitRecord())
-        .collect(toList());
-  }
-
-  @VisibleForTesting
-  static List<byte[]> storedSubmitRecords(List<SubmitRecord> records) {
-    return Lists.transform(records, r -> GSON.toJson(new StoredSubmitRecord(r)).getBytes(UTF_8));
-  }
-
-  private static Iterable<byte[]> storedSubmitRecords(ChangeData cd, SubmitRuleOptions opts)
-      throws OrmException {
-    return storedSubmitRecords(cd.submitRecords(opts));
-  }
-
-  public static List<String> formatSubmitRecordValues(ChangeData cd) throws OrmException {
-    return formatSubmitRecordValues(
-        cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT), cd.change().getOwner());
-  }
-
-  @VisibleForTesting
-  static List<String> formatSubmitRecordValues(List<SubmitRecord> records, Account.Id changeOwner) {
-    List<String> result = new ArrayList<>();
-    for (SubmitRecord rec : records) {
-      result.add(rec.status.name());
-      if (rec.labels == null) {
-        continue;
-      }
-      for (SubmitRecord.Label label : rec.labels) {
-        String sl = label.status.toString() + ',' + label.label.toLowerCase();
-        result.add(sl);
-        String slc = sl + ',';
-        if (label.appliedBy != null) {
-          result.add(slc + label.appliedBy.get());
-          if (label.appliedBy.equals(changeOwner)) {
-            result.add(slc + ChangeQueryBuilder.OWNER_ACCOUNT_ID.get());
-          }
-        }
-      }
-    }
-    return result;
-  }
-
-  /**
-   * All values of all refs that were used in the course of indexing this document.
-   *
-   * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
-   */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE =
-      storedOnly("ref_state")
-          .buildRepeatable(
-              cd -> {
-                List<byte[]> result = new ArrayList<>();
-                Project.NameKey project = cd.change().getProject();
-
-                cd.editRefs()
-                    .values()
-                    .forEach(r -> result.add(RefState.of(r).toByteArray(project)));
-                cd.starRefs()
-                    .values()
-                    .forEach(r -> result.add(RefState.of(r.ref()).toByteArray(allUsers(cd))));
-
-                if (PrimaryStorage.of(cd.change()) == PrimaryStorage.NOTE_DB) {
-                  ChangeNotes notes = cd.notes();
-                  result.add(
-                      RefState.create(notes.getRefName(), notes.getMetaId()).toByteArray(project));
-                  notes.getRobotComments(); // Force loading robot comments.
-                  RobotCommentNotes robotNotes = notes.getRobotCommentNotes();
-                  result.add(
-                      RefState.create(robotNotes.getRefName(), robotNotes.getMetaId())
-                          .toByteArray(project));
-                  cd.draftRefs()
-                      .values()
-                      .forEach(r -> result.add(RefState.of(r).toByteArray(allUsers(cd))));
-                }
-
-                return result;
-              });
-
-  /**
-   * All ref wildcard patterns that were used in the course of indexing this document.
-   *
-   * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name/*}. See {@link
-   * RefStatePattern} for the pattern format.
-   */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE_PATTERN =
-      storedOnly("ref_state_pattern")
-          .buildRepeatable(
-              cd -> {
-                Change.Id id = cd.getId();
-                Project.NameKey project = cd.change().getProject();
-                List<byte[]> result = new ArrayList<>(3);
-                result.add(
-                    RefStatePattern.create(
-                            RefNames.REFS_USERS + "*/" + RefNames.EDIT_PREFIX + id + "/*")
-                        .toByteArray(project));
-                result.add(
-                    RefStatePattern.create(RefNames.refsStarredChangesPrefix(id) + "*")
-                        .toByteArray(allUsers(cd)));
-                if (PrimaryStorage.of(cd.change()) == PrimaryStorage.NOTE_DB) {
-                  result.add(
-                      RefStatePattern.create(RefNames.refsDraftCommentsPrefix(id) + "*")
-                          .toByteArray(allUsers(cd)));
-                }
-                return result;
-              });
-
-  private static String getTopic(ChangeData cd) throws OrmException {
-    Change c = cd.change();
-    if (c == null) {
-      return null;
-    }
-    return firstNonNull(c.getTopic(), "");
-  }
-
-  private static <T> List<byte[]> toProtos(ProtobufCodec<T> codec, Collection<T> objs)
-      throws OrmException {
-    List<byte[]> result = Lists.newArrayListWithCapacity(objs.size());
-    ByteArrayOutputStream out = new ByteArrayOutputStream(256);
-    try {
-      for (T obj : objs) {
-        out.reset();
-        CodedOutputStream cos = CodedOutputStream.newInstance(out);
-        codec.encode(obj, cos);
-        cos.flush();
-        result.add(out.toByteArray());
-      }
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-    return result;
-  }
-
-  private static <T> FieldDef.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
-    return in -> in.change() != null ? func.apply(in.change()) : null;
-  }
-
-  private static AllUsersName allUsers(ChangeData cd) {
-    return cd.getAllUsersNameForIndexing();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
deleted file mode 100644
index f62b662..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ /dev/null
@@ -1,480 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.change;
-
-import static com.google.gerrit.server.extensions.events.EventUtil.logEventListenerError;
-import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
-
-import com.google.common.base.Function;
-import com.google.common.util.concurrent.Atomics;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.extensions.events.ChangeIndexedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.index.Index;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.OutOfScopeException;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import com.google.inject.util.Providers;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Future;
-import java.util.concurrent.atomic.AtomicReference;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Helper for (re)indexing a change document.
- *
- * <p>Indexing is run in the background, as it may require substantial work to compute some of the
- * fields and/or update the index.
- */
-public class ChangeIndexer {
-  private static final Logger log = LoggerFactory.getLogger(ChangeIndexer.class);
-
-  public interface Factory {
-    ChangeIndexer create(ListeningExecutorService executor, ChangeIndex index);
-
-    ChangeIndexer create(ListeningExecutorService executor, ChangeIndexCollection indexes);
-  }
-
-  @SuppressWarnings("deprecation")
-  public static com.google.common.util.concurrent.CheckedFuture<?, IOException> allAsList(
-      List<? extends ListenableFuture<?>> futures) {
-    // allAsList propagates the first seen exception, wrapped in
-    // ExecutionException, so we can reuse the same mapper as for a single
-    // future. Assume the actual contents of the exception are not useful to
-    // callers. All exceptions are already logged by IndexTask.
-    return Futures.makeChecked(Futures.allAsList(futures), MAPPER);
-  }
-
-  private static final Function<Exception, IOException> MAPPER =
-      new Function<Exception, IOException>() {
-        @Override
-        public IOException apply(Exception in) {
-          if (in instanceof IOException) {
-            return (IOException) in;
-          } else if (in instanceof ExecutionException && in.getCause() instanceof IOException) {
-            return (IOException) in.getCause();
-          } else {
-            return new IOException(in);
-          }
-        }
-      };
-
-  private final ChangeIndexCollection indexes;
-  private final ChangeIndex index;
-  private final SchemaFactory<ReviewDb> schemaFactory;
-  private final NotesMigration notesMigration;
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeData.Factory changeDataFactory;
-  private final ThreadLocalRequestContext context;
-  private final ListeningExecutorService batchExecutor;
-  private final ListeningExecutorService executor;
-  private final DynamicSet<ChangeIndexedListener> indexedListeners;
-  private final StalenessChecker stalenessChecker;
-  private final boolean autoReindexIfStale;
-
-  @AssistedInject
-  ChangeIndexer(
-      @GerritServerConfig Config cfg,
-      SchemaFactory<ReviewDb> schemaFactory,
-      NotesMigration notesMigration,
-      ChangeNotes.Factory changeNotesFactory,
-      ChangeData.Factory changeDataFactory,
-      ThreadLocalRequestContext context,
-      DynamicSet<ChangeIndexedListener> indexedListeners,
-      StalenessChecker stalenessChecker,
-      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
-      @Assisted ListeningExecutorService executor,
-      @Assisted ChangeIndex index) {
-    this.executor = executor;
-    this.schemaFactory = schemaFactory;
-    this.notesMigration = notesMigration;
-    this.changeNotesFactory = changeNotesFactory;
-    this.changeDataFactory = changeDataFactory;
-    this.context = context;
-    this.indexedListeners = indexedListeners;
-    this.stalenessChecker = stalenessChecker;
-    this.batchExecutor = batchExecutor;
-    this.autoReindexIfStale = autoReindexIfStale(cfg);
-    this.index = index;
-    this.indexes = null;
-  }
-
-  @AssistedInject
-  ChangeIndexer(
-      SchemaFactory<ReviewDb> schemaFactory,
-      @GerritServerConfig Config cfg,
-      NotesMigration notesMigration,
-      ChangeNotes.Factory changeNotesFactory,
-      ChangeData.Factory changeDataFactory,
-      ThreadLocalRequestContext context,
-      DynamicSet<ChangeIndexedListener> indexedListeners,
-      StalenessChecker stalenessChecker,
-      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
-      @Assisted ListeningExecutorService executor,
-      @Assisted ChangeIndexCollection indexes) {
-    this.executor = executor;
-    this.schemaFactory = schemaFactory;
-    this.notesMigration = notesMigration;
-    this.changeNotesFactory = changeNotesFactory;
-    this.changeDataFactory = changeDataFactory;
-    this.context = context;
-    this.indexedListeners = indexedListeners;
-    this.stalenessChecker = stalenessChecker;
-    this.batchExecutor = batchExecutor;
-    this.autoReindexIfStale = autoReindexIfStale(cfg);
-    this.index = null;
-    this.indexes = indexes;
-  }
-
-  private static boolean autoReindexIfStale(Config cfg) {
-    return cfg.getBoolean("index", null, "autoReindexIfStale", true);
-  }
-
-  /**
-   * Start indexing a change.
-   *
-   * @param id change to index.
-   * @return future for the indexing task.
-   */
-  @SuppressWarnings("deprecation")
-  public com.google.common.util.concurrent.CheckedFuture<?, IOException> indexAsync(
-      Project.NameKey project, Change.Id id) {
-    return submit(new IndexTask(project, id));
-  }
-
-  /**
-   * Start indexing multiple changes in parallel.
-   *
-   * @param ids changes to index.
-   * @return future for completing indexing of all changes.
-   */
-  @SuppressWarnings("deprecation")
-  public com.google.common.util.concurrent.CheckedFuture<?, IOException> indexAsync(
-      Project.NameKey project, Collection<Change.Id> ids) {
-    List<ListenableFuture<?>> futures = new ArrayList<>(ids.size());
-    for (Change.Id id : ids) {
-      futures.add(indexAsync(project, id));
-    }
-    return allAsList(futures);
-  }
-
-  /**
-   * Synchronously index a change.
-   *
-   * @param cd change to index.
-   */
-  public void index(ChangeData cd) throws IOException {
-    for (Index<?, ChangeData> i : getWriteIndexes()) {
-      i.replace(cd);
-    }
-    fireChangeIndexedEvent(cd.getId().get());
-
-    // Always double-check whether the change might be stale immediately after
-    // interactively indexing it. This fixes up the case where two writers write
-    // to the primary storage in one order, and the corresponding index writes
-    // happen in the opposite order:
-    //  1. Writer A writes to primary storage.
-    //  2. Writer B writes to primary storage.
-    //  3. Writer B updates index.
-    //  4. Writer A updates index.
-    //
-    // Without the extra reindexIfStale step, A has no way of knowing that it's
-    // about to overwrite the index document with stale data. It doesn't work to
-    // have A check for staleness before attempting its index update, because
-    // B's index update might not have happened when it does the check.
-    //
-    // With the extra reindexIfStale step after (3)/(4), we are able to detect
-    // and fix the staleness. It doesn't matter which order the two
-    // reindexIfStale calls actually execute in; we are guaranteed that at least
-    // one of them will execute after the second index write, (4).
-    autoReindexIfStale(cd);
-  }
-
-  private void fireChangeIndexedEvent(int id) {
-    for (ChangeIndexedListener listener : indexedListeners) {
-      try {
-        listener.onChangeIndexed(id);
-      } catch (Exception e) {
-        logEventListenerError(listener, e);
-      }
-    }
-  }
-
-  private void fireChangeDeletedFromIndexEvent(int id) {
-    for (ChangeIndexedListener listener : indexedListeners) {
-      try {
-        listener.onChangeDeleted(id);
-      } catch (Exception e) {
-        logEventListenerError(listener, e);
-      }
-    }
-  }
-
-  /**
-   * Synchronously index a change.
-   *
-   * @param db review database.
-   * @param change change to index.
-   */
-  public void index(ReviewDb db, Change change) throws IOException, OrmException {
-    index(newChangeData(db, change));
-    // See comment in #index(ChangeData).
-    autoReindexIfStale(change.getProject(), change.getId());
-  }
-
-  /**
-   * Synchronously index a change.
-   *
-   * @param db review database.
-   * @param project the project to which the change belongs.
-   * @param changeId ID of the change to index.
-   */
-  public void index(ReviewDb db, Project.NameKey project, Change.Id changeId)
-      throws IOException, OrmException {
-    ChangeData cd = newChangeData(db, project, changeId);
-    index(cd);
-    // See comment in #index(ChangeData).
-    autoReindexIfStale(cd);
-  }
-
-  /**
-   * Start deleting a change.
-   *
-   * @param id change to delete.
-   * @return future for the deleting task.
-   */
-  @SuppressWarnings("deprecation")
-  public com.google.common.util.concurrent.CheckedFuture<?, IOException> deleteAsync(Change.Id id) {
-    return submit(new DeleteTask(id));
-  }
-
-  /**
-   * Synchronously delete a change.
-   *
-   * @param id change ID to delete.
-   */
-  public void delete(Change.Id id) throws IOException {
-    new DeleteTask(id).call();
-  }
-
-  /**
-   * Asynchronously check if a change is stale, and reindex if it is.
-   *
-   * <p>Always run on the batch executor, even if this indexer instance is configured to use a
-   * different executor.
-   *
-   * @param project the project to which the change belongs.
-   * @param id ID of the change to index.
-   * @return future for reindexing the change; returns true if the change was stale.
-   */
-  @SuppressWarnings("deprecation")
-  public com.google.common.util.concurrent.CheckedFuture<Boolean, IOException> reindexIfStale(
-      Project.NameKey project, Change.Id id) {
-    return submit(new ReindexIfStaleTask(project, id), batchExecutor);
-  }
-
-  private void autoReindexIfStale(ChangeData cd) {
-    autoReindexIfStale(cd.project(), cd.getId());
-  }
-
-  private void autoReindexIfStale(Project.NameKey project, Change.Id id) {
-    if (autoReindexIfStale) {
-      // Don't retry indefinitely; if this fails the change will be stale.
-      @SuppressWarnings("unused")
-      Future<?> possiblyIgnoredError = reindexIfStale(project, id);
-    }
-  }
-
-  private Collection<ChangeIndex> getWriteIndexes() {
-    return indexes != null ? indexes.getWriteIndexes() : Collections.singleton(index);
-  }
-
-  @SuppressWarnings("deprecation")
-  private <T> com.google.common.util.concurrent.CheckedFuture<T, IOException> submit(
-      Callable<T> task) {
-    return submit(task, executor);
-  }
-
-  @SuppressWarnings("deprecation")
-  private static <T> com.google.common.util.concurrent.CheckedFuture<T, IOException> submit(
-      Callable<T> task, ListeningExecutorService executor) {
-    return Futures.makeChecked(Futures.nonCancellationPropagating(executor.submit(task)), MAPPER);
-  }
-
-  private abstract class AbstractIndexTask<T> implements Callable<T> {
-    protected final Project.NameKey project;
-    protected final Change.Id id;
-
-    protected AbstractIndexTask(Project.NameKey project, Change.Id id) {
-      this.project = project;
-      this.id = id;
-    }
-
-    protected abstract T callImpl(Provider<ReviewDb> db) throws Exception;
-
-    @Override
-    public abstract String toString();
-
-    @Override
-    public final T call() throws Exception {
-      try {
-        final AtomicReference<Provider<ReviewDb>> dbRef = Atomics.newReference();
-        RequestContext newCtx =
-            new RequestContext() {
-              @Override
-              public Provider<ReviewDb> getReviewDbProvider() {
-                Provider<ReviewDb> db = dbRef.get();
-                if (db == null) {
-                  try {
-                    db = Providers.of(schemaFactory.open());
-                  } catch (OrmException e) {
-                    ProvisionException pe = new ProvisionException("error opening ReviewDb");
-                    pe.initCause(e);
-                    throw pe;
-                  }
-                  dbRef.set(db);
-                }
-                return db;
-              }
-
-              @Override
-              public CurrentUser getUser() {
-                throw new OutOfScopeException("No user during ChangeIndexer");
-              }
-            };
-        RequestContext oldCtx = context.setContext(newCtx);
-        try {
-          return callImpl(newCtx.getReviewDbProvider());
-        } finally {
-          context.setContext(oldCtx);
-          Provider<ReviewDb> db = dbRef.get();
-          if (db != null) {
-            db.get().close();
-          }
-        }
-      } catch (Exception e) {
-        log.error("Failed to execute " + this, e);
-        throw e;
-      }
-    }
-  }
-
-  private class IndexTask extends AbstractIndexTask<Void> {
-    private IndexTask(Project.NameKey project, Change.Id id) {
-      super(project, id);
-    }
-
-    @Override
-    public Void callImpl(Provider<ReviewDb> db) throws Exception {
-      ChangeData cd = newChangeData(db.get(), project, id);
-      index(cd);
-      return null;
-    }
-
-    @Override
-    public String toString() {
-      return "index-change-" + id;
-    }
-  }
-
-  // Not AbstractIndexTask as it doesn't need ReviewDb.
-  private class DeleteTask implements Callable<Void> {
-    private final Change.Id id;
-
-    private DeleteTask(Change.Id id) {
-      this.id = id;
-    }
-
-    @Override
-    public Void call() throws IOException {
-      // Don't bother setting a RequestContext to provide the DB.
-      // Implementations should not need to access the DB in order to delete a
-      // change ID.
-      for (ChangeIndex i : getWriteIndexes()) {
-        i.delete(id);
-      }
-      log.info("Deleted change {} from index.", id.get());
-      fireChangeDeletedFromIndexEvent(id.get());
-      return null;
-    }
-  }
-
-  private class ReindexIfStaleTask extends AbstractIndexTask<Boolean> {
-    private ReindexIfStaleTask(Project.NameKey project, Change.Id id) {
-      super(project, id);
-    }
-
-    @Override
-    public Boolean callImpl(Provider<ReviewDb> db) throws Exception {
-      if (!stalenessChecker.isStale(id)) {
-        return false;
-      }
-      index(newChangeData(db.get(), project, id));
-      return true;
-    }
-
-    @Override
-    public String toString() {
-      return "reindex-if-stale-change-" + id;
-    }
-  }
-
-  // Avoid auto-rebuilding when reindexing if reading is disabled. This just
-  // increases contention on the meta ref from a background indexing thread
-  // with little benefit. The next actual write to the entity may still incur a
-  // less-contentious rebuild.
-  private ChangeData newChangeData(ReviewDb db, Change change) throws OrmException {
-    if (!notesMigration.readChanges()) {
-      ChangeNotes notes = changeNotesFactory.createWithAutoRebuildingDisabled(change, null);
-      return changeDataFactory.create(db, notes);
-    }
-    return changeDataFactory.create(db, change);
-  }
-
-  private ChangeData newChangeData(ReviewDb db, Project.NameKey project, Change.Id changeId)
-      throws OrmException {
-    if (!notesMigration.readChanges()) {
-      ChangeNotes notes =
-          changeNotesFactory.createWithAutoRebuildingDisabled(db, project, changeId);
-      return changeDataFactory.create(db, notes);
-    }
-    return changeDataFactory.create(db, project, changeId);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
deleted file mode 100644
index 95bdaab..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ /dev/null
@@ -1,105 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.change;
-
-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.query.change.ChangeData;
-
-public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
-  @Deprecated
-  static final Schema<ChangeData> V39 =
-      schema(
-          ChangeField.ADDED,
-          ChangeField.APPROVAL,
-          ChangeField.ASSIGNEE,
-          ChangeField.AUTHOR,
-          ChangeField.CHANGE,
-          ChangeField.COMMENT,
-          ChangeField.COMMENTBY,
-          ChangeField.COMMIT,
-          ChangeField.COMMITTER,
-          ChangeField.COMMIT_MESSAGE,
-          ChangeField.DELETED,
-          ChangeField.DELTA,
-          ChangeField.DRAFTBY,
-          ChangeField.EDITBY,
-          ChangeField.EXACT_COMMIT,
-          ChangeField.EXACT_TOPIC,
-          ChangeField.FILE_PART,
-          ChangeField.FUZZY_TOPIC,
-          ChangeField.GROUP,
-          ChangeField.HASHTAG,
-          ChangeField.HASHTAG_CASE_AWARE,
-          ChangeField.ID,
-          ChangeField.LABEL,
-          ChangeField.LEGACY_ID,
-          ChangeField.MERGEABLE,
-          ChangeField.OWNER,
-          ChangeField.PATCH_SET,
-          ChangeField.PATH,
-          ChangeField.PROJECT,
-          ChangeField.PROJECTS,
-          ChangeField.REF,
-          ChangeField.REF_STATE,
-          ChangeField.REF_STATE_PATTERN,
-          ChangeField.REVIEWEDBY,
-          ChangeField.REVIEWER,
-          ChangeField.STAR,
-          ChangeField.STARBY,
-          ChangeField.STATUS,
-          ChangeField.STORED_SUBMIT_RECORD_LENIENT,
-          ChangeField.STORED_SUBMIT_RECORD_STRICT,
-          ChangeField.SUBMISSIONID,
-          ChangeField.SUBMIT_RECORD,
-          ChangeField.TR,
-          ChangeField.UNRESOLVED_COMMENT_COUNT,
-          ChangeField.UPDATED);
-
-  @Deprecated static final Schema<ChangeData> V40 = schema(V39, ChangeField.PRIVATE);
-  @Deprecated static final Schema<ChangeData> V41 = schema(V40, ChangeField.REVIEWER_BY_EMAIL);
-  @Deprecated static final Schema<ChangeData> V42 = schema(V41, ChangeField.WIP);
-
-  @Deprecated
-  static final Schema<ChangeData> V43 =
-      schema(V42, ChangeField.EXACT_AUTHOR, ChangeField.EXACT_COMMITTER);
-
-  @Deprecated
-  static final Schema<ChangeData> V44 =
-      schema(
-          V43,
-          ChangeField.STARTED,
-          ChangeField.PENDING_REVIEWER,
-          ChangeField.PENDING_REVIEWER_BY_EMAIL);
-
-  @Deprecated static final Schema<ChangeData> V45 = schema(V44, ChangeField.REVERT_OF);
-
-  @Deprecated static final Schema<ChangeData> V46 = schema(V45);
-
-  // Removal of draft change workflow requires reindexing
-  @Deprecated static final Schema<ChangeData> V47 = schema(V46);
-
-  // Rename of star label 'mute' to 'reviewed' requires reindexing
-  static final Schema<ChangeData> V48 = schema(V47);
-
-  public static final String NAME = "changes";
-  public static final ChangeSchemaDefinitions INSTANCE = new ChangeSchemaDefinitions();
-
-  private ChangeSchemaDefinitions() {
-    super(NAME, ChangeData.class);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
deleted file mode 100644
index e804702..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ /dev/null
@@ -1,276 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.change;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.joining;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
-import com.google.common.collect.Sets;
-import com.google.common.collect.Streams;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.RefState;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-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.List;
-import java.util.Optional;
-import java.util.Set;
-import java.util.regex.Pattern;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class StalenessChecker {
-  private static final Logger log = LoggerFactory.getLogger(StalenessChecker.class);
-
-  public static final ImmutableSet<String> FIELDS =
-      ImmutableSet.of(
-          ChangeField.CHANGE.getName(),
-          ChangeField.REF_STATE.getName(),
-          ChangeField.REF_STATE_PATTERN.getName());
-
-  private final ChangeIndexCollection indexes;
-  private final GitRepositoryManager repoManager;
-  private final IndexConfig indexConfig;
-  private final Provider<ReviewDb> db;
-
-  @Inject
-  StalenessChecker(
-      ChangeIndexCollection indexes,
-      GitRepositoryManager repoManager,
-      IndexConfig indexConfig,
-      Provider<ReviewDb> db) {
-    this.indexes = indexes;
-    this.repoManager = repoManager;
-    this.indexConfig = indexConfig;
-    this.db = db;
-  }
-
-  public boolean isStale(Change.Id id) throws IOException, OrmException {
-    ChangeIndex i = indexes.getSearchIndex();
-    if (i == null) {
-      return false; // No index; caller couldn't do anything if it is stale.
-    }
-    if (!i.getSchema().hasField(ChangeField.REF_STATE)
-        || !i.getSchema().hasField(ChangeField.REF_STATE_PATTERN)) {
-      return false; // Index version not new enough for this check.
-    }
-
-    Optional<ChangeData> result =
-        i.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, FIELDS));
-    if (!result.isPresent()) {
-      return true; // Not in index, but caller wants it to be.
-    }
-    ChangeData cd = result.get();
-    return isStale(
-        repoManager,
-        id,
-        cd.change(),
-        ChangeNotes.readOneReviewDbChange(db.get(), id),
-        parseStates(cd),
-        parsePatterns(cd));
-  }
-
-  public static boolean isStale(
-      GitRepositoryManager repoManager,
-      Change.Id id,
-      Change indexChange,
-      @Nullable Change reviewDbChange,
-      SetMultimap<Project.NameKey, RefState> states,
-      ListMultimap<Project.NameKey, RefStatePattern> patterns) {
-    return reviewDbChangeIsStale(indexChange, reviewDbChange)
-        || refsAreStale(repoManager, id, states, patterns);
-  }
-
-  @VisibleForTesting
-  static boolean refsAreStale(
-      GitRepositoryManager repoManager,
-      Change.Id id,
-      SetMultimap<Project.NameKey, RefState> states,
-      ListMultimap<Project.NameKey, RefStatePattern> patterns) {
-    Set<Project.NameKey> projects = Sets.union(states.keySet(), patterns.keySet());
-
-    for (Project.NameKey p : projects) {
-      if (refsAreStale(repoManager, id, p, states, patterns)) {
-        return true;
-      }
-    }
-
-    return false;
-  }
-
-  @VisibleForTesting
-  static boolean reviewDbChangeIsStale(Change indexChange, @Nullable Change reviewDbChange) {
-    checkNotNull(indexChange);
-    PrimaryStorage storageFromIndex = PrimaryStorage.of(indexChange);
-    PrimaryStorage storageFromReviewDb = PrimaryStorage.of(reviewDbChange);
-    if (reviewDbChange == null) {
-      if (storageFromIndex == PrimaryStorage.REVIEW_DB) {
-        return true; // Index says it should have been in ReviewDb, but it wasn't.
-      }
-      return false; // Not in ReviewDb, but that's ok.
-    }
-    checkArgument(
-        indexChange.getId().equals(reviewDbChange.getId()),
-        "mismatched change ID: %s != %s",
-        indexChange.getId(),
-        reviewDbChange.getId());
-    if (storageFromIndex != storageFromReviewDb) {
-      return true; // Primary storage differs, definitely stale.
-    }
-    if (storageFromReviewDb != PrimaryStorage.REVIEW_DB) {
-      return false; // Not a ReviewDb change, don't check rowVersion.
-    }
-    return reviewDbChange.getRowVersion() != indexChange.getRowVersion();
-  }
-
-  private SetMultimap<Project.NameKey, RefState> parseStates(ChangeData cd) {
-    return parseStates(cd.getRefStates());
-  }
-
-  public static SetMultimap<Project.NameKey, RefState> parseStates(Iterable<byte[]> states) {
-    RefState.check(states != null, null);
-    SetMultimap<Project.NameKey, RefState> result =
-        MultimapBuilder.hashKeys().hashSetValues().build();
-    for (byte[] b : states) {
-      RefState.check(b != null, null);
-      String s = new String(b, UTF_8);
-      List<String> parts = Splitter.on(':').splitToList(s);
-      RefState.check(parts.size() == 3 && !parts.get(0).isEmpty() && !parts.get(1).isEmpty(), s);
-      result.put(new Project.NameKey(parts.get(0)), RefState.create(parts.get(1), parts.get(2)));
-    }
-    return result;
-  }
-
-  private ListMultimap<Project.NameKey, RefStatePattern> parsePatterns(ChangeData cd) {
-    return parsePatterns(cd.getRefStatePatterns());
-  }
-
-  public static ListMultimap<Project.NameKey, RefStatePattern> parsePatterns(
-      Iterable<byte[]> patterns) {
-    RefStatePattern.check(patterns != null, null);
-    ListMultimap<Project.NameKey, RefStatePattern> result =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-    for (byte[] b : patterns) {
-      RefStatePattern.check(b != null, null);
-      String s = new String(b, UTF_8);
-      List<String> parts = Splitter.on(':').splitToList(s);
-      RefStatePattern.check(parts.size() == 2, s);
-      result.put(new Project.NameKey(parts.get(0)), RefStatePattern.create(parts.get(1)));
-    }
-    return result;
-  }
-
-  private static boolean refsAreStale(
-      GitRepositoryManager repoManager,
-      Change.Id id,
-      Project.NameKey project,
-      SetMultimap<Project.NameKey, RefState> allStates,
-      ListMultimap<Project.NameKey, RefStatePattern> allPatterns) {
-    try (Repository repo = repoManager.openRepository(project)) {
-      Set<RefState> states = allStates.get(project);
-      for (RefState state : states) {
-        if (!state.match(repo)) {
-          return true;
-        }
-      }
-      for (RefStatePattern pattern : allPatterns.get(project)) {
-        if (!pattern.match(repo, states)) {
-          return true;
-        }
-      }
-      return false;
-    } catch (IOException e) {
-      log.warn(String.format("error checking staleness of %s in %s", id, project), e);
-      return true;
-    }
-  }
-
-  /**
-   * Pattern for matching refs.
-   *
-   * <p>Similar to '*' syntax for native Git refspecs, but slightly more powerful: the pattern may
-   * contain arbitrarily many asterisks. There must be at least one '*' and the first one must
-   * immediately follow a '/'.
-   */
-  @AutoValue
-  public abstract static class RefStatePattern {
-    static RefStatePattern create(String pattern) {
-      int star = pattern.indexOf('*');
-      check(star > 0 && pattern.charAt(star - 1) == '/', pattern);
-      String prefix = pattern.substring(0, star);
-      check(Repository.isValidRefName(pattern.replace('*', 'x')), pattern);
-
-      // Quote everything except the '*'s, which become ".*".
-      String regex =
-          Streams.stream(Splitter.on('*').split(pattern))
-              .map(Pattern::quote)
-              .collect(joining(".*", "^", "$"));
-      return new AutoValue_StalenessChecker_RefStatePattern(
-          pattern, prefix, Pattern.compile(regex));
-    }
-
-    byte[] toByteArray(Project.NameKey project) {
-      return (project.toString() + ':' + pattern()).getBytes(UTF_8);
-    }
-
-    private static void check(boolean condition, String str) {
-      checkArgument(condition, "invalid RefStatePattern: %s", str);
-    }
-
-    abstract String pattern();
-
-    abstract String prefix();
-
-    abstract Pattern regex();
-
-    boolean match(String refName) {
-      return regex().matcher(refName).find();
-    }
-
-    private boolean match(Repository repo, Set<RefState> expected) throws IOException {
-      for (Ref r : repo.getRefDatabase().getRefs(prefix()).values()) {
-        if (!match(r.getName())) {
-          continue;
-        }
-        if (!expected.contains(RefState.of(r))) {
-          return false;
-        }
-      }
-      return true;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
deleted file mode 100644
index 2b59675..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ /dev/null
@@ -1,139 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.group;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
-
-import com.google.common.base.Stopwatch;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.index.SiteIndexer;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.Groups;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-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.Optional;
-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 AllGroupsIndexer extends SiteIndexer<AccountGroup.UUID, InternalGroup, GroupIndex> {
-  private static final Logger log = LoggerFactory.getLogger(AllGroupsIndexer.class);
-
-  private final SchemaFactory<ReviewDb> schemaFactory;
-  private final ListeningExecutorService executor;
-  private final GroupCache groupCache;
-  private final Groups groups;
-
-  @Inject
-  AllGroupsIndexer(
-      SchemaFactory<ReviewDb> schemaFactory,
-      @IndexExecutor(BATCH) ListeningExecutorService executor,
-      GroupCache groupCache,
-      Groups groups) {
-    this.schemaFactory = schemaFactory;
-    this.executor = executor;
-    this.groupCache = groupCache;
-    this.groups = groups;
-  }
-
-  @Override
-  public SiteIndexer.Result indexAll(GroupIndex index) {
-    ProgressMonitor progress = new TextProgressMonitor(new PrintWriter(progressOut));
-    progress.start(2);
-    Stopwatch sw = Stopwatch.createStarted();
-    List<AccountGroup.UUID> uuids;
-    try {
-      uuids = collectGroups(progress);
-    } catch (OrmException e) {
-      log.error("Error collecting groups", e);
-      return new SiteIndexer.Result(sw, false, 0, 0);
-    }
-    return reindexGroups(index, uuids, progress);
-  }
-
-  private SiteIndexer.Result reindexGroups(
-      GroupIndex index, List<AccountGroup.UUID> uuids, ProgressMonitor progress) {
-    progress.beginTask("Reindexing groups", uuids.size());
-    List<ListenableFuture<?>> futures = new ArrayList<>(uuids.size());
-    AtomicBoolean ok = new AtomicBoolean(true);
-    AtomicInteger done = new AtomicInteger();
-    AtomicInteger failed = new AtomicInteger();
-    Stopwatch sw = Stopwatch.createStarted();
-    for (AccountGroup.UUID uuid : uuids) {
-      String desc = "group " + uuid;
-      ListenableFuture<?> future =
-          executor.submit(
-              () -> {
-                try {
-                  Optional<InternalGroup> oldGroup = groupCache.get(uuid);
-                  if (oldGroup.isPresent()) {
-                    InternalGroup group = oldGroup.get();
-                    groupCache.evict(group.getGroupUUID(), group.getId(), group.getNameKey());
-                  }
-                  Optional<InternalGroup> internalGroup = groupCache.get(uuid);
-                  if (internalGroup.isPresent()) {
-                    index.replace(internalGroup.get());
-                  } else {
-                    index.delete(uuid);
-                  }
-                  verboseWriter.println("Reindexed " + desc);
-                  done.incrementAndGet();
-                } catch (Exception e) {
-                  failed.incrementAndGet();
-                  throw e;
-                }
-                return null;
-              });
-      addErrorListener(future, desc, progress, ok);
-      futures.add(future);
-    }
-
-    try {
-      Futures.successfulAsList(futures).get();
-    } catch (ExecutionException | InterruptedException e) {
-      log.error("Error waiting on group 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<AccountGroup.UUID> collectGroups(ProgressMonitor progress) throws OrmException {
-    progress.beginTask("Collecting groups", ProgressMonitor.UNKNOWN);
-    try (ReviewDb db = schemaFactory.open()) {
-      return groups.getAll(db).map(AccountGroup::getGroupUUID).collect(toImmutableList());
-    } finally {
-      progress.endTask();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupField.java
deleted file mode 100644
index 078433a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupField.java
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.group;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.fullText;
-import static com.google.gerrit.index.FieldDef.integer;
-import static com.google.gerrit.index.FieldDef.prefix;
-import static com.google.gerrit.index.FieldDef.timestamp;
-
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.SchemaUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.group.InternalGroup;
-import java.sql.Timestamp;
-
-/** Secondary index schemas for groups. */
-public class GroupField {
-  /** Legacy group ID. */
-  public static final FieldDef<InternalGroup, Integer> ID =
-      integer("id").build(g -> g.getId().get());
-
-  /** Group UUID. */
-  public static final FieldDef<InternalGroup, String> UUID =
-      exact("uuid").stored().build(g -> g.getGroupUUID().get());
-
-  /** Group owner UUID. */
-  public static final FieldDef<InternalGroup, String> OWNER_UUID =
-      exact("owner_uuid").build(g -> g.getOwnerGroupUUID().get());
-
-  /** Timestamp indicating when this group was created. */
-  public static final FieldDef<InternalGroup, Timestamp> CREATED_ON =
-      timestamp("created_on").build(InternalGroup::getCreatedOn);
-
-  /** Group name. */
-  public static final FieldDef<InternalGroup, String> NAME =
-      exact("name").build(InternalGroup::getName);
-
-  /** Prefix match on group name parts. */
-  public static final FieldDef<InternalGroup, Iterable<String>> NAME_PART =
-      prefix("name_part").buildRepeatable(g -> SchemaUtil.getNameParts(g.getName()));
-
-  /** Group description. */
-  public static final FieldDef<InternalGroup, String> DESCRIPTION =
-      fullText("description").build(InternalGroup::getDescription);
-
-  /** Whether the group is visible to all users. */
-  public static final FieldDef<InternalGroup, String> IS_VISIBLE_TO_ALL =
-      exact("is_visible_to_all").build(g -> g.isVisibleToAll() ? "1" : "0");
-
-  public static final FieldDef<InternalGroup, Iterable<Integer>> MEMBER =
-      integer("member")
-          .buildRepeatable(
-              g -> g.getMembers().stream().map(Account.Id::get).collect(toImmutableList()));
-
-  public static final FieldDef<InternalGroup, Iterable<String>> SUBGROUP =
-      exact("subgroup")
-          .buildRepeatable(
-              g ->
-                  g.getSubgroups().stream().map(AccountGroup.UUID::get).collect(toImmutableList()));
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
deleted file mode 100644
index 69b29bc..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.group;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.events.GroupIndexedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.index.Index;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Optional;
-
-public class GroupIndexerImpl implements GroupIndexer {
-  public interface Factory {
-    GroupIndexerImpl create(GroupIndexCollection indexes);
-
-    GroupIndexerImpl create(@Nullable GroupIndex index);
-  }
-
-  private final GroupCache groupCache;
-  private final DynamicSet<GroupIndexedListener> indexedListener;
-  private final GroupIndexCollection indexes;
-  private final GroupIndex index;
-
-  @AssistedInject
-  GroupIndexerImpl(
-      GroupCache groupCache,
-      DynamicSet<GroupIndexedListener> indexedListener,
-      @Assisted GroupIndexCollection indexes) {
-    this.groupCache = groupCache;
-    this.indexedListener = indexedListener;
-    this.indexes = indexes;
-    this.index = null;
-  }
-
-  @AssistedInject
-  GroupIndexerImpl(
-      GroupCache groupCache,
-      DynamicSet<GroupIndexedListener> indexedListener,
-      @Assisted GroupIndex index) {
-    this.groupCache = groupCache;
-    this.indexedListener = indexedListener;
-    this.indexes = null;
-    this.index = index;
-  }
-
-  @Override
-  public void index(AccountGroup.UUID uuid) throws IOException {
-    for (Index<AccountGroup.UUID, InternalGroup> i : getWriteIndexes()) {
-      Optional<InternalGroup> internalGroup = groupCache.get(uuid);
-      if (internalGroup.isPresent()) {
-        i.replace(internalGroup.get());
-      } else {
-        i.delete(uuid);
-      }
-    }
-    fireGroupIndexedEvent(uuid.get());
-  }
-
-  private void fireGroupIndexedEvent(String uuid) {
-    for (GroupIndexedListener listener : indexedListener) {
-      listener.onGroupIndexed(uuid);
-    }
-  }
-
-  private Collection<GroupIndex> 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/group/GroupSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
deleted file mode 100644
index b280b25..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.group;
-
-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.group.InternalGroup;
-
-public class GroupSchemaDefinitions extends SchemaDefinitions<InternalGroup> {
-  @Deprecated
-  static final Schema<InternalGroup> V2 =
-      schema(
-          GroupField.DESCRIPTION,
-          GroupField.ID,
-          GroupField.IS_VISIBLE_TO_ALL,
-          GroupField.NAME,
-          GroupField.NAME_PART,
-          GroupField.OWNER_UUID,
-          GroupField.UUID);
-
-  @Deprecated static final Schema<InternalGroup> V3 = schema(V2, GroupField.CREATED_ON);
-
-  static final Schema<InternalGroup> V4 = schema(V3, GroupField.MEMBER, GroupField.SUBGROUP);
-
-  public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
-
-  private GroupSchemaDefinitions() {
-    super("groups", InternalGroup.class);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
deleted file mode 100644
index 255df32..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.group;
-
-import com.google.gerrit.index.Index;
-import com.google.gerrit.index.IndexedQuery;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.group.InternalGroup;
-
-public class IndexedGroupQuery extends IndexedQuery<AccountGroup.UUID, InternalGroup>
-    implements DataSource<InternalGroup> {
-
-  public IndexedGroupQuery(
-      Index<AccountGroup.UUID, InternalGroup> index,
-      Predicate<InternalGroup> pred,
-      QueryOptions opts)
-      throws QueryParseException {
-    super(index, pred, opts.convertForBackend());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java
deleted file mode 100644
index 10ad33b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.ioutil;
-
-import com.google.gerrit.server.StringUtil;
-import java.io.PrintWriter;
-
-/**
- * Simple output formatter for column-oriented data, writing its output to a {@link
- * java.io.PrintWriter} object. Handles escaping of the column data so that the resulting output is
- * unambiguous and reasonably safe and machine parsable.
- */
-public class ColumnFormatter {
-  private char columnSeparator;
-  private boolean firstColumn;
-  private final PrintWriter out;
-
-  /**
-   * @param out The writer to which output should be sent.
-   * @param columnSeparator A character that should serve as the separator token between columns of
-   *     output. As only non-printable characters in the column text are ever escaped, the column
-   *     separator must be a non-printable character if the output needs to be unambiguously parsed.
-   */
-  public ColumnFormatter(PrintWriter out, char columnSeparator) {
-    this.out = out;
-    this.columnSeparator = columnSeparator;
-    this.firstColumn = true;
-  }
-
-  /**
-   * Adds a text string as a new column in the current line of output, taking care of escaping as
-   * necessary.
-   *
-   * @param content the string to add.
-   */
-  public void addColumn(String content) {
-    if (!firstColumn) {
-      out.print(columnSeparator);
-    }
-    out.print(StringUtil.escapeString(content));
-    firstColumn = false;
-  }
-
-  /**
-   * Finishes the output by flushing the current line and takes care of any other cleanup action.
-   */
-  public void finish() {
-    nextLine();
-    out.flush();
-  }
-
-  /**
-   * Flushes the current line of output and makes the formatter ready to start receiving new column
-   * data for a new line (or end-of-file). If the current line is empty nothing is done, i.e.
-   * consecutive calls to this method without intervening calls to {@link #addColumn} will be
-   * squashed.
-   */
-  public void nextLine() {
-    if (!firstColumn) {
-      out.print('\n');
-      firstColumn = true;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
deleted file mode 100644
index aaf3243..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
-import com.google.gwtjsonrpc.server.SignedToken;
-import com.google.gwtjsonrpc.server.ValidToken;
-import com.google.gwtjsonrpc.server.XsrfException;
-import com.google.inject.AbstractModule;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import org.eclipse.jgit.util.Base64;
-
-/** Verifies the token sent by {@link RegisterNewEmailSender}. */
-@Singleton
-public class SignedTokenEmailTokenVerifier implements EmailTokenVerifier {
-  private final SignedToken emailRegistrationToken;
-
-  public static class Module extends AbstractModule {
-    @Override
-    protected void configure() {
-      bind(EmailTokenVerifier.class).to(SignedTokenEmailTokenVerifier.class);
-    }
-  }
-
-  @Inject
-  SignedTokenEmailTokenVerifier(AuthConfig config) {
-    emailRegistrationToken = config.getEmailRegistrationToken();
-  }
-
-  @Override
-  public String encode(Account.Id accountId, String emailAddress) {
-    try {
-      String payload = String.format("%s:%s", accountId, emailAddress);
-      byte[] utf8 = payload.getBytes(UTF_8);
-      String base64 = Base64.encodeBytes(utf8);
-      return emailRegistrationToken.newToken(base64);
-    } catch (XsrfException e) {
-      throw new IllegalArgumentException(e);
-    }
-  }
-
-  @Override
-  public ParsedToken decode(String tokenString) throws InvalidTokenException {
-    ValidToken token;
-    try {
-      token = emailRegistrationToken.checkToken(tokenString, null);
-    } catch (XsrfException err) {
-      throw new InvalidTokenException(err);
-    }
-    if (token == null || token.getData() == null || token.getData().isEmpty()) {
-      throw new InvalidTokenException();
-    }
-
-    String payload = new String(Base64.decode(token.getData()), UTF_8);
-    Matcher matcher = Pattern.compile("^([0-9]+):(.+@.+)$").matcher(payload);
-    if (!matcher.matches()) {
-      throw new InvalidTokenException();
-    }
-
-    Account.Id id;
-    try {
-      id = Account.Id.parse(matcher.group(1));
-    } catch (IllegalArgumentException err) {
-      throw new InvalidTokenException(err);
-    }
-
-    String newEmail = matcher.group(2);
-    return new ParsedToken(id, newEmail);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/HtmlParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/HtmlParser.java
deleted file mode 100644
index 14cb09a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/HtmlParser.java
+++ /dev/null
@@ -1,137 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterators;
-import com.google.common.collect.PeekingIterator;
-import com.google.gerrit.reviewdb.client.Comment;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import org.jsoup.Jsoup;
-import org.jsoup.nodes.Document;
-import org.jsoup.nodes.Element;
-
-/** Provides functionality for parsing the HTML part of a {@link MailMessage}. */
-public class HtmlParser {
-
-  private static final ImmutableList<String> MAIL_PROVIDER_EXTRAS =
-      ImmutableList.of(
-          "gmail_extra", // "On 01/01/2017 User<user@gmail.com> wrote:"
-          "gmail_quote" // Used for quoting original content
-          );
-
-  private HtmlParser() {}
-
-  /**
-   * Parses comments from html email.
-   *
-   * <p>This parser goes though all html elements in the email and checks for matching patterns. It
-   * keeps track of the last file and comments it encountered to know in which context a parsed
-   * comment belongs. It uses the href attributes of <a> tags to identify comments sent out by
-   * Gerrit as these are generally more reliable then the text captions.
-   *
-   * @param email the message as received from the email service
-   * @param comments a specific set of comments as sent out in the original notification email.
-   *     Comments are expected to be in the same order as they were sent out to in the email.
-   * @param changeUrl canonical change URL that points to the change on this Gerrit instance.
-   *     Example: https://go-review.googlesource.com/#/c/91570
-   * @return list of MailComments parsed from the html part of the email
-   */
-  public static List<MailComment> parse(
-      MailMessage email, Collection<Comment> comments, String changeUrl) {
-    // TODO(hiesel) Add support for Gmail Mobile
-    // TODO(hiesel) Add tests for other popular email clients
-
-    // This parser goes though all html elements in the email and checks for
-    // matching patterns. It keeps track of the last file and comments it
-    // encountered to know in which context a parsed comment belongs.
-    // It uses the href attributes of <a> tags to identify comments sent out by
-    // Gerrit as these are generally more reliable then the text captions.
-    List<MailComment> parsedComments = new ArrayList<>();
-    Document d = Jsoup.parse(email.htmlContent());
-    PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
-
-    String lastEncounteredFileName = null;
-    Comment lastEncounteredComment = null;
-    for (Element e : d.body().getAllElements()) {
-      String elementName = e.tagName();
-      boolean isInBlockQuote =
-          e.parents().stream().filter(p -> p.tagName().equals("blockquote")).findAny().isPresent();
-
-      if (elementName.equals("a")) {
-        String href = e.attr("href");
-        // Check if there is still a next comment that could be contained in
-        // this <a> tag
-        if (!iter.hasNext()) {
-          continue;
-        }
-        Comment perspectiveComment = iter.peek();
-        if (href.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
-          if (lastEncounteredFileName == null
-              || !lastEncounteredFileName.equals(perspectiveComment.key.filename)) {
-            // Not a file-level comment, but users could have typed a comment
-            // right after this file annotation to create a new file-level
-            // comment. If this file has a file-level comment, we have already
-            // set lastEncounteredComment to that file-level comment when we
-            // encountered the file link and should not reset it now.
-            lastEncounteredFileName = perspectiveComment.key.filename;
-            lastEncounteredComment = null;
-          } else if (perspectiveComment.lineNbr == 0) {
-            // This was originally a file-level comment
-            lastEncounteredComment = perspectiveComment;
-            iter.next();
-          }
-        } else if (ParserUtil.isCommentUrl(href, changeUrl, perspectiveComment)) {
-          // This is a regular inline comment
-          lastEncounteredComment = perspectiveComment;
-          iter.next();
-        }
-      } else if (!isInBlockQuote
-          && elementName.equals("div")
-          && !MAIL_PROVIDER_EXTRAS.contains(e.className())) {
-        // This is a comment typed by the user
-        // Replace non-breaking spaces and trim string
-        String content = e.ownText().replace('\u00a0', ' ').trim();
-        if (!Strings.isNullOrEmpty(content)) {
-          if (lastEncounteredComment == null && lastEncounteredFileName == null) {
-            // Remove quotation line, email signature and
-            // "Sent from my xyz device"
-            content = ParserUtil.trimQuotation(content);
-            // TODO(hiesel) Add more sanitizer
-            if (!Strings.isNullOrEmpty(content)) {
-              ParserUtil.appendOrAddNewComment(
-                  new MailComment(content, null, null, MailComment.CommentType.CHANGE_MESSAGE),
-                  parsedComments);
-            }
-          } else if (lastEncounteredComment == null) {
-            ParserUtil.appendOrAddNewComment(
-                new MailComment(
-                    content, lastEncounteredFileName, null, MailComment.CommentType.FILE_COMMENT),
-                parsedComments);
-          } else {
-            ParserUtil.appendOrAddNewComment(
-                new MailComment(
-                    content, null, lastEncounteredComment, MailComment.CommentType.INLINE_COMMENT),
-                parsedComments);
-          }
-        }
-      }
-    }
-    return parsedComments;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailComment.java
deleted file mode 100644
index f7804b33..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailComment.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import com.google.gerrit.reviewdb.client.Comment;
-import java.util.Objects;
-
-/** A comment parsed from inbound email */
-public class MailComment {
-  enum CommentType {
-    CHANGE_MESSAGE,
-    FILE_COMMENT,
-    INLINE_COMMENT
-  }
-
-  CommentType type;
-  Comment inReplyTo;
-  String fileName;
-  String message;
-
-  public MailComment() {}
-
-  public MailComment(String message, String fileName, Comment inReplyTo, CommentType type) {
-    this.message = message;
-    this.fileName = fileName;
-    this.inReplyTo = inReplyTo;
-    this.type = type;
-  }
-
-  /**
-   * Checks if the provided comment concerns the same exact spot in the change. This is basically an
-   * equals method except that the message is not checked.
-   */
-  public boolean isSameCommentPath(MailComment c) {
-    return Objects.equals(fileName, c.fileName)
-        && Objects.equals(inReplyTo, c.inReplyTo)
-        && Objects.equals(type, c.type);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMessage.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMessage.java
deleted file mode 100644
index 68b3c23..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMessage.java
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.server.mail.Address;
-import org.joda.time.DateTime;
-
-/**
- * A simplified representation of an RFC 2045-2047 mime email message used for representing received
- * emails inside Gerrit. It is populated by the MailParser after MailReceiver has received a
- * message. Transformations done by the parser include stitching mime parts together, transforming
- * all content to UTF-16 and removing attachments.
- *
- * <p>A valid {@link MailMessage} contains at least the following fields: id, from, to, subject and
- * dateReceived.
- */
-@AutoValue
-public abstract class MailMessage {
-  // Unique Identifier
-  public abstract String id();
-  // Envelop Information
-  public abstract Address from();
-
-  public abstract ImmutableList<Address> to();
-
-  public abstract ImmutableList<Address> cc();
-  // Metadata
-  public abstract DateTime dateReceived();
-
-  public abstract ImmutableList<String> additionalHeaders();
-  // Content
-  public abstract String subject();
-
-  @Nullable
-  public abstract String textContent();
-
-  @Nullable
-  public abstract String htmlContent();
-  // Raw content as received over the wire
-  @Nullable
-  public abstract ImmutableList<Integer> rawContent();
-
-  @Nullable
-  public abstract String rawContentUTF();
-
-  public static Builder builder() {
-    return new AutoValue_MailMessage.Builder();
-  }
-
-  public abstract Builder toBuilder();
-
-  @AutoValue.Builder
-  public abstract static class Builder {
-    public abstract Builder id(String val);
-
-    public abstract Builder from(Address val);
-
-    public abstract ImmutableList.Builder<Address> toBuilder();
-
-    public Builder addTo(Address val) {
-      toBuilder().add(val);
-      return this;
-    }
-
-    public abstract ImmutableList.Builder<Address> ccBuilder();
-
-    public Builder addCc(Address val) {
-      ccBuilder().add(val);
-      return this;
-    }
-
-    public abstract Builder dateReceived(DateTime val);
-
-    public abstract ImmutableList.Builder<String> additionalHeadersBuilder();
-
-    public Builder addAdditionalHeader(String val) {
-      additionalHeadersBuilder().add(val);
-      return this;
-    }
-
-    public abstract Builder subject(String val);
-
-    public abstract Builder textContent(String val);
-
-    public abstract Builder htmlContent(String val);
-
-    public abstract Builder rawContent(ImmutableList<Integer> val);
-
-    public abstract Builder rawContentUTF(String val);
-
-    public abstract MailMessage build();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MetadataParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MetadataParser.java
deleted file mode 100644
index 7085051..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MetadataParser.java
+++ /dev/null
@@ -1,110 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import static com.google.gerrit.server.mail.MetadataName.toFooterWithDelimiter;
-import static com.google.gerrit.server.mail.MetadataName.toHeaderWithDelimiter;
-
-import com.google.common.base.Strings;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.server.mail.MailUtil;
-import com.google.gerrit.server.mail.MetadataName;
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.time.format.DateTimeParseException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Parse metadata from inbound email */
-public class MetadataParser {
-  private static final Logger log = LoggerFactory.getLogger(MailProcessor.class);
-
-  public static MailMetadata parse(MailMessage m) {
-    MailMetadata metadata = new MailMetadata();
-    // Find author
-    metadata.author = m.from().getEmail();
-
-    // Check email headers for X-Gerrit-<Name>
-    for (String header : m.additionalHeaders()) {
-      if (header.startsWith(toHeaderWithDelimiter(MetadataName.CHANGE_NUMBER))) {
-        String num = header.substring(toHeaderWithDelimiter(MetadataName.CHANGE_NUMBER).length());
-        metadata.changeNumber = Ints.tryParse(num);
-      } else if (header.startsWith(toHeaderWithDelimiter(MetadataName.PATCH_SET))) {
-        String ps = header.substring(toHeaderWithDelimiter(MetadataName.PATCH_SET).length());
-        metadata.patchSet = Ints.tryParse(ps);
-      } else if (header.startsWith(toHeaderWithDelimiter(MetadataName.TIMESTAMP))) {
-        String ts = header.substring(toHeaderWithDelimiter(MetadataName.TIMESTAMP).length()).trim();
-        try {
-          metadata.timestamp = Timestamp.from(MailUtil.rfcDateformatter.parse(ts, Instant::from));
-        } catch (DateTimeParseException e) {
-          log.error("Mail: Error while parsing timestamp from header of message " + m.id(), e);
-        }
-      } else if (header.startsWith(toHeaderWithDelimiter(MetadataName.MESSAGE_TYPE))) {
-        metadata.messageType =
-            header.substring(toHeaderWithDelimiter(MetadataName.MESSAGE_TYPE).length());
-      }
-    }
-    if (metadata.hasRequiredFields()) {
-      return metadata;
-    }
-
-    // If the required fields were not yet found, continue to parse the text
-    if (!Strings.isNullOrEmpty(m.textContent())) {
-      String[] lines = m.textContent().replace("\r\n", "\n").split("\n");
-      extractFooters(lines, metadata, m);
-      if (metadata.hasRequiredFields()) {
-        return metadata;
-      }
-    }
-
-    // If the required fields were not yet found, continue to parse the HTML
-    // HTML footer are contained inside a <div> tag
-    if (!Strings.isNullOrEmpty(m.htmlContent())) {
-      String[] lines = m.htmlContent().replace("\r\n", "\n").split("</div>");
-      extractFooters(lines, metadata, m);
-      if (metadata.hasRequiredFields()) {
-        return metadata;
-      }
-    }
-
-    return metadata;
-  }
-
-  private static void extractFooters(String[] lines, MailMetadata metadata, MailMessage m) {
-    for (String line : lines) {
-      if (metadata.changeNumber == null && line.contains(MetadataName.CHANGE_NUMBER)) {
-        metadata.changeNumber =
-            Ints.tryParse(extractFooter(toFooterWithDelimiter(MetadataName.CHANGE_NUMBER), line));
-      } else if (metadata.patchSet == null && line.contains(MetadataName.PATCH_SET)) {
-        metadata.patchSet =
-            Ints.tryParse(extractFooter(toFooterWithDelimiter(MetadataName.PATCH_SET), line));
-      } else if (metadata.timestamp == null && line.contains(MetadataName.TIMESTAMP)) {
-        String ts = extractFooter(toFooterWithDelimiter(MetadataName.TIMESTAMP), line);
-        try {
-          metadata.timestamp = Timestamp.from(MailUtil.rfcDateformatter.parse(ts, Instant::from));
-        } catch (DateTimeParseException e) {
-          log.error("Mail: Error while parsing timestamp from footer of message " + m.id(), e);
-        }
-      } else if (metadata.messageType == null && line.contains(MetadataName.MESSAGE_TYPE)) {
-        metadata.messageType =
-            extractFooter(toFooterWithDelimiter(MetadataName.MESSAGE_TYPE), line);
-      }
-    }
-  }
-
-  private static String extractFooter(String key, String line) {
-    return line.substring(line.indexOf(key) + key.length(), line.length()).trim();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ParserUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ParserUtil.java
deleted file mode 100644
index 5e3c0ed..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ParserUtil.java
+++ /dev/null
@@ -1,132 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.reviewdb.client.Comment;
-import java.util.List;
-import java.util.StringJoiner;
-import java.util.regex.Pattern;
-
-public class ParserUtil {
-  private static final Pattern SIMPLE_EMAIL_PATTERN =
-      Pattern.compile(
-          "[_A-Za-z0-9-\\+]+(\\.[_A-Za-z0-9-]+)*@[A-Za-z0-9-]+"
-              + "(\\.[A-Za-z0-9]+)*(\\.[A-Za-z]{2,})");
-
-  private ParserUtil() {}
-
-  /**
-   * Trims the quotation that email clients add Example: On Sun, Nov 20, 2016 at 10:33 PM,
-   * <gerrit@gerritcodereview.com> wrote:
-   *
-   * @param comment Comment parsed from an email.
-   * @return Trimmed comment.
-   */
-  public static String trimQuotation(String comment) {
-    StringJoiner j = new StringJoiner("\n");
-    String[] lines = comment.split("\n");
-    for (int i = 0; i < lines.length - 2; i++) {
-      j.add(lines[i]);
-    }
-
-    // Check if the last line contains the full quotation pattern (date + email)
-    String lastLine = lines[lines.length - 1];
-    if (containsQuotationPattern(lastLine)) {
-      if (lines.length > 1) {
-        j.add(lines[lines.length - 2]);
-      }
-      return j.toString().trim();
-    }
-
-    // Check if the second last line + the last line contain the full quotation pattern. This is
-    // necessary, as the quotation line can be split across the last two lines if it gets too long.
-    if (lines.length > 1) {
-      String lastLines = lines[lines.length - 2] + lastLine;
-      if (containsQuotationPattern(lastLines)) {
-        return j.toString().trim();
-      }
-    }
-
-    // Add the last two lines
-    if (lines.length > 1) {
-      j.add(lines[lines.length - 2]);
-    }
-    j.add(lines[lines.length - 1]);
-
-    return j.toString().trim();
-  }
-
-  /** Check if string is an inline comment url on a patch set or the base */
-  public static boolean isCommentUrl(String str, String changeUrl, Comment comment) {
-    int lineNbr = comment.range == null ? comment.lineNbr : comment.range.startLine;
-    return str.equals(filePath(changeUrl, comment) + "@" + lineNbr)
-        || str.equals(filePath(changeUrl, comment) + "@a" + lineNbr);
-  }
-
-  /** Generate the fully qualified filepath */
-  public static String filePath(String changeUrl, Comment comment) {
-    return changeUrl + "/" + comment.key.patchSetId + "/" + comment.key.filename;
-  }
-
-  /**
-   * When parsing mail content, we need to append comments prematurely since we are parsing
-   * block-by-block and never know what comes next. This can result in a comment being parsed as two
-   * comments when it spans multiple blocks. This method takes care of merging those blocks or
-   * adding a new comment to the list of appropriate.
-   */
-  public static void appendOrAddNewComment(MailComment comment, List<MailComment> comments) {
-    if (comments.isEmpty()) {
-      comments.add(comment);
-      return;
-    }
-    MailComment lastComment = Iterables.getLast(comments);
-
-    if (comment.isSameCommentPath(lastComment)) {
-      // Merge the two comments
-      lastComment.message += "\n\n" + comment.message;
-      return;
-    }
-
-    comments.add(comment);
-  }
-
-  private static boolean containsQuotationPattern(String s) {
-    // Identifying the quotation line is hard, as it can be in any language.
-    // We identify this line by it's characteristics: It usually contains a
-    // valid email address, some digits for the date in groups of 1-4 in a row
-    // as well as some characters.
-
-    // Count occurrences of digit groups
-    int numConsecutiveDigits = 0;
-    int maxConsecutiveDigits = 0;
-    int numDigitGroups = 0;
-    for (char c : s.toCharArray()) {
-      if (c >= '0' && c <= '9') {
-        numConsecutiveDigits++;
-      } else if (numConsecutiveDigits > 0) {
-        maxConsecutiveDigits = Integer.max(maxConsecutiveDigits, numConsecutiveDigits);
-        numConsecutiveDigits = 0;
-        numDigitGroups++;
-      }
-    }
-    if (numDigitGroups < 4 || maxConsecutiveDigits > 4) {
-      return false;
-    }
-
-    // Check if the string contains an email address
-    return SIMPLE_EMAIL_PATTERN.matcher(s).find();
-  }
-}
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
deleted file mode 100644
index d2f91ed..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/RawMailParser.java
+++ /dev/null
@@ -1,177 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.io.CharStreams;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.server.mail.Address;
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import org.apache.james.mime4j.MimeException;
-import org.apache.james.mime4j.dom.Entity;
-import org.apache.james.mime4j.dom.Message;
-import org.apache.james.mime4j.dom.MessageBuilder;
-import org.apache.james.mime4j.dom.Multipart;
-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 {
-  private static final ImmutableSet<String> MAIN_HEADERS =
-      ImmutableSet.of("to", "from", "cc", "date", "message-id", "subject", "content-type");
-
-  private RawMailParser() {}
-
-  /**
-   * Parses a MailMessage from a string.
-   *
-   * @param raw {@link String} payload as received over the wire
-   * @return parsed {@link MailMessage}
-   * @throws MailParsingException in case parsing fails
-   */
-  public static MailMessage parse(String raw) throws MailParsingException {
-    MailMessage.Builder messageBuilder = MailMessage.builder();
-    messageBuilder.rawContentUTF(raw);
-    Message mimeMessage;
-    try {
-      MessageBuilder builder = new DefaultMessageBuilder();
-      mimeMessage = builder.parseMessage(new ByteArrayInputStream(raw.getBytes(UTF_8)));
-    } catch (IOException | MimeException e) {
-      throw new MailParsingException("Can't parse email", e);
-    }
-    // Add general headers
-    if (mimeMessage.getMessageId() != null) {
-      messageBuilder.id(mimeMessage.getMessageId());
-    }
-    if (mimeMessage.getSubject() != null) {
-      messageBuilder.subject(mimeMessage.getSubject());
-    }
-    messageBuilder.dateReceived(new DateTime(mimeMessage.getDate()));
-
-    // Add From, To and Cc
-    if (mimeMessage.getFrom() != null && mimeMessage.getFrom().size() > 0) {
-      Mailbox from = mimeMessage.getFrom().get(0);
-      messageBuilder.from(new Address(from.getName(), from.getAddress()));
-    }
-    if (mimeMessage.getTo() != null) {
-      for (Mailbox m : mimeMessage.getTo().flatten()) {
-        messageBuilder.addTo(new Address(m.getName(), m.getAddress()));
-      }
-    }
-    if (mimeMessage.getCc() != null) {
-      for (Mailbox m : mimeMessage.getCc().flatten()) {
-        messageBuilder.addCc(new Address(m.getName(), m.getAddress()));
-      }
-    }
-
-    // Add additional headers
-    mimeMessage
-        .getHeader()
-        .getFields()
-        .stream()
-        .filter(f -> !MAIN_HEADERS.contains(f.getName().toLowerCase()))
-        .forEach(f -> messageBuilder.addAdditionalHeader(f.getName() + ": " + f.getBody()));
-
-    // Add text and html body parts
-    StringBuilder textBuilder = new StringBuilder();
-    StringBuilder htmlBuilder = new StringBuilder();
-    try {
-      handleMimePart(mimeMessage, textBuilder, htmlBuilder);
-    } catch (IOException e) {
-      throw new MailParsingException("Can't parse email", e);
-    }
-    messageBuilder.textContent(Strings.emptyToNull(textBuilder.toString()));
-    messageBuilder.htmlContent(Strings.emptyToNull(htmlBuilder.toString()));
-
-    try {
-      // build() will only succeed if all required attributes were set. We wrap
-      // the IllegalStateException in a MailParsingException indicating that
-      // required attributes are missing, so that the caller doesn't fall over.
-      return messageBuilder.build();
-    } catch (IllegalStateException e) {
-      throw new MailParsingException("Missing required attributes after email was parsed", e);
-    }
-  }
-
-  /**
-   * Parses a MailMessage from an array of characters. Note that the character array is int-typed.
-   * This method is only used by POP3, which specifies that all transferred characters are US-ASCII
-   * (RFC 6856). When reading the input in Java, io.Reader yields ints. These can be safely
-   * converted to chars as all US-ASCII characters fit in a char. If emails contain non-ASCII
-   * characters, such as UTF runes, these will be encoded in ASCII using either Base64 or
-   * quoted-printable encoding.
-   *
-   * @param chars Array as received over the wire
-   * @return Parsed {@link MailMessage}
-   * @throws MailParsingException in case parsing fails
-   */
-  public static MailMessage parse(int[] chars) throws MailParsingException {
-    StringBuilder b = new StringBuilder(chars.length);
-    for (int c : chars) {
-      b.append((char) c);
-    }
-
-    MailMessage.Builder messageBuilder = parse(b.toString()).toBuilder();
-    messageBuilder.rawContent(ImmutableList.copyOf(Ints.asList(chars)));
-    return messageBuilder.build();
-  }
-
-  /**
-   * Traverses a mime tree and parses out text and html parts. All other parts will be dropped.
-   *
-   * @param part {@code MimePart} to parse
-   * @param textBuilder {@link StringBuilder} to append all plaintext parts
-   * @param htmlBuilder {@link StringBuilder} to append all html parts
-   * @throws IOException in case of a failure while transforming the input to a {@link String}
-   */
-  private static void handleMimePart(
-      Entity part, StringBuilder textBuilder, StringBuilder htmlBuilder) throws IOException {
-    if (isPlainOrHtml(part.getMimeType()) && !isAttachment(part.getDispositionType())) {
-      TextBody tb = (TextBody) part.getBody();
-      String result =
-          CharStreams.toString(new InputStreamReader(tb.getInputStream(), tb.getMimeCharset()));
-      if (part.getMimeType().equals("text/plain")) {
-        textBuilder.append(result);
-      } else if (part.getMimeType().equals("text/html")) {
-        htmlBuilder.append(result);
-      }
-    } else if (isMultipart(part.getMimeType())) {
-      Multipart multipart = (Multipart) part.getBody();
-      for (Entity e : multipart.getBodyParts()) {
-        handleMimePart(e, textBuilder, htmlBuilder);
-      }
-    }
-  }
-
-  private static boolean isPlainOrHtml(String mimeType) {
-    return (mimeType.equals("text/plain") || mimeType.equals("text/html"));
-  }
-
-  private static boolean isMultipart(String mimeType) {
-    return mimeType.startsWith("multipart/");
-  }
-
-  private static boolean isAttachment(String dispositionType) {
-    return dispositionType != null && dispositionType.equals("attachment");
-  }
-}
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
deleted file mode 100644
index 53e7d22..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ /dev/null
@@ -1,602 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.common.base.Splitter;
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-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;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListEntry;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.template.soy.data.SoyListData;
-import com.google.template.soy.data.SoyMapData;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.text.MessageFormat;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import java.util.TreeSet;
-import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.internal.JGitText;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.util.RawParseUtils;
-import org.eclipse.jgit.util.TemporaryBuffer;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Sends an email to one or more interested parties. */
-public abstract class ChangeEmail extends NotificationEmail {
-  private static final Logger log = LoggerFactory.getLogger(ChangeEmail.class);
-
-  protected static ChangeData newChangeData(
-      EmailArguments ea, Project.NameKey project, Change.Id id) {
-    return ea.changeDataFactory.create(ea.db.get(), project, id);
-  }
-
-  protected final Change change;
-  protected final ChangeData changeData;
-  protected ListMultimap<Account.Id, String> stars;
-  protected PatchSet patchSet;
-  protected PatchSetInfo patchSetInfo;
-  protected String changeMessage;
-  protected Timestamp timestamp;
-
-  protected ProjectState projectState;
-  protected Set<Account.Id> authors;
-  protected boolean emailOnlyAuthors;
-
-  protected ChangeEmail(EmailArguments ea, String mc, ChangeData cd) throws OrmException {
-    super(ea, mc, cd.change().getDest());
-    changeData = cd;
-    change = cd.change();
-    emailOnlyAuthors = false;
-  }
-
-  @Override
-  public void setFrom(Account.Id id) {
-    super.setFrom(id);
-
-    /** Is the from user in an email squelching group? */
-    try {
-      IdentifiedUser user = args.identifiedUserFactory.create(id);
-      args.permissionBackend.user(user).check(GlobalPermission.EMAIL_REVIEWERS);
-    } catch (AuthException | PermissionBackendException e) {
-      emailOnlyAuthors = true;
-    }
-  }
-
-  public void setPatchSet(PatchSet ps) {
-    patchSet = ps;
-  }
-
-  public void setPatchSet(PatchSet ps, PatchSetInfo psi) {
-    patchSet = ps;
-    patchSetInfo = psi;
-  }
-
-  @Deprecated
-  public void setChangeMessage(ChangeMessage cm) {
-    setChangeMessage(cm.getMessage(), cm.getWrittenOn());
-  }
-
-  public void setChangeMessage(String cm, Timestamp t) {
-    changeMessage = cm;
-    timestamp = t;
-  }
-
-  /** Format the message body by calling {@link #appendText(String)}. */
-  @Override
-  protected void format() throws EmailException {
-    formatChange();
-    appendText(textTemplate("ChangeFooter"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("ChangeFooterHtml"));
-    }
-    formatFooter();
-  }
-
-  /** Format the message body by calling {@link #appendText(String)}. */
-  protected abstract void formatChange() throws EmailException;
-
-  /**
-   * Format the message footer by calling {@link #appendText(String)}.
-   *
-   * @throws EmailException if an error occurred.
-   */
-  protected void formatFooter() throws EmailException {}
-
-  /** Setup the message headers and envelope (TO, CC, BCC). */
-  @Override
-  protected void init() throws EmailException {
-    if (args.projectCache != null) {
-      projectState = args.projectCache.get(change.getProject());
-    } else {
-      projectState = null;
-    }
-
-    if (patchSet == null) {
-      try {
-        patchSet = changeData.currentPatchSet();
-      } catch (OrmException err) {
-        patchSet = null;
-      }
-    }
-
-    if (patchSet != null) {
-      setHeader("X-Gerrit-PatchSet", patchSet.getPatchSetId() + "");
-      if (patchSetInfo == null) {
-        try {
-          patchSetInfo =
-              args.patchSetInfoFactory.get(args.db.get(), changeData.notes(), patchSet.getId());
-        } catch (PatchSetInfoNotAvailableException | OrmException err) {
-          patchSetInfo = null;
-        }
-      }
-    }
-    authors = getAuthors();
-
-    try {
-      stars = changeData.stars();
-    } catch (OrmException e) {
-      throw new EmailException("Failed to load stars for change " + change.getChangeId(), e);
-    }
-
-    super.init();
-    if (timestamp != null) {
-      setHeader("Date", new Date(timestamp.getTime()));
-    }
-    setChangeSubjectHeader();
-    setHeader("X-Gerrit-Change-Id", "" + change.getKey().get());
-    setHeader("X-Gerrit-Change-Number", "" + change.getChangeId());
-    setChangeUrlHeader();
-    setCommitIdHeader();
-
-    if (notify.ordinal() >= NotifyHandling.OWNER_REVIEWERS.ordinal()) {
-      try {
-        addByEmail(
-            RecipientType.CC, changeData.reviewersByEmail().byState(ReviewerStateInternal.CC));
-        addByEmail(
-            RecipientType.CC,
-            changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER));
-      } catch (OrmException e) {
-        throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
-      }
-    }
-  }
-
-  private void setChangeUrlHeader() {
-    final String u = getChangeUrl();
-    if (u != null) {
-      setHeader("X-Gerrit-ChangeURL", "<" + u + ">");
-    }
-  }
-
-  private void setCommitIdHeader() {
-    if (patchSet != null
-        && patchSet.getRevision() != null
-        && patchSet.getRevision().get() != null
-        && patchSet.getRevision().get().length() > 0) {
-      setHeader("X-Gerrit-Commit", patchSet.getRevision().get());
-    }
-  }
-
-  private void setChangeSubjectHeader() throws EmailException {
-    setHeader("Subject", textTemplate("ChangeSubject"));
-  }
-
-  /** Get a link to the change; null if the server doesn't know its own address. */
-  public String getChangeUrl() {
-    if (getGerritUrl() != null) {
-      final StringBuilder r = new StringBuilder();
-      r.append(getGerritUrl());
-      r.append(change.getChangeId());
-      return r.toString();
-    }
-    return null;
-  }
-
-  public String getChangeMessageThreadId() throws EmailException {
-    return velocify("<gerrit.${change.createdOn.time}.$change.key.get()@$email.gerritHost>");
-  }
-
-  /** Format the sender's "cover letter", {@link #getCoverLetter()}. */
-  protected void formatCoverLetter() {
-    final String cover = getCoverLetter();
-    if (!"".equals(cover)) {
-      appendText(cover);
-      appendText("\n\n");
-    }
-  }
-
-  /** Get the text of the "cover letter". */
-  public String getCoverLetter() {
-    if (changeMessage != null) {
-      return changeMessage.trim();
-    }
-    return "";
-  }
-
-  /** Format the change message and the affected file list. */
-  protected void formatChangeDetail() {
-    appendText(getChangeDetail());
-  }
-
-  /** Create the change message and the affected file list. */
-  public String getChangeDetail() {
-    try {
-      StringBuilder detail = new StringBuilder();
-
-      if (patchSetInfo != null) {
-        detail.append(patchSetInfo.getMessage().trim()).append("\n");
-      } else {
-        detail.append(change.getSubject().trim()).append("\n");
-      }
-
-      if (patchSet != null) {
-        detail.append("---\n");
-        PatchList patchList = getPatchList();
-        for (PatchListEntry p : patchList.getPatches()) {
-          if (Patch.isMagic(p.getNewName())) {
-            continue;
-          }
-          detail
-              .append(p.getChangeType().getCode())
-              .append(" ")
-              .append(p.getNewName())
-              .append("\n");
-        }
-        detail.append(
-            MessageFormat.format(
-                "" //
-                    + "{0,choice,0#0 files|1#1 file|1<{0} files} changed, " //
-                    + "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
-                    + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
-                    + "\n",
-                patchList.getPatches().size() - 1, //
-                patchList.getInsertions(), //
-                patchList.getDeletions()));
-        detail.append("\n");
-      }
-      return detail.toString();
-    } catch (Exception err) {
-      log.warn("Cannot format change detail", err);
-      return "";
-    }
-  }
-
-  /** Get the patch list corresponding to this patch set. */
-  protected PatchList getPatchList() throws PatchListNotAvailableException {
-    if (patchSet != null) {
-      return args.patchListCache.get(change, patchSet);
-    }
-    throw new PatchListNotAvailableException("no patchSet specified");
-  }
-
-  /** Get the project entity the change is in; null if its been deleted. */
-  protected ProjectState getProjectState() {
-    return projectState;
-  }
-
-  /** Get the groups which own the project. */
-  protected Set<AccountGroup.UUID> getProjectOwners() {
-    final ProjectState r;
-
-    r = args.projectCache.get(change.getProject());
-    return r != null ? r.getOwners() : Collections.<AccountGroup.UUID>emptySet();
-  }
-
-  /** TO or CC all vested parties (change owner, patch set uploader, author). */
-  protected void rcptToAuthors(RecipientType rt) {
-    for (Account.Id id : authors) {
-      add(rt, id);
-    }
-  }
-
-  /** BCC any user who has starred this change. */
-  protected void bccStarredBy() {
-    if (!NotifyHandling.ALL.equals(notify)) {
-      return;
-    }
-
-    for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
-      if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
-        super.add(RecipientType.BCC, e.getKey());
-      }
-    }
-  }
-
-  protected void removeUsersThatIgnoredTheChange() {
-    for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
-      if (e.getValue().contains(StarredChangesUtil.IGNORE_LABEL)) {
-        AccountState accountState = args.accountCache.get(e.getKey());
-        if (accountState != null) {
-          removeUser(accountState.getAccount());
-        }
-      }
-    }
-  }
-
-  @Override
-  protected final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig)
-      throws OrmException {
-    if (!NotifyHandling.ALL.equals(notify)) {
-      return new Watchers();
-    }
-
-    ProjectWatch watch = new ProjectWatch(args, branch.getParentKey(), projectState, changeData);
-    return watch.getWatchers(type, includeWatchersFromNotifyConfig);
-  }
-
-  /** Any user who has published comments on this change. */
-  protected void ccAllApprovals() {
-    if (!NotifyHandling.ALL.equals(notify) && !NotifyHandling.OWNER_REVIEWERS.equals(notify)) {
-      return;
-    }
-
-    try {
-      for (Account.Id id : changeData.reviewers().all()) {
-        add(RecipientType.CC, id);
-      }
-    } catch (OrmException err) {
-      log.warn("Cannot CC users that reviewed updated change", err);
-    }
-  }
-
-  /** Users who have non-zero approval codes on the change. */
-  protected void ccExistingReviewers() {
-    if (!NotifyHandling.ALL.equals(notify) && !NotifyHandling.OWNER_REVIEWERS.equals(notify)) {
-      return;
-    }
-
-    try {
-      for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
-        add(RecipientType.CC, id);
-      }
-    } catch (OrmException err) {
-      log.warn("Cannot CC users that commented on updated change", err);
-    }
-  }
-
-  @Override
-  protected void add(RecipientType rt, Account.Id to) {
-    if (!emailOnlyAuthors || authors.contains(to)) {
-      super.add(rt, to);
-    }
-  }
-
-  @Override
-  protected boolean isVisibleTo(Account.Id to) throws OrmException, PermissionBackendException {
-    return args.permissionBackend
-        .user(args.identifiedUserFactory.create(to))
-        .change(changeData)
-        .database(args.db.get())
-        .test(ChangePermission.READ);
-  }
-
-  /** Find all users who are authors of any part of this change. */
-  protected Set<Account.Id> getAuthors() {
-    Set<Account.Id> authors = new HashSet<>();
-
-    switch (notify) {
-      case NONE:
-        break;
-      case ALL:
-      default:
-        if (patchSet != null) {
-          authors.add(patchSet.getUploader());
-        }
-        if (patchSetInfo != null) {
-          if (patchSetInfo.getAuthor().getAccount() != null) {
-            authors.add(patchSetInfo.getAuthor().getAccount());
-          }
-          if (patchSetInfo.getCommitter().getAccount() != null) {
-            authors.add(patchSetInfo.getCommitter().getAccount());
-          }
-        }
-        // $FALL-THROUGH$
-      case OWNER_REVIEWERS:
-      case OWNER:
-        authors.add(change.getOwner());
-        break;
-    }
-
-    return authors;
-  }
-
-  @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();
-
-    soyContext.put("changeId", change.getKey().get());
-    soyContext.put("coverLetter", getCoverLetter());
-    soyContext.put("fromName", getNameFor(fromId));
-    soyContext.put("fromEmail", getNameEmailFor(fromId));
-    soyContext.put("diffLines", getDiffTemplateData());
-
-    soyContextEmailData.put("unifiedDiff", getUnifiedDiff());
-    soyContextEmailData.put("changeDetail", getChangeDetail());
-    soyContextEmailData.put("changeUrl", getChangeUrl());
-    soyContextEmailData.put("includeDiff", getIncludeDiff());
-
-    Map<String, String> changeData = new HashMap<>();
-    changeData.put("subject", change.getSubject());
-    changeData.put("originalSubject", change.getOriginalSubject());
-    changeData.put("ownerName", getNameFor(change.getOwner()));
-    changeData.put("ownerEmail", getNameEmailFor(change.getOwner()));
-    changeData.put("changeNumber", Integer.toString(change.getChangeId()));
-    soyContext.put("change", changeData);
-
-    String subject = change.getSubject();
-    changeData.put("subject", subject);
-    // shortSubject is the subject limited to 63 characters, with an ellipsis if
-    // it exceeds that.
-    if (subject.length() < 73) {
-      changeData.put("shortSubject", subject);
-    } else {
-      changeData.put("shortSubject", subject.substring(0, 69) + "...");
-    }
-
-    Map<String, Object> patchSetData = new HashMap<>();
-    patchSetData.put("patchSetId", patchSet.getPatchSetId());
-    patchSetData.put("refName", patchSet.getRefName());
-    soyContext.put("patchSet", patchSetData);
-
-    // TODO(wyatta): patchSetInfo
-
-    footers.add("Gerrit-MessageType: " + messageClass);
-    footers.add("Gerrit-Change-Id: " + change.getKey().get());
-    footers.add("Gerrit-Change-Number: " + Integer.toString(change.getChangeId()));
-    footers.add("Gerrit-PatchSet: " + patchSet.getPatchSetId());
-    footers.add("Gerrit-Owner: " + getNameEmailFor(change.getOwner()));
-    if (change.getAssignee() != null) {
-      footers.add("Gerrit-Assignee: " + getNameEmailFor(change.getAssignee()));
-    }
-    for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
-      footers.add("Gerrit-Reviewer: " + reviewer);
-    }
-    for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
-      footers.add("Gerrit-CC: " + reviewer);
-    }
-  }
-
-  private Set<String> getEmailsByState(ReviewerStateInternal state) {
-    Set<String> reviewers = new TreeSet<>();
-    try {
-      for (Account.Id who : changeData.reviewers().byState(state)) {
-        reviewers.add(getNameEmailFor(who));
-      }
-    } catch (OrmException e) {
-      log.warn("Cannot get change reviewers", e);
-    }
-    return reviewers;
-  }
-
-  public boolean getIncludeDiff() {
-    return args.settings.includeDiff;
-  }
-
-  private static final int HEAP_EST_SIZE = 32 * 1024;
-
-  /** Show patch set as unified difference. */
-  public String getUnifiedDiff() {
-    PatchList patchList;
-    try {
-      patchList = getPatchList();
-      if (patchList.getOldId() == null) {
-        // Octopus merges are not well supported for diff output by Gerrit.
-        // Currently these always have a null oldId in the PatchList.
-        return "[Octopus merge; cannot be formatted as a diff.]\n";
-      }
-    } catch (PatchListNotAvailableException e) {
-      log.error("Cannot format patch", e);
-      return "";
-    }
-
-    int maxSize = args.settings.maximumDiffSize;
-    TemporaryBuffer.Heap buf = new TemporaryBuffer.Heap(Math.min(HEAP_EST_SIZE, maxSize), maxSize);
-    try (DiffFormatter fmt = new DiffFormatter(buf)) {
-      try (Repository git = args.server.openRepository(change.getProject())) {
-        try {
-          fmt.setRepository(git);
-          fmt.setDetectRenames(true);
-          fmt.format(patchList.getOldId(), patchList.getNewId());
-          return RawParseUtils.decode(buf.toByteArray());
-        } catch (IOException e) {
-          if (JGitText.get().inMemoryBufferLimitExceeded.equals(e.getMessage())) {
-            return "";
-          }
-          log.error("Cannot format patch", e);
-          return "";
-        }
-      } catch (IOException e) {
-        log.error("Cannot open repository to format patch", e);
-        return "";
-      }
-    }
-  }
-
-  /**
-   * Generate a Soy list of maps representing each line of the unified diff. The line maps will have
-   * a 'type' key which maps to one of 'common', 'add' or 'remove' and a 'text' key which maps to
-   * the line's content.
-   */
-  private SoyListData getDiffTemplateData() {
-    SoyListData result = new SoyListData();
-    Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
-    for (String diffLine : lineSplitter.split(getUnifiedDiff())) {
-      SoyMapData lineData = new SoyMapData();
-      lineData.put("text", diffLine);
-
-      // Skip empty lines and lines that look like diff headers.
-      if (diffLine.isEmpty() || diffLine.startsWith("---") || diffLine.startsWith("+++")) {
-        lineData.put("type", "common");
-      } else {
-        switch (diffLine.charAt(0)) {
-          case '+':
-            lineData.put("type", "add");
-            break;
-          case '-':
-            lineData.put("type", "remove");
-            break;
-          default:
-            lineData.put("type", "common");
-            break;
-        }
-      }
-      result.add(lineData);
-    }
-    return result;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
deleted file mode 100644
index 5b7d3b7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ /dev/null
@@ -1,657 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.common.data.FilenameComparator;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.common.errors.NoSuchEntityException;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.mail.MailUtil;
-import com.google.gerrit.server.mail.receive.Protocol;
-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.util.LabelVote;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.time.ZoneId;
-import java.time.ZonedDateTime;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Send comments, after the author of them hit used Publish Comments in the UI. */
-public class CommentSender extends ReplyToChangeSender {
-  private static final Logger log = LoggerFactory.getLogger(CommentSender.class);
-
-  public interface Factory {
-    CommentSender create(Project.NameKey project, Change.Id id);
-  }
-
-  private class FileCommentGroup {
-    public String filename;
-    public int patchSetId;
-    public PatchFile fileData;
-    public List<Comment> comments = new ArrayList<>();
-
-    /** @return a web link to the given patch set and file. */
-    public String getLink() {
-      String url = getGerritUrl();
-      if (url == null) {
-        return null;
-      }
-
-      return new StringBuilder()
-          .append(url)
-          .append("#/c/")
-          .append(change.getId())
-          .append('/')
-          .append(patchSetId)
-          .append('/')
-          .append(KeyUtil.encode(filename))
-          .toString();
-    }
-
-    /**
-     * @return A title for the group, i.e. "Commit Message", "Merge List", or "File [[filename]]".
-     */
-    public String getTitle() {
-      if (Patch.COMMIT_MSG.equals(filename)) {
-        return "Commit Message";
-      } else if (Patch.MERGE_LIST.equals(filename)) {
-        return "Merge List";
-      } else {
-        return "File " + filename;
-      }
-    }
-  }
-
-  private List<Comment> inlineComments = Collections.emptyList();
-  private String patchSetComment;
-  private List<LabelVote> labels = Collections.emptyList();
-  private final CommentsUtil commentsUtil;
-  private final boolean incomingEmailEnabled;
-  private final String replyToAddress;
-
-  @Inject
-  public CommentSender(
-      EmailArguments ea,
-      CommentsUtil commentsUtil,
-      @GerritServerConfig Config cfg,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "comment", newChangeData(ea, project, id));
-    this.commentsUtil = commentsUtil;
-    this.incomingEmailEnabled =
-        cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal()
-            > Protocol.NONE.ordinal();
-    this.replyToAddress = cfg.getString("sendemail", null, "replyToAddress");
-  }
-
-  public void setComments(List<Comment> comments) throws OrmException {
-    inlineComments = comments;
-
-    Set<String> paths = new HashSet<>();
-    for (Comment c : comments) {
-      if (!Patch.isMagic(c.key.filename)) {
-        paths.add(c.key.filename);
-      }
-    }
-    changeData.setCurrentFilePaths(Ordering.natural().sortedCopy(paths));
-  }
-
-  public void setPatchSetComment(String comment) {
-    this.patchSetComment = comment;
-  }
-
-  public void setLabels(List<LabelVote> labels) {
-    this.labels = labels;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    if (notify.compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
-      ccAllApprovals();
-    }
-    if (notify.compareTo(NotifyHandling.ALL) >= 0) {
-      bccStarredBy();
-      includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate());
-    }
-    removeUsersThatIgnoredTheChange();
-
-    // Add header that enables identifying comments on parsed email.
-    // Grouping is currently done by timestamp.
-    setHeader("X-Gerrit-Comment-Date", timestamp);
-
-    if (incomingEmailEnabled) {
-      if (replyToAddress == null) {
-        // Remove Reply-To and use outbound SMTP (default) instead.
-        removeHeader("Reply-To");
-      } else {
-        setHeader("Reply-To", replyToAddress);
-      }
-    }
-  }
-
-  @Override
-  public void formatChange() throws EmailException {
-    appendText(textTemplate("Comment"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("CommentHtml"));
-    }
-  }
-
-  @Override
-  public void formatFooter() throws EmailException {
-    appendText(textTemplate("CommentFooter"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("CommentFooterHtml"));
-    }
-  }
-
-  /** 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.
-   */
-  private List<CommentSender.FileCommentGroup> getGroupedInlineComments(Repository repo) {
-    List<CommentSender.FileCommentGroup> groups = new ArrayList<>();
-    // Get the patch list:
-    PatchList patchList = null;
-    if (repo != null) {
-      try {
-        patchList = getPatchList();
-      } catch (PatchListNotAvailableException e) {
-        log.error("Failed to get patch list", e);
-      }
-    }
-
-    // Loop over the comments and collect them into groups based on the file
-    // location of the comment.
-    FileCommentGroup currentGroup = null;
-    for (Comment c : inlineComments) {
-      // If it's a new group:
-      if (currentGroup == null
-          || !c.key.filename.equals(currentGroup.filename)
-          || c.key.patchSetId != currentGroup.patchSetId) {
-        currentGroup = new FileCommentGroup();
-        currentGroup.filename = c.key.filename;
-        currentGroup.patchSetId = c.key.patchSetId;
-        groups.add(currentGroup);
-        if (patchList != null) {
-          try {
-            currentGroup.fileData = new PatchFile(repo, patchList, c.key.filename);
-          } catch (IOException e) {
-            log.warn(
-                String.format(
-                    "Cannot load %s from %s in %s",
-                    c.key.filename, patchList.getNewId().name(), projectState.getName()),
-                e);
-            currentGroup.fileData = null;
-          }
-        }
-      }
-
-      if (currentGroup.fileData != null) {
-        currentGroup.comments.add(c);
-      }
-    }
-
-    Collections.sort(groups, Comparator.comparing(g -> g.filename, FilenameComparator.INSTANCE));
-    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();
-    sb.append("PS").append(comment.key.patchSetId);
-    if (lineNbr != 0) {
-      sb.append(", Line ").append(lineNbr);
-    }
-    sb.append(": ");
-    return sb.toString();
-  }
-
-  /**
-   * @return the lines of file content in fileData that are encompassed by range on the given side.
-   */
-  private List<String> getLinesByRange(Comment.Range range, PatchFile fileData, short side) {
-    List<String> lines = new ArrayList<>();
-
-    for (int n = range.startLine; n <= range.endLine; n++) {
-      String s = getLine(fileData, side, n);
-      if (n == range.startLine && n == range.endLine && range.startChar < range.endChar) {
-        s = s.substring(Math.min(range.startChar, s.length()), Math.min(range.endChar, s.length()));
-      } else if (n == range.startLine) {
-        s = s.substring(Math.min(range.startChar, s.length()));
-      } else if (n == range.endLine) {
-        s = s.substring(0, Math.min(range.endChar, s.length()));
-      }
-      lines.add(s);
-    }
-    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.
-   *
-   * @param child the comment with a potential parent comment.
-   * @return an optional comment that will be present if the given comment has a parent, and is
-   *     empty if it does not.
-   */
-  private Optional<Comment> getParent(Comment child) {
-    if (child.parentUuid == null) {
-      return Optional.empty();
-    }
-
-    Comment.Key key = new Comment.Key(child.parentUuid, child.key.filename, child.key.patchSetId);
-    try {
-      return commentsUtil.getPublished(args.db.get(), changeData.notes(), key);
-    } catch (OrmException e) {
-      log.warn("Could not find the parent of this comment: " + child.toString());
-      return Optional.empty();
-    }
-  }
-
-  /**
-   * Retrieve the file lines referred to by a comment.
-   *
-   * @param comment The comment that refers to some file contents. The comment may be a line comment
-   *     or a ranged comment.
-   * @param fileData The file on which the comment appears.
-   * @return file contents referred to by the comment. If the comment is a line comment, the result
-   *     will be a list of one string. Otherwise it will be a list of one or more strings.
-   */
-  private List<String> getLinesOfComment(Comment comment, PatchFile fileData) {
-    List<String> lines = new ArrayList<>();
-    if (comment.lineNbr == 0) {
-      // file level comment has no line
-      return lines;
-    }
-    if (comment.range == null) {
-      lines.add(getLine(fileData, comment.side, comment.lineNbr));
-    } else {
-      lines.addAll(getLinesByRange(comment.range, fileData, comment.side));
-    }
-    return lines;
-  }
-
-  /**
-   * @return a shortened version of the given comment's message. Will be shortened to 100 characters
-   *     or the first line, or following the last period within the first 100 characters, whichever
-   *     is shorter. If the message is shortened, an ellipsis is appended.
-   */
-  protected static String getShortenedCommentMessage(String message) {
-    int threshold = 100;
-    String fullMessage = message.trim();
-    String msg = fullMessage;
-
-    if (msg.length() > threshold) {
-      msg = msg.substring(0, threshold);
-    }
-
-    int lf = msg.indexOf('\n');
-    int period = msg.lastIndexOf('.');
-
-    if (lf > 0) {
-      // Truncate if a line feed appears within the threshold.
-      msg = msg.substring(0, lf);
-
-    } else if (period > 0) {
-      // Otherwise truncate if there is a period within the threshold.
-      msg = msg.substring(0, period + 1);
-    }
-
-    // Append an ellipsis if the message has been truncated.
-    if (!msg.equals(fullMessage)) {
-      msg += " […]";
-    }
-
-    return msg;
-  }
-
-  protected static String getShortenedCommentMessage(Comment comment) {
-    return getShortenedCommentMessage(comment.message);
-  }
-
-  /**
-   * @return grouped inline comment data mapped to data structures that are suitable for passing
-   *     into Soy.
-   */
-  private List<Map<String, Object>> getCommentGroupsTemplateData(Repository repo) {
-    List<Map<String, Object>> commentGroups = new ArrayList<>();
-
-    for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
-      Map<String, Object> groupData = new HashMap<>();
-      groupData.put("link", group.getLink());
-      groupData.put("title", group.getTitle());
-      groupData.put("patchSetId", group.patchSetId);
-
-      List<Map<String, Object>> commentsList = new ArrayList<>();
-      for (Comment comment : group.comments) {
-        Map<String, Object> commentData = new HashMap<>();
-        commentData.put("lines", getLinesOfComment(comment, group.fileData));
-        commentData.put("message", comment.message.trim());
-        List<CommentFormatter.Block> blocks = CommentFormatter.parse(comment.message);
-        commentData.put("messageBlocks", commentBlocksToSoyData(blocks));
-
-        // Set the prefix.
-        String prefix = getCommentLinePrefix(comment);
-        commentData.put("linePrefix", prefix);
-        commentData.put("linePrefixEmpty", Strings.padStart(": ", prefix.length(), ' '));
-
-        // Set line numbers.
-        int startLine;
-        if (comment.range == null) {
-          startLine = comment.lineNbr;
-        } else {
-          startLine = comment.range.startLine;
-          commentData.put("endLine", comment.range.endLine);
-        }
-        commentData.put("startLine", startLine);
-
-        // Set the comment link.
-        if (comment.lineNbr == 0) {
-          commentData.put("link", group.getLink());
-        } else if (comment.side == 0) {
-          commentData.put("link", group.getLink() + "@a" + startLine);
-        } else {
-          commentData.put("link", group.getLink() + '@' + startLine);
-        }
-
-        // Set robot comment data.
-        if (comment instanceof RobotComment) {
-          RobotComment robotComment = (RobotComment) comment;
-          commentData.put("isRobotComment", true);
-          commentData.put("robotId", robotComment.robotId);
-          commentData.put("robotRunId", robotComment.robotRunId);
-          commentData.put("robotUrl", robotComment.url);
-        } else {
-          commentData.put("isRobotComment", false);
-        }
-
-        // If the comment has a quote, don't bother loading the parent message.
-        if (!hasQuote(blocks)) {
-          // Set parent comment info.
-          Optional<Comment> parent = getParent(comment);
-          if (parent.isPresent()) {
-            commentData.put("parentMessage", getShortenedCommentMessage(parent.get()));
-          }
-        }
-
-        commentsList.add(commentData);
-      }
-      groupData.put("comments", commentsList);
-
-      commentGroups.add(groupData);
-    }
-    return commentGroups;
-  }
-
-  private List<Map<String, Object>> commentBlocksToSoyData(List<CommentFormatter.Block> blocks) {
-    return blocks
-        .stream()
-        .map(
-            b -> {
-              Map<String, Object> map = new HashMap<>();
-              switch (b.type) {
-                case PARAGRAPH:
-                  map.put("type", "paragraph");
-                  map.put("text", b.text);
-                  break;
-                case PRE_FORMATTED:
-                  map.put("type", "pre");
-                  map.put("text", b.text);
-                  break;
-                case QUOTE:
-                  map.put("type", "quote");
-                  map.put("quotedBlocks", commentBlocksToSoyData(b.quotedBlocks));
-                  break;
-                case LIST:
-                  map.put("type", "list");
-                  map.put("items", b.items);
-                  break;
-              }
-              return map;
-            })
-        .collect(toList());
-  }
-
-  private boolean hasQuote(List<CommentFormatter.Block> blocks) {
-    for (CommentFormatter.Block block : blocks) {
-      if (block.type == CommentFormatter.BlockType.QUOTE) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  private Repository getRepository() {
-    try {
-      return args.server.openRepository(projectState.getNameKey());
-    } catch (IOException e) {
-      return null;
-    }
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    boolean hasComments = false;
-    try (Repository repo = getRepository()) {
-      List<Map<String, Object>> files = getCommentGroupsTemplateData(repo);
-      soyContext.put("commentFiles", files);
-      hasComments = !files.isEmpty();
-    }
-
-    soyContext.put(
-        "patchSetCommentBlocks", commentBlocksToSoyData(CommentFormatter.parse(patchSetComment)));
-    soyContext.put("labels", getLabelVoteSoyData(labels));
-    soyContext.put("commentCount", inlineComments.size());
-    soyContext.put("commentTimestamp", getCommentTimestamp());
-    soyContext.put(
-        "coverLetterBlocks", commentBlocksToSoyData(CommentFormatter.parse(getCoverLetter())));
-
-    footers.add("Gerrit-Comment-Date: " + getCommentTimestamp());
-    footers.add("Gerrit-HasComments: " + (hasComments ? "Yes" : "No"));
-    footers.add("Gerrit-HasLabels: " + (labels.isEmpty() ? "No" : "Yes"));
-  }
-
-  private String getLine(PatchFile fileInfo, short side, int lineNbr) {
-    try {
-      return fileInfo.getLine(side, lineNbr);
-    } catch (IOException err) {
-      // Default to the empty string if the file cannot be safely read.
-      log.warn(String.format("Failed to read file on side %d", side), err);
-      return "";
-    } catch (IndexOutOfBoundsException err) {
-      // Default to the empty string if the given line number does not appear
-      // in the file.
-      log.debug(String.format("Failed to get line number of file on side %d", side), err);
-      return "";
-    } catch (NoSuchEntityException err) {
-      // Default to the empty string if the side cannot be found.
-      log.warn(String.format("Side %d of file didn't exist", side), err);
-      return "";
-    }
-  }
-
-  private List<Map<String, Object>> getLabelVoteSoyData(List<LabelVote> votes) {
-    List<Map<String, Object>> result = new ArrayList<>();
-    for (LabelVote vote : votes) {
-      Map<String, Object> data = new HashMap<>();
-      data.put("label", vote.label());
-
-      // Soy needs the short to be cast as an int for it to get converted to the
-      // correct tamplate type.
-      data.put("value", (int) vote.value());
-      result.add(data);
-    }
-    return result;
-  }
-
-  private String getCommentTimestamp() {
-    // Grouping is currently done by timestamp.
-    return MailUtil.rfcDateformatter.format(
-        ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneId.of("UTC")));
-  }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
-}
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
deleted file mode 100644
index 6d15d6f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.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.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Notify interested parties of a brand new change. */
-public class CreateChangeSender extends NewChangeSender {
-  private static final Logger log = LoggerFactory.getLogger(CreateChangeSender.class);
-
-  public interface Factory {
-    CreateChangeSender create(Project.NameKey project, Change.Id id);
-  }
-
-  @Inject
-  public CreateChangeSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, newChangeData(ea, project, id));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    try {
-      // Try to mark interested owners with TO and CC or BCC line.
-      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);
-        }
-      }
-
-      // Add everyone else. Owners added above will not be duplicated.
-      add(RecipientType.TO, matching.to);
-      add(RecipientType.CC, matching.cc);
-      add(RecipientType.BCC, matching.bcc);
-    } catch (OrmException err) {
-      // Just don't CC everyone. Better to send a partial message to those
-      // we already have queued up then to fail deliver entirely to people
-      // who have a lower interest in the change.
-      log.warn("Cannot notify watchers for new change", err);
-    }
-
-    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();
-  }
-}
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
deleted file mode 100644
index 869d7d1..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ /dev/null
@@ -1,148 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.GerritPersonIdentProvider;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.IdentifiedUser.GenericFactory;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupIncludeCache;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.Groups;
-import com.google.gerrit.server.mail.EmailSettings;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.query.account.InternalAccountQuery;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.ssh.SshAdvertisedAddresses;
-import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
-import com.google.inject.Inject;
-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 {
-  final GitRepositoryManager server;
-  final ProjectCache projectCache;
-  final PermissionBackend permissionBackend;
-  final GroupBackend groupBackend;
-  final GroupIncludeCache groupIncludes;
-  final Groups groups;
-  final AccountCache accountCache;
-  final PatchListCache patchListCache;
-  final ApprovalsUtil approvalsUtil;
-  final FromAddressGenerator fromAddressGenerator;
-  final EmailSender emailSender;
-  final PatchSetInfoFactory patchSetInfoFactory;
-  final IdentifiedUser.GenericFactory identifiedUserFactory;
-  final ChangeNotes.Factory changeNotesFactory;
-  final AnonymousUser anonymousUser;
-  final String anonymousCowardName;
-  final PersonIdent gerritPersonIdent;
-  final Provider<String> urlProvider;
-  final AllProjectsName allProjectsName;
-  final List<String> sshAddresses;
-  final SitePaths site;
-
-  final ChangeQueryBuilder queryBuilder;
-  final Provider<ReviewDb> db;
-  final ChangeData.Factory changeDataFactory;
-  final RuntimeInstance velocityRuntime;
-  final SoyTofu soyTofu;
-  final EmailSettings settings;
-  final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
-  final Provider<InternalAccountQuery> accountQueryProvider;
-  final OutgoingEmailValidator validator;
-
-  @Inject
-  EmailArguments(
-      GitRepositoryManager server,
-      ProjectCache projectCache,
-      PermissionBackend permissionBackend,
-      GroupBackend groupBackend,
-      GroupIncludeCache groupIncludes,
-      AccountCache accountCache,
-      PatchListCache patchListCache,
-      ApprovalsUtil approvalsUtil,
-      FromAddressGenerator fromAddressGenerator,
-      EmailSender emailSender,
-      PatchSetInfoFactory patchSetInfoFactory,
-      GenericFactory identifiedUserFactory,
-      ChangeNotes.Factory changeNotesFactory,
-      AnonymousUser anonymousUser,
-      @AnonymousCowardName String anonymousCowardName,
-      GerritPersonIdentProvider gerritPersonIdentProvider,
-      Groups groups,
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      AllProjectsName allProjectsName,
-      ChangeQueryBuilder queryBuilder,
-      Provider<ReviewDb> db,
-      ChangeData.Factory changeDataFactory,
-      RuntimeInstance velocityRuntime,
-      @MailTemplates SoyTofu soyTofu,
-      EmailSettings settings,
-      @SshAdvertisedAddresses List<String> sshAddresses,
-      SitePaths site,
-      DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners,
-      Provider<InternalAccountQuery> accountQueryProvider,
-      OutgoingEmailValidator validator) {
-    this.server = server;
-    this.projectCache = projectCache;
-    this.permissionBackend = permissionBackend;
-    this.groupBackend = groupBackend;
-    this.groupIncludes = groupIncludes;
-    this.accountCache = accountCache;
-    this.patchListCache = patchListCache;
-    this.approvalsUtil = approvalsUtil;
-    this.fromAddressGenerator = fromAddressGenerator;
-    this.emailSender = emailSender;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.identifiedUserFactory = identifiedUserFactory;
-    this.changeNotesFactory = changeNotesFactory;
-    this.anonymousUser = anonymousUser;
-    this.anonymousCowardName = anonymousCowardName;
-    this.gerritPersonIdent = gerritPersonIdentProvider.get();
-    this.groups = groups;
-    this.urlProvider = urlProvider;
-    this.allProjectsName = allProjectsName;
-    this.queryBuilder = queryBuilder;
-    this.db = db;
-    this.changeDataFactory = changeDataFactory;
-    this.velocityRuntime = velocityRuntime;
-    this.soyTofu = soyTofu;
-    this.settings = settings;
-    this.sshAddresses = sshAddresses;
-    this.site = site;
-    this.outgoingEmailValidationListeners = outgoingEmailValidationListeners;
-    this.accountQueryProvider = accountQueryProvider;
-    this.validator = validator;
-  }
-}
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
deleted file mode 100644
index bceac72..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ /dev/null
@@ -1,130 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.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.Branch;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
-import com.google.gwtorm.server.OrmException;
-import java.util.HashMap;
-import java.util.Map;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Common class for notifications that are related to a project and branch */
-public abstract class NotificationEmail extends OutgoingEmail {
-  private static final Logger log = LoggerFactory.getLogger(NotificationEmail.class);
-
-  protected Branch.NameKey branch;
-
-  protected NotificationEmail(EmailArguments ea, String mc, Branch.NameKey branch) {
-    super(ea, mc);
-    this.branch = branch;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setListIdHeader();
-  }
-
-  private void setListIdHeader() throws EmailException {
-    // Set a reasonable list id so that filters can be used to sort messages
-    setVHeader("List-Id", "<$email.listId.replace('@', '.')>");
-    if (getSettingsUrl() != null) {
-      setVHeader("List-Unsubscribe", "<$email.settingsUrl>");
-    }
-  }
-
-  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);
-  }
-
-  /** Include users and groups that want notification of events. */
-  protected void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
-    try {
-      Watchers matching = getWatchers(type, includeWatchersFromNotifyConfig);
-      add(RecipientType.TO, matching.to);
-      add(RecipientType.CC, matching.cc);
-      add(RecipientType.BCC, matching.bcc);
-    } catch (OrmException err) {
-      // Just don't CC everyone. Better to send a partial message to those
-      // we already have queued up then to fail deliver entirely to people
-      // who have a lower interest in the change.
-      log.warn("Cannot BCC watchers for " + type, err);
-    }
-  }
-
-  /** Returns all watchers that are relevant */
-  protected abstract Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig)
-      throws OrmException;
-
-  /** Add users or email addresses to the TO, CC, or BCC list. */
-  protected void add(RecipientType type, Watchers.List list) {
-    for (Account.Id user : list.accounts) {
-      add(type, user);
-    }
-    for (Address addr : list.emails) {
-      add(type, addr);
-    }
-  }
-
-  public String getSshHost() {
-    String host = Iterables.getFirst(args.sshAddresses, null);
-    if (host == null) {
-      return null;
-    }
-    if (host.startsWith("*:")) {
-      return getGerritHost() + host.substring(1);
-    }
-    return host;
-  }
-
-  @Override
-  protected void setupVelocityContext() {
-    super.setupVelocityContext();
-    velocityContext.put("projectName", branch.getParentKey().get());
-    velocityContext.put("branch", branch);
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-
-    String projectName = branch.getParentKey().get();
-    soyContext.put("projectName", projectName);
-    // shortProjectName is the project name with the path abbreviated.
-    soyContext.put("shortProjectName", projectName.replaceAll("/.*/", "..."));
-
-    soyContextEmailData.put("sshHost", getSshHost());
-
-    Map<String, String> branchData = new HashMap<>();
-    branchData.put("shortName", branch.getShortName());
-    soyContext.put("branch", branchData);
-
-    footers.add("Gerrit-Project: " + branch.getParentKey().get());
-    footers.add("Gerrit-Branch: " + branch.getShortName());
-  }
-}
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
deleted file mode 100644
index e569adf..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ /dev/null
@@ -1,675 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import static com.google.common.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;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.UserIdentity;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.EmailHeader.AddressList;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
-import com.google.gerrit.server.validators.ValidationException;
-import com.google.gwtorm.server.OrmException;
-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;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.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;
-
-/** Sends an email to one or more interested parties. */
-public abstract class OutgoingEmail {
-  private static final Logger log = LoggerFactory.getLogger(OutgoingEmail.class);
-
-  private static final String HDR_TO = "To";
-  private static final String HDR_CC = "CC";
-
-  protected String messageClass;
-  private final HashSet<Account.Id> rcptTo = new HashSet<>();
-  private final Map<String, EmailHeader> headers;
-  private final Set<Address> smtpRcptTo = new HashSet<>();
-  private Address smtpFromAddress;
-  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;
-  protected final EmailArguments args;
-  protected Account.Id fromId;
-  protected NotifyHandling notify = NotifyHandling.ALL;
-
-  protected OutgoingEmail(EmailArguments ea, String mc) {
-    args = ea;
-    messageClass = mc;
-    headers = new LinkedHashMap<>();
-  }
-
-  public void setFrom(Account.Id id) {
-    fromId = id;
-  }
-
-  public void setNotify(NotifyHandling notify) {
-    this.notify = checkNotNull(notify);
-  }
-
-  public void setAccountsToNotify(ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    this.accountsToNotify = checkNotNull(accountsToNotify);
-  }
-
-  /**
-   * Format and enqueue the message for delivery.
-   *
-   * @throws EmailException
-   */
-  public void send() throws EmailException {
-    if (NotifyHandling.NONE.equals(notify) && accountsToNotify.isEmpty()) {
-      return;
-    }
-
-    if (!args.emailSender.isEnabled()) {
-      // Server has explicitly disabled email sending.
-      //
-      return;
-    }
-
-    init();
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("HeaderHtml"));
-    }
-    format();
-    appendText(textTemplate("Footer"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("FooterHtml"));
-    }
-
-    Set<Address> smtpRcptToPlaintextOnly = new HashSet<>();
-    if (shouldSendMessage()) {
-      if (fromId != null) {
-        final Account fromUser = args.accountCache.get(fromId).getAccount();
-        GeneralPreferencesInfo senderPrefs = fromUser.getGeneralPreferencesInfo();
-
-        if (senderPrefs != null && senderPrefs.getEmailStrategy() == CC_ON_OWN_COMMENTS) {
-          // If we are impersonating a user, make sure they receive a CC of
-          // this message so they can always review and audit what we sent
-          // on their behalf to others.
-          //
-          add(RecipientType.CC, fromId);
-        } else if (!accountsToNotify.containsValue(fromId) && rcptTo.remove(fromId)) {
-          // If they don't want a copy, but we queued one up anyway,
-          // drop them from the recipient lists.
-          //
-          removeUser(fromUser);
-        }
-      }
-      // Check the preferences of all recipients. If any user has disabled
-      // his email notifications then drop him from recipients' list.
-      // In addition, check if users only want to receive plaintext email.
-      for (Account.Id id : rcptTo) {
-        Account thisUser = args.accountCache.get(id).getAccount();
-        GeneralPreferencesInfo prefs = thisUser.getGeneralPreferencesInfo();
-        if (prefs == null || prefs.getEmailStrategy() == DISABLED) {
-          removeUser(thisUser);
-        } else if (useHtml() && prefs.getEmailFormat() == EmailFormat.PLAINTEXT) {
-          removeUser(thisUser);
-          smtpRcptToPlaintextOnly.add(
-              new Address(thisUser.getFullName(), thisUser.getPreferredEmail()));
-        }
-        if (smtpRcptTo.isEmpty() && smtpRcptToPlaintextOnly.isEmpty()) {
-          return;
-        }
-      }
-
-      // Set Reply-To only if it hasn't been set by a child class
-      // Reply-To will already be populated for the message types where Gerrit supports
-      // inbound email replies.
-      if (!headers.containsKey("Reply-To")) {
-        StringJoiner j = new StringJoiner(", ");
-        if (fromId != null) {
-          Address address = toAddress(fromId);
-          if (address != null) {
-            j.add(address.getEmail());
-          }
-        }
-        smtpRcptTo.stream().forEach(a -> j.add(a.getEmail()));
-        smtpRcptToPlaintextOnly.stream().forEach(a -> j.add(a.getEmail()));
-        setHeader("Reply-To", j.toString());
-      }
-
-      String textPart = textBody.toString();
-      OutgoingEmailValidationListener.Args va = new OutgoingEmailValidationListener.Args();
-      va.messageClass = messageClass;
-      va.smtpFromAddress = smtpFromAddress;
-      va.smtpRcptTo = smtpRcptTo;
-      va.headers = headers;
-      va.body = textPart;
-
-      if (useHtml()) {
-        va.htmlBody = htmlBody.toString();
-      } else {
-        va.htmlBody = null;
-      }
-
-      for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
-        try {
-          validator.validateOutgoingEmail(va);
-        } catch (ValidationException e) {
-          return;
-        }
-      }
-
-      if (!smtpRcptTo.isEmpty()) {
-        // Send multipart message
-        args.emailSender.send(va.smtpFromAddress, va.smtpRcptTo, va.headers, va.body, va.htmlBody);
-      }
-
-      if (!smtpRcptToPlaintextOnly.isEmpty()) {
-        // Send plaintext message
-        Map<String, EmailHeader> shallowCopy = new HashMap<>();
-        shallowCopy.putAll(headers);
-        // Remove To and Cc
-        shallowCopy.remove(HDR_TO);
-        shallowCopy.remove(HDR_CC);
-        for (Address a : smtpRcptToPlaintextOnly) {
-          // Add new To
-          EmailHeader.AddressList to = new EmailHeader.AddressList();
-          to.add(a);
-          shallowCopy.put(HDR_TO, to);
-        }
-        args.emailSender.send(va.smtpFromAddress, smtpRcptToPlaintextOnly, shallowCopy, va.body);
-      }
-    }
-  }
-
-  /** Format the message body by calling {@link #appendText(String)}. */
-  protected abstract void format() throws EmailException;
-
-  /**
-   * Setup the message headers and envelope (TO, CC, BCC).
-   *
-   * @throws EmailException if an error occurred.
-   */
-  protected void init() throws EmailException {
-    setupVelocityContext();
-    setupSoyContext();
-
-    smtpFromAddress = args.fromAddressGenerator.from(fromId);
-    setHeader("Date", new Date());
-    headers.put("From", new EmailHeader.AddressList(smtpFromAddress));
-    headers.put(HDR_TO, new EmailHeader.AddressList());
-    headers.put(HDR_CC, new EmailHeader.AddressList());
-    setHeader("Message-ID", "");
-
-    for (RecipientType recipientType : accountsToNotify.keySet()) {
-      add(recipientType, accountsToNotify.get(recipientType));
-    }
-
-    setHeader("X-Gerrit-MessageType", messageClass);
-    textBody = new StringBuilder();
-    htmlBody = new StringBuilder();
-
-    if (fromId != null && args.fromAddressGenerator.isGenericAddress(fromId)) {
-      appendText(getFromLine());
-    }
-  }
-
-  protected String getFromLine() {
-    final Account account = args.accountCache.get(fromId).getAccount();
-    final String name = account.getFullName();
-    final String email = account.getPreferredEmail();
-    StringBuilder f = new StringBuilder();
-
-    if ((name != null && !name.isEmpty()) || (email != null && !email.isEmpty())) {
-      f.append("From");
-      if (name != null && !name.isEmpty()) {
-        f.append(" ").append(name);
-      }
-      if (email != null && !email.isEmpty()) {
-        f.append(" <").append(email).append(">");
-      }
-      f.append(":\n\n");
-    }
-    return f.toString();
-  }
-
-  public String getGerritHost() {
-    if (getGerritUrl() != null) {
-      try {
-        return new URL(getGerritUrl()).getHost();
-      } catch (MalformedURLException e) {
-        // Try something else.
-      }
-    }
-
-    // Fall back onto whatever the local operating system thinks
-    // this server is called. We hopefully didn't get here as a
-    // good admin would have configured the canonical url.
-    //
-    return SystemReader.getInstance().getHostname();
-  }
-
-  public String getSettingsUrl() {
-    if (getGerritUrl() != null) {
-      final StringBuilder r = new StringBuilder();
-      r.append(getGerritUrl());
-      r.append("settings");
-      return r.toString();
-    }
-    return null;
-  }
-
-  public String getGerritUrl() {
-    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));
-  }
-
-  /** Remove a header from the outgoing message. */
-  protected void removeHeader(String name) {
-    headers.remove(name);
-  }
-
-  protected void setHeader(String name, Date date) {
-    headers.put(name, new EmailHeader.Date(date));
-  }
-
-  /** Append text to the outgoing email body. */
-  protected void appendText(String text) {
-    if (text != null) {
-      textBody.append(text);
-    }
-  }
-
-  /** Append html to the outgoing email body. */
-  protected void appendHtml(String html) {
-    if (html != null) {
-      htmlBody.append(html);
-    }
-  }
-
-  /** Lookup a human readable name for an account, usually the "full name". */
-  protected String getNameFor(Account.Id accountId) {
-    if (accountId == null) {
-      return args.gerritPersonIdent.getName();
-    }
-
-    final Account userAccount = args.accountCache.get(accountId).getAccount();
-    String name = userAccount.getFullName();
-    if (name == null) {
-      name = userAccount.getPreferredEmail();
-    }
-    if (name == null) {
-      name = args.anonymousCowardName + " #" + accountId;
-    }
-    return name;
-  }
-
-  /**
-   * Gets the human readable name and email for an account; if neither are available, returns the
-   * Anonymous Coward name.
-   *
-   * @param accountId user to fetch.
-   * @return name/email of account, or Anonymous Coward if unset.
-   */
-  public String getNameEmailFor(Account.Id accountId) {
-    AccountState who = args.accountCache.get(accountId);
-    String name = who.getAccount().getFullName();
-    String email = who.getAccount().getPreferredEmail();
-
-    if (name != null && email != null) {
-      return name + " <" + email + ">";
-
-    } else if (name != null) {
-      return name;
-    } else if (email != null) {
-      return email;
-
-    } else /* (name == null && email == null) */ {
-      return args.anonymousCowardName + " #" + accountId;
-    }
-  }
-
-  /**
-   * Gets the human readable name and email for an account; if both are unavailable, returns the
-   * username. If no username is set, this function returns null.
-   *
-   * @param accountId user to fetch.
-   * @return name/email of account, username, or null if unset.
-   */
-  public String getUserNameEmailFor(Account.Id accountId) {
-    AccountState who = args.accountCache.get(accountId);
-    String name = who.getAccount().getFullName();
-    String email = who.getAccount().getPreferredEmail();
-
-    if (name != null && email != null) {
-      return name + " <" + email + ">";
-    } else if (email != null) {
-      return email;
-    } else if (name != null) {
-      return name;
-    }
-    String username = who.getUserName();
-    if (username != null) {
-      return username;
-    }
-    return null;
-  }
-
-  protected boolean shouldSendMessage() {
-    if (textBody.length() == 0) {
-      // If we have no message body, don't send.
-      return false;
-    }
-
-    if (smtpRcptTo.isEmpty()) {
-      // If we have nobody to send this message to, then all of our
-      // selection filters previously for this type of message were
-      // unable to match a destination. Don't bother sending it.
-      return false;
-    }
-
-    if ((accountsToNotify == null || accountsToNotify.isEmpty())
-        && smtpRcptTo.size() == 1
-        && rcptTo.size() == 1
-        && rcptTo.contains(fromId)) {
-      // If the only recipient is also the sender, don't bother.
-      //
-      return false;
-    }
-
-    return true;
-  }
-
-  /** Schedule this message for delivery to the listed accounts. */
-  protected void add(RecipientType rt, Collection<Account.Id> list) {
-    add(rt, list, false);
-  }
-
-  /** Schedule this message for delivery to the listed accounts. */
-  protected void add(RecipientType rt, Collection<Account.Id> list, boolean override) {
-    for (final Account.Id id : list) {
-      add(rt, id, override);
-    }
-  }
-
-  /** Schedule this message for delivery to the listed address. */
-  protected void addByEmail(RecipientType rt, Collection<Address> list) {
-    addByEmail(rt, list, false);
-  }
-
-  /** Schedule this message for delivery to the listed address. */
-  protected void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
-    for (final Address id : list) {
-      add(rt, id, override);
-    }
-  }
-
-  protected void add(RecipientType rt, UserIdentity who) {
-    add(rt, who, false);
-  }
-
-  protected void add(RecipientType rt, UserIdentity who, boolean override) {
-    if (who != null && who.getAccount() != null) {
-      add(rt, who.getAccount(), override);
-    }
-  }
-
-  /** Schedule delivery of this message to the given account. */
-  protected void add(RecipientType rt, Account.Id to) {
-    add(rt, to, false);
-  }
-
-  protected void add(RecipientType rt, Account.Id to, boolean override) {
-    try {
-      if (!rcptTo.contains(to) && isVisibleTo(to)) {
-        rcptTo.add(to);
-        add(rt, toAddress(to), override);
-      }
-    } catch (OrmException | PermissionBackendException e) {
-      log.error("Error reading database for account: " + to, e);
-    }
-  }
-
-  /**
-   * @param to account.
-   * @throws OrmException
-   * @throws PermissionBackendException
-   * @return whether this email is visible to the given account.
-   */
-  protected boolean isVisibleTo(Account.Id to) throws OrmException, PermissionBackendException {
-    return true;
-  }
-
-  /** Schedule delivery of this message to the given account. */
-  protected void add(RecipientType rt, Address addr) {
-    add(rt, addr, false);
-  }
-
-  protected void add(RecipientType rt, Address addr, boolean override) {
-    if (addr != null && addr.getEmail() != null && addr.getEmail().length() > 0) {
-      if (!args.validator.isValid(addr.getEmail())) {
-        log.warn("Not emailing " + addr.getEmail() + " (invalid email address)");
-      } else if (!args.emailSender.canEmail(addr.getEmail())) {
-        log.warn("Not emailing " + addr.getEmail() + " (prohibited by allowrcpt)");
-      } else {
-        if (!smtpRcptTo.add(addr)) {
-          if (!override) {
-            return;
-          }
-          ((EmailHeader.AddressList) headers.get(HDR_TO)).remove(addr.getEmail());
-          ((EmailHeader.AddressList) headers.get(HDR_CC)).remove(addr.getEmail());
-        }
-        switch (rt) {
-          case TO:
-            ((EmailHeader.AddressList) headers.get(HDR_TO)).add(addr);
-            break;
-          case CC:
-            ((EmailHeader.AddressList) headers.get(HDR_CC)).add(addr);
-            break;
-          case BCC:
-            break;
-        }
-      }
-    }
-  }
-
-  private Address toAddress(Account.Id id) {
-    final Account a = args.accountCache.get(id).getAccount();
-    final String e = a.getPreferredEmail();
-    if (!a.isActive() || e == null) {
-      return null;
-    }
-    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<>();
-
-    soyContext.put("messageClass", messageClass);
-    soyContext.put("footers", footers);
-
-    soyContextEmailData = new HashMap<>();
-    soyContextEmailData.put("settingsUrl", getSettingsUrl());
-    soyContextEmailData.put("gerritHost", getGerritHost());
-    soyContextEmailData.put("gerritUrl", getGerritUrl());
-    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)
-        .setContentKind(kind)
-        .setData(soyContext)
-        .render();
-  }
-
-  protected String soyTextTemplate(String name) {
-    return soyTemplate(name, SanitizedContent.ContentKind.TEXT);
-  }
-
-  protected String soyHtmlTemplate(String name) {
-    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);
-  }
-
-  public String joinStrings(Iterator<Object> in, String joiner) {
-    if (!in.hasNext()) {
-      return "";
-    }
-
-    Object first = in.next();
-    if (!in.hasNext()) {
-      return safeToString(first);
-    }
-
-    StringBuilder r = new StringBuilder();
-    r.append(safeToString(first));
-    while (in.hasNext()) {
-      r.append(joiner).append(safeToString(in.next()));
-    }
-    return r.toString();
-  }
-
-  protected void removeUser(Account user) {
-    String fromEmail = user.getPreferredEmail();
-    for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext(); ) {
-      if (j.next().getEmail().equals(fromEmail)) {
-        j.remove();
-      }
-    }
-    for (Map.Entry<String, EmailHeader> entry : headers.entrySet()) {
-      // Don't remove fromEmail from the "From" header though!
-      if (entry.getValue() instanceof AddressList && !entry.getKey().equals("From")) {
-        ((AddressList) entry.getValue()).remove(fromEmail);
-      }
-    }
-  }
-
-  private static String safeToString(Object obj) {
-    return obj != null ? obj.toString() : "";
-  }
-
-  protected final boolean useHtml() {
-    return args.settings.html && supportsHtml();
-  }
-
-  /** Override this method to enable HTML in a subclass. */
-  protected boolean supportsHtml() {
-    return false;
-  }
-}
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
deleted file mode 100644
index e1b6e36..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ /dev/null
@@ -1,242 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
-import com.google.gerrit.server.git.NotifyConfig;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.SingleGroupUser;
-import com.google.gwtorm.server.OrmException;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ProjectWatch {
-  private static final Logger log = LoggerFactory.getLogger(ProjectWatch.class);
-
-  protected final EmailArguments args;
-  protected final ProjectState projectState;
-  protected final Project.NameKey project;
-  protected final ChangeData changeData;
-
-  public ProjectWatch(
-      EmailArguments args,
-      Project.NameKey project,
-      ProjectState projectState,
-      ChangeData changeData) {
-    this.args = args;
-    this.project = project;
-    this.projectState = projectState;
-    this.changeData = changeData;
-  }
-
-  /** Returns all watchers that are relevant */
-  public final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig)
-      throws OrmException {
-    Watchers matching = new Watchers();
-    Set<Account.Id> projectWatchers = new HashSet<>();
-
-    for (AccountState a : args.accountQueryProvider.get().byWatchedProject(project)) {
-      Account.Id accountId = a.getAccount().getId();
-      for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : a.getProjectWatches().entrySet()) {
-        if (project.equals(e.getKey().project())
-            && add(matching, accountId, e.getKey(), e.getValue(), type)) {
-          // We only want to prevent matching All-Projects if this filter hits
-          projectWatchers.add(accountId);
-        }
-      }
-    }
-
-    for (AccountState a : args.accountQueryProvider.get().byWatchedProject(args.allProjectsName)) {
-      for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : a.getProjectWatches().entrySet()) {
-        if (args.allProjectsName.equals(e.getKey().project())) {
-          Account.Id accountId = a.getAccount().getId();
-          if (!projectWatchers.contains(accountId)) {
-            add(matching, accountId, e.getKey(), e.getValue(), type);
-          }
-        }
-      }
-    }
-
-    if (!includeWatchersFromNotifyConfig) {
-      return matching;
-    }
-
-    for (ProjectState state : projectState.tree()) {
-      for (NotifyConfig nc : state.getConfig().getNotifyConfigs()) {
-        if (nc.isNotify(type)) {
-          try {
-            add(matching, nc);
-          } catch (QueryParseException e) {
-            log.warn(
-                "Project {} has invalid notify {} filter \"{}\": {}",
-                state.getName(),
-                nc.getName(),
-                nc.getFilter(),
-                e.getMessage());
-          }
-        }
-      }
-    }
-
-    return matching;
-  }
-
-  public static class Watchers {
-    static class List {
-      protected final Set<Account.Id> accounts = new HashSet<>();
-      protected final Set<Address> emails = new HashSet<>();
-    }
-
-    protected final List to = new List();
-    protected final List cc = new List();
-    protected final List bcc = new List();
-
-    List list(NotifyConfig.Header header) {
-      switch (header) {
-        case TO:
-          return to;
-        case CC:
-          return cc;
-        default:
-        case BCC:
-          return bcc;
-      }
-    }
-  }
-
-  private void add(Watchers matching, NotifyConfig nc) throws OrmException, QueryParseException {
-    for (GroupReference ref : nc.getGroups()) {
-      CurrentUser user = new SingleGroupUser(ref.getUUID());
-      if (filterMatch(user, nc.getFilter())) {
-        deliverToMembers(matching.list(nc.getHeader()), ref.getUUID());
-      }
-    }
-
-    if (!nc.getAddresses().isEmpty()) {
-      if (filterMatch(null, nc.getFilter())) {
-        matching.list(nc.getHeader()).emails.addAll(nc.getAddresses());
-      }
-    }
-  }
-
-  private void deliverToMembers(Watchers.List matching, AccountGroup.UUID startUUID)
-      throws OrmException {
-    ReviewDb db = args.db.get();
-    Set<AccountGroup.UUID> seen = new HashSet<>();
-    List<AccountGroup.UUID> q = new ArrayList<>();
-
-    seen.add(startUUID);
-    q.add(startUUID);
-
-    while (!q.isEmpty()) {
-      AccountGroup.UUID uuid = q.remove(q.size() - 1);
-      GroupDescription.Basic group = args.groupBackend.get(uuid);
-      if (group == null) {
-        continue;
-      }
-      if (!Strings.isNullOrEmpty(group.getEmailAddress())) {
-        // If the group has an email address, do not expand membership.
-        matching.emails.add(new Address(group.getEmailAddress()));
-        continue;
-      }
-
-      if (!(group instanceof GroupDescription.Internal)) {
-        // Non-internal groups cannot be expanded by the server.
-        continue;
-      }
-
-      GroupDescription.Internal ig = (GroupDescription.Internal) group;
-      try {
-        args.groups.getMembers(db, ig.getGroupUUID()).forEach(matching.accounts::add);
-      } catch (NoSuchGroupException e) {
-        continue;
-      }
-      for (AccountGroup.UUID m : args.groupIncludes.subgroupsOf(uuid)) {
-        if (seen.add(m)) {
-          q.add(m);
-        }
-      }
-    }
-  }
-
-  private boolean add(
-      Watchers matching,
-      Account.Id accountId,
-      ProjectWatchKey key,
-      Set<NotifyType> watchedTypes,
-      NotifyType type)
-      throws OrmException {
-    IdentifiedUser user = args.identifiedUserFactory.create(accountId);
-
-    try {
-      if (filterMatch(user, key.filter())) {
-        // If we are set to notify on this type, add the user.
-        // Otherwise, still return true to stop notifications for this user.
-        if (watchedTypes.contains(type)) {
-          matching.bcc.accounts.add(accountId);
-        }
-        return true;
-      }
-    } catch (QueryParseException e) {
-      // Ignore broken filter expressions.
-    }
-    return false;
-  }
-
-  private boolean filterMatch(CurrentUser user, String filter)
-      throws OrmException, QueryParseException {
-    ChangeQueryBuilder qb;
-    Predicate<ChangeData> p = null;
-
-    if (user == null) {
-      qb = args.queryBuilder.asUser(args.anonymousUser);
-    } else {
-      qb = args.queryBuilder.asUser(user);
-      p = qb.is_visible();
-    }
-
-    if (filter != null) {
-      Predicate<ChangeData> filterPredicate = qb.parse(filter);
-      if (p == null) {
-        p = filterPredicate;
-      } else {
-        p = Predicate.and(filterPredicate, p);
-      }
-    }
-    return p == null || p.asMatchable().match(changeData);
-  }
-}
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/notedb/AbstractChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
deleted file mode 100644
index f3f3a13..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ /dev/null
@@ -1,316 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.InternalUser;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.Date;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/** A single delta related to a specific patch-set of a change. */
-public abstract class AbstractChangeUpdate {
-  protected final NotesMigration migration;
-  protected final ChangeNoteUtil noteUtil;
-  protected final String anonymousCowardName;
-  protected final Account.Id accountId;
-  protected final Account.Id realAccountId;
-  protected final PersonIdent authorIdent;
-  protected final Date when;
-  private final long readOnlySkewMs;
-
-  @Nullable private final ChangeNotes notes;
-  private final Change change;
-  protected final PersonIdent serverIdent;
-
-  protected PatchSet.Id psId;
-  private ObjectId result;
-  protected boolean rootOnly;
-
-  protected AbstractChangeUpdate(
-      Config cfg,
-      NotesMigration migration,
-      ChangeNotes notes,
-      CurrentUser user,
-      PersonIdent serverIdent,
-      String anonymousCowardName,
-      ChangeNoteUtil noteUtil,
-      Date when) {
-    this.migration = migration;
-    this.noteUtil = noteUtil;
-    this.serverIdent = new PersonIdent(serverIdent, when);
-    this.anonymousCowardName = anonymousCowardName;
-    this.notes = notes;
-    this.change = notes.getChange();
-    this.accountId = accountId(user);
-    Account.Id realAccountId = accountId(user.getRealUser());
-    this.realAccountId = realAccountId != null ? realAccountId : accountId;
-    this.authorIdent = ident(noteUtil, serverIdent, anonymousCowardName, user, when);
-    this.when = when;
-    this.readOnlySkewMs = NoteDbChangeState.getReadOnlySkew(cfg);
-  }
-
-  protected AbstractChangeUpdate(
-      Config cfg,
-      NotesMigration migration,
-      ChangeNoteUtil noteUtil,
-      PersonIdent serverIdent,
-      String anonymousCowardName,
-      @Nullable ChangeNotes notes,
-      @Nullable Change change,
-      Account.Id accountId,
-      Account.Id realAccountId,
-      PersonIdent authorIdent,
-      Date when) {
-    checkArgument(
-        (notes != null && change == null) || (notes == null && change != null),
-        "exactly one of notes or change required");
-    this.migration = migration;
-    this.noteUtil = noteUtil;
-    this.serverIdent = new PersonIdent(serverIdent, when);
-    this.anonymousCowardName = anonymousCowardName;
-    this.notes = notes;
-    this.change = change != null ? change : notes.getChange();
-    this.accountId = accountId;
-    this.realAccountId = realAccountId;
-    this.authorIdent = authorIdent;
-    this.when = when;
-    this.readOnlySkewMs = NoteDbChangeState.getReadOnlySkew(cfg);
-  }
-
-  private static void checkUserType(CurrentUser user) {
-    checkArgument(
-        (user instanceof IdentifiedUser) || (user instanceof InternalUser),
-        "user must be IdentifiedUser or InternalUser: %s",
-        user);
-  }
-
-  private static Account.Id accountId(CurrentUser u) {
-    checkUserType(u);
-    return (u instanceof IdentifiedUser) ? u.getAccountId() : null;
-  }
-
-  private static PersonIdent ident(
-      ChangeNoteUtil noteUtil,
-      PersonIdent serverIdent,
-      String anonymousCowardName,
-      CurrentUser u,
-      Date when) {
-    checkUserType(u);
-    if (u instanceof IdentifiedUser) {
-      return noteUtil.newIdent(
-          u.asIdentifiedUser().getAccount(), when, serverIdent, anonymousCowardName);
-    } else if (u instanceof InternalUser) {
-      return serverIdent;
-    }
-    throw new IllegalStateException();
-  }
-
-  public Change.Id getId() {
-    return change.getId();
-  }
-
-  /**
-   * @return notes for the state of this change prior to this update. If this update is part of a
-   *     series managed by a {@link NoteDbUpdateManager}, then this reflects the state prior to the
-   *     first update in the series. A null return value can only happen when the change is being
-   *     rebuilt from NoteDb. A change that is in the process of being created will result in a
-   *     non-null return value from this method, but a null return value from {@link
-   *     ChangeNotes#getRevision()}.
-   */
-  @Nullable
-  public ChangeNotes getNotes() {
-    return notes;
-  }
-
-  public Change getChange() {
-    return change;
-  }
-
-  public Date getWhen() {
-    return when;
-  }
-
-  public PatchSet.Id getPatchSetId() {
-    return psId;
-  }
-
-  public void setPatchSetId(PatchSet.Id psId) {
-    checkArgument(psId == null || psId.getParentKey().equals(getId()));
-    this.psId = psId;
-  }
-
-  public Account.Id getAccountId() {
-    checkState(
-        accountId != null,
-        "author identity for %s is not from an IdentifiedUser: %s",
-        getClass().getSimpleName(),
-        authorIdent.toExternalString());
-    return accountId;
-  }
-
-  public Account.Id getNullableAccountId() {
-    return accountId;
-  }
-
-  protected PersonIdent newIdent(Account author, Date when) {
-    return noteUtil.newIdent(author, when, serverIdent, anonymousCowardName);
-  }
-
-  /** Whether no updates have been done. */
-  public abstract boolean isEmpty();
-
-  /** Wether this update can only be a root commit. */
-  public boolean isRootOnly() {
-    return rootOnly;
-  }
-
-  /**
-   * @return the NameKey for the project where the update will be stored, which is not necessarily
-   *     the same as the change's project.
-   */
-  protected abstract Project.NameKey getProjectName();
-
-  protected abstract String getRefName();
-
-  /**
-   * Apply this update to the given inserter.
-   *
-   * @param rw walk for reading back any objects needed for the update.
-   * @param ins inserter to write to; callers should not flush.
-   * @param curr the current tip of the branch prior to this update.
-   * @return commit ID produced by inserting this update's commit, or null if this update is a no-op
-   *     and should be skipped. The zero ID is a valid return value, and indicates the ref should be
-   *     deleted.
-   * @throws OrmException if a Gerrit-level error occurred.
-   * @throws IOException if a lower-level error occurred.
-   */
-  final ObjectId apply(RevWalk rw, ObjectInserter ins, ObjectId curr)
-      throws OrmException, IOException {
-    if (isEmpty()) {
-      return null;
-    }
-
-    // Allow this method to proceed even if migration.failChangeWrites() = true.
-    // This may be used by an auto-rebuilding step that the caller does not plan
-    // to actually store.
-
-    checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
-    checkNotReadOnly();
-    ObjectId z = ObjectId.zeroId();
-    CommitBuilder cb = applyImpl(rw, ins, curr);
-    if (cb == null) {
-      result = z;
-      return z; // Impl intends to delete the ref.
-    } else if (cb == NO_OP_UPDATE) {
-      return null; // Impl is a no-op.
-    }
-    cb.setAuthor(authorIdent);
-    cb.setCommitter(new PersonIdent(serverIdent, when));
-    if (!curr.equals(z)) {
-      cb.setParentId(curr);
-    } else {
-      cb.setParentIds(); // Ref is currently nonexistent, commit has no parents.
-    }
-    if (cb.getTreeId() == null) {
-      if (curr.equals(z)) {
-        cb.setTreeId(emptyTree(ins)); // No parent, assume empty tree.
-      } else {
-        RevCommit p = rw.parseCommit(curr);
-        cb.setTreeId(p.getTree()); // Copy tree from parent.
-      }
-    }
-    result = ins.insert(cb);
-    return result;
-  }
-
-  protected void checkNotReadOnly() throws OrmException {
-    ChangeNotes notes = getNotes();
-    if (notes == null) {
-      // Can only happen during ChangeRebuilder, which will never include a read-only lease.
-      return;
-    }
-    Timestamp until = notes.getReadOnlyUntil();
-    if (until != null && NoteDbChangeState.timeForReadOnlyCheck(readOnlySkewMs).before(until)) {
-      throw new OrmException("change " + notes.getChangeId() + " is read-only until " + until);
-    }
-  }
-
-  /**
-   * Create a commit containing the contents of this update.
-   *
-   * @param ins inserter to write to; callers should not flush.
-   * @return a new commit builder representing this commit, or null to indicate the meta ref should
-   *     be deleted as a result of this update. The parent, author, and committer fields in the
-   *     return value are always overwritten. The tree ID may be unset by this method, which
-   *     indicates to the caller that it should be copied from the parent commit. To indicate that
-   *     this update is a no-op (but this could not be determined by {@link #isEmpty()}), return the
-   *     sentinel {@link #NO_OP_UPDATE}.
-   * @throws OrmException if a Gerrit-level error occurred.
-   * @throws IOException if a lower-level error occurred.
-   */
-  protected abstract CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
-      throws OrmException, IOException;
-
-  protected static final CommitBuilder NO_OP_UPDATE = new CommitBuilder();
-
-  ObjectId getResult() {
-    return result;
-  }
-
-  public boolean allowWriteToNewRef() {
-    return true;
-  }
-
-  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
-    return ins.insert(Constants.OBJ_TREE, new byte[] {});
-  }
-
-  protected void verifyComment(Comment c) {
-    checkArgument(c.revId != null, "RevId required for comment: %s", c);
-    checkArgument(
-        c.author.getId().equals(getAccountId()),
-        "The author for the following comment does not match the author of this %s (%s): %s",
-        getClass().getSimpleName(),
-        getAccountId(),
-        c);
-    checkArgument(
-        c.getRealAuthor().getId().equals(realAccountId),
-        "The real author for the following comment does not match the real"
-            + " author of this %s (%s): %s",
-        getClass().getSimpleName(),
-        realAccountId,
-        c);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
deleted file mode 100644
index a9663c7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
+++ /dev/null
@@ -1,1033 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.common.TimeUtil.roundToSecond;
-import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
-import static com.google.gerrit.server.notedb.ChangeBundle.Source.NOTE_DB;
-import static com.google.gerrit.server.notedb.ChangeBundle.Source.REVIEW_DB;
-import static java.util.stream.Collectors.toList;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Function;
-import com.google.common.base.Predicate;
-import com.google.common.base.Predicates;
-import com.google.common.base.Strings;
-import com.google.common.collect.Collections2;
-import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.ImmutableCollection;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSortedMap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Ordering;
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.server.OrmException;
-import java.lang.reflect.Field;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Comparator;
-import java.util.Iterator;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
-import java.util.TreeMap;
-import java.util.TreeSet;
-
-/**
- * A bundle of all entities rooted at a single {@link Change} entity.
- *
- * <p>See the {@link Change} Javadoc for a depiction of this tree. Bundles may be compared using
- * {@link #differencesFrom(ChangeBundle)}, which normalizes out the minor implementation differences
- * between ReviewDb and NoteDb.
- */
-public class ChangeBundle {
-  public enum Source {
-    REVIEW_DB,
-    NOTE_DB;
-  }
-
-  public static ChangeBundle fromNotes(CommentsUtil commentsUtil, ChangeNotes notes)
-      throws OrmException {
-    return new ChangeBundle(
-        notes.getChange(),
-        notes.getChangeMessages(),
-        notes.getPatchSets().values(),
-        notes.getApprovals().values(),
-        Iterables.concat(
-            CommentsUtil.toPatchLineComments(
-                notes.getChangeId(),
-                PatchLineComment.Status.DRAFT,
-                commentsUtil.draftByChange(null, notes)),
-            CommentsUtil.toPatchLineComments(
-                notes.getChangeId(),
-                PatchLineComment.Status.PUBLISHED,
-                commentsUtil.publishedByChange(null, notes))),
-        notes.getReviewers(),
-        Source.NOTE_DB);
-  }
-
-  private static Map<ChangeMessage.Key, ChangeMessage> changeMessageMap(
-      Iterable<ChangeMessage> in) {
-    Map<ChangeMessage.Key, ChangeMessage> out =
-        new TreeMap<>(
-            new Comparator<ChangeMessage.Key>() {
-              @Override
-              public int compare(ChangeMessage.Key a, ChangeMessage.Key b) {
-                return ComparisonChain.start()
-                    .compare(a.getParentKey().get(), b.getParentKey().get())
-                    .compare(a.get(), b.get())
-                    .result();
-              }
-            });
-    for (ChangeMessage cm : in) {
-      out.put(cm.getKey(), cm);
-    }
-    return out;
-  }
-
-  // Unlike the *Map comparators, which are intended to make key lists diffable,
-  // this comparator sorts first on timestamp, then on every other field.
-  private static final Ordering<ChangeMessage> CHANGE_MESSAGE_ORDER =
-      new Ordering<ChangeMessage>() {
-        final Ordering<Comparable<?>> nullsFirst = Ordering.natural().nullsFirst();
-
-        @Override
-        public int compare(ChangeMessage a, ChangeMessage b) {
-          return ComparisonChain.start()
-              .compare(a.getWrittenOn(), b.getWrittenOn())
-              .compare(a.getKey().getParentKey().get(), b.getKey().getParentKey().get())
-              .compare(psId(a), psId(b), nullsFirst)
-              .compare(a.getAuthor(), b.getAuthor(), intKeyOrdering())
-              .compare(a.getMessage(), b.getMessage(), nullsFirst)
-              .result();
-        }
-
-        private Integer psId(ChangeMessage m) {
-          return m.getPatchSetId() != null ? m.getPatchSetId().get() : null;
-        }
-      };
-
-  private static ImmutableList<ChangeMessage> changeMessageList(Iterable<ChangeMessage> in) {
-    return CHANGE_MESSAGE_ORDER.immutableSortedCopy(in);
-  }
-
-  private static TreeMap<PatchSet.Id, PatchSet> patchSetMap(Iterable<PatchSet> in) {
-    TreeMap<PatchSet.Id, PatchSet> out =
-        new TreeMap<>(
-            new Comparator<PatchSet.Id>() {
-              @Override
-              public int compare(PatchSet.Id a, PatchSet.Id b) {
-                return patchSetIdChain(a, b).result();
-              }
-            });
-    for (PatchSet ps : in) {
-      out.put(ps.getId(), ps);
-    }
-    return out;
-  }
-
-  private static Map<PatchSetApproval.Key, PatchSetApproval> patchSetApprovalMap(
-      Iterable<PatchSetApproval> in) {
-    Map<PatchSetApproval.Key, PatchSetApproval> out =
-        new TreeMap<>(
-            new Comparator<PatchSetApproval.Key>() {
-              @Override
-              public int compare(PatchSetApproval.Key a, PatchSetApproval.Key b) {
-                return patchSetIdChain(a.getParentKey(), b.getParentKey())
-                    .compare(a.getAccountId().get(), b.getAccountId().get())
-                    .compare(a.getLabelId(), b.getLabelId())
-                    .result();
-              }
-            });
-    for (PatchSetApproval psa : in) {
-      out.put(psa.getKey(), psa);
-    }
-    return out;
-  }
-
-  private static Map<PatchLineComment.Key, PatchLineComment> patchLineCommentMap(
-      Iterable<PatchLineComment> in) {
-    Map<PatchLineComment.Key, PatchLineComment> out =
-        new TreeMap<>(
-            new Comparator<PatchLineComment.Key>() {
-              @Override
-              public int compare(PatchLineComment.Key a, PatchLineComment.Key b) {
-                Patch.Key pka = a.getParentKey();
-                Patch.Key pkb = b.getParentKey();
-                return patchSetIdChain(pka.getParentKey(), pkb.getParentKey())
-                    .compare(pka.get(), pkb.get())
-                    .compare(a.get(), b.get())
-                    .result();
-              }
-            });
-    for (PatchLineComment plc : in) {
-      out.put(plc.getKey(), plc);
-    }
-    return out;
-  }
-
-  private static ComparisonChain patchSetIdChain(PatchSet.Id a, PatchSet.Id b) {
-    return ComparisonChain.start()
-        .compare(a.getParentKey().get(), b.getParentKey().get())
-        .compare(a.get(), b.get());
-  }
-
-  private static void checkColumns(Class<?> clazz, Integer... expected) {
-    Set<Integer> ids = new TreeSet<>();
-    for (Field f : clazz.getDeclaredFields()) {
-      Column col = f.getAnnotation(Column.class);
-      if (col != null) {
-        ids.add(col.id());
-      }
-    }
-    Set<Integer> expectedIds = Sets.newTreeSet(Arrays.asList(expected));
-    checkState(
-        ids.equals(expectedIds),
-        "Unexpected column set for %s: %s != %s",
-        clazz.getSimpleName(),
-        ids,
-        expectedIds);
-  }
-
-  static {
-    // Initialization-time checks that the column set hasn't changed since the
-    // last time this file was updated.
-    checkColumns(Change.Id.class, 1);
-
-    checkColumns(
-        Change.class, 1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 20, 21, 22, 23, 101);
-    checkColumns(ChangeMessage.Key.class, 1, 2);
-    checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6, 7);
-    checkColumns(PatchSet.Id.class, 1, 2);
-    checkColumns(PatchSet.class, 1, 2, 3, 4, 6, 8, 9);
-    checkColumns(PatchSetApproval.Key.class, 1, 2, 3);
-    checkColumns(PatchSetApproval.class, 1, 2, 3, 6, 7, 8);
-    checkColumns(PatchLineComment.Key.class, 1, 2);
-    checkColumns(PatchLineComment.class, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12);
-  }
-
-  private final Change change;
-  private final ImmutableList<ChangeMessage> changeMessages;
-  private final ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
-  private final ImmutableMap<PatchSetApproval.Key, PatchSetApproval> patchSetApprovals;
-  private final ImmutableMap<PatchLineComment.Key, PatchLineComment> patchLineComments;
-  private final ReviewerSet reviewers;
-  private final Source source;
-
-  public ChangeBundle(
-      Change change,
-      Iterable<ChangeMessage> changeMessages,
-      Iterable<PatchSet> patchSets,
-      Iterable<PatchSetApproval> patchSetApprovals,
-      Iterable<PatchLineComment> patchLineComments,
-      ReviewerSet reviewers,
-      Source source) {
-    this.change = checkNotNull(change);
-    this.changeMessages = changeMessageList(changeMessages);
-    this.patchSets = ImmutableSortedMap.copyOfSorted(patchSetMap(patchSets));
-    this.patchSetApprovals = ImmutableMap.copyOf(patchSetApprovalMap(patchSetApprovals));
-    this.patchLineComments = ImmutableMap.copyOf(patchLineCommentMap(patchLineComments));
-    this.reviewers = checkNotNull(reviewers);
-    this.source = checkNotNull(source);
-
-    for (ChangeMessage m : this.changeMessages) {
-      checkArgument(m.getKey().getParentKey().equals(change.getId()));
-    }
-    for (PatchSet.Id id : this.patchSets.keySet()) {
-      checkArgument(id.getParentKey().equals(change.getId()));
-    }
-    for (PatchSetApproval.Key k : this.patchSetApprovals.keySet()) {
-      checkArgument(k.getParentKey().getParentKey().equals(change.getId()));
-    }
-    for (PatchLineComment.Key k : this.patchLineComments.keySet()) {
-      checkArgument(k.getParentKey().getParentKey().getParentKey().equals(change.getId()));
-    }
-  }
-
-  public Change getChange() {
-    return change;
-  }
-
-  public ImmutableCollection<ChangeMessage> getChangeMessages() {
-    return changeMessages;
-  }
-
-  public ImmutableCollection<PatchSet> getPatchSets() {
-    return patchSets.values();
-  }
-
-  public ImmutableCollection<PatchSetApproval> getPatchSetApprovals() {
-    return patchSetApprovals.values();
-  }
-
-  public ImmutableCollection<PatchLineComment> getPatchLineComments() {
-    return patchLineComments.values();
-  }
-
-  public ReviewerSet getReviewers() {
-    return reviewers;
-  }
-
-  public Source getSource() {
-    return source;
-  }
-
-  public ImmutableList<String> differencesFrom(ChangeBundle o) {
-    List<String> diffs = new ArrayList<>();
-    diffChanges(diffs, this, o);
-    diffChangeMessages(diffs, this, o);
-    diffPatchSets(diffs, this, o);
-    diffPatchSetApprovals(diffs, this, o);
-    diffReviewers(diffs, this, o);
-    diffPatchLineComments(diffs, this, o);
-    return ImmutableList.copyOf(diffs);
-  }
-
-  private Timestamp getFirstPatchSetTime() {
-    if (patchSets.isEmpty()) {
-      return change.getCreatedOn();
-    }
-    return patchSets.firstEntry().getValue().getCreatedOn();
-  }
-
-  private Timestamp getLatestTimestamp() {
-    Ordering<Timestamp> o = Ordering.natural().nullsFirst();
-    Timestamp ts = null;
-    for (ChangeMessage cm : filterChangeMessages()) {
-      ts = o.max(ts, cm.getWrittenOn());
-    }
-    for (PatchSet ps : getPatchSets()) {
-      ts = o.max(ts, ps.getCreatedOn());
-    }
-    for (PatchSetApproval psa : filterPatchSetApprovals().values()) {
-      ts = o.max(ts, psa.getGranted());
-    }
-    for (PatchLineComment plc : filterPatchLineComments().values()) {
-      // Ignore draft comments, as they do not show up in the change meta graph.
-      if (plc.getStatus() != PatchLineComment.Status.DRAFT) {
-        ts = o.max(ts, plc.getWrittenOn());
-      }
-    }
-    return firstNonNull(ts, change.getLastUpdatedOn());
-  }
-
-  private Map<PatchSetApproval.Key, PatchSetApproval> filterPatchSetApprovals() {
-    return limitToValidPatchSets(patchSetApprovals, PatchSetApproval.Key::getParentKey);
-  }
-
-  private Map<PatchLineComment.Key, PatchLineComment> filterPatchLineComments() {
-    return limitToValidPatchSets(patchLineComments, k -> k.getParentKey().getParentKey());
-  }
-
-  private <K, V> Map<K, V> limitToValidPatchSets(Map<K, V> in, Function<K, PatchSet.Id> func) {
-    return Maps.filterKeys(in, Predicates.compose(validPatchSetPredicate(), func));
-  }
-
-  private Predicate<PatchSet.Id> validPatchSetPredicate() {
-    return patchSets::containsKey;
-  }
-
-  private Collection<ChangeMessage> filterChangeMessages() {
-    final Predicate<PatchSet.Id> validPatchSet = validPatchSetPredicate();
-    return Collections2.filter(
-        changeMessages,
-        m -> {
-          PatchSet.Id psId = m.getPatchSetId();
-          if (psId == null) {
-            return true;
-          }
-          return validPatchSet.apply(psId);
-        });
-  }
-
-  private static void diffChanges(List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
-    Change a = bundleA.change;
-    Change b = bundleB.change;
-    String desc = a.getId().equals(b.getId()) ? describe(a.getId()) : "Changes";
-
-    boolean excludeCreatedOn = false;
-    boolean excludeCurrentPatchSetId = false;
-    boolean excludeTopic = false;
-    Timestamp aCreated = a.getCreatedOn();
-    Timestamp bCreated = b.getCreatedOn();
-    Timestamp aUpdated = a.getLastUpdatedOn();
-    Timestamp bUpdated = b.getLastUpdatedOn();
-
-    boolean excludeSubject = false;
-    boolean excludeOrigSubj = false;
-    // Subject is not technically a nullable field, but we observed some null
-    // subjects in the wild on googlesource.com, so treat null as empty.
-    String aSubj = Strings.nullToEmpty(a.getSubject());
-    String bSubj = Strings.nullToEmpty(b.getSubject());
-
-    // Allow created timestamp in NoteDb to be any of:
-    //  - The created timestamp of the change.
-    //  - The timestamp of the first remaining patch set.
-    //  - The last updated timestamp, if it is less than the created timestamp.
-    //
-    // Ignore subject if the NoteDb subject starts with the ReviewDb subject.
-    // The NoteDb subject is read directly from the commit, whereas the ReviewDb
-    // subject historically may have been truncated to fit in a SQL varchar
-    // column.
-    //
-    // Ignore original subject on the ReviewDb side when comparing to NoteDb.
-    // This field may have any number of values:
-    //  - It may be null, if the change has had no new patch sets pushed since
-    //    migrating to schema 103.
-    //  - It may match the first patch set subject, if the change was created
-    //    after migrating to schema 103.
-    //  - It may match the subject of the first patch set that was pushed after
-    //    the migration to schema 103, even though that is neither the subject
-    //    of the first patch set nor the subject of the last patch set. (See
-    //    Change#setCurrentPatchSet as of 43b10f86 for this behavior.) This
-    //    subject of an intermediate patch set is not available to the
-    //    ChangeBundle; we would have to get the subject from the repo, which is
-    //    inconvenient at this point.
-    //
-    // Ignore original subject on the ReviewDb side if it equals the subject of
-    // the current patch set.
-    //
-    // For all of the above subject comparisons, first trim any leading spaces
-    // from the NoteDb strings. (We actually do represent the leading spaces
-    // faithfully during conversion, but JGit's FooterLine parser trims them
-    // when reading.)
-    //
-    // Ignore empty topic on the ReviewDb side if it is null on the NoteDb side.
-    //
-    // Ignore currentPatchSetId on NoteDb side if ReviewDb does not point to a
-    // valid patch set.
-    //
-    // Use max timestamp of all ReviewDb entities when comparing with NoteDb.
-    if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
-      boolean createdOnMatchesFirstPs =
-          !timestampsDiffer(bundleA, bundleA.getFirstPatchSetTime(), bundleB, bCreated);
-      boolean createdOnMatchesLastUpdatedOn =
-          !timestampsDiffer(bundleA, aUpdated, bundleB, bCreated);
-      boolean createdAfterUpdated = aCreated.compareTo(aUpdated) > 0;
-      excludeCreatedOn =
-          createdOnMatchesFirstPs || (createdAfterUpdated && createdOnMatchesLastUpdatedOn);
-
-      aSubj = cleanReviewDbSubject(aSubj);
-      bSubj = cleanNoteDbSubject(bSubj);
-      excludeCurrentPatchSetId = !bundleA.validPatchSetPredicate().apply(a.currentPatchSetId());
-      excludeSubject = bSubj.startsWith(aSubj) || excludeCurrentPatchSetId;
-      excludeOrigSubj = true;
-      String aTopic = trimOrNull(a.getTopic());
-      excludeTopic =
-          Objects.equals(aTopic, b.getTopic()) || "".equals(aTopic) && b.getTopic() == null;
-      aUpdated = bundleA.getLatestTimestamp();
-    } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
-      boolean createdOnMatchesFirstPs =
-          !timestampsDiffer(bundleA, aCreated, bundleB, bundleB.getFirstPatchSetTime());
-      boolean createdOnMatchesLastUpdatedOn =
-          !timestampsDiffer(bundleA, aCreated, bundleB, bUpdated);
-      boolean createdAfterUpdated = bCreated.compareTo(bUpdated) > 0;
-      excludeCreatedOn =
-          createdOnMatchesFirstPs || (createdAfterUpdated && createdOnMatchesLastUpdatedOn);
-
-      aSubj = cleanNoteDbSubject(aSubj);
-      bSubj = cleanReviewDbSubject(bSubj);
-      excludeCurrentPatchSetId = !bundleB.validPatchSetPredicate().apply(b.currentPatchSetId());
-      excludeSubject = aSubj.startsWith(bSubj) || excludeCurrentPatchSetId;
-      excludeOrigSubj = true;
-      String bTopic = trimOrNull(b.getTopic());
-      excludeTopic =
-          Objects.equals(bTopic, a.getTopic()) || a.getTopic() == null && "".equals(bTopic);
-      bUpdated = bundleB.getLatestTimestamp();
-    }
-
-    String subjectField = "subject";
-    String updatedField = "lastUpdatedOn";
-    List<String> exclude =
-        Lists.newArrayList(subjectField, updatedField, "noteDbState", "rowVersion");
-    if (excludeCreatedOn) {
-      exclude.add("createdOn");
-    }
-    if (excludeCurrentPatchSetId) {
-      exclude.add("currentPatchSetId");
-    }
-    if (excludeOrigSubj) {
-      exclude.add("originalSubject");
-    }
-    if (excludeTopic) {
-      exclude.add("topic");
-    }
-    diffColumnsExcluding(diffs, Change.class, desc, bundleA, a, bundleB, b, exclude);
-
-    // Allow last updated timestamps to either be exactly equal (within slop),
-    // or the NoteDb timestamp to be equal to the latest entity timestamp in the
-    // whole ReviewDb bundle (within slop).
-    if (timestampsDiffer(bundleA, a.getLastUpdatedOn(), bundleB, b.getLastUpdatedOn())) {
-      diffTimestamps(
-          diffs, desc, bundleA, aUpdated, bundleB, bUpdated, "effective last updated time");
-    }
-    if (!excludeSubject) {
-      diffValues(diffs, desc, aSubj, bSubj, subjectField);
-    }
-  }
-
-  private static String trimOrNull(String s) {
-    return s != null ? CharMatcher.whitespace().trimFrom(s) : null;
-  }
-
-  private static String cleanReviewDbSubject(String s) {
-    s = CharMatcher.is(' ').trimLeadingFrom(s);
-
-    // An old JGit bug failed to extract subjects from commits with "\r\n"
-    // terminators: https://bugs.eclipse.org/bugs/show_bug.cgi?id=400707
-    // Changes created with this bug may have "\r\n" converted to "\r " and the
-    // entire commit in the subject. The version of JGit used to read NoteDb
-    // changes parses these subjects correctly, so we need to clean up old
-    // ReviewDb subjects before comparing.
-    int rn = s.indexOf("\r \r ");
-    if (rn >= 0) {
-      s = s.substring(0, rn);
-    }
-    return ChangeNoteUtil.sanitizeFooter(s);
-  }
-
-  private static String cleanNoteDbSubject(String s) {
-    return ChangeNoteUtil.sanitizeFooter(s);
-  }
-
-  /**
-   * Set of fields that must always exactly match between ReviewDb and NoteDb.
-   *
-   * <p>Used to limit the worst-case quadratic search when pairing off matching messages below.
-   */
-  @AutoValue
-  abstract static class ChangeMessageCandidate {
-    static ChangeMessageCandidate create(ChangeMessage cm) {
-      return new AutoValue_ChangeBundle_ChangeMessageCandidate(
-          cm.getAuthor(), cm.getMessage(), cm.getTag());
-    }
-
-    @Nullable
-    abstract Account.Id author();
-
-    @Nullable
-    abstract String message();
-
-    @Nullable
-    abstract String tag();
-
-    // Exclude:
-    //  - patch set, which may be null on ReviewDb side but not NoteDb
-    //  - UUID, which is always different between ReviewDb and NoteDb
-    //  - writtenOn, which is fuzzy
-  }
-
-  private static void diffChangeMessages(
-      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
-    if (bundleA.source == REVIEW_DB && bundleB.source == REVIEW_DB) {
-      // Both came from ReviewDb: check all fields exactly.
-      Map<ChangeMessage.Key, ChangeMessage> as = changeMessageMap(bundleA.filterChangeMessages());
-      Map<ChangeMessage.Key, ChangeMessage> bs = changeMessageMap(bundleB.filterChangeMessages());
-
-      for (ChangeMessage.Key k : diffKeySets(diffs, as, bs)) {
-        ChangeMessage a = as.get(k);
-        ChangeMessage b = bs.get(k);
-        String desc = describe(k);
-        diffColumns(diffs, ChangeMessage.class, desc, bundleA, a, bundleB, b);
-      }
-      return;
-    }
-    Change.Id id = bundleA.getChange().getId();
-    checkArgument(id.equals(bundleB.getChange().getId()));
-
-    // Try to pair up matching ChangeMessages from each side, and succeed only
-    // if both collections are empty at the end. Quadratic in the worst case,
-    // but easy to reason about.
-    List<ChangeMessage> as = new LinkedList<>(bundleA.filterChangeMessages());
-
-    ListMultimap<ChangeMessageCandidate, ChangeMessage> bs = LinkedListMultimap.create();
-    for (ChangeMessage b : bundleB.filterChangeMessages()) {
-      bs.put(ChangeMessageCandidate.create(b), b);
-    }
-
-    Iterator<ChangeMessage> ait = as.iterator();
-    A:
-    while (ait.hasNext()) {
-      ChangeMessage a = ait.next();
-      Iterator<ChangeMessage> bit = bs.get(ChangeMessageCandidate.create(a)).iterator();
-      while (bit.hasNext()) {
-        ChangeMessage b = bit.next();
-        if (changeMessagesMatch(bundleA, a, bundleB, b)) {
-          ait.remove();
-          bit.remove();
-          continue A;
-        }
-      }
-    }
-
-    if (as.isEmpty() && bs.isEmpty()) {
-      return;
-    }
-    StringBuilder sb =
-        new StringBuilder("ChangeMessages differ for Change.Id ").append(id).append('\n');
-    if (!as.isEmpty()) {
-      sb.append("Only in A:");
-      for (ChangeMessage cm : as) {
-        sb.append("\n  ").append(cm);
-      }
-      if (!bs.isEmpty()) {
-        sb.append('\n');
-      }
-    }
-    if (!bs.isEmpty()) {
-      sb.append("Only in B:");
-      for (ChangeMessage cm : CHANGE_MESSAGE_ORDER.sortedCopy(bs.values())) {
-        sb.append("\n  ").append(cm);
-      }
-    }
-    diffs.add(sb.toString());
-  }
-
-  private static boolean changeMessagesMatch(
-      ChangeBundle bundleA, ChangeMessage a, ChangeBundle bundleB, ChangeMessage b) {
-    List<String> tempDiffs = new ArrayList<>();
-    String temp = "temp";
-
-    // ReviewDb allows timestamps before patch set was created, but NoteDb
-    // truncates this to the patch set creation timestamp.
-    Timestamp ta = a.getWrittenOn();
-    Timestamp tb = b.getWrittenOn();
-    PatchSet psa = bundleA.patchSets.get(a.getPatchSetId());
-    PatchSet psb = bundleB.patchSets.get(b.getPatchSetId());
-    boolean excludePatchSet = false;
-    boolean excludeWrittenOn = false;
-    if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
-      excludePatchSet = a.getPatchSetId() == null;
-      excludeWrittenOn =
-          psa != null
-              && psb != null
-              && ta.before(psa.getCreatedOn())
-              && tb.equals(psb.getCreatedOn());
-    } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
-      excludePatchSet = b.getPatchSetId() == null;
-      excludeWrittenOn =
-          psa != null
-              && psb != null
-              && tb.before(psb.getCreatedOn())
-              && ta.equals(psa.getCreatedOn());
-    }
-
-    List<String> exclude = Lists.newArrayList("key");
-    if (excludePatchSet) {
-      exclude.add("patchset");
-    }
-    if (excludeWrittenOn) {
-      exclude.add("writtenOn");
-    }
-
-    diffColumnsExcluding(tempDiffs, ChangeMessage.class, temp, bundleA, a, bundleB, b, exclude);
-    return tempDiffs.isEmpty();
-  }
-
-  private static void diffPatchSets(
-      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
-    Map<PatchSet.Id, PatchSet> as = bundleA.patchSets;
-    Map<PatchSet.Id, PatchSet> bs = bundleB.patchSets;
-    Optional<PatchSet.Id> minA = as.keySet().stream().min(intKeyOrdering());
-    Optional<PatchSet.Id> minB = bs.keySet().stream().min(intKeyOrdering());
-    Set<PatchSet.Id> ids = diffKeySets(diffs, as, bs);
-
-    // Old versions of Gerrit had a bug that created patch sets during
-    // rebase or submission with a createdOn timestamp earlier than the patch
-    // set it was replacing. (In the cases I examined, it was equal to createdOn
-    // for the change, but we're not counting on this exact behavior.)
-    //
-    // ChangeRebuilder ensures patch set events come out in order, but it's hard
-    // to predict what the resulting timestamps would look like. So, completely
-    // ignore the createdOn timestamps if both:
-    //   * ReviewDb timestamps are non-monotonic.
-    //   * NoteDb timestamps are monotonic.
-    //
-    // Allow the timestamp of the first patch set to match the creation time of
-    // the change.
-    boolean excludeAllCreatedOn = false;
-    if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
-      excludeAllCreatedOn = !createdOnIsMonotonic(as, ids) && createdOnIsMonotonic(bs, ids);
-    } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
-      excludeAllCreatedOn = createdOnIsMonotonic(as, ids) && !createdOnIsMonotonic(bs, ids);
-    }
-
-    for (PatchSet.Id id : ids) {
-      PatchSet a = as.get(id);
-      PatchSet b = bs.get(id);
-      String desc = describe(id);
-      String pushCertField = "pushCertificate";
-
-      boolean excludeCreatedOn = excludeAllCreatedOn;
-      boolean excludeDesc = false;
-      if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
-        excludeDesc = Objects.equals(trimOrNull(a.getDescription()), b.getDescription());
-        excludeCreatedOn |=
-            Optional.of(id).equals(minB) && b.getCreatedOn().equals(bundleB.change.getCreatedOn());
-      } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
-        excludeDesc = Objects.equals(a.getDescription(), trimOrNull(b.getDescription()));
-        excludeCreatedOn |=
-            Optional.of(id).equals(minA) && a.getCreatedOn().equals(bundleA.change.getCreatedOn());
-      }
-
-      List<String> exclude = Lists.newArrayList(pushCertField);
-      if (excludeCreatedOn) {
-        exclude.add("createdOn");
-      }
-      if (excludeDesc) {
-        exclude.add("description");
-      }
-
-      diffColumnsExcluding(diffs, PatchSet.class, desc, bundleA, a, bundleB, b, exclude);
-      diffValues(diffs, desc, trimPushCert(a), trimPushCert(b), pushCertField);
-    }
-  }
-
-  private static String trimPushCert(PatchSet ps) {
-    if (ps.getPushCertificate() == null) {
-      return null;
-    }
-    return CharMatcher.is('\n').trimTrailingFrom(ps.getPushCertificate());
-  }
-
-  private static boolean createdOnIsMonotonic(
-      Map<?, PatchSet> patchSets, Set<PatchSet.Id> limitToIds) {
-    List<PatchSet> orderedById =
-        patchSets
-            .values()
-            .stream()
-            .filter(ps -> limitToIds.contains(ps.getId()))
-            .sorted(ChangeUtil.PS_ID_ORDER)
-            .collect(toList());
-    return Ordering.natural().onResultOf(PatchSet::getCreatedOn).isOrdered(orderedById);
-  }
-
-  private static void diffPatchSetApprovals(
-      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
-    Map<PatchSetApproval.Key, PatchSetApproval> as = bundleA.filterPatchSetApprovals();
-    Map<PatchSetApproval.Key, PatchSetApproval> bs = bundleB.filterPatchSetApprovals();
-    for (PatchSetApproval.Key k : diffKeySets(diffs, as, bs)) {
-      PatchSetApproval a = as.get(k);
-      PatchSetApproval b = bs.get(k);
-      String desc = describe(k);
-
-      // ReviewDb allows timestamps before patch set was created, but NoteDb
-      // truncates this to the patch set creation timestamp.
-      //
-      // ChangeRebuilder ensures all post-submit approvals happen after the
-      // actual submit, so the timestamps may not line up. This shouldn't really
-      // happen, because postSubmit shouldn't be set in ReviewDb until after the
-      // change is submitted in ReviewDb, but you never know.
-      //
-      // Due to a quirk of PostReview, post-submit 0 votes might not have the
-      // postSubmit bit set in ReviewDb. As these are only used for tombstone
-      // purposes, ignore the postSubmit bit in NoteDb in this case.
-      Timestamp ta = a.getGranted();
-      Timestamp tb = b.getGranted();
-      PatchSet psa = checkNotNull(bundleA.patchSets.get(a.getPatchSetId()));
-      PatchSet psb = checkNotNull(bundleB.patchSets.get(b.getPatchSetId()));
-      boolean excludeGranted = false;
-      boolean excludePostSubmit = false;
-      List<String> exclude = new ArrayList<>(1);
-      if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
-        excludeGranted =
-            (ta.before(psa.getCreatedOn()) && tb.equals(psb.getCreatedOn()))
-                || ta.compareTo(tb) < 0;
-        excludePostSubmit = a.getValue() == 0 && b.isPostSubmit();
-      } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
-        excludeGranted =
-            tb.before(psb.getCreatedOn()) && ta.equals(psa.getCreatedOn()) || tb.compareTo(ta) < 0;
-        excludePostSubmit = b.getValue() == 0 && a.isPostSubmit();
-      }
-
-      // Legacy submit approvals may or may not have tags associated with them,
-      // depending on whether ChangeRebuilder happened to group them with the
-      // status change.
-      boolean excludeTag =
-          bundleA.source != bundleB.source && a.isLegacySubmit() && b.isLegacySubmit();
-
-      if (excludeGranted) {
-        exclude.add("granted");
-      }
-      if (excludePostSubmit) {
-        exclude.add("postSubmit");
-      }
-      if (excludeTag) {
-        exclude.add("tag");
-      }
-
-      diffColumnsExcluding(diffs, PatchSetApproval.class, desc, bundleA, a, bundleB, b, exclude);
-    }
-  }
-
-  private static void diffReviewers(
-      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
-    diffSets(diffs, bundleA.reviewers.all(), bundleB.reviewers.all(), "reviewer");
-  }
-
-  private static void diffPatchLineComments(
-      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
-    Map<PatchLineComment.Key, PatchLineComment> as = bundleA.filterPatchLineComments();
-    Map<PatchLineComment.Key, PatchLineComment> bs = bundleB.filterPatchLineComments();
-    for (PatchLineComment.Key k : diffKeySets(diffs, as, bs)) {
-      PatchLineComment a = as.get(k);
-      PatchLineComment b = bs.get(k);
-      String desc = describe(k);
-      diffColumns(diffs, PatchLineComment.class, desc, bundleA, a, bundleB, b);
-    }
-  }
-
-  private static <T> Set<T> diffKeySets(List<String> diffs, Map<T, ?> a, Map<T, ?> b) {
-    if (a.isEmpty() && b.isEmpty()) {
-      return a.keySet();
-    }
-    String clazz = keyClass((!a.isEmpty() ? a.keySet() : b.keySet()).iterator().next());
-    return diffSets(diffs, a.keySet(), b.keySet(), clazz);
-  }
-
-  private static <T> Set<T> diffSets(List<String> diffs, Set<T> as, Set<T> bs, String desc) {
-    if (as.isEmpty() && bs.isEmpty()) {
-      return as;
-    }
-
-    Set<T> aNotB = Sets.difference(as, bs);
-    Set<T> bNotA = Sets.difference(bs, as);
-    if (aNotB.isEmpty() && bNotA.isEmpty()) {
-      return as;
-    }
-    diffs.add(desc + " sets differ: " + aNotB + " only in A; " + bNotA + " only in B");
-    return Sets.intersection(as, bs);
-  }
-
-  private static <T> void diffColumns(
-      List<String> diffs,
-      Class<T> clazz,
-      String desc,
-      ChangeBundle bundleA,
-      T a,
-      ChangeBundle bundleB,
-      T b) {
-    diffColumnsExcluding(diffs, clazz, desc, bundleA, a, bundleB, b);
-  }
-
-  private static <T> void diffColumnsExcluding(
-      List<String> diffs,
-      Class<T> clazz,
-      String desc,
-      ChangeBundle bundleA,
-      T a,
-      ChangeBundle bundleB,
-      T b,
-      String... exclude) {
-    diffColumnsExcluding(diffs, clazz, desc, bundleA, a, bundleB, b, Arrays.asList(exclude));
-  }
-
-  private static <T> void diffColumnsExcluding(
-      List<String> diffs,
-      Class<T> clazz,
-      String desc,
-      ChangeBundle bundleA,
-      T a,
-      ChangeBundle bundleB,
-      T b,
-      Iterable<String> exclude) {
-    Set<String> toExclude = Sets.newLinkedHashSet(exclude);
-    for (Field f : clazz.getDeclaredFields()) {
-      Column col = f.getAnnotation(Column.class);
-      if (col == null) {
-        continue;
-      } else if (toExclude.remove(f.getName())) {
-        continue;
-      }
-      f.setAccessible(true);
-      try {
-        if (Timestamp.class.isAssignableFrom(f.getType())) {
-          diffTimestamps(diffs, desc, bundleA, a, bundleB, b, f.getName());
-        } else {
-          diffValues(diffs, desc, f.get(a), f.get(b), f.getName());
-        }
-      } catch (IllegalAccessException e) {
-        throw new IllegalArgumentException(e);
-      }
-    }
-    checkArgument(
-        toExclude.isEmpty(),
-        "requested columns to exclude not present in %s: %s",
-        clazz.getSimpleName(),
-        toExclude);
-  }
-
-  private static void diffTimestamps(
-      List<String> diffs,
-      String desc,
-      ChangeBundle bundleA,
-      Object a,
-      ChangeBundle bundleB,
-      Object b,
-      String field) {
-    checkArgument(a.getClass() == b.getClass());
-    Class<?> clazz = a.getClass();
-
-    Timestamp ta;
-    Timestamp tb;
-    try {
-      Field f = clazz.getDeclaredField(field);
-      checkArgument(f.getAnnotation(Column.class) != null);
-      f.setAccessible(true);
-      ta = (Timestamp) f.get(a);
-      tb = (Timestamp) f.get(b);
-    } catch (IllegalAccessException | NoSuchFieldException | SecurityException e) {
-      throw new IllegalArgumentException(e);
-    }
-    diffTimestamps(diffs, desc, bundleA, ta, bundleB, tb, field);
-  }
-
-  private static void diffTimestamps(
-      List<String> diffs,
-      String desc,
-      ChangeBundle bundleA,
-      Timestamp ta,
-      ChangeBundle bundleB,
-      Timestamp tb,
-      String fieldDesc) {
-    if (bundleA.source == bundleB.source || ta == null || tb == null) {
-      diffValues(diffs, desc, ta, tb, fieldDesc);
-    } else if (bundleA.source == NOTE_DB) {
-      diffTimestamps(diffs, desc, bundleA.getChange(), ta, bundleB.getChange(), tb, fieldDesc);
-    } else {
-      diffTimestamps(diffs, desc, bundleB.getChange(), tb, bundleA.getChange(), ta, fieldDesc);
-    }
-  }
-
-  private static boolean timestampsDiffer(
-      ChangeBundle bundleA, Timestamp ta, ChangeBundle bundleB, Timestamp tb) {
-    List<String> tempDiffs = new ArrayList<>(1);
-    diffTimestamps(tempDiffs, "temp", bundleA, ta, bundleB, tb, "temp");
-    return !tempDiffs.isEmpty();
-  }
-
-  private static void diffTimestamps(
-      List<String> diffs,
-      String desc,
-      Change changeFromNoteDb,
-      Timestamp tsFromNoteDb,
-      Change changeFromReviewDb,
-      Timestamp tsFromReviewDb,
-      String field) {
-    // Because ChangeRebuilder may batch events together that are several
-    // seconds apart, the timestamp in NoteDb may actually be several seconds
-    // *earlier* than the timestamp in ReviewDb that it was converted from.
-    checkArgument(
-        tsFromNoteDb.equals(roundToSecond(tsFromNoteDb)),
-        "%s from NoteDb has non-rounded %s timestamp: %s",
-        desc,
-        field,
-        tsFromNoteDb);
-
-    if (tsFromReviewDb.before(changeFromReviewDb.getCreatedOn())
-        && tsFromNoteDb.equals(changeFromNoteDb.getCreatedOn())) {
-      // Timestamp predates change creation. These are truncated to change
-      // creation time during NoteDb conversion, so allow this if the timestamp
-      // in NoteDb matches the createdOn time in NoteDb.
-      return;
-    }
-
-    long delta = tsFromReviewDb.getTime() - tsFromNoteDb.getTime();
-    long max = ChangeRebuilderImpl.MAX_WINDOW_MS;
-    if (delta < 0 || delta > max) {
-      diffs.add(
-          field
-              + " differs for "
-              + desc
-              + " in NoteDb vs. ReviewDb:"
-              + " {"
-              + tsFromNoteDb
-              + "} != {"
-              + tsFromReviewDb
-              + "}");
-    }
-  }
-
-  private static void diffValues(
-      List<String> diffs, String desc, Object va, Object vb, String name) {
-    if (!Objects.equals(va, vb)) {
-      diffs.add(name + " differs for " + desc + ": {" + va + "} != {" + vb + "}");
-    }
-  }
-
-  private static String describe(Object key) {
-    return keyClass(key) + " " + key;
-  }
-
-  private static String keyClass(Object obj) {
-    Class<?> clazz = obj.getClass();
-    String name = clazz.getSimpleName();
-    checkArgument(name.endsWith("Key") || name.endsWith("Id"), "not an Id/Key class: %s", name);
-    if (name.equals("Key") || name.equals("Id")) {
-      return clazz.getEnclosingClass().getSimpleName() + "." + name;
-    } else if (name.startsWith("AutoValue_")) {
-      return name.substring(name.lastIndexOf('_') + 1);
-    }
-    return name;
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName()
-        + "{id="
-        + change.getId()
-        + ", ChangeMessage["
-        + changeMessages.size()
-        + "]"
-        + ", PatchSet["
-        + patchSets.size()
-        + "]"
-        + ", PatchSetApproval["
-        + patchSetApprovals.size()
-        + "]"
-        + ", PatchLineComment["
-        + patchLineComments.size()
-        + "]"
-        + "}";
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
deleted file mode 100644
index 428faef..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ /dev/null
@@ -1,273 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Date;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * A single delta to apply atomically to a change.
- *
- * <p>This delta contains only draft comments on a single patch set of a change by a single author.
- * This delta will become a single commit in the All-Users repository.
- *
- * <p>This class is not thread safe.
- */
-public class ChangeDraftUpdate extends AbstractChangeUpdate {
-  public interface Factory {
-    ChangeDraftUpdate create(
-        ChangeNotes notes,
-        @Assisted("effective") Account.Id accountId,
-        @Assisted("real") Account.Id realAccountId,
-        PersonIdent authorIdent,
-        Date when);
-
-    ChangeDraftUpdate create(
-        Change change,
-        @Assisted("effective") Account.Id accountId,
-        @Assisted("real") Account.Id realAccountId,
-        PersonIdent authorIdent,
-        Date when);
-  }
-
-  @AutoValue
-  abstract static class Key {
-    abstract String revId();
-
-    abstract Comment.Key key();
-  }
-
-  private static Key key(Comment c) {
-    return new AutoValue_ChangeDraftUpdate_Key(c.revId, c.key);
-  }
-
-  private final AllUsersName draftsProject;
-
-  private List<Comment> put = new ArrayList<>();
-  private Set<Key> delete = new HashSet<>();
-
-  @AssistedInject
-  private ChangeDraftUpdate(
-      @GerritServerConfig Config cfg,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @AnonymousCowardName String anonymousCowardName,
-      NotesMigration migration,
-      AllUsersName allUsers,
-      ChangeNoteUtil noteUtil,
-      @Assisted ChangeNotes notes,
-      @Assisted("effective") Account.Id accountId,
-      @Assisted("real") Account.Id realAccountId,
-      @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
-    super(
-        cfg,
-        migration,
-        noteUtil,
-        serverIdent,
-        anonymousCowardName,
-        notes,
-        null,
-        accountId,
-        realAccountId,
-        authorIdent,
-        when);
-    this.draftsProject = allUsers;
-  }
-
-  @AssistedInject
-  private ChangeDraftUpdate(
-      @GerritServerConfig Config cfg,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @AnonymousCowardName String anonymousCowardName,
-      NotesMigration migration,
-      AllUsersName allUsers,
-      ChangeNoteUtil noteUtil,
-      @Assisted Change change,
-      @Assisted("effective") Account.Id accountId,
-      @Assisted("real") Account.Id realAccountId,
-      @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
-    super(
-        cfg,
-        migration,
-        noteUtil,
-        serverIdent,
-        anonymousCowardName,
-        null,
-        change,
-        accountId,
-        realAccountId,
-        authorIdent,
-        when);
-    this.draftsProject = allUsers;
-  }
-
-  public void putComment(Comment c) {
-    verifyComment(c);
-    put.add(c);
-  }
-
-  public void deleteComment(Comment c) {
-    verifyComment(c);
-    delete.add(key(c));
-  }
-
-  public void deleteComment(String revId, Comment.Key key) {
-    delete.add(new AutoValue_ChangeDraftUpdate_Key(revId, key));
-  }
-
-  private CommitBuilder storeCommentsInNotes(
-      RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
-      throws ConfigInvalidException, OrmException, IOException {
-    RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
-    Set<RevId> updatedRevs = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
-    RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
-
-    for (Comment c : put) {
-      if (!delete.contains(key(c))) {
-        cache.get(new RevId(c.revId)).putComment(c);
-      }
-    }
-    for (Key k : delete) {
-      cache.get(new RevId(k.revId())).deleteComment(k.key());
-    }
-
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
-    boolean touchedAnyRevs = false;
-    boolean hasComments = false;
-    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
-      updatedRevs.add(e.getKey());
-      ObjectId id = ObjectId.fromString(e.getKey().get());
-      byte[] data = e.getValue().build(noteUtil, noteUtil.getWriteJson());
-      if (!Arrays.equals(data, e.getValue().baseRaw)) {
-        touchedAnyRevs = true;
-      }
-      if (data.length == 0) {
-        rnm.noteMap.remove(id);
-      } else {
-        hasComments = true;
-        ObjectId dataBlob = ins.insert(OBJ_BLOB, data);
-        rnm.noteMap.set(id, dataBlob);
-      }
-    }
-
-    // If we didn't touch any notes, tell the caller this was a no-op update. We
-    // couldn't have done this in isEmpty() below because we hadn't read the old
-    // data yet.
-    if (!touchedAnyRevs) {
-      return NO_OP_UPDATE;
-    }
-
-    // If we touched every revision and there are no comments left, tell the
-    // caller to delete the entire ref.
-    boolean touchedAllRevs = updatedRevs.equals(rnm.revisionNotes.keySet());
-    if (touchedAllRevs && !hasComments) {
-      return null;
-    }
-
-    cb.setTreeId(rnm.noteMap.writeTree(ins));
-    return cb;
-  }
-
-  private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr)
-      throws ConfigInvalidException, OrmException, IOException {
-    if (migration.readChanges()) {
-      // If reading from changes is enabled, then the old DraftCommentNotes
-      // already parsed the revision notes. We can reuse them as long as the ref
-      // hasn't advanced.
-      ChangeNotes changeNotes = getNotes();
-      if (changeNotes != null) {
-        DraftCommentNotes draftNotes = changeNotes.load().getDraftCommentNotes();
-        if (draftNotes != null) {
-          ObjectId idFromNotes = firstNonNull(draftNotes.getRevision(), ObjectId.zeroId());
-          RevisionNoteMap<ChangeRevisionNote> rnm = draftNotes.getRevisionNoteMap();
-          if (idFromNotes.equals(curr) && rnm != null) {
-            return rnm;
-          }
-        }
-      }
-    }
-    NoteMap noteMap;
-    if (!curr.equals(ObjectId.zeroId())) {
-      noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(curr));
-    } else {
-      noteMap = NoteMap.newEmptyMap();
-    }
-    // Even though reading from changes might not be enabled, we need to
-    // parse any existing revision notes so we can merge them.
-    return RevisionNoteMap.parse(
-        noteUtil, getId(), rw.getObjectReader(), noteMap, PatchLineComment.Status.DRAFT);
-  }
-
-  @Override
-  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
-      throws OrmException, IOException {
-    CommitBuilder cb = new CommitBuilder();
-    cb.setMessage("Update draft comments");
-    try {
-      return storeCommentsInNotes(rw, ins, curr, cb);
-    } catch (ConfigInvalidException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  @Override
-  protected Project.NameKey getProjectName() {
-    return draftsProject;
-  }
-
-  @Override
-  protected String getRefName() {
-    return RefNames.refsDraftComments(getId(), accountId);
-  }
-
-  @Override
-  public boolean isEmpty() {
-    return delete.isEmpty() && put.isEmpty();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
deleted file mode 100644
index 0628913..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ /dev/null
@@ -1,647 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
-import static com.google.gerrit.server.notedb.ChangeNotes.parseException;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.CharMatcher;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ListMultimap;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.GerritServerId;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.inject.Inject;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.sql.Timestamp;
-import java.text.ParseException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Date;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Locale;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.FooterKey;
-import org.eclipse.jgit.util.GitDateFormatter;
-import org.eclipse.jgit.util.GitDateFormatter.Format;
-import org.eclipse.jgit.util.GitDateParser;
-import org.eclipse.jgit.util.MutableInteger;
-import org.eclipse.jgit.util.QuotedString;
-import org.eclipse.jgit.util.RawParseUtils;
-
-public class ChangeNoteUtil {
-  public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
-  public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
-  public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
-  public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
-  public static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
-  public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
-  public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
-  public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
-  public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
-  public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
-      new FooterKey("Patch-set-description");
-  public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
-  public static final FooterKey FOOTER_READ_ONLY_UNTIL = new FooterKey("Read-only-until");
-  public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
-  public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
-  public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
-  public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
-  public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
-  public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
-  public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
-  public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
-  public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
-
-  private static final String AUTHOR = "Author";
-  private static final String BASE_PATCH_SET = "Base-for-patch-set";
-  private static final String COMMENT_RANGE = "Comment-range";
-  private static final String FILE = "File";
-  private static final String LENGTH = "Bytes";
-  private static final String PARENT = "Parent";
-  private static final String PARENT_NUMBER = "Parent-number";
-  private static final String PATCH_SET = "Patch-set";
-  private static final String REAL_AUTHOR = "Real-author";
-  private static final String REVISION = "Revision";
-  private static final String UUID = "UUID";
-  private static final String UNRESOLVED = "Unresolved";
-  private static final String TAG = FOOTER_TAG.getName();
-
-  public static String formatTime(PersonIdent ident, Timestamp t) {
-    GitDateFormatter dateFormatter = new GitDateFormatter(Format.DEFAULT);
-    // TODO(dborowitz): Use a ThreadLocal or use Joda.
-    PersonIdent newIdent = new PersonIdent(ident, t);
-    return dateFormatter.formatDate(newIdent);
-  }
-
-  static Gson newGson() {
-    return new GsonBuilder()
-        .registerTypeAdapter(Timestamp.class, new CommentTimestampAdapter().nullSafe())
-        .setPrettyPrinting()
-        .create();
-  }
-
-  private final AccountCache accountCache;
-  private final PersonIdent serverIdent;
-  private final String anonymousCowardName;
-  private final String serverId;
-  private final Gson gson = newGson();
-  private final boolean writeJson;
-
-  @Inject
-  public ChangeNoteUtil(
-      AccountCache accountCache,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @AnonymousCowardName String anonymousCowardName,
-      @GerritServerId String serverId,
-      @GerritServerConfig Config config) {
-    this.accountCache = accountCache;
-    this.serverIdent = serverIdent;
-    this.anonymousCowardName = anonymousCowardName;
-    this.serverId = serverId;
-    this.writeJson = config.getBoolean("notedb", "writeJson", true);
-  }
-
-  @VisibleForTesting
-  public PersonIdent newIdent(
-      Account author, Date when, PersonIdent serverIdent, String anonymousCowardName) {
-    return new PersonIdent(
-        author.getName(anonymousCowardName),
-        author.getId().get() + "@" + serverId,
-        when,
-        serverIdent.getTimeZone());
-  }
-
-  public boolean getWriteJson() {
-    return writeJson;
-  }
-
-  public Gson getGson() {
-    return gson;
-  }
-
-  public String getServerId() {
-    return serverId;
-  }
-
-  public Account.Id parseIdent(PersonIdent ident, Change.Id changeId)
-      throws ConfigInvalidException {
-    String email = ident.getEmailAddress();
-    int at = email.indexOf('@');
-    if (at >= 0) {
-      String host = email.substring(at + 1, email.length());
-      if (host.equals(serverId)) {
-        Integer id = Ints.tryParse(email.substring(0, at));
-        if (id != null) {
-          return new Account.Id(id);
-        }
-      }
-    }
-    throw parseException(changeId, "invalid identity, expected <id>@%s: %s", serverId, email);
-  }
-
-  private static boolean match(byte[] note, MutableInteger p, byte[] expected) {
-    int m = RawParseUtils.match(note, p.value, expected);
-    return m == p.value + expected.length;
-  }
-
-  public List<Comment> parseNote(byte[] note, MutableInteger p, Change.Id changeId)
-      throws ConfigInvalidException {
-    if (p.value >= note.length) {
-      return ImmutableList.of();
-    }
-    Set<Comment.Key> seen = new HashSet<>();
-    List<Comment> result = new ArrayList<>();
-    int sizeOfNote = note.length;
-    byte[] psb = PATCH_SET.getBytes(UTF_8);
-    byte[] bpsb = BASE_PATCH_SET.getBytes(UTF_8);
-    byte[] bpn = PARENT_NUMBER.getBytes(UTF_8);
-
-    RevId revId = new RevId(parseStringField(note, p, changeId, REVISION));
-    String fileName = null;
-    PatchSet.Id psId = null;
-    boolean isForBase = false;
-    Integer parentNumber = null;
-
-    while (p.value < sizeOfNote) {
-      boolean matchPs = match(note, p, psb);
-      boolean matchBase = match(note, p, bpsb);
-      if (matchPs) {
-        fileName = null;
-        psId = parsePsId(note, p, changeId, PATCH_SET);
-        isForBase = false;
-      } else if (matchBase) {
-        fileName = null;
-        psId = parsePsId(note, p, changeId, BASE_PATCH_SET);
-        isForBase = true;
-        if (match(note, p, bpn)) {
-          parentNumber = parseParentNumber(note, p, changeId);
-        }
-      } else if (psId == null) {
-        throw parseException(changeId, "missing %s or %s header", PATCH_SET, BASE_PATCH_SET);
-      }
-
-      Comment c = parseComment(note, p, fileName, psId, revId, isForBase, parentNumber);
-      fileName = c.key.filename;
-      if (!seen.add(c.key)) {
-        throw parseException(changeId, "multiple comments for %s in note", c.key);
-      }
-      result.add(c);
-    }
-    return result;
-  }
-
-  private Comment parseComment(
-      byte[] note,
-      MutableInteger curr,
-      String currentFileName,
-      PatchSet.Id psId,
-      RevId revId,
-      boolean isForBase,
-      Integer parentNumber)
-      throws ConfigInvalidException {
-    Change.Id changeId = psId.getParentKey();
-
-    // Check if there is a new file.
-    boolean newFile = (RawParseUtils.match(note, curr.value, FILE.getBytes(UTF_8))) != -1;
-    if (newFile) {
-      // If so, parse the new file name.
-      currentFileName = parseFilename(note, curr, changeId);
-    } else if (currentFileName == null) {
-      throw parseException(changeId, "could not parse %s", FILE);
-    }
-
-    CommentRange range = parseCommentRange(note, curr);
-    if (range == null) {
-      throw parseException(changeId, "could not parse %s", COMMENT_RANGE);
-    }
-
-    Timestamp commentTime = parseTimestamp(note, curr, changeId);
-    Account.Id aId = parseAuthor(note, curr, changeId, AUTHOR);
-    boolean hasRealAuthor =
-        (RawParseUtils.match(note, curr.value, REAL_AUTHOR.getBytes(UTF_8))) != -1;
-    Account.Id raId = null;
-    if (hasRealAuthor) {
-      raId = parseAuthor(note, curr, changeId, REAL_AUTHOR);
-    }
-
-    boolean hasParent = (RawParseUtils.match(note, curr.value, PARENT.getBytes(UTF_8))) != -1;
-    String parentUUID = null;
-    boolean unresolved = false;
-    if (hasParent) {
-      parentUUID = parseStringField(note, curr, changeId, PARENT);
-    }
-    boolean hasUnresolved =
-        (RawParseUtils.match(note, curr.value, UNRESOLVED.getBytes(UTF_8))) != -1;
-    if (hasUnresolved) {
-      unresolved = parseBooleanField(note, curr, changeId, UNRESOLVED);
-    }
-
-    String uuid = parseStringField(note, curr, changeId, UUID);
-
-    boolean hasTag = (RawParseUtils.match(note, curr.value, TAG.getBytes(UTF_8))) != -1;
-    String tag = null;
-    if (hasTag) {
-      tag = parseStringField(note, curr, changeId, TAG);
-    }
-
-    int commentLength = parseCommentLength(note, curr, changeId);
-
-    String message = RawParseUtils.decode(UTF_8, note, curr.value, curr.value + commentLength);
-    checkResult(message, "message contents", changeId);
-
-    Comment c =
-        new Comment(
-            new Comment.Key(uuid, currentFileName, psId.get()),
-            aId,
-            commentTime,
-            isForBase ? (short) (parentNumber == null ? 0 : -parentNumber) : (short) 1,
-            message,
-            serverId,
-            unresolved);
-    c.lineNbr = range.getEndLine();
-    c.parentUuid = parentUUID;
-    c.tag = tag;
-    c.setRevId(revId);
-    if (raId != null) {
-      c.setRealAuthor(raId);
-    }
-
-    if (range.getStartCharacter() != -1) {
-      c.setRange(range);
-    }
-
-    curr.value = RawParseUtils.nextLF(note, curr.value + commentLength);
-    curr.value = RawParseUtils.nextLF(note, curr.value);
-    return c;
-  }
-
-  private static String parseStringField(
-      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
-      throws ConfigInvalidException {
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    checkHeaderLineFormat(note, curr, fieldName, changeId);
-    int startOfField = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
-    curr.value = endOfLine;
-    return RawParseUtils.decode(UTF_8, note, startOfField, endOfLine - 1);
-  }
-
-  /**
-   * @return a comment range. If the comment range line in the note only has one number, we return a
-   *     CommentRange with that one number as the end line and the other fields as -1. If the
-   *     comment range line in the note contains a whole comment range, then we return a
-   *     CommentRange with all fields set. If the line is not correctly formatted, return null.
-   */
-  private static CommentRange parseCommentRange(byte[] note, MutableInteger ptr) {
-    CommentRange range = new CommentRange(-1, -1, -1, -1);
-
-    int last = ptr.value;
-    int startLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
-    if (ptr.value == last) {
-      return null;
-    } else if (note[ptr.value] == '\n') {
-      range.setEndLine(startLine);
-      ptr.value += 1;
-      return range;
-    } else if (note[ptr.value] == ':') {
-      range.setStartLine(startLine);
-      ptr.value += 1;
-    } else {
-      return null;
-    }
-
-    last = ptr.value;
-    int startChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
-    if (ptr.value == last) {
-      return null;
-    } else if (note[ptr.value] == '-') {
-      range.setStartCharacter(startChar);
-      ptr.value += 1;
-    } else {
-      return null;
-    }
-
-    last = ptr.value;
-    int endLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
-    if (ptr.value == last) {
-      return null;
-    } else if (note[ptr.value] == ':') {
-      range.setEndLine(endLine);
-      ptr.value += 1;
-    } else {
-      return null;
-    }
-
-    last = ptr.value;
-    int endChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
-    if (ptr.value == last) {
-      return null;
-    } else if (note[ptr.value] == '\n') {
-      range.setEndCharacter(endChar);
-      ptr.value += 1;
-    } else {
-      return null;
-    }
-    return range;
-  }
-
-  private static PatchSet.Id parsePsId(
-      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
-      throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, fieldName, changeId);
-    int startOfPsId = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
-    MutableInteger i = new MutableInteger();
-    int patchSetId = RawParseUtils.parseBase10(note, startOfPsId, i);
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    if (i.value != endOfLine - 1) {
-      throw parseException(changeId, "could not parse %s", fieldName);
-    }
-    checkResult(patchSetId, "patchset id", changeId);
-    curr.value = endOfLine;
-    return new PatchSet.Id(changeId, patchSetId);
-  }
-
-  private static Integer parseParentNumber(byte[] note, MutableInteger curr, Change.Id changeId)
-      throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, PARENT_NUMBER, changeId);
-
-    int start = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
-    MutableInteger i = new MutableInteger();
-    int parentNumber = RawParseUtils.parseBase10(note, start, i);
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    if (i.value != endOfLine - 1) {
-      throw parseException(changeId, "could not parse %s", PARENT_NUMBER);
-    }
-    checkResult(parentNumber, "parent number", changeId);
-    curr.value = endOfLine;
-    return Integer.valueOf(parentNumber);
-  }
-
-  private static String parseFilename(byte[] note, MutableInteger curr, Change.Id changeId)
-      throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, FILE, changeId);
-    int startOfFileName = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    curr.value = endOfLine;
-    curr.value = RawParseUtils.nextLF(note, curr.value);
-    return QuotedString.GIT_PATH.dequote(
-        RawParseUtils.decode(UTF_8, note, startOfFileName, endOfLine - 1));
-  }
-
-  private static Timestamp parseTimestamp(byte[] note, MutableInteger curr, Change.Id changeId)
-      throws ConfigInvalidException {
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    Timestamp commentTime;
-    String dateString = RawParseUtils.decode(UTF_8, note, curr.value, endOfLine - 1);
-    try {
-      commentTime = new Timestamp(GitDateParser.parse(dateString, null, Locale.US).getTime());
-    } catch (ParseException e) {
-      throw new ConfigInvalidException("could not parse comment timestamp", e);
-    }
-    curr.value = endOfLine;
-    return checkResult(commentTime, "comment timestamp", changeId);
-  }
-
-  private Account.Id parseAuthor(
-      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
-      throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, fieldName, changeId);
-    int startOfAccountId = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
-    PersonIdent ident = RawParseUtils.parsePersonIdent(note, startOfAccountId);
-    Account.Id aId = parseIdent(ident, changeId);
-    curr.value = RawParseUtils.nextLF(note, curr.value);
-    return checkResult(aId, fieldName, changeId);
-  }
-
-  private static int parseCommentLength(byte[] note, MutableInteger curr, Change.Id changeId)
-      throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, LENGTH, changeId);
-    int startOfLength = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
-    MutableInteger i = new MutableInteger();
-    i.value = startOfLength;
-    int commentLength = RawParseUtils.parseBase10(note, startOfLength, i);
-    if (i.value == startOfLength) {
-      throw parseException(changeId, "could not parse %s", LENGTH);
-    }
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    if (i.value != endOfLine - 1) {
-      throw parseException(changeId, "could not parse %s", LENGTH);
-    }
-    curr.value = endOfLine;
-    return checkResult(commentLength, "comment length", changeId);
-  }
-
-  private boolean parseBooleanField(
-      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
-      throws ConfigInvalidException {
-    String str = parseStringField(note, curr, changeId, fieldName);
-    if ("true".equalsIgnoreCase(str)) {
-      return true;
-    } else if ("false".equalsIgnoreCase(str)) {
-      return false;
-    }
-    throw parseException(changeId, "invalid boolean for %s: %s", fieldName, str);
-  }
-
-  private static <T> T checkResult(T o, String fieldName, Change.Id changeId)
-      throws ConfigInvalidException {
-    if (o == null) {
-      throw parseException(changeId, "could not parse %s", fieldName);
-    }
-    return o;
-  }
-
-  private static int checkResult(int i, String fieldName, Change.Id changeId)
-      throws ConfigInvalidException {
-    if (i <= 0) {
-      throw parseException(changeId, "could not parse %s", fieldName);
-    }
-    return i;
-  }
-
-  private void appendHeaderField(PrintWriter writer, String field, String value) {
-    writer.print(field);
-    writer.print(": ");
-    writer.print(value);
-    writer.print('\n');
-  }
-
-  private static void checkHeaderLineFormat(
-      byte[] note, MutableInteger curr, String fieldName, Change.Id changeId)
-      throws ConfigInvalidException {
-    boolean correct = RawParseUtils.match(note, curr.value, fieldName.getBytes(UTF_8)) != -1;
-    int p = curr.value + fieldName.length();
-    correct &= (p < note.length && note[p] == ':');
-    p++;
-    correct &= (p < note.length && note[p] == ' ');
-    if (!correct) {
-      throw parseException(changeId, "could not parse %s", fieldName);
-    }
-  }
-
-  /**
-   * Build a note that contains the metadata for and the contents of all of the comments in the
-   * given comments.
-   *
-   * @param comments Comments to be written to the output stream, keyed by patch set ID; multiple
-   *     patch sets are allowed since base revisions may be shared across patch sets. All of the
-   *     comments must share the same RevId, and all the comments for a given patch set must have
-   *     the same side.
-   * @param out output stream to write to.
-   */
-  void buildNote(ListMultimap<Integer, Comment> comments, OutputStream out) {
-    if (comments.isEmpty()) {
-      return;
-    }
-
-    List<Integer> psIds = new ArrayList<>(comments.keySet());
-    Collections.sort(psIds);
-
-    OutputStreamWriter streamWriter = new OutputStreamWriter(out, UTF_8);
-    try (PrintWriter writer = new PrintWriter(streamWriter)) {
-      String revId = comments.values().iterator().next().revId;
-      appendHeaderField(writer, REVISION, revId);
-
-      for (int psId : psIds) {
-        List<Comment> psComments = COMMENT_ORDER.sortedCopy(comments.get(psId));
-        Comment first = psComments.get(0);
-
-        short side = first.side;
-        appendHeaderField(writer, side <= 0 ? BASE_PATCH_SET : PATCH_SET, Integer.toString(psId));
-        if (side < 0) {
-          appendHeaderField(writer, PARENT_NUMBER, Integer.toString(-side));
-        }
-
-        String currentFilename = null;
-
-        for (Comment c : psComments) {
-          checkArgument(
-              revId.equals(c.revId),
-              "All comments being added must have all the same RevId. The "
-                  + "comment below does not have the same RevId as the others "
-                  + "(%s).\n%s",
-              revId,
-              c);
-          checkArgument(
-              side == c.side,
-              "All comments being added must all have the same side. The "
-                  + "comment below does not have the same side as the others "
-                  + "(%s).\n%s",
-              side,
-              c);
-          String commentFilename = QuotedString.GIT_PATH.quote(c.key.filename);
-
-          if (!commentFilename.equals(currentFilename)) {
-            currentFilename = commentFilename;
-            writer.print("File: ");
-            writer.print(commentFilename);
-            writer.print("\n\n");
-          }
-
-          appendOneComment(writer, c);
-        }
-      }
-    }
-  }
-
-  private void appendOneComment(PrintWriter writer, Comment c) {
-    // The CommentRange field for a comment is allowed to be null. If it is
-    // null, then in the first line, we simply use the line number field for a
-    // comment instead. If it isn't null, we write the comment range itself.
-    Comment.Range range = c.range;
-    if (range != null) {
-      writer.print(range.startLine);
-      writer.print(':');
-      writer.print(range.startChar);
-      writer.print('-');
-      writer.print(range.endLine);
-      writer.print(':');
-      writer.print(range.endChar);
-    } else {
-      writer.print(c.lineNbr);
-    }
-    writer.print("\n");
-
-    writer.print(formatTime(serverIdent, c.writtenOn));
-    writer.print("\n");
-
-    appendIdent(writer, AUTHOR, c.author.getId(), c.writtenOn);
-    if (!c.getRealAuthor().equals(c.author)) {
-      appendIdent(writer, REAL_AUTHOR, c.getRealAuthor().getId(), c.writtenOn);
-    }
-
-    String parent = c.parentUuid;
-    if (parent != null) {
-      appendHeaderField(writer, PARENT, parent);
-    }
-
-    appendHeaderField(writer, UNRESOLVED, Boolean.toString(c.unresolved));
-    appendHeaderField(writer, UUID, c.key.uuid);
-
-    if (c.tag != null) {
-      appendHeaderField(writer, TAG, c.tag);
-    }
-
-    byte[] messageBytes = c.message.getBytes(UTF_8);
-    appendHeaderField(writer, LENGTH, Integer.toString(messageBytes.length));
-
-    writer.print(c.message);
-    writer.print("\n\n");
-  }
-
-  private void appendIdent(PrintWriter writer, String header, Account.Id id, Timestamp ts) {
-    PersonIdent ident =
-        newIdent(accountCache.get(id).getAccount(), ts, serverIdent, anonymousCowardName);
-    StringBuilder name = new StringBuilder();
-    PersonIdent.appendSanitized(name, ident.getName());
-    name.append(" <");
-    PersonIdent.appendSanitized(name, ident.getEmailAddress());
-    name.append('>');
-    appendHeaderField(writer, header, name.toString());
-  }
-
-  private static final CharMatcher INVALID_FOOTER_CHARS = CharMatcher.anyOf("\r\n\0");
-
-  static String sanitizeFooter(String value) {
-    // Remove characters that would confuse JGit's footer parser if they were
-    // included in footer values, for example by splitting the footer block into
-    // multiple paragraphs.
-    //
-    // One painful example: RevCommit#getShorMessage() might return a message
-    // containing "\r\r", which RevCommit#getFooterLines() will treat as an
-    // empty paragraph for the purposes of footer parsing.
-    return INVALID_FOOTER_CHARS.trimAndCollapseFrom(value, ' ');
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
deleted file mode 100644
index 153c9c3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ /dev/null
@@ -1,111 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.primitives.Bytes;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.util.MutableInteger;
-import org.eclipse.jgit.util.RawParseUtils;
-
-class ChangeRevisionNote extends RevisionNote<Comment> {
-  private static final byte[] CERT_HEADER = "certificate version ".getBytes(UTF_8);
-  // See org.eclipse.jgit.transport.PushCertificateParser.END_SIGNATURE
-  private static final byte[] END_SIGNATURE = "-----END PGP SIGNATURE-----\n".getBytes(UTF_8);
-
-  private final ChangeNoteUtil noteUtil;
-  private final Change.Id changeId;
-  private final PatchLineComment.Status status;
-  private String pushCert;
-
-  ChangeRevisionNote(
-      ChangeNoteUtil noteUtil,
-      Change.Id changeId,
-      ObjectReader reader,
-      ObjectId noteId,
-      PatchLineComment.Status status) {
-    super(reader, noteId);
-    this.noteUtil = noteUtil;
-    this.changeId = changeId;
-    this.status = status;
-  }
-
-  public String getPushCert() {
-    checkParsed();
-    return pushCert;
-  }
-
-  @Override
-  protected List<Comment> parse(byte[] raw, int offset) throws IOException, ConfigInvalidException {
-    MutableInteger p = new MutableInteger();
-    p.value = offset;
-
-    if (isJson(raw, p.value)) {
-      RevisionNoteData data = parseJson(noteUtil, raw, p.value);
-      if (status == PatchLineComment.Status.PUBLISHED) {
-        pushCert = data.pushCert;
-      } else {
-        pushCert = null;
-      }
-      return data.comments;
-    }
-
-    if (status == PatchLineComment.Status.PUBLISHED) {
-      pushCert = parsePushCert(changeId, raw, p);
-      trimLeadingEmptyLines(raw, p);
-    } else {
-      pushCert = null;
-    }
-    return noteUtil.parseNote(raw, p, changeId);
-  }
-
-  private static boolean isJson(byte[] raw, int offset) {
-    return raw[offset] == '{' || raw[offset] == '[';
-  }
-
-  private RevisionNoteData parseJson(ChangeNoteUtil noteUtil, byte[] raw, int offset)
-      throws IOException {
-    try (InputStream is = new ByteArrayInputStream(raw, offset, raw.length - offset);
-        Reader r = new InputStreamReader(is, UTF_8)) {
-      return noteUtil.getGson().fromJson(r, RevisionNoteData.class);
-    }
-  }
-
-  private static String parsePushCert(Change.Id changeId, byte[] bytes, MutableInteger p)
-      throws ConfigInvalidException {
-    if (RawParseUtils.match(bytes, p.value, CERT_HEADER) < 0) {
-      return null;
-    }
-    int end = Bytes.indexOf(bytes, END_SIGNATURE);
-    if (end < 0) {
-      throw ChangeNotes.parseException(changeId, "invalid push certificate in note");
-    }
-    int start = p.value;
-    p.value = end + END_SIGNATURE.length;
-    return new String(bytes, start, p.value, UTF_8);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
deleted file mode 100644
index 7e0daa6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ /dev/null
@@ -1,890 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.sanitizeFooter;
-import static java.util.Comparator.comparing;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Joiner;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Table;
-import com.google.common.collect.TreeBasedTable;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.util.LabelVote;
-import com.google.gerrit.server.util.RequestId;
-import com.google.gwtorm.client.IntKey;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Comparator;
-import java.util.Date;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.FooterKey;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * A delta to apply to a change.
- *
- * <p>This delta will become two unique commits: one in the AllUsers repo that will contain the
- * draft comments on this change and one in the notes branch that will contain approvals, reviewers,
- * change status, subject, submit records, the change message, and published comments. There are
- * limitations on the set of modifications that can be handled in a single update. In particular,
- * there is a single author and timestamp for each update.
- *
- * <p>This class is not thread-safe.
- */
-public class ChangeUpdate extends AbstractChangeUpdate {
-  public interface Factory {
-    ChangeUpdate create(ChangeNotes notes, CurrentUser user);
-
-    ChangeUpdate create(ChangeNotes notes, CurrentUser user, Date when);
-
-    ChangeUpdate create(
-        Change change,
-        @Assisted("effective") @Nullable Account.Id accountId,
-        @Assisted("real") @Nullable Account.Id realAccountId,
-        PersonIdent authorIdent,
-        Date when,
-        Comparator<String> labelNameComparator);
-
-    @VisibleForTesting
-    ChangeUpdate create(
-        ChangeNotes notes, CurrentUser user, Date when, Comparator<String> labelNameComparator);
-  }
-
-  private final AccountCache accountCache;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
-  private final ChangeDraftUpdate.Factory draftUpdateFactory;
-  private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
-  private final DeleteCommentRewriter.Factory deleteCommentRewriterFactory;
-
-  private final Table<String, Account.Id, Optional<Short>> approvals;
-  private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
-  private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
-  private final List<Comment> comments = new ArrayList<>();
-
-  private String commitSubject;
-  private String subject;
-  private String changeId;
-  private String branch;
-  private Change.Status status;
-  private List<SubmitRecord> submitRecords;
-  private String submissionId;
-  private String topic;
-  private String commit;
-  private Optional<Account.Id> assignee;
-  private Set<String> hashtags;
-  private String changeMessage;
-  private String tag;
-  private PatchSetState psState;
-  private Iterable<String> groups;
-  private String pushCert;
-  private boolean isAllowWriteToNewtRef;
-  private String psDescription;
-  private boolean currentPatchSet;
-  private Timestamp readOnlyUntil;
-  private Boolean isPrivate;
-  private Boolean workInProgress;
-  private Integer revertOf;
-
-  private ChangeDraftUpdate draftUpdate;
-  private RobotCommentUpdate robotCommentUpdate;
-  private DeleteCommentRewriter deleteCommentRewriter;
-
-  @AssistedInject
-  private ChangeUpdate(
-      @GerritServerConfig Config cfg,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @AnonymousCowardName String anonymousCowardName,
-      NotesMigration migration,
-      AccountCache accountCache,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      ChangeDraftUpdate.Factory draftUpdateFactory,
-      RobotCommentUpdate.Factory robotCommentUpdateFactory,
-      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
-      ProjectCache projectCache,
-      @Assisted ChangeNotes notes,
-      @Assisted CurrentUser user,
-      ChangeNoteUtil noteUtil) {
-    this(
-        cfg,
-        serverIdent,
-        anonymousCowardName,
-        migration,
-        accountCache,
-        updateManagerFactory,
-        draftUpdateFactory,
-        robotCommentUpdateFactory,
-        deleteCommentRewriterFactory,
-        projectCache,
-        notes,
-        user,
-        serverIdent.getWhen(),
-        noteUtil);
-  }
-
-  @AssistedInject
-  private ChangeUpdate(
-      @GerritServerConfig Config cfg,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @AnonymousCowardName String anonymousCowardName,
-      NotesMigration migration,
-      AccountCache accountCache,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      ChangeDraftUpdate.Factory draftUpdateFactory,
-      RobotCommentUpdate.Factory robotCommentUpdateFactory,
-      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
-      ProjectCache projectCache,
-      @Assisted ChangeNotes notes,
-      @Assisted CurrentUser user,
-      @Assisted Date when,
-      ChangeNoteUtil noteUtil) {
-    this(
-        cfg,
-        serverIdent,
-        anonymousCowardName,
-        migration,
-        accountCache,
-        updateManagerFactory,
-        draftUpdateFactory,
-        robotCommentUpdateFactory,
-        deleteCommentRewriterFactory,
-        notes,
-        user,
-        when,
-        projectCache.get(notes.getProjectName()).getLabelTypes().nameComparator(),
-        noteUtil);
-  }
-
-  private static Table<String, Account.Id, Optional<Short>> approvals(
-      Comparator<String> nameComparator) {
-    return TreeBasedTable.create(nameComparator, comparing(IntKey::get));
-  }
-
-  @AssistedInject
-  private ChangeUpdate(
-      @GerritServerConfig Config cfg,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @AnonymousCowardName String anonymousCowardName,
-      NotesMigration migration,
-      AccountCache accountCache,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      ChangeDraftUpdate.Factory draftUpdateFactory,
-      RobotCommentUpdate.Factory robotCommentUpdateFactory,
-      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
-      @Assisted ChangeNotes notes,
-      @Assisted CurrentUser user,
-      @Assisted Date when,
-      @Assisted Comparator<String> labelNameComparator,
-      ChangeNoteUtil noteUtil) {
-    super(cfg, migration, notes, user, serverIdent, anonymousCowardName, noteUtil, when);
-    this.accountCache = accountCache;
-    this.updateManagerFactory = updateManagerFactory;
-    this.draftUpdateFactory = draftUpdateFactory;
-    this.robotCommentUpdateFactory = robotCommentUpdateFactory;
-    this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
-    this.approvals = approvals(labelNameComparator);
-  }
-
-  @AssistedInject
-  private ChangeUpdate(
-      @GerritServerConfig Config cfg,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @AnonymousCowardName String anonymousCowardName,
-      NotesMigration migration,
-      AccountCache accountCache,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      ChangeDraftUpdate.Factory draftUpdateFactory,
-      RobotCommentUpdate.Factory robotCommentUpdateFactory,
-      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
-      ChangeNoteUtil noteUtil,
-      @Assisted Change change,
-      @Assisted("effective") @Nullable Account.Id accountId,
-      @Assisted("real") @Nullable Account.Id realAccountId,
-      @Assisted PersonIdent authorIdent,
-      @Assisted Date when,
-      @Assisted Comparator<String> labelNameComparator) {
-    super(
-        cfg,
-        migration,
-        noteUtil,
-        serverIdent,
-        anonymousCowardName,
-        null,
-        change,
-        accountId,
-        realAccountId,
-        authorIdent,
-        when);
-    this.accountCache = accountCache;
-    this.draftUpdateFactory = draftUpdateFactory;
-    this.robotCommentUpdateFactory = robotCommentUpdateFactory;
-    this.updateManagerFactory = updateManagerFactory;
-    this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
-    this.approvals = approvals(labelNameComparator);
-  }
-
-  public ObjectId commit() throws IOException, OrmException {
-    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(getProjectName())) {
-      updateManager.add(this);
-      updateManager.stageAndApplyDelta(getChange());
-      updateManager.execute();
-    }
-    return getResult();
-  }
-
-  public void setChangeId(String changeId) {
-    String old = getChange().getKey().get();
-    checkArgument(
-        old.equals(changeId),
-        "The Change-Id was already set to %s, so we cannot set this Change-Id: %s",
-        old,
-        changeId);
-    this.changeId = changeId;
-  }
-
-  public void setBranch(String branch) {
-    this.branch = branch;
-  }
-
-  public void setStatus(Change.Status status) {
-    checkArgument(status != Change.Status.MERGED, "use merge(Iterable<SubmitRecord>)");
-    this.status = status;
-  }
-
-  public void fixStatus(Change.Status status) {
-    this.status = status;
-  }
-
-  public void putApproval(String label, short value) {
-    putApprovalFor(getAccountId(), label, value);
-  }
-
-  public void putApprovalFor(Account.Id reviewer, String label, short value) {
-    approvals.put(label, reviewer, Optional.of(value));
-  }
-
-  public void removeApproval(String label) {
-    removeApprovalFor(getAccountId(), label);
-  }
-
-  public void removeApprovalFor(Account.Id reviewer, String label) {
-    approvals.put(label, reviewer, Optional.empty());
-  }
-
-  public void merge(RequestId submissionId, Iterable<SubmitRecord> submitRecords) {
-    this.status = Change.Status.MERGED;
-    this.submissionId = submissionId.toStringForStorage();
-    this.submitRecords = ImmutableList.copyOf(submitRecords);
-    checkArgument(!this.submitRecords.isEmpty(), "no submit records specified at submit time");
-  }
-
-  @Deprecated // Only until we improve ChangeRebuilder to call merge().
-  public void setSubmissionId(String submissionId) {
-    this.submissionId = submissionId;
-  }
-
-  public void setSubjectForCommit(String commitSubject) {
-    this.commitSubject = commitSubject;
-  }
-
-  public void setSubject(String subject) {
-    this.subject = subject;
-  }
-
-  @VisibleForTesting
-  ObjectId getCommit() {
-    return ObjectId.fromString(commit);
-  }
-
-  public void setChangeMessage(String changeMessage) {
-    this.changeMessage = changeMessage;
-  }
-
-  public void setTag(String tag) {
-    this.tag = tag;
-  }
-
-  public void setPsDescription(String psDescription) {
-    this.psDescription = psDescription;
-  }
-
-  public void putComment(PatchLineComment.Status status, Comment c) {
-    verifyComment(c);
-    createDraftUpdateIfNull();
-    if (status == PatchLineComment.Status.DRAFT) {
-      draftUpdate.putComment(c);
-    } else {
-      comments.add(c);
-      // Always delete the corresponding comment from drafts. Published comments
-      // are immutable, meaning in normal operation we only hit this path when
-      // publishing a comment. It's exactly in that case that we have to delete
-      // the draft.
-      draftUpdate.deleteComment(c);
-    }
-  }
-
-  public void putRobotComment(RobotComment c) {
-    verifyComment(c);
-    createRobotCommentUpdateIfNull();
-    robotCommentUpdate.putComment(c);
-  }
-
-  public void deleteComment(Comment c) {
-    verifyComment(c);
-    createDraftUpdateIfNull().deleteComment(c);
-  }
-
-  public void deleteCommentByRewritingHistory(String uuid, String newMessage) {
-    deleteCommentRewriter =
-        deleteCommentRewriterFactory.create(getChange().getId(), uuid, newMessage);
-  }
-
-  @VisibleForTesting
-  ChangeDraftUpdate createDraftUpdateIfNull() {
-    if (draftUpdate == null) {
-      ChangeNotes notes = getNotes();
-      if (notes != null) {
-        draftUpdate = draftUpdateFactory.create(notes, accountId, realAccountId, authorIdent, when);
-      } else {
-        draftUpdate =
-            draftUpdateFactory.create(getChange(), accountId, realAccountId, authorIdent, when);
-      }
-    }
-    return draftUpdate;
-  }
-
-  @VisibleForTesting
-  RobotCommentUpdate createRobotCommentUpdateIfNull() {
-    if (robotCommentUpdate == null) {
-      ChangeNotes notes = getNotes();
-      if (notes != null) {
-        robotCommentUpdate =
-            robotCommentUpdateFactory.create(notes, accountId, realAccountId, authorIdent, when);
-      } else {
-        robotCommentUpdate =
-            robotCommentUpdateFactory.create(
-                getChange(), accountId, realAccountId, authorIdent, when);
-      }
-    }
-    return robotCommentUpdate;
-  }
-
-  public void setTopic(String topic) {
-    this.topic = Strings.nullToEmpty(topic);
-  }
-
-  public void setCommit(RevWalk rw, ObjectId id) throws IOException {
-    setCommit(rw, id, null);
-  }
-
-  public void setCommit(RevWalk rw, ObjectId id, String pushCert) throws IOException {
-    RevCommit commit = rw.parseCommit(id);
-    rw.parseBody(commit);
-    this.commit = commit.name();
-    subject = commit.getShortMessage();
-    this.pushCert = pushCert;
-  }
-
-  /**
-   * Set the revision without depending on the commit being present in the repository; should only
-   * be used for converting old corrupt commits.
-   */
-  public void setRevisionForMissingCommit(String id, String pushCert) {
-    commit = id;
-    this.pushCert = pushCert;
-  }
-
-  public void setHashtags(Set<String> hashtags) {
-    this.hashtags = hashtags;
-  }
-
-  public void setAssignee(Account.Id assignee) {
-    checkArgument(assignee != null, "use removeAssignee");
-    this.assignee = Optional.of(assignee);
-  }
-
-  public void removeAssignee() {
-    this.assignee = Optional.empty();
-  }
-
-  public Map<Account.Id, ReviewerStateInternal> getReviewers() {
-    return reviewers;
-  }
-
-  public void putReviewer(Account.Id reviewer, ReviewerStateInternal type) {
-    checkArgument(type != ReviewerStateInternal.REMOVED, "invalid ReviewerType");
-    reviewers.put(reviewer, type);
-  }
-
-  public void removeReviewer(Account.Id reviewer) {
-    reviewers.put(reviewer, ReviewerStateInternal.REMOVED);
-  }
-
-  public void putReviewerByEmail(Address reviewer, ReviewerStateInternal type) {
-    checkArgument(type != ReviewerStateInternal.REMOVED, "invalid ReviewerType");
-    reviewersByEmail.put(reviewer, type);
-  }
-
-  public void removeReviewerByEmail(Address reviewer) {
-    reviewersByEmail.put(reviewer, ReviewerStateInternal.REMOVED);
-  }
-
-  public void setPatchSetState(PatchSetState psState) {
-    this.psState = psState;
-  }
-
-  public void setCurrentPatchSet() {
-    this.currentPatchSet = true;
-  }
-
-  public void setGroups(List<String> groups) {
-    checkNotNull(groups, "groups may not be null");
-    this.groups = groups;
-  }
-
-  public void setRevertOf(int revertOf) {
-    int ownId = getChange().getId().get();
-    checkArgument(ownId != revertOf, "A change cannot revert itself");
-    this.revertOf = revertOf;
-    rootOnly = true;
-  }
-
-  /** @return the tree id for the updated tree */
-  private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
-      throws ConfigInvalidException, OrmException, IOException {
-    if (comments.isEmpty() && pushCert == null) {
-      return null;
-    }
-    RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
-
-    RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
-    for (Comment c : comments) {
-      c.tag = tag;
-      cache.get(new RevId(c.revId)).putComment(c);
-    }
-    if (pushCert != null) {
-      checkState(commit != null);
-      cache.get(new RevId(commit)).setPushCertificate(pushCert);
-    }
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
-    checkComments(rnm.revisionNotes, builders);
-
-    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
-      ObjectId data =
-          inserter.insert(OBJ_BLOB, e.getValue().build(noteUtil, noteUtil.getWriteJson()));
-      rnm.noteMap.set(ObjectId.fromString(e.getKey().get()), data);
-    }
-
-    return rnm.noteMap.writeTree(inserter);
-  }
-
-  private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr)
-      throws ConfigInvalidException, OrmException, IOException {
-    if (curr.equals(ObjectId.zeroId())) {
-      return RevisionNoteMap.emptyMap();
-    }
-    if (migration.readChanges()) {
-      // If reading from changes is enabled, then the old ChangeNotes may have
-      // already parsed the revision notes. We can reuse them as long as the ref
-      // hasn't advanced.
-      ChangeNotes notes = getNotes();
-      if (notes != null && notes.revisionNoteMap != null) {
-        ObjectId idFromNotes = firstNonNull(notes.load().getRevision(), ObjectId.zeroId());
-        if (idFromNotes.equals(curr)) {
-          return notes.revisionNoteMap;
-        }
-      }
-    }
-    NoteMap noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(curr));
-    // Even though reading from changes might not be enabled, we need to
-    // parse any existing revision notes so we can merge them.
-    return RevisionNoteMap.parse(
-        noteUtil, getId(), rw.getObjectReader(), noteMap, PatchLineComment.Status.PUBLISHED);
-  }
-
-  private void checkComments(
-      Map<RevId, ChangeRevisionNote> existingNotes, Map<RevId, RevisionNoteBuilder> toUpdate)
-      throws OrmException {
-    // Prohibit various kinds of illegal operations on comments.
-    Set<Comment.Key> existing = new HashSet<>();
-    for (ChangeRevisionNote rn : existingNotes.values()) {
-      for (Comment c : rn.getComments()) {
-        existing.add(c.key);
-        if (draftUpdate != null) {
-          // Take advantage of an existing update on All-Users to prune any
-          // published comments from drafts. NoteDbUpdateManager takes care of
-          // ensuring that this update is applied before its dependent draft
-          // update.
-          //
-          // Deleting aggressively in this way, combined with filtering out
-          // duplicate published/draft comments in ChangeNotes#getDraftComments,
-          // makes up for the fact that updates between the change repo and
-          // All-Users are not atomic.
-          //
-          // TODO(dborowitz): We might want to distinguish between deleted
-          // drafts that we're fixing up after the fact by putting them in a
-          // separate commit. But note that we don't care much about the commit
-          // graph of the draft ref, particularly because the ref is completely
-          // deleted when all drafts are gone.
-          draftUpdate.deleteComment(c.revId, c.key);
-        }
-      }
-    }
-
-    for (RevisionNoteBuilder b : toUpdate.values()) {
-      for (Comment c : b.put.values()) {
-        if (existing.contains(c.key)) {
-          throw new OrmException("Cannot update existing published comment: " + c);
-        }
-      }
-    }
-  }
-
-  @Override
-  protected String getRefName() {
-    return changeMetaRef(getId());
-  }
-
-  @Override
-  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
-      throws OrmException, IOException {
-    checkState(deleteCommentRewriter == null, "cannot update and rewrite ref in one BatchUpdate");
-
-    CommitBuilder cb = new CommitBuilder();
-
-    int ps = psId != null ? psId.get() : getChange().currentPatchSetId().get();
-    StringBuilder msg = new StringBuilder();
-    if (commitSubject != null) {
-      msg.append(commitSubject);
-    } else {
-      msg.append("Update patch set ").append(ps);
-    }
-    msg.append("\n\n");
-
-    if (changeMessage != null) {
-      msg.append(changeMessage);
-      msg.append("\n\n");
-    }
-
-    addPatchSetFooter(msg, ps);
-
-    if (currentPatchSet) {
-      addFooter(msg, FOOTER_CURRENT, Boolean.TRUE);
-    }
-
-    if (psDescription != null) {
-      addFooter(msg, FOOTER_PATCH_SET_DESCRIPTION, psDescription);
-    }
-
-    if (changeId != null) {
-      addFooter(msg, FOOTER_CHANGE_ID, changeId);
-    }
-
-    if (subject != null) {
-      addFooter(msg, FOOTER_SUBJECT, subject);
-    }
-
-    if (branch != null) {
-      addFooter(msg, FOOTER_BRANCH, branch);
-    }
-
-    if (status != null) {
-      addFooter(msg, FOOTER_STATUS, status.name().toLowerCase());
-    }
-
-    if (topic != null) {
-      addFooter(msg, FOOTER_TOPIC, topic);
-    }
-
-    if (commit != null) {
-      addFooter(msg, FOOTER_COMMIT, commit);
-    }
-
-    if (assignee != null) {
-      if (assignee.isPresent()) {
-        addFooter(msg, FOOTER_ASSIGNEE);
-        addIdent(msg, assignee.get()).append('\n');
-      } else {
-        addFooter(msg, FOOTER_ASSIGNEE).append('\n');
-      }
-    }
-
-    Joiner comma = Joiner.on(',');
-    if (hashtags != null) {
-      addFooter(msg, FOOTER_HASHTAGS, comma.join(hashtags));
-    }
-
-    if (tag != null) {
-      addFooter(msg, FOOTER_TAG, tag);
-    }
-
-    if (groups != null) {
-      addFooter(msg, FOOTER_GROUPS, comma.join(groups));
-    }
-
-    for (Map.Entry<Account.Id, ReviewerStateInternal> e : reviewers.entrySet()) {
-      addFooter(msg, e.getValue().getFooterKey());
-      addIdent(msg, e.getKey()).append('\n');
-    }
-
-    for (Map.Entry<Address, ReviewerStateInternal> e : reviewersByEmail.entrySet()) {
-      addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
-    }
-
-    for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
-      addFooter(msg, FOOTER_LABEL);
-      // Label names/values are safe to append without sanitizing.
-      if (!c.getValue().isPresent()) {
-        msg.append('-').append(c.getRowKey());
-      } else {
-        msg.append(LabelVote.create(c.getRowKey(), c.getValue().get()).formatWithEquals());
-      }
-      Account.Id id = c.getColumnKey();
-      if (!id.equals(getAccountId())) {
-        addIdent(msg.append(' '), id);
-      }
-      msg.append('\n');
-    }
-
-    if (submissionId != null) {
-      addFooter(msg, FOOTER_SUBMISSION_ID, submissionId);
-    }
-
-    if (submitRecords != null) {
-      for (SubmitRecord rec : submitRecords) {
-        addFooter(msg, FOOTER_SUBMITTED_WITH).append(rec.status);
-        if (rec.errorMessage != null) {
-          msg.append(' ').append(sanitizeFooter(rec.errorMessage));
-        }
-        msg.append('\n');
-
-        if (rec.labels != null) {
-          for (SubmitRecord.Label label : rec.labels) {
-            // Label names/values are safe to append without sanitizing.
-            addFooter(msg, FOOTER_SUBMITTED_WITH)
-                .append(label.status)
-                .append(": ")
-                .append(label.label);
-            if (label.appliedBy != null) {
-              msg.append(": ");
-              addIdent(msg, label.appliedBy);
-            }
-            msg.append('\n');
-          }
-        }
-      }
-    }
-
-    if (!Objects.equals(accountId, realAccountId)) {
-      addFooter(msg, FOOTER_REAL_USER);
-      addIdent(msg, realAccountId).append('\n');
-    }
-
-    if (readOnlyUntil != null) {
-      addFooter(msg, FOOTER_READ_ONLY_UNTIL, ChangeNoteUtil.formatTime(serverIdent, readOnlyUntil));
-    }
-
-    if (isPrivate != null) {
-      addFooter(msg, FOOTER_PRIVATE, isPrivate);
-    }
-
-    if (workInProgress != null) {
-      addFooter(msg, FOOTER_WORK_IN_PROGRESS, workInProgress);
-    }
-
-    if (revertOf != null) {
-      addFooter(msg, FOOTER_REVERT_OF, revertOf);
-    }
-
-    cb.setMessage(msg.toString());
-    try {
-      ObjectId treeId = storeRevisionNotes(rw, ins, curr);
-      if (treeId != null) {
-        cb.setTreeId(treeId);
-      }
-    } catch (ConfigInvalidException e) {
-      throw new OrmException(e);
-    }
-    return cb;
-  }
-
-  private void addPatchSetFooter(StringBuilder sb, int ps) {
-    addFooter(sb, FOOTER_PATCH_SET).append(ps);
-    if (psState != null) {
-      sb.append(" (").append(psState.name().toLowerCase()).append(')');
-    }
-    sb.append('\n');
-  }
-
-  @Override
-  protected Project.NameKey getProjectName() {
-    return getChange().getProject();
-  }
-
-  @Override
-  public boolean isEmpty() {
-    return commitSubject == null
-        && approvals.isEmpty()
-        && changeMessage == null
-        && comments.isEmpty()
-        && reviewers.isEmpty()
-        && reviewersByEmail.isEmpty()
-        && changeId == null
-        && branch == null
-        && status == null
-        && submissionId == null
-        && submitRecords == null
-        && assignee == null
-        && hashtags == null
-        && topic == null
-        && commit == null
-        && psState == null
-        && groups == null
-        && tag == null
-        && psDescription == null
-        && !currentPatchSet
-        && readOnlyUntil == null
-        && isPrivate == null
-        && workInProgress == null
-        && revertOf == null;
-  }
-
-  ChangeDraftUpdate getDraftUpdate() {
-    return draftUpdate;
-  }
-
-  RobotCommentUpdate getRobotCommentUpdate() {
-    return robotCommentUpdate;
-  }
-
-  public DeleteCommentRewriter getDeleteCommentRewriter() {
-    return deleteCommentRewriter;
-  }
-
-  public void setAllowWriteToNewRef(boolean allow) {
-    isAllowWriteToNewtRef = allow;
-  }
-
-  @Override
-  public boolean allowWriteToNewRef() {
-    return isAllowWriteToNewtRef;
-  }
-
-  public void setPrivate(boolean isPrivate) {
-    this.isPrivate = isPrivate;
-  }
-
-  public void setWorkInProgress(boolean workInProgress) {
-    this.workInProgress = workInProgress;
-  }
-
-  void setReadOnlyUntil(Timestamp readOnlyUntil) {
-    this.readOnlyUntil = readOnlyUntil;
-  }
-
-  private static StringBuilder addFooter(StringBuilder sb, FooterKey footer) {
-    return sb.append(footer.getName()).append(": ");
-  }
-
-  private static void addFooter(StringBuilder sb, FooterKey footer, Object... values) {
-    addFooter(sb, footer);
-    for (Object value : values) {
-      sb.append(sanitizeFooter(Objects.toString(value)));
-    }
-    sb.append('\n');
-  }
-
-  private StringBuilder addIdent(StringBuilder sb, Account.Id accountId) {
-    Account account = accountCache.get(accountId).getAccount();
-    PersonIdent ident = newIdent(account, when);
-
-    PersonIdent.appendSanitized(sb, ident.getName());
-    sb.append(" <");
-    PersonIdent.appendSanitized(sb, ident.getEmailAddress());
-    sb.append('>');
-    return sb;
-  }
-
-  @Override
-  protected void checkNotReadOnly() throws OrmException {
-    // Allow setting Read-only-until to 0 to release an existing lease.
-    if (readOnlyUntil != null && readOnlyUntil.getTime() == 0) {
-      return;
-    }
-    super.checkNotReadOnly();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java
deleted file mode 100644
index be24e28..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-public enum NoteDbTable {
-  ACCOUNTS,
-  CHANGES;
-
-  public String key() {
-    return name().toLowerCase();
-  }
-
-  @Override
-  public String toString() {
-    return key();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
deleted file mode 100644
index e560ec8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
+++ /dev/null
@@ -1,250 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
-
-import com.google.auto.value.AutoValue;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.inject.AbstractModule;
-import java.util.concurrent.atomic.AtomicReference;
-import org.eclipse.jgit.lib.Config;
-
-/**
- * Current low-level settings of the NoteDb migration for changes.
- *
- * <p>This class only describes the migration state of the {@link
- * com.google.gerrit.reviewdb.client.Change Change} entity group, since it is possible for a given
- * site to be in different states of the Change NoteDb migration process while staying at the same
- * ReviewDb schema version. It does <em>not</em> describe the migration state of non-Change tables;
- * those are automatically migrated using the ReviewDb schema migration process, so the NoteDb
- * migration state at a given ReviewDb schema cannot vary.
- *
- * <p>In many places, core Gerrit code should not directly care about the NoteDb migration state,
- * and should prefer high-level APIs like {@link com.google.gerrit.server.ApprovalsUtil
- * ApprovalsUtil} that don't require callers to inspect the migration state. The
- * <em>implementation</em> of those utilities does care about the state, and should query the {@code
- * NotesMigration} for the properties of the migration, for example, {@link #changePrimaryStorage()
- * where new changes should be stored}.
- *
- * <p>Core Gerrit code is mostly interested in one facet of the migration at a time (reading or
- * writing, say), but not all combinations of return values are supported or even make sense.
- *
- * <p>This class controls the state of the migration according to options in {@code gerrit.config}.
- * In general, any changes to these options should only be made by adventurous administrators, who
- * know what they're doing, on non-production data, for the purposes of testing the NoteDb
- * implementation. Changing options quite likely requires re-running {@code MigrateToNoteDb}. For
- * these reasons, the options remain undocumented.
- *
- * <p><strong>Note:</strong> Callers should not assume the values returned by {@code
- * NotesMigration}'s methods will not change in a running server.
- */
-public abstract class NotesMigration {
-  public static final String SECTION_NOTE_DB = "noteDb";
-
-  private static final String DISABLE_REVIEW_DB = "disableReviewDb";
-  private static final String PRIMARY_STORAGE = "primaryStorage";
-  private static final String READ = "read";
-  private static final String SEQUENCE = "sequence";
-  private static final String WRITE = "write";
-
-  public static class Module extends AbstractModule {
-    @Override
-    public void configure() {
-      bind(MutableNotesMigration.class);
-      bind(NotesMigration.class).to(MutableNotesMigration.class);
-    }
-  }
-
-  @AutoValue
-  abstract static class Snapshot {
-    static Builder builder() {
-      // Default values are defined as what we would read from an empty config.
-      return create(new Config()).toBuilder();
-    }
-
-    static Snapshot create(Config cfg) {
-      return new AutoValue_NotesMigration_Snapshot.Builder()
-          .setWriteChanges(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, false))
-          .setReadChanges(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, false))
-          .setReadChangeSequence(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), SEQUENCE, false))
-          .setChangePrimaryStorage(
-              cfg.getEnum(
-                  SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.REVIEW_DB))
-          .setDisableChangeReviewDb(
-              cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, false))
-          .setFailOnLoadForTest(false) // Only set in tests, can't be set via config.
-          .build();
-    }
-
-    abstract boolean writeChanges();
-
-    abstract boolean readChanges();
-
-    abstract boolean readChangeSequence();
-
-    abstract PrimaryStorage changePrimaryStorage();
-
-    abstract boolean disableChangeReviewDb();
-
-    abstract boolean failOnLoadForTest();
-
-    abstract Builder toBuilder();
-
-    void setConfigValues(Config cfg) {
-      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, writeChanges());
-      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, readChanges());
-      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), SEQUENCE, readChangeSequence());
-      cfg.setEnum(SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, changePrimaryStorage());
-      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, disableChangeReviewDb());
-    }
-
-    @AutoValue.Builder
-    abstract static class Builder {
-      abstract Builder setWriteChanges(boolean writeChanges);
-
-      abstract Builder setReadChanges(boolean readChanges);
-
-      abstract Builder setReadChangeSequence(boolean readChangeSequence);
-
-      abstract Builder setChangePrimaryStorage(PrimaryStorage changePrimaryStorage);
-
-      abstract Builder setDisableChangeReviewDb(boolean disableChangeReviewDb);
-
-      abstract Builder setFailOnLoadForTest(boolean failOnLoadForTest);
-
-      abstract Snapshot autoBuild();
-
-      Snapshot build() {
-        Snapshot s = autoBuild();
-        checkArgument(
-            !(s.disableChangeReviewDb() && s.changePrimaryStorage() != PrimaryStorage.NOTE_DB),
-            "cannot disable ReviewDb for changes if default change primary storage is ReviewDb");
-        return s;
-      }
-    }
-  }
-
-  protected final AtomicReference<Snapshot> snapshot;
-
-  /**
-   * Read changes from NoteDb.
-   *
-   * <p>Change data is read from NoteDb refs, but ReviewDb is still the source of truth. If the
-   * loader determines NoteDb is out of date, the change data in NoteDb will be transparently
-   * rebuilt. This means that some code paths that look read-only may in fact attempt to write.
-   *
-   * <p>If true and {@code writeChanges() = false}, changes can still be read from NoteDb, but any
-   * attempts to write will generate an error.
-   */
-  public final boolean readChanges() {
-    return snapshot.get().readChanges();
-  }
-
-  /**
-   * Write changes to NoteDb.
-   *
-   * <p>This method is awkwardly named because you should be using either {@link
-   * #commitChangeWrites()} or {@link #failChangeWrites()} instead.
-   *
-   * <p>Updates to change data are written to NoteDb refs, but ReviewDb is still the source of
-   * truth. Change data will not be written unless the NoteDb refs are already up to date, and the
-   * write path will attempt to rebuild the change if not.
-   *
-   * <p>If false, the behavior when attempting to write depends on {@code readChanges()}. If {@code
-   * readChanges() = false}, writes to NoteDb are simply ignored; if {@code true}, any attempts to
-   * write will generate an error.
-   */
-  public final boolean rawWriteChangesSetting() {
-    return snapshot.get().writeChanges();
-  }
-
-  /**
-   * Read sequential change ID numbers from NoteDb.
-   *
-   * <p>If true, change IDs are read from {@code refs/sequences/changes} in All-Projects. If false,
-   * change IDs are read from ReviewDb's native sequences.
-   */
-  public final boolean readChangeSequence() {
-    return snapshot.get().readChangeSequence();
-  }
-
-  /** @return default primary storage for new changes. */
-  public final PrimaryStorage changePrimaryStorage() {
-    return snapshot.get().changePrimaryStorage();
-  }
-
-  /**
-   * Disable ReviewDb access for changes.
-   *
-   * <p>When set, ReviewDb operations involving the Changes table become no-ops. Lookups return no
-   * results; updates do nothing, as does opening, committing, or rolling back a transaction on the
-   * Changes table.
-   */
-  public final boolean disableChangeReviewDb() {
-    return snapshot.get().disableChangeReviewDb();
-  }
-
-  /**
-   * Whether to fail when reading any data from NoteDb.
-   *
-   * <p>Used in conjunction with {@link #readChanges()} for tests.
-   */
-  public boolean failOnLoadForTest() {
-    return snapshot.get().failOnLoadForTest();
-  }
-
-  public final boolean commitChangeWrites() {
-    // It may seem odd that readChanges() without writeChanges() means we should
-    // attempt to commit writes. However, this method is used by callers to know
-    // whether or not they should short-circuit and skip attempting to read or
-    // write NoteDb refs.
-    //
-    // It is possible for commitChangeWrites() to return true and
-    // failChangeWrites() to also return true, causing an error later in the
-    // same codepath. This specific condition is used by the auto-rebuilding
-    // path to rebuild a change and stage the results, but not commit them due
-    // to failChangeWrites().
-    return rawWriteChangesSetting() || readChanges();
-  }
-
-  public final boolean failChangeWrites() {
-    return !rawWriteChangesSetting() && readChanges();
-  }
-
-  public final void setConfigValues(Config cfg) {
-    snapshot.get().setConfigValues(cfg);
-  }
-
-  @Override
-  public final boolean equals(Object o) {
-    return o instanceof NotesMigration
-        && snapshot.get().equals(((NotesMigration) o).snapshot.get());
-  }
-
-  @Override
-  public final int hashCode() {
-    return snapshot.get().hashCode();
-  }
-
-  protected NotesMigration(Snapshot snapshot) {
-    this.snapshot = new AtomicReference<>(snapshot);
-  }
-
-  final Snapshot snapshot() {
-    return snapshot.get();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
deleted file mode 100644
index 759e336..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
+++ /dev/null
@@ -1,499 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.Preconditions.checkState;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.NANOSECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.github.rholder.retry.RetryException;
-import com.github.rholder.retry.Retryer;
-import com.github.rholder.retry.RetryerBuilder;
-import com.github.rholder.retry.StopStrategies;
-import com.github.rholder.retry.WaitStrategies;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Stopwatch;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.RepoRefCache;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.AtomicUpdate;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
-import java.util.TreeSet;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.atomic.AtomicBoolean;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Helper to migrate the {@link PrimaryStorage} of individual changes. */
-@Singleton
-public class PrimaryStorageMigrator {
-  private static final Logger log = LoggerFactory.getLogger(PrimaryStorageMigrator.class);
-
-  private final AllUsersName allUsers;
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeRebuilder rebuilder;
-  private final ChangeUpdate.Factory updateFactory;
-  private final GitRepositoryManager repoManager;
-  private final InternalUser.Factory internalUserFactory;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final Provider<ReviewDb> db;
-  private final RetryHelper retryHelper;
-
-  private final long skewMs;
-  private final long timeoutMs;
-  private final Retryer<NoteDbChangeState> testEnsureRebuiltRetryer;
-
-  @Inject
-  PrimaryStorageMigrator(
-      @GerritServerConfig Config cfg,
-      Provider<ReviewDb> db,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      ChangeRebuilder rebuilder,
-      ChangeNotes.Factory changeNotesFactory,
-      Provider<InternalChangeQuery> queryProvider,
-      ChangeUpdate.Factory updateFactory,
-      InternalUser.Factory internalUserFactory,
-      RetryHelper retryHelper) {
-    this(
-        cfg,
-        db,
-        repoManager,
-        allUsers,
-        rebuilder,
-        null,
-        changeNotesFactory,
-        queryProvider,
-        updateFactory,
-        internalUserFactory,
-        retryHelper);
-  }
-
-  @VisibleForTesting
-  public PrimaryStorageMigrator(
-      Config cfg,
-      Provider<ReviewDb> db,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      ChangeRebuilder rebuilder,
-      @Nullable Retryer<NoteDbChangeState> testEnsureRebuiltRetryer,
-      ChangeNotes.Factory changeNotesFactory,
-      Provider<InternalChangeQuery> queryProvider,
-      ChangeUpdate.Factory updateFactory,
-      InternalUser.Factory internalUserFactory,
-      RetryHelper retryHelper) {
-    this.db = db;
-    this.repoManager = repoManager;
-    this.allUsers = allUsers;
-    this.rebuilder = rebuilder;
-    this.testEnsureRebuiltRetryer = testEnsureRebuiltRetryer;
-    this.changeNotesFactory = changeNotesFactory;
-    this.queryProvider = queryProvider;
-    this.updateFactory = updateFactory;
-    this.internalUserFactory = internalUserFactory;
-    this.retryHelper = retryHelper;
-    skewMs = NoteDbChangeState.getReadOnlySkew(cfg);
-
-    String s = "notedb";
-    timeoutMs =
-        cfg.getTimeUnit(
-            s,
-            null,
-            "primaryStorageMigrationTimeout",
-            MILLISECONDS.convert(60, SECONDS),
-            MILLISECONDS);
-  }
-
-  /**
-   * Migrate a change's primary storage from ReviewDb to NoteDb.
-   *
-   * <p>This method will return only if the primary storage of the change is NoteDb afterwards. (It
-   * may return early if the primary storage was already NoteDb.)
-   *
-   * <p>If this method throws an exception, then the primary storage of the change is probably not
-   * NoteDb. (It is possible that the primary storage of the change is NoteDb in this case, but
-   * there was an error reading the state.) Moreover, after an exception, the change may be
-   * read-only until a lease expires. If the caller chooses to retry, they should wait until the
-   * read-only lease expires; this method will fail relatively quickly if called on a read-only
-   * change.
-   *
-   * <p>Note that if the change is read-only after this method throws an exception, that does not
-   * necessarily guarantee that the read-only lease was acquired during that particular method
-   * invocation; this call may have in fact failed because another thread acquired the lease first.
-   *
-   * @param id change ID.
-   * @throws OrmException if a ReviewDb-level error occurs.
-   * @throws IOException if a repo-level error occurs.
-   */
-  public void migrateToNoteDbPrimary(Change.Id id) throws OrmException, IOException {
-    // Since there are multiple non-atomic steps in this method, we need to
-    // consider what happens when there is another writer concurrent with the
-    // thread executing this method.
-    //
-    // Let:
-    // * OR = other writer writes noteDbState & new data to ReviewDb (in one
-    //        transaction)
-    // * ON = other writer writes to NoteDb
-    // * MRO = migrator sets state to read-only
-    // * MR = ensureRebuilt writes rebuilt noteDbState to ReviewDb (but does not
-    //        otherwise update ReviewDb in this transaction)
-    // * MN = ensureRebuilt writes rebuilt state to NoteDb
-    //
-    // Consider all the interleavings of these operations.
-    //
-    // * OR,ON,MRO,...
-    //   Other writer completes before migrator begins; this is not a concurrent
-    //   write.
-    // * MRO,...,OR,...
-    //   OR will fail, since it atomically checks that the noteDbState is not
-    //   read-only before proceeding. This results in an exception, but not a
-    //   concurrent write.
-    //
-    // Thus all the "interesting" interleavings start with OR,MRO, and differ on
-    // where ON falls relative to MR/MN.
-    //
-    // * OR,MRO,ON,MR,MN
-    //   The other NoteDb write succeeds despite the noteDbState being
-    //   read-only. Because the read-only state from MRO includes the update
-    //   from OR, the change is up-to-date at this point. Thus MR,MN is a no-op.
-    //   The end result is an up-to-date, read-only change.
-    //
-    // * OR,MRO,MR,ON,MN
-    //   The change is out-of-date when ensureRebuilt begins, because OR
-    //   succeeded but the corresponding ON has not happened yet. ON will
-    //   succeed, because there have been no intervening NoteDb writes. MN will
-    //   fail, because ON updated the state in NoteDb to something other than
-    //   what MR claimed. This leaves the change in an out-of-date, read-only
-    //   state.
-    //
-    //   If this method threw an exception in this case, the change would
-    //   eventually switch back to read-write when the read-only lease expires,
-    //   so this situation is recoverable. However, it would be inconvenient for
-    //   a change to be read-only for so long.
-    //
-    //   Thus, as an optimization, we have a retry loop that attempts
-    //   ensureRebuilt while still holding the same read-only lease. This
-    //   effectively results in the interleaving OR,MR,ON,MR,MN; in contrast
-    //   with the previous case, here, MR/MN actually rebuilds the change. In
-    //   the case of a write failure, MR/MN might fail and get retried again. If
-    //   it exceeds the maximum number of retries, an exception is thrown.
-    //
-    // * OR,MRO,MR,MN,ON
-    //   The change is out-of-date when ensureRebuilt begins. The change is
-    //   rebuilt, leaving a new state in NoteDb. ON will fail, because the old
-    //   NoteDb state has changed since the ref state was read when the update
-    //   began (prior to OR). This results in an exception from ON, but the end
-    //   result is still an up-to-date, read-only change. The end user that
-    //   initiated the other write observes an error, but this is no different
-    //   from other errors that need retrying, e.g. due to a backend write
-    //   failure.
-
-    Stopwatch sw = Stopwatch.createStarted();
-    Change readOnlyChange = setReadOnlyInReviewDb(id); // MRO
-    if (readOnlyChange == null) {
-      return; // Already migrated.
-    }
-
-    NoteDbChangeState rebuiltState;
-    try {
-      // MR,MN
-      rebuiltState =
-          ensureRebuiltRetryer(sw)
-              .call(
-                  () ->
-                      ensureRebuilt(
-                          readOnlyChange.getProject(),
-                          id,
-                          NoteDbChangeState.parse(readOnlyChange)));
-    } catch (RetryException | ExecutionException e) {
-      throw new OrmException(e);
-    }
-
-    // At this point, the noteDbState in ReviewDb is read-only, and it is
-    // guaranteed to match the state actually in NoteDb. Now it is safe to set
-    // the primary storage to NoteDb.
-
-    setPrimaryStorageNoteDb(id, rebuiltState);
-    log.debug("Migrated change {} to NoteDb primary in {}ms", id, sw.elapsed(MILLISECONDS));
-  }
-
-  private Change setReadOnlyInReviewDb(Change.Id id) throws OrmException {
-    AtomicBoolean alreadyMigrated = new AtomicBoolean(false);
-    Change result =
-        db().changes()
-            .atomicUpdate(
-                id,
-                new AtomicUpdate<Change>() {
-                  @Override
-                  public Change update(Change change) {
-                    NoteDbChangeState state = NoteDbChangeState.parse(change);
-                    if (state == null) {
-                      // Could rebuild the change here, but that's more complexity, and this
-                      // really shouldn't happen.
-                      throw new OrmRuntimeException(
-                          "change " + id + " has no note_db_state; rebuild it first");
-                    }
-                    // If the change is already read-only, then the lease is held by another
-                    // (likely failed) migrator thread. Fail early, as we can't take over
-                    // the lease.
-                    NoteDbChangeState.checkNotReadOnly(change, skewMs);
-                    if (state.getPrimaryStorage() != PrimaryStorage.NOTE_DB) {
-                      Timestamp now = TimeUtil.nowTs();
-                      Timestamp until = new Timestamp(now.getTime() + timeoutMs);
-                      change.setNoteDbState(state.withReadOnlyUntil(until).toString());
-                    } else {
-                      alreadyMigrated.set(true);
-                    }
-                    return change;
-                  }
-                });
-    return alreadyMigrated.get() ? null : result;
-  }
-
-  private Retryer<NoteDbChangeState> ensureRebuiltRetryer(Stopwatch sw) {
-    if (testEnsureRebuiltRetryer != null) {
-      return testEnsureRebuiltRetryer;
-    }
-    // Retry the ensureRebuilt step with backoff until half the timeout has
-    // expired, leaving the remaining half for the rest of the steps.
-    long remainingNanos = (MILLISECONDS.toNanos(timeoutMs) / 2) - sw.elapsed(NANOSECONDS);
-    remainingNanos = Math.max(remainingNanos, 0);
-    return RetryerBuilder.<NoteDbChangeState>newBuilder()
-        .retryIfException(e -> (e instanceof IOException) || (e instanceof OrmException))
-        .withWaitStrategy(
-            WaitStrategies.join(
-                WaitStrategies.exponentialWait(250, MILLISECONDS),
-                WaitStrategies.randomWait(50, MILLISECONDS)))
-        .withStopStrategy(StopStrategies.stopAfterDelay(remainingNanos, NANOSECONDS))
-        .build();
-  }
-
-  private NoteDbChangeState ensureRebuilt(
-      Project.NameKey project, Change.Id id, NoteDbChangeState readOnlyState)
-      throws IOException, OrmException, RepositoryNotFoundException {
-    try (Repository changeRepo = repoManager.openRepository(project);
-        Repository allUsersRepo = repoManager.openRepository(allUsers)) {
-      if (!readOnlyState.isUpToDate(new RepoRefCache(changeRepo), new RepoRefCache(allUsersRepo))) {
-        NoteDbUpdateManager.Result r = rebuilder.rebuildEvenIfReadOnly(db(), id);
-        checkState(
-            r.newState().getReadOnlyUntil().equals(readOnlyState.getReadOnlyUntil()),
-            "state after rebuilding has different read-only lease: %s != %s",
-            r.newState(),
-            readOnlyState);
-        readOnlyState = r.newState();
-      }
-    }
-    return readOnlyState;
-  }
-
-  private void setPrimaryStorageNoteDb(Change.Id id, NoteDbChangeState expectedState)
-      throws OrmException {
-    db().changes()
-        .atomicUpdate(
-            id,
-            new AtomicUpdate<Change>() {
-              @Override
-              public Change update(Change change) {
-                NoteDbChangeState state = NoteDbChangeState.parse(change);
-                if (!Objects.equals(state, expectedState)) {
-                  throw new OrmRuntimeException(badState(state, expectedState));
-                }
-                Timestamp until = state.getReadOnlyUntil().get();
-                if (TimeUtil.nowTs().after(until)) {
-                  throw new OrmRuntimeException(
-                      "read-only lease on change " + id + " expired at " + until);
-                }
-                change.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-                return change;
-              }
-            });
-  }
-
-  private ReviewDb db() {
-    return ReviewDbUtil.unwrapDb(db.get());
-  }
-
-  private String badState(NoteDbChangeState actual, NoteDbChangeState expected) {
-    return "state changed unexpectedly: " + actual + " != " + expected;
-  }
-
-  public void migrateToReviewDbPrimary(Change.Id id, @Nullable Project.NameKey project)
-      throws OrmException, IOException {
-    // Migrating back to ReviewDb primary is much simpler than the original migration to NoteDb
-    // primary, because when NoteDb is primary, each write only goes to one storage location rather
-    // than both. We only need to consider whether a concurrent writer (OR) conflicts with the first
-    // setReadOnlyInNoteDb step (MR) in this method.
-    //
-    // If OR wins, then either:
-    // * MR will set read-only after OR is completed, which is not a concurrent write.
-    // * MR will fail to set read-only with a lock failure. The caller will have to retry, but the
-    //   change is not in a read-only state, so behavior is not degraded in the meantime.
-    //
-    // If MR wins, then either:
-    // * OR will fail with a read-only exception (via AbstractChangeNotes#apply).
-    // * OR will fail with a lock failure.
-    //
-    // In all of these scenarios, the change is read-only if and only if MR succeeds.
-    //
-    // There will be no concurrent writes to ReviewDb for this change until
-    // setPrimaryStorageReviewDb completes, because ReviewDb writes are not attempted when primary
-    // storage is NoteDb. After the primary storage changes back, it is possible for subsequent
-    // NoteDb writes to conflict with the releaseReadOnlyLeaseInNoteDb step, but at this point,
-    // since ReviewDb is primary, we are back to ignoring them.
-    Stopwatch sw = Stopwatch.createStarted();
-    if (project == null) {
-      project = getProject(id);
-    }
-    ObjectId newMetaId = setReadOnlyInNoteDb(project, id);
-    rebuilder.rebuildReviewDb(db(), project, id);
-    setPrimaryStorageReviewDb(id, newMetaId);
-    releaseReadOnlyLeaseInNoteDb(project, id);
-    log.debug("Migrated change {} to ReviewDb primary in {}ms", id, sw.elapsed(MILLISECONDS));
-  }
-
-  private ObjectId setReadOnlyInNoteDb(Project.NameKey project, Change.Id id)
-      throws OrmException, IOException {
-    Timestamp now = TimeUtil.nowTs();
-    Timestamp until = new Timestamp(now.getTime() + timeoutMs);
-    ChangeUpdate update =
-        updateFactory.create(
-            changeNotesFactory.createChecked(db.get(), project, id), internalUserFactory.create());
-    update.setReadOnlyUntil(until);
-    return update.commit();
-  }
-
-  private void setPrimaryStorageReviewDb(Change.Id id, ObjectId newMetaId)
-      throws OrmException, IOException {
-    ImmutableMap.Builder<Account.Id, ObjectId> draftIds = ImmutableMap.builder();
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      for (Ref draftRef :
-          repo.getRefDatabase().getRefs(RefNames.refsDraftCommentsPrefix(id)).values()) {
-        Account.Id accountId = Account.Id.fromRef(draftRef.getName());
-        if (accountId != null) {
-          draftIds.put(accountId, draftRef.getObjectId().copy());
-        }
-      }
-    }
-    NoteDbChangeState newState =
-        new NoteDbChangeState(
-            id,
-            PrimaryStorage.REVIEW_DB,
-            Optional.of(RefState.create(newMetaId, draftIds.build())),
-            Optional.empty());
-    db().changes()
-        .atomicUpdate(
-            id,
-            new AtomicUpdate<Change>() {
-              @Override
-              public Change update(Change change) {
-                if (PrimaryStorage.of(change) != PrimaryStorage.NOTE_DB) {
-                  throw new OrmRuntimeException(
-                      "change " + id + " is not NoteDb primary: " + change.getNoteDbState());
-                }
-                change.setNoteDbState(newState.toString());
-                return change;
-              }
-            });
-  }
-
-  private void releaseReadOnlyLeaseInNoteDb(Project.NameKey project, Change.Id id)
-      throws OrmException {
-    // Use a BatchUpdate since ReviewDb is primary at this point, so it needs to reflect the update.
-    // (In practice retrying won't happen, since we aren't using fused updates at this point.)
-    try {
-      retryHelper.execute(
-          updateFactory -> {
-            try (BatchUpdate bu =
-                updateFactory.create(
-                    db.get(), project, internalUserFactory.create(), TimeUtil.nowTs())) {
-              bu.addOp(
-                  id,
-                  new BatchUpdateOp() {
-                    @Override
-                    public boolean updateChange(ChangeContext ctx) {
-                      ctx.getUpdate(ctx.getChange().currentPatchSetId())
-                          .setReadOnlyUntil(new Timestamp(0));
-                      return true;
-                    }
-                  });
-              bu.execute();
-              return null;
-            }
-          });
-    } catch (RestApiException | UpdateException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  private Project.NameKey getProject(Change.Id id) throws OrmException {
-    List<ChangeData> cds =
-        queryProvider
-            .get()
-            .setRequestedFields(ImmutableSet.of(ChangeField.PROJECT.getName()))
-            .byLegacyChangeId(id);
-    Set<Project.NameKey> projects = new TreeSet<>();
-    for (ChangeData cd : cds) {
-      projects.add(cd.project());
-    }
-    if (projects.size() != 1) {
-      throw new OrmException(
-          "zero or multiple projects found for change "
-              + id
-              + ", must specify project explicitly: "
-              + projects);
-    }
-    return projects.iterator().next();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
deleted file mode 100644
index 777624a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ /dev/null
@@ -1,307 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_SEQUENCES;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.github.rholder.retry.RetryException;
-import com.github.rholder.retry.Retryer;
-import com.github.rholder.retry.RetryerBuilder;
-import com.github.rholder.retry.StopStrategies;
-import com.github.rholder.retry.WaitStrategies;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Predicates;
-import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableList;
-import com.google.common.primitives.Ints;
-import com.google.common.util.concurrent.Runnables;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReentrantLock;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-/**
- * Class for managing an incrementing sequence backed by a git repository.
- *
- * <p>The current sequence number is stored as UTF-8 text in a blob pointed to by a ref in the
- * {@code refs/sequences/*} namespace. Multiple processes can share the same sequence by
- * incrementing the counter using normal git ref updates. To amortize the cost of these ref updates,
- * processes can increment the counter by a larger number and hand out numbers from that range in
- * memory until they run out. This means concurrent processes will hand out somewhat non-monotonic
- * numbers.
- */
-public class RepoSequence {
-  @FunctionalInterface
-  public interface Seed {
-    int get() throws OrmException;
-  }
-
-  @VisibleForTesting
-  static RetryerBuilder<RefUpdate.Result> retryerBuilder() {
-    return RetryerBuilder.<RefUpdate.Result>newBuilder()
-        .retryIfResult(Predicates.equalTo(RefUpdate.Result.LOCK_FAILURE))
-        .withWaitStrategy(
-            WaitStrategies.join(
-                WaitStrategies.exponentialWait(5, TimeUnit.SECONDS),
-                WaitStrategies.randomWait(50, TimeUnit.MILLISECONDS)))
-        .withStopStrategy(StopStrategies.stopAfterDelay(30, TimeUnit.SECONDS));
-  }
-
-  private static final Retryer<RefUpdate.Result> RETRYER = retryerBuilder().build();
-
-  private final GitRepositoryManager repoManager;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final Project.NameKey projectName;
-  private final String refName;
-  private final Seed seed;
-  private final int batchSize;
-  private final Runnable afterReadRef;
-  private final Retryer<RefUpdate.Result> retryer;
-
-  // Protects all non-final fields.
-  private final Lock counterLock;
-
-  private int limit;
-  private int counter;
-
-  @VisibleForTesting int acquireCount;
-
-  public RepoSequence(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      Project.NameKey projectName,
-      String name,
-      Seed seed,
-      int batchSize) {
-    this(
-        repoManager,
-        gitRefUpdated,
-        projectName,
-        name,
-        seed,
-        batchSize,
-        Runnables.doNothing(),
-        RETRYER);
-  }
-
-  @VisibleForTesting
-  RepoSequence(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      Project.NameKey projectName,
-      String name,
-      Seed seed,
-      int batchSize,
-      Runnable afterReadRef,
-      Retryer<RefUpdate.Result> retryer) {
-    this.repoManager = checkNotNull(repoManager, "repoManager");
-    this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
-    this.projectName = checkNotNull(projectName, "projectName");
-
-    checkArgument(
-        name != null
-            && !name.startsWith(REFS)
-            && !name.startsWith(REFS_SEQUENCES.substring(REFS.length())),
-        "name should be a suffix to follow \"refs/sequences/\", got: %s",
-        name);
-    this.refName = RefNames.REFS_SEQUENCES + name;
-
-    this.seed = checkNotNull(seed, "seed");
-
-    checkArgument(batchSize > 0, "expected batchSize > 0, got: %s", batchSize);
-    this.batchSize = batchSize;
-    this.afterReadRef = checkNotNull(afterReadRef, "afterReadRef");
-    this.retryer = checkNotNull(retryer, "retryer");
-
-    counterLock = new ReentrantLock(true);
-  }
-
-  public int next() throws OrmException {
-    counterLock.lock();
-    try {
-      if (counter >= limit) {
-        acquire(batchSize);
-      }
-      return counter++;
-    } finally {
-      counterLock.unlock();
-    }
-  }
-
-  public ImmutableList<Integer> next(int count) throws OrmException {
-    if (count == 0) {
-      return ImmutableList.of();
-    }
-    checkArgument(count > 0, "count is negative: %s", count);
-    counterLock.lock();
-    try {
-      List<Integer> ids = new ArrayList<>(count);
-      while (counter < limit) {
-        ids.add(counter++);
-        if (ids.size() == count) {
-          return ImmutableList.copyOf(ids);
-        }
-      }
-      acquire(Math.max(count - ids.size(), batchSize));
-      while (ids.size() < count) {
-        ids.add(counter++);
-      }
-      return ImmutableList.copyOf(ids);
-    } finally {
-      counterLock.unlock();
-    }
-  }
-
-  @VisibleForTesting
-  public void set(int val) throws OrmException {
-    // Don't bother spinning. This is only for tests, and a test that calls set
-    // concurrently with other writes is doing it wrong.
-    counterLock.lock();
-    try {
-      try (Repository repo = repoManager.openRepository(projectName);
-          RevWalk rw = new RevWalk(repo)) {
-        checkResult(store(repo, rw, null, val));
-        counter = limit;
-      } catch (IOException e) {
-        throw new OrmException(e);
-      }
-    } finally {
-      counterLock.unlock();
-    }
-  }
-
-  private void acquire(int count) throws OrmException {
-    try (Repository repo = repoManager.openRepository(projectName);
-        RevWalk rw = new RevWalk(repo)) {
-      TryAcquire attempt = new TryAcquire(repo, rw, count);
-      checkResult(retryer.call(attempt));
-      counter = attempt.next;
-      limit = counter + count;
-      acquireCount++;
-    } catch (ExecutionException | RetryException e) {
-      if (e.getCause() != null) {
-        Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
-      }
-      throw new OrmException(e);
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  private void checkResult(RefUpdate.Result result) throws OrmException {
-    if (!refUpdated(result)) {
-      throw new OrmException("failed to update " + refName + ": " + result);
-    }
-  }
-
-  private boolean refUpdated(RefUpdate.Result result) {
-    return result == RefUpdate.Result.NEW || result == RefUpdate.Result.FORCED;
-  }
-
-  private class TryAcquire implements Callable<RefUpdate.Result> {
-    private final Repository repo;
-    private final RevWalk rw;
-    private final int count;
-
-    private int next;
-
-    private TryAcquire(Repository repo, RevWalk rw, int count) {
-      this.repo = repo;
-      this.rw = rw;
-      this.count = count;
-    }
-
-    @Override
-    public RefUpdate.Result call() throws Exception {
-      Ref ref = repo.exactRef(refName);
-      afterReadRef.run();
-      ObjectId oldId;
-      if (ref == null) {
-        oldId = ObjectId.zeroId();
-        next = seed.get();
-      } else {
-        oldId = ref.getObjectId();
-        next = parse(oldId);
-      }
-      return store(repo, rw, oldId, next + count);
-    }
-
-    private int parse(ObjectId id) throws IOException, OrmException {
-      ObjectLoader ol = rw.getObjectReader().open(id, OBJ_BLOB);
-      if (ol.getType() != OBJ_BLOB) {
-        // In theory this should be thrown by open but not all implementations
-        // may do it properly (certainly InMemoryRepository doesn't).
-        throw new IncorrectObjectTypeException(id, OBJ_BLOB);
-      }
-      String str = CharMatcher.whitespace().trimFrom(new String(ol.getCachedBytes(), UTF_8));
-      Integer val = Ints.tryParse(str);
-      if (val == null) {
-        throw new OrmException("invalid value in " + refName + " blob at " + id.name());
-      }
-      return val;
-    }
-  }
-
-  private RefUpdate.Result store(Repository repo, RevWalk rw, @Nullable ObjectId oldId, int val)
-      throws IOException {
-    ObjectId newId;
-    try (ObjectInserter ins = repo.newObjectInserter()) {
-      newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
-      ins.flush();
-    }
-    RefUpdate ru = repo.updateRef(refName);
-    if (oldId != null) {
-      ru.setExpectedOldObjectId(oldId);
-    }
-    ru.setNewObjectId(newId);
-    ru.setForceUpdate(true); // Required for non-commitish updates.
-    RefUpdate.Result result = ru.update(rw);
-    if (refUpdated(result)) {
-      gitRefUpdated.fire(projectName, ru, null);
-    }
-    return result;
-  }
-
-  public static ReceiveCommand storeNew(ObjectInserter ins, String name, int val)
-      throws IOException {
-    ObjectId newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
-    return new ReceiveCommand(ObjectId.zeroId(), newId, RefNames.REFS_SEQUENCES + name);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
deleted file mode 100644
index 82593eb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ /dev/null
@@ -1,239 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.gerrit.reviewdb.client.RefNames.robotCommentsRef;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Date;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * A single delta to apply atomically to a change.
- *
- * <p>This delta contains only robot comments on a single patch set of a change by a single author.
- * This delta will become a single commit in the repository.
- *
- * <p>This class is not thread safe.
- */
-public class RobotCommentUpdate extends AbstractChangeUpdate {
-  public interface Factory {
-    RobotCommentUpdate create(
-        ChangeNotes notes,
-        @Assisted("effective") Account.Id accountId,
-        @Assisted("real") Account.Id realAccountId,
-        PersonIdent authorIdent,
-        Date when);
-
-    RobotCommentUpdate create(
-        Change change,
-        @Assisted("effective") Account.Id accountId,
-        @Assisted("real") Account.Id realAccountId,
-        PersonIdent authorIdent,
-        Date when);
-  }
-
-  private List<RobotComment> put = new ArrayList<>();
-
-  @AssistedInject
-  private RobotCommentUpdate(
-      @GerritServerConfig Config cfg,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @AnonymousCowardName String anonymousCowardName,
-      NotesMigration migration,
-      ChangeNoteUtil noteUtil,
-      @Assisted ChangeNotes notes,
-      @Assisted("effective") Account.Id accountId,
-      @Assisted("real") Account.Id realAccountId,
-      @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
-    super(
-        cfg,
-        migration,
-        noteUtil,
-        serverIdent,
-        anonymousCowardName,
-        notes,
-        null,
-        accountId,
-        realAccountId,
-        authorIdent,
-        when);
-  }
-
-  @AssistedInject
-  private RobotCommentUpdate(
-      @GerritServerConfig Config cfg,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @AnonymousCowardName String anonymousCowardName,
-      NotesMigration migration,
-      ChangeNoteUtil noteUtil,
-      @Assisted Change change,
-      @Assisted("effective") Account.Id accountId,
-      @Assisted("real") Account.Id realAccountId,
-      @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
-    super(
-        cfg,
-        migration,
-        noteUtil,
-        serverIdent,
-        anonymousCowardName,
-        null,
-        change,
-        accountId,
-        realAccountId,
-        authorIdent,
-        when);
-  }
-
-  public void putComment(RobotComment c) {
-    verifyComment(c);
-    put.add(c);
-  }
-
-  private CommitBuilder storeCommentsInNotes(
-      RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
-      throws ConfigInvalidException, OrmException, IOException {
-    RevisionNoteMap<RobotCommentsRevisionNote> rnm = getRevisionNoteMap(rw, curr);
-    Set<RevId> updatedRevs = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
-    RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
-
-    for (RobotComment c : put) {
-      cache.get(new RevId(c.revId)).putComment(c);
-    }
-
-    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
-    boolean touchedAnyRevs = false;
-    boolean hasComments = false;
-    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
-      updatedRevs.add(e.getKey());
-      ObjectId id = ObjectId.fromString(e.getKey().get());
-      byte[] data = e.getValue().build(noteUtil, true);
-      if (!Arrays.equals(data, e.getValue().baseRaw)) {
-        touchedAnyRevs = true;
-      }
-      if (data.length == 0) {
-        rnm.noteMap.remove(id);
-      } else {
-        hasComments = true;
-        ObjectId dataBlob = ins.insert(OBJ_BLOB, data);
-        rnm.noteMap.set(id, dataBlob);
-      }
-    }
-
-    // If we didn't touch any notes, tell the caller this was a no-op update. We
-    // couldn't have done this in isEmpty() below because we hadn't read the old
-    // data yet.
-    if (!touchedAnyRevs) {
-      return NO_OP_UPDATE;
-    }
-
-    // If we touched every revision and there are no comments left, tell the
-    // caller to delete the entire ref.
-    boolean touchedAllRevs = updatedRevs.equals(rnm.revisionNotes.keySet());
-    if (touchedAllRevs && !hasComments) {
-      return null;
-    }
-
-    cb.setTreeId(rnm.noteMap.writeTree(ins));
-    return cb;
-  }
-
-  private RevisionNoteMap<RobotCommentsRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr)
-      throws ConfigInvalidException, OrmException, IOException {
-    if (curr.equals(ObjectId.zeroId())) {
-      return RevisionNoteMap.emptyMap();
-    }
-    if (migration.readChanges()) {
-      // If reading from changes is enabled, then the old RobotCommentNotes
-      // already parsed the revision notes. We can reuse them as long as the ref
-      // hasn't advanced.
-      ChangeNotes changeNotes = getNotes();
-      if (changeNotes != null) {
-        RobotCommentNotes robotCommentNotes = changeNotes.load().getRobotCommentNotes();
-        if (robotCommentNotes != null) {
-          ObjectId idFromNotes = firstNonNull(robotCommentNotes.getRevision(), ObjectId.zeroId());
-          RevisionNoteMap<RobotCommentsRevisionNote> rnm = robotCommentNotes.getRevisionNoteMap();
-          if (idFromNotes.equals(curr) && rnm != null) {
-            return rnm;
-          }
-        }
-      }
-    }
-    NoteMap noteMap;
-    if (!curr.equals(ObjectId.zeroId())) {
-      noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(curr));
-    } else {
-      noteMap = NoteMap.newEmptyMap();
-    }
-    // Even though reading from changes might not be enabled, we need to
-    // parse any existing revision notes so we can merge them.
-    return RevisionNoteMap.parseRobotComments(noteUtil, rw.getObjectReader(), noteMap);
-  }
-
-  @Override
-  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
-      throws OrmException, IOException {
-    CommitBuilder cb = new CommitBuilder();
-    cb.setMessage("Update robot comments");
-    try {
-      return storeCommentsInNotes(rw, ins, curr, cb);
-    } catch (ConfigInvalidException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  @Override
-  protected Project.NameKey getProjectName() {
-    return getNotes().getProjectName();
-  }
-
-  @Override
-  protected String getRefName() {
-    return robotCommentsRef(getId());
-  }
-
-  @Override
-  public boolean isEmpty() {
-    return put.isEmpty();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
deleted file mode 100644
index 29ca6d0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
+++ /dev/null
@@ -1,695 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Splitter;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableCollection;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Ordering;
-import com.google.common.collect.Sets;
-import com.google.common.collect.Table;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.GerritServerId;
-import com.google.gerrit.server.notedb.ChangeBundle;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.ChangeDraftUpdate;
-import com.google.gerrit.server.notedb.ChangeNoteUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager.OpenRepo;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.update.ChainedReceiveCommands;
-import com.google.gwtorm.client.Key;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.AtomicUpdate;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
-import java.util.TreeMap;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-public class ChangeRebuilderImpl extends ChangeRebuilder {
-  /**
-   * The maximum amount of time between the ReviewDb timestamp of the first and last events batched
-   * together into a single NoteDb update.
-   *
-   * <p>Used to account for the fact that different records with their own timestamps (e.g. {@link
-   * PatchSetApproval} and {@link ChangeMessage}) historically didn't necessarily use the same
-   * timestamp, and tended to call {@code System.currentTimeMillis()} independently.
-   */
-  public static final long MAX_WINDOW_MS = SECONDS.toMillis(3);
-
-  /**
-   * The maximum amount of time between two consecutive events to consider them to be in the same
-   * batch.
-   */
-  static final long MAX_DELTA_MS = SECONDS.toMillis(1);
-
-  private final AccountCache accountCache;
-  private final ChangeBundleReader bundleReader;
-  private final ChangeDraftUpdate.Factory draftUpdateFactory;
-  private final ChangeNoteUtil changeNoteUtil;
-  private final ChangeNotes.Factory notesFactory;
-  private final ChangeUpdate.Factory updateFactory;
-  private final CommentsUtil commentsUtil;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
-  private final NotesMigration migration;
-  private final PatchListCache patchListCache;
-  private final PersonIdent serverIdent;
-  private final ProjectCache projectCache;
-  private final String anonymousCowardName;
-  private final String serverId;
-  private final long skewMs;
-
-  @Inject
-  ChangeRebuilderImpl(
-      @GerritServerConfig Config cfg,
-      SchemaFactory<ReviewDb> schemaFactory,
-      AccountCache accountCache,
-      ChangeBundleReader bundleReader,
-      ChangeDraftUpdate.Factory draftUpdateFactory,
-      ChangeNoteUtil changeNoteUtil,
-      ChangeNotes.Factory notesFactory,
-      ChangeUpdate.Factory updateFactory,
-      CommentsUtil commentsUtil,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      NotesMigration migration,
-      PatchListCache patchListCache,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @Nullable ProjectCache projectCache,
-      @AnonymousCowardName String anonymousCowardName,
-      @GerritServerId String serverId) {
-    super(schemaFactory);
-    this.accountCache = accountCache;
-    this.bundleReader = bundleReader;
-    this.draftUpdateFactory = draftUpdateFactory;
-    this.changeNoteUtil = changeNoteUtil;
-    this.notesFactory = notesFactory;
-    this.updateFactory = updateFactory;
-    this.commentsUtil = commentsUtil;
-    this.updateManagerFactory = updateManagerFactory;
-    this.migration = migration;
-    this.patchListCache = patchListCache;
-    this.serverIdent = serverIdent;
-    this.projectCache = projectCache;
-    this.anonymousCowardName = anonymousCowardName;
-    this.serverId = serverId;
-    this.skewMs = NoteDbChangeState.getReadOnlySkew(cfg);
-  }
-
-  @Override
-  public Result rebuild(ReviewDb db, Change.Id changeId) throws IOException, OrmException {
-    return rebuild(db, changeId, true);
-  }
-
-  @Override
-  public Result rebuildEvenIfReadOnly(ReviewDb db, Change.Id changeId)
-      throws IOException, OrmException {
-    return rebuild(db, changeId, false);
-  }
-
-  private Result rebuild(ReviewDb db, Change.Id changeId, boolean checkReadOnly)
-      throws IOException, OrmException {
-    db = ReviewDbUtil.unwrapDb(db);
-    // Read change just to get project; this instance is then discarded so we can read a consistent
-    // ChangeBundle inside a transaction.
-    Change change = db.changes().get(changeId);
-    if (change == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-    try (NoteDbUpdateManager manager = updateManagerFactory.create(change.getProject())) {
-      buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
-      return execute(db, changeId, manager, checkReadOnly, true);
-    }
-  }
-
-  @Override
-  public Result rebuild(NoteDbUpdateManager manager, ChangeBundle bundle)
-      throws NoSuchChangeException, IOException, OrmException {
-    Change change = new Change(bundle.getChange());
-    buildUpdates(manager, bundle);
-    return manager.stageAndApplyDelta(change);
-  }
-
-  @Override
-  public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
-      throws IOException, OrmException {
-    db = ReviewDbUtil.unwrapDb(db);
-    Change change = checkNoteDbState(ChangeNotes.readOneReviewDbChange(db, changeId));
-    if (change == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-    NoteDbUpdateManager manager = updateManagerFactory.create(change.getProject());
-    buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
-    manager.stage();
-    return manager;
-  }
-
-  @Override
-  public Result execute(ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager)
-      throws OrmException, IOException {
-    return execute(db, changeId, manager, true, true);
-  }
-
-  public Result execute(
-      ReviewDb db,
-      Change.Id changeId,
-      NoteDbUpdateManager manager,
-      boolean checkReadOnly,
-      boolean executeManager)
-      throws OrmException, IOException {
-    db = ReviewDbUtil.unwrapDb(db);
-    Change change = checkNoteDbState(ChangeNotes.readOneReviewDbChange(db, changeId));
-    if (change == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-
-    String oldNoteDbStateStr = change.getNoteDbState();
-    Result r = manager.stageAndApplyDelta(change);
-    String newNoteDbStateStr = change.getNoteDbState();
-    if (newNoteDbStateStr == null) {
-      throw new OrmException(
-          "Rebuilding change %s produced no writes to NoteDb: "
-              + bundleReader.fromReviewDb(db, changeId));
-    }
-    NoteDbChangeState newNoteDbState =
-        checkNotNull(NoteDbChangeState.parse(changeId, newNoteDbStateStr));
-    try {
-      db.changes()
-          .atomicUpdate(
-              changeId,
-              new AtomicUpdate<Change>() {
-                @Override
-                public Change update(Change change) {
-                  if (checkReadOnly) {
-                    NoteDbChangeState.checkNotReadOnly(change, skewMs);
-                  }
-                  String currNoteDbStateStr = change.getNoteDbState();
-                  if (Objects.equals(currNoteDbStateStr, newNoteDbStateStr)) {
-                    // Another thread completed the same rebuild we were about to.
-                    throw new AbortUpdateException();
-                  } else if (!Objects.equals(oldNoteDbStateStr, currNoteDbStateStr)) {
-                    // Another thread updated the state to something else.
-                    throw new ConflictingUpdateRuntimeException(change, oldNoteDbStateStr);
-                  }
-                  change.setNoteDbState(newNoteDbStateStr);
-                  return change;
-                }
-              });
-    } catch (ConflictingUpdateRuntimeException e) {
-      // Rethrow as an OrmException so the caller knows to use staged results. Strictly speaking
-      // they are not completely up to date, but result we send to the caller is the same as if this
-      // rebuild had executed before the other thread.
-      throw new ConflictingUpdateException(e);
-    } catch (AbortUpdateException e) {
-      if (newNoteDbState.isUpToDate(
-          manager.getChangeRepo().cmds.getRepoRefCache(),
-          manager.getAllUsersRepo().cmds.getRepoRefCache())) {
-        // If the state in ReviewDb matches NoteDb at this point, it means another thread
-        // successfully completed this rebuild. It's ok to not execute the update in this case,
-        // since the object referenced in the Result was flushed to the repo by whatever thread won
-        // the race.
-        return r;
-      }
-      // If the state doesn't match, that means another thread attempted this rebuild, but
-      // failed. Fall through and try to update the ref again.
-    }
-    if (migration.failChangeWrites()) {
-      // Don't even attempt to execute if read-only, it would fail anyway. But do throw an exception
-      // to the caller so they know to use the staged results instead of reading from the repo.
-      throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
-    }
-    if (executeManager) {
-      manager.execute();
-    }
-    return r;
-  }
-
-  static Change checkNoteDbState(Change c) throws OrmException {
-    // Can only rebuild a change if its primary storage is ReviewDb.
-    NoteDbChangeState s = NoteDbChangeState.parse(c);
-    if (s != null && s.getPrimaryStorage() != PrimaryStorage.REVIEW_DB) {
-      throw new OrmException(
-          String.format("cannot rebuild change " + c.getId() + " with state " + s));
-    }
-    return c;
-  }
-
-  @Override
-  public void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle)
-      throws IOException, OrmException {
-    manager.setCheckExpectedState(false).setRefLogMessage("Rebuilding change");
-    Change change = new Change(bundle.getChange());
-    if (bundle.getPatchSets().isEmpty()) {
-      throw new NoPatchSetsException(change.getId());
-    }
-    if (change.getLastUpdatedOn().compareTo(change.getCreatedOn()) < 0) {
-      // A bug in data migration might set created_on to the time of the migration. The
-      // correct timestamps were lost, but we can at least set it so created_on is not after
-      // last_updated_on.
-      // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7397
-      change.setCreatedOn(change.getLastUpdatedOn());
-    }
-
-    // We will rebuild all events, except for draft comments, in buckets based on author and
-    // timestamp.
-    List<Event> events = new ArrayList<>();
-    ListMultimap<Account.Id, DraftCommentEvent> draftCommentEvents =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-
-    events.addAll(getHashtagsEvents(change, manager));
-
-    // Delete ref only after hashtags have been read.
-    deleteChangeMetaRef(change, manager.getChangeRepo().cmds);
-    deleteDraftRefs(change, manager.getAllUsersRepo());
-
-    Integer minPsNum = getMinPatchSetNum(bundle);
-    TreeMap<PatchSet.Id, PatchSetEvent> patchSetEvents =
-        new TreeMap<>(ReviewDbUtil.intKeyOrdering());
-
-    for (PatchSet ps : bundle.getPatchSets()) {
-      PatchSetEvent pse = new PatchSetEvent(change, ps, manager.getChangeRepo().rw);
-      patchSetEvents.put(ps.getId(), pse);
-      events.add(pse);
-      for (Comment c : getComments(bundle, serverId, Status.PUBLISHED, ps)) {
-        CommentEvent e = new CommentEvent(c, change, ps, patchListCache);
-        events.add(e.addDep(pse));
-      }
-      for (Comment c : getComments(bundle, serverId, Status.DRAFT, ps)) {
-        DraftCommentEvent e = new DraftCommentEvent(c, change, ps, patchListCache);
-        draftCommentEvents.put(c.author.getId(), e);
-      }
-    }
-    ensurePatchSetOrder(patchSetEvents);
-
-    for (PatchSetApproval psa : bundle.getPatchSetApprovals()) {
-      PatchSetEvent pse = patchSetEvents.get(psa.getPatchSetId());
-      if (pse != null) {
-        events.add(new ApprovalEvent(psa, change.getCreatedOn()).addDep(pse));
-      }
-    }
-
-    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> r :
-        bundle.getReviewers().asTable().cellSet()) {
-      events.add(new ReviewerEvent(r, change.getCreatedOn()));
-    }
-
-    Change noteDbChange = new Change(null, null, null, null, null);
-    for (ChangeMessage msg : bundle.getChangeMessages()) {
-      Event msgEvent = new ChangeMessageEvent(change, noteDbChange, msg, change.getCreatedOn());
-      if (msg.getPatchSetId() != null) {
-        PatchSetEvent pse = patchSetEvents.get(msg.getPatchSetId());
-        if (pse == null) {
-          continue; // Ignore events for missing patch sets.
-        }
-        msgEvent.addDep(pse);
-      }
-      events.add(msgEvent);
-    }
-
-    sortAndFillEvents(change, noteDbChange, bundle.getPatchSets(), events, minPsNum);
-
-    EventList<Event> el = new EventList<>();
-    for (Event e : events) {
-      if (!el.canAdd(e)) {
-        flushEventsToUpdate(manager, el, change);
-        checkState(el.canAdd(e));
-      }
-      el.add(e);
-    }
-    flushEventsToUpdate(manager, el, change);
-
-    EventList<DraftCommentEvent> plcel = new EventList<>();
-    for (Account.Id author : draftCommentEvents.keys()) {
-      for (DraftCommentEvent e : Ordering.natural().sortedCopy(draftCommentEvents.get(author))) {
-        if (!plcel.canAdd(e)) {
-          flushEventsToDraftUpdate(manager, plcel, change);
-          checkState(plcel.canAdd(e));
-        }
-        plcel.add(e);
-      }
-      flushEventsToDraftUpdate(manager, plcel, change);
-    }
-  }
-
-  private static Integer getMinPatchSetNum(ChangeBundle bundle) {
-    Integer minPsNum = null;
-    for (PatchSet ps : bundle.getPatchSets()) {
-      int n = ps.getId().get();
-      if (minPsNum == null || n < minPsNum) {
-        minPsNum = n;
-      }
-    }
-    return minPsNum;
-  }
-
-  private static void ensurePatchSetOrder(TreeMap<PatchSet.Id, PatchSetEvent> events) {
-    if (events.isEmpty()) {
-      return;
-    }
-    Iterator<PatchSetEvent> it = events.values().iterator();
-    PatchSetEvent curr = it.next();
-    while (it.hasNext()) {
-      PatchSetEvent next = it.next();
-      next.addDep(curr);
-      curr = next;
-    }
-  }
-
-  private static List<Comment> getComments(
-      ChangeBundle bundle, String serverId, PatchLineComment.Status status, PatchSet ps) {
-    return bundle
-        .getPatchLineComments()
-        .stream()
-        .filter(c -> c.getPatchSetId().equals(ps.getId()) && c.getStatus() == status)
-        .map(plc -> plc.asComment(serverId))
-        .sorted(CommentsUtil.COMMENT_ORDER)
-        .collect(toList());
-  }
-
-  private void sortAndFillEvents(
-      Change change,
-      Change noteDbChange,
-      ImmutableCollection<PatchSet> patchSets,
-      List<Event> events,
-      Integer minPsNum) {
-    Event finalUpdates = new FinalUpdatesEvent(change, noteDbChange, patchSets);
-    events.add(finalUpdates);
-    setPostSubmitDeps(events);
-    new EventSorter(events).sort();
-
-    // Ensure the first event in the list creates the change, setting the author and any required
-    // footers. Also force the creation time of the first patch set to match the creation time of
-    // the change.
-    Event first = events.get(0);
-    if (first instanceof PatchSetEvent && change.getOwner().equals(first.user)) {
-      first.when = change.getCreatedOn();
-      ((PatchSetEvent) first).createChange = true;
-    } else {
-      events.add(0, new CreateChangeEvent(change, minPsNum));
-    }
-
-    // Final pass to correct some inconsistencies.
-    //
-    // First, fill in any missing patch set IDs using the latest patch set of the change at the time
-    // of the event, because NoteDb can't represent actions with no associated patch set ID. This
-    // workaround is as if a user added a ChangeMessage on the change by replying from the latest
-    // patch set.
-    //
-    // Start with the first patch set that actually exists. If there are no patch sets at all,
-    // minPsNum will be null, so just bail and use 1 as the patch set ID.
-    //
-    // Second, ensure timestamps are nondecreasing, by copying the previous timestamp if this
-    // happens. This assumes that the only way this can happen is due to dependency constraints, and
-    // it is ok to give an event the same timestamp as one of its dependencies.
-    int ps = firstNonNull(minPsNum, 1);
-    for (int i = 0; i < events.size(); i++) {
-      Event e = events.get(i);
-      if (e.psId == null) {
-        e.psId = new PatchSet.Id(change.getId(), ps);
-      } else {
-        ps = Math.max(ps, e.psId.get());
-      }
-
-      if (i > 0) {
-        Event p = events.get(i - 1);
-        if (e.when.before(p.when)) {
-          e.when = p.when;
-        }
-      }
-    }
-  }
-
-  private void setPostSubmitDeps(List<Event> events) {
-    Optional<Event> submitEvent =
-        Lists.reverse(events).stream().filter(Event::isSubmit).findFirst();
-    if (submitEvent.isPresent()) {
-      events.stream().filter(Event::isPostSubmitApproval).forEach(e -> e.addDep(submitEvent.get()));
-    }
-  }
-
-  private void flushEventsToUpdate(
-      NoteDbUpdateManager manager, EventList<Event> events, Change change)
-      throws OrmException, IOException {
-    if (events.isEmpty()) {
-      return;
-    }
-    Comparator<String> labelNameComparator;
-    if (projectCache != null) {
-      labelNameComparator = projectCache.get(change.getProject()).getLabelTypes().nameComparator();
-    } else {
-      // No project cache available, bail and use natural ordering; there's no semantic difference
-      // anyway difference.
-      labelNameComparator = Ordering.natural();
-    }
-    ChangeUpdate update =
-        updateFactory.create(
-            change,
-            events.getAccountId(),
-            events.getRealAccountId(),
-            newAuthorIdent(events),
-            events.getWhen(),
-            labelNameComparator);
-    update.setAllowWriteToNewRef(true);
-    update.setPatchSetId(events.getPatchSetId());
-    update.setTag(events.getTag());
-    for (Event e : events) {
-      e.apply(update);
-    }
-    manager.add(update);
-    events.clear();
-  }
-
-  private void flushEventsToDraftUpdate(
-      NoteDbUpdateManager manager, EventList<DraftCommentEvent> events, Change change)
-      throws OrmException {
-    if (events.isEmpty()) {
-      return;
-    }
-    ChangeDraftUpdate update =
-        draftUpdateFactory.create(
-            change,
-            events.getAccountId(),
-            events.getRealAccountId(),
-            newAuthorIdent(events),
-            events.getWhen());
-    update.setPatchSetId(events.getPatchSetId());
-    for (DraftCommentEvent e : events) {
-      e.applyDraft(update);
-    }
-    manager.add(update);
-    events.clear();
-  }
-
-  private PersonIdent newAuthorIdent(EventList<?> events) {
-    Account.Id id = events.getAccountId();
-    if (id == null) {
-      return new PersonIdent(serverIdent, events.getWhen());
-    }
-    return changeNoteUtil.newIdent(
-        accountCache.get(id).getAccount(), events.getWhen(), serverIdent, anonymousCowardName);
-  }
-
-  private List<HashtagsEvent> getHashtagsEvents(Change change, NoteDbUpdateManager manager)
-      throws IOException {
-    String refName = changeMetaRef(change.getId());
-    Optional<ObjectId> old = manager.getChangeRepo().getObjectId(refName);
-    if (!old.isPresent()) {
-      return Collections.emptyList();
-    }
-
-    RevWalk rw = manager.getChangeRepo().rw;
-    List<HashtagsEvent> events = new ArrayList<>();
-    rw.reset();
-    rw.markStart(rw.parseCommit(old.get()));
-    for (RevCommit commit : rw) {
-      Account.Id authorId;
-      try {
-        authorId = changeNoteUtil.parseIdent(commit.getAuthorIdent(), change.getId());
-      } catch (ConfigInvalidException e) {
-        continue; // Corrupt data, no valid hashtags in this commit.
-      }
-      PatchSet.Id psId = parsePatchSetId(change, commit);
-      Set<String> hashtags = parseHashtags(commit);
-      if (authorId == null || psId == null || hashtags == null) {
-        continue;
-      }
-
-      Timestamp commitTime = new Timestamp(commit.getCommitterIdent().getWhen().getTime());
-      events.add(new HashtagsEvent(psId, authorId, commitTime, hashtags, change.getCreatedOn()));
-    }
-    return events;
-  }
-
-  private Set<String> parseHashtags(RevCommit commit) {
-    List<String> hashtagsLines = commit.getFooterLines(FOOTER_HASHTAGS);
-    if (hashtagsLines.isEmpty() || hashtagsLines.size() > 1) {
-      return null;
-    }
-
-    if (hashtagsLines.get(0).isEmpty()) {
-      return ImmutableSet.of();
-    }
-    return Sets.newHashSet(Splitter.on(',').split(hashtagsLines.get(0)));
-  }
-
-  private PatchSet.Id parsePatchSetId(Change change, RevCommit commit) {
-    List<String> psIdLines = commit.getFooterLines(FOOTER_PATCH_SET);
-    if (psIdLines.size() != 1) {
-      return null;
-    }
-    Integer psId = Ints.tryParse(psIdLines.get(0));
-    if (psId == null) {
-      return null;
-    }
-    return new PatchSet.Id(change.getId(), psId);
-  }
-
-  private void deleteChangeMetaRef(Change change, ChainedReceiveCommands cmds) throws IOException {
-    String refName = changeMetaRef(change.getId());
-    Optional<ObjectId> old = cmds.get(refName);
-    if (old.isPresent()) {
-      cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), refName));
-    }
-  }
-
-  private void deleteDraftRefs(Change change, OpenRepo allUsersRepo) throws IOException {
-    for (Ref r :
-        allUsersRepo
-            .repo
-            .getRefDatabase()
-            .getRefs(RefNames.refsDraftCommentsPrefix(change.getId()))
-            .values()) {
-      allUsersRepo.cmds.add(new ReceiveCommand(r.getObjectId(), ObjectId.zeroId(), r.getName()));
-    }
-  }
-
-  static void createChange(ChangeUpdate update, Change change) {
-    update.setSubjectForCommit("Create change");
-    update.setChangeId(change.getKey().get());
-    update.setBranch(change.getDest().get());
-    update.setSubject(change.getOriginalSubject());
-    if (change.getRevertOf() != null) {
-      update.setRevertOf(change.getRevertOf().get());
-    }
-  }
-
-  @Override
-  public void rebuildReviewDb(ReviewDb db, Project.NameKey project, Change.Id changeId)
-      throws OrmException {
-    // TODO(dborowitz): Fail fast if changes tables are disabled in ReviewDb.
-    ChangeNotes notes = notesFactory.create(db, project, changeId);
-    ChangeBundle bundle = ChangeBundle.fromNotes(commentsUtil, notes);
-
-    db = ReviewDbUtil.unwrapDb(db);
-    db.changes().beginTransaction(changeId);
-    try {
-      Change c = db.changes().get(changeId);
-      if (c != null) {
-        PrimaryStorage ps = PrimaryStorage.of(c);
-        switch (ps) {
-          case REVIEW_DB:
-            return; // Nothing to do.
-          case NOTE_DB:
-            break; // Continue and rebuild.
-          default:
-            throw new OrmException("primary storage of " + changeId + " is " + ps);
-        }
-      } else {
-        c = notes.getChange();
-      }
-      db.changes().upsert(Collections.singleton(c));
-      putExactlyEntities(
-          db.changeMessages(), db.changeMessages().byChange(c.getId()), bundle.getChangeMessages());
-      putExactlyEntities(db.patchSets(), db.patchSets().byChange(c.getId()), bundle.getPatchSets());
-      putExactlyEntities(
-          db.patchSetApprovals(),
-          db.patchSetApprovals().byChange(c.getId()),
-          bundle.getPatchSetApprovals());
-      putExactlyEntities(
-          db.patchComments(),
-          db.patchComments().byChange(c.getId()),
-          bundle.getPatchLineComments());
-      db.commit();
-    } finally {
-      db.rollback();
-    }
-  }
-
-  private static <T, K extends Key<?>> void putExactlyEntities(
-      Access<T, K> access, Iterable<T> existing, Collection<T> ents) throws OrmException {
-    Set<K> toKeep = access.toMap(ents).keySet();
-    access.delete(
-        FluentIterable.from(existing).filter(e -> !toKeep.contains(access.primaryKey(e))));
-    access.upsert(ents);
-  }
-}
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
deleted file mode 100644
index 7777400..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ /dev/null
@@ -1,196 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-package com.google.gerrit.server.patch;
-
-import static com.google.gerrit.server.patch.DiffSummaryLoader.toDiffSummary;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.cache.Cache;
-import com.google.common.util.concurrent.UncheckedExecutionException;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Singleton;
-import com.google.inject.name.Named;
-import java.util.concurrent.ExecutionException;
-import org.eclipse.jgit.errors.LargeObjectException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-
-/** Provides a cached list of {@link PatchListEntry}. */
-@Singleton
-public class PatchListCacheImpl implements PatchListCache {
-  static final String FILE_NAME = "diff";
-  static final String INTRA_NAME = "diff_intraline";
-  static final String DIFF_SUMMARY = "diff_summary";
-
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        factory(PatchListLoader.Factory.class);
-        persist(FILE_NAME, PatchListKey.class, PatchList.class)
-            .maximumWeight(10 << 20)
-            .weigher(PatchListWeigher.class);
-
-        factory(IntraLineLoader.Factory.class);
-        persist(INTRA_NAME, IntraLineDiffKey.class, IntraLineDiff.class)
-            .maximumWeight(10 << 20)
-            .weigher(IntraLineWeigher.class);
-
-        factory(DiffSummaryLoader.Factory.class);
-        persist(DIFF_SUMMARY, DiffSummaryKey.class, DiffSummary.class)
-            .maximumWeight(10 << 20)
-            .weigher(DiffSummaryWeigher.class)
-            .diskLimit(1 << 30);
-
-        bind(PatchListCacheImpl.class);
-        bind(PatchListCache.class).to(PatchListCacheImpl.class);
-      }
-    };
-  }
-
-  private final Cache<PatchListKey, PatchList> fileCache;
-  private final Cache<IntraLineDiffKey, IntraLineDiff> intraCache;
-  private final Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
-  private final PatchListLoader.Factory fileLoaderFactory;
-  private final IntraLineLoader.Factory intraLoaderFactory;
-  private final DiffSummaryLoader.Factory diffSummaryLoaderFactory;
-  private final boolean computeIntraline;
-
-  @Inject
-  PatchListCacheImpl(
-      @Named(FILE_NAME) Cache<PatchListKey, PatchList> fileCache,
-      @Named(INTRA_NAME) Cache<IntraLineDiffKey, IntraLineDiff> intraCache,
-      @Named(DIFF_SUMMARY) Cache<DiffSummaryKey, DiffSummary> diffSummaryCache,
-      PatchListLoader.Factory fileLoaderFactory,
-      IntraLineLoader.Factory intraLoaderFactory,
-      DiffSummaryLoader.Factory diffSummaryLoaderFactory,
-      @GerritServerConfig Config cfg) {
-    this.fileCache = fileCache;
-    this.intraCache = intraCache;
-    this.diffSummaryCache = diffSummaryCache;
-    this.fileLoaderFactory = fileLoaderFactory;
-    this.intraLoaderFactory = intraLoaderFactory;
-    this.diffSummaryLoaderFactory = diffSummaryLoaderFactory;
-
-    this.computeIntraline =
-        cfg.getBoolean(
-            "cache", INTRA_NAME, "enabled", cfg.getBoolean("cache", "diff", "intraline", true));
-  }
-
-  @Override
-  public PatchList get(PatchListKey key, Project.NameKey project)
-      throws PatchListNotAvailableException {
-    try {
-      PatchList pl = fileCache.get(key, fileLoaderFactory.create(key, project));
-      if (pl instanceof LargeObjectTombstone) {
-        throw new PatchListNotAvailableException(
-            "Error computing " + key + ". Previous attempt failed with LargeObjectException");
-      }
-      if (key.getAlgorithm() == PatchListKey.Algorithm.OPTIMIZED_DIFF) {
-        diffSummaryCache.put(DiffSummaryKey.fromPatchListKey(key), toDiffSummary(pl));
-      }
-      return pl;
-    } catch (ExecutionException e) {
-      PatchListLoader.log.warn("Error computing " + key, e);
-      throw new PatchListNotAvailableException(e);
-    } catch (UncheckedExecutionException e) {
-      if (e.getCause() instanceof LargeObjectException) {
-        // Cache negative result so we don't need to redo expensive computations that would yield
-        // the same result.
-        fileCache.put(key, new LargeObjectTombstone());
-        PatchListLoader.log.warn("Error computing " + key, e);
-        throw new PatchListNotAvailableException(e);
-      }
-      throw e;
-    }
-  }
-
-  @Override
-  public PatchList get(Change change, PatchSet patchSet) throws PatchListNotAvailableException {
-    return get(change, patchSet, null);
-  }
-
-  @Override
-  public ObjectId getOldId(Change change, PatchSet patchSet, Integer parentNum)
-      throws PatchListNotAvailableException {
-    return get(change, patchSet, parentNum).getOldId();
-  }
-
-  private PatchList get(Change change, PatchSet patchSet, Integer parentNum)
-      throws PatchListNotAvailableException {
-    Project.NameKey project = change.getProject();
-    if (patchSet.getRevision() == null) {
-      throw new PatchListNotAvailableException("revision is null for " + patchSet.getId());
-    }
-    ObjectId b = ObjectId.fromString(patchSet.getRevision().get());
-    Whitespace ws = Whitespace.IGNORE_NONE;
-    if (parentNum != null) {
-      return get(PatchListKey.againstParentNum(parentNum, b, ws), project);
-    }
-    return get(PatchListKey.againstDefaultBase(b, ws), project);
-  }
-
-  @Override
-  public IntraLineDiff getIntraLineDiff(IntraLineDiffKey key, IntraLineDiffArgs args) {
-    if (computeIntraline) {
-      try {
-        return intraCache.get(key, intraLoaderFactory.create(key, args));
-      } catch (ExecutionException | LargeObjectException e) {
-        IntraLineLoader.log.warn("Error computing " + key, e);
-        return new IntraLineDiff(IntraLineDiff.Status.ERROR);
-      }
-    }
-    return new IntraLineDiff(IntraLineDiff.Status.DISABLED);
-  }
-
-  @Override
-  public DiffSummary getDiffSummary(DiffSummaryKey key, Project.NameKey project)
-      throws PatchListNotAvailableException {
-    try {
-      return diffSummaryCache.get(key, diffSummaryLoaderFactory.create(key, project));
-    } catch (ExecutionException e) {
-      PatchListLoader.log.warn("Error computing " + key, e);
-      throw new PatchListNotAvailableException(e);
-    } catch (UncheckedExecutionException e) {
-      if (e.getCause() instanceof LargeObjectException) {
-        PatchListLoader.log.warn("Error computing " + key, e);
-        throw new PatchListNotAvailableException(e);
-      }
-      throw e;
-    }
-  }
-
-  /** Used to cache negative results in {@code fileCache}. */
-  @VisibleForTesting
-  public static class LargeObjectTombstone extends PatchList {
-    private static final long serialVersionUID = 1L;
-
-    @VisibleForTesting
-    public LargeObjectTombstone() {
-      // Initialize super class with valid values. We don't care about the inner state, but need to
-      // pass valid values that don't break (de)serialization.
-      super(
-          null, ObjectId.zeroId(), false, ComparisonType.againstAutoMerge(), new PatchListEntry[0]);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
deleted file mode 100644
index 8fce6d3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ /dev/null
@@ -1,605 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-package com.google.gerrit.server.patch;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toSet;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMultimap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Multimap;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.InMemoryInserter;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.patch.EditTransformer.ContextAwareEdit;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-import org.eclipse.jgit.diff.DiffEntry;
-import org.eclipse.jgit.diff.DiffEntry.ChangeType;
-import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.diff.EditList;
-import org.eclipse.jgit.diff.HistogramDiff;
-import org.eclipse.jgit.diff.RawText;
-import org.eclipse.jgit.diff.RawTextComparator;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.FileMode;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
-import org.eclipse.jgit.patch.FileHeader;
-import org.eclipse.jgit.patch.FileHeader.PatchType;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.TreeWalk;
-import org.eclipse.jgit.util.io.DisabledOutputStream;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class PatchListLoader implements Callable<PatchList> {
-  static final Logger log = LoggerFactory.getLogger(PatchListLoader.class);
-
-  public interface Factory {
-    PatchListLoader create(PatchListKey key, Project.NameKey project);
-  }
-
-  private final GitRepositoryManager repoManager;
-  private final PatchListCache patchListCache;
-  private final ThreeWayMergeStrategy mergeStrategy;
-  private final ExecutorService diffExecutor;
-  private final AutoMerger autoMerger;
-  private final PatchListKey key;
-  private final Project.NameKey project;
-  private final long timeoutMillis;
-  private final boolean save;
-
-  @Inject
-  PatchListLoader(
-      GitRepositoryManager mgr,
-      PatchListCache plc,
-      @GerritServerConfig Config cfg,
-      @DiffExecutor ExecutorService de,
-      AutoMerger am,
-      @Assisted PatchListKey k,
-      @Assisted Project.NameKey p) {
-    repoManager = mgr;
-    patchListCache = plc;
-    mergeStrategy = MergeUtil.getMergeStrategy(cfg);
-    diffExecutor = de;
-    autoMerger = am;
-    key = k;
-    project = p;
-    timeoutMillis =
-        ConfigUtil.getTimeUnit(
-            cfg,
-            "cache",
-            PatchListCacheImpl.FILE_NAME,
-            "timeout",
-            TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
-            TimeUnit.MILLISECONDS);
-    save = AutoMerger.cacheAutomerge(cfg);
-  }
-
-  @Override
-  public PatchList call() throws IOException, PatchListNotAvailableException {
-    try (Repository repo = repoManager.openRepository(project);
-        ObjectInserter ins = newInserter(repo);
-        ObjectReader reader = ins.newReader();
-        RevWalk rw = new RevWalk(reader)) {
-      return readPatchList(repo, rw, ins);
-    }
-  }
-
-  private static RawTextComparator comparatorFor(Whitespace ws) {
-    switch (ws) {
-      case IGNORE_ALL:
-        return RawTextComparator.WS_IGNORE_ALL;
-
-      case IGNORE_TRAILING:
-        return RawTextComparator.WS_IGNORE_TRAILING;
-
-      case IGNORE_LEADING_AND_TRAILING:
-        return RawTextComparator.WS_IGNORE_CHANGE;
-
-      case IGNORE_NONE:
-      default:
-        return RawTextComparator.DEFAULT;
-    }
-  }
-
-  private ObjectInserter newInserter(Repository repo) {
-    return save ? repo.newObjectInserter() : new InMemoryInserter(repo);
-  }
-
-  private PatchList readPatchList(Repository repo, RevWalk rw, ObjectInserter ins)
-      throws IOException, PatchListNotAvailableException {
-    ObjectReader reader = rw.getObjectReader();
-    checkArgument(reader.getCreatedFromInserter() == ins);
-    RawTextComparator cmp = comparatorFor(key.getWhitespace());
-    try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-      RevCommit b = rw.parseCommit(key.getNewId());
-      RevObject a = aFor(key, repo, rw, ins, b);
-
-      if (a == null) {
-        // TODO(sop) Remove this case.
-        // This is an octopus merge commit which should be compared against the
-        // auto-merge. However since we don't support computing the auto-merge
-        // for octopus merge commits, we fall back to diffing against the first
-        // parent, even though this wasn't what was requested.
-        //
-        ComparisonType comparisonType = ComparisonType.againstParent(1);
-        PatchListEntry[] entries = new PatchListEntry[2];
-        entries[0] = newCommitMessage(cmp, reader, null, b);
-        entries[1] = newMergeList(cmp, reader, null, b, comparisonType);
-        return new PatchList(a, b, true, comparisonType, entries);
-      }
-
-      ComparisonType comparisonType = getComparisonType(a, b);
-
-      RevCommit aCommit = a instanceof RevCommit ? (RevCommit) a : null;
-      RevTree aTree = rw.parseTree(a);
-      RevTree bTree = b.getTree();
-
-      df.setReader(reader, repo.getConfig());
-      df.setDiffComparator(cmp);
-      df.setDetectRenames(true);
-      List<DiffEntry> diffEntries = df.scan(aTree, bTree);
-
-      Multimap<String, ContextAwareEdit> editsDueToRebasePerFilePath = ImmutableMultimap.of();
-      if (key.getAlgorithm() == PatchListKey.Algorithm.OPTIMIZED_DIFF) {
-        EditsDueToRebaseResult editsDueToRebaseResult =
-            determineEditsDueToRebase(aCommit, b, diffEntries, df, rw);
-        diffEntries = editsDueToRebaseResult.getRelevantOriginalDiffEntries();
-        editsDueToRebasePerFilePath = editsDueToRebaseResult.getEditsDueToRebasePerFilePath();
-      }
-
-      List<PatchListEntry> entries = new ArrayList<>();
-      entries.add(
-          newCommitMessage(
-              cmp, reader, comparisonType.isAgainstParentOrAutoMerge() ? null : aCommit, b));
-      boolean isMerge = b.getParentCount() > 1;
-      if (isMerge) {
-        entries.add(
-            newMergeList(
-                cmp,
-                reader,
-                comparisonType.isAgainstParentOrAutoMerge() ? null : aCommit,
-                b,
-                comparisonType));
-      }
-      for (DiffEntry diffEntry : diffEntries) {
-        Set<ContextAwareEdit> editsDueToRebase =
-            getEditsDueToRebase(editsDueToRebasePerFilePath, diffEntry);
-        Optional<PatchListEntry> patchListEntry =
-            getPatchListEntry(reader, df, diffEntry, aTree, bTree, editsDueToRebase);
-        patchListEntry.ifPresent(entries::add);
-      }
-      return new PatchList(
-          a, b, isMerge, comparisonType, entries.toArray(new PatchListEntry[entries.size()]));
-    }
-  }
-
-  /**
-   * Identifies the edits which are present between {@code commitA} and {@code commitB} due to other
-   * commits in between those two. Edits which cannot be clearly attributed to those other commits
-   * (because they overlap with modifications introduced by {@code commitA} or {@code commitB}) are
-   * omitted from the result. The edits are expressed as differences between {@code treeA} of {@code
-   * commitA} and {@code treeB} of {@code commitB}.
-   *
-   * <p><b>Note:</b> If one of the commits is a merge commit, an empty {@code Multimap} will be
-   * returned.
-   *
-   * <p><b>Warning:</b> This method assumes that commitA and commitB are either a parent and child
-   * commit or represent two patch sets which belong to the same change. No checks are made to
-   * confirm this assumption! Passing arbitrary commits to this method may lead to strange results
-   * or take very long.
-   *
-   * <p>This logic could be expanded to arbitrary commits if the following adjustments were applied:
-   *
-   * <ul>
-   *   <li>If {@code commitA} is an ancestor of {@code commitB} (or the other way around), {@code
-   *       commitA} (or {@code commitB}) is used instead of its parent in this method.
-   *   <li>Special handling for merge commits is added. If only one of them is a merge commit, the
-   *       whole computation has to be done between the single parent and all parents of the merge
-   *       commit. If both of them are merge commits, all combinations of parents have to be
-   *       considered. Alternatively, we could decide to not support this feature for merge commits
-   *       (or just for specific types of merge commits).
-   * </ul>
-   *
-   * @param commitA the commit defining {@code treeA}
-   * @param commitB the commit defining {@code treeB}
-   * @param diffEntries the list of {@code DiffEntries} for the diff between {@code commitA} and
-   *     {@code commitB}
-   * @param df the {@code DiffFormatter}
-   * @param rw the current {@code RevWalk}
-   * @return an aggregated result of the computation
-   * @throws PatchListNotAvailableException if the edits can't be identified
-   * @throws IOException if an error occurred while accessing the repository
-   */
-  private EditsDueToRebaseResult determineEditsDueToRebase(
-      RevCommit commitA,
-      RevCommit commitB,
-      List<DiffEntry> diffEntries,
-      DiffFormatter df,
-      RevWalk rw)
-      throws PatchListNotAvailableException, IOException {
-    if (commitA == null
-        || isRootOrMergeCommit(commitA)
-        || isRootOrMergeCommit(commitB)
-        || areParentChild(commitA, commitB)
-        || haveCommonParent(commitA, commitB)) {
-      return EditsDueToRebaseResult.create(diffEntries, ImmutableMultimap.of());
-    }
-
-    PatchListKey oldKey = PatchListKey.againstDefaultBase(key.getOldId(), key.getWhitespace());
-    PatchList oldPatchList = patchListCache.get(oldKey, project);
-    PatchListKey newKey = PatchListKey.againstDefaultBase(key.getNewId(), key.getWhitespace());
-    PatchList newPatchList = patchListCache.get(newKey, project);
-
-    List<PatchListEntry> oldPatches = oldPatchList.getPatches();
-    List<PatchListEntry> newPatches = newPatchList.getPatches();
-    // TODO(aliceks): Have separate but more limited lists for parents and patch sets (but don't
-    // mess up renames/copies).
-    Set<String> touchedFilePaths = new HashSet<>();
-    for (PatchListEntry patchListEntry : oldPatches) {
-      touchedFilePaths.addAll(getTouchedFilePaths(patchListEntry));
-    }
-    for (PatchListEntry patchListEntry : newPatches) {
-      touchedFilePaths.addAll(getTouchedFilePaths(patchListEntry));
-    }
-
-    List<DiffEntry> relevantDiffEntries =
-        diffEntries
-            .stream()
-            .filter(diffEntry -> isTouched(touchedFilePaths, diffEntry))
-            .collect(toImmutableList());
-
-    RevCommit parentCommitA = commitA.getParent(0);
-    rw.parseBody(parentCommitA);
-    RevCommit parentCommitB = commitB.getParent(0);
-    rw.parseBody(parentCommitB);
-    List<DiffEntry> parentDiffEntries = df.scan(parentCommitA, parentCommitB);
-    // TODO(aliceks): Find a way to not construct a PatchListEntry as it contains many unnecessary
-    // details and we don't fill all of them properly.
-    List<PatchListEntry> parentPatchListEntries =
-        getRelevantPatchListEntries(
-            parentDiffEntries, parentCommitA, parentCommitB, touchedFilePaths, df);
-
-    EditTransformer editTransformer = new EditTransformer(parentPatchListEntries);
-    editTransformer.transformReferencesOfSideA(oldPatches);
-    editTransformer.transformReferencesOfSideB(newPatches);
-    return EditsDueToRebaseResult.create(
-        relevantDiffEntries, editTransformer.getEditsPerFilePath());
-  }
-
-  private static boolean isRootOrMergeCommit(RevCommit commit) {
-    return commit.getParentCount() != 1;
-  }
-
-  private static boolean areParentChild(RevCommit commitA, RevCommit commitB) {
-    return ObjectId.equals(commitA.getParent(0), commitB)
-        || ObjectId.equals(commitB.getParent(0), commitA);
-  }
-
-  private static boolean haveCommonParent(RevCommit commitA, RevCommit commitB) {
-    return ObjectId.equals(commitA.getParent(0), commitB.getParent(0));
-  }
-
-  private static Set<String> getTouchedFilePaths(PatchListEntry patchListEntry) {
-    String oldFilePath = patchListEntry.getOldName();
-    String newFilePath = patchListEntry.getNewName();
-
-    return oldFilePath == null
-        ? ImmutableSet.of(newFilePath)
-        : ImmutableSet.of(oldFilePath, newFilePath);
-  }
-
-  private static boolean isTouched(Set<String> touchedFilePaths, DiffEntry diffEntry) {
-    String oldFilePath = diffEntry.getOldPath();
-    String newFilePath = diffEntry.getNewPath();
-    // One of the above file paths could be /dev/null but we need not explicitly check for this
-    // value as the set of file paths shouldn't contain it.
-    return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
-  }
-
-  private List<PatchListEntry> getRelevantPatchListEntries(
-      List<DiffEntry> parentDiffEntries,
-      RevCommit parentCommitA,
-      RevCommit parentCommitB,
-      Set<String> touchedFilePaths,
-      DiffFormatter diffFormatter)
-      throws IOException {
-    List<PatchListEntry> parentPatchListEntries = new ArrayList<>(parentDiffEntries.size());
-    for (DiffEntry parentDiffEntry : parentDiffEntries) {
-      if (!isTouched(touchedFilePaths, parentDiffEntry)) {
-        continue;
-      }
-      FileHeader fileHeader = toFileHeader(parentCommitB, diffFormatter, parentDiffEntry);
-      // The code which uses this PatchListEntry doesn't care about the last three parameters. As
-      // they are expensive to compute, we use arbitrary values for them.
-      PatchListEntry patchListEntry =
-          newEntry(parentCommitA.getTree(), fileHeader, ImmutableSet.of(), 0, 0);
-      parentPatchListEntries.add(patchListEntry);
-    }
-    return parentPatchListEntries;
-  }
-
-  private static Set<ContextAwareEdit> getEditsDueToRebase(
-      Multimap<String, ContextAwareEdit> editsDueToRebasePerFilePath, DiffEntry diffEntry) {
-    if (editsDueToRebasePerFilePath.isEmpty()) {
-      return ImmutableSet.of();
-    }
-
-    String filePath = diffEntry.getNewPath();
-    if (diffEntry.getChangeType() == ChangeType.DELETE) {
-      filePath = diffEntry.getOldPath();
-    }
-    return ImmutableSet.copyOf(editsDueToRebasePerFilePath.get(filePath));
-  }
-
-  private Optional<PatchListEntry> getPatchListEntry(
-      ObjectReader objectReader,
-      DiffFormatter diffFormatter,
-      DiffEntry diffEntry,
-      RevTree treeA,
-      RevTree treeB,
-      Set<ContextAwareEdit> editsDueToRebase)
-      throws IOException {
-    FileHeader fileHeader = toFileHeader(key.getNewId(), diffFormatter, diffEntry);
-    long oldSize = getFileSize(objectReader, diffEntry.getOldMode(), diffEntry.getOldPath(), treeA);
-    long newSize = getFileSize(objectReader, diffEntry.getNewMode(), diffEntry.getNewPath(), treeB);
-    Set<Edit> contentEditsDueToRebase = getContentEdits(editsDueToRebase);
-    PatchListEntry patchListEntry =
-        newEntry(treeA, fileHeader, contentEditsDueToRebase, newSize, newSize - oldSize);
-    // All edits in a file are due to rebase -> exclude the file from the diff.
-    if (EditTransformer.toEdits(patchListEntry).allMatch(editsDueToRebase::contains)) {
-      return Optional.empty();
-    }
-    return Optional.of(patchListEntry);
-  }
-
-  private static Set<Edit> getContentEdits(Set<ContextAwareEdit> editsDueToRebase) {
-    return editsDueToRebase
-        .stream()
-        .map(ContextAwareEdit::toEdit)
-        .filter(Optional::isPresent)
-        .map(Optional::get)
-        .collect(toSet());
-  }
-
-  private ComparisonType getComparisonType(RevObject a, RevCommit b) {
-    for (int i = 0; i < b.getParentCount(); i++) {
-      if (b.getParent(i).equals(a)) {
-        return ComparisonType.againstParent(i + 1);
-      }
-    }
-
-    if (key.getOldId() == null && b.getParentCount() > 0) {
-      return ComparisonType.againstAutoMerge();
-    }
-
-    return ComparisonType.againstOtherPatchSet();
-  }
-
-  private static long getFileSize(ObjectReader reader, FileMode mode, String path, RevTree t)
-      throws IOException {
-    if (!isBlob(mode)) {
-      return 0;
-    }
-    try (TreeWalk tw = TreeWalk.forPath(reader, path, t)) {
-      return tw != null ? reader.open(tw.getObjectId(0), OBJ_BLOB).getSize() : 0;
-    }
-  }
-
-  private static boolean isBlob(FileMode mode) {
-    int t = mode.getBits() & FileMode.TYPE_MASK;
-    return t == FileMode.TYPE_FILE || t == FileMode.TYPE_SYMLINK;
-  }
-
-  private FileHeader toFileHeader(
-      ObjectId commitB, DiffFormatter diffFormatter, DiffEntry diffEntry) throws IOException {
-
-    Future<FileHeader> result =
-        diffExecutor.submit(
-            () -> {
-              synchronized (diffEntry) {
-                return diffFormatter.toFileHeader(diffEntry);
-              }
-            });
-
-    try {
-      return result.get(timeoutMillis, TimeUnit.MILLISECONDS);
-    } catch (InterruptedException | TimeoutException e) {
-      log.warn(
-          timeoutMillis
-              + " ms timeout reached for Diff loader"
-              + " in project "
-              + project
-              + " on commit "
-              + commitB.name()
-              + " on path "
-              + diffEntry.getNewPath()
-              + " comparing "
-              + diffEntry.getOldId().name()
-              + ".."
-              + diffEntry.getNewId().name());
-      result.cancel(true);
-      synchronized (diffEntry) {
-        return toFileHeaderWithoutMyersDiff(diffFormatter, diffEntry);
-      }
-    } catch (ExecutionException e) {
-      // If there was an error computing the result, carry it
-      // up to the caller so the cache knows this key is invalid.
-      Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
-      throw new IOException(e.getMessage(), e.getCause());
-    }
-  }
-
-  private FileHeader toFileHeaderWithoutMyersDiff(DiffFormatter diffFormatter, DiffEntry diffEntry)
-      throws IOException {
-    HistogramDiff histogramDiff = new HistogramDiff();
-    histogramDiff.setFallbackAlgorithm(null);
-    diffFormatter.setDiffAlgorithm(histogramDiff);
-    return diffFormatter.toFileHeader(diffEntry);
-  }
-
-  private PatchListEntry newCommitMessage(
-      RawTextComparator cmp, ObjectReader reader, RevCommit aCommit, RevCommit bCommit)
-      throws IOException {
-    Text aText = aCommit != null ? Text.forCommit(reader, aCommit) : Text.EMPTY;
-    Text bText = Text.forCommit(reader, bCommit);
-    return createPatchListEntry(cmp, aCommit, aText, bText, Patch.COMMIT_MSG);
-  }
-
-  private PatchListEntry newMergeList(
-      RawTextComparator cmp,
-      ObjectReader reader,
-      RevCommit aCommit,
-      RevCommit bCommit,
-      ComparisonType comparisonType)
-      throws IOException {
-    Text aText = aCommit != null ? Text.forMergeList(comparisonType, reader, aCommit) : Text.EMPTY;
-    Text bText = Text.forMergeList(comparisonType, reader, bCommit);
-    return createPatchListEntry(cmp, aCommit, aText, bText, Patch.MERGE_LIST);
-  }
-
-  private static PatchListEntry createPatchListEntry(
-      RawTextComparator cmp, RevCommit aCommit, Text aText, Text bText, String fileName) {
-    byte[] rawHdr = getRawHeader(aCommit != null, fileName);
-    byte[] aContent = aText.getContent();
-    byte[] bContent = bText.getContent();
-    long size = bContent.length;
-    long sizeDelta = bContent.length - aContent.length;
-    RawText aRawText = new RawText(aContent);
-    RawText bRawText = new RawText(bContent);
-    EditList edits = new HistogramDiff().diff(cmp, aRawText, bRawText);
-    FileHeader fh = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
-    return new PatchListEntry(fh, edits, ImmutableSet.of(), size, sizeDelta);
-  }
-
-  private static byte[] getRawHeader(boolean hasA, String fileName) {
-    StringBuilder hdr = new StringBuilder();
-    hdr.append("diff --git");
-    if (hasA) {
-      hdr.append(" a/").append(fileName);
-    } else {
-      hdr.append(" ").append(FileHeader.DEV_NULL);
-    }
-    hdr.append(" b/").append(fileName);
-    hdr.append("\n");
-
-    if (hasA) {
-      hdr.append("--- a/").append(fileName).append("\n");
-    } else {
-      hdr.append("--- ").append(FileHeader.DEV_NULL).append("\n");
-    }
-    hdr.append("+++ b/").append(fileName).append("\n");
-    return hdr.toString().getBytes(UTF_8);
-  }
-
-  private static PatchListEntry newEntry(
-      RevTree aTree, FileHeader fileHeader, Set<Edit> editsDueToRebase, long size, long sizeDelta) {
-    if (aTree == null // want combined diff
-        || fileHeader.getPatchType() != PatchType.UNIFIED
-        || fileHeader.getHunks().isEmpty()) {
-      return new PatchListEntry(fileHeader, ImmutableList.of(), ImmutableSet.of(), size, sizeDelta);
-    }
-
-    List<Edit> edits = fileHeader.toEditList();
-    if (edits.isEmpty()) {
-      return new PatchListEntry(fileHeader, ImmutableList.of(), ImmutableSet.of(), size, sizeDelta);
-    }
-    return new PatchListEntry(fileHeader, edits, editsDueToRebase, size, sizeDelta);
-  }
-
-  private RevObject aFor(
-      PatchListKey key, Repository repo, RevWalk rw, ObjectInserter ins, RevCommit b)
-      throws IOException {
-    if (key.getOldId() != null) {
-      return rw.parseAny(key.getOldId());
-    }
-
-    switch (b.getParentCount()) {
-      case 0:
-        return rw.parseAny(emptyTree(ins));
-      case 1:
-        {
-          RevCommit r = b.getParent(0);
-          rw.parseBody(r);
-          return r;
-        }
-      case 2:
-        if (key.getParentNum() != null) {
-          RevCommit r = b.getParent(key.getParentNum() - 1);
-          rw.parseBody(r);
-          return r;
-        }
-        return autoMerger.merge(repo, rw, ins, b, mergeStrategy);
-      default:
-        // TODO(sop) handle an octopus merge.
-        return null;
-    }
-  }
-
-  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
-    ObjectId id = ins.insert(Constants.OBJ_TREE, new byte[] {});
-    ins.flush();
-    return id;
-  }
-
-  @AutoValue
-  abstract static class EditsDueToRebaseResult {
-    public static EditsDueToRebaseResult create(
-        List<DiffEntry> relevantDiffEntries,
-        Multimap<String, ContextAwareEdit> editsDueToRebasePerFilePath) {
-      return new AutoValue_PatchListLoader_EditsDueToRebaseResult(
-          relevantDiffEntries, editsDueToRebasePerFilePath);
-    }
-
-    public abstract List<DiffEntry> getRelevantOriginalDiffEntries();
-
-    /** Returns the edits per file path they modify in {@code treeB}. */
-    public abstract Multimap<String, ContextAwareEdit> getEditsDueToRebasePerFilePath();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
deleted file mode 100644
index 384d4fd..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ /dev/null
@@ -1,409 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.patch;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.CommentDetail;
-import com.google.gerrit.common.data.PatchScript;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gerrit.server.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.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.concurrent.Callable;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class PatchScriptFactory implements Callable<PatchScript> {
-  public interface Factory {
-    PatchScriptFactory create(
-        ChangeNotes notes,
-        String fileName,
-        @Assisted("patchSetA") PatchSet.Id patchSetA,
-        @Assisted("patchSetB") PatchSet.Id patchSetB,
-        DiffPreferencesInfo diffPrefs);
-
-    PatchScriptFactory create(
-        ChangeNotes notes,
-        String fileName,
-        int parentNum,
-        PatchSet.Id patchSetB,
-        DiffPreferencesInfo diffPrefs);
-  }
-
-  private static final Logger log = LoggerFactory.getLogger(PatchScriptFactory.class);
-
-  private final GitRepositoryManager repoManager;
-  private final PatchSetUtil psUtil;
-  private final Provider<PatchScriptBuilder> builderFactory;
-  private final PatchListCache patchListCache;
-  private final ReviewDb db;
-  private final CommentsUtil commentsUtil;
-
-  private final String fileName;
-  @Nullable private final PatchSet.Id psa;
-  private final int parentNum;
-  private final PatchSet.Id psb;
-  private final DiffPreferencesInfo diffPrefs;
-  private final ChangeEditUtil editReader;
-  private final Provider<CurrentUser> userProvider;
-  private final ChangeControl.GenericFactory changeControlFactory;
-  private Optional<ChangeEdit> edit;
-
-  private final Change.Id changeId;
-  private boolean loadHistory = true;
-  private boolean loadComments = true;
-
-  private ChangeNotes notes;
-  private ObjectId aId;
-  private ObjectId bId;
-  private List<Patch> history;
-  private CommentDetail comments;
-
-  @AssistedInject
-  PatchScriptFactory(
-      GitRepositoryManager grm,
-      PatchSetUtil psUtil,
-      Provider<PatchScriptBuilder> builderFactory,
-      PatchListCache patchListCache,
-      ReviewDb db,
-      CommentsUtil commentsUtil,
-      ChangeEditUtil editReader,
-      Provider<CurrentUser> userProvider,
-      ChangeControl.GenericFactory changeControlFactory,
-      @Assisted ChangeNotes notes,
-      @Assisted String fileName,
-      @Assisted("patchSetA") @Nullable PatchSet.Id patchSetA,
-      @Assisted("patchSetB") PatchSet.Id patchSetB,
-      @Assisted DiffPreferencesInfo diffPrefs) {
-    this.repoManager = grm;
-    this.psUtil = psUtil;
-    this.builderFactory = builderFactory;
-    this.patchListCache = patchListCache;
-    this.db = db;
-    this.notes = notes;
-    this.commentsUtil = commentsUtil;
-    this.editReader = editReader;
-    this.userProvider = userProvider;
-    this.changeControlFactory = changeControlFactory;
-
-    this.fileName = fileName;
-    this.psa = patchSetA;
-    this.parentNum = -1;
-    this.psb = patchSetB;
-    this.diffPrefs = diffPrefs;
-
-    changeId = patchSetB.getParentKey();
-  }
-
-  @AssistedInject
-  PatchScriptFactory(
-      GitRepositoryManager grm,
-      PatchSetUtil psUtil,
-      Provider<PatchScriptBuilder> builderFactory,
-      PatchListCache patchListCache,
-      ReviewDb db,
-      CommentsUtil commentsUtil,
-      ChangeEditUtil editReader,
-      Provider<CurrentUser> userProvider,
-      ChangeControl.GenericFactory changeControlFactory,
-      @Assisted ChangeNotes notes,
-      @Assisted String fileName,
-      @Assisted int parentNum,
-      @Assisted PatchSet.Id patchSetB,
-      @Assisted DiffPreferencesInfo diffPrefs) {
-    this.repoManager = grm;
-    this.psUtil = psUtil;
-    this.builderFactory = builderFactory;
-    this.patchListCache = patchListCache;
-    this.db = db;
-    this.notes = notes;
-    this.commentsUtil = commentsUtil;
-    this.editReader = editReader;
-    this.userProvider = userProvider;
-    this.changeControlFactory = changeControlFactory;
-
-    this.fileName = fileName;
-    this.psa = null;
-    this.parentNum = parentNum;
-    this.psb = patchSetB;
-    this.diffPrefs = diffPrefs;
-
-    changeId = patchSetB.getParentKey();
-    checkArgument(parentNum >= 0, "parentNum must be >= 0");
-  }
-
-  public void setLoadHistory(boolean load) {
-    loadHistory = load;
-  }
-
-  public void setLoadComments(boolean load) {
-    loadComments = load;
-  }
-
-  @Override
-  public PatchScript call()
-      throws OrmException, LargeObjectException, AuthException, InvalidChangeOperationException,
-          IOException {
-    if (parentNum < 0) {
-      validatePatchSetId(psa);
-    }
-    validatePatchSetId(psb);
-
-    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);
-    }
-
-    try (Repository git = repoManager.openRepository(notes.getProjectName())) {
-      bId = toObjectId(psEntityB);
-      if (parentNum < 0) {
-        aId = psEntityA != null ? toObjectId(psEntityA) : null;
-      }
-
-      try {
-        final PatchList list = listFor(keyFor(diffPrefs.ignoreWhitespace));
-        final PatchScriptBuilder b = newBuilder(list, git);
-        final PatchListEntry content = list.get(fileName);
-
-        loadCommentsAndHistory(
-            ctl, content.getChangeType(), content.getOldName(), content.getNewName());
-
-        return b.toPatchScript(content, comments, history);
-      } catch (PatchListNotAvailableException e) {
-        throw new NoSuchChangeException(changeId, e);
-      } catch (IOException e) {
-        log.error("File content unavailable", e);
-        throw new NoSuchChangeException(changeId, e);
-      } catch (org.eclipse.jgit.errors.LargeObjectException err) {
-        throw new LargeObjectException("File content is too large", err);
-      }
-    } catch (RepositoryNotFoundException e) {
-      log.error("Repository " + notes.getProjectName() + " not found", e);
-      throw new NoSuchChangeException(changeId, e);
-    } catch (IOException e) {
-      log.error("Cannot open repository " + notes.getProjectName(), e);
-      throw new NoSuchChangeException(changeId, e);
-    }
-  }
-
-  private PatchListKey keyFor(Whitespace whitespace) {
-    if (parentNum < 0) {
-      return PatchListKey.againstCommit(aId, bId, whitespace);
-    }
-    return PatchListKey.againstParentNum(parentNum + 1, bId, whitespace);
-  }
-
-  private PatchList listFor(PatchListKey key) throws PatchListNotAvailableException {
-    return patchListCache.get(key, notes.getProjectName());
-  }
-
-  private PatchScriptBuilder newBuilder(PatchList list, Repository git) {
-    final PatchScriptBuilder b = builderFactory.get();
-    b.setRepository(git, notes.getProjectName());
-    b.setChange(notes.getChange());
-    b.setDiffPrefs(diffPrefs);
-    b.setTrees(list.getComparisonType(), list.getOldId(), list.getNewId());
-    return b;
-  }
-
-  private ObjectId toObjectId(PatchSet ps) throws AuthException, IOException, OrmException {
-    if (ps.getId().get() == 0) {
-      return getEditRev();
-    }
-    if (ps.getRevision() == null || ps.getRevision().get() == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-
-    try {
-      return ObjectId.fromString(ps.getRevision().get());
-    } catch (IllegalArgumentException e) {
-      log.error("Patch set " + ps.getId() + " has invalid revision");
-      throw new NoSuchChangeException(changeId, e);
-    }
-  }
-
-  private ObjectId getEditRev() throws AuthException, IOException, OrmException {
-    edit = editReader.byChange(notes);
-    if (edit.isPresent()) {
-      return edit.get().getEditCommit();
-    }
-    throw new NoSuchChangeException(notes.getChangeId());
-  }
-
-  private void validatePatchSetId(PatchSet.Id psId) throws NoSuchChangeException {
-    if (psId == null) { // OK, means use base;
-    } else if (changeId.equals(psId.getParentKey())) { // OK, same change;
-    } else {
-      throw new NoSuchChangeException(changeId);
-    }
-  }
-
-  private void loadCommentsAndHistory(
-      ChangeControl ctl, ChangeType changeType, String oldName, String newName)
-      throws OrmException {
-    Map<Patch.Key, Patch> byKey = new HashMap<>();
-
-    if (loadHistory) {
-      // This seems like a cheap trick. It doesn't properly account for a
-      // file that gets renamed between patch set 1 and patch set 2. We
-      // will wind up packing the wrong Patch object because we didn't do
-      // proper rename detection between the patch sets.
-      //
-      history = new ArrayList<>();
-      for (PatchSet ps : psUtil.byChange(db, notes)) {
-        if (!ctl.isVisible(db)) {
-          continue;
-        }
-        String name = fileName;
-        if (psa != null) {
-          switch (changeType) {
-            case COPIED:
-            case RENAMED:
-              if (ps.getId().equals(psa)) {
-                name = oldName;
-              }
-              break;
-
-            case MODIFIED:
-            case DELETED:
-            case ADDED:
-            case REWRITE:
-              break;
-          }
-        }
-
-        Patch p = new Patch(new Patch.Key(ps.getId(), name));
-        history.add(p);
-        byKey.put(p.getKey(), p);
-      }
-      if (edit != null && edit.isPresent()) {
-        Patch p = new Patch(new Patch.Key(new PatchSet.Id(psb.getParentKey(), 0), fileName));
-        history.add(p);
-        byKey.put(p.getKey(), p);
-      }
-    }
-
-    if (loadComments && edit == null) {
-      comments = new CommentDetail(psa, psb);
-      switch (changeType) {
-        case ADDED:
-        case MODIFIED:
-          loadPublished(byKey, newName);
-          break;
-
-        case DELETED:
-          loadPublished(byKey, newName);
-          break;
-
-        case COPIED:
-        case RENAMED:
-          if (psa != null) {
-            loadPublished(byKey, oldName);
-          }
-          loadPublished(byKey, newName);
-          break;
-
-        case REWRITE:
-          break;
-      }
-
-      CurrentUser user = userProvider.get();
-      if (user.isIdentifiedUser()) {
-        Account.Id me = user.getAccountId();
-        switch (changeType) {
-          case ADDED:
-          case MODIFIED:
-            loadDrafts(byKey, me, newName);
-            break;
-
-          case DELETED:
-            loadDrafts(byKey, me, newName);
-            break;
-
-          case COPIED:
-          case RENAMED:
-            if (psa != null) {
-              loadDrafts(byKey, me, oldName);
-            }
-            loadDrafts(byKey, me, newName);
-            break;
-
-          case REWRITE:
-            break;
-        }
-      }
-    }
-  }
-
-  private void loadPublished(Map<Patch.Key, Patch> byKey, String file) throws OrmException {
-    for (Comment c : commentsUtil.publishedByChangeFile(db, notes, changeId, file)) {
-      comments.include(notes.getChangeId(), c);
-      PatchSet.Id psId = new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
-      Patch.Key pKey = new Patch.Key(psId, c.key.filename);
-      Patch p = byKey.get(pKey);
-      if (p != null) {
-        p.setCommentCount(p.getCommentCount() + 1);
-      }
-    }
-  }
-
-  private void loadDrafts(Map<Patch.Key, Patch> byKey, Account.Id me, String file)
-      throws OrmException {
-    for (Comment c : commentsUtil.draftByChangeFileAuthor(db, notes, file, me)) {
-      comments.include(notes.getChangeId(), c);
-      PatchSet.Id psId = new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
-      Patch.Key pKey = new Patch.Key(psId, c.key.filename);
-      Patch p = byKey.get(pKey);
-      if (p != null) {
-        p.setDraftCount(p.getDraftCount() + 1);
-      }
-    }
-  }
-}
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
deleted file mode 100644
index 6db9357..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ /dev/null
@@ -1,493 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.permissions;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
-import com.google.gerrit.extensions.conditions.BooleanCondition;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.DefaultPermissionBackend;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.ImplementedBy;
-import com.google.inject.Provider;
-import com.google.inject.util.Providers;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.Iterator;
-import java.util.Set;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Checks authorization to perform an action on a project, reference, or change.
- *
- * <p>{@code check} methods should be used during action handlers to verify the user is allowed to
- * exercise the specified permission. For convenience in implementation {@code check} methods throw
- * {@link AuthException} if the permission is denied.
- *
- * <p>{@code test} methods should be used when constructing replies to the client and the result
- * object needs to include a true/false hint indicating the user's ability to exercise the
- * permission. This is suitable for configuring UI button state, but should not be relied upon to
- * guard handlers before making state changes.
- *
- * <p>{@code PermissionBackend} is a singleton for the server, acting as a factory for lightweight
- * request instances. Implementation classes may cache supporting data inside of {@link WithUser},
- * {@link ForProject}, {@link ForRef}, and {@link ForChange} instances, in addition to storing
- * within {@link CurrentUser} using a {@link com.google.gerrit.server.CurrentUser.PropertyKey}.
- * {@link GlobalPermission} caching for {@link WithUser} may best cached inside {@link CurrentUser}
- * as {@link WithUser} instances are frequently created.
- *
- * <p>Example use:
- *
- * <pre>
- *   private final PermissionBackend permissions;
- *   private final Provider<CurrentUser> user;
- *
- *   @Inject
- *   Foo(PermissionBackend permissions, Provider<CurrentUser> user) {
- *     this.permissions = permissions;
- *     this.user = user;
- *   }
- *
- *   public void apply(...) {
- *     permissions.user(user).change(cd).check(ChangePermission.SUBMIT);
- *   }
- *
- *   public UiAction.Description getDescription(ChangeResource rsrc) {
- *     return new UiAction.Description()
- *       .setLabel("Submit")
- *       .setVisible(rsrc.permissions().testCond(ChangePermission.SUBMIT));
- * }
- * </pre>
- */
-@ImplementedBy(DefaultPermissionBackend.class)
-public abstract class PermissionBackend {
-  private static final Logger logger = LoggerFactory.getLogger(PermissionBackend.class);
-
-  /** @return lightweight factory scoped to answer for the specified user. */
-  public abstract WithUser user(CurrentUser user);
-
-  /** @return lightweight factory scoped to answer for the specified user. */
-  public <U extends CurrentUser> WithUser user(Provider<U> user) {
-    return user(checkNotNull(user, "Provider<CurrentUser>").get());
-  }
-
-  /**
-   * Bulk evaluate a collection of {@link PermissionBackendCondition} for view handling.
-   *
-   * <p>Overridden implementations should call {@link PermissionBackendCondition#set(boolean)} to
-   * cache the result of {@code testOrFalse} in the condition for later evaluation. Caching the
-   * result will bypass the usual invocation of {@code testOrFalse}.
-   *
-   * <p>{@code conds} may contain duplicate entries (such as same user, resource, permission
-   * triplet). When duplicates exist, implementations should set a result into all instances to
-   * ensure {@code testOrFalse} does not get invoked during evaluation of the containing condition.
-   *
-   * @param conds conditions to consider.
-   */
-  public void bulkEvaluateTest(Collection<PermissionBackendCondition> conds) {
-    // Do nothing by default. The default implementation of PermissionBackendCondition
-    // delegates to the appropriate testOrFalse method in PermissionBackend.
-  }
-
-  /** PermissionBackend with an optional per-request ReviewDb handle. */
-  public abstract static class AcceptsReviewDb<T> {
-    protected Provider<ReviewDb> db;
-
-    public T database(Provider<ReviewDb> db) {
-      if (db != null) {
-        this.db = db;
-      }
-      return self();
-    }
-
-    public T database(ReviewDb db) {
-      return database(Providers.of(checkNotNull(db, "ReviewDb")));
-    }
-
-    @SuppressWarnings("unchecked")
-    private T self() {
-      return (T) this;
-    }
-  }
-
-  /** PermissionBackend scoped to a specific user. */
-  public abstract static class WithUser extends AcceptsReviewDb<WithUser> {
-    /** @return instance scoped for the specified project. */
-    public abstract ForProject project(Project.NameKey project);
-
-    /** @return instance scoped for the {@code ref}, and its parent project. */
-    public ForRef ref(Branch.NameKey ref) {
-      return project(ref.getParentKey()).ref(ref.get()).database(db);
-    }
-
-    /** @return instance scoped for the change, and its destination ref and project. */
-    public ForChange change(ChangeData cd) {
-      try {
-        return ref(cd.change().getDest()).change(cd);
-      } catch (OrmException e) {
-        return FailedPermissionBackend.change("unavailable", e);
-      }
-    }
-
-    /** @return instance scoped for the change, and its destination ref and project. */
-    public ForChange change(ChangeNotes notes) {
-      return ref(notes.getChange().getDest()).change(notes);
-    }
-
-    /**
-     * @return instance scoped for the change loaded from index, and its destination ref and
-     *     project. This method should only be used when database access is harmful and potentially
-     *     stale data from the index is acceptable.
-     */
-    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return ref(notes.getChange().getDest()).indexedChange(cd, notes);
-    }
-
-    /** Verify scoped user can {@code perm}, throwing if denied. */
-    public abstract void check(GlobalOrPluginPermission perm)
-        throws AuthException, PermissionBackendException;
-
-    /**
-     * Verify scoped user can perform at least one listed permission.
-     *
-     * <p>If {@code any} is empty, the method completes normally and allows the caller to continue.
-     * Since no permissions were supplied to check, its assumed no permissions are necessary to
-     * continue with the caller's operation.
-     *
-     * <p>If the user has at least one of the permissions in {@code any}, the method completes
-     * normally, possibly without checking all listed permissions.
-     *
-     * <p>If {@code any} is non-empty and the user has none, {@link AuthException} is thrown for one
-     * of the failed permissions.
-     *
-     * @param any set of permissions to check.
-     */
-    public void checkAny(Set<GlobalOrPluginPermission> any)
-        throws PermissionBackendException, AuthException {
-      for (Iterator<GlobalOrPluginPermission> itr = any.iterator(); itr.hasNext(); ) {
-        try {
-          check(itr.next());
-          return;
-        } catch (AuthException err) {
-          if (!itr.hasNext()) {
-            throw err;
-          }
-        }
-      }
-    }
-
-    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
-    public abstract <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
-        throws PermissionBackendException;
-
-    public boolean test(GlobalOrPluginPermission perm) throws PermissionBackendException {
-      return test(Collections.singleton(perm)).contains(perm);
-    }
-
-    public boolean testOrFalse(GlobalOrPluginPermission perm) {
-      try {
-        return test(perm);
-      } catch (PermissionBackendException e) {
-        logger.warn("Cannot test " + perm + "; assuming false", e);
-        return false;
-      }
-    }
-
-    public BooleanCondition testCond(GlobalOrPluginPermission perm) {
-      return new PermissionBackendCondition.WithUser(this, perm);
-    }
-
-    /**
-     * Filter a set of projects using {@code check(perm)}.
-     *
-     * @param perm required permission in a project to be included in result.
-     * @param projects candidate set of projects; may be empty.
-     * @return filtered set of {@code projects} where {@code check(perm)} was successful.
-     * @throws PermissionBackendException backend cannot access its internal state.
-     */
-    public Set<Project.NameKey> filter(ProjectPermission perm, Collection<Project.NameKey> projects)
-        throws PermissionBackendException {
-      checkNotNull(perm, "ProjectPermission");
-      checkNotNull(projects, "projects");
-      Set<Project.NameKey> allowed = Sets.newHashSetWithExpectedSize(projects.size());
-      for (Project.NameKey project : projects) {
-        try {
-          project(project).check(perm);
-          allowed.add(project);
-        } catch (AuthException e) {
-          // Do not include this project in allowed.
-        }
-      }
-      return allowed;
-    }
-  }
-
-  /** PermissionBackend scoped to a user and project. */
-  public abstract static class ForProject extends AcceptsReviewDb<ForProject> {
-    /** @return new instance rescoped to same project, but different {@code user}. */
-    public abstract ForProject user(CurrentUser user);
-
-    /** @return instance scoped for {@code ref} in this project. */
-    public abstract ForRef ref(String ref);
-
-    /** @return instance scoped for the change, and its destination ref and project. */
-    public ForChange change(ChangeData cd) {
-      try {
-        return ref(cd.change().getDest().get()).change(cd);
-      } catch (OrmException e) {
-        return FailedPermissionBackend.change("unavailable", e);
-      }
-    }
-
-    /** @return instance scoped for the change, and its destination ref and project. */
-    public ForChange change(ChangeNotes notes) {
-      return ref(notes.getChange().getDest().get()).change(notes);
-    }
-
-    /**
-     * @return instance scoped for the change loaded from index, and its destination ref and
-     *     project. This method should only be used when database access is harmful and potentially
-     *     stale data from the index is acceptable.
-     */
-    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return ref(notes.getChange().getDest().get()).indexedChange(cd, notes);
-    }
-
-    /** Verify scoped user can {@code perm}, throwing if denied. */
-    public abstract void check(ProjectPermission perm)
-        throws AuthException, PermissionBackendException;
-
-    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
-    public abstract Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
-        throws PermissionBackendException;
-
-    public boolean test(ProjectPermission perm) throws PermissionBackendException {
-      return test(EnumSet.of(perm)).contains(perm);
-    }
-
-    public boolean testOrFalse(ProjectPermission perm) {
-      try {
-        return test(perm);
-      } catch (PermissionBackendException e) {
-        logger.warn("Cannot test " + perm + "; assuming false", e);
-        return false;
-      }
-    }
-
-    public BooleanCondition testCond(ProjectPermission perm) {
-      return new PermissionBackendCondition.ForProject(this, perm);
-    }
-  }
-
-  /** PermissionBackend scoped to a user, project and reference. */
-  public abstract static class ForRef extends AcceptsReviewDb<ForRef> {
-    /** @return new instance rescoped to same reference, but different {@code user}. */
-    public abstract ForRef user(CurrentUser user);
-
-    /** @return instance scoped to change. */
-    public abstract ForChange change(ChangeData cd);
-
-    /** @return instance scoped to change. */
-    public abstract ForChange change(ChangeNotes notes);
-
-    /**
-     * @return instance scoped to change loaded from index. This method should only be used when
-     *     database access is harmful and potentially stale data from the index is acceptable.
-     */
-    public abstract ForChange indexedChange(ChangeData cd, ChangeNotes notes);
-
-    /** Verify scoped user can {@code perm}, throwing if denied. */
-    public abstract void check(RefPermission perm) throws AuthException, PermissionBackendException;
-
-    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
-    public abstract Set<RefPermission> test(Collection<RefPermission> permSet)
-        throws PermissionBackendException;
-
-    public boolean test(RefPermission perm) throws PermissionBackendException {
-      return test(EnumSet.of(perm)).contains(perm);
-    }
-
-    /**
-     * Test if user may be able to perform the permission.
-     *
-     * <p>Similar to {@link #test(RefPermission)} except this method returns {@code false} instead
-     * of throwing an exception.
-     *
-     * @param perm the permission to test.
-     * @return true if the user might be able to perform the permission; false if the user may be
-     *     missing the necessary grants or state, or if the backend threw an exception.
-     */
-    public boolean testOrFalse(RefPermission perm) {
-      try {
-        return test(perm);
-      } catch (PermissionBackendException e) {
-        logger.warn("Cannot test " + perm + "; assuming false", e);
-        return false;
-      }
-    }
-
-    public BooleanCondition testCond(RefPermission perm) {
-      return new PermissionBackendCondition.ForRef(this, perm);
-    }
-  }
-
-  /** PermissionBackend scoped to a user, project, reference and change. */
-  public abstract static class ForChange extends AcceptsReviewDb<ForChange> {
-    /** @return user this instance is scoped to. */
-    public abstract CurrentUser user();
-
-    /** @return new instance rescoped to same change, but different {@code user}. */
-    public abstract ForChange user(CurrentUser user);
-
-    /** Verify scoped user can {@code perm}, throwing if denied. */
-    public abstract void check(ChangePermissionOrLabel perm)
-        throws AuthException, PermissionBackendException;
-
-    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
-    public abstract <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
-        throws PermissionBackendException;
-
-    public boolean test(ChangePermissionOrLabel perm) throws PermissionBackendException {
-      return test(Collections.singleton(perm)).contains(perm);
-    }
-
-    /**
-     * Test if user may be able to perform the permission.
-     *
-     * <p>Similar to {@link #test(ChangePermissionOrLabel)} except this method returns {@code false}
-     * instead of throwing an exception.
-     *
-     * @param perm the permission to test.
-     * @return true if the user might be able to perform the permission; false if the user may be
-     *     missing the necessary grants or state, or if the backend threw an exception.
-     */
-    public boolean testOrFalse(ChangePermissionOrLabel perm) {
-      try {
-        return test(perm);
-      } catch (PermissionBackendException e) {
-        logger.warn("Cannot test " + perm + "; assuming false", e);
-        return false;
-      }
-    }
-
-    public BooleanCondition testCond(ChangePermissionOrLabel perm) {
-      return new PermissionBackendCondition.ForChange(this, perm);
-    }
-
-    /**
-     * Test which values of a label the user may be able to set.
-     *
-     * @param label definition of the label to test values of.
-     * @return set containing values the user may be able to use; may be empty if none.
-     * @throws PermissionBackendException if failure consulting backend configuration.
-     */
-    public Set<LabelPermission.WithValue> test(LabelType label) throws PermissionBackendException {
-      return test(valuesOf(checkNotNull(label, "LabelType")));
-    }
-
-    /**
-     * Test which values of a group of labels the user may be able to set.
-     *
-     * @param types definition of the labels to test values of.
-     * @return set containing values the user may be able to use; may be empty if none.
-     * @throws PermissionBackendException if failure consulting backend configuration.
-     */
-    public Set<LabelPermission.WithValue> testLabels(Collection<LabelType> types)
-        throws PermissionBackendException {
-      checkNotNull(types, "LabelType");
-      return test(types.stream().flatMap((t) -> valuesOf(t).stream()).collect(toSet()));
-    }
-
-    private static Set<LabelPermission.WithValue> valuesOf(LabelType label) {
-      return label
-          .getValues()
-          .stream()
-          .map((v) -> new LabelPermission.WithValue(label, v))
-          .collect(toSet());
-    }
-
-    /**
-     * 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
deleted file mode 100644
index d0abf9a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ProjectPermission.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.permissions;
-
-import com.google.gerrit.common.data.Permission;
-import java.util.Locale;
-import java.util.Optional;
-
-public enum ProjectPermission {
-  /**
-   * Can access at least one reference or change within the repository.
-   *
-   * <p>Checking this permission instead of {@link #READ} may require filtering to hide specific
-   * references or changes, which can be expensive.
-   */
-  ACCESS,
-
-  /**
-   * Can read all references in the repository.
-   *
-   * <p>This is a stronger form of {@link #ACCESS} where no filtering is required.
-   */
-  READ(Permission.READ),
-
-  /**
-   * Can read all non-config references in the repository.
-   *
-   * <p>This is the same as {@code READ} but does not check if they user can see refs/meta/config.
-   * Therefore, callers should check {@code READ} before excluding config refs in a short-circuit.
-   */
-  READ_NO_CONFIG,
-
-  /**
-   * Can create at least one reference in the project.
-   *
-   * <p>This project level permission only validates the user may create some type of reference
-   * within the project. The exact reference name must be checked at creation:
-   *
-   * <pre>permissionBackend
-   *    .user(user)
-   *    .project(proj)
-   *    .ref(ref)
-   *    .check(RefPermission.CREATE);
-   * </pre>
-   */
-  CREATE_REF,
-
-  /**
-   * Can create at least one change in the project.
-   *
-   * <p>This project level permission only validates the user may create a change for some branch
-   * within the project. The exact reference name must be checked at creation:
-   *
-   * <pre>permissionBackend
-   *    .user(user)
-   *    .project(proj)
-   *    .ref(ref)
-   *    .check(RefPermission.CREATE_CHANGE);
-   * </pre>
-   */
-  CREATE_CHANGE,
-
-  /** Can run receive pack. */
-  RUN_RECEIVE_PACK,
-
-  /** Can run upload pack. */
-  RUN_UPLOAD_PACK;
-
-  private final String name;
-
-  ProjectPermission() {
-    name = null;
-  }
-
-  ProjectPermission(String name) {
-    this.name = name;
-  }
-
-  /** @return name used in {@code project.config} permissions. */
-  public Optional<String> permissionName() {
-    return Optional.ofNullable(name);
-  }
-
-  public String describeForException() {
-    return toString().toLowerCase(Locale.US).replace('_', ' ');
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java
deleted file mode 100644
index 8b5d8fb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.permissions;
-
-import com.google.gerrit.common.data.Permission;
-import java.util.Locale;
-import java.util.Optional;
-
-public enum RefPermission {
-  READ(Permission.READ),
-  CREATE(Permission.CREATE),
-  DELETE(Permission.DELETE),
-  UPDATE(Permission.PUSH),
-  FORCE_UPDATE,
-
-  FORGE_AUTHOR(Permission.FORGE_AUTHOR),
-  FORGE_COMMITTER(Permission.FORGE_COMMITTER),
-  FORGE_SERVER(Permission.FORGE_SERVER),
-  MERGE,
-  SKIP_VALIDATION,
-
-  /** Create a change to code review a commit. */
-  CREATE_CHANGE,
-
-  /**
-   * Creates changes, then also immediately submits them during {@code push}.
-   *
-   * <p>This is similar to {@link #UPDATE} except it constructs changes first, then submits them
-   * according to the submit strategy, which may include cherry-pick or rebase. By creating changes
-   * for each commit, automatic server side rebase, and post-update review are enabled.
-   */
-  UPDATE_BY_SUBMIT;
-
-  private final String name;
-
-  RefPermission() {
-    name = null;
-  }
-
-  RefPermission(String name) {
-    this.name = name;
-  }
-
-  /** @return name used in {@code project.config} permissions. */
-  public Optional<String> permissionName() {
-    return Optional.ofNullable(name);
-  }
-
-  public String describeForException() {
-    return toString().toLowerCase(Locale.US).replace('_', ' ');
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java
deleted file mode 100644
index a2da580..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.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.PluginInfo;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.plugins.DisablePlugin.Input;
-import com.google.inject.Inject;
-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;
-
-  @Inject
-  DisablePlugin(PluginLoader loader) {
-    this.loader = loader;
-  }
-
-  @Override
-  public PluginInfo apply(PluginResource resource, Input input) throws MethodNotAllowedException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote plugin administration is disabled");
-    }
-    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
deleted file mode 100644
index f29e36b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.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.PluginInfo;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.plugins.EnablePlugin.Input;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.PrintWriter;
-import java.io.StringWriter;
-
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@Singleton
-public class EnablePlugin implements RestModifyView<PluginResource, Input> {
-  public static class Input {}
-
-  private final PluginLoader loader;
-
-  @Inject
-  EnablePlugin(PluginLoader loader) {
-    this.loader = loader;
-  }
-
-  @Override
-  public PluginInfo apply(PluginResource resource, Input input)
-      throws ResourceConflictException, MethodNotAllowedException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote plugin administration is disabled");
-    }
-    String name = resource.getName();
-    try {
-      loader.enablePlugins(ImmutableSet.of(name));
-    } catch (PluginInstallException e) {
-      StringWriter buf = new StringWriter();
-      buf.write(String.format("cannot enable %s\n", name));
-      PrintWriter pw = new PrintWriter(buf);
-      e.printStackTrace(pw);
-      pw.flush();
-      throw new ResourceConflictException(buf.toString());
-    }
-    return ListPlugins.toPluginInfo(loader.get(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
deleted file mode 100644
index 531e9ac..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java
+++ /dev/null
@@ -1,111 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.plugins;
-
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.common.InstallPluginInput;
-import com.google.gerrit.extensions.common.PluginInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.PrintWriter;
-import java.io.StringWriter;
-import java.net.URL;
-import java.util.zip.ZipException;
-
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-public class InstallPlugin implements RestModifyView<TopLevelResource, InstallPluginInput> {
-  private final PluginLoader loader;
-
-  private String name;
-  private boolean created;
-
-  @Inject
-  InstallPlugin(PluginLoader loader) {
-    this.loader = loader;
-  }
-
-  public InstallPlugin setName(String name) {
-    this.name = name;
-    return this;
-  }
-
-  public InstallPlugin setCreated(boolean created) {
-    this.created = created;
-    return this;
-  }
-
-  @Override
-  public Response<PluginInfo> apply(TopLevelResource resource, InstallPluginInput input)
-      throws BadRequestException, MethodNotAllowedException, IOException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote installation is disabled");
-    }
-    try {
-      try (InputStream in = openStream(input)) {
-        String pluginName = loader.installPluginFromStream(name, in);
-        PluginInfo info = ListPlugins.toPluginInfo(loader.get(pluginName));
-        return created ? Response.created(info) : Response.ok(info);
-      }
-    } catch (PluginInstallException e) {
-      StringWriter buf = new StringWriter();
-      buf.write(String.format("cannot install %s", name));
-      if (e.getCause() instanceof ZipException) {
-        buf.write(": ");
-        buf.write(e.getCause().getMessage());
-      } else {
-        buf.write(":\n");
-        PrintWriter pw = new PrintWriter(buf);
-        e.printStackTrace(pw);
-        pw.flush();
-      }
-      throw new BadRequestException(buf.toString());
-    }
-  }
-
-  private InputStream openStream(InstallPluginInput input) throws IOException, BadRequestException {
-    if (input.raw != null) {
-      return input.raw.getInputStream();
-    }
-    try {
-      return new URL(input.url).openStream();
-    } catch (IOException e) {
-      throw new BadRequestException(e.getMessage());
-    }
-  }
-
-  @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-  static class Overwrite implements RestModifyView<PluginResource, InstallPluginInput> {
-    private final Provider<InstallPlugin> install;
-
-    @Inject
-    Overwrite(Provider<InstallPlugin> install) {
-      this.install = install;
-    }
-
-    @Override
-    public Response<PluginInfo> apply(PluginResource resource, InstallPluginInput input)
-        throws BadRequestException, MethodNotAllowedException, IOException {
-      return install.get().setName(resource.getName()).apply(TopLevelResource.INSTANCE, input);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
deleted file mode 100644
index 796acff..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
+++ /dev/null
@@ -1,347 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.plugins;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.collect.Iterables.transform;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.MultimapBuilder;
-import java.io.IOException;
-import java.io.InputStream;
-import java.lang.annotation.Annotation;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Enumeration;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.jar.Attributes;
-import java.util.jar.JarEntry;
-import java.util.jar.JarFile;
-import java.util.jar.Manifest;
-import org.eclipse.jgit.util.IO;
-import org.objectweb.asm.AnnotationVisitor;
-import org.objectweb.asm.Attribute;
-import org.objectweb.asm.ClassReader;
-import org.objectweb.asm.ClassVisitor;
-import org.objectweb.asm.FieldVisitor;
-import org.objectweb.asm.MethodVisitor;
-import org.objectweb.asm.Opcodes;
-import org.objectweb.asm.Type;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class JarScanner implements PluginContentScanner, AutoCloseable {
-  private static final Logger log = LoggerFactory.getLogger(JarScanner.class);
-  private static final int SKIP_ALL =
-      ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES;
-  private final JarFile jarFile;
-
-  public JarScanner(Path src) throws IOException {
-    this.jarFile = new JarFile(src.toFile());
-  }
-
-  @Override
-  public Map<Class<? extends Annotation>, Iterable<ExtensionMetaData>> scan(
-      String pluginName, Iterable<Class<? extends Annotation>> annotations)
-      throws InvalidPluginException {
-    Set<String> descriptors = new HashSet<>();
-    ListMultimap<String, JarScanner.ClassData> rawMap =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-    Map<Class<? extends Annotation>, String> classObjToClassDescr = new HashMap<>();
-
-    for (Class<? extends Annotation> annotation : annotations) {
-      String descriptor = Type.getType(annotation).getDescriptor();
-      descriptors.add(descriptor);
-      classObjToClassDescr.put(annotation, descriptor);
-    }
-
-    Enumeration<JarEntry> e = jarFile.entries();
-    while (e.hasMoreElements()) {
-      JarEntry entry = e.nextElement();
-      if (skip(entry)) {
-        continue;
-      }
-
-      ClassData def = new ClassData(descriptors);
-      try {
-        new ClassReader(read(jarFile, entry)).accept(def, SKIP_ALL);
-      } catch (IOException err) {
-        throw new InvalidPluginException("Cannot auto-register", err);
-      } catch (RuntimeException err) {
-        log.warn(
-            String.format(
-                "Plugin %s has invalid class file %s inside of %s",
-                pluginName, entry.getName(), jarFile.getName()),
-            err);
-        continue;
-      }
-
-      if (!Strings.isNullOrEmpty(def.annotationName)) {
-        if (def.isConcrete()) {
-          rawMap.put(def.annotationName, def);
-        } else {
-          log.warn(
-              String.format(
-                  "Plugin %s tries to @%s(\"%s\") abstract class %s",
-                  pluginName, def.annotationName, def.annotationValue, def.className));
-        }
-      }
-    }
-
-    ImmutableMap.Builder<Class<? extends Annotation>, Iterable<ExtensionMetaData>> result =
-        ImmutableMap.builder();
-
-    for (Class<? extends Annotation> annotoation : annotations) {
-      String descr = classObjToClassDescr.get(annotoation);
-      Collection<ClassData> discoverdData = rawMap.get(descr);
-      Collection<ClassData> values = firstNonNull(discoverdData, Collections.<ClassData>emptySet());
-
-      result.put(
-          annotoation,
-          transform(values, cd -> new ExtensionMetaData(cd.className, cd.annotationValue)));
-    }
-
-    return result.build();
-  }
-
-  public List<String> findSubClassesOf(Class<?> superClass) throws IOException {
-    return findSubClassesOf(superClass.getName());
-  }
-
-  @Override
-  public void close() throws IOException {
-    jarFile.close();
-  }
-
-  private List<String> findSubClassesOf(String superClass) throws IOException {
-    String name = superClass.replace('.', '/');
-
-    List<String> classes = new ArrayList<>();
-    Enumeration<JarEntry> e = jarFile.entries();
-    while (e.hasMoreElements()) {
-      JarEntry entry = e.nextElement();
-      if (skip(entry)) {
-        continue;
-      }
-
-      ClassData def = new ClassData(Collections.<String>emptySet());
-      try {
-        new ClassReader(read(jarFile, entry)).accept(def, SKIP_ALL);
-      } catch (RuntimeException err) {
-        log.warn(
-            String.format("Jar %s has invalid class file %s", jarFile.getName(), entry.getName()),
-            err);
-        continue;
-      }
-
-      if (name.equals(def.superName)) {
-        classes.addAll(findSubClassesOf(def.className));
-        if (def.isConcrete()) {
-          classes.add(def.className);
-        }
-      }
-    }
-
-    return classes;
-  }
-
-  private static boolean skip(JarEntry entry) {
-    if (!entry.getName().endsWith(".class")) {
-      return true; // Avoid non-class resources.
-    }
-    if (entry.getSize() <= 0) {
-      return true; // Directories have 0 size.
-    }
-    if (entry.getSize() >= 1024 * 1024) {
-      return true; // Do not scan huge class files.
-    }
-    return false;
-  }
-
-  private static byte[] read(JarFile jarFile, JarEntry entry) throws IOException {
-    byte[] data = new byte[(int) entry.getSize()];
-    try (InputStream in = jarFile.getInputStream(entry)) {
-      IO.readFully(in, data, 0, data.length);
-    }
-    return data;
-  }
-
-  public static class ClassData extends ClassVisitor {
-    int access;
-    String className;
-    String superName;
-    String annotationName;
-    String annotationValue;
-    String[] interfaces;
-    Collection<String> exports;
-
-    private ClassData(Collection<String> exports) {
-      super(Opcodes.ASM5);
-      this.exports = exports;
-    }
-
-    boolean isConcrete() {
-      return (access & Opcodes.ACC_ABSTRACT) == 0 && (access & Opcodes.ACC_INTERFACE) == 0;
-    }
-
-    @Override
-    public void visit(
-        int version,
-        int access,
-        String name,
-        String signature,
-        String superName,
-        String[] interfaces) {
-      this.className = Type.getObjectType(name).getClassName();
-      this.access = access;
-      this.superName = superName;
-    }
-
-    @Override
-    public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
-      if (!visible) {
-        return null;
-      }
-      Optional<String> found = exports.stream().filter(x -> x.equals(desc)).findAny();
-      if (found.isPresent()) {
-        annotationName = desc;
-        return new AbstractAnnotationVisitor() {
-          @Override
-          public void visit(String name, Object value) {
-            annotationValue = (String) value;
-          }
-        };
-      }
-      return null;
-    }
-
-    @Override
-    public void visitSource(String arg0, String arg1) {}
-
-    @Override
-    public void visitOuterClass(String arg0, String arg1, String arg2) {}
-
-    @Override
-    public MethodVisitor visitMethod(
-        int arg0, String arg1, String arg2, String arg3, String[] arg4) {
-      return null;
-    }
-
-    @Override
-    public void visitInnerClass(String arg0, String arg1, String arg2, int arg3) {}
-
-    @Override
-    public FieldVisitor visitField(int arg0, String arg1, String arg2, String arg3, Object arg4) {
-      return null;
-    }
-
-    @Override
-    public void visitEnd() {}
-
-    @Override
-    public void visitAttribute(Attribute arg0) {}
-  }
-
-  private abstract static class AbstractAnnotationVisitor extends AnnotationVisitor {
-    AbstractAnnotationVisitor() {
-      super(Opcodes.ASM5);
-    }
-
-    @Override
-    public AnnotationVisitor visitAnnotation(String arg0, String arg1) {
-      return null;
-    }
-
-    @Override
-    public AnnotationVisitor visitArray(String arg0) {
-      return null;
-    }
-
-    @Override
-    public void visitEnum(String arg0, String arg1, String arg2) {}
-
-    @Override
-    public void visitEnd() {}
-  }
-
-  @Override
-  public Optional<PluginEntry> getEntry(String resourcePath) throws IOException {
-    JarEntry jarEntry = jarFile.getJarEntry(resourcePath);
-    if (jarEntry == null || jarEntry.getSize() == 0) {
-      return Optional.empty();
-    }
-
-    return Optional.of(resourceOf(jarEntry));
-  }
-
-  @Override
-  public Enumeration<PluginEntry> entries() {
-    return Collections.enumeration(
-        Lists.transform(
-            Collections.list(jarFile.entries()),
-            jarEntry -> {
-              try {
-                return resourceOf(jarEntry);
-              } catch (IOException e) {
-                throw new IllegalArgumentException(
-                    "Cannot convert jar entry " + jarEntry + " to a resource", e);
-              }
-            }));
-  }
-
-  @Override
-  public InputStream getInputStream(PluginEntry entry) throws IOException {
-    return jarFile.getInputStream(jarFile.getEntry(entry.getName()));
-  }
-
-  @Override
-  public Manifest getManifest() throws IOException {
-    return jarFile.getManifest();
-  }
-
-  private PluginEntry resourceOf(JarEntry jarEntry) throws IOException {
-    return new PluginEntry(
-        jarEntry.getName(),
-        jarEntry.getTime(),
-        Optional.of(jarEntry.getSize()),
-        attributesOf(jarEntry));
-  }
-
-  private Map<Object, String> attributesOf(JarEntry jarEntry) throws IOException {
-    Attributes attributes = jarEntry.getAttributes();
-    if (attributes == null) {
-      return Collections.emptyMap();
-    }
-    return Maps.transformEntries(
-        attributes,
-        new Maps.EntryTransformer<Object, Object, String>() {
-          @Override
-          public String transformEntry(Object key, Object value) {
-            return (String) value;
-          }
-        });
-  }
-}
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
deleted file mode 100644
index d972087..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ /dev/null
@@ -1,724 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.plugins;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Joiner;
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.LinkedHashMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.SetMultimap;
-import com.google.common.collect.Sets;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.extensions.systemstatus.ServerInformation;
-import com.google.gerrit.server.PluginUser;
-import com.google.gerrit.server.cache.PersistentCacheFactory;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.plugins.ServerPluginProvider.PluginDescription;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.file.DirectoryStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.AbstractMap;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Queue;
-import java.util.Set;
-import java.util.TreeSet;
-import java.util.concurrent.ConcurrentMap;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.internal.storage.file.FileSnapshot;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class PluginLoader implements LifecycleListener {
-  private static final Logger log = LoggerFactory.getLogger(PluginLoader.class);
-
-  public String getPluginName(Path srcPath) {
-    return MoreObjects.firstNonNull(getGerritPluginName(srcPath), PluginUtil.nameOf(srcPath));
-  }
-
-  private final Path pluginsDir;
-  private final Path dataDir;
-  private final Path tempDir;
-  private final PluginGuiceEnvironment env;
-  private final ServerInformationImpl srvInfoImpl;
-  private final PluginUser.Factory pluginUserFactory;
-  private final ConcurrentMap<String, Plugin> running;
-  private final ConcurrentMap<String, Plugin> disabled;
-  private final Map<String, FileSnapshot> broken;
-  private final Map<Plugin, CleanupHandle> cleanupHandles;
-  private final Queue<Plugin> toCleanup;
-  private final Provider<PluginCleanerTask> cleaner;
-  private final PluginScannerThread scanner;
-  private final Provider<String> urlProvider;
-  private final PersistentCacheFactory persistentCacheFactory;
-  private final boolean remoteAdmin;
-  private final UniversalServerPluginProvider serverPluginFactory;
-
-  @Inject
-  public PluginLoader(
-      SitePaths sitePaths,
-      PluginGuiceEnvironment pe,
-      ServerInformationImpl sii,
-      PluginUser.Factory puf,
-      Provider<PluginCleanerTask> pct,
-      @GerritServerConfig Config cfg,
-      @CanonicalWebUrl Provider<String> provider,
-      PersistentCacheFactory cacheFactory,
-      UniversalServerPluginProvider pluginFactory) {
-    pluginsDir = sitePaths.plugins_dir;
-    dataDir = sitePaths.data_dir;
-    tempDir = sitePaths.tmp_dir;
-    env = pe;
-    srvInfoImpl = sii;
-    pluginUserFactory = puf;
-    running = Maps.newConcurrentMap();
-    disabled = Maps.newConcurrentMap();
-    broken = new HashMap<>();
-    toCleanup = new ArrayDeque<>();
-    cleanupHandles = Maps.newConcurrentMap();
-    cleaner = pct;
-    urlProvider = provider;
-    persistentCacheFactory = cacheFactory;
-    serverPluginFactory = pluginFactory;
-
-    remoteAdmin = cfg.getBoolean("plugins", null, "allowRemoteAdmin", false);
-
-    long checkFrequency =
-        ConfigUtil.getTimeUnit(
-            cfg,
-            "plugins",
-            null,
-            "checkFrequency",
-            TimeUnit.MINUTES.toMillis(1),
-            TimeUnit.MILLISECONDS);
-    if (checkFrequency > 0) {
-      scanner = new PluginScannerThread(this, checkFrequency);
-    } else {
-      scanner = null;
-    }
-  }
-
-  public boolean isRemoteAdminEnabled() {
-    return remoteAdmin;
-  }
-
-  public Plugin get(String name) {
-    Plugin p = running.get(name);
-    if (p != null) {
-      return p;
-    }
-    return disabled.get(name);
-  }
-
-  public Iterable<Plugin> getPlugins(boolean all) {
-    if (!all) {
-      return running.values();
-    }
-    List<Plugin> plugins = new ArrayList<>(running.values());
-    plugins.addAll(disabled.values());
-    return plugins;
-  }
-
-  public String installPluginFromStream(String originalName, InputStream in)
-      throws IOException, PluginInstallException {
-    checkRemoteInstall();
-
-    String fileName = originalName;
-    Path tmp = PluginUtil.asTemp(in, ".next_" + fileName + "_", ".tmp", pluginsDir);
-    String name = MoreObjects.firstNonNull(getGerritPluginName(tmp), PluginUtil.nameOf(fileName));
-    if (!originalName.equals(name)) {
-      log.warn(
-          String.format(
-              "Plugin provides its own name: <%s>, use it instead of the input name: <%s>",
-              name, originalName));
-    }
-
-    String fileExtension = getExtension(fileName);
-    Path dst = pluginsDir.resolve(name + fileExtension);
-    synchronized (this) {
-      Plugin active = running.get(name);
-      if (active != null) {
-        fileName = active.getSrcFile().getFileName().toString();
-        log.info(String.format("Replacing plugin %s", active.getName()));
-        Path old = pluginsDir.resolve(".last_" + fileName);
-        Files.deleteIfExists(old);
-        Files.move(active.getSrcFile(), old);
-      }
-
-      Files.deleteIfExists(pluginsDir.resolve(fileName + ".disabled"));
-      Files.move(tmp, dst);
-      try {
-        Plugin plugin = runPlugin(name, dst, active);
-        if (active == null) {
-          log.info(String.format("Installed plugin %s", plugin.getName()));
-        }
-      } catch (PluginInstallException e) {
-        Files.deleteIfExists(dst);
-        throw e;
-      }
-
-      cleanInBackground();
-    }
-
-    return name;
-  }
-
-  private synchronized void unloadPlugin(Plugin plugin) {
-    persistentCacheFactory.onStop(plugin);
-    String name = plugin.getName();
-    log.info(String.format("Unloading plugin %s, version %s", name, plugin.getVersion()));
-    plugin.stop(env);
-    env.onStopPlugin(plugin);
-    running.remove(name);
-    disabled.remove(name);
-    toCleanup.add(plugin);
-  }
-
-  public void disablePlugins(Set<String> names) {
-    if (!isRemoteAdminEnabled()) {
-      log.warn("Remote plugin administration is disabled, ignoring disablePlugins(" + names + ")");
-      return;
-    }
-
-    synchronized (this) {
-      for (String name : names) {
-        Plugin active = running.get(name);
-        if (active == null) {
-          continue;
-        }
-
-        log.info(String.format("Disabling plugin %s", active.getName()));
-        Path off =
-            active.getSrcFile().resolveSibling(active.getSrcFile().getFileName() + ".disabled");
-        try {
-          Files.move(active.getSrcFile(), off);
-        } catch (IOException e) {
-          log.error("Failed to disable plugin", e);
-          // In theory we could still unload the plugin even if the rename
-          // failed. However, it would be reloaded on the next server startup,
-          // which is probably not what the user expects.
-          continue;
-        }
-
-        unloadPlugin(active);
-        try {
-          FileSnapshot snapshot = FileSnapshot.save(off.toFile());
-          Plugin offPlugin = loadPlugin(name, off, snapshot);
-          disabled.put(name, offPlugin);
-        } catch (Throwable e) {
-          // This shouldn't happen, as the plugin was loaded earlier.
-          log.warn(String.format("Cannot load disabled plugin %s", active.getName()), e.getCause());
-        }
-      }
-      cleanInBackground();
-    }
-  }
-
-  public void enablePlugins(Set<String> names) throws PluginInstallException {
-    if (!isRemoteAdminEnabled()) {
-      log.warn("Remote plugin administration is disabled, ignoring enablePlugins(" + names + ")");
-      return;
-    }
-
-    synchronized (this) {
-      for (String name : names) {
-        Plugin off = disabled.get(name);
-        if (off == null) {
-          continue;
-        }
-
-        log.info(String.format("Enabling plugin %s", name));
-        String n = off.getSrcFile().toFile().getName();
-        if (n.endsWith(".disabled")) {
-          n = n.substring(0, n.lastIndexOf('.'));
-        }
-        Path on = pluginsDir.resolve(n);
-        try {
-          Files.move(off.getSrcFile(), on);
-        } catch (IOException e) {
-          log.error("Failed to move plugin " + name + " into place", e);
-          continue;
-        }
-        disabled.remove(name);
-        runPlugin(name, on, null);
-      }
-      cleanInBackground();
-    }
-  }
-
-  private void removeStalePluginFiles() {
-    DirectoryStream.Filter<Path> filter =
-        new DirectoryStream.Filter<Path>() {
-          @Override
-          public boolean accept(Path entry) throws IOException {
-            return entry.getFileName().toString().startsWith("plugin_");
-          }
-        };
-    try (DirectoryStream<Path> files = Files.newDirectoryStream(tempDir, filter)) {
-      for (Path file : files) {
-        log.info("Removing stale plugin file: " + file.toFile().getName());
-        try {
-          Files.delete(file);
-        } catch (IOException e) {
-          log.error(
-              String.format(
-                  "Failed to remove stale plugin file %s: %s",
-                  file.toFile().getName(), e.getMessage()));
-        }
-      }
-    } catch (IOException e) {
-      log.warn("Unable to discover stale plugin files: " + e.getMessage());
-    }
-  }
-
-  @Override
-  public synchronized void start() {
-    removeStalePluginFiles();
-    Path absolutePath = pluginsDir.toAbsolutePath();
-    if (!Files.exists(absolutePath)) {
-      log.info(absolutePath + " does not exist; creating");
-      try {
-        Files.createDirectories(absolutePath);
-      } catch (IOException e) {
-        log.error(String.format("Failed to create %s: %s", absolutePath, e.getMessage()));
-      }
-    }
-    log.info("Loading plugins from " + absolutePath);
-    srvInfoImpl.state = ServerInformation.State.STARTUP;
-    rescan();
-    srvInfoImpl.state = ServerInformation.State.RUNNING;
-    if (scanner != null) {
-      scanner.start();
-    }
-  }
-
-  @Override
-  public void stop() {
-    if (scanner != null) {
-      scanner.end();
-    }
-    srvInfoImpl.state = ServerInformation.State.SHUTDOWN;
-    synchronized (this) {
-      for (Plugin p : running.values()) {
-        unloadPlugin(p);
-      }
-      running.clear();
-      disabled.clear();
-      broken.clear();
-      if (!toCleanup.isEmpty()) {
-        System.gc();
-        processPendingCleanups();
-      }
-    }
-  }
-
-  public void reload(List<String> names) throws InvalidPluginException, PluginInstallException {
-    synchronized (this) {
-      List<Plugin> reload = Lists.newArrayListWithCapacity(names.size());
-      List<String> bad = Lists.newArrayListWithExpectedSize(4);
-      for (String name : names) {
-        Plugin active = running.get(name);
-        if (active != null) {
-          reload.add(active);
-        } else {
-          bad.add(name);
-        }
-      }
-      if (!bad.isEmpty()) {
-        throw new InvalidPluginException(
-            String.format("Plugin(s) \"%s\" not running", Joiner.on("\", \"").join(bad)));
-      }
-
-      for (Plugin active : reload) {
-        String name = active.getName();
-        try {
-          log.info(String.format("Reloading plugin %s", name));
-          Plugin newPlugin = runPlugin(name, active.getSrcFile(), active);
-          log.info(
-              String.format(
-                  "Reloaded plugin %s, version %s", newPlugin.getName(), newPlugin.getVersion()));
-        } catch (PluginInstallException e) {
-          log.warn(String.format("Cannot reload plugin %s", name), e.getCause());
-          throw e;
-        }
-      }
-
-      cleanInBackground();
-    }
-  }
-
-  public synchronized void rescan() {
-    SetMultimap<String, Path> pluginsFiles = prunePlugins(pluginsDir);
-    if (pluginsFiles.isEmpty()) {
-      return;
-    }
-
-    syncDisabledPlugins(pluginsFiles);
-
-    Map<String, Path> activePlugins = filterDisabled(pluginsFiles);
-    for (Map.Entry<String, Path> entry : jarsFirstSortedPluginsSet(activePlugins)) {
-      String name = entry.getKey();
-      Path path = entry.getValue();
-      String fileName = path.getFileName().toString();
-      if (!isUiPlugin(fileName) && !serverPluginFactory.handles(path)) {
-        log.warn("No Plugin provider was found that handles this file format: {}", fileName);
-        continue;
-      }
-
-      FileSnapshot brokenTime = broken.get(name);
-      if (brokenTime != null && !brokenTime.isModified(path.toFile())) {
-        continue;
-      }
-
-      Plugin active = running.get(name);
-      if (active != null && !active.isModified(path)) {
-        continue;
-      }
-
-      if (active != null) {
-        log.info(String.format("Reloading plugin %s", active.getName()));
-      }
-
-      try {
-        Plugin loadedPlugin = runPlugin(name, path, active);
-        if (!loadedPlugin.isDisabled()) {
-          log.info(
-              String.format(
-                  "%s plugin %s, version %s",
-                  active == null ? "Loaded" : "Reloaded",
-                  loadedPlugin.getName(),
-                  loadedPlugin.getVersion()));
-        }
-      } catch (PluginInstallException e) {
-        log.warn(String.format("Cannot load plugin %s", name), e.getCause());
-      }
-    }
-
-    cleanInBackground();
-  }
-
-  private void addAllEntries(Map<String, Path> from, TreeSet<Entry<String, Path>> to) {
-    Iterator<Entry<String, Path>> it = from.entrySet().iterator();
-    while (it.hasNext()) {
-      Entry<String, Path> entry = it.next();
-      to.add(new AbstractMap.SimpleImmutableEntry<>(entry.getKey(), entry.getValue()));
-    }
-  }
-
-  private TreeSet<Entry<String, Path>> jarsFirstSortedPluginsSet(Map<String, Path> activePlugins) {
-    TreeSet<Entry<String, Path>> sortedPlugins =
-        Sets.newTreeSet(
-            new Comparator<Entry<String, Path>>() {
-              @Override
-              public int compare(Entry<String, Path> e1, Entry<String, Path> e2) {
-                Path n1 = e1.getValue().getFileName();
-                Path n2 = e2.getValue().getFileName();
-                return ComparisonChain.start()
-                    .compareTrueFirst(isJar(n1), isJar(n2))
-                    .compare(n1, n2)
-                    .result();
-              }
-
-              private boolean isJar(Path n1) {
-                return n1.toString().endsWith(".jar");
-              }
-            });
-
-    addAllEntries(activePlugins, sortedPlugins);
-    return sortedPlugins;
-  }
-
-  private void syncDisabledPlugins(SetMultimap<String, Path> jars) {
-    stopRemovedPlugins(jars);
-    dropRemovedDisabledPlugins(jars);
-  }
-
-  private Plugin runPlugin(String name, Path plugin, Plugin oldPlugin)
-      throws PluginInstallException {
-    FileSnapshot snapshot = FileSnapshot.save(plugin.toFile());
-    try {
-      Plugin newPlugin = loadPlugin(name, plugin, snapshot);
-      if (newPlugin.getCleanupHandle() != null) {
-        cleanupHandles.put(newPlugin, newPlugin.getCleanupHandle());
-      }
-      /*
-       * Pluggable plugin provider may have assigned a plugin name that could be
-       * actually different from the initial one assigned during scan. It is
-       * safer then to reassign it.
-       */
-      name = newPlugin.getName();
-      boolean reload = oldPlugin != null && oldPlugin.canReload() && newPlugin.canReload();
-      if (!reload && oldPlugin != null) {
-        unloadPlugin(oldPlugin);
-      }
-      if (!newPlugin.isDisabled()) {
-        newPlugin.start(env);
-      }
-      if (reload) {
-        env.onReloadPlugin(oldPlugin, newPlugin);
-        unloadPlugin(oldPlugin);
-      } else if (!newPlugin.isDisabled()) {
-        env.onStartPlugin(newPlugin);
-      }
-      if (!newPlugin.isDisabled()) {
-        running.put(name, newPlugin);
-      } else {
-        disabled.put(name, newPlugin);
-      }
-      broken.remove(name);
-      return newPlugin;
-    } catch (Throwable err) {
-      broken.put(name, snapshot);
-      throw new PluginInstallException(err);
-    }
-  }
-
-  private void stopRemovedPlugins(SetMultimap<String, Path> jars) {
-    Set<String> unload = Sets.newHashSet(running.keySet());
-    for (Map.Entry<String, Collection<Path>> entry : jars.asMap().entrySet()) {
-      for (Path path : entry.getValue()) {
-        if (!path.getFileName().toString().endsWith(".disabled")) {
-          unload.remove(entry.getKey());
-        }
-      }
-    }
-    for (String name : unload) {
-      unloadPlugin(running.get(name));
-    }
-  }
-
-  private void dropRemovedDisabledPlugins(SetMultimap<String, Path> jars) {
-    Set<String> unload = Sets.newHashSet(disabled.keySet());
-    for (Map.Entry<String, Collection<Path>> entry : jars.asMap().entrySet()) {
-      for (Path path : entry.getValue()) {
-        if (path.getFileName().toString().endsWith(".disabled")) {
-          unload.remove(entry.getKey());
-        }
-      }
-    }
-    for (String name : unload) {
-      disabled.remove(name);
-    }
-  }
-
-  synchronized int processPendingCleanups() {
-    Iterator<Plugin> iterator = toCleanup.iterator();
-    while (iterator.hasNext()) {
-      Plugin plugin = iterator.next();
-      iterator.remove();
-
-      CleanupHandle cleanupHandle = cleanupHandles.remove(plugin);
-      if (cleanupHandle != null) {
-        cleanupHandle.cleanup();
-      }
-    }
-    return toCleanup.size();
-  }
-
-  private void cleanInBackground() {
-    int cnt = toCleanup.size();
-    if (0 < cnt) {
-      cleaner.get().clean(cnt);
-    }
-  }
-
-  private String getExtension(String name) {
-    int ext = name.lastIndexOf('.');
-    return 0 < ext ? name.substring(ext) : "";
-  }
-
-  private Plugin loadPlugin(String name, Path srcPlugin, FileSnapshot snapshot)
-      throws InvalidPluginException {
-    String pluginName = srcPlugin.getFileName().toString();
-    if (isUiPlugin(pluginName)) {
-      return loadJsPlugin(name, srcPlugin, snapshot);
-    } else if (serverPluginFactory.handles(srcPlugin)) {
-      return loadServerPlugin(srcPlugin, snapshot);
-    } else {
-      throw new InvalidPluginException(
-          String.format("Unsupported plugin type: %s", srcPlugin.getFileName()));
-    }
-  }
-
-  private Path getPluginDataDir(String name) {
-    return dataDir.resolve(name);
-  }
-
-  private String getPluginCanonicalWebUrl(String name) {
-    String canonicalWebUrl = urlProvider.get();
-    if (Strings.isNullOrEmpty(canonicalWebUrl)) {
-      return "/plugins/" + name;
-    }
-
-    String url =
-        String.format(
-            "%s/plugins/%s/", CharMatcher.is('/').trimTrailingFrom(canonicalWebUrl), name);
-    return url;
-  }
-
-  private Plugin loadJsPlugin(String name, Path srcJar, FileSnapshot snapshot) {
-    return new JsPlugin(name, srcJar, pluginUserFactory.create(name), snapshot);
-  }
-
-  private ServerPlugin loadServerPlugin(Path scriptFile, FileSnapshot snapshot)
-      throws InvalidPluginException {
-    String name = serverPluginFactory.getPluginName(scriptFile);
-    return serverPluginFactory.get(
-        scriptFile,
-        snapshot,
-        new PluginDescription(
-            pluginUserFactory.create(name),
-            getPluginCanonicalWebUrl(name),
-            getPluginDataDir(name)));
-  }
-
-  // Only one active plugin per plugin name can exist for each plugin name.
-  // Filter out disabled plugins and transform the multimap to a map
-  private Map<String, Path> filterDisabled(SetMultimap<String, Path> pluginPaths) {
-    Map<String, Path> activePlugins = Maps.newHashMapWithExpectedSize(pluginPaths.keys().size());
-    for (String name : pluginPaths.keys()) {
-      for (Path pluginPath : pluginPaths.asMap().get(name)) {
-        if (!pluginPath.getFileName().toString().endsWith(".disabled")) {
-          assert !activePlugins.containsKey(name);
-          activePlugins.put(name, pluginPath);
-        }
-      }
-    }
-    return activePlugins;
-  }
-
-  // Scan the $site_path/plugins directory and fetch all files and directories.
-  // The Key in returned multimap is the plugin name initially assigned from its filename.
-  // Values are the files. Plugins can optionally provide their name in MANIFEST file.
-  // If multiple plugin files provide the same plugin name, then only
-  // the first plugin remains active and all other plugins with the same
-  // name are disabled.
-  //
-  // NOTE: Bear in mind that the plugin name can be reassigned after load by the
-  //       Server plugin provider.
-  public SetMultimap<String, Path> prunePlugins(Path pluginsDir) {
-    List<Path> pluginPaths = scanPathsInPluginsDirectory(pluginsDir);
-    SetMultimap<String, Path> map;
-    map = asMultimap(pluginPaths);
-    for (String plugin : map.keySet()) {
-      Collection<Path> files = map.asMap().get(plugin);
-      if (files.size() == 1) {
-        continue;
-      }
-      // retrieve enabled plugins
-      Iterable<Path> enabled = filterDisabledPlugins(files);
-      // If we have only one (the winner) plugin, nothing to do
-      if (!Iterables.skip(enabled, 1).iterator().hasNext()) {
-        continue;
-      }
-      Path winner = Iterables.getFirst(enabled, null);
-      assert winner != null;
-      // Disable all loser plugins by renaming their file names to
-      // "file.disabled" and replace the disabled files in the multimap.
-      Collection<Path> elementsToRemove = new ArrayList<>();
-      Collection<Path> elementsToAdd = new ArrayList<>();
-      for (Path loser : Iterables.skip(enabled, 1)) {
-        log.warn(
-            String.format(
-                "Plugin <%s> was disabled, because"
-                    + " another plugin <%s>"
-                    + " with the same name <%s> already exists",
-                loser, winner, plugin));
-        Path disabledPlugin = Paths.get(loser + ".disabled");
-        elementsToAdd.add(disabledPlugin);
-        elementsToRemove.add(loser);
-        try {
-          Files.move(loser, disabledPlugin);
-        } catch (IOException e) {
-          log.warn("Failed to fully disable plugin " + loser, e);
-        }
-      }
-      Iterables.removeAll(files, elementsToRemove);
-      Iterables.addAll(files, elementsToAdd);
-    }
-    return map;
-  }
-
-  private List<Path> scanPathsInPluginsDirectory(Path pluginsDir) {
-    try {
-      return PluginUtil.listPlugins(pluginsDir);
-    } catch (IOException e) {
-      log.error("Cannot list " + pluginsDir.toAbsolutePath(), e);
-      return ImmutableList.of();
-    }
-  }
-
-  private Iterable<Path> filterDisabledPlugins(Collection<Path> paths) {
-    return Iterables.filter(paths, p -> !p.getFileName().toString().endsWith(".disabled"));
-  }
-
-  public String getGerritPluginName(Path srcPath) {
-    String fileName = srcPath.getFileName().toString();
-    if (isUiPlugin(fileName)) {
-      return fileName.substring(0, fileName.lastIndexOf('.'));
-    }
-    if (serverPluginFactory.handles(srcPath)) {
-      return serverPluginFactory.getPluginName(srcPath);
-    }
-    return null;
-  }
-
-  private SetMultimap<String, Path> asMultimap(List<Path> plugins) {
-    SetMultimap<String, Path> map = LinkedHashMultimap.create();
-    for (Path srcPath : plugins) {
-      map.put(getPluginName(srcPath), srcPath);
-    }
-    return map;
-  }
-
-  private boolean isUiPlugin(String name) {
-    return isPlugin(name, "js") || isPlugin(name, "html");
-  }
-
-  private boolean isPlugin(String fileName, String ext) {
-    String fullExt = "." + ext;
-    return fileName.endsWith(fullExt) || fileName.endsWith(fullExt + ".disabled");
-  }
-
-  private void checkRemoteInstall() throws PluginInstallException {
-    if (!isRemoteAdminEnabled()) {
-      throw new PluginInstallException("remote installation is disabled");
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginRestApiModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
deleted file mode 100644
index 5f97134..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.plugins;
-
-import static com.google.gerrit.server.plugins.PluginResource.PLUGIN_KIND;
-
-import com.google.gerrit.extensions.api.plugins.Plugins;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.api.plugins.PluginApiImpl;
-import com.google.gerrit.server.api.plugins.PluginsImpl;
-
-public class PluginRestApiModule extends RestApiModule {
-  @Override
-  protected void configure() {
-    bind(PluginsCollection.class);
-    DynamicMap.mapOf(binder(), PLUGIN_KIND);
-    put(PLUGIN_KIND).to(InstallPlugin.Overwrite.class);
-    delete(PLUGIN_KIND).to(DisablePlugin.class);
-    get(PLUGIN_KIND, "status").to(GetStatus.class);
-    post(PLUGIN_KIND, "disable").to(DisablePlugin.class);
-    post(PLUGIN_KIND, "enable").to(EnablePlugin.class);
-    post(PLUGIN_KIND, "reload").to(ReloadPlugin.class);
-    bind(Plugins.class).to(PluginsImpl.class);
-    factory(PluginApiImpl.Factory.class);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java
deleted file mode 100644
index 768aa86..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.plugins;
-
-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.RestCollection;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class PluginsCollection
-    implements RestCollection<TopLevelResource, PluginResource>, AcceptsCreate<TopLevelResource> {
-
-  private final DynamicMap<RestView<PluginResource>> views;
-  private final PluginLoader loader;
-  private final Provider<ListPlugins> list;
-  private final Provider<InstallPlugin> install;
-
-  @Inject
-  PluginsCollection(
-      DynamicMap<RestView<PluginResource>> views,
-      PluginLoader loader,
-      Provider<ListPlugins> list,
-      Provider<InstallPlugin> install) {
-    this.views = views;
-    this.loader = loader;
-    this.list = list;
-    this.install = install;
-  }
-
-  @Override
-  public RestView<TopLevelResource> list() throws ResourceNotFoundException {
-    return list.get();
-  }
-
-  @Override
-  public PluginResource parse(TopLevelResource parent, IdString id)
-      throws ResourceNotFoundException {
-    return parse(id.get());
-  }
-
-  public PluginResource parse(String id) throws ResourceNotFoundException {
-    Plugin p = loader.get(id);
-    if (p == null) {
-      throw new ResourceNotFoundException(id);
-    }
-    return new PluginResource(p);
-  }
-
-  @Override
-  public InstallPlugin create(TopLevelResource parent, IdString id)
-      throws ResourceNotFoundException, MethodNotAllowedException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote installation is disabled");
-    }
-    return install.get().setName(id.get()).setCreated(true);
-  }
-
-  @Override
-  public DynamicMap<RestView<PluginResource>> views() {
-    return views;
-  }
-}
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
deleted file mode 100644
index 7b464bb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPlugin.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.plugins;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.common.PluginInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.plugins.ReloadPlugin.Input;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.PrintWriter;
-import java.io.StringWriter;
-
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@Singleton
-public class ReloadPlugin implements RestModifyView<PluginResource, Input> {
-  public static class Input {}
-
-  private final PluginLoader loader;
-
-  @Inject
-  ReloadPlugin(PluginLoader loader) {
-    this.loader = loader;
-  }
-
-  @Override
-  public PluginInfo apply(PluginResource resource, Input input) throws ResourceConflictException {
-    String name = resource.getName();
-    try {
-      loader.reload(ImmutableList.of(name));
-    } catch (InvalidPluginException e) {
-      throw new ResourceConflictException(e.getMessage());
-    } catch (PluginInstallException e) {
-      StringWriter buf = new StringWriter();
-      buf.write(String.format("cannot reload %s\n", name));
-      PrintWriter pw = new PrintWriter(buf);
-      e.printStackTrace(pw);
-      pw.flush();
-      throw new ResourceConflictException(buf.toString());
-    }
-    return ListPlugins.toPluginInfo(loader.get(name));
-  }
-}
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
deleted file mode 100644
index 278b2af..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/BanCommit.java
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.errors.PermissionDeniedException;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.git.BanCommitResult;
-import com.google.gerrit.server.project.BanCommit.BanResultInfo;
-import com.google.gerrit.server.project.BanCommit.Input;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import org.eclipse.jgit.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;
-    }
-  }
-
-  private final com.google.gerrit.server.git.BanCommit banCommit;
-
-  @Inject
-  BanCommit(RetryHelper retryHelper, com.google.gerrit.server.git.BanCommit banCommit) {
-    super(retryHelper);
-    this.banCommit = banCommit;
-  }
-
-  @Override
-  protected BanResultInfo applyImpl(
-      BatchUpdate.Factory updateFactory, ProjectResource rsrc, Input input)
-      throws RestApiException, UpdateException, IOException {
-    BanResultInfo r = new BanResultInfo();
-    if (input != null && input.commits != null && !input.commits.isEmpty()) {
-      List<ObjectId> commitsToBan = new ArrayList<>(input.commits.size());
-      for (String c : input.commits) {
-        try {
-          commitsToBan.add(ObjectId.fromString(c));
-        } catch (IllegalArgumentException e) {
-          throw new UnprocessableEntityException(e.getMessage());
-        }
-      }
-
-      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());
-      }
-    }
-    return r;
-  }
-
-  private static List<String> transformCommits(List<ObjectId> commits) {
-    if (commits == null || commits.isEmpty()) {
-      return null;
-    }
-    return Lists.transform(commits, ObjectId::getName);
-  }
-
-  public static class BanResultInfo {
-    public List<String> newlyBanned;
-    public List<String> alreadyBanned;
-    public List<String> ignored;
-  }
-}
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
deleted file mode 100644
index 2e81af3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchResource.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.inject.TypeLiteral;
-import org.eclipse.jgit.lib.Ref;
-
-public class BranchResource extends RefResource {
-  public static final TypeLiteral<RestView<BranchResource>> BRANCH_KIND =
-      new TypeLiteral<RestView<BranchResource>>() {};
-
-  private final String refName;
-  private final String revision;
-
-  public BranchResource(ProjectControl control, Ref ref) {
-    super(control);
-    this.refName = ref.getName();
-    this.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
-  }
-
-  public Branch.NameKey getBranchKey() {
-    return new Branch.NameKey(getNameKey(), refName);
-  }
-
-  @Override
-  public String getRef() {
-    return refName;
-  }
-
-  @Override
-  public String getRevision() {
-    return revision;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchesCollection.java
deleted file mode 100644
index a40eabb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchesCollection.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.server.project;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-
-@Singleton
-public class BranchesCollection
-    implements ChildCollection<ProjectResource, BranchResource>, AcceptsCreate<ProjectResource> {
-  private final DynamicMap<RestView<BranchResource>> views;
-  private final Provider<ListBranches> list;
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> user;
-  private final GitRepositoryManager repoManager;
-  private final CreateBranch.Factory createBranchFactory;
-
-  @Inject
-  BranchesCollection(
-      DynamicMap<RestView<BranchResource>> views,
-      Provider<ListBranches> list,
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> user,
-      GitRepositoryManager repoManager,
-      CreateBranch.Factory createBranchFactory) {
-    this.views = views;
-    this.list = list;
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-    this.repoManager = repoManager;
-    this.createBranchFactory = createBranchFactory;
-  }
-
-  @Override
-  public RestView<ProjectResource> list() {
-    return list.get();
-  }
-
-  @Override
-  public BranchResource parse(ProjectResource parent, IdString id)
-      throws ResourceNotFoundException, IOException, PermissionBackendException {
-    Project.NameKey project = parent.getNameKey();
-    try (Repository repo = repoManager.openRepository(project)) {
-      Ref ref = repo.exactRef(RefNames.fullName(id.get()));
-      if (ref == null) {
-        throw new ResourceNotFoundException(id);
-      }
-
-      // ListBranches checks the target of a symbolic reference to determine access
-      // rights on the symbolic reference itself. This check prevents seeing a hidden
-      // branch simply because the symbolic reference name was visible.
-      permissionBackend
-          .user(user)
-          .project(project)
-          .ref(ref.isSymbolic() ? ref.getTarget().getName() : ref.getName())
-          .check(RefPermission.READ);
-      return new BranchResource(parent.getControl(), ref);
-    } catch (AuthException notAllowed) {
-      throw new ResourceNotFoundException(id);
-    } catch (RepositoryNotFoundException noRepo) {
-      throw new ResourceNotFoundException();
-    }
-  }
-
-  @Override
-  public DynamicMap<RestView<BranchResource>> views() {
-    return views;
-  }
-
-  @Override
-  public CreateBranch create(ProjectResource parent, IdString name) {
-    return createBranchFactory.create(name.get());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
deleted file mode 100644
index 2203f77..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
+++ /dev/null
@@ -1,531 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.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.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;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.ChangePermissionOrLabel;
-import com.google.gerrit.server.permissions.LabelPermission;
-import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.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 {
-  @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 {
-    private final ChangeData.Factory changeDataFactory;
-    private final ChangeNotes.Factory notesFactory;
-    private final ApprovalsUtil approvalsUtil;
-    private final PatchSetUtil patchSetUtil;
-
-    @Inject
-    Factory(
-        ChangeData.Factory changeDataFactory,
-        ChangeNotes.Factory notesFactory,
-        ApprovalsUtil approvalsUtil,
-        PatchSetUtil patchSetUtil) {
-      this.changeDataFactory = changeDataFactory;
-      this.notesFactory = notesFactory;
-      this.approvalsUtil = approvalsUtil;
-      this.patchSetUtil = patchSetUtil;
-    }
-
-    ChangeControl create(
-        RefControl refControl, ReviewDb db, Project.NameKey project, Change.Id changeId)
-        throws OrmException {
-      return create(refControl, notesFactory.create(db, project, changeId));
-    }
-
-    ChangeControl create(RefControl refControl, ChangeNotes notes) {
-      return new ChangeControl(changeDataFactory, approvalsUtil, refControl, notes, patchSetUtil);
-    }
-  }
-
-  private final ChangeData.Factory changeDataFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final RefControl refControl;
-  private final ChangeNotes notes;
-  private final PatchSetUtil patchSetUtil;
-
-  ChangeControl(
-      ChangeData.Factory changeDataFactory,
-      ApprovalsUtil approvalsUtil,
-      RefControl refControl,
-      ChangeNotes notes,
-      PatchSetUtil patchSetUtil) {
-    this.changeDataFactory = changeDataFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.refControl = refControl;
-    this.notes = notes;
-    this.patchSetUtil = patchSetUtil;
-  }
-
-  public ChangeControl forUser(CurrentUser who) {
-    if (getUser().equals(who)) {
-      return this;
-    }
-    return new ChangeControl(
-        changeDataFactory, approvalsUtil, getRefControl().forUser(who), notes, patchSetUtil);
-  }
-
-  public RefControl getRefControl() {
-    return refControl;
-  }
-
-  public CurrentUser getUser() {
-    return getRefControl().getUser();
-  }
-
-  public ProjectControl getProjectControl() {
-    return getRefControl().getProjectControl();
-  }
-
-  public Project getProject() {
-    return getProjectControl().getProject();
-  }
-
-  public Change.Id getId() {
-    return notes.getChangeId();
-  }
-
-  public Change getChange() {
-    return notes.getChange();
-  }
-
-  public 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;
-    }
-    return isRefVisible();
-  }
-
-  /** Can the user see this change? Does not account for draft status */
-  public 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
-            || getRefControl().isOwner() // branch owner can abandon
-            || getProjectControl().isOwner() // project owner can abandon
-            || getRefControl().canAbandon() // user can abandon a specific ref
-            || getProjectControl().isAdmin())
-        && !isPatchSetLocked(db);
-  }
-
-  /** Can this user delete this change? */
-  public boolean canDelete(Change.Status status) {
-    switch (status) {
-      case NEW:
-      case ABANDONED:
-        return (isOwner() && getRefControl().canDeleteOwnChanges())
-            || getProjectControl().isAdmin();
-      case MERGED:
-      default:
-        return false;
-    }
-  }
-
-  /** Can this user rebase this change? */
-  private boolean canRebase(ReviewDb db) throws OrmException {
-    return (isOwner() || getRefControl().canSubmit(isOwner()) || getRefControl().canRebase())
-        && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE)
-        && !isPatchSetLocked(db);
-  }
-
-  /** Can this user restore this change? */
-  private boolean canRestore(ReviewDb db) throws OrmException {
-    // Anyone who can abandon the change can restore it, as long as they can create changes.
-    return canAbandon(db) && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
-  }
-
-  /** The range of permitted values associated with a label permission. */
-  private PermissionRange getRange(String permission) {
-    return getRefControl().getRange(permission, isOwner());
-  }
-
-  /** Can this user add a patch set to this change? */
-  private boolean canAddPatchSet(ReviewDb db) throws OrmException {
-    if (!refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE) || isPatchSetLocked(db)) {
-      return false;
-    }
-    if (isOwner()) {
-      return true;
-    }
-    return getRefControl().canAddPatchSet();
-  }
-
-  /** Is the current patch set locked against state changes? */
-  boolean isPatchSetLocked(ReviewDb db) throws OrmException {
-    if (getChange().getStatus() == Change.Status.MERGED) {
-      return false;
-    }
-
-    for (PatchSetApproval ap :
-        approvalsUtil.byPatchSet(
-            db, getNotes(), getUser(), getChange().currentPatchSetId(), null, null)) {
-      LabelType type =
-          getProjectControl()
-              .getProjectState()
-              .getLabelTypes(getNotes(), getUser())
-              .byLabel(ap.getLabel());
-      if (type != null
-          && ap.getValue() == 1
-          && type.getFunctionName().equalsIgnoreCase("PatchSetLock")) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  /** Is this user the owner of the change? */
-  private boolean isOwner() {
-    if (getUser().isIdentifiedUser()) {
-      Account.Id id = getUser().asIdentifiedUser().getAccountId();
-      return id.equals(getChange().getOwner());
-    }
-    return false;
-  }
-
-  /** Is this user assigned to this change? */
-  private boolean isAssignee() {
-    Account.Id currentAssignee = notes.getChange().getAssignee();
-    if (currentAssignee != null && getUser().isIdentifiedUser()) {
-      Account.Id id = getUser().getAccountId();
-      return id.equals(currentAssignee);
-    }
-    return false;
-  }
-
-  /** Is this user a reviewer for the change? */
-  private boolean isReviewer(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
-    if (getUser().isIdentifiedUser()) {
-      Collection<Account.Id> results = changeData(db, cd).reviewers().all();
-      return results.contains(getUser().getAccountId());
-    }
-    return false;
-  }
-
-  /** Can this user edit the topic name? */
-  private boolean canEditTopicName() {
-    if (getChange().getStatus().isOpen()) {
-      return isOwner() // owner (aka creator) of the change can edit topic
-          || getRefControl().isOwner() // branch owner can edit topic
-          || getProjectControl().isOwner() // project owner can edit topic
-          || getRefControl().canEditTopicName() // user can edit topic on a specific ref
-          || getProjectControl().isAdmin();
-    }
-    return getRefControl().canForceEditTopicName();
-  }
-
-  /** Can this user edit the description? */
-  private boolean canEditDescription() {
-    if (getChange().getStatus().isOpen()) {
-      return isOwner() // owner (aka creator) of the change can edit desc
-          || getRefControl().isOwner() // branch owner can edit desc
-          || getProjectControl().isOwner() // project owner can edit desc
-          || getProjectControl().isAdmin();
-    }
-    return false;
-  }
-
-  private boolean canEditAssignee() {
-    return isOwner()
-        || getProjectControl().isOwner()
-        || getRefControl().canEditAssignee()
-        || isAssignee();
-  }
-
-  /** Can this user edit the hashtag name? */
-  private boolean canEditHashtags() {
-    return isOwner() // owner (aka creator) of the change can edit hashtags
-        || getRefControl().isOwner() // branch owner can edit hashtags
-        || getProjectControl().isOwner() // project owner can edit hashtags
-        || getRefControl().canEditHashtags() // user can edit hashtag on a specific ref
-        || getProjectControl().isAdmin();
-  }
-
-  private ChangeData changeData(ReviewDb db, @Nullable ChangeData cd) {
-    return cd != null ? cd : changeDataFactory.create(db, getNotes());
-  }
-
-  private boolean isPrivateVisible(ReviewDb db, ChangeData cd) throws OrmException {
-    return isOwner()
-        || isReviewer(db, cd)
-        || getRefControl().canViewPrivateChanges()
-        || getUser().isInternalUser();
-  }
-
-  ForChange asForChange(@Nullable ChangeData cd, @Nullable Provider<ReviewDb> db) {
-    return new ForChangeImpl(cd, db);
-  }
-
-  private class ForChangeImpl extends ForChange {
-    private ChangeData cd;
-    private Map<String, PermissionRange> labels;
-
-    ForChangeImpl(@Nullable ChangeData cd, @Nullable Provider<ReviewDb> db) {
-      this.cd = cd;
-      this.db = db;
-    }
-
-    private ReviewDb db() {
-      if (db != null) {
-        return db.get();
-      } else if (cd != null) {
-        return cd.db();
-      } else {
-        return null;
-      }
-    }
-
-    private ChangeData changeData() {
-      if (cd == null) {
-        ReviewDb reviewDb = db();
-        checkState(reviewDb != null, "need ReviewDb");
-        cd = changeDataFactory.create(reviewDb, getNotes());
-      }
-      return cd;
-    }
-
-    @Override
-    public CurrentUser user() {
-      return getUser();
-    }
-
-    @Override
-    public ForChange user(CurrentUser user) {
-      return user().equals(user) ? this : forUser(user).asForChange(cd, db);
-    }
-
-    @Override
-    public void check(ChangePermissionOrLabel perm)
-        throws AuthException, PermissionBackendException {
-      if (!can(perm)) {
-        throw new AuthException(perm.describeForException() + " not permitted");
-      }
-    }
-
-    @Override
-    public <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
-        throws PermissionBackendException {
-      Set<T> ok = newSet(permSet);
-      for (T perm : permSet) {
-        if (can(perm)) {
-          ok.add(perm);
-        }
-      }
-      return ok;
-    }
-
-    private boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
-      if (perm instanceof ChangePermission) {
-        return can((ChangePermission) perm);
-      } else if (perm instanceof LabelPermission) {
-        return can((LabelPermission) perm);
-      } else if (perm instanceof LabelPermission.WithValue) {
-        return can((LabelPermission.WithValue) perm);
-      }
-      throw new PermissionBackendException(perm + " unsupported");
-    }
-
-    private boolean can(ChangePermission perm) throws PermissionBackendException {
-      try {
-        switch (perm) {
-          case READ:
-            return isVisible(db(), changeData());
-          case ABANDON:
-            return canAbandon(db());
-          case DELETE:
-            return canDelete(getChange().getStatus());
-          case ADD_PATCH_SET:
-            return canAddPatchSet(db());
-          case EDIT_ASSIGNEE:
-            return canEditAssignee();
-          case EDIT_DESCRIPTION:
-            return canEditDescription();
-          case EDIT_HASHTAGS:
-            return canEditHashtags();
-          case EDIT_TOPIC_NAME:
-            return canEditTopicName();
-          case REBASE:
-            return canRebase(db());
-          case RESTORE:
-            return canRestore(db());
-          case SUBMIT:
-            return getRefControl().canSubmit(isOwner());
-
-          case REMOVE_REVIEWER:
-          case SUBMIT_AS:
-            return getRefControl().canPerform(perm.permissionName().get());
-        }
-      } catch (OrmException e) {
-        throw new PermissionBackendException("unavailable", e);
-      }
-      throw new PermissionBackendException(perm + " unsupported");
-    }
-
-    private boolean can(LabelPermission perm) {
-      return !label(perm.permissionName().get()).isEmpty();
-    }
-
-    private boolean can(LabelPermission.WithValue perm) {
-      PermissionRange r = label(perm.permissionName().get());
-      if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
-        return false;
-      }
-      return r.contains(perm.value());
-    }
-
-    private PermissionRange label(String permission) {
-      if (labels == null) {
-        labels = Maps.newHashMapWithExpectedSize(4);
-      }
-      PermissionRange r = labels.get(permission);
-      if (r == null) {
-        r = getRange(permission);
-        labels.put(permission, r);
-      }
-      return r;
-    }
-  }
-
-  static <T extends ChangePermissionOrLabel> Set<T> newSet(Collection<T> permSet) {
-    if (permSet instanceof EnumSet) {
-      @SuppressWarnings({"unchecked", "rawtypes"})
-      Set<T> s = ((EnumSet) permSet).clone();
-      s.clear();
-      return s;
-    }
-    return Sets.newHashSetWithExpectedSize(permSet.size());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckAccess.java
deleted file mode 100644
index b8d3fbc..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckAccess.java
+++ /dev/null
@@ -1,109 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.api.config.AccessCheckInfo;
-import com.google.gerrit.extensions.api.config.AccessCheckInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class CheckAccess implements RestModifyView<ProjectResource, AccessCheckInput> {
-  private final AccountResolver accountResolver;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final PermissionBackend permissionBackend;
-
-  @Inject
-  CheckAccess(
-      AccountResolver resolver,
-      IdentifiedUser.GenericFactory userFactory,
-      PermissionBackend permissionBackend) {
-    this.accountResolver = resolver;
-    this.userFactory = userFactory;
-    this.permissionBackend = permissionBackend;
-  }
-
-  @Override
-  public AccessCheckInfo apply(ProjectResource rsrc, AccessCheckInput input)
-      throws OrmException, PermissionBackendException, RestApiException, IOException,
-          ConfigInvalidException {
-    permissionBackend.user(rsrc.getUser()).check(GlobalPermission.ADMINISTRATE_SERVER);
-
-    if (input == null) {
-      throw new BadRequestException("input is required");
-    }
-    if (Strings.isNullOrEmpty(input.account)) {
-      throw new BadRequestException("input requires 'account'");
-    }
-
-    Account match = accountResolver.find(input.account);
-    if (match == null) {
-      throw new UnprocessableEntityException(
-          String.format("cannot find account %s", input.account));
-    }
-
-    AccessCheckInfo info = new AccessCheckInfo();
-
-    IdentifiedUser user = userFactory.create(match.getId());
-    try {
-      permissionBackend.user(user).project(rsrc.getNameKey()).check(ProjectPermission.ACCESS);
-    } catch (AuthException | PermissionBackendException e) {
-      info.message =
-          String.format(
-              "user %s (%s) cannot see project %s",
-              user.getNameEmail(), user.getAccount().getId(), rsrc.getName());
-      info.status = HttpServletResponse.SC_FORBIDDEN;
-      return info;
-    }
-
-    if (!Strings.isNullOrEmpty(input.ref)) {
-      try {
-        permissionBackend
-            .user(user)
-            .ref(new Branch.NameKey(rsrc.getNameKey(), input.ref))
-            .check(RefPermission.READ);
-      } catch (AuthException | PermissionBackendException e) {
-        info.status = HttpServletResponse.SC_FORBIDDEN;
-        info.message =
-            String.format(
-                "user %s (%s) cannot see ref %s in project %s",
-                user.getNameEmail(), user.getAccount().getId(), input.ref, rsrc.getName());
-        return info;
-      }
-    }
-
-    info.status = HttpServletResponse.SC_OK;
-    return info;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
deleted file mode 100644
index 1ab2dbd..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
+++ /dev/null
@@ -1,128 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.MergeableInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.InMemoryInserter;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.Merger;
-import org.eclipse.jgit.merge.ResolveMerger;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.kohsuke.args4j.Option;
-
-/** Check the mergeability at current branch for a git object references expression. */
-public class CheckMergeability implements RestReadView<BranchResource> {
-  private String source;
-  private String strategy;
-  private SubmitType submitType;
-
-  @Option(
-    name = "--source",
-    metaVar = "COMMIT",
-    usage =
-        "the source reference to merge, which could be any git object "
-            + "references expression, refer to "
-            + "org.eclipse.jgit.lib.Repository#resolve(String)",
-    required = true
-  )
-  public void setSource(String source) {
-    this.source = source;
-  }
-
-  @Option(
-    name = "--strategy",
-    metaVar = "STRATEGY",
-    usage = "name of the merge strategy, refer to org.eclipse.jgit.merge.MergeStrategy"
-  )
-  public void setStrategy(String strategy) {
-    this.strategy = strategy;
-  }
-
-  private final GitRepositoryManager gitManager;
-  private final CommitsCollection commits;
-
-  @Inject
-  CheckMergeability(
-      GitRepositoryManager gitManager, CommitsCollection commits, @GerritServerConfig Config cfg) {
-    this.gitManager = gitManager;
-    this.commits = commits;
-    this.strategy = MergeUtil.getMergeStrategy(cfg).getName();
-    this.submitType = cfg.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
-  }
-
-  @Override
-  public MergeableInfo apply(BranchResource resource)
-      throws IOException, BadRequestException, ResourceNotFoundException {
-    if (!(submitType.equals(SubmitType.MERGE_ALWAYS)
-        || submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
-      throw new BadRequestException("Submit type: " + submitType + " is not supported");
-    }
-
-    MergeableInfo result = new MergeableInfo();
-    result.submitType = submitType;
-    result.strategy = strategy;
-    try (Repository git = gitManager.openRepository(resource.getNameKey());
-        RevWalk rw = new RevWalk(git);
-        ObjectInserter inserter = new InMemoryInserter(git)) {
-      Merger m = MergeUtil.newMerger(inserter, git.getConfig(), strategy);
-
-      Ref destRef = git.getRefDatabase().exactRef(resource.getRef());
-      if (destRef == null) {
-        throw new ResourceNotFoundException(resource.getRef());
-      }
-
-      RevCommit targetCommit = rw.parseCommit(destRef.getObjectId());
-      RevCommit sourceCommit = MergeUtil.resolveCommit(git, rw, source);
-
-      if (!commits.canRead(resource.getProjectState(), git, sourceCommit)) {
-        throw new BadRequestException("do not have read permission for: " + source);
-      }
-
-      if (rw.isMergedInto(sourceCommit, targetCommit)) {
-        result.mergeable = true;
-        result.commitMerged = true;
-        result.contentMerged = true;
-        return result;
-      }
-
-      if (m.merge(false, targetCommit, sourceCommit)) {
-        result.mergeable = true;
-        result.commitMerged = false;
-        result.contentMerged = m.getResultTreeId().equals(targetCommit.getTree());
-      } else {
-        result.mergeable = false;
-        if (m instanceof ResolveMerger) {
-          result.conflicts = ((ResolveMerger) m).getUnmergedPaths();
-        }
-      }
-    } catch (IllegalArgumentException e) {
-      throw new BadRequestException(e.getMessage());
-    }
-    return result;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java
deleted file mode 100644
index b372b38..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.inject.TypeLiteral;
-
-public class ChildProjectResource implements RestResource {
-  public static final TypeLiteral<RestView<ChildProjectResource>> CHILD_PROJECT_KIND =
-      new TypeLiteral<RestView<ChildProjectResource>>() {};
-
-  private final ProjectResource parent;
-  private final ProjectState child;
-
-  public ChildProjectResource(ProjectResource parent, ProjectState child) {
-    this.parent = parent;
-    this.child = child;
-  }
-
-  public ProjectResource getParent() {
-    return parent;
-  }
-
-  public ProjectState getChild() {
-    return child;
-  }
-
-  public boolean isDirectChild() {
-    ProjectState firstParent = Iterables.getFirst(child.parents(), null);
-    return firstParent != null && parent.getNameKey().equals(firstParent.getNameKey());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java
deleted file mode 100644
index 0cd7d19..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class ChildProjectsCollection
-    implements ChildCollection<ProjectResource, ChildProjectResource> {
-  private final Provider<ListChildProjects> list;
-  private final ProjectsCollection projectsCollection;
-  private final DynamicMap<RestView<ChildProjectResource>> views;
-
-  @Inject
-  ChildProjectsCollection(
-      Provider<ListChildProjects> list,
-      ProjectsCollection projectsCollection,
-      DynamicMap<RestView<ChildProjectResource>> views) {
-    this.list = list;
-    this.projectsCollection = projectsCollection;
-    this.views = views;
-  }
-
-  @Override
-  public ListChildProjects list() throws ResourceNotFoundException, AuthException {
-    return list.get();
-  }
-
-  @Override
-  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()) {
-      if (parent.getNameKey().equals(pp.getProject().getNameKey())) {
-        return new ChildProjectResource(parent, p.getProjectState());
-      }
-    }
-    throw new ResourceNotFoundException(id);
-  }
-
-  @Override
-  public DynamicMap<RestView<ChildProjectResource>> views() {
-    return views;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitIncludedIn.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitIncludedIn.java
deleted file mode 100644
index 5b36916..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitIncludedIn.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.api.changes.IncludedInInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.IncludedIn;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.revwalk.RevCommit;
-
-@Singleton
-class CommitIncludedIn implements RestReadView<CommitResource> {
-  private IncludedIn includedIn;
-
-  @Inject
-  CommitIncludedIn(IncludedIn includedIn) {
-    this.includedIn = includedIn;
-  }
-
-  @Override
-  public IncludedInInfo apply(CommitResource rsrc)
-      throws RestApiException, OrmException, IOException {
-    RevCommit commit = rsrc.getCommit();
-    Project.NameKey project = rsrc.getProjectState().getNameKey();
-    return includedIn.apply(project, commit.getId().getName());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitResource.java
deleted file mode 100644
index 0925524..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitResource.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.inject.TypeLiteral;
-import org.eclipse.jgit.revwalk.RevCommit;
-
-public class CommitResource implements RestResource {
-  public static final TypeLiteral<RestView<CommitResource>> COMMIT_KIND =
-      new TypeLiteral<RestView<CommitResource>>() {};
-
-  private final ProjectResource project;
-  private final RevCommit commit;
-
-  public CommitResource(ProjectResource project, RevCommit commit) {
-    this.project = project;
-    this.commit = commit;
-  }
-
-  public ProjectState getProjectState() {
-    return project.getProjectState();
-  }
-
-  public RevCommit getCommit() {
-    return commit;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java
deleted file mode 100644
index a504a1f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.IncludedInResolver;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.VisibleRefFilter;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class CommitsCollection implements ChildCollection<ProjectResource, CommitResource> {
-  private static final Logger log = LoggerFactory.getLogger(CommitsCollection.class);
-
-  private final DynamicMap<RestView<CommitResource>> views;
-  private final GitRepositoryManager repoManager;
-  private final VisibleRefFilter.Factory refFilter;
-  private final Provider<InternalChangeQuery> queryProvider;
-
-  @Inject
-  public CommitsCollection(
-      DynamicMap<RestView<CommitResource>> views,
-      GitRepositoryManager repoManager,
-      VisibleRefFilter.Factory refFilter,
-      Provider<InternalChangeQuery> queryProvider) {
-    this.views = views;
-    this.repoManager = repoManager;
-    this.refFilter = refFilter;
-    this.queryProvider = queryProvider;
-  }
-
-  @Override
-  public RestView<ProjectResource> list() throws ResourceNotFoundException {
-    throw new ResourceNotFoundException();
-  }
-
-  @Override
-  public CommitResource parse(ProjectResource parent, IdString id)
-      throws ResourceNotFoundException, IOException {
-    ObjectId objectId;
-    try {
-      objectId = ObjectId.fromString(id.get());
-    } catch (IllegalArgumentException e) {
-      throw new ResourceNotFoundException(id);
-    }
-
-    try (Repository repo = repoManager.openRepository(parent.getNameKey());
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(objectId);
-      rw.parseBody(commit);
-      if (!canRead(parent.getProjectState(), repo, commit)) {
-        throw new ResourceNotFoundException(id);
-      }
-      for (int i = 0; i < commit.getParentCount(); i++) {
-        rw.parseBody(rw.parseCommit(commit.getParent(i)));
-      }
-      return new CommitResource(parent, commit);
-    } catch (MissingObjectException | IncorrectObjectTypeException e) {
-      throw new ResourceNotFoundException(id);
-    }
-  }
-
-  @Override
-  public DynamicMap<RestView<CommitResource>> views() {
-    return views;
-  }
-
-  /** @return true if {@code commit} is visible to the caller. */
-  public boolean canRead(ProjectState state, Repository repo, RevCommit commit) {
-    Project.NameKey project = state.getNameKey();
-
-    // Look for changes associated with the commit.
-    try {
-      List<ChangeData> changes =
-          queryProvider.get().enforceVisibility(true).byProjectCommit(project, commit);
-      if (!changes.isEmpty()) {
-        return true;
-      }
-    } catch (OrmException e) {
-      log.error("Cannot look up change for commit " + commit.name() + " in " + project, e);
-    }
-
-    return isReachableFrom(state, repo, commit, repo.getAllRefs());
-  }
-
-  public boolean isReachableFrom(
-      ProjectState state, Repository repo, RevCommit commit, Map<String, Ref> refs) {
-    try (RevWalk rw = new RevWalk(repo)) {
-      refs = refFilter.create(state, repo).filter(refs, true);
-      return IncludedInResolver.includedInAny(repo, rw, commit, refs.values());
-    } catch (IOException e) {
-      log.error(
-          String.format(
-              "Cannot verify permissions to commit object %s in repository %s",
-              commit.name(), state.getNameKey()),
-          e);
-      return false;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
deleted file mode 100644
index eb0dde4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
+++ /dev/null
@@ -1,206 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
-import com.google.gerrit.extensions.api.projects.ConfigInfo;
-import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
-import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicMap.Entry;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.PluginConfig;
-import com.google.gerrit.server.config.PluginConfigFactory;
-import com.google.gerrit.server.config.ProjectConfigEntry;
-import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.TransferConfig;
-import java.util.Arrays;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.TreeMap;
-
-public class ConfigInfoImpl extends ConfigInfo {
-  public ConfigInfoImpl(
-      boolean serverEnableSignedPush,
-      ProjectControl control,
-      TransferConfig config,
-      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
-      PluginConfigFactory cfgFactory,
-      AllProjectsName allProjects,
-      UiActions uiActions,
-      DynamicMap<RestView<ProjectResource>> views) {
-    ProjectState projectState = control.getProjectState();
-    Project p = control.getProject();
-    this.description = Strings.emptyToNull(p.getDescription());
-
-    InheritedBooleanInfo useContributorAgreements = new InheritedBooleanInfo();
-    InheritedBooleanInfo useSignedOffBy = new InheritedBooleanInfo();
-    InheritedBooleanInfo useContentMerge = new InheritedBooleanInfo();
-    InheritedBooleanInfo requireChangeId = new InheritedBooleanInfo();
-    InheritedBooleanInfo createNewChangeForAllNotInTarget = new InheritedBooleanInfo();
-    InheritedBooleanInfo enableSignedPush = new InheritedBooleanInfo();
-    InheritedBooleanInfo requireSignedPush = new InheritedBooleanInfo();
-    InheritedBooleanInfo rejectImplicitMerges = new InheritedBooleanInfo();
-    InheritedBooleanInfo privateByDefault = new InheritedBooleanInfo();
-    InheritedBooleanInfo enableReviewerByEmail = new InheritedBooleanInfo();
-    InheritedBooleanInfo matchAuthorToCommitterDate = new InheritedBooleanInfo();
-
-    useContributorAgreements.value = projectState.isUseContributorAgreements();
-    useSignedOffBy.value = projectState.isUseSignedOffBy();
-    useContentMerge.value = projectState.isUseContentMerge();
-    requireChangeId.value = projectState.isRequireChangeID();
-    createNewChangeForAllNotInTarget.value = projectState.isCreateNewChangeForAllNotInTarget();
-
-    useContributorAgreements.configuredValue = p.getUseContributorAgreements();
-    useSignedOffBy.configuredValue = p.getUseSignedOffBy();
-    useContentMerge.configuredValue = p.getUseContentMerge();
-    requireChangeId.configuredValue = p.getRequireChangeID();
-    createNewChangeForAllNotInTarget.configuredValue = p.getCreateNewChangeForAllNotInTarget();
-    enableSignedPush.configuredValue = p.getEnableSignedPush();
-    requireSignedPush.configuredValue = p.getRequireSignedPush();
-    rejectImplicitMerges.configuredValue = p.getRejectImplicitMerges();
-    privateByDefault.configuredValue = p.getPrivateByDefault();
-    enableReviewerByEmail.configuredValue = p.getEnableReviewerByEmail();
-    matchAuthorToCommitterDate.configuredValue = p.getMatchAuthorToCommitterDate();
-
-    ProjectState parentState = Iterables.getFirst(projectState.parents(), null);
-    if (parentState != null) {
-      useContributorAgreements.inheritedValue = parentState.isUseContributorAgreements();
-      useSignedOffBy.inheritedValue = parentState.isUseSignedOffBy();
-      useContentMerge.inheritedValue = parentState.isUseContentMerge();
-      requireChangeId.inheritedValue = parentState.isRequireChangeID();
-      createNewChangeForAllNotInTarget.inheritedValue =
-          parentState.isCreateNewChangeForAllNotInTarget();
-      enableSignedPush.inheritedValue = projectState.isEnableSignedPush();
-      requireSignedPush.inheritedValue = projectState.isRequireSignedPush();
-      privateByDefault.inheritedValue = projectState.isPrivateByDefault();
-      rejectImplicitMerges.inheritedValue = projectState.isRejectImplicitMerges();
-      enableReviewerByEmail.inheritedValue = projectState.isEnableReviewerByEmail();
-      matchAuthorToCommitterDate.inheritedValue = projectState.isMatchAuthorToCommitterDate();
-    }
-
-    this.useContributorAgreements = useContributorAgreements;
-    this.useSignedOffBy = useSignedOffBy;
-    this.useContentMerge = useContentMerge;
-    this.requireChangeId = requireChangeId;
-    this.rejectImplicitMerges = rejectImplicitMerges;
-    this.createNewChangeForAllNotInTarget = createNewChangeForAllNotInTarget;
-    this.enableReviewerByEmail = enableReviewerByEmail;
-    this.matchAuthorToCommitterDate = matchAuthorToCommitterDate;
-    if (serverEnableSignedPush) {
-      this.enableSignedPush = enableSignedPush;
-      this.requireSignedPush = requireSignedPush;
-    }
-    this.privateByDefault = privateByDefault;
-
-    MaxObjectSizeLimitInfo maxObjectSizeLimit = new MaxObjectSizeLimitInfo();
-    maxObjectSizeLimit.value =
-        config.getEffectiveMaxObjectSizeLimit(projectState) == config.getMaxObjectSizeLimit()
-            ? config.getFormattedMaxObjectSizeLimit()
-            : p.getMaxObjectSizeLimit();
-    maxObjectSizeLimit.configuredValue = p.getMaxObjectSizeLimit();
-    maxObjectSizeLimit.inheritedValue = config.getFormattedMaxObjectSizeLimit();
-    this.maxObjectSizeLimit = maxObjectSizeLimit;
-
-    this.submitType = p.getSubmitType();
-    this.state =
-        p.getState() != com.google.gerrit.extensions.client.ProjectState.ACTIVE
-            ? p.getState()
-            : null;
-
-    this.commentlinks = new LinkedHashMap<>();
-    for (CommentLinkInfo cl : projectState.getCommentLinks()) {
-      this.commentlinks.put(cl.name, cl);
-    }
-
-    pluginConfig =
-        getPluginConfig(control.getProjectState(), pluginConfigEntries, cfgFactory, allProjects);
-
-    actions = new TreeMap<>();
-    for (UiAction.Description d : uiActions.from(views, new ProjectResource(control))) {
-      actions.put(d.getId(), new ActionInfo(d));
-    }
-    this.theme = projectState.getTheme();
-
-    this.extensionPanelNames = projectState.getConfig().getExtensionPanelSections();
-  }
-
-  private Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
-      ProjectState project,
-      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
-      PluginConfigFactory cfgFactory,
-      AllProjectsName allProjects) {
-    TreeMap<String, Map<String, ConfigParameterInfo>> pluginConfig = new TreeMap<>();
-    for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
-      ProjectConfigEntry configEntry = e.getProvider().get();
-      PluginConfig cfg = cfgFactory.getFromProjectConfig(project, e.getPluginName());
-      String configuredValue = cfg.getString(e.getExportName());
-      ConfigParameterInfo p = new ConfigParameterInfo();
-      p.displayName = configEntry.getDisplayName();
-      p.description = configEntry.getDescription();
-      p.warning = configEntry.getWarning(project);
-      p.type = configEntry.getType();
-      p.permittedValues = configEntry.getPermittedValues();
-      p.editable = configEntry.isEditable(project) ? true : null;
-      if (configEntry.isInheritable() && !allProjects.equals(project.getNameKey())) {
-        PluginConfig cfgWithInheritance =
-            cfgFactory.getFromProjectConfigWithInheritance(project, e.getPluginName());
-        p.inheritable = true;
-        p.value =
-            configEntry.onRead(
-                project,
-                cfgWithInheritance.getString(e.getExportName(), configEntry.getDefaultValue()));
-        p.configuredValue = configuredValue;
-        p.inheritedValue = getInheritedValue(project, cfgFactory, e);
-      } else {
-        if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
-          p.values =
-              configEntry.onRead(project, Arrays.asList(cfg.getStringList(e.getExportName())));
-        } else {
-          p.value =
-              configEntry.onRead(
-                  project,
-                  configuredValue != null ? configuredValue : configEntry.getDefaultValue());
-        }
-      }
-      Map<String, ConfigParameterInfo> pc = pluginConfig.get(e.getPluginName());
-      if (pc == null) {
-        pc = new TreeMap<>();
-        pluginConfig.put(e.getPluginName(), pc);
-      }
-      pc.put(e.getExportName(), p);
-    }
-    return !pluginConfig.isEmpty() ? pluginConfig : null;
-  }
-
-  private String getInheritedValue(
-      ProjectState project, PluginConfigFactory cfgFactory, Entry<ProjectConfigEntry> e) {
-    ProjectConfigEntry configEntry = e.getProvider().get();
-    ProjectState parent = Iterables.getFirst(project.parents(), null);
-    String inheritedValue = configEntry.getDefaultValue();
-    if (parent != null) {
-      PluginConfig parentCfgWithInheritance =
-          cfgFactory.getFromProjectConfigWithInheritance(parent, e.getPluginName());
-      inheritedValue =
-          parentCfgWithInheritance.getString(e.getExportName(), configEntry.getDefaultValue());
-    }
-    return inheritedValue;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
deleted file mode 100644
index 0033b12..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.project.ProjectControl.Metrics;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-
-@Singleton
-public class ContributorAgreementsChecker {
-
-  private final String canonicalWebUrl;
-  private final ProjectCache projectCache;
-  private final Metrics metrics;
-
-  @Inject
-  ContributorAgreementsChecker(
-      @CanonicalWebUrl @Nullable String canonicalWebUrl,
-      ProjectCache projectCache,
-      Metrics metrics) {
-    this.canonicalWebUrl = canonicalWebUrl;
-    this.projectCache = projectCache;
-    this.metrics = metrics;
-  }
-
-  /**
-   * Checks if the user has signed a contributor agreement for the project.
-   *
-   * @throws AuthException if the user has not signed a contributor agreement for the project
-   * @throws IOException if project states could not be loaded
-   */
-  public void check(Project.NameKey project, CurrentUser user) throws IOException, AuthException {
-    metrics.claCheckCount.increment();
-
-    ProjectState projectState = projectCache.checkedGet(project);
-    if (projectState == null) {
-      throw new IOException("Can't load All-Projects");
-    }
-
-    if (!projectState.isUseContributorAgreements()) {
-      return;
-    }
-
-    if (!user.isIdentifiedUser()) {
-      throw new AuthException("Must be logged in to verify Contributor Agreement");
-    }
-
-    IdentifiedUser iUser = user.asIdentifiedUser();
-    Collection<ContributorAgreement> contributorAgreements =
-        projectCache.getAllProjects().getConfig().getContributorAgreements();
-    List<UUID> okGroupIds = new ArrayList<>();
-    for (ContributorAgreement ca : contributorAgreements) {
-      List<AccountGroup.UUID> groupIds;
-      groupIds = okGroupIds;
-
-      for (PermissionRule rule : ca.getAccepted()) {
-        if ((rule.getAction() == Action.ALLOW)
-            && (rule.getGroup() != null)
-            && (rule.getGroup().getUUID() != null)) {
-          groupIds.add(new AccountGroup.UUID(rule.getGroup().getUUID().get()));
-        }
-      }
-    }
-
-    if (!iUser.getEffectiveGroups().containsAnyOf(okGroupIds)) {
-      final StringBuilder msg = new StringBuilder();
-      msg.append("A Contributor Agreement must be completed before uploading");
-      if (canonicalWebUrl != null) {
-        msg.append(":\n\n  ");
-        msg.append(canonicalWebUrl);
-        msg.append("#");
-        msg.append(PageLinks.SETTINGS_AGREEMENTS);
-        msg.append("\n");
-      } else {
-        msg.append(".");
-      }
-      throw new AuthException(msg.toString());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateAccessChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateAccessChange.java
deleted file mode 100644
index 326d395..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateAccessChange.java
+++ /dev/null
@@ -1,162 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.errors.InvalidNameException;
-import com.google.gerrit.common.errors.PermissionDeniedException;
-import com.google.gerrit.extensions.api.access.ProjectAccessInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.change.ChangeInserter;
-import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-@Singleton
-public class CreateAccessChange implements RestModifyView<ProjectResource, ProjectAccessInput> {
-  private final PermissionBackend permissionBackend;
-  private final Sequences seq;
-  private final ChangeInserter.Factory changeInserterFactory;
-  private final BatchUpdate.Factory updateFactory;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final Provider<ReviewDb> db;
-  private final SetAccessUtil setAccess;
-  private final ChangeJson.Factory jsonFactory;
-
-  @Inject
-  CreateAccessChange(
-      PermissionBackend permissionBackend,
-      ChangeInserter.Factory changeInserterFactory,
-      BatchUpdate.Factory updateFactory,
-      Sequences seq,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      Provider<ReviewDb> db,
-      SetAccessUtil accessUtil,
-      ChangeJson.Factory jsonFactory) {
-    this.permissionBackend = permissionBackend;
-    this.seq = seq;
-    this.changeInserterFactory = changeInserterFactory;
-    this.updateFactory = updateFactory;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.db = db;
-    this.setAccess = accessUtil;
-    this.jsonFactory = jsonFactory;
-  }
-
-  @Override
-  public Response<ChangeInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
-      throws PermissionBackendException, PermissionDeniedException, IOException,
-          ConfigInvalidException, OrmException, InvalidNameException, UpdateException,
-          RestApiException {
-    MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
-    List<AccessSection> removals = setAccess.getAccessSections(input.remove);
-    List<AccessSection> additions = setAccess.getAccessSections(input.add);
-
-    PermissionBackend.ForRef metaRef =
-        permissionBackend.user(rsrc.getUser()).project(rsrc.getNameKey()).ref(RefNames.REFS_CONFIG);
-    try {
-      metaRef.check(RefPermission.READ);
-    } catch (AuthException denied) {
-      throw new PermissionDeniedException(RefNames.REFS_CONFIG + " not visible");
-    }
-    if (!rsrc.getControl().isOwner()) {
-      try {
-        metaRef.check(RefPermission.CREATE_CHANGE);
-      } catch (AuthException denied) {
-        throw new PermissionDeniedException("cannot create change for " + RefNames.REFS_CONFIG);
-      }
-    }
-
-    Project.NameKey newParentProjectName =
-        input.parent == null ? null : new Project.NameKey(input.parent);
-
-    try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
-      ProjectConfig config = ProjectConfig.read(md);
-
-      setAccess.validateChanges(config, removals, additions);
-      setAccess.applyChanges(config, removals, additions);
-      try {
-        setAccess.setParentName(
-            rsrc.getUser().asIdentifiedUser(),
-            config,
-            rsrc.getNameKey(),
-            newParentProjectName,
-            false);
-      } catch (AuthException e) {
-        throw new IllegalStateException(e);
-      }
-
-      md.setMessage("Review access change");
-      md.setInsertChangeId(true);
-      Change.Id changeId = new Change.Id(seq.nextChangeId());
-      RevCommit commit =
-          config.commitToNewRef(
-              md, new PatchSet.Id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
-
-      try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
-          ObjectReader objReader = objInserter.newReader();
-          RevWalk rw = new RevWalk(objReader);
-          BatchUpdate bu =
-              updateFactory.create(db.get(), rsrc.getNameKey(), rsrc.getUser(), TimeUtil.nowTs())) {
-        bu.setRepository(md.getRepository(), rw, objInserter);
-        ChangeInserter ins = newInserter(changeId, commit);
-        bu.insertChange(ins);
-        bu.execute();
-        return Response.created(jsonFactory.noOptions().format(ins.getChange()));
-      }
-    }
-  }
-
-  // ProjectConfig doesn't currently support fusing into a BatchUpdate.
-  @SuppressWarnings("deprecation")
-  private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
-    return changeInserterFactory
-        .create(changeId, commit, RefNames.REFS_CONFIG)
-        .setMessage(
-            // Same message as in ReceiveCommits.CreateRequest.
-            ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
-        .setValidate(false)
-        .setUpdateRef(false);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
deleted file mode 100644
index 0a648d9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
+++ /dev/null
@@ -1,191 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.api.projects.BranchInfo;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.util.MagicBranch;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class CreateBranch implements RestModifyView<ProjectResource, BranchInput> {
-  private static final Logger log = LoggerFactory.getLogger(CreateBranch.class);
-
-  public interface Factory {
-    CreateBranch create(String ref);
-  }
-
-  private final Provider<IdentifiedUser> identifiedUser;
-  private final PermissionBackend permissionBackend;
-  private final GitRepositoryManager repoManager;
-  private final GitReferenceUpdated referenceUpdated;
-  private final RefValidationHelper refCreationValidator;
-  private final CreateRefControl createRefControl;
-  private String ref;
-
-  @Inject
-  CreateBranch(
-      Provider<IdentifiedUser> identifiedUser,
-      PermissionBackend permissionBackend,
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated referenceUpdated,
-      RefValidationHelper.Factory refHelperFactory,
-      CreateRefControl createRefControl,
-      @Assisted String ref) {
-    this.identifiedUser = identifiedUser;
-    this.permissionBackend = permissionBackend;
-    this.repoManager = repoManager;
-    this.referenceUpdated = referenceUpdated;
-    this.refCreationValidator = refHelperFactory.create(ReceiveCommand.Type.CREATE);
-    this.createRefControl = createRefControl;
-    this.ref = ref;
-  }
-
-  @Override
-  public BranchInfo apply(ProjectResource rsrc, BranchInput input)
-      throws BadRequestException, AuthException, ResourceConflictException, IOException,
-          PermissionBackendException, NoSuchProjectException {
-    if (input == null) {
-      input = new BranchInput();
-    }
-    if (input.ref != null && !ref.equals(input.ref)) {
-      throw new BadRequestException("ref must match URL");
-    }
-    if (input.revision == null) {
-      input.revision = Constants.HEAD;
-    }
-    while (ref.startsWith("/")) {
-      ref = ref.substring(1);
-    }
-    ref = RefNames.fullName(ref);
-    if (!Repository.isValidRefName(ref)) {
-      throw new BadRequestException("invalid branch name \"" + ref + "\"");
-    }
-    if (MagicBranch.isMagicBranch(ref)) {
-      throw new BadRequestException(
-          "not allowed to create branches under \""
-              + MagicBranch.getMagicRefNamePrefix(ref)
-              + "\"");
-    }
-
-    final Branch.NameKey name = new Branch.NameKey(rsrc.getNameKey(), ref);
-    try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
-      ObjectId revid = RefUtil.parseBaseRevision(repo, rsrc.getNameKey(), input.revision);
-      RevWalk rw = RefUtil.verifyConnected(repo, revid);
-      RevObject object = rw.parseAny(revid);
-
-      if (ref.startsWith(Constants.R_HEADS)) {
-        // Ensure that what we start the branch from is a commit. If we
-        // were given a tag, deference to the commit instead.
-        //
-        try {
-          object = rw.parseCommit(object);
-        } catch (IncorrectObjectTypeException notCommit) {
-          throw new BadRequestException("\"" + input.revision + "\" not a commit");
-        }
-      }
-
-      createRefControl.checkCreateRef(identifiedUser, repo, name, object);
-
-      try {
-        final RefUpdate u = repo.updateRef(ref);
-        u.setExpectedOldObjectId(ObjectId.zeroId());
-        u.setNewObjectId(object.copy());
-        u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
-        u.setRefLogMessage("created via REST from " + input.revision, false);
-        refCreationValidator.validateRefOperation(rsrc.getName(), identifiedUser.get(), u);
-        final RefUpdate.Result result = u.update(rw);
-        switch (result) {
-          case FAST_FORWARD:
-          case NEW:
-          case NO_CHANGE:
-            referenceUpdated.fire(
-                name.getParentKey(),
-                u,
-                ReceiveCommand.Type.CREATE,
-                identifiedUser.get().getAccount());
-            break;
-          case LOCK_FAILURE:
-            if (repo.getRefDatabase().exactRef(ref) != null) {
-              throw new ResourceConflictException("branch \"" + ref + "\" already exists");
-            }
-            String refPrefix = RefUtil.getRefPrefix(ref);
-            while (!Constants.R_HEADS.equals(refPrefix)) {
-              if (repo.getRefDatabase().exactRef(refPrefix) != null) {
-                throw new ResourceConflictException(
-                    "Cannot create branch \""
-                        + ref
-                        + "\" since it conflicts with branch \""
-                        + refPrefix
-                        + "\".");
-              }
-              refPrefix = RefUtil.getRefPrefix(refPrefix);
-            }
-            // $FALL-THROUGH$
-          case FORCED:
-          case IO_FAILURE:
-          case NOT_ATTEMPTED:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case RENAMED:
-          case REJECTED_MISSING_OBJECT:
-          case REJECTED_OTHER_REASON:
-          default:
-            {
-              throw new IOException(result.name());
-            }
-        }
-
-        BranchInfo info = new BranchInfo();
-        info.ref = ref;
-        info.revision = revid.getName();
-        info.canDelete =
-            permissionBackend.user(identifiedUser).ref(name).testOrFalse(RefPermission.DELETE)
-                ? true
-                : null;
-        return info;
-      } catch (IOException err) {
-        log.error("Cannot create branch \"" + name + "\"", err);
-        throw err;
-      }
-    } catch (RefUtil.InvalidRevisionException e) {
-      throw new BadRequestException("invalid revision \"" + input.revision + "\"");
-    }
-  }
-}
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
deleted file mode 100644
index 9b355f1..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
+++ /dev/null
@@ -1,394 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.ProjectUtil;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.api.projects.ConfigInput;
-import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.extensions.events.NewProjectCreatedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.ProjectOwnerGroupsProvider;
-import com.google.gerrit.server.config.RepositoryConfig;
-import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.RepositoryCaseMismatchException;
-import com.google.gerrit.server.group.GroupsCollection;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.validators.ProjectCreationValidationListener;
-import com.google.gerrit.server.validators.ValidationException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@RequiresCapability(GlobalCapability.CREATE_PROJECT)
-public class CreateProject implements RestModifyView<TopLevelResource, ProjectInput> {
-  public interface Factory {
-    CreateProject create(String name);
-  }
-
-  private static final Logger log = LoggerFactory.getLogger(CreateProject.class);
-
-  private final Provider<ProjectsCollection> projectsCollection;
-  private final Provider<GroupsCollection> groupsCollection;
-  private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
-  private final ProjectJson json;
-  private final GitRepositoryManager repoManager;
-  private final DynamicSet<NewProjectCreatedListener> createdListeners;
-  private final ProjectCache projectCache;
-  private final GroupBackend groupBackend;
-  private final ProjectOwnerGroupsProvider.Factory projectOwnerGroups;
-  private final MetaDataUpdate.User metaDataUpdateFactory;
-  private final GitReferenceUpdated referenceUpdated;
-  private final RepositoryConfig repositoryCfg;
-  private final PersonIdent serverIdent;
-  private final Provider<IdentifiedUser> identifiedUser;
-  private final Provider<PutConfig> putConfig;
-  private final AllProjectsName allProjects;
-  private final String name;
-
-  @Inject
-  CreateProject(
-      Provider<ProjectsCollection> projectsCollection,
-      Provider<GroupsCollection> groupsCollection,
-      ProjectJson json,
-      DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
-      GitRepositoryManager repoManager,
-      DynamicSet<NewProjectCreatedListener> createdListeners,
-      ProjectCache projectCache,
-      GroupBackend groupBackend,
-      ProjectOwnerGroupsProvider.Factory projectOwnerGroups,
-      MetaDataUpdate.User metaDataUpdateFactory,
-      GitReferenceUpdated referenceUpdated,
-      RepositoryConfig repositoryCfg,
-      @GerritPersonIdent PersonIdent serverIdent,
-      Provider<IdentifiedUser> identifiedUser,
-      Provider<PutConfig> putConfig,
-      AllProjectsName allProjects,
-      @Assisted String name) {
-    this.projectsCollection = projectsCollection;
-    this.groupsCollection = groupsCollection;
-    this.projectCreationValidationListeners = projectCreationValidationListeners;
-    this.json = json;
-    this.repoManager = repoManager;
-    this.createdListeners = createdListeners;
-    this.projectCache = projectCache;
-    this.groupBackend = groupBackend;
-    this.projectOwnerGroups = projectOwnerGroups;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.referenceUpdated = referenceUpdated;
-    this.repositoryCfg = repositoryCfg;
-    this.serverIdent = serverIdent;
-    this.identifiedUser = identifiedUser;
-    this.putConfig = putConfig;
-    this.allProjects = allProjects;
-    this.name = name;
-  }
-
-  @Override
-  public Response<ProjectInfo> apply(TopLevelResource resource, ProjectInput input)
-      throws BadRequestException, UnprocessableEntityException, ResourceConflictException,
-          ResourceNotFoundException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    if (input == null) {
-      input = new ProjectInput();
-    }
-    if (input.name != null && !name.equals(input.name)) {
-      throw new BadRequestException("name must match URL");
-    }
-
-    CreateProjectArgs args = new CreateProjectArgs();
-    args.setProjectName(ProjectUtil.stripGitSuffix(name));
-
-    String parentName =
-        MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
-    args.newParent = projectsCollection.get().parse(parentName, false).getNameKey();
-    args.createEmptyCommit = input.createEmptyCommit;
-    args.permissionsOnly = input.permissionsOnly;
-    args.projectDescription = Strings.emptyToNull(input.description);
-    args.submitType = input.submitType;
-    args.branch = normalizeBranchNames(input.branches);
-    if (input.owners == null || input.owners.isEmpty()) {
-      args.ownerIds = new ArrayList<>(projectOwnerGroups.create(args.getProject()).get());
-    } else {
-      args.ownerIds = Lists.newArrayListWithCapacity(input.owners.size());
-      for (String owner : input.owners) {
-        args.ownerIds.add(groupsCollection.get().parse(owner).getGroupUUID());
-      }
-    }
-    args.contributorAgreements =
-        MoreObjects.firstNonNull(input.useContributorAgreements, InheritableBoolean.INHERIT);
-    args.signedOffBy = MoreObjects.firstNonNull(input.useSignedOffBy, InheritableBoolean.INHERIT);
-    args.contentMerge =
-        input.submitType == SubmitType.FAST_FORWARD_ONLY
-            ? InheritableBoolean.FALSE
-            : MoreObjects.firstNonNull(input.useContentMerge, InheritableBoolean.INHERIT);
-    args.newChangeForAllNotInTarget =
-        MoreObjects.firstNonNull(
-            input.createNewChangeForAllNotInTarget, InheritableBoolean.INHERIT);
-    args.changeIdRequired =
-        MoreObjects.firstNonNull(input.requireChangeId, InheritableBoolean.INHERIT);
-    try {
-      args.maxObjectSizeLimit = ProjectConfig.validMaxObjectSizeLimit(input.maxObjectSizeLimit);
-    } catch (ConfigInvalidException e) {
-      throw new BadRequestException(e.getMessage());
-    }
-
-    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);
-    }
-
-    return Response.created(json.format(projectState));
-  }
-
-  private ProjectState createProject(CreateProjectArgs args)
-      throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
-    final Project.NameKey nameKey = args.getProject();
-    try {
-      final String head = args.permissionsOnly ? RefNames.REFS_CONFIG : args.branch.get(0);
-      try (Repository repo = repoManager.openRepository(nameKey)) {
-        if (repo.getObjectDatabase().exists()) {
-          throw new ResourceConflictException("project \"" + nameKey + "\" exists");
-        }
-      } catch (RepositoryNotFoundException e) {
-        // It does not exist, safe to ignore.
-      }
-      try (Repository repo = repoManager.createRepository(nameKey)) {
-        RefUpdate u = repo.updateRef(Constants.HEAD);
-        u.disableRefLog();
-        u.link(head);
-
-        createProjectConfig(args);
-
-        if (!args.permissionsOnly && args.createEmptyCommit) {
-          createEmptyCommits(repo, nameKey, args.branch);
-        }
-
-        fire(nameKey, head);
-
-        return projectCache.get(nameKey);
-      }
-    } catch (RepositoryCaseMismatchException e) {
-      throw new ResourceConflictException(
-          "Cannot create "
-              + nameKey.get()
-              + " because the name is already occupied by another project."
-              + " The other project has the same name, only spelled in a"
-              + " different case.");
-    } catch (RepositoryNotFoundException badName) {
-      throw new BadRequestException("invalid project name: " + nameKey);
-    } catch (ConfigInvalidException e) {
-      String msg = "Cannot create " + nameKey;
-      log.error(msg, e);
-      throw e;
-    }
-  }
-
-  private void createProjectConfig(CreateProjectArgs args)
-      throws IOException, ConfigInvalidException {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(args.getProject())) {
-      ProjectConfig config = ProjectConfig.read(md);
-
-      Project newProject = config.getProject();
-      newProject.setDescription(args.projectDescription);
-      newProject.setSubmitType(
-          MoreObjects.firstNonNull(
-              args.submitType, repositoryCfg.getDefaultSubmitType(args.getProject())));
-      newProject.setUseContributorAgreements(args.contributorAgreements);
-      newProject.setUseSignedOffBy(args.signedOffBy);
-      newProject.setUseContentMerge(args.contentMerge);
-      newProject.setCreateNewChangeForAllNotInTarget(args.newChangeForAllNotInTarget);
-      newProject.setRequireChangeID(args.changeIdRequired);
-      newProject.setMaxObjectSizeLimit(args.maxObjectSizeLimit);
-      if (args.newParent != null) {
-        newProject.setParentName(args.newParent);
-      }
-
-      if (!args.ownerIds.isEmpty()) {
-        AccessSection all = config.getAccessSection(AccessSection.ALL, true);
-        for (AccountGroup.UUID ownerId : args.ownerIds) {
-          GroupDescription.Basic g = groupBackend.get(ownerId);
-          if (g != null) {
-            GroupReference group = config.resolve(GroupReference.forGroup(g));
-            all.getPermission(Permission.OWNER, true).add(new PermissionRule(group));
-          }
-        }
-      }
-
-      md.setMessage("Created project\n");
-      config.commit(md);
-      md.getRepository().setGitwebDescription(args.projectDescription);
-    }
-    projectCache.onCreateProject(args.getProject());
-  }
-
-  private List<String> normalizeBranchNames(List<String> branches) throws BadRequestException {
-    if (branches == null || branches.isEmpty()) {
-      return Collections.singletonList(Constants.R_HEADS + Constants.MASTER);
-    }
-
-    List<String> normalizedBranches = new ArrayList<>();
-    for (String branch : branches) {
-      while (branch.startsWith("/")) {
-        branch = branch.substring(1);
-      }
-      branch = RefNames.fullName(branch);
-      if (!Repository.isValidRefName(branch)) {
-        throw new BadRequestException(String.format("Branch \"%s\" is not a valid name.", branch));
-      }
-      if (!normalizedBranches.contains(branch)) {
-        normalizedBranches.add(branch);
-      }
-    }
-    return normalizedBranches;
-  }
-
-  private void createEmptyCommits(Repository repo, Project.NameKey project, List<String> refs)
-      throws IOException {
-    try (ObjectInserter oi = repo.newObjectInserter()) {
-      CommitBuilder cb = new CommitBuilder();
-      cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
-      cb.setAuthor(metaDataUpdateFactory.getUserPersonIdent());
-      cb.setCommitter(serverIdent);
-      cb.setMessage("Initial empty repository\n");
-
-      ObjectId id = oi.insert(cb);
-      oi.flush();
-
-      for (String ref : refs) {
-        RefUpdate ru = repo.updateRef(ref);
-        ru.setNewObjectId(id);
-        Result result = ru.update();
-        switch (result) {
-          case NEW:
-            referenceUpdated.fire(
-                project, ru, ReceiveCommand.Type.CREATE, identifiedUser.get().getAccount());
-            break;
-          case FAST_FORWARD:
-          case FORCED:
-          case IO_FAILURE:
-          case LOCK_FAILURE:
-          case NOT_ATTEMPTED:
-          case NO_CHANGE:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case RENAMED:
-          case REJECTED_MISSING_OBJECT:
-          case REJECTED_OTHER_REASON:
-          default:
-            {
-              throw new IOException(
-                  String.format("Failed to create ref \"%s\": %s", ref, result.name()));
-            }
-        }
-      }
-    } catch (IOException e) {
-      log.error("Cannot create empty commit for " + project.get(), e);
-      throw e;
-    }
-  }
-
-  private void fire(Project.NameKey name, String head) {
-    if (!createdListeners.iterator().hasNext()) {
-      return;
-    }
-
-    Event event = new Event(name, head);
-    for (NewProjectCreatedListener l : createdListeners) {
-      try {
-        l.onNewProjectCreated(event);
-      } catch (RuntimeException e) {
-        log.warn("Failure in NewProjectCreatedListener", e);
-      }
-    }
-  }
-
-  static class Event extends AbstractNoNotifyEvent implements NewProjectCreatedListener.Event {
-    private final Project.NameKey name;
-    private final String head;
-
-    Event(Project.NameKey name, String head) {
-      this.name = name;
-      this.head = head;
-    }
-
-    @Override
-    public String getProjectName() {
-      return name.get();
-    }
-
-    @Override
-    public String getHeadName() {
-      return head;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProjectArgs.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProjectArgs.java
deleted file mode 100644
index b98ffc2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProjectArgs.java
+++ /dev/null
@@ -1,64 +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.project;
-
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import java.util.List;
-
-public class CreateProjectArgs {
-
-  private Project.NameKey projectName;
-  public List<AccountGroup.UUID> ownerIds;
-  public Project.NameKey newParent;
-  public String projectDescription;
-  public SubmitType submitType;
-  public InheritableBoolean contributorAgreements;
-  public InheritableBoolean signedOffBy;
-  public boolean permissionsOnly;
-  public List<String> branch;
-  public InheritableBoolean contentMerge;
-  public InheritableBoolean newChangeForAllNotInTarget;
-  public InheritableBoolean changeIdRequired;
-  public boolean createEmptyCommit;
-  public String maxObjectSizeLimit;
-
-  public CreateProjectArgs() {
-    contributorAgreements = InheritableBoolean.INHERIT;
-    signedOffBy = InheritableBoolean.INHERIT;
-    contentMerge = InheritableBoolean.INHERIT;
-    changeIdRequired = InheritableBoolean.INHERIT;
-    newChangeForAllNotInTarget = InheritableBoolean.INHERIT;
-    submitType = SubmitType.MERGE_IF_NECESSARY;
-  }
-
-  public Project.NameKey getProject() {
-    return projectName;
-  }
-
-  public String getProjectName() {
-    return projectName != null ? projectName.get() : null;
-  }
-
-  public void setProjectName(String n) {
-    projectName = n != null ? new Project.NameKey(n) : null;
-  }
-
-  public void setProjectName(Project.NameKey n) {
-    projectName = n;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateRefControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateRefControl.java
deleted file mode 100644
index de6fba2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateRefControl.java
+++ /dev/null
@@ -1,149 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTag;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Manages access control for creating Git references (aka branches, tags). */
-@Singleton
-public class CreateRefControl {
-  private static final Logger log = LoggerFactory.getLogger(CreateRefControl.class);
-
-  private final PermissionBackend permissionBackend;
-  private final ProjectCache projectCache;
-
-  @Inject
-  CreateRefControl(PermissionBackend permissionBackend, ProjectCache projectCache) {
-    this.permissionBackend = permissionBackend;
-    this.projectCache = projectCache;
-  }
-
-  /**
-   * Checks whether the {@link CurrentUser} can create a new Git ref.
-   *
-   * @param user the user performing the operation
-   * @param repo repository on which user want to create
-   * @param branch the branch the new {@link RevObject} should be created on
-   * @param object the object the user will start the reference with
-   * @throws AuthException if creation is denied; the message explains the denial.
-   * @throws PermissionBackendException on failure of permission checks.
-   */
-  public void checkCreateRef(
-      Provider<? extends CurrentUser> user,
-      Repository repo,
-      Branch.NameKey branch,
-      RevObject object)
-      throws AuthException, PermissionBackendException, NoSuchProjectException, IOException {
-    ProjectState ps = projectCache.checkedGet(branch.getParentKey());
-    if (ps == null) {
-      throw new NoSuchProjectException(branch.getParentKey());
-    }
-    if (!ps.getProject().getState().permitsWrite()) {
-      throw new AuthException("project state does not permit write");
-    }
-
-    PermissionBackend.ForRef perm = permissionBackend.user(user).ref(branch);
-    if (object instanceof RevCommit) {
-      perm.check(RefPermission.CREATE);
-      checkCreateCommit(user, repo, (RevCommit) object, ps, perm);
-    } else if (object instanceof RevTag) {
-      RevTag tag = (RevTag) object;
-      try (RevWalk rw = new RevWalk(repo)) {
-        rw.parseBody(tag);
-      } catch (IOException e) {
-        log.error(String.format("RevWalk(%s) parsing %s:", branch.getParentKey(), tag.name()), e);
-        throw e;
-      }
-
-      // If tagger is present, require it matches the user's email.
-      PersonIdent tagger = tag.getTaggerIdent();
-      if (tagger != null
-          && (!user.get().isIdentifiedUser()
-              || !user.get().asIdentifiedUser().hasEmailAddress(tagger.getEmailAddress()))) {
-        perm.check(RefPermission.FORGE_COMMITTER);
-      }
-
-      RevObject target = tag.getObject();
-      if (target instanceof RevCommit) {
-        checkCreateCommit(user, repo, (RevCommit) target, ps, perm);
-      } else {
-        checkCreateRef(user, repo, branch, target);
-      }
-
-      // If the tag has a PGP signature, allow a lower level of permission
-      // than if it doesn't have a PGP signature.
-      RefControl refControl = ps.controlFor(user.get()).controlForRef(branch);
-      if (tag.getFullMessage().contains("-----BEGIN PGP SIGNATURE-----\n")) {
-        if (!refControl.canPerform(Permission.CREATE_SIGNED_TAG)) {
-          throw new AuthException(Permission.CREATE_SIGNED_TAG + " not permitted");
-        }
-      } else if (!refControl.canPerform(Permission.CREATE_TAG)) {
-        throw new AuthException(Permission.CREATE_TAG + " not permitted");
-      }
-    }
-  }
-
-  /**
-   * Check if the user is allowed to create a new commit object if this creation would introduce a
-   * new commit to the repository.
-   */
-  private void checkCreateCommit(
-      Provider<? extends CurrentUser> user,
-      Repository repo,
-      RevCommit commit,
-      ProjectState projectState,
-      PermissionBackend.ForRef forRef)
-      throws AuthException, PermissionBackendException {
-    try {
-      // If the user has update (push) permission, they can create the ref regardless
-      // of whether they are pushing any new objects along with the create.
-      forRef.check(RefPermission.UPDATE);
-      return;
-    } catch (AuthException denied) {
-      // Fall through to check reachability.
-    }
-
-    if (projectState.controlFor(user.get()).isReachableFromHeadsOrTags(repo, commit)) {
-      // If the user has no push permissions, check whether the object is
-      // merged into a branch or tag readable by this user. If so, they are
-      // not effectively "pushing" more objects, so they can create the ref
-      // even if they don't have push permission.
-      return;
-    }
-
-    throw new AuthException(
-        String.format(
-            "%s for creating new commit object not permitted",
-            RefPermission.UPDATE.describeForException()));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
deleted file mode 100644
index 61548c4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
+++ /dev/null
@@ -1,160 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static org.eclipse.jgit.lib.Constants.R_TAGS;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.projects.TagInfo;
-import com.google.gerrit.extensions.api.projects.TagInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.WebLinks;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.TagCache;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.RefUtil.InvalidRevisionException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.TimeZone;
-import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.api.TagCommand;
-import org.eclipse.jgit.api.errors.GitAPIException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class CreateTag implements RestModifyView<ProjectResource, TagInput> {
-  private static final Logger log = LoggerFactory.getLogger(CreateTag.class);
-
-  public interface Factory {
-    CreateTag create(String ref);
-  }
-
-  private final PermissionBackend permissionBackend;
-  private final Provider<IdentifiedUser> identifiedUser;
-  private final GitRepositoryManager repoManager;
-  private final TagCache tagCache;
-  private final GitReferenceUpdated referenceUpdated;
-  private final WebLinks links;
-  private String ref;
-
-  @Inject
-  CreateTag(
-      PermissionBackend permissionBackend,
-      Provider<IdentifiedUser> identifiedUser,
-      GitRepositoryManager repoManager,
-      TagCache tagCache,
-      GitReferenceUpdated referenceUpdated,
-      WebLinks webLinks,
-      @Assisted String ref) {
-    this.permissionBackend = permissionBackend;
-    this.identifiedUser = identifiedUser;
-    this.repoManager = repoManager;
-    this.tagCache = tagCache;
-    this.referenceUpdated = referenceUpdated;
-    this.links = webLinks;
-    this.ref = ref;
-  }
-
-  @Override
-  public TagInfo apply(ProjectResource resource, TagInput input)
-      throws RestApiException, IOException, PermissionBackendException {
-    if (input == null) {
-      input = new TagInput();
-    }
-    if (input.ref != null && !ref.equals(input.ref)) {
-      throw new BadRequestException("ref must match URL");
-    }
-    if (input.revision == null) {
-      input.revision = Constants.HEAD;
-    }
-
-    ref = RefUtil.normalizeTagRef(ref);
-
-    RefControl refControl = resource.getControl().controlForRef(ref);
-    PermissionBackend.ForRef perm =
-        permissionBackend.user(identifiedUser).project(resource.getNameKey()).ref(ref);
-
-    try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
-      ObjectId revid = RefUtil.parseBaseRevision(repo, resource.getNameKey(), input.revision);
-      RevWalk rw = RefUtil.verifyConnected(repo, revid);
-      RevObject object = rw.parseAny(revid);
-      rw.reset();
-      boolean isAnnotated = Strings.emptyToNull(input.message) != null;
-      boolean isSigned = isAnnotated && input.message.contains("-----BEGIN PGP SIGNATURE-----\n");
-      if (isSigned) {
-        throw new MethodNotAllowedException("Cannot create signed tag \"" + ref + "\"");
-      } else if (isAnnotated && !refControl.canPerform(Permission.CREATE_TAG)) {
-        throw new AuthException("Cannot create annotated tag \"" + ref + "\"");
-      } else {
-        perm.check(RefPermission.CREATE);
-      }
-      if (repo.getRefDatabase().exactRef(ref) != null) {
-        throw new ResourceConflictException("tag \"" + ref + "\" already exists");
-      }
-
-      try (Git git = new Git(repo)) {
-        TagCommand tag =
-            git.tag()
-                .setObjectId(object)
-                .setName(ref.substring(R_TAGS.length()))
-                .setAnnotated(isAnnotated)
-                .setSigned(isSigned);
-
-        if (isAnnotated) {
-          tag.setMessage(input.message)
-              .setTagger(
-                  identifiedUser.get().newCommitterIdent(TimeUtil.nowTs(), TimeZone.getDefault()));
-        }
-
-        Ref result = tag.call();
-        tagCache.updateFastForward(
-            resource.getNameKey(), ref, ObjectId.zeroId(), result.getObjectId());
-        referenceUpdated.fire(
-            resource.getNameKey(),
-            ref,
-            ObjectId.zeroId(),
-            result.getObjectId(),
-            identifiedUser.get().getAccount());
-        try (RevWalk w = new RevWalk(repo)) {
-          return ListTags.createTagInfo(perm, result, w, resource.getNameKey(), links);
-        }
-      }
-    } catch (InvalidRevisionException e) {
-      throw new BadRequestException("Invalid base revision");
-    } catch (GitAPIException e) {
-      log.error("Cannot create tag \"" + ref + "\"", e);
-      throw new IOException(e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardResource.java
deleted file mode 100644
index a3fd09e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardResource.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.inject.TypeLiteral;
-import org.eclipse.jgit.lib.Config;
-
-public class DashboardResource implements RestResource {
-  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);
-  }
-
-  private final ProjectControl control;
-  private final String refName;
-  private final String pathName;
-  private final Config config;
-  private final boolean projectDefault;
-
-  public DashboardResource(
-      ProjectControl control,
-      String refName,
-      String pathName,
-      Config config,
-      boolean projectDefault) {
-    this.control = control;
-    this.refName = refName;
-    this.pathName = pathName;
-    this.config = config;
-    this.projectDefault = projectDefault;
-  }
-
-  public ProjectControl getControl() {
-    return control;
-  }
-
-  public String getRefName() {
-    return refName;
-  }
-
-  public String getPathName() {
-    return pathName;
-  }
-
-  public Config getConfig() {
-    return config;
-  }
-
-  public boolean isProjectDefault() {
-    return projectDefault;
-  }
-}
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
deleted file mode 100644
index d43a066..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
+++ /dev/null
@@ -1,254 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
-
-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.Lists;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.UrlEncoded;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.errors.AmbiguousObjectException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.BlobBasedConfig;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-
-@Singleton
-public class DashboardsCollection
-    implements ChildCollection<ProjectResource, DashboardResource>, AcceptsCreate<ProjectResource> {
-  public static final String DEFAULT_DASHBOARD_NAME = "default";
-
-  private final GitRepositoryManager gitManager;
-  private final DynamicMap<RestView<DashboardResource>> views;
-  private final Provider<ListDashboards> list;
-  private final Provider<SetDefaultDashboard.CreateDefault> createDefault;
-  private final PermissionBackend permissionBackend;
-
-  @Inject
-  DashboardsCollection(
-      GitRepositoryManager gitManager,
-      DynamicMap<RestView<DashboardResource>> views,
-      Provider<ListDashboards> list,
-      Provider<SetDefaultDashboard.CreateDefault> createDefault,
-      PermissionBackend permissionBackend) {
-    this.gitManager = gitManager;
-    this.views = views;
-    this.list = list;
-    this.createDefault = createDefault;
-    this.permissionBackend = permissionBackend;
-  }
-
-  public static boolean isDefaultDashboard(@Nullable String id) {
-    return DEFAULT_DASHBOARD_NAME.equals(id);
-  }
-
-  public static boolean isDefaultDashboard(@Nullable IdString id) {
-    return id != null && isDefaultDashboard(id.toString());
-  }
-
-  @Override
-  public RestView<ProjectResource> list() throws ResourceNotFoundException {
-    return list.get();
-  }
-
-  @Override
-  public RestModifyView<ProjectResource, ?> create(ProjectResource parent, IdString id)
-      throws RestApiException {
-    if (isDefaultDashboard(id)) {
-      return createDefault.get();
-    }
-    throw new ResourceNotFoundException(id);
-  }
-
-  @Override
-  public DashboardResource parse(ProjectResource parent, IdString id)
-      throws ResourceNotFoundException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    ProjectControl myCtl = parent.getControl();
-    if (isDefaultDashboard(id)) {
-      return DashboardResource.projectDefault(myCtl);
-    }
-
-    DashboardInfo info;
-    try {
-      info = newDashboardInfo(id.get());
-    } catch (InvalidDashboardId e) {
-      throw new ResourceNotFoundException(id);
-    }
-
-    CurrentUser user = myCtl.getUser();
-    for (ProjectState ps : myCtl.getProjectState().tree()) {
-      try {
-        return parse(ps.controlFor(user), info, myCtl);
-      } catch (AmbiguousObjectException | ConfigInvalidException | IncorrectObjectTypeException e) {
-        throw new ResourceNotFoundException(id);
-      } catch (ResourceNotFoundException e) {
-        continue;
-      }
-    }
-    throw new ResourceNotFoundException(id);
-  }
-
-  public static String normalizeDashboardRef(String ref) {
-    if (!ref.startsWith(REFS_DASHBOARDS)) {
-      return REFS_DASHBOARDS + ref;
-    }
-    return ref;
-  }
-
-  private DashboardResource parse(ProjectControl ctl, DashboardInfo info, ProjectControl myCtl)
-      throws ResourceNotFoundException, IOException, AmbiguousObjectException,
-          IncorrectObjectTypeException, ConfigInvalidException, PermissionBackendException {
-    String ref = normalizeDashboardRef(info.ref);
-    try {
-      permissionBackend
-          .user(ctl.getUser())
-          .project(ctl.getProject().getNameKey())
-          .ref(ref)
-          .check(RefPermission.READ);
-    } catch (AuthException e) {
-      // Don't leak the project's existence
-      throw new ResourceNotFoundException(info.id);
-    }
-    if (!Repository.isValidRefName(ref)) {
-      throw new ResourceNotFoundException(info.id);
-    }
-
-    try (Repository git = gitManager.openRepository(ctl.getProject().getNameKey())) {
-      ObjectId objId = git.resolve(ref + ":" + 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);
-    } catch (RepositoryNotFoundException e) {
-      throw new ResourceNotFoundException(info.id);
-    }
-  }
-
-  @Override
-  public DynamicMap<RestView<DashboardResource>> views() {
-    return views;
-  }
-
-  public static DashboardInfo newDashboardInfo(String ref, String path) {
-    DashboardInfo info = new DashboardInfo();
-    info.ref = ref;
-    info.path = path;
-    info.id = Joiner.on(':').join(Url.encode(ref), Url.encode(path));
-    return info;
-  }
-
-  public static class InvalidDashboardId extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    public InvalidDashboardId(String id) {
-      super(id);
-    }
-  }
-
-  static DashboardInfo newDashboardInfo(String id) throws InvalidDashboardId {
-    DashboardInfo info = new DashboardInfo();
-    List<String> parts = Lists.newArrayList(Splitter.on(':').limit(2).split(id));
-    if (parts.size() != 2) {
-      throw new InvalidDashboardId(id);
-    }
-    info.id = id;
-    info.ref = parts.get(0);
-    info.path = parts.get(1);
-    return info;
-  }
-
-  static DashboardInfo parse(
-      Project definingProject,
-      String refName,
-      String path,
-      Config config,
-      String project,
-      boolean setDefault) {
-    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);
-    info.description = replace(project, config.getString("dashboard", null, "description"));
-    info.foreach = config.getString("dashboard", null, "foreach");
-
-    if (setDefault) {
-      String id = refName + ":" + path;
-      info.isDefault = id.equals(defaultOf(definingProject)) ? true : null;
-    }
-
-    UrlEncoded u = new UrlEncoded("/dashboard/");
-    u.put("title", MoreObjects.firstNonNull(info.title, info.path));
-    if (info.foreach != null) {
-      u.put("foreach", replace(project, info.foreach));
-    }
-    for (String name : config.getSubsections("section")) {
-      DashboardSectionInfo s = new DashboardSectionInfo();
-      s.name = name;
-      s.query = config.getString("section", name, "query");
-      u.put(s.name, replace(project, s.query));
-      info.sections.add(s);
-    }
-    info.url = u.toString().replace("%3A", ":");
-
-    return info;
-  }
-
-  private static String replace(String project, String query) {
-    return query.replace("${project}", project);
-  }
-
-  private static String defaultOf(Project proj) {
-    final String defaultId =
-        MoreObjects.firstNonNull(
-            proj.getLocalDefaultDashboard(), Strings.nullToEmpty(proj.getDefaultDashboard()));
-    if (defaultId.startsWith(REFS_DASHBOARDS)) {
-      return defaultId.substring(REFS_DASHBOARDS.length());
-    }
-    return defaultId;
-  }
-}
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
deleted file mode 100644
index a37d356..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackend.java
+++ /dev/null
@@ -1,208 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
-import com.google.gerrit.extensions.api.access.PluginPermission;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PeerDaemonUser;
-import com.google.gerrit.server.account.CapabilityCollection;
-import com.google.gerrit.server.permissions.FailedPermissionBackend;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.EnumSet;
-import java.util.List;
-import java.util.Set;
-
-@Singleton
-public class DefaultPermissionBackend extends PermissionBackend {
-  private static final CurrentUser.PropertyKey<Boolean> IS_ADMIN = CurrentUser.PropertyKey.create();
-
-  private final ProjectCache projectCache;
-
-  @Inject
-  DefaultPermissionBackend(ProjectCache projectCache) {
-    this.projectCache = projectCache;
-  }
-
-  private CapabilityCollection capabilities() {
-    return projectCache.getAllProjects().getCapabilityCollection();
-  }
-
-  @Override
-  public WithUser user(CurrentUser user) {
-    return new WithUserImpl(checkNotNull(user, "user"));
-  }
-
-  class WithUserImpl extends WithUser {
-    private final CurrentUser user;
-    private Boolean admin;
-
-    WithUserImpl(CurrentUser user) {
-      this.user = checkNotNull(user, "user");
-    }
-
-    @Override
-    public ForProject project(Project.NameKey project) {
-      try {
-        ProjectState state = projectCache.checkedGet(project);
-        if (state != null) {
-          return state.controlFor(user).asForProject().database(db);
-        }
-        return FailedPermissionBackend.project("not found");
-      } catch (IOException e) {
-        return FailedPermissionBackend.project("unavailable", e);
-      }
-    }
-
-    @Override
-    public void check(GlobalOrPluginPermission perm)
-        throws AuthException, PermissionBackendException {
-      if (!can(perm)) {
-        throw new AuthException(perm.describeForException() + " not permitted");
-      }
-    }
-
-    @Override
-    public <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
-        throws PermissionBackendException {
-      Set<T> ok = newSet(permSet);
-      for (T perm : permSet) {
-        if (can(perm)) {
-          ok.add(perm);
-        }
-      }
-      return ok;
-    }
-
-    private boolean can(GlobalOrPluginPermission perm) throws PermissionBackendException {
-      if (perm instanceof GlobalPermission) {
-        return can((GlobalPermission) perm);
-      } else if (perm instanceof PluginPermission) {
-        PluginPermission pluginPermission = (PluginPermission) perm;
-        return has(pluginPermission.permissionName())
-            || (pluginPermission.fallBackToAdmin() && isAdmin());
-      }
-      throw new PermissionBackendException(perm + " unsupported");
-    }
-
-    private boolean can(GlobalPermission perm) throws PermissionBackendException {
-      switch (perm) {
-        case ADMINISTRATE_SERVER:
-          return isAdmin();
-        case EMAIL_REVIEWERS:
-          return canEmailReviewers();
-
-        case FLUSH_CACHES:
-        case KILL_TASK:
-        case RUN_GC:
-        case VIEW_CACHES:
-        case VIEW_QUEUE:
-          return has(perm.permissionName()) || can(GlobalPermission.MAINTAIN_SERVER);
-
-        case CREATE_ACCOUNT:
-        case CREATE_GROUP:
-        case CREATE_PROJECT:
-        case MAINTAIN_SERVER:
-        case MODIFY_ACCOUNT:
-        case STREAM_EVENTS:
-        case VIEW_ALL_ACCOUNTS:
-        case VIEW_CONNECTIONS:
-        case VIEW_PLUGINS:
-          return has(perm.permissionName()) || isAdmin();
-
-        case ACCESS_DATABASE:
-        case RUN_AS:
-          return has(perm.permissionName());
-      }
-      throw new PermissionBackendException(perm + " unsupported");
-    }
-
-    private boolean isAdmin() {
-      if (admin == null) {
-        admin = computeAdmin();
-      }
-      return admin;
-    }
-
-    private Boolean computeAdmin() {
-      Boolean r = user.get(IS_ADMIN);
-      if (r == null) {
-        if (user.isImpersonating()) {
-          r = false;
-        } else if (user instanceof PeerDaemonUser) {
-          r = true;
-        } else {
-          r = allow(capabilities().administrateServer);
-        }
-        user.put(IS_ADMIN, r);
-      }
-      return r;
-    }
-
-    private boolean canEmailReviewers() {
-      List<PermissionRule> email = capabilities().emailReviewers;
-      return allow(email) || notDenied(email);
-    }
-
-    private boolean has(String permissionName) {
-      return allow(capabilities().getPermission(permissionName));
-    }
-
-    private boolean allow(Collection<PermissionRule> rules) {
-      return user.getEffectiveGroups()
-          .containsAnyOf(
-              rules
-                  .stream()
-                  .filter(r -> r.getAction() == Action.ALLOW)
-                  .map(r -> r.getGroup().getUUID())
-                  .collect(toSet()));
-    }
-
-    private boolean notDenied(Collection<PermissionRule> rules) {
-      Set<AccountGroup.UUID> denied =
-          rules
-              .stream()
-              .filter(r -> r.getAction() != Action.ALLOW)
-              .map(r -> r.getGroup().getUUID())
-              .collect(toSet());
-      return denied.isEmpty() || !user.getEffectiveGroups().containsAnyOf(denied);
-    }
-  }
-
-  private static <T extends GlobalOrPluginPermission> Set<T> newSet(Collection<T> permSet) {
-    if (permSet instanceof EnumSet) {
-      @SuppressWarnings({"unchecked", "rawtypes"})
-      Set<T> s = ((EnumSet) permSet).clone();
-      s.clear();
-      return s;
-    }
-    return Sets.newHashSetWithExpectedSize(permSet.size());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java
deleted file mode 100644
index 8fa049d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.inject.AbstractModule;
-
-/** Binds the default {@link PermissionBackend}. */
-public class DefaultPermissionBackendModule extends AbstractModule {
-  @Override
-  protected void configure() {
-    install(new LegacyControlsModule());
-  }
-
-  /** Binds legacy ProjectControl, RefControl, ChangeControl. */
-  public static class LegacyControlsModule extends FactoryModule {
-    @Override
-    protected void configure() {
-      // TODO(sop) Hide ProjectControl, RefControl, ChangeControl related bindings.
-      bind(ProjectControl.GenericFactory.class);
-      factory(ProjectControl.AssistedFactory.class);
-      bind(ChangeControl.GenericFactory.class);
-      bind(ChangeControl.Factory.class);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
deleted file mode 100644
index 8cd44d1..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static org.eclipse.jgit.lib.Constants.R_HEADS;
-
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.DeleteBranch.Input;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class DeleteBranch implements RestModifyView<BranchResource, Input> {
-  public static class Input {}
-
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final DeleteRef.Factory deleteRefFactory;
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-
-  @Inject
-  DeleteBranch(
-      Provider<InternalChangeQuery> queryProvider,
-      DeleteRef.Factory deleteRefFactory,
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend) {
-    this.queryProvider = queryProvider;
-    this.deleteRefFactory = deleteRefFactory;
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-  }
-
-  @Override
-  public Response<?> apply(BranchResource rsrc, Input input)
-      throws RestApiException, OrmException, IOException, PermissionBackendException {
-    permissionBackend.user(user).ref(rsrc.getBranchKey()).check(RefPermission.DELETE);
-
-    if (!queryProvider.get().setLimit(1).byBranchOpen(rsrc.getBranchKey()).isEmpty()) {
-      throw new ResourceConflictException("branch " + rsrc.getBranchKey() + " has open changes");
-    }
-
-    deleteRefFactory.create(rsrc).ref(rsrc.getRef()).prefix(R_HEADS).delete();
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
deleted file mode 100644
index fa7e917..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static org.eclipse.jgit.lib.Constants.R_HEADS;
-
-import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class DeleteBranches implements RestModifyView<ProjectResource, DeleteBranchesInput> {
-  private final DeleteRef.Factory deleteRefFactory;
-
-  @Inject
-  DeleteBranches(DeleteRef.Factory deleteRefFactory) {
-    this.deleteRefFactory = deleteRefFactory;
-  }
-
-  @Override
-  public Response<?> apply(ProjectResource project, DeleteBranchesInput input)
-      throws OrmException, IOException, RestApiException, PermissionBackendException {
-    if (input == null || input.branches == null || input.branches.isEmpty()) {
-      throw new BadRequestException("branches must be specified");
-    }
-    deleteRefFactory.create(project).refs(input.branches).prefix(R_HEADS).delete();
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteDashboard.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteDashboard.java
deleted file mode 100644
index 958de55..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteDashboard.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.common.SetDashboardInput;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-class DeleteDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
-  private final Provider<SetDefaultDashboard> defaultSetter;
-
-  @Inject
-  DeleteDashboard(Provider<SetDefaultDashboard> defaultSetter) {
-    this.defaultSetter = defaultSetter;
-  }
-
-  @Override
-  public Response<DashboardInfo> apply(DashboardResource resource, SetDashboardInput input)
-      throws RestApiException, IOException, PermissionBackendException {
-    if (resource.isProjectDefault()) {
-      SetDashboardInput in = new SetDashboardInput();
-      in.commitMessage = input != null ? input.commitMessage : null;
-      return defaultSetter.get().apply(resource, in);
-    }
-
-    // TODO: Implement delete of dashboards by API.
-    throw new MethodNotAllowedException();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
deleted file mode 100644
index 3b4638f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
+++ /dev/null
@@ -1,279 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static java.lang.String.format;
-import static java.util.stream.Collectors.toList;
-import static org.eclipse.jgit.lib.Constants.R_REFS;
-import static org.eclipse.jgit.lib.Constants.R_TAGS;
-import static org.eclipse.jgit.transport.ReceiveCommand.Type.DELETE;
-
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import org.eclipse.jgit.errors.LockFailedException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.transport.ReceiveCommand.Result;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class DeleteRef {
-  private static final Logger log = LoggerFactory.getLogger(DeleteRef.class);
-
-  private static final int MAX_LOCK_FAILURE_CALLS = 10;
-  private static final long SLEEP_ON_LOCK_FAILURE_MS = 15;
-
-  private final Provider<IdentifiedUser> identifiedUser;
-  private final PermissionBackend permissionBackend;
-  private final GitRepositoryManager repoManager;
-  private final GitReferenceUpdated referenceUpdated;
-  private final RefValidationHelper refDeletionValidator;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final ProjectResource resource;
-  private final List<String> refsToDelete;
-  private String prefix;
-
-  public interface Factory {
-    DeleteRef create(ProjectResource r);
-  }
-
-  @Inject
-  DeleteRef(
-      Provider<IdentifiedUser> identifiedUser,
-      PermissionBackend permissionBackend,
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated referenceUpdated,
-      RefValidationHelper.Factory refDeletionValidatorFactory,
-      Provider<InternalChangeQuery> queryProvider,
-      @Assisted ProjectResource resource) {
-    this.identifiedUser = identifiedUser;
-    this.permissionBackend = permissionBackend;
-    this.repoManager = repoManager;
-    this.referenceUpdated = referenceUpdated;
-    this.refDeletionValidator = refDeletionValidatorFactory.create(DELETE);
-    this.queryProvider = queryProvider;
-    this.resource = resource;
-    this.refsToDelete = new ArrayList<>();
-  }
-
-  public DeleteRef ref(String ref) {
-    this.refsToDelete.add(ref);
-    return this;
-  }
-
-  public DeleteRef refs(List<String> refs) {
-    this.refsToDelete.addAll(refs);
-    return this;
-  }
-
-  public DeleteRef prefix(String prefix) {
-    this.prefix = prefix;
-    return this;
-  }
-
-  public void delete()
-      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
-    if (!refsToDelete.isEmpty()) {
-      try (Repository r = repoManager.openRepository(resource.getNameKey())) {
-        if (refsToDelete.size() == 1) {
-          deleteSingleRef(r);
-        } else {
-          deleteMultipleRefs(r);
-        }
-      }
-    }
-  }
-
-  private void deleteSingleRef(Repository r) throws IOException, ResourceConflictException {
-    String ref = refsToDelete.get(0);
-    if (prefix != null && !ref.startsWith(R_REFS)) {
-      ref = prefix + ref;
-    }
-    RefUpdate.Result result;
-    RefUpdate u = r.updateRef(ref);
-    u.setExpectedOldObjectId(r.exactRef(ref).getObjectId());
-    u.setNewObjectId(ObjectId.zeroId());
-    u.setForceUpdate(true);
-    refDeletionValidator.validateRefOperation(resource.getName(), identifiedUser.get(), u);
-    int remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
-    for (; ; ) {
-      try {
-        result = u.delete();
-      } catch (LockFailedException e) {
-        result = RefUpdate.Result.LOCK_FAILURE;
-      } catch (IOException e) {
-        log.error("Cannot delete " + ref, e);
-        throw e;
-      }
-      if (result == RefUpdate.Result.LOCK_FAILURE && --remainingLockFailureCalls > 0) {
-        try {
-          Thread.sleep(SLEEP_ON_LOCK_FAILURE_MS);
-        } catch (InterruptedException ie) {
-          // ignore
-        }
-      } else {
-        break;
-      }
-    }
-
-    switch (result) {
-      case NEW:
-      case NO_CHANGE:
-      case FAST_FORWARD:
-      case FORCED:
-        referenceUpdated.fire(
-            resource.getNameKey(),
-            u,
-            ReceiveCommand.Type.DELETE,
-            identifiedUser.get().getAccount());
-        break;
-
-      case REJECTED_CURRENT_BRANCH:
-        log.error("Cannot delete " + ref + ": " + result.name());
-        throw new ResourceConflictException("cannot delete current branch");
-
-      case IO_FAILURE:
-      case LOCK_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case RENAMED:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      default:
-        log.error("Cannot delete " + ref + ": " + result.name());
-        throw new ResourceConflictException("cannot delete: " + result.name());
-    }
-  }
-
-  private void deleteMultipleRefs(Repository r)
-      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
-    BatchRefUpdate batchUpdate = r.getRefDatabase().newBatchUpdate();
-    batchUpdate.setAtomic(false);
-    List<String> refs =
-        prefix == null
-            ? refsToDelete
-            : refsToDelete
-                .stream()
-                .map(ref -> ref.startsWith(R_REFS) ? ref : prefix + ref)
-                .collect(toList());
-    for (String ref : refs) {
-      batchUpdate.addCommand(createDeleteCommand(resource, r, ref));
-    }
-    try (RevWalk rw = new RevWalk(r)) {
-      batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
-    }
-    StringBuilder errorMessages = new StringBuilder();
-    for (ReceiveCommand command : batchUpdate.getCommands()) {
-      if (command.getResult() == Result.OK) {
-        postDeletion(resource, command);
-      } else {
-        appendAndLogErrorMessage(errorMessages, command);
-      }
-    }
-    if (errorMessages.length() > 0) {
-      throw new ResourceConflictException(errorMessages.toString());
-    }
-  }
-
-  private ReceiveCommand createDeleteCommand(ProjectResource project, Repository r, String refName)
-      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
-    Ref ref = r.getRefDatabase().getRef(refName);
-    ReceiveCommand command;
-    if (ref == null) {
-      command = new ReceiveCommand(ObjectId.zeroId(), ObjectId.zeroId(), refName);
-      command.setResult(
-          Result.REJECTED_OTHER_REASON,
-          "it doesn't exist or you do not have permission to delete it");
-      return command;
-    }
-    command = new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName());
-
-    try {
-      permissionBackend
-          .user(identifiedUser)
-          .project(project.getNameKey())
-          .ref(refName)
-          .check(RefPermission.DELETE);
-    } catch (AuthException denied) {
-      command.setResult(
-          Result.REJECTED_OTHER_REASON,
-          "it doesn't exist or you do not have permission to delete it");
-    }
-
-    if (!refName.startsWith(R_TAGS)) {
-      Branch.NameKey branchKey = new Branch.NameKey(project.getNameKey(), ref.getName());
-      if (!queryProvider.get().setLimit(1).byBranchOpen(branchKey).isEmpty()) {
-        command.setResult(Result.REJECTED_OTHER_REASON, "it has open changes");
-      }
-    }
-
-    RefUpdate u = r.updateRef(refName);
-    u.setForceUpdate(true);
-    u.setExpectedOldObjectId(r.exactRef(refName).getObjectId());
-    u.setNewObjectId(ObjectId.zeroId());
-    refDeletionValidator.validateRefOperation(project.getName(), identifiedUser.get(), u);
-    return command;
-  }
-
-  private void appendAndLogErrorMessage(StringBuilder errorMessages, ReceiveCommand cmd) {
-    String msg = null;
-    switch (cmd.getResult()) {
-      case REJECTED_CURRENT_BRANCH:
-        msg = format("Cannot delete %s: it is the current branch", cmd.getRefName());
-        break;
-      case REJECTED_OTHER_REASON:
-        msg = format("Cannot delete %s: %s", cmd.getRefName(), cmd.getMessage());
-        break;
-      case LOCK_FAILURE:
-      case NOT_ATTEMPTED:
-      case OK:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_NOCREATE:
-      case REJECTED_NODELETE:
-      case REJECTED_NONFASTFORWARD:
-      default:
-        msg = format("Cannot delete %s: %s", cmd.getRefName(), cmd.getResult());
-        break;
-    }
-    log.error(msg);
-    errorMessages.append(msg);
-    errorMessages.append("\n");
-  }
-
-  private void postDeletion(ProjectResource project, ReceiveCommand cmd) {
-    referenceUpdated.fire(project.getNameKey(), cmd, identifiedUser.get().getAccount());
-  }
-}
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
deleted file mode 100644
index a05fa2e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class DeleteTag implements RestModifyView<TagResource, DeleteTag.Input> {
-  public static class Input {}
-
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> user;
-  private final DeleteRef.Factory deleteRefFactory;
-
-  @Inject
-  DeleteTag(
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> user,
-      DeleteRef.Factory deleteRefFactory) {
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-    this.deleteRefFactory = deleteRefFactory;
-  }
-
-  @Override
-  public Response<?> apply(TagResource resource, Input input)
-      throws OrmException, RestApiException, IOException, PermissionBackendException {
-    String tag = RefUtil.normalizeTagRef(resource.getTagInfo().ref);
-    permissionBackend
-        .user(user)
-        .project(resource.getNameKey())
-        .ref(tag)
-        .check(RefPermission.DELETE);
-    deleteRefFactory.create(resource).ref(tag).delete();
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java
deleted file mode 100644
index c020351..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static org.eclipse.jgit.lib.Constants.R_TAGS;
-
-import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class DeleteTags implements RestModifyView<ProjectResource, DeleteTagsInput> {
-  private final DeleteRef.Factory deleteRefFactory;
-
-  @Inject
-  DeleteTags(DeleteRef.Factory deleteRefFactory) {
-    this.deleteRefFactory = deleteRefFactory;
-  }
-
-  @Override
-  public Response<?> apply(ProjectResource project, DeleteTagsInput input)
-      throws OrmException, RestApiException, IOException, PermissionBackendException {
-    if (input == null || input.tags == null || input.tags.isEmpty()) {
-      throw new BadRequestException("tags must be specified");
-    }
-    deleteRefFactory.create(project).refs(input.tags).prefix(R_TAGS).delete();
-    return Response.none();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java
deleted file mode 100644
index 82462b2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.TypeLiteral;
-import java.io.IOException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.TreeWalk;
-
-public class FileResource implements RestResource {
-  public static final TypeLiteral<RestView<FileResource>> FILE_KIND =
-      new TypeLiteral<RestView<FileResource>>() {};
-
-  public static FileResource create(
-      GitRepositoryManager repoManager, ProjectState projectState, ObjectId rev, String path)
-      throws ResourceNotFoundException, IOException {
-    try (Repository repo = repoManager.openRepository(projectState.getNameKey());
-        RevWalk rw = new RevWalk(repo)) {
-      RevTree tree = rw.parseTree(rev);
-      if (TreeWalk.forPath(repo, path, tree) != null) {
-        return new FileResource(projectState, rev, path);
-      }
-    }
-    throw new ResourceNotFoundException(IdString.fromDecoded(path));
-  }
-
-  private final ProjectState projectState;
-  private final ObjectId rev;
-  private final String path;
-
-  public FileResource(ProjectState projectState, ObjectId rev, String path) {
-    this.projectState = projectState;
-    this.rev = rev;
-    this.path = path;
-  }
-
-  public ProjectState getProjectState() {
-    return projectState;
-  }
-
-  public ObjectId getRev() {
-    return rev;
-  }
-
-  public String getPath() {
-    return path;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java
deleted file mode 100644
index dd32f85..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.lib.ObjectId;
-
-@Singleton
-public class FilesCollection implements ChildCollection<BranchResource, FileResource> {
-  private final DynamicMap<RestView<FileResource>> views;
-  private final GitRepositoryManager repoManager;
-
-  @Inject
-  FilesCollection(DynamicMap<RestView<FileResource>> views, GitRepositoryManager repoManager) {
-    this.views = views;
-    this.repoManager = repoManager;
-  }
-
-  @Override
-  public RestView<BranchResource> list() throws ResourceNotFoundException {
-    throw new ResourceNotFoundException();
-  }
-
-  @Override
-  public FileResource parse(BranchResource parent, IdString id)
-      throws ResourceNotFoundException, IOException {
-    return FileResource.create(
-        repoManager, parent.getProjectState(), ObjectId.fromString(parent.getRevision()), id.get());
-  }
-
-  @Override
-  public DynamicMap<RestView<FileResource>> views() {
-    return views;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java
deleted file mode 100644
index 7144099..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class FilesInCommitCollection implements ChildCollection<CommitResource, FileResource> {
-  private final DynamicMap<RestView<FileResource>> views;
-  private final GitRepositoryManager repoManager;
-
-  @Inject
-  FilesInCommitCollection(
-      DynamicMap<RestView<FileResource>> views, GitRepositoryManager repoManager) {
-    this.views = views;
-    this.repoManager = repoManager;
-  }
-
-  @Override
-  public RestView<CommitResource> list() throws ResourceNotFoundException {
-    throw new ResourceNotFoundException();
-  }
-
-  @Override
-  public FileResource parse(CommitResource parent, IdString id)
-      throws ResourceNotFoundException, IOException {
-    if (Patch.isMagic(id.get())) {
-      return new FileResource(parent.getProjectState(), parent.getCommit(), id.get());
-    }
-    return FileResource.create(repoManager, parent.getProjectState(), parent.getCommit(), id.get());
-  }
-
-  @Override
-  public DynamicMap<RestView<FileResource>> views() {
-    return views;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java
deleted file mode 100644
index f81a0f3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java
+++ /dev/null
@@ -1,170 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.gerrit.common.data.GarbageCollectionResult;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.git.GarbageCollection;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LocalDiskRepositoryManager;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.project.GarbageCollect.Input;
-import com.google.gerrit.server.util.IdGenerator;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.util.Collections;
-
-@RequiresCapability(GlobalCapability.RUN_GC)
-@Singleton
-public class GarbageCollect
-    implements RestModifyView<ProjectResource, Input>, UiAction<ProjectResource> {
-  public static class Input {
-    public boolean showProgress;
-    public boolean aggressive;
-    public boolean async;
-  }
-
-  private final boolean canGC;
-  private final GarbageCollection.Factory garbageCollectionFactory;
-  private final WorkQueue workQueue;
-  private final Provider<String> canonicalUrl;
-
-  @Inject
-  GarbageCollect(
-      GitRepositoryManager repoManager,
-      GarbageCollection.Factory garbageCollectionFactory,
-      WorkQueue workQueue,
-      @CanonicalWebUrl Provider<String> canonicalUrl) {
-    this.workQueue = workQueue;
-    this.canonicalUrl = canonicalUrl;
-    this.canGC = repoManager instanceof LocalDiskRepositoryManager;
-    this.garbageCollectionFactory = garbageCollectionFactory;
-  }
-
-  @Override
-  public Object apply(ProjectResource rsrc, Input input) {
-    Project.NameKey project = rsrc.getNameKey();
-    if (input.async) {
-      return applyAsync(project, input);
-    }
-    return applySync(project, input);
-  }
-
-  private Response.Accepted applyAsync(Project.NameKey project, Input input) {
-    Runnable job =
-        new Runnable() {
-          @Override
-          public void run() {
-            runGC(project, input, null);
-          }
-
-          @Override
-          public String toString() {
-            return "Run "
-                + (input.aggressive ? "aggressive " : "")
-                + "garbage collection on project "
-                + project.get();
-          }
-        };
-
-    @SuppressWarnings("unchecked")
-    WorkQueue.Task<Void> task = (WorkQueue.Task<Void>) workQueue.getDefaultQueue().submit(job);
-
-    String location =
-        canonicalUrl.get() + "a/config/server/tasks/" + IdGenerator.format(task.getTaskId());
-
-    return Response.accepted(location);
-  }
-
-  @SuppressWarnings("resource")
-  private BinaryResult applySync(Project.NameKey project, Input input) {
-    return new BinaryResult() {
-      @Override
-      public void writeTo(OutputStream out) throws IOException {
-        PrintWriter writer =
-            new PrintWriter(new OutputStreamWriter(out, UTF_8)) {
-              @Override
-              public void println() {
-                write('\n');
-              }
-            };
-        try {
-          PrintWriter progressWriter = input.showProgress ? writer : null;
-          GarbageCollectionResult result = runGC(project, input, progressWriter);
-          String msg = "Garbage collection completed successfully.";
-          if (result.hasErrors()) {
-            for (GarbageCollectionResult.Error e : result.getErrors()) {
-              switch (e.getType()) {
-                case REPOSITORY_NOT_FOUND:
-                  msg = "Error: project \"" + e.getProjectName() + "\" not found.";
-                  break;
-                case GC_ALREADY_SCHEDULED:
-                  msg =
-                      "Error: garbage collection for project \""
-                          + e.getProjectName()
-                          + "\" was already scheduled.";
-                  break;
-                case GC_FAILED:
-                  msg =
-                      "Error: garbage collection for project \""
-                          + e.getProjectName()
-                          + "\" failed.";
-                  break;
-                default:
-                  msg =
-                      "Error: garbage collection for project \""
-                          + e.getProjectName()
-                          + "\" failed: "
-                          + e.getType()
-                          + ".";
-              }
-            }
-          }
-          writer.println(msg);
-        } finally {
-          writer.flush();
-        }
-      }
-    }.setContentType("text/plain").setCharacterEncoding(UTF_8).disableGzip();
-  }
-
-  GarbageCollectionResult runGC(Project.NameKey project, Input input, PrintWriter progressWriter) {
-    return garbageCollectionFactory
-        .create()
-        .run(Collections.singletonList(project), input.aggressive, progressWriter);
-  }
-
-  @Override
-  public UiAction.Description getDescription(ProjectResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Run GC")
-        .setTitle("Triggers the Git Garbage Collection for this project.")
-        .setVisible(canGC);
-  }
-}
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
deleted file mode 100644
index 07c52f9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
+++ /dev/null
@@ -1,331 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
-import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_REF;
-import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
-import static com.google.gerrit.server.permissions.RefPermission.READ;
-import static java.util.stream.Collectors.toMap;
-
-import com.google.common.collect.ImmutableBiMap;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.RefConfigSection;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.api.access.AccessSectionInfo;
-import com.google.gerrit.extensions.api.access.PermissionInfo;
-import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
-import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.GroupJson;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class GetAccess implements RestReadView<ProjectResource> {
-  private static final Logger LOG = LoggerFactory.getLogger(GetAccess.class);
-
-  /** Marker value used in {@code Map<?, GroupInfo>} for groups not visible to current user. */
-  private static final GroupInfo INVISIBLE_SENTINEL = new GroupInfo();
-
-  public static final ImmutableBiMap<PermissionRule.Action, PermissionRuleInfo.Action> ACTION_TYPE =
-      ImmutableBiMap.of(
-          PermissionRule.Action.ALLOW,
-          PermissionRuleInfo.Action.ALLOW,
-          PermissionRule.Action.BATCH,
-          PermissionRuleInfo.Action.BATCH,
-          PermissionRule.Action.BLOCK,
-          PermissionRuleInfo.Action.BLOCK,
-          PermissionRule.Action.DENY,
-          PermissionRuleInfo.Action.DENY,
-          PermissionRule.Action.INTERACTIVE,
-          PermissionRuleInfo.Action.INTERACTIVE);
-
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final GroupControl.Factory groupControlFactory;
-  private final AllProjectsName allProjectsName;
-  private final ProjectJson projectJson;
-  private final ProjectCache projectCache;
-  private final MetaDataUpdate.Server metaDataUpdateFactory;
-  private final ProjectControl.GenericFactory projectControlFactory;
-  private final GroupBackend groupBackend;
-  private final GroupJson groupJson;
-
-  @Inject
-  public GetAccess(
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend,
-      GroupControl.Factory groupControlFactory,
-      AllProjectsName allProjectsName,
-      ProjectCache projectCache,
-      MetaDataUpdate.Server metaDataUpdateFactory,
-      ProjectJson projectJson,
-      ProjectControl.GenericFactory projectControlFactory,
-      GroupBackend groupBackend,
-      GroupJson groupJson) {
-    this.user = self;
-    this.permissionBackend = permissionBackend;
-    this.groupControlFactory = groupControlFactory;
-    this.allProjectsName = allProjectsName;
-    this.projectJson = projectJson;
-    this.projectCache = projectCache;
-    this.projectControlFactory = projectControlFactory;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.groupBackend = groupBackend;
-    this.groupJson = groupJson;
-  }
-
-  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) {
-      throw new ResourceNotFoundException(nameKey.get());
-    }
-  }
-
-  @Override
-  public ProjectAccessInfo apply(ProjectResource rsrc)
-      throws ResourceNotFoundException, ResourceConflictException, IOException,
-          PermissionBackendException, OrmException {
-    // Load the current configuration from the repository, ensuring it's the most
-    // recent version available. If it differs from what was in the project
-    // state, force a cache flush now.
-
-    Project.NameKey projectName = rsrc.getNameKey();
-    ProjectAccessInfo info = new ProjectAccessInfo();
-    ProjectControl pc = createProjectControl(projectName);
-    PermissionBackend.ForProject perm = permissionBackend.user(user).project(projectName);
-
-    ProjectConfig config;
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
-      config = ProjectConfig.read(md);
-
-      if (config.updateGroupNames(groupBackend)) {
-        md.setMessage("Update group names\n");
-        config.commit(md);
-        projectCache.evict(config.getProject());
-        pc = createProjectControl(projectName);
-        perm = permissionBackend.user(user).project(projectName);
-      } else if (config.getRevision() != null
-          && !config.getRevision().equals(pc.getProjectState().getConfig().getRevision())) {
-        projectCache.evict(config.getProject());
-        pc = createProjectControl(projectName);
-        perm = permissionBackend.user(user).project(projectName);
-      }
-    } catch (ConfigInvalidException e) {
-      throw new ResourceConflictException(e.getMessage());
-    } catch (RepositoryNotFoundException e) {
-      throw new ResourceNotFoundException(rsrc.getName());
-    }
-
-    info.local = new HashMap<>();
-    info.ownerOf = new HashSet<>();
-    Map<AccountGroup.UUID, GroupInfo> visibleGroups = new HashMap<>();
-    boolean checkReadConfig = check(perm, RefNames.REFS_CONFIG, READ);
-
-    for (AccessSection section : config.getAccessSections()) {
-      String name = section.getName();
-      if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
-        if (pc.isOwner()) {
-          info.local.put(name, createAccessSection(visibleGroups, section));
-          info.ownerOf.add(name);
-
-        } else if (checkReadConfig) {
-          info.local.put(section.getName(), createAccessSection(visibleGroups, section));
-        }
-
-      } else if (RefConfigSection.isValid(name)) {
-        if (pc.controlForRef(name).isOwner()) {
-          info.local.put(name, createAccessSection(visibleGroups, section));
-          info.ownerOf.add(name);
-
-        } else if (checkReadConfig) {
-          info.local.put(name, createAccessSection(visibleGroups, section));
-
-        } else if (check(perm, name, READ)) {
-          // Filter the section to only add rules describing groups that
-          // are visible to the current-user. This includes any group the
-          // user is a member of, as well as groups they own or that
-          // are visible to all users.
-
-          AccessSection dst = null;
-          for (Permission srcPerm : section.getPermissions()) {
-            Permission dstPerm = null;
-
-            for (PermissionRule srcRule : srcPerm.getRules()) {
-              AccountGroup.UUID groupId = srcRule.getGroup().getUUID();
-              if (groupId == null) {
-                continue;
-              }
-
-              GroupInfo group = loadGroup(visibleGroups, groupId);
-
-              if (group != INVISIBLE_SENTINEL) {
-                if (dstPerm == null) {
-                  if (dst == null) {
-                    dst = new AccessSection(name);
-                    info.local.put(name, createAccessSection(visibleGroups, dst));
-                  }
-                  dstPerm = dst.getPermission(srcPerm.getName(), true);
-                }
-                dstPerm.add(srcRule);
-              }
-            }
-          }
-        }
-      }
-    }
-
-    if (info.ownerOf.isEmpty()
-        && permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER)) {
-      // Special case: If the section list is empty, this project has no current
-      // access control information. Fall back to site administrators.
-      info.ownerOf.add(AccessSection.ALL);
-    }
-
-    if (config.getRevision() != null) {
-      info.revision = config.getRevision().name();
-    }
-
-    ProjectState parent = Iterables.getFirst(pc.getProjectState().parents(), null);
-    if (parent != null) {
-      info.inheritsFrom = projectJson.format(parent.getProject());
-    }
-
-    if (projectName.equals(allProjectsName)
-        && permissionBackend.user(user).testOrFalse(ADMINISTRATE_SERVER)) {
-      info.ownerOf.add(AccessSection.GLOBAL_CAPABILITIES);
-    }
-
-    info.isOwner = toBoolean(pc.isOwner());
-    info.canUpload =
-        toBoolean(
-            pc.isOwner()
-                || (checkReadConfig && perm.ref(RefNames.REFS_CONFIG).testOrFalse(CREATE_CHANGE)));
-    info.canAdd = toBoolean(perm.testOrFalse(CREATE_REF));
-    info.configVisible = checkReadConfig || pc.isOwner();
-
-    info.groups =
-        visibleGroups
-            .entrySet()
-            .stream()
-            .filter(e -> e.getValue() != INVISIBLE_SENTINEL)
-            .collect(toMap(e -> e.getKey().get(), e -> e.getValue()));
-
-    return info;
-  }
-
-  private GroupInfo loadGroup(Map<AccountGroup.UUID, GroupInfo> visibleGroups, AccountGroup.UUID id)
-      throws OrmException {
-    GroupInfo group = visibleGroups.get(id);
-    if (group == null) {
-      try {
-        GroupControl control = groupControlFactory.controlFor(id);
-        group = INVISIBLE_SENTINEL;
-        if (control.isVisible()) {
-          group = groupJson.format(control.getGroup());
-          group.id = null;
-        }
-      } catch (NoSuchGroupException e) {
-        LOG.warn("NoSuchGroupException; ignoring group " + id, e);
-        group = INVISIBLE_SENTINEL;
-      }
-      visibleGroups.put(id, group);
-    }
-
-    return group;
-  }
-
-  private static boolean check(PermissionBackend.ForProject ctx, String ref, RefPermission perm)
-      throws PermissionBackendException {
-    try {
-      ctx.ref(ref).check(perm);
-      return true;
-    } catch (AuthException denied) {
-      return false;
-    }
-  }
-
-  private AccessSectionInfo createAccessSection(
-      Map<AccountGroup.UUID, GroupInfo> groups, AccessSection section) throws OrmException {
-    AccessSectionInfo accessSectionInfo = new AccessSectionInfo();
-    accessSectionInfo.permissions = new HashMap<>();
-    for (Permission p : section.getPermissions()) {
-      PermissionInfo pInfo = new PermissionInfo(p.getLabel(), p.getExclusiveGroup() ? true : null);
-      pInfo.rules = new HashMap<>();
-      for (PermissionRule r : p.getRules()) {
-        PermissionRuleInfo info =
-            new PermissionRuleInfo(ACTION_TYPE.get(r.getAction()), r.getForce());
-        if (r.hasRange()) {
-          info.max = r.getMax();
-          info.min = r.getMin();
-        }
-        AccountGroup.UUID group = r.getGroup().getUUID();
-        if (group != null) {
-          pInfo.rules.put(group.get(), info);
-          loadGroup(groups, group);
-        }
-      }
-      accessSectionInfo.permissions.put(p.getName(), pInfo);
-    }
-    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/GetBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetBranch.java
deleted file mode 100644
index d312bde..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetBranch.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.api.projects.BranchInfo;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class GetBranch implements RestReadView<BranchResource> {
-  private final Provider<ListBranches> list;
-
-  @Inject
-  GetBranch(Provider<ListBranches> list) {
-    this.list = list;
-  }
-
-  @Override
-  public BranchInfo apply(BranchResource rsrc)
-      throws ResourceNotFoundException, IOException, PermissionBackendException {
-    return list.get().toBranchInfo(rsrc);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java
deleted file mode 100644
index afffdfc..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Inject;
-import org.kohsuke.args4j.Option;
-
-public class GetChildProject implements RestReadView<ChildProjectResource> {
-  @Option(name = "--recursive", usage = "to list child projects recursively")
-  public void setRecursive(boolean recursive) {
-    this.recursive = recursive;
-  }
-
-  private final ProjectJson json;
-  private boolean recursive;
-
-  @Inject
-  GetChildProject(ProjectJson json) {
-    this.json = json;
-  }
-
-  @Override
-  public ProjectInfo apply(ChildProjectResource rsrc) throws ResourceNotFoundException {
-    if (recursive || rsrc.isDirectChild()) {
-      return json.format(rsrc.getChild().getProject());
-    }
-    throw new ResourceNotFoundException(rsrc.getChild().getName());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetCommit.java
deleted file mode 100644
index bd4492e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetCommit.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.CommonConverters;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import org.eclipse.jgit.revwalk.RevCommit;
-
-@Singleton
-public class GetCommit implements RestReadView<CommitResource> {
-
-  @Override
-  public CommitInfo apply(CommitResource rsrc) {
-    return toCommitInfo(rsrc.getCommit());
-  }
-
-  private static CommitInfo toCommitInfo(RevCommit commit) {
-    CommitInfo info = new CommitInfo();
-    info.commit = commit.getName();
-    info.author = CommonConverters.toGitPerson(commit.getAuthorIdent());
-    info.committer = CommonConverters.toGitPerson(commit.getCommitterIdent());
-    info.subject = commit.getShortMessage();
-    info.message = commit.getFullMessage();
-    info.parents = new ArrayList<>(commit.getParentCount());
-    for (int i = 0; i < commit.getParentCount(); i++) {
-      RevCommit p = commit.getParent(i);
-      CommitInfo parentInfo = new CommitInfo();
-      parentInfo.commit = p.getName();
-      parentInfo.subject = p.getShortMessage();
-      info.parents.add(parentInfo);
-    }
-    return info;
-  }
-}
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
deleted file mode 100644
index b1ba281..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.api.projects.ConfigInfo;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.EnableSignedPush;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.PluginConfigFactory;
-import com.google.gerrit.server.config.ProjectConfigEntry;
-import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.TransferConfig;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetConfig implements RestReadView<ProjectResource> {
-  private final boolean serverEnableSignedPush;
-  private final TransferConfig config;
-  private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
-  private final PluginConfigFactory cfgFactory;
-  private final AllProjectsName allProjects;
-  private final UiActions uiActions;
-  private final DynamicMap<RestView<ProjectResource>> views;
-
-  @Inject
-  public GetConfig(
-      @EnableSignedPush boolean serverEnableSignedPush,
-      TransferConfig config,
-      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
-      PluginConfigFactory cfgFactory,
-      AllProjectsName allProjects,
-      UiActions uiActions,
-      DynamicMap<RestView<ProjectResource>> views) {
-    this.serverEnableSignedPush = serverEnableSignedPush;
-    this.config = config;
-    this.pluginConfigEntries = pluginConfigEntries;
-    this.allProjects = allProjects;
-    this.cfgFactory = cfgFactory;
-    this.uiActions = uiActions;
-    this.views = views;
-  }
-
-  @Override
-  public ConfigInfo apply(ProjectResource resource) {
-    return new ConfigInfoImpl(
-        serverEnableSignedPush,
-        resource.getControl(),
-        config,
-        pluginConfigEntries,
-        cfgFactory,
-        allProjects,
-        uiActions,
-        views);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java
deleted file mode 100644
index b5294c4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.change.FileContentUtil;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class GetContent implements RestReadView<FileResource> {
-  private final FileContentUtil fileContentUtil;
-
-  @Inject
-  GetContent(FileContentUtil fileContentUtil) {
-    this.fileContentUtil = fileContentUtil;
-  }
-
-  @Override
-  public BinaryResult apply(FileResource rsrc)
-      throws ResourceNotFoundException, BadRequestException, IOException {
-    return fileContentUtil.getContent(rsrc.getProjectState(), rsrc.getRev(), rsrc.getPath(), null);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDashboard.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDashboard.java
deleted file mode 100644
index cdf23bb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDashboard.java
+++ /dev/null
@@ -1,115 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
-import static com.google.gerrit.server.project.DashboardsCollection.isDefaultDashboard;
-
-import com.google.common.base.Splitter;
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.kohsuke.args4j.Option;
-
-public class GetDashboard implements RestReadView<DashboardResource> {
-  private final DashboardsCollection dashboards;
-
-  @Option(name = "--inherited", usage = "include inherited dashboards")
-  private boolean inherited;
-
-  @Inject
-  GetDashboard(DashboardsCollection dashboards) {
-    this.dashboards = dashboards;
-  }
-
-  public GetDashboard setInherited(boolean inherited) {
-    this.inherited = inherited;
-    return this;
-  }
-
-  @Override
-  public DashboardInfo apply(DashboardResource resource)
-      throws RestApiException, IOException, PermissionBackendException {
-    if (inherited && !resource.isProjectDefault()) {
-      throw new BadRequestException("inherited flag can only be used with default");
-    }
-
-    String project = resource.getControl().getProject().getName();
-    if (resource.isProjectDefault()) {
-      // The default is not resolved to a definition yet.
-      try {
-        resource = defaultOf(resource.getControl());
-      } 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,
-        true);
-  }
-
-  private DashboardResource defaultOf(ProjectControl ctl)
-      throws ResourceNotFoundException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    String id = ctl.getProject().getLocalDefaultDashboard();
-    if (Strings.isNullOrEmpty(id)) {
-      id = ctl.getProject().getDefaultDashboard();
-    }
-    if (isDefaultDashboard(id)) {
-      throw new ResourceNotFoundException();
-    } else if (!Strings.isNullOrEmpty(id)) {
-      return parse(ctl, id);
-    } else if (!inherited) {
-      throw new ResourceNotFoundException();
-    }
-
-    for (ProjectState ps : ctl.getProjectState().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);
-      }
-    }
-    throw new ResourceNotFoundException();
-  }
-
-  private DashboardResource parse(ProjectControl ctl, 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));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDescription.java
deleted file mode 100644
index dd03e97..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDescription.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetDescription implements RestReadView<ProjectResource> {
-  @Override
-  public String apply(ProjectResource rsrc) {
-    return Strings.nullToEmpty(rsrc.getProjectState().getProject().getDescription());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java
deleted file mode 100644
index 31dc7bf..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-@Singleton
-public class GetHead implements RestReadView<ProjectResource> {
-  private final GitRepositoryManager repoManager;
-  private final CommitsCollection commits;
-  private final PermissionBackend permissionBackend;
-
-  @Inject
-  GetHead(
-      GitRepositoryManager repoManager,
-      CommitsCollection commits,
-      PermissionBackend permissionBackend) {
-    this.repoManager = repoManager;
-    this.commits = commits;
-    this.permissionBackend = permissionBackend;
-  }
-
-  @Override
-  public String apply(ProjectResource rsrc)
-      throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
-    try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
-      Ref head = repo.getRefDatabase().exactRef(Constants.HEAD);
-      if (head == null) {
-        throw new ResourceNotFoundException(Constants.HEAD);
-      } else if (head.isSymbolic()) {
-        String n = head.getTarget().getName();
-        permissionBackend
-            .user(rsrc.getUser())
-            .project(rsrc.getNameKey())
-            .ref(n)
-            .check(RefPermission.READ);
-        return n;
-      } else if (head.getObjectId() != null) {
-        try (RevWalk rw = new RevWalk(repo)) {
-          RevCommit commit = rw.parseCommit(head.getObjectId());
-          if (commits.canRead(rsrc.getProjectState(), repo, commit)) {
-            return head.getObjectId().name();
-          }
-          throw new AuthException("not allowed to see HEAD");
-        } catch (MissingObjectException | IncorrectObjectTypeException e) {
-          if (rsrc.getControl().isOwner()) {
-            return head.getObjectId().name();
-          }
-          throw new AuthException("not allowed to see HEAD");
-        }
-      }
-      throw new ResourceNotFoundException(Constants.HEAD);
-    } catch (RepositoryNotFoundException e) {
-      throw new ResourceNotFoundException(rsrc.getName());
-    }
-  }
-}
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
deleted file mode 100644
index 8f0b6f0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetParent.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-class GetParent implements RestReadView<ProjectResource> {
-  private final AllProjectsName allProjectsName;
-
-  @Inject
-  GetParent(AllProjectsName allProjectsName) {
-    this.allProjectsName = allProjectsName;
-  }
-
-  @Override
-  public String apply(ProjectResource resource) {
-    Project project = resource.getProjectState().getProject();
-    Project.NameKey parentName = project.getParent(allProjectsName);
-    return parentName != null ? parentName.get() : "";
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetProject.java
deleted file mode 100644
index 8288610..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetProject.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-class GetProject implements RestReadView<ProjectResource> {
-
-  private final ProjectJson json;
-
-  @Inject
-  GetProject(ProjectJson json) {
-    this.json = json;
-  }
-
-  @Override
-  public ProjectInfo apply(ProjectResource rsrc) {
-    return json.format(rsrc.getProjectState());
-  }
-}
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
deleted file mode 100644
index 44d6a4f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetReflog.java
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import 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;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.CommonConverters;
-import com.google.gerrit.server.args4j.TimestampHandler;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.List;
-import org.eclipse.jgit.lib.ReflogEntry;
-import org.eclipse.jgit.lib.ReflogReader;
-import org.eclipse.jgit.lib.Repository;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class GetReflog implements RestReadView<BranchResource> {
-  private static final Logger log = LoggerFactory.getLogger(GetReflog.class);
-
-  private final GitRepositoryManager repoManager;
-
-  @Option(
-    name = "--limit",
-    aliases = {"-n"},
-    metaVar = "CNT",
-    usage = "maximum number of reflog entries to list"
-  )
-  public GetReflog setLimit(int limit) {
-    this.limit = limit;
-    return this;
-  }
-
-  @Option(
-    name = "--from",
-    metaVar = "TIMESTAMP",
-    usage =
-        "timestamp from which the reflog entries should be listed (UTC, format: "
-            + TimestampHandler.TIMESTAMP_FORMAT
-            + ")"
-  )
-  public GetReflog setFrom(Timestamp from) {
-    this.from = from;
-    return this;
-  }
-
-  @Option(
-    name = "--to",
-    metaVar = "TIMESTAMP",
-    usage =
-        "timestamp until which the reflog entries should be listed (UTC, format: "
-            + TimestampHandler.TIMESTAMP_FORMAT
-            + ")"
-  )
-  public GetReflog setTo(Timestamp to) {
-    this.to = to;
-    return this;
-  }
-
-  private int limit;
-  private Timestamp from;
-  private Timestamp to;
-
-  @Inject
-  public GetReflog(GitRepositoryManager repoManager) {
-    this.repoManager = repoManager;
-  }
-
-  @Override
-  public List<ReflogEntryInfo> apply(BranchResource rsrc) throws RestApiException, IOException {
-    if (!rsrc.getControl().isOwner()) {
-      throw new AuthException("not project owner");
-    }
-
-    try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
-      ReflogReader r;
-      try {
-        r = repo.getReflogReader(rsrc.getRef());
-      } catch (UnsupportedOperationException e) {
-        String msg = "reflog not supported on repo " + rsrc.getNameKey().get();
-        log.error(msg);
-        throw new MethodNotAllowedException(msg);
-      }
-      if (r == null) {
-        throw new ResourceNotFoundException(rsrc.getRef());
-      }
-      List<ReflogEntry> entries;
-      if (from == null && to == null) {
-        entries = limit > 0 ? r.getReverseEntries(limit) : r.getReverseEntries();
-      } else {
-        entries = limit > 0 ? new ArrayList<>(limit) : new ArrayList<>();
-        for (ReflogEntry e : r.getReverseEntries()) {
-          Timestamp timestamp = new Timestamp(e.getWho().getWhen().getTime());
-          if ((from == null || from.before(timestamp)) && (to == null || to.after(timestamp))) {
-            entries.add(e);
-          }
-          if (limit > 0 && entries.size() >= limit) {
-            break;
-          }
-        }
-      }
-      return Lists.transform(entries, e -> newReflogEntryInfo(e));
-    }
-  }
-
-  private ReflogEntryInfo newReflogEntryInfo(ReflogEntry e) {
-    return new ReflogEntryInfo(
-        e.getOldId().getName(),
-        e.getNewId().getName(),
-        CommonConverters.toGitPerson(e.getWho()),
-        e.getComment());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetStatistics.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetStatistics.java
deleted file mode 100644
index 36d558c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetStatistics.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.api.GarbageCollectCommand;
-import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.api.errors.GitAPIException;
-import org.eclipse.jgit.api.errors.JGitInternalException;
-import org.eclipse.jgit.lib.Repository;
-
-@RequiresCapability(GlobalCapability.RUN_GC)
-@Singleton
-public class GetStatistics implements RestReadView<ProjectResource> {
-
-  private final GitRepositoryManager repoManager;
-
-  @Inject
-  GetStatistics(GitRepositoryManager repoManager) {
-    this.repoManager = repoManager;
-  }
-
-  @Override
-  public RepositoryStatistics apply(ProjectResource rsrc)
-      throws ResourceNotFoundException, ResourceConflictException {
-    try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
-      GarbageCollectCommand gc = Git.wrap(repo).gc();
-      return new RepositoryStatistics(gc.getStatistics());
-    } catch (GitAPIException | JGitInternalException e) {
-      throw new ResourceConflictException(e.getMessage());
-    } catch (IOException e) {
-      throw new ResourceNotFoundException(rsrc.getName());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetTag.java
deleted file mode 100644
index a94d17e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetTag.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.api.projects.TagInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetTag implements RestReadView<TagResource> {
-
-  @Override
-  public TagInfo apply(TagResource resource) {
-    return resource.getTagInfo();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/Index.java
deleted file mode 100644
index 8c8314b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/Index.java
+++ /dev/null
@@ -1,71 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
-
-import com.google.common.io.ByteStreams;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.git.MultiProgressMonitor;
-import com.google.gerrit.server.git.MultiProgressMonitor.Task;
-import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.index.change.AllChangesIndexer;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.concurrent.Future;
-import org.eclipse.jgit.util.io.NullOutputStream;
-
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@Singleton
-public class Index implements RestModifyView<ProjectResource, ProjectInput> {
-
-  private final Provider<AllChangesIndexer> allChangesIndexerProvider;
-  private final ChangeIndexer indexer;
-  private final ListeningExecutorService executor;
-
-  @Inject
-  Index(
-      Provider<AllChangesIndexer> allChangesIndexerProvider,
-      ChangeIndexer indexer,
-      @IndexExecutor(BATCH) ListeningExecutorService executor) {
-    this.allChangesIndexerProvider = allChangesIndexerProvider;
-    this.indexer = indexer;
-    this.executor = executor;
-  }
-
-  @Override
-  public Response.Accepted apply(ProjectResource resource, ProjectInput input) {
-    Project.NameKey project = resource.getNameKey();
-    Task mpt =
-        new MultiProgressMonitor(ByteStreams.nullOutputStream(), "Reindexing project")
-            .beginSubTask("", MultiProgressMonitor.UNKNOWN);
-    AllChangesIndexer allChangesIndexer = allChangesIndexerProvider.get();
-    allChangesIndexer.setVerboseOut(NullOutputStream.INSTANCE);
-    // The REST call is just a trigger for async reindexing, so it is safe to ignore the future's
-    // return value.
-    @SuppressWarnings("unused")
-    Future<Void> ignored =
-        executor.submit(allChangesIndexer.reindexProject(indexer, project, mpt, mpt));
-    return Response.accepted("Project " + project + " submitted for reindexing");
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
deleted file mode 100644
index b2edc6b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
+++ /dev/null
@@ -1,256 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Sets;
-import com.google.gerrit.extensions.api.projects.BranchInfo;
-import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
-import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.WebLinks;
-import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Set;
-import java.util.TreeMap;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.kohsuke.args4j.Option;
-
-public class ListBranches implements RestReadView<ProjectResource> {
-  private final GitRepositoryManager repoManager;
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> user;
-  private final DynamicMap<RestView<BranchResource>> branchViews;
-  private final UiActions uiActions;
-  private final WebLinks webLinks;
-
-  @Option(
-    name = "--limit",
-    aliases = {"-n"},
-    metaVar = "CNT",
-    usage = "maximum number of branches to list"
-  )
-  public void setLimit(int limit) {
-    this.limit = limit;
-  }
-
-  @Option(
-    name = "--start",
-    aliases = {"-S", "-s"},
-    metaVar = "CNT",
-    usage = "number of branches to skip"
-  )
-  public void setStart(int start) {
-    this.start = start;
-  }
-
-  @Option(
-    name = "--match",
-    aliases = {"-m"},
-    metaVar = "MATCH",
-    usage = "match branches substring"
-  )
-  public void setMatchSubstring(String matchSubstring) {
-    this.matchSubstring = matchSubstring;
-  }
-
-  @Option(
-    name = "--regex",
-    aliases = {"-r"},
-    metaVar = "REGEX",
-    usage = "match branches regex"
-  )
-  public void setMatchRegex(String matchRegex) {
-    this.matchRegex = matchRegex;
-  }
-
-  private int limit;
-  private int start;
-  private String matchSubstring;
-  private String matchRegex;
-
-  @Inject
-  public ListBranches(
-      GitRepositoryManager repoManager,
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> user,
-      DynamicMap<RestView<BranchResource>> branchViews,
-      UiActions uiActions,
-      WebLinks webLinks) {
-    this.repoManager = repoManager;
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-    this.branchViews = branchViews;
-    this.uiActions = uiActions;
-    this.webLinks = webLinks;
-  }
-
-  public ListBranches request(ListRefsRequest<BranchInfo> request) {
-    this.setLimit(request.getLimit());
-    this.setStart(request.getStart());
-    this.setMatchSubstring(request.getSubstring());
-    this.setMatchRegex(request.getRegex());
-    return this;
-  }
-
-  @Override
-  public List<BranchInfo> apply(ProjectResource rsrc)
-      throws ResourceNotFoundException, IOException, BadRequestException,
-          PermissionBackendException {
-    return new RefFilter<BranchInfo>(Constants.R_HEADS)
-        .subString(matchSubstring)
-        .regex(matchRegex)
-        .start(start)
-        .limit(limit)
-        .filter(allBranches(rsrc));
-  }
-
-  BranchInfo toBranchInfo(BranchResource rsrc)
-      throws IOException, ResourceNotFoundException, PermissionBackendException {
-    try (Repository db = repoManager.openRepository(rsrc.getNameKey())) {
-      Ref r = db.exactRef(rsrc.getRef());
-      if (r == null) {
-        throw new ResourceNotFoundException();
-      }
-      return toBranchInfo(rsrc, ImmutableList.of(r)).get(0);
-    } catch (RepositoryNotFoundException noRepo) {
-      throw new ResourceNotFoundException();
-    }
-  }
-
-  private List<BranchInfo> allBranches(ProjectResource rsrc)
-      throws IOException, ResourceNotFoundException, PermissionBackendException {
-    List<Ref> refs;
-    try (Repository db = repoManager.openRepository(rsrc.getNameKey())) {
-      Collection<Ref> heads = db.getRefDatabase().getRefs(Constants.R_HEADS).values();
-      refs = new ArrayList<>(heads.size() + 3);
-      refs.addAll(heads);
-      refs.addAll(
-          db.getRefDatabase()
-              .exactRef(Constants.HEAD, RefNames.REFS_CONFIG, RefNames.REFS_USERS_DEFAULT)
-              .values());
-    } catch (RepositoryNotFoundException noGitRepository) {
-      throw new ResourceNotFoundException();
-    }
-    return toBranchInfo(rsrc, refs);
-  }
-
-  private List<BranchInfo> toBranchInfo(ProjectResource rsrc, List<Ref> refs)
-      throws PermissionBackendException {
-    Set<String> targets = Sets.newHashSetWithExpectedSize(1);
-    for (Ref ref : refs) {
-      if (ref.isSymbolic()) {
-        targets.add(ref.getTarget().getName());
-      }
-    }
-
-    ProjectControl pctl = rsrc.getControl();
-    PermissionBackend.ForProject perm = permissionBackend.user(user).project(rsrc.getNameKey());
-    List<BranchInfo> branches = new ArrayList<>(refs.size());
-    for (Ref ref : refs) {
-      if (ref.isSymbolic()) {
-        // A symbolic reference to another branch, instead of
-        // showing the resolved value, show the name it references.
-        //
-        String target = ref.getTarget().getName();
-        if (!perm.ref(target).test(RefPermission.READ)) {
-          continue;
-        }
-        if (target.startsWith(Constants.R_HEADS)) {
-          target = target.substring(Constants.R_HEADS.length());
-        }
-
-        BranchInfo b = new BranchInfo();
-        b.ref = ref.getName();
-        b.revision = target;
-        branches.add(b);
-
-        if (!Constants.HEAD.equals(ref.getName())) {
-          b.canDelete = perm.ref(ref.getName()).testOrFalse(RefPermission.DELETE) ? true : null;
-        }
-        continue;
-      }
-
-      if (perm.ref(ref.getName()).test(RefPermission.READ)) {
-        branches.add(createBranchInfo(perm.ref(ref.getName()), ref, pctl, targets));
-      }
-    }
-    Collections.sort(branches, new BranchComparator());
-    return branches;
-  }
-
-  private static class BranchComparator implements Comparator<BranchInfo> {
-    @Override
-    public int compare(BranchInfo a, BranchInfo b) {
-      return ComparisonChain.start()
-          .compareTrueFirst(isHead(a), isHead(b))
-          .compareTrueFirst(isConfig(a), isConfig(b))
-          .compare(a.ref, b.ref)
-          .result();
-    }
-
-    private static boolean isHead(BranchInfo i) {
-      return Constants.HEAD.equals(i.ref);
-    }
-
-    private static boolean isConfig(BranchInfo i) {
-      return RefNames.REFS_CONFIG.equals(i.ref);
-    }
-  }
-
-  private BranchInfo createBranchInfo(
-      PermissionBackend.ForRef perm, Ref ref, ProjectControl pctl, Set<String> targets) {
-    BranchInfo info = new BranchInfo();
-    info.ref = ref.getName();
-    info.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
-    info.canDelete =
-        !targets.contains(ref.getName()) && perm.testOrFalse(RefPermission.DELETE) ? true : null;
-
-    BranchResource rsrc = new BranchResource(pctl, ref);
-    for (UiAction.Description d : uiActions.from(branchViews, rsrc)) {
-      if (info.actions == null) {
-        info.actions = new TreeMap<>();
-      }
-      info.actions.put(d.getId(), new ActionInfo(d));
-    }
-
-    List<WebLinkInfo> links = webLinks.getBranchLinks(pctl.getProject().getName(), ref.getName());
-    info.webLinks = links.isEmpty() ? null : links;
-    return info;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
deleted file mode 100644
index e5fe37d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
+++ /dev/null
@@ -1,145 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.Multimap;
-import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.kohsuke.args4j.Option;
-
-public class ListChildProjects implements RestReadView<ProjectResource> {
-
-  @Option(name = "--recursive", usage = "to list child projects recursively")
-  private boolean recursive;
-
-  private final ProjectCache projectCache;
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> user;
-  private final AllProjectsName allProjects;
-  private final ProjectJson json;
-
-  @Inject
-  ListChildProjects(
-      ProjectCache projectCache,
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> user,
-      AllProjectsName allProjectsName,
-      ProjectJson json) {
-    this.projectCache = projectCache;
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-    this.allProjects = allProjectsName;
-    this.json = json;
-  }
-
-  public void setRecursive(boolean recursive) {
-    this.recursive = recursive;
-  }
-
-  @Override
-  public List<ProjectInfo> apply(ProjectResource rsrc) throws PermissionBackendException {
-    if (recursive) {
-      return recursiveChildProjects(rsrc.getNameKey());
-    }
-    return directChildProjects(rsrc.getNameKey());
-  }
-
-  private List<ProjectInfo> directChildProjects(Project.NameKey parent)
-      throws PermissionBackendException {
-    Map<Project.NameKey, Project> children = new HashMap<>();
-    for (Project.NameKey name : projectCache.all()) {
-      ProjectState c = projectCache.get(name);
-      if (c != null && parent.equals(c.getProject().getParent(allProjects))) {
-        children.put(c.getNameKey(), c.getProject());
-      }
-    }
-    return permissionBackend
-        .user(user)
-        .filter(ProjectPermission.ACCESS, children.keySet())
-        .stream()
-        .sorted()
-        .map((p) -> json.format(children.get(p)))
-        .collect(toList());
-  }
-
-  private List<ProjectInfo> recursiveChildProjects(Project.NameKey parent)
-      throws PermissionBackendException {
-    Map<Project.NameKey, Project> projects = readAllProjects();
-    Multimap<Project.NameKey, Project.NameKey> children = parentToChildren(projects);
-    PermissionBackend.WithUser perm = permissionBackend.user(user);
-
-    List<ProjectInfo> results = new ArrayList<>();
-    depthFirstFormat(results, perm, projects, children, parent);
-    return results;
-  }
-
-  private Map<Project.NameKey, Project> readAllProjects() {
-    Map<Project.NameKey, Project> projects = new HashMap<>();
-    for (Project.NameKey name : projectCache.all()) {
-      ProjectState c = projectCache.get(name);
-      if (c != null) {
-        projects.put(c.getNameKey(), c.getProject());
-      }
-    }
-    return projects;
-  }
-
-  /** Map of parent project to direct child. */
-  private Multimap<Project.NameKey, Project.NameKey> parentToChildren(
-      Map<Project.NameKey, Project> projects) {
-    Multimap<Project.NameKey, Project.NameKey> m = ArrayListMultimap.create();
-    for (Map.Entry<Project.NameKey, Project> e : projects.entrySet()) {
-      if (!allProjects.equals(e.getKey())) {
-        m.put(e.getValue().getParent(allProjects), e.getKey());
-      }
-    }
-    return m;
-  }
-
-  private void depthFirstFormat(
-      List<ProjectInfo> results,
-      PermissionBackend.WithUser perm,
-      Map<Project.NameKey, Project> projects,
-      Multimap<Project.NameKey, Project.NameKey> children,
-      Project.NameKey parent)
-      throws PermissionBackendException {
-    List<Project.NameKey> canSee =
-        perm.filter(ProjectPermission.ACCESS, children.get(parent))
-            .stream()
-            .sorted()
-            .collect(toList());
-    children.removeAll(parent); // removing all entries prevents cycles.
-
-    for (Project.NameKey c : canSee) {
-      results.add(json.format(projects.get(c)));
-      depthFirstFormat(results, perm, projects, children, c);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
deleted file mode 100644
index 6960b47..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
+++ /dev/null
@@ -1,154 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
-
-import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.BlobBasedConfig;
-import org.eclipse.jgit.lib.FileMode;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.TreeWalk;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ListDashboards implements RestReadView<ProjectResource> {
-  private static final Logger log = LoggerFactory.getLogger(ListDashboards.class);
-
-  private final GitRepositoryManager gitManager;
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> user;
-
-  @Option(name = "--inherited", usage = "include inherited dashboards")
-  private boolean inherited;
-
-  @Inject
-  ListDashboards(
-      GitRepositoryManager gitManager,
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> user) {
-    this.gitManager = gitManager;
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-  }
-
-  @Override
-  public List<?> apply(ProjectResource rsrc)
-      throws ResourceNotFoundException, IOException, PermissionBackendException {
-    String project = rsrc.getName();
-    if (!inherited) {
-      return scan(rsrc.getProjectState(), project, true);
-    }
-
-    List<List<DashboardInfo>> all = new ArrayList<>();
-    boolean setDefault = true;
-    for (ProjectState ps : tree(rsrc)) {
-      List<DashboardInfo> list = scan(ps, project, setDefault);
-      for (DashboardInfo d : list) {
-        if (d.isDefault != null && Boolean.TRUE.equals(d.isDefault)) {
-          setDefault = false;
-        }
-      }
-      if (!list.isEmpty()) {
-        all.add(list);
-      }
-    }
-    return all;
-  }
-
-  private Collection<ProjectState> tree(ProjectResource rsrc) throws PermissionBackendException {
-    Map<Project.NameKey, ProjectState> tree = new LinkedHashMap<>();
-    for (ProjectState ps : rsrc.getProjectState().tree()) {
-      tree.put(ps.getNameKey(), ps);
-    }
-    tree.keySet()
-        .retainAll(permissionBackend.user(user).filter(ProjectPermission.ACCESS, tree.keySet()));
-    return tree.values();
-  }
-
-  private List<DashboardInfo> scan(ProjectState state, String project, boolean setDefault)
-      throws ResourceNotFoundException, IOException, PermissionBackendException {
-    PermissionBackend.ForProject perm = permissionBackend.user(user).project(state.getNameKey());
-    try (Repository git = gitManager.openRepository(state.getNameKey());
-        RevWalk rw = new RevWalk(git)) {
-      List<DashboardInfo> all = new ArrayList<>();
-      for (Ref ref : git.getRefDatabase().getRefs(REFS_DASHBOARDS).values()) {
-        if (perm.ref(ref.getName()).test(RefPermission.READ)) {
-          all.addAll(scanDashboards(state.getProject(), git, rw, ref, project, setDefault));
-        }
-      }
-      return all;
-    } catch (RepositoryNotFoundException e) {
-      throw new ResourceNotFoundException();
-    }
-  }
-
-  private List<DashboardInfo> scanDashboards(
-      Project definingProject,
-      Repository git,
-      RevWalk rw,
-      Ref ref,
-      String project,
-      boolean setDefault)
-      throws IOException {
-    List<DashboardInfo> list = new ArrayList<>();
-    try (TreeWalk tw = new TreeWalk(rw.getObjectReader())) {
-      tw.addTree(rw.parseTree(ref.getObjectId()));
-      tw.setRecursive(true);
-      while (tw.next()) {
-        if (tw.getFileMode(0) == FileMode.REGULAR_FILE) {
-          try {
-            list.add(
-                DashboardsCollection.parse(
-                    definingProject,
-                    ref.getName().substring(REFS_DASHBOARDS.length()),
-                    tw.getPathString(),
-                    new BlobBasedConfig(null, git, tw.getObjectId(0)),
-                    project,
-                    setDefault));
-          } catch (ConfigInvalidException e) {
-            log.warn(
-                String.format(
-                    "Cannot parse dashboard %s:%s:%s: %s",
-                    definingProject.getName(), ref.getName(), tw.getPathString(), e.getMessage()));
-          }
-        }
-      }
-    }
-    return list;
-  }
-}
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
deleted file mode 100644
index dc3610c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
+++ /dev/null
@@ -1,626 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.gerrit.extensions.client.ProjectState.HIDDEN;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.OutputFormat;
-import com.google.gerrit.server.StringUtil;
-import com.google.gerrit.server.WebLinks;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.GroupsCollection;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.util.RegexListSearcher;
-import com.google.gerrit.server.util.TreeFormatter;
-import com.google.gson.reflect.TypeToken;
-import com.google.inject.Inject;
-import java.io.BufferedWriter;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeMap;
-import java.util.TreeSet;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** List projects visible to the calling user. */
-public class ListProjects implements RestReadView<TopLevelResource> {
-  private static final Logger log = LoggerFactory.getLogger(ListProjects.class);
-
-  public enum FilterType {
-    CODE {
-      @Override
-      boolean matches(Repository git) throws IOException {
-        return !PERMISSIONS.matches(git);
-      }
-
-      @Override
-      boolean useMatch() {
-        return true;
-      }
-    },
-    PARENT_CANDIDATES {
-      @Override
-      boolean matches(Repository git) {
-        return true;
-      }
-
-      @Override
-      boolean useMatch() {
-        return false;
-      }
-    },
-    PERMISSIONS {
-      @Override
-      boolean matches(Repository git) throws IOException {
-        Ref head = git.getRefDatabase().exactRef(Constants.HEAD);
-        return head != null
-            && head.isSymbolic()
-            && RefNames.REFS_CONFIG.equals(head.getLeaf().getName());
-      }
-
-      @Override
-      boolean useMatch() {
-        return true;
-      }
-    },
-    ALL {
-      @Override
-      boolean matches(Repository git) {
-        return true;
-      }
-
-      @Override
-      boolean useMatch() {
-        return false;
-      }
-    };
-
-    abstract boolean matches(Repository git) throws IOException;
-
-    abstract boolean useMatch();
-  }
-
-  private final CurrentUser currentUser;
-  private final ProjectCache projectCache;
-  private final GroupsCollection groupsCollection;
-  private final GroupControl.Factory groupControlFactory;
-  private final GitRepositoryManager repoManager;
-  private final PermissionBackend permissionBackend;
-  private final ProjectNode.Factory projectNodeFactory;
-  private final WebLinks webLinks;
-
-  @Deprecated
-  @Option(name = "--format", usage = "(deprecated) output format")
-  private OutputFormat format = OutputFormat.TEXT;
-
-  @Option(
-    name = "--show-branch",
-    aliases = {"-b"},
-    usage = "displays the sha of each project in the specified branch"
-  )
-  public void addShowBranch(String branch) {
-    showBranch.add(branch);
-  }
-
-  @Option(
-    name = "--tree",
-    aliases = {"-t"},
-    usage =
-        "displays project inheritance in a tree-like format\n"
-            + "this option does not work together with the show-branch option"
-  )
-  public void setShowTree(boolean showTree) {
-    this.showTree = showTree;
-  }
-
-  @Option(name = "--type", usage = "type of project")
-  public void setFilterType(FilterType type) {
-    this.type = type;
-  }
-
-  @Option(
-    name = "--description",
-    aliases = {"-d"},
-    usage = "include description of project in list"
-  )
-  public void setShowDescription(boolean showDescription) {
-    this.showDescription = showDescription;
-  }
-
-  @Option(name = "--all", usage = "display all projects that are accessible by the calling user")
-  public void setAll(boolean all) {
-    this.all = all;
-  }
-
-  @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;
-  }
-
-  @Option(
-    name = "--prefix",
-    aliases = {"-p"},
-    metaVar = "PREFIX",
-    usage = "match project prefix"
-  )
-  public void setMatchPrefix(String matchPrefix) {
-    this.matchPrefix = matchPrefix;
-  }
-
-  @Option(
-    name = "--match",
-    aliases = {"-m"},
-    metaVar = "MATCH",
-    usage = "match project substring"
-  )
-  public void setMatchSubstring(String matchSubstring) {
-    this.matchSubstring = matchSubstring;
-  }
-
-  @Option(name = "-r", metaVar = "REGEX", usage = "match project regex")
-  public void setMatchRegex(String matchRegex) {
-    this.matchRegex = matchRegex;
-  }
-
-  @Option(
-    name = "--has-acl-for",
-    metaVar = "GROUP",
-    usage = "displays only projects on which access rights for this group are directly assigned"
-  )
-  public void setGroupUuid(AccountGroup.UUID groupUuid) {
-    this.groupUuid = groupUuid;
-  }
-
-  private final List<String> showBranch = new ArrayList<>();
-  private boolean showTree;
-  private FilterType type = FilterType.ALL;
-  private boolean showDescription;
-  private boolean all;
-  private int limit;
-  private int start;
-  private String matchPrefix;
-  private String matchSubstring;
-  private String matchRegex;
-  private AccountGroup.UUID groupUuid;
-
-  @Inject
-  protected ListProjects(
-      CurrentUser currentUser,
-      ProjectCache projectCache,
-      GroupsCollection groupsCollection,
-      GroupControl.Factory groupControlFactory,
-      GitRepositoryManager repoManager,
-      PermissionBackend permissionBackend,
-      ProjectNode.Factory projectNodeFactory,
-      WebLinks webLinks) {
-    this.currentUser = currentUser;
-    this.projectCache = projectCache;
-    this.groupsCollection = groupsCollection;
-    this.groupControlFactory = groupControlFactory;
-    this.repoManager = repoManager;
-    this.permissionBackend = permissionBackend;
-    this.projectNodeFactory = projectNodeFactory;
-    this.webLinks = webLinks;
-  }
-
-  public List<String> getShowBranch() {
-    return showBranch;
-  }
-
-  public boolean isShowTree() {
-    return showTree;
-  }
-
-  public boolean isShowDescription() {
-    return showDescription;
-  }
-
-  public OutputFormat getFormat() {
-    return format;
-  }
-
-  public ListProjects setFormat(OutputFormat fmt) {
-    format = fmt;
-    return this;
-  }
-
-  @Override
-  public Object apply(TopLevelResource resource)
-      throws BadRequestException, PermissionBackendException {
-    if (format == OutputFormat.TEXT) {
-      ByteArrayOutputStream buf = new ByteArrayOutputStream();
-      display(buf);
-      return BinaryResult.create(buf.toByteArray())
-          .setContentType("text/plain")
-          .setCharacterEncoding(UTF_8);
-    }
-    return apply();
-  }
-
-  public SortedMap<String, ProjectInfo> apply()
-      throws BadRequestException, PermissionBackendException {
-    format = OutputFormat.JSON;
-    return display(null);
-  }
-
-  public SortedMap<String, ProjectInfo> display(@Nullable OutputStream displayOutputStream)
-      throws BadRequestException, PermissionBackendException {
-    if (groupUuid != null) {
-      try {
-        if (!groupControlFactory.controlFor(groupUuid).isVisible()) {
-          return Collections.emptySortedMap();
-        }
-      } catch (NoSuchGroupException ex) {
-        return Collections.emptySortedMap();
-      }
-    }
-
-    PrintWriter stdout = null;
-    if (displayOutputStream != null) {
-      stdout =
-          new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
-    }
-
-    if (type == FilterType.PARENT_CANDIDATES) {
-      // Historically, PARENT_CANDIDATES implied showDescription.
-      showDescription = true;
-    }
-
-    int foundIndex = 0;
-    int found = 0;
-    TreeMap<String, ProjectInfo> output = new TreeMap<>();
-    Map<String, String> hiddenNames = new HashMap<>();
-    Map<Project.NameKey, Boolean> accessibleParents = new HashMap<>();
-    PermissionBackend.WithUser perm = permissionBackend.user(currentUser);
-    final TreeMap<Project.NameKey, ProjectNode> treeMap = new TreeMap<>();
-    try {
-      for (Project.NameKey projectName : filter(perm)) {
-        final ProjectState e = projectCache.get(projectName);
-        if (e == null || (!all && e.getProject().getState() == HIDDEN)) {
-          // If we can't get it from the cache, pretend its not present.
-          // If all wasn't selected, and its HIDDEN, pretend its not present.
-          continue;
-        }
-
-        final ProjectControl pctl = e.controlFor(currentUser);
-        if (groupUuid != null
-            && !pctl.getProjectState()
-                .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));
-          continue;
-        }
-
-        info.name = projectName.get();
-        if (showTree && format.isJson()) {
-          ProjectState parent = Iterables.getFirst(e.parents(), null);
-          if (parent != null) {
-            if (isParentAccessible(accessibleParents, perm, parent)) {
-              info.parent = parent.getName();
-            } else {
-              info.parent = hiddenNames.get(parent.getName());
-              if (info.parent == null) {
-                info.parent = "?-" + (hiddenNames.size() + 1);
-                hiddenNames.put(parent.getName(), info.parent);
-              }
-            }
-          }
-        }
-
-        if (showDescription) {
-          info.description = Strings.emptyToNull(e.getProject().getDescription());
-        }
-        info.state = e.getProject().getState();
-
-        try {
-          if (!showBranch.isEmpty()) {
-            try (Repository git = repoManager.openRepository(projectName)) {
-              if (!type.matches(git)) {
-                continue;
-              }
-
-              List<Ref> refs = getBranchRefs(projectName, pctl);
-              if (!hasValidRef(refs)) {
-                continue;
-              }
-
-              for (int i = 0; i < showBranch.size(); i++) {
-                Ref ref = refs.get(i);
-                if (ref != null && ref.getObjectId() != null) {
-                  if (info.branches == null) {
-                    info.branches = new LinkedHashMap<>();
-                  }
-                  info.branches.put(showBranch.get(i), ref.getObjectId().name());
-                }
-              }
-            }
-          } else if (!showTree && type.useMatch()) {
-            try (Repository git = repoManager.openRepository(projectName)) {
-              if (!type.matches(git)) {
-                continue;
-              }
-            }
-          }
-        } catch (RepositoryNotFoundException err) {
-          // If the Git repository is gone, the project doesn't actually exist anymore.
-          continue;
-        } catch (IOException err) {
-          log.warn("Unexpected error reading " + projectName, err);
-          continue;
-        }
-
-        if (type != FilterType.PARENT_CANDIDATES) {
-          List<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
-          info.webLinks = links.isEmpty() ? null : links;
-        }
-
-        if (foundIndex++ < start) {
-          continue;
-        }
-        if (limit > 0 && ++found > limit) {
-          break;
-        }
-
-        if (stdout == null || format.isJson()) {
-          output.put(info.name, info);
-          continue;
-        }
-
-        if (!showBranch.isEmpty()) {
-          for (String name : showBranch) {
-            String ref = info.branches != null ? info.branches.get(name) : null;
-            if (ref == null) {
-              // Print stub (forty '-' symbols)
-              ref = "----------------------------------------";
-            }
-            stdout.print(ref);
-            stdout.print(' ');
-          }
-        }
-        stdout.print(info.name);
-
-        if (info.description != null) {
-          // We still want to list every project as one-liners, hence escaping \n.
-          stdout.print(" - " + StringUtil.escapeString(info.description));
-        }
-        stdout.print('\n');
-      }
-
-      for (ProjectInfo info : output.values()) {
-        info.id = Url.encode(info.name);
-        info.name = null;
-      }
-      if (stdout == null) {
-        return output;
-      } else if (format.isJson()) {
-        format
-            .newGson()
-            .toJson(output, new TypeToken<Map<String, ProjectInfo>>() {}.getType(), stdout);
-        stdout.print('\n');
-      } else if (showTree && treeMap.size() > 0) {
-        printProjectTree(stdout, treeMap);
-      }
-      return null;
-    } finally {
-      if (stdout != null) {
-        stdout.flush();
-      }
-    }
-  }
-
-  private Collection<Project.NameKey> filter(PermissionBackend.WithUser perm)
-      throws BadRequestException, PermissionBackendException {
-    Collection<Project.NameKey> matches = Lists.newArrayList(scan());
-    if (type == FilterType.PARENT_CANDIDATES) {
-      matches = parentsOf(matches);
-    }
-    return perm.filter(ProjectPermission.ACCESS, matches).stream().sorted().collect(toList());
-  }
-
-  private Collection<Project.NameKey> parentsOf(Collection<Project.NameKey> matches) {
-    Set<Project.NameKey> parents = new HashSet<>();
-    for (Project.NameKey p : matches) {
-      ProjectState ps = projectCache.get(p);
-      if (ps != null) {
-        Project.NameKey parent = ps.getProject().getParent();
-        if (parent != null) {
-          if (projectCache.get(parent) != null) {
-            parents.add(parent);
-          } else {
-            log.warn(
-                String.format(
-                    "parent project %s of project %s not found", parent.get(), ps.getName()));
-          }
-        }
-      }
-    }
-    return parents;
-  }
-
-  private boolean isParentAccessible(
-      Map<Project.NameKey, Boolean> checked, PermissionBackend.WithUser perm, ProjectState p)
-      throws PermissionBackendException {
-    Project.NameKey name = p.getNameKey();
-    Boolean b = checked.get(name);
-    if (b == null) {
-      try {
-        perm.project(name).check(ProjectPermission.ACCESS);
-        b = true;
-      } catch (AuthException denied) {
-        b = false;
-      }
-      checked.put(name, b);
-    }
-    return b;
-  }
-
-  private Iterable<Project.NameKey> scan() throws BadRequestException {
-    if (matchPrefix != null) {
-      checkMatchOptions(matchSubstring == null && matchRegex == null);
-      return projectCache.byName(matchPrefix);
-    } else if (matchSubstring != null) {
-      checkMatchOptions(matchPrefix == null && matchRegex == null);
-      return Iterables.filter(
-          projectCache.all(),
-          p -> p.get().toLowerCase(Locale.US).contains(matchSubstring.toLowerCase(Locale.US)));
-    } else if (matchRegex != null) {
-      checkMatchOptions(matchPrefix == null && matchSubstring == null);
-      RegexListSearcher<Project.NameKey> searcher;
-      try {
-        searcher =
-            new RegexListSearcher<Project.NameKey>(matchRegex) {
-              @Override
-              public String apply(Project.NameKey in) {
-                return in.get();
-              }
-            };
-      } catch (IllegalArgumentException e) {
-        throw new BadRequestException(e.getMessage());
-      }
-      return searcher.search(ImmutableList.copyOf(projectCache.all()));
-    } else {
-      return projectCache.all();
-    }
-  }
-
-  private static void checkMatchOptions(boolean cond) throws BadRequestException {
-    if (!cond) {
-      throw new BadRequestException("specify exactly one of p/m/r");
-    }
-  }
-
-  private void printProjectTree(
-      final PrintWriter stdout, TreeMap<Project.NameKey, ProjectNode> treeMap) {
-    final SortedSet<ProjectNode> sortedNodes = new TreeSet<>();
-
-    // Builds the inheritance tree using a list.
-    //
-    for (ProjectNode key : treeMap.values()) {
-      if (key.isAllProjects()) {
-        sortedNodes.add(key);
-        continue;
-      }
-
-      ProjectNode node = treeMap.get(key.getParentName());
-      if (node != null) {
-        node.addChild(key);
-      } else {
-        sortedNodes.add(key);
-      }
-    }
-
-    final TreeFormatter treeFormatter = new TreeFormatter(stdout);
-    treeFormatter.printTree(sortedNodes);
-    stdout.flush();
-  }
-
-  private List<Ref> getBranchRefs(Project.NameKey projectName, ProjectControl projectControl) {
-    Ref[] result = new Ref[showBranch.size()];
-    try (Repository git = repoManager.openRepository(projectName)) {
-      PermissionBackend.ForProject perm = permissionBackend.user(currentUser).project(projectName);
-      for (int i = 0; i < showBranch.size(); i++) {
-        Ref ref = git.findRef(showBranch.get(i));
-        if (all && projectControl.isOwner()) {
-          result[i] = ref;
-        } else if (ref != null && ref.getObjectId() != null) {
-          try {
-            perm.ref(ref.getLeaf().getName()).check(RefPermission.READ);
-            result[i] = ref;
-          } catch (AuthException e) {
-            continue;
-          }
-        }
-      }
-    } catch (IOException | PermissionBackendException e) {
-      // Fall through and return what is available.
-    }
-    return Arrays.asList(result);
-  }
-
-  private static boolean hasValidRef(List<Ref> refs) {
-    for (Ref ref : refs) {
-      if (ref != null) {
-        return true;
-      }
-    }
-    return false;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
deleted file mode 100644
index d57234a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
+++ /dev/null
@@ -1,228 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
-import com.google.gerrit.extensions.api.projects.TagInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CommonConverters;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.WebLinks;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.VisibleRefFilter;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTag;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.kohsuke.args4j.Option;
-
-public class ListTags implements RestReadView<ProjectResource> {
-  private final GitRepositoryManager repoManager;
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> user;
-  private final VisibleRefFilter.Factory refFilterFactory;
-  private final WebLinks links;
-
-  @Option(
-    name = "--limit",
-    aliases = {"-n"},
-    metaVar = "CNT",
-    usage = "maximum number of tags to list"
-  )
-  public void setLimit(int limit) {
-    this.limit = limit;
-  }
-
-  @Option(
-    name = "--start",
-    aliases = {"-S", "-s"},
-    metaVar = "CNT",
-    usage = "number of tags to skip"
-  )
-  public void setStart(int start) {
-    this.start = start;
-  }
-
-  @Option(
-    name = "--match",
-    aliases = {"-m"},
-    metaVar = "MATCH",
-    usage = "match tags substring"
-  )
-  public void setMatchSubstring(String matchSubstring) {
-    this.matchSubstring = matchSubstring;
-  }
-
-  @Option(
-    name = "--regex",
-    aliases = {"-r"},
-    metaVar = "REGEX",
-    usage = "match tags regex"
-  )
-  public void setMatchRegex(String matchRegex) {
-    this.matchRegex = matchRegex;
-  }
-
-  private int limit;
-  private int start;
-  private String matchSubstring;
-  private String matchRegex;
-
-  @Inject
-  public ListTags(
-      GitRepositoryManager repoManager,
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> user,
-      VisibleRefFilter.Factory refFilterFactory,
-      WebLinks webLinks) {
-    this.repoManager = repoManager;
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-    this.refFilterFactory = refFilterFactory;
-    this.links = webLinks;
-  }
-
-  public ListTags request(ListRefsRequest<TagInfo> request) {
-    this.setLimit(request.getLimit());
-    this.setStart(request.getStart());
-    this.setMatchSubstring(request.getSubstring());
-    this.setMatchRegex(request.getRegex());
-    return this;
-  }
-
-  @Override
-  public List<TagInfo> apply(ProjectResource resource)
-      throws IOException, ResourceNotFoundException, BadRequestException {
-    List<TagInfo> tags = new ArrayList<>();
-
-    PermissionBackend.ForProject perm = permissionBackend.user(user).project(resource.getNameKey());
-    try (Repository repo = getRepository(resource.getNameKey());
-        RevWalk rw = new RevWalk(repo)) {
-      Map<String, Ref> all =
-          visibleTags(
-              resource.getProjectState(), repo, repo.getRefDatabase().getRefs(Constants.R_TAGS));
-      for (Ref ref : all.values()) {
-        tags.add(createTagInfo(perm.ref(ref.getName()), ref, rw, resource.getNameKey(), links));
-      }
-    }
-
-    Collections.sort(
-        tags,
-        new Comparator<TagInfo>() {
-          @Override
-          public int compare(TagInfo a, TagInfo b) {
-            return a.ref.compareTo(b.ref);
-          }
-        });
-
-    return new RefFilter<TagInfo>(Constants.R_TAGS)
-        .start(start)
-        .limit(limit)
-        .subString(matchSubstring)
-        .regex(matchRegex)
-        .filter(tags);
-  }
-
-  public TagInfo get(ProjectResource resource, IdString id)
-      throws ResourceNotFoundException, IOException {
-    try (Repository repo = getRepository(resource.getNameKey());
-        RevWalk rw = new RevWalk(repo)) {
-      String tagName = id.get();
-      if (!tagName.startsWith(Constants.R_TAGS)) {
-        tagName = Constants.R_TAGS + tagName;
-      }
-      Ref ref = repo.getRefDatabase().exactRef(tagName);
-      if (ref != null
-          && !visibleTags(resource.getProjectState(), repo, ImmutableMap.of(ref.getName(), ref))
-              .isEmpty()) {
-        return createTagInfo(
-            permissionBackend
-                .user(resource.getUser())
-                .project(resource.getNameKey())
-                .ref(ref.getName()),
-            ref,
-            rw,
-            resource.getNameKey(),
-            links);
-      }
-    }
-    throw new ResourceNotFoundException(id);
-  }
-
-  public static TagInfo createTagInfo(
-      PermissionBackend.ForRef perm,
-      Ref ref,
-      RevWalk rw,
-      Project.NameKey projectName,
-      WebLinks links)
-      throws MissingObjectException, IOException {
-    RevObject object = rw.parseAny(ref.getObjectId());
-    Boolean canDelete = perm.testOrFalse(RefPermission.DELETE) ? true : null;
-    List<WebLinkInfo> webLinks = links.getTagLinks(projectName.get(), ref.getName());
-    if (object instanceof RevTag) {
-      // Annotated or signed tag
-      RevTag tag = (RevTag) object;
-      PersonIdent tagger = tag.getTaggerIdent();
-      return new TagInfo(
-          ref.getName(),
-          tag.getName(),
-          tag.getObject().getName(),
-          tag.getFullMessage().trim(),
-          tagger != null ? CommonConverters.toGitPerson(tag.getTaggerIdent()) : null,
-          canDelete,
-          webLinks.isEmpty() ? null : webLinks);
-    }
-    // Lightweight tag
-    return new TagInfo(
-        ref.getName(),
-        ref.getObjectId().getName(),
-        canDelete,
-        webLinks.isEmpty() ? null : webLinks);
-  }
-
-  private Repository getRepository(Project.NameKey project)
-      throws ResourceNotFoundException, IOException {
-    try {
-      return repoManager.openRepository(project);
-    } catch (RepositoryNotFoundException noGitRepository) {
-      throw new ResourceNotFoundException();
-    }
-  }
-
-  private Map<String, Ref> visibleTags(ProjectState state, Repository repo, Map<String, Ref> tags) {
-    return refFilterFactory.create(state, repo).setShowMetadata(false).filter(tags, true);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
deleted file mode 100644
index d0753eb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
+++ /dev/null
@@ -1,105 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.gerrit.server.project.BranchResource.BRANCH_KIND;
-import static com.google.gerrit.server.project.ChildProjectResource.CHILD_PROJECT_KIND;
-import static com.google.gerrit.server.project.CommitResource.COMMIT_KIND;
-import static com.google.gerrit.server.project.DashboardResource.DASHBOARD_KIND;
-import static com.google.gerrit.server.project.FileResource.FILE_KIND;
-import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
-import static com.google.gerrit.server.project.TagResource.TAG_KIND;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.change.CherryPickCommit;
-
-public class Module extends RestApiModule {
-  @Override
-  protected void configure() {
-    bind(ProjectsCollection.class);
-    bind(DashboardsCollection.class);
-
-    DynamicMap.mapOf(binder(), PROJECT_KIND);
-    DynamicMap.mapOf(binder(), CHILD_PROJECT_KIND);
-    DynamicMap.mapOf(binder(), BRANCH_KIND);
-    DynamicMap.mapOf(binder(), DASHBOARD_KIND);
-    DynamicMap.mapOf(binder(), FILE_KIND);
-    DynamicMap.mapOf(binder(), COMMIT_KIND);
-    DynamicMap.mapOf(binder(), TAG_KIND);
-
-    put(PROJECT_KIND).to(PutProject.class);
-    get(PROJECT_KIND).to(GetProject.class);
-    get(PROJECT_KIND, "description").to(GetDescription.class);
-    put(PROJECT_KIND, "description").to(PutDescription.class);
-    delete(PROJECT_KIND, "description").to(PutDescription.class);
-
-    get(PROJECT_KIND, "access").to(GetAccess.class);
-    post(PROJECT_KIND, "access").to(SetAccess.class);
-    put(PROJECT_KIND, "access:review").to(CreateAccessChange.class);
-    post(PROJECT_KIND, "check.access").to(CheckAccess.class);
-
-    get(PROJECT_KIND, "parent").to(GetParent.class);
-    put(PROJECT_KIND, "parent").to(SetParent.class);
-
-    child(PROJECT_KIND, "children").to(ChildProjectsCollection.class);
-    get(CHILD_PROJECT_KIND).to(GetChildProject.class);
-
-    get(PROJECT_KIND, "HEAD").to(GetHead.class);
-    put(PROJECT_KIND, "HEAD").to(SetHead.class);
-
-    put(PROJECT_KIND, "ban").to(BanCommit.class);
-
-    get(PROJECT_KIND, "statistics.git").to(GetStatistics.class);
-    post(PROJECT_KIND, "gc").to(GarbageCollect.class);
-    post(PROJECT_KIND, "index").to(Index.class);
-
-    child(PROJECT_KIND, "branches").to(BranchesCollection.class);
-    put(BRANCH_KIND).to(PutBranch.class);
-    get(BRANCH_KIND).to(GetBranch.class);
-    delete(BRANCH_KIND).to(DeleteBranch.class);
-    post(PROJECT_KIND, "branches:delete").to(DeleteBranches.class);
-    factory(CreateBranch.Factory.class);
-    get(BRANCH_KIND, "mergeable").to(CheckMergeability.class);
-    factory(RefValidationHelper.Factory.class);
-    get(BRANCH_KIND, "reflog").to(GetReflog.class);
-    child(BRANCH_KIND, "files").to(FilesCollection.class);
-    get(FILE_KIND, "content").to(GetContent.class);
-
-    child(PROJECT_KIND, "commits").to(CommitsCollection.class);
-    get(COMMIT_KIND).to(GetCommit.class);
-    get(COMMIT_KIND, "in").to(CommitIncludedIn.class);
-    child(COMMIT_KIND, "files").to(FilesInCommitCollection.class);
-
-    child(PROJECT_KIND, "tags").to(TagsCollection.class);
-    get(TAG_KIND).to(GetTag.class);
-    put(TAG_KIND).to(PutTag.class);
-    delete(TAG_KIND).to(DeleteTag.class);
-    post(PROJECT_KIND, "tags:delete").to(DeleteTags.class);
-    factory(CreateTag.Factory.class);
-
-    child(PROJECT_KIND, "dashboards").to(DashboardsCollection.class);
-    get(DASHBOARD_KIND).to(GetDashboard.class);
-    put(DASHBOARD_KIND).to(SetDashboard.class);
-    delete(DASHBOARD_KIND).to(DeleteDashboard.class);
-    factory(CreateProject.Factory.class);
-
-    get(PROJECT_KIND, "config").to(GetConfig.class);
-    put(PROJECT_KIND, "config").to(PutConfig.class);
-    post(COMMIT_KIND, "cherrypick").to(CherryPickCommit.class);
-
-    factory(DeleteRef.Factory.class);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java
deleted file mode 100644
index 0f71ac8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java
+++ /dev/null
@@ -1,55 +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.project;
-
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.inject.Inject;
-import com.google.inject.servlet.RequestScoped;
-import java.util.HashMap;
-import java.util.Map;
-
-/** Caches {@link ProjectControl} objects for the current user of the request. */
-@RequestScoped
-public class PerRequestProjectControlCache {
-  private final ProjectCache projectCache;
-  private final CurrentUser user;
-  private final Map<Project.NameKey, ProjectControl> controls;
-
-  @Inject
-  PerRequestProjectControlCache(ProjectCache projectCache, CurrentUser userProvider) {
-    this.projectCache = projectCache;
-    this.user = userProvider;
-    this.controls = new HashMap<>();
-  }
-
-  ProjectControl get(Project.NameKey nameKey) throws NoSuchProjectException {
-    ProjectControl ctl = controls.get(nameKey);
-    if (ctl == null) {
-      ProjectState p = projectCache.get(nameKey);
-      if (p == null) {
-        throw new NoSuchProjectException(nameKey);
-      }
-      ctl = p.controlFor(user);
-      controls.put(nameKey, ctl);
-    }
-    return ctl;
-  }
-
-  public void evict(Project project) {
-    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
deleted file mode 100644
index 65c7315..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import java.io.IOException;
-import java.util.Set;
-
-/** Cache of project information, including access rights. */
-public interface ProjectCache {
-  /** @return the parent state for all projects on this server. */
-  ProjectState getAllProjects();
-
-  /** @return the project state of the project storing meta data for all users. */
-  ProjectState getAllUsers();
-
-  /**
-   * Get the cached data for a project by its unique name.
-   *
-   * @param projectName name of the project.
-   * @return the cached data; null if no such project exists or a error occurred.
-   * @see #checkedGet(com.google.gerrit.reviewdb.client.Project.NameKey)
-   */
-  ProjectState get(Project.NameKey projectName);
-
-  /**
-   * Get the cached data for a project by its unique name.
-   *
-   * @param projectName name of the project.
-   * @throws IOException when there was an error.
-   * @return the cached data; null if no such project exists.
-   */
-  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. */
-  void evict(Project.NameKey p);
-
-  /**
-   * Remove information about the given project from the cache. It will no longer be returned from
-   * {@link #all()}.
-   */
-  void remove(Project p);
-
-  /** @return sorted iteration of projects. */
-  Iterable<Project.NameKey> all();
-
-  /**
-   * @return estimated set of relevant groups extracted from hot project access rules. If the cache
-   *     is cold or too small for the entire project set of the server, this set may be incomplete.
-   */
-  Set<AccountGroup.UUID> guessRelevantGroupUUIDs();
-
-  /**
-   * Filter the set of registered project names by common prefix.
-   *
-   * @param prefix common prefix.
-   * @return sorted iteration of projects sharing the same prefix.
-   */
-  Iterable<Project.NameKey> byName(String prefix);
-
-  /** Notify the cache that a new project was constructed. */
-  void onCreateProject(Project.NameKey newProjectName);
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
deleted file mode 100644
index 6312a45..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ /dev/null
@@ -1,325 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.base.Throwables;
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.common.collect.Sets;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.name.Named;
-import java.io.IOException;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.NoSuchElementException;
-import java.util.Objects;
-import java.util.Set;
-import java.util.SortedSet;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReentrantLock;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Cache of project information, including access rights. */
-@Singleton
-public class ProjectCacheImpl implements ProjectCache {
-  private static final Logger log = LoggerFactory.getLogger(ProjectCacheImpl.class);
-
-  private static final String CACHE_NAME = "projects";
-  private static final String CACHE_LIST = "project_list";
-
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        cache(CACHE_NAME, String.class, ProjectState.class).loader(Loader.class);
-
-        cache(CACHE_LIST, ListKey.class, new TypeLiteral<SortedSet<Project.NameKey>>() {})
-            .maximumWeight(1)
-            .loader(Lister.class);
-
-        bind(ProjectCacheImpl.class);
-        bind(ProjectCache.class).to(ProjectCacheImpl.class);
-
-        install(
-            new LifecycleModule() {
-              @Override
-              protected void configure() {
-                listener().to(ProjectCacheWarmer.class);
-                listener().to(ProjectCacheClock.class);
-              }
-            });
-      }
-    };
-  }
-
-  private final AllProjectsName allProjectsName;
-  private final AllUsersName allUsersName;
-  private final LoadingCache<String, ProjectState> byName;
-  private final LoadingCache<ListKey, SortedSet<Project.NameKey>> list;
-  private final Lock listLock;
-  private final ProjectCacheClock clock;
-
-  @Inject
-  ProjectCacheImpl(
-      final AllProjectsName allProjectsName,
-      final AllUsersName allUsersName,
-      @Named(CACHE_NAME) LoadingCache<String, ProjectState> byName,
-      @Named(CACHE_LIST) LoadingCache<ListKey, SortedSet<Project.NameKey>> list,
-      ProjectCacheClock clock) {
-    this.allProjectsName = allProjectsName;
-    this.allUsersName = allUsersName;
-    this.byName = byName;
-    this.list = list;
-    this.listLock = new ReentrantLock(true /* fair */);
-    this.clock = clock;
-  }
-
-  @Override
-  public ProjectState getAllProjects() {
-    ProjectState state = get(allProjectsName);
-    if (state == null) {
-      // This should never occur, the server must have this
-      // project to process anything.
-      throw new IllegalStateException("Missing project " + allProjectsName);
-    }
-    return state;
-  }
-
-  @Override
-  public ProjectState getAllUsers() {
-    ProjectState state = get(allUsersName);
-    if (state == null) {
-      // This should never occur.
-      throw new IllegalStateException("Missing project " + allUsersName);
-    }
-    return state;
-  }
-
-  @Override
-  public ProjectState get(Project.NameKey projectName) {
-    try {
-      return checkedGet(projectName);
-    } catch (IOException e) {
-      return null;
-    }
-  }
-
-  @Override
-  public ProjectState checkedGet(Project.NameKey projectName) throws IOException {
-    if (projectName == null) {
-      return null;
-    }
-    try {
-      ProjectState state = byName.get(projectName.get());
-      if (state != null && state.needsRefresh(clock.read())) {
-        byName.invalidate(projectName.get());
-        state = byName.get(projectName.get());
-      }
-      return state;
-    } catch (ExecutionException e) {
-      if (!(e.getCause() instanceof RepositoryNotFoundException)) {
-        log.warn(String.format("Cannot read project %s", projectName.get()), e);
-        Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
-        throw new IOException(e);
-      }
-      return null;
-    }
-  }
-
-  @Override
-  public void evict(Project p) {
-    if (p != null) {
-      byName.invalidate(p.getNameKey().get());
-    }
-  }
-
-  /** Invalidate the cached information about the given project. */
-  @Override
-  public void evict(Project.NameKey p) {
-    if (p != null) {
-      byName.invalidate(p.get());
-    }
-  }
-
-  @Override
-  public void remove(Project p) {
-    listLock.lock();
-    try {
-      SortedSet<Project.NameKey> n = Sets.newTreeSet(list.get(ListKey.ALL));
-      n.remove(p.getNameKey());
-      list.put(ListKey.ALL, Collections.unmodifiableSortedSet(n));
-    } catch (ExecutionException e) {
-      log.warn("Cannot list available projects", e);
-    } finally {
-      listLock.unlock();
-    }
-    evict(p);
-  }
-
-  @Override
-  public void onCreateProject(Project.NameKey newProjectName) {
-    listLock.lock();
-    try {
-      SortedSet<Project.NameKey> n = Sets.newTreeSet(list.get(ListKey.ALL));
-      n.add(newProjectName);
-      list.put(ListKey.ALL, Collections.unmodifiableSortedSet(n));
-    } catch (ExecutionException e) {
-      log.warn("Cannot list available projects", e);
-    } finally {
-      listLock.unlock();
-    }
-  }
-
-  @Override
-  public SortedSet<Project.NameKey> all() {
-    try {
-      return list.get(ListKey.ALL);
-    } catch (ExecutionException e) {
-      log.warn("Cannot list available projects", e);
-      return Collections.emptySortedSet();
-    }
-  }
-
-  @Override
-  public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
-    return all()
-        .stream()
-        .map(n -> byName.getIfPresent(n.get()))
-        .filter(Objects::nonNull)
-        .flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
-        // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
-        // against them just in case there is a bug or corner case.
-        .filter(id -> id != null && id.get() != null)
-        .collect(toSet());
-  }
-
-  @Override
-  public Iterable<Project.NameKey> byName(String pfx) {
-    final Iterable<Project.NameKey> src;
-    try {
-      src = list.get(ListKey.ALL).tailSet(new Project.NameKey(pfx));
-    } catch (ExecutionException e) {
-      return Collections.emptyList();
-    }
-    return new Iterable<Project.NameKey>() {
-      @Override
-      public Iterator<Project.NameKey> iterator() {
-        return new Iterator<Project.NameKey>() {
-          private Iterator<Project.NameKey> itr = src.iterator();
-          private Project.NameKey next;
-
-          @Override
-          public boolean hasNext() {
-            if (next != null) {
-              return true;
-            }
-
-            if (!itr.hasNext()) {
-              return false;
-            }
-
-            Project.NameKey r = itr.next();
-            if (r.get().startsWith(pfx)) {
-              next = r;
-              return true;
-            }
-            itr = Collections.<Project.NameKey>emptyList().iterator();
-            return false;
-          }
-
-          @Override
-          public Project.NameKey next() {
-            if (!hasNext()) {
-              throw new NoSuchElementException();
-            }
-
-            Project.NameKey r = next;
-            next = null;
-            return r;
-          }
-
-          @Override
-          public void remove() {
-            throw new UnsupportedOperationException();
-          }
-        };
-      }
-    };
-  }
-
-  static class Loader extends CacheLoader<String, ProjectState> {
-    private final ProjectState.Factory projectStateFactory;
-    private final GitRepositoryManager mgr;
-    private final ProjectCacheClock clock;
-
-    @Inject
-    Loader(ProjectState.Factory psf, GitRepositoryManager g, ProjectCacheClock clock) {
-      projectStateFactory = psf;
-      mgr = g;
-      this.clock = clock;
-    }
-
-    @Override
-    public ProjectState load(String projectName) throws Exception {
-      long now = clock.read();
-      Project.NameKey key = new Project.NameKey(projectName);
-      try (Repository git = mgr.openRepository(key)) {
-        ProjectConfig cfg = new ProjectConfig(key);
-        cfg.load(git);
-
-        ProjectState state = projectStateFactory.create(cfg);
-        state.initLastCheck(now);
-        return state;
-      }
-    }
-  }
-
-  static class ListKey {
-    static final ListKey ALL = new ListKey();
-
-    private ListKey() {}
-  }
-
-  static class Lister extends CacheLoader<ListKey, SortedSet<Project.NameKey>> {
-    private final GitRepositoryManager mgr;
-
-    @Inject
-    Lister(GitRepositoryManager mgr) {
-      this.mgr = mgr;
-    }
-
-    @Override
-    public SortedSet<Project.NameKey> load(ListKey key) throws Exception {
-      return mgr.list();
-    }
-  }
-}
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
deleted file mode 100644
index 7a7418c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
+++ /dev/null
@@ -1,477 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.collect.ImmutableSet;
-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;
-import com.google.gerrit.metrics.Counter0;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupMembership;
-import com.google.gerrit.server.config.GitReceivePackGroups;
-import com.google.gerrit.server.config.GitUploadPackGroups;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.FailedPermissionBackend;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
-import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
-import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Access control management for a user accessing a project's data. */
-public class ProjectControl {
-  private static final Logger log = LoggerFactory.getLogger(ProjectControl.class);
-
-  public static class GenericFactory {
-    private final ProjectCache projectCache;
-
-    @Inject
-    GenericFactory(ProjectCache pc) {
-      projectCache = pc;
-    }
-
-    public ProjectControl controlFor(Project.NameKey nameKey, CurrentUser user)
-        throws NoSuchProjectException, IOException {
-      final ProjectState p = projectCache.checkedGet(nameKey);
-      if (p == null) {
-        throw new NoSuchProjectException(nameKey);
-      }
-      return p.controlFor(user);
-    }
-  }
-
-  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 {
-    ProjectControl create(CurrentUser who, ProjectState ps);
-  }
-
-  @Singleton
-  protected static class Metrics {
-    final Counter0 claCheckCount;
-
-    @Inject
-    Metrics(MetricMaker metricMaker) {
-      claCheckCount =
-          metricMaker.newCounter(
-              "license/cla_check_count",
-              new Description("Total number of CLA check requests").setRate().setUnit("requests"));
-    }
-  }
-
-  private final Set<AccountGroup.UUID> uploadGroups;
-  private final Set<AccountGroup.UUID> receiveGroups;
-  private final PermissionBackend.WithUser perm;
-  private final CurrentUser user;
-  private final ProjectState state;
-  private final CommitsCollection commits;
-  private final ChangeControl.Factory changeControlFactory;
-  private final PermissionCollection.Factory permissionFilter;
-
-  private List<SectionMatcher> allSections;
-  private Map<String, RefControl> refControls;
-  private Boolean declaredOwner;
-
-  @Inject
-  ProjectControl(
-      @GitUploadPackGroups Set<AccountGroup.UUID> uploadGroups,
-      @GitReceivePackGroups Set<AccountGroup.UUID> receiveGroups,
-      PermissionCollection.Factory permissionFilter,
-      CommitsCollection commits,
-      ChangeControl.Factory changeControlFactory,
-      PermissionBackend permissionBackend,
-      @Assisted CurrentUser who,
-      @Assisted ProjectState ps) {
-    this.changeControlFactory = changeControlFactory;
-    this.uploadGroups = uploadGroups;
-    this.receiveGroups = receiveGroups;
-    this.permissionFilter = permissionFilter;
-    this.commits = commits;
-    this.perm = permissionBackend.user(who);
-    user = who;
-    state = ps;
-  }
-
-  public 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 {
-    return changeControlFactory.create(
-        controlForRef(change.getDest()), db, change.getProject(), change.getId());
-  }
-
-  public ChangeControl controlFor(ChangeNotes notes) {
-    return changeControlFactory.create(controlForRef(notes.getChange().getDest()), notes);
-  }
-
-  public RefControl controlForRef(Branch.NameKey ref) {
-    return controlForRef(ref.get());
-  }
-
-  public RefControl controlForRef(String refName) {
-    if (refControls == null) {
-      refControls = new HashMap<>();
-    }
-    RefControl ctl = refControls.get(refName);
-    if (ctl == null) {
-      PermissionCollection relevant = permissionFilter.filter(access(), refName, user);
-      ctl = new RefControl(this, refName, relevant);
-      refControls.put(refName, ctl);
-    }
-    return ctl;
-  }
-
-  public CurrentUser getUser() {
-    return user;
-  }
-
-  public ProjectState getProjectState() {
-    return state;
-  }
-
-  public Project getProject() {
-    return state.getProject();
-  }
-
-  /** Is this user a project owner? */
-  public boolean isOwner() {
-    return (isDeclaredOwner() && !controlForRef("refs/*").isBlocked(Permission.OWNER)) || isAdmin();
-  }
-
-  /**
-   * @return {@code Capable.OK} if the user can upload to at least one reference. Does not check
-   *     Contributor Agreements.
-   */
-  public Capable canPushToAtLeastOneRef() {
-    if (!canPerformOnAnyRef(Permission.PUSH)
-        && !canPerformOnAnyRef(Permission.CREATE_TAG)
-        && !isOwner()) {
-      return new Capable("Upload denied for project '" + state.getName() + "'");
-    }
-    return Capable.OK;
-  }
-
-  /** Can the user run upload pack? */
-  private boolean canRunUploadPack() {
-    for (AccountGroup.UUID group : uploadGroups) {
-      if (match(group)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  /** Can the user run receive pack? */
-  private boolean canRunReceivePack() {
-    for (AccountGroup.UUID group : receiveGroups) {
-      if (match(group)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  private boolean allRefsAreVisible(Set<String> ignore) {
-    return user.isInternalUser() || canPerformOnAllRefs(Permission.READ, ignore);
-  }
-
-  /** Returns whether the project is hidden. */
-  private boolean isHidden() {
-    return getProject().getState().equals(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
-  }
-
-  private boolean canAddRefs() {
-    return (canPerformOnAnyRef(Permission.CREATE) || isAdmin());
-  }
-
-  private boolean canCreateChanges() {
-    for (SectionMatcher matcher : access()) {
-      AccessSection section = matcher.section;
-      if (section.getName().startsWith("refs/for/")) {
-        Permission permission = section.getPermission(Permission.PUSH);
-        if (permission != null && controlForRef(section.getName()).canPerform(Permission.PUSH)) {
-          return true;
-        }
-      }
-    }
-    return false;
-  }
-
-  boolean isAdmin() {
-    try {
-      perm.check(GlobalPermission.ADMINISTRATE_SERVER);
-      return true;
-    } catch (AuthException | PermissionBackendException e) {
-      return false;
-    }
-  }
-
-  private boolean isDeclaredOwner() {
-    if (declaredOwner == null) {
-      GroupMembership effectiveGroups = user.getEffectiveGroups();
-      declaredOwner = effectiveGroups.containsAnyOf(state.getAllOwners());
-    }
-    return declaredOwner;
-  }
-
-  private boolean canPerformOnAnyRef(String permissionName) {
-    for (SectionMatcher matcher : access()) {
-      AccessSection section = matcher.section;
-      Permission permission = section.getPermission(permissionName);
-      if (permission == null) {
-        continue;
-      }
-
-      for (PermissionRule rule : permission.getRules()) {
-        if (rule.isBlock() || rule.isDeny() || !match(rule)) {
-          continue;
-        }
-
-        // Being in a group that was granted this permission is only an
-        // approximation.  There might be overrides and doNotInherit
-        // that would render this to be false.
-        //
-        if (controlForRef(section.getName()).canPerform(permissionName)) {
-          return true;
-        }
-        break;
-      }
-    }
-
-    return false;
-  }
-
-  private boolean canPerformOnAllRefs(String permission, Set<String> ignore) {
-    boolean canPerform = false;
-    Set<String> patterns = allRefPatterns(permission);
-    if (patterns.contains(AccessSection.ALL)) {
-      // Only possible if granted on the pattern that
-      // matches every possible reference.  Check all
-      // patterns also have the permission.
-      //
-      for (String pattern : patterns) {
-        if (controlForRef(pattern).canPerform(permission)) {
-          canPerform = true;
-        } else if (ignore.contains(pattern)) {
-          continue;
-        } else {
-          return false;
-        }
-      }
-    }
-    return canPerform;
-  }
-
-  private Set<String> allRefPatterns(String permissionName) {
-    Set<String> all = new HashSet<>();
-    for (SectionMatcher matcher : access()) {
-      AccessSection section = matcher.section;
-      Permission permission = section.getPermission(permissionName);
-      if (permission != null) {
-        all.add(section.getName());
-      }
-    }
-    return all;
-  }
-
-  private List<SectionMatcher> access() {
-    if (allSections == null) {
-      allSections = state.getAllSections();
-    }
-    return allSections;
-  }
-
-  boolean match(PermissionRule rule) {
-    return match(rule.getGroup().getUUID());
-  }
-
-  boolean match(PermissionRule rule, boolean isChangeOwner) {
-    return match(rule.getGroup().getUUID(), isChangeOwner);
-  }
-
-  boolean match(AccountGroup.UUID uuid) {
-    return match(uuid, false);
-  }
-
-  boolean match(AccountGroup.UUID uuid, boolean isChangeOwner) {
-    if (SystemGroupBackend.PROJECT_OWNERS.equals(uuid)) {
-      return isDeclaredOwner();
-    } else if (SystemGroupBackend.CHANGE_OWNER.equals(uuid)) {
-      return isChangeOwner;
-    } else {
-      return user.getEffectiveGroups().contains(uuid);
-    }
-  }
-
-  boolean isReachableFromHeadsOrTags(Repository repo, RevCommit commit) {
-    try {
-      RefDatabase refdb = repo.getRefDatabase();
-      Collection<Ref> heads = refdb.getRefs(Constants.R_HEADS).values();
-      Collection<Ref> tags = refdb.getRefs(Constants.R_TAGS).values();
-      Map<String, Ref> refs = Maps.newHashMapWithExpectedSize(heads.size() + tags.size());
-      for (Ref r : Iterables.concat(heads, tags)) {
-        refs.put(r.getName(), r);
-      }
-      return commits.isReachableFrom(state, repo, commit, refs);
-    } catch (IOException e) {
-      log.error(
-          String.format(
-              "Cannot verify permissions to commit object %s in repository %s",
-              commit.name(), getProject().getNameKey()),
-          e);
-      return false;
-    }
-  }
-
-  ForProject asForProject() {
-    return new ForProjectImpl();
-  }
-
-  private class ForProjectImpl extends ForProject {
-    @Override
-    public ForProject user(CurrentUser user) {
-      return forUser(user).asForProject().database(db);
-    }
-
-    @Override
-    public ForRef ref(String ref) {
-      return controlForRef(ref).asForRef().database(db);
-    }
-
-    @Override
-    public ForChange change(ChangeData cd) {
-      try {
-        checkProject(cd.change());
-        return super.change(cd);
-      } catch (OrmException e) {
-        return FailedPermissionBackend.change("unavailable", e);
-      }
-    }
-
-    @Override
-    public ForChange change(ChangeNotes notes) {
-      checkProject(notes.getChange());
-      return super.change(notes);
-    }
-
-    private void checkProject(Change change) {
-      Project.NameKey project = getProject().getNameKey();
-      checkArgument(
-          project.equals(change.getProject()),
-          "expected change in project %s, not %s",
-          project,
-          change.getProject());
-    }
-
-    @Override
-    public void check(ProjectPermission perm) throws AuthException, PermissionBackendException {
-      if (!can(perm)) {
-        throw new AuthException(perm.describeForException() + " not permitted");
-      }
-    }
-
-    @Override
-    public Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
-        throws PermissionBackendException {
-      EnumSet<ProjectPermission> ok = EnumSet.noneOf(ProjectPermission.class);
-      for (ProjectPermission perm : permSet) {
-        if (can(perm)) {
-          ok.add(perm);
-        }
-      }
-      return ok;
-    }
-
-    private boolean can(ProjectPermission perm) throws PermissionBackendException {
-      switch (perm) {
-        case ACCESS:
-          return (!isHidden() && (user.isInternalUser() || canPerformOnAnyRef(Permission.READ)))
-              || isOwner();
-
-        case READ:
-          return !isHidden() && allRefsAreVisible(Collections.emptySet());
-
-        case READ_NO_CONFIG:
-          return !isHidden() && allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG));
-
-        case CREATE_REF:
-          return canAddRefs();
-        case CREATE_CHANGE:
-          return canCreateChanges();
-
-        case RUN_RECEIVE_PACK:
-          return canRunReceivePack();
-        case RUN_UPLOAD_PACK:
-          return canRunUploadPack();
-      }
-      throw new PermissionBackendException(perm + " unsupported");
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectNode.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectNode.java
deleted file mode 100644
index e1ba692..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectNode.java
+++ /dev/null
@@ -1,87 +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.project;
-
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.util.TreeFormatter.TreeNode;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
-/** Node of a Project in a tree formatted by {@link ListProjects}. */
-public class ProjectNode implements TreeNode, Comparable<ProjectNode> {
-  public interface Factory {
-    ProjectNode create(Project project, boolean isVisible);
-  }
-
-  private final AllProjectsName allProjectsName;
-  private final Project project;
-  private final boolean isVisible;
-
-  private final SortedSet<ProjectNode> children = new TreeSet<>();
-
-  @Inject
-  protected ProjectNode(
-      final AllProjectsName allProjectsName,
-      @Assisted final Project project,
-      @Assisted final boolean isVisible) {
-    this.allProjectsName = allProjectsName;
-    this.project = project;
-    this.isVisible = isVisible;
-  }
-
-  /**
-   * Returns the project parent name.
-   *
-   * @return Project parent name, {@code null} for the 'All-Projects' root project
-   */
-  public Project.NameKey getParentName() {
-    return project.getParent(allProjectsName);
-  }
-
-  public boolean isAllProjects() {
-    return allProjectsName.equals(project.getNameKey());
-  }
-
-  public Project getProject() {
-    return project;
-  }
-
-  @Override
-  public String getDisplayName() {
-    return project.getName();
-  }
-
-  @Override
-  public boolean isVisible() {
-    return isVisible;
-  }
-
-  @Override
-  public SortedSet<? extends ProjectNode> getChildren() {
-    return children;
-  }
-
-  public void addChild(ProjectNode child) {
-    children.add(child);
-  }
-
-  @Override
-  public int compareTo(ProjectNode o) {
-    return project.getNameKey().compareTo(o.project.getNameKey());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java
deleted file mode 100644
index a91ba62..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.inject.TypeLiteral;
-
-public class ProjectResource implements RestResource {
-  public static final TypeLiteral<RestView<ProjectResource>> PROJECT_KIND =
-      new TypeLiteral<RestView<ProjectResource>>() {};
-
-  private final ProjectControl control;
-
-  public ProjectResource(ProjectControl control) {
-    this.control = control;
-  }
-
-  ProjectResource(ProjectResource rsrc) {
-    this.control = rsrc.getControl();
-  }
-
-  public String getName() {
-    return control.getProject().getName();
-  }
-
-  public Project.NameKey getNameKey() {
-    return control.getProject().getNameKey();
-  }
-
-  public ProjectState getProjectState() {
-    return control.getProjectState();
-  }
-
-  public CurrentUser getUser() {
-    return getControl().getUser();
-  }
-
-  public ProjectControl getControl() {
-    return control;
-  }
-}
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
deleted file mode 100644
index 3015164..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
+++ /dev/null
@@ -1,598 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.gerrit.common.data.PermissionRule.Action.ALLOW;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.RefConfigSection;
-import com.google.gerrit.common.data.SubscribeSection;
-import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
-import com.google.gerrit.extensions.api.projects.ThemeInfo;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.rules.PrologEnvironment;
-import com.google.gerrit.rules.RulesCache;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.CapabilityCollection;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.BranchOrderSection;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.ProjectLevelConfig;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import com.googlecode.prolog_cafe.exceptions.CompileException;
-import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
-import java.io.IOException;
-import java.io.Reader;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.Function;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Cached information on a project. */
-public class ProjectState {
-  private static final Logger log = LoggerFactory.getLogger(ProjectState.class);
-
-  public interface Factory {
-    ProjectState create(ProjectConfig config);
-  }
-
-  private final boolean isAllProjects;
-  private final boolean isAllUsers;
-  private final SitePaths sitePaths;
-  private final AllProjectsName allProjectsName;
-  private final ProjectCache projectCache;
-  private final ProjectControl.AssistedFactory projectControlFactory;
-  private final PrologEnvironment.Factory envFactory;
-  private final GitRepositoryManager gitMgr;
-  private final RulesCache rulesCache;
-  private final List<CommentLinkInfo> commentLinks;
-
-  private final ProjectConfig config;
-  private final Map<String, ProjectLevelConfig> configs;
-  private final Set<AccountGroup.UUID> localOwners;
-
-  /** Prolog rule state. */
-  private volatile PrologMachineCopy rulesMachine;
-
-  /** Last system time the configuration's revision was examined. */
-  private volatile long lastCheckGeneration;
-
-  /** Local access sections, wrapped in SectionMatchers for faster evaluation. */
-  private volatile List<SectionMatcher> localAccessSections;
-
-  /** Theme information loaded from site_path/themes. */
-  private volatile ThemeInfo theme;
-
-  /** If this is all projects, the capabilities used by the server. */
-  private final CapabilityCollection capabilities;
-
-  /** All label types applicable to changes in this project. */
-  private LabelTypes labelTypes;
-
-  @Inject
-  public ProjectState(
-      final SitePaths sitePaths,
-      final ProjectCache projectCache,
-      final AllProjectsName allProjectsName,
-      final AllUsersName allUsersName,
-      final ProjectControl.AssistedFactory projectControlFactory,
-      final PrologEnvironment.Factory envFactory,
-      final GitRepositoryManager gitMgr,
-      final RulesCache rulesCache,
-      final List<CommentLinkInfo> commentLinks,
-      final CapabilityCollection.Factory limitsFactory,
-      @Assisted final ProjectConfig config) {
-    this.sitePaths = sitePaths;
-    this.projectCache = projectCache;
-    this.isAllProjects = config.getProject().getNameKey().equals(allProjectsName);
-    this.isAllUsers = config.getProject().getNameKey().equals(allUsersName);
-    this.allProjectsName = allProjectsName;
-    this.projectControlFactory = projectControlFactory;
-    this.envFactory = envFactory;
-    this.gitMgr = gitMgr;
-    this.rulesCache = rulesCache;
-    this.commentLinks = commentLinks;
-    this.config = config;
-    this.configs = new HashMap<>();
-    this.capabilities =
-        isAllProjects
-            ? limitsFactory.create(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
-            : null;
-
-    if (isAllProjects && !Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)) {
-      localOwners = Collections.emptySet();
-    } else {
-      HashSet<AccountGroup.UUID> groups = new HashSet<>();
-      AccessSection all = config.getAccessSection(AccessSection.ALL);
-      if (all != null) {
-        Permission owner = all.getPermission(Permission.OWNER);
-        if (owner != null) {
-          for (PermissionRule rule : owner.getRules()) {
-            GroupReference ref = rule.getGroup();
-            if (rule.getAction() == ALLOW && ref.getUUID() != null) {
-              groups.add(ref.getUUID());
-            }
-          }
-        }
-      }
-      localOwners = Collections.unmodifiableSet(groups);
-    }
-  }
-
-  void initLastCheck(long generation) {
-    lastCheckGeneration = generation;
-  }
-
-  boolean needsRefresh(long generation) {
-    if (generation <= 0) {
-      return isRevisionOutOfDate();
-    }
-    if (lastCheckGeneration != generation) {
-      lastCheckGeneration = generation;
-      return isRevisionOutOfDate();
-    }
-    return false;
-  }
-
-  private boolean isRevisionOutOfDate() {
-    try (Repository git = gitMgr.openRepository(getNameKey())) {
-      Ref ref = git.getRefDatabase().exactRef(RefNames.REFS_CONFIG);
-      if (ref == null || ref.getObjectId() == null) {
-        return true;
-      }
-      return !ref.getObjectId().equals(config.getRevision());
-    } catch (IOException gone) {
-      return true;
-    }
-  }
-
-  /**
-   * @return cached computation of all global capabilities. This should only be invoked on the state
-   *     from {@link ProjectCache#getAllProjects()}. Null on any other project.
-   */
-  public CapabilityCollection getCapabilityCollection() {
-    return capabilities;
-  }
-
-  /** @return Construct a new PrologEnvironment for the calling thread. */
-  public PrologEnvironment newPrologEnvironment() throws CompileException {
-    PrologMachineCopy pmc = rulesMachine;
-    if (pmc == null) {
-      pmc = rulesCache.loadMachine(getNameKey(), config.getRulesId());
-      rulesMachine = pmc;
-    }
-    return envFactory.create(pmc);
-  }
-
-  /**
-   * Like {@link #newPrologEnvironment()} but instead of reading the rules.pl read the provided
-   * input stream.
-   *
-   * @param name a name of the input stream. Could be any name.
-   * @param in stream to read prolog rules from
-   * @throws CompileException
-   */
-  public PrologEnvironment newPrologEnvironment(String name, Reader in) throws CompileException {
-    PrologMachineCopy pmc = rulesCache.loadMachine(name, in);
-    return envFactory.create(pmc);
-  }
-
-  public Project getProject() {
-    return config.getProject();
-  }
-
-  public Project.NameKey getNameKey() {
-    return getProject().getNameKey();
-  }
-
-  public String getName() {
-    return getNameKey().get();
-  }
-
-  public ProjectConfig getConfig() {
-    return config;
-  }
-
-  public ProjectLevelConfig getConfig(String fileName) {
-    if (configs.containsKey(fileName)) {
-      return configs.get(fileName);
-    }
-
-    ProjectLevelConfig cfg = new ProjectLevelConfig(fileName, this);
-    try (Repository git = gitMgr.openRepository(getNameKey())) {
-      cfg.load(git);
-    } catch (IOException | ConfigInvalidException e) {
-      log.warn("Failed to load " + fileName + " for " + getName(), e);
-    }
-
-    configs.put(fileName, cfg);
-    return cfg;
-  }
-
-  public long getMaxObjectSizeLimit() {
-    return config.getMaxObjectSizeLimit();
-  }
-
-  /** Get the sections that pertain only to this project. */
-  List<SectionMatcher> getLocalAccessSections() {
-    List<SectionMatcher> sm = localAccessSections;
-    if (sm == null) {
-      Collection<AccessSection> fromConfig = config.getAccessSections();
-      sm = new ArrayList<>(fromConfig.size());
-      for (AccessSection section : fromConfig) {
-        if (isAllProjects) {
-          List<Permission> copy = Lists.newArrayListWithCapacity(section.getPermissions().size());
-          for (Permission p : section.getPermissions()) {
-            if (Permission.canBeOnAllProjects(section.getName(), p.getName())) {
-              copy.add(p);
-            }
-          }
-          section = new AccessSection(section.getName());
-          section.setPermissions(copy);
-        }
-
-        SectionMatcher matcher = SectionMatcher.wrap(getNameKey(), section);
-        if (matcher != null) {
-          sm.add(matcher);
-        }
-      }
-      localAccessSections = sm;
-    }
-    return sm;
-  }
-
-  /**
-   * Obtain all local and inherited sections. This collection is looked up dynamically and is not
-   * cached. Callers should try to cache this result per-request as much as possible.
-   */
-  List<SectionMatcher> getAllSections() {
-    if (isAllProjects) {
-      return getLocalAccessSections();
-    }
-
-    List<SectionMatcher> all = new ArrayList<>();
-    for (ProjectState s : tree()) {
-      all.addAll(s.getLocalAccessSections());
-    }
-    return all;
-  }
-
-  /**
-   * @return all {@link AccountGroup}'s to which the owner privilege for 'refs/*' is assigned for
-   *     this project (the local owners), if there are no local owners the local owners of the
-   *     nearest parent project that has local owners are returned
-   */
-  public Set<AccountGroup.UUID> getOwners() {
-    for (ProjectState p : tree()) {
-      if (!p.localOwners.isEmpty()) {
-        return p.localOwners;
-      }
-    }
-    return Collections.emptySet();
-  }
-
-  /**
-   * @return all {@link AccountGroup}'s that are allowed to administrate the complete project. This
-   *     includes all groups to which the owner privilege for 'refs/*' is assigned for this project
-   *     (the local owners) and all groups to which the owner privilege for 'refs/*' is assigned for
-   *     one of the parent projects (the inherited owners).
-   */
-  public Set<AccountGroup.UUID> getAllOwners() {
-    Set<AccountGroup.UUID> result = new HashSet<>();
-
-    for (ProjectState p : tree()) {
-      result.addAll(p.localOwners);
-    }
-
-    return result;
-  }
-
-  public ProjectControl controlFor(CurrentUser user) {
-    return projectControlFactory.create(user, this);
-  }
-
-  /**
-   * @return an iterable that walks through this project and then the parents of this project.
-   *     Starts from this project and progresses up the hierarchy to All-Projects.
-   */
-  public Iterable<ProjectState> tree() {
-    return new Iterable<ProjectState>() {
-      @Override
-      public Iterator<ProjectState> iterator() {
-        return new ProjectHierarchyIterator(projectCache, allProjectsName, ProjectState.this);
-      }
-    };
-  }
-
-  /**
-   * @return an iterable that walks in-order from All-Projects through the project hierarchy to this
-   *     project.
-   */
-  public Iterable<ProjectState> treeInOrder() {
-    List<ProjectState> projects = Lists.newArrayList(tree());
-    Collections.reverse(projects);
-    return projects;
-  }
-
-  /**
-   * @return an iterable that walks through the parents of this project. Starts from the immediate
-   *     parent of this project and progresses up the hierarchy to All-Projects.
-   */
-  public FluentIterable<ProjectState> parents() {
-    return FluentIterable.from(tree()).skip(1);
-  }
-
-  public boolean isAllProjects() {
-    return isAllProjects;
-  }
-
-  public boolean isAllUsers() {
-    return isAllUsers;
-  }
-
-  public boolean isUseContributorAgreements() {
-    return getInheritableBoolean(Project::getUseContributorAgreements);
-  }
-
-  public boolean isUseContentMerge() {
-    return getInheritableBoolean(Project::getUseContentMerge);
-  }
-
-  public boolean isUseSignedOffBy() {
-    return getInheritableBoolean(Project::getUseSignedOffBy);
-  }
-
-  public boolean isRequireChangeID() {
-    return getInheritableBoolean(Project::getRequireChangeID);
-  }
-
-  public boolean isCreateNewChangeForAllNotInTarget() {
-    return getInheritableBoolean(Project::getCreateNewChangeForAllNotInTarget);
-  }
-
-  public boolean isEnableSignedPush() {
-    return getInheritableBoolean(Project::getEnableSignedPush);
-  }
-
-  public boolean isRequireSignedPush() {
-    return getInheritableBoolean(Project::getRequireSignedPush);
-  }
-
-  public boolean isRejectImplicitMerges() {
-    return getInheritableBoolean(Project::getRejectImplicitMerges);
-  }
-
-  public boolean isPrivateByDefault() {
-    return getInheritableBoolean(Project::getPrivateByDefault);
-  }
-
-  public boolean isEnableReviewerByEmail() {
-    return getInheritableBoolean(Project::getEnableReviewerByEmail);
-  }
-
-  public boolean isMatchAuthorToCommitterDate() {
-    return getInheritableBoolean(Project::getMatchAuthorToCommitterDate);
-  }
-
-  /** All available label types. */
-  public LabelTypes getLabelTypes() {
-    if (labelTypes == null) {
-      labelTypes = loadLabelTypes();
-    }
-    return labelTypes;
-  }
-
-  /** All available label types for this change and user. */
-  public LabelTypes getLabelTypes(ChangeNotes notes, CurrentUser user) {
-    return getLabelTypes(notes.getChange().getDest(), user);
-  }
-
-  /** All available label types for this branch and user. */
-  public LabelTypes getLabelTypes(Branch.NameKey destination, CurrentUser user) {
-    List<LabelType> all = getLabelTypes().getLabelTypes();
-
-    List<LabelType> r = Lists.newArrayListWithCapacity(all.size());
-    for (LabelType l : all) {
-      List<String> refs = l.getRefPatterns();
-      if (refs == null) {
-        r.add(l);
-      } else {
-        for (String refPattern : refs) {
-          if (RefConfigSection.isValid(refPattern) && match(destination, refPattern, user)) {
-            r.add(l);
-            break;
-          }
-        }
-      }
-    }
-
-    return new LabelTypes(r);
-  }
-
-  public List<CommentLinkInfo> getCommentLinks() {
-    Map<String, CommentLinkInfo> cls = new LinkedHashMap<>();
-    for (CommentLinkInfo cl : commentLinks) {
-      cls.put(cl.name.toLowerCase(), cl);
-    }
-    for (ProjectState s : treeInOrder()) {
-      for (CommentLinkInfoImpl cl : s.getConfig().getCommentLinkSections()) {
-        String name = cl.name.toLowerCase();
-        if (cl.isOverrideOnly()) {
-          CommentLinkInfo parent = cls.get(name);
-          if (parent == null) {
-            continue; // Ignore invalid overrides.
-          }
-          cls.put(name, cl.inherit(parent));
-        } else {
-          cls.put(name, cl);
-        }
-      }
-    }
-    return ImmutableList.copyOf(cls.values());
-  }
-
-  public BranchOrderSection getBranchOrderSection() {
-    for (ProjectState s : tree()) {
-      BranchOrderSection section = s.getConfig().getBranchOrderSection();
-      if (section != null) {
-        return section;
-      }
-    }
-    return null;
-  }
-
-  public Collection<SubscribeSection> getSubscribeSections(Branch.NameKey branch) {
-    Collection<SubscribeSection> ret = new ArrayList<>();
-    for (ProjectState s : tree()) {
-      ret.addAll(s.getConfig().getSubscribeSections(branch));
-    }
-    return ret;
-  }
-
-  public ThemeInfo getTheme() {
-    ThemeInfo theme = this.theme;
-    if (theme == null) {
-      synchronized (this) {
-        theme = this.theme;
-        if (theme == null) {
-          theme = loadTheme();
-          this.theme = theme;
-        }
-      }
-    }
-    if (theme == ThemeInfo.INHERIT) {
-      ProjectState parent = Iterables.getFirst(parents(), null);
-      return parent != null ? parent.getTheme() : null;
-    }
-    return theme;
-  }
-
-  public Set<GroupReference> getAllGroups() {
-    return getGroups(getAllSections());
-  }
-
-  public Set<GroupReference> getLocalGroups() {
-    return getGroups(getLocalAccessSections());
-  }
-
-  private static Set<GroupReference> getGroups(List<SectionMatcher> sectionMatcherList) {
-    final Set<GroupReference> all = new HashSet<>();
-    for (SectionMatcher matcher : sectionMatcherList) {
-      final AccessSection section = matcher.section;
-      for (Permission permission : section.getPermissions()) {
-        for (PermissionRule rule : permission.getRules()) {
-          all.add(rule.getGroup());
-        }
-      }
-    }
-    return all;
-  }
-
-  private ThemeInfo loadTheme() {
-    String name = getConfig().getProject().getName();
-    Path dir = sitePaths.themes_dir.resolve(name);
-    if (!Files.exists(dir)) {
-      return ThemeInfo.INHERIT;
-    } else if (!Files.isDirectory(dir)) {
-      log.warn("Bad theme for {}: not a directory", name);
-      return ThemeInfo.INHERIT;
-    }
-    try {
-      return new ThemeInfo(
-          readFile(dir.resolve(SitePaths.CSS_FILENAME)),
-          readFile(dir.resolve(SitePaths.HEADER_FILENAME)),
-          readFile(dir.resolve(SitePaths.FOOTER_FILENAME)));
-    } catch (IOException e) {
-      log.error("Error reading theme for " + name, e);
-      return ThemeInfo.INHERIT;
-    }
-  }
-
-  private String readFile(Path p) throws IOException {
-    return Files.exists(p) ? new String(Files.readAllBytes(p), UTF_8) : null;
-  }
-
-  private boolean getInheritableBoolean(Function<Project, InheritableBoolean> func) {
-    for (ProjectState s : tree()) {
-      switch (func.apply(s.getProject())) {
-        case TRUE:
-          return true;
-        case FALSE:
-          return false;
-        case INHERIT:
-        default:
-          continue;
-      }
-    }
-    return false;
-  }
-
-  private LabelTypes loadLabelTypes() {
-    Map<String, LabelType> types = new LinkedHashMap<>();
-    for (ProjectState s : treeInOrder()) {
-      for (LabelType type : s.getConfig().getLabelSections().values()) {
-        String lower = type.getName().toLowerCase();
-        LabelType old = types.get(lower);
-        if (old == null || old.canOverride()) {
-          types.put(lower, type);
-        }
-      }
-    }
-    List<LabelType> all = Lists.newArrayListWithCapacity(types.size());
-    for (LabelType type : types.values()) {
-      if (!type.getValues().isEmpty()) {
-        all.add(type);
-      }
-    }
-    return new LabelTypes(Collections.unmodifiableList(all));
-  }
-
-  private boolean match(Branch.NameKey destination, String refPattern, CurrentUser user) {
-    return RefPatternMatcher.getMatcher(refPattern).match(destination.get(), user);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
deleted file mode 100644
index e0741f0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
+++ /dev/null
@@ -1,149 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestCollection;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.OutputFormat;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.lib.Constants;
-
-@Singleton
-public class ProjectsCollection
-    implements RestCollection<TopLevelResource, ProjectResource>, AcceptsCreate<TopLevelResource> {
-  private final DynamicMap<RestView<ProjectResource>> views;
-  private final Provider<ListProjects> list;
-  private final ProjectControl.GenericFactory controlFactory;
-  private final PermissionBackend permissionBackend;
-  private final Provider<CurrentUser> user;
-  private final CreateProject.Factory createProjectFactory;
-
-  @Inject
-  ProjectsCollection(
-      DynamicMap<RestView<ProjectResource>> views,
-      Provider<ListProjects> list,
-      ProjectControl.GenericFactory controlFactory,
-      PermissionBackend permissionBackend,
-      CreateProject.Factory factory,
-      Provider<CurrentUser> user) {
-    this.views = views;
-    this.list = list;
-    this.controlFactory = controlFactory;
-    this.permissionBackend = permissionBackend;
-    this.user = user;
-    this.createProjectFactory = factory;
-  }
-
-  @Override
-  public RestView<TopLevelResource> list() {
-    return list.get().setFormat(OutputFormat.JSON);
-  }
-
-  @Override
-  public ProjectResource parse(TopLevelResource parent, IdString id)
-      throws ResourceNotFoundException, IOException, PermissionBackendException {
-    ProjectResource rsrc = _parse(id.get(), true);
-    if (rsrc == null) {
-      throw new ResourceNotFoundException(id);
-    }
-    return rsrc;
-  }
-
-  /**
-   * Parses a project ID from a request body and returns the project.
-   *
-   * @param id ID of the project, can be a project name
-   * @return the project
-   * @throws UnprocessableEntityException thrown if the project ID cannot be resolved or if the
-   *     project is not visible to the calling user
-   * @throws IOException thrown when there is an error.
-   * @throws PermissionBackendException
-   */
-  public ProjectResource parse(String id)
-      throws UnprocessableEntityException, IOException, PermissionBackendException {
-    return parse(id, true);
-  }
-
-  /**
-   * Parses a project ID from a request body and returns the project.
-   *
-   * @param id ID of the project, can be a project name
-   * @param checkAccess if true, check the project is accessible by the current user
-   * @return the project
-   * @throws UnprocessableEntityException thrown if the project ID cannot be resolved or if the
-   *     project is not visible to the calling user and checkVisibility is true.
-   * @throws IOException thrown when there is an error.
-   * @throws PermissionBackendException
-   */
-  public ProjectResource parse(String id, boolean checkAccess)
-      throws UnprocessableEntityException, IOException, PermissionBackendException {
-    ProjectResource rsrc = _parse(id, checkAccess);
-    if (rsrc == null) {
-      throw new UnprocessableEntityException(String.format("Project Not Found: %s", id));
-    }
-    return rsrc;
-  }
-
-  @Nullable
-  private ProjectResource _parse(String id, boolean checkAccess)
-      throws IOException, PermissionBackendException {
-    if (id.endsWith(Constants.DOT_GIT_EXT)) {
-      id = id.substring(0, id.length() - Constants.DOT_GIT_EXT.length());
-    }
-
-    Project.NameKey nameKey = new Project.NameKey(id);
-    ProjectControl ctl;
-    try {
-      ctl = controlFactory.controlFor(nameKey, user.get());
-    } catch (NoSuchProjectException e) {
-      return null;
-    }
-
-    if (checkAccess) {
-      try {
-        permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
-      } catch (AuthException e) {
-        return null; // Pretend like not found on access denied.
-      }
-    }
-    return new ProjectResource(ctl);
-  }
-
-  @Override
-  public DynamicMap<RestView<ProjectResource>> views() {
-    return views;
-  }
-
-  @Override
-  public CreateProject create(TopLevelResource parent, IdString name) {
-    return createProjectFactory.create(name.get());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java
deleted file mode 100644
index 8e8efaf..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.api.projects.BranchInfo;
-import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class PutBranch implements RestModifyView<BranchResource, BranchInput> {
-
-  @Override
-  public BranchInfo apply(BranchResource rsrc, BranchInput input) throws ResourceConflictException {
-    throw new ResourceConflictException("Branch \"" + rsrc.getRef() + "\" already exists");
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
deleted file mode 100644
index c4a7eb4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
+++ /dev/null
@@ -1,320 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Joiner;
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.api.projects.ConfigInfo;
-import com.google.gerrit.extensions.api.projects.ConfigInput;
-import com.google.gerrit.extensions.api.projects.ConfigValue;
-import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.EnableSignedPush;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.PluginConfig;
-import com.google.gerrit.server.config.PluginConfigFactory;
-import com.google.gerrit.server.config.ProjectConfigEntry;
-import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.TransferConfig;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class PutConfig implements RestModifyView<ProjectResource, ConfigInput> {
-  private static final Logger log = LoggerFactory.getLogger(PutConfig.class);
-
-  private final boolean serverEnableSignedPush;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final ProjectCache projectCache;
-  private final ProjectState.Factory projectStateFactory;
-  private final TransferConfig config;
-  private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
-  private final PluginConfigFactory cfgFactory;
-  private final AllProjectsName allProjects;
-  private final UiActions uiActions;
-  private final DynamicMap<RestView<ProjectResource>> views;
-  private final Provider<CurrentUser> user;
-
-  @Inject
-  PutConfig(
-      @EnableSignedPush boolean serverEnableSignedPush,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      ProjectCache projectCache,
-      ProjectState.Factory projectStateFactory,
-      TransferConfig config,
-      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
-      PluginConfigFactory cfgFactory,
-      AllProjectsName allProjects,
-      UiActions uiActions,
-      DynamicMap<RestView<ProjectResource>> views,
-      Provider<CurrentUser> user) {
-    this.serverEnableSignedPush = serverEnableSignedPush;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.projectCache = projectCache;
-    this.projectStateFactory = projectStateFactory;
-    this.config = config;
-    this.pluginConfigEntries = pluginConfigEntries;
-    this.cfgFactory = cfgFactory;
-    this.allProjects = allProjects;
-    this.uiActions = uiActions;
-    this.views = views;
-    this.user = user;
-  }
-
-  @Override
-  public ConfigInfo apply(ProjectResource rsrc, ConfigInput input) throws RestApiException {
-    if (!rsrc.getControl().isOwner()) {
-      throw new AuthException("restricted to project owner");
-    }
-    return apply(rsrc.getProjectState(), input);
-  }
-
-  public ConfigInfo apply(ProjectState projectState, ConfigInput input)
-      throws ResourceNotFoundException, BadRequestException, ResourceConflictException {
-    Project.NameKey projectName = projectState.getNameKey();
-    if (input == null) {
-      throw new BadRequestException("config is required");
-    }
-
-    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
-      ProjectConfig projectConfig = ProjectConfig.read(md);
-      Project p = projectConfig.getProject();
-
-      p.setDescription(Strings.emptyToNull(input.description));
-
-      if (input.useContributorAgreements != null) {
-        p.setUseContributorAgreements(input.useContributorAgreements);
-      }
-      if (input.useContentMerge != null) {
-        p.setUseContentMerge(input.useContentMerge);
-      }
-      if (input.useSignedOffBy != null) {
-        p.setUseSignedOffBy(input.useSignedOffBy);
-      }
-
-      if (input.createNewChangeForAllNotInTarget != null) {
-        p.setCreateNewChangeForAllNotInTarget(input.createNewChangeForAllNotInTarget);
-      }
-
-      if (input.requireChangeId != null) {
-        p.setRequireChangeID(input.requireChangeId);
-      }
-
-      if (serverEnableSignedPush) {
-        if (input.enableSignedPush != null) {
-          p.setEnableSignedPush(input.enableSignedPush);
-        }
-        if (input.requireSignedPush != null) {
-          p.setRequireSignedPush(input.requireSignedPush);
-        }
-      }
-
-      if (input.rejectImplicitMerges != null) {
-        p.setRejectImplicitMerges(input.rejectImplicitMerges);
-      }
-
-      if (input.privateByDefault != null) {
-        p.setPrivateByDefault(input.privateByDefault);
-      }
-
-      if (input.maxObjectSizeLimit != null) {
-        p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
-      }
-
-      if (input.submitType != null) {
-        p.setSubmitType(input.submitType);
-      }
-
-      if (input.state != null) {
-        p.setState(input.state);
-      }
-
-      if (input.enableReviewerByEmail != null) {
-        p.setEnableReviewerByEmail(input.enableReviewerByEmail);
-      }
-
-      if (input.matchAuthorToCommitterDate != null) {
-        p.setMatchAuthorToCommitterDate(input.matchAuthorToCommitterDate);
-      }
-
-      if (input.pluginConfigValues != null) {
-        setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
-      }
-
-      md.setMessage("Modified project settings\n");
-      try {
-        projectConfig.commit(md);
-        projectCache.evict(projectConfig.getProject());
-        md.getRepository().setGitwebDescription(p.getDescription());
-      } catch (IOException e) {
-        if (e.getCause() instanceof ConfigInvalidException) {
-          throw new ResourceConflictException(
-              "Cannot update " + projectName + ": " + e.getCause().getMessage());
-        }
-        log.warn(String.format("Failed to update config of project %s.", projectName), e);
-        throw new ResourceConflictException("Cannot update " + projectName);
-      }
-
-      ProjectState state = projectStateFactory.create(projectConfig);
-      return new ConfigInfoImpl(
-          serverEnableSignedPush,
-          state.controlFor(user.get()),
-          config,
-          pluginConfigEntries,
-          cfgFactory,
-          allProjects,
-          uiActions,
-          views);
-    } catch (RepositoryNotFoundException notFound) {
-      throw new ResourceNotFoundException(projectName.get());
-    } catch (ConfigInvalidException err) {
-      throw new ResourceConflictException("Cannot read project " + projectName, err);
-    } catch (IOException err) {
-      throw new ResourceConflictException("Cannot update project " + projectName, err);
-    }
-  }
-
-  private void setPluginConfigValues(
-      ProjectState projectState,
-      ProjectConfig projectConfig,
-      Map<String, Map<String, ConfigValue>> pluginConfigValues)
-      throws BadRequestException {
-    for (Entry<String, Map<String, ConfigValue>> e : pluginConfigValues.entrySet()) {
-      String pluginName = e.getKey();
-      PluginConfig cfg = projectConfig.getPluginConfig(pluginName);
-      for (Entry<String, ConfigValue> v : e.getValue().entrySet()) {
-        ProjectConfigEntry projectConfigEntry = pluginConfigEntries.get(pluginName, v.getKey());
-        if (projectConfigEntry != null) {
-          if (!isValidParameterName(v.getKey())) {
-            log.warn(
-                String.format(
-                    "Parameter name '%s' must match '^[a-zA-Z0-9]+[a-zA-Z0-9-]*$'", v.getKey()));
-            continue;
-          }
-          String oldValue = cfg.getString(v.getKey());
-          String value = v.getValue().value;
-          if (projectConfigEntry.getType() == ProjectConfigEntryType.ARRAY) {
-            List<String> l = Arrays.asList(cfg.getStringList(v.getKey()));
-            oldValue = Joiner.on("\n").join(l);
-            value = Joiner.on("\n").join(v.getValue().values);
-          }
-          if (Strings.emptyToNull(value) != null) {
-            if (!value.equals(oldValue)) {
-              validateProjectConfigEntryIsEditable(
-                  projectConfigEntry, projectState, v.getKey(), pluginName);
-              v.setValue(projectConfigEntry.preUpdate(v.getValue()));
-              value = v.getValue().value;
-              try {
-                switch (projectConfigEntry.getType()) {
-                  case BOOLEAN:
-                    boolean newBooleanValue = Boolean.parseBoolean(value);
-                    cfg.setBoolean(v.getKey(), newBooleanValue);
-                    break;
-                  case INT:
-                    int newIntValue = Integer.parseInt(value);
-                    cfg.setInt(v.getKey(), newIntValue);
-                    break;
-                  case LONG:
-                    long newLongValue = Long.parseLong(value);
-                    cfg.setLong(v.getKey(), newLongValue);
-                    break;
-                  case LIST:
-                    if (!projectConfigEntry.getPermittedValues().contains(value)) {
-                      throw new BadRequestException(
-                          String.format(
-                              "The value '%s' is not permitted for parameter '%s' of plugin '"
-                                  + pluginName
-                                  + "'",
-                              value,
-                              v.getKey()));
-                    }
-                    // $FALL-THROUGH$
-                  case STRING:
-                    cfg.setString(v.getKey(), value);
-                    break;
-                  case ARRAY:
-                    cfg.setStringList(v.getKey(), v.getValue().values);
-                    break;
-                  default:
-                    log.warn(
-                        String.format(
-                            "The type '%s' of parameter '%s' is not supported.",
-                            projectConfigEntry.getType().name(), v.getKey()));
-                }
-              } catch (NumberFormatException ex) {
-                throw new BadRequestException(
-                    String.format(
-                        "The value '%s' of config parameter '%s' of plugin '%s' is invalid: %s",
-                        v.getValue(), v.getKey(), pluginName, ex.getMessage()));
-              }
-            }
-          } else {
-            if (oldValue != null) {
-              validateProjectConfigEntryIsEditable(
-                  projectConfigEntry, projectState, v.getKey(), pluginName);
-              cfg.unset(v.getKey());
-            }
-          }
-        } else {
-          throw new BadRequestException(
-              String.format(
-                  "The config parameter '%s' of plugin '%s' does not exist.",
-                  v.getKey(), pluginName));
-        }
-      }
-    }
-  }
-
-  private static void validateProjectConfigEntryIsEditable(
-      ProjectConfigEntry projectConfigEntry,
-      ProjectState projectState,
-      String parameterName,
-      String pluginName)
-      throws BadRequestException {
-    if (!projectConfigEntry.isEditable(projectState)) {
-      throw new BadRequestException(
-          String.format(
-              "Not allowed to set parameter '%s' of plugin '%s' on project '%s'.",
-              parameterName, pluginName, projectState.getName()));
-    }
-  }
-
-  private static boolean isValidParameterName(String name) {
-    return CharMatcher.javaLetterOrDigit().or(CharMatcher.is('-')).matchesAllOf(name)
-        && !name.startsWith("-");
-  }
-}
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
deleted file mode 100644
index 78230bd..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.api.projects.DescriptionInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-
-@Singleton
-public class PutDescription implements RestModifyView<ProjectResource, DescriptionInput> {
-  private final ProjectCache cache;
-  private final MetaDataUpdate.Server updateFactory;
-
-  @Inject
-  PutDescription(ProjectCache cache, MetaDataUpdate.Server updateFactory) {
-    this.cache = cache;
-    this.updateFactory = updateFactory;
-  }
-
-  @Override
-  public Response<String> apply(ProjectResource resource, DescriptionInput input)
-      throws AuthException, ResourceConflictException, ResourceNotFoundException, IOException {
-    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");
-    }
-
-    try (MetaDataUpdate md = updateFactory.create(resource.getNameKey())) {
-      ProjectConfig config = ProjectConfig.read(md);
-      Project project = config.getProject();
-      project.setDescription(Strings.emptyToNull(input.description));
-
-      String msg =
-          MoreObjects.firstNonNull(
-              Strings.emptyToNull(input.commitMessage), "Updated description.\n");
-      if (!msg.endsWith("\n")) {
-        msg += "\n";
-      }
-      md.setAuthor(user);
-      md.setMessage(msg);
-      config.commit(md);
-      cache.evict(ctl.getProject());
-      md.getRepository().setGitwebDescription(project.getDescription());
-
-      return Strings.isNullOrEmpty(project.getDescription())
-          ? Response.<String>none()
-          : Response.ok(project.getDescription());
-    } catch (RepositoryNotFoundException notFound) {
-      throw new ResourceNotFoundException(resource.getName());
-    } catch (ConfigInvalidException e) {
-      throw new ResourceConflictException(
-          String.format("invalid project.config: %s", e.getMessage()));
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutProject.java
deleted file mode 100644
index 1d2384f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutProject.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.inject.Singleton;
-
-@Singleton
-public class PutProject implements RestModifyView<ProjectResource, ProjectInput> {
-  @Override
-  public Response<?> apply(ProjectResource resource, ProjectInput input)
-      throws ResourceConflictException {
-    throw new ResourceConflictException("Project \"" + resource.getName() + "\" already exists");
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java
deleted file mode 100644
index b8a8f6d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.api.projects.TagInfo;
-import com.google.gerrit.extensions.api.projects.TagInput;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-
-public class PutTag implements RestModifyView<TagResource, TagInput> {
-
-  @Override
-  public TagInfo apply(TagResource resource, TagInput input) throws ResourceConflictException {
-    throw new ResourceConflictException("Tag \"" + resource.getTagInfo().ref + "\" already exists");
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
deleted file mode 100644
index 4b9b8e2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
+++ /dev/null
@@ -1,590 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.FailedPermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
-import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.util.Providers;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-/** Manages access control for Git references (aka branches, tags). */
-public class RefControl {
-  private final ProjectControl projectControl;
-  private final String refName;
-
-  /** All permissions that apply to this reference. */
-  private final PermissionCollection relevant;
-
-  /** Cached set of permissions matching this user. */
-  private final Map<String, List<PermissionRule>> effective;
-
-  private Boolean owner;
-  private Boolean canForgeAuthor;
-  private Boolean canForgeCommitter;
-  private Boolean isVisible;
-
-  RefControl(ProjectControl projectControl, String ref, PermissionCollection relevant) {
-    this.projectControl = projectControl;
-    this.refName = ref;
-    this.relevant = relevant;
-    this.effective = new HashMap<>();
-  }
-
-  public String getRefName() {
-    return refName;
-  }
-
-  public ProjectControl getProjectControl() {
-    return projectControl;
-  }
-
-  public CurrentUser getUser() {
-    return projectControl.getUser();
-  }
-
-  public RefControl forUser(CurrentUser who) {
-    ProjectControl newCtl = projectControl.forUser(who);
-    if (relevant.isUserSpecific()) {
-      return newCtl.controlForRef(getRefName());
-    }
-    return new RefControl(newCtl, getRefName(), relevant);
-  }
-
-  /** Is this user a ref owner? */
-  public boolean isOwner() {
-    if (owner == null) {
-      if (canPerform(Permission.OWNER)) {
-        owner = true;
-
-      } else {
-        owner = projectControl.isOwner();
-      }
-    }
-    return owner;
-  }
-
-  /** Can this user see this reference exists? */
-  boolean isVisible() {
-    if (isVisible == null) {
-      isVisible =
-          (getUser().isInternalUser() || canPerform(Permission.READ))
-              && isProjectStatePermittingRead();
-    }
-    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();
-  }
-
-  /** @return true if this user can add a new patch set to this ref */
-  boolean canAddPatchSet() {
-    return projectControl
-            .controlForRef("refs/for/" + getRefName())
-            .canPerform(Permission.ADD_PATCH_SET)
-        && isProjectStatePermittingWrite();
-  }
-
-  /** @return true if this user can submit merge patch sets to this ref */
-  private boolean canUploadMerges() {
-    return projectControl
-            .controlForRef("refs/for/" + getRefName())
-            .canPerform(Permission.PUSH_MERGE)
-        && isProjectStatePermittingWrite();
-  }
-
-  /** @return true if this user can rebase changes on this ref */
-  boolean canRebase() {
-    return canPerform(Permission.REBASE) && isProjectStatePermittingWrite();
-  }
-
-  /** @return true if this user can submit patch sets to this ref */
-  boolean canSubmit(boolean isChangeOwner) {
-    if (RefNames.REFS_CONFIG.equals(refName)) {
-      // Always allow project owners to submit configuration changes.
-      // Submitting configuration changes modifies the access control
-      // rules. Allowing this to be done by a non-project-owner opens
-      // a security hole enabling editing of access rules, and thus
-      // granting of powers beyond submitting to the configuration.
-      return projectControl.isOwner();
-    }
-    return canPerform(Permission.SUBMIT, isChangeOwner) && isProjectStatePermittingWrite();
-  }
-
-  /** @return true if the user can update the reference as a fast-forward. */
-  private boolean canUpdate() {
-    if (RefNames.REFS_CONFIG.equals(refName) && !projectControl.isOwner()) {
-      // Pushing requires being at least project owner, in addition to push.
-      // Pushing configuration changes modifies the access control
-      // rules. Allowing this to be done by a non-project-owner opens
-      // a security hole enabling editing of access rules, and thus
-      // granting of powers beyond pushing to the configuration.
-
-      // On the AllProjects project the owner access right cannot be assigned,
-      // this why for the AllProjects project we allow administrators to push
-      // configuration changes if they have push without being project owner.
-      if (!(projectControl.getProjectState().isAllProjects() && projectControl.isAdmin())) {
-        return false;
-      }
-    }
-    return canPerform(Permission.PUSH) && isProjectStatePermittingWrite();
-  }
-
-  /** @return true if the user can rewind (force push) the reference. */
-  private boolean canForceUpdate() {
-    if (!isProjectStatePermittingWrite()) {
-      return false;
-    }
-
-    if (canPushWithForce()) {
-      return true;
-    }
-
-    switch (getUser().getAccessPath()) {
-      case GIT:
-        return false;
-
-      case JSON_RPC:
-      case REST_API:
-      case SSH_COMMAND:
-      case UNKNOWN:
-      case WEB_BROWSER:
-      default:
-        return (isOwner() && !isForceBlocked(Permission.PUSH)) || projectControl.isAdmin();
-    }
-  }
-
-  private boolean isProjectStatePermittingWrite() {
-    return getProjectControl().getProject().getState().permitsWrite();
-  }
-
-  private boolean isProjectStatePermittingRead() {
-    return getProjectControl().getProject().getState().permitsRead();
-  }
-
-  private boolean canPushWithForce() {
-    if (!isProjectStatePermittingWrite()
-        || (RefNames.REFS_CONFIG.equals(refName) && !projectControl.isOwner())) {
-      // Pushing requires being at least project owner, in addition to push.
-      // Pushing configuration changes modifies the access control
-      // rules. Allowing this to be done by a non-project-owner opens
-      // a security hole enabling editing of access rules, and thus
-      // granting of powers beyond pushing to the configuration.
-      return false;
-    }
-    return canForcePerform(Permission.PUSH);
-  }
-
-  /**
-   * Determines whether the user can delete the Git ref controlled by this object.
-   *
-   * @return {@code true} if the user specified can delete a Git ref.
-   */
-  private boolean canDelete() {
-    if (!isProjectStatePermittingWrite() || (RefNames.REFS_CONFIG.equals(refName))) {
-      // Never allow removal of the refs/meta/config branch.
-      // Deleting the branch would destroy all Gerrit specific
-      // metadata about the project, including its access rules.
-      // If a project is to be removed from Gerrit, its repository
-      // should be removed first.
-      return false;
-    }
-
-    switch (getUser().getAccessPath()) {
-      case GIT:
-        return canPushWithForce() || canPerform(Permission.DELETE);
-
-      case JSON_RPC:
-      case REST_API:
-      case SSH_COMMAND:
-      case UNKNOWN:
-      case WEB_BROWSER:
-      default:
-        return (isOwner() && !isForceBlocked(Permission.PUSH))
-            || canPushWithForce()
-            || canPerform(Permission.DELETE)
-            || projectControl.isAdmin();
-    }
-  }
-
-  /** @return true if this user can forge the author line in a commit. */
-  private boolean canForgeAuthor() {
-    if (canForgeAuthor == null) {
-      canForgeAuthor = canPerform(Permission.FORGE_AUTHOR);
-    }
-    return canForgeAuthor;
-  }
-
-  /** @return true if this user can forge the committer line in a commit. */
-  private boolean canForgeCommitter() {
-    if (canForgeCommitter == null) {
-      canForgeCommitter = canPerform(Permission.FORGE_COMMITTER);
-    }
-    return canForgeCommitter;
-  }
-
-  /** @return true if this user can forge the server on the committer line. */
-  private boolean canForgeGerritServerIdentity() {
-    return canPerform(Permission.FORGE_SERVER);
-  }
-
-  /** @return true if this user can abandon a change for this ref */
-  boolean canAbandon() {
-    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);
-  }
-
-  /** @return true if this user can delete their own changes. */
-  boolean canDeleteOwnChanges() {
-    return canPerform(Permission.DELETE_OWN_CHANGES);
-  }
-
-  /** @return true if this user can edit topic names. */
-  boolean canEditTopicName() {
-    return canPerform(Permission.EDIT_TOPIC_NAME);
-  }
-
-  /** @return true if this user can edit hashtag names. */
-  boolean canEditHashtags() {
-    return canPerform(Permission.EDIT_HASHTAGS);
-  }
-
-  boolean canEditAssignee() {
-    return canPerform(Permission.EDIT_ASSIGNEE);
-  }
-
-  /** @return true if this user can force edit topic names. */
-  boolean canForceEditTopicName() {
-    return canForcePerform(Permission.EDIT_TOPIC_NAME);
-  }
-
-  /** The range of permitted values associated with a label permission. */
-  PermissionRange getRange(String permission) {
-    return getRange(permission, false);
-  }
-
-  /** The range of permitted values associated with a label permission. */
-  PermissionRange getRange(String permission, boolean isChangeOwner) {
-    if (Permission.hasRange(permission)) {
-      return toRange(permission, access(permission, isChangeOwner));
-    }
-    return null;
-  }
-
-  private static class AllowedRange {
-    private int allowMin;
-    private int allowMax;
-    private int blockMin = Integer.MIN_VALUE;
-    private int blockMax = Integer.MAX_VALUE;
-
-    void update(PermissionRule rule) {
-      if (rule.isBlock()) {
-        blockMin = Math.max(blockMin, rule.getMin());
-        blockMax = Math.min(blockMax, rule.getMax());
-      } else {
-        allowMin = Math.min(allowMin, rule.getMin());
-        allowMax = Math.max(allowMax, rule.getMax());
-      }
-    }
-
-    int getAllowMin() {
-      return allowMin;
-    }
-
-    int getAllowMax() {
-      return allowMax;
-    }
-
-    int getBlockMin() {
-      // ALLOW wins over BLOCK on the same project
-      return Math.min(blockMin, allowMin - 1);
-    }
-
-    int getBlockMax() {
-      // ALLOW wins over BLOCK on the same project
-      return Math.max(blockMax, allowMax + 1);
-    }
-  }
-
-  private PermissionRange toRange(String permissionName, List<PermissionRule> ruleList) {
-    Map<ProjectRef, AllowedRange> ranges = new HashMap<>();
-    for (PermissionRule rule : ruleList) {
-      ProjectRef p = relevant.getRuleProps(rule);
-      AllowedRange r = ranges.get(p);
-      if (r == null) {
-        r = new AllowedRange();
-        ranges.put(p, r);
-      }
-      r.update(rule);
-    }
-    int allowMin = 0;
-    int allowMax = 0;
-    int blockMin = Integer.MIN_VALUE;
-    int blockMax = Integer.MAX_VALUE;
-    for (AllowedRange r : ranges.values()) {
-      allowMin = Math.min(allowMin, r.getAllowMin());
-      allowMax = Math.max(allowMax, r.getAllowMax());
-      blockMin = Math.max(blockMin, r.getBlockMin());
-      blockMax = Math.min(blockMax, r.getBlockMax());
-    }
-
-    // BLOCK wins over ALLOW across projects
-    int min = Math.max(allowMin, blockMin + 1);
-    int max = Math.min(allowMax, blockMax - 1);
-    return new PermissionRange(permissionName, min, max);
-  }
-
-  /** True if the user has this permission. Works only for non labels. */
-  boolean canPerform(String permissionName) {
-    return canPerform(permissionName, false);
-  }
-
-  boolean canPerform(String permissionName, boolean isChangeOwner) {
-    return doCanPerform(permissionName, isChangeOwner, false);
-  }
-
-  /** True if the user is blocked from using this permission. */
-  public boolean isBlocked(String permissionName) {
-    return !doCanPerform(permissionName, false, true);
-  }
-
-  private boolean doCanPerform(String permissionName, boolean isChangeOwner, boolean blockOnly) {
-    List<PermissionRule> access = access(permissionName, isChangeOwner);
-    List<PermissionRule> overridden = relevant.getOverridden(permissionName);
-    Set<ProjectRef> allows = new HashSet<>();
-    Set<ProjectRef> blocks = new HashSet<>();
-    for (PermissionRule rule : access) {
-      if (rule.isBlock() && !rule.getForce()) {
-        blocks.add(relevant.getRuleProps(rule));
-      } else {
-        allows.add(relevant.getRuleProps(rule));
-      }
-    }
-    for (PermissionRule rule : overridden) {
-      blocks.remove(relevant.getRuleProps(rule));
-    }
-    blocks.removeAll(allows);
-    return blocks.isEmpty() && (!allows.isEmpty() || blockOnly);
-  }
-
-  /** True if the user has force this permission. Works only for non labels. */
-  private boolean canForcePerform(String permissionName) {
-    List<PermissionRule> access = access(permissionName);
-    List<PermissionRule> overridden = relevant.getOverridden(permissionName);
-    Set<ProjectRef> allows = new HashSet<>();
-    Set<ProjectRef> blocks = new HashSet<>();
-    for (PermissionRule rule : access) {
-      if (rule.isBlock()) {
-        blocks.add(relevant.getRuleProps(rule));
-      } else if (rule.getForce()) {
-        allows.add(relevant.getRuleProps(rule));
-      }
-    }
-    for (PermissionRule rule : overridden) {
-      if (rule.getForce()) {
-        blocks.remove(relevant.getRuleProps(rule));
-      }
-    }
-    blocks.removeAll(allows);
-    return blocks.isEmpty() && !allows.isEmpty();
-  }
-
-  /** True if for this permission force is blocked for the user. Works only for non labels. */
-  private boolean isForceBlocked(String permissionName) {
-    List<PermissionRule> access = access(permissionName);
-    List<PermissionRule> overridden = relevant.getOverridden(permissionName);
-    Set<ProjectRef> allows = new HashSet<>();
-    Set<ProjectRef> blocks = new HashSet<>();
-    for (PermissionRule rule : access) {
-      if (rule.isBlock()) {
-        blocks.add(relevant.getRuleProps(rule));
-      } else if (rule.getForce()) {
-        allows.add(relevant.getRuleProps(rule));
-      }
-    }
-    for (PermissionRule rule : overridden) {
-      if (rule.getForce()) {
-        blocks.remove(relevant.getRuleProps(rule));
-      }
-    }
-    blocks.removeAll(allows);
-    return !blocks.isEmpty();
-  }
-
-  /** Rules for the given permission, or the empty list. */
-  private List<PermissionRule> access(String permissionName) {
-    return access(permissionName, false);
-  }
-
-  /** Rules for the given permission, or the empty list. */
-  private List<PermissionRule> access(String permissionName, boolean isChangeOwner) {
-    List<PermissionRule> rules = effective.get(permissionName);
-    if (rules != null) {
-      return rules;
-    }
-
-    rules = relevant.getPermission(permissionName);
-
-    List<PermissionRule> mine = new ArrayList<>(rules.size());
-    for (PermissionRule rule : rules) {
-      if (projectControl.match(rule, isChangeOwner)) {
-        mine.add(rule);
-      }
-    }
-
-    if (mine.isEmpty()) {
-      mine = Collections.emptyList();
-    }
-    effective.put(permissionName, mine);
-    return mine;
-  }
-
-  ForRef asForRef() {
-    return new ForRefImpl();
-  }
-
-  private class ForRefImpl extends ForRef {
-    @Override
-    public ForRef user(CurrentUser user) {
-      return forUser(user).asForRef().database(db);
-    }
-
-    @Override
-    public ForChange change(ChangeData cd) {
-      try {
-        // TODO(hiesel) Force callers to call database() and use db instead of cd.db()
-        return getProjectControl()
-            .controlFor(cd.db(), cd.change())
-            .asForChange(cd, Providers.of(cd.db()));
-      } catch (OrmException e) {
-        return FailedPermissionBackend.change("unavailable", e);
-      }
-    }
-
-    @Override
-    public ForChange change(ChangeNotes notes) {
-      Project.NameKey project = getProjectControl().getProject().getNameKey();
-      Change change = notes.getChange();
-      checkArgument(
-          project.equals(change.getProject()),
-          "expected change in project %s, not %s",
-          project,
-          change.getProject());
-      return getProjectControl().controlFor(notes).asForChange(null, db);
-    }
-
-    @Override
-    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return getProjectControl().controlFor(notes).asForChange(cd, db);
-    }
-
-    @Override
-    public void check(RefPermission perm) throws AuthException, PermissionBackendException {
-      if (!can(perm)) {
-        throw new AuthException(perm.describeForException() + " not permitted for " + getRefName());
-      }
-    }
-
-    @Override
-    public Set<RefPermission> test(Collection<RefPermission> permSet)
-        throws PermissionBackendException {
-      EnumSet<RefPermission> ok = EnumSet.noneOf(RefPermission.class);
-      for (RefPermission perm : permSet) {
-        if (can(perm)) {
-          ok.add(perm);
-        }
-      }
-      return ok;
-    }
-
-    private boolean can(RefPermission perm) throws PermissionBackendException {
-      switch (perm) {
-        case READ:
-          return isVisible();
-        case CREATE:
-          // TODO This isn't an accurate test.
-          return canPerform(perm.permissionName().get());
-        case DELETE:
-          return canDelete();
-        case UPDATE:
-          return canUpdate();
-        case FORCE_UPDATE:
-          return canForceUpdate();
-
-        case FORGE_AUTHOR:
-          return canForgeAuthor();
-        case FORGE_COMMITTER:
-          return canForgeCommitter();
-        case FORGE_SERVER:
-          return canForgeGerritServerIdentity();
-        case MERGE:
-          return canUploadMerges();
-
-        case CREATE_CHANGE:
-          return canUpload();
-
-        case UPDATE_BY_SUBMIT:
-          return projectControl.controlForRef("refs/for/" + getRefName()).canSubmit(true);
-
-        case SKIP_VALIDATION:
-          return canForgeAuthor()
-              && canForgeCommitter()
-              && canForgeGerritServerIdentity()
-              && canUploadMerges()
-              && !projectControl.getProjectState().isUseSignedOffBy();
-      }
-      throw new PermissionBackendException(perm + " unsupported");
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefResource.java
deleted file mode 100644
index 124439f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefResource.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-public abstract class RefResource extends ProjectResource {
-
-  public RefResource(ProjectControl control) {
-    super(control);
-  }
-
-  /** @return the ref's name */
-  public abstract String getRef();
-
-  /** @return the ref's revision */
-  public abstract String getRevision();
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefUtil.java
deleted file mode 100644
index 8a7e5f1..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefUtil.java
+++ /dev/null
@@ -1,132 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static org.eclipse.jgit.lib.Constants.R_REFS;
-import static org.eclipse.jgit.lib.Constants.R_TAGS;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import java.io.IOException;
-import java.util.Collections;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RevisionSyntaxException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.ObjectWalk;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class RefUtil {
-  private static final Logger log = LoggerFactory.getLogger(RefUtil.class);
-
-  public static ObjectId parseBaseRevision(
-      Repository repo, Project.NameKey projectName, String baseRevision)
-      throws InvalidRevisionException {
-    try {
-      ObjectId revid = repo.resolve(baseRevision);
-      if (revid == null) {
-        throw new InvalidRevisionException();
-      }
-      return revid;
-    } catch (IOException err) {
-      log.error(
-          "Cannot resolve \"" + baseRevision + "\" in project \"" + projectName.get() + "\"", err);
-      throw new InvalidRevisionException();
-    } catch (RevisionSyntaxException err) {
-      log.error("Invalid revision syntax \"" + baseRevision + "\"", err);
-      throw new InvalidRevisionException();
-    }
-  }
-
-  public static RevWalk verifyConnected(Repository repo, ObjectId revid)
-      throws InvalidRevisionException {
-    try {
-      ObjectWalk rw = new ObjectWalk(repo);
-      try {
-        rw.markStart(rw.parseCommit(revid));
-      } catch (IncorrectObjectTypeException err) {
-        throw new InvalidRevisionException();
-      }
-      RefDatabase refDb = repo.getRefDatabase();
-      Iterable<Ref> refs =
-          Iterables.concat(
-              refDb.getRefs(Constants.R_HEADS).values(), refDb.getRefs(Constants.R_TAGS).values());
-      Ref rc = refDb.exactRef(RefNames.REFS_CONFIG);
-      if (rc != null) {
-        refs = Iterables.concat(refs, Collections.singleton(rc));
-      }
-      for (Ref r : refs) {
-        try {
-          rw.markUninteresting(rw.parseAny(r.getObjectId()));
-        } catch (MissingObjectException err) {
-          continue;
-        }
-      }
-      rw.checkConnectivity();
-      return rw;
-    } catch (IncorrectObjectTypeException | MissingObjectException err) {
-      throw new InvalidRevisionException();
-    } catch (IOException err) {
-      log.error(
-          "Repository \"" + repo.getDirectory() + "\" may be corrupt; suggest running git fsck",
-          err);
-      throw new InvalidRevisionException();
-    }
-  }
-
-  public static String getRefPrefix(String refName) {
-    int i = refName.lastIndexOf('/');
-    if (i > Constants.R_HEADS.length() - 1) {
-      return refName.substring(0, i);
-    }
-    return Constants.R_HEADS;
-  }
-
-  public static String normalizeTagRef(String tag) throws BadRequestException {
-    String result = tag;
-    while (result.startsWith("/")) {
-      result = result.substring(1);
-    }
-    if (result.startsWith(R_REFS) && !result.startsWith(R_TAGS)) {
-      throw new BadRequestException("invalid tag name \"" + result + "\"");
-    }
-    if (!result.startsWith(R_TAGS)) {
-      result = R_TAGS + result;
-    }
-    if (!Repository.isValidRefName(result)) {
-      throw new BadRequestException("invalid tag name \"" + result + "\"");
-    }
-    return result;
-  }
-
-  /** Error indicating the revision is invalid as supplied. */
-  static class InvalidRevisionException extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    public static final String MESSAGE = "Invalid Revision";
-
-    InvalidRevisionException() {
-      super(MESSAGE);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RemoveReviewerControl.java
deleted file mode 100644
index bdbdbe4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ /dev/null
@@ -1,126 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class RemoveReviewerControl {
-  private final PermissionBackend permissionBackend;
-  private final Provider<ReviewDb> dbProvider;
-  private final ChangeControl.GenericFactory changeControlFactory;
-
-  @Inject
-  RemoveReviewerControl(
-      PermissionBackend permissionBackend,
-      Provider<ReviewDb> dbProvider,
-      ChangeControl.GenericFactory changeControlFactory) {
-    this.permissionBackend = permissionBackend;
-    this.dbProvider = dbProvider;
-    this.changeControlFactory = changeControlFactory;
-  }
-
-  /**
-   * Checks if removing the given reviewer and patch set approval is OK.
-   *
-   * @throws AuthException if this user is not allowed to remove this approval.
-   */
-  public void checkRemoveReviewer(
-      ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
-      throws PermissionBackendException, AuthException, NoSuchChangeException, OrmException {
-    checkRemoveReviewer(notes, currentUser, approval.getAccountId(), approval.getValue());
-  }
-
-  /**
-   * Checks if removing the given reviewer is OK. Does not check if removing any approvals the
-   * reviewer might have given is OK.
-   *
-   * @throws AuthException if this user is not allowed to remove this approval.
-   */
-  public void checkRemoveReviewer(ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer)
-      throws PermissionBackendException, AuthException, NoSuchChangeException, OrmException {
-    checkRemoveReviewer(notes, currentUser, reviewer, 0);
-  }
-
-  /** @return true if the user is allowed to remove this reviewer. */
-  public boolean testRemoveReviewer(
-      ChangeData cd, CurrentUser currentUser, Account.Id reviewer, int value)
-      throws PermissionBackendException, NoSuchChangeException, OrmException {
-    if (canRemoveReviewerWithoutPermissionCheck(cd.change(), currentUser, reviewer, value)) {
-      return true;
-    }
-    return permissionBackend
-        .user(currentUser)
-        .change(cd)
-        .database(dbProvider)
-        .test(ChangePermission.REMOVE_REVIEWER);
-  }
-
-  private void checkRemoveReviewer(
-      ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int val)
-      throws PermissionBackendException, AuthException, NoSuchChangeException, OrmException {
-    if (canRemoveReviewerWithoutPermissionCheck(notes.getChange(), currentUser, reviewer, val)) {
-      return;
-    }
-
-    permissionBackend
-        .user(currentUser)
-        .change(notes)
-        .database(dbProvider)
-        .check(ChangePermission.REMOVE_REVIEWER);
-  }
-
-  private boolean canRemoveReviewerWithoutPermissionCheck(
-      Change change, CurrentUser currentUser, Account.Id reviewer, int value)
-      throws NoSuchChangeException, OrmException {
-    if (!change.getStatus().isOpen()) {
-      return false;
-    }
-
-    if (currentUser.isIdentifiedUser()) {
-      Account.Id aId = currentUser.getAccountId();
-      if (aId.equals(reviewer)) {
-        return true; // A user can always remove themselves.
-      } else if (aId.equals(change.getOwner()) && 0 <= value) {
-        return true; // The change owner may remove any zero or positive score.
-      }
-    }
-
-    // Users with the remove reviewer permission, the branch owner, project
-    // owner and site admin can remove anyone
-    ChangeControl changeControl =
-        changeControlFactory.controlFor(dbProvider.get(), change, currentUser);
-    if (changeControl.getRefControl().isOwner() // branch owner
-        || changeControl.getProjectControl().isOwner() // project owner
-        || changeControl.getProjectControl().isAdmin()) { // project admin
-      return true;
-    }
-    return false;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RepositoryStatistics.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RepositoryStatistics.java
deleted file mode 100644
index 3cb4bac..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RepositoryStatistics.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.base.CaseFormat;
-import java.util.Map.Entry;
-import java.util.Properties;
-import java.util.TreeMap;
-
-class RepositoryStatistics extends TreeMap<String, Object> {
-  private static final long serialVersionUID = 1L;
-
-  RepositoryStatistics(Properties p) {
-    for (Entry<Object, Object> e : p.entrySet()) {
-      put(
-          CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getKey().toString()),
-          e.getValue());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
deleted file mode 100644
index e875388..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
+++ /dev/null
@@ -1,131 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.errors.InvalidNameException;
-import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
-import com.google.gerrit.extensions.api.access.ProjectAccessInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class SetAccess implements RestModifyView<ProjectResource, ProjectAccessInput> {
-  protected final GroupBackend groupBackend;
-  private final PermissionBackend permissionBackend;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final GetAccess getAccess;
-  private final ProjectCache projectCache;
-  private final Provider<IdentifiedUser> identifiedUser;
-  private final SetAccessUtil accessUtil;
-
-  @Inject
-  private SetAccess(
-      GroupBackend groupBackend,
-      PermissionBackend permissionBackend,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      ProjectCache projectCache,
-      GetAccess getAccess,
-      Provider<IdentifiedUser> identifiedUser,
-      SetAccessUtil accessUtil) {
-    this.groupBackend = groupBackend;
-    this.permissionBackend = permissionBackend;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.getAccess = getAccess;
-    this.projectCache = projectCache;
-    this.identifiedUser = identifiedUser;
-    this.accessUtil = accessUtil;
-  }
-
-  @Override
-  public ProjectAccessInfo apply(ProjectResource rsrc, ProjectAccessInput input)
-      throws ResourceNotFoundException, ResourceConflictException, IOException, AuthException,
-          BadRequestException, UnprocessableEntityException, OrmException,
-          PermissionBackendException {
-    MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
-
-    ProjectConfig config;
-
-    List<AccessSection> removals = accessUtil.getAccessSections(input.remove);
-    List<AccessSection> additions = accessUtil.getAccessSections(input.add);
-    try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
-      config = ProjectConfig.read(md);
-
-      // Check that the user has the right permissions.
-      boolean checkedAdmin = false;
-      for (AccessSection section : Iterables.concat(additions, removals)) {
-        boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
-        if (isGlobalCapabilities) {
-          if (!checkedAdmin) {
-            permissionBackend.user(identifiedUser).check(GlobalPermission.ADMINISTRATE_SERVER);
-            checkedAdmin = true;
-          }
-        } else if (!rsrc.getControl().controlForRef(section.getName()).isOwner()) {
-          throw new AuthException(
-              "You are not allowed to edit permissions for ref: " + section.getName());
-        }
-      }
-
-      accessUtil.validateChanges(config, removals, additions);
-      accessUtil.applyChanges(config, removals, additions);
-
-      accessUtil.setParentName(
-          identifiedUser.get(),
-          config,
-          rsrc.getNameKey(),
-          input.parent == null ? null : new Project.NameKey(input.parent),
-          !checkedAdmin);
-
-      if (!Strings.isNullOrEmpty(input.message)) {
-        if (!input.message.endsWith("\n")) {
-          input.message += "\n";
-        }
-        md.setMessage(input.message);
-      } else {
-        md.setMessage("Modify access rules\n");
-      }
-
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    } catch (InvalidNameException e) {
-      throw new BadRequestException(e.toString());
-    } catch (ConfigInvalidException e) {
-      throw new ResourceConflictException(rsrc.getName());
-    }
-
-    return getAccess.apply(rsrc.getNameKey());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccessUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccessUtil.java
deleted file mode 100644
index 848d68c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccessUtil.java
+++ /dev/null
@@ -1,224 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.errors.InvalidNameException;
-import com.google.gerrit.extensions.api.access.AccessSectionInfo;
-import com.google.gerrit.extensions.api.access.PermissionInfo;
-import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.GroupsCollection;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-
-@Singleton
-public class SetAccessUtil {
-  private final GroupsCollection groupsCollection;
-  private final AllProjectsName allProjects;
-  private final Provider<SetParent> setParent;
-
-  @Inject
-  private SetAccessUtil(
-      GroupsCollection groupsCollection,
-      AllProjectsName allProjects,
-      Provider<SetParent> setParent) {
-    this.groupsCollection = groupsCollection;
-    this.allProjects = allProjects;
-    this.setParent = setParent;
-  }
-
-  List<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
-      throws UnprocessableEntityException {
-    if (sectionInfos == null) {
-      return Collections.emptyList();
-    }
-
-    List<AccessSection> sections = new ArrayList<>(sectionInfos.size());
-    for (Map.Entry<String, AccessSectionInfo> entry : sectionInfos.entrySet()) {
-      if (entry.getValue().permissions == null) {
-        continue;
-      }
-
-      AccessSection accessSection = new AccessSection(entry.getKey());
-      for (Map.Entry<String, PermissionInfo> permissionEntry :
-          entry.getValue().permissions.entrySet()) {
-        if (permissionEntry.getValue().rules == null) {
-          continue;
-        }
-
-        Permission p = new Permission(permissionEntry.getKey());
-        if (permissionEntry.getValue().exclusive != null) {
-          p.setExclusiveGroup(permissionEntry.getValue().exclusive);
-        }
-
-        for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
-            permissionEntry.getValue().rules.entrySet()) {
-          GroupDescription.Basic group = groupsCollection.parseId(permissionRuleInfoEntry.getKey());
-          if (group == null) {
-            throw new UnprocessableEntityException(
-                permissionRuleInfoEntry.getKey() + " is not a valid group ID");
-          }
-
-          PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
-          PermissionRule r = new PermissionRule(GroupReference.forGroup(group));
-          if (pri != null) {
-            if (pri.max != null) {
-              r.setMax(pri.max);
-            }
-            if (pri.min != null) {
-              r.setMin(pri.min);
-            }
-            r.setAction(GetAccess.ACTION_TYPE.inverse().get(pri.action));
-            if (pri.force != null) {
-              r.setForce(pri.force);
-            }
-          }
-          p.add(r);
-        }
-        accessSection.getPermissions().add(p);
-      }
-      sections.add(accessSection);
-    }
-    return sections;
-  }
-
-  /**
-   * Checks that the removals and additions are logically valid, but doesn't check current user's
-   * permission.
-   */
-  void validateChanges(
-      ProjectConfig config, List<AccessSection> removals, List<AccessSection> additions)
-      throws BadRequestException, InvalidNameException {
-    // Perform permission checks
-    for (AccessSection section : Iterables.concat(additions, removals)) {
-      boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
-      if (isGlobalCapabilities) {
-        if (!allProjects.equals(config.getName())) {
-          throw new BadRequestException(
-              "Cannot edit global capabilities for projects other than " + allProjects.get());
-        }
-      }
-    }
-
-    // Perform addition checks
-    for (AccessSection section : additions) {
-      String name = section.getName();
-      boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(name);
-
-      if (!isGlobalCapabilities) {
-        if (!AccessSection.isValid(name)) {
-          throw new BadRequestException("invalid section name");
-        }
-        RefPattern.validate(name);
-      } else {
-        // Check all permissions for soundness
-        for (Permission p : section.getPermissions()) {
-          if (!GlobalCapability.isCapability(p.getName())) {
-            throw new BadRequestException(
-                "Cannot add non-global capability " + p.getName() + " to global capabilities");
-          }
-        }
-      }
-    }
-  }
-
-  void applyChanges(
-      ProjectConfig config, List<AccessSection> removals, List<AccessSection> additions) {
-    // Apply removals
-    for (AccessSection section : removals) {
-      if (section.getPermissions().isEmpty()) {
-        // Remove entire section
-        config.remove(config.getAccessSection(section.getName()));
-        continue;
-      }
-
-      // Remove specific permissions
-      for (Permission p : section.getPermissions()) {
-        if (p.getRules().isEmpty()) {
-          config.remove(config.getAccessSection(section.getName()), p);
-        } else {
-          for (PermissionRule r : p.getRules()) {
-            config.remove(config.getAccessSection(section.getName()), p, r);
-          }
-        }
-      }
-    }
-
-    // Apply additions
-    for (AccessSection section : additions) {
-      AccessSection currentAccessSection = config.getAccessSection(section.getName());
-
-      if (currentAccessSection == null) {
-        // Add AccessSection
-        config.replace(section);
-      } else {
-        for (Permission p : section.getPermissions()) {
-          Permission currentPermission = currentAccessSection.getPermission(p.getName());
-          if (currentPermission == null) {
-            // Add Permission
-            currentAccessSection.addPermission(p);
-          } else {
-            for (PermissionRule r : p.getRules()) {
-              // AddPermissionRule
-              currentPermission.add(r);
-            }
-          }
-        }
-      }
-    }
-  }
-
-  void setParentName(
-      IdentifiedUser identifiedUser,
-      ProjectConfig config,
-      Project.NameKey projectName,
-      Project.NameKey newParentProjectName,
-      boolean checkAdmin)
-      throws ResourceConflictException, AuthException, PermissionBackendException {
-    if (newParentProjectName != null
-        && !config.getProject().getNameKey().equals(allProjects)
-        && !config.getProject().getParent(allProjects).equals(newParentProjectName)) {
-      try {
-        setParent
-            .get()
-            .validateParentUpdate(
-                projectName, identifiedUser, newParentProjectName.get(), checkAdmin);
-      } catch (UnprocessableEntityException e) {
-        throw new ResourceConflictException(e.getMessage(), e);
-      }
-      config.getProject().setParentName(newParentProjectName);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java
deleted file mode 100644
index 21ec077..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.common.SetDashboardInput;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class SetDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
-  private final Provider<SetDefaultDashboard> defaultSetter;
-
-  @Inject
-  SetDashboard(Provider<SetDefaultDashboard> defaultSetter) {
-    this.defaultSetter = defaultSetter;
-  }
-
-  @Override
-  public Response<DashboardInfo> apply(DashboardResource resource, SetDashboardInput input)
-      throws RestApiException, IOException, PermissionBackendException {
-    if (resource.isProjectDefault()) {
-      return defaultSetter.get().apply(resource, input);
-    }
-
-    // TODO: Implement creation/update of dashboards by API.
-    throw new MethodNotAllowedException();
-  }
-}
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
deleted file mode 100644
index 9aa9ae7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDefaultDashboard.java
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.api.projects.DashboardInfo;
-import com.google.gerrit.extensions.common.SetDashboardInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.kohsuke.args4j.Option;
-
-class SetDefaultDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
-  private final ProjectCache cache;
-  private final MetaDataUpdate.Server updateFactory;
-  private final DashboardsCollection dashboards;
-  private final Provider<GetDashboard> get;
-
-  @Option(name = "--inherited", usage = "set dashboard inherited by children")
-  private boolean inherited;
-
-  @Inject
-  SetDefaultDashboard(
-      ProjectCache cache,
-      MetaDataUpdate.Server updateFactory,
-      DashboardsCollection dashboards,
-      Provider<GetDashboard> get) {
-    this.cache = cache;
-    this.updateFactory = updateFactory;
-    this.dashboards = dashboards;
-    this.get = get;
-  }
-
-  @Override
-  public Response<DashboardInfo> apply(DashboardResource resource, 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");
-    }
-
-    DashboardResource target = null;
-    if (input.id != null) {
-      try {
-        target = dashboards.parse(new ProjectResource(ctl), IdString.fromUrl(input.id));
-      } catch (ResourceNotFoundException e) {
-        throw new BadRequestException("dashboard " + input.id + " not found");
-      } catch (ConfigInvalidException e) {
-        throw new ResourceConflictException(e.getMessage());
-      }
-    }
-
-    try (MetaDataUpdate md = updateFactory.create(ctl.getProject().getNameKey())) {
-      ProjectConfig config = ProjectConfig.read(md);
-      Project project = config.getProject();
-      if (inherited) {
-        project.setDefaultDashboard(input.id);
-      } else {
-        project.setLocalDefaultDashboard(input.id);
-      }
-
-      String msg =
-          MoreObjects.firstNonNull(
-              Strings.emptyToNull(input.commitMessage),
-              input.id == null
-                  ? "Removed default dashboard.\n"
-                  : String.format("Changed default dashboard to %s.\n", input.id));
-      if (!msg.endsWith("\n")) {
-        msg += "\n";
-      }
-      md.setAuthor(ctl.getUser().asIdentifiedUser());
-      md.setMessage(msg);
-      config.commit(md);
-      cache.evict(ctl.getProject());
-
-      if (target != null) {
-        DashboardInfo info = get.get().apply(target);
-        info.isDefault = true;
-        return Response.ok(info);
-      }
-      return Response.none();
-    } catch (RepositoryNotFoundException notFound) {
-      throw new ResourceNotFoundException(ctl.getProject().getName());
-    } catch (ConfigInvalidException e) {
-      throw new ResourceConflictException(
-          String.format("invalid project.config: %s", e.getMessage()));
-    }
-  }
-
-  static class CreateDefault implements RestModifyView<ProjectResource, SetDashboardInput> {
-    private final Provider<SetDefaultDashboard> setDefault;
-
-    @Option(name = "--inherited", usage = "set dashboard inherited by children")
-    private boolean inherited;
-
-    @Inject
-    CreateDefault(Provider<SetDefaultDashboard> setDefault) {
-      this.setDefault = setDefault;
-    }
-
-    @Override
-    public Response<DashboardInfo> apply(ProjectResource resource, SetDashboardInput input)
-        throws RestApiException, IOException, PermissionBackendException {
-      SetDefaultDashboard set = setDefault.get();
-      set.inherited = inherited;
-      return set.apply(DashboardResource.projectDefault(resource.getControl()), input);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java
deleted file mode 100644
index eeb47df..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java
+++ /dev/null
@@ -1,157 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.base.Strings;
-import com.google.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;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.SetHead.Input;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Map;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class SetHead implements RestModifyView<ProjectResource, Input> {
-  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;
-
-  @Inject
-  SetHead(
-      GitRepositoryManager repoManager,
-      Provider<IdentifiedUser> identifiedUser,
-      DynamicSet<HeadUpdatedListener> headUpdatedListeners) {
-    this.repoManager = repoManager;
-    this.identifiedUser = identifiedUser;
-    this.headUpdatedListeners = headUpdatedListeners;
-  }
-
-  @Override
-  public String apply(ProjectResource rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, BadRequestException,
-          UnprocessableEntityException, IOException {
-    if (!rsrc.getControl().isOwner()) {
-      throw new AuthException("restricted to project owner");
-    }
-    if (input == null || Strings.isNullOrEmpty(input.ref)) {
-      throw new BadRequestException("ref required");
-    }
-    String ref = RefNames.fullName(input.ref);
-
-    try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
-      Map<String, Ref> cur = repo.getRefDatabase().exactRef(Constants.HEAD, ref);
-      if (!cur.containsKey(ref)) {
-        throw new UnprocessableEntityException(String.format("Ref Not Found: %s", ref));
-      }
-
-      final String oldHead = cur.get(Constants.HEAD).getTarget().getName();
-      final String newHead = ref;
-      if (!oldHead.equals(newHead)) {
-        final RefUpdate u = repo.updateRef(Constants.HEAD, true);
-        u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
-        RefUpdate.Result res = u.link(newHead);
-        switch (res) {
-          case NO_CHANGE:
-          case RENAMED:
-          case FORCED:
-          case NEW:
-            break;
-          case FAST_FORWARD:
-          case IO_FAILURE:
-          case LOCK_FAILURE:
-          case NOT_ATTEMPTED:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case REJECTED_MISSING_OBJECT:
-          case REJECTED_OTHER_REASON:
-          default:
-            throw new IOException("Setting HEAD failed with " + res);
-        }
-
-        fire(rsrc.getNameKey(), oldHead, newHead);
-      }
-      return ref;
-    } catch (RepositoryNotFoundException e) {
-      throw new ResourceNotFoundException(rsrc.getName());
-    }
-  }
-
-  private void fire(Project.NameKey nameKey, String oldHead, String newHead) {
-    if (!headUpdatedListeners.iterator().hasNext()) {
-      return;
-    }
-    Event event = new Event(nameKey, oldHead, newHead);
-    for (HeadUpdatedListener l : headUpdatedListeners) {
-      try {
-        l.onHeadUpdated(event);
-      } catch (RuntimeException e) {
-        log.warn("Failure in HeadUpdatedListener", e);
-      }
-    }
-  }
-
-  static class Event extends AbstractNoNotifyEvent implements HeadUpdatedListener.Event {
-    private final Project.NameKey nameKey;
-    private final String oldHead;
-    private final String newHead;
-
-    Event(Project.NameKey nameKey, String oldHead, String newHead) {
-      this.nameKey = nameKey;
-      this.oldHead = oldHead;
-      this.newHead = newHead;
-    }
-
-    @Override
-    public String getProjectName() {
-      return nameKey.get();
-    }
-
-    @Override
-    public String getOldHeadName() {
-      return oldHead;
-    }
-
-    @Override
-    public String getNewHeadName() {
-      return newHead;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
deleted file mode 100644
index 37cfcdd..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
+++ /dev/null
@@ -1,138 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.common.collect.Iterables;
-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;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.SetParent.Input;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-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;
-  }
-
-  private final ProjectCache cache;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.Server updateFactory;
-  private final AllProjectsName allProjects;
-
-  @Inject
-  SetParent(
-      ProjectCache cache,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.Server updateFactory,
-      AllProjectsName allProjects) {
-    this.cache = cache;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.allProjects = allProjects;
-  }
-
-  @Override
-  public String apply(ProjectResource rsrc, Input input)
-      throws AuthException, ResourceConflictException, ResourceNotFoundException,
-          UnprocessableEntityException, IOException, PermissionBackendException {
-    return apply(rsrc, input, true);
-  }
-
-  public String apply(ProjectResource rsrc, Input input, boolean checkIfAdmin)
-      throws AuthException, ResourceConflictException, ResourceNotFoundException,
-          UnprocessableEntityException, IOException, PermissionBackendException {
-    IdentifiedUser user = rsrc.getUser().asIdentifiedUser();
-    String parentName =
-        MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
-    validateParentUpdate(rsrc.getProjectState().getNameKey(), user, parentName, checkIfAdmin);
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
-      ProjectConfig config = ProjectConfig.read(md);
-      Project project = config.getProject();
-      project.setParentName(parentName);
-
-      String msg = Strings.emptyToNull(input.commitMessage);
-      if (msg == null) {
-        msg = String.format("Changed parent to %s.\n", parentName);
-      } else if (!msg.endsWith("\n")) {
-        msg += "\n";
-      }
-      md.setAuthor(user);
-      md.setMessage(msg);
-      config.commit(md);
-      cache.evict(rsrc.getProjectState().getProject());
-
-      Project.NameKey parent = project.getParent(allProjects);
-      checkNotNull(parent);
-      return parent.get();
-    } catch (RepositoryNotFoundException notFound) {
-      throw new ResourceNotFoundException(rsrc.getName());
-    } catch (ConfigInvalidException e) {
-      throw new ResourceConflictException(
-          String.format("invalid project.config: %s", e.getMessage()));
-    }
-  }
-
-  public void validateParentUpdate(
-      Project.NameKey project, IdentifiedUser user, String newParent, boolean checkIfAdmin)
-      throws AuthException, ResourceConflictException, UnprocessableEntityException,
-          PermissionBackendException {
-    if (checkIfAdmin) {
-      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-    }
-
-    if (project.equals(allProjects)) {
-      throw new ResourceConflictException("cannot set parent of " + allProjects.get());
-    }
-
-    newParent = Strings.emptyToNull(newParent);
-    if (newParent != null) {
-      ProjectState parent = cache.get(new Project.NameKey(newParent));
-      if (parent == null) {
-        throw new UnprocessableEntityException("parent project " + newParent + " not found");
-      }
-
-      if (Iterables.tryFind(
-              parent.tree(),
-              p -> {
-                return p.getNameKey().equals(project);
-              })
-          .isPresent()) {
-        throw new ResourceConflictException(
-            "cycle exists between " + project.get() + " and " + parent.getName());
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
deleted file mode 100644
index f501dd3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ /dev/null
@@ -1,673 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitTypeRecord;
-import com.google.gerrit.extensions.client.SubmitType;
-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.rules.PrologEnvironment;
-import com.google.gerrit.rules.StoredValues;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.account.Emails;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import com.googlecode.prolog_cafe.exceptions.CompileException;
-import com.googlecode.prolog_cafe.exceptions.ReductionLimitException;
-import com.googlecode.prolog_cafe.lang.IntegerTerm;
-import com.googlecode.prolog_cafe.lang.ListTerm;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.StructureTerm;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-import com.googlecode.prolog_cafe.lang.VariableTerm;
-import java.io.IOException;
-import java.io.StringReader;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Evaluates a submit-like Prolog rule found in the rules.pl file of the current project and filters
- * the results through rules found in the parent projects, all the way up to All-Projects.
- */
-public class SubmitRuleEvaluator {
-  private static final Logger log = LoggerFactory.getLogger(SubmitRuleEvaluator.class);
-
-  private static final String DEFAULT_MSG = "Error evaluating project rules, check server log";
-
-  public static List<SubmitRecord> defaultRuleError() {
-    return createRuleError(DEFAULT_MSG);
-  }
-
-  public static List<SubmitRecord> createRuleError(String err) {
-    SubmitRecord rec = new SubmitRecord();
-    rec.status = SubmitRecord.Status.RULE_ERROR;
-    rec.errorMessage = err;
-    return Collections.singletonList(rec);
-  }
-
-  public static SubmitTypeRecord defaultTypeError() {
-    return SubmitTypeRecord.error(DEFAULT_MSG);
-  }
-
-  /**
-   * Exception thrown when the label term of a submit record unexpectedly didn't contain a user
-   * term.
-   */
-  private static class UserTermExpected extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    UserTermExpected(SubmitRecord.Label label) {
-      super(String.format("A label with the status %s must contain a user.", label.toString()));
-    }
-  }
-
-  public interface Factory {
-    SubmitRuleEvaluator create(CurrentUser user, ChangeData cd);
-  }
-
-  private final AccountCache accountCache;
-  private final Accounts accounts;
-  private final Emails emails;
-  private final ProjectCache projectCache;
-  private final ChangeData cd;
-
-  private SubmitRuleOptions.Builder optsBuilder = SubmitRuleOptions.defaults();
-  private SubmitRuleOptions opts;
-  private Change change;
-  private CurrentUser user;
-  private PatchSet patchSet;
-  private boolean logErrors = true;
-  private long reductionsConsumed;
-  private ProjectState projectState;
-
-  private Term submitRule;
-
-  @Inject
-  SubmitRuleEvaluator(
-      AccountCache accountCache,
-      Accounts accounts,
-      Emails emails,
-      ProjectCache projectCache,
-      @Assisted CurrentUser user,
-      @Assisted ChangeData cd) {
-    this.accountCache = accountCache;
-    this.accounts = accounts;
-    this.emails = emails;
-    this.projectCache = projectCache;
-    this.user = user;
-    this.cd = cd;
-  }
-
-  /**
-   * @return immutable snapshot of options configured so far. If neither {@link #getSubmitRule()}
-   *     nor {@link #getSubmitType()} have been called yet, state within this instance is still
-   *     mutable, so may change before evaluation. The instance's options are frozen at evaluation
-   *     time.
-   */
-  public SubmitRuleOptions getOptions() {
-    if (opts != null) {
-      return opts;
-    }
-    return optsBuilder.build();
-  }
-
-  public SubmitRuleEvaluator setOptions(SubmitRuleOptions opts) {
-    checkNotStarted();
-    if (opts != null) {
-      optsBuilder = opts.toBuilder();
-    } else {
-      optsBuilder = SubmitRuleOptions.defaults();
-    }
-    return this;
-  }
-
-  /**
-   * @param ps patch set of the change to evaluate. If not set, the current patch set will be loaded
-   *     from {@link #evaluate()} or {@link #getSubmitType}.
-   * @return this
-   */
-  public SubmitRuleEvaluator setPatchSet(PatchSet ps) {
-    checkArgument(
-        ps.getId().getParentKey().equals(cd.getId()),
-        "Patch set %s does not match change %s",
-        ps.getId(),
-        cd.getId());
-    patchSet = ps;
-    return this;
-  }
-
-  /**
-   * @param fast if true assume reviewers are permitted to use label values currently stored on the
-   *     change. Fast mode bypasses some reviewer permission checks.
-   * @return this
-   */
-  public SubmitRuleEvaluator setFastEvalLabels(boolean fast) {
-    checkNotStarted();
-    optsBuilder.fastEvalLabels(fast);
-    return this;
-  }
-
-  /**
-   * @param allow whether to allow {@link #evaluate()} on closed changes.
-   * @return this
-   */
-  public SubmitRuleEvaluator setAllowClosed(boolean allow) {
-    checkNotStarted();
-    optsBuilder.allowClosed(allow);
-    return this;
-  }
-
-  /**
-   * @param skip if true, submit filter will not be applied.
-   * @return this
-   */
-  public SubmitRuleEvaluator setSkipSubmitFilters(boolean skip) {
-    checkNotStarted();
-    optsBuilder.skipFilters(skip);
-    return this;
-  }
-
-  /**
-   * @param rule custom rule to use, or null to use refs/meta/config:rules.pl.
-   * @return this
-   */
-  public SubmitRuleEvaluator setRule(@Nullable String rule) {
-    checkNotStarted();
-    optsBuilder.rule(rule);
-    return this;
-  }
-
-  /**
-   * @param log whether to log error messages in addition to returning error records. If true, error
-   *     record messages will be less descriptive.
-   */
-  public SubmitRuleEvaluator setLogErrors(boolean log) {
-    logErrors = log;
-    return this;
-  }
-
-  /** @return Prolog reductions consumed during evaluation. */
-  public long getReductionsConsumed() {
-    return reductionsConsumed;
-  }
-
-  /**
-   * Evaluate the submit rules.
-   *
-   * @return List of {@link SubmitRecord} objects returned from the evaluated rules, including any
-   *     errors.
-   */
-  public List<SubmitRecord> evaluate() {
-    initOptions();
-    try {
-      init();
-    } catch (OrmException e) {
-      return ruleError("Error looking up change " + cd.getId(), e);
-    }
-
-    if (!opts.allowClosed() && change.getStatus().isClosed()) {
-      SubmitRecord rec = new SubmitRecord();
-      rec.status = SubmitRecord.Status.CLOSED;
-      return Collections.singletonList(rec);
-    }
-
-    List<Term> results;
-    try {
-      results =
-          evaluateImpl(
-              "locate_submit_rule",
-              "can_submit",
-              "locate_submit_filter",
-              "filter_submit_results",
-              user);
-    } catch (RuleEvalException e) {
-      return ruleError(e.getMessage(), e);
-    }
-
-    if (results.isEmpty()) {
-      // This should never occur. A well written submit rule will always produce
-      // at least one result informing the caller of the labels that are
-      // required for this change to be submittable. Each label will indicate
-      // whether or not that is actually possible given the permissions.
-      return ruleError(
-          String.format(
-              "Submit rule '%s' for change %s of %s has no solution.",
-              getSubmitRuleName(), cd.getId(), getProjectName()));
-    }
-
-    return resultsToSubmitRecord(getSubmitRule(), results);
-  }
-
-  /**
-   * Convert the results from Prolog Cafe's format to Gerrit's common format.
-   *
-   * <p>can_submit/1 terminates when an ok(P) record is found. Therefore walk the results backwards,
-   * using only that ok(P) record if it exists. This skips partial results that occur early in the
-   * output. Later after the loop the out collection is reversed to restore it to the original
-   * ordering.
-   */
-  private List<SubmitRecord> resultsToSubmitRecord(Term submitRule, List<Term> results) {
-    List<SubmitRecord> out = new ArrayList<>(results.size());
-    for (int resultIdx = results.size() - 1; 0 <= resultIdx; resultIdx--) {
-      Term submitRecord = results.get(resultIdx);
-      SubmitRecord rec = new SubmitRecord();
-      out.add(rec);
-
-      if (!(submitRecord instanceof StructureTerm) || 1 != submitRecord.arity()) {
-        return invalidResult(submitRule, submitRecord);
-      }
-
-      if ("ok".equals(submitRecord.name())) {
-        rec.status = SubmitRecord.Status.OK;
-
-      } else if ("not_ready".equals(submitRecord.name())) {
-        rec.status = SubmitRecord.Status.NOT_READY;
-
-      } else {
-        return invalidResult(submitRule, submitRecord);
-      }
-
-      // Unpack the one argument. This should also be a structure with one
-      // argument per label that needs to be reported on to the caller.
-      //
-      submitRecord = submitRecord.arg(0);
-
-      if (!(submitRecord instanceof StructureTerm)) {
-        return invalidResult(submitRule, submitRecord);
-      }
-
-      rec.labels = new ArrayList<>(submitRecord.arity());
-
-      for (Term state : ((StructureTerm) submitRecord).args()) {
-        if (!(state instanceof StructureTerm)
-            || 2 != state.arity()
-            || !"label".equals(state.name())) {
-          return invalidResult(submitRule, submitRecord);
-        }
-
-        SubmitRecord.Label lbl = new SubmitRecord.Label();
-        rec.labels.add(lbl);
-
-        lbl.label = state.arg(0).name();
-        Term status = state.arg(1);
-
-        try {
-          if ("ok".equals(status.name())) {
-            lbl.status = SubmitRecord.Label.Status.OK;
-            appliedBy(lbl, status);
-
-          } else if ("reject".equals(status.name())) {
-            lbl.status = SubmitRecord.Label.Status.REJECT;
-            appliedBy(lbl, status);
-
-          } else if ("need".equals(status.name())) {
-            lbl.status = SubmitRecord.Label.Status.NEED;
-
-          } else if ("may".equals(status.name())) {
-            lbl.status = SubmitRecord.Label.Status.MAY;
-
-          } else if ("impossible".equals(status.name())) {
-            lbl.status = SubmitRecord.Label.Status.IMPOSSIBLE;
-
-          } else {
-            return invalidResult(submitRule, submitRecord);
-          }
-        } catch (UserTermExpected e) {
-          return invalidResult(submitRule, submitRecord, e.getMessage());
-        }
-      }
-
-      if (rec.status == SubmitRecord.Status.OK) {
-        break;
-      }
-    }
-    Collections.reverse(out);
-
-    return out;
-  }
-
-  private List<SubmitRecord> invalidResult(Term rule, Term record, String reason) {
-    return ruleError(
-        String.format(
-            "Submit rule %s for change %s of %s output invalid result: %s%s",
-            rule,
-            cd.getId(),
-            getProjectName(),
-            record,
-            (reason == null ? "" : ". Reason: " + reason)));
-  }
-
-  private List<SubmitRecord> invalidResult(Term rule, Term record) {
-    return invalidResult(rule, record, null);
-  }
-
-  private List<SubmitRecord> ruleError(String err) {
-    return ruleError(err, null);
-  }
-
-  private List<SubmitRecord> ruleError(String err, Exception e) {
-    if (logErrors) {
-      if (e == null) {
-        log.error(err);
-      } else {
-        log.error(err, e);
-      }
-      return defaultRuleError();
-    }
-    return createRuleError(err);
-  }
-
-  /**
-   * Evaluate the submit type rules to get the submit type.
-   *
-   * @return record from the evaluated rules.
-   */
-  public SubmitTypeRecord getSubmitType() {
-    initOptions();
-    try {
-      init();
-    } catch (OrmException e) {
-      return typeError("Error looking up change " + cd.getId(), e);
-    }
-
-    List<Term> results;
-    try {
-      results =
-          evaluateImpl(
-              "locate_submit_type",
-              "get_submit_type",
-              "locate_submit_type_filter",
-              "filter_submit_type_results",
-              // Do not include current user in submit type evaluation. This is used
-              // for mergeability checks, which are stored persistently and so must
-              // have a consistent view of the submit type.
-              null);
-    } catch (RuleEvalException e) {
-      return typeError(e.getMessage(), e);
-    }
-
-    if (results.isEmpty()) {
-      // Should never occur for a well written rule
-      return typeError(
-          "Submit rule '"
-              + getSubmitRuleName()
-              + "' for change "
-              + cd.getId()
-              + " of "
-              + getProjectName()
-              + " has no solution.");
-    }
-
-    Term typeTerm = results.get(0);
-    if (!(typeTerm instanceof SymbolTerm)) {
-      return typeError(
-          "Submit rule '"
-              + getSubmitRuleName()
-              + "' for change "
-              + cd.getId()
-              + " of "
-              + getProjectName()
-              + " did not return a symbol.");
-    }
-
-    String typeName = ((SymbolTerm) typeTerm).name();
-    try {
-      return SubmitTypeRecord.OK(SubmitType.valueOf(typeName.toUpperCase()));
-    } catch (IllegalArgumentException e) {
-      return typeError(
-          "Submit type rule "
-              + getSubmitRule()
-              + " for change "
-              + cd.getId()
-              + " of "
-              + getProjectName()
-              + " output invalid result: "
-              + typeName);
-    }
-  }
-
-  private SubmitTypeRecord typeError(String err) {
-    return typeError(err, null);
-  }
-
-  private SubmitTypeRecord typeError(String err, Exception e) {
-    if (logErrors) {
-      if (e == null) {
-        log.error(err);
-      } else {
-        log.error(err, e);
-      }
-      return defaultTypeError();
-    }
-    return SubmitTypeRecord.error(err);
-  }
-
-  private List<Term> evaluateImpl(
-      String userRuleLocatorName,
-      String userRuleWrapperName,
-      String filterRuleLocatorName,
-      String filterRuleWrapperName,
-      CurrentUser user)
-      throws RuleEvalException {
-    PrologEnvironment env = getPrologEnvironment(user);
-    try {
-      Term sr = env.once("gerrit", userRuleLocatorName, new VariableTerm());
-      if (opts.fastEvalLabels()) {
-        env.once("gerrit", "assume_range_from_label");
-      }
-
-      List<Term> results = new ArrayList<>();
-      try {
-        for (Term[] template : env.all("gerrit", userRuleWrapperName, sr, new VariableTerm())) {
-          results.add(template[1]);
-        }
-      } catch (ReductionLimitException err) {
-        throw new RuleEvalException(
-            String.format(
-                "%s on change %d of %s", err.getMessage(), cd.getId().get(), getProjectName()));
-      } catch (RuntimeException err) {
-        throw new RuleEvalException(
-            String.format(
-                "Exception calling %s on change %d of %s", sr, cd.getId().get(), getProjectName()),
-            err);
-      } finally {
-        reductionsConsumed = env.getReductions();
-      }
-
-      Term resultsTerm = toListTerm(results);
-      if (!opts.skipFilters()) {
-        resultsTerm =
-            runSubmitFilters(resultsTerm, env, filterRuleLocatorName, filterRuleWrapperName);
-      }
-      List<Term> r;
-      if (resultsTerm instanceof ListTerm) {
-        r = new ArrayList<>();
-        for (Term t = resultsTerm; t instanceof ListTerm; ) {
-          ListTerm l = (ListTerm) t;
-          r.add(l.car().dereference());
-          t = l.cdr().dereference();
-        }
-      } else {
-        r = Collections.emptyList();
-      }
-      submitRule = sr;
-      return r;
-    } finally {
-      env.close();
-    }
-  }
-
-  private PrologEnvironment getPrologEnvironment(CurrentUser user) throws RuleEvalException {
-    PrologEnvironment env;
-    try {
-      if (opts.rule() == null) {
-        env = projectState.newPrologEnvironment();
-      } else {
-        env = projectState.newPrologEnvironment("stdin", new StringReader(opts.rule()));
-      }
-    } catch (CompileException err) {
-      String msg;
-      if (opts.rule() == null) {
-        msg = String.format("Cannot load rules.pl for %s: %s", getProjectName(), err.getMessage());
-      } else {
-        msg = err.getMessage();
-      }
-      throw new RuleEvalException(msg, err);
-    }
-    env.set(StoredValues.ACCOUNTS, accounts);
-    env.set(StoredValues.ACCOUNT_CACHE, accountCache);
-    env.set(StoredValues.EMAILS, emails);
-    env.set(StoredValues.REVIEW_DB, cd.db());
-    env.set(StoredValues.CHANGE_DATA, cd);
-    if (user != null) {
-      env.set(StoredValues.CURRENT_USER, user);
-    }
-    env.set(StoredValues.PROJECT_STATE, projectState);
-    return env;
-  }
-
-  private Term runSubmitFilters(
-      Term results,
-      PrologEnvironment env,
-      String filterRuleLocatorName,
-      String filterRuleWrapperName)
-      throws RuleEvalException {
-    PrologEnvironment childEnv = env;
-    for (ProjectState parentState : projectState.parents()) {
-      PrologEnvironment parentEnv;
-      try {
-        parentEnv = parentState.newPrologEnvironment();
-      } catch (CompileException err) {
-        throw new RuleEvalException("Cannot consult rules.pl for " + parentState.getName(), err);
-      }
-
-      parentEnv.copyStoredValues(childEnv);
-      Term filterRule = parentEnv.once("gerrit", filterRuleLocatorName, new VariableTerm());
-      try {
-        if (opts.fastEvalLabels()) {
-          env.once("gerrit", "assume_range_from_label");
-        }
-
-        Term[] template =
-            parentEnv.once(
-                "gerrit", filterRuleWrapperName, filterRule, results, new VariableTerm());
-        results = template[2];
-      } catch (ReductionLimitException err) {
-        throw new RuleEvalException(
-            String.format(
-                "%s on change %d of %s",
-                err.getMessage(), cd.getId().get(), parentState.getName()));
-      } catch (RuntimeException err) {
-        throw new RuleEvalException(
-            String.format(
-                "Exception calling %s on change %d of %s",
-                filterRule, cd.getId().get(), parentState.getName()),
-            err);
-      } finally {
-        reductionsConsumed += env.getReductions();
-      }
-      childEnv = parentEnv;
-    }
-    return results;
-  }
-
-  private static Term toListTerm(List<Term> terms) {
-    Term list = Prolog.Nil;
-    for (int i = terms.size() - 1; i >= 0; i--) {
-      list = new ListTerm(terms.get(i), list);
-    }
-    return list;
-  }
-
-  private void appliedBy(SubmitRecord.Label label, Term status) throws UserTermExpected {
-    if (status instanceof StructureTerm && status.arity() == 1) {
-      Term who = status.arg(0);
-      if (isUser(who)) {
-        label.appliedBy = new Account.Id(((IntegerTerm) who.arg(0)).intValue());
-      } else {
-        throw new UserTermExpected(label);
-      }
-    }
-  }
-
-  private static boolean isUser(Term who) {
-    return who instanceof StructureTerm
-        && who.arity() == 1
-        && who.name().equals("user")
-        && who.arg(0) instanceof IntegerTerm;
-  }
-
-  public Term getSubmitRule() {
-    checkState(submitRule != null, "getSubmitRule() invalid before evaluation");
-    return submitRule;
-  }
-
-  public String getSubmitRuleName() {
-    return submitRule != null ? submitRule.toString() : "<unknown rule>";
-  }
-
-  private void checkNotStarted() {
-    checkState(opts == null, "cannot set options after starting evaluation");
-  }
-
-  private void initOptions() {
-    if (opts == null) {
-      opts = optsBuilder.build();
-      optsBuilder = null;
-    }
-  }
-
-  private void init() throws OrmException {
-    if (change == null) {
-      change = cd.change();
-      if (change == null) {
-        throw new OrmException("No change found");
-      }
-    }
-
-    if (projectState == null) {
-      try {
-        projectState = projectCache.checkedGet(change.getProject());
-      } catch (IOException e) {
-        throw new OrmException("Can't load project state", e);
-      }
-    }
-
-    if (patchSet == null) {
-      patchSet = cd.currentPatchSet();
-      if (patchSet == null) {
-        throw new OrmException("No patch set found");
-      }
-    }
-  }
-
-  private String getProjectName() {
-    return projectState.getName();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleOptions.java
deleted file mode 100644
index 3e89f23..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleOptions.java
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.auto.value.AutoValue;
-import com.google.gerrit.common.Nullable;
-
-/**
- * Stable identifier for options passed to a particular submit rule evaluator.
- *
- * <p>Used to test whether it is ok to reuse a cached list of submit records. Does not include a
- * change or patch set ID; callers are responsible for checking those on their own.
- */
-@AutoValue
-public abstract class SubmitRuleOptions {
-  public static Builder builder() {
-    return new AutoValue_SubmitRuleOptions.Builder();
-  }
-
-  public static Builder defaults() {
-    return builder().fastEvalLabels(false).allowClosed(false).skipFilters(false).rule(null);
-  }
-
-  public abstract boolean fastEvalLabels();
-
-  public abstract boolean allowClosed();
-
-  public abstract boolean skipFilters();
-
-  @Nullable
-  public abstract String rule();
-
-  @AutoValue.Builder
-  public abstract static class Builder {
-    public abstract SubmitRuleOptions.Builder fastEvalLabels(boolean fastEvalLabels);
-
-    public abstract SubmitRuleOptions.Builder allowClosed(boolean allowClosed);
-
-    public abstract SubmitRuleOptions.Builder skipFilters(boolean skipFilters);
-
-    public abstract SubmitRuleOptions.Builder rule(@Nullable String rule);
-
-    public abstract SubmitRuleOptions build();
-  }
-
-  public Builder toBuilder() {
-    return builder()
-        .fastEvalLabels(fastEvalLabels())
-        .allowClosed(allowClosed())
-        .skipFilters(skipFilters())
-        .rule(rule());
-  }
-}
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
deleted file mode 100644
index fe4d68d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagResource.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.api.projects.TagInfo;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.inject.TypeLiteral;
-
-public class TagResource extends RefResource {
-  public static final TypeLiteral<RestView<TagResource>> TAG_KIND =
-      new TypeLiteral<RestView<TagResource>>() {};
-
-  private final TagInfo tagInfo;
-
-  public TagResource(ProjectControl control, TagInfo tagInfo) {
-    super(control);
-    this.tagInfo = tagInfo;
-  }
-
-  public TagInfo getTagInfo() {
-    return tagInfo;
-  }
-
-  @Override
-  public String getRef() {
-    return tagInfo.ref;
-  }
-
-  @Override
-  public String getRevision() {
-    return tagInfo.revision;
-  }
-}
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
deleted file mode 100644
index 78670ad..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-
-@Singleton
-public class TagsCollection
-    implements ChildCollection<ProjectResource, TagResource>, AcceptsCreate<ProjectResource> {
-  private final DynamicMap<RestView<TagResource>> views;
-  private final Provider<ListTags> list;
-  private final CreateTag.Factory createTagFactory;
-
-  @Inject
-  public TagsCollection(
-      DynamicMap<RestView<TagResource>> views,
-      Provider<ListTags> list,
-      CreateTag.Factory createTagFactory) {
-    this.views = views;
-    this.list = list;
-    this.createTagFactory = createTagFactory;
-  }
-
-  @Override
-  public RestView<ProjectResource> list() throws ResourceNotFoundException {
-    return list.get();
-  }
-
-  @Override
-  public TagResource parse(ProjectResource resource, IdString id)
-      throws ResourceNotFoundException, IOException {
-    return new TagResource(resource.getControl(), list.get().get(resource, id));
-  }
-
-  @Override
-  public DynamicMap<RestView<TagResource>> views() {
-    return views;
-  }
-
-  @Override
-  public CreateTag create(ProjectResource resource, IdString name) {
-    return createTagFactory.create(name.get());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
deleted file mode 100644
index 9213353..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ /dev/null
@@ -1,115 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.account;
-
-import com.google.common.collect.Lists;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryBuilder;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.index.account.AccountField;
-import java.util.List;
-
-public class AccountPredicates {
-  public static boolean hasActive(Predicate<AccountState> p) {
-    return QueryBuilder.find(p, AccountPredicate.class, AccountField.ACTIVE.getName()) != null;
-  }
-
-  public static Predicate<AccountState> andActive(Predicate<AccountState> p) {
-    return Predicate.and(p, isActive());
-  }
-
-  public static Predicate<AccountState> defaultPredicate(String query) {
-    // Adapt the capacity of this list when adding more default predicates.
-    List<Predicate<AccountState>> preds = Lists.newArrayListWithCapacity(3);
-    Integer id = Ints.tryParse(query);
-    if (id != null) {
-      preds.add(id(new Account.Id(id)));
-    }
-    preds.add(equalsName(query));
-    preds.add(username(query));
-    // Adapt the capacity of the "predicates" list when adding more default
-    // predicates.
-    return Predicate.or(preds);
-  }
-
-  public static Predicate<AccountState> id(Account.Id accountId) {
-    return new AccountPredicate(
-        AccountField.ID, AccountQueryBuilder.FIELD_ACCOUNT, accountId.toString());
-  }
-
-  public static Predicate<AccountState> email(String email) {
-    return new AccountPredicate(
-        AccountField.EMAIL, AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
-  }
-
-  public static Predicate<AccountState> preferredEmail(String email) {
-    return new AccountPredicate(
-        AccountField.PREFERRED_EMAIL,
-        AccountQueryBuilder.FIELD_PREFERRED_EMAIL,
-        email.toLowerCase());
-  }
-
-  public static Predicate<AccountState> preferredEmailExact(String email) {
-    return new AccountPredicate(
-        AccountField.PREFERRED_EMAIL_EXACT, AccountQueryBuilder.FIELD_PREFERRED_EMAIL_EXACT, email);
-  }
-
-  public static Predicate<AccountState> equalsName(String name) {
-    return new AccountPredicate(
-        AccountField.NAME_PART, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
-  }
-
-  public static Predicate<AccountState> externalId(String externalId) {
-    return new AccountPredicate(AccountField.EXTERNAL_ID, externalId);
-  }
-
-  public static Predicate<AccountState> fullName(String fullName) {
-    return new AccountPredicate(AccountField.FULL_NAME, fullName);
-  }
-
-  public static Predicate<AccountState> isActive() {
-    return new AccountPredicate(AccountField.ACTIVE, "1");
-  }
-
-  public static Predicate<AccountState> isNotActive() {
-    return new AccountPredicate(AccountField.ACTIVE, "0");
-  }
-
-  public static Predicate<AccountState> username(String username) {
-    return new AccountPredicate(
-        AccountField.USERNAME, AccountQueryBuilder.FIELD_USERNAME, username.toLowerCase());
-  }
-
-  public static Predicate<AccountState> watchedProject(Project.NameKey project) {
-    return new AccountPredicate(AccountField.WATCHED_PROJECT, project.get());
-  }
-
-  static class AccountPredicate extends IndexPredicate<AccountState> {
-    AccountPredicate(FieldDef<AccountState, ?> def, String value) {
-      super(def, value);
-    }
-
-    AccountPredicate(FieldDef<AccountState, ?> def, String name, String value) {
-      super(def, name, value);
-    }
-  }
-
-  private AccountPredicates() {}
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
deleted file mode 100644
index 946a729..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.account;
-
-import com.google.common.base.Splitter;
-import com.google.common.collect.Lists;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.errors.NotSignedInException;
-import com.google.gerrit.index.query.LimitPredicate;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryBuilder;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountState;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-
-/** Parses a query string meant to be applied to account objects. */
-public class AccountQueryBuilder extends QueryBuilder<AccountState> {
-  public static final String FIELD_ACCOUNT = "account";
-  public static final String FIELD_EMAIL = "email";
-  public static final String FIELD_LIMIT = "limit";
-  public static final String FIELD_NAME = "name";
-  public static final String FIELD_PREFERRED_EMAIL = "preferredemail";
-  public static final String FIELD_PREFERRED_EMAIL_EXACT = "preferredemail_exact";
-  public static final String FIELD_USERNAME = "username";
-  public static final String FIELD_VISIBLETO = "visibleto";
-
-  private static final QueryBuilder.Definition<AccountState, AccountQueryBuilder> mydef =
-      new QueryBuilder.Definition<>(AccountQueryBuilder.class);
-
-  public static class Arguments {
-    private final Provider<CurrentUser> self;
-
-    @Inject
-    public Arguments(Provider<CurrentUser> self) {
-      this.self = self;
-    }
-
-    IdentifiedUser getIdentifiedUser() throws QueryParseException {
-      try {
-        CurrentUser u = getUser();
-        if (u.isIdentifiedUser()) {
-          return u.asIdentifiedUser();
-        }
-        throw new QueryParseException(NotSignedInException.MESSAGE);
-      } catch (ProvisionException e) {
-        throw new QueryParseException(NotSignedInException.MESSAGE, e);
-      }
-    }
-
-    CurrentUser getUser() throws QueryParseException {
-      try {
-        return self.get();
-      } catch (ProvisionException e) {
-        throw new QueryParseException(NotSignedInException.MESSAGE, e);
-      }
-    }
-  }
-
-  private final Arguments args;
-
-  @Inject
-  AccountQueryBuilder(Arguments args) {
-    super(mydef);
-    this.args = args;
-  }
-
-  @Operator
-  public Predicate<AccountState> email(String email) {
-    return AccountPredicates.email(email);
-  }
-
-  @Operator
-  public Predicate<AccountState> is(String value) throws QueryParseException {
-    if ("active".equalsIgnoreCase(value)) {
-      return AccountPredicates.isActive();
-    }
-    if ("inactive".equalsIgnoreCase(value)) {
-      return AccountPredicates.isNotActive();
-    }
-    throw error("Invalid query");
-  }
-
-  @Operator
-  public Predicate<AccountState> limit(String query) throws QueryParseException {
-    Integer limit = Ints.tryParse(query);
-    if (limit == null) {
-      throw error("Invalid limit: " + query);
-    }
-    return new LimitPredicate<>(FIELD_LIMIT, limit);
-  }
-
-  @Operator
-  public Predicate<AccountState> name(String name) {
-    return AccountPredicates.equalsName(name);
-  }
-
-  @Operator
-  public Predicate<AccountState> username(String username) {
-    return AccountPredicates.username(username);
-  }
-
-  public Predicate<AccountState> defaultQuery(String query) {
-    return Predicate.and(
-        Lists.transform(
-            Splitter.on(' ').omitEmptyStrings().splitToList(query), this::defaultField));
-  }
-
-  @Override
-  protected Predicate<AccountState> defaultField(String query) {
-    Predicate<AccountState> defaultPredicate = AccountPredicates.defaultPredicate(query);
-    if ("self".equalsIgnoreCase(query) || "me".equalsIgnoreCase(query)) {
-      try {
-        return Predicate.or(defaultPredicate, AccountPredicates.id(self()));
-      } catch (QueryParseException e) {
-        // Skip.
-      }
-    }
-    return defaultPredicate;
-  }
-
-  private Account.Id self() throws QueryParseException {
-    return args.getIdentifiedUser().getAccountId();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
deleted file mode 100644
index f42c099..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ /dev/null
@@ -1,204 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.account;
-
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.base.Joiner;
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Multimap;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.InternalQuery;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.index.account.AccountField;
-import com.google.gerrit.server.index.account.AccountIndexCollection;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Set;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Query wrapper for the account index.
- *
- * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
- * holding on to a single instance.
- */
-public class InternalAccountQuery extends InternalQuery<AccountState> {
-  private static final Logger log = LoggerFactory.getLogger(InternalAccountQuery.class);
-
-  @Inject
-  InternalAccountQuery(
-      AccountQueryProcessor queryProcessor,
-      AccountIndexCollection indexes,
-      IndexConfig indexConfig) {
-    super(queryProcessor, indexes, indexConfig);
-  }
-
-  @Override
-  public InternalAccountQuery setLimit(int n) {
-    super.setLimit(n);
-    return this;
-  }
-
-  @Override
-  public InternalAccountQuery enforceVisibility(boolean enforce) {
-    super.enforceVisibility(enforce);
-    return this;
-  }
-
-  @Override
-  public InternalAccountQuery setRequestedFields(Set<String> fields) {
-    super.setRequestedFields(fields);
-    return this;
-  }
-
-  @Override
-  public InternalAccountQuery noFields() {
-    super.noFields();
-    return this;
-  }
-
-  public List<AccountState> byDefault(String query) throws OrmException {
-    return query(AccountPredicates.defaultPredicate(query));
-  }
-
-  public List<AccountState> byExternalId(String scheme, String id) throws OrmException {
-    return byExternalId(ExternalId.Key.create(scheme, id));
-  }
-
-  public List<AccountState> byExternalId(ExternalId.Key externalId) throws OrmException {
-    return query(AccountPredicates.externalId(externalId.toString()));
-  }
-
-  public AccountState oneByExternalId(String externalId) throws OrmException {
-    return oneByExternalId(ExternalId.Key.parse(externalId));
-  }
-
-  public AccountState oneByExternalId(String scheme, String id) throws OrmException {
-    return oneByExternalId(ExternalId.Key.create(scheme, id));
-  }
-
-  public AccountState oneByExternalId(ExternalId.Key externalId) throws OrmException {
-    List<AccountState> accountStates = byExternalId(externalId);
-    if (accountStates.size() == 1) {
-      return accountStates.get(0);
-    } else if (accountStates.size() > 0) {
-      StringBuilder msg = new StringBuilder();
-      msg.append("Ambiguous external ID ").append(externalId).append(" for accounts: ");
-      Joiner.on(", ")
-          .appendTo(msg, Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
-      log.warn(msg.toString());
-    }
-    return null;
-  }
-
-  public List<AccountState> byFullName(String fullName) throws OrmException {
-    return query(AccountPredicates.fullName(fullName));
-  }
-
-  /**
-   * Queries for accounts that have a preferred email that exactly matches the given email.
-   *
-   * @param email preferred email by which accounts should be found
-   * @return list of accounts that have a preferred email that exactly matches the given email
-   * @throws OrmException if query cannot be parsed
-   */
-  public List<AccountState> byPreferredEmail(String email) throws OrmException {
-    if (hasPreferredEmailExact()) {
-      return query(AccountPredicates.preferredEmailExact(email));
-    }
-
-    if (!hasPreferredEmail()) {
-      return ImmutableList.of();
-    }
-
-    return query(AccountPredicates.preferredEmail(email))
-        .stream()
-        .filter(a -> a.getAccount().getPreferredEmail().equals(email))
-        .collect(toList());
-  }
-
-  /**
-   * Makes multiple queries for accounts by preferred email (exact match).
-   *
-   * @param emails preferred emails by which accounts should be found
-   * @return multimap of the given emails to accounts that have a preferred email that exactly
-   *     matches this email
-   * @throws OrmException if query cannot be parsed
-   */
-  public Multimap<String, AccountState> byPreferredEmail(String... emails) throws OrmException {
-    List<String> emailList = Arrays.asList(emails);
-
-    if (hasPreferredEmailExact()) {
-      List<List<AccountState>> r =
-          query(
-              emailList
-                  .stream()
-                  .map(e -> AccountPredicates.preferredEmailExact(e))
-                  .collect(toList()));
-      Multimap<String, AccountState> accountsByEmail = ArrayListMultimap.create();
-      for (int i = 0; i < emailList.size(); i++) {
-        accountsByEmail.putAll(emailList.get(i), r.get(i));
-      }
-      return accountsByEmail;
-    }
-
-    if (!hasPreferredEmail()) {
-      return ImmutableListMultimap.of();
-    }
-
-    List<List<AccountState>> r =
-        query(emailList.stream().map(e -> AccountPredicates.preferredEmail(e)).collect(toList()));
-    Multimap<String, AccountState> accountsByEmail = ArrayListMultimap.create();
-    for (int i = 0; i < emailList.size(); i++) {
-      String email = emailList.get(i);
-      Set<AccountState> matchingAccounts =
-          r.get(i)
-              .stream()
-              .filter(a -> a.getAccount().getPreferredEmail().equals(email))
-              .collect(toSet());
-      accountsByEmail.putAll(email, matchingAccounts);
-    }
-    return accountsByEmail;
-  }
-
-  public List<AccountState> byWatchedProject(Project.NameKey project) throws OrmException {
-    return query(AccountPredicates.watchedProject(project));
-  }
-
-  private boolean hasField(FieldDef<AccountState, ?> field) {
-    Schema<AccountState> s = schema();
-    return (s != null && s.hasField(field));
-  }
-
-  private boolean hasPreferredEmail() {
-    return hasField(AccountField.PREFERRED_EMAIL);
-  }
-
-  private boolean hasPreferredEmailExact() {
-    return hasField(AccountField.PREFERRED_EMAIL_EXACT);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
deleted file mode 100644
index 2a71258..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ /dev/null
@@ -1,1270 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.ApprovalsUtil.sortApprovals;
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toMap;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSortedSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitTypeRecord;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RobotComment;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.ReviewerByEmailSet;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.StarRef;
-import com.google.gerrit.server.change.GetPureRevert;
-import com.google.gerrit.server.change.MergeabilityCache;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.patch.DiffSummary;
-import com.google.gerrit.server.patch.DiffSummaryKey;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListKey;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.project.SubmitRuleOptions;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.stream.Stream;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.FooterLine;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-public class ChangeData {
-  private static final int BATCH_SIZE = 50;
-
-  public static List<Change> asChanges(List<ChangeData> changeDatas) throws OrmException {
-    List<Change> result = new ArrayList<>(changeDatas.size());
-    for (ChangeData cd : changeDatas) {
-      result.add(cd.change());
-    }
-    return result;
-  }
-
-  public static Map<Change.Id, ChangeData> asMap(List<ChangeData> changes) {
-    return changes.stream().collect(toMap(ChangeData::getId, cd -> cd));
-  }
-
-  public static void ensureChangeLoaded(Iterable<ChangeData> changes) throws OrmException {
-    ChangeData first = Iterables.getFirst(changes, null);
-    if (first == null) {
-      return;
-    } else if (first.notesMigration.readChanges()) {
-      for (ChangeData cd : changes) {
-        cd.change();
-      }
-      return;
-    }
-
-    Map<Change.Id, ChangeData> missing = new HashMap<>();
-    for (ChangeData cd : changes) {
-      if (cd.change == null) {
-        missing.put(cd.getId(), cd);
-      }
-    }
-    if (missing.isEmpty()) {
-      return;
-    }
-    for (ChangeNotes notes : first.notesFactory.create(first.db, missing.keySet())) {
-      missing.get(notes.getChangeId()).change = notes.getChange();
-    }
-  }
-
-  public static void ensureAllPatchSetsLoaded(Iterable<ChangeData> changes) throws OrmException {
-    ChangeData first = Iterables.getFirst(changes, null);
-    if (first == null) {
-      return;
-    } else if (first.notesMigration.readChanges()) {
-      for (ChangeData cd : changes) {
-        cd.patchSets();
-      }
-      return;
-    }
-
-    List<ResultSet<PatchSet>> results = new ArrayList<>(BATCH_SIZE);
-    for (List<ChangeData> batch : Iterables.partition(changes, BATCH_SIZE)) {
-      results.clear();
-      for (ChangeData cd : batch) {
-        if (cd.patchSets == null) {
-          results.add(cd.db.patchSets().byChange(cd.getId()));
-        } else {
-          results.add(null);
-        }
-      }
-      for (int i = 0; i < batch.size(); i++) {
-        ResultSet<PatchSet> result = results.get(i);
-        if (result != null) {
-          batch.get(i).patchSets = result.toList();
-        }
-      }
-    }
-  }
-
-  public static void ensureCurrentPatchSetLoaded(Iterable<ChangeData> changes) throws OrmException {
-    ChangeData first = Iterables.getFirst(changes, null);
-    if (first == null) {
-      return;
-    } else if (first.notesMigration.readChanges()) {
-      for (ChangeData cd : changes) {
-        cd.currentPatchSet();
-      }
-      return;
-    }
-
-    Map<PatchSet.Id, ChangeData> missing = new HashMap<>();
-    for (ChangeData cd : changes) {
-      if (cd.currentPatchSet == null && cd.patchSets == null) {
-        missing.put(cd.change().currentPatchSetId(), cd);
-      }
-    }
-    if (missing.isEmpty()) {
-      return;
-    }
-    for (PatchSet ps : first.db.patchSets().get(missing.keySet())) {
-      missing.get(ps.getId()).currentPatchSet = ps;
-    }
-  }
-
-  public static void ensureCurrentApprovalsLoaded(Iterable<ChangeData> changes)
-      throws OrmException {
-    ChangeData first = Iterables.getFirst(changes, null);
-    if (first == null) {
-      return;
-    } else if (first.notesMigration.readChanges()) {
-      for (ChangeData cd : changes) {
-        cd.currentApprovals();
-      }
-      return;
-    }
-
-    List<ResultSet<PatchSetApproval>> results = new ArrayList<>(BATCH_SIZE);
-    for (List<ChangeData> batch : Iterables.partition(changes, BATCH_SIZE)) {
-      results.clear();
-      for (ChangeData cd : batch) {
-        if (cd.currentApprovals == null) {
-          PatchSet.Id psId = cd.change().currentPatchSetId();
-          results.add(cd.db.patchSetApprovals().byPatchSet(psId));
-        } else {
-          results.add(null);
-        }
-      }
-      for (int i = 0; i < batch.size(); i++) {
-        ResultSet<PatchSetApproval> result = results.get(i);
-        if (result != null) {
-          batch.get(i).currentApprovals = sortApprovals(result);
-        }
-      }
-    }
-  }
-
-  public static void ensureMessagesLoaded(Iterable<ChangeData> changes) throws OrmException {
-    ChangeData first = Iterables.getFirst(changes, null);
-    if (first == null) {
-      return;
-    } else if (first.notesMigration.readChanges()) {
-      for (ChangeData cd : changes) {
-        cd.messages();
-      }
-      return;
-    }
-
-    List<ResultSet<ChangeMessage>> results = new ArrayList<>(BATCH_SIZE);
-    for (List<ChangeData> batch : Iterables.partition(changes, BATCH_SIZE)) {
-      results.clear();
-      for (ChangeData cd : batch) {
-        if (cd.messages == null) {
-          PatchSet.Id psId = cd.change().currentPatchSetId();
-          results.add(cd.db.changeMessages().byPatchSet(psId));
-        } else {
-          results.add(null);
-        }
-      }
-      for (int i = 0; i < batch.size(); i++) {
-        ResultSet<ChangeMessage> result = results.get(i);
-        if (result != null) {
-          batch.get(i).messages = result.toList();
-        }
-      }
-    }
-  }
-
-  public static void ensureReviewedByLoadedForOpenChanges(Iterable<ChangeData> changes)
-      throws OrmException {
-    List<ChangeData> pending = new ArrayList<>();
-    for (ChangeData cd : changes) {
-      if (cd.reviewedBy == null && cd.change().getStatus().isOpen()) {
-        pending.add(cd);
-      }
-    }
-
-    if (!pending.isEmpty()) {
-      ensureAllPatchSetsLoaded(pending);
-      ensureMessagesLoaded(pending);
-      for (ChangeData cd : pending) {
-        cd.reviewedBy();
-      }
-    }
-  }
-
-  public static class Factory {
-    private final AssistedFactory assistedFactory;
-
-    @Inject
-    Factory(AssistedFactory assistedFactory) {
-      this.assistedFactory = assistedFactory;
-    }
-
-    public ChangeData create(ReviewDb db, Project.NameKey project, Change.Id id) {
-      return assistedFactory.create(db, project, id, null, null);
-    }
-
-    public ChangeData create(ReviewDb db, Change change) {
-      return assistedFactory.create(db, change.getProject(), change.getId(), change, null);
-    }
-
-    public ChangeData create(ReviewDb db, ChangeNotes notes) {
-      return assistedFactory.create(
-          db, notes.getChange().getProject(), notes.getChangeId(), notes.getChange(), notes);
-    }
-  }
-
-  public interface AssistedFactory {
-    ChangeData create(
-        ReviewDb db,
-        Project.NameKey project,
-        Change.Id id,
-        @Nullable Change change,
-        @Nullable ChangeNotes notes);
-  }
-
-  /**
-   * Create an instance for testing only.
-   *
-   * <p>Attempting to lazy load data will fail with NPEs. Callers may consider manually setting
-   * fields that can be set.
-   *
-   * @param id change ID
-   * @return instance for testing.
-   */
-  public static ChangeData createForTest(
-      Project.NameKey project, Change.Id id, int currentPatchSetId) {
-    ChangeData cd =
-        new ChangeData(
-            null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, null, null, project, id, null, null);
-    cd.currentPatchSet = new PatchSet(new PatchSet.Id(id, currentPatchSetId));
-    return cd;
-  }
-
-  // Injected fields.
-  private @Nullable final StarredChangesUtil starredChangesUtil;
-  private final AllUsersName allUsersName;
-  private final ApprovalsUtil approvalsUtil;
-  private final ChangeMessagesUtil cmUtil;
-  private final ChangeNotes.Factory notesFactory;
-  private final CommentsUtil commentsUtil;
-  private final GitRepositoryManager repoManager;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final MergeUtil.Factory mergeUtilFactory;
-  private final MergeabilityCache mergeabilityCache;
-  private final NotesMigration notesMigration;
-  private final PatchListCache patchListCache;
-  private final PatchSetUtil psUtil;
-  private final ProjectCache projectCache;
-  private final TrackingFooters trackingFooters;
-  private final GetPureRevert pureRevert;
-  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
-  private final ChangeControl.GenericFactory changeControlFactory;
-
-  // Required assisted injected fields.
-  private final ReviewDb db;
-  private final Project.NameKey project;
-  private final Change.Id legacyId;
-
-  // Lazily populated fields, including optional assisted injected fields.
-
-  private final Map<SubmitRuleOptions, List<SubmitRecord>> submitRecords =
-      Maps.newLinkedHashMapWithExpectedSize(1);
-
-  private boolean lazyLoad = true;
-  private Change change;
-  private ChangeNotes notes;
-  private String commitMessage;
-  private List<FooterLine> commitFooters;
-  private PatchSet currentPatchSet;
-  private Collection<PatchSet> patchSets;
-  private ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals;
-  private List<PatchSetApproval> currentApprovals;
-  private List<String> currentFiles;
-  private Optional<DiffSummary> diffSummary;
-  private Collection<Comment> publishedComments;
-  private Collection<RobotComment> robotComments;
-  private CurrentUser visibleTo;
-  private ChangeControl changeControl;
-  private List<ChangeMessage> messages;
-  private Optional<ChangedLines> changedLines;
-  private SubmitTypeRecord submitTypeRecord;
-  private Boolean mergeable;
-  private Set<String> hashtags;
-  private Map<Account.Id, Ref> editsByUser;
-  private Set<Account.Id> reviewedBy;
-  private Map<Account.Id, Ref> draftsByUser;
-  private ImmutableListMultimap<Account.Id, String> stars;
-  private StarsOf starsOf;
-  private ImmutableMap<Account.Id, StarRef> starRefs;
-  private ReviewerSet reviewers;
-  private ReviewerByEmailSet reviewersByEmail;
-  private ReviewerSet pendingReviewers;
-  private ReviewerByEmailSet pendingReviewersByEmail;
-  private List<ReviewerStatusUpdate> reviewerUpdates;
-  private PersonIdent author;
-  private PersonIdent committer;
-  private int parentCount;
-  private Integer unresolvedCommentCount;
-  private LabelTypes labelTypes;
-
-  private ImmutableList<byte[]> refStates;
-  private ImmutableList<byte[]> refStatePatterns;
-
-  @Inject
-  private ChangeData(
-      @Nullable StarredChangesUtil starredChangesUtil,
-      ApprovalsUtil approvalsUtil,
-      AllUsersName allUsersName,
-      ChangeMessagesUtil cmUtil,
-      ChangeNotes.Factory notesFactory,
-      CommentsUtil commentsUtil,
-      GitRepositoryManager repoManager,
-      IdentifiedUser.GenericFactory userFactory,
-      MergeUtil.Factory mergeUtilFactory,
-      MergeabilityCache mergeabilityCache,
-      NotesMigration notesMigration,
-      PatchListCache patchListCache,
-      PatchSetUtil psUtil,
-      ProjectCache projectCache,
-      TrackingFooters trackingFooters,
-      GetPureRevert pureRevert,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
-      ChangeControl.GenericFactory changeControlFactory,
-      @Assisted ReviewDb db,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id id,
-      @Assisted @Nullable Change change,
-      @Assisted @Nullable ChangeNotes notes) {
-    this.approvalsUtil = approvalsUtil;
-    this.allUsersName = allUsersName;
-    this.cmUtil = cmUtil;
-    this.notesFactory = notesFactory;
-    this.commentsUtil = commentsUtil;
-    this.repoManager = repoManager;
-    this.userFactory = userFactory;
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.mergeabilityCache = mergeabilityCache;
-    this.notesMigration = notesMigration;
-    this.patchListCache = patchListCache;
-    this.psUtil = psUtil;
-    this.projectCache = projectCache;
-    this.starredChangesUtil = starredChangesUtil;
-    this.trackingFooters = trackingFooters;
-    this.pureRevert = pureRevert;
-    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
-    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
-    // using Guice to pass a non-null value.
-    this.db = db;
-
-    this.project = project;
-    this.legacyId = id;
-
-    this.change = change;
-    this.notes = notes;
-  }
-
-  public ChangeData setLazyLoad(boolean load) {
-    lazyLoad = load;
-    return this;
-  }
-
-  public ReviewDb db() {
-    return db;
-  }
-
-  public AllUsersName getAllUsersNameForIndexing() {
-    return allUsersName;
-  }
-
-  public void setCurrentFilePaths(List<String> filePaths) throws OrmException {
-    PatchSet ps = currentPatchSet();
-    if (ps != null) {
-      currentFiles = ImmutableList.copyOf(filePaths);
-    }
-  }
-
-  public List<String> currentFilePaths() throws IOException, OrmException {
-    if (currentFiles == null) {
-      if (!lazyLoad) {
-        return Collections.emptyList();
-      }
-      Optional<DiffSummary> p = getDiffSummary();
-      currentFiles = p.map(DiffSummary::getPaths).orElse(Collections.emptyList());
-    }
-    return currentFiles;
-  }
-
-  private Optional<DiffSummary> getDiffSummary() throws OrmException, IOException {
-    if (diffSummary == null) {
-      if (!lazyLoad) {
-        return Optional.empty();
-      }
-
-      Change c = change();
-      PatchSet ps = currentPatchSet();
-      if (c == null || ps == null || !loadCommitData()) {
-        return Optional.empty();
-      }
-
-      ObjectId id = ObjectId.fromString(ps.getRevision().get());
-      Whitespace ws = Whitespace.IGNORE_NONE;
-      PatchListKey pk =
-          parentCount > 1
-              ? PatchListKey.againstParentNum(1, id, ws)
-              : PatchListKey.againstDefaultBase(id, ws);
-      DiffSummaryKey key = DiffSummaryKey.fromPatchListKey(pk);
-      try {
-        diffSummary = Optional.of(patchListCache.getDiffSummary(key, c.getProject()));
-      } catch (PatchListNotAvailableException e) {
-        diffSummary = Optional.empty();
-      }
-    }
-    return diffSummary;
-  }
-
-  private Optional<ChangedLines> computeChangedLines() throws OrmException, IOException {
-    Optional<DiffSummary> ds = getDiffSummary();
-    if (ds.isPresent()) {
-      return Optional.of(ds.get().getChangedLines());
-    }
-    return Optional.empty();
-  }
-
-  public Optional<ChangedLines> changedLines() throws OrmException, IOException {
-    if (changedLines == null) {
-      if (!lazyLoad) {
-        return Optional.empty();
-      }
-      changedLines = computeChangedLines();
-    }
-    return changedLines;
-  }
-
-  public void setChangedLines(int insertions, int deletions) {
-    changedLines = Optional.of(new ChangedLines(insertions, deletions));
-  }
-
-  public void setNoChangedLines() {
-    changedLines = Optional.empty();
-  }
-
-  public Change.Id getId() {
-    return legacyId;
-  }
-
-  public Project.NameKey project() {
-    return project;
-  }
-
-  boolean fastIsVisibleTo(CurrentUser user) {
-    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;
-  }
-
-  public Change change() throws OrmException {
-    if (change == null && lazyLoad) {
-      reloadChange();
-    }
-    return change;
-  }
-
-  public void setChange(Change c) {
-    change = c;
-  }
-
-  public Change reloadChange() throws OrmException {
-    try {
-      notes = notesFactory.createChecked(db, project, legacyId);
-    } catch (NoSuchChangeException e) {
-      throw new OrmException("Unable to load change " + legacyId, e);
-    }
-    change = notes.getChange();
-    setPatchSets(null);
-    return change;
-  }
-
-  public LabelTypes getLabelTypes() throws OrmException {
-    if (labelTypes == null) {
-      ProjectState state;
-      try {
-        state = projectCache.checkedGet(project());
-      } catch (IOException e) {
-        throw new OrmException("project state not available", e);
-      }
-      labelTypes = state.getLabelTypes(change().getDest(), userFactory.create(change().getOwner()));
-    }
-    return labelTypes;
-  }
-
-  public ChangeNotes notes() throws OrmException {
-    if (notes == null) {
-      if (!lazyLoad) {
-        throw new OrmException("ChangeNotes not available, lazyLoad = false");
-      }
-      notes = notesFactory.create(db, project(), legacyId);
-    }
-    return notes;
-  }
-
-  public PatchSet currentPatchSet() throws OrmException {
-    if (currentPatchSet == null) {
-      Change c = change();
-      if (c == null) {
-        return null;
-      }
-      for (PatchSet p : patchSets()) {
-        if (p.getId().equals(c.currentPatchSetId())) {
-          currentPatchSet = p;
-          return p;
-        }
-      }
-    }
-    return currentPatchSet;
-  }
-
-  public List<PatchSetApproval> currentApprovals() throws OrmException {
-    if (currentApprovals == null) {
-      if (!lazyLoad) {
-        return Collections.emptyList();
-      }
-      Change c = change();
-      if (c == null) {
-        currentApprovals = Collections.emptyList();
-      } else {
-        try {
-          currentApprovals =
-              ImmutableList.copyOf(
-                  approvalsUtil.byPatchSet(
-                      db,
-                      notes(),
-                      userFactory.create(c.getOwner()),
-                      c.currentPatchSetId(),
-                      null,
-                      null));
-        } catch (OrmException e) {
-          if (e.getCause() instanceof NoSuchChangeException) {
-            currentApprovals = Collections.emptyList();
-          } else {
-            throw e;
-          }
-        }
-      }
-    }
-    return currentApprovals;
-  }
-
-  public void setCurrentApprovals(List<PatchSetApproval> approvals) {
-    currentApprovals = approvals;
-  }
-
-  public String commitMessage() throws IOException, OrmException {
-    if (commitMessage == null) {
-      if (!loadCommitData()) {
-        return null;
-      }
-    }
-    return commitMessage;
-  }
-
-  public List<FooterLine> commitFooters() throws IOException, OrmException {
-    if (commitFooters == null) {
-      if (!loadCommitData()) {
-        return null;
-      }
-    }
-    return commitFooters;
-  }
-
-  public ListMultimap<String, String> trackingFooters() throws IOException, OrmException {
-    return trackingFooters.extract(commitFooters());
-  }
-
-  public PersonIdent getAuthor() throws IOException, OrmException {
-    if (author == null) {
-      if (!loadCommitData()) {
-        return null;
-      }
-    }
-    return author;
-  }
-
-  public PersonIdent getCommitter() throws IOException, OrmException {
-    if (committer == null) {
-      if (!loadCommitData()) {
-        return null;
-      }
-    }
-    return committer;
-  }
-
-  private boolean loadCommitData()
-      throws OrmException, RepositoryNotFoundException, IOException, MissingObjectException,
-          IncorrectObjectTypeException {
-    PatchSet ps = currentPatchSet();
-    if (ps == null) {
-      return false;
-    }
-    String sha1 = ps.getRevision().get();
-    try (Repository repo = repoManager.openRepository(project());
-        RevWalk walk = new RevWalk(repo)) {
-      RevCommit c = walk.parseCommit(ObjectId.fromString(sha1));
-      commitMessage = c.getFullMessage();
-      commitFooters = c.getFooterLines();
-      author = c.getAuthorIdent();
-      committer = c.getCommitterIdent();
-      parentCount = c.getParentCount();
-    }
-    return true;
-  }
-
-  /**
-   * @return patches for the change, in patch set ID order.
-   * @throws OrmException an error occurred reading the database.
-   */
-  public Collection<PatchSet> patchSets() throws OrmException {
-    if (patchSets == null) {
-      patchSets = psUtil.byChange(db, notes());
-    }
-    return patchSets;
-  }
-
-  public void setPatchSets(Collection<PatchSet> patchSets) {
-    this.currentPatchSet = null;
-    this.patchSets = patchSets;
-  }
-
-  /**
-   * @return patch with the given ID, or null if it does not exist.
-   * @throws OrmException an error occurred reading the database.
-   */
-  public PatchSet patchSet(PatchSet.Id psId) throws OrmException {
-    if (currentPatchSet != null && currentPatchSet.getId().equals(psId)) {
-      return currentPatchSet;
-    }
-    for (PatchSet ps : patchSets()) {
-      if (ps.getId().equals(psId)) {
-        return ps;
-      }
-    }
-    return null;
-  }
-
-  /**
-   * @return all patch set approvals for the change, keyed by ID, ordered by timestamp within each
-   *     patch set.
-   * @throws OrmException an error occurred reading the database.
-   */
-  public ListMultimap<PatchSet.Id, PatchSetApproval> approvals() throws OrmException {
-    if (allApprovals == null) {
-      if (!lazyLoad) {
-        return ImmutableListMultimap.of();
-      }
-      allApprovals = approvalsUtil.byChange(db, notes());
-    }
-    return allApprovals;
-  }
-
-  /**
-   * @return The submit ('SUBM') approval label
-   * @throws OrmException an error occurred reading the database.
-   */
-  public Optional<PatchSetApproval> getSubmitApproval() throws OrmException {
-    return currentApprovals().stream().filter(PatchSetApproval::isLegacySubmit).findFirst();
-  }
-
-  public ReviewerSet reviewers() throws OrmException {
-    if (reviewers == null) {
-      if (!lazyLoad) {
-        return ReviewerSet.empty();
-      }
-      reviewers = approvalsUtil.getReviewers(notes(), approvals().values());
-    }
-    return reviewers;
-  }
-
-  public void setReviewers(ReviewerSet reviewers) {
-    this.reviewers = reviewers;
-  }
-
-  public ReviewerSet getReviewers() {
-    return reviewers;
-  }
-
-  public ReviewerByEmailSet reviewersByEmail() throws OrmException {
-    if (reviewersByEmail == null) {
-      if (!lazyLoad) {
-        return ReviewerByEmailSet.empty();
-      }
-      reviewersByEmail = notes().getReviewersByEmail();
-    }
-    return reviewersByEmail;
-  }
-
-  public void setReviewersByEmail(ReviewerByEmailSet reviewersByEmail) {
-    this.reviewersByEmail = reviewersByEmail;
-  }
-
-  public ReviewerByEmailSet getReviewersByEmail() {
-    return reviewersByEmail;
-  }
-
-  public void setPendingReviewers(ReviewerSet pendingReviewers) {
-    this.pendingReviewers = pendingReviewers;
-  }
-
-  public ReviewerSet getPendingReviewers() {
-    return this.pendingReviewers;
-  }
-
-  public ReviewerSet pendingReviewers() throws OrmException {
-    if (pendingReviewers == null) {
-      if (!lazyLoad) {
-        return ReviewerSet.empty();
-      }
-      pendingReviewers = notes().getPendingReviewers();
-    }
-    return pendingReviewers;
-  }
-
-  public void setPendingReviewersByEmail(ReviewerByEmailSet pendingReviewersByEmail) {
-    this.pendingReviewersByEmail = pendingReviewersByEmail;
-  }
-
-  public ReviewerByEmailSet getPendingReviewersByEmail() {
-    return pendingReviewersByEmail;
-  }
-
-  public ReviewerByEmailSet pendingReviewersByEmail() throws OrmException {
-    if (pendingReviewersByEmail == null) {
-      if (!lazyLoad) {
-        return ReviewerByEmailSet.empty();
-      }
-      pendingReviewersByEmail = notes().getPendingReviewersByEmail();
-    }
-    return pendingReviewersByEmail;
-  }
-
-  public List<ReviewerStatusUpdate> reviewerUpdates() throws OrmException {
-    if (reviewerUpdates == null) {
-      if (!lazyLoad) {
-        return Collections.emptyList();
-      }
-      reviewerUpdates = approvalsUtil.getReviewerUpdates(notes());
-    }
-    return reviewerUpdates;
-  }
-
-  public void setReviewerUpdates(List<ReviewerStatusUpdate> reviewerUpdates) {
-    this.reviewerUpdates = reviewerUpdates;
-  }
-
-  public List<ReviewerStatusUpdate> getReviewerUpdates() {
-    return reviewerUpdates;
-  }
-
-  public Collection<Comment> publishedComments() throws OrmException {
-    if (publishedComments == null) {
-      if (!lazyLoad) {
-        return Collections.emptyList();
-      }
-      publishedComments = commentsUtil.publishedByChange(db, notes());
-    }
-    return publishedComments;
-  }
-
-  public Collection<RobotComment> robotComments() throws OrmException {
-    if (robotComments == null) {
-      if (!lazyLoad) {
-        return Collections.emptyList();
-      }
-      robotComments = commentsUtil.robotCommentsByChange(notes());
-    }
-    return robotComments;
-  }
-
-  public Integer unresolvedCommentCount() throws OrmException {
-    if (unresolvedCommentCount == null) {
-      if (!lazyLoad) {
-        return null;
-      }
-
-      List<Comment> comments =
-          Stream.concat(publishedComments().stream(), robotComments().stream()).collect(toList());
-      Set<String> nonLeafSet = comments.stream().map(c -> c.parentUuid).collect(toSet());
-
-      Long count =
-          comments.stream().filter(c -> (c.unresolved && !nonLeafSet.contains(c.key.uuid))).count();
-      unresolvedCommentCount = count.intValue();
-    }
-    return unresolvedCommentCount;
-  }
-
-  public void setUnresolvedCommentCount(Integer count) {
-    this.unresolvedCommentCount = count;
-  }
-
-  public List<ChangeMessage> messages() throws OrmException {
-    if (messages == null) {
-      if (!lazyLoad) {
-        return Collections.emptyList();
-      }
-      messages = cmUtil.byChange(db, notes());
-    }
-    return messages;
-  }
-
-  public List<SubmitRecord> submitRecords(SubmitRuleOptions options) throws OrmException {
-    List<SubmitRecord> records = submitRecords.get(options);
-    if (records == null) {
-      if (!lazyLoad) {
-        return Collections.emptyList();
-      }
-      records =
-          submitRuleEvaluatorFactory
-              .create(userFactory.create(change().getOwner()), this)
-              .setOptions(options)
-              .evaluate();
-      submitRecords.put(options, records);
-    }
-    return records;
-  }
-
-  @Nullable
-  public List<SubmitRecord> getSubmitRecords(SubmitRuleOptions options) {
-    return submitRecords.get(options);
-  }
-
-  public void setSubmitRecords(SubmitRuleOptions options, List<SubmitRecord> records) {
-    submitRecords.put(options, records);
-  }
-
-  public SubmitTypeRecord submitTypeRecord() throws OrmException {
-    if (submitTypeRecord == null) {
-      submitTypeRecord =
-          submitRuleEvaluatorFactory
-              .create(userFactory.create(change().getOwner()), this)
-              .getSubmitType();
-    }
-    return submitTypeRecord;
-  }
-
-  public void setMergeable(Boolean mergeable) {
-    this.mergeable = mergeable;
-  }
-
-  public Boolean isMergeable() throws OrmException {
-    if (mergeable == null) {
-      Change c = change();
-      if (c == null) {
-        return null;
-      }
-      if (c.getStatus() == Change.Status.MERGED) {
-        mergeable = true;
-      } else if (c.getStatus() == Change.Status.ABANDONED) {
-        return null;
-      } else if (c.isWorkInProgress()) {
-        return null;
-      } else {
-        if (!lazyLoad) {
-          return null;
-        }
-        PatchSet ps = currentPatchSet();
-        try {
-          if (ps == null || !changeControl().isVisible(db)) {
-            return null;
-          }
-        } catch (OrmException e) {
-          if (e.getCause() instanceof NoSuchChangeException) {
-            return null;
-          }
-          throw e;
-        }
-
-        try (Repository repo = repoManager.openRepository(project())) {
-          Ref ref = repo.getRefDatabase().exactRef(c.getDest().get());
-          SubmitTypeRecord str = submitTypeRecord();
-          if (!str.isOk()) {
-            // If submit type rules are broken, it's definitely not mergeable.
-            // No need to log, as SubmitRuleEvaluator already did it for us.
-            return false;
-          }
-          String mergeStrategy =
-              mergeUtilFactory.create(projectCache.get(project())).mergeStrategyName();
-          mergeable =
-              mergeabilityCache.get(
-                  ObjectId.fromString(ps.getRevision().get()),
-                  ref,
-                  str.type,
-                  mergeStrategy,
-                  c.getDest(),
-                  repo);
-        } catch (IOException e) {
-          throw new OrmException(e);
-        }
-      }
-    }
-    return mergeable;
-  }
-
-  public Set<Account.Id> editsByUser() throws OrmException {
-    return editRefs().keySet();
-  }
-
-  public Map<Account.Id, Ref> editRefs() throws OrmException {
-    if (editsByUser == null) {
-      if (!lazyLoad) {
-        return Collections.emptyMap();
-      }
-      Change c = change();
-      if (c == null) {
-        return Collections.emptyMap();
-      }
-      editsByUser = new HashMap<>();
-      Change.Id id = checkNotNull(change.getId());
-      try (Repository repo = repoManager.openRepository(project())) {
-        for (Map.Entry<String, Ref> e :
-            repo.getRefDatabase().getRefs(RefNames.REFS_USERS).entrySet()) {
-          if (id.equals(Change.Id.fromEditRefPart(e.getKey()))) {
-            editsByUser.put(Account.Id.fromRefPart(e.getKey()), e.getValue());
-          }
-        }
-      } catch (IOException e) {
-        throw new OrmException(e);
-      }
-    }
-    return editsByUser;
-  }
-
-  public Set<Account.Id> draftsByUser() throws OrmException {
-    return draftRefs().keySet();
-  }
-
-  public Map<Account.Id, Ref> draftRefs() throws OrmException {
-    if (draftsByUser == null) {
-      if (!lazyLoad) {
-        return Collections.emptyMap();
-      }
-      Change c = change();
-      if (c == null) {
-        return Collections.emptyMap();
-      }
-
-      draftsByUser = new HashMap<>();
-      if (notesMigration.readChanges()) {
-        for (Ref ref : commentsUtil.getDraftRefs(notes.getChangeId())) {
-          Account.Id account = Account.Id.fromRefSuffix(ref.getName());
-          if (account != null
-              // Double-check that any drafts exist for this user after
-              // filtering out zombies. If some but not all drafts in the ref
-              // were zombies, the returned Ref still includes those zombies;
-              // this is suboptimal, but is ok for the purposes of
-              // draftsByUser(), and easier than trying to rebuild the change at
-              // this point.
-              && !notes().getDraftComments(account, ref).isEmpty()) {
-            draftsByUser.put(account, ref);
-          }
-        }
-      } else {
-        for (Comment sc : commentsUtil.draftByChange(db, notes())) {
-          draftsByUser.put(sc.author.getId(), null);
-        }
-      }
-    }
-    return draftsByUser;
-  }
-
-  public boolean isReviewedBy(Account.Id accountId) throws OrmException {
-    Collection<String> stars = stars(accountId);
-
-    if (stars.contains(
-        StarredChangesUtil.REVIEWED_LABEL + "/" + currentPatchSet().getPatchSetId())) {
-      return true;
-    }
-
-    if (stars.contains(
-        StarredChangesUtil.UNREVIEWED_LABEL + "/" + currentPatchSet().getPatchSetId())) {
-      return false;
-    }
-
-    return reviewedBy().contains(accountId);
-  }
-
-  public Set<Account.Id> reviewedBy() throws OrmException {
-    if (reviewedBy == null) {
-      if (!lazyLoad) {
-        return Collections.emptySet();
-      }
-      Change c = change();
-      if (c == null) {
-        return Collections.emptySet();
-      }
-      List<ReviewedByEvent> events = new ArrayList<>();
-      for (ChangeMessage msg : messages()) {
-        if (msg.getAuthor() != null) {
-          events.add(ReviewedByEvent.create(msg));
-        }
-      }
-      events = Lists.reverse(events);
-      reviewedBy = new LinkedHashSet<>();
-      Account.Id owner = c.getOwner();
-      for (ReviewedByEvent event : events) {
-        if (owner.equals(event.author())) {
-          break;
-        }
-        reviewedBy.add(event.author());
-      }
-    }
-    return reviewedBy;
-  }
-
-  public void setReviewedBy(Set<Account.Id> reviewedBy) {
-    this.reviewedBy = reviewedBy;
-  }
-
-  public Set<String> hashtags() throws OrmException {
-    if (hashtags == null) {
-      if (!lazyLoad) {
-        return Collections.emptySet();
-      }
-      hashtags = notes().getHashtags();
-    }
-    return hashtags;
-  }
-
-  public void setHashtags(Set<String> hashtags) {
-    this.hashtags = hashtags;
-  }
-
-  public ImmutableListMultimap<Account.Id, String> stars() throws OrmException {
-    if (stars == null) {
-      if (!lazyLoad) {
-        return ImmutableListMultimap.of();
-      }
-      ImmutableListMultimap.Builder<Account.Id, String> b = ImmutableListMultimap.builder();
-      for (Map.Entry<Account.Id, StarRef> e : starRefs().entrySet()) {
-        b.putAll(e.getKey(), e.getValue().labels());
-      }
-      return b.build();
-    }
-    return stars;
-  }
-
-  public void setStars(ListMultimap<Account.Id, String> stars) {
-    this.stars = ImmutableListMultimap.copyOf(stars);
-  }
-
-  public ImmutableMap<Account.Id, StarRef> starRefs() throws OrmException {
-    if (starRefs == null) {
-      if (!lazyLoad) {
-        return ImmutableMap.of();
-      }
-      starRefs = checkNotNull(starredChangesUtil).byChange(legacyId);
-    }
-    return starRefs;
-  }
-
-  public Set<String> stars(Account.Id accountId) throws OrmException {
-    if (starsOf != null) {
-      if (!starsOf.accountId().equals(accountId)) {
-        starsOf = null;
-      }
-    }
-    if (starsOf == null) {
-      if (stars != null) {
-        starsOf = StarsOf.create(accountId, stars.get(accountId));
-      } else {
-        if (!lazyLoad) {
-          return ImmutableSet.of();
-        }
-        starsOf = StarsOf.create(accountId, starredChangesUtil.getLabels(accountId, legacyId));
-      }
-    }
-    return starsOf.stars();
-  }
-
-  /**
-   * @return {@code null} if {@code revertOf} is {@code null}; true if the change is a pure revert;
-   *     false otherwise.
-   */
-  @Nullable
-  public Boolean isPureRevert() throws OrmException {
-    if (change().getRevertOf() == null) {
-      return null;
-    }
-    try {
-      return pureRevert.getPureRevert(notes()).isPureRevert;
-    } catch (IOException | BadRequestException | ResourceConflictException e) {
-      throw new OrmException("could not compute pure revert", e);
-    }
-  }
-
-  @Override
-  public String toString() {
-    MoreObjects.ToStringHelper h = MoreObjects.toStringHelper(this);
-    if (change != null) {
-      h.addValue(change);
-    } else {
-      h.addValue(legacyId);
-    }
-    return h.toString();
-  }
-
-  public static class ChangedLines {
-    public final int insertions;
-    public final int deletions;
-
-    public ChangedLines(int insertions, int deletions) {
-      this.insertions = insertions;
-      this.deletions = deletions;
-    }
-  }
-
-  public ImmutableList<byte[]> getRefStates() {
-    return refStates;
-  }
-
-  public void setRefStates(Iterable<byte[]> refStates) {
-    this.refStates = ImmutableList.copyOf(refStates);
-  }
-
-  public ImmutableList<byte[]> getRefStatePatterns() {
-    return refStatePatterns;
-  }
-
-  public void setRefStatePatterns(Iterable<byte[]> refStatePatterns) {
-    this.refStatePatterns = ImmutableList.copyOf(refStatePatterns);
-  }
-
-  @AutoValue
-  abstract static class ReviewedByEvent {
-    private static ReviewedByEvent create(ChangeMessage msg) {
-      return new AutoValue_ChangeData_ReviewedByEvent(msg.getAuthor(), msg.getWrittenOn());
-    }
-
-    public abstract Account.Id author();
-
-    public abstract Timestamp ts();
-  }
-
-  @AutoValue
-  abstract static class StarsOf {
-    private static StarsOf create(Account.Id accountId, Iterable<String> stars) {
-      return new AutoValue_ChangeData_StarsOf(accountId, ImmutableSortedSet.copyOf(stars));
-    }
-
-    public abstract Account.Id accountId();
-
-    public abstract ImmutableSortedSet<String> stars();
-  }
-}
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
deleted file mode 100644
index 7bbb27b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.index.query.IsVisibleToPredicate;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.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.gwtorm.server.OrmException;
-import com.google.inject.Provider;
-
-public class ChangeIsVisibleToPredicate extends IsVisibleToPredicate<ChangeData> {
-  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;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) throws OrmException {
-    if (cd.fastIsVisibleTo(user)) {
-      return true;
-    }
-    Change change = cd.change();
-    if (change == null) {
-      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 =
-          permissionBackend
-              .user(user)
-              .indexedChange(cd, notes)
-              .database(db)
-              .test(ChangePermission.READ);
-    } catch (PermissionBackendException e) {
-      throw new OrmException("unable to check permissions", e);
-    }
-    if (visible) {
-      cd.cacheVisibleTo(changeControl);
-      return true;
-    }
-    return false;
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
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
deleted file mode 100644
index 16d07d4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ /dev/null
@@ -1,1340 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
-import static com.google.gerrit.server.query.change.ChangeData.asChanges;
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Enums;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.errors.NotSignedInException;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.SchemaUtil;
-import com.google.gerrit.index.query.LimitPredicate;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryBuilder;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.index.query.QueryRequiresAuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupBackends;
-import com.google.gerrit.server.account.VersionedAccountDestinations;
-import com.google.gerrit.server.account.VersionedAccountQueries;
-import com.google.gerrit.server.change.ChangeTriplet;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.strategy.SubmitDryRun;
-import com.google.gerrit.server.group.ListMembers;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.index.change.ChangeIndexRewriter;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.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;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import com.google.inject.util.Providers;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
-import java.util.function.Function;
-import java.util.regex.Pattern;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
-
-/** Parses a query string meant to be applied to change objects. */
-public class ChangeQueryBuilder extends QueryBuilder<ChangeData> {
-  public interface ChangeOperatorFactory extends OperatorFactory<ChangeData, ChangeQueryBuilder> {}
-
-  /**
-   * Converts a operand (operator value) passed to an operator into a {@link Predicate}.
-   *
-   * <p>Register a ChangeOperandFactory in a config Module like this (note, for an example we are
-   * using the has predicate, when other predicate plugin operands are created they can be
-   * registered in a similar manner):
-   *
-   * <p>bind(ChangeHasOperandFactory.class) .annotatedWith(Exports.named("your has operand"))
-   * .to(YourClass.class);
-   */
-  private interface ChangeOperandFactory {
-    Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException;
-  }
-
-  public interface ChangeHasOperandFactory extends ChangeOperandFactory {}
-
-  private static final Pattern PAT_LEGACY_ID = Pattern.compile("^[1-9][0-9]*$");
-  private static final Pattern PAT_CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
-  private static final Pattern DEF_CHANGE =
-      Pattern.compile("^(?:[1-9][0-9]*|(?:[^~]+~[^~]+~)?[iI][0-9a-f]{4,}.*)$");
-
-  static final int MAX_ACCOUNTS_PER_DEFAULT_FIELD = 10;
-
-  // NOTE: As new search operations are added, please keep the
-  // SearchSuggestOracle up to date.
-
-  public static final String FIELD_ADDED = "added";
-  public static final String FIELD_AGE = "age";
-  public static final String FIELD_ASSIGNEE = "assignee";
-  public static final String FIELD_AUTHOR = "author";
-  public static final String FIELD_EXACTAUTHOR = "exactauthor";
-  public static final String FIELD_BEFORE = "before";
-  public static final String FIELD_CHANGE = "change";
-  public static final String FIELD_CHANGE_ID = "change_id";
-  public static final String FIELD_COMMENT = "comment";
-  public static final String FIELD_COMMENTBY = "commentby";
-  public static final String FIELD_COMMIT = "commit";
-  public static final String FIELD_COMMITTER = "committer";
-  public static final String FIELD_EXACTCOMMITTER = "exactcommitter";
-  public static final String FIELD_CONFLICTS = "conflicts";
-  public static final String FIELD_DELETED = "deleted";
-  public static final String FIELD_DELTA = "delta";
-  public static final String FIELD_DESTINATION = "destination";
-  public static final String FIELD_DRAFTBY = "draftby";
-  public static final String FIELD_EDITBY = "editby";
-  public static final String FIELD_EXACTCOMMIT = "exactcommit";
-  public static final String FIELD_FILE = "file";
-  public static final String FIELD_FILEPART = "filepart";
-  public static final String FIELD_GROUP = "group";
-  public static final String FIELD_HASHTAG = "hashtag";
-  public static final String FIELD_LABEL = "label";
-  public static final String FIELD_LIMIT = "limit";
-  public static final String FIELD_MERGE = "merge";
-  public static final String FIELD_MERGEABLE = "mergeable2";
-  public static final String FIELD_MESSAGE = "message";
-  public static final String FIELD_OWNER = "owner";
-  public static final String FIELD_OWNERIN = "ownerin";
-  public static final String FIELD_PARENTPROJECT = "parentproject";
-  public static final String FIELD_PATH = "path";
-  public static final String FIELD_PENDING_REVIEWER = "pendingreviewer";
-  public static final String FIELD_PENDING_REVIEWER_BY_EMAIL = "pendingreviewerbyemail";
-  public static final String FIELD_PRIVATE = "private";
-  public static final String FIELD_PROJECT = "project";
-  public static final String FIELD_PROJECTS = "projects";
-  public static final String FIELD_REF = "ref";
-  public static final String FIELD_REVIEWEDBY = "reviewedby";
-  public static final String FIELD_REVIEWER = "reviewer";
-  public static final String FIELD_REVIEWERIN = "reviewerin";
-  public static final String FIELD_STAR = "star";
-  public static final String FIELD_STARBY = "starby";
-  public static final String FIELD_STARREDBY = "starredby";
-  public static final String FIELD_STARTED = "started";
-  public static final String FIELD_STATUS = "status";
-  public static final String FIELD_SUBMISSIONID = "submissionid";
-  public static final String FIELD_TR = "tr";
-  public static final String FIELD_UNRESOLVED_COMMENT_COUNT = "unresolved";
-  public static final String FIELD_VISIBLETO = "visibleto";
-  public static final String FIELD_WATCHEDBY = "watchedby";
-  public static final String FIELD_WIP = "wip";
-  public static final String FIELD_REVERTOF = "revertof";
-
-  public static final String ARG_ID_USER = "user";
-  public static final String ARG_ID_GROUP = "group";
-  public static final String ARG_ID_OWNER = "owner";
-  public static final Account.Id OWNER_ACCOUNT_ID = new Account.Id(0);
-
-  private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> mydef =
-      new QueryBuilder.Definition<>(ChangeQueryBuilder.class);
-
-  @VisibleForTesting
-  public static class Arguments {
-    final AccountCache accountCache;
-    final AccountResolver accountResolver;
-    final AllProjectsName allProjectsName;
-    final AllUsersName allUsersName;
-    final PermissionBackend permissionBackend;
-    final ChangeControl.GenericFactory changeControlGenericFactory;
-    final ChangeData.Factory changeDataFactory;
-    final ChangeIndex index;
-    final ChangeIndexRewriter rewriter;
-    final ChangeNotes.Factory notesFactory;
-    final CommentsUtil commentsUtil;
-    final ConflictsCache conflictsCache;
-    final DynamicMap<ChangeHasOperandFactory> hasOperands;
-    final DynamicMap<ChangeOperatorFactory> opFactories;
-    final GitRepositoryManager repoManager;
-    final GroupBackend groupBackend;
-    final IdentifiedUser.GenericFactory userFactory;
-    final IndexConfig indexConfig;
-    final NotesMigration notesMigration;
-    final PatchListCache patchListCache;
-    final ProjectCache projectCache;
-    final Provider<InternalChangeQuery> queryProvider;
-    final Provider<ListChildProjects> listChildProjects;
-    final Provider<ListMembers> listMembers;
-    final Provider<ReviewDb> db;
-    final StarredChangesUtil starredChangesUtil;
-    final SubmitDryRun submitDryRun;
-    final boolean allowsDrafts;
-
-    private final Provider<CurrentUser> self;
-
-    @Inject
-    @VisibleForTesting
-    public Arguments(
-        Provider<ReviewDb> db,
-        Provider<InternalChangeQuery> queryProvider,
-        ChangeIndexRewriter rewriter,
-        DynamicMap<ChangeOperatorFactory> opFactories,
-        DynamicMap<ChangeHasOperandFactory> hasOperands,
-        IdentifiedUser.GenericFactory userFactory,
-        Provider<CurrentUser> self,
-        PermissionBackend permissionBackend,
-        ChangeControl.GenericFactory changeControlGenericFactory,
-        ChangeNotes.Factory notesFactory,
-        ChangeData.Factory changeDataFactory,
-        CommentsUtil commentsUtil,
-        AccountResolver accountResolver,
-        GroupBackend groupBackend,
-        AllProjectsName allProjectsName,
-        AllUsersName allUsersName,
-        PatchListCache patchListCache,
-        GitRepositoryManager repoManager,
-        ProjectCache projectCache,
-        Provider<ListChildProjects> listChildProjects,
-        ChangeIndexCollection indexes,
-        SubmitDryRun submitDryRun,
-        ConflictsCache conflictsCache,
-        IndexConfig indexConfig,
-        Provider<ListMembers> listMembers,
-        StarredChangesUtil starredChangesUtil,
-        AccountCache accountCache,
-        @GerritServerConfig Config cfg,
-        NotesMigration notesMigration) {
-      this(
-          db,
-          queryProvider,
-          rewriter,
-          opFactories,
-          hasOperands,
-          userFactory,
-          self,
-          permissionBackend,
-          changeControlGenericFactory,
-          notesFactory,
-          changeDataFactory,
-          commentsUtil,
-          accountResolver,
-          groupBackend,
-          allProjectsName,
-          allUsersName,
-          patchListCache,
-          repoManager,
-          projectCache,
-          listChildProjects,
-          submitDryRun,
-          conflictsCache,
-          indexes != null ? indexes.getSearchIndex() : null,
-          indexConfig,
-          listMembers,
-          starredChangesUtil,
-          accountCache,
-          cfg == null ? true : cfg.getBoolean("change", "allowDrafts", true),
-          notesMigration);
-    }
-
-    private Arguments(
-        Provider<ReviewDb> db,
-        Provider<InternalChangeQuery> queryProvider,
-        ChangeIndexRewriter rewriter,
-        DynamicMap<ChangeOperatorFactory> opFactories,
-        DynamicMap<ChangeHasOperandFactory> hasOperands,
-        IdentifiedUser.GenericFactory userFactory,
-        Provider<CurrentUser> self,
-        PermissionBackend permissionBackend,
-        ChangeControl.GenericFactory changeControlGenericFactory,
-        ChangeNotes.Factory notesFactory,
-        ChangeData.Factory changeDataFactory,
-        CommentsUtil commentsUtil,
-        AccountResolver accountResolver,
-        GroupBackend groupBackend,
-        AllProjectsName allProjectsName,
-        AllUsersName allUsersName,
-        PatchListCache patchListCache,
-        GitRepositoryManager repoManager,
-        ProjectCache projectCache,
-        Provider<ListChildProjects> listChildProjects,
-        SubmitDryRun submitDryRun,
-        ConflictsCache conflictsCache,
-        ChangeIndex index,
-        IndexConfig indexConfig,
-        Provider<ListMembers> listMembers,
-        StarredChangesUtil starredChangesUtil,
-        AccountCache accountCache,
-        boolean allowsDrafts,
-        NotesMigration notesMigration) {
-      this.db = db;
-      this.queryProvider = queryProvider;
-      this.rewriter = rewriter;
-      this.opFactories = opFactories;
-      this.userFactory = userFactory;
-      this.self = self;
-      this.permissionBackend = permissionBackend;
-      this.notesFactory = notesFactory;
-      this.changeControlGenericFactory = changeControlGenericFactory;
-      this.changeDataFactory = changeDataFactory;
-      this.commentsUtil = commentsUtil;
-      this.accountResolver = accountResolver;
-      this.groupBackend = groupBackend;
-      this.allProjectsName = allProjectsName;
-      this.allUsersName = allUsersName;
-      this.patchListCache = patchListCache;
-      this.repoManager = repoManager;
-      this.projectCache = projectCache;
-      this.listChildProjects = listChildProjects;
-      this.submitDryRun = submitDryRun;
-      this.conflictsCache = conflictsCache;
-      this.index = index;
-      this.indexConfig = indexConfig;
-      this.listMembers = listMembers;
-      this.starredChangesUtil = starredChangesUtil;
-      this.accountCache = accountCache;
-      this.allowsDrafts = allowsDrafts;
-      this.hasOperands = hasOperands;
-      this.notesMigration = notesMigration;
-    }
-
-    Arguments asUser(CurrentUser otherUser) {
-      return new Arguments(
-          db,
-          queryProvider,
-          rewriter,
-          opFactories,
-          hasOperands,
-          userFactory,
-          Providers.of(otherUser),
-          permissionBackend,
-          changeControlGenericFactory,
-          notesFactory,
-          changeDataFactory,
-          commentsUtil,
-          accountResolver,
-          groupBackend,
-          allProjectsName,
-          allUsersName,
-          patchListCache,
-          repoManager,
-          projectCache,
-          listChildProjects,
-          submitDryRun,
-          conflictsCache,
-          index,
-          indexConfig,
-          listMembers,
-          starredChangesUtil,
-          accountCache,
-          allowsDrafts,
-          notesMigration);
-    }
-
-    Arguments asUser(Account.Id otherId) {
-      try {
-        CurrentUser u = self.get();
-        if (u.isIdentifiedUser() && otherId.equals(u.getAccountId())) {
-          return this;
-        }
-      } catch (ProvisionException e) {
-        // Doesn't match current user, continue.
-      }
-      return asUser(userFactory.create(otherId));
-    }
-
-    IdentifiedUser getIdentifiedUser() throws QueryRequiresAuthException {
-      try {
-        CurrentUser u = getUser();
-        if (u.isIdentifiedUser()) {
-          return u.asIdentifiedUser();
-        }
-        throw new QueryRequiresAuthException(NotSignedInException.MESSAGE);
-      } catch (ProvisionException e) {
-        throw new QueryRequiresAuthException(NotSignedInException.MESSAGE, e);
-      }
-    }
-
-    CurrentUser getUser() throws QueryRequiresAuthException {
-      try {
-        return self.get();
-      } catch (ProvisionException e) {
-        throw new QueryRequiresAuthException(NotSignedInException.MESSAGE, e);
-      }
-    }
-
-    Schema<ChangeData> getSchema() {
-      return index != null ? index.getSchema() : null;
-    }
-  }
-
-  private final Arguments args;
-
-  @Inject
-  ChangeQueryBuilder(Arguments args) {
-    super(mydef);
-    this.args = args;
-    setupDynamicOperators();
-  }
-
-  @VisibleForTesting
-  protected ChangeQueryBuilder(
-      Definition<ChangeData, ? extends QueryBuilder<ChangeData>> def, Arguments args) {
-    super(def);
-    this.args = args;
-  }
-
-  private void setupDynamicOperators() {
-    for (DynamicMap.Entry<ChangeOperatorFactory> e : args.opFactories) {
-      String name = e.getExportName() + "_" + e.getPluginName();
-      opFactories.put(name, e.getProvider().get());
-    }
-  }
-
-  public Arguments getArgs() {
-    return args;
-  }
-
-  public ChangeQueryBuilder asUser(CurrentUser user) {
-    return new ChangeQueryBuilder(builderDef, args.asUser(user));
-  }
-
-  @Operator
-  public Predicate<ChangeData> age(String value) {
-    return new AgePredicate(value);
-  }
-
-  @Operator
-  public Predicate<ChangeData> before(String value) throws QueryParseException {
-    return new BeforePredicate(value);
-  }
-
-  @Operator
-  public Predicate<ChangeData> until(String value) throws QueryParseException {
-    return before(value);
-  }
-
-  @Operator
-  public Predicate<ChangeData> after(String value) throws QueryParseException {
-    return new AfterPredicate(value);
-  }
-
-  @Operator
-  public Predicate<ChangeData> since(String value) throws QueryParseException {
-    return after(value);
-  }
-
-  @Operator
-  public Predicate<ChangeData> change(String query) throws QueryParseException {
-    Optional<ChangeTriplet> triplet = ChangeTriplet.parse(query);
-    if (triplet.isPresent()) {
-      return Predicate.and(
-          project(triplet.get().project().get()),
-          branch(triplet.get().branch().get()),
-          new ChangeIdPredicate(parseChangeId(triplet.get().id().get())));
-    }
-    if (PAT_LEGACY_ID.matcher(query).matches()) {
-      return new LegacyChangeIdPredicate(Change.Id.parse(query));
-    } else if (PAT_CHANGE_ID.matcher(query).matches()) {
-      return new ChangeIdPredicate(parseChangeId(query));
-    }
-
-    throw new QueryParseException("Invalid change format");
-  }
-
-  @Operator
-  public Predicate<ChangeData> comment(String value) {
-    return new CommentPredicate(args.index, value);
-  }
-
-  @Operator
-  public Predicate<ChangeData> status(String statusName) {
-    if ("reviewed".equalsIgnoreCase(statusName)) {
-      return IsReviewedPredicate.create();
-    }
-    return ChangeStatusPredicate.parse(statusName);
-  }
-
-  public Predicate<ChangeData> status_open() {
-    return ChangeStatusPredicate.open();
-  }
-
-  @Operator
-  public Predicate<ChangeData> has(String value) throws QueryParseException {
-    if ("star".equalsIgnoreCase(value)) {
-      return starredby(self());
-    }
-
-    if ("stars".equalsIgnoreCase(value)) {
-      return new HasStarsPredicate(self());
-    }
-
-    if ("draft".equalsIgnoreCase(value)) {
-      return draftby(self());
-    }
-
-    if ("edit".equalsIgnoreCase(value)) {
-      return new EditByPredicate(self());
-    }
-
-    if ("unresolved".equalsIgnoreCase(value)) {
-      return new IsUnresolvedPredicate();
-    }
-
-    // for plugins the value will be operandName_pluginName
-    String[] names = value.split("_");
-    if (names.length == 2) {
-      ChangeHasOperandFactory op = args.hasOperands.get(names[1], names[0]);
-      if (op != null) {
-        return op.create(this);
-      }
-    }
-
-    throw new IllegalArgumentException();
-  }
-
-  @Operator
-  public Predicate<ChangeData> is(String value) throws QueryParseException {
-    if ("starred".equalsIgnoreCase(value)) {
-      return starredby(self());
-    }
-
-    if ("watched".equalsIgnoreCase(value)) {
-      return new IsWatchedByPredicate(args, false);
-    }
-
-    if ("visible".equalsIgnoreCase(value)) {
-      return is_visible();
-    }
-
-    if ("reviewed".equalsIgnoreCase(value)) {
-      return IsReviewedPredicate.create();
-    }
-
-    if ("owner".equalsIgnoreCase(value)) {
-      return new OwnerPredicate(self());
-    }
-
-    if ("reviewer".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.WIP)) {
-        return Predicate.and(
-            Predicate.not(new BooleanPredicate(ChangeField.WIP)),
-            ReviewerPredicate.reviewer(args, self()));
-      }
-      return ReviewerPredicate.reviewer(args, self());
-    }
-
-    if ("cc".equalsIgnoreCase(value)) {
-      return ReviewerPredicate.cc(self());
-    }
-
-    if ("mergeable".equalsIgnoreCase(value)) {
-      return new BooleanPredicate(ChangeField.MERGEABLE);
-    }
-
-    if ("private".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.PRIVATE)) {
-        return new BooleanPredicate(ChangeField.PRIVATE);
-      }
-      throw new QueryParseException(
-          "'is:private' operator is not supported by change index version");
-    }
-
-    if ("assigned".equalsIgnoreCase(value)) {
-      return Predicate.not(new AssigneePredicate(new Account.Id(ChangeField.NO_ASSIGNEE)));
-    }
-
-    if ("unassigned".equalsIgnoreCase(value)) {
-      return new AssigneePredicate(new Account.Id(ChangeField.NO_ASSIGNEE));
-    }
-
-    if ("submittable".equalsIgnoreCase(value)) {
-      return new SubmittablePredicate(SubmitRecord.Status.OK);
-    }
-
-    if ("ignored".equalsIgnoreCase(value)) {
-      return star("ignore");
-    }
-
-    if ("started".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.STARTED)) {
-        return new BooleanPredicate(ChangeField.STARTED);
-      }
-      throw new QueryParseException(
-          "'is:started' operator is not supported by change index version");
-    }
-
-    if ("wip".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.WIP)) {
-        return new BooleanPredicate(ChangeField.WIP);
-      }
-      throw new QueryParseException("'is:wip' operator is not supported by change index version");
-    }
-
-    return status(value);
-  }
-
-  @Operator
-  public Predicate<ChangeData> commit(String id) {
-    return new CommitPredicate(id);
-  }
-
-  @Operator
-  public Predicate<ChangeData> conflicts(String value) throws OrmException, QueryParseException {
-    List<Change> changes = parseChange(value);
-    List<Predicate<ChangeData>> or = new ArrayList<>(changes.size());
-    for (Change c : changes) {
-      or.add(ConflictsPredicate.create(args, value, c));
-    }
-    return Predicate.or(or);
-  }
-
-  @Operator
-  public Predicate<ChangeData> p(String name) {
-    return project(name);
-  }
-
-  @Operator
-  public Predicate<ChangeData> project(String name) {
-    if (name.startsWith("^")) {
-      return new RegexProjectPredicate(name);
-    }
-    return new ProjectPredicate(name);
-  }
-
-  @Operator
-  public Predicate<ChangeData> projects(String name) {
-    return new ProjectPrefixPredicate(name);
-  }
-
-  @Operator
-  public Predicate<ChangeData> parentproject(String name) {
-    return new ParentProjectPredicate(args.projectCache, args.listChildProjects, args.self, name);
-  }
-
-  @Operator
-  public Predicate<ChangeData> branch(String name) {
-    if (name.startsWith("^")) {
-      return ref("^" + RefNames.fullName(name.substring(1)));
-    }
-    return ref(RefNames.fullName(name));
-  }
-
-  @Operator
-  public Predicate<ChangeData> hashtag(String hashtag) {
-    return new HashtagPredicate(hashtag);
-  }
-
-  @Operator
-  public Predicate<ChangeData> topic(String name) {
-    return new ExactTopicPredicate(name);
-  }
-
-  @Operator
-  public Predicate<ChangeData> intopic(String name) {
-    if (name.startsWith("^")) {
-      return new RegexTopicPredicate(name);
-    }
-    if (name.isEmpty()) {
-      return new ExactTopicPredicate(name);
-    }
-    return new FuzzyTopicPredicate(name, args.index);
-  }
-
-  @Operator
-  public Predicate<ChangeData> ref(String ref) {
-    if (ref.startsWith("^")) {
-      return new RegexRefPredicate(ref);
-    }
-    return new RefPredicate(ref);
-  }
-
-  @Operator
-  public Predicate<ChangeData> f(String file) {
-    return file(file);
-  }
-
-  @Operator
-  public Predicate<ChangeData> file(String file) {
-    if (file.startsWith("^")) {
-      return new RegexPathPredicate(file);
-    }
-    return EqualsFilePredicate.create(args, file);
-  }
-
-  @Operator
-  public Predicate<ChangeData> path(String path) {
-    if (path.startsWith("^")) {
-      return new RegexPathPredicate(path);
-    }
-    return new EqualsPathPredicate(FIELD_PATH, path);
-  }
-
-  @Operator
-  public Predicate<ChangeData> label(String name)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    Set<Account.Id> accounts = null;
-    AccountGroup.UUID group = null;
-
-    // Parse for:
-    // label:CodeReview=1,user=jsmith or
-    // label:CodeReview=1,jsmith or
-    // label:CodeReview=1,group=android_approvers or
-    // label:CodeReview=1,android_approvers
-    // user/groups without a label will first attempt to match user
-    // Special case: votes by owners can be tracked with ",owner":
-    // label:Code-Review+2,owner
-    // label:Code-Review+2,user=owner
-    String[] splitReviewer = name.split(",", 2);
-    name = splitReviewer[0]; // remove all but the vote piece, e.g.'CodeReview=1'
-
-    if (splitReviewer.length == 2) {
-      // process the user/group piece
-      PredicateArgs lblArgs = new PredicateArgs(splitReviewer[1]);
-
-      for (Map.Entry<String, String> pair : lblArgs.keyValue.entrySet()) {
-        if (pair.getKey().equalsIgnoreCase(ARG_ID_USER)) {
-          if (pair.getValue().equals(ARG_ID_OWNER)) {
-            accounts = Collections.singleton(OWNER_ACCOUNT_ID);
-          } else {
-            accounts = parseAccount(pair.getValue());
-          }
-        } else if (pair.getKey().equalsIgnoreCase(ARG_ID_GROUP)) {
-          group = parseGroup(pair.getValue()).getUUID();
-        } else {
-          throw new QueryParseException("Invalid argument identifier '" + pair.getKey() + "'");
-        }
-      }
-
-      for (String value : lblArgs.positional) {
-        if (accounts != null || group != null) {
-          throw new QueryParseException("more than one user/group specified (" + value + ")");
-        }
-        try {
-          if (value.equals(ARG_ID_OWNER)) {
-            accounts = Collections.singleton(OWNER_ACCOUNT_ID);
-          } else {
-            accounts = parseAccount(value);
-          }
-        } catch (QueryParseException qpex) {
-          // If it doesn't match an account, see if it matches a group
-          // (accounts get precedence)
-          try {
-            group = parseGroup(value).getUUID();
-          } catch (QueryParseException e) {
-            throw error("Neither user nor group " + value + " found", e);
-          }
-        }
-      }
-    }
-
-    // expand a group predicate into multiple user predicates
-    if (group != null) {
-      Set<Account.Id> allMembers =
-          args.listMembers
-              .get()
-              .setRecursive(true)
-              .apply(group)
-              .stream()
-              .map(a -> new Account.Id(a._accountId))
-              .collect(toSet());
-      int maxTerms = args.indexConfig.maxTerms();
-      if (allMembers.size() > maxTerms) {
-        // limit the number of query terms otherwise Gerrit will barf
-        accounts = ImmutableSet.copyOf(Iterables.limit(allMembers, maxTerms));
-      } else {
-        accounts = allMembers;
-      }
-    }
-
-    // If the vote piece looks like Code-Review=NEED with a valid non-numeric
-    // submit record status, interpret as a submit record query.
-    int eq = name.indexOf('=');
-    if (args.getSchema().hasField(ChangeField.SUBMIT_RECORD) && eq > 0) {
-      String statusName = name.substring(eq + 1).toUpperCase();
-      if (!isInt(statusName)) {
-        SubmitRecord.Label.Status status =
-            Enums.getIfPresent(SubmitRecord.Label.Status.class, statusName).orNull();
-        if (status == null) {
-          throw error("Invalid label status " + statusName + " in " + name);
-        }
-        return SubmitRecordPredicate.create(name.substring(0, eq), status, accounts);
-      }
-    }
-
-    return new LabelPredicate(args, name, accounts, group);
-  }
-
-  private static boolean isInt(String s) {
-    if (s == null) {
-      return false;
-    }
-    if (s.startsWith("+")) {
-      s = s.substring(1);
-    }
-    return Ints.tryParse(s) != null;
-  }
-
-  @Operator
-  public Predicate<ChangeData> message(String text) {
-    return new MessagePredicate(args.index, text);
-  }
-
-  @Operator
-  public Predicate<ChangeData> star(String label) throws QueryParseException {
-    return new StarPredicate(self(), label);
-  }
-
-  @Operator
-  public Predicate<ChangeData> starredby(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    return starredby(parseAccount(who));
-  }
-
-  private Predicate<ChangeData> starredby(Set<Account.Id> who) {
-    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
-    for (Account.Id id : who) {
-      p.add(starredby(id));
-    }
-    return Predicate.or(p);
-  }
-
-  private Predicate<ChangeData> starredby(Account.Id who) {
-    return new StarPredicate(who, StarredChangesUtil.DEFAULT_LABEL);
-  }
-
-  @Operator
-  public Predicate<ChangeData> watchedby(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    Set<Account.Id> m = parseAccount(who);
-    List<IsWatchedByPredicate> p = Lists.newArrayListWithCapacity(m.size());
-
-    Account.Id callerId;
-    try {
-      CurrentUser caller = args.self.get();
-      callerId = caller.isIdentifiedUser() ? caller.getAccountId() : null;
-    } catch (ProvisionException e) {
-      callerId = null;
-    }
-
-    for (Account.Id id : m) {
-      // Each child IsWatchedByPredicate includes a visibility filter for the
-      // corresponding user, to ensure that predicate subtree only returns
-      // changes visible to that user. The exception is if one of the users is
-      // the caller of this method, in which case visibility is already being
-      // checked at the top level.
-      p.add(new IsWatchedByPredicate(args.asUser(id), !id.equals(callerId)));
-    }
-    return Predicate.or(p);
-  }
-
-  @Operator
-  public Predicate<ChangeData> draftby(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    Set<Account.Id> m = parseAccount(who);
-    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
-    for (Account.Id id : m) {
-      p.add(draftby(id));
-    }
-    return Predicate.or(p);
-  }
-
-  private Predicate<ChangeData> draftby(Account.Id who) {
-    return new HasDraftByPredicate(who);
-  }
-
-  private boolean isSelf(String who) {
-    return "self".equals(who) || "me".equals(who);
-  }
-
-  @Operator
-  public Predicate<ChangeData> visibleto(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    if (isSelf(who)) {
-      return is_visible();
-    }
-    Set<Account.Id> m = args.accountResolver.findAll(who);
-    if (!m.isEmpty()) {
-      List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
-      for (Account.Id id : m) {
-        return visibleto(args.userFactory.create(id));
-      }
-      return Predicate.or(p);
-    }
-
-    // If its not an account, maybe its a group?
-    //
-    Collection<GroupReference> suggestions = args.groupBackend.suggest(who, null);
-    if (!suggestions.isEmpty()) {
-      HashSet<AccountGroup.UUID> ids = new HashSet<>();
-      for (GroupReference ref : suggestions) {
-        ids.add(ref.getUUID());
-      }
-      return visibleto(new SingleGroupUser(ids));
-    }
-
-    throw error("No user or group matches \"" + who + "\".");
-  }
-
-  public Predicate<ChangeData> visibleto(CurrentUser user) {
-    return new ChangeIsVisibleToPredicate(
-        args.db, args.notesFactory, args.changeControlGenericFactory, user, args.permissionBackend);
-  }
-
-  public Predicate<ChangeData> is_visible() throws QueryParseException {
-    return visibleto(args.getUser());
-  }
-
-  @Operator
-  public Predicate<ChangeData> o(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    return owner(who);
-  }
-
-  @Operator
-  public Predicate<ChangeData> owner(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    return owner(parseAccount(who));
-  }
-
-  private Predicate<ChangeData> owner(Set<Account.Id> who) {
-    List<OwnerPredicate> p = Lists.newArrayListWithCapacity(who.size());
-    for (Account.Id id : who) {
-      p.add(new OwnerPredicate(id));
-    }
-    return Predicate.or(p);
-  }
-
-  private Predicate<ChangeData> ownerDefaultField(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    Set<Account.Id> accounts = parseAccount(who);
-    if (accounts.size() > MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
-      return Predicate.any();
-    }
-    return owner(accounts);
-  }
-
-  @Operator
-  public Predicate<ChangeData> assignee(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    return assignee(parseAccount(who));
-  }
-
-  private Predicate<ChangeData> assignee(Set<Account.Id> who) {
-    List<AssigneePredicate> p = Lists.newArrayListWithCapacity(who.size());
-    for (Account.Id id : who) {
-      p.add(new AssigneePredicate(id));
-    }
-    return Predicate.or(p);
-  }
-
-  @Operator
-  public Predicate<ChangeData> ownerin(String group) throws QueryParseException {
-    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
-    if (g == null) {
-      throw error("Group " + group + " not found");
-    }
-    return new OwnerinPredicate(args.userFactory, g.getUUID());
-  }
-
-  @Operator
-  public Predicate<ChangeData> r(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    return reviewer(who);
-  }
-
-  @Operator
-  public Predicate<ChangeData> reviewer(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    return reviewer(who, false);
-  }
-
-  private Predicate<ChangeData> reviewerDefaultField(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    return reviewer(who, true);
-  }
-
-  private Predicate<ChangeData> reviewer(String who, boolean forDefaultField)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    Predicate<ChangeData> byState =
-        reviewerByState(who, ReviewerStateInternal.REVIEWER, forDefaultField);
-    if (Objects.equals(byState, Predicate.<ChangeData>any())) {
-      return Predicate.any();
-    }
-    if (args.getSchema().hasField(ChangeField.WIP)) {
-      return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP)), byState);
-    }
-    return byState;
-  }
-
-  @Operator
-  public Predicate<ChangeData> cc(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    return reviewerByState(who, ReviewerStateInternal.CC, false);
-  }
-
-  @Operator
-  public Predicate<ChangeData> reviewerin(String group) throws QueryParseException {
-    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
-    if (g == null) {
-      throw error("Group " + group + " not found");
-    }
-    return new ReviewerinPredicate(args.userFactory, g.getUUID());
-  }
-
-  @Operator
-  public Predicate<ChangeData> tr(String trackingId) {
-    return new TrackingIdPredicate(trackingId);
-  }
-
-  @Operator
-  public Predicate<ChangeData> bug(String trackingId) {
-    return tr(trackingId);
-  }
-
-  @Operator
-  public Predicate<ChangeData> limit(String query) throws QueryParseException {
-    Integer limit = Ints.tryParse(query);
-    if (limit == null) {
-      throw error("Invalid limit: " + query);
-    }
-    return new LimitPredicate<>(FIELD_LIMIT, limit);
-  }
-
-  @Operator
-  public Predicate<ChangeData> added(String value) throws QueryParseException {
-    return new AddedPredicate(value);
-  }
-
-  @Operator
-  public Predicate<ChangeData> deleted(String value) throws QueryParseException {
-    return new DeletedPredicate(value);
-  }
-
-  @Operator
-  public Predicate<ChangeData> size(String value) throws QueryParseException {
-    return delta(value);
-  }
-
-  @Operator
-  public Predicate<ChangeData> delta(String value) throws QueryParseException {
-    return new DeltaPredicate(value);
-  }
-
-  @Operator
-  public Predicate<ChangeData> commentby(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    return commentby(parseAccount(who));
-  }
-
-  private Predicate<ChangeData> commentby(Set<Account.Id> who) {
-    List<CommentByPredicate> p = Lists.newArrayListWithCapacity(who.size());
-    for (Account.Id id : who) {
-      p.add(new CommentByPredicate(id));
-    }
-    return Predicate.or(p);
-  }
-
-  @Operator
-  public Predicate<ChangeData> from(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    Set<Account.Id> ownerIds = parseAccount(who);
-    return Predicate.or(owner(ownerIds), commentby(ownerIds));
-  }
-
-  @Operator
-  public Predicate<ChangeData> query(String name) throws QueryParseException {
-    try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
-      VersionedAccountQueries q = VersionedAccountQueries.forUser(self());
-      q.load(git);
-      String query = q.getQueryList().getQuery(name);
-      if (query != null) {
-        return parse(query);
-      }
-    } catch (RepositoryNotFoundException e) {
-      throw new QueryParseException(
-          "Unknown named query (no " + args.allUsersName + " repo): " + name, e);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new QueryParseException("Error parsing named query: " + name, e);
-    }
-    throw new QueryParseException("Unknown named query: " + name);
-  }
-
-  @Operator
-  public Predicate<ChangeData> reviewedby(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    return IsReviewedPredicate.create(parseAccount(who));
-  }
-
-  @Operator
-  public Predicate<ChangeData> destination(String name) throws QueryParseException {
-    try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
-      VersionedAccountDestinations d = VersionedAccountDestinations.forUser(self());
-      d.load(git);
-      Set<Branch.NameKey> destinations = d.getDestinationList().getDestinations(name);
-      if (destinations != null) {
-        return new DestinationPredicate(destinations, name);
-      }
-    } catch (RepositoryNotFoundException e) {
-      throw new QueryParseException(
-          "Unknown named destination (no " + args.allUsersName + " repo): " + name, e);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new QueryParseException("Error parsing named destination: " + name, e);
-    }
-    throw new QueryParseException("Unknown named destination: " + name);
-  }
-
-  @Operator
-  public Predicate<ChangeData> author(String who) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.EXACT_AUTHOR)) {
-      return getAuthorOrCommitterPredicate(
-          who.trim(), ExactAuthorPredicate::new, AuthorPredicate::new);
-    }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), AuthorPredicate::new);
-  }
-
-  @Operator
-  public Predicate<ChangeData> committer(String who) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.EXACT_COMMITTER)) {
-      return getAuthorOrCommitterPredicate(
-          who.trim(), ExactCommitterPredicate::new, CommitterPredicate::new);
-    }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), CommitterPredicate::new);
-  }
-
-  @Operator
-  public Predicate<ChangeData> submittable(String str) throws QueryParseException {
-    SubmitRecord.Status status =
-        Enums.getIfPresent(SubmitRecord.Status.class, str.toUpperCase()).orNull();
-    if (status == null) {
-      throw error("invalid value for submittable:" + str);
-    }
-    return new SubmittablePredicate(status);
-  }
-
-  @Operator
-  public Predicate<ChangeData> unresolved(String value) throws QueryParseException {
-    return new IsUnresolvedPredicate(value);
-  }
-
-  @Operator
-  public Predicate<ChangeData> revertof(String value) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.REVERT_OF)) {
-      return new RevertOfPredicate(value);
-    }
-    throw new QueryParseException("'revertof' operator is not supported by change index version");
-  }
-
-  @Override
-  protected Predicate<ChangeData> defaultField(String query) throws QueryParseException {
-    if (query.startsWith("refs/")) {
-      return ref(query);
-    } else if (DEF_CHANGE.matcher(query).matches()) {
-      List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(2);
-      try {
-        predicates.add(change(query));
-      } catch (QueryParseException e) {
-        // Skip.
-      }
-
-      // For PAT_LEGACY_ID, it may also be the prefix of some commits.
-      if (query.length() >= 6 && PAT_LEGACY_ID.matcher(query).matches()) {
-        predicates.add(commit(query));
-      }
-
-      return Predicate.or(predicates);
-    }
-
-    // Adapt the capacity of this list when adding more default predicates.
-    List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(11);
-    try {
-      Predicate<ChangeData> p = ownerDefaultField(query);
-      if (!Objects.equals(p, Predicate.<ChangeData>any())) {
-        predicates.add(p);
-      }
-    } catch (OrmException | IOException | ConfigInvalidException | QueryParseException e) {
-      // Skip.
-    }
-    try {
-      Predicate<ChangeData> p = reviewerDefaultField(query);
-      if (!Objects.equals(p, Predicate.<ChangeData>any())) {
-        predicates.add(p);
-      }
-    } catch (OrmException | IOException | ConfigInvalidException | QueryParseException e) {
-      // Skip.
-    }
-    predicates.add(file(query));
-    try {
-      predicates.add(label(query));
-    } catch (OrmException | IOException | ConfigInvalidException | QueryParseException e) {
-      // Skip.
-    }
-    predicates.add(commit(query));
-    predicates.add(message(query));
-    predicates.add(comment(query));
-    predicates.add(projects(query));
-    predicates.add(ref(query));
-    predicates.add(branch(query));
-    predicates.add(topic(query));
-    // Adapt the capacity of the "predicates" list when adding more default
-    // predicates.
-    return Predicate.or(predicates);
-  }
-
-  private Predicate<ChangeData> getAuthorOrCommitterPredicate(
-      String who,
-      Function<String, Predicate<ChangeData>> exactPredicateFunc,
-      Function<String, Predicate<ChangeData>> fullPredicateFunc)
-      throws QueryParseException {
-    if (Address.tryParse(who) != null) {
-      return exactPredicateFunc.apply(who);
-    }
-    return getAuthorOrCommitterFullTextPredicate(who, fullPredicateFunc);
-  }
-
-  private Predicate<ChangeData> getAuthorOrCommitterFullTextPredicate(
-      String who, Function<String, Predicate<ChangeData>> fullPredicateFunc)
-      throws QueryParseException {
-    Set<String> parts = SchemaUtil.getNameParts(who);
-    if (parts.isEmpty()) {
-      throw error("invalid value");
-    }
-
-    List<Predicate<ChangeData>> predicates =
-        parts.stream().map(fullPredicateFunc).collect(toList());
-    return Predicate.and(predicates);
-  }
-
-  private Set<Account.Id> parseAccount(String who)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    if (isSelf(who)) {
-      return Collections.singleton(self());
-    }
-    Set<Account.Id> matches = args.accountResolver.findAll(who);
-    if (matches.isEmpty()) {
-      throw error("User " + who + " not found");
-    }
-    return matches;
-  }
-
-  private GroupReference parseGroup(String group) throws QueryParseException {
-    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
-    if (g == null) {
-      throw error("Group " + group + " not found");
-    }
-    return g;
-  }
-
-  private List<Change> parseChange(String value) throws OrmException, QueryParseException {
-    if (PAT_LEGACY_ID.matcher(value).matches()) {
-      return asChanges(args.queryProvider.get().byLegacyChangeId(Change.Id.parse(value)));
-    } else if (PAT_CHANGE_ID.matcher(value).matches()) {
-      List<Change> changes = asChanges(args.queryProvider.get().byKeyPrefix(parseChangeId(value)));
-      if (changes.isEmpty()) {
-        throw error("Change " + value + " not found");
-      }
-      return changes;
-    }
-
-    throw error("Change " + value + " not found");
-  }
-
-  private static String parseChangeId(String value) {
-    if (value.charAt(0) == 'i') {
-      value = "I" + value.substring(1);
-    }
-    return value;
-  }
-
-  private Account.Id self() throws QueryParseException {
-    return args.getIdentifiedUser().getAccountId();
-  }
-
-  public Predicate<ChangeData> reviewerByState(
-      String who, ReviewerStateInternal state, boolean forDefaultField)
-      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
-    Predicate<ChangeData> reviewerByEmailPredicate = null;
-    if (args.index.getSchema().hasField(ChangeField.REVIEWER_BY_EMAIL)) {
-      Address address = Address.tryParse(who);
-      if (address != null) {
-        reviewerByEmailPredicate = ReviewerByEmailPredicate.forState(address, state);
-      }
-    }
-
-    Predicate<ChangeData> reviewerPredicate = null;
-    try {
-      Set<Account.Id> accounts = parseAccount(who);
-      if (!forDefaultField || accounts.size() <= MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
-        reviewerPredicate =
-            Predicate.or(
-                accounts
-                    .stream()
-                    .map(id -> ReviewerPredicate.forState(id, state))
-                    .collect(toList()));
-      }
-    } catch (QueryParseException e) {
-      // Propagate this exception only if we can't use 'who' to query by email
-      if (reviewerByEmailPredicate == null) {
-        throw e;
-      }
-    }
-
-    if (reviewerPredicate != null && reviewerByEmailPredicate != null) {
-      return Predicate.or(reviewerPredicate, reviewerByEmailPredicate);
-    } else if (reviewerPredicate != null) {
-      return reviewerPredicate;
-    } else if (reviewerByEmailPredicate != null) {
-      return reviewerByEmailPredicate;
-    } else {
-      return Predicate.any();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
deleted file mode 100644
index eb6cf77..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ /dev/null
@@ -1,149 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
-
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryProcessor;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountLimits;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.index.change.ChangeIndexRewriter;
-import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
-import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Set;
-
-/**
- * Query processor for the change index.
- *
- * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
- * holding on to a single instance.
- */
-public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
-    implements PluginDefinedAttributesFactory {
-  /**
-   * Register a ChangeAttributeFactory in a config Module like this:
-   *
-   * <p>bind(ChangeAttributeFactory.class) .annotatedWith(Exports.named("export-name"))
-   * .to(YourClass.class);
-   */
-  public interface ChangeAttributeFactory {
-    PluginDefinedInfo create(ChangeData a, ChangeQueryProcessor qp, String plugin);
-  }
-
-  private final Provider<ReviewDb> db;
-  private final Provider<CurrentUser> userProvider;
-  private final ChangeControl.GenericFactory changeControlFactory;
-  private final ChangeNotes.Factory notesFactory;
-  private final DynamicMap<ChangeAttributeFactory> attributeFactories;
-  private final PermissionBackend permissionBackend;
-
-  static {
-    // It is assumed that basic rewrites do not touch visibleto predicates.
-    checkState(
-        !ChangeIsVisibleToPredicate.class.isAssignableFrom(IndexPredicate.class),
-        "ChangeQueryProcessor assumes visibleto is not used by the index rewriter.");
-  }
-
-  @Inject
-  ChangeQueryProcessor(
-      Provider<CurrentUser> userProvider,
-      AccountLimits.Factory limitsFactory,
-      MetricMaker metricMaker,
-      IndexConfig indexConfig,
-      ChangeIndexCollection indexes,
-      ChangeIndexRewriter rewriter,
-      Provider<ReviewDb> db,
-      ChangeControl.GenericFactory changeControlFactory,
-      ChangeNotes.Factory notesFactory,
-      DynamicMap<ChangeAttributeFactory> attributeFactories,
-      PermissionBackend permissionBackend) {
-    super(
-        metricMaker,
-        ChangeSchemaDefinitions.INSTANCE,
-        indexConfig,
-        indexes,
-        rewriter,
-        FIELD_LIMIT,
-        () -> limitsFactory.create(userProvider.get()).getQueryLimit());
-    this.db = db;
-    this.userProvider = userProvider;
-    this.changeControlFactory = changeControlFactory;
-    this.notesFactory = notesFactory;
-    this.attributeFactories = attributeFactories;
-    this.permissionBackend = permissionBackend;
-  }
-
-  @Override
-  public ChangeQueryProcessor enforceVisibility(boolean enforce) {
-    super.enforceVisibility(enforce);
-    return this;
-  }
-
-  @Override
-  protected QueryOptions createOptions(
-      IndexConfig indexConfig, int start, int limit, Set<String> requestedFields) {
-    return IndexedChangeQuery.createOptions(indexConfig, start, limit, requestedFields);
-  }
-
-  @Override
-  public List<PluginDefinedInfo> create(ChangeData cd) {
-    List<PluginDefinedInfo> plugins = new ArrayList<>(attributeFactories.plugins().size());
-    for (String plugin : attributeFactories.plugins()) {
-      for (Provider<ChangeAttributeFactory> provider :
-          attributeFactories.byPlugin(plugin).values()) {
-        PluginDefinedInfo pda = null;
-        try {
-          pda = provider.get().create(cd, this, plugin);
-        } catch (RuntimeException e) {
-          /* Eat runtime exceptions so that queries don't fail. */
-        }
-        if (pda != null) {
-          pda.name = plugin;
-          plugins.add(pda);
-        }
-      }
-    }
-    if (plugins.isEmpty()) {
-      plugins = null;
-    }
-    return plugins;
-  }
-
-  @Override
-  protected Predicate<ChangeData> enforceVisibility(Predicate<ChangeData> pred) {
-    return new AndChangeSource(
-        pred,
-        new ChangeIsVisibleToPredicate(
-            db, notesFactory, changeControlFactory, userProvider.get(), permissionBackend),
-        start);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
deleted file mode 100644
index dbcb879..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ /dev/null
@@ -1,200 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.common.data.SubmitTypeRecord;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.strategy.SubmitDryRun;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-public class ConflictsPredicate {
-  // UI code may depend on this string, so use caution when changing.
-  protected static final String TOO_MANY_FILES = "too many files to find conflicts";
-
-  private ConflictsPredicate() {}
-
-  public static Predicate<ChangeData> create(Arguments args, String value, Change c)
-      throws QueryParseException, OrmException {
-    ChangeData cd;
-    List<String> files;
-    try {
-      cd = args.changeDataFactory.create(args.db.get(), c);
-      files = cd.currentFilePaths();
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-
-    if (3 + files.size() > args.indexConfig.maxTerms()) {
-      // Short-circuit with a nice error message if we exceed the index
-      // backend's term limit. This assumes that "conflicts:foo" is the entire
-      // query; if there are more terms in the input, we might not
-      // short-circuit here, which will result in a more generic error message
-      // later on in the query parsing.
-      throw new QueryParseException(TOO_MANY_FILES);
-    }
-
-    List<Predicate<ChangeData>> filePredicates = new ArrayList<>(files.size());
-    for (String file : files) {
-      filePredicates.add(new EqualsPathPredicate(ChangeQueryBuilder.FIELD_PATH, file));
-    }
-
-    List<Predicate<ChangeData>> and = new ArrayList<>(5);
-    and.add(new ProjectPredicate(c.getProject().get()));
-    and.add(new RefPredicate(c.getDest().get()));
-    and.add(Predicate.not(new LegacyChangeIdPredicate(c.getId())));
-    and.add(Predicate.or(filePredicates));
-
-    ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
-    and.add(new CheckConflict(ChangeQueryBuilder.FIELD_CONFLICTS, value, args, c, changeDataCache));
-    return Predicate.and(and);
-  }
-
-  private static final class CheckConflict extends ChangeOperatorPredicate {
-    private final Arguments args;
-    private final Branch.NameKey dest;
-    private final ChangeDataCache changeDataCache;
-
-    CheckConflict(
-        String field, String value, Arguments args, Change c, ChangeDataCache changeDataCache) {
-      super(field, value);
-      this.args = args;
-      this.dest = c.getDest();
-      this.changeDataCache = changeDataCache;
-    }
-
-    @Override
-    public boolean match(ChangeData object) throws OrmException {
-      Change otherChange = object.change();
-      if (otherChange == null || !otherChange.getDest().equals(dest)) {
-        return false;
-      }
-
-      SubmitTypeRecord str = object.submitTypeRecord();
-      if (!str.isOk()) {
-        return false;
-      }
-
-      ProjectState projectState;
-      try {
-        projectState = changeDataCache.getProjectState();
-      } catch (NoSuchProjectException e) {
-        return false;
-      }
-
-      ObjectId other = ObjectId.fromString(object.currentPatchSet().getRevision().get());
-      ConflictKey conflictsKey =
-          new ConflictKey(
-              changeDataCache.getTestAgainst(), other, str.type, projectState.isUseContentMerge());
-      Boolean conflicts = args.conflictsCache.getIfPresent(conflictsKey);
-      if (conflicts != null) {
-        return conflicts;
-      }
-
-      try (Repository repo = args.repoManager.openRepository(otherChange.getProject());
-          CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-        conflicts =
-            !args.submitDryRun.run(
-                str.type,
-                repo,
-                rw,
-                otherChange.getDest(),
-                changeDataCache.getTestAgainst(),
-                other,
-                getAlreadyAccepted(repo, rw));
-        args.conflictsCache.put(conflictsKey, conflicts);
-        return conflicts;
-      } catch (IntegrationException | NoSuchProjectException | IOException e) {
-        throw new OrmException(e);
-      }
-    }
-
-    @Override
-    public int getCost() {
-      return 5;
-    }
-
-    private Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw)
-        throws IntegrationException {
-      try {
-        Set<RevCommit> accepted = new HashSet<>();
-        SubmitDryRun.addCommits(changeDataCache.getAlreadyAccepted(repo), rw, accepted);
-        ObjectId tip = changeDataCache.getTestAgainst();
-        if (tip != null) {
-          accepted.add(rw.parseCommit(tip));
-        }
-        return accepted;
-      } catch (OrmException | IOException e) {
-        throw new IntegrationException("Failed to determine already accepted commits.", e);
-      }
-    }
-  }
-
-  private static class ChangeDataCache {
-    private final ChangeData cd;
-    private final ProjectCache projectCache;
-
-    private ObjectId testAgainst;
-    private ProjectState projectState;
-    private Set<ObjectId> alreadyAccepted;
-
-    ChangeDataCache(ChangeData cd, ProjectCache projectCache) {
-      this.cd = cd;
-      this.projectCache = projectCache;
-    }
-
-    ObjectId getTestAgainst() throws OrmException {
-      if (testAgainst == null) {
-        testAgainst = ObjectId.fromString(cd.currentPatchSet().getRevision().get());
-      }
-      return testAgainst;
-    }
-
-    ProjectState getProjectState() throws NoSuchProjectException {
-      if (projectState == null) {
-        projectState = projectCache.get(cd.project());
-        if (projectState == null) {
-          throw new NoSuchProjectException(cd.project());
-        }
-      }
-      return projectState;
-    }
-
-    Set<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
-      if (alreadyAccepted == null) {
-        alreadyAccepted = SubmitDryRun.getAlreadyAccepted(repo);
-      }
-      return alreadyAccepted;
-    }
-  }
-}
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
deleted file mode 100644
index 1917d6f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ /dev/null
@@ -1,136 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-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.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
-
-public class EqualsLabelPredicate extends ChangeIndexPredicate {
-  protected final ProjectCache projectCache;
-  protected final PermissionBackend permissionBackend;
-  protected final IdentifiedUser.GenericFactory userFactory;
-  protected final Provider<ReviewDb> dbProvider;
-  protected final String label;
-  protected final int expVal;
-  protected final Account.Id account;
-  protected final AccountGroup.UUID group;
-
-  public EqualsLabelPredicate(
-      LabelPredicate.Args args, String label, int expVal, Account.Id account) {
-    super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account));
-    this.permissionBackend = args.permissionBackend;
-    this.projectCache = args.projectCache;
-    this.userFactory = args.userFactory;
-    this.dbProvider = args.dbProvider;
-    this.group = args.group;
-    this.label = label;
-    this.expVal = expVal;
-    this.account = account;
-  }
-
-  @Override
-  public boolean match(ChangeData object) throws OrmException {
-    Change c = object.change();
-    if (c == null) {
-      // The change has disappeared.
-      //
-      return false;
-    }
-
-    ProjectState project = projectCache.get(c.getDest().getParentKey());
-    if (project == null) {
-      // The project has disappeared.
-      //
-      return false;
-    }
-
-    LabelType labelType = type(project.getLabelTypes(), label);
-    if (labelType == null) {
-      return false; // Label is not defined by this project.
-    }
-
-    boolean hasVote = false;
-    for (PatchSetApproval p : object.currentApprovals()) {
-      if (labelType.matches(p)) {
-        hasVote = true;
-        if (match(object, p.getValue(), p.getAccountId(), labelType)) {
-          return true;
-        }
-      }
-    }
-
-    if (!hasVote && expVal == 0) {
-      return true;
-    }
-
-    return false;
-  }
-
-  protected static LabelType type(LabelTypes types, String toFind) {
-    if (types.byLabel(toFind) != null) {
-      return types.byLabel(toFind);
-    }
-
-    for (LabelType lt : types.getLabelTypes()) {
-      if (toFind.equalsIgnoreCase(lt.getName())) {
-        return lt;
-      }
-    }
-    return null;
-  }
-
-  protected boolean match(ChangeData cd, short value, Account.Id approver, LabelType type) {
-    if (value != expVal) {
-      return false;
-    }
-
-    if (account != null && !account.equals(approver)) {
-      return false;
-    }
-
-    IdentifiedUser reviewer = userFactory.create(approver);
-    if (group != null && !reviewer.getEffectiveGroups().contains(group)) {
-      return false;
-    }
-
-    // Double check the value is still permitted for the user.
-    try {
-      PermissionBackend.ForChange perm =
-          permissionBackend.user(reviewer).database(dbProvider).change(cd);
-      return perm.test(ChangePermission.READ) && expVal == perm.squashByTest(type, value);
-    } catch (PermissionBackendException e) {
-      return false;
-    }
-  }
-
-  @Override
-  public int getCost() {
-    return 1 + (group == null ? 0 : 1);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
deleted file mode 100644
index 4d10c0e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ /dev/null
@@ -1,284 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.index.query.Predicate.and;
-import static com.google.gerrit.index.query.Predicate.not;
-import static com.google.gerrit.index.query.Predicate.or;
-import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.query.InternalQuery;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-
-/**
- * Query wrapper for the change index.
- *
- * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
- * holding on to a single instance.
- */
-public class InternalChangeQuery extends InternalQuery<ChangeData> {
-  private static Predicate<ChangeData> ref(Branch.NameKey branch) {
-    return new RefPredicate(branch.get());
-  }
-
-  private static Predicate<ChangeData> change(Change.Key key) {
-    return new ChangeIdPredicate(key.get());
-  }
-
-  private static Predicate<ChangeData> project(Project.NameKey project) {
-    return new ProjectPredicate(project.get());
-  }
-
-  private static Predicate<ChangeData> status(Change.Status status) {
-    return ChangeStatusPredicate.forStatus(status);
-  }
-
-  private static Predicate<ChangeData> commit(String id) {
-    return new CommitPredicate(id);
-  }
-
-  private final ChangeData.Factory changeDataFactory;
-  private final ChangeNotes.Factory notesFactory;
-
-  @Inject
-  InternalChangeQuery(
-      ChangeQueryProcessor queryProcessor,
-      ChangeIndexCollection indexes,
-      IndexConfig indexConfig,
-      ChangeData.Factory changeDataFactory,
-      ChangeNotes.Factory notesFactory) {
-    super(queryProcessor, indexes, indexConfig);
-    this.changeDataFactory = changeDataFactory;
-    this.notesFactory = notesFactory;
-  }
-
-  @Override
-  public InternalChangeQuery setLimit(int n) {
-    super.setLimit(n);
-    return this;
-  }
-
-  @Override
-  public InternalChangeQuery enforceVisibility(boolean enforce) {
-    super.enforceVisibility(enforce);
-    return this;
-  }
-
-  @Override
-  public InternalChangeQuery setRequestedFields(Set<String> fields) {
-    super.setRequestedFields(fields);
-    return this;
-  }
-
-  @Override
-  public InternalChangeQuery noFields() {
-    super.noFields();
-    return this;
-  }
-
-  public List<ChangeData> byKey(Change.Key key) throws OrmException {
-    return byKeyPrefix(key.get());
-  }
-
-  public List<ChangeData> byKeyPrefix(String prefix) throws OrmException {
-    return query(new ChangeIdPredicate(prefix));
-  }
-
-  public List<ChangeData> byLegacyChangeId(Change.Id id) throws OrmException {
-    return query(new LegacyChangeIdPredicate(id));
-  }
-
-  public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) throws OrmException {
-    List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
-    for (Change.Id id : ids) {
-      preds.add(new LegacyChangeIdPredicate(id));
-    }
-    return query(or(preds));
-  }
-
-  public List<ChangeData> byBranchKey(Branch.NameKey branch, Change.Key key) throws OrmException {
-    return query(and(ref(branch), project(branch.getParentKey()), change(key)));
-  }
-
-  public List<ChangeData> byProject(Project.NameKey project) throws OrmException {
-    return query(project(project));
-  }
-
-  public List<ChangeData> byBranchOpen(Branch.NameKey branch) throws OrmException {
-    return query(and(ref(branch), project(branch.getParentKey()), open()));
-  }
-
-  public List<ChangeData> byBranchNew(Branch.NameKey branch) throws OrmException {
-    return query(and(ref(branch), project(branch.getParentKey()), status(Change.Status.NEW)));
-  }
-
-  public Iterable<ChangeData> byCommitsOnBranchNotMerged(
-      Repository repo, ReviewDb db, Branch.NameKey branch, Collection<String> hashes)
-      throws OrmException, IOException {
-    return byCommitsOnBranchNotMerged(
-        repo,
-        db,
-        branch,
-        hashes,
-        // Account for all commit predicates plus ref, project, status.
-        indexConfig.maxTerms() - 3);
-  }
-
-  @VisibleForTesting
-  Iterable<ChangeData> byCommitsOnBranchNotMerged(
-      Repository repo,
-      ReviewDb db,
-      Branch.NameKey branch,
-      Collection<String> hashes,
-      int indexLimit)
-      throws OrmException, IOException {
-    if (hashes.size() > indexLimit) {
-      return byCommitsOnBranchNotMergedFromDatabase(repo, db, branch, hashes);
-    }
-    return byCommitsOnBranchNotMergedFromIndex(branch, hashes);
-  }
-
-  private Iterable<ChangeData> byCommitsOnBranchNotMergedFromDatabase(
-      Repository repo, ReviewDb db, Branch.NameKey branch, Collection<String> hashes)
-      throws OrmException, IOException {
-    Set<Change.Id> changeIds = Sets.newHashSetWithExpectedSize(hashes.size());
-    String lastPrefix = null;
-    for (Ref ref : repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES).values()) {
-      String r = ref.getName();
-      if ((lastPrefix != null && r.startsWith(lastPrefix))
-          || !hashes.contains(ref.getObjectId().name())) {
-        continue;
-      }
-      Change.Id id = Change.Id.fromRef(r);
-      if (id == null) {
-        continue;
-      }
-      if (changeIds.add(id)) {
-        lastPrefix = r.substring(0, r.lastIndexOf('/'));
-      }
-    }
-
-    List<ChangeNotes> notes =
-        notesFactory.create(
-            db,
-            branch.getParentKey(),
-            changeIds,
-            cn -> {
-              Change c = cn.getChange();
-              return c.getDest().equals(branch) && c.getStatus() != Change.Status.MERGED;
-            });
-    return Lists.transform(notes, n -> changeDataFactory.create(db, n));
-  }
-
-  private Iterable<ChangeData> byCommitsOnBranchNotMergedFromIndex(
-      Branch.NameKey branch, Collection<String> hashes) throws OrmException {
-    return query(
-        and(
-            ref(branch),
-            project(branch.getParentKey()),
-            not(status(Change.Status.MERGED)),
-            or(commits(hashes))));
-  }
-
-  private static List<Predicate<ChangeData>> commits(Collection<String> hashes) {
-    List<Predicate<ChangeData>> commits = new ArrayList<>(hashes.size());
-    for (String s : hashes) {
-      commits.add(commit(s));
-    }
-    return commits;
-  }
-
-  public List<ChangeData> byProjectOpen(Project.NameKey project) throws OrmException {
-    return query(and(project(project), open()));
-  }
-
-  public List<ChangeData> byTopicOpen(String topic) throws OrmException {
-    return query(and(new ExactTopicPredicate(topic), open()));
-  }
-
-  public List<ChangeData> byCommit(ObjectId id) throws OrmException {
-    return byCommit(id.name());
-  }
-
-  public List<ChangeData> byCommit(String hash) throws OrmException {
-    return query(commit(hash));
-  }
-
-  public List<ChangeData> byProjectCommit(Project.NameKey project, ObjectId id)
-      throws OrmException {
-    return byProjectCommit(project, id.name());
-  }
-
-  public List<ChangeData> byProjectCommit(Project.NameKey project, String hash)
-      throws OrmException {
-    return query(and(project(project), commit(hash)));
-  }
-
-  public List<ChangeData> byProjectCommits(Project.NameKey project, List<String> hashes)
-      throws OrmException {
-    int n = indexConfig.maxTerms() - 1;
-    checkArgument(hashes.size() <= n, "cannot exceed %s commits", n);
-    return query(and(project(project), or(commits(hashes))));
-  }
-
-  public List<ChangeData> byBranchCommit(String project, String branch, String hash)
-      throws OrmException {
-    return query(and(new ProjectPredicate(project), new RefPredicate(branch), commit(hash)));
-  }
-
-  public List<ChangeData> byBranchCommit(Branch.NameKey branch, String hash) throws OrmException {
-    return byBranchCommit(branch.getParentKey().get(), branch.get(), hash);
-  }
-
-  public List<ChangeData> bySubmissionId(String cs) throws OrmException {
-    if (Strings.isNullOrEmpty(cs)) {
-      return Collections.emptyList();
-    }
-    return query(new SubmissionIdPredicate(cs));
-  }
-
-  public List<ChangeData> byProjectGroups(Project.NameKey project, Collection<String> groups)
-      throws OrmException {
-    List<GroupPredicate> groupPredicates = new ArrayList<>(groups.size());
-    for (String g : groups) {
-      groupPredicates.add(new GroupPredicate(g));
-    }
-    return query(and(project(project), or(groupPredicates)));
-  }
-}
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
deleted file mode 100644
index c9ddfb7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ /dev/null
@@ -1,172 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.common.collect.Lists;
-import com.google.gerrit.index.query.OrPredicate;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.RangeUtil;
-import com.google.gerrit.index.query.RangeUtil.Range;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.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;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Set;
-
-public class LabelPredicate extends OrPredicate<ChangeData> {
-  protected static final int MAX_LABEL_VALUE = 4;
-
-  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;
-    protected final Set<Account.Id> accounts;
-    protected final AccountGroup.UUID group;
-
-    protected Args(
-        ProjectCache projectCache,
-        PermissionBackend permissionBackend,
-        ChangeControl.GenericFactory ccFactory,
-        IdentifiedUser.GenericFactory userFactory,
-        Provider<ReviewDb> dbProvider,
-        String value,
-        Set<Account.Id> accounts,
-        AccountGroup.UUID group) {
-      this.projectCache = projectCache;
-      this.permissionBackend = permissionBackend;
-      this.ccFactory = ccFactory;
-      this.userFactory = userFactory;
-      this.dbProvider = dbProvider;
-      this.value = value;
-      this.accounts = accounts;
-      this.group = group;
-    }
-  }
-
-  protected static class Parsed {
-    protected final String label;
-    protected final String test;
-    protected final int expVal;
-
-    protected Parsed(String label, String test, int expVal) {
-      this.label = label;
-      this.test = test;
-      this.expVal = expVal;
-    }
-  }
-
-  protected final String value;
-
-  public LabelPredicate(
-      ChangeQueryBuilder.Arguments a,
-      String value,
-      Set<Account.Id> accounts,
-      AccountGroup.UUID group) {
-    super(
-        predicates(
-            new Args(
-                a.projectCache,
-                a.permissionBackend,
-                a.changeControlGenericFactory,
-                a.userFactory,
-                a.db,
-                value,
-                accounts,
-                group)));
-    this.value = value;
-  }
-
-  protected static List<Predicate<ChangeData>> predicates(Args args) {
-    String v = args.value;
-    Parsed parsed = null;
-
-    try {
-      LabelVote lv = LabelVote.parse(v);
-      parsed = new Parsed(lv.label(), "=", lv.value());
-    } catch (IllegalArgumentException e) {
-      // Try next format.
-    }
-
-    try {
-      LabelVote lv = LabelVote.parseWithEquals(v);
-      parsed = new Parsed(lv.label(), "=", lv.value());
-    } catch (IllegalArgumentException e) {
-      // Try next format.
-    }
-
-    Range range;
-    if (parsed == null) {
-      range = RangeUtil.getRange(v, -MAX_LABEL_VALUE, MAX_LABEL_VALUE);
-      if (range == null) {
-        range = new Range(v, 1, 1);
-      }
-    } else {
-      range =
-          RangeUtil.getRange(
-              parsed.label, parsed.test, parsed.expVal, -MAX_LABEL_VALUE, MAX_LABEL_VALUE);
-    }
-    String prefix = range.prefix;
-    int min = range.min;
-    int max = range.max;
-
-    List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(max - min + 1);
-    for (int i = min; i <= max; i++) {
-      r.add(onePredicate(args, prefix, i));
-    }
-    return r;
-  }
-
-  protected static Predicate<ChangeData> onePredicate(Args args, String label, int expVal) {
-    if (expVal != 0) {
-      return equalsLabelPredicate(args, label, expVal);
-    }
-    return noLabelQuery(args, label);
-  }
-
-  protected static Predicate<ChangeData> noLabelQuery(Args args, String label) {
-    List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(2 * MAX_LABEL_VALUE);
-    for (int i = 1; i <= MAX_LABEL_VALUE; i++) {
-      r.add(equalsLabelPredicate(args, label, i));
-      r.add(equalsLabelPredicate(args, label, -i));
-    }
-    return not(or(r));
-  }
-
-  protected static Predicate<ChangeData> equalsLabelPredicate(Args args, String label, int expVal) {
-    if (args.accounts == null || args.accounts.isEmpty()) {
-      return new EqualsLabelPredicate(args, label, expVal, null);
-    }
-    List<Predicate<ChangeData>> r = new ArrayList<>();
-    for (Account.Id a : args.accounts) {
-      r.add(new EqualsLabelPredicate(args, label, expVal, a));
-    }
-    return or(r);
-  }
-
-  @Override
-  public String toString() {
-    return ChangeQueryBuilder.FIELD_LABEL + ":" + value;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java
deleted file mode 100644
index a703852..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.index.query.OrPredicate;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwtorm.server.ListResultSet;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-public class OrSource extends OrPredicate<ChangeData> implements ChangeDataSource {
-  private int cardinality = -1;
-
-  public OrSource(Collection<? extends Predicate<ChangeData>> that) {
-    super(that);
-  }
-
-  @Override
-  public ResultSet<ChangeData> read() throws OrmException {
-    // TODO(spearce) This probably should be more lazy.
-    //
-    List<ChangeData> r = new ArrayList<>();
-    Set<Change.Id> have = new HashSet<>();
-    for (Predicate<ChangeData> p : getChildren()) {
-      if (p instanceof ChangeDataSource) {
-        for (ChangeData cd : ((ChangeDataSource) p).read()) {
-          if (have.add(cd.getId())) {
-            r.add(cd);
-          }
-        }
-      } else {
-        throw new OrmException("No ChangeDataSource: " + p);
-      }
-    }
-    return new ListResultSet<>(r);
-  }
-
-  @Override
-  public boolean hasChange() {
-    for (Predicate<ChangeData> p : getChildren()) {
-      if (!(p instanceof ChangeDataSource) || !((ChangeDataSource) p).hasChange()) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  @Override
-  public int getCardinality() {
-    if (cardinality < 0) {
-      cardinality = 0;
-      for (Predicate<ChangeData> p : getChildren()) {
-        if (p instanceof ChangeDataSource) {
-          cardinality += ((ChangeDataSource) p).getCardinality();
-        }
-      }
-    }
-    return cardinality;
-  }
-}
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
deleted file mode 100644
index ee9c570..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ /dev/null
@@ -1,468 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.common.base.Preconditions.checkState;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.index.query.QueryResult;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.data.ChangeAttribute;
-import com.google.gerrit.server.data.PatchSetAttribute;
-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;
-import com.google.inject.Inject;
-import java.io.BufferedWriter;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.lang.reflect.Field;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.List;
-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;
-
-/**
- * Change query implementation that outputs to a stream in the style of an SSH command.
- *
- * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
- * holding on to a single instance.
- */
-public class OutputStreamQuery {
-  private static final Logger log = LoggerFactory.getLogger(OutputStreamQuery.class);
-
-  private static final DateTimeFormatter dtf = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss zzz");
-
-  public enum OutputFormat {
-    TEXT,
-    JSON
-  }
-
-  private final ReviewDb db;
-  private final GitRepositoryManager repoManager;
-  private final ChangeQueryBuilder queryBuilder;
-  private final ChangeQueryProcessor queryProcessor;
-  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;
-  private boolean includePatchSets;
-  private boolean includeCurrentPatchSet;
-  private boolean includeApprovals;
-  private boolean includeComments;
-  private boolean includeFiles;
-  private boolean includeCommitMessage;
-  private boolean includeDependencies;
-  private boolean includeSubmitRecords;
-  private boolean includeAllReviewers;
-
-  private OutputStream outputStream = DisabledOutputStream.INSTANCE;
-  private PrintWriter out;
-
-  @Inject
-  OutputStreamQuery(
-      ReviewDb db,
-      GitRepositoryManager repoManager,
-      ChangeQueryBuilder queryBuilder,
-      ChangeQueryProcessor queryProcessor,
-      EventFactory eventFactory,
-      TrackingFooters trackingFooters,
-      CurrentUser user,
-      ChangeControl.GenericFactory changeControlFactory,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
-    this.db = db;
-    this.repoManager = repoManager;
-    this.queryBuilder = queryBuilder;
-    this.queryProcessor = queryProcessor;
-    this.eventFactory = eventFactory;
-    this.trackingFooters = trackingFooters;
-    this.user = user;
-    this.changeControlFactory = changeControlFactory;
-    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
-  }
-
-  void setLimit(int n) {
-    queryProcessor.setUserProvidedLimit(n);
-  }
-
-  public void setStart(int n) {
-    queryProcessor.setStart(n);
-  }
-
-  public void setIncludePatchSets(boolean on) {
-    includePatchSets = on;
-  }
-
-  public boolean getIncludePatchSets() {
-    return includePatchSets;
-  }
-
-  public void setIncludeCurrentPatchSet(boolean on) {
-    includeCurrentPatchSet = on;
-  }
-
-  public boolean getIncludeCurrentPatchSet() {
-    return includeCurrentPatchSet;
-  }
-
-  public void setIncludeApprovals(boolean on) {
-    includeApprovals = on;
-  }
-
-  public void setIncludeComments(boolean on) {
-    includeComments = on;
-  }
-
-  public void setIncludeFiles(boolean on) {
-    includeFiles = on;
-  }
-
-  public boolean getIncludeFiles() {
-    return includeFiles;
-  }
-
-  public void setIncludeDependencies(boolean on) {
-    includeDependencies = on;
-  }
-
-  public boolean getIncludeDependencies() {
-    return includeDependencies;
-  }
-
-  public void setIncludeCommitMessage(boolean on) {
-    includeCommitMessage = on;
-  }
-
-  public void setIncludeSubmitRecords(boolean on) {
-    includeSubmitRecords = on;
-  }
-
-  public void setIncludeAllReviewers(boolean on) {
-    includeAllReviewers = on;
-  }
-
-  public void setOutput(OutputStream out, OutputFormat fmt) {
-    this.outputStream = out;
-    this.outputFormat = fmt;
-  }
-
-  public void query(String queryString) throws IOException {
-    out =
-        new PrintWriter( //
-            new BufferedWriter( //
-                new OutputStreamWriter(outputStream, UTF_8)));
-    try {
-      if (queryProcessor.isDisabled()) {
-        ErrorMessage m = new ErrorMessage();
-        m.message = "query disabled";
-        show(m);
-        return;
-      }
-
-      try {
-        final QueryStatsAttribute stats = new QueryStatsAttribute();
-        stats.runTimeMilliseconds = TimeUtil.nowMs();
-
-        Map<Project.NameKey, Repository> repos = new HashMap<>();
-        Map<Project.NameKey, RevWalk> revWalks = new HashMap<>();
-        QueryResult<ChangeData> results = queryProcessor.query(queryBuilder.parse(queryString));
-        try {
-          for (ChangeData d : results.entities()) {
-            show(buildChangeAttribute(d, repos, revWalks));
-          }
-        } finally {
-          closeAll(revWalks.values(), repos.values());
-        }
-
-        stats.rowCount = results.entities().size();
-        stats.moreChanges = results.more();
-        stats.runTimeMilliseconds = TimeUtil.nowMs() - stats.runTimeMilliseconds;
-        show(stats);
-      } catch (OrmException err) {
-        log.error("Cannot execute query: " + queryString, err);
-
-        ErrorMessage m = new ErrorMessage();
-        m.message = "cannot query database";
-        show(m);
-
-      } catch (QueryParseException e) {
-        ErrorMessage m = new ErrorMessage();
-        m.message = e.getMessage();
-        show(m);
-      }
-    } finally {
-      try {
-        out.flush();
-      } finally {
-        out = null;
-      }
-    }
-  }
-
-  private ChangeAttribute buildChangeAttribute(
-      ChangeData d, Map<Project.NameKey, Repository> repos, Map<Project.NameKey, RevWalk> revWalks)
-      throws OrmException, IOException {
-    LabelTypes labelTypes = d.getLabelTypes();
-    ChangeAttribute c = eventFactory.asChangeAttribute(db, d.change());
-    eventFactory.extend(c, d.change());
-
-    if (!trackingFooters.isEmpty()) {
-      eventFactory.addTrackingIds(c, d.trackingFooters());
-    }
-
-    if (includeAllReviewers) {
-      eventFactory.addAllReviewers(db, c, d.notes());
-    }
-
-    if (includeSubmitRecords) {
-      eventFactory.addSubmitRecords(
-          c, submitRuleEvaluatorFactory.create(user, d).setAllowClosed(true).evaluate());
-    }
-
-    if (includeCommitMessage) {
-      eventFactory.addCommitMessage(c, d.commitMessage());
-    }
-
-    RevWalk rw = null;
-    if (includePatchSets || includeCurrentPatchSet || includeDependencies) {
-      Project.NameKey p = d.change().getProject();
-      rw = revWalks.get(p);
-      // Cache and reuse repos and revwalks.
-      if (rw == null) {
-        Repository repo = repoManager.openRepository(p);
-        checkState(repos.put(p, repo) == null);
-        rw = new RevWalk(repo);
-        revWalks.put(p, rw);
-      }
-    }
-
-    ChangeControl ctl = changeControlFactory.controlFor(db, d.change(), user);
-    if (includePatchSets) {
-      eventFactory.addPatchSets(
-          db,
-          rw,
-          c,
-          ctl.getVisiblePatchSets(d.patchSets(), db),
-          includeApprovals ? d.approvals().asMap() : null,
-          includeFiles,
-          d.change(),
-          labelTypes);
-    }
-
-    if (includeCurrentPatchSet) {
-      PatchSet current = d.currentPatchSet();
-      if (current != null && ctl.isVisible(d.db())) {
-        c.currentPatchSet = eventFactory.asPatchSetAttribute(db, rw, d.change(), current);
-        eventFactory.addApprovals(c.currentPatchSet, d.currentApprovals(), labelTypes);
-
-        if (includeFiles) {
-          eventFactory.addPatchSetFileNames(c.currentPatchSet, d.change(), d.currentPatchSet());
-        }
-        if (includeComments) {
-          eventFactory.addPatchSetComments(c.currentPatchSet, d.publishedComments());
-        }
-      }
-    }
-
-    if (includeComments) {
-      eventFactory.addComments(c, d.messages());
-      if (includePatchSets) {
-        eventFactory.addPatchSets(
-            db,
-            rw,
-            c,
-            ctl.getVisiblePatchSets(d.patchSets(), db),
-            includeApprovals ? d.approvals().asMap() : null,
-            includeFiles,
-            d.change(),
-            labelTypes);
-        for (PatchSetAttribute attribute : c.patchSets) {
-          eventFactory.addPatchSetComments(attribute, d.publishedComments());
-        }
-      }
-    }
-
-    if (includeDependencies) {
-      eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
-    }
-
-    c.plugins = queryProcessor.create(d);
-    return c;
-  }
-
-  private static void closeAll(Iterable<RevWalk> revWalks, Iterable<Repository> repos) {
-    if (repos != null) {
-      for (Repository repo : repos) {
-        repo.close();
-      }
-    }
-    if (revWalks != null) {
-      for (RevWalk revWalk : revWalks) {
-        revWalk.close();
-      }
-    }
-  }
-
-  private void show(Object data) {
-    switch (outputFormat) {
-      default:
-      case TEXT:
-        if (data instanceof ChangeAttribute) {
-          out.print("change ");
-          out.print(((ChangeAttribute) data).id);
-          out.print("\n");
-          showText(data, 1);
-        } else {
-          showText(data, 0);
-        }
-        out.print('\n');
-        break;
-
-      case JSON:
-        out.print(new Gson().toJson(data));
-        out.print('\n');
-        break;
-    }
-  }
-
-  private void showText(Object data, int depth) {
-    for (Field f : fieldsOf(data.getClass())) {
-      Object val;
-      try {
-        val = f.get(data);
-      } catch (IllegalArgumentException err) {
-        continue;
-      } catch (IllegalAccessException err) {
-        continue;
-      }
-      if (val == null) {
-        continue;
-      }
-
-      showField(f.getName(), val, depth);
-    }
-  }
-
-  private String indent(int spaces) {
-    if (spaces == 0) {
-      return "";
-    }
-    return String.format("%" + spaces + "s", " ");
-  }
-
-  private void showField(String field, Object value, int depth) {
-    final int spacesDepthRatio = 2;
-    String indent = indent(depth * spacesDepthRatio);
-    out.print(indent);
-    out.print(field);
-    out.print(':');
-    if (value instanceof String && ((String) value).contains("\n")) {
-      out.print(' ');
-      // Idention for multi-line text is
-      // current depth indetion + length of field + length of ": "
-      indent = indent(indent.length() + field.length() + spacesDepthRatio);
-      out.print(((String) value).replaceAll("\n", "\n" + indent).trim());
-      out.print('\n');
-    } else if (value instanceof Long && isDateField(field)) {
-      out.print(' ');
-      out.print(dtf.print(((Long) value) * 1000L));
-      out.print('\n');
-    } else if (isPrimitive(value)) {
-      out.print(' ');
-      out.print(value);
-      out.print('\n');
-    } else if (value instanceof Collection) {
-      out.print('\n');
-      boolean firstElement = true;
-      for (Object thing : ((Collection<?>) value)) {
-        // The name of the collection was initially printed at the beginning
-        // of this routine.  Beginning at the second sub-element, reprint
-        // the collection name so humans can separate individual elements
-        // with less strain and error.
-        //
-        if (firstElement) {
-          firstElement = false;
-        } else {
-          out.print(indent);
-          out.print(field);
-          out.print(":\n");
-        }
-        if (isPrimitive(thing)) {
-          out.print(' ');
-          out.print(value);
-          out.print('\n');
-        } else {
-          showText(thing, depth + 1);
-        }
-      }
-    } else {
-      out.print('\n');
-      showText(value, depth + 1);
-    }
-  }
-
-  private static boolean isPrimitive(Object value) {
-    return value instanceof String //
-        || value instanceof Number //
-        || value instanceof Boolean //
-        || value instanceof Enum;
-  }
-
-  private static boolean isDateField(String name) {
-    return "lastUpdated".equals(name) //
-        || "grantedOn".equals(name) //
-        || "timestamp".equals(name) //
-        || "createdOn".equals(name);
-  }
-
-  private List<Field> fieldsOf(Class<?> type) {
-    List<Field> r = new ArrayList<>();
-    if (type.getSuperclass() != null) {
-      r.addAll(fieldsOf(type.getSuperclass()));
-    }
-    r.addAll(Arrays.asList(type.getDeclaredFields()));
-    return r;
-  }
-
-  static class ErrorMessage {
-    public final String type = "error";
-    public String message;
-  }
-}
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
deleted file mode 100644
index 19c0515..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.index.query.OrPredicate;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ListChildProjects;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.inject.Provider;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ParentProjectPredicate extends OrPredicate<ChangeData> {
-  private static final Logger log = LoggerFactory.getLogger(ParentProjectPredicate.class);
-
-  protected final String value;
-
-  public ParentProjectPredicate(
-      ProjectCache projectCache,
-      Provider<ListChildProjects> listChildProjects,
-      Provider<CurrentUser> self,
-      String value) {
-    super(predicates(projectCache, listChildProjects, self, value));
-    this.value = value;
-  }
-
-  protected static List<Predicate<ChangeData>> predicates(
-      ProjectCache projectCache,
-      Provider<ListChildProjects> listChildProjects,
-      Provider<CurrentUser> self,
-      String value) {
-    ProjectState projectState = projectCache.get(new Project.NameKey(value));
-    if (projectState == null) {
-      return Collections.emptyList();
-    }
-
-    List<Predicate<ChangeData>> r = new ArrayList<>();
-    r.add(new ProjectPredicate(projectState.getName()));
-    try {
-      ProjectResource proj = new ProjectResource(projectState.controlFor(self.get()));
-      ListChildProjects children = listChildProjects.get();
-      children.setRecursive(true);
-      for (ProjectInfo p : children.apply(proj)) {
-        r.add(new ProjectPredicate(p.name));
-      }
-    } catch (PermissionBackendException e) {
-      log.warn("cannot check permissions to expand child projects", e);
-    }
-    return r;
-  }
-
-  @Override
-  public String toString() {
-    return ChangeQueryBuilder.FIELD_PARENTPROJECT + ":" + value;
-  }
-}
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
deleted file mode 100644
index 8ad0e0b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
+++ /dev/null
@@ -1,151 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Sets;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.index.query.QueryRequiresAuthException;
-import com.google.gerrit.index.query.QueryResult;
-import com.google.gerrit.server.change.ChangeJson;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.List;
-import org.kohsuke.args4j.Option;
-
-public class QueryChanges implements RestReadView<TopLevelResource> {
-  private final ChangeJson.Factory json;
-  private final ChangeQueryBuilder qb;
-  private final ChangeQueryProcessor imp;
-  private EnumSet<ListChangesOption> options;
-
-  @Option(
-    name = "--query",
-    aliases = {"-q"},
-    metaVar = "QUERY",
-    usage = "Query string"
-  )
-  private List<String> queries;
-
-  @Option(
-    name = "--limit",
-    aliases = {"-n"},
-    metaVar = "CNT",
-    usage = "Maximum number of results to return"
-  )
-  public void setLimit(int limit) {
-    imp.setUserProvidedLimit(limit);
-  }
-
-  @Option(name = "-o", usage = "Output options per change")
-  public void addOption(ListChangesOption o) {
-    options.add(o);
-  }
-
-  @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListChangesOption.fromBits(Integer.parseInt(hex, 16)));
-  }
-
-  @Option(
-    name = "--start",
-    aliases = {"-S"},
-    metaVar = "CNT",
-    usage = "Number of changes to skip"
-  )
-  public void setStart(int start) {
-    imp.setStart(start);
-  }
-
-  @Inject
-  QueryChanges(ChangeJson.Factory json, ChangeQueryBuilder qb, ChangeQueryProcessor qp) {
-    this.json = json;
-    this.qb = qb;
-    this.imp = qp;
-
-    options = EnumSet.noneOf(ListChangesOption.class);
-  }
-
-  public void addQuery(String query) {
-    if (queries == null) {
-      queries = new ArrayList<>();
-    }
-    queries.add(query);
-  }
-
-  public String getQuery(int i) {
-    return queries.get(i);
-  }
-
-  @Override
-  public List<?> apply(TopLevelResource rsrc)
-      throws BadRequestException, AuthException, OrmException {
-    List<List<ChangeInfo>> out;
-    try {
-      out = query();
-    } catch (QueryRequiresAuthException e) {
-      throw new AuthException("Must be signed-in to use this operator");
-    } catch (QueryParseException e) {
-      throw new BadRequestException(e.getMessage(), e);
-    }
-    return out.size() == 1 ? out.get(0) : out;
-  }
-
-  private List<List<ChangeInfo>> query() throws OrmException, QueryParseException {
-    if (imp.isDisabled()) {
-      throw new QueryParseException("query disabled");
-    }
-    if (queries == null || queries.isEmpty()) {
-      queries = Collections.singletonList("status:open");
-    } else if (queries.size() > 10) {
-      // Hard-code a default maximum number of queries to prevent
-      // users from submitting too much to the server in a single call.
-      throw new QueryParseException("limit of 10 queries");
-    }
-
-    int cnt = queries.size();
-    List<QueryResult<ChangeData>> results = imp.query(qb.parse(queries));
-
-    ChangeJson cjson = json.create(options);
-    cjson.setPluginDefinedAttributesFactory(this.imp);
-    List<List<ChangeInfo>> res =
-        cjson
-            .lazyLoad(containsAnyOf(options, ChangeJson.REQUIRE_LAZY_LOAD))
-            .formatQueryResults(results);
-
-    for (int n = 0; n < cnt; n++) {
-      List<ChangeInfo> info = res.get(n);
-      if (results.get(n).more() && !info.isEmpty()) {
-        Iterables.getLast(info)._moreChanges = true;
-      }
-    }
-    return res;
-  }
-
-  private static boolean containsAnyOf(
-      EnumSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
-    return !Sets.intersection(toFind, set).isEmpty();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
deleted file mode 100644
index 46b4cd5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.util.RegexListSearcher;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import java.util.List;
-
-public class RegexPathPredicate extends ChangeRegexPredicate {
-  public RegexPathPredicate(String re) {
-    super(ChangeField.PATH, re);
-  }
-
-  @Override
-  public boolean match(ChangeData object) throws OrmException {
-    List<String> files;
-    try {
-      files = object.currentFilePaths();
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-    return RegexListSearcher.ofStrings(getValue()).hasMatch(files);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java
deleted file mode 100644
index 3b87fb6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.server.git.ProjectConfig;
-
-public class AclUtil {
-  public static void grant(
-      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
-    grant(config, section, permission, false, groupList);
-  }
-
-  public static void grant(
-      ProjectConfig config,
-      AccessSection section,
-      String permission,
-      boolean force,
-      GroupReference... groupList) {
-    grant(config, section, permission, force, null, groupList);
-  }
-
-  public static void grant(
-      ProjectConfig config,
-      AccessSection section,
-      String permission,
-      boolean force,
-      Boolean exclusive,
-      GroupReference... groupList) {
-    Permission p = section.getPermission(permission, true);
-    if (exclusive != null) {
-      p.setExclusiveGroup(exclusive);
-    }
-    for (GroupReference group : groupList) {
-      if (group != null) {
-        PermissionRule r = rule(config, group);
-        r.setForce(force);
-        p.add(r);
-      }
-    }
-  }
-
-  public static void block(
-      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
-    Permission p = section.getPermission(permission, true);
-    for (GroupReference group : groupList) {
-      if (group != null) {
-        PermissionRule r = rule(config, group);
-        r.setBlock();
-        p.add(r);
-      }
-    }
-  }
-
-  public static void grant(
-      ProjectConfig config,
-      AccessSection section,
-      LabelType type,
-      int min,
-      int max,
-      GroupReference... groupList) {
-    String name = Permission.LABEL + type.getName();
-    Permission p = section.getPermission(name, true);
-    for (GroupReference group : groupList) {
-      if (group != null) {
-        PermissionRule r = rule(config, group);
-        r.setRange(min, max);
-        p.add(r);
-      }
-    }
-  }
-
-  public static PermissionRule rule(ProjectConfig config, GroupReference group) {
-    return new PermissionRule(config.resolve(group));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
deleted file mode 100644
index dfcacb7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ /dev/null
@@ -1,245 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_SEQUENCES;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.schema.AclUtil.grant;
-import static com.google.gerrit.server.schema.AclUtil.rule;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Version;
-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.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.common.data.PermissionRule.Action;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.RepoSequence;
-import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-/** Creates the {@code All-Projects} repository and initial ACLs. */
-public class AllProjectsCreator {
-  private final GitRepositoryManager mgr;
-  private final AllProjectsName allProjectsName;
-  private final PersonIdent serverUser;
-  private final NotesMigration notesMigration;
-  private String message;
-  private int firstChangeId = ReviewDb.FIRST_CHANGE_ID;
-
-  private GroupReference admin;
-  private GroupReference batch;
-  private GroupReference anonymous;
-  private GroupReference registered;
-  private GroupReference owners;
-
-  @Inject
-  AllProjectsCreator(
-      GitRepositoryManager mgr,
-      AllProjectsName allProjectsName,
-      SystemGroupBackend systemGroupBackend,
-      @GerritPersonIdent PersonIdent serverUser,
-      NotesMigration notesMigration) {
-    this.mgr = mgr;
-    this.allProjectsName = allProjectsName;
-    this.serverUser = serverUser;
-    this.notesMigration = notesMigration;
-
-    this.anonymous = systemGroupBackend.getGroup(ANONYMOUS_USERS);
-    this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
-    this.owners = systemGroupBackend.getGroup(PROJECT_OWNERS);
-  }
-
-  public AllProjectsCreator setAdministrators(GroupReference admin) {
-    this.admin = admin;
-    return this;
-  }
-
-  public AllProjectsCreator setBatchUsers(GroupReference batch) {
-    this.batch = batch;
-    return this;
-  }
-
-  public AllProjectsCreator setCommitMessage(String message) {
-    this.message = message;
-    return this;
-  }
-
-  public AllProjectsCreator setFirstChangeIdForNoteDb(int id) {
-    checkArgument(id > 0, "id must be positive: %s", id);
-    firstChangeId = id;
-    return this;
-  }
-
-  public void create() throws IOException, ConfigInvalidException {
-    try (Repository git = mgr.openRepository(allProjectsName)) {
-      initAllProjects(git);
-    } catch (RepositoryNotFoundException notFound) {
-      // A repository may be missing if this project existed only to store
-      // inheritable permissions. For example 'All-Projects'.
-      try (Repository git = mgr.createRepository(allProjectsName)) {
-        initAllProjects(git);
-        RefUpdate u = git.updateRef(Constants.HEAD);
-        u.link(RefNames.REFS_CONFIG);
-      } catch (RepositoryNotFoundException err) {
-        String name = allProjectsName.get();
-        throw new IOException("Cannot create repository " + name, err);
-      }
-    }
-  }
-
-  private void initAllProjects(Repository git) throws IOException, ConfigInvalidException {
-    BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
-    try (MetaDataUpdate md =
-        new MetaDataUpdate(GitReferenceUpdated.DISABLED, allProjectsName, git, bru)) {
-      md.getCommitBuilder().setAuthor(serverUser);
-      md.getCommitBuilder().setCommitter(serverUser);
-      md.setMessage(
-          MoreObjects.firstNonNull(
-              Strings.emptyToNull(message),
-              "Initialized Gerrit Code Review " + Version.getVersion()));
-
-      ProjectConfig config = ProjectConfig.read(md);
-      Project p = config.getProject();
-      p.setDescription("Access inherited by all other projects.");
-      p.setRequireChangeID(InheritableBoolean.TRUE);
-      p.setUseContentMerge(InheritableBoolean.TRUE);
-      p.setUseContributorAgreements(InheritableBoolean.FALSE);
-      p.setUseSignedOffBy(InheritableBoolean.FALSE);
-      p.setEnableSignedPush(InheritableBoolean.FALSE);
-
-      AccessSection cap = config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true);
-      AccessSection all = config.getAccessSection(AccessSection.ALL, true);
-      AccessSection heads = config.getAccessSection(AccessSection.HEADS, true);
-      AccessSection tags = config.getAccessSection("refs/tags/*", true);
-      AccessSection meta = config.getAccessSection(RefNames.REFS_CONFIG, true);
-      AccessSection refsFor = config.getAccessSection("refs/for/*", true);
-      AccessSection magic = config.getAccessSection("refs/for/" + AccessSection.ALL, true);
-
-      grant(config, cap, GlobalCapability.ADMINISTRATE_SERVER, admin);
-      grant(config, all, Permission.READ, admin, anonymous);
-      grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
-
-      if (batch != null) {
-        Permission priority = cap.getPermission(GlobalCapability.PRIORITY, true);
-        PermissionRule r = rule(config, batch);
-        r.setAction(Action.BATCH);
-        priority.add(r);
-
-        Permission stream = cap.getPermission(GlobalCapability.STREAM_EVENTS, true);
-        stream.add(rule(config, batch));
-      }
-
-      LabelType cr = initCodeReviewLabel(config);
-      grant(config, heads, cr, -1, 1, registered);
-      grant(config, heads, cr, -2, 2, admin, owners);
-      grant(config, heads, Permission.CREATE, admin, owners);
-      grant(config, heads, Permission.PUSH, admin, owners);
-      grant(config, heads, Permission.SUBMIT, admin, owners);
-      grant(config, heads, Permission.FORGE_AUTHOR, registered);
-      grant(config, heads, Permission.FORGE_COMMITTER, admin, owners);
-      grant(config, heads, Permission.EDIT_TOPIC_NAME, true, admin, owners);
-
-      grant(config, tags, Permission.CREATE, admin, owners);
-      grant(config, tags, Permission.CREATE_TAG, admin, owners);
-      grant(config, tags, Permission.CREATE_SIGNED_TAG, admin, owners);
-
-      grant(config, magic, Permission.PUSH, registered);
-      grant(config, magic, Permission.PUSH_MERGE, registered);
-
-      meta.getPermission(Permission.READ, true).setExclusiveGroup(true);
-      grant(config, meta, Permission.READ, admin, owners);
-      grant(config, meta, cr, -2, 2, admin, owners);
-      grant(config, meta, Permission.CREATE, admin, owners);
-      grant(config, meta, Permission.PUSH, admin, owners);
-      grant(config, meta, Permission.SUBMIT, admin, owners);
-
-      config.commitToNewRef(md, RefNames.REFS_CONFIG);
-      initSequences(git, bru);
-      execute(git, bru);
-    }
-  }
-
-  public static LabelType initCodeReviewLabel(ProjectConfig c) {
-    LabelType type =
-        new LabelType(
-            "Code-Review",
-            ImmutableList.of(
-                new LabelValue((short) 2, "Looks good to me, approved"),
-                new LabelValue((short) 1, "Looks good to me, but someone else must approve"),
-                new LabelValue((short) 0, "No score"),
-                new LabelValue((short) -1, "I would prefer this is not merged as is"),
-                new LabelValue((short) -2, "This shall not be merged")));
-    type.setCopyMinScore(true);
-    type.setCopyAllScoresOnTrivialRebase(true);
-    c.getLabelSections().put(type.getName(), type);
-    return type;
-  }
-
-  private void initSequences(Repository git, BatchRefUpdate bru) throws IOException {
-    if (notesMigration.readChangeSequence()
-        && git.exactRef(REFS_SEQUENCES + Sequences.NAME_CHANGES) == null) {
-      // Can't easily reuse the inserter from MetaDataUpdate, but this shouldn't slow down site
-      // initialization unduly.
-      try (ObjectInserter ins = git.newObjectInserter()) {
-        bru.addCommand(RepoSequence.storeNew(ins, Sequences.NAME_CHANGES, firstChangeId));
-        ins.flush();
-      }
-    }
-  }
-
-  private void execute(Repository git, BatchRefUpdate bru) throws IOException {
-    try (RevWalk rw = new RevWalk(git)) {
-      bru.execute(rw, NullProgressMonitor.INSTANCE);
-    }
-    for (ReceiveCommand cmd : bru.getCommands()) {
-      if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        throw new IOException("Failed to initialize " + allProjectsName + " refs:\n" + bru);
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllUsersCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllUsersCreator.java
deleted file mode 100644
index b524ecc..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ /dev/null
@@ -1,111 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.schema.AclUtil.grant;
-
-import com.google.gerrit.common.Version;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.RefPattern;
-import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-/** Creates the {@code All-Users} repository. */
-public class AllUsersCreator {
-  private final GitRepositoryManager mgr;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverUser;
-  private final GroupReference registered;
-
-  private GroupReference admin;
-
-  @Inject
-  AllUsersCreator(
-      GitRepositoryManager mgr,
-      AllUsersName allUsersName,
-      SystemGroupBackend systemGroupBackend,
-      @GerritPersonIdent PersonIdent serverUser) {
-    this.mgr = mgr;
-    this.allUsersName = allUsersName;
-    this.serverUser = serverUser;
-    this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
-  }
-
-  public AllUsersCreator setAdministrators(GroupReference admin) {
-    this.admin = admin;
-    return this;
-  }
-
-  public void create() throws IOException, ConfigInvalidException {
-    try (Repository git = mgr.openRepository(allUsersName)) {
-      initAllUsers(git);
-    } catch (RepositoryNotFoundException notFound) {
-      try (Repository git = mgr.createRepository(allUsersName)) {
-        initAllUsers(git);
-      } catch (RepositoryNotFoundException err) {
-        String name = allUsersName.get();
-        throw new IOException("Cannot create repository " + name, err);
-      }
-    }
-  }
-
-  private void initAllUsers(Repository git) throws IOException, ConfigInvalidException {
-    try (MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
-      md.getCommitBuilder().setAuthor(serverUser);
-      md.getCommitBuilder().setCommitter(serverUser);
-      md.setMessage("Initialized Gerrit Code Review " + Version.getVersion());
-
-      ProjectConfig config = ProjectConfig.read(md);
-      Project project = config.getProject();
-      project.setDescription("Individual user settings and preferences.");
-
-      AccessSection users =
-          config.getAccessSection(
-              RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true);
-      LabelType cr = AllProjectsCreator.initCodeReviewLabel(config);
-      grant(config, users, Permission.READ, false, true, registered);
-      grant(config, users, Permission.PUSH, false, true, registered);
-      grant(config, users, Permission.SUBMIT, false, true, registered);
-      grant(config, users, cr, -2, 2, registered);
-
-      AccessSection defaults = config.getAccessSection(RefNames.REFS_USERS_DEFAULT, true);
-      defaults.getPermission(Permission.READ, true).setExclusiveGroup(true);
-      grant(config, defaults, Permission.READ, admin);
-      defaults.getPermission(Permission.PUSH, true).setExclusiveGroup(true);
-      grant(config, defaults, Permission.PUSH, admin);
-      defaults.getPermission(Permission.CREATE, true).setExclusiveGroup(true);
-      grant(config, defaults, Permission.CREATE, admin);
-
-      config.commit(md);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
deleted file mode 100644
index fd0c7fc..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
+++ /dev/null
@@ -1,357 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.util.concurrent.Futures;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ChangeAccess;
-import com.google.gerrit.reviewdb.server.ChangeMessageAccess;
-import com.google.gerrit.reviewdb.server.PatchLineCommentAccess;
-import com.google.gerrit.reviewdb.server.PatchSetAccess;
-import com.google.gerrit.reviewdb.server.PatchSetApprovalAccess;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gwtorm.client.Key;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.AtomicUpdate;
-import com.google.gwtorm.server.ListResultSet;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import java.util.Map;
-import java.util.function.Function;
-
-/**
- * Wrapper for ReviewDb that never calls the underlying change tables.
- *
- * <p>See {@link NotesMigrationSchemaFactory} for discussion.
- */
-class NoChangesReviewDbWrapper extends ReviewDbWrapper {
-  private static <T> ResultSet<T> empty() {
-    return new ListResultSet<>(ImmutableList.of());
-  }
-
-  @SuppressWarnings("deprecation")
-  private static <T, K extends Key<?>>
-      com.google.common.util.concurrent.CheckedFuture<T, OrmException> emptyFuture() {
-    return Futures.immediateCheckedFuture(null);
-  }
-
-  private final ChangeAccess changes;
-  private final PatchSetApprovalAccess patchSetApprovals;
-  private final ChangeMessageAccess changeMessages;
-  private final PatchSetAccess patchSets;
-  private final PatchLineCommentAccess patchComments;
-
-  private boolean inTransaction;
-
-  NoChangesReviewDbWrapper(ReviewDb db) {
-    super(db);
-    changes = new Changes(this, delegate);
-    patchSetApprovals = new PatchSetApprovals(this, delegate);
-    changeMessages = new ChangeMessages(this, delegate);
-    patchSets = new PatchSets(this, delegate);
-    patchComments = new PatchLineComments(this, delegate);
-  }
-
-  @Override
-  public boolean changesTablesEnabled() {
-    return false;
-  }
-
-  @Override
-  public ChangeAccess changes() {
-    return changes;
-  }
-
-  @Override
-  public PatchSetApprovalAccess patchSetApprovals() {
-    return patchSetApprovals;
-  }
-
-  @Override
-  public ChangeMessageAccess changeMessages() {
-    return changeMessages;
-  }
-
-  @Override
-  public PatchSetAccess patchSets() {
-    return patchSets;
-  }
-
-  @Override
-  public PatchLineCommentAccess patchComments() {
-    return patchComments;
-  }
-
-  @Override
-  public void commit() throws OrmException {
-    if (!inTransaction) {
-      // This reads a little weird, we're not in a transaction, so why are we calling commit?
-      // Because we want to let the underlying ReviewDb do its normal thing in this case (which may
-      // be throwing an exception, or not, depending on implementation).
-      delegate.commit();
-    }
-  }
-
-  @Override
-  public void rollback() throws OrmException {
-    if (inTransaction) {
-      inTransaction = false;
-    } else {
-      // See comment in commit(): we want to let the underlying ReviewDb do its thing.
-      delegate.rollback();
-    }
-  }
-
-  private abstract static class AbstractDisabledAccess<T, K extends Key<?>>
-      implements Access<T, K> {
-    // Don't even hold a reference to delegate, so it's not possible to use it accidentally.
-    private final NoChangesReviewDbWrapper wrapper;
-    private final String relationName;
-    private final int relationId;
-    private final Function<T, K> primaryKey;
-    private final Function<Iterable<T>, Map<K, T>> toMap;
-
-    private AbstractDisabledAccess(NoChangesReviewDbWrapper wrapper, Access<T, K> delegate) {
-      this.wrapper = wrapper;
-      this.relationName = delegate.getRelationName();
-      this.relationId = delegate.getRelationID();
-      this.primaryKey = delegate::primaryKey;
-      this.toMap = delegate::toMap;
-    }
-
-    @Override
-    public final int getRelationID() {
-      return relationId;
-    }
-
-    @Override
-    public final String getRelationName() {
-      return relationName;
-    }
-
-    @Override
-    public final K primaryKey(T entity) {
-      return primaryKey.apply(entity);
-    }
-
-    @Override
-    public final Map<K, T> toMap(Iterable<T> iterable) {
-      return toMap.apply(iterable);
-    }
-
-    @Override
-    public final ResultSet<T> iterateAllEntities() {
-      return empty();
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public final com.google.common.util.concurrent.CheckedFuture<T, OrmException> getAsync(K key) {
-      return emptyFuture();
-    }
-
-    @Override
-    public final ResultSet<T> get(Iterable<K> keys) {
-      return empty();
-    }
-
-    @Override
-    public final void insert(Iterable<T> instances) {
-      // Do nothing.
-    }
-
-    @Override
-    public final void update(Iterable<T> instances) {
-      // Do nothing.
-    }
-
-    @Override
-    public final void upsert(Iterable<T> instances) {
-      // Do nothing.
-    }
-
-    @Override
-    public final void deleteKeys(Iterable<K> keys) {
-      // Do nothing.
-    }
-
-    @Override
-    public final void delete(Iterable<T> instances) {
-      // Do nothing.
-    }
-
-    @Override
-    public final void beginTransaction(K key) {
-      // Keep track of when we've started a transaction so that we can avoid calling commit/rollback
-      // on the underlying ReviewDb. This is just a simple arm's-length approach, and may produce
-      // slightly different results from a native ReviewDb in corner cases like:
-      //  * beginning transactions on different tables simultaneously
-      //  * doing work between commit and rollback
-      // These kinds of things are already misuses of ReviewDb, and shouldn't be happening in
-      // current code anyway.
-      checkState(!wrapper.inTransaction, "already in transaction");
-      wrapper.inTransaction = true;
-    }
-
-    @Override
-    public final T atomicUpdate(K key, AtomicUpdate<T> update) {
-      return null;
-    }
-
-    @Override
-    public final T get(K id) {
-      return null;
-    }
-  }
-
-  private static class Changes extends AbstractDisabledAccess<Change, Change.Id>
-      implements ChangeAccess {
-    private Changes(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.changes());
-    }
-
-    @Override
-    public ResultSet<Change> all() {
-      return empty();
-    }
-  }
-
-  private static class ChangeMessages
-      extends AbstractDisabledAccess<ChangeMessage, ChangeMessage.Key>
-      implements ChangeMessageAccess {
-    private ChangeMessages(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.changeMessages());
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> byChange(Change.Id id) throws OrmException {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id) throws OrmException {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> all() throws OrmException {
-      return empty();
-    }
-  }
-
-  private static class PatchSets extends AbstractDisabledAccess<PatchSet, PatchSet.Id>
-      implements PatchSetAccess {
-    private PatchSets(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.patchSets());
-    }
-
-    @Override
-    public ResultSet<PatchSet> byChange(Change.Id id) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchSet> all() {
-      return empty();
-    }
-  }
-
-  private static class PatchSetApprovals
-      extends AbstractDisabledAccess<PatchSetApproval, PatchSetApproval.Key>
-      implements PatchSetApprovalAccess {
-    private PatchSetApprovals(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.patchSetApprovals());
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byChange(Change.Id id) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byPatchSetUser(PatchSet.Id patchSet, Account.Id account) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> all() {
-      return empty();
-    }
-  }
-
-  private static class PatchLineComments
-      extends AbstractDisabledAccess<PatchLineComment, PatchLineComment.Key>
-      implements PatchLineCommentAccess {
-    private PatchLineComments(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.patchComments());
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> byChange(Change.Id id) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id, String file) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> publishedByPatchSet(PatchSet.Id patchset) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByPatchSetAuthor(
-        PatchSet.Id patchset, Account.Id author) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByChangeFileAuthor(
-        Change.Id id, String file, Account.Id author) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByAuthor(Account.Id author) {
-      return empty();
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> all() {
-      return empty();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
deleted file mode 100644
index d73a5f4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.server.DisallowReadFromChangesReviewDbWrapper;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class NotesMigrationSchemaFactory implements SchemaFactory<ReviewDb> {
-  private final SchemaFactory<ReviewDb> delegate;
-  private final NotesMigration migration;
-
-  @Inject
-  NotesMigrationSchemaFactory(
-      @ReviewDbFactory SchemaFactory<ReviewDb> delegate, NotesMigration migration) {
-    this.delegate = delegate;
-    this.migration = migration;
-  }
-
-  @Override
-  public ReviewDb open() throws OrmException {
-    ReviewDb db = delegate.open();
-    if (!migration.readChanges()) {
-      return db;
-    }
-
-    // There are two levels at which this class disables access to Changes and related tables,
-    // corresponding to two phases of the NoteDb migration:
-    //
-    // 1. When changes are read from NoteDb but some changes might still have their primary storage
-    //    in ReviewDb, it is generally programmer error to read changes from ReviewDb. However,
-    //    since ReviewDb is still the primary storage for most or all changes, we still need to
-    //    support writing to ReviewDb. This behavior is accomplished by wrapping in a
-    //    DisallowReadFromChangesReviewDbWrapper.
-    //
-    //    Some codepaths might need to be able to read from ReviewDb if they really need to, because
-    //    they need to operate on the underlying source of truth, for example when reading a change
-    //    to determine its primary storage. To support this, ReviewDbUtil#unwrapDb can detect and
-    //    unwrap databases of this type.
-    //
-    // 2. After all changes have their primary storage in NoteDb, we can completely shut off access
-    //    to the change tables. At this point in the migration, we are by definition not using the
-    //    ReviewDb tables at all; we could even delete the tables at this point, and Gerrit would
-    //    continue to function.
-    //
-    //    This is accomplished by setting the delegate ReviewDb *underneath* DisallowReadFromChanges
-    //    to be a complete no-op, with NoChangesReviewDbWrapper. With this wrapper, all read
-    //    operations return no results, and write operations silently do nothing. This wrapper is
-    //    not a public class and nobody should ever attempt to unwrap it.
-
-    if (migration.disableChangeReviewDb()) {
-      db = new NoChangesReviewDbWrapper(db);
-    }
-    return new DisallowReadFromChangesReviewDbWrapper(db);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaCreator.java
deleted file mode 100644
index 8c1ccd2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaCreator.java
+++ /dev/null
@@ -1,139 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
-import com.google.gerrit.reviewdb.client.SystemConfig;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.GroupUUID;
-import com.google.gerrit.server.config.SitePath;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.group.GroupsUpdate;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.gerrit.server.index.group.GroupIndexCollection;
-import com.google.gwtorm.jdbc.JdbcExecutor;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.Collections;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-
-/** Creates the current database schema and populates initial code rows. */
-public class SchemaCreator {
-  @SitePath private final Path site_path;
-
-  private final AllProjectsCreator allProjectsCreator;
-  private final AllUsersCreator allUsersCreator;
-  private final PersonIdent serverUser;
-  private final DataSourceType dataSourceType;
-  private final GroupIndexCollection indexCollection;
-
-  private AccountGroup admin;
-  private AccountGroup batch;
-
-  @Inject
-  public SchemaCreator(
-      SitePaths site,
-      AllProjectsCreator ap,
-      AllUsersCreator auc,
-      @GerritPersonIdent PersonIdent au,
-      DataSourceType dst,
-      GroupIndexCollection ic) {
-    this(site.site_path, ap, auc, au, dst, ic);
-  }
-
-  public SchemaCreator(
-      @SitePath Path site,
-      AllProjectsCreator ap,
-      AllUsersCreator auc,
-      @GerritPersonIdent PersonIdent au,
-      DataSourceType dst,
-      GroupIndexCollection ic) {
-    site_path = site;
-    allProjectsCreator = ap;
-    allUsersCreator = auc;
-    serverUser = au;
-    dataSourceType = dst;
-    indexCollection = ic;
-  }
-
-  public void create(ReviewDb db) throws OrmException, IOException, ConfigInvalidException {
-    final JdbcSchema jdbc = (JdbcSchema) db;
-    try (JdbcExecutor e = new JdbcExecutor(jdbc)) {
-      jdbc.updateSchema(e);
-    }
-
-    final CurrentSchemaVersion sVer = CurrentSchemaVersion.create();
-    sVer.versionNbr = SchemaVersion.getBinaryVersion();
-    db.schemaVersion().insert(Collections.singleton(sVer));
-
-    createDefaultGroups(db);
-    initSystemConfig(db);
-    allProjectsCreator
-        .setAdministrators(GroupReference.forGroup(admin))
-        .setBatchUsers(GroupReference.forGroup(batch))
-        .create();
-    allUsersCreator.setAdministrators(GroupReference.forGroup(admin)).create();
-    dataSourceType.getIndexScript().run(db);
-  }
-
-  private void createDefaultGroups(ReviewDb db) throws OrmException, IOException {
-    admin = newGroup(db, "Administrators");
-    admin.setDescription("Gerrit Site Administrators");
-    GroupsUpdate.addNewGroup(db, admin);
-    index(InternalGroup.create(admin, ImmutableSet.of(), ImmutableSet.of()));
-
-    batch = newGroup(db, "Non-Interactive Users");
-    batch.setDescription("Users who perform batch actions on Gerrit");
-    batch.setOwnerGroupUUID(admin.getGroupUUID());
-    GroupsUpdate.addNewGroup(db, batch);
-    index(InternalGroup.create(batch, ImmutableSet.of(), ImmutableSet.of()));
-  }
-
-  private void index(InternalGroup group) throws IOException {
-    for (GroupIndex groupIndex : indexCollection.getWriteIndexes()) {
-      groupIndex.replace(group);
-    }
-  }
-
-  private AccountGroup newGroup(ReviewDb c, String name) throws OrmException {
-    AccountGroup.UUID uuid = GroupUUID.make(name, serverUser);
-    return new AccountGroup( //
-        new AccountGroup.NameKey(name), //
-        new AccountGroup.Id(c.nextAccountGroupId()), //
-        uuid,
-        TimeUtil.nowTs());
-  }
-
-  private SystemConfig initSystemConfig(ReviewDb db) throws OrmException {
-    SystemConfig s = SystemConfig.create();
-    try {
-      s.sitePath = site_path.toRealPath().normalize().toString();
-    } catch (IOException e) {
-      s.sitePath = site_path.toAbsolutePath().normalize().toString();
-    }
-    db.systemConfig().insert(Collections.singleton(s));
-    return s;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
deleted file mode 100644
index d1cbad6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ /dev/null
@@ -1,214 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Stopwatch;
-import com.google.common.collect.Lists;
-import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.jdbc.JdbcExecutor;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import com.google.inject.Provider;
-import java.sql.PreparedStatement;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
-/** A version of the database schema. */
-public abstract class SchemaVersion {
-  /** The current schema version. */
-  public static final Class<Schema_161> C = Schema_161.class;
-
-  public static int getBinaryVersion() {
-    return guessVersion(C);
-  }
-
-  private final Provider<? extends SchemaVersion> prior;
-  private final int versionNbr;
-
-  protected SchemaVersion(Provider<? extends SchemaVersion> prior) {
-    this.prior = prior;
-    this.versionNbr = guessVersion(getClass());
-  }
-
-  public static int guessVersion(Class<?> c) {
-    String n = c.getName();
-    n = n.substring(n.lastIndexOf('_') + 1);
-    while (n.startsWith("0")) {
-      n = n.substring(1);
-    }
-    return Integer.parseInt(n);
-  }
-
-  /** @return the {@link CurrentSchemaVersion#versionNbr} this step targets. */
-  public final int getVersionNbr() {
-    return versionNbr;
-  }
-
-  @VisibleForTesting
-  public final SchemaVersion getPrior() {
-    return prior.get();
-  }
-
-  public final void check(UpdateUI ui, CurrentSchemaVersion curr, ReviewDb db)
-      throws OrmException, SQLException {
-    if (curr.versionNbr == versionNbr) {
-      // Nothing to do, we are at the correct schema.
-    } else if (curr.versionNbr > versionNbr) {
-      throw new OrmException(
-          "Cannot downgrade database schema from version "
-              + curr.versionNbr
-              + " to "
-              + versionNbr
-              + ".");
-    } else {
-      upgradeFrom(ui, curr, db);
-    }
-  }
-
-  /** Runs check on the prior schema version, and then upgrades. */
-  private void upgradeFrom(UpdateUI ui, CurrentSchemaVersion curr, ReviewDb db)
-      throws OrmException, SQLException {
-    List<SchemaVersion> pending = pending(curr.versionNbr);
-    updateSchema(pending, ui, db);
-    migrateData(pending, ui, curr, db);
-
-    JdbcSchema s = (JdbcSchema) db;
-    final List<String> pruneList = new ArrayList<>();
-    s.pruneSchema(
-        new StatementExecutor() {
-          @Override
-          public void execute(String sql) {
-            pruneList.add(sql);
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        });
-
-    try (JdbcExecutor e = new JdbcExecutor(s)) {
-      if (!pruneList.isEmpty()) {
-        ui.pruneSchema(e, pruneList);
-      }
-    }
-  }
-
-  private List<SchemaVersion> pending(int curr) {
-    List<SchemaVersion> r = Lists.newArrayListWithCapacity(versionNbr - curr);
-    for (SchemaVersion v = this; curr < v.getVersionNbr(); v = v.prior.get()) {
-      r.add(v);
-    }
-    Collections.reverse(r);
-    return r;
-  }
-
-  private void updateSchema(List<SchemaVersion> pending, UpdateUI ui, ReviewDb db)
-      throws OrmException, SQLException {
-    for (SchemaVersion v : pending) {
-      ui.message(String.format("Upgrading schema to %d ...", v.getVersionNbr()));
-      v.preUpdateSchema(db);
-    }
-
-    JdbcSchema s = (JdbcSchema) db;
-    try (JdbcExecutor e = new JdbcExecutor(s)) {
-      s.updateSchema(e);
-    }
-  }
-
-  /**
-   * Invoked before updateSchema adds new columns/tables.
-   *
-   * @param db open database handle.
-   * @throws OrmException if a Gerrit-specific exception occurred.
-   * @throws SQLException if an underlying SQL exception occurred.
-   */
-  protected void preUpdateSchema(ReviewDb db) throws OrmException, SQLException {}
-
-  private void migrateData(
-      List<SchemaVersion> pending, UpdateUI ui, CurrentSchemaVersion curr, ReviewDb db)
-      throws OrmException, SQLException {
-    for (SchemaVersion v : pending) {
-      Stopwatch sw = Stopwatch.createStarted();
-      ui.message(String.format("Migrating data to schema %d ...", v.getVersionNbr()));
-      v.migrateData(db, ui);
-      v.finish(curr, db);
-      ui.message(String.format("\t> Done (%.3f s)", sw.elapsed(TimeUnit.MILLISECONDS) / 1000d));
-    }
-  }
-
-  /**
-   * Invoked between updateSchema (adds new columns/tables) and pruneSchema (removes deleted
-   * columns/tables).
-   *
-   * @param db open database handle.
-   * @param ui interface for interacting with the user.
-   * @throws OrmException if a Gerrit-specific exception occurred.
-   * @throws SQLException if an underlying SQL exception occurred.
-   */
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {}
-
-  /** Mark the current schema version. */
-  protected void finish(CurrentSchemaVersion curr, ReviewDb db) throws OrmException {
-    curr.versionNbr = versionNbr;
-    db.schemaVersion().update(Collections.singleton(curr));
-  }
-
-  /** Rename an existing table. */
-  protected static void renameTable(ReviewDb db, String from, String to) throws OrmException {
-    JdbcSchema s = (JdbcSchema) db;
-    try (JdbcExecutor e = new JdbcExecutor(s)) {
-      s.renameTable(e, from, to);
-    }
-  }
-
-  /** Rename an existing column. */
-  protected static void renameColumn(ReviewDb db, String table, String from, String to)
-      throws OrmException {
-    JdbcSchema s = (JdbcSchema) db;
-    try (JdbcExecutor e = new JdbcExecutor(s)) {
-      s.renameColumn(e, table, from, to);
-    }
-  }
-
-  /** Execute an SQL statement. */
-  protected static void execute(ReviewDb db, String sql) throws SQLException {
-    try (Statement s = newStatement(db)) {
-      s.execute(sql);
-    }
-  }
-
-  /** Open a new single statement. */
-  protected static Statement newStatement(ReviewDb db) throws SQLException {
-    return ((JdbcSchema) db).getConnection().createStatement();
-  }
-
-  /** Open a new prepared statement. */
-  protected static PreparedStatement prepareStatement(ReviewDb db, String sql) throws SQLException {
-    return ((JdbcSchema) db).getConnection().prepareStatement(sql);
-  }
-
-  /** Open a new statement executor. */
-  protected static JdbcExecutor newExecutor(ReviewDb db) throws OrmException {
-    return new JdbcExecutor(((JdbcSchema) db).getConnection());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_139.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_139.java
deleted file mode 100644
index 4dfc41a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_139.java
+++ /dev/null
@@ -1,206 +0,0 @@
-//Copyright (C) 2016 The Android Open Source Project
-//
-//Licensed under the Apache License, Version 2.0 (the "License");
-//you may not use this file except in compliance with the License.
-//You may obtain a copy of the License at
-//
-//http://www.apache.org/licenses/LICENSE-2.0
-//
-//Unless required by applicable law or agreed to in writing, software
-//distributed under the License is distributed on an "AS IS" BASIS,
-//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//See the License for the specific language governing permissions and
-//limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.Preconditions;
-import com.google.common.base.Strings;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.WatchConfig;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.Collection;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-public class Schema_139 extends SchemaVersion {
-  private static final String MSG = "Migrate project watches to git";
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_139(
-      Provider<Schema_138> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    ListMultimap<Account.Id, ProjectWatch> imports =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
-        ResultSet rs =
-            stmt.executeQuery(
-                "SELECT "
-                    + "account_id, "
-                    + "project_name, "
-                    + "filter, "
-                    + "notify_abandoned_changes, "
-                    + "notify_all_comments, "
-                    + "notify_new_changes, "
-                    + "notify_new_patch_sets, "
-                    + "notify_submitted_changes "
-                    + "FROM account_project_watches")) {
-      while (rs.next()) {
-        Account.Id accountId = new Account.Id(rs.getInt(1));
-        ProjectWatch.Builder b =
-            ProjectWatch.builder()
-                .project(new Project.NameKey(rs.getString(2)))
-                .filter(rs.getString(3))
-                .notifyAbandonedChanges(toBoolean(rs.getString(4)))
-                .notifyAllComments(toBoolean(rs.getString(5)))
-                .notifyNewChanges(toBoolean(rs.getString(6)))
-                .notifyNewPatchSets(toBoolean(rs.getString(7)))
-                .notifySubmittedChanges(toBoolean(rs.getString(8)));
-        imports.put(accountId, b.build());
-      }
-    }
-
-    if (imports.isEmpty()) {
-      return;
-    }
-
-    try (Repository git = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(git)) {
-      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
-      bru.setRefLogIdent(serverUser);
-      bru.setRefLogMessage(MSG, false);
-
-      for (Map.Entry<Account.Id, Collection<ProjectWatch>> e : imports.asMap().entrySet()) {
-        Map<ProjectWatchKey, Set<NotifyType>> projectWatches = new HashMap<>();
-        for (ProjectWatch projectWatch : e.getValue()) {
-          ProjectWatchKey key =
-              ProjectWatchKey.create(projectWatch.project(), projectWatch.filter());
-          if (projectWatches.containsKey(key)) {
-            throw new OrmDuplicateKeyException(
-                "Duplicate key for watched project: " + key.toString());
-          }
-          Set<NotifyType> notifyValues = EnumSet.noneOf(NotifyType.class);
-          if (projectWatch.notifyAbandonedChanges()) {
-            notifyValues.add(NotifyType.ABANDONED_CHANGES);
-          }
-          if (projectWatch.notifyAllComments()) {
-            notifyValues.add(NotifyType.ALL_COMMENTS);
-          }
-          if (projectWatch.notifyNewChanges()) {
-            notifyValues.add(NotifyType.NEW_CHANGES);
-          }
-          if (projectWatch.notifyNewPatchSets()) {
-            notifyValues.add(NotifyType.NEW_PATCHSETS);
-          }
-          if (projectWatch.notifySubmittedChanges()) {
-            notifyValues.add(NotifyType.SUBMITTED_CHANGES);
-          }
-          projectWatches.put(key, notifyValues);
-        }
-
-        try (MetaDataUpdate md =
-            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git, bru)) {
-          md.getCommitBuilder().setAuthor(serverUser);
-          md.getCommitBuilder().setCommitter(serverUser);
-          md.setMessage(MSG);
-
-          WatchConfig watchConfig = new WatchConfig(e.getKey());
-          watchConfig.load(md);
-          watchConfig.setProjectWatches(projectWatches);
-          watchConfig.commit(md);
-        }
-      }
-      bru.execute(rw, NullProgressMonitor.INSTANCE);
-    } catch (IOException | ConfigInvalidException ex) {
-      throw new OrmException(ex);
-    }
-  }
-
-  @AutoValue
-  abstract static class ProjectWatch {
-    abstract Project.NameKey project();
-
-    abstract @Nullable String filter();
-
-    abstract boolean notifyAbandonedChanges();
-
-    abstract boolean notifyAllComments();
-
-    abstract boolean notifyNewChanges();
-
-    abstract boolean notifyNewPatchSets();
-
-    abstract boolean notifySubmittedChanges();
-
-    static Builder builder() {
-      return new AutoValue_Schema_139_ProjectWatch.Builder();
-    }
-
-    @AutoValue.Builder
-    abstract static class Builder {
-      abstract Builder project(Project.NameKey project);
-
-      abstract Builder filter(@Nullable String filter);
-
-      abstract Builder notifyAbandonedChanges(boolean notifyAbandonedChanges);
-
-      abstract Builder notifyAllComments(boolean notifyAllComments);
-
-      abstract Builder notifyNewChanges(boolean notifyNewChanges);
-
-      abstract Builder notifyNewPatchSets(boolean notifyNewPatchSets);
-
-      abstract Builder notifySubmittedChanges(boolean notifySubmittedChanges);
-
-      abstract ProjectWatch build();
-    }
-  }
-
-  private static boolean toBoolean(String v) {
-    Preconditions.checkState(!Strings.isNullOrEmpty(v));
-    return v.equals("Y");
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java
deleted file mode 100644
index d43b887..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java
+++ /dev/null
@@ -1,114 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdReader;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.HashSet;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-public class Schema_144 extends SchemaVersion {
-  private static final String COMMIT_MSG = "Import external IDs from ReviewDb";
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverIdent;
-
-  @Inject
-  Schema_144(
-      Provider<Schema_143> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverIdent) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.serverIdent = serverIdent;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    Set<ExternalId> toAdd = new HashSet<>();
-    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
-        ResultSet rs =
-            stmt.executeQuery(
-                "SELECT "
-                    + "account_id, "
-                    + "email_address, "
-                    + "password, "
-                    + "external_id "
-                    + "FROM account_external_ids")) {
-      while (rs.next()) {
-        Account.Id accountId = new Account.Id(rs.getInt(1));
-        String email = rs.getString(2);
-        String password = rs.getString(3);
-        String externalId = rs.getString(4);
-
-        toAdd.add(ExternalId.create(ExternalId.Key.parse(externalId), accountId, email, password));
-      }
-    }
-
-    try {
-      try (Repository repo = repoManager.openRepository(allUsersName);
-          RevWalk rw = new RevWalk(repo);
-          ObjectInserter ins = repo.newObjectInserter()) {
-        ObjectId rev = ExternalIdReader.readRevision(repo);
-
-        NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-
-        for (ExternalId extId : toAdd) {
-          ExternalIdsUpdate.upsert(rw, ins, noteMap, extId);
-        }
-
-        ExternalIdsUpdate.commit(
-            allUsersName,
-            repo,
-            rw,
-            ins,
-            rev,
-            noteMap,
-            COMMIT_MSG,
-            serverIdent,
-            serverIdent,
-            null,
-            GitReferenceUpdated.DISABLED);
-      }
-    } catch (IOException | ConfigInvalidException e) {
-      throw new OrmException("Failed to migrate external IDs to NoteDb", e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_147.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_147.java
deleted file mode 100644
index 29ae7d5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_147.java
+++ /dev/null
@@ -1,90 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static java.util.stream.Collectors.toSet;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.HashSet;
-import java.util.Objects;
-import java.util.Set;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-/** Delete user branches for which no account exists. */
-public class Schema_147 extends SchemaVersion {
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverIdent;
-
-  @Inject
-  Schema_147(
-      Provider<Schema_146> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverIdent) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.serverIdent = serverIdent;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      Set<Account.Id> accountIdsFromReviewDb = scanAccounts(db);
-      Set<Account.Id> accountIdsFromUserBranches =
-          repo.getRefDatabase()
-              .getRefs(RefNames.REFS_USERS)
-              .values()
-              .stream()
-              .map(r -> Account.Id.fromRef(r.getName()))
-              .filter(Objects::nonNull)
-              .collect(toSet());
-      accountIdsFromUserBranches.removeAll(accountIdsFromReviewDb);
-      for (Account.Id accountId : accountIdsFromUserBranches) {
-        AccountsUpdate.deleteUserBranch(
-            repo, allUsersName, GitReferenceUpdated.DISABLED, null, serverIdent, accountId);
-      }
-    } catch (IOException e) {
-      throw new OrmException("Failed to delete user branches for non-existing accounts.", e);
-    }
-  }
-
-  private Set<Account.Id> scanAccounts(ReviewDb db) throws SQLException {
-    try (Statement stmt = newStatement(db);
-        ResultSet rs = stmt.executeQuery("SELECT account_id FROM accounts")) {
-      Set<Account.Id> ids = new HashSet<>();
-      while (rs.next()) {
-        ids.add(new Account.Id(rs.getInt(1)));
-      }
-      return ids;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java
deleted file mode 100644
index 47751cd..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java
+++ /dev/null
@@ -1,111 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.google.common.primitives.Ints;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdReader;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.SQLException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.Note;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-public class Schema_148 extends SchemaVersion {
-  private static final String COMMIT_MSG = "Make account IDs of external IDs human-readable";
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverUser;
-
-  @Inject
-  Schema_148(
-      Provider<Schema_147> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverUser) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.serverUser = serverUser;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try (Repository repo = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(repo);
-        ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId rev = ExternalIdReader.readRevision(repo);
-      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-      boolean dirty = false;
-      for (Note note : noteMap) {
-        byte[] raw =
-            rw.getObjectReader()
-                .open(note.getData(), OBJ_BLOB)
-                .getCachedBytes(ExternalIdReader.MAX_NOTE_SZ);
-        try {
-          ExternalId extId = ExternalId.parse(note.getName(), raw, note.getData());
-
-          if (needsUpdate(extId)) {
-            ExternalIdsUpdate.upsert(rw, ins, noteMap, extId);
-            dirty = true;
-          }
-        } catch (ConfigInvalidException e) {
-          ui.message(
-              String.format("Warning: Ignoring invalid external ID note %s", note.getName()));
-        }
-      }
-      if (dirty) {
-        ExternalIdsUpdate.commit(
-            allUsersName,
-            repo,
-            rw,
-            ins,
-            rev,
-            noteMap,
-            COMMIT_MSG,
-            serverUser,
-            serverUser,
-            null,
-            GitReferenceUpdated.DISABLED);
-      }
-    } catch (IOException e) {
-      throw new OrmException("Failed to update external IDs", e);
-    }
-  }
-
-  private static boolean needsUpdate(ExternalId extId) {
-    Config cfg = new Config();
-    cfg.setInt("externalId", extId.key().get(), "accountId", extId.accountId().get());
-    return Ints.tryParse(cfg.getString("externalId", extId.key().get(), "accountId")) == null;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_151.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_151.java
deleted file mode 100644
index 2015c14..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_151.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.common.collect.Streams;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
-import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit.Key;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.Timestamp;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Optional;
-
-/** A schema which adds the 'created on' field to groups. */
-public class Schema_151 extends SchemaVersion {
-  @Inject
-  protected Schema_151(Provider<Schema_150> prior) {
-    super(prior);
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    List<AccountGroup> accountGroups = db.accountGroups().all().toList();
-    for (AccountGroup accountGroup : accountGroups) {
-      ResultSet<AccountGroupMemberAudit> groupMemberAudits =
-          db.accountGroupMembersAudit().byGroup(accountGroup.getId());
-      Optional<Timestamp> firstTimeMentioned =
-          Streams.stream(groupMemberAudits)
-              .map(AccountGroupMemberAudit::getKey)
-              .map(Key::getAddedOn)
-              .min(Comparator.naturalOrder());
-      Timestamp createdOn =
-          firstTimeMentioned.orElseGet(() -> AccountGroup.auditCreationInstantTs());
-
-      accountGroup.setCreatedOn(createdOn);
-    }
-    db.accountGroups().update(accountGroups);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_154.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_154.java
deleted file mode 100644
index 596999d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_154.java
+++ /dev/null
@@ -1,152 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static java.util.stream.Collectors.toMap;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountConfig;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.sql.Connection;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.TextProgressMonitor;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Migrate accounts to NoteDb. */
-public class Schema_154 extends SchemaVersion {
-  private static final Logger log = LoggerFactory.getLogger(Schema_154.class);
-  private static final String TABLE = "accounts";
-  private static final Map<String, AccountSetter> ACCOUNT_FIELDS_MAP =
-      ImmutableMap.<String, AccountSetter>builder()
-          .put("full_name", (a, rs, field) -> a.setFullName(rs.getString(field)))
-          .put("preferred_email", (a, rs, field) -> a.setPreferredEmail(rs.getString(field)))
-          .put("status", (a, rs, field) -> a.setStatus(rs.getString(field)))
-          .put("inactive", (a, rs, field) -> a.setActive(rs.getString(field).equals("N")))
-          .build();
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final Provider<PersonIdent> serverIdent;
-
-  @Inject
-  Schema_154(
-      Provider<Schema_153> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
-    super(prior);
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.serverIdent = serverIdent;
-  }
-
-  @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try {
-      try (Repository repo = repoManager.openRepository(allUsersName)) {
-        ProgressMonitor pm = new TextProgressMonitor();
-        pm.beginTask("Collecting accounts", ProgressMonitor.UNKNOWN);
-        Set<Account> accounts = scanAccounts(db, pm);
-        pm.endTask();
-        pm.beginTask("Migrating accounts to NoteDb", accounts.size());
-        for (Account account : accounts) {
-          updateAccountInNoteDb(repo, account);
-          pm.update(1);
-        }
-        pm.endTask();
-      }
-    } catch (IOException | ConfigInvalidException e) {
-      throw new OrmException("Migrating accounts to NoteDb failed", e);
-    }
-  }
-
-  private Set<Account> scanAccounts(ReviewDb db, ProgressMonitor pm) throws SQLException {
-    Map<String, AccountSetter> fields = getFields(db);
-    if (fields.isEmpty()) {
-      log.warn("Only account_id and registered_on fields are migrated for accounts");
-    }
-
-    List<String> queryFields = new ArrayList<>();
-    queryFields.add("account_id");
-    queryFields.add("registered_on");
-    queryFields.addAll(fields.keySet());
-    String query = "SELECT " + String.join(", ", queryFields) + String.format(" FROM %s", TABLE);
-    try (Statement stmt = newStatement(db);
-        ResultSet rs = stmt.executeQuery(query)) {
-      Set<Account> s = new HashSet<>();
-      while (rs.next()) {
-        Account a = new Account(new Account.Id(rs.getInt(1)), rs.getTimestamp(2));
-        for (Map.Entry<String, AccountSetter> field : fields.entrySet()) {
-          field.getValue().set(a, rs, field.getKey());
-        }
-        s.add(a);
-        pm.update(1);
-      }
-      return s;
-    }
-  }
-
-  private Map<String, AccountSetter> getFields(ReviewDb db) throws SQLException {
-    JdbcSchema schema = (JdbcSchema) db;
-    Connection connection = schema.getConnection();
-    Set<String> columns = schema.getDialect().listColumns(connection, TABLE);
-    return ACCOUNT_FIELDS_MAP
-        .entrySet()
-        .stream()
-        .filter(e -> columns.contains(e.getKey()))
-        .collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
-  }
-
-  private void updateAccountInNoteDb(Repository allUsersRepo, Account account)
-      throws IOException, ConfigInvalidException {
-    MetaDataUpdate md =
-        new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo);
-    PersonIdent ident = serverIdent.get();
-    md.getCommitBuilder().setAuthor(ident);
-    md.getCommitBuilder().setCommitter(ident);
-    AccountConfig accountConfig = new AccountConfig(null, account.getId());
-    accountConfig.load(allUsersRepo);
-    accountConfig.setAccount(account);
-    accountConfig.commit(md);
-  }
-
-  @FunctionalInterface
-  private interface AccountSetter {
-    void set(Account a, ResultSet rs, String field) throws SQLException;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_87.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_87.java
deleted file mode 100644
index 8ab949e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_87.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Set;
-
-public class Schema_87 extends SchemaVersion {
-  @Inject
-  Schema_87(Provider<Schema_86> prior) {
-    super(prior);
-  }
-
-  @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()));
-      }
-    }
-  }
-
-  private 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'")) {
-      Set<AccountGroup.Id> ids = new HashSet<>();
-      while (rs.next()) {
-        ids.add(new AccountGroup.Id(rs.getInt(1)));
-      }
-      return ids;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateReviewDb.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateReviewDb.java
deleted file mode 100644
index 21e1f92..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateReviewDb.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ChangeAccess;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gwtorm.server.AtomicUpdate;
-
-public class BatchUpdateReviewDb extends ReviewDbWrapper {
-  private final ChangeAccess changesWrapper;
-
-  BatchUpdateReviewDb(ReviewDb delegate) {
-    super(delegate);
-    changesWrapper = new BatchUpdateChanges(delegate.changes());
-  }
-
-  public ReviewDb unsafeGetDelegate() {
-    return delegate;
-  }
-
-  @Override
-  public ChangeAccess changes() {
-    return changesWrapper;
-  }
-
-  @Override
-  public void commit() {
-    throw new UnsupportedOperationException(
-        "do not call commit; BatchUpdate always manages transactions");
-  }
-
-  @Override
-  public void rollback() {
-    throw new UnsupportedOperationException(
-        "do not call rollback; BatchUpdate always manages transactions");
-  }
-
-  private static class BatchUpdateChanges extends ChangeAccessWrapper {
-    private BatchUpdateChanges(ChangeAccess delegate) {
-      super(delegate);
-    }
-
-    @Override
-    public void insert(Iterable<Change> instances) {
-      throw new UnsupportedOperationException(
-          "do not call insert; change is automatically inserted");
-    }
-
-    @Override
-    public void upsert(Iterable<Change> instances) {
-      throw new UnsupportedOperationException(
-          "do not call upsert; existing changes are updated automatically,"
-              + " or use InsertChangeOp for insertion");
-    }
-
-    @Override
-    public void update(Iterable<Change> instances) {
-      throw new UnsupportedOperationException(
-          "do not call update; change is updated automatically");
-    }
-
-    @Override
-    public void beginTransaction(Change.Id key) {
-      throw new UnsupportedOperationException("updateChange is always called within a transaction");
-    }
-
-    @Override
-    public void deleteKeys(Iterable<Change.Id> keys) {
-      throw new UnsupportedOperationException(
-          "do not call deleteKeys; use ChangeContext#deleteChange()");
-    }
-
-    @Override
-    public void delete(Iterable<Change> instances) {
-      throw new UnsupportedOperationException(
-          "do not call delete; use ChangeContext#deleteChange()");
-    }
-
-    @Override
-    public Change atomicUpdate(Change.Id key, AtomicUpdate<Change> update) {
-      throw new UnsupportedOperationException(
-          "do not call atomicUpdate; updateChange is always called within a transaction");
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
deleted file mode 100644
index ceef352..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
+++ /dev/null
@@ -1,457 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static java.util.Comparator.comparing;
-
-import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.util.RequestId;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.TimeZone;
-import java.util.TreeMap;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-/**
- * {@link BatchUpdate} implementation using only NoteDb that updates code refs and meta refs in a
- * single {@link org.eclipse.jgit.lib.BatchRefUpdate}.
- *
- * <p>Used when {@code noteDb.changes.disableReviewDb=true}, at which point ReviewDb is not
- * consulted during updates.
- */
-class NoteDbBatchUpdate extends BatchUpdate {
-  interface AssistedFactory {
-    NoteDbBatchUpdate create(
-        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when);
-  }
-
-  static void execute(
-      ImmutableList<NoteDbBatchUpdate> updates,
-      BatchUpdateListener listener,
-      @Nullable RequestId requestId,
-      boolean dryrun)
-      throws UpdateException, RestApiException {
-    if (updates.isEmpty()) {
-      return;
-    }
-    setRequestIds(updates, requestId);
-
-    try {
-      @SuppressWarnings("deprecation")
-      List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
-          new ArrayList<>();
-      List<ChangesHandle> handles = new ArrayList<>(updates.size());
-      Order order = getOrder(updates, listener);
-      try {
-        switch (order) {
-          case REPO_BEFORE_DB:
-            for (NoteDbBatchUpdate u : updates) {
-              u.executeUpdateRepo();
-            }
-            listener.afterUpdateRepos();
-            for (NoteDbBatchUpdate u : updates) {
-              handles.add(u.executeChangeOps(dryrun));
-            }
-            for (ChangesHandle h : handles) {
-              h.execute();
-              indexFutures.addAll(h.startIndexFutures());
-            }
-            listener.afterUpdateRefs();
-            listener.afterUpdateChanges();
-            break;
-
-          case DB_BEFORE_REPO:
-            // Call updateChange for each op before updateRepo, but defer executing the
-            // NoteDbUpdateManager until after calling updateRepo. They share an inserter and
-            // BatchRefUpdate, so it will all execute as a single batch. But we have to let
-            // NoteDbUpdateManager actually execute the update, since it has to interleave it
-            // properly with All-Users updates.
-            //
-            // TODO(dborowitz): This may still result in multiple updates to All-Users, but that's
-            // currently not a big deal because multi-change batches generally aren't affecting
-            // drafts anyway.
-            for (NoteDbBatchUpdate u : updates) {
-              handles.add(u.executeChangeOps(dryrun));
-            }
-            for (NoteDbBatchUpdate u : updates) {
-              u.executeUpdateRepo();
-            }
-            for (ChangesHandle h : handles) {
-              // TODO(dborowitz): This isn't quite good enough: in theory updateRepo may want to
-              // see the results of change meta commands, but they aren't actually added to the
-              // BatchUpdate until the body of execute. To fix this, execute needs to be split up
-              // into a method that returns a BatchRefUpdate before execution. Not a big deal at the
-              // moment, because this order is only used for deleting changes, and those updateRepo
-              // implementations definitely don't need to observe the updated change meta refs.
-              h.execute();
-              indexFutures.addAll(h.startIndexFutures());
-            }
-            break;
-          default:
-            throw new IllegalStateException("invalid execution order: " + order);
-        }
-      } finally {
-        for (ChangesHandle h : handles) {
-          h.close();
-        }
-      }
-
-      ChangeIndexer.allAsList(indexFutures).get();
-
-      // Fire ref update events only after all mutations are finished, since callers may assume a
-      // patch set ref being created means the change was created, or a branch advancing meaning
-      // some changes were closed.
-      updates
-          .stream()
-          .filter(u -> u.batchRefUpdate != null)
-          .forEach(
-              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
-
-      if (!dryrun) {
-        for (NoteDbBatchUpdate u : updates) {
-          u.executePostOps();
-        }
-      }
-    } catch (Exception e) {
-      wrapAndThrowException(e);
-    }
-  }
-
-  class ContextImpl implements Context {
-    @Override
-    public RepoView getRepoView() throws IOException {
-      return NoteDbBatchUpdate.this.getRepoView();
-    }
-
-    @Override
-    public RevWalk getRevWalk() throws IOException {
-      return getRepoView().getRevWalk();
-    }
-
-    @Override
-    public Project.NameKey getProject() {
-      return project;
-    }
-
-    @Override
-    public Timestamp getWhen() {
-      return when;
-    }
-
-    @Override
-    public TimeZone getTimeZone() {
-      return tz;
-    }
-
-    @Override
-    public ReviewDb getDb() {
-      return db;
-    }
-
-    @Override
-    public CurrentUser getUser() {
-      return user;
-    }
-
-    @Override
-    public Order getOrder() {
-      return order;
-    }
-  }
-
-  private class RepoContextImpl extends ContextImpl implements RepoContext {
-    @Override
-    public ObjectInserter getInserter() throws IOException {
-      return getRepoView().getInserterWrapper();
-    }
-
-    @Override
-    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
-      getRepoView().getCommands().add(cmd);
-    }
-  }
-
-  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
-    private final ChangeNotes notes;
-    private final Map<PatchSet.Id, ChangeUpdate> updates;
-
-    private boolean deleted;
-
-    protected ChangeContextImpl(ChangeNotes notes) {
-      this.notes = checkNotNull(notes);
-      updates = new TreeMap<>(comparing(PatchSet.Id::get));
-    }
-
-    @Override
-    public ChangeUpdate getUpdate(PatchSet.Id psId) {
-      ChangeUpdate u = updates.get(psId);
-      if (u == null) {
-        u = changeUpdateFactory.create(notes, user, when);
-        if (newChanges.containsKey(notes.getChangeId())) {
-          u.setAllowWriteToNewRef(true);
-        }
-        u.setPatchSetId(psId);
-        updates.put(psId, u);
-      }
-      return u;
-    }
-
-    @Override
-    public ChangeNotes getNotes() {
-      return notes;
-    }
-
-    @Override
-    public void dontBumpLastUpdatedOn() {
-      // Do nothing; NoteDb effectively updates timestamp if and only if a commit was written to the
-      // change meta ref.
-    }
-
-    @Override
-    public void deleteChange() {
-      deleted = true;
-    }
-  }
-
-  /** Per-change result status from {@link #executeChangeOps}. */
-  private enum ChangeResult {
-    SKIPPED,
-    UPSERTED,
-    DELETED;
-  }
-
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeUpdate.Factory changeUpdateFactory;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
-  private final ChangeIndexer indexer;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final ReviewDb db;
-
-  @Inject
-  NoteDbBatchUpdate(
-      GitRepositoryManager repoManager,
-      @GerritPersonIdent PersonIdent serverIdent,
-      ChangeNotes.Factory changeNotesFactory,
-      ChangeUpdate.Factory changeUpdateFactory,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      ChangeIndexer indexer,
-      GitReferenceUpdated gitRefUpdated,
-      @Assisted ReviewDb db,
-      @Assisted Project.NameKey project,
-      @Assisted CurrentUser user,
-      @Assisted Timestamp when) {
-    super(repoManager, serverIdent, project, user, when);
-    this.changeNotesFactory = changeNotesFactory;
-    this.changeUpdateFactory = changeUpdateFactory;
-    this.updateManagerFactory = updateManagerFactory;
-    this.indexer = indexer;
-    this.gitRefUpdated = gitRefUpdated;
-    this.db = db;
-  }
-
-  @Override
-  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), listener, requestId, false);
-  }
-
-  @Override
-  protected Context newContext() {
-    return new ContextImpl();
-  }
-
-  private void executeUpdateRepo() throws UpdateException, RestApiException {
-    try {
-      logDebug("Executing updateRepo on {} ops", ops.size());
-      RepoContextImpl ctx = new RepoContextImpl();
-      for (BatchUpdateOp op : ops.values()) {
-        op.updateRepo(ctx);
-      }
-
-      logDebug("Executing updateRepo on {} RepoOnlyOps", repoOnlyOps.size());
-      for (RepoOnlyOp op : repoOnlyOps) {
-        op.updateRepo(ctx);
-      }
-
-      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
-        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
-        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
-        // first update's executeRefUpdates has finished, hence after first repo's refs have been
-        // updated, which is too late.
-        onSubmitValidators.validate(
-            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
-      }
-    } catch (Exception e) {
-      Throwables.throwIfInstanceOf(e, RestApiException.class);
-      throw new UpdateException(e);
-    }
-  }
-
-  private class ChangesHandle implements AutoCloseable {
-    private final NoteDbUpdateManager manager;
-    private final boolean dryrun;
-    private final Map<Change.Id, ChangeResult> results;
-
-    ChangesHandle(NoteDbUpdateManager manager, boolean dryrun) {
-      this.manager = manager;
-      this.dryrun = dryrun;
-      results = new HashMap<>();
-    }
-
-    @Override
-    public void close() {
-      manager.close();
-    }
-
-    void setResult(Change.Id id, ChangeResult result) {
-      ChangeResult old = results.putIfAbsent(id, result);
-      checkArgument(old == null, "result for change %s already set: %s", id, old);
-    }
-
-    void execute() throws OrmException, IOException {
-      NoteDbBatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
-    }
-
-    @SuppressWarnings("deprecation")
-    List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> startIndexFutures() {
-      if (dryrun) {
-        return ImmutableList.of();
-      }
-      logDebug("Reindexing {} changes", results.size());
-      List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
-          new ArrayList<>(results.size());
-      for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
-        Change.Id id = e.getKey();
-        switch (e.getValue()) {
-          case UPSERTED:
-            indexFutures.add(indexer.indexAsync(project, id));
-            break;
-          case DELETED:
-            indexFutures.add(indexer.deleteAsync(id));
-            break;
-          case SKIPPED:
-            break;
-          default:
-            throw new IllegalStateException("unexpected result: " + e.getValue());
-        }
-      }
-      return indexFutures;
-    }
-  }
-
-  private ChangesHandle executeChangeOps(boolean dryrun) throws Exception {
-    logDebug("Executing change ops");
-    initRepository();
-    Repository repo = repoView.getRepository();
-    checkState(
-        repo.getRefDatabase().performsAtomicTransactions(),
-        "cannot use NoteDb with a repository that does not support atomic batch ref updates: %s",
-        repo);
-
-    ChangesHandle handle =
-        new ChangesHandle(
-            updateManagerFactory
-                .create(project)
-                .setChangeRepo(
-                    repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
-            dryrun);
-    if (user.isIdentifiedUser()) {
-      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
-    }
-    handle.manager.setRefLogMessage(refLogMessage);
-    handle.manager.setPushCertificate(pushCert);
-    for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
-      Change.Id id = e.getKey();
-      ChangeContextImpl ctx = newChangeContext(id);
-      boolean dirty = false;
-      logDebug("Applying {} ops for change {}", e.getValue().size(), id);
-      for (BatchUpdateOp op : e.getValue()) {
-        dirty |= op.updateChange(ctx);
-      }
-      if (!dirty) {
-        logDebug("No ops reported dirty, short-circuiting");
-        handle.setResult(id, ChangeResult.SKIPPED);
-        continue;
-      }
-      for (ChangeUpdate u : ctx.updates.values()) {
-        handle.manager.add(u);
-      }
-      if (ctx.deleted) {
-        logDebug("Change {} was deleted", id);
-        handle.manager.deleteChange(id);
-        handle.setResult(id, ChangeResult.DELETED);
-      } else {
-        handle.setResult(id, ChangeResult.UPSERTED);
-      }
-    }
-    return handle;
-  }
-
-  private ChangeContextImpl newChangeContext(Change.Id id) throws OrmException {
-    logDebug("Opening change {} for update", id);
-    Change c = newChanges.get(id);
-    boolean isNew = c != null;
-    if (!isNew) {
-      // Pass a synthetic change into ChangeNotes.Factory, which will take care of checking for
-      // existence and populating columns from the parsed notes state.
-      // TODO(dborowitz): This dance made more sense when using Reviewdb; consider a nicer way.
-      c = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
-    } else {
-      logDebug("Change {} is new", id);
-    }
-    ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
-    return new ChangeContextImpl(notes);
-  }
-
-  private void executePostOps() throws Exception {
-    ContextImpl ctx = new ContextImpl();
-    for (BatchUpdateOp op : ops.values()) {
-      op.postUpdate(ctx);
-    }
-
-    for (RepoOnlyOp op : repoOnlyOps) {
-      op.postUpdate(ctx);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RefUpdateUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RefUpdateUtil.java
deleted file mode 100644
index 86b4eef..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/RefUpdateUtil.java
+++ /dev/null
@@ -1,84 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.server.git.LockFailureException;
-import java.io.IOException;
-import org.eclipse.jgit.internal.JGitText;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-/** Static utilities for working with JGit's ref update APIs. */
-public class RefUpdateUtil {
-  /**
-   * Execute a batch ref update, throwing a checked exception if not all updates succeeded.
-   *
-   * @param bru batch update; should already have been executed.
-   * @throws LockFailureException if the transaction was aborted due to lock failure; see {@link
-   *     #checkResults(BatchRefUpdate)} for details.
-   * @throws IOException if any result was not {@code OK}.
-   */
-  public static void executeChecked(BatchRefUpdate bru, RevWalk rw) throws IOException {
-    bru.execute(rw, NullProgressMonitor.INSTANCE);
-    checkResults(bru);
-  }
-
-  /**
-   * Check results of all commands in the update batch, reducing to a single exception if there was
-   * a failure.
-   *
-   * <p>Throws {@link LockFailureException} if at least one command failed with {@code
-   * LOCK_FAILURE}, and the entire transaction was aborted, i.e. any non-{@code LOCK_FAILURE}
-   * results, if there were any, failed with "transaction aborted".
-   *
-   * <p>In particular, if the underlying ref database does not {@link
-   * org.eclipse.jgit.lib.RefDatabase#performsAtomicTransactions() perform atomic transactions},
-   * then a combination of {@code LOCK_FAILURE} on one ref and {@code OK} or another result on other
-   * refs will <em>not</em> throw {@code LockFailureException}.
-   *
-   * @param bru batch update; should already have been executed.
-   * @throws LockFailureException if the transaction was aborted due to lock failure.
-   * @throws IOException if any result was not {@code OK}.
-   */
-  @VisibleForTesting
-  static void checkResults(BatchRefUpdate bru) throws IOException {
-    int lockFailure = 0;
-    int aborted = 0;
-    int failure = 0;
-
-    for (ReceiveCommand cmd : bru.getCommands()) {
-      if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        failure++;
-      }
-      if (cmd.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
-        lockFailure++;
-      } else if (cmd.getResult() == ReceiveCommand.Result.REJECTED_OTHER_REASON
-          && JGitText.get().transactionAborted.equals(cmd.getMessage())) {
-        aborted++;
-      }
-    }
-
-    if (lockFailure + aborted == bru.getCommands().size()) {
-      throw new LockFailureException("Update aborted with one or more lock failures: " + bru, bru);
-    } else if (failure > 0) {
-      throw new IOException("Update failed: " + bru);
-    }
-  }
-
-  private RefUpdateUtil() {}
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryHelper.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryHelper.java
deleted file mode 100644
index 4cbaffd..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryHelper.java
+++ /dev/null
@@ -1,214 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.github.rholder.retry.Attempt;
-import com.github.rholder.retry.RetryException;
-import com.github.rholder.retry.RetryListener;
-import com.github.rholder.retry.RetryerBuilder;
-import com.github.rholder.retry.StopStrategies;
-import com.github.rholder.retry.WaitStrategies;
-import com.github.rholder.retry.WaitStrategy;
-import com.google.auto.value.AutoValue;
-import com.google.common.base.Throwables;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.metrics.Counter0;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Histogram0;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.LockFailureException;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.time.Duration;
-import java.util.concurrent.ExecutionException;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class RetryHelper {
-  public interface Action<T> {
-    T call(BatchUpdate.Factory updateFactory) throws Exception;
-  }
-
-  /**
-   * Options for retrying a single operation.
-   *
-   * <p>This class is similar in function to upstream's {@link RetryerBuilder}, but it exists as its
-   * own class in Gerrit for several reasons:
-   *
-   * <ul>
-   *   <li>Gerrit needs to support defaults for some of the options, such as a default timeout.
-   *       {@code RetryerBuilder} doesn't support calling the same setter multiple times, so doing
-   *       this with {@code RetryerBuilder} directly would not be easy.
-   *   <li>Gerrit explicitly does not want callers to have full control over all possible options,
-   *       so this class exposes a curated subset.
-   * </ul>
-   */
-  @AutoValue
-  public abstract static class Options {
-    @Nullable
-    abstract RetryListener listener();
-
-    @Nullable
-    abstract Duration timeout();
-
-    @AutoValue.Builder
-    public abstract static class Builder {
-      public abstract Builder listener(RetryListener listener);
-
-      public abstract Builder timeout(Duration timeout);
-
-      public abstract Options build();
-    }
-  }
-
-  @Singleton
-  private static class Metrics {
-    final Histogram0 attemptCounts;
-    final Counter0 timeoutCount;
-
-    @Inject
-    Metrics(MetricMaker metricMaker) {
-      attemptCounts =
-          metricMaker.newHistogram(
-              "batch_update/retry_attempt_counts",
-              new Description(
-                      "Distribution of number of attempts made by RetryHelper"
-                          + " (1 == single attempt, no retry)")
-                  .setCumulative()
-                  .setUnit("attempts"));
-      timeoutCount =
-          metricMaker.newCounter(
-              "batch_update/retry_timeout_count",
-              new Description("Number of executions of RetryHelper that ultimately timed out")
-                  .setCumulative()
-                  .setUnit("timeouts"));
-    }
-  }
-
-  public static Options.Builder options() {
-    return new AutoValue_RetryHelper_Options.Builder();
-  }
-
-  public static Options defaults() {
-    return options().build();
-  }
-
-  private final NotesMigration migration;
-  private final Metrics metrics;
-  private final BatchUpdate.Factory updateFactory;
-  private final Duration defaultTimeout;
-  private final WaitStrategy waitStrategy;
-
-  @Inject
-  RetryHelper(
-      @GerritServerConfig Config cfg,
-      Metrics metrics,
-      NotesMigration migration,
-      ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory,
-      NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory) {
-    this.metrics = metrics;
-    this.migration = migration;
-    this.updateFactory =
-        new BatchUpdate.Factory(migration, reviewDbBatchUpdateFactory, noteDbBatchUpdateFactory);
-    this.defaultTimeout =
-        Duration.ofMillis(
-            cfg.getTimeUnit("noteDb", null, "retryTimeout", SECONDS.toMillis(20), MILLISECONDS));
-    this.waitStrategy =
-        WaitStrategies.join(
-            WaitStrategies.exponentialWait(
-                cfg.getTimeUnit("noteDb", null, "retryMaxWait", SECONDS.toMillis(5), MILLISECONDS),
-                MILLISECONDS),
-            WaitStrategies.randomWait(50, MILLISECONDS));
-  }
-
-  public Duration getDefaultTimeout() {
-    return defaultTimeout;
-  }
-
-  public <T> T execute(Action<T> action) throws RestApiException, UpdateException {
-    return execute(action, defaults());
-  }
-
-  public <T> T execute(Action<T> action, Options opts) throws RestApiException, UpdateException {
-    MetricListener listener = null;
-    try {
-      RetryerBuilder<T> builder = RetryerBuilder.newBuilder();
-      if (migration.disableChangeReviewDb()) {
-        listener = new MetricListener(opts.listener());
-        builder
-            .withRetryListener(listener)
-            .withStopStrategy(
-                StopStrategies.stopAfterDelay(
-                    firstNonNull(opts.timeout(), defaultTimeout).toMillis(), MILLISECONDS))
-            .withWaitStrategy(waitStrategy)
-            .retryIfException(RetryHelper::isLockFailure);
-      } else {
-        // Either we aren't full-NoteDb, or the underlying ref storage doesn't support atomic
-        // transactions. Either way, retrying a partially-failed operation is not idempotent, so
-        // don't do it automatically. Let the end user decide whether they want to retry.
-      }
-      return builder.build().call(() -> action.call(updateFactory));
-    } catch (ExecutionException | RetryException e) {
-      if (e instanceof RetryException) {
-        metrics.timeoutCount.increment();
-      }
-      if (e.getCause() != null) {
-        Throwables.throwIfInstanceOf(e.getCause(), UpdateException.class);
-        Throwables.throwIfInstanceOf(e.getCause(), RestApiException.class);
-      }
-      throw new UpdateException(e);
-    } finally {
-      if (listener != null) {
-        metrics.attemptCounts.record(listener.getAttemptCount());
-      }
-    }
-  }
-
-  private static boolean isLockFailure(Throwable t) {
-    if (t instanceof UpdateException) {
-      t = t.getCause();
-    }
-    return t instanceof LockFailureException;
-  }
-
-  private static class MetricListener implements RetryListener {
-    private final RetryListener delegate;
-    private long attemptCount;
-
-    MetricListener(@Nullable RetryListener delegate) {
-      this.delegate = delegate;
-      attemptCount = 1;
-    }
-
-    @Override
-    public <V> void onRetry(Attempt<V> attempt) {
-      attemptCount = attempt.getAttemptNumber();
-      if (delegate != null) {
-        delegate.onRetry(attempt);
-      }
-    }
-
-    long getAttemptCount() {
-      return attemptCount;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
deleted file mode 100644
index 6835cb4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
+++ /dev/null
@@ -1,859 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static java.util.Comparator.comparing;
-import static java.util.concurrent.TimeUnit.NANOSECONDS;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Stopwatch;
-import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableList;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Description.Units;
-import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.Timer1;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.InsertedObject;
-import com.google.gerrit.server.git.LockFailureException;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager.MismatchedStateException;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.util.RequestId;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.TimeZone;
-import java.util.TreeMap;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * {@link BatchUpdate} implementation that supports mixed ReviewDb/NoteDb operations, depending on
- * the migration state specified in {@link NotesMigration}.
- *
- * <p>When performing change updates in a mixed ReviewDb/NoteDb environment with ReviewDb primary,
- * the order of operations is very subtle:
- *
- * <ol>
- *   <li>Stage NoteDb updates to get the new NoteDb state, but do not write to the repo.
- *   <li>Write the new state in the Change entity, and commit this to ReviewDb.
- *   <li>Update NoteDb, ignoring any write failures.
- * </ol>
- *
- * The implementation in this class is well-tested, and it is strongly recommended that you not
- * attempt to reimplement this logic. Use {@code BatchUpdate} if at all possible.
- */
-class ReviewDbBatchUpdate extends BatchUpdate {
-  private static final Logger log = LoggerFactory.getLogger(ReviewDbBatchUpdate.class);
-
-  interface AssistedFactory {
-    ReviewDbBatchUpdate create(
-        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when);
-  }
-
-  class ContextImpl implements Context {
-    @Override
-    public RepoView getRepoView() throws IOException {
-      return ReviewDbBatchUpdate.this.getRepoView();
-    }
-
-    @Override
-    public RevWalk getRevWalk() throws IOException {
-      return getRepoView().getRevWalk();
-    }
-
-    @Override
-    public Project.NameKey getProject() {
-      return project;
-    }
-
-    @Override
-    public Timestamp getWhen() {
-      return when;
-    }
-
-    @Override
-    public TimeZone getTimeZone() {
-      return tz;
-    }
-
-    @Override
-    public ReviewDb getDb() {
-      return db;
-    }
-
-    @Override
-    public CurrentUser getUser() {
-      return user;
-    }
-
-    @Override
-    public Order getOrder() {
-      return order;
-    }
-  }
-
-  private class RepoContextImpl extends ContextImpl implements RepoContext {
-    @Override
-    public ObjectInserter getInserter() throws IOException {
-      return getRepoView().getInserterWrapper();
-    }
-
-    @Override
-    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
-      initRepository();
-      repoView.getCommands().add(cmd);
-    }
-  }
-
-  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
-    private final ChangeNotes notes;
-    private final Map<PatchSet.Id, ChangeUpdate> updates;
-    private final ReviewDbWrapper dbWrapper;
-    private final Repository threadLocalRepo;
-    private final RevWalk threadLocalRevWalk;
-
-    private boolean deleted;
-    private boolean bumpLastUpdatedOn = true;
-
-    protected ChangeContextImpl(
-        ChangeNotes notes, ReviewDbWrapper dbWrapper, Repository repo, RevWalk rw) {
-      this.notes = checkNotNull(notes);
-      this.dbWrapper = dbWrapper;
-      this.threadLocalRepo = repo;
-      this.threadLocalRevWalk = rw;
-      updates = new TreeMap<>(comparing(PatchSet.Id::get));
-    }
-
-    @Override
-    public ReviewDb getDb() {
-      checkNotNull(dbWrapper);
-      return dbWrapper;
-    }
-
-    @Override
-    public RevWalk getRevWalk() {
-      return threadLocalRevWalk;
-    }
-
-    @Override
-    public ChangeUpdate getUpdate(PatchSet.Id psId) {
-      ChangeUpdate u = updates.get(psId);
-      if (u == null) {
-        u = changeUpdateFactory.create(notes, user, when);
-        if (newChanges.containsKey(notes.getChangeId())) {
-          u.setAllowWriteToNewRef(true);
-        }
-        u.setPatchSetId(psId);
-        updates.put(psId, u);
-      }
-      return u;
-    }
-
-    @Override
-    public ChangeNotes getNotes() {
-      return notes;
-    }
-
-    @Override
-    public void dontBumpLastUpdatedOn() {
-      bumpLastUpdatedOn = false;
-    }
-
-    @Override
-    public void deleteChange() {
-      deleted = true;
-    }
-  }
-
-  @Singleton
-  private static class Metrics {
-    final Timer1<Boolean> executeChangeOpsLatency;
-
-    @Inject
-    Metrics(MetricMaker metricMaker) {
-      executeChangeOpsLatency =
-          metricMaker.newTimer(
-              "batch_update/execute_change_ops",
-              new Description("BatchUpdate change update latency, excluding reindexing")
-                  .setCumulative()
-                  .setUnit(Units.MILLISECONDS),
-              Field.ofBoolean("success"));
-    }
-  }
-
-  static void execute(
-      ImmutableList<ReviewDbBatchUpdate> updates,
-      BatchUpdateListener listener,
-      @Nullable RequestId requestId,
-      boolean dryrun)
-      throws UpdateException, RestApiException {
-    if (updates.isEmpty()) {
-      return;
-    }
-    setRequestIds(updates, requestId);
-    try {
-      Order order = getOrder(updates, listener);
-      boolean updateChangesInParallel = getUpdateChangesInParallel(updates);
-      switch (order) {
-        case REPO_BEFORE_DB:
-          for (ReviewDbBatchUpdate u : updates) {
-            u.executeUpdateRepo();
-          }
-          listener.afterUpdateRepos();
-          for (ReviewDbBatchUpdate u : updates) {
-            u.executeRefUpdates(dryrun);
-          }
-          listener.afterUpdateRefs();
-          for (ReviewDbBatchUpdate u : updates) {
-            u.reindexChanges(u.executeChangeOps(updateChangesInParallel, dryrun));
-          }
-          listener.afterUpdateChanges();
-          break;
-        case DB_BEFORE_REPO:
-          for (ReviewDbBatchUpdate u : updates) {
-            u.reindexChanges(u.executeChangeOps(updateChangesInParallel, dryrun));
-          }
-          for (ReviewDbBatchUpdate u : updates) {
-            u.executeUpdateRepo();
-          }
-          for (ReviewDbBatchUpdate u : updates) {
-            u.executeRefUpdates(dryrun);
-          }
-          break;
-        default:
-          throw new IllegalStateException("invalid execution order: " + order);
-      }
-
-      ChangeIndexer.allAsList(
-              updates.stream().flatMap(u -> u.indexFutures.stream()).collect(toList()))
-          .get();
-
-      // Fire ref update events only after all mutations are finished, since callers may assume a
-      // patch set ref being created means the change was created, or a branch advancing meaning
-      // some changes were closed.
-      updates
-          .stream()
-          .filter(u -> u.batchRefUpdate != null)
-          .forEach(
-              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
-
-      if (!dryrun) {
-        for (ReviewDbBatchUpdate u : updates) {
-          u.executePostOps();
-        }
-      }
-    } catch (Exception e) {
-      wrapAndThrowException(e);
-    }
-  }
-
-  private final AllUsersName allUsers;
-  private final ChangeIndexer indexer;
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeUpdate.Factory changeUpdateFactory;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final ListeningExecutorService changeUpdateExector;
-  private final Metrics metrics;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
-  private final NotesMigration notesMigration;
-  private final ReviewDb db;
-  private final SchemaFactory<ReviewDb> schemaFactory;
-  private final long skewMs;
-
-  @SuppressWarnings("deprecation")
-  private final List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
-      new ArrayList<>();
-
-  @Inject
-  ReviewDbBatchUpdate(
-      @GerritServerConfig Config cfg,
-      AllUsersName allUsers,
-      ChangeIndexer indexer,
-      ChangeNotes.Factory changeNotesFactory,
-      @ChangeUpdateExecutor ListeningExecutorService changeUpdateExector,
-      ChangeUpdate.Factory changeUpdateFactory,
-      @GerritPersonIdent PersonIdent serverIdent,
-      GitReferenceUpdated gitRefUpdated,
-      GitRepositoryManager repoManager,
-      Metrics metrics,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      NotesMigration notesMigration,
-      SchemaFactory<ReviewDb> schemaFactory,
-      @Assisted ReviewDb db,
-      @Assisted Project.NameKey project,
-      @Assisted CurrentUser user,
-      @Assisted Timestamp when) {
-    super(repoManager, serverIdent, project, user, when);
-    this.allUsers = allUsers;
-    this.changeNotesFactory = changeNotesFactory;
-    this.changeUpdateExector = changeUpdateExector;
-    this.changeUpdateFactory = changeUpdateFactory;
-    this.gitRefUpdated = gitRefUpdated;
-    this.indexer = indexer;
-    this.metrics = metrics;
-    this.notesMigration = notesMigration;
-    this.schemaFactory = schemaFactory;
-    this.updateManagerFactory = updateManagerFactory;
-    this.db = db;
-    skewMs = NoteDbChangeState.getReadOnlySkew(cfg);
-  }
-
-  @Override
-  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), listener, requestId, false);
-  }
-
-  @Override
-  protected Context newContext() {
-    return new ContextImpl();
-  }
-
-  private void executeUpdateRepo() throws UpdateException, RestApiException {
-    try {
-      logDebug("Executing updateRepo on {} ops", ops.size());
-      RepoContextImpl ctx = new RepoContextImpl();
-      for (BatchUpdateOp op : ops.values()) {
-        op.updateRepo(ctx);
-      }
-
-      logDebug("Executing updateRepo on {} RepoOnlyOps", repoOnlyOps.size());
-      for (RepoOnlyOp op : repoOnlyOps) {
-        op.updateRepo(ctx);
-      }
-
-      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
-        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
-        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
-        // first update's executeRefUpdates has finished, hence after first repo's refs have been
-        // updated, which is too late.
-        onSubmitValidators.validate(
-            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
-      }
-
-      if (repoView != null) {
-        logDebug("Flushing inserter");
-        repoView.getInserter().flush();
-      } else {
-        logDebug("No objects to flush");
-      }
-    } catch (Exception e) {
-      Throwables.throwIfInstanceOf(e, RestApiException.class);
-      throw new UpdateException(e);
-    }
-  }
-
-  private void executeRefUpdates(boolean dryrun) throws IOException, RestApiException {
-    if (getRefUpdates().isEmpty()) {
-      logDebug("No ref updates to execute");
-      return;
-    }
-    // May not be opened if the caller added ref updates but no new objects.
-    // TODO(dborowitz): Really?
-    initRepository();
-    batchRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
-    batchRefUpdate.setPushCertificate(pushCert);
-    batchRefUpdate.setRefLogMessage(refLogMessage, true);
-    batchRefUpdate.setAllowNonFastForwards(true);
-    repoView.getCommands().addTo(batchRefUpdate);
-    if (user.isIdentifiedUser()) {
-      batchRefUpdate.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
-    }
-    logDebug("Executing batch of {} ref updates", batchRefUpdate.getCommands().size());
-    if (dryrun) {
-      return;
-    }
-
-    // Force BatchRefUpdate to read newly referenced objects using a new RevWalk, rather than one
-    // that might have access to unflushed objects.
-    try (RevWalk updateRw = new RevWalk(repoView.getRepository())) {
-      batchRefUpdate.execute(updateRw, NullProgressMonitor.INSTANCE);
-    }
-    boolean ok = true;
-    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
-      if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        ok = false;
-        break;
-      }
-    }
-    if (!ok) {
-      throw new RestApiException("BatchRefUpdate failed: " + batchRefUpdate);
-    }
-  }
-
-  private List<ChangeTask> executeChangeOps(boolean parallel, boolean dryrun)
-      throws UpdateException, RestApiException {
-    List<ChangeTask> tasks;
-    boolean success = false;
-    Stopwatch sw = Stopwatch.createStarted();
-    try {
-      logDebug("Executing change ops (parallel? {})", parallel);
-      ListeningExecutorService executor =
-          parallel ? changeUpdateExector : MoreExecutors.newDirectExecutorService();
-
-      tasks = new ArrayList<>(ops.keySet().size());
-      try {
-        if (notesMigration.commitChangeWrites() && repoView != null) {
-          // A NoteDb change may have been rebuilt since the repo was originally
-          // opened, so make sure we see that.
-          logDebug("Preemptively scanning for repo changes");
-          repoView.getRepository().scanForRepoChanges();
-        }
-        if (!ops.isEmpty() && notesMigration.failChangeWrites()) {
-          // Fail fast before attempting any writes if changes are read-only, as
-          // this is a programmer error.
-          logDebug("Failing early due to read-only Changes table");
-          throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
-        }
-        List<ListenableFuture<?>> futures = new ArrayList<>(ops.keySet().size());
-        for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
-          ChangeTask task =
-              new ChangeTask(e.getKey(), e.getValue(), Thread.currentThread(), dryrun);
-          tasks.add(task);
-          if (!parallel) {
-            logDebug("Direct execution of task for ops: {}", ops);
-          }
-          futures.add(executor.submit(task));
-        }
-        if (parallel) {
-          logDebug(
-              "Waiting on futures for {} ops spanning {} changes", ops.size(), ops.keySet().size());
-        }
-        Futures.allAsList(futures).get();
-
-        if (notesMigration.commitChangeWrites()) {
-          if (!dryrun) {
-            executeNoteDbUpdates(tasks);
-          }
-        }
-        success = true;
-      } catch (ExecutionException | InterruptedException e) {
-        Throwables.throwIfInstanceOf(e.getCause(), UpdateException.class);
-        Throwables.throwIfInstanceOf(e.getCause(), RestApiException.class);
-        throw new UpdateException(e);
-      } catch (OrmException | IOException e) {
-        throw new UpdateException(e);
-      }
-    } finally {
-      metrics.executeChangeOpsLatency.record(success, sw.elapsed(NANOSECONDS), NANOSECONDS);
-    }
-    return tasks;
-  }
-
-  private void reindexChanges(List<ChangeTask> tasks) {
-    // Reindex changes.
-    for (ChangeTask task : tasks) {
-      if (task.deleted) {
-        indexFutures.add(indexer.deleteAsync(task.id));
-      } else if (task.dirty) {
-        indexFutures.add(indexer.indexAsync(project, task.id));
-      }
-    }
-  }
-
-  private void executeNoteDbUpdates(List<ChangeTask> tasks)
-      throws ResourceConflictException, IOException {
-    // Aggregate together all NoteDb ref updates from the ops we executed,
-    // possibly in parallel. Each task had its own NoteDbUpdateManager instance
-    // with its own thread-local copy of the repo(s), but each of those was just
-    // used for staging updates and was never executed.
-    //
-    // Use a new BatchRefUpdate as the original batchRefUpdate field is intended
-    // for use only by the updateRepo phase.
-    //
-    // See the comments in NoteDbUpdateManager#execute() for why we execute the
-    // updates on the change repo first.
-    logDebug("Executing NoteDb updates for {} changes", tasks.size());
-    try {
-      initRepository();
-      BatchRefUpdate changeRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
-      boolean hasAllUsersCommands = false;
-      try (ObjectInserter ins = repoView.getRepository().newObjectInserter()) {
-        int objs = 0;
-        for (ChangeTask task : tasks) {
-          if (task.noteDbResult == null) {
-            logDebug("No-op update to {}", task.id);
-            continue;
-          }
-          for (ReceiveCommand cmd : task.noteDbResult.changeCommands()) {
-            changeRefUpdate.addCommand(cmd);
-          }
-          for (InsertedObject obj : task.noteDbResult.changeObjects()) {
-            objs++;
-            ins.insert(obj.type(), obj.data().toByteArray());
-          }
-          hasAllUsersCommands |= !task.noteDbResult.allUsersCommands().isEmpty();
-        }
-        logDebug(
-            "Collected {} objects and {} ref updates to change repo",
-            objs,
-            changeRefUpdate.getCommands().size());
-        executeNoteDbUpdate(getRevWalk(), ins, changeRefUpdate);
-      }
-
-      if (hasAllUsersCommands) {
-        try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-            RevWalk allUsersRw = new RevWalk(allUsersRepo);
-            ObjectInserter allUsersIns = allUsersRepo.newObjectInserter()) {
-          int objs = 0;
-          BatchRefUpdate allUsersRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
-          for (ChangeTask task : tasks) {
-            for (ReceiveCommand cmd : task.noteDbResult.allUsersCommands()) {
-              allUsersRefUpdate.addCommand(cmd);
-            }
-            for (InsertedObject obj : task.noteDbResult.allUsersObjects()) {
-              allUsersIns.insert(obj.type(), obj.data().toByteArray());
-            }
-          }
-          logDebug(
-              "Collected {} objects and {} ref updates to All-Users",
-              objs,
-              allUsersRefUpdate.getCommands().size());
-          executeNoteDbUpdate(allUsersRw, allUsersIns, allUsersRefUpdate);
-        }
-      } else {
-        logDebug("No All-Users updates");
-      }
-    } catch (IOException e) {
-      if (tasks.stream().allMatch(t -> t.storage == PrimaryStorage.REVIEW_DB)) {
-        // Ignore all errors trying to update NoteDb at this point. We've already written the
-        // NoteDbChangeStates to ReviewDb, which means if any state is out of date it will be
-        // rebuilt the next time it is needed.
-        //
-        // Always log even without RequestId.
-        log.debug("Ignoring NoteDb update error after ReviewDb write", e);
-
-        // Otherwise, we can't prove it's safe to ignore the error, either because some change had
-        // NOTE_DB primary, or a task failed before determining the primary storage.
-      } else if (e instanceof LockFailureException) {
-        // LOCK_FAILURE is a special case indicating there was a conflicting write to a meta ref,
-        // although it happened too late for us to produce anything but a generic error message.
-        throw new ResourceConflictException("Updating change failed due to conflicting write", e);
-      }
-      throw e;
-    }
-  }
-
-  private void executeNoteDbUpdate(RevWalk rw, ObjectInserter ins, BatchRefUpdate bru)
-      throws IOException {
-    if (bru.getCommands().isEmpty()) {
-      logDebug("No commands, skipping flush and ref update");
-      return;
-    }
-    ins.flush();
-    bru.setAllowNonFastForwards(true);
-    bru.execute(rw, NullProgressMonitor.INSTANCE);
-    for (ReceiveCommand cmd : bru.getCommands()) {
-      // TODO(dborowitz): LOCK_FAILURE for NoteDb primary should be retried.
-      if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        throw new IOException("Update failed: " + bru);
-      }
-    }
-  }
-
-  private class ChangeTask implements Callable<Void> {
-    final Change.Id id;
-    private final Collection<BatchUpdateOp> changeOps;
-    private final Thread mainThread;
-    private final boolean dryrun;
-
-    PrimaryStorage storage;
-    NoteDbUpdateManager.StagedResult noteDbResult;
-    boolean dirty;
-    boolean deleted;
-    private String taskId;
-
-    private ChangeTask(
-        Change.Id id, Collection<BatchUpdateOp> changeOps, Thread mainThread, boolean dryrun) {
-      this.id = id;
-      this.changeOps = changeOps;
-      this.mainThread = mainThread;
-      this.dryrun = dryrun;
-    }
-
-    @Override
-    public Void call() throws Exception {
-      taskId = id.toString() + "-" + Thread.currentThread().getId();
-      if (Thread.currentThread() == mainThread) {
-        initRepository();
-        Repository repo = repoView.getRepository();
-        try (RevWalk rw = new RevWalk(repo)) {
-          call(ReviewDbBatchUpdate.this.db, repo, rw);
-        }
-      } else {
-        // Possible optimization: allow Ops to declare whether they need to
-        // access the repo from updateChange, and don't open in this thread
-        // unless we need it. However, as of this writing the only operations
-        // that are executed in parallel are during ReceiveCommits, and they
-        // all need the repo open anyway. (The non-parallel case above does not
-        // reopen the repo.)
-        try (ReviewDb threadLocalDb = schemaFactory.open();
-            Repository repo = repoManager.openRepository(project);
-            RevWalk rw = new RevWalk(repo)) {
-          call(threadLocalDb, repo, rw);
-        }
-      }
-      return null;
-    }
-
-    private void call(ReviewDb db, Repository repo, RevWalk rw) throws Exception {
-      @SuppressWarnings("resource") // Not always opened.
-      NoteDbUpdateManager updateManager = null;
-      try {
-        db.changes().beginTransaction(id);
-        try {
-          ChangeContextImpl ctx = newChangeContext(db, repo, rw, id);
-          NoteDbChangeState oldState = NoteDbChangeState.parse(ctx.getChange());
-          NoteDbChangeState.checkNotReadOnly(oldState, skewMs);
-
-          storage = PrimaryStorage.of(oldState);
-          if (storage == PrimaryStorage.NOTE_DB && !notesMigration.readChanges()) {
-            throw new OrmException("must have NoteDb enabled to update change " + id);
-          }
-
-          // Call updateChange on each op.
-          logDebug("Calling updateChange on {} ops", changeOps.size());
-          for (BatchUpdateOp op : changeOps) {
-            dirty |= op.updateChange(ctx);
-          }
-          if (!dirty) {
-            logDebug("No ops reported dirty, short-circuiting");
-            return;
-          }
-          deleted = ctx.deleted;
-          if (deleted) {
-            logDebug("Change was deleted");
-          }
-
-          // Stage the NoteDb update and store its state in the Change.
-          if (notesMigration.commitChangeWrites()) {
-            updateManager = stageNoteDbUpdate(ctx, deleted);
-          }
-
-          if (storage == PrimaryStorage.REVIEW_DB) {
-            // If primary storage of this change is in ReviewDb, bump
-            // lastUpdatedOn or rowVersion and commit. Otherwise, don't waste
-            // time updating ReviewDb at all.
-            Iterable<Change> cs = changesToUpdate(ctx);
-            if (isNewChange(id)) {
-              // Insert rather than upsert in case of a race on change IDs.
-              logDebug("Inserting change");
-              db.changes().insert(cs);
-            } else if (deleted) {
-              logDebug("Deleting change");
-              db.changes().delete(cs);
-            } else {
-              logDebug("Updating change");
-              db.changes().update(cs);
-            }
-            if (!dryrun) {
-              db.commit();
-            }
-          } else {
-            logDebug("Skipping ReviewDb write since primary storage is {}", storage);
-          }
-        } finally {
-          db.rollback();
-        }
-
-        // Do not execute the NoteDbUpdateManager, as we don't want too much
-        // contention on the underlying repo, and we would rather use a single
-        // ObjectInserter/BatchRefUpdate later.
-        //
-        // TODO(dborowitz): May or may not be worth trying to batch together
-        // flushed inserters as well.
-        if (storage == PrimaryStorage.NOTE_DB) {
-          // Should have failed above if NoteDb is disabled.
-          checkState(notesMigration.commitChangeWrites());
-          noteDbResult = updateManager.stage().get(id);
-        } else if (notesMigration.commitChangeWrites()) {
-          try {
-            noteDbResult = updateManager.stage().get(id);
-          } catch (IOException ex) {
-            // Ignore all errors trying to update NoteDb at this point. We've
-            // already written the NoteDbChangeState to ReviewDb, which means
-            // if the state is out of date it will be rebuilt the next time it
-            // is needed.
-            log.debug("Ignoring NoteDb update error after ReviewDb write", ex);
-          }
-        }
-      } catch (Exception e) {
-        logDebug("Error updating change (should be rethrown)", e);
-        Throwables.propagateIfPossible(e, RestApiException.class);
-        throw new UpdateException(e);
-      } finally {
-        if (updateManager != null) {
-          updateManager.close();
-        }
-      }
-    }
-
-    private ChangeContextImpl newChangeContext(
-        ReviewDb db, Repository repo, RevWalk rw, Change.Id id) throws OrmException {
-      Change c = newChanges.get(id);
-      boolean isNew = c != null;
-      if (isNew) {
-        // New change: populate noteDbState.
-        checkState(c.getNoteDbState() == null, "noteDbState should not be filled in by callers");
-        if (notesMigration.changePrimaryStorage() == PrimaryStorage.NOTE_DB) {
-          c.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-        }
-      } else {
-        // Existing change.
-        c = ChangeNotes.readOneReviewDbChange(db, id);
-        if (c == null) {
-          // Not in ReviewDb, but new changes are created with default primary
-          // storage as NOTE_DB, so we can assume that a missing change is
-          // NoteDb primary. Pass a synthetic change into ChangeNotes.Factory,
-          // which lets ChangeNotes take care of the existence check.
-          //
-          // TODO(dborowitz): This assumption is potentially risky, because
-          // it means once we turn this option on and start creating changes
-          // without writing anything to ReviewDb, we can't turn this option
-          // back off without making those changes inaccessible. The problem
-          // is we have no way of distinguishing a change that only exists in
-          // NoteDb because it only ever existed in NoteDb, from a change that
-          // only exists in NoteDb because it used to exist in ReviewDb and
-          // deleting from ReviewDb succeeded but deleting from NoteDb failed.
-          //
-          // TODO(dborowitz): We actually still have that problem anyway. Maybe
-          // we need a cutoff timestamp? Or maybe we need to start leaving
-          // tombstones in ReviewDb?
-          c = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
-        }
-        NoteDbChangeState.checkNotReadOnly(c, skewMs);
-      }
-      ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
-      return new ChangeContextImpl(notes, new BatchUpdateReviewDb(db), repo, rw);
-    }
-
-    private NoteDbUpdateManager stageNoteDbUpdate(ChangeContextImpl ctx, boolean deleted)
-        throws OrmException, IOException {
-      logDebug("Staging NoteDb update");
-      NoteDbUpdateManager updateManager =
-          updateManagerFactory
-              .create(ctx.getProject())
-              .setChangeRepo(
-                  ctx.threadLocalRepo,
-                  ctx.threadLocalRevWalk,
-                  null,
-                  new ChainedReceiveCommands(ctx.threadLocalRepo));
-      if (ctx.getUser().isIdentifiedUser()) {
-        updateManager.setRefLogIdent(
-            ctx.getUser().asIdentifiedUser().newRefLogIdent(ctx.getWhen(), tz));
-      }
-      for (ChangeUpdate u : ctx.updates.values()) {
-        updateManager.add(u);
-      }
-
-      Change c = ctx.getChange();
-      if (deleted) {
-        updateManager.deleteChange(c.getId());
-      }
-      try {
-        updateManager.stageAndApplyDelta(c);
-      } catch (MismatchedStateException ex) {
-        // Refused to apply update because NoteDb was out of sync, which can
-        // only happen if ReviewDb is the primary storage for this change.
-        //
-        // Go ahead with this ReviewDb update; it's still out of sync, but this
-        // is no worse than before, and it will eventually get rebuilt.
-        logDebug("Ignoring MismatchedStateException while staging");
-      }
-
-      return updateManager;
-    }
-
-    private boolean isNewChange(Change.Id id) {
-      return newChanges.containsKey(id);
-    }
-
-    private void logDebug(String msg, Throwable t) {
-      if (log.isDebugEnabled()) {
-        ReviewDbBatchUpdate.this.logDebug("[" + taskId + "]" + msg, t);
-      }
-    }
-
-    private void logDebug(String msg, Object... args) {
-      if (log.isDebugEnabled()) {
-        ReviewDbBatchUpdate.this.logDebug("[" + taskId + "]" + msg, args);
-      }
-    }
-  }
-
-  private static Iterable<Change> changesToUpdate(ChangeContextImpl ctx) {
-    Change c = ctx.getChange();
-    if (ctx.bumpLastUpdatedOn && c.getLastUpdatedOn().before(ctx.getWhen())) {
-      c.setLastUpdatedOn(ctx.getWhen());
-    }
-    return Collections.singleton(c);
-  }
-
-  private void executePostOps() throws Exception {
-    ContextImpl ctx = new ContextImpl();
-    for (BatchUpdateOp op : ops.values()) {
-      op.postUpdate(ctx);
-    }
-
-    for (RepoOnlyOp op : repoOnlyOps) {
-      op.postUpdate(ctx);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RegexListSearcher.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RegexListSearcher.java
deleted file mode 100644
index 91cb709..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/RegexListSearcher.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.util;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.common.base.Function;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.primitives.Chars;
-import dk.brics.automaton.Automaton;
-import dk.brics.automaton.RegExp;
-import dk.brics.automaton.RunAutomaton;
-import java.util.Collections;
-import java.util.List;
-
-/** Helper to search sorted lists for elements matching a regex. */
-public abstract class RegexListSearcher<T> implements Function<T, String> {
-  public static RegexListSearcher<String> ofStrings(String re) {
-    return new RegexListSearcher<String>(re) {
-      @Override
-      public String apply(String in) {
-        return in;
-      }
-    };
-  }
-
-  private final RunAutomaton pattern;
-
-  private final String prefixBegin;
-  private final String prefixEnd;
-  private final int prefixLen;
-  private final boolean prefixOnly;
-
-  public RegexListSearcher(String re) {
-    if (re.startsWith("^")) {
-      re = re.substring(1);
-    }
-
-    if (re.endsWith("$") && !re.endsWith("\\$")) {
-      re = re.substring(0, re.length() - 1);
-    }
-
-    Automaton automaton = new RegExp(re).toAutomaton();
-    prefixBegin = automaton.getCommonPrefix();
-    prefixLen = prefixBegin.length();
-
-    if (0 < prefixLen) {
-      char max = Chars.checkedCast(prefixBegin.charAt(prefixLen - 1) + 1);
-      prefixEnd = prefixBegin.substring(0, prefixLen - 1) + max;
-      prefixOnly = re.equals(prefixBegin + ".*");
-    } else {
-      prefixEnd = "";
-      prefixOnly = false;
-    }
-
-    pattern = prefixOnly ? null : new RunAutomaton(automaton);
-  }
-
-  public Iterable<T> search(List<T> list) {
-    checkNotNull(list);
-    int begin;
-    int end;
-
-    if (0 < prefixLen) {
-      // Assumes many consecutive elements may have the same prefix, so the cost
-      // of two binary searches is less than iterating to find the endpoints.
-      begin = find(list, prefixBegin);
-      end = find(list, prefixEnd);
-    } else {
-      begin = 0;
-      end = list.size();
-    }
-
-    if (prefixOnly) {
-      return begin < end ? list.subList(begin, end) : ImmutableList.<T>of();
-    }
-
-    return Iterables.filter(list.subList(begin, end), x -> pattern.run(apply(x)));
-  }
-
-  public boolean hasMatch(List<T> list) {
-    return !Iterables.isEmpty(search(list));
-  }
-
-  private int find(List<T> list, String p) {
-    int r = Collections.binarySearch(Lists.transform(list, this), p);
-    return r < 0 ? -(r + 1) : r;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/SystemLog.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/SystemLog.java
deleted file mode 100644
index 81a2df2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/SystemLog.java
+++ /dev/null
@@ -1,130 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.util;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.Die;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.nio.file.Path;
-import org.apache.log4j.Appender;
-import org.apache.log4j.AsyncAppender;
-import org.apache.log4j.DailyRollingFileAppender;
-import org.apache.log4j.Layout;
-import org.apache.log4j.LogManager;
-import org.apache.log4j.Logger;
-import org.apache.log4j.helpers.OnlyOnceErrorHandler;
-import org.apache.log4j.spi.ErrorHandler;
-import org.apache.log4j.spi.LoggingEvent;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class SystemLog {
-  private static final org.slf4j.Logger log = LoggerFactory.getLogger(SystemLog.class);
-
-  public static final String LOG4J_CONFIGURATION = "log4j.configuration";
-
-  private final SitePaths site;
-  private final Config config;
-
-  @Inject
-  public SystemLog(SitePaths site, @GerritServerConfig Config config) {
-    this.site = site;
-    this.config = config;
-  }
-
-  public static boolean shouldConfigure() {
-    return Strings.isNullOrEmpty(System.getProperty(LOG4J_CONFIGURATION));
-  }
-
-  public static Appender createAppender(Path logdir, String name, Layout layout) {
-    final DailyRollingFileAppender dst = new DailyRollingFileAppender();
-    dst.setName(name);
-    dst.setLayout(layout);
-    dst.setEncoding(UTF_8.name());
-    dst.setFile(resolve(logdir).resolve(name).toString());
-    dst.setImmediateFlush(true);
-    dst.setAppend(true);
-    dst.setErrorHandler(new DieErrorHandler());
-    dst.activateOptions();
-    dst.setErrorHandler(new OnlyOnceErrorHandler());
-    return dst;
-  }
-
-  public AsyncAppender createAsyncAppender(String name, Layout layout) {
-    AsyncAppender async = new AsyncAppender();
-    async.setName(name);
-    async.setBlocking(true);
-    async.setBufferSize(config.getInt("core", "asyncLoggingBufferSize", 64));
-    async.setLocationInfo(false);
-
-    if (shouldConfigure()) {
-      async.addAppender(createAppender(site.logs_dir, name, layout));
-    } else {
-      Appender appender = LogManager.getLogger(name).getAppender(name);
-      if (appender != null) {
-        async.addAppender(appender);
-      } else {
-        log.warn(
-            "No appender with the name: " + name + " was found. " + name + " logging is disabled");
-      }
-    }
-    async.activateOptions();
-    return async;
-  }
-
-  private static Path resolve(Path p) {
-    try {
-      return p.toRealPath().normalize();
-    } catch (IOException e) {
-      return p.toAbsolutePath().normalize();
-    }
-  }
-
-  private static final class DieErrorHandler implements ErrorHandler {
-    @Override
-    public void error(String message, Exception e, int errorCode, LoggingEvent event) {
-      error(e != null ? e.getMessage() : message);
-    }
-
-    @Override
-    public void error(String message, Exception e, int errorCode) {
-      error(e != null ? e.getMessage() : message);
-    }
-
-    @Override
-    public void error(String message) {
-      throw new Die("Cannot open log file: " + message);
-    }
-
-    @Override
-    public void activateOptions() {}
-
-    @Override
-    public void setAppender(Appender appender) {}
-
-    @Override
-    public void setBackupAppender(Appender appender) {}
-
-    @Override
-    public void setLogger(Logger logger) {}
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED__check_user_label_3.java b/gerrit-server/src/main/java/gerrit/PRED__check_user_label_3.java
deleted file mode 100644
index e84b3ac..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED__check_user_label_3.java
+++ /dev/null
@@ -1,107 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package gerrit;
-
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.rules.StoredValues;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.permissions.LabelPermission;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
-import com.googlecode.prolog_cafe.exceptions.JavaException;
-import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.exceptions.SystemException;
-import com.googlecode.prolog_cafe.lang.IntegerTerm;
-import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-import com.googlecode.prolog_cafe.lang.VariableTerm;
-
-/**
- * Checks user can set label to val.
- *
- * <pre>
- *   '_check_user_label'(+Label, +CurrentUser, +Val)
- * </pre>
- */
-class PRED__check_user_label_3 extends Predicate.P3 {
-  PRED__check_user_label_3(Term a1, Term a2, Term a3, Operation n) {
-    arg1 = a1;
-    arg2 = a2;
-    arg3 = a3;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-    Term a2 = arg2.dereference();
-    Term a3 = arg3.dereference();
-
-    if (a1 instanceof VariableTerm) {
-      throw new PInstantiationException(this, 1);
-    }
-    if (!(a1 instanceof SymbolTerm)) {
-      throw new IllegalTypeException(this, 1, "atom", a1);
-    }
-    String label = a1.name();
-
-    if (a2 instanceof VariableTerm) {
-      throw new PInstantiationException(this, 2);
-    }
-    if (!(a2 instanceof JavaObjectTerm) || !a2.convertible(CurrentUser.class)) {
-      throw new IllegalTypeException(this, 2, "CurrentUser)", a2);
-    }
-    CurrentUser user = (CurrentUser) ((JavaObjectTerm) a2).object();
-
-    if (a3 instanceof VariableTerm) {
-      throw new PInstantiationException(this, 3);
-    }
-    if (!(a3 instanceof IntegerTerm)) {
-      throw new IllegalTypeException(this, 3, "integer", a3);
-    }
-    short val = (short) ((IntegerTerm) a3).intValue();
-
-    try {
-      ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
-      LabelType type = cd.getLabelTypes().byLabel(label);
-      if (type == null) {
-        return engine.fail();
-      }
-      StoredValues.PERMISSION_BACKEND
-          .get(engine)
-          .user(user)
-          .change(cd)
-          .check(new LabelPermission.WithValue(type, val));
-      return cont;
-    } catch (OrmException err) {
-      throw new JavaException(this, 1, err);
-    } catch (AuthException err) {
-      return engine.fail();
-    } catch (PermissionBackendException err) {
-      SystemException se = new SystemException(err.getMessage());
-      se.initCause(err);
-      throw se;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java b/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java
deleted file mode 100644
index 5a3d656..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright 2011 Google Inc. All Rights Reserved.
-
-package gerrit;
-
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.rules.StoredValues;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.googlecode.prolog_cafe.exceptions.JavaException;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.IntegerTerm;
-import com.googlecode.prolog_cafe.lang.ListTerm;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.StructureTerm;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-
-/** Exports list of {@code commit_label( label('Code-Review', 2), user(12345789) )}. */
-class PRED__load_commit_labels_1 extends Predicate.P1 {
-  private static final SymbolTerm sym_commit_label = SymbolTerm.intern("commit_label", 2);
-  private static final SymbolTerm sym_label = SymbolTerm.intern("label", 2);
-  private static final SymbolTerm sym_user = SymbolTerm.intern("user", 1);
-
-  PRED__load_commit_labels_1(Term a1, Operation n) {
-    arg1 = a1;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-
-    Term listHead = Prolog.Nil;
-    try {
-      ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
-      LabelTypes types = cd.getLabelTypes();
-
-      for (PatchSetApproval a : cd.currentApprovals()) {
-        LabelType t = types.byLabel(a.getLabelId());
-        if (t == null) {
-          continue;
-        }
-
-        StructureTerm labelTerm =
-            new StructureTerm(
-                sym_label, SymbolTerm.intern(t.getName()), new IntegerTerm(a.getValue()));
-
-        StructureTerm userTerm =
-            new StructureTerm(sym_user, new IntegerTerm(a.getAccountId().get()));
-
-        listHead = new ListTerm(new StructureTerm(sym_commit_label, labelTerm, userTerm), listHead);
-      }
-    } catch (OrmException err) {
-      throw new JavaException(this, 1, err);
-    }
-
-    if (!a1.unify(listHead, engine.trail)) {
-      return engine.fail();
-    }
-    return cont;
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java b/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java
deleted file mode 100644
index f7f39da..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java
+++ /dev/null
@@ -1,112 +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 gerrit;
-
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.rules.StoredValues;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.permissions.LabelPermission;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
-import com.googlecode.prolog_cafe.exceptions.JavaException;
-import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.exceptions.SystemException;
-import com.googlecode.prolog_cafe.lang.IntegerTerm;
-import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-import com.googlecode.prolog_cafe.lang.VariableTerm;
-import java.util.Set;
-
-/**
- * Resolves the valid range for a label on a CurrentUser.
- *
- * <pre>
- *   '_user_label_range'(+Label, +CurrentUser, -Min, -Max)
- * </pre>
- */
-class PRED__user_label_range_4 extends Predicate.P4 {
-  PRED__user_label_range_4(Term a1, Term a2, Term a3, Term a4, Operation n) {
-    arg1 = a1;
-    arg2 = a2;
-    arg3 = a3;
-    arg4 = a4;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-    Term a2 = arg2.dereference();
-    Term a3 = arg3.dereference();
-    Term a4 = arg4.dereference();
-
-    if (a1 instanceof VariableTerm) {
-      throw new PInstantiationException(this, 1);
-    }
-    if (!(a1 instanceof SymbolTerm)) {
-      throw new IllegalTypeException(this, 1, "atom", a1);
-    }
-    String label = a1.name();
-
-    if (a2 instanceof VariableTerm) {
-      throw new PInstantiationException(this, 2);
-    }
-    if (!(a2 instanceof JavaObjectTerm) || !a2.convertible(CurrentUser.class)) {
-      throw new IllegalTypeException(this, 2, "CurrentUser)", a2);
-    }
-    CurrentUser user = (CurrentUser) ((JavaObjectTerm) a2).object();
-
-    Set<LabelPermission.WithValue> can;
-    try {
-      ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
-      LabelType type = cd.getLabelTypes().byLabel(label);
-      if (type == null) {
-        return engine.fail();
-      }
-      can = StoredValues.PERMISSION_BACKEND.get(engine).user(user).change(cd).test(type);
-    } catch (OrmException err) {
-      throw new JavaException(this, 1, err);
-    } catch (PermissionBackendException err) {
-      SystemException se = new SystemException(err.getMessage());
-      se.initCause(err);
-      throw se;
-    }
-
-    int min = 0;
-    int max = 0;
-    for (LabelPermission.WithValue v : can) {
-      min = Math.min(min, v.value());
-      max = Math.max(max, v.value());
-    }
-
-    if (!a3.unify(new IntegerTerm(min), engine.trail)) {
-      return engine.fail();
-    }
-
-    if (!a4.unify(new IntegerTerm(max), engine.trail)) {
-      return engine.fail();
-    }
-
-    return cont;
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_change_branch_1.java b/gerrit-server/src/main/java/gerrit/PRED_change_branch_1.java
deleted file mode 100644
index f050c7f..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_change_branch_1.java
+++ /dev/null
@@ -1,44 +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 gerrit;
-
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.rules.StoredValues;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-
-public class PRED_change_branch_1 extends Predicate.P1 {
-  public PRED_change_branch_1(Term a1, Operation n) {
-    arg1 = a1;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-
-    Branch.NameKey name = StoredValues.getChange(engine).getDest();
-
-    if (!a1.unify(SymbolTerm.create(name.get()), engine.trail)) {
-      return engine.fail();
-    }
-    return cont;
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_change_owner_1.java b/gerrit-server/src/main/java/gerrit/PRED_change_owner_1.java
deleted file mode 100644
index b9dac68..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_change_owner_1.java
+++ /dev/null
@@ -1,48 +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 gerrit;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.rules.StoredValues;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.IntegerTerm;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.StructureTerm;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-
-public class PRED_change_owner_1 extends Predicate.P1 {
-  private static final SymbolTerm user = SymbolTerm.intern("user", 1);
-
-  public PRED_change_owner_1(Term a1, Operation n) {
-    arg1 = a1;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-
-    Account.Id ownerId = StoredValues.getChange(engine).getOwner();
-
-    if (!a1.unify(new StructureTerm(user, new IntegerTerm(ownerId.get())), engine.trail)) {
-      return engine.fail();
-    }
-    return cont;
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_change_project_1.java b/gerrit-server/src/main/java/gerrit/PRED_change_project_1.java
deleted file mode 100644
index 568ef2b..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_change_project_1.java
+++ /dev/null
@@ -1,44 +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 gerrit;
-
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.rules.StoredValues;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-
-public class PRED_change_project_1 extends Predicate.P1 {
-  public PRED_change_project_1(Term a1, Operation n) {
-    arg1 = a1;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-
-    Project.NameKey name = StoredValues.getChange(engine).getProject();
-
-    if (!a1.unify(SymbolTerm.create(name.get()), engine.trail)) {
-      return engine.fail();
-    }
-    return cont;
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_change_topic_1.java b/gerrit-server/src/main/java/gerrit/PRED_change_topic_1.java
deleted file mode 100644
index 534d097..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_change_topic_1.java
+++ /dev/null
@@ -1,49 +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 gerrit;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.rules.StoredValues;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-
-public class PRED_change_topic_1 extends Predicate.P1 {
-  public PRED_change_topic_1(Term a1, Operation n) {
-    arg1 = a1;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-
-    Term topicTerm = Prolog.Nil;
-    Change change = StoredValues.getChange(engine);
-    String topic = change.getTopic();
-    if (topic != null) {
-      topicTerm = SymbolTerm.create(topic);
-    }
-
-    if (!a1.unify(topicTerm, engine.trail)) {
-      return engine.fail();
-    }
-    return cont;
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_author_3.java b/gerrit-server/src/main/java/gerrit/PRED_commit_author_3.java
deleted file mode 100644
index 51d0913..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_author_3.java
+++ /dev/null
@@ -1,36 +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 gerrit;
-
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.UserIdentity;
-import com.google.gerrit.rules.StoredValues;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.Term;
-
-public class PRED_commit_author_3 extends AbstractCommitUserIdentityPredicate {
-  public PRED_commit_author_3(Term a1, Term a2, Term a3, Operation n) {
-    super(a1, a2, a3, n);
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    PatchSetInfo psInfo = StoredValues.PATCH_SET_INFO.get(engine);
-    UserIdentity author = psInfo.getAuthor();
-    return exec(engine, author);
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_committer_3.java b/gerrit-server/src/main/java/gerrit/PRED_commit_committer_3.java
deleted file mode 100644
index 7fa9ff4..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_committer_3.java
+++ /dev/null
@@ -1,36 +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 gerrit;
-
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.UserIdentity;
-import com.google.gerrit.rules.StoredValues;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.Term;
-
-public class PRED_commit_committer_3 extends AbstractCommitUserIdentityPredicate {
-  public PRED_commit_committer_3(Term a1, Term a2, Term a3, Operation n) {
-    super(a1, a2, a3, n);
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    PatchSetInfo psInfo = StoredValues.PATCH_SET_INFO.get(engine);
-    UserIdentity committer = psInfo.getCommitter();
-    return exec(engine, committer);
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_delta_4.java b/gerrit-server/src/main/java/gerrit/PRED_commit_delta_4.java
deleted file mode 100644
index 97e5219..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_delta_4.java
+++ /dev/null
@@ -1,172 +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 gerrit;
-
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.rules.StoredValues;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListEntry;
-import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
-import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-import com.googlecode.prolog_cafe.lang.VariableTerm;
-import java.util.Iterator;
-import java.util.regex.Pattern;
-
-/**
- * Given a regular expression, checks it against the file list in the most recent patchset of a
- * change. For all files that match the regex, returns the (new) path of the file, the change type,
- * and the old path of the file if applicable (if the file was copied or renamed).
- *
- * <pre>
- *   'commit_delta'(+Regex, -ChangeType, -NewPath, -OldPath)
- * </pre>
- */
-public class PRED_commit_delta_4 extends Predicate.P4 {
-  private static final SymbolTerm add = SymbolTerm.intern("add");
-  private static final SymbolTerm modify = SymbolTerm.intern("modify");
-  private static final SymbolTerm delete = SymbolTerm.intern("delete");
-  private static final SymbolTerm rename = SymbolTerm.intern("rename");
-  private static final SymbolTerm copy = SymbolTerm.intern("copy");
-  static final Operation commit_delta_check = new PRED_commit_delta_check();
-  static final Operation commit_delta_next = new PRED_commit_delta_next();
-  static final Operation commit_delta_empty = new PRED_commit_delta_empty();
-
-  public PRED_commit_delta_4(Term a1, Term a2, Term a3, Term a4, Operation n) {
-    arg1 = a1;
-    arg2 = a2;
-    arg3 = a3;
-    arg4 = a4;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.cont = cont;
-    engine.setB0();
-
-    Term a1 = arg1.dereference();
-    if (a1 instanceof VariableTerm) {
-      throw new PInstantiationException(this, 1);
-    }
-    if (!(a1 instanceof SymbolTerm)) {
-      throw new IllegalTypeException(this, 1, "symbol", a1);
-    }
-    Pattern regex = Pattern.compile(a1.name());
-    engine.r1 = new JavaObjectTerm(regex);
-    engine.r2 = arg2;
-    engine.r3 = arg3;
-    engine.r4 = arg4;
-
-    PatchList pl = StoredValues.PATCH_LIST.get(engine);
-    Iterator<PatchListEntry> iter = pl.getPatches().iterator();
-
-    engine.r5 = new JavaObjectTerm(iter);
-
-    return engine.jtry5(commit_delta_check, commit_delta_next);
-  }
-
-  private static final class PRED_commit_delta_check extends Operation {
-    @Override
-    public Operation exec(Prolog engine) {
-      Term a1 = engine.r1;
-      Term a2 = engine.r2;
-      Term a3 = engine.r3;
-      Term a4 = engine.r4;
-      Term a5 = engine.r5;
-
-      Pattern regex = (Pattern) ((JavaObjectTerm) a1).object();
-      @SuppressWarnings("unchecked")
-      Iterator<PatchListEntry> iter = (Iterator<PatchListEntry>) ((JavaObjectTerm) a5).object();
-      while (iter.hasNext()) {
-        PatchListEntry patch = iter.next();
-        String newName = patch.getNewName();
-        String oldName = patch.getOldName();
-        Patch.ChangeType changeType = patch.getChangeType();
-
-        if (newName.equals("/COMMIT_MSG")) {
-          continue;
-        }
-
-        if (regex.matcher(newName).find() || (oldName != null && regex.matcher(oldName).find())) {
-          SymbolTerm changeSym = getTypeSymbol(changeType);
-          SymbolTerm newSym = SymbolTerm.create(newName);
-          SymbolTerm oldSym = Prolog.Nil;
-          if (oldName != null) {
-            oldSym = SymbolTerm.create(oldName);
-          }
-
-          if (!a2.unify(changeSym, engine.trail)) {
-            continue;
-          }
-          if (!a3.unify(newSym, engine.trail)) {
-            continue;
-          }
-          if (!a4.unify(oldSym, engine.trail)) {
-            continue;
-          }
-          return engine.cont;
-        }
-      }
-      return engine.fail();
-    }
-  }
-
-  private static final class PRED_commit_delta_next extends Operation {
-    @Override
-    public Operation exec(Prolog engine) {
-      return engine.trust(commit_delta_empty);
-    }
-  }
-
-  private static final class PRED_commit_delta_empty extends Operation {
-    @Override
-    public Operation exec(Prolog engine) {
-      Term a5 = engine.r5;
-
-      @SuppressWarnings("unchecked")
-      Iterator<PatchListEntry> iter = (Iterator<PatchListEntry>) ((JavaObjectTerm) a5).object();
-      if (!iter.hasNext()) {
-        return engine.fail();
-      }
-
-      return engine.jtry5(commit_delta_check, commit_delta_next);
-    }
-  }
-
-  private static SymbolTerm getTypeSymbol(Patch.ChangeType type) {
-    switch (type) {
-      case ADDED:
-        return add;
-      case MODIFIED:
-        return modify;
-      case DELETED:
-        return delete;
-      case RENAMED:
-        return rename;
-      case COPIED:
-        return copy;
-      case REWRITE:
-        break;
-    }
-    throw new IllegalArgumentException("ChangeType not recognized");
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_edits_2.java b/gerrit-server/src/main/java/gerrit/PRED_commit_edits_2.java
deleted file mode 100644
index 95c4aaef..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_edits_2.java
+++ /dev/null
@@ -1,159 +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 gerrit;
-
-import com.google.gerrit.rules.StoredValues;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListEntry;
-import com.google.gerrit.server.patch.Text;
-import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
-import com.googlecode.prolog_cafe.exceptions.JavaException;
-import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-import com.googlecode.prolog_cafe.lang.VariableTerm;
-import java.io.IOException;
-import java.util.List;
-import java.util.regex.Pattern;
-import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.errors.CorruptObjectException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.TreeWalk;
-
-/**
- * Returns true if any of the files that match FileNameRegex have edited lines that match EditRegex
- *
- * <pre>
- *   'commit_edits'(+FileNameRegex, +EditRegex)
- * </pre>
- */
-public class PRED_commit_edits_2 extends Predicate.P2 {
-  public PRED_commit_edits_2(Term a1, Term a2, Operation n) {
-    arg1 = a1;
-    arg2 = a2;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-
-    Term a1 = arg1.dereference();
-    Term a2 = arg2.dereference();
-
-    Pattern fileRegex = getRegexParameter(a1);
-    Pattern editRegex = getRegexParameter(a2);
-
-    PatchList pl = StoredValues.PATCH_LIST.get(engine);
-    Repository repo = StoredValues.REPOSITORY.get(engine);
-
-    try (ObjectReader reader = repo.newObjectReader();
-        RevWalk rw = new RevWalk(reader)) {
-      final RevTree aTree;
-      final RevTree bTree;
-      final RevCommit bCommit = rw.parseCommit(pl.getNewId());
-
-      if (pl.getOldId() != null) {
-        aTree = rw.parseTree(pl.getOldId());
-      } else {
-        // Octopus merge with unknown automatic merge result, since the
-        // web UI returns no files to match against, just fail.
-        return engine.fail();
-      }
-      bTree = bCommit.getTree();
-
-      for (PatchListEntry entry : pl.getPatches()) {
-        String newName = entry.getNewName();
-        String oldName = entry.getOldName();
-
-        if (newName.equals("/COMMIT_MSG")) {
-          continue;
-        }
-
-        if (fileRegex.matcher(newName).find()
-            || (oldName != null && fileRegex.matcher(oldName).find())) {
-          List<Edit> edits = entry.getEdits();
-
-          if (edits.isEmpty()) {
-            continue;
-          }
-          Text tA;
-          if (oldName != null) {
-            tA = load(aTree, oldName, reader);
-          } else {
-            tA = load(aTree, newName, reader);
-          }
-          Text tB = load(bTree, newName, reader);
-          for (Edit edit : edits) {
-            if (tA != Text.EMPTY) {
-              String aDiff = tA.getString(edit.getBeginA(), edit.getEndA(), true);
-              if (editRegex.matcher(aDiff).find()) {
-                return cont;
-              }
-            }
-            if (tB != Text.EMPTY) {
-              String bDiff = tB.getString(edit.getBeginB(), edit.getEndB(), true);
-              if (editRegex.matcher(bDiff).find()) {
-                return cont;
-              }
-            }
-          }
-        }
-      }
-    } catch (IOException err) {
-      throw new JavaException(this, 1, err);
-    }
-
-    return engine.fail();
-  }
-
-  private Pattern getRegexParameter(Term term) {
-    if (term instanceof VariableTerm) {
-      throw new PInstantiationException(this, 1);
-    }
-    if (!(term instanceof SymbolTerm)) {
-      throw new IllegalTypeException(this, 1, "symbol", term);
-    }
-    return Pattern.compile(term.name(), Pattern.MULTILINE);
-  }
-
-  private Text load(ObjectId tree, String path, ObjectReader reader)
-      throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
-          IOException {
-    if (path == null) {
-      return Text.EMPTY;
-    }
-    final TreeWalk tw = TreeWalk.forPath(reader, path, tree);
-    if (tw == null) {
-      return Text.EMPTY;
-    }
-    if (tw.getFileMode(0).getObjectType() != Constants.OBJ_BLOB) {
-      return Text.EMPTY;
-    }
-    return new Text(reader.open(tw.getObjectId(0), Constants.OBJ_BLOB));
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_message_1.java b/gerrit-server/src/main/java/gerrit/PRED_commit_message_1.java
deleted file mode 100644
index 16a5b13..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_message_1.java
+++ /dev/null
@@ -1,52 +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 gerrit;
-
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.rules.StoredValues;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-
-/**
- * Returns the commit message as a symbol
- *
- * <pre>
- *   'commit_message'(-Msg)
- * </pre>
- */
-public class PRED_commit_message_1 extends Predicate.P1 {
-  public PRED_commit_message_1(Term a1, Operation n) {
-    arg1 = a1;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-
-    PatchSetInfo psInfo = StoredValues.PATCH_SET_INFO.get(engine);
-
-    SymbolTerm msg = SymbolTerm.create(psInfo.getMessage());
-    if (!a1.unify(msg, engine.trail)) {
-      return engine.fail();
-    }
-    return cont;
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java b/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java
deleted file mode 100644
index 6ed82e5..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package gerrit;
-
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.rules.StoredValues;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListEntry;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.IntegerTerm;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.Term;
-import java.util.List;
-
-/**
- * Exports basic commit statistics.
- *
- * <pre>
- *   'commit_stats'(-Files, -Insertions, -Deletions)
- * </pre>
- */
-public class PRED_commit_stats_3 extends Predicate.P3 {
-  public PRED_commit_stats_3(Term a1, Term a2, Term a3, Operation n) {
-    arg1 = a1;
-    arg2 = a2;
-    arg3 = a3;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-
-    Term a1 = arg1.dereference();
-    Term a2 = arg2.dereference();
-    Term a3 = arg3.dereference();
-
-    PatchList pl = StoredValues.PATCH_LIST.get(engine);
-    // Account for magic files
-    if (!a1.unify(
-        new IntegerTerm(pl.getPatches().size() - countMagicFiles(pl.getPatches())), engine.trail)) {
-      return engine.fail();
-    }
-    if (!a2.unify(new IntegerTerm(pl.getInsertions()), engine.trail)) {
-      return engine.fail();
-    }
-    if (!a3.unify(new IntegerTerm(pl.getDeletions()), engine.trail)) {
-      return engine.fail();
-    }
-    return cont;
-  }
-
-  private int countMagicFiles(List<PatchListEntry> entries) {
-    int count = 0;
-    for (PatchListEntry e : entries) {
-      if (Patch.isMagic(e.getNewName())) {
-        count++;
-      }
-    }
-    return count;
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_current_user_1.java b/gerrit-server/src/main/java/gerrit/PRED_current_user_1.java
deleted file mode 100644
index 6dc1e52..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_current_user_1.java
+++ /dev/null
@@ -1,69 +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 gerrit;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.rules.StoredValues;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PeerDaemonUser;
-import com.googlecode.prolog_cafe.exceptions.EvaluationException;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.IntegerTerm;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.StructureTerm;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-
-public class PRED_current_user_1 extends Predicate.P1 {
-  private static final SymbolTerm user = SymbolTerm.intern("user", 1);
-  private static final SymbolTerm anonymous = SymbolTerm.intern("anonymous");
-  private static final SymbolTerm peerDaemon = SymbolTerm.intern("peer_daemon");
-
-  public PRED_current_user_1(Term a1, Operation n) {
-    arg1 = a1;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-
-    CurrentUser curUser = StoredValues.CURRENT_USER.getOrNull(engine);
-    if (curUser == null) {
-      throw new EvaluationException("Current user not available in this rule type");
-    }
-    Term resultTerm;
-
-    if (curUser.isIdentifiedUser()) {
-      Account.Id id = curUser.getAccountId();
-      resultTerm = new IntegerTerm(id.get());
-    } else if (curUser instanceof AnonymousUser) {
-      resultTerm = anonymous;
-    } else if (curUser instanceof PeerDaemonUser) {
-      resultTerm = peerDaemon;
-    } else {
-      throw new EvaluationException("Unknown user type");
-    }
-
-    if (!a1.unify(new StructureTerm(user, resultTerm), engine.trail)) {
-      return engine.fail();
-    }
-    return cont;
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_current_user_2.java b/gerrit-server/src/main/java/gerrit/PRED_current_user_2.java
deleted file mode 100644
index 7da1ce8..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_current_user_2.java
+++ /dev/null
@@ -1,108 +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 gerrit;
-
-import static com.googlecode.prolog_cafe.lang.SymbolTerm.intern;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.rules.PrologEnvironment;
-import com.google.gerrit.rules.StoredValues;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
-import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.IntegerTerm;
-import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.StructureTerm;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-import com.googlecode.prolog_cafe.lang.VariableTerm;
-import java.util.Map;
-
-/**
- * Loads a CurrentUser object for a user identity.
- *
- * <p>Values are cached in the hash {@code current_user}, avoiding recreation during a single
- * evaluation.
- *
- * <pre>
- *   current_user(user(+AccountId), -CurrentUser).
- * </pre>
- */
-class PRED_current_user_2 extends Predicate.P2 {
-  private static final SymbolTerm user = intern("user", 1);
-  private static final SymbolTerm anonymous = intern("anonymous");
-
-  PRED_current_user_2(Term a1, Term a2, Operation n) {
-    arg1 = a1;
-    arg2 = a2;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-    Term a2 = arg2.dereference();
-
-    if (a1 instanceof VariableTerm) {
-      throw new PInstantiationException(this, 1);
-    }
-
-    if (!a2.unify(createUser(engine, a1), engine.trail)) {
-      return engine.fail();
-    }
-
-    return cont;
-  }
-
-  public Term createUser(Prolog engine, Term key) {
-    if (!(key instanceof StructureTerm)
-        || key.arity() != 1
-        || !((StructureTerm) key).functor().equals(user)) {
-      throw new IllegalTypeException(this, 1, "user(int)", key);
-    }
-
-    Term idTerm = key.arg(0);
-    CurrentUser user;
-    if (idTerm instanceof IntegerTerm) {
-      Map<Account.Id, IdentifiedUser> cache = StoredValues.USERS.get(engine);
-      Account.Id accountId = new Account.Id(((IntegerTerm) idTerm).intValue());
-      user = cache.get(accountId);
-      if (user == null) {
-        IdentifiedUser.GenericFactory userFactory = userFactory(engine);
-        IdentifiedUser who = userFactory.create(accountId);
-        cache.put(accountId, who);
-        user = who;
-      }
-
-    } else if (idTerm.equals(anonymous)) {
-      user = StoredValues.ANONYMOUS_USER.get(engine);
-
-    } else {
-      throw new IllegalTypeException(this, 1, "user(int)", key);
-    }
-
-    return new JavaObjectTerm(user);
-  }
-
-  private static IdentifiedUser.GenericFactory userFactory(Prolog engine) {
-    return ((PrologEnvironment) engine.control).getArgs().getUserFactory();
-  }
-}
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
deleted file mode 100644
index 9bfcc61..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java
+++ /dev/null
@@ -1,85 +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 gerrit;
-
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.rules.StoredValues;
-import com.google.gwtorm.server.OrmException;
-import com.googlecode.prolog_cafe.exceptions.JavaException;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.IntegerTerm;
-import com.googlecode.prolog_cafe.lang.ListTerm;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.StructureTerm;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-import java.util.List;
-
-/**
- * Obtain a list of label types from the server configuration.
- *
- * <p>Unifies to a Prolog list of: {@code label_type(Label, Fun, Min, Max)} where:
- *
- * <ul>
- *   <li>{@code Label} - the newer style label name
- *   <li>{@code Fun} - legacy function name
- *   <li>{@code Min, Max} - the smallest and largest configured values.
- * </ul>
- */
-class PRED_get_legacy_label_types_1 extends Predicate.P1 {
-  private static final SymbolTerm NONE = SymbolTerm.intern("none");
-
-  PRED_get_legacy_label_types_1(Term a1, Operation n) {
-    arg1 = a1;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-    List<LabelType> list;
-    try {
-      list = StoredValues.CHANGE_DATA.get(engine).getLabelTypes().getLabelTypes();
-    } catch (OrmException err) {
-      throw new JavaException(this, 1, err);
-    }
-    Term head = Prolog.Nil;
-    for (int idx = list.size() - 1; 0 <= idx; idx--) {
-      head = new ListTerm(export(list.get(idx)), head);
-    }
-
-    if (!a1.unify(head, engine.trail)) {
-      return engine.fail();
-    }
-    return cont;
-  }
-
-  static final SymbolTerm symLabelType = SymbolTerm.intern("label_type", 4);
-
-  static Term export(LabelType type) {
-    LabelValue min = type.getMin();
-    LabelValue max = type.getMax();
-    return new StructureTerm(
-        symLabelType,
-        SymbolTerm.intern(type.getName()),
-        SymbolTerm.intern(type.getFunctionName()),
-        min != null ? new IntegerTerm(min.getValue()) : NONE,
-        max != null ? new IntegerTerm(max.getValue()) : NONE);
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_project_default_submit_type_1.java b/gerrit-server/src/main/java/gerrit/PRED_project_default_submit_type_1.java
deleted file mode 100644
index 1d96433..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_project_default_submit_type_1.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package gerrit;
-
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.rules.StoredValues;
-import com.google.gerrit.server.project.ProjectState;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-
-public class PRED_project_default_submit_type_1 extends Predicate.P1 {
-
-  private static final SymbolTerm[] term;
-
-  static {
-    SubmitType[] val = SubmitType.values();
-    term = new SymbolTerm[val.length];
-    for (int i = 0; i < val.length; i++) {
-      term[i] = SymbolTerm.create(val[i].name());
-    }
-  }
-
-  public PRED_project_default_submit_type_1(Term a1, Operation n) {
-    arg1 = a1;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-
-    ProjectState projectState = StoredValues.PROJECT_STATE.get(engine);
-    SubmitType submitType = projectState.getProject().getSubmitType();
-    if (!a1.unify(term[submitType.ordinal()], engine.trail)) {
-      return engine.fail();
-    }
-    return cont;
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_pure_revert_1.java b/gerrit-server/src/main/java/gerrit/PRED_pure_revert_1.java
deleted file mode 100644
index f3721fb..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_pure_revert_1.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package gerrit;
-
-import com.google.gerrit.rules.StoredValues;
-import com.google.gwtorm.server.OrmException;
-import com.googlecode.prolog_cafe.exceptions.JavaException;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.IntegerTerm;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.Term;
-
-/** Checks if change is a pure revert of the change it references in 'revertOf'. */
-public class PRED_pure_revert_1 extends Predicate.P1 {
-  public PRED_pure_revert_1(Term a1, Operation n) {
-    arg1 = a1;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-
-    Boolean isPureRevert;
-    try {
-      isPureRevert = StoredValues.CHANGE_DATA.get(engine).isPureRevert();
-    } catch (OrmException e) {
-      throw new JavaException(this, 1, e);
-    }
-    if (!a1.unify(new IntegerTerm(Boolean.TRUE.equals(isPureRevert) ? 1 : 0), engine.trail)) {
-      return engine.fail();
-    }
-    return cont;
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_unresolved_comments_count_1.java b/gerrit-server/src/main/java/gerrit/PRED_unresolved_comments_count_1.java
deleted file mode 100644
index 10d5520..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_unresolved_comments_count_1.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package gerrit;
-
-import com.google.gerrit.rules.StoredValues;
-import com.google.gwtorm.server.OrmException;
-import com.googlecode.prolog_cafe.exceptions.JavaException;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.IntegerTerm;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.Term;
-
-public class PRED_unresolved_comments_count_1 extends Predicate.P1 {
-  public PRED_unresolved_comments_count_1(Term a1, Operation n) {
-    arg1 = a1;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-
-    try {
-      Integer count = StoredValues.CHANGE_DATA.get(engine).unresolvedCommentCount();
-      if (!a1.unify(new IntegerTerm(count != null ? count : 0), engine.trail)) {
-        return engine.fail();
-      }
-    } catch (OrmException err) {
-      throw new JavaException(this, 1, err);
-    }
-    return cont;
-  }
-}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_uploader_1.java b/gerrit-server/src/main/java/gerrit/PRED_uploader_1.java
deleted file mode 100644
index 77d31d9..0000000
--- a/gerrit-server/src/main/java/gerrit/PRED_uploader_1.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package gerrit;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.rules.StoredValues;
-import com.googlecode.prolog_cafe.exceptions.PrologException;
-import com.googlecode.prolog_cafe.lang.IntegerTerm;
-import com.googlecode.prolog_cafe.lang.Operation;
-import com.googlecode.prolog_cafe.lang.Predicate;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.StructureTerm;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class PRED_uploader_1 extends Predicate.P1 {
-  private static final Logger log = LoggerFactory.getLogger(PRED_uploader_1.class);
-
-  private static final SymbolTerm user = SymbolTerm.intern("user", 1);
-
-  public PRED_uploader_1(Term a1, Operation n) {
-    arg1 = a1;
-    cont = n;
-  }
-
-  @Override
-  public Operation exec(Prolog engine) throws PrologException {
-    engine.setB0();
-    Term a1 = arg1.dereference();
-
-    PatchSet patchSet = StoredValues.getPatchSet(engine);
-    if (patchSet == null) {
-      log.error(
-          "Failed to load current patch set of change "
-              + StoredValues.getChange(engine).getChangeId());
-      return engine.fail();
-    }
-
-    Account.Id uploaderId = patchSet.getUploader();
-
-    if (!a1.unify(new StructureTerm(user, new IntegerTerm(uploaderId.get())), engine.trail)) {
-      return engine.fail();
-    }
-    return cont;
-  }
-}
diff --git a/gerrit-server/src/main/prolog/gerrit_common.pl b/gerrit-server/src/main/prolog/gerrit_common.pl
deleted file mode 100644
index 4671e0d..0000000
--- a/gerrit-server/src/main/prolog/gerrit_common.pl
+++ /dev/null
@@ -1,510 +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 gerrit.
-'$init' :- init.
-
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% init:
-%%
-%%   Initialize the module's private state. These typically take the form of global
-%%   aliased hashes carrying "constant" data about the current change for any
-%%   predicate that needs to obtain it.
-%%
-init :-
-  define_hash(commit_labels).
-
-define_hash(A) :- hash_exists(A), !, hash_clear(A).
-define_hash(A) :- atom(A), !, new_hash(_, [alias(A)]).
-
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% commit_label/2:
-%%
-%% During rule evaluation of a change, this predicate is defined to
-%% be a table of labels that pertain to the commit of interest.
-%%
-%%   commit_label( label('Code-Review', 2), user(12345789) ).
-%%   commit_label( label('Verified', -1), user(8181) ).
-%%
-:- public commit_label/2.
-%%
-commit_label(L, User) :- L = label(H, _),
-  atom(H),
-  !,
-  hash_get(commit_labels, H, Cached),
-  ( [] == Cached ->
-    get_commit_labels(_),
-    hash_get(commit_labels, H, Rs), !
-    ;
-    Rs = Cached
-  ),
-  scan_commit_labels(Rs, L, User)
-  .
-commit_label(Label, User) :-
-  get_commit_labels(Rs),
-  scan_commit_labels(Rs, Label, User).
-
-scan_commit_labels([R | Rs], L, U) :- R = commit_label(L, U).
-scan_commit_labels([_ | Rs], L, U) :- scan_commit_labels(Rs, L, U).
-scan_commit_labels([], _, _) :- fail.
-
-get_commit_labels(Rs) :-
-  hash_contains_key(commit_labels, '$all'),
-  !,
-  hash_get(commit_labels, '$all', Rs)
-  .
-get_commit_labels(Rs) :-
-  '_load_commit_labels'(Rs),
-  set_commit_labels(Rs).
-
-set_commit_labels(Rs) :-
-  define_hash(commit_labels),
-  hash_put(commit_labels, '$all', Rs),
-  index_commit_labels(Rs).
-
-index_commit_labels([]).
-index_commit_labels([R | Rs]) :-
-  R = commit_label(label(H, _), _),
-  atom(H),
-  !,
-  hash_get(commit_labels, H, Tmp),
-  hash_put(commit_labels, H, [R | Tmp]),
-  index_commit_labels(Rs)
-  .
-index_commit_labels([_ | Rs]) :-
-  index_commit_labels(Rs).
-
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% check_user_label/3:
-%%
-%%   Check Who can set Label to Val.
-%%
-check_user_label(Label, Who, Val) :-
-  hash_get(commit_labels, '$fast_range', true), !,
-  atom(Label),
-  assume_range_from_label(Label, Who, Min, Max),
-  Min @=< Val, Val @=< Max.
-check_user_label(Label, Who, Val) :-
-  Who = user(_), !,
-  atom(Label),
-  current_user(Who, User),
-  '_check_user_label'(Label, User, Val).
-check_user_label(Label, test_user(Name), Val) :-
-  clause(user:test_grant(Label, test_user(Name), range(Min, Max)), _),
-  Min @=< Val, Val @=< Max
-  .
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% user_label_range/4:
-%%
-%%   Lookup the range allowed to be used.
-%%
-user_label_range(Label, Who, Min, Max) :-
-  hash_get(commit_labels, '$fast_range', true), !,
-  atom(Label),
-  assume_range_from_label(Label, Who, Min, Max).
-user_label_range(Label, Who, Min, Max) :-
-  Who = user(_), !,
-  atom(Label),
-  current_user(Who, User),
-  '_user_label_range'(Label, User, Min, Max).
-user_label_range(Label, test_user(Name), Min, Max) :-
-  clause(user:test_grant(Label, test_user(Name), range(Min, Max)), _)
-  .
-
-assume_range_from_label :-
-  hash_put(commit_labels, '$fast_range', true).
-
-assume_range_from_label(Label, Who, Min, Max) :-
-  commit_label(label(Label, Value), Who), !,
-  Min = Value, Max = Value.
-assume_range_from_label(_, _, 0, 0).
-
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% not_same/2:
-%%
-:- public not_same/2.
-%%
-not_same(ok(A), ok(B)) :- !, A \= B.
-not_same(label(_, ok(A)), label(_, ok(B))) :- !, A \= B.
-not_same(_, _).
-
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% can_submit/2:
-%%
-%%   Executes the SubmitRule for each solution until one where all of the
-%%   states has the format label(_, ok(_)) is found, then cut away any
-%%   remaining choice points leaving this as the last solution.
-%%
-:- public can_submit/2.
-%%
-can_submit(SubmitRule, S) :-
-  call_rule(SubmitRule, Tmp),
-  Tmp =.. [submit | Ls],
-  ( is_all_ok(Ls) -> S = ok(Tmp), ! ; S = not_ready(Tmp) ).
-
-call_rule(P:X, Arg) :- !, F =.. [X, Arg], P:F.
-call_rule(X, Arg) :- !, F =.. [X, Arg], F.
-
-is_all_ok([]).
-is_all_ok([label(_, ok(__)) | Ls]) :- is_all_ok(Ls).
-is_all_ok([label(_, may(__)) | Ls]) :- is_all_ok(Ls).
-is_all_ok(_) :- fail.
-
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% locate_helper
-%%
-%%   Returns user:Func if it exists otherwise returns gerrit:Default
-
-locate_helper(Func, Default, Arity, user:Func) :-
-    '$compiled_predicate'(user, Func, Arity), !.
-locate_helper(Func, Default, Arity, user:Func) :-
-    listN(Arity, P), C =.. [Func | P], clause(user:C, _), !.
-locate_helper(Func, Default, _, gerrit:Default).
-
-listN(0, []).
-listN(N, [_|T]) :- N > 0, N1 is N - 1, listN(N1, T).
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% locate_submit_rule/1:
-%%
-%%   Finds a submit_rule depending on what rules are available.
-%%   If none are available, use default_submit/1.
-%%
-:- public locate_submit_rule/1.
-%%
-
-locate_submit_rule(RuleName) :-
-  locate_helper(submit_rule, default_submit, 1, RuleName).
-
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% get_submit_type/2:
-%%
-%%   Executes the SubmitTypeRule and return the first solution
-%%
-:- public get_submit_type/2.
-%%
-get_submit_type(SubmitTypeRule, A) :-
-  call_rule(SubmitTypeRule, A), !.
-
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% locate_submit_type/1:
-%%
-%%   Finds a submit_type_rule depending on what rules are available.
-%%   If none are available, use project_default_submit_type/1.
-%%
-:- public locate_submit_type/1.
-%%
-locate_submit_type(RuleName) :-
-  locate_helper(submit_type, project_default_submit_type, 1, RuleName).
-
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% default_submit/1:
-%%
-:- public default_submit/1.
-%%
-default_submit(P) :-
-  get_legacy_label_types(LabelTypes),
-  default_submit(LabelTypes, P).
-
-% Apply the old "all approval categories must be satisfied"
-% loop by scanning over all of the label types to build up the
-% submit record.
-%
-default_submit(LabelTypes, P) :-
-  default_submit(LabelTypes, [], Tmp),
-  reverse(Tmp, Ls),
-  P =.. [ submit | Ls].
-
-default_submit([], Out, Out).
-default_submit([Type | Types], Tmp, Out) :-
-  label_type(Label, Fun, Min, Max) = Type,
-  legacy_submit_rule(Fun, Label, Min, Max, Status),
-  R = label(Label, Status),
-  default_submit(Types, [R | Tmp], Out).
-
-
-%% legacy_submit_rule:
-%%
-%% Apply the old -2..+2 style logic.
-%%
-legacy_submit_rule('MaxWithBlock', Label, Min, Max, T) :- !, max_with_block(Label, Min, Max, T).
-legacy_submit_rule('AnyWithBlock', Label, Min, Max, T) :- !, any_with_block(Label, Min, T).
-legacy_submit_rule('MaxNoBlock', Label, Min, Max, T) :- !, max_no_block(Label, Max, T).
-legacy_submit_rule('NoBlock', Label, Min, Max, T) :- !, T = may(_).
-legacy_submit_rule('NoOp', Label, Min, Max, T) :- !, T = may(_).
-legacy_submit_rule('PatchSetLock', Label, Min, Max, T) :- !, T = may(_).
-legacy_submit_rule(Fun, Label, Min, Max, T) :- T = impossible(unsupported(Fun)).
-
-%% max_with_block:
-%%
-%% - The minimum is never used.
-%% - At least one maximum is used.
-%%
-:- public max_with_block/4.
-%%
-max_with_block(Min, Max, Label, label(Label, S)) :-
-  number(Min), number(Max), atom(Label),
-  !,
-  max_with_block(Label, Min, Max, S).
-max_with_block(Label, Min, Max, reject(Who)) :-
-  check_label_range_permission(Label, Min, ok(Who)),
-  !
-  .
-max_with_block(Label, Min, Max, ok(Who)) :-
-  \+ check_label_range_permission(Label, Min, ok(_)),
-  check_label_range_permission(Label, Max, ok(Who)),
-  !
-  .
-max_with_block(Label, Min, Max, need(Max)) :-
-  true
-  .
-
-%TODO Uncomment this clause when group suggesting is possible.
-%max_with_block(Label, Min, Max, need(Max, Group)) :-
-%  \+ check_label_range_permission(Label, Max, ok(_)),
-%  check_label_range_permission(Label, Max, ask(Group))
-%  .
-%max_with_block(Label, Min, Max, impossible(no_access)) :-
-%  \+ check_label_range_permission(Label, Max, ask(Group))
-%  .
-
-%% any_with_block:
-%%
-%% - The maximum is never used.
-%%
-any_with_block(Label, Min, reject(Who)) :-
-  Min < 0,
-  check_label_range_permission(Label, Min, ok(Who)),
-  !
-  .
-any_with_block(Label, Min, may(_)).
-
-
-%% max_no_block:
-%%
-%% - At least one maximum is used.
-%%
-max_no_block(Max, Label, label(Label, S)) :-
-  number(Max), atom(Label),
-  !,
-  max_no_block(Label, Max, S).
-max_no_block(Label, Max, ok(Who)) :-
-  check_label_range_permission(Label, Max, ok(Who)),
-  !
-  .
-max_no_block(Label, Max, need(Max)) :-
-  true
-  .
-%TODO Uncomment this clause when group suggesting is possible.
-%max_no_block(Label, Max, need(Max, Group)) :-
-%  check_label_range_permission(Label, Max, ask(Group))
-%  .
-%max_no_block(Label, Max, impossible(no_access)) :-
-%  \+ check_label_range_permission(Label, Max, ask(Group))
-%  .
-
-
-%% check_label_range_permission:
-%%
-check_label_range_permission(Label, ExpValue, ok(Who)) :-
-  commit_label(label(Label, ExpValue), Who),
-  check_user_label(Label, Who, ExpValue)
-  .
-%TODO Uncomment this clause when group suggesting is possible.
-%check_label_range_permission(Label, ExpValue, ask(Group)) :-
-%  grant_range(Label, Group, Min, Max),
-%  Min @=< ExpValue, ExpValue @=< Max
-%  .
-
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% filter_submit_results/3:
-%%
-%%   Executes the submit_filter against the given list of results,
-%%   returns a list of filtered results.
-%%
-:- public filter_submit_results/3.
-%%
-filter_submit_results(Filter, In, Out) :-
-    filter_submit_results(Filter, In, [], Tmp),
-    reverse(Tmp, Out).
-filter_submit_results(Filter, [I | In], Tmp, Out) :-
-    arg(1, I, R),
-    call_submit_filter(Filter, R, S),
-    !,
-    S =.. [submit | Ls],
-    ( is_all_ok(Ls) -> T = ok(S) ; T = not_ready(S) ),
-    filter_submit_results(Filter, In, [T | Tmp], Out).
-filter_submit_results(Filter, [_ | In], Tmp, Out) :-
-   filter_submit_results(Filter, In, Tmp, Out),
-   !
-   .
-filter_submit_results(Filter, [], Out, Out).
-
-call_submit_filter(P:X, R, S) :- !, F =.. [X, R, S], P:F.
-call_submit_filter(X, R, S) :- F =.. [X, R, S], F.
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% filter_submit_type_results/3:
-%%
-%%   Executes the submit_type_filter against the result,
-%%   returns the filtered result.
-%%
-:- public filter_submit_type_results/3.
-%%
-filter_submit_type_results(Filter, In, Out) :- call_submit_filter(Filter, In, Out).
-
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% locate_submit_filter/1:
-%%
-%%   Finds a submit_filter if available.
-%%
-:- public locate_submit_filter/1.
-%%
-locate_submit_filter(FilterName) :-
-  locate_helper(submit_filter, noop_filter, 2, FilterName).
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% noop_filter/2:
-%%
-:- public noop_filter/2.
-%%
-noop_filter(In, In).
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% locate_submit_type_filter/1:
-%%
-%%   Finds a submit_type_filter if available.
-%%
-:- public locate_submit_type_filter/1.
-%%
-locate_submit_type_filter(FilterName) :-
-  locate_helper(submit_type_filter, noop_filter, 2, FilterName).
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% find_label/3:
-%%
-%%   Finds labels successively and fails when there are no more results.
-%%
-:- public find_label/3.
-%%
-find_label([], _, _) :- !, fail.
-find_label(List, Name, Label) :-
-  List = [_ | _],
-  !,
-  find_label2(List, Name, Label).
-find_label(S, Name, Label) :-
-  S =.. [submit | Ls],
-  find_label2(Ls, Name, Label).
-
-find_label2([L | _ ], Name, L) :- L = label(Name, _).
-find_label2([_ | Ls], Name, L) :- find_label2(Ls, Name, L).
-
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% remove_label/3:
-%%
-%%   Removes all occurances of label(Name, Status).
-%%
-:- public remove_label/3.
-%%
-remove_label([], _, []) :- !.
-remove_label(List, Label, Out) :-
-  List = [_ | _],
-  !,
-  subtract1(List, Label, Out).
-remove_label(S, Label, Out) :-
-  S =.. [submit | Ls],
-  subtract1(Ls, Label, Tmp),
-  Out =.. [submit | Tmp].
-
-subtract1([], _, []) :- !.
-subtract1([E | L], E, R) :- !, subtract1(L, E, R).
-subtract1([H | L], E, [H | R]) :- subtract1(L, E, R).
-
-
-%% commit_author/1:
-%%
-:- public commit_author/1.
-%%
-commit_author(Author) :-
-  commit_author(Author, _, _).
-
-
-%% commit_committer/1:
-%%
-:- public commit_committer/1.
-%%
-commit_committer(Committer) :-
-  commit_committer(Committer, _, _).
-
-
-%% commit_delta/1:
-%%
-:- public commit_delta/1.
-%%
-commit_delta(Regex) :-
-  once(commit_delta(Regex, _, _, _)).
-
-
-%% commit_delta/3:
-%%
-:- public commit_delta/3.
-%%
-commit_delta(Regex, Type, Path) :-
-  commit_delta(Regex, TmpType, NewPath, OldPath),
-  split_commit_delta(TmpType, NewPath, OldPath, Type, Path).
-
-split_commit_delta(rename, NewPath, OldPath, delete, OldPath).
-split_commit_delta(rename, NewPath, OldPath, add, NewPath) :- !.
-split_commit_delta(copy, NewPath, OldPath, add, NewPath) :- !.
-split_commit_delta(Type, Path, _, Type, Path).
-
-
-%% commit_message_matches/1:
-%%
-:- public commit_message_matches/1.
-%%
-commit_message_matches(Pattern) :-
-  commit_message(Msg),
-  regex_matches(Pattern, Msg).
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
deleted file mode 100644
index 50c5fc3..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * .Abandoned template will determine the contents of the email related to a
- * change being abandoned.
- * @param change
- * @param coverLetter
- * @param email
- * @param fromName
- */
-{template .Abandoned autoescape="strict" kind="text"}
-  {$fromName} has abandoned this change.
-  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
-  {\n}
-  Change subject: {$change.subject}{\n}
-  ......................................................................{\n}
-  {if $coverLetter}
-    {\n}
-    {\n}
-    {$coverLetter}
-    {\n}
-  {/if}
-{/template}
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
deleted file mode 100644
index c7d4699..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * @param coverLetter
- * @param email
- * @param fromName
- */
-{template .AbandonedHtml autoescape="strict" kind="html"}
-  <p>
-    {$fromName} <strong>abandoned</strong> this change.
-  </p>
-
-  {if $email.changeUrl}
-    <p>
-      {call .ViewChangeButton data="all" /}
-    </p>
-  {/if}
-
-  {if $coverLetter}
-    <div style="white-space:pre-wrap">{$coverLetter}</div>
-  {/if}
-{/template}
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
deleted file mode 100644
index aa2b27d..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .AddKey template will determine the contents of the email related to
- * adding a new SSH or GPG key to an account.
- * @param email
- */
-{template .AddKey autoescape="strict" kind="text"}
-  One or more new {$email.keyType} keys have been added to Gerrit Code Review at
-  {sp}{$email.gerritHost}:
-
-  {\n}
-  {\n}
-
-  {if $email.sshKey}
-    {$email.sshKey}
-  {elseif $email.gpgKeys}
-    {$email.gpgKeys}
-  {/if}
-
-  {\n}
-  {\n}
-
-  If this is not expected, please contact your Gerrit Administrators
-  immediately.
-
-  {\n}
-  {\n}
-
-  You can also manage your {$email.keyType} keys by visiting
-  {\n}
-  {if $email.sshKey}
-    {$email.gerritUrl}#/settings/ssh-keys
-  {elseif $email.gpgKeys}
-    {$email.gerritUrl}#/settings/gpg-keys
-  {/if}
-  {\n}
-  {if $email.userNameEmail}
-    (while signed in as {$email.userNameEmail})
-  {else}
-    (while signed in as {$email.email})
-  {/if}
-
-  {\n}
-  {\n}
-
-  If clicking the link above does not work, copy and paste the URL in a new
-  browser window instead.
-
-  {\n}
-  {\n}
-
-  This is a send-only email address.  Replies to this message will not be read
-  or answered.
-{/template}
\ No newline at end of file
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
deleted file mode 100644
index 017fd6d..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
+++ /dev/null
@@ -1,66 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * @param email
- */
-{template .AddKeyHtml autoescape="strict" kind="html"}
-  <p>
-    One or more new {$email.keyType} keys have been added to Gerrit Code Review
-    at {$email.gerritHost}:
-  </p>
-
-  {let $keyStyle kind="css"}
-    background: #f0f0f0;
-    border: 1px solid #ccc;
-    color: #555;
-    padding: 12px;
-    width: 400px;
-  {/let}
-
-  {if $email.sshKey}
-    <pre style="{$keyStyle}">{$email.sshKey}</pre>
-  {elseif $email.gpgKeys}
-    <pre style="{$keyStyle}">{$email.gpgKeys}</pre>
-  {/if}
-
-  <p>
-    If this is not expected, please contact your Gerrit Administrators
-    immediately.
-  </p>
-
-  <p>
-    You can also manage your {$email.keyType} keys by following{sp}
-    {if $email.sshKey}
-      <a href="{$email.gerritUrl}#/settings/ssh-keys">this link</a>
-    {elseif $email.gpgKeys}
-      <a href="{$email.gerritUrl}#/settings/gpg-keys">this link</a>
-    {/if}
-    {sp}
-    {if $email.userNameEmail}
-      (while signed in as {$email.userNameEmail})
-    {else}
-      (while signed in as {$email.email})
-    {/if}.
-  </p>
-
-  <p>
-    This is a send-only email address.  Replies to this message will not be read
-    or answered.
-  </p>
-{/template}
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
deleted file mode 100644
index 37ac126..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .ChangeFooter template will determine the contents of the footer text
- * that will be appended to ALL emails related to changes.
- * @param email
- */
-{template .ChangeFooter autoescape="strict" kind="text"}
-  --{sp}
-  {\n}
-
-  {if $email.changeUrl}
-    To view, visit {$email.changeUrl}{\n}
-  {/if}
-
-  {if $email.settingsUrl}
-    To unsubscribe, or for help writing mail filters,{sp}
-    visit {$email.settingsUrl}{\n}
-  {/if}
-
-  {if $email.changeUrl or $email.settingsUrl}
-    {\n}
-  {/if}
-{/template}
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
deleted file mode 100644
index 00f21db..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * @param change
- * @param email
- */
-{template .ChangeFooterHtml autoescape="strict" kind="html"}
-  {if $email.changeUrl or $email.settingsUrl}
-    <p>
-      {if $email.changeUrl}
-        To view, visit{sp}
-        <a href="{$email.changeUrl}">change {$change.changeNumber}</a>.
-      {/if}
-      {if $email.changeUrl and $email.settingsUrl}{sp}{/if}
-      {if $email.settingsUrl}
-        To unsubscribe, or for help writing mail filters,{sp}
-        visit <a href="{$email.settingsUrl}">settings</a>.
-      {/if}
-    </p>
-  {/if}
-
-  {if $email.changeUrl}
-    <div itemscope itemtype="http://schema.org/EmailMessage">
-      <div itemscope itemprop="action" itemtype="http://schema.org/ViewAction">
-        <link itemprop="url" href="{$email.changeUrl}"/>
-        <meta itemprop="name" content="View Change"/>
-      </div>
-    </div>
-  {/if}
-{/template}
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
deleted file mode 100644
index 98de6e7..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .ChangeSubject template will determine the contents of the email subject
- * line for ALL emails related to changes.
- * @param branch
- * @param change
- * @param shortProjectName
- */
-{template .ChangeSubject autoescape="strict" 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
deleted file mode 100644
index 7bedc1c..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .Comment template will determine the contents of the email related to a
- * user submitting comments on changes.
- * @param change
- * @param coverLetter
- * @param email
- * @param fromName
- * @param commentFiles
- */
-{template .Comment autoescape="strict" kind="text"}
-  {$fromName} has posted comments on this change.
-  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
-  {\n}
-  Change subject: {$change.subject}{\n}
-  ......................................................................{\n}
-  {if $coverLetter}
-    {\n}
-    {\n}
-    {$coverLetter}{\n}
-    {\n}
-  {/if}
-
-  {foreach $group in $commentFiles}
-    {$group.link}{\n}
-    {$group.title}:{\n}
-    {\n}
-
-    {foreach $comment in $group.comments}
-      {if $comment.isRobotComment}
-        Robot Comment from {$comment.robotId} (run ID {$comment.robotRunId}):
-        {\n}
-      {/if}
-
-      {foreach $line in $comment.lines}
-        {if isFirst($line)}
-          {if $comment.startLine != 0}
-            {$comment.link}
-          {/if}{\n}
-          {$comment.linePrefix}
-        {else}
-          {$comment.linePrefixEmpty}
-        {/if}
-        {$line}{\n}
-      {/foreach}
-      {if length($comment.lines) == 0}
-        {$comment.linePrefix}{\n}
-      {/if}
-
-      {if $comment.parentMessage}
-        >{sp}{$comment.parentMessage}{\n}
-      {/if}
-      {$comment.message}{\n}
-      {\n}
-      {\n}
-    {/foreach}
-  {/foreach}
-  {\n}
-{/template}
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
deleted file mode 100644
index 73fdfba..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .CommentFooter template will determine the contents of the footer text
- * that will be appended to emails related to a user submitting comments on
- * changes.
- */
-{template .CommentFooter autoescape="strict" 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
deleted file mode 100644
index 7bf28e7..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-{template .CommentFooterHtml autoescape="strict" kind="html"}
-{/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
deleted file mode 100644
index 870ad46..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ /dev/null
@@ -1,175 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * @param commentFiles
- * @param commentCount
- * @param email
- * @param labels
- * @param patchSet
- * @param patchSetCommentBlocks
- */
-{template .CommentHtml autoescape="strict" kind="html"}
-  {let $commentHeaderStyle kind="css"}
-    margin-bottom: 4px;
-  {/let}
-
-  {let $blockquoteStyle kind="css"}
-    border-left: 1px solid #aaa;
-    margin: 10px 0;
-    padding: 0 10px;
-  {/let}
-
-  {let $ulStyle kind="css"}
-    list-style: none;
-    padding: 0;
-  {/let}
-
-  {let $fileLiStyle kind="css"}
-    margin: 0;
-    padding: 0;
-  {/let}
-
-  {let $commentLiStyle kind="css"}
-    margin: 0;
-    padding: 0 0 0 16px;
-  {/let}
-
-  {let $voteStyle kind="css"}
-    border-radius: 3px;
-    display: inline-block;
-    margin: 0 2px;
-    padding: 4px;
-  {/let}
-
-  {let $positiveVoteStyle kind="css"}
-    {$voteStyle}
-    background-color: #d4ffd4;
-  {/let}
-
-  {let $negativeVoteStyle kind="css"}
-    {$voteStyle}
-    background-color: #ffd4d4;
-  {/let}
-
-  {let $neutralVoteStyle kind="css"}
-    {$voteStyle}
-    background-color: #ddd;
-  {/let}
-
-  {if $patchSetCommentBlocks}
-    {call .WikiFormat}{param content: $patchSetCommentBlocks /}{/call}
-  {/if}
-
-  {if length($labels) > 0}
-    <p>
-      Patch set {$patchSet.patchSetId}:
-      {foreach $label in $labels}
-        {if $label.value > 0}
-          <span style="{$positiveVoteStyle}">
-            {$label.label}{sp}+{$label.value}
-          </span>
-        {elseif $label.value < 0}
-          <span style="{$negativeVoteStyle}">
-            {$label.label}{sp}{$label.value}
-          </span>
-        {else}
-          <span style="{$neutralVoteStyle}">
-            -{$label.label}
-          </span>
-        {/if}
-      {/foreach}
-    </p>
-  {/if}
-
-  {if $email.changeUrl}
-    <p>
-      {call .ViewChangeButton data="all" /}
-    </p>
-  {/if}
-
-  {if $commentCount == 1}
-    <p>1 comment:</p>
-  {elseif $commentCount > 1}
-    <p>{$commentCount} comments:</p>
-  {/if}
-
-  <ul style="{$ulStyle}">
-    {foreach $group in $commentFiles}
-      <li style="{$fileLiStyle}">
-        <p>
-          <a href="{$group.link}">{$group.title}:</a>
-        </p>
-
-        <ul style="{$ulStyle}">
-          {foreach $comment in $group.comments}
-            <li style="{$commentLiStyle}">
-              {if $comment.isRobotComment}
-                <p style="{$commentHeaderStyle}">
-                  Robot Comment from{sp}
-                  {if $comment.robotUrl}<a href="{$comment.robotUrl}">{/if}
-                  {$comment.robotId}
-                  {if $comment.robotUrl}</a>{/if}{sp}
-                  (run ID {$comment.robotRunId}):
-                </p>
-              {/if}
-
-              <p style="{$commentHeaderStyle}">
-                <a href="{$comment.link}">
-                  {if $comment.startLine == 0}
-                    Patch Set #{$group.patchSetId}:
-                  {else}
-                    Patch Set #{$group.patchSetId},{sp}
-                    Line {$comment.startLine}:
-                  {/if}
-                </a>{sp}
-                {if length($comment.lines) == 1}
-                  <code style="font-family:monospace,monospace">
-                    {$comment.lines[0]}
-                  </code>
-                {/if}
-              </p>
-
-              {if length($comment.lines) > 1}
-                <p>
-                  <blockquote style="{$blockquoteStyle}">
-                    {call .Pre}{param content kind="html"}
-                      {foreach $line in $comment.lines}
-                        {$line}{\n}
-                      {/foreach}
-                    {/param}{/call}
-                  </blockquote>
-                </p>
-              {/if}
-
-              {if $comment.parentMessage}
-                <p>
-                  <blockquote style="{$blockquoteStyle}">
-                    {$comment.parentMessage}
-                  </blockquote>
-                </p>
-              {/if}
-
-              {call .WikiFormat}{param content: $comment.messageBlocks /}{/call}
-            </li>
-          {/foreach}
-        </ul>
-      </li>
-    {/foreach}
-  </ul>
-{/template}
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
deleted file mode 100644
index 888ee4b..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .DeleteReviewer template will determine the contents of the email related
- * to removal of a reviewer (and the reviewer's votes) from reviews.
- * @param change
- * @param coverLetter
- * @param email
- * @param fromName
- */
-{template .DeleteReviewer autoescape="strict" kind="text"}
-  {$fromName} has removed{sp}
-  {foreach $reviewerName in $email.reviewerNames}
-    {if not isFirst($reviewerName)},{sp}{/if}
-    {$reviewerName}
-  {/foreach}{sp}
-  from this change.{sp}
-  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
-  {\n}
-  Change subject: {$change.subject}{\n}
-  ......................................................................{\n}
-  {if $coverLetter}
-    {\n}
-    {\n}
-    {$coverLetter}
-    {\n}
-  {/if}
-{/template}
\ No newline at end of file
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
deleted file mode 100644
index 5faa411..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * @param email
- * @param fromName
- */
-{template .DeleteReviewerHtml autoescape="strict" kind="html"}
-  <p>
-    {$fromName}{sp}
-    <strong>
-      removed{sp}
-      {foreach $reviewerName in $email.reviewerNames}
-        {if not isFirst($reviewerName)}
-          {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
-        {/if}
-        {$reviewerName}
-      {/foreach}
-    </strong>{sp}
-    from this change.
-  </p>
-
-  {if $email.changeUrl}
-    <p>
-      {call .ViewChangeButton data="all" /}
-    </p>
-  {/if}
-{/template}
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
deleted file mode 100644
index b249ded..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .DeleteVote template will determine the contents of the email related
- * to removing votes on changes.
- * @param change
- * @param coverLetter
- * @param fromName
- */
-{template .DeleteVote autoescape="strict" kind="text"}
-  {$fromName} has removed a vote on this change.{\n}
-  {\n}
-  Change subject: {$change.subject}{\n}
-  ......................................................................{\n}
-  {if $coverLetter}
-    {\n}
-    {\n}
-    {$coverLetter}
-    {\n}
-  {/if}
-{/template}
\ No newline at end of file
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
deleted file mode 100644
index 3d76ae2..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * @param coverLetter
- * @param email
- * @param fromName
- */
-{template .DeleteVoteHtml autoescape="strict" kind="html"}
-  <p>
-    {$fromName} <strong>removed a vote</strong> from this change.
-  </p>
-
-  {if $email.changeUrl}
-    <p>
-      {call .ViewChangeButton data="all" /}
-    </p>
-  {/if}
-
-  {if $coverLetter}
-    <div style="white-space:pre-wrap">{$coverLetter}</div>
-  {/if}
-{/template}
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
deleted file mode 100644
index 24db2fd..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
-*/
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .Footer template will determine the contents of the footer text
- * appended to the end of all outgoing emails after the ChangeFooter and
- * CommentFooter.
- * @param footers
- */
-{template .Footer autoescape="strict" kind="text"}
-  {foreach $footer in $footers}
-    {$footer}{\n}
-  {/foreach}
-{/template}
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
deleted file mode 100644
index 9f9c503..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
-*/
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * @param footers
- */
-{template .FooterHtml autoescape="strict" kind="html"}
-  {\n}
-  {\n}
-  {foreach $footer in $footers}
-    <div style="display:none">{sp}{$footer}{sp}</div>{\n}
-  {/foreach}
-  {\n}
-{/template}
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
deleted file mode 100644
index fdc3fee..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
-*/
-
-{namespace com.google.gerrit.server.mail.template}
-
-{template .HeaderHtml autoescape="strict" kind="html"}
-{/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
deleted file mode 100644
index d483264..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy
+++ /dev/null
@@ -1,42 +0,0 @@
-
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .Merged template will determine the contents of the email related to
- * a change successfully merged to the head.
- * @param change
- * @param email
- * @param fromName
- */
-{template .Merged autoescape="strict" kind="text"}
-  {$fromName} has submitted this change and it was merged.
-  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
-  {\n}
-  Change subject: {$change.subject}{\n}
-  ......................................................................{\n}
-  {\n}
-  {$email.changeDetail}
-  {$email.approvals}
-  {if $email.includeDiff}
-    {\n}
-    {\n}
-    {$email.unifiedDiff}
-    {\n}
-  {/if}
-{/template}
\ No newline at end of file
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
deleted file mode 100644
index 927601b..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * @param diffLines
- * @param email
- * @param fromName
- */
-{template .MergedHtml autoescape="strict" kind="html"}
-  <p>
-    {$fromName} <strong>merged</strong> this change.
-  </p>
-
-  {if $email.changeUrl}
-    <p>
-      {call .ViewChangeButton data="all" /}
-    </p>
-  {/if}
-
-  <div style="white-space:pre-wrap">{$email.approvals}</div>
-
-  {call .Pre}{param content: $email.changeDetail /}{/call}
-
-  {if $email.includeDiff}
-    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
-  {/if}
-{/template}
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
deleted file mode 100644
index 9f7429f..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .NewChange template will determine the contents of the email related to a
- * user submitting a new change for review.
- * @param change
- * @param email
- * @param ownerName
- * @param patchSet
- * @param projectName
- */
-{template .NewChange autoescape="strict" kind="text"}
-  {if $email.reviewerNames}
-    Hello{sp}
-    {foreach $reviewerName in $email.reviewerNames}
-      {if not isFirst($reviewerName)},{sp}{/if}
-      {$reviewerName}
-    {/foreach},
-
-    {\n}
-    {\n}
-
-    I'd like you to do a code review.
-
-    {if $email.changeUrl}
-      {sp}Please visit
-
-      {\n}
-      {\n}
-
-      {sp}{sp}{sp}{sp}{$email.changeUrl}
-
-      {\n}
-      {\n}
-
-      to review the following change.
-    {/if}
-  {else}
-    {$ownerName} has uploaded this change for review.
-    {if $email.changeUrl} ( {$email.changeUrl}{/if}
-  {/if}{\n}
-
-  {\n}
-  {\n}
-
-  Change subject: {$change.subject}{\n}
-  ......................................................................{\n}
-
-  {\n}
-
-  {$email.changeDetail}{\n}
-
-  {if $email.sshHost}
-    {\n}
-    {sp}{sp}git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
-        {sp}{$patchSet.refName}
-    {\n}
-  {/if}
-
-  {if $email.includeDiff}
-    {\n}
-    {$email.unifiedDiff}
-    {\n}
-  {/if}
-{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
deleted file mode 100644
index 8026666..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * @param diffLines
- * @param email
- * @param fromName
- * @param ownerName
- * @param patchSet
- * @param projectName
- */
-{template .NewChangeHtml autoescape="strict" kind="html"}
-  <p>
-    {if $email.reviewerNames}
-      {$fromName} would like{sp}
-      {foreach $reviewerName in $email.reviewerNames}
-        {if not isFirst($reviewerName)}
-          {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
-        {/if}
-        {$reviewerName}
-      {/foreach}{sp}
-      to <strong>review</strong> this change.
-    {else}
-      {$ownerName} has uploaded this change for <strong>review</strong>.
-    {/if}
-  </p>
-
-  {if $email.changeUrl}
-    <p>
-      {call .ViewChangeButton data="all" /}
-    </p>
-  {/if}
-
-  {call .Pre}{param content: $email.changeDetail /}{/call}
-
-  {if $email.sshHost}
-    {call .Pre}{param content kind="html"}
-      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
-          {sp}{$patchSet.refName}
-    {/param}{/call}
-  {/if}
-
-  {if $email.includeDiff}
-    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
-  {/if}
-{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
deleted file mode 100644
index b26535b..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
+++ /dev/null
@@ -1,121 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/*
- * Private templates that cannot be overridden.
- */
-
-/**
- * Private template to generate "View Change" buttons.
- * @param email
- */
-{template .ViewChangeButton autoescape="strict" kind="html"}
-  <a href="{$email.changeUrl}">View Change</a>
-{/template}
-
-/**
- * Private template to render PRE block with consistent font-sizing.
- * @param content
- */
-{template .Pre autoescape="strict" kind="html"}
-  {let $preStyle kind="css"}
-    font-family: monospace,monospace; // Use this to avoid browsers scaling down
-                                      // monospace text.
-    white-space: pre-wrap;
-  {/let}
-  <pre style="{$preStyle}">{$content|changeNewlineToBr}</pre>
-{/template}
-
-/**
- * Take a list of unescaped comment blocks and emit safely escaped HTML to
- * render it nicely with wiki-like format.
- *
- * Each block is a map with a type key. When the type is 'paragraph', or 'pre',
- * it also has a 'text' key that maps to the unescaped text content for the
- * block. If the type is 'list', the map will have a 'items' key which maps to
- * list of unescaped list item strings. If the type is quote, the map will have
- * a 'quotedBlocks' key which maps to the blocks contained within the quote.
- *
- * This mechanism encodes as little structure as possible in order to depend on
- * the Soy autoescape mechanism for all of the content.
- *
- * @param content
- */
-{template .WikiFormat autoescape="strict" kind="html"}
-  {let $blockquoteStyle kind="css"}
-    border-left: 1px solid #aaa;
-    margin: 10px 0;
-    padding: 0 10px;
-  {/let}
-
-  {let $pStyle kind="css"}
-    white-space: pre-wrap;
-    word-wrap: break-word;
-  {/let}
-
-  {foreach $block in $content}
-    {if $block.type == 'paragraph'}
-      <p style="{$pStyle}">{$block.text|changeNewlineToBr}</p>
-    {elseif $block.type == 'quote'}
-      <blockquote style="{$blockquoteStyle}">
-        {call .WikiFormat}{param content: $block.quotedBlocks /}{/call}
-      </blockquote>
-    {elseif $block.type == 'pre'}
-      {call .Pre}{param content: $block.text /}{/call}
-    {elseif $block.type == 'list'}
-      <ul>
-        {foreach $item in $block.items}
-          <li>{$item}</li>
-        {/foreach}
-      </ul>
-    {/if}
-  {/foreach}
-{/template}
-
-/**
- * @param diffLines
- */
-{template .UnifiedDiff autoescape="strict" kind="html"}
-  {let $addStyle kind="css"}
-    color: hsl(120, 100%, 40%);
-  {/let}
-
-  {let $removeStyle kind="css"}
-    color: hsl(0, 100%, 40%);
-  {/let}
-
-  {let $preStyle kind="css"}
-    font-family: monospace,monospace; // Use this to avoid browsers scaling down
-                                      // monospace text.
-    white-space: pre-wrap;
-  {/let}
-
-  <pre style="{$preStyle}">
-    {foreach $line in $diffLines}
-      {if $line.type == 'add'}
-        <span style="{$addStyle}">
-      {elseif $line.type == 'remove'}
-        <span style="{$removeStyle}">
-      {else}
-        <span>
-      {/if}
-        {$line.text}
-      </span><br>
-    {/foreach}
-  </pre>
-{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
deleted file mode 100644
index 2b30ae6..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .RegisterNewEmail template will determine the contents of the email
- * related to registering new email accounts.
- * @param email
- */
-{template .RegisterNewEmail autoescape="strict" kind="text"}
-  Welcome to Gerrit Code Review at {$email.gerritHost}.{\n}
-
-  {\n}
-
-  To add a verified email address to your user account, please{\n}
-  click on the following link
-  {if $email.userNameEmail}
-    {sp}while signed in as {$email.userNameEmail}
-  {/if}:{\n}
-
-  {\n}
-
-  {$email.gerritUrl}#/VE/{$email.emailRegistrationToken}{\n}
-
-  {\n}
-
-  If you have received this mail in error, you do not need to take any{\n}
-  action to cancel the account.  The address will not be activated, and{\n}
-  you will not receive any further emails.{\n}
-
-  {\n}
-
-  If clicking the link above does not work, copy and paste the URL in a{\n}
-  new browser window instead.{\n}
-
-  {\n}
-
-  This is a send-only email address.  Replies to this message will not{\n}
-  be read or answered.{\n}
-{/template}
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
deleted file mode 100644
index e41bdda..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .ReplacePatchSet template will determine the contents of the email
- * related to a user submitting a new patchset for a change.
- * @param change
- * @param email
- * @param fromEmail
- * @param fromName
- * @param patchSet
- * @param projectName
- */
-{template .ReplacePatchSet autoescape="strict" kind="text"}
-  {if $email.reviewerNames and $fromEmail == $change.ownerEmail}
-    Hello{sp}
-    {foreach $reviewerName in $email.reviewerNames}
-      {$reviewerName},{sp}
-    {/foreach}{\n}
-    {\n}
-    I'd like you to reexamine a change.
-    {if $email.changeUrl}
-      {sp}Please visit
-      {\n}
-      {\n}
-      {sp}{sp}{sp}{sp}{$email.changeUrl}
-      {\n}
-      {\n}
-      to look at the new patch set (#{$patchSet.patchSetId}).
-    {/if}
-  {else}
-    {$fromName} has uploaded a new patch set (#{$patchSet.patchSetId})
-    {if $fromEmail != $change.ownerEmail}
-      {sp}to the change originally created by {$change.ownerName}
-    {/if}.
-    {if $email.changeUrl} ( {$email.changeUrl} ){/if}
-  {/if}{\n}
-  {\n}
-  Change subject: {$change.subject}{\n}
-  ......................................................................{\n}
-  {\n}
-  {$email.changeDetail}{\n}
-  {if $email.sshHost}
-    {sp}{sp}git pull ssh:{print '//'}{$email.sshHost}/{$projectName}{sp}
-        {$patchSet.refName}
-    {\n}
-  {/if}
-{/template}
\ No newline at end of file
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
deleted file mode 100644
index 05c60a1..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * @param change
- * @param email
- * @param fromName
- * @param fromEmail
- * @param patchSet
- * @param projectName
- */
-{template .ReplacePatchSetHtml autoescape="strict" kind="html"}
-  <p>
-    {$fromName} <strong>uploaded patch set #{$patchSet.patchSetId}</strong>{sp}
-    to{sp}
-    {if $fromEmail == $change.ownerEmail}
-      this change.
-    {else}
-      the change originally created by {$change.ownerName}.
-    {/if}
-  </p>
-
-  {if $email.changeUrl}
-    <p>
-      {call .ViewChangeButton data="all" /}
-    </p>
-  {/if}
-
-  {call .Pre}{param content: $email.changeDetail /}{/call}
-
-  {if $email.sshHost}
-    {call .Pre}{param content kind="html"}
-      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}{sp}
-          {$patchSet.refName}
-    {/param}{/call}
-  {/if}
-{/template}
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
deleted file mode 100644
index 14ae0f3..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .Restored template will determine the contents of the email related to a
- * change being restored.
- * @param change
- * @param coverLetter
- * @param email
- * @param fromName
- */
-{template .Restored autoescape="strict" kind="text"}
-  {$fromName} has restored this change.
-  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
-  {\n}
-  Change subject: {$change.subject}{\n}
-  ......................................................................{\n}
-  {if $coverLetter}
-    {\n}
-    {\n}
-    {$coverLetter}
-    {\n}
-  {/if}
-{/template}
\ No newline at end of file
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
deleted file mode 100644
index ea4f615..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * @param email
- * @param fromName
- */
-{template .RestoredHtml autoescape="strict" kind="html"}
-  <p>
-    {$fromName} <strong>restored</strong> this change.
-  </p>
-
-  {if $email.changeUrl}
-    <p>
-      {call .ViewChangeButton data="all" /}
-    </p>
-  {/if}
-{/template}
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
deleted file mode 100644
index 7f74df9..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .Reverted template will determine the contents of the email related
- * to a change being reverted.
- * @param change
- * @param coverLetter
- * @param email
- * @param fromName
- */
-{template .Reverted autoescape="strict" kind="text"}
-  {$fromName} has reverted this change.
-  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
-  {\n}
-  Change subject: {$change.subject}{\n}
-  ......................................................................{\n}
-  {if $coverLetter}
-    {\n}
-    {\n}
-    {$coverLetter}
-    {\n}
-  {/if}
-{/template}
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
deleted file mode 100644
index d6407e7..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * @param email
- * @param fromName
- */
-{template .RevertedHtml autoescape="strict" kind="html"}
-  <p>
-    {$fromName} <strong>reverted</strong> this change.
-  </p>
-
-  {if $email.changeUrl}
-    <p>
-      {call .ViewChangeButton data="all" /}
-    </p>
-  {/if}
-{/template}
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
deleted file mode 100644
index ca4f267..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssignee.soy
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * The .SetAssignee template will determine the contents of the email related
- * to a user being assigned to a change.
- * @param change
- * @param email
- * @param fromName
- * @param patchSet
- * @param projectName
- */
-{template .SetAssignee autoescape="strict" kind="text"}
-  Hello{sp}
-  {$email.assigneeName},
-
-  {\n}
-  {\n}
-
-  {$fromName} has assigned a change to you.
-
-  {sp}Please visit
-
-  {\n}
-  {\n}
-
-  {sp}{sp}{sp}{sp}{$email.changeUrl}
-
-  {\n}
-  {\n}
-
-  to view the change.
-
-  {\n}
-  {\n}
-
-  Change subject: {$change.subject}{\n}
-  ......................................................................{\n}
-
-  {\n}
-
-  {$email.changeDetail}{\n}
-
-  {if $email.sshHost}
-    {\n}
-    {sp}{sp}git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
-        {sp}{$patchSet.refName}
-    {\n}
-  {/if}
-
-  {if $email.includeDiff}
-    {\n}
-    {$email.unifiedDiff}
-    {\n}
-  {/if}
-{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
deleted file mode 100644
index 31cfbd6..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template}
-
-/**
- * @param diffLines
- * @param email
- * @param fromName
- * @param patchSet
- * @param projectName
- */
-{template .SetAssigneeHtml autoescape="strict" kind="html"}
-  <p>
-    {$fromName} has <strong>assigned</strong> a change to{sp}
-    {$email.assigneeName}.{sp}
-  </p>
-
-  {if $email.changeUrl}
-    <p>
-      {call .ViewChangeButton data="all" /}
-    </p>
-  {/if}
-
-  {call .Pre}{param content: $email.changeDetail /}{/call}
-
-  {if $email.sshHost}
-    {call .Pre}{param content kind="html"}
-      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
-          {sp}{$patchSet.refName}
-    {/param}{/call}
-  {/if}
-
-  {if $email.includeDiff}
-    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
-  {/if}
-{/template}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
deleted file mode 100644
index 40596e8..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
+++ /dev/null
@@ -1,91 +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.rules;
-
-import static org.easymock.EasyMock.expect;
-
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.server.project.Util;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.AbstractModule;
-import com.googlecode.prolog_cafe.exceptions.CompileException;
-import com.googlecode.prolog_cafe.exceptions.ReductionLimitException;
-import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.StructureTerm;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import java.io.PushbackReader;
-import java.io.StringReader;
-import java.util.Arrays;
-import org.easymock.EasyMock;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Before;
-import org.junit.Test;
-
-public class GerritCommonTest extends PrologTestCase {
-  @Before
-  public void setUp() throws Exception {
-    load(
-        "gerrit",
-        "gerrit_common_test.pl",
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            Config cfg = new Config();
-            cfg.setInt("rules", null, "reductionLimit", 1300);
-            cfg.setInt("rules", null, "compileReductionLimit", (int) 1e6);
-            bind(PrologEnvironment.Args.class)
-                .toInstance(
-                    new PrologEnvironment.Args(null, null, null, null, null, null, null, cfg));
-          }
-        });
-  }
-
-  @Override
-  protected void setUpEnvironment(PrologEnvironment env) throws Exception {
-    LabelTypes labelTypes = new LabelTypes(Arrays.asList(Util.codeReview(), Util.verified()));
-    ChangeData cd = EasyMock.createMock(ChangeData.class);
-    expect(cd.getLabelTypes()).andStubReturn(labelTypes);
-    EasyMock.replay(cd);
-    env.set(StoredValues.CHANGE_DATA, cd);
-  }
-
-  @Test
-  public void gerritCommon() throws Exception {
-    runPrologBasedTests();
-  }
-
-  @Test
-  public void reductionLimit() throws Exception {
-    PrologEnvironment env = envFactory.create(machine);
-    setUpEnvironment(env);
-
-    String script = "loopy :- b(5).\nb(N) :- N > 0, !, S = N - 1, b(S).\nb(_) :- true.\n";
-
-    SymbolTerm nameTerm = SymbolTerm.create("testReductionLimit");
-    JavaObjectTerm inTerm =
-        new JavaObjectTerm(new PushbackReader(new StringReader(script), Prolog.PUSHBACK_SIZE));
-    if (!env.execute(Prolog.BUILTIN, "consult_stream", nameTerm, inTerm)) {
-      throw new CompileException("Cannot consult " + nameTerm);
-    }
-
-    exception.expect(ReductionLimitException.class);
-    exception.expectMessage("exceeded reduction limit of 1300");
-    env.once(
-        Prolog.BUILTIN,
-        "call",
-        new StructureTerm(":", SymbolTerm.create("user"), SymbolTerm.create("loopy")));
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
deleted file mode 100644
index c0a2192..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
+++ /dev/null
@@ -1,187 +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.rules;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.testutil.GerritBaseTests;
-import com.google.inject.Guice;
-import com.google.inject.Module;
-import com.googlecode.prolog_cafe.exceptions.CompileException;
-import com.googlecode.prolog_cafe.lang.BufferingPrologControl;
-import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
-import com.googlecode.prolog_cafe.lang.Prolog;
-import com.googlecode.prolog_cafe.lang.PrologClassLoader;
-import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
-import com.googlecode.prolog_cafe.lang.StructureTerm;
-import com.googlecode.prolog_cafe.lang.SymbolTerm;
-import com.googlecode.prolog_cafe.lang.Term;
-import com.googlecode.prolog_cafe.lang.VariableTerm;
-import java.io.BufferedReader;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.PushbackReader;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-/** Base class for any tests written in Prolog. */
-public abstract class PrologTestCase extends GerritBaseTests {
-  private static final SymbolTerm test_1 = SymbolTerm.intern("test", 1);
-
-  private String pkg;
-  private boolean hasSetup;
-  private boolean hasTeardown;
-  private List<Term> tests;
-  protected PrologMachineCopy machine;
-  protected PrologEnvironment.Factory envFactory;
-
-  protected void load(String pkg, String prologResource, Module... modules)
-      throws CompileException, IOException {
-    ArrayList<Module> moduleList = new ArrayList<>();
-    moduleList.add(new PrologModule.EnvironmentModule());
-    moduleList.addAll(Arrays.asList(modules));
-
-    envFactory = Guice.createInjector(moduleList).getInstance(PrologEnvironment.Factory.class);
-    PrologEnvironment env = envFactory.create(newMachine());
-    consult(env, getClass(), prologResource);
-
-    this.pkg = pkg;
-    hasSetup = has(env, "setup");
-    hasTeardown = has(env, "teardown");
-
-    StructureTerm head =
-        new StructureTerm(
-            ":", SymbolTerm.intern(pkg), new StructureTerm(test_1, new VariableTerm()));
-
-    tests = new ArrayList<>();
-    for (Term[] pair : env.all(Prolog.BUILTIN, "clause", head, new VariableTerm())) {
-      tests.add(pair[0]);
-    }
-    assertThat(tests).isNotEmpty();
-    machine = PrologMachineCopy.save(env);
-  }
-
-  /**
-   * Set up the Prolog environment.
-   *
-   * @param env Prolog environment.
-   */
-  protected void setUpEnvironment(PrologEnvironment env) throws Exception {}
-
-  private PrologMachineCopy newMachine() {
-    BufferingPrologControl ctl = new BufferingPrologControl();
-    ctl.setMaxDatabaseSize(16 * 1024);
-    ctl.setPrologClassLoader(new PrologClassLoader(getClass().getClassLoader()));
-    return PrologMachineCopy.save(ctl);
-  }
-
-  protected void consult(BufferingPrologControl env, Class<?> clazz, String prologResource)
-      throws CompileException, IOException {
-    try (InputStream in = clazz.getResourceAsStream(prologResource)) {
-      if (in == null) {
-        throw new FileNotFoundException(prologResource);
-      }
-      SymbolTerm pathTerm = SymbolTerm.create(prologResource);
-      JavaObjectTerm inTerm =
-          new JavaObjectTerm(
-              new PushbackReader(
-                  new BufferedReader(new InputStreamReader(in, UTF_8)), Prolog.PUSHBACK_SIZE));
-      if (!env.execute(Prolog.BUILTIN, "consult_stream", pathTerm, inTerm)) {
-        throw new CompileException("Cannot consult " + prologResource);
-      }
-    }
-  }
-
-  private boolean has(BufferingPrologControl env, String name) {
-    StructureTerm head = SymbolTerm.create(pkg, name, 0);
-    return env.execute(Prolog.BUILTIN, "clause", head, new VariableTerm());
-  }
-
-  public void runPrologBasedTests() throws Exception {
-    int errors = 0;
-    long start = TimeUtil.nowMs();
-
-    for (Term test : tests) {
-      PrologEnvironment env = envFactory.create(machine);
-      setUpEnvironment(env);
-      env.setEnabled(Prolog.Feature.IO, true);
-
-      System.out.format("Prolog %-60s ...", removePackage(test));
-      System.out.flush();
-
-      if (hasSetup) {
-        call(env, "setup");
-      }
-
-      List<Term> all = env.all(Prolog.BUILTIN, "call", test);
-
-      if (hasTeardown) {
-        call(env, "teardown");
-      }
-
-      System.out.println(all.size() == 1 ? "OK" : "FAIL");
-
-      if (all.size() > 0 && !test.equals(all.get(0))) {
-        for (Term t : all) {
-          Term head = ((StructureTerm) removePackage(t)).args()[0];
-          Term[] args = ((StructureTerm) head).args();
-          System.out.print("  Result: ");
-          for (int i = 0; i < args.length; i++) {
-            if (0 < i) {
-              System.out.print(", ");
-            }
-            System.out.print(args[i]);
-          }
-          System.out.println();
-        }
-        System.out.println();
-      }
-
-      if (all.size() != 1) {
-        errors++;
-      }
-    }
-
-    long end = TimeUtil.nowMs();
-    System.out.println("-------------------------------");
-    System.out.format(
-        "Prolog tests: %d, Failures: %d, Time elapsed %.3f sec",
-        tests.size(), errors, (end - start) / 1000.0);
-    System.out.println();
-
-    assertThat(errors).isEqualTo(0);
-  }
-
-  private void call(BufferingPrologControl env, String name) {
-    StructureTerm head = SymbolTerm.create(pkg, name, 0);
-    assertWithMessage("Cannot invoke " + pkg + ":" + name)
-        .that(env.execute(Prolog.BUILTIN, "call", head))
-        .isTrue();
-  }
-
-  private Term removePackage(Term test) {
-    Term name = test;
-    if (name instanceof StructureTerm && ":".equals(name.name())) {
-      name = name.arg(1);
-    }
-    return name;
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java
deleted file mode 100644
index b32bdc6..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java
+++ /dev/null
@@ -1,126 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.inject.Scopes.SINGLETON;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.FakeRealm;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.Realm;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.DisableReverseDnsLookup;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.FakeAccountCache;
-import com.google.inject.AbstractModule;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.Set;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-@RunWith(ConfigSuite.class)
-public class IdentifiedUserTest {
-  @ConfigSuite.Parameter public Config config;
-
-  private IdentifiedUser identifiedUser;
-
-  @Inject private IdentifiedUser.GenericFactory identifiedUserFactory;
-
-  private static final String[] TEST_CASES = {
-    "",
-    "FirstName.LastName@Corporation.com",
-    "!#$%&'+-/=.?^`{|}~@[IPv6:0123:4567:89AB:CDEF:0123:4567:89AB:CDEF]",
-  };
-
-  @Before
-  public void setUp() throws Exception {
-    final FakeAccountCache accountCache = new FakeAccountCache();
-    final Realm mockRealm =
-        new FakeRealm() {
-          HashSet<String> emails = new HashSet<>(Arrays.asList(TEST_CASES));
-
-          @Override
-          public boolean hasEmailAddress(IdentifiedUser who, String email) {
-            return emails.contains(email);
-          }
-
-          @Override
-          public Set<String> getEmailAddresses(IdentifiedUser who) {
-            return emails;
-          }
-        };
-
-    AbstractModule mod =
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            bind(Boolean.class)
-                .annotatedWith(DisableReverseDnsLookup.class)
-                .toInstance(Boolean.FALSE);
-            bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(config);
-            bind(String.class)
-                .annotatedWith(AnonymousCowardName.class)
-                .toProvider(AnonymousCowardNameProvider.class);
-            bind(String.class)
-                .annotatedWith(CanonicalWebUrl.class)
-                .toInstance("http://localhost:8080/");
-            bind(AccountCache.class).toInstance(accountCache);
-            bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
-            bind(Realm.class).toInstance(mockRealm);
-          }
-        };
-
-    Injector injector = Guice.createInjector(mod);
-    injector.injectMembers(this);
-
-    Account account = new Account(new Account.Id(1), TimeUtil.nowTs());
-    Account.Id ownerId = account.getId();
-
-    identifiedUser = identifiedUserFactory.create(ownerId);
-
-    /* Trigger identifiedUser to load the email addresses from mockRealm */
-    identifiedUser.getEmailAddresses();
-  }
-
-  @Test
-  public void emailsExistence() {
-    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[0])).isTrue();
-    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1].toLowerCase())).isTrue();
-    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1])).isTrue();
-    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1].toUpperCase())).isTrue();
-    /* assert again to test cached email address by IdentifiedUser.validEmails */
-    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1])).isTrue();
-
-    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[2])).isTrue();
-    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[2].toLowerCase())).isTrue();
-
-    assertThat(identifiedUser.hasEmailAddress("non-exist@email.com")).isFalse();
-    /* assert again to test cached email address by IdentifiedUser.invalidEmails */
-    assertThat(identifiedUser.hasEmailAddress("non-exist@email.com")).isFalse();
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java
deleted file mode 100644
index acf2577..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import static org.junit.Assert.assertEquals;
-
-import org.junit.Test;
-
-public class StringUtilTest {
-  /** Test the boundary condition that the first character of a string should be escaped. */
-  @Test
-  public void escapeFirstChar() {
-    assertEquals(StringUtil.escapeString("\tLeading tab"), "\\tLeading tab");
-  }
-
-  /** Test the boundary condition that the last character of a string should be escaped. */
-  @Test
-  public void escapeLastChar() {
-    assertEquals(StringUtil.escapeString("Trailing tab\t"), "Trailing tab\\t");
-  }
-
-  /** Test that various forms of input strings are escaped (or left as-is) in the expected way. */
-  @Test
-  public void escapeString() {
-    final String[] testPairs = {
-      "", "",
-      "plain string", "plain string",
-      "string with \"quotes\"", "string with \"quotes\"",
-      "string with 'quotes'", "string with 'quotes'",
-      "string with 'quotes'", "string with 'quotes'",
-      "C:\\Program Files\\MyProgram", "C:\\\\Program Files\\\\MyProgram",
-      "string\nwith\nnewlines", "string\\nwith\\nnewlines",
-      "string\twith\ttabs", "string\\twith\\ttabs",
-    };
-    for (int i = 0; i < testPairs.length; i += 2) {
-      assertEquals(StringUtil.escapeString(testPairs[i]), testPairs[i + 1]);
-    }
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/account/UniversalGroupBackendTest.java
deleted file mode 100644
index 5444c5b..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/account/UniversalGroupBackendTest.java
+++ /dev/null
@@ -1,138 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static org.easymock.EasyMock.anyObject;
-import static org.easymock.EasyMock.createMock;
-import static org.easymock.EasyMock.createNiceMock;
-import static org.easymock.EasyMock.eq;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.getCurrentArguments;
-import static org.easymock.EasyMock.not;
-import static org.easymock.EasyMock.replay;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.testutil.GerritBaseTests;
-import java.util.Set;
-import org.easymock.IAnswer;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Before;
-import org.junit.Test;
-
-public class UniversalGroupBackendTest extends GerritBaseTests {
-  private static final AccountGroup.UUID OTHER_UUID = new AccountGroup.UUID("other");
-
-  private UniversalGroupBackend backend;
-  private IdentifiedUser user;
-
-  private DynamicSet<GroupBackend> backends;
-
-  @Before
-  public void setup() {
-    user = createNiceMock(IdentifiedUser.class);
-    replay(user);
-    backends = new DynamicSet<>();
-    backends.add(new SystemGroupBackend(new Config()));
-    backend = new UniversalGroupBackend(backends);
-  }
-
-  @Test
-  public void handles() {
-    assertTrue(backend.handles(ANONYMOUS_USERS));
-    assertTrue(backend.handles(PROJECT_OWNERS));
-    assertFalse(backend.handles(OTHER_UUID));
-  }
-
-  @Test
-  public void get() {
-    assertEquals("Registered Users", backend.get(REGISTERED_USERS).getName());
-    assertEquals("Project Owners", backend.get(PROJECT_OWNERS).getName());
-    assertNull(backend.get(OTHER_UUID));
-  }
-
-  @Test
-  public void suggest() {
-    assertTrue(backend.suggest("X", null).isEmpty());
-    assertEquals(1, backend.suggest("project", null).size());
-    assertEquals(1, backend.suggest("reg", null).size());
-  }
-
-  @Test
-  public void sytemGroupMemberships() {
-    GroupMembership checker = backend.membershipsOf(user);
-    assertTrue(checker.contains(REGISTERED_USERS));
-    assertFalse(checker.contains(OTHER_UUID));
-    assertFalse(checker.contains(PROJECT_OWNERS));
-  }
-
-  @Test
-  public void knownGroups() {
-    GroupMembership checker = backend.membershipsOf(user);
-    Set<UUID> knownGroups = checker.getKnownGroups();
-    assertEquals(2, knownGroups.size());
-    assertTrue(knownGroups.contains(REGISTERED_USERS));
-    assertTrue(knownGroups.contains(ANONYMOUS_USERS));
-  }
-
-  @Test
-  public void otherMemberships() {
-    final AccountGroup.UUID handled = new AccountGroup.UUID("handled");
-    final AccountGroup.UUID notHandled = new AccountGroup.UUID("not handled");
-    final IdentifiedUser member = createNiceMock(IdentifiedUser.class);
-    final IdentifiedUser notMember = createNiceMock(IdentifiedUser.class);
-
-    GroupBackend backend = createMock(GroupBackend.class);
-    expect(backend.handles(handled)).andStubReturn(true);
-    expect(backend.handles(not(eq(handled)))).andStubReturn(false);
-    expect(backend.membershipsOf(anyObject(IdentifiedUser.class)))
-        .andStubAnswer(
-            new IAnswer<GroupMembership>() {
-              @Override
-              public GroupMembership answer() throws Throwable {
-                Object[] args = getCurrentArguments();
-                GroupMembership membership = createMock(GroupMembership.class);
-                expect(membership.contains(eq(handled))).andStubReturn(args[0] == member);
-                expect(membership.contains(not(eq(notHandled)))).andStubReturn(false);
-                replay(membership);
-                return membership;
-              }
-            });
-    replay(member, notMember, backend);
-
-    backends = new DynamicSet<>();
-    backends.add(backend);
-    backend = new UniversalGroupBackend(backends);
-
-    GroupMembership checker = backend.membershipsOf(member);
-    assertFalse(checker.contains(REGISTERED_USERS));
-    assertFalse(checker.contains(OTHER_UUID));
-    assertTrue(checker.contains(handled));
-    assertFalse(checker.contains(notHandled));
-    checker = backend.membershipsOf(notMember);
-    assertFalse(checker.contains(handled));
-    assertFalse(checker.contains(notHandled));
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java
deleted file mode 100644
index 37e4d3f..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java
+++ /dev/null
@@ -1,374 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.collect.Collections2.permutations;
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.change.WalkSorter.PatchSetData;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.testutil.GerritBaseTests;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
-import com.google.gerrit.testutil.TestChanges;
-import java.util.ArrayList;
-import java.util.List;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.Before;
-import org.junit.Test;
-
-public class WalkSorterTest extends GerritBaseTests {
-  private Account.Id userId;
-  private InMemoryRepositoryManager repoManager;
-
-  @Before
-  public void setUp() {
-    userId = new Account.Id(1);
-    repoManager = new InMemoryRepositoryManager();
-  }
-
-  @Test
-  public void seriesOfChanges() throws Exception {
-    TestRepository<Repo> p = newRepo("p");
-    RevCommit c1_1 = p.commit().create();
-    RevCommit c2_1 = p.commit().parent(c1_1).create();
-    RevCommit c3_1 = p.commit().parent(c2_1).create();
-
-    ChangeData cd1 = newChange(p, c1_1);
-    ChangeData cd2 = newChange(p, c2_1);
-    ChangeData cd3 = newChange(p, c3_1);
-
-    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3);
-    WalkSorter sorter = new WalkSorter(repoManager);
-
-    assertSorted(
-        sorter,
-        changes,
-        ImmutableList.of(
-            patchSetData(cd3, c3_1), patchSetData(cd2, c2_1), patchSetData(cd1, c1_1)));
-
-    // Add new patch sets whose commits are in reverse order, so output is in
-    // reverse order.
-    RevCommit c3_2 = p.commit().create();
-    RevCommit c2_2 = p.commit().parent(c3_2).create();
-    RevCommit c1_2 = p.commit().parent(c2_2).create();
-
-    addPatchSet(cd1, c1_2);
-    addPatchSet(cd2, c2_2);
-    addPatchSet(cd3, c3_2);
-
-    assertSorted(
-        sorter,
-        changes,
-        ImmutableList.of(
-            patchSetData(cd1, c1_2), patchSetData(cd2, c2_2), patchSetData(cd3, c3_2)));
-  }
-
-  @Test
-  public void subsetOfSeriesOfChanges() throws Exception {
-    TestRepository<Repo> p = newRepo("p");
-    RevCommit c1_1 = p.commit().create();
-    RevCommit c2_1 = p.commit().parent(c1_1).create();
-    RevCommit c3_1 = p.commit().parent(c2_1).create();
-
-    ChangeData cd1 = newChange(p, c1_1);
-    ChangeData cd3 = newChange(p, c3_1);
-
-    List<ChangeData> changes = ImmutableList.of(cd1, cd3);
-    WalkSorter sorter = new WalkSorter(repoManager);
-
-    assertSorted(
-        sorter, changes, ImmutableList.of(patchSetData(cd3, c3_1), patchSetData(cd1, c1_1)));
-  }
-
-  @Test
-  public void seriesOfChangesAtSameTimestamp() throws Exception {
-    TestRepository<Repo> p = newRepo("p");
-    RevCommit c0 = p.commit().tick(0).create();
-    RevCommit c1 = p.commit().tick(0).parent(c0).create();
-    RevCommit c2 = p.commit().tick(0).parent(c1).create();
-    RevCommit c3 = p.commit().tick(0).parent(c2).create();
-    RevCommit c4 = p.commit().tick(0).parent(c3).create();
-
-    RevWalk rw = p.getRevWalk();
-    rw.parseCommit(c1);
-    assertThat(rw.parseCommit(c2).getCommitTime()).isEqualTo(c1.getCommitTime());
-    assertThat(rw.parseCommit(c3).getCommitTime()).isEqualTo(c1.getCommitTime());
-    assertThat(rw.parseCommit(c4).getCommitTime()).isEqualTo(c1.getCommitTime());
-
-    ChangeData cd1 = newChange(p, c1);
-    ChangeData cd2 = newChange(p, c2);
-    ChangeData cd3 = newChange(p, c3);
-    ChangeData cd4 = newChange(p, c4);
-
-    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
-    WalkSorter sorter = new WalkSorter(repoManager);
-
-    assertSorted(
-        sorter,
-        changes,
-        ImmutableList.of(
-            patchSetData(cd4, c4),
-            patchSetData(cd3, c3),
-            patchSetData(cd2, c2),
-            patchSetData(cd1, c1)));
-  }
-
-  @Test
-  public void seriesOfChangesWithReverseTimestamps() throws Exception {
-    TestRepository<Repo> p = newRepo("p");
-    RevCommit c0 = p.commit().tick(-1).create();
-    RevCommit c1 = p.commit().tick(-1).parent(c0).create();
-    RevCommit c2 = p.commit().tick(-1).parent(c1).create();
-    RevCommit c3 = p.commit().tick(-1).parent(c2).create();
-    RevCommit c4 = p.commit().tick(-1).parent(c3).create();
-
-    RevWalk rw = p.getRevWalk();
-    rw.parseCommit(c1);
-    assertThat(rw.parseCommit(c2).getCommitTime()).isLessThan(c1.getCommitTime());
-    assertThat(rw.parseCommit(c3).getCommitTime()).isLessThan(c2.getCommitTime());
-    assertThat(rw.parseCommit(c4).getCommitTime()).isLessThan(c3.getCommitTime());
-
-    ChangeData cd1 = newChange(p, c1);
-    ChangeData cd2 = newChange(p, c2);
-    ChangeData cd3 = newChange(p, c3);
-    ChangeData cd4 = newChange(p, c4);
-
-    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
-    WalkSorter sorter = new WalkSorter(repoManager);
-
-    assertSorted(
-        sorter,
-        changes,
-        ImmutableList.of(
-            patchSetData(cd4, c4),
-            patchSetData(cd3, c3),
-            patchSetData(cd2, c2),
-            patchSetData(cd1, c1)));
-  }
-
-  @Test
-  public void subsetOfSeriesOfChangesWithReverseTimestamps() throws Exception {
-    TestRepository<Repo> p = newRepo("p");
-    RevCommit c0 = p.commit().tick(-1).create();
-    RevCommit c1 = p.commit().tick(-1).parent(c0).create();
-    RevCommit c2 = p.commit().tick(-1).parent(c1).create();
-    RevCommit c3 = p.commit().tick(-1).parent(c2).create();
-    RevCommit c4 = p.commit().tick(-1).parent(c3).create();
-
-    RevWalk rw = p.getRevWalk();
-    rw.parseCommit(c1);
-    assertThat(rw.parseCommit(c2).getCommitTime()).isLessThan(c1.getCommitTime());
-    assertThat(rw.parseCommit(c3).getCommitTime()).isLessThan(c2.getCommitTime());
-    assertThat(rw.parseCommit(c4).getCommitTime()).isLessThan(c3.getCommitTime());
-
-    ChangeData cd1 = newChange(p, c1);
-    ChangeData cd2 = newChange(p, c2);
-    ChangeData cd4 = newChange(p, c4);
-
-    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd4);
-    WalkSorter sorter = new WalkSorter(repoManager);
-    List<PatchSetData> expected =
-        ImmutableList.of(patchSetData(cd4, c4), patchSetData(cd2, c2), patchSetData(cd1, c1));
-
-    for (List<ChangeData> list : permutations(changes)) {
-      // Not inOrder(); since child of c2 is missing, partial topo sort isn't
-      // guaranteed to work.
-      assertThat(sorter.sort(list)).containsExactlyElementsIn(expected);
-    }
-  }
-
-  @Test
-  public void seriesOfChangesAtSameTimestampWithRootCommit() throws Exception {
-    TestRepository<Repo> p = newRepo("p");
-    RevCommit c1 = p.commit().tick(0).create();
-    RevCommit c2 = p.commit().tick(0).parent(c1).create();
-    RevCommit c3 = p.commit().tick(0).parent(c2).create();
-    RevCommit c4 = p.commit().tick(0).parent(c3).create();
-
-    RevWalk rw = p.getRevWalk();
-    rw.parseCommit(c1);
-    assertThat(rw.parseCommit(c2).getCommitTime()).isEqualTo(c1.getCommitTime());
-    assertThat(rw.parseCommit(c3).getCommitTime()).isEqualTo(c1.getCommitTime());
-    assertThat(rw.parseCommit(c4).getCommitTime()).isEqualTo(c1.getCommitTime());
-
-    ChangeData cd1 = newChange(p, c1);
-    ChangeData cd2 = newChange(p, c2);
-    ChangeData cd3 = newChange(p, c3);
-    ChangeData cd4 = newChange(p, c4);
-
-    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
-    WalkSorter sorter = new WalkSorter(repoManager);
-
-    assertSorted(
-        sorter,
-        changes,
-        ImmutableList.of(
-            patchSetData(cd4, c4),
-            patchSetData(cd3, c3),
-            patchSetData(cd2, c2),
-            patchSetData(cd1, c1)));
-  }
-
-  @Test
-  public void projectsSortedByName() throws Exception {
-    TestRepository<Repo> pa = newRepo("a");
-    TestRepository<Repo> pb = newRepo("b");
-    RevCommit c1 = pa.commit().create();
-    RevCommit c2 = pb.commit().create();
-    RevCommit c3 = pa.commit().parent(c1).create();
-    RevCommit c4 = pb.commit().parent(c2).create();
-
-    ChangeData cd1 = newChange(pa, c1);
-    ChangeData cd2 = newChange(pb, c2);
-    ChangeData cd3 = newChange(pa, c3);
-    ChangeData cd4 = newChange(pb, c4);
-
-    assertSorted(
-        new WalkSorter(repoManager),
-        ImmutableList.of(cd1, cd2, cd3, cd4),
-        ImmutableList.of(
-            patchSetData(cd3, c3),
-            patchSetData(cd1, c1),
-            patchSetData(cd4, c4),
-            patchSetData(cd2, c2)));
-  }
-
-  @Test
-  public void restrictToPatchSets() throws Exception {
-    TestRepository<Repo> p = newRepo("p");
-    RevCommit c1_1 = p.commit().create();
-    RevCommit c2_1 = p.commit().parent(c1_1).create();
-
-    ChangeData cd1 = newChange(p, c1_1);
-    ChangeData cd2 = newChange(p, c2_1);
-
-    // Add new patch sets whose commits are in reverse order.
-    RevCommit c2_2 = p.commit().create();
-    RevCommit c1_2 = p.commit().parent(c2_2).create();
-
-    addPatchSet(cd1, c1_2);
-    addPatchSet(cd2, c2_2);
-
-    List<ChangeData> changes = ImmutableList.of(cd1, cd2);
-    WalkSorter sorter = new WalkSorter(repoManager);
-
-    assertSorted(
-        sorter, changes, ImmutableList.of(patchSetData(cd1, c1_2), patchSetData(cd2, c2_2)));
-
-    // If we restrict to PS1 of each change, the sorter uses that commit.
-    sorter.includePatchSets(
-        ImmutableSet.of(new PatchSet.Id(cd1.getId(), 1), new PatchSet.Id(cd2.getId(), 1)));
-    assertSorted(
-        sorter, changes, ImmutableList.of(patchSetData(cd2, 1, c2_1), patchSetData(cd1, 1, c1_1)));
-  }
-
-  @Test
-  public void restrictToPatchSetsOmittingWholeProject() throws Exception {
-    TestRepository<Repo> pa = newRepo("a");
-    TestRepository<Repo> pb = newRepo("b");
-    RevCommit c1 = pa.commit().create();
-    RevCommit c2 = pa.commit().create();
-
-    ChangeData cd1 = newChange(pa, c1);
-    ChangeData cd2 = newChange(pb, c2);
-
-    List<ChangeData> changes = ImmutableList.of(cd1, cd2);
-    WalkSorter sorter =
-        new WalkSorter(repoManager)
-            .includePatchSets(ImmutableSet.of(cd1.currentPatchSet().getId()));
-
-    assertSorted(sorter, changes, ImmutableList.of(patchSetData(cd1, c1)));
-  }
-
-  @Test
-  public void retainBody() throws Exception {
-    TestRepository<Repo> p = newRepo("p");
-    RevCommit c = p.commit().message("message").create();
-    ChangeData cd = newChange(p, c);
-
-    List<ChangeData> changes = ImmutableList.of(cd);
-    RevCommit actual =
-        new WalkSorter(repoManager).setRetainBody(true).sort(changes).iterator().next().commit();
-    assertThat(actual.getRawBuffer()).isNotNull();
-    assertThat(actual.getShortMessage()).isEqualTo("message");
-
-    actual =
-        new WalkSorter(repoManager).setRetainBody(false).sort(changes).iterator().next().commit();
-    assertThat(actual.getRawBuffer()).isNull();
-  }
-
-  @Test
-  public void oneChange() throws Exception {
-    TestRepository<Repo> p = newRepo("p");
-    RevCommit c = p.commit().create();
-    ChangeData cd = newChange(p, c);
-
-    List<ChangeData> changes = ImmutableList.of(cd);
-    WalkSorter sorter = new WalkSorter(repoManager);
-
-    assertSorted(sorter, changes, ImmutableList.of(patchSetData(cd, c)));
-  }
-
-  private ChangeData newChange(TestRepository<Repo> tr, ObjectId id) throws Exception {
-    Project.NameKey project = tr.getRepository().getDescription().getProject();
-    Change c = TestChanges.newChange(project, userId);
-    ChangeData cd = ChangeData.createForTest(project, c.getId(), 1);
-    cd.setChange(c);
-    cd.currentPatchSet().setRevision(new RevId(id.name()));
-    cd.setPatchSets(ImmutableList.of(cd.currentPatchSet()));
-    return cd;
-  }
-
-  private PatchSet addPatchSet(ChangeData cd, ObjectId id) throws Exception {
-    TestChanges.incrementPatchSet(cd.change());
-    PatchSet ps = new PatchSet(cd.change().currentPatchSetId());
-    ps.setRevision(new RevId(id.name()));
-    List<PatchSet> patchSets = new ArrayList<>(cd.patchSets());
-    patchSets.add(ps);
-    cd.setPatchSets(patchSets);
-    return ps;
-  }
-
-  private TestRepository<Repo> newRepo(String name) throws Exception {
-    return new TestRepository<>(repoManager.createRepository(new Project.NameKey(name)));
-  }
-
-  private static PatchSetData patchSetData(ChangeData cd, RevCommit commit) throws Exception {
-    return PatchSetData.create(cd, cd.currentPatchSet(), commit);
-  }
-
-  private static PatchSetData patchSetData(ChangeData cd, int psId, RevCommit commit)
-      throws Exception {
-    return PatchSetData.create(cd, cd.patchSet(new PatchSet.Id(cd.getId(), psId)), commit);
-  }
-
-  private static void assertSorted(
-      WalkSorter sorter, List<ChangeData> changes, List<PatchSetData> expected) throws Exception {
-    for (List<ChangeData> list : permutations(changes)) {
-      assertThat(sorter.sort(list)).containsExactlyElementsIn(expected).inOrder();
-    }
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/ListCapabilitiesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/ListCapabilitiesTest.java
deleted file mode 100644
index 6fe48dc..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/ListCapabilitiesTest.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertTrue;
-
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.Exports;
-import com.google.gerrit.extensions.config.CapabilityDefinition;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.server.config.ListCapabilities.CapabilityInfo;
-import com.google.inject.AbstractModule;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import java.util.Map;
-import org.junit.Before;
-import org.junit.Test;
-
-public class ListCapabilitiesTest {
-  private Injector injector;
-
-  @Before
-  public void setUp() throws Exception {
-    AbstractModule mod =
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            DynamicMap.mapOf(binder(), CapabilityDefinition.class);
-            bind(CapabilityDefinition.class)
-                .annotatedWith(Exports.named("printHello"))
-                .toInstance(
-                    new CapabilityDefinition() {
-                      @Override
-                      public String getDescription() {
-                        return "Print Hello";
-                      }
-                    });
-          }
-        };
-    injector = Guice.createInjector(mod);
-  }
-
-  @Test
-  public void list() throws Exception {
-    Map<String, CapabilityInfo> m =
-        injector.getInstance(ListCapabilities.class).apply(new ConfigResource());
-    for (String id : GlobalCapability.getAllNames()) {
-      assertTrue("contains " + id, m.containsKey(id));
-      assertEquals(id, m.get(id).id);
-      assertNotNull(id + " has name", m.get(id).name);
-    }
-
-    String pluginCapability = "gerrit-printHello";
-    assertTrue("contains " + pluginCapability, m.containsKey(pluginCapability));
-    assertEquals(pluginCapability, m.get(pluginCapability).id);
-    assertEquals("Print Hello", m.get(pluginCapability).name);
-  }
-}
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
deleted file mode 100644
index e6f36b9..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/ScheduleConfigTest.java
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static java.util.concurrent.TimeUnit.DAYS;
-import static java.util.concurrent.TimeUnit.HOURS;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.MINUTES;
-import static org.junit.Assert.assertEquals;
-
-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");
-
-  @Test
-  public void initialDelay() throws Exception {
-    assertEquals(ms(1, HOURS), initialDelay("11:00", "1h"));
-    assertEquals(ms(30, MINUTES), initialDelay("05:30", "1h"));
-    assertEquals(ms(30, MINUTES), initialDelay("09:30", "1h"));
-    assertEquals(ms(30, MINUTES), initialDelay("13:30", "1h"));
-    assertEquals(ms(59, MINUTES), initialDelay("13:59", "1h"));
-
-    assertEquals(ms(1, HOURS), initialDelay("11:00", "1d"));
-    assertEquals(ms(19, HOURS) + ms(30, MINUTES), initialDelay("05:30", "1d"));
-
-    assertEquals(ms(1, HOURS), initialDelay("11:00", "1w"));
-    assertEquals(ms(7, DAYS) - ms(4, HOURS) - ms(30, MINUTES), initialDelay("05:30", "1w"));
-
-    assertEquals(ms(3, DAYS) + ms(1, HOURS), initialDelay("Mon 11:00", "1w"));
-    assertEquals(ms(1, HOURS), initialDelay("Fri 11:00", "1w"));
-
-    assertEquals(ms(1, HOURS), initialDelay("Mon 11:00", "1d"));
-    assertEquals(ms(23, HOURS), initialDelay("Mon 09:00", "1d"));
-    assertEquals(ms(1, DAYS), initialDelay("Mon 10:00", "1d"));
-    assertEquals(ms(1, DAYS), initialDelay("Mon 10:00", "1d"));
-  }
-
-  @Test
-  public void customKeys() {
-    Config rc = new Config();
-    rc.setString("a", "b", "i", "1h");
-    rc.setString("a", "b", "s", "01:00");
-
-    ScheduleConfig s = new ScheduleConfig(rc, "a", "b", "i", "s", NOW);
-    assertEquals(ms(1, HOURS), s.getInterval());
-    assertEquals(ms(1, HOURS), s.getInitialDelay());
-
-    s = new ScheduleConfig(rc, "a", "b", "myInterval", "myStart", NOW);
-    assertEquals(s.getInterval(), ScheduleConfig.MISSING_CONFIG);
-    assertEquals(s.getInitialDelay(), ScheduleConfig.MISSING_CONFIG);
-  }
-
-  private static long initialDelay(String startTime, String interval) {
-    return new ScheduleConfig(config(startTime, interval), "section", "subsection", NOW)
-        .getInitialDelay();
-  }
-
-  private static Config config(String startTime, String interval) {
-    Config rc = new Config();
-    rc.setString("section", "subsection", "startTime", startTime);
-    rc.setString("section", "subsection", "interval", interval);
-    return rc;
-  }
-
-  private static long ms(int cnt, TimeUnit unit) {
-    return MILLISECONDS.convert(cnt, unit);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java
deleted file mode 100644
index 3fb278d..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.extensions.common.PathSubject;
-import com.google.gerrit.server.util.HostPlatform;
-import com.google.gerrit.testutil.GerritBaseTests;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.NotDirectoryException;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import org.junit.Test;
-
-public class SitePathsTest extends GerritBaseTests {
-  @Test
-  public void create_NotExisting() throws IOException {
-    final Path root = random();
-    final SitePaths site = new SitePaths(root);
-    assertThat(site.isNew).isTrue();
-    PathSubject.assertThat(site.site_path).isEqualTo(root);
-    PathSubject.assertThat(site.etc_dir).isEqualTo(root.resolve("etc"));
-  }
-
-  @Test
-  public void create_Empty() throws IOException {
-    final Path root = random();
-    try {
-      Files.createDirectory(root);
-
-      final SitePaths site = new SitePaths(root);
-      assertThat(site.isNew).isTrue();
-      PathSubject.assertThat(site.site_path).isEqualTo(root);
-    } finally {
-      Files.delete(root);
-    }
-  }
-
-  @Test
-  public void create_NonEmpty() throws IOException {
-    final Path root = random();
-    final Path txt = root.resolve("test.txt");
-    try {
-      Files.createDirectory(root);
-      Files.createFile(txt);
-
-      final SitePaths site = new SitePaths(root);
-      assertThat(site.isNew).isFalse();
-      PathSubject.assertThat(site.site_path).isEqualTo(root);
-    } finally {
-      Files.delete(txt);
-      Files.delete(root);
-    }
-  }
-
-  @Test
-  public void create_NotDirectory() throws IOException {
-    final Path root = random();
-    try {
-      Files.createFile(root);
-      exception.expect(NotDirectoryException.class);
-      new SitePaths(root);
-    } finally {
-      Files.delete(root);
-    }
-  }
-
-  @Test
-  public void resolve() throws IOException {
-    final Path root = random();
-    final SitePaths site = new SitePaths(root);
-
-    PathSubject.assertThat(site.resolve(null)).isNull();
-    PathSubject.assertThat(site.resolve("")).isNull();
-
-    PathSubject.assertThat(site.resolve("a")).isNotNull();
-    PathSubject.assertThat(site.resolve("a"))
-        .isEqualTo(root.resolve("a").toAbsolutePath().normalize());
-
-    final String pfx = HostPlatform.isWin32() ? "C:/" : "/";
-    PathSubject.assertThat(site.resolve(pfx + "a")).isNotNull();
-    PathSubject.assertThat(site.resolve(pfx + "a")).isEqualTo(Paths.get(pfx + "a"));
-  }
-
-  private static Path random() throws IOException {
-    Path tmp = Files.createTempFile("gerrit_test_", "_site");
-    Files.deleteIfExists(tmp);
-    return tmp;
-  }
-}
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
deleted file mode 100644
index 801b2b0..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.edit.tree;
-
-import static com.google.common.truth.Truth.assertAbout;
-
-import com.google.common.io.CharStreams;
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.StringSubject;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.common.truth.Truth;
-import com.google.gerrit.extensions.restapi.RawInput;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.nio.charset.StandardCharsets;
-
-public class ChangeFileContentModificationSubject
-    extends Subject<ChangeFileContentModificationSubject, ChangeFileContentModification> {
-
-  private static final SubjectFactory<
-          ChangeFileContentModificationSubject, ChangeFileContentModification>
-      MODIFICATION_SUBJECT_FACTORY =
-          new SubjectFactory<
-              ChangeFileContentModificationSubject, ChangeFileContentModification>() {
-            @Override
-            public ChangeFileContentModificationSubject getSubject(
-                FailureStrategy failureStrategy, ChangeFileContentModification modification) {
-              return new ChangeFileContentModificationSubject(failureStrategy, modification);
-            }
-          };
-
-  public static ChangeFileContentModificationSubject assertThat(
-      ChangeFileContentModification modification) {
-    return assertAbout(MODIFICATION_SUBJECT_FACTORY).that(modification);
-  }
-
-  private ChangeFileContentModificationSubject(
-      FailureStrategy failureStrategy, ChangeFileContentModification modification) {
-    super(failureStrategy, modification);
-  }
-
-  public StringSubject filePath() {
-    isNotNull();
-    return Truth.assertThat(actual().getFilePath()).named("filePath");
-  }
-
-  public StringSubject newContent() throws IOException {
-    isNotNull();
-    RawInput newContent = actual().getNewContent();
-    Truth.assertThat(newContent).named("newContent").isNotNull();
-    String contentString =
-        CharStreams.toString(
-            new InputStreamReader(newContent.getInputStream(), StandardCharsets.UTF_8));
-    return Truth.assertThat(contentString).named("newContent");
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/TreeModificationSubject.java b/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
deleted file mode 100644
index ac4ebb8..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.edit.tree;
-
-import static com.google.common.truth.Truth.assertAbout;
-
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.gerrit.truth.ListSubject;
-import java.util.List;
-
-public class TreeModificationSubject extends Subject<TreeModificationSubject, TreeModification> {
-
-  private static final SubjectFactory<TreeModificationSubject, TreeModification>
-      TREE_MODIFICATION_SUBJECT_FACTORY =
-          new SubjectFactory<TreeModificationSubject, TreeModification>() {
-            @Override
-            public TreeModificationSubject getSubject(
-                FailureStrategy failureStrategy, TreeModification treeModification) {
-              return new TreeModificationSubject(failureStrategy, treeModification);
-            }
-          };
-
-  public static TreeModificationSubject assertThat(TreeModification treeModification) {
-    return assertAbout(TREE_MODIFICATION_SUBJECT_FACTORY).that(treeModification);
-  }
-
-  public static ListSubject<TreeModificationSubject, TreeModification> assertThatList(
-      List<TreeModification> treeModifications) {
-    return ListSubject.assertThat(treeModifications, TreeModificationSubject::assertThat)
-        .named("treeModifications");
-  }
-
-  private TreeModificationSubject(
-      FailureStrategy failureStrategy, TreeModification treeModification) {
-    super(failureStrategy, treeModification);
-  }
-
-  public ChangeFileContentModificationSubject asChangeFileContentModification() {
-    isInstanceOf(ChangeFileContentModification.class);
-    return ChangeFileContentModificationSubject.assertThat(
-        (ChangeFileContentModification) actual());
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java
deleted file mode 100644
index 75b00fd..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java
+++ /dev/null
@@ -1,228 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.gerrit.common.data.Permission.forLabel;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.Util.allow;
-import static com.google.gerrit.server.project.Util.category;
-import static com.google.gerrit.server.project.Util.value;
-import static org.junit.Assert.assertEquals;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.LabelNormalizer.Result;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testutil.InMemoryDatabase;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Provider;
-import com.google.inject.util.Providers;
-import java.util.List;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-/** Unit tests for {@link LabelNormalizer}. */
-public class LabelNormalizerTest {
-  @Inject private AccountManager accountManager;
-  @Inject private AllProjectsName allProjects;
-  @Inject private GitRepositoryManager repoManager;
-  @Inject private IdentifiedUser.GenericFactory userFactory;
-  @Inject private InMemoryDatabase schemaFactory;
-  @Inject private LabelNormalizer norm;
-  @Inject private MetaDataUpdate.User metaDataUpdateFactory;
-  @Inject private ProjectCache projectCache;
-  @Inject private SchemaCreator schemaCreator;
-  @Inject protected ThreadLocalRequestContext requestContext;
-  @Inject private ChangeNotes.Factory changeNotesFactory;
-
-  private LifecycleManager lifecycle;
-  private ReviewDb db;
-  private Account.Id userId;
-  private IdentifiedUser user;
-  private Change change;
-  private ChangeNotes notes;
-
-  @Before
-  public void setUpInjector() throws Exception {
-    Injector injector = Guice.createInjector(new InMemoryModule());
-    injector.injectMembers(this);
-    lifecycle = new LifecycleManager();
-    lifecycle.add(injector);
-    lifecycle.start();
-
-    db = schemaFactory.open();
-    schemaCreator.create(db);
-    userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
-    user = userFactory.create(userId);
-
-    requestContext.setContext(
-        new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return user;
-          }
-
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return Providers.of(db);
-          }
-        });
-
-    configureProject();
-    setUpChange();
-  }
-
-  private void configureProject() throws Exception {
-    ProjectConfig pc = loadAllProjects();
-    for (AccessSection sec : pc.getAccessSections()) {
-      for (String label : pc.getLabelSections().keySet()) {
-        sec.removePermission(forLabel(label));
-      }
-    }
-    LabelType lt =
-        category("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
-    pc.getLabelSections().put(lt.getName(), lt);
-    save(pc);
-  }
-
-  private void setUpChange() throws Exception {
-    change =
-        new Change(
-            new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
-            new Change.Id(1),
-            userId,
-            new Branch.NameKey(allProjects, "refs/heads/master"),
-            TimeUtil.nowTs());
-    PatchSetInfo ps = new PatchSetInfo(new PatchSet.Id(change.getId(), 1));
-    ps.setSubject("Test change");
-    change.setCurrentPatchSet(ps);
-    db.changes().insert(ImmutableList.of(change));
-    notes = changeNotesFactory.createChecked(db, change);
-  }
-
-  @After
-  public void tearDown() {
-    if (lifecycle != null) {
-      lifecycle.stop();
-    }
-    requestContext.setContext(null);
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(schemaFactory);
-  }
-
-  @Test
-  public void normalizeByPermission() 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/*");
-    save(pc);
-
-    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)));
-  }
-
-  @Test
-  public void normalizeByType() throws Exception {
-    ProjectConfig pc = loadAllProjects();
-    allow(pc, forLabel("Code-Review"), -5, 5, REGISTERED_USERS, "refs/heads/*");
-    allow(pc, forLabel("Verified"), -5, 5, REGISTERED_USERS, "refs/heads/*");
-    save(pc);
-
-    PatchSetApproval cr = psa(userId, "Code-Review", 5);
-    PatchSetApproval v = psa(userId, "Verified", 5);
-    assertEquals(
-        Result.create(list(), list(copy(cr, 2), copy(v, 1)), list()),
-        norm.normalize(notes, list(cr, v)));
-  }
-
-  @Test
-  public void emptyPermissionRangeOmitsResult() 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)));
-  }
-
-  @Test
-  public void explicitZeroVoteOnNonEmptyRangeIsPresent() throws Exception {
-    ProjectConfig pc = loadAllProjects();
-    allow(pc, forLabel("Code-Review"), -1, 1, REGISTERED_USERS, "refs/heads/*");
-    save(pc);
-
-    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)));
-  }
-
-  private ProjectConfig loadAllProjects() throws Exception {
-    try (Repository repo = repoManager.openRepository(allProjects)) {
-      ProjectConfig pc = new ProjectConfig(allProjects);
-      pc.load(repo);
-      return pc;
-    }
-  }
-
-  private void save(ProjectConfig pc) throws Exception {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(pc.getProject().getNameKey(), user)) {
-      pc.commit(md);
-      projectCache.evict(pc.getProject().getNameKey());
-    }
-  }
-
-  private PatchSetApproval psa(Account.Id accountId, String label, int value) {
-    return new PatchSetApproval(
-        new PatchSetApproval.Key(change.currentPatchSetId(), accountId, new LabelId(label)),
-        (short) value,
-        TimeUtil.nowTs());
-  }
-
-  private PatchSetApproval copy(PatchSetApproval src, int newValue) {
-    PatchSetApproval result = new PatchSetApproval(src.getKey().getParentKey(), src);
-    result.setValue((short) newValue);
-    return result;
-  }
-
-  private static List<PatchSetApproval> list(PatchSetApproval... psas) {
-    return ImmutableList.<PatchSetApproval>copyOf(psas);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
deleted file mode 100644
index 286f694..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
+++ /dev/null
@@ -1,251 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.util.HostPlatform;
-import com.google.gerrit.testutil.TempFileUtil;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import org.easymock.EasyMockSupport;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.RepositoryCache;
-import org.eclipse.jgit.lib.RepositoryCache.FileKey;
-import org.eclipse.jgit.util.FS;
-import org.junit.Before;
-import org.junit.Test;
-
-public class LocalDiskRepositoryManagerTest extends EasyMockSupport {
-
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
-  private Config cfg;
-  private SitePaths site;
-  private LocalDiskRepositoryManager repoManager;
-
-  @Before
-  public void setUp() throws Exception {
-    site = new SitePaths(TempFileUtil.createTempDirectory().toPath());
-    site.resolve("git").toFile().mkdir();
-    cfg = new Config();
-    cfg.setString("gerrit", null, "basePath", "git");
-    repoManager = new LocalDiskRepositoryManager(site, cfg);
-  }
-
-  @Test(expected = IllegalStateException.class)
-  public void testThatNullBasePathThrowsAnException() {
-    new LocalDiskRepositoryManager(site, new Config());
-  }
-
-  @Test
-  public void projectCreation() throws Exception {
-    Project.NameKey projectA = new Project.NameKey("projectA");
-    try (Repository repo = repoManager.createRepository(projectA)) {
-      assertThat(repo).isNotNull();
-    }
-    try (Repository repo = repoManager.openRepository(projectA)) {
-      assertThat(repo).isNotNull();
-    }
-    assertThat(repoManager.list()).containsExactly(projectA);
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithEmptyName() throws Exception {
-    repoManager.createRepository(new Project.NameKey(""));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithTrailingSlash() throws Exception {
-    repoManager.createRepository(new Project.NameKey("projectA/"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithBackSlash() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a\\projectA"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationAbsolutePath() throws Exception {
-    repoManager.createRepository(new Project.NameKey("/projectA"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationStartingWithDotDot() throws Exception {
-    repoManager.createRepository(new Project.NameKey("../projectA"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationContainsDotDot() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a/../projectA"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationDotPathSegment() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a/./projectA"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithTwoSlashes() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a//projectA"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithPathSegmentEndingByDotGit() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a/b.git/projectA"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithQuestionMark() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project?A"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithPercentageSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project%A"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithWidlcard() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project*A"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithColon() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project:A"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithLessThatSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project<A"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithGreaterThatSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project>A"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithPipe() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project|A"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithDollarSign() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project$A"));
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithCarriageReturn() throws Exception {
-    repoManager.createRepository(new Project.NameKey("project\\rA"));
-  }
-
-  @Test(expected = IllegalStateException.class)
-  public void testProjectRecreation() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a"));
-    repoManager.createRepository(new Project.NameKey("a"));
-  }
-
-  @Test(expected = IllegalStateException.class)
-  public void testProjectRecreationAfterRestart() throws Exception {
-    repoManager.createRepository(new Project.NameKey("a"));
-    LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
-    newRepoManager.createRepository(new Project.NameKey("a"));
-  }
-
-  @Test
-  public void openRepositoryCreatedDirectlyOnDisk() throws Exception {
-    Project.NameKey projectA = new Project.NameKey("projectA");
-    createRepository(repoManager.getBasePath(projectA), projectA.get());
-    try (Repository repo = repoManager.openRepository(projectA)) {
-      assertThat(repo).isNotNull();
-    }
-    assertThat(repoManager.list()).containsExactly(projectA);
-  }
-
-  @Test(expected = RepositoryCaseMismatchException.class)
-  public void testNameCaseMismatch() throws Exception {
-    assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
-    repoManager.createRepository(new Project.NameKey("a"));
-    repoManager.createRepository(new Project.NameKey("A"));
-  }
-
-  @Test(expected = RepositoryCaseMismatchException.class)
-  public void testNameCaseMismatchWithSymlink() throws Exception {
-    assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
-    Project.NameKey name = new Project.NameKey("a");
-    repoManager.createRepository(name);
-    createSymLink(name, "b.git");
-    repoManager.createRepository(new Project.NameKey("B"));
-  }
-
-  @Test(expected = RepositoryCaseMismatchException.class)
-  public void testNameCaseMismatchAfterRestart() throws Exception {
-    assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
-    Project.NameKey name = new Project.NameKey("a");
-    repoManager.createRepository(name);
-
-    LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
-    newRepoManager.createRepository(new Project.NameKey("A"));
-  }
-
-  private void createSymLink(Project.NameKey project, String link) throws IOException {
-    Path base = repoManager.getBasePath(project);
-    Path projectDir = base.resolve(project.get() + ".git");
-    Path symlink = base.resolve(link);
-    Files.createSymbolicLink(symlink, projectDir);
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testOpenRepositoryInvalidName() throws Exception {
-    repoManager.openRepository(new Project.NameKey("project%?|<>A"));
-  }
-
-  @Test
-  public void list() throws Exception {
-    Project.NameKey projectA = new Project.NameKey("projectA");
-    createRepository(repoManager.getBasePath(projectA), projectA.get());
-
-    Project.NameKey projectB = new Project.NameKey("path/projectB");
-    createRepository(repoManager.getBasePath(projectB), projectB.get());
-
-    Project.NameKey projectC = new Project.NameKey("anotherPath/path/projectC");
-    createRepository(repoManager.getBasePath(projectC), projectC.get());
-    // create an invalid git repo named only .git
-    repoManager.getBasePath(null).resolve(".git").toFile().mkdir();
-    // create an invalid repo name
-    createRepository(repoManager.getBasePath(null), "project?A");
-    assertThat(repoManager.list()).containsExactly(projectA, projectB, projectC);
-  }
-
-  private void createRepository(Path directory, String projectName) throws IOException {
-    String n = projectName + Constants.DOT_GIT_EXT;
-    FileKey loc = FileKey.exact(directory.resolve(n).toFile(), FS.DETECTED);
-    try (Repository db = RepositoryCache.open(loc, false)) {
-      db.create(true /* bare */);
-    }
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
deleted file mode 100644
index 74515ae..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
+++ /dev/null
@@ -1,164 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.easymock.EasyMock.createNiceMock;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.reset;
-
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.RepositoryConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.testutil.GerritBaseTests;
-import com.google.gerrit.testutil.TempFileUtil;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.SortedSet;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.RepositoryCache;
-import org.eclipse.jgit.lib.RepositoryCache.FileKey;
-import org.eclipse.jgit.util.FS;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class MultiBaseLocalDiskRepositoryManagerTest extends GerritBaseTests {
-  private Config cfg;
-  private SitePaths site;
-  private MultiBaseLocalDiskRepositoryManager repoManager;
-  private RepositoryConfig configMock;
-
-  @Before
-  public void setUp() throws IOException {
-    site = new SitePaths(TempFileUtil.createTempDirectory().toPath());
-    site.resolve("git").toFile().mkdir();
-    cfg = new Config();
-    cfg.setString("gerrit", null, "basePath", "git");
-    configMock = createNiceMock(RepositoryConfig.class);
-    expect(configMock.getAllBasePaths()).andReturn(new ArrayList<Path>()).anyTimes();
-    replay(configMock);
-    repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
-  }
-
-  @After
-  public void tearDown() throws IOException {
-    TempFileUtil.cleanup();
-  }
-
-  @Test
-  public void defaultRepositoryLocation()
-      throws RepositoryCaseMismatchException, RepositoryNotFoundException, IOException {
-    Project.NameKey someProjectKey = new Project.NameKey("someProject");
-    Repository repo = repoManager.createRepository(someProjectKey);
-    assertThat(repo.getDirectory()).isNotNull();
-    assertThat(repo.getDirectory().exists()).isTrue();
-    assertThat(repo.getDirectory().getParent())
-        .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
-
-    repo = repoManager.openRepository(someProjectKey);
-    assertThat(repo.getDirectory()).isNotNull();
-    assertThat(repo.getDirectory().exists()).isTrue();
-    assertThat(repo.getDirectory().getParent())
-        .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
-
-    assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
-        .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
-
-    SortedSet<Project.NameKey> repoList = repoManager.list();
-    assertThat(repoList.size()).isEqualTo(1);
-    assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
-        .isEqualTo(new Project.NameKey[] {someProjectKey});
-  }
-
-  @Test
-  public void alternateRepositoryLocation() throws IOException {
-    Path alternateBasePath = TempFileUtil.createTempDirectory().toPath();
-    Project.NameKey someProjectKey = new Project.NameKey("someProject");
-    reset(configMock);
-    expect(configMock.getBasePath(someProjectKey)).andReturn(alternateBasePath).anyTimes();
-    expect(configMock.getAllBasePaths()).andReturn(Arrays.asList(alternateBasePath)).anyTimes();
-    replay(configMock);
-
-    Repository repo = repoManager.createRepository(someProjectKey);
-    assertThat(repo.getDirectory()).isNotNull();
-    assertThat(repo.getDirectory().exists()).isTrue();
-    assertThat(repo.getDirectory().getParent()).isEqualTo(alternateBasePath.toString());
-
-    repo = repoManager.openRepository(someProjectKey);
-    assertThat(repo.getDirectory()).isNotNull();
-    assertThat(repo.getDirectory().exists()).isTrue();
-    assertThat(repo.getDirectory().getParent()).isEqualTo(alternateBasePath.toString());
-
-    assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
-        .isEqualTo(alternateBasePath.toString());
-
-    SortedSet<Project.NameKey> repoList = repoManager.list();
-    assertThat(repoList.size()).isEqualTo(1);
-    assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
-        .isEqualTo(new Project.NameKey[] {someProjectKey});
-  }
-
-  @Test
-  public void listReturnRepoFromProperLocation() throws IOException {
-    Project.NameKey basePathProject = new Project.NameKey("basePathProject");
-    Project.NameKey altPathProject = new Project.NameKey("altPathProject");
-    Project.NameKey misplacedProject1 = new Project.NameKey("misplacedProject1");
-    Project.NameKey misplacedProject2 = new Project.NameKey("misplacedProject2");
-
-    Path alternateBasePath = TempFileUtil.createTempDirectory().toPath();
-
-    reset(configMock);
-    expect(configMock.getBasePath(altPathProject)).andReturn(alternateBasePath).anyTimes();
-    expect(configMock.getBasePath(misplacedProject2)).andReturn(alternateBasePath).anyTimes();
-    expect(configMock.getAllBasePaths()).andReturn(Arrays.asList(alternateBasePath)).anyTimes();
-    replay(configMock);
-
-    repoManager.createRepository(basePathProject);
-    repoManager.createRepository(altPathProject);
-    // create the misplaced ones without the repomanager otherwise they would
-    // end up at the proper place.
-    createRepository(repoManager.getBasePath(basePathProject), misplacedProject2);
-    createRepository(alternateBasePath, misplacedProject1);
-
-    SortedSet<Project.NameKey> repoList = repoManager.list();
-    assertThat(repoList.size()).isEqualTo(2);
-    assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
-        .isEqualTo(new Project.NameKey[] {altPathProject, basePathProject});
-  }
-
-  private void createRepository(Path directory, Project.NameKey projectName) throws IOException {
-    String n = projectName.get() + Constants.DOT_GIT_EXT;
-    FileKey loc = FileKey.exact(directory.resolve(n).toFile(), FS.DETECTED);
-    try (Repository db = RepositoryCache.open(loc, false)) {
-      db.create(true /* bare */);
-    }
-  }
-
-  @Test(expected = IllegalStateException.class)
-  public void testRelativeAlternateLocation() {
-    configMock = createNiceMock(RepositoryConfig.class);
-    expect(configMock.getAllBasePaths()).andReturn(Arrays.asList(Paths.get("repos"))).anyTimes();
-    replay(configMock);
-    repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java
deleted file mode 100644
index ed7a005..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java
+++ /dev/null
@@ -1,526 +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.git;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
-
-import com.google.common.collect.Iterables;
-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.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.config.PluginConfig;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
-import java.io.IOException;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Map;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.junit.LocalDiskRepositoryTestCase;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.util.RawParseUtils;
-import org.junit.Before;
-import org.junit.BeforeClass;
-import org.junit.Test;
-
-public class ProjectConfigTest extends LocalDiskRepositoryTestCase {
-  private static final String LABEL_SCORES_CONFIG =
-      "  copyMinScore = "
-          + !LabelType.DEF_COPY_MIN_SCORE
-          + "\n" //
-          + "  copyMaxScore = "
-          + !LabelType.DEF_COPY_MAX_SCORE
-          + "\n" //
-          + "  copyAllScoresOnMergeFirstParentUpdate = "
-          + !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE
-          + "\n" //
-          + "  copyAllScoresOnTrivialRebase = "
-          + !LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE
-          + "\n" //
-          + "  copyAllScoresIfNoCodeChange = "
-          + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE
-          + "\n" //
-          + "  copyAllScoresIfNoChange = "
-          + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE
-          + "\n";
-
-  private final GroupReference developers =
-      new GroupReference(new AccountGroup.UUID("X"), "Developers");
-  private final GroupReference staff = new GroupReference(new AccountGroup.UUID("Y"), "Staff");
-
-  private Repository db;
-  private TestRepository<Repository> util;
-
-  @BeforeClass
-  public static void setUpOnce() {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
-  @Override
-  @Before
-  public void setUp() throws Exception {
-    super.setUp();
-    db = createBareRepository();
-    util = new TestRepository<>(db);
-  }
-
-  @Test
-  public void readConfig() throws Exception {
-    RevCommit rev =
-        util.commit(
-            util.tree( //
-                util.file("groups", util.blob(group(developers))), //
-                util.file(
-                    "project.config",
-                    util.blob(
-                        "" //
-                            + "[access \"refs/heads/*\"]\n" //
-                            + "  exclusiveGroupPermissions = read submit create\n" //
-                            + "  submit = group Developers\n" //
-                            + "  push = group Developers\n" //
-                            + "  read = group Developers\n" //
-                            + "[accounts]\n" //
-                            + "  sameGroupVisibility = deny group Developers\n" //
-                            + "  sameGroupVisibility = block group Staff\n" //
-                            + "[contributor-agreement \"Individual\"]\n" //
-                            + "  description = A simple description\n" //
-                            + "  accepted = group Developers\n" //
-                            + "  accepted = group Staff\n" //
-                            + "  autoVerify = group Developers\n" //
-                            + "  agreementUrl = http://www.example.com/agree\n")) //
-                ));
-
-    ProjectConfig cfg = read(rev);
-    assertThat(cfg.getAccountsSection().getSameGroupVisibility()).hasSize(2);
-    ContributorAgreement ca = cfg.getContributorAgreement("Individual");
-    assertThat(ca.getName()).isEqualTo("Individual");
-    assertThat(ca.getDescription()).isEqualTo("A simple description");
-    assertThat(ca.getAgreementUrl()).isEqualTo("http://www.example.com/agree");
-    assertThat(ca.getAccepted()).hasSize(2);
-    assertThat(ca.getAccepted().get(0).getGroup()).isEqualTo(developers);
-    assertThat(ca.getAccepted().get(1).getGroup().getName()).isEqualTo("Staff");
-    assertThat(ca.getAutoVerify().getName()).isEqualTo("Developers");
-
-    AccessSection section = cfg.getAccessSection("refs/heads/*");
-    assertThat(section).isNotNull();
-    assertThat(cfg.getAccessSection("refs/*")).isNull();
-
-    Permission create = section.getPermission(Permission.CREATE);
-    Permission submit = section.getPermission(Permission.SUBMIT);
-    Permission read = section.getPermission(Permission.READ);
-    Permission push = section.getPermission(Permission.PUSH);
-
-    assertThat(create.getExclusiveGroup()).isTrue();
-    assertThat(submit.getExclusiveGroup()).isTrue();
-    assertThat(read.getExclusiveGroup()).isTrue();
-    assertThat(push.getExclusiveGroup()).isFalse();
-  }
-
-  @Test
-  public void readConfigLabelDefaultValue() throws Exception {
-    RevCommit rev =
-        util.commit(
-            util.tree( //
-                util.file("groups", util.blob(group(developers))), //
-                util.file(
-                    "project.config",
-                    util.blob(
-                        "" //
-                            + "[label \"CustomLabel\"]\n" //
-                            + "  value = -1 Negative\n" //
-                            + "  value =  0 No Score\n" //
-                            + "  value =  1 Positive\n")) //
-                ));
-
-    ProjectConfig cfg = read(rev);
-    Map<String, LabelType> labels = cfg.getLabelSections();
-    Short dv = labels.entrySet().iterator().next().getValue().getDefaultValue();
-    assertThat((int) dv).isEqualTo(0);
-  }
-
-  @Test
-  public void readConfigLabelDefaultValueInRange() throws Exception {
-    RevCommit rev =
-        util.commit(
-            util.tree( //
-                util.file("groups", util.blob(group(developers))), //
-                util.file(
-                    "project.config",
-                    util.blob(
-                        "" //
-                            + "[label \"CustomLabel\"]\n" //
-                            + "  value = -1 Negative\n" //
-                            + "  value =  0 No Score\n" //
-                            + "  value =  1 Positive\n" //
-                            + "  defaultValue = -1\n")) //
-                ));
-
-    ProjectConfig cfg = read(rev);
-    Map<String, LabelType> labels = cfg.getLabelSections();
-    Short dv = labels.entrySet().iterator().next().getValue().getDefaultValue();
-    assertThat((int) dv).isEqualTo(-1);
-  }
-
-  @Test
-  public void readConfigLabelDefaultValueNotInRange() throws Exception {
-    RevCommit rev =
-        util.commit(
-            util.tree( //
-                util.file("groups", util.blob(group(developers))), //
-                util.file(
-                    "project.config",
-                    util.blob(
-                        "" //
-                            + "[label \"CustomLabel\"]\n" //
-                            + "  value = -1 Negative\n" //
-                            + "  value =  0 No Score\n" //
-                            + "  value =  1 Positive\n" //
-                            + "  defaultValue = -2\n")) //
-                ));
-
-    ProjectConfig cfg = read(rev);
-    assertThat(cfg.getValidationErrors()).hasSize(1);
-    assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
-        .isEqualTo("project.config: Invalid defaultValue \"-2\" for label \"CustomLabel\"");
-  }
-
-  @Test
-  public void readConfigLabelScores() throws Exception {
-    RevCommit rev =
-        util.commit(
-            util.tree( //
-                util.file("groups", util.blob(group(developers))), //
-                util.file(
-                    "project.config",
-                    util.blob(
-                        "" //
-                            + "[label \"CustomLabel\"]\n" //
-                            + LABEL_SCORES_CONFIG)) //
-                ));
-
-    ProjectConfig cfg = read(rev);
-    Map<String, LabelType> labels = cfg.getLabelSections();
-    LabelType type = labels.entrySet().iterator().next().getValue();
-    assertThat(type.isCopyMinScore()).isNotEqualTo(LabelType.DEF_COPY_MIN_SCORE);
-    assertThat(type.isCopyMaxScore()).isNotEqualTo(LabelType.DEF_COPY_MAX_SCORE);
-    assertThat(type.isCopyAllScoresOnMergeFirstParentUpdate())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
-    assertThat(type.isCopyAllScoresOnTrivialRebase())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
-    assertThat(type.isCopyAllScoresIfNoCodeChange())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
-    assertThat(type.isCopyAllScoresIfNoChange())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
-  }
-
-  @Test
-  public void editConfig() throws Exception {
-    RevCommit rev =
-        util.commit(
-            util.tree( //
-                util.file("groups", util.blob(group(developers))), //
-                util.file(
-                    "project.config",
-                    util.blob(
-                        "" //
-                            + "[access \"refs/heads/*\"]\n" //
-                            + "  exclusiveGroupPermissions = read submit\n" //
-                            + "  submit = group Developers\n" //
-                            + "  upload = group Developers\n" //
-                            + "  read = group Developers\n" //
-                            + "[accounts]\n" //
-                            + "  sameGroupVisibility = deny group Developers\n" //
-                            + "  sameGroupVisibility = block group Staff\n" //
-                            + "[contributor-agreement \"Individual\"]\n" //
-                            + "  description = A simple description\n" //
-                            + "  accepted = group Developers\n" //
-                            + "  autoVerify = group Developers\n" //
-                            + "  agreementUrl = http://www.example.com/agree\n" //
-                            + "[label \"CustomLabel\"]\n" //
-                            + LABEL_SCORES_CONFIG)) //
-                ));
-    update(rev);
-
-    ProjectConfig cfg = read(rev);
-    AccessSection section = cfg.getAccessSection("refs/heads/*");
-    cfg.getAccountsSection()
-        .setSameGroupVisibility(Collections.singletonList(new PermissionRule(cfg.resolve(staff))));
-    Permission submit = section.getPermission(Permission.SUBMIT);
-    submit.add(new PermissionRule(cfg.resolve(staff)));
-    ContributorAgreement ca = cfg.getContributorAgreement("Individual");
-    ca.setAccepted(Collections.singletonList(new PermissionRule(cfg.resolve(staff))));
-    ca.setAutoVerify(null);
-    ca.setDescription("A new description");
-    rev = commit(cfg);
-    assertThat(text(rev, "project.config"))
-        .isEqualTo(
-            "" //
-                + "[access \"refs/heads/*\"]\n" //
-                + "  exclusiveGroupPermissions = read submit\n" //
-                + "  submit = group Developers\n" //
-                + "\tsubmit = group Staff\n" //
-                + "  upload = group Developers\n" //
-                + "  read = group Developers\n" //
-                + "[accounts]\n" //
-                + "  sameGroupVisibility = group Staff\n" //
-                + "[contributor-agreement \"Individual\"]\n" //
-                + "  description = A new description\n" //
-                + "  accepted = group Staff\n" //
-                + "  agreementUrl = http://www.example.com/agree\n"
-                + "[label \"CustomLabel\"]\n" //
-                + LABEL_SCORES_CONFIG
-                + "\tfunction = MaxWithBlock\n" // label gets this function when it is created
-                + "\tdefaultValue = 0\n"); //  label gets this value when it is created
-  }
-
-  @Test
-  public void editConfigMissingGroupTableEntry() throws Exception {
-    RevCommit rev =
-        util.commit(
-            util.tree( //
-                util.file("groups", util.blob(group(developers))), //
-                util.file(
-                    "project.config",
-                    util.blob(
-                        "" //
-                            + "[access \"refs/heads/*\"]\n" //
-                            + "  exclusiveGroupPermissions = read submit\n" //
-                            + "  submit = group People Who Can Submit\n" //
-                            + "  upload = group Developers\n" //
-                            + "  read = group Developers\n")) //
-                ));
-    update(rev);
-
-    ProjectConfig cfg = read(rev);
-    AccessSection section = cfg.getAccessSection("refs/heads/*");
-    Permission submit = section.getPermission(Permission.SUBMIT);
-    submit.add(new PermissionRule(cfg.resolve(staff)));
-    rev = commit(cfg);
-    assertThat(text(rev, "project.config"))
-        .isEqualTo(
-            "" //
-                + "[access \"refs/heads/*\"]\n" //
-                + "  exclusiveGroupPermissions = read submit\n" //
-                + "  submit = group People Who Can Submit\n" //
-                + "\tsubmit = group Staff\n" //
-                + "  upload = group Developers\n" //
-                + "  read = group Developers\n");
-  }
-
-  @Test
-  public void readExistingPluginConfig() throws Exception {
-    RevCommit rev =
-        util.commit(
-            util.tree( //
-                util.file(
-                    "project.config",
-                    util.blob(
-                        "" //
-                            + "[plugin \"somePlugin\"]\n" //
-                            + "  key1 = value1\n" //
-                            + "  key2 = value2a\n"
-                            + "  key2 = value2b\n")) //
-                ));
-    update(rev);
-
-    ProjectConfig cfg = read(rev);
-    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
-    assertThat(pluginCfg.getNames().size()).isEqualTo(2);
-    assertThat(pluginCfg.getString("key1")).isEqualTo("value1");
-    assertThat(pluginCfg.getStringList(("key2"))).isEqualTo(new String[] {"value2a", "value2b"});
-  }
-
-  @Test
-  public void readUnexistingPluginConfig() throws Exception {
-    ProjectConfig cfg = new ProjectConfig(new Project.NameKey("test"));
-    cfg.load(db);
-    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
-    assertThat(pluginCfg.getNames()).isEmpty();
-  }
-
-  @Test
-  public void editPluginConfig() throws Exception {
-    RevCommit rev =
-        util.commit(
-            util.tree( //
-                util.file(
-                    "project.config",
-                    util.blob(
-                        "" //
-                            + "[plugin \"somePlugin\"]\n" //
-                            + "  key1 = value1\n" //
-                            + "  key2 = value2a\n" //
-                            + "  key2 = value2b\n")) //
-                ));
-    update(rev);
-
-    ProjectConfig cfg = read(rev);
-    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
-    pluginCfg.setString("key1", "updatedValue1");
-    pluginCfg.setStringList("key2", Arrays.asList("updatedValue2a", "updatedValue2b"));
-    rev = commit(cfg);
-    assertThat(text(rev, "project.config"))
-        .isEqualTo(
-            "" //
-                + "[plugin \"somePlugin\"]\n" //
-                + "\tkey1 = updatedValue1\n" //
-                + "\tkey2 = updatedValue2a\n" //
-                + "\tkey2 = updatedValue2b\n");
-  }
-
-  @Test
-  public void readPluginConfigGroupReference() throws Exception {
-    RevCommit rev =
-        util.commit(
-            util.tree( //
-                util.file("groups", util.blob(group(developers))), //
-                util.file(
-                    "project.config",
-                    util.blob(
-                        "" //
-                            + "[plugin \"somePlugin\"]\n" //
-                            + "key1 = "
-                            + developers.toConfigValue()
-                            + "\n")) //
-                ));
-    update(rev);
-
-    ProjectConfig cfg = read(rev);
-    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
-    assertThat(pluginCfg.getNames().size()).isEqualTo(1);
-    assertThat(pluginCfg.getGroupReference("key1")).isEqualTo(developers);
-  }
-
-  @Test
-  public void readPluginConfigGroupReferenceNotInGroupsFile() throws Exception {
-    RevCommit rev =
-        util.commit(
-            util.tree( //
-                util.file("groups", util.blob(group(developers))), //
-                util.file(
-                    "project.config",
-                    util.blob(
-                        "" //
-                            + "[plugin \"somePlugin\"]\n" //
-                            + "key1 = "
-                            + staff.toConfigValue()
-                            + "\n")) //
-                ));
-    update(rev);
-
-    ProjectConfig cfg = read(rev);
-    assertThat(cfg.getValidationErrors()).hasSize(1);
-    assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
-        .isEqualTo(
-            "project.config: group \"" + staff.getName() + "\" not in " + GroupList.FILE_NAME);
-  }
-
-  @Test
-  public void editPluginConfigGroupReference() throws Exception {
-    RevCommit rev =
-        util.commit(
-            util.tree( //
-                util.file("groups", util.blob(group(developers))), //
-                util.file(
-                    "project.config",
-                    util.blob(
-                        "" //
-                            + "[plugin \"somePlugin\"]\n" //
-                            + "key1 = "
-                            + developers.toConfigValue()
-                            + "\n")) //
-                ));
-    update(rev);
-
-    ProjectConfig cfg = read(rev);
-    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
-    assertThat(pluginCfg.getNames().size()).isEqualTo(1);
-    assertThat(pluginCfg.getGroupReference("key1")).isEqualTo(developers);
-
-    pluginCfg.setGroupReference("key1", staff);
-    rev = commit(cfg);
-    assertThat(text(rev, "project.config"))
-        .isEqualTo(
-            "" //
-                + "[plugin \"somePlugin\"]\n" //
-                + "\tkey1 = "
-                + staff.toConfigValue()
-                + "\n");
-    assertThat(text(rev, "groups"))
-        .isEqualTo(
-            "# UUID\tGroup Name\n" //
-                + "#\n" //
-                + staff.getUUID().get()
-                + "     \t"
-                + staff.getName()
-                + "\n");
-  }
-
-  private ProjectConfig read(RevCommit rev) throws IOException, ConfigInvalidException {
-    ProjectConfig cfg = new ProjectConfig(new Project.NameKey("test"));
-    cfg.load(db, rev);
-    return cfg;
-  }
-
-  private RevCommit commit(ProjectConfig cfg)
-      throws IOException, MissingObjectException, IncorrectObjectTypeException {
-    try (MetaDataUpdate md =
-        new MetaDataUpdate(GitReferenceUpdated.DISABLED, cfg.getProject().getNameKey(), db)) {
-      util.tick(5);
-      util.setAuthorAndCommitter(md.getCommitBuilder());
-      md.setMessage("Edit\n");
-      cfg.commit(md);
-
-      Ref ref = db.exactRef(RefNames.REFS_CONFIG);
-      return util.getRevWalk().parseCommit(ref.getObjectId());
-    }
-  }
-
-  private void update(RevCommit rev) throws Exception {
-    RefUpdate u = db.updateRef(RefNames.REFS_CONFIG);
-    u.disableRefLog();
-    u.setNewObjectId(rev);
-    Result result = u.forceUpdate();
-    assertWithMessage("Cannot update ref for test: " + result)
-        .that(result)
-        .isAnyOf(Result.FAST_FORWARD, Result.FORCED, Result.NEW, Result.NO_CHANGE);
-  }
-
-  private String text(RevCommit rev, String path) throws Exception {
-    RevObject blob = util.get(rev.getTree(), path);
-    byte[] data = db.open(blob).getCachedBytes(Integer.MAX_VALUE);
-    return RawParseUtils.decode(data);
-  }
-
-  private static String group(GroupReference g) {
-    return g.getUUID().get() + "\t" + g.getName() + "\n";
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/account/AccountFieldTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/account/AccountFieldTest.java
deleted file mode 100644
index 7eed034..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/account/AccountFieldTest.java
+++ /dev/null
@@ -1,94 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.account;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Streams;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.testutil.GerritBaseTests;
-import java.util.List;
-import org.eclipse.jgit.lib.ObjectId;
-import org.junit.Test;
-
-public class AccountFieldTest extends GerritBaseTests {
-  @Test
-  public void refStateFieldValues() throws Exception {
-    AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
-    Account account = new Account(new Account.Id(1), TimeUtil.nowTs());
-    String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
-    account.setMetaId(metaId);
-    List<String> values =
-        toStrings(
-            AccountField.REF_STATE.get(
-                new AccountState(
-                    allUsersName,
-                    account,
-                    ImmutableSet.of(),
-                    ImmutableSet.of(),
-                    ImmutableMap.of())));
-    assertThat(values).hasSize(1);
-    String expectedValue =
-        allUsersName.get() + ":" + RefNames.refsUsers(account.getId()) + ":" + metaId;
-    assertThat(Iterables.getOnlyElement(values)).isEqualTo(expectedValue);
-  }
-
-  @Test
-  public void externalIdStateFieldValues() throws Exception {
-    Account.Id id = new Account.Id(1);
-    Account account = new Account(id, TimeUtil.nowTs());
-    ExternalId extId1 =
-        ExternalId.create(
-            ExternalId.Key.create(ExternalId.SCHEME_MAILTO, "foo.bar@example.com"),
-            id,
-            "foo.bar@example.com",
-            null,
-            ObjectId.fromString("1b9a0cf038ea38a0ab08617c39aa8e28413a27ca"));
-    ExternalId extId2 =
-        ExternalId.create(
-            ExternalId.Key.create(ExternalId.SCHEME_USERNAME, "foo"),
-            id,
-            null,
-            "secret",
-            ObjectId.fromString("5b3a73dc9a668a5b89b5f049225261e3e3291d1a"));
-    List<String> values =
-        toStrings(
-            AccountField.EXTERNAL_ID_STATE.get(
-                new AccountState(
-                    null,
-                    account,
-                    ImmutableSet.of(),
-                    ImmutableSet.of(extId1, extId2),
-                    ImmutableMap.of())));
-    String expectedValue1 = extId1.key().sha1().name() + ":" + extId1.blobId().name();
-    String expectedValue2 = extId2.key().sha1().name() + ":" + extId2.blobId().name();
-    assertThat(values).containsExactly(expectedValue1, expectedValue2);
-  }
-
-  private List<String> toStrings(Iterable<byte[]> values) {
-    return Streams.stream(values).map(v -> new String(v, UTF_8)).collect(toList());
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java
deleted file mode 100644
index 3a4db30..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ /dev/null
@@ -1,120 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.HashBasedTable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Table;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.testutil.GerritBaseTests;
-import com.google.gerrit.testutil.TestTimeUtil;
-import java.sql.Timestamp;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class ChangeFieldTest extends GerritBaseTests {
-  @Before
-  public void setUp() {
-    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
-  }
-
-  @After
-  public void tearDown() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @Test
-  public void reviewerFieldValues() {
-    Table<ReviewerStateInternal, Account.Id, Timestamp> t = HashBasedTable.create();
-    Timestamp t1 = TimeUtil.nowTs();
-    t.put(ReviewerStateInternal.REVIEWER, new Account.Id(1), t1);
-    Timestamp t2 = TimeUtil.nowTs();
-    t.put(ReviewerStateInternal.CC, new Account.Id(2), t2);
-    ReviewerSet reviewers = ReviewerSet.fromTable(t);
-
-    List<String> values = ChangeField.getReviewerFieldValues(reviewers);
-    assertThat(values)
-        .containsExactly(
-            "REVIEWER,1", "REVIEWER,1," + t1.getTime(), "CC,2", "CC,2," + t2.getTime());
-
-    assertThat(ChangeField.parseReviewerFieldValues(values)).isEqualTo(reviewers);
-  }
-
-  @Test
-  public void formatSubmitRecordValues() {
-    assertThat(
-            ChangeField.formatSubmitRecordValues(
-                ImmutableList.of(
-                    record(
-                        SubmitRecord.Status.OK,
-                        label(SubmitRecord.Label.Status.MAY, "Label-1", null),
-                        label(SubmitRecord.Label.Status.OK, "Label-2", 1))),
-                new Account.Id(1)))
-        .containsExactly("OK", "MAY,label-1", "OK,label-2", "OK,label-2,0", "OK,label-2,1");
-  }
-
-  @Test
-  public void storedSubmitRecords() {
-    assertStoredRecordRoundTrip(record(SubmitRecord.Status.CLOSED));
-    assertStoredRecordRoundTrip(
-        record(
-            SubmitRecord.Status.OK,
-            label(SubmitRecord.Label.Status.MAY, "Label-1", null),
-            label(SubmitRecord.Label.Status.OK, "Label-2", 1)));
-  }
-
-  private static SubmitRecord record(SubmitRecord.Status status, SubmitRecord.Label... labels) {
-    SubmitRecord r = new SubmitRecord();
-    r.status = status;
-    if (labels.length > 0) {
-      r.labels = ImmutableList.copyOf(labels);
-    }
-    return r;
-  }
-
-  private static SubmitRecord.Label label(
-      SubmitRecord.Label.Status status, String label, Integer appliedBy) {
-    SubmitRecord.Label l = new SubmitRecord.Label();
-    l.status = status;
-    l.label = label;
-    if (appliedBy != null) {
-      l.appliedBy = new Account.Id(appliedBy);
-    }
-    return l;
-  }
-
-  private static void assertStoredRecordRoundTrip(SubmitRecord... records) {
-    List<SubmitRecord> recordList = ImmutableList.copyOf(records);
-    List<String> stored =
-        ChangeField.storedSubmitRecords(recordList)
-            .stream()
-            .map(s -> new String(s, UTF_8))
-            .collect(toList());
-    assertThat(ChangeField.parseSubmitRecords(stored))
-        .named("JSON %s" + stored)
-        .isEqualTo(recordList);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
deleted file mode 100644
index 4a6663a..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ /dev/null
@@ -1,262 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
-import static com.google.gerrit.index.query.Predicate.and;
-import static com.google.gerrit.index.query.Predicate.or;
-import static com.google.gerrit.reviewdb.client.Change.Status.MERGED;
-import static com.google.gerrit.reviewdb.client.Change.Status.NEW;
-import static com.google.gerrit.server.index.change.IndexedChangeQuery.convertOptions;
-import static org.junit.Assert.assertEquals;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.query.change.AndChangeSource;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.ChangeStatusPredicate;
-import com.google.gerrit.server.query.change.OrSource;
-import com.google.gerrit.testutil.GerritBaseTests;
-import java.util.Arrays;
-import java.util.EnumSet;
-import java.util.Set;
-import org.junit.Before;
-import org.junit.Test;
-
-public class ChangeIndexRewriterTest extends GerritBaseTests {
-  private static final IndexConfig CONFIG = IndexConfig.createDefault();
-
-  private FakeChangeIndex index;
-  private ChangeIndexCollection indexes;
-  private ChangeQueryBuilder queryBuilder;
-  private ChangeIndexRewriter rewrite;
-
-  @Before
-  public void setUp() throws Exception {
-    index = new FakeChangeIndex(FakeChangeIndex.V2);
-    indexes = new ChangeIndexCollection();
-    indexes.setSearchIndex(index);
-    queryBuilder = new FakeQueryBuilder(indexes);
-    rewrite = new ChangeIndexRewriter(indexes, IndexConfig.builder().maxTerms(3).build());
-  }
-
-  @Test
-  public void indexPredicate() throws Exception {
-    Predicate<ChangeData> in = parse("file:a");
-    assertThat(rewrite(in)).isEqualTo(query(in));
-  }
-
-  @Test
-  public void nonIndexPredicate() throws Exception {
-    Predicate<ChangeData> in = parse("foo:a");
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameAs(out.getClass());
-    assertThat(out.getChildren())
-        .containsExactly(query(ChangeStatusPredicate.open()), in)
-        .inOrder();
-  }
-
-  @Test
-  public void indexPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("file:a file:b");
-    assertThat(rewrite(in)).isEqualTo(query(in));
-  }
-
-  @Test
-  public void nonIndexPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("foo:a OR foo:b");
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameAs(out.getClass());
-    assertThat(out.getChildren())
-        .containsExactly(query(ChangeStatusPredicate.open()), in)
-        .inOrder();
-  }
-
-  @Test
-  public void oneIndexPredicate() throws Exception {
-    Predicate<ChangeData> in = parse("foo:a file:b");
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameAs(out.getClass());
-    assertThat(out.getChildren()).containsExactly(query(in.getChild(1)), in.getChild(0)).inOrder();
-  }
-
-  @Test
-  public void threeLevelTreeWithAllIndexPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("-status:abandoned (file:a OR file:b)");
-    assertThat(rewrite.rewrite(in, options(0, DEFAULT_MAX_QUERY_LIMIT))).isEqualTo(query(in));
-  }
-
-  @Test
-  public void threeLevelTreeWithSomeIndexPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("-foo:a (file:b OR file:c)");
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(out.getClass()).isSameAs(AndChangeSource.class);
-    assertThat(out.getChildren()).containsExactly(query(in.getChild(1)), in.getChild(0)).inOrder();
-  }
-
-  @Test
-  public void multipleIndexPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("file:a OR foo:b OR file:c OR foo:d");
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(out.getClass()).isSameAs(OrSource.class);
-    assertThat(out.getChildren())
-        .containsExactly(query(or(in.getChild(0), in.getChild(2))), in.getChild(1), in.getChild(3))
-        .inOrder();
-  }
-
-  @Test
-  public void indexAndNonIndexPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("status:new bar:p file:a");
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameAs(out.getClass());
-    assertThat(out.getChildren())
-        .containsExactly(query(and(in.getChild(0), in.getChild(2))), in.getChild(1))
-        .inOrder();
-  }
-
-  @Test
-  public void duplicateCompoundNonIndexOnlyPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("status:new bar:p file:a");
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
-    assertThat(out.getChildren())
-        .containsExactly(query(and(in.getChild(0), in.getChild(2))), in.getChild(1))
-        .inOrder();
-  }
-
-  @Test
-  public void duplicateCompoundIndexOnlyPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("(status:new OR file:a) bar:p file:b");
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
-    assertThat(out.getChildren())
-        .containsExactly(query(and(in.getChild(0), in.getChild(2))), in.getChild(1))
-        .inOrder();
-  }
-
-  @Test
-  public void optionsArgumentOverridesAllLimitPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("limit:1 file:a limit:3");
-    Predicate<ChangeData> out = rewrite(in, options(0, 5));
-    assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
-    assertThat(out.getChildren())
-        .containsExactly(query(in.getChild(1), 5), parse("limit:5"), parse("limit:5"))
-        .inOrder();
-  }
-
-  @Test
-  public void startIncreasesLimitInQueryButNotPredicate() throws Exception {
-    int n = 3;
-    Predicate<ChangeData> f = parse("file:a");
-    Predicate<ChangeData> l = parse("limit:" + n);
-    Predicate<ChangeData> in = andSource(f, l);
-    assertThat(rewrite.rewrite(in, options(0, n))).isEqualTo(andSource(query(f, 3), l));
-    assertThat(rewrite.rewrite(in, options(1, n))).isEqualTo(andSource(query(f, 4), l));
-    assertThat(rewrite.rewrite(in, options(2, n))).isEqualTo(andSource(query(f, 5), l));
-  }
-
-  @Test
-  public void getPossibleStatus() throws Exception {
-    Set<Change.Status> all = EnumSet.allOf(Change.Status.class);
-    assertThat(status("file:a")).isEqualTo(all);
-    assertThat(status("is:new")).containsExactly(NEW);
-    assertThat(status("is:new OR is:merged")).containsExactly(NEW, MERGED);
-    assertThat(status("is:new OR is:x")).isEqualTo(all);
-
-    assertThat(status("is:new is:merged")).isEmpty();
-    assertThat(status("(is:new) (is:merged)")).isEmpty();
-    assertThat(status("(is:new) (is:merged)")).isEmpty();
-    assertThat(status("is:new is:x")).containsExactly(NEW);
-  }
-
-  @Test
-  public void unsupportedIndexOperator() throws Exception {
-    Predicate<ChangeData> in = parse("status:merged file:a");
-    assertThat(rewrite(in)).isEqualTo(query(in));
-
-    indexes.setSearchIndex(new FakeChangeIndex(FakeChangeIndex.V1));
-
-    exception.expect(QueryParseException.class);
-    exception.expectMessage("Unsupported index predicate: file:a");
-    rewrite(in);
-  }
-
-  @Test
-  public void tooManyTerms() throws Exception {
-    String q = "file:a OR file:b OR file:c";
-    Predicate<ChangeData> in = parse(q);
-    assertEquals(query(in), rewrite(in));
-
-    exception.expect(QueryParseException.class);
-    exception.expectMessage("too many terms in query");
-    rewrite(parse(q + " OR file:d"));
-  }
-
-  @Test
-  public void testConvertOptions() throws Exception {
-    assertEquals(options(0, 3), convertOptions(options(0, 3)));
-    assertEquals(options(0, 4), convertOptions(options(1, 3)));
-    assertEquals(options(0, 5), convertOptions(options(2, 3)));
-  }
-
-  @Test
-  public void addingStartToLimitDoesNotExceedBackendLimit() throws Exception {
-    int max = CONFIG.maxLimit();
-    assertEquals(options(0, max), convertOptions(options(0, max)));
-    assertEquals(options(0, max), convertOptions(options(1, max)));
-    assertEquals(options(0, max), convertOptions(options(1, max - 1)));
-    assertEquals(options(0, max), convertOptions(options(2, max - 1)));
-  }
-
-  private Predicate<ChangeData> parse(String query) throws QueryParseException {
-    return queryBuilder.parse(query);
-  }
-
-  @SafeVarargs
-  private static AndChangeSource andSource(Predicate<ChangeData>... preds) {
-    return new AndChangeSource(Arrays.asList(preds));
-  }
-
-  private Predicate<ChangeData> rewrite(Predicate<ChangeData> in) throws QueryParseException {
-    return rewrite.rewrite(in, options(0, DEFAULT_MAX_QUERY_LIMIT));
-  }
-
-  private Predicate<ChangeData> rewrite(Predicate<ChangeData> in, QueryOptions opts)
-      throws QueryParseException {
-    return rewrite.rewrite(in, opts);
-  }
-
-  private IndexedChangeQuery query(Predicate<ChangeData> p) throws QueryParseException {
-    return query(p, DEFAULT_MAX_QUERY_LIMIT);
-  }
-
-  private IndexedChangeQuery query(Predicate<ChangeData> p, int limit) throws QueryParseException {
-    return new IndexedChangeQuery(index, p, options(0, limit));
-  }
-
-  private static QueryOptions options(int start, int limit) {
-    return IndexedChangeQuery.createOptions(CONFIG, start, limit, ImmutableSet.<String>of());
-  }
-
-  private Set<Change.Status> status(String query) throws QueryParseException {
-    return ChangeIndexRewriter.getPossibleStatus(parse(query));
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java
deleted file mode 100644
index 74e1c09..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.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.server.index.change;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import org.junit.Ignore;
-
-@Ignore
-public class FakeChangeIndex implements ChangeIndex {
-  static Schema<ChangeData> V1 =
-      new Schema<>(1, ImmutableList.<FieldDef<ChangeData, ?>>of(ChangeField.STATUS));
-
-  static Schema<ChangeData> V2 =
-      new Schema<>(2, ImmutableList.of(ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED));
-
-  private static class Source implements ChangeDataSource {
-    private final Predicate<ChangeData> p;
-
-    Source(Predicate<ChangeData> p) {
-      this.p = p;
-    }
-
-    @Override
-    public int getCardinality() {
-      return 1;
-    }
-
-    @Override
-    public boolean hasChange() {
-      throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public ResultSet<ChangeData> read() throws OrmException {
-      throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public String toString() {
-      return p.toString();
-    }
-  }
-
-  private final Schema<ChangeData> schema;
-
-  FakeChangeIndex(Schema<ChangeData> schema) {
-    this.schema = schema;
-  }
-
-  @Override
-  public void replace(ChangeData cd) {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public void delete(Change.Id id) {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public void deleteAll() {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
-      throws QueryParseException {
-    return new FakeChangeIndex.Source(p);
-  }
-
-  @Override
-  public Schema<ChangeData> getSchema() {
-    return schema;
-  }
-
-  @Override
-  public void close() {}
-
-  @Override
-  public void markReady(boolean ready) {
-    throw new UnsupportedOperationException();
-  }
-}
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
deleted file mode 100644
index a194336..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.change;
-
-import com.google.gerrit.index.query.OperatorPredicate;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import org.junit.Ignore;
-
-@Ignore
-public class FakeQueryBuilder extends ChangeQueryBuilder {
-  FakeQueryBuilder(ChangeIndexCollection indexes) {
-    super(
-        new FakeQueryBuilder.Definition<>(FakeQueryBuilder.class),
-        new ChangeQueryBuilder.Arguments(
-            null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, null, null, null, indexes, null, null, null, null, null, null, null,
-            null));
-  }
-
-  @Operator
-  public Predicate<ChangeData> foo(String value) {
-    return predicate("foo", value);
-  }
-
-  @Operator
-  public Predicate<ChangeData> bar(String value) {
-    return predicate("bar", value);
-  }
-
-  private Predicate<ChangeData> predicate(String name, String value) {
-    return new OperatorPredicate<ChangeData>(name, value) {};
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
deleted file mode 100644
index b25ed2b..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
+++ /dev/null
@@ -1,344 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-import static com.google.gerrit.server.index.change.StalenessChecker.refsAreStale;
-import static com.google.gerrit.testutil.TestChanges.newChange;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.ListMultimap;
-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.git.GitRepositoryManager;
-import com.google.gerrit.server.index.RefState;
-import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.testutil.GerritBaseTests;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.gwtorm.protobuf.CodecFactory;
-import com.google.gwtorm.protobuf.ProtobufCodec;
-import java.util.stream.Stream;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
-import org.junit.Test;
-
-public class StalenessCheckerTest extends GerritBaseTests {
-  private static final String SHA1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-  private static final String SHA2 = "badc0feebadc0feebadc0feebadc0feebadc0fee";
-
-  private static final Project.NameKey P1 = new Project.NameKey("project1");
-  private static final Project.NameKey P2 = new Project.NameKey("project2");
-
-  private static final Change.Id C = new Change.Id(1234);
-
-  private static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
-
-  private GitRepositoryManager repoManager;
-  private Repository r1;
-  private Repository r2;
-  private TestRepository<Repository> tr1;
-  private TestRepository<Repository> tr2;
-
-  @Before
-  public void setUp() throws Exception {
-    repoManager = new InMemoryRepositoryManager();
-    r1 = repoManager.createRepository(P1);
-    tr1 = new TestRepository<>(r1);
-    r2 = repoManager.createRepository(P2);
-    tr2 = new TestRepository<>(r2);
-  }
-
-  @Test
-  public void parseStates() {
-    assertInvalidState(null);
-    assertInvalidState("");
-    assertInvalidState("project1:refs/heads/foo");
-    assertInvalidState("project1:refs/heads/foo:notasha");
-    assertInvalidState("project1:refs/heads/foo:");
-
-    assertThat(
-            StalenessChecker.parseStates(
-                byteArrays(
-                    P1 + ":refs/heads/foo:" + SHA1,
-                    P1 + ":refs/heads/bar:" + SHA2,
-                    P2 + ":refs/heads/baz:" + SHA1)))
-        .isEqualTo(
-            ImmutableSetMultimap.of(
-                P1, RefState.create("refs/heads/foo", SHA1),
-                P1, RefState.create("refs/heads/bar", SHA2),
-                P2, RefState.create("refs/heads/baz", SHA1)));
-  }
-
-  private static void assertInvalidState(String state) {
-    try {
-      StalenessChecker.parseStates(byteArrays(state));
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
-  }
-
-  @Test
-  public void refStateToByteArray() {
-    assertThat(
-            new String(
-                RefState.create("refs/heads/foo", ObjectId.fromString(SHA1)).toByteArray(P1),
-                UTF_8))
-        .isEqualTo(P1 + ":refs/heads/foo:" + SHA1);
-    assertThat(
-            new String(RefState.create("refs/heads/foo", (ObjectId) null).toByteArray(P1), UTF_8))
-        .isEqualTo(P1 + ":refs/heads/foo:" + ObjectId.zeroId().name());
-  }
-
-  @Test
-  public void parsePatterns() {
-    assertInvalidPattern(null);
-    assertInvalidPattern("");
-    assertInvalidPattern("project:");
-    assertInvalidPattern("project:refs/heads/foo");
-    assertInvalidPattern("project:refs/he*ds/bar");
-    assertInvalidPattern("project:refs/(he)*ds/bar");
-    assertInvalidPattern("project:invalidrefname");
-
-    ListMultimap<Project.NameKey, RefStatePattern> r =
-        StalenessChecker.parsePatterns(
-            byteArrays(
-                P1 + ":refs/heads/*",
-                P2 + ":refs/heads/foo/*/bar",
-                P2 + ":refs/heads/foo/*-baz/*/quux"));
-
-    assertThat(r.keySet()).containsExactly(P1, P2);
-    RefStatePattern p = r.get(P1).get(0);
-    assertThat(p.pattern()).isEqualTo("refs/heads/*");
-    assertThat(p.prefix()).isEqualTo("refs/heads/");
-    assertThat(p.regex().pattern()).isEqualTo("^\\Qrefs/heads/\\E.*\\Q\\E$");
-    assertThat(p.match("refs/heads/foo")).isTrue();
-    assertThat(p.match("xrefs/heads/foo")).isFalse();
-    assertThat(p.match("refs/tags/foo")).isFalse();
-
-    p = r.get(P2).get(0);
-    assertThat(p.pattern()).isEqualTo("refs/heads/foo/*/bar");
-    assertThat(p.prefix()).isEqualTo("refs/heads/foo/");
-    assertThat(p.regex().pattern()).isEqualTo("^\\Qrefs/heads/foo/\\E.*\\Q/bar\\E$");
-    assertThat(p.match("refs/heads/foo//bar")).isTrue();
-    assertThat(p.match("refs/heads/foo/x/bar")).isTrue();
-    assertThat(p.match("refs/heads/foo/x/y/bar")).isTrue();
-    assertThat(p.match("refs/heads/foo/x/baz")).isFalse();
-
-    p = r.get(P2).get(1);
-    assertThat(p.pattern()).isEqualTo("refs/heads/foo/*-baz/*/quux");
-    assertThat(p.prefix()).isEqualTo("refs/heads/foo/");
-    assertThat(p.regex().pattern()).isEqualTo("^\\Qrefs/heads/foo/\\E.*\\Q-baz/\\E.*\\Q/quux\\E$");
-    assertThat(p.match("refs/heads/foo/-baz//quux")).isTrue();
-    assertThat(p.match("refs/heads/foo/x-baz/x/quux")).isTrue();
-    assertThat(p.match("refs/heads/foo/x/y-baz/x/y/quux")).isTrue();
-    assertThat(p.match("refs/heads/foo/x-baz/x/y")).isFalse();
-  }
-
-  @Test
-  public void refStatePatternToByteArray() {
-    assertThat(new String(RefStatePattern.create("refs/*").toByteArray(P1), UTF_8))
-        .isEqualTo(P1 + ":refs/*");
-  }
-
-  private static void assertInvalidPattern(String state) {
-    try {
-      StalenessChecker.parsePatterns(byteArrays(state));
-      assert_().fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
-  }
-
-  @Test
-  public void isStaleRefStatesOnly() throws Exception {
-    String ref1 = "refs/heads/foo";
-    ObjectId id1 = tr1.update(ref1, tr1.commit().message("commit 1"));
-    String ref2 = "refs/heads/bar";
-    ObjectId id2 = tr2.update(ref2, tr2.commit().message("commit 2"));
-
-    // Not stale.
-    assertThat(
-            refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P2, RefState.create(ref2, id2.name())),
-                ImmutableListMultimap.of()))
-        .isFalse();
-
-    // Wrong ref value.
-    assertThat(
-            refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, SHA1),
-                    P2, RefState.create(ref2, id2.name())),
-                ImmutableListMultimap.of()))
-        .isTrue();
-
-    // Swapped repos.
-    assertThat(
-            refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id2.name()),
-                    P2, RefState.create(ref2, id1.name())),
-                ImmutableListMultimap.of()))
-        .isTrue();
-
-    // Two refs in same repo, not stale.
-    String ref3 = "refs/heads/baz";
-    ObjectId id3 = tr1.update(ref3, tr1.commit().message("commit 3"));
-    tr1.update(ref3, id3);
-    assertThat(
-            refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P1, RefState.create(ref3, id3.name())),
-                ImmutableListMultimap.of()))
-        .isFalse();
-
-    // Ignore ref not mentioned.
-    assertThat(
-            refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of()))
-        .isFalse();
-
-    // One ref wrong.
-    assertThat(
-            refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P1, RefState.create(ref3, SHA1)),
-                ImmutableListMultimap.of()))
-        .isTrue();
-  }
-
-  @Test
-  public void isStaleWithRefStatePatterns() throws Exception {
-    String ref1 = "refs/heads/foo";
-    ObjectId id1 = tr1.update(ref1, tr1.commit().message("commit 1"));
-
-    // ref1 is only ref matching pattern.
-    assertThat(
-            refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
-        .isFalse();
-
-    // Now ref2 matches pattern, so stale unless ref2 is present in state map.
-    String ref2 = "refs/heads/bar";
-    ObjectId id2 = tr1.update(ref2, tr1.commit().message("commit 2"));
-    assertThat(
-            refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
-        .isTrue();
-    assertThat(
-            refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P1, RefState.create(ref2, id2.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
-        .isFalse();
-  }
-
-  @Test
-  public void isStaleWithNonPrefixPattern() throws Exception {
-    String ref1 = "refs/heads/foo";
-    ObjectId id1 = tr1.update(ref1, tr1.commit().message("commit 1"));
-    tr1.update("refs/heads/bar", tr1.commit().message("commit 2"));
-
-    // ref1 is only ref matching pattern.
-    assertThat(
-            refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
-        .isFalse();
-
-    // Now ref2 matches pattern, so stale unless ref2 is present in state map.
-    String ref3 = "refs/other/foo";
-    ObjectId id3 = tr1.update(ref3, tr1.commit().message("commit 3"));
-    assertThat(
-            refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
-        .isTrue();
-    assertThat(
-            refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P1, RefState.create(ref3, id3.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
-        .isFalse();
-  }
-
-  @Test
-  public void reviewDbChangeIsStale() throws Exception {
-    Change indexChange = newChange(P1, new Account.Id(1));
-    indexChange.setNoteDbState(SHA1);
-
-    // Change is missing from ReviewDb but present in index.
-    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, null)).isTrue();
-
-    // Change differs only in primary storage.
-    Change noteDbPrimary = clone(indexChange);
-    noteDbPrimary.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, noteDbPrimary)).isTrue();
-
-    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, clone(indexChange))).isFalse();
-
-    // Can't easily change row version to check true case.
-  }
-
-  private static Iterable<byte[]> byteArrays(String... strs) {
-    return Stream.of(strs).map(s -> s != null ? s.getBytes(UTF_8) : null).collect(toList());
-  }
-
-  private static Change clone(Change change) {
-    return CHANGE_CODEC.decode(CHANGE_CODEC.encodeToByteArray(change));
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java
deleted file mode 100644
index 42e8a8e..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java
+++ /dev/null
@@ -1,157 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
-
-import com.google.gerrit.testutil.GerritBaseTests;
-import org.junit.Test;
-
-public class AddressTest extends GerritBaseTests {
-  @Test
-  public void parse_NameEmail1() {
-    final Address a = Address.parse("A U Thor <author@example.com>");
-    assertThat(a.name).isEqualTo("A U Thor");
-    assertThat(a.email).isEqualTo("author@example.com");
-  }
-
-  @Test
-  public void parse_NameEmail2() {
-    final Address a = Address.parse("A <a@b>");
-    assertThat(a.name).isEqualTo("A");
-    assertThat(a.email).isEqualTo("a@b");
-  }
-
-  @Test
-  public void parse_NameEmail3() {
-    final Address a = Address.parse("<a@b>");
-    assertThat(a.name).isNull();
-    assertThat(a.email).isEqualTo("a@b");
-  }
-
-  @Test
-  public void parse_NameEmail4() {
-    final Address a = Address.parse("A U Thor<author@example.com>");
-    assertThat(a.name).isEqualTo("A U Thor");
-    assertThat(a.email).isEqualTo("author@example.com");
-  }
-
-  @Test
-  public void parse_NameEmail5() {
-    final Address a = Address.parse("A U Thor  <author@example.com>");
-    assertThat(a.name).isEqualTo("A U Thor");
-    assertThat(a.email).isEqualTo("author@example.com");
-  }
-
-  @Test
-  public void parse_Email1() {
-    final Address a = Address.parse("author@example.com");
-    assertThat(a.name).isNull();
-    assertThat(a.email).isEqualTo("author@example.com");
-  }
-
-  @Test
-  public void parse_Email2() {
-    final Address a = Address.parse("a@b");
-    assertThat(a.name).isNull();
-    assertThat(a.email).isEqualTo("a@b");
-  }
-
-  @Test
-  public void parse_NewTLD() {
-    Address a = Address.parse("A U Thor <author@example.systems>");
-    assertThat(a.name).isEqualTo("A U Thor");
-    assertThat(a.email).isEqualTo("author@example.systems");
-  }
-
-  @Test
-  public void parseInvalid() {
-    assertInvalid("");
-    assertInvalid("a");
-    assertInvalid("a<");
-    assertInvalid("<a");
-    assertInvalid("<a>");
-    assertInvalid("a<a>");
-    assertInvalid("a <a>");
-
-    assertInvalid("a");
-    assertInvalid("a<@");
-    assertInvalid("<a@");
-    assertInvalid("<a@>");
-    assertInvalid("a<a@>");
-    assertInvalid("a <a@>");
-    assertInvalid("a <@a>");
-  }
-
-  private void assertInvalid(String in) {
-    try {
-      Address.parse(in);
-      fail("Expected IllegalArgumentException for " + in);
-    } catch (IllegalArgumentException e) {
-      assertThat(e.getMessage()).isEqualTo("Invalid email address: " + in);
-    }
-  }
-
-  @Test
-  public void toHeaderString_NameEmail1() {
-    assertThat(format("A", "a@a")).isEqualTo("A <a@a>");
-  }
-
-  @Test
-  public void toHeaderString_NameEmail2() {
-    assertThat(format("A B", "a@a")).isEqualTo("A B <a@a>");
-  }
-
-  @Test
-  public void toHeaderString_NameEmail3() {
-    assertThat(format("A B. C", "a@a")).isEqualTo("\"A B. C\" <a@a>");
-  }
-
-  @Test
-  public void toHeaderString_NameEmail4() {
-    assertThat(format("A B, C", "a@a")).isEqualTo("\"A B, C\" <a@a>");
-  }
-
-  @Test
-  public void toHeaderString_NameEmail5() {
-    assertThat(format("A \" C", "a@a")).isEqualTo("\"A \\\" C\" <a@a>");
-  }
-
-  @Test
-  public void toHeaderString_NameEmail6() {
-    assertThat(format("A \u20ac B", "a@a")).isEqualTo("=?UTF-8?Q?A_=E2=82=AC_B?= <a@a>");
-  }
-
-  @Test
-  public void toHeaderString_NameEmail7() {
-    assertThat(format("A \u20ac B (Code Review)", "a@a"))
-        .isEqualTo("=?UTF-8?Q?A_=E2=82=AC_B_=28Code_Review=29?= <a@a>");
-  }
-
-  @Test
-  public void toHeaderString_Email1() {
-    assertThat(format(null, "a@a")).isEqualTo("a@a");
-  }
-
-  @Test
-  public void toHeaderString_Email2() {
-    assertThat(format(null, "a,b@a")).isEqualTo("<a,b@a>");
-  }
-
-  private static String format(String name, String email) {
-    return new Address(name, email).toHeaderString();
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java
deleted file mode 100644
index 19ad8bb..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java
+++ /dev/null
@@ -1,102 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.server.mail.Address;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.List;
-import org.joda.time.DateTime;
-import org.junit.Ignore;
-
-@Ignore
-public class AbstractParserTest {
-  protected static final String CHANGE_URL = "https://gerrit-review.googlesource.com/#/changes/123";
-
-  protected static void assertChangeMessage(String message, MailComment comment) {
-    assertThat(comment.fileName).isNull();
-    assertThat(comment.message).isEqualTo(message);
-    assertThat(comment.inReplyTo).isNull();
-    assertThat(comment.type).isEqualTo(MailComment.CommentType.CHANGE_MESSAGE);
-  }
-
-  protected static void assertInlineComment(
-      String message, MailComment comment, Comment inReplyTo) {
-    assertThat(comment.fileName).isNull();
-    assertThat(comment.message).isEqualTo(message);
-    assertThat(comment.inReplyTo).isEqualTo(inReplyTo);
-    assertThat(comment.type).isEqualTo(MailComment.CommentType.INLINE_COMMENT);
-  }
-
-  protected static void assertFileComment(String message, MailComment comment, String file) {
-    assertThat(comment.fileName).isEqualTo(file);
-    assertThat(comment.message).isEqualTo(message);
-    assertThat(comment.inReplyTo).isNull();
-    assertThat(comment.type).isEqualTo(MailComment.CommentType.FILE_COMMENT);
-  }
-
-  protected static Comment newComment(String uuid, String file, String message, int line) {
-    Comment c =
-        new Comment(
-            new Comment.Key(uuid, file, 1),
-            new Account.Id(0),
-            new Timestamp(0L),
-            (short) 0,
-            message,
-            "",
-            false);
-    c.lineNbr = line;
-    return c;
-  }
-
-  protected static Comment newRangeComment(String uuid, String file, String message, int line) {
-    Comment c =
-        new Comment(
-            new Comment.Key(uuid, file, 1),
-            new Account.Id(0),
-            new Timestamp(0L),
-            (short) 0,
-            message,
-            "",
-            false);
-    c.range = new Comment.Range(line, 1, line + 1, 1);
-    c.lineNbr = line + 1;
-    return c;
-  }
-
-  /** Returns a MailMessage.Builder with all required fields populated. */
-  protected static MailMessage.Builder newMailMessageBuilder() {
-    MailMessage.Builder b = MailMessage.builder();
-    b.id("id");
-    b.from(new Address("Foo Bar", "foo@bar.com"));
-    b.dateReceived(new DateTime());
-    b.subject("");
-    return b;
-  }
-
-  /** Returns a List of default comments for testing. */
-  protected static List<Comment> defaultComments() {
-    List<Comment> comments = new ArrayList<>();
-    comments.add(newComment("c1", "gerrit-server/test.txt", "comment", 0));
-    comments.add(newComment("c2", "gerrit-server/test.txt", "comment", 2));
-    comments.add(newComment("c3", "gerrit-server/test.txt", "comment", 3));
-    comments.add(newRangeComment("c4", "gerrit-server/readme.txt", "comment", 3));
-    return comments;
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java
deleted file mode 100644
index e210847..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java
+++ /dev/null
@@ -1,120 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-public class GmailHtmlParserTest extends HtmlParserTest {
-  @Override
-  protected String newHtmlBody(
-      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1) {
-    String email =
-        ""
-            + "<div class=\"gmail_default\" dir=\"ltr\">"
-            + (changeMessage != null ? changeMessage : "")
-            + "<div class=\"gmail_extra\"><br><div class=\"gmail_quote\">"
-            + "On Fri, Nov 18, 2016 at 11:15 AM, foobar (Gerrit) noreply@gerrit.com"
-            + "<span dir=\"ltr\">&lt;<a href=\"mailto:noreply@gerrit.com\" "
-            + "target=\"_blank\">noreply@gerrit.com</a>&gt;</span> wrote:<br>"
-            + "<blockquote class=\"gmail_quote\" "
-            + "<p>foobar <strong>posted comments</strong> on this change.</p>"
-            + "<p><a href=\""
-            + CHANGE_URL
-            + "/1\" "
-            + "target=\"_blank\">View Change</a></p><div>Patch Set 2: CR-1\n"
-            + "\n"
-            + "(3 comments)</div><ul><li>"
-            + "<p>"
-            + // File #1: test.txt
-            "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt\">"
-            + "File gerrit-server/<wbr>test.txt:</a></p>"
-            + commentBlock(f1)
-            + "<li><p>"
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt\">"
-            + "Patch Set #2:</a> </p>"
-            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
-            + "</blockquote><p>Some comment on file 1</p>"
-            + "</li>"
-            + commentBlock(fc1)
-            + "<li><p>"
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt@2\">"
-            + "Patch Set #2, Line 31:</a> </p>"
-            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
-            + "</blockquote><p>Some text from original comment</p>"
-            + "</li>"
-            + commentBlock(c1)
-            + ""
-            + // Inline comment #2
-            "<li><p>"
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt@3\">"
-            + "Patch Set #2, Line 47:</a> </p>"
-            + "<blockquote><pre>Some comment posted on Gerrit</pre>"
-            + "</blockquote><p>Some more comments from Gerrit</p>"
-            + "</li>"
-            + commentBlock(c2)
-            + "<li><p>"
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/test.txt@115\">"
-            + "Patch Set #2, Line 115:</a> <code>some code</code></p>"
-            + "<p>some comment</p></li></ul></li>"
-            + ""
-            + "<li><p>"
-            + // File #2: test.txt
-            "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/readme.txt\">"
-            + "File gerrit-server/<wbr>readme.txt:</a></p>"
-            + commentBlock(f2)
-            + "<li><p>"
-            + "<a href=\""
-            + CHANGE_URL
-            + "/1/gerrit-server/readme.txt@3\">"
-            + "Patch Set #2, Line 31:</a> </p>"
-            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
-            + "</blockquote><p>Some text from original comment</p>"
-            + "</li>"
-            + commentBlock(c3)
-            + ""
-            + // Inline comment #2
-            "</ul></li></ul>"
-            + ""
-            + // Footer
-            "<p>To view, visit <a href=\""
-            + CHANGE_URL
-            + "/1\">this change</a>. "
-            + "To unsubscribe, visit <a href=\"https://someurl\">settings</a>."
-            + "</p><p>Gerrit-MessageType: comment<br>"
-            + "Footer omitted</p>"
-            + "<div><div></div></div>"
-            + "<p>Gerrit-HasComments: Yes</p></blockquote></div><br></div></div>";
-    return email;
-  }
-
-  private static String commentBlock(String comment) {
-    if (comment == null) {
-      return "";
-    }
-    return "</ul></li></ul></blockquote><div>"
-        + comment
-        + "</div><blockquote class=\"gmail_quote\"><ul><li><ul>";
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/HtmlParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/HtmlParserTest.java
deleted file mode 100644
index 0e0e8b0..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/HtmlParserTest.java
+++ /dev/null
@@ -1,142 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.reviewdb.client.Comment;
-import java.util.List;
-import org.junit.Ignore;
-import org.junit.Test;
-
-/**
- * Abstract parser test for HTML messages. Payload will be added through concrete implementations.
- */
-@Ignore
-public abstract class HtmlParserTest extends AbstractParserTest {
-  @Test
-  public void simpleChangeMessage() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.htmlContent(newHtmlBody("Looks good to me", null, null, null, null, null, null));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
-
-    assertThat(parsedComments).hasSize(1);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
-  }
-
-  @Test
-  public void simpleInlineComments() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.htmlContent(
-        newHtmlBody(
-            "Looks good to me",
-            "I have a comment on this.&nbsp;",
-            null,
-            "Also have a comment here.",
-            null,
-            null,
-            null));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(3);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
-    assertInlineComment("I have a comment on this.", parsedComments.get(1), comments.get(1));
-    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
-  }
-
-  @Test
-  public void simpleFileComment() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.htmlContent(
-        newHtmlBody(
-            "Looks good to me",
-            null,
-            null,
-            "Also have a comment here.",
-            "This is a nice file",
-            null,
-            null));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(3);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
-    assertFileComment("This is a nice file", parsedComments.get(1), comments.get(1).key.filename);
-    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
-  }
-
-  @Test
-  public void noComments() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.htmlContent(newHtmlBody(null, null, null, null, null, null, null));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).isEmpty();
-  }
-
-  @Test
-  public void noChangeMessage() {
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.htmlContent(
-        newHtmlBody(
-            null, null, null, "Also have a comment here.", "This is a nice file", null, null));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(2);
-    assertFileComment("This is a nice file", parsedComments.get(0), comments.get(1).key.filename);
-    assertInlineComment("Also have a comment here.", parsedComments.get(1), comments.get(3));
-  }
-
-  @Test
-  public void commentsSpanningMultipleBlocks() {
-    String htmlMessage =
-        "This is a very long test comment. <div><br></div><div>Now this is a new paragraph yay.</div>";
-    String txtMessage = "This is a very long test comment.\n\nNow this is a new paragraph yay.";
-    MailMessage.Builder b = newMailMessageBuilder();
-    b.htmlContent(newHtmlBody(htmlMessage, null, null, htmlMessage, htmlMessage, null, null));
-
-    List<Comment> comments = defaultComments();
-    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
-
-    assertThat(parsedComments).hasSize(3);
-    assertChangeMessage(txtMessage, parsedComments.get(0));
-    assertFileComment(txtMessage, parsedComments.get(1), comments.get(1).key.filename);
-    assertInlineComment(txtMessage, parsedComments.get(2), comments.get(3));
-  }
-
-  /**
-   * Create an html message body with the specified comments.
-   *
-   * @param changeMessage
-   * @param c1 Comment in reply to first comment.
-   * @param c2 Comment in reply to second comment.
-   * @param c3 Comment in reply to third comment.
-   * @param f1 Comment on file one.
-   * @param f2 Comment on file two.
-   * @param fc1 Comment in reply to a comment on file 1.
-   * @return A string with all inline comments and the original quoted email.
-   */
-  protected abstract String newHtmlBody(
-      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1);
-}
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
deleted file mode 100644
index 84bae96..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java
+++ /dev/null
@@ -1,117 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.mail.MetadataName.toFooterWithDelimiter;
-import static com.google.gerrit.server.mail.MetadataName.toHeaderWithDelimiter;
-
-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 org.junit.Test;
-
-public class MetadataParserTest {
-  @Test
-  public void parseMetadataFromHeader() {
-    // This tests if the metadata parser is able to parse metadata from the
-    // email headers of the message.
-    MailMessage.Builder b = MailMessage.builder();
-    b.id("");
-    b.dateReceived(new DateTime());
-    b.subject("");
-
-    b.addAdditionalHeader(toHeaderWithDelimiter(MetadataName.CHANGE_NUMBER) + "123");
-    b.addAdditionalHeader(toHeaderWithDelimiter(MetadataName.PATCH_SET) + "1");
-    b.addAdditionalHeader(toHeaderWithDelimiter(MetadataName.MESSAGE_TYPE) + "comment");
-    b.addAdditionalHeader(
-        toHeaderWithDelimiter(MetadataName.TIMESTAMP) + "Tue, 25 Oct 2016 02:11:35 -0700");
-
-    Address author = new Address("Diffy", "test@gerritcodereview.com");
-    b.from(author);
-
-    MailMetadata meta = MetadataParser.parse(b.build());
-    assertThat(meta.author).isEqualTo(author.getEmail());
-    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());
-  }
-
-  @Test
-  public void parseMetadataFromText() {
-    // This tests if the metadata parser is able to parse metadata from the
-    // the text body of the message.
-    MailMessage.Builder b = MailMessage.builder();
-    b.id("");
-    b.dateReceived(new DateTime());
-    b.subject("");
-
-    StringBuilder stringBuilder = new StringBuilder();
-    stringBuilder.append(toFooterWithDelimiter(MetadataName.CHANGE_NUMBER) + "123\r\n");
-    stringBuilder.append("> " + toFooterWithDelimiter(MetadataName.PATCH_SET) + "1\n");
-    stringBuilder.append(toFooterWithDelimiter(MetadataName.MESSAGE_TYPE) + "comment\n");
-    stringBuilder.append(
-        toFooterWithDelimiter(MetadataName.TIMESTAMP) + "Tue, 25 Oct 2016 02:11:35 -0700\r\n");
-    b.textContent(stringBuilder.toString());
-
-    Address author = new Address("Diffy", "test@gerritcodereview.com");
-    b.from(author);
-
-    MailMetadata meta = MetadataParser.parse(b.build());
-    assertThat(meta.author).isEqualTo(author.getEmail());
-    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());
-  }
-
-  @Test
-  public void parseMetadataFromHTML() {
-    // This tests if the metadata parser is able to parse metadata from the
-    // the HTML body of the message.
-    MailMessage.Builder b = MailMessage.builder();
-    b.id("");
-    b.dateReceived(new DateTime());
-    b.subject("");
-
-    StringBuilder stringBuilder = new StringBuilder();
-    stringBuilder.append(
-        "<div id\"someid\">" + toFooterWithDelimiter(MetadataName.CHANGE_NUMBER) + "123</div>");
-    stringBuilder.append("<div>" + toFooterWithDelimiter(MetadataName.PATCH_SET) + "1</div>");
-    stringBuilder.append(
-        "<div>" + toFooterWithDelimiter(MetadataName.MESSAGE_TYPE) + "comment</div>");
-    stringBuilder.append(
-        "<div>"
-            + toFooterWithDelimiter(MetadataName.TIMESTAMP)
-            + "Tue, 25 Oct 2016 02:11:35 -0700"
-            + "</div>");
-    b.htmlContent(stringBuilder.toString());
-
-    Address author = new Address("Diffy", "test@gerritcodereview.com");
-    b.from(author);
-
-    MailMetadata meta = MetadataParser.parse(b.build());
-    assertThat(meta.author).isEqualTo(author.getEmail());
-    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());
-  }
-}
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
deleted file mode 100644
index 4efa817..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/RawMailParserTest.java
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.server.mail.receive.data.AttachmentMessage;
-import com.google.gerrit.server.mail.receive.data.Base64HeaderMessage;
-import com.google.gerrit.server.mail.receive.data.HtmlMimeMessage;
-import com.google.gerrit.server.mail.receive.data.NonUTF8Message;
-import com.google.gerrit.server.mail.receive.data.QuotedPrintableHeaderMessage;
-import com.google.gerrit.server.mail.receive.data.RawMailMessage;
-import com.google.gerrit.server.mail.receive.data.SimpleTextMessage;
-import com.google.gerrit.testutil.GerritBaseTests;
-import org.junit.Test;
-
-public class RawMailParserTest extends GerritBaseTests {
-  @Test
-  public void parseEmail() throws Exception {
-    RawMailMessage[] messages =
-        new RawMailMessage[] {
-          new SimpleTextMessage(),
-          new Base64HeaderMessage(),
-          new QuotedPrintableHeaderMessage(),
-          new HtmlMimeMessage(),
-          new AttachmentMessage(),
-          new NonUTF8Message(),
-        };
-    for (RawMailMessage rawMailMessage : messages) {
-      if (rawMailMessage.rawChars() != null) {
-        // Assert Character to Mail Parser
-        MailMessage parsedMailMessage = RawMailParser.parse(rawMailMessage.rawChars());
-        assertMail(parsedMailMessage, rawMailMessage.expectedMailMessage());
-      }
-      if (rawMailMessage.raw() != null) {
-        // Assert String to Mail Parser
-        MailMessage parsedMailMessage = RawMailParser.parse(rawMailMessage.raw());
-        assertMail(parsedMailMessage, rawMailMessage.expectedMailMessage());
-      }
-    }
-  }
-
-  /**
-   * This method makes it easier to debug failing tests by checking each property individual instead
-   * of calling equals as it will immediately reveal the property that diverges between the two
-   * objects.
-   *
-   * @param have MailMessage retrieved from the parser
-   * @param want MailMessage that would be expected
-   */
-  private void assertMail(MailMessage have, MailMessage want) {
-    assertThat(have.id()).isEqualTo(want.id());
-    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.additionalHeaders()).isEqualTo(want.additionalHeaders());
-    assertThat(have.subject()).isEqualTo(want.subject());
-    assertThat(have.textContent()).isEqualTo(want.textContent());
-    assertThat(have.htmlContent()).isEqualTo(want.htmlContent());
-  }
-}
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
deleted file mode 100644
index be8d882..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive.data;
-
-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 org.junit.Ignore;
-
-/**
- * Provides a raw message payload and a parsed {@code MailMessage} to check that mime parts that are
- * neither text/plain, nor * text/html are dropped.
- */
-@Ignore
-public class AttachmentMessage extends RawMailMessage {
-  private static String raw =
-      "MIME-Version: 1.0\n"
-          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
-          + "Message-ID: <CAM7sg=3meaAVUxW3KXeJEVs8sv_ADw1BnvpcHHiYVR2TQQi__w"
-          + "@mail.gmail.com>\n"
-          + "Subject: Test Subject\n"
-          + "From: Patrick Hiesel <hiesel@google.com>\n"
-          + "To: Patrick Hiesel <hiesel@google.com>\n"
-          + "Content-Type: multipart/mixed; boundary=001a114e019a56962d054062708f\n"
-          + "\n"
-          + "--001a114e019a56962d054062708f\n"
-          + "Content-Type: multipart/alternative; boundary=001a114e019a5696250540"
-          + "62708d\n"
-          + "\n"
-          + "--001a114e019a569625054062708d\n"
-          + "Content-Type: text/plain; charset=UTF-8\n"
-          + "\n"
-          + "Contains unwanted attachment"
-          + "\n"
-          + "--001a114e019a569625054062708d\n"
-          + "Content-Type: text/html; charset=UTF-8\n"
-          + "\n"
-          + "<div dir=\"ltr\">Contains unwanted attachment</div>"
-          + "\n"
-          + "--001a114e019a569625054062708d--\n"
-          + "--001a114e019a56962d054062708f\n"
-          + "Content-Type: text/plain; charset=US-ASCII; name=\"test.txt\"\n"
-          + "Content-Disposition: attachment; filename=\"test.txt\"\n"
-          + "Content-Transfer-Encoding: base64\n"
-          + "X-Attachment-Id: f_iv264bt50\n"
-          + "\n"
-          + "VEVTVAo=\n"
-          + "--001a114e019a56962d054062708f--";
-
-  @Override
-  public String raw() {
-    return raw;
-  }
-
-  @Override
-  public int[] rawChars() {
-    return null;
-  }
-
-  @Override
-  public MailMessage expectedMailMessage() {
-    System.out.println("\uD83D\uDE1B test");
-    MailMessage.Builder expect = MailMessage.builder();
-    expect
-        .id("<CAM7sg=3meaAVUxW3KXeJEVs8sv_ADw1BnvpcHHiYVR2TQQi__w@mail.gmail.com>")
-        .from(new Address("Patrick Hiesel", "hiesel@google.com"))
-        .addTo(new Address("Patrick Hiesel", "hiesel@google.com"))
-        .textContent("Contains unwanted attachment")
-        .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));
-    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
deleted file mode 100644
index affa3bd..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive.data;
-
-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 org.junit.Ignore;
-
-/** Tests parsing a Base64 encoded subject. */
-@Ignore
-public class Base64HeaderMessage extends RawMailMessage {
-  private static String textContent = "Some Text";
-  private static String raw =
-      ""
-          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
-          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
-          + "Subject: =?UTF-8?B?8J+YmyB0ZXN0?=\n"
-          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
-          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
-          + "To: ekempin <ekempin@google.com>\n"
-          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
-          + "\n"
-          + textContent;
-
-  @Override
-  public String raw() {
-    return raw;
-  }
-
-  @Override
-  public int[] rawChars() {
-    return null;
-  }
-
-  @Override
-  public MailMessage expectedMailMessage() {
-    MailMessage.Builder expect = MailMessage.builder();
-    expect
-        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
-        .from(
-            new Address(
-                "Jonathan Nieder (Gerrit)",
-                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
-        .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));
-    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
deleted file mode 100644
index 487e9dd..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive.data;
-
-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 org.junit.Ignore;
-
-/** Tests a message containing mime/alternative (text + html) content. */
-@Ignore
-public class HtmlMimeMessage extends RawMailMessage {
-  private static String textContent = "Simple test";
-
-  // htmlContent is encoded in quoted-printable
-  private static String htmlContent =
-      "<div dir=3D\"ltr\">Test <span style"
-          + "=3D\"background-color:rgb(255,255,0)\">Messa=\n"
-          + "ge</span> in <u>HTML=C2=A0</u><a href=3D\"https://en.wikipedia.org/"
-          + "wiki/%C3%=\n9Cmlaut_(band)\" class=3D\"gmail-mw-redirect\" title=3D\""
-          + "=C3=9Cmlaut (band)\" st=\nyle=3D\"text-decoration:none;color:rgb(11,"
-          + "0,128);background-image:none;backg=\nround-position:initial;background"
-          + "-size:initial;background-repeat:initial;ba=\nckground-origin:initial;"
-          + "background-clip:initial;font-family:sans-serif;font=\n"
-          + "-size:14px\">=C3=9C</a></div>";
-
-  private static String unencodedHtmlContent =
-      ""
-          + "<div dir=\"ltr\">Test <span style=\"background-color:rgb(255,255,0)\">"
-          + "Message</span> in <u>HTML </u><a href=\"https://en.wikipedia.org/wiki/"
-          + "%C3%9Cmlaut_(band)\" class=\"gmail-mw-redirect\" title=\"Ümlaut "
-          + "(band)\" style=\"text-decoration:none;color:rgb(11,0,128);"
-          + "background-image:none;background-position:initial;background-size:"
-          + "initial;background-repeat:initial;background-origin:initial;background"
-          + "-clip:initial;font-family:sans-serif;font-size:14px\">Ü</a></div>";
-
-  private static String raw =
-      ""
-          + "MIME-Version: 1.0\n"
-          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
-          + "Message-ID: <001a114cd8be55b4ab053face5cd@google.com>\n"
-          + "Subject: Change in gerrit[master]: Implement receiver class structure "
-          + "and bindings\n"
-          + "From: \"ekempin (Gerrit)\" <noreply-gerritcodereview-qUgXfQecoDLHwp0Ml"
-          + "dAzig@google.com>\n"
-          + "To: Patrick Hiesel <hiesel@google.com>\n"
-          + "Cc: ekempin <ekempin@google.com>\n"
-          + "Content-Type: multipart/alternative; boundary=001a114cd8b"
-          + "e55b486053face5ca\n"
-          + "\n"
-          + "--001a114cd8be55b486053face5ca\n"
-          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
-          + "\n"
-          + textContent
-          + "\n"
-          + "--001a114cd8be55b486053face5ca\n"
-          + "Content-Type: text/html; charset=UTF-8\n"
-          + "Content-Transfer-Encoding: quoted-printable\n"
-          + "\n"
-          + htmlContent
-          + "\n"
-          + "--001a114cd8be55b486053face5ca--";
-
-  @Override
-  public String raw() {
-    return raw;
-  }
-
-  @Override
-  public int[] rawChars() {
-    return null;
-  }
-
-  @Override
-  public MailMessage expectedMailMessage() {
-    MailMessage.Builder expect = MailMessage.builder();
-    expect
-        .id("<001a114cd8be55b4ab053face5cd@google.com>")
-        .from(
-            new Address(
-                "ekempin (Gerrit)", "noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com"))
-        .addCc(new Address("ekempin", "ekempin@google.com"))
-        .addTo(new Address("Patrick Hiesel", "hiesel@google.com"))
-        .textContent(textContent)
-        .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));
-    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
deleted file mode 100644
index 9f2af0d..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.google.gerrit.server.mail.receive.data;
-
-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 org.junit.Ignore;
-
-/** Tests that non-UTF8 encodings are handled correctly. */
-@Ignore
-public class NonUTF8Message extends RawMailMessage {
-  private static String textContent = "Some Text";
-  private static String raw =
-      ""
-          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
-          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
-          + "Subject: =?UTF-8?B?8J+YmyB0ZXN0?=\n"
-          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
-          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
-          + "To: ekempin <ekempin@google.com>\n"
-          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
-          + "\n"
-          + textContent;
-
-  @Override
-  public String raw() {
-    return null;
-  }
-
-  @Override
-  public int[] rawChars() {
-    int[] arr = new int[raw.length()];
-    int i = 0;
-    for (char c : raw.toCharArray()) {
-      arr[i++] = c;
-    }
-    return arr;
-  }
-
-  @Override
-  public MailMessage expectedMailMessage() {
-    MailMessage.Builder expect = MailMessage.builder();
-    expect
-        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
-        .from(
-            new Address(
-                "Jonathan Nieder (Gerrit)",
-                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
-        .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));
-    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
deleted file mode 100644
index 2c17859..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive.data;
-
-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 org.junit.Ignore;
-
-/** Tests parsing a quoted printable encoded subject */
-@Ignore
-public class QuotedPrintableHeaderMessage extends RawMailMessage {
-  private static String textContent = "Some Text";
-  private static String raw =
-      ""
-          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
-          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
-          + "Subject: =?UTF-8?Q?=C3=A2me vulgaire?=\n"
-          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
-          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
-          + "To: ekempin <ekempin@google.com>\n"
-          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
-          + "\n"
-          + textContent;
-
-  @Override
-  public String raw() {
-    return raw;
-  }
-
-  @Override
-  public int[] rawChars() {
-    return null;
-  }
-
-  @Override
-  public MailMessage expectedMailMessage() {
-    System.out.println("\uD83D\uDE1B test");
-    MailMessage.Builder expect = MailMessage.builder();
-    expect
-        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
-        .from(
-            new Address(
-                "Jonathan Nieder (Gerrit)",
-                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
-        .addTo(new Address("ekempin", "ekempin@google.com"))
-        .textContent(textContent)
-        .subject("âme vulgaire")
-        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
-    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
deleted file mode 100644
index ce833d5..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
+++ /dev/null
@@ -1,138 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.receive.data;
-
-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 org.junit.Ignore;
-
-/** Tests parsing a simple text message with different headers. */
-@Ignore
-public class SimpleTextMessage extends RawMailMessage {
-  private static String textContent =
-      ""
-          + "Jonathan Nieder has posted comments on this change. (  \n"
-          + "https://gerrit-review.googlesource.com/90018 )\n"
-          + "\n"
-          + "Change subject: (Re)enable voting buttons for merged changes\n"
-          + "...........................................................\n"
-          + "\n"
-          + "\n"
-          + "Patch Set 2:\n"
-          + "\n"
-          + "This is producing NPEs server-side and 500s for the client.   \n"
-          + "when I try to load this change:\n"
-          + "\n"
-          + "  Error in GET /changes/90018/detail?O=10004\n"
-          + "  com.google.gwtorm.OrmException: java.lang.NullPointerException\n"
-          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:303)\n"
-          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:285)\n"
-          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:263)\n"
-          + "\tat com.google.gerrit.change.GetChange.apply(GetChange.java:50)\n"
-          + "\tat com.google.gerrit.change.GetDetail.apply(GetDetail.java:51)\n"
-          + "\tat com.google.gerrit.change.GetDetail.apply(GetDetail.java:26)\n"
-          + "\tat  \n"
-          + "com.google.gerrit.RestApiServlet.service(RestApiServlet.java:367)\n"
-          + "\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:717)\n"
-          + "[...]\n"
-          + "  Caused by: java.lang.NullPointerException\n"
-          + "\tat  \n"
-          + "com.google.gerrit.ChangeJson.setLabelScores(ChangeJson.java:670)\n"
-          + "\tat  \n"
-          + "com.google.gerrit.ChangeJson.labelsFor(ChangeJson.java:845)\n"
-          + "\tat  \n"
-          + "com.google.gerrit.change.ChangeJson.labelsFor(ChangeJson.java:598)\n"
-          + "\tat  \n"
-          + "com.google.gerrit.change.ChangeJson.toChange(ChangeJson.java:499)\n"
-          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:294)\n"
-          + "\t... 105 more\n"
-          + "-- \n"
-          + "To view, visit https://gerrit-review.googlesource.com/90018\n"
-          + "To unsubscribe, visit https://gerrit-review.googlesource.com\n"
-          + "\n"
-          + "Gerrit-MessageType: comment\n"
-          + "Gerrit-Change-Id: Iba501e00bee77be3bd0ced72f88fd04ba0accaed\n"
-          + "Gerrit-PatchSet: 2\n"
-          + "Gerrit-Project: gerrit\n"
-          + "Gerrit-Branch: master\n"
-          + "Gerrit-Owner: ekempin <ekempin@google.com>\n"
-          + "Gerrit-Reviewer: Dave Borowitz <dborowitz@google.com>\n"
-          + "Gerrit-Reviewer: Edwin Kempin <ekempin@google.com>\n"
-          + "Gerrit-Reviewer: GerritForge CI <gerritforge@gmail.com>\n"
-          + "Gerrit-Reviewer: Jonathan Nieder <jrn@google.com>\n"
-          + "Gerrit-Reviewer: Patrick Hiesel <hiesel@google.com>\n"
-          + "Gerrit-Reviewer: ekempin <ekempin@google.com>\n"
-          + "Gerrit-HasComments: No";
-
-  private static String raw =
-      ""
-          + "Authentication-Results: mx.google.com; dkim=pass header.i="
-          + "@google.com;\n"
-          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
-          + "In-Reply-To: <gerrit.1477487889000.Iba501e00bee77be3bd0ced"
-          + "72f88fd04ba0accaed@gerrit-review.googlesource.com>\n"
-          + "References: <gerrit.1477487889000.Iba501e00bee77be3bd0ced72f8"
-          + "8fd04ba0accaed@gerrit-review.googlesource.com>\n"
-          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
-          + "Subject: Change in gerrit[master]: (Re)enable voting buttons for "
-          + "merged changes\n"
-          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-CtTy0"
-          + "igsBrnvL7dKoWEIEg@google.com>\n"
-          + "To: ekempin <ekempin@google.com>\n"
-          + "Cc: Dave Borowitz <dborowitz@google.com>, Jonathan Nieder "
-          + "<jrn@google.com>, Patrick Hiesel <hiesel@google.com>\n"
-          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
-          + "\n"
-          + textContent;
-
-  @Override
-  public String raw() {
-    return raw;
-  }
-
-  @Override
-  public int[] rawChars() {
-    return null;
-  }
-
-  @Override
-  public MailMessage expectedMailMessage() {
-    MailMessage.Builder expect = MailMessage.builder();
-    expect
-        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
-        .from(
-            new Address(
-                "Jonathan Nieder (Gerrit)",
-                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
-        .addTo(new Address("ekempin", "ekempin@google.com"))
-        .addCc(new Address("Dave Borowitz", "dborowitz@google.com"))
-        .addCc(new Address("Jonathan Nieder", "jrn@google.com"))
-        .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))
-        .addAdditionalHeader(
-            "Authentication-Results: mx.google.com; dkim=pass header.i=@google.com;")
-        .addAdditionalHeader(
-            "In-Reply-To: <gerrit.1477487889000.Iba501e00bee"
-                + "77be3bd0ced72f88fd04ba0accaed@gerrit-review.googlesource.com>")
-        .addAdditionalHeader(
-            "References: <gerrit.1477487889000.Iba501e00bee"
-                + "77be3bd0ced72f88fd04ba0accaed@gerrit-review.googlesource.com>");
-    return expect.build();
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
deleted file mode 100644
index 5215561..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ /dev/null
@@ -1,394 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.easymock.EasyMock.createStrictMock;
-import static org.easymock.EasyMock.eq;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.verify;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.server.mail.Address;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.junit.Before;
-import org.junit.Test;
-
-public class FromAddressGeneratorProviderTest {
-  private Config config;
-  private PersonIdent ident;
-  private AccountCache accountCache;
-
-  @Before
-  public void setUp() throws Exception {
-    config = new Config();
-    ident = new PersonIdent("NAME", "e@email", 0, 0);
-    accountCache = createStrictMock(AccountCache.class);
-  }
-
-  private FromAddressGenerator create() {
-    return new FromAddressGeneratorProvider(config, "Anonymous Coward", ident, accountCache).get();
-  }
-
-  private void setFrom(String newFrom) {
-    config.setString("sendemail", null, "from", newFrom);
-  }
-
-  private void setDomains(List<String> domains) {
-    config.setStringList("sendemail", null, "allowedDomain", domains);
-  }
-
-  @Test
-  public void defaultIsMIXED() {
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
-  }
-
-  @Test
-  public void selectUSER() {
-    setFrom("USER");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.UserGen.class);
-
-    setFrom("user");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.UserGen.class);
-
-    setFrom("uSeR");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.UserGen.class);
-  }
-
-  @Test
-  public void USER_FullyConfiguredUser() {
-    setFrom("USER");
-
-    final String name = "A U. Thor";
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = user(name, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name);
-    assertThat(r.getEmail()).isEqualTo(email);
-    verify(accountCache);
-  }
-
-  @Test
-  public void USER_NoFullNameUser() {
-    setFrom("USER");
-
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = user(null, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isNull();
-    assertThat(r.getEmail()).isEqualTo(email);
-    verify(accountCache);
-  }
-
-  @Test
-  public void USER_NoPreferredEmailUser() {
-    setFrom("USER");
-
-    final String name = "A U. Thor";
-    final Account.Id user = user(name, null);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void USER_NullUser() {
-    setFrom("USER");
-    replay(accountCache);
-    final Address r = create().from(null);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(ident.getName());
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void USERAllowDomain() {
-    setFrom("USER");
-    setDomains(Arrays.asList("*.example.com"));
-    final String name = "A U. Thor";
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = user(name, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name);
-    assertThat(r.getEmail()).isEqualTo(email);
-    verify(accountCache);
-  }
-
-  @Test
-  public void USERNoAllowDomain() {
-    setFrom("USER");
-    setDomains(Arrays.asList("example.com"));
-    final String name = "A U. Thor";
-    final String email = "a.u.thor@test.com";
-    final Account.Id user = user(name, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void USERAllowDomainTwice() {
-    setFrom("USER");
-    setDomains(Arrays.asList("example.com"));
-    setDomains(Arrays.asList("test.com"));
-    final String name = "A U. Thor";
-    final String email = "a.u.thor@test.com";
-    final Account.Id user = user(name, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name);
-    assertThat(r.getEmail()).isEqualTo(email);
-    verify(accountCache);
-  }
-
-  @Test
-  public void USERAllowDomainTwiceReverse() {
-    setFrom("USER");
-    setDomains(Arrays.asList("test.com"));
-    setDomains(Arrays.asList("example.com"));
-    final String name = "A U. Thor";
-    final String email = "a.u.thor@test.com";
-    final Account.Id user = user(name, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void USERAllowTwoDomains() {
-    setFrom("USER");
-    setDomains(Arrays.asList("example.com", "test.com"));
-    final String name = "A U. Thor";
-    final String email = "a.u.thor@test.com";
-    final Account.Id user = user(name, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name);
-    assertThat(r.getEmail()).isEqualTo(email);
-    verify(accountCache);
-  }
-
-  @Test
-  public void selectSERVER() {
-    setFrom("SERVER");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.ServerGen.class);
-
-    setFrom("server");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.ServerGen.class);
-
-    setFrom("sErVeR");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.ServerGen.class);
-  }
-
-  @Test
-  public void SERVER_FullyConfiguredUser() {
-    setFrom("SERVER");
-
-    final String name = "A U. Thor";
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = userNoLookup(name, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(ident.getName());
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void SERVER_NullUser() {
-    setFrom("SERVER");
-    replay(accountCache);
-    final Address r = create().from(null);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(ident.getName());
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void selectMIXED() {
-    setFrom("MIXED");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
-
-    setFrom("mixed");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
-
-    setFrom("mIxEd");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
-  }
-
-  @Test
-  public void MIXED_FullyConfiguredUser() {
-    setFrom("MIXED");
-
-    final String name = "A U. Thor";
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = user(name, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void MIXED_NoFullNameUser() {
-    setFrom("MIXED");
-
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = user(null, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo("Anonymous Coward (Code Review)");
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void MIXED_NoPreferredEmailUser() {
-    setFrom("MIXED");
-
-    final String name = "A U. Thor";
-    final Account.Id user = user(name, null);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void MIXED_NullUser() {
-    setFrom("MIXED");
-    replay(accountCache);
-    final Address r = create().from(null);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(ident.getName());
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void CUSTOM_FullyConfiguredUser() {
-    setFrom("A ${user} B <my.server@email.address>");
-
-    final String name = "A U. Thor";
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = user(name, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo("A " + name + " B");
-    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
-    verify(accountCache);
-  }
-
-  @Test
-  public void CUSTOM_NoFullNameUser() {
-    setFrom("A ${user} B <my.server@email.address>");
-
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = user(null, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo("A Anonymous Coward B");
-    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
-    verify(accountCache);
-  }
-
-  @Test
-  public void CUSTOM_NullUser() {
-    setFrom("A ${user} B <my.server@email.address>");
-
-    replay(accountCache);
-    final Address r = create().from(null);
-    assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(ident.getName());
-    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
-    verify(accountCache);
-  }
-
-  private Account.Id user(String name, String email) {
-    final AccountState s = makeUser(name, email);
-    expect(accountCache.get(eq(s.getAccount().getId()))).andReturn(s);
-    return s.getAccount().getId();
-  }
-
-  private Account.Id userNoLookup(String name, String email) {
-    final AccountState s = makeUser(name, email);
-    return s.getAccount().getId();
-  }
-
-  private AccountState makeUser(String name, String email) {
-    final Account.Id userId = new Account.Id(42);
-    final Account account = new Account(userId, TimeUtil.nowTs());
-    account.setFullName(name);
-    account.setPreferredEmail(email);
-    return new AccountState(
-        new AllUsersName(AllUsersNameProvider.DEFAULT),
-        account,
-        Collections.emptySet(),
-        Collections.emptySet(),
-        new HashMap<>());
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
deleted file mode 100644
index f03fb37..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ /dev/null
@@ -1,299 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.inject.Scopes.SINGLETON;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.metrics.DisabledMetricMaker;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.FakeRealm;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.Realm;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.DisableReverseDnsLookup;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.GerritServerId;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitModule;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.FakeAccountCache;
-import com.google.gerrit.testutil.GerritBaseTests;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.gerrit.testutil.TestChanges;
-import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.TypeLiteral;
-import com.google.inject.util.Providers;
-import java.sql.Timestamp;
-import java.util.TimeZone;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Ignore;
-import org.junit.runner.RunWith;
-
-@Ignore
-@RunWith(ConfigSuite.class)
-public abstract class AbstractChangeNotesTest extends GerritBaseTests {
-  @ConfigSuite.Default
-  public static Config changeNotesLegacy() {
-    Config cfg = new Config();
-    cfg.setBoolean("notedb", null, "writeJson", false);
-    return cfg;
-  }
-
-  @ConfigSuite.Config
-  public static Config changeNotesJson() {
-    Config cfg = new Config();
-    cfg.setBoolean("notedb", null, "writeJson", true);
-    return cfg;
-  }
-
-  @ConfigSuite.Parameter public Config testConfig;
-
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
-
-  protected Account.Id otherUserId;
-  protected FakeAccountCache accountCache;
-  protected IdentifiedUser changeOwner;
-  protected IdentifiedUser otherUser;
-  protected InMemoryRepository repo;
-  protected InMemoryRepositoryManager repoManager;
-  protected PersonIdent serverIdent;
-  protected InternalUser internalUser;
-  protected Project.NameKey project;
-  protected RevWalk rw;
-  protected TestRepository<InMemoryRepository> tr;
-
-  @Inject protected IdentifiedUser.GenericFactory userFactory;
-
-  @Inject protected NoteDbUpdateManager.Factory updateManagerFactory;
-
-  @Inject protected AllUsersName allUsers;
-
-  @Inject protected AbstractChangeNotes.Args args;
-
-  @Inject @GerritServerId private String serverId;
-
-  protected Injector injector;
-  private String systemTimeZone;
-
-  @Before
-  public void setUp() throws Exception {
-    setTimeForTesting();
-
-    serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.nowTs(), TZ);
-    project = new Project.NameKey("test-project");
-    repoManager = new InMemoryRepositoryManager();
-    repo = repoManager.createRepository(project);
-    tr = new TestRepository<>(repo);
-    rw = tr.getRevWalk();
-    accountCache = new FakeAccountCache();
-    Account co = new Account(new Account.Id(1), TimeUtil.nowTs());
-    co.setFullName("Change Owner");
-    co.setPreferredEmail("change@owner.com");
-    accountCache.put(co);
-    Account ou = new Account(new Account.Id(2), TimeUtil.nowTs());
-    ou.setFullName("Other Account");
-    ou.setPreferredEmail("other@account.com");
-    accountCache.put(ou);
-
-    injector =
-        Guice.createInjector(
-            new FactoryModule() {
-              @Override
-              public void configure() {
-                install(new GitModule());
-                install(NoteDbModule.forTest(testConfig));
-                bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
-                bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
-                bind(GitRepositoryManager.class).toInstance(repoManager);
-                bind(ProjectCache.class).toProvider(Providers.<ProjectCache>of(null));
-                bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
-                bind(String.class)
-                    .annotatedWith(AnonymousCowardName.class)
-                    .toProvider(AnonymousCowardNameProvider.class);
-                bind(String.class)
-                    .annotatedWith(CanonicalWebUrl.class)
-                    .toInstance("http://localhost:8080/");
-                bind(Boolean.class)
-                    .annotatedWith(DisableReverseDnsLookup.class)
-                    .toInstance(Boolean.FALSE);
-                bind(Realm.class).to(FakeRealm.class);
-                bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
-                bind(AccountCache.class).toInstance(accountCache);
-                bind(PersonIdent.class)
-                    .annotatedWith(GerritPersonIdent.class)
-                    .toInstance(serverIdent);
-                bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
-                bind(MetricMaker.class).to(DisabledMetricMaker.class);
-                bind(ReviewDb.class).toProvider(Providers.<ReviewDb>of(null));
-
-                MutableNotesMigration migration = MutableNotesMigration.newDisabled();
-                migration.setFrom(NotesMigrationState.FINAL);
-                bind(MutableNotesMigration.class).toInstance(migration);
-                bind(NotesMigration.class).to(MutableNotesMigration.class);
-
-                // Tests don't support ReviewDb at all, but bindings are required via NoteDbModule.
-                bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {})
-                    .toInstance(
-                        () -> {
-                          throw new UnsupportedOperationException();
-                        });
-                bind(ChangeBundleReader.class)
-                    .toInstance(
-                        (db, id) -> {
-                          throw new UnsupportedOperationException();
-                        });
-              }
-            });
-
-    injector.injectMembers(this);
-    repoManager.createRepository(allUsers);
-    changeOwner = userFactory.create(co.getId());
-    otherUser = userFactory.create(ou.getId());
-    otherUserId = otherUser.getAccountId();
-    internalUser = new InternalUser();
-  }
-
-  private void setTimeForTesting() {
-    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-    TestTimeUtil.resetWithClockStep(1, SECONDS);
-  }
-
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
-  }
-
-  protected Change newChange(boolean workInProgress) throws Exception {
-    Change c = TestChanges.newChange(project, changeOwner.getAccountId());
-    ChangeUpdate u = newUpdate(c, changeOwner);
-    u.setChangeId(c.getKey().get());
-    u.setBranch(c.getDest().get());
-    u.setWorkInProgress(workInProgress);
-    u.commit();
-    return c;
-  }
-
-  protected Change newWorkInProgressChange() throws Exception {
-    return newChange(true);
-  }
-
-  protected Change newChange() throws Exception {
-    return newChange(false);
-  }
-
-  protected ChangeUpdate newUpdate(Change c, CurrentUser user) throws Exception {
-    ChangeUpdate update = TestChanges.newUpdate(injector, c, user);
-    update.setPatchSetId(c.currentPatchSetId());
-    update.setAllowWriteToNewRef(true);
-    return update;
-  }
-
-  protected ChangeNotes newNotes(Change c) throws OrmException {
-    return new ChangeNotes(args, c).load();
-  }
-
-  protected static SubmitRecord submitRecord(
-      String status, String errorMessage, SubmitRecord.Label... labels) {
-    SubmitRecord rec = new SubmitRecord();
-    rec.status = SubmitRecord.Status.valueOf(status);
-    rec.errorMessage = errorMessage;
-    if (labels.length > 0) {
-      rec.labels = ImmutableList.copyOf(labels);
-    }
-    return rec;
-  }
-
-  protected static SubmitRecord.Label submitLabel(
-      String name, String status, Account.Id appliedBy) {
-    SubmitRecord.Label label = new SubmitRecord.Label();
-    label.label = name;
-    label.status = SubmitRecord.Label.Status.valueOf(status);
-    label.appliedBy = appliedBy;
-    return label;
-  }
-
-  protected Comment newComment(
-      PatchSet.Id psId,
-      String filename,
-      String UUID,
-      CommentRange range,
-      int line,
-      IdentifiedUser commenter,
-      String parentUUID,
-      Timestamp t,
-      String message,
-      short side,
-      String commitSHA1,
-      boolean unresolved) {
-    Comment c =
-        new Comment(
-            new Comment.Key(UUID, filename, psId.get()),
-            commenter.getAccountId(),
-            t,
-            side,
-            message,
-            serverId,
-            unresolved);
-    c.lineNbr = line;
-    c.parentUuid = parentUUID;
-    c.revId = commitSHA1;
-    c.setRange(range);
-    return c;
-  }
-
-  protected static Timestamp truncate(Timestamp ts) {
-    return new Timestamp((ts.getTime() / 1000) * 1000);
-  }
-
-  protected static Timestamp after(Change c, long millis) {
-    return new Timestamp(c.getCreatedOn().getTime() + millis);
-  }
-}
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
deleted file mode 100644
index 80a8ab9..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
+++ /dev/null
@@ -1,1972 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.common.TimeUtil.roundToSecond;
-import static com.google.gerrit.server.notedb.ChangeBundle.Source.NOTE_DB;
-import static com.google.gerrit.server.notedb.ChangeBundle.Source.REVIEW_DB;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.google.common.collect.HashBasedTable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Table;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.LabelId;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
-import com.google.gerrit.testutil.GerritBaseTests;
-import com.google.gerrit.testutil.TestChanges;
-import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gwtorm.protobuf.CodecFactory;
-import com.google.gwtorm.protobuf.ProtobufCodec;
-import java.sql.Timestamp;
-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;
-
-public class ChangeBundleTest extends GerritBaseTests {
-  private static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
-  private static final ProtobufCodec<ChangeMessage> CHANGE_MESSAGE_CODEC =
-      CodecFactory.encoder(ChangeMessage.class);
-  private static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
-      CodecFactory.encoder(PatchSet.class);
-  private static final ProtobufCodec<PatchSetApproval> PATCH_SET_APPROVAL_CODEC =
-      CodecFactory.encoder(PatchSetApproval.class);
-  private static final ProtobufCodec<PatchLineComment> PATCH_LINE_COMMENT_CODEC =
-      CodecFactory.encoder(PatchLineComment.class);
-
-  private String systemTimeZoneProperty;
-  private TimeZone systemTimeZone;
-
-  private Project.NameKey project;
-  private Account.Id accountId;
-
-  @Before
-  public void setUp() {
-    String tz = "US/Eastern";
-    systemTimeZoneProperty = System.setProperty("user.timezone", tz);
-    systemTimeZone = TimeZone.getDefault();
-    TimeZone.setDefault(TimeZone.getTimeZone(tz));
-    long maxMs = ChangeRebuilderImpl.MAX_WINDOW_MS;
-    assertThat(maxMs).isGreaterThan(1000L);
-    TestTimeUtil.resetWithClockStep(maxMs * 2, MILLISECONDS);
-    project = new Project.NameKey("project");
-    accountId = new Account.Id(100);
-  }
-
-  @After
-  public void tearDown() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZoneProperty);
-    TimeZone.setDefault(systemTimeZone);
-  }
-
-  private void superWindowResolution() {
-    TestTimeUtil.setClockStep(ChangeRebuilderImpl.MAX_WINDOW_MS * 2, MILLISECONDS);
-    TimeUtil.nowTs();
-  }
-
-  private void subWindowResolution() {
-    TestTimeUtil.setClockStep(1, SECONDS);
-    TimeUtil.nowTs();
-  }
-
-  @Test
-  public void diffChangesDifferentIds() throws Exception {
-    Change c1 = TestChanges.newChange(project, accountId);
-    int id1 = c1.getId().get();
-    Change c2 = TestChanges.newChange(project, accountId);
-    int id2 = c2.getId().get();
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-
-    assertDiffs(
-        b1,
-        b2,
-        "changeId differs for Changes: {" + id1 + "} != {" + id2 + "}",
-        "createdOn differs for Changes: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}",
-        "effective last updated time differs for Changes:"
-            + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}");
-  }
-
-  @Test
-  public void diffChangesSameId() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    Change c2 = clone(c1);
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-
-    assertNoDiffs(b1, b2);
-
-    c2.setTopic("topic");
-    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {null} != {topic}");
-  }
-
-  @Test
-  public void diffChangesMixedSourcesAllowsSlop() throws Exception {
-    subWindowResolution();
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    Change c2 = clone(c1);
-    c2.setCreatedOn(TimeUtil.nowTs());
-    c2.setLastUpdatedOn(TimeUtil.nowTs());
-
-    // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "createdOn differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:02.0}",
-        "effective last updated time differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:03.0}");
-
-    // One NoteDb, slop is allowed.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-
-    // But not too much slop.
-    superWindowResolution();
-    Change c3 = clone(c1);
-    c3.setLastUpdatedOn(TimeUtil.nowTs());
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    ChangeBundle b3 =
-        new ChangeBundle(
-            c3, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    String msg =
-        "effective last updated time differs for Change.Id "
-            + c1.getId()
-            + " in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:10.0}";
-    assertDiffs(b1, b3, msg);
-    assertDiffs(b3, b1, msg);
-  }
-
-  @Test
-  public void diffChangesIgnoresOriginalSubjectInReviewDb() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    c1.setCurrentPatchSet(c1.currentPatchSetId(), "Subject", "Original A");
-    Change c2 = clone(c1);
-    c2.setCurrentPatchSet(c2.currentPatchSetId(), c1.getSubject(), "Original B");
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "originalSubject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Original A} != {Original B}");
-
-    // Both NoteDb, exact match required.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "originalSubject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Original A} != {Original B}");
-
-    // One ReviewDb, one NoteDb, original subject is ignored.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffChangesSanitizesSubjectsBeforeComparison() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    c1.setCurrentPatchSet(c1.currentPatchSetId(), "Subject\r\rbody", "Original");
-    Change c2 = clone(c1);
-    c2.setCurrentPatchSet(c2.currentPatchSetId(), "Subject  body", "Original");
-
-    // Both ReviewDb, exact match required
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Subject\r\rbody} != {Subject  body}");
-
-    // Both NoteDb, exact match required (although it should be impossible to
-    // create a NoteDb change with '\r' in the subject).
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Subject\r\rbody} != {Subject  body}");
-
-    // One ReviewDb, one NoteDb, '\r' is normalized to ' '.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffChangesConsidersEmptyReviewDbTopicEquivalentToNullInNoteDb() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    c1.setTopic("");
-    Change c2 = clone(c1);
-    c2.setTopic(null);
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {} != {null}");
-
-    // Topic ignored if ReviewDb is empty and NoteDb is null.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-
-    // Exact match still required if NoteDb has empty value (not realistic).
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {} != {null}");
-
-    // Null is not equal to a non-empty string.
-    Change c3 = clone(c1);
-    c3.setTopic("topic");
-    b1 =
-        new ChangeBundle(
-            c3, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {topic} != {null}");
-
-    // Null is equal to a string that is all whitespace.
-    Change c4 = clone(c1);
-    c4.setTopic("  ");
-    b1 =
-        new ChangeBundle(
-            c4, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffChangesIgnoresLeadingAndTrailingWhitespaceInReviewDbTopics() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    c1.setTopic(" abc ");
-    Change c2 = clone(c1);
-    c2.setTopic("abc");
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": { abc } != {abc}");
-
-    // Leading whitespace in ReviewDb topic is ignored.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-
-    // Must match except for the leading/trailing whitespace.
-    Change c3 = clone(c1);
-    c3.setTopic("cba");
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c3, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": { abc } != {cba}");
-  }
-
-  @Test
-  public void diffChangesTakesMaxEntityTimestampFromReviewDb() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    PatchSet ps = new PatchSet(c1.currentPatchSetId());
-    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps.setUploader(accountId);
-    ps.setCreatedOn(TimeUtil.nowTs());
-    PatchSetApproval a =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c1.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            TimeUtil.nowTs());
-
-    Change c2 = clone(c1);
-    c2.setLastUpdatedOn(a.getGranted());
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(ps), approvals(a), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(ps), approvals(a), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "effective last updated time differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:12.0}");
-
-    // NoteDb allows latest timestamp from all entities in bundle.
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(ps), approvals(a), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-  }
-
-  @Test
-  public void diffChangesIgnoresChangeTimestampIfAnyOtherEntitiesExist() {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    PatchSet ps = new PatchSet(c1.currentPatchSetId());
-    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps.setUploader(accountId);
-    ps.setCreatedOn(TimeUtil.nowTs());
-    PatchSetApproval a =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c1.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            TimeUtil.nowTs());
-    c1.setLastUpdatedOn(a.getGranted());
-
-    Change c2 = clone(c1);
-    c2.setLastUpdatedOn(TimeUtil.nowTs());
-
-    // ReviewDb has later lastUpdatedOn timestamp than NoteDb, allowed since
-    // NoteDb matches the latest timestamp of a non-Change entity.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c2, messages(), patchSets(ps), approvals(a), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c1, messages(), patchSets(ps), approvals(a), comments(), reviewers(), NOTE_DB);
-    assertThat(b1.getChange().getLastUpdatedOn()).isGreaterThan(b2.getChange().getLastUpdatedOn());
-    assertNoDiffs(b1, b2);
-
-    // Timestamps must actually match if Change is the only entity.
-    b1 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "effective last updated time differs for Change.Id "
-            + c1.getId()
-            + " in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:12.0} != {2009-09-30 17:00:18.0}");
-  }
-
-  @Test
-  public void diffChangesAllowsReviewDbSubjectToBePrefixOfNoteDbSubject() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    Change c2 = clone(c1);
-    c2.setCurrentPatchSet(
-        c1.currentPatchSetId(), c1.getSubject().substring(0, 10), c1.getOriginalSubject());
-    assertThat(c2.getSubject()).isNotEqualTo(c1.getSubject());
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "subject differs for Change.Id " + c1.getId() + ": {Change subject} != {Change sub}");
-
-    // ReviewDb has shorter subject, allowed.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-
-    // NoteDb has shorter subject, not allowed.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), latest(c1), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(c2, messages(), latest(c2), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "subject differs for Change.Id " + c1.getId() + ": {Change subject} != {Change sub}");
-  }
-
-  @Test
-  public void diffChangesTrimsLeadingSpacesFromReviewDbComparingToNoteDb() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    Change c2 = clone(c1);
-    c2.setCurrentPatchSet(c1.currentPatchSetId(), "   " + c1.getSubject(), c1.getOriginalSubject());
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Change subject} != {   Change subject}");
-
-    // ReviewDb is missing leading spaces, allowed.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffChangesDoesntTrimLeadingNonSpaceWhitespaceFromSubject() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    Change c2 = clone(c1);
-    c2.setCurrentPatchSet(c1.currentPatchSetId(), "\t" + c1.getSubject(), c1.getOriginalSubject());
-
-    // Both ReviewDb.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Change subject} != {\tChange subject}");
-
-    // One NoteDb.
-    b1 =
-        new ChangeBundle(c1, messages(), latest(c1), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), latest(c2), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Change subject} != {\tChange subject}");
-    assertDiffs(
-        b2,
-        b1,
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {\tChange subject} != {Change subject}");
-  }
-
-  @Test
-  public void diffChangesHandlesBuggyJGitSubjectExtraction() throws Exception {
-    Change c1 = TestChanges.newChange(project, accountId);
-    String buggySubject = "Subject\r \r Rest of message.";
-    c1.setCurrentPatchSet(c1.currentPatchSetId(), buggySubject, buggySubject);
-    Change c2 = clone(c1);
-    c2.setCurrentPatchSet(c2.currentPatchSetId(), "Subject", "Subject");
-
-    // Both ReviewDb.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "originalSubject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Subject\r \r Rest of message.} != {Subject}",
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Subject\r \r Rest of message.} != {Subject}");
-
-    // NoteDb has correct subject without "\r ".
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffChangesIgnoresInvalidCurrentPatchSetIdInReviewDb() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    Change c2 = clone(c1);
-    c2.setCurrentPatchSet(
-        new PatchSet.Id(c2.getId(), 0), "Unrelated subject", c2.getOriginalSubject());
-
-    // Both ReviewDb.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "currentPatchSetId differs for Change.Id " + c1.getId() + ": {1} != {0}",
-        "subject differs for Change.Id "
-            + c1.getId()
-            + ":"
-            + " {Change subject} != {Unrelated subject}");
-
-    // One NoteDb.
-    //
-    // This is based on a real corrupt change where all patch sets were deleted
-    // but the Change entity stuck around, resulting in a currentPatchSetId of 0
-    // after converting to NoteDb.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffChangesAllowsCreatedToMatchLastUpdated() throws Exception {
-    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
-    c1.setCreatedOn(TimeUtil.nowTs());
-    assertThat(c1.getCreatedOn()).isGreaterThan(c1.getLastUpdatedOn());
-    Change c2 = clone(c1);
-    c2.setCreatedOn(c2.getLastUpdatedOn());
-
-    // Both ReviewDb.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "createdOn differs for Change.Id "
-            + c1.getId()
-            + ": {2009-09-30 17:00:06.0} != {2009-09-30 17:00:00.0}");
-
-    // One NoteDb.
-    b1 =
-        new ChangeBundle(
-            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffChangeMessageKeySets() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    int id = c.getId().get();
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid1"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    ChangeMessage cm2 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid2"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-
-    assertDiffs(
-        b1,
-        b2,
-        "ChangeMessage.Key sets differ:"
-            + " ["
-            + id
-            + ",uuid1] only in A; ["
-            + id
-            + ",uuid2] only in B");
-  }
-
-  @Test
-  public void diffChangeMessages() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    cm1.setMessage("message 1");
-    ChangeMessage cm2 = clone(cm1);
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-
-    assertNoDiffs(b1, b2);
-
-    cm2.setMessage("message 2");
-    assertDiffs(
-        b1,
-        b2,
-        "message differs for ChangeMessage.Key "
-            + c.getId()
-            + ",uuid:"
-            + " {message 1} != {message 2}");
-  }
-
-  @Test
-  public void diffChangeMessagesIgnoresUuids() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    int id = c.getId().get();
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid1"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    cm1.setMessage("message 1");
-    ChangeMessage cm2 = clone(cm1);
-    cm2.getKey().set("uuid2");
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    // Both are ReviewDb, exact UUID match is required.
-    assertDiffs(
-        b1,
-        b2,
-        "ChangeMessage.Key sets differ:"
-            + " ["
-            + id
-            + ",uuid1] only in A; ["
-            + id
-            + ",uuid2] only in B");
-
-    // One NoteDb, UUIDs are ignored.
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-  }
-
-  @Test
-  public void diffChangeMessagesWithDifferentCounts() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    int id = c.getId().get();
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid1"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    cm1.setMessage("message 1");
-    ChangeMessage cm2 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid2"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    cm1.setMessage("message 2");
-
-    // Both ReviewDb: Uses same keySet diff as other types.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1, cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1, b2, "ChangeMessage.Key sets differ: [" + id + ",uuid2] only in A; [] only in B");
-
-    // One NoteDb: UUIDs in keys can't be used for comparison, just diff counts.
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1, cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(b1, b2, "ChangeMessages differ for Change.Id " + id + "\nOnly in A:\n  " + cm2);
-    assertDiffs(b2, b1, "ChangeMessages differ for Change.Id " + id + "\nOnly in B:\n  " + cm2);
-  }
-
-  @Test
-  public void diffChangeMessagesMixedSourcesWithDifferences() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    int id = c.getId().get();
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid1"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    cm1.setMessage("message 1");
-    ChangeMessage cm2 = clone(cm1);
-    cm2.setMessage("message 2");
-    ChangeMessage cm3 = clone(cm1);
-    cm3.getKey().set("uuid2"); // Differs only in UUID.
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1, cm3), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(cm2, cm3), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
-    // Implementation happens to pair up cm1 in b1 with cm3 in b2 because it
-    // depends on iteration order and doesn't care about UUIDs. The important
-    // thing is that there's some diff.
-    assertDiffs(
-        b1,
-        b2,
-        "ChangeMessages differ for Change.Id "
-            + id
-            + "\n"
-            + "Only in A:\n  "
-            + cm3
-            + "\n"
-            + "Only in B:\n  "
-            + cm2);
-    assertDiffs(
-        b2,
-        b1,
-        "ChangeMessages differ for Change.Id "
-            + id
-            + "\n"
-            + "Only in A:\n  "
-            + cm2
-            + "\n"
-            + "Only in B:\n  "
-            + cm3);
-  }
-
-  @Test
-  public void diffChangeMessagesMixedSourcesAllowsSlop() throws Exception {
-    subWindowResolution();
-    Change c = TestChanges.newChange(project, accountId);
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid1"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    ChangeMessage cm2 = clone(cm1);
-    cm2.setWrittenOn(TimeUtil.nowTs());
-
-    // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "writtenOn differs for ChangeMessage.Key "
-            + c.getId()
-            + ",uuid1:"
-            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
-
-    // One NoteDb, slop is allowed.
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-
-    // But not too much slop.
-    superWindowResolution();
-    ChangeMessage cm3 = clone(cm1);
-    cm3.setWrittenOn(TimeUtil.nowTs());
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
-    ChangeBundle b3 =
-        new ChangeBundle(
-            c, messages(cm3), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    int id = c.getId().get();
-    assertDiffs(
-        b1,
-        b3,
-        "ChangeMessages differ for Change.Id "
-            + id
-            + "\n"
-            + "Only in A:\n  "
-            + cm1
-            + "\n"
-            + "Only in B:\n  "
-            + cm3);
-    assertDiffs(
-        b3,
-        b1,
-        "ChangeMessages differ for Change.Id "
-            + id
-            + "\n"
-            + "Only in A:\n  "
-            + cm3
-            + "\n"
-            + "Only in B:\n  "
-            + cm1);
-  }
-
-  @Test
-  public void diffChangeMessagesAllowsNullPatchSetIdFromReviewDb() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    int id = c.getId().get();
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    cm1.setMessage("message 1");
-    ChangeMessage cm2 = clone(cm1);
-    cm2.setPatchSetId(null);
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-
-    // Both are ReviewDb, exact patch set ID match is required.
-    assertDiffs(
-        b1,
-        b2,
-        "patchset differs for ChangeMessage.Key "
-            + c.getId()
-            + ",uuid:"
-            + " {"
-            + id
-            + ",1} != {null}");
-
-    // Null patch set ID on ReviewDb is ignored.
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-
-    // Null patch set ID on NoteDb is not ignored (but is not realistic).
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "ChangeMessages differ for Change.Id "
-            + id
-            + "\n"
-            + "Only in A:\n  "
-            + cm1
-            + "\n"
-            + "Only in B:\n  "
-            + cm2);
-    assertDiffs(
-        b2,
-        b1,
-        "ChangeMessages differ for Change.Id "
-            + id
-            + "\n"
-            + "Only in A:\n  "
-            + cm2
-            + "\n"
-            + "Only in B:\n  "
-            + cm1);
-  }
-
-  @Test
-  public void diffPatchSetIdSets() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    TestChanges.incrementPatchSet(c);
-
-    PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
-    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps1.setUploader(accountId);
-    ps1.setCreatedOn(TimeUtil.nowTs());
-    PatchSet ps2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
-    ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
-    ps2.setUploader(accountId);
-    ps2.setCreatedOn(TimeUtil.nowTs());
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1, ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-
-    assertDiffs(b1, b2, "PatchSet.Id sets differ: [] only in A; [" + c.getId() + ",1] only in B");
-  }
-
-  @Test
-  public void diffPatchSets() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    PatchSet ps1 = new PatchSet(c.currentPatchSetId());
-    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps1.setUploader(accountId);
-    ps1.setCreatedOn(TimeUtil.nowTs());
-    PatchSet ps2 = clone(ps1);
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-
-    assertNoDiffs(b1, b2);
-
-    ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
-    assertDiffs(
-        b1,
-        b2,
-        "revision differs for PatchSet.Id "
-            + c.getId()
-            + ",1:"
-            + " {RevId{deadbeefdeadbeefdeadbeefdeadbeefdeadbeef}}"
-            + " != {RevId{badc0feebadc0feebadc0feebadc0feebadc0fee}}");
-  }
-
-  @Test
-  public void diffPatchSetsMixedSourcesAllowsSlop() throws Exception {
-    subWindowResolution();
-    Change c = TestChanges.newChange(project, accountId);
-    PatchSet ps1 = new PatchSet(c.currentPatchSetId());
-    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps1.setUploader(accountId);
-    ps1.setCreatedOn(roundToSecond(TimeUtil.nowTs()));
-    PatchSet ps2 = clone(ps1);
-    ps2.setCreatedOn(TimeUtil.nowTs());
-
-    // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "createdOn differs for PatchSet.Id "
-            + c.getId()
-            + ",1:"
-            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
-
-    // One NoteDb, slop is allowed.
-    b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-
-    // But not too much slop.
-    superWindowResolution();
-    PatchSet ps3 = clone(ps1);
-    ps3.setCreatedOn(TimeUtil.nowTs());
-    b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB);
-    ChangeBundle b3 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps3), approvals(), comments(), reviewers(), REVIEW_DB);
-    String msg =
-        "createdOn differs for PatchSet.Id "
-            + c.getId()
-            + ",1 in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:10.0}";
-    assertDiffs(b1, b3, msg);
-    assertDiffs(b3, b1, msg);
-  }
-
-  @Test
-  public void diffPatchSetsIgnoresTrailingNewlinesInPushCertificate() throws Exception {
-    subWindowResolution();
-    Change c = TestChanges.newChange(project, accountId);
-    PatchSet ps1 = new PatchSet(c.currentPatchSetId());
-    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps1.setUploader(accountId);
-    ps1.setCreatedOn(roundToSecond(TimeUtil.nowTs()));
-    ps1.setPushCertificate("some cert");
-    PatchSet ps2 = clone(ps1);
-    ps2.setPushCertificate(ps2.getPushCertificate() + "\n\n");
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-
-    b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffPatchSetsGreaterThanCurrent() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-
-    PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
-    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps1.setUploader(accountId);
-    ps1.setCreatedOn(TimeUtil.nowTs());
-    PatchSet ps2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
-    ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
-    ps2.setUploader(accountId);
-    ps2.setCreatedOn(TimeUtil.nowTs());
-    assertThat(ps2.getId().get()).isGreaterThan(c.currentPatchSetId().get());
-
-    ChangeMessage cm1 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid1"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-    ChangeMessage cm2 =
-        new ChangeMessage(
-            new ChangeMessage.Key(c.getId(), "uuid2"),
-            accountId,
-            TimeUtil.nowTs(),
-            c.currentPatchSetId());
-
-    PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(ps1.getId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            TimeUtil.nowTs());
-    PatchSetApproval a2 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(ps2.getId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            TimeUtil.nowTs());
-
-    // Both ReviewDb.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c,
-            messages(cm1, cm2),
-            patchSets(ps1, ps2),
-            approvals(a1, a2),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "ChangeMessage.Key sets differ: [] only in A; [" + cm2.getKey() + "] only in B",
-        "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B",
-        "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B");
-
-    // One NoteDb.
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c,
-            messages(cm1, cm2),
-            patchSets(ps1, ps2),
-            approvals(a1, a2),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "ChangeMessages differ for Change.Id " + c.getId() + "\nOnly in B:\n  " + cm2,
-        "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B",
-        "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B");
-
-    // Both NoteDb.
-    b1 =
-        new ChangeBundle(
-            c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c,
-            messages(cm1, cm2),
-            patchSets(ps1, ps2),
-            approvals(a1, a2),
-            comments(),
-            reviewers(),
-            NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "ChangeMessages differ for Change.Id " + c.getId() + "\nOnly in B:\n  " + cm2,
-        "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B",
-        "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B");
-  }
-
-  @Test
-  public void diffPatchSetsIgnoresLeadingAndTrailingWhitespaceInReviewDbDescriptions()
-      throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-
-    PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
-    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps1.setUploader(accountId);
-    ps1.setCreatedOn(TimeUtil.nowTs());
-    ps1.setDescription(" abc ");
-    PatchSet ps2 = clone(ps1);
-    ps2.setDescription("abc");
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1, b2, "description differs for PatchSet.Id " + ps1.getId() + ": { abc } != {abc}");
-
-    // Whitespace in ReviewDb description is ignored.
-    b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-
-    // Must match except for the leading/trailing whitespace.
-    PatchSet ps3 = clone(ps1);
-    ps3.setDescription("cba");
-    b1 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(), patchSets(ps3), approvals(), comments(), reviewers(), NOTE_DB);
-    assertDiffs(
-        b1, b2, "description differs for PatchSet.Id " + ps1.getId() + ": { abc } != {cba}");
-  }
-
-  @Test
-  public void diffPatchSetsIgnoresCreatedOnWhenReviewDbIsNonMonotonic() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-
-    Timestamp beforePs1 = TimeUtil.nowTs();
-
-    PatchSet goodPs1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
-    goodPs1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    goodPs1.setUploader(accountId);
-    goodPs1.setCreatedOn(TimeUtil.nowTs());
-    PatchSet goodPs2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
-    goodPs2.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    goodPs2.setUploader(accountId);
-    goodPs2.setCreatedOn(TimeUtil.nowTs());
-    assertThat(goodPs2.getCreatedOn()).isGreaterThan(goodPs1.getCreatedOn());
-
-    PatchSet badPs2 = clone(goodPs2);
-    badPs2.setCreatedOn(beforePs1);
-    assertThat(badPs2.getCreatedOn()).isLessThan(goodPs1.getCreatedOn());
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(goodPs1, goodPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(goodPs1, badPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "createdOn differs for PatchSet.Id "
-            + badPs2.getId()
-            + ":"
-            + " {2009-09-30 17:00:18.0} != {2009-09-30 17:00:06.0}");
-
-    // Non-monotonic in ReviewDb but monotonic in NoteDb, timestamps are
-    // ignored, including for ps1.
-    PatchSet badPs1 = clone(goodPs1);
-    badPs1.setCreatedOn(TimeUtil.nowTs());
-    b1 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(badPs1, badPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(goodPs1, goodPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-
-    // Non-monotonic in NoteDb but monotonic in ReviewDb, timestamps are not
-    // ignored.
-    b1 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(goodPs1, goodPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(badPs1, badPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "createdOn differs for PatchSet.Id "
-            + badPs1.getId()
-            + " in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:24.0} != {2009-09-30 17:00:12.0}",
-        "createdOn differs for PatchSet.Id "
-            + badPs2.getId()
-            + " in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:06.0} != {2009-09-30 17:00:18.0}");
-  }
-
-  @Test
-  public void diffPatchSetsAllowsFirstPatchSetCreatedOnToMatchChangeCreatedOn() {
-    Change c = TestChanges.newChange(project, accountId);
-    c.setLastUpdatedOn(TimeUtil.nowTs());
-
-    PatchSet goodPs1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
-    goodPs1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    goodPs1.setUploader(accountId);
-    goodPs1.setCreatedOn(TimeUtil.nowTs());
-    assertThat(goodPs1.getCreatedOn()).isGreaterThan(c.getCreatedOn());
-
-    PatchSet ps1AtCreatedOn = clone(goodPs1);
-    ps1AtCreatedOn.setCreatedOn(c.getCreatedOn());
-
-    PatchSet goodPs2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
-    goodPs2.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    goodPs2.setUploader(accountId);
-    goodPs2.setCreatedOn(TimeUtil.nowTs());
-
-    PatchSet ps2AtCreatedOn = clone(goodPs2);
-    ps2AtCreatedOn.setCreatedOn(c.getCreatedOn());
-
-    // Both ReviewDb, exact match required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(goodPs1, goodPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(ps1AtCreatedOn, ps2AtCreatedOn),
-            approvals(),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "createdOn differs for PatchSet.Id "
-            + c.getId()
-            + ",1: {2009-09-30 17:00:12.0} != {2009-09-30 17:00:00.0}",
-        "createdOn differs for PatchSet.Id "
-            + c.getId()
-            + ",2: {2009-09-30 17:00:18.0} != {2009-09-30 17:00:00.0}");
-
-    // One ReviewDb, PS1 is allowed to match change createdOn, but PS2 isn't.
-    b1 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(goodPs1, goodPs2),
-            approvals(),
-            comments(),
-            reviewers(),
-            REVIEW_DB);
-    b2 =
-        new ChangeBundle(
-            c,
-            messages(),
-            patchSets(ps1AtCreatedOn, ps2AtCreatedOn),
-            approvals(),
-            comments(),
-            reviewers(),
-            NOTE_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "createdOn differs for PatchSet.Id "
-            + c.getId()
-            + ",2 in NoteDb vs. ReviewDb: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:18.0}");
-    assertDiffs(
-        b2,
-        b1,
-        "createdOn differs for PatchSet.Id "
-            + c.getId()
-            + ",2 in NoteDb vs. ReviewDb: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:18.0}");
-  }
-
-  @Test
-  public void diffPatchSetApprovalKeySets() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    int id = c.getId().get();
-    PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            TimeUtil.nowTs());
-    PatchSetApproval a2 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Verified")),
-            (short) 1,
-            TimeUtil.nowTs());
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
-
-    assertDiffs(
-        b1,
-        b2,
-        "PatchSetApproval.Key sets differ:"
-            + " ["
-            + id
-            + "%2C1,100,Code-Review] only in A;"
-            + " ["
-            + id
-            + "%2C1,100,Verified] only in B");
-  }
-
-  @Test
-  public void diffPatchSetApprovals() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            TimeUtil.nowTs());
-    PatchSetApproval a2 = clone(a1);
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
-
-    assertNoDiffs(b1, b2);
-
-    a2.setValue((short) -1);
-    assertDiffs(
-        b1,
-        b2,
-        "value differs for PatchSetApproval.Key "
-            + c.getId()
-            + "%2C1,100,Code-Review: {1} != {-1}");
-  }
-
-  @Test
-  public void diffPatchSetApprovalsMixedSourcesAllowsSlop() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    subWindowResolution();
-    PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            roundToSecond(TimeUtil.nowTs()));
-    PatchSetApproval a2 = clone(a1);
-    a2.setGranted(TimeUtil.nowTs());
-
-    // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "granted differs for PatchSetApproval.Key "
-            + c.getId()
-            + "%2C1,100,Code-Review:"
-            + " {2009-09-30 17:00:07.0} != {2009-09-30 17:00:08.0}");
-
-    // One NoteDb, slop is allowed.
-    b1 =
-        new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-
-    // But not too much slop.
-    superWindowResolution();
-    PatchSetApproval a3 = clone(a1);
-    a3.setGranted(TimeUtil.nowTs());
-    b1 =
-        new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB);
-    ChangeBundle b3 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a3), comments(), reviewers(), REVIEW_DB);
-    String msg =
-        "granted differs for PatchSetApproval.Key "
-            + c.getId()
-            + "%2C1,100,Code-Review in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:07.0} != {2009-09-30 17:00:15.0}";
-    assertDiffs(b1, b3, msg);
-    assertDiffs(b3, b1, msg);
-  }
-
-  @Test
-  public void diffPatchSetApprovalsAllowsTruncatedTimestampInNoteDb() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-            (short) 1,
-            c.getCreatedOn());
-    PatchSetApproval a2 = clone(a1);
-    a2.setGranted(
-        new Timestamp(
-            new DateTime(1900, 1, 1, 0, 0, 0, DateTimeZone.forTimeZone(TimeZone.getDefault()))
-                .getMillis()));
-
-    // Both are ReviewDb, exact match is required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "granted differs for PatchSetApproval.Key "
-            + c.getId()
-            + "%2C1,100,Code-Review:"
-            + " {2009-09-30 17:00:00.0} != {1900-01-01 00:00:00.0}");
-
-    // Truncating NoteDb timestamp is allowed.
-    b1 =
-        new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-  }
-
-  @Test
-  public void diffPatchSetApprovalsIgnoresPostSubmitBitOnZeroVote() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    c.setStatus(Change.Status.MERGED);
-    PatchSetApproval a1 =
-        new PatchSetApproval(
-            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-            (short) 0,
-            TimeUtil.nowTs());
-    a1.setPostSubmit(false);
-    PatchSetApproval a2 = clone(a1);
-    a2.setPostSubmit(true);
-
-    // Both are ReviewDb, exact match is required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "postSubmit differs for PatchSetApproval.Key "
-            + c.getId()
-            + "%2C1,100,Code-Review:"
-            + " {false} != {true}");
-
-    // One NoteDb, postSubmit is ignored.
-    b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
-    b2 =
-        new ChangeBundle(c, messages(), latest(c), approvals(a2), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
-
-    // postSubmit is not ignored if vote isn't 0.
-    a1.setValue((short) 1);
-    a2.setValue((short) 1);
-    assertDiffs(
-        b1,
-        b2,
-        "postSubmit differs for PatchSetApproval.Key "
-            + c.getId()
-            + "%2C1,100,Code-Review:"
-            + " {false} != {true}");
-    assertDiffs(
-        b2,
-        b1,
-        "postSubmit differs for PatchSetApproval.Key "
-            + c.getId()
-            + "%2C1,100,Code-Review:"
-            + " {true} != {false}");
-  }
-
-  @Test
-  public void diffReviewers() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    Timestamp now = TimeUtil.nowTs();
-    ReviewerSet r1 = reviewers(REVIEWER, new Account.Id(1), now);
-    ReviewerSet r2 = reviewers(REVIEWER, new Account.Id(2), now);
-
-    ChangeBundle b1 =
-        new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r1, REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r2, REVIEW_DB);
-    assertNoDiffs(b1, b1);
-    assertNoDiffs(b2, b2);
-    assertDiffs(b1, b2, "reviewer sets differ: [1] only in A; [2] only in B");
-  }
-
-  @Test
-  public void diffReviewersIgnoresStateAndTimestamp() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    ReviewerSet r1 = reviewers(REVIEWER, new Account.Id(1), TimeUtil.nowTs());
-    ReviewerSet r2 = reviewers(CC, new Account.Id(1), TimeUtil.nowTs());
-
-    ChangeBundle b1 =
-        new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r1, REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r2, REVIEW_DB);
-    assertNoDiffs(b1, b1);
-    assertNoDiffs(b2, b2);
-  }
-
-  @Test
-  public void diffPatchLineCommentKeySets() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    int id = c.getId().get();
-    PatchLineComment c1 =
-        new PatchLineComment(
-            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename1"), "uuid1"),
-            5,
-            accountId,
-            null,
-            TimeUtil.nowTs());
-    PatchLineComment c2 =
-        new PatchLineComment(
-            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename2"), "uuid2"),
-            5,
-            accountId,
-            null,
-            TimeUtil.nowTs());
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
-
-    assertDiffs(
-        b1,
-        b2,
-        "PatchLineComment.Key sets differ:"
-            + " ["
-            + id
-            + ",1,filename1,uuid1] only in A;"
-            + " ["
-            + id
-            + ",1,filename2,uuid2] only in B");
-  }
-
-  @Test
-  public void diffPatchLineComments() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    PatchLineComment c1 =
-        new PatchLineComment(
-            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename"), "uuid"),
-            5,
-            accountId,
-            null,
-            TimeUtil.nowTs());
-    PatchLineComment c2 = clone(c1);
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
-
-    assertNoDiffs(b1, b2);
-
-    c2.setStatus(PatchLineComment.Status.PUBLISHED);
-    assertDiffs(
-        b1,
-        b2,
-        "status differs for PatchLineComment.Key " + c.getId() + ",1,filename,uuid: {d} != {P}");
-  }
-
-  @Test
-  public void diffPatchLineCommentsMixedSourcesAllowsSlop() throws Exception {
-    subWindowResolution();
-    Change c = TestChanges.newChange(project, accountId);
-    PatchLineComment c1 =
-        new PatchLineComment(
-            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename"), "uuid"),
-            5,
-            accountId,
-            null,
-            roundToSecond(TimeUtil.nowTs()));
-    PatchLineComment c2 = clone(c1);
-    c2.setWrittenOn(TimeUtil.nowTs());
-
-    // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
-    assertDiffs(
-        b1,
-        b2,
-        "writtenOn differs for PatchLineComment.Key "
-            + c.getId()
-            + ",1,filename,uuid:"
-            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
-
-    // One NoteDb, slop is allowed.
-    b1 =
-        new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1), reviewers(), NOTE_DB);
-    b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-
-    // But not too much slop.
-    superWindowResolution();
-    PatchLineComment c3 = clone(c1);
-    c3.setWrittenOn(TimeUtil.nowTs());
-    b1 =
-        new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1), reviewers(), NOTE_DB);
-    ChangeBundle b3 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c3), reviewers(), REVIEW_DB);
-    String msg =
-        "writtenOn differs for PatchLineComment.Key "
-            + c.getId()
-            + ",1,filename,uuid in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:10.0}";
-    assertDiffs(b1, b3, msg);
-    assertDiffs(b3, b1, msg);
-  }
-
-  @Test
-  public void diffPatchLineCommentsIgnoresCommentsOnInvalidPatchSet() throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-    PatchLineComment c1 =
-        new PatchLineComment(
-            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename1"), "uuid1"),
-            5,
-            accountId,
-            null,
-            TimeUtil.nowTs());
-    PatchLineComment c2 =
-        new PatchLineComment(
-            new PatchLineComment.Key(
-                new Patch.Key(new PatchSet.Id(c.getId(), 0), "filename2"), "uuid2"),
-            5,
-            accountId,
-            null,
-            TimeUtil.nowTs());
-
-    ChangeBundle b1 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c1, c2), reviewers(), REVIEW_DB);
-    ChangeBundle b2 =
-        new ChangeBundle(
-            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
-  }
-
-  private static void assertNoDiffs(ChangeBundle a, ChangeBundle b) {
-    assertThat(a.differencesFrom(b)).isEmpty();
-    assertThat(b.differencesFrom(a)).isEmpty();
-  }
-
-  private static void assertDiffs(ChangeBundle a, ChangeBundle b, String first, String... rest) {
-    List<String> actual = a.differencesFrom(b);
-    if (actual.size() == 1 && rest.length == 0) {
-      // This error message is much easier to read.
-      assertThat(actual.get(0)).isEqualTo(first);
-    } else {
-      List<String> expected = new ArrayList<>(1 + rest.length);
-      expected.add(first);
-      Collections.addAll(expected, rest);
-      assertThat(actual).containsExactlyElementsIn(expected).inOrder();
-    }
-    assertThat(a).isNotEqualTo(b);
-  }
-
-  private static List<ChangeMessage> messages(ChangeMessage... ents) {
-    return Arrays.asList(ents);
-  }
-
-  private static List<PatchSet> patchSets(PatchSet... ents) {
-    return Arrays.asList(ents);
-  }
-
-  private static List<PatchSet> latest(Change c) {
-    PatchSet ps = new PatchSet(c.currentPatchSetId());
-    ps.setCreatedOn(c.getLastUpdatedOn());
-    return ImmutableList.of(ps);
-  }
-
-  private static List<PatchSetApproval> approvals(PatchSetApproval... ents) {
-    return Arrays.asList(ents);
-  }
-
-  private static ReviewerSet reviewers(Object... ents) {
-    checkArgument(ents.length % 3 == 0);
-    Table<ReviewerStateInternal, Account.Id, Timestamp> t = HashBasedTable.create();
-    for (int i = 0; i < ents.length; i += 3) {
-      t.put((ReviewerStateInternal) ents[i], (Account.Id) ents[i + 1], (Timestamp) ents[i + 2]);
-    }
-    return ReviewerSet.fromTable(t);
-  }
-
-  private static List<PatchLineComment> comments(PatchLineComment... ents) {
-    return Arrays.asList(ents);
-  }
-
-  private static Change clone(Change ent) {
-    return clone(CHANGE_CODEC, ent);
-  }
-
-  private static ChangeMessage clone(ChangeMessage ent) {
-    return clone(CHANGE_MESSAGE_CODEC, ent);
-  }
-
-  private static PatchSet clone(PatchSet ent) {
-    return clone(PATCH_SET_CODEC, ent);
-  }
-
-  private static PatchSetApproval clone(PatchSetApproval ent) {
-    return clone(PATCH_SET_APPROVAL_CODEC, ent);
-  }
-
-  private static PatchLineComment clone(PatchLineComment ent) {
-    return clone(PATCH_LINE_COMMENT_CODEC, ent);
-  }
-
-  private static <T> T clone(ProtobufCodec<T> codec, T obj) {
-    return codec.decode(codec.encodeToByteArray(obj));
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
deleted file mode 100644
index 5fa7a30..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ /dev/null
@@ -1,565 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class ChangeNotesParserTest extends AbstractChangeNotesTest {
-  private TestRepository<InMemoryRepository> testRepo;
-  private ChangeNotesRevWalk walk;
-
-  @Before
-  public void setUpTestRepo() throws Exception {
-    testRepo = new TestRepository<>(repo);
-    walk = ChangeNotesCommit.newRevWalk(repo);
-  }
-
-  @After
-  public void tearDownTestRepo() throws Exception {
-    walk.close();
-  }
-
-  @Test
-  public void parseAuthor() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Subject: This is a test change\n");
-    assertParseFails(
-        writeCommit(
-            "Update change\n\nPatch-set: 1\n",
-            new PersonIdent(
-                "Change Owner",
-                "owner@example.com",
-                serverIdent.getWhen(),
-                serverIdent.getTimeZone())));
-    assertParseFails(
-        writeCommit(
-            "Update change\n\nPatch-set: 1\n",
-            new PersonIdent(
-                "Change Owner", "x@gerrit", serverIdent.getWhen(), serverIdent.getTimeZone())));
-    assertParseFails(
-        writeCommit(
-            "Update change\n\nPatch-set: 1\n",
-            new PersonIdent(
-                "Change\n\u1234<Owner>",
-                "\n\nx<@>\u0002gerrit",
-                serverIdent.getWhen(),
-                serverIdent.getTimeZone())));
-  }
-
-  @Test
-  public void parseStatus() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Status: NEW\n"
-            + "Subject: This is a test change\n");
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Status: new\n"
-            + "Subject: This is a test change\n");
-    assertParseFails("Update change\n\nPatch-set: 1\nStatus: OOPS\n");
-    assertParseFails("Update change\n\nPatch-set: 1\nStatus: NEW\nStatus: NEW\n");
-  }
-
-  @Test
-  public void parsePatchSetId() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Subject: This is a test change\n");
-    assertParseFails("Update change\n\n");
-    assertParseFails("Update change\n\nPatch-set: 1\nPatch-set: 1\n");
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Subject: This is a test change\n");
-    assertParseFails("Update change\n\nPatch-set: x\n");
-  }
-
-  @Test
-  public void parseApproval() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Label: Label1=+1\n"
-            + "Label: Label2=1\n"
-            + "Label: Label3=0\n"
-            + "Label: Label4=-1\n"
-            + "Subject: This is a test change\n");
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Label: -Label1\n"
-            + "Label: -Label1 Other Account <2@gerrit>\n"
-            + "Subject: This is a test change\n");
-    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1=X\n");
-    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1 = 1\n");
-    assertParseFails("Update change\n\nPatch-set: 1\nLabel: X+Y\n");
-    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1 Other Account <2@gerrit>\n");
-    assertParseFails("Update change\n\nPatch-set: 1\nLabel: -Label!1\n");
-    assertParseFails("Update change\n\nPatch-set: 1\nLabel: -Label!1 Other Account <2@gerrit>\n");
-  }
-
-  @Test
-  public void parseSubmitRecords() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Subject: This is a test change\n"
-            + "Submitted-with: NOT_READY\n"
-            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
-            + "Submitted-with: NEED: Code-Review\n"
-            + "Submitted-with: NOT_READY\n"
-            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
-            + "Submitted-with: NEED: Alternative-Code-Review\n");
-    assertParseFails("Update change\n\nPatch-set: 1\nSubmitted-with: OOPS\n");
-    assertParseFails("Update change\n\nPatch-set: 1\nSubmitted-with: NEED: X+Y\n");
-    assertParseFails(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Submitted-with: OK: X+Y: Change Owner <1@gerrit>\n");
-    assertParseFails(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Submitted-with: OK: Code-Review: 1@gerrit\n");
-  }
-
-  @Test
-  public void parseSubmissionId() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Subject: This is a test change\n"
-            + "Submission-id: 1-1453387607626-96fabc25");
-    assertParseFails(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Submission-id: 1-1453387607626-96fabc25\n"
-            + "Submission-id: 1-1453387901516-5d1e2450");
-  }
-
-  @Test
-  public void parseReviewer() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Reviewer: Change Owner <1@gerrit>\n"
-            + "CC: Other Account <2@gerrit>\n"
-            + "Subject: This is a test change\n");
-    assertParseFails("Update change\n\nPatch-set: 1\nReviewer: 1@gerrit\n");
-  }
-
-  @Test
-  public void parseTopic() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Topic: Some Topic\n"
-            + "Subject: This is a test change\n");
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Topic:\n"
-            + "Subject: This is a test change\n");
-    assertParseFails("Update change\n\nPatch-set: 1\nTopic: Some Topic\nTopic: Other Topic");
-  }
-
-  @Test
-  public void parseBranch() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Subject: This is a test change\n");
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Subject: This is a test change\n");
-    assertParseFails(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Branch: refs/heads/master\n"
-            + "Branch: refs/heads/stable");
-  }
-
-  @Test
-  public void parseChangeId() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Subject: This is a test change\n");
-    assertParseFails(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Change-id: I159532ef4844d7c18f7f3fd37a0b275590d41b1b");
-  }
-
-  @Test
-  public void parseSubject() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Subject: Some subject of a change\n");
-    assertParseFails(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Subject: Some subject of a change\n"
-            + "Subject: Some other subject\n");
-  }
-
-  @Test
-  public void parseCommit() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 2\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Subject: Some subject of a change\n"
-            + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234");
-    assertParseFails(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 2\n"
-            + "Branch: refs/heads/master\n"
-            + "Subject: Some subject of a change\n"
-            + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-            + "Commit: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    assertParseFails(
-        "Update patch set 1\n"
-            + "Uploaded patch set 1.\n"
-            + "Patch-set: 2\n"
-            + "Branch: refs/heads/master\n"
-            + "Subject: Some subject of a change\n"
-            + "Commit: beef");
-  }
-
-  @Test
-  public void parsePatchSetState() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1 (PUBLISHED)\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Subject: Some subject of a change\n");
-    assertParseFails(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1 (DRAFT)\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Subject: Some subject of a change\n");
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1 (DELETED)\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Subject: Some subject of a change\n");
-    assertParseFails(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1 (NOT A STATUS)\n"
-            + "Branch: refs/heads/master\n"
-            + "Subject: Some subject of a change\n");
-  }
-
-  @Test
-  public void parsePatchSetGroups() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 2\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-            + "Subject: Change subject\n"
-            + "Groups: a,b,c\n");
-    assertParseFails(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 2\n"
-            + "Branch: refs/heads/master\n"
-            + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-            + "Subject: Change subject\n"
-            + "Groups: a,b,c\n"
-            + "Groups: d,e,f\n");
-  }
-
-  @Test
-  public void parseServerIdent() throws Exception {
-    String msg =
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Subject: Change subject\n";
-    assertParseSucceeds(msg);
-    assertParseSucceeds(writeCommit(msg, serverIdent));
-
-    msg =
-        "Update change\n"
-            + "\n"
-            + "With a message."
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Subject: Change subject\n";
-    assertParseSucceeds(msg);
-    assertParseSucceeds(writeCommit(msg, serverIdent));
-
-    msg =
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Subject: Change subject\n"
-            + "Label: Label1=+1\n";
-    assertParseSucceeds(msg);
-    assertParseFails(writeCommit(msg, serverIdent));
-  }
-
-  @Test
-  public void parseTag() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Subject: Change subject\n"
-            + "Tag:\n");
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Subject: Change subject\n"
-            + "Tag: jenkins\n");
-    assertParseFails(
-        "Update change\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Subject: Change subject\n"
-            + "Tag: ci\n"
-            + "Tag: jenkins\n");
-  }
-
-  @Test
-  public void parseWorkInProgress() throws Exception {
-    // Change created in WIP remains in WIP.
-    RevCommit commit = writeCommit("Update WIP change\n" + "\n" + "Patch-set: 1\n", true);
-    ChangeNotesState state = newParser(commit).parseAll();
-    assertThat(state.hasReviewStarted()).isFalse();
-
-    // Moving change out of WIP starts review.
-    commit =
-        writeCommit("New ready change\n" + "\n" + "Patch-set: 1\n" + "Work-in-progress: false\n");
-    state = newParser(commit).parseAll();
-    assertThat(state.hasReviewStarted()).isTrue();
-
-    // Change created not in WIP has always been in review started state.
-    state = assertParseSucceeds("New change that doesn't declare WIP\n" + "\n" + "Patch-set: 1\n");
-    assertThat(state.hasReviewStarted()).isTrue();
-  }
-
-  @Test
-  public void pendingReviewers() throws Exception {
-    // Change created in WIP.
-    RevCommit commit = writeCommit("Update WIP change\n" + "\n" + "Patch-set: 1\n", true);
-    ChangeNotesState state = newParser(commit).parseAll();
-    assertThat(state.pendingReviewers().all()).isEmpty();
-    assertThat(state.pendingReviewersByEmail().all()).isEmpty();
-
-    // Reviewers added while in WIP.
-    commit =
-        writeCommit(
-            "Add reviewers\n"
-                + "\n"
-                + "Patch-set: 1\n"
-                + "Reviewer: Change Owner "
-                + "<1@gerrit>\n",
-            true);
-    state = newParser(commit).parseAll();
-    assertThat(state.pendingReviewers().byState(ReviewerStateInternal.REVIEWER)).isNotEmpty();
-  }
-
-  @Test
-  public void caseInsensitiveFooters() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "BRaNch: refs/heads/master\n"
-            + "Change-ID: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "patcH-set: 1\n"
-            + "subject: This is a test change\n");
-  }
-
-  @Test
-  public void currentPatchSet() throws Exception {
-    assertParseSucceeds("Update change\n\nPatch-set: 1\nCurrent: true");
-    assertParseSucceeds("Update change\n\nPatch-set: 1\nCurrent: tRUe");
-    assertParseFails("Update change\n\nPatch-set: 1\nCurrent: false");
-    assertParseFails("Update change\n\nPatch-set: 1\nCurrent: blah");
-  }
-
-  private RevCommit writeCommit(String body) throws Exception {
-    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
-    return writeCommit(
-        body,
-        noteUtil.newIdent(
-            changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent, "Anonymous Coward"),
-        false);
-  }
-
-  private RevCommit writeCommit(String body, PersonIdent author) throws Exception {
-    return writeCommit(body, author, false);
-  }
-
-  private RevCommit writeCommit(String body, boolean initWorkInProgress) throws Exception {
-    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
-    return writeCommit(
-        body,
-        noteUtil.newIdent(
-            changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent, "Anonymous Coward"),
-        initWorkInProgress);
-  }
-
-  private RevCommit writeCommit(String body, PersonIdent author, boolean initWorkInProgress)
-      throws Exception {
-    Change change = newChange(initWorkInProgress);
-    ChangeNotes notes = newNotes(change).load();
-    try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
-      CommitBuilder cb = new CommitBuilder();
-      cb.setParentId(notes.getRevision());
-      cb.setAuthor(author);
-      cb.setCommitter(new PersonIdent(serverIdent, author.getWhen()));
-      cb.setTreeId(testRepo.tree());
-      cb.setMessage(body);
-      ObjectId id = ins.insert(cb);
-      ins.flush();
-      RevCommit commit = walk.parseCommit(id);
-      walk.parseBody(commit);
-      return commit;
-    }
-  }
-
-  private ChangeNotesState assertParseSucceeds(String body) throws Exception {
-    return assertParseSucceeds(writeCommit(body));
-  }
-
-  private ChangeNotesState assertParseSucceeds(RevCommit commit) throws Exception {
-    return newParser(commit).parseAll();
-  }
-
-  private void assertParseFails(String body) throws Exception {
-    assertParseFails(writeCommit(body));
-  }
-
-  private void assertParseFails(RevCommit commit) throws Exception {
-    try {
-      newParser(commit).parseAll();
-      fail("Expected parse to fail:\n" + commit.getFullMessage());
-    } catch (ConfigInvalidException e) {
-      // Expected
-    }
-  }
-
-  private ChangeNotesParser newParser(ObjectId tip) throws Exception {
-    walk.reset();
-    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
-    return new ChangeNotesParser(newChange().getId(), tip, walk, noteUtil, args.metrics);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
deleted file mode 100644
index baa51b7..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ /dev/null
@@ -1,3579 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REMOVED;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-import static org.junit.Assert.fail;
-
-import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableTable;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.config.GerritServerId;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
-import com.google.gerrit.server.util.RequestId;
-import com.google.gerrit.testutil.TestChanges;
-import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.Note;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.Test;
-
-public class ChangeNotesTest extends AbstractChangeNotesTest {
-  @Inject private DraftCommentNotes.Factory draftNotesFactory;
-
-  @Inject private ChangeNoteUtil noteUtil;
-
-  @Inject private @GerritServerId String serverId;
-
-  @Test
-  public void tagChangeMessage() throws Exception {
-    String tag = "jenkins";
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeMessage("verification from jenkins");
-    update.setTag(tag);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    assertThat(notes.getChangeMessages()).hasSize(1);
-    assertThat(notes.getChangeMessages().get(0).getTag()).isEqualTo(tag);
-  }
-
-  @Test
-  public void patchSetDescription() throws Exception {
-    String description = "descriptive";
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPsDescription(description);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getCurrentPatchSet().getDescription()).isEqualTo(description);
-
-    description = "new, now more descriptive!";
-    update = newUpdate(c, changeOwner);
-    update.setPsDescription(description);
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getCurrentPatchSet().getDescription()).isEqualTo(description);
-  }
-
-  @Test
-  public void tagInlineComments() throws Exception {
-    String tag = "jenkins";
-    Change c = newChange();
-    RevCommit commit = tr.commit().message("PS2").create();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putComment(
-        Status.PUBLISHED,
-        newComment(
-            c.currentPatchSetId(),
-            "a.txt",
-            "uuid1",
-            new CommentRange(1, 2, 3, 4),
-            1,
-            changeOwner,
-            null,
-            TimeUtil.nowTs(),
-            "Comment",
-            (short) 1,
-            commit.name(),
-            false));
-    update.setTag(tag);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    ImmutableListMultimap<RevId, Comment> comments = notes.getComments();
-    assertThat(comments).hasSize(1);
-    assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(tag);
-  }
-
-  @Test
-  public void tagApprovals() throws Exception {
-    String tag1 = "jenkins";
-    String tag2 = "ip";
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) -1);
-    update.setTag(tag1);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) 1);
-    update.setTag(tag2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
-    assertThat(approvals).hasSize(1);
-    assertThat(approvals.entries().asList().get(0).getValue().getTag()).isEqualTo(tag2);
-  }
-
-  @Test
-  public void multipleTags() throws Exception {
-    String ipTag = "ip";
-    String coverageTag = "coverage";
-    String integrationTag = "integration";
-    Change c = newChange();
-
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) -1);
-    update.setChangeMessage("integration verification");
-    update.setTag(integrationTag);
-    update.commit();
-
-    RevCommit commit = tr.commit().message("PS2").create();
-    update = newUpdate(c, changeOwner);
-    update.putComment(
-        Status.PUBLISHED,
-        newComment(
-            c.currentPatchSetId(),
-            "a.txt",
-            "uuid1",
-            new CommentRange(1, 2, 3, 4),
-            1,
-            changeOwner,
-            null,
-            TimeUtil.nowTs(),
-            "Comment",
-            (short) 1,
-            commit.name(),
-            false));
-    update.setChangeMessage("coverage verification");
-    update.setTag(coverageTag);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.setChangeMessage("ip clear");
-    update.setTag(ipTag);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
-    assertThat(approvals).hasSize(1);
-    PatchSetApproval approval = approvals.entries().asList().get(0).getValue();
-    assertThat(approval.getTag()).isEqualTo(integrationTag);
-    assertThat(approval.getValue()).isEqualTo(-1);
-
-    ImmutableListMultimap<RevId, Comment> comments = notes.getComments();
-    assertThat(comments).hasSize(1);
-    assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(coverageTag);
-
-    ImmutableList<ChangeMessage> messages = notes.getChangeMessages();
-    assertThat(messages).hasSize(3);
-    assertThat(messages.get(0).getTag()).isEqualTo(integrationTag);
-    assertThat(messages.get(1).getTag()).isEqualTo(coverageTag);
-    assertThat(messages.get(2).getTag()).isEqualTo(ipTag);
-  }
-
-  @Test
-  public void approvalsOnePatchSet() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) 1);
-    update.putApproval("Code-Review", (short) -1);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
-    assertThat(psas).hasSize(2);
-
-    assertThat(psas.get(0).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(0).getAccountId().get()).isEqualTo(1);
-    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(0).getValue()).isEqualTo((short) -1);
-    assertThat(psas.get(0).getGranted()).isEqualTo(truncate(after(c, 2000)));
-
-    assertThat(psas.get(1).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(1).getAccountId().get()).isEqualTo(1);
-    assertThat(psas.get(1).getLabel()).isEqualTo("Verified");
-    assertThat(psas.get(1).getValue()).isEqualTo((short) 1);
-    assertThat(psas.get(1).getGranted()).isEqualTo(psas.get(0).getGranted());
-  }
-
-  @Test
-  public void approvalsMultiplePatchSets() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
-    update.commit();
-    PatchSet.Id ps1 = c.currentPatchSetId();
-
-    incrementPatchSet(c);
-    update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.commit();
-    PatchSet.Id ps2 = c.currentPatchSetId();
-
-    ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, PatchSetApproval> psas = notes.getApprovals();
-    assertThat(psas).hasSize(2);
-
-    PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
-    assertThat(psa1.getPatchSetId()).isEqualTo(ps1);
-    assertThat(psa1.getAccountId().get()).isEqualTo(1);
-    assertThat(psa1.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa1.getValue()).isEqualTo((short) -1);
-    assertThat(psa1.getGranted()).isEqualTo(truncate(after(c, 2000)));
-
-    PatchSetApproval psa2 = Iterables.getOnlyElement(psas.get(ps2));
-    assertThat(psa2.getPatchSetId()).isEqualTo(ps2);
-    assertThat(psa2.getAccountId().get()).isEqualTo(1);
-    assertThat(psa2.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa2.getValue()).isEqualTo((short) +1);
-    assertThat(psa2.getGranted()).isEqualTo(truncate(after(c, 4000)));
-  }
-
-  @Test
-  public void approvalsMultipleApprovals() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getValue()).isEqualTo((short) -1);
-
-    update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.commit();
-
-    notes = newNotes(c);
-    psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getLabel()).isEqualTo("Code-Review");
-    assertThat(psa.getValue()).isEqualTo((short) 1);
-  }
-
-  @Test
-  public void approvalsMultipleUsers() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    update.putApproval("Code-Review", (short) 1);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
-    assertThat(psas).hasSize(2);
-
-    assertThat(psas.get(0).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(0).getAccountId().get()).isEqualTo(1);
-    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(0).getValue()).isEqualTo((short) -1);
-    assertThat(psas.get(0).getGranted()).isEqualTo(truncate(after(c, 2000)));
-
-    assertThat(psas.get(1).getPatchSetId()).isEqualTo(c.currentPatchSetId());
-    assertThat(psas.get(1).getAccountId().get()).isEqualTo(2);
-    assertThat(psas.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(1).getValue()).isEqualTo((short) 1);
-    assertThat(psas.get(1).getGranted()).isEqualTo(truncate(after(c, 3000)));
-  }
-
-  @Test
-  public void approvalsTombstone() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Not-For-Long", (short) 1);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getAccountId().get()).isEqualTo(1);
-    assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
-    assertThat(psa.getValue()).isEqualTo((short) 1);
-
-    update = newUpdate(c, changeOwner);
-    update.removeApproval("Not-For-Long");
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getApprovals())
-        .containsExactlyEntriesIn(
-            ImmutableListMultimap.of(
-                psa.getPatchSetId(),
-                new PatchSetApproval(psa.getKey(), (short) 0, update.getWhen())));
-  }
-
-  @Test
-  public void removeOtherUsersApprovals() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    update.putApproval("Not-For-Long", (short) 1);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getAccountId()).isEqualTo(otherUserId);
-    assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
-    assertThat(psa.getValue()).isEqualTo((short) 1);
-
-    update = newUpdate(c, changeOwner);
-    update.removeApprovalFor(otherUserId, "Not-For-Long");
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getApprovals())
-        .containsExactlyEntriesIn(
-            ImmutableListMultimap.of(
-                psa.getPatchSetId(),
-                new PatchSetApproval(psa.getKey(), (short) 0, update.getWhen())));
-
-    // Add back approval on same label.
-    update = newUpdate(c, otherUser);
-    update.putApproval("Not-For-Long", (short) 2);
-    update.commit();
-
-    notes = newNotes(c);
-    psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.getAccountId()).isEqualTo(otherUserId);
-    assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
-    assertThat(psa.getValue()).isEqualTo((short) 2);
-  }
-
-  @Test
-  public void putOtherUsersApprovals() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.putApprovalFor(otherUser.getAccountId(), "Code-Review", (short) -1);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> approvals =
-        ReviewDbUtil.intKeyOrdering()
-            .onResultOf(PatchSetApproval::getAccountId)
-            .sortedCopy(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(approvals).hasSize(2);
-
-    assertThat(approvals.get(0).getAccountId()).isEqualTo(changeOwner.getAccountId());
-    assertThat(approvals.get(0).getLabel()).isEqualTo("Code-Review");
-    assertThat(approvals.get(0).getValue()).isEqualTo((short) 1);
-
-    assertThat(approvals.get(1).getAccountId()).isEqualTo(otherUser.getAccountId());
-    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(approvals.get(1).getValue()).isEqualTo((short) -1);
-  }
-
-  @Test
-  public void approvalsPostSubmit() throws Exception {
-    Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.putApproval("Verified", (short) 1);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.merge(
-        submissionId,
-        ImmutableList.of(
-            submitRecord(
-                "NOT_READY",
-                null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null))));
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
-    assertThat(approvals).hasSize(2);
-    assertThat(approvals.get(0).getLabel()).isEqualTo("Verified");
-    assertThat(approvals.get(0).getValue()).isEqualTo((short) 1);
-    assertThat(approvals.get(0).isPostSubmit()).isFalse();
-    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(approvals.get(1).getValue()).isEqualTo((short) 2);
-    assertThat(approvals.get(1).isPostSubmit()).isTrue();
-  }
-
-  @Test
-  public void approvalsDuringSubmit() throws Exception {
-    Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.putApproval("Verified", (short) 1);
-    update.commit();
-
-    Account.Id ownerId = changeOwner.getAccountId();
-    Account.Id otherId = otherUser.getAccountId();
-    update = newUpdate(c, otherUser);
-    update.merge(
-        submissionId,
-        ImmutableList.of(
-            submitRecord(
-                "NOT_READY",
-                null,
-                submitLabel("Verified", "OK", ownerId),
-                submitLabel("Code-Review", "NEED", null))));
-    update.putApproval("Other-Label", (short) 1);
-    update.putApprovalFor(ownerId, "Code-Review", (short) 2);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    update.putApproval("Other-Label", (short) 2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
-    assertThat(approvals).hasSize(3);
-    assertThat(approvals.get(0).getAccountId()).isEqualTo(ownerId);
-    assertThat(approvals.get(0).getLabel()).isEqualTo("Verified");
-    assertThat(approvals.get(0).getValue()).isEqualTo(1);
-    assertThat(approvals.get(0).isPostSubmit()).isFalse();
-    assertThat(approvals.get(1).getAccountId()).isEqualTo(ownerId);
-    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(approvals.get(1).getValue()).isEqualTo(2);
-    assertThat(approvals.get(1).isPostSubmit()).isFalse(); // During submit.
-    assertThat(approvals.get(2).getAccountId()).isEqualTo(otherId);
-    assertThat(approvals.get(2).getLabel()).isEqualTo("Other-Label");
-    assertThat(approvals.get(2).getValue()).isEqualTo(2);
-    assertThat(approvals.get(2).isPostSubmit()).isTrue();
-  }
-
-  @Test
-  public void multipleReviewers() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.putReviewer(otherUser.getAccount().getId(), REVIEWER);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    Timestamp ts = new Timestamp(update.getWhen().getTime());
-    assertThat(notes.getReviewers())
-        .isEqualTo(
-            ReviewerSet.fromTable(
-                ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                    .put(REVIEWER, new Account.Id(1), ts)
-                    .put(REVIEWER, new Account.Id(2), ts)
-                    .build()));
-  }
-
-  @Test
-  public void reviewerTypes() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.putReviewer(otherUser.getAccount().getId(), CC);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    Timestamp ts = new Timestamp(update.getWhen().getTime());
-    assertThat(notes.getReviewers())
-        .isEqualTo(
-            ReviewerSet.fromTable(
-                ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                    .put(REVIEWER, new Account.Id(1), ts)
-                    .put(CC, new Account.Id(2), ts)
-                    .build()));
-  }
-
-  @Test
-  public void oneReviewerMultipleTypes() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(otherUser.getAccount().getId(), REVIEWER);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    Timestamp ts = new Timestamp(update.getWhen().getTime());
-    assertThat(notes.getReviewers())
-        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(REVIEWER, new Account.Id(2), ts)));
-
-    update = newUpdate(c, otherUser);
-    update.putReviewer(otherUser.getAccount().getId(), CC);
-    update.commit();
-
-    notes = newNotes(c);
-    ts = new Timestamp(update.getWhen().getTime());
-    assertThat(notes.getReviewers())
-        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(CC, new Account.Id(2), ts)));
-  }
-
-  @Test
-  public void removeReviewer() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(otherUser.getAccount().getId(), REVIEWER);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    update.putApproval("Code-Review", (short) 1);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
-    assertThat(psas).hasSize(2);
-    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
-    assertThat(psas.get(1).getAccountId()).isEqualTo(otherUser.getAccount().getId());
-
-    update = newUpdate(c, changeOwner);
-    update.removeReviewer(otherUser.getAccount().getId());
-    update.commit();
-
-    notes = newNotes(c);
-    psas = notes.getApprovals().get(c.currentPatchSetId());
-    assertThat(psas).hasSize(1);
-    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
-  }
-
-  @Test
-  public void submitRecords() throws Exception {
-    Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setSubjectForCommit("Submit patch set 1");
-
-    update.merge(
-        submissionId,
-        ImmutableList.of(
-            submitRecord(
-                "NOT_READY",
-                null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null)),
-            submitRecord(
-                "NOT_READY",
-                null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Alternative-Code-Review", "NEED", null))));
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    List<SubmitRecord> recs = notes.getSubmitRecords();
-    assertThat(recs).hasSize(2);
-    assertThat(recs.get(0))
-        .isEqualTo(
-            submitRecord(
-                "NOT_READY",
-                null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null)));
-    assertThat(recs.get(1))
-        .isEqualTo(
-            submitRecord(
-                "NOT_READY",
-                null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Alternative-Code-Review", "NEED", null)));
-    assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toStringForStorage());
-  }
-
-  @Test
-  public void latestSubmitRecordsOnly() throws Exception {
-    Change c = newChange();
-    RequestId submissionId = RequestId.forChange(c);
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setSubjectForCommit("Submit patch set 1");
-    update.merge(
-        submissionId,
-        ImmutableList.of(
-            submitRecord("OK", null, submitLabel("Code-Review", "OK", otherUser.getAccountId()))));
-    update.commit();
-
-    incrementPatchSet(c);
-    update = newUpdate(c, changeOwner);
-    update.setSubjectForCommit("Submit patch set 2");
-    update.merge(
-        submissionId,
-        ImmutableList.of(
-            submitRecord(
-                "OK", null, submitLabel("Code-Review", "OK", changeOwner.getAccountId()))));
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getSubmitRecords())
-        .containsExactly(
-            submitRecord("OK", null, submitLabel("Code-Review", "OK", changeOwner.getAccountId())));
-    assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toStringForStorage());
-  }
-
-  @Test
-  public void emptyChangeUpdate() throws Exception {
-    Change c = newChange();
-    Ref initial = repo.exactRef(changeMetaRef(c.getId()));
-    assertThat(initial).isNotNull();
-
-    // Empty update doesn't create a new commit.
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.commit();
-    assertThat(update.getResult()).isNull();
-
-    Ref updated = repo.exactRef(changeMetaRef(c.getId()));
-    assertThat(updated.getObjectId()).isEqualTo(initial.getObjectId());
-  }
-
-  @Test
-  public void assigneeCommit() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setAssignee(otherUserId);
-    ObjectId result = update.commit();
-    assertThat(result).isNotNull();
-    try (RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(update.getResult());
-      rw.parseBody(commit);
-      String strIdent = otherUser.getName() + " <" + otherUserId + "@" + serverId + ">";
-      assertThat(commit.getFullMessage()).contains("Assignee: " + strIdent);
-    }
-  }
-
-  @Test
-  public void assigneeChangeNotes() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setAssignee(otherUserId);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getChange().getAssignee()).isEqualTo(otherUserId);
-
-    update = newUpdate(c, changeOwner);
-    update.setAssignee(changeOwner.getAccountId());
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getChange().getAssignee()).isEqualTo(changeOwner.getAccountId());
-  }
-
-  @Test
-  public void pastAssigneesChangeNotes() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setAssignee(otherUserId);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    update = newUpdate(c, changeOwner);
-    update.setAssignee(changeOwner.getAccountId());
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.setAssignee(otherUserId);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.removeAssignee();
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getPastAssignees()).hasSize(2);
-  }
-
-  @Test
-  public void hashtagCommit() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    LinkedHashSet<String> hashtags = new LinkedHashSet<>();
-    hashtags.add("tag1");
-    hashtags.add("tag2");
-    update.setHashtags(hashtags);
-    update.commit();
-    try (RevWalk walk = new RevWalk(repo)) {
-      RevCommit commit = walk.parseCommit(update.getResult());
-      walk.parseBody(commit);
-      assertThat(commit.getFullMessage()).contains("Hashtags: tag1,tag2\n");
-    }
-  }
-
-  @Test
-  public void hashtagChangeNotes() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    LinkedHashSet<String> hashtags = new LinkedHashSet<>();
-    hashtags.add("tag1");
-    hashtags.add("tag2");
-    update.setHashtags(hashtags);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getHashtags()).isEqualTo(hashtags);
-  }
-
-  @Test
-  public void topicChangeNotes() throws Exception {
-    Change c = newChange();
-
-    // initially topic is not set
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getChange().getTopic()).isNull();
-
-    // set topic
-    String topic = "myTopic";
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic(topic);
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.getChange().getTopic()).isEqualTo(topic);
-
-    // clear topic by setting empty string
-    update = newUpdate(c, changeOwner);
-    update.setTopic("");
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.getChange().getTopic()).isNull();
-
-    // set other topic
-    topic = "otherTopic";
-    update = newUpdate(c, changeOwner);
-    update.setTopic(topic);
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.getChange().getTopic()).isEqualTo(topic);
-
-    // clear topic by setting null
-    update = newUpdate(c, changeOwner);
-    update.setTopic(null);
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.getChange().getTopic()).isNull();
-  }
-
-  @Test
-  public void changeIdChangeNotes() throws Exception {
-    Change c = newChange();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getChange().getKey()).isEqualTo(c.getKey());
-
-    // An update doesn't affect the Change-Id
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic("topic"); // Change something to get a new commit.
-    update.commit();
-    assertThat(notes.getChange().getKey()).isEqualTo(c.getKey());
-
-    // Trying to set another Change-Id fails
-    String otherChangeId = "I577fb248e474018276351785930358ec0450e9f7";
-    update = newUpdate(c, changeOwner);
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage(
-        "The Change-Id was already set to "
-            + c.getKey()
-            + ", so we cannot set this Change-Id: "
-            + otherChangeId);
-    update.setChangeId(otherChangeId);
-  }
-
-  @Test
-  public void branchChangeNotes() throws Exception {
-    Change c = newChange();
-
-    ChangeNotes notes = newNotes(c);
-    Branch.NameKey expectedBranch = new Branch.NameKey(project, "refs/heads/master");
-    assertThat(notes.getChange().getDest()).isEqualTo(expectedBranch);
-
-    // An update doesn't affect the branch
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic("topic"); // Change something to get a new commit.
-    update.commit();
-    assertThat(newNotes(c).getChange().getDest()).isEqualTo(expectedBranch);
-
-    // Set another branch
-    String otherBranch = "refs/heads/stable";
-    update = newUpdate(c, changeOwner);
-    update.setBranch(otherBranch);
-    update.commit();
-    assertThat(newNotes(c).getChange().getDest())
-        .isEqualTo(new Branch.NameKey(project, otherBranch));
-  }
-
-  @Test
-  public void ownerChangeNotes() throws Exception {
-    Change c = newChange();
-
-    assertThat(newNotes(c).getChange().getOwner()).isEqualTo(changeOwner.getAccountId());
-
-    // An update doesn't affect the owner
-    ChangeUpdate update = newUpdate(c, otherUser);
-    update.setTopic("topic"); // Change something to get a new commit.
-    update.commit();
-    assertThat(newNotes(c).getChange().getOwner()).isEqualTo(changeOwner.getAccountId());
-  }
-
-  @Test
-  public void createdOnChangeNotes() throws Exception {
-    Change c = newChange();
-
-    Timestamp createdOn = newNotes(c).getChange().getCreatedOn();
-    assertThat(createdOn).isNotNull();
-
-    // An update doesn't affect the createdOn timestamp.
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic("topic"); // Change something to get a new commit.
-    update.commit();
-    assertThat(newNotes(c).getChange().getCreatedOn()).isEqualTo(createdOn);
-  }
-
-  @Test
-  public void lastUpdatedOnChangeNotes() throws Exception {
-    Change c = newChange();
-
-    ChangeNotes notes = newNotes(c);
-    Timestamp ts1 = notes.getChange().getLastUpdatedOn();
-    assertThat(ts1).isEqualTo(notes.getChange().getCreatedOn());
-
-    // Various kinds of updates that update the timestamp.
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic("topic"); // Change something to get a new commit.
-    update.commit();
-    Timestamp ts2 = newNotes(c).getChange().getLastUpdatedOn();
-    assertThat(ts2).isGreaterThan(ts1);
-
-    update = newUpdate(c, changeOwner);
-    update.setChangeMessage("Some message");
-    update.commit();
-    Timestamp ts3 = newNotes(c).getChange().getLastUpdatedOn();
-    assertThat(ts3).isGreaterThan(ts2);
-
-    update = newUpdate(c, changeOwner);
-    update.setHashtags(ImmutableSet.of("foo"));
-    update.commit();
-    Timestamp ts4 = newNotes(c).getChange().getLastUpdatedOn();
-    assertThat(ts4).isGreaterThan(ts3);
-
-    incrementPatchSet(c);
-    Timestamp ts5 = newNotes(c).getChange().getLastUpdatedOn();
-    assertThat(ts5).isGreaterThan(ts4);
-
-    update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.commit();
-    Timestamp ts6 = newNotes(c).getChange().getLastUpdatedOn();
-    assertThat(ts6).isGreaterThan(ts5);
-
-    update = newUpdate(c, changeOwner);
-    update.setStatus(Change.Status.ABANDONED);
-    update.commit();
-    Timestamp ts7 = newNotes(c).getChange().getLastUpdatedOn();
-    assertThat(ts7).isGreaterThan(ts6);
-
-    update = newUpdate(c, changeOwner);
-    update.putReviewer(otherUser.getAccountId(), ReviewerStateInternal.REVIEWER);
-    update.commit();
-    Timestamp ts8 = newNotes(c).getChange().getLastUpdatedOn();
-    assertThat(ts8).isGreaterThan(ts7);
-
-    update = newUpdate(c, changeOwner);
-    update.setGroups(ImmutableList.of("a", "b"));
-    update.commit();
-    Timestamp ts9 = newNotes(c).getChange().getLastUpdatedOn();
-    assertThat(ts9).isGreaterThan(ts8);
-
-    // Finish off by merging the change.
-    update = newUpdate(c, changeOwner);
-    update.merge(
-        RequestId.forChange(c),
-        ImmutableList.of(
-            submitRecord(
-                "NOT_READY",
-                null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Alternative-Code-Review", "NEED", null))));
-    update.commit();
-    Timestamp ts10 = newNotes(c).getChange().getLastUpdatedOn();
-    assertThat(ts10).isGreaterThan(ts9);
-  }
-
-  @Test
-  public void subjectLeadingWhitespaceChangeNotes() throws Exception {
-    Change c = TestChanges.newChange(project, changeOwner.getAccountId());
-    String trimmedSubj = c.getSubject();
-    c.setCurrentPatchSet(c.currentPatchSetId(), "  " + trimmedSubj, c.getOriginalSubject());
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getChange().getSubject()).isEqualTo(trimmedSubj);
-
-    String tabSubj = "\t\t" + trimmedSubj;
-
-    c = TestChanges.newChange(project, changeOwner.getAccountId());
-    c.setCurrentPatchSet(c.currentPatchSetId(), tabSubj, c.getOriginalSubject());
-    update = newUpdate(c, changeOwner);
-    update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getChange().getSubject()).isEqualTo(tabSubj);
-  }
-
-  @Test
-  public void commitChangeNotesUnique() throws Exception {
-    // PatchSetId -> RevId must be a one to one mapping
-    Change c = newChange();
-
-    ChangeNotes notes = newNotes(c);
-    PatchSet ps = notes.getCurrentPatchSet();
-    assertThat(ps).isNotNull();
-
-    // new revId for the same patch set, ps1
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    RevCommit commit = tr.commit().message("PS1 again").create();
-    update.setCommit(rw, commit);
-    update.commit();
-
-    try {
-      notes = newNotes(c);
-      fail("Expected IOException");
-    } catch (OrmException e) {
-      assertCause(
-          e,
-          ConfigInvalidException.class,
-          "Multiple revisions parsed for patch set 1:"
-              + " RevId{"
-              + commit.name()
-              + "} and "
-              + ps.getRevision().get());
-    }
-  }
-
-  @Test
-  public void patchSetChangeNotes() throws Exception {
-    Change c = newChange();
-
-    // ps1 created by newChange()
-    ChangeNotes notes = newNotes(c);
-    PatchSet ps1 = notes.getCurrentPatchSet();
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps1.getId());
-    assertThat(notes.getChange().getSubject()).isEqualTo("Change subject");
-    assertThat(notes.getChange().getOriginalSubject()).isEqualTo("Change subject");
-    assertThat(ps1.getId()).isEqualTo(new PatchSet.Id(c.getId(), 1));
-    assertThat(ps1.getUploader()).isEqualTo(changeOwner.getAccountId());
-
-    // ps2 by other user
-    RevCommit commit = incrementPatchSet(c, otherUser);
-    notes = newNotes(c);
-    PatchSet ps2 = notes.getCurrentPatchSet();
-    assertThat(ps2.getId()).isEqualTo(new PatchSet.Id(c.getId(), 2));
-    assertThat(notes.getChange().getSubject()).isEqualTo("PS2");
-    assertThat(notes.getChange().getOriginalSubject()).isEqualTo("Change subject");
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.getId());
-    assertThat(ps2.getRevision().get()).isNotEqualTo(ps1.getRevision());
-    assertThat(ps2.getRevision().get()).isEqualTo(commit.name());
-    assertThat(ps2.getUploader()).isEqualTo(otherUser.getAccountId());
-    assertThat(ps2.getCreatedOn()).isEqualTo(notes.getChange().getLastUpdatedOn());
-
-    // comment on ps1, current patch set is still ps2
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPatchSetId(ps1.getId());
-    update.setChangeMessage("Comment on old patch set.");
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.getId());
-  }
-
-  @Test
-  public void patchSetStates() throws Exception {
-    Change c = newChange();
-    PatchSet.Id psId1 = c.currentPatchSetId();
-
-    incrementCurrentPatchSetFieldOnly(c);
-    PatchSet.Id psId2 = c.currentPatchSetId();
-    RevCommit commit = tr.commit().message("PS2").create();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setCommit(rw, commit);
-    update.putApproval("Code-Review", (short) 1);
-    update.setChangeMessage("This is a message");
-    update.putComment(
-        Status.PUBLISHED,
-        newComment(
-            c.currentPatchSetId(),
-            "a.txt",
-            "uuid1",
-            new CommentRange(1, 2, 3, 4),
-            1,
-            changeOwner,
-            null,
-            TimeUtil.nowTs(),
-            "Comment",
-            (short) 1,
-            commit.name(),
-            false));
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getPatchSets().keySet()).containsExactly(psId1, psId2);
-    assertThat(notes.getApprovals()).isNotEmpty();
-    assertThat(notes.getChangeMessagesByPatchSet()).isNotEmpty();
-    assertThat(notes.getChangeMessages()).isNotEmpty();
-    assertThat(notes.getComments()).isNotEmpty();
-
-    // publish ps2
-    update = newUpdate(c, changeOwner);
-    update.setPatchSetState(PatchSetState.PUBLISHED);
-    update.commit();
-
-    // delete ps2
-    update = newUpdate(c, changeOwner);
-    update.setPatchSetState(PatchSetState.DELETED);
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getPatchSets().keySet()).containsExactly(psId1);
-    assertThat(notes.getApprovals()).isEmpty();
-    assertThat(notes.getChangeMessagesByPatchSet()).isEmpty();
-    assertThat(notes.getChangeMessages()).isEmpty();
-    assertThat(notes.getComments()).isEmpty();
-  }
-
-  @Test
-  public void patchSetGroups() throws Exception {
-    Change c = newChange();
-    PatchSet.Id psId1 = c.currentPatchSetId();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getPatchSets().get(psId1).getGroups()).isEmpty();
-
-    // ps1
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setGroups(ImmutableList.of("a", "b"));
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.getPatchSets().get(psId1).getGroups()).containsExactly("a", "b").inOrder();
-
-    incrementCurrentPatchSetFieldOnly(c);
-    PatchSet.Id psId2 = c.currentPatchSetId();
-    update = newUpdate(c, changeOwner);
-    update.setCommit(rw, tr.commit().message("PS2").create());
-    update.setGroups(ImmutableList.of("d"));
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.getPatchSets().get(psId2).getGroups()).containsExactly("d");
-    assertThat(notes.getPatchSets().get(psId1).getGroups()).containsExactly("a", "b").inOrder();
-  }
-
-  @Test
-  public void pushCertificate() throws Exception {
-    String pushCert =
-        "certificate version 0.1\n"
-            + "pusher This is not a real push cert\n"
-            + "-----BEGIN PGP SIGNATURE-----\n"
-            + "Version: GnuPG v1\n"
-            + "\n"
-            + "Nor is this a real signature.\n"
-            + "-----END PGP SIGNATURE-----\n";
-
-    // ps2 with push cert
-    Change c = newChange();
-    PatchSet.Id psId1 = c.currentPatchSetId();
-    incrementCurrentPatchSetFieldOnly(c);
-    PatchSet.Id psId2 = c.currentPatchSetId();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPatchSetId(psId2);
-    RevCommit commit = tr.commit().message("PS2").create();
-    update.setCommit(rw, commit, pushCert);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    String note = readNote(notes, commit);
-    if (!testJson()) {
-      assertThat(note).isEqualTo(pushCert);
-    }
-    Map<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
-    assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
-    assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
-    assertThat(notes.getComments()).isEmpty();
-
-    // comment on ps2
-    update = newUpdate(c, changeOwner);
-    update.setPatchSetId(psId2);
-    Timestamp ts = TimeUtil.nowTs();
-    update.putComment(
-        Status.PUBLISHED,
-        newComment(
-            psId2,
-            "a.txt",
-            "uuid1",
-            new CommentRange(1, 2, 3, 4),
-            1,
-            changeOwner,
-            null,
-            ts,
-            "Comment",
-            (short) 1,
-            commit.name(),
-            false));
-    update.commit();
-
-    notes = newNotes(c);
-
-    patchSets = notes.getPatchSets();
-    assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
-    assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
-    assertThat(notes.getComments()).isNotEmpty();
-
-    if (!testJson()) {
-      assertThat(readNote(notes, commit))
-          .isEqualTo(
-              pushCert
-                  + "Revision: "
-                  + commit.name()
-                  + "\n"
-                  + "Patch-set: 2\n"
-                  + "File: a.txt\n"
-                  + "\n"
-                  + "1:2-3:4\n"
-                  + ChangeNoteUtil.formatTime(serverIdent, ts)
-                  + "\n"
-                  + "Author: Change Owner <1@gerrit>\n"
-                  + "Unresolved: false\n"
-                  + "UUID: uuid1\n"
-                  + "Bytes: 7\n"
-                  + "Comment\n"
-                  + "\n");
-    }
-  }
-
-  @Test
-  public void emptyExceptSubject() throws Exception {
-    ChangeUpdate update = newUpdate(newChange(), changeOwner);
-    update.setSubjectForCommit("Create change");
-    assertThat(update.commit()).isNotNull();
-  }
-
-  @Test
-  public void multipleUpdatesInManager() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update1 = newUpdate(c, changeOwner);
-    update1.putApproval("Verified", (short) 1);
-
-    ChangeUpdate update2 = newUpdate(c, otherUser);
-    update2.putApproval("Code-Review", (short) 2);
-
-    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
-      updateManager.add(update1);
-      updateManager.add(update2);
-      updateManager.execute();
-    }
-
-    ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
-    assertThat(psas).hasSize(2);
-
-    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
-    assertThat(psas.get(0).getLabel()).isEqualTo("Verified");
-    assertThat(psas.get(0).getValue()).isEqualTo((short) 1);
-
-    assertThat(psas.get(1).getAccountId()).isEqualTo(otherUser.getAccount().getId());
-    assertThat(psas.get(1).getLabel()).isEqualTo("Code-Review");
-    assertThat(psas.get(1).getValue()).isEqualTo((short) 2);
-  }
-
-  @Test
-  public void multipleUpdatesIncludingComments() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update1 = newUpdate(c, otherUser);
-    String uuid1 = "uuid1";
-    String message1 = "comment 1";
-    CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-    RevCommit tipCommit;
-    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
-      Comment comment1 =
-          newComment(
-              psId,
-              "file1",
-              uuid1,
-              range1,
-              range1.getEndLine(),
-              otherUser,
-              null,
-              time1,
-              message1,
-              (short) 0,
-              "abcd1234abcd1234abcd1234abcd1234abcd1234",
-              false);
-      update1.setPatchSetId(psId);
-      update1.putComment(Status.PUBLISHED, comment1);
-      updateManager.add(update1);
-
-      ChangeUpdate update2 = newUpdate(c, otherUser);
-      update2.putApproval("Code-Review", (short) 2);
-      updateManager.add(update2);
-
-      updateManager.execute();
-    }
-
-    ChangeNotes notes = newNotes(c);
-    ObjectId tip = notes.getRevision();
-    tipCommit = rw.parseCommit(tip);
-
-    RevCommit commitWithApprovals = tipCommit;
-    assertThat(commitWithApprovals).isNotNull();
-    RevCommit commitWithComments = commitWithApprovals.getParent(0);
-    assertThat(commitWithComments).isNotNull();
-
-    try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
-      ChangeNotesParser notesWithComments =
-          new ChangeNotesParser(c.getId(), commitWithComments.copy(), rw, noteUtil, args.metrics);
-      ChangeNotesState state = notesWithComments.parseAll();
-      assertThat(state.approvals()).isEmpty();
-      assertThat(state.publishedComments()).hasSize(1);
-    }
-
-    try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
-      ChangeNotesParser notesWithApprovals =
-          new ChangeNotesParser(c.getId(), commitWithApprovals.copy(), rw, noteUtil, args.metrics);
-      ChangeNotesState state = notesWithApprovals.parseAll();
-      assertThat(state.approvals()).hasSize(1);
-      assertThat(state.publishedComments()).hasSize(1);
-    }
-  }
-
-  @Test
-  public void multipleUpdatesAcrossRefs() throws Exception {
-    Change c1 = newChange();
-    ChangeUpdate update1 = newUpdate(c1, changeOwner);
-    update1.putApproval("Verified", (short) 1);
-
-    Change c2 = newChange();
-    ChangeUpdate update2 = newUpdate(c2, otherUser);
-    update2.putApproval("Code-Review", (short) 2);
-
-    Ref initial1 = repo.exactRef(update1.getRefName());
-    assertThat(initial1).isNotNull();
-    Ref initial2 = repo.exactRef(update2.getRefName());
-    assertThat(initial2).isNotNull();
-
-    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
-      updateManager.add(update1);
-      updateManager.add(update2);
-      updateManager.execute();
-    }
-
-    Ref ref1 = repo.exactRef(update1.getRefName());
-    assertThat(ref1.getObjectId()).isEqualTo(update1.getResult());
-    assertThat(ref1.getObjectId()).isNotEqualTo(initial1.getObjectId());
-    Ref ref2 = repo.exactRef(update2.getRefName());
-    assertThat(ref2.getObjectId()).isEqualTo(update2.getResult());
-    assertThat(ref2.getObjectId()).isNotEqualTo(initial2.getObjectId());
-
-    PatchSetApproval approval1 =
-        newNotes(c1).getApprovals().get(c1.currentPatchSetId()).iterator().next();
-    assertThat(approval1.getLabel()).isEqualTo("Verified");
-
-    PatchSetApproval approval2 =
-        newNotes(c2).getApprovals().get(c2.currentPatchSetId()).iterator().next();
-    assertThat(approval2.getLabel()).isEqualTo("Code-Review");
-  }
-
-  @Test
-  public void changeMessageOnePatchSet() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.setChangeMessage("Just a little code change.\n");
-    update.commit();
-    PatchSet.Id ps1 = c.currentPatchSetId();
-
-    ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
-    assertThat(changeMessages.keySet()).containsExactly(ps1);
-
-    ChangeMessage cm = Iterables.getOnlyElement(changeMessages.get(ps1));
-    assertThat(cm.getMessage()).isEqualTo("Just a little code change.\n");
-    assertThat(cm.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
-    assertThat(cm.getPatchSetId()).isEqualTo(ps1);
-  }
-
-  @Test
-  public void noChangeMessage() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getChangeMessages()).isEmpty();
-  }
-
-  @Test
-  public void changeMessageWithTrailingDoubleNewline() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeMessage("Testing trailing double newline\n\n");
-    update.commit();
-    PatchSet.Id ps1 = c.currentPatchSetId();
-
-    ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
-    assertThat(changeMessages).hasSize(1);
-
-    ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
-    assertThat(cm1.getMessage()).isEqualTo("Testing trailing double newline\n\n");
-    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
-  }
-
-  @Test
-  public void changeMessageWithMultipleParagraphs() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeMessage("Testing paragraph 1\n\nTesting paragraph 2\n\nTesting paragraph 3");
-    update.commit();
-    PatchSet.Id ps1 = c.currentPatchSetId();
-
-    ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
-    assertThat(changeMessages).hasSize(1);
-
-    ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
-    assertThat(cm1.getMessage())
-        .isEqualTo(
-            "Testing paragraph 1\n"
-                + "\n"
-                + "Testing paragraph 2\n"
-                + "\n"
-                + "Testing paragraph 3");
-    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
-  }
-
-  @Test
-  public void changeMessagesMultiplePatchSets() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.setChangeMessage("This is the change message for the first PS.");
-    update.commit();
-    PatchSet.Id ps1 = c.currentPatchSetId();
-
-    incrementPatchSet(c);
-    update = newUpdate(c, changeOwner);
-
-    update.setChangeMessage("This is the change message for the second PS.");
-    update.commit();
-    PatchSet.Id ps2 = c.currentPatchSetId();
-
-    ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
-    assertThat(changeMessages).hasSize(2);
-
-    ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
-    assertThat(cm1.getMessage()).isEqualTo("This is the change message for the first PS.");
-    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
-
-    ChangeMessage cm2 = Iterables.getOnlyElement(changeMessages.get(ps2));
-    assertThat(cm1.getPatchSetId()).isEqualTo(ps1);
-    assertThat(cm2.getMessage()).isEqualTo("This is the change message for the second PS.");
-    assertThat(cm2.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
-    assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
-  }
-
-  @Test
-  public void changeMessageMultipleInOnePatchSet() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.setChangeMessage("First change message.\n");
-    update.commit();
-
-    PatchSet.Id ps1 = c.currentPatchSetId();
-
-    update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.setChangeMessage("Second change message.\n");
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
-    assertThat(changeMessages.keySet()).hasSize(1);
-
-    List<ChangeMessage> cm = changeMessages.get(ps1);
-    assertThat(cm).hasSize(2);
-    assertThat(cm.get(0).getMessage()).isEqualTo("First change message.\n");
-    assertThat(cm.get(0).getAuthor()).isEqualTo(changeOwner.getAccount().getId());
-    assertThat(cm.get(0).getPatchSetId()).isEqualTo(ps1);
-    assertThat(cm.get(1).getMessage()).isEqualTo("Second change message.\n");
-    assertThat(cm.get(1).getAuthor()).isEqualTo(changeOwner.getAccount().getId());
-    assertThat(cm.get(1).getPatchSetId()).isEqualTo(ps1);
-  }
-
-  @Test
-  public void patchLineCommentsFileComment() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
-
-    Comment comment =
-        newComment(
-            psId,
-            "file1",
-            "uuid",
-            null,
-            0,
-            otherUser,
-            null,
-            TimeUtil.nowTs(),
-            "message",
-            (short) 1,
-            revId.get(),
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
-  }
-
-  @Test
-  public void patchLineCommentsZeroColumns() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
-    CommentRange range = new CommentRange(1, 0, 2, 0);
-
-    Comment comment =
-        newComment(
-            psId,
-            "file1",
-            "uuid",
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            TimeUtil.nowTs(),
-            "message",
-            (short) 1,
-            revId.get(),
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
-  }
-
-  @Test
-  public void patchLineCommentZeroRange() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
-    CommentRange range = new CommentRange(0, 0, 0, 0);
-
-    Comment comment =
-        newComment(
-            psId,
-            "file",
-            "uuid",
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            TimeUtil.nowTs(),
-            "message",
-            (short) 1,
-            revId.get(),
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
-  }
-
-  @Test
-  public void patchLineCommentEmptyFilename() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
-    CommentRange range = new CommentRange(1, 2, 3, 4);
-
-    Comment comment =
-        newComment(
-            psId,
-            "",
-            "uuid",
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            TimeUtil.nowTs(),
-            "message",
-            (short) 1,
-            revId.get(),
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
-  }
-
-  @Test
-  public void patchLineCommentNotesFormatSide1() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String uuid3 = "uuid3";
-    String message1 = "comment 1";
-    String message2 = "comment 2";
-    String message3 = "comment 3";
-    CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
-    Timestamp time2 = TimeUtil.nowTs();
-    Timestamp time3 = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment1 =
-        newComment(
-            psId,
-            "file1",
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            time1,
-            message1,
-            (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    Comment comment2 =
-        newComment(
-            psId,
-            "file1",
-            uuid2,
-            range2,
-            range2.getEndLine(),
-            otherUser,
-            null,
-            time2,
-            message2,
-            (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    CommentRange range3 = new CommentRange(3, 0, 4, 1);
-    Comment comment3 =
-        newComment(
-            psId,
-            "file2",
-            uuid3,
-            range3,
-            range3.getEndLine(),
-            otherUser,
-            null,
-            time3,
-            message3,
-            (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment3);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + ChangeNoteUtil.formatTime(serverIdent, time1)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "2:1-3:1\n"
-                    + ChangeNoteUtil.formatTime(serverIdent, time2)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n"
-                    + "File: file2\n"
-                    + "\n"
-                    + "3:0-4:1\n"
-                    + ChangeNoteUtil.formatTime(serverIdent, time3)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid3\n"
-                    + "Bytes: 9\n"
-                    + "comment 3\n"
-                    + "\n");
-      }
-    }
-  }
-
-  @Test
-  public void patchLineCommentNotesFormatSide0() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String message1 = "comment 1";
-    String message2 = "comment 2";
-    CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
-    Timestamp time2 = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment1 =
-        newComment(
-            psId,
-            "file1",
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            time1,
-            message1,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    Comment comment2 =
-        newComment(
-            psId,
-            "file1",
-            uuid2,
-            range2,
-            range2.getEndLine(),
-            otherUser,
-            null,
-            time2,
-            message2,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Base-for-patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + ChangeNoteUtil.formatTime(serverIdent, time1)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "2:1-3:1\n"
-                    + ChangeNoteUtil.formatTime(serverIdent, time2)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n");
-      }
-    }
-  }
-
-  @Test
-  public void patchLineCommentNotesResolvedChangesValue() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String message1 = "comment 1";
-    String message2 = "comment 2";
-    CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
-    Timestamp time2 = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment1 =
-        newComment(
-            psId,
-            "file1",
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            time1,
-            message1,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    Comment comment2 =
-        newComment(
-            psId,
-            "file1",
-            uuid2,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            uuid1,
-            time2,
-            message2,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            true);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Base-for-patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + ChangeNoteUtil.formatTime(serverIdent, time1)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + ChangeNoteUtil.formatTime(serverIdent, time2)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Parent: uuid1\n"
-                    + "Unresolved: true\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n");
-      }
-    }
-  }
-
-  @Test
-  public void patchLineCommentNotesFormatMultiplePatchSetsSameRevId() throws Exception {
-    Change c = newChange();
-    PatchSet.Id psId1 = c.currentPatchSetId();
-    incrementPatchSet(c);
-    PatchSet.Id psId2 = c.currentPatchSetId();
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String uuid3 = "uuid3";
-    String message1 = "comment 1";
-    String message2 = "comment 2";
-    String message3 = "comment 3";
-    CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    Timestamp time = TimeUtil.nowTs();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
-
-    Comment comment1 =
-        newComment(
-            psId1,
-            "file1",
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            time,
-            message1,
-            (short) 0,
-            revId.get(),
-            false);
-    Comment comment2 =
-        newComment(
-            psId1,
-            "file1",
-            uuid2,
-            range2,
-            range2.getEndLine(),
-            otherUser,
-            null,
-            time,
-            message2,
-            (short) 0,
-            revId.get(),
-            false);
-    Comment comment3 =
-        newComment(
-            psId2,
-            "file1",
-            uuid3,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            time,
-            message3,
-            (short) 0,
-            revId.get(),
-            false);
-
-    ChangeUpdate update = newUpdate(c, otherUser);
-    update.setPatchSetId(psId2);
-    update.putComment(Status.PUBLISHED, comment3);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-      String timeStr = ChangeNoteUtil.formatTime(serverIdent, time);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Base-for-patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "2:1-3:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n"
-                    + "Base-for-patch-set: 2\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid3\n"
-                    + "Bytes: 9\n"
-                    + "comment 3\n"
-                    + "\n");
-      }
-    }
-    assertThat(notes.getComments())
-        .isEqualTo(
-            ImmutableListMultimap.of(
-                revId, comment1,
-                revId, comment2,
-                revId, comment3));
-  }
-
-  @Test
-  public void patchLineCommentNotesFormatRealAuthor() throws Exception {
-    Change c = newChange();
-    CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
-    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
-    String uuid = "uuid";
-    String message = "comment";
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    Timestamp time = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
-
-    Comment comment =
-        newComment(
-            psId,
-            "file",
-            uuid,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            time,
-            message,
-            (short) 1,
-            revId.get(),
-            false);
-    comment.setRealAuthor(changeOwner.getAccountId());
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Patch-set: 1\n"
-                    + "File: file\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + ChangeNoteUtil.formatTime(serverIdent, time)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Real-author: Change Owner <1@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid\n"
-                    + "Bytes: 7\n"
-                    + "comment\n"
-                    + "\n");
-      }
-    }
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
-  }
-
-  @Test
-  public void patchLineCommentNotesFormatWeirdUser() throws Exception {
-    Account account = new Account(new Account.Id(3), TimeUtil.nowTs());
-    account.setFullName("Weird\n\u0002<User>\n");
-    account.setPreferredEmail(" we\r\nird@ex>ample<.com");
-    accountCache.put(account);
-    IdentifiedUser user = userFactory.create(account.getId());
-
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, user);
-    String uuid = "uuid";
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    Timestamp time = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment =
-        newComment(
-            psId,
-            "file1",
-            uuid,
-            range,
-            range.getEndLine(),
-            user,
-            null,
-            time,
-            "comment",
-            (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-      String timeStr = ChangeNoteUtil.formatTime(serverIdent, time);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Weird\u0002User <3@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid\n"
-                    + "Bytes: 7\n"
-                    + "comment\n"
-                    + "\n");
-      }
-    }
-    assertThat(notes.getComments())
-        .isEqualTo(ImmutableListMultimap.of(new RevId(comment.revId), comment));
-  }
-
-  @Test
-  public void patchLineCommentMultipleOnePatchsetOneFileBothSides() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
-    String messageForBase = "comment for base";
-    String messageForPS = "comment for ps";
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    Timestamp now = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment commentForBase =
-        newComment(
-            psId,
-            "filename",
-            uuid1,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            messageForBase,
-            (short) 0,
-            rev1,
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, commentForBase);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    Comment commentForPS =
-        newComment(
-            psId,
-            "filename",
-            uuid2,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            messageForPS,
-            (short) 1,
-            rev2,
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, commentForPS);
-    update.commit();
-
-    assertThat(newNotes(c).getComments())
-        .containsExactlyEntriesIn(
-            ImmutableListMultimap.of(
-                new RevId(rev1), commentForBase,
-                new RevId(rev2), commentForPS));
-  }
-
-  @Test
-  public void patchLineCommentMultipleOnePatchsetOneFile() throws Exception {
-    Change c = newChange();
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    PatchSet.Id psId = c.currentPatchSetId();
-    String filename = "filename";
-    short side = (short) 1;
-
-    ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp timeForComment1 = TimeUtil.nowTs();
-    Timestamp timeForComment2 = TimeUtil.nowTs();
-    Comment comment1 =
-        newComment(
-            psId,
-            filename,
-            uuid1,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            timeForComment1,
-            "comment 1",
-            side,
-            rev,
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    Comment comment2 =
-        newComment(
-            psId,
-            filename,
-            uuid2,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            timeForComment2,
-            "comment 2",
-            side,
-            rev,
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    assertThat(newNotes(c).getComments())
-        .containsExactlyEntriesIn(
-            ImmutableListMultimap.of(
-                new RevId(rev), comment1,
-                new RevId(rev), comment2))
-        .inOrder();
-  }
-
-  @Test
-  public void patchLineCommentMultipleOnePatchsetMultipleFiles() throws Exception {
-    Change c = newChange();
-    String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    PatchSet.Id psId = c.currentPatchSetId();
-    String filename1 = "filename1";
-    String filename2 = "filename2";
-    short side = (short) 1;
-
-    ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
-        newComment(
-            psId,
-            filename1,
-            uuid,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment 1",
-            side,
-            rev,
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    Comment comment2 =
-        newComment(
-            psId,
-            filename2,
-            uuid,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment 2",
-            side,
-            rev,
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    assertThat(newNotes(c).getComments())
-        .containsExactlyEntriesIn(
-            ImmutableListMultimap.of(
-                new RevId(rev), comment1,
-                new RevId(rev), comment2))
-        .inOrder();
-  }
-
-  @Test
-  public void patchLineCommentMultiplePatchsets() throws Exception {
-    Change c = newChange();
-    String uuid = "uuid";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    PatchSet.Id ps1 = c.currentPatchSetId();
-    String filename = "filename1";
-    short side = (short) 1;
-
-    ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
-        newComment(
-            ps1,
-            filename,
-            uuid,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment on ps1",
-            side,
-            rev1,
-            false);
-    update.setPatchSetId(ps1);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    incrementPatchSet(c);
-    PatchSet.Id ps2 = c.currentPatchSetId();
-
-    update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
-    Comment comment2 =
-        newComment(
-            ps2,
-            filename,
-            uuid,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment on ps2",
-            side,
-            rev2,
-            false);
-    update.setPatchSetId(ps2);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    assertThat(newNotes(c).getComments())
-        .containsExactlyEntriesIn(
-            ImmutableListMultimap.of(
-                new RevId(rev1), comment1,
-                new RevId(rev2), comment2));
-  }
-
-  @Test
-  public void patchLineCommentSingleDraftToPublished() throws Exception {
-    Change c = newChange();
-    String uuid = "uuid";
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    PatchSet.Id ps1 = c.currentPatchSetId();
-    String filename = "filename1";
-    short side = (short) 1;
-
-    ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
-        newComment(
-            ps1,
-            filename,
-            uuid,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment on ps1",
-            side,
-            rev,
-            false);
-    update.setPatchSetId(ps1);
-    update.putComment(Status.DRAFT, comment1);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId))
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
-    assertThat(notes.getComments()).isEmpty();
-
-    update = newUpdate(c, otherUser);
-    update.setPatchSetId(ps1);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
-  }
-
-  @Test
-  public void patchLineCommentMultipleDraftsSameSidePublishOne() throws Exception {
-    Change c = newChange();
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
-    CommentRange range1 = new CommentRange(1, 1, 2, 2);
-    CommentRange range2 = new CommentRange(2, 2, 3, 3);
-    String filename = "filename1";
-    short side = (short) 1;
-    Timestamp now = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    // Write two drafts on the same side of one patch set.
-    ChangeUpdate update = newUpdate(c, otherUser);
-    update.setPatchSetId(psId);
-    Comment comment1 =
-        newComment(
-            psId,
-            filename,
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment on ps1",
-            side,
-            rev,
-            false);
-    Comment comment2 =
-        newComment(
-            psId,
-            filename,
-            uuid2,
-            range2,
-            range2.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "other on ps1",
-            side,
-            rev,
-            false);
-    update.putComment(Status.DRAFT, comment1);
-    update.putComment(Status.DRAFT, comment2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId))
-        .containsExactlyEntriesIn(
-            ImmutableListMultimap.of(
-                new RevId(rev), comment1,
-                new RevId(rev), comment2))
-        .inOrder();
-    assertThat(notes.getComments()).isEmpty();
-
-    // Publish first draft.
-    update = newUpdate(c, otherUser);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId))
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment2));
-    assertThat(notes.getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
-  }
-
-  @Test
-  public void patchLineCommentsMultipleDraftsBothSidesPublishAll() throws Exception {
-    Change c = newChange();
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
-    CommentRange range1 = new CommentRange(1, 1, 2, 2);
-    CommentRange range2 = new CommentRange(2, 2, 3, 3);
-    String filename = "filename1";
-    Timestamp now = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    // Write two drafts, one on each side of the patchset.
-    ChangeUpdate update = newUpdate(c, otherUser);
-    update.setPatchSetId(psId);
-    Comment baseComment =
-        newComment(
-            psId,
-            filename,
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment on base",
-            (short) 0,
-            rev1,
-            false);
-    Comment psComment =
-        newComment(
-            psId,
-            filename,
-            uuid2,
-            range2,
-            range2.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment on ps",
-            (short) 1,
-            rev2,
-            false);
-
-    update.putComment(Status.DRAFT, baseComment);
-    update.putComment(Status.DRAFT, psComment);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId))
-        .containsExactlyEntriesIn(
-            ImmutableListMultimap.of(
-                new RevId(rev1), baseComment,
-                new RevId(rev2), psComment));
-    assertThat(notes.getComments()).isEmpty();
-
-    // Publish both comments.
-    update = newUpdate(c, otherUser);
-    update.setPatchSetId(psId);
-
-    update.putComment(Status.PUBLISHED, baseComment);
-    update.putComment(Status.PUBLISHED, psComment);
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments())
-        .containsExactlyEntriesIn(
-            ImmutableListMultimap.of(
-                new RevId(rev1), baseComment,
-                new RevId(rev2), psComment));
-  }
-
-  @Test
-  public void patchLineCommentsDeleteAllDrafts() throws Exception {
-    Change c = newChange();
-    String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    ObjectId objId = ObjectId.fromString(rev);
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    PatchSet.Id psId = c.currentPatchSetId();
-    String filename = "filename";
-    short side = (short) 1;
-
-    ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
-    Comment comment =
-        newComment(
-            psId,
-            filename,
-            uuid,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment on ps1",
-            side,
-            rev,
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.DRAFT, comment);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).hasSize(1);
-    assertThat(notes.getDraftCommentNotes().getNoteMap().contains(objId)).isTrue();
-
-    update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
-    update.setPatchSetId(psId);
-    update.deleteComment(comment);
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getDraftCommentNotes().getNoteMap()).isNull();
-  }
-
-  @Test
-  public void patchLineCommentsDeleteAllDraftsForOneRevision() throws Exception {
-    Change c = newChange();
-    String uuid = "uuid";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
-    ObjectId objId1 = ObjectId.fromString(rev1);
-    ObjectId objId2 = ObjectId.fromString(rev2);
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    PatchSet.Id ps1 = c.currentPatchSetId();
-    String filename = "filename1";
-    short side = (short) 1;
-
-    ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
-        newComment(
-            ps1,
-            filename,
-            uuid,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment on ps1",
-            side,
-            rev1,
-            false);
-    update.setPatchSetId(ps1);
-    update.putComment(Status.DRAFT, comment1);
-    update.commit();
-
-    incrementPatchSet(c);
-    PatchSet.Id ps2 = c.currentPatchSetId();
-
-    update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
-    Comment comment2 =
-        newComment(
-            ps2,
-            filename,
-            uuid,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment on ps2",
-            side,
-            rev2,
-            false);
-    update.setPatchSetId(ps2);
-    update.putComment(Status.DRAFT, comment2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).hasSize(2);
-
-    update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
-    update.setPatchSetId(ps2);
-    update.deleteComment(comment2);
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).hasSize(1);
-    NoteMap noteMap = notes.getDraftCommentNotes().getNoteMap();
-    assertThat(noteMap.contains(objId1)).isTrue();
-    assertThat(noteMap.contains(objId2)).isFalse();
-  }
-
-  @Test
-  public void addingPublishedCommentDoesNotCreateNoOpCommitOnEmptyDraftRef() throws Exception {
-    Change c = newChange();
-    String uuid = "uuid";
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    PatchSet.Id ps1 = c.currentPatchSetId();
-    String filename = "filename1";
-    short side = (short) 1;
-
-    ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
-    Comment comment =
-        newComment(
-            ps1,
-            filename,
-            uuid,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment on ps1",
-            side,
-            rev,
-            false);
-    update.putComment(Status.PUBLISHED, comment);
-    update.commit();
-
-    assertThat(repo.exactRef(changeMetaRef(c.getId()))).isNotNull();
-    String draftRef = refsDraftComments(c.getId(), otherUser.getAccountId());
-    assertThat(exactRefAllUsers(draftRef)).isNull();
-  }
-
-  @Test
-  public void addingPublishedCommentDoesNotCreateNoOpCommitOnNonEmptyDraftRef() throws Exception {
-    Change c = newChange();
-    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    PatchSet.Id ps1 = c.currentPatchSetId();
-    String filename = "filename1";
-    short side = (short) 1;
-
-    ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
-    Comment draft =
-        newComment(
-            ps1,
-            filename,
-            "uuid1",
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "draft comment on ps1",
-            side,
-            rev,
-            false);
-    update.putComment(Status.DRAFT, draft);
-    update.commit();
-
-    String draftRef = refsDraftComments(c.getId(), otherUser.getAccountId());
-    ObjectId old = exactRefAllUsers(draftRef);
-    assertThat(old).isNotNull();
-
-    update = newUpdate(c, otherUser);
-    Comment pub =
-        newComment(
-            ps1,
-            filename,
-            "uuid2",
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment on ps1",
-            side,
-            rev,
-            false);
-    update.putComment(Status.PUBLISHED, pub);
-    update.commit();
-
-    assertThat(exactRefAllUsers(draftRef)).isEqualTo(old);
-  }
-
-  @Test
-  public void fileComment() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String messageForBase = "comment for base";
-    Timestamp now = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment =
-        newComment(
-            psId,
-            "filename",
-            uuid,
-            null,
-            0,
-            otherUser,
-            null,
-            now,
-            messageForBase,
-            (short) 0,
-            rev,
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
-    update.commit();
-
-    assertThat(newNotes(c).getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment));
-  }
-
-  @Test
-  public void patchLineCommentNoRange() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid = "uuid";
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String messageForBase = "comment for base";
-    Timestamp now = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment =
-        newComment(
-            psId,
-            "filename",
-            uuid,
-            null,
-            1,
-            otherUser,
-            null,
-            now,
-            messageForBase,
-            (short) 0,
-            rev,
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment);
-    update.commit();
-
-    assertThat(newNotes(c).getComments())
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment));
-  }
-
-  @Test
-  public void putCommentsForMultipleRevisions() throws Exception {
-    Change c = newChange();
-    String uuid = "uuid";
-    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    PatchSet.Id ps1 = c.currentPatchSetId();
-    String filename = "filename1";
-    short side = (short) 1;
-
-    incrementPatchSet(c);
-    PatchSet.Id ps2 = c.currentPatchSetId();
-
-    ChangeUpdate update = newUpdate(c, otherUser);
-    update.setPatchSetId(ps2);
-    Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
-        newComment(
-            ps1,
-            filename,
-            uuid,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment on ps1",
-            side,
-            rev1,
-            false);
-    Comment comment2 =
-        newComment(
-            ps2,
-            filename,
-            uuid,
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment on ps2",
-            side,
-            rev2,
-            false);
-    update.putComment(Status.DRAFT, comment1);
-    update.putComment(Status.DRAFT, comment2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).hasSize(2);
-    assertThat(notes.getComments()).isEmpty();
-
-    update = newUpdate(c, otherUser);
-    update.setPatchSetId(ps2);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments()).hasSize(2);
-  }
-
-  @Test
-  public void publishSubsetOfCommentsOnRevision() throws Exception {
-    Change c = newChange();
-    RevId rev1 = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    PatchSet.Id ps1 = c.currentPatchSetId();
-    short side = (short) 1;
-
-    ChangeUpdate update = newUpdate(c, otherUser);
-    update.setPatchSetId(ps1);
-    Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
-        newComment(
-            ps1,
-            "file1",
-            "uuid1",
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment1",
-            side,
-            rev1.get(),
-            false);
-    Comment comment2 =
-        newComment(
-            ps1,
-            "file2",
-            "uuid2",
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment2",
-            side,
-            rev1.get(),
-            false);
-    update.putComment(Status.DRAFT, comment1);
-    update.putComment(Status.DRAFT, comment2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1, comment2);
-    assertThat(notes.getComments()).isEmpty();
-
-    update = newUpdate(c, otherUser);
-    update.setPatchSetId(ps1);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1);
-    assertThat(notes.getComments().get(rev1)).containsExactly(comment2);
-  }
-
-  @Test
-  public void updateWithServerIdent() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, internalUser);
-    update.setChangeMessage("A message.");
-    update.commit();
-
-    ChangeMessage msg = Iterables.getLast(newNotes(c).getChangeMessages());
-    assertThat(msg.getMessage()).isEqualTo("A message.");
-    assertThat(msg.getAuthor()).isNull();
-
-    update = newUpdate(c, internalUser);
-    exception.expect(IllegalStateException.class);
-    update.putApproval("Code-Review", (short) 1);
-  }
-
-  @Test
-  public void filterOutAndFixUpZombieDraftComments() throws Exception {
-    Change c = newChange();
-    RevId rev1 = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    PatchSet.Id ps1 = c.currentPatchSetId();
-    short side = (short) 1;
-
-    ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
-        newComment(
-            ps1,
-            "file1",
-            "uuid1",
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "comment on ps1",
-            side,
-            rev1.get(),
-            false);
-    Comment comment2 =
-        newComment(
-            ps1,
-            "file2",
-            "uuid2",
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            now,
-            "another comment",
-            side,
-            rev1.get(),
-            false);
-    update.putComment(Status.DRAFT, comment1);
-    update.putComment(Status.DRAFT, comment2);
-    update.commit();
-
-    String refName = refsDraftComments(c.getId(), otherUserId);
-    ObjectId oldDraftId = exactRefAllUsers(refName);
-
-    update = newUpdate(c, otherUser);
-    update.setPatchSetId(ps1);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-    assertThat(exactRefAllUsers(refName)).isNotNull();
-    assertThat(exactRefAllUsers(refName)).isNotEqualTo(oldDraftId);
-
-    // Re-add draft version of comment2 back to draft ref without updating
-    // change ref. Simulates the case where deleting the draft failed
-    // non-atomically after adding the published comment succeeded.
-    ChangeDraftUpdate draftUpdate = newUpdate(c, otherUser).createDraftUpdateIfNull();
-    draftUpdate.putComment(comment2);
-    try (NoteDbUpdateManager manager = updateManagerFactory.create(c.getProject())) {
-      manager.add(draftUpdate);
-      manager.execute();
-    }
-
-    // Looking at drafts directly shows the zombie comment.
-    DraftCommentNotes draftNotes = draftNotesFactory.create(c, otherUserId);
-    assertThat(draftNotes.load().getComments().get(rev1)).containsExactly(comment1, comment2);
-
-    // Zombie comment is filtered out of drafts via ChangeNotes.
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1);
-    assertThat(notes.getComments().get(rev1)).containsExactly(comment2);
-
-    update = newUpdate(c, otherUser);
-    update.setPatchSetId(ps1);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    // Updating an unrelated comment causes the zombie comment to get fixed up.
-    assertThat(exactRefAllUsers(refName)).isNull();
-  }
-
-  @Test
-  public void updateCommentsInSequentialUpdates() throws Exception {
-    Change c = newChange();
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
-
-    ChangeUpdate update1 = newUpdate(c, otherUser);
-    Comment comment1 =
-        newComment(
-            c.currentPatchSetId(),
-            "filename",
-            "uuid1",
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            new Timestamp(update1.getWhen().getTime()),
-            "comment 1",
-            (short) 1,
-            rev,
-            false);
-    update1.putComment(Status.PUBLISHED, comment1);
-
-    ChangeUpdate update2 = newUpdate(c, otherUser);
-    Comment comment2 =
-        newComment(
-            c.currentPatchSetId(),
-            "filename",
-            "uuid2",
-            range,
-            range.getEndLine(),
-            otherUser,
-            null,
-            new Timestamp(update2.getWhen().getTime()),
-            "comment 2",
-            (short) 1,
-            rev,
-            false);
-    update2.putComment(Status.PUBLISHED, comment2);
-
-    try (NoteDbUpdateManager manager = updateManagerFactory.create(project)) {
-      manager.add(update1);
-      manager.add(update2);
-      manager.execute();
-    }
-
-    ChangeNotes notes = newNotes(c);
-    List<Comment> comments = notes.getComments().get(new RevId(rev));
-    assertThat(comments).hasSize(2);
-    assertThat(comments.get(0).message).isEqualTo("comment 1");
-    assertThat(comments.get(1).message).isEqualTo("comment 2");
-  }
-
-  @Test
-  public void realUser() throws Exception {
-    Change c = newChange();
-    CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
-    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
-    update.setChangeMessage("Message on behalf of other user");
-    update.commit();
-
-    ChangeMessage msg = Iterables.getLast(newNotes(c).getChangeMessages());
-    assertThat(msg.getMessage()).isEqualTo("Message on behalf of other user");
-    assertThat(msg.getAuthor()).isEqualTo(otherUserId);
-    assertThat(msg.getRealAuthor()).isEqualTo(changeOwner.getAccountId());
-  }
-
-  @Test
-  public void ignoreEntitiesBeyondCurrentPatchSet() throws Exception {
-    Change c = newChange();
-    ChangeNotes notes = newNotes(c);
-    int numMessages = notes.getChangeMessages().size();
-    int numPatchSets = notes.getPatchSets().size();
-    int numApprovals = notes.getApprovals().size();
-    int numComments = notes.getComments().size();
-
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPatchSetId(new PatchSet.Id(c.getId(), c.currentPatchSetId().get() + 1));
-    update.setChangeMessage("Should be ignored");
-    update.putApproval("Code-Review", (short) 2);
-    CommentRange range = new CommentRange(1, 1, 2, 1);
-    Comment comment =
-        newComment(
-            update.getPatchSetId(),
-            "filename",
-            "uuid",
-            range,
-            range.getEndLine(),
-            changeOwner,
-            null,
-            new Timestamp(update.getWhen().getTime()),
-            "comment",
-            (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.putComment(Status.PUBLISHED, comment);
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getChangeMessages()).hasSize(numMessages);
-    assertThat(notes.getPatchSets()).hasSize(numPatchSets);
-    assertThat(notes.getApprovals()).hasSize(numApprovals);
-    assertThat(notes.getComments()).hasSize(numComments);
-  }
-
-  @Test
-  public void currentPatchSet() throws Exception {
-    Change c = newChange();
-    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(1);
-
-    incrementPatchSet(c);
-    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
-
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPatchSetId(new PatchSet.Id(c.getId(), 1));
-    update.setCurrentPatchSet();
-    update.commit();
-    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(1);
-
-    incrementPatchSet(c);
-    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(3);
-
-    // Delete PS3, PS1 becomes current, as the most recent event explicitly set
-    // it to current.
-    update = newUpdate(c, changeOwner);
-    update.setPatchSetState(PatchSetState.DELETED);
-    update.commit();
-    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(1);
-
-    // Delete PS1, PS2 becomes current.
-    update = newUpdate(c, changeOwner);
-    update.setPatchSetId(new PatchSet.Id(c.getId(), 1));
-    update.setPatchSetState(PatchSetState.DELETED);
-    update.commit();
-    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
-  }
-
-  @Test
-  public void readOnlyUntilExpires() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    Timestamp until = new Timestamp(TimeUtil.nowMs() + 10000);
-    update.setReadOnlyUntil(until);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.setTopic("failing-topic");
-    try {
-      update.commit();
-      assert_().fail("expected OrmException");
-    } catch (OrmException e) {
-      assertThat(e.getMessage()).contains("read-only until");
-    }
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getChange().getTopic()).isNotEqualTo("failing-topic");
-    assertThat(notes.getReadOnlyUntil()).isEqualTo(until);
-
-    TestTimeUtil.incrementClock(30, TimeUnit.SECONDS);
-    update = newUpdate(c, changeOwner);
-    update.setTopic("succeeding-topic");
-    update.commit();
-
-    // Write succeeded; lease still exists, even though it's expired.
-    notes = newNotes(c);
-    assertThat(notes.getChange().getTopic()).isEqualTo("succeeding-topic");
-    assertThat(notes.getReadOnlyUntil()).isEqualTo(until);
-
-    // New lease takes precedence.
-    update = newUpdate(c, changeOwner);
-    until = new Timestamp(TimeUtil.nowMs() + 10000);
-    update.setReadOnlyUntil(until);
-    update.commit();
-    assertThat(newNotes(c).getReadOnlyUntil()).isEqualTo(until);
-  }
-
-  @Test
-  public void readOnlyUntilCleared() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    Timestamp until = new Timestamp(TimeUtil.nowMs() + TimeUnit.DAYS.toMillis(30));
-    update.setReadOnlyUntil(until);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.setTopic("failing-topic");
-    try {
-      update.commit();
-      assert_().fail("expected OrmException");
-    } catch (OrmException e) {
-      assertThat(e.getMessage()).contains("read-only until");
-    }
-
-    // Sentinel timestamp of 0 can be written to clear lease.
-    update = newUpdate(c, changeOwner);
-    update.setReadOnlyUntil(new Timestamp(0));
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.setTopic("succeeding-topic");
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getChange().getTopic()).isEqualTo("succeeding-topic");
-    assertThat(notes.getReadOnlyUntil()).isEqualTo(new Timestamp(0));
-  }
-
-  @Test
-  public void privateDefault() throws Exception {
-    Change c = newChange();
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.isPrivate()).isFalse();
-  }
-
-  @Test
-  public void privateSetPrivate() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPrivate(true);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.isPrivate()).isTrue();
-  }
-
-  @Test
-  public void privateSetPrivateMultipleTimes() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setPrivate(true);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.setPrivate(false);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.isPrivate()).isFalse();
-  }
-
-  @Test
-  public void defaultReviewersByEmailIsEmpty() throws Exception {
-    Change c = newChange();
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getReviewersByEmail().all()).isEmpty();
-  }
-
-  @Test
-  public void putReviewerByEmail() throws Exception {
-    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
-
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
-  }
-
-  @Test
-  public void putAndRemoveReviewerByEmail() throws Exception {
-    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
-
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.removeReviewerByEmail(adr);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getReviewersByEmail().all()).isEmpty();
-  }
-
-  @Test
-  public void putRemoveAndAddBackReviewerByEmail() throws Exception {
-    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
-
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.removeReviewerByEmail(adr);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.putReviewerByEmail(adr, ReviewerStateInternal.CC);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
-  }
-
-  @Test
-  public void putReviewerByEmailAndCcByEmail() throws Exception {
-    Address adrReviewer = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
-    Address adrCc = new Address("Foo Bor", "foo.bar.2@gerritcodereview.com");
-
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewerByEmail(adrReviewer, ReviewerStateInternal.REVIEWER);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.putReviewerByEmail(adrCc, ReviewerStateInternal.CC);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.REVIEWER))
-        .containsExactly(adrReviewer);
-    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.CC))
-        .containsExactly(adrCc);
-    assertThat(notes.getReviewersByEmail().all()).containsExactly(adrReviewer, adrCc);
-  }
-
-  @Test
-  public void putReviewerByEmailAndChangeToCc() throws Exception {
-    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
-
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.putReviewerByEmail(adr, ReviewerStateInternal.CC);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.REVIEWER)).isEmpty();
-    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.CC)).containsExactly(adr);
-    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
-  }
-
-  @Test
-  public void hasReviewStarted() throws Exception {
-    ChangeNotes notes = newNotes(newChange());
-    assertThat(notes.hasReviewStarted()).isTrue();
-
-    notes = newNotes(newWorkInProgressChange());
-    assertThat(notes.hasReviewStarted()).isFalse();
-
-    Change c = newWorkInProgressChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.hasReviewStarted()).isFalse();
-
-    update = newUpdate(c, changeOwner);
-    update.setWorkInProgress(true);
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.hasReviewStarted()).isFalse();
-
-    update = newUpdate(c, changeOwner);
-    update.setWorkInProgress(false);
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.hasReviewStarted()).isTrue();
-
-    // Once review is started, setting WIP should have no impact.
-    c = newChange();
-    notes = newNotes(c);
-    assertThat(notes.hasReviewStarted()).isTrue();
-    update = newUpdate(c, changeOwner);
-    update.setWorkInProgress(true);
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.hasReviewStarted()).isTrue();
-  }
-
-  @Test
-  public void pendingReviewers() throws Exception {
-    Address adr1 = new Address("Foo Bar1", "foo.bar1@gerritcodereview.com");
-    Address adr2 = new Address("Foo Bar2", "foo.bar2@gerritcodereview.com");
-    Account.Id ownerId = changeOwner.getAccount().getId();
-    Account.Id otherUserId = otherUser.getAccount().getId();
-
-    ChangeNotes notes = newNotes(newChange());
-    assertThat(notes.getPendingReviewers().asTable()).isEmpty();
-    assertThat(notes.getPendingReviewersByEmail().asTable()).isEmpty();
-
-    Change c = newWorkInProgressChange();
-    notes = newNotes(c);
-    assertThat(notes.getPendingReviewers().asTable()).isEmpty();
-    assertThat(notes.getPendingReviewersByEmail().asTable()).isEmpty();
-
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(ownerId, REVIEWER);
-    update.putReviewer(otherUserId, CC);
-    update.putReviewerByEmail(adr1, REVIEWER);
-    update.putReviewerByEmail(adr2, CC);
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.getPendingReviewers().byState(REVIEWER)).containsExactly(ownerId);
-    assertThat(notes.getPendingReviewers().byState(CC)).containsExactly(otherUserId);
-    assertThat(notes.getPendingReviewers().byState(REMOVED)).isEmpty();
-    assertThat(notes.getPendingReviewersByEmail().byState(REVIEWER)).containsExactly(adr1);
-    assertThat(notes.getPendingReviewersByEmail().byState(CC)).containsExactly(adr2);
-    assertThat(notes.getPendingReviewersByEmail().byState(REMOVED)).isEmpty();
-
-    update = newUpdate(c, changeOwner);
-    update.removeReviewer(ownerId);
-    update.removeReviewerByEmail(adr1);
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.getPendingReviewers().byState(REVIEWER)).isEmpty();
-    assertThat(notes.getPendingReviewers().byState(CC)).containsExactly(otherUserId);
-    assertThat(notes.getPendingReviewers().byState(REMOVED)).containsExactly(ownerId);
-    assertThat(notes.getPendingReviewersByEmail().byState(REVIEWER)).isEmpty();
-    assertThat(notes.getPendingReviewersByEmail().byState(CC)).containsExactly(adr2);
-    assertThat(notes.getPendingReviewersByEmail().byState(REMOVED)).containsExactly(adr1);
-
-    update = newUpdate(c, changeOwner);
-    update.setWorkInProgress(false);
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.getPendingReviewers().asTable()).isEmpty();
-    assertThat(notes.getPendingReviewersByEmail().asTable()).isEmpty();
-
-    update = newUpdate(c, changeOwner);
-    update.putReviewer(ownerId, REVIEWER);
-    update.putReviewerByEmail(adr1, REVIEWER);
-    update.commit();
-    notes = newNotes(c);
-    assertThat(notes.getPendingReviewers().asTable()).isEmpty();
-    assertThat(notes.getPendingReviewersByEmail().asTable()).isEmpty();
-  }
-
-  @Test
-  public void revertOfIsNullByDefault() throws Exception {
-    Change c = newChange();
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getRevertOf()).isNull();
-  }
-
-  @Test
-  public void setRevertOfPersistsValue() throws Exception {
-    Change changeToRevert = newChange();
-    Change c = TestChanges.newChange(project, changeOwner.getAccountId());
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeId(c.getKey().get());
-    update.setRevertOf(changeToRevert.getId().get());
-    update.commit();
-    assertThat(newNotes(c).getRevertOf()).isEqualTo(changeToRevert.getId());
-  }
-
-  @Test
-  public void setRevertOfToCurrentChangeFails() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("A change cannot revert itself");
-    update.setRevertOf(c.getId().get());
-  }
-
-  @Test
-  public void setRevertOfOnChildCommitFails() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    exception.expect(OrmException.class);
-    exception.expectMessage("Given ChangeUpdate is only allowed on initial commit");
-    update.setRevertOf(newChange().getId().get());
-    update.commit();
-  }
-
-  private boolean testJson() {
-    return noteUtil.getWriteJson();
-  }
-
-  private String readNote(ChangeNotes notes, ObjectId noteId) throws Exception {
-    ObjectId dataId = notes.revisionNoteMap.noteMap.getNote(noteId).getData();
-    return new String(rw.getObjectReader().open(dataId, OBJ_BLOB).getCachedBytes(), UTF_8);
-  }
-
-  private ObjectId exactRefAllUsers(String refName) throws Exception {
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
-      Ref ref = allUsersRepo.exactRef(refName);
-      return ref != null ? ref.getObjectId() : null;
-    }
-  }
-
-  private void assertCause(
-      Throwable e, Class<? extends Throwable> expectedClass, String expectedMsg) {
-    Throwable cause = null;
-    for (Throwable t : Throwables.getCausalChain(e)) {
-      if (expectedClass.isAssignableFrom(t.getClass())) {
-        cause = t;
-        break;
-      }
-    }
-    assertThat(cause)
-        .named(
-            expectedClass.getSimpleName()
-                + " in causal chain of:\n"
-                + Throwables.getStackTraceAsString(e))
-        .isNotNull();
-    assertThat(cause.getMessage()).isEqualTo(expectedMsg);
-  }
-
-  private void incrementCurrentPatchSetFieldOnly(Change c) {
-    TestChanges.incrementPatchSet(c);
-  }
-
-  private RevCommit incrementPatchSet(Change c) throws Exception {
-    return incrementPatchSet(c, userFactory.create(c.getOwner()));
-  }
-
-  private RevCommit incrementPatchSet(Change c, IdentifiedUser user) throws Exception {
-    incrementCurrentPatchSetFieldOnly(c);
-    RevCommit commit = tr.commit().message("PS" + c.currentPatchSetId().get()).create();
-    ChangeUpdate update = newUpdate(c, user);
-    update.setCommit(rw, commit);
-    update.commit();
-    return tr.parseBody(commit);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
deleted file mode 100644
index 83dcf61..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ /dev/null
@@ -1,427 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.util.RequestId;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.TestChanges;
-import java.util.Date;
-import java.util.TimeZone;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-@RunWith(ConfigSuite.class)
-public class CommitMessageOutputTest extends AbstractChangeNotesTest {
-  @Test
-  public void approvalsCommitFormatSimple() throws Exception {
-    Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) 1);
-    update.putApproval("Code-Review", (short) -1);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.putReviewer(otherUser.getAccount().getId(), CC);
-    update.commit();
-    assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
-
-    RevCommit commit = parseCommit(update.getResult());
-    assertBodyEquals(
-        "Update patch set 1\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Change-id: "
-            + c.getKey().get()
-            + "\n"
-            + "Subject: Change subject\n"
-            + "Branch: refs/heads/master\n"
-            + "Commit: "
-            + update.getCommit().name()
-            + "\n"
-            + "Reviewer: Change Owner <1@gerrit>\n"
-            + "CC: Other Account <2@gerrit>\n"
-            + "Label: Code-Review=-1\n"
-            + "Label: Verified=+1\n",
-        commit);
-
-    PersonIdent author = commit.getAuthorIdent();
-    assertThat(author.getName()).isEqualTo("Change Owner");
-    assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 1000));
-    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
-
-    PersonIdent committer = commit.getCommitterIdent();
-    assertThat(committer.getName()).isEqualTo("Gerrit Server");
-    assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
-    assertThat(committer.getWhen()).isEqualTo(author.getWhen());
-    assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
-  }
-
-  @Test
-  public void changeMessageCommitFormatSimple() throws Exception {
-    Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeMessage("Just a little code change.\nHow about a new line");
-    update.commit();
-    assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
-
-    assertBodyEquals(
-        "Update patch set 1\n"
-            + "\n"
-            + "Just a little code change.\n"
-            + "How about a new line\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Change-id: "
-            + c.getKey().get()
-            + "\n"
-            + "Subject: Change subject\n"
-            + "Branch: refs/heads/master\n"
-            + "Commit: "
-            + update.getCommit().name()
-            + "\n",
-        update.getResult());
-  }
-
-  @Test
-  public void changeWithRevision() throws Exception {
-    Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeMessage("Foo");
-    RevCommit commit = tr.commit().message("Subject").create();
-    update.setCommit(rw, commit);
-    update.commit();
-    assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
-
-    assertBodyEquals(
-        "Update patch set 1\n"
-            + "\n"
-            + "Foo\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Change-id: "
-            + c.getKey().get()
-            + "\n"
-            + "Subject: Subject\n"
-            + "Branch: refs/heads/master\n"
-            + "Commit: "
-            + commit.name()
-            + "\n",
-        update.getResult());
-  }
-
-  @Test
-  public void approvalTombstoneCommitFormat() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.removeApproval("Code-Review");
-    update.commit();
-
-    assertBodyEquals(
-        "Update patch set 1\n\nPatch-set: 1\nLabel: -Code-Review\n", update.getResult());
-  }
-
-  @Test
-  public void submitCommitFormat() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setSubjectForCommit("Submit patch set 1");
-
-    RequestId submissionId = RequestId.forChange(c);
-    update.merge(
-        submissionId,
-        ImmutableList.of(
-            submitRecord(
-                "NOT_READY",
-                null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null)),
-            submitRecord(
-                "NOT_READY",
-                null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Alternative-Code-Review", "NEED", null))));
-    update.commit();
-
-    RevCommit commit = parseCommit(update.getResult());
-    assertBodyEquals(
-        "Submit patch set 1\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Status: merged\n"
-            + "Submission-id: "
-            + submissionId.toStringForStorage()
-            + "\n"
-            + "Submitted-with: NOT_READY\n"
-            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
-            + "Submitted-with: NEED: Code-Review\n"
-            + "Submitted-with: NOT_READY\n"
-            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
-            + "Submitted-with: NEED: Alternative-Code-Review\n",
-        commit);
-
-    PersonIdent author = commit.getAuthorIdent();
-    assertThat(author.getName()).isEqualTo("Change Owner");
-    assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 2000));
-    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
-
-    PersonIdent committer = commit.getCommitterIdent();
-    assertThat(committer.getName()).isEqualTo("Gerrit Server");
-    assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
-    assertThat(committer.getWhen()).isEqualTo(author.getWhen());
-    assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
-  }
-
-  @Test
-  public void anonymousUser() throws Exception {
-    Account anon = new Account(new Account.Id(3), TimeUtil.nowTs());
-    accountCache.put(anon);
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, userFactory.create(anon.getId()));
-    update.setChangeMessage("Comment on the change.");
-    update.commit();
-
-    RevCommit commit = parseCommit(update.getResult());
-    assertBodyEquals("Update patch set 1\n\nComment on the change.\n\nPatch-set: 1\n", commit);
-
-    PersonIdent author = commit.getAuthorIdent();
-    assertThat(author.getName()).isEqualTo("Anonymous Coward (3)");
-    assertThat(author.getEmailAddress()).isEqualTo("3@gerrit");
-  }
-
-  @Test
-  public void submitWithErrorMessage() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setSubjectForCommit("Submit patch set 1");
-
-    RequestId submissionId = RequestId.forChange(c);
-    update.merge(
-        submissionId, ImmutableList.of(submitRecord("RULE_ERROR", "Problem with patch set:\n1")));
-    update.commit();
-
-    assertBodyEquals(
-        "Submit patch set 1\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Status: merged\n"
-            + "Submission-id: "
-            + submissionId.toStringForStorage()
-            + "\n"
-            + "Submitted-with: RULE_ERROR Problem with patch set: 1\n",
-        update.getResult());
-  }
-
-  @Test
-  public void noChangeMessage() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
-    update.commit();
-
-    assertBodyEquals(
-        "Update patch set 1\n\nPatch-set: 1\nReviewer: Change Owner <1@gerrit>\n",
-        update.getResult());
-  }
-
-  @Test
-  public void changeMessageWithTrailingDoubleNewline() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeMessage("Testing trailing double newline\n\n");
-    update.commit();
-
-    assertBodyEquals(
-        "Update patch set 1\n"
-            + "\n"
-            + "Testing trailing double newline\n"
-            + "\n"
-            + "\n"
-            + "\n"
-            + "Patch-set: 1\n",
-        update.getResult());
-  }
-
-  @Test
-  public void changeMessageWithMultipleParagraphs() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeMessage("Testing paragraph 1\n\nTesting paragraph 2\n\nTesting paragraph 3");
-    update.commit();
-
-    assertBodyEquals(
-        "Update patch set 1\n"
-            + "\n"
-            + "Testing paragraph 1\n"
-            + "\n"
-            + "Testing paragraph 2\n"
-            + "\n"
-            + "Testing paragraph 3\n"
-            + "\n"
-            + "Patch-set: 1\n",
-        update.getResult());
-  }
-
-  @Test
-  public void changeMessageWithTag() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeMessage("Change message with tag");
-    update.setTag("jenkins");
-    update.commit();
-
-    assertBodyEquals(
-        "Update patch set 1\n"
-            + "\n"
-            + "Change message with tag\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Tag: jenkins\n",
-        update.getResult());
-  }
-
-  @Test
-  public void leadingWhitespace() throws Exception {
-    Change c = TestChanges.newChange(project, changeOwner.getAccountId());
-    c.setCurrentPatchSet(c.currentPatchSetId(), "  " + c.getSubject(), c.getOriginalSubject());
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
-    update.commit();
-
-    assertBodyEquals(
-        "Update patch set 1\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Change-id: "
-            + c.getKey().get()
-            + "\n"
-            + "Subject:   Change subject\n"
-            + "Branch: refs/heads/master\n"
-            + "Commit: "
-            + update.getCommit().name()
-            + "\n",
-        update.getResult());
-
-    c = TestChanges.newChange(project, changeOwner.getAccountId());
-    c.setCurrentPatchSet(c.currentPatchSetId(), "\t\t" + c.getSubject(), c.getOriginalSubject());
-    update = newUpdate(c, changeOwner);
-    update.setChangeId(c.getKey().get());
-    update.setBranch(c.getDest().get());
-    update.commit();
-
-    assertBodyEquals(
-        "Update patch set 1\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Change-id: "
-            + c.getKey().get()
-            + "\n"
-            + "Subject: \t\tChange subject\n"
-            + "Branch: refs/heads/master\n"
-            + "Commit: "
-            + update.getCommit().name()
-            + "\n",
-        update.getResult());
-  }
-
-  @Test
-  public void realUser() throws Exception {
-    Change c = newChange();
-    CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
-    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
-    update.setChangeMessage("Message on behalf of other user");
-    update.commit();
-
-    RevCommit commit = parseCommit(update.getResult());
-    PersonIdent author = commit.getAuthorIdent();
-    assertThat(author.getName()).isEqualTo("Other Account");
-    assertThat(author.getEmailAddress()).isEqualTo("2@gerrit");
-
-    assertBodyEquals(
-        "Update patch set 1\n"
-            + "\n"
-            + "Message on behalf of other user\n"
-            + "\n"
-            + "Patch-set: 1\n"
-            + "Real-user: Change Owner <1@gerrit>\n",
-        commit);
-  }
-
-  @Test
-  public void currentPatchSet() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setCurrentPatchSet();
-    update.commit();
-
-    assertBodyEquals("Update patch set 1\n\nPatch-set: 1\nCurrent: true\n", update.getResult());
-  }
-
-  @Test
-  public void reviewerByEmail() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewerByEmail(
-        new Address("John Doe", "j.doe@gerritcodereview.com"), ReviewerStateInternal.REVIEWER);
-    update.commit();
-
-    assertBodyEquals(
-        "Update patch set 1\n\nPatch-set: 1\n"
-            + "Reviewer-email: John Doe <j.doe@gerritcodereview.com>\n",
-        update.getResult());
-  }
-
-  @Test
-  public void ccByEmail() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewerByEmail(new Address("j.doe@gerritcodereview.com"), ReviewerStateInternal.CC);
-    update.commit();
-
-    assertBodyEquals(
-        "Update patch set 1\n\nPatch-set: 1\nCC-email: j.doe@gerritcodereview.com\n",
-        update.getResult());
-  }
-
-  private RevCommit parseCommit(ObjectId id) throws Exception {
-    if (id instanceof RevCommit) {
-      return (RevCommit) id;
-    }
-    try (RevWalk walk = new RevWalk(repo)) {
-      RevCommit commit = walk.parseCommit(id);
-      walk.parseBody(commit);
-      return commit;
-    }
-  }
-
-  private void assertBodyEquals(String expected, ObjectId commitId) throws Exception {
-    RevCommit commit = parseCommit(commitId);
-    assertThat(commit.getFullMessage()).isEqualTo(expected);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
deleted file mode 100644
index 67ad65c..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
+++ /dev/null
@@ -1,241 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.gerrit.common.TimeUtil.nowTs;
-import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.NOTE_DB;
-import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
-import static com.google.gerrit.server.notedb.NoteDbChangeState.applyDelta;
-import static com.google.gerrit.server.notedb.NoteDbChangeState.parse;
-import static org.eclipse.jgit.lib.ObjectId.zeroId;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.notedb.NoteDbChangeState.Delta;
-import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
-import com.google.gerrit.testutil.GerritBaseTests;
-import com.google.gerrit.testutil.TestChanges;
-import com.google.gerrit.testutil.TestTimeUtil;
-import java.sql.Timestamp;
-import java.util.Optional;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.ObjectId;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-/** Unit tests for {@link NoteDbChangeState}. */
-public class NoteDbChangeStateTest extends GerritBaseTests {
-  ObjectId SHA1 = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-  ObjectId SHA2 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
-  ObjectId SHA3 = ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee");
-
-  @Before
-  public void setUp() {
-    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
-  }
-
-  @After
-  public void tearDown() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @Test
-  public void parseReviewDbWithoutDrafts() {
-    NoteDbChangeState state = parse(new Change.Id(1), SHA1.name());
-    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
-    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
-    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
-    assertThat(state.getDraftIds()).isEmpty();
-    assertThat(state.getReadOnlyUntil()).isEmpty();
-    assertThat(state.toString()).isEqualTo(SHA1.name());
-
-    state = parse(new Change.Id(1), "R," + SHA1.name());
-    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
-    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
-    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
-    assertThat(state.getDraftIds()).isEmpty();
-    assertThat(state.getReadOnlyUntil()).isEmpty();
-    assertThat(state.toString()).isEqualTo(SHA1.name());
-  }
-
-  @Test
-  public void parseReviewDbWithDrafts() {
-    String str = SHA1.name() + ",2003=" + SHA2.name() + ",1001=" + SHA3.name();
-    String expected = SHA1.name() + ",1001=" + SHA3.name() + ",2003=" + SHA2.name();
-    NoteDbChangeState state = parse(new Change.Id(1), str);
-    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
-    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
-    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
-    assertThat(state.getDraftIds())
-        .containsExactly(
-            new Account.Id(1001), SHA3,
-            new Account.Id(2003), SHA2);
-    assertThat(state.getReadOnlyUntil()).isEmpty();
-    assertThat(state.toString()).isEqualTo(expected);
-
-    state = parse(new Change.Id(1), "R," + str);
-    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
-    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
-    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
-    assertThat(state.getDraftIds())
-        .containsExactly(
-            new Account.Id(1001), SHA3,
-            new Account.Id(2003), SHA2);
-    assertThat(state.getReadOnlyUntil()).isEmpty();
-    assertThat(state.toString()).isEqualTo(expected);
-  }
-
-  @Test
-  public void parseReadOnlyUntil() {
-    Timestamp ts = new Timestamp(12345);
-    String str = "R=12345," + SHA1.name();
-    NoteDbChangeState state = parse(new Change.Id(1), str);
-    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
-    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
-    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
-    assertThat(state.getReadOnlyUntil().get()).isEqualTo(ts);
-    assertThat(state.toString()).isEqualTo(str);
-
-    str = "N=12345";
-    state = parse(new Change.Id(1), str);
-    assertThat(state.getPrimaryStorage()).isEqualTo(NOTE_DB);
-    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
-    assertThat(state.getRefState()).isEmpty();
-    assertThat(state.getReadOnlyUntil().get()).isEqualTo(ts);
-    assertThat(state.toString()).isEqualTo(str);
-  }
-
-  @Test
-  public void applyDeltaToNullWithNoNewMetaId() throws Exception {
-    Change c = newChange();
-    assertThat(c.getNoteDbState()).isNull();
-    applyDelta(c, Delta.create(c.getId(), noMetaId(), noDrafts()));
-    assertThat(c.getNoteDbState()).isNull();
-
-    applyDelta(c, Delta.create(c.getId(), noMetaId(), drafts(new Account.Id(1001), zeroId())));
-    assertThat(c.getNoteDbState()).isNull();
-  }
-
-  @Test
-  public void applyDeltaToMetaId() throws Exception {
-    Change c = newChange();
-    applyDelta(c, Delta.create(c.getId(), metaId(SHA1), noDrafts()));
-    assertThat(c.getNoteDbState()).isEqualTo(SHA1.name());
-
-    applyDelta(c, Delta.create(c.getId(), metaId(SHA2), noDrafts()));
-    assertThat(c.getNoteDbState()).isEqualTo(SHA2.name());
-
-    // No-op delta.
-    applyDelta(c, Delta.create(c.getId(), noMetaId(), noDrafts()));
-    assertThat(c.getNoteDbState()).isEqualTo(SHA2.name());
-
-    // Set to zero clears the field.
-    applyDelta(c, Delta.create(c.getId(), metaId(zeroId()), noDrafts()));
-    assertThat(c.getNoteDbState()).isNull();
-  }
-
-  @Test
-  public void applyDeltaToDrafts() throws Exception {
-    Change c = newChange();
-    applyDelta(c, Delta.create(c.getId(), metaId(SHA1), drafts(new Account.Id(1001), SHA2)));
-    assertThat(c.getNoteDbState()).isEqualTo(SHA1.name() + ",1001=" + SHA2.name());
-
-    applyDelta(c, Delta.create(c.getId(), noMetaId(), drafts(new Account.Id(2003), SHA3)));
-    assertThat(c.getNoteDbState())
-        .isEqualTo(SHA1.name() + ",1001=" + SHA2.name() + ",2003=" + SHA3.name());
-
-    applyDelta(c, Delta.create(c.getId(), noMetaId(), drafts(new Account.Id(2003), zeroId())));
-    assertThat(c.getNoteDbState()).isEqualTo(SHA1.name() + ",1001=" + SHA2.name());
-
-    applyDelta(c, Delta.create(c.getId(), metaId(SHA3), noDrafts()));
-    assertThat(c.getNoteDbState()).isEqualTo(SHA3.name() + ",1001=" + SHA2.name());
-  }
-
-  @Test
-  public void applyDeltaToReadOnly() throws Exception {
-    Timestamp ts = nowTs();
-    Change c = newChange();
-    NoteDbChangeState state =
-        new NoteDbChangeState(
-            c.getId(),
-            REVIEW_DB,
-            Optional.of(RefState.create(SHA1, ImmutableMap.of())),
-            Optional.of(new Timestamp(ts.getTime() + 10000)));
-    c.setNoteDbState(state.toString());
-    Delta delta = Delta.create(c.getId(), metaId(SHA2), noDrafts());
-    applyDelta(c, delta);
-    assertThat(NoteDbChangeState.parse(c))
-        .isEqualTo(
-            new NoteDbChangeState(
-                state.getChangeId(),
-                state.getPrimaryStorage(),
-                Optional.of(RefState.create(SHA2, ImmutableMap.of())),
-                state.getReadOnlyUntil()));
-  }
-
-  @Test
-  public void parseNoteDbPrimary() {
-    NoteDbChangeState state = parse(new Change.Id(1), "N");
-    assertThat(state.getPrimaryStorage()).isEqualTo(NOTE_DB);
-    assertThat(state.getRefState()).isEmpty();
-    assertThat(state.getReadOnlyUntil()).isEmpty();
-  }
-
-  @Test(expected = IllegalArgumentException.class)
-  public void parseInvalidPrimaryStorage() {
-    parse(new Change.Id(1), "X");
-  }
-
-  @Test
-  public void applyDeltaToNoteDbPrimaryIsNoOp() throws Exception {
-    Change c = newChange();
-    c.setNoteDbState("N");
-    applyDelta(c, Delta.create(c.getId(), metaId(SHA1), drafts(new Account.Id(1001), SHA2)));
-    assertThat(c.getNoteDbState()).isEqualTo("N");
-  }
-
-  private static Change newChange() {
-    return TestChanges.newChange(new Project.NameKey("project"), new Account.Id(12345));
-  }
-
-  // Static factory methods to avoid type arguments when using as method args.
-
-  private static Optional<ObjectId> noMetaId() {
-    return Optional.empty();
-  }
-
-  private static Optional<ObjectId> metaId(ObjectId id) {
-    return Optional.of(id);
-  }
-
-  private static ImmutableMap<Account.Id, ObjectId> noDrafts() {
-    return ImmutableMap.of();
-  }
-
-  private static ImmutableMap<Account.Id, ObjectId> drafts(Object... args) {
-    checkArgument(args.length % 2 == 0);
-    ImmutableMap.Builder<Account.Id, ObjectId> b = ImmutableMap.builder();
-    for (int i = 0; i < args.length / 2; i++) {
-      b.put((Account.Id) args[2 * i], (ObjectId) args[2 * i + 1]);
-    }
-    return b.build();
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
deleted file mode 100644
index 76be4569..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ /dev/null
@@ -1,305 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-import static org.junit.Assert.fail;
-
-import com.github.rholder.retry.Retryer;
-import com.github.rholder.retry.RetryerBuilder;
-import com.github.rholder.retry.StopStrategies;
-import com.google.common.util.concurrent.Runnables;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicInteger;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.ExpectedException;
-
-public class RepoSequenceTest {
-  // Don't sleep in tests.
-  private static final Retryer<RefUpdate.Result> RETRYER =
-      RepoSequence.retryerBuilder().withBlockStrategy(t -> {}).build();
-
-  @Rule public ExpectedException exception = ExpectedException.none();
-
-  private InMemoryRepositoryManager repoManager;
-  private Project.NameKey project;
-
-  @Before
-  public void setUp() throws Exception {
-    repoManager = new InMemoryRepositoryManager();
-    project = new Project.NameKey("project");
-    repoManager.createRepository(project);
-  }
-
-  @Test
-  public void oneCaller() throws Exception {
-    int max = 20;
-    for (int batchSize = 1; batchSize <= 10; batchSize++) {
-      String name = "batch-size-" + batchSize;
-      RepoSequence s = newSequence(name, 1, batchSize);
-      for (int i = 1; i <= max; i++) {
-        try {
-          assertThat(s.next()).named("i=" + i + " for " + name).isEqualTo(i);
-        } catch (OrmException e) {
-          throw new AssertionError("failed batchSize=" + batchSize + ", i=" + i, e);
-        }
-      }
-      assertThat(s.acquireCount)
-          .named("acquireCount for " + name)
-          .isEqualTo(divCeil(max, batchSize));
-    }
-  }
-
-  @Test
-  public void oneCallerNoLoop() throws Exception {
-    RepoSequence s = newSequence("id", 1, 3);
-    assertThat(s.acquireCount).isEqualTo(0);
-
-    assertThat(s.next()).isEqualTo(1);
-    assertThat(s.acquireCount).isEqualTo(1);
-    assertThat(s.next()).isEqualTo(2);
-    assertThat(s.acquireCount).isEqualTo(1);
-    assertThat(s.next()).isEqualTo(3);
-    assertThat(s.acquireCount).isEqualTo(1);
-
-    assertThat(s.next()).isEqualTo(4);
-    assertThat(s.acquireCount).isEqualTo(2);
-    assertThat(s.next()).isEqualTo(5);
-    assertThat(s.acquireCount).isEqualTo(2);
-    assertThat(s.next()).isEqualTo(6);
-    assertThat(s.acquireCount).isEqualTo(2);
-
-    assertThat(s.next()).isEqualTo(7);
-    assertThat(s.acquireCount).isEqualTo(3);
-    assertThat(s.next()).isEqualTo(8);
-    assertThat(s.acquireCount).isEqualTo(3);
-    assertThat(s.next()).isEqualTo(9);
-    assertThat(s.acquireCount).isEqualTo(3);
-
-    assertThat(s.next()).isEqualTo(10);
-    assertThat(s.acquireCount).isEqualTo(4);
-  }
-
-  @Test
-  public void twoCallers() throws Exception {
-    RepoSequence s1 = newSequence("id", 1, 3);
-    RepoSequence s2 = newSequence("id", 1, 3);
-
-    // s1 acquires 1-3; s2 acquires 4-6.
-    assertThat(s1.next()).isEqualTo(1);
-    assertThat(s2.next()).isEqualTo(4);
-    assertThat(s1.next()).isEqualTo(2);
-    assertThat(s2.next()).isEqualTo(5);
-    assertThat(s1.next()).isEqualTo(3);
-    assertThat(s2.next()).isEqualTo(6);
-
-    // s2 acquires 7-9; s1 acquires 10-12.
-    assertThat(s2.next()).isEqualTo(7);
-    assertThat(s1.next()).isEqualTo(10);
-    assertThat(s2.next()).isEqualTo(8);
-    assertThat(s1.next()).isEqualTo(11);
-    assertThat(s2.next()).isEqualTo(9);
-    assertThat(s1.next()).isEqualTo(12);
-  }
-
-  @Test
-  public void populateEmptyRefWithStartValue() throws Exception {
-    RepoSequence s = newSequence("id", 1234, 10);
-    assertThat(s.next()).isEqualTo(1234);
-    assertThat(readBlob("id")).isEqualTo("1244");
-  }
-
-  @Test
-  public void startIsIgnoredIfRefIsPresent() throws Exception {
-    writeBlob("id", "1234");
-    RepoSequence s = newSequence("id", 3456, 10);
-    assertThat(s.next()).isEqualTo(1234);
-    assertThat(readBlob("id")).isEqualTo("1244");
-  }
-
-  @Test
-  public void retryOnLockFailure() throws Exception {
-    // Seed existing ref value.
-    writeBlob("id", "1");
-
-    AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
-    Runnable bgUpdate =
-        () -> {
-          if (!doneBgUpdate.getAndSet(true)) {
-            writeBlob("id", "1234");
-          }
-        };
-
-    RepoSequence s = newSequence("id", 1, 10, bgUpdate, RETRYER);
-    assertThat(doneBgUpdate.get()).isFalse();
-    assertThat(s.next()).isEqualTo(1234);
-    // Single acquire call that results in 2 ref reads.
-    assertThat(s.acquireCount).isEqualTo(1);
-    assertThat(doneBgUpdate.get()).isTrue();
-  }
-
-  @Test
-  public void failOnInvalidValue() throws Exception {
-    ObjectId id = writeBlob("id", "not a number");
-    exception.expect(OrmException.class);
-    exception.expectMessage("invalid value in refs/sequences/id blob at " + id.name());
-    newSequence("id", 1, 3).next();
-  }
-
-  @Test
-  public void failOnWrongType() throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      TestRepository<Repository> tr = new TestRepository<>(repo);
-      tr.branch(RefNames.REFS_SEQUENCES + "id").commit().create();
-      try {
-        newSequence("id", 1, 3).next();
-        fail();
-      } catch (OrmException e) {
-        assertThat(e.getCause()).isInstanceOf(ExecutionException.class);
-        assertThat(e.getCause().getCause()).isInstanceOf(IncorrectObjectTypeException.class);
-      }
-    }
-  }
-
-  @Test
-  public void failAfterRetryerGivesUp() throws Exception {
-    AtomicInteger bgCounter = new AtomicInteger(1234);
-    RepoSequence s =
-        newSequence(
-            "id",
-            1,
-            10,
-            () -> writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000))),
-            RetryerBuilder.<RefUpdate.Result>newBuilder()
-                .withStopStrategy(StopStrategies.stopAfterAttempt(3))
-                .build());
-    exception.expect(OrmException.class);
-    exception.expectMessage("failed to update refs/sequences/id: LOCK_FAILURE");
-    s.next();
-  }
-
-  @Test
-  public void nextWithCountOneCaller() throws Exception {
-    RepoSequence s = newSequence("id", 1, 3);
-    assertThat(s.next(2)).containsExactly(1, 2).inOrder();
-    assertThat(s.acquireCount).isEqualTo(1);
-    assertThat(s.next(2)).containsExactly(3, 4).inOrder();
-    assertThat(s.acquireCount).isEqualTo(2);
-    assertThat(s.next(2)).containsExactly(5, 6).inOrder();
-    assertThat(s.acquireCount).isEqualTo(2);
-
-    assertThat(s.next(3)).containsExactly(7, 8, 9).inOrder();
-    assertThat(s.acquireCount).isEqualTo(3);
-    assertThat(s.next(3)).containsExactly(10, 11, 12).inOrder();
-    assertThat(s.acquireCount).isEqualTo(4);
-    assertThat(s.next(3)).containsExactly(13, 14, 15).inOrder();
-    assertThat(s.acquireCount).isEqualTo(5);
-
-    assertThat(s.next(7)).containsExactly(16, 17, 18, 19, 20, 21, 22).inOrder();
-    assertThat(s.acquireCount).isEqualTo(6);
-    assertThat(s.next(7)).containsExactly(23, 24, 25, 26, 27, 28, 29).inOrder();
-    assertThat(s.acquireCount).isEqualTo(7);
-    assertThat(s.next(7)).containsExactly(30, 31, 32, 33, 34, 35, 36).inOrder();
-    assertThat(s.acquireCount).isEqualTo(8);
-  }
-
-  @Test
-  public void nextWithCountMultipleCallers() throws Exception {
-    RepoSequence s1 = newSequence("id", 1, 3);
-    RepoSequence s2 = newSequence("id", 1, 4);
-
-    assertThat(s1.next(2)).containsExactly(1, 2).inOrder();
-    assertThat(s1.acquireCount).isEqualTo(1);
-
-    // s1 hasn't exhausted its last batch.
-    assertThat(s2.next(2)).containsExactly(4, 5).inOrder();
-    assertThat(s2.acquireCount).isEqualTo(1);
-
-    // s1 acquires again to cover this request, plus a whole new batch.
-    assertThat(s1.next(3)).containsExactly(3, 8, 9);
-    assertThat(s1.acquireCount).isEqualTo(2);
-
-    // s2 hasn't exhausted its last batch, do so now.
-    assertThat(s2.next(2)).containsExactly(6, 7);
-    assertThat(s2.acquireCount).isEqualTo(1);
-  }
-
-  private RepoSequence newSequence(String name, int start, int batchSize) {
-    return newSequence(name, start, batchSize, Runnables.doNothing(), RETRYER);
-  }
-
-  private RepoSequence newSequence(
-      String name,
-      final int start,
-      int batchSize,
-      Runnable afterReadRef,
-      Retryer<RefUpdate.Result> retryer) {
-    return new RepoSequence(
-        repoManager,
-        GitReferenceUpdated.DISABLED,
-        project,
-        name,
-        () -> start,
-        batchSize,
-        afterReadRef,
-        retryer);
-  }
-
-  private ObjectId writeBlob(String sequenceName, String value) {
-    String refName = RefNames.REFS_SEQUENCES + sequenceName;
-    try (Repository repo = repoManager.openRepository(project);
-        ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId newId = ins.insert(OBJ_BLOB, value.getBytes(UTF_8));
-      ins.flush();
-      RefUpdate ru = repo.updateRef(refName);
-      ru.setNewObjectId(newId);
-      assertThat(ru.forceUpdate()).isAnyOf(RefUpdate.Result.NEW, RefUpdate.Result.FORCED);
-      return newId;
-    } catch (IOException e) {
-      throw new RuntimeException(e);
-    }
-  }
-
-  private String readBlob(String sequenceName) throws Exception {
-    String refName = RefNames.REFS_SEQUENCES + sequenceName;
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId id = repo.exactRef(refName).getObjectId();
-      return new String(rw.getObjectReader().open(id).getCachedBytes(), UTF_8);
-    }
-  }
-
-  private static long divCeil(float a, float b) {
-    return Math.round(Math.ceil(a / b));
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java
deleted file mode 100644
index 1de82b1..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java
+++ /dev/null
@@ -1,231 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb.rebuild;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.util.stream.Collectors.toList;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.Collections2;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.testutil.TestTimeUtil;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-import java.util.stream.Stream;
-import org.junit.Before;
-import org.junit.Test;
-
-public class EventSorterTest {
-  private class TestEvent extends Event {
-    protected TestEvent(Timestamp when) {
-      super(
-          new PatchSet.Id(new Change.Id(1), 1),
-          new Account.Id(1000),
-          new Account.Id(1000),
-          when,
-          changeCreatedOn,
-          null);
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return false;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) {
-      throw new UnsupportedOperationException();
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public String toString() {
-      return "E{" + when.getSeconds() + '}';
-    }
-  }
-
-  private Timestamp changeCreatedOn;
-
-  @Before
-  public void setUp() {
-    TestTimeUtil.resetWithClockStep(10, TimeUnit.SECONDS);
-    changeCreatedOn = TimeUtil.nowTs();
-  }
-
-  @Test
-  public void naturalSort() {
-    Event e1 = new TestEvent(TimeUtil.nowTs());
-    Event e2 = new TestEvent(TimeUtil.nowTs());
-    Event e3 = new TestEvent(TimeUtil.nowTs());
-
-    for (List<Event> events : Collections2.permutations(events(e1, e2, e3))) {
-      assertSorted(events, events(e1, e2, e3));
-    }
-  }
-
-  @Test
-  public void topoSortOneDep() {
-    List<Event> es;
-
-    // Input list is 0,1,2
-
-    // 0 depends on 1 => 1,0,2
-    es = threeEventsOneDep(0, 1);
-    assertSorted(es, events(es, 1, 0, 2));
-
-    // 1 depends on 0 => 0,1,2
-    es = threeEventsOneDep(1, 0);
-    assertSorted(es, events(es, 0, 1, 2));
-
-    // 0 depends on 2 => 1,2,0
-    es = threeEventsOneDep(0, 2);
-    assertSorted(es, events(es, 1, 2, 0));
-
-    // 2 depends on 0 => 0,1,2
-    es = threeEventsOneDep(2, 0);
-    assertSorted(es, events(es, 0, 1, 2));
-
-    // 1 depends on 2 => 0,2,1
-    es = threeEventsOneDep(1, 2);
-    assertSorted(es, events(es, 0, 2, 1));
-
-    // 2 depends on 1 => 0,1,2
-    es = threeEventsOneDep(2, 1);
-    assertSorted(es, events(es, 0, 1, 2));
-  }
-
-  private List<Event> threeEventsOneDep(int depFromIdx, int depOnIdx) {
-    List<Event> events =
-        Lists.newArrayList(
-            new TestEvent(TimeUtil.nowTs()),
-            new TestEvent(TimeUtil.nowTs()),
-            new TestEvent(TimeUtil.nowTs()));
-    events.get(depFromIdx).addDep(events.get(depOnIdx));
-    return events;
-  }
-
-  @Test
-  public void lastEventDependsOnFirstEvent() {
-    List<Event> events = new ArrayList<>();
-    for (int i = 0; i < 20; i++) {
-      events.add(new TestEvent(TimeUtil.nowTs()));
-    }
-    events.get(events.size() - 1).addDep(events.get(0));
-    assertSorted(events, events);
-  }
-
-  @Test
-  public void firstEventDependsOnLastEvent() {
-    List<Event> events = new ArrayList<>();
-    for (int i = 0; i < 20; i++) {
-      events.add(new TestEvent(TimeUtil.nowTs()));
-    }
-    events.get(0).addDep(events.get(events.size() - 1));
-
-    List<Event> expected = new ArrayList<>();
-    expected.addAll(events.subList(1, events.size()));
-    expected.add(events.get(0));
-    assertSorted(events, expected);
-  }
-
-  @Test
-  public void topoSortChainOfDeps() {
-    Event e1 = new TestEvent(TimeUtil.nowTs());
-    Event e2 = new TestEvent(TimeUtil.nowTs());
-    Event e3 = new TestEvent(TimeUtil.nowTs());
-    Event e4 = new TestEvent(TimeUtil.nowTs());
-    e1.addDep(e2);
-    e2.addDep(e3);
-    e3.addDep(e4);
-
-    assertSorted(events(e1, e2, e3, e4), events(e4, e3, e2, e1));
-  }
-
-  @Test
-  public void topoSortMultipleDeps() {
-    Event e1 = new TestEvent(TimeUtil.nowTs());
-    Event e2 = new TestEvent(TimeUtil.nowTs());
-    Event e3 = new TestEvent(TimeUtil.nowTs());
-    Event e4 = new TestEvent(TimeUtil.nowTs());
-    e1.addDep(e2);
-    e1.addDep(e4);
-    e2.addDep(e3);
-
-    // Processing 3 pops 2, processing 4 pops 1.
-    assertSorted(events(e2, e3, e1, e4), events(e3, e2, e4, e1));
-  }
-
-  @Test
-  public void topoSortMultipleDepsPreservesNaturalOrder() {
-    Event e1 = new TestEvent(TimeUtil.nowTs());
-    Event e2 = new TestEvent(TimeUtil.nowTs());
-    Event e3 = new TestEvent(TimeUtil.nowTs());
-    Event e4 = new TestEvent(TimeUtil.nowTs());
-    e1.addDep(e4);
-    e2.addDep(e4);
-    e3.addDep(e4);
-
-    // Processing 4 pops 1, 2, 3 in natural order.
-    assertSorted(events(e4, e3, e2, e1), events(e4, e1, e2, e3));
-  }
-
-  @Test
-  public void topoSortCycle() {
-    Event e1 = new TestEvent(TimeUtil.nowTs());
-    Event e2 = new TestEvent(TimeUtil.nowTs());
-
-    // Implementation is not really defined, but infinite looping would be bad.
-    // According to current implementation details, 2 pops 1, 1 pops 2 which was
-    // already seen.
-    assertSorted(events(e2, e1), events(e1, e2));
-  }
-
-  @Test
-  public void topoSortDepNotInInputList() {
-    Event e1 = new TestEvent(TimeUtil.nowTs());
-    Event e2 = new TestEvent(TimeUtil.nowTs());
-    Event e3 = new TestEvent(TimeUtil.nowTs());
-    e1.addDep(e3);
-
-    List<Event> events = events(e2, e1);
-    try {
-      new EventSorter(events).sort();
-      fail("expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      // Expected.
-    }
-  }
-
-  private static List<Event> events(Event... es) {
-    return Lists.newArrayList(es);
-  }
-
-  private static List<Event> events(List<Event> in, Integer... indexes) {
-    return Stream.of(indexes).map(in::get).collect(toList());
-  }
-
-  private static void assertSorted(List<Event> unsorted, List<Event> expected) {
-    List<Event> actual = new ArrayList<>(unsorted);
-    new EventSorter(actual).sort();
-    assertThat(actual).named("sorted" + unsorted).isEqualTo(expected);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/CommitsCollectionTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/CommitsCollectionTest.java
deleted file mode 100644
index 7f1b233..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/CommitsCollectionTest.java
+++ /dev/null
@@ -1,266 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.gerrit.common.data.Permission.READ;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testutil.InMemoryDatabase;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Provider;
-import com.google.inject.util.Providers;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-/** Unit tests for {@link CommitsCollection}. */
-public class CommitsCollectionTest {
-  @Inject private AccountManager accountManager;
-  @Inject private IdentifiedUser.GenericFactory userFactory;
-  @Inject private InMemoryDatabase schemaFactory;
-  @Inject private InMemoryRepositoryManager repoManager;
-  @Inject private SchemaCreator schemaCreator;
-  @Inject private ThreadLocalRequestContext requestContext;
-  @Inject protected ProjectCache projectCache;
-  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
-  @Inject protected AllProjectsName allProjects;
-  @Inject protected GroupCache groupCache;
-  @Inject private CommitsCollection commits;
-
-  private LifecycleManager lifecycle;
-  private ReviewDb db;
-  private TestRepository<InMemoryRepository> repo;
-  private ProjectConfig project;
-  private IdentifiedUser user;
-  private AccountGroup.UUID admins;
-
-  @Before
-  public void setUp() throws Exception {
-    Injector injector = Guice.createInjector(new InMemoryModule());
-    injector.injectMembers(this);
-    lifecycle = new LifecycleManager();
-    lifecycle.add(injector);
-    lifecycle.start();
-
-    db = schemaFactory.open();
-    schemaCreator.create(db);
-    // Need to create at least one user to be admin before creating a "normal"
-    // registered user.
-    // See AccountManager#create().
-    accountManager.authenticate(AuthRequest.forUser("admin")).getAccountId();
-    admins = groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID();
-    setUpPermissions();
-
-    Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
-    user = userFactory.create(userId);
-
-    Project.NameKey name = new Project.NameKey("project");
-    InMemoryRepository inMemoryRepo = repoManager.createRepository(name);
-    project = new ProjectConfig(name);
-    project.load(inMemoryRepo);
-    repo = new TestRepository<>(inMemoryRepo);
-
-    requestContext.setContext(
-        new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return user;
-          }
-
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return Providers.of(db);
-          }
-        });
-  }
-
-  @After
-  public void tearDown() {
-    if (repo != null) {
-      repo.getRepository().close();
-    }
-    if (lifecycle != null) {
-      lifecycle.stop();
-    }
-    requestContext.setContext(null);
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(schemaFactory);
-  }
-
-  @Test
-  public void canReadCommitWhenAllRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/*");
-    ObjectId id = repo.branch("master").commit().create();
-    ProjectState state = readProjectState();
-    RevWalk rw = repo.getRevWalk();
-    Repository r = repo.getRepository();
-
-    assertTrue(commits.canRead(state, r, rw.parseCommit(id)));
-  }
-
-  @Test
-  public void canReadCommitIfTwoRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch2");
-
-    ObjectId id1 = repo.branch("branch1").commit().create();
-    ObjectId id2 = repo.branch("branch2").commit().create();
-
-    ProjectState state = readProjectState();
-    RevWalk rw = repo.getRevWalk();
-    Repository r = repo.getRepository();
-
-    assertTrue(commits.canRead(state, r, rw.parseCommit(id1)));
-    assertTrue(commits.canRead(state, r, rw.parseCommit(id2)));
-  }
-
-  @Test
-  public void canReadCommitIfRefVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    deny(project, READ, REGISTERED_USERS, "refs/heads/branch2");
-
-    ObjectId id1 = repo.branch("branch1").commit().create();
-    ObjectId id2 = repo.branch("branch2").commit().create();
-
-    ProjectState state = readProjectState();
-    RevWalk rw = repo.getRevWalk();
-    Repository r = repo.getRepository();
-
-    assertTrue(commits.canRead(state, r, rw.parseCommit(id1)));
-    assertFalse(commits.canRead(state, r, rw.parseCommit(id2)));
-  }
-
-  @Test
-  public void canReadCommitIfReachableFromVisibleRef() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    deny(project, READ, REGISTERED_USERS, "refs/heads/branch2");
-
-    RevCommit parent1 = repo.commit().create();
-    repo.branch("branch1").commit().parent(parent1).create();
-
-    RevCommit parent2 = repo.commit().create();
-    repo.branch("branch2").commit().parent(parent2).create();
-
-    ProjectState state = readProjectState();
-    RevWalk rw = repo.getRevWalk();
-    Repository r = repo.getRepository();
-    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
-    assertFalse(commits.canRead(state, r, rw.parseCommit(parent2)));
-  }
-
-  @Test
-  public void cannotReadAfterRollbackWithRestrictedRead() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-
-    RevCommit parent1 = repo.commit().create();
-    ObjectId id1 = repo.branch("branch1").commit().parent(parent1).create();
-
-    ProjectState state = readProjectState();
-    RevWalk rw = repo.getRevWalk();
-    Repository r = repo.getRepository();
-
-    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
-    assertTrue(commits.canRead(state, r, rw.parseCommit(id1)));
-
-    repo.branch("branch1").update(parent1);
-    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
-    assertFalse(commits.canRead(state, r, rw.parseCommit(id1)));
-  }
-
-  @Test
-  public void canReadAfterRollbackWithAllRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/*");
-
-    RevCommit parent1 = repo.commit().create();
-    ObjectId id1 = repo.branch("branch1").commit().parent(parent1).create();
-
-    ProjectState state = readProjectState();
-    RevWalk rw = repo.getRevWalk();
-    Repository r = repo.getRepository();
-
-    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
-    assertTrue(commits.canRead(state, r, rw.parseCommit(id1)));
-
-    repo.branch("branch1").update(parent1);
-    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
-    assertFalse(commits.canRead(state, r, rw.parseCommit(id1)));
-  }
-
-  private ProjectState readProjectState() throws Exception {
-    return projectCache.get(project.getName());
-  }
-
-  protected void allow(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
-      throws Exception {
-    Util.allow(project, permission, id, ref);
-    saveProjectConfig(project);
-  }
-
-  protected void deny(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
-      throws Exception {
-    Util.deny(project, permission, id, ref);
-    saveProjectConfig(project);
-  }
-
-  protected void saveProjectConfig(ProjectConfig cfg) throws Exception {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(cfg.getName())) {
-      cfg.commit(md);
-    }
-    projectCache.evict(cfg.getProject());
-  }
-
-  private void setUpPermissions() throws Exception {
-    // Remove read permissions for all users besides admin, because by default
-    // Anonymous user group has ALLOW READ permission in refs/*.
-    // This method is idempotent, so is safe to call on every test setup.
-    ProjectConfig pc = projectCache.checkedGet(allProjects).getConfig();
-    for (AccessSection sec : pc.getAccessSections()) {
-      sec.removePermission(Permission.READ);
-    }
-    allow(pc, Permission.READ, admins, "refs/*");
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
deleted file mode 100644
index f7ce73f..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
+++ /dev/null
@@ -1,908 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.common.data.Permission.EDIT_TOPIC_NAME;
-import static com.google.gerrit.common.data.Permission.LABEL;
-import static com.google.gerrit.common.data.Permission.OWNER;
-import static com.google.gerrit.common.data.Permission.PUSH;
-import static com.google.gerrit.common.data.Permission.READ;
-import static com.google.gerrit.common.data.Permission.SUBMIT;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.Util.ADMIN;
-import static com.google.gerrit.server.project.Util.DEVS;
-import static com.google.gerrit.server.project.Util.allow;
-import static com.google.gerrit.server.project.Util.block;
-import static com.google.gerrit.server.project.Util.deny;
-import static com.google.gerrit.server.project.Util.doNotInherit;
-import static com.google.gerrit.testutil.InMemoryRepositoryManager.newRepository;
-
-import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.Capable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.errors.InvalidNameException;
-import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.rules.PrologEnvironment;
-import com.google.gerrit.rules.RulesCache;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.CapabilityCollection;
-import com.google.gerrit.server.account.GroupMembership;
-import com.google.gerrit.server.account.ListGroupMembership;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testutil.InMemoryDatabase;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Provider;
-import com.google.inject.util.Providers;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class RefControlTest {
-  private void assertAdminsAreOwnersAndDevsAreNot() {
-    ProjectControl uBlah = user(local, DEVS);
-    ProjectControl uAdmin = user(local, DEVS, ADMIN);
-
-    assertThat(uBlah.isOwner()).named("not owner").isFalse();
-    assertThat(uAdmin.isOwner()).named("is owner").isTrue();
-  }
-
-  private void assertOwner(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isOwner()).named("OWN " + ref).isTrue();
-  }
-
-  private void assertNotOwner(ProjectControl u) {
-    assertThat(u.isOwner()).named("not owner").isFalse();
-  }
-
-  private void assertNotOwner(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isOwner()).named("NOT OWN " + ref).isFalse();
-  }
-
-  private void assertCanAccess(ProjectControl u) {
-    boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
-    assertThat(access).named("can access").isTrue();
-  }
-
-  private void assertAccessDenied(ProjectControl u) {
-    boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
-    assertThat(access).named("cannot access").isFalse();
-  }
-
-  private void assertCanRead(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isVisible()).named("can read " + ref).isTrue();
-  }
-
-  private void assertCannotRead(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isVisible()).named("cannot read " + ref).isFalse();
-  }
-
-  private void assertCanSubmit(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canSubmit(false)).named("can submit " + ref).isTrue();
-  }
-
-  private void assertCannotSubmit(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canSubmit(false)).named("can submit " + ref).isFalse();
-  }
-
-  private void assertCanUpload(ProjectControl u) {
-    assertThat(u.canPushToAtLeastOneRef()).named("can upload").isEqualTo(Capable.OK);
-  }
-
-  private void assertCreateChange(String ref, ProjectControl u) {
-    boolean create = u.asForProject().ref(ref).testOrFalse(RefPermission.CREATE_CHANGE);
-    assertThat(create).named("can create change " + ref).isTrue();
-  }
-
-  private void assertCannotUpload(ProjectControl u) {
-    assertThat(u.canPushToAtLeastOneRef()).named("cannot upload").isNotEqualTo(Capable.OK);
-  }
-
-  private void assertCannotCreateChange(String ref, ProjectControl u) {
-    boolean create = u.asForProject().ref(ref).testOrFalse(RefPermission.CREATE_CHANGE);
-    assertThat(create).named("cannot create change " + ref).isFalse();
-  }
-
-  private void assertBlocked(String p, String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isBlocked(p)).named(p + " is blocked for " + ref).isTrue();
-  }
-
-  private void assertNotBlocked(String p, String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isBlocked(p)).named(p + " is blocked for " + ref).isFalse();
-  }
-
-  private void assertCanUpdate(String ref, ProjectControl u) {
-    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
-    assertThat(update).named("can update " + ref).isTrue();
-  }
-
-  private void assertCannotUpdate(String ref, ProjectControl u) {
-    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
-    assertThat(update).named("cannot update " + ref).isFalse();
-  }
-
-  private void assertCanForceUpdate(String ref, ProjectControl u) {
-    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
-    assertThat(update).named("can force push " + ref).isTrue();
-  }
-
-  private void assertCannotForceUpdate(String ref, ProjectControl u) {
-    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
-    assertThat(update).named("cannot force push " + ref).isFalse();
-  }
-
-  private void assertCanVote(int score, PermissionRange range) {
-    assertThat(range.contains(score)).named("can vote " + score).isTrue();
-  }
-
-  private void assertCannotVote(int score, PermissionRange range) {
-    assertThat(range.contains(score)).named("cannot vote " + score).isFalse();
-  }
-
-  private final AllProjectsName allProjectsName =
-      new AllProjectsName(AllProjectsNameProvider.DEFAULT);
-  private final AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
-  private final AccountGroup.UUID fixers = new AccountGroup.UUID("test.fixers");
-  private final Map<Project.NameKey, ProjectState> all = new HashMap<>();
-  private Project.NameKey localKey = new Project.NameKey("local");
-  private ProjectConfig local;
-  private Project.NameKey parentKey = new Project.NameKey("parent");
-  private ProjectConfig parent;
-  private InMemoryRepositoryManager repoManager;
-  private ProjectCache projectCache;
-  private PermissionCollection.Factory sectionSorter;
-  private ChangeControl.Factory changeControlFactory;
-  private ReviewDb db;
-
-  @Inject private PermissionBackend permissionBackend;
-  @Inject private CapabilityCollection.Factory capabilityCollectionFactory;
-  @Inject private SchemaCreator schemaCreator;
-  @Inject private SingleVersionListener singleVersionListener;
-  @Inject private InMemoryDatabase schemaFactory;
-  @Inject private ThreadLocalRequestContext requestContext;
-
-  @Before
-  public void setUp() throws Exception {
-    repoManager = new InMemoryRepositoryManager();
-    projectCache =
-        new ProjectCache() {
-          @Override
-          public ProjectState getAllProjects() {
-            return get(allProjectsName);
-          }
-
-          @Override
-          public ProjectState getAllUsers() {
-            return null;
-          }
-
-          @Override
-          public ProjectState get(Project.NameKey projectName) {
-            return all.get(projectName);
-          }
-
-          @Override
-          public void evict(Project p) {}
-
-          @Override
-          public void remove(Project p) {}
-
-          @Override
-          public Iterable<Project.NameKey> all() {
-            return Collections.emptySet();
-          }
-
-          @Override
-          public Iterable<Project.NameKey> byName(String prefix) {
-            return Collections.emptySet();
-          }
-
-          @Override
-          public void onCreateProject(Project.NameKey newProjectName) {}
-
-          @Override
-          public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
-            return Collections.emptySet();
-          }
-
-          @Override
-          public ProjectState checkedGet(Project.NameKey projectName) throws IOException {
-            return all.get(projectName);
-          }
-
-          @Override
-          public void evict(Project.NameKey p) {}
-        };
-
-    Injector injector = Guice.createInjector(new InMemoryModule());
-    injector.injectMembers(this);
-
-    try {
-      Repository repo = repoManager.createRepository(allProjectsName);
-      ProjectConfig allProjects = new ProjectConfig(new Project.NameKey(allProjectsName.get()));
-      allProjects.load(repo);
-      LabelType cr = Util.codeReview();
-      allProjects.getLabelSections().put(cr.getName(), cr);
-      add(allProjects);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RuntimeException(e);
-    }
-
-    db = schemaFactory.open();
-    singleVersionListener.start();
-    try {
-      schemaCreator.create(db);
-    } finally {
-      singleVersionListener.stop();
-    }
-
-    Cache<SectionSortCache.EntryKey, SectionSortCache.EntryVal> c =
-        CacheBuilder.newBuilder().build();
-    sectionSorter = new PermissionCollection.Factory(new SectionSortCache(c));
-
-    parent = new ProjectConfig(parentKey);
-    parent.load(newRepository(parentKey));
-    add(parent);
-
-    local = new ProjectConfig(localKey);
-    local.load(newRepository(localKey));
-    add(local);
-    local.getProject().setParentName(parentKey);
-
-    requestContext.setContext(
-        new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return null;
-          }
-
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return Providers.of(db);
-          }
-        });
-
-    changeControlFactory = injector.getInstance(ChangeControl.Factory.class);
-  }
-
-  @After
-  public void tearDown() {
-    requestContext.setContext(null);
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(schemaFactory);
-  }
-
-  @Test
-  public void ownerProject() {
-    allow(local, OWNER, ADMIN, "refs/*");
-
-    assertAdminsAreOwnersAndDevsAreNot();
-  }
-
-  @Test
-  public void denyOwnerProject() {
-    allow(local, OWNER, ADMIN, "refs/*");
-    deny(local, OWNER, DEVS, "refs/*");
-
-    assertAdminsAreOwnersAndDevsAreNot();
-  }
-
-  @Test
-  public void blockOwnerProject() {
-    allow(local, OWNER, ADMIN, "refs/*");
-    block(local, OWNER, DEVS, "refs/*");
-
-    assertAdminsAreOwnersAndDevsAreNot();
-  }
-
-  @Test
-  public void branchDelegation1() {
-    allow(local, OWNER, ADMIN, "refs/*");
-    allow(local, OWNER, DEVS, "refs/heads/x/*");
-
-    ProjectControl uDev = user(local, DEVS);
-    assertNotOwner(uDev);
-
-    assertOwner("refs/heads/x/*", uDev);
-    assertOwner("refs/heads/x/y", uDev);
-    assertOwner("refs/heads/x/y/*", uDev);
-
-    assertNotOwner("refs/*", uDev);
-    assertNotOwner("refs/heads/master", uDev);
-  }
-
-  @Test
-  public void branchDelegation2() {
-    allow(local, OWNER, ADMIN, "refs/*");
-    allow(local, OWNER, DEVS, "refs/heads/x/*");
-    allow(local, OWNER, fixers, "refs/heads/x/y/*");
-    doNotInherit(local, OWNER, "refs/heads/x/y/*");
-
-    ProjectControl uDev = user(local, DEVS);
-    assertNotOwner(uDev);
-
-    assertOwner("refs/heads/x/*", uDev);
-    assertOwner("refs/heads/x/y", uDev);
-    assertOwner("refs/heads/x/y/*", uDev);
-    assertNotOwner("refs/*", uDev);
-    assertNotOwner("refs/heads/master", uDev);
-
-    ProjectControl uFix = user(local, fixers);
-    assertNotOwner(uFix);
-
-    assertOwner("refs/heads/x/y/*", uFix);
-    assertOwner("refs/heads/x/y/bar", uFix);
-    assertNotOwner("refs/heads/x/*", uFix);
-    assertNotOwner("refs/heads/x/y", uFix);
-    assertNotOwner("refs/*", uFix);
-    assertNotOwner("refs/heads/master", uFix);
-  }
-
-  @Test
-  public void inheritRead_SingleBranchDeniesUpload() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
-    allow(local, READ, REGISTERED_USERS, "refs/heads/foobar");
-    doNotInherit(local, READ, "refs/heads/foobar");
-    doNotInherit(local, PUSH, "refs/for/refs/heads/foobar");
-
-    ProjectControl u = user(local);
-    assertCanUpload(u);
-    assertCreateChange("refs/heads/master", u);
-    assertCannotCreateChange("refs/heads/foobar", u);
-  }
-
-  @Test
-  public void blockPushDrafts() {
-    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
-
-    ProjectControl u = user(local);
-    assertCreateChange("refs/heads/master", u);
-    assertBlocked(PUSH, "refs/drafts/refs/heads/master", u);
-  }
-
-  @Test
-  public void blockPushDraftsUnblockAdmin() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
-    allow(parent, PUSH, ADMIN, "refs/drafts/*");
-
-    ProjectControl u = user(local);
-    ProjectControl a = user(local, "a", ADMIN);
-    assertBlocked(PUSH, "refs/drafts/refs/heads/master", u);
-    assertNotBlocked(PUSH, "refs/drafts/refs/heads/master", a);
-  }
-
-  @Test
-  public void inheritRead_SingleBranchDoesNotOverrideInherited() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
-    allow(local, READ, REGISTERED_USERS, "refs/heads/foobar");
-
-    ProjectControl u = user(local);
-    assertCanUpload(u);
-    assertCreateChange("refs/heads/master", u);
-    assertCreateChange("refs/heads/foobar", u);
-  }
-
-  @Test
-  public void inheritDuplicateSections() throws Exception {
-    allow(parent, READ, ADMIN, "refs/*");
-    allow(local, READ, DEVS, "refs/heads/*");
-    assertCanAccess(user(local, "a", ADMIN));
-
-    local = new ProjectConfig(localKey);
-    local.load(newRepository(localKey));
-    local.getProject().setParentName(parentKey);
-    allow(local, READ, DEVS, "refs/*");
-    assertCanAccess(user(local, "d", DEVS));
-  }
-
-  @Test
-  public void inheritRead_OverrideWithDeny() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    deny(local, READ, REGISTERED_USERS, "refs/*");
-
-    assertAccessDenied(user(local));
-  }
-
-  @Test
-  public void inheritRead_AppendWithDenyOfRef() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    deny(local, READ, REGISTERED_USERS, "refs/heads/*");
-
-    ProjectControl u = user(local);
-    assertCanAccess(u);
-    assertCanRead("refs/master", u);
-    assertCanRead("refs/tags/foobar", u);
-    assertCanRead("refs/heads/master", u);
-  }
-
-  @Test
-  public void inheritRead_OverridesAndDeniesOfRef() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    deny(local, READ, REGISTERED_USERS, "refs/*");
-    allow(local, READ, REGISTERED_USERS, "refs/heads/*");
-
-    ProjectControl u = user(local);
-    assertCanAccess(u);
-    assertCannotRead("refs/foobar", u);
-    assertCannotRead("refs/tags/foobar", u);
-    assertCanRead("refs/heads/foobar", u);
-  }
-
-  @Test
-  public void inheritSubmit_OverridesAndDeniesOfRef() {
-    allow(parent, SUBMIT, REGISTERED_USERS, "refs/*");
-    deny(local, SUBMIT, REGISTERED_USERS, "refs/*");
-    allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
-
-    ProjectControl u = user(local);
-    assertCannotSubmit("refs/foobar", u);
-    assertCannotSubmit("refs/tags/foobar", u);
-    assertCanSubmit("refs/heads/foobar", u);
-  }
-
-  @Test
-  public void cannotUploadToAnyRef() {
-    allow(parent, READ, REGISTERED_USERS, "refs/*");
-    allow(local, READ, DEVS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/for/refs/heads/*");
-
-    ProjectControl u = user(local);
-    assertCannotUpload(u);
-    assertCannotCreateChange("refs/heads/master", u);
-  }
-
-  @Test
-  public void usernamePatternCanUploadToAnyRef() {
-    allow(local, PUSH, REGISTERED_USERS, "refs/heads/users/${username}/*");
-    ProjectControl u = user(local, "a-registered-user");
-    assertCanUpload(u);
-  }
-
-  @Test
-  public void usernamePatternNonRegex() {
-    allow(local, READ, DEVS, "refs/sb/${username}/heads/*");
-
-    ProjectControl u = user(local, "u", DEVS);
-    ProjectControl d = user(local, "d", DEVS);
-    assertCannotRead("refs/sb/d/heads/foobar", u);
-    assertCanRead("refs/sb/d/heads/foobar", d);
-  }
-
-  @Test
-  public void usernamePatternWithRegex() {
-    allow(local, READ, DEVS, "^refs/sb/${username}/heads/.*");
-
-    ProjectControl u = user(local, "d.v", DEVS);
-    ProjectControl d = user(local, "dev", DEVS);
-    assertCannotRead("refs/sb/dev/heads/foobar", u);
-    assertCanRead("refs/sb/dev/heads/foobar", d);
-  }
-
-  @Test
-  public void usernameEmailPatternWithRegex() {
-    allow(local, READ, DEVS, "^refs/sb/${username}/heads/.*");
-
-    ProjectControl u = user(local, "d.v@ger-rit.org", DEVS);
-    ProjectControl d = user(local, "dev@ger-rit.org", DEVS);
-    assertCannotRead("refs/sb/dev@ger-rit.org/heads/foobar", u);
-    assertCanRead("refs/sb/dev@ger-rit.org/heads/foobar", d);
-  }
-
-  @Test
-  public void sortWithRegex() {
-    allow(local, READ, DEVS, "^refs/heads/.*");
-    allow(parent, READ, ANONYMOUS_USERS, "^refs/heads/.*-QA-.*");
-
-    ProjectControl u = user(local, DEVS);
-    ProjectControl d = user(local, DEVS);
-    assertCanRead("refs/heads/foo-QA-bar", u);
-    assertCanRead("refs/heads/foo-QA-bar", d);
-  }
-
-  @Test
-  public void blockRule_ParentBlocksChild() {
-    allow(local, PUSH, DEVS, "refs/tags/*");
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/tags/*");
-    ProjectControl u = user(local, DEVS);
-    assertCannotUpdate("refs/tags/V10", u);
-  }
-
-  @Test
-  public void blockRule_ParentBlocksChildEvenIfAlreadyBlockedInChild() {
-    allow(local, PUSH, DEVS, "refs/tags/*");
-    block(local, PUSH, ANONYMOUS_USERS, "refs/tags/*");
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/tags/*");
-
-    ProjectControl u = user(local, DEVS);
-    assertCannotUpdate("refs/tags/V10", u);
-  }
-
-  @Test
-  public void blockLabelRange_ParentBlocksChild() {
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-    block(parent, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-
-    ProjectControl u = user(local, DEVS);
-
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
-    assertCanVote(-1, range);
-    assertCanVote(1, range);
-    assertCannotVote(-2, range);
-    assertCannotVote(2, range);
-  }
-
-  @Test
-  public void blockLabelRange_ParentBlocksChildEvenIfAlreadyBlockedInChild() {
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-    block(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-    block(parent, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-
-    ProjectControl u = user(local, DEVS);
-
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
-    assertCanVote(-1, range);
-    assertCanVote(1, range);
-    assertCannotVote(-2, range);
-    assertCannotVote(2, range);
-  }
-
-  @Test
-  public void inheritSubmit_AllowInChildDoesntAffectUnblockInParent() {
-    block(parent, SUBMIT, ANONYMOUS_USERS, "refs/heads/*");
-    allow(parent, SUBMIT, REGISTERED_USERS, "refs/heads/*");
-    allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
-
-    ProjectControl u = user(local);
-    assertNotBlocked(SUBMIT, "refs/heads/master", u);
-  }
-
-  @Test
-  public void unblockNoForce() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/*");
-
-    ProjectControl u = user(local, DEVS);
-    assertCanUpdate("refs/heads/master", u);
-  }
-
-  @Test
-  public void unblockForce() {
-    PermissionRule r = block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    r.setForce(true);
-    allow(local, PUSH, DEVS, "refs/heads/*").setForce(true);
-
-    ProjectControl u = user(local, DEVS);
-    assertCanForceUpdate("refs/heads/master", u);
-  }
-
-  @Test
-  public void unblockForceWithAllowNoForce_NotPossible() {
-    PermissionRule r = block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    r.setForce(true);
-    allow(local, PUSH, DEVS, "refs/heads/*");
-
-    ProjectControl u = user(local, DEVS);
-    assertCannotForceUpdate("refs/heads/master", u);
-  }
-
-  @Test
-  public void unblockMoreSpecificRef_Fails() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master");
-
-    ProjectControl u = user(local, DEVS);
-    assertCannotUpdate("refs/heads/master", u);
-  }
-
-  @Test
-  public void unblockMoreSpecificRefInLocal_Fails() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master");
-
-    ProjectControl u = user(local, DEVS);
-    assertCannotUpdate("refs/heads/master", u);
-  }
-
-  @Test
-  public void unblockMoreSpecificRefWithExclusiveFlag() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master", true);
-
-    ProjectControl u = user(local, DEVS);
-    assertCanUpdate("refs/heads/master", u);
-  }
-
-  @Test
-  public void unblockMoreSpecificRefInLocalWithExclusiveFlag_Fails() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master", true);
-
-    ProjectControl u = user(local, DEVS);
-    assertCannotUpdate("refs/heads/master", u);
-  }
-
-  @Test
-  public void unblockOtherPermissionWithMoreSpecificRefAndExclusiveFlag_Fails() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, DEVS, "refs/heads/master");
-    allow(local, SUBMIT, DEVS, "refs/heads/master", true);
-
-    ProjectControl u = user(local, DEVS);
-    assertCannotUpdate("refs/heads/master", u);
-  }
-
-  @Test
-  public void unblockLargerScope_Fails() {
-    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/master");
-    allow(local, PUSH, DEVS, "refs/heads/*");
-
-    ProjectControl u = user(local, DEVS);
-    assertCannotUpdate("refs/heads/master", u);
-  }
-
-  @Test
-  public void unblockInLocal_Fails() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, PUSH, fixers, "refs/heads/*");
-
-    ProjectControl f = user(local, fixers);
-    assertCannotUpdate("refs/heads/master", f);
-  }
-
-  @Test
-  public void unblockInParentBlockInLocal() {
-    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
-    allow(parent, PUSH, DEVS, "refs/heads/*");
-    block(local, PUSH, DEVS, "refs/heads/*");
-
-    ProjectControl d = user(local, DEVS);
-    assertCannotUpdate("refs/heads/master", d);
-  }
-
-  @Test
-  public void unblockForceEditTopicName() {
-    block(local, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
-
-    ProjectControl u = user(local, DEVS);
-    assertThat(u.controlForRef("refs/heads/master").canForceEditTopicName())
-        .named("u can edit topic name")
-        .isTrue();
-  }
-
-  @Test
-  public void unblockInLocalForceEditTopicName_Fails() {
-    block(parent, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
-
-    ProjectControl u = user(local, REGISTERED_USERS);
-    assertThat(u.controlForRef("refs/heads/master").canForceEditTopicName())
-        .named("u can't edit topic name")
-        .isFalse();
-  }
-
-  @Test
-  public void unblockRange() {
-    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-
-    ProjectControl u = user(local, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
-    assertCanVote(-2, range);
-    assertCanVote(2, range);
-  }
-
-  @Test
-  public void unblockRangeOnMoreSpecificRef_Fails() {
-    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/master");
-
-    ProjectControl u = user(local, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
-    assertCannotVote(-2, range);
-    assertCannotVote(2, range);
-  }
-
-  @Test
-  public void unblockRangeOnLargerScope_Fails() {
-    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/master");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-
-    ProjectControl u = user(local, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
-    assertCannotVote(-2, range);
-    assertCannotVote(2, range);
-  }
-
-  @Test
-  public void unblockInLocalRange_Fails() {
-    block(parent, LABEL + "Code-Review", -1, 1, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-
-    ProjectControl u = user(local, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
-    assertCannotVote(-2, range);
-    assertCannotVote(2, range);
-  }
-
-  @Test
-  public void unblockRangeForChangeOwner() {
-    allow(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
-
-    ProjectControl u = user(local, DEVS);
-    PermissionRange range =
-        u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review", true);
-    assertCanVote(-2, range);
-    assertCanVote(2, range);
-  }
-
-  @Test
-  public void unblockRangeForNotChangeOwner() {
-    allow(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
-
-    ProjectControl u = user(local, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
-    assertCannotVote(-2, range);
-    assertCannotVote(2, range);
-  }
-
-  @Test
-  public void blockOwner() {
-    block(parent, OWNER, ANONYMOUS_USERS, "refs/*");
-    allow(local, OWNER, DEVS, "refs/*");
-
-    assertThat(user(local, DEVS).isOwner()).isFalse();
-  }
-
-  @Test
-  public void validateRefPatternsOK() throws Exception {
-    RefPattern.validate("refs/*");
-    RefPattern.validate("^refs/heads/*");
-    RefPattern.validate("^refs/tags/[0-9a-zA-Z-_.]+");
-    RefPattern.validate("refs/heads/review/${username}/*");
-  }
-
-  @Test(expected = InvalidNameException.class)
-  public void testValidateBadRefPatternDoubleCaret() throws Exception {
-    RefPattern.validate("^^refs/*");
-  }
-
-  @Test(expected = InvalidNameException.class)
-  public void testValidateBadRefPatternDanglingCharacter() throws Exception {
-    RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}*");
-  }
-
-  @Test
-  public void validateRefPatternNoDanglingCharacter() throws Exception {
-    RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}");
-  }
-
-  private InMemoryRepository add(ProjectConfig pc) {
-    PrologEnvironment.Factory envFactory = null;
-    ProjectControl.AssistedFactory projectControlFactory = null;
-    RulesCache rulesCache = null;
-    SitePaths sitePaths = null;
-    List<CommentLinkInfo> commentLinks = null;
-
-    InMemoryRepository repo;
-    try {
-      repo = repoManager.createRepository(pc.getName());
-      if (pc.getProject() == null) {
-        pc.load(repo);
-      }
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RuntimeException(e);
-    }
-    all.put(
-        pc.getName(),
-        new ProjectState(
-            sitePaths,
-            projectCache,
-            allProjectsName,
-            allUsersName,
-            projectControlFactory,
-            envFactory,
-            repoManager,
-            rulesCache,
-            commentLinks,
-            capabilityCollectionFactory,
-            pc));
-    return repo;
-  }
-
-  private ProjectControl user(ProjectConfig local, AccountGroup.UUID... memberOf) {
-    return user(local, null, memberOf);
-  }
-
-  private ProjectControl user(ProjectConfig local, String name, AccountGroup.UUID... memberOf) {
-    return new ProjectControl(
-        Collections.<AccountGroup.UUID>emptySet(),
-        Collections.<AccountGroup.UUID>emptySet(),
-        sectionSorter,
-        null, // commitsCollection
-        changeControlFactory,
-        permissionBackend,
-        new MockUser(name, memberOf),
-        newProjectState(local));
-  }
-
-  private ProjectState newProjectState(ProjectConfig local) {
-    add(local);
-    return all.get(local.getProject().getNameKey());
-  }
-
-  private class MockUser extends CurrentUser {
-    private final String username;
-    private final GroupMembership groups;
-
-    MockUser(String name, AccountGroup.UUID[] groupId) {
-      username = name;
-      ArrayList<AccountGroup.UUID> groupIds = Lists.newArrayList(groupId);
-      groupIds.add(REGISTERED_USERS);
-      groupIds.add(ANONYMOUS_USERS);
-      groups = new ListGroupMembership(groupIds);
-    }
-
-    @Override
-    public GroupMembership getEffectiveGroups() {
-      return groups;
-    }
-
-    @Override
-    public String getUserName() {
-      return username;
-    }
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
deleted file mode 100644
index 5a72d5c..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
+++ /dev/null
@@ -1,213 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupReference;
-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.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.git.ProjectConfig;
-import java.util.Arrays;
-
-public class Util {
-  public static final AccountGroup.UUID ADMIN = new AccountGroup.UUID("test.admin");
-  public static final AccountGroup.UUID DEVS = new AccountGroup.UUID("test.devs");
-
-  public static final LabelType codeReview() {
-    return category(
-        "Code-Review",
-        value(2, "Looks good to me, approved"),
-        value(1, "Looks good to me, but someone else must approve"),
-        value(0, "No score"),
-        value(-1, "I would prefer this is not merged as is"),
-        value(-2, "This shall not be merged"));
-  }
-
-  public static final LabelType verified() {
-    return category("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
-  }
-
-  public static final LabelType patchSetLock() {
-    LabelType label =
-        category("Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
-    label.setFunctionName("PatchSetLock");
-    return label;
-  }
-
-  public static LabelValue value(int value, String text) {
-    return new LabelValue((short) value, text);
-  }
-
-  public static LabelType category(String name, LabelValue... values) {
-    return new LabelType(name, Arrays.asList(values));
-  }
-
-  public static PermissionRule newRule(ProjectConfig project, AccountGroup.UUID groupUUID) {
-    GroupReference group = new GroupReference(groupUUID, groupUUID.get());
-    group = project.resolve(group);
-
-    return new PermissionRule(group);
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project,
-      String permissionName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule rule = newRule(project, group);
-    rule.setMin(min);
-    rule.setMax(max);
-    return grant(project, permissionName, rule, ref);
-  }
-
-  public static PermissionRule block(
-      ProjectConfig project,
-      String permissionName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule rule = newRule(project, group);
-    rule.setMin(min);
-    rule.setMax(max);
-    PermissionRule r = grant(project, permissionName, rule, ref);
-    r.setBlock();
-    return r;
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    return grant(project, permissionName, newRule(project, group), ref);
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project,
-      String permissionName,
-      AccountGroup.UUID group,
-      String ref,
-      boolean exclusive) {
-    return grant(project, permissionName, newRule(project, group), ref, exclusive);
-  }
-
-  public static PermissionRule allow(
-      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
-    PermissionRule rule = newRule(project, group);
-    project
-        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-        .getPermission(capabilityName, true)
-        .add(rule);
-    if (GlobalCapability.hasRange(capabilityName)) {
-      PermissionRange.WithDefaults range = GlobalCapability.getRange(capabilityName);
-      if (range != null) {
-        rule.setRange(range.getDefaultMin(), range.getDefaultMax());
-      }
-    }
-    return rule;
-  }
-
-  public static PermissionRule remove(
-      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
-    PermissionRule rule = newRule(project, group);
-    project
-        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-        .getPermission(capabilityName, true)
-        .remove(rule);
-    return rule;
-  }
-
-  public static PermissionRule remove(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    PermissionRule rule = newRule(project, group);
-    project.getAccessSection(ref, true).getPermission(permissionName, true).remove(rule);
-    return rule;
-  }
-
-  public static PermissionRule block(
-      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
-    PermissionRule rule = newRule(project, group);
-    project
-        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-        .getPermission(capabilityName, true)
-        .add(rule);
-    return rule;
-  }
-
-  public static PermissionRule block(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    PermissionRule r = grant(project, permissionName, newRule(project, group), ref);
-    r.setBlock();
-    return r;
-  }
-
-  public static PermissionRule blockLabel(
-      ProjectConfig project, String labelName, AccountGroup.UUID group, String ref) {
-    return blockLabel(project, labelName, -1, 1, group, ref);
-  }
-
-  public static PermissionRule blockLabel(
-      ProjectConfig project,
-      String labelName,
-      int min,
-      int max,
-      AccountGroup.UUID group,
-      String ref) {
-    PermissionRule r = grant(project, Permission.LABEL + labelName, newRule(project, group), ref);
-    r.setBlock();
-    r.setRange(min, max);
-    return r;
-  }
-
-  public static PermissionRule deny(
-      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
-    PermissionRule r = grant(project, permissionName, newRule(project, group), ref);
-    r.setDeny();
-    return r;
-  }
-
-  public static void doNotInherit(ProjectConfig project, String permissionName, String ref) {
-    project
-        .getAccessSection(ref, true) //
-        .getPermission(permissionName, true) //
-        .setExclusiveGroup(true);
-  }
-
-  private static PermissionRule grant(
-      ProjectConfig project, String permissionName, PermissionRule rule, String ref) {
-    return grant(project, permissionName, rule, ref, false);
-  }
-
-  private static PermissionRule grant(
-      ProjectConfig project,
-      String permissionName,
-      PermissionRule rule,
-      String ref,
-      boolean exclusive) {
-    Permission permission = project.getAccessSection(ref, true).getPermission(permissionName, true);
-    if (exclusive) {
-      permission.setExclusiveGroup(exclusive);
-    }
-    permission.add(rule);
-    return rule;
-  }
-
-  private Util() {}
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
deleted file mode 100644
index 62d1df9..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ /dev/null
@@ -1,593 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.account;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.util.stream.Collectors.toList;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.api.accounts.Accounts.QueryRequest;
-import com.google.gerrit.extensions.client.ListAccountsOption;
-import com.google.gerrit.extensions.client.ProjectWatchInfo;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.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.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountConfig;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.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.ArrayList;
-import java.util.Arrays;
-import java.util.Iterator;
-import java.util.List;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Ignore;
-import org.junit.Test;
-
-@Ignore
-public abstract class AbstractQueryAccountsTest extends GerritServerTests {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    Config cfg = new Config();
-    cfg.setInt("index", null, "maxPages", 10);
-    return cfg;
-  }
-
-  @Inject protected Accounts accounts;
-
-  @Inject protected AccountsUpdate.Server accountsUpdate;
-
-  @Inject protected AccountCache accountCache;
-
-  @Inject protected AccountManager accountManager;
-
-  @Inject protected GerritApi gApi;
-
-  @Inject @GerritPersonIdent Provider<PersonIdent> serverIdent;
-
-  @Inject protected IdentifiedUser.GenericFactory userFactory;
-
-  @Inject private Provider<AnonymousUser> anonymousUser;
-
-  @Inject protected InMemoryDatabase schemaFactory;
-
-  @Inject protected SchemaCreator schemaCreator;
-
-  @Inject protected ThreadLocalRequestContext requestContext;
-
-  @Inject protected OneOffRequestContext oneOffRequestContext;
-
-  @Inject protected Provider<InternalAccountQuery> queryProvider;
-
-  @Inject protected AllProjectsName allProjects;
-
-  @Inject protected AllUsersName allUsers;
-
-  @Inject protected GitRepositoryManager repoManager;
-
-  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();
-  }
-
-  @After
-  public void cleanUp() {
-    lifecycle.stop();
-    db.close();
-  }
-
-  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 byId() throws Exception {
-    AccountInfo user = newAccount("user");
-
-    assertQuery("9999999");
-    assertQuery(currentUserInfo._accountId, currentUserInfo);
-    assertQuery(user._accountId, user);
-  }
-
-  @Test
-  public void bySelf() throws Exception {
-    assertQuery("self", currentUserInfo);
-  }
-
-  @Test
-  public void byEmail() throws Exception {
-    AccountInfo user1 = newAccountWithEmail("user1", name("user1@example.com"));
-
-    String domain = name("test.com");
-    AccountInfo user2 = newAccountWithEmail("user2", "user2@" + domain);
-    AccountInfo user3 = newAccountWithEmail("user3", "user3@" + domain);
-
-    String prefix = name("prefix");
-    AccountInfo user4 = newAccountWithEmail("user4", prefix + "user4@example.com");
-
-    AccountInfo user5 = newAccountWithEmail("user5", name("user5MixedCase@example.com"));
-
-    assertQuery("notexisting@test.com");
-
-    assertQuery(currentUserInfo.email, currentUserInfo);
-    assertQuery("email:" + currentUserInfo.email, currentUserInfo);
-
-    assertQuery(user1.email, user1);
-    assertQuery("email:" + user1.email, user1);
-
-    assertQuery(domain, user2, user3);
-
-    assertQuery("email:" + prefix, user4);
-
-    assertQuery(user5.email, user5);
-    assertQuery("email:" + user5.email, user5);
-    assertQuery("email:" + user5.email.toUpperCase(), user5);
-  }
-
-  @Test
-  public void byUsername() throws Exception {
-    AccountInfo user1 = newAccount("myuser");
-
-    assertQuery("notexisting");
-    assertQuery("Not Existing");
-
-    assertQuery(user1.username, user1);
-    assertQuery("username:" + user1.username, user1);
-    assertQuery("username:" + user1.username.toUpperCase(), user1);
-  }
-
-  @Test
-  public void isActive() throws Exception {
-    String domain = name("test.com");
-    AccountInfo user1 = newAccountWithEmail("user1", "user1@" + domain);
-    AccountInfo user2 = newAccountWithEmail("user2", "user2@" + domain);
-    AccountInfo user3 = newAccount("user3", "user3@" + domain, false);
-    AccountInfo user4 = newAccount("user4", "user4@" + domain, false);
-
-    // by default only active accounts are returned
-    assertQuery(domain, user1, user2);
-    assertQuery("name:" + domain, user1, user2);
-
-    assertQuery("is:active name:" + domain, user1, user2);
-
-    assertQuery("is:inactive name:" + domain, user3, user4);
-  }
-
-  @Test
-  public void byName() throws Exception {
-    AccountInfo user1 = newAccountWithFullName("jdoe", "John Doe");
-    AccountInfo user2 = newAccountWithFullName("jroe", "Jane Roe");
-    AccountInfo user3 = newAccountWithFullName("user3", "Mr Selfish");
-
-    assertQuery("notexisting");
-    assertQuery("Not Existing");
-
-    assertQuery(quote(user1.name), user1);
-    assertQuery("name:" + quote(user1.name), user1);
-    assertQuery("John", user1);
-    assertQuery("john", user1);
-    assertQuery("Doe", user1);
-    assertQuery("doe", user1);
-    assertQuery("DOE", user1);
-    assertQuery("Jo Do", user1);
-    assertQuery("jo do", user1);
-    assertQuery("self", currentUserInfo, user3);
-    assertQuery("me", currentUserInfo);
-    assertQuery("name:John", user1);
-    assertQuery("name:john", user1);
-    assertQuery("name:Doe", user1);
-    assertQuery("name:doe", user1);
-    assertQuery("name:DOE", user1);
-    assertQuery("name:self", user3);
-
-    assertQuery(quote(user2.name), user2);
-    assertQuery("name:" + quote(user2.name), user2);
-  }
-
-  @Test
-  public void byWatchedProject() throws Exception {
-    Project.NameKey p = createProject(name("p"));
-    Project.NameKey p2 = createProject(name("p2"));
-    AccountInfo user1 = newAccountWithFullName("jdoe", "John Doe");
-    AccountInfo user2 = newAccountWithFullName("jroe", "Jane Roe");
-    AccountInfo user3 = newAccountWithFullName("user3", "Mr Selfish");
-
-    assertThat(queryProvider.get().byWatchedProject(p)).isEmpty();
-
-    watch(user1, p, null);
-    assertAccounts(queryProvider.get().byWatchedProject(p), user1);
-
-    watch(user2, p, "keyword");
-    assertAccounts(queryProvider.get().byWatchedProject(p), user1, user2);
-
-    watch(user3, p2, "keyword");
-    watch(user3, allProjects, "keyword");
-    assertAccounts(queryProvider.get().byWatchedProject(p), user1, user2);
-    assertAccounts(queryProvider.get().byWatchedProject(p2), user3);
-    assertAccounts(queryProvider.get().byWatchedProject(allProjects), user3);
-  }
-
-  @Test
-  public void withLimit() throws Exception {
-    String domain = name("test.com");
-    AccountInfo user1 = newAccountWithEmail("user1", "user1@" + domain);
-    AccountInfo user2 = newAccountWithEmail("user2", "user2@" + domain);
-    AccountInfo user3 = newAccountWithEmail("user3", "user3@" + domain);
-
-    List<AccountInfo> result = assertQuery(domain, user1, user2, user3);
-    assertThat(Iterables.getLast(result)._moreAccounts).isNull();
-
-    result = assertQuery(newQuery(domain).withLimit(2), result.subList(0, 2));
-    assertThat(Iterables.getLast(result)._moreAccounts).isTrue();
-  }
-
-  @Test
-  public void withStart() throws Exception {
-    String domain = name("test.com");
-    AccountInfo user1 = newAccountWithEmail("user1", "user1@" + domain);
-    AccountInfo user2 = newAccountWithEmail("user2", "user2@" + domain);
-    AccountInfo user3 = newAccountWithEmail("user3", "user3@" + domain);
-
-    List<AccountInfo> result = assertQuery(domain, user1, user2, user3);
-    assertQuery(newQuery(domain).withStart(1), result.subList(1, 3));
-  }
-
-  @Test
-  public void withDetails() throws Exception {
-    AccountInfo user1 = newAccount("myuser", "My User", "my.user@example.com", true);
-
-    List<AccountInfo> result = assertQuery(user1.username, user1);
-    AccountInfo ai = result.get(0);
-    assertThat(ai._accountId).isEqualTo(user1._accountId);
-    assertThat(ai.name).isNull();
-    assertThat(ai.username).isNull();
-    assertThat(ai.email).isNull();
-    assertThat(ai.avatars).isNull();
-
-    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
-    ai = result.get(0);
-    assertThat(ai._accountId).isEqualTo(user1._accountId);
-    assertThat(ai.name).isEqualTo(user1.name);
-    assertThat(ai.username).isEqualTo(user1.username);
-    assertThat(ai.email).isEqualTo(user1.email);
-    assertThat(ai.avatars).isNull();
-  }
-
-  @Test
-  public void withSecondaryEmails() throws Exception {
-    AccountInfo user1 = newAccount("myuser", "My User", "my.user@example.com", true);
-    String[] secondaryEmails = new String[] {"bar@example.com", "foo@example.com"};
-    addEmails(user1, secondaryEmails);
-
-    List<AccountInfo> result = assertQuery(user1.username, user1);
-    assertThat(result.get(0).secondaryEmails).isNull();
-
-    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
-    assertThat(result.get(0).secondaryEmails).isNull();
-
-    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.ALL_EMAILS), user1);
-    assertThat(result.get(0).secondaryEmails)
-        .containsExactlyElementsIn(Arrays.asList(secondaryEmails))
-        .inOrder();
-
-    result =
-        assertQuery(
-            newQuery(user1.username)
-                .withOptions(ListAccountsOption.DETAILS, ListAccountsOption.ALL_EMAILS),
-            user1);
-    assertThat(result.get(0).secondaryEmails)
-        .containsExactlyElementsIn(Arrays.asList(secondaryEmails))
-        .inOrder();
-  }
-
-  @Test
-  public void asAnonymous() throws Exception {
-    AccountInfo user1 = newAccount("user1");
-
-    setAnonymous();
-    assertQuery("9999999");
-    assertQuery("self");
-    assertQuery("username:" + user1.username, user1);
-  }
-
-  // reindex permissions are tested by {@link AccountIT#reindexPermissions}
-  @Test
-  public void reindex() throws Exception {
-    AccountInfo user1 = newAccountWithFullName("tester", "Test Usre");
-
-    // update account without reindex so that account index is stale
-    Account.Id accountId = new Account.Id(user1._accountId);
-    String newName = "Test User";
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsers, repo);
-      PersonIdent ident = serverIdent.get();
-      md.getCommitBuilder().setAuthor(ident);
-      md.getCommitBuilder().setCommitter(ident);
-      AccountConfig accountConfig = new AccountConfig(null, accountId);
-      accountConfig.load(repo);
-      accountConfig.getAccount().setFullName(newName);
-      accountConfig.commit(md);
-    }
-
-    assertQuery("name:" + quote(user1.name), user1);
-    assertQuery("name:" + quote(newName));
-
-    gApi.accounts().id(user1.username).index();
-    assertQuery("name:" + quote(user1.name));
-    assertQuery("name:" + quote(newName), user1);
-  }
-
-  protected AccountInfo newAccount(String username) throws Exception {
-    return newAccountWithEmail(username, null);
-  }
-
-  protected AccountInfo newAccountWithEmail(String username, String email) throws Exception {
-    return newAccount(username, email, true);
-  }
-
-  protected AccountInfo newAccountWithFullName(String username, String fullName) throws Exception {
-    return newAccount(username, fullName, null, true);
-  }
-
-  protected AccountInfo newAccount(String username, String email, boolean active) throws Exception {
-    return newAccount(username, null, email, active);
-  }
-
-  protected AccountInfo newAccount(String username, String fullName, String email, boolean active)
-      throws Exception {
-    String uniqueName = name(username);
-
-    try {
-      gApi.accounts().id(uniqueName).get();
-      fail("user " + uniqueName + " already exists");
-    } catch (ResourceNotFoundException e) {
-      // expected: user does not exist yet
-    }
-
-    Account.Id id = createAccount(uniqueName, fullName, email, active);
-    return gApi.accounts().id(id.get()).get();
-  }
-
-  protected Project.NameKey createProject(String name) throws RestApiException {
-    gApi.projects().create(name);
-    return new Project.NameKey(name);
-  }
-
-  protected void watch(AccountInfo account, Project.NameKey project, String filter)
-      throws RestApiException {
-    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
-    ProjectWatchInfo pwi = new ProjectWatchInfo();
-    pwi.project = project.get();
-    pwi.filter = filter;
-    pwi.notifyAbandonedChanges = true;
-    pwi.notifyNewChanges = true;
-    pwi.notifyAllComments = true;
-    projectsToWatch.add(pwi);
-    gApi.accounts().id(account._accountId).setWatchedProjects(projectsToWatch);
-  }
-
-  protected String quote(String s) {
-    return "\"" + s + "\"";
-  }
-
-  protected String name(String name) {
-    if (name == null) {
-      return null;
-    }
-
-    String suffix = getSanitizedMethodName();
-    if (name.contains("@")) {
-      return name + "." + suffix;
-    }
-    return name + "_" + suffix;
-  }
-
-  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;
-    }
-  }
-
-  private void addEmails(AccountInfo account, String... emails) throws Exception {
-    Account.Id id = new Account.Id(account._accountId);
-    for (String email : emails) {
-      accountManager.link(id, AuthRequest.forEmail(email));
-    }
-    accountCache.evict(id);
-  }
-
-  protected QueryRequest newQuery(Object query) throws RestApiException {
-    return gApi.accounts().query(query.toString());
-  }
-
-  protected List<AccountInfo> assertQuery(Object query, AccountInfo... accounts) throws Exception {
-    return assertQuery(newQuery(query), accounts);
-  }
-
-  protected List<AccountInfo> assertQuery(QueryRequest query, AccountInfo... accounts)
-      throws Exception {
-    return assertQuery(query, Arrays.asList(accounts));
-  }
-
-  protected List<AccountInfo> assertQuery(QueryRequest query, List<AccountInfo> accounts)
-      throws Exception {
-    List<AccountInfo> result = query.get();
-    Iterable<Integer> ids = ids(result);
-    assertThat(ids)
-        .named(format(query, result, accounts))
-        .containsExactlyElementsIn(ids(accounts))
-        .inOrder();
-    return result;
-  }
-
-  protected void assertAccounts(List<AccountState> accounts, AccountInfo... expectedAccounts) {
-    assertThat(accounts.stream().map(a -> a.getAccount().getId().get()).collect(toList()))
-        .containsExactlyElementsIn(
-            Arrays.asList(expectedAccounts).stream().map(a -> a._accountId).collect(toList()));
-  }
-
-  private String format(
-      QueryRequest query, List<AccountInfo> actualIds, List<AccountInfo> expectedAccounts) {
-    StringBuilder b = new StringBuilder();
-    b.append("query '").append(query.getQuery()).append("' with expected accounts ");
-    b.append(format(expectedAccounts));
-    b.append(" and result ");
-    b.append(format(actualIds));
-    return b.toString();
-  }
-
-  private String format(Iterable<AccountInfo> accounts) {
-    StringBuilder b = new StringBuilder();
-    b.append("[");
-    Iterator<AccountInfo> it = accounts.iterator();
-    while (it.hasNext()) {
-      AccountInfo a = it.next();
-      b.append("{")
-          .append(a._accountId)
-          .append(", ")
-          .append("name=")
-          .append(a.name)
-          .append(", ")
-          .append("email=")
-          .append(a.email)
-          .append(", ")
-          .append("username=")
-          .append(a.username)
-          .append("}");
-      if (it.hasNext()) {
-        b.append(", ");
-      }
-    }
-    b.append("]");
-    return b.toString();
-  }
-
-  protected static Iterable<Integer> ids(AccountInfo... accounts) {
-    return ids(Arrays.asList(accounts));
-  }
-
-  protected static Iterable<Integer> ids(List<AccountInfo> accounts) {
-    return accounts.stream().map(a -> a._accountId).collect(toList());
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
deleted file mode 100644
index fa130ca..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.account;
-
-import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.gerrit.testutil.IndexVersions;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.lib.Config;
-
-public class LuceneQueryAccountsTest extends AbstractQueryAccountsTest {
-  @ConfigSuite.Configs
-  public static Map<String, Config> againstPreviousIndexVersion() {
-    // the current schema version is already tested by the inherited default config suite
-    List<Integer> schemaVersions =
-        IndexVersions.getWithoutLatest(AccountSchemaDefinitions.INSTANCE);
-    return IndexVersions.asConfigMap(
-        AccountSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config luceneConfig = new Config(config);
-    InMemoryModule.setDefaults(luceneConfig);
-    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
deleted file mode 100644
index 783bd26..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ /dev/null
@@ -1,2458 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
-import static java.util.concurrent.TimeUnit.HOURS;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.MINUTES;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.toList;
-import static org.junit.Assert.fail;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Streams;
-import com.google.common.truth.ThrowableSubject;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
-import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.api.changes.HashtagsInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
-import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
-import com.google.gerrit.extensions.api.changes.StarsInput;
-import com.google.gerrit.extensions.api.groups.GroupInput;
-import com.google.gerrit.extensions.api.projects.ConfigInput;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
-import com.google.gerrit.server.change.ChangeInserter;
-import com.google.gerrit.server.change.ChangeTriplet;
-import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gerrit.server.index.change.StalenessChecker;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.util.ManualRequestContext;
-import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.DisabledReviewDb;
-import com.google.gerrit.testutil.GerritServerTests;
-import com.google.gerrit.testutil.InMemoryDatabase;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
-import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Provider;
-import com.google.inject.util.Providers;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.SystemReader;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Ignore;
-import org.junit.Test;
-
-@Ignore
-public abstract class AbstractQueryChangesTest 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 AccountCache accountCache;
-  @Inject protected AccountsUpdate.Server accountsUpdate;
-  @Inject protected AccountManager accountManager;
-  @Inject protected AllUsersName allUsersName;
-  @Inject protected BatchUpdate.Factory updateFactory;
-  @Inject protected ChangeInserter.Factory changeFactory;
-  @Inject protected ChangeQueryBuilder queryBuilder;
-  @Inject protected GerritApi gApi;
-  @Inject protected IdentifiedUser.GenericFactory userFactory;
-  @Inject protected ChangeIndexCollection indexes;
-  @Inject protected ChangeIndexer indexer;
-  @Inject protected IndexConfig indexConfig;
-  @Inject protected InMemoryRepositoryManager repoManager;
-  @Inject protected Provider<InternalChangeQuery> queryProvider;
-  @Inject protected ChangeNotes.Factory notesFactory;
-  @Inject protected OneOffRequestContext oneOffRequestContext;
-  @Inject protected PatchSetInserter.Factory patchSetFactory;
-  @Inject protected PatchSetUtil psUtil;
-  @Inject protected ChangeNotes.Factory changeNotesFactory;
-  @Inject protected Provider<ChangeQueryProcessor> queryProcessorProvider;
-  @Inject protected SchemaCreator schemaCreator;
-  @Inject protected SchemaFactory<ReviewDb> schemaFactory;
-  @Inject protected Sequences seq;
-  @Inject protected ThreadLocalRequestContext requestContext;
-  @Inject protected ProjectCache projectCache;
-  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
-  @Inject protected ExternalIdsUpdate.Server externalIdsUpdate;
-
-  // Only for use in setting up/tearing down injector; other users should use schemaFactory.
-  @Inject private InMemoryDatabase inMemoryDatabase;
-
-  protected Injector injector;
-  protected LifecycleManager lifecycle;
-  protected ReviewDb db;
-  protected Account.Id userId;
-  protected CurrentUser user;
-
-  private String systemTimeZone;
-
-  protected abstract Injector createInjector();
-
-  @Before
-  public void setUpInjector() throws Exception {
-    lifecycle = new LifecycleManager();
-    injector = createInjector();
-    lifecycle.add(injector);
-    injector.injectMembers(this);
-    lifecycle.start();
-    setUpDatabase();
-  }
-
-  @After
-  public void cleanUp() {
-    lifecycle.stop();
-    db.close();
-  }
-
-  protected void setUpDatabase() throws Exception {
-    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
-      schemaCreator.create(underlyingDb);
-    }
-    db = schemaFactory.open();
-
-    userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
-    String email = "user@example.com";
-    externalIdsUpdate.create().insert(ExternalId.createEmail(userId, email));
-    accountsUpdate.create().update(userId, a -> a.setPreferredEmail(email));
-    user = userFactory.create(userId);
-    requestContext.setContext(newRequestContext(userId));
-  }
-
-  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);
-      }
-    };
-  }
-
-  @After
-  public void tearDownInjector() {
-    if (lifecycle != null) {
-      lifecycle.stop();
-    }
-    requestContext.setContext(null);
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(inMemoryDatabase);
-  }
-
-  @Before
-  public void setTimeForTesting() {
-    resetTimeWithClockStep(1, SECONDS);
-  }
-
-  private void resetTimeWithClockStep(long clockStep, TimeUnit clockStepUnit) {
-    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-    // TODO(dborowitz): Figure out why tests fail when stubbing out
-    // SystemReader.
-    TestTimeUtil.resetWithClockStep(clockStep, clockStepUnit);
-    SystemReader.setInstance(null);
-  }
-
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
-  }
-
-  @Test
-  public void byId() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-
-    assertQuery("12345");
-    assertQuery(change1.getId().get(), change1);
-    assertQuery(change2.getId().get(), change2);
-  }
-
-  @Test
-  public void byKey() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
-    String key = change.getKey().get();
-
-    assertQuery("I0000000000000000000000000000000000000000");
-    for (int i = 0; i <= 36; i++) {
-      String q = key.substring(0, 41 - i);
-      assertQuery(q, change);
-    }
-  }
-
-  @Test
-  public void byTriplet() throws Exception {
-    TestRepository<Repo> repo = createProject("iabcde");
-    Change change = insert(repo, newChangeForBranch(repo, "branch"));
-    String k = change.getKey().get();
-
-    assertQuery("iabcde~branch~" + k, change);
-    assertQuery("change:iabcde~branch~" + k, change);
-    assertQuery("iabcde~refs/heads/branch~" + k, change);
-    assertQuery("change:iabcde~refs/heads/branch~" + k, change);
-    assertQuery("iabcde~branch~" + k.substring(0, 10), change);
-    assertQuery("change:iabcde~branch~" + k.substring(0, 10), change);
-
-    assertQuery("foo~bar");
-    assertThatQueryException("change:foo~bar").hasMessageThat().isEqualTo("Invalid change format");
-    assertQuery("otherrepo~branch~" + k);
-    assertQuery("change:otherrepo~branch~" + k);
-    assertQuery("iabcde~otherbranch~" + k);
-    assertQuery("change:iabcde~otherbranch~" + k);
-    assertQuery("iabcde~branch~I0000000000000000000000000000000000000000");
-    assertQuery("change:iabcde~branch~I0000000000000000000000000000000000000000");
-  }
-
-  @Test
-  public void byStatus() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
-    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
-    Change change2 = insert(repo, ins2);
-
-    assertQuery("status:new", change1);
-    assertQuery("status:NEW", change1);
-    assertQuery("is:new", change1);
-    assertQuery("status:merged", change2);
-    assertQuery("is:merged", change2);
-    assertQuery("status:draft");
-    assertQuery("is:draft");
-  }
-
-  @Test
-  public void byStatusOpen() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
-    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-
-    Change[] expected = new Change[] {change1};
-    assertQuery("status:open", expected);
-    assertQuery("status:OPEN", expected);
-    assertQuery("status:o", expected);
-    assertQuery("status:op", expected);
-    assertQuery("status:ope", expected);
-    assertQuery("status:pending", expected);
-    assertQuery("status:PENDING", expected);
-    assertQuery("status:p", expected);
-    assertQuery("status:pe", expected);
-    assertQuery("status:pen", expected);
-    assertQuery("is:open", expected);
-  }
-
-  @Test
-  public void byStatusClosed() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
-    Change change1 = insert(repo, ins1);
-    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.ABANDONED);
-    Change change2 = insert(repo, ins2);
-    insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
-
-    Change[] expected = new Change[] {change2, change1};
-    assertQuery("status:closed", expected);
-    assertQuery("status:CLOSED", expected);
-    assertQuery("status:c", expected);
-    assertQuery("status:cl", expected);
-    assertQuery("status:clo", expected);
-    assertQuery("status:clos", expected);
-    assertQuery("status:close", expected);
-    assertQuery("status:closed", expected);
-    assertQuery("is:closed", expected);
-  }
-
-  @Test
-  public void byStatusPrefix() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
-    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-
-    assertQuery("status:n", change1);
-    assertQuery("status:ne", change1);
-    assertQuery("status:new", change1);
-    assertQuery("status:N", change1);
-    assertQuery("status:nE", change1);
-    assertQuery("status:neW", change1);
-    assertQuery("status:nx");
-    assertQuery("status:newx");
-  }
-
-  @Test
-  public void byPrivate() throws Exception {
-    if (getSchemaVersion() < 40) {
-      assertMissingField(ChangeField.PRIVATE);
-      assertFailingQuery(
-          "is:private", "'is:private' operator is not supported by change index version");
-      return;
-    }
-
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
-    Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
-
-    // No private changes.
-    assertQuery("is:open", change2, change1);
-    assertQuery("is:private");
-
-    gApi.changes().id(change1.getChangeId()).setPrivate(true, null);
-
-    // Change1 is not private, but should be still visible to its owner.
-    assertQuery("is:open", change1, change2);
-    assertQuery("is:private", change1);
-
-    // Switch request context to user2.
-    requestContext.setContext(newRequestContext(user2));
-    assertQuery("is:open", change2);
-    assertQuery("is:private");
-  }
-
-  @Test
-  public void byWip() throws Exception {
-    if (getSchemaVersion() < 42) {
-      assertMissingField(ChangeField.WIP);
-      assertFailingQuery("is:wip", "'is:wip' operator is not supported by change index version");
-      return;
-    }
-
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
-
-    assertQuery("is:open", change1);
-    assertQuery("is:wip");
-
-    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
-
-    assertQuery("is:wip", change1);
-
-    gApi.changes().id(change1.getChangeId()).setReadyForReview();
-
-    assertQuery("is:wip");
-  }
-
-  @Test
-  public void excludeWipChangeFromReviewersDashboardsBeforeSchema42() throws Exception {
-    assume().that(getSchemaVersion()).isLessThan(42);
-
-    assertMissingField(ChangeField.WIP);
-    assertFailingQuery("is:wip", "'is:wip' operator is not supported by change index version");
-
-    Account.Id user1 = createAccount("user1");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWorkInProgress(repo), userId);
-    assertQuery("reviewer:" + user1, change1);
-    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
-    assertQuery("reviewer:" + user1, change1);
-  }
-
-  @Test
-  public void excludeWipChangeFromReviewersDashboards() throws Exception {
-    assume().that(getSchemaVersion()).isAtLeast(42);
-
-    Account.Id user1 = createAccount("user1");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWorkInProgress(repo), userId);
-
-    assertQuery("is:wip", change1);
-    assertQuery("reviewer:" + user1);
-
-    gApi.changes().id(change1.getChangeId()).setReadyForReview();
-    assertQuery("is:wip");
-    assertQuery("reviewer:" + user1);
-
-    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
-    assertQuery("is:wip", change1);
-    assertQuery("reviewer:" + user1);
-  }
-
-  @Test
-  public void byStartedBeforeSchema44() throws Exception {
-    assume().that(getSchemaVersion()).isLessThan(44);
-    assertMissingField(ChangeField.STARTED);
-    assertFailingQuery(
-        "is:started", "'is:started' operator is not supported by change index version");
-  }
-
-  @Test
-  public void byStarted() throws Exception {
-    assume().that(getSchemaVersion()).isAtLeast(44);
-
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWorkInProgress(repo));
-
-    assertQuery("is:started");
-
-    gApi.changes().id(change1.getChangeId()).setReadyForReview();
-    assertQuery("is:started", change1);
-
-    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
-    assertQuery("is:started", change1);
-  }
-
-  private void assertReviewers(Collection<AccountInfo> reviewers, Object... expected)
-      throws Exception {
-    if (expected.length == 0) {
-      assertThat(reviewers).isNull();
-      return;
-    }
-
-    // Convert AccountInfos to strings, either account ID or email.
-    List<String> reviewerIds =
-        reviewers
-            .stream()
-            .map(
-                ai -> {
-                  if (ai._accountId != null) {
-                    return ai._accountId.toString();
-                  }
-                  return ai.email;
-                })
-            .collect(toList());
-    assertThat(reviewerIds).containsExactly(expected);
-  }
-
-  @Test
-  public void restorePendingReviewers() throws Exception {
-    assume().that(getSchemaVersion()).isAtLeast(44);
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    Project.NameKey project = new Project.NameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
-    ConfigInput conf = new ConfigInput();
-    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
-    gApi.projects().name(project.get()).config(conf);
-    Change change1 = insert(repo, newChangeWorkInProgress(repo));
-    Account.Id user1 = createAccount("user1");
-    Account.Id user2 = createAccount("user2");
-    String email1 = "email1@example.com";
-    String email2 = "email2@example.com";
-
-    ReviewInput in =
-        ReviewInput.noScore()
-            .reviewer(user1.toString())
-            .reviewer(user2.toString(), ReviewerState.CC, false)
-            .reviewer(email1)
-            .reviewer(email2, ReviewerState.CC, false);
-    gApi.changes().id(change1.getId().get()).revision("current").review(in);
-
-    List<ChangeInfo> changeInfos =
-        assertQuery(newQuery("is:wip").withOption(DETAILED_LABELS), change1);
-    assertThat(changeInfos).isNotEmpty();
-
-    Map<ReviewerState, Collection<AccountInfo>> pendingReviewers =
-        changeInfos.get(0).pendingReviewers;
-    assertThat(pendingReviewers).isNotNull();
-
-    assertReviewers(
-        pendingReviewers.get(ReviewerState.REVIEWER), userId.toString(), user1.toString(), email1);
-    assertReviewers(pendingReviewers.get(ReviewerState.CC), user2.toString(), email2);
-    assertReviewers(pendingReviewers.get(ReviewerState.REMOVED));
-
-    // Pending reviewers may also be presented in the REMOVED state. Toggle the
-    // change to ready and then back to WIP and remove reviewers to produce.
-    assertThat(pendingReviewers.get(ReviewerState.REMOVED)).isNull();
-    gApi.changes().id(change1.getId().get()).setReadyForReview();
-    gApi.changes().id(change1.getId().get()).setWorkInProgress();
-    gApi.changes().id(change1.getId().get()).reviewer(user1.toString()).remove();
-    gApi.changes().id(change1.getId().get()).reviewer(user2.toString()).remove();
-    gApi.changes().id(change1.getId().get()).reviewer(email1).remove();
-    gApi.changes().id(change1.getId().get()).reviewer(email2).remove();
-
-    changeInfos = assertQuery(newQuery("is:wip").withOption(DETAILED_LABELS), change1);
-    assertThat(changeInfos).isNotEmpty();
-
-    pendingReviewers = changeInfos.get(0).pendingReviewers;
-    assertThat(pendingReviewers).isNotNull();
-    assertReviewers(pendingReviewers.get(ReviewerState.REVIEWER));
-    assertReviewers(pendingReviewers.get(ReviewerState.CC));
-    assertReviewers(
-        pendingReviewers.get(ReviewerState.REMOVED),
-        user1.toString(),
-        user2.toString(),
-        email1,
-        email2);
-  }
-
-  @Test
-  public void byCommit() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins = newChange(repo);
-    insert(repo, ins);
-    String sha = ins.getCommitId().name();
-
-    assertQuery("0000000000000000000000000000000000000000");
-    for (int i = 0; i <= 36; i++) {
-      String q = sha.substring(0, 40 - i);
-      assertQuery(q, ins.getChange());
-    }
-  }
-
-  @Test
-  public void byOwner() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
-    Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
-
-    assertQuery("owner:" + userId.get(), change1);
-    assertQuery("owner:" + user2, change2);
-
-    String nameEmail = user.asIdentifiedUser().getNameEmail();
-    assertQuery("owner: \"" + nameEmail + "\"", change1);
-  }
-
-  @Test
-  public void byAuthorExact() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.EXACT_AUTHOR)).isTrue();
-    byAuthorOrCommitterExact("author:");
-  }
-
-  @Test
-  public void byAuthorFullText() throws Exception {
-    byAuthorOrCommitterFullText("author:");
-  }
-
-  @Test
-  public void byCommitterExact() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.EXACT_COMMITTER)).isTrue();
-    byAuthorOrCommitterExact("committer:");
-  }
-
-  @Test
-  public void byCommitterFullText() throws Exception {
-    byAuthorOrCommitterFullText("committer:");
-  }
-
-  private void byAuthorOrCommitterExact(String searchOperator) throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
-    PersonIdent john = new PersonIdent("John", "john@example.com");
-    PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
-    Change change1 = createChange(repo, johnDoe);
-    Change change2 = createChange(repo, john);
-    Change change3 = createChange(repo, doeSmith);
-
-    // Only email address.
-    assertQuery(searchOperator + "john.doe@example.com", change1);
-    assertQuery(searchOperator + "john@example.com", change2);
-    assertQuery(searchOperator + "Doe_SmIth@example.com", change3); // Case insensitive.
-
-    // Right combination of email address and name.
-    assertQuery(searchOperator + "\"John Doe <john.doe@example.com>\"", change1);
-    assertQuery(searchOperator + "\" John <john@example.com> \"", change2);
-    assertQuery(searchOperator + "\"doE SMITH <doe_smitH@example.com>\"", change3);
-
-    // Wrong combination of email address and name.
-    assertQuery(searchOperator + "\"John <john.doe@example.com>\"");
-    assertQuery(searchOperator + "\"Doe John <john@example.com>\"");
-    assertQuery(searchOperator + "\"Doe John <doe_smith@example.com>\"");
-  }
-
-  private void byAuthorOrCommitterFullText(String searchOperator) throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
-    PersonIdent john = new PersonIdent("John", "john@example.com");
-    PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
-    Change change1 = createChange(repo, johnDoe);
-    Change change2 = createChange(repo, john);
-    Change change3 = createChange(repo, doeSmith);
-
-    // By exact name.
-    assertQuery(searchOperator + "\"John Doe\"", change1);
-    assertQuery(searchOperator + "\"john\"", change2, change1);
-    assertQuery(searchOperator + "\"Doe smith\"", change3);
-
-    // By name part.
-    assertQuery(searchOperator + "Doe", change3, change1);
-    assertQuery(searchOperator + "smith", change3);
-
-    // By wrong combination.
-    assertQuery(searchOperator + "\"John Smith\"");
-
-    // By invalid query.
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid value");
-    // SchemaUtil.getNameParts will return an empty set for query only containing these characters.
-    assertQuery(searchOperator + "@.- /_");
-  }
-
-  private Change createChange(TestRepository<Repo> repo, PersonIdent person) throws Exception {
-    RevCommit commit =
-        repo.parseBody(repo.commit().message("message").author(person).committer(person).create());
-    return insert(repo, newChangeForCommit(repo, commit), null);
-  }
-
-  @Test
-  public void byOwnerIn() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
-    Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
-
-    assertQuery("ownerin:Administrators", change1);
-    assertQuery("ownerin:\"Registered Users\"", change2, change1);
-  }
-
-  @Test
-  public void byProject() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
-
-    assertQuery("project:foo");
-    assertQuery("project:repo");
-    assertQuery("project:repo1", change1);
-    assertQuery("project:repo2", change2);
-  }
-
-  @Test
-  public void byProjectPrefix() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
-
-    assertQuery("projects:foo");
-    assertQuery("projects:repo1", change1);
-    assertQuery("projects:repo2", change2);
-    assertQuery("projects:repo", change2, change1);
-  }
-
-  @Test
-  public void byBranchAndRef() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeForBranch(repo, "master"));
-    Change change2 = insert(repo, newChangeForBranch(repo, "branch"));
-
-    assertQuery("branch:foo");
-    assertQuery("branch:master", change1);
-    assertQuery("branch:refs/heads/master", change1);
-    assertQuery("ref:master");
-    assertQuery("ref:refs/heads/master", change1);
-    assertQuery("branch:refs/heads/master", change1);
-    assertQuery("branch:branch", change2);
-    assertQuery("branch:refs/heads/branch", change2);
-    assertQuery("ref:branch");
-    assertQuery("ref:refs/heads/branch", change2);
-  }
-
-  @Test
-  public void byTopic() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
-    Change change1 = insert(repo, ins1);
-
-    ChangeInserter ins2 = newChangeWithTopic(repo, "feature2");
-    Change change2 = insert(repo, ins2);
-
-    ChangeInserter ins3 = newChangeWithTopic(repo, "Cherrypick-feature2");
-    Change change3 = insert(repo, ins3);
-
-    ChangeInserter ins4 = newChangeWithTopic(repo, "feature2-fixup");
-    Change change4 = insert(repo, ins4);
-
-    Change change5 = insert(repo, newChange(repo));
-
-    assertQuery("intopic:foo");
-    assertQuery("intopic:feature1", change1);
-    assertQuery("intopic:feature2", change4, change3, change2);
-    assertQuery("topic:feature2", change2);
-    assertQuery("intopic:feature2", change4, change3, change2);
-    assertQuery("intopic:fixup", change4);
-    assertQuery("topic:\"\"", change5);
-    assertQuery("intopic:\"\"", change5);
-  }
-
-  @Test
-  public void byTopicRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-
-    ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
-    Change change1 = insert(repo, ins1);
-
-    ChangeInserter ins2 = newChangeWithTopic(repo, "Cherrypick-feature1");
-    Change change2 = insert(repo, ins2);
-
-    ChangeInserter ins3 = newChangeWithTopic(repo, "feature1-fixup");
-    Change change3 = insert(repo, ins3);
-
-    assertQuery("intopic:^feature1.*", change3, change1);
-    assertQuery("intopic:{^.*feature1$}", change2, change1);
-  }
-
-  @Test
-  public void byMessageExact() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit1 = repo.parseBody(repo.commit().message("one").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    RevCommit commit2 = repo.parseBody(repo.commit().message("two").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
-
-    assertQuery("message:foo");
-    assertQuery("message:one", change1);
-    assertQuery("message:two", change2);
-  }
-
-  @Test
-  public void fullTextWithNumbers() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit1 = repo.parseBody(repo.commit().message("12345 67890").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    RevCommit commit2 = repo.parseBody(repo.commit().message("12346 67891").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
-
-    assertQuery("message:1234");
-    assertQuery("message:12345", change1);
-    assertQuery("message:12346", change2);
-  }
-
-  @Test
-  public void byLabel() throws Exception {
-    accountManager.authenticate(AuthRequest.forUser("anotheruser"));
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins = newChange(repo, null, null, null, null, false);
-    ChangeInserter ins2 = newChange(repo, null, null, null, null, false);
-    ChangeInserter ins3 = newChange(repo, null, null, null, null, false);
-    ChangeInserter ins4 = newChange(repo, null, null, null, null, false);
-    ChangeInserter ins5 = newChange(repo, null, null, null, null, false);
-
-    Change reviewMinus2Change = insert(repo, ins);
-    gApi.changes().id(reviewMinus2Change.getId().get()).current().review(ReviewInput.reject());
-
-    Change reviewMinus1Change = insert(repo, ins2);
-    gApi.changes().id(reviewMinus1Change.getId().get()).current().review(ReviewInput.dislike());
-
-    Change noLabelChange = insert(repo, ins3);
-
-    Change reviewPlus1Change = insert(repo, ins4);
-    gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
-
-    Change reviewPlus2Change = insert(repo, ins5);
-    gApi.changes().id(reviewPlus2Change.getId().get()).current().review(ReviewInput.approve());
-
-    Map<String, Short> m =
-        gApi.changes()
-            .id(reviewPlus1Change.getId().get())
-            .reviewer(user.getAccountId().toString())
-            .votes();
-    assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 1));
-
-    Map<Integer, Change> changes = new LinkedHashMap<>(5);
-    changes.put(2, reviewPlus2Change);
-    changes.put(1, reviewPlus1Change);
-    changes.put(0, noLabelChange);
-    changes.put(-1, reviewMinus1Change);
-    changes.put(-2, reviewMinus2Change);
-
-    assertQuery("label:Code-Review=-2", reviewMinus2Change);
-    assertQuery("label:Code-Review-2", reviewMinus2Change);
-    assertQuery("label:Code-Review=-1", reviewMinus1Change);
-    assertQuery("label:Code-Review-1", reviewMinus1Change);
-    assertQuery("label:Code-Review=0", noLabelChange);
-    assertQuery("label:Code-Review=+1", reviewPlus1Change);
-    assertQuery("label:Code-Review=1", reviewPlus1Change);
-    assertQuery("label:Code-Review+1", reviewPlus1Change);
-    assertQuery("label:Code-Review=+2", reviewPlus2Change);
-    assertQuery("label:Code-Review=2", reviewPlus2Change);
-    assertQuery("label:Code-Review+2", reviewPlus2Change);
-
-    assertQuery("label:Code-Review>-3", codeReviewInRange(changes, -2, 2));
-    assertQuery("label:Code-Review>=-2", codeReviewInRange(changes, -2, 2));
-    assertQuery("label:Code-Review>-2", codeReviewInRange(changes, -1, 2));
-    assertQuery("label:Code-Review>=-1", codeReviewInRange(changes, -1, 2));
-    assertQuery("label:Code-Review>-1", codeReviewInRange(changes, 0, 2));
-    assertQuery("label:Code-Review>=0", codeReviewInRange(changes, 0, 2));
-    assertQuery("label:Code-Review>0", codeReviewInRange(changes, 1, 2));
-    assertQuery("label:Code-Review>=1", codeReviewInRange(changes, 1, 2));
-    assertQuery("label:Code-Review>1", reviewPlus2Change);
-    assertQuery("label:Code-Review>=2", reviewPlus2Change);
-    assertQuery("label:Code-Review>2");
-
-    assertQuery("label:Code-Review<=2", codeReviewInRange(changes, -2, 2));
-    assertQuery("label:Code-Review<2", codeReviewInRange(changes, -2, 1));
-    assertQuery("label:Code-Review<=1", codeReviewInRange(changes, -2, 1));
-    assertQuery("label:Code-Review<1", codeReviewInRange(changes, -2, 0));
-    assertQuery("label:Code-Review<=0", codeReviewInRange(changes, -2, 0));
-    assertQuery("label:Code-Review<0", codeReviewInRange(changes, -2, -1));
-    assertQuery("label:Code-Review<=-1", codeReviewInRange(changes, -2, -1));
-    assertQuery("label:Code-Review<-1", reviewMinus2Change);
-    assertQuery("label:Code-Review<=-2", reviewMinus2Change);
-    assertQuery("label:Code-Review<-2");
-
-    assertQuery("label:Code-Review=+1,anotheruser");
-    assertQuery("label:Code-Review=+1,user", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,user=user", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,Administrators", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,group=Administrators", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,user=owner", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,owner", reviewPlus1Change);
-    assertQuery("label:Code-Review=+2,owner", reviewPlus2Change);
-    assertQuery("label:Code-Review=-2,owner", reviewMinus2Change);
-  }
-
-  @Test
-  public void byLabelNotOwner() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins = newChange(repo, null, null, null, null, false);
-    Account.Id user1 = createAccount("user1");
-
-    Change reviewPlus1Change = insert(repo, ins);
-
-    // post a review with user1
-    requestContext.setContext(newRequestContext(user1));
-    gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
-
-    assertQuery("label:Code-Review=+1,user=user1", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,owner");
-  }
-
-  private Change[] codeReviewInRange(Map<Integer, Change> changes, int start, int end) {
-    int size = 0;
-    Change[] range = new Change[end - start + 1];
-    for (int i : changes.keySet()) {
-      if (i >= start && i <= end) {
-        range[size] = changes.get(i);
-        size++;
-      }
-    }
-    return range;
-  }
-
-  private String createGroup(String name, String owner) throws Exception {
-    GroupInput in = new GroupInput();
-    in.name = name;
-    in.ownerId = owner;
-    gApi.groups().create(in);
-    return name;
-  }
-
-  private Account.Id createAccount(String name) throws Exception {
-    return accountManager.authenticate(AuthRequest.forUser(name)).getAccountId();
-  }
-
-  @Test
-  public void byLabelGroup() throws Exception {
-    Account.Id user1 = createAccount("user1");
-    createAccount("user2");
-    TestRepository<Repo> repo = createProject("repo");
-
-    // create group and add users
-    String g1 = createGroup("group1", "Administrators");
-    String g2 = createGroup("group2", "Administrators");
-    gApi.groups().id(g1).addMembers("user1");
-    gApi.groups().id(g2).addMembers("user2");
-
-    // create a change
-    Change change1 = insert(repo, newChange(repo), user1);
-
-    // post a review with user1
-    requestContext.setContext(newRequestContext(user1));
-    gApi.changes()
-        .id(change1.getId().get())
-        .current()
-        .review(new ReviewInput().label("Code-Review", 1));
-
-    // verify that query with user1 will return results.
-    requestContext.setContext(newRequestContext(userId));
-    assertQuery("label:Code-Review=+1,group1", change1);
-    assertQuery("label:Code-Review=+1,group=group1", change1);
-    assertQuery("label:Code-Review=+1,user=user1", change1);
-    assertQuery("label:Code-Review=+1,user=user2");
-    assertQuery("label:Code-Review=+1,group=group2");
-  }
-
-  @Test
-  public void limit() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change last = null;
-    int n = 5;
-    for (int i = 0; i < n; i++) {
-      last = insert(repo, newChange(repo));
-    }
-
-    for (int i = 1; i <= n + 2; i++) {
-      int expectedSize;
-      Boolean expectedMoreChanges;
-      if (i < n) {
-        expectedSize = i;
-        expectedMoreChanges = true;
-      } else {
-        expectedSize = n;
-        expectedMoreChanges = null;
-      }
-      String q = "status:new limit:" + i;
-      List<ChangeInfo> results = newQuery(q).get();
-      assertThat(results).named(q).hasSize(expectedSize);
-      assertThat(results.get(results.size() - 1)._moreChanges)
-          .named(q)
-          .isEqualTo(expectedMoreChanges);
-      assertThat(results.get(0)._number).isEqualTo(last.getId().get());
-    }
-  }
-
-  @Test
-  public void start() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    List<Change> changes = new ArrayList<>();
-    for (int i = 0; i < 2; i++) {
-      changes.add(insert(repo, newChange(repo)));
-    }
-
-    assertQuery("status:new", changes.get(1), changes.get(0));
-    assertQuery(newQuery("status:new").withStart(1), changes.get(0));
-    assertQuery(newQuery("status:new").withStart(2));
-    assertQuery(newQuery("status:new").withStart(3));
-  }
-
-  @Test
-  public void startWithLimit() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    List<Change> changes = new ArrayList<>();
-    for (int i = 0; i < 3; i++) {
-      changes.add(insert(repo, newChange(repo)));
-    }
-
-    assertQuery("status:new limit:2", changes.get(2), changes.get(1));
-    assertQuery(newQuery("status:new limit:2").withStart(1), changes.get(1), changes.get(0));
-    assertQuery(newQuery("status:new limit:2").withStart(2), changes.get(0));
-    assertQuery(newQuery("status:new limit:2").withStart(3));
-  }
-
-  @Test
-  public void maxPages() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
-
-    QueryRequest query = newQuery("status:new").withLimit(10);
-    assertQuery(query, change);
-    assertQuery(query.withStart(1));
-    assertQuery(query.withStart(99));
-    assertThatQueryException(query.withStart(100))
-        .hasMessageThat()
-        .isEqualTo("Cannot go beyond page 10 of results");
-    assertQuery(query.withLimit(100).withStart(100));
-  }
-
-  @Test
-  public void updateOrder() throws Exception {
-    resetTimeWithClockStep(2, MINUTES);
-    TestRepository<Repo> repo = createProject("repo");
-    List<ChangeInserter> inserters = new ArrayList<>();
-    List<Change> changes = new ArrayList<>();
-    for (int i = 0; i < 5; i++) {
-      inserters.add(newChange(repo));
-      changes.add(insert(repo, inserters.get(i)));
-    }
-
-    for (int i : ImmutableList.of(2, 0, 1, 4, 3)) {
-      gApi.changes()
-          .id(changes.get(i).getId().get())
-          .current()
-          .review(new ReviewInput().message("modifying " + i));
-    }
-
-    assertQuery(
-        "status:new",
-        changes.get(3),
-        changes.get(4),
-        changes.get(1),
-        changes.get(0),
-        changes.get(2));
-  }
-
-  @Test
-  public void updatedOrder() throws Exception {
-    resetTimeWithClockStep(1, SECONDS);
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChange(repo);
-    Change change1 = insert(repo, ins1);
-    Change change2 = insert(repo, newChange(repo));
-
-    assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
-    assertQuery("status:new", change2, change1);
-
-    gApi.changes().id(change1.getId().get()).topic("new-topic");
-    change1 = notesFactory.create(db, change1.getProject(), change1.getId()).getChange();
-
-    assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
-    assertThat(lastUpdatedMs(change1) - lastUpdatedMs(change2))
-        .isAtLeast(MILLISECONDS.convert(1, SECONDS));
-
-    // change1 moved to the top.
-    assertQuery("status:new", change1, change2);
-  }
-
-  @Test
-  public void filterOutMoreThanOnePageOfResults() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo), userId);
-    Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
-    for (int i = 0; i < 5; i++) {
-      insert(repo, newChange(repo), user2);
-    }
-
-    assertQuery("status:new ownerin:Administrators", change);
-    assertQuery("status:new ownerin:Administrators limit:2", change);
-  }
-
-  @Test
-  public void filterOutAllResults() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
-    for (int i = 0; i < 5; i++) {
-      insert(repo, newChange(repo), user2);
-    }
-
-    assertQuery("status:new ownerin:Administrators");
-    assertQuery("status:new ownerin:Administrators limit:2");
-  }
-
-  @Test
-  public void byFileExact() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit =
-        repo.parseBody(
-            repo.commit()
-                .message("one")
-                .add("dir/file1", "contents1")
-                .add("dir/file2", "contents2")
-                .create());
-    Change change = insert(repo, newChangeForCommit(repo, commit));
-
-    assertQuery("file:file");
-    assertQuery("file:dir", change);
-    assertQuery("file:file1", change);
-    assertQuery("file:file2", change);
-    assertQuery("file:dir/file1", change);
-    assertQuery("file:dir/file2", change);
-  }
-
-  @Test
-  public void byFileRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit =
-        repo.parseBody(
-            repo.commit()
-                .message("one")
-                .add("dir/file1", "contents1")
-                .add("dir/file2", "contents2")
-                .create());
-    Change change = insert(repo, newChangeForCommit(repo, commit));
-
-    assertQuery("file:.*file.*");
-    assertQuery("file:^file.*"); // Whole path only.
-    assertQuery("file:^dir.file.*", change);
-  }
-
-  @Test
-  public void byPathExact() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit =
-        repo.parseBody(
-            repo.commit()
-                .message("one")
-                .add("dir/file1", "contents1")
-                .add("dir/file2", "contents2")
-                .create());
-    Change change = insert(repo, newChangeForCommit(repo, commit));
-
-    assertQuery("path:file");
-    assertQuery("path:dir");
-    assertQuery("path:file1");
-    assertQuery("path:file2");
-    assertQuery("path:dir/file1", change);
-    assertQuery("path:dir/file2", change);
-  }
-
-  @Test
-  public void byPathRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit =
-        repo.parseBody(
-            repo.commit()
-                .message("one")
-                .add("dir/file1", "contents1")
-                .add("dir/file2", "contents2")
-                .create());
-    Change change = insert(repo, newChangeForCommit(repo, commit));
-
-    assertQuery("path:.*file.*");
-    assertQuery("path:^dir.file.*", change);
-  }
-
-  @Test
-  public void byComment() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins = newChange(repo);
-    Change change = insert(repo, ins);
-
-    ReviewInput input = new ReviewInput();
-    input.message = "toplevel";
-    ReviewInput.CommentInput commentInput = new ReviewInput.CommentInput();
-    commentInput.line = 1;
-    commentInput.message = "inline";
-    input.comments =
-        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
-            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(commentInput));
-    gApi.changes().id(change.getId().get()).current().review(input);
-
-    Map<String, List<CommentInfo>> comments =
-        gApi.changes().id(change.getId().get()).current().comments();
-    assertThat(comments).hasSize(1);
-    CommentInfo comment = Iterables.getOnlyElement(comments.get(Patch.COMMIT_MSG));
-    assertThat(comment.message).isEqualTo(commentInput.message);
-    ChangeMessageInfo lastMsg =
-        Iterables.getLast(gApi.changes().id(change.getId().get()).get().messages, null);
-    assertThat(lastMsg.message).isEqualTo("Patch Set 1:\n\n(1 comment)\n\n" + input.message);
-
-    assertQuery("comment:foo");
-    assertQuery("comment:toplevel", change);
-    assertQuery("comment:inline", change);
-  }
-
-  @Test
-  public void byAge() throws Exception {
-    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
-    resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
-    long startMs = TestTimeUtil.START.getMillis();
-    Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
-    Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
-
-    // Stop time so age queries use the same endpoint.
-    TestTimeUtil.setClockStep(0, MILLISECONDS);
-    TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
-    long nowMs = TimeUtil.nowMs();
-
-    assertThat(lastUpdatedMs(change2) - lastUpdatedMs(change1)).isEqualTo(thirtyHoursInMs);
-    assertThat(nowMs - lastUpdatedMs(change2)).isEqualTo(thirtyHoursInMs);
-    assertThat(TimeUtil.nowMs()).isEqualTo(nowMs);
-
-    assertQuery("-age:1d");
-    assertQuery("-age:" + (30 * 60 - 1) + "m");
-    assertQuery("-age:2d", change2);
-    assertQuery("-age:3d", change2, change1);
-    assertQuery("age:3d");
-    assertQuery("age:2d", change1);
-    assertQuery("age:1d", change2, change1);
-  }
-
-  @Test
-  public void byBefore() throws Exception {
-    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
-    resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
-    long startMs = TestTimeUtil.START.getMillis();
-    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);
-
-    assertQuery("before:2009-09-29");
-    assertQuery("before:2009-09-30");
-    assertQuery("before:\"2009-09-30 16:59:00 -0400\"");
-    assertQuery("before:\"2009-09-30 20:59:00 -0000\"");
-    assertQuery("before:\"2009-09-30 20:59:00\"");
-    assertQuery("before:\"2009-09-30 17:02:00 -0400\"", change1);
-    assertQuery("before:\"2009-10-01 21:02:00 -0000\"", change1);
-    assertQuery("before:\"2009-10-01 21:02:00\"", change1);
-    assertQuery("before:2009-10-01", change1);
-    assertQuery("before:2009-10-03", change2, change1);
-  }
-
-  @Test
-  public void byAfter() throws Exception {
-    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
-    resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
-    long startMs = TestTimeUtil.START.getMillis();
-    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);
-
-    assertQuery("after:2009-10-03");
-    assertQuery("after:\"2009-10-01 20:59:59 -0400\"", change2);
-    assertQuery("after:\"2009-10-01 20:59:59 -0000\"", change2);
-    assertQuery("after:2009-10-01", change2);
-    assertQuery("after:2009-09-30", change2, change1);
-  }
-
-  @Test
-  public void bySize() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-
-    // added = 3, deleted = 0, delta = 3
-    RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "foo\n\foo\nfoo").create());
-    // added = 0, deleted = 2, delta = 2
-    RevCommit commit2 = repo.parseBody(repo.commit().parent(commit1).add("file1", "foo").create());
-
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
-
-    assertQuery("added:>4");
-    assertQuery("-added:<=4");
-
-    assertQuery("added:3", change1);
-    assertQuery("-(added:<3 OR added>3)", change1);
-
-    assertQuery("added:>2", change1);
-    assertQuery("-added:<=2", change1);
-
-    assertQuery("added:>=3", change1);
-    assertQuery("-added:<3", change1);
-
-    assertQuery("added:<1", change2);
-    assertQuery("-added:>=1", change2);
-
-    assertQuery("added:<=0", change2);
-    assertQuery("-added:>0", change2);
-
-    assertQuery("deleted:>3");
-    assertQuery("-deleted:<=3");
-
-    assertQuery("deleted:2", change2);
-    assertQuery("-(deleted:<2 OR deleted>2)", change2);
-
-    assertQuery("deleted:>1", change2);
-    assertQuery("-deleted:<=1", change2);
-
-    assertQuery("deleted:>=2", change2);
-    assertQuery("-deleted:<2", change2);
-
-    assertQuery("deleted:<1", change1);
-    assertQuery("-deleted:>=1", change1);
-
-    assertQuery("deleted:<=0", change1);
-
-    for (String str : Lists.newArrayList("delta", "size")) {
-      assertQuery(str + ":<2");
-      assertQuery(str + ":3", change1);
-      assertQuery(str + ":>2", change1);
-      assertQuery(str + ":>=3", change1);
-      assertQuery(str + ":<3", change2);
-      assertQuery(str + ":<=2", change2);
-    }
-  }
-
-  private List<Change> setUpHashtagChanges() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-
-    HashtagsInput in = new HashtagsInput();
-    in.add = ImmutableSet.of("foo");
-    gApi.changes().id(change1.getId().get()).setHashtags(in);
-
-    in.add = ImmutableSet.of("foo", "bar", "a tag");
-    gApi.changes().id(change2.getId().get()).setHashtags(in);
-
-    return ImmutableList.of(change1, change2);
-  }
-
-  @Test
-  public void byHashtagWithNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    List<Change> changes = setUpHashtagChanges();
-    assertQuery("hashtag:foo", changes.get(1), changes.get(0));
-    assertQuery("hashtag:bar", changes.get(1));
-    assertQuery("hashtag:\"a tag\"", changes.get(1));
-    assertQuery("hashtag:\"a tag \"", changes.get(1));
-    assertQuery("hashtag:\" a tag \"", changes.get(1));
-    assertQuery("hashtag:\"#a tag\"", changes.get(1));
-    assertQuery("hashtag:\"# #a tag\"", changes.get(1));
-  }
-
-  @Test
-  public void byHashtagWithoutNoteDb() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-
-    notesMigration.setWriteChanges(true);
-    notesMigration.setReadChanges(true);
-    db.close();
-    db = schemaFactory.open();
-    List<Change> changes;
-    try {
-      changes = setUpHashtagChanges();
-      notesMigration.setWriteChanges(false);
-      notesMigration.setReadChanges(false);
-    } finally {
-      db.close();
-    }
-    db = schemaFactory.open();
-    for (Change c : changes) {
-      indexer.index(db, c); // Reindex without hashtag field.
-    }
-    assertQuery("hashtag:foo");
-    assertQuery("hashtag:bar");
-    assertQuery("hashtag:\" bar \"");
-    assertQuery("hashtag:\"a tag\"");
-    assertQuery("hashtag:\" a tag \"");
-    assertQuery("hashtag:#foo");
-    assertQuery("hashtag:\"# #foo\"");
-  }
-
-  @Test
-  public void byDefault() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-
-    Change change1 = insert(repo, newChange(repo));
-
-    RevCommit commit2 = repo.parseBody(repo.commit().message("foosubject").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
-
-    RevCommit commit3 = repo.parseBody(repo.commit().add("Foo.java", "foo contents").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
-
-    ChangeInserter ins4 = newChange(repo);
-    Change change4 = insert(repo, ins4);
-    ReviewInput ri4 = new ReviewInput();
-    ri4.message = "toplevel";
-    ri4.labels = ImmutableMap.<String, Short>of("Code-Review", (short) 1);
-    gApi.changes().id(change4.getId().get()).current().review(ri4);
-
-    ChangeInserter ins5 = newChangeWithTopic(repo, "feature5");
-    Change change5 = insert(repo, ins5);
-
-    Change change6 = insert(repo, newChangeForBranch(repo, "branch6"));
-
-    assertQuery(change1.getId().get(), change1);
-    assertQuery(ChangeTriplet.format(change1), change1);
-    assertQuery("foosubject", change2);
-    assertQuery("Foo.java", change3);
-    assertQuery("Code-Review+1", change4);
-    assertQuery("toplevel", change4);
-    assertQuery("feature5", change5);
-    assertQuery("branch6", change6);
-    assertQuery("refs/heads/branch6", change6);
-
-    Change[] expected = new Change[] {change6, change5, change4, change3, change2, change1};
-    assertQuery("user@example.com", expected);
-    assertQuery("repo", expected);
-  }
-
-  @Test
-  public void byDefaultWithCommitPrefix() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit = repo.parseBody(repo.commit().message("message").create());
-    Change change = insert(repo, newChangeForCommit(repo, commit));
-
-    assertQuery(commit.getId().getName().substring(0, 6), change);
-  }
-
-  @Test
-  public void byCommentBy() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-
-    int user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId().get();
-
-    ReviewInput input = new ReviewInput();
-    input.message = "toplevel";
-    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
-    comment.line = 1;
-    comment.message = "inline";
-    input.comments =
-        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
-            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
-    gApi.changes().id(change1.getId().get()).current().review(input);
-
-    input = new ReviewInput();
-    input.message = "toplevel";
-    gApi.changes().id(change2.getId().get()).current().review(input);
-
-    assertQuery("commentby:" + userId.get(), change2, change1);
-    assertQuery("commentby:" + user2);
-  }
-
-  @Test
-  public void byDraftBy() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-
-    DraftInput in = new DraftInput();
-    in.line = 1;
-    in.message = "nit: trailing whitespace";
-    in.path = Patch.COMMIT_MSG;
-    gApi.changes().id(change1.getId().get()).current().createDraft(in);
-
-    in = new DraftInput();
-    in.line = 2;
-    in.message = "nit: point in the end of the statement";
-    in.path = Patch.COMMIT_MSG;
-    gApi.changes().id(change2.getId().get()).current().createDraft(in);
-
-    int user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId().get();
-
-    assertQuery("draftby:" + userId.get(), change2, change1);
-    assertQuery("draftby:" + user2);
-  }
-
-  @Test
-  public void byDraftByExcludesZombieDrafts() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    Project.NameKey project = new Project.NameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
-    Change change = insert(repo, newChange(repo));
-    Change.Id id = change.getId();
-
-    DraftInput in = new DraftInput();
-    in.line = 1;
-    in.message = "nit: trailing whitespace";
-    in.path = Patch.COMMIT_MSG;
-    gApi.changes().id(id.get()).current().createDraft(in);
-
-    assertQuery("draftby:" + userId, change);
-    assertQuery("commentby:" + userId);
-
-    TestRepository<Repo> allUsers = new TestRepository<>(repoManager.openRepository(allUsersName));
-
-    Ref draftsRef = allUsers.getRepository().exactRef(RefNames.refsDraftComments(id, userId));
-    assertThat(draftsRef).isNotNull();
-
-    ReviewInput rin = ReviewInput.dislike();
-    rin.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
-    gApi.changes().id(id.get()).current().review(rin);
-
-    assertQuery("draftby:" + userId);
-    assertQuery("commentby:" + userId, change);
-    assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNull();
-
-    // Re-add drafts ref and ensure it gets filtered out during indexing.
-    allUsers.update(draftsRef.getName(), draftsRef.getObjectId());
-    assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNotNull();
-
-    if (PrimaryStorage.of(change) == PrimaryStorage.REVIEW_DB
-        && !notesMigration.disableChangeReviewDb()) {
-      // Record draft ref in noteDbState as well.
-      ReviewDb db = ReviewDbUtil.unwrapDb(this.db);
-      change = db.changes().get(id);
-      NoteDbChangeState.applyDelta(
-          change,
-          NoteDbChangeState.Delta.create(
-              id, Optional.empty(), ImmutableMap.of(userId, draftsRef.getObjectId())));
-      db.changes().update(Collections.singleton(change));
-    }
-
-    indexer.index(db, project, id);
-    assertQuery("draftby:" + userId);
-  }
-
-  @Test
-  public void byStarredBy() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
-
-    gApi.accounts().self().starChange(change1.getId().toString());
-    gApi.accounts().self().starChange(change2.getId().toString());
-
-    int user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId().get();
-
-    assertQuery("starredby:self", change2, change1);
-    assertQuery("starredby:" + user2);
-  }
-
-  @Test
-  public void byStar() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    Change change3 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    Change change4 = insert(repo, newChange(repo));
-
-    gApi.accounts()
-        .self()
-        .setStars(
-            change1.getId().toString(),
-            new StarsInput(new HashSet<>(Arrays.asList("red", "blue"))));
-    gApi.accounts()
-        .self()
-        .setStars(
-            change2.getId().toString(),
-            new StarsInput(
-                new HashSet<>(Arrays.asList(StarredChangesUtil.DEFAULT_LABEL, "green", "blue"))));
-
-    gApi.accounts()
-        .self()
-        .setStars(
-            change4.getId().toString(), new StarsInput(new HashSet<>(Arrays.asList("ignore"))));
-
-    // check labeled stars
-    assertQuery("star:red", change1);
-    assertQuery("star:blue", change2, change1);
-    assertQuery("has:stars", change4, change2, change1);
-
-    // check default star
-    assertQuery("has:star", change2);
-    assertQuery("is:starred", change2);
-    assertQuery("starredby:self", change2);
-    assertQuery("star:" + StarredChangesUtil.DEFAULT_LABEL, change2);
-
-    // check ignored
-    assertQuery("is:ignored", change4);
-    assertQuery("-is:ignored", change3, change2, change1);
-  }
-
-  @Test
-  public void byIgnore() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
-    Change change1 = insert(repo, newChange(repo), user2);
-    Change change2 = insert(repo, newChange(repo), user2);
-
-    gApi.changes().id(change1.getId().toString()).ignore(true);
-    assertQuery("is:ignored", change1);
-    assertQuery("-is:ignored", change2);
-
-    gApi.changes().id(change1.getId().toString()).ignore(false);
-    assertQuery("is:ignored");
-    assertQuery("-is:ignored", change2, change1);
-  }
-
-  @Test
-  public void byFrom() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-
-    Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
-
-    ReviewInput input = new ReviewInput();
-    input.message = "toplevel";
-    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
-    comment.line = 1;
-    comment.message = "inline";
-    input.comments =
-        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
-            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
-    gApi.changes().id(change2.getId().get()).current().review(input);
-
-    assertQuery("from:" + userId.get(), change2, change1);
-    assertQuery("from:" + user2, change2);
-  }
-
-  @Test
-  public void conflicts() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit1 =
-        repo.parseBody(
-            repo.commit()
-                .add("file1", "contents1")
-                .add("dir/file2", "contents2")
-                .add("dir/file3", "contents3")
-                .create());
-    RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents1").create());
-    RevCommit commit3 =
-        repo.parseBody(repo.commit().add("dir/file2", "contents2 different").create());
-    RevCommit commit4 = repo.parseBody(repo.commit().add("file4", "contents4").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
-    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
-
-    assertQuery("conflicts:" + change1.getId().get(), change3);
-    assertQuery("conflicts:" + change2.getId().get());
-    assertQuery("conflicts:" + change3.getId().get(), change1);
-    assertQuery("conflicts:" + change4.getId().get());
-  }
-
-  @Test
-  public void reviewedBy() throws Exception {
-    resetTimeWithClockStep(2, MINUTES);
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
-
-    gApi.changes().id(change1.getId().get()).current().review(new ReviewInput().message("comment"));
-
-    Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
-    requestContext.setContext(newRequestContext(user2));
-
-    gApi.changes().id(change2.getId().get()).current().review(new ReviewInput().message("comment"));
-
-    PatchSet.Id ps3_1 = change3.currentPatchSetId();
-    change3 = newPatchSet(repo, change3);
-    assertThat(change3.currentPatchSetId()).isNotEqualTo(ps3_1);
-    // Response to previous patch set still counts as reviewing.
-    gApi.changes()
-        .id(change3.getId().get())
-        .revision(ps3_1.get())
-        .review(new ReviewInput().message("comment"));
-
-    List<ChangeInfo> actual;
-    actual = assertQuery(newQuery("is:reviewed").withOption(REVIEWED), change3, change2);
-    assertThat(actual.get(0).reviewed).isTrue();
-    assertThat(actual.get(1).reviewed).isTrue();
-
-    actual = assertQuery(newQuery("-is:reviewed").withOption(REVIEWED), change1);
-    assertThat(actual.get(0).reviewed).isNull();
-
-    actual = assertQuery("reviewedby:" + userId.get());
-
-    actual =
-        assertQuery(newQuery("reviewedby:" + user2.get()).withOption(REVIEWED), change3, change2);
-    assertThat(actual.get(0).reviewed).isTrue();
-    assertThat(actual.get(1).reviewed).isTrue();
-  }
-
-  @Test
-  public void reviewerAndCc() throws Exception {
-    Account.Id user1 = createAccount("user1");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
-
-    AddReviewerInput rin = new AddReviewerInput();
-    rin.reviewer = user1.toString();
-    rin.state = ReviewerState.REVIEWER;
-    gApi.changes().id(change1.getId().get()).addReviewer(rin);
-
-    rin = new AddReviewerInput();
-    rin.reviewer = user1.toString();
-    rin.state = ReviewerState.CC;
-    gApi.changes().id(change2.getId().get()).addReviewer(rin);
-
-    if (notesMigration.readChanges()) {
-      assertQuery("reviewer:" + user1, change1);
-      assertQuery("cc:" + user1, change2);
-    } else {
-      assertQuery("reviewer:" + user1, change2, change1);
-      assertQuery("cc:" + user1);
-    }
-  }
-
-  @Test
-  public void reviewerAndCcByEmail() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    Project.NameKey project = new Project.NameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
-    ConfigInput conf = new ConfigInput();
-    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
-    gApi.projects().name(project.get()).config(conf);
-
-    String userByEmail = "un.registered@reviewer.com";
-    String userByEmailWithName = "John Doe <" + userByEmail + ">";
-
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
-
-    AddReviewerInput rin = new AddReviewerInput();
-    rin.reviewer = userByEmailWithName;
-    rin.state = ReviewerState.REVIEWER;
-    gApi.changes().id(change1.getId().get()).addReviewer(rin);
-
-    rin = new AddReviewerInput();
-    rin.reviewer = userByEmailWithName;
-    rin.state = ReviewerState.CC;
-    gApi.changes().id(change2.getId().get()).addReviewer(rin);
-
-    if (getSchemaVersion() >= 41) {
-      assertQuery("reviewer:\"" + userByEmailWithName + "\"", change1);
-      assertQuery("cc:\"" + userByEmailWithName + "\"", change2);
-
-      // Omitting the name:
-      assertQuery("reviewer:\"" + userByEmail + "\"", change1);
-      assertQuery("cc:\"" + userByEmail + "\"", change2);
-    } else {
-      assertMissingField(ChangeField.REVIEWER_BY_EMAIL);
-
-      assertFailingQuery(
-          "reviewer:\"" + userByEmailWithName + "\"", "User " + userByEmailWithName + " not found");
-      assertFailingQuery(
-          "cc:\"" + userByEmailWithName + "\"", "User " + userByEmailWithName + " not found");
-
-      // Omitting the name:
-      assertFailingQuery("reviewer:\"" + userByEmail + "\"", "User " + userByEmail + " not found");
-      assertFailingQuery("cc:\"" + userByEmail + "\"", "User " + userByEmail + " not found");
-    }
-  }
-
-  @Test
-  public void reviewerAndCcByEmailWithQueryForDifferentUser() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-
-    Project.NameKey project = new Project.NameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
-    ConfigInput conf = new ConfigInput();
-    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
-    gApi.projects().name(project.get()).config(conf);
-
-    String userByEmail = "John Doe <un.registered@reviewer.com>";
-
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
-
-    AddReviewerInput rin = new AddReviewerInput();
-    rin.reviewer = userByEmail;
-    rin.state = ReviewerState.REVIEWER;
-    gApi.changes().id(change1.getId().get()).addReviewer(rin);
-
-    rin = new AddReviewerInput();
-    rin.reviewer = userByEmail;
-    rin.state = ReviewerState.CC;
-    gApi.changes().id(change2.getId().get()).addReviewer(rin);
-
-    if (getSchemaVersion() >= 41) {
-      assertQuery("reviewer:\"someone@example.com\"");
-      assertQuery("cc:\"someone@example.com\"");
-    } else {
-      assertMissingField(ChangeField.REVIEWER_BY_EMAIL);
-
-      String someoneEmail = "someone@example.com";
-      assertFailingQuery(
-          "reviewer:\"" + someoneEmail + "\"", "User " + someoneEmail + " not found");
-      assertFailingQuery("cc:\"" + someoneEmail + "\"", "User " + someoneEmail + " not found");
-    }
-  }
-
-  @Test
-  public void submitRecords() throws Exception {
-    Account.Id user1 = createAccount("user1");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-
-    gApi.changes().id(change1.getId().get()).current().review(ReviewInput.approve());
-    requestContext.setContext(newRequestContext(user1));
-    gApi.changes().id(change2.getId().get()).current().review(ReviewInput.recommend());
-    requestContext.setContext(newRequestContext(user.getAccountId()));
-
-    assertQuery("is:submittable", change1);
-    assertQuery("-is:submittable", change2);
-    assertQuery("submittable:ok", change1);
-    assertQuery("submittable:not_ready", change2);
-
-    assertQuery("label:CodE-RevieW=ok", change1);
-    assertQuery("label:CodE-RevieW=ok,user=user", change1);
-    assertQuery("label:CodE-RevieW=ok,Administrators", change1);
-    assertQuery("label:CodE-RevieW=ok,group=Administrators", change1);
-    assertQuery("label:CodE-RevieW=ok,owner", change1);
-    assertQuery("label:CodE-RevieW=ok,user1");
-    assertQuery("label:CodE-RevieW=need", change2);
-    // NEED records don't have associated users.
-    assertQuery("label:CodE-RevieW=need,user1");
-    assertQuery("label:CodE-RevieW=need,user");
-  }
-
-  @Test
-  public void hasEdit() throws Exception {
-    Account.Id user1 = createAccount("user1");
-    Account.Id user2 = createAccount("user2");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    String changeId1 = change1.getKey().get();
-    Change change2 = insert(repo, newChange(repo));
-    String changeId2 = change2.getKey().get();
-
-    requestContext.setContext(newRequestContext(user1));
-    assertQuery("has:edit");
-    gApi.changes().id(changeId1).edit().create();
-    gApi.changes().id(changeId2).edit().create();
-
-    requestContext.setContext(newRequestContext(user2));
-    assertQuery("has:edit");
-    gApi.changes().id(changeId2).edit().create();
-
-    requestContext.setContext(newRequestContext(user1));
-    assertQuery("has:edit", change2, change1);
-
-    requestContext.setContext(newRequestContext(user2));
-    assertQuery("has:edit", change2);
-  }
-
-  @Test
-  public void byUnresolved() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
-
-    // Change1 has one resolved comment (unresolvedcount = 0)
-    // Change2 has one unresolved comment (unresolvedcount = 1)
-    // Change3 has one resolved comment and one unresolved comment (unresolvedcount = 1)
-    addComment(change1.getChangeId(), "comment 1", false);
-    addComment(change2.getChangeId(), "comment 2", true);
-    addComment(change3.getChangeId(), "comment 3", false);
-    addComment(change3.getChangeId(), "comment 4", true);
-
-    assertQuery("has:unresolved", change3, change2);
-
-    assertQuery("unresolved:0", change1);
-    List<ChangeInfo> changeInfos = assertQuery("unresolved:>=0", change3, change2, change1);
-    assertThat(changeInfos.get(0).unresolvedCommentCount).isEqualTo(1); // Change3
-    assertThat(changeInfos.get(1).unresolvedCommentCount).isEqualTo(1); // Change2
-    assertThat(changeInfos.get(2).unresolvedCommentCount).isEqualTo(0); // Change1
-    assertQuery("unresolved:>0", change3, change2);
-
-    assertQuery("unresolved:<1", change1);
-    assertQuery("unresolved:<=1", change3, change2, change1);
-    assertQuery("unresolved:1", change3, change2);
-    assertQuery("unresolved:>1");
-    assertQuery("unresolved:>=1", change3, change2);
-  }
-
-  @Test
-  public void byCommitsOnBranchNotMerged() throws Exception {
-    TestRepository<Repo> tr = createProject("repo");
-    testByCommitsOnBranchNotMerged(tr, ImmutableSet.of());
-  }
-
-  @Test
-  public void byCommitsOnBranchNotMergedSkipsMissingChanges() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    ObjectId missing =
-        repo.branch(new PatchSet.Id(new Change.Id(987654), 1).toRefName())
-            .commit()
-            .message("No change for this commit")
-            .insertChangeId()
-            .create()
-            .copy();
-    testByCommitsOnBranchNotMerged(repo, ImmutableSet.of(missing));
-  }
-
-  private void testByCommitsOnBranchNotMerged(TestRepository<Repo> repo, Collection<ObjectId> extra)
-      throws Exception {
-    int n = 10;
-    List<String> shas = new ArrayList<>(n + extra.size());
-    extra.forEach(i -> shas.add(i.name()));
-    List<Integer> expectedIds = new ArrayList<>(n);
-    Branch.NameKey dest = null;
-    for (int i = 0; i < n; i++) {
-      ChangeInserter ins = newChange(repo);
-      insert(repo, ins);
-      if (dest == null) {
-        dest = ins.getChange().getDest();
-      }
-      shas.add(ins.getCommitId().name());
-      expectedIds.add(ins.getChange().getId().get());
-    }
-
-    for (int i = 1; i <= 11; i++) {
-      Iterable<ChangeData> cds =
-          queryProvider.get().byCommitsOnBranchNotMerged(repo.getRepository(), db, dest, shas, i);
-      Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
-      String name = "limit " + i;
-      assertThat(ids).named(name).hasSize(n);
-      assertThat(ids).named(name).containsExactlyElementsIn(expectedIds);
-    }
-  }
-
-  @Test
-  public void prepopulatedFields() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
-
-    db = new DisabledReviewDb();
-    requestContext.setContext(newRequestContext(userId));
-    // Use QueryProcessor directly instead of API so we get ChangeDatas back.
-    List<ChangeData> cds =
-        queryProcessorProvider
-            .get()
-            .query(queryBuilder.parse(change.getId().toString()))
-            .entities();
-    assertThat(cds).hasSize(1);
-
-    ChangeData cd = cds.get(0);
-    cd.change();
-    cd.patchSets();
-    cd.currentApprovals();
-    cd.changedLines();
-    cd.reviewedBy();
-    cd.reviewers();
-    cd.unresolvedCommentCount();
-
-    // TODO(dborowitz): Swap out GitRepositoryManager somehow? Will probably be
-    // necessary for NoteDb anyway.
-    cd.isMergeable();
-
-    exception.expect(DisabledReviewDb.Disabled.class);
-    cd.messages();
-  }
-
-  @Test
-  public void prepopulateOnlyRequestedFields() throws Exception {
-    assume().that(notesMigration.readChanges()).isFalse();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
-
-    db = new DisabledReviewDb();
-    requestContext.setContext(newRequestContext(userId));
-    // Use QueryProcessor directly instead of API so we get ChangeDatas back.
-    List<ChangeData> cds =
-        queryProcessorProvider
-            .get()
-            .setRequestedFields(
-                ImmutableSet.of(ChangeField.PATCH_SET.getName(), ChangeField.CHANGE.getName()))
-            .query(queryBuilder.parse(change.getId().toString()))
-            .entities();
-    assertThat(cds).hasSize(1);
-
-    ChangeData cd = cds.get(0);
-    cd.change();
-    cd.patchSets();
-
-    exception.expect(DisabledReviewDb.Disabled.class);
-    cd.currentApprovals();
-  }
-
-  @Test
-  public void reindexIfStale() throws Exception {
-    Account.Id user = createAccount("user");
-    Project.NameKey project = new Project.NameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
-    Change change = insert(repo, newChange(repo));
-    String changeId = change.getKey().get();
-    ChangeNotes notes = notesFactory.create(db, change.getProject(), change.getId());
-    PatchSet ps = psUtil.get(db, notes, change.currentPatchSetId());
-
-    requestContext.setContext(newRequestContext(user));
-    gApi.changes().id(changeId).edit().create();
-    assertQuery("has:edit", change);
-    assertThat(indexer.reindexIfStale(project, change.getId()).get()).isFalse();
-
-    // Delete edit ref behind index's back.
-    RefUpdate ru =
-        repo.getRepository().updateRef(RefNames.refsEdit(user, change.getId(), ps.getId()));
-    ru.setForceUpdate(true);
-    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
-
-    // Index is stale.
-    assertQuery("has:edit", change);
-    assertThat(indexer.reindexIfStale(project, change.getId()).get()).isTrue();
-    assertQuery("has:edit");
-  }
-
-  @Test
-  public void refStateFields() throws Exception {
-    // This test method manages primary storage manually.
-    assume().that(notesMigration.changePrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-    Account.Id user = createAccount("user");
-    Project.NameKey project = new Project.NameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
-    String path = "file";
-    RevCommit commit = repo.parseBody(repo.commit().message("one").add(path, "contents").create());
-    Change change = insert(repo, newChangeForCommit(repo, commit));
-    Change.Id id = change.getId();
-    int c = id.get();
-    String changeId = change.getKey().get();
-    requestContext.setContext(newRequestContext(user));
-
-    // Ensure one of each type of supported ref is present for the change. If
-    // any more refs are added, update this test to reflect them.
-
-    // Edit
-    gApi.changes().id(changeId).edit().create();
-
-    // Star
-    gApi.accounts().self().starChange(change.getId().toString());
-
-    if (notesMigration.readChanges()) {
-      // Robot comment.
-      ReviewInput rin = new ReviewInput();
-      RobotCommentInput rcin = new RobotCommentInput();
-      rcin.robotId = "happyRobot";
-      rcin.robotRunId = "1";
-      rcin.line = 1;
-      rcin.message = "nit: trailing whitespace";
-      rcin.path = path;
-      rin.robotComments = ImmutableMap.of(path, ImmutableList.of(rcin));
-      gApi.changes().id(c).current().review(rin);
-    }
-
-    // Draft.
-    DraftInput din = new DraftInput();
-    din.path = path;
-    din.line = 1;
-    din.message = "draft";
-    gApi.changes().id(c).current().createDraft(din);
-
-    if (notesMigration.readChanges()) {
-      // Force NoteDb primary.
-      change = ReviewDbUtil.unwrapDb(db).changes().get(id);
-      change.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-      ReviewDbUtil.unwrapDb(db).changes().update(Collections.singleton(change));
-      indexer.index(db, change);
-    }
-
-    QueryOptions opts =
-        IndexedChangeQuery.createOptions(indexConfig, 0, 1, StalenessChecker.FIELDS);
-    ChangeData cd = indexes.getSearchIndex().get(id, opts).get();
-
-    String cs = RefNames.shard(c);
-    int u = user.get();
-    String us = RefNames.shard(u);
-
-    List<String> expectedStates =
-        Lists.newArrayList(
-            "repo:refs/users/" + us + "/edit-" + c + "/1",
-            "All-Users:refs/starred-changes/" + cs + "/" + u);
-    if (notesMigration.readChanges()) {
-      expectedStates.add("repo:refs/changes/" + cs + "/meta");
-      expectedStates.add("repo:refs/changes/" + cs + "/robot-comments");
-      expectedStates.add("All-Users:refs/draft-comments/" + cs + "/" + u);
-    }
-    assertThat(
-            cd.getRefStates()
-                .stream()
-                .map(String::new)
-                // Omit SHA-1, we're just concerned with the project/ref names.
-                .map(s -> s.substring(0, s.lastIndexOf(':')))
-                .collect(toList()))
-        .containsExactlyElementsIn(expectedStates);
-
-    List<String> expectedPatterns = Lists.newArrayList("repo:refs/users/*/edit-" + c + "/*");
-    expectedPatterns.add("All-Users:refs/starred-changes/" + cs + "/*");
-    if (notesMigration.readChanges()) {
-      expectedPatterns.add("All-Users:refs/draft-comments/" + cs + "/*");
-    }
-    assertThat(cd.getRefStatePatterns().stream().map(String::new).collect(toList()))
-        .containsExactlyElementsIn(expectedPatterns);
-  }
-
-  @Test
-  public void selfAndMe() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo), userId);
-    insert(repo, newChange(repo));
-    gApi.accounts().self().starChange(change1.getId().toString());
-    gApi.accounts().self().starChange(change2.getId().toString());
-
-    assertQuery("starredby:self", change2, change1);
-    assertQuery("starredby:me", change2, change1);
-  }
-
-  @Test
-  public void defaultFieldWithManyUsers() throws Exception {
-    for (int i = 0; i < ChangeQueryBuilder.MAX_ACCOUNTS_PER_DEFAULT_FIELD * 2; i++) {
-      createAccount("user" + i, "User " + i, "user" + i + "@example.com", true);
-    }
-    assertQuery("us");
-  }
-
-  @Test
-  public void revertOf() throws Exception {
-    if (getSchemaVersion() < 45) {
-      assertMissingField(ChangeField.REVERT_OF);
-      assertFailingQuery(
-          "revertof:1", "'revertof' operator is not supported by change index version");
-      return;
-    }
-
-    TestRepository<Repo> repo = createProject("repo");
-    // Create two commits and revert second commit (initial commit can't be reverted)
-    Change initial = insert(repo, newChange(repo));
-    gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(initial.getChangeId()).current().submit();
-
-    ChangeInfo changeToRevert =
-        gApi.changes().create(new ChangeInput("repo", "master", "commit to revert")).get();
-    gApi.changes().id(changeToRevert.id).current().review(ReviewInput.approve());
-    gApi.changes().id(changeToRevert.id).current().submit();
-
-    ChangeInfo changeThatReverts = gApi.changes().id(changeToRevert.id).revert().get();
-    assertQueryByIds(
-        "revertof:" + changeToRevert._number, new Change.Id(changeThatReverts._number));
-  }
-
-  protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
-    return newChange(repo, null, null, null, null, false);
-  }
-
-  protected ChangeInserter newChangeForCommit(TestRepository<Repo> repo, RevCommit commit)
-      throws Exception {
-    return newChange(repo, commit, null, null, null, false);
-  }
-
-  protected ChangeInserter newChangeForBranch(TestRepository<Repo> repo, String branch)
-      throws Exception {
-    return newChange(repo, null, branch, null, null, false);
-  }
-
-  protected ChangeInserter newChangeWithStatus(TestRepository<Repo> repo, Change.Status status)
-      throws Exception {
-    return newChange(repo, null, null, status, null, false);
-  }
-
-  protected ChangeInserter newChangeWithTopic(TestRepository<Repo> repo, String topic)
-      throws Exception {
-    return newChange(repo, null, null, null, topic, false);
-  }
-
-  protected ChangeInserter newChangeWorkInProgress(TestRepository<Repo> repo) throws Exception {
-    return newChange(repo, null, null, null, null, true);
-  }
-
-  protected ChangeInserter newChange(
-      TestRepository<Repo> repo,
-      @Nullable RevCommit commit,
-      @Nullable String branch,
-      @Nullable Change.Status status,
-      @Nullable String topic,
-      boolean workInProgress)
-      throws Exception {
-    if (commit == null) {
-      commit = repo.parseBody(repo.commit().message("message").create());
-    }
-
-    branch = MoreObjects.firstNonNull(branch, "refs/heads/master");
-    if (!branch.startsWith("refs/heads/")) {
-      branch = "refs/heads/" + branch;
-    }
-
-    Change.Id id = new Change.Id(seq.nextChangeId());
-    ChangeInserter ins =
-        changeFactory
-            .create(id, commit, branch)
-            .setValidate(false)
-            .setStatus(status)
-            .setTopic(topic)
-            .setWorkInProgress(workInProgress);
-    return ins;
-  }
-
-  protected Change insert(TestRepository<Repo> repo, ChangeInserter ins) throws Exception {
-    return insert(repo, ins, null, TimeUtil.nowTs());
-  }
-
-  protected Change insert(TestRepository<Repo> repo, ChangeInserter ins, @Nullable Account.Id owner)
-      throws Exception {
-    return insert(repo, ins, owner, TimeUtil.nowTs());
-  }
-
-  protected Change insert(
-      TestRepository<Repo> repo,
-      ChangeInserter ins,
-      @Nullable Account.Id owner,
-      Timestamp createdOn)
-      throws Exception {
-    Project.NameKey project =
-        new Project.NameKey(repo.getRepository().getDescription().getRepositoryName());
-    Account.Id ownerId = owner != null ? owner : userId;
-    IdentifiedUser user = userFactory.create(ownerId);
-    try (BatchUpdate bu = updateFactory.create(db, project, user, createdOn)) {
-      bu.insertChange(ins);
-      bu.execute();
-      return ins.getChange();
-    }
-  }
-
-  protected Change newPatchSet(TestRepository<Repo> repo, Change c) throws Exception {
-    // Add a new file so the patch set is not a trivial rebase, to avoid default
-    // Code-Review label copying.
-    int n = c.currentPatchSetId().get() + 1;
-    RevCommit commit =
-        repo.parseBody(repo.commit().message("message").add("file" + n, "contents " + n).create());
-
-    PatchSetInserter inserter =
-        patchSetFactory
-            .create(changeNotesFactory.createChecked(db, c), new PatchSet.Id(c.getId(), n), commit)
-            .setNotify(NotifyHandling.NONE)
-            .setFireRevisionCreated(false)
-            .setValidate(false);
-    try (BatchUpdate bu = updateFactory.create(db, c.getProject(), user, TimeUtil.nowTs());
-        ObjectInserter oi = repo.getRepository().newObjectInserter();
-        ObjectReader reader = oi.newReader();
-        RevWalk rw = new RevWalk(reader)) {
-      bu.setRepository(repo.getRepository(), rw, oi);
-      bu.addOp(c.getId(), inserter);
-      bu.execute();
-    }
-
-    return inserter.getChange();
-  }
-
-  protected ThrowableSubject assertThatQueryException(Object query) throws Exception {
-    return assertThatQueryException(newQuery(query));
-  }
-
-  protected ThrowableSubject assertThatQueryException(QueryRequest query) throws Exception {
-    try {
-      query.get();
-      throw new AssertionError("expected BadRequestException for query: " + query);
-    } catch (BadRequestException e) {
-      return assertThat(e);
-    }
-  }
-
-  protected TestRepository<Repo> createProject(String name) throws Exception {
-    gApi.projects().create(name).get();
-    return new TestRepository<>(repoManager.openRepository(new Project.NameKey(name)));
-  }
-
-  protected QueryRequest newQuery(Object query) {
-    return gApi.changes().query(query.toString());
-  }
-
-  protected List<ChangeInfo> assertQuery(Object query, Change... changes) throws Exception {
-    return assertQuery(newQuery(query), changes);
-  }
-
-  protected List<ChangeInfo> assertQueryByIds(Object query, Change.Id... changes) throws Exception {
-    return assertQueryByIds(newQuery(query), changes);
-  }
-
-  protected List<ChangeInfo> assertQuery(QueryRequest query, Change... changes) throws Exception {
-    return assertQueryByIds(
-        query, Arrays.stream(changes).map(Change::getId).toArray(Change.Id[]::new));
-  }
-
-  protected List<ChangeInfo> assertQueryByIds(QueryRequest query, Change.Id... changes)
-      throws Exception {
-    List<ChangeInfo> result = query.get();
-    Iterable<Change.Id> ids = ids(result);
-    assertThat(ids)
-        .named(format(query, ids, changes))
-        .containsExactlyElementsIn(Arrays.asList(changes))
-        .inOrder();
-    return result;
-  }
-
-  private String format(
-      QueryRequest query, Iterable<Change.Id> actualIds, Change.Id... expectedChanges)
-      throws RestApiException {
-    StringBuilder b = new StringBuilder();
-    b.append("query '").append(query.getQuery()).append("' with expected changes ");
-    b.append(format(Arrays.asList(expectedChanges)));
-    b.append(" and result ");
-    b.append(format(actualIds));
-    return b.toString();
-  }
-
-  private String format(Iterable<Change.Id> changeIds) throws RestApiException {
-    return format(changeIds.iterator());
-  }
-
-  private String format(Iterator<Change.Id> changeIds) throws RestApiException {
-    StringBuilder b = new StringBuilder();
-    b.append("[");
-    while (changeIds.hasNext()) {
-      Change.Id id = changeIds.next();
-      ChangeInfo c = gApi.changes().id(id.get()).get();
-      b.append("{")
-          .append(id)
-          .append(" (")
-          .append(c.changeId)
-          .append("), ")
-          .append("dest=")
-          .append(new Branch.NameKey(new Project.NameKey(c.project), c.branch))
-          .append(", ")
-          .append("status=")
-          .append(c.status)
-          .append(", ")
-          .append("lastUpdated=")
-          .append(c.updated.getTime())
-          .append("}");
-      if (changeIds.hasNext()) {
-        b.append(", ");
-      }
-    }
-    b.append("]");
-    return b.toString();
-  }
-
-  protected static Iterable<Change.Id> ids(Change... changes) {
-    return Arrays.stream(changes).map(c -> c.getId()).collect(toList());
-  }
-
-  protected static Iterable<Change.Id> ids(Iterable<ChangeInfo> changes) {
-    return Streams.stream(changes).map(c -> new Change.Id(c._number)).collect(toList());
-  }
-
-  protected static long lastUpdatedMs(Change c) {
-    return c.getLastUpdatedOn().getTime();
-  }
-
-  private void addComment(int changeId, String message, Boolean unresolved) throws Exception {
-    ReviewInput input = new ReviewInput();
-    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
-    comment.line = 1;
-    comment.message = message;
-    comment.unresolved = unresolved;
-    input.comments =
-        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
-            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
-    gApi.changes().id(changeId).current().review(input);
-  }
-
-  private Account.Id createAccount(String username, String fullName, String email, boolean active)
-      throws Exception {
-    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
-      Account.Id id = accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
-      if (email != null) {
-        accountManager.link(id, AuthRequest.forEmail(email));
-      }
-      accountsUpdate
-          .create()
-          .update(
-              id,
-              a -> {
-                a.setFullName(fullName);
-                a.setPreferredEmail(email);
-                a.setActive(active);
-              });
-      return id;
-    }
-  }
-
-  protected void assertMissingField(FieldDef<ChangeData, ?> field) {
-    assertThat(getSchema().hasField(field))
-        .named("schema %s has field %s", getSchemaVersion(), field.getName())
-        .isFalse();
-  }
-
-  protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
-    try {
-      assertQuery(query);
-      fail("expected BadRequestException for query '" + query + "'");
-    } catch (BadRequestException e) {
-      assertThat(e.getMessage()).isEqualTo(expectedMessage);
-    }
-  }
-
-  protected int getSchemaVersion() {
-    return getSchema().getVersion();
-  }
-
-  protected Schema<ChangeData> getSchema() {
-    return indexes.getSearchIndex().getSchema();
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/ChangeDataTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/ChangeDataTest.java
deleted file mode 100644
index def0b08..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.testutil.TestChanges;
-import org.junit.Test;
-
-public class ChangeDataTest {
-  @Test
-  public void setPatchSetsClearsCurrentPatchSet() throws Exception {
-    Project.NameKey project = new Project.NameKey("project");
-    ChangeData cd = ChangeData.createForTest(project, new Change.Id(1), 1);
-    cd.setChange(TestChanges.newChange(project, new Account.Id(1000)));
-    PatchSet curr1 = cd.currentPatchSet();
-    int currId = curr1.getId().get();
-    PatchSet ps1 = new PatchSet(new PatchSet.Id(cd.getId(), currId + 1));
-    PatchSet ps2 = new PatchSet(new PatchSet.Id(cd.getId(), currId + 2));
-    cd.setPatchSets(ImmutableList.of(ps1, ps2));
-    PatchSet curr2 = cd.currentPatchSet();
-    assertThat(curr2).isNotSameAs(curr1);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
deleted file mode 100644
index c1f5c8a..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
-import com.google.gerrit.testutil.IndexVersions;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Test;
-
-public class LuceneQueryChangesTest extends AbstractQueryChangesTest {
-  @ConfigSuite.Configs
-  public static Map<String, Config> againstPreviousIndexVersion() {
-    // the current schema version is already tested by the inherited default config suite
-    List<Integer> schemaVersions = IndexVersions.getWithoutLatest(ChangeSchemaDefinitions.INSTANCE);
-    return IndexVersions.asConfigMap(
-        ChangeSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config luceneConfig = new Config(config);
-    InMemoryModule.setDefaults(luceneConfig);
-    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
-  }
-
-  @Test
-  public void fullTextWithSpecialChars() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit1 = repo.parseBody(repo.commit().message("foo_bar_foo").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    RevCommit commit2 = repo.parseBody(repo.commit().message("one.two.three").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
-
-    assertQuery("message:foo_ba");
-    assertQuery("message:bar", change1);
-    assertQuery("message:foo_bar", change1);
-    assertQuery("message:foo bar", change1);
-    assertQuery("message:two", change2);
-    assertQuery("message:one.two", change2);
-    assertQuery("message:one two", change2);
-  }
-
-  @Test
-  public void byOwnerInvalidQuery() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
-    String nameEmail = user.asIdentifiedUser().getNameEmail();
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("Cannot create full-text query with value: \\");
-    assertQuery("owner: \"" + nameEmail + "\"\\", change1);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
deleted file mode 100644
index e352d8b..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ /dev/null
@@ -1,558 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.group;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.util.stream.Collectors.toList;
-import static org.junit.Assert.fail;
-
-import com.google.common.base.CharMatcher;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.api.accounts.AccountInput;
-import com.google.gerrit.extensions.api.groups.GroupInput;
-import com.google.gerrit.extensions.api.groups.Groups.QueryRequest;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-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.account.GroupCache;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.group.GroupsUpdate;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.ServerInitiated;
-import com.google.gerrit.server.index.group.GroupField;
-import com.google.gerrit.server.index.group.GroupIndexCollection;
-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 AbstractQueryGroupsTest 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 AllProjectsName allProjects;
-
-  @Inject protected GroupCache groupCache;
-
-  @Inject @ServerInitiated protected Provider<GroupsUpdate> groupsUpdateProvider;
-
-  @Inject protected GroupIndexCollection indexes;
-
-  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();
-  }
-
-  @After
-  public void cleanUp() {
-    lifecycle.stop();
-    db.close();
-  }
-
-  protected void setUpDatabase() throws Exception {
-    db = schemaFactory.open();
-    schemaCreator.create(db);
-
-    Account.Id userId =
-        createAccountOutsideRequestContext("user", "User", "user@example.com", true);
-    user = userFactory.create(userId);
-    requestContext.setContext(newRequestContext(userId));
-    currentUserInfo = gApi.accounts().id(userId.get()).get();
-  }
-
-  protected 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();
-    }
-    if (requestContext != null) {
-      requestContext.setContext(null);
-    }
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(schemaFactory);
-  }
-
-  @Test
-  public void byUuid() throws Exception {
-    assertQuery("uuid:6d70856bc40ded50f2585c4c0f7e179f3544a272");
-    assertQuery("uuid:non-existing");
-
-    GroupInfo group = createGroup(name("group"));
-    assertQuery("uuid:" + group.id, group);
-
-    GroupInfo admins = gApi.groups().id("Administrators").get();
-    assertQuery("uuid:" + admins.id, admins);
-  }
-
-  @Test
-  public void byName() throws Exception {
-    assertQuery("name:non-existing");
-
-    GroupInfo group = createGroup(name("Group"));
-    assertQuery("name:" + group.name, group);
-    assertQuery("name:" + group.name.toLowerCase(Locale.US));
-
-    // only exact match
-    GroupInfo groupWithHyphen = createGroup(name("group-with-hyphen"));
-    createGroup(name("group-no-match-with-hyphen"));
-    assertQuery("name:" + groupWithHyphen.name, groupWithHyphen);
-  }
-
-  @Test
-  public void byInname() throws Exception {
-    String namePart = getSanitizedMethodName();
-    namePart = CharMatcher.is('_').removeFrom(namePart);
-
-    GroupInfo group1 = createGroup("group-" + namePart);
-    GroupInfo group2 = createGroup("group-" + namePart + "-2");
-    GroupInfo group3 = createGroup("group-" + namePart + "3");
-    assertQuery("inname:" + namePart, group1, group2, group3);
-    assertQuery("inname:" + namePart.toUpperCase(Locale.US), group1, group2, group3);
-    assertQuery("inname:" + namePart.toLowerCase(Locale.US), group1, group2, group3);
-  }
-
-  @Test
-  public void byDescription() throws Exception {
-    GroupInfo group1 = createGroupWithDescription(name("group1"), "This is a test group.");
-    GroupInfo group2 = createGroupWithDescription(name("group2"), "ANOTHER TEST GROUP.");
-    createGroupWithDescription(name("group3"), "Maintainers of project foo.");
-    assertQuery("description:test", group1, group2);
-
-    assertQuery("description:non-existing");
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("description operator requires a value");
-    assertQuery("description:\"\"");
-  }
-
-  @Test
-  public void byOwner() throws Exception {
-    GroupInfo ownerGroup = createGroup(name("owner-group"));
-    GroupInfo group = createGroupWithOwner(name("group"), ownerGroup);
-    createGroup(name("group2"));
-
-    assertQuery("owner:" + group.id);
-
-    // ownerGroup owns itself
-    assertQuery("owner:" + ownerGroup.id, group, ownerGroup);
-    assertQuery("owner:" + ownerGroup.name, group, ownerGroup);
-  }
-
-  @Test
-  public void byIsVisibleToAll() throws Exception {
-    assertQuery("is:visibletoall");
-
-    GroupInfo groupThatIsVisibleToAll =
-        createGroupThatIsVisibleToAll(name("group-that-is-visible-to-all"));
-    createGroup(name("group"));
-
-    assertQuery("is:visibletoall", groupThatIsVisibleToAll);
-  }
-
-  @Test
-  public void byMember() throws Exception {
-    if (getSchemaVersion() < 4) {
-      assertMissingField(GroupField.MEMBER);
-      assertFailingQuery(
-          "member:someName", "'member' operator is not supported by group index version");
-      return;
-    }
-
-    AccountInfo user1 = createAccount("user1", "User1", "user1@example.com");
-    AccountInfo user2 = createAccount("user2", "User2", "user2@example.com");
-
-    GroupInfo group1 = createGroup(name("group1"), user1);
-    GroupInfo group2 = createGroup(name("group2"), user2);
-    GroupInfo group3 = createGroup(name("group3"), user1);
-
-    assertQuery("member:" + user1.name, group1, group3);
-    assertQuery("member:" + user1.email, group1, group3);
-
-    gApi.groups().id(group3.id).removeMembers(user1.username);
-    gApi.groups().id(group2.id).addMembers(user1.username);
-
-    assertQuery("member:" + user1.name, group1, group2);
-  }
-
-  @Test
-  public void bySubgroups() throws Exception {
-    if (getSchemaVersion() < 4) {
-      assertMissingField(GroupField.SUBGROUP);
-      assertFailingQuery(
-          "subgroup:someGroupName", "'subgroup' operator is not supported by group index version");
-      return;
-    }
-
-    GroupInfo superParentGroup = createGroup(name("superParentGroup"));
-    GroupInfo parentGroup1 = createGroup(name("parentGroup1"));
-    GroupInfo parentGroup2 = createGroup(name("parentGroup2"));
-    GroupInfo subGroup = createGroup(name("subGroup"));
-
-    gApi.groups().id(superParentGroup.id).addGroups(parentGroup1.id, parentGroup2.id);
-    gApi.groups().id(parentGroup1.id).addGroups(subGroup.id);
-    gApi.groups().id(parentGroup2.id).addGroups(subGroup.id);
-
-    assertQuery("subgroup:" + subGroup.id, parentGroup1, parentGroup2);
-    assertQuery("subgroup:" + parentGroup1.id, superParentGroup);
-
-    gApi.groups().id(superParentGroup.id).addGroups(subGroup.id);
-    gApi.groups().id(parentGroup1.id).removeGroups(subGroup.id);
-
-    assertQuery("subgroup:" + subGroup.id, superParentGroup, parentGroup2);
-  }
-
-  @Test
-  public void byDefaultField() throws Exception {
-    GroupInfo group1 = createGroup(name("foo-group"));
-    GroupInfo group2 = createGroup(name("group2"));
-    GroupInfo group3 =
-        createGroupWithDescription(
-            name("group3"), "decription that contains foo and the UUID of group2: " + group2.id);
-
-    assertQuery("non-existing");
-    assertQuery("foo", group1, group3);
-    assertQuery(group2.id, group2, group3);
-  }
-
-  @Test
-  public void withLimit() throws Exception {
-    GroupInfo group1 = createGroup(name("group1"));
-    GroupInfo group2 = createGroup(name("group2"));
-    GroupInfo group3 = createGroup(name("group3"));
-
-    String query = "uuid:" + group1.id + " OR uuid:" + group2.id + " OR uuid:" + group3.id;
-    List<GroupInfo> result = assertQuery(query, group1, group2, group3);
-    assertThat(result.get(result.size() - 1)._moreGroups).isNull();
-
-    result = assertQuery(newQuery(query).withLimit(2), result.subList(0, 2));
-    assertThat(result.get(result.size() - 1)._moreGroups).isTrue();
-  }
-
-  @Test
-  public void withStart() throws Exception {
-    GroupInfo group1 = createGroup(name("group1"));
-    GroupInfo group2 = createGroup(name("group2"));
-    GroupInfo group3 = createGroup(name("group3"));
-
-    String query = "uuid:" + group1.id + " OR uuid:" + group2.id + " OR uuid:" + group3.id;
-    List<GroupInfo> result = assertQuery(query, group1, group2, group3);
-
-    assertQuery(newQuery(query).withStart(1), result.subList(1, 3));
-  }
-
-  @Test
-  public void asAnonymous() throws Exception {
-    GroupInfo group = createGroup(name("group"));
-
-    setAnonymous();
-    assertQuery("uuid:" + group.id);
-  }
-
-  // reindex permissions are tested by {@link GroupsIT#reindexPermissions}
-  @Test
-  public void reindex() throws Exception {
-    GroupInfo group1 = createGroupWithDescription(name("group"), "barX");
-
-    // update group in the database so that group index is stale
-    String newDescription = "barY";
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID(group1.id);
-    groupsUpdateProvider
-        .get()
-        .updateGroupInDb(db, groupUuid, group -> group.setDescription(newDescription));
-
-    assertQuery("description:" + group1.description, group1);
-    assertQuery("description:" + newDescription);
-
-    gApi.groups().id(group1.id).index();
-    assertQuery("description:" + group1.description);
-    assertQuery("description:" + newDescription, group1);
-  }
-
-  private Account.Id createAccountOutsideRequestContext(
-      String username, String fullName, String email, boolean active) throws Exception {
-    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
-      Account.Id id = accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
-      if (email != null) {
-        accountManager.link(id, AuthRequest.forEmail(email));
-      }
-      accountsUpdate
-          .create()
-          .update(
-              id,
-              a -> {
-                a.setFullName(fullName);
-                a.setPreferredEmail(email);
-                a.setActive(active);
-              });
-      return id;
-    }
-  }
-
-  protected AccountInfo createAccount(String username, String fullName, String email)
-      throws Exception {
-    String uniqueName = name(username);
-    AccountInput accountInput = new AccountInput();
-    accountInput.username = uniqueName;
-    accountInput.name = fullName;
-    accountInput.email = email;
-    return gApi.accounts().create(accountInput).get();
-  }
-
-  protected GroupInfo createGroup(String name, AccountInfo... members) throws Exception {
-    return createGroupWithDescription(name, null, members);
-  }
-
-  protected GroupInfo createGroupWithDescription(
-      String name, String description, AccountInfo... members) throws Exception {
-    GroupInput in = new GroupInput();
-    in.name = name;
-    in.description = description;
-    in.members =
-        Arrays.asList(members).stream().map(a -> String.valueOf(a._accountId)).collect(toList());
-    return gApi.groups().create(in).get();
-  }
-
-  protected GroupInfo createGroupWithOwner(String name, GroupInfo ownerGroup) throws Exception {
-    GroupInput in = new GroupInput();
-    in.name = name;
-    in.ownerId = ownerGroup.id;
-    return gApi.groups().create(in).get();
-  }
-
-  protected GroupInfo createGroupThatIsVisibleToAll(String name) throws Exception {
-    GroupInput in = new GroupInput();
-    in.name = name;
-    in.visibleToAll = true;
-    return gApi.groups().create(in).get();
-  }
-
-  protected GroupInfo getGroup(AccountGroup.UUID uuid) throws Exception {
-    return gApi.groups().id(uuid.get()).get();
-  }
-
-  protected List<GroupInfo> assertQuery(Object query, GroupInfo... groups) throws Exception {
-    return assertQuery(newQuery(query), groups);
-  }
-
-  protected List<GroupInfo> assertQuery(QueryRequest query, GroupInfo... groups) throws Exception {
-    return assertQuery(query, Arrays.asList(groups));
-  }
-
-  protected List<GroupInfo> assertQuery(QueryRequest query, List<GroupInfo> groups)
-      throws Exception {
-    List<GroupInfo> result = query.get();
-    Iterable<String> uuids = uuids(result);
-    assertThat(uuids).named(format(query, result, groups)).containsExactlyElementsIn(uuids(groups));
-    return result;
-  }
-
-  protected QueryRequest newQuery(Object query) {
-    return gApi.groups().query(query.toString());
-  }
-
-  protected String format(
-      QueryRequest query, List<GroupInfo> actualGroups, List<GroupInfo> expectedGroups) {
-    StringBuilder b = new StringBuilder();
-    b.append("query '").append(query.getQuery()).append("' with expected groups ");
-    b.append(format(expectedGroups));
-    b.append(" and result ");
-    b.append(format(actualGroups));
-    return b.toString();
-  }
-
-  protected String format(Iterable<GroupInfo> groups) {
-    StringBuilder b = new StringBuilder();
-    b.append("[");
-    Iterator<GroupInfo> it = groups.iterator();
-    while (it.hasNext()) {
-      GroupInfo g = it.next();
-      b.append("{")
-          .append(g.id)
-          .append(", ")
-          .append("name=")
-          .append(g.name)
-          .append(", ")
-          .append("groupId=")
-          .append(g.groupId)
-          .append(", ")
-          .append("url=")
-          .append(g.url)
-          .append(", ")
-          .append("ownerId=")
-          .append(g.ownerId)
-          .append(", ")
-          .append("owner=")
-          .append(g.owner)
-          .append(", ")
-          .append("description=")
-          .append(g.description)
-          .append(", ")
-          .append("visibleToAll=")
-          .append(toBoolean(g.options.visibleToAll))
-          .append("}");
-      if (it.hasNext()) {
-        b.append(", ");
-      }
-    }
-    b.append("]");
-    return b.toString();
-  }
-
-  protected static boolean toBoolean(Boolean b) {
-    return b == null ? false : b;
-  }
-
-  protected static Iterable<String> ids(GroupInfo... groups) {
-    return uuids(Arrays.asList(groups));
-  }
-
-  protected static Iterable<String> uuids(List<GroupInfo> groups) {
-    return groups.stream().map(g -> g.id).collect(toList());
-  }
-
-  protected String name(String name) {
-    if (name == null) {
-      return null;
-    }
-
-    return name + "_" + getSanitizedMethodName();
-  }
-
-  protected void assertMissingField(FieldDef<InternalGroup, ?> field) {
-    assertThat(getSchema().hasField(field))
-        .named("schema %s has field %s", getSchemaVersion(), field.getName())
-        .isFalse();
-  }
-
-  protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
-    try {
-      assertQuery(query);
-      fail("expected BadRequestException for query '" + query + "'");
-    } catch (BadRequestException e) {
-      assertThat(e.getMessage()).isEqualTo(expectedMessage);
-    }
-  }
-
-  protected int getSchemaVersion() {
-    return getSchema().getVersion();
-  }
-
-  protected Schema<InternalGroup> getSchema() {
-    return indexes.getSearchIndex().getSchema();
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
deleted file mode 100644
index 001a897..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.group;
-
-import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.gerrit.testutil.IndexVersions;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.lib.Config;
-
-public class LuceneQueryGroupsTest extends AbstractQueryGroupsTest {
-  @ConfigSuite.Configs
-  public static Map<String, Config> againstPreviousIndexVersion() {
-    // the current schema version is already tested by the inherited default config suite
-    List<Integer> schemaVersions = IndexVersions.getWithoutLatest(GroupSchemaDefinitions.INSTANCE);
-    return IndexVersions.asConfigMap(
-        GroupSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config luceneConfig = new Config(config);
-    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
deleted file mode 100644
index 7eda3cc..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java
+++ /dev/null
@@ -1,121 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-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.server.config.AllProjectsName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.testutil.InMemoryDatabase;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.File;
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class SchemaCreatorTest {
-  @Inject private AllProjectsName allProjects;
-
-  @Inject private GitRepositoryManager repoManager;
-
-  @Inject private InMemoryDatabase db;
-
-  @Before
-  public void setUp() throws Exception {
-    new InMemoryModule().inject(this);
-  }
-
-  @After
-  public void tearDown() throws Exception {
-    InMemoryDatabase.drop(db);
-  }
-
-  @Test
-  public void getCauses_CreateSchema() throws OrmException, SQLException, IOException {
-    // Initially the schema should be empty.
-    String[] types = {"TABLE", "VIEW"};
-    try (JdbcSchema d = (JdbcSchema) db.open();
-        ResultSet rs = d.getConnection().getMetaData().getTables(null, null, null, types)) {
-      assertThat(rs.next()).isFalse();
-    }
-
-    // Create the schema using the current schema version.
-    //
-    db.create();
-    db.assertSchemaVersion();
-
-    // By default sitePath is set to the current working directory.
-    //
-    File sitePath = new File(".").getAbsoluteFile();
-    if (sitePath.getName().equals(".")) {
-      sitePath = sitePath.getParentFile();
-    }
-    assertThat(db.getSystemConfig().sitePath).isEqualTo(sitePath.getCanonicalPath());
-  }
-
-  private LabelTypes getLabelTypes() throws Exception {
-    db.create();
-    ProjectConfig c = new ProjectConfig(allProjects);
-    try (Repository repo = repoManager.openRepository(allProjects)) {
-      c.load(repo);
-      return new LabelTypes(ImmutableList.copyOf(c.getLabelSections().values()));
-    }
-  }
-
-  @Test
-  public void createSchema_LabelTypes() throws Exception {
-    List<String> labels = new ArrayList<>();
-    for (LabelType label : getLabelTypes().getLabelTypes()) {
-      labels.add(label.getName());
-    }
-    assertThat(labels).containsExactly("Code-Review");
-  }
-
-  @Test
-  public void createSchema_Label_CodeReview() throws Exception {
-    LabelType codeReview = getLabelTypes().byLabel("Code-Review");
-    assertThat(codeReview).isNotNull();
-    assertThat(codeReview.getName()).isEqualTo("Code-Review");
-    assertThat(codeReview.getDefaultValue()).isEqualTo(0);
-    assertThat(codeReview.getFunctionName()).isEqualTo("MaxWithBlock");
-    assertThat(codeReview.isCopyMinScore()).isTrue();
-    assertValueRange(codeReview, 2, 1, 0, -1, -2);
-  }
-
-  private void assertValueRange(LabelType label, Integer... range) {
-    assertThat(label.getValuesAsList()).containsExactlyElementsIn(Arrays.asList(range)).inOrder();
-    assertThat(label.getMax().getValue()).isEqualTo(range[0]);
-    assertThat(label.getMin().getValue()).isEqualTo(range[range.length - 1]);
-    for (LabelValue v : label.getValues()) {
-      assertThat(Strings.isNullOrEmpty(v.getText())).isFalse();
-    }
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
deleted file mode 100644
index 5b86f46..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.SystemConfig;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.GerritPersonIdentProvider;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.testutil.InMemoryDatabase;
-import com.google.gerrit.testutil.InMemoryH2Type;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.gerrit.testutil.TestUpdateUI;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Guice;
-import com.google.inject.Key;
-import com.google.inject.ProvisionException;
-import com.google.inject.TypeLiteral;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.UUID;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class SchemaUpdaterTest {
-  private LifecycleManager lifecycle;
-  private InMemoryDatabase db;
-
-  @Before
-  public void setUp() throws Exception {
-    lifecycle = new LifecycleManager();
-    db = InMemoryDatabase.newDatabase(lifecycle);
-    lifecycle.start();
-  }
-
-  @After
-  public void tearDown() throws Exception {
-    if (lifecycle != null) {
-      lifecycle.stop();
-    }
-    InMemoryDatabase.drop(db);
-  }
-
-  @Test
-  public void update() throws OrmException, FileNotFoundException, IOException {
-    db.create();
-
-    final Path site = Paths.get(UUID.randomUUID().toString());
-    final SitePaths paths = new SitePaths(site);
-    SchemaUpdater u =
-        Guice.createInjector(
-                new FactoryModule() {
-                  @Override
-                  protected void configure() {
-                    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
-                        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
-                    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
-                    bind(Key.get(schemaFactory, ReviewDbFactory.class)).toInstance(db);
-                    bind(SitePaths.class).toInstance(paths);
-
-                    Config cfg = new Config();
-                    cfg.setString("user", null, "name", "Gerrit Code Review");
-                    cfg.setString("user", null, "email", "gerrit@localhost");
-
-                    bind(Config.class) //
-                        .annotatedWith(GerritServerConfig.class) //
-                        .toInstance(cfg);
-
-                    bind(PersonIdent.class) //
-                        .annotatedWith(GerritPersonIdent.class) //
-                        .toProvider(GerritPersonIdentProvider.class);
-
-                    bind(AllProjectsName.class).toInstance(new AllProjectsName("All-Projects"));
-                    bind(AllUsersName.class).toInstance(new AllUsersName("All-Users"));
-
-                    bind(GitRepositoryManager.class).toInstance(new InMemoryRepositoryManager());
-
-                    bind(String.class) //
-                        .annotatedWith(AnonymousCowardName.class) //
-                        .toProvider(AnonymousCowardNameProvider.class);
-
-                    bind(DataSourceType.class).to(InMemoryH2Type.class);
-
-                    bind(SystemGroupBackend.class);
-                    install(new NotesMigration.Module());
-                  }
-                })
-            .getInstance(SchemaUpdater.class);
-
-    for (SchemaVersion s = u.getLatestSchemaVersion(); s.getVersionNbr() > 1; s = s.getPrior()) {
-      try {
-        assertThat(s.getPrior().getVersionNbr())
-            .named(
-                "schema %s has prior version %s. Not true that",
-                s.getVersionNbr(), s.getPrior().getVersionNbr())
-            .isEqualTo(s.getVersionNbr() - 1);
-      } catch (ProvisionException e) {
-        // Ignored
-        // The oldest supported schema version doesn't have a prior schema
-        // version.
-        break;
-      }
-    }
-
-    u.update(new TestUpdateUI());
-
-    db.assertSchemaVersion();
-    final SystemConfig sc = db.getSystemConfig();
-    assertThat(sc.sitePath).isEqualTo(paths.site_path.toAbsolutePath().toString());
-  }
-}
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
deleted file mode 100644
index dcd1ae5..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/Schema_150_to_151_Test.java
+++ /dev/null
@@ -1,102 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.groups.GroupInput;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroup.Id;
-import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.group.CreateGroup;
-import com.google.gerrit.testutil.SchemaUpgradeTestEnvironment;
-import com.google.gerrit.testutil.TestUpdateUI;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Inject;
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.time.LocalDateTime;
-import java.time.Month;
-import java.time.ZoneOffset;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-
-public class Schema_150_to_151_Test {
-
-  @Rule public SchemaUpgradeTestEnvironment testEnv = new SchemaUpgradeTestEnvironment();
-
-  @Inject private CreateGroup.Factory createGroupFactory;
-  @Inject private Schema_151 schema151;
-
-  private ReviewDb db;
-
-  @Before
-  public void setUp() throws Exception {
-    testEnv.getInjector().injectMembers(this);
-    db = testEnv.getDb();
-  }
-
-  @Test
-  public void createdOnIsPopulatedForGroupsCreatedAfterAudit() throws Exception {
-    Timestamp testStartTime = TimeUtil.nowTs();
-    AccountGroup.Id groupId = createGroup("Group for schema migration");
-    setCreatedOnToVeryOldTimestamp(groupId);
-
-    schema151.migrateData(db, new TestUpdateUI());
-
-    AccountGroup group = db.accountGroups().get(groupId);
-    assertThat(group.getCreatedOn()).isAtLeast(testStartTime);
-  }
-
-  @Test
-  public void createdOnIsPopulatedForGroupsCreatedBeforeAudit() throws Exception {
-    AccountGroup.Id groupId = createGroup("Ancient group for schema migration");
-    setCreatedOnToVeryOldTimestamp(groupId);
-    removeAuditEntriesFor(groupId);
-
-    schema151.migrateData(db, new TestUpdateUI());
-
-    AccountGroup group = db.accountGroups().get(groupId);
-    assertThat(group.getCreatedOn()).isEqualTo(AccountGroup.auditCreationInstantTs());
-  }
-
-  private AccountGroup.Id createGroup(String name) throws Exception {
-    GroupInput groupInput = new GroupInput();
-    groupInput.name = name;
-    GroupInfo groupInfo =
-        createGroupFactory.create(name).apply(TopLevelResource.INSTANCE, groupInput);
-    return new Id(groupInfo.groupId);
-  }
-
-  private void setCreatedOnToVeryOldTimestamp(Id groupId) throws OrmException {
-    AccountGroup group = db.accountGroups().get(groupId);
-    Instant instant = LocalDateTime.of(1800, Month.JANUARY, 1, 0, 0).toInstant(ZoneOffset.UTC);
-    group.setCreatedOn(Timestamp.from(instant));
-    db.accountGroups().update(ImmutableList.of(group));
-  }
-
-  private void removeAuditEntriesFor(AccountGroup.Id groupId) throws Exception {
-    ResultSet<AccountGroupMemberAudit> groupMemberAudits =
-        db.accountGroupMembersAudit().byGroup(groupId);
-    db.accountGroupMembersAudit().delete(groupMemberAudits);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/Schema_159_to_160_Test.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/Schema_159_to_160_Test.java
deleted file mode 100644
index 1c7bc20..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/Schema_159_to_160_Test.java
+++ /dev/null
@@ -1,137 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import static com.google.common.collect.ImmutableMap.toImmutableMap;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
-import static com.google.gerrit.server.git.UserConfigSections.MY;
-import static com.google.gerrit.server.schema.Schema_160.DEFAULT_DRAFT_ITEM;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.MenuItem;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.VersionedAccountPreferences;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.testutil.SchemaUpgradeTestEnvironment;
-import com.google.gerrit.testutil.TestUpdateUI;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.util.List;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-
-public class Schema_159_to_160_Test {
-  @Rule public SchemaUpgradeTestEnvironment testEnv = new SchemaUpgradeTestEnvironment();
-
-  @Inject private AccountCache accountCache;
-  @Inject private AllUsersName allUsersName;
-  @Inject private GerritApi gApi;
-  @Inject private GitRepositoryManager repoManager;
-  @Inject private Provider<IdentifiedUser> userProvider;
-  @Inject private Schema_160 schema160;
-
-  private ReviewDb db;
-  private Account.Id accountId;
-
-  @Before
-  public void setUp() throws Exception {
-    testEnv.getInjector().injectMembers(this);
-    db = testEnv.getDb();
-    accountId = userProvider.get().getAccountId();
-  }
-
-  @Test
-  public void skipUnmodified() throws Exception {
-    ObjectId oldMetaId = metaRef(accountId);
-    assertThat(myMenusFromNoteDb(accountId).values()).doesNotContain(DEFAULT_DRAFT_ITEM);
-    assertThat(myMenusFromApi(accountId).values()).doesNotContain(DEFAULT_DRAFT_ITEM);
-
-    schema160.migrateData(db, new TestUpdateUI());
-
-    assertThat(metaRef(accountId)).isEqualTo(oldMetaId);
-  }
-
-  @Test
-  public void deleteItems() throws Exception {
-    ObjectId oldMetaId = metaRef(accountId);
-    List<String> defaultNames = ImmutableList.copyOf(myMenusFromApi(accountId).keySet());
-
-    GeneralPreferencesInfo prefs = gApi.accounts().id(accountId.get()).getPreferences();
-    prefs.my.add(0, new MenuItem("Something else", DEFAULT_DRAFT_ITEM + "+is:mergeable"));
-    prefs.my.add(new MenuItem("Drafts", DEFAULT_DRAFT_ITEM));
-    prefs.my.add(new MenuItem("Totally not drafts", DEFAULT_DRAFT_ITEM));
-    gApi.accounts().id(accountId.get()).setPreferences(prefs);
-
-    List<String> oldNames =
-        ImmutableList.<String>builder()
-            .add("Something else")
-            .addAll(defaultNames)
-            .add("Drafts")
-            .add("Totally not drafts")
-            .build();
-    assertThat(myMenusFromApi(accountId).keySet()).containsExactlyElementsIn(oldNames).inOrder();
-
-    schema160.migrateData(db, new TestUpdateUI());
-    accountCache.evict(accountId);
-    testEnv.setApiUser(accountId);
-
-    assertThat(metaRef(accountId)).isNotEqualTo(oldMetaId);
-
-    List<String> newNames =
-        ImmutableList.<String>builder().add("Something else").addAll(defaultNames).build();
-    assertThat(myMenusFromNoteDb(accountId).keySet()).containsExactlyElementsIn(newNames).inOrder();
-    assertThat(myMenusFromApi(accountId).keySet()).containsExactlyElementsIn(newNames).inOrder();
-  }
-
-  // Raw config values, bypassing the defaults set by GeneralPreferencesLoader.
-  private ImmutableMap<String, String> myMenusFromNoteDb(Account.Id id) throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      VersionedAccountPreferences prefs = VersionedAccountPreferences.forUser(id);
-      prefs.load(repo);
-      Config cfg = prefs.getConfig();
-      return cfg.getSubsections(MY)
-          .stream()
-          .collect(toImmutableMap(i -> i, i -> cfg.getString(MY, i, KEY_URL)));
-    }
-  }
-
-  private ImmutableMap<String, String> myMenusFromApi(Account.Id id) throws Exception {
-    return gApi.accounts()
-        .id(id.get())
-        .getPreferences()
-        .my
-        .stream()
-        .collect(toImmutableMap(i -> i.name, i -> i.url));
-  }
-
-  private ObjectId metaRef(Account.Id id) throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return repo.exactRef(RefNames.refsUsers(id)).getObjectId();
-    }
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java
deleted file mode 100644
index dba3b3d..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java
+++ /dev/null
@@ -1,133 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import static org.junit.Assert.assertEquals;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testutil.InMemoryDatabase;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Provider;
-import com.google.inject.util.Providers;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class BatchUpdateTest {
-  @Inject private AccountManager accountManager;
-  @Inject private IdentifiedUser.GenericFactory userFactory;
-  @Inject private SchemaFactory<ReviewDb> schemaFactory;
-  @Inject private InMemoryRepositoryManager repoManager;
-  @Inject private SchemaCreator schemaCreator;
-  @Inject private ThreadLocalRequestContext requestContext;
-  @Inject private BatchUpdate.Factory batchUpdateFactory;
-
-  // Only for use in setting up/tearing down injector; other users should use schemaFactory.
-  @Inject private InMemoryDatabase inMemoryDatabase;
-
-  private LifecycleManager lifecycle;
-  private ReviewDb db;
-  private TestRepository<InMemoryRepository> repo;
-  private Project.NameKey project;
-  private IdentifiedUser user;
-
-  @Before
-  public void setUp() throws Exception {
-    Injector injector = Guice.createInjector(new InMemoryModule());
-    injector.injectMembers(this);
-    lifecycle = new LifecycleManager();
-    lifecycle.add(injector);
-    lifecycle.start();
-
-    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
-      schemaCreator.create(underlyingDb);
-    }
-    db = schemaFactory.open();
-    Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
-    user = userFactory.create(userId);
-
-    project = new Project.NameKey("test");
-
-    InMemoryRepository inMemoryRepo = repoManager.createRepository(project);
-    repo = new TestRepository<>(inMemoryRepo);
-
-    requestContext.setContext(
-        new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return user;
-          }
-
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return Providers.of(db);
-          }
-        });
-  }
-
-  @After
-  public void tearDown() {
-    if (repo != null) {
-      repo.getRepository().close();
-    }
-    if (lifecycle != null) {
-      lifecycle.stop();
-    }
-    requestContext.setContext(null);
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(inMemoryDatabase);
-  }
-
-  @Test
-  public void addRefUpdateFromFastForwardCommit() throws Exception {
-    final RevCommit masterCommit = repo.branch("master").commit().create();
-    final RevCommit branchCommit = repo.branch("branch").commit().parent(masterCommit).create();
-
-    try (BatchUpdate bu = batchUpdateFactory.create(db, project, user, TimeUtil.nowTs())) {
-      bu.addRepoOnlyOp(
-          new RepoOnlyOp() {
-            @Override
-            public void updateRepo(RepoContext ctx) throws Exception {
-              ctx.addRefUpdate(masterCommit.getId(), branchCommit.getId(), "refs/heads/master");
-            }
-          });
-      bu.execute();
-    }
-
-    assertEquals(
-        repo.getRepository().exactRef("refs/heads/master").getObjectId(), branchCommit.getId());
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/update/RefUpdateUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/update/RefUpdateUtilTest.java
deleted file mode 100644
index 286827a..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/update/RefUpdateUtilTest.java
+++ /dev/null
@@ -1,118 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.server.git.LockFailureException;
-import java.io.IOException;
-import java.util.function.Consumer;
-import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
-
-@RunWith(JUnit4.class)
-public class RefUpdateUtilTest {
-  private static final Consumer<ReceiveCommand> OK = c -> c.setResult(ReceiveCommand.Result.OK);
-  private static final Consumer<ReceiveCommand> LOCK_FAILURE =
-      c -> c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
-  private static final Consumer<ReceiveCommand> REJECTED =
-      c -> c.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON);
-  private static final Consumer<ReceiveCommand> ABORTED =
-      c -> {
-        c.setResult(ReceiveCommand.Result.NOT_ATTEMPTED);
-        ReceiveCommand.abort(ImmutableList.of(c));
-        checkState(
-            c.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED
-                && c.getResult() != ReceiveCommand.Result.LOCK_FAILURE
-                && c.getResult() != ReceiveCommand.Result.OK,
-            "unexpected state after abort: %s",
-            c);
-      };
-
-  @Test
-  public void checkBatchRefUpdateResults() throws Exception {
-    checkResults(OK);
-    checkResults(OK, OK);
-
-    assertIoException(REJECTED);
-    assertIoException(OK, REJECTED);
-    assertIoException(LOCK_FAILURE, REJECTED);
-    assertIoException(LOCK_FAILURE, OK);
-    assertIoException(LOCK_FAILURE, REJECTED, OK);
-    assertIoException(LOCK_FAILURE, LOCK_FAILURE, REJECTED);
-    assertIoException(LOCK_FAILURE, ABORTED, REJECTED);
-    assertIoException(LOCK_FAILURE, ABORTED, OK);
-
-    assertLockFailureException(LOCK_FAILURE);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED, ABORTED);
-    assertLockFailureException(ABORTED);
-    assertLockFailureException(ABORTED, ABORTED);
-  }
-
-  @SafeVarargs
-  private static void checkResults(Consumer<ReceiveCommand>... resultSetters) throws Exception {
-    RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
-  }
-
-  @SafeVarargs
-  private static void assertIoException(Consumer<ReceiveCommand>... resultSetters) {
-    try {
-      RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
-      assert_().fail("expected IOException");
-    } catch (IOException e) {
-      assertThat(e).isNotInstanceOf(LockFailureException.class);
-    }
-  }
-
-  @SafeVarargs
-  private static void assertLockFailureException(Consumer<ReceiveCommand>... resultSetters)
-      throws Exception {
-    try {
-      RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
-      assert_().fail("expected LockFailureException");
-    } catch (LockFailureException e) {
-      // Expected.
-    }
-  }
-
-  @SafeVarargs
-  private static BatchRefUpdate newBatchRefUpdate(Consumer<ReceiveCommand>... resultSetters) {
-    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
-      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
-      for (int i = 0; i < resultSetters.length; i++) {
-        ReceiveCommand cmd =
-            new ReceiveCommand(
-                ObjectId.fromString(String.format("%039x1", i)),
-                ObjectId.fromString(String.format("%039x2", i)),
-                "refs/heads/branch" + i);
-        bru.addCommand(cmd);
-        resultSetters[i].accept(cmd);
-      }
-      return bru;
-    }
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/update/RepoViewTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/update/RepoViewTest.java
deleted file mode 100644
index 0ea9f83..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/update/RepoViewTest.java
+++ /dev/null
@@ -1,154 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static org.eclipse.jgit.lib.Constants.R_HEADS;
-
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.StoredConfig;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class RepoViewTest {
-  private static final String MASTER = "refs/heads/master";
-  private static final String BRANCH = "refs/heads/branch";
-
-  private Repository repo;
-  private TestRepository<?> tr;
-  private RepoView view;
-
-  @Before
-  public void setUp() throws Exception {
-    InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
-    Project.NameKey project = new Project.NameKey("project");
-    repo = repoManager.createRepository(project);
-    tr = new TestRepository<>(repo);
-    tr.branch(MASTER).commit().create();
-    view = new RepoView(repoManager, project);
-  }
-
-  @After
-  public void tearDown() {
-    view.close();
-    repo.close();
-  }
-
-  @Test
-  public void getConfigIsDefensiveCopy() throws Exception {
-    StoredConfig orig = repo.getConfig();
-    orig.setString("a", "config", "option", "yes");
-    orig.save();
-
-    Config copy = view.getConfig();
-    copy.setString("a", "config", "option", "no");
-
-    assertThat(orig.getString("a", "config", "option")).isEqualTo("yes");
-    assertThat(repo.getConfig().getString("a", "config", "option")).isEqualTo("yes");
-  }
-
-  @Test
-  public void getRef() throws Exception {
-    ObjectId oldMaster = repo.exactRef(MASTER).getObjectId();
-    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(oldMaster);
-    assertThat(repo.exactRef(BRANCH)).isNull();
-    assertThat(view.getRef(MASTER)).hasValue(oldMaster);
-    assertThat(view.getRef(BRANCH)).isEmpty();
-
-    tr.branch(MASTER).commit().create();
-    tr.branch(BRANCH).commit().create();
-    assertThat(repo.exactRef(MASTER).getObjectId()).isNotEqualTo(oldMaster);
-    assertThat(repo.exactRef(BRANCH)).isNotNull();
-    assertThat(view.getRef(MASTER)).hasValue(oldMaster);
-    assertThat(view.getRef(BRANCH)).isEmpty();
-  }
-
-  @Test
-  public void getRefsRescansWhenNotCaching() throws Exception {
-    ObjectId oldMaster = repo.exactRef(MASTER).getObjectId();
-    assertThat(view.getRefs(R_HEADS)).containsExactly("master", oldMaster);
-
-    ObjectId newBranch = tr.branch(BRANCH).commit().create();
-    assertThat(view.getRefs(R_HEADS)).containsExactly("master", oldMaster, "branch", newBranch);
-  }
-
-  @Test
-  public void getRefsUsesCachedValueMatchingGetRef() throws Exception {
-    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
-    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
-    assertThat(view.getRef(MASTER)).hasValue(master1);
-
-    // Doesn't reflect new value for master.
-    ObjectId master2 = tr.branch(MASTER).commit().create();
-    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master2);
-    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
-
-    // Branch wasn't previously cached, so does reflect new value.
-    ObjectId branch1 = tr.branch(BRANCH).commit().create();
-    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1, "branch", branch1);
-
-    // Looking up branch causes it to be cached.
-    assertThat(view.getRef(BRANCH)).hasValue(branch1);
-    ObjectId branch2 = tr.branch(BRANCH).commit().create();
-    assertThat(repo.exactRef(BRANCH).getObjectId()).isEqualTo(branch2);
-    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1, "branch", branch1);
-  }
-
-  @Test
-  public void getRefsReflectsCommands() throws Exception {
-    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
-    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
-
-    ObjectId master2 = tr.commit().create();
-    view.getCommands().add(new ReceiveCommand(master1, master2, MASTER));
-
-    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
-    assertThat(view.getRef(MASTER)).hasValue(master2);
-    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master2);
-
-    view.getCommands().add(new ReceiveCommand(master2, ObjectId.zeroId(), MASTER));
-
-    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
-    assertThat(view.getRef(MASTER)).isEmpty();
-    assertThat(view.getRefs(R_HEADS)).isEmpty();
-  }
-
-  @Test
-  public void getRefsOverwritesCachedValueWithCommand() throws Exception {
-    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
-    assertThat(view.getRef(MASTER)).hasValue(master1);
-
-    ObjectId master2 = tr.commit().create();
-    view.getCommands().add(new ReceiveCommand(master1, master2, MASTER));
-
-    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
-    assertThat(view.getRef(MASTER)).hasValue(master2);
-    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master2);
-
-    view.getCommands().add(new ReceiveCommand(master2, ObjectId.zeroId(), MASTER));
-
-    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
-    assertThat(view.getRef(MASTER)).isEmpty();
-    assertThat(view.getRefs(R_HEADS)).isEmpty();
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/RegexListSearcherTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/util/RegexListSearcherTest.java
deleted file mode 100644
index dc8c0d8..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/util/RegexListSearcherTest.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.util;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Ordering;
-import java.util.List;
-import org.junit.Test;
-
-public class RegexListSearcherTest {
-  private static final ImmutableList<String> EMPTY = ImmutableList.of();
-
-  @Test
-  public void emptyList() {
-    assertSearchReturns(EMPTY, "pat", EMPTY);
-  }
-
-  @Test
-  public void hasMatch() {
-    List<String> list = ImmutableList.of("bar", "foo", "quux");
-    assertTrue(RegexListSearcher.ofStrings("foo").hasMatch(list));
-    assertFalse(RegexListSearcher.ofStrings("xyz").hasMatch(list));
-  }
-
-  @Test
-  public void anchors() {
-    List<String> list = ImmutableList.of("foo");
-    assertSearchReturns(list, "^f.*", list);
-    assertSearchReturns(list, "^f.*o$", list);
-    assertSearchReturns(list, "f.*o$", list);
-    assertSearchReturns(list, "f.*o$", list);
-    assertSearchReturns(EMPTY, "^.*\\$", list);
-  }
-
-  @Test
-  public void noCommonPrefix() {
-    List<String> list = ImmutableList.of("bar", "foo", "quux");
-    assertSearchReturns(ImmutableList.of("foo"), "f.*", list);
-    assertSearchReturns(ImmutableList.of("foo"), ".*o.*", list);
-    assertSearchReturns(ImmutableList.of("bar", "foo", "quux"), ".*[aou].*", list);
-  }
-
-  @Test
-  public void commonPrefix() {
-    List<String> list = ImmutableList.of("bar", "baz", "foo1", "foo2", "foo3", "quux");
-    assertSearchReturns(ImmutableList.of("bar", "baz"), "b.*", list);
-    assertSearchReturns(ImmutableList.of("foo1", "foo2"), "foo[12]", list);
-    assertSearchReturns(ImmutableList.of("foo1", "foo2", "foo3"), "foo.*", list);
-    assertSearchReturns(ImmutableList.of("quux"), "q.*", list);
-  }
-
-  private void assertSearchReturns(List<?> expected, String re, List<String> inputs) {
-    assertTrue(Ordering.natural().isOrdered(inputs));
-    assertEquals(expected, ImmutableList.copyOf(RegexListSearcher.ofStrings(re).search(inputs)));
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java
deleted file mode 100644
index 473c44d..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.util;
-
-import static com.google.gerrit.server.util.SocketUtil.hostname;
-import static com.google.gerrit.server.util.SocketUtil.isIPv6;
-import static com.google.gerrit.server.util.SocketUtil.parse;
-import static com.google.gerrit.server.util.SocketUtil.resolve;
-import static java.net.InetAddress.getByName;
-import static java.net.InetSocketAddress.createUnresolved;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-
-import com.google.gerrit.testutil.GerritBaseTests;
-import java.net.Inet4Address;
-import java.net.Inet6Address;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
-import java.net.UnknownHostException;
-import org.junit.Test;
-
-public class SocketUtilTest extends GerritBaseTests {
-  @Test
-  public void testIsIPv6() throws UnknownHostException {
-    final InetAddress ipv6 = getByName("1:2:3:4:5:6:7:8");
-    assertTrue(ipv6 instanceof Inet6Address);
-    assertTrue(isIPv6(ipv6));
-
-    final InetAddress ipv4 = getByName("127.0.0.1");
-    assertTrue(ipv4 instanceof Inet4Address);
-    assertFalse(isIPv6(ipv4));
-  }
-
-  @Test
-  public void testHostname() {
-    assertEquals("*", hostname(new InetSocketAddress(80)));
-    assertEquals("localhost", hostname(new InetSocketAddress("localhost", 80)));
-    assertEquals("foo", hostname(createUnresolved("foo", 80)));
-  }
-
-  @Test
-  public void testFormat() throws UnknownHostException {
-    assertEquals("*:1234", SocketUtil.format(new InetSocketAddress(1234), 80));
-    assertEquals("*", SocketUtil.format(new InetSocketAddress(80), 80));
-
-    assertEquals("foo:1234", SocketUtil.format(createUnresolved("foo", 1234), 80));
-    assertEquals("foo", SocketUtil.format(createUnresolved("foo", 80), 80));
-
-    assertEquals(
-        "[1:2:3:4:5:6:7:8]:1234", //
-        SocketUtil.format(new InetSocketAddress(getByName("1:2:3:4:5:6:7:8"), 1234), 80));
-    assertEquals(
-        "[1:2:3:4:5:6:7:8]", //
-        SocketUtil.format(new InetSocketAddress(getByName("1:2:3:4:5:6:7:8"), 80), 80));
-
-    assertEquals(
-        "localhost:1234", //
-        SocketUtil.format(new InetSocketAddress("localhost", 1234), 80));
-    assertEquals(
-        "localhost", //
-        SocketUtil.format(new InetSocketAddress("localhost", 80), 80));
-  }
-
-  @Test
-  public void testParse() {
-    assertEquals(new InetSocketAddress(1234), parse("*:1234", 80));
-    assertEquals(new InetSocketAddress(80), parse("*", 80));
-    assertEquals(new InetSocketAddress(1234), parse(":1234", 80));
-    assertEquals(new InetSocketAddress(80), parse("", 80));
-
-    assertEquals(
-        createUnresolved("1:2:3:4:5:6:7:8", 1234), //
-        parse("[1:2:3:4:5:6:7:8]:1234", 80));
-    assertEquals(
-        createUnresolved("1:2:3:4:5:6:7:8", 80), //
-        parse("[1:2:3:4:5:6:7:8]", 80));
-
-    assertEquals(
-        createUnresolved("localhost", 1234), //
-        parse("[localhost]:1234", 80));
-    assertEquals(
-        createUnresolved("localhost", 80), //
-        parse("[localhost]", 80));
-
-    assertEquals(
-        createUnresolved("foo.bar.example.com", 1234), //
-        parse("[foo.bar.example.com]:1234", 80));
-    assertEquals(
-        createUnresolved("foo.bar.example.com", 80), //
-        parse("[foo.bar.example.com]", 80));
-  }
-
-  @Test
-  public void testParseInvalidIPv6() {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("invalid IPv6: [:3");
-    parse("[:3", 80);
-  }
-
-  @Test
-  public void testParseInvalidPort() {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("invalid port: localhost:A");
-    parse("localhost:A", 80);
-  }
-
-  @Test
-  public void testResolve() throws UnknownHostException {
-    assertEquals(new InetSocketAddress(1234), resolve("*:1234", 80));
-    assertEquals(new InetSocketAddress(80), resolve("*", 80));
-    assertEquals(new InetSocketAddress(1234), resolve(":1234", 80));
-    assertEquals(new InetSocketAddress(80), resolve("", 80));
-
-    assertEquals(
-        new InetSocketAddress(getByName("1:2:3:4:5:6:7:8"), 1234), //
-        resolve("[1:2:3:4:5:6:7:8]:1234", 80));
-    assertEquals(
-        new InetSocketAddress(getByName("1:2:3:4:5:6:7:8"), 80), //
-        resolve("[1:2:3:4:5:6:7:8]", 80));
-
-    assertEquals(
-        new InetSocketAddress(getByName("localhost"), 1234), //
-        resolve("[localhost]:1234", 80));
-    assertEquals(
-        new InetSocketAddress(getByName("localhost"), 80), //
-        resolve("[localhost]", 80));
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/ConfigSuite.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/ConfigSuite.java
deleted file mode 100644
index 7a4b2b0..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/ConfigSuite.java
+++ /dev/null
@@ -1,309 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static java.lang.annotation.ElementType.FIELD;
-import static java.lang.annotation.ElementType.METHOD;
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import java.lang.annotation.Annotation;
-import java.lang.annotation.Retention;
-import java.lang.annotation.Target;
-import java.lang.reflect.Field;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.lang.reflect.Modifier;
-import java.lang.reflect.ParameterizedType;
-import java.lang.reflect.Type;
-import java.util.List;
-import java.util.Map;
-import org.junit.runner.Runner;
-import org.junit.runners.BlockJUnit4ClassRunner;
-import org.junit.runners.Suite;
-import org.junit.runners.model.FrameworkMethod;
-import org.junit.runners.model.InitializationError;
-
-/**
- * Suite to run tests with different {@code gerrit.config} values.
- *
- * <p>For each {@link Config} method in the class and base classes, a new group of tests is created
- * with the {@link Parameter} field set to the config.
- *
- * <pre>
- * {@literal @}RunWith(ConfigSuite.class)
- * public abstract class MyAbstractTest {
- *   {@literal @}ConfigSuite.Parameter
- *   protected Config cfg;
- *
- *   {@literal @}ConfigSuite.Config
- *   public static Config firstConfig() {
- *     Config cfg = new Config();
- *     cfg.setString("gerrit", null, "testValue", "a");
- *   }
- * }
- *
- * public class MyTest extends MyAbstractTest {
- *   {@literal @}ConfigSuite.Config
- *   public static Config secondConfig() {
- *     Config cfg = new Config();
- *     cfg.setString("gerrit", null, "testValue", "b");
- *   }
- *
- *   {@literal @}Test
- *   public void myTest() {
- *     // Test using cfg.
- *   }
- * }
- * </pre>
- *
- * This creates a suite of tests with three groups:
- *
- * <ul>
- *   <li><strong>default</strong>: {@code MyTest.myTest}
- *   <li><strong>firstConfig</strong>: {@code MyTest.myTest[firstConfig]}
- *   <li><strong>secondConfig</strong>: {@code MyTest.myTest[secondConfig]}
- * </ul>
- *
- * Additionally, config values used by <strong>default</strong> can be set in a method annotated
- * with {@code @ConfigSuite.Default}.
- *
- * <p>In addition groups of tests for different configurations can be defined by annotating a method
- * that returns a Map&lt;String, Config&gt; with {@link Configs}. The map keys define the test suite
- * names, while the values define the configurations for the test suites.
- *
- * <pre>
- * {@literal @}ConfigSuite.Configs
- * public static Map&lt;String, Config&gt; configs() {
- *   Config cfgA = new Config();
- *   cfgA.setString("gerrit", null, "testValue", "a");
- *   Config cfgB = new Config();
- *   cfgB.setString("gerrit", null, "testValue", "b");
- *   return ImmutableMap.of("testWithValueA", cfgA, "testWithValueB", cfgB);
- * }
- * </pre>
- *
- * <p>The name of the config method corresponding to the currently-running test can be stored in a
- * field annotated with {@code @ConfigSuite.Name}.
- */
-public class ConfigSuite extends Suite {
-  public static final String DEFAULT = "default";
-
-  @Target({METHOD})
-  @Retention(RUNTIME)
-  public static @interface Default {}
-
-  @Target({METHOD})
-  @Retention(RUNTIME)
-  public static @interface Config {}
-
-  @Target({METHOD})
-  @Retention(RUNTIME)
-  public static @interface Configs {}
-
-  @Target({FIELD})
-  @Retention(RUNTIME)
-  public static @interface Parameter {}
-
-  @Target({FIELD})
-  @Retention(RUNTIME)
-  public static @interface Name {}
-
-  private static class ConfigRunner extends BlockJUnit4ClassRunner {
-    private final org.eclipse.jgit.lib.Config cfg;
-    private final Field parameterField;
-    private final Field nameField;
-    private final String name;
-
-    private ConfigRunner(
-        Class<?> clazz,
-        Field parameterField,
-        Field nameField,
-        String name,
-        org.eclipse.jgit.lib.Config cfg)
-        throws InitializationError {
-      super(clazz);
-      this.parameterField = parameterField;
-      this.nameField = nameField;
-      this.name = name;
-      this.cfg = cfg;
-    }
-
-    @Override
-    public Object createTest() throws Exception {
-      Object test = getTestClass().getJavaClass().newInstance();
-      parameterField.set(test, new org.eclipse.jgit.lib.Config(cfg));
-      if (nameField != null) {
-        nameField.set(test, name);
-      }
-      return test;
-    }
-
-    @Override
-    protected String getName() {
-      return MoreObjects.firstNonNull(name, DEFAULT);
-    }
-
-    @Override
-    protected String testName(FrameworkMethod method) {
-      String n = method.getName();
-      return name == null ? n : n + "[" + name + "]";
-    }
-  }
-
-  private static List<Runner> runnersFor(Class<?> clazz) {
-    Method defaultConfig = getDefaultConfig(clazz);
-    List<Method> configs = getConfigs(clazz);
-    Map<String, org.eclipse.jgit.lib.Config> configMap =
-        callConfigMapMethod(getConfigMap(clazz), configs);
-
-    Field parameterField = getOnlyField(clazz, Parameter.class);
-    checkArgument(parameterField != null, "No @ConfigSuite.Parameter found");
-    Field nameField = getOnlyField(clazz, Name.class);
-    List<Runner> result = Lists.newArrayListWithCapacity(configs.size() + 1);
-    try {
-      result.add(
-          new ConfigRunner(
-              clazz, parameterField, nameField, null, callConfigMethod(defaultConfig)));
-      for (Method m : configs) {
-        result.add(
-            new ConfigRunner(clazz, parameterField, nameField, m.getName(), callConfigMethod(m)));
-      }
-      for (Map.Entry<String, org.eclipse.jgit.lib.Config> e : configMap.entrySet()) {
-        result.add(new ConfigRunner(clazz, parameterField, nameField, e.getKey(), e.getValue()));
-      }
-      return result;
-    } catch (InitializationError e) {
-      System.err.println("Errors initializing runners:");
-      for (Throwable t : e.getCauses()) {
-        t.printStackTrace();
-      }
-      throw new RuntimeException(e);
-    }
-  }
-
-  private static Method getDefaultConfig(Class<?> clazz) {
-    return getAnnotatedMethod(clazz, Default.class);
-  }
-
-  private static Method getConfigMap(Class<?> clazz) {
-    return getAnnotatedMethod(clazz, Configs.class);
-  }
-
-  private static <T extends Annotation> Method getAnnotatedMethod(
-      Class<?> clazz, Class<T> annotationClass) {
-    Method result = null;
-    for (Method m : clazz.getMethods()) {
-      T ann = m.getAnnotation(annotationClass);
-      if (ann != null) {
-        checkArgument(result == null, "Multiple methods annotated with %s: %s, %s", ann, result, m);
-        result = m;
-      }
-    }
-    return result;
-  }
-
-  private static List<Method> getConfigs(Class<?> clazz) {
-    List<Method> result = Lists.newArrayListWithExpectedSize(3);
-    for (Method m : clazz.getMethods()) {
-      Config ann = m.getAnnotation(Config.class);
-      if (ann != null) {
-        checkArgument(!m.getName().equals(DEFAULT), "%s cannot be named %s", ann, DEFAULT);
-        result.add(m);
-      }
-    }
-    return result;
-  }
-
-  private static org.eclipse.jgit.lib.Config callConfigMethod(Method m) {
-    if (m == null) {
-      return new org.eclipse.jgit.lib.Config();
-    }
-    checkArgument(
-        org.eclipse.jgit.lib.Config.class.isAssignableFrom(m.getReturnType()),
-        "%s must return Config",
-        m);
-    checkArgument((m.getModifiers() & Modifier.STATIC) != 0, "%s must be static", m);
-    checkArgument(m.getParameterTypes().length == 0, "%s must take no parameters", m);
-    try {
-      return (org.eclipse.jgit.lib.Config) m.invoke(null);
-    } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
-      throw new IllegalArgumentException(e);
-    }
-  }
-
-  private static Map<String, org.eclipse.jgit.lib.Config> callConfigMapMethod(
-      Method m, List<Method> configs) {
-    if (m == null) {
-      return ImmutableMap.of();
-    }
-    checkArgument(Map.class.isAssignableFrom(m.getReturnType()), "%s must return Map", m);
-    Type[] types = ((ParameterizedType) m.getGenericReturnType()).getActualTypeArguments();
-    checkArgument(
-        String.class.isAssignableFrom((Class<?>) types[0]),
-        "The map returned by %s must have String as key",
-        m);
-    checkArgument(
-        org.eclipse.jgit.lib.Config.class.isAssignableFrom((Class<?>) types[1]),
-        "The map returned by %s must have Config as value",
-        m);
-    checkArgument((m.getModifiers() & Modifier.STATIC) != 0, "%s must be static", m);
-    checkArgument(m.getParameterTypes().length == 0, "%s must take no parameters", m);
-    try {
-      @SuppressWarnings("unchecked")
-      Map<String, org.eclipse.jgit.lib.Config> configMap =
-          (Map<String, org.eclipse.jgit.lib.Config>) m.invoke(null);
-      checkArgument(
-          !configMap.containsKey(DEFAULT),
-          "The map returned by %s cannot contain key %s (duplicate test suite name)",
-          m,
-          DEFAULT);
-      for (String name : configs.stream().map(cm -> cm.getName()).collect(toSet())) {
-        checkArgument(
-            !configMap.containsKey(name),
-            "The map returned by %s cannot contain key %s (duplicate test suite name)",
-            m,
-            name);
-      }
-      return configMap;
-    } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
-      throw new IllegalArgumentException(e);
-    }
-  }
-
-  private static Field getOnlyField(Class<?> clazz, Class<? extends Annotation> ann) {
-    List<Field> fields = Lists.newArrayListWithExpectedSize(1);
-    for (Field f : clazz.getFields()) {
-      if (f.getAnnotation(ann) != null) {
-        fields.add(f);
-      }
-    }
-    checkArgument(
-        fields.size() <= 1,
-        "expected 1 @ConfigSuite.%s field, found: %s",
-        ann.getSimpleName(),
-        fields);
-    return Iterables.getFirst(fields, null);
-  }
-
-  public ConfigSuite(Class<?> clazz) throws InitializationError {
-    super(clazz, runnersFor(clazz));
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java
deleted file mode 100644
index 123645e..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java
+++ /dev/null
@@ -1,153 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import com.google.gerrit.reviewdb.server.AccountGroupAccess;
-import com.google.gerrit.reviewdb.server.AccountGroupByIdAccess;
-import com.google.gerrit.reviewdb.server.AccountGroupByIdAudAccess;
-import com.google.gerrit.reviewdb.server.AccountGroupMemberAccess;
-import com.google.gerrit.reviewdb.server.AccountGroupMemberAuditAccess;
-import com.google.gerrit.reviewdb.server.AccountGroupNameAccess;
-import com.google.gerrit.reviewdb.server.ChangeAccess;
-import com.google.gerrit.reviewdb.server.ChangeMessageAccess;
-import com.google.gerrit.reviewdb.server.PatchLineCommentAccess;
-import com.google.gerrit.reviewdb.server.PatchSetAccess;
-import com.google.gerrit.reviewdb.server.PatchSetApprovalAccess;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.SchemaVersionAccess;
-import com.google.gerrit.reviewdb.server.SystemConfigAccess;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.StatementExecutor;
-
-/** ReviewDb that is disabled for testing. */
-public class DisabledReviewDb implements ReviewDb {
-  public static class Disabled extends RuntimeException {
-    private static final long serialVersionUID = 1L;
-
-    private Disabled() {
-      super("ReviewDb is disabled for this test");
-    }
-  }
-
-  @Override
-  public void close() {
-    // Do nothing.
-  }
-
-  @Override
-  public void commit() {
-    throw new Disabled();
-  }
-
-  @Override
-  public void rollback() {
-    throw new Disabled();
-  }
-
-  @Override
-  public void updateSchema(StatementExecutor e) {
-    throw new Disabled();
-  }
-
-  @Override
-  public void pruneSchema(StatementExecutor e) {
-    throw new Disabled();
-  }
-
-  @Override
-  public Access<?, ?>[] allRelations() {
-    throw new Disabled();
-  }
-
-  @Override
-  public SchemaVersionAccess schemaVersion() {
-    throw new Disabled();
-  }
-
-  @Override
-  public SystemConfigAccess systemConfig() {
-    throw new Disabled();
-  }
-
-  @Override
-  public AccountGroupAccess accountGroups() {
-    throw new Disabled();
-  }
-
-  @Override
-  public AccountGroupNameAccess accountGroupNames() {
-    throw new Disabled();
-  }
-
-  @Override
-  public AccountGroupMemberAccess accountGroupMembers() {
-    throw new Disabled();
-  }
-
-  @Override
-  public AccountGroupMemberAuditAccess accountGroupMembersAudit() {
-    throw new Disabled();
-  }
-
-  @Override
-  public ChangeAccess changes() {
-    throw new Disabled();
-  }
-
-  @Override
-  public PatchSetApprovalAccess patchSetApprovals() {
-    throw new Disabled();
-  }
-
-  @Override
-  public ChangeMessageAccess changeMessages() {
-    throw new Disabled();
-  }
-
-  @Override
-  public PatchSetAccess patchSets() {
-    throw new Disabled();
-  }
-
-  @Override
-  public PatchLineCommentAccess patchComments() {
-    throw new Disabled();
-  }
-
-  @Override
-  public AccountGroupByIdAccess accountGroupById() {
-    throw new Disabled();
-  }
-
-  @Override
-  public AccountGroupByIdAudAccess accountGroupByIdAud() {
-    throw new Disabled();
-  }
-
-  @Override
-  public int nextAccountId() {
-    throw new Disabled();
-  }
-
-  @Override
-  public int nextAccountGroupId() {
-    throw new Disabled();
-  }
-
-  @Override
-  public int nextChangeId() {
-    throw new Disabled();
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
deleted file mode 100644
index 51f5268..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
+++ /dev/null
@@ -1,85 +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.testutil;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import java.util.HashMap;
-import java.util.Map;
-
-/** Fake implementation of {@link AccountCache} for testing. */
-public class FakeAccountCache implements AccountCache {
-  private final Map<Account.Id, AccountState> byId;
-  private final Map<String, AccountState> byUsername;
-
-  public FakeAccountCache() {
-    byId = new HashMap<>();
-    byUsername = new HashMap<>();
-  }
-
-  @Override
-  public synchronized AccountState get(Account.Id accountId) {
-    AccountState state = byId.get(accountId);
-    if (state != null) {
-      return state;
-    }
-    return newState(new Account(accountId, TimeUtil.nowTs()));
-  }
-
-  @Override
-  @Nullable
-  public synchronized AccountState getOrNull(Account.Id accountId) {
-    return byId.get(accountId);
-  }
-
-  @Override
-  public synchronized AccountState getByUsername(String username) {
-    return byUsername.get(username);
-  }
-
-  @Override
-  public synchronized void evict(Account.Id accountId) {
-    byId.remove(accountId);
-  }
-
-  @Override
-  public synchronized void evictAllNoReindex() {
-    byId.clear();
-    byUsername.clear();
-  }
-
-  public synchronized void put(Account account) {
-    AccountState state = newState(account);
-    byId.put(account.getId(), state);
-    if (account.getUserName() != null) {
-      byUsername.put(account.getUserName(), state);
-    }
-  }
-
-  private static AccountState newState(Account account) {
-    return new AccountState(
-        new AllUsersName(AllUsersNameProvider.DEFAULT),
-        account,
-        ImmutableSet.of(),
-        ImmutableSet.of(),
-        new HashMap<>());
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
deleted file mode 100644
index c70d241..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
+++ /dev/null
@@ -1,173 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.send.EmailHeader;
-import com.google.gerrit.server.mail.send.EmailSender;
-import com.google.inject.AbstractModule;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ExecutionException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Email sender implementation that records messages in memory.
- *
- * <p>This class is mostly threadsafe. The only exception is that not all {@link EmailHeader}
- * subclasses are immutable. In particular, if a caller holds a reference to an {@code AddressList}
- * and mutates it after sending, the message returned by {@link #getMessages()} may or may not
- * reflect mutations.
- */
-@Singleton
-public class FakeEmailSender implements EmailSender {
-  private static final Logger log = LoggerFactory.getLogger(FakeEmailSender.class);
-
-  public static class Module extends AbstractModule {
-    @Override
-    public void configure() {
-      bind(EmailSender.class).to(FakeEmailSender.class);
-    }
-  }
-
-  @AutoValue
-  public abstract static class Message {
-    private static Message create(
-        Address from,
-        Collection<Address> rcpt,
-        Map<String, EmailHeader> headers,
-        String body,
-        String htmlBody) {
-      return new AutoValue_FakeEmailSender_Message(
-          from, ImmutableList.copyOf(rcpt), ImmutableMap.copyOf(headers), body, htmlBody);
-    }
-
-    public abstract Address from();
-
-    public abstract ImmutableList<Address> rcpt();
-
-    public abstract ImmutableMap<String, EmailHeader> headers();
-
-    public abstract String body();
-
-    @Nullable
-    public abstract String htmlBody();
-  }
-
-  private final WorkQueue workQueue;
-  private final List<Message> messages;
-  private int messagesRead;
-
-  @Inject
-  FakeEmailSender(WorkQueue workQueue) {
-    this.workQueue = workQueue;
-    messages = Collections.synchronizedList(new ArrayList<Message>());
-    messagesRead = 0;
-  }
-
-  @Override
-  public boolean isEnabled() {
-    return true;
-  }
-
-  @Override
-  public boolean canEmail(String address) {
-    return true;
-  }
-
-  @Override
-  public void send(
-      Address from, Collection<Address> rcpt, Map<String, EmailHeader> headers, String body)
-      throws EmailException {
-    send(from, rcpt, headers, body, null);
-  }
-
-  @Override
-  public void send(
-      Address from,
-      Collection<Address> rcpt,
-      Map<String, EmailHeader> headers,
-      String body,
-      String htmlBody)
-      throws EmailException {
-    messages.add(Message.create(from, rcpt, headers, body, htmlBody));
-  }
-
-  public void clear() {
-    waitForEmails();
-    synchronized (messages) {
-      messages.clear();
-      messagesRead = 0;
-    }
-  }
-
-  public synchronized @Nullable Message peekMessage() {
-    if (messagesRead >= messages.size()) {
-      return null;
-    }
-    return messages.get(messagesRead);
-  }
-
-  public synchronized @Nullable Message nextMessage() {
-    Message msg = peekMessage();
-    messagesRead++;
-    return msg;
-  }
-
-  public ImmutableList<Message> getMessages() {
-    waitForEmails();
-    synchronized (messages) {
-      return ImmutableList.copyOf(messages);
-    }
-  }
-
-  public List<Message> getMessages(String changeId, String type) {
-    final String idFooter = "\nGerrit-Change-Id: " + changeId + "\n";
-    final String typeFooter = "\nGerrit-MessageType: " + type + "\n";
-    return getMessages()
-        .stream()
-        .filter(in -> in.body().contains(idFooter) && in.body().contains(typeFooter))
-        .collect(toList());
-  }
-
-  private void waitForEmails() {
-    // TODO(dborowitz): This is brittle; consider forcing emails to use
-    // a single thread in tests (tricky because most callers just use the
-    // default executor).
-    for (WorkQueue.Task<?> task : workQueue.getTasks()) {
-      if (task.toString().contains("send-email")) {
-        try {
-          task.get();
-        } catch (ExecutionException | InterruptedException e) {
-          log.warn("error finishing email task", e);
-        }
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
deleted file mode 100644
index 4ba5d3a..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import com.google.common.base.CharMatcher;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
-import org.junit.Ignore;
-import org.junit.Rule;
-import org.junit.rules.ExpectedException;
-import org.junit.rules.TestName;
-
-@Ignore
-public abstract class GerritBaseTests {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
-  @Rule public ExpectedException exception = ExpectedException.none();
-  @Rule public final TestName testName = new TestName();
-
-  protected String getSanitizedMethodName() {
-    String name = testName.getMethodName().toLowerCase();
-    name = CharMatcher.javaLetterOrDigit().negate().replaceFrom(name, '_');
-    name = CharMatcher.is('_').trimTrailingFrom(name);
-    return name;
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
deleted file mode 100644
index b84b8ed..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
+++ /dev/null
@@ -1,59 +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.testutil;
-
-import com.google.gerrit.server.notedb.MutableNotesMigration;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Rule;
-import org.junit.rules.TestRule;
-import org.junit.runner.Description;
-import org.junit.runner.RunWith;
-import org.junit.runners.model.Statement;
-
-@RunWith(ConfigSuite.class)
-public class GerritServerTests extends GerritBaseTests {
-  @ConfigSuite.Parameter public Config config;
-
-  @ConfigSuite.Name private String configName;
-
-  protected MutableNotesMigration notesMigration;
-
-  @Rule
-  public TestRule testRunner =
-      new TestRule() {
-        @Override
-        public Statement apply(Statement base, Description description) {
-          return new Statement() {
-            @Override
-            public void evaluate() throws Throwable {
-              beforeTest();
-              try {
-                base.evaluate();
-              } finally {
-                afterTest();
-              }
-            }
-          };
-        }
-      };
-
-  public void beforeTest() throws Exception {
-    notesMigration = NoteDbMode.newNotesMigrationFromEnv();
-  }
-
-  public void afterTest() {
-    NoteDbMode.resetFromEnv(notesMigration);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryDatabase.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryDatabase.java
deleted file mode 100644
index 21b21ef..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryDatabase.java
+++ /dev/null
@@ -1,178 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.pgm.init.index.elasticsearch.ElasticIndexModuleOnInit;
-import com.google.gerrit.pgm.init.index.lucene.LuceneIndexModuleOnInit;
-import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
-import com.google.gerrit.reviewdb.client.SystemConfig;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.schema.SchemaVersion;
-import com.google.gwtorm.jdbc.Database;
-import com.google.gwtorm.jdbc.SimpleDataSource;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.AbstractModule;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import java.io.IOException;
-import java.sql.Connection;
-import java.sql.SQLException;
-import java.util.Properties;
-import javax.sql.DataSource;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-/**
- * An in-memory test instance of {@link ReviewDb} database.
- *
- * <p>Test classes should create one instance of this class for each unique test database they want
- * to use. When the tests needing this instance are complete, ensure that {@link
- * #drop(InMemoryDatabase)} is called to free the resources so the JVM running the unit tests
- * doesn't run out of heap space.
- */
-public class InMemoryDatabase implements SchemaFactory<ReviewDb> {
-  public static InMemoryDatabase newDatabase(LifecycleManager lifecycle) {
-    Injector injector = Guice.createInjector(new InMemoryModule());
-    lifecycle.add(injector);
-    return injector.getInstance(InMemoryDatabase.class);
-  }
-
-  private static int dbCnt;
-
-  private static synchronized DataSource newDataSource() throws SQLException {
-    final Properties p = new Properties();
-    p.setProperty("driver", org.h2.Driver.class.getName());
-    p.setProperty("url", "jdbc:h2:mem:Test_" + (++dbCnt));
-    return new SimpleDataSource(p);
-  }
-
-  /** Drop the database from memory; does nothing if the instance was null. */
-  public static void drop(InMemoryDatabase db) {
-    if (db != null) {
-      db.drop();
-    }
-  }
-
-  private final SchemaCreator schemaCreator;
-
-  private Connection openHandle;
-  private Database<ReviewDb> database;
-  private boolean created;
-
-  @Inject
-  InMemoryDatabase(Injector injector) throws OrmException {
-    Injector childInjector =
-        injector.createChildInjector(
-            new AbstractModule() {
-              @Override
-              protected void configure() {
-                switch (IndexModule.getIndexType(injector)) {
-                  case LUCENE:
-                    install(new LuceneIndexModuleOnInit());
-                    break;
-                  case ELASTICSEARCH:
-                    install(new ElasticIndexModuleOnInit());
-                    break;
-                  default:
-                    throw new IllegalStateException("unsupported index.type");
-                }
-              }
-            });
-    this.schemaCreator = childInjector.getInstance(SchemaCreator.class);
-    initDatabase();
-  }
-
-  InMemoryDatabase(SchemaCreator schemaCreator) throws OrmException {
-    this.schemaCreator = schemaCreator;
-    initDatabase();
-  }
-
-  private void initDatabase() throws OrmException {
-    try {
-      DataSource dataSource = newDataSource();
-
-      // Open one connection. This will peg the database into memory
-      // until someone calls drop on us, allowing subsequent connections
-      // opened against the same URL to go to the same set of tables.
-      //
-      openHandle = dataSource.getConnection();
-
-      // Build the access layer around the connection factory.
-      //
-      database = new Database<>(dataSource, ReviewDb.class);
-
-    } catch (SQLException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  public Database<ReviewDb> getDatabase() {
-    return database;
-  }
-
-  @Override
-  public ReviewDb open() throws OrmException {
-    return getDatabase().open();
-  }
-
-  /** Ensure the database schema has been created and initialized. */
-  public InMemoryDatabase create() throws OrmException {
-    if (!created) {
-      created = true;
-      try (ReviewDb c = open()) {
-        schemaCreator.create(c);
-      } catch (IOException | ConfigInvalidException e) {
-        throw new OrmException("Cannot create in-memory database", e);
-      }
-    }
-    return this;
-  }
-
-  /** Drop this database from memory so it no longer exists. */
-  public void drop() {
-    if (openHandle != null) {
-      try {
-        openHandle.close();
-      } catch (SQLException e) {
-        System.err.println("WARNING: Cannot close database connection");
-        e.printStackTrace(System.err);
-      }
-      openHandle = null;
-      database = null;
-    }
-  }
-
-  public SystemConfig getSystemConfig() throws OrmException {
-    try (ReviewDb c = open()) {
-      return c.systemConfig().get(new SystemConfig.Key());
-    }
-  }
-
-  public CurrentSchemaVersion getSchemaVersion() throws OrmException {
-    try (ReviewDb c = open()) {
-      return c.schemaVersion().get(new CurrentSchemaVersion.Key());
-    }
-  }
-
-  public void assertSchemaVersion() throws OrmException {
-    assertThat(getSchemaVersion().versionNbr).isEqualTo(SchemaVersion.getBinaryVersion());
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryH2Type.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryH2Type.java
deleted file mode 100644
index 7edfa1a..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryH2Type.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import com.google.gerrit.server.schema.BaseDataSourceType;
-
-public class InMemoryH2Type extends BaseDataSourceType {
-
-  protected InMemoryH2Type() {
-    super(null);
-  }
-
-  @Override
-  public String getUrl() {
-    // not used
-    throw new UnsupportedOperationException();
-  }
-}
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
deleted file mode 100644
index e036495..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
+++ /dev/null
@@ -1,310 +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.testutil;
-
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.inject.Scopes.SINGLETON;
-
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.extensions.systemstatus.ServerInformation;
-import com.google.gerrit.gpg.GpgModule;
-import com.google.gerrit.index.SchemaDefinitions;
-import com.google.gerrit.metrics.DisabledMetricMaker;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.GerritPersonIdentProvider;
-import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import com.google.gerrit.server.config.CanonicalWebUrlModule;
-import com.google.gerrit.server.config.CanonicalWebUrlProvider;
-import com.google.gerrit.server.config.GerritGlobalModule;
-import com.google.gerrit.server.config.GerritOptions;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.GerritServerId;
-import com.google.gerrit.server.config.SitePath;
-import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.config.TrackingFootersProvider;
-import com.google.gerrit.server.git.GarbageCollection;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.PerThreadRequestScope;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
-import com.google.gerrit.server.git.SendEmailExecutor;
-import com.google.gerrit.server.index.IndexModule.IndexType;
-import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
-import com.google.gerrit.server.index.account.AllAccountsIndexer;
-import com.google.gerrit.server.index.change.AllChangesIndexer;
-import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
-import com.google.gerrit.server.index.group.AllGroupsIndexer;
-import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
-import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
-import com.google.gerrit.server.notedb.MutableNotesMigration;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.patch.DiffExecutor;
-import com.google.gerrit.server.plugins.PluginRestApiModule;
-import com.google.gerrit.server.plugins.ServerInformationImpl;
-import com.google.gerrit.server.project.DefaultPermissionBackendModule;
-import com.google.gerrit.server.schema.DataSourceType;
-import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
-import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
-import com.google.gerrit.server.schema.ReviewDbFactory;
-import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.securestore.DefaultSecureStore;
-import com.google.gerrit.server.securestore.SecureStore;
-import com.google.gerrit.server.ssh.NoSshKeyCache;
-import com.google.gerrit.server.update.ChangeUpdateExecutor;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.AbstractModule;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import com.google.inject.Key;
-import com.google.inject.Module;
-import com.google.inject.Provider;
-import com.google.inject.Provides;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.servlet.RequestScoped;
-import com.google.inject.util.Providers;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.ExecutorService;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-
-public class InMemoryModule extends FactoryModule {
-  public static Config newDefaultConfig() {
-    Config cfg = new Config();
-    setDefaults(cfg);
-    return cfg;
-  }
-
-  public static void setDefaults(Config cfg) {
-    cfg.setEnum("auth", null, "type", AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT);
-    cfg.setString("gerrit", null, "allProjects", "Test-Projects");
-    cfg.setString("gerrit", null, "basePath", "git");
-    cfg.setString("gerrit", null, "canonicalWebUrl", "http://test/");
-    cfg.setString("user", null, "name", "Gerrit Code Review");
-    cfg.setString("user", null, "email", "gerrit@localhost");
-    cfg.unset("cache", null, "directory");
-    cfg.setString("index", null, "type", "lucene");
-    cfg.setBoolean("index", "lucene", "testInmemory", true);
-    cfg.setInt("sendemail", null, "threadPoolSize", 0);
-    cfg.setBoolean("receive", null, "enableSignedPush", false);
-    cfg.setString("receive", null, "certNonceSeed", "sekret");
-  }
-
-  private final Config cfg;
-  private final MutableNotesMigration notesMigration;
-
-  public InMemoryModule() {
-    this(newDefaultConfig(), NoteDbMode.newNotesMigrationFromEnv());
-  }
-
-  public InMemoryModule(Config cfg, MutableNotesMigration notesMigration) {
-    this.cfg = cfg;
-    this.notesMigration = notesMigration;
-  }
-
-  public void inject(Object instance) {
-    Guice.createInjector(this).injectMembers(instance);
-  }
-
-  @Override
-  protected void configure() {
-    // Do NOT bind @RemotePeer, as it is bound in a child injector of
-    // ChangeMergeQueue (bound via GerritGlobalModule below), so there cannot be
-    // a binding in the parent injector. If you need @RemotePeer, you must bind
-    // it in a child injector of the one containing InMemoryModule. But unless
-    // you really need to test something request-scoped, you likely don't
-    // actually need it.
-
-    // For simplicity, don't create child injectors, just use this one to get a
-    // few required modules.
-    Injector cfgInjector =
-        Guice.createInjector(
-            new AbstractModule() {
-              @Override
-              protected void configure() {
-                bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
-              }
-            });
-    bind(MetricMaker.class).to(DisabledMetricMaker.class);
-    install(cfgInjector.getInstance(GerritGlobalModule.class));
-    install(new DefaultPermissionBackendModule());
-    install(new SearchingChangeCacheImpl.Module());
-    factory(GarbageCollection.Factory.class);
-
-    bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
-
-    // TODO(dborowitz): Use jimfs.
-    bind(Path.class).annotatedWith(SitePath.class).toInstance(Paths.get("."));
-    bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
-    bind(GerritOptions.class).toInstance(new GerritOptions(cfg, false, false, false));
-    bind(PersonIdent.class)
-        .annotatedWith(GerritPersonIdent.class)
-        .toProvider(GerritPersonIdentProvider.class);
-    bind(String.class)
-        .annotatedWith(AnonymousCowardName.class)
-        .toProvider(AnonymousCowardNameProvider.class);
-    bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
-    bind(AllProjectsName.class).toProvider(AllProjectsNameProvider.class);
-    bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
-    bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
-    bind(InMemoryRepositoryManager.class).in(SINGLETON);
-    bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class).in(SINGLETON);
-    bind(MutableNotesMigration.class).toInstance(notesMigration);
-    bind(NotesMigration.class).to(MutableNotesMigration.class);
-    bind(ListeningExecutorService.class)
-        .annotatedWith(ChangeUpdateExecutor.class)
-        .toInstance(MoreExecutors.newDirectExecutorService());
-    bind(DataSourceType.class).to(InMemoryH2Type.class);
-    bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
-    bind(SecureStore.class).to(DefaultSecureStore.class);
-
-    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
-        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
-    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
-    bind(Key.get(schemaFactory, ReviewDbFactory.class)).to(InMemoryDatabase.class);
-
-    install(NoSshKeyCache.module());
-    install(
-        new CanonicalWebUrlModule() {
-          @Override
-          protected Class<? extends Provider<String>> provider() {
-            return CanonicalWebUrlProvider.class;
-          }
-        });
-    //Replacement of DiffExecutorModule to not use thread pool in the tests
-    install(
-        new AbstractModule() {
-          @Override
-          protected void configure() {}
-
-          @Provides
-          @Singleton
-          @DiffExecutor
-          public ExecutorService createDiffExecutor() {
-            return MoreExecutors.newDirectExecutorService();
-          }
-        });
-    install(new DefaultCacheFactory.Module());
-    install(new FakeEmailSender.Module());
-    install(new SignedTokenEmailTokenVerifier.Module());
-    install(new GpgModule(cfg));
-    install(new InMemoryAccountPatchReviewStore.Module());
-
-    bind(AllAccountsIndexer.class).toProvider(Providers.of(null));
-    bind(AllChangesIndexer.class).toProvider(Providers.of(null));
-    bind(AllGroupsIndexer.class).toProvider(Providers.of(null));
-
-    IndexType indexType = null;
-    try {
-      indexType = cfg.getEnum("index", null, "type", IndexType.LUCENE);
-    } catch (IllegalArgumentException e) {
-      // Custom index type, caller must provide their own module.
-    }
-    if (indexType != null) {
-      switch (indexType) {
-        case LUCENE:
-          install(luceneIndexModule());
-          break;
-        case ELASTICSEARCH:
-          install(elasticIndexModule());
-          break;
-        default:
-          throw new ProvisionException("index type unsupported in tests: " + indexType);
-      }
-    }
-    bind(ServerInformationImpl.class);
-    bind(ServerInformation.class).to(ServerInformationImpl.class);
-    install(new PluginRestApiModule());
-  }
-
-  @Provides
-  @Singleton
-  @SendEmailExecutor
-  public ExecutorService createSendEmailExecutor() {
-    return MoreExecutors.newDirectExecutorService();
-  }
-
-  @Provides
-  @Singleton
-  InMemoryDatabase getInMemoryDatabase(SchemaCreator schemaCreator) throws OrmException {
-    return new InMemoryDatabase(schemaCreator);
-  }
-
-  private Module luceneIndexModule() {
-    return indexModule("com.google.gerrit.lucene.LuceneIndexModule");
-  }
-
-  private Module elasticIndexModule() {
-    return indexModule("com.google.gerrit.elasticsearch.ElasticIndexModule");
-  }
-
-  private Module indexModule(String moduleClassName) {
-    try {
-      Class<?> clazz = Class.forName(moduleClassName);
-      Method m = clazz.getMethod("singleVersionWithExplicitVersions", Map.class, int.class);
-      return (Module) m.invoke(null, getSingleSchemaVersions(), 0);
-    } catch (ClassNotFoundException
-        | SecurityException
-        | NoSuchMethodException
-        | IllegalArgumentException
-        | IllegalAccessException
-        | InvocationTargetException e) {
-      e.printStackTrace();
-      ProvisionException pe = new ProvisionException(e.getMessage());
-      pe.initCause(e);
-      throw pe;
-    }
-  }
-
-  private Map<String, Integer> getSingleSchemaVersions() {
-    Map<String, Integer> singleVersions = new HashMap<>();
-    putSchemaVersion(singleVersions, AccountSchemaDefinitions.INSTANCE);
-    putSchemaVersion(singleVersions, ChangeSchemaDefinitions.INSTANCE);
-    putSchemaVersion(singleVersions, GroupSchemaDefinitions.INSTANCE);
-    return singleVersions;
-  }
-
-  private void putSchemaVersion(
-      Map<String, Integer> singleVersions, SchemaDefinitions<?> schemaDef) {
-    String schemaName = schemaDef.getName();
-    int version = cfg.getInt("index", "lucene", schemaName + "TestVersion", -1);
-    if (version > 0) {
-      checkState(
-          !singleVersions.containsKey(schemaName),
-          "version for schema %s was alreay set",
-          schemaName);
-      singleVersions.put(schemaName, version);
-    }
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
deleted file mode 100644
index e0c51b7..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
+++ /dev/null
@@ -1,127 +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.testutil;
-
-import com.google.common.collect.ImmutableSortedSet;
-import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.RepositoryCaseMismatchException;
-import com.google.inject.Inject;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.SortedSet;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.internal.storage.dfs.DfsRepository;
-import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-
-/** Repository manager that uses in-memory repositories. */
-public class InMemoryRepositoryManager implements GitRepositoryManager {
-  public static InMemoryRepository newRepository(Project.NameKey name) {
-    return new Repo(name);
-  }
-
-  public static class Description extends DfsRepositoryDescription {
-    private final Project.NameKey name;
-
-    private Description(Project.NameKey name) {
-      super(name.get());
-      this.name = name;
-    }
-
-    public Project.NameKey getProject() {
-      return name;
-    }
-  }
-
-  public static class Repo extends InMemoryRepository {
-    private String description;
-
-    private Repo(Project.NameKey name) {
-      super(new Description(name));
-      setPerformsAtomicTransactions(true);
-    }
-
-    @Override
-    public Description getDescription() {
-      return (Description) super.getDescription();
-    }
-
-    @Override
-    public String getGitwebDescription() {
-      return description;
-    }
-
-    @Override
-    public void setGitwebDescription(String d) {
-      description = d;
-    }
-  }
-
-  private final Map<String, Repo> repos;
-
-  @Inject
-  public InMemoryRepositoryManager() {
-    this.repos = new HashMap<>();
-  }
-
-  @Override
-  public synchronized Repo openRepository(Project.NameKey name) throws RepositoryNotFoundException {
-    return get(name);
-  }
-
-  @Override
-  public synchronized Repo createRepository(Project.NameKey name)
-      throws RepositoryCaseMismatchException, RepositoryNotFoundException {
-    Repo repo;
-    try {
-      repo = get(name);
-      if (!repo.getDescription().getRepositoryName().equals(name.get())) {
-        throw new RepositoryCaseMismatchException(name);
-      }
-    } catch (RepositoryNotFoundException e) {
-      repo = new Repo(name);
-      repos.put(normalize(name), repo);
-    }
-    return repo;
-  }
-
-  @Override
-  public synchronized SortedSet<Project.NameKey> list() {
-    SortedSet<Project.NameKey> names = Sets.newTreeSet();
-    for (DfsRepository repo : repos.values()) {
-      names.add(new Project.NameKey(repo.getDescription().getRepositoryName()));
-    }
-    return ImmutableSortedSet.copyOf(names);
-  }
-
-  public synchronized void deleteRepository(Project.NameKey name) {
-    repos.remove(normalize(name));
-  }
-
-  private synchronized Repo get(Project.NameKey name) throws RepositoryNotFoundException {
-    Repo repo = repos.get(normalize(name));
-    if (repo != null) {
-      repo.incrementOpen();
-      return repo;
-    }
-    throw new RepositoryNotFoundException(name.get());
-  }
-
-  private static String normalize(Project.NameKey name) {
-    return name.get().toLowerCase();
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/IndexVersions.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/IndexVersions.java
deleted file mode 100644
index c2ba740..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/IndexVersions.java
+++ /dev/null
@@ -1,146 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static java.util.stream.Collectors.toMap;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Splitter;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.SchemaDefinitions;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.SortedMap;
-import org.eclipse.jgit.lib.Config;
-
-public class IndexVersions {
-  static final String ALL = "all";
-  static final String CURRENT = "current";
-  static final String PREVIOUS = "previous";
-
-  /**
-   * Returns the index versions from {@link IndexVersions#get(SchemaDefinitions)} without the latest
-   * schema version.
-   *
-   * @param schemaDef the schema definition
-   * @return the index versions from {@link IndexVersions#get(SchemaDefinitions)} without the latest
-   *     schema version
-   */
-  public static <V> ImmutableList<Integer> getWithoutLatest(SchemaDefinitions<V> schemaDef) {
-    List<Integer> schemaVersions = new ArrayList<>(get(schemaDef));
-    schemaVersions.remove(Integer.valueOf(schemaDef.getLatest().getVersion()));
-    return ImmutableList.copyOf(schemaVersions);
-  }
-
-  /**
-   * Returns the schema versions against which the query tests should be executed.
-   *
-   * <p>The schema versions are read from the '<schema-name>_INDEX_VERSIONS' env var if it is set,
-   * e.g. 'ACCOUNTS_INDEX_VERSIONS', 'CHANGES_INDEX_VERSIONS', 'GROUPS_INDEX_VERSIONS'.
-   *
-   * <p>If schema versions were not specified by an env var, they are read from the
-   * 'gerrit.index.<schema-name>.versions' system property, e.g. 'gerrit.index.accounts.version',
-   * 'gerrit.index.changes.version', 'gerrit.index.groups.version'.
-   *
-   * <p>As value a comma-separated list of schema versions is expected. {@code current} can be used
-   * for the latest schema version and {@code previous} is resolved to the second last schema
-   * version. Alternatively the value can also be {@code all} for all schema versions.
-   *
-   * <p>If schema versions were neither specified by an env var nor by a system property, the
-   * current and the second last schema versions are returned. If there is no other schema version
-   * than the current schema version, only the current schema version is returned.
-   *
-   * @param schemaDef the schema definition
-   * @return the schema versions against which the query tests should be executed
-   * @throws IllegalArgumentException if the value of the env var or system property is invalid or
-   *     if any of the specified schema versions doesn't exist
-   */
-  public static <V> ImmutableList<Integer> get(SchemaDefinitions<V> schemaDef) {
-    String envVar = schemaDef.getName().toUpperCase() + "_INDEX_VERSIONS";
-    String value = System.getenv(envVar);
-    if (!Strings.isNullOrEmpty(value)) {
-      return get(schemaDef, "env variable " + envVar, value);
-    }
-
-    String systemProperty = "gerrit.index." + schemaDef.getName().toLowerCase() + ".versions";
-    value = System.getProperty(systemProperty);
-    return get(schemaDef, "system property " + systemProperty, value);
-  }
-
-  @VisibleForTesting
-  static <V> ImmutableList<Integer> get(SchemaDefinitions<V> schemaDef, String name, String value) {
-    if (value != null) {
-      value = value.trim();
-    }
-
-    SortedMap<Integer, Schema<V>> schemas = schemaDef.getSchemas();
-    if (!Strings.isNullOrEmpty(value)) {
-      if (ALL.equals(value)) {
-        return ImmutableList.copyOf(schemas.keySet());
-      }
-
-      List<Integer> versions = new ArrayList<>();
-      for (String s : Splitter.on(',').trimResults().split(value)) {
-        if (CURRENT.equals(s)) {
-          versions.add(schemaDef.getLatest().getVersion());
-        } else if (PREVIOUS.equals(s)) {
-          checkArgument(schemaDef.getPrevious() != null, "previous version does not exist");
-          versions.add(schemaDef.getPrevious().getVersion());
-        } else {
-          Integer version = Ints.tryParse(s);
-          checkArgument(version != null, "Invalid value for %s: %s", name, s);
-          checkArgument(
-              schemas.containsKey(version),
-              "Index version %s that was specified by %s not found." + " Possible versions are: %s",
-              version,
-              name,
-              schemas.keySet());
-          versions.add(version);
-        }
-      }
-      return ImmutableList.copyOf(versions);
-    }
-
-    List<Integer> schemaVersions = new ArrayList<>(2);
-    if (schemaDef.getPrevious() != null) {
-      schemaVersions.add(schemaDef.getPrevious().getVersion());
-    }
-    schemaVersions.add(schemaDef.getLatest().getVersion());
-    return ImmutableList.copyOf(schemaVersions);
-  }
-
-  public static <V> Map<String, Config> asConfigMap(
-      SchemaDefinitions<V> schemaDef,
-      List<Integer> schemaVersions,
-      String testSuiteNamePrefix,
-      Config baseConfig) {
-    return schemaVersions
-        .stream()
-        .collect(
-            toMap(
-                i -> testSuiteNamePrefix + i,
-                i -> {
-                  Config cfg = baseConfig;
-                  cfg.setInt(
-                      "index", "lucene", schemaDef.getName().toLowerCase() + "TestVersion", i);
-                  return cfg;
-                }));
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/IndexVersionsTest.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/IndexVersionsTest.java
deleted file mode 100644
index d3c889a..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/IndexVersionsTest.java
+++ /dev/null
@@ -1,140 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.testutil.IndexVersions.ALL;
-import static com.google.gerrit.testutil.IndexVersions.CURRENT;
-import static com.google.gerrit.testutil.IndexVersions.PREVIOUS;
-
-import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
-import java.util.ArrayList;
-import java.util.List;
-import org.junit.Test;
-
-public class IndexVersionsTest extends GerritBaseTests {
-  private static final ChangeSchemaDefinitions SCHEMA_DEF = ChangeSchemaDefinitions.INSTANCE;
-
-  @Test
-  public void noValue() {
-    List<Integer> expected = new ArrayList<>();
-    if (SCHEMA_DEF.getPrevious() != null) {
-      expected.add(SCHEMA_DEF.getPrevious().getVersion());
-    }
-    expected.add(SCHEMA_DEF.getLatest().getVersion());
-
-    assertThat(get(null)).containsExactlyElementsIn(expected).inOrder();
-  }
-
-  @Test
-  public void emptyValue() {
-    List<Integer> expected = new ArrayList<>();
-    if (SCHEMA_DEF.getPrevious() != null) {
-      expected.add(SCHEMA_DEF.getPrevious().getVersion());
-    }
-    expected.add(SCHEMA_DEF.getLatest().getVersion());
-
-    assertThat(get("")).containsExactlyElementsIn(expected).inOrder();
-  }
-
-  @Test
-  public void all() {
-    assertThat(get(ALL)).containsExactlyElementsIn(SCHEMA_DEF.getSchemas().keySet()).inOrder();
-  }
-
-  @Test
-  public void current() {
-    assertThat(get(CURRENT)).containsExactly(SCHEMA_DEF.getLatest().getVersion());
-  }
-
-  @Test
-  public void previous() {
-    assume().that(SCHEMA_DEF.getPrevious()).isNotNull();
-
-    assertThat(get(PREVIOUS)).containsExactly(SCHEMA_DEF.getPrevious().getVersion());
-  }
-
-  @Test
-  public void versionNumber() {
-    assume().that(SCHEMA_DEF.getPrevious()).isNotNull();
-
-    assertThat(get(Integer.toString(SCHEMA_DEF.getPrevious().getVersion())))
-        .containsExactly(SCHEMA_DEF.getPrevious().getVersion());
-  }
-
-  @Test
-  public void invalid() {
-    assertIllegalArgument("foo", "Invalid value for test: foo");
-  }
-
-  @Test
-  public void currentAndPrevious() {
-    if (SCHEMA_DEF.getPrevious() == null) {
-      assertIllegalArgument(CURRENT + "," + PREVIOUS, "previous version does not exist");
-      return;
-    }
-
-    assertThat(get(CURRENT + "," + PREVIOUS))
-        .containsExactly(SCHEMA_DEF.getLatest().getVersion(), SCHEMA_DEF.getPrevious().getVersion())
-        .inOrder();
-    assertThat(get(PREVIOUS + "," + CURRENT))
-        .containsExactly(SCHEMA_DEF.getPrevious().getVersion(), SCHEMA_DEF.getLatest().getVersion())
-        .inOrder();
-  }
-
-  @Test
-  public void currentAndVersionNumber() {
-    assume().that(SCHEMA_DEF.getPrevious()).isNotNull();
-
-    assertThat(get(CURRENT + "," + SCHEMA_DEF.getPrevious().getVersion()))
-        .containsExactly(SCHEMA_DEF.getLatest().getVersion(), SCHEMA_DEF.getPrevious().getVersion())
-        .inOrder();
-    assertThat(get(SCHEMA_DEF.getPrevious().getVersion() + "," + CURRENT))
-        .containsExactly(SCHEMA_DEF.getPrevious().getVersion(), SCHEMA_DEF.getLatest().getVersion())
-        .inOrder();
-  }
-
-  @Test
-  public void currentAndAll() {
-    assertIllegalArgument(CURRENT + "," + ALL, "Invalid value for test: " + ALL);
-  }
-
-  @Test
-  public void currentAndInvalid() {
-    assertIllegalArgument(CURRENT + ",foo", "Invalid value for test: foo");
-  }
-
-  @Test
-  public void nonExistingVersion() {
-    int nonExistingVersion = SCHEMA_DEF.getLatest().getVersion() + 1;
-    assertIllegalArgument(
-        Integer.toString(nonExistingVersion),
-        "Index version "
-            + nonExistingVersion
-            + " that was specified by test not found. Possible versions are: "
-            + SCHEMA_DEF.getSchemas().keySet());
-  }
-
-  private static List<Integer> get(String value) {
-    return IndexVersions.get(ChangeSchemaDefinitions.INSTANCE, "test", value);
-  }
-
-  private void assertIllegalArgument(String value, String expectedMessage) {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage(expectedMessage);
-    get(value);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
deleted file mode 100644
index b5edb25..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
+++ /dev/null
@@ -1,205 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.Joiner;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeBundle;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.MutableNotesMigration;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
-import com.google.gwtorm.client.IntKey;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.stream.Stream;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.runner.Description;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class NoteDbChecker {
-  static final Logger log = LoggerFactory.getLogger(NoteDbChecker.class);
-
-  private final Provider<ReviewDb> dbProvider;
-  private final GitRepositoryManager repoManager;
-  private final MutableNotesMigration notesMigration;
-  private final ChangeBundleReader bundleReader;
-  private final ChangeNotes.Factory notesFactory;
-  private final ChangeRebuilder changeRebuilder;
-  private final CommentsUtil commentsUtil;
-
-  @Inject
-  NoteDbChecker(
-      Provider<ReviewDb> dbProvider,
-      GitRepositoryManager repoManager,
-      MutableNotesMigration notesMigration,
-      ChangeBundleReader bundleReader,
-      ChangeNotes.Factory notesFactory,
-      ChangeRebuilder changeRebuilder,
-      CommentsUtil commentsUtil) {
-    this.dbProvider = dbProvider;
-    this.repoManager = repoManager;
-    this.bundleReader = bundleReader;
-    this.notesMigration = notesMigration;
-    this.notesFactory = notesFactory;
-    this.changeRebuilder = changeRebuilder;
-    this.commentsUtil = commentsUtil;
-  }
-
-  public void rebuildAndCheckAllChanges() throws Exception {
-    rebuildAndCheckChanges(getUnwrappedDb().changes().all().toList().stream().map(Change::getId));
-  }
-
-  public void rebuildAndCheckChanges(Change.Id... changeIds) throws Exception {
-    rebuildAndCheckChanges(Arrays.stream(changeIds));
-  }
-
-  private void rebuildAndCheckChanges(Stream<Change.Id> changeIds) throws Exception {
-    ReviewDb db = getUnwrappedDb();
-
-    List<ChangeBundle> allExpected = readExpected(changeIds);
-
-    boolean oldWrite = notesMigration.rawWriteChangesSetting();
-    boolean oldRead = notesMigration.readChanges();
-    try {
-      notesMigration.setWriteChanges(true);
-      notesMigration.setReadChanges(true);
-      List<String> msgs = new ArrayList<>();
-      for (ChangeBundle expected : allExpected) {
-        Change c = expected.getChange();
-        try {
-          changeRebuilder.rebuild(db, c.getId());
-        } catch (RepositoryNotFoundException e) {
-          msgs.add("Repository not found for change, cannot convert: " + c);
-        }
-      }
-
-      checkActual(allExpected, msgs);
-    } finally {
-      notesMigration.setReadChanges(oldRead);
-      notesMigration.setWriteChanges(oldWrite);
-    }
-  }
-
-  public void checkChanges(Change.Id... changeIds) throws Exception {
-    checkActual(readExpected(Arrays.stream(changeIds)), new ArrayList<>());
-  }
-
-  public void assertNoChangeRef(Project.NameKey project, Change.Id changeId) throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef(RefNames.changeMetaRef(changeId))).isNull();
-    }
-  }
-
-  public void assertNoReviewDbChanges(Description desc) throws Exception {
-    ReviewDb db = getUnwrappedDb();
-    assertThat(db.changes().all().toList()).named("Changes in " + desc.getTestClass()).isEmpty();
-    assertThat(db.changeMessages().all().toList())
-        .named("ChangeMessages in " + desc.getTestClass())
-        .isEmpty();
-    assertThat(db.patchSets().all().toList())
-        .named("PatchSets in " + desc.getTestClass())
-        .isEmpty();
-    assertThat(db.patchSetApprovals().all().toList())
-        .named("PatchSetApprovals in " + desc.getTestClass())
-        .isEmpty();
-    assertThat(db.patchComments().all().toList())
-        .named("PatchLineComments in " + desc.getTestClass())
-        .isEmpty();
-  }
-
-  private List<ChangeBundle> readExpected(Stream<Change.Id> changeIds) throws Exception {
-    boolean old = notesMigration.readChanges();
-    try {
-      notesMigration.setReadChanges(false);
-      return changeIds
-          .sorted(comparing(IntKey::get))
-          .map(this::readBundleUnchecked)
-          .collect(toList());
-    } finally {
-      notesMigration.setReadChanges(old);
-    }
-  }
-
-  private ChangeBundle readBundleUnchecked(Change.Id id) {
-    try {
-      return bundleReader.fromReviewDb(getUnwrappedDb(), id);
-    } catch (OrmException e) {
-      throw new OrmRuntimeException(e);
-    }
-  }
-
-  private void checkActual(List<ChangeBundle> allExpected, List<String> msgs) throws Exception {
-    ReviewDb db = getUnwrappedDb();
-    boolean oldRead = notesMigration.readChanges();
-    boolean oldWrite = notesMigration.rawWriteChangesSetting();
-    try {
-      notesMigration.setWriteChanges(true);
-      notesMigration.setReadChanges(true);
-      for (ChangeBundle expected : allExpected) {
-        Change c = expected.getChange();
-        ChangeBundle actual;
-        try {
-          actual =
-              ChangeBundle.fromNotes(
-                  commentsUtil, notesFactory.create(db, c.getProject(), c.getId()));
-        } catch (Throwable t) {
-          String msg = "Error converting change: " + c;
-          msgs.add(msg);
-          log.error(msg, t);
-          continue;
-        }
-        List<String> diff = expected.differencesFrom(actual);
-        if (!diff.isEmpty()) {
-          msgs.add("Differences between ReviewDb and NoteDb for " + c + ":");
-          msgs.addAll(diff);
-          msgs.add("");
-        } else {
-          System.err.println("NoteDb conversion of change " + c.getId() + " successful");
-        }
-      }
-    } finally {
-      notesMigration.setReadChanges(oldRead);
-      notesMigration.setWriteChanges(oldWrite);
-    }
-    if (!msgs.isEmpty()) {
-      throw new AssertionError(Joiner.on('\n').join(msgs));
-    }
-  }
-
-  private ReviewDb getUnwrappedDb() {
-    ReviewDb db = dbProvider.get();
-    return ReviewDbUtil.unwrapDb(db);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
deleted file mode 100644
index 078ce43..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
+++ /dev/null
@@ -1,87 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.base.Enums;
-import com.google.common.base.Strings;
-import com.google.gerrit.server.notedb.MutableNotesMigration;
-import com.google.gerrit.server.notedb.NotesMigrationState;
-
-public enum NoteDbMode {
-  /** NoteDb is disabled. */
-  OFF(NotesMigrationState.REVIEW_DB),
-
-  /** Writing data to NoteDb is enabled. */
-  WRITE(NotesMigrationState.WRITE),
-
-  /** Reading and writing all data to NoteDb is enabled. */
-  READ_WRITE(NotesMigrationState.READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY),
-
-  /** Changes are created with their primary storage as NoteDb. */
-  PRIMARY(NotesMigrationState.READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY),
-
-  /** All change tables are entirely disabled, and code/meta ref updates are fused. */
-  ON(NotesMigrationState.NOTE_DB),
-
-  /**
-   * Run tests with NoteDb disabled, then convert ReviewDb to NoteDb and check that the results
-   * match.
-   */
-  CHECK(NotesMigrationState.REVIEW_DB);
-
-  private static final String ENV_VAR = "GERRIT_NOTEDB";
-  private static final String SYS_PROP = "gerrit.notedb";
-
-  public static NoteDbMode get() {
-    String value = System.getenv(ENV_VAR);
-    if (Strings.isNullOrEmpty(value)) {
-      value = System.getProperty(SYS_PROP);
-    }
-    if (Strings.isNullOrEmpty(value)) {
-      return OFF;
-    }
-    value = value.toUpperCase().replace("-", "_");
-    NoteDbMode mode = Enums.getIfPresent(NoteDbMode.class, value).orNull();
-    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
-      checkArgument(
-          mode != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
-    } else {
-      checkArgument(
-          mode != null,
-          "Invalid value for system property %s: %s",
-          SYS_PROP,
-          System.getProperty(SYS_PROP));
-    }
-    return mode;
-  }
-
-  public static MutableNotesMigration newNotesMigrationFromEnv() {
-    MutableNotesMigration m = MutableNotesMigration.newDisabled();
-    resetFromEnv(m);
-    return m;
-  }
-
-  public static void resetFromEnv(MutableNotesMigration migration) {
-    migration.setFrom(get().state);
-  }
-
-  private final NotesMigrationState state;
-
-  private NoteDbMode(NotesMigrationState state) {
-    this.state = state;
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/SchemaUpgradeTestEnvironment.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/SchemaUpgradeTestEnvironment.java
deleted file mode 100644
index adcde40..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/SchemaUpgradeTestEnvironment.java
+++ /dev/null
@@ -1,115 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Provider;
-import com.google.inject.util.Providers;
-import org.junit.rules.TestRule;
-import org.junit.runner.Description;
-import org.junit.runners.model.Statement;
-
-public final class SchemaUpgradeTestEnvironment implements TestRule {
-  @Inject private AccountManager accountManager;
-  @Inject private IdentifiedUser.GenericFactory userFactory;
-  @Inject private SchemaFactory<ReviewDb> schemaFactory;
-  @Inject private SchemaCreator schemaCreator;
-  @Inject private ThreadLocalRequestContext requestContext;
-  // Only for use in setting up/tearing down injector.
-  @Inject private InMemoryDatabase inMemoryDatabase;
-
-  private ReviewDb db;
-  private Injector injector;
-  private LifecycleManager lifecycle;
-
-  @Override
-  public Statement apply(Statement statement, Description description) {
-    return new Statement() {
-      @Override
-      public void evaluate() throws Throwable {
-        try {
-          setUp();
-          statement.evaluate();
-        } finally {
-          tearDown();
-        }
-      }
-    };
-  }
-
-  public ReviewDb getDb() {
-    return db;
-  }
-
-  public Injector getInjector() {
-    return injector;
-  }
-
-  public void setApiUser(Account.Id id) {
-    IdentifiedUser user = userFactory.create(id);
-    requestContext.setContext(
-        new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return user;
-          }
-
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return Providers.of(db);
-          }
-        });
-  }
-
-  private void setUp() throws Exception {
-    injector = Guice.createInjector(new InMemoryModule());
-    injector.injectMembers(this);
-    lifecycle = new LifecycleManager();
-    lifecycle.add(injector);
-    lifecycle.start();
-
-    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
-      schemaCreator.create(underlyingDb);
-    }
-    db = schemaFactory.open();
-    setApiUser(accountManager.authenticate(AuthRequest.forUser("user")).getAccountId());
-  }
-
-  private void tearDown() {
-    if (lifecycle != null) {
-      lifecycle.stop();
-    }
-    if (requestContext != null) {
-      requestContext.setContext(null);
-    }
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(inMemoryDatabase);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/SshMode.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/SshMode.java
deleted file mode 100644
index 0bf643cc..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/SshMode.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.base.Enums;
-import com.google.common.base.Strings;
-
-/**
- * Whether to enable/disable tests using SSH by inspecting the global environment.
- *
- * <p>Acceptance tests should generally not inspect this directly, since SSH may also be disabled on
- * a per-class or per-method basis. Inject {@code @SshEnabled boolean} instead.
- */
-public enum SshMode {
-  /** Tests annotated with UseSsh will be disabled. */
-  NO,
-
-  /** Tests annotated with UseSsh will be enabled. */
-  YES;
-
-  private static final String ENV_VAR = "GERRIT_USE_SSH";
-  private static final String SYS_PROP = "gerrit.use.ssh";
-
-  public static SshMode get() {
-    String value = System.getenv(ENV_VAR);
-    if (Strings.isNullOrEmpty(value)) {
-      value = System.getProperty(SYS_PROP);
-    }
-    if (Strings.isNullOrEmpty(value)) {
-      return YES;
-    }
-    value = value.toUpperCase();
-    SshMode mode = Enums.getIfPresent(SshMode.class, value).orNull();
-    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
-      checkArgument(
-          mode != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
-    } else {
-      checkArgument(
-          mode != null,
-          "Invalid value for system property %s: %s",
-          SYS_PROP,
-          System.getProperty(SYS_PROP));
-    }
-    return mode;
-  }
-
-  public static boolean useSsh() {
-    return get() == YES;
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TempFileUtil.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TempFileUtil.java
deleted file mode 100644
index f90a4fe..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TempFileUtil.java
+++ /dev/null
@@ -1,66 +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.testutil;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
-public class TempFileUtil {
-  private static List<File> allDirsCreated = new ArrayList<>();
-
-  public static synchronized File createTempDirectory() throws IOException {
-    File tmp = File.createTempFile("gerrit_test_", "").getCanonicalFile();
-    if (!tmp.delete() || !tmp.mkdir()) {
-      throw new IOException("Cannot create " + tmp.getPath());
-    }
-    allDirsCreated.add(tmp);
-    return tmp;
-  }
-
-  public static synchronized void cleanup() throws IOException {
-    for (File dir : allDirsCreated) {
-      recursivelyDelete(dir);
-    }
-    allDirsCreated.clear();
-  }
-
-  public static void recursivelyDelete(File dir) throws IOException {
-    if (!dir.getPath().equals(dir.getCanonicalPath())) {
-      // Directory symlink reaching outside of temporary space.
-      return;
-    }
-    File[] contents = dir.listFiles();
-    if (contents != null) {
-      for (File d : contents) {
-        if (d.isDirectory()) {
-          recursivelyDelete(d);
-        } else {
-          deleteNowOrOnExit(d);
-        }
-      }
-    }
-    deleteNowOrOnExit(dir);
-  }
-
-  private static void deleteNowOrOnExit(File dir) {
-    if (!dir.delete()) {
-      dir.deleteOnExit();
-    }
-  }
-
-  private TempFileUtil() {}
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java
deleted file mode 100644
index a47a0e5..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java
+++ /dev/null
@@ -1,136 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-
-import com.google.common.collect.Ordering;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.AbstractChangeNotes;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.inject.Injector;
-import java.util.TimeZone;
-import java.util.concurrent.atomic.AtomicInteger;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-
-/**
- * Utility functions to create and manipulate Change, ChangeUpdate, and ChangeControl objects for
- * testing.
- */
-public class TestChanges {
-  private static final AtomicInteger nextChangeId = new AtomicInteger(1);
-
-  public static Change newChange(Project.NameKey project, Account.Id userId) {
-    return newChange(project, userId, nextChangeId.getAndIncrement());
-  }
-
-  public static Change newChange(Project.NameKey project, Account.Id userId, int id) {
-    Change.Id changeId = new Change.Id(id);
-    Change c =
-        new Change(
-            new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
-            changeId,
-            userId,
-            new Branch.NameKey(project, "master"),
-            TimeUtil.nowTs());
-    incrementPatchSet(c);
-    return c;
-  }
-
-  public static PatchSet newPatchSet(PatchSet.Id id, ObjectId revision, Account.Id userId) {
-    return newPatchSet(id, revision.name(), userId);
-  }
-
-  public static PatchSet newPatchSet(PatchSet.Id id, String revision, Account.Id userId) {
-    PatchSet ps = new PatchSet(id);
-    ps.setRevision(new RevId(revision));
-    ps.setUploader(userId);
-    ps.setCreatedOn(TimeUtil.nowTs());
-    return ps;
-  }
-
-  public static ChangeUpdate newUpdate(Injector injector, Change c, CurrentUser user)
-      throws Exception {
-    injector =
-        injector.createChildInjector(
-            new FactoryModule() {
-              @Override
-              public void configure() {
-                bind(CurrentUser.class).toInstance(user);
-              }
-            });
-    ChangeUpdate update =
-        injector
-            .getInstance(ChangeUpdate.Factory.class)
-            .create(
-                new ChangeNotes(injector.getInstance(AbstractChangeNotes.Args.class), c).load(),
-                user,
-                TimeUtil.nowTs(),
-                Ordering.<String>natural());
-
-    ChangeNotes notes = update.getNotes();
-    boolean hasPatchSets = notes.getPatchSets() != null && !notes.getPatchSets().isEmpty();
-    NotesMigration migration = injector.getInstance(NotesMigration.class);
-    if (hasPatchSets || !migration.readChanges()) {
-      return update;
-    }
-
-    // Change doesn't exist yet. NoteDb requires that there be a commit for the
-    // first patch set, so create one.
-    GitRepositoryManager repoManager = injector.getInstance(GitRepositoryManager.class);
-    try (Repository repo = repoManager.openRepository(c.getProject())) {
-      TestRepository<Repository> tr = new TestRepository<>(repo);
-      PersonIdent ident =
-          user.asIdentifiedUser().newCommitterIdent(update.getWhen(), TimeZone.getDefault());
-      TestRepository<Repository>.CommitBuilder cb =
-          tr.commit()
-              .author(ident)
-              .committer(ident)
-              .message(firstNonNull(c.getSubject(), "Test change"));
-      Ref parent = repo.exactRef(c.getDest().get());
-      if (parent != null) {
-        cb.parent(tr.getRevWalk().parseCommit(parent.getObjectId()));
-      }
-      update.setBranch(c.getDest().get());
-      update.setChangeId(c.getKey().get());
-      update.setCommit(tr.getRevWalk(), cb.create());
-      return update;
-    }
-  }
-
-  public static void incrementPatchSet(Change change) {
-    PatchSet.Id curr = change.currentPatchSetId();
-    PatchSetInfo ps =
-        new PatchSetInfo(new PatchSet.Id(change.getId(), curr != null ? curr.get() + 1 : 1));
-    ps.setSubject("Change subject");
-    change.setCurrentPatchSet(ps);
-  }
-}
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
deleted file mode 100644
index 7921204..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
+++ /dev/null
@@ -1,147 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import static com.google.common.base.Preconditions.checkState;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-
-import java.sql.Timestamp;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicLong;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
-import org.eclipse.jgit.util.SystemReader;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeUtils;
-import org.joda.time.DateTimeUtils.MillisProvider;
-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));
-
-  private static Long clockStepMs;
-  private static AtomicLong clockMs;
-
-  /**
-   * Reset the clock to a known start point, then set the clock step.
-   *
-   * <p>The clock is initially set to 2009/09/30 17:00:00 -0400.
-   *
-   * @param clockStep amount to increment clock by on each lookup.
-   * @param clockStepUnit time unit for {@code clockStep}.
-   */
-  public static synchronized void resetWithClockStep(long clockStep, TimeUnit clockStepUnit) {
-    // Set an arbitrary start point so tests are more repeatable.
-    clockMs = new AtomicLong(START.getMillis());
-    setClockStep(clockStep, clockStepUnit);
-  }
-
-  /**
-   * Set the clock step used by {@link com.google.gerrit.common.TimeUtil}.
-   *
-   * @param clockStep amount to increment clock by on each lookup.
-   * @param clockStepUnit time unit for {@code clockStep}.
-   */
-  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);
-          }
-        });
-
-    SystemReader.setInstance(null);
-    final SystemReader defaultReader = SystemReader.getInstance();
-    SystemReader r =
-        new SystemReader() {
-          @Override
-          public String getHostname() {
-            return defaultReader.getHostname();
-          }
-
-          @Override
-          public String getenv(String variable) {
-            return defaultReader.getenv(variable);
-          }
-
-          @Override
-          public String getProperty(String key) {
-            return defaultReader.getProperty(key);
-          }
-
-          @Override
-          public FileBasedConfig openUserConfig(Config parent, FS fs) {
-            return defaultReader.openUserConfig(parent, fs);
-          }
-
-          @Override
-          public FileBasedConfig openSystemConfig(Config parent, FS fs) {
-            return defaultReader.openSystemConfig(parent, fs);
-          }
-
-          @Override
-          public long getCurrentTime() {
-            return clockMs.getAndAdd(clockStepMs);
-          }
-
-          @Override
-          public int getTimezone(long when) {
-            return defaultReader.getTimezone(when);
-          }
-        };
-    SystemReader.setInstance(r);
-  }
-
-  /**
-   * Set the clock to a specific timestamp.
-   *
-   * @param ts time to set
-   */
-  public static synchronized void setClock(Timestamp ts) {
-    checkState(clockMs != null, "call resetWithClockStep first");
-    clockMs.set(ts.getTime());
-  }
-
-  /**
-   * Increment the clock once by a given amount.
-   *
-   * @param clockStep amount to increment clock by.
-   * @param clockStepUnit time unit for {@code clockStep}.
-   */
-  public static synchronized void incrementClock(long clockStep, TimeUnit clockStepUnit) {
-    checkState(clockMs != null, "call resetWithClockStep first");
-    clockMs.addAndGet(clockStepUnit.toMillis(clockStep));
-  }
-
-  /**
-   * Reset the clock to use the actual system clock.
-   *
-   * <p>As a side effect, resets the {@link SystemReader} to the original default instance.
-   */
-  public static synchronized void useSystemTime() {
-    clockMs = null;
-    DateTimeUtils.setCurrentMillisSystem();
-    SystemReader.setInstance(null);
-  }
-
-  private TestTimeUtil() {}
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestUpdateUI.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestUpdateUI.java
deleted file mode 100644
index d4acbcb..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestUpdateUI.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import com.google.gerrit.server.schema.UpdateUI;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StatementExecutor;
-import java.util.List;
-import java.util.Set;
-
-public class TestUpdateUI implements UpdateUI {
-  @Override
-  public void message(String message) {}
-
-  @Override
-  public boolean yesno(boolean defaultValue, String message) {
-    return defaultValue;
-  }
-
-  @Override
-  public void waitForUser() {}
-
-  @Override
-  public String readString(String defaultValue, Set<String> allowedValues, String message) {
-    return defaultValue;
-  }
-
-  @Override
-  public boolean isBatch() {
-    return true;
-  }
-
-  @Override
-  public void pruneSchema(StatementExecutor e, List<String> pruneList) throws OrmException {
-    for (String sql : pruneList) {
-      e.execute(sql);
-    }
-  }
-}
diff --git a/gerrit-server/src/test/resources/com/google/gerrit/rules/gerrit_common_test.pl b/gerrit-server/src/test/resources/com/google/gerrit/rules/gerrit_common_test.pl
deleted file mode 100644
index c993394..0000000
--- a/gerrit-server/src/test/resources/com/google/gerrit/rules/gerrit_common_test.pl
+++ /dev/null
@@ -1,180 +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 gerrit.
-
-
-%% not_same
-%%
-test(not_same_success) :-
-  not_same(ok(a), ok(b)),
-  not_same(label(e, ok(a)), label(e, ok(b))).
-
-
-%% get_legacy_label_types
-%%
-test(get_legacy_label_types) :-
-  get_legacy_label_types(T),
-  T = [C, V],
-  C = label_type('Code-Review', 'MaxWithBlock', -2, 2),
-  V = label_type('Verified', 'MaxWithBlock', -1, 1).
-
-
-%% commit_label
-%%
-test(commit_label_all) :-
-  findall(commit_label(L, U), commit_label(L, U), Out),
-  all_commit_labels(Ls),
-  Ls = Out.
-
-test(commit_label_CodeReview) :-
-  L = label('Code-Review', _),
-  findall(p(L, U), commit_label(L, U), Out),
-  [ p(label('Code-Review', 2), test_user(bob)),
-    p(label('Code-Review', 2), test_user(alice)) ] == Out.
-
-
-%% max_with_block
-%%
-test(max_with_block_success_accept_max_score) :-
-  max_with_block('Code-Review', -2, 2, ok(test_user(alice))).
-
-test(max_with_block_success_reject_min_score) :-
-  max_with_block('You-Fail', -1, 1, reject(test_user(failer))).
-
-test(max_with_block_success_need_suggest) :-
-  max_with_block('Verified', -1, 1, need(1)).
-
-skip_test(max_with_block_success_impossible) :-
-  max_with_block('Code-Style', 0, 1, impossible(no_access)).
-
-
-%% default_submit
-%%
-test(default_submit_fails) :-
-  findall(P, default_submit(P), All),
-  All = [submit(C, V)],
-  C = label('Code-Review', ok(test_user(alice))),
-  V = label('Verified', need(1)).
-
-
-%% can_submit
-%%
-test(can_submit_ok) :-
-  set_commit_labels([
-    commit_label( label('Code-Review', 2), test_user(alice) ),
-    commit_label( label('Verified', 1), test_user(builder) )
-  ]),
-  can_submit(gerrit:default_submit, S),
-  S = ok(submit(C, V)),
-  C = label('Code-Review', ok(test_user(alice))),
-  V = label('Verified', ok(test_user(builder))).
-
-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))),
-  V = label('Verified', need(1)).
-
-test(can_submit_only_verified_not_ready) :-
-  can_submit(submit_only_verified, S),
-  S = not_ready(submit(V)),
-  V = label('Verified', need(1)).
-
-
-%% filter_submit_results
-%%
-test(filter_submit_remove_verified) :-
-  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))).
-
-test(filter_submit_add_code_review) :-
-  set_commit_labels([
-    commit_label( label('Code-Review', 2), test_user(alice) ),
-    commit_label( label('Verified', 1), test_user(builder) )
-  ]),
-  can_submit(submit_only_verified, R),
-  filter_submit_results(filter_in_cr, [R], S),
-  S = [ok(submit(C, V))],
-  C = label('Code-Review', ok(test_user(alice))),
-  V = label('Verified', ok(test_user(builder))).
-
-
-%% find_label
-%%
-test(find_default_code_review) :-
-  can_submit(gerrit:default_submit, R),
-  arg(1, R, S),
-  find_label(S, 'Code-Review', L),
-  L = label('Code-Review', ok(test_user(alice))).
-
-test(find_default_verified) :-
-  can_submit(gerrit:default_submit, R),
-  arg(1, R, S),
-  find_label(S, 'Verified', L),
-  L = label('Verified', need(1)).
-
-
-%% remove_label
-%%
-test(remove_default_code_review) :-
-  can_submit(gerrit:default_submit, R),
-  arg(1, R, S),
-  C = label('Code-Review', ok(test_user(alice))),
-  remove_label(S, C, Out),
-  Out = submit(V),
-  V = label('Verified', need(1)).
-
-
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-%%
-%% Supporting Data
-%%
-%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-
-setup :-
-  init,
-  all_commit_labels(Ls),
-  set_commit_labels(Ls).
-
-all_commit_labels(Ls) :-
-  Ls = [
-    commit_label( label('Code-Review', 2), test_user(alice) ),
-    commit_label( label('Code-Review', 2), test_user(bob) ),
-    commit_label( label('You-Fail', -1), test_user(failer) ),
-    commit_label( label('You-Fail', -1), test_user(alice) )
-  ].
-
-submit_only_verified(P) :-
-  max_with_block('Verified', -1, 1, Status),
-  P = submit(label('Verified', Status)).
-
-filter_out_v(R, S) :-
-  find_label(R, 'Verified', Verified), !,
-  remove_label(R, Verified, S).
-filter_out_v(R, S).
-
-filter_in_cr(R, S) :-
-  R =.. [submit | Labels],
-  max_with_block('Code-Review', -2, 2, Status),
-  CR = label('Code-Review', Status),
-  S =.. [submit , CR | Labels].
-
-:- package user.
-test_grant('Code-Review', test_user(alice), range(-2, 2)).
-test_grant('Verified', test_user(builder), range(-1, 1)).
-test_grant('You-Fail', test_user(alice), range(-1, 1)).
-test_grant('You-Fail', test_user(failer), range(-1, 1)).
diff --git a/gerrit-sshd/BUILD b/gerrit-sshd/BUILD
deleted file mode 100644
index 6dd0d5f..0000000
--- a/gerrit-sshd/BUILD
+++ /dev/null
@@ -1,55 +0,0 @@
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-SRCS = glob(["src/main/java/**/*.java"])
-
-java_library(
-    name = "sshd",
-    srcs = SRCS,
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-cache-h2:cache-h2",
-        "//gerrit-common:annotations",
-        "//gerrit-common:server",
-        "//gerrit-extension-api:api",
-        "//gerrit-lucene:lucene",
-        "//gerrit-patch-jgit:server",
-        "//gerrit-reviewdb:server",
-        "//gerrit-server:metrics",
-        "//gerrit-server:receive",
-        "//gerrit-server:server",
-        "//gerrit-util-cli:cli",
-        "//lib:args4j",
-        "//lib:gson",
-        "//lib:guava",
-        "//lib:gwtorm",
-        "//lib:jsch",
-        "//lib:servlet-api-3_1",
-        "//lib/auto:auto-value",
-        "//lib/bouncycastle:bcprov-neverlink",
-        "//lib/commons:codec",
-        "//lib/dropwizard:dropwizard-core",
-        "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-        "//lib/guice:guice-servlet",  # SSH should not depend on servlet
-        "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/log:api",
-        "//lib/log:log4j",
-        "//lib/mina:core",
-        "//lib/mina:sshd",
-    ],
-)
-
-junit_tests(
-    name = "sshd_tests",
-    srcs = glob(
-        ["src/test/java/**/*.java"],
-    ),
-    deps = [
-        ":sshd",
-        "//gerrit-extension-api:api",
-        "//gerrit-server:server",
-        "//lib:truth",
-        "//lib/mina:sshd",
-    ],
-)
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
deleted file mode 100644
index b9a98b9..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ /dev/null
@@ -1,109 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd;
-
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.AccessPath;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.sshd.SshScope.Context;
-import com.google.inject.Inject;
-import java.io.IOException;
-import org.apache.sshd.server.Environment;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Repository;
-import org.kohsuke.args4j.Argument;
-
-public abstract class AbstractGitCommand extends BaseCommand {
-  @Argument(index = 0, metaVar = "PROJECT.git", required = true, usage = "project name")
-  protected ProjectControl projectControl;
-
-  @Inject private SshScope sshScope;
-
-  @Inject private GitRepositoryManager repoManager;
-
-  @Inject private SshSession session;
-
-  @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;
-
-  @Override
-  public void start(Environment env) {
-    Context ctx = context.subContext(newSession(), context.getCommandLine());
-    final Context old = sshScope.set(ctx);
-    try {
-      startThread(
-          new ProjectCommandRunnable() {
-            @Override
-            public void executeParseCommand() throws Exception {
-              parseCommandLine();
-            }
-
-            @Override
-            public void run() throws Exception {
-              AbstractGitCommand.this.service();
-            }
-
-            @Override
-            public Project.NameKey getProjectName() {
-              return projectControl.getProjectState().getNameKey();
-            }
-          });
-    } finally {
-      sshScope.set(old);
-    }
-  }
-
-  private SshSession newSession() {
-    SshSession n =
-        new SshSession(
-            session,
-            session.getRemoteAddress(),
-            userFactory.create(session.getRemoteAddress(), user.getAccountId()));
-    n.setAccessPath(AccessPath.GIT);
-    return n;
-  }
-
-  private void service() throws IOException, PermissionBackendException, Failure {
-    state = projectControl.getProjectState();
-    project = state.getProject();
-    projectName = project.getNameKey();
-
-    try {
-      repo = repoManager.openRepository(projectName);
-    } catch (RepositoryNotFoundException e) {
-      throw new Failure(1, "fatal: '" + project.getName() + "': not a git archive", e);
-    }
-
-    try {
-      runImpl();
-    } finally {
-      repo.close();
-    }
-  }
-
-  protected abstract void runImpl() throws IOException, PermissionBackendException, Failure;
-}
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
deleted file mode 100644
index 634c47c..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
+++ /dev/null
@@ -1,564 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.util.concurrent.Atomics;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.annotations.PluginName;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.DynamicOptions;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.RequestCleanup;
-import com.google.gerrit.server.git.ProjectRunnable;
-import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.sshd.SshScope.Context;
-import com.google.gerrit.util.cli.CmdLineParser;
-import com.google.gerrit.util.cli.EndOfOptionsHandler;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import java.io.BufferedWriter;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InterruptedIOException;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.io.StringWriter;
-import java.nio.charset.Charset;
-import java.util.concurrent.Future;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import java.util.concurrent.atomic.AtomicReference;
-import org.apache.sshd.common.SshException;
-import org.apache.sshd.server.Command;
-import org.apache.sshd.server.Environment;
-import org.apache.sshd.server.ExitCallback;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.CmdLineException;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public abstract class BaseCommand implements Command {
-  private static final Logger log = LoggerFactory.getLogger(BaseCommand.class);
-  public static final Charset ENC = UTF_8;
-
-  private static final int PRIVATE_STATUS = 1 << 30;
-  static final int STATUS_CANCEL = PRIVATE_STATUS | 1;
-  static final int STATUS_NOT_FOUND = PRIVATE_STATUS | 2;
-  public static final int STATUS_NOT_ADMIN = PRIVATE_STATUS | 3;
-
-  @Option(name = "--", usage = "end of options", handler = EndOfOptionsHandler.class)
-  private boolean endOfOptions;
-
-  protected InputStream in;
-  protected OutputStream out;
-  protected OutputStream err;
-
-  private ExitCallback exit;
-
-  @Inject private SshScope sshScope;
-
-  @Inject private CmdLineParser.Factory cmdLineParserFactory;
-
-  @Inject private RequestCleanup cleanup;
-
-  @Inject @CommandExecutor private ScheduledThreadPoolExecutor executor;
-
-  @Inject private PermissionBackend permissionBackend;
-  @Inject private CurrentUser user;
-
-  @Inject private SshScope.Context context;
-
-  /** Commands declared by a plugin can be scoped by the plugin name. */
-  @Inject(optional = true)
-  @PluginName
-  private String pluginName;
-
-  @Inject private Injector injector;
-
-  @Inject private DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
-
-  /** The task, as scheduled on a worker thread. */
-  private final AtomicReference<Future<?>> task;
-
-  /** Text of the command line which lead up to invoking this instance. */
-  private String commandName = "";
-
-  /** Unparsed command line options. */
-  private String[] argv;
-
-  public BaseCommand() {
-    task = Atomics.newReference();
-  }
-
-  @Override
-  public void setInputStream(InputStream in) {
-    this.in = in;
-  }
-
-  @Override
-  public void setOutputStream(OutputStream out) {
-    this.out = out;
-  }
-
-  @Override
-  public void setErrorStream(OutputStream err) {
-    this.err = err;
-  }
-
-  @Override
-  public void setExitCallback(ExitCallback callback) {
-    this.exit = callback;
-  }
-
-  @Nullable
-  protected String getPluginName() {
-    return pluginName;
-  }
-
-  protected String getName() {
-    return commandName;
-  }
-
-  void setName(String prefix) {
-    this.commandName = prefix;
-  }
-
-  public String[] getArguments() {
-    return argv;
-  }
-
-  public void setArguments(String[] argv) {
-    this.argv = argv;
-  }
-
-  @Override
-  public void destroy() {
-    Future<?> future = task.getAndSet(null);
-    if (future != null && !future.isDone()) {
-      future.cancel(true);
-    }
-  }
-
-  /**
-   * Pass all state into the command, then run its start method.
-   *
-   * <p>This method copies all critical state, like the input and output streams, into the supplied
-   * command. The caller must still invoke {@code cmd.start()} if wants to pass control to the
-   * command.
-   *
-   * @param cmd the command that will receive the current state.
-   */
-  protected void provideStateTo(Command cmd) {
-    cmd.setInputStream(in);
-    cmd.setOutputStream(out);
-    cmd.setErrorStream(err);
-    cmd.setExitCallback(exit);
-  }
-
-  /**
-   * Parses the command line argument, injecting parsed values into fields.
-   *
-   * <p>This method must be explicitly invoked to cause a parse.
-   *
-   * @throws UnloggedFailure if the command line arguments were invalid.
-   * @see Option
-   * @see Argument
-   */
-  protected void parseCommandLine() throws UnloggedFailure {
-    parseCommandLine(this);
-  }
-
-  /**
-   * Parses the command line argument, injecting parsed values into fields.
-   *
-   * <p>This method must be explicitly invoked to cause a parse.
-   *
-   * @param options object whose fields declare Option and Argument annotations to describe the
-   *     parameters of the command. Usually {@code this}.
-   * @throws UnloggedFailure if the command line arguments were invalid.
-   * @see Option
-   * @see Argument
-   */
-  protected void parseCommandLine(Object options) throws UnloggedFailure {
-    final CmdLineParser clp = newCmdLineParser(options);
-    DynamicOptions pluginOptions = new DynamicOptions(options, injector, dynamicBeans);
-    pluginOptions.parseDynamicBeans(clp);
-    pluginOptions.setDynamicBeans();
-    pluginOptions.onBeanParseStart();
-    try {
-      clp.parseArgument(argv);
-    } catch (IllegalArgumentException | CmdLineException err) {
-      if (!clp.wasHelpRequestedByOption()) {
-        throw new UnloggedFailure(1, "fatal: " + err.getMessage());
-      }
-    }
-
-    if (clp.wasHelpRequestedByOption()) {
-      StringWriter msg = new StringWriter();
-      clp.printDetailedUsage(commandName, msg);
-      msg.write(usage());
-      throw new UnloggedFailure(1, msg.toString());
-    }
-    pluginOptions.onBeanParseEnd();
-  }
-
-  protected String usage() {
-    return "";
-  }
-
-  /** Construct a new parser for this command's received command line. */
-  protected CmdLineParser newCmdLineParser(Object options) {
-    return cmdLineParserFactory.create(options);
-  }
-
-  /**
-   * Spawn a function into its own thread.
-   *
-   * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
-   *
-   * <pre>
-   * startThread(new CommandRunnable() {
-   *   public void run() throws Exception {
-   *     runImp();
-   *   }
-   * });
-   * </pre>
-   *
-   * <p>If the function throws an exception, it is translated to a simple message for the client, a
-   * non-zero exit code, and the stack trace is logged.
-   *
-   * @param thunk the runnable to execute on the thread, performing the command's logic.
-   */
-  protected void startThread(CommandRunnable thunk) {
-    final TaskThunk tt = new TaskThunk(thunk);
-
-    if (isAdminHighPriorityCommand()) {
-      // Admin commands should not block the main work threads (there
-      // might be an interactive shell there), nor should they wait
-      // for the main work threads.
-      //
-      new Thread(tt, tt.toString()).start();
-    } else {
-      task.set(executor.submit(tt));
-    }
-  }
-
-  private boolean isAdminHighPriorityCommand() {
-    if (getClass().getAnnotation(AdminHighPriorityCommand.class) != null) {
-      try {
-        permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-        return true;
-      } catch (AuthException | PermissionBackendException e) {
-        return false;
-      }
-    }
-    return false;
-  }
-
-  /**
-   * Terminate this command and return a result code to the remote client.
-   *
-   * <p>Commands should invoke this at most once. Once invoked, the command may lose access to
-   * request based resources as any callbacks previously registered with {@link RequestCleanup} will
-   * fire.
-   *
-   * @param rc exit code for the remote client.
-   */
-  protected void onExit(int rc) {
-    exit.onExit(rc);
-    if (cleanup != null) {
-      cleanup.run();
-    }
-  }
-
-  /** Wrap the supplied output stream in a UTF-8 encoded PrintWriter. */
-  protected static PrintWriter toPrintWriter(OutputStream o) {
-    return new PrintWriter(new BufferedWriter(new OutputStreamWriter(o, ENC)));
-  }
-
-  private int handleError(Throwable e) {
-    if ((e.getClass() == IOException.class && "Pipe closed".equals(e.getMessage()))
-        || //
-        (e.getClass() == SshException.class && "Already closed".equals(e.getMessage()))
-        || //
-        e.getClass() == InterruptedIOException.class) {
-      // This is sshd telling us the client just dropped off while
-      // we were waiting for a read or a write to complete. Either
-      // way its not really a fatal error. Don't log it.
-      //
-      return 127;
-    }
-
-    if (!(e instanceof UnloggedFailure)) {
-      final StringBuilder m = new StringBuilder();
-      m.append("Internal server error");
-      if (user.isIdentifiedUser()) {
-        final IdentifiedUser u = user.asIdentifiedUser();
-        m.append(" (user ");
-        m.append(u.getAccount().getUserName());
-        m.append(" account ");
-        m.append(u.getAccountId());
-        m.append(")");
-      }
-      m.append(" during ");
-      m.append(context.getCommandLine());
-      log.error(m.toString(), e);
-    }
-
-    if (e instanceof Failure) {
-      final Failure f = (Failure) e;
-      try {
-        err.write((f.getMessage() + "\n").getBytes(ENC));
-        err.flush();
-      } catch (IOException e2) {
-        // Ignored
-      } catch (Throwable e2) {
-        log.warn("Cannot send failure message to client", e2);
-      }
-      return f.exitCode;
-    }
-
-    try {
-      err.write("fatal: internal server error\n".getBytes(ENC));
-      err.flush();
-    } catch (IOException e2) {
-      // Ignored
-    } catch (Throwable e2) {
-      log.warn("Cannot send internal server error message to client", e2);
-    }
-    return 128;
-  }
-
-  protected UnloggedFailure die(String msg) {
-    return new UnloggedFailure(1, "fatal: " + msg);
-  }
-
-  protected UnloggedFailure die(Throwable why) {
-    return new UnloggedFailure(1, "fatal: " + why.getMessage(), why);
-  }
-
-  protected void writeError(String type, String msg) {
-    try {
-      err.write((type + ": " + msg + "\n").getBytes(ENC));
-    } catch (IOException e) {
-      // Ignored
-    }
-  }
-
-  protected String getTaskDescription() {
-    StringBuilder m = new StringBuilder();
-    m.append(context.getCommandLine());
-    return m.toString();
-  }
-
-  private String getTaskName() {
-    StringBuilder m = new StringBuilder();
-    m.append(getTaskDescription());
-    if (user.isIdentifiedUser()) {
-      IdentifiedUser u = user.asIdentifiedUser();
-      m.append(" (").append(u.getAccount().getUserName()).append(")");
-    }
-    return m.toString();
-  }
-
-  private final class TaskThunk implements CancelableRunnable, ProjectRunnable {
-    private final CommandRunnable thunk;
-    private final String taskName;
-    private Project.NameKey projectName;
-
-    private TaskThunk(CommandRunnable thunk) {
-      this.thunk = thunk;
-      this.taskName = getTaskName();
-    }
-
-    @Override
-    public void cancel() {
-      synchronized (this) {
-        final Context old = sshScope.set(context);
-        try {
-          onExit(STATUS_CANCEL);
-        } finally {
-          sshScope.set(old);
-        }
-      }
-    }
-
-    @Override
-    public void run() {
-      synchronized (this) {
-        final Thread thisThread = Thread.currentThread();
-        final String thisName = thisThread.getName();
-        int rc = 0;
-        final Context old = sshScope.set(context);
-        try {
-          context.started = TimeUtil.nowMs();
-          thisThread.setName("SSH " + taskName);
-
-          if (thunk instanceof ProjectCommandRunnable) {
-            ((ProjectCommandRunnable) thunk).executeParseCommand();
-            projectName = ((ProjectCommandRunnable) thunk).getProjectName();
-          }
-
-          try {
-            thunk.run();
-          } catch (NoSuchProjectException e) {
-            throw new UnloggedFailure(1, e.getMessage());
-          } catch (NoSuchChangeException e) {
-            throw new UnloggedFailure(1, e.getMessage() + " no such change");
-          }
-
-          out.flush();
-          err.flush();
-        } catch (Throwable e) {
-          try {
-            out.flush();
-          } catch (Throwable e2) {
-            // Ignored
-          }
-          try {
-            err.flush();
-          } catch (Throwable e2) {
-            // Ignored
-          }
-          rc = handleError(e);
-        } finally {
-          try {
-            onExit(rc);
-          } finally {
-            sshScope.set(old);
-            thisThread.setName(thisName);
-          }
-        }
-      }
-    }
-
-    @Override
-    public String toString() {
-      return taskName;
-    }
-
-    @Override
-    public Project.NameKey getProjectNameKey() {
-      return projectName;
-    }
-
-    @Override
-    public String getRemoteName() {
-      return null;
-    }
-
-    @Override
-    public boolean hasCustomizedPrint() {
-      return false;
-    }
-  }
-
-  /** Runnable function which can throw an exception. */
-  @FunctionalInterface
-  public interface CommandRunnable {
-    void run() throws Exception;
-  }
-
-  /** Runnable function which can retrieve a project name related to the task */
-  public interface ProjectCommandRunnable extends CommandRunnable {
-    // execute parser command before running, in order to be able to retrieve
-    // project name
-    void executeParseCommand() throws Exception;
-
-    Project.NameKey getProjectName();
-  }
-
-  /** Thrown from {@link CommandRunnable#run()} with client message and code. */
-  public static class Failure extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    final int exitCode;
-
-    /**
-     * Create a new failure.
-     *
-     * @param exitCode exit code to return the client, which indicates the failure status of this
-     *     command. Should be between 1 and 255, inclusive.
-     * @param msg message to also send to the client's stderr.
-     */
-    public Failure(int exitCode, String msg) {
-      this(exitCode, msg, null);
-    }
-
-    /**
-     * Create a new failure.
-     *
-     * @param exitCode exit code to return the client, which indicates the failure status of this
-     *     command. Should be between 1 and 255, inclusive.
-     * @param msg message to also send to the client's stderr.
-     * @param why stack trace to include in the server's log, but is not sent to the client's
-     *     stderr.
-     */
-    public Failure(int exitCode, String msg, Throwable why) {
-      super(msg, why);
-      this.exitCode = exitCode;
-    }
-  }
-
-  /** Thrown from {@link CommandRunnable#run()} with client message and code. */
-  public static class UnloggedFailure extends Failure {
-    private static final long serialVersionUID = 1L;
-
-    /**
-     * Create a new failure.
-     *
-     * @param msg message to also send to the client's stderr.
-     */
-    public UnloggedFailure(String msg) {
-      this(1, msg);
-    }
-
-    /**
-     * Create a new failure.
-     *
-     * @param exitCode exit code to return the client, which indicates the failure status of this
-     *     command. Should be between 1 and 255, inclusive.
-     * @param msg message to also send to the client's stderr.
-     */
-    public UnloggedFailure(int exitCode, String msg) {
-      this(exitCode, msg, null);
-    }
-
-    /**
-     * Create a new failure.
-     *
-     * @param exitCode exit code to return the client, which indicates the failure status of this
-     *     command. Should be between 1 and 255, inclusive.
-     * @param msg message to also send to the client's stderr.
-     * @param why stack trace to include in the server's log, but is not sent to the client's
-     *     stderr.
-     */
-    public UnloggedFailure(int exitCode, String msg, Throwable why) {
-      super(exitCode, msg, why);
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
deleted file mode 100644
index 1c55f48..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ /dev/null
@@ -1,138 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd;
-
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeFinder;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.ChangesCollection;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.sshd.BaseCommand.UnloggedFailure;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-
-public class ChangeArgumentParser {
-  private final CurrentUser currentUser;
-  private final ChangesCollection changesCollection;
-  private final ChangeFinder changeFinder;
-  private final ReviewDb db;
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final PermissionBackend permissionBackend;
-
-  @Inject
-  ChangeArgumentParser(
-      CurrentUser currentUser,
-      ChangesCollection changesCollection,
-      ChangeFinder changeFinder,
-      ReviewDb db,
-      ChangeNotes.Factory changeNotesFactory,
-      PermissionBackend permissionBackend) {
-    this.currentUser = currentUser;
-    this.changesCollection = changesCollection;
-    this.changeFinder = changeFinder;
-    this.db = db;
-    this.changeNotesFactory = changeNotesFactory;
-    this.permissionBackend = permissionBackend;
-  }
-
-  public void addChange(String id, Map<Change.Id, ChangeResource> changes)
-      throws UnloggedFailure, OrmException, PermissionBackendException {
-    addChange(id, changes, null);
-  }
-
-  public void addChange(
-      String id, Map<Change.Id, ChangeResource> changes, ProjectControl projectControl)
-      throws UnloggedFailure, OrmException, PermissionBackendException {
-    addChange(id, changes, projectControl, true);
-  }
-
-  public void addChange(
-      String id,
-      Map<Change.Id, ChangeResource> changes,
-      ProjectControl projectControl,
-      boolean useIndex)
-      throws UnloggedFailure, OrmException, PermissionBackendException {
-    List<ChangeNotes> matched = useIndex ? changeFinder.find(id) : changeFromNotesFactory(id);
-    List<ChangeNotes> toAdd = new ArrayList<>(changes.size());
-    boolean canMaintainServer;
-    try {
-      permissionBackend.user(currentUser).check(GlobalPermission.MAINTAIN_SERVER);
-      canMaintainServer = true;
-    } catch (AuthException | PermissionBackendException e) {
-      canMaintainServer = false;
-    }
-    for (ChangeNotes notes : matched) {
-      if (!changes.containsKey(notes.getChangeId())
-          && inProject(projectControl, notes.getProjectName())
-          && (canMaintainServer
-              || permissionBackend
-                  .user(currentUser)
-                  .change(notes)
-                  .database(db)
-                  .test(ChangePermission.READ))) {
-        toAdd.add(notes);
-      }
-    }
-
-    if (toAdd.isEmpty()) {
-      throw new UnloggedFailure(1, "\"" + id + "\" no such change");
-    } else if (toAdd.size() > 1) {
-      throw new UnloggedFailure(1, "\"" + id + "\" matches multiple changes");
-    }
-    Change.Id cId = toAdd.get(0).getChangeId();
-    ChangeResource changeResource;
-    try {
-      changeResource = changesCollection.parse(cId);
-    } catch (ResourceNotFoundException e) {
-      throw new UnloggedFailure(1, "\"" + id + "\" no such change");
-    }
-    changes.put(cId, changeResource);
-  }
-
-  private List<ChangeNotes> changeFromNotesFactory(String id) throws OrmException, UnloggedFailure {
-    return changeNotesFactory.create(db, parseId(id));
-  }
-
-  private List<Change.Id> parseId(String id) throws UnloggedFailure {
-    try {
-      return Arrays.asList(new Change.Id(Integer.parseInt(id)));
-    } catch (NumberFormatException e) {
-      throw new UnloggedFailure(2, "Invalid change ID " + id, e);
-    }
-  }
-
-  private boolean inProject(ProjectControl projectControl, Project.NameKey project) {
-    if (projectControl != null) {
-      return projectControl.getProject().getNameKey().equals(project);
-    }
-
-    // No --project option, so they want every project.
-    return true;
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java
deleted file mode 100644
index c0b6d5a..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java
+++ /dev/null
@@ -1,87 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd;
-
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import java.io.File;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
-import org.apache.sshd.common.keyprovider.KeyPairProvider;
-import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
-
-class HostKeyProvider implements Provider<KeyPairProvider> {
-  private final SitePaths site;
-
-  @Inject
-  HostKeyProvider(SitePaths site) {
-    this.site = site;
-  }
-
-  @Override
-  public KeyPairProvider get() {
-    Path objKey = site.ssh_key;
-    Path rsaKey = site.ssh_rsa;
-    Path dsaKey = site.ssh_dsa;
-    Path ecdsaKey_256 = site.ssh_ecdsa_256;
-    Path ecdsaKey_384 = site.ssh_ecdsa_384;
-    Path ecdsaKey_521 = site.ssh_ecdsa_521;
-    Path ed25519Key = site.ssh_ed25519;
-
-    final List<File> stdKeys = new ArrayList<>(6);
-    if (Files.exists(rsaKey)) {
-      stdKeys.add(rsaKey.toAbsolutePath().toFile());
-    }
-    if (Files.exists(dsaKey)) {
-      stdKeys.add(dsaKey.toAbsolutePath().toFile());
-    }
-    if (Files.exists(ecdsaKey_256)) {
-      stdKeys.add(ecdsaKey_256.toAbsolutePath().toFile());
-    }
-    if (Files.exists(ecdsaKey_384)) {
-      stdKeys.add(ecdsaKey_384.toAbsolutePath().toFile());
-    }
-    if (Files.exists(ecdsaKey_521)) {
-      stdKeys.add(ecdsaKey_521.toAbsolutePath().toFile());
-    }
-    if (Files.exists(ed25519Key)) {
-      stdKeys.add(ed25519Key.toAbsolutePath().toFile());
-    }
-
-    if (Files.exists(objKey)) {
-      if (stdKeys.isEmpty()) {
-        SimpleGeneratorHostKeyProvider p = new SimpleGeneratorHostKeyProvider();
-        p.setPath(objKey.toAbsolutePath());
-        return p;
-      }
-      // Both formats of host key exist, we don't know which format
-      // should be authoritative. Complain and abort.
-      //
-      stdKeys.add(objKey.toAbsolutePath().toFile());
-      throw new ProvisionException("Multiple host keys exist: " + stdKeys);
-    }
-    if (stdKeys.isEmpty()) {
-      throw new ProvisionException("No SSH keys under " + site.etc_dir);
-    }
-    FileKeyPairProvider kp = new FileKeyPairProvider();
-    kp.setFiles(stdKeys);
-    return kp;
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java
deleted file mode 100644
index 12064c8..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java
+++ /dev/null
@@ -1,291 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd;
-
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.audit.AuditService;
-import com.google.gerrit.audit.SshAuditEvent;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PeerDaemonUser;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.util.IdGenerator;
-import com.google.gerrit.server.util.SystemLog;
-import com.google.gerrit.sshd.SshScope.Context;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import org.apache.log4j.AsyncAppender;
-import org.apache.log4j.Level;
-import org.apache.log4j.Logger;
-import org.apache.log4j.spi.LoggingEvent;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-class SshLog implements LifecycleListener {
-  private static final Logger log = Logger.getLogger(SshLog.class);
-  private static final String LOG_NAME = "sshd_log";
-  private static final String P_SESSION = "session";
-  private static final String P_USER_NAME = "userName";
-  private static final String P_ACCOUNT_ID = "accountId";
-  private static final String P_WAIT = "queueWaitTime";
-  private static final String P_EXEC = "executionTime";
-  private static final String P_STATUS = "status";
-  private static final String P_AGENT = "agent";
-
-  private final Provider<SshSession> session;
-  private final Provider<Context> context;
-  private final AsyncAppender async;
-  private final AuditService auditService;
-
-  @Inject
-  SshLog(
-      final Provider<SshSession> session,
-      final Provider<Context> context,
-      SystemLog systemLog,
-      @GerritServerConfig Config config,
-      AuditService auditService) {
-    this.session = session;
-    this.context = context;
-    this.auditService = auditService;
-
-    if (!config.getBoolean("sshd", "requestLog", true)) {
-      async = null;
-      return;
-    }
-    async = systemLog.createAsyncAppender(LOG_NAME, new SshLogLayout());
-  }
-
-  @Override
-  public void start() {}
-
-  @Override
-  public void stop() {
-    if (async != null) {
-      async.close();
-    }
-  }
-
-  void onLogin() {
-    LoggingEvent entry = log("LOGIN FROM " + session.get().getRemoteAddressAsString());
-    if (async != null) {
-      async.append(entry);
-    }
-    audit(context.get(), "0", "LOGIN");
-  }
-
-  void onAuthFail(SshSession sd) {
-    final LoggingEvent event =
-        new LoggingEvent( //
-            Logger.class.getName(), // fqnOfCategoryClass
-            log, // logger
-            TimeUtil.nowMs(), // when
-            Level.INFO, // level
-            "AUTH FAILURE FROM " + sd.getRemoteAddressAsString(), // message text
-            "SSHD", // thread name
-            null, // exception information
-            null, // current NDC string
-            null, // caller location
-            null // MDC properties
-            );
-
-    event.setProperty(P_SESSION, id(sd.getSessionId()));
-    event.setProperty(P_USER_NAME, sd.getUsername());
-
-    final String error = sd.getAuthenticationError();
-    if (error != null) {
-      event.setProperty(P_STATUS, error);
-    }
-    if (async != null) {
-      async.append(event);
-    }
-    audit(null, "FAIL", "AUTH");
-  }
-
-  void onExecute(DispatchCommand dcmd, int exitValue, SshSession sshSession) {
-    final Context ctx = context.get();
-    ctx.finished = TimeUtil.nowMs();
-
-    String cmd = extractWhat(dcmd);
-
-    final LoggingEvent event = log(cmd);
-    event.setProperty(P_WAIT, (ctx.started - ctx.created) + "ms");
-    event.setProperty(P_EXEC, (ctx.finished - ctx.started) + "ms");
-
-    final String status;
-    switch (exitValue) {
-      case BaseCommand.STATUS_CANCEL:
-        status = "killed";
-        break;
-
-      case BaseCommand.STATUS_NOT_FOUND:
-        status = "not-found";
-        break;
-
-      case BaseCommand.STATUS_NOT_ADMIN:
-        status = "not-admin";
-        break;
-
-      default:
-        status = String.valueOf(exitValue);
-        break;
-    }
-    event.setProperty(P_STATUS, status);
-    String peerAgent = sshSession.getPeerAgent();
-    if (peerAgent != null) {
-      event.setProperty(P_AGENT, peerAgent);
-    }
-
-    if (async != null) {
-      async.append(event);
-    }
-    audit(context.get(), status, dcmd);
-  }
-
-  private ListMultimap<String, ?> extractParameters(DispatchCommand dcmd) {
-    if (dcmd == null) {
-      return MultimapBuilder.hashKeys(0).arrayListValues(0).build();
-    }
-    String[] cmdArgs = dcmd.getArguments();
-    String paramName = null;
-    int argPos = 0;
-    ListMultimap<String, String> parms = MultimapBuilder.hashKeys().arrayListValues().build();
-    for (int i = 2; i < cmdArgs.length; i++) {
-      String arg = cmdArgs[i];
-      // -- stop parameters parsing
-      if (arg.equals("--")) {
-        for (i++; i < cmdArgs.length; i++) {
-          parms.put("$" + argPos++, cmdArgs[i]);
-        }
-        break;
-      }
-      // --param=value
-      int eqPos = arg.indexOf('=');
-      if (arg.startsWith("--") && eqPos > 0) {
-        parms.put(arg.substring(0, eqPos), arg.substring(eqPos + 1));
-        continue;
-      }
-      // -p value or --param value
-      if (arg.startsWith("-")) {
-        if (paramName != null) {
-          parms.put(paramName, null);
-        }
-        paramName = arg;
-        continue;
-      }
-      // value
-      if (paramName == null) {
-        parms.put("$" + argPos++, arg);
-      } else {
-        parms.put(paramName, arg);
-        paramName = null;
-      }
-    }
-    if (paramName != null) {
-      parms.put(paramName, null);
-    }
-    return parms;
-  }
-
-  void onLogout() {
-    LoggingEvent entry = log("LOGOUT");
-    if (async != null) {
-      async.append(entry);
-    }
-    audit(context.get(), "0", "LOGOUT");
-  }
-
-  private LoggingEvent log(String msg) {
-    final SshSession sd = session.get();
-    final CurrentUser user = sd.getUser();
-
-    final LoggingEvent event =
-        new LoggingEvent( //
-            Logger.class.getName(), // fqnOfCategoryClass
-            log, // logger
-            TimeUtil.nowMs(), // when
-            Level.INFO, // level
-            msg, // message text
-            "SSHD", // thread name
-            null, // exception information
-            null, // current NDC string
-            null, // caller location
-            null // MDC properties
-            );
-
-    event.setProperty(P_SESSION, id(sd.getSessionId()));
-
-    String userName = "-";
-    String accountId = "-";
-
-    if (user != null && user.isIdentifiedUser()) {
-      IdentifiedUser u = user.asIdentifiedUser();
-      userName = u.getAccount().getUserName();
-      accountId = "a/" + u.getAccountId().toString();
-
-    } else if (user instanceof PeerDaemonUser) {
-      userName = PeerDaemonUser.USER_NAME;
-    }
-
-    event.setProperty(P_USER_NAME, userName);
-    event.setProperty(P_ACCOUNT_ID, accountId);
-
-    return event;
-  }
-
-  private static String id(int id) {
-    return IdGenerator.format(id);
-  }
-
-  void audit(Context ctx, Object result, String cmd) {
-    audit(ctx, result, cmd, null);
-  }
-
-  void audit(Context ctx, Object result, DispatchCommand cmd) {
-    audit(ctx, result, extractWhat(cmd), extractParameters(cmd));
-  }
-
-  private void audit(Context ctx, Object result, String cmd, ListMultimap<String, ?> params) {
-    String sessionId;
-    CurrentUser currentUser;
-    long created;
-    if (ctx == null) {
-      sessionId = null;
-      currentUser = null;
-      created = TimeUtil.nowMs();
-    } else {
-      SshSession session = ctx.getSession();
-      sessionId = IdGenerator.format(session.getSessionId());
-      currentUser = session.getUser();
-      created = ctx.created;
-    }
-    auditService.dispatch(new SshAuditEvent(sessionId, currentUser, cmd, created, params, result));
-  }
-
-  private String extractWhat(DispatchCommand dcmd) {
-    if (dcmd == null) {
-      return "Command was already destroyed";
-    }
-    StringBuilder commandName = new StringBuilder(dcmd.getCommandName());
-    String[] args = dcmd.getArguments();
-    for (int i = 1; i < args.length; i++) {
-      commandName.append(".").append(args[i]);
-    }
-    return commandName.toString();
-  }
-}
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
deleted file mode 100644
index 0d7fa24..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
+++ /dev/null
@@ -1,231 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License
-
-package com.google.gerrit.sshd.commands;
-
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ListChildProjects;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectControl;
-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;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@CommandMetaData(
-  name = "set-project-parent",
-  description = "Change the project permissions are inherited from"
-)
-final class AdminSetParent extends SshCommand {
-  private static final Logger log = LoggerFactory.getLogger(AdminSetParent.class);
-
-  @Option(
-    name = "--parent",
-    aliases = {"-p"},
-    metaVar = "NAME",
-    usage = "new parent project"
-  )
-  private ProjectControl newParent;
-
-  @Option(
-    name = "--children-of",
-    metaVar = "NAME",
-    usage = "parent project for which the child projects should be reparented"
-  )
-  private ProjectControl oldParent;
-
-  @Option(
-    name = "--exclude",
-    metaVar = "NAME",
-    usage = "child project of old parent project which should not be reparented"
-  )
-  private List<ProjectControl> excludedChildren = new ArrayList<>();
-
-  @Argument(
-    index = 0,
-    required = false,
-    multiValued = true,
-    metaVar = "NAME",
-    usage = "projects to modify"
-  )
-  private List<ProjectControl> children = new ArrayList<>();
-
-  @Inject private ProjectCache projectCache;
-
-  @Inject private MetaDataUpdate.User metaDataUpdateFactory;
-
-  @Inject private AllProjectsName allProjectsName;
-
-  @Inject private ListChildProjects listChildProjects;
-
-  private Project.NameKey newParentKey;
-
-  @Override
-  protected void run() throws Failure {
-    if (oldParent == null && children.isEmpty()) {
-      throw die(
-          "child projects have to be specified as "
-              + "arguments or the --children-of option has to be set");
-    }
-    if (oldParent == null && !excludedChildren.isEmpty()) {
-      throw die("--exclude can only be used together with --children-of");
-    }
-
-    final StringBuilder err = new StringBuilder();
-    final Set<Project.NameKey> grandParents = new HashSet<>();
-
-    grandParents.add(allProjectsName);
-
-    if (newParent != null) {
-      newParentKey = newParent.getProject().getNameKey();
-
-      // Catalog all grandparents of the "parent", we want to
-      // catch a cycle in the parent pointers before it occurs.
-      //
-      Project.NameKey gp = newParent.getProject().getParent();
-      while (gp != null && grandParents.add(gp)) {
-        final ProjectState s = projectCache.get(gp);
-        if (s != null) {
-          gp = s.getProject().getParent();
-        } else {
-          break;
-        }
-      }
-    }
-
-    final List<Project.NameKey> childProjects = new ArrayList<>();
-    for (ProjectControl pc : children) {
-      childProjects.add(pc.getProject().getNameKey());
-    }
-    if (oldParent != null) {
-      try {
-        childProjects.addAll(getChildrenForReparenting(oldParent));
-      } catch (PermissionBackendException e) {
-        throw new Failure(1, "permissions unavailable", e);
-      }
-    }
-
-    for (Project.NameKey nameKey : childProjects) {
-      final String name = nameKey.get();
-
-      if (allProjectsName.equals(nameKey)) {
-        // Don't allow the wild card project to have a parent.
-        //
-        err.append("error: Cannot set parent of '").append(name).append("'\n");
-        continue;
-      }
-
-      if (grandParents.contains(nameKey) || nameKey.equals(newParentKey)) {
-        // Try to avoid creating a cycle in the parent pointers.
-        //
-        err.append("error: Cycle exists between '")
-            .append(name)
-            .append("' and '")
-            .append(newParentKey != null ? newParentKey.get() : allProjectsName.get())
-            .append("'\n");
-        continue;
-      }
-
-      try (MetaDataUpdate md = metaDataUpdateFactory.create(nameKey)) {
-        ProjectConfig config = ProjectConfig.read(md);
-        config.getProject().setParentName(newParentKey);
-        md.setMessage(
-            "Inherit access from "
-                + (newParentKey != null ? newParentKey.get() : allProjectsName.get())
-                + "\n");
-        config.commit(md);
-      } catch (RepositoryNotFoundException notFound) {
-        err.append("error: Project ").append(name).append(" not found\n");
-      } catch (IOException | ConfigInvalidException e) {
-        final String msg = "Cannot update project " + name;
-        log.error(msg, e);
-        err.append("error: ").append(msg).append("\n");
-      }
-
-      projectCache.evict(nameKey);
-    }
-
-    if (err.length() > 0) {
-      while (err.charAt(err.length() - 1) == '\n') {
-        err.setLength(err.length() - 1);
-      }
-      throw die(err.toString());
-    }
-  }
-
-  /**
-   * Returns the children of the specified parent project that should be reparented. The returned
-   * list of child projects does not contain projects that were specified to be excluded from
-   * reparenting.
-   */
-  private List<Project.NameKey> getChildrenForReparenting(ProjectControl parent)
-      throws PermissionBackendException {
-    final List<Project.NameKey> childProjects = new ArrayList<>();
-    final List<Project.NameKey> excluded = new ArrayList<>(excludedChildren.size());
-    for (ProjectControl excludedChild : excludedChildren) {
-      excluded.add(excludedChild.getProject().getNameKey());
-    }
-    final List<Project.NameKey> automaticallyExcluded = new ArrayList<>(excludedChildren.size());
-    if (newParentKey != null) {
-      automaticallyExcluded.addAll(getAllParents(newParentKey));
-    }
-    for (ProjectInfo child : listChildProjects.apply(new ProjectResource(parent))) {
-      final Project.NameKey childName = new Project.NameKey(child.name);
-      if (!excluded.contains(childName)) {
-        if (!automaticallyExcluded.contains(childName)) {
-          childProjects.add(childName);
-        } else {
-          stdout.println(
-              "Automatically excluded '"
-                  + childName
-                  + "' "
-                  + "from reparenting because it is in the parent "
-                  + "line of the new parent '"
-                  + newParentKey
-                  + "'.");
-        }
-      }
-    }
-    return childProjects;
-  }
-
-  private Set<Project.NameKey> getAllParents(Project.NameKey projectName) {
-    ProjectState ps = projectCache.get(projectName);
-    if (ps == null) {
-      return Collections.emptySet();
-    }
-    return ps.parents().transform(s -> s.getNameKey()).toSet();
-  }
-}
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
deleted file mode 100644
index d06f65c..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
+++ /dev/null
@@ -1,90 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER;
-
-import com.google.common.base.Joiner;
-import com.google.common.collect.Lists;
-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.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.List;
-import org.eclipse.jgit.lib.ObjectId;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
-@CommandMetaData(
-  name = "ban-commit",
-  description = "Ban a commit from a project's repository",
-  runsAt = MASTER
-)
-public class BanCommitCommand extends SshCommand {
-  @Option(
-    name = "--reason",
-    aliases = {"-r"},
-    metaVar = "REASON",
-    usage = "reason for banning the commit"
-  )
-  private String reason;
-
-  @Argument(
-    index = 0,
-    required = true,
-    metaVar = "PROJECT",
-    usage = "name of the project for which the commit should be banned"
-  )
-  private ProjectControl projectControl;
-
-  @Argument(
-    index = 1,
-    required = true,
-    multiValued = true,
-    metaVar = "COMMIT",
-    usage = "commit(s) that should be banned"
-  )
-  private List<ObjectId> commitsToBan = new ArrayList<>();
-
-  @Inject private BanCommit banCommit;
-
-  @Override
-  protected void run() throws Failure {
-    try {
-      BanCommit.Input input =
-          BanCommit.Input.fromCommits(Lists.transform(commitsToBan, ObjectId::getName));
-      input.reason = reason;
-
-      BanResultInfo r = banCommit.apply(new ProjectResource(projectControl), 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");
-    } catch (Exception e) {
-      throw die(e);
-    }
-  }
-
-  private void printCommits(List<String> commits, String message) {
-    if (commits != null && !commits.isEmpty()) {
-      stdout.print(message + ":\n");
-      stdout.print(Joiner.on(",\n").join(commits));
-      stdout.print("\n\n");
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
deleted file mode 100644
index 56ff5ea..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import com.google.gerrit.extensions.common.TestSubmitRuleInput;
-import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.OutputFormat;
-import com.google.gerrit.server.change.ChangesCollection;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.change.Revisions;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import java.nio.ByteBuffer;
-import org.eclipse.jgit.util.IO;
-import org.eclipse.jgit.util.RawParseUtils;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
-abstract class BaseTestPrologCommand extends SshCommand {
-  private TestSubmitRuleInput input = new TestSubmitRuleInput();
-
-  @Inject private ChangesCollection changes;
-
-  @Inject private Revisions revisions;
-
-  @Argument(index = 0, required = true, usage = "ChangeId to load in prolog environment")
-  protected String changeId;
-
-  @Option(
-    name = "-s",
-    usage =
-        "Read prolog script from stdin instead of reading rules.pl from the refs/meta/config branch"
-  )
-  protected boolean useStdin;
-
-  @Option(
-    name = "--no-filters",
-    aliases = {"-n"},
-    usage = "Don't run the submit_filter/2 from the parent projects"
-  )
-  void setNoFilters(boolean no) {
-    input.filters = no ? Filters.SKIP : Filters.RUN;
-  }
-
-  protected abstract RestModifyView<RevisionResource, TestSubmitRuleInput> createView();
-
-  @Override
-  protected final void run() throws UnloggedFailure {
-    try {
-      RevisionResource revision =
-          revisions.parse(
-              changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId)),
-              IdString.fromUrl("current"));
-      if (useStdin) {
-        ByteBuffer buf = IO.readWholeStream(in, 4096);
-        input.rule = RawParseUtils.decode(buf.array(), buf.arrayOffset(), buf.limit());
-      }
-      Object result = createView().apply(revision, input);
-      OutputFormat.JSON.newGson().toJson(result, stdout);
-      stdout.print('\n');
-    } catch (Exception e) {
-      throw die("Processing of prolog script failed: " + e);
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
deleted file mode 100644
index 0c63fb3..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.api.accounts.AccountInput;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.account.CreateAccount;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.util.ArrayList;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
-/** Create a new user account. * */
-@RequiresCapability(GlobalCapability.CREATE_ACCOUNT)
-@CommandMetaData(name = "create-account", description = "Create a new batch/role account")
-final class CreateAccountCommand extends SshCommand {
-  @Option(
-    name = "--group",
-    aliases = {"-g"},
-    metaVar = "GROUP",
-    usage = "groups to add account to"
-  )
-  private List<AccountGroup.Id> groups = new ArrayList<>();
-
-  @Option(name = "--full-name", metaVar = "NAME", usage = "display name of the account")
-  private String fullName;
-
-  @Option(name = "--email", metaVar = "EMAIL", usage = "email address of the account")
-  private String email;
-
-  @Option(name = "--ssh-key", metaVar = "-|KEY", usage = "public key for SSH authentication")
-  private String sshKey;
-
-  @Option(
-    name = "--http-password",
-    metaVar = "PASSWORD",
-    usage = "password for HTTP authentication"
-  )
-  private String httpPassword;
-
-  @Argument(index = 0, required = true, metaVar = "USERNAME", usage = "name of the user account")
-  private String username;
-
-  @Inject private CreateAccount.Factory createAccountFactory;
-
-  @Override
-  protected void run() throws OrmException, IOException, ConfigInvalidException, UnloggedFailure {
-    AccountInput input = new AccountInput();
-    input.username = username;
-    input.email = email;
-    input.name = fullName;
-    input.sshKey = readSshKey();
-    input.httpPassword = httpPassword;
-    input.groups = Lists.transform(groups, AccountGroup.Id::toString);
-    try {
-      createAccountFactory.create(username).apply(TopLevelResource.INSTANCE, input);
-    } catch (RestApiException e) {
-      throw die(e.getMessage());
-    }
-  }
-
-  private String readSshKey() throws IOException {
-    if (sshKey == null) {
-      return null;
-    }
-    if ("-".equals(sshKey)) {
-      sshKey = "";
-      BufferedReader br = new BufferedReader(new InputStreamReader(in, UTF_8));
-      String line;
-      while ((line = br.readLine()) != null) {
-        sshKey += line + "\n";
-      }
-    }
-    return sshKey;
-  }
-}
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
deleted file mode 100644
index 5962faa..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-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.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import org.kohsuke.args4j.Argument;
-
-/** Create a new branch. * */
-@CommandMetaData(name = "create-branch", description = "Create a new branch")
-public final class CreateBranchCommand extends SshCommand {
-
-  @Argument(index = 0, required = true, metaVar = "PROJECT", usage = "name of the project")
-  private ProjectControl project;
-
-  @Argument(index = 1, required = true, metaVar = "NAME", usage = "name of branch to be created")
-  private String name;
-
-  @Argument(
-    index = 2,
-    required = true,
-    metaVar = "REVISION",
-    usage = "base revision of the new branch"
-  )
-  private String revision;
-
-  @Inject GerritApi gApi;
-
-  @Override
-  protected void run() throws UnloggedFailure {
-    try {
-      BranchInput in = new BranchInput();
-      in.revision = revision;
-      gApi.projects().name(project.getProject().getNameKey().get()).branch(name).create(in);
-    } catch (RestApiException e) {
-      throw die(e);
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
deleted file mode 100644
index 6b5d632..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ /dev/null
@@ -1,151 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.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.api.groups.GroupInput;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.group.AddMembers;
-import com.google.gerrit.server.group.AddSubgroups;
-import com.google.gerrit.server.group.CreateGroup;
-import com.google.gerrit.server.group.GroupResource;
-import com.google.gerrit.server.group.GroupsCollection;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.HashSet;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
-/**
- * Creates a new group.
- *
- * <p>Optionally, puts an initial set of user in the newly created group.
- */
-@RequiresCapability(GlobalCapability.CREATE_GROUP)
-@CommandMetaData(name = "create-group", description = "Create a new account group")
-final class CreateGroupCommand extends SshCommand {
-  @Option(
-    name = "--owner",
-    aliases = {"-o"},
-    metaVar = "GROUP",
-    usage = "owning group, if not specified the group will be self-owning"
-  )
-  private AccountGroup.Id ownerGroupId;
-
-  @Option(
-    name = "--description",
-    aliases = {"-d"},
-    metaVar = "DESC",
-    usage = "description of group"
-  )
-  private String groupDescription = "";
-
-  @Argument(index = 0, required = true, metaVar = "GROUP", usage = "name of group to be created")
-  private String groupName;
-
-  private final Set<Account.Id> initialMembers = new HashSet<>();
-
-  @Option(
-    name = "--member",
-    aliases = {"-m"},
-    metaVar = "USERNAME",
-    usage = "initial set of users to become members of the group"
-  )
-  void addMember(Account.Id id) {
-    initialMembers.add(id);
-  }
-
-  @Option(name = "--visible-to-all", usage = "to make the group visible to all registered users")
-  private boolean visibleToAll;
-
-  private final Set<AccountGroup.UUID> initialGroups = new HashSet<>();
-
-  @Option(
-    name = "--group",
-    aliases = "-g",
-    metaVar = "GROUP",
-    usage = "initial set of groups to be included in the group"
-  )
-  void addGroup(AccountGroup.UUID id) {
-    initialGroups.add(id);
-  }
-
-  @Inject private CreateGroup.Factory createGroupFactory;
-
-  @Inject private GroupsCollection groups;
-
-  @Inject private AddMembers addMembers;
-
-  @Inject private AddSubgroups addSubgroups;
-
-  @Override
-  protected void run() throws Failure, OrmException, IOException, ConfigInvalidException {
-    try {
-      GroupResource rsrc = createGroup();
-
-      if (!initialMembers.isEmpty()) {
-        addMembers(rsrc);
-      }
-
-      if (!initialGroups.isEmpty()) {
-        addSubgroups(rsrc);
-      }
-    } catch (RestApiException e) {
-      throw die(e);
-    }
-  }
-
-  private GroupResource createGroup()
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
-    GroupInput input = new GroupInput();
-    input.description = groupDescription;
-    input.visibleToAll = visibleToAll;
-
-    if (ownerGroupId != null) {
-      input.ownerId = String.valueOf(ownerGroupId.get());
-    }
-
-    GroupInfo group = createGroupFactory.create(groupName).apply(TopLevelResource.INSTANCE, input);
-    return groups.parse(TopLevelResource.INSTANCE, IdString.fromUrl(group.id));
-  }
-
-  private void addMembers(GroupResource rsrc)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
-    AddMembers.Input input =
-        AddMembers.Input.fromMembers(
-            initialMembers.stream().map(Object::toString).collect(toList()));
-    addMembers.apply(rsrc, input);
-  }
-
-  private void addSubgroups(GroupResource rsrc) throws RestApiException, OrmException, IOException {
-    AddSubgroups.Input input =
-        AddSubgroups.Input.fromGroups(
-            initialGroups.stream().map(AccountGroup.UUID::get).collect(toList()));
-    addSubgroups.apply(rsrc, input);
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
deleted file mode 100644
index 0df2a80..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ /dev/null
@@ -1,253 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Splitter;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.api.projects.ConfigValue;
-import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.SuggestParentCandidates;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
-/** Create a new project. * */
-@RequiresCapability(GlobalCapability.CREATE_PROJECT)
-@CommandMetaData(
-  name = "create-project",
-  description = "Create a new project and associated Git repository"
-)
-final class CreateProjectCommand extends SshCommand {
-  @Option(
-    name = "--suggest-parents",
-    aliases = {"-S"},
-    usage =
-        "suggest parent candidates, "
-            + "if this option is used all other options and arguments are ignored"
-  )
-  private boolean suggestParent;
-
-  @Option(
-    name = "--owner",
-    aliases = {"-o"},
-    usage = "owner(s) of project"
-  )
-  private List<AccountGroup.UUID> ownerIds;
-
-  @Option(
-    name = "--parent",
-    aliases = {"-p"},
-    metaVar = "NAME",
-    usage = "parent project"
-  )
-  private ProjectControl newParent;
-
-  @Option(name = "--permissions-only", usage = "create project for use only as parent")
-  private boolean permissionsOnly;
-
-  @Option(
-    name = "--description",
-    aliases = {"-d"},
-    metaVar = "DESCRIPTION",
-    usage = "description of project"
-  )
-  private String projectDescription = "";
-
-  @Option(
-    name = "--submit-type",
-    aliases = {"-t"},
-    usage = "project submit type"
-  )
-  private SubmitType submitType;
-
-  @Option(name = "--contributor-agreements", usage = "if contributor agreement is required")
-  private InheritableBoolean contributorAgreements = InheritableBoolean.INHERIT;
-
-  @Option(name = "--signed-off-by", usage = "if signed-off-by is required")
-  private InheritableBoolean signedOffBy = InheritableBoolean.INHERIT;
-
-  @Option(name = "--content-merge", usage = "allow automatic conflict resolving within files")
-  private InheritableBoolean contentMerge = InheritableBoolean.INHERIT;
-
-  @Option(name = "--change-id", usage = "if change-id is required")
-  private InheritableBoolean requireChangeID = InheritableBoolean.INHERIT;
-
-  @Option(
-    name = "--new-change-for-all-not-in-target",
-    usage = "if a new change will be created for every commit not in target branch"
-  )
-  private InheritableBoolean createNewChangeForAllNotInTarget = InheritableBoolean.INHERIT;
-
-  @Option(
-    name = "--use-contributor-agreements",
-    aliases = {"--ca"},
-    usage = "if contributor agreement is required"
-  )
-  void setUseContributorArgreements(@SuppressWarnings("unused") boolean on) {
-    contributorAgreements = InheritableBoolean.TRUE;
-  }
-
-  @Option(
-    name = "--use-signed-off-by",
-    aliases = {"--so"},
-    usage = "if signed-off-by is required"
-  )
-  void setUseSignedOffBy(@SuppressWarnings("unused") boolean on) {
-    signedOffBy = InheritableBoolean.TRUE;
-  }
-
-  @Option(name = "--use-content-merge", usage = "allow automatic conflict resolving within files")
-  void setUseContentMerge(@SuppressWarnings("unused") boolean on) {
-    contentMerge = InheritableBoolean.TRUE;
-  }
-
-  @Option(
-    name = "--require-change-id",
-    aliases = {"--id"},
-    usage = "if change-id is required"
-  )
-  void setRequireChangeId(@SuppressWarnings("unused") boolean on) {
-    requireChangeID = InheritableBoolean.TRUE;
-  }
-
-  @Option(
-    name = "--create-new-change-for-all-not-in-target",
-    aliases = {"--ncfa"},
-    usage = "if a new change will be created for every commit not in target branch"
-  )
-  void setNewChangeForAllNotInTarget(@SuppressWarnings("unused") boolean on) {
-    createNewChangeForAllNotInTarget = InheritableBoolean.TRUE;
-  }
-
-  @Option(
-    name = "--branch",
-    aliases = {"-b"},
-    metaVar = "BRANCH",
-    usage = "initial branch name\n(default: master)"
-  )
-  private List<String> branch;
-
-  @Option(name = "--empty-commit", usage = "to create initial empty commit")
-  private boolean createEmptyCommit;
-
-  @Option(name = "--max-object-size-limit", usage = "max Git object size for this project")
-  private String maxObjectSizeLimit;
-
-  @Option(
-    name = "--plugin-config",
-    usage = "plugin configuration parameter with format '<plugin-name>.<parameter-name>=<value>'"
-  )
-  private List<String> pluginConfigValues;
-
-  @Argument(index = 0, metaVar = "NAME", usage = "name of project to be created")
-  private String projectName;
-
-  @Inject private GerritApi gApi;
-
-  @Inject private SuggestParentCandidates suggestParentCandidates;
-
-  @Override
-  protected void run() throws Failure {
-    try {
-      if (!suggestParent) {
-        if (projectName == null) {
-          throw die("Project name is required.");
-        }
-
-        ProjectInput input = new ProjectInput();
-        input.name = projectName;
-        if (ownerIds != null) {
-          input.owners = Lists.transform(ownerIds, AccountGroup.UUID::get);
-        }
-        if (newParent != null) {
-          input.parent = newParent.getProject().getName();
-        }
-        input.permissionsOnly = permissionsOnly;
-        input.description = projectDescription;
-        input.submitType = submitType;
-        input.useContributorAgreements = contributorAgreements;
-        input.useSignedOffBy = signedOffBy;
-        input.useContentMerge = contentMerge;
-        input.requireChangeId = requireChangeID;
-        input.createNewChangeForAllNotInTarget = createNewChangeForAllNotInTarget;
-        input.branches = branch;
-        input.createEmptyCommit = createEmptyCommit;
-        input.maxObjectSizeLimit = maxObjectSizeLimit;
-        if (pluginConfigValues != null) {
-          input.pluginConfigValues = parsePluginConfigValues(pluginConfigValues);
-        }
-
-        gApi.projects().create(input);
-      } else {
-        for (Project.NameKey parent : suggestParentCandidates.getNameKeys()) {
-          stdout.print(parent.get() + '\n');
-        }
-      }
-    } catch (RestApiException err) {
-      throw die(err);
-    } catch (PermissionBackendException err) {
-      throw new Failure(1, "permissions unavailable", err);
-    }
-  }
-
-  @VisibleForTesting
-  Map<String, Map<String, ConfigValue>> parsePluginConfigValues(List<String> pluginConfigValues)
-      throws UnloggedFailure {
-    Map<String, Map<String, ConfigValue>> m = new HashMap<>();
-    for (String pluginConfigValue : pluginConfigValues) {
-      String[] s = pluginConfigValue.split("=");
-      String[] s2 = s[0].split("\\.");
-      if (s.length != 2 || s2.length != 2) {
-        throw die(
-            "Invalid plugin config value '"
-                + pluginConfigValue
-                + "', expected format '<plugin-name>.<parameter-name>=<value>'"
-                + " or '<plugin-name>.<parameter-name>=<value1,value2,...>'");
-      }
-      ConfigValue value = new ConfigValue();
-      String v = s[1];
-      if (v.contains(",")) {
-        value.values = Lists.newArrayList(Splitter.on(",").split(v));
-      } else {
-        value.value = v;
-      }
-      String pluginName = s2[0];
-      String paramName = s2[1];
-      Map<String, ConfigValue> l = m.get(pluginName);
-      if (l == null) {
-        l = new HashMap<>();
-        m.put(pluginName, l);
-      }
-      l.put(paramName, value);
-    }
-    return m;
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
deleted file mode 100644
index 392fd29..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static com.google.gerrit.common.data.GlobalCapability.FLUSH_CACHES;
-import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
-import static com.google.gerrit.server.config.PostCaches.Operation.FLUSH;
-import static com.google.gerrit.server.config.PostCaches.Operation.FLUSH_ALL;
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
-
-import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.config.ListCaches;
-import com.google.gerrit.server.config.ListCaches.OutputFormat;
-import com.google.gerrit.server.config.PostCaches;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.List;
-import org.kohsuke.args4j.Option;
-
-/** Causes the caches to purge all entries and reload. */
-@RequiresAnyCapability({FLUSH_CACHES, MAINTAIN_SERVER})
-@CommandMetaData(
-  name = "flush-caches",
-  description = "Flush some/all server caches from memory",
-  runsAt = MASTER_OR_SLAVE
-)
-final class FlushCaches extends SshCommand {
-  @Option(name = "--cache", usage = "flush named cache", metaVar = "NAME")
-  private List<String> caches = new ArrayList<>();
-
-  @Option(name = "--all", usage = "flush all caches")
-  private boolean all;
-
-  @Option(name = "--list", usage = "list available caches")
-  private boolean list;
-
-  @Inject private ListCaches listCaches;
-
-  @Inject private PostCaches postCaches;
-
-  @Override
-  protected void run() throws Failure {
-    try {
-      if (list) {
-        if (all || caches.size() > 0) {
-          throw die("cannot use --list with --all or --cache");
-        }
-        doList();
-        return;
-      }
-
-      if (all && caches.size() > 0) {
-        throw die("cannot combine --all and --cache");
-      } else if (!all && caches.size() == 1 && caches.contains("all")) {
-        caches.clear();
-        all = true;
-      } else if (!all && caches.isEmpty()) {
-        all = true;
-      }
-
-      if (all) {
-        postCaches.apply(new ConfigResource(), new PostCaches.Input(FLUSH_ALL));
-      } else {
-        postCaches.apply(new ConfigResource(), new PostCaches.Input(FLUSH, caches));
-      }
-    } catch (RestApiException e) {
-      throw die(e.getMessage());
-    } catch (PermissionBackendException e) {
-      throw new Failure(1, "unavailable", e);
-    }
-  }
-
-  @SuppressWarnings("unchecked")
-  private void doList() {
-    for (String name :
-        (List<String>) listCaches.setFormat(OutputFormat.LIST).apply(new ConfigResource())) {
-      stderr.print(name);
-      stderr.print('\n');
-    }
-    stderr.flush();
-  }
-}
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
deleted file mode 100644
index b0b26fa..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
+++ /dev/null
@@ -1,120 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-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 com.google.common.collect.Lists;
-import com.google.gerrit.common.data.GarbageCollectionResult;
-import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
-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.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.List;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
-/** Runs the Git garbage collection. */
-@RequiresAnyCapability({RUN_GC, MAINTAIN_SERVER})
-@CommandMetaData(name = "gc", description = "Run Git garbage collection", runsAt = MASTER_OR_SLAVE)
-public class GarbageCollectionCommand extends SshCommand {
-
-  @Option(name = "--all", usage = "runs the Git garbage collection for all projects")
-  private boolean all;
-
-  @Option(name = "--show-progress", usage = "progress information is shown")
-  private boolean showProgress;
-
-  @Option(name = "--aggressive", usage = "run aggressive garbage collection")
-  private boolean aggressive;
-
-  @Argument(
-    index = 0,
-    required = false,
-    multiValued = true,
-    metaVar = "NAME",
-    usage = "projects for which the Git garbage collection should be run"
-  )
-  private List<ProjectControl> projects = new ArrayList<>();
-
-  @Inject private ProjectCache projectCache;
-
-  @Inject private GarbageCollection.Factory garbageCollectionFactory;
-
-  @Override
-  public void run() throws Exception {
-    verifyCommandLine();
-    runGC();
-  }
-
-  private void verifyCommandLine() throws UnloggedFailure {
-    if (!all && projects.isEmpty()) {
-      throw die("needs projects as command arguments or --all option");
-    }
-    if (all && !projects.isEmpty()) {
-      throw die("either specify projects as command arguments or use --all option");
-    }
-  }
-
-  private void runGC() {
-    List<Project.NameKey> projectNames;
-    if (all) {
-      projectNames = Lists.newArrayList(projectCache.all());
-    } else {
-      projectNames = Lists.newArrayListWithCapacity(projects.size());
-      for (ProjectControl pc : projects) {
-        projectNames.add(pc.getProject().getNameKey());
-      }
-    }
-
-    GarbageCollectionResult result =
-        garbageCollectionFactory
-            .create()
-            .run(projectNames, aggressive, showProgress ? stdout : null);
-    if (result.hasErrors()) {
-      for (GarbageCollectionResult.Error e : result.getErrors()) {
-        String msg;
-        switch (e.getType()) {
-          case REPOSITORY_NOT_FOUND:
-            msg = "error: project \"" + e.getProjectName() + "\" not found";
-            break;
-          case GC_ALREADY_SCHEDULED:
-            msg =
-                "error: garbage collection for project \""
-                    + e.getProjectName()
-                    + "\" was already scheduled";
-            break;
-          case GC_FAILED:
-            msg = "error: garbage collection for project \"" + e.getProjectName() + "\" failed";
-            break;
-          default:
-            msg =
-                "error: garbage collection for project \""
-                    + e.getProjectName()
-                    + "\" failed: "
-                    + e.getType();
-        }
-        stdout.print(msg + "\n");
-      }
-    }
-  }
-}
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
deleted file mode 100644
index 821257c..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.Index;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.sshd.ChangeArgumentParser;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import org.kohsuke.args4j.Argument;
-
-@CommandMetaData(name = "changes", description = "Index changes")
-final class IndexChangesCommand extends SshCommand {
-  @Inject private Index index;
-
-  @Inject private ChangeArgumentParser changeArgumentParser;
-
-  @Argument(
-    index = 0,
-    required = true,
-    multiValued = true,
-    metaVar = "CHANGE",
-    usage = "changes to index"
-  )
-  void addChange(String token) {
-    try {
-      changeArgumentParser.addChange(token, changes, null, false);
-    } catch (UnloggedFailure | OrmException | PermissionBackendException e) {
-      writeError("warning", e.getMessage());
-    }
-  }
-
-  private Map<Change.Id, ChangeResource> changes = new LinkedHashMap<>();
-
-  @Override
-  protected void run() throws UnloggedFailure {
-    boolean ok = true;
-    for (ChangeResource rsrc : changes.values()) {
-      try {
-        index.apply(rsrc, new Index.Input());
-      } catch (Exception e) {
-        ok = false;
-        writeError(
-            "error", String.format("failed to index change %s: %s", rsrc.getId(), e.getMessage()));
-      }
-    }
-    if (!ok) {
-      throw die("failed to index one or more changes");
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
deleted file mode 100644
index 476c25b..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
-
-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.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.List;
-import org.kohsuke.args4j.Argument;
-
-@RequiresAnyCapability({MAINTAIN_SERVER})
-@CommandMetaData(name = "project", description = "Index changes of a project")
-final class IndexProjectCommand extends SshCommand {
-
-  @Inject private Index index;
-
-  @Argument(
-    index = 0,
-    required = true,
-    multiValued = true,
-    metaVar = "PROJECT",
-    usage = "projects for which the changes should be indexed"
-  )
-  private List<ProjectControl> projects = new ArrayList<>();
-
-  @Override
-  protected void run() throws UnloggedFailure, Failure, Exception {
-    if (projects.isEmpty()) {
-      throw die("needs at least one project as command arguments");
-    }
-    projects.stream().forEach(this::index);
-  }
-
-  private void index(ProjectControl projectControl) {
-    try {
-      index.apply(new ProjectResource(projectControl), null);
-    } catch (Exception e) {
-      writeError(
-          "error",
-          String.format(
-              "Unable to index %s: %s", projectControl.getProject().getName(), e.getMessage()));
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java
deleted file mode 100644
index 3465a9c..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static com.google.gerrit.common.data.GlobalCapability.KILL_TASK;
-import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
-
-import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
-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.server.config.ConfigResource;
-import com.google.gerrit.server.config.DeleteTask;
-import com.google.gerrit.server.config.TaskResource;
-import com.google.gerrit.server.config.TasksCollection;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.sshd.AdminHighPriorityCommand;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.List;
-import org.kohsuke.args4j.Argument;
-
-/** Kill a task in the work queue. */
-@AdminHighPriorityCommand
-@RequiresAnyCapability({KILL_TASK, MAINTAIN_SERVER})
-final class KillCommand extends SshCommand {
-  @Inject private TasksCollection tasksCollection;
-
-  @Inject private DeleteTask deleteTask;
-
-  @Argument(index = 0, multiValued = true, required = true, metaVar = "ID")
-  private final List<String> taskIds = new ArrayList<>();
-
-  @Override
-  protected void run() {
-    ConfigResource cfgRsrc = new ConfigResource();
-    for (String id : taskIds) {
-      try {
-        TaskResource taskRsrc = tasksCollection.parse(cfgRsrc, IdString.fromDecoded(id));
-        deleteTask.apply(taskRsrc, null);
-      } catch (AuthException | ResourceNotFoundException | PermissionBackendException e) {
-        stderr.print("kill: " + id + ": No such task\n");
-      }
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
deleted file mode 100644
index ac3784a..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
+++ /dev/null
@@ -1,81 +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.sshd.commands;
-
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.ListGroups;
-import com.google.gerrit.server.ioutil.ColumnFormatter;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.gerrit.util.cli.Options;
-import com.google.inject.Inject;
-import java.util.Optional;
-import org.kohsuke.args4j.Option;
-
-@CommandMetaData(
-  name = "ls-groups",
-  description = "List groups visible to the caller",
-  runsAt = MASTER_OR_SLAVE
-)
-public class ListGroupsCommand extends SshCommand {
-  @Inject private GroupCache groupCache;
-
-  @Inject @Options public ListGroups listGroups;
-
-  @Option(
-    name = "--verbose",
-    aliases = {"-v"},
-    usage =
-        "verbose output format with tab-separated columns for the "
-            + "group name, UUID, description, owner group name, "
-            + "owner group UUID, and whether the group is visible to all"
-  )
-  private boolean verboseOutput;
-
-  @Override
-  public void run() throws Exception {
-    if (listGroups.getUser() != null && !listGroups.getProjects().isEmpty()) {
-      throw die("--user and --project options are not compatible.");
-    }
-
-    ColumnFormatter formatter = new ColumnFormatter(stdout, '\t');
-    for (GroupInfo info : listGroups.get()) {
-      formatter.addColumn(MoreObjects.firstNonNull(info.name, "n/a"));
-      if (verboseOutput) {
-        Optional<InternalGroup> group =
-            info.ownerId != null
-                ? groupCache.get(new AccountGroup.UUID(Url.decode(info.ownerId)))
-                : Optional.empty();
-
-        formatter.addColumn(Url.decode(info.id));
-        formatter.addColumn(Strings.nullToEmpty(info.description));
-        formatter.addColumn(group.map(InternalGroup::getName).orElse("n/a"));
-        formatter.addColumn(group.map(g -> g.getGroupUUID().get()).orElse(""));
-        formatter.addColumn(
-            Boolean.toString(MoreObjects.firstNonNull(info.options.visibleToAll, Boolean.FALSE)));
-      }
-      formatter.nextLine();
-    }
-    formatter.finish();
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
deleted file mode 100644
index ffaf923..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.ListMembers;
-import com.google.gerrit.server.ioutil.ColumnFormatter;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.PrintWriter;
-import java.util.List;
-import java.util.Optional;
-import org.kohsuke.args4j.Argument;
-
-/** Implements a command that allows the user to see the members of a group. */
-@CommandMetaData(
-  name = "ls-members",
-  description = "List the members of a given group",
-  runsAt = MASTER_OR_SLAVE
-)
-public class ListMembersCommand extends SshCommand {
-  @Inject ListMembersCommandImpl impl;
-
-  @Override
-  public void run() throws Exception {
-    impl.display(stdout);
-  }
-
-  @Override
-  protected void parseCommandLine() throws UnloggedFailure {
-    parseCommandLine(impl);
-  }
-
-  private static class ListMembersCommandImpl extends ListMembers {
-    @Argument(required = true, usage = "the name of the group", metaVar = "GROUPNAME")
-    private String name;
-
-    private final GroupCache groupCache;
-
-    @Inject
-    protected ListMembersCommandImpl(
-        GroupCache groupCache,
-        GroupControl.Factory groupControlFactory,
-        AccountLoader.Factory accountLoaderFactory) {
-      super(groupCache, groupControlFactory, accountLoaderFactory);
-      this.groupCache = groupCache;
-    }
-
-    void display(PrintWriter writer) throws OrmException {
-      Optional<InternalGroup> group = groupCache.get(new AccountGroup.NameKey(name));
-      String errorText = "Group not found or not visible\n";
-
-      if (!group.isPresent()) {
-        writer.write(errorText);
-        writer.flush();
-        return;
-      }
-
-      List<AccountInfo> members = apply(group.get().getGroupUUID());
-      ColumnFormatter formatter = new ColumnFormatter(writer, '\t');
-      formatter.addColumn("id");
-      formatter.addColumn("username");
-      formatter.addColumn("full name");
-      formatter.addColumn("email");
-      formatter.nextLine();
-      for (AccountInfo member : members) {
-        if (member == null) {
-          continue;
-        }
-
-        formatter.addColumn(Integer.toString(member._accountId));
-        formatter.addColumn(MoreObjects.firstNonNull(member.username, "n/a"));
-        formatter.addColumn(MoreObjects.firstNonNull(Strings.emptyToNull(member.name), "n/a"));
-        formatter.addColumn(MoreObjects.firstNonNull(member.email, "n/a"));
-        formatter.nextLine();
-      }
-
-      formatter.finish();
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
deleted file mode 100644
index 7ef1e20..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
-
-import com.google.gerrit.server.project.ListProjects;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.gerrit.util.cli.Options;
-import com.google.inject.Inject;
-import java.util.List;
-
-@CommandMetaData(
-  name = "ls-projects",
-  description = "List projects visible to the caller",
-  runsAt = MASTER_OR_SLAVE
-)
-public class ListProjectsCommand extends SshCommand {
-  @Inject @Options public ListProjects impl;
-
-  @Override
-  public void run() throws Exception {
-    if (!impl.getFormat().isJson()) {
-      List<String> showBranch = impl.getShowBranch();
-      if (impl.isShowTree() && (showBranch != null) && !showBranch.isEmpty()) {
-        throw die("--tree and --show-branch options are not compatible.");
-      }
-      if (impl.isShowTree() && impl.isShowDescription()) {
-        throw die("--tree and --description options are not compatible.");
-      }
-    }
-    impl.display(out);
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java
deleted file mode 100644
index 275da7c..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java
+++ /dev/null
@@ -1,113 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
-import static org.eclipse.jgit.lib.RefDatabase.ALL;
-
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.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.util.ManualRequestContext;
-import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.Map;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.kohsuke.args4j.Option;
-
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@CommandMetaData(
-  name = "ls-user-refs",
-  description = "List refs visible to a specific user",
-  runsAt = MASTER_OR_SLAVE
-)
-public class LsUserRefs extends SshCommand {
-  @Inject private AccountResolver accountResolver;
-  @Inject private OneOffRequestContext requestContext;
-  @Inject private VisibleRefFilter.Factory refFilterFactory;
-  @Inject private GitRepositoryManager repoManager;
-
-  @Option(
-    name = "--project",
-    aliases = {"-p"},
-    metaVar = "PROJECT",
-    required = true,
-    usage = "project for which the refs should be listed"
-  )
-  private ProjectControl projectControl;
-
-  @Option(
-    name = "--user",
-    aliases = {"-u"},
-    metaVar = "USER",
-    required = true,
-    usage = "user for which the groups should be listed"
-  )
-  private String userName;
-
-  @Option(name = "--only-refs-heads", usage = "list only refs under refs/heads")
-  private boolean onlyRefsHeads;
-
-  @Override
-  protected void run() throws Failure {
-    Account userAccount;
-    try {
-      userAccount = accountResolver.find(userName);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw die(e);
-    }
-    if (userAccount == null) {
-      stdout.print("No single user could be found when searching for: " + userName + '\n');
-      stdout.flush();
-      return;
-    }
-
-    Project.NameKey projectName = projectControl.getProject().getNameKey();
-    try (Repository repo = repoManager.openRepository(projectName);
-        ManualRequestContext ctx = requestContext.openAs(userAccount.getId())) {
-      try {
-        Map<String, Ref> refsMap =
-            refFilterFactory
-                .create(projectControl.getProjectState(), repo)
-                .filter(repo.getRefDatabase().getRefs(ALL), false);
-
-        for (String ref : refsMap.keySet()) {
-          if (!onlyRefsHeads || ref.startsWith(RefNames.REFS_HEADS)) {
-            stdout.println(ref);
-          }
-        }
-      } catch (IOException e) {
-        throw new Failure(1, "fatal: Error reading refs: '" + projectName, e);
-      }
-    } catch (RepositoryNotFoundException e) {
-      throw die("'" + projectName + "': not a git archive");
-    } catch (IOException | OrmException e) {
-      throw die("Error opening: '" + projectName);
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PatchSetParser.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PatchSetParser.java
deleted file mode 100644
index c3613b1..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PatchSetParser.java
+++ /dev/null
@@ -1,165 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeFinder;
-import com.google.gerrit.server.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.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.sshd.BaseCommand.UnloggedFailure;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.List;
-
-@Singleton
-public class PatchSetParser {
-  private final Provider<ReviewDb> db;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final ChangeNotes.Factory notesFactory;
-  private final PatchSetUtil psUtil;
-  private final ChangeFinder changeFinder;
-
-  @Inject
-  PatchSetParser(
-      Provider<ReviewDb> db,
-      Provider<InternalChangeQuery> queryProvider,
-      ChangeNotes.Factory notesFactory,
-      PatchSetUtil psUtil,
-      ChangeFinder changeFinder) {
-    this.db = db;
-    this.queryProvider = queryProvider;
-    this.notesFactory = notesFactory;
-    this.psUtil = psUtil;
-    this.changeFinder = changeFinder;
-  }
-
-  public PatchSet parsePatchSet(String token, ProjectControl projectControl, String branch)
-      throws UnloggedFailure, OrmException {
-    // By commit?
-    //
-    if (token.matches("^([0-9a-fA-F]{4," + RevId.LEN + "})$")) {
-      InternalChangeQuery query = queryProvider.get();
-      List<ChangeData> cds;
-      if (projectControl != null) {
-        Project.NameKey p = projectControl.getProject().getNameKey();
-        if (branch != null) {
-          cds = query.byBranchCommit(p.get(), branch, token);
-        } else {
-          cds = query.byProjectCommit(p, token);
-        }
-      } else {
-        cds = query.byCommit(token);
-      }
-      List<PatchSet> matches = new ArrayList<>(cds.size());
-      for (ChangeData cd : cds) {
-        Change c = cd.change();
-        if (!(inProject(c, projectControl) && inBranch(c, branch))) {
-          continue;
-        }
-        for (PatchSet ps : cd.patchSets()) {
-          if (ps.getRevision().matches(token)) {
-            matches.add(ps);
-          }
-        }
-      }
-
-      switch (matches.size()) {
-        case 1:
-          return matches.iterator().next();
-        case 0:
-          throw error("\"" + token + "\" no such patch set");
-        default:
-          throw error("\"" + token + "\" matches multiple patch sets");
-      }
-    }
-
-    // By older style change,patchset?
-    //
-    if (token.matches("^[1-9][0-9]*,[1-9][0-9]*$")) {
-      PatchSet.Id patchSetId;
-      try {
-        patchSetId = PatchSet.Id.parse(token);
-      } catch (IllegalArgumentException e) {
-        throw error("\"" + token + "\" is not a valid patch set");
-      }
-      ChangeNotes notes = getNotes(projectControl, patchSetId.getParentKey());
-      PatchSet patchSet = psUtil.get(db.get(), notes, patchSetId);
-      if (patchSet == null) {
-        throw error("\"" + token + "\" no such patch set");
-      }
-      if (projectControl != null || branch != null) {
-        Change change = notes.getChange();
-        if (!inProject(change, projectControl)) {
-          throw error(
-              "change "
-                  + change.getId()
-                  + " not in project "
-                  + projectControl.getProject().getName());
-        }
-        if (!inBranch(change, branch)) {
-          throw error("change " + change.getId() + " not in branch " + branch);
-        }
-      }
-      return patchSet;
-    }
-
-    throw error("\"" + token + "\" is not a valid patch set");
-  }
-
-  private ChangeNotes getNotes(@Nullable ProjectControl projectControl, Change.Id changeId)
-      throws OrmException, UnloggedFailure {
-    if (projectControl != null) {
-      return notesFactory.create(db.get(), projectControl.getProject().getNameKey(), changeId);
-    }
-    try {
-      ChangeNotes notes = changeFinder.findOne(changeId);
-      return notesFactory.create(db.get(), notes.getProjectName(), changeId);
-    } catch (NoSuchChangeException e) {
-      throw error("\"" + changeId + "\" no such change");
-    }
-  }
-
-  private static boolean inProject(Change change, ProjectControl projectControl) {
-    if (projectControl == null) {
-      // No --project option, so they want every project.
-      return true;
-    }
-    return projectControl.getProject().getNameKey().equals(change.getProject());
-  }
-
-  private static boolean inBranch(Change change, String branch) {
-    if (branch == null) {
-      // No --branch option, so they want every branch.
-      return true;
-    }
-    return change.getDest().get().equals(branch);
-  }
-
-  public static UnloggedFailure error(String msg) {
-    return new UnloggedFailure(1, msg);
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
deleted file mode 100644
index d7c8f3a..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-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 {
-  @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");
-    }
-    if (names != null && !names.isEmpty()) {
-      try {
-        loader.enablePlugins(Sets.newHashSet(names));
-      } catch (PluginInstallException e) {
-        e.printStackTrace(stderr);
-        throw die("plugin failed to enable");
-      }
-    }
-  }
-}
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
deleted file mode 100644
index 820052c..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
+++ /dev/null
@@ -1,112 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-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;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.nio.file.Files;
-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 {
-  @Option(
-    name = "--name",
-    aliases = {"-n"},
-    usage = "install under name"
-  )
-  private String name;
-
-  @Option(name = "-")
-  void useInput(@SuppressWarnings("unused") boolean on) {
-    source = "-";
-  }
-
-  @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");
-    }
-    if (Strings.isNullOrEmpty(source)) {
-      throw die("Argument \"-|URL\" is required");
-    }
-    if (Strings.isNullOrEmpty(name) && "-".equalsIgnoreCase(source)) {
-      throw die("--name required when source is stdin");
-    }
-
-    if (Strings.isNullOrEmpty(name)) {
-      int s = source.lastIndexOf('/');
-      if (0 <= s) {
-        name = source.substring(s + 1);
-      } else {
-        name = source;
-      }
-    }
-
-    InputStream data;
-    if ("-".equalsIgnoreCase(source)) {
-      data = in;
-    } else if (new File(source).isFile() && source.equals(new File(source).getAbsolutePath())) {
-      try {
-        data = Files.newInputStream(new File(source).toPath());
-      } catch (IOException e) {
-        throw die("cannot read " + source);
-      }
-    } else {
-      try {
-        data = new URL(source).openStream();
-      } catch (MalformedURLException e) {
-        throw die("invalid url " + source);
-      } catch (IOException e) {
-        throw die("cannot read " + source);
-      }
-    }
-    try {
-      loader.installPluginFromStream(name, data);
-    } catch (IOException e) {
-      throw die("cannot install plugin");
-    } catch (PluginInstallException e) {
-      e.printStackTrace(stderr);
-      String msg = String.format("Plugin failed to install. Cause: %s", e.getMessage());
-      throw die(msg);
-    } finally {
-      try {
-        data.close();
-      } catch (IOException err) {
-        // Ignored
-      }
-    }
-  }
-}
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
deleted file mode 100644
index 0f2c912..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-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 {
-  @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");
-    }
-    if (names == null || names.isEmpty()) {
-      loader.rescan();
-    } else {
-      try {
-        loader.reload(names);
-      } catch (InvalidPluginException | PluginInstallException e) {
-        throw die(e.getMessage());
-      }
-    }
-  }
-}
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
deleted file mode 100644
index 8a38739..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-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 {
-  @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");
-    }
-    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
deleted file mode 100644
index 0f68d61..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java
+++ /dev/null
@@ -1,177 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
-import com.google.gerrit.common.data.Capable;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.VisibleRefFilter;
-import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.sshd.AbstractGitCommand;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshSession;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.errors.TooLargeObjectInPackException;
-import org.eclipse.jgit.errors.UnpackException;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.transport.AdvertiseRefsHook;
-import org.eclipse.jgit.transport.ReceivePack;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Receives change upload over SSH using the Git receive-pack protocol. */
-@CommandMetaData(
-  name = "receive-pack",
-  description = "Standard Git server side command for client side git push"
-)
-final class Receive extends AbstractGitCommand {
-  private static final Logger log = LoggerFactory.getLogger(Receive.class);
-
-  @Inject private AsyncReceiveCommits.Factory factory;
-  @Inject private IdentifiedUser currentUser;
-  @Inject private SshSession session;
-  @Inject private PermissionBackend permissionBackend;
-
-  private final SetMultimap<ReviewerStateInternal, Account.Id> reviewers =
-      MultimapBuilder.hashKeys(2).hashSetValues().build();
-
-  @Option(
-    name = "--reviewer",
-    aliases = {"--re"},
-    metaVar = "EMAIL",
-    usage = "request reviewer for change(s)"
-  )
-  void addReviewer(Account.Id id) {
-    reviewers.put(ReviewerStateInternal.REVIEWER, id);
-  }
-
-  @Option(
-    name = "--cc",
-    aliases = {},
-    metaVar = "EMAIL",
-    usage = "CC user on change(s)"
-  )
-  void addCC(Account.Id id) {
-    reviewers.put(ReviewerStateInternal.CC, id);
-  }
-
-  @Override
-  protected void runImpl() throws IOException, Failure {
-    try {
-      permissionBackend
-          .user(currentUser)
-          .project(project.getNameKey())
-          .check(ProjectPermission.RUN_RECEIVE_PACK);
-    } catch (AuthException e) {
-      throw new Failure(1, "fatal: receive-pack not permitted on this server");
-    } catch (PermissionBackendException e) {
-      throw new Failure(1, "fatal: unable to check permissions " + e);
-    }
-
-    AsyncReceiveCommits arc = factory.create(projectControl, repo, null, reviewers);
-
-    Capable r = arc.canUpload();
-    if (r != Capable.OK) {
-      throw die(r.getMessage());
-    }
-
-    ReceivePack rp = arc.getReceivePack();
-    try {
-      rp.receive(in, out, err);
-      session.setPeerAgent(rp.getPeerUserAgent());
-    } catch (UnpackException badStream) {
-      // In case this was caused by the user pushing an object whose size
-      // is larger than the receive.maxObjectSizeLimit gerrit.config parameter
-      // 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(" (user ");
-        msg.append(currentUser.getAccount().getUserName());
-        msg.append(" account ");
-        msg.append(currentUser.getAccountId());
-        msg.append("): ");
-        msg.append(badStream.getCause().getMessage());
-        log.info(msg.toString());
-        throw new UnloggedFailure(128, "error: " + badStream.getCause().getMessage());
-      }
-
-      // This may have been triggered by branch level access controls.
-      // 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("  AdvertiseRefsHook: ").append(rp.getAdvertiseRefsHook());
-      if (rp.getAdvertiseRefsHook() == AdvertiseRefsHook.DEFAULT) {
-        msg.append("DEFAULT");
-      } else if (rp.getAdvertiseRefsHook() instanceof VisibleRefFilter) {
-        msg.append("VisibleRefFilter");
-      } else {
-        msg.append(rp.getAdvertiseRefsHook().getClass());
-      }
-      msg.append("\n");
-
-      if (rp.getAdvertiseRefsHook() instanceof VisibleRefFilter) {
-        Map<String, Ref> adv = rp.getAdvertisedRefs();
-        msg.append("  Visible references (").append(adv.size()).append("):\n");
-        for (Ref ref : adv.values()) {
-          msg.append("  - ")
-              .append(ref.getObjectId().abbreviate(8).name())
-              .append(" ")
-              .append(ref.getName())
-              .append("\n");
-        }
-
-        Map<String, Ref> allRefs = rp.getRepository().getRefDatabase().getRefs(RefDatabase.ALL);
-        List<Ref> hidden = new ArrayList<>();
-        for (Ref ref : allRefs.values()) {
-          if (!adv.containsKey(ref.getName())) {
-            hidden.add(ref);
-          }
-        }
-
-        msg.append("  Hidden references (").append(hidden.size()).append("):\n");
-        for (Ref ref : hidden) {
-          msg.append("  - ")
-              .append(ref.getObjectId().abbreviate(8).name())
-              .append(" ")
-              .append(ref.getName())
-              .append("\n");
-        }
-      }
-
-      IOException detail = new IOException(msg.toString(), badStream);
-      throw new Failure(128, "fatal: Unpack error, check server log", detail);
-    }
-  }
-}
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
deleted file mode 100644
index 6ec3a28..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
+++ /dev/null
@@ -1,58 +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.sshd.commands;
-
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.group.GroupResource;
-import com.google.gerrit.server.group.GroupsCollection;
-import com.google.gerrit.server.group.PutName;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import org.kohsuke.args4j.Argument;
-
-@CommandMetaData(name = "rename-group", description = "Rename an account group")
-public class RenameGroupCommand extends SshCommand {
-  @Argument(
-    index = 0,
-    required = true,
-    metaVar = "GROUP",
-    usage = "name of the group to be renamed"
-  )
-  private String groupName;
-
-  @Argument(index = 1, required = true, metaVar = "NEWNAME", usage = "new name of the group")
-  private String newGroupName;
-
-  @Inject private GroupsCollection groups;
-
-  @Inject private PutName putName;
-
-  @Override
-  protected void run() throws Failure {
-    try {
-      GroupResource rsrc = groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName));
-      PutName.Input input = new PutName.Input();
-      input.name = newGroupName;
-      putName.apply(rsrc, input);
-    } catch (RestApiException | OrmException | IOException e) {
-      throw die(e);
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java
deleted file mode 100644
index ff444e6..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ /dev/null
@@ -1,364 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Strings;
-import com.google.common.io.CharStreams;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.api.changes.AbandonInput;
-import com.google.gerrit.extensions.api.changes.ChangeApi;
-import com.google.gerrit.extensions.api.changes.MoveInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RestoreInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.RevisionApi;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.OutputFormat;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.util.LabelVote;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.gerrit.util.cli.CmdLineParser;
-import com.google.gson.JsonSyntaxException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.TreeMap;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@CommandMetaData(name = "review", description = "Apply reviews to one or more patch sets")
-public class ReviewCommand extends SshCommand {
-  private static final Logger log = LoggerFactory.getLogger(ReviewCommand.class);
-
-  @Override
-  protected final CmdLineParser newCmdLineParser(Object options) {
-    final CmdLineParser parser = super.newCmdLineParser(options);
-    for (ApproveOption c : optionList) {
-      parser.addOption(c, c);
-    }
-    return parser;
-  }
-
-  private final Set<PatchSet> patchSets = new HashSet<>();
-
-  @Argument(
-    index = 0,
-    required = true,
-    multiValued = true,
-    metaVar = "{COMMIT | CHANGE,PATCHSET}",
-    usage = "list of commits or patch sets to review"
-  )
-  void addPatchSetId(String token) {
-    try {
-      PatchSet ps = psParser.parsePatchSet(token, projectControl, branch);
-      patchSets.add(ps);
-    } catch (UnloggedFailure e) {
-      throw new IllegalArgumentException(e.getMessage(), e);
-    } catch (OrmException e) {
-      throw new IllegalArgumentException("database error", e);
-    }
-  }
-
-  @Option(
-    name = "--project",
-    aliases = "-p",
-    usage = "project containing the specified patch set(s)"
-  )
-  private ProjectControl projectControl;
-
-  @Option(name = "--branch", aliases = "-b", usage = "branch containing the specified patch set(s)")
-  private String branch;
-
-  @Option(
-    name = "--message",
-    aliases = "-m",
-    usage = "cover message to publish on change(s)",
-    metaVar = "MESSAGE"
-  )
-  private String changeComment;
-
-  @Option(
-    name = "--notify",
-    aliases = "-n",
-    usage = "Who to send email notifications to after the review is stored.",
-    metaVar = "NOTIFYHANDLING"
-  )
-  private NotifyHandling notify;
-
-  @Option(name = "--abandon", usage = "abandon the specified change(s)")
-  private boolean abandonChange;
-
-  @Option(name = "--restore", usage = "restore the specified abandoned change(s)")
-  private boolean restoreChange;
-
-  @Option(name = "--rebase", usage = "rebase the specified change(s)")
-  private boolean rebaseChange;
-
-  @Option(name = "--move", usage = "move the specified change(s)", metaVar = "BRANCH")
-  private String moveToBranch;
-
-  @Option(name = "--submit", aliases = "-s", usage = "submit the specified patch set(s)")
-  private boolean submitChange;
-
-  @Option(name = "--json", aliases = "-j", usage = "read review input json from stdin")
-  private boolean json;
-
-  @Option(
-    name = "--strict-labels",
-    usage = "Strictly check if the labels specified can be applied to the given patch set(s)"
-  )
-  private boolean strictLabels;
-
-  @Option(
-    name = "--tag",
-    aliases = "-t",
-    usage = "applies a tag to the given review",
-    metaVar = "TAG"
-  )
-  private String changeTag;
-
-  @Option(
-    name = "--label",
-    aliases = "-l",
-    usage = "custom label(s) to assign",
-    metaVar = "LABEL=VALUE"
-  )
-  void addLabel(String token) {
-    LabelVote v = LabelVote.parseWithEquals(token);
-    LabelType.checkName(v.label()); // Disallow SUBM.
-    customLabels.put(v.label(), v.value());
-  }
-
-  @Inject private ProjectCache projectCache;
-
-  @Inject private AllProjectsName allProjects;
-
-  @Inject private GerritApi gApi;
-
-  @Inject private PatchSetParser psParser;
-
-  private List<ApproveOption> optionList;
-  private Map<String, Short> customLabels;
-
-  @Override
-  protected void run() throws UnloggedFailure {
-    if (abandonChange) {
-      if (restoreChange) {
-        throw die("abandon and restore actions are mutually exclusive");
-      }
-      if (submitChange) {
-        throw die("abandon and submit actions are mutually exclusive");
-      }
-      if (rebaseChange) {
-        throw die("abandon and rebase actions are mutually exclusive");
-      }
-      if (moveToBranch != null) {
-        throw die("abandon and move actions are mutually exclusive");
-      }
-    }
-    if (json) {
-      if (restoreChange) {
-        throw die("json and restore actions are mutually exclusive");
-      }
-      if (submitChange) {
-        throw die("json and submit actions are mutually exclusive");
-      }
-      if (abandonChange) {
-        throw die("json and abandon actions are mutually exclusive");
-      }
-      if (changeComment != null) {
-        throw die("json and message are mutually exclusive");
-      }
-      if (rebaseChange) {
-        throw die("json and rebase actions are mutually exclusive");
-      }
-      if (moveToBranch != null) {
-        throw die("json and move actions are mutually exclusive");
-      }
-      if (changeTag != null) {
-        throw die("json and tag actions are mutually exclusive");
-      }
-    }
-    if (rebaseChange) {
-      if (submitChange) {
-        throw die("rebase and submit actions are mutually exclusive");
-      }
-    }
-
-    boolean ok = true;
-    ReviewInput input = null;
-    if (json) {
-      input = reviewFromJson();
-    }
-
-    for (PatchSet patchSet : patchSets) {
-      try {
-        if (input != null) {
-          applyReview(patchSet, input);
-        } else {
-          reviewPatchSet(patchSet);
-        }
-      } catch (RestApiException | UnloggedFailure e) {
-        ok = false;
-        writeError("error", e.getMessage() + "\n");
-      } catch (NoSuchChangeException e) {
-        ok = false;
-        writeError("error", "no such change " + patchSet.getId().getParentKey().get());
-      } catch (Exception e) {
-        ok = false;
-        writeError("fatal", "internal server error while reviewing " + patchSet.getId() + "\n");
-        log.error("internal error while reviewing " + patchSet.getId(), e);
-      }
-    }
-
-    if (!ok) {
-      throw die("one or more reviews failed; review output above");
-    }
-  }
-
-  @Override
-  protected String getTaskDescription() {
-    return "gerrit review";
-  }
-
-  private void applyReview(PatchSet patchSet, ReviewInput review) throws RestApiException {
-    gApi.changes()
-        .id(patchSet.getId().getParentKey().get())
-        .revision(patchSet.getRevision().get())
-        .review(review);
-  }
-
-  private ReviewInput reviewFromJson() throws UnloggedFailure {
-    try (InputStreamReader r = new InputStreamReader(in, UTF_8)) {
-      return OutputFormat.JSON.newGson().fromJson(CharStreams.toString(r), ReviewInput.class);
-    } catch (IOException | JsonSyntaxException e) {
-      writeError("error", e.getMessage() + '\n');
-      throw die("internal error while reading review input");
-    }
-  }
-
-  private void reviewPatchSet(PatchSet patchSet) throws Exception {
-    if (notify == null) {
-      notify = NotifyHandling.ALL;
-    }
-
-    ReviewInput review = new ReviewInput();
-    review.message = Strings.emptyToNull(changeComment);
-    review.tag = Strings.emptyToNull(changeTag);
-    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) {
-        review.labels.put(ao.getLabelName(), v);
-      }
-    }
-    review.labels.putAll(customLabels);
-
-    // We don't need to add the review comment when abandoning/restoring.
-    if (abandonChange || restoreChange || moveToBranch != null) {
-      review.message = null;
-    }
-
-    try {
-      if (abandonChange) {
-        AbandonInput input = new AbandonInput();
-        input.message = Strings.emptyToNull(changeComment);
-        applyReview(patchSet, review);
-        changeApi(patchSet).abandon(input);
-      } else if (restoreChange) {
-        RestoreInput input = new RestoreInput();
-        input.message = Strings.emptyToNull(changeComment);
-        changeApi(patchSet).restore(input);
-        applyReview(patchSet, review);
-      } else {
-        applyReview(patchSet, review);
-      }
-
-      if (moveToBranch != null) {
-        MoveInput moveInput = new MoveInput();
-        moveInput.destinationBranch = moveToBranch;
-        moveInput.message = Strings.emptyToNull(changeComment);
-        changeApi(patchSet).move(moveInput);
-      }
-
-      if (rebaseChange) {
-        revisionApi(patchSet).rebase();
-      }
-
-      if (submitChange) {
-        revisionApi(patchSet).submit();
-      }
-
-    } catch (IllegalStateException | RestApiException e) {
-      throw die(e);
-    }
-  }
-
-  private ChangeApi changeApi(PatchSet patchSet) throws RestApiException {
-    return gApi.changes().id(patchSet.getId().getParentKey().get());
-  }
-
-  private RevisionApi revisionApi(PatchSet patchSet) throws RestApiException {
-    return changeApi(patchSet).revision(patchSet.getRevision().get());
-  }
-
-  @Override
-  protected void parseCommandLine() throws UnloggedFailure {
-    optionList = new ArrayList<>();
-    customLabels = new HashMap<>();
-
-    ProjectState allProjectsState;
-    try {
-      allProjectsState = projectCache.checkedGet(allProjects);
-    } catch (IOException e) {
-      throw die("missing " + allProjects.get());
-    }
-
-    for (LabelType type : allProjectsState.getLabelTypes().getLabelTypes()) {
-      StringBuilder usage = new StringBuilder("score for ").append(type.getName()).append("\n");
-
-      for (LabelValue v : type.getValues()) {
-        usage.append(v.format()).append("\n");
-      }
-
-      final String name = "--" + type.getName().toLowerCase();
-      optionList.add(new ApproveOption(name, usage.toString(), type));
-    }
-
-    super.parseCommandLine();
-  }
-}
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
deleted file mode 100644
index 033b4c6..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ /dev/null
@@ -1,322 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.api.accounts.EmailInput;
-import com.google.gerrit.extensions.common.EmailInfo;
-import com.google.gerrit.extensions.common.SshKeyInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.account.AddSshKey;
-import com.google.gerrit.server.account.CreateEmail;
-import com.google.gerrit.server.account.DeleteActive;
-import com.google.gerrit.server.account.DeleteEmail;
-import com.google.gerrit.server.account.DeleteSshKey;
-import com.google.gerrit.server.account.GetEmails;
-import com.google.gerrit.server.account.GetSshKeys;
-import com.google.gerrit.server.account.PutActive;
-import com.google.gerrit.server.account.PutHttpPassword;
-import com.google.gerrit.server.account.PutName;
-import com.google.gerrit.server.account.PutPreferred;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.io.UnsupportedEncodingException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
-/** Set a user's account settings. * */
-@CommandMetaData(name = "set-account", description = "Change an account's settings")
-@RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
-final class SetAccountCommand extends SshCommand {
-
-  @Argument(
-    index = 0,
-    required = true,
-    metaVar = "USER",
-    usage = "full name, email-address, ssh username or account id"
-  )
-  private Account.Id id;
-
-  @Option(name = "--full-name", metaVar = "NAME", usage = "display name of the account")
-  private String fullName;
-
-  @Option(name = "--active", usage = "set account's state to active")
-  private boolean active;
-
-  @Option(name = "--inactive", usage = "set account's state to inactive")
-  private boolean inactive;
-
-  @Option(name = "--add-email", metaVar = "EMAIL", usage = "email addresses to add to the account")
-  private List<String> addEmails = new ArrayList<>();
-
-  @Option(
-    name = "--delete-email",
-    metaVar = "EMAIL",
-    usage = "email addresses to delete from the account"
-  )
-  private List<String> deleteEmails = new ArrayList<>();
-
-  @Option(
-    name = "--preferred-email",
-    metaVar = "EMAIL",
-    usage = "a registered email address from the account"
-  )
-  private String preferredEmail;
-
-  @Option(name = "--add-ssh-key", metaVar = "-|KEY", usage = "public keys to add to the account")
-  private List<String> addSshKeys = new ArrayList<>();
-
-  @Option(
-    name = "--delete-ssh-key",
-    metaVar = "-|KEY",
-    usage = "public keys to delete from the account"
-  )
-  private List<String> deleteSshKeys = new ArrayList<>();
-
-  @Option(
-    name = "--http-password",
-    metaVar = "PASSWORD",
-    usage = "password for HTTP authentication for the account"
-  )
-  private String httpPassword;
-
-  @Option(name = "--clear-http-password", usage = "clear HTTP password for the account")
-  private boolean clearHttpPassword;
-
-  @Inject private IdentifiedUser.GenericFactory genericUserFactory;
-
-  @Inject private CreateEmail.Factory createEmailFactory;
-
-  @Inject private GetEmails getEmails;
-
-  @Inject private DeleteEmail deleteEmail;
-
-  @Inject private PutPreferred putPreferred;
-
-  @Inject private PutName putName;
-
-  @Inject private PutHttpPassword putHttpPassword;
-
-  @Inject private PutActive putActive;
-
-  @Inject private DeleteActive deleteActive;
-
-  @Inject private AddSshKey addSshKey;
-
-  @Inject private GetSshKeys getSshKeys;
-
-  @Inject private DeleteSshKey deleteSshKey;
-
-  private IdentifiedUser user;
-  private AccountResource rsrc;
-
-  @Override
-  public void run() throws Exception {
-    validate();
-    setAccount();
-  }
-
-  private void validate() throws UnloggedFailure {
-    if (active && inactive) {
-      throw die("--active and --inactive options are mutually exclusive.");
-    }
-    if (clearHttpPassword && !Strings.isNullOrEmpty(httpPassword)) {
-      throw die("--http-password and --clear-http-password options are mutually exclusive.");
-    }
-    if (addSshKeys.contains("-") && deleteSshKeys.contains("-")) {
-      throw die("Only one option may use the stdin");
-    }
-    if (deleteSshKeys.contains("ALL")) {
-      deleteSshKeys = Collections.singletonList("ALL");
-    }
-    if (deleteEmails.contains("ALL")) {
-      deleteEmails = Collections.singletonList("ALL");
-    }
-    if (deleteEmails.contains(preferredEmail)) {
-      throw die(
-          "--preferred-email and --delete-email options are mutually "
-              + "exclusive for the same email address.");
-    }
-  }
-
-  private void setAccount()
-      throws OrmException, IOException, UnloggedFailure, ConfigInvalidException,
-          PermissionBackendException {
-    user = genericUserFactory.create(id);
-    rsrc = new AccountResource(user);
-    try {
-      for (String email : addEmails) {
-        addEmail(email);
-      }
-
-      for (String email : deleteEmails) {
-        deleteEmail(email);
-      }
-
-      if (preferredEmail != null) {
-        putPreferred(preferredEmail);
-      }
-
-      if (fullName != null) {
-        PutName.Input in = new PutName.Input();
-        in.name = fullName;
-        putName.apply(rsrc, in);
-      }
-
-      if (httpPassword != null || clearHttpPassword) {
-        PutHttpPassword.Input in = new PutHttpPassword.Input();
-        in.httpPassword = httpPassword;
-        putHttpPassword.apply(rsrc, in);
-      }
-
-      if (active) {
-        putActive.apply(rsrc, null);
-      } else if (inactive) {
-        try {
-          deleteActive.apply(rsrc, null);
-        } catch (ResourceNotFoundException e) {
-          // user is already inactive
-        }
-      }
-
-      addSshKeys = readSshKey(addSshKeys);
-      if (!addSshKeys.isEmpty()) {
-        addSshKeys(addSshKeys);
-      }
-
-      deleteSshKeys = readSshKey(deleteSshKeys);
-      if (!deleteSshKeys.isEmpty()) {
-        deleteSshKeys(deleteSshKeys);
-      }
-    } catch (RestApiException e) {
-      throw die(e.getMessage());
-    }
-  }
-
-  private void addSshKeys(List<String> sshKeys)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    for (String sshKey : sshKeys) {
-      AddSshKey.Input in = new AddSshKey.Input();
-      in.raw = RawInputUtil.create(sshKey.getBytes(UTF_8), "plain/text");
-      addSshKey.apply(rsrc, in);
-    }
-  }
-
-  private void deleteSshKeys(List<String> sshKeys)
-      throws RestApiException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException, PermissionBackendException {
-    List<SshKeyInfo> infos = getSshKeys.apply(rsrc);
-    if (sshKeys.contains("ALL")) {
-      for (SshKeyInfo i : infos) {
-        deleteSshKey(i);
-      }
-    } else {
-      for (String sshKey : sshKeys) {
-        for (SshKeyInfo i : infos) {
-          if (sshKey.trim().equals(i.sshPublicKey) || sshKey.trim().equals(i.comment)) {
-            deleteSshKey(i);
-          }
-        }
-      }
-    }
-  }
-
-  private void deleteSshKey(SshKeyInfo i)
-      throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException, PermissionBackendException {
-    AccountSshKey sshKey =
-        new AccountSshKey(new AccountSshKey.Id(user.getAccountId(), i.seq), i.sshPublicKey);
-    deleteSshKey.apply(new AccountResource.SshKey(user, sshKey), null);
-  }
-
-  private void addEmail(String email)
-      throws UnloggedFailure, RestApiException, OrmException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    EmailInput in = new EmailInput();
-    in.email = email;
-    in.noConfirmation = true;
-    try {
-      createEmailFactory.create(email).apply(rsrc, in);
-    } catch (EmailException e) {
-      throw die(e.getMessage());
-    }
-  }
-
-  private void deleteEmail(String email)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    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());
-      }
-    } else {
-      deleteEmail.apply(new AccountResource.Email(user, email), new DeleteEmail.Input());
-    }
-  }
-
-  private void putPreferred(String email)
-      throws RestApiException, OrmException, IOException, PermissionBackendException,
-          ConfigInvalidException {
-    for (EmailInfo e : getEmails.apply(rsrc)) {
-      if (e.email.equals(email)) {
-        putPreferred.apply(new AccountResource.Email(user, email), null);
-        return;
-      }
-    }
-    stderr.println("preferred email not found: " + email);
-  }
-
-  private List<String> readSshKey(List<String> sshKeys)
-      throws UnsupportedEncodingException, IOException {
-    if (!sshKeys.isEmpty()) {
-      int idx = sshKeys.indexOf("-");
-      if (idx >= 0) {
-        StringBuilder sshKey = new StringBuilder();
-        BufferedReader br = new BufferedReader(new InputStreamReader(in, UTF_8));
-        String line;
-        while ((line = br.readLine()) != null) {
-          sshKey.append(line).append("\n");
-        }
-        sshKeys.set(idx, sshKey.toString());
-      }
-    }
-    return sshKeys;
-  }
-}
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
deleted file mode 100644
index ce4116d..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-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.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;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
-@CommandMetaData(name = "set-head", description = "Change HEAD reference for a project")
-public class SetHeadCommand extends SshCommand {
-
-  @Argument(index = 0, required = true, metaVar = "NAME", usage = "name of the project")
-  private ProjectControl project;
-
-  @Option(name = "--new-head", required = true, metaVar = "REF", usage = "new HEAD reference")
-  private String newHead;
-
-  private final SetHead setHead;
-
-  @Inject
-  SetHeadCommand(SetHead setHead) {
-    this.setHead = setHead;
-  }
-
-  @Override
-  protected void run() throws Exception {
-    Input input = new SetHead.Input();
-    input.ref = newHead;
-    try {
-      setHead.apply(new ProjectResource(project), input);
-    } catch (UnprocessableEntityException e) {
-      throw die(e);
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
deleted file mode 100644
index 9062b52..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
+++ /dev/null
@@ -1,170 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.Streams;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.AddMembers;
-import com.google.gerrit.server.group.AddSubgroups;
-import com.google.gerrit.server.group.DeleteMembers;
-import com.google.gerrit.server.group.DeleteSubgroups;
-import com.google.gerrit.server.group.GroupResource;
-import com.google.gerrit.server.group.GroupsCollection;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.io.UnsupportedEncodingException;
-import java.util.ArrayList;
-import java.util.List;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
-@CommandMetaData(
-  name = "set-members",
-  description = "Modify members of specific group or number of groups"
-)
-public class SetMembersCommand extends SshCommand {
-
-  @Option(
-    name = "--add",
-    aliases = {"-a"},
-    metaVar = "USER",
-    usage = "users that should be added as group member"
-  )
-  private List<Account.Id> accountsToAdd = new ArrayList<>();
-
-  @Option(
-    name = "--remove",
-    aliases = {"-r"},
-    metaVar = "USER",
-    usage = "users that should be removed from the group"
-  )
-  private List<Account.Id> accountsToRemove = new ArrayList<>();
-
-  @Option(
-    name = "--include",
-    aliases = {"-i"},
-    metaVar = "GROUP",
-    usage = "group that should be included as group member"
-  )
-  private List<AccountGroup.UUID> groupsToInclude = new ArrayList<>();
-
-  @Option(
-    name = "--exclude",
-    aliases = {"-e"},
-    metaVar = "GROUP",
-    usage = "group that should be excluded from the group"
-  )
-  private List<AccountGroup.UUID> groupsToRemove = new ArrayList<>();
-
-  @Argument(
-    index = 0,
-    required = true,
-    multiValued = true,
-    metaVar = "GROUP",
-    usage = "groups to modify"
-  )
-  private List<AccountGroup.UUID> groups = new ArrayList<>();
-
-  @Inject private AddMembers addMembers;
-
-  @Inject private DeleteMembers deleteMembers;
-
-  @Inject private AddSubgroups addSubgroups;
-
-  @Inject private DeleteSubgroups deleteSubgroups;
-
-  @Inject private GroupsCollection groupsCollection;
-
-  @Inject private GroupCache groupCache;
-
-  @Inject private AccountCache accountCache;
-
-  @Override
-  protected void run() throws UnloggedFailure, Failure, Exception {
-    try {
-      for (AccountGroup.UUID groupUuid : groups) {
-        GroupResource resource =
-            groupsCollection.parse(TopLevelResource.INSTANCE, IdString.fromUrl(groupUuid.get()));
-        if (!accountsToRemove.isEmpty()) {
-          deleteMembers.apply(resource, fromMembers(accountsToRemove));
-          reportMembersAction("removed from", resource, accountsToRemove);
-        }
-        if (!groupsToRemove.isEmpty()) {
-          deleteSubgroups.apply(resource, fromGroups(groupsToRemove));
-          reportGroupsAction("excluded from", resource, groupsToRemove);
-        }
-        if (!accountsToAdd.isEmpty()) {
-          addMembers.apply(resource, fromMembers(accountsToAdd));
-          reportMembersAction("added to", resource, accountsToAdd);
-        }
-        if (!groupsToInclude.isEmpty()) {
-          addSubgroups.apply(resource, fromGroups(groupsToInclude));
-          reportGroupsAction("included to", resource, groupsToInclude);
-        }
-      }
-    } catch (RestApiException e) {
-      throw die(e.getMessage());
-    }
-  }
-
-  private void reportMembersAction(
-      String action, GroupResource group, List<Account.Id> accountIdList)
-      throws UnsupportedEncodingException, IOException {
-    String names =
-        accountIdList
-            .stream()
-            .map(
-                accountId ->
-                    MoreObjects.firstNonNull(
-                        accountCache.get(accountId).getAccount().getPreferredEmail(), "n/a"))
-            .collect(joining(", "));
-    out.write(
-        String.format("Members %s group %s: %s\n", action, group.getName(), names).getBytes(ENC));
-  }
-
-  private void reportGroupsAction(
-      String action, GroupResource group, List<AccountGroup.UUID> groupUuidList)
-      throws UnsupportedEncodingException, IOException {
-    String names =
-        groupUuidList
-            .stream()
-            .map(uuid -> groupCache.get(uuid).map(InternalGroup::getName))
-            .flatMap(Streams::stream)
-            .collect(joining(", "));
-    out.write(
-        String.format("Groups %s group %s: %s\n", action, group.getName(), names).getBytes(ENC));
-  }
-
-  private AddSubgroups.Input fromGroups(List<AccountGroup.UUID> accounts) {
-    return AddSubgroups.Input.fromGroups(accounts.stream().map(Object::toString).collect(toList()));
-  }
-
-  private AddMembers.Input fromMembers(List<Account.Id> accounts) {
-    return AddMembers.Input.fromMembers(accounts.stream().map(Object::toString).collect(toList()));
-  }
-}
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
deleted file mode 100644
index c275af8..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
+++ /dev/null
@@ -1,167 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.api.projects.ConfigInput;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.ProjectState;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.project.PutConfig;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
-@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;
-
-  @Option(
-    name = "--description",
-    aliases = {"-d"},
-    metaVar = "DESCRIPTION",
-    usage = "description of project"
-  )
-  private String projectDescription;
-
-  @Option(
-    name = "--submit-type",
-    aliases = {"-t"},
-    usage = "project submit type\n(default: MERGE_IF_NECESSARY)"
-  )
-  private SubmitType submitType;
-
-  @Option(name = "--contributor-agreements", usage = "if contributor agreement is required")
-  private InheritableBoolean contributorAgreements;
-
-  @Option(name = "--signed-off-by", usage = "if signed-off-by is required")
-  private InheritableBoolean signedOffBy;
-
-  @Option(name = "--content-merge", usage = "allow automatic conflict resolving within files")
-  private InheritableBoolean contentMerge;
-
-  @Option(name = "--change-id", usage = "if change-id is required")
-  private InheritableBoolean requireChangeID;
-
-  @Option(
-    name = "--use-contributor-agreements",
-    aliases = {"--ca"},
-    usage = "if contributor agreement is required"
-  )
-  void setUseContributorArgreements(@SuppressWarnings("unused") boolean on) {
-    contributorAgreements = InheritableBoolean.TRUE;
-  }
-
-  @Option(
-    name = "--no-contributor-agreements",
-    aliases = {"--nca"},
-    usage = "if contributor agreement is not required"
-  )
-  void setNoContributorArgreements(@SuppressWarnings("unused") boolean on) {
-    contributorAgreements = InheritableBoolean.FALSE;
-  }
-
-  @Option(
-    name = "--use-signed-off-by",
-    aliases = {"--so"},
-    usage = "if signed-off-by is required"
-  )
-  void setUseSignedOffBy(@SuppressWarnings("unused") boolean on) {
-    signedOffBy = InheritableBoolean.TRUE;
-  }
-
-  @Option(
-    name = "--no-signed-off-by",
-    aliases = {"--nso"},
-    usage = "if signed-off-by is not required"
-  )
-  void setNoSignedOffBy(@SuppressWarnings("unused") boolean on) {
-    signedOffBy = InheritableBoolean.FALSE;
-  }
-
-  @Option(name = "--use-content-merge", usage = "allow automatic conflict resolving within files")
-  void setUseContentMerge(@SuppressWarnings("unused") boolean on) {
-    contentMerge = InheritableBoolean.TRUE;
-  }
-
-  @Option(
-    name = "--no-content-merge",
-    usage = "don't allow automatic conflict resolving within files"
-  )
-  void setNoContentMerge(@SuppressWarnings("unused") boolean on) {
-    contentMerge = InheritableBoolean.FALSE;
-  }
-
-  @Option(
-    name = "--require-change-id",
-    aliases = {"--id"},
-    usage = "if change-id is required"
-  )
-  void setRequireChangeId(@SuppressWarnings("unused") boolean on) {
-    requireChangeID = InheritableBoolean.TRUE;
-  }
-
-  @Option(
-    name = "--no-change-id",
-    aliases = {"--nid"},
-    usage = "if change-id is not required"
-  )
-  void setNoChangeId(@SuppressWarnings("unused") boolean on) {
-    requireChangeID = InheritableBoolean.FALSE;
-  }
-
-  @Option(
-    name = "--project-state",
-    aliases = {"--ps"},
-    usage = "project's visibility state"
-  )
-  private ProjectState state;
-
-  @Option(name = "--max-object-size-limit", usage = "max Git object size for this project")
-  private String maxObjectSizeLimit;
-
-  @Inject private PutConfig putConfig;
-
-  @Override
-  protected void run() throws Failure {
-    ConfigInput configInput = new ConfigInput();
-    configInput.requireChangeId = requireChangeID;
-    configInput.submitType = submitType;
-    configInput.useContentMerge = contentMerge;
-    configInput.useContributorAgreements = contributorAgreements;
-    configInput.useSignedOffBy = signedOffBy;
-    configInput.state = state;
-    configInput.maxObjectSizeLimit = maxObjectSizeLimit;
-    // Description is different to other parameters, null won't result in
-    // keeping the existing description, it would delete it.
-    if (Strings.emptyToNull(projectDescription) != null) {
-      configInput.description = projectDescription;
-    } else {
-      configInput.description = projectControl.getProject().getDescription();
-    }
-
-    try {
-      putConfig.apply(new ProjectResource(projectControl), configInput);
-    } catch (RestApiException e) {
-      throw die(e);
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
deleted file mode 100644
index 026f9b7..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ /dev/null
@@ -1,159 +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.sshd.commands;
-
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.DeleteReviewer;
-import com.google.gerrit.server.change.PostReviewers;
-import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.sshd.ChangeArgumentParser;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@CommandMetaData(name = "set-reviewers", description = "Add or remove reviewers on a change")
-public class SetReviewersCommand extends SshCommand {
-  private static final Logger log = LoggerFactory.getLogger(SetReviewersCommand.class);
-
-  @Option(name = "--project", aliases = "-p", usage = "project containing the change")
-  private ProjectControl projectControl;
-
-  @Option(
-    name = "--add",
-    aliases = {"-a"},
-    metaVar = "REVIEWER",
-    usage = "user or group that should be added as reviewer"
-  )
-  private List<String> toAdd = new ArrayList<>();
-
-  @Option(
-    name = "--remove",
-    aliases = {"-r"},
-    metaVar = "REVIEWER",
-    usage = "user that should be removed from the reviewer list"
-  )
-  void optionRemove(Account.Id who) {
-    toRemove.add(who);
-  }
-
-  @Argument(
-    index = 0,
-    required = true,
-    multiValued = true,
-    metaVar = "CHANGE",
-    usage = "changes to modify"
-  )
-  void addChange(String token) {
-    try {
-      changeArgumentParser.addChange(token, changes, projectControl);
-    } catch (UnloggedFailure e) {
-      throw new IllegalArgumentException(e.getMessage(), e);
-    } catch (OrmException e) {
-      throw new IllegalArgumentException("database is down", e);
-    } catch (PermissionBackendException e) {
-      throw new IllegalArgumentException("can't check permissions", e);
-    }
-  }
-
-  @Inject private ReviewerResource.Factory reviewerFactory;
-
-  @Inject private PostReviewers postReviewers;
-
-  @Inject private DeleteReviewer deleteReviewer;
-
-  @Inject private ChangeArgumentParser changeArgumentParser;
-
-  private Set<Account.Id> toRemove = new HashSet<>();
-
-  private Map<Change.Id, ChangeResource> changes = new LinkedHashMap<>();
-
-  @Override
-  protected void run() throws UnloggedFailure {
-    boolean ok = true;
-    for (ChangeResource rsrc : changes.values()) {
-      try {
-        ok &= modifyOne(rsrc);
-      } catch (Exception err) {
-        ok = false;
-        log.error("Error updating reviewers on change " + rsrc.getId(), err);
-        writeError("fatal", "internal error while updating " + rsrc.getId());
-      }
-    }
-
-    if (!ok) {
-      throw die("one or more updates failed; review output above");
-    }
-  }
-
-  private boolean modifyOne(ChangeResource changeRsrc) throws Exception {
-    boolean ok = true;
-
-    // Remove reviewers
-    //
-    for (Account.Id reviewer : toRemove) {
-      ReviewerResource rsrc = reviewerFactory.create(changeRsrc, reviewer);
-      String error = null;
-      try {
-        deleteReviewer.apply(rsrc, new DeleteReviewerInput());
-      } catch (ResourceNotFoundException e) {
-        error = String.format("could not remove %s: not found", reviewer);
-      } catch (Exception e) {
-        error = String.format("could not remove %s: %s", reviewer, e.getMessage());
-      }
-      if (error != null) {
-        ok = false;
-        writeError("error", error);
-      }
-    }
-
-    // Add reviewers
-    //
-    for (String reviewer : toAdd) {
-      AddReviewerInput input = new AddReviewerInput();
-      input.reviewer = reviewer;
-      input.confirmed = true;
-      String error;
-      try {
-        error = postReviewers.apply(changeRsrc, input).error;
-      } catch (Exception e) {
-        error = String.format("could not add %s: %s", reviewer, e.getMessage());
-      }
-      if (error != null) {
-        ok = false;
-        writeError("error", error);
-      }
-    }
-
-    return ok;
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
deleted file mode 100644
index 1ed7db3..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ /dev/null
@@ -1,347 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_CACHES;
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.Version;
-import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.config.GetSummary;
-import com.google.gerrit.server.config.GetSummary.JvmSummaryInfo;
-import com.google.gerrit.server.config.GetSummary.MemSummaryInfo;
-import com.google.gerrit.server.config.GetSummary.SummaryInfo;
-import com.google.gerrit.server.config.GetSummary.TaskSummaryInfo;
-import com.google.gerrit.server.config.GetSummary.ThreadSummaryInfo;
-import com.google.gerrit.server.config.ListCaches;
-import com.google.gerrit.server.config.ListCaches.CacheInfo;
-import com.google.gerrit.server.config.ListCaches.CacheType;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.gerrit.sshd.SshDaemon;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.text.SimpleDateFormat;
-import java.util.Collection;
-import java.util.Date;
-import java.util.Map;
-import java.util.Map.Entry;
-import org.apache.sshd.common.io.IoAcceptor;
-import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.io.mina.MinaSession;
-import org.apache.sshd.server.Environment;
-import org.kohsuke.args4j.Option;
-
-/** Show the current cache states. */
-@RequiresAnyCapability({VIEW_CACHES, MAINTAIN_SERVER})
-@CommandMetaData(
-  name = "show-caches",
-  description = "Display current cache statistics",
-  runsAt = MASTER_OR_SLAVE
-)
-final class ShowCaches extends SshCommand {
-  private static volatile long serverStarted;
-
-  static class StartupListener implements LifecycleListener {
-    @Override
-    public void start() {
-      serverStarted = TimeUtil.nowMs();
-    }
-
-    @Override
-    public void stop() {}
-  }
-
-  @Option(name = "--gc", usage = "perform Java GC before printing memory stats")
-  private boolean gc;
-
-  @Option(name = "--show-jvm", usage = "show details about the JVM")
-  private boolean showJVM;
-
-  @Option(name = "--show-threads", usage = "show detailed thread counts")
-  private boolean showThreads;
-
-  @Inject private SshDaemon daemon;
-  @Inject private ListCaches listCaches;
-  @Inject private GetSummary getSummary;
-  @Inject private CurrentUser self;
-  @Inject private PermissionBackend permissionBackend;
-
-  @Option(
-    name = "--width",
-    aliases = {"-w"},
-    metaVar = "COLS",
-    usage = "width of output table"
-  )
-  private int columns = 80;
-
-  private int nw;
-
-  @Override
-  public void start(Environment env) throws IOException {
-    String s = env.getEnv().get(Environment.ENV_COLUMNS);
-    if (s != null && !s.isEmpty()) {
-      try {
-        columns = Integer.parseInt(s);
-      } catch (NumberFormatException err) {
-        columns = 80;
-      }
-    }
-    super.start(env);
-  }
-
-  @Override
-  protected void run() throws UnloggedFailure {
-    nw = columns - 50;
-    Date now = new Date();
-    stdout.format(
-        "%-25s %-20s      now  %16s\n",
-        "Gerrit Code Review",
-        Version.getVersion() != null ? Version.getVersion() : "",
-        new SimpleDateFormat("HH:mm:ss   zzz").format(now));
-    stdout.format("%-25s %-20s   uptime %16s\n", "", "", uptime(now.getTime() - serverStarted));
-    stdout.print('\n');
-
-    stdout.print(
-        String.format( //
-            "%1s %-" + nw + "s|%-21s|  %-5s |%-9s|\n" //
-            ,
-            "" //
-            ,
-            "Name" //
-            ,
-            "Entries" //
-            ,
-            "AvgGet" //
-            ,
-            "Hit Ratio" //
-            ));
-    stdout.print(
-        String.format( //
-            "%1s %-" + nw + "s|%6s %6s %7s|  %-5s  |%-4s %-4s|\n" //
-            ,
-            "" //
-            ,
-            "" //
-            ,
-            "Mem" //
-            ,
-            "Disk" //
-            ,
-            "Space" //
-            ,
-            "" //
-            ,
-            "Mem" //
-            ,
-            "Disk" //
-            ));
-    stdout.print("--");
-    for (int i = 0; i < nw; i++) {
-      stdout.print('-');
-    }
-    stdout.print("+---------------------+---------+---------+\n");
-
-    Collection<CacheInfo> caches = getCaches();
-    printMemoryCoreCaches(caches);
-    printMemoryPluginCaches(caches);
-    printDiskCaches(caches);
-    stdout.print('\n');
-
-    boolean showJvm;
-    try {
-      permissionBackend.user(self).check(GlobalPermission.MAINTAIN_SERVER);
-      showJvm = true;
-    } catch (AuthException | PermissionBackendException e) {
-      // Silently ignore and do not display detailed JVM information.
-      showJvm = false;
-    }
-    if (showJvm) {
-      sshSummary();
-
-      SummaryInfo summary = getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource());
-      taskSummary(summary.taskSummary);
-      memSummary(summary.memSummary);
-      threadSummary(summary.threadSummary);
-
-      if (showJVM && summary.jvmSummary != null) {
-        jvmSummary(summary.jvmSummary);
-      }
-    }
-
-    stdout.flush();
-  }
-
-  private Collection<CacheInfo> getCaches() {
-    @SuppressWarnings("unchecked")
-    Map<String, CacheInfo> caches = (Map<String, CacheInfo>) listCaches.apply(new ConfigResource());
-    for (Map.Entry<String, CacheInfo> entry : caches.entrySet()) {
-      CacheInfo cache = entry.getValue();
-      cache.name = entry.getKey();
-    }
-    return caches.values();
-  }
-
-  private void printMemoryCoreCaches(Collection<CacheInfo> caches) {
-    for (CacheInfo cache : caches) {
-      if (!cache.name.contains("-") && CacheType.MEM.equals(cache.type)) {
-        printCache(cache);
-      }
-    }
-  }
-
-  private void printMemoryPluginCaches(Collection<CacheInfo> caches) {
-    for (CacheInfo cache : caches) {
-      if (cache.name.contains("-") && CacheType.MEM.equals(cache.type)) {
-        printCache(cache);
-      }
-    }
-  }
-
-  private void printDiskCaches(Collection<CacheInfo> caches) {
-    for (CacheInfo cache : caches) {
-      if (CacheType.DISK.equals(cache.type)) {
-        printCache(cache);
-      }
-    }
-  }
-
-  private void printCache(CacheInfo cache) {
-    stdout.print(
-        String.format(
-            "%1s %-" + nw + "s|%6s %6s %7s| %7s |%4s %4s|\n",
-            CacheType.DISK.equals(cache.type) ? "D" : "",
-            cache.name,
-            nullToEmpty(cache.entries.mem),
-            nullToEmpty(cache.entries.disk),
-            Strings.nullToEmpty(cache.entries.space),
-            Strings.nullToEmpty(cache.averageGet),
-            formatAsPercent(cache.hitRatio.mem),
-            formatAsPercent(cache.hitRatio.disk)));
-  }
-
-  private static String nullToEmpty(Long l) {
-    return l != null ? String.valueOf(l) : "";
-  }
-
-  private static String formatAsPercent(Integer i) {
-    return i != null ? String.valueOf(i) + "%" : "";
-  }
-
-  private void memSummary(MemSummaryInfo memSummary) {
-    stdout.format(
-        "Mem: %s total = %s used + %s free + %s buffers\n",
-        memSummary.total, memSummary.used, memSummary.free, memSummary.buffers);
-    stdout.format("     %s max\n", memSummary.max);
-    stdout.format("    %8d open files\n", nullToZero(memSummary.openFiles));
-    stdout.print('\n');
-  }
-
-  private void threadSummary(ThreadSummaryInfo threadSummary) {
-    stdout.format(
-        "Threads: %d CPUs available, %d threads\n", threadSummary.cpus, threadSummary.threads);
-
-    if (showThreads) {
-      stdout.print(String.format("  %22s", ""));
-      for (Thread.State s : Thread.State.values()) {
-        stdout.print(String.format(" %14s", s.name()));
-      }
-      stdout.print('\n');
-      for (Entry<String, Map<Thread.State, Integer>> e : threadSummary.counts.entrySet()) {
-        stdout.print(String.format("  %-22s", e.getKey()));
-        for (Thread.State s : Thread.State.values()) {
-          stdout.print(String.format(" %14d", nullToZero(e.getValue().get(s))));
-        }
-        stdout.print('\n');
-      }
-    }
-    stdout.print('\n');
-  }
-
-  private void taskSummary(TaskSummaryInfo taskSummary) {
-    stdout.format(
-        "Tasks: %4d  total = %4d running +   %4d ready + %4d sleeping\n",
-        nullToZero(taskSummary.total),
-        nullToZero(taskSummary.running),
-        nullToZero(taskSummary.ready),
-        nullToZero(taskSummary.sleeping));
-  }
-
-  private static int nullToZero(Integer i) {
-    return i != null ? i : 0;
-  }
-
-  private void sshSummary() {
-    IoAcceptor acceptor = daemon.getIoAcceptor();
-    if (acceptor == null) {
-      return;
-    }
-
-    long now = TimeUtil.nowMs();
-    Collection<IoSession> list = acceptor.getManagedSessions().values();
-    long oldest = now;
-
-    for (IoSession s : list) {
-      if (s instanceof MinaSession) {
-        MinaSession minaSession = (MinaSession) s;
-        oldest = Math.min(oldest, minaSession.getSession().getCreationTime());
-      }
-    }
-
-    stdout.format(
-        "SSH:   %4d  users, oldest session started %s ago\n", list.size(), uptime(now - oldest));
-  }
-
-  private void jvmSummary(JvmSummaryInfo jvmSummary) {
-    stdout.format("JVM: %s %s %s\n", jvmSummary.vmVendor, jvmSummary.vmName, jvmSummary.vmVersion);
-    stdout.format("  on %s %s %s\n", jvmSummary.osName, jvmSummary.osVersion, jvmSummary.osArch);
-    stdout.format("  running as %s on %s\n", jvmSummary.user, Strings.nullToEmpty(jvmSummary.host));
-    stdout.format("  cwd  %s\n", jvmSummary.currentWorkingDirectory);
-    stdout.format("  site %s\n", jvmSummary.site);
-  }
-
-  private String uptime(long uptimeMillis) {
-    if (uptimeMillis < 1000) {
-      return String.format("%3d ms", uptimeMillis);
-    }
-
-    long uptime = uptimeMillis / 1000L;
-
-    long min = uptime / 60;
-    if (min < 60) {
-      return String.format("%2d min %2d sec", min, uptime - min * 60);
-    }
-
-    long hr = uptime / 3600;
-    if (hr < 24) {
-      min = (uptime - hr * 3600) / 60;
-      return String.format("%2d hrs %2d min", hr, min);
-    }
-
-    long days = uptime / (24 * 3600);
-    hr = (uptime - (days * 24 * 3600)) / 3600;
-    return String.format("%4d days %2d hrs", days, hr);
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
deleted file mode 100644
index 0296690..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ /dev/null
@@ -1,212 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.config.ListTasks;
-import com.google.gerrit.server.config.ListTasks.TaskInfo;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.git.WorkQueue.Task;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.sshd.AdminHighPriorityCommand;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.text.SimpleDateFormat;
-import java.util.Date;
-import java.util.List;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import org.apache.sshd.server.Environment;
-import org.kohsuke.args4j.Option;
-
-/** Display the current work queue. */
-@AdminHighPriorityCommand
-@CommandMetaData(
-  name = "show-queue",
-  description = "Display the background work queues",
-  runsAt = MASTER_OR_SLAVE
-)
-final class ShowQueue extends SshCommand {
-  @Option(
-    name = "--wide",
-    aliases = {"-w"},
-    usage = "display without line width truncation"
-  )
-  private boolean wide;
-
-  @Option(
-    name = "--by-queue",
-    aliases = {"-q"},
-    usage = "group tasks by queue and print queue info"
-  )
-  private boolean groupByQueue;
-
-  @Inject private PermissionBackend permissionBackend;
-  @Inject private ListTasks listTasks;
-  @Inject private IdentifiedUser currentUser;
-  @Inject private WorkQueue workQueue;
-
-  private int columns = 80;
-  private int maxCommandWidth;
-
-  @Override
-  public void start(Environment env) throws IOException {
-    String s = env.getEnv().get(Environment.ENV_COLUMNS);
-    if (s != null && !s.isEmpty()) {
-      try {
-        columns = Integer.parseInt(s);
-      } catch (NumberFormatException err) {
-        columns = 80;
-      }
-    }
-    super.start(env);
-  }
-
-  @Override
-  protected void run() throws Failure {
-    maxCommandWidth = wide ? Integer.MAX_VALUE : columns - 8 - 12 - 12 - 4 - 4;
-    stdout.print(
-        String.format(
-            "%-8s %-12s %-12s %-4s %s\n", //
-            "Task", "State", "StartTime", "", "Command"));
-    stdout.print(
-        "------------------------------------------------------------------------------\n");
-
-    List<TaskInfo> tasks;
-    try {
-      tasks = listTasks.apply(new ConfigResource());
-    } catch (AuthException e) {
-      throw die(e);
-    } catch (PermissionBackendException e) {
-      throw new Failure(1, "permission backend unavailable", e);
-    }
-
-    boolean viewAll = permissionBackend.user(currentUser).testOrFalse(GlobalPermission.VIEW_QUEUE);
-    long now = TimeUtil.nowMs();
-    if (groupByQueue) {
-      ListMultimap<String, TaskInfo> byQueue = byQueue(tasks);
-      for (String queueName : byQueue.keySet()) {
-        ScheduledThreadPoolExecutor e = workQueue.getExecutor(queueName);
-        stdout.print(String.format("Queue: %s\n", queueName));
-        print(byQueue.get(queueName), now, viewAll, e.getCorePoolSize());
-      }
-    } else {
-      print(tasks, now, viewAll, 0);
-    }
-  }
-
-  private ListMultimap<String, TaskInfo> byQueue(List<TaskInfo> tasks) {
-    ListMultimap<String, TaskInfo> byQueue = LinkedListMultimap.create();
-    for (TaskInfo task : tasks) {
-      byQueue.put(task.queueName, task);
-    }
-    return byQueue;
-  }
-
-  private void print(List<TaskInfo> tasks, long now, boolean viewAll, int threadPoolSize) {
-    for (TaskInfo task : tasks) {
-      String start;
-      switch (task.state) {
-        case DONE:
-        case CANCELLED:
-        case RUNNING:
-        case READY:
-          start = format(task.state);
-          break;
-        case OTHER:
-        case SLEEPING:
-        default:
-          start = time(now, task.delay);
-          break;
-      }
-
-      // Shows information about tasks depending on the user rights
-      if (viewAll || task.projectName == null) {
-        String command =
-            task.command.length() < maxCommandWidth
-                ? task.command
-                : task.command.substring(0, maxCommandWidth);
-
-        stdout.print(
-            String.format(
-                "%8s %-12s %-12s %-4s %s\n",
-                task.id, start, startTime(task.startTime), "", command));
-      } else {
-        String remoteName =
-            task.remoteName != null ? task.remoteName + "/" + task.projectName : task.projectName;
-
-        stdout.print(
-            String.format(
-                "%8s %-12s %-4s %s\n",
-                task.id,
-                start,
-                startTime(task.startTime),
-                MoreObjects.firstNonNull(remoteName, "n/a")));
-      }
-    }
-    stdout.print(
-        "------------------------------------------------------------------------------\n");
-    stdout.print("  " + tasks.size() + " tasks");
-    if (threadPoolSize > 0) {
-      stdout.print(", " + threadPoolSize + " worker threads");
-    }
-    stdout.print("\n\n");
-  }
-
-  private static String time(long now, long delay) {
-    Date when = new Date(now + delay);
-    return format(when, delay);
-  }
-
-  private static String startTime(Date when) {
-    return format(when, TimeUtil.nowMs() - when.getTime());
-  }
-
-  private static String format(Date when, long timeFromNow) {
-    if (timeFromNow < 24 * 60 * 60 * 1000L) {
-      return new SimpleDateFormat("HH:mm:ss.SSS").format(when);
-    }
-    return new SimpleDateFormat("MMM-dd HH:mm").format(when);
-  }
-
-  private static String format(Task.State state) {
-    switch (state) {
-      case DONE:
-        return "....... done";
-      case CANCELLED:
-        return "..... killed";
-      case RUNNING:
-        return "";
-      case READY:
-        return "waiting ....";
-      case SLEEPING:
-        return "sleeping";
-      case OTHER:
-      default:
-        return state.toString();
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java
deleted file mode 100644
index bdc5f4b..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ /dev/null
@@ -1,291 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Supplier;
-import com.google.gerrit.common.UserScopedEventListener;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.events.Event;
-import com.google.gerrit.server.events.EventTypes;
-import com.google.gerrit.server.events.ProjectNameKeySerializer;
-import com.google.gerrit.server.events.SupplierSerializer;
-import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
-import com.google.gerrit.sshd.BaseCommand;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.StreamCommandExecutor;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.io.PrintWriter;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.Future;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import org.apache.sshd.server.Environment;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@RequiresCapability(GlobalCapability.STREAM_EVENTS)
-@CommandMetaData(name = "stream-events", description = "Monitor events occurring in real time")
-final class StreamEvents extends BaseCommand {
-  private static final Logger log = LoggerFactory.getLogger(StreamEvents.class);
-
-  /** Maximum number of events that may be queued up for each connection. */
-  private static final int MAX_EVENTS = 128;
-
-  /** Number of events to write before yielding off the thread. */
-  private static final int BATCH_SIZE = 32;
-
-  @Option(
-    name = "--subscribe",
-    aliases = {"-s"},
-    metaVar = "SUBSCRIBE",
-    usage = "subscribe to specific stream-events"
-  )
-  private List<String> subscribedToEvents = new ArrayList<>();
-
-  @Inject private IdentifiedUser currentUser;
-
-  @Inject private DynamicSet<UserScopedEventListener> eventListeners;
-
-  @Inject @StreamCommandExecutor private ScheduledThreadPoolExecutor pool;
-
-  /** Queue of events to stream to the connected user. */
-  private final LinkedBlockingQueue<Event> queue = new LinkedBlockingQueue<>(MAX_EVENTS);
-
-  private Gson gson;
-
-  private RegistrationHandle eventListenerRegistration;
-
-  /** Special event to notify clients they missed other events. */
-  private static final class DroppedOutputEvent extends Event {
-    private static final String TYPE = "dropped-output";
-
-    DroppedOutputEvent() {
-      super(TYPE);
-    }
-  }
-
-  static {
-    EventTypes.register(DroppedOutputEvent.TYPE, DroppedOutputEvent.class);
-  }
-
-  private final CancelableRunnable writer =
-      new CancelableRunnable() {
-        @Override
-        public void run() {
-          writeEvents();
-        }
-
-        @Override
-        public void cancel() {
-          onExit(0);
-        }
-
-        @Override
-        public String toString() {
-          return "Stream Events (" + currentUser.getAccount().getUserName() + ")";
-        }
-      };
-
-  /** True if {@link DroppedOutputEvent} needs to be sent. */
-  private volatile boolean dropped;
-
-  /** Lock to protect {@link #queue}, {@link #task}, {@link #done}. */
-  private final Object taskLock = new Object();
-
-  /** True if no more messages should be sent to the output. */
-  private boolean done;
-
-  /**
-   * Currently scheduled task to spin out {@link #queue}.
-   *
-   * <p>This field is usually {@code null}, unless there is at least one object present inside of
-   * {@link #queue} ready for delivery. Tasks are only started when there are events to be sent.
-   */
-  private Future<?> task;
-
-  private PrintWriter stdout;
-
-  @Override
-  public void start(Environment env) throws IOException {
-    try {
-      parseCommandLine();
-    } catch (UnloggedFailure e) {
-      String msg = e.getMessage();
-      if (!msg.endsWith("\n")) {
-        msg += "\n";
-      }
-      err.write(msg.getBytes(UTF_8));
-      err.flush();
-      onExit(1);
-      return;
-    }
-
-    stdout = toPrintWriter(out);
-    eventListenerRegistration =
-        eventListeners.add(
-            new UserScopedEventListener() {
-              @Override
-              public void onEvent(Event event) {
-                if (subscribedToEvents.isEmpty() || subscribedToEvents.contains(event.getType())) {
-                  offer(event);
-                }
-              }
-
-              @Override
-              public CurrentUser getUser() {
-                return currentUser;
-              }
-            });
-
-    gson =
-        new GsonBuilder()
-            .registerTypeAdapter(Supplier.class, new SupplierSerializer())
-            .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeySerializer())
-            .create();
-  }
-
-  private void removeEventListenerRegistration() {
-    if (eventListenerRegistration != null) {
-      eventListenerRegistration.remove();
-    }
-  }
-
-  @Override
-  protected void onExit(int rc) {
-    removeEventListenerRegistration();
-
-    synchronized (taskLock) {
-      done = true;
-    }
-
-    super.onExit(rc);
-  }
-
-  @Override
-  public void destroy() {
-    removeEventListenerRegistration();
-
-    final boolean exit;
-    synchronized (taskLock) {
-      if (task != null) {
-        task.cancel(true);
-        exit = false; // onExit will be invoked by the task cancellation.
-      } else {
-        exit = !done;
-      }
-      done = true;
-    }
-    if (exit) {
-      onExit(0);
-    }
-  }
-
-  private void offer(Event event) {
-    synchronized (taskLock) {
-      if (!queue.offer(event)) {
-        dropped = true;
-      }
-
-      if (task == null && !done) {
-        task = pool.submit(writer);
-      }
-    }
-  }
-
-  private Event poll() {
-    synchronized (taskLock) {
-      Event event = queue.poll();
-      if (event == null) {
-        task = null;
-      }
-      return event;
-    }
-  }
-
-  private void writeEvents() {
-    int processed = 0;
-
-    while (processed < BATCH_SIZE) {
-      if (Thread.interrupted() || stdout.checkError()) {
-        // The other side either requested a shutdown by calling our
-        // destroy() above, or it closed the stream and is no longer
-        // accepting output. Either way terminate this instance.
-        //
-        removeEventListenerRegistration();
-        flush();
-        onExit(0);
-        return;
-      }
-
-      if (dropped) {
-        write(new DroppedOutputEvent());
-        dropped = false;
-      }
-
-      final Event event = poll();
-      if (event == null) {
-        break;
-      }
-
-      write(event);
-      processed++;
-    }
-
-    flush();
-
-    if (BATCH_SIZE <= processed) {
-      // We processed the limit, but more might remain in the queue.
-      // Schedule the write task again so we will come back here and
-      // can process more events.
-      //
-      synchronized (taskLock) {
-        task = pool.submit(writer);
-      }
-    }
-  }
-
-  private void write(Object message) {
-    String msg = null;
-    try {
-      msg = gson.toJson(message) + "\n";
-    } catch (Exception e) {
-      log.warn("Could not deserialize the msg: ", e);
-    }
-    if (msg != null) {
-      synchronized (stdout) {
-        stdout.print(msg);
-      }
-    }
-  }
-
-  private void flush() {
-    synchronized (stdout) {
-      stdout.flush();
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitRuleCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitRuleCommand.java
deleted file mode 100644
index a7d529b..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitRuleCommand.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import com.google.gerrit.extensions.common.TestSubmitRuleInput;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.change.TestSubmitRule;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.inject.Inject;
-
-/** Command that allows testing of prolog submit-rules in a live instance. */
-@CommandMetaData(name = "rule", description = "Test prolog submit rules")
-final class TestSubmitRuleCommand extends BaseTestPrologCommand {
-  @Inject private TestSubmitRule view;
-
-  @Override
-  protected RestModifyView<RevisionResource, TestSubmitRuleInput> createView() {
-    return view;
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitTypeCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitTypeCommand.java
deleted file mode 100644
index ebe8925..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitTypeCommand.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-package com.google.gerrit.sshd.commands;
-
-import com.google.gerrit.extensions.common.TestSubmitRuleInput;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.change.TestSubmitType;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.inject.Inject;
-
-@CommandMetaData(name = "type", description = "Test prolog submit type")
-final class TestSubmitTypeCommand extends BaseTestPrologCommand {
-  @Inject private TestSubmitType view;
-
-  @Override
-  protected RestModifyView<RevisionResource, TestSubmitRuleInput> createView() {
-    return view;
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java
deleted file mode 100644
index 7049c7f..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import com.google.common.collect.Lists;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.git.TransferConfig;
-import com.google.gerrit.server.git.UploadPackInitializer;
-import com.google.gerrit.server.git.VisibleRefFilter;
-import com.google.gerrit.server.git.validators.UploadValidationException;
-import com.google.gerrit.server.git.validators.UploadValidators;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.sshd.AbstractGitCommand;
-import com.google.gerrit.sshd.SshSession;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.transport.PostUploadHook;
-import org.eclipse.jgit.transport.PostUploadHookChain;
-import org.eclipse.jgit.transport.PreUploadHook;
-import org.eclipse.jgit.transport.PreUploadHookChain;
-import org.eclipse.jgit.transport.UploadPack;
-
-/** Publishes Git repositories over SSH using the Git upload-pack protocol. */
-final class Upload extends AbstractGitCommand {
-  @Inject private TransferConfig config;
-  @Inject private VisibleRefFilter.Factory refFilterFactory;
-  @Inject private DynamicSet<PreUploadHook> preUploadHooks;
-  @Inject private DynamicSet<PostUploadHook> postUploadHooks;
-  @Inject private DynamicSet<UploadPackInitializer> uploadPackInitializers;
-  @Inject private UploadValidators.Factory uploadValidatorsFactory;
-  @Inject private SshSession session;
-  @Inject private PermissionBackend permissionBackend;
-
-  @Override
-  protected void runImpl() throws IOException, Failure {
-    try {
-      permissionBackend
-          .user(projectControl.getUser())
-          .project(projectControl.getProject().getNameKey())
-          .check(ProjectPermission.RUN_UPLOAD_PACK);
-    } catch (AuthException e) {
-      throw new Failure(1, "fatal: upload-pack not permitted on this server");
-    } catch (PermissionBackendException e) {
-      throw new Failure(1, "fatal: unable to check permissions " + e);
-    }
-
-    final UploadPack up = new UploadPack(repo);
-    up.setAdvertiseRefsHook(refFilterFactory.create(projectControl.getProjectState(), repo));
-    up.setPackConfig(config.getPackConfig());
-    up.setTimeout(config.getTimeout());
-    up.setPostUploadHook(PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
-
-    List<PreUploadHook> allPreUploadHooks = Lists.newArrayList(preUploadHooks);
-    allPreUploadHooks.add(
-        uploadValidatorsFactory.create(project, repo, session.getRemoteAddressAsString()));
-    up.setPreUploadHook(PreUploadHookChain.newChain(allPreUploadHooks));
-    for (UploadPackInitializer initializer : uploadPackInitializers) {
-      initializer.init(projectControl.getProject().getNameKey(), up);
-    }
-    try {
-      up.upload(in, out, err);
-      session.setPeerAgent(up.getPeerUserAgent());
-    } catch (UploadValidationException e) {
-      // UploadValidationException is used by the UploadValidators to
-      // stop the uploadPack. We do not want this exception to go beyond this
-      // point otherwise it would print a stacktrace in the logs and return an
-      // internal server error to the client.
-      if (!e.isOutput()) {
-        up.sendMessage(e.getMessage());
-      }
-    }
-  }
-}
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
deleted file mode 100644
index 9a3e6ab..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ /dev/null
@@ -1,257 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-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;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.CommitsCollection;
-import com.google.gerrit.sshd.AbstractGitCommand;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.api.ArchiveCommand;
-import org.eclipse.jgit.api.errors.GitAPIException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.PacketLineIn;
-import org.eclipse.jgit.transport.PacketLineOut;
-import org.eclipse.jgit.transport.SideBandOutputStream;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.CmdLineException;
-import org.kohsuke.args4j.CmdLineParser;
-import org.kohsuke.args4j.Option;
-
-/** Allows getting archives for Git repositories over SSH using the Git upload-archive protocol. */
-public class UploadArchive extends AbstractGitCommand {
-  /**
-   * Options for parsing Git commands.
-   *
-   * <p>These options are not passed on command line, but received through input stream in pkt-line
-   * format.
-   */
-  static class Options {
-    @Option(
-      name = "-f",
-      aliases = {"--format"},
-      usage =
-          "Format of the"
-              + " resulting archive: tar or zip... If this option is not given, and"
-              + " the output file is specified, the format is inferred from the"
-              + " filename if possible (e.g. writing to \"foo.zip\" makes the output"
-              + " to be in the zip format). Otherwise the output format is tar."
-    )
-    private String format = "tar";
-
-    @Option(name = "--prefix", usage = "Prepend <prefix>/ to each filename in the archive.")
-    private String prefix;
-
-    @Option(name = "-0", usage = "Store the files instead of deflating them.")
-    private boolean level0;
-
-    @Option(name = "-1")
-    private boolean level1;
-
-    @Option(name = "-2")
-    private boolean level2;
-
-    @Option(name = "-3")
-    private boolean level3;
-
-    @Option(name = "-4")
-    private boolean level4;
-
-    @Option(name = "-5")
-    private boolean level5;
-
-    @Option(name = "-6")
-    private boolean level6;
-
-    @Option(name = "-7")
-    private boolean level7;
-
-    @Option(name = "-8")
-    private boolean level8;
-
-    @Option(
-      name = "-9",
-      usage =
-          "Highest and slowest compression level. You "
-              + "can specify any number from 1 to 9 to adjust compression speed and "
-              + "ratio."
-    )
-    private boolean level9;
-
-    @Argument(index = 0, required = true, usage = "The tree or commit to produce an archive for.")
-    private String treeIsh = "master";
-
-    @Argument(
-      index = 1,
-      multiValued = true,
-      usage =
-          "Without an optional path parameter, all files and subdirectories of "
-              + "the current working directory are included in the archive. If one "
-              + "or more paths are specified, only these are included."
-    )
-    private List<String> path;
-  }
-
-  @Inject private PermissionBackend permissionBackend;
-  @Inject private CommitsCollection commits;
-  @Inject private IdentifiedUser user;
-  @Inject private AllowedFormats allowedFormats;
-  private Options options = new Options();
-
-  /**
-   * Read and parse arguments from input stream. This method gets the arguments from input stream,
-   * in Pkt-line format, then parses them to fill the options object.
-   */
-  protected void readArguments() throws IOException, Failure {
-    String argCmd = "argument ";
-    List<String> args = new ArrayList<>();
-
-    // Read arguments in Pkt-Line format
-    PacketLineIn packetIn = new PacketLineIn(in);
-    for (; ; ) {
-      String s = packetIn.readString();
-      if (s == PacketLineIn.END) {
-        break;
-      }
-      if (!s.startsWith(argCmd)) {
-        throw new Failure(1, "fatal: 'argument' token or flush expected");
-      }
-      String[] parts = s.substring(argCmd.length()).split("=", 2);
-      for (String p : parts) {
-        args.add(p);
-      }
-    }
-
-    try {
-      // Parse them into the 'options' field
-      CmdLineParser parser = new CmdLineParser(options);
-      parser.parseArgument(args);
-      if (options.path == null || Arrays.asList(".").equals(options.path)) {
-        options.path = Collections.emptyList();
-      }
-    } catch (CmdLineException e) {
-      throw new Failure(2, "fatal: unable to parse arguments, " + e);
-    }
-  }
-
-  @Override
-  protected void runImpl() throws IOException, PermissionBackendException, Failure {
-    PacketLineOut packetOut = new PacketLineOut(out);
-    packetOut.setFlushOnEnd(true);
-    packetOut.writeString("ACK");
-    packetOut.end();
-
-    try {
-      // Parse Git arguments
-      readArguments();
-
-      ArchiveFormat f = allowedFormats.getExtensions().get("." + options.format);
-      if (f == null) {
-        throw new Failure(3, "fatal: upload-archive not permitted");
-      }
-
-      // Find out the object to get from the specified reference and paths
-      ObjectId treeId = repo.resolve(options.treeIsh);
-      if (treeId == null) {
-        throw new Failure(4, "fatal: reference not found");
-      }
-
-      // Verify the user has permissions to read the specified tree.
-      if (!canRead(treeId)) {
-        throw new Failure(5, "fatal: cannot perform upload-archive operation");
-      }
-
-      // The archive is sent in DATA sideband channel
-      try (SideBandOutputStream sidebandOut =
-          new SideBandOutputStream(
-              SideBandOutputStream.CH_DATA, SideBandOutputStream.MAX_BUF, out)) {
-        new ArchiveCommand(repo)
-            .setFormat(f.name())
-            .setFormatOptions(getFormatOptions(f))
-            .setTree(treeId)
-            .setPaths(options.path.toArray(new String[0]))
-            .setPrefix(options.prefix)
-            .setOutputStream(sidebandOut)
-            .call();
-        sidebandOut.flush();
-      } catch (GitAPIException e) {
-        throw new Failure(7, "fatal: git api exception, " + e);
-      }
-    } catch (Failure f) {
-      // Report the error in ERROR sideband channel
-      try (SideBandOutputStream sidebandError =
-          new SideBandOutputStream(
-              SideBandOutputStream.CH_ERROR, SideBandOutputStream.MAX_BUF, out)) {
-        sidebandError.write(f.getMessage().getBytes(UTF_8));
-        sidebandError.flush();
-      }
-      throw f;
-    } finally {
-      // In any case, cleanly close the packetOut channel
-      packetOut.end();
-    }
-  }
-
-  private Map<String, Object> getFormatOptions(ArchiveFormat f) {
-    if (f == ArchiveFormat.ZIP) {
-      int value =
-          Arrays.asList(
-                  options.level0,
-                  options.level1,
-                  options.level2,
-                  options.level3,
-                  options.level4,
-                  options.level5,
-                  options.level6,
-                  options.level7,
-                  options.level8,
-                  options.level9)
-              .indexOf(true);
-      if (value >= 0) {
-        return ImmutableMap.<String, Object>of("level", Integer.valueOf(value));
-      }
-    }
-    return Collections.emptyMap();
-  }
-
-  private boolean canRead(ObjectId revId) throws IOException, PermissionBackendException {
-    try {
-      permissionBackend.user(user).project(projectName).check(ProjectPermission.READ);
-      return true;
-    } catch (AuthException e) {
-      // Check reachability of the specific revision.
-      try (RevWalk rw = new RevWalk(repo)) {
-        RevCommit commit = rw.parseCommit(revId);
-        return commits.canRead(state, repo, commit);
-      }
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
deleted file mode 100644
index b44f0fc..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.plugin;
-
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.sshd.CommandModule;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.util.ArrayList;
-import java.util.List;
-import org.eclipse.jgit.lib.Config;
-import org.kohsuke.args4j.Argument;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class LfsPluginAuthCommand extends SshCommand {
-  private static final Logger log = LoggerFactory.getLogger(LfsPluginAuthCommand.class);
-  private static final String CONFIGURATION_ERROR =
-      "Server configuration error: LFS auth over SSH is not properly configured.";
-
-  public interface LfsSshPluginAuth {
-    String authenticate(CurrentUser user, List<String> args) throws UnloggedFailure, Failure;
-  }
-
-  public static class Module extends CommandModule {
-    private final boolean pluginProvided;
-
-    @Inject
-    Module(@GerritServerConfig Config cfg) {
-      pluginProvided = cfg.getString("lfs", null, "plugin") != null;
-    }
-
-    @Override
-    protected void configure() {
-      if (pluginProvided) {
-        command("git-lfs-authenticate").to(LfsPluginAuthCommand.class);
-        DynamicItem.itemOf(binder(), LfsSshPluginAuth.class);
-      }
-    }
-  }
-
-  private final DynamicItem<LfsSshPluginAuth> auth;
-  private final Provider<CurrentUser> user;
-
-  @Argument(index = 0, multiValued = true, metaVar = "PARAMS")
-  private List<String> args = new ArrayList<>();
-
-  @Inject
-  LfsPluginAuthCommand(DynamicItem<LfsSshPluginAuth> auth, Provider<CurrentUser> user) {
-    this.auth = auth;
-    this.user = user;
-  }
-
-  @Override
-  protected void run() throws UnloggedFailure, Exception {
-    LfsSshPluginAuth pluginAuth = auth.get();
-    if (pluginAuth == null) {
-      log.warn(CONFIGURATION_ERROR);
-      throw new UnloggedFailure(1, CONFIGURATION_ERROR);
-    }
-
-    stdout.print(pluginAuth.authenticate(user.get(), args));
-  }
-}
diff --git a/gerrit-test-util/BUILD b/gerrit-test-util/BUILD
deleted file mode 100644
index 55954ba..0000000
--- a/gerrit-test-util/BUILD
+++ /dev/null
@@ -1,10 +0,0 @@
-java_library(
-    name = "test_util",
-    testonly = 1,
-    srcs = glob(["src/main/java/**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-extension-api:api",
-        "//lib:truth",
-    ],
-)
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
deleted file mode 100644
index 70f5ec6..0000000
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/client/RangeSubject.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.client;
-
-import static com.google.common.truth.Truth.assertAbout;
-
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.IntegerSubject;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.common.truth.Truth;
-
-public class 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);
-  }
-
-  private RangeSubject(FailureStrategy failureStrategy, Comment.Range range) {
-    super(failureStrategy, range);
-  }
-
-  public IntegerSubject startLine() {
-    return Truth.assertThat(actual().startLine).named("startLine");
-  }
-
-  public IntegerSubject startCharacter() {
-    return Truth.assertThat(actual().startCharacter).named("startCharacter");
-  }
-
-  public IntegerSubject endLine() {
-    return Truth.assertThat(actual().endLine).named("endLine");
-  }
-
-  public IntegerSubject endCharacter() {
-    return Truth.assertThat(actual().endCharacter).named("endCharacter");
-  }
-
-  public void isValid() {
-    isNotNull();
-    if (!actual().isValid()) {
-      fail("is valid");
-    }
-  }
-
-  public void isInvalid() {
-    isNotNull();
-    if (actual().isValid()) {
-      fail("is invalid");
-    }
-  }
-}
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
deleted file mode 100644
index b2717af..0000000
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/CommitInfoSubject.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import static com.google.common.truth.Truth.assertAbout;
-
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.StringSubject;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.common.truth.Truth;
-import com.google.gerrit.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);
-  }
-
-  private CommitInfoSubject(FailureStrategy failureStrategy, CommitInfo commitInfo) {
-    super(failureStrategy, commitInfo);
-  }
-
-  public StringSubject commit() {
-    isNotNull();
-    CommitInfo commitInfo = actual();
-    return Truth.assertThat(commitInfo.commit).named("commit");
-  }
-
-  public ListSubject<CommitInfoSubject, CommitInfo> parents() {
-    isNotNull();
-    CommitInfo commitInfo = actual();
-    return ListSubject.assertThat(commitInfo.parents, CommitInfoSubject::assertThat)
-        .named("parents");
-  }
-
-  public GitPersonSubject committer() {
-    isNotNull();
-    CommitInfo commitInfo = actual();
-    return GitPersonSubject.assertThat(commitInfo.committer).named("committer");
-  }
-}
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
deleted file mode 100644
index 9c9893c..0000000
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/ContentEntrySubject.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import static com.google.common.truth.Truth.assertAbout;
-
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.StringSubject;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.common.truth.Truth;
-import com.google.gerrit.extensions.common.DiffInfo.ContentEntry;
-import com.google.gerrit.truth.ListSubject;
-
-public class ContentEntrySubject extends Subject<ContentEntrySubject, ContentEntry> {
-
-  private static final SubjectFactory<ContentEntrySubject, ContentEntry> DIFF_INFO_SUBJECT_FACTORY =
-      new SubjectFactory<ContentEntrySubject, ContentEntry>() {
-        @Override
-        public ContentEntrySubject getSubject(
-            FailureStrategy failureStrategy, ContentEntry contentEntry) {
-          return new ContentEntrySubject(failureStrategy, contentEntry);
-        }
-      };
-
-  public static ContentEntrySubject assertThat(ContentEntry contentEntry) {
-    return assertAbout(DIFF_INFO_SUBJECT_FACTORY).that(contentEntry);
-  }
-
-  private ContentEntrySubject(FailureStrategy failureStrategy, ContentEntry contentEntry) {
-    super(failureStrategy, contentEntry);
-  }
-
-  public void isDueToRebase() {
-    isNotNull();
-    ContentEntry contentEntry = actual();
-    Truth.assertWithMessage("Entry should be marked 'dueToRebase'")
-        .that(contentEntry.dueToRebase)
-        .named("dueToRebase")
-        .isTrue();
-  }
-
-  public void isNotDueToRebase() {
-    isNotNull();
-    ContentEntry contentEntry = actual();
-    Truth.assertWithMessage("Entry should not be marked 'dueToRebase'")
-        .that(contentEntry.dueToRebase)
-        .named("dueToRebase")
-        .isNull();
-  }
-
-  public ListSubject<StringSubject, String> commonLines() {
-    isNotNull();
-    ContentEntry contentEntry = actual();
-    return ListSubject.assertThat(contentEntry.ab, Truth::assertThat).named("common lines");
-  }
-
-  public ListSubject<StringSubject, String> linesOfA() {
-    isNotNull();
-    ContentEntry contentEntry = actual();
-    return ListSubject.assertThat(contentEntry.a, Truth::assertThat).named("lines of 'a'");
-  }
-
-  public ListSubject<StringSubject, String> linesOfB() {
-    isNotNull();
-    ContentEntry contentEntry = actual();
-    return ListSubject.assertThat(contentEntry.b, Truth::assertThat).named("lines of 'b'");
-  }
-}
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
deleted file mode 100644
index 1b1b847..0000000
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/DiffInfoSubject.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import static com.google.common.truth.Truth.assertAbout;
-
-import com.google.common.truth.ComparableSubject;
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.common.truth.Truth;
-import com.google.gerrit.extensions.common.DiffInfo.ContentEntry;
-import com.google.gerrit.truth.ListSubject;
-
-public class DiffInfoSubject extends Subject<DiffInfoSubject, DiffInfo> {
-
-  private static final SubjectFactory<DiffInfoSubject, DiffInfo> DIFF_INFO_SUBJECT_FACTORY =
-      new SubjectFactory<DiffInfoSubject, DiffInfo>() {
-        @Override
-        public DiffInfoSubject getSubject(FailureStrategy failureStrategy, DiffInfo diffInfo) {
-          return new DiffInfoSubject(failureStrategy, diffInfo);
-        }
-      };
-
-  public static DiffInfoSubject assertThat(DiffInfo diffInfo) {
-    return assertAbout(DIFF_INFO_SUBJECT_FACTORY).that(diffInfo);
-  }
-
-  private DiffInfoSubject(FailureStrategy failureStrategy, DiffInfo diffInfo) {
-    super(failureStrategy, diffInfo);
-  }
-
-  public ListSubject<ContentEntrySubject, ContentEntry> content() {
-    isNotNull();
-    DiffInfo diffInfo = actual();
-    return ListSubject.assertThat(diffInfo.content, ContentEntrySubject::assertThat)
-        .named("content");
-  }
-
-  public ComparableSubject<?, ChangeType> changeType() {
-    isNotNull();
-    DiffInfo diffInfo = actual();
-    return Truth.assertThat(diffInfo.changeType).named("changeType");
-  }
-}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/EditInfoSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/EditInfoSubject.java
deleted file mode 100644
index 95b2158..0000000
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/EditInfoSubject.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import static com.google.common.truth.Truth.assertAbout;
-
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.StringSubject;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.common.truth.Truth;
-import com.google.gerrit.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);
-  }
-
-  public static OptionalSubject<EditInfoSubject, EditInfo> assertThat(
-      Optional<EditInfo> editInfoOptional) {
-    return OptionalSubject.assertThat(editInfoOptional, EditInfoSubject::assertThat);
-  }
-
-  private EditInfoSubject(FailureStrategy failureStrategy, EditInfo editInfo) {
-    super(failureStrategy, editInfo);
-  }
-
-  public CommitInfoSubject commit() {
-    isNotNull();
-    EditInfo editInfo = actual();
-    return CommitInfoSubject.assertThat(editInfo.commit).named("commit");
-  }
-
-  public StringSubject baseRevision() {
-    isNotNull();
-    EditInfo editInfo = actual();
-    return Truth.assertThat(editInfo.baseRevision).named("baseRevision");
-  }
-}
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
deleted file mode 100644
index f8cdb34..0000000
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FileInfoSubject.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import static com.google.common.truth.Truth.assertAbout;
-
-import com.google.common.truth.ComparableSubject;
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.IntegerSubject;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.common.truth.Truth;
-
-public class FileInfoSubject extends Subject<FileInfoSubject, FileInfo> {
-
-  private static final SubjectFactory<FileInfoSubject, FileInfo> FILE_INFO_SUBJECT_FACTORY =
-      new SubjectFactory<FileInfoSubject, FileInfo>() {
-        @Override
-        public FileInfoSubject getSubject(FailureStrategy failureStrategy, FileInfo fileInfo) {
-          return new FileInfoSubject(failureStrategy, fileInfo);
-        }
-      };
-
-  public static FileInfoSubject assertThat(FileInfo fileInfo) {
-    return assertAbout(FILE_INFO_SUBJECT_FACTORY).that(fileInfo);
-  }
-
-  private FileInfoSubject(FailureStrategy failureStrategy, FileInfo fileInfo) {
-    super(failureStrategy, fileInfo);
-  }
-
-  public IntegerSubject linesInserted() {
-    isNotNull();
-    FileInfo fileInfo = actual();
-    return Truth.assertThat(fileInfo.linesInserted).named("linesInserted");
-  }
-
-  public IntegerSubject linesDeleted() {
-    isNotNull();
-    FileInfo fileInfo = actual();
-    return Truth.assertThat(fileInfo.linesDeleted).named("linesDeleted");
-  }
-
-  public ComparableSubject<?, Character> status() {
-    isNotNull();
-    FileInfo fileInfo = actual();
-    return Truth.assertThat(fileInfo.status).named("status");
-  }
-}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfoSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfoSubject.java
deleted file mode 100644
index f798622..0000000
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfoSubject.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import static com.google.common.truth.Truth.assertAbout;
-
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.StringSubject;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.common.truth.Truth;
-import com.google.gerrit.extensions.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);
-  }
-
-  private FixReplacementInfoSubject(
-      FailureStrategy failureStrategy, FixReplacementInfo fixReplacementInfo) {
-    super(failureStrategy, fixReplacementInfo);
-  }
-
-  public StringSubject path() {
-    return Truth.assertThat(actual().path).named("path");
-  }
-
-  public RangeSubject range() {
-    return RangeSubject.assertThat(actual().range).named("range");
-  }
-
-  public StringSubject replacement() {
-    return Truth.assertThat(actual().replacement).named("replacement");
-  }
-}
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
deleted file mode 100644
index 9af4d1f..0000000
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixSuggestionInfoSubject.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import static com.google.common.truth.Truth.assertAbout;
-
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.StringSubject;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.common.truth.Truth;
-import com.google.gerrit.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);
-  }
-
-  private FixSuggestionInfoSubject(
-      FailureStrategy failureStrategy, FixSuggestionInfo fixSuggestionInfo) {
-    super(failureStrategy, fixSuggestionInfo);
-  }
-
-  public StringSubject fixId() {
-    return Truth.assertThat(actual().fixId).named("fixId");
-  }
-
-  public ListSubject<FixReplacementInfoSubject, FixReplacementInfo> replacements() {
-    return ListSubject.assertThat(actual().replacements, FixReplacementInfoSubject::assertThat)
-        .named("replacements");
-  }
-
-  public FixReplacementInfoSubject onlyReplacement() {
-    return replacements().onlyElement();
-  }
-
-  public StringSubject description() {
-    return Truth.assertThat(actual().description).named("description");
-  }
-}
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
deleted file mode 100644
index 9ef06dc..0000000
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/GitPersonSubject.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import static com.google.common.truth.Truth.assertAbout;
-
-import com.google.common.truth.ComparableSubject;
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.common.truth.Truth;
-import 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);
-  }
-
-  private GitPersonSubject(FailureStrategy failureStrategy, GitPerson gitPerson) {
-    super(failureStrategy, gitPerson);
-  }
-
-  public ComparableSubject<?, Timestamp> creationDate() {
-    isNotNull();
-    GitPerson gitPerson = actual();
-    return Truth.assertThat(gitPerson.date).named("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
deleted file mode 100644
index 307c19e..0000000
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/PathSubject.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import static com.google.common.truth.Truth.assertAbout;
-
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import 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);
-  }
-
-  public static PathSubject assertThat(Path path) {
-    return assertAbout(PATH_SUBJECT_FACTORY).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
deleted file mode 100644
index afa1b9b..0000000
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfoSubject.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import static com.google.common.truth.Truth.assertAbout;
-
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.gerrit.truth.ListSubject;
-import java.util.List;
-
-public class 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)
-        .named("robotCommentInfos");
-  }
-
-  public static RobotCommentInfoSubject assertThat(RobotCommentInfo robotCommentInfo) {
-    return assertAbout(ROBOT_COMMENT_INFO_SUBJECT_FACTORY).that(robotCommentInfo);
-  }
-
-  private RobotCommentInfoSubject(
-      FailureStrategy failureStrategy, RobotCommentInfo robotCommentInfo) {
-    super(failureStrategy, robotCommentInfo);
-  }
-
-  public ListSubject<FixSuggestionInfoSubject, FixSuggestionInfo> fixSuggestions() {
-    return ListSubject.assertThat(actual().fixSuggestions, FixSuggestionInfoSubject::assertThat)
-        .named("fixSuggestions");
-  }
-
-  public FixSuggestionInfoSubject onlyFixSuggestion() {
-    return fixSuggestions().onlyElement();
-  }
-}
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
deleted file mode 100644
index 30ac496..0000000
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.restapi;
-
-import static com.google.common.truth.Truth.assertAbout;
-
-import com.google.common.truth.FailureStrategy;
-import com.google.common.truth.PrimitiveByteArraySubject;
-import com.google.common.truth.StringSubject;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.common.truth.Truth;
-import com.google.gerrit.truth.OptionalSubject;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.util.Optional;
-
-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);
-  }
-
-  public static OptionalSubject<BinaryResultSubject, BinaryResult> assertThat(
-      Optional<BinaryResult> binaryResultOptional) {
-    return OptionalSubject.assertThat(binaryResultOptional, BinaryResultSubject::assertThat);
-  }
-
-  private BinaryResultSubject(FailureStrategy failureStrategy, BinaryResult binaryResult) {
-    super(failureStrategy, binaryResult);
-  }
-
-  public StringSubject asString() throws IOException {
-    isNotNull();
-    // We shouldn't close the BinaryResult within this method as it might still
-    // be used afterwards. Besides, closing it doesn't have an effect for most
-    // implementations of a BinaryResult.
-    BinaryResult binaryResult = actual();
-    return Truth.assertThat(binaryResult.asString());
-  }
-
-  public PrimitiveByteArraySubject bytes() throws IOException {
-    isNotNull();
-    // We shouldn't close the BinaryResult within this method as it might still
-    // be used afterwards. Besides, closing it doesn't have an effect for most
-    // implementations of a BinaryResult.
-    BinaryResult binaryResult = actual();
-    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
-    binaryResult.writeTo(byteArrayOutputStream);
-    byte[] bytes = byteArrayOutputStream.toByteArray();
-    return Truth.assertThat(bytes);
-  }
-}
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
deleted file mode 100644
index e7f1074..0000000
--- a/gerrit-test-util/src/main/java/com/google/gerrit/truth/ListSubject.java
+++ /dev/null
@@ -1,90 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.truth;
-
-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.IterableSubject;
-import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
-import java.util.List;
-import java.util.function.Function;
-
-public class ListSubject<S extends Subject<S, E>, E> extends IterableSubject {
-
-  private final Function<E, S> elementAssertThatFunction;
-
-  @SuppressWarnings("unchecked")
-  public static <S extends Subject<S, E>, E> ListSubject<S, E> assertThat(
-      List<E> list, Function<E, S> elementAssertThatFunction) {
-    // The ListSubjectFactory always returns ListSubjects.
-    // -> Casting is appropriate.
-    return (ListSubject<S, E>)
-        assertAbout(new ListSubjectFactory<>(elementAssertThatFunction)).that(list);
-  }
-
-  private ListSubject(
-      FailureStrategy failureStrategy, List<E> list, Function<E, S> elementAssertThatFunction) {
-    super(failureStrategy, list);
-    this.elementAssertThatFunction = elementAssertThatFunction;
-  }
-
-  public S element(int index) {
-    checkArgument(index >= 0, "index(%s) must be >= 0", index);
-    // The constructor only accepts lists.
-    // -> Casting is appropriate.
-    @SuppressWarnings("unchecked")
-    List<E> list = (List<E>) actual();
-    isNotNull();
-    if (index >= list.size()) {
-      fail("has an element at index " + index);
-    }
-    return elementAssertThatFunction.apply(list.get(index));
-  }
-
-  public S onlyElement() {
-    isNotNull();
-    hasSize(1);
-    return element(0);
-  }
-
-  @SuppressWarnings("unchecked")
-  @Override
-  public ListSubject<S, E> named(String s, Object... objects) {
-    // This object is returned which is of type ListSubject.
-    // -> Casting is appropriate.
-    return (ListSubject<S, E>) super.named(s, objects);
-  }
-
-  private static class ListSubjectFactory<S extends Subject<S, T>, T>
-      extends SubjectFactory<IterableSubject, Iterable<?>> {
-
-    private Function<T, S> elementAssertThatFunction;
-
-    ListSubjectFactory(Function<T, S> elementAssertThatFunction) {
-      this.elementAssertThatFunction = elementAssertThatFunction;
-    }
-
-    @SuppressWarnings("unchecked")
-    @Override
-    public ListSubject<S, T> getSubject(FailureStrategy failureStrategy, Iterable<?> objects) {
-      // The constructor of ListSubject only accepts lists.
-      // -> Casting is appropriate.
-      return new ListSubject<>(failureStrategy, (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
deleted file mode 100644
index 49e91a8..0000000
--- a/gerrit-test-util/src/main/java/com/google/gerrit/truth/OptionalSubject.java
+++ /dev/null
@@ -1,98 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.truth;
-
-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.Subject;
-import com.google.common.truth.SubjectFactory;
-import com.google.common.truth.Truth;
-import java.util.Optional;
-import java.util.function.Function;
-
-public class OptionalSubject<S extends Subject<S, ? super T>, T>
-    extends Subject<OptionalSubject<S, T>, Optional<T>> {
-
-  private final Function<? super T, ? extends S> valueAssertThatFunction;
-
-  public static <S extends Subject<S, ? super T>, T> OptionalSubject<S, T> assertThat(
-      Optional<T> optional, Function<? super T, ? extends S> elementAssertThatFunction) {
-    OptionalSubjectFactory<S, T> optionalSubjectFactory =
-        new OptionalSubjectFactory<>(elementAssertThatFunction);
-    return assertAbout(optionalSubjectFactory).that(optional);
-  }
-
-  public static OptionalSubject<DefaultSubject, ?> assertThat(Optional<?> optional) {
-    // Unfortunately, we need to cast to DefaultSubject as Truth.assertThat()
-    // only returns Subject<DefaultSubject, Object>. There shouldn't be a way
-    // for that method not to return a DefaultSubject because the generic type
-    // definitions of a Subject are quite strict.
-    Function<Object, DefaultSubject> valueAssertThatFunction =
-        value -> (DefaultSubject) Truth.assertThat(value);
-    return assertThat(optional, valueAssertThatFunction);
-  }
-
-  private OptionalSubject(
-      FailureStrategy failureStrategy,
-      Optional<T> optional,
-      Function<? super T, ? extends S> valueAssertThatFunction) {
-    super(failureStrategy, optional);
-    this.valueAssertThatFunction = valueAssertThatFunction;
-  }
-
-  public void isPresent() {
-    isNotNull();
-    Optional<T> optional = actual();
-    if (!optional.isPresent()) {
-      fail("has a value");
-    }
-  }
-
-  public void isAbsent() {
-    isNotNull();
-    Optional<T> optional = actual();
-    if (optional.isPresent()) {
-      fail("does not have a value");
-    }
-  }
-
-  public void isEmpty() {
-    isAbsent();
-  }
-
-  public S value() {
-    isNotNull();
-    isPresent();
-    Optional<T> optional = actual();
-    return valueAssertThatFunction.apply(optional.get());
-  }
-
-  private static class OptionalSubjectFactory<S extends Subject<S, ? super T>, T>
-      extends SubjectFactory<OptionalSubject<S, T>, Optional<T>> {
-
-    private Function<? super T, ? extends S> valueAssertThatFunction;
-
-    OptionalSubjectFactory(Function<? super T, ? extends S> valueAssertThatFunction) {
-      this.valueAssertThatFunction = valueAssertThatFunction;
-    }
-
-    @Override
-    public OptionalSubject<S, T> getSubject(FailureStrategy failureStrategy, Optional<T> optional) {
-      return new OptionalSubject<>(failureStrategy, optional, valueAssertThatFunction);
-    }
-  }
-}
diff --git a/gerrit-util-cli/BUILD b/gerrit-util-cli/BUILD
deleted file mode 100644
index bb282f4..0000000
--- a/gerrit-util-cli/BUILD
+++ /dev/null
@@ -1,13 +0,0 @@
-java_library(
-    name = "cli",
-    srcs = glob(["src/main/java/**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-common:annotations",
-        "//gerrit-common:server",
-        "//lib:args4j",
-        "//lib:guava",
-        "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-    ],
-)
diff --git a/gerrit-util-http/BUILD b/gerrit-util-http/BUILD
deleted file mode 100644
index 47cc62e..0000000
--- a/gerrit-util-http/BUILD
+++ /dev/null
@@ -1,40 +0,0 @@
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-java_library(
-    name = "http",
-    srcs = glob(["src/main/java/**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = ["//lib:servlet-api-3_1"],
-)
-
-TESTUTIL_SRCS = glob(["src/test/**/testutil/**/*.java"])
-
-java_library(
-    name = "testutil",
-    testonly = 1,
-    srcs = TESTUTIL_SRCS,
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-extension-api:api",
-        "//lib:guava",
-        "//lib:servlet-api-3_1",
-        "//lib/httpcomponents:httpclient",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-    ],
-)
-
-junit_tests(
-    name = "http_tests",
-    srcs = glob(
-        ["src/test/java/**/*.java"],
-        exclude = TESTUTIL_SRCS,
-    ),
-    deps = [
-        ":http",
-        ":testutil",
-        "//lib:junit",
-        "//lib:servlet-api-3_1-without-neverlink",
-        "//lib:truth",
-        "//lib/easymock",
-    ],
-)
diff --git a/gerrit-util-ssl/BUILD b/gerrit-util-ssl/BUILD
deleted file mode 100644
index ce53a26..0000000
--- a/gerrit-util-ssl/BUILD
+++ /dev/null
@@ -1,5 +0,0 @@
-java_library(
-    name = "ssl",
-    srcs = glob(["src/main/java/**/*.java"]),
-    visibility = ["//visibility:public"],
-)
diff --git a/gerrit-war/BUILD b/gerrit-war/BUILD
deleted file mode 100644
index f2efb5f..0000000
--- a/gerrit-war/BUILD
+++ /dev/null
@@ -1,73 +0,0 @@
-load("//tools/bzl:genrule2.bzl", "genrule2")
-
-java_library(
-    name = "init",
-    srcs = glob(["src/main/java/**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//gerrit-cache-h2:cache-h2",
-        "//gerrit-elasticsearch:elasticsearch",
-        "//gerrit-extension-api:api",
-        "//gerrit-gpg:gpg",
-        "//gerrit-httpd:httpd",
-        "//gerrit-lucene:lucene",
-        "//gerrit-oauth:oauth",
-        "//gerrit-openid:openid",
-        "//gerrit-pgm:http",
-        "//gerrit-pgm:init",
-        "//gerrit-pgm:init-api",
-        "//gerrit-pgm:util",
-        "//gerrit-reviewdb:server",
-        "//gerrit-server:module",
-        "//gerrit-server:prolog-common",
-        "//gerrit-server:receive",
-        "//gerrit-server:server",
-        "//gerrit-sshd:sshd",
-        "//lib:guava",
-        "//lib:gwtorm",
-        "//lib:servlet-api-3_1",
-        "//lib/guice",
-        "//lib/guice:guice-servlet",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/log:api",
-    ],
-)
-
-genrule2(
-    name = "webapp_assets",
-    srcs = glob(["src/main/webapp/**/*"]),
-    outs = ["webapp_assets.zip"],
-    cmd = "cd gerrit-war/src/main/webapp; zip -qr $$ROOT/$@ .",
-    visibility = ["//visibility:public"],
-)
-
-java_import(
-    name = "log4j-config",
-    jars = [":log4j-config__jar"],
-    visibility = ["//visibility:public"],
-)
-
-genrule2(
-    name = "log4j-config__jar",
-    srcs = ["src/main/resources/log4j.properties"],
-    outs = ["log4j-config.jar"],
-    cmd = "cd gerrit-war/src/main/resources && zip -9Dqr $$ROOT/$@ .",
-)
-
-java_import(
-    name = "version",
-    jars = [":gen_version"],
-    visibility = ["//visibility:public"],
-)
-
-genrule2(
-    name = "gen_version",
-    outs = ["gen_version.jar"],
-    cmd = " && ".join([
-        "cd $$TMP",
-        "mkdir -p com/google/gerrit/common",
-        "cat $$ROOT/$(location //:version.txt) >com/google/gerrit/common/Version",
-        "zip -9Dqr $$ROOT/$@ .",
-    ]),
-    tools = ["//:version.txt"],
-)
diff --git a/gerrit-war/pom.xml b/gerrit-war/pom.xml
deleted file mode 100644
index 3b1098c..0000000
--- a/gerrit-war/pom.xml
+++ /dev/null
@@ -1,86 +0,0 @@
-<project>
-  <modelVersion>4.0.0</modelVersion>
-  <groupId>com.google.gerrit</groupId>
-  <artifactId>gerrit-war</artifactId>
-  <version>2.15-rc2</version>
-  <packaging>war</packaging>
-  <name>Gerrit Code Review - WAR</name>
-  <description>Gerrit WAR</description>
-  <url>https://www.gerritcodereview.com/</url>
-
-  <licenses>
-    <license>
-      <name>The Apache Software License, Version 2.0</name>
-      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
-      <distribution>repo</distribution>
-    </license>
-  </licenses>
-
-  <scm>
-    <url>https://gerrit.googlesource.com/gerrit</url>
-    <connection>https://gerrit.googlesource.com/gerrit</connection>
-  </scm>
-
-  <developers>
-    <developer>
-      <name>Alice Kober-Sotzek</name>
-    </developer>
-    <developer>
-      <name>Andrew Bonventre</name>
-    </developer>
-    <developer>
-      <name>Becky Siegel</name>
-    </developer>
-    <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
-      <name>David Ostrovsky</name>
-    </developer>
-    <developer>
-      <name>David Pursehouse</name>
-    </developer>
-    <developer>
-      <name>Edwin Kempin</name>
-    </developer>
-    <developer>
-      <name>Hugo Arès</name>
-    </developer>
-    <developer>
-      <name>Kasper Nilsson</name>
-    </developer>
-    <developer>
-      <name>Logan Hanks</name>
-    </developer>
-    <developer>
-      <name>Martin Fick</name>
-    </developer>
-    <developer>
-      <name>Saša Živkov</name>
-    </developer>
-    <developer>
-      <name>Shawn Pearce</name>
-    </developer>
-    <developer>
-      <name>Viktar Donich</name>
-    </developer>
-    <developer>
-      <name>Wyatt Allen</name>
-    </developer>
-  </developers>
-
-  <mailingLists>
-    <mailingList>
-      <name>Repo and Gerrit Discussion</name>
-      <post>repo-discuss@googlegroups.com</post>
-      <subscribe>https://groups.google.com/forum/#!forum/repo-discuss</subscribe>
-      <unsubscribe>https://groups.google.com/forum/#!forum/repo-discuss</unsubscribe>
-      <archive>https://groups.google.com/forum/#!forum/repo-discuss</archive>
-    </mailingList>
-  </mailingLists>
-
-  <issueManagement>
-    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
-    <system>Gerrit Issue Tracker</system>
-  </issueManagement>
-</project>
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/ReviewDbDataSourceProvider.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/ReviewDbDataSourceProvider.java
deleted file mode 100644
index 616030e..0000000
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/ReviewDbDataSourceProvider.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd;
-
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import javax.naming.InitialContext;
-import javax.naming.NamingException;
-import javax.sql.DataSource;
-
-/** Provides access to the {@code ReviewDb} DataSource. */
-@Singleton
-final class ReviewDbDataSourceProvider implements Provider<DataSource>, LifecycleListener {
-  private DataSource ds;
-
-  @Override
-  public synchronized DataSource get() {
-    if (ds == null) {
-      ds = open();
-    }
-    return ds;
-  }
-
-  @Override
-  public void start() {}
-
-  @Override
-  public synchronized void stop() {
-    if (ds != null) {
-      closeDataSource(ds);
-    }
-  }
-
-  private DataSource open() {
-    final String dsName = "java:comp/env/jdbc/ReviewDb";
-    try {
-      return (DataSource) new InitialContext().lookup(dsName);
-    } catch (NamingException namingErr) {
-      throw new ProvisionException("No DataSource " + dsName, namingErr);
-    }
-  }
-
-  private void closeDataSource(DataSource ds) {
-    try {
-      Class<?> type = Class.forName("org.apache.commons.dbcp.BasicDataSource");
-      if (type.isInstance(ds)) {
-        type.getMethod("close").invoke(ds);
-        return;
-      }
-    } catch (Throwable bad) {
-      // Oh well, its not a Commons DBCP pooled connection.
-    }
-
-    try {
-      Class<?> type = Class.forName("com.mchange.v2.c3p0.DataSources");
-      if (type.isInstance(ds)) {
-        type.getMethod("destroy", DataSource.class).invoke(null, ds);
-      }
-    } catch (Throwable bad) {
-      // Oh well, its not a c3p0 pooled connection.
-    }
-  }
-}
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/SiteInitializer.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/SiteInitializer.java
deleted file mode 100644
index 07e662b..0000000
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/SiteInitializer.java
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd;
-
-import com.google.gerrit.pgm.init.BaseInit;
-import com.google.gerrit.pgm.init.PluginsDistribution;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.sql.Connection;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.List;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public final class SiteInitializer {
-  private static final Logger LOG = LoggerFactory.getLogger(SiteInitializer.class);
-
-  private final String sitePath;
-  private final String initPath;
-  private final PluginsDistribution pluginsDistribution;
-  private final List<String> pluginsToInstall;
-
-  SiteInitializer(
-      String sitePath,
-      String initPath,
-      PluginsDistribution pluginsDistribution,
-      List<String> pluginsToInstall) {
-    this.sitePath = sitePath;
-    this.initPath = initPath;
-    this.pluginsDistribution = pluginsDistribution;
-    this.pluginsToInstall = pluginsToInstall;
-  }
-
-  public void init() {
-    try {
-      if (sitePath != null) {
-        Path site = Paths.get(sitePath);
-        LOG.info("Initializing site at " + site.toRealPath().normalize());
-        new BaseInit(site, false, true, pluginsDistribution, pluginsToInstall).run();
-        return;
-      }
-
-      try (Connection conn = connectToDb()) {
-        Path site = getSiteFromReviewDb(conn);
-        if (site == null && initPath != null) {
-          site = Paths.get(initPath);
-        }
-        if (site != null) {
-          LOG.info("Initializing site at " + site.toRealPath().normalize());
-          new BaseInit(
-                  site,
-                  new ReviewDbDataSourceProvider(),
-                  false,
-                  false,
-                  pluginsDistribution,
-                  pluginsToInstall)
-              .run();
-        }
-      }
-    } catch (Exception e) {
-      LOG.error("Site init failed", e);
-      throw new RuntimeException(e);
-    }
-  }
-
-  private Connection connectToDb() throws SQLException {
-    return new ReviewDbDataSourceProvider().get().getConnection();
-  }
-
-  private Path getSiteFromReviewDb(Connection conn) {
-    try (Statement stmt = conn.createStatement();
-        ResultSet rs = stmt.executeQuery("SELECT site_path FROM system_config")) {
-      if (rs.next()) {
-        return Paths.get(rs.getString(1));
-      }
-    } catch (SQLException e) {
-      return null;
-    }
-    return null;
-  }
-}
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/SitePathFromSystemConfigProvider.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/SitePathFromSystemConfigProvider.java
deleted file mode 100644
index e1eb9de..0000000
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/SitePathFromSystemConfigProvider.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd;
-
-import com.google.gerrit.reviewdb.client.SystemConfig;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.config.SitePath;
-import com.google.gerrit.server.schema.ReviewDbFactory;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.List;
-
-/** Provides {@link Path} annotated with {@link SitePath}. */
-class SitePathFromSystemConfigProvider implements Provider<Path> {
-  private final Path path;
-
-  @Inject
-  SitePathFromSystemConfigProvider(@ReviewDbFactory SchemaFactory<ReviewDb> schemaFactory)
-      throws OrmException {
-    path = read(schemaFactory);
-  }
-
-  @Override
-  public Path get() {
-    return path;
-  }
-
-  private static Path read(SchemaFactory<ReviewDb> schemaFactory) throws OrmException {
-    try (ReviewDb db = schemaFactory.open()) {
-      List<SystemConfig> all = db.systemConfig().all().toList();
-      switch (all.size()) {
-        case 1:
-          return Paths.get(all.get(0).sitePath);
-        case 0:
-          throw new OrmException("system_config table is empty");
-        default:
-          throw new OrmException(
-              "system_config must have exactly 1 row; found " + all.size() + " rows instead");
-      }
-    }
-  }
-}
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/UnzippedDistribution.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/UnzippedDistribution.java
deleted file mode 100644
index ec92fba..0000000
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/UnzippedDistribution.java
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd;
-
-import static com.google.gerrit.pgm.init.InitPlugins.JAR;
-import static com.google.gerrit.pgm.init.InitPlugins.PLUGIN_DIR;
-
-import com.google.gerrit.pgm.init.PluginsDistribution;
-import com.google.inject.Singleton;
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.file.Files;
-import java.util.ArrayList;
-import java.util.List;
-import javax.servlet.ServletContext;
-
-@Singleton
-class UnzippedDistribution implements PluginsDistribution {
-
-  private ServletContext servletContext;
-  private File pluginsDir;
-
-  UnzippedDistribution(ServletContext servletContext) {
-    this.servletContext = servletContext;
-  }
-
-  @Override
-  public void foreach(Processor processor) throws FileNotFoundException, IOException {
-    File[] list = getPluginsDir().listFiles();
-    if (list != null) {
-      for (File p : list) {
-        String pluginJarName = p.getName();
-        String pluginName = pluginJarName.substring(0, pluginJarName.length() - JAR.length());
-        try (InputStream in = Files.newInputStream(p.toPath())) {
-          processor.process(pluginName, in);
-        }
-      }
-    }
-  }
-
-  @Override
-  public List<String> listPluginNames() throws FileNotFoundException {
-    List<String> names = new ArrayList<>();
-    String[] list = getPluginsDir().list();
-    if (list != null) {
-      for (String pluginJarName : list) {
-        String pluginName = pluginJarName.substring(0, pluginJarName.length() - JAR.length());
-        names.add(pluginName);
-      }
-    }
-    return names;
-  }
-
-  private File getPluginsDir() {
-    if (pluginsDir == null) {
-      File root = new File(servletContext.getRealPath(""));
-      pluginsDir = new File(root, PLUGIN_DIR);
-    }
-    return pluginsDir;
-  }
-}
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
deleted file mode 100644
index 9a02fcd..0000000
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
+++ /dev/null
@@ -1,467 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd;
-
-import static com.google.inject.Scopes.SINGLETON;
-import static com.google.inject.Stage.PRODUCTION;
-
-import com.google.common.base.Splitter;
-import com.google.gerrit.common.EventBroker;
-import com.google.gerrit.elasticsearch.ElasticIndexModule;
-import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.gpg.GpgModule;
-import com.google.gerrit.httpd.auth.oauth.OAuthModule;
-import com.google.gerrit.httpd.auth.openid.OpenIdModule;
-import com.google.gerrit.httpd.plugins.HttpPluginModule;
-import com.google.gerrit.httpd.raw.StaticModule;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.lucene.LuceneIndexModule;
-import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
-import com.google.gerrit.pgm.util.LogFileCompressor;
-import com.google.gerrit.server.LibModuleLoader;
-import com.google.gerrit.server.StartupChecks;
-import com.google.gerrit.server.account.AccountDeactivator;
-import com.google.gerrit.server.account.InternalAccountDirectory;
-import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
-import com.google.gerrit.server.change.ChangeCleanupRunner;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.config.AuthConfigModule;
-import com.google.gerrit.server.config.CanonicalWebUrlModule;
-import com.google.gerrit.server.config.DownloadConfig;
-import com.google.gerrit.server.config.GerritGlobalModule;
-import com.google.gerrit.server.config.GerritOptions;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.GerritServerConfigModule;
-import com.google.gerrit.server.config.RestCacheAdminModule;
-import com.google.gerrit.server.config.SitePath;
-import com.google.gerrit.server.events.StreamEventsApiListener;
-import com.google.gerrit.server.git.GarbageCollectionModule;
-import com.google.gerrit.server.git.GitRepositoryManagerModule;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.git.receive.ReceiveCommitsExecutorModule;
-import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.IndexModule.IndexType;
-import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
-import com.google.gerrit.server.mail.receive.MailReceiver;
-import com.google.gerrit.server.mail.send.SmtpEmailSender;
-import com.google.gerrit.server.mime.MimeUtil2Module;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.patch.DiffExecutorModule;
-import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
-import com.google.gerrit.server.plugins.PluginModule;
-import com.google.gerrit.server.plugins.PluginRestApiModule;
-import com.google.gerrit.server.project.DefaultPermissionBackendModule;
-import com.google.gerrit.server.schema.DataSourceModule;
-import com.google.gerrit.server.schema.DataSourceProvider;
-import com.google.gerrit.server.schema.DataSourceType;
-import com.google.gerrit.server.schema.DatabaseModule;
-import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
-import com.google.gerrit.server.schema.SchemaModule;
-import com.google.gerrit.server.schema.SchemaVersionCheck;
-import com.google.gerrit.server.securestore.SecureStoreClassName;
-import com.google.gerrit.server.ssh.NoSshModule;
-import com.google.gerrit.server.ssh.SshAddressesModule;
-import com.google.gerrit.sshd.SshHostKeyModule;
-import com.google.gerrit.sshd.SshKeyCacheImpl;
-import com.google.gerrit.sshd.SshModule;
-import com.google.gerrit.sshd.commands.DefaultCommandModule;
-import com.google.gerrit.sshd.commands.IndexCommandsModule;
-import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand;
-import com.google.inject.AbstractModule;
-import com.google.inject.CreationException;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import com.google.inject.Key;
-import com.google.inject.Module;
-import com.google.inject.Provider;
-import com.google.inject.name.Names;
-import com.google.inject.servlet.GuiceFilter;
-import com.google.inject.servlet.GuiceServletContextListener;
-import com.google.inject.spi.Message;
-import com.google.inject.util.Providers;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import javax.servlet.Filter;
-import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
-import javax.servlet.ServletContext;
-import javax.servlet.ServletContextEvent;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
-import javax.servlet.http.HttpServletRequest;
-import javax.sql.DataSource;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Configures the web application environment for Gerrit Code Review. */
-public class WebAppInitializer extends GuiceServletContextListener implements Filter {
-  private static final Logger log = LoggerFactory.getLogger(WebAppInitializer.class);
-
-  private Path sitePath;
-  private Injector dbInjector;
-  private Injector cfgInjector;
-  private Config config;
-  private Injector sysInjector;
-  private Injector webInjector;
-  private Injector sshInjector;
-  private LifecycleManager manager;
-  private GuiceFilter filter;
-
-  private ServletContext servletContext;
-  private IndexType indexType;
-
-  @Override
-  public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
-      throws IOException, ServletException {
-    filter.doFilter(req, res, chain);
-  }
-
-  private synchronized void init() {
-    if (manager == null) {
-      final String path = System.getProperty("gerrit.site_path");
-      if (path != null) {
-        sitePath = Paths.get(path);
-      }
-
-      if (System.getProperty("gerrit.init") != null) {
-        List<String> pluginsToInstall;
-        String installPlugins = System.getProperty("gerrit.install_plugins");
-        if (installPlugins == null) {
-          pluginsToInstall = null;
-        } else {
-          pluginsToInstall =
-              Splitter.on(",").trimResults().omitEmptyStrings().splitToList(installPlugins);
-        }
-        new SiteInitializer(
-                path,
-                System.getProperty("gerrit.init_path"),
-                new UnzippedDistribution(servletContext),
-                pluginsToInstall)
-            .init();
-      }
-
-      try {
-        dbInjector = createDbInjector();
-      } catch (CreationException ce) {
-        final Message first = ce.getErrorMessages().iterator().next();
-        final StringBuilder buf = new StringBuilder();
-        buf.append(first.getMessage());
-        Throwable why = first.getCause();
-        while (why != null) {
-          buf.append("\n  caused by ");
-          buf.append(why.toString());
-          why = why.getCause();
-        }
-        if (first.getCause() != null) {
-          buf.append("\n");
-          buf.append("\nResolve above errors before continuing.");
-          buf.append("\nComplete stack trace follows:");
-        }
-        log.error(buf.toString(), first.getCause());
-        throw new CreationException(Collections.singleton(first));
-      }
-
-      cfgInjector = createCfgInjector();
-      initIndexType();
-      config = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-      sysInjector = createSysInjector();
-      if (!sshdOff()) {
-        sshInjector = createSshInjector();
-      }
-      webInjector = createWebInjector();
-
-      PluginGuiceEnvironment env = sysInjector.getInstance(PluginGuiceEnvironment.class);
-      env.setDbCfgInjector(dbInjector, cfgInjector);
-      if (sshInjector != null) {
-        env.setSshInjector(sshInjector);
-      }
-      env.setHttpInjector(webInjector);
-
-      // Push the Provider<HttpServletRequest> down into the canonical
-      // URL provider. Its optional for that provider, but since we can
-      // supply one we should do so, in case the administrator has not
-      // setup the canonical URL in the configuration file.
-      //
-      // Note we have to do this manually as Guice failed to do the
-      // injection here because the HTTP environment is not visible
-      // to the core server modules.
-      //
-      sysInjector
-          .getInstance(HttpCanonicalWebUrlProvider.class)
-          .setHttpServletRequest(webInjector.getProvider(HttpServletRequest.class));
-
-      filter = webInjector.getInstance(GuiceFilter.class);
-      manager = new LifecycleManager();
-      manager.add(dbInjector);
-      manager.add(cfgInjector);
-      manager.add(sysInjector);
-      if (sshInjector != null) {
-        manager.add(sshInjector);
-      }
-      manager.add(webInjector);
-    }
-  }
-
-  private boolean sshdOff() {
-    return new SshAddressesModule().getListenAddresses(config).isEmpty();
-  }
-
-  private Injector createDbInjector() {
-    final List<Module> modules = new ArrayList<>();
-    AbstractModule secureStore = createSecureStoreModule();
-    modules.add(secureStore);
-    if (sitePath != null) {
-      Module sitePathModule =
-          new AbstractModule() {
-            @Override
-            protected void configure() {
-              bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
-            }
-          };
-      modules.add(sitePathModule);
-
-      Module configModule = new GerritServerConfigModule();
-      modules.add(configModule);
-
-      Injector cfgInjector = Guice.createInjector(sitePathModule, configModule, secureStore);
-      Config cfg = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-      String dbType = cfg.getString("database", null, "type");
-
-      final DataSourceType dst =
-          Guice.createInjector(new DataSourceModule(), configModule, sitePathModule, secureStore)
-              .getInstance(Key.get(DataSourceType.class, Names.named(dbType.toLowerCase())));
-      modules.add(
-          new LifecycleModule() {
-            @Override
-            protected void configure() {
-              bind(DataSourceType.class).toInstance(dst);
-              bind(DataSourceProvider.Context.class)
-                  .toInstance(DataSourceProvider.Context.MULTI_USER);
-              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
-                  .toProvider(DataSourceProvider.class)
-                  .in(SINGLETON);
-              listener().to(DataSourceProvider.class);
-            }
-          });
-
-    } else {
-      modules.add(
-          new LifecycleModule() {
-            @Override
-            protected void configure() {
-              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
-                  .toProvider(ReviewDbDataSourceProvider.class)
-                  .in(SINGLETON);
-              listener().to(ReviewDbDataSourceProvider.class);
-            }
-          });
-
-      // If we didn't get the site path from the system property
-      // we need to get it from the database, as that's our old
-      // method of locating the site path on disk.
-      //
-      modules.add(
-          new AbstractModule() {
-            @Override
-            protected void configure() {
-              bind(Path.class)
-                  .annotatedWith(SitePath.class)
-                  .toProvider(SitePathFromSystemConfigProvider.class)
-                  .in(SINGLETON);
-            }
-          });
-      modules.add(new GerritServerConfigModule());
-    }
-    modules.add(new DatabaseModule());
-    modules.add(new NotesMigration.Module());
-    modules.add(new DropWizardMetricMaker.ApiModule());
-    return Guice.createInjector(PRODUCTION, modules);
-  }
-
-  private Injector createCfgInjector() {
-    final List<Module> modules = new ArrayList<>();
-    modules.add(new SchemaModule());
-    modules.add(SchemaVersionCheck.module());
-    modules.add(new AuthConfigModule());
-    return dbInjector.createChildInjector(modules);
-  }
-
-  private Injector createSysInjector() {
-    final List<Module> modules = new ArrayList<>();
-    modules.add(new DropWizardMetricMaker.RestModule());
-    modules.add(new LogFileCompressor.Module());
-    modules.add(new EventBroker.Module());
-    modules.add(new JdbcAccountPatchReviewStore.Module(config));
-    modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
-    modules.add(new StreamEventsApiListener.Module());
-    modules.add(new ReceiveCommitsExecutorModule());
-    modules.add(new DiffExecutorModule());
-    modules.add(new MimeUtil2Module());
-    modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
-    modules.add(new SearchingChangeCacheImpl.Module());
-    modules.add(new InternalAccountDirectory.Module());
-    modules.add(new DefaultPermissionBackendModule());
-    modules.add(new DefaultCacheFactory.Module());
-    modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
-    modules.add(new SmtpEmailSender.Module());
-    modules.add(new SignedTokenEmailTokenVerifier.Module());
-
-    // Plugin module needs to be inserted *before* the index module.
-    // There is the concept of LifecycleModule, in Gerrit's own extension
-    // to Guice, which has these:
-    //  listener().to(SomeClassImplementingLifecycleListener.class);
-    // and the start() methods of each such listener are executed in the
-    // order they are declared.
-    // Makes sure that PluginLoader.start() is executed before the
-    // LuceneIndexModule.start() so that plugins get loaded and the respective
-    // Guice modules installed so that the on-line reindexing will happen
-    // with the proper classes (e.g. group backends, custom Prolog
-    // predicates) and the associated rules ready to be evaluated.
-    modules.add(new PluginModule());
-    modules.add(new PluginRestApiModule());
-
-    modules.add(new RestCacheAdminModule());
-    modules.add(new GpgModule(config));
-    modules.add(new StartupChecks.Module());
-
-    // Index module shutdown must happen before work queue shutdown, otherwise
-    // work queue can get stuck waiting on index futures that will never return.
-    modules.add(createIndexModule());
-
-    modules.add(new WorkQueue.Module());
-    modules.add(
-        new CanonicalWebUrlModule() {
-          @Override
-          protected Class<? extends Provider<String>> provider() {
-            return HttpCanonicalWebUrlProvider.class;
-          }
-        });
-    modules.add(SshKeyCacheImpl.module());
-    modules.add(
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            bind(GerritOptions.class).toInstance(new GerritOptions(config, false, false, false));
-          }
-        });
-    modules.add(new GarbageCollectionModule());
-    modules.add(new ChangeCleanupRunner.Module());
-    modules.add(new AccountDeactivator.Module());
-    modules.addAll(LibModuleLoader.loadModules(cfgInjector));
-    return cfgInjector.createChildInjector(modules);
-  }
-
-  private Module createIndexModule() {
-    switch (indexType) {
-      case LUCENE:
-        return LuceneIndexModule.latestVersionWithOnlineUpgrade();
-      case ELASTICSEARCH:
-        return ElasticIndexModule.latestVersionWithOnlineUpgrade();
-      default:
-        throw new IllegalStateException("unsupported index.type = " + indexType);
-    }
-  }
-
-  private void initIndexType() {
-    indexType = IndexModule.getIndexType(cfgInjector);
-  }
-
-  private Injector createSshInjector() {
-    final List<Module> modules = new ArrayList<>();
-    modules.add(sysInjector.getInstance(SshModule.class));
-    modules.add(new SshHostKeyModule());
-    modules.add(
-        new DefaultCommandModule(
-            false,
-            sysInjector.getInstance(DownloadConfig.class),
-            sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
-    if (indexType == IndexType.LUCENE) {
-      modules.add(new IndexCommandsModule());
-    }
-    return sysInjector.createChildInjector(modules);
-  }
-
-  private Injector createWebInjector() {
-    final List<Module> modules = new ArrayList<>();
-    modules.add(RequestContextFilter.module());
-    modules.add(AllRequestFilter.module());
-    modules.add(RequestMetricsFilter.module());
-    modules.add(sysInjector.getInstance(GitOverHttpModule.class));
-    modules.add(sysInjector.getInstance(WebModule.class));
-    modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
-    if (sshInjector != null) {
-      modules.add(sshInjector.getInstance(WebSshGlueModule.class));
-    } else {
-      modules.add(new NoSshModule());
-    }
-    modules.add(H2CacheBasedWebSession.module());
-    modules.add(new HttpPluginModule());
-
-    AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
-    if (authConfig.getAuthType() == AuthType.OPENID) {
-      modules.add(new OpenIdModule());
-    } else if (authConfig.getAuthType() == AuthType.OAUTH) {
-      modules.add(new OAuthModule());
-    }
-    modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
-
-    // StaticModule contains a "/*" wildcard, place it last.
-    modules.add(sysInjector.getInstance(StaticModule.class));
-
-    return sysInjector.createChildInjector(modules);
-  }
-
-  @Override
-  protected Injector getInjector() {
-    init();
-    return webInjector;
-  }
-
-  @Override
-  public void init(FilterConfig cfg) throws ServletException {
-    servletContext = cfg.getServletContext();
-    contextInitialized(new ServletContextEvent(servletContext));
-    init();
-    manager.start();
-  }
-
-  @Override
-  public void destroy() {
-    if (manager != null) {
-      manager.stop();
-      manager = null;
-    }
-  }
-
-  private AbstractModule createSecureStoreModule() {
-    return new AbstractModule() {
-      @Override
-      public void configure() {
-        String secureStoreClassName = GerritServerConfigModule.getSecureStoreClassName(sitePath);
-        bind(String.class)
-            .annotatedWith(SecureStoreClassName.class)
-            .toProvider(Providers.of(secureStoreClassName));
-      }
-    };
-  }
-}
diff --git a/gerrit-war/src/main/resources/log4j.properties b/gerrit-war/src/main/resources/log4j.properties
deleted file mode 100644
index 8bc9bb2..0000000
--- a/gerrit-war/src/main/resources/log4j.properties
+++ /dev/null
@@ -1,54 +0,0 @@
-# Copyright (C) 2008 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-#
-log4j.rootCategory=INFO, stderr
-log4j.appender.stderr=org.apache.log4j.ConsoleAppender
-log4j.appender.stderr.target=System.err
-log4j.appender.stderr.layout=org.apache.log4j.PatternLayout
-log4j.appender.stderr.layout.ConversionPattern=[%d] [%t] %-5p %c %x: %m%n
-
-# Silence non-critical messages from MINA SSHD.
-#
-log4j.logger.org.apache.mina=WARN
-log4j.logger.org.apache.sshd.common=WARN
-log4j.logger.org.apache.sshd.server=WARN
-log4j.logger.org.apache.sshd.common.keyprovider.FileKeyPairProvider=INFO
-log4j.logger.com.google.gerrit.sshd.GerritServerSession=WARN
-
-# Silence non-critical messages from mime-util.
-#
-log4j.logger.eu.medsea.mimeutil=WARN
-
-# Silence non-critical messages from openid4java
-#
-log4j.logger.org.apache.http=WARN
-log4j.logger.org.apache.xml=WARN
-log4j.logger.org.openid4java=WARN
-log4j.logger.org.openid4java.consumer.ConsumerManager=FATAL
-log4j.logger.org.openid4java.discovery.Discovery=ERROR
-log4j.logger.org.openid4java.server.RealmVerifier=ERROR
-log4j.logger.org.openid4java.message.AuthSuccess=ERROR
-
-# Silence non-critical messages from c3p0 (if used).
-#
-log4j.logger.com.mchange.v2.c3p0=WARN
-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/java/BUILD b/java/BUILD
new file mode 100644
index 0000000..4fc4d79
--- /dev/null
+++ b/java/BUILD
@@ -0,0 +1,12 @@
+java_binary(
+    name = "gerrit-main-class",
+    main_class = "Main",
+    visibility = ["//visibility:public"],
+    runtime_deps = [":gerrit-main-class-lib"],
+)
+
+java_library(
+    name = "gerrit-main-class-lib",
+    srcs = ["Main.java"],
+    deps = ["//java/com/google/gerrit/launcher"],
+)
diff --git a/gerrit-main/src/main/java/Main.java b/java/Main.java
similarity index 100%
rename from gerrit-main/src/main/java/Main.java
rename to java/Main.java
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
new file mode 100644
index 0000000..88e322a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -0,0 +1,1594 @@
+// 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;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.initSsh;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+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.testing.Util.category;
+import static com.google.gerrit.server.project.testing.Util.value;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+
+import com.github.rholder.retry.BlockStrategy;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.jimfs.Jimfs;
+import com.google.common.primitives.Chars;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.ContributorAgreement;
+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;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.groups.GroupApi;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.api.projects.BranchApi;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.ChangeFinder;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.change.BatchAbandon;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.change.Revisions;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.MutableNotesMigration;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.FakeEmailSender;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.testing.NoteDbMode;
+import com.google.gerrit.testing.SshMode;
+import com.google.gerrit.testing.TempFileUtil;
+import com.google.gson.Gson;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.FetchResult;
+import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.Transport;
+import org.eclipse.jgit.transport.TransportBundleStream;
+import org.eclipse.jgit.transport.URIish;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.rules.ExpectedException;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runner.RunWith;
+import org.junit.runners.model.Statement;
+
+@RunWith(ConfigSuite.class)
+public abstract class AbstractDaemonTest {
+  private static GerritServer commonServer;
+  private static Description firstTest;
+
+  @ConfigSuite.Parameter public Config baseConfig;
+  @ConfigSuite.Name private String configName;
+
+  @Rule public ExpectedException exception = ExpectedException.none();
+
+  @Rule
+  public TestRule testRunner =
+      new TestRule() {
+        @Override
+        public Statement apply(Statement base, Description description) {
+          return new Statement() {
+            @Override
+            public void evaluate() throws Throwable {
+              if (firstTest == null) {
+                firstTest = description;
+              }
+              beforeTest(description);
+              try (ProjectResetter resetter = resetProjects(projectResetter.builder())) {
+                base.evaluate();
+              } finally {
+                afterTest();
+              }
+            }
+          };
+        }
+      };
+
+  @Inject @CanonicalWebUrl protected Provider<String> canonicalWebUrl;
+  @Inject @GerritPersonIdent protected Provider<PersonIdent> serverIdent;
+  @Inject @GerritServerConfig protected Config cfg;
+  @Inject protected AcceptanceTestRequestScope atrScope;
+  @Inject protected AccountCache accountCache;
+  @Inject protected AccountCreator accountCreator;
+  @Inject protected Accounts accounts;
+  @Inject protected AllProjectsName allProjects;
+  @Inject protected AllUsersName allUsers;
+  @Inject protected BatchUpdate.Factory batchUpdateFactory;
+  @Inject protected ChangeData.Factory changeDataFactory;
+  @Inject protected ChangeFinder changeFinder;
+  @Inject protected ChangeIndexer indexer;
+  @Inject protected ChangeNoteUtil changeNoteUtil;
+  @Inject protected ChangeResource.Factory changeResourceFactory;
+  @Inject protected FakeEmailSender sender;
+  @Inject protected GerritApi gApi;
+  @Inject protected GitRepositoryManager repoManager;
+  @Inject protected GroupBackend groupBackend;
+  @Inject protected GroupCache groupCache;
+  @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
+  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject protected PatchSetUtil psUtil;
+  @Inject protected ProjectCache projectCache;
+  @Inject protected ProjectResetter.Builder.Factory projectResetter;
+  @Inject protected Provider<InternalChangeQuery> queryProvider;
+  @Inject protected PushOneCommit.Factory pushFactory;
+  @Inject protected PluginConfigFactory pluginConfig;
+  @Inject protected Revisions revisions;
+  @Inject protected SystemGroupBackend systemGroupBackend;
+  @Inject protected MutableNotesMigration notesMigration;
+  @Inject protected ChangeNotes.Factory notesFactory;
+  @Inject protected BatchAbandon batchAbandon;
+
+  protected EventRecorder eventRecorder;
+  protected GerritServer server;
+  protected Project.NameKey project;
+  protected RestSession adminRestSession;
+  protected RestSession userRestSession;
+  protected ReviewDb db;
+  protected SshSession adminSshSession;
+  protected SshSession userSshSession;
+  protected TestAccount admin;
+  protected TestAccount user;
+  protected TestRepository<InMemoryRepository> testRepo;
+  protected String resourcePrefix;
+  protected Description description;
+  protected boolean testRequiresSsh;
+  protected BlockStrategy noSleepBlockStrategy = t -> {}; // Don't sleep in tests.
+
+  @Inject private ChangeIndexCollection changeIndexes;
+  @Inject private EventRecorder.Factory eventRecorderFactory;
+  @Inject private InProcessProtocol inProcessProtocol;
+  @Inject private Provider<AnonymousUser> anonymousUser;
+  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
+  @Inject private Groups groups;
+
+  private List<Repository> toClose;
+
+  @Before
+  public void clearSender() {
+    sender.clear();
+  }
+
+  @Before
+  public void startEventRecorder() {
+    eventRecorder = eventRecorderFactory.create(admin);
+  }
+
+  @Before
+  public void assumeSshIfRequired() {
+    if (testRequiresSsh) {
+      // If the test uses ssh, we use assume() to make sure ssh is enabled on
+      // the test suite. JUnit will skip tests annotated with @UseSsh if we
+      // disable them using the command line flag.
+      assume().that(SshMode.useSsh()).isTrue();
+    }
+  }
+
+  @After
+  public void closeEventRecorder() {
+    eventRecorder.close();
+  }
+
+  @AfterClass
+  public static void stopCommonServer() throws Exception {
+    if (commonServer != null) {
+      try {
+        commonServer.close();
+      } catch (Throwable t) {
+        throw new AssertionError(
+            "Error stopping common server in "
+                + (firstTest != null ? firstTest.getTestClass().getName() : "unknown test class"),
+            t);
+      } finally {
+        commonServer = null;
+      }
+    }
+    TempFileUtil.cleanup();
+  }
+
+  /** Controls which project and branches should be reset after each test case. */
+  protected ProjectResetter resetProjects(ProjectResetter.Builder resetter) throws IOException {
+    return resetter
+        // Don't reset all refs so that refs/sequences/changes is not touched and change IDs are
+        // not reused.
+        .reset(allProjects, RefNames.REFS_CONFIG)
+        // Don't reset group branches since this would make the groups inconsistent between
+        // ReviewDb and NoteDb.
+        // Don't reset refs/sequences/accounts so that account IDs are not reused.
+        .reset(
+            allUsers,
+            RefNames.REFS_CONFIG,
+            RefNames.REFS_USERS + "*",
+            RefNames.REFS_EXTERNAL_IDS,
+            RefNames.REFS_STARRED_CHANGES + "*",
+            RefNames.REFS_DRAFT_COMMENTS + "*")
+        .build();
+  }
+
+  protected static Config submitWholeTopicEnabledConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("change", null, "submitWholeTopic", true);
+    return cfg;
+  }
+
+  protected boolean isSubmitWholeTopicEnabled() {
+    return cfg.getBoolean("change", null, "submitWholeTopic", false);
+  }
+
+  protected boolean isContributorAgreementsEnabled() {
+    return cfg.getBoolean("auth", null, "contributorAgreements", false);
+  }
+
+  protected void beforeTest(Description description) throws Exception {
+    this.description = description;
+    GerritServer.Description classDesc =
+        GerritServer.Description.forTestClass(description, configName);
+    GerritServer.Description methodDesc =
+        GerritServer.Description.forTestMethod(description, configName);
+
+    testRequiresSsh = classDesc.useSshAnnotation() || methodDesc.useSshAnnotation();
+    if (!testRequiresSsh) {
+      baseConfig.setString("sshd", null, "listenAddress", "off");
+    }
+
+    baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
+    if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
+      if (commonServer == null) {
+        commonServer = GerritServer.initAndStart(classDesc, baseConfig);
+      }
+      server = commonServer;
+    } else {
+      server = GerritServer.initAndStart(methodDesc, baseConfig);
+    }
+
+    server.getTestInjector().injectMembers(this);
+    Transport.register(inProcessProtocol);
+    toClose = Collections.synchronizedList(new ArrayList<Repository>());
+
+    db = reviewDbProvider.open();
+
+    // All groups which were added during the server start (e.g. in SchemaCreator) aren't contained
+    // in the instance of the group index which is available here and in tests. There are two
+    // reasons:
+    // 1) No group index is available in SchemaCreator when using an in-memory database. (This could
+    // be fixed by using the IndexManagerOnInit in InMemoryDatabase similar as BaseInit uses it.)
+    // 2) During the on-init part of the server start, we use another instance of the index than
+    // later on. As test indexes are non-permanent, closing an instance and opening another one
+    // removes all indexed data.
+    // As a workaround, we simply reindex all available groups here.
+    Iterable<GroupReference> allGroups = groups.getAllGroupReferences(db)::iterator;
+    for (GroupReference group : allGroups) {
+      groupCache.onCreateGroup(group.getUUID());
+    }
+
+    admin = accountCreator.admin();
+    user = accountCreator.user();
+
+    // Evict cached user state in case tests modify it.
+    accountCache.evict(admin.getId());
+    accountCache.evict(user.getId());
+
+    adminRestSession = new RestSession(server, admin);
+    userRestSession = new RestSession(server, user);
+
+    if (testRequiresSsh
+        && SshMode.useSsh()
+        && (adminSshSession == null || userSshSession == null)) {
+      // Create Ssh sessions
+      initSsh(admin);
+      Context ctx = newRequestContext(user);
+      atrScope.set(ctx);
+      userSshSession = ctx.getSession();
+      userSshSession.open();
+      ctx = newRequestContext(admin);
+      atrScope.set(ctx);
+      adminSshSession = ctx.getSession();
+      adminSshSession.open();
+    }
+
+    resourcePrefix =
+        UNSAFE_PROJECT_NAME
+            .matcher(description.getClassName() + "_" + description.getMethodName() + "_")
+            .replaceAll("");
+
+    Context ctx = newRequestContext(admin);
+    atrScope.set(ctx);
+    project = createProject(projectInput(description));
+    testRepo = cloneProject(project, getCloneAsAccount(description));
+  }
+
+  private TestAccount getCloneAsAccount(Description description) {
+    TestProjectInput ann = description.getAnnotation(TestProjectInput.class);
+    return accountCreator.get(ann != null ? ann.cloneAs() : "admin");
+  }
+
+  private ProjectInput projectInput(Description description) {
+    ProjectInput in = new ProjectInput();
+    TestProjectInput ann = description.getAnnotation(TestProjectInput.class);
+    in.name = name("project");
+    if (ann != null) {
+      in.parent = Strings.emptyToNull(ann.parent());
+      in.description = Strings.emptyToNull(ann.description());
+      in.createEmptyCommit = ann.createEmptyCommit();
+      in.submitType = ann.submitType();
+      in.useContentMerge = ann.useContributorAgreements();
+      in.useSignedOffBy = ann.useSignedOffBy();
+      in.useContentMerge = ann.useContentMerge();
+      in.rejectEmptyCommit = ann.rejectEmptyCommit();
+    } else {
+      // Defaults should match TestProjectConfig, omitting nullable values.
+      in.createEmptyCommit = true;
+    }
+    updateProjectInput(in);
+    return in;
+  }
+
+  private static final Pattern UNSAFE_PROJECT_NAME = Pattern.compile("[^a-zA-Z0-9._/-]+");
+
+  protected Git git() {
+    return testRepo.git();
+  }
+
+  protected InMemoryRepository repo() {
+    return testRepo.getRepository();
+  }
+
+  /**
+   * Return a resource name scoped to this test method.
+   *
+   * <p>Test methods in a single class by default share a running server. For any resource name you
+   * require to be unique to a test method, wrap it in a call to this method.
+   *
+   * @param name resource name (group, project, topic, etc.)
+   * @return name prefixed by a string unique to this test method.
+   */
+  protected String name(String name) {
+    return resourcePrefix + name;
+  }
+
+  protected Project.NameKey createProject(String nameSuffix) throws RestApiException {
+    return createProject(nameSuffix, null);
+  }
+
+  protected Project.NameKey createProject(String nameSuffix, Project.NameKey parent)
+      throws RestApiException {
+    // Default for createEmptyCommit should match TestProjectConfig.
+    return createProject(nameSuffix, parent, true, null);
+  }
+
+  protected Project.NameKey createProject(
+      String nameSuffix, Project.NameKey parent, boolean createEmptyCommit)
+      throws RestApiException {
+    // Default for createEmptyCommit should match TestProjectConfig.
+    return createProject(nameSuffix, parent, createEmptyCommit, null);
+  }
+
+  protected Project.NameKey createProject(
+      String nameSuffix, Project.NameKey parent, SubmitType submitType) throws RestApiException {
+    // Default for createEmptyCommit should match TestProjectConfig.
+    return createProject(nameSuffix, parent, true, submitType);
+  }
+
+  protected Project.NameKey createProject(
+      String nameSuffix, Project.NameKey parent, boolean createEmptyCommit, SubmitType submitType)
+      throws RestApiException {
+    ProjectInput in = new ProjectInput();
+    in.name = name(nameSuffix);
+    in.parent = parent != null ? parent.get() : null;
+    in.submitType = submitType;
+    in.createEmptyCommit = createEmptyCommit;
+    return createProject(in);
+  }
+
+  private Project.NameKey createProject(ProjectInput in) throws RestApiException {
+    gApi.projects().create(in);
+    return new Project.NameKey(in.name);
+  }
+
+  /**
+   * Modify a project input before creating the initial test project.
+   *
+   * @param in input; may be modified in place.
+   */
+  protected void updateProjectInput(ProjectInput in) {
+    // Default implementation does nothing.
+  }
+
+  protected TestRepository<InMemoryRepository> cloneProject(Project.NameKey p) throws Exception {
+    return cloneProject(p, admin);
+  }
+
+  protected TestRepository<InMemoryRepository> cloneProject(Project.NameKey p, String ref)
+      throws Exception {
+    TestRepository<InMemoryRepository> repo = cloneProject(p);
+    GitUtil.fetch(repo, ref + ":" + ref);
+    repo.reset(ref);
+    return repo;
+  }
+
+  protected TestRepository<InMemoryRepository> cloneProject(
+      Project.NameKey p, TestAccount testAccount) throws Exception {
+    return GitUtil.cloneProject(p, registerRepoConnection(p, testAccount));
+  }
+
+  /**
+   * Register a repository connection over the test protocol.
+   *
+   * @return a URI string that can be used to connect to this repository for both fetch and push.
+   */
+  protected String registerRepoConnection(Project.NameKey p, TestAccount testAccount)
+      throws Exception {
+    InProcessProtocol.Context ctx =
+        new InProcessProtocol.Context(
+            reviewDbProvider, identifiedUserFactory, testAccount.getId(), p);
+    Repository repo = repoManager.openRepository(p);
+    toClose.add(repo);
+    return inProcessProtocol.register(ctx, repo).toString();
+  }
+
+  protected void afterTest() throws Exception {
+    Transport.unregister(inProcessProtocol);
+    for (Repository repo : toClose) {
+      repo.close();
+    }
+    db.close();
+    if (adminSshSession != null) {
+      adminSshSession.close();
+    }
+    if (userSshSession != null) {
+      userSshSession.close();
+    }
+    if (server != commonServer) {
+      server.close();
+      server = null;
+    }
+    NoteDbMode.resetFromEnv(notesMigration);
+  }
+
+  protected TestRepository<?>.CommitBuilder commitBuilder() throws Exception {
+    return testRepo.branch("HEAD").commit().insertChangeId();
+  }
+
+  protected TestRepository<?>.CommitBuilder amendBuilder() throws Exception {
+    ObjectId head = repo().exactRef("HEAD").getObjectId();
+    TestRepository<?>.CommitBuilder b = testRepo.amendRef("HEAD");
+    Optional<String> id = GitUtil.getChangeId(testRepo, head);
+    // TestRepository behaves like "git commit --amend -m foo", which does not
+    // preserve an existing Change-Id. Tests probably want this.
+    if (id.isPresent()) {
+      b.insertChangeId(id.get().substring(1));
+    } else {
+      b.insertChangeId();
+    }
+    return b;
+  }
+
+  protected PushOneCommit.Result createChange() throws Exception {
+    return createChange("refs/for/master");
+  }
+
+  protected PushOneCommit.Result createChange(String ref) throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result result = push.to(ref);
+    result.assertOkStatus();
+    return result;
+  }
+
+  protected PushOneCommit.Result createMergeCommitChange(String ref) throws Exception {
+    return createMergeCommitChange(ref, "foo");
+  }
+
+  protected PushOneCommit.Result createMergeCommitChange(String ref, String file) throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    PushOneCommit.Result p1 =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                testRepo,
+                "parent 1",
+                ImmutableMap.of(file, "foo-1", "bar", "bar-1"))
+            .to(ref);
+
+    // reset HEAD in order to create a sibling of the first change
+    testRepo.reset(initial);
+
+    PushOneCommit.Result p2 =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                testRepo,
+                "parent 2",
+                ImmutableMap.of(file, "foo-2", "bar", "bar-2"))
+            .to(ref);
+
+    PushOneCommit m =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "merge",
+            ImmutableMap.of(file, "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(p1.getCommit(), p2.getCommit()));
+    PushOneCommit.Result result = m.to(ref);
+    result.assertOkStatus();
+    return result;
+  }
+
+  protected PushOneCommit.Result createCommitAndPush(
+      TestRepository<InMemoryRepository> repo,
+      String ref,
+      String commitMsg,
+      String fileName,
+      String content)
+      throws Exception {
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), repo, commitMsg, fileName, content).to(ref);
+    result.assertOkStatus();
+    return result;
+  }
+
+  protected PushOneCommit.Result createChangeWithTopic() throws Exception {
+    return createChangeWithTopic(testRepo, "topic", "message", "a.txt", "content\n");
+  }
+
+  protected PushOneCommit.Result createChangeWithTopic(
+      TestRepository<InMemoryRepository> repo,
+      String topic,
+      String commitMsg,
+      String fileName,
+      String content)
+      throws Exception {
+    assertThat(topic).isNotEmpty();
+    return createCommitAndPush(
+        repo, "refs/for/master/" + name(topic), commitMsg, fileName, content);
+  }
+
+  protected PushOneCommit.Result createWorkInProgressChange() throws Exception {
+    return pushTo("refs/for/master%wip");
+  }
+
+  protected PushOneCommit.Result createChange(String subject, String fileName, String content)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
+    return push.to("refs/for/master");
+  }
+
+  protected PushOneCommit.Result createChange(
+      String subject, String fileName, String content, String topic) throws Exception {
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
+    return push.to("refs/for/master/" + name(topic));
+  }
+
+  protected PushOneCommit.Result createChange(
+      TestRepository<?> repo,
+      String branch,
+      String subject,
+      String fileName,
+      String content,
+      String topic)
+      throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo, subject, fileName, content);
+    return push.to("refs/for/" + branch + "/" + name(topic));
+  }
+
+  protected BranchApi createBranch(String branch) throws Exception {
+    return createBranch(new Branch.NameKey(project, branch));
+  }
+
+  protected BranchApi createBranch(Branch.NameKey branch) throws Exception {
+    return gApi.projects()
+        .name(branch.getParentKey().get())
+        .branch(branch.get())
+        .create(new BranchInput());
+  }
+
+  protected BranchApi createBranchWithRevision(Branch.NameKey branch, String revision)
+      throws Exception {
+    BranchInput in = new BranchInput();
+    in.revision = revision;
+    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get()).create(in);
+  }
+
+  private static final List<Character> RANDOM =
+      Chars.asList(new char[] {'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'});
+
+  protected PushOneCommit.Result amendChange(String changeId) throws Exception {
+    return amendChange(changeId, "refs/for/master");
+  }
+
+  protected PushOneCommit.Result amendChange(String changeId, String ref) throws Exception {
+    return amendChange(changeId, ref, admin, testRepo);
+  }
+
+  protected PushOneCommit.Result amendChange(
+      String changeId, String ref, TestAccount testAccount, TestRepository<?> repo)
+      throws Exception {
+    Collections.shuffle(RANDOM);
+    return amendChange(
+        changeId,
+        ref,
+        testAccount,
+        repo,
+        PushOneCommit.SUBJECT,
+        PushOneCommit.FILE_NAME,
+        new String(Chars.toArray(RANDOM)));
+  }
+
+  protected PushOneCommit.Result amendChange(
+      String changeId, String subject, String fileName, String content) throws Exception {
+    return amendChange(changeId, "refs/for/master", admin, testRepo, subject, fileName, content);
+  }
+
+  protected PushOneCommit.Result amendChange(
+      String changeId,
+      String ref,
+      TestAccount testAccount,
+      TestRepository<?> repo,
+      String subject,
+      String fileName,
+      String content)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(db, testAccount.getIdent(), repo, subject, fileName, content, changeId);
+    return push.to(ref);
+  }
+
+  protected void merge(PushOneCommit.Result r) throws Exception {
+    revision(r).review(ReviewInput.approve());
+    revision(r).submit();
+  }
+
+  protected ChangeInfo info(String id) throws RestApiException {
+    return gApi.changes().id(id).info();
+  }
+
+  protected Optional<EditInfo> getEdit(String id) throws RestApiException {
+    return gApi.changes().id(id).edit().get();
+  }
+
+  protected ChangeInfo get(String id, ListChangesOption... options) throws RestApiException {
+    return gApi.changes().id(id).get(options);
+  }
+
+  protected List<ChangeInfo> query(String q) throws RestApiException {
+    return gApi.changes().query(q).get();
+  }
+
+  private Context newRequestContext(TestAccount account) {
+    return atrScope.newContext(
+        reviewDbProvider,
+        new SshSession(server, account),
+        identifiedUserFactory.create(account.getId()));
+  }
+
+  /**
+   * Enforce a new request context for the current API user.
+   *
+   * <p>This recreates the IdentifiedUser, hence everything which is cached in the IdentifiedUser is
+   * reloaded (e.g. the email addresses of the user).
+   */
+  protected Context resetCurrentApiUser() {
+    return atrScope.set(newRequestContext(atrScope.get().getSession().getAccount()));
+  }
+
+  protected Context setApiUser(TestAccount account) {
+    return atrScope.set(newRequestContext(account));
+  }
+
+  protected Context setApiUserAnonymous() {
+    return atrScope.set(atrScope.newContext(reviewDbProvider, null, anonymousUser.get()));
+  }
+
+  protected Context disableDb() {
+    notesMigration.setFailOnLoadForTest(true);
+    return atrScope.disableDb();
+  }
+
+  protected void enableDb(Context preDisableContext) {
+    notesMigration.setFailOnLoadForTest(false);
+    atrScope.set(preDisableContext);
+  }
+
+  protected void disableChangeIndexWrites() {
+    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
+      if (!(i instanceof ReadOnlyChangeIndex)) {
+        changeIndexes.addWriteIndex(new ReadOnlyChangeIndex(i));
+      }
+    }
+  }
+
+  protected void enableChangeIndexWrites() {
+    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
+      if (i instanceof ReadOnlyChangeIndex) {
+        changeIndexes.addWriteIndex(((ReadOnlyChangeIndex) i).unwrap());
+      }
+    }
+  }
+
+  protected AutoCloseable disableChangeIndex() {
+    disableChangeIndexWrites();
+    ChangeIndex searchIndex = changeIndexes.getSearchIndex();
+    if (!(searchIndex instanceof DisabledChangeIndex)) {
+      changeIndexes.setSearchIndex(new DisabledChangeIndex(searchIndex), false);
+    }
+
+    return new AutoCloseable() {
+      @Override
+      public void close() throws Exception {
+        enableChangeIndexWrites();
+        ChangeIndex searchIndex = changeIndexes.getSearchIndex();
+        if (searchIndex instanceof DisabledChangeIndex) {
+          changeIndexes.setSearchIndex(((DisabledChangeIndex) searchIndex).unwrap(), false);
+        }
+      }
+    };
+  }
+
+  protected static Gson newGson() {
+    return OutputFormat.JSON_COMPACT.newGson();
+  }
+
+  protected RevisionApi revision(PushOneCommit.Result r) throws Exception {
+    return gApi.changes().id(r.getChangeId()).current();
+  }
+
+  protected void allow(String ref, String permission, AccountGroup.UUID id) throws Exception {
+    allow(project, ref, permission, id);
+  }
+
+  protected void allow(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
+      throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.allow(cfg, permission, id, ref);
+    saveProjectConfig(p, cfg);
+  }
+
+  protected void allowGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
+      throws Exception {
+    allowGlobalCapabilities(id, Arrays.asList(capabilityNames));
+  }
+
+  protected void allowGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
+      throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    for (String capabilityName : capabilityNames) {
+      Util.allow(cfg, capabilityName, id);
+    }
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  protected void removeGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
+      throws Exception {
+    removeGlobalCapabilities(id, Arrays.asList(capabilityNames));
+  }
+
+  protected void removeGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
+      throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    for (String capabilityName : capabilityNames) {
+      Util.remove(cfg, capabilityName, id);
+    }
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig config = ProjectConfig.read(md);
+      config.getProject().setBooleanConfig(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, value);
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
+  }
+
+  protected void setUseSignedOffBy(InheritableBoolean value) throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig config = ProjectConfig.read(md);
+      config.getProject().setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, value);
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
+  }
+
+  protected void deny(String ref, String permission, AccountGroup.UUID id) throws Exception {
+    deny(project, ref, permission, id);
+  }
+
+  protected void deny(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
+      throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.deny(cfg, permission, id, ref);
+    saveProjectConfig(p, cfg);
+  }
+
+  protected PermissionRule block(String ref, String permission, AccountGroup.UUID id)
+      throws Exception {
+    return block(project, ref, permission, id);
+  }
+
+  protected PermissionRule block(
+      Project.NameKey project, String ref, String permission, AccountGroup.UUID id)
+      throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    PermissionRule rule = Util.block(cfg, permission, id, ref);
+    saveProjectConfig(project, cfg);
+    return rule;
+  }
+
+  protected void blockLabel(
+      String label, int min, int max, AccountGroup.UUID id, String ref, Project.NameKey project)
+      throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.block(cfg, Permission.LABEL + label, min, max, id, ref);
+    saveProjectConfig(project, cfg);
+  }
+
+  protected void saveProjectConfig(Project.NameKey p, ProjectConfig cfg) throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(p)) {
+      md.setAuthor(identifiedUserFactory.create(admin.getId()));
+      cfg.commit(md);
+    }
+    projectCache.evict(cfg.getProject());
+  }
+
+  protected void saveProjectConfig(ProjectConfig cfg) throws Exception {
+    saveProjectConfig(project, cfg);
+  }
+
+  protected void grant(Project.NameKey project, String ref, String permission)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    grant(project, ref, permission, false);
+  }
+
+  protected void grant(Project.NameKey project, String ref, String permission, boolean force)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    grant(project, ref, permission, force, adminGroupUuid());
+  }
+
+  protected void grant(
+      Project.NameKey project,
+      String ref,
+      String permission,
+      boolean force,
+      AccountGroup.UUID groupUUID)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      md.setMessage(String.format("Grant %s on %s", permission, ref));
+      ProjectConfig config = ProjectConfig.read(md);
+      AccessSection s = config.getAccessSection(ref, true);
+      Permission p = s.getPermission(permission, true);
+      PermissionRule rule = Util.newRule(config, groupUUID);
+      rule.setForce(force);
+      p.add(rule);
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
+  }
+
+  protected void grantLabel(
+      String label,
+      int min,
+      int max,
+      Project.NameKey project,
+      String ref,
+      boolean force,
+      AccountGroup.UUID groupUUID,
+      boolean exclusive)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    String permission = Permission.LABEL + label;
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      md.setMessage(String.format("Grant %s on %s", permission, ref));
+      ProjectConfig config = ProjectConfig.read(md);
+      AccessSection s = config.getAccessSection(ref, true);
+      Permission p = s.getPermission(permission, true);
+      p.setExclusiveGroup(exclusive);
+      PermissionRule rule = Util.newRule(config, groupUUID);
+      rule.setForce(force);
+      rule.setMin(min);
+      rule.setMax(max);
+      p.add(rule);
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
+  }
+
+  protected void removePermission(Project.NameKey project, String ref, String permission)
+      throws IOException, ConfigInvalidException {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      md.setMessage(String.format("Remove %s on %s", permission, ref));
+      ProjectConfig config = ProjectConfig.read(md);
+      AccessSection s = config.getAccessSection(ref, true);
+      Permission p = s.getPermission(permission, true);
+      p.getRules().clear();
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
+  }
+
+  protected void blockRead(String ref) throws Exception {
+    block(ref, Permission.READ, REGISTERED_USERS);
+  }
+
+  protected void blockForgeCommitter(Project.NameKey project, String ref) throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.block(cfg, Permission.FORGE_COMMITTER, REGISTERED_USERS, ref);
+    saveProjectConfig(project, cfg);
+  }
+
+  protected PushOneCommit.Result pushTo(String ref) throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    return push.to(ref);
+  }
+
+  protected void approve(String id) throws Exception {
+    gApi.changes().id(id).revision("current").review(ReviewInput.approve());
+  }
+
+  protected void recommend(String id) throws Exception {
+    gApi.changes().id(id).revision("current").review(ReviewInput.recommend());
+  }
+
+  protected Map<String, ActionInfo> getActions(String id) throws Exception {
+    return gApi.changes().id(id).revision(1).actions();
+  }
+
+  protected String getETag(String id) throws Exception {
+    return gApi.changes().id(id).current().etag();
+  }
+
+  private static Iterable<String> changeIds(Iterable<ChangeInfo> changes) {
+    return Iterables.transform(changes, i -> i.changeId);
+  }
+
+  protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
+    List<ChangeInfo> actual = gApi.changes().id(chId).submittedTogether();
+    SubmittedTogetherInfo info =
+        gApi.changes().id(chId).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+
+    assertThat(info.nonVisibleChanges).isEqualTo(0);
+    assertThat(changeIds(actual)).containsExactly((Object[]) expected).inOrder();
+    assertThat(changeIds(info.changes)).containsExactly((Object[]) expected).inOrder();
+  }
+
+  protected PatchSet getPatchSet(PatchSet.Id psId) throws OrmException {
+    return changeDataFactory.create(db, project, psId.getParentKey()).patchSet(psId);
+  }
+
+  protected IdentifiedUser user(TestAccount testAccount) {
+    return identifiedUserFactory.create(testAccount.getId());
+  }
+
+  protected RevisionResource parseCurrentRevisionResource(String changeId) throws Exception {
+    ChangeResource cr = parseChangeResource(changeId);
+    int psId = cr.getChange().currentPatchSetId().get();
+    return revisions.parse(cr, IdString.fromDecoded(Integer.toString(psId)));
+  }
+
+  protected RevisionResource parseRevisionResource(String changeId, int n) throws Exception {
+    return revisions.parse(
+        parseChangeResource(changeId), IdString.fromDecoded(Integer.toString(n)));
+  }
+
+  protected RevisionResource parseRevisionResource(PushOneCommit.Result r) throws Exception {
+    PatchSet.Id psId = r.getPatchSetId();
+    return parseRevisionResource(psId.getParentKey().toString(), psId.get());
+  }
+
+  protected ChangeResource parseChangeResource(String changeId) throws Exception {
+    List<ChangeNotes> notes = changeFinder.find(changeId);
+    assertThat(notes).hasSize(1);
+    return changeResourceFactory.create(notes.get(0), atrScope.get().getUser());
+  }
+
+  protected String createGroup(String name) throws Exception {
+    return createGroup(name, "Administrators");
+  }
+
+  protected String createGroup(String name, String owner) throws Exception {
+    name = name(name);
+    GroupInput in = new GroupInput();
+    in.name = name;
+    in.ownerId = owner;
+    gApi.groups().create(in);
+    return name;
+  }
+
+  protected String createAccount(String name, String group) throws Exception {
+    name = name(name);
+    accountCreator.create(name, group);
+    return name;
+  }
+
+  protected TestAccount createUniqueAccount(String userName, String fullName) throws Exception {
+    String uniqueUserName = name(userName);
+    String uniqueFullName = name(fullName);
+    return accountCreator.create(uniqueUserName, uniqueUserName + "@example.com", uniqueFullName);
+  }
+
+  protected RevCommit getHead(Repository repo, String name) throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      Ref r = repo.exactRef(name);
+      return r != null ? rw.parseCommit(r.getObjectId()) : null;
+    }
+  }
+
+  protected RevCommit getHead(Repository repo) throws Exception {
+    return getHead(repo, "HEAD");
+  }
+
+  protected RevCommit getRemoteHead(Project.NameKey project, String branch) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return getHead(repo, branch.startsWith(Constants.R_REFS) ? branch : "refs/heads/" + branch);
+    }
+  }
+
+  protected RevCommit getRemoteHead(String project, String branch) throws Exception {
+    return getRemoteHead(new Project.NameKey(project), branch);
+  }
+
+  protected RevCommit getRemoteHead() throws Exception {
+    return getRemoteHead(project, "master");
+  }
+
+  protected void grantTagPermissions() throws Exception {
+    grant(project, R_TAGS + "*", Permission.CREATE);
+    grant(project, R_TAGS + "", Permission.DELETE);
+    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
+    grant(project, R_TAGS + "*", Permission.CREATE_SIGNED_TAG);
+  }
+
+  protected void assertMailReplyTo(Message message, String email) throws Exception {
+    assertThat(message.headers()).containsKey("Reply-To");
+    EmailHeader.String replyTo = (EmailHeader.String) message.headers().get("Reply-To");
+    assertThat(replyTo.getString()).contains(email);
+  }
+
+  protected ContributorAgreement configureContributorAgreement(boolean autoVerify)
+      throws Exception {
+    ContributorAgreement ca;
+    if (autoVerify) {
+      String g = createGroup("cla-test-group");
+      GroupApi groupApi = gApi.groups().id(g);
+      groupApi.description("CLA test group");
+      InternalGroup caGroup = group(new AccountGroup.UUID(groupApi.detail().id));
+      GroupReference groupRef = new GroupReference(caGroup.getGroupUUID(), caGroup.getName());
+      PermissionRule rule = new PermissionRule(groupRef);
+      rule.setAction(PermissionRule.Action.ALLOW);
+      ca = new ContributorAgreement("cla-test");
+      ca.setAutoVerify(groupRef);
+      ca.setAccepted(ImmutableList.of(rule));
+    } else {
+      ca = new ContributorAgreement("cla-test-no-auto-verify");
+    }
+    ca.setDescription("description");
+    ca.setAgreementUrl("agreement-url");
+
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    cfg.replace(ca);
+    saveProjectConfig(allProjects, cfg);
+    return ca;
+  }
+
+  protected BinaryResult submitPreview(String changeId) throws Exception {
+    return gApi.changes().id(changeId).current().submitPreview();
+  }
+
+  protected BinaryResult submitPreview(String changeId, String format) throws Exception {
+    return gApi.changes().id(changeId).current().submitPreview(format);
+  }
+
+  protected Map<Branch.NameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
+    try (BinaryResult result = submitPreview(changeId)) {
+      return fetchFromBundles(result);
+    }
+  }
+
+  /**
+   * Fetches each bundle into a newly cloned repository, then it applies the bundle, and returns the
+   * resulting tree id.
+   *
+   * <p>Omits NoteDb meta refs.
+   */
+  protected Map<Branch.NameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
+    assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
+
+    FileSystem fs = Jimfs.newFileSystem();
+    Path previewPath = fs.getPath("preview.zip");
+    try (OutputStream out = Files.newOutputStream(previewPath)) {
+      bundles.writeTo(out);
+    }
+    Map<Branch.NameKey, ObjectId> ret = new HashMap<>();
+    try (FileSystem zipFs = FileSystems.newFileSystem(previewPath, null);
+        DirectoryStream<Path> dirStream =
+            Files.newDirectoryStream(Iterables.getOnlyElement(zipFs.getRootDirectories()))) {
+      for (Path p : dirStream) {
+        if (!Files.isRegularFile(p)) {
+          continue;
+        }
+        String bundleName = p.getFileName().toString();
+        int len = bundleName.length();
+        assertThat(bundleName).endsWith(".git");
+        String repoName = bundleName.substring(0, len - 4);
+        Project.NameKey proj = new Project.NameKey(repoName);
+        TestRepository<?> localRepo = cloneProject(proj);
+
+        try (InputStream bundleStream = Files.newInputStream(p);
+            TransportBundleStream tbs =
+                new TransportBundleStream(
+                    localRepo.getRepository(), new URIish(bundleName), bundleStream)) {
+          FetchResult fr =
+              tbs.fetch(
+                  NullProgressMonitor.INSTANCE,
+                  Arrays.asList(new RefSpec("refs/*:refs/preview/*")));
+          for (Ref r : fr.getAdvertisedRefs()) {
+            String refName = r.getName();
+            if (RefNames.isNoteDbMetaRef(refName)) {
+              continue;
+            }
+            RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
+            ret.put(new Branch.NameKey(proj, refName), c.getTree().copy());
+          }
+        }
+      }
+    }
+    assertThat(ret).isNotEmpty();
+    return ret;
+  }
+
+  /** Assert that the given branches have the given tree ids. */
+  protected void assertTrees(Project.NameKey proj, Map<Branch.NameKey, ObjectId> trees)
+      throws Exception {
+    TestRepository<?> localRepo = cloneProject(proj);
+    GitUtil.fetch(localRepo, "refs/*:refs/*");
+    Map<String, Ref> refs = localRepo.getRepository().getAllRefs();
+    Map<Branch.NameKey, RevTree> refValues = new HashMap<>();
+
+    for (Branch.NameKey b : trees.keySet()) {
+      if (!b.getParentKey().equals(proj)) {
+        continue;
+      }
+
+      Ref r = refs.get(b.get());
+      assertThat(r).isNotNull();
+      RevWalk rw = localRepo.getRevWalk();
+      RevCommit c = rw.parseCommit(r.getObjectId());
+      refValues.put(b, c.getTree());
+
+      assertThat(trees.get(b)).isEqualTo(refValues.get(b));
+    }
+    assertThat(refValues.keySet()).containsAnyIn(trees.keySet());
+  }
+
+  protected void assertDiffForNewFile(
+      DiffInfo diff, RevCommit commit, String path, String expectedContentSideB) throws Exception {
+    List<String> expectedLines = new ArrayList<>();
+    for (String line : expectedContentSideB.split("\n")) {
+      expectedLines.add(line);
+    }
+
+    assertThat(diff.binary).isNull();
+    assertThat(diff.changeType).isEqualTo(ChangeType.ADDED);
+    assertThat(diff.diffHeader).isNotNull();
+    assertThat(diff.intralineStatus).isNull();
+    assertThat(diff.webLinks).isNull();
+
+    assertThat(diff.metaA).isNull();
+    assertThat(diff.metaB).isNotNull();
+    assertThat(diff.metaB.commitId).isEqualTo(commit.name());
+
+    String expectedContentType = "text/plain";
+    if (COMMIT_MSG.equals(path)) {
+      expectedContentType = FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE;
+    } else if (MERGE_LIST.equals(path)) {
+      expectedContentType = FileContentUtil.TEXT_X_GERRIT_MERGE_LIST;
+    }
+    assertThat(diff.metaB.contentType).isEqualTo(expectedContentType);
+
+    assertThat(diff.metaB.lines).isEqualTo(expectedLines.size());
+    assertThat(diff.metaB.name).isEqualTo(path);
+    assertThat(diff.metaB.webLinks).isNull();
+
+    assertThat(diff.content).hasSize(1);
+    DiffInfo.ContentEntry contentEntry = diff.content.get(0);
+    assertThat(contentEntry.b).containsExactlyElementsIn(expectedLines).inOrder();
+    assertThat(contentEntry.a).isNull();
+    assertThat(contentEntry.ab).isNull();
+    assertThat(contentEntry.common).isNull();
+    assertThat(contentEntry.editA).isNull();
+    assertThat(contentEntry.editB).isNull();
+    assertThat(contentEntry.skip).isNull();
+  }
+
+  protected TestRepository<?> createProjectWithPush(
+      String name, @Nullable Project.NameKey parent, SubmitType submitType) throws Exception {
+    Project.NameKey project = createProject(name, parent, true, submitType);
+    grant(project, "refs/heads/*", Permission.PUSH);
+    grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
+    return cloneProject(project);
+  }
+
+  protected void assertPermitted(ChangeInfo info, String label, Integer... expected) {
+    assertThat(info.permittedLabels).isNotNull();
+    Collection<String> strs = info.permittedLabels.get(label);
+    if (expected.length == 0) {
+      assertThat(strs).isNull();
+    } else {
+      assertThat(strs.stream().map(s -> Integer.valueOf(s.trim())).collect(toList()))
+          .containsExactlyElementsIn(Arrays.asList(expected));
+    }
+  }
+
+  protected void assertPermissions(
+      Project.NameKey project,
+      GroupReference groupReference,
+      String ref,
+      boolean exclusive,
+      String... permissionNames)
+      throws IOException {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccessSection accessSection = cfg.getAccessSection(ref);
+    assertThat(accessSection).isNotNull();
+    for (String permissionName : permissionNames) {
+      Permission permission = accessSection.getPermission(permissionName);
+      assertPermission(permission, permissionName, exclusive, null);
+      assertPermissionRule(
+          permission.getRule(groupReference), groupReference, Action.ALLOW, false, 0, 0);
+    }
+  }
+
+  protected void assertLabelPermission(
+      Project.NameKey project,
+      GroupReference groupReference,
+      String ref,
+      boolean exclusive,
+      String labelName,
+      int min,
+      int max)
+      throws IOException {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccessSection accessSection = cfg.getAccessSection(ref);
+    assertThat(accessSection).isNotNull();
+
+    String permissionName = Permission.LABEL + labelName;
+    Permission permission = accessSection.getPermission(permissionName);
+    assertPermission(permission, permissionName, exclusive, labelName);
+    assertPermissionRule(
+        permission.getRule(groupReference), groupReference, Action.ALLOW, false, min, max);
+  }
+
+  private void assertPermission(
+      Permission permission,
+      String expectedName,
+      boolean expectedExclusive,
+      @Nullable String expectedLabelName) {
+    assertThat(permission).isNotNull();
+    assertThat(permission.getName()).isEqualTo(expectedName);
+    assertThat(permission.getExclusiveGroup()).isEqualTo(expectedExclusive);
+    assertThat(permission.getLabel()).isEqualTo(expectedLabelName);
+  }
+
+  private void assertPermissionRule(
+      PermissionRule rule,
+      GroupReference expectedGroupReference,
+      Action expectedAction,
+      boolean expectedForce,
+      int expectedMin,
+      int expectedMax) {
+    assertThat(rule.getGroup()).isEqualTo(expectedGroupReference);
+    assertThat(rule.getAction()).isEqualTo(expectedAction);
+    assertThat(rule.getForce()).isEqualTo(expectedForce);
+    assertThat(rule.getMin()).isEqualTo(expectedMin);
+    assertThat(rule.getMax()).isEqualTo(expectedMax);
+  }
+
+  protected InternalGroup group(AccountGroup.UUID groupUuid) {
+    InternalGroup group = groupCache.get(groupUuid).orElse(null);
+    assertThat(group).named(groupUuid.get()).isNotNull();
+    return group;
+  }
+
+  protected GroupReference groupRef(AccountGroup.UUID groupUuid) {
+    GroupDescription.Basic groupDescription = groupBackend.get(groupUuid);
+    return new GroupReference(groupDescription.getGroupUUID(), groupDescription.getName());
+  }
+
+  protected InternalGroup group(String groupName) {
+    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
+    assertThat(group).named(groupName).isNotNull();
+    return group;
+  }
+
+  protected GroupReference groupRef(String groupName) {
+    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
+    assertThat(group).isNotNull();
+    return new GroupReference(group.getGroupUUID(), group.getName());
+  }
+
+  protected AccountGroup.UUID groupUuid(String groupName) {
+    return group(groupName).getGroupUUID();
+  }
+
+  protected InternalGroup adminGroup() {
+    return group("Administrators");
+  }
+
+  protected GroupReference adminGroupRef() {
+    return groupRef("Administrators");
+  }
+
+  protected AccountGroup.UUID adminGroupUuid() {
+    return groupUuid("Administrators");
+  }
+
+  protected void assertGroupDoesNotExist(String groupName) {
+    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
+    assertThat(group).named(groupName).isNull();
+  }
+
+  protected void assertNotifyTo(TestAccount expected) {
+    assertNotifyTo(expected.emailAddress);
+  }
+
+  protected void assertNotifyTo(Address expected) {
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(expected);
+    assertThat(((EmailHeader.AddressList) m.headers().get("To")).getAddressList())
+        .containsExactly(expected);
+    assertThat(m.headers().get("CC").isEmpty()).isTrue();
+  }
+
+  protected void assertNotifyCc(TestAccount expected) {
+    assertNotifyCc(expected.emailAddress);
+  }
+
+  protected void assertNotifyCc(Address expected) {
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(expected);
+    assertThat(m.headers().get("To").isEmpty()).isTrue();
+    assertThat(((EmailHeader.AddressList) m.headers().get("CC")).getAddressList())
+        .containsExactly(expected);
+  }
+
+  protected void assertNotifyBcc(TestAccount expected) {
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(expected.emailAddress);
+    assertThat(m.headers().get("To").isEmpty()).isTrue();
+    assertThat(m.headers().get("CC").isEmpty()).isTrue();
+  }
+
+  protected interface ProjectWatchInfoConfiguration {
+    void configure(ProjectWatchInfo pwi);
+  }
+
+  protected void watch(String project, ProjectWatchInfoConfiguration config)
+      throws RestApiException {
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = project;
+    config.configure(pwi);
+    gApi.accounts().self().setWatchedProjects(ImmutableList.of(pwi));
+  }
+
+  protected void watch(PushOneCommit.Result r, ProjectWatchInfoConfiguration config)
+      throws OrmException, RestApiException {
+    watch(r.getChange().project().get(), config);
+  }
+
+  protected void watch(String project, String filter) throws RestApiException {
+    watch(
+        project,
+        pwi -> {
+          pwi.filter = filter;
+          pwi.notifyAbandonedChanges = true;
+          pwi.notifyNewChanges = true;
+          pwi.notifyAllComments = true;
+        });
+  }
+
+  protected void watch(String project) throws RestApiException {
+    watch(project, (String) null);
+  }
+
+  protected void assertContent(PushOneCommit.Result pushResult, String path, String expectedContent)
+      throws Exception {
+    BinaryResult bin =
+        gApi.changes()
+            .id(pushResult.getChangeId())
+            .revision(pushResult.getCommit().name())
+            .file(path)
+            .content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String res = new String(os.toByteArray(), UTF_8);
+    assertThat(res).isEqualTo(expectedContent);
+  }
+
+  protected RevCommit createNewCommitWithoutChangeId(String branch, String file, String content)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk walk = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(branch);
+      RevCommit tip = null;
+      if (ref != null) {
+        tip = walk.parseCommit(ref.getObjectId());
+      }
+      TestRepository<?> testSrcRepo = new TestRepository<>(repo);
+      TestRepository<?>.BranchBuilder builder = testSrcRepo.branch(branch);
+      RevCommit revCommit =
+          tip == null
+              ? builder.commit().message("commit 1").add(file, content).create()
+              : builder.commit().parent(tip).message("commit 1").add(file, content).create();
+      assertThat(GitUtil.getChangeId(testSrcRepo, revCommit).isPresent()).isFalse();
+      return revCommit;
+    }
+  }
+
+  protected RevCommit parseCurrentRevision(RevWalk rw, PushOneCommit.Result r) throws Exception {
+    return parseCurrentRevision(rw, r.getChangeId());
+  }
+
+  protected RevCommit parseCurrentRevision(RevWalk rw, String changeId) throws Exception {
+    return rw.parseCommit(
+        ObjectId.fromString(get(changeId, ListChangesOption.CURRENT_REVISION).currentRevision));
+  }
+
+  protected void 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);
+  }
+
+  protected void fail(@Nullable String format, Object... args) {
+    assert_().fail(format, args);
+  }
+
+  protected void fail() {
+    assert_().fail();
+  }
+
+  protected void enableCreateNewChangeForAllNotInTarget() throws Exception {
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config
+        .getProject()
+        .setBooleanConfig(
+            BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET, InheritableBoolean.TRUE);
+    saveProjectConfig(project, config);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
new file mode 100644
index 0000000..ab887fe
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -0,0 +1,525 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.extensions.api.changes.RecipientType.BCC;
+import static com.google.gerrit.extensions.api.changes.RecipientType.CC;
+import static com.google.gerrit.extensions.api.changes.RecipientType.TO;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import com.google.gerrit.acceptance.ProjectResetter.Builder;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.server.mail.send.EmailHeader.AddressList;
+import com.google.gerrit.testing.FakeEmailSender;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.After;
+import org.junit.Before;
+
+public abstract class AbstractNotificationTest extends AbstractDaemonTest {
+  @Before
+  public void enableReviewerByEmail() throws Exception {
+    setApiUser(admin);
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+  }
+
+  @Override
+  protected ProjectResetter resetProjects(Builder resetter) throws IOException {
+    // Don't reset anything so that stagedUsers can be cached across all tests.
+    // Without this caching these tests become much too slow.
+    return resetter.build();
+  }
+
+  protected static FakeEmailSenderSubject assertThat(FakeEmailSender sender) {
+    return assertAbout(FakeEmailSenderSubject::new).that(sender);
+  }
+
+  protected void setEmailStrategy(TestAccount account, EmailStrategy strategy) throws Exception {
+    setEmailStrategy(account, strategy, true);
+  }
+
+  protected void setEmailStrategy(TestAccount account, EmailStrategy strategy, boolean record)
+      throws Exception {
+    if (record) {
+      accountsModifyingEmailStrategy.add(account);
+    }
+    setApiUser(account);
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = strategy;
+    gApi.accounts().self().setPreferences(prefs);
+  }
+
+  protected static class FakeEmailSenderSubject
+      extends Subject<FakeEmailSenderSubject, FakeEmailSender> {
+    private Message message;
+    private StagedUsers users;
+    private Map<RecipientType, List<String>> recipients = new HashMap<>();
+    private Set<String> accountedFor = new HashSet<>();
+
+    FakeEmailSenderSubject(FailureMetadata failureMetadata, FakeEmailSender target) {
+      super(failureMetadata, target);
+    }
+
+    public FakeEmailSenderSubject notSent() {
+      if (actual().peekMessage() != null) {
+        fail("a message wasn't sent");
+      }
+      return this;
+    }
+
+    public FakeEmailSenderSubject sent(String messageType, StagedUsers users) {
+      message = actual().nextMessage();
+      if (message == null) {
+        fail("a message was sent");
+      }
+      recipients = new HashMap<>();
+      recipients.put(TO, parseAddresses(message, "To"));
+      recipients.put(CC, parseAddresses(message, "CC"));
+      recipients.put(
+          BCC,
+          message
+              .rcpt()
+              .stream()
+              .map(Address::getEmail)
+              .filter(e -> !recipients.get(TO).contains(e) && !recipients.get(CC).contains(e))
+              .collect(toList()));
+      this.users = users;
+      if (!message.headers().containsKey("X-Gerrit-MessageType")) {
+        fail("a message was sent with X-Gerrit-MessageType header");
+      }
+      EmailHeader header = message.headers().get("X-Gerrit-MessageType");
+      if (!header.equals(new EmailHeader.String(messageType))) {
+        fail("message of type " + messageType + " was sent; X-Gerrit-MessageType is " + header);
+      }
+
+      // Return a named subject that displays a human-readable table of
+      // recipients.
+      return named(recipientMapToString(recipients, e -> users.emailToName(e)));
+    }
+
+    private static String recipientMapToString(
+        Map<RecipientType, List<String>> recipients, Function<String, String> emailToName) {
+      StringBuilder buf = new StringBuilder();
+      buf.append('[');
+      for (RecipientType type : ImmutableList.of(TO, CC, BCC)) {
+        buf.append('\n');
+        buf.append(type);
+        buf.append(':');
+        String delim = " ";
+        for (String r : recipients.get(type)) {
+          buf.append(delim);
+          buf.append(emailToName.apply(r));
+          delim = ", ";
+        }
+      }
+      buf.append("\n]");
+      return buf.toString();
+    }
+
+    List<String> parseAddresses(Message msg, String headerName) {
+      EmailHeader header = msg.headers().get(headerName);
+      if (header == null) {
+        return ImmutableList.of();
+      }
+      Truth.assertThat(header).isInstanceOf(AddressList.class);
+      AddressList addrList = (AddressList) header;
+      return addrList.getAddressList().stream().map(Address::getEmail).collect(toList());
+    }
+
+    public FakeEmailSenderSubject to(String... emails) {
+      return rcpt(users.supportReviewersByEmail ? TO : null, emails);
+    }
+
+    public FakeEmailSenderSubject cc(String... emails) {
+      return rcpt(users.supportReviewersByEmail ? CC : null, emails);
+    }
+
+    public FakeEmailSenderSubject bcc(String... emails) {
+      return rcpt(users.supportReviewersByEmail ? BCC : null, emails);
+    }
+
+    private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, String[] emails) {
+      for (String email : emails) {
+        rcpt(type, email);
+      }
+      return this;
+    }
+
+    private void rcpt(@Nullable RecipientType type, String email) {
+      rcpt(TO, email, TO.equals(type));
+      rcpt(CC, email, CC.equals(type));
+      rcpt(BCC, email, BCC.equals(type));
+    }
+
+    private void rcpt(@Nullable RecipientType type, String email, boolean expected) {
+      if (recipients.get(type).contains(email) != expected) {
+        fail(
+            expected ? "notifies" : "doesn't notify",
+            "]\n" + type + ": " + users.emailToName(email) + "\n]");
+      }
+      if (expected) {
+        accountedFor.add(email);
+      }
+    }
+
+    public FakeEmailSenderSubject noOneElse() {
+      for (Map.Entry<NotifyType, TestAccount> watchEntry : users.watchers.entrySet()) {
+        if (!accountedFor.contains(watchEntry.getValue().email)) {
+          notTo(watchEntry.getKey());
+        }
+      }
+
+      Map<RecipientType, List<String>> unaccountedFor = new HashMap<>();
+      boolean ok = true;
+      for (Map.Entry<RecipientType, List<String>> entry : recipients.entrySet()) {
+        unaccountedFor.put(entry.getKey(), new ArrayList<>());
+        for (String address : entry.getValue()) {
+          if (!accountedFor.contains(address)) {
+            unaccountedFor.get(entry.getKey()).add(address);
+            ok = false;
+          }
+        }
+      }
+      if (!ok) {
+        fail(
+            "was fully tested, missing assertions for: "
+                + recipientMapToString(unaccountedFor, e -> users.emailToName(e)));
+      }
+      return this;
+    }
+
+    public FakeEmailSenderSubject notTo(String... emails) {
+      return rcpt(null, emails);
+    }
+
+    public FakeEmailSenderSubject to(TestAccount... accounts) {
+      return rcpt(TO, accounts);
+    }
+
+    public FakeEmailSenderSubject cc(TestAccount... accounts) {
+      return rcpt(CC, accounts);
+    }
+
+    public FakeEmailSenderSubject bcc(TestAccount... accounts) {
+      return rcpt(BCC, accounts);
+    }
+
+    public FakeEmailSenderSubject notTo(TestAccount... accounts) {
+      return rcpt(null, accounts);
+    }
+
+    private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, TestAccount[] accounts) {
+      for (TestAccount account : accounts) {
+        rcpt(type, account);
+      }
+      return this;
+    }
+
+    private void rcpt(@Nullable RecipientType type, TestAccount account) {
+      rcpt(type, account.email);
+    }
+
+    public FakeEmailSenderSubject to(NotifyType... watches) {
+      return rcpt(TO, watches);
+    }
+
+    public FakeEmailSenderSubject cc(NotifyType... watches) {
+      return rcpt(CC, watches);
+    }
+
+    public FakeEmailSenderSubject bcc(NotifyType... watches) {
+      return rcpt(BCC, watches);
+    }
+
+    public FakeEmailSenderSubject notTo(NotifyType... watches) {
+      return rcpt(null, watches);
+    }
+
+    private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, NotifyType[] watches) {
+      for (NotifyType watch : watches) {
+        rcpt(type, watch);
+      }
+      return this;
+    }
+
+    private void rcpt(@Nullable RecipientType type, NotifyType watch) {
+      if (!users.watchers.containsKey(watch)) {
+        fail("configured to watch", watch);
+      }
+      rcpt(type, users.watchers.get(watch));
+    }
+  }
+
+  private static final Map<String, StagedUsers> stagedUsers = new HashMap<>();
+
+  // TestAccount doesn't implement hashCode/equals, so this set is according
+  // to object identity. That's fine for our purposes.
+  private Set<TestAccount> accountsModifyingEmailStrategy = new HashSet<>();
+
+  @After
+  public void resetEmailStrategies() throws Exception {
+    for (TestAccount account : accountsModifyingEmailStrategy) {
+      setEmailStrategy(account, EmailStrategy.ENABLED, false);
+    }
+    accountsModifyingEmailStrategy.clear();
+  }
+
+  protected class StagedUsers {
+    public final TestAccount owner;
+    public final TestAccount author;
+    public final TestAccount uploader;
+    public final TestAccount reviewer;
+    public final TestAccount ccer;
+    public final TestAccount starrer;
+    public final TestAccount assignee;
+    public final TestAccount watchingProjectOwner;
+    public final String reviewerByEmail = "reviewerByEmail@example.com";
+    public final String ccerByEmail = "ccByEmail@example.com";
+    private final Map<NotifyType, TestAccount> watchers = new HashMap<>();
+    private final Map<String, TestAccount> accountsByEmail = new HashMap<>();
+    boolean supportReviewersByEmail;
+
+    private String usersCacheKey() {
+      return description.getClassName();
+    }
+
+    private TestAccount evictAndCopy(TestAccount account) throws IOException {
+      accountCache.evict(account.id);
+      return account;
+    }
+
+    public StagedUsers() throws Exception {
+      synchronized (stagedUsers) {
+        if (stagedUsers.containsKey(usersCacheKey())) {
+          StagedUsers existing = stagedUsers.get(usersCacheKey());
+          owner = evictAndCopy(existing.owner);
+          author = evictAndCopy(existing.author);
+          uploader = evictAndCopy(existing.uploader);
+          reviewer = evictAndCopy(existing.reviewer);
+          ccer = evictAndCopy(existing.ccer);
+          starrer = evictAndCopy(existing.starrer);
+          assignee = evictAndCopy(existing.assignee);
+          watchingProjectOwner = evictAndCopy(existing.watchingProjectOwner);
+          watchers.putAll(existing.watchers);
+          return;
+        }
+
+        owner = testAccount("owner");
+        reviewer = testAccount("reviewer");
+        author = testAccount("author");
+        uploader = testAccount("uploader");
+        ccer = testAccount("ccer");
+        starrer = testAccount("starrer");
+        assignee = testAccount("assignee");
+
+        watchingProjectOwner = testAccount("watchingProjectOwner", "Administrators");
+        setApiUser(watchingProjectOwner);
+        watch(allProjects.get(), pwi -> pwi.notifyNewChanges = true);
+
+        for (NotifyType watch : NotifyType.values()) {
+          if (watch == NotifyType.ALL) {
+            continue;
+          }
+          TestAccount watcher = testAccount(watch.toString());
+          setApiUser(watcher);
+          watch(
+              allProjects.get(),
+              pwi -> {
+                pwi.notifyAllComments = watch.equals(NotifyType.ALL_COMMENTS);
+                pwi.notifyAbandonedChanges = watch.equals(NotifyType.ABANDONED_CHANGES);
+                pwi.notifyNewChanges = watch.equals(NotifyType.NEW_CHANGES);
+                pwi.notifyNewPatchSets = watch.equals(NotifyType.NEW_PATCHSETS);
+                pwi.notifySubmittedChanges = watch.equals(NotifyType.SUBMITTED_CHANGES);
+              });
+          watchers.put(watch, watcher);
+        }
+
+        stagedUsers.put(usersCacheKey(), this);
+      }
+    }
+
+    private String email(String username) {
+      // Email validator rejects usernames longer than 64 bytes.
+      if (username.length() > 64) {
+        username = username.substring(username.length() - 64);
+        if (username.startsWith(".")) {
+          username = username.substring(1);
+        }
+      }
+      return username + "@example.com";
+    }
+
+    public TestAccount testAccount(String name) throws Exception {
+      String username = name(name);
+      TestAccount account = accountCreator.create(username, email(username), name);
+      accountsByEmail.put(account.email, account);
+      return account;
+    }
+
+    public TestAccount testAccount(String name, String groupName) throws Exception {
+      String username = name(name);
+      TestAccount account = accountCreator.create(username, email(username), name, groupName);
+      accountsByEmail.put(account.email, account);
+      return account;
+    }
+
+    String emailToName(String email) {
+      if (accountsByEmail.containsKey(email)) {
+        return accountsByEmail.get(email).fullName;
+      }
+      return email;
+    }
+
+    protected void addReviewers(PushOneCommit.Result r) throws Exception {
+      ReviewInput in =
+          ReviewInput.noScore()
+              .reviewer(reviewer.email)
+              .reviewer(reviewerByEmail)
+              .reviewer(ccer.email, ReviewerState.CC, false)
+              .reviewer(ccerByEmail, ReviewerState.CC, false);
+      ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+      supportReviewersByEmail = true;
+      if (result.reviewers.values().stream().anyMatch(v -> v.error != null)) {
+        supportReviewersByEmail = false;
+        in =
+            ReviewInput.noScore()
+                .reviewer(reviewer.email)
+                .reviewer(ccer.email, ReviewerState.CC, false);
+        result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+      }
+      Truth.assertThat(result.reviewers.values().stream().allMatch(v -> v.error == null)).isTrue();
+    }
+  }
+
+  protected interface PushOptionGenerator {
+    List<String> pushOptions(StagedUsers users);
+  }
+
+  protected class StagedPreChange extends StagedUsers {
+    public final TestRepository<?> repo;
+    protected final PushOneCommit.Result result;
+    public final String changeId;
+
+    StagedPreChange(String ref) throws Exception {
+      this(ref, null);
+    }
+
+    StagedPreChange(String ref, @Nullable PushOptionGenerator pushOptionGenerator)
+        throws Exception {
+      super();
+      List<String> pushOptions = null;
+      if (pushOptionGenerator != null) {
+        pushOptions = pushOptionGenerator.pushOptions(this);
+      }
+      if (pushOptions != null) {
+        ref = ref + '%' + Joiner.on(',').join(pushOptions);
+      }
+      setApiUser(owner);
+      repo = cloneProject(project, owner);
+      PushOneCommit push = pushFactory.create(db, owner.getIdent(), repo);
+      result = push.to(ref);
+      result.assertOkStatus();
+      changeId = result.getChangeId();
+    }
+  }
+
+  protected StagedPreChange stagePreChange(String ref) throws Exception {
+    return new StagedPreChange(ref);
+  }
+
+  protected StagedPreChange stagePreChange(
+      String ref, @Nullable PushOptionGenerator pushOptionGenerator) throws Exception {
+    return new StagedPreChange(ref, pushOptionGenerator);
+  }
+
+  protected class StagedChange extends StagedPreChange {
+    StagedChange(String ref) throws Exception {
+      super(ref);
+
+      setApiUser(starrer);
+      gApi.accounts().self().starChange(result.getChangeId());
+
+      setApiUser(owner);
+      addReviewers(result);
+      sender.clear();
+    }
+  }
+
+  protected StagedChange stageReviewableChange() throws Exception {
+    return new StagedChange("refs/for/master");
+  }
+
+  protected StagedChange stageWipChange() throws Exception {
+    return new StagedChange("refs/for/master%wip");
+  }
+
+  protected StagedChange stageReviewableWipChange() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    setApiUser(sc.owner);
+    gApi.changes().id(sc.changeId).setWorkInProgress();
+    return sc;
+  }
+
+  protected StagedChange stageAbandonedReviewableChange() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    setApiUser(sc.owner);
+    gApi.changes().id(sc.changeId).abandon();
+    sender.clear();
+    return sc;
+  }
+
+  protected StagedChange stageAbandonedReviewableWipChange() throws Exception {
+    StagedChange sc = stageReviewableWipChange();
+    setApiUser(sc.owner);
+    gApi.changes().id(sc.changeId).abandon();
+    sender.clear();
+    return sc;
+  }
+
+  protected StagedChange stageAbandonedWipChange() throws Exception {
+    StagedChange sc = stageWipChange();
+    setApiUser(sc.owner);
+    gApi.changes().id(sc.changeId).abandon();
+    sender.clear();
+    return sc;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java b/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
new file mode 100644
index 0000000..d8dc605
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
@@ -0,0 +1,213 @@
+// 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;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.RequestCleanup;
+import com.google.gerrit.server.config.RequestScopedReviewDbProvider;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestScopePropagator;
+import com.google.gerrit.testing.DisabledReviewDb;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Key;
+import com.google.inject.OutOfScopeException;
+import com.google.inject.Provider;
+import com.google.inject.Scope;
+import com.google.inject.util.Providers;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Guice scopes for state during an Acceptance Test connection. */
+public class AcceptanceTestRequestScope {
+  private static final Key<RequestCleanup> RC_KEY = Key.get(RequestCleanup.class);
+
+  private static final Key<RequestScopedReviewDbProvider> DB_KEY =
+      Key.get(RequestScopedReviewDbProvider.class);
+
+  public static class Context implements RequestContext {
+    private final RequestCleanup cleanup = new RequestCleanup();
+    private final Map<Key<?>, Object> map = new HashMap<>();
+    private final SchemaFactory<ReviewDb> schemaFactory;
+    private final SshSession session;
+    private final CurrentUser user;
+
+    final long created;
+    volatile long started;
+    volatile long finished;
+
+    private Context(SchemaFactory<ReviewDb> sf, SshSession s, CurrentUser u, long at) {
+      schemaFactory = sf;
+      session = s;
+      user = u;
+      created = started = finished = at;
+      map.put(RC_KEY, cleanup);
+      map.put(DB_KEY, new RequestScopedReviewDbProvider(schemaFactory, Providers.of(cleanup)));
+    }
+
+    private Context(Context p, SshSession s, CurrentUser c) {
+      this(p.schemaFactory, s, c, p.created);
+      started = p.started;
+      finished = p.finished;
+    }
+
+    SshSession getSession() {
+      return session;
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      if (user == null) {
+        throw new IllegalStateException("user == null, forgot to set it?");
+      }
+      return user;
+    }
+
+    @Override
+    public Provider<ReviewDb> getReviewDbProvider() {
+      return (RequestScopedReviewDbProvider) map.get(DB_KEY);
+    }
+
+    synchronized <T> T get(Key<T> key, Provider<T> creator) {
+      @SuppressWarnings("unchecked")
+      T t = (T) map.get(key);
+      if (t == null) {
+        t = creator.get();
+        map.put(key, t);
+      }
+      return t;
+    }
+  }
+
+  static class ContextProvider implements Provider<Context> {
+    @Override
+    public Context get() {
+      return requireContext();
+    }
+  }
+
+  static class SshSessionProvider implements Provider<SshSession> {
+    @Override
+    public SshSession get() {
+      return requireContext().getSession();
+    }
+  }
+
+  static class Propagator extends ThreadLocalRequestScopePropagator<Context> {
+    private final AcceptanceTestRequestScope atrScope;
+
+    @Inject
+    Propagator(
+        AcceptanceTestRequestScope atrScope,
+        ThreadLocalRequestContext local,
+        Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
+      super(REQUEST, current, local, dbProviderProvider);
+      this.atrScope = atrScope;
+    }
+
+    @Override
+    protected Context continuingContext(Context ctx) {
+      // The cleanup is not chained, since the RequestScopePropagator executors
+      // the Context's cleanup when finished executing.
+      return atrScope.newContinuingContext(ctx);
+    }
+  }
+
+  private static final ThreadLocal<Context> current = new ThreadLocal<>();
+
+  private static Context requireContext() {
+    final Context ctx = current.get();
+    if (ctx == null) {
+      throw new OutOfScopeException("Not in command/request");
+    }
+    return ctx;
+  }
+
+  private final ThreadLocalRequestContext local;
+
+  @Inject
+  AcceptanceTestRequestScope(ThreadLocalRequestContext local) {
+    this.local = local;
+  }
+
+  public Context newContext(SchemaFactory<ReviewDb> sf, SshSession s, CurrentUser user) {
+    return new Context(sf, s, user, TimeUtil.nowMs());
+  }
+
+  private Context newContinuingContext(Context ctx) {
+    return new Context(ctx, ctx.getSession(), ctx.getUser());
+  }
+
+  public Context set(Context ctx) {
+    Context old = current.get();
+    current.set(ctx);
+    local.setContext(ctx);
+    return old;
+  }
+
+  public Context get() {
+    return current.get();
+  }
+
+  public Context disableDb() {
+    Context old = current.get();
+    SchemaFactory<ReviewDb> sf =
+        new SchemaFactory<ReviewDb>() {
+          @Override
+          public ReviewDb open() {
+            return new DisabledReviewDb();
+          }
+        };
+    Context ctx = new Context(sf, old.session, old.user, old.created);
+
+    current.set(ctx);
+    local.setContext(ctx);
+    return old;
+  }
+
+  public Context reopenDb() {
+    // Setting a new context with the same fields is enough to get the ReviewDb
+    // provider to reopen the database.
+    Context old = current.get();
+    return set(new Context(old.schemaFactory, old.session, old.user, old.created));
+  }
+
+  /** Returns exactly one instance per command executed. */
+  static final Scope REQUEST =
+      new Scope() {
+        @Override
+        public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
+          return new Provider<T>() {
+            @Override
+            public T get() {
+              return requireContext().get(key, creator);
+            }
+
+            @Override
+            public String toString() {
+              return String.format("%s[%s]", creator, REQUEST);
+            }
+          };
+        }
+
+        @Override
+        public String toString() {
+          return "Acceptance Test Scope.REQUEST";
+        }
+      };
+}
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
new file mode 100644
index 0000000..21e3cbb
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -0,0 +1,204 @@
+// 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;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.nio.charset.StandardCharsets.US_ASCII;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.ServerInitiated;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.KeyPair;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class AccountCreator {
+  private final Map<String, TestAccount> accounts;
+
+  private final SchemaFactory<ReviewDb> reviewDbProvider;
+  private final Sequences sequences;
+  private final AccountsUpdate.Server accountsUpdate;
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
+  private final GroupCache groupCache;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
+  private final SshKeyCache sshKeyCache;
+  private final boolean sshEnabled;
+
+  @Inject
+  AccountCreator(
+      SchemaFactory<ReviewDb> schema,
+      Sequences sequences,
+      AccountsUpdate.Server accountsUpdate,
+      VersionedAuthorizedKeys.Accessor authorizedKeys,
+      GroupCache groupCache,
+      @ServerInitiated Provider<GroupsUpdate> groupsUpdateProvider,
+      SshKeyCache sshKeyCache,
+      @SshEnabled boolean sshEnabled) {
+    accounts = new HashMap<>();
+    reviewDbProvider = schema;
+    this.sequences = sequences;
+    this.accountsUpdate = accountsUpdate;
+    this.authorizedKeys = authorizedKeys;
+    this.groupCache = groupCache;
+    this.groupsUpdateProvider = groupsUpdateProvider;
+    this.sshKeyCache = sshKeyCache;
+    this.sshEnabled = sshEnabled;
+  }
+
+  public synchronized TestAccount create(
+      @Nullable String username,
+      @Nullable String email,
+      @Nullable String fullName,
+      String... groupNames)
+      throws Exception {
+
+    TestAccount account = accounts.get(username);
+    if (account != null) {
+      return account;
+    }
+    try (ReviewDb db = reviewDbProvider.open()) {
+      Account.Id id = new Account.Id(sequences.nextAccountId());
+
+      List<ExternalId> extIds = new ArrayList<>(2);
+      String httpPass = null;
+      if (username != null) {
+        httpPass = "http-pass";
+        extIds.add(ExternalId.createUsername(username, id, httpPass));
+      }
+
+      if (email != null) {
+        extIds.add(ExternalId.createEmail(id, email));
+      }
+
+      accountsUpdate
+          .create()
+          .insert(
+              "Create Test Account",
+              id,
+              u -> u.setFullName(fullName).setPreferredEmail(email).addExternalIds(extIds));
+
+      if (groupNames != null) {
+        for (String n : groupNames) {
+          AccountGroup.NameKey k = new AccountGroup.NameKey(n);
+          Optional<InternalGroup> group = groupCache.get(k);
+          if (!group.isPresent()) {
+            throw new NoSuchGroupException(n);
+          }
+          addGroupMember(db, group.get().getGroupUUID(), id);
+        }
+      }
+
+      KeyPair sshKey = null;
+      if (sshEnabled && username != null) {
+        sshKey = genSshKey();
+        authorizedKeys.addKey(id, publicKey(sshKey, email));
+        sshKeyCache.evict(username);
+      }
+
+      account = new TestAccount(id, username, email, fullName, sshKey, httpPass);
+      if (username != null) {
+        accounts.put(username, account);
+      }
+      return account;
+    }
+  }
+
+  public TestAccount create(@Nullable String username, String group) throws Exception {
+    return create(username, null, username, group);
+  }
+
+  public TestAccount create() throws Exception {
+    return create(null);
+  }
+
+  public TestAccount create(@Nullable String username) throws Exception {
+    return create(username, null, username, (String[]) null);
+  }
+
+  public TestAccount admin() throws Exception {
+    return create("admin", "admin@example.com", "Administrator", "Administrators");
+  }
+
+  public TestAccount admin2() throws Exception {
+    return create("admin2", "admin2@example.com", "Administrator2", "Administrators");
+  }
+
+  public TestAccount user() throws Exception {
+    return create("user", "user@example.com", "User");
+  }
+
+  public TestAccount user2() throws Exception {
+    return create("user2", "user2@example.com", "User2");
+  }
+
+  public TestAccount get(String username) {
+    return checkNotNull(accounts.get(username), "No TestAccount created for %s", username);
+  }
+
+  public void evict(Collection<Account.Id> ids) {
+    accounts.values().removeIf(a -> ids.contains(a.id));
+  }
+
+  public static KeyPair genSshKey() throws JSchException {
+    JSch jsch = new JSch();
+    return KeyPair.genKeyPair(jsch, KeyPair.RSA);
+  }
+
+  public static String publicKey(KeyPair sshKey, String comment)
+      throws UnsupportedEncodingException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    sshKey.writePublicKey(out, comment);
+    return out.toString(US_ASCII.name()).trim();
+  }
+
+  private void addGroupMember(ReviewDb db, AccountGroup.UUID groupUuid, Account.Id accountId)
+      throws OrmException, IOException, NoSuchGroupException, ConfigInvalidException {
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder()
+            .setMemberModification(memberIds -> Sets.union(memberIds, ImmutableSet.of(accountId)))
+            .build();
+    groupsUpdateProvider.get().updateGroup(db, groupUuid, groupUpdate);
+  }
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AssertUtil.java b/java/com/google/gerrit/acceptance/AssertUtil.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AssertUtil.java
rename to java/com/google/gerrit/acceptance/AssertUtil.java
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
new file mode 100644
index 0000000..f42749a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -0,0 +1,132 @@
+load("//tools/bzl:java.bzl", "java_library2")
+
+java_library(
+    name = "lib",
+    testonly = 1,
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/acceptance"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":framework-lib",
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/extensions/common/testing:common-test-util",
+        "//java/com/google/gerrit/extensions/restapi/testing:restapi-test-util",
+        "//java/com/google/gerrit/gpg/testing:gpg-test-util",
+        "//java/com/google/gerrit/httpd",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/launcher",
+        "//java/com/google/gerrit/lucene",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/pgm",
+        "//java/com/google/gerrit/pgm/init",
+        "//java/com/google/gerrit/pgm/util",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/sshd",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:args4j",
+        "//lib:gson",
+        "//lib:guava-retrying",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtorm",
+        "//lib:h2",
+        "//lib:jimfs",
+        "//lib:jsch",
+        "//lib:servlet-api-3_1-without-neverlink",
+        "//lib/bouncycastle:bcpg",
+        "//lib/bouncycastle:bcprov",
+        "//lib/commons:compress",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//lib/mina:sshd",
+        "//prolog:gerrit-prolog-common",
+    ],
+)
+
+PROVIDED = [
+    "//java/com/google/gerrit/common:annotations",
+    "//java/com/google/gerrit/common:server",
+    "//java/com/google/gerrit/extensions:api",
+    "//java/com/google/gerrit/httpd",
+    "//java/com/google/gerrit/index",
+    "//java/com/google/gerrit/lucene",
+    "//java/com/google/gerrit/metrics",
+    "//java/com/google/gerrit/reviewdb:server",
+    "//java/com/google/gerrit/server",
+    "//java/com/google/gerrit/server/schema",
+    "//java/com/google/gerrit/pgm/init",
+    "//java/com/google/gerrit/server/git/receive",
+    "//lib:gson",
+    "//lib:jsch",
+    "//lib/jgit/org.eclipse.jgit:jgit",
+    "//lib/mina:sshd",
+    "//lib:servlet-api-3_1",
+]
+
+java_binary(
+    name = "framework",
+    testonly = 1,
+    main_class = "Dummy",
+    visibility = ["//visibility:public"],
+    runtime_deps = [":framework-lib"],
+)
+
+java_library2(
+    name = "framework-lib",
+    testonly = 1,
+    srcs = glob(["**/*.java"]),
+    exported_deps = [
+        "//java/com/google/gerrit/gpg",
+        "//java/com/google/gerrit/httpd/auth/openid",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/launcher",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/pgm:daemon",
+        "//java/com/google/gerrit/pgm/http/jetty",
+        "//java/com/google/gerrit/pgm/util",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:jimfs",
+        "//lib:truth",
+        "//lib:truth-java8-extension",
+        "//lib/auto:auto-value",
+        "//lib/httpcomponents:fluent-hc",
+        "//lib/httpcomponents:httpclient",
+        "//lib/httpcomponents:httpcore",
+        "//lib/jetty:servlet",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/log:impl_log4j",
+        "//lib/log:log4j",
+        "//prolog:gerrit-prolog-common",
+    ],
+    visibility = ["//visibility:public"],
+    deps = PROVIDED + [
+        # We want these deps to be exported_deps
+        "//lib:guava-retrying",
+        "//lib:gwtorm",
+        "//lib/greenmail:greenmail",
+        "//lib/guice:guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",
+        "//lib/mail:mail",
+    ],
+)
+
+load("//tools/bzl:javadoc.bzl", "java_doc")
+
+java_doc(
+    name = "framework-javadoc",
+    testonly = 1,
+    libs = [":framework-lib"],
+    pkgs = ["com.google.gerrit.acceptance"],
+    title = "Gerrit Acceptance Test Framework Documentation",
+    visibility = ["//visibility:public"],
+)
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java b/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java
rename to java/com/google/gerrit/acceptance/ConfigAnnotationParser.java
diff --git a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
new file mode 100644
index 0000000..0d473af
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/DisabledChangeIndex.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.acceptance;
+
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Id;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.io.IOException;
+import java.util.Optional;
+
+/**
+ * This class wraps an index and assumes the search index can't handle any queries. However, it does
+ * return the current schema as the assumption is that we need a search index for starting Gerrit in
+ * the first place and only later lose the index connection (making it so that we can't send
+ * requests there anymore).
+ */
+public class DisabledChangeIndex implements ChangeIndex {
+  private final ChangeIndex index;
+
+  public DisabledChangeIndex(ChangeIndex index) {
+    this.index = index;
+  }
+
+  public ChangeIndex unwrap() {
+    return index;
+  }
+
+  @Override
+  public Schema<ChangeData> getSchema() {
+    return index.getSchema();
+  }
+
+  @Override
+  public void close() {
+    index.close();
+  }
+
+  @Override
+  public void replace(ChangeData obj) throws IOException {
+    throw new UnsupportedOperationException("ChangeIndex is disabled");
+  }
+
+  @Override
+  public void delete(Id key) throws IOException {
+    throw new UnsupportedOperationException("ChangeIndex is disabled");
+  }
+
+  @Override
+  public void deleteAll() throws IOException {
+    throw new UnsupportedOperationException("ChangeIndex is disabled");
+  }
+
+  @Override
+  public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts)
+      throws QueryParseException {
+    throw new UnsupportedOperationException("ChangeIndex is disabled");
+  }
+
+  @Override
+  public void markReady(boolean ready) throws IOException {
+    throw new UnsupportedOperationException("ChangeIndex is disabled");
+  }
+
+  @Override
+  public Optional<ChangeData> get(Change.Id key, QueryOptions opts) throws IOException {
+    throw new UnsupportedOperationException("ChangeIndex is disabled");
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/EventRecorder.java b/java/com/google/gerrit/acceptance/EventRecorder.java
new file mode 100644
index 0000000..f9f95b5
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/EventRecorder.java
@@ -0,0 +1,198 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.events.ChangeMergedEvent;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.RefEvent;
+import com.google.gerrit.server.events.RefUpdatedEvent;
+import com.google.gerrit.server.events.ReviewerDeletedEvent;
+import com.google.gerrit.server.events.UserScopedEventListener;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+public class EventRecorder {
+  private final RegistrationHandle eventListenerRegistration;
+  private final ListMultimap<String, RefEvent> recordedEvents;
+
+  @Singleton
+  public static class Factory {
+    private final DynamicSet<UserScopedEventListener> eventListeners;
+    private final IdentifiedUser.GenericFactory userFactory;
+
+    @Inject
+    Factory(
+        DynamicSet<UserScopedEventListener> eventListeners,
+        IdentifiedUser.GenericFactory userFactory) {
+      this.eventListeners = eventListeners;
+      this.userFactory = userFactory;
+    }
+
+    public EventRecorder create(TestAccount user) {
+      return new EventRecorder(eventListeners, userFactory.create(user.id));
+    }
+  }
+
+  public EventRecorder(DynamicSet<UserScopedEventListener> eventListeners, IdentifiedUser user) {
+    recordedEvents = LinkedListMultimap.create();
+
+    eventListenerRegistration =
+        eventListeners.add(
+            new UserScopedEventListener() {
+              @Override
+              public void onEvent(Event e) {
+                if (e instanceof ReviewerDeletedEvent) {
+                  recordedEvents.put(ReviewerDeletedEvent.TYPE, (ReviewerDeletedEvent) e);
+                } else if (e instanceof RefEvent) {
+                  RefEvent event = (RefEvent) e;
+                  String key =
+                      refEventKey(
+                          event.getType(), event.getProjectNameKey().get(), event.getRefName());
+                  recordedEvents.put(key, event);
+                }
+              }
+
+              @Override
+              public CurrentUser getUser() {
+                return user;
+              }
+            });
+  }
+
+  private static String refEventKey(String type, String project, String ref) {
+    return String.format("%s-%s-%s", type, project, ref);
+  }
+
+  private ImmutableList<RefUpdatedEvent> getRefUpdatedEvents(
+      String project, String refName, int expectedSize) {
+    String key = refEventKey(RefUpdatedEvent.TYPE, project, refName);
+    if (expectedSize == 0) {
+      assertThat(recordedEvents).doesNotContainKey(key);
+      return ImmutableList.of();
+    }
+
+    assertThat(recordedEvents).containsKey(key);
+    ImmutableList<RefUpdatedEvent> events =
+        FluentIterable.from(recordedEvents.get(key))
+            .transform(RefUpdatedEvent.class::cast)
+            .toList();
+    assertThat(events).hasSize(expectedSize);
+    return events;
+  }
+
+  private ImmutableList<ChangeMergedEvent> getChangeMergedEvents(
+      String project, String branch, int expectedSize) {
+    String key = refEventKey(ChangeMergedEvent.TYPE, project, branch);
+    if (expectedSize == 0) {
+      assertThat(recordedEvents).doesNotContainKey(key);
+      return ImmutableList.of();
+    }
+
+    assertThat(recordedEvents).containsKey(key);
+    ImmutableList<ChangeMergedEvent> events =
+        FluentIterable.from(recordedEvents.get(key))
+            .transform(ChangeMergedEvent.class::cast)
+            .toList();
+    assertThat(events).hasSize(expectedSize);
+    return events;
+  }
+
+  private ImmutableList<ReviewerDeletedEvent> getReviewerDeletedEvents(int expectedSize) {
+    String key = ReviewerDeletedEvent.TYPE;
+    if (expectedSize == 0) {
+      assertThat(recordedEvents).doesNotContainKey(key);
+      return ImmutableList.of();
+    }
+    assertThat(recordedEvents).containsKey(key);
+    ImmutableList<ReviewerDeletedEvent> events =
+        FluentIterable.from(recordedEvents.get(key))
+            .transform(ReviewerDeletedEvent.class::cast)
+            .toList();
+    assertThat(events).hasSize(expectedSize);
+    return events;
+  }
+
+  public void assertRefUpdatedEvents(String project, String branch, String... expected)
+      throws Exception {
+    ImmutableList<RefUpdatedEvent> events =
+        getRefUpdatedEvents(project, branch, expected.length / 2);
+    int i = 0;
+    for (RefUpdatedEvent event : events) {
+      RefUpdateAttribute actual = event.refUpdate.get();
+      String oldRev = expected[i] == null ? ObjectId.zeroId().name() : expected[i];
+      String newRev = expected[i + 1] == null ? ObjectId.zeroId().name() : expected[i + 1];
+      assertThat(actual.oldRev).isEqualTo(oldRev);
+      assertThat(actual.newRev).isEqualTo(newRev);
+      i += 2;
+    }
+  }
+
+  public void assertRefUpdatedEvents(String project, String branch, RevCommit... expected)
+      throws Exception {
+    ImmutableList<RefUpdatedEvent> events =
+        getRefUpdatedEvents(project, branch, expected.length / 2);
+    int i = 0;
+    for (RefUpdatedEvent event : events) {
+      RefUpdateAttribute actual = event.refUpdate.get();
+      String oldRev = expected[i] == null ? ObjectId.zeroId().name() : expected[i].name();
+      String newRev = expected[i + 1] == null ? ObjectId.zeroId().name() : expected[i + 1].name();
+      assertThat(actual.oldRev).isEqualTo(oldRev);
+      assertThat(actual.newRev).isEqualTo(newRev);
+      i += 2;
+    }
+  }
+
+  public void assertChangeMergedEvents(String project, String branch, String... expected)
+      throws Exception {
+    ImmutableList<ChangeMergedEvent> events =
+        getChangeMergedEvents(project, branch, expected.length / 2);
+    int i = 0;
+    for (ChangeMergedEvent event : events) {
+      String id = event.change.get().id;
+      assertThat(id).isEqualTo(expected[i]);
+      assertThat(event.newRev).isEqualTo(expected[i + 1]);
+      i += 2;
+    }
+  }
+
+  public void assertReviewerDeletedEvents(String... expected) {
+    ImmutableList<ReviewerDeletedEvent> events = getReviewerDeletedEvents(expected.length / 2);
+    int i = 0;
+    for (ReviewerDeletedEvent event : events) {
+      String id = event.change.get().id;
+      assertThat(id).isEqualTo(expected[i]);
+      String reviewer = event.reviewer.get().email;
+      assertThat(reviewer).isEqualTo(expected[i + 1]);
+      i += 2;
+    }
+  }
+
+  public void close() {
+    eventListenerRegistration.remove();
+  }
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GcAssert.java b/java/com/google/gerrit/acceptance/GcAssert.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GcAssert.java
rename to java/com/google/gerrit/acceptance/GcAssert.java
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfig.java b/java/com/google/gerrit/acceptance/GerritConfig.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfig.java
rename to java/com/google/gerrit/acceptance/GerritConfig.java
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfigs.java b/java/com/google/gerrit/acceptance/GerritConfigs.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfigs.java
rename to java/com/google/gerrit/acceptance/GerritConfigs.java
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
new file mode 100644
index 0000000..1b9e8aa
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -0,0 +1,526 @@
+// 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;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.lucene.LuceneIndexModule;
+import com.google.gerrit.pgm.Daemon;
+import com.google.gerrit.pgm.Init;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
+import com.google.gerrit.server.ssh.NoSshModule;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.gerrit.server.util.SocketUtil;
+import com.google.gerrit.server.util.SystemLog;
+import com.google.gerrit.testing.FakeEmailSender;
+import com.google.gerrit.testing.GroupNoteDbMode;
+import com.google.gerrit.testing.NoteDbChecker;
+import com.google.gerrit.testing.NoteDbMode;
+import com.google.gerrit.testing.SshMode;
+import com.google.gerrit.testing.TempFileUtil;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.Module;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.BrokenBarrierException;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+import org.apache.log4j.Level;
+import org.apache.log4j.Logger;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.RepositoryCache;
+import org.eclipse.jgit.util.FS;
+
+public class GerritServer implements AutoCloseable {
+  public static class StartupException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    StartupException(String msg, Throwable cause) {
+      super(msg, cause);
+    }
+  }
+
+  @AutoValue
+  public abstract static class Description {
+    public static Description forTestClass(
+        org.junit.runner.Description testDesc, String configName) {
+      return new AutoValue_GerritServer_Description(
+          testDesc,
+          configName,
+          !has(UseLocalDisk.class, testDesc.getTestClass()) && !forceLocalDisk(),
+          !has(NoHttpd.class, testDesc.getTestClass()),
+          has(Sandboxed.class, testDesc.getTestClass()),
+          has(UseSsh.class, testDesc.getTestClass()),
+          null, // @GerritConfig is only valid on methods.
+          null, // @GerritConfigs is only valid on methods.
+          null, // @GlobalPluginConfig is only valid on methods.
+          null); // @GlobalPluginConfigs is only valid on methods.
+    }
+
+    public static Description forTestMethod(
+        org.junit.runner.Description testDesc, String configName) {
+      return new AutoValue_GerritServer_Description(
+          testDesc,
+          configName,
+          (testDesc.getAnnotation(UseLocalDisk.class) == null
+                  && !has(UseLocalDisk.class, testDesc.getTestClass()))
+              && !forceLocalDisk(),
+          testDesc.getAnnotation(NoHttpd.class) == null
+              && !has(NoHttpd.class, testDesc.getTestClass()),
+          testDesc.getAnnotation(Sandboxed.class) != null
+              || has(Sandboxed.class, testDesc.getTestClass()),
+          testDesc.getAnnotation(UseSsh.class) != null
+              || has(UseSsh.class, testDesc.getTestClass()),
+          testDesc.getAnnotation(GerritConfig.class),
+          testDesc.getAnnotation(GerritConfigs.class),
+          testDesc.getAnnotation(GlobalPluginConfig.class),
+          testDesc.getAnnotation(GlobalPluginConfigs.class));
+    }
+
+    private static boolean has(Class<? extends Annotation> annotation, Class<?> clazz) {
+      for (; clazz != null; clazz = clazz.getSuperclass()) {
+        if (clazz.getAnnotation(annotation) != null) {
+          return true;
+        }
+      }
+      return false;
+    }
+
+    abstract org.junit.runner.Description testDescription();
+
+    @Nullable
+    abstract String configName();
+
+    abstract boolean memory();
+
+    abstract boolean httpd();
+
+    abstract boolean sandboxed();
+
+    abstract boolean useSshAnnotation();
+
+    boolean useSsh() {
+      return useSshAnnotation() && SshMode.useSsh();
+    }
+
+    @Nullable
+    abstract GerritConfig config();
+
+    @Nullable
+    abstract GerritConfigs configs();
+
+    @Nullable
+    abstract GlobalPluginConfig pluginConfig();
+
+    @Nullable
+    abstract GlobalPluginConfigs pluginConfigs();
+
+    private void checkValidAnnotations() {
+      if (configs() != null && config() != null) {
+        throw new IllegalStateException("Use either @GerritConfigs or @GerritConfig not both");
+      }
+      if (pluginConfigs() != null && pluginConfig() != null) {
+        throw new IllegalStateException(
+            "Use either @GlobalPluginConfig or @GlobalPluginConfigs not both");
+      }
+      if ((pluginConfigs() != null || pluginConfig() != null) && memory()) {
+        throw new IllegalStateException("Must use @UseLocalDisk with @GlobalPluginConfig(s)");
+      }
+    }
+
+    private Config buildConfig(Config baseConfig) {
+      if (configs() != null) {
+        return ConfigAnnotationParser.parse(baseConfig, configs());
+      } else if (config() != null) {
+        return ConfigAnnotationParser.parse(baseConfig, config());
+      } else {
+        return baseConfig;
+      }
+    }
+
+    private Map<String, Config> buildPluginConfigs() {
+      if (pluginConfigs() != null) {
+        return ConfigAnnotationParser.parse(pluginConfigs());
+      } else if (pluginConfig() != null) {
+        return ConfigAnnotationParser.parse(pluginConfig());
+      }
+      return new HashMap<>();
+    }
+  }
+
+  private static boolean forceLocalDisk() {
+    String value = Strings.nullToEmpty(System.getenv("GERRIT_FORCE_LOCAL_DISK"));
+    if (value.isEmpty()) {
+      value = Strings.nullToEmpty(System.getProperty("gerrit.forceLocalDisk"));
+    }
+    switch (value.trim().toLowerCase(Locale.US)) {
+      case "1":
+      case "yes":
+      case "true":
+        return true;
+      default:
+        return false;
+    }
+  }
+
+  /**
+   * Initializes on-disk site but does not start server.
+   *
+   * @param desc server description
+   * @param baseConfig default config values; merged with config from {@code desc} and then written
+   *     into {@code site/etc/gerrit.config}.
+   * @param site temp directory where site will live.
+   * @throws Exception
+   */
+  public static void init(Description desc, Config baseConfig, Path site) throws Exception {
+    checkArgument(!desc.memory(), "can't initialize site path for in-memory test: %s", desc);
+    Config cfg = desc.buildConfig(baseConfig);
+    Map<String, Config> pluginConfigs = desc.buildPluginConfigs();
+
+    MergeableFileBasedConfig gerritConfig =
+        new MergeableFileBasedConfig(
+            site.resolve("etc").resolve("gerrit.config").toFile(), FS.DETECTED);
+    gerritConfig.load();
+    gerritConfig.merge(cfg);
+    mergeTestConfig(gerritConfig);
+    gerritConfig.save();
+
+    Init init = new Init();
+    int rc =
+        init.main(
+            new String[] {
+              "-d", site.toString(), "--batch", "--no-auto-start", "--skip-plugins",
+            });
+    if (rc != 0) {
+      throw new RuntimeException("Couldn't initialize site");
+    }
+
+    for (String pluginName : pluginConfigs.keySet()) {
+      MergeableFileBasedConfig pluginCfg =
+          new MergeableFileBasedConfig(
+              site.resolve("etc").resolve(pluginName + ".config").toFile(), FS.DETECTED);
+      pluginCfg.load();
+      pluginCfg.merge(pluginConfigs.get(pluginName));
+      pluginCfg.save();
+    }
+  }
+
+  /**
+   * Initializes new Gerrit site and returns started server.
+   *
+   * <p>A new temporary directory for the site will be created with {@link TempFileUtil}, even in
+   * the server is otherwise configured in-memory. Closing the server stops the daemon but does not
+   * delete the temporary directory. Callers may either get the directory with {@link
+   * #getSitePath()} and delete it manually, or call {@link TempFileUtil#cleanup()}.
+   *
+   * @param desc server description.
+   * @param baseConfig default config values; merged with config from {@code desc}.
+   * @return started server.
+   * @throws Exception
+   */
+  public static GerritServer initAndStart(Description desc, Config baseConfig) throws Exception {
+    Path site = TempFileUtil.createTempDirectory().toPath();
+    baseConfig = new Config(baseConfig);
+    baseConfig.setString("gerrit", null, "basePath", site.resolve("git").toString());
+    baseConfig.setString("gerrit", null, "tempSiteDir", site.toString());
+    try {
+      if (!desc.memory()) {
+        init(desc, baseConfig, site);
+      }
+      return start(desc, baseConfig, site, null);
+    } catch (Exception e) {
+      TempFileUtil.recursivelyDelete(site.toFile());
+      throw e;
+    }
+  }
+
+  /**
+   * Starts Gerrit server from existing on-disk site.
+   *
+   * @param desc server description.
+   * @param baseConfig default config values; merged with config from {@code desc}.
+   * @param site existing temporary directory for site. Required, but may be empty, for in-memory
+   *     servers. For on-disk servers, assumes that {@link #init} was previously called to
+   *     initialize this directory. Can be retrieved from the returned instance via {@link
+   *     #getSitePath()}.
+   * @param testSysModule optional additional module to add to the system injector.
+   * @param additionalArgs additional command-line arguments for the daemon program; only allowed if
+   *     the test is not in-memory.
+   * @return started server.
+   * @throws Exception
+   */
+  public static GerritServer start(
+      Description desc,
+      Config baseConfig,
+      Path site,
+      @Nullable Module testSysModule,
+      String... additionalArgs)
+      throws Exception {
+    checkArgument(site != null, "site is required (even for in-memory server");
+    desc.checkValidAnnotations();
+    Logger.getLogger("com.google.gerrit").setLevel(Level.DEBUG);
+    CyclicBarrier serverStarted = new CyclicBarrier(2);
+    Daemon daemon =
+        new Daemon(
+            () -> {
+              try {
+                serverStarted.await();
+              } catch (InterruptedException | BrokenBarrierException e) {
+                throw new RuntimeException(e);
+              }
+            },
+            site);
+    daemon.setEmailModuleForTesting(new FakeEmailSender.Module());
+    daemon.setAdditionalSysModuleForTesting(testSysModule);
+    daemon.setEnableSshd(desc.useSsh());
+
+    if (desc.memory()) {
+      checkArgument(additionalArgs.length == 0, "cannot pass args to in-memory server");
+      return startInMemory(desc, site, baseConfig, daemon);
+    }
+    return startOnDisk(desc, site, daemon, serverStarted, additionalArgs);
+  }
+
+  private static GerritServer startInMemory(
+      Description desc, Path site, Config baseConfig, Daemon daemon) throws Exception {
+    Config cfg = desc.buildConfig(baseConfig);
+    mergeTestConfig(cfg);
+    // Set the log4j configuration to an invalid one to prevent system logs
+    // from getting configured and creating log files.
+    System.setProperty(SystemLog.LOG4J_CONFIGURATION, "invalidConfiguration");
+    cfg.setBoolean("httpd", null, "requestLog", false);
+    cfg.setBoolean("sshd", null, "requestLog", false);
+    cfg.setBoolean("index", "lucene", "testInmemory", true);
+    cfg.setString("gitweb", null, "cgi", "");
+    daemon.setEnableHttpd(desc.httpd());
+    daemon.setLuceneModule(LuceneIndexModule.singleVersionAllLatest(0));
+    daemon.setDatabaseForTesting(
+        ImmutableList.<Module>of(new InMemoryTestingDatabaseModule(cfg, site)));
+    daemon.start();
+    return new GerritServer(desc, null, createTestInjector(daemon), daemon, null);
+  }
+
+  private static GerritServer startOnDisk(
+      Description desc,
+      Path site,
+      Daemon daemon,
+      CyclicBarrier serverStarted,
+      String[] additionalArgs)
+      throws Exception {
+    checkNotNull(site);
+    ExecutorService daemonService = Executors.newSingleThreadExecutor();
+    String[] args =
+        Stream.concat(
+                Stream.of(
+                    "-d", site.toString(), "--headless", "--console-log", "--show-stack-trace"),
+                Arrays.stream(additionalArgs))
+            .toArray(String[]::new);
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        daemonService.submit(
+            () -> {
+              int rc = daemon.main(args);
+              if (rc != 0) {
+                System.err.println("Failed to start Gerrit daemon");
+                serverStarted.reset();
+              }
+              return null;
+            });
+    try {
+      serverStarted.await();
+    } catch (BrokenBarrierException e) {
+      daemon.stop();
+      throw new StartupException("Failed to start Gerrit daemon; see log", e);
+    }
+    System.out.println("Gerrit Server Started");
+
+    return new GerritServer(desc, site, createTestInjector(daemon), daemon, daemonService);
+  }
+
+  private static void mergeTestConfig(Config cfg) {
+    String forceEphemeralPort = String.format("%s:0", getLocalHost().getHostName());
+    String url = "http://" + forceEphemeralPort + "/";
+    cfg.setString("gerrit", null, "canonicalWebUrl", url);
+    cfg.setString("httpd", null, "listenUrl", url);
+
+    if (cfg.getString("sshd", null, "listenAddress") == null) {
+      cfg.setString("sshd", null, "listenAddress", forceEphemeralPort);
+    }
+    cfg.setBoolean("sshd", null, "testUseInsecureRandom", true);
+    cfg.unset("cache", null, "directory");
+    cfg.setString("gerrit", null, "basePath", "git");
+    cfg.setBoolean("sendemail", null, "enable", true);
+    cfg.setInt("sendemail", null, "threadPoolSize", 0);
+    cfg.setInt("cache", "projects", "checkFrequency", 0);
+    cfg.setInt("plugins", null, "checkFrequency", 0);
+
+    cfg.setInt("sshd", null, "threads", 1);
+    cfg.setInt("sshd", null, "commandStartThreads", 1);
+    cfg.setInt("receive", null, "threadPoolSize", 1);
+    cfg.setInt("index", null, "threads", 1);
+    cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
+
+    NoteDbMode.newNotesMigrationFromEnv().setConfigValues(cfg);
+    GroupNoteDbMode.get().getGroupsMigration().setConfigValuesIfNotSetYet(cfg);
+  }
+
+  private static Injector createTestInjector(Daemon daemon) throws Exception {
+    Injector sysInjector = get(daemon, "sysInjector");
+    Module module =
+        new FactoryModule() {
+          @Override
+          protected void configure() {
+            bindConstant().annotatedWith(SshEnabled.class).to(daemon.getEnableSshd());
+            bind(AccountCreator.class);
+            factory(PushOneCommit.Factory.class);
+            install(InProcessProtocol.module());
+            install(new NoSshModule());
+            install(new AsyncReceiveCommits.Module());
+            factory(ProjectResetter.Builder.Factory.class);
+          }
+        };
+    return sysInjector.createChildInjector(module);
+  }
+
+  @SuppressWarnings("unchecked")
+  private static <T> T get(Object obj, String field)
+      throws SecurityException, NoSuchFieldException, IllegalArgumentException,
+          IllegalAccessException {
+    Field f = obj.getClass().getDeclaredField(field);
+    f.setAccessible(true);
+    return (T) f.get(obj);
+  }
+
+  private static InetAddress getLocalHost() {
+    return InetAddress.getLoopbackAddress();
+  }
+
+  private final Description desc;
+  private final Path sitePath;
+
+  private Daemon daemon;
+  private ExecutorService daemonService;
+  private Injector testInjector;
+  private String url;
+  private InetSocketAddress sshdAddress;
+  private InetSocketAddress httpAddress;
+
+  private GerritServer(
+      Description desc,
+      @Nullable Path sitePath,
+      Injector testInjector,
+      Daemon daemon,
+      @Nullable ExecutorService daemonService) {
+    this.desc = checkNotNull(desc);
+    this.sitePath = sitePath;
+    this.testInjector = checkNotNull(testInjector);
+    this.daemon = checkNotNull(daemon);
+    this.daemonService = daemonService;
+
+    Config cfg = testInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+    url = cfg.getString("gerrit", null, "canonicalWebUrl");
+    URI uri = URI.create(url);
+
+    String addr = cfg.getString("sshd", null, "listenAddress");
+    // We do not use InitSshd.isOff to avoid coupling GerritServer to the SSH code.
+    if (!"off".equalsIgnoreCase(addr)) {
+      sshdAddress = SocketUtil.resolve(cfg.getString("sshd", null, "listenAddress"), 0);
+    }
+    httpAddress = new InetSocketAddress(uri.getHost(), uri.getPort());
+  }
+
+  String getUrl() {
+    return url;
+  }
+
+  InetSocketAddress getSshdAddress() {
+    return sshdAddress;
+  }
+
+  InetSocketAddress getHttpAddress() {
+    return httpAddress;
+  }
+
+  public Injector getTestInjector() {
+    return testInjector;
+  }
+
+  Description getDescription() {
+    return desc;
+  }
+
+  @Override
+  public void close() throws Exception {
+    try {
+      checkNoteDbState();
+    } finally {
+      daemon.getLifecycleManager().stop();
+      if (daemonService != null) {
+        System.out.println("Gerrit Server Shutdown");
+        daemonService.shutdownNow();
+        daemonService.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
+      }
+      RepositoryCache.clear();
+    }
+  }
+
+  public Path getSitePath() {
+    return sitePath;
+  }
+
+  private void checkNoteDbState() throws Exception {
+    NoteDbMode mode = NoteDbMode.get();
+    if (mode != NoteDbMode.CHECK && mode != NoteDbMode.PRIMARY) {
+      return;
+    }
+    NoteDbChecker checker = testInjector.getInstance(NoteDbChecker.class);
+    OneOffRequestContext oneOffRequestContext =
+        testInjector.getInstance(OneOffRequestContext.class);
+    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
+      if (mode == NoteDbMode.CHECK) {
+        checker.rebuildAndCheckAllChanges();
+      } else if (mode == NoteDbMode.PRIMARY) {
+        checker.assertNoReviewDbChanges(desc.testDescription());
+      }
+    }
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this).addValue(desc).toString();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/GitUtil.java b/java/com/google/gerrit/acceptance/GitUtil.java
new file mode 100644
index 0000000..e11651f
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/GitUtil.java
@@ -0,0 +1,238 @@
+// 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;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.reviewdb.client.Project;
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.Session;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.api.FetchCommand;
+import org.eclipse.jgit.api.PushCommand;
+import org.eclipse.jgit.api.TagCommand;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.FetchResult;
+import org.eclipse.jgit.transport.JschConfigSessionFactory;
+import org.eclipse.jgit.transport.OpenSshConfig.Host;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.transport.SshSessionFactory;
+import org.eclipse.jgit.util.FS;
+
+public class GitUtil {
+  private static final AtomicInteger testRepoCount = new AtomicInteger();
+  private static final int TEST_REPO_WINDOW_DAYS = 2;
+
+  public static void initSsh(TestAccount a) {
+    final Properties config = new Properties();
+    config.put("StrictHostKeyChecking", "no");
+    JSch.setConfig(config);
+
+    // register a JschConfigSessionFactory that adds the private key as identity
+    // to the JSch instance of JGit so that SSH communication via JGit can
+    // succeed
+    SshSessionFactory.setInstance(
+        new JschConfigSessionFactory() {
+          @Override
+          protected void configure(Host hc, Session session) {
+            try {
+              final JSch jsch = getJSch(hc, FS.DETECTED);
+              jsch.addIdentity("KeyPair", a.privateKey(), a.sshKey.getPublicKeyBlob(), null);
+            } catch (JSchException e) {
+              throw new RuntimeException(e);
+            }
+          }
+        });
+  }
+
+  /**
+   * Create a new {@link TestRepository} with a distinct commit clock.
+   *
+   * <p>It is very easy for tests to create commits with identical subjects and trees; if such
+   * commits also have identical authors/committers, then the computed Change-Id is identical as
+   * well. Tests may generally assume that Change-Ids are unique, so to ensure this, we provision
+   * TestRepository instances with non-overlapping commit clock times.
+   *
+   * <p>Space test repos 1 day apart, which allows for about 86k ticks per repo before overlapping,
+   * and about 8k instances per process before hitting JGit's year 2038 limit.
+   *
+   * @param repo repository to wrap.
+   * @return wrapped test repository with distinct commit time space.
+   */
+  public static <R extends Repository> TestRepository<R> newTestRepository(R repo)
+      throws IOException {
+    TestRepository<R> tr = new TestRepository<>(repo);
+    tr.tick(
+        Ints.checkedCast(
+            TimeUnit.SECONDS.convert(
+                testRepoCount.getAndIncrement() * TEST_REPO_WINDOW_DAYS, TimeUnit.DAYS)));
+    return tr;
+  }
+
+  public static TestRepository<InMemoryRepository> cloneProject(Project.NameKey project, String uri)
+      throws Exception {
+    DfsRepositoryDescription desc = new DfsRepositoryDescription("clone of " + project.get());
+
+    FS fs = FS.detect();
+
+    // Avoid leaking user state into our tests.
+    fs.setUserHome(null);
+
+    InMemoryRepository dest =
+        new InMemoryRepository.Builder()
+            .setRepositoryDescription(desc)
+            // SshTransport depends on a real FS to read ~/.ssh/config, but
+            // InMemoryRepository by default uses a null FS.
+            // TODO(dborowitz): Remove when we no longer depend on SSH.
+            .setFS(fs)
+            .build();
+    Config cfg = dest.getConfig();
+    cfg.setString("remote", "origin", "url", uri);
+    cfg.setString("remote", "origin", "fetch", "+refs/heads/*:refs/remotes/origin/*");
+    TestRepository<InMemoryRepository> testRepo = newTestRepository(dest);
+    FetchResult result = testRepo.git().fetch().setRemote("origin").call();
+    String originMaster = "refs/remotes/origin/master";
+    if (result.getTrackingRefUpdate(originMaster) != null) {
+      testRepo.reset(originMaster);
+    }
+    return testRepo;
+  }
+
+  public static TestRepository<InMemoryRepository> cloneProject(
+      Project.NameKey project, SshSession sshSession) throws Exception {
+    return cloneProject(project, sshSession.getUrl() + "/" + project.get());
+  }
+
+  public static Ref createAnnotatedTag(TestRepository<?> testRepo, String name, PersonIdent tagger)
+      throws GitAPIException {
+    TagCommand cmd =
+        testRepo.git().tag().setName(name).setAnnotated(true).setMessage(name).setTagger(tagger);
+    return cmd.call();
+  }
+
+  public static Ref updateAnnotatedTag(TestRepository<?> testRepo, String name, PersonIdent tagger)
+      throws GitAPIException {
+    TagCommand tc = testRepo.git().tag().setName(name);
+    return tc.setAnnotated(true).setMessage(name).setTagger(tagger).setForceUpdate(true).call();
+  }
+
+  public static void fetch(TestRepository<?> testRepo, String spec) throws GitAPIException {
+    FetchCommand fetch = testRepo.git().fetch();
+    fetch.setRefSpecs(new RefSpec(spec));
+    fetch.call();
+  }
+
+  public static PushResult pushHead(TestRepository<?> testRepo, String ref) throws GitAPIException {
+    return pushHead(testRepo, ref, false);
+  }
+
+  public static PushResult pushHead(TestRepository<?> testRepo, String ref, boolean pushTags)
+      throws GitAPIException {
+    return pushHead(testRepo, ref, pushTags, false);
+  }
+
+  public static PushResult pushHead(
+      TestRepository<?> testRepo, String ref, boolean pushTags, boolean force)
+      throws GitAPIException {
+    return pushOne(testRepo, "HEAD", ref, pushTags, force, null);
+  }
+
+  public static PushResult pushHead(
+      TestRepository<?> testRepo,
+      String ref,
+      boolean pushTags,
+      boolean force,
+      List<String> pushOptions)
+      throws GitAPIException {
+    return pushOne(testRepo, "HEAD", ref, pushTags, force, pushOptions);
+  }
+
+  public static PushResult deleteRef(TestRepository<?> testRepo, String ref)
+      throws GitAPIException {
+    return pushOne(testRepo, "", ref, false, true, null);
+  }
+
+  public static PushResult pushOne(
+      TestRepository<?> testRepo,
+      String source,
+      String target,
+      boolean pushTags,
+      boolean force,
+      List<String> pushOptions)
+      throws GitAPIException {
+    PushCommand pushCmd = testRepo.git().push();
+    pushCmd.setForce(force);
+    pushCmd.setPushOptions(pushOptions);
+    pushCmd.setRefSpecs(new RefSpec((source != null ? source : "") + ":" + target));
+    if (pushTags) {
+      pushCmd.setPushTags();
+    }
+    Iterable<PushResult> r = pushCmd.call();
+    return Iterables.getOnlyElement(r);
+  }
+
+  public static void assertPushOk(PushResult result, String ref) {
+    RemoteRefUpdate rru = result.getRemoteUpdate(ref);
+    assertThat(rru.getStatus()).named(rru.toString()).isEqualTo(RemoteRefUpdate.Status.OK);
+  }
+
+  public static void assertPushRejected(PushResult result, String ref, String expectedMessage) {
+    RemoteRefUpdate rru = result.getRemoteUpdate(ref);
+    assertThat(rru.getStatus())
+        .named(rru.toString())
+        .isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(rru.getMessage()).isEqualTo(expectedMessage);
+  }
+
+  public static PushResult pushTag(TestRepository<?> testRepo, String tag) throws GitAPIException {
+    return pushTag(testRepo, tag, false);
+  }
+
+  public static PushResult pushTag(TestRepository<?> testRepo, String tag, boolean force)
+      throws GitAPIException {
+    PushCommand pushCmd = testRepo.git().push();
+    pushCmd.setForce(force);
+    pushCmd.setRefSpecs(new RefSpec("refs/tags/" + tag + ":refs/tags/" + tag));
+    Iterable<PushResult> r = pushCmd.call();
+    return Iterables.getOnlyElement(r);
+  }
+
+  public static Optional<String> getChangeId(TestRepository<?> tr, ObjectId id) throws IOException {
+    RevCommit c = tr.getRevWalk().parseCommit(id);
+    tr.getRevWalk().parseBody(c);
+    return Lists.reverse(c.getFooterLines(FooterConstants.CHANGE_ID)).stream().findFirst();
+  }
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GlobalPluginConfig.java b/java/com/google/gerrit/acceptance/GlobalPluginConfig.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GlobalPluginConfig.java
rename to java/com/google/gerrit/acceptance/GlobalPluginConfig.java
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GlobalPluginConfigs.java b/java/com/google/gerrit/acceptance/GlobalPluginConfigs.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GlobalPluginConfigs.java
rename to java/com/google/gerrit/acceptance/GlobalPluginConfigs.java
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpResponse.java b/java/com/google/gerrit/acceptance/HttpResponse.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpResponse.java
rename to java/com/google/gerrit/acceptance/HttpResponse.java
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpSession.java b/java/com/google/gerrit/acceptance/HttpSession.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpSession.java
rename to java/com/google/gerrit/acceptance/HttpSession.java
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
new file mode 100644
index 0000000..4b1211b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -0,0 +1,148 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.inject.Scopes.SINGLETON;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.config.TrackingFooters;
+import com.google.gerrit.server.config.TrackingFootersProvider;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.schema.DataSourceType;
+import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
+import com.google.gerrit.server.schema.ReviewDbFactory;
+import com.google.gerrit.server.schema.SchemaModule;
+import com.google.gerrit.server.schema.SchemaVersion;
+import com.google.gerrit.testing.InMemoryDatabase;
+import com.google.gerrit.testing.InMemoryH2Type;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Key;
+import com.google.inject.Provides;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
+import org.eclipse.jgit.lib.Config;
+
+class InMemoryTestingDatabaseModule extends LifecycleModule {
+  private final Config cfg;
+  private final Path sitePath;
+
+  InMemoryTestingDatabaseModule(Config cfg, Path sitePath) {
+    this.cfg = cfg;
+    this.sitePath = sitePath;
+    makeSiteDirs(sitePath);
+  }
+
+  @Override
+  protected void configure() {
+    bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
+
+    // TODO(dborowitz): Use jimfs.
+    bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
+
+    bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
+    bind(InMemoryRepositoryManager.class).in(SINGLETON);
+
+    bind(MetricMaker.class).to(DisabledMetricMaker.class);
+    bind(DataSourceType.class).to(InMemoryH2Type.class);
+
+    install(new NotesMigration.Module());
+    install(new GroupsMigration.Module());
+    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
+        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
+    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
+    bind(Key.get(schemaFactory, ReviewDbFactory.class)).to(InMemoryDatabase.class);
+    bind(InMemoryDatabase.class).in(SINGLETON);
+    bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
+
+    listener().to(CreateDatabase.class);
+
+    bind(SitePaths.class);
+    bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class).in(SINGLETON);
+
+    install(new SchemaModule());
+    bind(SchemaVersion.class).to(SchemaVersion.C);
+  }
+
+  @Provides
+  @Singleton
+  KeyPairProvider createHostKey() {
+    return getHostKeys();
+  }
+
+  private static SimpleGeneratorHostKeyProvider keys;
+
+  private static synchronized KeyPairProvider getHostKeys() {
+    if (keys == null) {
+      keys = new SimpleGeneratorHostKeyProvider();
+      keys.setAlgorithm("RSA");
+      keys.loadKeys();
+    }
+    return keys;
+  }
+
+  static class CreateDatabase implements LifecycleListener {
+    private final InMemoryDatabase mem;
+
+    @Inject
+    CreateDatabase(InMemoryDatabase mem) {
+      this.mem = mem;
+    }
+
+    @Override
+    public void start() {
+      try {
+        mem.create();
+      } catch (OrmException e) {
+        throw new OrmRuntimeException(e);
+      }
+    }
+
+    @Override
+    public void stop() {
+      mem.drop();
+    }
+  }
+
+  private static void makeSiteDirs(Path p) {
+    try {
+      Files.createDirectories(p.resolve("etc"));
+    } catch (IOException e) {
+      ProvisionException pe = new ProvisionException(e.getMessage());
+      pe.initCause(e);
+      throw pe;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/InProcessProtocol.java b/java/com/google/gerrit/acceptance/InProcessProtocol.java
new file mode 100644
index 0000000..629c6bd
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/InProcessProtocol.java
@@ -0,0 +1,369 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.InProcessProtocol.Context;
+import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.RemotePeer;
+import com.google.gerrit.server.RequestCleanup;
+import com.google.gerrit.server.config.GerritRequestModule;
+import com.google.gerrit.server.config.RequestScopedReviewDbProvider;
+import com.google.gerrit.server.git.ReceivePackInitializer;
+import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.UploadPackInitializer;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
+import com.google.gerrit.server.git.validators.UploadValidators;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestScopePropagator;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Key;
+import com.google.inject.Module;
+import com.google.inject.OutOfScopeException;
+import com.google.inject.Provider;
+import com.google.inject.Provides;
+import com.google.inject.Scope;
+import com.google.inject.servlet.RequestScoped;
+import com.google.inject.util.Providers;
+import java.io.IOException;
+import java.net.SocketAddress;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.PostReceiveHook;
+import org.eclipse.jgit.transport.PostReceiveHookChain;
+import org.eclipse.jgit.transport.PreUploadHook;
+import org.eclipse.jgit.transport.PreUploadHookChain;
+import org.eclipse.jgit.transport.ReceivePack;
+import org.eclipse.jgit.transport.TestProtocol;
+import org.eclipse.jgit.transport.UploadPack;
+import org.eclipse.jgit.transport.resolver.ReceivePackFactory;
+import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
+import org.eclipse.jgit.transport.resolver.UploadPackFactory;
+
+class InProcessProtocol extends TestProtocol<Context> {
+  static Module module() {
+    return new AbstractModule() {
+      @Override
+      public void configure() {
+        install(new GerritRequestModule());
+        bind(RequestScopePropagator.class).to(Propagator.class);
+        bindScope(RequestScoped.class, InProcessProtocol.REQUEST);
+      }
+
+      @Provides
+      @RemotePeer
+      SocketAddress getSocketAddress() {
+        // TODO(dborowitz): Could potentially fake this with thread ID or
+        // something.
+        throw new OutOfScopeException("No remote peer in acceptance tests");
+      }
+    };
+  }
+
+  private static final Scope REQUEST =
+      new Scope() {
+        @Override
+        public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
+          return new Provider<T>() {
+            @Override
+            public T get() {
+              Context ctx = current.get();
+              if (ctx == null) {
+                throw new OutOfScopeException("Not in TestProtocol scope");
+              }
+              return ctx.get(key, creator);
+            }
+
+            @Override
+            public String toString() {
+              return String.format("%s[%s]", creator, REQUEST);
+            }
+          };
+        }
+
+        @Override
+        public String toString() {
+          return "InProcessProtocol.REQUEST";
+        }
+      };
+
+  private static class Propagator extends ThreadLocalRequestScopePropagator<Context> {
+    @Inject
+    Propagator(
+        ThreadLocalRequestContext local,
+        Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
+      super(REQUEST, current, local, dbProviderProvider);
+    }
+
+    @Override
+    protected Context continuingContext(Context ctx) {
+      return ctx.newContinuingContext();
+    }
+  }
+
+  private static final ThreadLocal<Context> current = new ThreadLocal<>();
+
+  // TODO(dborowitz): Merge this with AcceptanceTestRequestScope.
+  /**
+   * Multi-purpose session/context object.
+   *
+   * <p>Confusingly, Gerrit has two ideas of what a "context" object is: one for Guice {@link
+   * RequestScoped}, and one for its own simplified version of request scoping using {@link
+   * ThreadLocalRequestContext}. This class provides both, in essence just delegating the {@code
+   * ThreadLocalRequestContext} scoping to the Guice scoping mechanism.
+   *
+   * <p>It is also used as the session type for {@code UploadPackFactory} and {@code
+   * ReceivePackFactory}, since, after all, it encapsulates all the information about a single
+   * request.
+   */
+  static class Context implements RequestContext {
+    private static final Key<RequestScopedReviewDbProvider> DB_KEY =
+        Key.get(RequestScopedReviewDbProvider.class);
+    private static final Key<RequestCleanup> RC_KEY = Key.get(RequestCleanup.class);
+    private static final Key<CurrentUser> USER_KEY = Key.get(CurrentUser.class);
+
+    private final SchemaFactory<ReviewDb> schemaFactory;
+    private final IdentifiedUser.GenericFactory userFactory;
+    private final Account.Id accountId;
+    private final Project.NameKey project;
+    private final RequestCleanup cleanup;
+    private final Map<Key<?>, Object> map;
+
+    Context(
+        SchemaFactory<ReviewDb> schemaFactory,
+        IdentifiedUser.GenericFactory userFactory,
+        Account.Id accountId,
+        Project.NameKey project) {
+      this.schemaFactory = schemaFactory;
+      this.userFactory = userFactory;
+      this.accountId = accountId;
+      this.project = project;
+      map = new HashMap<>();
+      cleanup = new RequestCleanup();
+      map.put(DB_KEY, new RequestScopedReviewDbProvider(schemaFactory, Providers.of(cleanup)));
+      map.put(RC_KEY, cleanup);
+
+      IdentifiedUser user = userFactory.create(accountId);
+      user.setAccessPath(AccessPath.GIT);
+      map.put(USER_KEY, user);
+    }
+
+    private Context newContinuingContext() {
+      return new Context(schemaFactory, userFactory, accountId, project);
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return get(USER_KEY, null);
+    }
+
+    @Override
+    public Provider<ReviewDb> getReviewDbProvider() {
+      return get(DB_KEY, null);
+    }
+
+    private synchronized <T> T get(Key<T> key, Provider<T> creator) {
+      @SuppressWarnings("unchecked")
+      T t = (T) map.get(key);
+      if (t == null) {
+        t = creator.get();
+        map.put(key, t);
+      }
+      return t;
+    }
+  }
+
+  private static class Upload implements UploadPackFactory<Context> {
+    private final Provider<CurrentUser> userProvider;
+    private final VisibleRefFilter.Factory refFilterFactory;
+    private final TransferConfig transferConfig;
+    private final DynamicSet<UploadPackInitializer> uploadPackInitializers;
+    private final DynamicSet<PreUploadHook> preUploadHooks;
+    private final UploadValidators.Factory uploadValidatorsFactory;
+    private final ThreadLocalRequestContext threadContext;
+    private final ProjectCache projectCache;
+    private final PermissionBackend permissionBackend;
+
+    @Inject
+    Upload(
+        Provider<CurrentUser> userProvider,
+        VisibleRefFilter.Factory refFilterFactory,
+        TransferConfig transferConfig,
+        DynamicSet<UploadPackInitializer> uploadPackInitializers,
+        DynamicSet<PreUploadHook> preUploadHooks,
+        UploadValidators.Factory uploadValidatorsFactory,
+        ThreadLocalRequestContext threadContext,
+        ProjectCache projectCache,
+        PermissionBackend permissionBackend) {
+      this.userProvider = userProvider;
+      this.refFilterFactory = refFilterFactory;
+      this.transferConfig = transferConfig;
+      this.uploadPackInitializers = uploadPackInitializers;
+      this.preUploadHooks = preUploadHooks;
+      this.uploadValidatorsFactory = uploadValidatorsFactory;
+      this.threadContext = threadContext;
+      this.projectCache = projectCache;
+      this.permissionBackend = permissionBackend;
+    }
+
+    @Override
+    public UploadPack create(Context req, Repository repo) throws ServiceNotAuthorizedException {
+      // Set the request context, but don't bother unsetting, since we don't
+      // have an easy way to run code when this instance is done being used.
+      // Each operation is run in its own thread, so we don't need to recover
+      // its original context anyway.
+      threadContext.setContext(req);
+      current.set(req);
+
+      try {
+        permissionBackend
+            .user(userProvider)
+            .project(req.project)
+            .check(ProjectPermission.RUN_UPLOAD_PACK);
+      } catch (AuthException e) {
+        throw new ServiceNotAuthorizedException();
+      } catch (PermissionBackendException e) {
+        throw new RuntimeException(e);
+      }
+
+      ProjectState projectState;
+      try {
+        projectState = projectCache.checkedGet(req.project);
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      }
+      if (projectState == null) {
+        throw new RuntimeException("can't load project state for " + req.project.get());
+      }
+      UploadPack up = new UploadPack(repo);
+      up.setPackConfig(transferConfig.getPackConfig());
+      up.setTimeout(transferConfig.getTimeout());
+      up.setAdvertiseRefsHook(refFilterFactory.create(projectState, repo));
+      List<PreUploadHook> hooks = Lists.newArrayList(preUploadHooks);
+      hooks.add(uploadValidatorsFactory.create(projectState.getProject(), repo, "localhost-test"));
+      up.setPreUploadHook(PreUploadHookChain.newChain(hooks));
+      for (UploadPackInitializer initializer : uploadPackInitializers) {
+        initializer.init(req.project, up);
+      }
+      return up;
+    }
+  }
+
+  private static class Receive implements ReceivePackFactory<Context> {
+    private final Provider<CurrentUser> userProvider;
+    private final ProjectCache projectCache;
+    private final AsyncReceiveCommits.Factory factory;
+    private final TransferConfig config;
+    private final DynamicSet<ReceivePackInitializer> receivePackInitializers;
+    private final DynamicSet<PostReceiveHook> postReceiveHooks;
+    private final ThreadLocalRequestContext threadContext;
+    private final PermissionBackend permissionBackend;
+
+    @Inject
+    Receive(
+        Provider<CurrentUser> userProvider,
+        ProjectCache projectCache,
+        AsyncReceiveCommits.Factory factory,
+        TransferConfig config,
+        DynamicSet<ReceivePackInitializer> receivePackInitializers,
+        DynamicSet<PostReceiveHook> postReceiveHooks,
+        ThreadLocalRequestContext threadContext,
+        PermissionBackend permissionBackend) {
+      this.userProvider = userProvider;
+      this.projectCache = projectCache;
+      this.factory = factory;
+      this.config = config;
+      this.receivePackInitializers = receivePackInitializers;
+      this.postReceiveHooks = postReceiveHooks;
+      this.threadContext = threadContext;
+      this.permissionBackend = permissionBackend;
+    }
+
+    @Override
+    public ReceivePack create(Context req, Repository db) throws ServiceNotAuthorizedException {
+      // Set the request context, but don't bother unsetting, since we don't
+      // have an easy way to run code when this instance is done being used.
+      // Each operation is run in its own thread, so we don't need to recover
+      // its original context anyway.
+      threadContext.setContext(req);
+      current.set(req);
+      try {
+        permissionBackend
+            .user(userProvider)
+            .project(req.project)
+            .check(ProjectPermission.RUN_RECEIVE_PACK);
+      } catch (AuthException e) {
+        throw new ServiceNotAuthorizedException();
+      } catch (PermissionBackendException e) {
+        throw new RuntimeException(e);
+      }
+      try {
+        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();
+        if (r != Capable.OK) {
+          throw new ServiceNotAuthorizedException();
+        }
+
+        rp.setRefLogIdent(identifiedUser.newRefLogIdent());
+        rp.setTimeout(config.getTimeout());
+        rp.setMaxObjectSizeLimit(config.getMaxObjectSizeLimit());
+
+        for (ReceivePackInitializer initializer : receivePackInitializers) {
+          initializer.init(projectState.getNameKey(), rp);
+        }
+
+        rp.setPostReceiveHook(PostReceiveHookChain.newChain(Lists.newArrayList(postReceiveHooks)));
+        return rp;
+      } catch (IOException | PermissionBackendException e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
+  @Inject
+  InProcessProtocol(Upload uploadPackFactory, Receive receivePackFactory) {
+    super(uploadPackFactory, receivePackFactory);
+  }
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java b/java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java
rename to java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfig.java b/java/com/google/gerrit/acceptance/MergeableFileBasedConfig.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfig.java
rename to java/com/google/gerrit/acceptance/MergeableFileBasedConfig.java
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/NoHttpd.java b/java/com/google/gerrit/acceptance/NoHttpd.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/NoHttpd.java
rename to java/com/google/gerrit/acceptance/NoHttpd.java
diff --git a/java/com/google/gerrit/acceptance/ProjectResetter.java b/java/com/google/gerrit/acceptance/ProjectResetter.java
new file mode 100644
index 0000000..637fb2a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ProjectResetter.java
@@ -0,0 +1,320 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.RefState;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.RefPatternMatcher;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Saves the states of given projects and resets the project states on close.
+ *
+ * <p>Saving the project states is done by saving the states of all refs in the project. On close
+ * those refs are reset to the saved states. Refs that were newly created are deleted.
+ *
+ * <p>By providing ref patterns per project it can be controlled which refs should be reset on
+ * close.
+ *
+ * <p>If resetting touches {@code refs/meta/config} branches the corresponding projects are evicted
+ * from the project cache.
+ *
+ * <p>If resetting touches user branches or the {@code refs/meta/external-ids} branch the
+ * corresponding accounts are evicted from the account cache and also if needed from the cache in
+ * {@link AccountCreator}.
+ *
+ * <p>At the moment this class has the following limitations:
+ *
+ * <ul>
+ *   <li>Resetting group branches doesn't evict the corresponding groups from the group cache.
+ *   <li>Changes are not reindexed if change meta refs are reset.
+ *   <li>Changes are not reindexed if starred-changes refs in All-Users are reset.
+ *   <li>If accounts are deleted changes may still refer to these accounts (e.g. as reviewers).
+ * </ul>
+ *
+ * Primarily this class is intended to reset the states of the All-Projects and All-Users projects
+ * after each test. These projects rarely contain changes and it's currently not a problem if these
+ * changes get stale. For creating changes each test gets a brand new project. Since this project is
+ * not used outside of the test method that creates it, it doesn't need to be reset.
+ */
+public class ProjectResetter implements AutoCloseable {
+  public static class Builder {
+    public interface Factory {
+      Builder builder();
+    }
+
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsersName;
+    @Nullable private final AccountCreator accountCreator;
+    @Nullable private final AccountCache accountCache;
+    @Nullable private final ProjectCache projectCache;
+
+    private final Multimap<Project.NameKey, String> refsByProject;
+
+    @Inject
+    public Builder(
+        GitRepositoryManager repoManager,
+        AllUsersName allUsersName,
+        @Nullable AccountCreator accountCreator,
+        @Nullable AccountCache accountCache,
+        @Nullable ProjectCache projectCache) {
+      this.repoManager = repoManager;
+      this.allUsersName = allUsersName;
+      this.accountCreator = accountCreator;
+      this.accountCache = accountCache;
+      this.projectCache = projectCache;
+      this.refsByProject = MultimapBuilder.hashKeys().arrayListValues().build();
+    }
+
+    public Builder reset(Project.NameKey project, String... refPatterns) {
+      List<String> refPatternList = Arrays.asList(refPatterns);
+      if (refPatternList.isEmpty()) {
+        refPatternList = ImmutableList.of(RefNames.REFS + "*");
+      }
+      refsByProject.putAll(project, refPatternList);
+      return this;
+    }
+
+    public ProjectResetter build() throws IOException {
+      return new ProjectResetter(
+          repoManager, allUsersName, accountCreator, accountCache, projectCache, refsByProject);
+    }
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  @Nullable private final AccountCreator accountCreator;
+  @Nullable private final AccountCache accountCache;
+  @Nullable private final ProjectCache projectCache;
+  private final Multimap<Project.NameKey, String> refsPatternByProject;
+  private final Multimap<Project.NameKey, RefState> savedRefStatesByProject;
+
+  private Multimap<Project.NameKey, String> keptRefsByProject;
+  private Multimap<Project.NameKey, String> restoredRefsByProject;
+  private Multimap<Project.NameKey, String> deletedRefsByProject;
+
+  private ProjectResetter(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @Nullable AccountCreator accountCreator,
+      @Nullable AccountCache accountCache,
+      @Nullable ProjectCache projectCache,
+      Multimap<Project.NameKey, String> refPatternByProject)
+      throws IOException {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.accountCreator = accountCreator;
+    this.accountCache = accountCache;
+    this.projectCache = projectCache;
+    this.refsPatternByProject = refPatternByProject;
+    this.savedRefStatesByProject = readRefStates();
+  }
+
+  @Override
+  public void close() throws Exception {
+    keptRefsByProject = MultimapBuilder.hashKeys().arrayListValues().build();
+    restoredRefsByProject = MultimapBuilder.hashKeys().arrayListValues().build();
+    deletedRefsByProject = MultimapBuilder.hashKeys().arrayListValues().build();
+
+    restoreRefs();
+    deleteNewlyCreatedRefs();
+    evictCachesAndReindex();
+  }
+
+  /** Read the states of all matching refs. */
+  private Multimap<Project.NameKey, RefState> readRefStates() throws IOException {
+    Multimap<Project.NameKey, RefState> refStatesByProject =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+    for (Map.Entry<Project.NameKey, Collection<String>> e :
+        refsPatternByProject.asMap().entrySet()) {
+      try (Repository repo = repoManager.openRepository(e.getKey())) {
+        Collection<Ref> refs = repo.getAllRefs().values();
+        for (String refPattern : e.getValue()) {
+          RefPatternMatcher matcher = RefPatternMatcher.getMatcher(refPattern);
+          for (Ref ref : refs) {
+            if (matcher.match(ref.getName(), null)) {
+              refStatesByProject.put(e.getKey(), RefState.create(ref.getName(), ref.getObjectId()));
+            }
+          }
+        }
+      }
+    }
+    return refStatesByProject;
+  }
+
+  private void restoreRefs() throws IOException {
+    for (Map.Entry<Project.NameKey, Collection<RefState>> e :
+        savedRefStatesByProject.asMap().entrySet()) {
+      try (Repository repo = repoManager.openRepository(e.getKey())) {
+        for (RefState refState : e.getValue()) {
+          if (refState.match(repo)) {
+            keptRefsByProject.put(e.getKey(), refState.ref());
+            continue;
+          }
+          Ref ref = repo.exactRef(refState.ref());
+          RefUpdate updateRef = repo.updateRef(refState.ref());
+          updateRef.setExpectedOldObjectId(ref != null ? ref.getObjectId() : ObjectId.zeroId());
+          updateRef.setNewObjectId(refState.id());
+          updateRef.setForceUpdate(true);
+          RefUpdate.Result result = updateRef.update();
+          checkState(
+              result == RefUpdate.Result.FORCED || result == RefUpdate.Result.NEW,
+              "resetting branch %s in %s failed",
+              refState.ref(),
+              e.getKey());
+          restoredRefsByProject.put(e.getKey(), refState.ref());
+        }
+      }
+    }
+  }
+
+  private void deleteNewlyCreatedRefs() throws IOException {
+    for (Map.Entry<Project.NameKey, Collection<String>> e :
+        refsPatternByProject.asMap().entrySet()) {
+      try (Repository repo = repoManager.openRepository(e.getKey())) {
+        Collection<Ref> nonRestoredRefs =
+            repo.getAllRefs()
+                .values()
+                .stream()
+                .filter(
+                    r ->
+                        !keptRefsByProject.containsEntry(e.getKey(), r.getName())
+                            && !restoredRefsByProject.containsEntry(e.getKey(), r.getName()))
+                .collect(toSet());
+        for (String refPattern : e.getValue()) {
+          RefPatternMatcher matcher = RefPatternMatcher.getMatcher(refPattern);
+          for (Ref ref : nonRestoredRefs) {
+            if (matcher.match(ref.getName(), null)
+                && !deletedRefsByProject.containsEntry(e.getKey(), ref.getName())) {
+              RefUpdate updateRef = repo.updateRef(ref.getName());
+              updateRef.setExpectedOldObjectId(ref.getObjectId());
+              updateRef.setNewObjectId(ObjectId.zeroId());
+              updateRef.setForceUpdate(true);
+              RefUpdate.Result result = updateRef.delete();
+              checkState(
+                  result == RefUpdate.Result.FORCED,
+                  "deleting branch %s in %s failed",
+                  ref.getName(),
+                  e.getKey());
+              deletedRefsByProject.put(e.getKey(), ref.getName());
+            }
+          }
+        }
+      }
+    }
+  }
+
+  private void evictCachesAndReindex() throws IOException {
+    evictAndReindexProjects();
+    evictAndReindexAccounts();
+
+    // TODO(ekempin): Evict groups from cache if group refs were modified.
+    // TODO(ekempin): Reindex changes if starred-changes refs in All-Users were modified.
+  }
+
+  /** Evict projects for which the config was changed. */
+  private void evictAndReindexProjects() throws IOException {
+    if (projectCache == null) {
+      return;
+    }
+
+    for (Project.NameKey project :
+        Sets.union(
+            projectsWithConfigChanges(restoredRefsByProject),
+            projectsWithConfigChanges(deletedRefsByProject))) {
+      projectCache.evict(project);
+    }
+  }
+
+  private Set<Project.NameKey> projectsWithConfigChanges(
+      Multimap<Project.NameKey, String> projects) {
+    return projects
+        .entries()
+        .stream()
+        .filter(e -> e.getValue().equals(RefNames.REFS_CONFIG))
+        .map(Map.Entry::getKey)
+        .collect(toSet());
+  }
+
+  /** Evict accounts that were modified. */
+  private void evictAndReindexAccounts() throws IOException {
+    Set<Account.Id> deletedAccounts = accountIds(deletedRefsByProject.get(allUsersName));
+    if (accountCreator != null) {
+      accountCreator.evict(deletedAccounts);
+    }
+    if (accountCache != null) {
+      Set<Account.Id> modifiedAccounts =
+          new HashSet<>(accountIds(restoredRefsByProject.get(allUsersName)));
+
+      if (restoredRefsByProject.get(allUsersName).contains(RefNames.REFS_EXTERNAL_IDS)
+          || deletedRefsByProject.get(allUsersName).contains(RefNames.REFS_EXTERNAL_IDS)) {
+        // The external IDs have been modified but we don't know which accounts were affected.
+        // Make sure all accounts are evicted and reindexed.
+        try (Repository repo = repoManager.openRepository(allUsersName)) {
+          for (Account.Id id :
+              accountIds(
+                  repo.getAllRefs().values().stream().map(r -> r.getName()).collect(toSet()))) {
+            accountCache.evict(id);
+          }
+        }
+
+        // Remove deleted accounts from the cache and index.
+        for (Account.Id id : deletedAccounts) {
+          accountCache.evict(id);
+        }
+      } else {
+        // Evict and reindex all modified and deleted accounts.
+        for (Account.Id id : Sets.union(modifiedAccounts, deletedAccounts)) {
+          accountCache.evict(id);
+        }
+      }
+    }
+  }
+
+  private Set<Account.Id> accountIds(Collection<String> refs) {
+    return refs.stream()
+        .filter(r -> r.startsWith(REFS_USERS))
+        .map(r -> Account.Id.fromRef(r))
+        .filter(Objects::nonNull)
+        .collect(toSet());
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
new file mode 100644
index 0000000..5e45df2
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -0,0 +1,513 @@
+// 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;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
+import org.eclipse.jgit.api.TagCommand;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
+
+public class PushOneCommit {
+  public static final String SUBJECT = "test commit";
+  public static final String FILE_NAME = "a.txt";
+  public static final String FILE_CONTENT = "some content";
+  public static final String PATCH_FILE_ONLY =
+      "diff --git a/a.txt b/a.txt\n"
+          + "new file mode 100644\n"
+          + "index 0000000..f0eec86\n"
+          + "--- /dev/null\n"
+          + "+++ b/a.txt\n"
+          + "@@ -0,0 +1 @@\n"
+          + "+some content\n"
+          + "\\ No newline at end of file\n";
+  public static final String PATCH =
+      "From %s Mon Sep 17 00:00:00 2001\n"
+          + "From: Administrator <admin@example.com>\n"
+          + "Date: %s\n"
+          + "Subject: [PATCH] test commit\n"
+          + "\n"
+          + "Change-Id: %s\n"
+          + "---\n"
+          + "\n"
+          + PATCH_FILE_ONLY;
+
+  public interface Factory {
+    PushOneCommit create(ReviewDb db, PersonIdent i, TestRepository<?> testRepo);
+
+    PushOneCommit create(
+        ReviewDb db,
+        PersonIdent i,
+        TestRepository<?> testRepo,
+        @Assisted("changeId") String changeId);
+
+    PushOneCommit create(
+        ReviewDb db,
+        PersonIdent i,
+        TestRepository<?> testRepo,
+        @Assisted("subject") String subject,
+        @Assisted("fileName") String fileName,
+        @Assisted("content") String content);
+
+    PushOneCommit create(
+        ReviewDb db,
+        PersonIdent i,
+        TestRepository<?> testRepo,
+        @Assisted String subject,
+        @Assisted Map<String, String> files);
+
+    PushOneCommit create(
+        ReviewDb db,
+        PersonIdent i,
+        TestRepository<?> testRepo,
+        @Assisted("subject") String subject,
+        @Assisted("fileName") String fileName,
+        @Assisted("content") String content,
+        @Assisted("changeId") String changeId);
+  }
+
+  public static class Tag {
+    public String name;
+
+    public Tag(String name) {
+      this.name = name;
+    }
+  }
+
+  public static class AnnotatedTag extends Tag {
+    public String message;
+    public PersonIdent tagger;
+
+    public AnnotatedTag(String name, String message, PersonIdent tagger) {
+      super(name);
+      this.message = message;
+      this.tagger = tagger;
+    }
+  }
+
+  private static final AtomicInteger CHANGE_ID_COUNTER = new AtomicInteger();
+
+  private static String nextChangeId() {
+    // Tests use a variety of mechanisms for setting temporary timestamps, so we can't guarantee
+    // that the PersonIdent (or any other field used by the Change-Id generator) for any two test
+    // methods in the same acceptance test class are going to be different. But tests generally
+    // assume that Change-Ids are unique unless otherwise specified. So, don't even bother trying to
+    // reuse JGit's Change-Id generator, just do the simplest possible thing and convert a counter
+    // to hex.
+    return String.format("%040x", CHANGE_ID_COUNTER.incrementAndGet());
+  }
+
+  private final ChangeNotes.Factory notesFactory;
+  private final ApprovalsUtil approvalsUtil;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final NotesMigration notesMigration;
+  private final ReviewDb db;
+  private final TestRepository<?> testRepo;
+
+  private final String subject;
+  private final Map<String, String> files;
+  private String changeId;
+  private Tag tag;
+  private boolean force;
+  private List<String> pushOptions;
+
+  private final TestRepository<?>.CommitBuilder commitBuilder;
+
+  @AssistedInject
+  PushOneCommit(
+      ChangeNotes.Factory notesFactory,
+      ApprovalsUtil approvalsUtil,
+      Provider<InternalChangeQuery> queryProvider,
+      NotesMigration notesMigration,
+      @Assisted ReviewDb db,
+      @Assisted PersonIdent i,
+      @Assisted TestRepository<?> testRepo)
+      throws Exception {
+    this(
+        notesFactory,
+        approvalsUtil,
+        queryProvider,
+        notesMigration,
+        db,
+        i,
+        testRepo,
+        SUBJECT,
+        FILE_NAME,
+        FILE_CONTENT);
+  }
+
+  @AssistedInject
+  PushOneCommit(
+      ChangeNotes.Factory notesFactory,
+      ApprovalsUtil approvalsUtil,
+      Provider<InternalChangeQuery> queryProvider,
+      NotesMigration notesMigration,
+      @Assisted ReviewDb db,
+      @Assisted PersonIdent i,
+      @Assisted TestRepository<?> testRepo,
+      @Assisted("changeId") String changeId)
+      throws Exception {
+    this(
+        notesFactory,
+        approvalsUtil,
+        queryProvider,
+        notesMigration,
+        db,
+        i,
+        testRepo,
+        SUBJECT,
+        FILE_NAME,
+        FILE_CONTENT,
+        changeId);
+  }
+
+  @AssistedInject
+  PushOneCommit(
+      ChangeNotes.Factory notesFactory,
+      ApprovalsUtil approvalsUtil,
+      Provider<InternalChangeQuery> queryProvider,
+      NotesMigration notesMigration,
+      @Assisted ReviewDb db,
+      @Assisted PersonIdent i,
+      @Assisted TestRepository<?> testRepo,
+      @Assisted("subject") String subject,
+      @Assisted("fileName") String fileName,
+      @Assisted("content") String content)
+      throws Exception {
+    this(
+        notesFactory,
+        approvalsUtil,
+        queryProvider,
+        notesMigration,
+        db,
+        i,
+        testRepo,
+        subject,
+        fileName,
+        content,
+        null);
+  }
+
+  @AssistedInject
+  PushOneCommit(
+      ChangeNotes.Factory notesFactory,
+      ApprovalsUtil approvalsUtil,
+      Provider<InternalChangeQuery> queryProvider,
+      NotesMigration notesMigration,
+      @Assisted ReviewDb db,
+      @Assisted PersonIdent i,
+      @Assisted TestRepository<?> testRepo,
+      @Assisted String subject,
+      @Assisted Map<String, String> files)
+      throws Exception {
+    this(
+        notesFactory,
+        approvalsUtil,
+        queryProvider,
+        notesMigration,
+        db,
+        i,
+        testRepo,
+        subject,
+        files,
+        null);
+  }
+
+  @AssistedInject
+  PushOneCommit(
+      ChangeNotes.Factory notesFactory,
+      ApprovalsUtil approvalsUtil,
+      Provider<InternalChangeQuery> queryProvider,
+      NotesMigration notesMigration,
+      @Assisted ReviewDb db,
+      @Assisted PersonIdent i,
+      @Assisted TestRepository<?> testRepo,
+      @Assisted("subject") String subject,
+      @Assisted("fileName") String fileName,
+      @Assisted("content") String content,
+      @Nullable @Assisted("changeId") String changeId)
+      throws Exception {
+    this(
+        notesFactory,
+        approvalsUtil,
+        queryProvider,
+        notesMigration,
+        db,
+        i,
+        testRepo,
+        subject,
+        ImmutableMap.of(fileName, content),
+        changeId);
+  }
+
+  private PushOneCommit(
+      ChangeNotes.Factory notesFactory,
+      ApprovalsUtil approvalsUtil,
+      Provider<InternalChangeQuery> queryProvider,
+      NotesMigration notesMigration,
+      ReviewDb db,
+      PersonIdent i,
+      TestRepository<?> testRepo,
+      String subject,
+      Map<String, String> files,
+      String changeId)
+      throws Exception {
+    this.db = db;
+    this.testRepo = testRepo;
+    this.notesFactory = notesFactory;
+    this.approvalsUtil = approvalsUtil;
+    this.queryProvider = queryProvider;
+    this.notesMigration = notesMigration;
+    this.subject = subject;
+    this.files = files;
+    this.changeId = changeId;
+    if (changeId != null) {
+      commitBuilder = testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
+    } else {
+      commitBuilder = testRepo.branch("HEAD").commit().insertChangeId(nextChangeId());
+    }
+    commitBuilder.message(subject).author(i).committer(new PersonIdent(i, testRepo.getDate()));
+  }
+
+  public PushOneCommit setParents(List<RevCommit> parents) throws Exception {
+    commitBuilder.noParents();
+    for (RevCommit p : parents) {
+      commitBuilder.parent(p);
+    }
+    return this;
+  }
+
+  public PushOneCommit setParent(RevCommit parent) throws Exception {
+    commitBuilder.noParents();
+    commitBuilder.parent(parent);
+    return this;
+  }
+
+  public Result to(String ref) throws Exception {
+    for (Map.Entry<String, String> e : files.entrySet()) {
+      commitBuilder.add(e.getKey(), e.getValue());
+    }
+    return execute(ref);
+  }
+
+  public Result rm(String ref) throws Exception {
+    for (String fileName : files.keySet()) {
+      commitBuilder.rm(fileName);
+    }
+    return execute(ref);
+  }
+
+  public Result execute(String ref) throws Exception {
+    RevCommit c = commitBuilder.create();
+    if (changeId == null) {
+      changeId = GitUtil.getChangeId(testRepo, c).get();
+    }
+    if (tag != null) {
+      TagCommand tagCommand = testRepo.git().tag().setName(tag.name);
+      if (tag instanceof AnnotatedTag) {
+        AnnotatedTag annotatedTag = (AnnotatedTag) tag;
+        tagCommand
+            .setAnnotated(true)
+            .setMessage(annotatedTag.message)
+            .setTagger(annotatedTag.tagger);
+      } else {
+        tagCommand.setAnnotated(false);
+      }
+      tagCommand.call();
+    }
+    return new Result(ref, pushHead(testRepo, ref, tag != null, force, pushOptions), c, subject);
+  }
+
+  public void setTag(Tag tag) {
+    this.tag = tag;
+  }
+
+  public void setForce(boolean force) {
+    this.force = force;
+  }
+
+  public List<String> getPushOptions() {
+    return pushOptions;
+  }
+
+  public void setPushOptions(List<String> pushOptions) {
+    this.pushOptions = pushOptions;
+  }
+
+  public void noParents() {
+    commitBuilder.noParents();
+  }
+
+  public class Result {
+    private final String ref;
+    private final PushResult result;
+    private final RevCommit commit;
+    private final String resSubj;
+
+    private Result(String ref, PushResult resSubj, RevCommit commit, String subject) {
+      this.ref = ref;
+      this.result = resSubj;
+      this.commit = commit;
+      this.resSubj = subject;
+    }
+
+    public ChangeData getChange() throws OrmException {
+      return Iterables.getOnlyElement(queryProvider.get().byKeyPrefix(changeId));
+    }
+
+    public PatchSet getPatchSet() throws OrmException {
+      return getChange().currentPatchSet();
+    }
+
+    public PatchSet.Id getPatchSetId() throws OrmException {
+      return getChange().change().currentPatchSetId();
+    }
+
+    public String getChangeId() {
+      return changeId;
+    }
+
+    public RevCommit getCommit() {
+      return commit;
+    }
+
+    public void assertPushOptions(List<String> pushOptions) {
+      assertEquals(pushOptions, getPushOptions());
+    }
+
+    public void assertChange(
+        Change.Status expectedStatus, String expectedTopic, TestAccount... expectedReviewers)
+        throws OrmException {
+      assertChange(
+          expectedStatus, expectedTopic, Arrays.asList(expectedReviewers), ImmutableList.of());
+    }
+
+    public void assertChange(
+        Change.Status expectedStatus,
+        String expectedTopic,
+        List<TestAccount> expectedReviewers,
+        List<TestAccount> expectedCcs)
+        throws OrmException {
+      Change c = getChange().change();
+      assertThat(c.getSubject()).isEqualTo(resSubj);
+      assertThat(c.getStatus()).isEqualTo(expectedStatus);
+      assertThat(Strings.emptyToNull(c.getTopic())).isEqualTo(expectedTopic);
+      if (notesMigration.readChanges()) {
+        assertReviewers(c, ReviewerStateInternal.REVIEWER, expectedReviewers);
+        assertReviewers(c, ReviewerStateInternal.CC, expectedCcs);
+      } else {
+        assertReviewers(
+            c,
+            ReviewerStateInternal.REVIEWER,
+            Stream.concat(expectedReviewers.stream(), expectedCcs.stream()).collect(toList()));
+      }
+    }
+
+    private void assertReviewers(
+        Change c, ReviewerStateInternal state, List<TestAccount> expectedReviewers)
+        throws OrmException {
+      Iterable<Account.Id> actualIds =
+          approvalsUtil.getReviewers(db, notesFactory.createChecked(db, c)).byState(state);
+      assertThat(actualIds)
+          .containsExactlyElementsIn(Sets.newHashSet(TestAccount.ids(expectedReviewers)));
+    }
+
+    public void assertOkStatus() {
+      assertStatus(Status.OK, null);
+    }
+
+    public void assertErrorStatus(String expectedMessage) {
+      assertStatus(Status.REJECTED_OTHER_REASON, expectedMessage);
+    }
+
+    public void assertErrorStatus() {
+      RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
+      assertThat(refUpdate).isNotNull();
+      assertThat(refUpdate.getStatus())
+          .named(message(refUpdate))
+          .isEqualTo(Status.REJECTED_OTHER_REASON);
+    }
+
+    private void assertStatus(Status expectedStatus, String expectedMessage) {
+      RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
+      assertThat(refUpdate).isNotNull();
+      assertThat(refUpdate.getStatus()).named(message(refUpdate)).isEqualTo(expectedStatus);
+      if (expectedMessage == null) {
+        assertThat(refUpdate.getMessage()).isNull();
+      } else {
+        assertThat(refUpdate.getMessage()).contains(expectedMessage);
+      }
+    }
+
+    public void assertMessage(String expectedMessage) {
+      RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
+      assertThat(refUpdate).isNotNull();
+      assertThat(message(refUpdate).toLowerCase()).contains(expectedMessage.toLowerCase());
+    }
+
+    public void assertNotMessage(String message) {
+      RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
+      assertThat(message(refUpdate).toLowerCase()).doesNotContain(message.toLowerCase());
+    }
+
+    public String getMessage() {
+      RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
+      assertThat(refUpdate).isNotNull();
+      return message(refUpdate);
+    }
+
+    private String message(RemoteRefUpdate refUpdate) {
+      StringBuilder b = new StringBuilder();
+      if (refUpdate.getMessage() != null) {
+        b.append(refUpdate.getMessage());
+        b.append("\n");
+      }
+      b.append(result.getMessages());
+      return b.toString();
+    }
+  }
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java b/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
rename to java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestResponse.java b/java/com/google/gerrit/acceptance/RestResponse.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestResponse.java
rename to java/com/google/gerrit/acceptance/RestResponse.java
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java b/java/com/google/gerrit/acceptance/RestSession.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java
rename to java/com/google/gerrit/acceptance/RestSession.java
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/Sandboxed.java b/java/com/google/gerrit/acceptance/Sandboxed.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/Sandboxed.java
rename to java/com/google/gerrit/acceptance/Sandboxed.java
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshEnabled.java b/java/com/google/gerrit/acceptance/SshEnabled.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshEnabled.java
rename to java/com/google/gerrit/acceptance/SshEnabled.java
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshSession.java b/java/com/google/gerrit/acceptance/SshSession.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshSession.java
rename to java/com/google/gerrit/acceptance/SshSession.java
diff --git a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
new file mode 100644
index 0000000..8790e78
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
@@ -0,0 +1,166 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.joining;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.launcher.GerritLauncher;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.SitePaths;
+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.testing.ConfigSuite;
+import com.google.inject.Injector;
+import com.google.inject.Module;
+import com.google.inject.Provider;
+import java.util.Arrays;
+import java.util.Collections;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Rule;
+import org.junit.rules.RuleChain;
+import org.junit.rules.TemporaryFolder;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runner.RunWith;
+import org.junit.runners.model.Statement;
+
+@RunWith(ConfigSuite.class)
+@UseLocalDisk
+public abstract class StandaloneSiteTest {
+  protected class ServerContext implements RequestContext, AutoCloseable {
+    private final GerritServer server;
+    private final ManualRequestContext ctx;
+
+    private ServerContext(GerritServer server) throws Exception {
+      this.server = server;
+      Injector i = server.getTestInjector();
+      if (adminId == null) {
+        adminId = i.getInstance(AccountCreator.class).admin().getId();
+      }
+      ctx = i.getInstance(OneOffRequestContext.class).openAs(adminId);
+      GerritApi gApi = i.getInstance(GerritApi.class);
+
+      try {
+        // ServerContext ctor is called multiple times but the group can be only created once
+        gApi.groups().id("Group");
+      } catch (ResourceNotFoundException e) {
+        GroupInput in = new GroupInput();
+        in.members = Collections.singletonList("admin");
+        in.name = "Group";
+        gApi.groups().create(in);
+      }
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return ctx.getUser();
+    }
+
+    @Override
+    public Provider<ReviewDb> getReviewDbProvider() {
+      return ctx.getReviewDbProvider();
+    }
+
+    public Injector getInjector() {
+      return server.getTestInjector();
+    }
+
+    @Override
+    public void close() throws Exception {
+      try {
+        ctx.close();
+      } finally {
+        server.close();
+      }
+    }
+  }
+
+  @ConfigSuite.Parameter public Config baseConfig;
+  @ConfigSuite.Name private String configName;
+
+  private final TemporaryFolder tempSiteDir = new TemporaryFolder();
+
+  private final TestRule testRunner =
+      new TestRule() {
+        @Override
+        public Statement apply(Statement base, Description description) {
+          return new Statement() {
+            @Override
+            public void evaluate() throws Throwable {
+              beforeTest(description);
+              base.evaluate();
+            }
+          };
+        }
+      };
+
+  @Rule public RuleChain ruleChain = RuleChain.outerRule(tempSiteDir).around(testRunner);
+
+  protected SitePaths sitePaths;
+  protected Account.Id adminId;
+
+  private GerritServer.Description serverDesc;
+
+  private void beforeTest(Description description) throws Exception {
+    serverDesc = GerritServer.Description.forTestMethod(description, configName);
+    sitePaths = new SitePaths(tempSiteDir.getRoot().toPath());
+    GerritServer.init(serverDesc, baseConfig, sitePaths.site_path);
+  }
+
+  protected ServerContext startServer() throws Exception {
+    return startServer(null);
+  }
+
+  protected ServerContext startServer(@Nullable Module testSysModule, String... additionalArgs)
+      throws Exception {
+    return new ServerContext(startImpl(testSysModule, additionalArgs));
+  }
+
+  protected void assertServerStartupFails() throws Exception {
+    try (GerritServer server = startImpl(null)) {
+      fail("expected server startup to fail");
+    } catch (GerritServer.StartupException e) {
+      // Expected.
+    }
+  }
+
+  private GerritServer startImpl(@Nullable Module testSysModule, String... additionalArgs)
+      throws Exception {
+    return GerritServer.start(
+        serverDesc, baseConfig, sitePaths.site_path, testSysModule, additionalArgs);
+  }
+
+  protected static void runGerrit(String... args) throws Exception {
+    assertThat(GerritLauncher.mainImpl(args))
+        .named("gerrit.war " + Arrays.stream(args).collect(joining(" ")))
+        .isEqualTo(0);
+  }
+
+  @SafeVarargs
+  protected static void runGerrit(Iterable<String>... multiArgs) throws Exception {
+    runGerrit(
+        Arrays.stream(multiArgs).flatMap(args -> Streams.stream(args)).toArray(String[]::new));
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/TestAccount.java b/java/com/google/gerrit/acceptance/TestAccount.java
new file mode 100644
index 0000000..3563ca1
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/TestAccount.java
@@ -0,0 +1,90 @@
+// 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;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.net.InetAddresses;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.mail.Address;
+import com.jcraft.jsch.KeyPair;
+import java.io.ByteArrayOutputStream;
+import java.net.InetSocketAddress;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.http.client.utils.URIBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+
+public class TestAccount {
+  public static List<Account.Id> ids(List<TestAccount> accounts) {
+    return accounts.stream().map(a -> a.id).collect(toList());
+  }
+
+  public static List<String> names(List<TestAccount> accounts) {
+    return accounts.stream().map(a -> a.fullName).collect(toList());
+  }
+
+  public static List<String> names(TestAccount... accounts) {
+    return names(Arrays.asList(accounts));
+  }
+
+  public final Account.Id id;
+  public final String username;
+  public final String email;
+  public final Address emailAddress;
+  public final String fullName;
+  public final KeyPair sshKey;
+  public final String httpPassword;
+
+  TestAccount(
+      Account.Id id,
+      String username,
+      String email,
+      String fullName,
+      KeyPair sshKey,
+      String httpPassword) {
+    this.id = id;
+    this.username = username;
+    this.email = email;
+    this.emailAddress = new Address(fullName, email);
+    this.fullName = fullName;
+    this.sshKey = sshKey;
+    this.httpPassword = httpPassword;
+  }
+
+  public byte[] privateKey() {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    sshKey.writePrivateKey(out);
+    return out.toByteArray();
+  }
+
+  public PersonIdent getIdent() {
+    return new PersonIdent(fullName, email);
+  }
+
+  public String getHttpUrl(GerritServer server) {
+    InetSocketAddress addr = server.getHttpAddress();
+    return new URIBuilder()
+        .setScheme("http")
+        .setUserInfo(username, httpPassword)
+        .setHost(InetAddresses.toUriString(addr.getAddress()))
+        .setPort(addr.getPort())
+        .toString();
+  }
+
+  public Account.Id getId() {
+    return id;
+  }
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestPlugin.java b/java/com/google/gerrit/acceptance/TestPlugin.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestPlugin.java
rename to java/com/google/gerrit/acceptance/TestPlugin.java
diff --git a/java/com/google/gerrit/acceptance/TestProjectInput.java b/java/com/google/gerrit/acceptance/TestProjectInput.java
new file mode 100644
index 0000000..eada6434
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/TestProjectInput.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.SubmitType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target({METHOD})
+@Retention(RUNTIME)
+public @interface TestProjectInput {
+  // Fields from ProjectInput for creating the project.
+
+  String parent() default "";
+
+  boolean createEmptyCommit() default true;
+
+  String description() default "";
+
+  // These may be null in a ProjectInput, but annotations do not allow null
+  // default values. Thus these defaults should match ProjectConfig.
+  SubmitType submitType() default SubmitType.MERGE_IF_NECESSARY;
+
+  InheritableBoolean useContributorAgreements() default InheritableBoolean.INHERIT;
+
+  InheritableBoolean useSignedOffBy() default InheritableBoolean.INHERIT;
+
+  InheritableBoolean useContentMerge() default InheritableBoolean.INHERIT;
+
+  InheritableBoolean requireChangeId() default InheritableBoolean.INHERIT;
+
+  InheritableBoolean rejectEmptyCommit() default InheritableBoolean.INHERIT;
+
+  // Fields specific to acceptance test behavior.
+
+  /** Username to use for initial clone, passed to {@link AccountCreator}. */
+  String cloneAs() default "admin";
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/UseLocalDisk.java b/java/com/google/gerrit/acceptance/UseLocalDisk.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/UseLocalDisk.java
rename to java/com/google/gerrit/acceptance/UseLocalDisk.java
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/UseSsh.java b/java/com/google/gerrit/acceptance/UseSsh.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/UseSsh.java
rename to java/com/google/gerrit/acceptance/UseSsh.java
diff --git a/java/com/google/gerrit/common/BUILD b/java/com/google/gerrit/common/BUILD
new file mode 100644
index 0000000..919f532
--- /dev/null
+++ b/java/com/google/gerrit/common/BUILD
@@ -0,0 +1,68 @@
+load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//tools/bzl:gwt.bzl", "gwt_module")
+
+ANNOTATIONS = [
+    "Nullable.java",
+    "audit/Audit.java",
+    "auth/SignInRequired.java",
+]
+
+java_library(
+    name = "annotations",
+    srcs = ANNOTATIONS,
+    visibility = ["//visibility:public"],
+)
+
+gwt_module(
+    name = "client",
+    srcs = glob(["**/*.java"]),
+    exported_deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/prettify:client",
+        "//lib:guava",
+        "//lib:gwtorm_client",
+        "//lib:servlet-api-3_1",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+    gwt_xml = "Common.gwt.xml",
+    visibility = ["//visibility:public"],
+)
+
+java_library(
+    name = "server",
+    srcs = glob(
+        ["**/*.java"],
+        exclude = ANNOTATIONS,
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":annotations",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/prettify:server",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/org/eclipse/jgit:server",
+        "//lib:guava",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtorm",
+        "//lib:servlet-api-3_1",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
+
+# ":version" should not be in the dependency graph of the acceptance
+# tests to avoid spurious test re-runs. That's because the content of
+# //:version.txt is changed when the outcome of `git describe` is changed.
+java_library(
+    name = "version",
+    resources = [":Version"],
+    visibility = ["//visibility:public"],
+)
+
+genrule(
+    name = "gen_version",
+    srcs = ["//:version.txt"],
+    outs = ["Version"],
+    cmd = "cat $< > $@",
+)
diff --git a/java/com/google/gerrit/common/Common.gwt.xml b/java/com/google/gerrit/common/Common.gwt.xml
new file mode 100644
index 0000000..fede665
--- /dev/null
+++ b/java/com/google/gerrit/common/Common.gwt.xml
@@ -0,0 +1,23 @@
+<!--
+ Copyright (C) 2009 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<module>
+  <inherits name='com.google.gerrit.reviewdb.ReviewDB' />
+  <inherits name='com.google.gwtjsonrpc.GWTJSONRPC'/>
+  <inherits name="com.google.gwt.logging.Logging"/>
+  <source path="">
+    <include name='**/*.java'/>
+  </source>
+</module>
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/Die.java b/java/com/google/gerrit/common/Die.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/Die.java
rename to java/com/google/gerrit/common/Die.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/FileUtil.java b/java/com/google/gerrit/common/FileUtil.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/FileUtil.java
rename to java/com/google/gerrit/common/FileUtil.java
diff --git a/java/com/google/gerrit/common/FooterConstants.java b/java/com/google/gerrit/common/FooterConstants.java
new file mode 100644
index 0000000..d76c92b
--- /dev/null
+++ b/java/com/google/gerrit/common/FooterConstants.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common;
+
+import com.google.common.annotations.GwtIncompatible;
+import org.eclipse.jgit.revwalk.FooterKey;
+
+@GwtIncompatible("Unemulated com.google.gerrit.common.FooterConstants")
+public class FooterConstants {
+  /** The change ID as used to track patch sets. */
+  public static final FooterKey CHANGE_ID = new FooterKey("Change-Id");
+
+  /** The footer telling us who reviewed the change. */
+  public static final FooterKey REVIEWED_BY = new FooterKey("Reviewed-by");
+
+  /** The footer telling us the URL where the review took place. */
+  public static final FooterKey REVIEWED_ON = new FooterKey("Reviewed-on");
+
+  /** The footer telling us who tested the change. */
+  public static final FooterKey TESTED_BY = new FooterKey("Tested-by");
+}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/FormatUtil.java b/java/com/google/gerrit/common/FormatUtil.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/FormatUtil.java
rename to java/com/google/gerrit/common/FormatUtil.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/IoUtil.java b/java/com/google/gerrit/common/IoUtil.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/IoUtil.java
rename to java/com/google/gerrit/common/IoUtil.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/Nullable.java b/java/com/google/gerrit/common/Nullable.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/Nullable.java
rename to java/com/google/gerrit/common/Nullable.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java b/java/com/google/gerrit/common/PageLinks.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
rename to java/com/google/gerrit/common/PageLinks.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/PluginData.java b/java/com/google/gerrit/common/PluginData.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/PluginData.java
rename to java/com/google/gerrit/common/PluginData.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/ProjectAccessUtil.java b/java/com/google/gerrit/common/ProjectAccessUtil.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/ProjectAccessUtil.java
rename to java/com/google/gerrit/common/ProjectAccessUtil.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/ProjectUtil.java b/java/com/google/gerrit/common/ProjectUtil.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/ProjectUtil.java
rename to java/com/google/gerrit/common/ProjectUtil.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/RawInputUtil.java b/java/com/google/gerrit/common/RawInputUtil.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/RawInputUtil.java
rename to java/com/google/gerrit/common/RawInputUtil.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java b/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
rename to java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
diff --git a/java/com/google/gerrit/common/TimeUtil.java b/java/com/google/gerrit/common/TimeUtil.java
new file mode 100644
index 0000000..7f53f84
--- /dev/null
+++ b/java/com/google/gerrit/common/TimeUtil.java
@@ -0,0 +1,107 @@
+// 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.common;
+
+import com.google.common.annotations.GwtIncompatible;
+import com.google.common.annotations.VisibleForTesting;
+import java.sql.Timestamp;
+import java.util.function.LongSupplier;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.SystemReader;
+
+/** Static utility methods for dealing with dates and times. */
+@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() {
+    // 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() {
+    return new Timestamp(nowMs());
+  }
+
+  public static Timestamp truncateToSecond(Timestamp t) {
+    return new Timestamp((t.getTime() / 1000) * 1000);
+  }
+
+  @VisibleForTesting
+  public static void setCurrentMillisSupplier(LongSupplier customCurrentMillisSupplier) {
+    currentMillisSupplier = customCurrentMillisSupplier;
+
+    SystemReader oldSystemReader = SystemReader.getInstance();
+    if (!(oldSystemReader instanceof GerritSystemReader)) {
+      SystemReader.setInstance(new GerritSystemReader(oldSystemReader));
+    }
+  }
+
+  @VisibleForTesting
+  public static void resetCurrentMillisSupplier() {
+    currentMillisSupplier = SYSTEM_CURRENT_MILLIS_SUPPLIER;
+    SystemReader.setInstance(null);
+  }
+
+  private static class GerritSystemReader extends SystemReader {
+    SystemReader delegate;
+
+    GerritSystemReader(SystemReader delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public String getHostname() {
+      return delegate.getHostname();
+    }
+
+    @Override
+    public String getenv(String variable) {
+      return delegate.getenv(variable);
+    }
+
+    @Override
+    public String getProperty(String key) {
+      return delegate.getProperty(key);
+    }
+
+    @Override
+    public FileBasedConfig openUserConfig(Config parent, FS fs) {
+      return delegate.openUserConfig(parent, fs);
+    }
+
+    @Override
+    public FileBasedConfig openSystemConfig(Config parent, FS fs) {
+      return delegate.openSystemConfig(parent, fs);
+    }
+
+    @Override
+    public long getCurrentTime() {
+      return currentMillisSupplier.getAsLong();
+    }
+
+    @Override
+    public int getTimezone(long when) {
+      return delegate.getTimezone(when);
+    }
+  }
+
+  private TimeUtil() {}
+}
diff --git a/java/com/google/gerrit/common/Version.java b/java/com/google/gerrit/common/Version.java
new file mode 100644
index 0000000..1777c3c
--- /dev/null
+++ b/java/com/google/gerrit/common/Version.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.annotations.GwtIncompatible;
+import com.google.common.annotations.VisibleForTesting;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@GwtIncompatible("Unemulated com.google.gerrit.common.Version")
+public class Version {
+  private static final Logger log = LoggerFactory.getLogger(Version.class);
+
+  @VisibleForTesting static final String DEV = "(dev)";
+
+  private static final String VERSION;
+
+  public static String getVersion() {
+    return VERSION;
+  }
+
+  static {
+    VERSION = loadVersion();
+  }
+
+  private static String loadVersion() {
+    try (InputStream in = Version.class.getResourceAsStream("Version")) {
+      if (in == null) {
+        return DEV;
+      }
+      try (BufferedReader r = new BufferedReader(new InputStreamReader(in, UTF_8))) {
+        String vs = r.readLine();
+        if (vs != null && vs.startsWith("v")) {
+          vs = vs.substring(1);
+        }
+        if (vs != null && vs.isEmpty()) {
+          vs = null;
+        }
+        return vs;
+      }
+    } catch (IOException e) {
+      log.error(e.getMessage(), e);
+      return "(unknown version)";
+    }
+  }
+
+  private Version() {}
+}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/audit/Audit.java b/java/com/google/gerrit/common/audit/Audit.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/audit/Audit.java
rename to java/com/google/gerrit/common/audit/Audit.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/auth/SignInRequired.java b/java/com/google/gerrit/common/auth/SignInRequired.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/auth/SignInRequired.java
rename to java/com/google/gerrit/common/auth/SignInRequired.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/auth/openid/OpenIdUrls.java b/java/com/google/gerrit/common/auth/openid/OpenIdUrls.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/auth/openid/OpenIdUrls.java
rename to java/com/google/gerrit/common/auth/openid/OpenIdUrls.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccessSection.java b/java/com/google/gerrit/common/data/AccessSection.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/AccessSection.java
rename to java/com/google/gerrit/common/data/AccessSection.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/Capable.java b/java/com/google/gerrit/common/data/Capable.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/Capable.java
rename to java/com/google/gerrit/common/data/Capable.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/CommentDetail.java b/java/com/google/gerrit/common/data/CommentDetail.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/CommentDetail.java
rename to java/com/google/gerrit/common/data/CommentDetail.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ContributorAgreement.java b/java/com/google/gerrit/common/data/ContributorAgreement.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/ContributorAgreement.java
rename to java/com/google/gerrit/common/data/ContributorAgreement.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/FilenameComparator.java b/java/com/google/gerrit/common/data/FilenameComparator.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/FilenameComparator.java
rename to java/com/google/gerrit/common/data/FilenameComparator.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GarbageCollectionResult.java b/java/com/google/gerrit/common/data/GarbageCollectionResult.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/GarbageCollectionResult.java
rename to java/com/google/gerrit/common/data/GarbageCollectionResult.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GitwebType.java b/java/com/google/gerrit/common/data/GitwebType.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/GitwebType.java
rename to java/com/google/gerrit/common/data/GitwebType.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java
rename to java/com/google/gerrit/common/data/GlobalCapability.java
diff --git a/java/com/google/gerrit/common/data/GroupDescription.java b/java/com/google/gerrit/common/data/GroupDescription.java
new file mode 100644
index 0000000..d22b94b
--- /dev/null
+++ b/java/com/google/gerrit/common/data/GroupDescription.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common.data;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.sql.Timestamp;
+import java.util.Set;
+
+/** Group methods exposed by the GroupBackend. */
+public class GroupDescription {
+  /** The Basic information required to be exposed by any Group. */
+  public interface Basic {
+    /** @return the non-null UUID of the group. */
+    AccountGroup.UUID getGroupUUID();
+
+    /** @return the non-null name of the group. */
+    String getName();
+
+    /**
+     * @return optional email address to send to the group's members. If provided, Gerrit will use
+     *     this email address to send change notifications to the group.
+     */
+    @Nullable
+    String getEmailAddress();
+
+    /**
+     * @return optional URL to information about the group. Typically a URL to a web page that
+     *     permits users to apply to join the group, or manage their membership.
+     */
+    @Nullable
+    String getUrl();
+  }
+
+  /** The extended information exposed by internal groups. */
+  public interface Internal extends Basic {
+
+    AccountGroup.Id getId();
+
+    @Nullable
+    String getDescription();
+
+    AccountGroup.UUID getOwnerGroupUUID();
+
+    boolean isVisibleToAll();
+
+    Timestamp getCreatedOn();
+
+    Set<Account.Id> getMembers();
+
+    Set<AccountGroup.UUID> getSubgroups();
+  }
+
+  private GroupDescription() {}
+}
diff --git a/java/com/google/gerrit/common/data/GroupDetail.java b/java/com/google/gerrit/common/data/GroupDetail.java
new file mode 100644
index 0000000..1ac06db
--- /dev/null
+++ b/java/com/google/gerrit/common/data/GroupDetail.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common.data;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.util.Set;
+
+public class GroupDetail {
+  private Set<Account.Id> members;
+  private Set<AccountGroup.UUID> includes;
+
+  public GroupDetail(Set<Account.Id> members, Set<AccountGroup.UUID> includes) {
+    this.members = members;
+    this.includes = includes;
+  }
+
+  public Set<Account.Id> getMembers() {
+    return members;
+  }
+
+  public Set<AccountGroup.UUID> getIncludes() {
+    return includes;
+  }
+}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfo.java b/java/com/google/gerrit/common/data/GroupInfo.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfo.java
rename to java/com/google/gerrit/common/data/GroupInfo.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupReference.java b/java/com/google/gerrit/common/data/GroupReference.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/GroupReference.java
rename to java/com/google/gerrit/common/data/GroupReference.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/HostPageData.java b/java/com/google/gerrit/common/data/HostPageData.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/HostPageData.java
rename to java/com/google/gerrit/common/data/HostPageData.java
diff --git a/java/com/google/gerrit/common/data/LabelFunction.java b/java/com/google/gerrit/common/data/LabelFunction.java
new file mode 100644
index 0000000..0ce2c29
--- /dev/null
+++ b/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/java/com/google/gerrit/common/data/LabelType.java b/java/com/google/gerrit/common/data/LabelType.java
new file mode 100644
index 0000000..7bfd22e
--- /dev/null
+++ b/java/com/google/gerrit/common/data/LabelType.java
@@ -0,0 +1,336 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common.data;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+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;
+  public static final boolean DEF_CAN_OVERRIDE = true;
+  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
+  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
+  public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
+  public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
+  public static final boolean DEF_COPY_MAX_SCORE = false;
+  public static final boolean DEF_COPY_MIN_SCORE = false;
+
+  public static LabelType withDefaultValues(String name) {
+    checkName(name);
+    List<LabelValue> values = new ArrayList<>(2);
+    values.add(new LabelValue((short) 0, "Rejected"));
+    values.add(new LabelValue((short) 1, "Approved"));
+    return new LabelType(name, values);
+  }
+
+  public static String checkName(String name) {
+    checkNameInternal(name);
+    if ("SUBM".equals(name)) {
+      throw new IllegalArgumentException("Reserved label name \"" + name + "\"");
+    }
+    return name;
+  }
+
+  public static String checkNameInternal(String name) {
+    if (name == null || name.isEmpty()) {
+      throw new IllegalArgumentException("Empty label name");
+    }
+    for (int i = 0; i < name.length(); i++) {
+      char c = name.charAt(i);
+      if ((i == 0 && c == '-')
+          || !((c >= 'a' && c <= 'z')
+              || (c >= 'A' && c <= 'Z')
+              || (c >= '0' && c <= '9')
+              || c == '-')) {
+        throw new IllegalArgumentException("Illegal label name \"" + name + "\"");
+      }
+    }
+    return name;
+  }
+
+  private static List<LabelValue> sortValues(List<LabelValue> values) {
+    values = new ArrayList<>(values);
+    if (values.size() <= 1) {
+      return Collections.unmodifiableList(values);
+    }
+    Collections.sort(
+        values,
+        new Comparator<LabelValue>() {
+          @Override
+          public int compare(LabelValue o1, LabelValue o2) {
+            return o1.getValue() - o2.getValue();
+          }
+        });
+    short min = values.get(0).getValue();
+    short max = values.get(values.size() - 1).getValue();
+    short v = min;
+    short i = 0;
+    List<LabelValue> result = new ArrayList<>(max - min + 1);
+    // Fill in any missing values with empty text.
+    while (i < values.size()) {
+      while (v < values.get(i).getValue()) {
+        result.add(new LabelValue(v++, ""));
+      }
+      v++;
+      result.add(values.get(i++));
+    }
+    return Collections.unmodifiableList(result);
+  }
+
+  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;
+  protected boolean copyAllScoresOnTrivialRebase;
+  protected boolean copyAllScoresIfNoCodeChange;
+  protected boolean copyAllScoresIfNoChange;
+  protected boolean allowPostSubmit;
+  protected short defaultValue;
+
+  protected List<LabelValue> values;
+  protected short maxNegative;
+  protected short maxPositive;
+
+  private transient boolean canOverride;
+  private transient List<String> refPatterns;
+  private transient List<Integer> intList;
+  private transient Map<Short, LabelValue> byValue;
+
+  protected LabelType() {}
+
+  public LabelType(String name, List<LabelValue> valueList) {
+    this.name = checkName(name);
+    canOverride = true;
+    values = sortValues(valueList);
+    defaultValue = 0;
+
+    functionName = LabelFunction.MAX_WITH_BLOCK.getFunctionName();
+
+    maxNegative = Short.MIN_VALUE;
+    maxPositive = Short.MAX_VALUE;
+    if (values.size() > 0) {
+      if (values.get(0).getValue() < 0) {
+        maxNegative = values.get(0).getValue();
+      }
+      if (values.get(values.size() - 1).getValue() > 0) {
+        maxPositive = values.get(values.size() - 1).getValue();
+      }
+    }
+    setCanOverride(DEF_CAN_OVERRIDE);
+    setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
+    setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+    setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    setCopyAllScoresOnMergeFirstParentUpdate(DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    setCopyMaxScore(DEF_COPY_MAX_SCORE);
+    setCopyMinScore(DEF_COPY_MIN_SCORE);
+    setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT);
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public boolean matches(PatchSetApproval psa) {
+    return psa.getLabelId().get().equalsIgnoreCase(name);
+  }
+
+  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 setFunction(@Nullable LabelFunction function) {
+    this.functionName = function != null ? function.getFunctionName() : null;
+  }
+
+  public boolean canOverride() {
+    return canOverride;
+  }
+
+  public List<String> getRefPatterns() {
+    return refPatterns;
+  }
+
+  public void setCanOverride(boolean canOverride) {
+    this.canOverride = canOverride;
+  }
+
+  public boolean allowPostSubmit() {
+    return allowPostSubmit;
+  }
+
+  public void setAllowPostSubmit(boolean allowPostSubmit) {
+    this.allowPostSubmit = allowPostSubmit;
+  }
+
+  public void setRefPatterns(List<String> refPatterns) {
+    this.refPatterns = refPatterns;
+  }
+
+  public List<LabelValue> getValues() {
+    return values;
+  }
+
+  public LabelValue getMin() {
+    if (values.isEmpty()) {
+      return null;
+    }
+    return values.get(0);
+  }
+
+  public LabelValue getMax() {
+    if (values.isEmpty()) {
+      return null;
+    }
+    return values.get(values.size() - 1);
+  }
+
+  public short getDefaultValue() {
+    return defaultValue;
+  }
+
+  public void setDefaultValue(short defaultValue) {
+    this.defaultValue = defaultValue;
+  }
+
+  public boolean isCopyMinScore() {
+    return copyMinScore;
+  }
+
+  public void setCopyMinScore(boolean copyMinScore) {
+    this.copyMinScore = copyMinScore;
+  }
+
+  public boolean isCopyMaxScore() {
+    return copyMaxScore;
+  }
+
+  public void setCopyMaxScore(boolean copyMaxScore) {
+    this.copyMaxScore = copyMaxScore;
+  }
+
+  public boolean isCopyAllScoresOnMergeFirstParentUpdate() {
+    return copyAllScoresOnMergeFirstParentUpdate;
+  }
+
+  public void setCopyAllScoresOnMergeFirstParentUpdate(
+      boolean copyAllScoresOnMergeFirstParentUpdate) {
+    this.copyAllScoresOnMergeFirstParentUpdate = copyAllScoresOnMergeFirstParentUpdate;
+  }
+
+  public boolean isCopyAllScoresOnTrivialRebase() {
+    return copyAllScoresOnTrivialRebase;
+  }
+
+  public void setCopyAllScoresOnTrivialRebase(boolean copyAllScoresOnTrivialRebase) {
+    this.copyAllScoresOnTrivialRebase = copyAllScoresOnTrivialRebase;
+  }
+
+  public boolean isCopyAllScoresIfNoCodeChange() {
+    return copyAllScoresIfNoCodeChange;
+  }
+
+  public void setCopyAllScoresIfNoCodeChange(boolean copyAllScoresIfNoCodeChange) {
+    this.copyAllScoresIfNoCodeChange = copyAllScoresIfNoCodeChange;
+  }
+
+  public boolean isCopyAllScoresIfNoChange() {
+    return copyAllScoresIfNoChange;
+  }
+
+  public void setCopyAllScoresIfNoChange(boolean copyAllScoresIfNoChange) {
+    this.copyAllScoresIfNoChange = copyAllScoresIfNoChange;
+  }
+
+  public boolean isMaxNegative(PatchSetApproval ca) {
+    return maxNegative == ca.getValue();
+  }
+
+  public boolean isMaxPositive(PatchSetApproval ca) {
+    return maxPositive == ca.getValue();
+  }
+
+  public LabelValue getValue(short value) {
+    initByValue();
+    return byValue.get(value);
+  }
+
+  public LabelValue getValue(PatchSetApproval ca) {
+    initByValue();
+    return byValue.get(ca.getValue());
+  }
+
+  private void initByValue() {
+    if (byValue == null) {
+      byValue = new HashMap<>();
+      for (LabelValue v : values) {
+        byValue.put(v.getValue(), v);
+      }
+    }
+  }
+
+  public List<Integer> getValuesAsList() {
+    if (intList == null) {
+      intList = new ArrayList<>(values.size());
+      for (LabelValue v : values) {
+        intList.add(Integer.valueOf(v.getValue()));
+      }
+      Collections.sort(intList);
+      Collections.reverse(intList);
+    }
+    return intList;
+  }
+
+  public LabelId getLabelId() {
+    return new LabelId(name);
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder sb = new StringBuilder(name).append('[');
+    LabelValue min = getMin();
+    LabelValue max = getMax();
+    if (min != null && max != null) {
+      sb.append(
+          new PermissionRange(Permission.forLabel(name), min.getValue(), max.getValue())
+              .toString()
+              .trim());
+    } else if (min != null) {
+      sb.append(min.formatValue().trim());
+    } else if (max != null) {
+      sb.append(max.formatValue().trim());
+    }
+    sb.append(']');
+    return sb.toString();
+  }
+}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelTypes.java b/java/com/google/gerrit/common/data/LabelTypes.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/LabelTypes.java
rename to java/com/google/gerrit/common/data/LabelTypes.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelValue.java b/java/com/google/gerrit/common/data/LabelValue.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/LabelValue.java
rename to java/com/google/gerrit/common/data/LabelValue.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ParameterizedString.java b/java/com/google/gerrit/common/data/ParameterizedString.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/ParameterizedString.java
rename to java/com/google/gerrit/common/data/ParameterizedString.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchScript.java b/java/com/google/gerrit/common/data/PatchScript.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/PatchScript.java
rename to java/com/google/gerrit/common/data/PatchScript.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java b/java/com/google/gerrit/common/data/Permission.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
rename to java/com/google/gerrit/common/data/Permission.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRange.java b/java/com/google/gerrit/common/data/PermissionRange.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRange.java
rename to java/com/google/gerrit/common/data/PermissionRange.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRule.java b/java/com/google/gerrit/common/data/PermissionRule.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRule.java
rename to java/com/google/gerrit/common/data/PermissionRule.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAccess.java b/java/com/google/gerrit/common/data/ProjectAccess.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAccess.java
rename to java/com/google/gerrit/common/data/ProjectAccess.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAdminService.java b/java/com/google/gerrit/common/data/ProjectAdminService.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAdminService.java
rename to java/com/google/gerrit/common/data/ProjectAdminService.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/RefConfigSection.java b/java/com/google/gerrit/common/data/RefConfigSection.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/RefConfigSection.java
rename to java/com/google/gerrit/common/data/RefConfigSection.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SshHostKey.java b/java/com/google/gerrit/common/data/SshHostKey.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/SshHostKey.java
rename to java/com/google/gerrit/common/data/SshHostKey.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitRecord.java b/java/com/google/gerrit/common/data/SubmitRecord.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitRecord.java
rename to java/com/google/gerrit/common/data/SubmitRecord.java
diff --git a/java/com/google/gerrit/common/data/SubmitTypeRecord.java b/java/com/google/gerrit/common/data/SubmitTypeRecord.java
new file mode 100644
index 0000000..d16da96
--- /dev/null
+++ b/java/com/google/gerrit/common/data/SubmitTypeRecord.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common.data;
+
+import com.google.gerrit.extensions.client.SubmitType;
+
+/** Describes the submit type for a change. */
+public class SubmitTypeRecord {
+  public enum Status {
+    /** The type was computed successfully */
+    OK,
+
+    /**
+     * An internal server error occurred preventing computation.
+     *
+     * <p>Additional detail may be available in {@link SubmitTypeRecord#errorMessage}
+     */
+    RULE_ERROR
+  }
+
+  public static SubmitTypeRecord OK(SubmitType type) {
+    return new SubmitTypeRecord(Status.OK, type, null);
+  }
+
+  public static SubmitTypeRecord error(String err) {
+    return new SubmitTypeRecord(SubmitTypeRecord.Status.RULE_ERROR, null, err);
+  }
+
+  /** Status enum value of the record. */
+  public final Status status;
+
+  /** Submit type of the record; never null if {@link #status} is {@code OK}. */
+  public final SubmitType type;
+
+  /** Submit type of the record; always null if {@link #status} is {@code OK}. */
+  public final String errorMessage;
+
+  private SubmitTypeRecord(Status status, SubmitType type, String errorMessage) {
+    if (type == SubmitType.INHERIT) {
+      throw new IllegalArgumentException("Cannot output submit type " + type);
+    }
+    this.status = status;
+    this.type = type;
+    this.errorMessage = errorMessage;
+  }
+
+  public boolean isOk() {
+    return status == Status.OK;
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append(status);
+    if (status == Status.RULE_ERROR && errorMessage != null) {
+      sb.append('(').append(errorMessage).append(")");
+    }
+    if (type != null) {
+      sb.append('[');
+      sb.append(type.name());
+      sb.append(']');
+    }
+    return sb.toString();
+  }
+}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubscribeSection.java b/java/com/google/gerrit/common/data/SubscribeSection.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/SubscribeSection.java
rename to java/com/google/gerrit/common/data/SubscribeSection.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SystemInfoService.java b/java/com/google/gerrit/common/data/SystemInfoService.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/SystemInfoService.java
rename to java/com/google/gerrit/common/data/SystemInfoService.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/WebLinkInfoCommon.java b/java/com/google/gerrit/common/data/WebLinkInfoCommon.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/data/WebLinkInfoCommon.java
rename to java/com/google/gerrit/common/data/WebLinkInfoCommon.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/EmailException.java b/java/com/google/gerrit/common/errors/EmailException.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/errors/EmailException.java
rename to java/com/google/gerrit/common/errors/EmailException.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidNameException.java b/java/com/google/gerrit/common/errors/InvalidNameException.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidNameException.java
rename to java/com/google/gerrit/common/errors/InvalidNameException.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidSshKeyException.java b/java/com/google/gerrit/common/errors/InvalidSshKeyException.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidSshKeyException.java
rename to java/com/google/gerrit/common/errors/InvalidSshKeyException.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/NameAlreadyUsedException.java b/java/com/google/gerrit/common/errors/NameAlreadyUsedException.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/errors/NameAlreadyUsedException.java
rename to java/com/google/gerrit/common/errors/NameAlreadyUsedException.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/NoSuchAccountException.java b/java/com/google/gerrit/common/errors/NoSuchAccountException.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/errors/NoSuchAccountException.java
rename to java/com/google/gerrit/common/errors/NoSuchAccountException.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/NoSuchEntityException.java b/java/com/google/gerrit/common/errors/NoSuchEntityException.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/errors/NoSuchEntityException.java
rename to java/com/google/gerrit/common/errors/NoSuchEntityException.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/NoSuchGroupException.java b/java/com/google/gerrit/common/errors/NoSuchGroupException.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/errors/NoSuchGroupException.java
rename to java/com/google/gerrit/common/errors/NoSuchGroupException.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/NotSignedInException.java b/java/com/google/gerrit/common/errors/NotSignedInException.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/errors/NotSignedInException.java
rename to java/com/google/gerrit/common/errors/NotSignedInException.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/PermissionDeniedException.java b/java/com/google/gerrit/common/errors/PermissionDeniedException.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/errors/PermissionDeniedException.java
rename to java/com/google/gerrit/common/errors/PermissionDeniedException.java
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/UpdateParentFailedException.java b/java/com/google/gerrit/common/errors/UpdateParentFailedException.java
similarity index 100%
rename from gerrit-common/src/main/java/com/google/gerrit/common/errors/UpdateParentFailedException.java
rename to java/com/google/gerrit/common/errors/UpdateParentFailedException.java
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
new file mode 100644
index 0000000..3c471ac
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -0,0 +1,331 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
+import static java.util.stream.Collectors.toList;
+import static org.apache.commons.codec.binary.Base64.decodeBase64;
+import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Streams;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.FieldType;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import io.searchbox.client.JestResult;
+import io.searchbox.client.http.JestHttpClient;
+import io.searchbox.core.Bulk;
+import io.searchbox.core.Delete;
+import io.searchbox.core.Search;
+import io.searchbox.core.search.sort.Sort;
+import io.searchbox.indices.CreateIndex;
+import io.searchbox.indices.DeleteIndex;
+import io.searchbox.indices.IndicesExists;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.function.Function;
+import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
+  private static final Logger log = LoggerFactory.getLogger(AbstractElasticIndex.class);
+
+  protected static <T> List<T> decodeProtos(
+      JsonObject doc, String fieldName, ProtobufCodec<T> codec) {
+    JsonArray field = doc.getAsJsonArray(fieldName);
+    if (field == null) {
+      return null;
+    }
+    return FluentIterable.from(field)
+        .transform(i -> codec.decode(decodeBase64(i.toString())))
+        .toList();
+  }
+
+  private final Schema<V> schema;
+  private final SitePaths sitePaths;
+
+  protected final String indexName;
+  protected final JestHttpClient client;
+  protected final Gson gson;
+  protected final ElasticQueryBuilder queryBuilder;
+
+  AbstractElasticIndex(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      Schema<V> schema,
+      JestClientBuilder clientBuilder,
+      String indexName) {
+    this.sitePaths = sitePaths;
+    this.schema = schema;
+    this.gson = new GsonBuilder().setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
+    this.queryBuilder = new ElasticQueryBuilder();
+    this.indexName =
+        String.format(
+            "%s%s%04d",
+            Strings.nullToEmpty(cfg.getString("elasticsearch", null, "prefix")),
+            indexName,
+            schema.getVersion());
+    this.client = clientBuilder.build();
+  }
+
+  @Override
+  public Schema<V> getSchema() {
+    return schema;
+  }
+
+  @Override
+  public void close() {
+    client.shutdownClient();
+  }
+
+  @Override
+  public void markReady(boolean ready) throws IOException {
+    IndexUtils.setReady(sitePaths, indexName, schema.getVersion(), ready);
+  }
+
+  @Override
+  public void delete(K c) throws IOException {
+    Bulk bulk = addActions(new Bulk.Builder(), c).refresh(true).build();
+    JestResult result = client.execute(bulk);
+    if (!result.isSucceeded()) {
+      throw new IOException(
+          String.format(
+              "Failed to delete change %s in index %s: %s",
+              c, indexName, result.getErrorMessage()));
+    }
+  }
+
+  @Override
+  public void deleteAll() throws IOException {
+    // Delete the index, if it exists.
+    JestResult result = client.execute(new IndicesExists.Builder(indexName).build());
+    if (result.isSucceeded()) {
+      result = client.execute(new DeleteIndex.Builder(indexName).build());
+      if (!result.isSucceeded()) {
+        throw new IOException(
+            String.format("Failed to delete index %s: %s", indexName, result.getErrorMessage()));
+      }
+    }
+
+    // Recreate the index.
+    result = client.execute(new CreateIndex.Builder(indexName).settings(getMappings()).build());
+    if (!result.isSucceeded()) {
+      String error =
+          String.format("Failed to create index %s: %s", indexName, result.getErrorMessage());
+      throw new IOException(error);
+    }
+  }
+
+  protected abstract Bulk.Builder addActions(Bulk.Builder builder, K c);
+
+  protected abstract String getMappings();
+
+  protected abstract String getId(V v);
+
+  protected Delete delete(String type, K c) {
+    String id = c.toString();
+    return new Delete.Builder(id).index(indexName).type(type).build();
+  }
+
+  protected io.searchbox.core.Index insert(String type, V v) throws IOException {
+    String id = getId(v);
+    String doc = toDocument(v);
+    return new io.searchbox.core.Index.Builder(doc).index(indexName).type(type).id(id).build();
+  }
+
+  private static boolean shouldAddElement(Object element) {
+    return !(element instanceof String) || !((String) element).isEmpty();
+  }
+
+  private String toDocument(V v) throws IOException {
+    XContentBuilder builder = jsonBuilder().startObject();
+    for (Values<V> values : schema.buildFields(v)) {
+      String name = values.getField().getName();
+      if (values.getField().isRepeatable()) {
+        builder.field(
+            name,
+            Streams.stream(values.getValues()).filter(e -> shouldAddElement(e)).collect(toList()));
+      } else {
+        Object element = Iterables.getOnlyElement(values.getValues(), "");
+        if (shouldAddElement(element)) {
+          builder.field(name, element);
+        }
+      }
+    }
+    return builder.endObject().string();
+  }
+
+  protected abstract V fromDocument(JsonObject doc, Set<String> fields);
+
+  protected FieldBundle toFieldBundle(JsonObject doc) {
+    Map<String, FieldDef<V, ?>> allFields = getSchema().getFields();
+    ListMultimap<String, Object> rawFields = ArrayListMultimap.create();
+    for (Entry<String, JsonElement> element : doc.get("fields").getAsJsonObject().entrySet()) {
+      checkArgument(
+          allFields.containsKey(element.getKey()), "Unrecognized field " + element.getKey());
+      FieldType<?> type = allFields.get(element.getKey()).getType();
+
+      Iterable<JsonElement> innerItems =
+          element.getValue().isJsonArray()
+              ? element.getValue().getAsJsonArray()
+              : Collections.singleton(element.getValue());
+
+      for (JsonElement inner : innerItems) {
+        if (type == FieldType.EXACT || type == FieldType.FULL_TEXT || type == FieldType.PREFIX) {
+          rawFields.put(element.getKey(), inner.getAsString());
+        } else if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
+          rawFields.put(element.getKey(), inner.getAsInt());
+        } else if (type == FieldType.LONG) {
+          rawFields.put(element.getKey(), inner.getAsLong());
+        } else if (type == FieldType.TIMESTAMP) {
+          rawFields.put(element.getKey(), new Timestamp(inner.getAsLong()));
+        } else if (type == FieldType.STORED_ONLY) {
+          rawFields.put(element.getKey(), Base64.decodeBase64(inner.getAsString()));
+        } else {
+          throw FieldType.badFieldType(type);
+        }
+      }
+    }
+    return new FieldBundle(rawFields);
+  }
+
+  protected class ElasticQuerySource implements DataSource<V> {
+    private final QueryOptions opts;
+    private final Search search;
+
+    ElasticQuerySource(Predicate<V> p, QueryOptions opts, String type, Sort sort)
+        throws QueryParseException {
+      this(p, opts, ImmutableList.of(type), ImmutableList.of(sort));
+    }
+
+    ElasticQuerySource(
+        Predicate<V> p, QueryOptions opts, Collection<String> types, Collection<Sort> sorts)
+        throws QueryParseException {
+      this.opts = opts;
+      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
+      SearchSourceBuilder searchSource =
+          new SearchSourceBuilder()
+              .query(qb)
+              .from(opts.start())
+              .size(opts.limit())
+              .fields(Lists.newArrayList(opts.fields()));
+
+      search =
+          new Search.Builder(searchSource.toString())
+              .addType(types)
+              .addSort(sorts)
+              .addIndex(indexName)
+              .build();
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<V> read() throws OrmException {
+      return readImpl((doc) -> AbstractElasticIndex.this.fromDocument(doc, opts.fields()));
+    }
+
+    @Override
+    public ResultSet<FieldBundle> readRaw() throws OrmException {
+      return readImpl(AbstractElasticIndex.this::toFieldBundle);
+    }
+
+    @Override
+    public String toString() {
+      return search.toString();
+    }
+
+    private <T> ResultSet<T> readImpl(Function<JsonObject, T> mapper) throws OrmException {
+      try {
+        List<T> 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++) {
+              T mapperResult = mapper.apply(json.get(i).getAsJsonObject());
+              if (mapperResult != null) {
+                results.add(mapperResult);
+              }
+            }
+          }
+        } else {
+          log.error(result.getErrorMessage());
+        }
+        final List<T> r = Collections.unmodifiableList(results);
+        return new ResultSet<T>() {
+          @Override
+          public Iterator<T> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<T> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/elasticsearch/BUILD b/java/com/google/gerrit/elasticsearch/BUILD
new file mode 100644
index 0000000..f6d4608
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/BUILD
@@ -0,0 +1,29 @@
+java_library(
+    name = "elasticsearch",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:protobuf",
+        "//lib/commons:codec",
+        "//lib/commons:lang",
+        "//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/log:api",
+        "//lib/lucene:lucene-analyzers-common",
+        "//lib/lucene:lucene-core",
+    ],
+)
diff --git a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
new file mode 100644
index 0000000..ba5178e
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -0,0 +1,132 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.gerrit.server.index.account.AccountField.ID;
+
+import com.google.common.collect.ImmutableMap;
+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.Account;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.account.AccountField;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+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.sort.Sort;
+import io.searchbox.core.search.sort.Sort.Sorting;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+public class ElasticAccountIndex extends AbstractElasticIndex<Account.Id, AccountState>
+    implements AccountIndex {
+  static class AccountMapping {
+    MappingProperties accounts;
+
+    AccountMapping(Schema<AccountState> schema) {
+      this.accounts = ElasticMapping.createMapping(schema);
+    }
+  }
+
+  static final String ACCOUNTS = "accounts";
+  static final String ACCOUNTS_PREFIX = ACCOUNTS + "_";
+
+  private final AccountMapping mapping;
+  private final Provider<AccountCache> accountCache;
+
+  @Inject
+  ElasticAccountIndex(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      Provider<AccountCache> accountCache,
+      JestClientBuilder clientBuilder,
+      @Assisted Schema<AccountState> schema) {
+    super(cfg, sitePaths, schema, clientBuilder, ACCOUNTS_PREFIX);
+    this.accountCache = accountCache;
+    this.mapping = new AccountMapping(schema);
+  }
+
+  @Override
+  public void replace(AccountState as) throws IOException {
+    Bulk bulk =
+        new Bulk.Builder()
+            .defaultIndex(indexName)
+            .defaultType(ACCOUNTS)
+            .addAction(insert(ACCOUNTS, as))
+            .refresh(true)
+            .build();
+    JestResult result = client.execute(bulk);
+    if (!result.isSucceeded()) {
+      throw new IOException(
+          String.format(
+              "Failed to replace account %s in index %s: %s",
+              as.getAccount().getId(), indexName, result.getErrorMessage()));
+    }
+  }
+
+  @Override
+  public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
+      throws QueryParseException {
+    Sort sort = new Sort(AccountField.ID.getName(), Sorting.ASC);
+    sort.setIgnoreUnmapped();
+    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::accountFields), ACCOUNTS, sort);
+  }
+
+  @Override
+  protected Builder addActions(Builder builder, Account.Id c) {
+    return builder.addAction(delete(ACCOUNTS, c));
+  }
+
+  @Override
+  protected String getMappings() {
+    ImmutableMap<String, AccountMapping> mappings = ImmutableMap.of("mappings", mapping);
+    return gson.toJson(mappings);
+  }
+
+  @Override
+  protected String getId(AccountState as) {
+    return as.getAccount().getId().toString();
+  }
+
+  @Override
+  protected AccountState fromDocument(JsonObject json, Set<String> fields) {
+    JsonElement source = json.get("_source");
+    if (source == null) {
+      source = json.getAsJsonObject().get("fields");
+    }
+
+    Account.Id id = new Account.Id(source.getAsJsonObject().get(ID.getName()).getAsInt());
+    // Use the AccountCache rather than depending on any stored fields in the
+    // document (of which there shouldn't be any). The most expensive part to
+    // compute anyway is the effective group IDs, and we don't have a good way
+    // to reindex when those change.
+    return accountCache.get().get(id);
+  }
+}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
new file mode 100644
index 0000000..b21d3df
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -0,0 +1,390 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.index.change.ChangeField.APPROVAL_CODEC;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE_CODEC;
+import static com.google.gerrit.server.index.change.ChangeField.PATCH_SET_CODEC;
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.commons.codec.binary.Base64.decodeBase64;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Sets;
+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.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Id;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ReviewerByEmailSet;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.StarredChangesUtil;
+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.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gwtorm.server.OrmException;
+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.sort.Sort;
+import io.searchbox.core.search.sort.Sort.Sorting;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.lib.Config;
+
+/** Secondary index implementation using Elasticsearch. */
+class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
+    implements ChangeIndex {
+  static class ChangeMapping {
+    MappingProperties openChanges;
+    MappingProperties closedChanges;
+
+    ChangeMapping(Schema<ChangeData> schema) {
+      MappingProperties mapping = ElasticMapping.createMapping(schema);
+      this.openChanges = mapping;
+      this.closedChanges = mapping;
+    }
+  }
+
+  static final String CHANGES_PREFIX = "changes_";
+  static final String OPEN_CHANGES = "open_changes";
+  static final String CLOSED_CHANGES = "closed_changes";
+
+  private final ChangeMapping mapping;
+  private final Provider<ReviewDb> db;
+  private final ChangeData.Factory changeDataFactory;
+
+  @Inject
+  ElasticChangeIndex(
+      @GerritServerConfig Config cfg,
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      SitePaths sitePaths,
+      JestClientBuilder clientBuilder,
+      @Assisted Schema<ChangeData> schema) {
+    super(cfg, sitePaths, schema, clientBuilder, CHANGES_PREFIX);
+    this.db = db;
+    this.changeDataFactory = changeDataFactory;
+    mapping = new ChangeMapping(schema);
+  }
+
+  @Override
+  public void replace(ChangeData cd) throws IOException {
+    String deleteIndex;
+    String insertIndex;
+
+    try {
+      if (cd.change().getStatus().isOpen()) {
+        insertIndex = OPEN_CHANGES;
+        deleteIndex = CLOSED_CHANGES;
+      } else {
+        insertIndex = CLOSED_CHANGES;
+        deleteIndex = OPEN_CHANGES;
+      }
+    } catch (OrmException e) {
+      throw new IOException(e);
+    }
+
+    Bulk bulk =
+        new Bulk.Builder()
+            .defaultIndex(indexName)
+            .defaultType("changes")
+            .addAction(insert(insertIndex, cd))
+            .addAction(delete(deleteIndex, cd.getId()))
+            .refresh(true)
+            .build();
+    JestResult result = client.execute(bulk);
+    if (!result.isSucceeded()) {
+      throw new IOException(
+          String.format(
+              "Failed to replace change %s in index %s: %s",
+              cd.getId(), indexName, result.getErrorMessage()));
+    }
+  }
+
+  @Override
+  public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts)
+      throws QueryParseException {
+    Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
+    List<String> indexes = Lists.newArrayListWithCapacity(2);
+    if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
+      indexes.add(OPEN_CHANGES);
+    }
+    if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
+      indexes.add(CLOSED_CHANGES);
+    }
+
+    List<Sort> sorts =
+        ImmutableList.of(
+            new Sort(ChangeField.UPDATED.getName(), Sorting.DESC),
+            new Sort(ChangeField.LEGACY_ID.getName(), Sorting.DESC));
+    for (Sort sort : sorts) {
+      sort.setIgnoreUnmapped();
+    }
+    QueryOptions filteredOpts = opts.filterFields(IndexUtils::changeFields);
+    return new ElasticQuerySource(p, filteredOpts, indexes, sorts);
+  }
+
+  @Override
+  protected Builder addActions(Builder builder, Id c) {
+    return builder.addAction(delete(OPEN_CHANGES, c)).addAction(delete(OPEN_CHANGES, c));
+  }
+
+  @Override
+  protected String getMappings() {
+    return gson.toJson(ImmutableMap.of("mappings", mapping));
+  }
+
+  @Override
+  protected String getId(ChangeData cd) {
+    return cd.getId().toString();
+  }
+
+  @Override
+  protected ChangeData fromDocument(JsonObject json, Set<String> fields) {
+    JsonElement sourceElement = json.get("_source");
+    if (sourceElement == null) {
+      sourceElement = json.getAsJsonObject().get("fields");
+    }
+    JsonObject source = sourceElement.getAsJsonObject();
+    JsonElement c = source.get(ChangeField.CHANGE.getName());
+
+    if (c == null) {
+      int id = source.get(ChangeField.LEGACY_ID.getName()).getAsInt();
+      // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
+      String projectName = checkNotNull(source.get(ChangeField.PROJECT.getName()).getAsString());
+      return changeDataFactory.create(
+          db.get(), new Project.NameKey(projectName), new Change.Id(id));
+    }
+
+    ChangeData cd =
+        changeDataFactory.create(
+            db.get(), CHANGE_CODEC.decode(Base64.decodeBase64(c.getAsString())));
+
+    // Any decoding that is done here must also be done in {@link LuceneChangeIndex}.
+
+    // Patch sets.
+    cd.setPatchSets(decodeProtos(source, ChangeField.PATCH_SET.getName(), PATCH_SET_CODEC));
+
+    // Approvals.
+    if (source.get(ChangeField.APPROVAL.getName()) != null) {
+      cd.setCurrentApprovals(decodeProtos(source, ChangeField.APPROVAL.getName(), APPROVAL_CODEC));
+    } else if (fields.contains(ChangeField.APPROVAL.getName())) {
+      cd.setCurrentApprovals(Collections.emptyList());
+    }
+
+    // Added & Deleted.
+    JsonElement addedElement = source.get(ChangeField.ADDED.getName());
+    JsonElement deletedElement = source.get(ChangeField.DELETED.getName());
+    if (addedElement != null && deletedElement != null) {
+      // Changed lines.
+      int added = addedElement.getAsInt();
+      int deleted = deletedElement.getAsInt();
+      if (added != 0 && deleted != 0) {
+        cd.setChangedLines(added, deleted);
+      }
+    }
+
+    // Mergeable.
+    JsonElement mergeableElement = source.get(ChangeField.MERGEABLE.getName());
+    if (mergeableElement != null) {
+      String mergeable = mergeableElement.getAsString();
+      if ("1".equals(mergeable)) {
+        cd.setMergeable(true);
+      } else if ("0".equals(mergeable)) {
+        cd.setMergeable(false);
+      }
+    }
+
+    // Reviewed-by.
+    if (source.get(ChangeField.REVIEWEDBY.getName()) != null) {
+      JsonArray reviewedBy = source.get(ChangeField.REVIEWEDBY.getName()).getAsJsonArray();
+      if (reviewedBy.size() > 0) {
+        Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
+        for (int i = 0; i < reviewedBy.size(); i++) {
+          int aId = reviewedBy.get(i).getAsInt();
+          if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
+            break;
+          }
+          accounts.add(new Account.Id(aId));
+        }
+        cd.setReviewedBy(accounts);
+      }
+    } else if (fields.contains(ChangeField.REVIEWEDBY.getName())) {
+      cd.setReviewedBy(Collections.emptySet());
+    }
+
+    // Hashtag.
+    if (source.get(ChangeField.HASHTAG.getName()) != null) {
+      JsonArray hashtagArray = source.get(ChangeField.HASHTAG.getName()).getAsJsonArray();
+      if (hashtagArray.size() > 0) {
+        Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtagArray.size());
+        for (int i = 0; i < hashtagArray.size(); i++) {
+          hashtags.add(hashtagArray.get(i).getAsString());
+        }
+        cd.setHashtags(hashtags);
+      }
+    } else if (fields.contains(ChangeField.HASHTAG.getName())) {
+      cd.setHashtags(Collections.emptySet());
+    }
+
+    // Star.
+    if (source.get(ChangeField.STAR.getName()) != null) {
+      JsonArray starArray = source.get(ChangeField.STAR.getName()).getAsJsonArray();
+      if (starArray.size() > 0) {
+        ListMultimap<Account.Id, String> stars =
+            MultimapBuilder.hashKeys().arrayListValues().build();
+        for (int i = 0; i < starArray.size(); i++) {
+          StarredChangesUtil.StarField starField =
+              StarredChangesUtil.StarField.parse(starArray.get(i).getAsString());
+          stars.put(starField.accountId(), starField.label());
+        }
+        cd.setStars(stars);
+      }
+    } else if (fields.contains(ChangeField.STAR.getName())) {
+      cd.setStars(ImmutableListMultimap.of());
+    }
+
+    // Reviewer.
+    if (source.get(ChangeField.REVIEWER.getName()) != null) {
+      cd.setReviewers(
+          ChangeField.parseReviewerFieldValues(
+              FluentIterable.from(source.get(ChangeField.REVIEWER.getName()).getAsJsonArray())
+                  .transform(JsonElement::getAsString)));
+    } else if (fields.contains(ChangeField.REVIEWER.getName())) {
+      cd.setReviewers(ReviewerSet.empty());
+    }
+
+    // Reviewer-by-email.
+    if (source.get(ChangeField.REVIEWER_BY_EMAIL.getName()) != null) {
+      cd.setReviewersByEmail(
+          ChangeField.parseReviewerByEmailFieldValues(
+              FluentIterable.from(
+                      source.get(ChangeField.REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
+                  .transform(JsonElement::getAsString)));
+    } else if (fields.contains(ChangeField.REVIEWER_BY_EMAIL.getName())) {
+      cd.setReviewersByEmail(ReviewerByEmailSet.empty());
+    }
+
+    // Pending-reviewer.
+    if (source.get(ChangeField.PENDING_REVIEWER.getName()) != null) {
+      cd.setPendingReviewers(
+          ChangeField.parseReviewerFieldValues(
+              FluentIterable.from(
+                      source.get(ChangeField.PENDING_REVIEWER.getName()).getAsJsonArray())
+                  .transform(JsonElement::getAsString)));
+    } else if (fields.contains(ChangeField.PENDING_REVIEWER.getName())) {
+      cd.setPendingReviewers(ReviewerSet.empty());
+    }
+
+    // Pending-reviewer-by-email.
+    if (source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()) != null) {
+      cd.setPendingReviewersByEmail(
+          ChangeField.parseReviewerByEmailFieldValues(
+              FluentIterable.from(
+                      source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
+                  .transform(JsonElement::getAsString)));
+    } else if (fields.contains(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName())) {
+      cd.setPendingReviewersByEmail(ReviewerByEmailSet.empty());
+    }
+
+    // Stored-submit-record-strict.
+    decodeSubmitRecords(
+        source,
+        ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
+        ChangeField.SUBMIT_RULE_OPTIONS_STRICT,
+        cd);
+
+    // Stored-submit-record-leniant.
+    decodeSubmitRecords(
+        source,
+        ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName(),
+        ChangeField.SUBMIT_RULE_OPTIONS_LENIENT,
+        cd);
+
+    // Ref-state.
+    if (fields.contains(ChangeField.REF_STATE.getName())) {
+      cd.setRefStates(getByteArray(source, ChangeField.REF_STATE.getName()));
+    }
+
+    // Ref-state-pattern.
+    if (fields.contains(ChangeField.REF_STATE_PATTERN.getName())) {
+      cd.setRefStatePatterns(getByteArray(source, ChangeField.REF_STATE_PATTERN.getName()));
+    }
+
+    // Unresolved-comment-count.
+    decodeUnresolvedCommentCount(source, ChangeField.UNRESOLVED_COMMENT_COUNT.getName(), cd);
+
+    return cd;
+  }
+
+  private Iterable<byte[]> getByteArray(JsonObject source, String name) {
+    JsonElement element = source.get(name);
+    return element != null
+        ? Iterables.transform(element.getAsJsonArray(), e -> Base64.decodeBase64(e.getAsString()))
+        : Collections.emptyList();
+  }
+
+  private void decodeSubmitRecords(
+      JsonObject doc, String fieldName, SubmitRuleOptions opts, ChangeData out) {
+    JsonArray records = doc.getAsJsonArray(fieldName);
+    if (records == null) {
+      return;
+    }
+    ChangeField.parseSubmitRecords(
+        FluentIterable.from(records)
+            .transform(i -> new String(decodeBase64(i.toString()), UTF_8))
+            .toList(),
+        opts,
+        out);
+  }
+
+  private void decodeUnresolvedCommentCount(JsonObject doc, String fieldName, ChangeData out) {
+    JsonElement count = doc.get(fieldName);
+    if (count == null) {
+      return;
+    }
+    out.setUnresolvedCommentCount(count.getAsInt());
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
rename to java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
new file mode 100644
index 0000000..7f0ec34
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -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.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.common.collect.ImmutableMap;
+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.AccountGroup;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.group.GroupField;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+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.sort.Sort;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+public class ElasticGroupIndex extends AbstractElasticIndex<AccountGroup.UUID, InternalGroup>
+    implements GroupIndex {
+  static class GroupMapping {
+    MappingProperties groups;
+
+    GroupMapping(Schema<InternalGroup> schema) {
+      this.groups = ElasticMapping.createMapping(schema);
+    }
+  }
+
+  static final String GROUPS = "groups";
+  static final String GROUPS_PREFIX = GROUPS + "_";
+
+  private final GroupMapping mapping;
+  private final Provider<GroupCache> groupCache;
+
+  @Inject
+  ElasticGroupIndex(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      Provider<GroupCache> groupCache,
+      JestClientBuilder clientBuilder,
+      @Assisted Schema<InternalGroup> schema) {
+    super(cfg, sitePaths, schema, clientBuilder, GROUPS_PREFIX);
+    this.groupCache = groupCache;
+    this.mapping = new GroupMapping(schema);
+  }
+
+  @Override
+  public void replace(InternalGroup group) throws IOException {
+    Bulk bulk =
+        new Bulk.Builder()
+            .defaultIndex(indexName)
+            .defaultType(GROUPS)
+            .addAction(insert(GROUPS, group))
+            .refresh(true)
+            .build();
+    JestResult result = client.execute(bulk);
+    if (!result.isSucceeded()) {
+      throw new IOException(
+          String.format(
+              "Failed to replace group %s in index %s: %s",
+              group.getGroupUUID().get(), indexName, result.getErrorMessage()));
+    }
+  }
+
+  @Override
+  public DataSource<InternalGroup> getSource(Predicate<InternalGroup> p, QueryOptions opts)
+      throws QueryParseException {
+    Sort sort = new Sort(GroupField.UUID.getName(), Sort.Sorting.ASC);
+    sort.setIgnoreUnmapped();
+    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::groupFields), GROUPS, sort);
+  }
+
+  @Override
+  protected Builder addActions(Builder builder, AccountGroup.UUID c) {
+    return builder.addAction(delete(GROUPS, c));
+  }
+
+  @Override
+  protected String getMappings() {
+    ImmutableMap<String, GroupMapping> mappings = ImmutableMap.of("mappings", mapping);
+    return gson.toJson(mappings);
+  }
+
+  @Override
+  protected String getId(InternalGroup group) {
+    return group.getGroupUUID().get();
+  }
+
+  @Override
+  protected InternalGroup fromDocument(JsonObject json, Set<String> fields) {
+    JsonElement source = json.get("_source");
+    if (source == null) {
+      source = json.getAsJsonObject().get("fields");
+    }
+
+    AccountGroup.UUID uuid =
+        new AccountGroup.UUID(
+            source.getAsJsonObject().get(GroupField.UUID.getName()).getAsString());
+    // Use the GroupCache rather than depending on any stored fields in the
+    // document (of which there shouldn't be any).
+    return groupCache.get().get(uuid).orElse(null);
+  }
+}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
new file mode 100644
index 0000000..2d04e11
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.IndexModule;
+import com.google.gerrit.server.index.OnlineUpgrader;
+import com.google.gerrit.server.index.SingleVersionModule;
+import com.google.gerrit.server.index.VersionManager;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.project.ProjectIndex;
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+public class ElasticIndexModule extends AbstractModule {
+  public static ElasticIndexModule singleVersionWithExplicitVersions(
+      Map<String, Integer> versions, int threads) {
+    return new ElasticIndexModule(versions, threads, false);
+  }
+
+  public static ElasticIndexModule latestVersionWithOnlineUpgrade() {
+    return new ElasticIndexModule(null, 0, true);
+  }
+
+  public static ElasticIndexModule latestVersionWithoutOnlineUpgrade() {
+    return new ElasticIndexModule(null, 0, false);
+  }
+
+  private final Map<String, Integer> singleVersions;
+  private final int threads;
+  private final boolean onlineUpgrade;
+
+  private ElasticIndexModule(
+      Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade) {
+    if (singleVersions != null) {
+      checkArgument(!onlineUpgrade, "online upgrade is incompatible with single version map");
+    }
+    this.singleVersions = singleVersions;
+    this.threads = threads;
+    this.onlineUpgrade = onlineUpgrade;
+  }
+
+  @Override
+  protected void configure() {
+    install(
+        new FactoryModuleBuilder()
+            .implement(AccountIndex.class, ElasticAccountIndex.class)
+            .build(AccountIndex.Factory.class));
+    install(
+        new FactoryModuleBuilder()
+            .implement(ChangeIndex.class, ElasticChangeIndex.class)
+            .build(ChangeIndex.Factory.class));
+    install(
+        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) {
+      install(new MultiVersionModule());
+    } else {
+      install(new SingleVersionModule(singleVersions));
+    }
+  }
+
+  @Provides
+  @Singleton
+  IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
+    return IndexConfig.fromConfig(cfg).separateChangeSubIndexes(true).build();
+  }
+
+  private class MultiVersionModule extends LifecycleModule {
+    @Override
+    public void configure() {
+      bind(VersionManager.class).to(ElasticVersionManager.class);
+      listener().to(ElasticVersionManager.class);
+      if (onlineUpgrade) {
+        listener().to(OnlineUpgrader.class);
+      }
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
rename to java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
rename to java/com/google/gerrit/elasticsearch/ElasticMapping.java
diff --git a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
new file mode 100644
index 0000000..a564e5b
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.common.collect.ImmutableMap;
+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.JsonElement;
+import com.google.gson.JsonObject;
+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.sort.Sort;
+import io.searchbox.core.search.sort.Sort.Sorting;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+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 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 {
+    Sort sort = new Sort(ProjectField.NAME.getName(), Sorting.ASC);
+    sort.setIgnoreUnmapped();
+    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::projectFields), PROJECTS, sort);
+  }
+
+  @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();
+  }
+
+  @Override
+  protected ProjectData fromDocument(JsonObject json, Set<String> fields) {
+    JsonElement source = json.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/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
rename to java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticVersionManager.java b/java/com/google/gerrit/elasticsearch/ElasticVersionManager.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticVersionManager.java
rename to java/com/google/gerrit/elasticsearch/ElasticVersionManager.java
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/JestClientBuilder.java b/java/com/google/gerrit/elasticsearch/JestClientBuilder.java
similarity index 100%
rename from gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/JestClientBuilder.java
rename to java/com/google/gerrit/elasticsearch/JestClientBuilder.java
diff --git a/java/com/google/gerrit/extensions/BUILD b/java/com/google/gerrit/extensions/BUILD
new file mode 100644
index 0000000..ad1b149
--- /dev/null
+++ b/java/com/google/gerrit/extensions/BUILD
@@ -0,0 +1,61 @@
+load("//lib:guava.bzl", "GUAVA_DOC_URL")
+load("//lib/jgit:jgit.bzl", "JGIT_DOC_URL")
+load("//tools/bzl:gwt.bzl", "gwt_module")
+
+SRCS = glob(["**/*.java"])
+
+EXT_API_SRCS = glob(["client/*.java"])
+
+gwt_module(
+    name = "client",
+    srcs = EXT_API_SRCS,
+    gwt_xml = "Extensions.gwt.xml",
+    visibility = ["//visibility:public"],
+)
+
+java_binary(
+    name = "extension-api",
+    main_class = "Dummy",
+    visibility = ["//visibility:public"],
+    runtime_deps = [":lib"],
+)
+
+java_library(
+    name = "lib",
+    visibility = ["//visibility:public"],
+    exports = [
+        ":api",
+        "//lib:guava",
+        "//lib:servlet-api-3_1",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",
+    ],
+)
+
+#TODO(davido): There is no provided_deps argument to java_library rule
+java_library(
+    name = "api",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//lib:guava",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+    ],
+)
+
+load("//tools/bzl:javadoc.bzl", "java_doc")
+
+java_doc(
+    name = "extension-api-javadoc",
+    external_docs = [
+        JGIT_DOC_URL,
+        GUAVA_DOC_URL,
+    ],
+    libs = [":api"],
+    pkgs = ["com.google.gerrit.extensions"],
+    title = "Gerrit Review Extension API Documentation",
+    visibility = ["//visibility:public"],
+)
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/Extensions.gwt.xml b/java/com/google/gerrit/extensions/Extensions.gwt.xml
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/Extensions.gwt.xml
rename to java/com/google/gerrit/extensions/Extensions.gwt.xml
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/CapabilityScope.java b/java/com/google/gerrit/extensions/annotations/CapabilityScope.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/CapabilityScope.java
rename to java/com/google/gerrit/extensions/annotations/CapabilityScope.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Export.java b/java/com/google/gerrit/extensions/annotations/Export.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Export.java
rename to java/com/google/gerrit/extensions/annotations/Export.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/ExportImpl.java b/java/com/google/gerrit/extensions/annotations/ExportImpl.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/ExportImpl.java
rename to java/com/google/gerrit/extensions/annotations/ExportImpl.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java b/java/com/google/gerrit/extensions/annotations/Exports.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java
rename to java/com/google/gerrit/extensions/annotations/Exports.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/ExtensionPoint.java b/java/com/google/gerrit/extensions/annotations/ExtensionPoint.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/ExtensionPoint.java
rename to java/com/google/gerrit/extensions/annotations/ExtensionPoint.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Listen.java b/java/com/google/gerrit/extensions/annotations/Listen.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Listen.java
rename to java/com/google/gerrit/extensions/annotations/Listen.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginCanonicalWebUrl.java b/java/com/google/gerrit/extensions/annotations/PluginCanonicalWebUrl.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginCanonicalWebUrl.java
rename to java/com/google/gerrit/extensions/annotations/PluginCanonicalWebUrl.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginData.java b/java/com/google/gerrit/extensions/annotations/PluginData.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginData.java
rename to java/com/google/gerrit/extensions/annotations/PluginData.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginName.java b/java/com/google/gerrit/extensions/annotations/PluginName.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginName.java
rename to java/com/google/gerrit/extensions/annotations/PluginName.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresAnyCapability.java b/java/com/google/gerrit/extensions/annotations/RequiresAnyCapability.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresAnyCapability.java
rename to java/com/google/gerrit/extensions/annotations/RequiresAnyCapability.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresCapability.java b/java/com/google/gerrit/extensions/annotations/RequiresCapability.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresCapability.java
rename to java/com/google/gerrit/extensions/annotations/RequiresCapability.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RootRelative.java b/java/com/google/gerrit/extensions/annotations/RootRelative.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RootRelative.java
rename to java/com/google/gerrit/extensions/annotations/RootRelative.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/GerritApi.java b/java/com/google/gerrit/extensions/api/GerritApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/GerritApi.java
rename to java/com/google/gerrit/extensions/api/GerritApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/AccessSectionInfo.java b/java/com/google/gerrit/extensions/api/access/AccessSectionInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/AccessSectionInfo.java
rename to java/com/google/gerrit/extensions/api/access/AccessSectionInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java b/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java
rename to java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionInfo.java b/java/com/google/gerrit/extensions/api/access/PermissionInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionInfo.java
rename to java/com/google/gerrit/extensions/api/access/PermissionInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionRuleInfo.java b/java/com/google/gerrit/extensions/api/access/PermissionRuleInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionRuleInfo.java
rename to java/com/google/gerrit/extensions/api/access/PermissionRuleInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PluginPermission.java b/java/com/google/gerrit/extensions/api/access/PluginPermission.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PluginPermission.java
rename to java/com/google/gerrit/extensions/api/access/PluginPermission.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java b/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
rename to java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInput.java b/java/com/google/gerrit/extensions/api/access/ProjectAccessInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInput.java
rename to java/com/google/gerrit/extensions/api/access/ProjectAccessInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
rename to java/com/google/gerrit/extensions/api/accounts/AccountApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountInput.java b/java/com/google/gerrit/extensions/api/accounts/AccountInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountInput.java
rename to java/com/google/gerrit/extensions/api/accounts/AccountInput.java
diff --git a/java/com/google/gerrit/extensions/api/accounts/Accounts.java b/java/com/google/gerrit/extensions/api/accounts/Accounts.java
new file mode 100644
index 0000000..651e786
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/accounts/Accounts.java
@@ -0,0 +1,261 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.accounts;
+
+import com.google.gerrit.extensions.client.ListAccountsOption;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.List;
+
+public interface Accounts {
+  /**
+   * Look up an account by ID.
+   *
+   * <p><strong>Note:</strong> This method eagerly reads the account. Methods that mutate the
+   * account do not necessarily re-read the account. Therefore, calling a getter method on an
+   * instance after calling a mutation method on that same instance is not guaranteed to reflect the
+   * mutation. It is not recommended to store references to {@code AccountApi} instances.
+   *
+   * @param id any identifier supported by the REST API, including numeric ID, email, or username.
+   * @return API for accessing the account.
+   * @throws RestApiException if an error occurred.
+   */
+  AccountApi id(String id) throws RestApiException;
+
+  /** @see #id(String) */
+  AccountApi id(int id) throws RestApiException;
+
+  /**
+   * Look up the account of the current in-scope user.
+   *
+   * @see #id(String)
+   */
+  AccountApi self() throws RestApiException;
+
+  /** Create a new account with the given username and default options. */
+  AccountApi create(String username) throws RestApiException;
+
+  /** Create a new account. */
+  AccountApi create(AccountInput input) throws RestApiException;
+
+  /**
+   * Suggest users for a given query.
+   *
+   * <p>Example code: {@code suggestAccounts().withQuery("Reviewer").withLimit(5).get()}
+   *
+   * @return API for setting parameters and getting result.
+   */
+  SuggestAccountsRequest suggestAccounts() throws RestApiException;
+
+  /**
+   * Suggest users for a given query.
+   *
+   * <p>Shortcut API for {@code suggestAccounts().withQuery(String)}.
+   *
+   * @see #suggestAccounts()
+   */
+  SuggestAccountsRequest suggestAccounts(String query) throws RestApiException;
+
+  /**
+   * Query users.
+   *
+   * <p>Example code: {@code query().withQuery("name:John email:example.com").withLimit(5).get()}
+   *
+   * @return API for setting parameters and getting result.
+   */
+  QueryRequest query() throws RestApiException;
+
+  /**
+   * Query users.
+   *
+   * <p>Shortcut API for {@code query().withQuery(String)}.
+   *
+   * @see #query()
+   */
+  QueryRequest query(String query) throws RestApiException;
+
+  /**
+   * API for setting parameters and getting result. Used for {@code suggestAccounts()}.
+   *
+   * @see #suggestAccounts()
+   */
+  abstract class SuggestAccountsRequest {
+    private String query;
+    private int limit;
+
+    /** Execute query and return a list of accounts. */
+    public abstract List<AccountInfo> get() throws RestApiException;
+
+    /**
+     * Set query.
+     *
+     * @param query needs to be in human-readable form.
+     */
+    public SuggestAccountsRequest withQuery(String query) {
+      this.query = query;
+      return this;
+    }
+
+    /**
+     * Set limit for returned list of accounts. Optional; server-default is used when not provided.
+     */
+    public SuggestAccountsRequest withLimit(int limit) {
+      this.limit = limit;
+      return this;
+    }
+
+    public String getQuery() {
+      return query;
+    }
+
+    public int getLimit() {
+      return limit;
+    }
+  }
+
+  /**
+   * API for setting parameters and getting result. Used for {@code query()}.
+   *
+   * @see #query()
+   */
+  abstract class QueryRequest {
+    private String query;
+    private int limit;
+    private int start;
+    private boolean suggest;
+    private EnumSet<ListAccountsOption> options = EnumSet.noneOf(ListAccountsOption.class);
+
+    /** Execute query and return a list of accounts. */
+    public abstract List<AccountInfo> get() throws RestApiException;
+
+    /**
+     * Set query.
+     *
+     * @param query needs to be in human-readable form.
+     */
+    public QueryRequest withQuery(String query) {
+      this.query = query;
+      return this;
+    }
+
+    /**
+     * Set limit for returned list of accounts. Optional; server-default is used when not provided.
+     */
+    public QueryRequest withLimit(int limit) {
+      this.limit = limit;
+      return this;
+    }
+
+    /** Set number of accounts to skip. Optional; no accounts are skipped when not provided. */
+    public QueryRequest withStart(int start) {
+      this.start = start;
+      return this;
+    }
+
+    public QueryRequest withSuggest(boolean suggest) {
+      this.suggest = suggest;
+      return this;
+    }
+
+    public QueryRequest withOption(ListAccountsOption options) {
+      this.options.add(options);
+      return this;
+    }
+
+    public QueryRequest withOptions(ListAccountsOption... options) {
+      this.options.addAll(Arrays.asList(options));
+      return this;
+    }
+
+    public QueryRequest withOptions(EnumSet<ListAccountsOption> options) {
+      this.options = options;
+      return this;
+    }
+
+    public String getQuery() {
+      return query;
+    }
+
+    public int getLimit() {
+      return limit;
+    }
+
+    public int getStart() {
+      return start;
+    }
+
+    public boolean getSuggest() {
+      return suggest;
+    }
+
+    public EnumSet<ListAccountsOption> getOptions() {
+      return options;
+    }
+  }
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements Accounts {
+    @Override
+    public AccountApi id(String id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccountApi id(int id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccountApi self() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccountApi create(String username) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccountApi create(AccountInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SuggestAccountsRequest suggestAccounts() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SuggestAccountsRequest suggestAccounts(String query) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public QueryRequest query() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public QueryRequest query(String query) throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/EmailInput.java b/java/com/google/gerrit/extensions/api/accounts/EmailInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/EmailInput.java
rename to java/com/google/gerrit/extensions/api/accounts/EmailInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java b/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java
rename to java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java
diff --git a/java/com/google/gerrit/extensions/api/accounts/GpgKeysInput.java b/java/com/google/gerrit/extensions/api/accounts/GpgKeysInput.java
new file mode 100644
index 0000000..8fb587a
--- /dev/null
+++ b/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/java/com/google/gerrit/extensions/api/accounts/SshKeyInput.java b/java/com/google/gerrit/extensions/api/accounts/SshKeyInput.java
new file mode 100644
index 0000000..46dd858
--- /dev/null
+++ b/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/java/com/google/gerrit/extensions/api/accounts/StatusInput.java b/java/com/google/gerrit/extensions/api/accounts/StatusInput.java
new file mode 100644
index 0000000..951c049
--- /dev/null
+++ b/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/java/com/google/gerrit/extensions/api/accounts/UsernameInput.java b/java/com/google/gerrit/extensions/api/accounts/UsernameInput.java
new file mode 100644
index 0000000..f774ddc
--- /dev/null
+++ b/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/AbandonInput.java b/java/com/google/gerrit/extensions/api/changes/AbandonInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AbandonInput.java
rename to java/com/google/gerrit/extensions/api/changes/AbandonInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ActionVisitor.java b/java/com/google/gerrit/extensions/api/changes/ActionVisitor.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ActionVisitor.java
rename to java/com/google/gerrit/extensions/api/changes/ActionVisitor.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java b/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
rename to java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java b/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
rename to java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AssigneeInput.java b/java/com/google/gerrit/extensions/api/changes/AssigneeInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AssigneeInput.java
rename to java/com/google/gerrit/extensions/api/changes/AssigneeInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
rename to java/com/google/gerrit/extensions/api/changes/ChangeApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
rename to java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/Changes.java b/java/com/google/gerrit/extensions/api/changes/Changes.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/Changes.java
rename to java/com/google/gerrit/extensions/api/changes/Changes.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
rename to java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CommentApi.java b/java/com/google/gerrit/extensions/api/changes/CommentApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CommentApi.java
rename to java/com/google/gerrit/extensions/api/changes/CommentApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteCommentInput.java b/java/com/google/gerrit/extensions/api/changes/DeleteCommentInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteCommentInput.java
rename to java/com/google/gerrit/extensions/api/changes/DeleteCommentInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteReviewerInput.java b/java/com/google/gerrit/extensions/api/changes/DeleteReviewerInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteReviewerInput.java
rename to java/com/google/gerrit/extensions/api/changes/DeleteReviewerInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java b/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
rename to java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftApi.java b/java/com/google/gerrit/extensions/api/changes/DraftApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftApi.java
rename to java/com/google/gerrit/extensions/api/changes/DraftApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftInput.java b/java/com/google/gerrit/extensions/api/changes/DraftInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftInput.java
rename to java/com/google/gerrit/extensions/api/changes/DraftInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FileApi.java b/java/com/google/gerrit/extensions/api/changes/FileApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FileApi.java
rename to java/com/google/gerrit/extensions/api/changes/FileApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FixInput.java b/java/com/google/gerrit/extensions/api/changes/FixInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FixInput.java
rename to java/com/google/gerrit/extensions/api/changes/FixInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java b/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java
rename to java/com/google/gerrit/extensions/api/changes/HashtagsInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java b/java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java
rename to java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/MoveInput.java b/java/com/google/gerrit/extensions/api/changes/MoveInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/MoveInput.java
rename to java/com/google/gerrit/extensions/api/changes/MoveInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java b/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java
rename to java/com/google/gerrit/extensions/api/changes/NotifyHandling.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java b/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
rename to java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/PublishChangeEditInput.java b/java/com/google/gerrit/extensions/api/changes/PublishChangeEditInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/PublishChangeEditInput.java
rename to java/com/google/gerrit/extensions/api/changes/PublishChangeEditInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
rename to java/com/google/gerrit/extensions/api/changes/RebaseInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RecipientType.java b/java/com/google/gerrit/extensions/api/changes/RecipientType.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RecipientType.java
rename to java/com/google/gerrit/extensions/api/changes/RecipientType.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RestoreInput.java b/java/com/google/gerrit/extensions/api/changes/RestoreInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RestoreInput.java
rename to java/com/google/gerrit/extensions/api/changes/RestoreInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevertInput.java b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevertInput.java
rename to java/com/google/gerrit/extensions/api/changes/RevertInput.java
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
new file mode 100644
index 0000000..a13fb75
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -0,0 +1,184 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Input passed to {@code POST /changes/[id]/revisions/[id]/review}. */
+public class ReviewInput {
+  @DefaultInput public String message;
+
+  public String tag;
+
+  public Map<String, Short> labels;
+  public Map<String, List<CommentInput>> comments;
+  public Map<String, List<RobotCommentInput>> robotComments;
+
+  /**
+   * If true require all labels to be within the user's permitted ranges based on access controls,
+   * attempting to use a label not granted to the user will fail the entire modify operation early.
+   * If false the operation will execute anyway, but the proposed labels given by the user will be
+   * modified to be the "best" value allowed by the access controls, or ignored if the label does
+   * not exist.
+   */
+  public boolean strictLabels = true;
+
+  /**
+   * How to process draft comments already in the database that were not also described in this
+   * input request.
+   *
+   * <p>If not set, the default is {@link DraftHandling#KEEP}. If {@link #onBehalfOf} is set, then
+   * no other value besides {@code KEEP} is allowed.
+   */
+  public DraftHandling drafts;
+
+  /** Who to send email notifications to after review is stored. */
+  public NotifyHandling notify;
+
+  public Map<RecipientType, NotifyInfo> notifyDetails;
+
+  /** If true check to make sure that the comments being posted aren't already present. */
+  public boolean omitDuplicateComments;
+
+  /**
+   * Account ID, name, email address or username of another user. The review will be posted/updated
+   * on behalf of this named user instead of the caller. Caller must have the labelAs-$NAME
+   * permission granted for each label that appears in {@link #labels}. This is in addition to the
+   * named user also needing to have permission to use the labels.
+   *
+   * <p>{@link #strictLabels} impacts how labels is processed for the named user, not the caller.
+   */
+  public String onBehalfOf;
+
+  /** Reviewers that should be added to this change. */
+  public List<AddReviewerInput> reviewers;
+
+  /**
+   * If true mark the change as work in progress. It is an error for both {@link #workInProgress}
+   * and {@link #ready} to be true.
+   */
+  public boolean workInProgress;
+
+  /**
+   * If true mark the change as ready for review. It is an error for both {@link #workInProgress}
+   * and {@link #ready} to be true.
+   */
+  public boolean ready;
+
+  public enum DraftHandling {
+    /** Leave pending drafts alone. */
+    KEEP,
+
+    /** Publish pending drafts on this revision only. */
+    PUBLISH,
+
+    /** Publish pending drafts on all revisions. */
+    PUBLISH_ALL_REVISIONS
+  }
+
+  public static class CommentInput extends Comment {}
+
+  public static class RobotCommentInput extends CommentInput {
+    public String robotId;
+    public String robotRunId;
+    public String url;
+    public Map<String, String> properties;
+    public List<FixSuggestionInfo> fixSuggestions;
+  }
+
+  public ReviewInput message(String msg) {
+    message = msg != null && !msg.isEmpty() ? msg : null;
+    return this;
+  }
+
+  public ReviewInput label(String name, short value) {
+    if (name == null || name.isEmpty()) {
+      throw new IllegalArgumentException();
+    }
+    if (labels == null) {
+      labels = new LinkedHashMap<>(4);
+    }
+    labels.put(name, value);
+    return this;
+  }
+
+  public ReviewInput label(String name, int value) {
+    if (value < Short.MIN_VALUE || value > Short.MAX_VALUE) {
+      throw new IllegalArgumentException();
+    }
+    return label(name, (short) value);
+  }
+
+  public ReviewInput label(String name) {
+    return label(name, (short) 1);
+  }
+
+  public ReviewInput reviewer(String reviewer) {
+    return reviewer(reviewer, REVIEWER, false);
+  }
+
+  public ReviewInput reviewer(String reviewer, ReviewerState state, boolean confirmed) {
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = reviewer;
+    input.state = state;
+    input.confirmed = confirmed;
+    if (reviewers == null) {
+      reviewers = new ArrayList<>();
+    }
+    reviewers.add(input);
+    return this;
+  }
+
+  public ReviewInput setWorkInProgress(boolean workInProgress) {
+    this.workInProgress = workInProgress;
+    ready = !workInProgress;
+    return this;
+  }
+
+  public ReviewInput setReady(boolean ready) {
+    this.ready = ready;
+    workInProgress = !ready;
+    return this;
+  }
+
+  public static ReviewInput recommend() {
+    return new ReviewInput().label("Code-Review", 1);
+  }
+
+  public static ReviewInput dislike() {
+    return new ReviewInput().label("Code-Review", -1);
+  }
+
+  public static ReviewInput noScore() {
+    return new ReviewInput().label("Code-Review", 0);
+  }
+
+  public static ReviewInput approve() {
+    return new ReviewInput().label("Code-Review", 2);
+  }
+
+  public static ReviewInput reject() {
+    return new ReviewInput().label("Code-Review", -2);
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewResult.java b/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
rename to java/com/google/gerrit/extensions/api/changes/ReviewResult.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java b/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
rename to java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java b/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
rename to java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
rename to java/com/google/gerrit/extensions/api/changes/RevisionApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionReviewerApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionReviewerApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionReviewerApi.java
rename to java/com/google/gerrit/extensions/api/changes/RevisionReviewerApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java b/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java
rename to java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/StarsInput.java b/java/com/google/gerrit/extensions/api/changes/StarsInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/StarsInput.java
rename to java/com/google/gerrit/extensions/api/changes/StarsInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmitInput.java b/java/com/google/gerrit/extensions/api/changes/SubmitInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmitInput.java
rename to java/com/google/gerrit/extensions/api/changes/SubmitInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherInfo.java b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherInfo.java
rename to java/com/google/gerrit/extensions/api/changes/SubmittedTogetherInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
rename to java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
diff --git a/java/com/google/gerrit/extensions/api/changes/TopicInput.java b/java/com/google/gerrit/extensions/api/changes/TopicInput.java
new file mode 100644
index 0000000..12240d2
--- /dev/null
+++ b/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/config/AccessCheckInfo.java b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
rename to java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInput.java b/java/com/google/gerrit/extensions/api/config/AccessCheckInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInput.java
rename to java/com/google/gerrit/extensions/api/config/AccessCheckInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Config.java b/java/com/google/gerrit/extensions/api/config/Config.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Config.java
rename to java/com/google/gerrit/extensions/api/config/Config.java
diff --git a/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java b/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
new file mode 100644
index 0000000..2c166d0
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.config;
+
+import java.util.List;
+import java.util.Objects;
+
+public class ConsistencyCheckInfo {
+  public CheckAccountsResultInfo checkAccountsResult;
+  public CheckAccountExternalIdsResultInfo checkAccountExternalIdsResult;
+  public CheckGroupsResultInfo checkGroupsResult;
+
+  public static class CheckAccountsResultInfo {
+    public List<ConsistencyProblemInfo> problems;
+
+    public CheckAccountsResultInfo(List<ConsistencyProblemInfo> problems) {
+      this.problems = problems;
+    }
+  }
+
+  public static class CheckAccountExternalIdsResultInfo {
+    public List<ConsistencyProblemInfo> problems;
+
+    public CheckAccountExternalIdsResultInfo(List<ConsistencyProblemInfo> problems) {
+      this.problems = problems;
+    }
+  }
+
+  public static class CheckGroupsResultInfo {
+    public List<ConsistencyProblemInfo> problems;
+
+    public CheckGroupsResultInfo(List<ConsistencyProblemInfo> problems) {
+      this.problems = problems;
+    }
+  }
+
+  public static class ConsistencyProblemInfo {
+    public enum Status {
+      ERROR,
+      WARNING,
+    }
+
+    public final Status status;
+    public final String message;
+
+    public ConsistencyProblemInfo(Status status, String message) {
+      this.status = status;
+      this.message = message;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof ConsistencyProblemInfo) {
+        ConsistencyProblemInfo other = ((ConsistencyProblemInfo) o);
+        return Objects.equals(status, other.status) && Objects.equals(message, other.message);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(status, message);
+    }
+
+    @Override
+    public String toString() {
+      return status.name() + ": " + message;
+    }
+
+    public static ConsistencyProblemInfo warning(String fmt, Object... args) {
+      return new ConsistencyProblemInfo(Status.WARNING, String.format(fmt, args));
+    }
+
+    public static ConsistencyProblemInfo error(String fmt, Object... args) {
+      return new ConsistencyProblemInfo(Status.ERROR, String.format(fmt, args));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java b/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java
new file mode 100644
index 0000000..fbc7e27
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.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.config;
+
+public class ConsistencyCheckInput {
+  public CheckAccountsInput checkAccounts;
+  public CheckAccountExternalIdsInput checkAccountExternalIds;
+  public CheckGroupsInput checkGroups;
+
+  public static class CheckAccountsInput {}
+
+  public static class CheckAccountExternalIdsInput {}
+
+  public static class CheckGroupsInput {}
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java b/java/com/google/gerrit/extensions/api/config/Server.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
rename to java/com/google/gerrit/extensions/api/config/Server.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupApi.java b/java/com/google/gerrit/extensions/api/groups/GroupApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupApi.java
rename to java/com/google/gerrit/extensions/api/groups/GroupApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupInput.java b/java/com/google/gerrit/extensions/api/groups/GroupInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupInput.java
rename to java/com/google/gerrit/extensions/api/groups/GroupInput.java
diff --git a/java/com/google/gerrit/extensions/api/groups/Groups.java b/java/com/google/gerrit/extensions/api/groups/Groups.java
new file mode 100644
index 0000000..0243ba3
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/groups/Groups.java
@@ -0,0 +1,323 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.groups;
+
+import com.google.gerrit.extensions.client.ListGroupsOption;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+
+public interface Groups {
+  /**
+   * Look up a group by ID.
+   *
+   * <p><strong>Note:</strong> This method eagerly reads the group. Methods that mutate the group do
+   * not necessarily re-read the group. Therefore, calling a getter method on an instance after
+   * calling a mutation method on that same instance is not guaranteed to reflect the mutation. It
+   * is not recommended to store references to {@code groupApi} instances.
+   *
+   * @param id any identifier supported by the REST API, including group name or UUID.
+   * @return API for accessing the group.
+   * @throws RestApiException if an error occurred.
+   */
+  GroupApi id(String id) throws RestApiException;
+
+  /** Create a new group with the given name and default options. */
+  GroupApi create(String name) throws RestApiException;
+
+  /** Create a new group. */
+  GroupApi create(GroupInput input) throws RestApiException;
+
+  /** @return new request for listing groups. */
+  ListRequest list();
+
+  /**
+   * Query groups.
+   *
+   * <p>Example code: {@code query().withQuery("inname:test").withLimit(10).get()}
+   *
+   * @return API for setting parameters and getting result.
+   */
+  QueryRequest query();
+
+  /**
+   * Query groups.
+   *
+   * <p>Shortcut API for {@code query().withQuery(String)}.
+   *
+   * @see #query()
+   */
+  QueryRequest query(String query);
+
+  abstract class ListRequest {
+    private final EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
+    private final List<String> projects = new ArrayList<>();
+    private final List<String> groups = new ArrayList<>();
+
+    private boolean visibleToAll;
+    private String user;
+    private boolean owned;
+    private int limit;
+    private int start;
+    private String substring;
+    private String suggest;
+    private String regex;
+    private String ownedBy;
+
+    public List<GroupInfo> get() throws RestApiException {
+      Map<String, GroupInfo> map = getAsMap();
+      List<GroupInfo> result = new ArrayList<>(map.size());
+      for (Map.Entry<String, GroupInfo> e : map.entrySet()) {
+        // ListGroups "helpfully" nulls out names when converting to a map.
+        e.getValue().name = e.getKey();
+        result.add(e.getValue());
+      }
+      return Collections.unmodifiableList(result);
+    }
+
+    public abstract Map<String, GroupInfo> getAsMap() throws RestApiException;
+
+    public ListRequest addOption(ListGroupsOption option) {
+      options.add(option);
+      return this;
+    }
+
+    public ListRequest addOptions(ListGroupsOption... options) {
+      return addOptions(Arrays.asList(options));
+    }
+
+    public ListRequest addOptions(Iterable<ListGroupsOption> options) {
+      for (ListGroupsOption option : options) {
+        this.options.add(option);
+      }
+      return this;
+    }
+
+    public ListRequest withProject(String project) {
+      projects.add(project);
+      return this;
+    }
+
+    public ListRequest addGroup(String uuid) {
+      groups.add(uuid);
+      return this;
+    }
+
+    public ListRequest withVisibleToAll(boolean visible) {
+      visibleToAll = visible;
+      return this;
+    }
+
+    public ListRequest withUser(String user) {
+      this.user = user;
+      return this;
+    }
+
+    public ListRequest withOwned(boolean owned) {
+      this.owned = owned;
+      return this;
+    }
+
+    public ListRequest withLimit(int limit) {
+      this.limit = limit;
+      return this;
+    }
+
+    public ListRequest withStart(int start) {
+      this.start = start;
+      return this;
+    }
+
+    public ListRequest withSubstring(String substring) {
+      this.substring = substring;
+      return this;
+    }
+
+    public ListRequest withRegex(String regex) {
+      this.regex = regex;
+      return this;
+    }
+
+    public ListRequest withSuggest(String suggest) {
+      this.suggest = suggest;
+      return this;
+    }
+
+    public ListRequest withOwnedBy(String ownedBy) {
+      this.ownedBy = ownedBy;
+      return this;
+    }
+
+    public EnumSet<ListGroupsOption> getOptions() {
+      return options;
+    }
+
+    public List<String> getProjects() {
+      return Collections.unmodifiableList(projects);
+    }
+
+    public List<String> getGroups() {
+      return Collections.unmodifiableList(groups);
+    }
+
+    public boolean getVisibleToAll() {
+      return visibleToAll;
+    }
+
+    public String getUser() {
+      return user;
+    }
+
+    public boolean getOwned() {
+      return owned;
+    }
+
+    public int getLimit() {
+      return limit;
+    }
+
+    public int getStart() {
+      return start;
+    }
+
+    public String getSubstring() {
+      return substring;
+    }
+
+    public String getRegex() {
+      return regex;
+    }
+
+    public String getSuggest() {
+      return suggest;
+    }
+
+    public String getOwnedBy() {
+      return ownedBy;
+    }
+  }
+
+  /**
+   * API for setting parameters and getting result. Used for {@code query()}.
+   *
+   * @see #query()
+   */
+  abstract class QueryRequest {
+    private String query;
+    private int limit;
+    private int start;
+    private EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
+
+    /** Execute query and returns the matched groups as list. */
+    public abstract List<GroupInfo> 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 groups. Optional; server-default is used when not provided.
+     */
+    public QueryRequest withLimit(int limit) {
+      this.limit = limit;
+      return this;
+    }
+
+    /** Set number of groups to skip. Optional; no groups are skipped when not provided. */
+    public QueryRequest withStart(int start) {
+      this.start = start;
+      return this;
+    }
+
+    public QueryRequest withOption(ListGroupsOption options) {
+      this.options.add(options);
+      return this;
+    }
+
+    public QueryRequest withOptions(ListGroupsOption... options) {
+      this.options.addAll(Arrays.asList(options));
+      return this;
+    }
+
+    public QueryRequest withOptions(EnumSet<ListGroupsOption> options) {
+      this.options = options;
+      return this;
+    }
+
+    public String getQuery() {
+      return query;
+    }
+
+    public int getLimit() {
+      return limit;
+    }
+
+    public int getStart() {
+      return start;
+    }
+
+    public EnumSet<ListGroupsOption> getOptions() {
+      return options;
+    }
+  }
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements Groups {
+    @Override
+    public GroupApi id(String id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public GroupApi create(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public GroupApi create(GroupInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ListRequest list() {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public QueryRequest query() {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public QueryRequest query(String query) {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/groups/OwnerInput.java b/java/com/google/gerrit/extensions/api/groups/OwnerInput.java
new file mode 100644
index 0000000..8b0006e
--- /dev/null
+++ b/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/lfs/LfsDefinitions.java b/java/com/google/gerrit/extensions/api/lfs/LfsDefinitions.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/lfs/LfsDefinitions.java
rename to java/com/google/gerrit/extensions/api/lfs/LfsDefinitions.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/plugins/PluginApi.java b/java/com/google/gerrit/extensions/api/plugins/PluginApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/plugins/PluginApi.java
rename to java/com/google/gerrit/extensions/api/plugins/PluginApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/plugins/Plugins.java b/java/com/google/gerrit/extensions/api/plugins/Plugins.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/plugins/Plugins.java
rename to java/com/google/gerrit/extensions/api/plugins/Plugins.java
diff --git a/java/com/google/gerrit/extensions/api/projects/BanCommitInput.java b/java/com/google/gerrit/extensions/api/projects/BanCommitInput.java
new file mode 100644
index 0000000..b0f674f
--- /dev/null
+++ b/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/BranchApi.java b/java/com/google/gerrit/extensions/api/projects/BranchApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchApi.java
rename to java/com/google/gerrit/extensions/api/projects/BranchApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInfo.java b/java/com/google/gerrit/extensions/api/projects/BranchInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInfo.java
rename to java/com/google/gerrit/extensions/api/projects/BranchInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInput.java b/java/com/google/gerrit/extensions/api/projects/BranchInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInput.java
rename to java/com/google/gerrit/extensions/api/projects/BranchInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java
rename to java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
rename to java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java b/java/com/google/gerrit/extensions/api/projects/CommitApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java
rename to java/com/google/gerrit/extensions/api/projects/CommitApi.java
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
new file mode 100644
index 0000000..80115aa
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ActionInfo;
+import java.util.List;
+import java.util.Map;
+
+public class ConfigInfo {
+  public String description;
+
+  public InheritedBooleanInfo useContributorAgreements;
+  public InheritedBooleanInfo useContentMerge;
+  public InheritedBooleanInfo useSignedOffBy;
+  public InheritedBooleanInfo createNewChangeForAllNotInTarget;
+  public InheritedBooleanInfo requireChangeId;
+  public InheritedBooleanInfo enableSignedPush;
+  public InheritedBooleanInfo requireSignedPush;
+  public InheritedBooleanInfo rejectImplicitMerges;
+  public InheritedBooleanInfo privateByDefault;
+  public InheritedBooleanInfo enableReviewerByEmail;
+  public InheritedBooleanInfo matchAuthorToCommitterDate;
+  public InheritedBooleanInfo rejectEmptyCommit;
+
+  public MaxObjectSizeLimitInfo maxObjectSizeLimit;
+  @Deprecated // Equivalent to defaultSubmitType.value
+  public SubmitType submitType;
+  public SubmitTypeInfo defaultSubmitType;
+  public ProjectState state;
+  public Map<String, Map<String, ConfigParameterInfo>> pluginConfig;
+  public Map<String, ActionInfo> actions;
+
+  public Map<String, CommentLinkInfo> commentlinks;
+  public ThemeInfo theme;
+
+  public Map<String, List<String>> extensionPanelNames;
+
+  public static class InheritedBooleanInfo {
+    public Boolean value;
+    public InheritableBoolean configuredValue;
+    public Boolean inheritedValue;
+  }
+
+  public static class MaxObjectSizeLimitInfo {
+    public String value;
+    public String configuredValue;
+    public String inheritedValue;
+  }
+
+  public static class ConfigParameterInfo {
+    public String displayName;
+    public String description;
+    public String warning;
+    public ProjectConfigEntryType type;
+    public String value;
+    public Boolean editable;
+    public Boolean inheritable;
+    public String configuredValue;
+    public String inheritedValue;
+    public List<String> permittedValues;
+    public List<String> values;
+  }
+
+  public static class SubmitTypeInfo {
+    public SubmitType value;
+    public SubmitType configuredValue;
+    public SubmitType inheritedValue;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
new file mode 100644
index 0000000..37a2e8b
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.SubmitType;
+import java.util.Map;
+
+public class ConfigInput {
+  public String description;
+  public InheritableBoolean useContributorAgreements;
+  public InheritableBoolean useContentMerge;
+  public InheritableBoolean useSignedOffBy;
+  public InheritableBoolean createNewChangeForAllNotInTarget;
+  public InheritableBoolean requireChangeId;
+  public InheritableBoolean enableSignedPush;
+  public InheritableBoolean requireSignedPush;
+  public InheritableBoolean rejectImplicitMerges;
+  public InheritableBoolean privateByDefault;
+  public InheritableBoolean enableReviewerByEmail;
+  public InheritableBoolean matchAuthorToCommitterDate;
+  public InheritableBoolean rejectEmptyCommit;
+  public String maxObjectSizeLimit;
+  public SubmitType submitType;
+  public ProjectState state;
+  public Map<String, Map<String, ConfigValue>> pluginConfigValues;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigValue.java b/java/com/google/gerrit/extensions/api/projects/ConfigValue.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigValue.java
rename to java/com/google/gerrit/extensions/api/projects/ConfigValue.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardApi.java b/java/com/google/gerrit/extensions/api/projects/DashboardApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardApi.java
rename to java/com/google/gerrit/extensions/api/projects/DashboardApi.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardInfo.java b/java/com/google/gerrit/extensions/api/projects/DashboardInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardInfo.java
rename to java/com/google/gerrit/extensions/api/projects/DashboardInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardSectionInfo.java b/java/com/google/gerrit/extensions/api/projects/DashboardSectionInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardSectionInfo.java
rename to java/com/google/gerrit/extensions/api/projects/DashboardSectionInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteBranchesInput.java b/java/com/google/gerrit/extensions/api/projects/DeleteBranchesInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteBranchesInput.java
rename to java/com/google/gerrit/extensions/api/projects/DeleteBranchesInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteTagsInput.java b/java/com/google/gerrit/extensions/api/projects/DeleteTagsInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteTagsInput.java
rename to java/com/google/gerrit/extensions/api/projects/DeleteTagsInput.java
diff --git a/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java b/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
new file mode 100644
index 0000000..672602d
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
@@ -0,0 +1,19 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+public class DescriptionInput extends com.google.gerrit.extensions.common.DescriptionInput {
+  public String commitMessage;
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/HeadInput.java b/java/com/google/gerrit/extensions/api/projects/HeadInput.java
new file mode 100644
index 0000000..606cf52
--- /dev/null
+++ b/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/java/com/google/gerrit/extensions/api/projects/ParentInput.java b/java/com/google/gerrit/extensions/api/projects/ParentInput.java
new file mode 100644
index 0000000..6e481ae
--- /dev/null
+++ b/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/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
new file mode 100644
index 0000000..c9f47c2
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -0,0 +1,348 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.api.config.AccessCheckInfo;
+import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.List;
+
+public interface ProjectApi {
+  ProjectApi create() throws RestApiException;
+
+  ProjectApi create(ProjectInput in) throws RestApiException;
+
+  ProjectInfo get() throws RestApiException;
+
+  String description() throws RestApiException;
+
+  void description(DescriptionInput in) throws RestApiException;
+
+  ProjectAccessInfo access() throws RestApiException;
+
+  ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException;
+
+  ChangeInfo accessChange(ProjectAccessInput p) throws RestApiException;
+
+  AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException;
+
+  ConfigInfo config() throws RestApiException;
+
+  ConfigInfo config(ConfigInput in) throws RestApiException;
+
+  ListRefsRequest<BranchInfo> branches();
+
+  ListRefsRequest<TagInfo> tags();
+
+  void deleteBranches(DeleteBranchesInput in) throws RestApiException;
+
+  void deleteTags(DeleteTagsInput in) throws RestApiException;
+
+  abstract class ListRefsRequest<T extends RefInfo> {
+    protected int limit;
+    protected int start;
+    protected String substring;
+    protected String regex;
+
+    public abstract List<T> get() throws RestApiException;
+
+    public ListRefsRequest<T> withLimit(int limit) {
+      this.limit = limit;
+      return this;
+    }
+
+    public ListRefsRequest<T> withStart(int start) {
+      this.start = start;
+      return this;
+    }
+
+    public ListRefsRequest<T> withSubstring(String substring) {
+      this.substring = substring;
+      return this;
+    }
+
+    public ListRefsRequest<T> withRegex(String regex) {
+      this.regex = regex;
+      return this;
+    }
+
+    public int getLimit() {
+      return limit;
+    }
+
+    public int getStart() {
+      return start;
+    }
+
+    public String getSubstring() {
+      return substring;
+    }
+
+    public String getRegex() {
+      return regex;
+    }
+  }
+
+  List<ProjectInfo> children() throws RestApiException;
+
+  List<ProjectInfo> children(boolean recursive) throws RestApiException;
+
+  ChildProjectApi child(String name) throws RestApiException;
+
+  /**
+   * Look up a branch by refname.
+   *
+   * <p><strong>Note:</strong> This method eagerly reads the branch. Methods that mutate the branch
+   * do not necessarily re-read the branch. Therefore, calling a getter method on an instance after
+   * calling a mutation method on that same instance is not guaranteed to reflect the mutation. It
+   * is not recommended to store references to {@code BranchApi} instances.
+   *
+   * @param ref branch name, with or without "refs/heads/" prefix.
+   * @throws RestApiException if a problem occurred reading the project.
+   * @return API for accessing the branch.
+   */
+  BranchApi branch(String ref) throws RestApiException;
+
+  /**
+   * Look up a tag by refname.
+   *
+   * <p>
+   *
+   * @param ref tag name, with or without "refs/tags/" prefix.
+   * @throws RestApiException if a problem occurred reading the project.
+   * @return API for accessing the tag.
+   */
+  TagApi tag(String ref) throws RestApiException;
+
+  /**
+   * Lookup a commit by its {@code ObjectId} string.
+   *
+   * @param commit the {@code ObjectId} string.
+   * @return API for accessing the commit.
+   */
+  CommitApi commit(String commit) throws RestApiException;
+
+  /**
+   * Lookup a dashboard by its name.
+   *
+   * @param name the name.
+   * @return API for accessing the dashboard.
+   */
+  DashboardApi dashboard(String name) throws RestApiException;
+
+  /**
+   * Get the project's default dashboard.
+   *
+   * @return API for accessing the dashboard.
+   */
+  DashboardApi defaultDashboard() throws RestApiException;
+
+  /**
+   * Set the project's default dashboard.
+   *
+   * @param name the dashboard to set as default.
+   */
+  void defaultDashboard(String name) throws RestApiException;
+
+  /** Remove the project's default dashboard. */
+  void removeDefaultDashboard() throws RestApiException;
+
+  abstract class ListDashboardsRequest {
+    public abstract List<DashboardInfo> get() throws RestApiException;
+  }
+
+  ListDashboardsRequest dashboards() throws RestApiException;
+
+  /** Get the name of the branch to which {@code HEAD} points. */
+  String head() throws RestApiException;
+
+  /**
+   * Set the project's {@code HEAD}.
+   *
+   * @param head the HEAD
+   */
+  void head(String head) throws RestApiException;
+
+  /** 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.
+   */
+  class NotImplemented implements ProjectApi {
+    @Override
+    public ProjectApi create() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ProjectApi create(ProjectInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ProjectInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public String description() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ProjectAccessInfo access() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ChangeInfo accessChange(ProjectAccessInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ConfigInfo config() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ConfigInfo config(ConfigInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void description(DescriptionInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ListRefsRequest<BranchInfo> branches() {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ListRefsRequest<TagInfo> tags() {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public List<ProjectInfo> children() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public List<ProjectInfo> children(boolean recursive) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ChildProjectApi child(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public BranchApi branch(String ref) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public TagApi tag(String ref) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void deleteBranches(DeleteBranchesInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void deleteTags(DeleteTagsInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public CommitApi commit(String commit) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public DashboardApi dashboard(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public DashboardApi defaultDashboard() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ListDashboardsRequest dashboards() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void defaultDashboard(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void removeDefaultDashboard() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public String head() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void head(String head) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @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/ProjectConfigEntryType.java b/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java
rename to java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectInput.java b/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
new file mode 100644
index 0000000..b7079ae
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.SubmitType;
+import java.util.List;
+import java.util.Map;
+
+public class ProjectInput {
+  public String name;
+  public String parent;
+  public String description;
+  public boolean permissionsOnly;
+  public boolean createEmptyCommit;
+  public SubmitType submitType;
+  public List<String> branches;
+  public List<String> owners;
+  public InheritableBoolean useContributorAgreements;
+  public InheritableBoolean useSignedOffBy;
+  public InheritableBoolean useContentMerge;
+  public InheritableBoolean requireChangeId;
+  public InheritableBoolean createNewChangeForAllNotInTarget;
+  public InheritableBoolean rejectEmptyCommit;
+  public String maxObjectSizeLimit;
+  public Map<String, Map<String, ConfigValue>> pluginConfigValues;
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/Projects.java b/java/com/google/gerrit/extensions/api/projects/Projects.java
new file mode 100644
index 0000000..85ec26f
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/Projects.java
@@ -0,0 +1,298 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+
+public interface Projects {
+  /**
+   * Look up a project by name.
+   *
+   * <p><strong>Note:</strong> This method eagerly reads the project. Methods that mutate the
+   * project do not necessarily re-read the project. Therefore, calling a getter method on an
+   * instance after calling a mutation method on that same instance is not guaranteed to reflect the
+   * mutation. It is not recommended to store references to {@code ProjectApi} instances.
+   *
+   * @param name project name.
+   * @return API for accessing the project.
+   * @throws RestApiException if an error occurred.
+   */
+  ProjectApi name(String name) throws RestApiException;
+
+  /**
+   * Create a project using the default configuration.
+   *
+   * @param name project name.
+   * @return API for accessing the newly-created project.
+   * @throws RestApiException if an error occurred.
+   */
+  ProjectApi create(String name) throws RestApiException;
+
+  /**
+   * Create a project.
+   *
+   * @param in project creation input; name must be set.
+   * @return API for accessing the newly-created project.
+   * @throws RestApiException if an error occurred.
+   */
+  ProjectApi create(ProjectInput in) throws RestApiException;
+
+  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,
+      PARENT_CANDIDATES,
+      PERMISSIONS,
+      ALL
+    }
+
+    private final List<String> branches = new ArrayList<>();
+    private boolean description;
+    private String prefix;
+    private String substring;
+    private String regex;
+    private int limit;
+    private int start;
+    private boolean showTree;
+    private boolean all;
+    private FilterType type = FilterType.ALL;
+    private ProjectState state = null;
+
+    public List<ProjectInfo> get() throws RestApiException {
+      Map<String, ProjectInfo> map = getAsMap();
+      List<ProjectInfo> result = new ArrayList<>(map.size());
+      for (Map.Entry<String, ProjectInfo> e : map.entrySet()) {
+        // ListProjects "helpfully" nulls out names when converting to a map.
+        e.getValue().name = e.getKey();
+        result.add(e.getValue());
+      }
+      return Collections.unmodifiableList(result);
+    }
+
+    public abstract SortedMap<String, ProjectInfo> getAsMap() throws RestApiException;
+
+    public ListRequest withDescription(boolean description) {
+      this.description = description;
+      return this;
+    }
+
+    public ListRequest withPrefix(String prefix) {
+      this.prefix = prefix;
+      return this;
+    }
+
+    public ListRequest withSubstring(String substring) {
+      this.substring = substring;
+      return this;
+    }
+
+    public ListRequest withRegex(String regex) {
+      this.regex = regex;
+      return this;
+    }
+
+    public ListRequest withLimit(int limit) {
+      this.limit = limit;
+      return this;
+    }
+
+    public ListRequest withStart(int start) {
+      this.start = start;
+      return this;
+    }
+
+    public ListRequest addShowBranch(String branch) {
+      branches.add(branch);
+      return this;
+    }
+
+    public ListRequest withTree(boolean show) {
+      showTree = show;
+      return this;
+    }
+
+    public ListRequest withType(FilterType type) {
+      this.type = type != null ? type : FilterType.ALL;
+      return this;
+    }
+
+    public ListRequest withAll(boolean all) {
+      this.all = all;
+      return this;
+    }
+
+    public ListRequest withState(ProjectState state) {
+      this.state = state;
+      return this;
+    }
+
+    public boolean getDescription() {
+      return description;
+    }
+
+    public String getPrefix() {
+      return prefix;
+    }
+
+    public String getSubstring() {
+      return substring;
+    }
+
+    public String getRegex() {
+      return regex;
+    }
+
+    public int getLimit() {
+      return limit;
+    }
+
+    public int getStart() {
+      return start;
+    }
+
+    public List<String> getBranches() {
+      return Collections.unmodifiableList(branches);
+    }
+
+    public boolean getShowTree() {
+      return showTree;
+    }
+
+    public FilterType getFilterType() {
+      return type;
+    }
+
+    public boolean isAll() {
+      return all;
+    }
+
+    public ProjectState getState() {
+      return state;
+    }
+  }
+
+  /**
+   * 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.
+   */
+  class NotImplemented implements Projects {
+    @Override
+    public ProjectApi name(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ProjectApi create(ProjectInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ProjectApi create(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    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/api/projects/RefInfo.java b/java/com/google/gerrit/extensions/api/projects/RefInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/RefInfo.java
rename to java/com/google/gerrit/extensions/api/projects/RefInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ReflogEntryInfo.java b/java/com/google/gerrit/extensions/api/projects/ReflogEntryInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ReflogEntryInfo.java
rename to java/com/google/gerrit/extensions/api/projects/ReflogEntryInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagApi.java b/java/com/google/gerrit/extensions/api/projects/TagApi.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagApi.java
rename to java/com/google/gerrit/extensions/api/projects/TagApi.java
diff --git a/java/com/google/gerrit/extensions/api/projects/TagInfo.java b/java/com/google/gerrit/extensions/api/projects/TagInfo.java
new file mode 100644
index 0000000..a6269fe
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/TagInfo.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import java.sql.Timestamp;
+import java.util.List;
+
+public class TagInfo extends RefInfo {
+  public String object;
+  public String message;
+  public GitPerson tagger;
+  public Timestamp created;
+  public List<WebLinkInfo> webLinks;
+
+  public TagInfo(
+      String ref,
+      String revision,
+      Boolean canDelete,
+      List<WebLinkInfo> webLinks,
+      Timestamp created) {
+    this.ref = ref;
+    this.revision = revision;
+    this.canDelete = canDelete;
+    this.webLinks = webLinks;
+    this.created = created;
+  }
+
+  public TagInfo(String ref, String revision, Boolean canDelete, List<WebLinkInfo> webLinks) {
+    this(ref, revision, canDelete, webLinks, null);
+  }
+
+  public TagInfo(
+      String ref,
+      String revision,
+      String object,
+      String message,
+      GitPerson tagger,
+      Boolean canDelete,
+      List<WebLinkInfo> webLinks,
+      Timestamp created) {
+    this(ref, revision, canDelete, webLinks, created);
+    this.object = object;
+    this.message = message;
+    this.tagger = tagger;
+    this.webLinks = webLinks;
+  }
+
+  public TagInfo(
+      String ref,
+      String revision,
+      String object,
+      String message,
+      GitPerson tagger,
+      Boolean canDelete,
+      List<WebLinkInfo> webLinks) {
+    this(ref, revision, object, message, tagger, canDelete, webLinks, null);
+    this.object = object;
+    this.message = message;
+    this.tagger = tagger;
+    this.webLinks = webLinks;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInput.java b/java/com/google/gerrit/extensions/api/projects/TagInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInput.java
rename to java/com/google/gerrit/extensions/api/projects/TagInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ThemeInfo.java b/java/com/google/gerrit/extensions/api/projects/ThemeInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ThemeInfo.java
rename to java/com/google/gerrit/extensions/api/projects/ThemeInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java b/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java
rename to java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java b/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java
rename to java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java b/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
rename to java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthTokenEncrypter.java b/java/com/google/gerrit/extensions/auth/oauth/OAuthTokenEncrypter.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthTokenEncrypter.java
rename to java/com/google/gerrit/extensions/auth/oauth/OAuthTokenEncrypter.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthUserInfo.java b/java/com/google/gerrit/extensions/auth/oauth/OAuthUserInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthUserInfo.java
rename to java/com/google/gerrit/extensions/auth/oauth/OAuthUserInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthVerifier.java b/java/com/google/gerrit/extensions/auth/oauth/OAuthVerifier.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthVerifier.java
rename to java/com/google/gerrit/extensions/auth/oauth/OAuthVerifier.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AccountFieldName.java b/java/com/google/gerrit/extensions/client/AccountFieldName.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AccountFieldName.java
rename to java/com/google/gerrit/extensions/client/AccountFieldName.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AuthType.java b/java/com/google/gerrit/extensions/client/AuthType.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AuthType.java
rename to java/com/google/gerrit/extensions/client/AuthType.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeKind.java b/java/com/google/gerrit/extensions/client/ChangeKind.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeKind.java
rename to java/com/google/gerrit/extensions/client/ChangeKind.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeStatus.java b/java/com/google/gerrit/extensions/client/ChangeStatus.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeStatus.java
rename to java/com/google/gerrit/extensions/client/ChangeStatus.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java b/java/com/google/gerrit/extensions/client/Comment.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
rename to java/com/google/gerrit/extensions/client/Comment.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
rename to java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
rename to java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
rename to java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GerritTopMenu.java b/java/com/google/gerrit/extensions/client/GerritTopMenu.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GerritTopMenu.java
rename to java/com/google/gerrit/extensions/client/GerritTopMenu.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GitBasicAuthPolicy.java b/java/com/google/gerrit/extensions/client/GitBasicAuthPolicy.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GitBasicAuthPolicy.java
rename to java/com/google/gerrit/extensions/client/GitBasicAuthPolicy.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/InheritableBoolean.java b/java/com/google/gerrit/extensions/client/InheritableBoolean.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/InheritableBoolean.java
rename to java/com/google/gerrit/extensions/client/InheritableBoolean.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/KeyMapType.java b/java/com/google/gerrit/extensions/client/KeyMapType.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/KeyMapType.java
rename to java/com/google/gerrit/extensions/client/KeyMapType.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListAccountsOption.java b/java/com/google/gerrit/extensions/client/ListAccountsOption.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListAccountsOption.java
rename to java/com/google/gerrit/extensions/client/ListAccountsOption.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListChangesOption.java b/java/com/google/gerrit/extensions/client/ListChangesOption.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListChangesOption.java
rename to java/com/google/gerrit/extensions/client/ListChangesOption.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListGroupsOption.java b/java/com/google/gerrit/extensions/client/ListGroupsOption.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListGroupsOption.java
rename to java/com/google/gerrit/extensions/client/ListGroupsOption.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/MenuItem.java b/java/com/google/gerrit/extensions/client/MenuItem.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/MenuItem.java
rename to java/com/google/gerrit/extensions/client/MenuItem.java
diff --git a/java/com/google/gerrit/extensions/client/ProjectState.java b/java/com/google/gerrit/extensions/client/ProjectState.java
new file mode 100644
index 0000000..4aee69c
--- /dev/null
+++ b/java/com/google/gerrit/extensions/client/ProjectState.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.client;
+
+public enum ProjectState {
+  /** Permits reading project state and contents as well as mutating data. */
+  ACTIVE(true, true),
+  /** Permits reading project state and contents. Does not permit any modifications. */
+  READ_ONLY(true, false),
+  /**
+   * Hides the project as if it was deleted, but makes requests fail with an error message that
+   * reveals the project's existence.
+   */
+  HIDDEN(false, false);
+
+  private final boolean permitsRead;
+  private final boolean permitsWrite;
+
+  ProjectState(boolean permitsRead, boolean permitsWrite) {
+    this.permitsRead = permitsRead;
+    this.permitsWrite = permitsWrite;
+  }
+
+  public boolean permitsRead() {
+    return permitsRead;
+  }
+
+  public boolean permitsWrite() {
+    return permitsWrite;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectWatchInfo.java b/java/com/google/gerrit/extensions/client/ProjectWatchInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectWatchInfo.java
rename to java/com/google/gerrit/extensions/client/ProjectWatchInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ReviewerState.java b/java/com/google/gerrit/extensions/client/ReviewerState.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ReviewerState.java
rename to java/com/google/gerrit/extensions/client/ReviewerState.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Side.java b/java/com/google/gerrit/extensions/client/Side.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Side.java
rename to java/com/google/gerrit/extensions/client/Side.java
diff --git a/java/com/google/gerrit/extensions/client/SubmitType.java b/java/com/google/gerrit/extensions/client/SubmitType.java
new file mode 100644
index 0000000..0e2f362
--- /dev/null
+++ b/java/com/google/gerrit/extensions/client/SubmitType.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.client;
+
+public enum SubmitType {
+  INHERIT,
+  FAST_FORWARD_ONLY,
+  MERGE_IF_NECESSARY,
+  REBASE_IF_NECESSARY,
+  REBASE_ALWAYS,
+  MERGE_ALWAYS,
+  CHERRY_PICK;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Theme.java b/java/com/google/gerrit/extensions/client/Theme.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Theme.java
rename to java/com/google/gerrit/extensions/client/Theme.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/UiType.java b/java/com/google/gerrit/extensions/client/UiType.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/UiType.java
rename to java/com/google/gerrit/extensions/client/UiType.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java b/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
rename to java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java b/java/com/google/gerrit/extensions/common/AccountInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
rename to java/com/google/gerrit/extensions/common/AccountInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountVisibility.java b/java/com/google/gerrit/extensions/common/AccountVisibility.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountVisibility.java
rename to java/com/google/gerrit/extensions/common/AccountVisibility.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountsInfo.java b/java/com/google/gerrit/extensions/common/AccountsInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountsInfo.java
rename to java/com/google/gerrit/extensions/common/AccountsInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ActionInfo.java b/java/com/google/gerrit/extensions/common/ActionInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ActionInfo.java
rename to java/com/google/gerrit/extensions/common/ActionInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInfo.java b/java/com/google/gerrit/extensions/common/AgreementInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInfo.java
rename to java/com/google/gerrit/extensions/common/AgreementInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInput.java b/java/com/google/gerrit/extensions/common/AgreementInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInput.java
rename to java/com/google/gerrit/extensions/common/AgreementInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
rename to java/com/google/gerrit/extensions/common/ApprovalInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AuthInfo.java b/java/com/google/gerrit/extensions/common/AuthInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AuthInfo.java
rename to java/com/google/gerrit/extensions/common/AuthInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AvatarInfo.java b/java/com/google/gerrit/extensions/common/AvatarInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AvatarInfo.java
rename to java/com/google/gerrit/extensions/common/AvatarInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/BlameInfo.java b/java/com/google/gerrit/extensions/common/BlameInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/BlameInfo.java
rename to java/com/google/gerrit/extensions/common/BlameInfo.java
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
new file mode 100644
index 0000000..9e02ae5
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class ChangeConfigInfo {
+  public Boolean allowBlame;
+  public Boolean showAssigneeInChangesTable;
+  public Boolean allowDrafts;
+  public Boolean disablePrivateChanges;
+  public int largeChange;
+  public String replyLabel;
+  public String replyTooltip;
+  public int updateDelay;
+  public Boolean submitWholeTopic;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
rename to java/com/google/gerrit/extensions/common/ChangeInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInput.java
rename to java/com/google/gerrit/extensions/common/ChangeInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
rename to java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeType.java b/java/com/google/gerrit/extensions/common/ChangeType.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeType.java
rename to java/com/google/gerrit/extensions/common/ChangeType.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommentInfo.java b/java/com/google/gerrit/extensions/common/CommentInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommentInfo.java
rename to java/com/google/gerrit/extensions/common/CommentInfo.java
diff --git a/java/com/google/gerrit/extensions/common/CommitInfo.java b/java/com/google/gerrit/extensions/common/CommitInfo.java
new file mode 100644
index 0000000..213b366
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/CommitInfo.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import static java.util.stream.Collectors.joining;
+
+import java.util.List;
+import java.util.Objects;
+
+public class CommitInfo {
+  public String commit;
+  public List<CommitInfo> parents;
+  public GitPerson author;
+  public GitPerson committer;
+  public String subject;
+  public String message;
+  public List<WebLinkInfo> webLinks;
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof CommitInfo)) {
+      return false;
+    }
+    CommitInfo c = (CommitInfo) o;
+    return Objects.equals(commit, c.commit)
+        && Objects.equals(parents, c.parents)
+        && Objects.equals(author, c.author)
+        && Objects.equals(committer, c.committer)
+        && Objects.equals(subject, c.subject)
+        && Objects.equals(message, c.message)
+        && Objects.equals(webLinks, c.webLinks);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(commit, parents, author, committer, subject, message, webLinks);
+  }
+
+  @Override
+  public String toString() {
+    // Using something like the raw commit format might be nice, but we can't depend on JGit here.
+    StringBuilder sb = new StringBuilder().append(getClass().getSimpleName()).append('{');
+    sb.append(commit);
+    sb.append(", parents=").append(parents.stream().map(p -> p.commit).collect(joining(", ")));
+    sb.append(", author=").append(author);
+    sb.append(", committer=").append(committer);
+    sb.append(", subject=").append(subject);
+    sb.append(", message=").append(message);
+    if (webLinks != null) {
+      sb.append(", webLinks=").append(webLinks);
+    }
+    return sb.append('}').toString();
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommitMessageInput.java b/java/com/google/gerrit/extensions/common/CommitMessageInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommitMessageInput.java
rename to java/com/google/gerrit/extensions/common/CommitMessageInput.java
diff --git a/java/com/google/gerrit/extensions/common/DescriptionInput.java b/java/com/google/gerrit/extensions/common/DescriptionInput.java
new file mode 100644
index 0000000..c0733dc
--- /dev/null
+++ b/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/DiffInfo.java b/java/com/google/gerrit/extensions/common/DiffInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DiffInfo.java
rename to java/com/google/gerrit/extensions/common/DiffInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java b/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java
rename to java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadInfo.java b/java/com/google/gerrit/extensions/common/DownloadInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadInfo.java
rename to java/com/google/gerrit/extensions/common/DownloadInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadSchemeInfo.java b/java/com/google/gerrit/extensions/common/DownloadSchemeInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadSchemeInfo.java
rename to java/com/google/gerrit/extensions/common/DownloadSchemeInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EditInfo.java b/java/com/google/gerrit/extensions/common/EditInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EditInfo.java
rename to java/com/google/gerrit/extensions/common/EditInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EmailInfo.java b/java/com/google/gerrit/extensions/common/EmailInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EmailInfo.java
rename to java/com/google/gerrit/extensions/common/EmailInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FetchInfo.java b/java/com/google/gerrit/extensions/common/FetchInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FetchInfo.java
rename to java/com/google/gerrit/extensions/common/FetchInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FileInfo.java b/java/com/google/gerrit/extensions/common/FileInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FileInfo.java
rename to java/com/google/gerrit/extensions/common/FileInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfo.java b/java/com/google/gerrit/extensions/common/FixReplacementInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfo.java
rename to java/com/google/gerrit/extensions/common/FixReplacementInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FixSuggestionInfo.java b/java/com/google/gerrit/extensions/common/FixSuggestionInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FixSuggestionInfo.java
rename to java/com/google/gerrit/extensions/common/FixSuggestionInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GerritInfo.java
rename to java/com/google/gerrit/extensions/common/GerritInfo.java
diff --git a/java/com/google/gerrit/extensions/common/GitPerson.java b/java/com/google/gerrit/extensions/common/GitPerson.java
new file mode 100644
index 0000000..904829c
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/GitPerson.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.sql.Timestamp;
+import java.util.Objects;
+
+public class GitPerson {
+  public String name;
+  public String email;
+  public Timestamp date;
+  public int tz;
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof GitPerson)) {
+      return false;
+    }
+    GitPerson p = (GitPerson) o;
+    return Objects.equals(name, p.name)
+        && Objects.equals(email, p.email)
+        && Objects.equals(date, p.date)
+        && tz == p.tz;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(name, email, date, tz);
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName()
+        + "{name="
+        + name
+        + ", email="
+        + email
+        + ", date="
+        + date
+        + ", tz="
+        + tz
+        + "}".toString();
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GpgKeyInfo.java b/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
rename to java/com/google/gerrit/extensions/common/GpgKeyInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java b/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
rename to java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupBaseInfo.java b/java/com/google/gerrit/extensions/common/GroupBaseInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupBaseInfo.java
rename to java/com/google/gerrit/extensions/common/GroupBaseInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupInfo.java b/java/com/google/gerrit/extensions/common/GroupInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupInfo.java
rename to java/com/google/gerrit/extensions/common/GroupInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupOptionsInfo.java b/java/com/google/gerrit/extensions/common/GroupOptionsInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupOptionsInfo.java
rename to java/com/google/gerrit/extensions/common/GroupOptionsInfo.java
diff --git a/java/com/google/gerrit/extensions/common/HttpPasswordInput.java b/java/com/google/gerrit/extensions/common/HttpPasswordInput.java
new file mode 100644
index 0000000..246c7cf
--- /dev/null
+++ b/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/java/com/google/gerrit/extensions/common/Input.java b/java/com/google/gerrit/extensions/common/Input.java
new file mode 100644
index 0000000..68f864c
--- /dev/null
+++ b/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/InstallPluginInput.java b/java/com/google/gerrit/extensions/common/InstallPluginInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/InstallPluginInput.java
rename to java/com/google/gerrit/extensions/common/InstallPluginInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/LabelInfo.java b/java/com/google/gerrit/extensions/common/LabelInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/LabelInfo.java
rename to java/com/google/gerrit/extensions/common/LabelInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/LabelTypeInfo.java b/java/com/google/gerrit/extensions/common/LabelTypeInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/LabelTypeInfo.java
rename to java/com/google/gerrit/extensions/common/LabelTypeInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeInput.java b/java/com/google/gerrit/extensions/common/MergeInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeInput.java
rename to java/com/google/gerrit/extensions/common/MergeInput.java
diff --git a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
new file mode 100644
index 0000000..53f5e07
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class MergePatchSetInput {
+  public String subject;
+  public boolean inheritParent;
+  public String baseChange;
+  public MergeInput merge;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeableInfo.java b/java/com/google/gerrit/extensions/common/MergeableInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeableInfo.java
rename to java/com/google/gerrit/extensions/common/MergeableInfo.java
diff --git a/java/com/google/gerrit/extensions/common/NameInput.java b/java/com/google/gerrit/extensions/common/NameInput.java
new file mode 100644
index 0000000..463eee1
--- /dev/null
+++ b/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/common/PluginConfigInfo.java b/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
rename to java/com/google/gerrit/extensions/common/PluginConfigInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
rename to java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginInfo.java b/java/com/google/gerrit/extensions/common/PluginInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginInfo.java
rename to java/com/google/gerrit/extensions/common/PluginInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProblemInfo.java b/java/com/google/gerrit/extensions/common/ProblemInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProblemInfo.java
rename to java/com/google/gerrit/extensions/common/ProblemInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProjectInfo.java b/java/com/google/gerrit/extensions/common/ProjectInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProjectInfo.java
rename to java/com/google/gerrit/extensions/common/ProjectInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PureRevertInfo.java b/java/com/google/gerrit/extensions/common/PureRevertInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PureRevertInfo.java
rename to java/com/google/gerrit/extensions/common/PureRevertInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PushCertificateInfo.java b/java/com/google/gerrit/extensions/common/PushCertificateInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PushCertificateInfo.java
rename to java/com/google/gerrit/extensions/common/PushCertificateInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RangeInfo.java b/java/com/google/gerrit/extensions/common/RangeInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RangeInfo.java
rename to java/com/google/gerrit/extensions/common/RangeInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReceiveInfo.java b/java/com/google/gerrit/extensions/common/ReceiveInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReceiveInfo.java
rename to java/com/google/gerrit/extensions/common/ReceiveInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
rename to java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RevisionInfo.java
rename to java/com/google/gerrit/extensions/common/RevisionInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfo.java b/java/com/google/gerrit/extensions/common/RobotCommentInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfo.java
rename to java/com/google/gerrit/extensions/common/RobotCommentInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ServerInfo.java b/java/com/google/gerrit/extensions/common/ServerInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ServerInfo.java
rename to java/com/google/gerrit/extensions/common/ServerInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SetDashboardInput.java b/java/com/google/gerrit/extensions/common/SetDashboardInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SetDashboardInput.java
rename to java/com/google/gerrit/extensions/common/SetDashboardInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshKeyInfo.java b/java/com/google/gerrit/extensions/common/SshKeyInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshKeyInfo.java
rename to java/com/google/gerrit/extensions/common/SshKeyInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshdInfo.java b/java/com/google/gerrit/extensions/common/SshdInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshdInfo.java
rename to java/com/google/gerrit/extensions/common/SshdInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestInfo.java b/java/com/google/gerrit/extensions/common/SuggestInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestInfo.java
rename to java/com/google/gerrit/extensions/common/SuggestInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestedReviewerInfo.java b/java/com/google/gerrit/extensions/common/SuggestedReviewerInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestedReviewerInfo.java
rename to java/com/google/gerrit/extensions/common/SuggestedReviewerInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TestSubmitRuleInput.java b/java/com/google/gerrit/extensions/common/TestSubmitRuleInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TestSubmitRuleInput.java
rename to java/com/google/gerrit/extensions/common/TestSubmitRuleInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TrackingIdInfo.java b/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
rename to java/com/google/gerrit/extensions/common/TrackingIdInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/UserConfigInfo.java b/java/com/google/gerrit/extensions/common/UserConfigInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/UserConfigInfo.java
rename to java/com/google/gerrit/extensions/common/UserConfigInfo.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/VotingRangeInfo.java b/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
rename to java/com/google/gerrit/extensions/common/VotingRangeInfo.java
diff --git a/java/com/google/gerrit/extensions/common/WebLinkInfo.java b/java/com/google/gerrit/extensions/common/WebLinkInfo.java
new file mode 100644
index 0000000..3af5aba
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/WebLinkInfo.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.gerrit.extensions.webui.WebLink.Target;
+import java.util.Objects;
+
+public class WebLinkInfo {
+  public String name;
+  public String imageUrl;
+  public String url;
+  public String target;
+
+  public WebLinkInfo(String name, String imageUrl, String url, String target) {
+    this.name = name;
+    this.imageUrl = imageUrl;
+    this.url = url;
+    this.target = target;
+  }
+
+  public WebLinkInfo(String name, String imageUrl, String url) {
+    this(name, imageUrl, url, Target.SELF);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof WebLinkInfo)) {
+      return false;
+    }
+    WebLinkInfo i = (WebLinkInfo) o;
+    return Objects.equals(name, i.name)
+        && Objects.equals(imageUrl, i.imageUrl)
+        && Objects.equals(url, i.url)
+        && Objects.equals(target, i.target);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(name, imageUrl, url, target);
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName()
+        + "{name="
+        + name
+        + ", imageUrl="
+        + imageUrl
+        + ", url="
+        + url
+        + ", target"
+        + target
+        + "}".toString();
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/BUILD b/java/com/google/gerrit/extensions/common/testing/BUILD
new file mode 100644
index 0000000..82dd425
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/BUILD
@@ -0,0 +1,12 @@
+java_library(
+    name = "common-test-util",
+    testonly = 1,
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/truth",
+        "//lib:truth",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
new file mode 100644
index 0000000..6dd5ce4
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.truth.ListSubject;
+
+public class CommitInfoSubject extends Subject<CommitInfoSubject, CommitInfo> {
+
+  public static CommitInfoSubject assertThat(CommitInfo commitInfo) {
+    return assertAbout(CommitInfoSubject::new).that(commitInfo);
+  }
+
+  private CommitInfoSubject(FailureMetadata failureMetadata, CommitInfo commitInfo) {
+    super(failureMetadata, commitInfo);
+  }
+
+  public StringSubject commit() {
+    isNotNull();
+    CommitInfo commitInfo = actual();
+    return Truth.assertThat(commitInfo.commit).named("commit");
+  }
+
+  public ListSubject<CommitInfoSubject, CommitInfo> parents() {
+    isNotNull();
+    CommitInfo commitInfo = actual();
+    return ListSubject.assertThat(commitInfo.parents, CommitInfoSubject::assertThat)
+        .named("parents");
+  }
+
+  public GitPersonSubject committer() {
+    isNotNull();
+    CommitInfo commitInfo = actual();
+    return GitPersonSubject.assertThat(commitInfo.committer).named("committer");
+  }
+
+  public GitPersonSubject author() {
+    isNotNull();
+    CommitInfo commitInfo = actual();
+    return GitPersonSubject.assertThat(commitInfo.author).named("author");
+  }
+
+  public StringSubject message() {
+    isNotNull();
+    CommitInfo commitInfo = actual();
+    return Truth.assertThat(commitInfo.message).named("message");
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
new file mode 100644
index 0000000..48f7f45
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.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.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+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> {
+
+  public static ContentEntrySubject assertThat(ContentEntry contentEntry) {
+    return assertAbout(ContentEntrySubject::new).that(contentEntry);
+  }
+
+  private ContentEntrySubject(FailureMetadata failureMetadata, ContentEntry contentEntry) {
+    super(failureMetadata, contentEntry);
+  }
+
+  public void isDueToRebase() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    Truth.assertWithMessage("Entry should be marked 'dueToRebase'")
+        .that(contentEntry.dueToRebase)
+        .named("dueToRebase")
+        .isTrue();
+  }
+
+  public void isNotDueToRebase() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    Truth.assertWithMessage("Entry should not be marked 'dueToRebase'")
+        .that(contentEntry.dueToRebase)
+        .named("dueToRebase")
+        .isNull();
+  }
+
+  public ListSubject<StringSubject, String> commonLines() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    return ListSubject.assertThat(contentEntry.ab, Truth::assertThat).named("common lines");
+  }
+
+  public ListSubject<StringSubject, String> linesOfA() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    return ListSubject.assertThat(contentEntry.a, Truth::assertThat).named("lines of 'a'");
+  }
+
+  public ListSubject<StringSubject, String> linesOfB() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    return ListSubject.assertThat(contentEntry.b, Truth::assertThat).named("lines of 'b'");
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
new file mode 100644
index 0000000..6918325
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.ComparableSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.DiffInfo.ContentEntry;
+import com.google.gerrit.truth.ListSubject;
+
+public class DiffInfoSubject extends Subject<DiffInfoSubject, DiffInfo> {
+
+  public static DiffInfoSubject assertThat(DiffInfo diffInfo) {
+    return assertAbout(DiffInfoSubject::new).that(diffInfo);
+  }
+
+  private DiffInfoSubject(FailureMetadata failureMetadata, DiffInfo diffInfo) {
+    super(failureMetadata, diffInfo);
+  }
+
+  public ListSubject<ContentEntrySubject, ContentEntry> content() {
+    isNotNull();
+    DiffInfo diffInfo = actual();
+    return ListSubject.assertThat(diffInfo.content, ContentEntrySubject::assertThat)
+        .named("content");
+  }
+
+  public ComparableSubject<?, ChangeType> changeType() {
+    isNotNull();
+    DiffInfo diffInfo = actual();
+    return Truth.assertThat(diffInfo.changeType).named("changeType");
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
new file mode 100644
index 0000000..84ad61c
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.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.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.truth.OptionalSubject;
+import java.util.Optional;
+
+public class EditInfoSubject extends Subject<EditInfoSubject, EditInfo> {
+
+  public static EditInfoSubject assertThat(EditInfo editInfo) {
+    return assertAbout(EditInfoSubject::new).that(editInfo);
+  }
+
+  public static OptionalSubject<EditInfoSubject, EditInfo> assertThat(
+      Optional<EditInfo> editInfoOptional) {
+    return OptionalSubject.assertThat(editInfoOptional, EditInfoSubject::assertThat);
+  }
+
+  private EditInfoSubject(FailureMetadata failureMetadata, EditInfo editInfo) {
+    super(failureMetadata, editInfo);
+  }
+
+  public CommitInfoSubject commit() {
+    isNotNull();
+    EditInfo editInfo = actual();
+    return CommitInfoSubject.assertThat(editInfo.commit).named("commit");
+  }
+
+  public StringSubject baseRevision() {
+    isNotNull();
+    EditInfo editInfo = actual();
+    return Truth.assertThat(editInfo.baseRevision).named("baseRevision");
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
new file mode 100644
index 0000000..b088016
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.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.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.ComparableSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.common.FileInfo;
+
+public class FileInfoSubject extends Subject<FileInfoSubject, FileInfo> {
+
+  public static FileInfoSubject assertThat(FileInfo fileInfo) {
+    return assertAbout(FileInfoSubject::new).that(fileInfo);
+  }
+
+  private FileInfoSubject(FailureMetadata failureMetadata, FileInfo fileInfo) {
+    super(failureMetadata, fileInfo);
+  }
+
+  public IntegerSubject linesInserted() {
+    isNotNull();
+    FileInfo fileInfo = actual();
+    return Truth.assertThat(fileInfo.linesInserted).named("linesInserted");
+  }
+
+  public IntegerSubject linesDeleted() {
+    isNotNull();
+    FileInfo fileInfo = actual();
+    return Truth.assertThat(fileInfo.linesDeleted).named("linesDeleted");
+  }
+
+  public ComparableSubject<?, Character> status() {
+    isNotNull();
+    FileInfo fileInfo = actual();
+    return Truth.assertThat(fileInfo.status).named("status");
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java
new file mode 100644
index 0000000..b56d399
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/FixReplacementInfoSubject.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+
+public class FixReplacementInfoSubject
+    extends Subject<FixReplacementInfoSubject, FixReplacementInfo> {
+
+  public static FixReplacementInfoSubject assertThat(FixReplacementInfo fixReplacementInfo) {
+    return assertAbout(FixReplacementInfoSubject::new).that(fixReplacementInfo);
+  }
+
+  private FixReplacementInfoSubject(
+      FailureMetadata failureMetadata, FixReplacementInfo fixReplacementInfo) {
+    super(failureMetadata, fixReplacementInfo);
+  }
+
+  public StringSubject path() {
+    return Truth.assertThat(actual().path).named("path");
+  }
+
+  public RangeSubject range() {
+    return RangeSubject.assertThat(actual().range).named("range");
+  }
+
+  public StringSubject replacement() {
+    return Truth.assertThat(actual().replacement).named("replacement");
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java
new file mode 100644
index 0000000..7a6da9c
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/FixSuggestionInfoSubject.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.truth.ListSubject;
+
+public class FixSuggestionInfoSubject extends Subject<FixSuggestionInfoSubject, FixSuggestionInfo> {
+
+  public static FixSuggestionInfoSubject assertThat(FixSuggestionInfo fixSuggestionInfo) {
+    return assertAbout(FixSuggestionInfoSubject::new).that(fixSuggestionInfo);
+  }
+
+  private FixSuggestionInfoSubject(
+      FailureMetadata failureMetadata, FixSuggestionInfo fixSuggestionInfo) {
+    super(failureMetadata, fixSuggestionInfo);
+  }
+
+  public StringSubject fixId() {
+    return Truth.assertThat(actual().fixId).named("fixId");
+  }
+
+  public ListSubject<FixReplacementInfoSubject, FixReplacementInfo> replacements() {
+    return ListSubject.assertThat(actual().replacements, FixReplacementInfoSubject::assertThat)
+        .named("replacements");
+  }
+
+  public FixReplacementInfoSubject onlyReplacement() {
+    return replacements().onlyElement();
+  }
+
+  public StringSubject description() {
+    return Truth.assertThat(actual().description).named("description");
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
new file mode 100644
index 0000000..cdbef34
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.ComparableSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.common.GitPerson;
+import java.sql.Timestamp;
+import java.util.Date;
+import org.eclipse.jgit.lib.PersonIdent;
+
+public class GitPersonSubject extends Subject<GitPersonSubject, GitPerson> {
+
+  public static GitPersonSubject assertThat(GitPerson gitPerson) {
+    return assertAbout(GitPersonSubject::new).that(gitPerson);
+  }
+
+  private GitPersonSubject(FailureMetadata failureMetadata, GitPerson gitPerson) {
+    super(failureMetadata, gitPerson);
+  }
+
+  public StringSubject name() {
+    isNotNull();
+    GitPerson gitPerson = actual();
+    return Truth.assertThat(gitPerson.name).named("name");
+  }
+
+  public StringSubject email() {
+    isNotNull();
+    GitPerson gitPerson = actual();
+    return Truth.assertThat(gitPerson.email).named("email");
+  }
+
+  public ComparableSubject<?, Timestamp> date() {
+    isNotNull();
+    GitPerson gitPerson = actual();
+    return Truth.assertThat(gitPerson.date).named("date");
+  }
+
+  public IntegerSubject tz() {
+    isNotNull();
+    GitPerson gitPerson = actual();
+    return Truth.assertThat(gitPerson.tz).named("tz");
+  }
+
+  public void hasSameDateAs(GitPerson other) {
+    isNotNull();
+    assertThat(other).named("other").isNotNull();
+    date().isEqualTo(other.date);
+    tz().isEqualTo(other.tz);
+  }
+
+  public void matches(PersonIdent ident) {
+    isNotNull();
+    name().isEqualTo(ident.getName());
+    email().isEqualTo(ident.getEmailAddress());
+    Truth.assertThat(new Date(actual().date.getTime()))
+        .named("rounded date")
+        .isEqualTo(ident.getWhen());
+    tz().isEqualTo(ident.getTimeZoneOffset());
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/PathSubject.java b/java/com/google/gerrit/extensions/common/testing/PathSubject.java
new file mode 100644
index 0000000..0b6917c
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/PathSubject.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import java.nio.file.Path;
+
+public class PathSubject extends Subject<PathSubject, Path> {
+  private PathSubject(FailureMetadata failureMetadata, Path path) {
+    super(failureMetadata, path);
+  }
+
+  public static PathSubject assertThat(Path path) {
+    return assertAbout(PathSubject::new).that(path);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/RangeSubject.java b/java/com/google/gerrit/extensions/common/testing/RangeSubject.java
new file mode 100644
index 0000000..b478a7e
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/RangeSubject.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.client.Comment;
+
+public class RangeSubject extends Subject<RangeSubject, Comment.Range> {
+
+  public static RangeSubject assertThat(Comment.Range range) {
+    return assertAbout(RangeSubject::new).that(range);
+  }
+
+  private RangeSubject(FailureMetadata failureMetadata, Comment.Range range) {
+    super(failureMetadata, range);
+  }
+
+  public IntegerSubject startLine() {
+    return Truth.assertThat(actual().startLine).named("startLine");
+  }
+
+  public IntegerSubject startCharacter() {
+    return Truth.assertThat(actual().startCharacter).named("startCharacter");
+  }
+
+  public IntegerSubject endLine() {
+    return Truth.assertThat(actual().endLine).named("endLine");
+  }
+
+  public IntegerSubject endCharacter() {
+    return Truth.assertThat(actual().endCharacter).named("endCharacter");
+  }
+
+  public void isValid() {
+    isNotNull();
+    if (!actual().isValid()) {
+      fail("is valid");
+    }
+  }
+
+  public void isInvalid() {
+    isNotNull();
+    if (actual().isValid()) {
+      fail("is invalid");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
new file mode 100644
index 0000000..c2bed86
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.truth.ListSubject;
+import java.util.List;
+
+public class RobotCommentInfoSubject extends Subject<RobotCommentInfoSubject, RobotCommentInfo> {
+
+  public static ListSubject<RobotCommentInfoSubject, RobotCommentInfo> assertThatList(
+      List<RobotCommentInfo> robotCommentInfos) {
+    return ListSubject.assertThat(robotCommentInfos, RobotCommentInfoSubject::assertThat)
+        .named("robotCommentInfos");
+  }
+
+  public static RobotCommentInfoSubject assertThat(RobotCommentInfo robotCommentInfo) {
+    return assertAbout(RobotCommentInfoSubject::new).that(robotCommentInfo);
+  }
+
+  private RobotCommentInfoSubject(
+      FailureMetadata failureMetadata, RobotCommentInfo robotCommentInfo) {
+    super(failureMetadata, robotCommentInfo);
+  }
+
+  public ListSubject<FixSuggestionInfoSubject, FixSuggestionInfo> fixSuggestions() {
+    return ListSubject.assertThat(actual().fixSuggestions, FixSuggestionInfoSubject::assertThat)
+        .named("fixSuggestions");
+  }
+
+  public FixSuggestionInfoSubject onlyFixSuggestion() {
+    return fixSuggestions().onlyElement();
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/conditions/BooleanCondition.java b/java/com/google/gerrit/extensions/conditions/BooleanCondition.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/conditions/BooleanCondition.java
rename to java/com/google/gerrit/extensions/conditions/BooleanCondition.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/conditions/PrivateInternals_BooleanCondition.java b/java/com/google/gerrit/extensions/conditions/PrivateInternals_BooleanCondition.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/conditions/PrivateInternals_BooleanCondition.java
rename to java/com/google/gerrit/extensions/conditions/PrivateInternals_BooleanCondition.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/CapabilityDefinition.java b/java/com/google/gerrit/extensions/config/CapabilityDefinition.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/CapabilityDefinition.java
rename to java/com/google/gerrit/extensions/config/CapabilityDefinition.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/CloneCommand.java b/java/com/google/gerrit/extensions/config/CloneCommand.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/CloneCommand.java
rename to java/com/google/gerrit/extensions/config/CloneCommand.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/DownloadCommand.java b/java/com/google/gerrit/extensions/config/DownloadCommand.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/DownloadCommand.java
rename to java/com/google/gerrit/extensions/config/DownloadCommand.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/DownloadScheme.java b/java/com/google/gerrit/extensions/config/DownloadScheme.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/DownloadScheme.java
rename to java/com/google/gerrit/extensions/config/DownloadScheme.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java b/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java
rename to java/com/google/gerrit/extensions/config/ExternalIncludedIn.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/FactoryModule.java b/java/com/google/gerrit/extensions/config/FactoryModule.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/FactoryModule.java
rename to java/com/google/gerrit/extensions/config/FactoryModule.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AccountIndexedListener.java b/java/com/google/gerrit/extensions/events/AccountIndexedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AccountIndexedListener.java
rename to java/com/google/gerrit/extensions/events/AccountIndexedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AgreementSignupListener.java b/java/com/google/gerrit/extensions/events/AgreementSignupListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AgreementSignupListener.java
rename to java/com/google/gerrit/extensions/events/AgreementSignupListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AssigneeChangedListener.java b/java/com/google/gerrit/extensions/events/AssigneeChangedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AssigneeChangedListener.java
rename to java/com/google/gerrit/extensions/events/AssigneeChangedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java b/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java
rename to java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeEvent.java b/java/com/google/gerrit/extensions/events/ChangeEvent.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeEvent.java
rename to java/com/google/gerrit/extensions/events/ChangeEvent.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java b/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
rename to java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java b/java/com/google/gerrit/extensions/events/ChangeMergedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java
rename to java/com/google/gerrit/extensions/events/ChangeMergedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java b/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java
rename to java/com/google/gerrit/extensions/events/ChangeRestoredListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRevertedListener.java b/java/com/google/gerrit/extensions/events/ChangeRevertedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRevertedListener.java
rename to java/com/google/gerrit/extensions/events/ChangeRevertedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java b/java/com/google/gerrit/extensions/events/CommentAddedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java
rename to java/com/google/gerrit/extensions/events/CommentAddedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java b/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
rename to java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GerritEvent.java b/java/com/google/gerrit/extensions/events/GerritEvent.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GerritEvent.java
rename to java/com/google/gerrit/extensions/events/GerritEvent.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java b/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
rename to java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GroupIndexedListener.java b/java/com/google/gerrit/extensions/events/GroupIndexedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GroupIndexedListener.java
rename to java/com/google/gerrit/extensions/events/GroupIndexedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java b/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java
rename to java/com/google/gerrit/extensions/events/HashtagsEditedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java b/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java
rename to java/com/google/gerrit/extensions/events/HeadUpdatedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/LifecycleListener.java b/java/com/google/gerrit/extensions/events/LifecycleListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/LifecycleListener.java
rename to java/com/google/gerrit/extensions/events/LifecycleListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java b/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java
rename to java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PluginEventListener.java b/java/com/google/gerrit/extensions/events/PluginEventListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PluginEventListener.java
rename to java/com/google/gerrit/extensions/events/PluginEventListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java b/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
rename to java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java b/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java
rename to java/com/google/gerrit/extensions/events/ProjectDeletedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectEvent.java b/java/com/google/gerrit/extensions/events/ProjectEvent.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectEvent.java
rename to java/com/google/gerrit/extensions/events/ProjectEvent.java
diff --git a/java/com/google/gerrit/extensions/events/ProjectIndexedListener.java b/java/com/google/gerrit/extensions/events/ProjectIndexedListener.java
new file mode 100644
index 0000000..93a610b
--- /dev/null
+++ b/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-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java b/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java
rename to java/com/google/gerrit/extensions/events/ReviewerAddedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerDeletedListener.java b/java/com/google/gerrit/extensions/events/ReviewerDeletedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerDeletedListener.java
rename to java/com/google/gerrit/extensions/events/ReviewerDeletedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java b/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java
rename to java/com/google/gerrit/extensions/events/RevisionCreatedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionEvent.java b/java/com/google/gerrit/extensions/events/RevisionEvent.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionEvent.java
rename to java/com/google/gerrit/extensions/events/RevisionEvent.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java b/java/com/google/gerrit/extensions/events/TopicEditedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java
rename to java/com/google/gerrit/extensions/events/TopicEditedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java b/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
rename to java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/VoteDeletedListener.java b/java/com/google/gerrit/extensions/events/VoteDeletedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/VoteDeletedListener.java
rename to java/com/google/gerrit/extensions/events/VoteDeletedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java b/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
rename to java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/persistence/DataSourceInterceptor.java b/java/com/google/gerrit/extensions/persistence/DataSourceInterceptor.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/persistence/DataSourceInterceptor.java
rename to java/com/google/gerrit/extensions/persistence/DataSourceInterceptor.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItem.java b/java/com/google/gerrit/extensions/registration/DynamicItem.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItem.java
rename to java/com/google/gerrit/extensions/registration/DynamicItem.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java b/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
rename to java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicMap.java b/java/com/google/gerrit/extensions/registration/DynamicMap.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicMap.java
rename to java/com/google/gerrit/extensions/registration/DynamicMap.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java b/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java
rename to java/com/google/gerrit/extensions/registration/DynamicMapProvider.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSet.java b/java/com/google/gerrit/extensions/registration/DynamicSet.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSet.java
rename to java/com/google/gerrit/extensions/registration/DynamicSet.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java b/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
rename to java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
rename to java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
rename to java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/RegistrationHandle.java b/java/com/google/gerrit/extensions/registration/RegistrationHandle.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/RegistrationHandle.java
rename to java/com/google/gerrit/extensions/registration/RegistrationHandle.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/ReloadableRegistrationHandle.java b/java/com/google/gerrit/extensions/registration/ReloadableRegistrationHandle.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/ReloadableRegistrationHandle.java
rename to java/com/google/gerrit/extensions/registration/ReloadableRegistrationHandle.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java b/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java
rename to java/com/google/gerrit/extensions/restapi/AcceptsCreate.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java b/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
rename to java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsPost.java b/java/com/google/gerrit/extensions/restapi/AcceptsPost.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsPost.java
rename to java/com/google/gerrit/extensions/restapi/AcceptsPost.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AuthException.java b/java/com/google/gerrit/extensions/restapi/AuthException.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AuthException.java
rename to java/com/google/gerrit/extensions/restapi/AuthException.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BadRequestException.java b/java/com/google/gerrit/extensions/restapi/BadRequestException.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BadRequestException.java
rename to java/com/google/gerrit/extensions/restapi/BadRequestException.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BinaryResult.java b/java/com/google/gerrit/extensions/restapi/BinaryResult.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BinaryResult.java
rename to java/com/google/gerrit/extensions/restapi/BinaryResult.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/CacheControl.java b/java/com/google/gerrit/extensions/restapi/CacheControl.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/CacheControl.java
rename to java/com/google/gerrit/extensions/restapi/CacheControl.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ChildCollection.java b/java/com/google/gerrit/extensions/restapi/ChildCollection.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ChildCollection.java
rename to java/com/google/gerrit/extensions/restapi/ChildCollection.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/DefaultInput.java b/java/com/google/gerrit/extensions/restapi/DefaultInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/DefaultInput.java
rename to java/com/google/gerrit/extensions/restapi/DefaultInput.java
diff --git a/java/com/google/gerrit/extensions/restapi/DeprecatedIdentifierException.java b/java/com/google/gerrit/extensions/restapi/DeprecatedIdentifierException.java
new file mode 100644
index 0000000..aa28cfc
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/DeprecatedIdentifierException.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.restapi;
+
+/** Named resource was accessed using a deprecated identifier. */
+public class DeprecatedIdentifierException extends BadRequestException {
+  private static final long serialVersionUID = 1L;
+
+  /** Requested resource using a deprecated identifier. */
+  public DeprecatedIdentifierException(String msg) {
+    super(msg);
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ETagView.java b/java/com/google/gerrit/extensions/restapi/ETagView.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ETagView.java
rename to java/com/google/gerrit/extensions/restapi/ETagView.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/IdString.java b/java/com/google/gerrit/extensions/restapi/IdString.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/IdString.java
rename to java/com/google/gerrit/extensions/restapi/IdString.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/MergeConflictException.java b/java/com/google/gerrit/extensions/restapi/MergeConflictException.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/MergeConflictException.java
rename to java/com/google/gerrit/extensions/restapi/MergeConflictException.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/MethodNotAllowedException.java b/java/com/google/gerrit/extensions/restapi/MethodNotAllowedException.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/MethodNotAllowedException.java
rename to java/com/google/gerrit/extensions/restapi/MethodNotAllowedException.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/NeedsParams.java b/java/com/google/gerrit/extensions/restapi/NeedsParams.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/NeedsParams.java
rename to java/com/google/gerrit/extensions/restapi/NeedsParams.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/NotImplementedException.java b/java/com/google/gerrit/extensions/restapi/NotImplementedException.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/NotImplementedException.java
rename to java/com/google/gerrit/extensions/restapi/NotImplementedException.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java b/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
rename to java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RawInput.java b/java/com/google/gerrit/extensions/restapi/RawInput.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RawInput.java
rename to java/com/google/gerrit/extensions/restapi/RawInput.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceConflictException.java b/java/com/google/gerrit/extensions/restapi/ResourceConflictException.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceConflictException.java
rename to java/com/google/gerrit/extensions/restapi/ResourceConflictException.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java b/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
rename to java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Response.java b/java/com/google/gerrit/extensions/restapi/Response.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Response.java
rename to java/com/google/gerrit/extensions/restapi/Response.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiException.java b/java/com/google/gerrit/extensions/restapi/RestApiException.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiException.java
rename to java/com/google/gerrit/extensions/restapi/RestApiException.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiModule.java b/java/com/google/gerrit/extensions/restapi/RestApiModule.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiModule.java
rename to java/com/google/gerrit/extensions/restapi/RestApiModule.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestCollection.java b/java/com/google/gerrit/extensions/restapi/RestCollection.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestCollection.java
rename to java/com/google/gerrit/extensions/restapi/RestCollection.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestModifyView.java b/java/com/google/gerrit/extensions/restapi/RestModifyView.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestModifyView.java
rename to java/com/google/gerrit/extensions/restapi/RestModifyView.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestReadView.java b/java/com/google/gerrit/extensions/restapi/RestReadView.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestReadView.java
rename to java/com/google/gerrit/extensions/restapi/RestReadView.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestResource.java b/java/com/google/gerrit/extensions/restapi/RestResource.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestResource.java
rename to java/com/google/gerrit/extensions/restapi/RestResource.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestView.java b/java/com/google/gerrit/extensions/restapi/RestView.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestView.java
rename to java/com/google/gerrit/extensions/restapi/RestView.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/TopLevelResource.java b/java/com/google/gerrit/extensions/restapi/TopLevelResource.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/TopLevelResource.java
rename to java/com/google/gerrit/extensions/restapi/TopLevelResource.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/UnprocessableEntityException.java b/java/com/google/gerrit/extensions/restapi/UnprocessableEntityException.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/UnprocessableEntityException.java
rename to java/com/google/gerrit/extensions/restapi/UnprocessableEntityException.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Url.java b/java/com/google/gerrit/extensions/restapi/Url.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Url.java
rename to java/com/google/gerrit/extensions/restapi/Url.java
diff --git a/java/com/google/gerrit/extensions/restapi/testing/BUILD b/java/com/google/gerrit/extensions/restapi/testing/BUILD
new file mode 100644
index 0000000..d035816
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/testing/BUILD
@@ -0,0 +1,11 @@
+java_library(
+    name = "restapi-test-util",
+    testonly = 1,
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/truth",
+        "//lib:truth",
+    ],
+)
diff --git a/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.java b/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.java
new file mode 100644
index 0000000..1867308
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/testing/BinaryResultSubject.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.extensions.restapi.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+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.Truth;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.truth.OptionalSubject;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Optional;
+
+public class BinaryResultSubject extends Subject<BinaryResultSubject, BinaryResult> {
+
+  public static BinaryResultSubject assertThat(BinaryResult binaryResult) {
+    return assertAbout(BinaryResultSubject::new).that(binaryResult);
+  }
+
+  public static OptionalSubject<BinaryResultSubject, BinaryResult> assertThat(
+      Optional<BinaryResult> binaryResultOptional) {
+    return OptionalSubject.assertThat(binaryResultOptional, BinaryResultSubject::assertThat);
+  }
+
+  private BinaryResultSubject(FailureMetadata failureMetadata, BinaryResult binaryResult) {
+    super(failureMetadata, binaryResult);
+  }
+
+  public StringSubject asString() throws IOException {
+    isNotNull();
+    // We shouldn't close the BinaryResult within this method as it might still
+    // be used afterwards. Besides, closing it doesn't have an effect for most
+    // implementations of a BinaryResult.
+    BinaryResult binaryResult = actual();
+    return Truth.assertThat(binaryResult.asString());
+  }
+
+  public PrimitiveByteArraySubject bytes() throws IOException {
+    isNotNull();
+    // We shouldn't close the BinaryResult within this method as it might still
+    // be used afterwards. Besides, closing it doesn't have an effect for most
+    // implementations of a BinaryResult.
+    BinaryResult binaryResult = actual();
+    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+    binaryResult.writeTo(byteArrayOutputStream);
+    byte[] bytes = byteArrayOutputStream.toByteArray();
+    return Truth.assertThat(bytes);
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/MessageOfTheDay.java b/java/com/google/gerrit/extensions/systemstatus/MessageOfTheDay.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/MessageOfTheDay.java
rename to java/com/google/gerrit/extensions/systemstatus/MessageOfTheDay.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/ServerInformation.java b/java/com/google/gerrit/extensions/systemstatus/ServerInformation.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/ServerInformation.java
rename to java/com/google/gerrit/extensions/systemstatus/ServerInformation.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/BranchWebLink.java b/java/com/google/gerrit/extensions/webui/BranchWebLink.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/BranchWebLink.java
rename to java/com/google/gerrit/extensions/webui/BranchWebLink.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/DiffWebLink.java b/java/com/google/gerrit/extensions/webui/DiffWebLink.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/DiffWebLink.java
rename to java/com/google/gerrit/extensions/webui/DiffWebLink.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileHistoryWebLink.java b/java/com/google/gerrit/extensions/webui/FileHistoryWebLink.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileHistoryWebLink.java
rename to java/com/google/gerrit/extensions/webui/FileHistoryWebLink.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileWebLink.java b/java/com/google/gerrit/extensions/webui/FileWebLink.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileWebLink.java
rename to java/com/google/gerrit/extensions/webui/FileWebLink.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/GwtPlugin.java b/java/com/google/gerrit/extensions/webui/GwtPlugin.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/GwtPlugin.java
rename to java/com/google/gerrit/extensions/webui/GwtPlugin.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/JavaScriptPlugin.java b/java/com/google/gerrit/extensions/webui/JavaScriptPlugin.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/JavaScriptPlugin.java
rename to java/com/google/gerrit/extensions/webui/JavaScriptPlugin.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ParentWebLink.java b/java/com/google/gerrit/extensions/webui/ParentWebLink.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ParentWebLink.java
rename to java/com/google/gerrit/extensions/webui/ParentWebLink.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
rename to java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PrivateInternals_UiActionDescription.java b/java/com/google/gerrit/extensions/webui/PrivateInternals_UiActionDescription.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PrivateInternals_UiActionDescription.java
rename to java/com/google/gerrit/extensions/webui/PrivateInternals_UiActionDescription.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ProjectWebLink.java b/java/com/google/gerrit/extensions/webui/ProjectWebLink.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ProjectWebLink.java
rename to java/com/google/gerrit/extensions/webui/ProjectWebLink.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TagWebLink.java b/java/com/google/gerrit/extensions/webui/TagWebLink.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TagWebLink.java
rename to java/com/google/gerrit/extensions/webui/TagWebLink.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TopMenu.java b/java/com/google/gerrit/extensions/webui/TopMenu.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TopMenu.java
rename to java/com/google/gerrit/extensions/webui/TopMenu.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiAction.java b/java/com/google/gerrit/extensions/webui/UiAction.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiAction.java
rename to java/com/google/gerrit/extensions/webui/UiAction.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiResult.java b/java/com/google/gerrit/extensions/webui/UiResult.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiResult.java
rename to java/com/google/gerrit/extensions/webui/UiResult.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebLink.java b/java/com/google/gerrit/extensions/webui/WebLink.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebLink.java
rename to java/com/google/gerrit/extensions/webui/WebLink.java
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebUiPlugin.java b/java/com/google/gerrit/extensions/webui/WebUiPlugin.java
similarity index 100%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebUiPlugin.java
rename to java/com/google/gerrit/extensions/webui/WebUiPlugin.java
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
new file mode 100644
index 0000000..bd6edab
--- /dev/null
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -0,0 +1,19 @@
+java_library(
+    name = "gpg",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib/bouncycastle:bcpg-neverlink",
+        "//lib/bouncycastle:bcprov-neverlink",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/BouncyCastleUtil.java b/java/com/google/gerrit/gpg/BouncyCastleUtil.java
similarity index 100%
rename from gerrit-gpg/src/main/java/com/google/gerrit/gpg/BouncyCastleUtil.java
rename to java/com/google/gerrit/gpg/BouncyCastleUtil.java
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/CheckResult.java b/java/com/google/gerrit/gpg/CheckResult.java
similarity index 100%
rename from gerrit-gpg/src/main/java/com/google/gerrit/gpg/CheckResult.java
rename to java/com/google/gerrit/gpg/CheckResult.java
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/Fingerprint.java b/java/com/google/gerrit/gpg/Fingerprint.java
similarity index 100%
rename from gerrit-gpg/src/main/java/com/google/gerrit/gpg/Fingerprint.java
rename to java/com/google/gerrit/gpg/Fingerprint.java
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java b/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
similarity index 100%
rename from gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
rename to java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java b/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
similarity index 100%
rename from gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
rename to java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GpgModule.java b/java/com/google/gerrit/gpg/GpgModule.java
similarity index 100%
rename from gerrit-gpg/src/main/java/com/google/gerrit/gpg/GpgModule.java
rename to java/com/google/gerrit/gpg/GpgModule.java
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyChecker.java b/java/com/google/gerrit/gpg/PublicKeyChecker.java
similarity index 100%
rename from gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyChecker.java
rename to java/com/google/gerrit/gpg/PublicKeyChecker.java
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyStore.java b/java/com/google/gerrit/gpg/PublicKeyStore.java
similarity index 100%
rename from gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyStore.java
rename to java/com/google/gerrit/gpg/PublicKeyStore.java
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PushCertificateChecker.java b/java/com/google/gerrit/gpg/PushCertificateChecker.java
similarity index 100%
rename from gerrit-gpg/src/main/java/com/google/gerrit/gpg/PushCertificateChecker.java
rename to java/com/google/gerrit/gpg/PushCertificateChecker.java
diff --git a/java/com/google/gerrit/gpg/SignedPushModule.java b/java/com/google/gerrit/gpg/SignedPushModule.java
new file mode 100644
index 0000000..c420f6f
--- /dev/null
+++ b/java/com/google/gerrit/gpg/SignedPushModule.java
@@ -0,0 +1,161 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.gpg;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.EnableSignedPush;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.ReceivePackInitializer;
+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;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.PreReceiveHook;
+import org.eclipse.jgit.transport.PreReceiveHookChain;
+import org.eclipse.jgit.transport.ReceivePack;
+import org.eclipse.jgit.transport.SignedPushConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class SignedPushModule extends AbstractModule {
+  private static final Logger log = LoggerFactory.getLogger(SignedPushModule.class);
+
+  @Override
+  protected void configure() {
+    if (!BouncyCastleUtil.havePGP()) {
+      throw new ProvisionException("Bouncy Castle PGP not installed");
+    }
+    bind(PublicKeyStore.class).toProvider(StoreProvider.class);
+    DynamicSet.bind(binder(), ReceivePackInitializer.class).to(Initializer.class);
+  }
+
+  @Singleton
+  private static class Initializer implements ReceivePackInitializer {
+    private final SignedPushConfig signedPushConfig;
+    private final SignedPushPreReceiveHook hook;
+    private final ProjectCache projectCache;
+
+    @Inject
+    Initializer(
+        @GerritServerConfig Config cfg,
+        @EnableSignedPush boolean enableSignedPush,
+        SignedPushPreReceiveHook hook,
+        ProjectCache projectCache) {
+      this.hook = hook;
+      this.projectCache = projectCache;
+
+      if (enableSignedPush) {
+        String seed = cfg.getString("receive", null, "certNonceSeed");
+        if (Strings.isNullOrEmpty(seed)) {
+          seed = randomString(64);
+        }
+        signedPushConfig = new SignedPushConfig();
+        signedPushConfig.setCertNonceSeed(seed);
+        signedPushConfig.setCertNonceSlopLimit(
+            cfg.getInt("receive", null, "certNonceSlop", 5 * 60));
+      } else {
+        signedPushConfig = null;
+      }
+    }
+
+    @Override
+    public void init(Project.NameKey project, ReceivePack rp) {
+      ProjectState ps = projectCache.get(project);
+      if (!ps.is(BooleanProjectConfig.ENABLE_SIGNED_PUSH)) {
+        rp.setSignedPushConfig(null);
+        return;
+      } else if (signedPushConfig == null) {
+        log.error(
+            "receive.enableSignedPush is true for project {} but"
+                + " false in gerrit.config, so signed push verification is"
+                + " disabled",
+            project.get());
+        rp.setSignedPushConfig(null);
+        return;
+      }
+      rp.setSignedPushConfig(signedPushConfig);
+
+      List<PreReceiveHook> hooks = new ArrayList<>(3);
+      if (ps.is(BooleanProjectConfig.REQUIRE_SIGNED_PUSH)) {
+        hooks.add(SignedPushPreReceiveHook.Required.INSTANCE);
+      }
+      hooks.add(hook);
+      hooks.add(rp.getPreReceiveHook());
+      rp.setPreReceiveHook(PreReceiveHookChain.newChain(hooks));
+    }
+  }
+
+  @Singleton
+  private static class StoreProvider implements Provider<PublicKeyStore> {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsers;
+
+    @Inject
+    StoreProvider(GitRepositoryManager repoManager, AllUsersName allUsers) {
+      this.repoManager = repoManager;
+      this.allUsers = allUsers;
+    }
+
+    @Override
+    public PublicKeyStore get() {
+      final Repository repo;
+      try {
+        repo = repoManager.openRepository(allUsers);
+      } catch (IOException e) {
+        throw new ProvisionException("Cannot open " + allUsers, e);
+      }
+      return new PublicKeyStore(repo) {
+        @Override
+        public void close() {
+          try {
+            super.close();
+          } finally {
+            repo.close();
+          }
+        }
+      };
+    }
+  }
+
+  private static String randomString(int len) {
+    Random random;
+    try {
+      random = SecureRandom.getInstance("SHA1PRNG");
+    } catch (NoSuchAlgorithmException e) {
+      throw new IllegalStateException(e);
+    }
+    StringBuilder sb = new StringBuilder(len);
+    for (int i = 0; i < len; i++) {
+      sb.append((char) random.nextInt());
+    }
+    return sb.toString();
+  }
+}
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java b/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
similarity index 100%
rename from gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
rename to java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
new file mode 100644
index 0000000..967259a
--- /dev/null
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.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;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.gpg.GerritPushCertificateChecker;
+import com.google.gerrit.gpg.PushCertificateChecker;
+import com.google.gerrit.gpg.server.GpgKeys;
+import com.google.gerrit.gpg.server.PostGpgKeys;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.GpgApiAdapter;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.bouncycastle.openpgp.PGPException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.transport.PushCertificate;
+import org.eclipse.jgit.transport.PushCertificateParser;
+
+public class GpgApiAdapterImpl implements GpgApiAdapter {
+  private final Provider<PostGpgKeys> postGpgKeys;
+  private final Provider<GpgKeys> gpgKeys;
+  private final GpgKeyApiImpl.Factory gpgKeyApiFactory;
+  private final GerritPushCertificateChecker.Factory pushCertCheckerFactory;
+
+  @Inject
+  GpgApiAdapterImpl(
+      Provider<PostGpgKeys> postGpgKeys,
+      Provider<GpgKeys> gpgKeys,
+      GpgKeyApiImpl.Factory gpgKeyApiFactory,
+      GerritPushCertificateChecker.Factory pushCertCheckerFactory) {
+    this.postGpgKeys = postGpgKeys;
+    this.gpgKeys = gpgKeys;
+    this.gpgKeyApiFactory = gpgKeyApiFactory;
+    this.pushCertCheckerFactory = pushCertCheckerFactory;
+  }
+
+  @Override
+  public boolean isEnabled() {
+    return true;
+  }
+
+  @Override
+  public Map<String, GpgKeyInfo> listGpgKeys(AccountResource account)
+      throws RestApiException, GpgException {
+    try {
+      return gpgKeys.get().list().apply(account);
+    } catch (OrmException | PGPException | IOException e) {
+      throw new GpgException(e);
+    }
+  }
+
+  @Override
+  public Map<String, GpgKeyInfo> putGpgKeys(
+      AccountResource account, List<String> add, List<String> delete)
+      throws RestApiException, GpgException {
+    GpgKeysInput in = new GpgKeysInput();
+    in.add = add;
+    in.delete = delete;
+    try {
+      return postGpgKeys.get().apply(account, in);
+    } catch (PGPException | OrmException | IOException | ConfigInvalidException e) {
+      throw new GpgException(e);
+    }
+  }
+
+  @Override
+  public GpgKeyApi gpgKey(AccountResource account, IdString idStr)
+      throws RestApiException, GpgException {
+    try {
+      return gpgKeyApiFactory.create(gpgKeys.get().parse(account, idStr));
+    } catch (PGPException | OrmException | IOException e) {
+      throw new GpgException(e);
+    }
+  }
+
+  @Override
+  public PushCertificateInfo checkPushCertificate(String certStr, IdentifiedUser expectedUser)
+      throws GpgException {
+    try {
+      PushCertificate cert = PushCertificateParser.fromString(certStr);
+      PushCertificateChecker.Result result =
+          pushCertCheckerFactory.create(expectedUser).setCheckNonce(false).check(cert);
+      PushCertificateInfo info = new PushCertificateInfo();
+      info.certificate = certStr;
+      info.key = GpgKeys.toJson(result.getPublicKey(), result.getCheckResult());
+      return info;
+    } catch (IOException e) {
+      throw new GpgException(e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/gpg/api/GpgApiModule.java b/java/com/google/gerrit/gpg/api/GpgApiModule.java
new file mode 100644
index 0000000..f0d34f3
--- /dev/null
+++ b/java/com/google/gerrit/gpg/api/GpgApiModule.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.gpg.api;
+
+import static com.google.gerrit.gpg.server.GpgKey.GPG_KEY_KIND;
+import static com.google.gerrit.server.account.AccountResource.ACCOUNT_KIND;
+
+import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
+import com.google.gerrit.extensions.common.GpgKeyInfo;
+import com.google.gerrit.extensions.common.PushCertificateInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.gpg.server.DeleteGpgKey;
+import com.google.gerrit.gpg.server.GpgKeys;
+import com.google.gerrit.gpg.server.PostGpgKeys;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.GpgApiAdapter;
+import java.util.List;
+import java.util.Map;
+
+public class GpgApiModule extends RestApiModule {
+  private final boolean enabled;
+
+  public GpgApiModule(boolean enabled) {
+    this.enabled = enabled;
+  }
+
+  @Override
+  protected void configure() {
+    if (!enabled) {
+      bind(GpgApiAdapter.class).to(NoGpgApi.class);
+      return;
+    }
+    bind(GpgApiAdapter.class).to(GpgApiAdapterImpl.class);
+    factory(GpgKeyApiImpl.Factory.class);
+
+    DynamicMap.mapOf(binder(), GPG_KEY_KIND);
+
+    child(ACCOUNT_KIND, "gpgkeys").to(GpgKeys.class);
+    post(ACCOUNT_KIND, "gpgkeys").to(PostGpgKeys.class);
+    get(GPG_KEY_KIND).to(GpgKeys.Get.class);
+    delete(GPG_KEY_KIND).to(DeleteGpgKey.class);
+  }
+
+  private static class NoGpgApi implements GpgApiAdapter {
+    private static final String MSG = "GPG key APIs disabled";
+
+    @Override
+    public boolean isEnabled() {
+      return false;
+    }
+
+    @Override
+    public Map<String, GpgKeyInfo> listGpgKeys(AccountResource account) {
+      throw new NotImplementedException(MSG);
+    }
+
+    @Override
+    public Map<String, GpgKeyInfo> putGpgKeys(
+        AccountResource account, List<String> add, List<String> delete) {
+      throw new NotImplementedException(MSG);
+    }
+
+    @Override
+    public GpgKeyApi gpgKey(AccountResource account, IdString idStr) {
+      throw new NotImplementedException(MSG);
+    }
+
+    @Override
+    public PushCertificateInfo checkPushCertificate(String certStr, IdentifiedUser expectedUser) {
+      throw new NotImplementedException(MSG);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
new file mode 100644
index 0000000..25b472d
--- /dev/null
+++ b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.gpg.api;
+
+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;
+import com.google.gerrit.gpg.server.GpgKeys;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import org.bouncycastle.openpgp.PGPException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public class GpgKeyApiImpl implements GpgKeyApi {
+  public interface Factory {
+    GpgKeyApiImpl create(GpgKey rsrc);
+  }
+
+  private final GpgKeys.Get get;
+  private final DeleteGpgKey delete;
+  private final GpgKey rsrc;
+
+  @Inject
+  GpgKeyApiImpl(GpgKeys.Get get, DeleteGpgKey delete, @Assisted GpgKey rsrc) {
+    this.get = get;
+    this.delete = delete;
+    this.rsrc = rsrc;
+  }
+
+  @Override
+  public GpgKeyInfo get() throws RestApiException {
+    try {
+      return get.apply(rsrc);
+    } catch (IOException e) {
+      throw new RestApiException("Cannot get GPG key", e);
+    }
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      delete.apply(rsrc, new Input());
+    } catch (PGPException | OrmException | IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot delete GPG key", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
new file mode 100644
index 0000000..6d132c8
--- /dev/null
+++ b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.gpg.server;
+
+import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
+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.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+
+public class DeleteGpgKey implements RestModifyView<GpgKey, Input> {
+
+  private final Provider<PersonIdent> serverIdent;
+  private final Provider<PublicKeyStore> storeProvider;
+  private final AccountsUpdate.User accountsUpdateFactory;
+  private final ExternalIds externalIds;
+
+  @Inject
+  DeleteGpgKey(
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      Provider<PublicKeyStore> storeProvider,
+      AccountsUpdate.User accountsUpdateFactory,
+      ExternalIds externalIds) {
+    this.serverIdent = serverIdent;
+    this.storeProvider = storeProvider;
+    this.accountsUpdateFactory = accountsUpdateFactory;
+    this.externalIds = externalIds;
+  }
+
+  @Override
+  public Response<?> apply(GpgKey rsrc, Input input)
+      throws ResourceConflictException, PGPException, OrmException, IOException,
+          ConfigInvalidException {
+    PGPPublicKey key = rsrc.getKeyRing().getPublicKey();
+    ExternalId extId =
+        externalIds.get(
+            ExternalId.Key.create(
+                SCHEME_GPGKEY, BaseEncoding.base16().encode(key.getFingerprint())));
+    accountsUpdateFactory
+        .create()
+        .update(
+            "Delete GPG Key via API",
+            rsrc.getUser().getAccountId(),
+            u -> u.deleteExternalId(extId));
+
+    try (PublicKeyStore store = storeProvider.get()) {
+      store.remove(rsrc.getKeyRing().getPublicKey().getFingerprint());
+
+      CommitBuilder cb = new CommitBuilder();
+      PersonIdent committer = serverIdent.get();
+      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
+      cb.setCommitter(committer);
+      cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
+
+      RefUpdate.Result saveResult = store.save(cb);
+      switch (saveResult) {
+        case NO_CHANGE:
+        case FAST_FORWARD:
+          break;
+        case FORCED:
+        case IO_FAILURE:
+        case LOCK_FAILURE:
+        case NEW:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case RENAMED:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
+          throw new ResourceConflictException("Failed to delete public key: " + saveResult);
+      }
+    }
+    return Response.none();
+  }
+}
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKey.java b/java/com/google/gerrit/gpg/server/GpgKey.java
similarity index 100%
rename from gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKey.java
rename to java/com/google/gerrit/gpg/server/GpgKey.java
diff --git a/java/com/google/gerrit/gpg/server/GpgKeys.java b/java/com/google/gerrit/gpg/server/GpgKeys.java
new file mode 100644
index 0000000..c4e35e0
--- /dev/null
+++ b/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -0,0 +1,250 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.gpg.server;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.BaseEncoding;
+import com.google.gerrit.extensions.common.GpgKeyInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.gpg.BouncyCastleUtil;
+import com.google.gerrit.gpg.CheckResult;
+import com.google.gerrit.gpg.Fingerprint;
+import com.google.gerrit.gpg.GerritPublicKeyChecker;
+import com.google.gerrit.gpg.PublicKeyChecker;
+import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import org.bouncycastle.bcpg.ArmoredOutputStream;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.eclipse.jgit.util.NB;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class GpgKeys implements ChildCollection<AccountResource, GpgKey> {
+  private static final Logger log = LoggerFactory.getLogger(GpgKeys.class);
+
+  public static final String MIME_TYPE = "application/pgp-keys";
+
+  private final DynamicMap<RestView<GpgKey>> views;
+  private final Provider<CurrentUser> self;
+  private final Provider<PublicKeyStore> storeProvider;
+  private final GerritPublicKeyChecker.Factory checkerFactory;
+  private final ExternalIds externalIds;
+
+  @Inject
+  GpgKeys(
+      DynamicMap<RestView<GpgKey>> views,
+      Provider<CurrentUser> self,
+      Provider<PublicKeyStore> storeProvider,
+      GerritPublicKeyChecker.Factory checkerFactory,
+      ExternalIds externalIds) {
+    this.views = views;
+    this.self = self;
+    this.storeProvider = storeProvider;
+    this.checkerFactory = checkerFactory;
+    this.externalIds = externalIds;
+  }
+
+  @Override
+  public ListGpgKeys list() throws ResourceNotFoundException, AuthException {
+    return new ListGpgKeys();
+  }
+
+  @Override
+  public GpgKey parse(AccountResource parent, IdString id)
+      throws ResourceNotFoundException, PGPException, OrmException, IOException {
+    checkVisible(self, parent);
+
+    ExternalId gpgKeyExtId = findGpgKey(id.get(), getGpgExtIds(parent));
+    byte[] fp = parseFingerprint(gpgKeyExtId);
+    try (PublicKeyStore store = storeProvider.get()) {
+      long keyId = keyId(fp);
+      for (PGPPublicKeyRing keyRing : store.get(keyId)) {
+        PGPPublicKey key = keyRing.getPublicKey();
+        if (Arrays.equals(key.getFingerprint(), fp)) {
+          return new GpgKey(parent.getUser(), keyRing);
+        }
+      }
+    }
+
+    throw new ResourceNotFoundException(id);
+  }
+
+  static ExternalId findGpgKey(String str, Iterable<ExternalId> existingExtIds)
+      throws ResourceNotFoundException {
+    str = CharMatcher.whitespace().removeFrom(str).toUpperCase();
+    if ((str.length() != 8 && str.length() != 40)
+        || !CharMatcher.anyOf("0123456789ABCDEF").matchesAllOf(str)) {
+      throw new ResourceNotFoundException(str);
+    }
+    ExternalId gpgKeyExtId = null;
+    for (ExternalId extId : existingExtIds) {
+      String fpStr = extId.key().id();
+      if (!fpStr.endsWith(str)) {
+        continue;
+      } else if (gpgKeyExtId != null) {
+        throw new ResourceNotFoundException("Multiple keys found for " + str);
+      }
+      gpgKeyExtId = extId;
+      if (str.length() == 40) {
+        break;
+      }
+    }
+    if (gpgKeyExtId == null) {
+      throw new ResourceNotFoundException(str);
+    }
+    return gpgKeyExtId;
+  }
+
+  static byte[] parseFingerprint(ExternalId gpgKeyExtId) {
+    return BaseEncoding.base16().decode(gpgKeyExtId.key().id());
+  }
+
+  @Override
+  public DynamicMap<RestView<GpgKey>> views() {
+    return views;
+  }
+
+  public class ListGpgKeys implements RestReadView<AccountResource> {
+    @Override
+    public Map<String, GpgKeyInfo> apply(AccountResource rsrc)
+        throws OrmException, PGPException, IOException, ResourceNotFoundException {
+      checkVisible(self, rsrc);
+      Map<String, GpgKeyInfo> keys = new HashMap<>();
+      try (PublicKeyStore store = storeProvider.get()) {
+        for (ExternalId extId : getGpgExtIds(rsrc)) {
+          byte[] fp = parseFingerprint(extId);
+          boolean found = false;
+          for (PGPPublicKeyRing keyRing : store.get(keyId(fp))) {
+            if (Arrays.equals(keyRing.getPublicKey().getFingerprint(), fp)) {
+              found = true;
+              GpgKeyInfo info =
+                  toJson(
+                      keyRing.getPublicKey(), checkerFactory.create(rsrc.getUser(), store), store);
+              keys.put(info.id, info);
+              info.id = null;
+              break;
+            }
+          }
+          if (!found) {
+            log.warn("No public key stored for fingerprint {}", Fingerprint.toString(fp));
+          }
+        }
+      }
+      return keys;
+    }
+  }
+
+  @Singleton
+  public static class Get implements RestReadView<GpgKey> {
+    private final Provider<PublicKeyStore> storeProvider;
+    private final GerritPublicKeyChecker.Factory checkerFactory;
+
+    @Inject
+    Get(Provider<PublicKeyStore> storeProvider, GerritPublicKeyChecker.Factory checkerFactory) {
+      this.storeProvider = storeProvider;
+      this.checkerFactory = checkerFactory;
+    }
+
+    @Override
+    public GpgKeyInfo apply(GpgKey rsrc) throws IOException {
+      try (PublicKeyStore store = storeProvider.get()) {
+        return toJson(
+            rsrc.getKeyRing().getPublicKey(),
+            checkerFactory.create().setExpectedUser(rsrc.getUser()),
+            store);
+      }
+    }
+  }
+
+  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws IOException {
+    return externalIds.byAccount(rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
+  }
+
+  private static long keyId(byte[] fp) {
+    return NB.decodeInt64(fp, fp.length - 8);
+  }
+
+  static void checkVisible(Provider<CurrentUser> self, AccountResource rsrc)
+      throws ResourceNotFoundException {
+    if (!BouncyCastleUtil.havePGP()) {
+      throw new ResourceNotFoundException("GPG not enabled");
+    }
+    if (self.get() != rsrc.getUser()) {
+      throw new ResourceNotFoundException();
+    }
+  }
+
+  public static GpgKeyInfo toJson(PGPPublicKey key, CheckResult checkResult) throws IOException {
+    GpgKeyInfo info = new GpgKeyInfo();
+
+    if (key != null) {
+      info.id = PublicKeyStore.keyIdToString(key.getKeyID());
+      info.fingerprint = Fingerprint.toString(key.getFingerprint());
+      Iterator<String> userIds = key.getUserIDs();
+      info.userIds = ImmutableList.copyOf(userIds);
+
+      try (ByteArrayOutputStream out = new ByteArrayOutputStream(4096);
+          ArmoredOutputStream aout = new ArmoredOutputStream(out)) {
+        // This is not exactly the key stored in the store, but is equivalent. In
+        // particular, it will have a Bouncy Castle version string. The armored
+        // stream reader in PublicKeyStore doesn't give us an easy way to extract
+        // the original ASCII armor.
+        key.encode(aout);
+        info.key = new String(out.toByteArray(), UTF_8);
+      }
+    }
+
+    info.status = checkResult.getStatus();
+    info.problems = checkResult.getProblems();
+
+    return info;
+  }
+
+  static GpgKeyInfo toJson(PGPPublicKey key, PublicKeyChecker checker, PublicKeyStore store)
+      throws IOException {
+    return toJson(key, checker.setStore(store).check(key));
+  }
+
+  public static void toJson(GpgKeyInfo info, CheckResult checkResult) {
+    info.status = checkResult.getStatus();
+    info.problems = checkResult.getProblems();
+  }
+}
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
new file mode 100644
index 0000000..4996e0e
--- /dev/null
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -0,0 +1,293 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.gpg.server;
+
+import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
+import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.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;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.gpg.CheckResult;
+import com.google.gerrit.gpg.Fingerprint;
+import com.google.gerrit.gpg.GerritPublicKeyChecker;
+import com.google.gerrit.gpg.PublicKeyChecker;
+import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.mail.send.AddKeySender;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import org.bouncycastle.bcpg.ArmoredInputStream;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.bouncycastle.openpgp.PGPRuntimeOperationException;
+import org.bouncycastle.openpgp.bc.BcPGPObjectFactory;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class PostGpgKeys implements RestModifyView<AccountResource, GpgKeysInput> {
+  private final Logger log = LoggerFactory.getLogger(getClass());
+  private final Provider<PersonIdent> serverIdent;
+  private final Provider<CurrentUser> self;
+  private final Provider<PublicKeyStore> storeProvider;
+  private final GerritPublicKeyChecker.Factory checkerFactory;
+  private final AddKeySender.Factory addKeyFactory;
+  private final Provider<InternalAccountQuery> accountQueryProvider;
+  private final ExternalIds externalIds;
+  private final AccountsUpdate.User accountsUpdateFactory;
+
+  @Inject
+  PostGpgKeys(
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      Provider<CurrentUser> self,
+      Provider<PublicKeyStore> storeProvider,
+      GerritPublicKeyChecker.Factory checkerFactory,
+      AddKeySender.Factory addKeyFactory,
+      Provider<InternalAccountQuery> accountQueryProvider,
+      ExternalIds externalIds,
+      AccountsUpdate.User accountsUpdateFactory) {
+    this.serverIdent = serverIdent;
+    this.self = self;
+    this.storeProvider = storeProvider;
+    this.checkerFactory = checkerFactory;
+    this.addKeyFactory = addKeyFactory;
+    this.accountQueryProvider = accountQueryProvider;
+    this.externalIds = externalIds;
+    this.accountsUpdateFactory = accountsUpdateFactory;
+  }
+
+  @Override
+  public Map<String, GpgKeyInfo> apply(AccountResource rsrc, GpgKeysInput input)
+      throws ResourceNotFoundException, BadRequestException, ResourceConflictException,
+          PGPException, OrmException, IOException, ConfigInvalidException {
+    GpgKeys.checkVisible(self, rsrc);
+
+    Collection<ExternalId> existingExtIds =
+        externalIds.byAccount(rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
+    try (PublicKeyStore store = storeProvider.get()) {
+      Map<ExternalId, Fingerprint> toRemove = readKeysToRemove(input, existingExtIds);
+      Collection<Fingerprint> fingerprintsToRemove = toRemove.values();
+      List<PGPPublicKeyRing> newKeys = readKeysToAdd(input, fingerprintsToRemove);
+      List<ExternalId> newExtIds = new ArrayList<>(existingExtIds.size());
+
+      for (PGPPublicKeyRing keyRing : newKeys) {
+        PGPPublicKey key = keyRing.getPublicKey();
+        ExternalId.Key extIdKey = toExtIdKey(key.getFingerprint());
+        Account account = getAccountByExternalId(extIdKey);
+        if (account != null) {
+          if (!account.getId().equals(rsrc.getUser().getAccountId())) {
+            throw new ResourceConflictException("GPG key already associated with another account");
+          }
+        } else {
+          newExtIds.add(ExternalId.create(extIdKey, rsrc.getUser().getAccountId()));
+        }
+      }
+
+      storeKeys(rsrc, newKeys, fingerprintsToRemove);
+
+      accountsUpdateFactory
+          .create()
+          .update(
+              "Update GPG Keys via API",
+              rsrc.getUser().getAccountId(),
+              u -> u.replaceExternalIds(toRemove.keySet(), newExtIds));
+      return toJson(newKeys, fingerprintsToRemove, store, rsrc.getUser());
+    }
+  }
+
+  private Map<ExternalId, Fingerprint> readKeysToRemove(
+      GpgKeysInput input, Collection<ExternalId> existingExtIds) {
+    if (input.delete == null || input.delete.isEmpty()) {
+      return ImmutableMap.of();
+    }
+    Map<ExternalId, Fingerprint> fingerprints =
+        Maps.newHashMapWithExpectedSize(input.delete.size());
+    for (String id : input.delete) {
+      try {
+        ExternalId gpgKeyExtId = GpgKeys.findGpgKey(id, existingExtIds);
+        fingerprints.put(gpgKeyExtId, new Fingerprint(GpgKeys.parseFingerprint(gpgKeyExtId)));
+      } catch (ResourceNotFoundException e) {
+        // Skip removal.
+      }
+    }
+    return fingerprints;
+  }
+
+  private List<PGPPublicKeyRing> readKeysToAdd(GpgKeysInput input, Collection<Fingerprint> toRemove)
+      throws BadRequestException, IOException {
+    if (input.add == null || input.add.isEmpty()) {
+      return ImmutableList.of();
+    }
+    List<PGPPublicKeyRing> keyRings = new ArrayList<>(input.add.size());
+    for (String armored : input.add) {
+      try (InputStream in = new ByteArrayInputStream(armored.getBytes(UTF_8));
+          ArmoredInputStream ain = new ArmoredInputStream(in)) {
+        @SuppressWarnings("unchecked")
+        List<Object> objs = Lists.newArrayList(new BcPGPObjectFactory(ain));
+        if (objs.size() != 1 || !(objs.get(0) instanceof PGPPublicKeyRing)) {
+          throw new BadRequestException("Expected exactly one PUBLIC KEY BLOCK");
+        }
+        PGPPublicKeyRing keyRing = (PGPPublicKeyRing) objs.get(0);
+        if (toRemove.contains(new Fingerprint(keyRing.getPublicKey().getFingerprint()))) {
+          throw new BadRequestException(
+              "Cannot both add and delete key: " + keyToString(keyRing.getPublicKey()));
+        }
+        keyRings.add(keyRing);
+      } catch (PGPRuntimeOperationException e) {
+        throw new BadRequestException("Failed to parse GPG keys", e);
+      }
+    }
+    return keyRings;
+  }
+
+  private void storeKeys(
+      AccountResource rsrc, List<PGPPublicKeyRing> keyRings, Collection<Fingerprint> toRemove)
+      throws BadRequestException, ResourceConflictException, PGPException, IOException {
+    try (PublicKeyStore store = storeProvider.get()) {
+      List<String> addedKeys = new ArrayList<>();
+      for (PGPPublicKeyRing keyRing : keyRings) {
+        PGPPublicKey key = keyRing.getPublicKey();
+        // Don't check web of trust; admins can fill in certifications later.
+        CheckResult result = checkerFactory.create(rsrc.getUser(), store).disableTrust().check(key);
+        if (!result.isOk()) {
+          throw new BadRequestException(
+              String.format(
+                  "Problems with public key %s:\n%s",
+                  keyToString(key), Joiner.on('\n').join(result.getProblems())));
+        }
+        addedKeys.add(PublicKeyStore.keyToString(key));
+        store.add(keyRing);
+      }
+      for (Fingerprint fp : toRemove) {
+        store.remove(fp.get());
+      }
+      CommitBuilder cb = new CommitBuilder();
+      PersonIdent committer = serverIdent.get();
+      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
+      cb.setCommitter(committer);
+
+      RefUpdate.Result saveResult = store.save(cb);
+      switch (saveResult) {
+        case NEW:
+        case FAST_FORWARD:
+        case FORCED:
+          try {
+            addKeyFactory.create(rsrc.getUser(), addedKeys).send();
+          } catch (EmailException e) {
+            log.error(
+                "Cannot send GPG key added message to "
+                    + rsrc.getUser().getAccount().getPreferredEmail(),
+                e);
+          }
+          break;
+        case NO_CHANGE:
+          break;
+        case IO_FAILURE:
+        case LOCK_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case RENAMED:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
+          // TODO(dborowitz): Backoff and retry on LOCK_FAILURE.
+          throw new ResourceConflictException("Failed to save public keys: " + saveResult);
+      }
+    }
+  }
+
+  private ExternalId.Key toExtIdKey(byte[] fp) {
+    return ExternalId.Key.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(fp));
+  }
+
+  private Account getAccountByExternalId(ExternalId.Key extIdKey) throws OrmException {
+    List<AccountState> accountStates = accountQueryProvider.get().byExternalId(extIdKey);
+
+    if (accountStates.isEmpty()) {
+      return null;
+    }
+
+    if (accountStates.size() > 1) {
+      StringBuilder msg = new StringBuilder();
+      msg.append("GPG key ").append(extIdKey.get()).append(" associated with multiple accounts: ");
+      Joiner.on(", ")
+          .appendTo(msg, Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
+      log.error(msg.toString());
+      throw new IllegalStateException(msg.toString());
+    }
+
+    return accountStates.get(0).getAccount();
+  }
+
+  private Map<String, GpgKeyInfo> toJson(
+      Collection<PGPPublicKeyRing> keys,
+      Collection<Fingerprint> deleted,
+      PublicKeyStore store,
+      IdentifiedUser user)
+      throws IOException {
+    // Unlike when storing keys, include web-of-trust checks when producing
+    // result JSON, so the user at least knows of any issues.
+    PublicKeyChecker checker = checkerFactory.create(user, store);
+    Map<String, GpgKeyInfo> infos = Maps.newHashMapWithExpectedSize(keys.size() + deleted.size());
+    for (PGPPublicKeyRing keyRing : keys) {
+      PGPPublicKey key = keyRing.getPublicKey();
+      CheckResult result = checker.check(key);
+      GpgKeyInfo info = GpgKeys.toJson(key, result);
+      infos.put(info.id, info);
+      info.id = null;
+    }
+    for (Fingerprint fp : deleted) {
+      infos.put(keyIdToString(fp.getId()), new GpgKeyInfo());
+    }
+    return infos;
+  }
+}
diff --git a/java/com/google/gerrit/gpg/testing/BUILD b/java/com/google/gerrit/gpg/testing/BUILD
new file mode 100644
index 0000000..ff8fecf
--- /dev/null
+++ b/java/com/google/gerrit/gpg/testing/BUILD
@@ -0,0 +1,12 @@
+java_library(
+    name = "gpg-test-util",
+    testonly = 1,
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/gpg",
+        "//lib:guava",
+        "//lib/bouncycastle:bcpg-neverlink",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/gpg/testing/TestKey.java b/java/com/google/gerrit/gpg/testing/TestKey.java
new file mode 100644
index 0000000..f7405f7
--- /dev/null
+++ b/java/com/google/gerrit/gpg/testing/TestKey.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.gpg.testing;
+
+import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import org.bouncycastle.bcpg.ArmoredInputStream;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPPrivateKey;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.bouncycastle.openpgp.PGPSecretKey;
+import org.bouncycastle.openpgp.PGPSecretKeyRing;
+import org.bouncycastle.openpgp.operator.bc.BcKeyFingerprintCalculator;
+import org.bouncycastle.openpgp.operator.bc.BcPBESecretKeyDecryptorBuilder;
+import org.bouncycastle.openpgp.operator.bc.BcPGPDigestCalculatorProvider;
+import org.eclipse.jgit.lib.Constants;
+
+public class TestKey {
+  private final String pubArmored;
+  private final String secArmored;
+  private final PGPPublicKeyRing pubRing;
+  private final PGPSecretKeyRing secRing;
+
+  public TestKey(String pubArmored, String secArmored) {
+    this.pubArmored = pubArmored;
+    this.secArmored = secArmored;
+    BcKeyFingerprintCalculator fc = new BcKeyFingerprintCalculator();
+    try {
+      this.pubRing = new PGPPublicKeyRing(newStream(pubArmored), fc);
+      this.secRing = new PGPSecretKeyRing(newStream(secArmored), fc);
+    } catch (PGPException | IOException e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  public String getPublicKeyArmored() {
+    return pubArmored;
+  }
+
+  public String getSecretKeyArmored() {
+    return secArmored;
+  }
+
+  public PGPPublicKeyRing getPublicKeyRing() {
+    return pubRing;
+  }
+
+  public PGPPublicKey getPublicKey() {
+    return pubRing.getPublicKey();
+  }
+
+  public PGPSecretKey getSecretKey() {
+    return secRing.getSecretKey();
+  }
+
+  public long getKeyId() {
+    return getPublicKey().getKeyID();
+  }
+
+  public String getKeyIdString() {
+    return keyIdToString(getPublicKey().getKeyID());
+  }
+
+  public String getFirstUserId() {
+    return getPublicKey().getUserIDs().next();
+  }
+
+  public PGPPrivateKey getPrivateKey() throws PGPException {
+    return getSecretKey()
+        .extractPrivateKey(
+            new BcPBESecretKeyDecryptorBuilder(new BcPGPDigestCalculatorProvider())
+                // All test keys have no passphrase.
+                .build(new char[0]));
+  }
+
+  private static ArmoredInputStream newStream(String armored) throws IOException {
+    return new ArmoredInputStream(new ByteArrayInputStream(Constants.encode(armored)));
+  }
+}
diff --git a/java/com/google/gerrit/gpg/testing/TestKeys.java b/java/com/google/gerrit/gpg/testing/TestKeys.java
new file mode 100644
index 0000000..00acedb
--- /dev/null
+++ b/java/com/google/gerrit/gpg/testing/TestKeys.java
@@ -0,0 +1,1032 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.gpg.testing;
+
+import com.google.common.collect.ImmutableList;
+
+/** Common test keys used by a variety of tests. */
+public class TestKeys {
+  public static ImmutableList<TestKey> allValidKeys() {
+    return ImmutableList.of(
+        validKeyWithoutExpiration(), validKeyWithExpiration(), validKeyWithSecondUserId());
+  }
+
+  /**
+   * A valid key with no expiration.
+   *
+   * <pre>
+   * pub   2048R/46328A8C 2015-07-08
+   *       Key fingerprint = 04AE A7ED 2F82 1133 E5B1  28D1 ED06 25DC 4632 8A8C
+   * uid                  Testuser One &lt;test1@example.com&gt;
+   * sub   2048R/F0AF69C0 2015-07-08
+   * </pre>
+   */
+  public static TestKey validKeyWithoutExpiration() {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBFWdTIkBCADOaygDKjLuRX6LXAvBAYB91cmTf1MSlmEy+qsG3c9ijjQixPkr\n"
+            + "atdYkocrrT2S0R9UGjksTOI2WN5S0lQfLA1RSk63KURQE+OF+IfNqdD6nQdLBs1w\n"
+            + "va+GDj/uvuI05I0oXf/M7POdFphutrS4EUDBnFPj6ns/0C2sTRTxliD+Y9Y9a84V\n"
+            + "DfVVUbJB6wc3LP3L6ImT+cSM7dLq3hZHya+9FNeYPmPYnBrkJyqf2NDd38Sddsro\n"
+            + "7smw/GgCZHnnuVNS4C7NsHr6900VKC+JDtdx+fqptixcAEJWiGoQfWqU+hYmia3p\n"
+            + "9+Xw02+3FcjOT6ONUCmHX+xlz0pXW4iIYlPpABEBAAG0IFRlc3R1c2VyIE9uZSA8\n"
+            + "dGVzdDFAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJVnUyJAhsDBgsJCAcDAgYVCAIJ\n"
+            + "CgsEFgIDAQIeAQIXgAAKCRDtBiXcRjKKjHblB/9RaFO5+GTDIphAL/aVj2u+d8Lq\n"
+            + "yUpBrDp3P06QDGpKGFMAovBuh+NLH76VKNIzQLQC8rdTj651fLcLMuJ1enQ3Rblg\n"
+            + "RKr1oc+wqqtFHr4QyOQjE/N3C9GQjEzfqn4qnp5KtZxYFnlvU5NGehid7M1HTZMx\n"
+            + "jRcHbM9KQnsE5Z4fh4wmN5ynG+5nbaF4O9otPOpFzYRvIhxFmHscWyOgRaMZiYEX\n"
+            + "7Qkzze+scAlc9E/EWRJQIFcxnxV/SYIT4qCTT1g2aKA8OCBO/ZTOleH8SzvTODjy\n"
+            + "W0lGHnh/ZqH6XGVcGUaJZZ2uHTck1+czuVVShNcXPW1W20T6E9UqzHbJHN0guQEN\n"
+            + "BFWdTIkBCACoLVdPr3gpQwzI+2NGXjdtoyqYoPlgfeyI2M1XQD/7+rLZTbi14ZjN\n"
+            + "vYkS/+/oGtVEmiYOiAVTwmkjCYkKGDgNcCiJVekiPAN6JryVv488wRc999b5LpFE\n"
+            + "fhLGwI0YxjcS4KFFnpMC3wSb6tJUnHRLVoE5d8icdiaOpgYdp7uqWkSx2oxqHgIb\n"
+            + "nuyrk3ydEcS4ZeGD+w+taIxMc9F1DS9kiXALD7xWgUkmqZLEQoNgF6KlwCHXRd3m\n"
+            + "rBCo97sE95yKcq98ZMIWuQtTcEccZsN/6jlsei+9RI0tqs+FbZnIFm/go9zk11Vl\n"
+            + "IQ9QFSj6ruqoKrYvNZuDDLD1lHvZPD4/ABEBAAGJAR8EGAECAAkFAlWdTIkCGwwA\n"
+            + "CgkQ7QYl3EYyiox+HAf/Z/OCQO3jxALAcn3oUb1g/IlHm6qZv7RJOFUsj/16fGiF\n"
+            + "rRTP15zMXzyqV+L/LGV/owvOsdD/o7boZz4C/U98COx0Nl1jOrmPATOl+xqsgpEj\n"
+            + "Fhk+eAR7exO2XxW+u2g4cYoSMosIOX5w1GrdsxQeaZDwiSJMEOR2cVLs3YI19Ci/\n"
+            + "FuzActZ0wJNk0nlNF6l8CAbzwN6pM9OIc/iBIwDjz92KUco0NF8XKZnxqhH4wfHB\n"
+            + "PGkTx8RwOvELUTDMtvYnG5R0QtND0RbOnmp4ZVZmeOjKSLo1mZliUZB1H2PPSxrA\n"
+            + "0oLr8+wLntz1SU7uS4ddvhSQW+j2M/0pa352KUwmrw==\n"
+            + "=o/aU\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBFWdTIkBCADOaygDKjLuRX6LXAvBAYB91cmTf1MSlmEy+qsG3c9ijjQixPkr\n"
+            + "atdYkocrrT2S0R9UGjksTOI2WN5S0lQfLA1RSk63KURQE+OF+IfNqdD6nQdLBs1w\n"
+            + "va+GDj/uvuI05I0oXf/M7POdFphutrS4EUDBnFPj6ns/0C2sTRTxliD+Y9Y9a84V\n"
+            + "DfVVUbJB6wc3LP3L6ImT+cSM7dLq3hZHya+9FNeYPmPYnBrkJyqf2NDd38Sddsro\n"
+            + "7smw/GgCZHnnuVNS4C7NsHr6900VKC+JDtdx+fqptixcAEJWiGoQfWqU+hYmia3p\n"
+            + "9+Xw02+3FcjOT6ONUCmHX+xlz0pXW4iIYlPpABEBAAEAB/wLoOXEJ+Buo+OZHjpb\n"
+            + "SSZf8GdGs+mOJoKbSJvR6zT/rFsrikUvOPmgt8B9qWjKmJVXO5L09+/Wd/MuX0L1\n"
+            + "7plhdvowP1bl2/j5VyLvZx2qwKXkiCGStFzrBGp9nKtJp4Z8O69pb//ZXaiAtDJC\n"
+            + "HFa1kYT4VgFTevrXtg/z/C0np4Yjx0mZpw4nfISEeHCiYCyRa/B8R1+Pc4uIcoSo\n"
+            + "G3aq6Ow9m/LGvw0MRO5qHvqoF41TLPQpGKjKEsCBKHF1qh0tOOUHnLGrvbmdFnGr\n"
+            + "UXJpRkLdRTnj8ufvA4XVZhImzL+lD+ALtjlV14xh8nsNKYL42880GFl5Cl0OtBcE\n"
+            + "lgQBBADPJ6kHdvUYOe0zugRdukBSYLkZcYwRiphom7dZuavYICIu6B14ljEONzVD\n"
+            + "mPhi2lDOawZOURKwYd9S4K11XWLsTYe7XEwkc+1Fpvu4L/JqnJTTnnvbx05ZsqD5\n"
+            + "j9tybPlrTuLrf2ctfcC03Z55wfo6azsbf89yrr6QX0+l9dlkYQQA/xcMdQJ0Z5vm\n"
+            + "kvyaCPsQzJc/8noVO9PMv7xJm14gJWK7Px3y2eBidzpCbVVFnGWW6CPb3qKerB5U\n"
+            + "pwcF4gCFWyP9C2YtnB0hgqixIPfR+UO8gpqdY6MP8NPspoXouffRn+Zic/P6Cxje\n"
+            + "/MGxNQBeRtqb2IGh1xZ8v/8tmmmxHIkEAP74HkGETcXmlj3/6RlwTBUAovPARSn7\n"
+            + "LDtOCPezg6mQmble1BvnTnAwOHKJVqjx+3qsGqMe8OGGXAxZPSU1xSmOShBFrpDp\n"
+            + "xArE67arE17pT1lyD/gmHRuqnNMvgRrwz1mDm3G2ohWkCVixEiB+8vPQfbZrJBgQ\n"
+            + "WxOF4RCo2WWyRKa0IFRlc3R1c2VyIE9uZSA8dGVzdDFAZXhhbXBsZS5jb20+iQE4\n"
+            + "BBMBAgAiBQJVnUyJAhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRDtBiXc\n"
+            + "RjKKjHblB/9RaFO5+GTDIphAL/aVj2u+d8LqyUpBrDp3P06QDGpKGFMAovBuh+NL\n"
+            + "H76VKNIzQLQC8rdTj651fLcLMuJ1enQ3RblgRKr1oc+wqqtFHr4QyOQjE/N3C9GQ\n"
+            + "jEzfqn4qnp5KtZxYFnlvU5NGehid7M1HTZMxjRcHbM9KQnsE5Z4fh4wmN5ynG+5n\n"
+            + "baF4O9otPOpFzYRvIhxFmHscWyOgRaMZiYEX7Qkzze+scAlc9E/EWRJQIFcxnxV/\n"
+            + "SYIT4qCTT1g2aKA8OCBO/ZTOleH8SzvTODjyW0lGHnh/ZqH6XGVcGUaJZZ2uHTck\n"
+            + "1+czuVVShNcXPW1W20T6E9UqzHbJHN0gnQOYBFWdTIkBCACoLVdPr3gpQwzI+2NG\n"
+            + "XjdtoyqYoPlgfeyI2M1XQD/7+rLZTbi14ZjNvYkS/+/oGtVEmiYOiAVTwmkjCYkK\n"
+            + "GDgNcCiJVekiPAN6JryVv488wRc999b5LpFEfhLGwI0YxjcS4KFFnpMC3wSb6tJU\n"
+            + "nHRLVoE5d8icdiaOpgYdp7uqWkSx2oxqHgIbnuyrk3ydEcS4ZeGD+w+taIxMc9F1\n"
+            + "DS9kiXALD7xWgUkmqZLEQoNgF6KlwCHXRd3mrBCo97sE95yKcq98ZMIWuQtTcEcc\n"
+            + "ZsN/6jlsei+9RI0tqs+FbZnIFm/go9zk11VlIQ9QFSj6ruqoKrYvNZuDDLD1lHvZ\n"
+            + "PD4/ABEBAAEAB/4kQnJauehcbRpqktjaqSGmP9HFSp+50CyZbLUJJM8m0uyQsZMr\n"
+            + "k9JQOZc+Q3RERNTKj7m41Fbhsj7c0Qd856/eJdp3kdBME0hko8lxN/X4EWGjeLYe\n"
+            + "z41+iPgfZhCF0Oa66TecPQ5RRihGPaDPoVPpkmMWMt9L7KVviBg1eJ6bobVIY5hu\n"
+            + "a7KFJHZQcCI1OvdJ0cx89KDSbnH8iMM6Kmw1bE3D2FEaWctuKLBo5PNRgyTJvdBd\n"
+            + "PSf56/Rc6csPqmOntQi2Yn8n47eCOTclHNuygSTJeHPpymVuWbhMq6fhJat/xA+V\n"
+            + "kyT8I2c45RQb0dKId+wEytjbKw8AI6Q3GXqhBADOhsr9M+JWc4MpD43mCDZACN4v\n"
+            + "RBRxSrJvO/V6HqQPmKYRmr9Gk3vxgF0zCf5zB1QeBiXpTpShxV87RIbUYReOyavp\n"
+            + "87zH6/SkRxQJiBEpQh5Fu5CoAaxGOivxbPqdWHrBY6jvqkrRoMPNiFJ6/ty5w9jx\n"
+            + "i9kGm9PelQGu2SdLNwQA0HbGo8sC8h5TSTEDCkFHRYzVYONx+32AlkCsJX9mEt0E\n"
+            + "nG8d97Ay24JsbnuXSq04FJrqzjOVyHLUffpXnAGELJZVNCIparSyqIaj43UG/oPc\n"
+            + "ICPmR7zI9G49ICUPSzI7+S2+BwjbiHRQcP0zmxbH92G4abYwKfk7dsDpGyVM+TkD\n"
+            + "/2nUiV0CRqnGipeiLWNjW/Md0ufkwqBvCWxrtxj0rQCyvBOVg3B6DocVNzgOOYa1\n"
+            + "ji3We5A9mSP40JBmMfk2veFrDdsGn4G+OpzMxKQtNfYemqjALfZ2zTdax0mXPXy6\n"
+            + "Gl0jUgSGrxGm8QnRLsrRx7G7ZKnvkcS+YsdQ8dbtzvJtQfiJAR8EGAECAAkFAlWd\n"
+            + "TIkCGwwACgkQ7QYl3EYyiox+HAf/Z/OCQO3jxALAcn3oUb1g/IlHm6qZv7RJOFUs\n"
+            + "j/16fGiFrRTP15zMXzyqV+L/LGV/owvOsdD/o7boZz4C/U98COx0Nl1jOrmPATOl\n"
+            + "+xqsgpEjFhk+eAR7exO2XxW+u2g4cYoSMosIOX5w1GrdsxQeaZDwiSJMEOR2cVLs\n"
+            + "3YI19Ci/FuzActZ0wJNk0nlNF6l8CAbzwN6pM9OIc/iBIwDjz92KUco0NF8XKZnx\n"
+            + "qhH4wfHBPGkTx8RwOvELUTDMtvYnG5R0QtND0RbOnmp4ZVZmeOjKSLo1mZliUZB1\n"
+            + "H2PPSxrA0oLr8+wLntz1SU7uS4ddvhSQW+j2M/0pa352KUwmrw==\n"
+            + "=MuAn\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * A valid key expiring in 2065.
+   *
+   * <pre>
+   * pub   2048R/378A0AED 2015-07-08 [expires: 2065-06-25]
+   *       Key fingerprint = C378 369A CBCD 34CC 138D  90B1 4531 1A6F 378A 0AED
+   * uid                  Testuser Two &lt;test2@example.com&gt;
+   * sub   2048R/46D4F204 2015-07-08 [expires: 2065-06-25]
+   * </pre>
+   */
+  public static final TestKey validKeyWithExpiration() {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBFWdTP8BCADRxNpasIv0jtNXTK6VYIS2VJ2Xk0ZD6gtxeoXCpjQ+TsB9fxh3\n"
+            + "vAMPt2Zu5LqoGwygKOJj1zquG8xk7GUCCHJk3+qG8xxB1xGtSz2vLyfRm7fOZmHj\n"
+            + "3W/C/25lynSPDrfvcwvwA4PN8iP5EWbWU10L6WOZGMwwwtVDUSEouSOw2LEepxLV\n"
+            + "rkKuZcyHaivheDbUlZliwe9rGXd4hh1h4qyNQWG3q+ytlL28sVkOzUh6IMBTvqhe\n"
+            + "IRsvxvaVSLV8jRVKfUTqw0g57ft4ZD2/L46yUTXzr9aUCBjTNxvWLlyboqql/D8P\n"
+            + "inp51h3cvAg7NW5RdG1GEYmylH8SygT5utPxABEBAAG0IFRlc3R1c2VyIFR3byA8\n"
+            + "dGVzdDJAZXhhbXBsZS5jb20+iQE+BBMBAgAoBQJVnUz/AhsDBQld/A8ABgsJCAcD\n"
+            + "AgYVCAIJCgsEFgIDAQIeAQIXgAAKCRBFMRpvN4oK7UZqCACWwQL/YvBK4b0m+R0d\n"
+            + "UdvAXeBx7DwOAnAodis9ZVqChb7RxcZQxF1Ti9mtCBPPQGuEs5wE2Ocrrq+L13r6\n"
+            + "bgW+1WOB1tZSDVxwL1PnZFw/SyADRIDCZrOHiAkp82UnZwWAkk39GzNJtt1wTYDZ\n"
+            + "FMTFUr2SPscXk1k7muS+ZfEFwNPD4tODo/poJKDYEJ80Z5UXXFQLDtsfdeIXMFIT\n"
+            + "449CYoq8XBMBfvyWl/LLpw0r3JI6pV/YdH3Oeuz8XkkEVzRxaxB6Zmeo5jSwjR/T\n"
+            + "8TKDGwwiuwiiT3SfkFSVdcjKulRuXSRNs1Ouf7/UC3cq4bG2WXWa85X1+HQRm7iu\n"
+            + "RHSOuQENBFWdTP8BCADhhGxAA0pX5yBHwIgM1j0gw2h5nSsopDrO6t/sbRUcNxnR\n"
+            + "tBScgKZnP0sjRTYEUIwmZuseHMBohtVCuMaDt06qyZDvDk/98j3AeE5t2dgFnOIe\n"
+            + "qCrm/6aejbFcQOpxe6U29KJRCAxuwNtB15X1VH1Kj7B0gRSTu13n/5sUsi2lunoZ\n"
+            + "oIvpIe9tZH4aXitCY2MCQH+hTyCyNBzlEa44kWz6LxUsPdo7I6rXkTr6Ot7wQh+9\n"
+            + "7HCe042GIq65h0apgujyjhJidjch5ur1mngaSNSEyvbji2MGC+cd3wAIstG5a7xP\n"
+            + "d9MncY5Q/eH+hn96694k5bckottSyGm/3f2Ihfj1ABEBAAGJASUEGAECAA8FAlWd\n"
+            + "TP8CGwwFCV38DwAACgkQRTEabzeKCu1FNwgAif4eK2v7R3QubL2S6wmb1nsgRMgV\n"
+            + "YoxGBeUk2EK6WZ5IPor93ySd0ixRVNMRmJ8BLH3EMjZQTzkDG+BH6zFyxo6lLHw9\n"
+            + "NxQjI06tqQWgyyK0mEweVwB/zqtxiB4lNUpsNbqOZWnBJ3d6o1SsnD2Q3uwvP5fb\n"
+            + "fSIgdmUk3c0VMdgA+KzWjPD/PJIPujE+ckHhjn5cbDNw35/FuyhkLJfqlOG7SPvM\n"
+            + "NmCdJ1Pcqju9t7sf6b0BGPDOCL4gpuWKK7HJz9WxngNb3FSziLbyPLk13ynADO+v\n"
+            + "EOR44LPyXE9kVxPusazsXlt9ayTOhELhwzw7sGFFu8E17Cpn7GnVj3tN9A==\n"
+            + "=1e/A\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBFWdTP8BCADRxNpasIv0jtNXTK6VYIS2VJ2Xk0ZD6gtxeoXCpjQ+TsB9fxh3\n"
+            + "vAMPt2Zu5LqoGwygKOJj1zquG8xk7GUCCHJk3+qG8xxB1xGtSz2vLyfRm7fOZmHj\n"
+            + "3W/C/25lynSPDrfvcwvwA4PN8iP5EWbWU10L6WOZGMwwwtVDUSEouSOw2LEepxLV\n"
+            + "rkKuZcyHaivheDbUlZliwe9rGXd4hh1h4qyNQWG3q+ytlL28sVkOzUh6IMBTvqhe\n"
+            + "IRsvxvaVSLV8jRVKfUTqw0g57ft4ZD2/L46yUTXzr9aUCBjTNxvWLlyboqql/D8P\n"
+            + "inp51h3cvAg7NW5RdG1GEYmylH8SygT5utPxABEBAAEAB/0WW33OVqzEBwj9b/3X\n"
+            + "i+75I/Gb+yVtDZ/km2NwSJie33PirE4mTNKitTBkt1oxmphw5Yqji4gEkI/rXcqy\n"
+            + "OcY/fCIZ+gVT+yE2MCPF7Se4Tnl7tSvPxoUn6mOQ09AygyYVjlSCY02EAL/WxwUH\n"
+            + "6OCs6VYlNiBlPg7O2vHGzlzAd1aMmlG3ytlhb0SIbilaJn/wlQ2SEGySjIAP1qRH\n"
+            + "UXsTfW7oAjdqAY1CbCWg/0FnMBF+DnChH634dbLrS2OefcB70l61trEfRcHbMNTv\n"
+            + "9nVxDDCpaIdxsOfgWpe0GMG1qddRAxBIOVjNUFOL22xEFyaXnt/uagUtKQ7yejci\n"
+            + "bgTFBADcuhsfQaBX1G095iG2qr8Rx2T5GqNf9oZA+rbweWegqIH7MUXHI1KKwwJx\n"
+            + "C+rR5AgnxTSP614XI/AWB/txdelm8z0jLobpS6B1vzM2vRQ7hpwjJ3UvUkoQ5uYL\n"
+            + "DjaBqQi0w1cPJA79H0Yujc1zgdhATymz0uDL1BC2bHLIMuhelwQA80p07G1w8HLQ\n"
+            + "bTdgNwtDBMKIw39/ZyQy8ppxmpD4J6zf25r95g3er0r+njrHsa+72LnvexbedpKA\n"
+            + "4eiDJPN+l5jJOEWfL2WtGcqJ01bdFBPcl73tuwDJJtieUlKZH0jRjykuuUX8F+tJ\n"
+            + "yrmVoIGtawoeLKq3hMMOK4xi+sh3OrcD+wXIU24eO3YfUde5bhyaQplNMU5smIU0\n"
+            + "+looOEmFsZcTONgoN+FKrnm2TY9d4FHZ+QgtnksWHmmLxQJPtp9rHJ5BgdxMBPcK\n"
+            + "3w5GXRuWlOmqmnAb6vp0Q0yzVDLKCcwba0S23m3tbjZsLDcI7MG/knsp9gtL676D\n"
+            + "AsrpeF2+Apj0OwG0IFRlc3R1c2VyIFR3byA8dGVzdDJAZXhhbXBsZS5jb20+iQE+\n"
+            + "BBMBAgAoBQJVnUz/AhsDBQld/A8ABgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAK\n"
+            + "CRBFMRpvN4oK7UZqCACWwQL/YvBK4b0m+R0dUdvAXeBx7DwOAnAodis9ZVqChb7R\n"
+            + "xcZQxF1Ti9mtCBPPQGuEs5wE2Ocrrq+L13r6bgW+1WOB1tZSDVxwL1PnZFw/SyAD\n"
+            + "RIDCZrOHiAkp82UnZwWAkk39GzNJtt1wTYDZFMTFUr2SPscXk1k7muS+ZfEFwNPD\n"
+            + "4tODo/poJKDYEJ80Z5UXXFQLDtsfdeIXMFIT449CYoq8XBMBfvyWl/LLpw0r3JI6\n"
+            + "pV/YdH3Oeuz8XkkEVzRxaxB6Zmeo5jSwjR/T8TKDGwwiuwiiT3SfkFSVdcjKulRu\n"
+            + "XSRNs1Ouf7/UC3cq4bG2WXWa85X1+HQRm7iuRHSOnQOYBFWdTP8BCADhhGxAA0pX\n"
+            + "5yBHwIgM1j0gw2h5nSsopDrO6t/sbRUcNxnRtBScgKZnP0sjRTYEUIwmZuseHMBo\n"
+            + "htVCuMaDt06qyZDvDk/98j3AeE5t2dgFnOIeqCrm/6aejbFcQOpxe6U29KJRCAxu\n"
+            + "wNtB15X1VH1Kj7B0gRSTu13n/5sUsi2lunoZoIvpIe9tZH4aXitCY2MCQH+hTyCy\n"
+            + "NBzlEa44kWz6LxUsPdo7I6rXkTr6Ot7wQh+97HCe042GIq65h0apgujyjhJidjch\n"
+            + "5ur1mngaSNSEyvbji2MGC+cd3wAIstG5a7xPd9MncY5Q/eH+hn96694k5bckottS\n"
+            + "yGm/3f2Ihfj1ABEBAAEAB/wP5H+mcTTrhe+57sEHuo9bQDocG+3fMtesHlRCept6\n"
+            + "vg1VQG4Va2GOtCCs7yMz4aNGz4jxOdB7bUkZJyFiRehG0+ahWi5b9JbSegf46Nm2\n"
+            + "54vt4icH2WtaEB04JaD/91k4yrunnzwVEAVDmhhIzjf4KbEjPLeBA7rF7zb0Gexq\n"
+            + "mdxEGO/6KdeQ6KOxkpWEqIIdl/mAGsYCprHeKL/XL+KXYr92nEbUcltmt59TTnoo\n"
+            + "00BQCPuHCdpcUd5nuaxpCZLM+BEpxtj0sinz0ofuWU9RI4K00R01MKXWMucdOhTZ\n"
+            + "kUy5dMx8wA07xbjkE/nH86N76Mty133OB7G3lBBDfO4PBADulfLzbjXUnS1kTKeP\n"
+            + "j/HF1E9qafzTDS/QD55OVajDq66A6zaOazKbURHNZmIqpLO4715+iNtrZQUEP3e1\n"
+            + "mwngeizvAv9luA9kJ1YDTCfsS5H5cYzavhfwuqBu7fQBm/PQqZplQuPCxgXEIBaY\n"
+            + "M0uvR0I/FSwFrepRN2IA6dAkrwQA8fpJEg8C9OLFzDf0rxV3eWwEelemN4E50Obu\n"
+            + "nxtg9IJWZ+QIWkRVLJ8if5+p85s2ieCw8hzEF0FyNfWUnfW5eoN4/j50loR4EbZS\n"
+            + "qOpUJGwr8ezyQN8PpduDOe9OQnUYAv9FY9Rk46L4937GDF2w5gdxyNdKO8yG+Z3A\n"
+            + "6/0DLZsEAOQsRUXIl1XLjkdugfFQ8V9Fv3AYWJt+8zknwcQ+Z3uOtyY2muCi9hX2\n"
+            + "BtuPojjwmN6x8wntMaUkzYHVSdz/cdx+na7VNS2kZHfnECWZGR6IHyRTJN5612yi\n"
+            + "e4MIdTE+BgL1HPq+VIPlMBehEksC5qM0WSq8baMsacGMYeAL8ntoRuyJASUEGAEC\n"
+            + "AA8FAlWdTP8CGwwFCV38DwAACgkQRTEabzeKCu1FNwgAif4eK2v7R3QubL2S6wmb\n"
+            + "1nsgRMgVYoxGBeUk2EK6WZ5IPor93ySd0ixRVNMRmJ8BLH3EMjZQTzkDG+BH6zFy\n"
+            + "xo6lLHw9NxQjI06tqQWgyyK0mEweVwB/zqtxiB4lNUpsNbqOZWnBJ3d6o1SsnD2Q\n"
+            + "3uwvP5fbfSIgdmUk3c0VMdgA+KzWjPD/PJIPujE+ckHhjn5cbDNw35/FuyhkLJfq\n"
+            + "lOG7SPvMNmCdJ1Pcqju9t7sf6b0BGPDOCL4gpuWKK7HJz9WxngNb3FSziLbyPLk1\n"
+            + "3ynADO+vEOR44LPyXE9kVxPusazsXlt9ayTOhELhwzw7sGFFu8E17Cpn7GnVj3tN\n"
+            + "9A==\n"
+            + "=qbV3\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * A key that expired in 2006.
+   *
+   * <pre>
+   * pub   2048R/17DE1ACD 2005-07-08 [expired: 2006-07-08]
+   *       Key fingerprint = 1D9E EB79 DD38 B049 939D  9CAF 3CEC 781B 17DE 1ACD
+   * uid                  Testuser Three &lt;test3@example.com&gt;
+   * </pre>
+   */
+  public static final TestKey expiredKey() {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBELOp0UBCACxholOPWuKhK+TYb88nvLUSCMvTLIFEpb5u3Eavr0wiluEzq6H\n"
+            + "55nswAD3dQm8DWxA7yUlEYjPr5btpw7V9441bb1+qtgZMJ10RTdEb/WjyctdGA99\n"
+            + "uOKBEarWbt8W+w6lyJ9NXy5bS/x5EwHHfoTFp4ff6ffHI5hbx1a00K8oxmitgd0X\n"
+            + "Mx86UmauFNJYupZOZG9gEcP4RbRp7e2pm4Jy1WLEOeg9Fdgm5e5Hj2nMkCSZ9BKV\n"
+            + "cxuOllSVzM/Zp0/4+RS9R57jKo3/V74Whwh9yQNgL9UxdNk7L0eGqvaT3EjXxjOc\n"
+            + "RCeJiucGN/0W2iq+V01/QGspp4SKtAogWBozABEBAAG0IlRlc3R1c2VyIFRocmVl\n"
+            + "IDx0ZXN0M0BleGFtcGxlLmNvbT6JAT4EEwECACgFAkLOp0UCGwMFCQHhM4AGCwkI\n"
+            + "BwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEDzseBsX3hrNYg0H/2CMm5/JDQNSuRFC\n"
+            + "ECWLrcOeimuvwbmkonNzOkvKbGXl73GStISAksRWAHBQED1rEPC0NkFCDeVZO7df\n"
+            + "SYLlsqKwV6uSh05Ra0F5XeniC12YpAyzoQyCGRS2wLaS822j0zUPXA8XLaO2blCu\n"
+            + "R+8sNu/oecMRcFK4S9NaApi3vdqBNhLiN1/Lpqn1LfB8uIO+eaUf4PmCWbaPgzSk\n"
+            + "qcPfKZmocNXdgLV5Q80n3hc2y2nrl+vDW2M+eVZuDHAok2BOD9uGKFfLAbaXLbX5\n"
+            + "btBW2L0UHtoEyiqkRfD6lX2laSLQmA6+eup7e4GS+s0vXBuVh8XEYddV6Yjt8H7/\n"
+            + "2thO41K5AQ0EQs6nRQEIAM/833UHK1DuFlOm7/n18dRMvs7BkXvg+hPquKWMG3be\n"
+            + "eE4sh1NG5DbRCdo6iacZLarWr3FDz7J9+wswRhtHCh3pGHEuaJk52vRjQxlkNh5F\n"
+            + "p5u2R4WF546bWqX45xPdLfHVTPyWB9q7aVxE+6Q+MHa6lMoyTVnTVCOy3nshiihw\n"
+            + "dxLsxaga+QmaL0bAR+dRcO6ucj7TDQXz1AJAVp26c0LXV9iErhFuuybUZKT0a9Aj\n"
+            + "FoumMZ6l+k30sSdjSjpBMsNvPos0dTPPRXUMu77o5sj+pHa4o8WctgB3o7BHQELp\n"
+            + "KgujZ2sKC9Nm395u6Q4cqUWihzb/Y7rIRuNHJarI7vUAEQEAAYkBJQQYAQIADwUC\n"
+            + "Qs6nRQIbDAUJAeEzgAAKCRA87HgbF94azRiBB/4vAyOOjUjK3lDWjHGs7mvEWJI/\n"
+            + "1MeLlGPswCSInJBa+HMiMI4tzq+hu5ejGThojNbmnL96GdzfDkMlP4Feyxb2rjtb\n"
+            + "NrD/R5tlXHmjX/QLzep4LCeMziP80fu8qUeiOej/Ecdny0w365PlMdt10RaYR8VE\n"
+            + "ZX/DAie6JfElnfQcG5q8TIOH3i71qxV+kIoPqKWfQ0MXrNEJ3BYFfDGdUt8U1Kq9\n"
+            + "OuIHVRgGS7mMSyjgNqqp7MBeMY+PFFZaZel5yoYVjb9d3L8XvVv2eoa/jPj5FUEU\n"
+            + "kE9uxNmwaD1PiV8DvBTYI+eQL4qzfu+3NTG2SfgQYtj5oiGHw8aL3U6QHDJb\n"
+            + "=d/Xp\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBELOp0UBCACxholOPWuKhK+TYb88nvLUSCMvTLIFEpb5u3Eavr0wiluEzq6H\n"
+            + "55nswAD3dQm8DWxA7yUlEYjPr5btpw7V9441bb1+qtgZMJ10RTdEb/WjyctdGA99\n"
+            + "uOKBEarWbt8W+w6lyJ9NXy5bS/x5EwHHfoTFp4ff6ffHI5hbx1a00K8oxmitgd0X\n"
+            + "Mx86UmauFNJYupZOZG9gEcP4RbRp7e2pm4Jy1WLEOeg9Fdgm5e5Hj2nMkCSZ9BKV\n"
+            + "cxuOllSVzM/Zp0/4+RS9R57jKo3/V74Whwh9yQNgL9UxdNk7L0eGqvaT3EjXxjOc\n"
+            + "RCeJiucGN/0W2iq+V01/QGspp4SKtAogWBozABEBAAEAB/4hGI3ckkLMTjRVa7G1\n"
+            + "YYSv4sr8dHXz0CVpZXKOo+Stef3Z4pZTK/BcXOdROvaXooD+EheAs6Yn4fpnT+/K\n"
+            + "IB7ZAx6C0OL8vz17gbPuBFltMZ/COUwaCi/gFCUfWQgqRp/SdHaOfCIuTxpAkDSS\n"
+            + "tpmWJ8eDDSFudMpgweb+SrF9DkCwp+FgUbzDRzO1aqzuu8PGihCHQt/pkhNHQ63/\n"
+            + "srDDqk6lIxxZHhv9+ucr3plDuijkvAa5/QDudQlucKDLtTPSD40UcqYnpg/V/RJU\n"
+            + "eBK0ZXmCIHpG9beHW/xdlwrK3eY4Z2sVDMm9TeeHmRYOCr5wQCyeLpMdAt0Ijk6a\n"
+            + "nINhBADI2lRodgnLvUKbOvVocz8WQjG1IXlL8iXSNuuHONijPXZiWh7XdkNxr9fm\n"
+            + "jRqzvZzYsWGT6MnirX2eXaEWJsWJHxTxJuiuOk0V/iGnV/d+jFduoKXNmB5k/ZB3\n"
+            + "6zySi7+STKNyIvnMATVsRoI/cNUwfmx53m6trFg581CnSiA82QQA4kSPw9OXmTKj\n"
+            + "ctlHrWsapWu+66pDVZw62lW6lvrd7t+m8liNb6VJuTnwIKVXJOQtUo1+GSMs0+YK\n"
+            + "wnd9FGq4jT8l0qBO4K/8B1HxppLC2S0ntC+CusxWMUDbdC2xg+G2W3oLwq3iamgz\n"
+            + "LvPTy1Pzs9PqDd6FXIdzieFy6J8W1+sEAKS3vjh7Z/PIVULZhdaohAd5Igd67S/Z\n"
+            + "BMWYNbBuJTnnb7DiOllLZSd2lR7IAKPKsUd6UY8uskOxI81hI116zNx17mIGFIIq\n"
+            + "DdDgRbvzMNEgNlOxg/BD01kXOS4fhnT2F6ca3VGTgUtOdcdF3M9MtePWQLBzEDPz\n"
+            + "8nx3O20HDupuQmG0IlRlc3R1c2VyIFRocmVlIDx0ZXN0M0BleGFtcGxlLmNvbT6J\n"
+            + "AT4EEwECACgFAkLOp0UCGwMFCQHhM4AGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheA\n"
+            + "AAoJEDzseBsX3hrNYg0H/2CMm5/JDQNSuRFCECWLrcOeimuvwbmkonNzOkvKbGXl\n"
+            + "73GStISAksRWAHBQED1rEPC0NkFCDeVZO7dfSYLlsqKwV6uSh05Ra0F5XeniC12Y\n"
+            + "pAyzoQyCGRS2wLaS822j0zUPXA8XLaO2blCuR+8sNu/oecMRcFK4S9NaApi3vdqB\n"
+            + "NhLiN1/Lpqn1LfB8uIO+eaUf4PmCWbaPgzSkqcPfKZmocNXdgLV5Q80n3hc2y2nr\n"
+            + "l+vDW2M+eVZuDHAok2BOD9uGKFfLAbaXLbX5btBW2L0UHtoEyiqkRfD6lX2laSLQ\n"
+            + "mA6+eup7e4GS+s0vXBuVh8XEYddV6Yjt8H7/2thO41KdA5gEQs6nRQEIAM/833UH\n"
+            + "K1DuFlOm7/n18dRMvs7BkXvg+hPquKWMG3beeE4sh1NG5DbRCdo6iacZLarWr3FD\n"
+            + "z7J9+wswRhtHCh3pGHEuaJk52vRjQxlkNh5Fp5u2R4WF546bWqX45xPdLfHVTPyW\n"
+            + "B9q7aVxE+6Q+MHa6lMoyTVnTVCOy3nshiihwdxLsxaga+QmaL0bAR+dRcO6ucj7T\n"
+            + "DQXz1AJAVp26c0LXV9iErhFuuybUZKT0a9AjFoumMZ6l+k30sSdjSjpBMsNvPos0\n"
+            + "dTPPRXUMu77o5sj+pHa4o8WctgB3o7BHQELpKgujZ2sKC9Nm395u6Q4cqUWihzb/\n"
+            + "Y7rIRuNHJarI7vUAEQEAAQAH+gNBKDf7FDzwdM37Sz8Ej7OsPcIbekzPcOpV3mzM\n"
+            + "u/NIuOY0QSvW7KRE8hwFlXjVZocJU/Z4Qqw+12pN55LusiRUrOq8eKuJIbl4QikI\n"
+            + "Dea8XUqM+CKJPV3YZXs6YVdIuzrRBSLgsB/Glff5JlzkEjsRYVmmnto8edETL/MK\n"
+            + "S9ClJqQiFKE4b01+Eh9oB/DfxzsiEf/a+rdRnWRh/jtpEwgeXcfmjhf+0zrzChu2\n"
+            + "ylQQ5QOuwQNKJP6DvRu/W5pOaKH9tPDR31SccDJDdnDUzBD7oSsXl06DcfMNEa8q\n"
+            + "PaNHLDDRNnqTEhwYSJ4r2emDFMxg7Kky+aatUNjAYk9vkgMEANnvumgr6/KCLWKc\n"
+            + "D3fZE09N7BveGBBDQBYNGPFtx60WbKrSY3e2RSfgWbyEXkzwm1VlB2869T1we0rL\n"
+            + "z6eV/TK5rrJQxJFHZ/anMxbQY0sCiOgqi6PKT03RTpA2N803hTym+oypy+5T6BFM\n"
+            + "rtjXvwIZN/BgAE2JjA70crTAd1mvBAD0UFNAU9oE7K7sgDbni4EhxmDyaviBHfxV\n"
+            + "PJP1ICUXAcEzAsz2T/L5TqZUD+LfYIkbf8wk2/mPZFfrCrQgCrzWn7KV1SHXkhf4\n"
+            + "4Sg6Y6p0g0Jl3mWRPiQ6ALlOVQIkp5V8z4b0hTF2c4oct1Pzaeq+ZkahyvrhW06P\n"
+            + "iaucRZb+mwP/aVTpkd4n/FyKCcbf9/KniYJ+Ou1OunsBQr/jzN+r0PKCb8l/ksig\n"
+            + "i/M0NGetemq9CxYsJDAyJs1aO4SWgx5LbfcMmyXDuJ3sL0ztFLOES31Mih3ZJebg\n"
+            + "xPpj2bB/67i2zeYRcjxQ116y23gOa2TWM8EE4TW7F/mQjw4fIPJ93ClBMIkBJQQY\n"
+            + "AQIADwUCQs6nRQIbDAUJAeEzgAAKCRA87HgbF94azRiBB/4vAyOOjUjK3lDWjHGs\n"
+            + "7mvEWJI/1MeLlGPswCSInJBa+HMiMI4tzq+hu5ejGThojNbmnL96GdzfDkMlP4Fe\n"
+            + "yxb2rjtbNrD/R5tlXHmjX/QLzep4LCeMziP80fu8qUeiOej/Ecdny0w365PlMdt1\n"
+            + "0RaYR8VEZX/DAie6JfElnfQcG5q8TIOH3i71qxV+kIoPqKWfQ0MXrNEJ3BYFfDGd\n"
+            + "Ut8U1Kq9OuIHVRgGS7mMSyjgNqqp7MBeMY+PFFZaZel5yoYVjb9d3L8XvVv2eoa/\n"
+            + "jPj5FUEUkE9uxNmwaD1PiV8DvBTYI+eQL4qzfu+3NTG2SfgQYtj5oiGHw8aL3U6Q\n"
+            + "HDJb\n"
+            + "=RrXv\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * A self-revoked key with no expiration.
+   *
+   * <pre>
+   * pub   2048R/7CA87821 2015-07-08 [revoked: 2015-07-08]
+   *       Key fingerprint = E328 CAB1 1F7E B1BC 1451  ABA5 0855 2A17 7CA8 7821
+   * uid                  Testuser Four &lt;test4@example.com&gt;
+   * </pre>
+   */
+  public static final TestKey selfRevokedKey() {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBFWdTZwBCAC1jukp5mlitfq2sAmdtx1s1VbWh+buDbBY2kWcxbbssczozFUP\n"
+            + "Ii67wPwjRbn3GM5+jY3GMsqKIrdyDlxeTxGWoU/qa2YkCQzgFGD/XJBqkVpP6osm\n"
+            + "qFYSP0xST1iBkatkMHq5KMjrX2q2bGVLlchLF9eHrWSefMcfff1Vs/Y8F2RCo38y\n"
+            + "gH88mbcvgyC+zq6Q2T3h5RiLK2IaZDNsn3uUoIMYHxI6oYtXXMSXRJlLJvamXVrB\n"
+            + "7QAq8L8cNikJjZMz+bHtLtGDyVVp9tqo4yvMrHe6BYmBUte3tPYQlDVdEo7UqepR\n"
+            + "uT7JbBOGBoD+9ngDrDggPUBAoa0h3VNOTyoDABEBAAGJAR8EIAECAAkFAlWdVXkC\n"
+            + "HQIACgkQCFUqF3yoeCH4lgf/aBdTYqnwL1lreHbQaUXI0/B2zlMuoptoi/x+xjIB\n"
+            + "7RszzaN3w0n4/87kUN2koNtgNymv2ccKTR1PiX+obscJhsWzNbz3/Cjtr/IpEQRd\n"
+            + "E6qRptHDk0U2cHW4BYDSltndOktICdhWCWYLDxJHGjdyXqqqdEEFJ24u2fUJ3yF3\n"
+            + "NF2Bxa6llrmLb2fVeVYBzQSztQopKRWP9nt3ySoeJQqRWjNBN2j7cC93nrLHZTvB\n"
+            + "L/sWuTq5ecbXeeNVzxoBd21jmGrIUPNwGdDKdbTB0CjpLpVHOTwGByeRKQXhMlQB\n"
+            + "pK96wUpxxtShtOjNjN1s9GEyLHwDiHSuHNYs/AxxFzf9nbQhVGVzdHVzZXIgRm91\n"
+            + "ciA8dGVzdDRAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJVnU2cAhsDBgsJCAcDAgYV\n"
+            + "CAIJCgsEFgIDAQIeAQIXgAAKCRAIVSoXfKh4IXsHCACSm9RIdxxqibAaxh+nm6w5\n"
+            + "F5a6Hju5cdmkk9albDoQYh2eM8E5NdDq+r0qSSe2+ujDaQ4C95DZNJQESvIcHHHb\n"
+            + "9AECrBfS8Yk86rX8hxVeYQczMkB9LdBHximTSoOr8L/eAxBE/VXDwust6EAe6Q1A\n"
+            + "a3tlTTvCfcmw4PipvtP7F6UzFaq+QU6fvARpBATOcvVc2JU4JQOrxuNEQ2PKrSti\n"
+            + "75S5mnVWm0pRebM+EorWBtlA0eOAeLNqCp87UwLdvUyOTRZT4DJ51eTxfrFADXrI\n"
+            + "9/ejs3/YxCPYxaPicAlcldduuajU/s+9ifrUn0Npg2ILl8mQkNzqeerlBeecUV4E\n"
+            + "uQENBFWdTZwBCADEOsK+mFQ/2uds9znkmAqrk24waVBpyPGrTTXtXX0dKhtQAsh6\n"
+            + "QkZGkjLTnKxEsa9syqVckw+1JtCh44SP1gjqDUoShpBz5wIuksZ7q96Hx+F0TVG/\n"
+            + "njS6GrWvwKhL2Lb9hYfdlrZiYtOOi0iiOzud25H/Ms15kC8tuQm7NWtANJJF4Sxo\n"
+            + "Bxor6L/F4zunEkTL0L9/dp4qVrw23fJVKE38cSdxjB0u1qSDzLV/u0QJqlYxJAiE\n"
+            + "ciwQN2uVnTY1/XSpouMy6LvbYU7B2uU/WohNmH3RiN/fQ6jJm4x+fCZ8+zqXMiZn\n"
+            + "G2fPkwmxxK9cl64YnNGcTwsVt6BMbCHk9jHxABEBAAGJAR8EGAECAAkFAlWdTZwC\n"
+            + "GwwACgkQCFUqF3yoeCGOdwf/TmoxH3pFBm/MDhY5Ct5FO0KvsgQk2ZgDa68HyQ8j\n"
+            + "QYi1FUCtyDjsxf5KTfyvzpzcTpS7cyOwcJNtTj6UixwATkcivvYWYoOXghAsTo4f\n"
+            + "1+j/x6ECq1+nYE6NpcAN7VRJpYMk2UO2qlhHCesTPGzsHchL7mwiYdhGrdiWGTpd\n"
+            + "KI9WfOYDZZ9ZSw/QINJUyTRxrDnauOvVbhbAXc7jdKCkRQRZpsNlF//1Stg6nstj\n"
+            + "FJ7SrjVdsMJNlihT6fG5ujmrty1/6b1VCLkIQfW5cWvzRzTBFytq7i4PVKh3u7Oz\n"
+            + "tt9lf8s50zt2uBE/AKMkyE6IJLsBWpJPk7iFKkHGDx044Q==\n"
+            + "=477N\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBFWdTZwBCAC1jukp5mlitfq2sAmdtx1s1VbWh+buDbBY2kWcxbbssczozFUP\n"
+            + "Ii67wPwjRbn3GM5+jY3GMsqKIrdyDlxeTxGWoU/qa2YkCQzgFGD/XJBqkVpP6osm\n"
+            + "qFYSP0xST1iBkatkMHq5KMjrX2q2bGVLlchLF9eHrWSefMcfff1Vs/Y8F2RCo38y\n"
+            + "gH88mbcvgyC+zq6Q2T3h5RiLK2IaZDNsn3uUoIMYHxI6oYtXXMSXRJlLJvamXVrB\n"
+            + "7QAq8L8cNikJjZMz+bHtLtGDyVVp9tqo4yvMrHe6BYmBUte3tPYQlDVdEo7UqepR\n"
+            + "uT7JbBOGBoD+9ngDrDggPUBAoa0h3VNOTyoDABEBAAEAB/4jqeZoOiACaV/Nygeh\n"
+            + "iOpJSiDsNDbrFRpKYdnhwT69APIQ2q5sshi+/dopbZVpkeBiIJk0UR7TAp3JVEPV\n"
+            + "rK92SMqjcCRYuMRkMeyZzMt7e4DjiN17ov6BSBjMZFSs4vnpTNKWk4ngHlaebe15\n"
+            + "6vq0sYK/XpKQxU7yAzQjxR190P/F+QEL98zVG/9uqM8PupfdSm4Smp2cIpfta+JD\n"
+            + "mO23HC6jAEm2RFwklovzgK3rbIjyiMuowIkAKx5xxRvpxMHf1l566b9zJrRi0xau\n"
+            + "vp4J/lnBJtTMzCbsaaFxhrj23xvTXaWR+UkaGPCv7wheXQ9K7NAHwmH8YrR+cZx7\n"
+            + "KbDlBADUTHZ+OhNslx/rkjRWrFuK9p49x7qxQc26kcqlGPbW6KOAMdUpwneQbhCG\n"
+            + "a36E/GAZgsgQ4SUqn37EVCtd2Y9Dp0inPAujcZXSwgDHev6ea7fzbxT9KLtEgvQN\n"
+            + "0vrFJDCPIt0wzGqNDw4wgFjF2rAafBO//Wu5K5QLW4hfzSguRQQA2u6DpVja/FYY\n"
+            + "UHVh2HLiB8th4T+qogOsBe5mKEsGRPXtAh7QzJu36C4PJyHeNlmlMx+15cCFnovj\n"
+            + "6cLpGn6ZP4okLyq2+VsW7wh/Vir+UZHoAO/cZRlOc1PsaQconcxxq30SsbaRQrAd\n"
+            + "YargKlXU7HMFiK34nkidBV6vVW0+P6cD/jYRInM983KXqX5bYvqsM1Zyvvlu6otD\n"
+            + "nG0F/nQYT7oaKKR46quDa+xHMxK8/Vu1+TabzY8XapnoYFaFvrl/d2rUBEZSoury\n"
+            + "z2yfTyeomft9MGGQsCGAJ95bVDT+jBoohnYwfwdC7HG3qk0aK/TxFyUqvMOX7SFe\n"
+            + "YT55n3HlD9InST+0IVRlc3R1c2VyIEZvdXIgPHRlc3Q0QGV4YW1wbGUuY29tPokB\n"
+            + "OAQTAQIAIgUCVZ1NnAIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQCFUq\n"
+            + "F3yoeCF7BwgAkpvUSHccaomwGsYfp5usOReWuh47uXHZpJPWpWw6EGIdnjPBOTXQ\n"
+            + "6vq9Kkkntvrow2kOAveQ2TSUBEryHBxx2/QBAqwX0vGJPOq1/IcVXmEHMzJAfS3Q\n"
+            + "R8Ypk0qDq/C/3gMQRP1Vw8LrLehAHukNQGt7ZU07wn3JsOD4qb7T+xelMxWqvkFO\n"
+            + "n7wEaQQEznL1XNiVOCUDq8bjRENjyq0rYu+UuZp1VptKUXmzPhKK1gbZQNHjgHiz\n"
+            + "agqfO1MC3b1Mjk0WU+AyedXk8X6xQA16yPf3o7N/2MQj2MWj4nAJXJXXbrmo1P7P\n"
+            + "vYn61J9DaYNiC5fJkJDc6nnq5QXnnFFeBJ0DmARVnU2cAQgAxDrCvphUP9rnbPc5\n"
+            + "5JgKq5NuMGlQacjxq0017V19HSobUALIekJGRpIy05ysRLGvbMqlXJMPtSbQoeOE\n"
+            + "j9YI6g1KEoaQc+cCLpLGe6veh8fhdE1Rv540uhq1r8CoS9i2/YWH3Za2YmLTjotI\n"
+            + "ojs7nduR/zLNeZAvLbkJuzVrQDSSReEsaAcaK+i/xeM7pxJEy9C/f3aeKla8Nt3y\n"
+            + "VShN/HEncYwdLtakg8y1f7tECapWMSQIhHIsEDdrlZ02Nf10qaLjMui722FOwdrl\n"
+            + "P1qITZh90Yjf30OoyZuMfnwmfPs6lzImZxtnz5MJscSvXJeuGJzRnE8LFbegTGwh\n"
+            + "5PYx8QARAQABAAf8CeTumd6jbN7USXXDyQdzjkguR6mfwN29dcY8YF4U52oOm3+w\n"
+            + "bR23XmqTvoDJXONatZEYOm093wP4hBktP3Vq2KZX5Ew9r2JoBUIoWOcHHvCQqSUW\n"
+            + "6KMJBJNBMv3zXnOscmcPvTgStS5HfYn/XRLAhEqkd2ov2x/OiS8p0vM0F7YYSOdu\n"
+            + "X6/nHeBCM5QSJl00kgcaeQYdIGL0bPv9DnoeAC2/yITEvtvs+MHZ7FjH8A45QjWn\n"
+            + "DwfVoLg7WOc3wJtqJ55/r/2pylrWz0YYM8s6I3gbDilCF+Wb8tEIOaWJEwY73J1/\n"
+            + "KQG5qlO3/hBlO80DtzNmi3ylRUuzGhTxQfvemwQA3EuZ+E48LJ3dwtdJhh5mFlWI\n"
+            + "Ket21e5v1mqMxuLhf5/2CYcifM08u3EsEUdIr7egF25Sea8otqmCYcG8FuB37VY/\n"
+            + "Hd4G/+YVVaaAB8EU6u64YfSswhzr0R2qWVLtkJr0EAephzdPdoUEtKDSdTxnXiDV\n"
+            + "3vSqLWtZekScLa979uMEAOQIodJwxSvveKQWILjK67ZJr56X8YQZWA6rFsr1xMY0\n"
+            + "N0GH+5k0k+tr4wT3H9uk9ZM1Z11G3c01mhzCNg5roFoKtftKUZRKxmbfjjDmAofl\n"
+            + "bA6EZ0WHLdOwDLLTuXK09IsjjSHq0YHOxIlgFzIreuoxtz27bEEGhVFQg7xb0Lgb\n"
+            + "A/9LP8i32L7/CHsuN0q4YjhJkkaB6JWUQMFqWwoAXALG3rnw/CGRYHmHpiAuSeHR\n"
+            + "dSlZzndVi5poNC/d27msTx7ZuWlN7nOyywHBCTWV/nstm2I9rDhrHK7Axgq0Vv0y\n"
+            + "bWAurUmEgDJHU3ZpsNVt4e30FooXIDLR4cnpRM7tILv39D4giQEfBBgBAgAJBQJV\n"
+            + "nU2cAhsMAAoJEAhVKhd8qHghjncH/05qMR96RQZvzA4WOQreRTtCr7IEJNmYA2uv\n"
+            + "B8kPI0GItRVArcg47MX+Sk38r86c3E6Uu3MjsHCTbU4+lIscAE5HIr72FmKDl4IQ\n"
+            + "LE6OH9fo/8ehAqtfp2BOjaXADe1USaWDJNlDtqpYRwnrEzxs7B3IS+5sImHYRq3Y\n"
+            + "lhk6XSiPVnzmA2WfWUsP0CDSVMk0caw52rjr1W4WwF3O43SgpEUEWabDZRf/9UrY\n"
+            + "Op7LYxSe0q41XbDCTZYoU+nxubo5q7ctf+m9VQi5CEH1uXFr80c0wRcrau4uD1So\n"
+            + "d7uzs7bfZX/LOdM7drgRPwCjJMhOiCS7AVqST5O4hSpBxg8dOOE=\n"
+            + "=5aNq\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * A key with an additional user ID.
+   *
+   * <pre>
+   * pub   2048R/98C51DBF 2015-07-30
+   *       Key fingerprint = 42B3 294D 1924 D7EB AF4A  A99F 5024 BB44 98C5 1DBF
+   * uid                  foo:myId
+   * uid                  Testuser Five <test5@example.com>
+   * sub   2048R/C781A9E3 2015-07-30
+   * </pre>
+   */
+  public static TestKey validKeyWithSecondUserId() {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBFW6jd4BCACrf+BZS3lntuWq2DWPOG07/BUWhx3RSoiS3JBuKEDmlsjswKcp\n"
+            + "JHT+p2tqH52XbujMlzNjAQcZjJwfOMt6Fg7zd3F8cwYQdCE/W5dpMs/mqdeEz6GL\n"
+            + "VJDZ0Y5wwz54ZQHp91Xq6uejxt5qffeTQk5cToQZ0RVx3iwBc+2P3iYJqMFmJzj8\n"
+            + "djabEoF4D50iI5tY8moE83VcXJ5Y4xn+5Z5AThmlfrMP6gIdG0b4lEe1tsnJC6AG\n"
+            + "GUU6VkzK6E1Tp93Y0brtWpJKi9Gt6eUqvWhZtPEdFVCFbLTpezUdRFEuaFbGg5pn\n"
+            + "9K/DceahFmquDJOHVgawt6erlq/ie7QEEld/ABEBAAG0IVRlc3R1c2VyIEZpdmUg\n"
+            + "PHRlc3Q1QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCVbqN3gIbAwYLCQgHAwIGFQgC\n"
+            + "CQoLBBYCAwECHgECF4AACgkQUCS7RJjFHb+/MAf9FKZatGcuOIoYqwGQQneyc63v\n"
+            + "3H/PyhvYF1nuKNftmhqIiUHec9RaUHQkgam6LRoonkDfIpNlQVRv2XBV2VOAOFVO\n"
+            + "RyQ/Tv7/xtpqGZqivV0yn2ZXbCceA627Vz7gP4gkO0ZJ0JsYJTc/5wO+nVG5Lohu\n"
+            + "/zdUofEbFAvcXs+Z1uXnUDdeGn47Lf1xZ2XOHOI0aQW4DdNaFoAd+AOTe0W3iB6W\n"
+            + "paCIGno69CyNHNnWjJCSD33oLVaXyvbgw5UoyITvSqRnPyLGIc6dsqDLT59ok0Fk\n"
+            + "t4jtiGu9aze4n59GbtSjmWQgzbLCQWhK9K7UCcSLYNKXVyMha2WapBO156V027QI\n"
+            + "Zm9vOm15SWSJATgEEwECACIFAlW6jwYCGwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4B\n"
+            + "AheAAAoJEFAku0SYxR2/zZUH/1BwPsResHLDSmo6UdQyQGxvV0NcwBqGAPSLHr+S\n"
+            + "PHEaHEIYvOywNfWXquYrECa/5iIrXuTQmCH0q8WRcz1UapDCeD8Ui82r+3O8m6gk\n"
+            + "hIR5VAeza+x/fGWhG342PvtpDU7JycDA3KMCTWtcAM89tFhffzuEQ3f5p5cMTtZk\n"
+            + "/23iegXbHd61vojYO17QYEj+qp9l0VNiyFymPL3qr5bVj/xn/mXFj+asj0L2ypIj\n"
+            + "zC36FkhzW5EX2xgV9Cl9zu7kLMTm+yM+jxbMLskYkG8z/D+xBQsoX8tEIPlxHLhB\n"
+            + "miEmVuZrp91ArRMWa3B7PYz7hQzs+M/bxKXcmWxacggTOvy5AQ0EVbqN3gEIAOlq\n"
+            + "mwdiXW0BQP/iQvIweP1taNypAvdjI2fpnXkUfBT5X/+E/RjYOHQEAzy8nEkS+Y0l\n"
+            + "MLwKt3S0IVRvdeXxlpL6Tl+P8DkcD5H+uvACrg9rtgbbNSoQtc9/3bknG9hea6xi\n"
+            + "6SBH1k9Y2RInIrwWslfKmuNkyZVhxPKypasBsvyhOWLlpCngGiCa74KJ1th1WKa2\n"
+            + "aaDqcbieBTc1mtsXR6kBhJZqK+JYBoHriUQMs7nyXxn2qyv6Lehs/tHlrBZ7j16S\n"
+            + "faQzYoBi1edVrpFr/CuGk6RNKxG9vi/uAA9q2cLCMjjyfMH4g0G2l0HuDPQLA9wi\n"
+            + "BfusEC+OceaeFKtS9ykAEQEAAYkBHwQYAQIACQUCVbqN3gIbDAAKCRBQJLtEmMUd\n"
+            + "vw/DB/9Qx9m1eSdddqz/fk16wJf7Ncr2teVvdQOjRf/qo43KDKxEzeepjgypG1br\n"
+            + "St7U4/MlPygJLBDB4pXp0kaKt+S/aqLpEGSGzQ1FysM8oY6K0e1Kbf6nMaQS8ATG\n"
+            + "aD377FrUJ42NV4JS+NGlwaM9PhpRVm5n8iCzRs9HtlTyfCBkNGDjGOSdWcah2m6T\n"
+            + "fEQdD+XVDN1ZC8zAnc8FW28YOTeTjX079okP6ZCjLJ16VZ7eiHFkrNbS9Dl4SPNK\n"
+            + "eElvsZLBaf8t4RQXFFKwRq4BW+zS8zm9E2H6bZ9yGrmgIREzyRPpwU98g8yrabu0\n"
+            + "54w16Vp/SVViJs7nTMSug0WREyd2\n"
+            + "=ldwB\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBFW6jd4BCACrf+BZS3lntuWq2DWPOG07/BUWhx3RSoiS3JBuKEDmlsjswKcp\n"
+            + "JHT+p2tqH52XbujMlzNjAQcZjJwfOMt6Fg7zd3F8cwYQdCE/W5dpMs/mqdeEz6GL\n"
+            + "VJDZ0Y5wwz54ZQHp91Xq6uejxt5qffeTQk5cToQZ0RVx3iwBc+2P3iYJqMFmJzj8\n"
+            + "djabEoF4D50iI5tY8moE83VcXJ5Y4xn+5Z5AThmlfrMP6gIdG0b4lEe1tsnJC6AG\n"
+            + "GUU6VkzK6E1Tp93Y0brtWpJKi9Gt6eUqvWhZtPEdFVCFbLTpezUdRFEuaFbGg5pn\n"
+            + "9K/DceahFmquDJOHVgawt6erlq/ie7QEEld/ABEBAAEAB/9MIlrQiWb+Gf3fWFh+\n"
+            + "mkg0Bva9p4IfNX1n5S7hGFGnjGzqXaRX6W1e16gh1qM5ZO1IVh9j5kLmnrt4SNhb\n"
+            + "/Irqnq3s14trpoJUBC81bm9JMUESHrLSjdo4OIWJncOP4xd0bG7h+SKYXGLE1+Me\n"
+            + "pqLu65RNebqRcFYM1xAxfCdaxatcz+LrW5ZX+6T/Gh/VCHRkkzzVIZO1dDBbyU2C\n"
+            + "JrNcfHSvNrjzfqYHtwfsk/lwcuY9pqkYcuwZ2IM+iWKit+WyCR2BzOpG/Sva1t8b\n"
+            + "7B7ituQCFMCv5IiaAoaSKX/t/0ucWCoT1ttih8LdwgEE0kgij/ZUfRxCiL9HmtLy\n"
+            + "ad9BBADBGYWv6NiTQiBG7+MZ+twCjlSL7vq8iENhQYZShGHF9z+ju7m8U1dteLny\n"
+            + "pC3NcNfCgWyy+8lRn1e6Oe6m7xL83LL3HJT5nIy9mpsCw/TIrrkzkoE+VpkEIL/o\n"
+            + "Yeoxauah4SU7laVD29aAQZ3TqwSwx0sJwPjsj73WjjqtzJfFkQQA410ghqMbQZN1\n"
+            + "yJzXgVAj162ZwTi961N5iYmqTiBtqGz1UfaNBJWdJMkCmhMTsiOtm1h4zUQRuEH+\n"
+            + "yq1xhKOGf15dB/cLSMj2KpVVlvgLoVmYDugSER8Q23juilY7iaf0bqo9q1sTHpn9\n"
+            + "O7Oin/9J3sz+ic45vDh4aa74sOzfhA8EAJwAFEWLrGSxtnYJR5vQNstHIH1wtQ5G\n"
+            + "ZUZ57y9CbDkKrfCQvd0JOBjfUDz+N8qiamNIqfhQBtlhIDYgtswiG+iGP/2G0l6S\n"
+            + "j9DHNe2CYPUKgy+zQiRnyNGE2XUfcE+HuNDfu3AryPqaD8vLLw8TnsAgis3bRGg+\n"
+            + "hhrAC1NyKfDXTg20IVRlc3R1c2VyIEZpdmUgPHRlc3Q1QGV4YW1wbGUuY29tPokB\n"
+            + "OAQTAQIAIgUCVbqN3gIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQUCS7\n"
+            + "RJjFHb+/MAf9FKZatGcuOIoYqwGQQneyc63v3H/PyhvYF1nuKNftmhqIiUHec9Ra\n"
+            + "UHQkgam6LRoonkDfIpNlQVRv2XBV2VOAOFVORyQ/Tv7/xtpqGZqivV0yn2ZXbCce\n"
+            + "A627Vz7gP4gkO0ZJ0JsYJTc/5wO+nVG5Lohu/zdUofEbFAvcXs+Z1uXnUDdeGn47\n"
+            + "Lf1xZ2XOHOI0aQW4DdNaFoAd+AOTe0W3iB6WpaCIGno69CyNHNnWjJCSD33oLVaX\n"
+            + "yvbgw5UoyITvSqRnPyLGIc6dsqDLT59ok0Fkt4jtiGu9aze4n59GbtSjmWQgzbLC\n"
+            + "QWhK9K7UCcSLYNKXVyMha2WapBO156V0250DmARVuo3eAQgA6WqbB2JdbQFA/+JC\n"
+            + "8jB4/W1o3KkC92MjZ+mdeRR8FPlf/4T9GNg4dAQDPLycSRL5jSUwvAq3dLQhVG91\n"
+            + "5fGWkvpOX4/wORwPkf668AKuD2u2Bts1KhC1z3/duScb2F5rrGLpIEfWT1jZEici\n"
+            + "vBayV8qa42TJlWHE8rKlqwGy/KE5YuWkKeAaIJrvgonW2HVYprZpoOpxuJ4FNzWa\n"
+            + "2xdHqQGElmor4lgGgeuJRAyzufJfGfarK/ot6Gz+0eWsFnuPXpJ9pDNigGLV51Wu\n"
+            + "kWv8K4aTpE0rEb2+L+4AD2rZwsIyOPJ8wfiDQbaXQe4M9AsD3CIF+6wQL45x5p4U\n"
+            + "q1L3KQARAQABAAf8C+2DsJPpPEnFHY5dZ2zssd6mbihA2414YLYCcw6F7Lh1nGQa\n"
+            + "XuulruAJnk/xGJbco8bTv7g4ecE+tsbfWnnG/QnHeYCsgO6bKRXATcWFSYpyidUn\n"
+            + "2VdzQwBAv1ZtSNhCXlPLn/erzvA2X4QadUwfnvbehWJAHt8ZJmHUr3FtyRUHEdCK\n"
+            + "2EXsBWnzPCcqHZOMvcbSINSqBFGzVXkOZsMFvPTNIUYRHz8NbJT/OPiOmyBshXpS\n"
+            + "t8w3QqZhBcTT3NZo3kgxN1RygaTa10ytB2cxTCVuD8hmUBaV9gakdfMYkVJds7/T\n"
+            + "ZY3It68F0vitBnqpppZQ+NFgr/vwVg0p3gbmAQQA79zsWPvyIqYvyJhmiKvLIpev\n"
+            + "569ho8tC9xx+IZ5WnjN8ZADlb9brAdA9cqGfBgZkpZUhngCRVOYUIco+m2NYkEJm\n"
+            + "BsSTTM77dqU55DRloJ3FtBwCPXHkwg9P/FHMMYYGyLpQTSB92hXk8yomo+ozX7kx\n"
+            + "DtUHZIrir/rr0lQe+GkEAPkep9V5jBmfHMArnfji7Nfb1/ZjrSAaK+rtqczgm+6j\n"
+            + "ubY/0DpM/6gm+/8X27WFw2m45ncH3qNvOe4Qm40EmgmHkXsdQyU0Fv7uXc9nBYoo\n"
+            + "G6s7DWLY4VAqWwPsvbqgpSp/qdGn9nlcJjjY1HtfU7HM3xysT7TJ2YVhYHlJdjDB\n"
+            + "A/0alBcYtHvaCJaRLWX4UiashbfETWAf/4oHlERjkXj64qOdsGnD6CD99t9x91Ue\n"
+            + "pClPsLDFvY8/HxWX7STA9pQZAa2ZdJd8b58Rgy9TBShw2mbz2S6Cbw77pP/WEjtJ\n"
+            + "pJuS2gDp70H01fYRaw7YH32CfUr1VeEv7hTjk/SNVteIZkkOiQEfBBgBAgAJBQJV\n"
+            + "uo3eAhsMAAoJEFAku0SYxR2/D8MH/1DH2bV5J112rP9+TXrAl/s1yva15W91A6NF\n"
+            + "/+qjjcoMrETN56mODKkbVutK3tTj8yU/KAksEMHilenSRoq35L9qoukQZIbNDUXK\n"
+            + "wzyhjorR7Upt/qcxpBLwBMZoPfvsWtQnjY1XglL40aXBoz0+GlFWbmfyILNGz0e2\n"
+            + "VPJ8IGQ0YOMY5J1ZxqHabpN8RB0P5dUM3VkLzMCdzwVbbxg5N5ONfTv2iQ/pkKMs\n"
+            + "nXpVnt6IcWSs1tL0OXhI80p4SW+xksFp/y3hFBcUUrBGrgFb7NLzOb0TYfptn3Ia\n"
+            + "uaAhETPJE+nBT3yDzKtpu7TnjDXpWn9JVWImzudMxK6DRZETJ3Y=\n"
+            + "=uND5\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * A key revoked by a valid key, due to key compromise.
+   *
+   * <p>Revoked by {@link #validKeyWithoutExpiration()}.
+   *
+   * <pre>
+   * pub   2048R/3434B39F 2015-10-20 [revoked: 2015-10-20]
+   *       Key fingerprint = 931F 047D 7D01 DDEF 367A  8D90 8C4F D28E 3434 B39F
+   * uid                  Testuser Six &lt;test6@example.com&gt;
+   * </pre>
+   */
+  public static TestKey revokedCompromisedKey() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBFYmpXkBCACqaLz51DcWQmfOJnat9iHSySfSHwbKfVvoN43Ba2cf/D/PadRc\n"
+            + "HLgc+91k2yk1kV1LnMdvUGj5zZ84ZqrQx3f1WeItnzZpqxtmQS/GSxCp9EY/s7w6\n"
+            + "5i86R/k9Tzgvk0B7dKZJXbM/OWxxDkkxHWE3Un9wreX7bDU5b9D2knHRiNFqH9ZJ\n"
+            + "KqDIFZqH9WTUxNZcHz20sTCRIMfvsAwf2vRU5N5xTu4Mbk6JFc7BAj7h1f/mYEPo\n"
+            + "CTyB1jV/DSDVdn1FjJVocSg6W/CvsYF9hKFYjJHl4VXdePTpnOjHhJLL0QWk0TMe\n"
+            + "xYeUi/xDr5DeMxTmi7F7BFaQEF+KmUM46e+9ABEBAAGJATAEIAECABoFAlYmq1gT\n"
+            + "HQJ0ZXN0NiBjb21wcm9taXNlZAAKCRDtBiXcRjKKjIm6B/9YwkyG4w+9KUNESywM\n"
+            + "bxC2WWGWrFcQGoKxixzt0uT251UY8qxa1IED0wnLsIQmffTQcnrK3B9svd4HhQlk\n"
+            + "pheKQ3w5iluLeGmGljhDBdAVyS07jYoFUGTXjwzPAgJ3Dxzul8Q8Zj+fOmRcfsP9\n"
+            + "72kl6g2yEEbevnydWIiOj/vWHVLFb54G8bwXTNwH/FXQsHuPYxXZifwyDwdwEQMq\n"
+            + "0VTZcrukgeJ+VbSSuq+uX4I3+kJw5hL49KYAQltQBmTo3yhuY/Q+LkgcBv/umtY/\n"
+            + "DrUqSCBV1bTnfq5SfaObkUu22HWjrtSFSjnXYyh+wyTG3AXG3N9VPrjGQIJIW1j6\n"
+            + "9QM0iQE3BB8BAgAhBQJWJqYUFwyAAQSup+0vghEz5bEo0e0GJdxGMoqMAgcAAAoJ\n"
+            + "EIxP0o40NLOfYd4H/3GpfxfJ+nMzBChn1JqFrKOqqYiOO4sUwubXzpRO33V2jUrU\n"
+            + "V75PTWG/6NlgDbPfKFcU0qZud6M2EQxSS9/I20i/MpRB7qJnWMM/6HxdMDJ0o/pN\n"
+            + "4ImIGj38QTIWx0DS9n3bwlcobl7ZlM8g2N1kv5jQPEuurffeJRS4ny4pEvCCm2IS\n"
+            + "SGOuB0DVtYHGDrJLQ0k4mDkEJuU8fP5un8mN8I8eAINlsTFpsTswMXMiptZTm5SI\n"
+            + "5QZlG3m5MvvckngYdhynvCWc6JHGt1EHXlI4A5Qetr/4FbNE4uYcEEhyzBy4WQfi\n"
+            + "QCPiIzzm3O4cMnr9N+5HzYqRhu2OveYm86G2Rxq0IFRlc3R1c2VyIFNpeCA8dGVz\n"
+            + "dDZAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJWJqV5AhsDBgsJCAcDAgYVCAIJCgsE\n"
+            + "FgIDAQIeAQIXgAAKCRCMT9KONDSzn2XtB/4wl4ctc3cW9Fwp17cktFi6md8fjRiR\n"
+            + "wE/ruVKIKmAHzeMLBoZn4LZVunyNCRGLZfP+MUs4JhLkp8ioTzUB7xPl9k94FXel\n"
+            + "bObn9F0T7htjFLiFAOMeykneylk2kalTt6IBKtaOPn+V6onBwO+YHbwt+xLMhAWj\n"
+            + "Z/WA0TIC1RIukdzWErhd+9lG8B9kupGC5bPo/AgCPoajPhS1qLrth+lCsNJXT/Rt\n"
+            + "k6Jx5omypxMXPzgzNtULMFONszaRnHnrCHQg/yJZDCw3ffW5ShfyfWdFM65jgEKo\n"
+            + "nMKLzy9XV+BM6IJQlgHCBAP8WHKSf4qMG4/hEWLrwA/bTQ7w0DSV88msuQENBFYm\n"
+            + "pXkBCACzIMFDC6kcV58uvF3XwOrS3DmKNPDNzO/4Ay/iOxZbm+9NP8QWEEm+AzCt\n"
+            + "ZMfYdZ8C3DjuzxkhcacI/E5agZICds6bs0+VS7VKEeNYp/UrTF9pkZNXseCrJPgr\n"
+            + "U31eoGVc5bE5c0TGLhAjbMKtR5LZFMpAXgpA7hXJSSuAXGs8gjkJkYSJYnJwIOyd\n"
+            + "xOi5jmnE/U5QuMjBG0bwxFXxkaDa5mcebJ/6C8mgkKyATbQkCe7YJGl1JLK4vY28\n"
+            + "ybSMhMDtZiwgvKzd+HcQr+xUQvmgSMApJaMxKPHRA1IrP/STXUEAjcGfk/HCz/0j\n"
+            + "7mJG2cvCxeOMAmp/pTzhSoXiqUNlABEBAAGJAR8EGAECAAkFAlYmpXkCGwwACgkQ\n"
+            + "jE/SjjQ0s5/kVAf/QvHOhuoBSlSxPcgvnvCl8V3zbNR1P9lgjYGwMsvLhwCT7Wvm\n"
+            + "mkUKvtT913uER93N8xJD2svGhKabpiPj9/eo0p3p64dicijsP1UQfpmWKPa/V9sv\n"
+            + "zep08cpDl/eczSiLqgcTXCoZeewWXoQGqqoXnwa4lwQv4Zvj7TTCN2wRzoGwbRcm\n"
+            + "G2hmc27uOwA+hXbF+bLe6HOZR/7U93j8a22g2X9OgST/QCsLgyiUSw3YYaEan9tn\n"
+            + "wuEgAEY/rchOvgeXe5Sl0wTFLHH6OS4BBGgc1LRKnSCM2dgZqvhOOxOvuuieBWY6\n"
+            + "tULvIEIjNNP8Qizfc4u2O8h7HP2b3yYSrp9MMQ==\n"
+            + "=Dxr7\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBFYmpXkBCACqaLz51DcWQmfOJnat9iHSySfSHwbKfVvoN43Ba2cf/D/PadRc\n"
+            + "HLgc+91k2yk1kV1LnMdvUGj5zZ84ZqrQx3f1WeItnzZpqxtmQS/GSxCp9EY/s7w6\n"
+            + "5i86R/k9Tzgvk0B7dKZJXbM/OWxxDkkxHWE3Un9wreX7bDU5b9D2knHRiNFqH9ZJ\n"
+            + "KqDIFZqH9WTUxNZcHz20sTCRIMfvsAwf2vRU5N5xTu4Mbk6JFc7BAj7h1f/mYEPo\n"
+            + "CTyB1jV/DSDVdn1FjJVocSg6W/CvsYF9hKFYjJHl4VXdePTpnOjHhJLL0QWk0TMe\n"
+            + "xYeUi/xDr5DeMxTmi7F7BFaQEF+KmUM46e+9ABEBAAEAB/wOspbuA1A3AsY6QRYG\n"
+            + "Xg6/w+rD1Do9N7+4ESaQUqej2hlU1d9jjHSSx2RqgP6WaLG/xkdrQeez9/iuICjG\n"
+            + "dhXSGw0He05xobjswl2RAENxLSjr8KAhAl57a97C23TQoaYzn7WB6Wt+3gCM5bsJ\n"
+            + "WevbHinwuYb2/ve+OvcudSYM+Nhtpv0DoTaizhi9wzc3g/XLbturlpdCffbw4y+h\n"
+            + "gBPd/t3cc/0Ams8Wi2RlmDOoe73ls23nBHcNomgydyIYBn7U5Z3v3YkPNp9VBiXx\n"
+            + "rC4mDtB1ugucMhqjRNAYqinaLP35CiBTU/IB0WLu7ZyytnjY5frly1ShAG8wFL0B\n"
+            + "MOMxBADJjGy1NwGSd/7eMeYyYThyhXDxo5so91/O1+RLnSUVv/Nz6VOPp2TtuVN5\n"
+            + "uTJkpSXtUFyWbf8mkQiFz4++vHW5E/Q6+KomXRalK7JeBzeFMtax64ykQHID9cSu\n"
+            + "TaSHBhOEEeZZuf6BlulYEJEBHYK6EFlPJn+cpZtTFaqDoKh22QQA2HKjfyeppNre\n"
+            + "WRFJ9h1x1hBlSRR+XIPYmDmZUjL37jQUlw8iF+txPclfyNBw2I2Om+Jhcf25peOx\n"
+            + "ow4yvjt8r3qDjNhI2zLE9u4zrQ9xU8CUingT0t4k3NO2vigpKlmp1/w2IHSMctry\n"
+            + "v1v3+BAS8qGIYDY1lgI7QBvle5hxGYUD/00zMyHOIgYg/cM5sR0qafesoj9kRff5\n"
+            + "UMnSy1dw+pGMv6GqKGbcZDoC060hUO9GhQRPZXF8PlYzD30lOLS2Uw4mPXjOmQVv\n"
+            + "lDiyl/vLkfkVfP/alYH0FW6mErDrjtHhrZewqDm3iPLGMVGfGCJsL+N37VBSe+jr\n"
+            + "4rZCnjk/Jo5JRoKJATcEHwECACEFAlYmphQXDIABBK6n7S+CETPlsSjR7QYl3EYy\n"
+            + "iowCBwAACgkQjE/SjjQ0s59h3gf/cal/F8n6czMEKGfUmoWso6qpiI47ixTC5tfO\n"
+            + "lE7fdXaNStRXvk9NYb/o2WANs98oVxTSpm53ozYRDFJL38jbSL8ylEHuomdYwz/o\n"
+            + "fF0wMnSj+k3giYgaPfxBMhbHQNL2fdvCVyhuXtmUzyDY3WS/mNA8S66t994lFLif\n"
+            + "LikS8IKbYhJIY64HQNW1gcYOsktDSTiYOQQm5Tx8/m6fyY3wjx4Ag2WxMWmxOzAx\n"
+            + "cyKm1lOblIjlBmUbebky+9ySeBh2HKe8JZzokca3UQdeUjgDlB62v/gVs0Ti5hwQ\n"
+            + "SHLMHLhZB+JAI+IjPObc7hwyev037kfNipGG7Y695ibzobZHGrQgVGVzdHVzZXIg\n"
+            + "U2l4IDx0ZXN0NkBleGFtcGxlLmNvbT6JATgEEwECACIFAlYmpXkCGwMGCwkIBwMC\n"
+            + "BhUIAgkKCwQWAgMBAh4BAheAAAoJEIxP0o40NLOfZe0H/jCXhy1zdxb0XCnXtyS0\n"
+            + "WLqZ3x+NGJHAT+u5UogqYAfN4wsGhmfgtlW6fI0JEYtl8/4xSzgmEuSnyKhPNQHv\n"
+            + "E+X2T3gVd6Vs5uf0XRPuG2MUuIUA4x7KSd7KWTaRqVO3ogEq1o4+f5XqicHA75gd\n"
+            + "vC37EsyEBaNn9YDRMgLVEi6R3NYSuF372UbwH2S6kYLls+j8CAI+hqM+FLWouu2H\n"
+            + "6UKw0ldP9G2TonHmibKnExc/ODM21QswU42zNpGceesIdCD/IlkMLDd99blKF/J9\n"
+            + "Z0UzrmOAQqicwovPL1dX4EzoglCWAcIEA/xYcpJ/iowbj+ERYuvAD9tNDvDQNJXz\n"
+            + "yaydA5gEVialeQEIALMgwUMLqRxXny68XdfA6tLcOYo08M3M7/gDL+I7Flub700/\n"
+            + "xBYQSb4DMK1kx9h1nwLcOO7PGSFxpwj8TlqBkgJ2zpuzT5VLtUoR41in9StMX2mR\n"
+            + "k1ex4Ksk+CtTfV6gZVzlsTlzRMYuECNswq1HktkUykBeCkDuFclJK4BcazyCOQmR\n"
+            + "hIlicnAg7J3E6LmOacT9TlC4yMEbRvDEVfGRoNrmZx5sn/oLyaCQrIBNtCQJ7tgk\n"
+            + "aXUksri9jbzJtIyEwO1mLCC8rN34dxCv7FRC+aBIwCklozEo8dEDUis/9JNdQQCN\n"
+            + "wZ+T8cLP/SPuYkbZy8LF44wCan+lPOFKheKpQ2UAEQEAAQAH/A1Os+Tb9yiGnuoN\n"
+            + "LuiSKa/YEgNBOxmC7dnuPK6xJpBQNZc200WzWJMf8AwVpl4foNxIyYb+Rjbsl1Ts\n"
+            + "z5JcOWFq+57oE5O7D+EMkqf5tFZO4nC4kqprac41HSW02mW/A0DDRKcIt/WEIwlK\n"
+            + "sWzHmjJ736moAtl/holRYQS0ePgB8bUPDQcFovH6X3SUxlPGTYD1DEX+WNvYRk3r\n"
+            + "pa9YXH65qbG9CEJIFTmwZIRDl+CBtBlN/fKadyMJr9fXtv7Fu9hNsK1K1pUtLqCa\n"
+            + "nc22Zak+o+LCPlZ8vmw/UmOGtp2iZlEragmh2rOywp0dHF7gsdlgoafQf8Q4NIag\n"
+            + "TFyHf1kEAMSOKUUwLBEmPnDVfoEOt5spQLVtlF8sh/Okk9zVazWmw0n/b1Ef72z6\n"
+            + "EZqCW9/XhH5pXfKJeV+08hroHI6a5UESa7/xOIx50TaQdRqjwGciMnH2LJcpIU/L\n"
+            + "f0cGXcnTLKt4Z2GeSPKFTj4VzwmwH5F/RYdc5eiVb7VNoy9DC5RZBADpTVH5pklS\n"
+            + "44VDJIcwSNy1LBEU3oj+Nu+sufCimJ5B7HLokoJtm6q8VQRga5hN1TZkdQcLy+b2\n"
+            + "wzxHYoIsIsYFfG/mqLZ3LJNDFqze1/Kj987DYSUGeNYexMN2Fkzbo35Jf0cpOiao\n"
+            + "390JFOS7qecUak5/yJ/V4xy8/nds37617QP9GWlFBykDoESBC2AIz8wXcpUBVNeH\n"
+            + "BNSthmC+PJPhsS6jTQuipqtXUZBgZBrMHp/bA8gTOkI4rPXycH3+ACbuQMAjbFny\n"
+            + "Kt69lPHD8VWw/82E4EY2J9LmHli+2BcATz89ouC4kqC5zF90qJseviSZPihpnFxA\n"
+            + "1UqMU2ZjsPb4CM9C/YkBHwQYAQIACQUCVialeQIbDAAKCRCMT9KONDSzn+RUB/9C\n"
+            + "8c6G6gFKVLE9yC+e8KXxXfNs1HU/2WCNgbAyy8uHAJPta+aaRQq+1P3Xe4RH3c3z\n"
+            + "EkPay8aEppumI+P396jSnenrh2JyKOw/VRB+mZYo9r9X2y/N6nTxykOX95zNKIuq\n"
+            + "BxNcKhl57BZehAaqqhefBriXBC/hm+PtNMI3bBHOgbBtFyYbaGZzbu47AD6FdsX5\n"
+            + "st7oc5lH/tT3ePxrbaDZf06BJP9AKwuDKJRLDdhhoRqf22fC4SAARj+tyE6+B5d7\n"
+            + "lKXTBMUscfo5LgEEaBzUtEqdIIzZ2Bmq+E47E6+66J4FZjq1Qu8gQiM00/xCLN9z\n"
+            + "i7Y7yHsc/ZvfJhKun0wx\n"
+            + "=M/kw\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * A key revoked by a valid key, due to no longer being used.
+   *
+   * <p>Revoked by {@link #validKeyWithoutExpiration()}.
+   *
+   * <pre>
+   * pub   2048R/3D6C52D0 2015-10-20 [revoked: 2015-10-20]
+   *       Key fingerprint = 32DB 6C31 2ED7 A98D 11B2  43EA FAD2 ABE2 3D6C 52D0
+   * uid                  Testuser Seven &lt;test7@example.com&gt;
+   * </pre>
+   */
+  public static TestKey revokedNoLongerUsedKey() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBFYmq3EBCAC9ssY6QhFsnZqKEPlQrx8Zomblj8qV93/B448isOT2L6OVY7UC\n"
+            + "kKPj6afW5UDkYeyZSmLZfTrpePcbAB8FB3uvd/AS9mHC+6zuBlwlkl9xIXlwUXQP\n"
+            + "KER4LKYNTP21AM+/vTJm4+u26tlZECIZlez31KEeqM30EAm+/pO8VkEp8+1ImfLv\n"
+            + "otndIjMoq9gxJvn6KZeexJT2eKCsSa20vVsmAhuFjLZitU3lEjIROfDiyHUZ2cZ+\n"
+            + "qynfppJCKlHJRu/T9L/yxDFVUFDFSajNzSfjG1g3FEveDITyAhRetVfZbhyJptnV\n"
+            + "jfiHSQkLamPsBmMoKfP+aO5SfsTHTJvxgLUdABEBAAGJAS0EIAECABcFAlYmq8AQ\n"
+            + "HQN0ZXN0NyBub3QgdXNlZAAKCRDtBiXcRjKKjPKqB/sF+ypJZaZ5M4jFdoH/YA3s\n"
+            + "4+VkA/NbLKcrlMI0lbnIrax02jdyTo7rBUJfTwuBs5QeQ25+VfaBcz9fWSv4Z8Bk\n"
+            + "9+w61bQZLQkExZ9W7hnhaapyR0aT0rY48KGtHOPNoMQu9Si+RnRiI024jMUUjrau\n"
+            + "w/exgCteY261VtCPRgyZOlpbX43rsBhF8ott0ZzSfLwaNTHhsjFsD1uH6TSFO8La\n"
+            + "/H1nO31sORlY3+rCGiQVuYIJD1qI7bEjDHYO0nq/f7JjfYKmVBg9grwLsX3h1qZ2\n"
+            + "L3Yz+0eCi7/6T/Sm7PavQ+EGL7+WBXX3qJpwc+EFNHs6VxQp86k6csba0c5mNcaQ\n"
+            + "iQE3BB8BAgAhBQJWJqusFwyAAQSup+0vghEz5bEo0e0GJdxGMoqMAgcAAAoJEPrS\n"
+            + "q+I9bFLQ2BYH/jm+t7pZuv8WqZdb8FiBa9CFfhcSKjYarMHjBw7GxWZJMd5VR4DC\n"
+            + "r4T/ZSAGRKBRKQ2uXrkm9H0NPDp0c/UKCHtQMFDnqTk7B63mwSR1d7W0qaRPXYQ1\n"
+            + "bbatnzkEDOj0e+rX6aiqVRMo/q6uMNUFl6UMrUZPSNB5PVRQWPnQ7K11mw3vg0e5\n"
+            + "ycqJbyFvER6EtyDUXGBo8a5/4bK8VBNBMTAIy6GeGpeSM5b7cpQk7/j4dXugCJAV\n"
+            + "fhFNUOgLduoIKM4u+VcFjk3Km/YxOtGi1dLqCbTX/0LiCRA9mgQpyNVyA+Sm48LM\n"
+            + "LUkbcrN/F3SHX1ao/5lm19r8Biu1ziQnLgC0IlRlc3R1c2VyIFNldmVuIDx0ZXN0\n"
+            + "N0BleGFtcGxlLmNvbT6JATgEEwECACIFAlYmq3ECGwMGCwkIBwMCBhUIAgkKCwQW\n"
+            + "AgMBAh4BAheAAAoJEPrSq+I9bFLQvjQH/0K7aBsGU2U/rm4I+u+uPa6BnFTYQJqg\n"
+            + "034pwdD0WfM3M/XgVh7ERjnR9ZViCMVej+K3kW5d2DNaXu5vVpcD6L6jjWwiJHBw\n"
+            + "LIcmpqQrL0TdoCr4F4FKQnBbcH1fNvP8A/hLDHB3k3ERPvEFIo1AkVuK4s/v7yZY\n"
+            + "HAowX0r4ok4ndu/wAc0HI1FkApkAfh18JDTuui53dkKhnkDp7Xnfm/ElAZYjB7Se\n"
+            + "ivxOD9vdhViWSx1VhttPZo5hSyJrEYaJ5u9hsXNUN85DxgLqCmS1v8n3pN1lVY/Q\n"
+            + "TYXtgocakQgHGEG0Tl6a3xpNkn9ihnyCr80mHCxXTyUUBGfygccelB+5AQ0EViar\n"
+            + "cQEIAKxwXb6HGV9QjepADyWW7GMxc2JVZ7pZM2sdf8wrgnQqV2G1rc9gAgwTX4jt\n"
+            + "OY0vSKT1vBq09ZXS3qpYHi/Wwft0KkaX/a7e6vKabDSfhilxC2LuGz2+56f6UOzj\n"
+            + "ggwf5k4LFTQvkDUZumwPjoeC2hqQO3Q/9PW39C6GnvsCr5L0MRdO3PbVJM7lJaOk\n"
+            + "MbGwgysErWgiZXKlxMpIvffIsLC4BAxnjXaCy6zHuBcPMPaRMs7sDRBzeuTV2wnX\n"
+            + "Sd+IXZgdpd1hF7VkuXenzwOqvBGS66C3ILW0ZTFaOtgrloIkTvtYEcJFWvxqWl2F\n"
+            + "+JQ5V6eu2aJ3HIGyr9L1R8MUA6EAEQEAAYkBHwQYAQIACQUCViarcQIbDAAKCRD6\n"
+            + "0qviPWxS0M0PB/9Rbk4/pNW67+uE1lwtaIG7uFiMbJqu8jK6MkD8GdayflroWEZA\n"
+            + "x0Xow9HL8UaRfeRPTZMrDRpjl+fJIXT5qnlB0FPmzSXAKr3piC8migBcbp5m6hWh\n"
+            + "c3ScAqWOeMt9j0TTWHh4hKS8Q+lK392ht65cI/kpFhxm9EEaXmajplNL/2G3PVrl\n"
+            + "fFUgCdOn2DYdVSgJsfBhkcoiy17G3vqtb+We6ulhziae4SIrkUSqdYmRjiFyvqZz\n"
+            + "tmMEoF6CQNCUb1NK0TsSDeIdDacYjUwyq0Qj6TaXrWcbC3kW0GtWoFTNIiX4q9bN\n"
+            + "+B6paw/s8P7XCWznTBRdlFWWgrhcpzQ8fefC\n"
+            + "=CHer\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBFYmq3EBCAC9ssY6QhFsnZqKEPlQrx8Zomblj8qV93/B448isOT2L6OVY7UC\n"
+            + "kKPj6afW5UDkYeyZSmLZfTrpePcbAB8FB3uvd/AS9mHC+6zuBlwlkl9xIXlwUXQP\n"
+            + "KER4LKYNTP21AM+/vTJm4+u26tlZECIZlez31KEeqM30EAm+/pO8VkEp8+1ImfLv\n"
+            + "otndIjMoq9gxJvn6KZeexJT2eKCsSa20vVsmAhuFjLZitU3lEjIROfDiyHUZ2cZ+\n"
+            + "qynfppJCKlHJRu/T9L/yxDFVUFDFSajNzSfjG1g3FEveDITyAhRetVfZbhyJptnV\n"
+            + "jfiHSQkLamPsBmMoKfP+aO5SfsTHTJvxgLUdABEBAAEAB/9AdCtFJSidcolNKwpC\n"
+            + "/1V+VL9IdYxcWx02CDccjuUkvrgCrL+WcQW2jS/hZMChOKJ2zR78DcBEDr1LF8Xy\n"
+            + "ZAIC8yoHj15VLUUrFM8fVvYFzt1fq9VWxxRIjscW0teLNgzgdYzYB84RtwcFa2Vi\n"
+            + "sx2ycTUTYUClEgP1uLMCtX3rnibJh4vR+lVgnDtKSoh4CLAlW6grAAVdw5sSuV7Q\n"
+            + "i9EJcPezGw1RvBU5PooqNDG6kyw/QqsAS4q3WP4uVJKK1e7S9oqXFEN8k/zfllI0\n"
+            + "SSkoyP2flzz71rJF/wQMfJ8uf/CelKXd+gPO4FbCWiZSTLe20JR23qiOyvZkfCwg\n"
+            + "eFmzBADIJUzspDrg5yaqE+HMc8U3O9G9FHoDSweZTbhiq3aK0BqMAn34u0ps6chy\n"
+            + "VMO6aPWVzgcSHNfTlzpjuN9lwDoimYBH5vZa1HlCHt5eeqTORixkxSerOmILabTi\n"
+            + "QWq5JPdJwYZiSvK45G5k3G37RTd6/QyhTlRYXj59RXYajrYngwQA8qMZRkRYcTop\n"
+            + "aG+5M0x44k6NgIyH7Ap+2vRPpDdUlHs+z+6iRvoutkSfKHeZUYBQjgt+tScfn1hM\n"
+            + "BRB+x146ecmSVh/Dh8yu6uCrhitFlKpyJqNptZo5o+sH41zjefpMd/bc8rtHTw3n\n"
+            + "GiFl57ZbXbze2O8UimUVgRI2DtOebt8EAJHM/8vZahzF0chzL4sNVAb8FcNYxAyn\n"
+            + "95VpnWeAtKX7f0bqUvIN4BNV++o6JdMNvBoYEQpKeQIda7QM59hNiS8f/bxkRikF\n"
+            + "OiHB5YGy2zRX5T1G5rVQ0YqrOu959eEwdGZmOQ8GOqq5B/NoHXUtotV6SGE3R+Tl\n"
+            + "grlV4U5/PT0fM3KJATcEHwECACEFAlYmq6wXDIABBK6n7S+CETPlsSjR7QYl3EYy\n"
+            + "iowCBwAACgkQ+tKr4j1sUtDYFgf+Ob63ulm6/xapl1vwWIFr0IV+FxIqNhqsweMH\n"
+            + "DsbFZkkx3lVHgMKvhP9lIAZEoFEpDa5euSb0fQ08OnRz9QoIe1AwUOepOTsHrebB\n"
+            + "JHV3tbSppE9dhDVttq2fOQQM6PR76tfpqKpVEyj+rq4w1QWXpQytRk9I0Hk9VFBY\n"
+            + "+dDsrXWbDe+DR7nJyolvIW8RHoS3INRcYGjxrn/hsrxUE0ExMAjLoZ4al5Izlvty\n"
+            + "lCTv+Ph1e6AIkBV+EU1Q6At26ggozi75VwWOTcqb9jE60aLV0uoJtNf/QuIJED2a\n"
+            + "BCnI1XID5KbjwswtSRtys38XdIdfVqj/mWbX2vwGK7XOJCcuALQiVGVzdHVzZXIg\n"
+            + "U2V2ZW4gPHRlc3Q3QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCViarcQIbAwYLCQgH\n"
+            + "AwIGFQgCCQoLBBYCAwECHgECF4AACgkQ+tKr4j1sUtC+NAf/QrtoGwZTZT+ubgj6\n"
+            + "7649roGcVNhAmqDTfinB0PRZ8zcz9eBWHsRGOdH1lWIIxV6P4reRbl3YM1pe7m9W\n"
+            + "lwPovqONbCIkcHAshyampCsvRN2gKvgXgUpCcFtwfV828/wD+EsMcHeTcRE+8QUi\n"
+            + "jUCRW4riz+/vJlgcCjBfSviiTid27/ABzQcjUWQCmQB+HXwkNO66Lnd2QqGeQOnt\n"
+            + "ed+b8SUBliMHtJ6K/E4P292FWJZLHVWG209mjmFLImsRhonm72Gxc1Q3zkPGAuoK\n"
+            + "ZLW/yfek3WVVj9BNhe2ChxqRCAcYQbROXprfGk2Sf2KGfIKvzSYcLFdPJRQEZ/KB\n"
+            + "xx6UH50DmARWJqtxAQgArHBdvocZX1CN6kAPJZbsYzFzYlVnulkzax1/zCuCdCpX\n"
+            + "YbWtz2ACDBNfiO05jS9IpPW8GrT1ldLeqlgeL9bB+3QqRpf9rt7q8ppsNJ+GKXEL\n"
+            + "Yu4bPb7np/pQ7OOCDB/mTgsVNC+QNRm6bA+Oh4LaGpA7dD/09bf0Loae+wKvkvQx\n"
+            + "F07c9tUkzuUlo6QxsbCDKwStaCJlcqXEyki998iwsLgEDGeNdoLLrMe4Fw8w9pEy\n"
+            + "zuwNEHN65NXbCddJ34hdmB2l3WEXtWS5d6fPA6q8EZLroLcgtbRlMVo62CuWgiRO\n"
+            + "+1gRwkVa/GpaXYX4lDlXp67ZonccgbKv0vVHwxQDoQARAQABAAf5Ae8xa1mPns1E\n"
+            + "B5yCrvzDl79Dw0F1rED46IWIW/ghpVTzmFHV6ngcvcRFM5TZquxHXSuxLv7YVxRq\n"
+            + "UVszXNJaEwyJYYkDRwAS1E2IKN+gknwapm2eWkchySAajUsQt+XEYHFpDPtQRlA3\n"
+            + "Z6PrCOPJDOLmT9Zcf0R6KurGrhvTGrZkKU6ZCFqZWETfZy5cPfq2qxtw3YEUI+eT\n"
+            + "09AgMmPJ9nDPI3cA69tvy/phVFgpglsS76qgd6uFJ5kcDoIB+YepmJoHnzJeowYt\n"
+            + "lvnmmyGqmVS/KCgvILaD0c73Dp2X0BN64hSZHa3nUU67WbKJzo2OXr+yr0hvofcf\n"
+            + "8vhKJe5+2wQAy+rRKSAOPaFiKT8ZenRucx1pTJLoB8JdediOdR4dtXB2Z59Ze7N3\n"
+            + "sedfrJn1ao+jJEpnKeudlDq7oa9THd7ZojN4gBF/lz0duzfertuQ/MrHaTPeK8YI\n"
+            + "dEPg3SgYVOLDBptaKmo0xr2f6aslGLPHgxCgzOcLuuUNGKJSigZvhdMEANh7VKsX\n"
+            + "nb5shZh+KRET84us/uu74q4iIfc8Q10oXuN9+IPlqfAIclo4uMhvo5rtI9ApFtxs\n"
+            + "oZzqqc+gt+OAbn/fHeb61eT36BA+r61Ka+erxkpWU5r1BPVIqq+biTY/HHchqroJ\n"
+            + "aw81qWudO9h5a0yP1alDiBSwhZWIMCKzp6Q7A/472amrSzgs7u8ToQ/2THDxaMf3\n"
+            + "Se0HgMrIT1/+5es2CWiEoZGSZTXlimDYXJULu/DFC7ia7kXOLrMsO85bEi7SHagA\n"
+            + "eO+mAw3xP3OuNkZDt9x4qtal28fNIz22DH5qg2wtsGdCWXz5C6OdcrtQ736kNxa2\n"
+            + "5QemZ/0VWxHPnvXz40RtiQEfBBgBAgAJBQJWJqtxAhsMAAoJEPrSq+I9bFLQzQ8H\n"
+            + "/1FuTj+k1brv64TWXC1ogbu4WIxsmq7yMroyQPwZ1rJ+WuhYRkDHRejD0cvxRpF9\n"
+            + "5E9NkysNGmOX58khdPmqeUHQU+bNJcAqvemILyaKAFxunmbqFaFzdJwCpY54y32P\n"
+            + "RNNYeHiEpLxD6Urf3aG3rlwj+SkWHGb0QRpeZqOmU0v/Ybc9WuV8VSAJ06fYNh1V\n"
+            + "KAmx8GGRyiLLXsbe+q1v5Z7q6WHOJp7hIiuRRKp1iZGOIXK+pnO2YwSgXoJA0JRv\n"
+            + "U0rROxIN4h0NpxiNTDKrRCPpNpetZxsLeRbQa1agVM0iJfir1s34HqlrD+zw/tcJ\n"
+            + "bOdMFF2UVZaCuFynNDx958I=\n"
+            + "=aoJv\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * Key revoked by an expired key, after that key's expiration.
+   *
+   * <p>Revoked by {@link #expiredKey()}.
+   *
+   * <pre>
+   * pub   2048R/78BF7D7E 2005-08-01 [revoked: 2015-10-20]
+   *       Key fingerprint = 916F AB22 5BE7 7585 F59A  994C 001A DF8B 78BF 7D7E
+   * uid                  Testuser Eight &lt;test8@example.com&gt;
+   * </pre>
+   */
+  public static TestKey keyRevokedByExpiredKeyAfterExpiration() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBELuRwABCAC56yhFKybBtuKT4nyb7RdLE98pZR54aGjcDcKH3VKVyBF8Z4Kx\n"
+            + "ptd7Sre0mLPCQiNWVOmCT+JG7GKVE6YeFmyXDUnhX9w4+HAeDEh23S4u9JvwWaF+\n"
+            + "wlJ6jLq/oe5gdT1F6Y2yqNpQ6CztOw52Ko9KSYz7/1zBMPcCkl/4k15ee4iebVdq\n"
+            + "c7qT5Qt49Poiozh0DI5prPQ624uckHkz2mXshjWQVuHWwrkIkCJZ2I/KQN2kBjKw\n"
+            + "/ALxumaWmiB9lQ0nIwLuGzHCh0Xg5RxuCrK8fJp47Aza3ikVuYlNzSxhJVav3OtK\n"
+            + "gftBihQXUlY3Uy/4QTCeH/BdVs5OALtXL3VhABEBAAGJAS0EIAECABcFAlYmr4kQ\n"
+            + "HQN0ZXN0OCBub3QgdXNlZAAKCRA87HgbF94azQJ5B/0TeQk7TSChNp+NqCKPTuw0\n"
+            + "wpflDyc+5ru/Gcs4r358cWzgiLUb3M0Q1+M8CF13BFQdrxT05vjheI9o5PCn3b//\n"
+            + "AHV8m+QFSnRi2J3QslbvuOqOnipz7vc7lyZ7q1sWNC33YN+ZcGZiMuu5HJi9iadf\n"
+            + "ZL7AdInpUb4Zb+XKphbMokDcN3yw7rqSMMcx+rKytUAqUnt9qvaSLrIH/zeazxlp\n"
+            + "YG4jaN53WPfLCcGG+Rw56mW+eCQD2rmzaNHCw8Qr+19sokXLB7OML+rd1wNwZT4q\n"
+            + "stWnL+nOj8ZkbFV0w3zClDYaARr7H+vTckwVStyDVRbnpRitSAtJwbRDzZBaS4Vx\n"
+            + "iQE3BB8BAgAhBQJC7lUQFwyAAR2e63ndOLBJk52crzzseBsX3hrNAgcAAAoJEAAa\n"
+            + "34t4v31+AS4H/0x3Y9E3q9DR5FCuYTXG4BHyrALo2WKoP0CfUWL98Fw9Txl0hF+9\n"
+            + "5wriNlnmd2zvM0quHs78x4/xehQO88cw0lqPx3RARq/ju5/VbOjoNlcHvfGYZiEd\n"
+            + "yWOwHu7O8sZrenFDjeDglD6NArrjncOcC51XIPSSTLvVQpSauQ1FS4tan5Q4aWMb\n"
+            + "s4DzE+Vqu2xMkO/X9toYAZKzyWP29OckpouMbt3GUnS6/o0A8Z7jVX+XOIk3XolP\n"
+            + "Li9tzTQB12Xl23mgFvearDoguR2Bu2SbmTJtdiXz8L3S54kGvxVqak5uOP2dagzU\n"
+            + "vBiqR4SVoAdGoXt6TI6mpA+qdYmPMG8v21S0IlRlc3R1c2VyIEVpZ2h0IDx0ZXN0\n"
+            + "OEBleGFtcGxlLmNvbT6JATgEEwECACIFAkLuRwACGwMGCwkIBwMCBhUIAgkKCwQW\n"
+            + "AgMBAh4BAheAAAoJEAAa34t4v31+8/sIAIuqd+dU8k9c5VQ12k7IfZGGYQHF2Mk/\n"
+            + "8FNuP7hFP/VOXBK3QIxIfGEOHbDX6uIxudYMaDmn2UJbdIqJd8NuQByh1gqXdX/x\n"
+            + "nteUa+4e7U6uTjkp/Ij5UzRed8suINA3NzVOy6qwCu3DTOXIZcjiOZtOA5GTqG6Z\n"
+            + "naDP0hwDssJp+LXIYTJgsvneJQFGSdQhhJSv19oV0JPSbb6Zc7gEIHtPcaJHjuZQ\n"
+            + "Ev+TRcRrI9HPTF0MvgOYgIDo2sbcSFV+8moKsHMC+j1Hmuuqgm/1yKGIZrt0V75s\n"
+            + "D9HYu0tiS3+Wlsry3y1hg/2XBQbwgh6sT/jWkpWar7+uzNxO5GdFYrC5AQ0EQu5H\n"
+            + "AAEIALPFTedbfyK+9B35Uo9cPsmFa3mT3qp/bAQtnOjiTTTiIO3tu0ALnaBjf6On\n"
+            + "fAV1HmGz6hRMRK4LGyHkNTaGDNNPoXO7+t9DWycSHmsCL5d5zp7VevQE8MPR8zHK\n"
+            + "Il2YQlCzdy5TWSUhunKd4guDNZ9GiOS6NQ9feYZ9DQ1kzC8nnu7jLkR2zNT02sYU\n"
+            + "kuOCZUktQhVNszUlavdIFjvToZo3RPcdb/E3kTTy2R9xi89AXjWZf3lSAZe3igkL\n"
+            + "jhwsd+u3RRx0ptOJym7zYl5ZdUZk4QrS7FPI6zEBpjawbS4/r6uEW89P3QAkanDI\n"
+            + "ridIAZP8awLZU3uSPtMwPIJpao0AEQEAAYkBHwQYAQIACQUCQu5HAAIbDAAKCRAA\n"
+            + "Gt+LeL99fqpHB/wOXhdMNtgeVW38bLk8YhcEB23FW6fDjFjBJb9m/yqRTh5CIeG2\n"
+            + "bm29ofT4PTamPb8Gt+YuDLnQQ3K2jURakxNDcYwiurvR/oHVdxsBRU7Px7UPeZk3\n"
+            + "BG5VnIJRT198dF7MWFJ+x5wHbNXwM8DDvUwTjXLH/TlGl1XIheSTHCYd9Pra4ejE\n"
+            + "ockkrDaZlPCQdTwY+P7K2ieb5tsqNpJkQeBrglF2bemY/CtQHnM9qwa6ZJqkyYNR\n"
+            + "F1nkSYn36BPuNpytYw1CaQV9GbePugPHtshECLwA160QzqISQUcJlKXttUqUGnoO\n"
+            + "0d0PyzZT3676mQwmFoebMR9vACAeHjvDxD4F\n"
+            + "=ihWb\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBELuRwABCAC56yhFKybBtuKT4nyb7RdLE98pZR54aGjcDcKH3VKVyBF8Z4Kx\n"
+            + "ptd7Sre0mLPCQiNWVOmCT+JG7GKVE6YeFmyXDUnhX9w4+HAeDEh23S4u9JvwWaF+\n"
+            + "wlJ6jLq/oe5gdT1F6Y2yqNpQ6CztOw52Ko9KSYz7/1zBMPcCkl/4k15ee4iebVdq\n"
+            + "c7qT5Qt49Poiozh0DI5prPQ624uckHkz2mXshjWQVuHWwrkIkCJZ2I/KQN2kBjKw\n"
+            + "/ALxumaWmiB9lQ0nIwLuGzHCh0Xg5RxuCrK8fJp47Aza3ikVuYlNzSxhJVav3OtK\n"
+            + "gftBihQXUlY3Uy/4QTCeH/BdVs5OALtXL3VhABEBAAEAB/wLr88oGuxsoqIHRQZL\n"
+            + "eGm9jc4aQGmcDMcjpwdGilhrwyfrO6f84hWbQdD+rJcnI8hsH7oOd5ZMGkWfpJyt\n"
+            + "eUAh9iNB5ChYGfDVSLUg6KojqDtprj6vNMihvLkr/OI6xL/hZksikwfnLFMPpgXU\n"
+            + "knwPocQ3nn+egsUSL7CR8/SLiIm4MC0brer6jhDxB5LKweExNlfTe4c0MDeYTsWt\n"
+            + "0WGzNPlvRZQXRotJzqemt3wdNZXUnCKR0n7pSQ8EhZr2O6NXr+mUgp6PIOE/3un2\n"
+            + "YGiBEf5uy3qEFe7FjEGIHz+Z3ySRdUDfHOk82TKAzynoJIxRUvLIYVNw4eFB3l5U\n"
+            + "s1w5BADUzfciG7RVLa8UFKJfqQ/5M06QmdS1v1/hMQXg38+3vKe8RgfSSnMJ08Sc\n"
+            + "eAEsmugwpNXAxgRKHcmWzN3NMBHhE3KiyiogWaMGqmSo6swFpu0+dwMvZSxMlfD+\n"
+            + "ka/BWt8YsUdrqW06ow39aTgCV+icbNRV81C7NKe7u0X1JDx2CQQA36gbdo62h/Wd\n"
+            + "gJI8kdz/se3xrt8x6RoWvOnWPNmsZR5XkDqAMTL1dWiEEA/dQTphMcgAe9z3WaP+\n"
+            + "F1TPAfounbiurGCcS3kxJ5tY7ojyU7nYz4DA/V2OU0C/LUoLXhttG5HM+m/i3qn4\n"
+            + "K9bBoWIQY1ijliS7cTSwNqd6IHaQGpkEAMnp5GwSGhY+kUuLw06hmH4xnsuf6agz\n"
+            + "AfhbPylB2nf/ZaX6dt6/mFEAkvQNahcoWEskfS3LGCD8jHm8PvF8K0mciXPDweq2\n"
+            + "gW3/irE0RXNwn3Oa222VSvcgUlocBm9InkfvpFXh20OYFe3dFH7uYkwUqIHJeXjw\n"
+            + "TjpXUX/vC5QJQOyJATcEHwECACEFAkLuVRAXDIABHZ7red04sEmTnZyvPOx4Gxfe\n"
+            + "Gs0CBwAACgkQABrfi3i/fX4BLgf/THdj0Ter0NHkUK5hNcbgEfKsAujZYqg/QJ9R\n"
+            + "Yv3wXD1PGXSEX73nCuI2WeZ3bO8zSq4ezvzHj/F6FA7zxzDSWo/HdEBGr+O7n9Vs\n"
+            + "6Og2Vwe98ZhmIR3JY7Ae7s7yxmt6cUON4OCUPo0CuuOdw5wLnVcg9JJMu9VClJq5\n"
+            + "DUVLi1qflDhpYxuzgPMT5Wq7bEyQ79f22hgBkrPJY/b05ySmi4xu3cZSdLr+jQDx\n"
+            + "nuNVf5c4iTdeiU8uL23NNAHXZeXbeaAW95qsOiC5HYG7ZJuZMm12JfPwvdLniQa/\n"
+            + "FWpqTm44/Z1qDNS8GKpHhJWgB0ahe3pMjqakD6p1iY8wby/bVLQiVGVzdHVzZXIg\n"
+            + "RWlnaHQgPHRlc3Q4QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCQu5HAAIbAwYLCQgH\n"
+            + "AwIGFQgCCQoLBBYCAwECHgECF4AACgkQABrfi3i/fX7z+wgAi6p351TyT1zlVDXa\n"
+            + "Tsh9kYZhAcXYyT/wU24/uEU/9U5cErdAjEh8YQ4dsNfq4jG51gxoOafZQlt0iol3\n"
+            + "w25AHKHWCpd1f/Ge15Rr7h7tTq5OOSn8iPlTNF53yy4g0Dc3NU7LqrAK7cNM5chl\n"
+            + "yOI5m04DkZOobpmdoM/SHAOywmn4tchhMmCy+d4lAUZJ1CGElK/X2hXQk9Jtvplz\n"
+            + "uAQge09xokeO5lAS/5NFxGsj0c9MXQy+A5iAgOjaxtxIVX7yagqwcwL6PUea66qC\n"
+            + "b/XIoYhmu3RXvmwP0di7S2JLf5aWyvLfLWGD/ZcFBvCCHqxP+NaSlZqvv67M3E7k\n"
+            + "Z0VisJ0DmARC7kcAAQgAs8VN51t/Ir70HflSj1w+yYVreZPeqn9sBC2c6OJNNOIg\n"
+            + "7e27QAudoGN/o6d8BXUeYbPqFExErgsbIeQ1NoYM00+hc7v630NbJxIeawIvl3nO\n"
+            + "ntV69ATww9HzMcoiXZhCULN3LlNZJSG6cp3iC4M1n0aI5Lo1D195hn0NDWTMLyee\n"
+            + "7uMuRHbM1PTaxhSS44JlSS1CFU2zNSVq90gWO9OhmjdE9x1v8TeRNPLZH3GLz0Be\n"
+            + "NZl/eVIBl7eKCQuOHCx367dFHHSm04nKbvNiXll1RmThCtLsU8jrMQGmNrBtLj+v\n"
+            + "q4Rbz0/dACRqcMiuJ0gBk/xrAtlTe5I+0zA8gmlqjQARAQABAAf+JNVkZOcGYaQm\n"
+            + "eI3BMMaBxuCjaMG3ec+p3iFKaR0VHKTIgneXSkQXA+nfGTUT4DpjAznN2GLYH6D+\n"
+            + "6i7MCGPm9NT4C7KUcHJoltTLjrlf7vVyNHEhRCZO/pBh9+2mpO6xh799x+wj88u5\n"
+            + "XAqlah50OjJFkjfk70VsrPWqWvgwLejkaQpGbE+pdL+vjy+ol5FHzidzmJvsXDR1\n"
+            + "I1as0vBu5g2XPpexyVanmHJglZdZX07OPYQBhxQKuPXT/2/IRnXsXEpitk4IyJT0\n"
+            + "U5D/iedEUldhBByep1lBcJnAap0CP7iuu2CYhRp6V2wVvdweNPng5Eo7f7LNyjnX\n"
+            + "UMAeaeCjAQQA1A0iKtg3Grxc9+lpFl1znc2/kO3p6ixM13uUvci+yGFNJJninnxo\n"
+            + "99KXEzqqVD0zerjiyyegQmzpITE/+hFIOJZInxEH08WQwZstV/KYeRSJkXf0Um48\n"
+            + "E+Zrh8fpJVW1w3ZCw9Ee2yE6fEhAA4w66+50pM+vBXanWOrG1HDrkxEEANkHc2Rz\n"
+            + "YJsO4v63xo/7/njLSQ31miOglb99ACKBA0Yl/jvj2KqLcomKILqvK3DKP+BHNq86\n"
+            + "LUBUglyKjKuj0wkSWT0tCnfgLzysUpowcoyFhJ36KzAz8hjqIn3TQpMF21HvkZdG\n"
+            + "Mtkcyhu5UDvbfOuWOBaKIeNQWCWv1rNzMme9A/9zU1+esEhKwGWEqa3/B/Te/xQh\n"
+            + "alk180n74sTZid6lXD8o8cEei0CUq7zBSV0P8v6kk8PP9/XyLRl3Rqa95fESUWrL\n"
+            + "xD6TBY1JlHBZS+N6rN/7Ilf5EXSELmnbDFsVxkNGp4elKxajvZxC6uEWYBu62AYy\n"
+            + "wS0dj8mZR3faCEps90YXiQEfBBgBAgAJBQJC7kcAAhsMAAoJEAAa34t4v31+qkcH\n"
+            + "/A5eF0w22B5VbfxsuTxiFwQHbcVbp8OMWMElv2b/KpFOHkIh4bZubb2h9Pg9NqY9\n"
+            + "vwa35i4MudBDcraNRFqTE0NxjCK6u9H+gdV3GwFFTs/HtQ95mTcEblWcglFPX3x0\n"
+            + "XsxYUn7HnAds1fAzwMO9TBONcsf9OUaXVciF5JMcJh30+trh6MShySSsNpmU8JB1\n"
+            + "PBj4/sraJ5vm2yo2kmRB4GuCUXZt6Zj8K1Aecz2rBrpkmqTJg1EXWeRJiffoE+42\n"
+            + "nK1jDUJpBX0Zt4+6A8e2yEQIvADXrRDOohJBRwmUpe21SpQaeg7R3Q/LNlPfrvqZ\n"
+            + "DCYWh5sxH28AIB4eO8PEPgU=\n"
+            + "=cSfw\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * Key revoked by an expired key, before that key's expiration.
+   *
+   * <p>Revoked by {@link #expiredKey()}.
+   *
+   * <pre>
+   * pub   2048R/C43BF2E1 2005-08-01 [revoked: 2005-08-01]
+   *       Key fingerprint = 916D 6AD6 36A5 CBA6 B5A6  7274 6040 8661 C43B F2E1
+   * uid                  Testuser Nine &lt;test9@example.com&gt;
+   * </pre>
+   */
+  public static TestKey keyRevokedByExpiredKeyBeforeExpiration() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBELuRwABCADnf2z5dqp3BMFlpd6iUs5dhROrslfzswak1LmbGirK2IPIl4NX\n"
+            + "arAi76xXK9BcF/Cqcj/X/WqFKBd/qMGxwdvwbSN6PVBP6T1jvuVgrPTjd4x5xPUD\n"
+            + "xZ5VPy9hgQXs+1mugTkHYVTU8GI1eGpZ8Oj3PJIgVyqGxGkjWmcz5APbVIRan6L1\n"
+            + "482bZTidH9Nd9YnYlXNgiJcaOPAVBwO/j/myocQCIohvIo4IT8vc/ODhRgfwA0gD\n"
+            + "GVK+tXwT4f4x3qjG/YRpOOZZjBS09B/gJ9QfEnR6WNxg/Tm3T0uipoISOhR+cP/V\n"
+            + "e5o/73SM+w+WlILk/xpbbOfyCxD4Q3lb8EZFABEBAAGJAS0EIAECABcFAkLuYyAQ\n"
+            + "HQN0ZXN0OSBub3QgdXNlZAAKCRA87HgbF94azV2BB/9Rc1j3XOxKbDyUFAORAGnE\n"
+            + "ezQtpOmQhaSUhFC35GFOdTg4eX53FTFSXLJQleTVzvE+eVkQI5tvUZ+SqHoyjnhU\n"
+            + "DpWlmfRUQy4GTUjUTkpFOK07TVTjhUQwaAxN13UZgByopVKc7hLf+uh1xkRJIqAJ\n"
+            + "Tx6LIFZiSIGwStDO6TJlhl1e8h45J3rAV4N+DsGpMy9S4uYOU7erJDupdXK739/l\n"
+            + "VBsP2SeT85iuAv+4A9Jq3+iq+cjK9q3QZCw1O6iI2v3seAWCI6HH3tVw4THr+M6T\n"
+            + "EdTGmyESjdAl+f7/uK0QNfqIMpvUf+AvMakrLi7WOeDs8mpUIjonpeQVLfz6I0Zo\n"
+            + "iQE3BB8BAgAhBQJC7lUQFwyAAR2e63ndOLBJk52crzzseBsX3hrNAgcAAAoJEGBA\n"
+            + "hmHEO/LhHjUH/R/7+iNBLAfKYbpprkWy/8eXVEJhxfh6DI/ppsKLIA+687gX74R9\n"
+            + "6CM5k6fZDjeND26ZEA0rDZmYrbnGUfsu55aeM0/+jiSOZJ2uTlrLXiHMurbNY0pT\n"
+            + "xv215muhumPBzuL1jsAK2Kc/4oE7Z46jaStsPCvDOcx9PW76wR8/uCPvHVz5H/A7\n"
+            + "3erXAloC43jupXwZB32VZq8L0kZNVfuEsjHUcu3GUoZdGfTb4/Qq5a1FK+CGhwWC\n"
+            + "OwpUWZEIUImwUv4FNE4iNFYEHaHLU9fotmIxIkH8TC4NcO+GvkEyMyJ6NVkBBDP2\n"
+            + "EarncWAJxDBlx1CO4ET+/ULvzDnAcYuTc6G0IVRlc3R1c2VyIE5pbmUgPHRlc3Q5\n"
+            + "QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCQu5HAAIbAwYLCQgHAwIGFQgCCQoLBBYC\n"
+            + "AwECHgECF4AACgkQYECGYcQ78uG78ggA1TjeOZtaXjXNG8Bx2sl4W+ypylWWB6yc\n"
+            + "IeR0suLhVlisZ33yOtV4MsvZw0TJNyYmFXiskPTyOcP8RJjS+a41IHc33i13MUnN\n"
+            + "RI5cqhqsWRhf9chlm7XqXtqv57IjojG9vgSUeZdXSTMdHIDDHAjJ/ryBXflzprSw\n"
+            + "2Sab8OXjLkyo9z6ZytFyfXSc8TNiWU6Duollh/bWIsgPETIe2wGn8LcFiVMfPpsI\n"
+            + "RhkphOdTJb+W/zQwLHUcS22A4xsJtBxIXTH/QSG3lAaw8IRbl25EIpaEAF+gExCr\n"
+            + "QM0haAVMmGgYYWpMHXrDhB7ff3kAiqD2qmhSySA6NLmTO+6qGPYJg7kBDQRC7kcA\n"
+            + "AQgA2wqE3DypQhTcYl26dXc9DZzABRQa6KFRqQbhmUBz95cQpAamQjrwOyl2fg84\n"
+            + "b9o9t+DuZcdLzLF/gPVSznOcNUV9mJNdLAxBPPOMUrP/+Snb83FkNpCscrXhIqSf\n"
+            + "BU5D+FOb3bEI2WTJ7lLe8oCrWPE3JIDVCrpAWgZk9puAk1Z7ZFaHsS6ezsZP0YIM\n"
+            + "qTWdoX0zHMPMnr9GG08c0mniXtvfcgtOCeIRU4WZws28sGYCoLeQXsHVDal+gcLp\n"
+            + "1enPh6dfEWBJuhhBBajzm53fzV2a7khEdffggVVylHPLpvms2nIqoearDQtVNpSK\n"
+            + "uhNiykJSMIUn/Y6g5LMySmL+MwARAQABiQEfBBgBAgAJBQJC7kcAAhsMAAoJEGBA\n"
+            + "hmHEO/LhdwcH/0wAxT1NGaR2boMjpTouVUcnEcEzHc0dSwuu+06mLRggSdAfBC8C\n"
+            + "9fdlAYHQ5tp1sRuPwLfQZjo8wLxJ+wLASnIPLaGrtpEHkIKvDwHqwkOXvXeGD/Bh\n"
+            + "40NbJUa7Ec3Jpo+FPFlM8hDsUyHf8IhUAdRd4d+znOVEaZ6S7c1RrtoVTUqzi59n\n"
+            + "nC6ZewL/Jp+znKZlMTM3X1onAGhd+/XdrS52LM8pE3xRjbTLTYWcjnjyLbm0yoO8\n"
+            + "G3yCfIibAaII4a/jGON2X9ZUwaFNIqJ4iIc8Nme86rD/flXsu6Zv+NXVQWylrIG/\n"
+            + "REW68wsnWjwTtrPG8bqo6cCsOzqGYVt81eU=\n"
+            + "=FnZg\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBELuRwABCADnf2z5dqp3BMFlpd6iUs5dhROrslfzswak1LmbGirK2IPIl4NX\n"
+            + "arAi76xXK9BcF/Cqcj/X/WqFKBd/qMGxwdvwbSN6PVBP6T1jvuVgrPTjd4x5xPUD\n"
+            + "xZ5VPy9hgQXs+1mugTkHYVTU8GI1eGpZ8Oj3PJIgVyqGxGkjWmcz5APbVIRan6L1\n"
+            + "482bZTidH9Nd9YnYlXNgiJcaOPAVBwO/j/myocQCIohvIo4IT8vc/ODhRgfwA0gD\n"
+            + "GVK+tXwT4f4x3qjG/YRpOOZZjBS09B/gJ9QfEnR6WNxg/Tm3T0uipoISOhR+cP/V\n"
+            + "e5o/73SM+w+WlILk/xpbbOfyCxD4Q3lb8EZFABEBAAEAB/9GTcWLkUU9tf0B4LjX\n"
+            + "NSyk7ChIKXZadVEcN9pSR0Udq1mCTrk9kBID2iPNqWmyvjaBnQbUkoqJ+93/EAIa\n"
+            + "+NPRlWOD2SEN07ioFS5WCNCqUAEibfU2+woVu4WpJ+TjzoWy4F2wZxe7P3Gj6Xjq\n"
+            + "7aXih8uc9Lveh8GiUe8rrCCbt+BH1RzuV/khZw+2ZDPMCx7yfcfKobc3NWx75WLh\n"
+            + "pki512fawSC6eJHRI50ilPrqAmmhcccfwPji9P+oPj2S6wlhe5kp3R5yU85fWy3b\n"
+            + "C8AtLTfZIn4v6NAtBaurGEjRjzeNEGMJHxnRPWvFc4iD+xvPg6SNPJM/bbTE+yZ3\n"
+            + "16W1BADxjAQLMuGpemaVmOpZ3K02hcNjwniEK2QPp11BnfoQCIwegON+sUD/6AuZ\n"
+            + "S1vOVvS3//eGbPaMM45FK/SQAVHpC9IOL4Tql0C8B6csRhFL824yPfc3WDb4kayQ\n"
+            + "T5oLjlJ0W2r7tWcBcREEzZT6gNi4KI7C4oFF6tU9lsQJuQyAbwQA9Vl6VW/7oG0W\n"
+            + "CC+lcHJc+4rxUB3yak7d4mEccTNb+crOBRH/7dKZOe7A6Fz+ra++MmucDUzsAx0K\n"
+            + "MGT9Xoi5+CBBaNr+Y2lB9fF20N7eRNzQ3Xrz2OPl4cmU4gfECTZ1vZaKlmB+Vt8C\n"
+            + "E/nn49QGRI+BNBOdW+2aEpPoENczFosEAJXi5Cn2l0jOswDD7FU2PER1wfVY629i\n"
+            + "bICunudOSo64GKQslKkQWktc57DgdOQnH15qW1nVO7Z4H0GBxjSTRCu7Z7q08/qM\n"
+            + "ueWIvJ85HcFhOCl+vITOn0fZV0p8/IwsWz8G9h5bb2QgMAwDSdhnLuK/cXaGM09w\n"
+            + "n6k8O2rCvDtXRjqJATcEHwECACEFAkLuVRAXDIABHZ7red04sEmTnZyvPOx4Gxfe\n"
+            + "Gs0CBwAACgkQYECGYcQ78uEeNQf9H/v6I0EsB8phummuRbL/x5dUQmHF+HoMj+mm\n"
+            + "wosgD7rzuBfvhH3oIzmTp9kON40PbpkQDSsNmZitucZR+y7nlp4zT/6OJI5kna5O\n"
+            + "WsteIcy6ts1jSlPG/bXma6G6Y8HO4vWOwArYpz/igTtnjqNpK2w8K8M5zH09bvrB\n"
+            + "Hz+4I+8dXPkf8Dvd6tcCWgLjeO6lfBkHfZVmrwvSRk1V+4SyMdRy7cZShl0Z9Nvj\n"
+            + "9CrlrUUr4IaHBYI7ClRZkQhQibBS/gU0TiI0VgQdoctT1+i2YjEiQfxMLg1w74a+\n"
+            + "QTIzIno1WQEEM/YRqudxYAnEMGXHUI7gRP79Qu/MOcBxi5NzobQhVGVzdHVzZXIg\n"
+            + "TmluZSA8dGVzdDlAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJC7kcAAhsDBgsJCAcD\n"
+            + "AgYVCAIJCgsEFgIDAQIeAQIXgAAKCRBgQIZhxDvy4bvyCADVON45m1peNc0bwHHa\n"
+            + "yXhb7KnKVZYHrJwh5HSy4uFWWKxnffI61Xgyy9nDRMk3JiYVeKyQ9PI5w/xEmNL5\n"
+            + "rjUgdzfeLXcxSc1EjlyqGqxZGF/1yGWbtepe2q/nsiOiMb2+BJR5l1dJMx0cgMMc\n"
+            + "CMn+vIFd+XOmtLDZJpvw5eMuTKj3PpnK0XJ9dJzxM2JZToO6iWWH9tYiyA8RMh7b\n"
+            + "AafwtwWJUx8+mwhGGSmE51Mlv5b/NDAsdRxLbYDjGwm0HEhdMf9BIbeUBrDwhFuX\n"
+            + "bkQiloQAX6ATEKtAzSFoBUyYaBhhakwdesOEHt9/eQCKoPaqaFLJIDo0uZM77qoY\n"
+            + "9gmDnQOYBELuRwABCADbCoTcPKlCFNxiXbp1dz0NnMAFFBrooVGpBuGZQHP3lxCk\n"
+            + "BqZCOvA7KXZ+Dzhv2j234O5lx0vMsX+A9VLOc5w1RX2Yk10sDEE884xSs//5Kdvz\n"
+            + "cWQ2kKxyteEipJ8FTkP4U5vdsQjZZMnuUt7ygKtY8TckgNUKukBaBmT2m4CTVntk\n"
+            + "VoexLp7Oxk/RggypNZ2hfTMcw8yev0YbTxzSaeJe299yC04J4hFThZnCzbywZgKg\n"
+            + "t5BewdUNqX6BwunV6c+Hp18RYEm6GEEFqPObnd/NXZruSER19+CBVXKUc8um+aza\n"
+            + "ciqh5qsNC1U2lIq6E2LKQlIwhSf9jqDkszJKYv4zABEBAAEAB/0c76POOw6aazUT\n"
+            + "TZHUnhQ+WHHJefbKuoeWI7w+dD7y+02NzaRoZW7XnJ+fAZW8Dlb5k/O1FayUIEgE\n"
+            + "GjnT336dpE4g5NQkfdifG7Fy5NKGRkWx6viJI3g/OHsYX3+ebNDFMmO0gq7067/9\n"
+            + "WuHsTpvUMRwkF1zi1j4AETjZ7IBXdjuSCSu8OhEwr3d+WXibEmY5ec/d24l/APJx\n"
+            + "c3RMHw9PiDQeAKrByS6N10/yFgRpnouVx3wC7zFmhVewNV476Nyg34OvRoc+lCtk\n"
+            + "ixKdua6KuUJzGRWxgw+q2JD4goXxe0v2qU2KSU63gOYi0kg9tpwpn98lDNQykgmJ\n"
+            + "aQYdNIZJBADdlbkg9qbH1DREs7UF4jXN/SoYRbTh9639GfA4zkbfPmh/RmVIIEKd\n"
+            + "QN7qWK/Xy1bUS9vDzRfFgmoYGtqMmygOOFsVtfm8Y18lSXopN/3vhtai+dn+04Ef\n"
+            + "dl1irmGvm3p7y9Jh3s6uYTEJok0MywA7qBHvgSTVtc1PcZc6j6Bz1QQA/Q+nqyZY\n"
+            + "fLimt4KVYO1y6kSHgEqzggLTxyfGMW5RplTA0V1zCwjM6S+QWNqRxVNdB9Kkzn+S\n"
+            + "YDKHLYs8lXO2zvf8Yk9M7glgqvT4rJ51Zn2rc6lg1YUwFBXup5idTsuZwtqkvvKJ\n"
+            + "eS7L3cSBCqJMRjk47Y3V8zkrrN/HcYmyFecD/A+HPf4eSweUS025Bb+eCk4gTHbR\n"
+            + "uwmnKq7npk2XY4m0A/QdYF9dEWlpadsAr+ZwNQB3f21nQgKG0BudfL4FmpeW9RMt\n"
+            + "35aSIaV7RkxYOt5HEvjFRvLbeL1YYaj+D0dvz8SP1AUPvpWIVlQ03OjRlPyrPW50\n"
+            + "LoqyP8PTb6svnHvmQseJAR8EGAECAAkFAkLuRwACGwwACgkQYECGYcQ78uF3Bwf/\n"
+            + "TADFPU0ZpHZugyOlOi5VRycRwTMdzR1LC677TqYtGCBJ0B8ELwL192UBgdDm2nWx\n"
+            + "G4/At9BmOjzAvEn7AsBKcg8toau2kQeQgq8PAerCQ5e9d4YP8GHjQ1slRrsRzcmm\n"
+            + "j4U8WUzyEOxTId/wiFQB1F3h37Oc5URpnpLtzVGu2hVNSrOLn2ecLpl7Av8mn7Oc\n"
+            + "pmUxMzdfWicAaF379d2tLnYszykTfFGNtMtNhZyOePItubTKg7wbfIJ8iJsBogjh\n"
+            + "r+MY43Zf1lTBoU0ioniIhzw2Z7zqsP9+Vey7pm/41dVBbKWsgb9ERbrzCydaPBO2\n"
+            + "s8bxuqjpwKw7OoZhW3zV5Q==\n"
+            + "=JxsF\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+}
diff --git a/java/com/google/gerrit/gpg/testing/TestTrustKeys.java b/java/com/google/gerrit/gpg/testing/TestTrustKeys.java
new file mode 100644
index 0000000..0f83f71
--- /dev/null
+++ b/java/com/google/gerrit/gpg/testing/TestTrustKeys.java
@@ -0,0 +1,1039 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.gpg.testing;
+
+/**
+ * Test keys specific to web-of-trust checks.
+ *
+ * <p>In the following diagrams, the notation <code>M---N</code> indicates N trusts M, and an 'x'
+ * indicates the key is expired.
+ *
+ * <p>
+ *
+ * <pre>
+ *  A---Bx
+ *   \
+ *    \---C---D
+ *         \
+ *          \---Ex
+ *
+ *  D and E trust C to be a valid introducer of depth 2.
+ *
+ * F---G---F, in a cycle.
+ *
+ * H---I---J, but J is only trusted to length 1.
+ * </pre>
+ */
+public class TestTrustKeys {
+  /**
+   * pub 2048R/9FD0D396 2010-08-29 Key fingerprint = E401 17FC 4BF4 17BD 8F93 DEB1 D25A D07A 9FD0
+   * D396 uid Testuser A &lt;testa@example.com&gt; sub 2048R/F5C099DB 2010-08-29
+   */
+  public static TestKey keyA() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx6npoBCACp0vHePNPeLzm0HM35i70bRChyZXu/ARxOHZHNbh6hWJkq5saI\n"
+            + "zuzZoaqXAr3xZwHftTULlkgoJIt40x6VCT8EBnUHTkOqoHTsFnXg2kNuhFvmn0OX\n"
+            + "7RQFlR1SGoQG4fEy6t/GlOwEknpNSIMLkbDMP2FzEkLVWtlIe2hqqawVIgqzyO3k\n"
+            + "HaQxW8gWyyTx0qeSdSi4DyFZIzdyu/aZa7sj/MhO3DB3UwubW6yE+PMcAVrJD+0d\n"
+            + "EToMT7i8Erncc+xEzuXAoQUHaQfOXV4DG5qSgVpKaLxJ/ABWUri0eMPhj0cT4iDx\n"
+            + "eNTL7cZ4h72B1uJs8byDN74PHrypNiVE+IRHABEBAAG0HlRlc3R1c2VyIEEgPHRl\n"
+            + "c3RhQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTHqemgIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQ0lrQep/Q05ZxMAf+OoRzXWbGfv7kZb7xdrVyAUTAV4bU\n"
+            + "UvLoJZUIQ1ckPBcty2LUvY7l9efgp3c57nvTD6U98dVnsKfaW4PT0CRXlpl1IFyh\n"
+            + "kgbInFS5rO+cJMQn1KyC+FfiwyGNii630SwiHyWRG5+XQ6Iptx9JELwWUMCLJxFp\n"
+            + "B8DZQKlNnvdl+YUgEeQOkWTXfTSaBATdXHiZhskiumnTOGO24jSg8CrZc5O/n6fC\n"
+            + "CgEsAFWL7fnO0ii6EW1JH5btLHPxL9QI+5DJIypgOhGI1lqZW9KrpfmJ3w6N1Gek\n"
+            + "GBda98DmzxxxZ9iyq1cELAAiQMjkvws67cOs/hwXNn9YaK74dzhb49MLGIkBIAQQ\n"
+            + "AQIACgUCTHqf0QMFAXgACgkQV2Bph7AH1JCO/Qf+PBJqeWS7p32+K5r1cA7AeCB2\n"
+            + "pcHs78wLjnSxuimf0l+JItb9JQAKjzcdZTKVGkUivkq3zhsPCCtssgSav2wlG59F\n"
+            + "TaqtpGOxvGjc8TKWHW1TrPhV86wh0yUempKTMWfdZ0RAJVG3krAj60bzUsQNK41/\n"
+            + "0EZi4JI+sm/TRlwQcmEzdaGxhFSJqiJyaBWbPL8AQNA2iRyjMKNeGCrgapEl2IkW\n"
+            + "2ST+/yUPI/485LS0uU1+TLB+NhiJ6j5PoiVqYD+ul8WJ+cy1vvcp1GCQpbRv1yXY\n"
+            + "4GB1mw0JPIinVE1q+eKKQxN38zARPqyupiIuBQaqX9NCHCAdNtFc3kJQ7Nm83YkB\n"
+            + "IAQQAQIACgUCTHqkCwMFAXgACgkQZB8Rk9JP5GfGVQgArMBVQo3AD56p4g5A+DRA\n"
+            + "h0KdQMt4hs/dl+2GLAi+nK0wwuHrHvr9kcZNiQNMtu+YiwvxMpJ/JvXRwOp4wbEx\n"
+            + "6P6Uzp18R2sqbV4agnL5tXFZXfsa3OR2NLm56Ox1ReHnZtAcC6qa1nHqt9z2sTt1\n"
+            + "vh7IfK8GDU/3M3z4XBXPpmpZPAczqujuO/yshz84O6oc3noXfRUJRklbkhNC3WyS\n"
+            + "u5+3nupq4GwIYehQQpxBTD9xXj4hl3KfUnctg/MkgUGweEK3oZ22kObTLJttTP9t\n"
+            + "9q/hLkVyDtFhGorcsYbNZyupm3xhddzYovkReePwOO4WA7VeRqRdiYDU1UjIKvv4\n"
+            + "TrkBDQRMep6aAQgA3NQtBhS8yiEGN8rT4hGtuuprVd5jQVprLz4ImcI2+Gt71+CR\n"
+            + "gv/BZ0zzFp3VPjTGRusungJYkKKOGpEpERiqEG1X/ZyL7EzoyT+iKIMDsVJgmyDN\n"
+            + "cryHTejlKA8Z6GQ1hPlOIws22oLq5zQXxD9pzMDuabHl/s/bYlU5qXc7LhxdtrmT\n"
+            + "b2uBP9a+eneWKrz8OfgtS5m9DgqJ6Bjl0TvbeVJgKHX42pqzJlBTCn3hJjJosy8x\n"
+            + "4qTbqMraENnl9y+qynM7atoHX6TPWsD7vWtWvi+FA5OWGEe3rof8o/sJSj05DQUn\n"
+            + "i8mmSiCYW/tUklPPXOvPRP0GZ/GhBzIUtE3jBwARAQABiQEfBBgBAgAJBQJMep6a\n"
+            + "AhsMAAoJENJa0Hqf0NOW/gkH/jm9FL+S53NjrthdbNjffryhp7KhTmYAsRk3Hc3X\n"
+            + "4TBj3upecarJynpvsz5HlLi/OxDRR6L2yfjKk6/2iKAbV56mdnnu5xG3TG8++naL\n"
+            + "7n/s9TGBhgknb6+vGhSMZ/1dpQ6wkiyuEmgKJo8DzHAh3k3VATHiBeSD7fNSsgtK\n"
+            + "gzK0hi53IFRFDDPYiCca+SS6/pA2zF56JWGETiIa8rSHIQaK4hNJ38vgKOZM80vQ\n"
+            + "fp+CxvJkYY71Yc94oQByaQzrXod7xnukp5SXe/N3BYTFCWoaSTRUI/THRywWwKqa\n"
+            + "rUsttYrqs/EQSy0X3kZ7CAm04uzA8csNyxapEVRvJxbrt5I=\n"
+            + "=DAMW\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx6npoBCACp0vHePNPeLzm0HM35i70bRChyZXu/ARxOHZHNbh6hWJkq5saI\n"
+            + "zuzZoaqXAr3xZwHftTULlkgoJIt40x6VCT8EBnUHTkOqoHTsFnXg2kNuhFvmn0OX\n"
+            + "7RQFlR1SGoQG4fEy6t/GlOwEknpNSIMLkbDMP2FzEkLVWtlIe2hqqawVIgqzyO3k\n"
+            + "HaQxW8gWyyTx0qeSdSi4DyFZIzdyu/aZa7sj/MhO3DB3UwubW6yE+PMcAVrJD+0d\n"
+            + "EToMT7i8Erncc+xEzuXAoQUHaQfOXV4DG5qSgVpKaLxJ/ABWUri0eMPhj0cT4iDx\n"
+            + "eNTL7cZ4h72B1uJs8byDN74PHrypNiVE+IRHABEBAAEAB/9BbaG9Bz9zd0tqjrx2\n"
+            + "u/VQR3qz1FCQXtuqZu8RMC+B5zIf2si71clf8c7ZHnfSxWZt65Ez1SMYwDeyBdje\n"
+            + "/7B1Gw3Ekk00tFxHx0GEL2NSdZE4sbynkHIp0nD4/HlIc41rmh08E405F7wiAWFn\n"
+            + "uCpfDr47SNpR/A4BxHYOvi8r9pBxn/fXiHluqYROit0Z4tfKDCvQ47k+wqVD5nOt\n"
+            + "BEbHDfEwUMibgTuJ1qPyHf6HDlSdTQSfYV8QW1/UbHWus9QikfjGfLJpX0Rv3UG+\n"
+            + "WXHmowpRDVixj74UQCYXQ/AZi/OBlcS8PRY6EZV4RLyEWlZrdzKViNLOTUbJNHvA\n"
+            + "ZAQVBADQND7CIO6z4k8e9Z8Lf4iLWP9iIbH9R7ArTZr2mX1vkwp+sk0BNQurL/BQ\n"
+            + "jUHOJZnouwkc+C3pQi/JvGvAe1fLHPA0+NKe/tcuDXMk+L1HH6XmDgKtByac41AR\n"
+            + "txxqhaECNeK9OKXAXaEvenkGFMcqQV3QMiF2q5VlmFxSSXydEwQA0M8tCowz0iZF\n"
+            + "i3fGuuZDTN3Ut4u6Uf9FiLcR4ye2Aa5ppO8vlNjObNqpHz0UqdDjB+e3O/n7BUx3\n"
+            + "A5PRZNQvcMbhgr2U3zjWvFMHS3YuxbuIaZ1Vj69vpOAGkUc98v4i0/3Lk7Lijpto\n"
+            + "n40S0eCVo+eccHA4HRvS5XSdNGHVJn0EAMzfBt3DalOlHm+PrAiZdVdp5IfbJwJv\n"
+            + "xkyI++0p4VaYTZhOxjswTs6vgv30FBmHAlx1FzoUOKLaOhxPyLgamFd9YG+ab4DK\n"
+            + "chc4TxIj3kkx3/m6JufW8DWhKyAJNZ/MW+Iqop5pUIeTbOBlNyaflK+XxjkP71rP\n"
+            + "2gZx4pjYjK5EPDy0HlRlc3R1c2VyIEEgPHRlc3RhQGV4YW1wbGUuY29tPokBOAQT\n"
+            + "AQIAIgUCTHqemgIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ0lrQep/Q\n"
+            + "05ZxMAf+OoRzXWbGfv7kZb7xdrVyAUTAV4bUUvLoJZUIQ1ckPBcty2LUvY7l9efg\n"
+            + "p3c57nvTD6U98dVnsKfaW4PT0CRXlpl1IFyhkgbInFS5rO+cJMQn1KyC+FfiwyGN\n"
+            + "ii630SwiHyWRG5+XQ6Iptx9JELwWUMCLJxFpB8DZQKlNnvdl+YUgEeQOkWTXfTSa\n"
+            + "BATdXHiZhskiumnTOGO24jSg8CrZc5O/n6fCCgEsAFWL7fnO0ii6EW1JH5btLHPx\n"
+            + "L9QI+5DJIypgOhGI1lqZW9KrpfmJ3w6N1GekGBda98DmzxxxZ9iyq1cELAAiQMjk\n"
+            + "vws67cOs/hwXNn9YaK74dzhb49MLGJ0DmARMep6aAQgA3NQtBhS8yiEGN8rT4hGt\n"
+            + "uuprVd5jQVprLz4ImcI2+Gt71+CRgv/BZ0zzFp3VPjTGRusungJYkKKOGpEpERiq\n"
+            + "EG1X/ZyL7EzoyT+iKIMDsVJgmyDNcryHTejlKA8Z6GQ1hPlOIws22oLq5zQXxD9p\n"
+            + "zMDuabHl/s/bYlU5qXc7LhxdtrmTb2uBP9a+eneWKrz8OfgtS5m9DgqJ6Bjl0Tvb\n"
+            + "eVJgKHX42pqzJlBTCn3hJjJosy8x4qTbqMraENnl9y+qynM7atoHX6TPWsD7vWtW\n"
+            + "vi+FA5OWGEe3rof8o/sJSj05DQUni8mmSiCYW/tUklPPXOvPRP0GZ/GhBzIUtE3j\n"
+            + "BwARAQABAAf+KQOPSS3Y0oHHsd0N9VLrPWgEf3JKZPzyI1gWKNiVdRYhbjrbS8VM\n"
+            + "mm8ERxMRY/hRSyKrCdXNtS87zVtgkThPfbWRPh0xL7YpFhenena63Ng78RPqlIDH\n"
+            + "cITs6r/DRBI4jnXvOTr/+R2Pm1llgKF2ePzsSt0rpmPcjyrdBsiKSUnLGxm4tGtW\n"
+            + "wVoEjy3+MRN2ULyTO8Pe4URKTtUkkb23iuQuJZy+k+SfH+H0/3oEb8ERRE3UXNG7\n"
+            + "BIbaj71nsx8+H8+x8ffRm1s5Unn86AJ418oEhxNzQk59NnrrlJ4HH9NNbjjzI3JE\n"
+            + "intSQKhFJsvMARdzX062yartQtnm1v6jwQQA65rpMMHCoh9pxvL6yagw3WjQLEPw\n"
+            + "vOGpD9ossBvcv/SfAe7SgJsx6J6X0IIW6EKIjyRhWTIfK/rVR0cmUFTGStib+y22\n"
+            + "BPcQmt/Oiw9rdUfOmDrnosPC0SB+19tKw1v1AfW5swpJnGBCkGz9UfX4Fr/eTS3e\n"
+            + "2KaMq+r1KALSUVkEAO/x0SWOiBRH3X1ETNE9nLTP6u2W3TAvrd+dXyP7JjXWZPB8\n"
+            + "NOwT7qidvUlhTbxdR7xWNI1W924Ywwgs43cAPGyq95pjdzhvi0Xxab7124UK+MS3\n"
+            + "V4WBvjOYYW8pkdMOydRLETXSkco2mDCRTiVKe3Zi7p+lKlVJj4xrFUPUnetfBADH\n"
+            + "EPwYeeZ8sQnW644J75eoph2e5KLRJaOy5GMPRLNmq+ODtJxdoIGpfQnEA35nSlze\n"
+            + "Ea+1UvLBlWyF+p08bNfnXHp3j5ugucAYbVEs4ptUwTB3vFt7eJ8rkx9GYcuBFiwm\n"
+            + "H47rg7QmS1mWDLyX6v2pI9brsb1SCgBL+oi9CyjypkjqiQEfBBgBAgAJBQJMep6a\n"
+            + "AhsMAAoJENJa0Hqf0NOW/gkH/jm9FL+S53NjrthdbNjffryhp7KhTmYAsRk3Hc3X\n"
+            + "4TBj3upecarJynpvsz5HlLi/OxDRR6L2yfjKk6/2iKAbV56mdnnu5xG3TG8++naL\n"
+            + "7n/s9TGBhgknb6+vGhSMZ/1dpQ6wkiyuEmgKJo8DzHAh3k3VATHiBeSD7fNSsgtK\n"
+            + "gzK0hi53IFRFDDPYiCca+SS6/pA2zF56JWGETiIa8rSHIQaK4hNJ38vgKOZM80vQ\n"
+            + "fp+CxvJkYY71Yc94oQByaQzrXod7xnukp5SXe/N3BYTFCWoaSTRUI/THRywWwKqa\n"
+            + "rUsttYrqs/EQSy0X3kZ7CAm04uzA8csNyxapEVRvJxbrt5I=\n"
+            + "=FLdD\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * pub 2048R/B007D490 2010-08-29 [expired: 2011-08-29] Key fingerprint = 355D 5B98 FECE 6199 83CD
+   * C91D 5760 6987 B007 D490 uid Testuser B &lt;testb@example.com&gt;
+   */
+  public static TestKey keyB() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx6ntMBCADG7j/nuI+VvvNbPY9nnfLrAc3KTj0Z+DMxxUMYoZNLjTw1szQ0\n"
+            + "PuKKACiiSA9Oyj4R0aIhWdIR9iYxp6gQdje3yewzoqMwE+t5onYDpdX9QDFXyEzF\n"
+            + "UPWCjA7OSji1G6fyWakiYxKseqyRXOdHXI5TqMikBalmSpwwvmik0cfRGO+l6qvM\n"
+            + "mVJlcn6mkZB0d8WOPV8j8rFxmVSPn9SVP9L8HaFWv1uI9EY3zXbfNeDNgNeTWIMY\n"
+            + "75saINBA2LALBQ54u52GoSbaR8ukZYAjjkif3WIFI8B9xREwjUBLFy3E357aGyLZ\n"
+            + "jE8nsmPk4MDxDaeDNoSHJjcxtDWQJBub3u1zABEBAAG0HlRlc3R1c2VyIEIgPHRl\n"
+            + "c3RiQGV4YW1wbGUuY29tPokBPgQTAQIAKAUCTHqe0wIbAwUJAeEzgAYLCQgHAwIG\n"
+            + "FQgCCQoLBBYCAwECHgECF4AACgkQV2Bph7AH1JD0nQf/Vm+/Mvl99/y3Qw10S6et\n"
+            + "H6NYWDUeAKXe9mfXBJ39HdtlF50jZ5NzSwksAOSQtQZJ3tQQeElXB29cZDvAscva\n"
+            + "RiTtt+KUxDZSYbEHrC0EO7w0Wi5ltwaWdXnoitMOgPZ/grL7UpUbL8rB1evfLbhm\n"
+            + "AqC/6kgHuXeY/7EAzwU3o0wKbmfx1sh8AyQSi4unUwIDCV1RIAP0+ZfJSg0WwGoS\n"
+            + "JB5+lKajtIE6kMn9m8CWM66/zxSCY3XLcoXvjVxCYPwwgYSyje8dDxxOI+x7uj2I\n"
+            + "IjM5RHQ9hTsR7NQ9JUTFmpKZlcdah93NZLKJAFLUtOPjMa5d5t2O0ZOxZ5ftlhHp\n"
+            + "Q7kBDQRMep7TAQgAwOuLBXnACIsd879ld/vLcn8umpKV8MIUjrqOMjR0rNKpCUDw\n"
+            + "LxL4uVh3q/ksESHnQPPqxFYkgeA66SYrx4jwZjbZ5vv9BW99LHe8lSahqrJA9A9g\n"
+            + "5iw5hH+2ZWrGlu3P65UdQUJW+JaDx1IIBt3BbmdGDuKF/ESsy9qxEKq7tKqHI2JL\n"
+            + "Ed+6OIwWblU7ZogfiNpgZJ0lapxTe84mGsD0TowGTu5re/8wIJf1f2q4PuG+L9OZ\n"
+            + "0ZD5i9s1MAxdw4OD+705owPCQnqsr18nH9aUBHWJn9NCXb3jL7QGaId84Yq8SRlK\n"
+            + "wHSRtHLLJoowJ5fXw5UbZcUtRUergxFRwae87wARAQABiQElBBgBAgAPBQJMep7T\n"
+            + "AhsMBQkB4TOAAAoJEFdgaYewB9SQMbsH/iu1HY7OMJxd8EkfxairRNec/v9uEvYQ\n"
+            + "XqfEPw/Hihdef1TY8vB69ymAPd89e1PRDj1m+0/RivO045qFP7lbWMkjKeR9dXXe\n"
+            + "UzIEsTUJ1CNnA7C3fo11NBVg59E0d84bMKQx7n4AZkljgKFKghUb6OJZiWRdh+8W\n"
+            + "0I95JI2R7nMYw3L8/sSGxt+Vjhs9acB1DldbyYbJitYA4fhVZQH9zgeuhQqdCULQ\n"
+            + "ZexpkQqvG0o4iJKO4yeJNHdeM+NwH38wXfzydtEv6Dxz/YZSTwt08p97l6DQ//H7\n"
+            + "wek1LcqeX47YFa9Ftns8Y8fjh4S8Kyi1F6BhZKbsdDqg2hA+0AFv7LA=\n"
+            + "=tmW1\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx6ntMBCADG7j/nuI+VvvNbPY9nnfLrAc3KTj0Z+DMxxUMYoZNLjTw1szQ0\n"
+            + "PuKKACiiSA9Oyj4R0aIhWdIR9iYxp6gQdje3yewzoqMwE+t5onYDpdX9QDFXyEzF\n"
+            + "UPWCjA7OSji1G6fyWakiYxKseqyRXOdHXI5TqMikBalmSpwwvmik0cfRGO+l6qvM\n"
+            + "mVJlcn6mkZB0d8WOPV8j8rFxmVSPn9SVP9L8HaFWv1uI9EY3zXbfNeDNgNeTWIMY\n"
+            + "75saINBA2LALBQ54u52GoSbaR8ukZYAjjkif3WIFI8B9xREwjUBLFy3E357aGyLZ\n"
+            + "jE8nsmPk4MDxDaeDNoSHJjcxtDWQJBub3u1zABEBAAEAB/wPPV1Om92pc9F3jJsZ\n"
+            + "2F3YZxukLfjnA76tnMEWd/pYGrUhdV3AdY4r/aB0njSeApxdXRlLQ3L2cUxdGCJQ\n"
+            + "mzM1ies7IXCC/w5WaShwAG+zpmFL/5+cq3vDc9tb2Q/IasVOVFQYEE2el7SfW5Cp\n"
+            + "mjZFGR8V1wvdNvC0Q0IHrmfdECYSeftzZBEj7CcoGc2pF5zpCG0XQxq7K6cEeSf5\n"
+            + "TKf//UVHgyBCIso6mzgP5k6DGw2d64843CPhhlHEbirUu/wNnbm1SqJ5xFL2VatH\n"
+            + "w7ij4V/hbgnP0GQkbY5+p/PU74P7fx/Ee8D8mF2HmEKRy6ZQY/SAnrjsAURBYR5S\n"
+            + "GF5RBADfhOYEgseWr81lq6Y1oM4YQz+pXRIZk34BagOJsL767B7+uwhvmxBJKIOS\n"
+            + "nRIxfV8GlvT22hrbqsRRyusoIlo2ZUat94IMAL6Oqm6VFm71PT3z9+ukWK43FIXf\n"
+            + "Bsz4swSV001398e3jpSizI6fGW7LRxvnua+NPN+xJLmDVcsPvwQA49ajm48NorD9\n"
+            + "bIWG87+2ScNTVOnHKryR+/LrGWA0f3G6LUsHZPKHNBdFZ4yza2QtEKw95L3K9D4y\n"
+            + "jIeKGwSRYJPb5oh5tSge58pxwP88eI9J4dL+XF1nsG0vYF9B41+qG1TCsPyUJTp6\n"
+            + "ry7NAgWrbpsZpjB0yJ1kFva3iS/hD00EAMu66p1CtsosoDHhekvRZp8a3svd+8uf\n"
+            + "YEKkEKXZuNNmJJktJBSA2FK1RKl9bV8wuG0Pi1/k39egLO3QTjruWUbSggT+aibR\n"
+            + "RW3hU7G+Z5IBOU3p+kTFLat6+TBg0XhCjJ+Eq366nZy1QIfqTCixIaDwrutZd6DC\n"
+            + "BXOjdoG6ZvLcQia0HlRlc3R1c2VyIEIgPHRlc3RiQGV4YW1wbGUuY29tPokBPgQT\n"
+            + "AQIAKAUCTHqe0wIbAwUJAeEzgAYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ\n"
+            + "V2Bph7AH1JD0nQf/Vm+/Mvl99/y3Qw10S6etH6NYWDUeAKXe9mfXBJ39HdtlF50j\n"
+            + "Z5NzSwksAOSQtQZJ3tQQeElXB29cZDvAscvaRiTtt+KUxDZSYbEHrC0EO7w0Wi5l\n"
+            + "twaWdXnoitMOgPZ/grL7UpUbL8rB1evfLbhmAqC/6kgHuXeY/7EAzwU3o0wKbmfx\n"
+            + "1sh8AyQSi4unUwIDCV1RIAP0+ZfJSg0WwGoSJB5+lKajtIE6kMn9m8CWM66/zxSC\n"
+            + "Y3XLcoXvjVxCYPwwgYSyje8dDxxOI+x7uj2IIjM5RHQ9hTsR7NQ9JUTFmpKZlcda\n"
+            + "h93NZLKJAFLUtOPjMa5d5t2O0ZOxZ5ftlhHpQ50DmARMep7TAQgAwOuLBXnACIsd\n"
+            + "879ld/vLcn8umpKV8MIUjrqOMjR0rNKpCUDwLxL4uVh3q/ksESHnQPPqxFYkgeA6\n"
+            + "6SYrx4jwZjbZ5vv9BW99LHe8lSahqrJA9A9g5iw5hH+2ZWrGlu3P65UdQUJW+JaD\n"
+            + "x1IIBt3BbmdGDuKF/ESsy9qxEKq7tKqHI2JLEd+6OIwWblU7ZogfiNpgZJ0lapxT\n"
+            + "e84mGsD0TowGTu5re/8wIJf1f2q4PuG+L9OZ0ZD5i9s1MAxdw4OD+705owPCQnqs\n"
+            + "r18nH9aUBHWJn9NCXb3jL7QGaId84Yq8SRlKwHSRtHLLJoowJ5fXw5UbZcUtRUer\n"
+            + "gxFRwae87wARAQABAAf8DAVBKsyswfuFGMB2vpSiVxaEnV3/2LoHFOOb45XwJSqV\n"
+            + "HL3+mThJ5iaUglMqw0CFC7+HA8fIS41grlFSDgNC02OcjS9rUxDg0En/pp17Gks0\n"
+            + "D+D7bSwZQ1+/yi7ug836lBe89GmBSMj8GgnK9T6RBGOL8nZ72b2ftK4CNWMmAfo4\n"
+            + "NZUy+rnnziV5WoYrkFZhl3dMMd3nITILBy9eYUoiKJl8O1b8amhrNkB/PEMAV7jc\n"
+            + "260XEQ9fgzMMe5/oT8pzIOGyrB+QO5rMu9pGVJ1qeMzTiZjjHXE2CEaEbvEk0F4l\n"
+            + "6w2gp5C6O5GoMpCOPwCy7dOYX5ETdO4Ppjnrob2XEQQAwus5q+EFoBVG8vfEf56x\n"
+            + "czkC15+0VcMe/IM8l/ur/oF1NUlAnPCq7WfgdELvGNszW7R+A625yXJJf7LJE/y/\n"
+            + "5GUGHAK60FUa0ElbVEn0A6kDcvll0dM6rKPQvFguaFpBKXre6k17cdOrf9hasfJk\n"
+            + "+lzaHlh9hJgoM30pAwG4+n8EAP1f+TEkEfVFo4Uy84eO6xVkYVndopDU1gCpfW1a\n"
+            + "84SA2PNjU3vkdIoFsEvOmf1xlfYeDYn37dikFPEZDsHBUzELDMewAXRgmVvnMJrj\n"
+            + "8Zq4FbEQSVjyz3qJOGk5V999qqoVMRXdnlQs5IXgZauPsnIqi5TRQZOMhbaiOVBO\n"
+            + "kqWRBAC9FhxypA3t9j1zGTFDppWmcBxpVzGGsgmzGO+WTVyk6szbZgTsf2+R+gTJ\n"
+            + "ZKVVzE6Mu+iZmPbrn/x7LWzKJuavRz0xSrvCYbIxYyheFz5LOPFHLF181h1g79gY\n"
+            + "E5Tz7uwu3jIldM7rY5RhxS6V5GGDVSfA+/Dsk6Iaujs6Hs7y30C0iQElBBgBAgAP\n"
+            + "BQJMep7TAhsMBQkB4TOAAAoJEFdgaYewB9SQMbsH/iu1HY7OMJxd8EkfxairRNec\n"
+            + "/v9uEvYQXqfEPw/Hihdef1TY8vB69ymAPd89e1PRDj1m+0/RivO045qFP7lbWMkj\n"
+            + "KeR9dXXeUzIEsTUJ1CNnA7C3fo11NBVg59E0d84bMKQx7n4AZkljgKFKghUb6OJZ\n"
+            + "iWRdh+8W0I95JI2R7nMYw3L8/sSGxt+Vjhs9acB1DldbyYbJitYA4fhVZQH9zgeu\n"
+            + "hQqdCULQZexpkQqvG0o4iJKO4yeJNHdeM+NwH38wXfzydtEv6Dxz/YZSTwt08p97\n"
+            + "l6DQ//H7wek1LcqeX47YFa9Ftns8Y8fjh4S8Kyi1F6BhZKbsdDqg2hA+0AFv7LA=\n"
+            + "=uFLT\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * pub 2048R/D24FE467 2010-08-29 Key fingerprint = 6C21 10AC F4FC 1C7B F270 C00E 641F 1193 D24F
+   * E467 uid Testuser C &lt;testc@example.com&gt; sub 2048R/DBECD4FA 2010-08-29
+   */
+  public static TestKey keyC() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx6nuMBCADd077pyfsDGbGhHh+7xzipWihMJRrzQnpbSeVJIxA/Js+Z2MW8\n"
+            + "9J98AgnjONjGVlLqtp11O8Bp9xgdoGYWvFl2CrooQrCe+70JORHE30MJT+61mgLQ\n"
+            + "jm9l2WmIIcuzNwoTOKqWlXuaRIKddXMVbwr++Enl/9znx81FCf1KioDijeeHzVZb\n"
+            + "IjELLCtLlhwhGlYNy6LfNhSY+rNHOomIM9CUXkGZU7JvTe3M1plUzYYIFu3tttZI\n"
+            + "b6e1FSfR60yZ/f88fLacloc3fSrPWA261R/gHuFfLCdTt/I3EcYE+x33LZnSSOgz\n"
+            + "v/JtAuFlCaF/oNRTJHeRbALeri+FxBYule15ABEBAAG0HlRlc3R1c2VyIEMgPHRl\n"
+            + "c3RjQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTHqe4wIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQZB8Rk9JP5GcEIgf/cMvYBwH8ENrWec366Txaaeh/TO6n\n"
+            + "4v4P2LUR4/hcrNpHx3+9ikznkyF/b8OCsOE+KstvOO6i9vuRGVBPmfoALVv8iCGs\n"
+            + "5MXZJskjACXOqQav0I7ZY5rDJxuOKq6DrxtpHNxK8n0D1PEZllyk/OZVBAcjL2vu\n"
+            + "WC6ujP3jbMKaV0+heFqOVIghQjdA4McLH2u1XLOGEZdp7hLfmTnClmfzbnslFBSQ\n"
+            + "xU2g3jCq2k2zAPhn+jOGCL0987QGj1e6pHRXdUxcfnLRyNadRied0HO/clIb8vdt\n"
+            + "UaexujHjgg+1KDxj4PBAftN2lRtnnsSG9z4T31aTFz5YVG+pq8UXk9ohCokBIAQQ\n"
+            + "AQIACgUCTHqkKQMFAngACgkQqZHi1Q/dNnexiQf/ba9LcR76+tVvos1cxrGO3VkD\n"
+            + "3R1pvIWsb37/NTypWCvrFhsy4OUEy3bVCfJcqfwdY3Q2XixB9kuKo3qCSom1EjGg\n"
+            + "Qhr5ZsrB3qYqaa6S0AeVusmIwArEr9uuMUDjXhKlUALDX8HfXWGy2UmjNJkkT8Jm\n"
+            + "GtISS4KOfXUuZY04DttvbukEnyxAiLU9V0BnzrI9DARh0gEjqjUZAVyP5lOXJJxt\n"
+            + "sau95mOe8E61GELXPkxDLrnCboX7ys2OxcFO6S7q1xJPkki2SVq0y0k5oY/3jktw\n"
+            + "jO8uC3n7NiyW+BYJK6+zj3u3iA+o0YGm+i6F7aneJEaJrFqRj9L1vbojvuH0cYkB\n"
+            + "IAQQAQIACgUCTHqkOwMFAngACgkQOwm5f0tDh+7dSQf+PnEUftNSOuLVLoJ+2tyD\n"
+            + "DPJpcLIavNCyNR3hCGL86NXRUxOrmYgDVVv8pJuYB6aUTm69rFFZlzNwqQN5pBiX\n"
+            + "Zr3NM1jgJT6gKfXddcg1p/X2S9+xn4RN92R0fn0kEjM65fpE1Do+YWHOuHDZEOrx\n"
+            + "L8OaSo8lr19+r27fn09/HBhz2lOyTYzsdTjHeWdxPVQ3JNiVX11k7iKsttdYtM/V\n"
+            + "mAHzzd54Kvt5So/2qLIAcfSmUe9DQAdmcEcJQpQ2veND9uwccX7tH0cH4n9Cp16o\n"
+            + "quJ2pxWzOvKR3zxSw+cRxyIS4VjT6k+UsG3Lw55QZgdb5IEaJfezPj+tOhQlQz0f\n"
+            + "VrkBDQRMep7jAQgAw+67ahlOGnkF6mTtmg6MOGzAbRQ11MNrORnNtGOccNgtlgrO\n"
+            + "Y8TBqw1HkJ56v26E1FxfRh69CUGkYVXx0tMw0QbI+unX35ce5hJD4aWa8bOA1vfw\n"
+            + "474p/NpI+czWsFvcdOu5K6xIGXHShaQQyf2FQ9QeIFrU60qfaBL5jzuLyujCACqU\n"
+            + "46QGgBgeUjaT54LjrWSdn/Jtsbpv0MPv3Ea8fMdtSMkkBsDkF55jaJDFYq+xbs+e\n"
+            + "IKBjTwtSvrUisnLAC0Z9YY21GXGI3DGYqpVXz+Fe5xMTX1a6K3VKEmxmX2m/ebhm\n"
+            + "1p6EqjAJguOjJbJJQHKHMOol0zU6ANB6SgP26wARAQABiQEfBBgBAgAJBQJMep7j\n"
+            + "AhsMAAoJEGQfEZPST+Rn7AcH/32HACPLdxINsi8OSWa8OccMG5XEUvHTZjmdwVT2\n"
+            + "czMss8nwgifU9D4hEVRu1MWpiyxUgegW94wuSh4PWIVOVd18PmzAYc73aYgonakb\n"
+            + "M+MDIqGVvAH8QtHo79sqZ9vrkQaQXB3Y8cq+WxDQZyl8KLXP2icmq1Rl6Q6+i9oS\n"
+            + "pFe88Wr0cGaTblkfDbbWcih3C6tKAfcFwLLg8u4jYfXjZg/E9eAJf0dIFcQSQoHd\n"
+            + "O8hVXaZwx/rYXA8UFwAuROo2nu6SIof1lrH92p+now95d5zUZ5BYnKVd3uXsln0j\n"
+            + "z585UPQKS2J8PUy9IirmahgTyEYFwO64kZ2B4hYOE2g+rYw=\n"
+            + "=LtMR\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx6nuMBCADd077pyfsDGbGhHh+7xzipWihMJRrzQnpbSeVJIxA/Js+Z2MW8\n"
+            + "9J98AgnjONjGVlLqtp11O8Bp9xgdoGYWvFl2CrooQrCe+70JORHE30MJT+61mgLQ\n"
+            + "jm9l2WmIIcuzNwoTOKqWlXuaRIKddXMVbwr++Enl/9znx81FCf1KioDijeeHzVZb\n"
+            + "IjELLCtLlhwhGlYNy6LfNhSY+rNHOomIM9CUXkGZU7JvTe3M1plUzYYIFu3tttZI\n"
+            + "b6e1FSfR60yZ/f88fLacloc3fSrPWA261R/gHuFfLCdTt/I3EcYE+x33LZnSSOgz\n"
+            + "v/JtAuFlCaF/oNRTJHeRbALeri+FxBYule15ABEBAAEAB/sFPLoJDG1eV5QpqEZf\n"
+            + "m/QMOTOn8ZJ9xraQvXFvV7zgVXxJBvTLMbuACrnHnoiCrULS+w8Dt66Nfz7s4yQJ\n"
+            + "5SDtFX2AlMDVWL7wBEPgF1UpN6ox1CzSa6HOaygaUFGeKHO20WDjV4HmBLhQkKIa\n"
+            + "vKbghHA/4Nm1s1z3BHB8GtdGZ1VHc+s1DhPK5w+WHqYpLYjpNmI9yJg3gclEqEG9\n"
+            + "XzBqTZm9mPJRBdDMOD0xLa4nUD3Dkrjimqod3X7EuXE6sT2DuGVa1nuynk/8gIyO\n"
+            + "uS6crY7YJzEQUtQJ2n3y/h+QnZFo9UFuIVpgsxhBDsCnYNFWNR91Q0IM6PohHvqx\n"
+            + "BtFhBADsax1Bc0obP+bIkeAXltGlUYqm3bjOgVZ87XR0qe4TGwXGe8T1Yjfc8rj0\n"
+            + "cfBYCud201r/05CgchojMnTWlFLg308bSIZ9YvN3oOVay8nZ7h62dUIs45zebw3R\n"
+            + "SHwvjE5Sm/VWIdLrUUW1aGfk/VPudNMMMu2C64ev8DF/iwYjoQQA8DM+9oPvFJPA\n"
+            + "kLYg71tP2iIE5GbFqkiIEx59eQUxTsn6ubEfREjI99QliAdcKbyRHc3jc68NopLB\n"
+            + "41L7ny0j6VKuEszOYhhQ0qQK/jlI461aG14qHAylhuQTLrjpsUPE+WelBm9bxli0\n"
+            + "gA8F81WLOvJ2HzuMYVrj3tjGl3AHetkEAI77VKxGCGRzK63qBnmLwQEvqbphpgxH\n"
+            + "ANNAsg5HuWtDUgk85t2nrIgL1kfhu++CfP9duN/qU4dw/bgJaKOamWTfLBwST8qe\n"
+            + "3F8omovi1vLzHVpmvQp6Ly4wggJ4Gl/n0DNFopKw20V8ZTiRYtuLS43H7VsczE+8\n"
+            + "NKjy01EgHDMAP8O0HlRlc3R1c2VyIEMgPHRlc3RjQGV4YW1wbGUuY29tPokBOAQT\n"
+            + "AQIAIgUCTHqe4wIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQZB8Rk9JP\n"
+            + "5GcEIgf/cMvYBwH8ENrWec366Txaaeh/TO6n4v4P2LUR4/hcrNpHx3+9ikznkyF/\n"
+            + "b8OCsOE+KstvOO6i9vuRGVBPmfoALVv8iCGs5MXZJskjACXOqQav0I7ZY5rDJxuO\n"
+            + "Kq6DrxtpHNxK8n0D1PEZllyk/OZVBAcjL2vuWC6ujP3jbMKaV0+heFqOVIghQjdA\n"
+            + "4McLH2u1XLOGEZdp7hLfmTnClmfzbnslFBSQxU2g3jCq2k2zAPhn+jOGCL0987QG\n"
+            + "j1e6pHRXdUxcfnLRyNadRied0HO/clIb8vdtUaexujHjgg+1KDxj4PBAftN2lRtn\n"
+            + "nsSG9z4T31aTFz5YVG+pq8UXk9ohCp0DmARMep7jAQgAw+67ahlOGnkF6mTtmg6M\n"
+            + "OGzAbRQ11MNrORnNtGOccNgtlgrOY8TBqw1HkJ56v26E1FxfRh69CUGkYVXx0tMw\n"
+            + "0QbI+unX35ce5hJD4aWa8bOA1vfw474p/NpI+czWsFvcdOu5K6xIGXHShaQQyf2F\n"
+            + "Q9QeIFrU60qfaBL5jzuLyujCACqU46QGgBgeUjaT54LjrWSdn/Jtsbpv0MPv3Ea8\n"
+            + "fMdtSMkkBsDkF55jaJDFYq+xbs+eIKBjTwtSvrUisnLAC0Z9YY21GXGI3DGYqpVX\n"
+            + "z+Fe5xMTX1a6K3VKEmxmX2m/ebhm1p6EqjAJguOjJbJJQHKHMOol0zU6ANB6SgP2\n"
+            + "6wARAQABAAf9HIsMy8S/92SmE018vQgILrgjwursz1Vgq22HkBNALm2acSnwgzbz\n"
+            + "V8M+0mH5U9ClPSKae+aXzLS+s7IHi++u7uSO0YQmKgZ5PonD+ygFoyxumo0oOfqc\n"
+            + "DJ/oKFaforWJ2jv05S3bRbRVN5l9G0/5jWC7ZXnrXBOqQUkdCLFjXhMPq3zg2Yy3\n"
+            + "XSU83dVteOtrYRZqv33umZNCdk44z6kQOvh9tgSCL/aZ3d7AqjRK99I/IYY1IuVN\n"
+            + "qreFriVcJ0EzlnbPCnva+ReWAd2zt5VEClGu9J0CVnHmZNlwfmbFSiUN1hiMonkr\n"
+            + "sFImlw3adfJ7dsi/GzCC4147ep6jXw7QwQQAzwkeRWR9xc3ndrnXqUbQmgQkAD3D\n"
+            + "p2cwPygyLr0UDBDVX0z+8GKeBhNs3KIFXwUs6GxmDodHh0t4HUJeVLs7ur5ZATqo\n"
+            + "Bx50cSUOoaeSHRFVwicdJRtVgTTQ4UwwmKcLLJe2fWv6hnmyInK7Lp8ThLGQgqo8\n"
+            + "UWg3cdfzCvhKSvsEAPJFYhsFA/E92xUpzP8oYs3AA4mUXB+F0eObe9gqv8lAE6SX\n"
+            + "gB5kWhcd+MGddUGJuJV2LRrgOx3nXu3m3n35AH6iAY4Qi9URPzi/K659oefUU1c5\n"
+            + "BFArHX9bN1k1cOvH28tpQ38eAxaMygLqyR5Q5VbtZ5tYqLKCvHVs3I8lekDRA/4i\n"
+            + "e0vlu34qenppPANPm+Vq/7cSlG3XY4ioxwC/j6Y+92u90DXbbGatOg1SqGSwn1VP\n"
+            + "S034m7bDCNoWOXL0yAcbXrLZV74AyfvVOYOs/WtehehzWeTQRT5lkxX5+xGc1/h6\n"
+            + "9HQvsKKnUK8n1oc5aM5xzRVkU9+kcmqYqXqyOHnIbDbPiQEfBBgBAgAJBQJMep7j\n"
+            + "AhsMAAoJEGQfEZPST+Rn7AcH/32HACPLdxINsi8OSWa8OccMG5XEUvHTZjmdwVT2\n"
+            + "czMss8nwgifU9D4hEVRu1MWpiyxUgegW94wuSh4PWIVOVd18PmzAYc73aYgonakb\n"
+            + "M+MDIqGVvAH8QtHo79sqZ9vrkQaQXB3Y8cq+WxDQZyl8KLXP2icmq1Rl6Q6+i9oS\n"
+            + "pFe88Wr0cGaTblkfDbbWcih3C6tKAfcFwLLg8u4jYfXjZg/E9eAJf0dIFcQSQoHd\n"
+            + "O8hVXaZwx/rYXA8UFwAuROo2nu6SIof1lrH92p+now95d5zUZ5BYnKVd3uXsln0j\n"
+            + "z585UPQKS2J8PUy9IirmahgTyEYFwO64kZ2B4hYOE2g+rYw=\n"
+            + "=5pIh\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * pub 2048R/0FDD3677 2010-08-29 Key fingerprint = C96C 5E9D 669C 448A D1B9 BEB5 A991 E2D5 0FDD
+   * 3677 uid Testuser D &lt;testd@example.com&gt; sub 2048R/CAB81AE0 2010-08-29
+   */
+  public static TestKey keyD() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx6nwkBCADuztv2tGhjPljwW46qEhth7ZnkdhYXuctZ6lNQuy5LMaEECE3C\n"
+            + "jvVKY+nBrgsLY2Trts+q+mdooBWvxy/qe5PAQTcPR83KjVS4fYwNMBgeRxBEZAZg\n"
+            + "DFwRRCsRrHost+cMgtzLocQ+vL3+9yTRAIe/WmYwbEDXg/c9JSC7kQbZqaAaOshO\n"
+            + "cIOyeB8/QoYee0fEnBzHMmcd0SB1YpwIvRG6v61lXmgpQ9CbovvXO6ZZyEyCX784\n"
+            + "9xprzqP1y03DPrbhuhBAY8EMf3KGJA1dEcU4+lbGEgmlOe2YSbWoLs7mRLFcq5xx\n"
+            + "JroYMtvXF04k4ZHNZAnT3IZc+lJyCqOp4vXpABEBAAG0HlRlc3R1c2VyIEQgPHRl\n"
+            + "c3RkQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTHqfCQIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQqZHi1Q/dNne/0wgApuPzh4J8p2quCK1ScsJHlgGRojGq\n"
+            + "IDPhZFtPn0p2IAkqr5sAhvZAjd3u9A2DqQ7pwOX7gnGRE7dSrK69IAjfbRMc5k16\n"
+            + "aBK2ADq2YgPEmTToots1A0Tj+LaCFOXYUtEkgAC+RfFIkCdt8z86GIr0kg19Q/vY\n"
+            + "I/LtvThAk28D8yIfDnW49Mc4GGq+qvrOytBaGu3dzW0mjYWGEyl0fdSjNqtKyWN7\n"
+            + "Qw70Kqysaoy1KiPRAgwiPQfMCEx6pVaXuAfgRKaJ18kCNOldpajLgQv6yeY7mhgu\n"
+            + "Q3Qe7xQlAtVObxskcTH2CWggl2dPqSMNieLK0g/ER8PIReGDCBXNSJ4qYbkBDQRM\n"
+            + "ep8JAQgAw/o1nhJPLGlIfEMzOGU0Jjj+DwEyB3QIEEc+WKRvgtGsJ4cbZdaGWBJq\n"
+            + "jSo7e9XC9jA2ih0+Gld0vWV7S0LZ84xXxQeadC+AZBFR+b9ga4aUFIji8Tdi2dWX\n"
+            + "QmY76hHIaF8rs6aJB7lRig735VRLxVHOb194t9KLUzZiEKqd71BvLQyuLqAfTEsT\n"
+            + "GRHgmydaxZbGXz+Z57jbQgm11CQEHX1dtS8uqWb64xrV5GAeuEhRj4R6Yiy7OPNi\n"
+            + "xXHxryH2Jd34pA0cGHYVcTgVjXuZ9FFP2SnXuxABONGAIaJuqg7ozYBa2kOdr0DN\n"
+            + "5Pxy5ocR7R2ZoN0pYD5+Cc7oGHjuCQARAQABiQEfBBgBAgAJBQJMep8JAhsMAAoJ\n"
+            + "EKmR4tUP3TZ369QIAKPlfX2TUfhP3otYiaa24zBJ/cvGljGiSfX0KrausBHH161j\n"
+            + "lraJfLzpe7vSOZhwZwgIY/eKoErAkJwVnX1+dLuOcHaqRDi5gnLqa6Yg9a2LWb4z\n"
+            + "rvgsvbiNUs1o9htOcvcpv7e3UUUcRa8lO+aNkO+VoI6DI8RJ3wIfJayboePRXdfr\n"
+            + "8g9of0jSdIOzlaaBPxA2wYSWXm4kv7QXzZooxuGqhn0+JKuq2+oO9y5QUig+c3oG\n"
+            + "a5mpVblmv5ZL6Gc36kCbeEC8j6JkNT4wnceQwpNUNYtPU186cjy3rAD4C58w0Uvp\n"
+            + "HZZSTc0syLOShQr//We39LUNaX6WF3NmyF8K/OM=\n"
+            + "=YDhQ\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx6nwkBCADuztv2tGhjPljwW46qEhth7ZnkdhYXuctZ6lNQuy5LMaEECE3C\n"
+            + "jvVKY+nBrgsLY2Trts+q+mdooBWvxy/qe5PAQTcPR83KjVS4fYwNMBgeRxBEZAZg\n"
+            + "DFwRRCsRrHost+cMgtzLocQ+vL3+9yTRAIe/WmYwbEDXg/c9JSC7kQbZqaAaOshO\n"
+            + "cIOyeB8/QoYee0fEnBzHMmcd0SB1YpwIvRG6v61lXmgpQ9CbovvXO6ZZyEyCX784\n"
+            + "9xprzqP1y03DPrbhuhBAY8EMf3KGJA1dEcU4+lbGEgmlOe2YSbWoLs7mRLFcq5xx\n"
+            + "JroYMtvXF04k4ZHNZAnT3IZc+lJyCqOp4vXpABEBAAEAB/0Yf+FiLHz/HYDbW9FF\n"
+            + "kmj7wXgFz7WRho6dsWQNxr5HmZZWxxFPMgJpONnc9GGOsApFAnLIrDraqX3AFFPO\n"
+            + "nxH36djfuPKcYqZ77Olm2vXGeWzqT0a2KN5zKQawH/1CxDUwe+Zx/60V8KAfXbSJ\n"
+            + "up+ymnAcbKa0VYYSYFI82/KTdthJ1jFMNtXkaLskpM8TrDBCgd38m8Dpb5GCrDVY\n"
+            + "faZgkHokTTrvaTcx7ebGOxlOcbfzOPMJyFiz6lHf4JGr5ZVQXymaAG18kRDFxXHm\n"
+            + "AskOJIxnMdcy2IzNximht2CIgRuGznyPoeh/j8KFONKIKf3N6dVfV12uIvGOVV+D\n"
+            + "/ZQZBAD2dennp3Z4IsOWkgHTG3bloOVcIY5n+WvliQY/5G3psKdKeaGZxt6MhMSj\n"
+            + "sJEiUgveYTt5PxvQc5jmFEyjEQJmDAHo3RbycdFVvICrKIhKFyIlcVFCOSwDvLAW\n"
+            + "aZhu/m47jGnnYZ+bDzZl4X8L7Zu8e3TStEiVhjYTRqJfdEdMVQQA+A0ehIhIa1mJ\n"
+            + "ytGKWQVxn9BwKTP583vf2qPzul7yDEsYdGfoA0QGUicVwV4NNK3vK3FQM9MBSevp\n"
+            + "JFpxh2bRS/tgd5tFDyRqekTcagMqTxnJoIpCPUvj5D+WXsS1Kwrcm7OpWoNHOcjD\n"
+            + "Hbhk/966QALO+T6BTVLx32/72jtQ10UD/RsqQfRDzlQUOd6ZYOlH5qCb1+f8f3qJ\n"
+            + "yUmudrmjj8unBK3QbBVrxZ1h9AyaI5evFmsMlLKdTp0y49CmrSQmgEnUYzvBDjse\n"
+            + "/jYanpRKnt69HeZFilHLIF+HBbQfSM66UVXVoJSNTJIsncVa0IcGoZTpCUVOng3/\n"
+            + "MLfW4sh9NX1yRIi0HlRlc3R1c2VyIEQgPHRlc3RkQGV4YW1wbGUuY29tPokBOAQT\n"
+            + "AQIAIgUCTHqfCQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQqZHi1Q/d\n"
+            + "Nne/0wgApuPzh4J8p2quCK1ScsJHlgGRojGqIDPhZFtPn0p2IAkqr5sAhvZAjd3u\n"
+            + "9A2DqQ7pwOX7gnGRE7dSrK69IAjfbRMc5k16aBK2ADq2YgPEmTToots1A0Tj+LaC\n"
+            + "FOXYUtEkgAC+RfFIkCdt8z86GIr0kg19Q/vYI/LtvThAk28D8yIfDnW49Mc4GGq+\n"
+            + "qvrOytBaGu3dzW0mjYWGEyl0fdSjNqtKyWN7Qw70Kqysaoy1KiPRAgwiPQfMCEx6\n"
+            + "pVaXuAfgRKaJ18kCNOldpajLgQv6yeY7mhguQ3Qe7xQlAtVObxskcTH2CWggl2dP\n"
+            + "qSMNieLK0g/ER8PIReGDCBXNSJ4qYZ0DmARMep8JAQgAw/o1nhJPLGlIfEMzOGU0\n"
+            + "Jjj+DwEyB3QIEEc+WKRvgtGsJ4cbZdaGWBJqjSo7e9XC9jA2ih0+Gld0vWV7S0LZ\n"
+            + "84xXxQeadC+AZBFR+b9ga4aUFIji8Tdi2dWXQmY76hHIaF8rs6aJB7lRig735VRL\n"
+            + "xVHOb194t9KLUzZiEKqd71BvLQyuLqAfTEsTGRHgmydaxZbGXz+Z57jbQgm11CQE\n"
+            + "HX1dtS8uqWb64xrV5GAeuEhRj4R6Yiy7OPNixXHxryH2Jd34pA0cGHYVcTgVjXuZ\n"
+            + "9FFP2SnXuxABONGAIaJuqg7ozYBa2kOdr0DN5Pxy5ocR7R2ZoN0pYD5+Cc7oGHju\n"
+            + "CQARAQABAAf/QiN/k9y+/pB7h4BQWXCCNIIYb6zqGuzUSdYZWuYHwiEL1f05SFmp\n"
+            + "VjDE5+ZAU+8U0Gv+BAeRbWdlfQOyI/ioQJL1DggeXqanUF4uCbjGDBPLhtCZsmmM\n"
+            + "QVLdrOl+v+SHe33e7E7AQSyQMaUSkUEtHycYIasZPQRfw9H/L3u9OEWXkMUbPso5\n"
+            + "L0A0StkcsM1isYfC8ApnF4zSTWHO9uqnc+qE4qChCqsGvaSIyLKEpVe4F0vEkbrq\n"
+            + "3usVp3cxJd9apN+JjMoC9dHJcQahgfJZ1jzgJ3rueRxrGZV+keo8VmyrDGFCerX9\n"
+            + "6Ke3RPMHN/evCHyPMtHC82QKYuy4ZTvldwQAyzbNKIIpNjyHRc/hXLMBUtnW0VYS\n"
+            + "dELA1VBMmT/d6Xx6pI9gg9HCjDx+DuQRych7ShxrYLL1pNQD8jwEJhZIeUpSgIFD\n"
+            + "BXdwkiGbmdrU5N0tBhxp8kRcqcGbL68zC9S0X2hNju6Dxu9hbG8ZAdYaCdAavVy0\n"
+            + "O6E66+T0cLRBinsEAPbiL/0rpV15DdITwD3hvzhYDyURE+yxQZe9ngS1uoui3mGn\n"
+            + "bLc/L/nbHf2Z91ViSsUaqJjpb2/eDsJtGJ9pFlFLTndujkA62CktJytD9DIYLlYD\n"
+            + "huXlsKvZkNZEZNDKLC5Tg8YR/28Opz0/ZFzfVuJAQqg7+iWkxklG3SvN71RLA/9x\n"
+            + "wun1AEw6tLJ2R2j8+yXIt8UaWExqAviT/JgZELVXdCTqcYuOmktsM2z+2D+OyUtP\n"
+            + "7+Yyz7MGQKMAU+V/1uOK4YqwUJrcGy501o9Of+xm+5DASsK1oM5e9sBdmNewdLHL\n"
+            + "ZJEllURrEC6zCE/4zzs7qUfakH4l4ZJgjRL6va+ED0HfiQEfBBgBAgAJBQJMep8J\n"
+            + "AhsMAAoJEKmR4tUP3TZ369QIAKPlfX2TUfhP3otYiaa24zBJ/cvGljGiSfX0Krau\n"
+            + "sBHH161jlraJfLzpe7vSOZhwZwgIY/eKoErAkJwVnX1+dLuOcHaqRDi5gnLqa6Yg\n"
+            + "9a2LWb4zrvgsvbiNUs1o9htOcvcpv7e3UUUcRa8lO+aNkO+VoI6DI8RJ3wIfJayb\n"
+            + "oePRXdfr8g9of0jSdIOzlaaBPxA2wYSWXm4kv7QXzZooxuGqhn0+JKuq2+oO9y5Q\n"
+            + "Uig+c3oGa5mpVblmv5ZL6Gc36kCbeEC8j6JkNT4wnceQwpNUNYtPU186cjy3rAD4\n"
+            + "C58w0UvpHZZSTc0syLOShQr//We39LUNaX6WF3NmyF8K/OM=\n"
+            + "=e1xT\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * pub 2048R/4B4387EE 2010-08-29 [expired: 2011-08-29] Key fingerprint = F01D 677C 8BDB 854E 1054
+   * 406E 3B09 B97F 4B43 87EE uid Testuser E &lt;teste@example.com&gt;
+   */
+  public static TestKey keyE() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx6nxoBCADjYOWOFa7ZBJpRuNspRoXBTK0LiK5zqN894b87LgIYEgUM6q5J\n"
+            + "yLNo43x7V+ow1/7BEq0JUAMSQ3uRn2jqXiJskSXvwlFYcTVFb0gY09CSD0ptHvda\n"
+            + "zqYOuM/MU1l9jqmlM+pDw/z0pLTKYmAHi6pKJ64pqccMHPUZHpLywyzSNX+JM86I\n"
+            + "K5KAsyGArtgpT9vfci3idNeXjhMR8rfLPDFbdGvGFOZrYv0cfgTbBpVEWeHjs2FR\n"
+            + "4vHG133AdjdZcvA9Y9VW34ZLeiyBEeFix7+HPVS82rko2kQxZu1UZRu340maKDAo\n"
+            + "+UVirgo0FQ8nNUR+c9oNKgiZtO39IAPJv/WZABEBAAG0HlRlc3R1c2VyIEUgPHRl\n"
+            + "c3RlQGV4YW1wbGUuY29tPokBPgQTAQIAKAUCTHqfGgIbAwUJAeEzgAYLCQgHAwIG\n"
+            + "FQgCCQoLBBYCAwECHgECF4AACgkQOwm5f0tDh+6Fowf9FZgntlW4qc7BHe8zYJ0q\n"
+            + "zoLZrHwCFcaeO3kz53y5Lz3+plMuqVDjoQDOt8DxsPHrXWKiu0qBTjZ28ztN3ef6\n"
+            + "f0MpguTGclvFroevUct0xiyox5r1DfMT8JRvqsojE1XPscR2zJzIgEg3OCPuksT9\n"
+            + "EsHsF+/3RBbsXbQgDpW38g0GzIJI4AiQ/yvG2ON9awN2kzIWoBkthVCGy54lCTGj\n"
+            + "yPhatE7Zu2ABNcerIDstupWww2Psec6pGbPPci8ojc90fzalk3UMXcXHD7m8cTJS\n"
+            + "kgHScOzTElIQqOA1+w6uiHy2oAn+qW7534j6p9Tj+DrSIzUXBedGjXZevaKaurVy\n"
+            + "KLkBDQRMep8aAQgAn5r6toYnEzwDeig8r+t89vqOFtohYcmtyeLeTiTTdAz/xWBW\n"
+            + "HUlqV8sglQ9aINpGtBf37v13RhtU3WkUv8cZMQoRM8P2H3cKDNwkucFO6uKSEQO5\n"
+            + "FdzTm4C4WaoE7QiTRbiekwh7O54mz4Wup6LHuEFQEcSpdRUp8w/qaJIHG9EJad1q\n"
+            + "UEsKNnITW+mWHY3+ccK1hgqPwOPqO3/8QtaipekKOYAtOb+57c1jtDFBZnYIkant\n"
+            + "oKs+kRw0DykXFTyFOMYqaleBMcVG+u7ljwAq18L8Ev+qVIpBIZ5eQ5+6p1w9B69h\n"
+            + "RH0Ebn50ebpoqKOXhN4/bu/wq596y0o4xDB0GQARAQABiQElBBgBAgAPBQJMep8a\n"
+            + "AhsMBQkB4TOAAAoJEDsJuX9LQ4fu0/wH/35/22xina8ktbvGV/kB0pH2LBqeXN/b\n"
+            + "CLdA+CDzfwMDzqG0kU39EJ3Fbux7fj4uMaeiYfbO9U85+NOuDmeH41B2dM9S1AzE\n"
+            + "H+/OiCp/Zf1fdd1qXhsA4Xe5vc/VD9oso9OrZK5CM5u0TPmYFijfVDPNgag6mPnD\n"
+            + "zd8JCsuEj4VEy6NF1KcoCc8edQ8AZ4L6ZQ6qiV24gxLnh8xImVr5YjBKDUCdrl79\n"
+            + "0u4wekfgapSx9Sw9Ycz5dFOL07OOHPiKZwUG0f8td6oJX4Ddxset5JAm1pPcLQHR\n"
+            + "6PRx0hI/Tz7rsAI6O37/BEM15+MVGIgOSLL/SRIpOa0L8qmuUhhS6Bg=\n"
+            + "=uA5x\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx6nxoBCADjYOWOFa7ZBJpRuNspRoXBTK0LiK5zqN894b87LgIYEgUM6q5J\n"
+            + "yLNo43x7V+ow1/7BEq0JUAMSQ3uRn2jqXiJskSXvwlFYcTVFb0gY09CSD0ptHvda\n"
+            + "zqYOuM/MU1l9jqmlM+pDw/z0pLTKYmAHi6pKJ64pqccMHPUZHpLywyzSNX+JM86I\n"
+            + "K5KAsyGArtgpT9vfci3idNeXjhMR8rfLPDFbdGvGFOZrYv0cfgTbBpVEWeHjs2FR\n"
+            + "4vHG133AdjdZcvA9Y9VW34ZLeiyBEeFix7+HPVS82rko2kQxZu1UZRu340maKDAo\n"
+            + "+UVirgo0FQ8nNUR+c9oNKgiZtO39IAPJv/WZABEBAAEAB/4xKKzYqDVyM/2NN5Mi\n"
+            + "fF3EqegruzRESzlgrqLij5LiU1sGLOLbjunC/pPWMu6t+rTYV0pT3hmb5D0eAcH0\n"
+            + "EcANiuAR0wg1P9yNk36Z54mLWoTzzKMb3dunCSvb+BU8AREKZ4v5dLEGz2lK7DPo\n"
+            + "zbhWaffMiClBpC0VbjfFBo91LrVUVnhRglBYKdPLQm/Lhw5cNCYOw194ZturO+cC\n"
+            + "iQZhGSy52HMoMs4Wr470CeFZvvWaiDCirVLcj4UhMsVANFKsahMARm9c+QrGrkRP\n"
+            + "+654f8M9ptapcQYpGOMmaeZVnpocONXOTkiJd7Hhr4PRUY+QS8C8F0LbmL2ERQbL\n"
+            + "F65RBADkIelztY/8Xy2S0jsW7+xF2ziz9riOR87G6b0wrXDdFz4GHPzLvwsdXOeN\n"
+            + "cODic14d9bf5jtXr9hgbAzx55ANDjOl3jK5qil8Z9qwsrNK9Mz0wT1acQXBwf/5D\n"
+            + "hI/whBK1FsH7Y+wdX64XA3EXmclxB8GZf1JsGXF3jNH30vyS7QQA/ydoMMw8ja9L\n"
+            + "j6MxHtVHcE4A4j6tFljLDuf8icOwwNUfb7SsHTDjUI2+30ZJOv+qISrthsASCSj3\n"
+            + "AN87CGdVR62Xe923DNdW8/moKKDILNaESyOi27qhI5qWrVRgNB5QwbQcSoClUxbj\n"
+            + "V7YZSfrZkiI+GE1gh1QPMOVyCUmqu90D+wc0x0wUj8emX/4xbbujOa5RAvNcNvnD\n"
+            + "mOB2CfPWD10TEeOOlHBhuoy2/GdIl76W0szJaxnzcV82VArllSciCBzpSfkExDZ6\n"
+            + "08hA8GpOsuOmAAPwXWZsb8YZbJeM0ULMgUCGHgvUj1/pGsCVA6c7sPAdkCfAFlmO\n"
+            + "smC9bvpS2VHZPuG0HlRlc3R1c2VyIEUgPHRlc3RlQGV4YW1wbGUuY29tPokBPgQT\n"
+            + "AQIAKAUCTHqfGgIbAwUJAeEzgAYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ\n"
+            + "Owm5f0tDh+6Fowf9FZgntlW4qc7BHe8zYJ0qzoLZrHwCFcaeO3kz53y5Lz3+plMu\n"
+            + "qVDjoQDOt8DxsPHrXWKiu0qBTjZ28ztN3ef6f0MpguTGclvFroevUct0xiyox5r1\n"
+            + "DfMT8JRvqsojE1XPscR2zJzIgEg3OCPuksT9EsHsF+/3RBbsXbQgDpW38g0GzIJI\n"
+            + "4AiQ/yvG2ON9awN2kzIWoBkthVCGy54lCTGjyPhatE7Zu2ABNcerIDstupWww2Ps\n"
+            + "ec6pGbPPci8ojc90fzalk3UMXcXHD7m8cTJSkgHScOzTElIQqOA1+w6uiHy2oAn+\n"
+            + "qW7534j6p9Tj+DrSIzUXBedGjXZevaKaurVyKJ0DmARMep8aAQgAn5r6toYnEzwD\n"
+            + "eig8r+t89vqOFtohYcmtyeLeTiTTdAz/xWBWHUlqV8sglQ9aINpGtBf37v13RhtU\n"
+            + "3WkUv8cZMQoRM8P2H3cKDNwkucFO6uKSEQO5FdzTm4C4WaoE7QiTRbiekwh7O54m\n"
+            + "z4Wup6LHuEFQEcSpdRUp8w/qaJIHG9EJad1qUEsKNnITW+mWHY3+ccK1hgqPwOPq\n"
+            + "O3/8QtaipekKOYAtOb+57c1jtDFBZnYIkantoKs+kRw0DykXFTyFOMYqaleBMcVG\n"
+            + "+u7ljwAq18L8Ev+qVIpBIZ5eQ5+6p1w9B69hRH0Ebn50ebpoqKOXhN4/bu/wq596\n"
+            + "y0o4xDB0GQARAQABAAf7Bk9bQCIXo2QJAyhaFd5qh10qhu7CyRnvG/8zKMW98mWd\n"
+            + "KxF+9hNz99qZBCuiNZBLoU0dST6OG6By/3nrDxXxAgZS3cgOj/nl1NJTRWDGHPUu\n"
+            + "LywFgj7Dwu8Y2rqlDTX8lJIS+t8n+BhtkmDHoesGmFtErh8nT/CxQuHLM60qSMgv\n"
+            + "6mSmtOkM+2KfiA5z2o1fDWXjDieW+hdgDPxkaB835wfuDn/Dsn1ch1XHON0xSyTo\n"
+            + "+c35nFXoK1pAXaoalAxZNxcXCAM3NhU37Ih4GejM0K7sSgK72HmgxtNYF77DrTIM\n"
+            + "m5+3960ri1JUuEaJ7ZcqbpKxy/GDldNCYBTx07QMzQQAyYQ+ujT9Pj8zfp1jMLRs\n"
+            + "Xn9GsvYawjo+AIZuHeUmmIXfEoyNmsEUoGHnz9ROLnJzanW5XEStiTys8tHJPIkz\n"
+            + "zL0Ce0oUF93ln0z/jQBIKaSzYB7PMmYCd7ueF94aKqAOrQ/QBb+6JsVjGAtLUoTv\n"
+            + "ey09hGYMogiBV1r0MB2Rsa8EAMrB5VKVQF6+q0XuP6ljFQRaumi4lH7PoQ65E7UD\n"
+            + "6YpyQpLBOE7dV+fHizdUuwsD/wyAOu0EskV1ZLXvXzyk10r3PRoFdpHOvijwZBGt\n"
+            + "jiOiVvK1vkQKDMBczOe74+DaknKn6HzgCsXmLgfk+P8BtLOJnCYsbS9IbnImy2vi\n"
+            + "aJC3A/9wOOK+po8C7JPHVIEfxbe7nwHOoi/h7T4uPrlq/gcQRquqGhQ16nDGYZvX\n"
+            + "ny9aPQ3NcvDR69RM2AaXav03bHVxfhVEyGjP5jLZz7956e4LlnKrsuEhDLfiv30i\n"
+            + "qCC7zNHNA99s5u25vt8AuPVVHfSQ++jifabfv5lU4FHqmK8/4EAoiQElBBgBAgAP\n"
+            + "BQJMep8aAhsMBQkB4TOAAAoJEDsJuX9LQ4fu0/wH/35/22xina8ktbvGV/kB0pH2\n"
+            + "LBqeXN/bCLdA+CDzfwMDzqG0kU39EJ3Fbux7fj4uMaeiYfbO9U85+NOuDmeH41B2\n"
+            + "dM9S1AzEH+/OiCp/Zf1fdd1qXhsA4Xe5vc/VD9oso9OrZK5CM5u0TPmYFijfVDPN\n"
+            + "gag6mPnDzd8JCsuEj4VEy6NF1KcoCc8edQ8AZ4L6ZQ6qiV24gxLnh8xImVr5YjBK\n"
+            + "DUCdrl790u4wekfgapSx9Sw9Ycz5dFOL07OOHPiKZwUG0f8td6oJX4Ddxset5JAm\n"
+            + "1pPcLQHR6PRx0hI/Tz7rsAI6O37/BEM15+MVGIgOSLL/SRIpOa0L8qmuUhhS6Bg=\n"
+            + "=HTKj\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * pub 2048R/31FA48C4 2010-09-01 Key fingerprint = 85CE F045 8113 42DA 14A4 42AA 4A9F AC70 31FA
+   * 48C4 uid Testuser F &lt;testf@example.com&gt; sub 2048R/50FF7D5C 2010-09-01
+   */
+  public static TestKey keyF() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx+aQkBCADycHmQ8wC4GzaDIvw4uv6/HiTWOUmuLcT06PpsvNBdR2sQ6Vyy\n"
+            + "w1SAnaPmskdgxE7TXpDrwIWPmIkg8KzSfPAap6qZy5zyE1ABQa9yD9v6wsew+lM5\n"
+            + "3UdBO6HQodpWSJMbeR48mUQ96z72B7Lb2GvhFLxvcn5od9jQhbQXfb2k67l33hgR\n"
+            + "D427lxXa+qnmL9pMRGhRw6QATFX+icHxsPfpKnzuk0aY3feJm4+jr4RgHP4djH3i\n"
+            + "NZbv3ibZ24Dj3CK07PwbhqUhZwMqueWbo3ChYjLkRGT/UosNTN0EbHjqBMl4N9OT\n"
+            + "Pl2CM6kuzuaLz3ZeAf48B29GX4rAXfuJTKBbABEBAAG0HlRlc3R1c2VyIEYgPHRl\n"
+            + "c3RmQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pCQIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQSp+scDH6SMRfqggAh//U/l4JuwFgWx14mo0SB9YWU81L\n"
+            + "EgUYUd2MUzvX4p/HIFQa0c7stj68Za40+O0tG/J0RCMNb7piM9JFii+MQZzOVuza\n"
+            + "4bbO59D9qboc7Anvx9hGlfIdinT+n5rwX9kZvD2D7GMskm8ZgovkvNwNKcW+5W/4\n"
+            + "ciWqCJKE/Fp9XsooJgN94pJfgDQ2WBL5KDx1aGt4wZXhH2Atl6a6oVZJIH4SaizD\n"
+            + "jau7F4vc7hBfbcDhxFcrVX1QMpzpl352cIx6KVw4FRWvQ8VKkga4JiQwosfvCT2Z\n"
+            + "pdMwy3cARynv8BWLc4Uexf88QIeClP9ZhoVeMqvHMfUb3d6Q5362VdZqI4kBIAQQ\n"
+            + "AQIACgUCTH5xcgMFCngACgkQiptSk+LTK6UqsAgAlsEmzC3Xxv4o5ui95AFbWZGi\n"
+            + "es5rI9WoW2P+6OqVUy1E8+5HdlJ8wUbU1H7JAdFTjY9rH3vKXCXsTetF4z0cupER\n"
+            + "Rkx06M9/jl5OSw8i9bPNNJFobHwiiNO00ctC1tT5oUVXVsfPQHlEbMofv8jehfgC\n"
+            + "gMqH/ve/aafKFfYCZkNHugRgLzxeDpXp3IdyXoSAFGiULnGvMDN7n61QOvEYOw2Z\n"
+            + "i63ql+bL2oj4G+/bNOkdYkuIBN4F/P45P7xy80MSOvkMH7IG/aFTKMNQGWSykKwI\n"
+            + "FRkC+y+F5Oqf/WD30GvbSA7q013sb6nHYvsaHS/48cgIJ5TSVd0LTlrF9uv43bkB\n"
+            + "DQRMfmkJAQgAzc1uAF4x16Cx4GtHI0Hvm+v7bUEUtBw2XzyOKu883XC5JmGcY18y\n"
+            + "YItRpchAtmacDpu0/2925/mWF7aS9RMgSYI/1D9LaTeimISM3iGFY35kt78NGZwJ\n"
+            + "DeCPJPI1sbOU0njfrCPTbOQuRDJ6evaBNX9HYArSEp0ygruJdOUYgnepCt4A7W95\n"
+            + "EKp9KPo7XV1K8y86vrKbgpJ+NnEi7dzMqVxnhO4wAWqb6HYcKLrEc2gVnLtzHkBl\n"
+            + "Y/6dOP15jgQKql1/yQIXae/WGT24n/VeaKqrbSmDNkhW5eW5o1Bkgy/M98oNHXd0\n"
+            + "nVrT8Lyf6un5TwMy+vk0l5AjMMtIZKS0GQARAQABiQEfBBgBAgAJBQJMfmkJAhsM\n"
+            + "AAoJEEqfrHAx+kjEvDAH/iO6BHQfFa+kqjfYD3NE+FNosXv3jiXOU7SCD2MG3AwD\n"
+            + "YqM+v1n4UvvMLLdEbtboht1Btys1vuyNM3RAmR45oh9Dfuc4SKtVzSCkKs85jNvH\n"
+            + "7Ik8gxZ9ARzJbawNzTLFyLwDdcdX42Umuvh49Pn7Nc7FDYcZLffEcTh9sZ7KyxLY\n"
+            + "qcjtnblx5oOQnYnpBbM61GvgNXC8Z+g9fg0oHRouKXKE/HDKbsN0siEf9XJFJTKd\n"
+            + "Eg1NgoyKWdaV4+pU/fTzZUvvDqOSRx8he5w64dvW9o7WdARq/3vPvHgy0O8fMTSI\n"
+            + "tmcHxCU8l0jptJz181N36Uhmjyc9oC4dn9ceSn6VDbg=\n"
+            + "=WDx2\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx+aQkBCADycHmQ8wC4GzaDIvw4uv6/HiTWOUmuLcT06PpsvNBdR2sQ6Vyy\n"
+            + "w1SAnaPmskdgxE7TXpDrwIWPmIkg8KzSfPAap6qZy5zyE1ABQa9yD9v6wsew+lM5\n"
+            + "3UdBO6HQodpWSJMbeR48mUQ96z72B7Lb2GvhFLxvcn5od9jQhbQXfb2k67l33hgR\n"
+            + "D427lxXa+qnmL9pMRGhRw6QATFX+icHxsPfpKnzuk0aY3feJm4+jr4RgHP4djH3i\n"
+            + "NZbv3ibZ24Dj3CK07PwbhqUhZwMqueWbo3ChYjLkRGT/UosNTN0EbHjqBMl4N9OT\n"
+            + "Pl2CM6kuzuaLz3ZeAf48B29GX4rAXfuJTKBbABEBAAEAB/4vTP+C5s5snS6ZDlHc\n"
+            + "datvOV/hhgLYn2huiigV4A7dLCp4/bbOz+pkP51zTLQ9bn+coLYwsPq+Bfo3OY3W\n"
+            + "cXbdFHpmEEJaPqdc32ZuICcAuVEBuA1V3FTjJtHO5U02iWleMlbSZurYE9ZQZTch\n"
+            + "yotdulB7hACivENKh9OXw7ok+1GZVvBGA8tpIwzLZo0Pkb2lDQHaL0GXAjlMNzwg\n"
+            + "cCPFtzjNu6K4g58nuYrjGiE+yWPMJgfo4fTGXcapqXgvh1tKIVxwr2YQSyEOqfMH\n"
+            + "8EwgBj5NPwv0UXAivQUkTaguUJXrlJLtS3mp45nCEAlGT4PNoMyPdvPEf62gND7C\n"
+            + "y9K1BAD493ADPAx9pWCSQI9wp4ARUelTzwHgZ6fRVIzmwO6MuZN1PrtiOLCwY5Jw\n"
+            + "r+97VvMmem7Ya3khP4vz0IiN7p1oCR5nJazk2eRaQNuim0aB0lqrTsli8OXtBlgQ\n"
+            + "5WtLcRi5798Jw8coczc5OftZKhu1SbQZ1VdDdmTbMTAsSRtMjQQA+UnU6FYJZBjE\n"
+            + "NHNheV6+k45HXHubcCm4Ka3kJK88zbZzyt+nrBLEtElosxDCqT8WbiAH7qmpnd/r\n"
+            + "ly7ryIX08etuWVYnx0Xa02cKQ6TzNcbxijeGQYGHIE0RK29nRo8zRWVmbCydqJz1\n"
+            + "5cHgcvoTu7DWWjM5QEZlLPQytJeAyocEAM6AiWDXYVZVnCB9w0wwK/9cX0v3tfYv\n"
+            + "QrJZCT3/YKxJWnMZ+LgHYO0w1B0YwGEeVTnmXODDy5mRh9lxV1aZnwKCwMR1tXTx\n"
+            + "G1potBR0GJxI2xpMb/MJPxeJCAZPu8NncRpl/8v0stiGnkpYCNR/k3JV5jEXq0u6\n"
+            + "4pDSzRGehOHnOqu0HlRlc3R1c2VyIEYgPHRlc3RmQGV4YW1wbGUuY29tPokBOAQT\n"
+            + "AQIAIgUCTH5pCQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQSp+scDH6\n"
+            + "SMRfqggAh//U/l4JuwFgWx14mo0SB9YWU81LEgUYUd2MUzvX4p/HIFQa0c7stj68\n"
+            + "Za40+O0tG/J0RCMNb7piM9JFii+MQZzOVuza4bbO59D9qboc7Anvx9hGlfIdinT+\n"
+            + "n5rwX9kZvD2D7GMskm8ZgovkvNwNKcW+5W/4ciWqCJKE/Fp9XsooJgN94pJfgDQ2\n"
+            + "WBL5KDx1aGt4wZXhH2Atl6a6oVZJIH4SaizDjau7F4vc7hBfbcDhxFcrVX1QMpzp\n"
+            + "l352cIx6KVw4FRWvQ8VKkga4JiQwosfvCT2ZpdMwy3cARynv8BWLc4Uexf88QIeC\n"
+            + "lP9ZhoVeMqvHMfUb3d6Q5362VdZqI50DmARMfmkJAQgAzc1uAF4x16Cx4GtHI0Hv\n"
+            + "m+v7bUEUtBw2XzyOKu883XC5JmGcY18yYItRpchAtmacDpu0/2925/mWF7aS9RMg\n"
+            + "SYI/1D9LaTeimISM3iGFY35kt78NGZwJDeCPJPI1sbOU0njfrCPTbOQuRDJ6evaB\n"
+            + "NX9HYArSEp0ygruJdOUYgnepCt4A7W95EKp9KPo7XV1K8y86vrKbgpJ+NnEi7dzM\n"
+            + "qVxnhO4wAWqb6HYcKLrEc2gVnLtzHkBlY/6dOP15jgQKql1/yQIXae/WGT24n/Ve\n"
+            + "aKqrbSmDNkhW5eW5o1Bkgy/M98oNHXd0nVrT8Lyf6un5TwMy+vk0l5AjMMtIZKS0\n"
+            + "GQARAQABAAf/T22JFmhESUnSTOBqeK+Sd/WIOJ7lDCxVScVXwzdJINfIBYmnr2yG\n"
+            + "x18NuHOEkkEg2rx6ixksZZRcurMynZZvoB8+Xj69bpLT1JRXv8VlM0SNP6NjPW6M\n"
+            + "ygfQhzxZv8ck2WRgQxIin8SjHJv0zG9F5+1DEUyrzhZQb8dMYkqm/nbZ1FDnMu4F\n"
+            + "1qUZxKx0hU70tAXfywtpH9NQs8jwenUjiXA00k6A48BF7gartYtcGnEG9mk+Z+lh\n"
+            + "/uD+z5j3/ym9XqOJPpFIWhMYTLueSD5yrCT34VdIc1xBOjjtxBsCCbgSFZaewCpB\n"
+            + "5usRr2I4+CK3vbAMny5Hk+/RYZdFQkCA5wQA2JusdhwqPjfzxtcxz13Vu1ZzKR41\n"
+            + "kkno/boGh5afBlf7kL/5FXDhGVVvHMvXtQntU1kHgOcE8b2Jfy38gNGkd3TAh4Oj\n"
+            + "fLavcYyn+9tEkjRVdOeU0P9fszDA1cW5Gjuv6GkbCUSQrv68TKp/mWiTlYm+FT3a\n"
+            + "RSIz2gEyOZNkTzsEAPM6sU/VOwpJ2ppOa5+290sptjSbRNYjKlQ66nHZnbafzLz5\n"
+            + "tKpRc0BzG/N2lXwlVl5+3oXSSSbWhJscA8EFwSnAx8Id10zW5NAEfxNuqxxEXlJg\n"
+            + "kOhqwJ1JMz32xlZFRZYxSdXSycYrX/AhV7I7RQxgC48X9udMb8LIXYq0lzy7A/9p\n"
+            + "Skd2Me9JotuTN3OaR42hXozLx+yERBBEWuI3WXovWRD8b8gCfWL3P40d2UVnjFmP\n"
+            + "TZ8p9aHAd2srWgaPSZaSsHtIyI6dQGScMEOKEaCJxYvF/wuvx/MABDatcaJhMaAc\n"
+            + "W/0w+gb8Lr2hbuRhBSP754V3Amma6LxsmLRAwB6ioT7NiQEfBBgBAgAJBQJMfmkJ\n"
+            + "AhsMAAoJEEqfrHAx+kjEvDAH/iO6BHQfFa+kqjfYD3NE+FNosXv3jiXOU7SCD2MG\n"
+            + "3AwDYqM+v1n4UvvMLLdEbtboht1Btys1vuyNM3RAmR45oh9Dfuc4SKtVzSCkKs85\n"
+            + "jNvH7Ik8gxZ9ARzJbawNzTLFyLwDdcdX42Umuvh49Pn7Nc7FDYcZLffEcTh9sZ7K\n"
+            + "yxLYqcjtnblx5oOQnYnpBbM61GvgNXC8Z+g9fg0oHRouKXKE/HDKbsN0siEf9XJF\n"
+            + "JTKdEg1NgoyKWdaV4+pU/fTzZUvvDqOSRx8he5w64dvW9o7WdARq/3vPvHgy0O8f\n"
+            + "MTSItmcHxCU8l0jptJz181N36Uhmjyc9oC4dn9ceSn6VDbg=\n"
+            + "=ZLpl\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * pub 2048R/E2D32BA5 2010-09-01 Key fingerprint = CB2B 665B 88DA D56A 7009 C15D 8A9B 5293 E2D3
+   * 2BA5 uid Testuser G &lt;testg@example.com&gt; sub 2048R/829DAE8D 2010-09-01
+   */
+  public static TestKey keyG() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx+aRYBCAC77YjBScTjRFHtZvk0yyAy8KAXopbCdMBQs7S7iidFMMxhs0Uu\n"
+            + "D7GeleyVusLFJfEM0Ul0b0pLgfJx9j3cot4BTl71OqnawHp4ktuqFyTjhhYy8kBe\n"
+            + "4mliNP36WW7fYXh+f5SZqDQ6rgyoJCOmiUlosb6CM2yUPH3oDtOKg/9Z0iMUcXfQ\n"
+            + "y+bxRKSQmDtiSIS7hwUZmQoo30iAZNygMBLnYyVau3YFan+xyBMCFLa2/pfE0qaU\n"
+            + "QMy67XP8uP7DXlepfc4Lk/qa/2WnAqmuTT2ty9MG+X8M8LuiPuMWfOEx8ICUWB9s\n"
+            + "kCCMWCagS7EUIPhp6AOqjMqEWGOyLmclkGCJABEBAAG0HlRlc3R1c2VyIEcgPHRl\n"
+            + "c3RnQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pFgIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQiptSk+LTK6VSwQf/WnIYkLZoARZIUfH61EDlkUPv8+6G\n"
+            + "1YY3YgFFMjeOKybu47eU3QtATEaKHphvKqFtxdNyEtmti1Zx7Cq2LzReY1KoQQ5E\n"
+            + "OlKeyxVmXAuAqoRWesxuG318rVTrozCqSdKPCHLcC26M5sO+Gd2sKbA4DjoSyfrE\n"
+            + "zEOVS1NA9dtZ7WBMXr8gjH//ob7dvuptSAlADaLYYaJugcmbzkRGRbfiCQHqv30I\n"
+            + "+81d7RAeSx8XS38YEWm2IvBLpiS/d7A/2AQ25SHxf+QMMWt83+uOuEVa9rEOraid\n"
+            + "ZC6T8vnSRu1TKkX/60LnJvAw9tigmedi21O6Gpz3H3uGyjuk9o18+m8dJokBIAQQ\n"
+            + "AQIACgUCTH5xfAMFCngACgkQSp+scDH6SMT42gf9H7K0jp6PF1vD5t90bcjtnP/t\n"
+            + "CkOXgfL3lJK/l0KMkoDzyO5z898PP8IAnAj1veJ2fNPsRP903/3K8kd9/31kBriC\n"
+            + "poTVPWBmeLut16TgSDxAQPDLsBPcKe2VadhszOQwhfmdsUlCXwXcwbiAjweXwKh+\n"
+            + "00UoW1GLnPw0T387ttCjHsLe972SVUPFxb6NUkA7val62qxDKg+6MRcf6tDs8sN8\n"
+            + "orhYgh9VJcI3Iw8qK1wHI0CenNie0U5xEkZ5U6W4lfhnL5sggjoAeVeAVLiQ4eiP\n"
+            + "sFrq4TOYq9qfuThYiRaSuTLXzuWG5NVs7NyXxOGFSkwzXrQsBo+LuPwjSCERLbkB\n"
+            + "DQRMfmkWAQgA1O0I9vfZNSRuYTx++SkJccXXqL4neVWEnQ4Ws9tzfSG0Rch3Gb/d\n"
+            + "+ckDtJhlQOdaayTVX7h5k8tTGx0myg6OjG2UM6i+aTgFAzwGnBh/N3p5tTaJhRCF\n"
+            + "x1IapX0N7ijq6rQPPCISc3CUZhCVBTnp5dk3c0/hNxsyYXlI1AwuoMabygzTFN/c\n"
+            + "b1bXp0UTTVrdN+Sj5hHVDvpxyaljLa77I0V+lI3bCil9VhQ9h/TP4C2iK3ZdXOMb\n"
+            + "uW7ANhd+I9LWulmExZIiD9RIsHvB3bDu32g1847uT+DUynKETbZWlZS0Q93Aly1N\n"
+            + "lBIkvOCVCBt+VatzZ8oBV8vbk5R41W1HywARAQABiQEfBBgBAgAJBQJMfmkWAhsM\n"
+            + "AAoJEIqbUpPi0yul/doH+wR+o6UCdD6OZxGMx7d0a7yDJqQFkFf2DRsJvY2suug0\n"
+            + "CMJZRWiA+hIin5P6Brn/eb5nTdWgzlrHxkvb68YkevHALdOvmrYNQFXbb9uWGgEf\n"
+            + "3qERdI8ayJsSTqYsTqyuh9YVz21kADxTHN3JkJ4evjHpyz0Xbtq+oDADg+uswj1b\n"
+            + "ihHthFif54vNMEIW9rX9T7ufhXKamr4LuGwKTPTxV8gEPW4h4ZoQwFKV2qOjR+su\n"
+            + "tHnuXVL24kTnv8CHXUVzJXVTNz7i7fAJTgWc9drH6Ktp3XHfLDBwzT5/5ZhyxGJk\n"
+            + "Qq2Jm/Q8mNkXi34H2DeQ3VPtjtMLr9JR9pf6ivmvUag=\n"
+            + "=34GE\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOXBEx+aRYBCAC77YjBScTjRFHtZvk0yyAy8KAXopbCdMBQs7S7iidFMMxhs0Uu\n"
+            + "D7GeleyVusLFJfEM0Ul0b0pLgfJx9j3cot4BTl71OqnawHp4ktuqFyTjhhYy8kBe\n"
+            + "4mliNP36WW7fYXh+f5SZqDQ6rgyoJCOmiUlosb6CM2yUPH3oDtOKg/9Z0iMUcXfQ\n"
+            + "y+bxRKSQmDtiSIS7hwUZmQoo30iAZNygMBLnYyVau3YFan+xyBMCFLa2/pfE0qaU\n"
+            + "QMy67XP8uP7DXlepfc4Lk/qa/2WnAqmuTT2ty9MG+X8M8LuiPuMWfOEx8ICUWB9s\n"
+            + "kCCMWCagS7EUIPhp6AOqjMqEWGOyLmclkGCJABEBAAEAB/QJiwZmylg1MkL2y0Pc\n"
+            + "anQ4If//M0J0nXkmn/mNjHZyDQhT7caVkDZ01ygsck9xs3uKKxaP0xbyvqaRIvAB\n"
+            + "REQBzPkFevUlJqERfmOpP4OgCi8WZzbdmqG/WvGKxP/cWBbGVbQ2GVSNpkj+QNeO\n"
+            + "nWoc5unFstbQsEG0hww2/Hz7EppYoBvDrDLY1EPKzr0r6sk1O5gk3VWOqMEJVCh+\n"
+            + "K7EV4pPGmzMrfZQ0jSwRpr0HhzzhDYR7+QUbxr4OS5PoSJDFh0+A5kqFagyupe7A\n"
+            + "96L3Lh7wJBQJsOe5xjOu3lkFp+3vU+Mq7VzO9Fnp9BCwjb4mEjI39bJdGeeOVCWR\n"
+            + "sYEEAMjmftMhIHrjGRlbZVrLcZY8Du4CFQqImb2Tluo/6siIEurVp4F2swZFm7fw\n"
+            + "B2v09GGJ6zKpauJuxlbwo3CFnxbk24W39F/SixZLggLPtNOXdSrLIQrQ1AXu5ucQ\n"
+            + "oCnXS5FaVkD3Rtd53hSMIf2xJiSRKGp/1X9hga/phScud7URBADveDh1oEmwl3gc\n"
+            + "gorhABLYV7cPrARteQRV13tYWcuAZ6WjqNlbbW2mzBE7KTh4bgTzIX0uQ6SZ7bPl\n"
+            + "RmuKQHrdOO9vFGiSf3zDnIg8fhqSyy2SNrC/e7teuaguGCrg5GrP5izBAsiwvXbt\n"
+            + "ST3OG7c8Ky717JGTiUeTJoe4IaET+QP/SB4uQzVTrbXjBNtq1KqL/CT7l2ABnXsn\n"
+            + "psaVwHOMmY/wP+PiazMEDvLInDAu7R8oLNGqYR+7UYmYeAGmWgrc0L3yFVC01tTG\n"
+            + "bk7Yt/V5KRKVO2I9x+2CP0v0EqW4BNOJzbx5TJ5lBFLMTvbviOdsoDXw0S98HIHB\n"
+            + "T1bFFmhVeulCDLQeVGVzdHVzZXIgRyA8dGVzdGdAZXhhbXBsZS5jb20+iQE4BBMB\n"
+            + "AgAiBQJMfmkWAhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRCKm1KT4tMr\n"
+            + "pVLBB/9achiQtmgBFkhR8frUQOWRQ+/z7obVhjdiAUUyN44rJu7jt5TdC0BMRooe\n"
+            + "mG8qoW3F03IS2a2LVnHsKrYvNF5jUqhBDkQ6Up7LFWZcC4CqhFZ6zG4bfXytVOuj\n"
+            + "MKpJ0o8IctwLbozmw74Z3awpsDgOOhLJ+sTMQ5VLU0D121ntYExevyCMf/+hvt2+\n"
+            + "6m1ICUANothhom6ByZvOREZFt+IJAeq/fQj7zV3tEB5LHxdLfxgRabYi8EumJL93\n"
+            + "sD/YBDblIfF/5Awxa3zf6464RVr2sQ6tqJ1kLpPy+dJG7VMqRf/rQucm8DD22KCZ\n"
+            + "52LbU7oanPcfe4bKO6T2jXz6bx0mnQOYBEx+aRYBCADU7Qj299k1JG5hPH75KQlx\n"
+            + "xdeovid5VYSdDhaz23N9IbRFyHcZv935yQO0mGVA51prJNVfuHmTy1MbHSbKDo6M\n"
+            + "bZQzqL5pOAUDPAacGH83enm1NomFEIXHUhqlfQ3uKOrqtA88IhJzcJRmEJUFOenl\n"
+            + "2TdzT+E3GzJheUjUDC6gxpvKDNMU39xvVtenRRNNWt035KPmEdUO+nHJqWMtrvsj\n"
+            + "RX6UjdsKKX1WFD2H9M/gLaIrdl1c4xu5bsA2F34j0ta6WYTFkiIP1Eiwe8HdsO7f\n"
+            + "aDXzju5P4NTKcoRNtlaVlLRD3cCXLU2UEiS84JUIG35Vq3NnygFXy9uTlHjVbUfL\n"
+            + "ABEBAAEAB/48KLaaNJ+xhJgNMA797crF0uyiOAumG/PqfeMLMQs5xQ6OktuXsl6Q\n"
+            + "pus9mLsu8c7Zq9//efsbt1xFMmDVwPQkmAdB60DVMKc16T1C2CcFcTy25vBG4Mqz\n"
+            + "bK6rqCAJ9JSe+H2/cy78X8gF6FR6VAkSUGN62IxcyfnbkW1yv/hiowZ5pQpGVjBH\n"
+            + "sjfu+6HGZhdJIyzrjnVjTJhXNCodtKq1lQGuL2t3ZB6osOXEsFtsI6lQF2s6QZZd\n"
+            + "MUOpSO+X1Rb5TCpWpR/Yj43sH6Tq7LZWEml9fV4wKe2PQWmFW+L8eZCwbYEz6GgZ\n"
+            + "w2pMoMxxOZJsOMOq4LFs4r9qaNQI+sU1BADZhx42JjqBIUsq0OhQcCizjCbPURNw\n"
+            + "7HRfPV8SQkldzmccVzGwFIKQqAVglNdT9AQefUQzx84CRqmWaROXaypkulOB79gM\n"
+            + "R/C/aXOdWz9/dGJ9fT/gcgq1vg9zt7dPE5QIYlhmNdfQPt6R50bUTXe22N2UYL98\n"
+            + "n1pQrhAdlsbT3QQA+pWPXQE4k3Hm7pwCycM2d4TmOIfB6YiaxjMNsZiepV4bqWPX\n"
+            + "iaHh0gw1f8Av6zmMncQELKRspA8Zrj3ZzB/OvNwfpgpqmjS0LyH4u8fGttm7y3In\n"
+            + "/NxZO33omf5vdB2yptzE6DegtsvS94ux6zp01SuzgCXjQbiSjb/VDL0/A8cD/1sQ\n"
+            + "PQGP1yrhn8aX/HAxgJv8cdI6ZnrSUW+G8RnhX281dl5a9so8APchhqeXspYFX6DJ\n"
+            + "Br6MqNkX69a7jthdLZCxaa3hGInr+A/nPVkNEHhjQ8a/kI+28ChRWndofme10hje\n"
+            + "QISFfGuMf6ULK9uo4d1MzGlstfcNRecizfniKby3SBmJAR8EGAECAAkFAkx+aRYC\n"
+            + "GwwACgkQiptSk+LTK6X92gf7BH6jpQJ0Po5nEYzHt3RrvIMmpAWQV/YNGwm9jay6\n"
+            + "6DQIwllFaID6EiKfk/oGuf95vmdN1aDOWsfGS9vrxiR68cAt06+atg1AVdtv25Ya\n"
+            + "AR/eoRF0jxrImxJOpixOrK6H1hXPbWQAPFMc3cmQnh6+MenLPRdu2r6gMAOD66zC\n"
+            + "PVuKEe2EWJ/ni80wQhb2tf1Pu5+Fcpqavgu4bApM9PFXyAQ9biHhmhDAUpXao6NH\n"
+            + "6y60ee5dUvbiROe/wIddRXMldVM3PuLt8AlOBZz12sfoq2ndcd8sMHDNPn/lmHLE\n"
+            + "YmRCrYmb9DyY2ReLfgfYN5DdU+2O0wuv0lH2l/qK+a9RqA==\n"
+            + "=T1WV\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * pub 2048R/080E5723 2010-09-01 Key fingerprint = 2957 ABE4 937D A84A 2E5D 31DB 65C4 33C4 080E
+   * 5723 uid Testuser H &lt;testh@example.com&gt; sub 2048R/68C7C262 2010-09-01
+   */
+  public static TestKey keyH() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx+aSUBCADzpZ1h9awUQR1ChzrMhtoE1ltyTlJpS1G5HFEov9QNxVDTjpB8\n"
+            + "PMdb20NNdk/7g6E+ETpCBJGPoC4/TPFDiqe+UI7cRrRZJVbInkCbflYycLTUt9qW\n"
+            + "5c7IuyZA1+cSYaKp3jYccFZfIWvfTWDLWyUozTs9t1TsI28s3r5fBPvrZ+F1nYv/\n"
+            + "xpSkx3Zsxnn7QJTnd63rZdp0RdfJmF2rXERwR6XVtuLj5CqrFoLxy6OrSOl4am4J\n"
+            + "C2HRWhskB21LpdRtloY8bz0DOn6W6JUFRmSxQ1kbPClOXaiNhzMI0fD/KFnHImgR\n"
+            + "IKbbQyHHsKHBjNyn+zTIm5zUL6JMZMf9PoSZABEBAAG0HlRlc3R1c2VyIEggPHRl\n"
+            + "c3RoQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pJQIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQZcQzxAgOVyORcAf/QaHVlyhlBnU4edujW2uG/PFrZvwK\n"
+            + "fqOKW0QqQ7kVN8okKhnFv4y11IwLIzL9mOLYe2+Zyv3I3bz4X8Xw+MsBF6sMWLLf\n"
+            + "9ieu4Wz/5ScVu0PxY36kgV0AQRiLXk802Vk4t9jElCp9qx/dDln7f3879LLb3wNt\n"
+            + "fajne8EH0hjR4E3joPoG+IXSvSzWcPoZTmAZOKHPcRS8iqy0Ao8/UuQWYCedI/4R\n"
+            + "S1IJaByk8mmkMkqqV0kuPyDkvGpqhfh9zFYh97LuKcJktRTEBp3YMvuGcBDBwofG\n"
+            + "vYIVEMr7Ci5rowRQO/sxJfI1zNSWterWC46v6tOb9IvenOgP0/dQxlU82YkBIAQQ\n"
+            + "AQIACgUCTH5xmAMFAXgACgkQ0CLaOl6a7dCYuQf/V2i3Ih5Dqze0Rz5zoTD56/J7\n"
+            + "0SA4/SFm5eDUirY5B9BohkyxoMVG04uyjUmVs62ree7N0IASmeiF/wkBUZ/r/rr/\n"
+            + "0ntGj43y+1JpuSEohZOfgZJryDKRqyVWhRbeBj0g/SzxIQ1lEt2iHFvdSlfFVd+a\n"
+            + "SH1uDDjT/ZATKfAXcgeajUirWorJRaldue7O4oFe67fMLy36ewvpaMVZ+SpxH4CC\n"
+            + "Owq4Ls3dIAg2C5GQK8G0G7FwT1M26EPg66C79EGYkaxprgrilWE6l7QHc484TY1L\n"
+            + "ys04qKoPRnBinmrRxgRyyimvDN/+nd1jdM6nMe1gVLL3s5Vgo0fJMwNhDZMtdrkB\n"
+            + "DQRMfmklAQgAyajPVMt+OXO1ow7xzb0aZYNa5Xdv+w50JzVeWI0boPOuOmq6RCc1\n"
+            + "3NhOmBzx3CKH6zbSRoLBCZWM3cs1EQbl+8noaxq6YQVWiaROX8U7CThYA50jONP/\n"
+            + "qEk655QFsP8Bq96Z5AT/MflxEMayOtQywUFREF4/olhXvJOdurZfQPGnIis35NUc\n"
+            + "IaubI+gGVsluqWBohLOgqzyF7GMlv+Y2JZE5JKGSTO7ZosyI+OCNdZ6X2CJdDPZ1\n"
+            + "325QHYkmqiMJtb73AYTXurL7NNTxdxQVOnfvwXXW4mgHwPEHr8PU30+2xgo1ktrr\n"
+            + "rpFsd0o2UFhybTe7w1z2sAO1gP5s1bbGlwARAQABiQEfBBgBAgAJBQJMfmklAhsM\n"
+            + "AAoJEGXEM8QIDlcjqkQIAI78nwAgO5EgrUDoFikH6d36Kie9SHleaYcSX2c95Vqc\n"
+            + "umuiSAhaulGX0gM/jwvZkoawSyWIq+O2sPSc9F7VzdYdEnWVj2J5BpVx83TRPrTu\n"
+            + "72tsJ97op6JZz+Q8HwTLYJBmyW3/TEKh+iRL9CBtfTVywodZa58j41vCkx37NFPw\n"
+            + "plglT/Se1/US1rWYTH3Kfqo5zNARLUYzAdcxEpjwXWOvqnybn86KfMwqiOunz8eq\n"
+            + "MnTQYECfUrhX2WrbEAjCSc6/LfrTv/S+cO0rvulO/R97gG99pZdWSUjZypU5KLbp\n"
+            + "MBh0qq2wQxO2iagNXE6ms3kV/XihvCpXo9RArmldmW0=\n"
+            + "=lddL\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx+aSUBCADzpZ1h9awUQR1ChzrMhtoE1ltyTlJpS1G5HFEov9QNxVDTjpB8\n"
+            + "PMdb20NNdk/7g6E+ETpCBJGPoC4/TPFDiqe+UI7cRrRZJVbInkCbflYycLTUt9qW\n"
+            + "5c7IuyZA1+cSYaKp3jYccFZfIWvfTWDLWyUozTs9t1TsI28s3r5fBPvrZ+F1nYv/\n"
+            + "xpSkx3Zsxnn7QJTnd63rZdp0RdfJmF2rXERwR6XVtuLj5CqrFoLxy6OrSOl4am4J\n"
+            + "C2HRWhskB21LpdRtloY8bz0DOn6W6JUFRmSxQ1kbPClOXaiNhzMI0fD/KFnHImgR\n"
+            + "IKbbQyHHsKHBjNyn+zTIm5zUL6JMZMf9PoSZABEBAAEAB/wPPOigp4d9VcwxbLkz\n"
+            + "8OwiONDLz5OuY6hHCjsWMBcgTFqffI9TQc7bExW8ur1KVuNm+RdaaSQ8ZhF2YobF\n"
+            + "SV7v02R36NEfMStiDSmvv+E+stdQZXY9kT5TRgcgr5ATUXllo9DhCvKP7Qxs0Q9Q\n"
+            + "cJEcoedGVxiv0xCBLyYbVbm2sW+GJYjq0R5loaOy/Swbt5vOKQsajU8iyA4czSE8\n"
+            + "Ryr63OtwZ1TZsxekj//HKcngnptYY/FT5TPe4uzw8g1tJTIg/OZXrm8CahWzpfE3\n"
+            + "q8lGafhd0GjLftA9ffIHF0cAUs7HklMrgIKGdVPXfQmPzqDpmH5FO2y6QmqTG0v6\n"
+            + "JYW9BAD4Iobwh80MT3JZhJ0jGYMdi07cRyFN+hRwVKgNcBTdx3QGpGJatcyumD0C\n"
+            + "Yn/aXAn+XUkewSgYhdj9sSRodnWGoavdWELxUQkktsdiFg2/rnqmpqRXTGfR/tDh\n"
+            + "ohD2JaPrsavmUF6ShT3stGp8nUN+n6Bhd+QosaCZm5TC1CtA7QQA+16rrNNdP8XN\n"
+            + "MvpQRqJM5ljH0haqR/yD8vdCCZjk23hBk3YsXwSrhSbPzMeZC2FcDqkQTraTxrSG\n"
+            + "U0+xK3NjKKtbzCjQFH4cy4zdNMUX04OWopLGOEnnvTYukGtXT4lZQ9qm8ZBPh5a4\n"
+            + "cXfWy3ovjvRbxUuFOWm0gOfIoRcuWN0D/isTjqPmjihCuWkKTfa3xoq+dD7ynYhg\n"
+            + "Yu3UKfCqbNVor59ZrB4AkQiaVIDLKim3E1XDMS+IukmTuNVXpJeqK32tAYbEduHM\n"
+            + "7kwEq7SgVh34QvryKjCC/EUkDcjSQ+xlUaKl8QKYOdwtH97zZYK6QixB4uNQ6CuM\n"
+            + "75dqTZ6iQw7jQA+0HlRlc3R1c2VyIEggPHRlc3RoQGV4YW1wbGUuY29tPokBOAQT\n"
+            + "AQIAIgUCTH5pJQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQZcQzxAgO\n"
+            + "VyORcAf/QaHVlyhlBnU4edujW2uG/PFrZvwKfqOKW0QqQ7kVN8okKhnFv4y11IwL\n"
+            + "IzL9mOLYe2+Zyv3I3bz4X8Xw+MsBF6sMWLLf9ieu4Wz/5ScVu0PxY36kgV0AQRiL\n"
+            + "Xk802Vk4t9jElCp9qx/dDln7f3879LLb3wNtfajne8EH0hjR4E3joPoG+IXSvSzW\n"
+            + "cPoZTmAZOKHPcRS8iqy0Ao8/UuQWYCedI/4RS1IJaByk8mmkMkqqV0kuPyDkvGpq\n"
+            + "hfh9zFYh97LuKcJktRTEBp3YMvuGcBDBwofGvYIVEMr7Ci5rowRQO/sxJfI1zNSW\n"
+            + "terWC46v6tOb9IvenOgP0/dQxlU82Z0DmARMfmklAQgAyajPVMt+OXO1ow7xzb0a\n"
+            + "ZYNa5Xdv+w50JzVeWI0boPOuOmq6RCc13NhOmBzx3CKH6zbSRoLBCZWM3cs1EQbl\n"
+            + "+8noaxq6YQVWiaROX8U7CThYA50jONP/qEk655QFsP8Bq96Z5AT/MflxEMayOtQy\n"
+            + "wUFREF4/olhXvJOdurZfQPGnIis35NUcIaubI+gGVsluqWBohLOgqzyF7GMlv+Y2\n"
+            + "JZE5JKGSTO7ZosyI+OCNdZ6X2CJdDPZ1325QHYkmqiMJtb73AYTXurL7NNTxdxQV\n"
+            + "OnfvwXXW4mgHwPEHr8PU30+2xgo1ktrrrpFsd0o2UFhybTe7w1z2sAO1gP5s1bbG\n"
+            + "lwARAQABAAf8C3vFcrqz0Wm5ajOrqV+fZTB5uJ94jP9htengGYLPk/bMcR8qxD7H\n"
+            + "XnAi6Z6cV0DQJKDWkJVZkMYnY2ny96lA53mz9oVrH6NCLkxg+istFXVT7cDBBLdt\n"
+            + "05N3+z/+ovmiirr+YHG4Zowh2Ca4d4kl6sNhbmEvlnsZY++0B7Hi8ru2KgFBag2g\n"
+            + "wDmeVt2+ANJNfJ4uIHUEG+sDSDL4+rxQlBTMhxfVY5+zjbvzPlTf2jyAgDa5zGN2\n"
+            + "vRjB33Z0lbdZTeW7HsJcDsXaS77lKnQeWMmHSvpOXvFSIjnrWpxcMpg8hGY5e5UC\n"
+            + "zLCk+nucY/Od1NbtFYu/e7fl9/n3YnT7AQQA0v/t43Ut3go9vRlb47NN/KpJYL1N\n"
+            + "hh9F/SRzFwWxS+79CiZkf/bgmdJe4XkkS7QJMv+nXhtcko/gfzoaCrvIWIAyvhYa\n"
+            + "7tEbqH+iZ0eaLrQf7bu89Jmp2UNRT1EHLzm38eJ8gg7eNu+SjIhs3wART1KB7GvT\n"
+            + "YmpN5caJA2t2OaEEAPSq7CbvlPDc0qomQSs+NrDnhAv89mQEeksZRmhVa0o4Z7EO\n"
+            + "84DzM+Vxho5fn9h0LtxthhuKWKT8uYN/Qu4Y42cKQuRgMx09+GGwc4GWSC6gJPeP\n"
+            + "oKVJCdZx0l9u8fWQb37gnyH34WDxPvdQx3e4iw/dvruNzu17zmPndkdcyEU3BACD\n"
+            + "yXo21SEflFcfrO16VsITXWc9yweKTSD8Mq7wg2GG6eJPopgtwCLZSlYjnehxD2w2\n"
+            + "38lyr6jGPyITvalVwH6R//676Q2osbQ948Dv2ZcxaTlyla4RyY6E33hsnV9m8ZmM\n"
+            + "PUoNJvFSkKCuPy1N5zaYgUAPKwbEkc3qG+bZm+x2WU2biQEfBBgBAgAJBQJMfmkl\n"
+            + "AhsMAAoJEGXEM8QIDlcjqkQIAI78nwAgO5EgrUDoFikH6d36Kie9SHleaYcSX2c9\n"
+            + "5VqcumuiSAhaulGX0gM/jwvZkoawSyWIq+O2sPSc9F7VzdYdEnWVj2J5BpVx83TR\n"
+            + "PrTu72tsJ97op6JZz+Q8HwTLYJBmyW3/TEKh+iRL9CBtfTVywodZa58j41vCkx37\n"
+            + "NFPwplglT/Se1/US1rWYTH3Kfqo5zNARLUYzAdcxEpjwXWOvqnybn86KfMwqiOun\n"
+            + "z8eqMnTQYECfUrhX2WrbEAjCSc6/LfrTv/S+cO0rvulO/R97gG99pZdWSUjZypU5\n"
+            + "KLbpMBh0qq2wQxO2iagNXE6ms3kV/XihvCpXo9RArmldmW0=\n"
+            + "=voB9\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * pub 2048R/5E9AEDD0 2010-09-01 Key fingerprint = 818D 5D0B 4AE2 A4FE A4C3 C44D D022 DA3A 5E9A
+   * EDD0 uid Testuser I &lt;testi@example.com&gt; sub 2048R/0884E452 2010-09-01
+   */
+  public static TestKey keyI() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx+aTEBCAC6dFperkew4ZowIfEyAjScjPBggcbw5XUXxLCF0nBRjWH+HvuI\n"
+            + "CGwznRyeuTiy5yyB9/CcvTLTkEs8qIyJUJoikm7QpaVVL6imVq1HD1xcOJpV1FV1\n"
+            + "eFu562xCRDUqD6KQf54N04V9TMDyubhPkQYbx1H2gq+uBEo9d1w6AsSMgaUn3xH/\n"
+            + "xYe+INxcP6jFT2OKc36x+8ipP6pc8Hba1X90JwadOcJlwEyJfJKs7hYHTaYn+I6+\n"
+            + "4w0Y//WebhT4ocsYIiOYrENQUcic+vL3fkwwJCloyDBCGxr7w7Gn4Pe3peTCl4Sp\n"
+            + "vIIoYnHPW4h3Nyh8qAlBNDw7dCPS9LP7wRdNABEBAAG0HlRlc3R1c2VyIEkgPHRl\n"
+            + "c3RpQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pMQIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQ0CLaOl6a7dAjNQf/fLmGeKgaesawP53UeioQ8hgDEFKP\n"
+            + "BddNQP248NpReZ1rg3h8Q21PQJVKrtDYn94WJi5NTqUtk9rtx9SiqKaEc3wzIpLc\n"
+            + "nIYrgGLWot5nq+5V1nY9t9QAiJJDrmm2/3tX+jTWW6CpuLih7IsD+gJmpZkY6PfM\n"
+            + "T+teKEeh5E1XBbu10fwDwMJta+043/TiljInjER1f/b41EnSjI6YXFnxnyiLeDgD\n"
+            + "A1QIIzB/W2ccGqphzJriDETDJhKFZIeqvjylZofgCLyMRSyZtsu+b4pfBK3hMpu5\n"
+            + "aaYylaM1BWOpAiqUmGUKqxN/o9EGx4wvsMxK6xgiZe5UdQPaoDcFCsEMg4kBIAQQ\n"
+            + "AQIACgUCTH5xrAMFAXgACgkQoTk8RsLmoZiu2Af8D4PnyWkosYYkcmU4T7CvIHGW\n"
+            + "Qnx4KsnYWaAqYrYrorL6R+f8SZ5caGwj05UOvHnqx/Ij0a1Zv4MpEuzB0se1XkyQ\n"
+            + "eCLdAIKVodfiepsCHyqW6/mc9LV2qKS1HF5x5LwDkI1atOuPt/O14fch4E0beTbl\n"
+            + "FXzGo7YdpH8RunV8l+i3FxxTcUtUkij3Ro4EMwVF/6YG8gBOd08GxWspEQWBH3GK\n"
+            + "k7Repj4IPwXCoEfU1H+XJNPaM5cnt+L87QfbhNOWmHmWhhrOmZg160joODON8w8x\n"
+            + "j3gma9Cp6luPDEQC3XnsEup3BdCdIciG5JS6JA/2GDeulg+eS4x9Xkmmp6nzObkB\n"
+            + "DQRMfmkxAQgAxeT+bUBbADga+lYtkmtYVbuG7uWjwdg9TR6qWKD7n37mcu6OgNNl\n"
+            + "rPaHoClvOL20fcArZ8wT/FbjvDI6ZHn22YA19OvAR+Eqmf3D7qTmebchnCu955Pk\n"
+            + "X7AOOpKfX48qoYq8BoskZDnbFidm5YKfIin3CNDdlQbd3na+ihGCuv0KoGzefuAH\n"
+            + "cITeYEUESh7HLzQ9/pMES9eCgdTEkwYD5NJjfkLnj2kZtDsSiNnENZ0TIlyKOBMn\n"
+            + "ixgsARDjLrkqyTg79thWALiqVBXUKn2NBtMkK5xTDc/7q3nIw4InYMIrLtntSu1w\n"
+            + "pn1gXbdg1HFl5BgqEB9Fp0k02YvrSiiVswARAQABiQEfBBgBAgAJBQJMfmkxAhsM\n"
+            + "AAoJENAi2jpemu3QFPoH/1ynX1j1QWL8TfJFPoB3vXivwGURs3J7LsywHTRjpQVQ\n"
+            + "vxQvKTzB1+woUxtEbdjKgMbvY/ShHSlEZKVV9l3ZihrNewHA1GMHrDtBGXcNRP9B\n"
+            + "RfJHTrDzjUxrEEwu4QIq71o4tS89NvQmlYYi7O4ThtVB4hYSwl436+vAT9ybIQkU\n"
+            + "OjCkYrKye6JHs1K4BnVuWcOVujQwW4H8QFbddcWF49uN6DSqrwDFsjFog6wL7u6V\n"
+            + "UL5upRBP/RZWA4HKJVF2tS0Ptr6xLTmf4Tp5n10CGFYkPcRp9biVyeVRJBW4uZf0\n"
+            + "EDsn9J5rNG0pWtgnhAEi6smoT4fADTOzpOovUiTSQhQ=\n"
+            + "=SiG3\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx+aTEBCAC6dFperkew4ZowIfEyAjScjPBggcbw5XUXxLCF0nBRjWH+HvuI\n"
+            + "CGwznRyeuTiy5yyB9/CcvTLTkEs8qIyJUJoikm7QpaVVL6imVq1HD1xcOJpV1FV1\n"
+            + "eFu562xCRDUqD6KQf54N04V9TMDyubhPkQYbx1H2gq+uBEo9d1w6AsSMgaUn3xH/\n"
+            + "xYe+INxcP6jFT2OKc36x+8ipP6pc8Hba1X90JwadOcJlwEyJfJKs7hYHTaYn+I6+\n"
+            + "4w0Y//WebhT4ocsYIiOYrENQUcic+vL3fkwwJCloyDBCGxr7w7Gn4Pe3peTCl4Sp\n"
+            + "vIIoYnHPW4h3Nyh8qAlBNDw7dCPS9LP7wRdNABEBAAEAB/oCD6EKLvjXgItlqdm/\n"
+            + "X+OWMYHDCtuRCMW7+2gEw/TxfLeGJaOHWxAouwUIArEEb/hjdaRfIg4wdJUxmyPX\n"
+            + "WyNqUdupkjdXNa7RNaesIi0ilrdZOn7NlHWJCCXwKt2R0jd2p8PDED6CWaE1+76I\n"
+            + "/IuwOHDTD8MABke3KvHDXMxjzdeuRbm670Aqz6zTVY+BZG1GH63Ef5JEyezMgAU5\n"
+            + "42+v+OgD0W0/jCxF7jt2ddP9QiOzu0q65mI4qlOuSebxjH8P7ye0LU9EuWVgAcwc\n"
+            + "YJh2lk3eH8bCWTwlIHj4+8MYgY5i510I5xfY3sWuylw/qtFP9vYjisrysadcUExc\n"
+            + "QUxFBADXQSCmvtgRoSLiGfQv2y2qInx67eJw8pUXFEIJKdOFOhX4vogT9qPWQAms\n"
+            + "/vSshcsAPgpZJZ8MNeGpMGLAGm8y4D2zWWd9YLNmVXsPu7EyrDpXlKHCFnsQfOGN\n"
+            + "c5j8u4CHBn1cS/Yk53S+6Yge2jvnOjVNFmxB0ocs0Y5zbdTJYwQA3b+hQebH7NNr\n"
+            + "FlPwthRZS0TiX5+qkE9tE/0mpRrUN3iS9bnF0IXRmHFp7Hz+EsVbA2Re2A5HIHnQ\n"
+            + "/BSpAsSHRhjU3MH4gzwfg9W43eZGVfofSY6IlUCIcd1bGjSAjJgmfhjU7ofS59i/\n"
+            + "DjzP1jBfXdjOEUQULTkXjHPqO7j4048D/jqMwZNY3AawTMjqKr9nGK49aWv/OVdy\n"
+            + "6xGn4dRJNk3gnnIvjAEFy5+HHbUCJ2lA3X2AssQ9tvbuyDnoSL5/G+zEYtyRuAC5\n"
+            + "9TLQQRmy4qjsYC5TwfoUwFbgqRsmGUcjj2wtE+gb1S8P/zudYrEqOD3K60Y5qXcn\n"
+            + "S3PHgJ++5TzFQba0HlRlc3R1c2VyIEkgPHRlc3RpQGV4YW1wbGUuY29tPokBOAQT\n"
+            + "AQIAIgUCTH5pMQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ0CLaOl6a\n"
+            + "7dAjNQf/fLmGeKgaesawP53UeioQ8hgDEFKPBddNQP248NpReZ1rg3h8Q21PQJVK\n"
+            + "rtDYn94WJi5NTqUtk9rtx9SiqKaEc3wzIpLcnIYrgGLWot5nq+5V1nY9t9QAiJJD\n"
+            + "rmm2/3tX+jTWW6CpuLih7IsD+gJmpZkY6PfMT+teKEeh5E1XBbu10fwDwMJta+04\n"
+            + "3/TiljInjER1f/b41EnSjI6YXFnxnyiLeDgDA1QIIzB/W2ccGqphzJriDETDJhKF\n"
+            + "ZIeqvjylZofgCLyMRSyZtsu+b4pfBK3hMpu5aaYylaM1BWOpAiqUmGUKqxN/o9EG\n"
+            + "x4wvsMxK6xgiZe5UdQPaoDcFCsEMg50DmARMfmkxAQgAxeT+bUBbADga+lYtkmtY\n"
+            + "VbuG7uWjwdg9TR6qWKD7n37mcu6OgNNlrPaHoClvOL20fcArZ8wT/FbjvDI6ZHn2\n"
+            + "2YA19OvAR+Eqmf3D7qTmebchnCu955PkX7AOOpKfX48qoYq8BoskZDnbFidm5YKf\n"
+            + "Iin3CNDdlQbd3na+ihGCuv0KoGzefuAHcITeYEUESh7HLzQ9/pMES9eCgdTEkwYD\n"
+            + "5NJjfkLnj2kZtDsSiNnENZ0TIlyKOBMnixgsARDjLrkqyTg79thWALiqVBXUKn2N\n"
+            + "BtMkK5xTDc/7q3nIw4InYMIrLtntSu1wpn1gXbdg1HFl5BgqEB9Fp0k02YvrSiiV\n"
+            + "swARAQABAAf/VXp4O5CUvh9956vZu2kKmt2Jhx9CALT6pZkdU3MVvOr/d517iEHH\n"
+            + "pVJHevLqy8OFdtvO4+LOryyI6f14I3ZbHc+3frdmMqYb1LA8NZScyO5FYkOyn5jO\n"
+            + "CFbvjnVOyeP5MhXO6bSoX3JuI7+ZPoGRYxxlTDWLwJdatoDsBI9TvJhVekyAchTH\n"
+            + "Tyt3NQIvLXqHvKU/8WAgclBKeL/y/idep1BrJ4cIJ+EFp0agEG0WpRRUAYjwfE3P\n"
+            + "aSEV0NOoB8rapPW3XuEjO+ZTht+NYvqgPIdTjwXZGFPYnwvEuz772Th4pO3o/PdF\n"
+            + "2cljvRn3qo+lSVnJ0Ki2pb+LukJSIdfHgQQA1DBdm29a/3dBla2y6wxlSXW/3WBp\n"
+            + "51Vpd8SBuwdVrNNQMwPmf1L93YskJnUKSTo7MwgrYZFWf7QzgfD/cHXr8QK2C1TP\n"
+            + "czUC0/uFCm8pPQoOt/osp3PjDAzGgUAMFXCgLtb04P2JqbFvtse5oTFWrKqmscTG\n"
+            + "KnEBkzfgy37U0iMEAO7BEgXCYvqyztHmQATqJfbpxgQGqk738UW6qWwG8mK6aT5V\n"
+            + "OidZvrWqJ3WeIKmEhoJlY2Ky1ZTuJfeQuVucqzNWlZy2yzDijs+t3v4pFGajv4nV\n"
+            + "ivGvlb/O/QoHBuF/9K36lIIqcZstfa2UIYRqkkdEz2JHWJsr81VvCw2Gb38xA/sG\n"
+            + "hqErrIgSBPRCJObM/gb9rJ6dbA5SNY5trc778EjS1myhyPhGOaOmYbdQMONUqLo2\n"
+            + "q1UZo1G7oaI1Um9v5MXN1yZNX/kvx1TMldZEEixrhCIob81eXSpEUfs+Mz2RqvqT\n"
+            + "YsYquYQNPrPXWZQwTJV6fpsBQUMeE/pmlisaSAijHkXPiQEfBBgBAgAJBQJMfmkx\n"
+            + "AhsMAAoJENAi2jpemu3QFPoH/1ynX1j1QWL8TfJFPoB3vXivwGURs3J7LsywHTRj\n"
+            + "pQVQvxQvKTzB1+woUxtEbdjKgMbvY/ShHSlEZKVV9l3ZihrNewHA1GMHrDtBGXcN\n"
+            + "RP9BRfJHTrDzjUxrEEwu4QIq71o4tS89NvQmlYYi7O4ThtVB4hYSwl436+vAT9yb\n"
+            + "IQkUOjCkYrKye6JHs1K4BnVuWcOVujQwW4H8QFbddcWF49uN6DSqrwDFsjFog6wL\n"
+            + "7u6VUL5upRBP/RZWA4HKJVF2tS0Ptr6xLTmf4Tp5n10CGFYkPcRp9biVyeVRJBW4\n"
+            + "uZf0EDsn9J5rNG0pWtgnhAEi6smoT4fADTOzpOovUiTSQhQ=\n"
+            + "=RcWw\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  /**
+   * pub 2048R/C2E6A198 2010-09-01 Key fingerprint = 83AB CE4D 6845 D6DA F7FB AA47 A139 3C46 C2E6
+   * A198 uid Testuser J &lt;testj@example.com&gt; sub 2048R/863E8ABF 2010-09-01
+   */
+  public static TestKey keyJ() throws Exception {
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx+aUgBCADczNio9UWnUggUZkdqJye57497oD9vNo9rmtR+i1TkMpVeWaMH\n"
+            + "UWrm1twzIPV9D4lAWLJoG2cYF6nXG1JxsKc9mOIZ6O1WfsopMUU0p+EfU8H/cvdM\n"
+            + "/iccYS6OnNL4/xR1R7hlA4+b/jaOZfzdS3i5jwOf+TtCk7c5qOuFhraiVQ9H1G86\n"
+            + "+LsiWVeXEFc/FXxnESRmbaZFNJrAdJl23eKXRC6az0S5FwMVBvUhRpLwIGDbVT/0\n"
+            + "/QwaNUOq3bYwudPREFLg/1HtBuxNhRdV6mCrit+tsPan9o0/WtsHuq8n4/pqOKBc\n"
+            + "RRmOIQR9SEohE2TuVT3XVFpMXa4a4CBuNXjTABEBAAG0HlRlc3R1c2VyIEogPHRl\n"
+            + "c3RqQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pSAIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQoTk8RsLmoZi0BggAlnbCwmwaLwcpU9YcOE9/8KF56dIs\n"
+            + "XhdxzqdP91UmhVT0df1OBhgTqFkKprBLCT+B9yBClsnyXMatkvuhQG6C7lw9toMO\n"
+            + "TITRPZoFJe3Ezi+HRRPqAPubIcSgeILuilvFhkoUOgoC1ubmVPgcGBLb8tdvI3bA\n"
+            + "svq+n2jaYUlgL5N6ZNRNakc07e8vH5SeKiD8ZntJlTU49fkxzlawtDaI3+GhyUiB\n"
+            + "0Ah8pl143DFNAq8CfvQCPKwX4WFPkEflh0LlgaEPJUZ/H6KxKXXF8SC9cD2VIii8\n"
+            + "Yrue8y9T+j5y699A0GCptb1IKrgxbfhgD//3g3l1eXsEwn2cwFNCt7pZFLkBDQRM\n"
+            + "fmlIAQgA3E2pM6oDJGgfxbqSfykuRtTbiAi7JEd1DNvEe6gJ7qkBLM4ipILBD0qR\n"
+            + "qCwL37E4/3nMsZjA7GIFLQj2DrFW3aEEKwR/zdh7R67lo9CunCY+FPWTuOkCG8Sh\n"
+            + "3RLpbAV6I61NG/wDznW30vmKNJDgPpkzYj8u0T4MtpywEgxTxCqWZKCufWDRfNAy\n"
+            + "IBLt+piG+bcYKfw9pS8PvXPQMNIi4U2pu3hb/BHC3Y1A8FVpEe4CFV7rWb/K2Ydx\n"
+            + "eBxwwxm9sBxF+vhlI+ZEeb9JxGH6jYlc6twD4e6p3KqusAKLKiLsS5uLQnpMGGZ8\n"
+            + "vcpTSfyHjG2QHc3qG9S/yDCZjhhe2QARAQABiQEfBBgBAgAJBQJMfmlIAhsMAAoJ\n"
+            + "EKE5PEbC5qGYClMIANTdZ+/g/FPl1Lm0tO1CSnHVHekeGNA9n3L6SGiSZQJjEDo0\n"
+            + "gsye5xgxh5JGKf7CqbEFfeLC9Jx5W5EN4dVFudncIlC/SutfRzdt5W8CLXMl0c41\n"
+            + "5FmtpWNStk3MglkcjE5PrRRrSiRc45S0e2kIPb8eiVKg98/rCToC9+Qn3pMi/fcM\n"
+            + "LVpYE+dhvB5EhOSwBWWgvWXzeLhv5CnBKxH0ItHhNwvt8qPOHgQAJKPc6dV888xn\n"
+            + "Sew62LFefHPnGTHLP8RRgVIvZBG5IoovxSz89QGHQZiC4xv00I7zNwmtr6eEcl+y\n"
+            + "BkUK9QWITEBHUDqR+cbVa2dRr3fUHwRP7G2G+ow=\n"
+            + "=ucAX\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
+        "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx+aUgBCADczNio9UWnUggUZkdqJye57497oD9vNo9rmtR+i1TkMpVeWaMH\n"
+            + "UWrm1twzIPV9D4lAWLJoG2cYF6nXG1JxsKc9mOIZ6O1WfsopMUU0p+EfU8H/cvdM\n"
+            + "/iccYS6OnNL4/xR1R7hlA4+b/jaOZfzdS3i5jwOf+TtCk7c5qOuFhraiVQ9H1G86\n"
+            + "+LsiWVeXEFc/FXxnESRmbaZFNJrAdJl23eKXRC6az0S5FwMVBvUhRpLwIGDbVT/0\n"
+            + "/QwaNUOq3bYwudPREFLg/1HtBuxNhRdV6mCrit+tsPan9o0/WtsHuq8n4/pqOKBc\n"
+            + "RRmOIQR9SEohE2TuVT3XVFpMXa4a4CBuNXjTABEBAAEAB/9sW1MQR53xKP6yFCeD\n"
+            + "3sdOJlSB1PiMeXgU1JznpTT58CEBdnfdRYVy14qkxM30m8U9gMm88YW8exBscgoZ\n"
+            + "pRnNztNW58phokNPx9AwsRp3p0ETPbZDYI6NDNwuPKQEchn2HEZPvFmjsjPP2hkn\n"
+            + "+Lu8RIUA4uzEFX3bnBxJIP1L2AztqyTgHDfXS4/nqerO/cheXhN7j1TUyRO4hinp\n"
+            + "C3WXaxm2kpQXFP2ktq2eu7YPFoW6I6HzHVDN2Z7fD/NzfmR2h4gcIaSDEjIs893N\n"
+            + "b3hsYiOTYwVFX9TBWLr9rSWyrjR4sWelFuMZpjQ53qq+rBm/+8knoNtoWgZFhbR0\n"
+            + "WJyRBADlBuX8kveqLl31QShgw+6TwTHXI40GiCA6DHwZiTstOO6d2KDNq2nHdtuo\n"
+            + "HBvSKYP4a2na39JKb7YfuSMg16QvxQNd7BQWz+NzbGLQEGuX455OD3TE74ZfVElo\n"
+            + "2H/i51hSjOdWihJVNBGlcDYPgb7oLLTbPdKXxptRM1+wrk2//QQA9s3pw2O3lSbV\n"
+            + "U8JyL/FhdyhDvRDuiNBPnB4O/Ynnzz8YSFwSdSE/u8FpguFWdh+UdSrdwE+Ux8kj\n"
+            + "W/miXaqTxUeKnpzOkiO5O2fLvAeriO3rU9KfBER03+NJo4weSorLXzeU4SWkw63N\n"
+            + "OiY3fc67Nj+l8qi1tmoEJyHUomuy7Q8EAOfBvMzGsQQJ12k+4gOSXN9DTWUa85P6\n"
+            + "IphFHC2cpTDy30IRR55sI6Mf3GpC+KzxEyw7WXjlTensEJAHMpyVVRhv6uF0eMaY\n"
+            + "+QGS+vyCgtUfGIwM5Teu6NjeqyShJDTC8qnM+75JgCNu6gZ2F2iTeY+tM3zE1auq\n"
+            + "po1pUACVm7qwR6u0HlRlc3R1c2VyIEogPHRlc3RqQGV4YW1wbGUuY29tPokBOAQT\n"
+            + "AQIAIgUCTH5pSAIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQoTk8RsLm\n"
+            + "oZi0BggAlnbCwmwaLwcpU9YcOE9/8KF56dIsXhdxzqdP91UmhVT0df1OBhgTqFkK\n"
+            + "prBLCT+B9yBClsnyXMatkvuhQG6C7lw9toMOTITRPZoFJe3Ezi+HRRPqAPubIcSg\n"
+            + "eILuilvFhkoUOgoC1ubmVPgcGBLb8tdvI3bAsvq+n2jaYUlgL5N6ZNRNakc07e8v\n"
+            + "H5SeKiD8ZntJlTU49fkxzlawtDaI3+GhyUiB0Ah8pl143DFNAq8CfvQCPKwX4WFP\n"
+            + "kEflh0LlgaEPJUZ/H6KxKXXF8SC9cD2VIii8Yrue8y9T+j5y699A0GCptb1IKrgx\n"
+            + "bfhgD//3g3l1eXsEwn2cwFNCt7pZFJ0DmARMfmlIAQgA3E2pM6oDJGgfxbqSfyku\n"
+            + "RtTbiAi7JEd1DNvEe6gJ7qkBLM4ipILBD0qRqCwL37E4/3nMsZjA7GIFLQj2DrFW\n"
+            + "3aEEKwR/zdh7R67lo9CunCY+FPWTuOkCG8Sh3RLpbAV6I61NG/wDznW30vmKNJDg\n"
+            + "PpkzYj8u0T4MtpywEgxTxCqWZKCufWDRfNAyIBLt+piG+bcYKfw9pS8PvXPQMNIi\n"
+            + "4U2pu3hb/BHC3Y1A8FVpEe4CFV7rWb/K2YdxeBxwwxm9sBxF+vhlI+ZEeb9JxGH6\n"
+            + "jYlc6twD4e6p3KqusAKLKiLsS5uLQnpMGGZ8vcpTSfyHjG2QHc3qG9S/yDCZjhhe\n"
+            + "2QARAQABAAf7BUTPxk/u/vi935DpBXoXRKHZnLM3bFuIexCGQ74rQqR2qazUMH8o\n"
+            + "SFEsaBJpm2WyR47J5WqSHNi5SxPT2AUdNFeh/39hxY61Q6SuBFED+WMRbHrKbURR\n"
+            + "WjPiFuwus02eAkAYFWfBFY0n9/BcAhicQa90MTRj+RZb/EHa+GDdbgDatpwEK22z\n"
+            + "pPb3t/D2TC7ModizelngBN7bdp4Vqna/vMLhsiE+FqL+Ob0KiLkDxtcjZljc9xLK\n"
+            + "B7ZuGH/AZfhF08OAxUcsJdu5cF3viBT+HeSI4OUvdfxPFX98U/SFfuW4mPdHPEI9\n"
+            + "438pdjDUIpJFtcnROtZdS2o6C9ohHa5BUwQA52P8AKKRfg7LpaFMvtKkNORnscac\n"
+            + "1qvXLqAXaMeSsvyU5o1GNvSgbhFzDcXbAFJcXdOo2XgT7JzW/6v1uW9AuQPAkYhr\n"
+            + "ep0uE3mewlzWHZR41MQRaMGN4l80RN6ju4c/Ei+OMHYp2DUfZFDBXbxwWpN8tNoR\n"
+            + "S1X+rOL5RsQgkrcEAPO7zthR+GQnIgJC3c9Las9JkPywCxddjoWZoyt6yITVjIso\n"
+            + "IGD0SJppAkOS3Vdb+raydLuN7HmbpPFnvzyc+RdSt+YCGUObrHb/z9MfahzDNG3S\n"
+            + "VwUQEIl+L6glhwscQOCz80MCcYMFMk4TiankvChRFF5Wil//8QnaonH4bcrvA/46\n"
+            + "VB+ZaEdR+Z8IkYIf7oHLJNEwaH+kRTBQ2x5F9Gnwr9SL6AXAkNkvYD4in/+Bw35r\n"
+            + "o9zGirQQvNrvH3JlZ5PWp1/9rRl2Tefaaf8P2ij/Ky2poBLAhPwK56JXHLt5v+BZ\n"
+            + "mQwhY+teJnbfCwiiS0OeWtpVY/tDVU7wYOd2RIhVfkUziQEfBBgBAgAJBQJMfmlI\n"
+            + "AhsMAAoJEKE5PEbC5qGYClMIANTdZ+/g/FPl1Lm0tO1CSnHVHekeGNA9n3L6SGiS\n"
+            + "ZQJjEDo0gsye5xgxh5JGKf7CqbEFfeLC9Jx5W5EN4dVFudncIlC/SutfRzdt5W8C\n"
+            + "LXMl0c415FmtpWNStk3MglkcjE5PrRRrSiRc45S0e2kIPb8eiVKg98/rCToC9+Qn\n"
+            + "3pMi/fcMLVpYE+dhvB5EhOSwBWWgvWXzeLhv5CnBKxH0ItHhNwvt8qPOHgQAJKPc\n"
+            + "6dV888xnSew62LFefHPnGTHLP8RRgVIvZBG5IoovxSz89QGHQZiC4xv00I7zNwmt\n"
+            + "r6eEcl+yBkUK9QWITEBHUDqR+cbVa2dRr3fUHwRP7G2G+ow=\n"
+            + "=NiQI\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
+  }
+
+  private TestTrustKeys() {}
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/AdvertisedObjectsCacheKey.java b/java/com/google/gerrit/httpd/AdvertisedObjectsCacheKey.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/AdvertisedObjectsCacheKey.java
rename to java/com/google/gerrit/httpd/AdvertisedObjectsCacheKey.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/AllRequestFilter.java b/java/com/google/gerrit/httpd/AllRequestFilter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/AllRequestFilter.java
rename to java/com/google/gerrit/httpd/AllRequestFilter.java
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
new file mode 100644
index 0000000..d7cbdb8
--- /dev/null
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -0,0 +1,41 @@
+java_library(
+    name = "httpd",
+    srcs = glob(["**/*.java"]),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/httpd"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/launcher",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/util/cli",
+        "//java/com/google/gerrit/util/http",
+        "//java/com/google/gwtexpui/linker:server",
+        "//java/com/google/gwtexpui/server",
+        "//java/org/eclipse/jgit:server",
+        "//lib:args4j",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtorm",
+        "//lib:jsch",
+        "//lib:servlet-api-3_1",
+        "//lib:soy",
+        "//lib/auto:auto-value",
+        "//lib/commons:codec",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
rename to java/com/google/gerrit/httpd/CacheBasedWebSession.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CanonicalWebUrl.java b/java/com/google/gerrit/httpd/CanonicalWebUrl.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/CanonicalWebUrl.java
rename to java/com/google/gerrit/httpd/CanonicalWebUrl.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ContainerAuthFilter.java b/java/com/google/gerrit/httpd/ContainerAuthFilter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/ContainerAuthFilter.java
rename to java/com/google/gerrit/httpd/ContainerAuthFilter.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CookieBase64.java b/java/com/google/gerrit/httpd/CookieBase64.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/CookieBase64.java
rename to java/com/google/gerrit/httpd/CookieBase64.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/DirectChangeByCommit.java b/java/com/google/gerrit/httpd/DirectChangeByCommit.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/DirectChangeByCommit.java
rename to java/com/google/gerrit/httpd/DirectChangeByCommit.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GetUserFilter.java b/java/com/google/gerrit/httpd/GetUserFilter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/GetUserFilter.java
rename to java/com/google/gerrit/httpd/GetUserFilter.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java b/java/com/google/gerrit/httpd/GitOverHttpModule.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java
rename to java/com/google/gerrit/httpd/GitOverHttpModule.java
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
new file mode 100644
index 0000000..4d472da
--- /dev/null
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -0,0 +1,424 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.UploadPackInitializer;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
+import com.google.gerrit.server.git.validators.UploadValidators;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.http.server.GitServlet;
+import org.eclipse.jgit.http.server.GitSmartHttpTools;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.http.server.resolver.AsIsFileService;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.PostUploadHook;
+import org.eclipse.jgit.transport.PostUploadHookChain;
+import org.eclipse.jgit.transport.PreUploadHook;
+import org.eclipse.jgit.transport.PreUploadHookChain;
+import org.eclipse.jgit.transport.ReceivePack;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+import org.eclipse.jgit.transport.UploadPack;
+import org.eclipse.jgit.transport.resolver.ReceivePackFactory;
+import org.eclipse.jgit.transport.resolver.RepositoryResolver;
+import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
+import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
+import org.eclipse.jgit.transport.resolver.UploadPackFactory;
+
+/** Serves Git repositories over HTTP. */
+@Singleton
+public class GitOverHttpServlet extends GitServlet {
+  private static final long serialVersionUID = 1L;
+
+  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";
+
+  public static final String URL_REGEX;
+
+  static {
+    StringBuilder url = new StringBuilder();
+    url.append("^(?:/a)?(?:/p/|/)(.*/(?:info/refs");
+    for (String name : GitSmartHttpTools.VALID_SERVICES) {
+      url.append('|').append(name);
+    }
+    url.append("))$");
+    URL_REGEX = url.toString();
+  }
+
+  static class Module extends AbstractModule {
+
+    private final boolean enableReceive;
+
+    Module(boolean enableReceive) {
+      this.enableReceive = enableReceive;
+    }
+
+    @Override
+    protected void configure() {
+      bind(Resolver.class);
+      bind(UploadFactory.class);
+      bind(UploadFilter.class);
+      bind(new TypeLiteral<ReceivePackFactory<HttpServletRequest>>() {})
+          .to(enableReceive ? ReceiveFactory.class : DisabledReceiveFactory.class);
+      bind(ReceiveFilter.class);
+      install(
+          new CacheModule() {
+            @Override
+            protected void configure() {
+              cache(ID_CACHE, AdvertisedObjectsCacheKey.class, new TypeLiteral<Set<ObjectId>>() {})
+                  .maximumWeight(4096)
+                  .expireAfterWrite(10, TimeUnit.MINUTES);
+            }
+          });
+    }
+  }
+
+  @Inject
+  GitOverHttpServlet(
+      Resolver resolver,
+      UploadFactory upload,
+      UploadFilter uploadFilter,
+      ReceivePackFactory<HttpServletRequest> receive,
+      ReceiveFilter receiveFilter) {
+    setRepositoryResolver(resolver);
+    setAsIsFileService(AsIsFileService.DISABLED);
+
+    setUploadPackFactory(upload);
+    addUploadPackFilter(uploadFilter);
+
+    setReceivePackFactory(receive);
+    addReceivePackFilter(receiveFilter);
+  }
+
+  static class Resolver implements RepositoryResolver<HttpServletRequest> {
+    private final GitRepositoryManager manager;
+    private final PermissionBackend permissionBackend;
+    private final Provider<CurrentUser> userProvider;
+    private final ProjectCache projectCache;
+
+    @Inject
+    Resolver(
+        GitRepositoryManager manager,
+        PermissionBackend permissionBackend,
+        Provider<CurrentUser> userProvider,
+        ProjectCache projectCache) {
+      this.manager = manager;
+      this.permissionBackend = permissionBackend;
+      this.userProvider = userProvider;
+      this.projectCache = projectCache;
+    }
+
+    @Override
+    public Repository open(HttpServletRequest req, String projectName)
+        throws RepositoryNotFoundException, ServiceNotAuthorizedException,
+            ServiceNotEnabledException, ServiceMayNotContinueException {
+      while (projectName.endsWith("/")) {
+        projectName = projectName.substring(0, projectName.length() - 1);
+      }
+
+      if (projectName.endsWith(".git")) {
+        // Be nice and drop the trailing ".git" suffix, which we never keep
+        // in our database, but clients might mistakenly provide anyway.
+        //
+        projectName = projectName.substring(0, projectName.length() - 4);
+        while (projectName.endsWith("/")) {
+          projectName = projectName.substring(0, projectName.length() - 1);
+        }
+      }
+
+      CurrentUser user = userProvider.get();
+      user.setAccessPath(AccessPath.GIT);
+
+      try {
+        Project.NameKey nameKey = new Project.NameKey(projectName);
+        ProjectState state = projectCache.checkedGet(nameKey);
+        if (state == null) {
+          throw new RepositoryNotFoundException(nameKey.get());
+        }
+        req.setAttribute(ATT_STATE, state);
+
+        try {
+          permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+        } catch (AuthException e) {
+          if (user instanceof AnonymousUser) {
+            throw new ServiceNotAuthorizedException();
+          }
+          throw new ServiceNotEnabledException(e.getMessage());
+        }
+
+        return manager.openRepository(nameKey);
+      } catch (IOException | PermissionBackendException err) {
+        throw new ServiceMayNotContinueException(projectName + " unavailable", err);
+      }
+    }
+  }
+
+  static class UploadFactory implements UploadPackFactory<HttpServletRequest> {
+    private final TransferConfig config;
+    private final DynamicSet<PreUploadHook> preUploadHooks;
+    private final DynamicSet<PostUploadHook> postUploadHooks;
+    private final DynamicSet<UploadPackInitializer> uploadPackInitializers;
+
+    @Inject
+    UploadFactory(
+        TransferConfig tc,
+        DynamicSet<PreUploadHook> preUploadHooks,
+        DynamicSet<PostUploadHook> postUploadHooks,
+        DynamicSet<UploadPackInitializer> uploadPackInitializers) {
+      this.config = tc;
+      this.preUploadHooks = preUploadHooks;
+      this.postUploadHooks = postUploadHooks;
+      this.uploadPackInitializers = uploadPackInitializers;
+    }
+
+    @Override
+    public UploadPack create(HttpServletRequest req, Repository repo) {
+      UploadPack up = new UploadPack(repo);
+      up.setPackConfig(config.getPackConfig());
+      up.setTimeout(config.getTimeout());
+      up.setPreUploadHook(PreUploadHookChain.newChain(Lists.newArrayList(preUploadHooks)));
+      up.setPostUploadHook(PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
+      ProjectState state = (ProjectState) req.getAttribute(ATT_STATE);
+      for (UploadPackInitializer initializer : uploadPackInitializers) {
+        initializer.init(state.getNameKey(), up);
+      }
+      return up;
+    }
+  }
+
+  static class UploadFilter implements Filter {
+    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,
+        Provider<CurrentUser> userProvider) {
+      this.refFilterFactory = refFilterFactory;
+      this.uploadValidatorsFactory = uploadValidatorsFactory;
+      this.permissionBackend = permissionBackend;
+      this.userProvider = userProvider;
+    }
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain next)
+        throws IOException, ServletException {
+      // The Resolver above already checked READ access for us.
+      Repository repo = ServletUtils.getRepository(request);
+      ProjectState state = (ProjectState) request.getAttribute(ATT_STATE);
+      UploadPack up = (UploadPack) request.getAttribute(ServletUtils.ATTRIBUTE_HANDLER);
+
+      try {
+        permissionBackend
+            .user(userProvider)
+            .project(state.getNameKey())
+            .check(ProjectPermission.RUN_UPLOAD_PACK);
+      } catch (AuthException e) {
+        GitSmartHttpTools.sendError(
+            (HttpServletRequest) request,
+            (HttpServletResponse) response,
+            HttpServletResponse.SC_FORBIDDEN,
+            "upload-pack not permitted on this server");
+        return;
+      } catch (PermissionBackendException e) {
+        throw new ServletException(e);
+      }
+      // 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(state.getProject(), repo, request.getRemoteHost());
+      up.setPreUploadHook(
+          PreUploadHookChain.newChain(Lists.newArrayList(up.getPreUploadHook(), uploadValidators)));
+      up.setAdvertiseRefsHook(refFilterFactory.create(state, repo));
+
+      next.doFilter(request, response);
+    }
+
+    @Override
+    public void init(FilterConfig config) {}
+
+    @Override
+    public void destroy() {}
+  }
+
+  static class ReceiveFactory implements ReceivePackFactory<HttpServletRequest> {
+    private final AsyncReceiveCommits.Factory factory;
+    private final Provider<CurrentUser> userProvider;
+
+    @Inject
+    ReceiveFactory(AsyncReceiveCommits.Factory factory, Provider<CurrentUser> userProvider) {
+      this.factory = factory;
+      this.userProvider = userProvider;
+    }
+
+    @Override
+    public ReceivePack create(HttpServletRequest req, Repository db)
+        throws ServiceNotAuthorizedException {
+      final ProjectState state = (ProjectState) req.getAttribute(ATT_STATE);
+
+      if (!(userProvider.get().isIdentifiedUser())) {
+        // Anonymous users are not permitted to push.
+        throw new ServiceNotAuthorizedException();
+      }
+
+      AsyncReceiveCommits arc =
+          factory.create(
+              state, userProvider.get().asIdentifiedUser(), db, null, ImmutableSetMultimap.of());
+      ReceivePack rp = arc.getReceivePack();
+      req.setAttribute(ATT_ARC, arc);
+      return rp;
+    }
+  }
+
+  static class DisabledReceiveFactory implements ReceivePackFactory<HttpServletRequest> {
+    @Override
+    public ReceivePack create(HttpServletRequest req, Repository db)
+        throws ServiceNotEnabledException {
+      throw new ServiceNotEnabledException();
+    }
+  }
+
+  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,
+        Provider<CurrentUser> userProvider) {
+      this.cache = cache;
+      this.permissionBackend = permissionBackend;
+      this.userProvider = userProvider;
+    }
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+        throws IOException, ServletException {
+      boolean isGet = "GET".equalsIgnoreCase(((HttpServletRequest) request).getMethod());
+
+      AsyncReceiveCommits arc = (AsyncReceiveCommits) request.getAttribute(ATT_ARC);
+      ReceivePack rp = arc.getReceivePack();
+      rp.getAdvertiseRefsHook().advertiseRefs(rp);
+      ProjectState state = (ProjectState) request.getAttribute(ATT_STATE);
+
+      Capable s;
+      try {
+        permissionBackend
+            .user(userProvider)
+            .project(state.getNameKey())
+            .check(ProjectPermission.RUN_RECEIVE_PACK);
+        s = arc.canUpload();
+      } catch (AuthException e) {
+        GitSmartHttpTools.sendError(
+            (HttpServletRequest) request,
+            (HttpServletResponse) response,
+            HttpServletResponse.SC_FORBIDDEN,
+            "receive-pack not permitted on this server");
+        return;
+      } catch (PermissionBackendException e) {
+        throw new RuntimeException(e);
+      }
+
+      if (s != Capable.OK) {
+        GitSmartHttpTools.sendError(
+            (HttpServletRequest) request,
+            (HttpServletResponse) response,
+            HttpServletResponse.SC_FORBIDDEN,
+            "\n" + s.getMessage());
+        return;
+      }
+
+      if (!rp.isCheckReferencedObjectsAreReachable()) {
+        chain.doFilter(request, response);
+        return;
+      }
+
+      if (!(userProvider.get().isIdentifiedUser())) {
+        chain.doFilter(request, response);
+        return;
+      }
+
+      AdvertisedObjectsCacheKey cacheKey =
+          AdvertisedObjectsCacheKey.create(userProvider.get().getAccountId(), state.getNameKey());
+
+      if (isGet) {
+        cache.invalidate(cacheKey);
+      } else {
+        Set<ObjectId> ids = cache.getIfPresent(cacheKey);
+        if (ids != null) {
+          rp.getAdvertisedObjects().addAll(ids);
+          cache.invalidate(cacheKey);
+        }
+      }
+
+      chain.doFilter(request, response);
+
+      if (isGet) {
+        cache.put(cacheKey, Collections.unmodifiableSet(new HashSet<>(rp.getAdvertisedObjects())));
+      }
+    }
+
+    @Override
+    public void init(FilterConfig arg0) {}
+
+    @Override
+    public void destroy() {}
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java b/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
rename to java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HtmlDomUtil.java b/java/com/google/gerrit/httpd/HtmlDomUtil.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/HtmlDomUtil.java
rename to java/com/google/gerrit/httpd/HtmlDomUtil.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java b/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
rename to java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
diff --git a/java/com/google/gerrit/httpd/HttpLogoutServlet.java b/java/com/google/gerrit/httpd/HttpLogoutServlet.java
new file mode 100644
index 0000000..39a39c6
--- /dev/null
+++ b/java/com/google/gerrit/httpd/HttpLogoutServlet.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.audit.AuditEvent;
+import com.google.gerrit.server.audit.AuditService;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+public class HttpLogoutServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final DynamicItem<WebSession> webSession;
+  private final Provider<String> urlProvider;
+  private final String logoutUrl;
+  private final AuditService audit;
+
+  @Inject
+  protected HttpLogoutServlet(
+      AuthConfig authConfig,
+      DynamicItem<WebSession> webSession,
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      AuditService audit) {
+    this.webSession = webSession;
+    this.urlProvider = urlProvider;
+    this.logoutUrl = authConfig.getLogoutURL();
+    this.audit = audit;
+  }
+
+  protected void doLogout(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+    webSession.get().logout();
+    if (logoutUrl != null) {
+      rsp.sendRedirect(logoutUrl);
+    } else {
+      String url = urlProvider.get();
+      if (Strings.isNullOrEmpty(url)) {
+        url = req.getContextPath();
+      }
+      if (Strings.isNullOrEmpty(url)) {
+        url = "/";
+      }
+      if (!url.endsWith("/")) {
+        url += "/";
+      }
+      rsp.sendRedirect(url);
+    }
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+
+    final String sid = webSession.get().getSessionId();
+    final CurrentUser currentUser = webSession.get().getUser();
+    final String what = "sign out";
+    final long when = TimeUtil.nowMs();
+
+    try {
+      doLogout(req, rsp);
+    } finally {
+      audit.dispatch(new AuditEvent(sid, currentUser, what, when, null, null));
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRemotePeerProvider.java b/java/com/google/gerrit/httpd/HttpRemotePeerProvider.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRemotePeerProvider.java
rename to java/com/google/gerrit/httpd/HttpRemotePeerProvider.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRequestContext.java b/java/com/google/gerrit/httpd/HttpRequestContext.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRequestContext.java
rename to java/com/google/gerrit/httpd/HttpRequestContext.java
diff --git a/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java b/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
new file mode 100644
index 0000000..6774ec80
--- /dev/null
+++ b/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * HttpServletResponse wrapper to allow response status code override.
+ *
+ * <p>Differently from the normal HttpServletResponse, this class allows multiple filters to
+ * override the response http status code.
+ */
+public class HttpServletResponseRecorder extends HttpServletResponseWrapper {
+  private static final Logger log = LoggerFactory.getLogger(HttpServletResponseRecorder.class);
+  private static final String LOCATION_HEADER = "Location";
+
+  private int status;
+  private String statusMsg = "";
+  private Map<String, String> headers = new HashMap<>();
+
+  /**
+   * Constructs a response recorder wrapping the given response.
+   *
+   * @param response the response to be wrapped
+   */
+  public HttpServletResponseRecorder(HttpServletResponse response) {
+    super(response);
+  }
+
+  @Override
+  public void sendError(int sc) throws IOException {
+    this.status = sc;
+  }
+
+  @Override
+  public void sendError(int sc, String msg) throws IOException {
+    this.status = sc;
+    this.statusMsg = msg;
+  }
+
+  @Override
+  public void sendRedirect(String location) throws IOException {
+    this.status = SC_MOVED_TEMPORARILY;
+    setHeader(LOCATION_HEADER, location);
+  }
+
+  @Override
+  public void setHeader(String name, String value) {
+    super.setHeader(name, value);
+    headers.put(name, value);
+  }
+
+  @SuppressWarnings("all")
+  // @Override is omitted for backwards compatibility with servlet-api 2.5
+  // TODO: Remove @SuppressWarnings and add @Override when Google upgrades
+  //       to servlet-api 3.1
+  public int getStatus() {
+    return status;
+  }
+
+  void play() throws IOException {
+    if (status != 0) {
+      log.debug("Replaying {} {}", status, statusMsg);
+
+      if (status == SC_MOVED_TEMPORARILY) {
+        super.sendRedirect(headers.get(LOCATION_HEADER));
+      } else {
+        super.sendError(status, statusMsg);
+      }
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/LoginUrlToken.java b/java/com/google/gerrit/httpd/LoginUrlToken.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/LoginUrlToken.java
rename to java/com/google/gerrit/httpd/LoginUrlToken.java
diff --git a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
new file mode 100644
index 0000000..1c7d508
--- /dev/null
+++ b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -0,0 +1,250 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.AuthenticationFailedException;
+import com.google.gerrit.server.auth.NoSuchUserException;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Locale;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+import org.apache.commons.codec.binary.Base64;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Authenticates the current user by HTTP basic authentication.
+ *
+ * <p>The current HTTP request is authenticated by looking up the username and password from the
+ * Base64 encoded Authorization header and validating them against any username/password configured
+ * authentication system in Gerrit. This filter is intended only to protect the {@link
+ * GitOverHttpServlet} and its handled URLs, which provide remote repository access over HTTP.
+ *
+ * @see <a href="http://www.ietf.org/rfc/rfc2617.txt">RFC 2617</a>
+ */
+@Singleton
+class ProjectBasicAuthFilter implements Filter {
+  private static final Logger log = LoggerFactory.getLogger(ProjectBasicAuthFilter.class);
+
+  public static final String REALM_NAME = "Gerrit Code Review";
+  private static final String AUTHORIZATION = "Authorization";
+  private static final String LIT_BASIC = "Basic ";
+
+  private final DynamicItem<WebSession> session;
+  private final AccountCache accountCache;
+  private final AccountManager accountManager;
+  private final AuthConfig authConfig;
+
+  @Inject
+  ProjectBasicAuthFilter(
+      DynamicItem<WebSession> session,
+      AccountCache accountCache,
+      AccountManager accountManager,
+      AuthConfig authConfig) {
+    this.session = session;
+    this.accountCache = accountCache;
+    this.accountManager = accountManager;
+    this.authConfig = authConfig;
+  }
+
+  @Override
+  public void init(FilterConfig config) {}
+
+  @Override
+  public void destroy() {}
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
+    HttpServletRequest req = (HttpServletRequest) request;
+    Response rsp = new Response((HttpServletResponse) response);
+
+    if (verify(req, rsp)) {
+      chain.doFilter(req, rsp);
+    }
+  }
+
+  private boolean verify(HttpServletRequest req, Response rsp) throws IOException {
+    final String hdr = req.getHeader(AUTHORIZATION);
+    if (hdr == null || !hdr.startsWith(LIT_BASIC)) {
+      // Allow an anonymous connection through, or it might be using a
+      // session cookie instead of basic authentication.
+      return true;
+    }
+
+    final byte[] decoded = Base64.decodeBase64(hdr.substring(LIT_BASIC.length()));
+    String usernamePassword = new String(decoded, encoding(req));
+    int splitPos = usernamePassword.indexOf(':');
+    if (splitPos < 1) {
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    }
+
+    String username = usernamePassword.substring(0, splitPos);
+    String password = usernamePassword.substring(splitPos + 1);
+    if (Strings.isNullOrEmpty(password)) {
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    }
+    if (authConfig.isUserNameToLowerCase()) {
+      username = username.toLowerCase(Locale.US);
+    }
+
+    final AccountState who = accountCache.getByUsername(username);
+    if (who == null || !who.getAccount().isActive()) {
+      log.warn(
+          "Authentication failed for "
+              + username
+              + ": account inactive or not provisioned in Gerrit");
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    }
+
+    GitBasicAuthPolicy gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
+    if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP
+        || gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP) {
+      if (who.checkPassword(password, username)) {
+        return succeedAuthentication(who);
+      }
+    }
+
+    if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP) {
+      return failAuthentication(rsp, username, req);
+    }
+
+    AuthRequest whoAuth = AuthRequest.forUser(username);
+    whoAuth.setPassword(password);
+
+    try {
+      AuthResult whoAuthResult = accountManager.authenticate(whoAuth);
+      setUserIdentified(whoAuthResult.getAccountId());
+      return true;
+    } catch (NoSuchUserException e) {
+      if (who.checkPassword(password, who.getUserName())) {
+        return succeedAuthentication(who);
+      }
+      log.warn(authenticationFailedMsg(username, req), e);
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    } catch (AuthenticationFailedException e) {
+      log.warn(authenticationFailedMsg(username, req) + ": " + e.getMessage());
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    } catch (AccountException e) {
+      log.warn(authenticationFailedMsg(username, req), e);
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    }
+  }
+
+  private boolean succeedAuthentication(AccountState who) {
+    setUserIdentified(who.getAccount().getId());
+    return true;
+  }
+
+  private boolean failAuthentication(Response rsp, String username, HttpServletRequest req)
+      throws IOException {
+    log.warn(
+        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);
+    ws.setAccessPathOk(AccessPath.GIT, true);
+    ws.setAccessPathOk(AccessPath.REST_API, true);
+  }
+
+  private String encoding(HttpServletRequest req) {
+    return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
+  }
+
+  static class Response extends HttpServletResponseWrapper {
+    private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
+
+    Response(HttpServletResponse rsp) {
+      super(rsp);
+    }
+
+    private void status(int sc) {
+      if (sc == SC_UNAUTHORIZED) {
+        StringBuilder v = new StringBuilder();
+        v.append(LIT_BASIC);
+        v.append("realm=\"").append(REALM_NAME).append("\"");
+        setHeader(WWW_AUTHENTICATE, v.toString());
+      } else if (containsHeader(WWW_AUTHENTICATE)) {
+        setHeader(WWW_AUTHENTICATE, null);
+      }
+    }
+
+    @Override
+    public void sendError(int sc, String msg) throws IOException {
+      status(sc);
+      super.sendError(sc, msg);
+    }
+
+    @Override
+    public void sendError(int sc) throws IOException {
+      status(sc);
+      super.sendError(sc);
+    }
+
+    @Override
+    @Deprecated
+    public void setStatus(int sc, String sm) {
+      status(sc);
+      super.setStatus(sc, sm);
+    }
+
+    @Override
+    public void setStatus(int sc) {
+      status(sc);
+      super.setStatus(sc);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
new file mode 100644
index 0000000..b910509
--- /dev/null
+++ b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -0,0 +1,339 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import static com.google.gerrit.httpd.ProjectBasicAuthFilter.authenticationFailedMsg;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicMap.Entry;
+import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.util.Locale;
+import java.util.NoSuchElementException;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Authenticates the current user with an OAuth2 server.
+ *
+ * @see <a href="https://tools.ietf.org/rfc/rfc6750.txt">RFC 6750</a>
+ */
+@Singleton
+class ProjectOAuthFilter implements Filter {
+
+  private static final Logger log = LoggerFactory.getLogger(ProjectOAuthFilter.class);
+
+  private static final String REALM_NAME = "Gerrit Code Review";
+  private static final String AUTHORIZATION = "Authorization";
+  private static final String BASIC = "Basic ";
+  private static final String GIT_COOKIE_PREFIX = "git-";
+
+  private final DynamicItem<WebSession> session;
+  private final DynamicMap<OAuthLoginProvider> loginProviders;
+  private final AccountCache accountCache;
+  private final AccountManager accountManager;
+  private final String gitOAuthProvider;
+  private final boolean userNameToLowerCase;
+
+  private String defaultAuthPlugin;
+  private String defaultAuthProvider;
+
+  @Inject
+  ProjectOAuthFilter(
+      DynamicItem<WebSession> session,
+      DynamicMap<OAuthLoginProvider> pluginsProvider,
+      AccountCache accountCache,
+      AccountManager accountManager,
+      @GerritServerConfig Config gerritConfig) {
+    this.session = session;
+    this.loginProviders = pluginsProvider;
+    this.accountCache = accountCache;
+    this.accountManager = accountManager;
+    this.gitOAuthProvider = gerritConfig.getString("auth", null, "gitOAuthProvider");
+    this.userNameToLowerCase = gerritConfig.getBoolean("auth", null, "userNameToLowerCase", false);
+  }
+
+  @Override
+  public void init(FilterConfig config) throws ServletException {
+    if (Strings.isNullOrEmpty(gitOAuthProvider)) {
+      pickOnlyProvider();
+    } else {
+      pickConfiguredProvider();
+    }
+  }
+
+  @Override
+  public void destroy() {}
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
+    HttpServletRequest req = (HttpServletRequest) request;
+    Response rsp = new Response((HttpServletResponse) response);
+    if (verify(req, rsp)) {
+      chain.doFilter(req, rsp);
+    }
+  }
+
+  private boolean verify(HttpServletRequest req, Response rsp) throws IOException {
+    AuthInfo authInfo = null;
+
+    // first check if there is a BASIC authentication header
+    String hdr = req.getHeader(AUTHORIZATION);
+    if (hdr != null && hdr.startsWith(BASIC)) {
+      authInfo = extractAuthInfo(hdr, encoding(req));
+      if (authInfo == null) {
+        rsp.sendError(SC_UNAUTHORIZED);
+        return false;
+      }
+    } else {
+      // if there is no BASIC authentication header, check if there is
+      // a cookie starting with the prefix "git-"
+      Cookie cookie = findGitCookie(req);
+      if (cookie != null) {
+        authInfo = extractAuthInfo(cookie);
+        if (authInfo == null) {
+          rsp.sendError(SC_UNAUTHORIZED);
+          return false;
+        }
+      } else {
+        // if there is no authentication information at all, it might be
+        // an anonymous connection, or there might be a session cookie
+        return true;
+      }
+    }
+
+    // if there is authentication information but no secret => 401
+    if (Strings.isNullOrEmpty(authInfo.tokenOrSecret)) {
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    }
+
+    AccountState who = accountCache.getByUsername(authInfo.username);
+    if (who == null || !who.getAccount().isActive()) {
+      log.warn(
+          authenticationFailedMsg(authInfo.username, req)
+              + ": account inactive or not provisioned in Gerrit");
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    }
+
+    AuthRequest authRequest = AuthRequest.forExternalUser(authInfo.username);
+    authRequest.setEmailAddress(who.getAccount().getPreferredEmail());
+    authRequest.setDisplayName(who.getAccount().getFullName());
+    authRequest.setPassword(authInfo.tokenOrSecret);
+    authRequest.setAuthPlugin(authInfo.pluginName);
+    authRequest.setAuthProvider(authInfo.exportName);
+
+    try {
+      AuthResult authResult = accountManager.authenticate(authRequest);
+      WebSession ws = session.get();
+      ws.setUserAccountId(authResult.getAccountId());
+      ws.setAccessPathOk(AccessPath.GIT, true);
+      ws.setAccessPathOk(AccessPath.REST_API, true);
+      return true;
+    } catch (AccountException e) {
+      log.warn(authenticationFailedMsg(authInfo.username, req), e);
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    }
+  }
+
+  /**
+   * Picks the only installed OAuth provider. If there is a multiude of providers available, the
+   * actual provider must be determined from the authentication request.
+   *
+   * @throws ServletException if there is no {@code OAuthLoginProvider} installed at all.
+   */
+  private void pickOnlyProvider() throws ServletException {
+    try {
+      Entry<OAuthLoginProvider> loginProvider = Iterables.getOnlyElement(loginProviders);
+      defaultAuthPlugin = loginProvider.getPluginName();
+      defaultAuthProvider = loginProvider.getExportName();
+    } catch (NoSuchElementException e) {
+      throw new ServletException("No OAuth login provider installed");
+    } catch (IllegalArgumentException e) {
+      // multiple providers found => do not pick any
+    }
+  }
+
+  /**
+   * Picks the {@code OAuthLoginProvider} configured with <tt>auth.gitOAuthProvider</tt>.
+   *
+   * @throws ServletException if the configured provider was not found.
+   */
+  private void pickConfiguredProvider() throws ServletException {
+    int splitPos = gitOAuthProvider.lastIndexOf(':');
+    if (splitPos < 1 || splitPos == gitOAuthProvider.length() - 1) {
+      // no colon at all or leading/trailing colon: malformed providerId
+      throw new ServletException(
+          "OAuth login provider configuration is"
+              + " invalid: Must be of the form pluginName:providerName");
+    }
+    defaultAuthPlugin = gitOAuthProvider.substring(0, splitPos);
+    defaultAuthProvider = gitOAuthProvider.substring(splitPos + 1);
+    OAuthLoginProvider provider = loginProviders.get(defaultAuthPlugin, defaultAuthProvider);
+    if (provider == null) {
+      throw new ServletException(
+          "Configured OAuth login provider " + gitOAuthProvider + " wasn't installed");
+    }
+  }
+
+  private AuthInfo extractAuthInfo(String hdr, String encoding)
+      throws UnsupportedEncodingException {
+    byte[] decoded = Base64.decodeBase64(hdr.substring(BASIC.length()));
+    String usernamePassword = new String(decoded, encoding);
+    int splitPos = usernamePassword.indexOf(':');
+    if (splitPos < 1 || splitPos == usernamePassword.length() - 1) {
+      return null;
+    }
+    return new AuthInfo(
+        usernamePassword.substring(0, splitPos),
+        usernamePassword.substring(splitPos + 1),
+        defaultAuthPlugin,
+        defaultAuthProvider);
+  }
+
+  private AuthInfo extractAuthInfo(Cookie cookie) throws UnsupportedEncodingException {
+    String username =
+        URLDecoder.decode(cookie.getName().substring(GIT_COOKIE_PREFIX.length()), UTF_8.name());
+    String value = cookie.getValue();
+    int splitPos = value.lastIndexOf('@');
+    if (splitPos < 1 || splitPos == value.length() - 1) {
+      // no providerId in the cookie value => assume default provider
+      // note: a leading/trailing at sign is considered to belong to
+      // the access token rather than being a separator
+      return new AuthInfo(username, cookie.getValue(), defaultAuthPlugin, defaultAuthProvider);
+    }
+    String token = value.substring(0, splitPos);
+    String providerId = value.substring(splitPos + 1);
+    splitPos = providerId.lastIndexOf(':');
+    if (splitPos < 1 || splitPos == providerId.length() - 1) {
+      // no colon at all or leading/trailing colon: malformed providerId
+      return null;
+    }
+    String pluginName = providerId.substring(0, splitPos);
+    String exportName = providerId.substring(splitPos + 1);
+    OAuthLoginProvider provider = loginProviders.get(pluginName, exportName);
+    if (provider == null) {
+      return null;
+    }
+    return new AuthInfo(username, token, pluginName, exportName);
+  }
+
+  private static String encoding(HttpServletRequest req) {
+    return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
+  }
+
+  private static Cookie findGitCookie(HttpServletRequest req) {
+    Cookie[] cookies = req.getCookies();
+    if (cookies != null) {
+      for (Cookie cookie : cookies) {
+        if (cookie.getName().startsWith(GIT_COOKIE_PREFIX)) {
+          return cookie;
+        }
+      }
+    }
+    return null;
+  }
+
+  private class AuthInfo {
+    private final String username;
+    private final String tokenOrSecret;
+    private final String pluginName;
+    private final String exportName;
+
+    private AuthInfo(String username, String tokenOrSecret, String pluginName, String exportName) {
+      this.username = userNameToLowerCase ? username.toLowerCase(Locale.US) : username;
+      this.tokenOrSecret = tokenOrSecret;
+      this.pluginName = pluginName;
+      this.exportName = exportName;
+    }
+  }
+
+  private static class Response extends HttpServletResponseWrapper {
+    private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
+
+    Response(HttpServletResponse rsp) {
+      super(rsp);
+    }
+
+    private void status(int sc) {
+      if (sc == SC_UNAUTHORIZED) {
+        StringBuilder v = new StringBuilder();
+        v.append(BASIC);
+        v.append("realm=\"").append(REALM_NAME).append("\"");
+        setHeader(WWW_AUTHENTICATE, v.toString());
+      } else if (containsHeader(WWW_AUTHENTICATE)) {
+        setHeader(WWW_AUTHENTICATE, null);
+      }
+    }
+
+    @Override
+    public void sendError(int sc, String msg) throws IOException {
+      status(sc);
+      super.sendError(sc, msg);
+    }
+
+    @Override
+    public void sendError(int sc) throws IOException {
+      status(sc);
+      super.sendError(sc);
+    }
+
+    @Override
+    @Deprecated
+    public void setStatus(int sc, String sm) {
+      status(sc);
+      super.setStatus(sc, sm);
+    }
+
+    @Override
+    public void setStatus(int sc) {
+      status(sc);
+      super.setStatus(sc);
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProxyProperties.java b/java/com/google/gerrit/httpd/ProxyProperties.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProxyProperties.java
rename to java/com/google/gerrit/httpd/ProxyProperties.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProxyPropertiesProvider.java b/java/com/google/gerrit/httpd/ProxyPropertiesProvider.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProxyPropertiesProvider.java
rename to java/com/google/gerrit/httpd/ProxyPropertiesProvider.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/QueryDocumentationFilter.java b/java/com/google/gerrit/httpd/QueryDocumentationFilter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/QueryDocumentationFilter.java
rename to java/com/google/gerrit/httpd/QueryDocumentationFilter.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RemoteUserUtil.java b/java/com/google/gerrit/httpd/RemoteUserUtil.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/RemoteUserUtil.java
rename to java/com/google/gerrit/httpd/RemoteUserUtil.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestContextFilter.java b/java/com/google/gerrit/httpd/RequestContextFilter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestContextFilter.java
rename to java/com/google/gerrit/httpd/RequestContextFilter.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetrics.java b/java/com/google/gerrit/httpd/RequestMetrics.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetrics.java
rename to java/com/google/gerrit/httpd/RequestMetrics.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetricsFilter.java b/java/com/google/gerrit/httpd/RequestMetricsFilter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetricsFilter.java
rename to java/com/google/gerrit/httpd/RequestMetricsFilter.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireIdentifiedUserFilter.java b/java/com/google/gerrit/httpd/RequireIdentifiedUserFilter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireIdentifiedUserFilter.java
rename to java/com/google/gerrit/httpd/RequireIdentifiedUserFilter.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireSslFilter.java b/java/com/google/gerrit/httpd/RequireSslFilter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireSslFilter.java
rename to java/com/google/gerrit/httpd/RequireSslFilter.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java b/java/com/google/gerrit/httpd/RunAsFilter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
rename to java/com/google/gerrit/httpd/RunAsFilter.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UniversalWebLoginFilter.java b/java/com/google/gerrit/httpd/UniversalWebLoginFilter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/UniversalWebLoginFilter.java
rename to java/com/google/gerrit/httpd/UniversalWebLoginFilter.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java
rename to java/com/google/gerrit/httpd/UrlModule.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebLoginListener.java b/java/com/google/gerrit/httpd/WebLoginListener.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebLoginListener.java
rename to java/com/google/gerrit/httpd/WebLoginListener.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java b/java/com/google/gerrit/httpd/WebModule.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java
rename to java/com/google/gerrit/httpd/WebModule.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java b/java/com/google/gerrit/httpd/WebSession.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
rename to java/com/google/gerrit/httpd/WebSession.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java b/java/com/google/gerrit/httpd/WebSessionManager.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
rename to java/com/google/gerrit/httpd/WebSessionManager.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManagerFactory.java b/java/com/google/gerrit/httpd/WebSessionManagerFactory.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManagerFactory.java
rename to java/com/google/gerrit/httpd/WebSessionManagerFactory.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSshGlueModule.java b/java/com/google/gerrit/httpd/WebSshGlueModule.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSshGlueModule.java
rename to java/com/google/gerrit/httpd/WebSshGlueModule.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/XsrfCookieFilter.java b/java/com/google/gerrit/httpd/XsrfCookieFilter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/XsrfCookieFilter.java
rename to java/com/google/gerrit/httpd/XsrfCookieFilter.java
diff --git a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
new file mode 100644
index 0000000..853d173
--- /dev/null
+++ b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -0,0 +1,254 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.auth.become;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
+
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.httpd.HtmlDomUtil;
+import com.google.gerrit.httpd.LoginUrlToken;
+import com.google.gerrit.httpd.WebSession;
+import com.google.gerrit.httpd.template.SiteHeaderFooter;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.Writer;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+@SuppressWarnings("serial")
+@Singleton
+class BecomeAnyAccountLoginServlet extends HttpServlet {
+  private final DynamicItem<WebSession> webSession;
+  private final SchemaFactory<ReviewDb> schema;
+  private final Accounts accounts;
+  private final AccountCache accountCache;
+  private final AccountManager accountManager;
+  private final SiteHeaderFooter headers;
+  private final Provider<InternalAccountQuery> queryProvider;
+
+  @Inject
+  BecomeAnyAccountLoginServlet(
+      DynamicItem<WebSession> ws,
+      SchemaFactory<ReviewDb> sf,
+      Accounts a,
+      AccountCache ac,
+      AccountManager am,
+      SiteHeaderFooter shf,
+      Provider<InternalAccountQuery> qp) {
+    webSession = ws;
+    schema = sf;
+    accounts = a;
+    accountCache = ac;
+    accountManager = am;
+    headers = shf;
+    queryProvider = qp;
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp)
+      throws IOException, ServletException {
+    doPost(req, rsp);
+  }
+
+  @Override
+  protected void doPost(HttpServletRequest req, HttpServletResponse rsp)
+      throws IOException, ServletException {
+    CacheHeaders.setNotCacheable(rsp);
+
+    final AuthResult res;
+    if ("create_account".equals(req.getParameter("action"))) {
+      res = create();
+
+    } else if (req.getParameter("user_name") != null) {
+      res = byUserName(req.getParameter("user_name"));
+
+    } else if (req.getParameter("preferred_email") != null) {
+      res = byPreferredEmail(req.getParameter("preferred_email"));
+
+    } else if (req.getParameter("account_id") != null) {
+      res = byAccountId(req.getParameter("account_id"));
+
+    } else {
+      byte[] raw;
+      try {
+        raw = prepareHtmlOutput();
+      } catch (OrmException e) {
+        throw new ServletException(e);
+      }
+      rsp.setContentType("text/html");
+      rsp.setCharacterEncoding(HtmlDomUtil.ENC.name());
+      rsp.setContentLength(raw.length);
+      try (OutputStream out = rsp.getOutputStream()) {
+        out.write(raw);
+      }
+      return;
+    }
+
+    if (res != null) {
+      webSession.get().login(res, false);
+      final StringBuilder rdr = new StringBuilder();
+      rdr.append(req.getContextPath());
+      rdr.append("/");
+
+      if (res.isNew()) {
+        rdr.append('#' + PageLinks.REGISTER);
+      } else {
+        rdr.append(LoginUrlToken.getToken(req));
+      }
+      rsp.sendRedirect(rdr.toString());
+
+    } else {
+      rsp.setContentType("text/html");
+      rsp.setCharacterEncoding(HtmlDomUtil.ENC.name());
+      try (Writer out = rsp.getWriter()) {
+        out.write("<html>");
+        out.write("<body>");
+        out.write("<h1>Account Not Found</h1>");
+        out.write("</body>");
+        out.write("</html>");
+      }
+    }
+  }
+
+  private byte[] prepareHtmlOutput() throws IOException, OrmException {
+    final String pageName = "BecomeAnyAccount.html";
+    Document doc = headers.parse(getClass(), pageName);
+    if (doc == null) {
+      throw new FileNotFoundException("No " + pageName + " in webapp");
+    }
+
+    Element userlistElement = HtmlDomUtil.find(doc, "userlist");
+    try (ReviewDb db = schema.open()) {
+      for (Account.Id accountId : accounts.firstNIds(100)) {
+        Account a = accountCache.get(accountId).getAccount();
+        String displayName;
+        if (a.getUserName() != null) {
+          displayName = a.getUserName();
+        } else if (a.getFullName() != null && !a.getFullName().isEmpty()) {
+          displayName = a.getFullName();
+        } else if (a.getPreferredEmail() != null) {
+          displayName = a.getPreferredEmail();
+        } else {
+          displayName = accountId.toString();
+        }
+
+        Element linkElement = doc.createElement("a");
+        linkElement.setAttribute("href", "?account_id=" + a.getId().toString());
+        linkElement.setTextContent(displayName);
+        userlistElement.appendChild(linkElement);
+        userlistElement.appendChild(doc.createElement("br"));
+      }
+    }
+
+    return HtmlDomUtil.toUTF8(doc);
+  }
+
+  private AuthResult auth(AccountState account) {
+    if (account != null) {
+      return new AuthResult(account.getAccount().getId(), null, false);
+    }
+    return null;
+  }
+
+  private AuthResult auth(Account.Id account) {
+    if (account != null) {
+      return new AuthResult(account, null, false);
+    }
+    return null;
+  }
+
+  private AuthResult byUserName(String userName) {
+    try {
+      List<AccountState> accountStates =
+          queryProvider.get().byExternalId(SCHEME_USERNAME, userName);
+      if (accountStates.isEmpty()) {
+        getServletContext().log("No accounts with username " + userName + " found");
+        return null;
+      }
+      if (accountStates.size() > 1) {
+        getServletContext().log("Multiple accounts with username " + userName + " found");
+        return null;
+      }
+      return auth(accountStates.get(0).getAccount().getId());
+    } catch (OrmException e) {
+      getServletContext().log("cannot query account index", e);
+      return null;
+    }
+  }
+
+  private AuthResult byPreferredEmail(String email) {
+    try (ReviewDb db = schema.open()) {
+      Optional<AccountState> match =
+          queryProvider.get().byPreferredEmail(email).stream().findFirst();
+      return match.isPresent() ? auth(match.get()) : null;
+    } catch (OrmException e) {
+      getServletContext().log("cannot query database", e);
+      return null;
+    }
+  }
+
+  private AuthResult byAccountId(String idStr) {
+    final Account.Id id;
+    try {
+      id = Account.Id.parse(idStr);
+    } catch (NumberFormatException nfe) {
+      return null;
+    }
+    try {
+      return auth(accounts.get(id));
+    } catch (IOException | ConfigInvalidException e) {
+      getServletContext().log("cannot query database", e);
+      return null;
+    }
+  }
+
+  private AuthResult create() throws IOException {
+    try {
+      return accountManager.authenticate(
+          new AuthRequest(ExternalId.Key.create(SCHEME_UUID, UUID.randomUUID().toString())));
+    } catch (AccountException e) {
+      getServletContext().log("cannot create new account", e);
+      return null;
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountModule.java b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountModule.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountModule.java
rename to java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountModule.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
rename to java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthModule.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthModule.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthModule.java
rename to java/com/google/gerrit/httpd/auth/container/HttpAuthModule.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java b/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
rename to java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
rename to java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java
rename to java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertModule.java b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertModule.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertModule.java
rename to java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertModule.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/ldap/LdapAuthModule.java b/java/com/google/gerrit/httpd/auth/ldap/LdapAuthModule.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/ldap/LdapAuthModule.java
rename to java/com/google/gerrit/httpd/auth/ldap/LdapAuthModule.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java b/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
rename to java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
diff --git a/java/com/google/gerrit/httpd/auth/oauth/BUILD b/java/com/google/gerrit/httpd/auth/oauth/BUILD
new file mode 100644
index 0000000..aa63f0d
--- /dev/null
+++ b/java/com/google/gerrit/httpd/auth/oauth/BUILD
@@ -0,0 +1,23 @@
+java_library(
+    name = "oauth",
+    srcs = glob(["**/*.java"]),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/httpd/auth/oauth"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/httpd",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:servlet-api-3_1",
+        "//lib/commons:codec",
+        "//lib/guice",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthLogoutServlet.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthLogoutServlet.java
new file mode 100644
index 0000000..d25ff60
--- /dev/null
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthLogoutServlet.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.auth.oauth;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.httpd.HttpLogoutServlet;
+import com.google.gerrit.httpd.WebSession;
+import com.google.gerrit.server.audit.AuditService;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+class OAuthLogoutServlet extends HttpLogoutServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final Provider<OAuthSession> oauthSession;
+
+  @Inject
+  OAuthLogoutServlet(
+      AuthConfig authConfig,
+      DynamicItem<WebSession> webSession,
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      AuditService audit,
+      Provider<OAuthSession> oauthSession) {
+    super(authConfig, webSession, urlProvider, audit);
+    this.oauthSession = oauthSession;
+  }
+
+  @Override
+  protected void doLogout(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+    super.doLogout(req, rsp);
+    if (req.getSession(false) != null) {
+      oauthSession.get().logout();
+    }
+  }
+}
diff --git a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthModule.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthModule.java
similarity index 100%
rename from gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthModule.java
rename to java/com/google/gerrit/httpd/auth/oauth/OAuthModule.java
diff --git a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
similarity index 100%
rename from gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
rename to java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
diff --git a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
similarity index 100%
rename from gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
rename to java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
diff --git a/java/com/google/gerrit/httpd/auth/openid/BUILD b/java/com/google/gerrit/httpd/auth/openid/BUILD
new file mode 100644
index 0000000..44b7bd1
--- /dev/null
+++ b/java/com/google/gerrit/httpd/auth/openid/BUILD
@@ -0,0 +1,26 @@
+java_library(
+    name = "openid",
+    srcs = glob(["**/*.java"]),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/httpd/auth/openid"],
+    visibility = ["//visibility:public"],
+    deps = [
+        # We want all these deps to be provided_deps
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/httpd",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gwtexpui/server",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:servlet-api-3_1",
+        "//lib/commons:codec",
+        "//lib/guice",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//lib/openid:consumer",
+    ],
+)
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/DiscoveryResult.java b/java/com/google/gerrit/httpd/auth/openid/DiscoveryResult.java
similarity index 100%
rename from gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/DiscoveryResult.java
rename to java/com/google/gerrit/httpd/auth/openid/DiscoveryResult.java
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java b/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
similarity index 100%
rename from gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
rename to java/com/google/gerrit/httpd/auth/openid/LoginForm.java
diff --git a/java/com/google/gerrit/httpd/auth/openid/OAuthOverOpenIDLogoutServlet.java b/java/com/google/gerrit/httpd/auth/openid/OAuthOverOpenIDLogoutServlet.java
new file mode 100644
index 0000000..8299c16
--- /dev/null
+++ b/java/com/google/gerrit/httpd/auth/openid/OAuthOverOpenIDLogoutServlet.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.auth.openid;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.httpd.HttpLogoutServlet;
+import com.google.gerrit.httpd.WebSession;
+import com.google.gerrit.server.audit.AuditService;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+class OAuthOverOpenIDLogoutServlet extends HttpLogoutServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final Provider<OAuthSessionOverOpenID> oauthSession;
+
+  @Inject
+  OAuthOverOpenIDLogoutServlet(
+      AuthConfig authConfig,
+      DynamicItem<WebSession> webSession,
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      AuditService audit,
+      Provider<OAuthSessionOverOpenID> oauthSession) {
+    super(authConfig, webSession, urlProvider, audit);
+    this.oauthSession = oauthSession;
+  }
+
+  @Override
+  protected void doLogout(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+    super.doLogout(req, rsp);
+    if (req.getSession(false) != null) {
+      oauthSession.get().logout();
+    }
+  }
+}
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java b/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
similarity index 100%
rename from gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
rename to java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java b/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
similarity index 100%
rename from gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
rename to java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
similarity index 100%
rename from gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
rename to java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdModule.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdModule.java
similarity index 100%
rename from gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdModule.java
rename to java/com/google/gerrit/httpd/auth/openid/OpenIdModule.java
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
similarity index 100%
rename from gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
rename to java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/SignInMode.java b/java/com/google/gerrit/httpd/auth/openid/SignInMode.java
similarity index 100%
rename from gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/SignInMode.java
rename to java/com/google/gerrit/httpd/auth/openid/SignInMode.java
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/XrdsFilter.java b/java/com/google/gerrit/httpd/auth/openid/XrdsFilter.java
similarity index 100%
rename from gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/XrdsFilter.java
rename to java/com/google/gerrit/httpd/auth/openid/XrdsFilter.java
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/XrdsServlet.java b/java/com/google/gerrit/httpd/auth/openid/XrdsServlet.java
similarity index 100%
rename from gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/XrdsServlet.java
rename to java/com/google/gerrit/httpd/auth/openid/XrdsServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java b/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
rename to java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
rename to java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
rename to java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebModule.java b/java/com/google/gerrit/httpd/gitweb/GitwebModule.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebModule.java
rename to java/com/google/gerrit/httpd/gitweb/GitwebModule.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
rename to java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
new file mode 100644
index 0000000..9624241
--- /dev/null
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -0,0 +1,35 @@
+java_library(
+    name = "init",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/elasticsearch",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/gpg",
+        "//java/com/google/gerrit/httpd",
+        "//java/com/google/gerrit/httpd/auth/oauth",
+        "//java/com/google/gerrit/httpd/auth/openid",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/lucene",
+        "//java/com/google/gerrit/metrics/dropwizard",
+        "//java/com/google/gerrit/pgm/init",
+        "//java/com/google/gerrit/pgm/util",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server:module",
+        "//java/com/google/gerrit/server/api",
+        "//java/com/google/gerrit/server/cache/h2",
+        "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/sshd",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:servlet-api-3_1",
+        "//lib/guice",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//prolog:gerrit-prolog-common",
+    ],
+)
diff --git a/java/com/google/gerrit/httpd/init/ReviewDbDataSourceProvider.java b/java/com/google/gerrit/httpd/init/ReviewDbDataSourceProvider.java
new file mode 100644
index 0000000..6e65780
--- /dev/null
+++ b/java/com/google/gerrit/httpd/init/ReviewDbDataSourceProvider.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.init;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import javax.naming.InitialContext;
+import javax.naming.NamingException;
+import javax.sql.DataSource;
+
+/** Provides access to the {@code ReviewDb} DataSource. */
+@Singleton
+final class ReviewDbDataSourceProvider implements Provider<DataSource>, LifecycleListener {
+  private DataSource ds;
+
+  @Override
+  public synchronized DataSource get() {
+    if (ds == null) {
+      ds = open();
+    }
+    return ds;
+  }
+
+  @Override
+  public void start() {}
+
+  @Override
+  public synchronized void stop() {
+    if (ds != null) {
+      closeDataSource(ds);
+    }
+  }
+
+  private DataSource open() {
+    final String dsName = "java:comp/env/jdbc/ReviewDb";
+    try {
+      return (DataSource) new InitialContext().lookup(dsName);
+    } catch (NamingException namingErr) {
+      throw new ProvisionException("No DataSource " + dsName, namingErr);
+    }
+  }
+
+  private void closeDataSource(DataSource ds) {
+    try {
+      Class<?> type = Class.forName("org.apache.commons.dbcp.BasicDataSource");
+      if (type.isInstance(ds)) {
+        type.getMethod("close").invoke(ds);
+        return;
+      }
+    } catch (Throwable bad) {
+      // Oh well, its not a Commons DBCP pooled connection.
+    }
+
+    try {
+      Class<?> type = Class.forName("com.mchange.v2.c3p0.DataSources");
+      if (type.isInstance(ds)) {
+        type.getMethod("destroy", DataSource.class).invoke(null, ds);
+      }
+    } catch (Throwable bad) {
+      // Oh well, its not a c3p0 pooled connection.
+    }
+  }
+}
diff --git a/java/com/google/gerrit/httpd/init/SiteInitializer.java b/java/com/google/gerrit/httpd/init/SiteInitializer.java
new file mode 100644
index 0000000..17a95b5
--- /dev/null
+++ b/java/com/google/gerrit/httpd/init/SiteInitializer.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.init;
+
+import com.google.gerrit.pgm.init.BaseInit;
+import com.google.gerrit.pgm.init.PluginsDistribution;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class SiteInitializer {
+  private static final Logger LOG = LoggerFactory.getLogger(SiteInitializer.class);
+
+  private final String sitePath;
+  private final String initPath;
+  private final PluginsDistribution pluginsDistribution;
+  private final List<String> pluginsToInstall;
+
+  SiteInitializer(
+      String sitePath,
+      String initPath,
+      PluginsDistribution pluginsDistribution,
+      List<String> pluginsToInstall) {
+    this.sitePath = sitePath;
+    this.initPath = initPath;
+    this.pluginsDistribution = pluginsDistribution;
+    this.pluginsToInstall = pluginsToInstall;
+  }
+
+  public void init() {
+    try {
+      if (sitePath != null) {
+        Path site = Paths.get(sitePath);
+        LOG.info("Initializing site at " + site.toRealPath().normalize());
+        new BaseInit(site, false, true, pluginsDistribution, pluginsToInstall).run();
+        return;
+      }
+
+      try (Connection conn = connectToDb()) {
+        Path site = getSiteFromReviewDb(conn);
+        if (site == null && initPath != null) {
+          site = Paths.get(initPath);
+        }
+        if (site != null) {
+          LOG.info("Initializing site at " + site.toRealPath().normalize());
+          new BaseInit(
+                  site,
+                  new ReviewDbDataSourceProvider(),
+                  false,
+                  false,
+                  pluginsDistribution,
+                  pluginsToInstall)
+              .run();
+        }
+      }
+    } catch (Exception e) {
+      LOG.error("Site init failed", e);
+      throw new RuntimeException(e);
+    }
+  }
+
+  private Connection connectToDb() throws SQLException {
+    return new ReviewDbDataSourceProvider().get().getConnection();
+  }
+
+  private Path getSiteFromReviewDb(Connection conn) {
+    try (Statement stmt = conn.createStatement();
+        ResultSet rs = stmt.executeQuery("SELECT site_path FROM system_config")) {
+      if (rs.next()) {
+        return Paths.get(rs.getString(1));
+      }
+    } catch (SQLException e) {
+      return null;
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/httpd/init/SitePathFromSystemConfigProvider.java b/java/com/google/gerrit/httpd/init/SitePathFromSystemConfigProvider.java
new file mode 100644
index 0000000..96ba28b
--- /dev/null
+++ b/java/com/google/gerrit/httpd/init/SitePathFromSystemConfigProvider.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.init;
+
+import com.google.gerrit.reviewdb.client.SystemConfig;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.schema.ReviewDbFactory;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+
+/** Provides {@link Path} annotated with {@link SitePath}. */
+class SitePathFromSystemConfigProvider implements Provider<Path> {
+  private final Path path;
+
+  @Inject
+  SitePathFromSystemConfigProvider(@ReviewDbFactory SchemaFactory<ReviewDb> schemaFactory)
+      throws OrmException {
+    path = read(schemaFactory);
+  }
+
+  @Override
+  public Path get() {
+    return path;
+  }
+
+  private static Path read(SchemaFactory<ReviewDb> schemaFactory) throws OrmException {
+    try (ReviewDb db = schemaFactory.open()) {
+      List<SystemConfig> all = db.systemConfig().all().toList();
+      switch (all.size()) {
+        case 1:
+          return Paths.get(all.get(0).sitePath);
+        case 0:
+          throw new OrmException("system_config table is empty");
+        default:
+          throw new OrmException(
+              "system_config must have exactly 1 row; found " + all.size() + " rows instead");
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/httpd/init/UnzippedDistribution.java b/java/com/google/gerrit/httpd/init/UnzippedDistribution.java
new file mode 100644
index 0000000..9c0142c
--- /dev/null
+++ b/java/com/google/gerrit/httpd/init/UnzippedDistribution.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.init;
+
+import static com.google.gerrit.pgm.init.InitPlugins.JAR;
+import static com.google.gerrit.pgm.init.InitPlugins.PLUGIN_DIR;
+
+import com.google.gerrit.pgm.init.PluginsDistribution;
+import com.google.inject.Singleton;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import javax.servlet.ServletContext;
+
+@Singleton
+class UnzippedDistribution implements PluginsDistribution {
+
+  private ServletContext servletContext;
+  private File pluginsDir;
+
+  UnzippedDistribution(ServletContext servletContext) {
+    this.servletContext = servletContext;
+  }
+
+  @Override
+  public void foreach(Processor processor) throws FileNotFoundException, IOException {
+    File[] list = getPluginsDir().listFiles();
+    if (list != null) {
+      for (File p : list) {
+        String pluginJarName = p.getName();
+        String pluginName = pluginJarName.substring(0, pluginJarName.length() - JAR.length());
+        try (InputStream in = Files.newInputStream(p.toPath())) {
+          processor.process(pluginName, in);
+        }
+      }
+    }
+  }
+
+  @Override
+  public List<String> listPluginNames() throws FileNotFoundException {
+    List<String> names = new ArrayList<>();
+    String[] list = getPluginsDir().list();
+    if (list != null) {
+      for (String pluginJarName : list) {
+        String pluginName = pluginJarName.substring(0, pluginJarName.length() - JAR.length());
+        names.add(pluginName);
+      }
+    }
+    return names;
+  }
+
+  private File getPluginsDir() {
+    if (pluginsDir == null) {
+      File root = new File(servletContext.getRealPath(""));
+      pluginsDir = new File(root, PLUGIN_DIR);
+    }
+    return pluginsDir;
+  }
+}
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
new file mode 100644
index 0000000..5b9cf3b
--- /dev/null
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -0,0 +1,485 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.init;
+
+import static com.google.inject.Scopes.SINGLETON;
+import static com.google.inject.Stage.PRODUCTION;
+
+import com.google.common.base.Splitter;
+import com.google.gerrit.elasticsearch.ElasticIndexModule;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.gpg.GpgModule;
+import com.google.gerrit.httpd.AllRequestFilter;
+import com.google.gerrit.httpd.GetUserFilter;
+import com.google.gerrit.httpd.GitOverHttpModule;
+import com.google.gerrit.httpd.H2CacheBasedWebSession;
+import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
+import com.google.gerrit.httpd.RequestContextFilter;
+import com.google.gerrit.httpd.RequestMetricsFilter;
+import com.google.gerrit.httpd.RequireSslFilter;
+import com.google.gerrit.httpd.WebModule;
+import com.google.gerrit.httpd.WebSshGlueModule;
+import com.google.gerrit.httpd.auth.oauth.OAuthModule;
+import com.google.gerrit.httpd.auth.openid.OpenIdModule;
+import com.google.gerrit.httpd.plugins.HttpPluginModule;
+import com.google.gerrit.httpd.raw.StaticModule;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.lucene.LuceneIndexModule;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
+import com.google.gerrit.pgm.util.LogFileCompressor;
+import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.StartupChecks;
+import com.google.gerrit.server.account.AccountDeactivator;
+import com.google.gerrit.server.account.InternalAccountDirectory;
+import com.google.gerrit.server.api.GerritApiModule;
+import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.change.ChangeCleanupRunner;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.config.AuthConfigModule;
+import com.google.gerrit.server.config.CanonicalWebUrlModule;
+import com.google.gerrit.server.config.DownloadConfig;
+import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritOptions;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GerritServerConfigModule;
+import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.events.EventBroker;
+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;
+import com.google.gerrit.server.index.IndexModule;
+import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
+import com.google.gerrit.server.mail.receive.MailReceiver;
+import com.google.gerrit.server.mail.send.SmtpEmailSender;
+import com.google.gerrit.server.mime.MimeUtil2Module;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.patch.DiffExecutorModule;
+import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
+import com.google.gerrit.server.plugins.PluginModule;
+import com.google.gerrit.server.plugins.PluginRestApiModule;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
+import com.google.gerrit.server.project.DefaultProjectNameLockManager;
+import com.google.gerrit.server.restapi.config.RestCacheAdminModule;
+import com.google.gerrit.server.schema.DataSourceModule;
+import com.google.gerrit.server.schema.DataSourceProvider;
+import com.google.gerrit.server.schema.DataSourceType;
+import com.google.gerrit.server.schema.DatabaseModule;
+import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
+import com.google.gerrit.server.schema.SchemaModule;
+import com.google.gerrit.server.schema.SchemaVersionCheck;
+import com.google.gerrit.server.securestore.SecureStoreClassName;
+import com.google.gerrit.server.ssh.NoSshModule;
+import com.google.gerrit.server.ssh.SshAddressesModule;
+import com.google.gerrit.sshd.SshHostKeyModule;
+import com.google.gerrit.sshd.SshKeyCacheImpl;
+import com.google.gerrit.sshd.SshModule;
+import com.google.gerrit.sshd.commands.DefaultCommandModule;
+import com.google.gerrit.sshd.commands.IndexCommandsModule;
+import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand;
+import com.google.inject.AbstractModule;
+import com.google.inject.CreationException;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.Module;
+import com.google.inject.Provider;
+import com.google.inject.name.Names;
+import com.google.inject.servlet.GuiceFilter;
+import com.google.inject.servlet.GuiceServletContextListener;
+import com.google.inject.spi.Message;
+import com.google.inject.util.Providers;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletContextEvent;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.sql.DataSource;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Configures the web application environment for Gerrit Code Review. */
+public class WebAppInitializer extends GuiceServletContextListener implements Filter {
+  private static final Logger log = LoggerFactory.getLogger(WebAppInitializer.class);
+
+  private Path sitePath;
+  private Injector dbInjector;
+  private Injector cfgInjector;
+  private Config config;
+  private Injector sysInjector;
+  private Injector webInjector;
+  private Injector sshInjector;
+  private LifecycleManager manager;
+  private GuiceFilter filter;
+
+  private ServletContext servletContext;
+  private IndexType indexType;
+
+  @Override
+  public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
+      throws IOException, ServletException {
+    filter.doFilter(req, res, chain);
+  }
+
+  private synchronized void init() {
+    if (manager == null) {
+      final String path = System.getProperty("gerrit.site_path");
+      if (path != null) {
+        sitePath = Paths.get(path);
+      }
+
+      if (System.getProperty("gerrit.init") != null) {
+        List<String> pluginsToInstall;
+        String installPlugins = System.getProperty("gerrit.install_plugins");
+        if (installPlugins == null) {
+          pluginsToInstall = null;
+        } else {
+          pluginsToInstall =
+              Splitter.on(",").trimResults().omitEmptyStrings().splitToList(installPlugins);
+        }
+        new SiteInitializer(
+                path,
+                System.getProperty("gerrit.init_path"),
+                new UnzippedDistribution(servletContext),
+                pluginsToInstall)
+            .init();
+      }
+
+      try {
+        dbInjector = createDbInjector();
+      } catch (CreationException ce) {
+        final Message first = ce.getErrorMessages().iterator().next();
+        final StringBuilder buf = new StringBuilder();
+        buf.append(first.getMessage());
+        Throwable why = first.getCause();
+        while (why != null) {
+          buf.append("\n  caused by ");
+          buf.append(why.toString());
+          why = why.getCause();
+        }
+        if (first.getCause() != null) {
+          buf.append("\n");
+          buf.append("\nResolve above errors before continuing.");
+          buf.append("\nComplete stack trace follows:");
+        }
+        log.error(buf.toString(), first.getCause());
+        throw new CreationException(Collections.singleton(first));
+      }
+
+      cfgInjector = createCfgInjector();
+      initIndexType();
+      config = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+      sysInjector = createSysInjector();
+      if (!sshdOff()) {
+        sshInjector = createSshInjector();
+      }
+      webInjector = createWebInjector();
+
+      PluginGuiceEnvironment env = sysInjector.getInstance(PluginGuiceEnvironment.class);
+      env.setDbCfgInjector(dbInjector, cfgInjector);
+      if (sshInjector != null) {
+        env.setSshInjector(sshInjector);
+      }
+      env.setHttpInjector(webInjector);
+
+      // Push the Provider<HttpServletRequest> down into the canonical
+      // URL provider. Its optional for that provider, but since we can
+      // supply one we should do so, in case the administrator has not
+      // setup the canonical URL in the configuration file.
+      //
+      // Note we have to do this manually as Guice failed to do the
+      // injection here because the HTTP environment is not visible
+      // to the core server modules.
+      //
+      sysInjector
+          .getInstance(HttpCanonicalWebUrlProvider.class)
+          .setHttpServletRequest(webInjector.getProvider(HttpServletRequest.class));
+
+      filter = webInjector.getInstance(GuiceFilter.class);
+      manager = new LifecycleManager();
+      manager.add(dbInjector);
+      manager.add(cfgInjector);
+      manager.add(sysInjector);
+      if (sshInjector != null) {
+        manager.add(sshInjector);
+      }
+      manager.add(webInjector);
+    }
+  }
+
+  private boolean sshdOff() {
+    return new SshAddressesModule().getListenAddresses(config).isEmpty();
+  }
+
+  private Injector createDbInjector() {
+    final List<Module> modules = new ArrayList<>();
+    AbstractModule secureStore = createSecureStoreModule();
+    modules.add(secureStore);
+    if (sitePath != null) {
+      Module sitePathModule =
+          new AbstractModule() {
+            @Override
+            protected void configure() {
+              bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
+            }
+          };
+      modules.add(sitePathModule);
+
+      Module configModule = new GerritServerConfigModule();
+      modules.add(configModule);
+
+      Injector cfgInjector = Guice.createInjector(sitePathModule, configModule, secureStore);
+      Config cfg = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+      String dbType = cfg.getString("database", null, "type");
+
+      final DataSourceType dst =
+          Guice.createInjector(new DataSourceModule(), configModule, sitePathModule, secureStore)
+              .getInstance(Key.get(DataSourceType.class, Names.named(dbType.toLowerCase())));
+      modules.add(
+          new LifecycleModule() {
+            @Override
+            protected void configure() {
+              bind(DataSourceType.class).toInstance(dst);
+              bind(DataSourceProvider.Context.class)
+                  .toInstance(DataSourceProvider.Context.MULTI_USER);
+              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
+                  .toProvider(DataSourceProvider.class)
+                  .in(SINGLETON);
+              listener().to(DataSourceProvider.class);
+            }
+          });
+
+    } else {
+      modules.add(
+          new LifecycleModule() {
+            @Override
+            protected void configure() {
+              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
+                  .toProvider(ReviewDbDataSourceProvider.class)
+                  .in(SINGLETON);
+              listener().to(ReviewDbDataSourceProvider.class);
+            }
+          });
+
+      // If we didn't get the site path from the system property
+      // we need to get it from the database, as that's our old
+      // method of locating the site path on disk.
+      //
+      modules.add(
+          new AbstractModule() {
+            @Override
+            protected void configure() {
+              bind(Path.class)
+                  .annotatedWith(SitePath.class)
+                  .toProvider(SitePathFromSystemConfigProvider.class)
+                  .in(SINGLETON);
+            }
+          });
+      modules.add(new GerritServerConfigModule());
+    }
+    modules.add(new DatabaseModule());
+    modules.add(new NotesMigration.Module());
+    modules.add(new GroupsMigration.Module());
+    modules.add(new DropWizardMetricMaker.ApiModule());
+    return Guice.createInjector(PRODUCTION, modules);
+  }
+
+  private Injector createCfgInjector() {
+    final List<Module> modules = new ArrayList<>();
+    modules.add(new SchemaModule());
+    modules.add(SchemaVersionCheck.module());
+    modules.add(new AuthConfigModule());
+    return dbInjector.createChildInjector(modules);
+  }
+
+  private Injector createSysInjector() {
+    final List<Module> modules = new ArrayList<>();
+    modules.add(new DropWizardMetricMaker.RestModule());
+    modules.add(new LogFileCompressor.Module());
+    modules.add(new EventBroker.Module());
+    modules.add(new JdbcAccountPatchReviewStore.Module(config));
+    modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
+    modules.add(new StreamEventsApiListener.Module());
+    modules.add(new ReceiveCommitsExecutorModule());
+    modules.add(new DiffExecutorModule());
+    modules.add(new MimeUtil2Module());
+    modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
+    modules.add(new GerritApiModule());
+    modules.add(new SearchingChangeCacheImpl.Module());
+    modules.add(new InternalAccountDirectory.Module());
+    modules.add(new DefaultPermissionBackendModule());
+    modules.add(new DefaultCacheFactory.Module());
+    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
+    // to Guice, which has these:
+    //  listener().to(SomeClassImplementingLifecycleListener.class);
+    // and the start() methods of each such listener are executed in the
+    // order they are declared.
+    // Makes sure that PluginLoader.start() is executed before the
+    // LuceneIndexModule.start() so that plugins get loaded and the respective
+    // Guice modules installed so that the on-line reindexing will happen
+    // with the proper classes (e.g. group backends, custom Prolog
+    // predicates) and the associated rules ready to be evaluated.
+    modules.add(new PluginModule());
+    modules.add(new PluginRestApiModule());
+
+    modules.add(new RestCacheAdminModule());
+    modules.add(new GpgModule(config));
+    modules.add(new StartupChecks.Module());
+
+    // Index module shutdown must happen before work queue shutdown, otherwise
+    // work queue can get stuck waiting on index futures that will never return.
+    modules.add(createIndexModule());
+
+    modules.add(new WorkQueue.Module());
+    modules.add(
+        new CanonicalWebUrlModule() {
+          @Override
+          protected Class<? extends Provider<String>> provider() {
+            return HttpCanonicalWebUrlProvider.class;
+          }
+        });
+    modules.add(SshKeyCacheImpl.module());
+    modules.add(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(GerritOptions.class).toInstance(new GerritOptions(config, false, false, false));
+          }
+        });
+    modules.add(new GarbageCollectionModule());
+    modules.add(new ChangeCleanupRunner.Module());
+    modules.add(new AccountDeactivator.Module());
+    modules.addAll(LibModuleLoader.loadModules(cfgInjector));
+    modules.add(new DefaultProjectNameLockManager.Module());
+    return cfgInjector.createChildInjector(modules);
+  }
+
+  private Module createIndexModule() {
+    switch (indexType) {
+      case LUCENE:
+        return LuceneIndexModule.latestVersionWithOnlineUpgrade();
+      case ELASTICSEARCH:
+        return ElasticIndexModule.latestVersionWithOnlineUpgrade();
+      default:
+        throw new IllegalStateException("unsupported index.type = " + indexType);
+    }
+  }
+
+  private void initIndexType() {
+    indexType = IndexModule.getIndexType(cfgInjector);
+  }
+
+  private Injector createSshInjector() {
+    final List<Module> modules = new ArrayList<>();
+    modules.add(sysInjector.getInstance(SshModule.class));
+    modules.add(new SshHostKeyModule());
+    modules.add(
+        new DefaultCommandModule(
+            false,
+            sysInjector.getInstance(DownloadConfig.class),
+            sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
+    if (indexType == IndexType.LUCENE) {
+      modules.add(new IndexCommandsModule());
+    }
+    return sysInjector.createChildInjector(modules);
+  }
+
+  private Injector createWebInjector() {
+    final List<Module> modules = new ArrayList<>();
+    modules.add(RequestContextFilter.module());
+    modules.add(AllRequestFilter.module());
+    modules.add(RequestMetricsFilter.module());
+    modules.add(sysInjector.getInstance(GitOverHttpModule.class));
+    modules.add(sysInjector.getInstance(WebModule.class));
+    modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
+    if (sshInjector != null) {
+      modules.add(sshInjector.getInstance(WebSshGlueModule.class));
+    } else {
+      modules.add(new NoSshModule());
+    }
+    modules.add(H2CacheBasedWebSession.module());
+    modules.add(new HttpPluginModule());
+
+    AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
+    if (authConfig.getAuthType() == AuthType.OPENID) {
+      modules.add(new OpenIdModule());
+    } else if (authConfig.getAuthType() == AuthType.OAUTH) {
+      modules.add(new OAuthModule());
+    }
+    modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
+
+    // StaticModule contains a "/*" wildcard, place it last.
+    modules.add(sysInjector.getInstance(StaticModule.class));
+
+    return sysInjector.createChildInjector(modules);
+  }
+
+  @Override
+  protected Injector getInjector() {
+    init();
+    return webInjector;
+  }
+
+  @Override
+  public void init(FilterConfig cfg) throws ServletException {
+    servletContext = cfg.getServletContext();
+    contextInitialized(new ServletContextEvent(servletContext));
+    init();
+    manager.start();
+  }
+
+  @Override
+  public void destroy() {
+    if (manager != null) {
+      manager.stop();
+      manager = null;
+    }
+  }
+
+  private AbstractModule createSecureStoreModule() {
+    return new AbstractModule() {
+      @Override
+      public void configure() {
+        String secureStoreClassName = GerritServerConfigModule.getSecureStoreClassName(sitePath);
+        bind(String.class)
+            .annotatedWith(SecureStoreClassName.class)
+            .toProvider(Providers.of(secureStoreClassName));
+      }
+    };
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/ContextMapper.java b/java/com/google/gerrit/httpd/plugins/ContextMapper.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/ContextMapper.java
rename to java/com/google/gerrit/httpd/plugins/ContextMapper.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java b/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java
rename to java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java b/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
rename to java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
new file mode 100644
index 0000000..969b9ff
--- /dev/null
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -0,0 +1,745 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.plugins;
+
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static com.google.common.net.HttpHeaders.ORIGIN;
+import static com.google.common.net.HttpHeaders.VARY;
+import static com.google.gerrit.common.FileUtil.lastModified;
+import static com.google.gerrit.server.plugins.PluginEntry.ATTR_CHARACTER_ENCODING;
+import static com.google.gerrit.server.plugins.PluginEntry.ATTR_CONTENT_TYPE;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.cache.Cache;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.io.ByteStreams;
+import com.google.common.net.HttpHeaders;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.httpd.resources.Resource;
+import com.google.gerrit.httpd.resources.ResourceKey;
+import com.google.gerrit.httpd.resources.SmallResource;
+import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.documentation.MarkdownFormatter;
+import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
+import com.google.gerrit.server.plugins.Plugin;
+import com.google.gerrit.server.plugins.Plugin.ApiType;
+import com.google.gerrit.server.plugins.PluginContentScanner;
+import com.google.gerrit.server.plugins.PluginEntry;
+import com.google.gerrit.server.plugins.PluginsCollection;
+import com.google.gerrit.server.plugins.ReloadPluginListener;
+import com.google.gerrit.server.plugins.StartPluginListener;
+import com.google.gerrit.server.ssh.SshInfo;
+import com.google.gerrit.util.http.RequestUtil;
+import com.google.gwtexpui.server.CacheHeaders;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import com.google.inject.servlet.GuiceFilter;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentMap;
+import java.util.function.Predicate;
+import java.util.jar.Attributes;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.util.IO;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+class HttpPluginServlet extends HttpServlet implements StartPluginListener, ReloadPluginListener {
+  private static final int SMALL_RESOURCE = 128 * 1024;
+  private static final long serialVersionUID = 1L;
+  private static final Logger log = LoggerFactory.getLogger(HttpPluginServlet.class);
+
+  private final MimeUtilFileTypeRegistry mimeUtil;
+  private final Provider<String> webUrl;
+  private final Cache<ResourceKey, Resource> resourceCache;
+  private final String sshHost;
+  private final int sshPort;
+  private final RestApiServlet managerApi;
+
+  private List<Plugin> pending = new ArrayList<>();
+  private ContextMapper wrapper;
+  private final ConcurrentMap<String, PluginHolder> plugins = Maps.newConcurrentMap();
+  private final Pattern allowOrigin;
+
+  @Inject
+  HttpPluginServlet(
+      MimeUtilFileTypeRegistry mimeUtil,
+      @CanonicalWebUrl Provider<String> webUrl,
+      @Named(HttpPluginModule.PLUGIN_RESOURCES) Cache<ResourceKey, Resource> cache,
+      SshInfo sshInfo,
+      RestApiServlet.Globals globals,
+      PluginsCollection plugins,
+      @GerritServerConfig Config cfg) {
+    this.mimeUtil = mimeUtil;
+    this.webUrl = webUrl;
+    this.resourceCache = cache;
+    this.managerApi = new RestApiServlet(globals, plugins);
+
+    String sshHost = "review.example.com";
+    int sshPort = 29418;
+    if (!sshInfo.getHostKeys().isEmpty()) {
+      String host = sshInfo.getHostKeys().get(0).getHost();
+      int c = host.lastIndexOf(':');
+      if (0 <= c) {
+        sshHost = host.substring(0, c);
+        sshPort = Integer.parseInt(host.substring(c + 1));
+      } else {
+        sshHost = host;
+        sshPort = 22;
+      }
+    }
+    this.sshHost = sshHost;
+    this.sshPort = sshPort;
+    this.allowOrigin = makeAllowOrigin(cfg);
+  }
+
+  @Override
+  public synchronized void init(ServletConfig config) throws ServletException {
+    super.init(config);
+
+    wrapper = new ContextMapper(config.getServletContext().getContextPath());
+    for (Plugin plugin : pending) {
+      install(plugin);
+    }
+    pending = null;
+  }
+
+  @Override
+  public synchronized void onStartPlugin(Plugin plugin) {
+    if (pending != null) {
+      pending.add(plugin);
+    } else {
+      install(plugin);
+    }
+  }
+
+  @Override
+  public void onReloadPlugin(Plugin oldPlugin, Plugin newPlugin) {
+    install(newPlugin);
+  }
+
+  private void install(Plugin plugin) {
+    GuiceFilter filter = load(plugin);
+    final String name = plugin.getName();
+    final PluginHolder holder = new PluginHolder(plugin, filter);
+    plugin.add(
+        new RegistrationHandle() {
+          @Override
+          public void remove() {
+            plugins.remove(name, holder);
+          }
+        });
+    plugins.put(name, holder);
+  }
+
+  private GuiceFilter load(Plugin plugin) {
+    if (plugin.getHttpInjector() != null) {
+      final String name = plugin.getName();
+      final GuiceFilter filter;
+      try {
+        filter = plugin.getHttpInjector().getInstance(GuiceFilter.class);
+      } catch (RuntimeException e) {
+        log.warn(String.format("Plugin %s cannot load GuiceFilter", name), e);
+        return null;
+      }
+
+      try {
+        ServletContext ctx = PluginServletContext.create(plugin, wrapper.getFullPath(name));
+        filter.init(new WrappedFilterConfig(ctx));
+      } catch (ServletException e) {
+        log.warn(String.format("Plugin %s failed to initialize HTTP", name), e);
+        return null;
+      }
+
+      plugin.add(
+          new RegistrationHandle() {
+            @Override
+            public void remove() {
+              filter.destroy();
+            }
+          });
+      return filter;
+    }
+    return null;
+  }
+
+  @Override
+  public void service(HttpServletRequest req, HttpServletResponse res)
+      throws IOException, ServletException {
+    List<String> parts =
+        Lists.newArrayList(
+            Splitter.on('/')
+                .limit(3)
+                .omitEmptyStrings()
+                .split(Strings.nullToEmpty(RequestUtil.getEncodedPathInfo(req))));
+
+    if (isApiCall(req, parts)) {
+      managerApi.service(req, res);
+      return;
+    }
+
+    String name = parts.get(0);
+    final PluginHolder holder = plugins.get(name);
+    if (holder == null) {
+      CacheHeaders.setNotCacheable(res);
+      res.sendError(HttpServletResponse.SC_NOT_FOUND);
+      return;
+    }
+
+    HttpServletRequest wr = wrapper.create(req, name);
+    FilterChain chain =
+        new FilterChain() {
+          @Override
+          public void doFilter(ServletRequest req, ServletResponse res) throws IOException {
+            onDefault(holder, (HttpServletRequest) req, (HttpServletResponse) res);
+          }
+        };
+    if (holder.filter != null) {
+      holder.filter.doFilter(wr, res, chain);
+    } else {
+      chain.doFilter(wr, res);
+    }
+  }
+
+  private static boolean isApiCall(HttpServletRequest req, List<String> parts) {
+    String method = req.getMethod();
+    int cnt = parts.size();
+    return cnt == 0
+        || (cnt == 1 && ("PUT".equals(method) || "DELETE".equals(method)))
+        || (cnt == 2 && parts.get(1).startsWith("gerrit~"));
+  }
+
+  private void onDefault(PluginHolder holder, HttpServletRequest req, HttpServletResponse res)
+      throws IOException {
+    if (!"GET".equals(req.getMethod()) && !"HEAD".equals(req.getMethod())) {
+      CacheHeaders.setNotCacheable(res);
+      res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
+      return;
+    }
+
+    String pathInfo = RequestUtil.getEncodedPathInfo(req);
+    if (pathInfo.length() < 1) {
+      Resource.NOT_FOUND.send(req, res);
+      return;
+    }
+
+    checkCors(req, res);
+
+    String file = pathInfo.substring(1);
+    PluginResourceKey key = PluginResourceKey.create(holder.plugin, file);
+    Resource rsc = resourceCache.getIfPresent(key);
+    if (rsc != null && req.getHeader(HttpHeaders.IF_MODIFIED_SINCE) == null) {
+      rsc.send(req, res);
+      return;
+    }
+
+    String uri = req.getRequestURI();
+    if ("".equals(file)) {
+      res.sendRedirect(uri + holder.docPrefix + "index.html");
+      return;
+    }
+
+    if (file.startsWith(holder.staticPrefix)) {
+      if (holder.plugin.getApiType() == ApiType.JS) {
+        sendJsPlugin(holder.plugin, key, req, res);
+      } else {
+        PluginContentScanner scanner = holder.plugin.getContentScanner();
+        Optional<PluginEntry> entry = scanner.getEntry(file);
+        if (entry.isPresent()) {
+          if (hasUpToDateCachedResource(rsc, entry.get().getTime())) {
+            rsc.send(req, res);
+          } else {
+            sendResource(scanner, entry.get(), key, res);
+          }
+        } else {
+          resourceCache.put(key, Resource.NOT_FOUND);
+          Resource.NOT_FOUND.send(req, res);
+        }
+      }
+    } else if (file.equals(holder.docPrefix.substring(0, holder.docPrefix.length() - 1))) {
+      res.sendRedirect(uri + "/index.html");
+    } else if (file.startsWith(holder.docPrefix) && file.endsWith("/")) {
+      res.sendRedirect(uri + "index.html");
+    } else if (file.startsWith(holder.docPrefix)) {
+      PluginContentScanner scanner = holder.plugin.getContentScanner();
+      Optional<PluginEntry> entry = scanner.getEntry(file);
+      if (!entry.isPresent()) {
+        entry = findSource(scanner, file);
+      }
+      if (!entry.isPresent() && file.endsWith("/index.html")) {
+        String pfx = file.substring(0, file.length() - "index.html".length());
+        long pluginLastModified = lastModified(holder.plugin.getSrcFile());
+        if (hasUpToDateCachedResource(rsc, pluginLastModified)) {
+          rsc.send(req, res);
+        } else {
+          sendAutoIndex(scanner, pfx, holder.plugin.getName(), key, res, pluginLastModified);
+        }
+      } else if (entry.isPresent() && entry.get().getName().endsWith(".md")) {
+        if (hasUpToDateCachedResource(rsc, entry.get().getTime())) {
+          rsc.send(req, res);
+        } else {
+          sendMarkdownAsHtml(scanner, entry.get(), holder.plugin.getName(), key, res);
+        }
+      } else if (entry.isPresent()) {
+        if (hasUpToDateCachedResource(rsc, entry.get().getTime())) {
+          rsc.send(req, res);
+        } else {
+          sendResource(scanner, entry.get(), key, res);
+        }
+      } else {
+        resourceCache.put(key, Resource.NOT_FOUND);
+        Resource.NOT_FOUND.send(req, res);
+      }
+    } else {
+      resourceCache.put(key, Resource.NOT_FOUND);
+      Resource.NOT_FOUND.send(req, res);
+    }
+  }
+
+  private static Pattern makeAllowOrigin(Config cfg) {
+    String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
+    if (allow.length > 0) {
+      return Pattern.compile(Joiner.on('|').join(allow));
+    }
+    return null;
+  }
+
+  private void checkCors(HttpServletRequest req, HttpServletResponse res) {
+    String origin = req.getHeader(ORIGIN);
+    if (!Strings.isNullOrEmpty(origin) && isOriginAllowed(origin)) {
+      res.addHeader(VARY, ORIGIN);
+      setCorsHeaders(res, origin);
+    }
+  }
+
+  private void setCorsHeaders(HttpServletResponse res, String origin) {
+    res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+    res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+    res.setHeader(ACCESS_CONTROL_ALLOW_METHODS, "GET, HEAD");
+  }
+
+  private boolean isOriginAllowed(String origin) {
+    return allowOrigin == null || allowOrigin.matcher(origin).matches();
+  }
+
+  private boolean hasUpToDateCachedResource(Resource cachedResource, long lastUpdateTime) {
+    return cachedResource != null && cachedResource.isUnchanged(lastUpdateTime);
+  }
+
+  private void appendEntriesSection(
+      PluginContentScanner scanner,
+      List<PluginEntry> entries,
+      String sectionTitle,
+      StringBuilder md,
+      String prefix,
+      int nameOffset)
+      throws IOException {
+    if (!entries.isEmpty()) {
+      md.append("## ").append(sectionTitle).append(" ##\n");
+      for (PluginEntry entry : entries) {
+        String rsrc = entry.getName().substring(prefix.length());
+        String entryTitle;
+        if (rsrc.endsWith(".html")) {
+          entryTitle = rsrc.substring(nameOffset, rsrc.length() - 5).replace('-', ' ');
+        } else if (rsrc.endsWith(".md")) {
+          entryTitle = extractTitleFromMarkdown(scanner, entry);
+          if (Strings.isNullOrEmpty(entryTitle)) {
+            entryTitle = rsrc.substring(nameOffset, rsrc.length() - 3).replace('-', ' ');
+          }
+        } else {
+          entryTitle = rsrc.substring(nameOffset).replace('-', ' ');
+        }
+        md.append(String.format("* [%s](%s)\n", entryTitle, rsrc));
+      }
+      md.append("\n");
+    }
+  }
+
+  private void sendAutoIndex(
+      PluginContentScanner scanner,
+      final String prefix,
+      final String pluginName,
+      PluginResourceKey cacheKey,
+      HttpServletResponse res,
+      long lastModifiedTime)
+      throws IOException {
+    List<PluginEntry> cmds = new ArrayList<>();
+    List<PluginEntry> servlets = new ArrayList<>();
+    List<PluginEntry> restApis = new ArrayList<>();
+    List<PluginEntry> docs = new ArrayList<>();
+    PluginEntry about = null;
+
+    Predicate<PluginEntry> filter =
+        entry -> {
+          String name = entry.getName();
+          Optional<Long> size = entry.getSize();
+          if (name.startsWith(prefix)
+              && (name.endsWith(".md") || name.endsWith(".html"))
+              && size.isPresent()) {
+            if (size.get() <= 0 || size.get() > SMALL_RESOURCE) {
+              log.warn(
+                  String.format(
+                      "Plugin %s: %s omitted from document index. "
+                          + "Size %d out of range (0,%d).",
+                      pluginName, name.substring(prefix.length()), size.get(), SMALL_RESOURCE));
+              return false;
+            }
+            return true;
+          }
+          return false;
+        };
+
+    List<PluginEntry> entries =
+        Collections.list(scanner.entries()).stream().filter(filter).collect(toList());
+    for (PluginEntry entry : entries) {
+      String name = entry.getName().substring(prefix.length());
+      if (name.startsWith("cmd-")) {
+        cmds.add(entry);
+      } else if (name.startsWith("servlet-")) {
+        servlets.add(entry);
+      } else if (name.startsWith("rest-api-")) {
+        restApis.add(entry);
+      } else if (name.startsWith("about.")) {
+        if (about == null) {
+          about = entry;
+        } else {
+          log.warn(
+              String.format(
+                  "Plugin %s: Multiple 'about' documents found; using %s",
+                  pluginName, about.getName().substring(prefix.length())));
+        }
+      } else {
+        docs.add(entry);
+      }
+    }
+
+    Collections.sort(cmds, PluginEntry.COMPARATOR_BY_NAME);
+    Collections.sort(docs, PluginEntry.COMPARATOR_BY_NAME);
+
+    StringBuilder md = new StringBuilder();
+    md.append(String.format("# Plugin %s #\n", pluginName));
+    md.append("\n");
+    appendPluginInfoTable(md, scanner.getManifest().getMainAttributes());
+
+    if (about != null) {
+      InputStreamReader isr = new InputStreamReader(scanner.getInputStream(about), UTF_8);
+      StringBuilder aboutContent = new StringBuilder();
+      try (BufferedReader reader = new BufferedReader(isr)) {
+        String line;
+        while ((line = reader.readLine()) != null) {
+          line = line.trim();
+          if (line.isEmpty()) {
+            aboutContent.append("\n");
+          } else {
+            aboutContent.append(line).append("\n");
+          }
+        }
+      }
+
+      // Only append the About section if there was anything in it
+      if (aboutContent.toString().trim().length() > 0) {
+        md.append("## About ##\n");
+        md.append("\n").append(aboutContent);
+      }
+    }
+
+    appendEntriesSection(scanner, docs, "Documentation", md, prefix, 0);
+    appendEntriesSection(scanner, servlets, "Servlets", md, prefix, "servlet-".length());
+    appendEntriesSection(scanner, restApis, "REST APIs", md, prefix, "rest-api-".length());
+    appendEntriesSection(scanner, cmds, "Commands", md, prefix, "cmd-".length());
+
+    sendMarkdownAsHtml(md.toString(), pluginName, cacheKey, res, lastModifiedTime);
+  }
+
+  private void sendMarkdownAsHtml(
+      String md,
+      String pluginName,
+      PluginResourceKey cacheKey,
+      HttpServletResponse res,
+      long lastModifiedTime)
+      throws UnsupportedEncodingException, IOException {
+    Map<String, String> macros = new HashMap<>();
+    macros.put("PLUGIN", pluginName);
+    macros.put("SSH_HOST", sshHost);
+    macros.put("SSH_PORT", "" + sshPort);
+    String url = webUrl.get();
+    if (Strings.isNullOrEmpty(url)) {
+      url = "http://review.example.com/";
+    }
+    macros.put("URL", url);
+
+    Matcher m = Pattern.compile("(\\\\)?@([A-Z_]+)@").matcher(md);
+    StringBuffer sb = new StringBuffer();
+    while (m.find()) {
+      String key = m.group(2);
+      String val = macros.get(key);
+      if (m.group(1) != null) {
+        m.appendReplacement(sb, "@" + key + "@");
+      } else if (val != null) {
+        m.appendReplacement(sb, val);
+      } else {
+        m.appendReplacement(sb, "@" + key + "@");
+      }
+    }
+    m.appendTail(sb);
+
+    byte[] html = new MarkdownFormatter().markdownToDocHtml(sb.toString(), UTF_8.name());
+    resourceCache.put(
+        cacheKey,
+        new SmallResource(html)
+            .setContentType("text/html")
+            .setCharacterEncoding(UTF_8.name())
+            .setLastModified(lastModifiedTime));
+    res.setContentType("text/html");
+    res.setCharacterEncoding(UTF_8.name());
+    res.setContentLength(html.length);
+    res.setDateHeader("Last-Modified", lastModifiedTime);
+    res.getOutputStream().write(html);
+  }
+
+  private static void appendPluginInfoTable(StringBuilder html, Attributes main) {
+    if (main != null) {
+      String t = main.getValue(Attributes.Name.IMPLEMENTATION_TITLE);
+      String n = main.getValue(Attributes.Name.IMPLEMENTATION_VENDOR);
+      String v = main.getValue(Attributes.Name.IMPLEMENTATION_VERSION);
+      String a = main.getValue("Gerrit-ApiVersion");
+
+      html.append("<table class=\"plugin_info\">");
+      if (!Strings.isNullOrEmpty(t)) {
+        html.append("<tr><th>Name</th><td>").append(t).append("</td></tr>\n");
+      }
+      if (!Strings.isNullOrEmpty(n)) {
+        html.append("<tr><th>Vendor</th><td>").append(n).append("</td></tr>\n");
+      }
+      if (!Strings.isNullOrEmpty(v)) {
+        html.append("<tr><th>Version</th><td>").append(v).append("</td></tr>\n");
+      }
+      if (!Strings.isNullOrEmpty(a)) {
+        html.append("<tr><th>API Version</th><td>").append(a).append("</td></tr>\n");
+      }
+      html.append("</table>\n");
+    }
+  }
+
+  private static String extractTitleFromMarkdown(PluginContentScanner scanner, PluginEntry entry)
+      throws IOException {
+    String charEnc = null;
+    Map<Object, String> atts = entry.getAttrs();
+    if (atts != null) {
+      charEnc = Strings.emptyToNull(atts.get(ATTR_CHARACTER_ENCODING));
+    }
+    if (charEnc == null) {
+      charEnc = UTF_8.name();
+    }
+    return new MarkdownFormatter()
+        .extractTitleFromMarkdown(readWholeEntry(scanner, entry), charEnc);
+  }
+
+  private static Optional<PluginEntry> findSource(PluginContentScanner scanner, String file)
+      throws IOException {
+    if (file.endsWith(".html")) {
+      int d = file.lastIndexOf('.');
+      return scanner.getEntry(file.substring(0, d) + ".md");
+    }
+    return Optional.empty();
+  }
+
+  private void sendMarkdownAsHtml(
+      PluginContentScanner scanner,
+      PluginEntry entry,
+      String pluginName,
+      PluginResourceKey key,
+      HttpServletResponse res)
+      throws IOException {
+    byte[] rawmd = readWholeEntry(scanner, entry);
+    String encoding = null;
+    Map<Object, String> atts = entry.getAttrs();
+    if (atts != null) {
+      encoding = Strings.emptyToNull(atts.get(ATTR_CHARACTER_ENCODING));
+    }
+
+    String txtmd =
+        RawParseUtils.decode(Charset.forName(encoding != null ? encoding : UTF_8.name()), rawmd);
+    long time = entry.getTime();
+    if (0 < time) {
+      res.setDateHeader("Last-Modified", time);
+    }
+    sendMarkdownAsHtml(txtmd, pluginName, key, res, time);
+  }
+
+  private void sendResource(
+      PluginContentScanner scanner,
+      PluginEntry entry,
+      PluginResourceKey key,
+      HttpServletResponse res)
+      throws IOException {
+    byte[] data = null;
+    Optional<Long> size = entry.getSize();
+    if (size.isPresent() && size.get() <= SMALL_RESOURCE) {
+      data = readWholeEntry(scanner, entry);
+    }
+
+    String contentType = null;
+    String charEnc = null;
+    Map<Object, String> atts = entry.getAttrs();
+    if (atts != null) {
+      contentType = Strings.emptyToNull(atts.get(ATTR_CONTENT_TYPE));
+      charEnc = Strings.emptyToNull(atts.get(ATTR_CHARACTER_ENCODING));
+    }
+    if (contentType == null) {
+      contentType = mimeUtil.getMimeType(entry.getName(), data).toString();
+      if ("application/octet-stream".equals(contentType) && entry.getName().endsWith(".js")) {
+        contentType = "application/javascript";
+      } else if ("application/x-pointplus".equals(contentType)
+          && entry.getName().endsWith(".css")) {
+        contentType = "text/css";
+      }
+    }
+
+    long time = entry.getTime();
+    if (0 < time) {
+      res.setDateHeader("Last-Modified", time);
+    }
+    if (size.isPresent()) {
+      res.setHeader("Content-Length", size.get().toString());
+    }
+    res.setContentType(contentType);
+    if (charEnc != null) {
+      res.setCharacterEncoding(charEnc);
+    }
+    if (data != null) {
+      resourceCache.put(
+          key,
+          new SmallResource(data)
+              .setContentType(contentType)
+              .setCharacterEncoding(charEnc)
+              .setLastModified(time));
+      res.getOutputStream().write(data);
+    } else {
+      writeToResponse(res, scanner.getInputStream(entry));
+    }
+  }
+
+  private void sendJsPlugin(
+      Plugin plugin, PluginResourceKey key, HttpServletRequest req, HttpServletResponse res)
+      throws IOException {
+    Path path = plugin.getSrcFile();
+    if (req.getRequestURI().endsWith(getJsPluginPath(plugin)) && Files.exists(path)) {
+      res.setHeader("Content-Length", Long.toString(Files.size(path)));
+      if (path.toString().toLowerCase(Locale.US).endsWith(".html")) {
+        res.setContentType("text/html");
+      } else {
+        res.setContentType("application/javascript");
+      }
+      writeToResponse(res, Files.newInputStream(path));
+    } else {
+      resourceCache.put(key, Resource.NOT_FOUND);
+      Resource.NOT_FOUND.send(req, res);
+    }
+  }
+
+  private static String getJsPluginPath(Plugin plugin) {
+    return String.format(
+        "/plugins/%s/static/%s", plugin.getName(), plugin.getSrcFile().getFileName());
+  }
+
+  private void writeToResponse(HttpServletResponse res, InputStream inputStream)
+      throws IOException {
+    try (InputStream in = inputStream;
+        OutputStream out = res.getOutputStream()) {
+      ByteStreams.copy(in, out);
+    }
+  }
+
+  private static byte[] readWholeEntry(PluginContentScanner scanner, PluginEntry entry)
+      throws IOException {
+    try (InputStream in = scanner.getInputStream(entry)) {
+      return IO.readWholeStream(in, entry.getSize().get().intValue()).array();
+    }
+  }
+
+  private static class PluginHolder {
+    final Plugin plugin;
+    final GuiceFilter filter;
+    final String staticPrefix;
+    final String docPrefix;
+
+    PluginHolder(Plugin plugin, GuiceFilter filter) {
+      this.plugin = plugin;
+      this.filter = filter;
+      this.staticPrefix = getPrefix(plugin, "Gerrit-HttpStaticPrefix", "static/");
+      this.docPrefix = getPrefix(plugin, "Gerrit-HttpDocumentationPrefix", "Documentation/");
+    }
+
+    private static String getPrefix(Plugin plugin, String attr, String def) {
+      Path path = plugin.getSrcFile();
+      PluginContentScanner scanner = plugin.getContentScanner();
+      if (path == null || scanner == PluginContentScanner.EMPTY) {
+        return def;
+      }
+      try {
+        String prefix = scanner.getManifest().getMainAttributes().getValue(attr);
+        if (prefix != null) {
+          return CharMatcher.is('/').trimFrom(prefix) + "/";
+        }
+        return def;
+      } catch (IOException e) {
+        log.warn(
+            String.format("Error getting %s for plugin %s, using default", attr, plugin.getName()),
+            e);
+        return null;
+      }
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java b/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
rename to java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginResourceKey.java b/java/com/google/gerrit/httpd/plugins/PluginResourceKey.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginResourceKey.java
rename to java/com/google/gerrit/httpd/plugins/PluginResourceKey.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginServletContext.java b/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
rename to java/com/google/gerrit/httpd/plugins/PluginServletContext.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java b/java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java
rename to java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BazelBuild.java b/java/com/google/gerrit/httpd/raw/BazelBuild.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BazelBuild.java
rename to java/com/google/gerrit/httpd/raw/BazelBuild.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsDevServlet.java b/java/com/google/gerrit/httpd/raw/BowerComponentsDevServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsDevServlet.java
rename to java/com/google/gerrit/httpd/raw/BowerComponentsDevServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java b/java/com/google/gerrit/httpd/raw/CatServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
rename to java/com/google/gerrit/httpd/raw/CatServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java b/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
rename to java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/DirectoryGwtUiServlet.java b/java/com/google/gerrit/httpd/raw/DirectoryGwtUiServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/DirectoryGwtUiServlet.java
rename to java/com/google/gerrit/httpd/raw/DirectoryGwtUiServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsDevServlet.java b/java/com/google/gerrit/httpd/raw/FontsDevServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsDevServlet.java
rename to java/com/google/gerrit/httpd/raw/FontsDevServlet.java
diff --git a/java/com/google/gerrit/httpd/raw/HostPageServlet.java b/java/com/google/gerrit/httpd/raw/HostPageServlet.java
new file mode 100644
index 0000000..ad903c7
--- /dev/null
+++ b/java/com/google/gerrit/httpd/raw/HostPageServlet.java
@@ -0,0 +1,408 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.gerrit.common.FileUtil.lastModified;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Strings;
+import com.google.common.hash.Hasher;
+import com.google.common.hash.Hashing;
+import com.google.common.primitives.Bytes;
+import com.google.gerrit.common.Version;
+import com.google.gerrit.common.data.HostPageData;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.systemstatus.MessageOfTheDay;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
+import com.google.gerrit.httpd.HtmlDomUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.account.GetDiffPreferences;
+import com.google.gwtexpui.server.CacheHeaders;
+import com.google.gwtjsonrpc.server.JsonServlet;
+import com.google.gwtjsonrpc.server.RPCServletUtils;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.StringWriter;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+
+/** Sends the Gerrit host page to clients. */
+@SuppressWarnings("serial")
+@Singleton
+public class HostPageServlet extends HttpServlet {
+  private static final Logger log = LoggerFactory.getLogger(HostPageServlet.class);
+
+  private static final String HPD_ID = "gerrit_hostpagedata";
+  private static final int DEFAULT_JS_LOAD_TIMEOUT = 5000;
+
+  private final Provider<CurrentUser> currentUser;
+  private final DynamicSet<WebUiPlugin> plugins;
+  private final DynamicSet<MessageOfTheDay> messages;
+  private final HostPageData.Theme signedOutTheme;
+  private final HostPageData.Theme signedInTheme;
+  private final SitePaths site;
+  private final Document template;
+  private final String noCacheName;
+  private final boolean refreshHeaderFooter;
+  private final SiteStaticDirectoryServlet staticServlet;
+  private final boolean isNoteDbEnabled;
+  private final Integer pluginsLoadTimeout;
+  private final boolean canLoadInIFrame;
+  private final GetDiffPreferences getDiff;
+  private volatile Page page;
+
+  @Inject
+  HostPageServlet(
+      Provider<CurrentUser> cu,
+      SitePaths sp,
+      ThemeFactory themeFactory,
+      ServletContext servletContext,
+      DynamicSet<WebUiPlugin> webUiPlugins,
+      DynamicSet<MessageOfTheDay> motd,
+      @GerritServerConfig Config cfg,
+      SiteStaticDirectoryServlet ss,
+      NotesMigration migration,
+      GetDiffPreferences diffPref)
+      throws IOException, ServletException {
+    currentUser = cu;
+    plugins = webUiPlugins;
+    messages = motd;
+    signedOutTheme = themeFactory.getSignedOutTheme();
+    signedInTheme = themeFactory.getSignedInTheme();
+    site = sp;
+    refreshHeaderFooter = cfg.getBoolean("site", "refreshHeaderFooter", true);
+    staticServlet = ss;
+    isNoteDbEnabled = migration.readChanges();
+    pluginsLoadTimeout = getPluginsLoadTimeout(cfg);
+    canLoadInIFrame = cfg.getBoolean("gerrit", "canLoadInIFrame", false);
+    getDiff = diffPref;
+
+    String pageName = "HostPage.html";
+    template = HtmlDomUtil.parseFile(getClass(), pageName);
+    if (template == null) {
+      throw new FileNotFoundException("No " + pageName + " in webapp");
+    }
+
+    if (HtmlDomUtil.find(template, "gerrit_module") == null) {
+      throw new ServletException("No gerrit_module in " + pageName);
+    }
+    if (HtmlDomUtil.find(template, HPD_ID) == null) {
+      throw new ServletException("No " + HPD_ID + " in " + pageName);
+    }
+
+    String src = "gerrit_ui/gerrit_ui.nocache.js";
+    try (InputStream in = servletContext.getResourceAsStream("/" + src)) {
+      if (in != null) {
+        Hasher md = Hashing.murmur3_128().newHasher();
+        byte[] buf = new byte[1024];
+        int n;
+        while ((n = in.read(buf)) > 0) {
+          md.putBytes(buf, 0, n);
+        }
+        src += "?content=" + md.hash().toString();
+      } else {
+        log.debug("No " + src + " in webapp root; keeping noncache.js URL");
+      }
+    } catch (IOException e) {
+      throw new IOException("Failed reading " + src, e);
+    }
+
+    noCacheName = src;
+    page = new Page();
+  }
+
+  private static int getPluginsLoadTimeout(Config cfg) {
+    long cfgValue =
+        ConfigUtil.getTimeUnit(
+            cfg, "plugins", null, "jsLoadTimeout", DEFAULT_JS_LOAD_TIMEOUT, TimeUnit.MILLISECONDS);
+    if (cfgValue < 0) {
+      return 0;
+    }
+    return (int) cfgValue;
+  }
+
+  private void json(Object data, StringWriter w) {
+    JsonServlet.defaultGsonBuilder().create().toJson(data, w);
+  }
+
+  private Page get() {
+    Page p = page;
+    try {
+      if (refreshHeaderFooter && p.isStale()) {
+        p = new Page();
+        page = p;
+      }
+    } catch (IOException e) {
+      log.error("Cannot refresh site header/footer", e);
+    }
+    return p;
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+    Page.Content page = select(req);
+    StringWriter w = new StringWriter();
+    CurrentUser user = currentUser.get();
+    if (user.isIdentifiedUser()) {
+      w.write(HPD_ID + ".accountDiffPref=");
+      json(getDiffPreferences(user.asIdentifiedUser()), w);
+      w.write(";");
+
+      w.write(HPD_ID + ".theme=");
+      json(signedInTheme, w);
+      w.write(";");
+    } else {
+      w.write(HPD_ID + ".theme=");
+      json(signedOutTheme, w);
+      w.write(";");
+    }
+    plugins(w);
+    messages(w);
+
+    byte[] hpd = w.toString().getBytes(UTF_8);
+    byte[] raw = Bytes.concat(page.part1, hpd, page.part2);
+    byte[] tosend;
+    if (RPCServletUtils.acceptsGzipEncoding(req)) {
+      rsp.setHeader("Content-Encoding", "gzip");
+      tosend = HtmlDomUtil.compress(raw);
+    } else {
+      tosend = raw;
+    }
+
+    CacheHeaders.setNotCacheable(rsp);
+    rsp.setContentType("text/html");
+    rsp.setCharacterEncoding(HtmlDomUtil.ENC.name());
+    rsp.setContentLength(tosend.length);
+    try (OutputStream out = rsp.getOutputStream()) {
+      out.write(tosend);
+    }
+  }
+
+  private DiffPreferencesInfo getDiffPreferences(IdentifiedUser user) {
+    try {
+      return getDiff.apply(new AccountResource(user));
+    } catch (AuthException | ConfigInvalidException | IOException | PermissionBackendException e) {
+      log.warn("Cannot query account diff preferences", e);
+    }
+    return DiffPreferencesInfo.defaults();
+  }
+
+  private void plugins(StringWriter w) {
+    List<String> urls = new ArrayList<>();
+    for (WebUiPlugin u : plugins) {
+      urls.add(String.format("plugins/%s/%s", u.getPluginName(), u.getJavaScriptResourcePath()));
+    }
+    if (!urls.isEmpty()) {
+      w.write(HPD_ID + ".plugins=");
+      json(urls, w);
+      w.write(";");
+    }
+  }
+
+  private void messages(StringWriter w) {
+    List<HostPageData.Message> list = new ArrayList<>(2);
+    for (MessageOfTheDay motd : messages) {
+      String html = motd.getHtmlMessage();
+      if (!Strings.isNullOrEmpty(html)) {
+        HostPageData.Message m = new HostPageData.Message();
+        m.id = motd.getMessageId();
+        m.redisplay = motd.getRedisplay();
+        m.html = html;
+        list.add(m);
+      }
+    }
+    if (!list.isEmpty()) {
+      w.write(HPD_ID + ".messages=");
+      json(list, w);
+      w.write(";");
+    }
+  }
+
+  private Page.Content select(HttpServletRequest req) {
+    Page pg = get();
+    if ("1".equals(req.getParameter("dbg"))) {
+      return pg.debug;
+    }
+    return pg.opt;
+  }
+
+  private void insertETags(Element e) {
+    if ("img".equalsIgnoreCase(e.getTagName()) || "script".equalsIgnoreCase(e.getTagName())) {
+      String src = e.getAttribute("src");
+      if (src != null && src.startsWith("static/")) {
+        String name = src.substring("static/".length());
+        ResourceServlet.Resource r = staticServlet.getResource(name);
+        if (r != null) {
+          e.setAttribute("src", src + "?e=" + r.etag);
+        }
+      }
+    }
+
+    for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling()) {
+      if (n instanceof Element) {
+        insertETags((Element) n);
+      }
+    }
+  }
+
+  private static class FileInfo {
+    private final Path path;
+    private final long time;
+
+    FileInfo(Path p) {
+      path = p;
+      time = lastModified(path);
+    }
+
+    boolean isStale() {
+      return time != lastModified(path);
+    }
+  }
+
+  private class Page {
+    private final FileInfo css;
+    private final FileInfo header;
+    private final FileInfo footer;
+    private final Content opt;
+    private final Content debug;
+
+    Page() throws IOException {
+      Document hostDoc = HtmlDomUtil.clone(template);
+
+      css = injectCssFile(hostDoc, "gerrit_sitecss", site.site_css);
+      header = injectXmlFile(hostDoc, "gerrit_header", site.site_header);
+      footer = injectXmlFile(hostDoc, "gerrit_footer", site.site_footer);
+
+      HostPageData pageData = new HostPageData();
+      pageData.version = Version.getVersion();
+      pageData.isNoteDbEnabled = isNoteDbEnabled;
+      pageData.pluginsLoadTimeout = pluginsLoadTimeout;
+      pageData.canLoadInIFrame = canLoadInIFrame;
+
+      StringWriter w = new StringWriter();
+      w.write("var " + HPD_ID + "=");
+      json(pageData, w);
+      w.write(";");
+
+      Element data = HtmlDomUtil.find(hostDoc, HPD_ID);
+      asScript(data);
+      data.appendChild(hostDoc.createTextNode(w.toString()));
+      data.appendChild(hostDoc.createComment(HPD_ID));
+
+      Element nocache = HtmlDomUtil.find(hostDoc, "gerrit_module");
+      asScript(nocache);
+      nocache.removeAttribute("id");
+      nocache.setAttribute("src", noCacheName);
+      opt = new Content(hostDoc);
+
+      nocache.setAttribute("src", "gerrit_ui/dbg_gerrit_ui.nocache.js");
+      debug = new Content(hostDoc);
+    }
+
+    boolean isStale() {
+      return css.isStale() || header.isStale() || footer.isStale();
+    }
+
+    private void asScript(Element scriptNode) {
+      scriptNode.setAttribute("type", "text/javascript");
+      scriptNode.setAttribute("language", "javascript");
+    }
+
+    class Content {
+      final byte[] part1;
+      final byte[] part2;
+
+      Content(Document hostDoc) throws IOException {
+        String raw = HtmlDomUtil.toString(hostDoc);
+        int p = raw.indexOf("<!--" + HPD_ID);
+        if (p < 0) {
+          throw new IOException("No tag in transformed host page HTML");
+        }
+        part1 = raw.substring(0, p).getBytes(UTF_8);
+        part2 = raw.substring(raw.indexOf('>', p) + 1).getBytes(UTF_8);
+      }
+    }
+
+    private FileInfo injectCssFile(Document hostDoc, String id, Path src) throws IOException {
+      FileInfo info = new FileInfo(src);
+      Element banner = HtmlDomUtil.find(hostDoc, id);
+      if (banner == null) {
+        return info;
+      }
+
+      while (banner.getFirstChild() != null) {
+        banner.removeChild(banner.getFirstChild());
+      }
+
+      String css = HtmlDomUtil.readFile(src.getParent(), src.getFileName().toString());
+      if (css == null) {
+        return info;
+      }
+
+      banner.appendChild(hostDoc.createCDATASection("\n" + css + "\n"));
+      return info;
+    }
+
+    private FileInfo injectXmlFile(Document hostDoc, String id, Path src) throws IOException {
+      FileInfo info = new FileInfo(src);
+      Element banner = HtmlDomUtil.find(hostDoc, id);
+      if (banner == null) {
+        return info;
+      }
+
+      while (banner.getFirstChild() != null) {
+        banner.removeChild(banner.getFirstChild());
+      }
+
+      Document html = HtmlDomUtil.parseFile(src);
+      if (html == null) {
+        return info;
+      }
+
+      Element content = html.getDocumentElement();
+      insertETags(content);
+      banner.appendChild(hostDoc.importNode(content, true));
+      return info;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
new file mode 100644
index 0000000..90b25d9
--- /dev/null
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
+import com.google.common.base.Strings;
+import com.google.common.io.Resources;
+import com.google.gerrit.common.Nullable;
+import com.google.template.soy.SoyFileSet;
+import com.google.template.soy.data.SanitizedContent;
+import com.google.template.soy.data.SoyMapData;
+import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
+import com.google.template.soy.tofu.SoyTofu;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class IndexServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+  protected final byte[] indexSource;
+
+  IndexServlet(String canonicalURL, @Nullable String cdnPath, @Nullable String faviconPath)
+      throws URISyntaxException {
+    String resourcePath = "com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy";
+    SoyFileSet.Builder builder = SoyFileSet.builder();
+    builder.add(Resources.getResource(resourcePath));
+    SoyTofu.Renderer renderer =
+        builder
+            .build()
+            .compileToTofu()
+            .newRenderer("com.google.gerrit.httpd.raw.Index")
+            .setContentKind(SanitizedContent.ContentKind.HTML)
+            .setData(getTemplateData(canonicalURL, cdnPath, faviconPath));
+    indexSource = renderer.render().getBytes(UTF_8);
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+    rsp.setCharacterEncoding(UTF_8.name());
+    rsp.setContentType("text/html");
+    rsp.setStatus(SC_OK);
+    try (OutputStream w = rsp.getOutputStream()) {
+      w.write(indexSource);
+    }
+  }
+
+  static String computeCanonicalPath(String canonicalURL) throws URISyntaxException {
+    if (Strings.isNullOrEmpty(canonicalURL)) {
+      return "";
+    }
+
+    // If we serving from a sub-directory rather than root, determine the path
+    // from the cannonical web URL.
+    URI uri = new URI(canonicalURL);
+    return uri.getPath().replaceAll("/$", "");
+  }
+
+  static SoyMapData getTemplateData(String canonicalURL, String cdnPath, String faviconPath)
+      throws URISyntaxException {
+    String canonicalPath = computeCanonicalPath(canonicalURL);
+
+    String staticPath = "";
+    if (cdnPath != null) {
+      staticPath = cdnPath;
+    } else if (canonicalPath != null) {
+      staticPath = canonicalPath;
+    }
+
+    // The resource path must be typed as safe for use in a script src.
+    // TODO(wyatta): Upgrade this to use an appropriate safe URL type.
+    SanitizedContent sanitizedStaticPath =
+        UnsafeSanitizedContentOrdainer.ordainAsSafe(
+            staticPath, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
+
+    return new SoyMapData(
+        "canonicalPath", canonicalPath,
+        "staticResourcePath", sanitizedStaticPath,
+        "faviconPath", faviconPath);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java b/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
rename to java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java b/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java
rename to java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java b/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
rename to java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java
rename to java/com/google/gerrit/httpd/raw/ResourceServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SingleFileServlet.java b/java/com/google/gerrit/httpd/raw/SingleFileServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SingleFileServlet.java
rename to java/com/google/gerrit/httpd/raw/SingleFileServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java b/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
rename to java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SshInfoServlet.java b/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
rename to java/com/google/gerrit/httpd/raw/SshInfoServlet.java
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
new file mode 100644
index 0000000..e1c094c
--- /dev/null
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -0,0 +1,602 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static java.nio.file.Files.exists;
+import static java.nio.file.Files.isReadable;
+
+import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.UiType;
+import com.google.gerrit.httpd.XsrfCookieFilter;
+import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
+import com.google.gerrit.launcher.GerritLauncher;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.GerritOptions;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Key;
+import com.google.inject.Provides;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import com.google.inject.name.Names;
+import com.google.inject.servlet.ServletModule;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.nio.file.FileSystem;
+import java.nio.file.Path;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class StaticModule extends ServletModule {
+  private static final Logger log = LoggerFactory.getLogger(StaticModule.class);
+
+  public static final String CACHE = "static_content";
+  public static final String GERRIT_UI_COOKIE = "GERRIT_UI";
+
+  /**
+   * Paths at which we should serve the main PolyGerrit application {@code index.html}.
+   *
+   * <p>Supports {@code "/*"} as a trailing wildcard.
+   */
+  public static final ImmutableList<String> POLYGERRIT_INDEX_PATHS =
+      ImmutableList.of(
+          "/", "/c/*", "/p/*", "/q/*", "/x/*", "/admin/*", "/dashboard/*", "/settings/*");
+  // TODO(dborowitz): These fragments conflict with the REST API
+  // namespace, so they will need to use a different path.
+  //"/groups/*",
+  //"/projects/*");
+  //
+
+  /**
+   * Paths that should be treated as static assets when serving PolyGerrit.
+   *
+   * <p>Supports {@code "/*"} as a trailing wildcard.
+   */
+  private static final ImmutableList<String> POLYGERRIT_ASSET_PATHS =
+      ImmutableList.of(
+          "/behaviors/*",
+          "/bower_components/*",
+          "/elements/*",
+          "/fonts/*",
+          "/scripts/*",
+          "/styles/*");
+
+  private static final String DOC_SERVLET = "DocServlet";
+  private static final String FAVICON_SERVLET = "FaviconServlet";
+  private static final String GWT_UI_SERVLET = "GwtUiServlet";
+  private static final String POLYGERRIT_INDEX_SERVLET = "PolyGerritUiIndexServlet";
+  private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet";
+
+  private static final int GERRIT_UI_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
+
+  private final GerritOptions options;
+  private Paths paths;
+
+  @Inject
+  public StaticModule(GerritOptions options) {
+    this.options = options;
+  }
+
+  @Provides
+  @Singleton
+  private Paths getPaths() {
+    if (paths == null) {
+      paths = new Paths(options);
+    }
+    return paths;
+  }
+
+  @Override
+  protected void configureServlets() {
+    serveRegex("^/Documentation/(.+)$").with(named(DOC_SERVLET));
+    serve("/static/*").with(SiteStaticDirectoryServlet.class);
+    install(
+        new CacheModule() {
+          @Override
+          protected void configure() {
+            cache(CACHE, Path.class, Resource.class)
+                .maximumWeight(1 << 20)
+                .weigher(ResourceServlet.Weigher.class);
+          }
+        });
+    if (!options.headless()) {
+      install(new CoreStaticModule());
+    }
+    if (options.enablePolyGerrit()) {
+      install(new PolyGerritModule());
+    }
+    if (options.enableGwtUi()) {
+      install(new GwtUiModule());
+    }
+  }
+
+  @Provides
+  @Singleton
+  @Named(DOC_SERVLET)
+  HttpServlet getDocServlet(@Named(CACHE) Cache<Path, Resource> cache) {
+    Paths p = getPaths();
+    if (p.warFs != null) {
+      return new WarDocServlet(cache, p.warFs);
+    } else if (p.unpackedWar != null && !p.isDev()) {
+      return new DirectoryDocServlet(cache, p.unpackedWar);
+    } else {
+      return new HttpServlet() {
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        protected void service(HttpServletRequest req, HttpServletResponse resp)
+            throws IOException {
+          resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+        }
+      };
+    }
+  }
+
+  private class CoreStaticModule extends ServletModule {
+    @Override
+    public void configureServlets() {
+      serve("/robots.txt").with(named(ROBOTS_TXT_SERVLET));
+      serve("/favicon.ico").with(named(FAVICON_SERVLET));
+    }
+
+    @Provides
+    @Singleton
+    @Named(ROBOTS_TXT_SERVLET)
+    HttpServlet getRobotsTxtServlet(
+        @GerritServerConfig Config cfg,
+        SitePaths sitePaths,
+        @Named(CACHE) Cache<Path, Resource> cache) {
+      Path configPath = sitePaths.resolve(cfg.getString("httpd", null, "robotsFile"));
+      if (configPath != null) {
+        if (exists(configPath) && isReadable(configPath)) {
+          return new SingleFileServlet(cache, configPath, true);
+        }
+        log.warn("Cannot read httpd.robotsFile, using default");
+      }
+      Paths p = getPaths();
+      if (p.warFs != null) {
+        return new SingleFileServlet(cache, p.warFs.getPath("/robots.txt"), false);
+      }
+      return new SingleFileServlet(cache, webappSourcePath("robots.txt"), true);
+    }
+
+    @Provides
+    @Singleton
+    @Named(FAVICON_SERVLET)
+    HttpServlet getFaviconServlet(@Named(CACHE) Cache<Path, Resource> cache) {
+      Paths p = getPaths();
+      if (p.warFs != null) {
+        return new SingleFileServlet(cache, p.warFs.getPath("/favicon.ico"), false);
+      }
+      return new SingleFileServlet(cache, webappSourcePath("favicon.ico"), true);
+    }
+
+    private Path webappSourcePath(String name) {
+      Paths p = getPaths();
+      if (p.unpackedWar != null) {
+        return p.unpackedWar.resolve(name);
+      }
+      return p.sourceRoot.resolve("webapp/" + name);
+    }
+  }
+
+  private class GwtUiModule extends ServletModule {
+    @Override
+    public void configureServlets() {
+      serveRegex("^/gerrit_ui/(?!rpc/)(.*)$")
+          .with(Key.get(HttpServlet.class, Names.named(GWT_UI_SERVLET)));
+      Paths p = getPaths();
+      if (p.isDev()) {
+        filter("/").through(new RecompileGwtUiFilter(p.builder, p.unpackedWar));
+      }
+    }
+
+    @Provides
+    @Singleton
+    @Named(GWT_UI_SERVLET)
+    HttpServlet getGwtUiServlet(@Named(CACHE) Cache<Path, Resource> cache) throws IOException {
+      Paths p = getPaths();
+      if (p.warFs != null) {
+        return new WarGwtUiServlet(cache, p.warFs);
+      }
+      return new DirectoryGwtUiServlet(cache, p.unpackedWar, p.isDev());
+    }
+  }
+
+  private class PolyGerritModule extends ServletModule {
+    @Override
+    public void configureServlets() {
+      for (String p : POLYGERRIT_INDEX_PATHS) {
+        // Skip XsrfCookieFilter for /, since that is already done in the GWT UI
+        // path (UrlModule).
+        if (!p.equals("/")) {
+          filter(p).through(XsrfCookieFilter.class);
+        }
+      }
+      filter("/*").through(PolyGerritFilter.class);
+    }
+
+    @Provides
+    @Singleton
+    @Named(POLYGERRIT_INDEX_SERVLET)
+    HttpServlet getPolyGerritUiIndexServlet(
+        @CanonicalWebUrl @Nullable String canonicalUrl, @GerritServerConfig Config cfg)
+        throws URISyntaxException {
+      String cdnPath = cfg.getString("gerrit", null, "cdnPath");
+      String faviconPath = cfg.getString("gerrit", null, "faviconPath");
+      return new IndexServlet(canonicalUrl, cdnPath, faviconPath);
+    }
+
+    @Provides
+    @Singleton
+    PolyGerritUiServlet getPolyGerritUiServlet(@Named(CACHE) Cache<Path, Resource> cache) {
+      return new PolyGerritUiServlet(cache, polyGerritBasePath());
+    }
+
+    @Provides
+    @Singleton
+    BowerComponentsDevServlet getBowerComponentsServlet(@Named(CACHE) Cache<Path, Resource> cache)
+        throws IOException {
+      return getPaths().isDev() ? new BowerComponentsDevServlet(cache, getPaths().builder) : null;
+    }
+
+    @Provides
+    @Singleton
+    FontsDevServlet getFontsServlet(@Named(CACHE) Cache<Path, Resource> cache) throws IOException {
+      return getPaths().isDev() ? new FontsDevServlet(cache, getPaths().builder) : null;
+    }
+
+    private Path polyGerritBasePath() {
+      Paths p = getPaths();
+      if (options.forcePolyGerritDev()) {
+        checkArgument(
+            p.sourceRoot != null, "no source root directory found for PolyGerrit developer mode");
+      }
+
+      if (p.isDev()) {
+        return p.sourceRoot.resolve("polygerrit-ui").resolve("app");
+      }
+
+      return p.warFs != null
+          ? p.warFs.getPath("/polygerrit_ui")
+          : p.unpackedWar.resolve("polygerrit_ui");
+    }
+  }
+
+  private static class Paths {
+    private final FileSystem warFs;
+    private final BazelBuild builder;
+    private final Path sourceRoot;
+    private final Path unpackedWar;
+    private final boolean development;
+
+    private Paths(GerritOptions options) {
+      try {
+        File launcherLoadedFrom = getLauncherLoadedFrom();
+        if (launcherLoadedFrom != null && launcherLoadedFrom.getName().endsWith(".jar")) {
+          // Special case: unpacked war archive deployed in container.
+          // The path is something like:
+          // <container>/<gerrit>/WEB-INF/lib/launcher.jar
+          // Switch to exploded war case with <container>/webapp>/<gerrit>
+          // root directory
+          warFs = null;
+          unpackedWar =
+              java.nio.file.Paths.get(
+                  launcherLoadedFrom.getParentFile().getParentFile().getParentFile().toURI());
+          sourceRoot = null;
+          development = false;
+          builder = null;
+          return;
+        }
+        warFs = getDistributionArchive(launcherLoadedFrom);
+        if (warFs == null) {
+          unpackedWar = makeWarTempDir();
+          development = true;
+        } else if (options.forcePolyGerritDev()) {
+          unpackedWar = null;
+          development = true;
+        } else {
+          unpackedWar = null;
+          development = false;
+          sourceRoot = null;
+          builder = null;
+          return;
+        }
+      } catch (IOException e) {
+        throw new ProvisionException("Error initializing static content paths", e);
+      }
+
+      sourceRoot = getSourceRootOrNull();
+      builder = new BazelBuild(sourceRoot);
+    }
+
+    private static Path getSourceRootOrNull() {
+      try {
+        return GerritLauncher.resolveInSourceRoot(".");
+      } catch (FileNotFoundException e) {
+        return null;
+      }
+    }
+
+    private FileSystem getDistributionArchive(File war) throws IOException {
+      if (war == null) {
+        return null;
+      }
+      return GerritLauncher.getZipFileSystem(war.toPath());
+    }
+
+    private File getLauncherLoadedFrom() {
+      File war;
+      try {
+        war = GerritLauncher.getDistributionArchive();
+      } catch (IOException e) {
+        if ((e instanceof FileNotFoundException)
+            && GerritLauncher.NOT_ARCHIVED.equals(e.getMessage())) {
+          return null;
+        }
+        ProvisionException pe = new ProvisionException("Error reading gerrit.war");
+        pe.initCause(e);
+        throw pe;
+      }
+      return war;
+    }
+
+    private boolean isDev() {
+      return development;
+    }
+
+    private Path makeWarTempDir() {
+      // Obtain our local temporary directory, but it comes back as a file
+      // so we have to switch it to be a directory post creation.
+      //
+      try {
+        File dstwar = GerritLauncher.createTempFile("gerrit_", "war");
+        if (!dstwar.delete() || !dstwar.mkdir()) {
+          throw new IOException("Cannot mkdir " + dstwar.getAbsolutePath());
+        }
+
+        // Jetty normally refuses to serve out of a symlinked directory, as
+        // a security feature. Try to resolve out any symlinks in the path.
+        //
+        try {
+          return dstwar.getCanonicalFile().toPath();
+        } catch (IOException e) {
+          return dstwar.getAbsoluteFile().toPath();
+        }
+      } catch (IOException e) {
+        ProvisionException pe = new ProvisionException("Cannot create war tempdir");
+        pe.initCause(e);
+        throw pe;
+      }
+    }
+  }
+
+  private static Key<HttpServlet> named(String name) {
+    return Key.get(HttpServlet.class, Names.named(name));
+  }
+
+  @Singleton
+  private static class PolyGerritFilter implements Filter {
+    private final GerritOptions options;
+    private final Paths paths;
+    private final HttpServlet polyGerritIndex;
+    private final PolyGerritUiServlet polygerritUI;
+    private final BowerComponentsDevServlet bowerComponentServlet;
+    private final FontsDevServlet fontServlet;
+
+    @Inject
+    PolyGerritFilter(
+        GerritOptions options,
+        Paths paths,
+        @Named(POLYGERRIT_INDEX_SERVLET) HttpServlet polyGerritIndex,
+        PolyGerritUiServlet polygerritUI,
+        @Nullable BowerComponentsDevServlet bowerComponentServlet,
+        @Nullable FontsDevServlet fontServlet) {
+      this.paths = paths;
+      this.options = options;
+      this.polyGerritIndex = polyGerritIndex;
+      this.polygerritUI = polygerritUI;
+      this.bowerComponentServlet = bowerComponentServlet;
+      this.fontServlet = fontServlet;
+      checkState(
+          options.enablePolyGerrit(), "can't install PolyGerritFilter when PolyGerrit is disabled");
+    }
+
+    @Override
+    public void init(FilterConfig filterConfig) throws ServletException {}
+
+    @Override
+    public void destroy() {}
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+        throws IOException, ServletException {
+      HttpServletRequest req = (HttpServletRequest) request;
+      HttpServletResponse res = (HttpServletResponse) response;
+      if (handlePolyGerritParam(req, res)) {
+        return;
+      }
+      if (!isPolyGerritEnabled(req)) {
+        chain.doFilter(req, res);
+        return;
+      }
+
+      GuiceFilterRequestWrapper reqWrapper = new GuiceFilterRequestWrapper(req);
+      String path = pathInfo(req);
+
+      // Special case assets during development that are built by Buck and not
+      // served out of the source tree.
+      //
+      // In the war case, these are either inlined by vulcanize, or live under
+      // /polygerrit_ui in the war file, so we can just treat them as normal
+      // assets.
+      if (paths.isDev()) {
+        if (path.startsWith("/bower_components/")) {
+          bowerComponentServlet.service(reqWrapper, res);
+          return;
+        } else if (path.startsWith("/fonts/")) {
+          fontServlet.service(reqWrapper, res);
+          return;
+        }
+      }
+
+      if (isPolyGerritIndex(path)) {
+        polyGerritIndex.service(reqWrapper, res);
+        return;
+      }
+      if (isPolyGerritAsset(path)) {
+        polygerritUI.service(reqWrapper, res);
+        return;
+      }
+
+      chain.doFilter(req, res);
+    }
+
+    private static String pathInfo(HttpServletRequest req) {
+      String uri = req.getRequestURI();
+      String ctx = req.getContextPath();
+      return uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri;
+    }
+
+    private boolean handlePolyGerritParam(HttpServletRequest req, HttpServletResponse res)
+        throws IOException {
+      if (!options.enableGwtUi() || !"GET".equals(req.getMethod())) {
+        return false;
+      }
+      boolean redirect = false;
+      String param = req.getParameter("polygerrit");
+      if ("1".equals(param)) {
+        setPolyGerritCookie(req, res, UiType.POLYGERRIT);
+        redirect = true;
+      } else if ("0".equals(param)) {
+        setPolyGerritCookie(req, res, UiType.GWT);
+        redirect = true;
+      }
+      if (redirect) {
+        // Strip polygerrit param from URL. This actually strips all params,
+        // which is a similar behavior to the JS PolyGerrit redirector code.
+        // Stripping just one param is frustratingly difficult without the use
+        // of Apache httpclient, which is a dep we don't want here:
+        // https://gerrit-review.googlesource.com/#/c/57570/57/gerrit-httpd/BUCK@32
+        res.sendRedirect(req.getRequestURL().toString());
+      }
+      return redirect;
+    }
+
+    private boolean isPolyGerritEnabled(HttpServletRequest req) {
+      return !options.enableGwtUi() || isPolyGerritCookie(req);
+    }
+
+    private boolean isPolyGerritCookie(HttpServletRequest req) {
+      UiType type = options.defaultUi();
+      Cookie[] all = req.getCookies();
+      if (all != null) {
+        for (Cookie c : all) {
+          if (GERRIT_UI_COOKIE.equals(c.getName())) {
+            UiType t = UiType.parse(c.getValue());
+            if (t != null) {
+              type = t;
+              break;
+            }
+          }
+        }
+      }
+      return type == UiType.POLYGERRIT;
+    }
+
+    private void setPolyGerritCookie(HttpServletRequest req, HttpServletResponse res, UiType pref) {
+      // Only actually set a cookie if both UIs are enabled in the server;
+      // otherwise clear it.
+      Cookie cookie = new Cookie(GERRIT_UI_COOKIE, pref.name());
+      if (options.enablePolyGerrit() && options.enableGwtUi()) {
+        cookie.setPath("/");
+        cookie.setSecure(isSecure(req));
+        cookie.setMaxAge(GERRIT_UI_COOKIE_MAX_AGE);
+      } else {
+        cookie.setValue("");
+        cookie.setMaxAge(0);
+      }
+      res.addCookie(cookie);
+    }
+
+    private static boolean isSecure(HttpServletRequest req) {
+      return req.isSecure() || "https".equals(req.getScheme());
+    }
+
+    private static boolean isPolyGerritAsset(String path) {
+      return matchPath(POLYGERRIT_ASSET_PATHS, path);
+    }
+
+    private static boolean isPolyGerritIndex(String path) {
+      return matchPath(POLYGERRIT_INDEX_PATHS, path);
+    }
+
+    private static boolean matchPath(Iterable<String> paths, String path) {
+      for (String p : paths) {
+        if (p.endsWith("/*")) {
+          if (path.regionMatches(0, p, 0, p.length() - 1)) {
+            return true;
+          }
+        } else if (p.equals(path)) {
+          return true;
+        }
+      }
+      return false;
+    }
+  }
+
+  private static class GuiceFilterRequestWrapper extends HttpServletRequestWrapper {
+    GuiceFilterRequestWrapper(HttpServletRequest req) {
+      super(req);
+    }
+
+    @Override
+    public String getPathInfo() {
+      String uri = getRequestURI();
+      String ctx = getContextPath();
+      // This is a workaround for long standing guice filter bug:
+      // https://github.com/google/guice/issues/807
+      String res = uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri;
+
+      // Match the logic in the ResourceServlet, that re-add "/"
+      // for null path info
+      if ("/".equals(res)) {
+        return null;
+      }
+      return res;
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ThemeFactory.java b/java/com/google/gerrit/httpd/raw/ThemeFactory.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ThemeFactory.java
rename to java/com/google/gerrit/httpd/raw/ThemeFactory.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ToolServlet.java b/java/com/google/gerrit/httpd/raw/ToolServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ToolServlet.java
rename to java/com/google/gerrit/httpd/raw/ToolServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/WarDocServlet.java b/java/com/google/gerrit/httpd/raw/WarDocServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/WarDocServlet.java
rename to java/com/google/gerrit/httpd/raw/WarDocServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/WarGwtUiServlet.java b/java/com/google/gerrit/httpd/raw/WarGwtUiServlet.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/WarGwtUiServlet.java
rename to java/com/google/gerrit/httpd/raw/WarGwtUiServlet.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/Resource.java b/java/com/google/gerrit/httpd/resources/Resource.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/Resource.java
rename to java/com/google/gerrit/httpd/resources/Resource.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/ResourceKey.java b/java/com/google/gerrit/httpd/resources/ResourceKey.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/ResourceKey.java
rename to java/com/google/gerrit/httpd/resources/ResourceKey.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/ResourceWeigher.java b/java/com/google/gerrit/httpd/resources/ResourceWeigher.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/ResourceWeigher.java
rename to java/com/google/gerrit/httpd/resources/ResourceWeigher.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/SmallResource.java b/java/com/google/gerrit/httpd/resources/SmallResource.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/SmallResource.java
rename to java/com/google/gerrit/httpd/resources/SmallResource.java
diff --git a/java/com/google/gerrit/httpd/restapi/AccessRestApiServlet.java b/java/com/google/gerrit/httpd/restapi/AccessRestApiServlet.java
new file mode 100644
index 0000000..7e3e0ca
--- /dev/null
+++ b/java/com/google/gerrit/httpd/restapi/AccessRestApiServlet.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.gerrit.server.restapi.access.AccessCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class AccessRestApiServlet extends RestApiServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Inject
+  AccessRestApiServlet(RestApiServlet.Globals globals, Provider<AccessCollection> access) {
+    super(globals, access);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/restapi/AccountsRestApiServlet.java b/java/com/google/gerrit/httpd/restapi/AccountsRestApiServlet.java
new file mode 100644
index 0000000..a1effb1
--- /dev/null
+++ b/java/com/google/gerrit/httpd/restapi/AccountsRestApiServlet.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.gerrit.server.restapi.account.AccountsCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class AccountsRestApiServlet extends RestApiServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Inject
+  AccountsRestApiServlet(RestApiServlet.Globals globals, Provider<AccountsCollection> accounts) {
+    super(globals, accounts);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/restapi/ChangesRestApiServlet.java b/java/com/google/gerrit/httpd/restapi/ChangesRestApiServlet.java
new file mode 100644
index 0000000..d35eb3e
--- /dev/null
+++ b/java/com/google/gerrit/httpd/restapi/ChangesRestApiServlet.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.gerrit.server.restapi.change.ChangesCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ChangesRestApiServlet extends RestApiServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Inject
+  ChangesRestApiServlet(RestApiServlet.Globals globals, Provider<ChangesCollection> changes) {
+    super(globals, changes);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java b/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java
new file mode 100644
index 0000000..4d56036
--- /dev/null
+++ b/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.gerrit.server.restapi.config.ConfigCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ConfigRestApiServlet extends RestApiServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Inject
+  ConfigRestApiServlet(
+      RestApiServlet.Globals globals, Provider<ConfigCollection> configCollection) {
+    super(globals, configCollection);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/restapi/GroupsRestApiServlet.java b/java/com/google/gerrit/httpd/restapi/GroupsRestApiServlet.java
new file mode 100644
index 0000000..fff696a
--- /dev/null
+++ b/java/com/google/gerrit/httpd/restapi/GroupsRestApiServlet.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.gerrit.server.restapi.group.GroupsCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GroupsRestApiServlet extends RestApiServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Inject
+  GroupsRestApiServlet(RestApiServlet.Globals globals, Provider<GroupsCollection> groups) {
+    super(globals, groups);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
rename to java/com/google/gerrit/httpd/restapi/ParameterParser.java
diff --git a/java/com/google/gerrit/httpd/restapi/ProjectsRestApiServlet.java b/java/com/google/gerrit/httpd/restapi/ProjectsRestApiServlet.java
new file mode 100644
index 0000000..d6b5db0
--- /dev/null
+++ b/java/com/google/gerrit/httpd/restapi/ProjectsRestApiServlet.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.gerrit.server.restapi.project.ProjectsCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ProjectsRestApiServlet extends RestApiServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Inject
+  ProjectsRestApiServlet(RestApiServlet.Globals globals, Provider<ProjectsCollection> projects) {
+    super(globals, projects);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java b/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
rename to java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
new file mode 100644
index 0000000..5b24284
--- /dev/null
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -0,0 +1,1315 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// WARNING: NoteDbUpdateManager cares about the package name RestApiServlet lives in.
+package com.google.gerrit.httpd.restapi;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_MAX_AGE;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
+import static com.google.common.net.HttpHeaders.AUTHORIZATION;
+import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
+import static com.google.common.net.HttpHeaders.ORIGIN;
+import static com.google.common.net.HttpHeaders.VARY;
+import static java.math.RoundingMode.CEILING;
+import static java.nio.charset.StandardCharsets.ISO_8859_1;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
+import static javax.servlet.http.HttpServletResponse.SC_ACCEPTED;
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
+import static javax.servlet.http.HttpServletResponse.SC_CREATED;
+import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
+import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
+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.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.io.BaseEncoding;
+import com.google.common.io.CountingOutputStream;
+import com.google.common.math.IntMath;
+import com.google.common.net.HttpHeaders;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AcceptsDelete;
+import com.google.gerrit.extensions.restapi.AcceptsPost;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.CacheControl;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.ETagView;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.NeedsParams;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+import com.google.gerrit.extensions.restapi.RawInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollection;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.httpd.WebSession;
+import com.google.gerrit.httpd.restapi.ParameterParser.QueryParams;
+import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.OptionUtil;
+import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.audit.AuditService;
+import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.util.http.RequestUtil;
+import com.google.gson.ExclusionStrategy;
+import com.google.gson.FieldAttributes;
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+import com.google.gson.stream.JsonWriter;
+import com.google.gson.stream.MalformedJsonException;
+import com.google.gwtexpui.server.CacheHeaders;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.TypeLiteral;
+import com.google.inject.util.Providers;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.EOFException;
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+import java.util.zip.GZIPOutputStream;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.util.TemporaryBuffer;
+import org.eclipse.jgit.util.TemporaryBuffer.Heap;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class RestApiServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+  private static final Logger log = LoggerFactory.getLogger(RestApiServlet.class);
+
+  /** MIME type used for a JSON response body. */
+  private static final String JSON_TYPE = "application/json";
+
+  private static final String FORM_TYPE = "application/x-www-form-urlencoded";
+
+  // HTTP 422 Unprocessable Entity.
+  // TODO: Remove when HttpServletResponse.SC_UNPROCESSABLE_ENTITY is available
+  private static final int SC_UNPROCESSABLE_ENTITY = 422;
+  private static final String X_REQUESTED_WITH = "X-Requested-With";
+  private static final String X_GERRIT_AUTH = "X-Gerrit-Auth";
+  static final ImmutableSet<String> ALLOWED_CORS_METHODS =
+      ImmutableSet.of("GET", "HEAD", "POST", "PUT", "DELETE");
+  private static final ImmutableSet<String> ALLOWED_CORS_REQUEST_HEADERS =
+      Stream.of(AUTHORIZATION, CONTENT_TYPE, X_GERRIT_AUTH, X_REQUESTED_WITH)
+          .map(s -> s.toLowerCase(Locale.US))
+          .collect(ImmutableSet.toImmutableSet());
+
+  public static final String XD_AUTHORIZATION = "access_token";
+  public static final String XD_CONTENT_TYPE = "$ct";
+  public static final String XD_METHOD = "$m";
+
+  private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
+
+  /**
+   * Garbage prefix inserted before JSON output to prevent XSSI.
+   *
+   * <p>This prefix is ")]}'\n" and is designed to prevent a web browser from executing the response
+   * body if the resource URI were to be referenced using a &lt;script src="...&gt; HTML tag from
+   * another web site. Clients using the HTTP interface will need to always strip the first line of
+   * response data to remove this magic header.
+   */
+  public static final byte[] JSON_MAGIC;
+
+  static {
+    JSON_MAGIC = ")]}'\n".getBytes(UTF_8);
+  }
+
+  public static class Globals {
+    final Provider<CurrentUser> currentUser;
+    final DynamicItem<WebSession> webSession;
+    final Provider<ParameterParser> paramParser;
+    final PermissionBackend permissionBackend;
+    final AuditService auditService;
+    final RestApiMetrics metrics;
+    final Pattern allowOrigin;
+
+    @Inject
+    Globals(
+        Provider<CurrentUser> currentUser,
+        DynamicItem<WebSession> webSession,
+        Provider<ParameterParser> paramParser,
+        PermissionBackend permissionBackend,
+        AuditService auditService,
+        RestApiMetrics metrics,
+        @GerritServerConfig Config cfg) {
+      this.currentUser = currentUser;
+      this.webSession = webSession;
+      this.paramParser = paramParser;
+      this.permissionBackend = permissionBackend;
+      this.auditService = auditService;
+      this.metrics = metrics;
+      allowOrigin = makeAllowOrigin(cfg);
+    }
+
+    private static Pattern makeAllowOrigin(Config cfg) {
+      String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
+      if (allow.length > 0) {
+        return Pattern.compile(Joiner.on('|').join(allow));
+      }
+      return null;
+    }
+  }
+
+  private final Globals globals;
+  private final Provider<RestCollection<RestResource, RestResource>> members;
+
+  public RestApiServlet(
+      Globals globals, RestCollection<? extends RestResource, ? extends RestResource> members) {
+    this(globals, Providers.of(members));
+  }
+
+  public RestApiServlet(
+      Globals globals,
+      Provider<? extends RestCollection<? extends RestResource, ? extends RestResource>> members) {
+    @SuppressWarnings("unchecked")
+    Provider<RestCollection<RestResource, RestResource>> n =
+        (Provider<RestCollection<RestResource, RestResource>>) checkNotNull((Object) members);
+    this.globals = globals;
+    this.members = n;
+  }
+
+  @Override
+  protected final void service(HttpServletRequest req, HttpServletResponse res)
+      throws ServletException, IOException {
+    final long startNanos = System.nanoTime();
+    long auditStartTs = TimeUtil.nowMs();
+    res.setHeader("Content-Disposition", "attachment");
+    res.setHeader("X-Content-Type-Options", "nosniff");
+    int status = SC_OK;
+    long responseBytes = -1;
+    Object result = null;
+    QueryParams qp = null;
+    Object inputRequestBody = null;
+    RestResource rsrc = TopLevelResource.INSTANCE;
+    ViewData viewData = null;
+
+    try {
+      if (isCorsPreflight(req)) {
+        doCorsPreflight(req, res);
+        return;
+      }
+
+      qp = ParameterParser.getQueryParams(req);
+      checkCors(req, res, qp.hasXdOverride());
+      if (qp.hasXdOverride()) {
+        req = applyXdOverrides(req, qp);
+      }
+      checkUserSession(req);
+
+      List<IdString> path = splitPath(req);
+      RestCollection<RestResource, RestResource> rc = members.get();
+      globals
+          .permissionBackend
+          .user(globals.currentUser)
+          .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
+
+      viewData = new ViewData(null, null);
+
+      if (path.isEmpty()) {
+        if (rc instanceof NeedsParams) {
+          ((NeedsParams) rc).setParams(qp.params());
+        }
+
+        if (isRead(req)) {
+          viewData = new ViewData(null, rc.list());
+        } else if (rc instanceof AcceptsPost && "POST".equals(req.getMethod())) {
+          @SuppressWarnings("unchecked")
+          AcceptsPost<RestResource> ac = (AcceptsPost<RestResource>) rc;
+          viewData = new ViewData(null, ac.post(rsrc));
+        } else {
+          throw new MethodNotAllowedException();
+        }
+      } else {
+        IdString id = path.remove(0);
+        try {
+          rsrc = rc.parse(rsrc, id);
+          if (path.isEmpty()) {
+            checkPreconditions(req);
+          }
+        } catch (ResourceNotFoundException e) {
+          if (rc instanceof AcceptsCreate
+              && path.isEmpty()
+              && ("POST".equals(req.getMethod()) || "PUT".equals(req.getMethod()))) {
+            @SuppressWarnings("unchecked")
+            AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) rc;
+            viewData = new ViewData(null, ac.create(rsrc, id));
+            status = SC_CREATED;
+          } else {
+            throw e;
+          }
+        }
+        if (viewData.view == null) {
+          viewData = view(rsrc, rc, req.getMethod(), path);
+        }
+      }
+      checkRequiresCapability(viewData);
+
+      while (viewData.view instanceof RestCollection<?, ?>) {
+        @SuppressWarnings("unchecked")
+        RestCollection<RestResource, RestResource> c =
+            (RestCollection<RestResource, RestResource>) viewData.view;
+
+        if (path.isEmpty()) {
+          if (isRead(req)) {
+            viewData = new ViewData(null, c.list());
+          } else if (c instanceof AcceptsPost && "POST".equals(req.getMethod())) {
+            @SuppressWarnings("unchecked")
+            AcceptsPost<RestResource> ac = (AcceptsPost<RestResource>) c;
+            viewData = new ViewData(null, ac.post(rsrc));
+          } else if (c instanceof AcceptsDelete && "DELETE".equals(req.getMethod())) {
+            @SuppressWarnings("unchecked")
+            AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c;
+            viewData = new ViewData(null, ac.delete(rsrc, null));
+          } else {
+            throw new MethodNotAllowedException();
+          }
+          break;
+        }
+        IdString id = path.remove(0);
+        try {
+          rsrc = c.parse(rsrc, id);
+          checkPreconditions(req);
+          viewData = new ViewData(null, null);
+        } catch (ResourceNotFoundException e) {
+          if (c instanceof AcceptsCreate
+              && path.isEmpty()
+              && ("POST".equals(req.getMethod()) || "PUT".equals(req.getMethod()))) {
+            @SuppressWarnings("unchecked")
+            AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) c;
+            viewData = new ViewData(viewData.pluginName, ac.create(rsrc, id));
+            status = SC_CREATED;
+          } else if (c instanceof AcceptsDelete
+              && path.isEmpty()
+              && "DELETE".equals(req.getMethod())) {
+            @SuppressWarnings("unchecked")
+            AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c;
+            viewData = new ViewData(viewData.pluginName, ac.delete(rsrc, id));
+            status = SC_NO_CONTENT;
+          } else {
+            throw e;
+          }
+        }
+        if (viewData.view == null) {
+          viewData = view(rsrc, c, req.getMethod(), path);
+        }
+        checkRequiresCapability(viewData);
+      }
+
+      if (notModified(req, rsrc, viewData.view)) {
+        res.sendError(SC_NOT_MODIFIED);
+        return;
+      }
+
+      if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
+        return;
+      }
+
+      if (viewData.view instanceof RestReadView<?> && isRead(req)) {
+        result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
+      } else if (viewData.view instanceof RestModifyView<?, ?>) {
+        @SuppressWarnings("unchecked")
+        RestModifyView<RestResource, Object> m =
+            (RestModifyView<RestResource, Object>) viewData.view;
+
+        Type type = inputType(m);
+        inputRequestBody = parseRequest(req, type);
+        result = m.apply(rsrc, inputRequestBody);
+        consumeRawInputRequestBody(req, type);
+      } else {
+        throw new ResourceNotFoundException();
+      }
+
+      if (result instanceof Response) {
+        @SuppressWarnings("rawtypes")
+        Response<?> r = (Response) result;
+        status = r.statusCode();
+        configureCaching(req, res, rsrc, viewData.view, r.caching());
+      } else if (result instanceof Response.Redirect) {
+        CacheHeaders.setNotCacheable(res);
+        res.sendRedirect(((Response.Redirect) result).location());
+        return;
+      } else if (result instanceof Response.Accepted) {
+        CacheHeaders.setNotCacheable(res);
+        res.setStatus(SC_ACCEPTED);
+        res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location());
+        return;
+      } else {
+        CacheHeaders.setNotCacheable(res);
+      }
+      res.setStatus(status);
+
+      if (result != Response.none()) {
+        result = Response.unwrap(result);
+        if (result instanceof BinaryResult) {
+          responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
+        } else {
+          responseBytes = replyJson(req, res, qp.config(), result);
+        }
+      }
+    } catch (MalformedJsonException e) {
+      responseBytes =
+          replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
+    } catch (JsonParseException e) {
+      responseBytes =
+          replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
+    } catch (BadRequestException e) {
+      responseBytes =
+          replyError(
+              req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
+    } catch (AuthException e) {
+      responseBytes =
+          replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
+    } catch (AmbiguousViewException e) {
+      responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
+    } catch (ResourceNotFoundException e) {
+      responseBytes =
+          replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e);
+    } catch (MethodNotAllowedException e) {
+      responseBytes =
+          replyError(
+              req,
+              res,
+              status = SC_METHOD_NOT_ALLOWED,
+              messageOr(e, "Method Not Allowed"),
+              e.caching(),
+              e);
+    } catch (ResourceConflictException e) {
+      responseBytes =
+          replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e);
+    } catch (PreconditionFailedException e) {
+      responseBytes =
+          replyError(
+              req,
+              res,
+              status = SC_PRECONDITION_FAILED,
+              messageOr(e, "Precondition Failed"),
+              e.caching(),
+              e);
+    } catch (UnprocessableEntityException e) {
+      responseBytes =
+          replyError(
+              req,
+              res,
+              status = SC_UNPROCESSABLE_ENTITY,
+              messageOr(e, "Unprocessable Entity"),
+              e.caching(),
+              e);
+    } catch (NotImplementedException e) {
+      responseBytes =
+          replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
+    } catch (Exception e) {
+      status = SC_INTERNAL_SERVER_ERROR;
+      responseBytes = handleException(e, req, res);
+    } finally {
+      String metric =
+          viewData != null && viewData.view != null ? globals.metrics.view(viewData) : "_unknown";
+      globals.metrics.count.increment(metric);
+      if (status >= SC_BAD_REQUEST) {
+        globals.metrics.errorCount.increment(metric, status);
+      }
+      if (responseBytes != -1) {
+        globals.metrics.responseBytes.record(metric, responseBytes);
+      }
+      globals.metrics.serverLatency.record(
+          metric, System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
+      globals.auditService.dispatch(
+          new ExtendedHttpAuditEvent(
+              globals.webSession.get().getSessionId(),
+              globals.currentUser.get(),
+              req,
+              auditStartTs,
+              qp != null ? qp.params() : ImmutableListMultimap.of(),
+              inputRequestBody,
+              status,
+              result,
+              rsrc,
+              viewData == null ? null : viewData.view));
+    }
+  }
+
+  private static HttpServletRequest applyXdOverrides(HttpServletRequest req, QueryParams qp)
+      throws BadRequestException {
+    if (!"POST".equals(req.getMethod())) {
+      throw new BadRequestException("POST required");
+    }
+
+    String method = qp.xdMethod();
+    String contentType = qp.xdContentType();
+    if (method.equals("POST") || method.equals("PUT")) {
+      if (!"text/plain".equals(req.getContentType())) {
+        throw new BadRequestException("invalid " + CONTENT_TYPE);
+      } else if (Strings.isNullOrEmpty(contentType)) {
+        throw new BadRequestException(XD_CONTENT_TYPE + " required");
+      }
+    }
+
+    return new HttpServletRequestWrapper(req) {
+      @Override
+      public String getMethod() {
+        return method;
+      }
+
+      @Override
+      public String getContentType() {
+        return contentType;
+      }
+    };
+  }
+
+  private void checkCors(HttpServletRequest req, HttpServletResponse res, boolean isXd)
+      throws BadRequestException {
+    String origin = req.getHeader(ORIGIN);
+    if (isXd) {
+      // Cross-domain, non-preflighted requests must come from an approved origin.
+      if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
+        throw new BadRequestException("origin not allowed");
+      }
+      res.addHeader(VARY, ORIGIN);
+      res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+      res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+    } else if (!Strings.isNullOrEmpty(origin)) {
+      // All other requests must be processed, but conditionally set CORS headers.
+      if (globals.allowOrigin != null) {
+        res.addHeader(VARY, ORIGIN);
+      }
+      if (isOriginAllowed(origin)) {
+        setCorsHeaders(res, origin);
+      }
+    }
+  }
+
+  private static boolean isCorsPreflight(HttpServletRequest req) {
+    return "OPTIONS".equals(req.getMethod())
+        && !Strings.isNullOrEmpty(req.getHeader(ORIGIN))
+        && !Strings.isNullOrEmpty(req.getHeader(ACCESS_CONTROL_REQUEST_METHOD));
+  }
+
+  private void doCorsPreflight(HttpServletRequest req, HttpServletResponse res)
+      throws BadRequestException {
+    CacheHeaders.setNotCacheable(res);
+    setHeaderList(
+        res,
+        VARY,
+        ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS));
+
+    String origin = req.getHeader(ORIGIN);
+    if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
+      throw new BadRequestException("CORS not allowed");
+    }
+
+    String method = req.getHeader(ACCESS_CONTROL_REQUEST_METHOD);
+    if (!ALLOWED_CORS_METHODS.contains(method)) {
+      throw new BadRequestException(method + " not allowed in CORS");
+    }
+
+    String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS);
+    if (headers != null) {
+      for (String reqHdr : Splitter.on(',').trimResults().split(headers)) {
+        if (!ALLOWED_CORS_REQUEST_HEADERS.contains(reqHdr.toLowerCase(Locale.US))) {
+          throw new BadRequestException(reqHdr + " not allowed in CORS");
+        }
+      }
+    }
+
+    res.setStatus(SC_OK);
+    setCorsHeaders(res, origin);
+    res.setContentType("text/plain");
+    res.setContentLength(0);
+  }
+
+  private static void setCorsHeaders(HttpServletResponse res, String origin) {
+    res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+    res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+    res.setHeader(ACCESS_CONTROL_MAX_AGE, "600");
+    setHeaderList(
+        res,
+        ACCESS_CONTROL_ALLOW_METHODS,
+        Iterables.concat(ALLOWED_CORS_METHODS, ImmutableList.of("OPTIONS")));
+    setHeaderList(res, ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_REQUEST_HEADERS);
+  }
+
+  private static void setHeaderList(HttpServletResponse res, String name, Iterable<String> values) {
+    res.setHeader(name, Joiner.on(", ").join(values));
+  }
+
+  private boolean isOriginAllowed(String origin) {
+    return globals.allowOrigin != null && globals.allowOrigin.matcher(origin).matches();
+  }
+
+  private static String messageOr(Throwable t, String defaultMessage) {
+    if (!Strings.isNullOrEmpty(t.getMessage())) {
+      return t.getMessage();
+    }
+    return defaultMessage;
+  }
+
+  @SuppressWarnings({"unchecked", "rawtypes"})
+  private static boolean notModified(
+      HttpServletRequest req, RestResource rsrc, RestView<RestResource> view) {
+    if (!isRead(req)) {
+      return false;
+    }
+
+    if (view instanceof ETagView) {
+      String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
+      if (have != null) {
+        return have.equals(((ETagView) view).getETag(rsrc));
+      }
+    }
+
+    if (rsrc instanceof RestResource.HasETag) {
+      String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
+      if (have != null) {
+        return have.equals(((RestResource.HasETag) rsrc).getETag());
+      }
+    }
+
+    if (rsrc instanceof RestResource.HasLastModified) {
+      Timestamp m = ((RestResource.HasLastModified) rsrc).getLastModified();
+      long d = req.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE);
+
+      // HTTP times are in seconds, database may have millisecond precision.
+      return d / 1000L == m.getTime() / 1000L;
+    }
+    return false;
+  }
+
+  private static <R extends RestResource> void configureCaching(
+      HttpServletRequest req, HttpServletResponse res, R rsrc, RestView<R> view, CacheControl c) {
+    if (isRead(req)) {
+      switch (c.getType()) {
+        case NONE:
+        default:
+          CacheHeaders.setNotCacheable(res);
+          break;
+        case PRIVATE:
+          addResourceStateHeaders(res, rsrc, view);
+          CacheHeaders.setCacheablePrivate(res, c.getAge(), c.getUnit(), c.isMustRevalidate());
+          break;
+        case PUBLIC:
+          addResourceStateHeaders(res, rsrc, view);
+          CacheHeaders.setCacheable(req, res, c.getAge(), c.getUnit(), c.isMustRevalidate());
+          break;
+      }
+    } else {
+      CacheHeaders.setNotCacheable(res);
+    }
+  }
+
+  private static <R extends RestResource> void addResourceStateHeaders(
+      HttpServletResponse res, R rsrc, RestView<R> view) {
+    if (view instanceof ETagView) {
+      res.setHeader(HttpHeaders.ETAG, ((ETagView<R>) view).getETag(rsrc));
+    } else if (rsrc instanceof RestResource.HasETag) {
+      res.setHeader(HttpHeaders.ETAG, ((RestResource.HasETag) rsrc).getETag());
+    }
+    if (rsrc instanceof RestResource.HasLastModified) {
+      res.setDateHeader(
+          HttpHeaders.LAST_MODIFIED,
+          ((RestResource.HasLastModified) rsrc).getLastModified().getTime());
+    }
+  }
+
+  private void checkPreconditions(HttpServletRequest req) throws PreconditionFailedException {
+    if ("*".equals(req.getHeader(HttpHeaders.IF_NONE_MATCH))) {
+      throw new PreconditionFailedException("Resource already exists");
+    }
+  }
+
+  private static Type inputType(RestModifyView<RestResource, Object> m) {
+    // MyModifyView implements RestModifyView<SomeResource, MyInput>
+    TypeLiteral<?> typeLiteral = TypeLiteral.get(m.getClass());
+
+    // RestModifyView<SomeResource, MyInput>
+    // This is smart enough to resolve even when there are intervening subclasses, even if they have
+    // reordered type arguments.
+    TypeLiteral<?> supertypeLiteral = typeLiteral.getSupertype(RestModifyView.class);
+
+    Type supertype = supertypeLiteral.getType();
+    checkState(
+        supertype instanceof ParameterizedType,
+        "supertype of %s is not parameterized: %s",
+        typeLiteral,
+        supertypeLiteral);
+    return ((ParameterizedType) supertype).getActualTypeArguments()[1];
+  }
+
+  private Object parseRequest(HttpServletRequest req, Type type)
+      throws IOException, BadRequestException, SecurityException, IllegalArgumentException,
+          NoSuchMethodException, IllegalAccessException, InstantiationException,
+          InvocationTargetException, MethodNotAllowedException {
+    // HTTP/1.1 requires consuming the request body before writing non-error response (less than
+    // 400). Consume the request body for all but raw input request types here.
+    if (isType(JSON_TYPE, req.getContentType())) {
+      try (BufferedReader br = req.getReader();
+          JsonReader json = new JsonReader(br)) {
+        try {
+          json.setLenient(true);
+
+          JsonToken first;
+          try {
+            first = json.peek();
+          } catch (EOFException e) {
+            throw new BadRequestException("Expected JSON object");
+          }
+          if (first == JsonToken.STRING) {
+            return parseString(json.nextString(), type);
+          }
+          return OutputFormat.JSON.newGson().fromJson(json, type);
+        } finally {
+          // Reader.close won't consume the rest of the input. Explicitly consume the request body.
+          br.skip(Long.MAX_VALUE);
+        }
+      }
+    } else if (rawInputRequest(req, type)) {
+      return parseRawInput(req, type);
+    } else if ("DELETE".equals(req.getMethod()) && hasNoBody(req)) {
+      return null;
+    } else if (hasNoBody(req)) {
+      return createInstance(type);
+    } else if (isType("text/plain", req.getContentType())) {
+      try (BufferedReader br = req.getReader()) {
+        char[] tmp = new char[256];
+        StringBuilder sb = new StringBuilder();
+        int n;
+        while (0 < (n = br.read(tmp))) {
+          sb.append(tmp, 0, n);
+        }
+        return parseString(sb.toString(), type);
+      }
+    } else if ("POST".equals(req.getMethod()) && isType(FORM_TYPE, req.getContentType())) {
+      return OutputFormat.JSON.newGson().fromJson(ParameterParser.formToJson(req), type);
+    } else {
+      throw new BadRequestException("Expected Content-Type: " + JSON_TYPE);
+    }
+  }
+
+  private void consumeRawInputRequestBody(HttpServletRequest req, Type type) throws IOException {
+    if (rawInputRequest(req, type)) {
+      try (InputStream is = req.getInputStream()) {
+        ServletUtils.consumeRequestBody(is);
+      }
+    }
+  }
+
+  private static boolean rawInputRequest(HttpServletRequest req, Type type) {
+    String method = req.getMethod();
+    return ("PUT".equals(method) || "POST".equals(method)) && acceptsRawInput(type);
+  }
+
+  private static boolean hasNoBody(HttpServletRequest req) {
+    int len = req.getContentLength();
+    String type = req.getContentType();
+    return (len <= 0 && type == null) || (len == 0 && isType(FORM_TYPE, type));
+  }
+
+  @SuppressWarnings("rawtypes")
+  private static boolean acceptsRawInput(Type type) {
+    if (type instanceof Class) {
+      for (Field f : ((Class) type).getDeclaredFields()) {
+        if (f.getType() == RawInput.class) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  private Object parseRawInput(HttpServletRequest req, Type type)
+      throws SecurityException, NoSuchMethodException, IllegalArgumentException,
+          InstantiationException, IllegalAccessException, InvocationTargetException,
+          MethodNotAllowedException {
+    Object obj = createInstance(type);
+    for (Field f : obj.getClass().getDeclaredFields()) {
+      if (f.getType() == RawInput.class) {
+        f.setAccessible(true);
+        f.set(obj, RawInputUtil.create(req));
+        return obj;
+      }
+    }
+    throw new MethodNotAllowedException();
+  }
+
+  private Object parseString(String value, Type type)
+      throws BadRequestException, SecurityException, NoSuchMethodException,
+          IllegalArgumentException, IllegalAccessException, InstantiationException,
+          InvocationTargetException {
+    if (type == String.class) {
+      return value;
+    }
+
+    Object obj = createInstance(type);
+    Field[] fields = obj.getClass().getDeclaredFields();
+    if (fields.length == 0 && Strings.isNullOrEmpty(value)) {
+      return obj;
+    }
+    for (Field f : fields) {
+      if (f.getAnnotation(DefaultInput.class) != null && f.getType() == String.class) {
+        f.setAccessible(true);
+        f.set(obj, value);
+        return obj;
+      }
+    }
+    throw new BadRequestException("Expected JSON object");
+  }
+
+  private static Object createInstance(Type type)
+      throws NoSuchMethodException, InstantiationException, IllegalAccessException,
+          InvocationTargetException {
+    if (type instanceof Class) {
+      @SuppressWarnings("unchecked")
+      Class<Object> clazz = (Class<Object>) type;
+      Constructor<Object> c = clazz.getDeclaredConstructor();
+      c.setAccessible(true);
+      return c.newInstance();
+    }
+    throw new InstantiationException("Cannot make " + type);
+  }
+
+  public static long replyJson(
+      @Nullable HttpServletRequest req,
+      HttpServletResponse res,
+      ListMultimap<String, String> config,
+      Object result)
+      throws IOException {
+    TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, Integer.MAX_VALUE);
+    buf.write(JSON_MAGIC);
+    Writer w = new BufferedWriter(new OutputStreamWriter(buf, UTF_8));
+    Gson gson = newGson(config, req);
+    if (result instanceof JsonElement) {
+      gson.toJson((JsonElement) result, w);
+    } else {
+      gson.toJson(result, w);
+    }
+    w.write('\n');
+    w.flush();
+    return replyBinaryResult(
+        req, res, asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8));
+  }
+
+  private static Gson newGson(
+      ListMultimap<String, String> config, @Nullable HttpServletRequest req) {
+    GsonBuilder gb = OutputFormat.JSON_COMPACT.newGsonBuilder();
+
+    enablePrettyPrint(gb, config, req);
+    enablePartialGetFields(gb, config);
+
+    return gb.create();
+  }
+
+  private static void enablePrettyPrint(
+      GsonBuilder gb, ListMultimap<String, String> config, @Nullable HttpServletRequest req) {
+    String pp = Iterables.getFirst(config.get("pp"), null);
+    if (pp == null) {
+      pp = Iterables.getFirst(config.get("prettyPrint"), null);
+      if (pp == null && req != null) {
+        pp = acceptsJson(req) ? "0" : "1";
+      }
+    }
+    if ("1".equals(pp) || "true".equals(pp)) {
+      gb.setPrettyPrinting();
+    }
+  }
+
+  private static void enablePartialGetFields(GsonBuilder gb, ListMultimap<String, String> config) {
+    final Set<String> want = new HashSet<>();
+    for (String p : config.get("fields")) {
+      Iterables.addAll(want, OptionUtil.splitOptionValue(p));
+    }
+    if (!want.isEmpty()) {
+      gb.addSerializationExclusionStrategy(
+          new ExclusionStrategy() {
+            private final Map<String, String> names = new HashMap<>();
+
+            @Override
+            public boolean shouldSkipField(FieldAttributes field) {
+              String name = names.get(field.getName());
+              if (name == null) {
+                // Names are supplied by Gson in terms of Java source.
+                // Translate and cache the JSON lower_case_style used.
+                try {
+                  name =
+                      FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES.translateName( //
+                          field.getDeclaringClass().getDeclaredField(field.getName()));
+                  names.put(field.getName(), name);
+                } catch (SecurityException e) {
+                  return true;
+                } catch (NoSuchFieldException e) {
+                  return true;
+                }
+              }
+              return !want.contains(name);
+            }
+
+            @Override
+            public boolean shouldSkipClass(Class<?> clazz) {
+              return false;
+            }
+          });
+    }
+  }
+
+  @SuppressWarnings("resource")
+  static long replyBinaryResult(
+      @Nullable HttpServletRequest req, HttpServletResponse res, BinaryResult bin)
+      throws IOException {
+    final BinaryResult appResult = bin;
+    try {
+      if (bin.getAttachmentName() != null) {
+        res.setHeader(
+            "Content-Disposition", "attachment; filename=\"" + bin.getAttachmentName() + "\"");
+      }
+      if (bin.isBase64()) {
+        if (req != null && JSON_TYPE.equals(req.getHeader(HttpHeaders.ACCEPT))) {
+          bin = stackJsonString(res, bin);
+        } else {
+          bin = stackBase64(res, bin);
+        }
+      }
+      if (bin.canGzip() && acceptsGzip(req)) {
+        bin = stackGzip(res, bin);
+      }
+
+      res.setContentType(bin.getContentType());
+      long len = bin.getContentLength();
+      if (0 <= len && len < Integer.MAX_VALUE) {
+        res.setContentLength((int) len);
+      } else if (0 <= len) {
+        res.setHeader("Content-Length", Long.toString(len));
+      }
+
+      if (req == null || !"HEAD".equals(req.getMethod())) {
+        try (CountingOutputStream dst = new CountingOutputStream(res.getOutputStream())) {
+          bin.writeTo(dst);
+          return dst.getCount();
+        }
+      }
+      return 0;
+    } finally {
+      appResult.close();
+    }
+  }
+
+  private static BinaryResult stackJsonString(HttpServletResponse res, BinaryResult src)
+      throws IOException {
+    TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, Integer.MAX_VALUE);
+    buf.write(JSON_MAGIC);
+    try (Writer w = new BufferedWriter(new OutputStreamWriter(buf, UTF_8));
+        JsonWriter json = new JsonWriter(w)) {
+      json.setLenient(true);
+      json.setHtmlSafe(true);
+      json.value(src.asString());
+      w.write('\n');
+    }
+    res.setHeader("X-FYI-Content-Encoding", "json");
+    res.setHeader("X-FYI-Content-Type", src.getContentType());
+    return asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8);
+  }
+
+  private static BinaryResult stackBase64(HttpServletResponse res, BinaryResult src)
+      throws IOException {
+    BinaryResult b64;
+    long len = src.getContentLength();
+    if (0 <= len && len <= (7 << 20)) {
+      b64 = base64(src);
+    } else {
+      b64 =
+          new BinaryResult() {
+            @Override
+            public void writeTo(OutputStream out) throws IOException {
+              try (OutputStreamWriter w =
+                      new OutputStreamWriter(
+                          new FilterOutputStream(out) {
+                            @Override
+                            public void close() {
+                              // Do not close out, but only w and e.
+                            }
+                          },
+                          ISO_8859_1);
+                  OutputStream e = BaseEncoding.base64().encodingStream(w)) {
+                src.writeTo(e);
+              }
+            }
+          };
+    }
+    res.setHeader("X-FYI-Content-Encoding", "base64");
+    res.setHeader("X-FYI-Content-Type", src.getContentType());
+    return b64.setContentType("text/plain").setCharacterEncoding(ISO_8859_1);
+  }
+
+  private static BinaryResult stackGzip(HttpServletResponse res, BinaryResult src)
+      throws IOException {
+    BinaryResult gz;
+    long len = src.getContentLength();
+    if (len < 256) {
+      return src; // Do not compress very small payloads.
+    } else if (len <= (10 << 20)) {
+      gz = compress(src);
+      if (len <= gz.getContentLength()) {
+        return src;
+      }
+    } else {
+      gz =
+          new BinaryResult() {
+            @Override
+            public void writeTo(OutputStream out) throws IOException {
+              GZIPOutputStream gz = new GZIPOutputStream(out);
+              src.writeTo(gz);
+              gz.finish();
+              gz.flush();
+            }
+          };
+    }
+    res.setHeader("Content-Encoding", "gzip");
+    return gz.setContentType(src.getContentType());
+  }
+
+  private ViewData view(
+      RestResource rsrc,
+      RestCollection<RestResource, RestResource> rc,
+      String method,
+      List<IdString> path)
+      throws AmbiguousViewException, RestApiException {
+    DynamicMap<RestView<RestResource>> views = rc.views();
+    final IdString projection = path.isEmpty() ? IdString.fromUrl("/") : path.remove(0);
+    if (!path.isEmpty()) {
+      // If there are path components still remaining after this projection
+      // is chosen, look for the projection based upon GET as the method as
+      // the client thinks it is a nested collection.
+      method = "GET";
+    } else if ("HEAD".equals(method)) {
+      method = "GET";
+    }
+
+    List<String> p = splitProjection(projection);
+    if (p.size() == 2) {
+      String viewname = p.get(1);
+      if (Strings.isNullOrEmpty(viewname)) {
+        viewname = "/";
+      }
+      RestView<RestResource> view = views.get(p.get(0), method + "." + viewname);
+      if (view != null) {
+        return new ViewData(p.get(0), view);
+      }
+      view = views.get(p.get(0), "GET." + viewname);
+      if (view != null) {
+        if (view instanceof AcceptsPost && "POST".equals(method)) {
+          @SuppressWarnings("unchecked")
+          AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) view;
+          return new ViewData(p.get(0), ap.post(rsrc));
+        }
+      }
+      throw new ResourceNotFoundException(projection);
+    }
+
+    String name = method + "." + p.get(0);
+    RestView<RestResource> core = views.get("gerrit", name);
+    if (core != null) {
+      return new ViewData(null, core);
+    }
+    core = views.get("gerrit", "GET." + p.get(0));
+    if (core instanceof AcceptsPost && "POST".equals(method)) {
+      @SuppressWarnings("unchecked")
+      AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) core;
+      return new ViewData(null, ap.post(rsrc));
+    }
+
+    Map<String, RestView<RestResource>> r = new TreeMap<>();
+    for (String plugin : views.plugins()) {
+      RestView<RestResource> action = views.get(plugin, name);
+      if (action != null) {
+        r.put(plugin, action);
+      }
+    }
+
+    if (r.size() == 1) {
+      Map.Entry<String, RestView<RestResource>> entry = Iterables.getOnlyElement(r.entrySet());
+      return new ViewData(entry.getKey(), entry.getValue());
+    } else if (r.isEmpty()) {
+      throw new ResourceNotFoundException(projection);
+    } else {
+      throw new AmbiguousViewException(
+          String.format(
+              "Projection %s is ambiguous: %s",
+              name, r.keySet().stream().map(in -> in + "~" + projection).collect(joining(", "))));
+    }
+  }
+
+  private static List<IdString> splitPath(HttpServletRequest req) {
+    String path = RequestUtil.getEncodedPathInfo(req);
+    if (Strings.isNullOrEmpty(path)) {
+      return Collections.emptyList();
+    }
+    List<IdString> out = new ArrayList<>();
+    for (String p : Splitter.on('/').split(path)) {
+      out.add(IdString.fromUrl(p));
+    }
+    if (out.size() > 0 && out.get(out.size() - 1).isEmpty()) {
+      out.remove(out.size() - 1);
+    }
+    return out;
+  }
+
+  private static List<String> splitProjection(IdString projection) {
+    List<String> p = Lists.newArrayListWithCapacity(2);
+    Iterables.addAll(p, Splitter.on('~').limit(2).split(projection.get()));
+    return p;
+  }
+
+  private void checkUserSession(HttpServletRequest req) throws AuthException {
+    CurrentUser user = globals.currentUser.get();
+    if (isRead(req)) {
+      user.setAccessPath(AccessPath.REST_API);
+    } else if (user instanceof AnonymousUser) {
+      throw new AuthException("Authentication required");
+    } else if (!globals.webSession.get().isAccessPathOk(AccessPath.REST_API)) {
+      throw new AuthException(
+          "Invalid authentication method. In order to authenticate, "
+              + "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
+    }
+    if (user.isIdentifiedUser()) {
+      user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId());
+    }
+  }
+
+  private static boolean isRead(HttpServletRequest req) {
+    return "GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod());
+  }
+
+  private void checkRequiresCapability(ViewData d)
+      throws AuthException, PermissionBackendException {
+    globals
+        .permissionBackend
+        .user(globals.currentUser)
+        .checkAny(GlobalPermission.fromAnnotation(d.pluginName, d.view.getClass()));
+  }
+
+  private static long handleException(
+      Throwable err, HttpServletRequest req, HttpServletResponse res) throws IOException {
+    String uri = req.getRequestURI();
+    if (!Strings.isNullOrEmpty(req.getQueryString())) {
+      uri += "?" + req.getQueryString();
+    }
+    log.error(String.format("Error in %s %s", req.getMethod(), uri), err);
+
+    if (!res.isCommitted()) {
+      res.reset();
+      return replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error", err);
+    }
+    return 0;
+  }
+
+  public static long replyError(
+      HttpServletRequest req,
+      HttpServletResponse res,
+      int statusCode,
+      String msg,
+      @Nullable Throwable err)
+      throws IOException {
+    return replyError(req, res, statusCode, msg, CacheControl.NONE, err);
+  }
+
+  public static long replyError(
+      HttpServletRequest req,
+      HttpServletResponse res,
+      int statusCode,
+      String msg,
+      CacheControl c,
+      @Nullable Throwable err)
+      throws IOException {
+    if (err != null) {
+      RequestUtil.setErrorTraceAttribute(req, err);
+    }
+    configureCaching(req, res, null, null, c);
+    checkArgument(statusCode >= 400, "non-error status: %s", statusCode);
+    res.setStatus(statusCode);
+    return replyText(req, res, msg);
+  }
+
+  static long replyText(@Nullable HttpServletRequest req, HttpServletResponse res, String text)
+      throws IOException {
+    if ((req == null || isRead(req)) && isMaybeHTML(text)) {
+      return replyJson(req, res, ImmutableListMultimap.of("pp", "0"), new JsonPrimitive(text));
+    }
+    if (!text.endsWith("\n")) {
+      text += "\n";
+    }
+    return replyBinaryResult(req, res, BinaryResult.create(text).setContentType("text/plain"));
+  }
+
+  private static boolean isMaybeHTML(String text) {
+    return CharMatcher.anyOf("<&").matchesAnyOf(text);
+  }
+
+  private static boolean acceptsJson(HttpServletRequest req) {
+    return req != null && isType(JSON_TYPE, req.getHeader(HttpHeaders.ACCEPT));
+  }
+
+  private static boolean acceptsGzip(HttpServletRequest req) {
+    if (req != null) {
+      String accepts = req.getHeader(HttpHeaders.ACCEPT_ENCODING);
+      return accepts != null && accepts.contains("gzip");
+    }
+    return false;
+  }
+
+  private static boolean isType(String expect, String given) {
+    if (given == null) {
+      return false;
+    } else if (expect.equals(given)) {
+      return true;
+    } else if (given.startsWith(expect + ",")) {
+      return true;
+    }
+    for (String p : given.split("[ ,;][ ,;]*")) {
+      if (expect.equals(p)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static int base64MaxSize(long n) {
+    return 4 * IntMath.divide((int) n, 3, CEILING);
+  }
+
+  private static BinaryResult base64(BinaryResult bin) throws IOException {
+    int maxSize = base64MaxSize(bin.getContentLength());
+    int estSize = Math.min(base64MaxSize(HEAP_EST_SIZE), maxSize);
+    TemporaryBuffer.Heap buf = heap(estSize, maxSize);
+    try (OutputStream encoded =
+        BaseEncoding.base64().encodingStream(new OutputStreamWriter(buf, ISO_8859_1))) {
+      bin.writeTo(encoded);
+    }
+    return asBinaryResult(buf);
+  }
+
+  private static BinaryResult compress(BinaryResult bin) throws IOException {
+    TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, 20 << 20);
+    try (GZIPOutputStream gz = new GZIPOutputStream(buf)) {
+      bin.writeTo(gz);
+    }
+    return asBinaryResult(buf).setContentType(bin.getContentType());
+  }
+
+  @SuppressWarnings("resource")
+  private static BinaryResult asBinaryResult(TemporaryBuffer.Heap buf) {
+    return new BinaryResult() {
+      @Override
+      public void writeTo(OutputStream os) throws IOException {
+        buf.writeTo(os, null);
+      }
+    }.setContentLength(buf.length());
+  }
+
+  private static Heap heap(int est, int max) {
+    return new TemporaryBuffer.Heap(est, max);
+  }
+
+  @SuppressWarnings("serial")
+  private static class AmbiguousViewException extends Exception {
+    AmbiguousViewException(String message) {
+      super(message);
+    }
+  }
+
+  static class ViewData {
+    String pluginName;
+    RestView<RestResource> view;
+
+    ViewData(String pluginName, RestView<RestResource> view) {
+      this.pluginName = pluginName;
+      this.view = view;
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/AuditedHttpServletResponse.java b/java/com/google/gerrit/httpd/rpc/AuditedHttpServletResponse.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/AuditedHttpServletResponse.java
rename to java/com/google/gerrit/httpd/rpc/AuditedHttpServletResponse.java
diff --git a/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java b/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java
new file mode 100644
index 0000000..0a6d05b
--- /dev/null
+++ b/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java
@@ -0,0 +1,288 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.rpc;
+
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.audit.Audit;
+import com.google.gerrit.common.auth.SignInRequired;
+import com.google.gerrit.common.errors.NotSignedInException;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.httpd.WebSession;
+import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.audit.AuditService;
+import com.google.gerrit.server.audit.RpcAuditEvent;
+import com.google.gson.GsonBuilder;
+import com.google.gwtjsonrpc.common.RemoteJsonService;
+import com.google.gwtjsonrpc.server.ActiveCall;
+import com.google.gwtjsonrpc.server.JsonServlet;
+import com.google.gwtjsonrpc.server.MethodHandle;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Base JSON servlet to ensure the current user is not forged. */
+@SuppressWarnings("serial")
+final class GerritJsonServlet extends JsonServlet<GerritJsonServlet.GerritCall> {
+  private static final Logger log = LoggerFactory.getLogger(GerritJsonServlet.class);
+  private static final ThreadLocal<GerritCall> currentCall = new ThreadLocal<>();
+  private static final ThreadLocal<MethodHandle> currentMethod = new ThreadLocal<>();
+  private final DynamicItem<WebSession> session;
+  private final RemoteJsonService service;
+  private final AuditService audit;
+
+  @Inject
+  GerritJsonServlet(final DynamicItem<WebSession> w, RemoteJsonService s, AuditService a) {
+    session = w;
+    service = s;
+    audit = a;
+  }
+
+  @Override
+  protected GerritCall createActiveCall(final HttpServletRequest req, HttpServletResponse rsp) {
+    final GerritCall call = new GerritCall(session.get(), req, new AuditedHttpServletResponse(rsp));
+    currentCall.set(call);
+    return call;
+  }
+
+  @Override
+  protected GsonBuilder createGsonBuilder() {
+    return gerritDefaultGsonBuilder();
+  }
+
+  private static GsonBuilder gerritDefaultGsonBuilder() {
+    final GsonBuilder g = defaultGsonBuilder();
+
+    g.registerTypeAdapter(
+        org.eclipse.jgit.diff.Edit.class, new org.eclipse.jgit.diff.EditDeserializer());
+
+    return g;
+  }
+
+  @Override
+  protected void preInvoke(GerritCall call) {
+    super.preInvoke(call);
+
+    if (call.isComplete()) {
+      return;
+    }
+
+    if (call.getMethod().getAnnotation(SignInRequired.class) != null) {
+      // If SignInRequired is set on this method we must have both a
+      // valid XSRF token *and* have the user signed in. Doing these
+      // checks also validates that they agree on the user identity.
+      //
+      if (!call.requireXsrfValid() || !session.get().isSignedIn()) {
+        call.onFailure(new NotSignedInException());
+      }
+    }
+  }
+
+  @Override
+  protected Object createServiceHandle() {
+    return service;
+  }
+
+  @Override
+  protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
+    try {
+      super.service(req, resp);
+    } finally {
+      audit();
+      currentCall.set(null);
+    }
+  }
+
+  private void audit() {
+    try {
+      GerritCall call = currentCall.get();
+      MethodHandle method = call.getMethod();
+      if (method == null) {
+        return;
+      }
+      Audit note = method.getAnnotation(Audit.class);
+      if (note != null) {
+        String sid = call.getWebSession().getSessionId();
+        CurrentUser username = call.getWebSession().getUser();
+        ListMultimap<String, ?> args = extractParams(note, call);
+        String what = extractWhat(note, call);
+        Object result = call.getResult();
+
+        audit.dispatch(
+            new RpcAuditEvent(
+                sid,
+                username,
+                what,
+                call.getWhen(),
+                args,
+                call.getHttpServletRequest().getMethod(),
+                call.getHttpServletRequest().getMethod(),
+                ((AuditedHttpServletResponse) (call.getHttpServletResponse())).getStatus(),
+                result));
+      }
+    } catch (Throwable all) {
+      log.error("Unable to log the call", all);
+    }
+  }
+
+  private ListMultimap<String, ?> extractParams(Audit note, GerritCall call) {
+    ListMultimap<String, Object> args = MultimapBuilder.hashKeys().arrayListValues().build();
+
+    Object[] params = call.getParams();
+    for (int i = 0; i < params.length; i++) {
+      args.put("$" + i, params[i]);
+    }
+
+    for (int idx : note.obfuscate()) {
+      args.removeAll("$" + idx);
+      args.put("$" + idx, "*****");
+    }
+    return args;
+  }
+
+  private String extractWhat(Audit note, GerritCall call) {
+    Class<?> methodClass = call.getMethodClass();
+    String methodClassName = methodClass != null ? methodClass.getName() : "<UNKNOWN_CLASS>";
+    methodClassName = methodClassName.substring(methodClassName.lastIndexOf(".") + 1);
+    String what = note.action();
+    if (what.length() == 0) {
+      what = call.getMethod().getName();
+    }
+
+    return methodClassName + "." + what;
+  }
+
+  static class GerritCall extends ActiveCall {
+    private final WebSession session;
+    private final long when;
+    private static final Field resultField;
+    private static final Field methodField;
+
+    // Needed to allow access to non-public result field in GWT/JSON-RPC
+    static {
+      resultField = getPrivateField(ActiveCall.class, "result");
+      methodField = getPrivateField(MethodHandle.class, "method");
+    }
+
+    private static Field getPrivateField(Class<?> clazz, String fieldName) {
+      Field declaredField = null;
+      try {
+        declaredField = clazz.getDeclaredField(fieldName);
+        declaredField.setAccessible(true);
+      } catch (Exception e) {
+        log.error("Unable to expose RPS/JSON result field");
+      }
+      return declaredField;
+    }
+
+    // Surrogate of the missing getMethodClass() in GWT/JSON-RPC
+    public Class<?> getMethodClass() {
+      if (methodField == null) {
+        return null;
+      }
+
+      try {
+        Method method = (Method) methodField.get(this.getMethod());
+        return method.getDeclaringClass();
+      } catch (IllegalArgumentException e) {
+        log.error("Cannot access result field");
+      } catch (IllegalAccessException e) {
+        log.error("No permissions to access result field");
+      }
+
+      return null;
+    }
+
+    // Surrogate of the missing getResult() in GWT/JSON-RPC
+    public Object getResult() {
+      if (resultField == null) {
+        return null;
+      }
+
+      try {
+        return resultField.get(this);
+      } catch (IllegalArgumentException e) {
+        log.error("Cannot access result field");
+      } catch (IllegalAccessException e) {
+        log.error("No permissions to access result field");
+      }
+
+      return null;
+    }
+
+    GerritCall(WebSession session, HttpServletRequest i, HttpServletResponse o) {
+      super(i, o);
+      this.session = session;
+      this.when = TimeUtil.nowMs();
+    }
+
+    @Override
+    public MethodHandle getMethod() {
+      if (currentMethod.get() == null) {
+        return super.getMethod();
+      }
+      return currentMethod.get();
+    }
+
+    @Override
+    public void onFailure(Throwable error) {
+      if (error instanceof IllegalArgumentException || error instanceof IllegalStateException) {
+        super.onFailure(error);
+      } else if (error instanceof OrmException || error instanceof RuntimeException) {
+        onInternalFailure(error);
+      } else {
+        super.onFailure(error);
+      }
+    }
+
+    @Override
+    public boolean xsrfValidate() {
+      final String keyIn = getXsrfKeyIn();
+      if (keyIn == null || "".equals(keyIn)) {
+        // Anonymous requests don't need XSRF protection, they shouldn't
+        // be able to cause critical state changes.
+        //
+        return !session.isSignedIn();
+
+      } else if (session.isSignedIn() && session.isValidXGerritAuth(keyIn)) {
+        // The session must exist, and must be using this token.
+        //
+        session.getUser().setAccessPath(AccessPath.JSON_RPC);
+        return true;
+      }
+      return false;
+    }
+
+    public WebSession getWebSession() {
+      return session;
+    }
+
+    public long getWhen() {
+      return when;
+    }
+
+    public long getElapsed() {
+      return TimeUtil.nowMs() - when;
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java b/java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java
rename to java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java
diff --git a/java/com/google/gerrit/httpd/rpc/Handler.java b/java/com/google/gerrit/httpd/rpc/Handler.java
new file mode 100644
index 0000000..ae20571
--- /dev/null
+++ b/java/com/google/gerrit/httpd/rpc/Handler.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.rpc;
+
+import com.google.gerrit.common.errors.NoSuchEntityException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.NoSuchRefException;
+import com.google.gwtjsonrpc.common.AsyncCallback;
+import com.google.gwtjsonrpc.common.VoidResult;
+import com.google.gwtorm.server.OrmException;
+import java.util.concurrent.Callable;
+
+/**
+ * Base class for RPC service implementations.
+ *
+ * <p>Typically an RPC service implementation will extend this class and use Guice injection to
+ * manage its state. For example:
+ *
+ * <pre>
+ *   class Foo extends Handler&lt;Result&gt; {
+ *     interface Factory {
+ *       Foo create(... args ...);
+ *     }
+ *     &#064;Inject
+ *     Foo(state, @Assisted args) { ... }
+ *     Result get() throws Exception { ... }
+ *   }
+ * </pre>
+ *
+ * @param <T> type of result for {@link AsyncCallback#onSuccess(Object)} if the operation completed
+ *     successfully.
+ */
+public abstract class Handler<T> implements Callable<T> {
+  public static <T> Handler<T> wrap(Callable<T> r) {
+    return new Handler<T>() {
+      @Override
+      public T call() throws Exception {
+        return r.call();
+      }
+    };
+  }
+
+  /**
+   * Run the operation and pass the result to the callback.
+   *
+   * @param callback callback to receive the result of {@link #call()}.
+   */
+  public final void to(AsyncCallback<T> callback) {
+    try {
+      final T r = call();
+      if (r != null) {
+        callback.onSuccess(r);
+      }
+    } catch (NoSuchProjectException | NoSuchChangeException | NoSuchRefException e) {
+      callback.onFailure(new NoSuchEntityException());
+
+    } catch (OrmException e) {
+      if (e.getCause() instanceof NoSuchEntityException) {
+        callback.onFailure(e.getCause());
+
+      } else {
+        callback.onFailure(e);
+      }
+    } catch (Exception e) {
+      callback.onFailure(e);
+    }
+  }
+
+  /**
+   * Compute the operation result.
+   *
+   * @return the result of the operation. Return {@link VoidResult#INSTANCE} if there is no
+   *     meaningful return value for the operation.
+   * @throws Exception the operation failed. The caller will log the exception and the stack trace,
+   *     if it is worth logging on the server side.
+   */
+  @Override
+  public abstract T call() throws Exception;
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/RpcServletModule.java b/java/com/google/gerrit/httpd/rpc/RpcServletModule.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/RpcServletModule.java
rename to java/com/google/gerrit/httpd/rpc/RpcServletModule.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java b/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java
rename to java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/UiRpcModule.java b/java/com/google/gerrit/httpd/rpc/UiRpcModule.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/UiRpcModule.java
rename to java/com/google/gerrit/httpd/rpc/UiRpcModule.java
diff --git a/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java b/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
new file mode 100644
index 0000000..4af124f
--- /dev/null
+++ b/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.rpc.project;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.ProjectAccess;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CreateGroupPermissionSyncer;
+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.restapi.project.SetParent;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+class ChangeProjectAccess extends ProjectAccessHandler<ProjectAccess> {
+  interface Factory {
+    ChangeProjectAccess create(
+        @Assisted("projectName") Project.NameKey projectName,
+        @Nullable @Assisted ObjectId base,
+        @Assisted List<AccessSection> sectionList,
+        @Nullable @Assisted("parentProjectName") Project.NameKey parentProjectName,
+        @Nullable @Assisted String message);
+  }
+
+  private final GitReferenceUpdated gitRefUpdated;
+  private final ProjectAccessFactory.Factory projectAccessFactory;
+  private final ProjectCache projectCache;
+  private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
+
+  @Inject
+  ChangeProjectAccess(
+      ProjectAccessFactory.Factory projectAccessFactory,
+      ProjectCache projectCache,
+      GroupBackend groupBackend,
+      MetaDataUpdate.User metaDataUpdateFactory,
+      AllProjectsName allProjects,
+      Provider<SetParent> setParent,
+      GitReferenceUpdated gitRefUpdated,
+      ContributorAgreementsChecker contributorAgreements,
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      CreateGroupPermissionSyncer createGroupPermissionSyncer,
+      @Assisted("projectName") Project.NameKey projectName,
+      @Nullable @Assisted ObjectId base,
+      @Assisted List<AccessSection> sectionList,
+      @Nullable @Assisted("parentProjectName") Project.NameKey parentProjectName,
+      @Nullable @Assisted String message) {
+    super(
+        groupBackend,
+        metaDataUpdateFactory,
+        allProjects,
+        setParent,
+        user.get(),
+        projectName,
+        base,
+        sectionList,
+        parentProjectName,
+        message,
+        contributorAgreements,
+        permissionBackend,
+        true);
+    this.projectAccessFactory = projectAccessFactory;
+    this.projectCache = projectCache;
+    this.gitRefUpdated = gitRefUpdated;
+    this.createGroupPermissionSyncer = createGroupPermissionSyncer;
+  }
+
+  @Override
+  protected ProjectAccess updateProjectConfig(
+      ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
+      throws IOException, NoSuchProjectException, ConfigInvalidException,
+          PermissionBackendException {
+    RevCommit commit = config.commit(md);
+
+    gitRefUpdated.fire(
+        config.getProject().getNameKey(),
+        RefNames.REFS_CONFIG,
+        base,
+        commit.getId(),
+        user.asIdentifiedUser().getAccount());
+
+    projectCache.evict(config.getProject());
+    createGroupPermissionSyncer.syncIfNeeded();
+    return projectAccessFactory.create(projectName).call();
+  }
+}
diff --git a/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java b/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
new file mode 100644
index 0000000..4c8918d
--- /dev/null
+++ b/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
@@ -0,0 +1,289 @@
+// 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.httpd.rpc.project;
+
+import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
+import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
+import static com.google.gerrit.server.permissions.RefPermission.READ;
+import static com.google.gerrit.server.permissions.RefPermission.WRITE_CONFIG;
+
+import com.google.common.collect.Maps;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupInfo;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.ProjectAccess;
+import com.google.gerrit.common.data.RefConfigSection;
+import com.google.gerrit.common.data.WebLinkInfoCommon;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.httpd.rpc.Handler;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.WebLinks;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+class ProjectAccessFactory extends Handler<ProjectAccess> {
+  interface Factory {
+    ProjectAccessFactory create(@Assisted Project.NameKey name);
+  }
+
+  private final GroupBackend groupBackend;
+  private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final GroupControl.Factory groupControlFactory;
+  private final MetaDataUpdate.Server metaDataUpdateFactory;
+  private final AllProjectsName allProjectsName;
+
+  private final Project.NameKey projectName;
+  private WebLinks webLinks;
+
+  @Inject
+  ProjectAccessFactory(
+      GroupBackend groupBackend,
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      GroupControl.Factory groupControlFactory,
+      MetaDataUpdate.Server metaDataUpdateFactory,
+      AllProjectsName allProjectsName,
+      WebLinks webLinks,
+      @Assisted final Project.NameKey name) {
+    this.groupBackend = groupBackend;
+    this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.groupControlFactory = groupControlFactory;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.allProjectsName = allProjectsName;
+    this.webLinks = webLinks;
+
+    this.projectName = name;
+  }
+
+  @Override
+  public ProjectAccess call()
+      throws NoSuchProjectException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    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
+    // state, force a cache flush now.
+    //
+    ProjectConfig config;
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
+      config = ProjectConfig.read(md);
+      if (config.updateGroupNames(groupBackend)) {
+        md.setMessage("Update group names\n");
+        config.commit(md);
+        projectCache.evict(config.getProject());
+        projectState = checkProjectState();
+      } else if (config.getRevision() != null
+          && !config.getRevision().equals(projectState.getConfig().getRevision())) {
+        projectCache.evict(config.getProject());
+        projectState = checkProjectState();
+      }
+    }
+
+    // The following implementation must match the GetAccess REST API endpoint.
+
+    List<AccessSection> local = new ArrayList<>();
+    Set<String> ownerOf = new HashSet<>();
+    Map<AccountGroup.UUID, Boolean> visibleGroups = new HashMap<>();
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(projectName);
+    boolean checkReadConfig = check(perm, RefNames.REFS_CONFIG, READ);
+    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 (canWriteProjectConfig) {
+          local.add(section);
+          ownerOf.add(name);
+
+        } else if (checkReadConfig) {
+          local.add(section);
+        }
+
+      } else if (RefConfigSection.isValid(name)) {
+        if (check(perm, name, WRITE_CONFIG)) {
+          local.add(section);
+          ownerOf.add(name);
+
+        } else if (checkReadConfig) {
+          local.add(section);
+
+        } else if (check(perm, name, READ)) {
+          // Filter the section to only add rules describing groups that
+          // are visible to the current-user. This includes any group the
+          // user is a member of, as well as groups they own or that
+          // are visible to all users.
+
+          AccessSection dst = null;
+          for (Permission srcPerm : section.getPermissions()) {
+            Permission dstPerm = null;
+
+            for (PermissionRule srcRule : srcPerm.getRules()) {
+              AccountGroup.UUID group = srcRule.getGroup().getUUID();
+              if (group == null) {
+                continue;
+              }
+
+              Boolean canSeeGroup = visibleGroups.get(group);
+              if (canSeeGroup == null) {
+                try {
+                  canSeeGroup = groupControlFactory.controlFor(group).isVisible();
+                } catch (NoSuchGroupException e) {
+                  canSeeGroup = Boolean.FALSE;
+                }
+                visibleGroups.put(group, canSeeGroup);
+              }
+
+              if (canSeeGroup) {
+                if (dstPerm == null) {
+                  if (dst == null) {
+                    dst = new AccessSection(name);
+                    local.add(dst);
+                  }
+                  dstPerm = dst.getPermission(srcPerm.getName(), true);
+                }
+                dstPerm.add(srcRule);
+              }
+            }
+          }
+        }
+      }
+    }
+
+    if (ownerOf.isEmpty() && isAdmin()) {
+      // Special case: If the section list is empty, this project has no current
+      // access control information. Fall back to site administrators.
+      ownerOf.add(AccessSection.ALL);
+    }
+
+    final ProjectAccess detail = new ProjectAccess();
+    detail.setProjectName(projectName);
+
+    if (config.getRevision() != null) {
+      detail.setRevision(config.getRevision().name());
+    }
+
+    detail.setInheritsFrom(config.getProject().getParent(allProjectsName));
+
+    if (projectName.equals(allProjectsName)
+        && permissionBackend.user(user).testOrFalse(ADMINISTRATE_SERVER)) {
+      ownerOf.add(AccessSection.GLOBAL_CAPABILITIES);
+    }
+
+    detail.setLocal(local);
+    detail.setOwnerOf(ownerOf);
+    detail.setCanUpload(
+        canWriteProjectConfig
+            || (checkReadConfig && perm.ref(RefNames.REFS_CONFIG).testOrFalse(CREATE_CHANGE)));
+    detail.setConfigVisible(canWriteProjectConfig || checkReadConfig);
+    detail.setGroupInfo(buildGroupInfo(local));
+    detail.setLabelTypes(projectState.getLabelTypes());
+    detail.setFileHistoryLinks(getConfigFileLogLinks(projectName.get()));
+    return detail;
+  }
+
+  private List<WebLinkInfoCommon> getConfigFileLogLinks(String projectName) {
+    List<WebLinkInfoCommon> links =
+        webLinks.getFileHistoryLinks(
+            projectName, RefNames.REFS_CONFIG, ProjectConfig.PROJECT_CONFIG);
+    return links.isEmpty() ? null : links;
+  }
+
+  private Map<AccountGroup.UUID, GroupInfo> buildGroupInfo(List<AccessSection> local) {
+    Map<AccountGroup.UUID, GroupInfo> infos = new HashMap<>();
+    for (AccessSection section : local) {
+      for (Permission permission : section.getPermissions()) {
+        for (PermissionRule rule : permission.getRules()) {
+          if (rule.getGroup() != null) {
+            AccountGroup.UUID uuid = rule.getGroup().getUUID();
+            if (uuid != null && !infos.containsKey(uuid)) {
+              GroupDescription.Basic group = groupBackend.get(uuid);
+              infos.put(uuid, group != null ? new GroupInfo(group) : null);
+            }
+          }
+        }
+      }
+    }
+    return Maps.filterEntries(infos, in -> in.getValue() != null);
+  }
+
+  private ProjectState checkProjectState()
+      throws NoSuchProjectException, IOException, PermissionBackendException {
+    ProjectState state = projectCache.checkedGet(projectName);
+    try {
+      permissionBackend.user(user).project(projectName).check(ProjectPermission.ACCESS);
+    } catch (AuthException e) {
+      throw new NoSuchProjectException(projectName);
+    }
+    return state;
+  }
+
+  private static boolean check(PermissionBackend.ForProject ctx, String ref, RefPermission perm)
+      throws PermissionBackendException {
+    try {
+      ctx.ref(ref).check(perm);
+      return true;
+    } catch (AuthException denied) {
+      return false;
+    }
+  }
+
+  private boolean isAdmin() throws PermissionBackendException {
+    try {
+      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java b/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
new file mode 100644
index 0000000..0240e2e
--- /dev/null
+++ b/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
@@ -0,0 +1,245 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.rpc.project;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.common.ProjectAccessUtil.mergeSections;
+
+import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.common.errors.PermissionDeniedException;
+import com.google.gerrit.common.errors.UpdateParentFailedException;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.httpd.rpc.Handler;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupBackends;
+import com.google.gerrit.server.config.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.RefPattern;
+import com.google.gerrit.server.restapi.project.SetParent;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+
+public abstract class ProjectAccessHandler<T> extends Handler<T> {
+
+  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;
+  private final PermissionBackend permissionBackend;
+  private final Project.NameKey parentProjectName;
+
+  protected String message;
+
+  private List<AccessSection> sectionList;
+  private boolean checkIfOwner;
+  private Boolean canWriteConfig;
+
+  protected ProjectAccessHandler(
+      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.groupBackend = groupBackend;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.allProjects = allProjects;
+    this.setParent = setParent;
+    this.user = user;
+
+    this.projectName = projectName;
+    this.base = base;
+    this.sectionList = sectionList;
+    this.parentProjectName = parentProjectName;
+    this.message = message;
+    this.contributorAgreements = contributorAgreements;
+    this.permissionBackend = permissionBackend;
+    this.checkIfOwner = checkIfOwner;
+  }
+
+  @Override
+  public final T call()
+      throws NoSuchProjectException, IOException, ConfigInvalidException, InvalidNameException,
+          NoSuchGroupException, OrmException, UpdateParentFailedException,
+          PermissionDeniedException, PermissionBackendException {
+    try {
+      contributorAgreements.check(projectName, user);
+    } catch (AuthException e) {
+      throw new PermissionDeniedException(e.getMessage());
+    }
+
+    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 && !canWriteConfig()) {
+            continue;
+          }
+          replace(config, toDelete, section);
+
+        } else if (AccessSection.isValid(name)) {
+          if (checkIfOwner && !forProject.ref(name).test(RefPermission.WRITE_CONFIG)) {
+            continue;
+          }
+
+          RefPattern.validate(name);
+
+          replace(config, toDelete, section);
+        }
+      }
+
+      for (String name : toDelete) {
+        if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
+          if (!checkIfOwner || canWriteConfig()) {
+            config.remove(config.getAccessSection(name));
+          }
+
+        } else if (!checkIfOwner || forProject.ref(name).test(RefPermission.WRITE_CONFIG)) {
+          config.remove(config.getAccessSection(name));
+        }
+      }
+
+      boolean parentProjectUpdate = false;
+      if (!config.getProject().getNameKey().equals(allProjects)
+          && !config.getProject().getParent(allProjects).equals(parentProjectName)) {
+        parentProjectUpdate = true;
+        try {
+          setParent
+              .get()
+              .validateParentUpdate(
+                  projectName,
+                  user.asIdentifiedUser(),
+                  MoreObjects.firstNonNull(parentProjectName, allProjects).get(),
+                  checkIfOwner);
+        } catch (AuthException e) {
+          throw new UpdateParentFailedException(
+              "You are not allowed to change the parent project since you are "
+                  + "not an administrator. You may save the modifications for review "
+                  + "so that an administrator can approve them.",
+              e);
+        } catch (ResourceConflictException | UnprocessableEntityException | BadRequestException e) {
+          throw new UpdateParentFailedException(e.getMessage(), e);
+        }
+        config.getProject().setParentName(parentProjectName);
+      }
+
+      if (message != null && !message.isEmpty()) {
+        if (!message.endsWith("\n")) {
+          message += "\n";
+        }
+        md.setMessage(message);
+      } else {
+        md.setMessage("Modify access rules\n");
+      }
+
+      return updateProjectConfig(config, md, parentProjectUpdate);
+    } catch (RepositoryNotFoundException notFound) {
+      throw new NoSuchProjectException(projectName);
+    }
+  }
+
+  protected abstract T updateProjectConfig(
+      ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
+      throws IOException, NoSuchProjectException, ConfigInvalidException, OrmException,
+          PermissionDeniedException, PermissionBackendException;
+
+  private void replace(ProjectConfig config, Set<String> toDelete, AccessSection section)
+      throws NoSuchGroupException {
+    for (Permission permission : section.getPermissions()) {
+      for (PermissionRule rule : permission.getRules()) {
+        lookupGroup(rule);
+      }
+    }
+    config.replace(section);
+    toDelete.remove(section.getName());
+  }
+
+  private static Set<String> scanSectionNames(ProjectConfig config) {
+    Set<String> names = new HashSet<>();
+    for (AccessSection section : config.getAccessSections()) {
+      names.add(section.getName());
+    }
+    return names;
+  }
+
+  private void lookupGroup(PermissionRule rule) throws NoSuchGroupException {
+    GroupReference ref = rule.getGroup();
+    if (ref.getUUID() == null) {
+      final GroupReference group = GroupBackends.findBestSuggestion(groupBackend, ref.getName());
+      if (group == null) {
+        throw new NoSuchGroupException(ref.getName());
+      }
+      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/ProjectAdminServiceImpl.java b/java/com/google/gerrit/httpd/rpc/project/ProjectAdminServiceImpl.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAdminServiceImpl.java
rename to java/com/google/gerrit/httpd/rpc/project/ProjectAdminServiceImpl.java
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectModule.java b/java/com/google/gerrit/httpd/rpc/project/ProjectModule.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectModule.java
rename to java/com/google/gerrit/httpd/rpc/project/ProjectModule.java
diff --git a/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java b/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
new file mode 100644
index 0000000..81957d6
--- /dev/null
+++ b/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
@@ -0,0 +1,238 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.rpc.project;
+
+import com.google.common.base.Throwables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.errors.PermissionDeniedException;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.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.restapi.change.ChangesCollection;
+import com.google.gerrit.server.restapi.change.PostReviewers;
+import com.google.gerrit.server.restapi.project.SetParent;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class ReviewProjectAccess extends ProjectAccessHandler<Change.Id> {
+  interface Factory {
+    ReviewProjectAccess create(
+        @Assisted("projectName") Project.NameKey projectName,
+        @Nullable @Assisted ObjectId base,
+        @Assisted List<AccessSection> sectionList,
+        @Nullable @Assisted("parentProjectName") Project.NameKey parentProjectName,
+        @Nullable @Assisted String message);
+  }
+
+  private final ReviewDb db;
+  private final PermissionBackend permissionBackend;
+  private final Sequences seq;
+  private final Provider<PostReviewers> reviewersProvider;
+  private final ProjectCache projectCache;
+  private final ChangesCollection changes;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final BatchUpdate.Factory updateFactory;
+
+  @Inject
+  ReviewProjectAccess(
+      PermissionBackend permissionBackend,
+      GroupBackend groupBackend,
+      MetaDataUpdate.User metaDataUpdateFactory,
+      ReviewDb db,
+      Provider<PostReviewers> reviewersProvider,
+      ProjectCache projectCache,
+      AllProjectsName allProjects,
+      ChangesCollection changes,
+      ChangeInserter.Factory changeInserterFactory,
+      BatchUpdate.Factory updateFactory,
+      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(
+        groupBackend,
+        metaDataUpdateFactory,
+        allProjects,
+        setParent,
+        user.get(),
+        projectName,
+        base,
+        sectionList,
+        parentProjectName,
+        message,
+        contributorAgreements,
+        permissionBackend,
+        false);
+    this.db = db;
+    this.permissionBackend = permissionBackend;
+    this.seq = seq;
+    this.reviewersProvider = reviewersProvider;
+    this.projectCache = projectCache;
+    this.changes = changes;
+    this.changeInserterFactory = changeInserterFactory;
+    this.updateFactory = updateFactory;
+  }
+
+  // TODO(dborowitz): Hack MetaDataUpdate so it can be created within a BatchUpdate and we can avoid
+  // calling setUpdateRef(false).
+  @SuppressWarnings("deprecation")
+  @Override
+  protected Change.Id updateProjectConfig(
+      ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
+      throws IOException, OrmException, PermissionDeniedException, PermissionBackendException,
+          ConfigInvalidException {
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(config.getName());
+    if (!check(perm, ProjectPermission.READ_CONFIG)) {
+      throw new PermissionDeniedException(RefNames.REFS_CONFIG + " not visible");
+    }
+
+    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);
+    Change.Id changeId = new Change.Id(seq.nextChangeId());
+    RevCommit commit =
+        config.commitToNewRef(
+            md, new PatchSet.Id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
+    if (commit.getId().equals(base)) {
+      return null;
+    }
+
+    try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
+        ObjectReader objReader = objInserter.newReader();
+        RevWalk rw = new RevWalk(objReader);
+        BatchUpdate bu =
+            updateFactory.create(db, config.getProject().getNameKey(), user, TimeUtil.nowTs())) {
+      bu.setRepository(md.getRepository(), rw, objInserter);
+      bu.insertChange(
+          changeInserterFactory
+              .create(changeId, commit, RefNames.REFS_CONFIG)
+              .setValidate(false)
+              .setUpdateRef(false)); // Created by commitToNewRef.
+      bu.execute();
+    } catch (UpdateException | RestApiException e) {
+      throw new IOException(e);
+    }
+
+    ChangeResource rsrc;
+    try {
+      rsrc = changes.parse(changeId);
+    } catch (ResourceNotFoundException e) {
+      throw new IOException(e);
+    }
+    addProjectOwnersAsReviewers(rsrc);
+    if (parentProjectUpdate) {
+      addAdministratorsAsReviewers(rsrc);
+    }
+    return changeId;
+  }
+
+  private void addProjectOwnersAsReviewers(ChangeResource rsrc) {
+    final String projectOwners = groupBackend.get(SystemGroupBackend.PROJECT_OWNERS).getName();
+    try {
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = projectOwners;
+      reviewersProvider.get().apply(rsrc, input);
+    } catch (Exception e) {
+      // one of the owner groups is not visible to the user and this it why it
+      // can't be added as reviewer
+      Throwables.throwIfUnchecked(e);
+    }
+  }
+
+  private void addAdministratorsAsReviewers(ChangeResource rsrc) {
+    List<PermissionRule> adminRules =
+        projectCache
+            .getAllProjects()
+            .getConfig()
+            .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
+            .getPermission(GlobalCapability.ADMINISTRATE_SERVER)
+            .getRules();
+    for (PermissionRule r : adminRules) {
+      try {
+        AddReviewerInput input = new AddReviewerInput();
+        input.reviewer = r.getGroup().getUUID().get();
+        reviewersProvider.get().apply(rsrc, input);
+      } catch (Exception e) {
+        // ignore
+        Throwables.throwIfUnchecked(e);
+      }
+    }
+  }
+
+  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/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java b/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
similarity index 100%
rename from gerrit-httpd/src/main/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
rename to java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
new file mode 100644
index 0000000..015eceb
--- /dev/null
+++ b/java/com/google/gerrit/index/BUILD
@@ -0,0 +1,48 @@
+load("//tools/bzl:genrule2.bzl", "genrule2")
+
+QUERY_PARSE_EXCEPTION_SRCS = [
+    "query/QueryParseException.java",
+    "query/QueryRequiresAuthException.java",
+]
+
+java_library(
+    name = "query_exception",
+    srcs = QUERY_PARSE_EXCEPTION_SRCS,
+    visibility = ["//visibility:public"],
+)
+
+java_library(
+    name = "query_parser",
+    srcs = ["//antlr3:query"],
+    visibility = [
+        "//javatests/com/google/gerrit/index:__pkg__",
+        "//plugins:__pkg__",
+    ],
+    deps = [
+        ":query_exception",
+        "//lib/antlr:java_runtime",
+    ],
+)
+
+java_library(
+    name = "index",
+    srcs = glob(
+        ["**/*.java"],
+        exclude = QUERY_PARSE_EXCEPTION_SRCS,
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":query_exception",
+        ":query_parser",
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/metrics",
+        "//lib:guava",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtorm",
+        "//lib/antlr:java_runtime",
+        "//lib/auto:auto-value",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/FieldDef.java b/java/com/google/gerrit/index/FieldDef.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/FieldDef.java
rename to java/com/google/gerrit/index/FieldDef.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/FieldType.java b/java/com/google/gerrit/index/FieldType.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/FieldType.java
rename to java/com/google/gerrit/index/FieldType.java
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
new file mode 100644
index 0000000..2d7e31e
--- /dev/null
+++ b/java/com/google/gerrit/index/Index.java
@@ -0,0 +1,152 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Secondary index implementation for arbitrary documents.
+ *
+ * <p>Documents are inserted into the index and are queried by converting special {@link
+ * com.google.gerrit.index.query.Predicate} instances into index-aware predicates that use the index
+ * search results as a source.
+ *
+ * <p>Implementations must be thread-safe and should batch inserts/updates where appropriate.
+ */
+public interface Index<K, V> {
+  /** @return the schema version used by this index. */
+  Schema<V> getSchema();
+
+  /** Close this index. */
+  void close();
+
+  /**
+   * Update a document in the index.
+   *
+   * <p>Semantically equivalent to deleting the document and reinserting it with new field values. A
+   * document that does not already exist is created. Results may not be immediately visible to
+   * searchers, but should be visible within a reasonable amount of time.
+   *
+   * @param obj document object
+   * @throws IOException
+   */
+  void replace(V obj) throws IOException;
+
+  /**
+   * Delete a document from the index by key.
+   *
+   * @param key document key
+   * @throws IOException
+   */
+  void delete(K key) throws IOException;
+
+  /**
+   * Delete all documents from the index.
+   *
+   * @throws IOException
+   */
+  void deleteAll() throws IOException;
+
+  /**
+   * Convert the given operator predicate into a source searching the index and returning only the
+   * documents matching that predicate.
+   *
+   * <p>This method may be called multiple times for variations on the same predicate or multiple
+   * predicate subtrees in the course of processing a single query, so it should not have any side
+   * effects (e.g. starting a search in the background).
+   *
+   * @param p the predicate to match. Must be a tree containing only AND, OR, or NOT predicates as
+   *     internal nodes, and {@link IndexPredicate}s as leaves.
+   * @param opts query options not implied by the predicate, such as start and limit.
+   * @return a source of documents matching the predicate, returned in a defined order depending on
+   *     the type of documents.
+   * @throws QueryParseException if the predicate could not be converted to an indexed data source.
+   */
+  DataSource<V> getSource(Predicate<V> p, QueryOptions opts) throws QueryParseException;
+
+  /**
+   * Get a single document from the index.
+   *
+   * @param key document key.
+   * @param opts query options. Options that do not make sense in the context of a single document,
+   *     such as start, will be ignored.
+   * @return a single document if present.
+   * @throws IOException
+   */
+  default Optional<V> get(K key, QueryOptions opts) throws IOException {
+    opts = opts.withStart(0).withLimit(2);
+    List<V> results;
+    try {
+      results = getSource(keyPredicate(key), opts).read().toList();
+    } catch (QueryParseException e) {
+      throw new IOException("Unexpected QueryParseException during get()", e);
+    } catch (OrmException e) {
+      throw new IOException(e);
+    }
+    if (results.size() > 1) {
+      throw new IOException("Multiple results found in index for key " + key + ": " + results);
+    }
+    return results.stream().findFirst();
+  }
+
+  /**
+   * Get a single raw document from the index.
+   *
+   * @param key document key.
+   * @param opts query options. Options that do not make sense in the context of a single document,
+   *     such as start, will be ignored.
+   * @return an abstraction of a raw index document to retrieve fields from.
+   * @throws IOException
+   */
+  default Optional<FieldBundle> getRaw(K key, QueryOptions opts) throws IOException {
+    opts = opts.withStart(0).withLimit(2);
+    List<FieldBundle> results;
+    try {
+      results = getSource(keyPredicate(key), opts).readRaw().toList();
+    } catch (QueryParseException e) {
+      throw new IOException("Unexpected QueryParseException during get()", e);
+    } catch (OrmException e) {
+      throw new IOException(e);
+    }
+    if (results.size() > 1) {
+      throw new IOException("Multiple results found in index for key " + key + ": " + results);
+    }
+    return results.stream().findFirst();
+  }
+
+  /**
+   * Get a predicate that looks up a single document by key.
+   *
+   * @param key document key.
+   * @return a single predicate.
+   */
+  Predicate<V> keyPredicate(K key);
+
+  /**
+   * Mark whether this index is up-to-date and ready to serve reads.
+   *
+   * @param ready whether the index is ready
+   * @throws IOException
+   */
+  void markReady(boolean ready) throws IOException;
+}
diff --git a/java/com/google/gerrit/index/IndexCollection.java b/java/com/google/gerrit/index/IndexCollection.java
new file mode 100644
index 0000000..0615453
--- /dev/null
+++ b/java/com/google/gerrit/index/IndexCollection.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicReference;
+
+/** Dynamic pointers to the index versions used for searching and writing. */
+public abstract class IndexCollection<K, V, I extends Index<K, V>> implements LifecycleListener {
+  private final CopyOnWriteArrayList<I> writeIndexes;
+  private final AtomicReference<I> searchIndex;
+
+  protected IndexCollection() {
+    this.writeIndexes = Lists.newCopyOnWriteArrayList();
+    this.searchIndex = new AtomicReference<>();
+  }
+
+  /** @return the current search index version. */
+  public I getSearchIndex() {
+    return searchIndex.get();
+  }
+
+  public void setSearchIndex(I index) {
+    setSearchIndex(index, true);
+  }
+
+  @VisibleForTesting
+  public void setSearchIndex(I index, boolean closeOld) {
+    I old = searchIndex.getAndSet(index);
+    if (closeOld && old != null && old != index && !writeIndexes.contains(old)) {
+      old.close();
+    }
+  }
+
+  public Collection<I> getWriteIndexes() {
+    return Collections.unmodifiableCollection(writeIndexes);
+  }
+
+  public synchronized I addWriteIndex(I index) {
+    int version = index.getSchema().getVersion();
+    for (int i = 0; i < writeIndexes.size(); i++) {
+      if (writeIndexes.get(i).getSchema().getVersion() == version) {
+        return writeIndexes.set(i, index);
+      }
+    }
+    writeIndexes.add(index);
+    return null;
+  }
+
+  public synchronized void removeWriteIndex(int version) {
+    int removeIndex = -1;
+    for (int i = 0; i < writeIndexes.size(); i++) {
+      if (writeIndexes.get(i).getSchema().getVersion() == version) {
+        removeIndex = i;
+        break;
+      }
+    }
+    if (removeIndex >= 0) {
+      try {
+        writeIndexes.get(removeIndex).close();
+      } finally {
+        writeIndexes.remove(removeIndex);
+      }
+    }
+  }
+
+  public I getWriteIndex(int version) {
+    for (I i : writeIndexes) {
+      if (i.getSchema().getVersion() == version) {
+        return i;
+      }
+    }
+    return null;
+  }
+
+  @Override
+  public void start() {}
+
+  @Override
+  public void stop() {
+    I read = searchIndex.get();
+    if (read != null) {
+      read.close();
+    }
+    for (I write : writeIndexes) {
+      if (write != read) {
+        write.close();
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/index/IndexConfig.java b/java/com/google/gerrit/index/IndexConfig.java
new file mode 100644
index 0000000..b5b36f1
--- /dev/null
+++ b/java/com/google/gerrit/index/IndexConfig.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import java.util.function.IntConsumer;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Implementation-specific configuration for secondary indexes.
+ *
+ * <p>Contains configuration that is tied to a specific index implementation but is otherwise
+ * global, i.e. not tied to a specific {@link Index} and schema version.
+ */
+@AutoValue
+public abstract class IndexConfig {
+  private static final int DEFAULT_MAX_TERMS = 1024;
+
+  public static IndexConfig createDefault() {
+    return builder().build();
+  }
+
+  public static Builder fromConfig(Config cfg) {
+    Builder b = builder();
+    setIfPresent(cfg, "maxLimit", b::maxLimit);
+    setIfPresent(cfg, "maxPages", b::maxPages);
+    setIfPresent(cfg, "maxTerms", b::maxTerms);
+    return b;
+  }
+
+  private static void setIfPresent(Config cfg, String name, IntConsumer setter) {
+    int n = cfg.getInt("index", null, name, 0);
+    if (n != 0) {
+      setter.accept(n);
+    }
+  }
+
+  public static Builder builder() {
+    return new AutoValue_IndexConfig.Builder()
+        .maxLimit(Integer.MAX_VALUE)
+        .maxPages(Integer.MAX_VALUE)
+        .maxTerms(DEFAULT_MAX_TERMS)
+        .separateChangeSubIndexes(false);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder maxLimit(int maxLimit);
+
+    public abstract int maxLimit();
+
+    public abstract Builder maxPages(int maxPages);
+
+    public abstract int maxPages();
+
+    public abstract Builder maxTerms(int maxTerms);
+
+    public abstract int maxTerms();
+
+    public abstract Builder separateChangeSubIndexes(boolean separate);
+
+    abstract IndexConfig autoBuild();
+
+    public IndexConfig build() {
+      IndexConfig cfg = autoBuild();
+      checkLimit(cfg.maxLimit(), "maxLimit");
+      checkLimit(cfg.maxPages(), "maxPages");
+      checkLimit(cfg.maxTerms(), "maxTerms");
+      return cfg;
+    }
+  }
+
+  private static void checkLimit(int limit, String name) {
+    checkArgument(limit > 0, "%s must be positive: %s", name, limit);
+  }
+
+  /**
+   * @return maximum limit supported by the underlying index, or limited for performance reasons.
+   */
+  public abstract int maxLimit();
+
+  /**
+   * @return maximum number of pages (limit / start) supported by the underlying index, or limited
+   *     for performance reasons.
+   */
+  public abstract int maxPages();
+
+  /**
+   * @return maximum number of total index query terms supported by the underlying index, or limited
+   *     for performance reasons.
+   */
+  public abstract int maxTerms();
+
+  /**
+   * @return whether different subsets of changes may be stored in different physical sub-indexes.
+   */
+  public abstract boolean separateChangeSubIndexes();
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/IndexDefinition.java b/java/com/google/gerrit/index/IndexDefinition.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/IndexDefinition.java
rename to java/com/google/gerrit/index/IndexDefinition.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/IndexRewriter.java b/java/com/google/gerrit/index/IndexRewriter.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/IndexRewriter.java
rename to java/com/google/gerrit/index/IndexRewriter.java
diff --git a/java/com/google/gerrit/index/IndexedQuery.java b/java/com/google/gerrit/index/IndexedQuery.java
new file mode 100644
index 0000000..143cc26
--- /dev/null
+++ b/java/com/google/gerrit/index/IndexedQuery.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Paginated;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Wrapper combining an {@link IndexPredicate} together with a {@link DataSource} that returns
+ * matching results from the index.
+ *
+ * <p>Appropriate to return as the rootmost predicate that can be processed using the secondary
+ * index; such predicates must also implement {@link DataSource} to be chosen by the query
+ * processor.
+ *
+ * @param <I> The type of the IDs by which the entities are stored in the index.
+ * @param <T> The type of the entities that are stored in the index.
+ */
+public class IndexedQuery<I, T> extends Predicate<T> implements DataSource<T>, Paginated<T> {
+  protected final Index<I, T> index;
+
+  private QueryOptions opts;
+  private final Predicate<T> pred;
+  protected DataSource<T> source;
+
+  public IndexedQuery(Index<I, T> index, Predicate<T> pred, QueryOptions opts)
+      throws QueryParseException {
+    this.index = index;
+    this.opts = opts;
+    this.pred = pred;
+    this.source = index.getSource(pred, this.opts);
+  }
+
+  @Override
+  public int getChildCount() {
+    return 1;
+  }
+
+  @Override
+  public Predicate<T> getChild(int i) {
+    if (i == 0) {
+      return pred;
+    }
+    throw new ArrayIndexOutOfBoundsException(i);
+  }
+
+  @Override
+  public List<Predicate<T>> getChildren() {
+    return ImmutableList.of(pred);
+  }
+
+  @Override
+  public QueryOptions getOptions() {
+    return opts;
+  }
+
+  @Override
+  public int getCardinality() {
+    return source != null ? source.getCardinality() : opts.limit();
+  }
+
+  @Override
+  public ResultSet<T> read() throws OrmException {
+    return source.read();
+  }
+
+  @Override
+  public ResultSet<FieldBundle> readRaw() throws OrmException {
+    return source.readRaw();
+  }
+
+  @Override
+  public ResultSet<T> restart(int start) throws OrmException {
+    opts = opts.withStart(start);
+    try {
+      source = index.getSource(pred, opts);
+    } catch (QueryParseException e) {
+      // Don't need to show this exception to the user; the only thing that
+      // changed about pred was its start, and any other QPEs that might happen
+      // should have already thrown from the constructor.
+      throw new OrmException(e);
+    }
+    // Don't convert start to a limit, since the caller of this method (see
+    // AndSource) has calculated the actual number to skip.
+    return read();
+  }
+
+  @Override
+  public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
+    return this;
+  }
+
+  @Override
+  public int hashCode() {
+    return pred.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other == null || getClass() != other.getClass()) {
+      return false;
+    }
+    IndexedQuery<?, ?> o = (IndexedQuery<?, ?>) other;
+    return pred.equals(o.pred) && opts.equals(o.opts);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("index").add("p", pred).add("opts", opts).toString();
+  }
+}
diff --git a/java/com/google/gerrit/index/QueryOptions.java b/java/com/google/gerrit/index/QueryOptions.java
new file mode 100644
index 0000000..0401dab
--- /dev/null
+++ b/java/com/google/gerrit/index/QueryOptions.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.primitives.Ints;
+import java.util.Set;
+import java.util.function.Function;
+
+@AutoValue
+public abstract class QueryOptions {
+  public static QueryOptions create(IndexConfig config, int start, int limit, Set<String> fields) {
+    checkArgument(start >= 0, "start must be nonnegative: %s", start);
+    checkArgument(limit > 0, "limit must be positive: %s", limit);
+    return new AutoValue_QueryOptions(config, start, limit, ImmutableSet.copyOf(fields));
+  }
+
+  public QueryOptions convertForBackend() {
+    // Increase the limit rather than skipping, since we don't know how many
+    // skipped results would have been filtered out by the enclosing AndSource.
+    int backendLimit = config().maxLimit();
+    int limit = Ints.saturatedCast((long) limit() + start());
+    limit = Math.min(limit, backendLimit);
+    return create(config(), 0, limit, fields());
+  }
+
+  public abstract IndexConfig config();
+
+  public abstract int start();
+
+  public abstract int limit();
+
+  public abstract ImmutableSet<String> fields();
+
+  public QueryOptions withLimit(int newLimit) {
+    return create(config(), start(), newLimit, fields());
+  }
+
+  public QueryOptions withStart(int newStart) {
+    return create(config(), newStart, limit(), fields());
+  }
+
+  public QueryOptions filterFields(Function<QueryOptions, Set<String>> filter) {
+    return create(config(), start(), limit(), filter.apply(this));
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/Schema.java
rename to java/com/google/gerrit/index/Schema.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/SchemaDefinitions.java b/java/com/google/gerrit/index/SchemaDefinitions.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/SchemaDefinitions.java
rename to java/com/google/gerrit/index/SchemaDefinitions.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/SchemaUtil.java b/java/com/google/gerrit/index/SchemaUtil.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/SchemaUtil.java
rename to java/com/google/gerrit/index/SchemaUtil.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/SiteIndexer.java b/java/com/google/gerrit/index/SiteIndexer.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/SiteIndexer.java
rename to java/com/google/gerrit/index/SiteIndexer.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/AndPredicate.java b/java/com/google/gerrit/index/query/AndPredicate.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/AndPredicate.java
rename to java/com/google/gerrit/index/query/AndPredicate.java
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
new file mode 100644
index 0000000..e2605f4
--- /dev/null
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -0,0 +1,206 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gwtorm.server.ListResultSet;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.gwtorm.server.ResultSet;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+public class AndSource<T> extends AndPredicate<T>
+    implements DataSource<T>, Comparator<Predicate<T>> {
+  protected final DataSource<T> source;
+
+  private final IsVisibleToPredicate<T> isVisibleToPredicate;
+  private final int start;
+  private final int cardinality;
+
+  public AndSource(Collection<? extends Predicate<T>> that) {
+    this(that, null, 0);
+  }
+
+  public AndSource(Predicate<T> that, IsVisibleToPredicate<T> isVisibleToPredicate) {
+    this(that, isVisibleToPredicate, 0);
+  }
+
+  public AndSource(Predicate<T> that, IsVisibleToPredicate<T> isVisibleToPredicate, int start) {
+    this(ImmutableList.of(that), isVisibleToPredicate, start);
+  }
+
+  public AndSource(
+      Collection<? extends Predicate<T>> that,
+      IsVisibleToPredicate<T> isVisibleToPredicate,
+      int start) {
+    super(that);
+    checkArgument(start >= 0, "negative start: %s", start);
+    this.isVisibleToPredicate = isVisibleToPredicate;
+    this.start = start;
+
+    int c = Integer.MAX_VALUE;
+    DataSource<T> s = null;
+    int minCost = Integer.MAX_VALUE;
+    for (Predicate<T> p : sort(getChildren())) {
+      if (p instanceof DataSource) {
+        c = Math.min(c, ((DataSource<?>) p).getCardinality());
+
+        int cost = p.estimateCost();
+        if (cost < minCost) {
+          s = toDataSource(p);
+          minCost = cost;
+        }
+      }
+    }
+    this.source = s;
+    this.cardinality = c;
+  }
+
+  @Override
+  public ResultSet<T> read() throws OrmException {
+    try {
+      return readImpl();
+    } catch (OrmRuntimeException err) {
+      if (err.getCause() != null) {
+        Throwables.throwIfInstanceOf(err.getCause(), OrmException.class);
+      }
+      throw new OrmException(err);
+    }
+  }
+
+  @Override
+  public ResultSet<FieldBundle> readRaw() throws OrmException {
+    // TOOD(hiesel): Implement
+    throw new UnsupportedOperationException("not implemented");
+  }
+
+  private ResultSet<T> readImpl() throws OrmException {
+    if (source == null) {
+      throw new OrmException("No DataSource: " + this);
+    }
+    List<T> r = new ArrayList<>();
+    T last = null;
+    int nextStart = 0;
+    boolean skipped = false;
+    for (T data : buffer(source.read())) {
+      if (!isMatchable() || match(data)) {
+        r.add(data);
+      } else {
+        skipped = true;
+      }
+      last = data;
+      nextStart++;
+    }
+
+    if (skipped && last != null && source instanceof Paginated) {
+      // If our source is a paginated source and we skipped at
+      // least one of its results, we may not have filled the full
+      // limit the caller wants.  Restart the source and continue.
+      //
+      @SuppressWarnings("unchecked")
+      Paginated<T> p = (Paginated<T>) source;
+      while (skipped && r.size() < p.getOptions().limit() + start) {
+        skipped = false;
+        ResultSet<T> next = p.restart(nextStart);
+
+        for (T data : buffer(next)) {
+          if (match(data)) {
+            r.add(data);
+          } else {
+            skipped = true;
+          }
+          nextStart++;
+        }
+      }
+    }
+
+    if (start >= r.size()) {
+      r = ImmutableList.of();
+    } else if (start > 0) {
+      r = ImmutableList.copyOf(r.subList(start, r.size()));
+    }
+    return new ListResultSet<>(r);
+  }
+
+  @Override
+  public boolean isMatchable() {
+    return isVisibleToPredicate != null || super.isMatchable();
+  }
+
+  @Override
+  public boolean match(T object) throws OrmException {
+    if (isVisibleToPredicate != null && !isVisibleToPredicate.match(object)) {
+      return false;
+    }
+
+    if (super.isMatchable() && !super.match(object)) {
+      return false;
+    }
+
+    return true;
+  }
+
+  private Iterable<T> buffer(ResultSet<T> scanner) {
+    return FluentIterable.from(Iterables.partition(scanner, 50))
+        .transformAndConcat(this::transformBuffer);
+  }
+
+  protected List<T> transformBuffer(List<T> buffer) throws OrmRuntimeException {
+    return buffer;
+  }
+
+  @Override
+  public int getCardinality() {
+    return cardinality;
+  }
+
+  private List<Predicate<T>> sort(Collection<? extends Predicate<T>> that) {
+    List<Predicate<T>> r = new ArrayList<>(that);
+    Collections.sort(r, this);
+    return r;
+  }
+
+  @Override
+  public int compare(Predicate<T> a, Predicate<T> b) {
+    int ai = a instanceof DataSource ? 0 : 1;
+    int bi = b instanceof DataSource ? 0 : 1;
+    int cmp = ai - bi;
+
+    if (cmp == 0) {
+      cmp = a.estimateCost() - b.estimateCost();
+    }
+
+    if (cmp == 0 && a instanceof DataSource && b instanceof DataSource) {
+      DataSource<?> as = (DataSource<?>) a;
+      DataSource<?> bs = (DataSource<?>) b;
+      cmp = as.getCardinality() - bs.getCardinality();
+    }
+    return cmp;
+  }
+
+  @SuppressWarnings("unchecked")
+  private DataSource<T> toDataSource(Predicate<T> pred) {
+    return (DataSource<T>) pred;
+  }
+}
diff --git a/java/com/google/gerrit/index/query/DataSource.java b/java/com/google/gerrit/index/query/DataSource.java
new file mode 100644
index 0000000..88cc0e3c
--- /dev/null
+++ b/java/com/google/gerrit/index/query/DataSource.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+public interface DataSource<T> {
+  /** @return an estimate of the number of results from {@link #read()}. */
+  int getCardinality();
+
+  /** @return read from the database and return the results. */
+  ResultSet<T> read() throws OrmException;
+
+  /** @return read from the database and return the raw results. */
+  ResultSet<FieldBundle> readRaw() throws OrmException;
+}
diff --git a/java/com/google/gerrit/index/query/FieldBundle.java b/java/com/google/gerrit/index/query/FieldBundle.java
new file mode 100644
index 0000000..6ecb6e6
--- /dev/null
+++ b/java/com/google/gerrit/index/query/FieldBundle.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.index.FieldDef;
+
+/** FieldBundle is an abstraction that allows retrieval of raw values from different sources. */
+public class FieldBundle {
+
+  // Map String => List{Integer, Long, Timestamp, String, byte[]}
+  private ImmutableListMultimap<String, Object> fields;
+
+  public FieldBundle(ListMultimap<String, Object> fields) {
+    this.fields = ImmutableListMultimap.copyOf(fields);
+  }
+
+  /**
+   * Get a field's value based on the field definition.
+   *
+   * @param fieldDef the definition of the field of which the value should be retrieved. The field
+   *     must be stored and contained in the result set as specified by {@link
+   *     com.google.gerrit.index.QueryOptions}.
+   * @param <T> Data type of the returned object based on the field definition
+   * @return Either a single element or an Iterable based on the field definition. An empty list is
+   *     returned for repeated fields that are not contained in the result.
+   * @throws IllegalArgumentException if the requested field is not stored or not present. This
+   *     check is only enforced on non-repeatable fields.
+   */
+  @SuppressWarnings("unchecked")
+  public <T> T getValue(FieldDef<?, T> fieldDef) {
+    checkArgument(fieldDef.isStored(), "Field must be stored");
+    checkArgument(
+        fields.containsKey(fieldDef.getName()) || fieldDef.isRepeatable(),
+        "Field %s is not in result set %s",
+        fieldDef.getName(),
+        fields.keySet());
+
+    Iterable<Object> result = fields.get(fieldDef.getName());
+    if (fieldDef.isRepeatable()) {
+      return (T) result;
+    }
+    return (T) Iterables.getOnlyElement(result);
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/IndexPredicate.java
rename to java/com/google/gerrit/index/query/IndexPredicate.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/IntPredicate.java b/java/com/google/gerrit/index/query/IntPredicate.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/IntPredicate.java
rename to java/com/google/gerrit/index/query/IntPredicate.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/IntegerRangePredicate.java b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/IntegerRangePredicate.java
rename to java/com/google/gerrit/index/query/IntegerRangePredicate.java
diff --git a/java/com/google/gerrit/index/query/InternalQuery.java b/java/com/google/gerrit/index/query/InternalQuery.java
new file mode 100644
index 0000000..3a4b372
--- /dev/null
+++ b/java/com/google/gerrit/index/query/InternalQuery.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexCollection;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.Schema;
+import com.google.gwtorm.server.OrmException;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Execute a single query over a secondary index, for use by Gerrit internals.
+ *
+ * <p>By default, visibility of returned entities is not enforced (unlike in {@link
+ * QueryProcessor}). The methods in this class are not typically used by user-facing paths, but
+ * rather by internal callers that need to process all matching results.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
+public class InternalQuery<T> {
+  private final QueryProcessor<T> queryProcessor;
+  private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
+
+  protected final IndexConfig indexConfig;
+
+  protected InternalQuery(
+      QueryProcessor<T> queryProcessor,
+      IndexCollection<?, T, ? extends Index<?, T>> indexes,
+      IndexConfig indexConfig) {
+    this.queryProcessor = queryProcessor.enforceVisibility(false);
+    this.indexes = indexes;
+    this.indexConfig = indexConfig;
+  }
+
+  public InternalQuery<T> setLimit(int n) {
+    queryProcessor.setUserProvidedLimit(n);
+    return this;
+  }
+
+  public InternalQuery<T> enforceVisibility(boolean enforce) {
+    queryProcessor.enforceVisibility(enforce);
+    return this;
+  }
+
+  @SuppressWarnings("unchecked") // Can't set @SafeVarargs on a non-final method.
+  public InternalQuery<T> setRequestedFields(FieldDef<T, ?>... fields) {
+    checkArgument(fields.length > 0, "requested field list is empty");
+    queryProcessor.setRequestedFields(
+        Arrays.stream(fields).map(FieldDef::getName).collect(toSet()));
+    return this;
+  }
+
+  public InternalQuery<T> noFields() {
+    queryProcessor.setRequestedFields(ImmutableSet.of());
+    return this;
+  }
+
+  public List<T> query(Predicate<T> p) throws OrmException {
+    try {
+      return queryProcessor.query(p).entities();
+    } catch (QueryParseException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  /**
+   * Run multiple queries in parallel.
+   *
+   * <p>If a limit was specified using {@link #setLimit(int)}, that limit is applied to each query
+   * independently.
+   *
+   * @param queries list of queries.
+   * @return results of the queries, one list of results per input query, in the same order as the
+   *     input.
+   */
+  public List<List<T>> query(List<Predicate<T>> queries) throws OrmException {
+    try {
+      return Lists.transform(queryProcessor.query(queries), QueryResult::entities);
+    } catch (QueryParseException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  protected Schema<T> schema() {
+    Index<?, T> index = indexes != null ? indexes.getSearchIndex() : null;
+    return index != null ? index.getSchema() : null;
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/IsVisibleToPredicate.java b/java/com/google/gerrit/index/query/IsVisibleToPredicate.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/IsVisibleToPredicate.java
rename to java/com/google/gerrit/index/query/IsVisibleToPredicate.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/LimitPredicate.java b/java/com/google/gerrit/index/query/LimitPredicate.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/LimitPredicate.java
rename to java/com/google/gerrit/index/query/LimitPredicate.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/Matchable.java b/java/com/google/gerrit/index/query/Matchable.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/Matchable.java
rename to java/com/google/gerrit/index/query/Matchable.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/NotPredicate.java b/java/com/google/gerrit/index/query/NotPredicate.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/NotPredicate.java
rename to java/com/google/gerrit/index/query/NotPredicate.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/OperatorPredicate.java b/java/com/google/gerrit/index/query/OperatorPredicate.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/OperatorPredicate.java
rename to java/com/google/gerrit/index/query/OperatorPredicate.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/OrPredicate.java b/java/com/google/gerrit/index/query/OrPredicate.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/OrPredicate.java
rename to java/com/google/gerrit/index/query/OrPredicate.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/Paginated.java b/java/com/google/gerrit/index/query/Paginated.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/Paginated.java
rename to java/com/google/gerrit/index/query/Paginated.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/Predicate.java b/java/com/google/gerrit/index/query/Predicate.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/Predicate.java
rename to java/com/google/gerrit/index/query/Predicate.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryBuilder.java b/java/com/google/gerrit/index/query/QueryBuilder.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/QueryBuilder.java
rename to java/com/google/gerrit/index/query/QueryBuilder.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryParseException.java b/java/com/google/gerrit/index/query/QueryParseException.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/QueryParseException.java
rename to java/com/google/gerrit/index/query/QueryParseException.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/QueryProcessor.java
rename to java/com/google/gerrit/index/query/QueryProcessor.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryRequiresAuthException.java b/java/com/google/gerrit/index/query/QueryRequiresAuthException.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/QueryRequiresAuthException.java
rename to java/com/google/gerrit/index/query/QueryRequiresAuthException.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryResult.java b/java/com/google/gerrit/index/query/QueryResult.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/QueryResult.java
rename to java/com/google/gerrit/index/query/QueryResult.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/RangeUtil.java b/java/com/google/gerrit/index/query/RangeUtil.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/RangeUtil.java
rename to java/com/google/gerrit/index/query/RangeUtil.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/RegexPredicate.java b/java/com/google/gerrit/index/query/RegexPredicate.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/RegexPredicate.java
rename to java/com/google/gerrit/index/query/RegexPredicate.java
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/TimestampRangePredicate.java b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
similarity index 100%
rename from gerrit-index/src/main/java/com/google/gerrit/index/query/TimestampRangePredicate.java
rename to java/com/google/gerrit/index/query/TimestampRangePredicate.java
diff --git a/java/com/google/gerrit/launcher/BUILD b/java/com/google/gerrit/launcher/BUILD
new file mode 100644
index 0000000..18dcd52
--- /dev/null
+++ b/java/com/google/gerrit/launcher/BUILD
@@ -0,0 +1,19 @@
+# NOTE: GerritLauncher must be a single, self-contained class. Do not add any
+# additional srcs or deps to this rule.
+java_library(
+    name = "launcher",
+    srcs = ["GerritLauncher.java"],
+    resources = [":workspace-root.txt"],
+    visibility = ["//visibility:public"],
+)
+
+# The root of the workspace is non-hermetic, but we need it for
+# on-the-fly GWT recompiles and PolyGerrit updates.
+genrule(
+    name = "gen_root",
+    outs = ["workspace-root.txt"],
+    cmd = ("cat bazel-out/stable-status.txt | " +
+           "grep STABLE_WORKSPACE_ROOT | cut -d ' ' -f 2 > $@"),
+    stamp = 1,
+    visibility = ["//visibility:public"],
+)
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
new file mode 100644
index 0000000..618d754
--- /dev/null
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -0,0 +1,713 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.launcher;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.TimeUnit.DAYS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.net.JarURLConnection;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.CodeSource;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.jar.Attributes;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+/** Main class for a JAR file to run code from "WEB-INF/lib". */
+public final class GerritLauncher {
+  private static final String PKG = "com.google.gerrit.pgm";
+  public static final String NOT_ARCHIVED = "NOT_ARCHIVED";
+
+  private static ClassLoader daemonClassLoader;
+
+  public static void main(String[] argv) throws Exception {
+    System.exit(mainImpl(argv));
+  }
+
+  public static int mainImpl(String[] argv) throws Exception {
+    if (argv.length == 0) {
+      File me;
+      try {
+        me = getDistributionArchive();
+      } catch (FileNotFoundException e) {
+        me = null;
+      }
+
+      String jar = me != null ? me.getName() : "gerrit.war";
+      System.err.println("Gerrit Code Review " + getVersion(me));
+      System.err.println("usage: java -jar " + jar + " command [ARG ...]");
+      System.err.println();
+      System.err.println("The most commonly used commands are:");
+      System.err.println("  init            Initialize a Gerrit installation");
+      System.err.println("  reindex         Rebuild the secondary index");
+      System.err.println("  daemon          Run the Gerrit network daemons");
+      System.err.println("  gsql            Run the interactive query console");
+      System.err.println("  version         Display the build version number");
+      System.err.println("  passwd          Set or change password in secure.config");
+
+      System.err.println();
+      System.err.println("  ls              List files available for cat");
+      System.err.println("  cat FILE        Display a file from the archive");
+      System.err.println();
+      return 1;
+    }
+
+    // Special cases, a few global options actually are programs.
+    //
+    if ("-v".equals(argv[0]) || "--version".equals(argv[0])) {
+      argv[0] = "version";
+    } else if ("-p".equals(argv[0]) || "--cat".equals(argv[0])) {
+      argv[0] = "cat";
+    } else if ("-l".equals(argv[0]) || "--ls".equals(argv[0])) {
+      argv[0] = "ls";
+    }
+
+    // Run the application class
+    //
+    final ClassLoader cl = libClassLoader(isProlog(programClassName(argv[0])));
+    Thread.currentThread().setContextClassLoader(cl);
+    return invokeProgram(cl, argv);
+  }
+
+  public static void daemonStart(String[] argv) throws Exception {
+    if (daemonClassLoader != null) {
+      throw new IllegalStateException("daemonStart can be called only once per JVM instance");
+    }
+    final ClassLoader cl = libClassLoader(false);
+    Thread.currentThread().setContextClassLoader(cl);
+
+    daemonClassLoader = cl;
+
+    String[] daemonArgv = new String[argv.length + 1];
+    daemonArgv[0] = "daemon";
+    for (int i = 0; i < argv.length; i++) {
+      daemonArgv[i + 1] = argv[i];
+    }
+    int res = invokeProgram(cl, daemonArgv);
+    if (res != 0) {
+      throw new Exception("Unexpected return value: " + res);
+    }
+  }
+
+  public static void daemonStop(String[] argv) throws Exception {
+    if (daemonClassLoader == null) {
+      throw new IllegalStateException("daemonStop can be called only after call to daemonStop");
+    }
+    String[] daemonArgv = new String[argv.length + 2];
+    daemonArgv[0] = "daemon";
+    daemonArgv[1] = "--stop-only";
+    for (int i = 0; i < argv.length; i++) {
+      daemonArgv[i + 2] = argv[i];
+    }
+    int res = invokeProgram(daemonClassLoader, daemonArgv);
+    if (res != 0) {
+      throw new Exception("Unexpected return value: " + res);
+    }
+  }
+
+  private static boolean isProlog(String cn) {
+    return "PrologShell".equals(cn) || "Rulec".equals(cn);
+  }
+
+  private static String getVersion(File me) {
+    if (me == null) {
+      return "";
+    }
+
+    try (JarFile jar = new JarFile(me)) {
+      Manifest mf = jar.getManifest();
+      Attributes att = mf.getMainAttributes();
+      String val = att.getValue(Attributes.Name.IMPLEMENTATION_VERSION);
+      return val != null ? val : "";
+    } catch (IOException e) {
+      return "";
+    }
+  }
+
+  private static int invokeProgram(ClassLoader loader, String[] origArgv) throws Exception {
+    String name = origArgv[0];
+    final String[] argv = new String[origArgv.length - 1];
+    System.arraycopy(origArgv, 1, argv, 0, argv.length);
+
+    Class<?> clazz;
+    try {
+      try {
+        String cn = programClassName(name);
+        clazz = Class.forName(PKG + "." + cn, true, loader);
+      } catch (ClassNotFoundException cnfe) {
+        if (name.equals(name.toLowerCase())) {
+          clazz = Class.forName(PKG + "." + name, true, loader);
+        } else {
+          throw cnfe;
+        }
+      }
+    } catch (ClassNotFoundException cnfe) {
+      System.err.println("fatal: unknown command " + name);
+      System.err.println("      (no " + PKG + "." + name + ")");
+      return 1;
+    }
+
+    final Method main;
+    try {
+      main = clazz.getMethod("main", argv.getClass());
+    } catch (SecurityException | NoSuchMethodException e) {
+      System.err.println("fatal: unknown command " + name);
+      return 1;
+    }
+
+    final Object res;
+    try {
+      if ((main.getModifiers() & Modifier.STATIC) == Modifier.STATIC) {
+        res = main.invoke(null, new Object[] {argv});
+      } else {
+        res =
+            main.invoke(clazz.getConstructor(new Class<?>[] {}).newInstance(), new Object[] {argv});
+      }
+    } catch (InvocationTargetException ite) {
+      if (ite.getCause() instanceof Exception) {
+        throw (Exception) ite.getCause();
+      } else if (ite.getCause() instanceof Error) {
+        throw (Error) ite.getCause();
+      } else {
+        throw ite;
+      }
+    }
+    if (res instanceof Number) {
+      return ((Number) res).intValue();
+    }
+    return 0;
+  }
+
+  private static String programClassName(String cn) {
+    if (cn.equals(cn.toLowerCase())) {
+      StringBuilder buf = new StringBuilder();
+      buf.append(Character.toUpperCase(cn.charAt(0)));
+      for (int i = 1; i < cn.length(); i++) {
+        if (cn.charAt(i) == '-' && i + 1 < cn.length()) {
+          i++;
+          buf.append(Character.toUpperCase(cn.charAt(i)));
+        } else {
+          buf.append(cn.charAt(i));
+        }
+      }
+      return buf.toString();
+    }
+    return cn;
+  }
+
+  private static ClassLoader libClassLoader(boolean prologCompiler) throws IOException {
+    final File path;
+    try {
+      path = getDistributionArchive();
+    } catch (FileNotFoundException e) {
+      if (NOT_ARCHIVED.equals(e.getMessage())) {
+        return useDevClasspath();
+      }
+      throw e;
+    }
+
+    final SortedMap<String, URL> jars = new TreeMap<>();
+    try (ZipFile zf = new ZipFile(path)) {
+      final Enumeration<? extends ZipEntry> e = zf.entries();
+      while (e.hasMoreElements()) {
+        final ZipEntry ze = e.nextElement();
+        if (ze.isDirectory()) {
+          continue;
+        }
+
+        String name = ze.getName();
+        if (name.startsWith("WEB-INF/lib/")) {
+          extractJar(zf, ze, jars);
+        } else if (name.startsWith("WEB-INF/pgm-lib/")) {
+          // Some Prolog tools are restricted.
+          if (prologCompiler || !name.startsWith("WEB-INF/pgm-lib/prolog-")) {
+            extractJar(zf, ze, jars);
+          }
+        }
+      }
+    } catch (IOException e) {
+      throw new IOException("Cannot obtain libraries from " + path, e);
+    }
+
+    if (jars.isEmpty()) {
+      return GerritLauncher.class.getClassLoader();
+    }
+
+    // The extension API needs to be its own ClassLoader, along
+    // with a few of its dependencies. Try to construct this first.
+    List<URL> extapi = new ArrayList<>();
+    move(jars, "gerrit-extension-api-", extapi);
+    move(jars, "guice-", extapi);
+    move(jars, "javax.inject-1.jar", extapi);
+    move(jars, "aopalliance-1.0.jar", extapi);
+    move(jars, "guice-servlet-", extapi);
+    move(jars, "tomcat-servlet-api-", extapi);
+
+    ClassLoader parent = ClassLoader.getSystemClassLoader();
+    if (!extapi.isEmpty()) {
+      parent = new URLClassLoader(extapi.toArray(new URL[extapi.size()]), parent);
+    }
+    return new URLClassLoader(jars.values().toArray(new URL[jars.size()]), parent);
+  }
+
+  private static void extractJar(ZipFile zf, ZipEntry ze, SortedMap<String, URL> jars)
+      throws IOException {
+    File tmp = createTempFile(safeName(ze), ".jar");
+    try (OutputStream out = Files.newOutputStream(tmp.toPath());
+        InputStream in = zf.getInputStream(ze)) {
+      byte[] buf = new byte[4096];
+      int n;
+      while ((n = in.read(buf, 0, buf.length)) > 0) {
+        out.write(buf, 0, n);
+      }
+    }
+
+    String name = ze.getName();
+    jars.put(name.substring(name.lastIndexOf('/'), name.length()), tmp.toURI().toURL());
+  }
+
+  private static void move(SortedMap<String, URL> jars, String prefix, List<URL> extapi) {
+    SortedMap<String, URL> matches = jars.tailMap(prefix);
+    if (!matches.isEmpty()) {
+      String first = matches.firstKey();
+      if (first.startsWith(prefix)) {
+        extapi.add(jars.remove(first));
+      }
+    }
+  }
+
+  private static String safeName(ZipEntry ze) {
+    // Try to derive the name of the temporary file so it
+    // doesn't completely suck. Best if we can make it
+    // match the name it was in the archive.
+    //
+    String name = ze.getName();
+    if (name.contains("/")) {
+      name = name.substring(name.lastIndexOf('/') + 1);
+    }
+    if (name.contains(".")) {
+      name = name.substring(0, name.lastIndexOf('.'));
+    }
+    if (name.isEmpty()) {
+      name = "code";
+    }
+    return name;
+  }
+
+  private static volatile File myArchive;
+  private static volatile File myHome;
+
+  private static final Map<Path, FileSystem> zipFileSystems = new HashMap<>();
+
+  /**
+   * Locate the JAR/WAR file we were launched from.
+   *
+   * @return local path of the Gerrit WAR file.
+   * @throws FileNotFoundException if the code cannot guess the location.
+   */
+  public static File getDistributionArchive() throws FileNotFoundException, IOException {
+    File result = myArchive;
+    if (result == null) {
+      synchronized (GerritLauncher.class) {
+        result = myArchive;
+        if (result != null) {
+          return result;
+        }
+        result = locateMyArchive();
+        myArchive = result;
+      }
+    }
+    return result;
+  }
+
+  public static synchronized FileSystem getZipFileSystem(Path zip) throws IOException {
+    // FileSystems canonicalizes the path, so we should too.
+    zip = zip.toRealPath();
+    FileSystem zipFs = zipFileSystems.get(zip);
+    if (zipFs == null) {
+      zipFs = newZipFileSystem(zip);
+      zipFileSystems.put(zip, zipFs);
+    }
+    return zipFs;
+  }
+
+  public static FileSystem newZipFileSystem(Path zip) throws IOException {
+    return FileSystems.newFileSystem(
+        URI.create("jar:" + zip.toUri()), Collections.<String, String>emptyMap());
+  }
+
+  private static File locateMyArchive() throws FileNotFoundException {
+    final ClassLoader myCL = GerritLauncher.class.getClassLoader();
+    final String myName = GerritLauncher.class.getName().replace('.', '/') + ".class";
+
+    final URL myClazz = myCL.getResource(myName);
+    if (myClazz == null) {
+      throw new FileNotFoundException("Cannot find JAR: no " + myName);
+    }
+
+    // ZipFile may have the path of our JAR hiding within itself.
+    //
+    try {
+      JarFile jar = ((JarURLConnection) myClazz.openConnection()).getJarFile();
+      File path = new File(jar.getName());
+      if (path.isFile()) {
+        return path;
+      }
+    } catch (Exception e) {
+      // Nope, that didn't work. Try a different method.
+      //
+    }
+
+    // Maybe this is a local class file, running under a debugger?
+    //
+    if ("file".equals(myClazz.getProtocol())) {
+      final File path = new File(myClazz.getPath());
+      if (path.isFile() && path.getParentFile().isDirectory()) {
+        throw new FileNotFoundException(NOT_ARCHIVED);
+      }
+    }
+
+    // The CodeSource might be able to give us the source as a stream.
+    // If so, copy it to a local file so we have random access to it.
+    //
+    final CodeSource src = GerritLauncher.class.getProtectionDomain().getCodeSource();
+    if (src != null) {
+      try (InputStream in = src.getLocation().openStream()) {
+        final File tmp = createTempFile("gerrit_", ".zip");
+        try (OutputStream out = Files.newOutputStream(tmp.toPath())) {
+          final byte[] buf = new byte[4096];
+          int n;
+          while ((n = in.read(buf, 0, buf.length)) > 0) {
+            out.write(buf, 0, n);
+          }
+        }
+        return tmp;
+      } catch (IOException e) {
+        // Nope, that didn't work.
+        //
+      }
+    }
+
+    throw new FileNotFoundException("Cannot find local copy of JAR");
+  }
+
+  private static boolean temporaryDirectoryFound;
+  private static File temporaryDirectory;
+
+  /**
+   * Creates a temporary file within the application's unpack location.
+   *
+   * <p>The launcher unpacks the nested JAR files into a temporary directory, allowing the classes
+   * to be loaded from local disk with standard Java APIs. This method constructs a new temporary
+   * file in the same directory.
+   *
+   * <p>The method first tries to create {@code prefix + suffix} within the directory under the
+   * assumption that a given {@code prefix + suffix} combination is made at most once per JVM
+   * execution. If this fails (e.g. the named file already exists) a mangled unique name is used and
+   * returned instead, with the unique string appearing between the prefix and suffix.
+   *
+   * <p>Files created by this method will be automatically deleted by the JVM when it terminates. If
+   * the returned file is converted into a directory by the caller, the caller must arrange for the
+   * contents to be deleted before the directory is.
+   *
+   * <p>If supported by the underlying operating system, the temporary directory which contains
+   * these temporary files is accessible only by the user running the JVM.
+   *
+   * @param prefix prefix of the file name.
+   * @param suffix suffix of the file name.
+   * @return the path of the temporary file. The returned object exists in the filesystem as a file;
+   *     caller may need to delete and recreate as a directory if a directory was preferred.
+   * @throws IOException the file could not be created.
+   */
+  public static synchronized File createTempFile(String prefix, String suffix) throws IOException {
+    if (!temporaryDirectoryFound) {
+      final File d = File.createTempFile("gerrit_", "_app", tmproot());
+      if (d.delete() && d.mkdir()) {
+        // Try to lock the directory down to be accessible by us.
+        // We first have to remove all permissions, then add back
+        // only the owner permissions.
+        //
+        d.setWritable(false, false /* all */);
+        d.setReadable(false, false /* all */);
+        d.setExecutable(false, false /* all */);
+
+        d.setWritable(true, true /* owner only */);
+        d.setReadable(true, true /* owner only */);
+        d.setExecutable(true, true /* owner only */);
+
+        d.deleteOnExit();
+        temporaryDirectory = d;
+      }
+      temporaryDirectoryFound = true;
+    }
+
+    if (temporaryDirectory != null) {
+      // If we have a private directory and this name has not yet
+      // been used within the private directory, create it as-is.
+      //
+      final File tmp = new File(temporaryDirectory, prefix + suffix);
+      if (tmp.createNewFile()) {
+        tmp.deleteOnExit();
+        return tmp;
+      }
+    }
+
+    if (!prefix.endsWith("_")) {
+      prefix += "_";
+    }
+
+    final File tmp = File.createTempFile(prefix, suffix, temporaryDirectory);
+    tmp.deleteOnExit();
+    return tmp;
+  }
+
+  /**
+   * Provide path to a working directory
+   *
+   * @return local path of the working directory or null if cannot be determined
+   */
+  public static File getHomeDirectory() {
+    if (myHome == null) {
+      myHome = locateHomeDirectory();
+    }
+    return myHome;
+  }
+
+  private static File tmproot() {
+    File tmp;
+    String gerritTemp = System.getenv("GERRIT_TMP");
+    if (gerritTemp != null && gerritTemp.length() > 0) {
+      tmp = new File(gerritTemp);
+    } else {
+      tmp = new File(getHomeDirectory(), "tmp");
+    }
+    if (!tmp.exists() && !tmp.mkdirs()) {
+      System.err.println("warning: cannot create " + tmp.getAbsolutePath());
+      System.err.println("warning: using system temporary directory instead");
+      return null;
+    }
+
+    // Try to clean up any stale empty directories. Assume any empty
+    // directory that is older than 7 days is one of these dead ones
+    // that we can clean up.
+    //
+    final File[] tmpEntries = tmp.listFiles();
+    if (tmpEntries != null) {
+      final long now = System.currentTimeMillis();
+      final long expired = now - MILLISECONDS.convert(7, DAYS);
+      for (File tmpEntry : tmpEntries) {
+        if (tmpEntry.isDirectory() && tmpEntry.lastModified() < expired) {
+          final String[] all = tmpEntry.list();
+          if (all == null || all.length == 0) {
+            tmpEntry.delete();
+          }
+        }
+      }
+    }
+
+    try {
+      return tmp.getCanonicalFile();
+    } catch (IOException e) {
+      return tmp;
+    }
+  }
+
+  private static File locateHomeDirectory() {
+    // Try to find the user's home directory. If we can't find it
+    // return null so the JVM's default temporary directory is used
+    // instead. This is probably /tmp or /var/tmp.
+    //
+    String userHome = System.getProperty("user.home");
+    if (userHome == null || "".equals(userHome)) {
+      userHome = System.getenv("HOME");
+      if (userHome == null || "".equals(userHome)) {
+        System.err.println("warning: cannot determine home directory");
+        System.err.println("warning: using system temporary directory instead");
+        return null;
+      }
+    }
+
+    // Ensure the home directory exists. If it doesn't, try to make it.
+    //
+    final File home = new File(userHome);
+    if (!home.exists()) {
+      if (home.mkdirs()) {
+        System.err.println("warning: created " + home.getAbsolutePath());
+      } else {
+        System.err.println("warning: " + home.getAbsolutePath() + " not found");
+        System.err.println("warning: using system temporary directory instead");
+        return null;
+      }
+    }
+
+    // Use $HOME/.gerritcodereview/tmp for our temporary file area.
+    //
+    final File gerrithome = new File(home, ".gerritcodereview");
+    if (!gerrithome.exists() && !gerrithome.mkdirs()) {
+      System.err.println("warning: cannot create " + gerrithome.getAbsolutePath());
+      System.err.println("warning: using system temporary directory instead");
+      return null;
+    }
+    try {
+      return gerrithome.getCanonicalFile();
+    } catch (IOException e) {
+      return gerrithome;
+    }
+  }
+
+  /**
+   * Check whether the process is running in Eclipse.
+   *
+   * <p>Unlike {@link #getDeveloperEclipseOut()}, this method checks the actual runtime stack, not
+   * the classpath.
+   *
+   * @return true if any thread has a stack frame in {@code org.eclipse.jdt}.
+   */
+  public static boolean isRunningInEclipse() {
+    return Thread.getAllStackTraces()
+        .values()
+        .stream()
+        .flatMap(Arrays::stream)
+        .anyMatch(e -> e.getClassName().startsWith("org.eclipse.jdt."));
+  }
+
+  /**
+   * Locate the path of the {@code eclipse-out} directory in a source tree.
+   *
+   * <p>Unlike {@link #isRunningInEclipse()}, this method only inspects files relative to the
+   * classpath, not the runtime stack.
+   *
+   * @return local path of the {@code eclipse-out} directory in a source tree.
+   * @throws FileNotFoundException if the directory cannot be found.
+   */
+  public static Path getDeveloperEclipseOut() throws FileNotFoundException {
+    return resolveInSourceRoot("eclipse-out");
+  }
+
+  static final String SOURCE_ROOT_RESOURCE = "/com/google/gerrit/launcher/workspace-root.txt";
+
+  /**
+   * Locate a path in the source tree.
+   *
+   * @return local path of the {@code name} directory in a source tree.
+   * @throws FileNotFoundException if the directory cannot be found.
+   */
+  public static Path resolveInSourceRoot(String name) throws FileNotFoundException {
+
+    // Find ourselves in the classpath, as a loose class file or jar.
+    Class<GerritLauncher> self = GerritLauncher.class;
+
+    // If the build system provides us with a source root, use that.
+    try (InputStream stream = self.getResourceAsStream(SOURCE_ROOT_RESOURCE)) {
+      if (stream != null) {
+        try (Scanner scan = new Scanner(stream, UTF_8.name()).useDelimiter("\n")) {
+          if (scan.hasNext()) {
+            Path p = Paths.get(scan.next());
+            if (!Files.exists(p)) {
+              throw new FileNotFoundException("source root not found: " + p);
+            }
+            return p;
+          }
+        }
+      }
+    } catch (IOException e) {
+      // not Bazel, then.
+    }
+
+    URL u = self.getResource(self.getSimpleName() + ".class");
+    if (u == null) {
+      throw new FileNotFoundException("Cannot find class " + self.getName());
+    } else if ("jar".equals(u.getProtocol())) {
+      String p = u.getPath();
+      try {
+        u = new URL(p.substring(0, p.indexOf('!')));
+      } catch (MalformedURLException e) {
+        FileNotFoundException fnfe = new FileNotFoundException("Not a valid jar file: " + u);
+        fnfe.initCause(e);
+        throw fnfe;
+      }
+    }
+    if (!"file".equals(u.getProtocol())) {
+      throw new FileNotFoundException("Cannot extract path from " + u);
+    }
+
+    // Pop up to the top-level source folder by looking for .buckconfig.
+    Path dir = Paths.get(u.getPath());
+    while (!Files.isRegularFile(dir.resolve("WORKSPACE"))) {
+      Path parent = dir.getParent();
+      if (parent == null) {
+        throw new FileNotFoundException("Cannot find source root from " + u);
+      }
+      dir = parent;
+    }
+
+    Path ret = dir.resolve(name);
+    if (!Files.exists(ret)) {
+      throw new FileNotFoundException(name + " not found in source root " + dir);
+    }
+    return ret;
+  }
+
+  private static ClassLoader useDevClasspath() throws MalformedURLException, FileNotFoundException {
+    Path out = getDeveloperEclipseOut();
+    List<URL> dirs = new ArrayList<>();
+    dirs.add(out.resolve("classes").toUri().toURL());
+    ClassLoader cl = GerritLauncher.class.getClassLoader();
+    for (URL u : ((URLClassLoader) cl).getURLs()) {
+      if (includeJar(u)) {
+        dirs.add(u);
+      }
+    }
+    return new URLClassLoader(
+        dirs.toArray(new URL[dirs.size()]), ClassLoader.getSystemClassLoader().getParent());
+  }
+
+  private static boolean includeJar(URL u) {
+    String path = u.getPath();
+    return path.endsWith(".jar")
+        && !path.endsWith("-src.jar")
+        && !path.contains("/buck-out/gen/lib/gwt/");
+  }
+
+  private GerritLauncher() {}
+}
diff --git a/java/com/google/gerrit/lifecycle/BUILD b/java/com/google/gerrit/lifecycle/BUILD
new file mode 100644
index 0000000..191305b
--- /dev/null
+++ b/java/com/google/gerrit/lifecycle/BUILD
@@ -0,0 +1,11 @@
+java_library(
+    name = "lifecycle",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//lib:guava",
+        "//lib/guice",
+        "//lib/log:api",
+    ],
+)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleManager.java b/java/com/google/gerrit/lifecycle/LifecycleManager.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleManager.java
rename to java/com/google/gerrit/lifecycle/LifecycleManager.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleModule.java b/java/com/google/gerrit/lifecycle/LifecycleModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleModule.java
rename to java/com/google/gerrit/lifecycle/LifecycleModule.java
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
new file mode 100644
index 0000000..0c261f6
--- /dev/null
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -0,0 +1,535 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.lucene;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Sets;
+import com.google.common.util.concurrent.AbstractFuture;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.FieldType;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Function;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.Field.Store;
+import org.apache.lucene.document.IntField;
+import org.apache.lucene.document.LongField;
+import org.apache.lucene.document.StoredField;
+import org.apache.lucene.document.StringField;
+import org.apache.lucene.document.TextField;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.index.TrackingIndexWriter;
+import org.apache.lucene.search.ControlledRealTimeReopenThread;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.ReferenceManager;
+import org.apache.lucene.search.ReferenceManager.RefreshListener;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.SearcherFactory;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.TopFieldDocs;
+import org.apache.lucene.store.AlreadyClosedException;
+import org.apache.lucene.store.Directory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Basic Lucene index implementation. */
+public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
+  private static final Logger log = LoggerFactory.getLogger(AbstractLuceneIndex.class);
+
+  static String sortFieldName(FieldDef<?, ?> f) {
+    return f.getName() + "_SORT";
+  }
+
+  private final Schema<V> schema;
+  private final SitePaths sitePaths;
+  private final Directory dir;
+  private final String name;
+  private final ListeningExecutorService writerThread;
+  private final TrackingIndexWriter writer;
+  private final ReferenceManager<IndexSearcher> searcherManager;
+  private final ControlledRealTimeReopenThread<IndexSearcher> reopenThread;
+  private final Set<NrtFuture> notDoneNrtFutures;
+  private ScheduledThreadPoolExecutor autoCommitExecutor;
+
+  AbstractLuceneIndex(
+      Schema<V> schema,
+      SitePaths sitePaths,
+      Directory dir,
+      String name,
+      String subIndex,
+      GerritIndexWriterConfig writerConfig,
+      SearcherFactory searcherFactory)
+      throws IOException {
+    this.schema = schema;
+    this.sitePaths = sitePaths;
+    this.dir = dir;
+    this.name = name;
+    String index = Joiner.on('_').skipNulls().join(name, subIndex);
+    IndexWriter delegateWriter;
+    long commitPeriod = writerConfig.getCommitWithinMs();
+
+    if (commitPeriod < 0) {
+      delegateWriter = new AutoCommitWriter(dir, writerConfig.getLuceneConfig());
+    } else if (commitPeriod == 0) {
+      delegateWriter = new AutoCommitWriter(dir, writerConfig.getLuceneConfig(), true);
+    } else {
+      final AutoCommitWriter autoCommitWriter =
+          new AutoCommitWriter(dir, writerConfig.getLuceneConfig());
+      delegateWriter = autoCommitWriter;
+
+      autoCommitExecutor =
+          new ScheduledThreadPoolExecutor(
+              1,
+              new ThreadFactoryBuilder()
+                  .setNameFormat(index + " Commit-%d")
+                  .setDaemon(true)
+                  .build());
+      @SuppressWarnings("unused") // Error handling within Runnable.
+      Future<?> possiblyIgnoredError =
+          autoCommitExecutor.scheduleAtFixedRate(
+              () -> {
+                try {
+                  if (autoCommitWriter.hasUncommittedChanges()) {
+                    autoCommitWriter.manualFlush();
+                    autoCommitWriter.commit();
+                  }
+                } catch (IOException e) {
+                  log.error("Error committing " + index + " Lucene index", e);
+                } catch (OutOfMemoryError e) {
+                  log.error("Error committing " + index + " Lucene index", e);
+                  try {
+                    autoCommitWriter.close();
+                  } catch (IOException e2) {
+                    log.error(
+                        "SEVERE: Error closing "
+                            + index
+                            + " Lucene index after OOM;"
+                            + " index may be corrupted.",
+                        e);
+                  }
+                }
+              },
+              commitPeriod,
+              commitPeriod,
+              MILLISECONDS);
+    }
+    writer = new TrackingIndexWriter(delegateWriter);
+    searcherManager = new WrappableSearcherManager(writer.getIndexWriter(), true, searcherFactory);
+
+    notDoneNrtFutures = Sets.newConcurrentHashSet();
+
+    writerThread =
+        MoreExecutors.listeningDecorator(
+            Executors.newFixedThreadPool(
+                1,
+                new ThreadFactoryBuilder()
+                    .setNameFormat(index + " Write-%d")
+                    .setDaemon(true)
+                    .build()));
+
+    reopenThread =
+        new ControlledRealTimeReopenThread<>(
+            writer,
+            searcherManager,
+            0.500 /* maximum stale age (seconds) */,
+            0.010 /* minimum stale age (seconds) */);
+    reopenThread.setName(index + " NRT");
+    reopenThread.setPriority(
+        Math.min(Thread.currentThread().getPriority() + 2, Thread.MAX_PRIORITY));
+    reopenThread.setDaemon(true);
+
+    // This must be added after the reopen thread is created. The reopen thread
+    // adds its own listener which copies its internally last-refreshed
+    // generation to the searching generation. removeIfDone() depends on the
+    // searching generation being up to date when calling
+    // reopenThread.waitForGeneration(gen, 0), therefore the reopen thread's
+    // internal listener needs to be called first.
+    // TODO(dborowitz): This may have been fixed by
+    // http://issues.apache.org/jira/browse/LUCENE-5461
+    searcherManager.addListener(
+        new RefreshListener() {
+          @Override
+          public void beforeRefresh() throws IOException {}
+
+          @Override
+          public void afterRefresh(boolean didRefresh) throws IOException {
+            for (NrtFuture f : notDoneNrtFutures) {
+              f.removeIfDone();
+            }
+          }
+        });
+
+    reopenThread.start();
+  }
+
+  @Override
+  public void markReady(boolean ready) throws IOException {
+    IndexUtils.setReady(sitePaths, name, schema.getVersion(), ready);
+  }
+
+  @Override
+  public void close() {
+    if (autoCommitExecutor != null) {
+      autoCommitExecutor.shutdown();
+    }
+
+    writerThread.shutdown();
+    try {
+      if (!writerThread.awaitTermination(5, TimeUnit.SECONDS)) {
+        log.warn("shutting down " + name + " index with pending Lucene writes");
+      }
+    } catch (InterruptedException e) {
+      log.warn("interrupted waiting for pending Lucene writes of " + name + " index", e);
+    }
+    reopenThread.close();
+
+    // Closing the reopen thread sets its generation to Long.MAX_VALUE, but we
+    // still need to refresh the searcher manager to let pending NrtFutures
+    // know.
+    //
+    // Any futures created after this method (which may happen due to undefined
+    // shutdown ordering behavior) will finish immediately, even though they may
+    // not have flushed.
+    try {
+      searcherManager.maybeRefreshBlocking();
+    } catch (IOException e) {
+      log.warn("error finishing pending Lucene writes", e);
+    }
+
+    try {
+      writer.getIndexWriter().close();
+    } catch (AlreadyClosedException e) {
+      // Ignore.
+    } catch (IOException e) {
+      log.warn("error closing Lucene writer", e);
+    }
+    try {
+      dir.close();
+    } catch (IOException e) {
+      log.warn("error closing Lucene directory", e);
+    }
+  }
+
+  ListenableFuture<?> insert(Document doc) {
+    return submit(() -> writer.addDocument(doc));
+  }
+
+  ListenableFuture<?> replace(Term term, Document doc) {
+    return submit(() -> writer.updateDocument(term, doc));
+  }
+
+  ListenableFuture<?> delete(Term term) {
+    return submit(() -> writer.deleteDocuments(term));
+  }
+
+  private ListenableFuture<?> submit(Callable<Long> task) {
+    ListenableFuture<Long> future = Futures.nonCancellationPropagating(writerThread.submit(task));
+    return Futures.transformAsync(
+        future,
+        gen -> {
+          // Tell the reopen thread a future is waiting on this
+          // generation so it uses the min stale time when refreshing.
+          reopenThread.waitForGeneration(gen, 0);
+          return new NrtFuture(gen);
+        },
+        directExecutor());
+  }
+
+  @Override
+  public void deleteAll() throws IOException {
+    writer.deleteAll();
+  }
+
+  public TrackingIndexWriter getWriter() {
+    return writer;
+  }
+
+  IndexSearcher acquire() throws IOException {
+    return searcherManager.acquire();
+  }
+
+  void release(IndexSearcher searcher) throws IOException {
+    searcherManager.release(searcher);
+  }
+
+  Document toDocument(V obj) {
+    Document result = new Document();
+    for (Values<V> vs : schema.buildFields(obj)) {
+      if (vs.getValues() != null) {
+        add(result, vs);
+      }
+    }
+    return result;
+  }
+
+  protected abstract V fromDocument(Document doc);
+
+  void add(Document doc, Values<V> values) {
+    String name = values.getField().getName();
+    FieldType<?> type = values.getField().getType();
+    Store store = store(values.getField());
+
+    if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
+      for (Object value : values.getValues()) {
+        doc.add(new IntField(name, (Integer) value, store));
+      }
+    } else if (type == FieldType.LONG) {
+      for (Object value : values.getValues()) {
+        doc.add(new LongField(name, (Long) value, store));
+      }
+    } else if (type == FieldType.TIMESTAMP) {
+      for (Object value : values.getValues()) {
+        doc.add(new LongField(name, ((Timestamp) value).getTime(), store));
+      }
+    } else if (type == FieldType.EXACT || type == FieldType.PREFIX) {
+      for (Object value : values.getValues()) {
+        doc.add(new StringField(name, (String) value, store));
+      }
+    } else if (type == FieldType.FULL_TEXT) {
+      for (Object value : values.getValues()) {
+        doc.add(new TextField(name, (String) value, store));
+      }
+    } else if (type == FieldType.STORED_ONLY) {
+      for (Object value : values.getValues()) {
+        doc.add(new StoredField(name, (byte[]) value));
+      }
+    } else {
+      throw FieldType.badFieldType(type);
+    }
+  }
+
+  protected FieldBundle toFieldBundle(Document doc) {
+    Map<String, FieldDef<V, ?>> allFields = getSchema().getFields();
+    ListMultimap<String, Object> rawFields = ArrayListMultimap.create();
+    for (IndexableField field : doc.getFields()) {
+      checkArgument(allFields.containsKey(field.name()), "Unrecognized field " + field.name());
+      FieldType<?> type = allFields.get(field.name()).getType();
+      if (type == FieldType.EXACT || type == FieldType.FULL_TEXT || type == FieldType.PREFIX) {
+        rawFields.put(field.name(), field.stringValue());
+      } else if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
+        rawFields.put(field.name(), field.numericValue().intValue());
+      } else if (type == FieldType.LONG) {
+        rawFields.put(field.name(), field.numericValue().longValue());
+      } else if (type == FieldType.TIMESTAMP) {
+        rawFields.put(field.name(), new Timestamp(field.numericValue().longValue()));
+      } else if (type == FieldType.STORED_ONLY) {
+        rawFields.put(field.name(), field.binaryValue().bytes);
+      } else {
+        throw FieldType.badFieldType(type);
+      }
+    }
+    return new FieldBundle(rawFields);
+  }
+
+  private static Field.Store store(FieldDef<?, ?> f) {
+    return f.isStored() ? Field.Store.YES : Field.Store.NO;
+  }
+
+  private final class NrtFuture extends AbstractFuture<Void> {
+    private final long gen;
+
+    NrtFuture(long gen) {
+      this.gen = gen;
+    }
+
+    @Override
+    public Void get() throws InterruptedException, ExecutionException {
+      if (!isDone()) {
+        reopenThread.waitForGeneration(gen);
+        set(null);
+      }
+      return super.get();
+    }
+
+    @Override
+    public Void get(long timeout, TimeUnit unit)
+        throws InterruptedException, TimeoutException, ExecutionException {
+      if (!isDone()) {
+        if (!reopenThread.waitForGeneration(gen, (int) unit.toMillis(timeout))) {
+          throw new TimeoutException();
+        }
+        set(null);
+      }
+      return super.get(timeout, unit);
+    }
+
+    @Override
+    public boolean isDone() {
+      if (super.isDone()) {
+        return true;
+      } else if (isGenAvailableNowForCurrentSearcher()) {
+        set(null);
+        return true;
+      } else if (!reopenThread.isAlive()) {
+        setException(new IllegalStateException("NRT thread is dead"));
+        return true;
+      }
+      return false;
+    }
+
+    @Override
+    public void addListener(Runnable listener, Executor executor) {
+      if (isGenAvailableNowForCurrentSearcher() && !isCancelled()) {
+        set(null);
+      } else if (!isDone()) {
+        notDoneNrtFutures.add(this);
+      }
+      super.addListener(listener, executor);
+    }
+
+    @Override
+    public boolean cancel(boolean mayInterruptIfRunning) {
+      boolean result = super.cancel(mayInterruptIfRunning);
+      if (result) {
+        notDoneNrtFutures.remove(this);
+      }
+      return result;
+    }
+
+    void removeIfDone() {
+      if (isGenAvailableNowForCurrentSearcher()) {
+        notDoneNrtFutures.remove(this);
+        if (!isCancelled()) {
+          set(null);
+        }
+      }
+    }
+
+    private boolean isGenAvailableNowForCurrentSearcher() {
+      try {
+        return reopenThread.waitForGeneration(gen, 0);
+      } catch (InterruptedException e) {
+        log.warn("Interrupted waiting for searcher generation", e);
+        return false;
+      }
+    }
+  }
+
+  @Override
+  public Schema<V> getSchema() {
+    return schema;
+  }
+
+  protected class LuceneQuerySource implements DataSource<V> {
+    private final QueryOptions opts;
+    private final Query query;
+    private final Sort sort;
+
+    LuceneQuerySource(QueryOptions opts, Query query, Sort sort) {
+      this.opts = opts;
+      this.query = query;
+      this.sort = sort;
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<V> read() throws OrmException {
+      return readImpl((doc) -> fromDocument(doc));
+    }
+
+    @Override
+    public ResultSet<FieldBundle> readRaw() throws OrmException {
+      return readImpl(AbstractLuceneIndex.this::toFieldBundle);
+    }
+
+    private <T> ResultSet<T> readImpl(Function<Document, T> mapper) throws OrmException {
+      IndexSearcher searcher = null;
+      try {
+        searcher = acquire();
+        int realLimit = opts.start() + opts.limit();
+        TopFieldDocs docs = searcher.search(query, realLimit, sort);
+        List<T> 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, opts.fields());
+          T mapperResult = mapper.apply(doc);
+          if (mapperResult != null) {
+            result.add(mapperResult);
+          }
+        }
+        final List<T> r = Collections.unmodifiableList(result);
+        return new ResultSet<T>() {
+          @Override
+          public Iterator<T> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<T> 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);
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AutoCommitWriter.java b/java/com/google/gerrit/lucene/AutoCommitWriter.java
similarity index 100%
rename from gerrit-lucene/src/main/java/com/google/gerrit/lucene/AutoCommitWriter.java
rename to java/com/google/gerrit/lucene/AutoCommitWriter.java
diff --git a/java/com/google/gerrit/lucene/BUILD b/java/com/google/gerrit/lucene/BUILD
new file mode 100644
index 0000000..2254905
--- /dev/null
+++ b/java/com/google/gerrit/lucene/BUILD
@@ -0,0 +1,45 @@
+QUERY_BUILDER = ["QueryBuilder.java"]
+
+java_library(
+    name = "query_builder",
+    srcs = QUERY_BUILDER,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib/lucene:lucene-core-and-backward-codecs",
+    ],
+)
+
+java_library(
+    name = "lucene",
+    srcs = glob(
+        ["**/*.java"],
+        exclude = QUERY_BUILDER,
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":query_builder",
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//lib/lucene:lucene-analyzers-common",
+        "//lib/lucene:lucene-core-and-backward-codecs",
+        "//lib/lucene:lucene-misc",
+    ],
+)
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
new file mode 100644
index 0000000..7d7cbef
--- /dev/null
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -0,0 +1,113 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.lucene;
+
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.gerrit.lucene.LuceneChangeIndex.ID_SORT_FIELD;
+import static com.google.gerrit.lucene.LuceneChangeIndex.UPDATED_SORT_FIELD;
+import static com.google.gerrit.server.index.change.ChangeSchemaDefinitions.NAME;
+
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.sql.Timestamp;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.NumericDocValuesField;
+import org.apache.lucene.search.SearcherFactory;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+
+public class ChangeSubIndex extends AbstractLuceneIndex<Change.Id, ChangeData>
+    implements ChangeIndex {
+  ChangeSubIndex(
+      Schema<ChangeData> schema,
+      SitePaths sitePaths,
+      Path path,
+      GerritIndexWriterConfig writerConfig,
+      SearcherFactory searcherFactory)
+      throws IOException {
+    this(
+        schema,
+        sitePaths,
+        FSDirectory.open(path),
+        path.getFileName().toString(),
+        writerConfig,
+        searcherFactory);
+  }
+
+  ChangeSubIndex(
+      Schema<ChangeData> schema,
+      SitePaths sitePaths,
+      Directory dir,
+      String subIndex,
+      GerritIndexWriterConfig writerConfig,
+      SearcherFactory searcherFactory)
+      throws IOException {
+    super(schema, sitePaths, dir, NAME, subIndex, writerConfig, searcherFactory);
+  }
+
+  @Override
+  public void replace(ChangeData obj) throws IOException {
+    throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
+  }
+
+  @Override
+  public void delete(Change.Id key) throws IOException {
+    throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
+  }
+
+  @Override
+  public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts)
+      throws QueryParseException {
+    throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
+  }
+
+  // Make method public so that it can be used in LuceneChangeIndex
+  @Override
+  public FieldBundle toFieldBundle(Document doc) {
+    return super.toFieldBundle(doc);
+  }
+
+  @Override
+  void add(Document doc, Values<ChangeData> values) {
+    // Add separate DocValues fields for those fields needed for sorting.
+    FieldDef<ChangeData, ?> f = values.getField();
+    if (f == ChangeField.LEGACY_ID) {
+      int v = (Integer) getOnlyElement(values.getValues());
+      doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
+    } else if (f == ChangeField.UPDATED) {
+      long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
+      doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
+    }
+    super.add(doc, values);
+  }
+
+  @Override
+  protected ChangeData fromDocument(Document doc) {
+    throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
+  }
+}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/CustomMappingAnalyzer.java b/java/com/google/gerrit/lucene/CustomMappingAnalyzer.java
similarity index 100%
rename from gerrit-lucene/src/main/java/com/google/gerrit/lucene/CustomMappingAnalyzer.java
rename to java/com/google/gerrit/lucene/CustomMappingAnalyzer.java
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java b/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
similarity index 100%
rename from gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
rename to java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
new file mode 100644
index 0000000..8ceea0d
--- /dev/null
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.lucene;
+
+import static com.google.gerrit.server.index.account.AccountField.ID;
+
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.account.AccountIndex;
+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.concurrent.ExecutionException;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.SearcherFactory;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+import org.apache.lucene.store.RAMDirectory;
+import org.eclipse.jgit.lib.Config;
+
+public class LuceneAccountIndex extends AbstractLuceneIndex<Account.Id, AccountState>
+    implements AccountIndex {
+  private static final String ACCOUNTS = "accounts";
+
+  private static final String ID_SORT_FIELD = sortFieldName(ID);
+
+  private static Term idTerm(AccountState as) {
+    return idTerm(as.getAccount().getId());
+  }
+
+  private static Term idTerm(Account.Id id) {
+    return QueryBuilder.intTerm(ID.getName(), id.get());
+  }
+
+  private final GerritIndexWriterConfig indexWriterConfig;
+  private final QueryBuilder<AccountState> queryBuilder;
+  private final Provider<AccountCache> accountCache;
+
+  private static Directory dir(Schema<AccountState> schema, Config cfg, SitePaths sitePaths)
+      throws IOException {
+    if (LuceneIndexModule.isInMemoryTest(cfg)) {
+      return new RAMDirectory();
+    }
+    Path indexDir = LuceneVersionManager.getDir(sitePaths, ACCOUNTS, schema);
+    return FSDirectory.open(indexDir);
+  }
+
+  @Inject
+  LuceneAccountIndex(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      Provider<AccountCache> accountCache,
+      @Assisted Schema<AccountState> schema)
+      throws IOException {
+    super(
+        schema,
+        sitePaths,
+        dir(schema, cfg, sitePaths),
+        ACCOUNTS,
+        null,
+        new GerritIndexWriterConfig(cfg, ACCOUNTS),
+        new SearcherFactory());
+    this.accountCache = accountCache;
+
+    indexWriterConfig = new GerritIndexWriterConfig(cfg, ACCOUNTS);
+    queryBuilder = new QueryBuilder<>(schema, indexWriterConfig.getAnalyzer());
+  }
+
+  @Override
+  public void replace(AccountState as) throws IOException {
+    try {
+      replace(idTerm(as), toDocument(as)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public void delete(Account.Id key) throws IOException {
+    try {
+      delete(idTerm(key)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
+      throws QueryParseException {
+    return new LuceneQuerySource(
+        opts.filterFields(IndexUtils::accountFields),
+        queryBuilder.toQuery(p),
+        new Sort(new SortField(ID_SORT_FIELD, SortField.Type.LONG, true)));
+  }
+
+  @Override
+  protected AccountState fromDocument(Document doc) {
+    Account.Id id = new Account.Id(doc.getField(ID.getName()).numericValue().intValue());
+    // Use the AccountCache rather than depending on any stored fields in the
+    // document (of which there shouldn't be any). The most expensive part to
+    // compute anyway is the effective group IDs, and we don't have a good way
+    // to reindex when those change.
+    return accountCache.get().get(id);
+  }
+}
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
new file mode 100644
index 0000000..b30e66c
--- /dev/null
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -0,0 +1,666 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.lucene;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
+import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
+import static com.google.gerrit.server.index.change.ChangeField.APPROVAL_CODEC;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE_CODEC;
+import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
+import static com.google.gerrit.server.index.change.ChangeField.PATCH_SET_CODEC;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Function;
+import com.google.common.base.Throwables;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Sets;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexableField;
+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.SearcherManager;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.search.TopFieldDocs;
+import org.apache.lucene.store.RAMDirectory;
+import org.apache.lucene.util.BytesRef;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Secondary index implementation using Apache Lucene.
+ *
+ * <p>Writes are managed using a single {@link IndexWriter} per process, committed aggressively.
+ * Reads use {@link SearcherManager} and periodically refresh, though there may be some lag between
+ * a committed write and it showing up to other threads' searchers.
+ */
+public class LuceneChangeIndex implements ChangeIndex {
+  private static final Logger log = LoggerFactory.getLogger(LuceneChangeIndex.class);
+
+  static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
+  static final String ID_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID);
+
+  private static final String CHANGES = "changes";
+  private static final String CHANGES_OPEN = "open";
+  private static final String CHANGES_CLOSED = "closed";
+  private static final String ADDED_FIELD = ChangeField.ADDED.getName();
+  private static final String APPROVAL_FIELD = ChangeField.APPROVAL.getName();
+  private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
+  private static final String DELETED_FIELD = ChangeField.DELETED.getName();
+  private static final String MERGEABLE_FIELD = ChangeField.MERGEABLE.getName();
+  private static final String PATCH_SET_FIELD = ChangeField.PATCH_SET.getName();
+  private static final String PENDING_REVIEWER_FIELD = ChangeField.PENDING_REVIEWER.getName();
+  private static final String PENDING_REVIEWER_BY_EMAIL_FIELD =
+      ChangeField.PENDING_REVIEWER_BY_EMAIL.getName();
+  private static final String REF_STATE_FIELD = ChangeField.REF_STATE.getName();
+  private static final String REF_STATE_PATTERN_FIELD = ChangeField.REF_STATE_PATTERN.getName();
+  private static final String REVIEWEDBY_FIELD = ChangeField.REVIEWEDBY.getName();
+  private static final String REVIEWER_FIELD = ChangeField.REVIEWER.getName();
+  private static final String REVIEWER_BY_EMAIL_FIELD = ChangeField.REVIEWER_BY_EMAIL.getName();
+  private static final String HASHTAG_FIELD = ChangeField.HASHTAG_CASE_AWARE.getName();
+  private static final String STAR_FIELD = ChangeField.STAR.getName();
+  private static final String SUBMIT_RECORD_LENIENT_FIELD =
+      ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName();
+  private static final String SUBMIT_RECORD_STRICT_FIELD =
+      ChangeField.STORED_SUBMIT_RECORD_STRICT.getName();
+  private static final String UNRESOLVED_COMMENT_COUNT_FIELD =
+      ChangeField.UNRESOLVED_COMMENT_COUNT.getName();
+
+  static Term idTerm(ChangeData cd) {
+    return QueryBuilder.intTerm(LEGACY_ID.getName(), cd.getId().get());
+  }
+
+  static Term idTerm(Change.Id id) {
+    return QueryBuilder.intTerm(LEGACY_ID.getName(), id.get());
+  }
+
+  private final ListeningExecutorService executor;
+  private final Provider<ReviewDb> db;
+  private final ChangeData.Factory changeDataFactory;
+  private final Schema<ChangeData> schema;
+  private final QueryBuilder<ChangeData> queryBuilder;
+  private final ChangeSubIndex openIndex;
+  private final ChangeSubIndex closedIndex;
+
+  @Inject
+  LuceneChangeIndex(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      @IndexExecutor(INTERACTIVE) ListeningExecutorService executor,
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      @Assisted Schema<ChangeData> schema)
+      throws IOException {
+    this.executor = executor;
+    this.db = db;
+    this.changeDataFactory = changeDataFactory;
+    this.schema = schema;
+
+    GerritIndexWriterConfig openConfig = new GerritIndexWriterConfig(cfg, "changes_open");
+    GerritIndexWriterConfig closedConfig = new GerritIndexWriterConfig(cfg, "changes_closed");
+
+    queryBuilder = new QueryBuilder<>(schema, openConfig.getAnalyzer());
+
+    SearcherFactory searcherFactory = new SearcherFactory();
+    if (LuceneIndexModule.isInMemoryTest(cfg)) {
+      openIndex =
+          new ChangeSubIndex(
+              schema, sitePaths, new RAMDirectory(), "ramOpen", openConfig, searcherFactory);
+      closedIndex =
+          new ChangeSubIndex(
+              schema, sitePaths, new RAMDirectory(), "ramClosed", closedConfig, searcherFactory);
+    } else {
+      Path dir = LuceneVersionManager.getDir(sitePaths, CHANGES, schema);
+      openIndex =
+          new ChangeSubIndex(
+              schema, sitePaths, dir.resolve(CHANGES_OPEN), openConfig, searcherFactory);
+      closedIndex =
+          new ChangeSubIndex(
+              schema, sitePaths, dir.resolve(CHANGES_CLOSED), closedConfig, searcherFactory);
+    }
+  }
+
+  @Override
+  public void close() {
+    try {
+      openIndex.close();
+    } finally {
+      closedIndex.close();
+    }
+  }
+
+  @Override
+  public Schema<ChangeData> getSchema() {
+    return schema;
+  }
+
+  @Override
+  public void replace(ChangeData cd) throws IOException {
+    Term id = LuceneChangeIndex.idTerm(cd);
+    // toDocument is essentially static and doesn't depend on the specific
+    // sub-index, so just pick one.
+    Document doc = openIndex.toDocument(cd);
+    try {
+      if (cd.change().getStatus().isOpen()) {
+        Futures.allAsList(closedIndex.delete(id), openIndex.replace(id, doc)).get();
+      } else {
+        Futures.allAsList(openIndex.delete(id), closedIndex.replace(id, doc)).get();
+      }
+    } catch (OrmException | ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public void delete(Change.Id id) throws IOException {
+    Term idTerm = LuceneChangeIndex.idTerm(id);
+    try {
+      Futures.allAsList(openIndex.delete(idTerm), closedIndex.delete(idTerm)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public void deleteAll() throws IOException {
+    openIndex.deleteAll();
+    closedIndex.deleteAll();
+  }
+
+  @Override
+  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
+      throws QueryParseException {
+    Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
+    List<ChangeSubIndex> indexes = new ArrayList<>(2);
+    if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
+      indexes.add(openIndex);
+    }
+    if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
+      indexes.add(closedIndex);
+    }
+    return new QuerySource(indexes, p, opts, getSort(), openIndex::toFieldBundle);
+  }
+
+  @Override
+  public void markReady(boolean ready) throws IOException {
+    // Arbitrary done on open index, as ready bit is set
+    // per index and not sub index
+    openIndex.markReady(ready);
+  }
+
+  private Sort getSort() {
+    return new Sort(
+        new SortField(UPDATED_SORT_FIELD, SortField.Type.LONG, true),
+        new SortField(ID_SORT_FIELD, SortField.Type.LONG, true));
+  }
+
+  public ChangeSubIndex getClosedChangesIndex() {
+    return closedIndex;
+  }
+
+  private class QuerySource implements ChangeDataSource {
+    private final List<ChangeSubIndex> indexes;
+    private final Predicate<ChangeData> predicate;
+    private final Query query;
+    private final QueryOptions opts;
+    private final Sort sort;
+    private final Function<Document, FieldBundle> rawDocumentMapper;
+
+    private QuerySource(
+        List<ChangeSubIndex> indexes,
+        Predicate<ChangeData> predicate,
+        QueryOptions opts,
+        Sort sort,
+        Function<Document, FieldBundle> rawDocumentMapper)
+        throws QueryParseException {
+      this.indexes = indexes;
+      this.predicate = predicate;
+      this.query = checkNotNull(queryBuilder.toQuery(predicate), "null query from Lucene");
+      this.opts = opts;
+      this.sort = sort;
+      this.rawDocumentMapper = rawDocumentMapper;
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10; // TODO(dborowitz): estimate from Lucene?
+    }
+
+    @Override
+    public boolean hasChange() {
+      return false;
+    }
+
+    @Override
+    public String toString() {
+      return predicate.toString();
+    }
+
+    @Override
+    public ResultSet<ChangeData> read() throws OrmException {
+      if (Thread.interrupted()) {
+        Thread.currentThread().interrupt();
+        throw new OrmException("interrupted");
+      }
+
+      final Set<String> fields = IndexUtils.changeFields(opts);
+      return new ChangeDataResults(
+          executor.submit(
+              new Callable<List<Document>>() {
+                @Override
+                public List<Document> call() throws IOException {
+                  return doRead(fields);
+                }
+
+                @Override
+                public String toString() {
+                  return predicate.toString();
+                }
+              }),
+          fields);
+    }
+
+    @Override
+    public ResultSet<FieldBundle> readRaw() throws OrmException {
+      List<Document> documents;
+      try {
+        documents = doRead(IndexUtils.changeFields(opts));
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+      List<FieldBundle> fieldBundles = documents.stream().map(rawDocumentMapper).collect(toList());
+      return new ResultSet<FieldBundle>() {
+        @Override
+        public Iterator<FieldBundle> iterator() {
+          return fieldBundles.iterator();
+        }
+
+        @Override
+        public List<FieldBundle> toList() {
+          return fieldBundles;
+        }
+
+        @Override
+        public void close() {
+          // Do nothing.
+        }
+      };
+    }
+
+    private List<Document> doRead(Set<String> fields) throws IOException {
+      IndexSearcher[] searchers = new IndexSearcher[indexes.size()];
+      try {
+        int realLimit = opts.start() + opts.limit();
+        if (Integer.MAX_VALUE - opts.limit() < opts.start()) {
+          realLimit = Integer.MAX_VALUE;
+        }
+        TopFieldDocs[] hits = new TopFieldDocs[indexes.size()];
+        for (int i = 0; i < indexes.size(); i++) {
+          searchers[i] = indexes.get(i).acquire();
+          hits[i] = searchers[i].search(query, realLimit, sort);
+        }
+        TopDocs docs = TopDocs.merge(sort, realLimit, hits);
+
+        List<Document> result = new ArrayList<>(docs.scoreDocs.length);
+        for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
+          ScoreDoc sd = docs.scoreDocs[i];
+          result.add(searchers[sd.shardIndex].doc(sd.doc, fields));
+        }
+        return result;
+      } finally {
+        for (int i = 0; i < indexes.size(); i++) {
+          if (searchers[i] != null) {
+            try {
+              indexes.get(i).release(searchers[i]);
+            } catch (IOException e) {
+              log.warn("cannot release Lucene searcher", e);
+            }
+          }
+        }
+      }
+    }
+  }
+
+  private class ChangeDataResults implements ResultSet<ChangeData> {
+    private final Future<List<Document>> future;
+    private final Set<String> fields;
+
+    ChangeDataResults(Future<List<Document>> future, Set<String> fields) {
+      this.future = future;
+      this.fields = fields;
+    }
+
+    @Override
+    public Iterator<ChangeData> iterator() {
+      return toList().iterator();
+    }
+
+    @Override
+    public List<ChangeData> toList() {
+      try {
+        List<Document> docs = future.get();
+        List<ChangeData> result = new ArrayList<>(docs.size());
+        String idFieldName = LEGACY_ID.getName();
+        for (Document doc : docs) {
+          result.add(toChangeData(fields(doc, fields), fields, idFieldName));
+        }
+        return result;
+      } catch (InterruptedException e) {
+        close();
+        throw new OrmRuntimeException(e);
+      } catch (ExecutionException e) {
+        Throwables.throwIfUnchecked(e.getCause());
+        throw new OrmRuntimeException(e.getCause());
+      }
+    }
+
+    @Override
+    public void close() {
+      future.cancel(false /* do not interrupt Lucene */);
+    }
+  }
+
+  private static ListMultimap<String, IndexableField> fields(Document doc, Set<String> fields) {
+    ListMultimap<String, IndexableField> stored =
+        MultimapBuilder.hashKeys(fields.size()).arrayListValues(4).build();
+    for (IndexableField f : doc) {
+      String name = f.name();
+      if (fields.contains(name)) {
+        stored.put(name, f);
+      }
+    }
+    return stored;
+  }
+
+  private ChangeData toChangeData(
+      ListMultimap<String, IndexableField> doc, Set<String> fields, String idFieldName) {
+    ChangeData cd;
+    // Either change or the ID field was guaranteed to be included in the call
+    // to fields() above.
+    IndexableField cb = Iterables.getFirst(doc.get(CHANGE_FIELD), null);
+    if (cb != null) {
+      BytesRef proto = cb.binaryValue();
+      cd =
+          changeDataFactory.create(
+              db.get(), CHANGE_CODEC.decode(proto.bytes, proto.offset, proto.length));
+    } else {
+      IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
+      Change.Id id = new Change.Id(f.numericValue().intValue());
+      // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
+      IndexableField project = doc.get(PROJECT.getName()).iterator().next();
+      cd = changeDataFactory.create(db.get(), new Project.NameKey(project.stringValue()), id);
+    }
+
+    // Any decoding that is done here must also be done in {@link ElasticChangeIndex}.
+
+    if (fields.contains(PATCH_SET_FIELD)) {
+      decodePatchSets(doc, cd);
+    }
+    if (fields.contains(APPROVAL_FIELD)) {
+      decodeApprovals(doc, cd);
+    }
+    if (fields.contains(ADDED_FIELD) && fields.contains(DELETED_FIELD)) {
+      decodeChangedLines(doc, cd);
+    }
+    if (fields.contains(MERGEABLE_FIELD)) {
+      decodeMergeable(doc, cd);
+    }
+    if (fields.contains(REVIEWEDBY_FIELD)) {
+      decodeReviewedBy(doc, cd);
+    }
+    if (fields.contains(HASHTAG_FIELD)) {
+      decodeHashtags(doc, cd);
+    }
+    if (fields.contains(STAR_FIELD)) {
+      decodeStar(doc, cd);
+    }
+    if (fields.contains(REVIEWER_FIELD)) {
+      decodeReviewers(doc, cd);
+    }
+    if (fields.contains(REVIEWER_BY_EMAIL_FIELD)) {
+      decodeReviewersByEmail(doc, cd);
+    }
+    if (fields.contains(PENDING_REVIEWER_FIELD)) {
+      decodePendingReviewers(doc, cd);
+    }
+    if (fields.contains(PENDING_REVIEWER_BY_EMAIL_FIELD)) {
+      decodePendingReviewersByEmail(doc, cd);
+    }
+    decodeSubmitRecords(
+        doc, SUBMIT_RECORD_STRICT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
+    decodeSubmitRecords(
+        doc, SUBMIT_RECORD_LENIENT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_LENIENT, cd);
+    if (fields.contains(REF_STATE_FIELD)) {
+      decodeRefStates(doc, cd);
+    }
+    if (fields.contains(REF_STATE_PATTERN_FIELD)) {
+      decodeRefStatePatterns(doc, cd);
+    }
+
+    decodeUnresolvedCommentCount(doc, cd);
+    return cd;
+  }
+
+  private void decodePatchSets(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    List<PatchSet> patchSets = decodeProtos(doc, PATCH_SET_FIELD, PATCH_SET_CODEC);
+    if (!patchSets.isEmpty()) {
+      // Will be an empty list for schemas prior to when this field was stored;
+      // this cannot be valid since a change needs at least one patch set.
+      cd.setPatchSets(patchSets);
+    }
+  }
+
+  private void decodeApprovals(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setCurrentApprovals(decodeProtos(doc, APPROVAL_FIELD, APPROVAL_CODEC));
+  }
+
+  private void decodeChangedLines(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    IndexableField added = Iterables.getFirst(doc.get(ADDED_FIELD), null);
+    IndexableField deleted = Iterables.getFirst(doc.get(DELETED_FIELD), null);
+    if (added != null && deleted != null) {
+      cd.setChangedLines(added.numericValue().intValue(), deleted.numericValue().intValue());
+    } else {
+      // No ChangedLines stored, likely due to failure during reindexing, for
+      // example due to LargeObjectException. But we know the field was
+      // requested, so update ChangeData to prevent callers from trying to
+      // lazily load it, as that would probably also fail.
+      cd.setNoChangedLines();
+    }
+  }
+
+  private void decodeMergeable(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    IndexableField f = Iterables.getFirst(doc.get(MERGEABLE_FIELD), null);
+    if (f != null) {
+      String mergeable = f.stringValue();
+      if ("1".equals(mergeable)) {
+        cd.setMergeable(true);
+      } else if ("0".equals(mergeable)) {
+        cd.setMergeable(false);
+      }
+    }
+  }
+
+  private void decodeReviewedBy(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    Collection<IndexableField> reviewedBy = doc.get(REVIEWEDBY_FIELD);
+    if (reviewedBy.size() > 0) {
+      Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
+      for (IndexableField r : reviewedBy) {
+        int id = r.numericValue().intValue();
+        if (reviewedBy.size() == 1 && id == ChangeField.NOT_REVIEWED) {
+          break;
+        }
+        accounts.add(new Account.Id(id));
+      }
+      cd.setReviewedBy(accounts);
+    }
+  }
+
+  private void decodeHashtags(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    Collection<IndexableField> hashtag = doc.get(HASHTAG_FIELD);
+    Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtag.size());
+    for (IndexableField r : hashtag) {
+      hashtags.add(r.binaryValue().utf8ToString());
+    }
+    cd.setHashtags(hashtags);
+  }
+
+  private void decodeStar(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    Collection<IndexableField> star = doc.get(STAR_FIELD);
+    ListMultimap<Account.Id, String> stars = MultimapBuilder.hashKeys().arrayListValues().build();
+    for (IndexableField r : star) {
+      StarredChangesUtil.StarField starField = StarredChangesUtil.StarField.parse(r.stringValue());
+      if (starField != null) {
+        stars.put(starField.accountId(), starField.label());
+      }
+    }
+    cd.setStars(stars);
+  }
+
+  private void decodeReviewers(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setReviewers(
+        ChangeField.parseReviewerFieldValues(
+            FluentIterable.from(doc.get(REVIEWER_FIELD)).transform(IndexableField::stringValue)));
+  }
+
+  private void decodeReviewersByEmail(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setReviewersByEmail(
+        ChangeField.parseReviewerByEmailFieldValues(
+            FluentIterable.from(doc.get(REVIEWER_BY_EMAIL_FIELD))
+                .transform(IndexableField::stringValue)));
+  }
+
+  private void decodePendingReviewers(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setPendingReviewers(
+        ChangeField.parseReviewerFieldValues(
+            FluentIterable.from(doc.get(PENDING_REVIEWER_FIELD))
+                .transform(IndexableField::stringValue)));
+  }
+
+  private void decodePendingReviewersByEmail(
+      ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setPendingReviewersByEmail(
+        ChangeField.parseReviewerByEmailFieldValues(
+            FluentIterable.from(doc.get(PENDING_REVIEWER_BY_EMAIL_FIELD))
+                .transform(IndexableField::stringValue)));
+  }
+
+  private void decodeSubmitRecords(
+      ListMultimap<String, IndexableField> doc,
+      String field,
+      SubmitRuleOptions opts,
+      ChangeData cd) {
+    ChangeField.parseSubmitRecords(
+        Collections2.transform(doc.get(field), f -> f.binaryValue().utf8ToString()), opts, cd);
+  }
+
+  private void decodeRefStates(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setRefStates(copyAsBytes(doc.get(REF_STATE_FIELD)));
+  }
+
+  private void decodeRefStatePatterns(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setRefStatePatterns(copyAsBytes(doc.get(REF_STATE_PATTERN_FIELD)));
+  }
+
+  private void decodeUnresolvedCommentCount(
+      ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    IndexableField f = Iterables.getFirst(doc.get(UNRESOLVED_COMMENT_COUNT_FIELD), null);
+    if (f != null && f.numericValue() != null) {
+      cd.setUnresolvedCommentCount(f.numericValue().intValue());
+    }
+  }
+
+  private static <T> List<T> decodeProtos(
+      ListMultimap<String, IndexableField> doc, String fieldName, ProtobufCodec<T> codec) {
+    Collection<IndexableField> fields = doc.get(fieldName);
+    if (fields.isEmpty()) {
+      return Collections.emptyList();
+    }
+
+    List<T> result = new ArrayList<>(fields.size());
+    for (IndexableField f : fields) {
+      BytesRef r = f.binaryValue();
+      result.add(codec.decode(r.bytes, r.offset, r.length));
+    }
+    return result;
+  }
+
+  private static List<byte[]> copyAsBytes(Collection<IndexableField> fields) {
+    return fields
+        .stream()
+        .map(
+            f -> {
+              BytesRef ref = f.binaryValue();
+              byte[] b = new byte[ref.length];
+              System.arraycopy(ref.bytes, ref.offset, b, 0, ref.length);
+              return b;
+            })
+        .collect(toList());
+  }
+}
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
new file mode 100644
index 0000000..7878afe
--- /dev/null
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -0,0 +1,130 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.lucene;
+
+import static com.google.gerrit.server.index.group.GroupField.UUID;
+
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.group.GroupIndex;
+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.concurrent.ExecutionException;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.SearcherFactory;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+import org.apache.lucene.store.RAMDirectory;
+import org.eclipse.jgit.lib.Config;
+
+public class LuceneGroupIndex extends AbstractLuceneIndex<AccountGroup.UUID, InternalGroup>
+    implements GroupIndex {
+
+  private static final String GROUPS = "groups";
+
+  private static final String UUID_SORT_FIELD = sortFieldName(UUID);
+
+  private static Term idTerm(InternalGroup group) {
+    return idTerm(group.getGroupUUID());
+  }
+
+  private static Term idTerm(AccountGroup.UUID uuid) {
+    return QueryBuilder.stringTerm(UUID.getName(), uuid.get());
+  }
+
+  private final GerritIndexWriterConfig indexWriterConfig;
+  private final QueryBuilder<InternalGroup> queryBuilder;
+  private final Provider<GroupCache> groupCache;
+
+  private static Directory dir(Schema<?> schema, Config cfg, SitePaths sitePaths)
+      throws IOException {
+    if (LuceneIndexModule.isInMemoryTest(cfg)) {
+      return new RAMDirectory();
+    }
+    Path indexDir = LuceneVersionManager.getDir(sitePaths, GROUPS, schema);
+    return FSDirectory.open(indexDir);
+  }
+
+  @Inject
+  LuceneGroupIndex(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      Provider<GroupCache> groupCache,
+      @Assisted Schema<InternalGroup> schema)
+      throws IOException {
+    super(
+        schema,
+        sitePaths,
+        dir(schema, cfg, sitePaths),
+        GROUPS,
+        null,
+        new GerritIndexWriterConfig(cfg, GROUPS),
+        new SearcherFactory());
+    this.groupCache = groupCache;
+
+    indexWriterConfig = new GerritIndexWriterConfig(cfg, GROUPS);
+    queryBuilder = new QueryBuilder<>(schema, indexWriterConfig.getAnalyzer());
+  }
+
+  @Override
+  public void replace(InternalGroup group) throws IOException {
+    try {
+      replace(idTerm(group), toDocument(group)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public void delete(AccountGroup.UUID key) throws IOException {
+    try {
+      delete(idTerm(key)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public DataSource<InternalGroup> getSource(Predicate<InternalGroup> p, QueryOptions opts)
+      throws QueryParseException {
+    return new LuceneQuerySource(
+        opts.filterFields(IndexUtils::groupFields),
+        queryBuilder.toQuery(p),
+        new Sort(new SortField(UUID_SORT_FIELD, SortField.Type.STRING, false)));
+  }
+
+  @Override
+  protected InternalGroup fromDocument(Document doc) {
+    AccountGroup.UUID uuid = new AccountGroup.UUID(doc.getField(UUID.getName()).stringValue());
+    // Use the GroupCache rather than depending on any stored fields in the
+    // document (of which there shouldn't be any).
+    return groupCache.get().get(uuid).orElse(null);
+  }
+}
diff --git a/java/com/google/gerrit/lucene/LuceneIndexModule.java b/java/com/google/gerrit/lucene/LuceneIndexModule.java
new file mode 100644
index 0000000..d5d6360
--- /dev/null
+++ b/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -0,0 +1,119 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.lucene;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.IndexModule;
+import com.google.gerrit.server.index.OnlineUpgrader;
+import com.google.gerrit.server.index.SingleVersionModule;
+import com.google.gerrit.server.index.VersionManager;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.project.ProjectIndex;
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+import java.util.Map;
+import org.apache.lucene.search.BooleanQuery;
+import org.eclipse.jgit.lib.Config;
+
+public class LuceneIndexModule extends AbstractModule {
+  public static LuceneIndexModule singleVersionAllLatest(int threads) {
+    return new LuceneIndexModule(ImmutableMap.<String, Integer>of(), threads, false);
+  }
+
+  public static LuceneIndexModule singleVersionWithExplicitVersions(
+      Map<String, Integer> versions, int threads) {
+    return new LuceneIndexModule(versions, threads, false);
+  }
+
+  public static LuceneIndexModule latestVersionWithOnlineUpgrade() {
+    return new LuceneIndexModule(null, 0, true);
+  }
+
+  public static LuceneIndexModule latestVersionWithoutOnlineUpgrade() {
+    return new LuceneIndexModule(null, 0, false);
+  }
+
+  static boolean isInMemoryTest(Config cfg) {
+    return cfg.getBoolean("index", "lucene", "testInmemory", false);
+  }
+
+  private final Map<String, Integer> singleVersions;
+  private final int threads;
+  private final boolean onlineUpgrade;
+
+  private LuceneIndexModule(
+      Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade) {
+    if (singleVersions != null) {
+      checkArgument(!onlineUpgrade, "online upgrade is incompatible with single version map");
+    }
+    this.singleVersions = singleVersions;
+    this.threads = threads;
+    this.onlineUpgrade = onlineUpgrade;
+  }
+
+  @Override
+  protected void configure() {
+    install(
+        new FactoryModuleBuilder()
+            .implement(AccountIndex.class, LuceneAccountIndex.class)
+            .build(AccountIndex.Factory.class));
+    install(
+        new FactoryModuleBuilder()
+            .implement(ChangeIndex.class, LuceneChangeIndex.class)
+            .build(ChangeIndex.Factory.class));
+    install(
+        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());
+    } else {
+      install(new SingleVersionModule(singleVersions));
+    }
+  }
+
+  @Provides
+  @Singleton
+  IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
+    BooleanQuery.setMaxClauseCount(
+        cfg.getInt("index", "maxTerms", BooleanQuery.getMaxClauseCount()));
+    return IndexConfig.fromConfig(cfg).separateChangeSubIndexes(true).build();
+  }
+
+  private class MultiVersionModule extends LifecycleModule {
+    @Override
+    public void configure() {
+      bind(VersionManager.class).to(LuceneVersionManager.class);
+      listener().to(LuceneVersionManager.class);
+      if (onlineUpgrade) {
+        listener().to(OnlineUpgrader.class);
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
new file mode 100644
index 0000000..e776a8b
--- /dev/null
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.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.concurrent.ExecutionException;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.SearcherFactory;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+import org.apache.lucene.store.RAMDirectory;
+import org.eclipse.jgit.lib.Config;
+
+public class LuceneProjectIndex extends AbstractLuceneIndex<Project.NameKey, ProjectData>
+    implements ProjectIndex {
+  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 LuceneQuerySource(
+        opts.filterFields(IndexUtils::projectFields),
+        queryBuilder.toQuery(p),
+        new Sort(new SortField(NAME_SORT_FIELD, SortField.Type.STRING, false)));
+  }
+
+  @Override
+  protected ProjectData fromDocument(Document doc) {
+    Project.NameKey nameKey = new Project.NameKey(doc.getField(NAME.getName()).stringValue());
+    return projectCache.get().get(nameKey).toProjectData();
+  }
+}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java b/java/com/google/gerrit/lucene/LuceneVersionManager.java
similarity index 100%
rename from gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
rename to java/com/google/gerrit/lucene/LuceneVersionManager.java
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java b/java/com/google/gerrit/lucene/QueryBuilder.java
similarity index 100%
rename from gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java
rename to java/com/google/gerrit/lucene/QueryBuilder.java
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/WrappableSearcherManager.java b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
similarity index 100%
rename from gerrit-lucene/src/main/java/com/google/gerrit/lucene/WrappableSearcherManager.java
rename to java/com/google/gerrit/lucene/WrappableSearcherManager.java
diff --git a/java/com/google/gerrit/metrics/BUILD b/java/com/google/gerrit/metrics/BUILD
new file mode 100644
index 0000000..b32b087
--- /dev/null
+++ b/java/com/google/gerrit/metrics/BUILD
@@ -0,0 +1,15 @@
+java_library(
+    name = "metrics",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/org/eclipse/jgit:server",
+        "//lib:guava",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric.java b/java/com/google/gerrit/metrics/CallbackMetric.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric.java
rename to java/com/google/gerrit/metrics/CallbackMetric.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric0.java b/java/com/google/gerrit/metrics/CallbackMetric0.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric0.java
rename to java/com/google/gerrit/metrics/CallbackMetric0.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric1.java b/java/com/google/gerrit/metrics/CallbackMetric1.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric1.java
rename to java/com/google/gerrit/metrics/CallbackMetric1.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter0.java b/java/com/google/gerrit/metrics/Counter0.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/Counter0.java
rename to java/com/google/gerrit/metrics/Counter0.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter1.java b/java/com/google/gerrit/metrics/Counter1.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/Counter1.java
rename to java/com/google/gerrit/metrics/Counter1.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter2.java b/java/com/google/gerrit/metrics/Counter2.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/Counter2.java
rename to java/com/google/gerrit/metrics/Counter2.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter3.java b/java/com/google/gerrit/metrics/Counter3.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/Counter3.java
rename to java/com/google/gerrit/metrics/Counter3.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Description.java b/java/com/google/gerrit/metrics/Description.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/Description.java
rename to java/com/google/gerrit/metrics/Description.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/DisabledMetricMaker.java b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/DisabledMetricMaker.java
rename to java/com/google/gerrit/metrics/DisabledMetricMaker.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Field.java b/java/com/google/gerrit/metrics/Field.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/Field.java
rename to java/com/google/gerrit/metrics/Field.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram0.java b/java/com/google/gerrit/metrics/Histogram0.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram0.java
rename to java/com/google/gerrit/metrics/Histogram0.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram1.java b/java/com/google/gerrit/metrics/Histogram1.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram1.java
rename to java/com/google/gerrit/metrics/Histogram1.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram2.java b/java/com/google/gerrit/metrics/Histogram2.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram2.java
rename to java/com/google/gerrit/metrics/Histogram2.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram3.java b/java/com/google/gerrit/metrics/Histogram3.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram3.java
rename to java/com/google/gerrit/metrics/Histogram3.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java b/java/com/google/gerrit/metrics/MetricMaker.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
rename to java/com/google/gerrit/metrics/MetricMaker.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer0.java b/java/com/google/gerrit/metrics/Timer0.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/Timer0.java
rename to java/com/google/gerrit/metrics/Timer0.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer1.java b/java/com/google/gerrit/metrics/Timer1.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/Timer1.java
rename to java/com/google/gerrit/metrics/Timer1.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer2.java b/java/com/google/gerrit/metrics/Timer2.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/Timer2.java
rename to java/com/google/gerrit/metrics/Timer2.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer3.java b/java/com/google/gerrit/metrics/Timer3.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/Timer3.java
rename to java/com/google/gerrit/metrics/Timer3.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/TimerContext.java b/java/com/google/gerrit/metrics/TimerContext.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/TimerContext.java
rename to java/com/google/gerrit/metrics/TimerContext.java
diff --git a/java/com/google/gerrit/metrics/dropwizard/BUILD b/java/com/google/gerrit/metrics/dropwizard/BUILD
new file mode 100644
index 0000000..9adb375
--- /dev/null
+++ b/java/com/google/gerrit/metrics/dropwizard/BUILD
@@ -0,0 +1,15 @@
+java_library(
+    name = "dropwizard",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/server",
+        "//lib:args4j",
+        "//lib:guava",
+        "//lib/dropwizard:dropwizard-core",
+        "//lib/guice",
+    ],
+)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java b/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
rename to java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java b/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java
rename to java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java b/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java
rename to java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedMetric.java b/java/com/google/gerrit/metrics/dropwizard/BucketedMetric.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedMetric.java
rename to java/com/google/gerrit/metrics/dropwizard/BucketedMetric.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java b/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
rename to java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackGroup.java b/java/com/google/gerrit/metrics/dropwizard/CallbackGroup.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackGroup.java
rename to java/com/google/gerrit/metrics/dropwizard/CallbackGroup.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricGlue.java b/java/com/google/gerrit/metrics/dropwizard/CallbackMetricGlue.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricGlue.java
rename to java/com/google/gerrit/metrics/dropwizard/CallbackMetricGlue.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java b/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
rename to java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java b/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
rename to java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java b/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
rename to java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java b/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
rename to java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
rename to java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java b/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
rename to java/com/google/gerrit/metrics/dropwizard/GetMetric.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java b/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
rename to java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java b/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
rename to java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java b/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
rename to java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricJson.java b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
rename to java/com/google/gerrit/metrics/dropwizard/MetricJson.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricResource.java b/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
rename to java/com/google/gerrit/metrics/dropwizard/MetricResource.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java b/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
rename to java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
rename to java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
rename to java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/JGitMetricModule.java b/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
rename to java/com/google/gerrit/metrics/proc/JGitMetricModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/MetricModule.java b/java/com/google/gerrit/metrics/proc/MetricModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/proc/MetricModule.java
rename to java/com/google/gerrit/metrics/proc/MetricModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
rename to java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java b/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
rename to java/com/google/gerrit/metrics/proc/ProcMetricModule.java
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
new file mode 100644
index 0000000..a255020
--- /dev/null
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -0,0 +1,63 @@
+# TODO(davido): This indirection doesn't avoid unwanted depdencies
+# in acceptance-framework and should be removed. Instead, provided_deps
+# should be used, once https://github.com/bazelbuild/bazel/issues/1402
+# is fixed.
+alias(
+    name = "pgm",
+    actual = ":daemon",
+    visibility = ["//visibility:public"],
+)
+
+java_library(
+    name = "daemon",
+    srcs = glob(["**/*.java"]),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/pgm"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/elasticsearch",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/gpg",
+        "//java/com/google/gerrit/httpd",
+        "//java/com/google/gerrit/httpd/auth/oauth",
+        "//java/com/google/gerrit/httpd/auth/openid",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/launcher",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/lucene",
+        "//java/com/google/gerrit/metrics/dropwizard",
+        "//java/com/google/gerrit/pgm/http",
+        "//java/com/google/gerrit/pgm/init",
+        "//java/com/google/gerrit/pgm/init/api",
+        "//java/com/google/gerrit/pgm/util",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server:module",
+        "//java/com/google/gerrit/server/api",
+        "//java/com/google/gerrit/server/cache/h2",
+        "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/sshd",
+        "//java/com/google/gwtexpui/linker:server",
+        "//java/com/google/gwtexpui/server",
+        "//lib:args4j",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:protobuf",
+        "//lib:servlet-api-3_1-without-neverlink",
+        "//lib/auto:auto-value",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//lib/log:jsonevent-layout",
+        "//lib/log:log4j",
+        "//lib/prolog:cafeteria",
+        "//lib/prolog:compiler",
+        "//lib/prolog:runtime",
+    ],
+)
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Cat.java b/java/com/google/gerrit/pgm/Cat.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/Cat.java
rename to java/com/google/gerrit/pgm/Cat.java
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
new file mode 100644
index 0000000..4bc06d0
--- /dev/null
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -0,0 +1,602 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.elasticsearch.ElasticIndexModule;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.gpg.GpgModule;
+import com.google.gerrit.httpd.AllRequestFilter;
+import com.google.gerrit.httpd.GetUserFilter;
+import com.google.gerrit.httpd.GitOverHttpModule;
+import com.google.gerrit.httpd.H2CacheBasedWebSession;
+import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
+import com.google.gerrit.httpd.RequestContextFilter;
+import com.google.gerrit.httpd.RequestMetricsFilter;
+import com.google.gerrit.httpd.RequireSslFilter;
+import com.google.gerrit.httpd.WebModule;
+import com.google.gerrit.httpd.WebSshGlueModule;
+import com.google.gerrit.httpd.auth.oauth.OAuthModule;
+import com.google.gerrit.httpd.auth.openid.OpenIdModule;
+import com.google.gerrit.httpd.plugins.HttpPluginModule;
+import com.google.gerrit.httpd.raw.StaticModule;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.lucene.LuceneIndexModule;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
+import com.google.gerrit.pgm.http.jetty.JettyEnv;
+import com.google.gerrit.pgm.http.jetty.JettyModule;
+import com.google.gerrit.pgm.http.jetty.ProjectQoSFilter;
+import com.google.gerrit.pgm.util.ErrorLogFile;
+import com.google.gerrit.pgm.util.LogFileCompressor;
+import com.google.gerrit.pgm.util.RuntimeShutdown;
+import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.StartupChecks;
+import com.google.gerrit.server.account.AccountDeactivator;
+import com.google.gerrit.server.account.InternalAccountDirectory;
+import com.google.gerrit.server.api.GerritApiModule;
+import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.change.ChangeCleanupRunner;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.config.AuthConfigModule;
+import com.google.gerrit.server.config.CanonicalWebUrlModule;
+import com.google.gerrit.server.config.CanonicalWebUrlProvider;
+import com.google.gerrit.server.config.DownloadConfig;
+import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritOptions;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.events.EventBroker;
+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;
+import com.google.gerrit.server.index.DummyIndexModule;
+import com.google.gerrit.server.index.IndexModule;
+import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.index.VersionManager;
+import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
+import com.google.gerrit.server.mail.receive.MailReceiver;
+import com.google.gerrit.server.mail.send.SmtpEmailSender;
+import com.google.gerrit.server.mime.MimeUtil2Module;
+import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
+import com.google.gerrit.server.notedb.rebuild.OnlineNoteDbMigrator;
+import com.google.gerrit.server.patch.DiffExecutorModule;
+import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
+import com.google.gerrit.server.plugins.PluginModule;
+import com.google.gerrit.server.plugins.PluginRestApiModule;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
+import com.google.gerrit.server.project.DefaultProjectNameLockManager;
+import com.google.gerrit.server.restapi.config.RestCacheAdminModule;
+import com.google.gerrit.server.schema.DataSourceProvider;
+import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
+import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
+import com.google.gerrit.server.schema.SchemaVersionCheck;
+import com.google.gerrit.server.securestore.DefaultSecureStore;
+import com.google.gerrit.server.securestore.SecureStore;
+import com.google.gerrit.server.securestore.SecureStoreClassName;
+import com.google.gerrit.server.securestore.SecureStoreProvider;
+import com.google.gerrit.server.ssh.NoSshKeyCache;
+import com.google.gerrit.server.ssh.NoSshModule;
+import com.google.gerrit.server.ssh.SshAddressesModule;
+import com.google.gerrit.sshd.SshHostKeyModule;
+import com.google.gerrit.sshd.SshKeyCacheImpl;
+import com.google.gerrit.sshd.SshModule;
+import com.google.gerrit.sshd.commands.DefaultCommandModule;
+import com.google.gerrit.sshd.commands.IndexCommandsModule;
+import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.Module;
+import com.google.inject.Provider;
+import com.google.inject.Stage;
+import java.io.IOException;
+import java.lang.Thread.UncaughtExceptionHandler;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import javax.servlet.http.HttpServletRequest;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Run SSH daemon portions of Gerrit. */
+public class Daemon extends SiteProgram {
+  private static final Logger log = LoggerFactory.getLogger(Daemon.class);
+
+  @Option(name = "--enable-httpd", usage = "Enable the internal HTTP daemon")
+  private Boolean httpd;
+
+  @Option(name = "--disable-httpd", usage = "Disable the internal HTTP daemon")
+  void setDisableHttpd(@SuppressWarnings("unused") boolean arg) {
+    httpd = false;
+  }
+
+  @Option(name = "--enable-sshd", usage = "Enable the internal SSH daemon")
+  private boolean sshd = true;
+
+  @Option(name = "--disable-sshd", usage = "Disable the internal SSH daemon")
+  void setDisableSshd(@SuppressWarnings("unused") boolean arg) {
+    sshd = false;
+  }
+
+  @Option(name = "--slave", usage = "Support fetch only")
+  private boolean slave;
+
+  @Option(name = "--console-log", usage = "Log to console (not $site_path/logs)")
+  private boolean consoleLog;
+
+  @Option(name = "-s", usage = "Start interactive shell")
+  private boolean inspector;
+
+  @Option(name = "--run-id", usage = "Cookie to store in $site_path/logs/gerrit.run")
+  private String runId;
+
+  @Option(name = "--headless", usage = "Don't start the UI frontend")
+  private boolean headless;
+
+  @Option(name = "--polygerrit-dev", usage = "Force PolyGerrit UI for development")
+  private boolean polyGerritDev;
+
+  @Option(
+    name = "--init",
+    aliases = {"-i"},
+    usage = "Init site before starting the daemon"
+  )
+  private boolean doInit;
+
+  @Option(name = "--stop-only", usage = "Stop the daemon", hidden = true)
+  private boolean stopOnly;
+
+  @Option(
+    name = "--migrate-to-note-db",
+    usage = "Automatically migrate changes to NoteDb",
+    handler = ExplicitBooleanOptionHandler.class
+  )
+  private boolean migrateToNoteDb;
+
+  @Option(name = "--trial", usage = "(With --migrate-to-note-db) " + MigrateToNoteDb.TRIAL_USAGE)
+  private boolean trial;
+
+  private final LifecycleManager manager = new LifecycleManager();
+  private Injector dbInjector;
+  private Injector cfgInjector;
+  private Config config;
+  private Injector sysInjector;
+  private Injector sshInjector;
+  private Injector webInjector;
+  private Injector httpdInjector;
+  private Path runFile;
+  private boolean inMemoryTest;
+  private AbstractModule luceneModule;
+  private Module emailModule;
+  private Module testSysModule;
+
+  private Runnable serverStarted;
+  private IndexType indexType;
+
+  public Daemon() {}
+
+  @VisibleForTesting
+  public Daemon(Runnable serverStarted, Path sitePath) {
+    super(sitePath);
+    this.serverStarted = serverStarted;
+  }
+
+  @VisibleForTesting
+  public void setEnableSshd(boolean enable) {
+    sshd = enable;
+  }
+
+  @VisibleForTesting
+  public boolean getEnableSshd() {
+    return sshd;
+  }
+
+  public void setEnableHttpd(boolean enable) {
+    httpd = enable;
+  }
+
+  @Override
+  public int run() throws Exception {
+    if (stopOnly) {
+      RuntimeShutdown.manualShutdown();
+      return 0;
+    }
+    if (doInit) {
+      try {
+        new Init(getSitePath()).run();
+      } catch (Exception e) {
+        throw die("Init failed", e);
+      }
+    }
+    mustHaveValidSite();
+    Thread.setDefaultUncaughtExceptionHandler(
+        new UncaughtExceptionHandler() {
+          @Override
+          public void uncaughtException(Thread t, Throwable e) {
+            log.error("Thread " + t.getName() + " threw exception", e);
+          }
+        });
+
+    if (runId != null) {
+      runFile = getSitePath().resolve("logs").resolve("gerrit.run");
+    }
+
+    if (httpd == null) {
+      httpd = !slave;
+    }
+
+    if (!httpd && !sshd) {
+      throw die("No services enabled, nothing to do");
+    }
+
+    try {
+      start();
+      RuntimeShutdown.add(
+          () -> {
+            log.info("caught shutdown, cleaning up");
+            stop();
+          });
+
+      log.info("Gerrit Code Review " + myVersion() + " ready");
+      if (runId != null) {
+        try {
+          Files.write(runFile, (runId + "\n").getBytes(UTF_8));
+          runFile.toFile().setReadable(true, false);
+        } catch (IOException err) {
+          log.warn("Cannot write --run-id to " + runFile, err);
+        }
+      }
+
+      if (serverStarted != null) {
+        serverStarted.run();
+      }
+
+      if (inspector) {
+        JythonShell shell = new JythonShell();
+        shell.set("m", manager);
+        shell.set("ds", dbInjector.getInstance(DataSourceProvider.class));
+        shell.set("schk", dbInjector.getInstance(SchemaVersionCheck.class));
+        shell.set("d", this);
+        shell.run();
+      } else {
+        RuntimeShutdown.waitFor();
+      }
+      return 0;
+    } catch (Throwable err) {
+      log.error("Unable to start daemon", err);
+      return 1;
+    }
+  }
+
+  @VisibleForTesting
+  public LifecycleManager getLifecycleManager() {
+    return manager;
+  }
+
+  @VisibleForTesting
+  public void setDatabaseForTesting(List<Module> modules) {
+    dbInjector = Guice.createInjector(Stage.PRODUCTION, modules);
+    inMemoryTest = true;
+    headless = true;
+  }
+
+  @VisibleForTesting
+  public void setEmailModuleForTesting(Module module) {
+    emailModule = module;
+  }
+
+  @VisibleForTesting
+  public void setLuceneModule(LuceneIndexModule m) {
+    luceneModule = m;
+    inMemoryTest = true;
+  }
+
+  @VisibleForTesting
+  public void setAdditionalSysModuleForTesting(@Nullable Module m) {
+    testSysModule = m;
+  }
+
+  @VisibleForTesting
+  public void start() throws IOException {
+    if (dbInjector == null) {
+      dbInjector = createDbInjector(true /* enableMetrics */, MULTI_USER);
+    }
+    cfgInjector = createCfgInjector();
+    config = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+    if (!slave) {
+      initIndexType();
+    }
+    sysInjector = createSysInjector();
+    sysInjector.getInstance(PluginGuiceEnvironment.class).setDbCfgInjector(dbInjector, cfgInjector);
+    manager.add(dbInjector, cfgInjector, sysInjector);
+
+    if (!consoleLog) {
+      manager.add(ErrorLogFile.start(getSitePath(), config));
+    }
+
+    sshd &= !sshdOff();
+    if (sshd) {
+      initSshd();
+    }
+
+    if (MoreObjects.firstNonNull(httpd, true)) {
+      initHttpd();
+    }
+
+    manager.start();
+  }
+
+  @VisibleForTesting
+  public void stop() {
+    if (runId != null) {
+      try {
+        Files.delete(runFile);
+      } catch (IOException err) {
+        log.warn("failed to delete " + runFile, err);
+      }
+    }
+    manager.stop();
+  }
+
+  private boolean sshdOff() {
+    return new SshAddressesModule().getListenAddresses(config).isEmpty();
+  }
+
+  private String myVersion() {
+    return com.google.gerrit.common.Version.getVersion();
+  }
+
+  private Injector createCfgInjector() {
+    final List<Module> modules = new ArrayList<>();
+    modules.add(new AuthConfigModule());
+    return dbInjector.createChildInjector(modules);
+  }
+
+  private Injector createSysInjector() {
+    final List<Module> modules = new ArrayList<>();
+    modules.add(SchemaVersionCheck.module());
+    modules.add(new DropWizardMetricMaker.RestModule());
+    modules.add(new LogFileCompressor.Module());
+
+    // Plugin module needs to be inserted *before* the index module.
+    // There is the concept of LifecycleModule, in Gerrit's own extension
+    // to Guice, which has these:
+    //  listener().to(SomeClassImplementingLifecycleListener.class);
+    // and the start() methods of each such listener are executed in the
+    // order they are declared.
+    // Makes sure that PluginLoader.start() is executed before the
+    // LuceneIndexModule.start() so that plugins get loaded and the respective
+    // Guice modules installed so that the on-line reindexing will happen
+    // with the proper classes (e.g. group backends, custom Prolog
+    // predicates) and the associated rules ready to be evaluated.
+    modules.add(new PluginModule());
+
+    // Index module shutdown must happen before work queue shutdown, otherwise
+    // work queue can get stuck waiting on index futures that will never return.
+    modules.add(createIndexModule());
+
+    modules.add(new WorkQueue.Module());
+    modules.add(new StreamEventsApiListener.Module());
+    modules.add(new EventBroker.Module());
+    modules.add(
+        inMemoryTest
+            ? new InMemoryAccountPatchReviewStore.Module()
+            : new JdbcAccountPatchReviewStore.Module(config));
+    modules.add(new ReceiveCommitsExecutorModule());
+    modules.add(new DiffExecutorModule());
+    modules.add(new MimeUtil2Module());
+    modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
+    modules.add(new GerritApiModule());
+    modules.add(new PluginApiModule());
+
+    modules.add(new SearchingChangeCacheImpl.Module(slave));
+    modules.add(new InternalAccountDirectory.Module());
+    modules.add(new DefaultPermissionBackendModule());
+    modules.add(new DefaultCacheFactory.Module());
+    modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
+    if (emailModule != null) {
+      modules.add(emailModule);
+    } else {
+      modules.add(new SmtpEmailSender.Module());
+    }
+    modules.add(new SignedTokenEmailTokenVerifier.Module());
+    modules.add(new PluginRestApiModule());
+    modules.add(new RestCacheAdminModule());
+    modules.add(new GpgModule(config));
+    modules.add(new StartupChecks.Module());
+    if (MoreObjects.firstNonNull(httpd, true)) {
+      modules.add(
+          new CanonicalWebUrlModule() {
+            @Override
+            protected Class<? extends Provider<String>> provider() {
+              return HttpCanonicalWebUrlProvider.class;
+            }
+          });
+    } else {
+      modules.add(
+          new CanonicalWebUrlModule() {
+            @Override
+            protected Class<? extends Provider<String>> provider() {
+              return CanonicalWebUrlProvider.class;
+            }
+          });
+    }
+    if (sshd) {
+      modules.add(SshKeyCacheImpl.module());
+    } else {
+      modules.add(NoSshKeyCache.module());
+    }
+    modules.add(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(GerritOptions.class)
+                .toInstance(new GerritOptions(config, headless, slave, polyGerritDev));
+            if (inMemoryTest) {
+              bind(String.class)
+                  .annotatedWith(SecureStoreClassName.class)
+                  .toInstance(DefaultSecureStore.class.getName());
+              bind(SecureStore.class).toProvider(SecureStoreProvider.class);
+            }
+          }
+        });
+    modules.add(new GarbageCollectionModule());
+    if (!slave) {
+      modules.add(new AccountDeactivator.Module());
+      modules.add(new ChangeCleanupRunner.Module());
+    }
+    modules.addAll(LibModuleLoader.loadModules(cfgInjector));
+    if (migrateToNoteDb()) {
+      modules.add(new OnlineNoteDbMigrator.Module(trial));
+    }
+    if (testSysModule != null) {
+      modules.add(testSysModule);
+    }
+    modules.add(new LocalMergeSuperSetComputation.Module());
+    modules.add(new DefaultProjectNameLockManager.Module());
+    return cfgInjector.createChildInjector(modules);
+  }
+
+  private boolean migrateToNoteDb() {
+    return migrateToNoteDb || NoteDbMigrator.getAutoMigrate(checkNotNull(config));
+  }
+
+  private Module createIndexModule() {
+    if (slave) {
+      return new DummyIndexModule();
+    }
+    if (luceneModule != null) {
+      return luceneModule;
+    }
+    boolean onlineUpgrade =
+        VersionManager.getOnlineUpgrade(config)
+            // Schema upgrade is handled by OnlineNoteDbMigrator in this case.
+            && !migrateToNoteDb();
+    switch (indexType) {
+      case LUCENE:
+        return onlineUpgrade
+            ? LuceneIndexModule.latestVersionWithOnlineUpgrade()
+            : LuceneIndexModule.latestVersionWithoutOnlineUpgrade();
+      case ELASTICSEARCH:
+        return onlineUpgrade
+            ? ElasticIndexModule.latestVersionWithOnlineUpgrade()
+            : ElasticIndexModule.latestVersionWithoutOnlineUpgrade();
+      default:
+        throw new IllegalStateException("unsupported index.type = " + indexType);
+    }
+  }
+
+  private void initIndexType() {
+    indexType = IndexModule.getIndexType(cfgInjector);
+    switch (indexType) {
+      case LUCENE:
+      case ELASTICSEARCH:
+        break;
+      default:
+        throw new IllegalStateException("unsupported index.type = " + indexType);
+    }
+  }
+
+  private void initSshd() {
+    sshInjector = createSshInjector();
+    sysInjector.getInstance(PluginGuiceEnvironment.class).setSshInjector(sshInjector);
+    manager.add(sshInjector);
+  }
+
+  private Injector createSshInjector() {
+    final List<Module> modules = new ArrayList<>();
+    modules.add(sysInjector.getInstance(SshModule.class));
+    if (!inMemoryTest) {
+      modules.add(new SshHostKeyModule());
+    }
+    modules.add(
+        new DefaultCommandModule(
+            slave,
+            sysInjector.getInstance(DownloadConfig.class),
+            sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
+    if (!slave && indexType == IndexType.LUCENE) {
+      modules.add(new IndexCommandsModule());
+    }
+    return sysInjector.createChildInjector(modules);
+  }
+
+  private void initHttpd() {
+    webInjector = createWebInjector();
+
+    sysInjector.getInstance(PluginGuiceEnvironment.class).setHttpInjector(webInjector);
+
+    sysInjector
+        .getInstance(HttpCanonicalWebUrlProvider.class)
+        .setHttpServletRequest(webInjector.getProvider(HttpServletRequest.class));
+
+    httpdInjector = createHttpdInjector();
+    manager.add(webInjector, httpdInjector);
+  }
+
+  private Injector createWebInjector() {
+    final List<Module> modules = new ArrayList<>();
+    if (sshd) {
+      modules.add(new ProjectQoSFilter.Module());
+    }
+    modules.add(RequestContextFilter.module());
+    modules.add(AllRequestFilter.module());
+    modules.add(RequestMetricsFilter.module());
+    modules.add(H2CacheBasedWebSession.module());
+    modules.add(sysInjector.getInstance(GitOverHttpModule.class));
+    modules.add(sysInjector.getInstance(WebModule.class));
+    modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
+    modules.add(new HttpPluginModule());
+    if (sshd) {
+      modules.add(sshInjector.getInstance(WebSshGlueModule.class));
+    } else {
+      modules.add(new NoSshModule());
+    }
+
+    AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
+    if (authConfig.getAuthType() == AuthType.OPENID
+        || authConfig.getAuthType() == AuthType.OPENID_SSO) {
+      modules.add(new OpenIdModule());
+    } else if (authConfig.getAuthType() == AuthType.OAUTH) {
+      modules.add(new OAuthModule());
+    }
+    modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
+
+    // StaticModule contains a "/*" wildcard, place it last.
+    modules.add(sysInjector.getInstance(StaticModule.class));
+
+    return sysInjector.createChildInjector(modules);
+  }
+
+  private Injector createHttpdInjector() {
+    final List<Module> modules = new ArrayList<>();
+    modules.add(new JettyModule(new JettyEnv(webInjector)));
+    return webInjector.createChildInjector(modules);
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java b/java/com/google/gerrit/pgm/Gsql.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
rename to java/com/google/gerrit/pgm/Gsql.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java b/java/com/google/gerrit/pgm/Init.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
rename to java/com/google/gerrit/pgm/Init.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/JythonShell.java b/java/com/google/gerrit/pgm/JythonShell.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/JythonShell.java
rename to java/com/google/gerrit/pgm/JythonShell.java
diff --git a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
new file mode 100644
index 0000000..d00b945
--- /dev/null
+++ b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -0,0 +1,129 @@
+// 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.pgm;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.schema.SchemaVersionCheck;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Locale;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+
+/** Converts the local username for all accounts to lower case */
+public class LocalUsernamesToLowerCase extends SiteProgram {
+  private final LifecycleManager manager = new LifecycleManager();
+  private final TextProgressMonitor monitor = new TextProgressMonitor();
+
+  @Inject private GitRepositoryManager repoManager;
+  @Inject private AllUsersName allUsersName;
+  @Inject private Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
+  @Inject private ExternalIdNotes.FactoryNoReindex externalIdNotesFactory;
+  @Inject private ExternalIds externalIds;
+
+  @Override
+  public int run() throws Exception {
+    Injector dbInjector = createDbInjector(MULTI_USER);
+    manager.add(dbInjector, dbInjector.createChildInjector(SchemaVersionCheck.module()));
+    manager.start();
+    dbInjector
+        .createChildInjector(
+            new FactoryModule() {
+              @Override
+              protected void configure() {
+                bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+                factory(MetaDataUpdate.InternalFactory.class);
+
+                // The LocalUsernamesToLowerCase program needs to access all external IDs only
+                // once to update them. After the update they are not accessed again. Hence the
+                // LocalUsernamesToLowerCase program doesn't benefit from caching external IDs and
+                // the external ID cache can be disabled.
+                install(DisabledExternalIdCache.module());
+              }
+            })
+        .injectMembers(this);
+
+    Collection<ExternalId> todo = externalIds.all();
+    monitor.beginTask("Converting local usernames", todo.size());
+
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(repo);
+      for (ExternalId extId : todo) {
+        convertLocalUserToLowerCase(extIdNotes, extId);
+        monitor.update(1);
+      }
+      try (MetaDataUpdate metaDataUpdate = metaDataUpdateServerFactory.get().create(allUsersName)) {
+        metaDataUpdate.setMessage("Convert local usernames to lower case");
+        extIdNotes.commit(metaDataUpdate);
+      }
+    }
+
+    monitor.endTask();
+
+    int exitCode = reindexAccounts();
+    manager.stop();
+    return exitCode;
+  }
+
+  private void convertLocalUserToLowerCase(ExternalIdNotes extIdNotes, ExternalId extId)
+      throws OrmDuplicateKeyException, IOException {
+    if (extId.isScheme(SCHEME_GERRIT)) {
+      String localUser = extId.key().id();
+      String localUserLowerCase = localUser.toLowerCase(Locale.US);
+      if (!localUser.equals(localUserLowerCase)) {
+        ExternalId extIdLowerCase =
+            ExternalId.create(
+                SCHEME_GERRIT,
+                localUserLowerCase,
+                extId.accountId(),
+                extId.email(),
+                extId.password());
+        extIdNotes.replace(extId, extIdLowerCase);
+      }
+    }
+  }
+
+  private int reindexAccounts() throws Exception {
+    monitor.beginTask("Reindex accounts", ProgressMonitor.UNKNOWN);
+    String[] reindexArgs = {
+      "--site-path", getSitePath().toString(), "--index", AccountSchemaDefinitions.NAME
+    };
+    System.out.println("Migration complete, reindexing accounts with:");
+    System.out.println("  reindex " + String.join(" ", reindexArgs));
+    Reindex reindexPgm = new Reindex();
+    int exitCode = reindexPgm.main(reindexArgs);
+    monitor.endTask();
+    return exitCode;
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Ls.java b/java/com/google/gerrit/pgm/Ls.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/Ls.java
rename to java/com/google/gerrit/pgm/Ls.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java b/java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java
rename to java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java
diff --git a/java/com/google/gerrit/pgm/MigrateToNoteDb.java b/java/com/google/gerrit/pgm/MigrateToNoteDb.java
new file mode 100644
index 0000000..8cd148f
--- /dev/null
+++ b/java/com/google/gerrit/pgm/MigrateToNoteDb.java
@@ -0,0 +1,189 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.pgm.util.BatchProgramModule;
+import com.google.gerrit.pgm.util.RuntimeShutdown;
+import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.pgm.util.ThreadLimiter;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.index.DummyIndexModule;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler;
+
+public class MigrateToNoteDb extends SiteProgram {
+  static final String TRIAL_USAGE =
+      "Trial mode: migrate changes and turn on reading from NoteDb, but leave ReviewDb as the"
+          + " source of truth";
+
+  @Option(name = "--threads", usage = "Number of threads to use for rebuilding NoteDb")
+  private int threads = Runtime.getRuntime().availableProcessors();
+
+  @Option(
+    name = "--project",
+    usage =
+        "Only rebuild these projects, do no other migration; incompatible with --change;"
+            + " recommended for debugging only"
+  )
+  private List<String> projects = new ArrayList<>();
+
+  @Option(
+    name = "--change",
+    usage =
+        "Only rebuild these changes, do no other migration; incompatible with --project;"
+            + " recommended for debugging only"
+  )
+  private List<Integer> changes = new ArrayList<>();
+
+  @Option(
+    name = "--force",
+    usage =
+        "Force rebuilding changes where ReviewDb is still the source of truth, even if they"
+            + " were previously migrated"
+  )
+  private boolean force;
+
+  @Option(name = "--trial", usage = TRIAL_USAGE)
+  private boolean trial;
+
+  @Option(
+    name = "--sequence-gap",
+    usage =
+        "gap in change sequence numbers between last ReviewDb number and first NoteDb number;"
+            + " negative indicates using the value of noteDb.changes.initialSequenceGap (default"
+            + " 1000)"
+  )
+  private int sequenceGap;
+
+  @Option(
+    name = "--reindex",
+    usage = "Reindex all changes after migration; defaults to false in trial mode, true otherwise",
+    handler = ExplicitBooleanOptionHandler.class
+  )
+  private Boolean reindex;
+
+  private Injector dbInjector;
+  private Injector sysInjector;
+  private LifecycleManager dbManager;
+  private LifecycleManager sysManager;
+
+  @Inject private Provider<NoteDbMigrator.Builder> migratorBuilderProvider;
+
+  @Override
+  public int run() throws Exception {
+    RuntimeShutdown.add(this::stop);
+    try {
+      mustHaveValidSite();
+      dbInjector = createDbInjector(MULTI_USER);
+      threads = ThreadLimiter.limitThreads(dbInjector, threads);
+
+      dbManager = new LifecycleManager();
+      dbManager.add(dbInjector);
+      dbManager.start();
+
+      sysInjector = createSysInjector();
+      sysInjector.injectMembers(this);
+      sysManager = new LifecycleManager();
+      sysManager.add(sysInjector);
+      sysManager.start();
+
+      try (NoteDbMigrator migrator =
+          migratorBuilderProvider
+              .get()
+              .setThreads(threads)
+              .setProgressOut(System.err)
+              .setProjects(projects.stream().map(Project.NameKey::new).collect(toList()))
+              .setChanges(changes.stream().map(Change.Id::new).collect(toList()))
+              .setTrialMode(trial)
+              .setForceRebuild(force)
+              .setSequenceGap(sequenceGap)
+              .build()) {
+        if (!projects.isEmpty() || !changes.isEmpty()) {
+          migrator.rebuild();
+        } else {
+          migrator.migrate();
+        }
+      }
+    } finally {
+      stop();
+    }
+
+    boolean reindex = firstNonNull(this.reindex, !trial);
+    if (!reindex) {
+      return 0;
+    }
+    // Reindex all indices, to save the user from having to run yet another program by hand while
+    // their server is offline.
+    List<String> reindexArgs =
+        ImmutableList.of(
+            "--site-path",
+            getSitePath().toString(),
+            "--threads",
+            Integer.toString(threads),
+            "--index",
+            ChangeSchemaDefinitions.NAME);
+    System.out.println("Migration complete, reindexing changes with:");
+    System.out.println("  reindex " + reindexArgs.stream().collect(joining(" ")));
+    Reindex reindexPgm = new Reindex();
+    return reindexPgm.main(reindexArgs.stream().toArray(String[]::new));
+  }
+
+  private Injector createSysInjector() {
+    return dbInjector.createChildInjector(
+        new FactoryModule() {
+          @Override
+          public void configure() {
+            install(dbInjector.getInstance(BatchProgramModule.class));
+            bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+            install(new DummyIndexModule());
+            factory(ChangeResource.Factory.class);
+          }
+        });
+  }
+
+  private void stop() {
+    try {
+      LifecycleManager m = sysManager;
+      sysManager = null;
+      if (m != null) {
+        m.stop();
+      }
+    } finally {
+      LifecycleManager m = dbManager;
+      dbManager = null;
+      if (m != null) {
+        m.stop();
+      }
+    }
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Passwd.java b/java/com/google/gerrit/pgm/Passwd.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/Passwd.java
rename to java/com/google/gerrit/pgm/Passwd.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/PrologShell.java b/java/com/google/gerrit/pgm/PrologShell.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/PrologShell.java
rename to java/com/google/gerrit/pgm/PrologShell.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtoGen.java b/java/com/google/gerrit/pgm/ProtoGen.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtoGen.java
rename to java/com/google/gerrit/pgm/ProtoGen.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java b/java/com/google/gerrit/pgm/ProtobufImport.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
rename to java/com/google/gerrit/pgm/ProtobufImport.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
rename to java/com/google/gerrit/pgm/Reindex.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Rulec.java b/java/com/google/gerrit/pgm/Rulec.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/Rulec.java
rename to java/com/google/gerrit/pgm/Rulec.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/SetPasswd.java b/java/com/google/gerrit/pgm/SetPasswd.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/SetPasswd.java
rename to java/com/google/gerrit/pgm/SetPasswd.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/SwitchSecureStore.java b/java/com/google/gerrit/pgm/SwitchSecureStore.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/SwitchSecureStore.java
rename to java/com/google/gerrit/pgm/SwitchSecureStore.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Version.java b/java/com/google/gerrit/pgm/Version.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/Version.java
rename to java/com/google/gerrit/pgm/Version.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/WarDistribution.java b/java/com/google/gerrit/pgm/WarDistribution.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/WarDistribution.java
rename to java/com/google/gerrit/pgm/WarDistribution.java
diff --git a/java/com/google/gerrit/pgm/http/BUILD b/java/com/google/gerrit/pgm/http/BUILD
new file mode 100644
index 0000000..838c614
--- /dev/null
+++ b/java/com/google/gerrit/pgm/http/BUILD
@@ -0,0 +1,5 @@
+java_library(
+    name = "http",
+    visibility = ["//visibility:public"],
+    exports = ["//java/com/google/gerrit/pgm/http/jetty"],
+)
diff --git a/java/com/google/gerrit/pgm/http/jetty/BUILD b/java/com/google/gerrit/pgm/http/jetty/BUILD
new file mode 100644
index 0000000..86961d6
--- /dev/null
+++ b/java/com/google/gerrit/pgm/http/jetty/BUILD
@@ -0,0 +1,26 @@
+java_library(
+    name = "jetty",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/httpd",
+        "//java/com/google/gerrit/launcher",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/sshd",
+        "//java/com/google/gwtexpui/server",
+        "//lib:guava",
+        "//lib:servlet-api-3_1",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",
+        "//lib/jetty:jmx",
+        "//lib/jetty:server",
+        "//lib/jetty:servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//lib/log:log4j",
+    ],
+)
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java b/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java
rename to java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLog.java b/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
rename to java/com/google/gerrit/pgm/http/jetty/HttpLog.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java b/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
rename to java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyEnv.java b/java/com/google/gerrit/pgm/http/jetty/JettyEnv.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyEnv.java
rename to java/com/google/gerrit/pgm/http/jetty/JettyEnv.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyModule.java b/java/com/google/gerrit/pgm/http/jetty/JettyModule.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyModule.java
rename to java/com/google/gerrit/pgm/http/jetty/JettyModule.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
rename to java/com/google/gerrit/pgm/http/jetty/JettyServer.java
diff --git a/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java b/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
new file mode 100644
index 0000000..260f695
--- /dev/null
+++ b/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
@@ -0,0 +1,245 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.http.jetty;
+
+import static com.google.gerrit.server.config.ConfigUtil.getTimeUnit;
+import static com.google.inject.Scopes.SINGLETON;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
+
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountLimits;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.QueueProvider;
+import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
+import com.google.gerrit.sshd.CommandExecutorQueueProvider;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.servlet.ServletModule;
+import java.io.IOException;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jetty.continuation.Continuation;
+import org.eclipse.jetty.continuation.ContinuationListener;
+import org.eclipse.jetty.continuation.ContinuationSupport;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Use Jetty continuations to defer execution until threads are available.
+ *
+ * <p>We actually schedule a task into the same execution queue as the SSH daemon uses for command
+ * execution, and then park the web request in a continuation until an execution thread is
+ * available. This ensures that the overall JVM process doesn't exceed the configured limit on
+ * concurrent Git requests.
+ *
+ * <p>During Git request execution however we have to use the Jetty service thread, not the thread
+ * from the SSH execution queue. Trying to complete the request on the SSH execution queue caused
+ * Jetty's HTTP parser to crash, so we instead block the SSH execution queue thread and ask Jetty to
+ * resume processing on the web service thread.
+ */
+@Singleton
+public class ProjectQoSFilter implements Filter {
+  private static final String ATT_SPACE = ProjectQoSFilter.class.getName();
+  private static final String TASK = ATT_SPACE + "/TASK";
+  private static final String CANCEL = ATT_SPACE + "/CANCEL";
+
+  private static final String FILTER_RE = "^/(.*)/(git-upload-pack|git-receive-pack)$";
+  private static final Pattern URI_PATTERN = Pattern.compile(FILTER_RE);
+
+  public static class Module extends ServletModule {
+    @Override
+    protected void configureServlets() {
+      bind(QueueProvider.class).to(CommandExecutorQueueProvider.class).in(SINGLETON);
+      filterRegex(FILTER_RE).through(ProjectQoSFilter.class);
+    }
+  }
+
+  private final AccountLimits.Factory limitsFactory;
+  private final Provider<CurrentUser> user;
+  private final QueueProvider queue;
+  private final ServletContext context;
+  private final long maxWait;
+
+  @Inject
+  ProjectQoSFilter(
+      AccountLimits.Factory limitsFactory,
+      Provider<CurrentUser> user,
+      QueueProvider queue,
+      ServletContext context,
+      @GerritServerConfig Config cfg) {
+    this.limitsFactory = limitsFactory;
+    this.user = user;
+    this.queue = queue;
+    this.context = context;
+    this.maxWait = MINUTES.toMillis(getTimeUnit(cfg, "httpd", null, "maxwait", 5, MINUTES));
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
+    final HttpServletRequest req = (HttpServletRequest) request;
+    final HttpServletResponse rsp = (HttpServletResponse) response;
+    final Continuation cont = ContinuationSupport.getContinuation(req);
+
+    if (cont.isInitial()) {
+      TaskThunk task = new TaskThunk(cont, req);
+      if (maxWait > 0) {
+        cont.setTimeout(maxWait);
+      }
+      cont.suspend(rsp);
+      cont.setAttribute(TASK, task);
+
+      Future<?> f = getExecutor().submit(task);
+      cont.addContinuationListener(new Listener(f));
+    } else if (cont.isExpired()) {
+      rsp.sendError(SC_SERVICE_UNAVAILABLE);
+
+    } else if (cont.isResumed() && cont.getAttribute(CANCEL) == Boolean.TRUE) {
+      rsp.sendError(SC_SERVICE_UNAVAILABLE);
+
+    } else if (cont.isResumed()) {
+      TaskThunk task = (TaskThunk) cont.getAttribute(TASK);
+      try {
+        task.begin(Thread.currentThread());
+        chain.doFilter(req, rsp);
+      } finally {
+        task.end();
+        Thread.interrupted();
+      }
+
+    } else {
+      context.log("Unexpected QoS continuation state, aborting request");
+      rsp.sendError(SC_SERVICE_UNAVAILABLE);
+    }
+  }
+
+  private ScheduledThreadPoolExecutor getExecutor() {
+    QueueProvider.QueueType qt = limitsFactory.create(user.get()).getQueueType();
+    return queue.getQueue(qt);
+  }
+
+  @Override
+  public void init(FilterConfig config) {}
+
+  @Override
+  public void destroy() {}
+
+  private static final class Listener implements ContinuationListener {
+    final Future<?> future;
+
+    Listener(Future<?> future) {
+      this.future = future;
+    }
+
+    @Override
+    public void onComplete(Continuation self) {}
+
+    @Override
+    public void onTimeout(Continuation self) {
+      future.cancel(true);
+    }
+  }
+
+  private final class TaskThunk implements CancelableRunnable {
+    private final Continuation cont;
+    private final String name;
+    private final Object lock = new Object();
+    private boolean done;
+    private Thread worker;
+
+    TaskThunk(Continuation cont, HttpServletRequest req) {
+      this.cont = cont;
+      this.name = generateName(req);
+    }
+
+    @Override
+    public void run() {
+      cont.resume();
+
+      synchronized (lock) {
+        while (!done) {
+          try {
+            lock.wait();
+          } catch (InterruptedException e) {
+            if (worker != null) {
+              worker.interrupt();
+            } else {
+              break;
+            }
+          }
+        }
+      }
+    }
+
+    void begin(Thread thread) {
+      synchronized (lock) {
+        worker = thread;
+      }
+    }
+
+    void end() {
+      synchronized (lock) {
+        worker = null;
+        done = true;
+        lock.notifyAll();
+      }
+    }
+
+    @Override
+    public void cancel() {
+      cont.setAttribute(CANCEL, Boolean.TRUE);
+      cont.resume();
+    }
+
+    @Override
+    public String toString() {
+      return name;
+    }
+
+    private String generateName(HttpServletRequest req) {
+      String userName = "";
+
+      CurrentUser who = user.get();
+      if (who.isIdentifiedUser()) {
+        String name = who.asIdentifiedUser().getUserName();
+        if (name != null && !name.isEmpty()) {
+          userName = " (" + name + ")";
+        }
+      }
+
+      String uri = req.getServletPath();
+      Matcher m = URI_PATTERN.matcher(uri);
+      if (m.matches()) {
+        String path = m.group(1);
+        String cmd = m.group(2);
+        return cmd + " " + path + userName;
+      }
+
+      return req.getMethod() + " " + uri + userName;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
new file mode 100644
index 0000000..0a94b42
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -0,0 +1,138 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.InternalAccountUpdate;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.util.FS;
+
+public class AccountsOnInit {
+  private final InitFlags flags;
+  private final SitePaths site;
+  private final String allUsers;
+
+  @Inject
+  public AccountsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
+    this.flags = flags;
+    this.site = site;
+    this.allUsers = allUsers.get();
+  }
+
+  public void insert(Account account) throws IOException {
+    File path = getPath();
+    if (path != null) {
+      try (Repository repo = new FileRepository(path);
+          ObjectInserter oi = repo.newObjectInserter()) {
+        PersonIdent ident =
+            new PersonIdent(
+                new GerritPersonIdentProvider(flags.cfg).get(), account.getRegisteredOn());
+
+        Config accountConfig = new Config();
+        AccountConfig.writeToAccountConfig(
+            InternalAccountUpdate.builder()
+                .setActive(account.isActive())
+                .setFullName(account.getFullName())
+                .setPreferredEmail(account.getPreferredEmail())
+                .setStatus(account.getStatus())
+                .build(),
+            accountConfig);
+
+        DirCache newTree = DirCache.newInCore();
+        DirCacheEditor editor = newTree.editor();
+        final ObjectId blobId =
+            oi.insert(Constants.OBJ_BLOB, accountConfig.toText().getBytes(UTF_8));
+        editor.add(
+            new PathEdit(AccountConfig.ACCOUNT_CONFIG) {
+              @Override
+              public void apply(DirCacheEntry ent) {
+                ent.setFileMode(FileMode.REGULAR_FILE);
+                ent.setObjectId(blobId);
+              }
+            });
+        editor.finish();
+
+        ObjectId treeId = newTree.writeTree(oi);
+
+        CommitBuilder cb = new CommitBuilder();
+        cb.setTreeId(treeId);
+        cb.setCommitter(ident);
+        cb.setAuthor(ident);
+        cb.setMessage("Create Account");
+        ObjectId id = oi.insert(cb);
+        oi.flush();
+
+        String refName = RefNames.refsUsers(account.getId());
+        RefUpdate ru = repo.updateRef(refName);
+        ru.setExpectedOldObjectId(ObjectId.zeroId());
+        ru.setNewObjectId(id);
+        ru.setRefLogIdent(ident);
+        ru.setRefLogMessage("Create Account", false);
+        Result result = ru.update();
+        if (result != Result.NEW) {
+          throw new IOException(
+              String.format("Failed to update ref %s: %s", refName, result.name()));
+        }
+        account.setMetaId(id.name());
+      }
+    }
+  }
+
+  public boolean hasAnyAccount() throws IOException {
+    File path = getPath();
+    if (path == null) {
+      return false;
+    }
+
+    try (Repository repo = new FileRepository(path)) {
+      return Accounts.hasAnyAccount(repo);
+    }
+  }
+
+  private File getPath() {
+    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
+    checkArgument(basePath != null, "gerrit.basePath must be configured");
+    return FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
+  }
+}
diff --git a/java/com/google/gerrit/pgm/init/BUILD b/java/com/google/gerrit/pgm/init/BUILD
new file mode 100644
index 0000000..4b53b67
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/BUILD
@@ -0,0 +1,31 @@
+java_library(
+    name = "init",
+    srcs = glob(["**/*.java"]),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/pgm/init"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/elasticsearch",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/launcher",
+        "//java/com/google/gerrit/lucene",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/pgm/init/api",
+        "//java/com/google/gerrit/pgm/util",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/schema",
+        "//lib:guava",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtorm",
+        "//lib:h2",
+        "//lib/commons:validator",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
rename to java/com/google/gerrit/pgm/init/BaseInit.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Browser.java b/java/com/google/gerrit/pgm/init/Browser.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Browser.java
rename to java/com/google/gerrit/pgm/init/Browser.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DB2Initializer.java b/java/com/google/gerrit/pgm/init/DB2Initializer.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DB2Initializer.java
rename to java/com/google/gerrit/pgm/init/DB2Initializer.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java b/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java
rename to java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java b/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
rename to java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DerbyInitializer.java b/java/com/google/gerrit/pgm/init/DerbyInitializer.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DerbyInitializer.java
rename to java/com/google/gerrit/pgm/init/DerbyInitializer.java
diff --git a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
new file mode 100644
index 0000000..d2a9b04
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.util.FS;
+
+public class ExternalIdsOnInit {
+  private final InitFlags flags;
+  private final SitePaths site;
+  private final String allUsers;
+
+  @Inject
+  public ExternalIdsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
+    this.flags = flags;
+    this.site = site;
+    this.allUsers = allUsers.get();
+  }
+
+  public synchronized void insert(String commitMessage, Collection<ExternalId> extIds)
+      throws OrmException, IOException, ConfigInvalidException {
+    File path = getPath();
+    if (path != null) {
+      try (Repository allUsersRepo = new FileRepository(path)) {
+        ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsersRepo);
+        extIdNotes.insert(extIds);
+        try (MetaDataUpdate metaDataUpdate =
+            new MetaDataUpdate(
+                GitReferenceUpdated.DISABLED, new Project.NameKey(allUsers), allUsersRepo)) {
+          PersonIdent serverIdent = new GerritPersonIdentProvider(flags.cfg).get();
+          metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
+          metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
+          metaDataUpdate.getCommitBuilder().setMessage(commitMessage);
+          extIdNotes.commit(metaDataUpdate);
+        }
+      }
+    }
+  }
+
+  private File getPath() {
+    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
+    if (basePath == null) {
+      throw new IllegalStateException("gerrit.basePath must be configured");
+    }
+    return FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
+  }
+}
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
new file mode 100644
index 0000000..3385244
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -0,0 +1,280 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupName;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.GroupNameNotes;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache;
+import org.eclipse.jgit.util.FS;
+
+/**
+ * A database accessor for calls related to groups.
+ *
+ * <p>All calls which read or write group related details to the database <strong>during
+ * init</strong> (either ReviewDb or NoteDb) are gathered here. For non-init cases, use {@code
+ * Groups} or {@code GroupsUpdate} instead.
+ *
+ * <p>All methods of this class refer to <em>internal</em> groups.
+ */
+public class GroupsOnInit {
+
+  private final InitFlags flags;
+  private final SitePaths site;
+  private final String allUsers;
+  private final GroupsMigration groupsMigration;
+
+  @Inject
+  public GroupsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
+    this.flags = flags;
+    this.site = site;
+    this.allUsers = allUsers.get();
+    this.groupsMigration = new GroupsMigration(flags.cfg);
+  }
+
+  /**
+   * Returns the {@code AccountGroup} for the specified {@code GroupReference}.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupReference the {@code GroupReference} of the group
+   * @return the {@code InternalGroup} represented by the {@code GroupReference}
+   * @throws OrmException if the group couldn't be retrieved from ReviewDb
+   * @throws IOException if an error occurs while reading from NoteDb
+   * @throws ConfigInvalidException if the data in NoteDb is in an incorrect format
+   * @throws NoSuchGroupException if a group with such a name doesn't exist
+   */
+  public InternalGroup getExistingGroup(ReviewDb db, GroupReference groupReference)
+      throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      return getExistingGroupFromNoteDb(groupReference);
+    }
+
+    return getExistingGroupFromReviewDb(db, groupReference);
+  }
+
+  private InternalGroup getExistingGroupFromNoteDb(GroupReference groupReference)
+      throws IOException, ConfigInvalidException, NoSuchGroupException {
+    File allUsersRepoPath = getPathToAllUsersRepository();
+    if (allUsersRepoPath != null) {
+      try (Repository allUsersRepo = new FileRepository(allUsersRepoPath)) {
+        AccountGroup.UUID groupUuid = groupReference.getUUID();
+        GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepo, groupUuid);
+        return groupConfig
+            .getLoadedGroup()
+            .orElseThrow(() -> new NoSuchGroupException(groupReference.getUUID()));
+      }
+    }
+    throw new NoSuchGroupException(groupReference.getUUID());
+  }
+
+  private static InternalGroup getExistingGroupFromReviewDb(
+      ReviewDb db, GroupReference groupReference) throws OrmException, NoSuchGroupException {
+    String groupName = groupReference.getName();
+    AccountGroupName accountGroupName =
+        db.accountGroupNames().get(new AccountGroup.NameKey(groupName));
+    if (accountGroupName == null) {
+      throw new NoSuchGroupException(groupName);
+    }
+
+    AccountGroup.Id groupId = accountGroupName.getId();
+    AccountGroup group = db.accountGroups().get(groupId);
+    if (group == null) {
+      throw new NoSuchGroupException(groupName);
+    }
+    return Groups.asInternalGroup(db, group);
+  }
+
+  /**
+   * Returns {@code GroupReference}s for all internal groups.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @return a stream of the {@code GroupReference}s of all internal groups
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   * @throws IOException if an error occurs while reading from NoteDb
+   * @throws ConfigInvalidException if the data in NoteDb is in an incorrect format
+   */
+  public Stream<GroupReference> getAllGroupReferences(ReviewDb db)
+      throws OrmException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      File allUsersRepoPath = getPathToAllUsersRepository();
+      if (allUsersRepoPath != null) {
+        try (Repository allUsersRepo = new FileRepository(allUsersRepoPath)) {
+          return GroupNameNotes.loadAllGroupReferences(allUsersRepo).stream();
+        }
+      }
+      return Stream.empty();
+    }
+
+    return Streams.stream(db.accountGroups().all())
+        .map(group -> new GroupReference(group.getGroupUUID(), group.getName()));
+  }
+
+  /**
+   * Adds an account as member to a group. The account is only added as a new member if it isn't
+   * already a member of the group.
+   *
+   * <p><strong>Note</strong>: This method doesn't check whether the account exists! It also doesn't
+   * update the account index!
+   *
+   * @param db the {@code ReviewDb} instance to update
+   * @param groupUuid the UUID of the group
+   * @param account the account to add
+   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
+   * @throws NoSuchGroupException if the specified group doesn't exist
+   */
+  public void addGroupMember(ReviewDb db, AccountGroup.UUID groupUuid, Account account)
+      throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
+    addGroupMemberInReviewDb(db, groupUuid, account.getId());
+    if (!groupsMigration.writeToNoteDb()) {
+      return;
+    }
+    addGroupMemberInNoteDb(groupUuid, account);
+  }
+
+  private static void addGroupMemberInReviewDb(
+      ReviewDb db, AccountGroup.UUID groupUuid, Account.Id accountId)
+      throws OrmException, NoSuchGroupException {
+    AccountGroup group = getExistingGroup(db, groupUuid);
+    AccountGroup.Id groupId = group.getId();
+
+    if (isMember(db, groupId, accountId)) {
+      return;
+    }
+
+    db.accountGroupMembers()
+        .insert(
+            ImmutableList.of(
+                new AccountGroupMember(new AccountGroupMember.Key(accountId, groupId))));
+  }
+
+  private static AccountGroup getExistingGroup(ReviewDb db, AccountGroup.UUID groupUuid)
+      throws OrmException, NoSuchGroupException {
+    List<AccountGroup> accountGroups = db.accountGroups().byUUID(groupUuid).toList();
+    if (accountGroups.size() == 1) {
+      return Iterables.getOnlyElement(accountGroups);
+    } else if (accountGroups.isEmpty()) {
+      throw new NoSuchGroupException(groupUuid);
+    } else {
+      throw new OrmDuplicateKeyException("Duplicate group UUID " + groupUuid);
+    }
+  }
+
+  private static boolean isMember(ReviewDb db, AccountGroup.Id groupId, Account.Id accountId)
+      throws OrmException {
+    AccountGroupMember.Key key = new AccountGroupMember.Key(accountId, groupId);
+    return db.accountGroupMembers().get(key) != null;
+  }
+
+  private void addGroupMemberInNoteDb(AccountGroup.UUID groupUuid, Account account)
+      throws IOException, ConfigInvalidException, NoSuchGroupException {
+    File allUsersRepoPath = getPathToAllUsersRepository();
+    if (allUsersRepoPath != null) {
+      try (Repository repository = new FileRepository(allUsersRepoPath)) {
+        addGroupMemberInNoteDb(repository, groupUuid, account);
+      }
+    }
+  }
+
+  private void addGroupMemberInNoteDb(
+      Repository repository, AccountGroup.UUID groupUuid, Account account)
+      throws IOException, ConfigInvalidException, NoSuchGroupException {
+    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    InternalGroup group =
+        groupConfig.getLoadedGroup().orElseThrow(() -> new NoSuchGroupException(groupUuid));
+
+    InternalGroupUpdate groupUpdate = getMemberAdditionUpdate(account);
+    groupConfig.setGroupUpdate(
+        groupUpdate, accountId -> getAccountNameEmail(account, accountId), AccountGroup.UUID::get);
+
+    commit(repository, groupConfig, group.getCreatedOn());
+  }
+
+  @Nullable
+  private File getPathToAllUsersRepository() {
+    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
+    checkArgument(basePath != null, "gerrit.basePath must be configured");
+    return RepositoryCache.FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
+  }
+
+  private static InternalGroupUpdate getMemberAdditionUpdate(Account account) {
+    return InternalGroupUpdate.builder()
+        .setMemberModification(members -> Sets.union(members, ImmutableSet.of(account.getId())))
+        .build();
+  }
+
+  private String getAccountNameEmail(Account knownAccount, Account.Id someAccountId) {
+    if (knownAccount.getId().equals(someAccountId)) {
+      String anonymousCowardName = new AnonymousCowardNameProvider(flags.cfg).get();
+      return knownAccount.getNameEmail(anonymousCowardName);
+    }
+    return String.valueOf(someAccountId);
+  }
+
+  private void commit(Repository repository, GroupConfig groupConfig, Timestamp groupCreatedOn)
+      throws IOException {
+    PersonIdent personIdent =
+        new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), groupCreatedOn);
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(repository, personIdent)) {
+      groupConfig.commit(metaDataUpdate);
+    }
+  }
+
+  private MetaDataUpdate createMetaDataUpdate(Repository repository, PersonIdent personIdent) {
+    MetaDataUpdate metaDataUpdate =
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, new Project.NameKey(allUsers), repository);
+    metaDataUpdate.getCommitBuilder().setAuthor(personIdent);
+    metaDataUpdate.getCommitBuilder().setCommitter(personIdent);
+    return metaDataUpdate;
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/H2Initializer.java b/java/com/google/gerrit/pgm/init/H2Initializer.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/H2Initializer.java
rename to java/com/google/gerrit/pgm/init/H2Initializer.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/HANAInitializer.java b/java/com/google/gerrit/pgm/init/HANAInitializer.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/HANAInitializer.java
rename to java/com/google/gerrit/pgm/init/HANAInitializer.java
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
new file mode 100644
index 0000000..e9f5cd5
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -0,0 +1,212 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
+import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.pgm.init.api.InitStep;
+import com.google.gerrit.pgm.init.api.SequencesOnInit;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountSshKey;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Optional;
+import org.apache.commons.validator.routines.EmailValidator;
+
+public class InitAdminUser implements InitStep {
+  private final InitFlags flags;
+  private final ConsoleUI ui;
+  private final AllUsersNameOnInitProvider allUsers;
+  private final AccountsOnInit accounts;
+  private final VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory;
+  private final ExternalIdsOnInit externalIds;
+  private final SequencesOnInit sequencesOnInit;
+  private final GroupsOnInit groupsOnInit;
+  private SchemaFactory<ReviewDb> dbFactory;
+  private AccountIndexCollection accountIndexCollection;
+  private GroupIndexCollection groupIndexCollection;
+
+  @Inject
+  InitAdminUser(
+      InitFlags flags,
+      ConsoleUI ui,
+      AllUsersNameOnInitProvider allUsers,
+      AccountsOnInit accounts,
+      VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory,
+      ExternalIdsOnInit externalIds,
+      SequencesOnInit sequencesOnInit,
+      GroupsOnInit groupsOnInit) {
+    this.flags = flags;
+    this.ui = ui;
+    this.allUsers = allUsers;
+    this.accounts = accounts;
+    this.authorizedKeysFactory = authorizedKeysFactory;
+    this.externalIds = externalIds;
+    this.sequencesOnInit = sequencesOnInit;
+    this.groupsOnInit = groupsOnInit;
+  }
+
+  @Override
+  public void run() {}
+
+  @Inject(optional = true)
+  void set(SchemaFactory<ReviewDb> dbFactory) {
+    this.dbFactory = dbFactory;
+  }
+
+  @Inject
+  void set(AccountIndexCollection accountIndexCollection) {
+    this.accountIndexCollection = accountIndexCollection;
+  }
+
+  @Inject
+  void set(GroupIndexCollection groupIndexCollection) {
+    this.groupIndexCollection = groupIndexCollection;
+  }
+
+  @Override
+  public void postRun() throws Exception {
+    AuthType authType = flags.cfg.getEnum(AuthType.values(), "auth", null, "type", null);
+    if (authType != AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT) {
+      return;
+    }
+
+    try (ReviewDb db = dbFactory.open()) {
+      if (!accounts.hasAnyAccount()) {
+        ui.header("Gerrit Administrator");
+        if (ui.yesno(true, "Create administrator user")) {
+          Account.Id id = new Account.Id(sequencesOnInit.nextAccountId(db));
+          String username = ui.readString("admin", "username");
+          String name = ui.readString("Administrator", "name");
+          String httpPassword = ui.readString("secret", "HTTP password");
+          AccountSshKey sshKey = readSshKey(id);
+          String email = readEmail(sshKey);
+
+          List<ExternalId> extIds = new ArrayList<>(2);
+          extIds.add(ExternalId.createUsername(username, id, httpPassword));
+
+          if (email != null) {
+            extIds.add(ExternalId.createEmail(id, email));
+          }
+          externalIds.insert("Add external IDs for initial admin user", extIds);
+
+          Account a = new Account(id, TimeUtil.nowTs());
+          a.setFullName(name);
+          a.setPreferredEmail(email);
+          accounts.insert(a);
+
+          // Only two groups should exist at this point in time and hence iterating over all of them
+          // is cheap.
+          Optional<GroupReference> adminGroupReference =
+              groupsOnInit
+                  .getAllGroupReferences(db)
+                  .filter(group -> group.getName().equals("Administrators"))
+                  .findAny();
+          if (!adminGroupReference.isPresent()) {
+            throw new NoSuchGroupException("Administrators");
+          }
+          GroupReference adminGroup = adminGroupReference.get();
+          groupsOnInit.addGroupMember(db, adminGroup.getUUID(), a);
+
+          if (sshKey != null) {
+            VersionedAuthorizedKeysOnInit authorizedKeys = authorizedKeysFactory.create(id).load();
+            authorizedKeys.addKey(sshKey.getSshPublicKey());
+            authorizedKeys.save("Add SSH key for initial admin user\n");
+          }
+
+          AccountState as =
+              new AccountState(
+                  new AllUsersName(allUsers.get()),
+                  a,
+                  extIds,
+                  new HashMap<>(),
+                  GeneralPreferencesInfo.defaults());
+          for (AccountIndex accountIndex : accountIndexCollection.getWriteIndexes()) {
+            accountIndex.replace(as);
+          }
+
+          InternalGroup adminInternalGroup = groupsOnInit.getExistingGroup(db, adminGroup);
+          for (GroupIndex groupIndex : groupIndexCollection.getWriteIndexes()) {
+            groupIndex.replace(adminInternalGroup);
+          }
+        }
+      }
+    }
+  }
+
+  private String readEmail(AccountSshKey sshKey) {
+    String defaultEmail = "admin@example.com";
+    if (sshKey != null && sshKey.getComment() != null) {
+      String c = sshKey.getComment().trim();
+      if (EmailValidator.getInstance().isValid(c)) {
+        defaultEmail = c;
+      }
+    }
+    return readEmail(defaultEmail);
+  }
+
+  private String readEmail(String defaultEmail) {
+    String email = ui.readString(defaultEmail, "email");
+    if (email != null && !EmailValidator.getInstance().isValid(email)) {
+      ui.message("error: invalid email address\n");
+      return readEmail(defaultEmail);
+    }
+    return email;
+  }
+
+  private AccountSshKey readSshKey(Account.Id id) throws IOException {
+    String defaultPublicSshKeyFile = "";
+    Path defaultPublicSshKeyPath = Paths.get(System.getProperty("user.home"), ".ssh", "id_rsa.pub");
+    if (Files.exists(defaultPublicSshKeyPath)) {
+      defaultPublicSshKeyFile = defaultPublicSshKeyPath.toString();
+    }
+    String publicSshKeyFile = ui.readString(defaultPublicSshKeyFile, "public SSH key file");
+    return !Strings.isNullOrEmpty(publicSshKeyFile) ? createSshKey(id, publicSshKeyFile) : null;
+  }
+
+  private AccountSshKey createSshKey(Account.Id id, String keyFile) throws IOException {
+    Path p = Paths.get(keyFile);
+    if (!Files.exists(p)) {
+      throw new IOException(String.format("Cannot add public SSH key: %s is not a file", keyFile));
+    }
+    String content = new String(Files.readAllBytes(p), UTF_8);
+    return new AccountSshKey(new AccountSshKey.Id(id, 1), content);
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAuth.java b/java/com/google/gerrit/pgm/init/InitAuth.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAuth.java
rename to java/com/google/gerrit/pgm/init/InitAuth.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java b/java/com/google/gerrit/pgm/init/InitCache.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java
rename to java/com/google/gerrit/pgm/init/InitCache.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java b/java/com/google/gerrit/pgm/init/InitContainer.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java
rename to java/com/google/gerrit/pgm/init/InitContainer.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java b/java/com/google/gerrit/pgm/init/InitDatabase.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
rename to java/com/google/gerrit/pgm/init/InitDatabase.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDev.java b/java/com/google/gerrit/pgm/init/InitDev.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDev.java
rename to java/com/google/gerrit/pgm/init/InitDev.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitExperimental.java b/java/com/google/gerrit/pgm/init/InitExperimental.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitExperimental.java
rename to java/com/google/gerrit/pgm/init/InitExperimental.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitGitManager.java b/java/com/google/gerrit/pgm/init/InitGitManager.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitGitManager.java
rename to java/com/google/gerrit/pgm/init/InitGitManager.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitHttpd.java b/java/com/google/gerrit/pgm/init/InitHttpd.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitHttpd.java
rename to java/com/google/gerrit/pgm/init/InitHttpd.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java b/java/com/google/gerrit/pgm/init/InitIndex.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
rename to java/com/google/gerrit/pgm/init/InitIndex.java
diff --git a/java/com/google/gerrit/pgm/init/InitLabels.java b/java/com/google/gerrit/pgm/init/InitLabels.java
new file mode 100644
index 0000000..3d1ec7b
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/InitLabels.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.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;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Arrays;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class InitLabels implements InitStep {
+  private static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
+  private static final String KEY_LABEL = "label";
+  private static final String KEY_FUNCTION = "function";
+  private static final String KEY_VALUE = "value";
+  private static final String LABEL_VERIFIED = "Verified";
+
+  private final ConsoleUI ui;
+  private final AllProjectsConfig allProjectsConfig;
+
+  private boolean installVerified;
+
+  @Inject
+  InitLabels(ConsoleUI ui, AllProjectsConfig allProjectsConfig) {
+    this.ui = ui;
+    this.allProjectsConfig = allProjectsConfig;
+  }
+
+  @Override
+  public void run() throws Exception {
+    Config cfg = allProjectsConfig.load().getConfig();
+    if (cfg == null || !cfg.getSubsections(KEY_LABEL).contains(LABEL_VERIFIED)) {
+      ui.header("Review Labels");
+      installVerified = ui.yesno(false, "Install Verified label");
+    }
+  }
+
+  @Override
+  public void postRun() throws Exception {
+    Config cfg = allProjectsConfig.load().getConfig();
+    if (installVerified) {
+      cfg.setString(KEY_LABEL, LABEL_VERIFIED, KEY_FUNCTION, MAX_WITH_BLOCK.getFunctionName());
+      cfg.setStringList(
+          KEY_LABEL,
+          LABEL_VERIFIED,
+          KEY_VALUE,
+          Arrays.asList(new String[] {"-1 Fails", "0 No score", "+1 Verified"}));
+      cfg.setBoolean(KEY_LABEL, LABEL_VERIFIED, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, true);
+      allProjectsConfig.save("Configure 'Verified' label");
+    }
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java b/java/com/google/gerrit/pgm/init/InitModule.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
rename to java/com/google/gerrit/pgm/init/InitModule.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java b/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
rename to java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPlugins.java b/java/com/google/gerrit/pgm/init/InitPlugins.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPlugins.java
rename to java/com/google/gerrit/pgm/init/InitPlugins.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSendEmail.java b/java/com/google/gerrit/pgm/init/InitSendEmail.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSendEmail.java
rename to java/com/google/gerrit/pgm/init/InitSendEmail.java
diff --git a/java/com/google/gerrit/pgm/init/InitSshd.java b/java/com/google/gerrit/pgm/init/InitSshd.java
new file mode 100644
index 0000000..d2e280d
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/InitSshd.java
@@ -0,0 +1,230 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import static com.google.gerrit.pgm.init.api.InitUtil.hostname;
+import static java.nio.file.Files.exists;
+
+import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.InitStep;
+import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.HostPlatform;
+import com.google.gerrit.server.util.SocketUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.lang.ProcessBuilder.Redirect;
+import java.net.InetSocketAddress;
+
+/** Initialize the {@code sshd} configuration section. */
+@Singleton
+public class InitSshd implements InitStep {
+  private final ConsoleUI ui;
+  private final SitePaths site;
+  private final Section sshd;
+  private final StaleLibraryRemover remover;
+
+  @Inject
+  InitSshd(ConsoleUI ui, SitePaths site, Section.Factory sections, StaleLibraryRemover remover) {
+    this.ui = ui;
+    this.site = site;
+    this.sshd = sections.get("sshd", null);
+    this.remover = remover;
+  }
+
+  @Override
+  public void run() throws Exception {
+    ui.header("SSH Daemon");
+
+    String hostname = "*";
+    int port = 29418;
+    String listenAddress = sshd.get("listenAddress");
+    if (isOff(listenAddress)) {
+      hostname = "off";
+    } else if (listenAddress != null && !listenAddress.isEmpty()) {
+      final InetSocketAddress addr = SocketUtil.parse(listenAddress, port);
+      hostname = SocketUtil.hostname(addr);
+      port = addr.getPort();
+    }
+
+    hostname = ui.readString(hostname, "Listen on address");
+    if (isOff(hostname)) {
+      sshd.set("listenAddress", "off");
+      return;
+    }
+
+    port = ui.readInt(port, "Listen on port");
+    sshd.set("listenAddress", SocketUtil.format(hostname, port));
+
+    generateSshHostKeys();
+    remover.remove("bc(pg|pkix|prov)-.*[.]jar");
+  }
+
+  static boolean isOff(String listenHostname) {
+    return "off".equalsIgnoreCase(listenHostname)
+        || "none".equalsIgnoreCase(listenHostname)
+        || "no".equalsIgnoreCase(listenHostname);
+  }
+
+  private void generateSshHostKeys() throws InterruptedException, IOException {
+    if (!exists(site.ssh_key)
+        && (!exists(site.ssh_rsa)
+            || !exists(site.ssh_ed25519)
+            || !exists(site.ssh_ecdsa_256)
+            || !exists(site.ssh_ecdsa_384)
+            || !exists(site.ssh_ecdsa_521))) {
+      System.err.print("Generating SSH host key ...");
+      System.err.flush();
+
+      // Generate the SSH daemon host key using ssh-keygen.
+      //
+      final String comment = "gerrit-code-review@" + hostname();
+
+      // Workaround for JDK-6518827 - zero-length argument ignored on Win32
+      String emptyPassphraseArg = HostPlatform.isWin32() ? "\"\"" : "";
+      if (!exists(site.ssh_rsa)) {
+        System.err.print(" rsa...");
+        System.err.flush();
+        new ProcessBuilder(
+                "ssh-keygen",
+                "-q" /* quiet */,
+                "-t",
+                "rsa",
+                "-P",
+                emptyPassphraseArg,
+                "-C",
+                comment,
+                "-f",
+                site.ssh_rsa.toAbsolutePath().toString())
+            .redirectError(Redirect.INHERIT)
+            .redirectOutput(Redirect.INHERIT)
+            .start()
+            .waitFor();
+      }
+
+      if (!exists(site.ssh_ed25519)) {
+        System.err.print(" ed25519...");
+        System.err.flush();
+        try {
+          new ProcessBuilder(
+                  "ssh-keygen",
+                  "-q" /* quiet */,
+                  "-t",
+                  "ed25519",
+                  "-P",
+                  emptyPassphraseArg,
+                  "-C",
+                  comment,
+                  "-f",
+                  site.ssh_ed25519.toAbsolutePath().toString())
+              .redirectError(Redirect.INHERIT)
+              .redirectOutput(Redirect.INHERIT)
+              .start()
+              .waitFor();
+        } catch (Exception e) {
+          // continue since older hosts won't be able to generate ed25519 keys.
+          System.err.print(" Failed to generate ed25519 key, continuing...");
+          System.err.flush();
+        }
+      }
+
+      if (!exists(site.ssh_ecdsa_256)) {
+        System.err.print(" ecdsa 256...");
+        System.err.flush();
+        try {
+          new ProcessBuilder(
+                  "ssh-keygen",
+                  "-q" /* quiet */,
+                  "-t",
+                  "ecdsa",
+                  "-b",
+                  "256",
+                  "-P",
+                  emptyPassphraseArg,
+                  "-C",
+                  comment,
+                  "-f",
+                  site.ssh_ecdsa_256.toAbsolutePath().toString())
+              .redirectError(Redirect.INHERIT)
+              .redirectOutput(Redirect.INHERIT)
+              .start()
+              .waitFor();
+        } catch (Exception e) {
+          // continue since older hosts won't be able to generate ecdsa keys.
+          System.err.print(" Failed to generate ecdsa 256 key, continuing...");
+          System.err.flush();
+        }
+      }
+
+      if (!exists(site.ssh_ecdsa_384)) {
+        System.err.print(" ecdsa 384...");
+        System.err.flush();
+        try {
+          new ProcessBuilder(
+                  "ssh-keygen",
+                  "-q" /* quiet */,
+                  "-t",
+                  "ecdsa",
+                  "-b",
+                  "384",
+                  "-P",
+                  emptyPassphraseArg,
+                  "-C",
+                  comment,
+                  "-f",
+                  site.ssh_ecdsa_384.toAbsolutePath().toString())
+              .redirectError(Redirect.INHERIT)
+              .redirectOutput(Redirect.INHERIT)
+              .start()
+              .waitFor();
+        } catch (Exception e) {
+          // continue since older hosts won't be able to generate ecdsa keys.
+          System.err.print(" Failed to generate ecdsa 384 key, continuing...");
+          System.err.flush();
+        }
+      }
+
+      if (!exists(site.ssh_ecdsa_521)) {
+        System.err.print(" ecdsa 521...");
+        System.err.flush();
+        try {
+          new ProcessBuilder(
+                  "ssh-keygen",
+                  "-q" /* quiet */,
+                  "-t",
+                  "ecdsa",
+                  "-b",
+                  "521",
+                  "-P",
+                  emptyPassphraseArg,
+                  "-C",
+                  comment,
+                  "-f",
+                  site.ssh_ecdsa_521.toAbsolutePath().toString())
+              .redirectError(Redirect.INHERIT)
+              .redirectOutput(Redirect.INHERIT)
+              .start()
+              .waitFor();
+        } catch (Exception e) {
+          // continue since older hosts won't be able to generate ecdsa keys.
+          System.err.print(" Failed to generate ecdsa 521 key, continuing...");
+          System.err.flush();
+        }
+      }
+      System.err.println(" done");
+    }
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InvalidSecureStoreException.java b/java/com/google/gerrit/pgm/init/InvalidSecureStoreException.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InvalidSecureStoreException.java
rename to java/com/google/gerrit/pgm/init/InvalidSecureStoreException.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/JDBCInitializer.java b/java/com/google/gerrit/pgm/init/JDBCInitializer.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/JDBCInitializer.java
rename to java/com/google/gerrit/pgm/init/JDBCInitializer.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java b/java/com/google/gerrit/pgm/init/Libraries.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java
rename to java/com/google/gerrit/pgm/init/Libraries.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/LibraryDownloader.java b/java/com/google/gerrit/pgm/init/LibraryDownloader.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/LibraryDownloader.java
rename to java/com/google/gerrit/pgm/init/LibraryDownloader.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/MariaDbInitializer.java b/java/com/google/gerrit/pgm/init/MariaDbInitializer.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/MariaDbInitializer.java
rename to java/com/google/gerrit/pgm/init/MariaDbInitializer.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/MaxDbInitializer.java b/java/com/google/gerrit/pgm/init/MaxDbInitializer.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/MaxDbInitializer.java
rename to java/com/google/gerrit/pgm/init/MaxDbInitializer.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/MySqlInitializer.java b/java/com/google/gerrit/pgm/init/MySqlInitializer.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/MySqlInitializer.java
rename to java/com/google/gerrit/pgm/init/MySqlInitializer.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/OracleInitializer.java b/java/com/google/gerrit/pgm/init/OracleInitializer.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/OracleInitializer.java
rename to java/com/google/gerrit/pgm/init/OracleInitializer.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PluginsDistribution.java b/java/com/google/gerrit/pgm/init/PluginsDistribution.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PluginsDistribution.java
rename to java/com/google/gerrit/pgm/init/PluginsDistribution.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PostgreSQLInitializer.java b/java/com/google/gerrit/pgm/init/PostgreSQLInitializer.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PostgreSQLInitializer.java
rename to java/com/google/gerrit/pgm/init/PostgreSQLInitializer.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SecureStoreInitData.java b/java/com/google/gerrit/pgm/init/SecureStoreInitData.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SecureStoreInitData.java
rename to java/com/google/gerrit/pgm/init/SecureStoreInitData.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
rename to java/com/google/gerrit/pgm/init/SitePathInitializer.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/StaleLibraryRemover.java b/java/com/google/gerrit/pgm/init/StaleLibraryRemover.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/StaleLibraryRemover.java
rename to java/com/google/gerrit/pgm/init/StaleLibraryRemover.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java b/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
rename to java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java b/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
rename to java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
rename to java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsNameOnInitProvider.java b/java/com/google/gerrit/pgm/init/api/AllProjectsNameOnInitProvider.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsNameOnInitProvider.java
rename to java/com/google/gerrit/pgm/init/api/AllProjectsNameOnInitProvider.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllUsersNameOnInitProvider.java b/java/com/google/gerrit/pgm/init/api/AllUsersNameOnInitProvider.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllUsersNameOnInitProvider.java
rename to java/com/google/gerrit/pgm/init/api/AllUsersNameOnInitProvider.java
diff --git a/java/com/google/gerrit/pgm/init/api/BUILD b/java/com/google/gerrit/pgm/init/api/BUILD
new file mode 100644
index 0000000..d84261c
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/api/BUILD
@@ -0,0 +1,17 @@
+java_library(
+    name = "api",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
rename to java/com/google/gerrit/pgm/init/api/ConsoleUI.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java b/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
rename to java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java b/java/com/google/gerrit/pgm/init/api/InitFlags.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
rename to java/com/google/gerrit/pgm/init/api/InitFlags.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitStep.java b/java/com/google/gerrit/pgm/init/api/InitStep.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitStep.java
rename to java/com/google/gerrit/pgm/init/api/InitStep.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitUtil.java b/java/com/google/gerrit/pgm/init/api/InitUtil.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitUtil.java
rename to java/com/google/gerrit/pgm/init/api/InitUtil.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallAllPlugins.java b/java/com/google/gerrit/pgm/init/api/InstallAllPlugins.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallAllPlugins.java
rename to java/com/google/gerrit/pgm/init/api/InstallAllPlugins.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallPlugins.java b/java/com/google/gerrit/pgm/init/api/InstallPlugins.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallPlugins.java
rename to java/com/google/gerrit/pgm/init/api/InstallPlugins.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/LibraryDownload.java b/java/com/google/gerrit/pgm/init/api/LibraryDownload.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/LibraryDownload.java
rename to java/com/google/gerrit/pgm/init/api/LibraryDownload.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/Section.java b/java/com/google/gerrit/pgm/init/api/Section.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/Section.java
rename to java/com/google/gerrit/pgm/init/api/Section.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java b/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
rename to java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java b/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
rename to java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexManagerOnInit.java b/java/com/google/gerrit/pgm/init/index/IndexManagerOnInit.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexManagerOnInit.java
rename to java/com/google/gerrit/pgm/init/index/IndexManagerOnInit.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java b/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
rename to java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java b/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java
rename to java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java b/java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java
rename to java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/rules/PrologCompiler.java b/java/com/google/gerrit/pgm/rules/PrologCompiler.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/rules/PrologCompiler.java
rename to java/com/google/gerrit/pgm/rules/PrologCompiler.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/AbstractProgram.java b/java/com/google/gerrit/pgm/util/AbstractProgram.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/AbstractProgram.java
rename to java/com/google/gerrit/pgm/util/AbstractProgram.java
diff --git a/java/com/google/gerrit/pgm/util/BUILD b/java/com/google/gerrit/pgm/util/BUILD
new file mode 100644
index 0000000..d6e44bd
--- /dev/null
+++ b/java/com/google/gerrit/pgm/util/BUILD
@@ -0,0 +1,29 @@
+java_library(
+    name = "util",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/metrics/dropwizard",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server:module",
+        "//java/com/google/gerrit/server/cache/h2",
+        "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/util/cli",
+        "//lib:args4j",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib/commons:dbcp",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//lib/log:jsonevent-layout",
+        "//lib/log:log4j",
+    ],
+)
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchGitModule.java b/java/com/google/gerrit/pgm/util/BatchGitModule.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchGitModule.java
rename to java/com/google/gerrit/pgm/util/BatchGitModule.java
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
new file mode 100644
index 0000000..d39c73a
--- /dev/null
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.util;
+
+import static com.google.inject.Scopes.SINGLETON;
+
+import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+import com.google.gerrit.extensions.common.AccountVisibility;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCacheImpl;
+import com.google.gerrit.server.account.AccountVisibilityProvider;
+import com.google.gerrit.server.account.CapabilityCollection;
+import com.google.gerrit.server.account.FakeRealm;
+import com.google.gerrit.server.account.GroupCacheImpl;
+import com.google.gerrit.server.account.GroupIncludeCacheImpl;
+import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.externalids.ExternalIdModule;
+import com.google.gerrit.server.cache.CacheRemovalListener;
+import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeKindCacheImpl;
+import com.google.gerrit.server.change.MergeabilityCacheImpl;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.config.AdministrateServerGroups;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.CanonicalWebUrlProvider;
+import com.google.gerrit.server.config.DisableReverseDnsLookup;
+import com.google.gerrit.server.config.DisableReverseDnsLookupProvider;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GitReceivePackGroups;
+import com.google.gerrit.server.config.GitUploadPackGroups;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.git.receive.ReceiveCommitsExecutorModule;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.notedb.NoteDbModule;
+import com.google.gerrit.server.patch.DiffExecutorModule;
+import com.google.gerrit.server.patch.PatchListCacheImpl;
+import com.google.gerrit.server.project.CommentLinkProvider;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
+import com.google.gerrit.server.project.ProjectCacheImpl;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SectionSortCache;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
+import com.google.gerrit.server.restapi.group.GroupModule;
+import com.google.gerrit.server.rules.PrologModule;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import com.google.inject.util.Providers;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Module for programs that perform batch operations on a site.
+ *
+ * <p>Any program that requires this module likely also requires using {@link ThreadLimiter} to
+ * limit the number of threads accessing the database concurrently.
+ */
+public class BatchProgramModule extends FactoryModule {
+  private final Config cfg;
+  private final Module reviewDbModule;
+
+  @Inject
+  BatchProgramModule(@GerritServerConfig Config cfg, PerThreadReviewDbModule reviewDbModule) {
+    this.cfg = cfg;
+    this.reviewDbModule = reviewDbModule;
+  }
+
+  @SuppressWarnings("rawtypes")
+  @Override
+  protected void configure() {
+    install(reviewDbModule);
+    install(new DiffExecutorModule());
+    install(new ReceiveCommitsExecutorModule());
+    install(BatchUpdate.module());
+    install(PatchListCacheImpl.module());
+
+    // Plugins are not loaded and we're just running through each change
+    // once, so don't worry about cache removal.
+    bind(new TypeLiteral<DynamicSet<CacheRemovalListener>>() {})
+        .toInstance(DynamicSet.<CacheRemovalListener>emptySet());
+    bind(new TypeLiteral<DynamicMap<Cache<?, ?>>>() {})
+        .toInstance(DynamicMap.<Cache<?, ?>>emptyMap());
+    bind(new TypeLiteral<List<CommentLinkInfo>>() {})
+        .toProvider(CommentLinkProvider.class)
+        .in(SINGLETON);
+    bind(new TypeLiteral<DynamicMap<ChangeQueryProcessor.ChangeAttributeFactory>>() {})
+        .toInstance(DynamicMap.<ChangeQueryProcessor.ChangeAttributeFactory>emptyMap());
+    bind(new TypeLiteral<DynamicMap<RestView<CommitResource>>>() {})
+        .toInstance(DynamicMap.<RestView<CommitResource>>emptyMap());
+    bind(String.class)
+        .annotatedWith(CanonicalWebUrl.class)
+        .toProvider(CanonicalWebUrlProvider.class);
+    bind(Boolean.class)
+        .annotatedWith(DisableReverseDnsLookup.class)
+        .toProvider(DisableReverseDnsLookupProvider.class)
+        .in(SINGLETON);
+    bind(Realm.class).to(FakeRealm.class);
+    bind(IdentifiedUser.class).toProvider(Providers.<IdentifiedUser>of(null));
+    bind(ReplacePatchSetSender.Factory.class)
+        .toProvider(Providers.<ReplacePatchSetSender.Factory>of(null));
+    bind(CurrentUser.class).to(IdentifiedUser.class);
+    factory(MergeUtil.Factory.class);
+    factory(PatchSetInserter.Factory.class);
+    factory(RebaseChangeOp.Factory.class);
+    factory(VisibleRefFilter.Factory.class);
+
+    // As Reindex is a batch program, don't assume the index is available for
+    // the change cache.
+    bind(SearchingChangeCacheImpl.class).toProvider(Providers.<SearchingChangeCacheImpl>of(null));
+
+    bind(new TypeLiteral<ImmutableSet<GroupReference>>() {})
+        .annotatedWith(AdministrateServerGroups.class)
+        .toInstance(ImmutableSet.<GroupReference>of());
+    bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
+        .annotatedWith(GitUploadPackGroups.class)
+        .toInstance(Collections.<AccountGroup.UUID>emptySet());
+    bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
+        .annotatedWith(GitReceivePackGroups.class)
+        .toInstance(Collections.<AccountGroup.UUID>emptySet());
+
+    install(new BatchGitModule());
+    install(new DefaultPermissionBackendModule());
+    install(new DefaultCacheFactory.Module());
+    install(new ExternalIdModule());
+    install(new GroupModule());
+    install(new NoteDbModule(cfg));
+    install(new PrologModule());
+    install(AccountCacheImpl.module());
+    install(GroupCacheImpl.module());
+    install(GroupIncludeCacheImpl.module());
+    install(ProjectCacheImpl.module());
+    install(SectionSortCache.module());
+    install(ChangeKindCacheImpl.module());
+    install(MergeabilityCacheImpl.module());
+    install(TagCache.module());
+    factory(CapabilityCollection.Factory.class);
+    factory(ChangeData.AssistedFactory.class);
+    factory(ProjectState.Factory.class);
+    factory(SubmitRuleEvaluator.Factory.class);
+
+    bind(ChangeJson.Factory.class).toProvider(Providers.<ChangeJson.Factory>of(null));
+    bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
+  }
+}
diff --git a/java/com/google/gerrit/pgm/util/ErrorLogFile.java b/java/com/google/gerrit/pgm/util/ErrorLogFile.java
new file mode 100644
index 0000000..5211f41
--- /dev/null
+++ b/java/com/google/gerrit/pgm/util/ErrorLogFile.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.util;
+
+import com.google.gerrit.common.FileUtil;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.SystemLog;
+import java.io.IOException;
+import java.nio.file.Path;
+import net.logstash.log4j.JSONEventLayoutV1;
+import org.apache.log4j.ConsoleAppender;
+import org.apache.log4j.Level;
+import org.apache.log4j.LogManager;
+import org.apache.log4j.Logger;
+import org.apache.log4j.PatternLayout;
+import org.eclipse.jgit.lib.Config;
+
+public class ErrorLogFile {
+  static final String LOG_NAME = "error_log";
+  static final String JSON_SUFFIX = ".json";
+
+  public static void errorOnlyConsole() {
+    LogManager.resetConfiguration();
+
+    final PatternLayout layout = new PatternLayout();
+    layout.setConversionPattern("%-5p %c %x: %m%n");
+
+    final ConsoleAppender dst = new ConsoleAppender();
+    dst.setLayout(layout);
+    dst.setTarget("System.err");
+    dst.setThreshold(Level.ERROR);
+    dst.activateOptions();
+
+    final Logger root = LogManager.getRootLogger();
+    root.removeAllAppenders();
+    root.addAppender(dst);
+  }
+
+  public static LifecycleListener start(Path sitePath, Config config) throws IOException {
+    Path logdir =
+        FileUtil.mkdirsOrDie(new SitePaths(sitePath).logs_dir, "Cannot create log directory");
+    if (SystemLog.shouldConfigure()) {
+      initLogSystem(logdir, config);
+    }
+
+    return new LifecycleListener() {
+      @Override
+      public void start() {}
+
+      @Override
+      public void stop() {
+        LogManager.shutdown();
+      }
+    };
+  }
+
+  private static void initLogSystem(Path logdir, Config config) {
+    final Logger root = LogManager.getRootLogger();
+    root.removeAllAppenders();
+
+    boolean json = config.getBoolean("log", "jsonLogging", false);
+    boolean text = config.getBoolean("log", "textLogging", true) || !json;
+    boolean rotate = config.getBoolean("log", "rotate", true);
+
+    if (text) {
+      root.addAppender(
+          SystemLog.createAppender(
+              logdir, LOG_NAME, new PatternLayout("[%d] [%t] %-5p %c %x: %m%n"), rotate));
+    }
+
+    if (json) {
+      root.addAppender(
+          SystemLog.createAppender(
+              logdir, LOG_NAME + JSON_SUFFIX, new JSONEventLayoutV1(), rotate));
+    }
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/GuiceLogger.java b/java/com/google/gerrit/pgm/util/GuiceLogger.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/GuiceLogger.java
rename to java/com/google/gerrit/pgm/util/GuiceLogger.java
diff --git a/java/com/google/gerrit/pgm/util/LogFileCompressor.java b/java/com/google/gerrit/pgm/util/LogFileCompressor.java
new file mode 100644
index 0000000..de39839
--- /dev/null
+++ b/java/com/google/gerrit/pgm/util/LogFileCompressor.java
@@ -0,0 +1,172 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.util;
+
+import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.common.io.ByteStreams;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.Future;
+import java.util.zip.GZIPOutputStream;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Compresses the old error logs. */
+public class LogFileCompressor implements Runnable {
+  private static final Logger log = LoggerFactory.getLogger(LogFileCompressor.class);
+
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(Lifecycle.class);
+    }
+  }
+
+  static class Lifecycle implements LifecycleListener {
+    private final WorkQueue queue;
+    private final LogFileCompressor compressor;
+    private final boolean enabled;
+
+    @Inject
+    Lifecycle(WorkQueue queue, LogFileCompressor compressor, @GerritServerConfig Config config) {
+      this.queue = queue;
+      this.compressor = compressor;
+      this.enabled = config.getBoolean("log", "compress", true);
+    }
+
+    @Override
+    public void start() {
+      if (!enabled) {
+        return;
+      }
+      //compress log once and then schedule compression every day at 11:00pm
+      queue.getDefaultQueue().execute(compressor);
+      ZoneId zone = ZoneId.systemDefault();
+      LocalDateTime now = LocalDateTime.now(zone);
+      long milliSecondsUntil11pm =
+          now.until(now.withHour(23).withMinute(0).withSecond(0).withNano(0), ChronoUnit.MILLIS);
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError =
+          queue
+              .getDefaultQueue()
+              .scheduleAtFixedRate(
+                  compressor, milliSecondsUntil11pm, HOURS.toMillis(24), MILLISECONDS);
+    }
+
+    @Override
+    public void stop() {}
+  }
+
+  private final Path logs_dir;
+
+  @Inject
+  LogFileCompressor(SitePaths site) {
+    logs_dir = resolve(site.logs_dir);
+  }
+
+  private static Path resolve(Path p) {
+    try {
+      return p.toRealPath().normalize();
+    } catch (IOException e) {
+      return p.toAbsolutePath().normalize();
+    }
+  }
+
+  @Override
+  public void run() {
+    try {
+      if (!Files.isDirectory(logs_dir)) {
+        return;
+      }
+      try (DirectoryStream<Path> list = Files.newDirectoryStream(logs_dir)) {
+        for (Path entry : list) {
+          if (!isLive(entry) && !isCompressed(entry) && isLogFile(entry)) {
+            compress(entry);
+          }
+        }
+      } catch (IOException e) {
+        log.error("Error listing logs to compress in " + logs_dir, e);
+      }
+    } catch (Exception e) {
+      log.error("Failed to compress log files: " + e.getMessage(), e);
+    }
+  }
+
+  private boolean isLive(Path entry) {
+    String name = entry.getFileName().toString();
+    return name.endsWith("_log")
+        || name.endsWith(".log")
+        || name.endsWith(".run")
+        || name.endsWith(".pid")
+        || name.endsWith(".json");
+  }
+
+  private boolean isCompressed(Path entry) {
+    String name = entry.getFileName().toString();
+    return name.endsWith(".gz") //
+        || name.endsWith(".zip") //
+        || name.endsWith(".bz2");
+  }
+
+  private boolean isLogFile(Path entry) {
+    return Files.isRegularFile(entry);
+  }
+
+  private void compress(Path src) {
+    Path dst = src.resolveSibling(src.getFileName() + ".gz");
+    Path tmp = src.resolveSibling(".tmp." + src.getFileName());
+    try {
+      try (InputStream in = Files.newInputStream(src);
+          OutputStream out = new GZIPOutputStream(Files.newOutputStream(tmp))) {
+        ByteStreams.copy(in, out);
+      }
+      tmp.toFile().setReadOnly();
+      try {
+        Files.move(tmp, dst);
+      } catch (IOException e) {
+        throw new IOException("Cannot rename " + tmp + " to " + dst, e);
+      }
+      Files.delete(src);
+    } catch (IOException e) {
+      log.error("Cannot compress " + src, e);
+      try {
+        Files.deleteIfExists(tmp);
+      } catch (IOException e2) {
+        log.warn("Failed to delete temporary log file " + tmp, e2);
+      }
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "Log File Compressor";
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java b/java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java
rename to java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ProxyUtil.java b/java/com/google/gerrit/pgm/util/ProxyUtil.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ProxyUtil.java
rename to java/com/google/gerrit/pgm/util/ProxyUtil.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java b/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
rename to java/com/google/gerrit/pgm/util/RuntimeShutdown.java
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java b/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java
rename to java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
new file mode 100644
index 0000000..afabcf6
--- /dev/null
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -0,0 +1,268 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.util;
+
+import static com.google.gerrit.server.config.GerritServerConfigModule.getSecureStoreClassName;
+import static com.google.inject.Scopes.SINGLETON;
+import static com.google.inject.Stage.PRODUCTION;
+
+import com.google.gerrit.common.Die;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GerritServerConfigModule;
+import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.git.GitRepositoryManagerModule;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.schema.DataSourceModule;
+import com.google.gerrit.server.schema.DataSourceProvider;
+import com.google.gerrit.server.schema.DataSourceType;
+import com.google.gerrit.server.schema.DatabaseModule;
+import com.google.gerrit.server.schema.SchemaModule;
+import com.google.gerrit.server.securestore.SecureStoreClassName;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Binding;
+import com.google.inject.CreationException;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.Module;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import com.google.inject.name.Names;
+import com.google.inject.spi.Message;
+import com.google.inject.util.Providers;
+import java.lang.annotation.Annotation;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+import javax.sql.DataSource;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
+
+public abstract class SiteProgram extends AbstractProgram {
+  @Option(
+    name = "--site-path",
+    aliases = {"-d"},
+    usage = "Local directory containing site data"
+  )
+  private void setSitePath(String path) {
+    sitePath = Paths.get(path);
+  }
+
+  protected Provider<DataSource> dsProvider;
+
+  private Path sitePath = Paths.get(".");
+
+  protected SiteProgram() {}
+
+  protected SiteProgram(Path sitePath) {
+    this.sitePath = sitePath;
+  }
+
+  protected SiteProgram(Path sitePath, Provider<DataSource> dsProvider) {
+    this.sitePath = sitePath;
+    this.dsProvider = dsProvider;
+  }
+
+  /** @return the site path specified on the command line. */
+  protected Path getSitePath() {
+    return sitePath;
+  }
+
+  /** Ensures we are running inside of a valid site, otherwise throws a Die. */
+  protected void mustHaveValidSite() throws Die {
+    if (!Files.exists(sitePath.resolve("etc").resolve("gerrit.config"))) {
+      throw die("not a Gerrit site: '" + getSitePath() + "'\nPerhaps you need to run init first?");
+    }
+  }
+
+  /** @return provides database connectivity and site path. */
+  protected Injector createDbInjector(DataSourceProvider.Context context) {
+    return createDbInjector(false, context);
+  }
+
+  /** @return provides database connectivity and site path. */
+  protected Injector createDbInjector(boolean enableMetrics, DataSourceProvider.Context context) {
+    Path sitePath = getSitePath();
+    List<Module> modules = new ArrayList<>();
+
+    Module sitePathModule =
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
+            bind(String.class)
+                .annotatedWith(SecureStoreClassName.class)
+                .toProvider(Providers.of(getConfiguredSecureStoreClass()));
+          }
+        };
+    modules.add(sitePathModule);
+
+    if (enableMetrics) {
+      modules.add(new DropWizardMetricMaker.ApiModule());
+    } else {
+      modules.add(
+          new AbstractModule() {
+            @Override
+            protected void configure() {
+              bind(MetricMaker.class).to(DisabledMetricMaker.class);
+            }
+          });
+    }
+
+    modules.add(
+        new LifecycleModule() {
+          @Override
+          protected void configure() {
+            bind(DataSourceProvider.Context.class).toInstance(context);
+            if (dsProvider != null) {
+              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
+                  .toProvider(dsProvider)
+                  .in(SINGLETON);
+              if (LifecycleListener.class.isAssignableFrom(dsProvider.getClass())) {
+                listener().toInstance((LifecycleListener) dsProvider);
+              }
+            } else {
+              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
+                  .toProvider(SiteLibraryBasedDataSourceProvider.class)
+                  .in(SINGLETON);
+              listener().to(SiteLibraryBasedDataSourceProvider.class);
+            }
+          }
+        });
+    Module configModule = new GerritServerConfigModule();
+    modules.add(configModule);
+    Injector cfgInjector = Guice.createInjector(sitePathModule, configModule);
+    Config cfg = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+    String dbType;
+    if (dsProvider != null) {
+      dbType = getDbType(dsProvider);
+    } else {
+      dbType = cfg.getString("database", null, "type");
+    }
+
+    if (dbType == null) {
+      throw new ProvisionException("database.type must be defined");
+    }
+
+    DataSourceType dst =
+        Guice.createInjector(new DataSourceModule(), configModule, sitePathModule)
+            .getInstance(Key.get(DataSourceType.class, Names.named(dbType.toLowerCase())));
+
+    modules.add(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(DataSourceType.class).toInstance(dst);
+          }
+        });
+    modules.add(new DatabaseModule());
+    modules.add(new SchemaModule());
+    modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
+    modules.add(new NotesMigration.Module());
+    modules.add(new GroupsMigration.Module());
+
+    try {
+      return Guice.createInjector(PRODUCTION, modules);
+    } catch (CreationException ce) {
+      Message first = ce.getErrorMessages().iterator().next();
+      Throwable why = first.getCause();
+
+      if (why instanceof SQLException) {
+        throw die("Cannot connect to SQL database", why);
+      }
+      if (why instanceof OrmException
+          && why.getCause() != null
+          && "Unable to determine driver URL".equals(why.getMessage())) {
+        why = why.getCause();
+        if (isCannotCreatePoolException(why)) {
+          throw die("Cannot connect to SQL database", why.getCause());
+        }
+        throw die("Cannot connect to SQL database", why);
+      }
+
+      StringBuilder buf = new StringBuilder();
+      if (why != null) {
+        buf.append(why.getMessage());
+        why = why.getCause();
+      } else {
+        buf.append(first.getMessage());
+      }
+      while (why != null) {
+        buf.append("\n  caused by ");
+        buf.append(why.toString());
+        why = why.getCause();
+      }
+      throw die(buf.toString(), new RuntimeException("DbInjector failed", ce));
+    }
+  }
+
+  protected final String getConfiguredSecureStoreClass() {
+    return getSecureStoreClassName(sitePath);
+  }
+
+  private String getDbType(Provider<DataSource> dsProvider) {
+    String dbProductName;
+    try (Connection conn = dsProvider.get().getConnection()) {
+      dbProductName = conn.getMetaData().getDatabaseProductName().toLowerCase();
+    } catch (SQLException e) {
+      throw new RuntimeException(e);
+    }
+
+    List<Module> modules = new ArrayList<>();
+    modules.add(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(Path.class).annotatedWith(SitePath.class).toInstance(getSitePath());
+          }
+        });
+    modules.add(new GerritServerConfigModule());
+    modules.add(new DataSourceModule());
+    Injector i = Guice.createInjector(modules);
+    List<Binding<DataSourceType>> dsTypeBindings =
+        i.findBindingsByType(new TypeLiteral<DataSourceType>() {});
+    for (Binding<DataSourceType> binding : dsTypeBindings) {
+      Annotation annotation = binding.getKey().getAnnotation();
+      if (annotation instanceof Named) {
+        if (((Named) annotation).value().toLowerCase().contains(dbProductName)) {
+          return ((Named) annotation).value();
+        }
+      }
+    }
+    throw new IllegalStateException(
+        String.format(
+            "Cannot guess database type from the database product name '%s'", dbProductName));
+  }
+
+  @SuppressWarnings("deprecation")
+  private static boolean isCannotCreatePoolException(Throwable why) {
+    return why instanceof org.apache.commons.dbcp.SQLNestedException
+        && why.getCause() != null
+        && why.getMessage().startsWith("Cannot create PoolableConnectionFactory");
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ThreadLimiter.java b/java/com/google/gerrit/pgm/util/ThreadLimiter.java
similarity index 100%
rename from gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ThreadLimiter.java
rename to java/com/google/gerrit/pgm/util/ThreadLimiter.java
diff --git a/java/com/google/gerrit/prettify/BUILD b/java/com/google/gerrit/prettify/BUILD
new file mode 100644
index 0000000..366a1a0
--- /dev/null
+++ b/java/com/google/gerrit/prettify/BUILD
@@ -0,0 +1,31 @@
+load("//tools/bzl:gwt.bzl", "gwt_module")
+
+gwt_module(
+    name = "client",
+    srcs = glob(["common/**/*.java"]),
+    exported_deps = [
+        "//java/com/google/gerrit/extensions:client",
+        "//java/com/google/gerrit/reviewdb:client",
+        "//java/com/google/gwtexpui/safehtml",
+        "//java/org/eclipse/jgit:Edit",
+        "//java/org/eclipse/jgit:client",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtjsonrpc_src",
+    ],
+    gwt_xml = "PrettyFormatter.gwt.xml",
+    visibility = ["//visibility:public"],
+    deps = ["//lib/gwt:user-neverlink"],
+)
+
+java_library(
+    name = "server",
+    srcs = glob(["common/**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/org/eclipse/jgit:server",
+        "//lib:guava",
+        "//lib:gwtjsonrpc",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/PrettyFormatter.gwt.xml b/java/com/google/gerrit/prettify/PrettyFormatter.gwt.xml
similarity index 100%
rename from gerrit-prettify/src/main/java/com/google/gerrit/prettify/PrettyFormatter.gwt.xml
rename to java/com/google/gerrit/prettify/PrettyFormatter.gwt.xml
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/EditList.java b/java/com/google/gerrit/prettify/common/EditList.java
similarity index 100%
rename from gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/EditList.java
rename to java/com/google/gerrit/prettify/common/EditList.java
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/SparseFileContent.java b/java/com/google/gerrit/prettify/common/SparseFileContent.java
similarity index 100%
rename from gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/SparseFileContent.java
rename to java/com/google/gerrit/prettify/common/SparseFileContent.java
diff --git a/java/com/google/gerrit/reviewdb/BUILD b/java/com/google/gerrit/reviewdb/BUILD
new file mode 100644
index 0000000..6f6b9a6
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/BUILD
@@ -0,0 +1,28 @@
+package(
+    default_visibility = ["//visibility:public"],
+)
+
+load("//tools/bzl:gwt.bzl", "gwt_module")
+
+gwt_module(
+    name = "client",
+    srcs = glob(["client/**/*.java"]),
+    gwt_xml = "ReviewDB.gwt.xml",
+    deps = [
+        "//java/com/google/gerrit/extensions:client",
+        "//lib:gwtorm_client",
+        "//lib:gwtorm_client_src",
+    ],
+)
+
+java_library(
+    name = "server",
+    srcs = glob(["**/*.java"]),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/reviewdb"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//lib:guava",
+        "//lib:gwtorm",
+    ],
+)
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/ReviewDB.gwt.xml b/java/com/google/gerrit/reviewdb/ReviewDB.gwt.xml
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/ReviewDB.gwt.xml
rename to java/com/google/gerrit/reviewdb/ReviewDB.gwt.xml
diff --git a/java/com/google/gerrit/reviewdb/client/Account.java b/java/com/google/gerrit/reviewdb/client/Account.java
new file mode 100644
index 0000000..1f9ae0e
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/Account.java
@@ -0,0 +1,328 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.client;
+
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_DRAFT_COMMENTS;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_STARRED_CHANGES;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS;
+
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gwtorm.client.Column;
+import com.google.gwtorm.client.IntKey;
+import java.sql.Timestamp;
+
+/**
+ * Information about a single user.
+ *
+ * <p>A user may have multiple identities they can use to login to Gerrit (see ExternalId), but in
+ * such cases they always map back to a single Account entity.
+ *
+ * <p>Entities "owned" by an Account (that is, their primary key contains the {@link Account.Id} key
+ * as part of their key structure):
+ *
+ * <ul>
+ *   <li>ExternalId: OpenID identities and email addresses known to be registered to this user.
+ *       Multiple records can exist when the user has more than one public identity, such as a work
+ *       and a personal email address.
+ *   <li>{@link AccountGroupMember}: membership of the user in a specific human managed {@link
+ *       AccountGroup}. Multiple records can exist when the user is a member of more than one group.
+ *   <li>{@link AccountSshKey}: user's public SSH keys, for authentication through the internal SSH
+ *       daemon. One record per SSH key uploaded by the user, keys are checked in random order until
+ *       a match is found.
+ *   <li>{@link DiffPreferencesInfo}: user's preferences for rendering side-to-side and unified diff
+ * </ul>
+ */
+public final class Account {
+  public static final String USER_NAME_PATTERN_FIRST = "[a-zA-Z0-9]";
+  public static final String USER_NAME_PATTERN_REST = "[a-zA-Z0-9._@-]";
+  public static final String USER_NAME_PATTERN_LAST = "[a-zA-Z0-9]";
+
+  /** Regular expression that {@link #userName} must match. */
+  public static final String USER_NAME_PATTERN =
+      "^"
+          + //
+          "("
+          + //
+          USER_NAME_PATTERN_FIRST
+          + //
+          USER_NAME_PATTERN_REST
+          + "*"
+          + //
+          USER_NAME_PATTERN_LAST
+          + //
+          "|"
+          + //
+          USER_NAME_PATTERN_FIRST
+          + //
+          ")"
+          + //
+          "$";
+
+  /** Key local to Gerrit to identify a user. */
+  public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
+    private static final long serialVersionUID = 1L;
+
+    @Column(id = 1)
+    protected int id;
+
+    protected Id() {}
+
+    public Id(int id) {
+      this.id = id;
+    }
+
+    @Override
+    public int get() {
+      return id;
+    }
+
+    @Override
+    protected void set(int newValue) {
+      id = newValue;
+    }
+
+    /** Parse an Account.Id out of a string representation. */
+    public static Id parse(String str) {
+      Id r = new Id();
+      r.fromString(str);
+      return r;
+    }
+
+    public static Id fromRef(String name) {
+      if (name == null) {
+        return null;
+      }
+      if (name.startsWith(REFS_USERS)) {
+        return fromRefPart(name.substring(REFS_USERS.length()));
+      } else if (name.startsWith(REFS_DRAFT_COMMENTS)) {
+        return parseAfterShardedRefPart(name.substring(REFS_DRAFT_COMMENTS.length()));
+      } else if (name.startsWith(REFS_STARRED_CHANGES)) {
+        return parseAfterShardedRefPart(name.substring(REFS_STARRED_CHANGES.length()));
+      }
+      return null;
+    }
+
+    /**
+     * Parse an Account.Id out of a part of a ref-name.
+     *
+     * @param name a ref name with the following syntax: {@code "34/1234..."}. We assume that the
+     *     caller has trimmed any prefix.
+     */
+    public static Id fromRefPart(String name) {
+      Integer id = RefNames.parseShardedRefPart(name);
+      return id != null ? new Account.Id(id) : null;
+    }
+
+    public static Id parseAfterShardedRefPart(String name) {
+      Integer id = RefNames.parseAfterShardedRefPart(name);
+      return id != null ? new Account.Id(id) : null;
+    }
+
+    /**
+     * Parse an Account.Id out of the last part of a ref name.
+     *
+     * <p>The input is a ref name of the form {@code ".../1234"}, where the suffix is a non-sharded
+     * account ID. Ref names using a sharded ID should use {@link #fromRefPart(String)} instead for
+     * greater safety.
+     *
+     * @param name ref name
+     * @return account ID, or null if not numeric.
+     */
+    public static Id fromRefSuffix(String name) {
+      Integer id = RefNames.parseRefSuffix(name);
+      return id != null ? new Account.Id(id) : null;
+    }
+  }
+
+  @Column(id = 1)
+  protected Id accountId;
+
+  /** Date and time the user registered with the review server. */
+  @Column(id = 2)
+  protected Timestamp registeredOn;
+
+  /** Full name of the user ("Given-name Surname" style). */
+  @Column(id = 3, notNull = false)
+  protected String fullName;
+
+  /** Email address the user prefers to be contacted through. */
+  @Column(id = 4, notNull = false)
+  protected String preferredEmail;
+
+  // DELETED: id = 5 (contactFiledOn)
+
+  // DELETED: id = 6 (generalPreferences)
+
+  /**
+   * Is this user inactive? This is used to avoid showing some users (eg. former employees) in
+   * auto-suggest.
+   */
+  @Column(id = 7)
+  protected boolean inactive;
+
+  /** The user-settable status of this account (e.g. busy, OOO, available) */
+  @Column(id = 8, notNull = false)
+  protected String status;
+
+  /** <i>computed</i> the username selected from the identities. */
+  protected String userName;
+
+  /**
+   * ID of the user branch from which the account was read, {@code null} if the account was read
+   * from ReviewDb.
+   */
+  private String metaId;
+
+  protected Account() {}
+
+  /**
+   * Create a new account.
+   *
+   * @param newId unique id, see {@link com.google.gerrit.server.Sequences#nextAccountId()}.
+   * @param registeredOn when the account was registered.
+   */
+  public Account(Account.Id newId, Timestamp registeredOn) {
+    this.accountId = newId;
+    this.registeredOn = registeredOn;
+  }
+
+  /** Get local id of this account, to link with in other entities */
+  public Account.Id getId() {
+    return accountId;
+  }
+
+  /** Get the full name of the user ("Given-name Surname" style). */
+  public String getFullName() {
+    return fullName;
+  }
+
+  /** Set the full name of the user ("Given-name Surname" style). */
+  public void setFullName(String name) {
+    if (name != null && !name.trim().isEmpty()) {
+      fullName = name.trim();
+    } else {
+      fullName = null;
+    }
+  }
+
+  /** Email address the user prefers to be contacted through. */
+  public String getPreferredEmail() {
+    return preferredEmail;
+  }
+
+  /** Set the email address the user prefers to be contacted through. */
+  public void setPreferredEmail(String addr) {
+    preferredEmail = addr;
+  }
+
+  /**
+   * Formats an account name.
+   *
+   * <p>The return value goes into NoteDb commits and audit logs, so it should not be changed.
+   *
+   * <p>This method deliberately does not use {@code Anonymous Coward} because it can be changed
+   * using a {@code gerrit.config} option which is a problem for NoteDb commits that still refer to
+   * a previously defined value.
+   *
+   * @return the fullname, if present, otherwise the preferred email, if present, as a last resort a
+   *     generic string containing the accountId.
+   */
+  public String getName() {
+    if (fullName != null) {
+      return fullName;
+    }
+    if (preferredEmail != null) {
+      return preferredEmail;
+    }
+    return "GerritAccount #" + accountId.get();
+  }
+
+  /**
+   * Get the name and email address.
+   *
+   * <p>Example output:
+   *
+   * <ul>
+   *   <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated
+   *   <li>{@code A U. Thor (12)}: missing email address
+   *   <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name
+   *   <li>{@code Anonymous Coward (12)}: missing name and email address
+   * </ul>
+   */
+  public String getNameEmail(String anonymousCowardName) {
+    String name = fullName != null ? fullName : anonymousCowardName;
+    StringBuilder b = new StringBuilder();
+    b.append(name);
+    if (preferredEmail != null) {
+      b.append(" <");
+      b.append(preferredEmail);
+      b.append(">");
+    } else {
+      b.append(" (");
+      b.append(accountId.get());
+      b.append(")");
+    }
+    return b.toString();
+  }
+
+  /** Get the date and time the user first registered. */
+  public Timestamp getRegisteredOn() {
+    return registeredOn;
+  }
+
+  public String getMetaId() {
+    return metaId;
+  }
+
+  public void setMetaId(String metaId) {
+    this.metaId = metaId;
+  }
+
+  public boolean isActive() {
+    return !inactive;
+  }
+
+  public void setActive(boolean active) {
+    inactive = !active;
+  }
+
+  public String getStatus() {
+    return status;
+  }
+
+  public void setStatus(String status) {
+    this.status = status;
+  }
+
+  /** @return the computed user name for this account */
+  public String getUserName() {
+    return userName;
+  }
+
+  /** Update the computed user name property. */
+  public void setUserName(String userName) {
+    this.userName = userName;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    return o instanceof Account && ((Account) o).getId().equals(getId());
+  }
+
+  @Override
+  public int hashCode() {
+    return getId().get();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroup.java b/java/com/google/gerrit/reviewdb/client/AccountGroup.java
new file mode 100644
index 0000000..c7dc420
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroup.java
@@ -0,0 +1,310 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.client;
+
+import com.google.gwtorm.client.Column;
+import com.google.gwtorm.client.IntKey;
+import com.google.gwtorm.client.StringKey;
+import java.sql.Timestamp;
+import java.util.Objects;
+
+/** Named group of one or more accounts, typically used for access controls. */
+public final class AccountGroup {
+  /**
+   * Time when the audit subsystem was implemented, used as the default value for {@link #createdOn}
+   * when one couldn't be determined from the audit log.
+   */
+  // Can't use Instant here because GWT. This is verified against a readable time in the tests,
+  // which don't need to compile under GWT.
+  private static final long AUDIT_CREATION_INSTANT_MS = 1244489460000L;
+
+  public static Timestamp auditCreationInstantTs() {
+    return new Timestamp(AUDIT_CREATION_INSTANT_MS);
+  }
+
+  /** Group name key */
+  public static class NameKey extends StringKey<com.google.gwtorm.client.Key<?>> {
+    private static final long serialVersionUID = 1L;
+
+    @Column(id = 1)
+    protected String name;
+
+    protected NameKey() {}
+
+    public NameKey(String n) {
+      name = n;
+    }
+
+    @Override
+    public String get() {
+      return name;
+    }
+
+    @Override
+    protected void set(String newValue) {
+      name = newValue;
+    }
+  }
+
+  /** Globally unique identifier. */
+  public static class UUID extends StringKey<com.google.gwtorm.client.Key<?>> {
+    private static final long serialVersionUID = 1L;
+
+    @Column(id = 1)
+    protected String uuid;
+
+    protected UUID() {}
+
+    public UUID(String n) {
+      uuid = n;
+    }
+
+    @Override
+    public String get() {
+      return uuid;
+    }
+
+    @Override
+    protected void set(String newValue) {
+      uuid = newValue;
+    }
+
+    /** Parse an {@link AccountGroup.UUID} out of a string representation. */
+    public static UUID parse(String str) {
+      final UUID r = new UUID();
+      r.fromString(str);
+      return r;
+    }
+
+    /** Parse an {@link AccountGroup.UUID} out of a ref-name. */
+    public static UUID fromRef(String ref) {
+      if (ref == null) {
+        return null;
+      }
+      if (ref.startsWith(RefNames.REFS_GROUPS)) {
+        return fromRefPart(ref.substring(RefNames.REFS_GROUPS.length()));
+      }
+      return null;
+    }
+
+    /**
+     * Parse an {@link AccountGroup.UUID} out of a part of a ref-name.
+     *
+     * @param refPart a ref name with the following syntax: {@code "12/1234..."}. We assume that the
+     *     caller has trimmed any prefix.
+     */
+    public static UUID fromRefPart(String refPart) {
+      String uuid = RefNames.parseShardedUuidFromRefPart(refPart);
+      return uuid != null ? new AccountGroup.UUID(uuid) : null;
+    }
+  }
+
+  /** @return true if the UUID is for a group managed within Gerrit. */
+  public static boolean isInternalGroup(AccountGroup.UUID uuid) {
+    return uuid.get().matches("^[0-9a-f]{40}$");
+  }
+
+  /** Synthetic key to link to within the database */
+  public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
+    private static final long serialVersionUID = 1L;
+
+    @Column(id = 1)
+    protected int id;
+
+    protected Id() {}
+
+    public Id(int id) {
+      this.id = id;
+    }
+
+    @Override
+    public int get() {
+      return id;
+    }
+
+    @Override
+    protected void set(int newValue) {
+      id = newValue;
+    }
+
+    /** Parse an AccountGroup.Id out of a string representation. */
+    public static Id parse(String str) {
+      final Id r = new Id();
+      r.fromString(str);
+      return r;
+    }
+  }
+
+  /** Unique name of this group within the system. */
+  @Column(id = 1)
+  protected NameKey name;
+
+  /** Unique identity, to link entities as {@link #name} can change. */
+  @Column(id = 2)
+  protected Id groupId;
+
+  // DELETED: id = 3 (ownerGroupId)
+
+  /** A textual description of the group's purpose. */
+  @Column(id = 4, length = Integer.MAX_VALUE, notNull = false)
+  protected String description;
+
+  // DELETED: id = 5 (groupType)
+  // DELETED: id = 6 (externalName)
+
+  @Column(id = 7)
+  protected boolean visibleToAll;
+
+  // DELETED: id = 8 (emailOnlyAuthors)
+
+  /** Globally unique identifier name for this group. */
+  @Column(id = 9)
+  protected UUID groupUUID;
+
+  /**
+   * Identity of the group whose members can manage this group.
+   *
+   * <p>This can be a self-reference to indicate the group's members manage itself.
+   */
+  @Column(id = 10)
+  protected UUID ownerGroupUUID;
+
+  @Column(id = 11, notNull = false)
+  protected Timestamp createdOn;
+
+  protected AccountGroup() {}
+
+  public AccountGroup(
+      AccountGroup.NameKey newName,
+      AccountGroup.Id newId,
+      AccountGroup.UUID uuid,
+      Timestamp createdOn) {
+    name = newName;
+    groupId = newId;
+    visibleToAll = false;
+    groupUUID = uuid;
+    ownerGroupUUID = groupUUID;
+    this.createdOn = createdOn;
+  }
+
+  public AccountGroup(AccountGroup other) {
+    name = other.name;
+    groupId = other.groupId;
+    description = other.description;
+    visibleToAll = other.visibleToAll;
+    groupUUID = other.groupUUID;
+    ownerGroupUUID = other.ownerGroupUUID;
+    createdOn = other.createdOn;
+  }
+
+  public AccountGroup.Id getId() {
+    return groupId;
+  }
+
+  public String getName() {
+    return name.get();
+  }
+
+  public AccountGroup.NameKey getNameKey() {
+    return name;
+  }
+
+  public void setNameKey(AccountGroup.NameKey nameKey) {
+    name = nameKey;
+  }
+
+  public String getDescription() {
+    return description;
+  }
+
+  public void setDescription(String d) {
+    description = d;
+  }
+
+  public AccountGroup.UUID getOwnerGroupUUID() {
+    return ownerGroupUUID;
+  }
+
+  public void setOwnerGroupUUID(AccountGroup.UUID uuid) {
+    ownerGroupUUID = uuid;
+  }
+
+  public void setVisibleToAll(boolean visibleToAll) {
+    this.visibleToAll = visibleToAll;
+  }
+
+  public boolean isVisibleToAll() {
+    return visibleToAll;
+  }
+
+  public AccountGroup.UUID getGroupUUID() {
+    return groupUUID;
+  }
+
+  public void setGroupUUID(AccountGroup.UUID uuid) {
+    groupUUID = uuid;
+  }
+
+  public Timestamp getCreatedOn() {
+    return createdOn != null ? createdOn : auditCreationInstantTs();
+  }
+
+  public void setCreatedOn(Timestamp createdOn) {
+    this.createdOn = createdOn;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof AccountGroup)) {
+      return false;
+    }
+    AccountGroup g = (AccountGroup) o;
+    return Objects.equals(name, g.name)
+        && Objects.equals(groupId, g.groupId)
+        && Objects.equals(description, g.description)
+        && visibleToAll == g.visibleToAll
+        && Objects.equals(groupUUID, g.groupUUID)
+        && Objects.equals(ownerGroupUUID, g.ownerGroupUUID)
+        // Treat created on epoch identical regardless if underlying value is null.
+        && getCreatedOn().equals(g.getCreatedOn());
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        name, groupId, description, visibleToAll, groupUUID, ownerGroupUUID, createdOn);
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName()
+        + "{"
+        + "name="
+        + name
+        + ", groupId="
+        + groupId
+        + ", description="
+        + description
+        + ", visibleToAll="
+        + visibleToAll
+        + ", groupUUID="
+        + groupUUID
+        + ", ownerGroupUUID="
+        + ownerGroupUUID
+        + ", createdOn="
+        + createdOn
+        + "}";
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupById.java b/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
new file mode 100644
index 0000000..17a205e
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.client;
+
+import com.google.gwtorm.client.Column;
+import com.google.gwtorm.client.CompoundKey;
+import java.util.Objects;
+
+/** Membership of an {@link AccountGroup} in an {@link AccountGroup}. */
+public final class AccountGroupById {
+  public static class Key extends CompoundKey<AccountGroup.Id> {
+    private static final long serialVersionUID = 1L;
+
+    @Column(id = 1)
+    protected AccountGroup.Id groupId;
+
+    @Column(id = 2)
+    protected AccountGroup.UUID includeUUID;
+
+    protected Key() {
+      groupId = new AccountGroup.Id();
+      includeUUID = new AccountGroup.UUID();
+    }
+
+    public Key(AccountGroup.Id g, AccountGroup.UUID u) {
+      groupId = g;
+      includeUUID = u;
+    }
+
+    @Override
+    public AccountGroup.Id getParentKey() {
+      return groupId;
+    }
+
+    public AccountGroup.Id getGroupId() {
+      return groupId;
+    }
+
+    public AccountGroup.UUID getIncludeUUID() {
+      return includeUUID;
+    }
+
+    @Override
+    public com.google.gwtorm.client.Key<?>[] members() {
+      return new com.google.gwtorm.client.Key<?>[] {includeUUID};
+    }
+  }
+
+  @Column(id = 1, name = Column.NONE)
+  protected Key key;
+
+  protected AccountGroupById() {}
+
+  public AccountGroupById(AccountGroupById.Key k) {
+    key = k;
+  }
+
+  public AccountGroupById.Key getKey() {
+    return key;
+  }
+
+  public AccountGroup.Id getGroupId() {
+    return key.groupId;
+  }
+
+  public AccountGroup.UUID getIncludeUUID() {
+    return key.includeUUID;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    return (o instanceof AccountGroupById) && Objects.equals(key, ((AccountGroupById) o).key);
+  }
+
+  @Override
+  public int hashCode() {
+    return key.hashCode();
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName() + "{key=" + key + "}";
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java b/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
new file mode 100644
index 0000000..759e4f6
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
@@ -0,0 +1,172 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.client;
+
+import com.google.gwtorm.client.Column;
+import com.google.gwtorm.client.CompoundKey;
+import java.sql.Timestamp;
+import java.util.Objects;
+
+/** Inclusion of an {@link AccountGroup} in another {@link AccountGroup}. */
+public final class AccountGroupByIdAud {
+  public static class Key extends CompoundKey<AccountGroup.Id> {
+    private static final long serialVersionUID = 1L;
+
+    @Column(id = 1)
+    protected AccountGroup.Id groupId;
+
+    @Column(id = 2)
+    protected AccountGroup.UUID includeUUID;
+
+    @Column(id = 3)
+    protected Timestamp addedOn;
+
+    protected Key() {
+      groupId = new AccountGroup.Id();
+      includeUUID = new AccountGroup.UUID();
+    }
+
+    public Key(AccountGroup.Id g, AccountGroup.UUID u, Timestamp t) {
+      groupId = g;
+      includeUUID = u;
+      addedOn = t;
+    }
+
+    @Override
+    public AccountGroup.Id getParentKey() {
+      return groupId;
+    }
+
+    public AccountGroup.UUID getIncludeUUID() {
+      return includeUUID;
+    }
+
+    public Timestamp getAddedOn() {
+      return addedOn;
+    }
+
+    @Override
+    public com.google.gwtorm.client.Key<?>[] members() {
+      return new com.google.gwtorm.client.Key<?>[] {includeUUID};
+    }
+
+    @Override
+    public String toString() {
+      return "Key{"
+          + "groupId="
+          + groupId
+          + ", includeUUID="
+          + includeUUID
+          + ", addedOn="
+          + addedOn
+          + '}';
+    }
+  }
+
+  @Column(id = 1, name = Column.NONE)
+  protected Key key;
+
+  @Column(id = 2)
+  protected Account.Id addedBy;
+
+  @Column(id = 3, notNull = false)
+  protected Account.Id removedBy;
+
+  @Column(id = 4, notNull = false)
+  protected Timestamp removedOn;
+
+  protected AccountGroupByIdAud() {}
+
+  public AccountGroupByIdAud(final AccountGroupById m, Account.Id adder, Timestamp when) {
+    final AccountGroup.Id group = m.getGroupId();
+    final AccountGroup.UUID include = m.getIncludeUUID();
+    key = new AccountGroupByIdAud.Key(group, include, when);
+    addedBy = adder;
+  }
+
+  public AccountGroupByIdAud(AccountGroupByIdAud.Key key, Account.Id adder) {
+    this.key = key;
+    addedBy = adder;
+  }
+
+  public AccountGroupByIdAud.Key getKey() {
+    return key;
+  }
+
+  public AccountGroup.Id getGroupId() {
+    return key.getParentKey();
+  }
+
+  public AccountGroup.UUID getIncludeUUID() {
+    return key.getIncludeUUID();
+  }
+
+  public boolean isActive() {
+    return removedOn == null;
+  }
+
+  public void removed(Account.Id deleter, Timestamp when) {
+    removedBy = deleter;
+    removedOn = when;
+  }
+
+  public Account.Id getAddedBy() {
+    return addedBy;
+  }
+
+  public Timestamp getAddedOn() {
+    return key.getAddedOn();
+  }
+
+  public Account.Id getRemovedBy() {
+    return removedBy;
+  }
+
+  public Timestamp getRemovedOn() {
+    return removedOn;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof AccountGroupByIdAud)) {
+      return false;
+    }
+    AccountGroupByIdAud a = (AccountGroupByIdAud) o;
+    return Objects.equals(key, a.key)
+        && Objects.equals(addedBy, a.addedBy)
+        && Objects.equals(removedBy, a.removedBy)
+        && Objects.equals(removedOn, a.removedOn);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(key, addedBy, removedBy, removedOn);
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName()
+        + "{"
+        + "key="
+        + key
+        + ", addedBy="
+        + addedBy
+        + ", removedBy="
+        + removedBy
+        + ", removedOn="
+        + removedOn
+        + "}";
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java b/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
new file mode 100644
index 0000000..e1e0754
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.client;
+
+import com.google.gwtorm.client.Column;
+import com.google.gwtorm.client.CompoundKey;
+import java.util.Objects;
+
+/** Membership of an {@link Account} in an {@link AccountGroup}. */
+public final class AccountGroupMember {
+  public static class Key extends CompoundKey<Account.Id> {
+    private static final long serialVersionUID = 1L;
+
+    @Column(id = 1)
+    protected Account.Id accountId;
+
+    @Column(id = 2)
+    protected AccountGroup.Id groupId;
+
+    protected Key() {
+      accountId = new Account.Id();
+      groupId = new AccountGroup.Id();
+    }
+
+    public Key(Account.Id a, AccountGroup.Id g) {
+      accountId = a;
+      groupId = g;
+    }
+
+    @Override
+    public Account.Id getParentKey() {
+      return accountId;
+    }
+
+    public AccountGroup.Id getAccountGroupId() {
+      return groupId;
+    }
+
+    @Override
+    public com.google.gwtorm.client.Key<?>[] members() {
+      return new com.google.gwtorm.client.Key<?>[] {groupId};
+    }
+  }
+
+  @Column(id = 1, name = Column.NONE)
+  protected Key key;
+
+  protected AccountGroupMember() {}
+
+  public AccountGroupMember(AccountGroupMember.Key k) {
+    key = k;
+  }
+
+  public AccountGroupMember.Key getKey() {
+    return key;
+  }
+
+  public Account.Id getAccountId() {
+    return key.accountId;
+  }
+
+  public AccountGroup.Id getAccountGroupId() {
+    return key.groupId;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    return (o instanceof AccountGroupMember) && Objects.equals(key, ((AccountGroupMember) o).key);
+  }
+
+  @Override
+  public int hashCode() {
+    return key.hashCode();
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName() + "{key=" + key + "}";
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java b/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
new file mode 100644
index 0000000..fc7b2d8
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
@@ -0,0 +1,177 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.client;
+
+import com.google.gwtorm.client.Column;
+import com.google.gwtorm.client.CompoundKey;
+import java.sql.Timestamp;
+import java.util.Objects;
+
+/** Membership of an {@link Account} in an {@link AccountGroup}. */
+public final class AccountGroupMemberAudit {
+  public static class Key extends CompoundKey<Account.Id> {
+    private static final long serialVersionUID = 1L;
+
+    @Column(id = 1)
+    protected Account.Id accountId;
+
+    @Column(id = 2)
+    protected AccountGroup.Id groupId;
+
+    @Column(id = 3)
+    protected Timestamp addedOn;
+
+    protected Key() {
+      accountId = new Account.Id();
+      groupId = new AccountGroup.Id();
+    }
+
+    public Key(Account.Id a, AccountGroup.Id g, Timestamp t) {
+      accountId = a;
+      groupId = g;
+      addedOn = t;
+    }
+
+    @Override
+    public Account.Id getParentKey() {
+      return accountId;
+    }
+
+    public AccountGroup.Id getGroupId() {
+      return groupId;
+    }
+
+    public Timestamp getAddedOn() {
+      return addedOn;
+    }
+
+    @Override
+    public com.google.gwtorm.client.Key<?>[] members() {
+      return new com.google.gwtorm.client.Key<?>[] {groupId};
+    }
+
+    @Override
+    public String toString() {
+      return "Key{"
+          + "groupId="
+          + groupId
+          + ", accountId="
+          + accountId
+          + ", addedOn="
+          + addedOn
+          + '}';
+    }
+  }
+
+  @Column(id = 1, name = Column.NONE)
+  protected Key key;
+
+  @Column(id = 2)
+  protected Account.Id addedBy;
+
+  @Column(id = 3, notNull = false)
+  protected Account.Id removedBy;
+
+  @Column(id = 4, notNull = false)
+  protected Timestamp removedOn;
+
+  protected AccountGroupMemberAudit() {}
+
+  public AccountGroupMemberAudit(final AccountGroupMember m, Account.Id adder, Timestamp addedOn) {
+    final Account.Id who = m.getAccountId();
+    final AccountGroup.Id group = m.getAccountGroupId();
+    key = new AccountGroupMemberAudit.Key(who, group, addedOn);
+    addedBy = adder;
+  }
+
+  public AccountGroupMemberAudit(AccountGroupMemberAudit.Key key, Account.Id adder) {
+    this.key = key;
+    addedBy = adder;
+  }
+
+  public AccountGroupMemberAudit.Key getKey() {
+    return key;
+  }
+
+  public AccountGroup.Id getGroupId() {
+    return key.getGroupId();
+  }
+
+  public Account.Id getMemberId() {
+    return key.getParentKey();
+  }
+
+  public boolean isActive() {
+    return removedOn == null;
+  }
+
+  public void removed(Account.Id deleter, Timestamp when) {
+    removedBy = deleter;
+    removedOn = when;
+  }
+
+  public void removedLegacy() {
+    removedBy = addedBy;
+    removedOn = key.addedOn;
+  }
+
+  public Account.Id getAddedBy() {
+    return addedBy;
+  }
+
+  public Timestamp getAddedOn() {
+    return key.getAddedOn();
+  }
+
+  public Account.Id getRemovedBy() {
+    return removedBy;
+  }
+
+  public Timestamp getRemovedOn() {
+    return removedOn;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof AccountGroupMemberAudit)) {
+      return false;
+    }
+    AccountGroupMemberAudit a = (AccountGroupMemberAudit) o;
+    return Objects.equals(key, a.key)
+        && Objects.equals(addedBy, a.addedBy)
+        && Objects.equals(removedBy, a.removedBy)
+        && Objects.equals(removedOn, a.removedOn);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(key, addedBy, removedBy, removedOn);
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName()
+        + "{"
+        + "key="
+        + key
+        + ", addedBy="
+        + addedBy
+        + ", removedBy="
+        + removedBy
+        + ", removedOn="
+        + removedOn
+        + "}";
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupName.java b/java/com/google/gerrit/reviewdb/client/AccountGroupName.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupName.java
rename to java/com/google/gerrit/reviewdb/client/AccountGroupName.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java b/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
rename to java/com/google/gerrit/reviewdb/client/AccountSshKey.java
diff --git a/java/com/google/gerrit/reviewdb/client/BooleanProjectConfig.java b/java/com/google/gerrit/reviewdb/client/BooleanProjectConfig.java
new file mode 100644
index 0000000..765e38c
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/BooleanProjectConfig.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.reviewdb.client;
+
+/**
+ * Contains all inheritable boolean project configs and maps internal representations to API
+ * objects.
+ *
+ * <p>Perform the following steps for adding a new inheritable boolean project config:
+ *
+ * <ol>
+ *   <li>Add a field to {@link com.google.gerrit.extensions.api.projects.ConfigInput}
+ *   <li>Add a field to {@link com.google.gerrit.extensions.api.projects.ConfigInfo}
+ *   <li>Add the config to this enum
+ *   <li>Add API mappers to {@link
+ *       com.google.gerrit.server.project.BooleanProjectConfigTransformations}
+ * </ol>
+ */
+public enum BooleanProjectConfig {
+  USE_CONTRIBUTOR_AGREEMENTS("receive", "requireContributorAgreement"),
+  USE_SIGNED_OFF_BY("receive", "requireSignedOffBy"),
+  USE_CONTENT_MERGE("submit", "mergeContent"),
+  REQUIRE_CHANGE_ID("receive", "requireChangeId"),
+  CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET("receive", "createNewChangeForAllNotInTarget"),
+  ENABLE_SIGNED_PUSH("receive", "enableSignedPush"),
+  REQUIRE_SIGNED_PUSH("receive", "requireSignedPush"),
+  REJECT_IMPLICIT_MERGES("receive", "rejectImplicitMerges"),
+  PRIVATE_BY_DEFAULT("change", "privateByDefault"),
+  ENABLE_REVIEWER_BY_EMAIL("reviewer", "enableByEmail"),
+  MATCH_AUTHOR_TO_COMMITTER_DATE("submit", "matchAuthorToCommitterDate"),
+  REJECT_EMPTY_COMMIT("submit", "rejectEmptyCommit");
+
+  // Git config
+  private final String section;
+  private final String name;
+
+  BooleanProjectConfig(String section, String name) {
+    this.section = section;
+    this.name = name;
+  }
+
+  public String getSection() {
+    return section;
+  }
+
+  public String getSubSection() {
+    return null;
+  }
+
+  public String getName() {
+    return name;
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java b/java/com/google/gerrit/reviewdb/client/Branch.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java
rename to java/com/google/gerrit/reviewdb/client/Branch.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java b/java/com/google/gerrit/reviewdb/client/Change.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
rename to java/com/google/gerrit/reviewdb/client/Change.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java b/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
rename to java/com/google/gerrit/reviewdb/client/ChangeMessage.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CodedEnum.java b/java/com/google/gerrit/reviewdb/client/CodedEnum.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CodedEnum.java
rename to java/com/google/gerrit/reviewdb/client/CodedEnum.java
diff --git a/java/com/google/gerrit/reviewdb/client/Comment.java b/java/com/google/gerrit/reviewdb/client/Comment.java
new file mode 100644
index 0000000..3d19da4
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/Comment.java
@@ -0,0 +1,337 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.client;
+
+import java.sql.Timestamp;
+import java.util.Comparator;
+import java.util.Objects;
+
+/**
+ * This class represents inline comments in NoteDb. This means it determines the JSON format for
+ * inline comments in the revision notes that NoteDb uses to persist inline comments.
+ *
+ * <p>Changing fields in this class changes the storage format of inline comments in NoteDb and may
+ * require a corresponding data migration (adding new optional fields is generally okay).
+ *
+ * <p>{@link PatchLineComment} also represents inline comments, but in ReviewDb. There are a few
+ * notable differences:
+ *
+ * <ul>
+ *   <li>PatchLineComment knows the comment status (published or draft). For comments in NoteDb the
+ *       status is determined by the branch in which they are stored (published comments are stored
+ *       in the change meta ref; draft comments are store in refs/draft-comments branches in
+ *       All-Users). Hence Comment doesn't need to contain the status, but the status is implicitly
+ *       known by where the comments are read from.
+ *   <li>PatchLineComment knows the change ID. For comments in NoteDb, the change ID is determined
+ *       by the branch in which they are stored (the ref name contains the change ID). Hence Comment
+ *       doesn't need to contain the change ID, but the change ID is implicitly known by where the
+ *       comments are read from.
+ * </ul>
+ *
+ * <p>For all utility classes and middle layer functionality using Comment over PatchLineComment is
+ * preferred, as PatchLineComment will go away together with ReviewDb. This means Comment should be
+ * used everywhere and only for storing inline comment in ReviewDb a conversion to PatchLineComment
+ * is done. Converting Comments to PatchLineComments and vice verse is done by
+ * CommentsUtil#toPatchLineComments(Change.Id, PatchLineComment.Status, Iterable) and
+ * CommentsUtil#toComments(String, Iterable).
+ */
+public class Comment {
+  public static class Key {
+    public String uuid;
+    public String filename;
+    public int patchSetId;
+
+    public Key(Key k) {
+      this(k.uuid, k.filename, k.patchSetId);
+    }
+
+    public Key(String uuid, String filename, int patchSetId) {
+      this.uuid = uuid;
+      this.filename = filename;
+      this.patchSetId = patchSetId;
+    }
+
+    @Override
+    public String toString() {
+      return new StringBuilder()
+          .append("Comment.Key{")
+          .append("uuid=")
+          .append(uuid)
+          .append(',')
+          .append("filename=")
+          .append(filename)
+          .append(',')
+          .append("patchSetId=")
+          .append(patchSetId)
+          .append('}')
+          .toString();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Key) {
+        Key k = (Key) o;
+        return Objects.equals(uuid, k.uuid)
+            && Objects.equals(filename, k.filename)
+            && Objects.equals(patchSetId, k.patchSetId);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(uuid, filename, patchSetId);
+    }
+  }
+
+  public static class Identity {
+    int id;
+
+    public Identity(Account.Id id) {
+      this.id = id.get();
+    }
+
+    public Account.Id getId() {
+      return new Account.Id(id);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Identity) {
+        return Objects.equals(id, ((Identity) o).id);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(id);
+    }
+
+    @Override
+    public String toString() {
+      return new StringBuilder()
+          .append("Comment.Identity{")
+          .append("id=")
+          .append(id)
+          .append('}')
+          .toString();
+    }
+  }
+
+  public static class Range implements Comparable<Range> {
+    private static final Comparator<Range> RANGE_COMPARATOR =
+        Comparator.<Range>comparingInt(range -> range.startLine)
+            .thenComparingInt(range -> range.startChar)
+            .thenComparingInt(range -> range.endLine)
+            .thenComparingInt(range -> range.endChar);
+
+    public int startLine; // 1-based, inclusive
+    public int startChar; // 0-based, inclusive
+    public int endLine; // 1-based, exclusive
+    public int endChar; // 0-based, exclusive
+
+    public Range(Range r) {
+      this(r.startLine, r.startChar, r.endLine, r.endChar);
+    }
+
+    public Range(com.google.gerrit.extensions.client.Comment.Range r) {
+      this(r.startLine, r.startCharacter, r.endLine, r.endCharacter);
+    }
+
+    public Range(int startLine, int startChar, int endLine, int endChar) {
+      this.startLine = startLine;
+      this.startChar = startChar;
+      this.endLine = endLine;
+      this.endChar = endChar;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Range) {
+        Range r = (Range) o;
+        return Objects.equals(startLine, r.startLine)
+            && Objects.equals(startChar, r.startChar)
+            && Objects.equals(endLine, r.endLine)
+            && Objects.equals(endChar, r.endChar);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(startLine, startChar, endLine, endChar);
+    }
+
+    @Override
+    public String toString() {
+      return new StringBuilder()
+          .append("Comment.Range{")
+          .append("startLine=")
+          .append(startLine)
+          .append(',')
+          .append("startChar=")
+          .append(startChar)
+          .append(',')
+          .append("endLine=")
+          .append(endLine)
+          .append(',')
+          .append("endChar=")
+          .append(endChar)
+          .append('}')
+          .toString();
+    }
+
+    @Override
+    public int compareTo(Range otherRange) {
+      return RANGE_COMPARATOR.compare(this, otherRange);
+    }
+  }
+
+  public Key key;
+  public int lineNbr;
+  public Identity author;
+  protected Identity realAuthor;
+  public Timestamp writtenOn;
+  public short side;
+  public String message;
+  public String parentUuid;
+  public Range range;
+  public String tag;
+  public String revId;
+  public String serverId;
+  public boolean unresolved;
+
+  /**
+   * Whether the comment was parsed from a JSON representation (false) or the legacy custom notes
+   * format (true).
+   */
+  public transient boolean legacyFormat;
+
+  public Comment(Comment c) {
+    this(
+        new Key(c.key),
+        c.author.getId(),
+        new Timestamp(c.writtenOn.getTime()),
+        c.side,
+        c.message,
+        c.serverId,
+        c.unresolved);
+    this.lineNbr = c.lineNbr;
+    this.realAuthor = c.realAuthor;
+    this.range = c.range != null ? new Range(c.range) : null;
+    this.tag = c.tag;
+    this.revId = c.revId;
+    this.unresolved = c.unresolved;
+  }
+
+  public Comment(
+      Key key,
+      Account.Id author,
+      Timestamp writtenOn,
+      short side,
+      String message,
+      String serverId,
+      boolean unresolved) {
+    this.key = key;
+    this.author = new Comment.Identity(author);
+    this.realAuthor = this.author;
+    this.writtenOn = writtenOn;
+    this.side = side;
+    this.message = message;
+    this.serverId = serverId;
+    this.unresolved = unresolved;
+  }
+
+  public void setLineNbrAndRange(
+      Integer lineNbr, com.google.gerrit.extensions.client.Comment.Range range) {
+    this.lineNbr = lineNbr != null ? lineNbr : range != null ? range.endLine : 0;
+    if (range != null) {
+      this.range = new Comment.Range(range);
+    }
+  }
+
+  public void setRange(CommentRange range) {
+    this.range = range != null ? range.asCommentRange() : null;
+  }
+
+  public void setRevId(RevId revId) {
+    this.revId = revId != null ? revId.get() : null;
+  }
+
+  public void setRealAuthor(Account.Id id) {
+    realAuthor = id != null && id.get() != author.id ? new Comment.Identity(id) : null;
+  }
+
+  public Identity getRealAuthor() {
+    return realAuthor != null ? realAuthor : author;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof Comment) {
+      return Objects.equals(key, ((Comment) o).key);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return key.hashCode();
+  }
+
+  @Override
+  public String toString() {
+    return new StringBuilder()
+        .append("Comment{")
+        .append("key=")
+        .append(key)
+        .append(',')
+        .append("lineNbr=")
+        .append(lineNbr)
+        .append(',')
+        .append("author=")
+        .append(author.getId().get())
+        .append(',')
+        .append("realAuthor=")
+        .append(realAuthor != null ? realAuthor.getId().get() : "")
+        .append(',')
+        .append("writtenOn=")
+        .append(writtenOn.toString())
+        .append(',')
+        .append("side=")
+        .append(side)
+        .append(',')
+        .append("message=")
+        .append(Objects.toString(message, ""))
+        .append(',')
+        .append("parentUuid=")
+        .append(Objects.toString(parentUuid, ""))
+        .append(',')
+        .append("range=")
+        .append(Objects.toString(range, ""))
+        .append(',')
+        .append("revId=")
+        .append(revId != null ? revId : "")
+        .append(',')
+        .append("tag=")
+        .append(Objects.toString(tag, ""))
+        .append(',')
+        .append("unresolved=")
+        .append(unresolved)
+        .append('}')
+        .toString();
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CommentRange.java b/java/com/google/gerrit/reviewdb/client/CommentRange.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CommentRange.java
rename to java/com/google/gerrit/reviewdb/client/CommentRange.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CoreDownloadSchemes.java b/java/com/google/gerrit/reviewdb/client/CoreDownloadSchemes.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CoreDownloadSchemes.java
rename to java/com/google/gerrit/reviewdb/client/CoreDownloadSchemes.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java b/java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java
rename to java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/FixReplacement.java b/java/com/google/gerrit/reviewdb/client/FixReplacement.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/FixReplacement.java
rename to java/com/google/gerrit/reviewdb/client/FixReplacement.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/FixSuggestion.java b/java/com/google/gerrit/reviewdb/client/FixSuggestion.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/FixSuggestion.java
rename to java/com/google/gerrit/reviewdb/client/FixSuggestion.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/LabelId.java b/java/com/google/gerrit/reviewdb/client/LabelId.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/LabelId.java
rename to java/com/google/gerrit/reviewdb/client/LabelId.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java b/java/com/google/gerrit/reviewdb/client/Patch.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java
rename to java/com/google/gerrit/reviewdb/client/Patch.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java b/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
rename to java/com/google/gerrit/reviewdb/client/PatchLineComment.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java b/java/com/google/gerrit/reviewdb/client/PatchSet.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
rename to java/com/google/gerrit/reviewdb/client/PatchSet.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java b/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
rename to java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java b/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
rename to java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
diff --git a/java/com/google/gerrit/reviewdb/client/Project.java b/java/com/google/gerrit/reviewdb/client/Project.java
new file mode 100644
index 0000000..921667e
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/Project.java
@@ -0,0 +1,242 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.client;
+
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gwtorm.client.Column;
+import com.google.gwtorm.client.StringKey;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Projects match a source code repository managed by Gerrit */
+public final class Project {
+  /** Default submit type for new projects. */
+  public static final SubmitType DEFAULT_SUBMIT_TYPE = SubmitType.MERGE_IF_NECESSARY;
+
+  /** Default submit type for root project (All-Projects). */
+  public static final SubmitType DEFAULT_ALL_PROJECTS_SUBMIT_TYPE = SubmitType.MERGE_IF_NECESSARY;
+
+  /** Project name key */
+  public static class NameKey extends StringKey<com.google.gwtorm.client.Key<?>> {
+    private static final long serialVersionUID = 1L;
+
+    @Column(id = 1)
+    protected String name;
+
+    protected NameKey() {}
+
+    public NameKey(String n) {
+      name = n;
+    }
+
+    @Override
+    public String get() {
+      return name;
+    }
+
+    @Override
+    protected void set(String newValue) {
+      name = newValue;
+    }
+
+    @Override
+    public int hashCode() {
+      return get().hashCode();
+    }
+
+    @Override
+    public boolean equals(Object b) {
+      if (b instanceof NameKey) {
+        return get().equals(((NameKey) b).get());
+      }
+      return false;
+    }
+
+    /** Parse a Project.NameKey out of a string representation. */
+    public static NameKey parse(String str) {
+      final NameKey r = new NameKey();
+      r.fromString(str);
+      return r;
+    }
+
+    public static String asStringOrNull(NameKey key) {
+      return key == null ? null : key.get();
+    }
+  }
+
+  protected NameKey name;
+
+  protected String description;
+
+  protected Map<BooleanProjectConfig, InheritableBoolean> booleanConfigs;
+
+  protected SubmitType submitType;
+
+  protected ProjectState state;
+
+  protected NameKey parent;
+
+  protected String maxObjectSizeLimit;
+
+  protected String defaultDashboardId;
+
+  protected String localDefaultDashboardId;
+
+  protected String themeName;
+
+  protected Project() {}
+
+  public Project(Project.NameKey nameKey) {
+    name = nameKey;
+    submitType = SubmitType.MERGE_IF_NECESSARY;
+    state = ProjectState.ACTIVE;
+
+    booleanConfigs = new HashMap<>();
+    Arrays.stream(BooleanProjectConfig.values())
+        .forEach(c -> booleanConfigs.put(c, InheritableBoolean.INHERIT));
+  }
+
+  public Project.NameKey getNameKey() {
+    return name;
+  }
+
+  public String getName() {
+    return name != null ? name.get() : null;
+  }
+
+  public String getDescription() {
+    return description;
+  }
+
+  public void setDescription(String d) {
+    description = d;
+  }
+
+  public String getMaxObjectSizeLimit() {
+    return maxObjectSizeLimit;
+  }
+
+  public InheritableBoolean getBooleanConfig(BooleanProjectConfig config) {
+    return booleanConfigs.get(config);
+  }
+
+  public void setBooleanConfig(BooleanProjectConfig config, InheritableBoolean val) {
+    booleanConfigs.replace(config, val);
+  }
+
+  public void setMaxObjectSizeLimit(String limit) {
+    maxObjectSizeLimit = limit;
+  }
+
+  /**
+   * Submit type as configured in {@code project.config}.
+   *
+   * <p>Does not take inheritance into account, i.e. may return {@link SubmitType#INHERIT}.
+   *
+   * @return submit type.
+   */
+  public SubmitType getConfiguredSubmitType() {
+    return submitType;
+  }
+
+  public void setSubmitType(SubmitType type) {
+    submitType = type;
+  }
+
+  public ProjectState getState() {
+    return state;
+  }
+
+  public void setState(ProjectState newState) {
+    state = newState;
+  }
+
+  public String getDefaultDashboard() {
+    return defaultDashboardId;
+  }
+
+  public void setDefaultDashboard(String defaultDashboardId) {
+    this.defaultDashboardId = defaultDashboardId;
+  }
+
+  public String getLocalDefaultDashboard() {
+    return localDefaultDashboardId;
+  }
+
+  public void setLocalDefaultDashboard(String localDefaultDashboardId) {
+    this.localDefaultDashboardId = localDefaultDashboardId;
+  }
+
+  public String getThemeName() {
+    return themeName;
+  }
+
+  public void setThemeName(String themeName) {
+    this.themeName = themeName;
+  }
+
+  public void copySettingsFrom(Project update) {
+    description = update.description;
+    booleanConfigs = new HashMap<>(update.booleanConfigs);
+    submitType = update.submitType;
+    state = update.state;
+    maxObjectSizeLimit = update.maxObjectSizeLimit;
+  }
+
+  /**
+   * Returns the name key of the parent project.
+   *
+   * @return name key of the parent project, {@code null} if this project is the wild project,
+   *     {@code null} or the name key of the wild project if this project is a direct child of the
+   *     wild project
+   */
+  public Project.NameKey getParent() {
+    return parent;
+  }
+
+  /**
+   * Returns the name key of the parent project.
+   *
+   * @param allProjectsName name key of the wild project
+   * @return name key of the parent project, {@code null} if this project is the All-Projects
+   *     project
+   */
+  public Project.NameKey getParent(Project.NameKey allProjectsName) {
+    if (parent != null) {
+      return parent;
+    }
+
+    if (name.equals(allProjectsName)) {
+      return null;
+    }
+
+    return allProjectsName;
+  }
+
+  public String getParentName() {
+    return parent != null ? parent.get() : null;
+  }
+
+  public void setParentName(String n) {
+    parent = n != null ? new NameKey(n) : null;
+  }
+
+  public void setParentName(NameKey n) {
+    parent = n;
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/client/RefNames.java b/java/com/google/gerrit/reviewdb/client/RefNames.java
new file mode 100644
index 0000000..3739fd4
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -0,0 +1,438 @@
+// 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.reviewdb.client;
+
+/** Constants and utilities for Gerrit-specific ref names. */
+public class RefNames {
+  public static final String HEAD = "HEAD";
+
+  public static final String REFS = "refs/";
+
+  public static final String REFS_HEADS = "refs/heads/";
+
+  public static final String REFS_TAGS = "refs/tags/";
+
+  public static final String REFS_CHANGES = "refs/changes/";
+
+  public static final String REFS_META = "refs/meta/";
+
+  /** Note tree listing commits we refuse {@code refs/meta/reject-commits} */
+  public static final String REFS_REJECT_COMMITS = "refs/meta/reject-commits";
+
+  /** Configuration settings for a project {@code refs/meta/config} */
+  public static final String REFS_CONFIG = "refs/meta/config";
+
+  /** Note tree listing external IDs */
+  public static final String REFS_EXTERNAL_IDS = "refs/meta/external-ids";
+
+  /** Magic user branch in All-Users {@code refs/users/self} */
+  public static final String REFS_USERS_SELF = "refs/users/self";
+
+  /** Default user preference settings */
+  public static final String REFS_USERS_DEFAULT = RefNames.REFS_USERS + "default";
+
+  /** Configurations of project-specific dashboards (canned search queries). */
+  public static final String REFS_DASHBOARDS = "refs/meta/dashboards/";
+
+  /** Sequence counters in NoteDb. */
+  public static final String REFS_SEQUENCES = "refs/sequences/";
+
+  /**
+   * Prefix applied to merge commit base nodes.
+   *
+   * <p>References in this directory should take the form {@code refs/cache-automerge/xx/yyyy...}
+   * where xx is the first two digits of the merge commit's object name, and yyyyy... is the
+   * remaining 38. The reference should point to a treeish that is the automatic merge result of the
+   * merge commit's parents.
+   */
+  public static final String REFS_CACHE_AUTOMERGE = "refs/cache-automerge/";
+
+  /** Suffix of a meta ref in the NoteDb. */
+  public static final String META_SUFFIX = "/meta";
+
+  /** Suffix of a ref that stores robot comments in the NoteDb. */
+  public static final String ROBOT_COMMENTS_SUFFIX = "/robot-comments";
+
+  public static final String EDIT_PREFIX = "edit-";
+
+  /*
+   * The following refs contain an account ID and should be visible only to that account.
+   *
+   * Parsing the account ID from the ref is implemented in Account.Id#fromRef(String). This ensures
+   * that VisibleRefFilter hides those refs from other users.
+   *
+   * This applies to:
+   * - User branches (e.g. 'refs/users/23/1011123')
+   * - Draft comment refs (e.g. 'refs/draft-comments/73/67473/1011123')
+   * - Starred changes refs (e.g. 'refs/starred-changes/73/67473/1011123')
+   */
+
+  /** Preference settings for a user {@code refs/users} */
+  public static final String REFS_USERS = "refs/users/";
+
+  /** NoteDb ref for a group {@code refs/groups} */
+  public static final String REFS_GROUPS = "refs/groups/";
+
+  /** NoteDb ref for the NoteMap of all group names */
+  public static final String REFS_GROUPNAMES = "refs/meta/group-names";
+
+  /**
+   * NoteDb ref for deleted groups {@code refs/deleted-groups}. This ref namespace is foreseen as an
+   * attic for deleted groups (it's reserved but not used yet)
+   */
+  public static final String REFS_DELETED_GROUPS = "refs/deleted-groups/";
+
+  /** Draft inline comments of a user on a change */
+  public static final String REFS_DRAFT_COMMENTS = "refs/draft-comments/";
+
+  /** A change starred by a user */
+  public static final String REFS_STARRED_CHANGES = "refs/starred-changes/";
+
+  public static String fullName(String ref) {
+    return (ref.startsWith(REFS) || ref.equals(HEAD)) ? ref : REFS_HEADS + ref;
+  }
+
+  public static final String shortName(String ref) {
+    if (ref.startsWith(REFS_HEADS)) {
+      return ref.substring(REFS_HEADS.length());
+    } else if (ref.startsWith(REFS_TAGS)) {
+      return ref.substring(REFS_TAGS.length());
+    }
+    return ref;
+  }
+
+  public static String changeMetaRef(Change.Id id) {
+    StringBuilder r = newStringBuilder().append(REFS_CHANGES);
+    return shard(id.get(), r).append(META_SUFFIX).toString();
+  }
+
+  public static String robotCommentsRef(Change.Id id) {
+    StringBuilder r = newStringBuilder().append(REFS_CHANGES);
+    return shard(id.get(), r).append(ROBOT_COMMENTS_SUFFIX).toString();
+  }
+
+  public static boolean isNoteDbMetaRef(String ref) {
+    if (ref.startsWith(REFS_CHANGES)
+        && (ref.endsWith(META_SUFFIX) || ref.endsWith(ROBOT_COMMENTS_SUFFIX))) {
+      return true;
+    }
+    if (ref.startsWith(REFS_DRAFT_COMMENTS) || ref.startsWith(REFS_STARRED_CHANGES)) {
+      return true;
+    }
+    return false;
+  }
+
+  public static String refsGroups(AccountGroup.UUID groupUuid) {
+    return REFS_GROUPS + shardUuid(groupUuid.get());
+  }
+
+  public static String refsDeletedGroups(AccountGroup.UUID groupUuid) {
+    return REFS_DELETED_GROUPS + shardUuid(groupUuid.get());
+  }
+
+  public static String refsUsers(Account.Id accountId) {
+    StringBuilder r = newStringBuilder().append(REFS_USERS);
+    return shard(accountId.get(), r).toString();
+  }
+
+  public static String refsDraftComments(Change.Id changeId, Account.Id accountId) {
+    return buildRefsPrefix(REFS_DRAFT_COMMENTS, changeId.get()).append(accountId.get()).toString();
+  }
+
+  public static String refsDraftCommentsPrefix(Change.Id changeId) {
+    return buildRefsPrefix(REFS_DRAFT_COMMENTS, changeId.get()).toString();
+  }
+
+  public static String refsStarredChanges(Change.Id changeId, Account.Id accountId) {
+    return buildRefsPrefix(REFS_STARRED_CHANGES, changeId.get()).append(accountId.get()).toString();
+  }
+
+  public static String refsStarredChangesPrefix(Change.Id changeId) {
+    return buildRefsPrefix(REFS_STARRED_CHANGES, changeId.get()).toString();
+  }
+
+  private static StringBuilder buildRefsPrefix(String prefix, int id) {
+    StringBuilder r = newStringBuilder().append(prefix);
+    return shard(id, r).append('/');
+  }
+
+  public static String refsCacheAutomerge(String hash) {
+    return REFS_CACHE_AUTOMERGE + hash.substring(0, 2) + '/' + hash.substring(2);
+  }
+
+  public static String shard(int id) {
+    if (id < 0) {
+      return null;
+    }
+    return shard(id, newStringBuilder()).toString();
+  }
+
+  private static StringBuilder shard(int id, StringBuilder sb) {
+    int n = id % 100;
+    if (n < 10) {
+      sb.append('0');
+    }
+    sb.append(n);
+    sb.append('/');
+    sb.append(id);
+    return sb;
+  }
+
+  private static String shardUuid(String uuid) {
+    if (uuid == null || uuid.length() < 2) {
+      throw new IllegalArgumentException("UUIDs must consist of at least two characters");
+    }
+    return uuid.substring(0, 2) + '/' + uuid;
+  }
+
+  /**
+   * Returns reference for this change edit with sharded user and change number:
+   * refs/users/UU/UUUU/edit-CCCC/P.
+   *
+   * @param accountId account id
+   * @param changeId change number
+   * @param psId patch set number
+   * @return reference for this change edit
+   */
+  public static String refsEdit(Account.Id accountId, Change.Id changeId, PatchSet.Id psId) {
+    return refsEditPrefix(accountId, changeId) + psId.get();
+  }
+
+  /**
+   * Returns reference prefix for this change edit with sharded user and change number:
+   * refs/users/UU/UUUU/edit-CCCC/.
+   *
+   * @param accountId account id
+   * @param changeId change number
+   * @return reference prefix for this change edit
+   */
+  public static String refsEditPrefix(Account.Id accountId, Change.Id changeId) {
+    return refsEditPrefix(accountId) + changeId.get() + '/';
+  }
+
+  public static String refsEditPrefix(Account.Id accountId) {
+    return refsUsers(accountId) + '/' + EDIT_PREFIX;
+  }
+
+  public static boolean isRefsEdit(String ref) {
+    return ref != null && ref.startsWith(REFS_USERS) && ref.contains(EDIT_PREFIX);
+  }
+
+  public static boolean isRefsUsers(String ref) {
+    return ref.startsWith(REFS_USERS);
+  }
+
+  /**
+   * Whether the ref is a group branch that stores NoteDb data of a group. Returns {@code true} for
+   * all refs that start with {@code refs/groups/}.
+   */
+  public static boolean isRefsGroups(String ref) {
+    return ref.startsWith(REFS_GROUPS);
+  }
+
+  /**
+   * Whether the ref is a group branch that stores NoteDb data of a deleted group. Returns {@code
+   * true} for all refs that start with {@code refs/deleted-groups/}.
+   */
+  public static boolean isRefsDeletedGroups(String ref) {
+    return ref.startsWith(REFS_DELETED_GROUPS);
+  }
+
+  /**
+   * Whether the ref is used for storing group data in NoteDb. Returns {@code true} for all group
+   * branches, refs/meta/group-names and deleted group branches.
+   */
+  public static boolean isGroupRef(String ref) {
+    return isRefsGroups(ref) || isRefsDeletedGroups(ref) || REFS_GROUPNAMES.equals(ref);
+  }
+
+  static Integer parseShardedRefPart(String name) {
+    if (name == null) {
+      return null;
+    }
+
+    String[] parts = name.split("/");
+    int n = parts.length;
+    if (n < 2) {
+      return null;
+    }
+
+    // Last 2 digits.
+    int le;
+    for (le = 0; le < parts[0].length(); le++) {
+      if (!Character.isDigit(parts[0].charAt(le))) {
+        return null;
+      }
+    }
+    if (le != 2) {
+      return null;
+    }
+
+    // Full ID.
+    int ie;
+    for (ie = 0; ie < parts[1].length(); ie++) {
+      if (!Character.isDigit(parts[1].charAt(ie))) {
+        if (ie == 0) {
+          return null;
+        }
+        break;
+      }
+    }
+
+    int shard = Integer.parseInt(parts[0]);
+    int id = Integer.parseInt(parts[1].substring(0, ie));
+
+    if (id % 100 != shard) {
+      return null;
+    }
+    return id;
+  }
+
+  static String parseShardedUuidFromRefPart(String name) {
+    if (name == null) {
+      return null;
+    }
+
+    String[] parts = name.split("/");
+    int n = parts.length;
+    if (n != 2) {
+      return null;
+    }
+
+    // First 2 chars.
+    if (parts[0].length() != 2) {
+      return null;
+    }
+
+    // Full UUID.
+    String uuid = parts[1];
+    if (!uuid.startsWith(parts[0])) {
+      return null;
+    }
+
+    return uuid;
+  }
+
+  /**
+   * Skips a sharded ref part at the beginning of the name.
+   *
+   * <p>E.g.: "01/1" -> "", "01/1/" -> "/", "01/1/2" -> "/2", "01/1-edit" -> "-edit"
+   *
+   * @param name ref part name
+   * @return the rest of the name, {@code null} if the ref name part doesn't start with a valid
+   *     sharded ID
+   */
+  static String skipShardedRefPart(String name) {
+    if (name == null) {
+      return null;
+    }
+
+    String[] parts = name.split("/");
+    int n = parts.length;
+    if (n < 2) {
+      return null;
+    }
+
+    // Last 2 digits.
+    int le;
+    for (le = 0; le < parts[0].length(); le++) {
+      if (!Character.isDigit(parts[0].charAt(le))) {
+        return null;
+      }
+    }
+    if (le != 2) {
+      return null;
+    }
+
+    // Full ID.
+    int ie;
+    for (ie = 0; ie < parts[1].length(); ie++) {
+      if (!Character.isDigit(parts[1].charAt(ie))) {
+        if (ie == 0) {
+          return null;
+        }
+        break;
+      }
+    }
+
+    int shard = Integer.parseInt(parts[0]);
+    int id = Integer.parseInt(parts[1].substring(0, ie));
+
+    if (id % 100 != shard) {
+      return null;
+    }
+
+    return name.substring(2 + 1 + ie); // 2 for the length of the shard, 1 for the '/'
+  }
+
+  /**
+   * Parses an ID that follows a sharded ref part at the beginning of the name.
+   *
+   * <p>E.g.: "01/1/2" -> 2, "01/1/2/4" -> 2, ""01/1/2-edit" -> 2
+   *
+   * @param name ref part name
+   * @return ID that follows the sharded ref part at the beginning of the name, {@code null} if the
+   *     ref name part doesn't start with a valid sharded ID or if no valid ID follows the sharded
+   *     ref part
+   */
+  static Integer parseAfterShardedRefPart(String name) {
+    String rest = skipShardedRefPart(name);
+    if (rest == null || !rest.startsWith("/")) {
+      return null;
+    }
+
+    rest = rest.substring(1);
+
+    int ie;
+    for (ie = 0; ie < rest.length(); ie++) {
+      if (!Character.isDigit(rest.charAt(ie))) {
+        break;
+      }
+    }
+    if (ie == 0) {
+      return null;
+    }
+    return Integer.parseInt(rest.substring(0, ie));
+  }
+
+  static Integer parseRefSuffix(String name) {
+    if (name == null) {
+      return null;
+    }
+    int i = name.length();
+    while (i > 0) {
+      char c = name.charAt(i - 1);
+      if (c == '/') {
+        break;
+      } else if (!Character.isDigit(c)) {
+        return null;
+      }
+      i--;
+    }
+    if (i == 0) {
+      return null;
+    }
+    return Integer.valueOf(name.substring(i, name.length()));
+  }
+
+  private static StringBuilder newStringBuilder() {
+    // Many refname types in this file are always are longer than the default of 16 chars, so
+    // presize StringBuilders larger by default. This hurts readability less than accurate
+    // calculations would, at a negligible cost to memory overhead.
+    return new StringBuilder(64);
+  }
+
+  private RefNames() {}
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RevId.java b/java/com/google/gerrit/reviewdb/client/RevId.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RevId.java
rename to java/com/google/gerrit/reviewdb/client/RevId.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RobotComment.java b/java/com/google/gerrit/reviewdb/client/RobotComment.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RobotComment.java
rename to java/com/google/gerrit/reviewdb/client/RobotComment.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java b/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java
rename to java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SystemConfig.java b/java/com/google/gerrit/reviewdb/client/SystemConfig.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SystemConfig.java
rename to java/com/google/gerrit/reviewdb/client/SystemConfig.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/TrackingId.java b/java/com/google/gerrit/reviewdb/client/TrackingId.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/TrackingId.java
rename to java/com/google/gerrit/reviewdb/client/TrackingId.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/UserIdentity.java b/java/com/google/gerrit/reviewdb/client/UserIdentity.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/UserIdentity.java
rename to java/com/google/gerrit/reviewdb/client/UserIdentity.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupAccess.java b/java/com/google/gerrit/reviewdb/server/AccountGroupAccess.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupAccess.java
rename to java/com/google/gerrit/reviewdb/server/AccountGroupAccess.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAccess.java b/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAccess.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAccess.java
rename to java/com/google/gerrit/reviewdb/server/AccountGroupByIdAccess.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAudAccess.java b/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAudAccess.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAudAccess.java
rename to java/com/google/gerrit/reviewdb/server/AccountGroupByIdAudAccess.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAccess.java b/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAccess.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAccess.java
rename to java/com/google/gerrit/reviewdb/server/AccountGroupMemberAccess.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAuditAccess.java b/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAuditAccess.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAuditAccess.java
rename to java/com/google/gerrit/reviewdb/server/AccountGroupMemberAuditAccess.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java b/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
rename to java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ChangeAccess.java b/java/com/google/gerrit/reviewdb/server/ChangeAccess.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ChangeAccess.java
rename to java/com/google/gerrit/reviewdb/server/ChangeAccess.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ChangeMessageAccess.java b/java/com/google/gerrit/reviewdb/server/ChangeMessageAccess.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ChangeMessageAccess.java
rename to java/com/google/gerrit/reviewdb/server/ChangeMessageAccess.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/DisallowReadFromChangesReviewDbWrapper.java b/java/com/google/gerrit/reviewdb/server/DisallowReadFromChangesReviewDbWrapper.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/DisallowReadFromChangesReviewDbWrapper.java
rename to java/com/google/gerrit/reviewdb/server/DisallowReadFromChangesReviewDbWrapper.java
diff --git a/java/com/google/gerrit/reviewdb/server/DisallowReadFromGroupsReviewDbWrapper.java b/java/com/google/gerrit/reviewdb/server/DisallowReadFromGroupsReviewDbWrapper.java
new file mode 100644
index 0000000..640924c
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/server/DisallowReadFromGroupsReviewDbWrapper.java
@@ -0,0 +1,310 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.server;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.AccountGroupName;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+public class DisallowReadFromGroupsReviewDbWrapper extends ReviewDbWrapper {
+  private static final String MSG = "This table has been migrated to NoteDb";
+
+  private final Groups groups;
+  private final GroupNames groupNames;
+  private final GroupMembers groupMembers;
+  private final GroupMemberAudits groupMemberAudits;
+  private final ByIds byIds;
+  private final ByIdAudits byIdAudits;
+
+  public DisallowReadFromGroupsReviewDbWrapper(ReviewDb db) {
+    super(db);
+    groups = new Groups(delegate.accountGroups());
+    groupNames = new GroupNames(delegate.accountGroupNames());
+    groupMembers = new GroupMembers(delegate.accountGroupMembers());
+    groupMemberAudits = new GroupMemberAudits(delegate.accountGroupMembersAudit());
+    byIds = new ByIds(delegate.accountGroupById());
+    byIdAudits = new ByIdAudits(delegate.accountGroupByIdAud());
+  }
+
+  public ReviewDb unsafeGetDelegate() {
+    return delegate;
+  }
+
+  @Override
+  public AccountGroupAccess accountGroups() {
+    return groups;
+  }
+
+  @Override
+  public AccountGroupNameAccess accountGroupNames() {
+    return groupNames;
+  }
+
+  @Override
+  public AccountGroupMemberAccess accountGroupMembers() {
+    return groupMembers;
+  }
+
+  @Override
+  public AccountGroupMemberAuditAccess accountGroupMembersAudit() {
+    return groupMemberAudits;
+  }
+
+  @Override
+  public AccountGroupByIdAccess accountGroupById() {
+    return byIds;
+  }
+
+  @Override
+  public AccountGroupByIdAudAccess accountGroupByIdAud() {
+    return byIdAudits;
+  }
+
+  private static class Groups extends AccountGroupAccessWrapper {
+    protected Groups(AccountGroupAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<AccountGroup> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroup, OrmException> getAsync(
+        AccountGroup.Id key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroup> get(Iterable<AccountGroup.Id> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public AccountGroup get(AccountGroup.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroup> byUUID(AccountGroup.UUID uuid) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroup> all() {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class GroupNames extends AccountGroupNameAccessWrapper {
+    protected GroupNames(AccountGroupNameAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<AccountGroupName> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupName, OrmException> getAsync(
+        AccountGroup.NameKey key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupName> get(Iterable<AccountGroup.NameKey> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public AccountGroupName get(AccountGroup.NameKey name) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupName> all() {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class GroupMembers extends AccountGroupMemberAccessWrapper {
+    protected GroupMembers(AccountGroupMemberAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupMember, OrmException>
+        getAsync(AccountGroupMember.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> get(Iterable<AccountGroupMember.Key> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public AccountGroupMember get(AccountGroupMember.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> byAccount(Account.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> byGroup(AccountGroup.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class GroupMemberAudits extends AccountGroupMemberAuditAccessWrapper {
+    protected GroupMemberAudits(AccountGroupMemberAuditAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupMemberAudit, OrmException>
+        getAsync(AccountGroupMemberAudit.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> get(Iterable<AccountGroupMemberAudit.Key> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public AccountGroupMemberAudit get(AccountGroupMemberAudit.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> byGroupAccount(
+        AccountGroup.Id groupId, Account.Id accountId) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> byGroup(AccountGroup.Id groupId) {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class ByIds extends AccountGroupByIdAccessWrapper {
+    protected ByIds(AccountGroupByIdAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupById, OrmException> getAsync(
+        AccountGroupById.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> get(Iterable<AccountGroupById.Key> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public AccountGroupById get(AccountGroupById.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> byIncludeUUID(AccountGroup.UUID uuid) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> byGroup(AccountGroup.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> all() {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class ByIdAudits extends AccountGroupByIdAudAccessWrapper {
+    protected ByIdAudits(AccountGroupByIdAudAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupByIdAud, OrmException>
+        getAsync(AccountGroupByIdAud.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> get(Iterable<AccountGroupByIdAud.Key> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public AccountGroupByIdAud get(AccountGroupByIdAud.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> byGroupInclude(
+        AccountGroup.Id groupId, AccountGroup.UUID incGroupUUID) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> byGroup(AccountGroup.Id groupId) {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchLineCommentAccess.java b/java/com/google/gerrit/reviewdb/server/PatchLineCommentAccess.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchLineCommentAccess.java
rename to java/com/google/gerrit/reviewdb/server/PatchLineCommentAccess.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java b/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java
rename to java/com/google/gerrit/reviewdb/server/PatchSetAccess.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetApprovalAccess.java b/java/com/google/gerrit/reviewdb/server/PatchSetApprovalAccess.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetApprovalAccess.java
rename to java/com/google/gerrit/reviewdb/server/PatchSetApprovalAccess.java
diff --git a/java/com/google/gerrit/reviewdb/server/ReviewDb.java b/java/com/google/gerrit/reviewdb/server/ReviewDb.java
new file mode 100644
index 0000000..22a9cf3
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/server/ReviewDb.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.server;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.SystemConfig;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.Relation;
+import com.google.gwtorm.server.Schema;
+import com.google.gwtorm.server.Sequence;
+
+/**
+ * The review service database schema.
+ *
+ * <p>Root entities that are at the top level of some important data graph:
+ *
+ * <ul>
+ *   <li>{@link Account}: Per-user account registration, preferences, identity.
+ *   <li>{@link Change}: All review information about a single proposed change.
+ *   <li>{@link SystemConfig}: Server-wide settings, managed by administrator.
+ * </ul>
+ */
+public interface ReviewDb extends Schema {
+  /* If you change anything, update SchemaVersion.C to use a new version. */
+
+  @Relation(id = 1)
+  SchemaVersionAccess schemaVersion();
+
+  @Relation(id = 2)
+  SystemConfigAccess systemConfig();
+
+  // Deleted @Relation(id = 3)
+
+  // Deleted @Relation(id = 4)
+
+  // Deleted @Relation(id = 6)
+
+  // Deleted @Relation(id = 7)
+
+  // Deleted @Relation(id = 8)
+
+  @Relation(id = 10)
+  AccountGroupAccess accountGroups();
+
+  @Relation(id = 11)
+  AccountGroupNameAccess accountGroupNames();
+
+  @Relation(id = 12)
+  AccountGroupMemberAccess accountGroupMembers();
+
+  @Relation(id = 13)
+  AccountGroupMemberAuditAccess accountGroupMembersAudit();
+
+  // Deleted @Relation(id = 17)
+
+  // Deleted @Relation(id = 18)
+
+  // Deleted @Relation(id = 19)
+
+  // Deleted @Relation(id = 20)
+
+  @Relation(id = 21)
+  ChangeAccess changes();
+
+  @Relation(id = 22)
+  PatchSetApprovalAccess patchSetApprovals();
+
+  @Relation(id = 23)
+  ChangeMessageAccess changeMessages();
+
+  @Relation(id = 24)
+  PatchSetAccess patchSets();
+
+  // Deleted @Relation(id = 25)
+
+  @Relation(id = 26)
+  PatchLineCommentAccess patchComments();
+
+  // Deleted @Relation(id = 28)
+
+  @Relation(id = 29)
+  AccountGroupByIdAccess accountGroupById();
+
+  @Relation(id = 30)
+  AccountGroupByIdAudAccess accountGroupByIdAud();
+
+  int FIRST_ACCOUNT_ID = 1000000;
+
+  /**
+   * Next unique id for a {@link Account}.
+   *
+   * @deprecated use {@link com.google.gerrit.server.Sequences#nextAccountId()}.
+   */
+  @Sequence(startWith = FIRST_ACCOUNT_ID)
+  @Deprecated
+  int nextAccountId() throws OrmException;
+
+  int FIRST_GROUP_ID = 1;
+
+  /** Next unique id for a {@link AccountGroup}. */
+  @Sequence(startWith = FIRST_GROUP_ID)
+  @Deprecated
+  int nextAccountGroupId() throws OrmException;
+
+  int FIRST_CHANGE_ID = 1;
+
+  /**
+   * Next unique id for a {@link Change}.
+   *
+   * @deprecated use {@link com.google.gerrit.server.Sequences#nextChangeId()}.
+   */
+  @Sequence(startWith = FIRST_CHANGE_ID)
+  @Deprecated
+  int nextChangeId() throws OrmException;
+}
diff --git a/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java b/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
new file mode 100644
index 0000000..ef057eb
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.server;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
+import com.google.gwtorm.client.Column;
+import com.google.gwtorm.client.IntKey;
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.TreeSet;
+
+/** Static utilities for ReviewDb types. */
+public class ReviewDbUtil {
+  private static final Ordering<? extends IntKey<?>> INT_KEY_ORDERING =
+      Ordering.natural().nullsFirst().<IntKey<?>>onResultOf(IntKey::get).nullsFirst();
+
+  /**
+   * Null-safe ordering over arbitrary subclass of {@code IntKey}.
+   *
+   * <p>In some cases, {@code Comparator.comparing(Change.Id::get)} may be shorter and cleaner.
+   * However, this method may be preferable in some cases:
+   *
+   * <ul>
+   *   <li>This ordering is null-safe over both input and the result of {@link IntKey#get()}; {@code
+   *       comparing} is only a good idea if all inputs are obviously non-null.
+   *   <li>{@code intKeyOrdering().sortedCopy(iterable)} is shorter than the stream equivalent.
+   *   <li>Creating derived comparators may be more readable with {@link Ordering} method chaining
+   *       rather than static {@code Comparator} methods.
+   * </ul>
+   */
+  @SuppressWarnings("unchecked")
+  public static <K extends IntKey<?>> Ordering<K> intKeyOrdering() {
+    return (Ordering<K>) INT_KEY_ORDERING;
+  }
+
+  public static ReviewDb unwrapDb(ReviewDb db) {
+    if (db instanceof DisallowReadFromChangesReviewDbWrapper) {
+      return unwrapDb(((DisallowReadFromChangesReviewDbWrapper) db).unsafeGetDelegate());
+    }
+    if (db instanceof DisallowReadFromGroupsReviewDbWrapper) {
+      return unwrapDb(((DisallowReadFromGroupsReviewDbWrapper) db).unsafeGetDelegate());
+    }
+    return db;
+  }
+
+  public static void checkColumns(Class<?> clazz, Integer... expected) {
+    Set<Integer> ids = new TreeSet<>();
+    for (Field f : clazz.getDeclaredFields()) {
+      Column col = f.getAnnotation(Column.class);
+      if (col != null) {
+        ids.add(col.id());
+      }
+    }
+    Set<Integer> expectedIds = Sets.newTreeSet(Arrays.asList(expected));
+    checkState(
+        ids.equals(expectedIds),
+        "Unexpected column set for %s: %s != %s",
+        clazz.getSimpleName(),
+        ids,
+        expectedIds);
+  }
+
+  private ReviewDbUtil() {}
+}
diff --git a/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java b/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
new file mode 100644
index 0000000..f0a8a34
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
@@ -0,0 +1,1290 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.server;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.AccountGroupName;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gwtorm.server.Access;
+import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.gwtorm.server.StatementExecutor;
+import java.util.Map;
+
+public class ReviewDbWrapper implements ReviewDb {
+  protected final ReviewDb delegate;
+
+  private boolean inTransaction;
+
+  protected ReviewDbWrapper(ReviewDb delegate) {
+    this.delegate = checkNotNull(delegate);
+  }
+
+  public boolean inTransaction() {
+    return inTransaction;
+  }
+
+  public void beginTransaction() {
+    inTransaction = true;
+  }
+
+  @Override
+  public void commit() throws OrmException {
+    if (!inTransaction) {
+      // This reads a little weird, we're not in a transaction, so why are we calling commit?
+      // Because we want to let the underlying ReviewDb do its normal thing in this case (which may
+      // be throwing an exception, or not, depending on implementation).
+      delegate.commit();
+    }
+  }
+
+  @Override
+  public void rollback() throws OrmException {
+    if (inTransaction) {
+      inTransaction = false;
+    } else {
+      // See comment in commit(): we want to let the underlying ReviewDb do its thing.
+      delegate.rollback();
+    }
+  }
+
+  @Override
+  public void updateSchema(StatementExecutor e) throws OrmException {
+    delegate.updateSchema(e);
+  }
+
+  @Override
+  public void pruneSchema(StatementExecutor e) throws OrmException {
+    delegate.pruneSchema(e);
+  }
+
+  @Override
+  public Access<?, ?>[] allRelations() {
+    return delegate.allRelations();
+  }
+
+  @Override
+  public void close() {
+    delegate.close();
+  }
+
+  @Override
+  public SchemaVersionAccess schemaVersion() {
+    return delegate.schemaVersion();
+  }
+
+  @Override
+  public SystemConfigAccess systemConfig() {
+    return delegate.systemConfig();
+  }
+
+  @Override
+  public AccountGroupAccess accountGroups() {
+    return delegate.accountGroups();
+  }
+
+  @Override
+  public AccountGroupNameAccess accountGroupNames() {
+    return delegate.accountGroupNames();
+  }
+
+  @Override
+  public AccountGroupMemberAccess accountGroupMembers() {
+    return delegate.accountGroupMembers();
+  }
+
+  @Override
+  public AccountGroupMemberAuditAccess accountGroupMembersAudit() {
+    return delegate.accountGroupMembersAudit();
+  }
+
+  @Override
+  public ChangeAccess changes() {
+    return delegate.changes();
+  }
+
+  @Override
+  public PatchSetApprovalAccess patchSetApprovals() {
+    return delegate.patchSetApprovals();
+  }
+
+  @Override
+  public ChangeMessageAccess changeMessages() {
+    return delegate.changeMessages();
+  }
+
+  @Override
+  public PatchSetAccess patchSets() {
+    return delegate.patchSets();
+  }
+
+  @Override
+  public PatchLineCommentAccess patchComments() {
+    return delegate.patchComments();
+  }
+
+  @Override
+  public AccountGroupByIdAccess accountGroupById() {
+    return delegate.accountGroupById();
+  }
+
+  @Override
+  public AccountGroupByIdAudAccess accountGroupByIdAud() {
+    return delegate.accountGroupByIdAud();
+  }
+
+  @Override
+  @SuppressWarnings("deprecation")
+  public int nextAccountId() throws OrmException {
+    return delegate.nextAccountId();
+  }
+
+  @Override
+  @SuppressWarnings("deprecation")
+  public int nextAccountGroupId() throws OrmException {
+    return delegate.nextAccountGroupId();
+  }
+
+  @Override
+  @SuppressWarnings("deprecation")
+  public int nextChangeId() throws OrmException {
+    return delegate.nextChangeId();
+  }
+
+  public static class ChangeAccessWrapper implements ChangeAccess {
+    protected final ChangeAccess delegate;
+
+    protected ChangeAccessWrapper(ChangeAccess delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<Change> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public Change.Id primaryKey(Change entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<Change.Id, Change> toMap(Iterable<Change> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<Change, OrmException> getAsync(
+        Change.Id key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<Change> get(Iterable<Change.Id> keys) throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<Change> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<Change> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<Change> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<Change.Id> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<Change> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(Change.Id key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public Change atomicUpdate(Change.Id key, AtomicUpdate<Change> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public Change get(Change.Id id) throws OrmException {
+      return delegate.get(id);
+    }
+
+    @Override
+    public ResultSet<Change> all() throws OrmException {
+      return delegate.all();
+    }
+  }
+
+  public static class PatchSetApprovalAccessWrapper implements PatchSetApprovalAccess {
+    protected final PatchSetApprovalAccess delegate;
+
+    protected PatchSetApprovalAccessWrapper(PatchSetApprovalAccess delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public PatchSetApproval.Key primaryKey(PatchSetApproval entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<PatchSetApproval.Key, PatchSetApproval> toMap(Iterable<PatchSetApproval> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<PatchSetApproval, OrmException> getAsync(
+        PatchSetApproval.Key key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> get(Iterable<PatchSetApproval.Key> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<PatchSetApproval> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<PatchSetApproval> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<PatchSetApproval> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<PatchSetApproval.Key> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<PatchSetApproval> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(PatchSetApproval.Key key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public PatchSetApproval atomicUpdate(
+        PatchSetApproval.Key key, AtomicUpdate<PatchSetApproval> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public PatchSetApproval get(PatchSetApproval.Key key) throws OrmException {
+      return delegate.get(key);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byChange(Change.Id id) throws OrmException {
+      return delegate.byChange(id);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id) throws OrmException {
+      return delegate.byPatchSet(id);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byPatchSetUser(PatchSet.Id patchSet, Account.Id account)
+        throws OrmException {
+      return delegate.byPatchSetUser(patchSet, account);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> all() throws OrmException {
+      return delegate.all();
+    }
+  }
+
+  public static class ChangeMessageAccessWrapper implements ChangeMessageAccess {
+    protected final ChangeMessageAccess delegate;
+
+    protected ChangeMessageAccessWrapper(ChangeMessageAccess delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public ChangeMessage.Key primaryKey(ChangeMessage entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<ChangeMessage.Key, ChangeMessage> toMap(Iterable<ChangeMessage> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<ChangeMessage, OrmException> getAsync(
+        ChangeMessage.Key key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> get(Iterable<ChangeMessage.Key> keys) throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<ChangeMessage> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<ChangeMessage> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<ChangeMessage> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<ChangeMessage.Key> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<ChangeMessage> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(ChangeMessage.Key key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public ChangeMessage atomicUpdate(ChangeMessage.Key key, AtomicUpdate<ChangeMessage> update)
+        throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public ChangeMessage get(ChangeMessage.Key id) throws OrmException {
+      return delegate.get(id);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> byChange(Change.Id id) throws OrmException {
+      return delegate.byChange(id);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id) throws OrmException {
+      return delegate.byPatchSet(id);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> all() throws OrmException {
+      return delegate.all();
+    }
+  }
+
+  public static class PatchSetAccessWrapper implements PatchSetAccess {
+    protected final PatchSetAccess delegate;
+
+    protected PatchSetAccessWrapper(PatchSetAccess delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<PatchSet> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public PatchSet.Id primaryKey(PatchSet entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<PatchSet.Id, PatchSet> toMap(Iterable<PatchSet> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<PatchSet, OrmException> getAsync(
+        PatchSet.Id key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<PatchSet> get(Iterable<PatchSet.Id> keys) throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<PatchSet> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<PatchSet> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<PatchSet> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<PatchSet.Id> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<PatchSet> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(PatchSet.Id key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public PatchSet atomicUpdate(PatchSet.Id key, AtomicUpdate<PatchSet> update)
+        throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public PatchSet get(PatchSet.Id id) throws OrmException {
+      return delegate.get(id);
+    }
+
+    @Override
+    public ResultSet<PatchSet> byChange(Change.Id id) throws OrmException {
+      return delegate.byChange(id);
+    }
+
+    @Override
+    public ResultSet<PatchSet> all() throws OrmException {
+      return delegate.all();
+    }
+  }
+
+  public static class PatchLineCommentAccessWrapper implements PatchLineCommentAccess {
+    protected PatchLineCommentAccess delegate;
+
+    protected PatchLineCommentAccessWrapper(PatchLineCommentAccess delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public PatchLineComment.Key primaryKey(PatchLineComment entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<PatchLineComment.Key, PatchLineComment> toMap(Iterable<PatchLineComment> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<PatchLineComment, OrmException> getAsync(
+        PatchLineComment.Key key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> get(Iterable<PatchLineComment.Key> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<PatchLineComment> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<PatchLineComment> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<PatchLineComment> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<PatchLineComment.Key> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<PatchLineComment> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(PatchLineComment.Key key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public PatchLineComment atomicUpdate(
+        PatchLineComment.Key key, AtomicUpdate<PatchLineComment> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public PatchLineComment get(PatchLineComment.Key id) throws OrmException {
+      return delegate.get(id);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> byChange(Change.Id id) throws OrmException {
+      return delegate.byChange(id);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id) throws OrmException {
+      return delegate.byPatchSet(id);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id, String file)
+        throws OrmException {
+      return delegate.publishedByChangeFile(id, file);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> publishedByPatchSet(PatchSet.Id patchset)
+        throws OrmException {
+      return delegate.publishedByPatchSet(patchset);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByPatchSetAuthor(
+        PatchSet.Id patchset, Account.Id author) throws OrmException {
+      return delegate.draftByPatchSetAuthor(patchset, author);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByChangeFileAuthor(
+        Change.Id id, String file, Account.Id author) throws OrmException {
+      return delegate.draftByChangeFileAuthor(id, file, author);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByAuthor(Account.Id author) throws OrmException {
+      return delegate.draftByAuthor(author);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> all() throws OrmException {
+      return delegate.all();
+    }
+  }
+
+  public static class AccountGroupAccessWrapper implements AccountGroupAccess {
+    protected final AccountGroupAccess delegate;
+
+    protected AccountGroupAccessWrapper(AccountGroupAccess delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<AccountGroup> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public AccountGroup.Id primaryKey(AccountGroup entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<AccountGroup.Id, AccountGroup> toMap(Iterable<AccountGroup> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroup, OrmException> getAsync(
+        AccountGroup.Id key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroup> get(Iterable<AccountGroup.Id> keys) throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<AccountGroup> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<AccountGroup> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<AccountGroup> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<AccountGroup.Id> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<AccountGroup> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(AccountGroup.Id key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public AccountGroup atomicUpdate(AccountGroup.Id key, AtomicUpdate<AccountGroup> update)
+        throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public AccountGroup get(AccountGroup.Id id) throws OrmException {
+      return delegate.get(id);
+    }
+
+    @Override
+    public ResultSet<AccountGroup> byUUID(AccountGroup.UUID uuid) throws OrmException {
+      return delegate.byUUID(uuid);
+    }
+
+    @Override
+    public ResultSet<AccountGroup> all() throws OrmException {
+      return delegate.all();
+    }
+  }
+
+  public static class AccountGroupNameAccessWrapper implements AccountGroupNameAccess {
+    protected final AccountGroupNameAccess delegate;
+
+    protected AccountGroupNameAccessWrapper(AccountGroupNameAccess delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<AccountGroupName> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public AccountGroup.NameKey primaryKey(AccountGroupName entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<AccountGroup.NameKey, AccountGroupName> toMap(Iterable<AccountGroupName> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupName, OrmException> getAsync(
+        AccountGroup.NameKey key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupName> get(Iterable<AccountGroup.NameKey> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<AccountGroupName> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<AccountGroupName> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<AccountGroupName> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<AccountGroup.NameKey> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<AccountGroupName> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(AccountGroup.NameKey key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public AccountGroupName atomicUpdate(
+        AccountGroup.NameKey key, AtomicUpdate<AccountGroupName> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public AccountGroupName get(AccountGroup.NameKey name) throws OrmException {
+      return delegate.get(name);
+    }
+
+    @Override
+    public ResultSet<AccountGroupName> all() throws OrmException {
+      return delegate.all();
+    }
+  }
+
+  public static class AccountGroupMemberAccessWrapper implements AccountGroupMemberAccess {
+    protected final AccountGroupMemberAccess delegate;
+
+    protected AccountGroupMemberAccessWrapper(AccountGroupMemberAccess delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public AccountGroupMember.Key primaryKey(AccountGroupMember entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<AccountGroupMember.Key, AccountGroupMember> toMap(Iterable<AccountGroupMember> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupMember, OrmException>
+        getAsync(AccountGroupMember.Key key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> get(Iterable<AccountGroupMember.Key> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<AccountGroupMember> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<AccountGroupMember> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<AccountGroupMember> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<AccountGroupMember.Key> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<AccountGroupMember> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(AccountGroupMember.Key key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public AccountGroupMember atomicUpdate(
+        AccountGroupMember.Key key, AtomicUpdate<AccountGroupMember> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public AccountGroupMember get(AccountGroupMember.Key key) throws OrmException {
+      return delegate.get(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> byAccount(Account.Id id) throws OrmException {
+      return delegate.byAccount(id);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> byGroup(AccountGroup.Id id) throws OrmException {
+      return delegate.byGroup(id);
+    }
+  }
+
+  public static class AccountGroupMemberAuditAccessWrapper
+      implements AccountGroupMemberAuditAccess {
+    protected final AccountGroupMemberAuditAccess delegate;
+
+    protected AccountGroupMemberAuditAccessWrapper(AccountGroupMemberAuditAccess delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public AccountGroupMemberAudit.Key primaryKey(AccountGroupMemberAudit entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<AccountGroupMemberAudit.Key, AccountGroupMemberAudit> toMap(
+        Iterable<AccountGroupMemberAudit> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupMemberAudit, OrmException>
+        getAsync(AccountGroupMemberAudit.Key key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> get(Iterable<AccountGroupMemberAudit.Key> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<AccountGroupMemberAudit> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<AccountGroupMemberAudit> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<AccountGroupMemberAudit> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<AccountGroupMemberAudit.Key> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<AccountGroupMemberAudit> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(AccountGroupMemberAudit.Key key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public AccountGroupMemberAudit atomicUpdate(
+        AccountGroupMemberAudit.Key key, AtomicUpdate<AccountGroupMemberAudit> update)
+        throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public AccountGroupMemberAudit get(AccountGroupMemberAudit.Key key) throws OrmException {
+      return delegate.get(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> byGroupAccount(
+        AccountGroup.Id groupId, Account.Id accountId) throws OrmException {
+      return delegate.byGroupAccount(groupId, accountId);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> byGroup(AccountGroup.Id groupId) throws OrmException {
+      return delegate.byGroup(groupId);
+    }
+  }
+
+  public static class AccountGroupByIdAccessWrapper implements AccountGroupByIdAccess {
+    protected final AccountGroupByIdAccess delegate;
+
+    protected AccountGroupByIdAccessWrapper(AccountGroupByIdAccess delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public AccountGroupById.Key primaryKey(AccountGroupById entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<AccountGroupById.Key, AccountGroupById> toMap(Iterable<AccountGroupById> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupById, OrmException> getAsync(
+        AccountGroupById.Key key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> get(Iterable<AccountGroupById.Key> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<AccountGroupById> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<AccountGroupById> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<AccountGroupById> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<AccountGroupById.Key> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<AccountGroupById> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(AccountGroupById.Key key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public AccountGroupById atomicUpdate(
+        AccountGroupById.Key key, AtomicUpdate<AccountGroupById> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public AccountGroupById get(AccountGroupById.Key key) throws OrmException {
+      return delegate.get(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> byIncludeUUID(AccountGroup.UUID uuid) throws OrmException {
+      return delegate.byIncludeUUID(uuid);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> byGroup(AccountGroup.Id id) throws OrmException {
+      return delegate.byGroup(id);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> all() throws OrmException {
+      return delegate.all();
+    }
+  }
+
+  public static class AccountGroupByIdAudAccessWrapper implements AccountGroupByIdAudAccess {
+    protected final AccountGroupByIdAudAccess delegate;
+
+    protected AccountGroupByIdAudAccessWrapper(AccountGroupByIdAudAccess delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public AccountGroupByIdAud.Key primaryKey(AccountGroupByIdAud entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<AccountGroupByIdAud.Key, AccountGroupByIdAud> toMap(
+        Iterable<AccountGroupByIdAud> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupByIdAud, OrmException>
+        getAsync(AccountGroupByIdAud.Key key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> get(Iterable<AccountGroupByIdAud.Key> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<AccountGroupByIdAud> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<AccountGroupByIdAud> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<AccountGroupByIdAud> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<AccountGroupByIdAud.Key> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<AccountGroupByIdAud> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(AccountGroupByIdAud.Key key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public AccountGroupByIdAud atomicUpdate(
+        AccountGroupByIdAud.Key key, AtomicUpdate<AccountGroupByIdAud> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public AccountGroupByIdAud get(AccountGroupByIdAud.Key key) throws OrmException {
+      return delegate.get(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> byGroupInclude(
+        AccountGroup.Id groupId, AccountGroup.UUID incGroupUUID) throws OrmException {
+      return delegate.byGroupInclude(groupId, incGroupUUID);
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> byGroup(AccountGroup.Id groupId) throws OrmException {
+      return delegate.byGroup(groupId);
+    }
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SchemaVersionAccess.java b/java/com/google/gerrit/reviewdb/server/SchemaVersionAccess.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SchemaVersionAccess.java
rename to java/com/google/gerrit/reviewdb/server/SchemaVersionAccess.java
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SystemConfigAccess.java b/java/com/google/gerrit/reviewdb/server/SystemConfigAccess.java
similarity index 100%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SystemConfigAccess.java
rename to java/com/google/gerrit/reviewdb/server/SystemConfigAccess.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/AccessPath.java b/java/com/google/gerrit/server/AccessPath.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/AccessPath.java
rename to java/com/google/gerrit/server/AccessPath.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/AnonymousUser.java b/java/com/google/gerrit/server/AnonymousUser.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/AnonymousUser.java
rename to java/com/google/gerrit/server/AnonymousUser.java
diff --git a/java/com/google/gerrit/server/ApprovalCopier.java b/java/com/google/gerrit/server/ApprovalCopier.java
new file mode 100644
index 0000000..c1f89e2
--- /dev/null
+++ b/java/com/google/gerrit/server/ApprovalCopier.java
@@ -0,0 +1,264 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Table;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.ChangeKindCache;
+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.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.TreeMap;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Copies approvals between patch sets.
+ *
+ * <p>The result of a copy may either be stored, as when stamping approvals in the database at
+ * submit time, or refreshed on demand, as when reading approvals from the NoteDb.
+ */
+@Singleton
+public class ApprovalCopier {
+  private final ProjectCache projectCache;
+  private final ChangeKindCache changeKindCache;
+  private final LabelNormalizer labelNormalizer;
+  private final ChangeData.Factory changeDataFactory;
+  private final PatchSetUtil psUtil;
+
+  @Inject
+  ApprovalCopier(
+      ProjectCache projectCache,
+      ChangeKindCache changeKindCache,
+      LabelNormalizer labelNormalizer,
+      ChangeData.Factory changeDataFactory,
+      PatchSetUtil psUtil) {
+    this.projectCache = projectCache;
+    this.changeKindCache = changeKindCache;
+    this.labelNormalizer = labelNormalizer;
+    this.changeDataFactory = changeDataFactory;
+    this.psUtil = psUtil;
+  }
+
+  /**
+   * Apply approval copy settings from prior PatchSets to a new PatchSet.
+   *
+   * @param db review database.
+   * @param notes change notes for user uploading PatchSet
+   * @param user user uploading PatchSet
+   * @param ps new PatchSet
+   * @param rw open walk that can read the patch set commit; null to open the repo on demand.
+   * @param repoConfig repo config used for change kind detection; null to read from repo on demand.
+   * @throws OrmException
+   */
+  public void copyInReviewDb(
+      ReviewDb db,
+      ChangeNotes notes,
+      CurrentUser user,
+      PatchSet ps,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig)
+      throws OrmException {
+    copyInReviewDb(db, notes, user, ps, rw, repoConfig, Collections.emptyList());
+  }
+
+  /**
+   * Apply approval copy settings from prior PatchSets to a new PatchSet.
+   *
+   * @param db review database.
+   * @param notes change notes for user uploading PatchSet
+   * @param user user uploading PatchSet
+   * @param ps new PatchSet
+   * @param rw open walk that can read the patch set commit; null to open the repo on demand.
+   * @param repoConfig repo config used for change kind detection; null to read from repo on demand.
+   * @param dontCopy PatchSetApprovals indicating which (account, label) pairs should not be copied
+   * @throws OrmException
+   */
+  public void copyInReviewDb(
+      ReviewDb db,
+      ChangeNotes notes,
+      CurrentUser user,
+      PatchSet ps,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      Iterable<PatchSetApproval> dontCopy)
+      throws OrmException {
+    if (PrimaryStorage.of(notes.getChange()) == PrimaryStorage.REVIEW_DB) {
+      db.patchSetApprovals().insert(getForPatchSet(db, notes, user, ps, rw, repoConfig, dontCopy));
+    }
+  }
+
+  Iterable<PatchSetApproval> getForPatchSet(
+      ReviewDb db,
+      ChangeNotes notes,
+      CurrentUser user,
+      PatchSet.Id psId,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig)
+      throws OrmException {
+    return getForPatchSet(
+        db, notes, user, psId, rw, repoConfig, Collections.<PatchSetApproval>emptyList());
+  }
+
+  Iterable<PatchSetApproval> getForPatchSet(
+      ReviewDb db,
+      ChangeNotes notes,
+      CurrentUser user,
+      PatchSet.Id psId,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      Iterable<PatchSetApproval> dontCopy)
+      throws OrmException {
+    PatchSet ps = psUtil.get(db, notes, psId);
+    if (ps == null) {
+      return Collections.emptyList();
+    }
+    return getForPatchSet(db, notes, user, ps, rw, repoConfig, dontCopy);
+  }
+
+  private Iterable<PatchSetApproval> getForPatchSet(
+      ReviewDb db,
+      ChangeNotes notes,
+      CurrentUser user,
+      PatchSet ps,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      Iterable<PatchSetApproval> dontCopy)
+      throws OrmException {
+    checkNotNull(ps, "ps should not be null");
+    ChangeData cd = changeDataFactory.create(db, notes);
+    try {
+      ProjectState project = projectCache.checkedGet(cd.change().getDest().getParentKey());
+      ListMultimap<PatchSet.Id, PatchSetApproval> all = cd.approvals();
+      checkNotNull(all, "all should not be null");
+
+      Table<String, Account.Id, PatchSetApproval> wontCopy = HashBasedTable.create();
+      for (PatchSetApproval psa : dontCopy) {
+        wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
+      }
+
+      Table<String, Account.Id, PatchSetApproval> byUser = HashBasedTable.create();
+      for (PatchSetApproval psa : all.get(ps.getId())) {
+        if (!wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
+          byUser.put(psa.getLabel(), psa.getAccountId(), psa);
+        }
+      }
+
+      TreeMap<Integer, PatchSet> patchSets = getPatchSets(cd);
+
+      // Walk patch sets strictly less than current in descending order.
+      Collection<PatchSet> allPrior =
+          patchSets.descendingMap().tailMap(ps.getId().get(), false).values();
+      for (PatchSet priorPs : allPrior) {
+        List<PatchSetApproval> priorApprovals = all.get(priorPs.getId());
+        if (priorApprovals.isEmpty()) {
+          continue;
+        }
+
+        ChangeKind kind =
+            changeKindCache.getChangeKind(
+                project.getNameKey(),
+                rw,
+                repoConfig,
+                ObjectId.fromString(priorPs.getRevision().get()),
+                ObjectId.fromString(ps.getRevision().get()));
+
+        for (PatchSetApproval psa : priorApprovals) {
+          if (wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
+            continue;
+          }
+          if (byUser.contains(psa.getLabel(), psa.getAccountId())) {
+            continue;
+          }
+          if (!canCopy(project, psa, ps.getId(), kind)) {
+            wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
+            continue;
+          }
+          byUser.put(psa.getLabel(), psa.getAccountId(), copy(psa, ps.getId()));
+        }
+      }
+      return labelNormalizer.normalize(notes, user, byUser.values()).getNormalized();
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  private static TreeMap<Integer, PatchSet> getPatchSets(ChangeData cd) throws OrmException {
+    Collection<PatchSet> patchSets = cd.patchSets();
+    TreeMap<Integer, PatchSet> result = new TreeMap<>();
+    for (PatchSet ps : patchSets) {
+      result.put(ps.getId().get(), ps);
+    }
+    return result;
+  }
+
+  private static boolean canCopy(
+      ProjectState project, PatchSetApproval psa, PatchSet.Id psId, ChangeKind kind) {
+    int n = psa.getKey().getParentKey().get();
+    checkArgument(n != psId.get());
+    LabelType type = project.getLabelTypes().byLabel(psa.getLabelId());
+    if (type == null) {
+      return false;
+    } else if ((type.isCopyMinScore() && type.isMaxNegative(psa))
+        || (type.isCopyMaxScore() && type.isMaxPositive(psa))) {
+      return true;
+    }
+    switch (kind) {
+      case MERGE_FIRST_PARENT_UPDATE:
+        return type.isCopyAllScoresOnMergeFirstParentUpdate();
+      case NO_CODE_CHANGE:
+        return type.isCopyAllScoresIfNoCodeChange();
+      case TRIVIAL_REBASE:
+        return type.isCopyAllScoresOnTrivialRebase();
+      case NO_CHANGE:
+        return type.isCopyAllScoresIfNoChange()
+            || type.isCopyAllScoresOnTrivialRebase()
+            || type.isCopyAllScoresOnMergeFirstParentUpdate()
+            || type.isCopyAllScoresIfNoCodeChange();
+      case REWORK:
+      default:
+        return false;
+    }
+  }
+
+  private static PatchSetApproval copy(PatchSetApproval src, PatchSet.Id psId) {
+    if (src.getKey().getParentKey().equals(psId)) {
+      return src;
+    }
+    return new PatchSetApproval(psId, src);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java b/java/com/google/gerrit/server/ApprovalsUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
rename to java/com/google/gerrit/server/ApprovalsUtil.java
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
new file mode 100644
index 0000000..fb1fc28
--- /dev/null
+++ b/java/com/google/gerrit/server/BUILD
@@ -0,0 +1,119 @@
+CONSTANTS_SRC = [
+    "documentation/Constants.java",
+]
+
+GERRIT_GLOBAL_MODULE_SRC = [
+    "config/GerritGlobalModule.java",
+]
+
+java_library(
+    name = "constants",
+    srcs = CONSTANTS_SRC,
+    visibility = ["//visibility:public"],
+)
+
+# Giant kitchen-sink target.
+#
+# The only reason this hasn't been split up further is because we have too many
+# tangled dependencies (and Guice unfortunately makes it quite easy to get into
+# this state). Which means if you see an opportunity to split something off, you
+# should seize it.
+java_library(
+    name = "server",
+    srcs = glob(
+        ["**/*.java"],
+        exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC,
+    ),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/server"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":constants",
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/prettify:server",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/util/cli",
+        "//java/com/google/gerrit/util/ssl",
+        "//java/org/apache/commons/net",
+        "//java/org/eclipse/jgit:server",
+        "//lib:args4j",
+        "//lib:automaton",
+        "//lib:blame-cache",
+        "//lib:grappa",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:guava-retrying",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtorm",
+        "//lib:jsch",
+        "//lib:juniversalchardet",
+        "//lib:mime-util",
+        "//lib:pegdown",
+        "//lib:protobuf",
+        "//lib:servlet-api-3_1",
+        "//lib:soy",
+        "//lib:tukaani-xz",
+        "//lib/auto:auto-value",
+        "//lib/bouncycastle:bcpkix-neverlink",
+        "//lib/bouncycastle:bcprov-neverlink",
+        "//lib/commons:codec",
+        "//lib/commons:compress",
+        "//lib/commons:dbcp",
+        "//lib/commons:lang",
+        "//lib/commons:net",
+        "//lib/commons:validator",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jsoup",
+        "//lib/log:api",
+        "//lib/log:jsonevent-layout",
+        "//lib/log:log4j",
+        "//lib/lucene:lucene-analyzers-common",
+        "//lib/lucene:lucene-core-and-backward-codecs",
+        "//lib/lucene:lucene-queryparser",
+        "//lib/mime4j:core",
+        "//lib/mime4j:dom",
+        "//lib/ow2:ow2-asm",
+        "//lib/ow2:ow2-asm-tree",
+        "//lib/ow2:ow2-asm-util",
+        "//lib/prolog:runtime",
+    ],
+)
+
+# Large modules that import things from all across the server package
+# hierarchy, so they need lots of dependencies.
+java_library(
+    name = "module",
+    srcs = GERRIT_GLOBAL_MODULE_SRC,
+    visibility = ["//visibility:public"],
+    deps = [
+        ":server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/restapi",
+        "//lib:blame-cache",
+        "//lib:guava",
+        "//lib:soy",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
+
+load("//tools/bzl:javadoc.bzl", "java_doc")
+
+java_doc(
+    name = "doc",
+    libs = [":server"],
+    pkgs = ["com.google.gerrit"],
+    title = "Gerrit Review Server Documentation",
+)
diff --git a/java/com/google/gerrit/server/ChangeFinder.java b/java/com/google/gerrit/server/ChangeFinder.java
new file mode 100644
index 0000000..cb82778
--- /dev/null
+++ b/java/com/google/gerrit/server/ChangeFinder.java
@@ -0,0 +1,272 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.common.base.Throwables;
+import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.extensions.restapi.DeprecatedIdentifierException;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.change.ChangeTriplet;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class ChangeFinder {
+  private static final String CACHE_NAME = "changeid_project";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        cache(CACHE_NAME, Change.Id.class, String.class).maximumWeight(1024);
+      }
+    };
+  }
+
+  public enum ChangeIdType {
+    ALL,
+    TRIPLET,
+    NUMERIC_ID,
+    I_HASH,
+    PROJECT_NUMERIC_ID,
+    COMMIT_HASH
+  }
+
+  private final IndexConfig indexConfig;
+  private final Cache<Change.Id, String> changeIdProjectCache;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Provider<ReviewDb> reviewDb;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final Counter1<ChangeIdType> changeIdCounter;
+  private final ImmutableSet<ChangeIdType> allowedIdTypes;
+
+  @Inject
+  ChangeFinder(
+      IndexConfig indexConfig,
+      @Named(CACHE_NAME) Cache<Change.Id, String> changeIdProjectCache,
+      Provider<InternalChangeQuery> queryProvider,
+      Provider<ReviewDb> reviewDb,
+      ChangeNotes.Factory changeNotesFactory,
+      MetricMaker metricMaker,
+      @GerritServerConfig Config config) {
+    this.indexConfig = indexConfig;
+    this.changeIdProjectCache = changeIdProjectCache;
+    this.queryProvider = queryProvider;
+    this.reviewDb = reviewDb;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeIdCounter =
+        metricMaker.newCounter(
+            "http/server/rest_api/change_id_type",
+            new Description("Total number of API calls per identifier type.")
+                .setRate()
+                .setUnit("requests"),
+            Field.ofEnum(ChangeIdType.class, "change_id_type"));
+    List<ChangeIdType> configuredChangeIdTypes =
+        ConfigUtil.getEnumList(config, "change", "api", "allowedIdentifier", ChangeIdType.ALL);
+    // Ensure that PROJECT_NUMERIC_ID can't be removed
+    configuredChangeIdTypes.add(ChangeIdType.PROJECT_NUMERIC_ID);
+    this.allowedIdTypes = ImmutableSet.copyOf(configuredChangeIdTypes);
+  }
+
+  /**
+   * Find changes matching the given identifier.
+   *
+   * @param id change identifier.
+   * @return possibly-empty list of notes for all matching changes; may or may not be visible.
+   * @throws OrmException if an error occurred querying the database.
+   */
+  public List<ChangeNotes> find(String id) throws OrmException {
+    try {
+      return find(id, false);
+    } catch (DeprecatedIdentifierException e) {
+      // This can't happen because we don't enforce deprecation
+      throw new OrmException(e);
+    }
+  }
+
+  /**
+   * Find changes matching the given identifier.
+   *
+   * @param id change identifier.
+   * @param enforceDeprecation boolean to see if we should throw {@link
+   *     DeprecatedIdentifierException} in case the identifier is deprecated
+   * @return possibly-empty list of notes for all matching changes; may or may not be visible.
+   * @throws OrmException if an error occurred querying the database
+   * @throws DeprecatedIdentifierException if the identifier is deprecated.
+   */
+  public List<ChangeNotes> find(String id, boolean enforceDeprecation)
+      throws OrmException, DeprecatedIdentifierException {
+    if (id.isEmpty()) {
+      return Collections.emptyList();
+    }
+
+    int z = id.lastIndexOf('~');
+    int y = id.lastIndexOf('~', z - 1);
+    if (y < 0 && z > 0) {
+      // Try project~numericChangeId
+      Integer n = Ints.tryParse(id.substring(z + 1));
+      if (n != null) {
+        checkIdType(ChangeIdType.PROJECT_NUMERIC_ID, enforceDeprecation, n.toString());
+        return fromProjectNumber(id.substring(0, z), n.intValue());
+      }
+    }
+
+    if (y < 0 && z < 0) {
+      // Try numeric changeId
+      Integer n = Ints.tryParse(id);
+      if (n != null) {
+        checkIdType(ChangeIdType.NUMERIC_ID, enforceDeprecation, n.toString());
+        return find(new Change.Id(n));
+      }
+    }
+
+    // Use the index to search for changes, but don't return any stored fields,
+    // to force rereading in case the index is stale.
+    InternalChangeQuery query = queryProvider.get().noFields();
+
+    // Try commit hash
+    if (id.matches("^([0-9a-fA-F]{" + RevId.ABBREV_LEN + "," + RevId.LEN + "})$")) {
+      checkIdType(ChangeIdType.COMMIT_HASH, enforceDeprecation, id);
+      return asChangeNotes(query.byCommit(id));
+    }
+
+    if (y > 0 && z > 0) {
+      // Try change triplet (project~branch~Ihash...)
+      Optional<ChangeTriplet> triplet = ChangeTriplet.parse(id, y, z);
+      if (triplet.isPresent()) {
+        ChangeTriplet t = triplet.get();
+        checkIdType(ChangeIdType.TRIPLET, enforceDeprecation, triplet.get().toString());
+        return asChangeNotes(query.byBranchKey(t.branch(), t.id()));
+      }
+    }
+
+    // Try isolated Ihash... format ("Change-Id: Ihash").
+    List<ChangeNotes> notes = asChangeNotes(query.byKeyPrefix(id));
+    if (!notes.isEmpty()) {
+      checkIdType(ChangeIdType.I_HASH, enforceDeprecation, id);
+    }
+    return notes;
+  }
+
+  private List<ChangeNotes> fromProjectNumber(String project, int changeNumber)
+      throws OrmException {
+    Change.Id cId = new Change.Id(changeNumber);
+    try {
+      return ImmutableList.of(
+          changeNotesFactory.createChecked(reviewDb.get(), Project.NameKey.parse(project), cId));
+    } catch (NoSuchChangeException e) {
+      return Collections.emptyList();
+    } catch (OrmException e) {
+      // Distinguish between a RepositoryNotFoundException (project argument invalid) and
+      // other OrmExceptions (failure in the persistence layer).
+      if (Throwables.getRootCause(e) instanceof RepositoryNotFoundException) {
+        return Collections.emptyList();
+      }
+      throw e;
+    }
+  }
+
+  public ChangeNotes findOne(Change.Id id) throws OrmException {
+    List<ChangeNotes> notes = find(id);
+    if (notes.size() != 1) {
+      throw new NoSuchChangeException(id);
+    }
+    return notes.get(0);
+  }
+
+  public List<ChangeNotes> find(Change.Id id) throws OrmException {
+    String project = changeIdProjectCache.getIfPresent(id);
+    if (project != null) {
+      return fromProjectNumber(project, id.get());
+    }
+
+    // Use the index to search for changes, but don't return any stored fields,
+    // to force rereading in case the index is stale.
+    InternalChangeQuery query = queryProvider.get().noFields();
+    List<ChangeData> r = query.byLegacyChangeId(id);
+    if (r.size() == 1) {
+      changeIdProjectCache.put(id, r.get(0).project().get());
+    }
+    return asChangeNotes(r);
+  }
+
+  private List<ChangeNotes> asChangeNotes(List<ChangeData> cds) throws OrmException {
+    List<ChangeNotes> notes = new ArrayList<>(cds.size());
+    if (!indexConfig.separateChangeSubIndexes()) {
+      for (ChangeData cd : cds) {
+        notes.add(cd.notes());
+      }
+      return notes;
+    }
+
+    // If an index implementation uses separate non-atomic subindexes, it's possible to temporarily
+    // observe a change as present in both subindexes, if this search is concurrent with a write.
+    // Dedup to avoid confusing the caller. We can choose an arbitrary ChangeData instance because
+    // the index results have no stored fields, so the data is already reloaded. (It's also possible
+    // that a change might appear in zero subindexes, but there's nothing we can do here to help
+    // this case.)
+    Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(cds.size());
+    for (ChangeData cd : cds) {
+      if (seen.add(cd.getId())) {
+        notes.add(cd.notes());
+      }
+    }
+    return notes;
+  }
+
+  private void checkIdType(ChangeIdType type, boolean enforceDeprecation, String val)
+      throws DeprecatedIdentifierException {
+    if (enforceDeprecation
+        && !allowedIdTypes.contains(ChangeIdType.ALL)
+        && !allowedIdTypes.contains(type)) {
+      throw new DeprecatedIdentifierException(
+          String.format(
+              "The provided change identifier %s is deprecated. "
+                  + "Use 'project~changeNumber' instead.",
+              val));
+    }
+    changeIdCounter.increment(type);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
rename to java/com/google/gerrit/server/ChangeMessagesUtil.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
rename to java/com/google/gerrit/server/ChangeUtil.java
diff --git a/java/com/google/gerrit/server/CmdLineParserModule.java b/java/com/google/gerrit/server/CmdLineParserModule.java
new file mode 100644
index 0000000..d7f6e30
--- /dev/null
+++ b/java/com/google/gerrit/server/CmdLineParserModule.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.args4j.AccountGroupIdHandler;
+import com.google.gerrit.server.args4j.AccountGroupUUIDHandler;
+import com.google.gerrit.server.args4j.AccountIdHandler;
+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.ProjectHandler;
+import com.google.gerrit.server.args4j.SocketAddressHandler;
+import com.google.gerrit.server.args4j.TimestampHandler;
+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;
+import java.net.SocketAddress;
+import java.sql.Timestamp;
+import org.eclipse.jgit.lib.ObjectId;
+import org.kohsuke.args4j.spi.OptionHandler;
+
+public class CmdLineParserModule extends FactoryModule {
+  public CmdLineParserModule() {}
+
+  @Override
+  protected void configure() {
+    factory(CmdLineParser.Factory.class);
+    bind(OptionHandlers.class);
+
+    registerOptionHandler(Account.Id.class, AccountIdHandler.class);
+    registerOptionHandler(AccountGroup.Id.class, AccountGroupIdHandler.class);
+    registerOptionHandler(AccountGroup.UUID.class, AccountGroupUUIDHandler.class);
+    registerOptionHandler(Change.Id.class, ChangeIdHandler.class);
+    registerOptionHandler(ObjectId.class, ObjectIdHandler.class);
+    registerOptionHandler(PatchSet.Id.class, PatchSetIdHandler.class);
+    registerOptionHandler(ProjectState.class, ProjectHandler.class);
+    registerOptionHandler(SocketAddress.class, SocketAddressHandler.class);
+    registerOptionHandler(Timestamp.class, TimestampHandler.class);
+  }
+
+  private <T> void registerOptionHandler(Class<T> type, Class<? extends OptionHandler<T>> impl) {
+    install(OptionHandlerUtil.moduleFor(type, impl));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
rename to java/com/google/gerrit/server/CommentsUtil.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CommonConverters.java b/java/com/google/gerrit/server/CommonConverters.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/CommonConverters.java
rename to java/com/google/gerrit/server/CommonConverters.java
diff --git a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
new file mode 100644
index 0000000..08278c1
--- /dev/null
+++ b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
@@ -0,0 +1,141 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.extensions.events.ChangeMergedListener;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * With groups in NoteDb, the capability of creating a group is expressed as a {@code CREATE}
+ * permission on {@code refs/groups/*} rather than a global capability in {@code All-Projects}.
+ *
+ * <p>During the transition phase, we have to keep these permissions in sync with the global
+ * capabilities that serve as the source of truth.
+ *
+ * <p><This class implements a one-way synchronization from the the global {@code CREATE_GROUP}
+ * capability in {@code All-Projects} to a {@code CREATE} permission on {@code refs/groups/*} in
+ * {@code All-Users}.
+ */
+@Singleton
+public class CreateGroupPermissionSyncer implements ChangeMergedListener {
+  private static final Logger log = LoggerFactory.getLogger(CreateGroupPermissionSyncer.class);
+
+  private final AllProjectsName allProjects;
+  private final AllUsersName allUsers;
+  private final ProjectCache projectCache;
+  private final Provider<MetaDataUpdate.Server> metaDataUpdateFactory;
+
+  @Inject
+  CreateGroupPermissionSyncer(
+      AllProjectsName allProjects,
+      AllUsersName allUsers,
+      ProjectCache projectCache,
+      Provider<MetaDataUpdate.Server> metaDataUpdateFactory) {
+    this.allProjects = allProjects;
+    this.allUsers = allUsers;
+    this.projectCache = projectCache;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+  }
+
+  /**
+   * Checks if {@code GlobalCapability.CREATE_GROUP} and {@code CREATE} permission on {@code
+   * refs/groups/*} have diverged and syncs them by applying the {@code CREATE} permission to {@code
+   * refs/groups/*}.
+   */
+  public void syncIfNeeded() throws IOException, ConfigInvalidException {
+    ProjectState allProjectsState = projectCache.checkedGet(allProjects);
+    checkNotNull(allProjectsState, "Can't obtain project state for " + allProjects);
+    ProjectState allUsersState = projectCache.checkedGet(allUsers);
+    checkNotNull(allUsersState, "Can't obtain project state for " + allUsers);
+
+    Set<PermissionRule> createGroupsGlobal =
+        new HashSet<>(allProjectsState.getCapabilityCollection().createGroup);
+    Set<PermissionRule> createGroupsRef = new HashSet<>();
+
+    AccessSection allUsersCreateGroupAccessSection =
+        allUsersState.getConfig().getAccessSection(RefNames.REFS_GROUPS + "*");
+    if (allUsersCreateGroupAccessSection != null) {
+      Permission create = allUsersCreateGroupAccessSection.getPermission(Permission.CREATE);
+      if (create != null && create.getRules() != null) {
+        createGroupsRef.addAll(create.getRules());
+      }
+    }
+
+    if (Sets.symmetricDifference(createGroupsGlobal, createGroupsRef).isEmpty()) {
+      // Nothing to sync
+      return;
+    }
+
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsers)) {
+      ProjectConfig config = ProjectConfig.read(md);
+      AccessSection createGroupAccessSection =
+          config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
+      if (createGroupsGlobal.isEmpty()) {
+        createGroupAccessSection.setPermissions(
+            createGroupAccessSection
+                .getPermissions()
+                .stream()
+                .filter(p -> !Permission.CREATE.equals(p.getName()))
+                .collect(toList()));
+        config.replace(createGroupAccessSection);
+      } else {
+        Permission createGroupPermission = new Permission(Permission.CREATE);
+        createGroupAccessSection.addPermission(createGroupPermission);
+        createGroupsGlobal.forEach(pr -> createGroupPermission.add(pr));
+        // The create permission is managed by Gerrit at this point only so there is no concern of
+        // overwriting user-defined permissions here.
+        config.replace(createGroupAccessSection);
+      }
+
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
+  }
+
+  @Override
+  public void onChangeMerged(Event event) {
+    if (!allProjects.get().equals(event.getChange().project)
+        || !RefNames.REFS_CONFIG.equals(event.getChange().branch)) {
+      return;
+    }
+    try {
+      syncIfNeeded();
+    } catch (IOException | ConfigInvalidException e) {
+      log.error("Can't sync create group permissions", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
rename to java/com/google/gerrit/server/CurrentUser.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/DynamicOptions.java b/java/com/google/gerrit/server/DynamicOptions.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/DynamicOptions.java
rename to java/com/google/gerrit/server/DynamicOptions.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/EnableSignedPush.java b/java/com/google/gerrit/server/EnableSignedPush.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/EnableSignedPush.java
rename to java/com/google/gerrit/server/EnableSignedPush.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdent.java b/java/com/google/gerrit/server/GerritPersonIdent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdent.java
rename to java/com/google/gerrit/server/GerritPersonIdent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdentProvider.java b/java/com/google/gerrit/server/GerritPersonIdentProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdentProvider.java
rename to java/com/google/gerrit/server/GerritPersonIdentProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/GpgException.java b/java/com/google/gerrit/server/GpgException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/GpgException.java
rename to java/com/google/gerrit/server/GpgException.java
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
new file mode 100644
index 0000000..2374b71
--- /dev/null
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -0,0 +1,517 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.ListGroupMembership;
+import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.DisableReverseDnsLookup;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
+import com.google.inject.OutOfScopeException;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.google.inject.util.Providers;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.MalformedURLException;
+import java.net.SocketAddress;
+import java.net.URL;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.util.SystemReader;
+
+/** An authenticated user. */
+public class IdentifiedUser extends CurrentUser {
+  /** Create an IdentifiedUser, ignoring any per-request state. */
+  @Singleton
+  public static class GenericFactory {
+    private final AuthConfig authConfig;
+    private final Realm realm;
+    private final String anonymousCowardName;
+    private final Provider<String> canonicalUrl;
+    private final AccountCache accountCache;
+    private final GroupBackend groupBackend;
+    private final Boolean disableReverseDnsLookup;
+
+    @Inject
+    public GenericFactory(
+        AuthConfig authConfig,
+        Realm realm,
+        @AnonymousCowardName String anonymousCowardName,
+        @CanonicalWebUrl Provider<String> canonicalUrl,
+        @DisableReverseDnsLookup Boolean disableReverseDnsLookup,
+        AccountCache accountCache,
+        GroupBackend groupBackend) {
+      this.authConfig = authConfig;
+      this.realm = realm;
+      this.anonymousCowardName = anonymousCowardName;
+      this.canonicalUrl = canonicalUrl;
+      this.accountCache = accountCache;
+      this.groupBackend = groupBackend;
+      this.disableReverseDnsLookup = disableReverseDnsLookup;
+    }
+
+    public IdentifiedUser create(AccountState state) {
+      return new IdentifiedUser(
+          authConfig,
+          realm,
+          anonymousCowardName,
+          canonicalUrl,
+          accountCache,
+          groupBackend,
+          disableReverseDnsLookup,
+          Providers.of((SocketAddress) null),
+          state,
+          null);
+    }
+
+    public IdentifiedUser create(Account.Id id) {
+      return create((SocketAddress) null, id);
+    }
+
+    public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
+      return runAs(remotePeer, id, null);
+    }
+
+    public IdentifiedUser runAs(
+        SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+      return new IdentifiedUser(
+          authConfig,
+          realm,
+          anonymousCowardName,
+          canonicalUrl,
+          accountCache,
+          groupBackend,
+          disableReverseDnsLookup,
+          Providers.of(remotePeer),
+          id,
+          caller);
+    }
+  }
+
+  /**
+   * Create an IdentifiedUser, relying on current request state.
+   *
+   * <p>Can only be used from within a module that has defined request scoped {@code @RemotePeer
+   * SocketAddress} and {@code ReviewDb} providers.
+   */
+  @Singleton
+  public static class RequestFactory {
+    private final AuthConfig authConfig;
+    private final Realm realm;
+    private final String anonymousCowardName;
+    private final Provider<String> canonicalUrl;
+    private final AccountCache accountCache;
+    private final GroupBackend groupBackend;
+    private final Boolean disableReverseDnsLookup;
+    private final Provider<SocketAddress> remotePeerProvider;
+
+    @Inject
+    RequestFactory(
+        AuthConfig authConfig,
+        Realm realm,
+        @AnonymousCowardName String anonymousCowardName,
+        @CanonicalWebUrl Provider<String> canonicalUrl,
+        AccountCache accountCache,
+        GroupBackend groupBackend,
+        @DisableReverseDnsLookup Boolean disableReverseDnsLookup,
+        @RemotePeer Provider<SocketAddress> remotePeerProvider) {
+      this.authConfig = authConfig;
+      this.realm = realm;
+      this.anonymousCowardName = anonymousCowardName;
+      this.canonicalUrl = canonicalUrl;
+      this.accountCache = accountCache;
+      this.groupBackend = groupBackend;
+      this.disableReverseDnsLookup = disableReverseDnsLookup;
+      this.remotePeerProvider = remotePeerProvider;
+    }
+
+    public IdentifiedUser create(Account.Id id) {
+      return new IdentifiedUser(
+          authConfig,
+          realm,
+          anonymousCowardName,
+          canonicalUrl,
+          accountCache,
+          groupBackend,
+          disableReverseDnsLookup,
+          remotePeerProvider,
+          id,
+          null);
+    }
+
+    public IdentifiedUser runAs(Account.Id id, CurrentUser caller) {
+      return new IdentifiedUser(
+          authConfig,
+          realm,
+          anonymousCowardName,
+          canonicalUrl,
+          accountCache,
+          groupBackend,
+          disableReverseDnsLookup,
+          remotePeerProvider,
+          id,
+          caller);
+    }
+  }
+
+  private static final GroupMembership registeredGroups =
+      new ListGroupMembership(
+          ImmutableSet.of(SystemGroupBackend.ANONYMOUS_USERS, SystemGroupBackend.REGISTERED_USERS));
+
+  private final Provider<String> canonicalUrl;
+  private final AccountCache accountCache;
+  private final AuthConfig authConfig;
+  private final Realm realm;
+  private final GroupBackend groupBackend;
+  private final String anonymousCowardName;
+  private final Boolean disableReverseDnsLookup;
+  private final Set<String> validEmails = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
+  private final CurrentUser realUser; // Must be final since cached properties depend on it.
+
+  private final Provider<SocketAddress> remotePeerProvider;
+  private final Account.Id accountId;
+
+  private AccountState state;
+  private boolean loadedAllEmails;
+  private Set<String> invalidEmails;
+  private GroupMembership effectiveGroups;
+  private Map<PropertyKey<Object>, Object> properties;
+
+  private IdentifiedUser(
+      AuthConfig authConfig,
+      Realm realm,
+      String anonymousCowardName,
+      Provider<String> canonicalUrl,
+      AccountCache accountCache,
+      GroupBackend groupBackend,
+      Boolean disableReverseDnsLookup,
+      @Nullable Provider<SocketAddress> remotePeerProvider,
+      AccountState state,
+      @Nullable CurrentUser realUser) {
+    this(
+        authConfig,
+        realm,
+        anonymousCowardName,
+        canonicalUrl,
+        accountCache,
+        groupBackend,
+        disableReverseDnsLookup,
+        remotePeerProvider,
+        state.getAccount().getId(),
+        realUser);
+    this.state = state;
+  }
+
+  private IdentifiedUser(
+      AuthConfig authConfig,
+      Realm realm,
+      String anonymousCowardName,
+      Provider<String> canonicalUrl,
+      AccountCache accountCache,
+      GroupBackend groupBackend,
+      Boolean disableReverseDnsLookup,
+      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Account.Id id,
+      @Nullable CurrentUser realUser) {
+    this.canonicalUrl = canonicalUrl;
+    this.accountCache = accountCache;
+    this.groupBackend = groupBackend;
+    this.authConfig = authConfig;
+    this.realm = realm;
+    this.anonymousCowardName = anonymousCowardName;
+    this.disableReverseDnsLookup = disableReverseDnsLookup;
+    this.remotePeerProvider = remotePeerProvider;
+    this.accountId = id;
+    this.realUser = realUser != null ? realUser : this;
+  }
+
+  @Override
+  public CurrentUser getRealUser() {
+    return realUser;
+  }
+
+  @Override
+  public boolean isImpersonating() {
+    if (realUser == this) {
+      return false;
+    }
+    if (realUser.isIdentifiedUser()) {
+      if (realUser.getAccountId().equals(getAccountId())) {
+        // Impersonating another copy of this user is allowed.
+        return false;
+      }
+    }
+    return true;
+  }
+
+  public AccountState state() {
+    if (state == null) {
+      state = accountCache.get(getAccountId());
+    }
+    return state;
+  }
+
+  @Override
+  public IdentifiedUser asIdentifiedUser() {
+    return this;
+  }
+
+  @Override
+  public Account.Id getAccountId() {
+    return accountId;
+  }
+
+  /** @return the user's user name; null if one has not been selected/assigned. */
+  @Override
+  public String getUserName() {
+    return state().getUserName();
+  }
+
+  public Account getAccount() {
+    return state().getAccount();
+  }
+
+  public boolean hasEmailAddress(String email) {
+    if (validEmails.contains(email)) {
+      return true;
+    } else if (invalidEmails != null && invalidEmails.contains(email)) {
+      return false;
+    } else if (realm.hasEmailAddress(this, email)) {
+      validEmails.add(email);
+      return true;
+    } else if (invalidEmails == null) {
+      invalidEmails = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
+    }
+    invalidEmails.add(email);
+    return false;
+  }
+
+  public Set<String> getEmailAddresses() {
+    if (!loadedAllEmails) {
+      validEmails.addAll(realm.getEmailAddresses(this));
+      loadedAllEmails = true;
+    }
+    return validEmails;
+  }
+
+  public String getName() {
+    return getAccount().getName();
+  }
+
+  public String getNameEmail() {
+    return getAccount().getNameEmail(anonymousCowardName);
+  }
+
+  @Override
+  public GroupMembership getEffectiveGroups() {
+    if (effectiveGroups == null) {
+      if (authConfig.isIdentityTrustable(state().getExternalIds())) {
+        effectiveGroups = groupBackend.membershipsOf(this);
+      } else {
+        effectiveGroups = registeredGroups;
+      }
+    }
+    return effectiveGroups;
+  }
+
+  public PersonIdent newRefLogIdent() {
+    return newRefLogIdent(new Date(), TimeZone.getDefault());
+  }
+
+  public PersonIdent newRefLogIdent(Date when, TimeZone tz) {
+    final Account ua = getAccount();
+
+    String name = ua.getFullName();
+    if (name == null || name.isEmpty()) {
+      name = ua.getPreferredEmail();
+    }
+    if (name == null || name.isEmpty()) {
+      name = anonymousCowardName;
+    }
+
+    String user = getUserName();
+    if (user == null) {
+      user = "";
+    }
+    user = user + "|account-" + ua.getId().toString();
+
+    return new PersonIdent(name, user + "@" + guessHost(), when, tz);
+  }
+
+  public PersonIdent newCommitterIdent(Date when, TimeZone tz) {
+    final Account ua = getAccount();
+    String name = ua.getFullName();
+    String email = ua.getPreferredEmail();
+
+    if (email == null || email.isEmpty()) {
+      // No preferred email is configured. Use a generic identity so we
+      // don't leak an address the user may have given us, but doesn't
+      // necessarily want to publish through Git records.
+      //
+      String user = getUserName();
+      if (user == null || user.isEmpty()) {
+        user = "account-" + ua.getId().toString();
+      }
+
+      String host;
+      if (canonicalUrl.get() != null) {
+        try {
+          host = new URL(canonicalUrl.get()).getHost();
+        } catch (MalformedURLException e) {
+          host = SystemReader.getInstance().getHostname();
+        }
+      } else {
+        host = SystemReader.getInstance().getHostname();
+      }
+
+      email = user + "@" + host;
+    }
+
+    if (name == null || name.isEmpty()) {
+      final int at = email.indexOf('@');
+      if (0 < at) {
+        name = email.substring(0, at);
+      } else {
+        name = anonymousCowardName;
+      }
+    }
+
+    return new PersonIdent(name, email, when, tz);
+  }
+
+  @Override
+  public String toString() {
+    return "IdentifiedUser[account " + getAccountId() + "]";
+  }
+
+  /** Check if user is the IdentifiedUser */
+  @Override
+  public boolean isIdentifiedUser() {
+    return true;
+  }
+
+  @Override
+  @Nullable
+  public synchronized <T> T get(PropertyKey<T> key) {
+    if (properties != null) {
+      @SuppressWarnings("unchecked")
+      T value = (T) properties.get(key);
+      return value;
+    }
+    return null;
+  }
+
+  /**
+   * Store a property for later retrieval.
+   *
+   * @param key unique property key.
+   * @param value value to store; or {@code null} to clear the value.
+   */
+  @Override
+  public synchronized <T> void put(PropertyKey<T> key, @Nullable T value) {
+    if (properties == null) {
+      if (value == null) {
+        return;
+      }
+      properties = new HashMap<>();
+    }
+
+    @SuppressWarnings("unchecked")
+    PropertyKey<Object> k = (PropertyKey<Object>) key;
+    if (value != null) {
+      properties.put(k, value);
+    } else {
+      properties.remove(k);
+    }
+  }
+
+  /**
+   * Returns a materialized copy of the user with all dependencies.
+   *
+   * <p>Invoke all providers and factories of dependent objects and store the references to a copy
+   * of the current identified user.
+   *
+   * @return copy of the identified user
+   */
+  public IdentifiedUser materializedCopy() {
+    Provider<SocketAddress> remotePeer;
+    try {
+      remotePeer = Providers.of(remotePeerProvider.get());
+    } catch (OutOfScopeException | ProvisionException e) {
+      remotePeer =
+          new Provider<SocketAddress>() {
+            @Override
+            public SocketAddress get() {
+              throw e;
+            }
+          };
+    }
+    return new IdentifiedUser(
+        authConfig,
+        realm,
+        anonymousCowardName,
+        Providers.of(canonicalUrl.get()),
+        accountCache,
+        groupBackend,
+        disableReverseDnsLookup,
+        remotePeer,
+        state,
+        realUser);
+  }
+
+  private String guessHost() {
+    String host = null;
+    SocketAddress remotePeer = null;
+    try {
+      remotePeer = remotePeerProvider.get();
+    } catch (OutOfScopeException | ProvisionException e) {
+      // Leave null.
+    }
+    if (remotePeer instanceof InetSocketAddress) {
+      InetSocketAddress sa = (InetSocketAddress) remotePeer;
+      InetAddress in = sa.getAddress();
+      host = in != null ? getHost(in) : sa.getHostName();
+    }
+    if (Strings.isNullOrEmpty(host)) {
+      return "unknown";
+    }
+    return host;
+  }
+
+  private String getHost(InetAddress in) {
+    if (Boolean.FALSE.equals(disableReverseDnsLookup)) {
+      return in.getCanonicalHostName();
+    }
+    return in.getHostAddress();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/InternalUser.java b/java/com/google/gerrit/server/InternalUser.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/InternalUser.java
rename to java/com/google/gerrit/server/InternalUser.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/LibModuleLoader.java b/java/com/google/gerrit/server/LibModuleLoader.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/LibModuleLoader.java
rename to java/com/google/gerrit/server/LibModuleLoader.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/OptionUtil.java b/java/com/google/gerrit/server/OptionUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/OptionUtil.java
rename to java/com/google/gerrit/server/OptionUtil.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/OutputFormat.java b/java/com/google/gerrit/server/OutputFormat.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/OutputFormat.java
rename to java/com/google/gerrit/server/OutputFormat.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
rename to java/com/google/gerrit/server/PatchSetUtil.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PeerDaemonUser.java b/java/com/google/gerrit/server/PeerDaemonUser.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/PeerDaemonUser.java
rename to java/com/google/gerrit/server/PeerDaemonUser.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PluginUser.java b/java/com/google/gerrit/server/PluginUser.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/PluginUser.java
rename to java/com/google/gerrit/server/PluginUser.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ProjectUtil.java b/java/com/google/gerrit/server/ProjectUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ProjectUtil.java
rename to java/com/google/gerrit/server/ProjectUtil.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/RemotePeer.java b/java/com/google/gerrit/server/RemotePeer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/RemotePeer.java
rename to java/com/google/gerrit/server/RemotePeer.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/RequestCleanup.java b/java/com/google/gerrit/server/RequestCleanup.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/RequestCleanup.java
rename to java/com/google/gerrit/server/RequestCleanup.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerByEmailSet.java b/java/com/google/gerrit/server/ReviewerByEmailSet.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ReviewerByEmailSet.java
rename to java/com/google/gerrit/server/ReviewerByEmailSet.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerSet.java b/java/com/google/gerrit/server/ReviewerSet.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ReviewerSet.java
rename to java/com/google/gerrit/server/ReviewerSet.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerStatusUpdate.java b/java/com/google/gerrit/server/ReviewerStatusUpdate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ReviewerStatusUpdate.java
rename to java/com/google/gerrit/server/ReviewerStatusUpdate.java
diff --git a/java/com/google/gerrit/server/Sequences.java b/java/com/google/gerrit/server/Sequences.java
new file mode 100644
index 0000000..fcf0759
--- /dev/null
+++ b/java/com/google/gerrit/server/Sequences.java
@@ -0,0 +1,166 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer2;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.RepoSequence;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class Sequences {
+  public static final String NAME_ACCOUNTS = "accounts";
+  public static final String NAME_GROUPS = "groups";
+  public static final String NAME_CHANGES = "changes";
+
+  public static int getChangeSequenceGap(Config cfg) {
+    return cfg.getInt("noteDb", "changes", "initialSequenceGap", 1000);
+  }
+
+  private enum SequenceType {
+    ACCOUNTS,
+    CHANGES,
+    GROUPS;
+  }
+
+  private final Provider<ReviewDb> db;
+  private final NotesMigration migration;
+  private final RepoSequence accountSeq;
+  private final RepoSequence changeSeq;
+  private final RepoSequence groupSeq;
+  private final Timer2<SequenceType, Boolean> nextIdLatency;
+
+  @Inject
+  public Sequences(
+      @GerritServerConfig Config cfg,
+      Provider<ReviewDb> db,
+      NotesMigration migration,
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      AllProjectsName allProjects,
+      AllUsersName allUsers,
+      MetricMaker metrics) {
+    this.db = db;
+    this.migration = migration;
+
+    int accountBatchSize = cfg.getInt("noteDb", "accounts", "sequenceBatchSize", 1);
+    accountSeq =
+        new RepoSequence(
+            repoManager,
+            gitRefUpdated,
+            allUsers,
+            NAME_ACCOUNTS,
+            () -> ReviewDb.FIRST_ACCOUNT_ID,
+            accountBatchSize);
+
+    int gap = getChangeSequenceGap(cfg);
+    @SuppressWarnings("deprecation")
+    RepoSequence.Seed changeSeed = () -> db.get().nextChangeId() + gap;
+    int changeBatchSize = cfg.getInt("noteDb", "changes", "sequenceBatchSize", 20);
+    changeSeq =
+        new RepoSequence(
+            repoManager, gitRefUpdated, allProjects, NAME_CHANGES, changeSeed, changeBatchSize);
+
+    RepoSequence.Seed groupSeed = () -> nextGroupId(db.get());
+    int groupBatchSize = 1;
+    groupSeq =
+        new RepoSequence(
+            repoManager, gitRefUpdated, allUsers, NAME_GROUPS, groupSeed, groupBatchSize);
+
+    nextIdLatency =
+        metrics.newTimer(
+            "sequence/next_id_latency",
+            new Description("Latency of requesting IDs from repo sequences")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS),
+            Field.ofEnum(SequenceType.class, "sequence"),
+            Field.ofBoolean("multiple"));
+  }
+
+  public int nextAccountId() throws OrmException {
+    try (Timer2.Context timer = nextIdLatency.start(SequenceType.ACCOUNTS, false)) {
+      return accountSeq.next();
+    }
+  }
+
+  public int nextChangeId() throws OrmException {
+    if (!migration.readChangeSequence()) {
+      return nextChangeId(db.get());
+    }
+    try (Timer2.Context timer = nextIdLatency.start(SequenceType.CHANGES, false)) {
+      return changeSeq.next();
+    }
+  }
+
+  public ImmutableList<Integer> nextChangeIds(int count) throws OrmException {
+    if (migration.readChangeSequence()) {
+      try (Timer2.Context timer = nextIdLatency.start(SequenceType.CHANGES, count > 1)) {
+        return changeSeq.next(count);
+      }
+    }
+
+    if (count == 0) {
+      return ImmutableList.of();
+    }
+    checkArgument(count > 0, "count is negative: %s", count);
+    List<Integer> ids = new ArrayList<>(count);
+    ReviewDb db = this.db.get();
+    for (int i = 0; i < count; i++) {
+      ids.add(nextChangeId(db));
+    }
+    return ImmutableList.copyOf(ids);
+  }
+
+  public int nextGroupId() throws OrmException {
+    try (Timer2.Context timer = nextIdLatency.start(SequenceType.GROUPS, false)) {
+      return groupSeq.next();
+    }
+  }
+
+  @VisibleForTesting
+  public RepoSequence getChangeIdRepoSequence() {
+    return changeSeq;
+  }
+
+  @SuppressWarnings("deprecation")
+  private static int nextChangeId(ReviewDb db) throws OrmException {
+    return db.nextChangeId();
+  }
+
+  @SuppressWarnings("deprecation")
+  static int nextGroupId(ReviewDb db) throws OrmException {
+    return db.nextAccountGroupId();
+  }
+}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
new file mode 100644
index 0000000..e2c08ce
--- /dev/null
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -0,0 +1,508 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Sets;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class StarredChangesUtil {
+  @AutoValue
+  public abstract static class StarField {
+    private static final String SEPARATOR = ":";
+
+    public static StarField parse(String s) {
+      int p = s.indexOf(SEPARATOR);
+      if (p >= 0) {
+        Integer id = Ints.tryParse(s.substring(0, p));
+        if (id == null) {
+          return null;
+        }
+        Account.Id accountId = new Account.Id(id);
+        String label = s.substring(p + 1);
+        return create(accountId, label);
+      }
+      return null;
+    }
+
+    public static StarField create(Account.Id accountId, String label) {
+      return new AutoValue_StarredChangesUtil_StarField(accountId, label);
+    }
+
+    public abstract Account.Id accountId();
+
+    public abstract String label();
+
+    @Override
+    public String toString() {
+      return accountId() + SEPARATOR + label();
+    }
+  }
+
+  @AutoValue
+  public abstract static class StarRef {
+    private static final StarRef MISSING =
+        new AutoValue_StarredChangesUtil_StarRef(null, ImmutableSortedSet.of());
+
+    private static StarRef create(Ref ref, Iterable<String> labels) {
+      return new AutoValue_StarredChangesUtil_StarRef(
+          checkNotNull(ref), ImmutableSortedSet.copyOf(labels));
+    }
+
+    @Nullable
+    public abstract Ref ref();
+
+    public abstract ImmutableSortedSet<String> labels();
+
+    public ObjectId objectId() {
+      return ref() != null ? ref().getObjectId() : ObjectId.zeroId();
+    }
+  }
+
+  public static class IllegalLabelException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    IllegalLabelException(String message) {
+      super(message);
+    }
+  }
+
+  public static class InvalidLabelsException extends IllegalLabelException {
+    private static final long serialVersionUID = 1L;
+
+    InvalidLabelsException(Set<String> invalidLabels) {
+      super(String.format("invalid labels: %s", Joiner.on(", ").join(invalidLabels)));
+    }
+  }
+
+  public static class MutuallyExclusiveLabelsException extends IllegalLabelException {
+    private static final long serialVersionUID = 1L;
+
+    MutuallyExclusiveLabelsException(String label1, String label2) {
+      super(
+          String.format(
+              "The labels %s and %s are mutually exclusive. Only one of them can be set.",
+              label1, label2));
+    }
+  }
+
+  private static final Logger log = LoggerFactory.getLogger(StarredChangesUtil.class);
+
+  public static final String DEFAULT_LABEL = "star";
+  public static final String IGNORE_LABEL = "ignore";
+  public static final String REVIEWED_LABEL = "reviewed";
+  public static final String UNREVIEWED_LABEL = "unreviewed";
+  public static final ImmutableSortedSet<String> DEFAULT_LABELS =
+      ImmutableSortedSet.of(DEFAULT_LABEL);
+
+  private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final AllUsersName allUsers;
+  private final Provider<ReviewDb> dbProvider;
+  private final PersonIdent serverIdent;
+  private final ChangeIndexer indexer;
+  private final Provider<InternalChangeQuery> queryProvider;
+
+  @Inject
+  StarredChangesUtil(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      AllUsersName allUsers,
+      Provider<ReviewDb> dbProvider,
+      @GerritPersonIdent PersonIdent serverIdent,
+      ChangeIndexer indexer,
+      Provider<InternalChangeQuery> queryProvider) {
+    this.repoManager = repoManager;
+    this.gitRefUpdated = gitRefUpdated;
+    this.allUsers = allUsers;
+    this.dbProvider = dbProvider;
+    this.serverIdent = serverIdent;
+    this.indexer = indexer;
+    this.queryProvider = queryProvider;
+  }
+
+  public ImmutableSortedSet<String> getLabels(Account.Id accountId, Change.Id changeId)
+      throws OrmException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)).labels();
+    } catch (IOException e) {
+      throw new OrmException(
+          String.format(
+              "Reading stars from change %d for account %d failed",
+              changeId.get(), accountId.get()),
+          e);
+    }
+  }
+
+  public ImmutableSortedSet<String> star(
+      Account.Id accountId,
+      Project.NameKey project,
+      Change.Id changeId,
+      Set<String> labelsToAdd,
+      Set<String> labelsToRemove)
+      throws OrmException, IllegalLabelException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      String refName = RefNames.refsStarredChanges(changeId, accountId);
+      StarRef old = readLabels(repo, refName);
+
+      Set<String> labels = new HashSet<>(old.labels());
+      if (labelsToAdd != null) {
+        labels.addAll(labelsToAdd);
+      }
+      if (labelsToRemove != null) {
+        labels.removeAll(labelsToRemove);
+      }
+
+      if (labels.isEmpty()) {
+        deleteRef(repo, refName, old.objectId());
+      } else {
+        checkMutuallyExclusiveLabels(labels);
+        updateLabels(repo, refName, old.objectId(), labels);
+      }
+
+      indexer.index(dbProvider.get(), project, changeId);
+      return ImmutableSortedSet.copyOf(labels);
+    } catch (IOException e) {
+      throw new OrmException(
+          String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
+          e);
+    }
+  }
+
+  public void unstarAll(Project.NameKey project, Change.Id changeId) throws OrmException {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
+      batchUpdate.setAllowNonFastForwards(true);
+      batchUpdate.setRefLogIdent(serverIdent);
+      batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
+      for (Account.Id accountId : byChangeFromIndex(changeId).keySet()) {
+        String refName = RefNames.refsStarredChanges(changeId, accountId);
+        Ref ref = repo.getRefDatabase().getRef(refName);
+        batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), refName));
+      }
+      batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
+      for (ReceiveCommand command : batchUpdate.getCommands()) {
+        if (command.getResult() != ReceiveCommand.Result.OK) {
+          throw new IOException(
+              String.format(
+                  "Unstar change %d failed, ref %s could not be deleted: %s",
+                  changeId.get(), command.getRefName(), command.getResult()));
+        }
+      }
+      indexer.index(dbProvider.get(), project, changeId);
+    } catch (IOException e) {
+      throw new OrmException(String.format("Unstar change %d failed", changeId.get()), e);
+    }
+  }
+
+  public ImmutableMap<Account.Id, StarRef> byChange(Change.Id changeId) throws OrmException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      ImmutableMap.Builder<Account.Id, StarRef> builder = ImmutableMap.builder();
+      for (String refPart : getRefNames(repo, RefNames.refsStarredChangesPrefix(changeId))) {
+        Integer id = Ints.tryParse(refPart);
+        if (id == null) {
+          continue;
+        }
+        Account.Id accountId = new Account.Id(id);
+        builder.put(accountId, readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
+      }
+      return builder.build();
+    } catch (IOException e) {
+      throw new OrmException(
+          String.format("Get accounts that starred change %d failed", changeId.get()), e);
+    }
+  }
+
+  public ImmutableListMultimap<Account.Id, String> byChangeFromIndex(Change.Id changeId)
+      throws OrmException {
+    List<ChangeData> changeData =
+        queryProvider
+            .get()
+            .setRequestedFields(ChangeField.ID, ChangeField.STAR)
+            .byLegacyChangeId(changeId);
+    if (changeData.size() != 1) {
+      throw new NoSuchChangeException(changeId);
+    }
+    return changeData.get(0).stars();
+  }
+
+  private static Set<String> getRefNames(Repository repo, String prefix) throws IOException {
+    RefDatabase refDb = repo.getRefDatabase();
+    return refDb.getRefs(prefix).keySet();
+  }
+
+  public ObjectId getObjectId(Account.Id accountId, Change.Id changeId) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      Ref ref = repo.exactRef(RefNames.refsStarredChanges(changeId, accountId));
+      return ref != null ? ref.getObjectId() : ObjectId.zeroId();
+    } catch (IOException e) {
+      log.error(
+          String.format(
+              "Getting star object ID for account %d on change %d failed",
+              accountId.get(), changeId.get()),
+          e);
+      return ObjectId.zeroId();
+    }
+  }
+
+  public void ignore(ChangeResource rsrc) throws OrmException, IllegalLabelException {
+    star(
+        rsrc.getUser().asIdentifiedUser().getAccountId(),
+        rsrc.getProject(),
+        rsrc.getChange().getId(),
+        ImmutableSet.of(IGNORE_LABEL),
+        ImmutableSet.of());
+  }
+
+  public void unignore(ChangeResource rsrc) throws OrmException, IllegalLabelException {
+    star(
+        rsrc.getUser().asIdentifiedUser().getAccountId(),
+        rsrc.getProject(),
+        rsrc.getChange().getId(),
+        ImmutableSet.of(),
+        ImmutableSet.of(IGNORE_LABEL));
+  }
+
+  public boolean isIgnoredBy(Change.Id changeId, Account.Id accountId) throws OrmException {
+    return getLabels(accountId, changeId).contains(IGNORE_LABEL);
+  }
+
+  public boolean isIgnored(ChangeResource rsrc) throws OrmException {
+    return isIgnoredBy(rsrc.getChange().getId(), rsrc.getUser().asIdentifiedUser().getAccountId());
+  }
+
+  private static String getReviewedLabel(Change change) {
+    return getReviewedLabel(change.currentPatchSetId().get());
+  }
+
+  private static String getReviewedLabel(int ps) {
+    return REVIEWED_LABEL + "/" + ps;
+  }
+
+  private static String getUnreviewedLabel(Change change) {
+    return getUnreviewedLabel(change.currentPatchSetId().get());
+  }
+
+  private static String getUnreviewedLabel(int ps) {
+    return UNREVIEWED_LABEL + "/" + ps;
+  }
+
+  public void markAsReviewed(ChangeResource rsrc) throws OrmException, IllegalLabelException {
+    star(
+        rsrc.getUser().asIdentifiedUser().getAccountId(),
+        rsrc.getProject(),
+        rsrc.getChange().getId(),
+        ImmutableSet.of(getReviewedLabel(rsrc.getChange())),
+        ImmutableSet.of(getUnreviewedLabel(rsrc.getChange())));
+  }
+
+  public void markAsUnreviewed(ChangeResource rsrc) throws OrmException, IllegalLabelException {
+    star(
+        rsrc.getUser().asIdentifiedUser().getAccountId(),
+        rsrc.getProject(),
+        rsrc.getChange().getId(),
+        ImmutableSet.of(getUnreviewedLabel(rsrc.getChange())),
+        ImmutableSet.of(getReviewedLabel(rsrc.getChange())));
+  }
+
+  public static StarRef readLabels(Repository repo, String refName) throws IOException {
+    Ref ref = repo.exactRef(refName);
+    if (ref == null) {
+      return StarRef.MISSING;
+    }
+
+    try (ObjectReader reader = repo.newObjectReader()) {
+      ObjectLoader obj = reader.open(ref.getObjectId(), Constants.OBJ_BLOB);
+      return StarRef.create(
+          ref,
+          Splitter.on(CharMatcher.whitespace())
+              .omitEmptyStrings()
+              .split(new String(obj.getCachedBytes(Integer.MAX_VALUE), UTF_8)));
+    }
+  }
+
+  public static ObjectId writeLabels(Repository repo, Collection<String> labels)
+      throws IOException, InvalidLabelsException {
+    validateLabels(labels);
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      ObjectId id =
+          oi.insert(
+              Constants.OBJ_BLOB,
+              labels.stream().sorted().distinct().collect(joining("\n")).getBytes(UTF_8));
+      oi.flush();
+      return id;
+    }
+  }
+
+  private static void checkMutuallyExclusiveLabels(Set<String> labels)
+      throws MutuallyExclusiveLabelsException {
+    if (labels.containsAll(ImmutableSet.of(DEFAULT_LABEL, IGNORE_LABEL))) {
+      throw new MutuallyExclusiveLabelsException(DEFAULT_LABEL, IGNORE_LABEL);
+    }
+
+    Set<Integer> reviewedPatchSets = getStarredPatchSets(labels, REVIEWED_LABEL);
+    Set<Integer> unreviewedPatchSets = getStarredPatchSets(labels, UNREVIEWED_LABEL);
+    Optional<Integer> ps =
+        Sets.intersection(reviewedPatchSets, unreviewedPatchSets).stream().findFirst();
+    if (ps.isPresent()) {
+      throw new MutuallyExclusiveLabelsException(
+          getReviewedLabel(ps.get()), getUnreviewedLabel(ps.get()));
+    }
+  }
+
+  public static Set<Integer> getStarredPatchSets(Set<String> labels, String label) {
+    return labels
+        .stream()
+        .filter(l -> l.startsWith(label + "/"))
+        .filter(l -> Ints.tryParse(l.substring(label.length() + 1)) != null)
+        .map(l -> Integer.valueOf(l.substring(label.length() + 1)))
+        .collect(toSet());
+  }
+
+  private static void validateLabels(Collection<String> labels) throws InvalidLabelsException {
+    if (labels == null) {
+      return;
+    }
+
+    SortedSet<String> invalidLabels = new TreeSet<>();
+    for (String label : labels) {
+      if (CharMatcher.whitespace().matchesAnyOf(label)) {
+        invalidLabels.add(label);
+      }
+    }
+    if (!invalidLabels.isEmpty()) {
+      throw new InvalidLabelsException(invalidLabels);
+    }
+  }
+
+  private void updateLabels(
+      Repository repo, String refName, ObjectId oldObjectId, Collection<String> labels)
+      throws IOException, OrmException, InvalidLabelsException {
+    try (RevWalk rw = new RevWalk(repo)) {
+      RefUpdate u = repo.updateRef(refName);
+      u.setExpectedOldObjectId(oldObjectId);
+      u.setForceUpdate(true);
+      u.setNewObjectId(writeLabels(repo, labels));
+      u.setRefLogIdent(serverIdent);
+      u.setRefLogMessage("Update star labels", true);
+      RefUpdate.Result result = u.update(rw);
+      switch (result) {
+        case NEW:
+        case FORCED:
+        case NO_CHANGE:
+        case FAST_FORWARD:
+          gitRefUpdated.fire(allUsers, u, null);
+          return;
+        case IO_FAILURE:
+        case LOCK_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case RENAMED:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
+          throw new OrmException(
+              String.format("Update star labels on ref %s failed: %s", refName, result.name()));
+      }
+    }
+  }
+
+  private void deleteRef(Repository repo, String refName, ObjectId oldObjectId)
+      throws IOException, OrmException {
+    RefUpdate u = repo.updateRef(refName);
+    u.setForceUpdate(true);
+    u.setExpectedOldObjectId(oldObjectId);
+    u.setRefLogIdent(serverIdent);
+    u.setRefLogMessage("Unstar change", true);
+    RefUpdate.Result result = u.delete();
+    switch (result) {
+      case FORCED:
+        gitRefUpdated.fire(allUsers, u, null);
+        return;
+      case NEW:
+      case NO_CHANGE:
+      case FAST_FORWARD:
+      case IO_FAILURE:
+      case LOCK_FAILURE:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case RENAMED:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+      default:
+        throw new OrmException(
+            String.format("Delete star ref %s failed: %s", refName, result.name()));
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StartupCheck.java b/java/com/google/gerrit/server/StartupCheck.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/StartupCheck.java
rename to java/com/google/gerrit/server/StartupCheck.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StartupChecks.java b/java/com/google/gerrit/server/StartupChecks.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/StartupChecks.java
rename to java/com/google/gerrit/server/StartupChecks.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StartupException.java b/java/com/google/gerrit/server/StartupException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/StartupException.java
rename to java/com/google/gerrit/server/StartupException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/UrlEncoded.java b/java/com/google/gerrit/server/UrlEncoded.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/UrlEncoded.java
rename to java/com/google/gerrit/server/UrlEncoded.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
rename to java/com/google/gerrit/server/WebLinks.java
diff --git a/java/com/google/gerrit/server/access/AccessResource.java b/java/com/google/gerrit/server/access/AccessResource.java
new file mode 100644
index 0000000..a1fe0c9
--- /dev/null
+++ b/java/com/google/gerrit/server/access/AccessResource.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.access;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class AccessResource implements RestResource {
+  public static final TypeLiteral<RestView<AccessResource>> ACCESS_KIND =
+      new TypeLiteral<RestView<AccessResource>>() {};
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractGroupBackend.java b/java/com/google/gerrit/server/account/AbstractGroupBackend.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractGroupBackend.java
rename to java/com/google/gerrit/server/account/AbstractGroupBackend.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java b/java/com/google/gerrit/server/account/AbstractRealm.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
rename to java/com/google/gerrit/server/account/AbstractRealm.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java b/java/com/google/gerrit/server/account/AccountCache.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
rename to java/com/google/gerrit/server/account/AccountCache.java
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
new file mode 100644
index 0000000..963da62
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.index.account.AccountIndexer;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Caches important (but small) account state to avoid database hits. */
+@Singleton
+public class AccountCacheImpl implements AccountCache {
+  private static final Logger log = LoggerFactory.getLogger(AccountCacheImpl.class);
+
+  private static final String BYID_NAME = "accounts";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        cache(BYID_NAME, Account.Id.class, new TypeLiteral<Optional<AccountState>>() {})
+            .loader(ByIdLoader.class);
+
+        bind(AccountCacheImpl.class);
+        bind(AccountCache.class).to(AccountCacheImpl.class);
+      }
+    };
+  }
+
+  private final AllUsersName allUsersName;
+  private final ExternalIds externalIds;
+  private final LoadingCache<Account.Id, Optional<AccountState>> byId;
+  private final Provider<AccountIndexer> indexer;
+
+  @Inject
+  AccountCacheImpl(
+      AllUsersName allUsersName,
+      ExternalIds externalIds,
+      @Named(BYID_NAME) LoadingCache<Account.Id, Optional<AccountState>> byId,
+      Provider<AccountIndexer> indexer) {
+    this.allUsersName = allUsersName;
+    this.externalIds = externalIds;
+    this.byId = byId;
+    this.indexer = indexer;
+  }
+
+  @Override
+  public AccountState get(Account.Id accountId) {
+    try {
+      return byId.get(accountId).orElse(missing(accountId));
+    } catch (ExecutionException e) {
+      log.warn("Cannot load AccountState for " + accountId, e);
+      return missing(accountId);
+    }
+  }
+
+  @Override
+  @Nullable
+  public AccountState getOrNull(Account.Id accountId) {
+    try {
+      return byId.get(accountId).orElse(null);
+    } catch (ExecutionException e) {
+      log.warn("Cannot load AccountState for ID " + accountId, e);
+      return null;
+    }
+  }
+
+  @Override
+  public AccountState getByUsername(String username) {
+    try {
+      ExternalId extId = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
+      if (extId == null) {
+        return null;
+      }
+      return getOrNull(extId.accountId());
+    } catch (IOException | ConfigInvalidException e) {
+      log.warn("Cannot load AccountState for username " + username, e);
+      return null;
+    }
+  }
+
+  @Override
+  public void evict(Account.Id accountId) throws IOException {
+    if (accountId != null) {
+      byId.invalidate(accountId);
+      indexer.get().index(accountId);
+    }
+  }
+
+  @Override
+  public void evictAllNoReindex() {
+    byId.invalidateAll();
+  }
+
+  private AccountState missing(Account.Id accountId) {
+    Account account = new Account(accountId, TimeUtil.nowTs());
+    account.setActive(false);
+    return new AccountState(
+        allUsersName,
+        account,
+        Collections.emptySet(),
+        new HashMap<>(),
+        GeneralPreferencesInfo.defaults());
+  }
+
+  static class ByIdLoader extends CacheLoader<Account.Id, Optional<AccountState>> {
+    private final Accounts accounts;
+
+    @Inject
+    ByIdLoader(Accounts accounts) {
+      this.accounts = accounts;
+    }
+
+    @Override
+    public Optional<AccountState> load(Account.Id who) throws Exception {
+      return Optional.ofNullable(accounts.get(who));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
new file mode 100644
index 0000000..b20aa0b
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -0,0 +1,430 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+
+/**
+ * ‘account.config’ file in the user branch in the All-Users repository that contains the properties
+ * of the account.
+ *
+ * <p>The 'account.config' file is a git config file that has one 'account' section with the
+ * properties of the account:
+ *
+ * <pre>
+ *   [account]
+ *     active = false
+ *     fullName = John Doe
+ *     preferredEmail = john.doe@foo.com
+ *     status = Overloaded with reviews
+ * </pre>
+ *
+ * <p>All keys are optional. This means 'account.config' may not exist on the user branch if no
+ * properties are set.
+ *
+ * <p>Not setting a key and setting a key to an empty string are treated the same way and result in
+ * a {@code null} value.
+ *
+ * <p>If no value for 'active' is specified, by default the account is considered as active.
+ *
+ * <p>The commit date of the first commit on the user branch is used as registration date of the
+ * account. The first commit may be an empty commit (if no properties were set and 'account.config'
+ * doesn't exist).
+ */
+public class AccountConfig extends VersionedMetaData implements ValidationError.Sink {
+  public static final String ACCOUNT_CONFIG = "account.config";
+  public static final String ACCOUNT = "account";
+  public static final String KEY_ACTIVE = "active";
+  public static final String KEY_FULL_NAME = "fullName";
+  public static final String KEY_PREFERRED_EMAIL = "preferredEmail";
+  public static final String KEY_STATUS = "status";
+
+  private final Account.Id accountId;
+  private final Repository repo;
+  private final String ref;
+
+  private Optional<Account> loadedAccount;
+  private Optional<ObjectId> externalIdsRev;
+  private WatchConfig watchConfig;
+  private PreferencesConfig prefConfig;
+  private Optional<InternalAccountUpdate> accountUpdate = Optional.empty();
+  private Timestamp registeredOn;
+  private boolean eagerParsing;
+  private List<ValidationError> validationErrors;
+
+  public AccountConfig(Account.Id accountId, Repository allUsersRepo) {
+    this.accountId = checkNotNull(accountId, "accountId");
+    this.repo = checkNotNull(allUsersRepo, "allUsersRepo");
+    this.ref = RefNames.refsUsers(accountId);
+  }
+
+  /**
+   * Sets whether all account data should be eagerly parsed.
+   *
+   * <p>Eager parsing should only be used if the caller is interested in validation errors for all
+   * account data (see {@link #getValidationErrors()}.
+   *
+   * @param eagerParsing whether all account data should be eagerly parsed
+   * @return this AccountConfig instance for chaining
+   */
+  public AccountConfig setEagerParsing(boolean eagerParsing) {
+    checkState(loadedAccount == null, "Account %s already loaded", accountId.get());
+    this.eagerParsing = eagerParsing;
+    return this;
+  }
+
+  @Override
+  protected String getRefName() {
+    return ref;
+  }
+
+  public AccountConfig load() throws IOException, ConfigInvalidException {
+    load(repo);
+    return this;
+  }
+
+  /**
+   * Get the loaded account.
+   *
+   * @return the loaded account, {@link Optional#empty()} if load didn't find the account because it
+   *     doesn't exist
+   * @throws IllegalStateException if the account was not loaded yet
+   */
+  public Optional<Account> getLoadedAccount() {
+    checkLoaded();
+    return loadedAccount;
+  }
+
+  /**
+   * Returns the revision of the {@code refs/meta/external-ids} branch.
+   *
+   * <p>This revision can be used to load the external IDs of the loaded account lazily via {@link
+   * ExternalIds#byAccount(com.google.gerrit.reviewdb.client.Account.Id, ObjectId)}.
+   *
+   * @return revision of the {@code refs/meta/external-ids} branch, {@link Optional#empty()} if no
+   *     {@code refs/meta/external-ids} branch exists
+   */
+  public Optional<ObjectId> getExternalIdsRev() {
+    checkLoaded();
+    return externalIdsRev;
+  }
+
+  /**
+   * Get the project watches of the loaded account.
+   *
+   * @return the project watches of the loaded account
+   */
+  public Map<ProjectWatchKey, Set<NotifyType>> getProjectWatches() {
+    checkLoaded();
+    return watchConfig.getProjectWatches();
+  }
+
+  /**
+   * Get the general preferences of the loaded account.
+   *
+   * @return the general preferences of the loaded account
+   */
+  public GeneralPreferencesInfo getGeneralPreferences() {
+    checkLoaded();
+    return prefConfig.getGeneralPreferences();
+  }
+
+  /**
+   * Sets the account. This means the loaded account will be overwritten with the given account.
+   *
+   * <p>Changing the registration date of an account is not supported.
+   *
+   * @param account account that should be set
+   * @throws IllegalStateException if the account was not loaded yet
+   */
+  public AccountConfig setAccount(Account account) {
+    checkLoaded();
+    this.loadedAccount = Optional.of(account);
+    this.accountUpdate =
+        Optional.of(
+            InternalAccountUpdate.builder()
+                .setActive(account.isActive())
+                .setFullName(account.getFullName())
+                .setPreferredEmail(account.getPreferredEmail())
+                .setStatus(account.getStatus())
+                .build());
+    this.registeredOn = account.getRegisteredOn();
+    return this;
+  }
+
+  /**
+   * Creates a new account.
+   *
+   * @return the new account
+   * @throws OrmDuplicateKeyException if the user branch already exists
+   */
+  public Account getNewAccount() throws OrmDuplicateKeyException {
+    return getNewAccount(TimeUtil.nowTs());
+  }
+
+  /**
+   * Creates a new account.
+   *
+   * @return the new account
+   * @throws OrmDuplicateKeyException if the user branch already exists
+   */
+  Account getNewAccount(Timestamp registeredOn) throws OrmDuplicateKeyException {
+    checkLoaded();
+    if (revision != null) {
+      throw new OrmDuplicateKeyException(String.format("account %s already exists", accountId));
+    }
+    this.registeredOn = registeredOn;
+    this.loadedAccount = Optional.of(new Account(accountId, registeredOn));
+    return loadedAccount.get();
+  }
+
+  public AccountConfig setAccountUpdate(InternalAccountUpdate accountUpdate) {
+    this.accountUpdate = Optional.of(accountUpdate);
+    return this;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    if (revision != null) {
+      rw.reset();
+      rw.markStart(revision);
+      rw.sort(RevSort.REVERSE);
+      registeredOn = new Timestamp(rw.next().getCommitTime() * 1000L);
+
+      Config accountConfig = readConfig(ACCOUNT_CONFIG);
+      loadedAccount = Optional.of(parse(accountConfig, revision.name()));
+
+      Ref externalIdsRef = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
+      externalIdsRev =
+          externalIdsRef != null ? Optional.of(externalIdsRef.getObjectId()) : Optional.empty();
+
+      watchConfig = new WatchConfig(accountId, readConfig(WatchConfig.WATCH_CONFIG), this);
+
+      prefConfig =
+          new PreferencesConfig(
+              accountId,
+              readConfig(PreferencesConfig.PREFERENCES_CONFIG),
+              PreferencesConfig.readDefaultConfig(repo),
+              this);
+
+      if (eagerParsing) {
+        watchConfig.parse();
+        prefConfig.parse();
+      }
+    } else {
+      loadedAccount = Optional.empty();
+    }
+  }
+
+  private Account parse(Config cfg, String metaId) {
+    Account account = new Account(accountId, registeredOn);
+    account.setActive(cfg.getBoolean(ACCOUNT, null, KEY_ACTIVE, true));
+    account.setFullName(get(cfg, KEY_FULL_NAME));
+
+    String preferredEmail = get(cfg, KEY_PREFERRED_EMAIL);
+    account.setPreferredEmail(preferredEmail);
+
+    account.setStatus(get(cfg, KEY_STATUS));
+    account.setMetaId(metaId);
+    return account;
+  }
+
+  @Override
+  public RevCommit commit(MetaDataUpdate update) throws IOException {
+    RevCommit c = super.commit(update);
+    loadedAccount.get().setMetaId(c.name());
+    return c;
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    checkLoaded();
+
+    if (!loadedAccount.isPresent()) {
+      return false;
+    }
+
+    if (revision != null) {
+      if (Strings.isNullOrEmpty(commit.getMessage())) {
+        commit.setMessage("Update account\n");
+      }
+    } else {
+      if (Strings.isNullOrEmpty(commit.getMessage())) {
+        commit.setMessage("Create account\n");
+      }
+
+      commit.setAuthor(new PersonIdent(commit.getAuthor(), registeredOn));
+      commit.setCommitter(new PersonIdent(commit.getCommitter(), registeredOn));
+    }
+
+    Config accountConfig = saveAccount();
+    saveProjectWatches();
+    saveGeneralPreferences();
+
+    // metaId is set in the commit(MetaDataUpdate) method after the commit is created
+    loadedAccount = Optional.of(parse(accountConfig, null));
+
+    accountUpdate = Optional.empty();
+
+    return true;
+  }
+
+  private Config saveAccount() throws IOException, ConfigInvalidException {
+    Config accountConfig = readConfig(ACCOUNT_CONFIG);
+    if (accountUpdate.isPresent()) {
+      writeToAccountConfig(accountUpdate.get(), accountConfig);
+    }
+    saveConfig(ACCOUNT_CONFIG, accountConfig);
+    return accountConfig;
+  }
+
+  public static void writeToAccountConfig(InternalAccountUpdate accountUpdate, Config cfg) {
+    accountUpdate.getActive().ifPresent(active -> setActive(cfg, active));
+    accountUpdate.getFullName().ifPresent(fullName -> set(cfg, KEY_FULL_NAME, fullName));
+    accountUpdate
+        .getPreferredEmail()
+        .ifPresent(preferredEmail -> set(cfg, KEY_PREFERRED_EMAIL, preferredEmail));
+    accountUpdate.getStatus().ifPresent(status -> set(cfg, KEY_STATUS, status));
+  }
+
+  private void saveProjectWatches() throws IOException {
+    if (accountUpdate.isPresent()
+        && (!accountUpdate.get().getDeletedProjectWatches().isEmpty()
+            || !accountUpdate.get().getUpdatedProjectWatches().isEmpty())) {
+      Map<ProjectWatchKey, Set<NotifyType>> projectWatches = watchConfig.getProjectWatches();
+      accountUpdate.get().getDeletedProjectWatches().forEach(pw -> projectWatches.remove(pw));
+      accountUpdate
+          .get()
+          .getUpdatedProjectWatches()
+          .forEach((pw, nt) -> projectWatches.put(pw, nt));
+      saveConfig(WatchConfig.WATCH_CONFIG, watchConfig.save(projectWatches));
+    }
+  }
+
+  private void saveGeneralPreferences() throws IOException, ConfigInvalidException {
+    if (accountUpdate.isPresent() && accountUpdate.get().getGeneralPreferences().isPresent()) {
+      saveConfig(
+          PreferencesConfig.PREFERENCES_CONFIG,
+          prefConfig.saveGeneralPreferences(accountUpdate.get().getGeneralPreferences().get()));
+    }
+  }
+
+  /**
+   * Sets/Unsets {@code account.active} in the given config.
+   *
+   * <p>{@code account.active} is set to {@code false} if the account is inactive.
+   *
+   * <p>If the account is active {@code account.active} is unset since {@code true} is the default
+   * if this field is missing.
+   *
+   * @param cfg the config
+   * @param value whether the account is active
+   */
+  private static void setActive(Config cfg, boolean value) {
+    if (!value) {
+      cfg.setBoolean(ACCOUNT, null, KEY_ACTIVE, false);
+    } else {
+      cfg.unset(ACCOUNT, null, KEY_ACTIVE);
+    }
+  }
+
+  /**
+   * Sets/Unsets the given key in the given config.
+   *
+   * <p>The key unset if the value is {@code null}.
+   *
+   * @param cfg the config
+   * @param key the key
+   * @param value the value
+   */
+  private static void set(Config cfg, String key, String value) {
+    if (!Strings.isNullOrEmpty(value)) {
+      cfg.setString(ACCOUNT, null, key, value);
+    } else {
+      cfg.unset(ACCOUNT, null, key);
+    }
+  }
+
+  /**
+   * Gets the given key from the given config.
+   *
+   * <p>Empty values are returned as {@code null}
+   *
+   * @param cfg the config
+   * @param key the key
+   * @return the value, {@code null} if key was not set or key was set to empty string
+   */
+  private static String get(Config cfg, String key) {
+    return Strings.emptyToNull(cfg.getString(ACCOUNT, null, key));
+  }
+
+  private void checkLoaded() {
+    checkState(loadedAccount != null, "Account %s not loaded yet", accountId.get());
+  }
+
+  /**
+   * Get the validation errors, if any were discovered during parsing the account data.
+   *
+   * <p>To get validation errors for all account data request eager parsing before loading the
+   * account (see {@link #setEagerParsing(boolean)}).
+   *
+   * @return list of errors; empty list if there are no errors.
+   */
+  public List<ValidationError> getValidationErrors() {
+    if (validationErrors != null) {
+      return ImmutableList.copyOf(validationErrors);
+    }
+    return ImmutableList.of();
+  }
+
+  @Override
+  public void error(ValidationError error) {
+    if (validationErrors == null) {
+      validationErrors = new ArrayList<>(4);
+    }
+    validationErrors.add(error);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
rename to java/com/google/gerrit/server/account/AccountControl.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDeactivator.java b/java/com/google/gerrit/server/account/AccountDeactivator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDeactivator.java
rename to java/com/google/gerrit/server/account/AccountDeactivator.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java b/java/com/google/gerrit/server/account/AccountDirectory.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java
rename to java/com/google/gerrit/server/account/AccountDirectory.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountException.java b/java/com/google/gerrit/server/account/AccountException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AccountException.java
rename to java/com/google/gerrit/server/account/AccountException.java
diff --git a/java/com/google/gerrit/server/account/AccountExternalIdCreator.java b/java/com/google/gerrit/server/account/AccountExternalIdCreator.java
new file mode 100644
index 0000000..8cf4ee0
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AccountExternalIdCreator.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import java.util.List;
+
+public interface AccountExternalIdCreator {
+
+  /**
+   * Returns additional external identifiers to assign to a given user when creating an account.
+   *
+   * @param id the identifier of the account.
+   * @param username the name of the user.
+   * @param email an optional email address to assign to the external identifiers, or {@code null}.
+   * @return a list of external identifiers, or an empty list.
+   */
+  List<ExternalId> create(Account.Id id, String username, String email);
+}
diff --git a/java/com/google/gerrit/server/account/AccountInfoComparator.java b/java/com/google/gerrit/server/account/AccountInfoComparator.java
new file mode 100644
index 0000000..533dece
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AccountInfoComparator.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.extensions.common.AccountInfo;
+import java.util.Comparator;
+
+public class AccountInfoComparator extends Ordering<AccountInfo>
+    implements Comparator<AccountInfo> {
+  public static final AccountInfoComparator ORDER_NULLS_FIRST = new AccountInfoComparator();
+  public static final AccountInfoComparator ORDER_NULLS_LAST =
+      new AccountInfoComparator().setNullsLast();
+
+  private boolean nullsLast;
+
+  private AccountInfoComparator() {}
+
+  private AccountInfoComparator setNullsLast() {
+    this.nullsLast = true;
+    return this;
+  }
+
+  @Override
+  public int compare(AccountInfo a, AccountInfo b) {
+    return ComparisonChain.start()
+        .compare(a.name, b.name, createOrdering())
+        .compare(a.email, b.email, createOrdering())
+        .compare(a._accountId, b._accountId, createOrdering())
+        .result();
+  }
+
+  private <S extends Comparable<?>> Ordering<S> createOrdering() {
+    if (nullsLast) {
+      return Ordering.natural().nullsLast();
+    }
+    return Ordering.natural().nullsFirst();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLimits.java b/java/com/google/gerrit/server/account/AccountLimits.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLimits.java
rename to java/com/google/gerrit/server/account/AccountLimits.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLoader.java b/java/com/google/gerrit/server/account/AccountLoader.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLoader.java
rename to java/com/google/gerrit/server/account/AccountLoader.java
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
new file mode 100644
index 0000000..aac515d
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -0,0 +1,477 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountsUpdate.AccountUpdater;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.auth.NoSuchUserException;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Tracks authentication related details for user accounts. */
+@Singleton
+public class AccountManager {
+  private static final Logger log = LoggerFactory.getLogger(AccountManager.class);
+
+  private final SchemaFactory<ReviewDb> schema;
+  private final Sequences sequences;
+  private final Accounts accounts;
+  private final AccountsUpdate.Server accountsUpdateFactory;
+  private final AccountCache byIdCache;
+  private final Realm realm;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final SshKeyCache sshKeyCache;
+  private final ProjectCache projectCache;
+  private final AtomicBoolean awaitsFirstAccountCheck;
+  private final ExternalIds externalIds;
+  private final GroupsUpdate.Factory groupsUpdateFactory;
+  private final boolean autoUpdateAccountActiveStatus;
+  private final SetInactiveFlag setInactiveFlag;
+
+  @Inject
+  AccountManager(
+      SchemaFactory<ReviewDb> schema,
+      Sequences sequences,
+      @GerritServerConfig Config cfg,
+      Accounts accounts,
+      AccountsUpdate.Server accountsUpdateFactory,
+      AccountCache byIdCache,
+      Realm accountMapper,
+      IdentifiedUser.GenericFactory userFactory,
+      SshKeyCache sshKeyCache,
+      ProjectCache projectCache,
+      ExternalIds externalIds,
+      GroupsUpdate.Factory groupsUpdateFactory,
+      SetInactiveFlag setInactiveFlag) {
+    this.schema = schema;
+    this.sequences = sequences;
+    this.accounts = accounts;
+    this.accountsUpdateFactory = accountsUpdateFactory;
+    this.byIdCache = byIdCache;
+    this.realm = accountMapper;
+    this.userFactory = userFactory;
+    this.sshKeyCache = sshKeyCache;
+    this.projectCache = projectCache;
+    this.awaitsFirstAccountCheck =
+        new AtomicBoolean(cfg.getBoolean("capability", "makeFirstUserAdmin", true));
+    this.externalIds = externalIds;
+    this.groupsUpdateFactory = groupsUpdateFactory;
+    this.autoUpdateAccountActiveStatus =
+        cfg.getBoolean("auth", "autoUpdateAccountActiveStatus", false);
+    this.setInactiveFlag = setInactiveFlag;
+  }
+
+  /** @return user identified by this external identity string */
+  public Optional<Account.Id> lookup(String externalId) throws AccountException {
+    try {
+      ExternalId extId = externalIds.get(ExternalId.Key.parse(externalId));
+      return extId != null ? Optional.of(extId.accountId()) : Optional.empty();
+    } catch (IOException | ConfigInvalidException e) {
+      throw new AccountException("Cannot lookup account " + externalId, e);
+    }
+  }
+
+  /**
+   * Authenticate the user, potentially creating a new account if they are new.
+   *
+   * @param who identity of the user, with any details we received about them.
+   * @return the result of authenticating the user.
+   * @throws AccountException the account does not exist, and cannot be created, or exists, but
+   *     cannot be located, is unable to be activated or deactivated, or is inactive, or cannot be
+   *     added to the admin group (only for the first account).
+   */
+  public AuthResult authenticate(AuthRequest who) throws AccountException, IOException {
+    try {
+      who = realm.authenticate(who);
+    } catch (NoSuchUserException e) {
+      deactivateAccountIfItExists(who);
+      throw e;
+    }
+    try {
+      try (ReviewDb db = schema.open()) {
+        ExternalId id = externalIds.get(who.getExternalIdKey());
+        if (id == null) {
+          // New account, automatically create and return.
+          //
+          return create(db, who);
+        }
+
+        // Account exists
+        Account act = updateAccountActiveStatus(who, byIdCache.get(id.accountId()).getAccount());
+        if (!act.isActive()) {
+          throw new AccountException("Authentication error, account inactive");
+        }
+
+        // return the identity to the caller.
+        update(who, id);
+        return new AuthResult(id.accountId(), who.getExternalIdKey(), false);
+      }
+    } catch (OrmException | ConfigInvalidException e) {
+      throw new AccountException("Authentication error", e);
+    }
+  }
+
+  private void deactivateAccountIfItExists(AuthRequest authRequest) {
+    if (!shouldUpdateActiveStatus(authRequest)) {
+      return;
+    }
+    try {
+      ExternalId id = externalIds.get(authRequest.getExternalIdKey());
+      if (id == null) {
+        return;
+      }
+      setInactiveFlag.deactivate(id.accountId());
+    } catch (Exception e) {
+      log.error("Unable to deactivate account " + authRequest.getUserName(), e);
+    }
+  }
+
+  private Account updateAccountActiveStatus(AuthRequest authRequest, Account account)
+      throws AccountException {
+    if (!shouldUpdateActiveStatus(authRequest) || authRequest.isActive() == account.isActive()) {
+      return account;
+    }
+
+    if (authRequest.isActive()) {
+      try {
+        setInactiveFlag.activate(account.getId());
+      } catch (Exception e) {
+        throw new AccountException("Unable to activate account " + account.getId(), e);
+      }
+    } else {
+      try {
+        setInactiveFlag.deactivate(account.getId());
+      } catch (Exception e) {
+        throw new AccountException("Unable to deactivate account " + account.getId(), e);
+      }
+    }
+    return byIdCache.get(account.getId()).getAccount();
+  }
+
+  private boolean shouldUpdateActiveStatus(AuthRequest authRequest) {
+    return autoUpdateAccountActiveStatus && authRequest.authProvidesAccountActiveStatus();
+  }
+
+  private void update(AuthRequest who, ExternalId extId)
+      throws OrmException, IOException, ConfigInvalidException {
+    IdentifiedUser user = userFactory.create(extId.accountId());
+    List<Consumer<InternalAccountUpdate.Builder>> accountUpdates = new ArrayList<>();
+
+    // If the email address was modified by the authentication provider,
+    // update our records to match the changed email.
+    //
+    String newEmail = who.getEmailAddress();
+    String oldEmail = extId.email();
+    if (newEmail != null && !newEmail.equals(oldEmail)) {
+      if (oldEmail != null && oldEmail.equals(user.getAccount().getPreferredEmail())) {
+        accountUpdates.add(u -> u.setPreferredEmail(newEmail));
+      }
+
+      accountUpdates.add(
+          u ->
+              u.replaceExternalId(
+                  extId,
+                  ExternalId.create(extId.key(), extId.accountId(), newEmail, extId.password())));
+    }
+
+    if (!realm.allowsEdit(AccountFieldName.FULL_NAME)
+        && !Strings.isNullOrEmpty(who.getDisplayName())
+        && !eq(user.getAccount().getFullName(), who.getDisplayName())) {
+      accountUpdates.add(u -> u.setFullName(who.getDisplayName()));
+    }
+
+    if (!realm.allowsEdit(AccountFieldName.USER_NAME)
+        && who.getUserName() != null
+        && !eq(user.getUserName(), who.getUserName())) {
+      log.warn(
+          String.format(
+              "Not changing already set username %s to %s", user.getUserName(), who.getUserName()));
+    }
+
+    if (!accountUpdates.isEmpty()) {
+      Account account =
+          accountsUpdateFactory
+              .create()
+              .update(
+                  "Update Account on Login",
+                  user.getAccountId(),
+                  AccountUpdater.joinConsumers(accountUpdates));
+      if (account == null) {
+        throw new OrmException("Account " + user.getAccountId() + " has been deleted");
+      }
+    }
+  }
+
+  private static boolean eq(String a, String b) {
+    return (a == null && b == null) || (a != null && a.equals(b));
+  }
+
+  private AuthResult create(ReviewDb db, AuthRequest who)
+      throws OrmException, AccountException, IOException, ConfigInvalidException {
+    Account.Id newId = new Account.Id(sequences.nextAccountId());
+
+    ExternalId extId =
+        ExternalId.createWithEmail(who.getExternalIdKey(), newId, who.getEmailAddress());
+    ExternalId userNameExtId =
+        !Strings.isNullOrEmpty(who.getUserName()) ? createUsername(newId, who.getUserName()) : null;
+
+    boolean isFirstAccount = awaitsFirstAccountCheck.getAndSet(false) && !accounts.hasAnyAccount();
+
+    Account account;
+    try {
+      account =
+          accountsUpdateFactory
+              .create()
+              .insert(
+                  "Create Account on First Login",
+                  newId,
+                  u -> {
+                    u.setFullName(who.getDisplayName())
+                        .setPreferredEmail(extId.email())
+                        .addExternalId(extId);
+                    if (userNameExtId != null) {
+                      u.addExternalId(userNameExtId);
+                    }
+                  });
+    } catch (DuplicateExternalIdKeyException e) {
+      throw new AccountException(
+          "Cannot assign external ID \""
+              + e.getDuplicateKey().get()
+              + "\" to account "
+              + newId
+              + "; external ID already in use.");
+    } finally {
+      // If adding the account failed, it may be that it actually was the
+      // first account. So we reset the 'check for first account'-guard, as
+      // otherwise the first account would not get administration permissions.
+      awaitsFirstAccountCheck.set(isFirstAccount);
+    }
+
+    if (userNameExtId != null) {
+      sshKeyCache.evict(who.getUserName());
+    }
+
+    IdentifiedUser user = userFactory.create(newId);
+
+    if (isFirstAccount) {
+      // This is the first user account on our site. Assume this user
+      // is going to be the site's administrator and just make them that
+      // to bootstrap the authentication database.
+      //
+      Permission admin =
+          projectCache
+              .getAllProjects()
+              .getConfig()
+              .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
+              .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
+
+      AccountGroup.UUID adminGroupUuid = admin.getRules().get(0).getGroup().getUUID();
+      addGroupMember(db, adminGroupUuid, user);
+    }
+
+    realm.onCreateAccount(who, account);
+    return new AuthResult(newId, extId.key(), true);
+  }
+
+  private ExternalId createUsername(Account.Id accountId, String username)
+      throws AccountUserNameException {
+    checkArgument(!Strings.isNullOrEmpty(username));
+
+    if (!ExternalId.isValidUsername(username)) {
+      throw new AccountUserNameException(
+          String.format(
+              "Cannot assign user name \"%s\" to account %s; name does not conform.",
+              username, accountId));
+    }
+    return ExternalId.create(SCHEME_USERNAME, username, accountId);
+  }
+
+  private void addGroupMember(ReviewDb db, AccountGroup.UUID groupUuid, IdentifiedUser user)
+      throws OrmException, IOException, ConfigInvalidException, AccountException {
+    // The user initiated this request by logging in. -> Attribute all modifications to that user.
+    GroupsUpdate groupsUpdate = groupsUpdateFactory.create(user);
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder()
+            .setMemberModification(
+                memberIds -> Sets.union(memberIds, ImmutableSet.of(user.getAccountId())))
+            .build();
+    try {
+      groupsUpdate.updateGroup(db, groupUuid, groupUpdate);
+    } catch (NoSuchGroupException e) {
+      throw new AccountException(String.format("Group %s not found", groupUuid));
+    }
+  }
+
+  /**
+   * Link another authentication identity to an existing account.
+   *
+   * @param to account to link the identity onto.
+   * @param who the additional identity.
+   * @return the result of linking the identity to the user.
+   * @throws AccountException the identity belongs to a different account, or it cannot be linked at
+   *     this time.
+   */
+  public AuthResult link(Account.Id to, AuthRequest who)
+      throws AccountException, OrmException, IOException, ConfigInvalidException {
+    ExternalId extId = externalIds.get(who.getExternalIdKey());
+    if (extId != null) {
+      if (!extId.accountId().equals(to)) {
+        throw new AccountException(
+            "Identity '" + extId.key().get() + "' in use by another account");
+      }
+      update(who, extId);
+    } else {
+      accountsUpdateFactory
+          .create()
+          .update(
+              "Link External ID",
+              to,
+              (a, u) -> {
+                u.addExternalId(
+                    ExternalId.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress()));
+                if (who.getEmailAddress() != null && a.getPreferredEmail() == null) {
+                  u.setPreferredEmail(who.getEmailAddress());
+                }
+              });
+    }
+    return new AuthResult(to, who.getExternalIdKey(), false);
+  }
+
+  /**
+   * Update the link to another unique authentication identity to an existing account.
+   *
+   * <p>Existing external identities with the same scheme will be removed and replaced with the new
+   * one.
+   *
+   * @param to account to link the identity onto.
+   * @param who the additional identity.
+   * @return the result of linking the identity to the user.
+   * @throws OrmException
+   * @throws AccountException the identity belongs to a different account, or it cannot be linked at
+   *     this time.
+   */
+  public AuthResult updateLink(Account.Id to, AuthRequest who)
+      throws OrmException, AccountException, IOException, ConfigInvalidException {
+    Collection<ExternalId> filteredExtIdsByScheme =
+        externalIds.byAccount(to, who.getExternalIdKey().scheme());
+
+    if (!filteredExtIdsByScheme.isEmpty()
+        && (filteredExtIdsByScheme.size() > 1
+            || !filteredExtIdsByScheme
+                .stream()
+                .filter(e -> e.key().equals(who.getExternalIdKey()))
+                .findAny()
+                .isPresent())) {
+      accountsUpdateFactory
+          .create()
+          .update(
+              "Delete External IDs on Update Link",
+              to,
+              u -> u.deleteExternalIds(filteredExtIdsByScheme));
+    }
+    return link(to, who);
+  }
+
+  /**
+   * Unlink an external identity from an existing account.
+   *
+   * @param from account to unlink the external identity from
+   * @param extIdKey the key of the external ID that should be deleted
+   * @throws AccountException the identity belongs to a different account, or the identity was not
+   *     found
+   */
+  public void unlink(Account.Id from, ExternalId.Key extIdKey)
+      throws AccountException, OrmException, IOException, ConfigInvalidException {
+    unlink(from, ImmutableList.of(extIdKey));
+  }
+
+  /**
+   * Unlink an external identities from an existing account.
+   *
+   * @param from account to unlink the external identity from
+   * @param extIdKeys the keys of the external IDs that should be deleted
+   * @throws AccountException any of the identity belongs to a different account, or any of the
+   *     identity was not found
+   */
+  public void unlink(Account.Id from, Collection<ExternalId.Key> extIdKeys)
+      throws AccountException, OrmException, IOException, ConfigInvalidException {
+    if (extIdKeys.isEmpty()) {
+      return;
+    }
+
+    List<ExternalId> extIds = new ArrayList<>(extIdKeys.size());
+    for (ExternalId.Key extIdKey : extIdKeys) {
+      ExternalId extId = externalIds.get(extIdKey);
+      if (extId != null) {
+        if (!extId.accountId().equals(from)) {
+          throw new AccountException("Identity '" + extIdKey.get() + "' in use by another account");
+        }
+        extIds.add(extId);
+      } else {
+        throw new AccountException("Identity '" + extIdKey.get() + "' not found");
+      }
+    }
+
+    accountsUpdateFactory
+        .create()
+        .update(
+            "Unlink External ID" + (extIds.size() > 1 ? "s" : ""),
+            from,
+            (a, u) -> {
+              u.deleteExternalIds(extIds);
+              if (a.getPreferredEmail() != null
+                  && extIds.stream().anyMatch(e -> a.getPreferredEmail().equals(e.email()))) {
+                u.setPreferredEmail(null);
+              }
+            });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java
rename to java/com/google/gerrit/server/account/AccountResolver.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java b/java/com/google/gerrit/server/account/AccountResource.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java
rename to java/com/google/gerrit/server/account/AccountResource.java
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
new file mode 100644
index 0000000..6601df7
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -0,0 +1,202 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.base.Function;
+import com.google.common.base.Strings;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser.PropertyKey;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.config.AllUsersName;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.apache.commons.codec.DecoderException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Superset of all information related to an Account. This includes external IDs, project watches,
+ * and properties from the account config file. AccountState maps one-to-one to Account.
+ */
+public class AccountState {
+  private static final Logger logger = LoggerFactory.getLogger(AccountState.class);
+
+  public static final Function<AccountState, Account.Id> ACCOUNT_ID_FUNCTION =
+      a -> a.getAccount().getId();
+
+  private final AllUsersName allUsersName;
+  private final Account account;
+  private final Collection<ExternalId> externalIds;
+  private final Map<ProjectWatchKey, Set<NotifyType>> projectWatches;
+  private final GeneralPreferencesInfo generalPreferences;
+  private Cache<IdentifiedUser.PropertyKey<Object>, Object> properties;
+
+  public AccountState(
+      AllUsersName allUsersName,
+      Account account,
+      Collection<ExternalId> externalIds,
+      Map<ProjectWatchKey, Set<NotifyType>> projectWatches,
+      GeneralPreferencesInfo generalPreferences) {
+    this.allUsersName = allUsersName;
+    this.account = account;
+    this.externalIds = externalIds;
+    this.projectWatches = projectWatches;
+    this.generalPreferences = generalPreferences;
+    this.account.setUserName(getUserName(externalIds));
+  }
+
+  public AllUsersName getAllUsersNameForIndexing() {
+    return allUsersName;
+  }
+
+  /** Get the cached account metadata. */
+  public Account getAccount() {
+    return account;
+  }
+
+  /**
+   * Get the username, if one has been declared for this user.
+   *
+   * <p>The username is the {@link ExternalId} using the scheme {@link ExternalId#SCHEME_USERNAME}.
+   */
+  public String getUserName() {
+    return account.getUserName();
+  }
+
+  public boolean checkPassword(String password, String username) {
+    if (password == null) {
+      return false;
+    }
+    for (ExternalId id : getExternalIds()) {
+      // Only process the "username:$USER" entry, which is unique.
+      if (!id.isScheme(SCHEME_USERNAME) || !username.equals(id.key().id())) {
+        continue;
+      }
+
+      String hashedStr = id.password();
+      if (!Strings.isNullOrEmpty(hashedStr)) {
+        try {
+          return HashedPassword.decode(hashedStr).checkPassword(password);
+        } catch (DecoderException e) {
+          logger.error(
+              String.format("DecoderException for user %s: %s ", username, e.getMessage()));
+          return false;
+        }
+      }
+    }
+    return false;
+  }
+
+  /** The external identities that identify the account holder. */
+  public Collection<ExternalId> getExternalIds() {
+    return externalIds;
+  }
+
+  /** The project watches of the account. */
+  public Map<ProjectWatchKey, Set<NotifyType>> getProjectWatches() {
+    return projectWatches;
+  }
+
+  /** The general preferences of the account. */
+  public GeneralPreferencesInfo getGeneralPreferences() {
+    return generalPreferences;
+  }
+
+  public static String getUserName(Collection<ExternalId> ids) {
+    for (ExternalId extId : ids) {
+      if (extId.isScheme(SCHEME_USERNAME)) {
+        return extId.key().id();
+      }
+    }
+    return null;
+  }
+
+  public static Set<String> getEmails(Collection<ExternalId> ids) {
+    Set<String> emails = new HashSet<>();
+    for (ExternalId extId : ids) {
+      if (extId.isScheme(SCHEME_MAILTO)) {
+        emails.add(extId.key().id());
+      }
+    }
+    return emails;
+  }
+
+  /**
+   * Lookup a previously stored property.
+   *
+   * <p>All properties are automatically cleared when the account cache invalidates the {@code
+   * AccountState}. This method is thread-safe.
+   *
+   * @param key unique property key.
+   * @return previously stored value, or {@code null}.
+   */
+  @Nullable
+  public <T> T get(PropertyKey<T> key) {
+    Cache<PropertyKey<Object>, Object> p = properties(false);
+    if (p != null) {
+      @SuppressWarnings("unchecked")
+      T value = (T) p.getIfPresent(key);
+      return value;
+    }
+    return null;
+  }
+
+  /**
+   * Store a property for later retrieval.
+   *
+   * <p>This method is thread-safe.
+   *
+   * @param key unique property key.
+   * @param value value to store; or {@code null} to clear the value.
+   */
+  public <T> void put(PropertyKey<T> key, @Nullable T value) {
+    Cache<PropertyKey<Object>, Object> p = properties(value != null);
+    if (p != null) {
+      @SuppressWarnings("unchecked")
+      PropertyKey<Object> k = (PropertyKey<Object>) key;
+      if (value != null) {
+        p.put(k, value);
+      } else {
+        p.invalidate(k);
+      }
+    }
+  }
+
+  private synchronized Cache<PropertyKey<Object>, Object> properties(boolean allocate) {
+    if (properties == null && allocate) {
+      properties =
+          CacheBuilder.newBuilder()
+              .concurrencyLevel(1)
+              .initialCapacity(16)
+              // Use weakKeys to ensure plugins that garbage collect will also
+              // eventually release data held in any still live AccountState.
+              .weakKeys()
+              .build();
+    }
+    return properties;
+  }
+}
diff --git a/java/com/google/gerrit/server/account/AccountUserNameException.java b/java/com/google/gerrit/server/account/AccountUserNameException.java
new file mode 100644
index 0000000..a1f1df2
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AccountUserNameException.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+/**
+ * Thrown by {@link AccountManager} if the user name for a newly created account could not be set
+ * and the realm does not allow the user to set a user name manually.
+ */
+public class AccountUserNameException extends AccountException {
+  private static final long serialVersionUID = 1L;
+
+  public AccountUserNameException(String message) {
+    super(message);
+  }
+
+  public AccountUserNameException(String message, Throwable why) {
+    super(message, why);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibilityProvider.java b/java/com/google/gerrit/server/account/AccountVisibilityProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibilityProvider.java
rename to java/com/google/gerrit/server/account/AccountVisibilityProvider.java
diff --git a/java/com/google/gerrit/server/account/Accounts.java b/java/com/google/gerrit/server/account/Accounts.java
new file mode 100644
index 0000000..45831ae
--- /dev/null
+++ b/java/com/google/gerrit/server/account/Accounts.java
@@ -0,0 +1,163 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Class to access accounts. */
+@Singleton
+public class Accounts {
+  private static final Logger log = LoggerFactory.getLogger(Accounts.class);
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final ExternalIds externalIds;
+
+  @Inject
+  Accounts(GitRepositoryManager repoManager, AllUsersName allUsersName, ExternalIds externalIds) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.externalIds = externalIds;
+  }
+
+  @Nullable
+  public AccountState get(Account.Id accountId) throws IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return read(repo, accountId).orElse(null);
+    }
+  }
+
+  public List<AccountState> get(Collection<Account.Id> accountIds)
+      throws IOException, ConfigInvalidException {
+    List<AccountState> accounts = new ArrayList<>(accountIds.size());
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      for (Account.Id accountId : accountIds) {
+        read(repo, accountId).ifPresent(accounts::add);
+      }
+    }
+    return accounts;
+  }
+
+  /**
+   * Returns all accounts.
+   *
+   * @return all accounts
+   */
+  public List<AccountState> all() throws IOException {
+    Set<Account.Id> accountIds = allIds();
+    List<AccountState> accounts = new ArrayList<>(accountIds.size());
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      for (Account.Id accountId : accountIds) {
+        try {
+          read(repo, accountId).ifPresent(accounts::add);
+        } catch (Exception e) {
+          log.error(String.format("Ignoring invalid account %s", accountId.get()), e);
+        }
+      }
+    }
+    return accounts;
+  }
+
+  /**
+   * Returns all account IDs.
+   *
+   * @return all account IDs
+   */
+  public Set<Account.Id> allIds() throws IOException {
+    return readUserRefs().collect(toSet());
+  }
+
+  /**
+   * Returns the first n account IDs.
+   *
+   * @param n the number of account IDs that should be returned
+   * @return first n account IDs
+   */
+  public List<Account.Id> firstNIds(int n) throws IOException {
+    return readUserRefs().sorted(comparing(id -> id.get())).limit(n).collect(toList());
+  }
+
+  /**
+   * Checks if any account exists.
+   *
+   * @return {@code true} if at least one account exists, otherwise {@code false}
+   */
+  public boolean hasAnyAccount() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return hasAnyAccount(repo);
+    }
+  }
+
+  public static boolean hasAnyAccount(Repository repo) throws IOException {
+    return readUserRefs(repo).findAny().isPresent();
+  }
+
+  private Stream<Account.Id> readUserRefs() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return readUserRefs(repo);
+    }
+  }
+
+  private Optional<AccountState> read(Repository allUsersRepository, Account.Id accountId)
+      throws IOException, ConfigInvalidException {
+    AccountConfig accountConfig = new AccountConfig(accountId, allUsersRepository).load();
+    if (!accountConfig.getLoadedAccount().isPresent()) {
+      return Optional.empty();
+    }
+    Account account = accountConfig.getLoadedAccount().get();
+    return Optional.of(
+        new AccountState(
+            allUsersName,
+            account,
+            accountConfig.getExternalIdsRev().isPresent()
+                ? externalIds.byAccount(accountId, accountConfig.getExternalIdsRev().get())
+                : ImmutableSet.of(),
+            accountConfig.getProjectWatches(),
+            accountConfig.getGeneralPreferences()));
+  }
+
+  public static Stream<Account.Id> readUserRefs(Repository repo) throws IOException {
+    return repo.getRefDatabase()
+        .getRefs(RefNames.REFS_USERS)
+        .values()
+        .stream()
+        .map(r -> Account.Id.fromRef(r.getName()))
+        .filter(Objects::nonNull);
+  }
+}
diff --git a/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java b/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java
new file mode 100644
index 0000000..0b63927
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+@Singleton
+public class AccountsConsistencyChecker {
+  private final Accounts accounts;
+
+  @Inject
+  AccountsConsistencyChecker(Accounts accounts) {
+    this.accounts = accounts;
+  }
+
+  public List<ConsistencyProblemInfo> check() throws IOException {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    for (AccountState accountState : accounts.all()) {
+      Account account = accountState.getAccount();
+      if (account.getPreferredEmail() != null) {
+        if (!accountState
+            .getExternalIds()
+            .stream()
+            .anyMatch(e -> account.getPreferredEmail().equals(e.email()))) {
+          addError(
+              String.format(
+                  "Account '%s' has no external ID for its preferred email '%s'",
+                  account.getId().get(), account.getPreferredEmail()),
+              problems);
+        }
+      }
+    }
+
+    return problems;
+  }
+
+  private static void addError(String error, List<ConsistencyProblemInfo> problems) {
+    problems.add(new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, error));
+  }
+}
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
new file mode 100644
index 0000000..21e6c5f
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -0,0 +1,596 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.util.concurrent.Runnables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes.ExternalIdNotesLoader;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryHelper.ActionType;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Updates accounts.
+ *
+ * <p>The account updates are written to NoteDb.
+ *
+ * <p>In NoteDb accounts are represented as user branches in the All-Users repository. Optionally a
+ * user branch can contain a 'account.config' file that stores account properties, such as full
+ * name, preferred email, status and the active flag. The timestamp of the first commit on a user
+ * branch denotes the registration date. The initial commit on the user branch may be empty (since
+ * having an 'account.config' is optional). See {@link AccountConfig} for details of the
+ * 'account.config' file format.
+ *
+ * <p>On updating accounts the accounts are evicted from the account cache and thus reindexed. The
+ * eviction from the account cache is done by the {@link ReindexAfterRefUpdate} class which receives
+ * the event about updating the user branch that is triggered by this class.
+ */
+@Singleton
+public class AccountsUpdate {
+  /**
+   * Updater for an account.
+   *
+   * <p>Allows to read the current state of an account and to prepare updates to it.
+   */
+  @FunctionalInterface
+  public static interface AccountUpdater {
+    /**
+     * Prepare updates to an account.
+     *
+     * <p>Use the provided account only to read the current state of the account. Don't do updates
+     * to the account. For updates use the provided account update builder.
+     *
+     * @param account the account that is being updated
+     * @param update account update builder
+     */
+    void update(Account account, InternalAccountUpdate.Builder update);
+
+    public static AccountUpdater join(List<AccountUpdater> updaters) {
+      return (a, u) -> updaters.stream().forEach(updater -> updater.update(a, u));
+    }
+
+    public static AccountUpdater joinConsumers(
+        List<Consumer<InternalAccountUpdate.Builder>> consumers) {
+      return join(Lists.transform(consumers, AccountUpdater::fromConsumer));
+    }
+
+    static AccountUpdater fromConsumer(Consumer<InternalAccountUpdate.Builder> consumer) {
+      return (a, u) -> consumer.accept(u);
+    }
+  }
+
+  /**
+   * Factory to create an AccountsUpdate instance for updating accounts by the Gerrit server.
+   *
+   * <p>The Gerrit server identity will be used as author and committer for all commits that update
+   * the accounts.
+   */
+  @Singleton
+  public static class Server {
+    private final GitRepositoryManager repoManager;
+    private final GitReferenceUpdated gitRefUpdated;
+    private final AllUsersName allUsersName;
+    private final Provider<PersonIdent> serverIdentProvider;
+    private final Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+    private final RetryHelper retryHelper;
+    private final ExternalIdNotes.Factory extIdNotesFactory;
+
+    @Inject
+    public Server(
+        GitRepositoryManager repoManager,
+        GitReferenceUpdated gitRefUpdated,
+        AllUsersName allUsersName,
+        @GerritPersonIdent Provider<PersonIdent> serverIdentProvider,
+        Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+        RetryHelper retryHelper,
+        ExternalIdNotes.Factory extIdNotesFactory) {
+      this.repoManager = repoManager;
+      this.gitRefUpdated = gitRefUpdated;
+      this.allUsersName = allUsersName;
+      this.serverIdentProvider = serverIdentProvider;
+      this.metaDataUpdateInternalFactory = metaDataUpdateInternalFactory;
+      this.retryHelper = retryHelper;
+      this.extIdNotesFactory = extIdNotesFactory;
+    }
+
+    public AccountsUpdate create() {
+      PersonIdent serverIdent = serverIdentProvider.get();
+      return new AccountsUpdate(
+          repoManager,
+          gitRefUpdated,
+          null,
+          allUsersName,
+          metaDataUpdateInternalFactory,
+          retryHelper,
+          extIdNotesFactory,
+          serverIdent,
+          serverIdent);
+    }
+  }
+
+  /**
+   * Factory to create an AccountsUpdate instance for updating accounts by the Gerrit server.
+   *
+   * <p>Using this class no reindex will be performed for the affected accounts and they will also
+   * not be evicted from the account cache.
+   *
+   * <p>The Gerrit server identity will be used as author and committer for all commits that update
+   * the accounts.
+   */
+  @Singleton
+  public static class ServerNoReindex {
+    private final GitRepositoryManager repoManager;
+    private final GitReferenceUpdated gitRefUpdated;
+    private final AllUsersName allUsersName;
+    private final Provider<PersonIdent> serverIdentProvider;
+    private final Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+    private final RetryHelper retryHelper;
+    private final ExternalIdNotes.FactoryNoReindex extIdNotesFactory;
+
+    @Inject
+    public ServerNoReindex(
+        GitRepositoryManager repoManager,
+        GitReferenceUpdated gitRefUpdated,
+        AllUsersName allUsersName,
+        @GerritPersonIdent Provider<PersonIdent> serverIdentProvider,
+        Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+        RetryHelper retryHelper,
+        ExternalIdNotes.FactoryNoReindex extIdNotesFactory) {
+      this.repoManager = repoManager;
+      this.gitRefUpdated = gitRefUpdated;
+      this.allUsersName = allUsersName;
+      this.serverIdentProvider = serverIdentProvider;
+      this.metaDataUpdateInternalFactory = metaDataUpdateInternalFactory;
+      this.retryHelper = retryHelper;
+      this.extIdNotesFactory = extIdNotesFactory;
+    }
+
+    public AccountsUpdate create() {
+      PersonIdent serverIdent = serverIdentProvider.get();
+      return new AccountsUpdate(
+          repoManager,
+          gitRefUpdated,
+          null,
+          allUsersName,
+          metaDataUpdateInternalFactory,
+          retryHelper,
+          extIdNotesFactory,
+          serverIdent,
+          serverIdent);
+    }
+  }
+
+  /**
+   * Factory to create an AccountsUpdate instance for updating accounts by the current user.
+   *
+   * <p>The identity of the current user will be used as author for all commits that update the
+   * accounts. The Gerrit server identity will be used as committer.
+   */
+  @Singleton
+  public static class User {
+    private final GitRepositoryManager repoManager;
+    private final GitReferenceUpdated gitRefUpdated;
+    private final AllUsersName allUsersName;
+    private final Provider<PersonIdent> serverIdentProvider;
+    private final Provider<IdentifiedUser> identifiedUser;
+    private final Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+    private final RetryHelper retryHelper;
+    private final ExternalIdNotes.Factory extIdNotesFactory;
+
+    @Inject
+    public User(
+        GitRepositoryManager repoManager,
+        GitReferenceUpdated gitRefUpdated,
+        AllUsersName allUsersName,
+        @GerritPersonIdent Provider<PersonIdent> serverIdentProvider,
+        Provider<IdentifiedUser> identifiedUser,
+        Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+        RetryHelper retryHelper,
+        ExternalIdNotes.Factory extIdNotesFactory) {
+      this.repoManager = repoManager;
+      this.gitRefUpdated = gitRefUpdated;
+      this.allUsersName = allUsersName;
+      this.serverIdentProvider = serverIdentProvider;
+      this.identifiedUser = identifiedUser;
+      this.metaDataUpdateInternalFactory = metaDataUpdateInternalFactory;
+      this.retryHelper = retryHelper;
+      this.extIdNotesFactory = extIdNotesFactory;
+    }
+
+    public AccountsUpdate create() {
+      IdentifiedUser user = identifiedUser.get();
+      PersonIdent serverIdent = serverIdentProvider.get();
+      PersonIdent userIdent = createPersonIdent(serverIdent, user);
+      return new AccountsUpdate(
+          repoManager,
+          gitRefUpdated,
+          user,
+          allUsersName,
+          metaDataUpdateInternalFactory,
+          retryHelper,
+          extIdNotesFactory,
+          serverIdent,
+          userIdent);
+    }
+
+    private PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
+      return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
+    }
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
+  @Nullable private final IdentifiedUser currentUser;
+  private final AllUsersName allUsersName;
+  private final Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+  private final RetryHelper retryHelper;
+  private final ExternalIdNotesLoader extIdNotesLoader;
+  private final PersonIdent committerIdent;
+  private final PersonIdent authorIdent;
+  private final Runnable afterReadRevision;
+
+  private AccountsUpdate(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      @Nullable IdentifiedUser currentUser,
+      AllUsersName allUsersName,
+      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+      RetryHelper retryHelper,
+      ExternalIdNotesLoader extIdNotesLoader,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent) {
+    this(
+        repoManager,
+        gitRefUpdated,
+        currentUser,
+        allUsersName,
+        metaDataUpdateInternalFactory,
+        retryHelper,
+        extIdNotesLoader,
+        committerIdent,
+        authorIdent,
+        Runnables.doNothing());
+  }
+
+  @VisibleForTesting
+  public AccountsUpdate(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      @Nullable IdentifiedUser currentUser,
+      AllUsersName allUsersName,
+      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+      RetryHelper retryHelper,
+      ExternalIdNotesLoader extIdNotesLoader,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent,
+      Runnable afterReadRevision) {
+    this.repoManager = checkNotNull(repoManager, "repoManager");
+    this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
+    this.currentUser = currentUser;
+    this.allUsersName = checkNotNull(allUsersName, "allUsersName");
+    this.metaDataUpdateInternalFactory =
+        checkNotNull(metaDataUpdateInternalFactory, "metaDataUpdateInternalFactory");
+    this.retryHelper = checkNotNull(retryHelper, "retryHelper");
+    this.extIdNotesLoader = checkNotNull(extIdNotesLoader, "extIdNotesLoader");
+    this.committerIdent = checkNotNull(committerIdent, "committerIdent");
+    this.authorIdent = checkNotNull(authorIdent, "authorIdent");
+    this.afterReadRevision = afterReadRevision;
+  }
+
+  /**
+   * Inserts a new account.
+   *
+   * @param message commit message for the account creation, must not be {@code null or empty}
+   * @param accountId ID of the new account
+   * @param init consumer to populate the new account
+   * @return the newly created account
+   * @throws OrmDuplicateKeyException if the account already exists
+   * @throws IOException if creating the user branch fails due to an IO error
+   * @throws OrmException if creating the user branch fails
+   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   */
+  public Account insert(
+      String message, Account.Id accountId, Consumer<InternalAccountUpdate.Builder> init)
+      throws OrmException, IOException, ConfigInvalidException {
+    return insert(message, accountId, AccountUpdater.fromConsumer(init));
+  }
+
+  /**
+   * Inserts a new account.
+   *
+   * @param message commit message for the account creation, must not be {@code null or empty}
+   * @param accountId ID of the new account
+   * @param updater updater to populate the new account
+   * @return the newly created account
+   * @throws OrmDuplicateKeyException if the account already exists
+   * @throws IOException if creating the user branch fails due to an IO error
+   * @throws OrmException if creating the user branch fails
+   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   */
+  public Account insert(String message, Account.Id accountId, AccountUpdater updater)
+      throws OrmException, IOException, ConfigInvalidException {
+    return updateAccount(
+        r -> {
+          AccountConfig accountConfig = read(r, accountId);
+          Account account =
+              accountConfig.getNewAccount(new Timestamp(committerIdent.getWhen().getTime()));
+          InternalAccountUpdate.Builder updateBuilder = InternalAccountUpdate.builder();
+          updater.update(account, updateBuilder);
+
+          InternalAccountUpdate update = updateBuilder.build();
+          accountConfig.setAccountUpdate(update);
+          ExternalIdNotes extIdNotes = createExternalIdNotes(r, accountId, update);
+          UpdatedAccount updatedAccounts = new UpdatedAccount(message, accountConfig, extIdNotes);
+          updatedAccounts.setCreated(true);
+          return updatedAccounts;
+        });
+  }
+
+  /**
+   * Gets the account and updates it atomically.
+   *
+   * <p>Changing the registration date of an account is not supported.
+   *
+   * @param message commit message for the account update, must not be {@code null or empty}
+   * @param accountId ID of the account
+   * @param update consumer to update the account, only invoked if the account exists
+   * @return the updated account, {@code null} if the account doesn't exist
+   * @throws IOException if updating the user branch fails due to an IO error
+   * @throws OrmException if updating the user branch fails
+   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   */
+  public Account update(
+      String message, Account.Id accountId, Consumer<InternalAccountUpdate.Builder> update)
+      throws OrmException, IOException, ConfigInvalidException {
+    return update(message, accountId, AccountUpdater.fromConsumer(update));
+  }
+
+  /**
+   * Gets the account and updates it atomically.
+   *
+   * <p>Changing the registration date of an account is not supported.
+   *
+   * @param message commit message for the account update, must not be {@code null or empty}
+   * @param accountId ID of the account
+   * @param updater updater to update the account, only invoked if the account exists
+   * @return the updated account, {@code null} if the account doesn't exist
+   * @throws IOException if updating the user branch fails due to an IO error
+   * @throws OrmException if updating the user branch fails
+   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   */
+  @Nullable
+  public Account update(String message, Account.Id accountId, AccountUpdater updater)
+      throws OrmException, IOException, ConfigInvalidException {
+    return updateAccount(
+        r -> {
+          AccountConfig accountConfig = read(r, accountId);
+          Optional<Account> account = accountConfig.getLoadedAccount();
+          if (!account.isPresent()) {
+            return null;
+          }
+
+          InternalAccountUpdate.Builder updateBuilder = InternalAccountUpdate.builder();
+          updater.update(account.get(), updateBuilder);
+
+          InternalAccountUpdate update = updateBuilder.build();
+          accountConfig.setAccountUpdate(update);
+          ExternalIdNotes extIdNotes = createExternalIdNotes(r, accountId, update);
+          UpdatedAccount updatedAccounts = new UpdatedAccount(message, accountConfig, extIdNotes);
+          return updatedAccounts;
+        });
+  }
+
+  private AccountConfig read(Repository allUsersRepo, Account.Id accountId)
+      throws IOException, ConfigInvalidException {
+    AccountConfig accountConfig = new AccountConfig(accountId, allUsersRepo).load();
+    afterReadRevision.run();
+    return accountConfig;
+  }
+
+  private Account updateAccount(AccountUpdate accountUpdate)
+      throws IOException, ConfigInvalidException, OrmException {
+    return retryHelper.execute(
+        ActionType.ACCOUNT_UPDATE,
+        () -> {
+          try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+            UpdatedAccount updatedAccount = accountUpdate.update(allUsersRepo);
+            if (updatedAccount == null) {
+              return null;
+            }
+
+            commit(allUsersRepo, updatedAccount);
+            return updatedAccount.getAccount();
+          }
+        });
+  }
+
+  private ExternalIdNotes createExternalIdNotes(
+      Repository allUsersRepo, Account.Id accountId, InternalAccountUpdate update)
+      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
+    ExternalIdNotes.checkSameAccount(
+        Iterables.concat(
+            update.getCreatedExternalIds(),
+            update.getUpdatedExternalIds(),
+            update.getDeletedExternalIds()),
+        accountId);
+
+    ExternalIdNotes extIdNotes = extIdNotesLoader.load(allUsersRepo);
+    extIdNotes.replace(update.getDeletedExternalIds(), update.getCreatedExternalIds());
+    extIdNotes.upsert(update.getUpdatedExternalIds());
+    return extIdNotes;
+  }
+
+  private void commit(Repository allUsersRepo, UpdatedAccount updatedAccount) throws IOException {
+    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+    if (updatedAccount.isCreated()) {
+      commitNewAccountConfig(
+          updatedAccount.getMessage(),
+          allUsersRepo,
+          batchRefUpdate,
+          updatedAccount.getAccountConfig());
+    } else {
+      commitAccountConfig(
+          updatedAccount.getMessage(),
+          allUsersRepo,
+          batchRefUpdate,
+          updatedAccount.getAccountConfig());
+    }
+
+    commitExternalIdUpdates(
+        updatedAccount.getMessage(),
+        allUsersRepo,
+        batchRefUpdate,
+        updatedAccount.getExternalIdNotes());
+    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
+    updatedAccount.getExternalIdNotes().updateCaches();
+    gitRefUpdated.fire(
+        allUsersName, batchRefUpdate, currentUser != null ? currentUser.getAccount() : null);
+  }
+
+  private void commitNewAccountConfig(
+      String message,
+      Repository allUsersRepo,
+      BatchRefUpdate batchRefUpdate,
+      AccountConfig accountConfig)
+      throws IOException {
+    // When creating a new account we must allow empty commits so that the user branch gets created
+    // with an empty commit when no account properties are set and hence no 'account.config' file
+    // will be created.
+    commitAccountConfig(message, allUsersRepo, batchRefUpdate, accountConfig, true);
+  }
+
+  private void commitAccountConfig(
+      String message,
+      Repository allUsersRepo,
+      BatchRefUpdate batchRefUpdate,
+      AccountConfig accountConfig)
+      throws IOException {
+    commitAccountConfig(message, allUsersRepo, batchRefUpdate, accountConfig, false);
+  }
+
+  private void commitAccountConfig(
+      String message,
+      Repository allUsersRepo,
+      BatchRefUpdate batchRefUpdate,
+      AccountConfig accountConfig,
+      boolean allowEmptyCommit)
+      throws IOException {
+    try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
+      md.setAllowEmpty(allowEmptyCommit);
+      accountConfig.commit(md);
+    }
+  }
+
+  private void commitExternalIdUpdates(
+      String message,
+      Repository allUsersRepo,
+      BatchRefUpdate batchRefUpdate,
+      ExternalIdNotes extIdNotes)
+      throws IOException {
+    try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
+      extIdNotes.commit(md);
+    }
+  }
+
+  private MetaDataUpdate createMetaDataUpdate(
+      String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) {
+    MetaDataUpdate metaDataUpdate =
+        metaDataUpdateInternalFactory.get().create(allUsersName, allUsersRepo, batchRefUpdate);
+    if (!message.endsWith("\n")) {
+      message = message + "\n";
+    }
+
+    metaDataUpdate.getCommitBuilder().setMessage(message);
+    metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
+    metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
+    return metaDataUpdate;
+  }
+
+  @FunctionalInterface
+  private static interface AccountUpdate {
+    UpdatedAccount update(Repository allUsersRepo)
+        throws IOException, ConfigInvalidException, OrmException;
+  }
+
+  private static class UpdatedAccount {
+    private final String message;
+    private final AccountConfig accountConfig;
+    private final ExternalIdNotes extIdNotes;
+
+    private boolean created;
+
+    private UpdatedAccount(
+        String message, AccountConfig accountConfig, ExternalIdNotes extIdNotes) {
+      checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
+      this.message = checkNotNull(message);
+      this.accountConfig = checkNotNull(accountConfig);
+      this.extIdNotes = checkNotNull(extIdNotes);
+    }
+
+    public String getMessage() {
+      return message;
+    }
+
+    public AccountConfig getAccountConfig() {
+      return accountConfig;
+    }
+
+    public Account getAccount() {
+      return accountConfig.getLoadedAccount().get();
+    }
+
+    public ExternalIdNotes getExternalIdNotes() {
+      return extIdNotes;
+    }
+
+    public void setCreated(boolean created) {
+      this.created = created;
+    }
+
+    public boolean isCreated() {
+      return created;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java b/java/com/google/gerrit/server/account/AuthRequest.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
rename to java/com/google/gerrit/server/account/AuthRequest.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java b/java/com/google/gerrit/server/account/AuthResult.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
rename to java/com/google/gerrit/server/account/AuthResult.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthenticationFailedException.java b/java/com/google/gerrit/server/account/AuthenticationFailedException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AuthenticationFailedException.java
rename to java/com/google/gerrit/server/account/AuthenticationFailedException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthorizedKeys.java b/java/com/google/gerrit/server/account/AuthorizedKeys.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/AuthorizedKeys.java
rename to java/com/google/gerrit/server/account/AuthorizedKeys.java
diff --git a/java/com/google/gerrit/server/account/CapabilityCollection.java b/java/com/google/gerrit/server/account/CapabilityCollection.java
new file mode 100644
index 0000000..ee74f47
--- /dev/null
+++ b/java/com/google/gerrit/server/account/CapabilityCollection.java
@@ -0,0 +1,153 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.server.config.AdministrateServerGroups;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Caches active {@link GlobalCapability} set for a site. */
+public class CapabilityCollection {
+  public interface Factory {
+    CapabilityCollection create(@Nullable AccessSection section);
+  }
+
+  private final SystemGroupBackend systemGroupBackend;
+  private final ImmutableMap<String, ImmutableList<PermissionRule>> permissions;
+
+  public final ImmutableList<PermissionRule> administrateServer;
+  public final ImmutableList<PermissionRule> batchChangesLimit;
+  public final ImmutableList<PermissionRule> emailReviewers;
+  public final ImmutableList<PermissionRule> priority;
+  public final ImmutableList<PermissionRule> queryLimit;
+  public final ImmutableList<PermissionRule> createGroup;
+
+  @Inject
+  CapabilityCollection(
+      SystemGroupBackend systemGroupBackend,
+      @AdministrateServerGroups ImmutableSet<GroupReference> admins,
+      @Assisted @Nullable AccessSection section) {
+    this.systemGroupBackend = systemGroupBackend;
+
+    if (section == null) {
+      section = new AccessSection(AccessSection.GLOBAL_CAPABILITIES);
+    }
+
+    Map<String, List<PermissionRule>> tmp = new HashMap<>();
+    for (Permission permission : section.getPermissions()) {
+      for (PermissionRule rule : permission.getRules()) {
+        if (!permission.getName().equals(GlobalCapability.EMAIL_REVIEWERS)
+            && rule.getAction() == PermissionRule.Action.DENY) {
+          continue;
+        }
+
+        List<PermissionRule> r = tmp.get(permission.getName());
+        if (r == null) {
+          r = new ArrayList<>(2);
+          tmp.put(permission.getName(), r);
+        }
+        r.add(rule);
+      }
+    }
+    configureDefaults(tmp, section);
+    if (!tmp.containsKey(GlobalCapability.ADMINISTRATE_SERVER) && !admins.isEmpty()) {
+      tmp.put(GlobalCapability.ADMINISTRATE_SERVER, ImmutableList.<PermissionRule>of());
+    }
+
+    ImmutableMap.Builder<String, ImmutableList<PermissionRule>> m = ImmutableMap.builder();
+    for (Map.Entry<String, List<PermissionRule>> e : tmp.entrySet()) {
+      List<PermissionRule> rules = e.getValue();
+      if (GlobalCapability.ADMINISTRATE_SERVER.equals(e.getKey())) {
+        rules = mergeAdmin(admins, rules);
+      }
+      m.put(e.getKey(), ImmutableList.copyOf(rules));
+    }
+    permissions = m.build();
+
+    administrateServer = getPermission(GlobalCapability.ADMINISTRATE_SERVER);
+    batchChangesLimit = getPermission(GlobalCapability.BATCH_CHANGES_LIMIT);
+    emailReviewers = getPermission(GlobalCapability.EMAIL_REVIEWERS);
+    priority = getPermission(GlobalCapability.PRIORITY);
+    queryLimit = getPermission(GlobalCapability.QUERY_LIMIT);
+    createGroup = getPermission(GlobalCapability.CREATE_GROUP);
+  }
+
+  private static List<PermissionRule> mergeAdmin(
+      Set<GroupReference> admins, List<PermissionRule> rules) {
+    if (admins.isEmpty()) {
+      return rules;
+    }
+
+    List<PermissionRule> r = new ArrayList<>(admins.size() + rules.size());
+    for (GroupReference g : admins) {
+      r.add(new PermissionRule(g));
+    }
+    for (PermissionRule rule : rules) {
+      if (!admins.contains(rule.getGroup())) {
+        r.add(rule);
+      }
+    }
+    return r;
+  }
+
+  public ImmutableList<PermissionRule> getPermission(String permissionName) {
+    ImmutableList<PermissionRule> r = permissions.get(permissionName);
+    return r != null ? r : ImmutableList.<PermissionRule>of();
+  }
+
+  private void configureDefaults(Map<String, List<PermissionRule>> out, AccessSection section) {
+    configureDefault(
+        out,
+        section,
+        GlobalCapability.QUERY_LIMIT,
+        systemGroupBackend.getGroup(SystemGroupBackend.ANONYMOUS_USERS));
+  }
+
+  private static void configureDefault(
+      Map<String, List<PermissionRule>> out,
+      AccessSection section,
+      String capName,
+      GroupReference group) {
+    if (doesNotDeclare(section, capName)) {
+      PermissionRange.WithDefaults range = GlobalCapability.getRange(capName);
+      if (range != null) {
+        PermissionRule rule = new PermissionRule(group);
+        rule.setRange(range.getDefaultMin(), range.getDefaultMax());
+        out.put(capName, Collections.singletonList(rule));
+      }
+    }
+  }
+
+  private static boolean doesNotDeclare(AccessSection section, String capName) {
+    return section.getPermission(capName) == null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateGroupArgs.java b/java/com/google/gerrit/server/account/CreateGroupArgs.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/CreateGroupArgs.java
rename to java/com/google/gerrit/server/account/CreateGroupArgs.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java b/java/com/google/gerrit/server/account/DefaultRealm.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java
rename to java/com/google/gerrit/server/account/DefaultRealm.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java b/java/com/google/gerrit/server/account/EmailExpander.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java
rename to java/com/google/gerrit/server/account/EmailExpander.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
rename to java/com/google/gerrit/server/account/Emails.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/FakeRealm.java b/java/com/google/gerrit/server/account/FakeRealm.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/FakeRealm.java
rename to java/com/google/gerrit/server/account/FakeRealm.java
diff --git a/java/com/google/gerrit/server/account/GpgApiAdapter.java b/java/com/google/gerrit/server/account/GpgApiAdapter.java
new file mode 100644
index 0000000..b060140
--- /dev/null
+++ b/java/com/google/gerrit/server/account/GpgApiAdapter.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
+import com.google.gerrit.extensions.common.GpgKeyInfo;
+import com.google.gerrit.extensions.common.PushCertificateInfo;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.IdentifiedUser;
+import java.util.List;
+import java.util.Map;
+
+public interface GpgApiAdapter {
+  boolean isEnabled();
+
+  Map<String, GpgKeyInfo> listGpgKeys(AccountResource account)
+      throws RestApiException, GpgException;
+
+  Map<String, GpgKeyInfo> putGpgKeys(AccountResource account, List<String> add, List<String> delete)
+      throws RestApiException, GpgException;
+
+  GpgKeyApi gpgKey(AccountResource account, IdString idStr) throws RestApiException, GpgException;
+
+  PushCertificateInfo checkPushCertificate(String certStr, IdentifiedUser expectedUser)
+      throws GpgException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackend.java b/java/com/google/gerrit/server/account/GroupBackend.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackend.java
rename to java/com/google/gerrit/server/account/GroupBackend.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackends.java b/java/com/google/gerrit/server/account/GroupBackends.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackends.java
rename to java/com/google/gerrit/server/account/GroupBackends.java
diff --git a/java/com/google/gerrit/server/account/GroupCache.java b/java/com/google/gerrit/server/account/GroupCache.java
new file mode 100644
index 0000000..545998a
--- /dev/null
+++ b/java/com/google/gerrit/server/account/GroupCache.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.group.InternalGroup;
+import java.io.IOException;
+import java.util.Optional;
+
+/** Tracks group objects in memory for efficient access. */
+public interface GroupCache {
+  /**
+   * Looks up an internal group by its ID.
+   *
+   * @param groupId the ID of the internal group
+   * @return an {@code Optional} of the internal group, or an empty {@code Optional} if no internal
+   *     group with this ID exists on this server or an error occurred during lookup
+   */
+  Optional<InternalGroup> get(AccountGroup.Id groupId);
+
+  /**
+   * Looks up an internal group by its name.
+   *
+   * @param name the name of the internal group
+   * @return an {@code Optional} of the internal group, or an empty {@code Optional} if no internal
+   *     group with this name exists on this server or an error occurred during lookup
+   */
+  Optional<InternalGroup> get(AccountGroup.NameKey name);
+
+  /**
+   * Looks up an internal group by its UUID.
+   *
+   * @param groupUuid the UUID of the internal group
+   * @return an {@code Optional} of the internal group, or an empty {@code Optional} if no internal
+   *     group with this UUID exists on this server or an error occurred during lookup
+   */
+  Optional<InternalGroup> get(AccountGroup.UUID groupUuid);
+
+  /** Notify the cache that a new group was constructed. */
+  void onCreateGroup(AccountGroup.UUID groupUuid) throws IOException;
+
+  void evict(AccountGroup.UUID groupUuid, AccountGroup.Id groupId, AccountGroup.NameKey groupName)
+      throws IOException;
+
+  void evictAfterRename(AccountGroup.NameKey oldName) throws IOException;
+}
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
new file mode 100644
index 0000000..4783f29
--- /dev/null
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -0,0 +1,212 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.index.group.GroupIndexer;
+import com.google.gerrit.server.query.group.InternalGroupQuery;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.function.BooleanSupplier;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Tracks group objects in memory for efficient access. */
+@Singleton
+public class GroupCacheImpl implements GroupCache {
+  private static final Logger log = LoggerFactory.getLogger(GroupCacheImpl.class);
+
+  private static final String BYID_NAME = "groups";
+  private static final String BYNAME_NAME = "groups_byname";
+  private static final String BYUUID_NAME = "groups_byuuid";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        cache(BYID_NAME, AccountGroup.Id.class, new TypeLiteral<Optional<InternalGroup>>() {})
+            .maximumWeight(Long.MAX_VALUE)
+            .loader(ByIdLoader.class);
+
+        cache(BYNAME_NAME, String.class, new TypeLiteral<Optional<InternalGroup>>() {})
+            .maximumWeight(Long.MAX_VALUE)
+            .loader(ByNameLoader.class);
+
+        cache(BYUUID_NAME, String.class, new TypeLiteral<Optional<InternalGroup>>() {})
+            .maximumWeight(Long.MAX_VALUE)
+            .loader(ByUUIDLoader.class);
+
+        bind(GroupCacheImpl.class);
+        bind(GroupCache.class).to(GroupCacheImpl.class);
+      }
+    };
+  }
+
+  private final LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId;
+  private final LoadingCache<String, Optional<InternalGroup>> byName;
+  private final LoadingCache<String, Optional<InternalGroup>> byUUID;
+  private final Provider<GroupIndexer> indexer;
+
+  @Inject
+  GroupCacheImpl(
+      @Named(BYID_NAME) LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId,
+      @Named(BYNAME_NAME) LoadingCache<String, Optional<InternalGroup>> byName,
+      @Named(BYUUID_NAME) LoadingCache<String, Optional<InternalGroup>> byUUID,
+      Provider<GroupIndexer> indexer) {
+    this.byId = byId;
+    this.byName = byName;
+    this.byUUID = byUUID;
+    this.indexer = indexer;
+  }
+
+  @Override
+  public Optional<InternalGroup> get(AccountGroup.Id groupId) {
+    try {
+      return byId.get(groupId);
+    } catch (ExecutionException e) {
+      log.warn("Cannot load group " + groupId, e);
+      return Optional.empty();
+    }
+  }
+
+  @Override
+  public void evict(
+      AccountGroup.UUID groupUuid, AccountGroup.Id groupId, AccountGroup.NameKey groupName)
+      throws IOException {
+    if (groupId != null) {
+      byId.invalidate(groupId);
+    }
+    if (groupName != null) {
+      byName.invalidate(groupName.get());
+    }
+    if (groupUuid != null) {
+      byUUID.invalidate(groupUuid.get());
+    }
+    indexer.get().index(groupUuid);
+  }
+
+  @Override
+  public void evictAfterRename(AccountGroup.NameKey oldName) throws IOException {
+    if (oldName != null) {
+      byName.invalidate(oldName.get());
+    }
+  }
+
+  @Override
+  public Optional<InternalGroup> get(AccountGroup.NameKey name) {
+    if (name == null) {
+      return Optional.empty();
+    }
+    try {
+      return byName.get(name.get());
+    } catch (ExecutionException e) {
+      log.warn(String.format("Cannot look up group %s by name", name.get()), e);
+      return Optional.empty();
+    }
+  }
+
+  @Override
+  public Optional<InternalGroup> get(AccountGroup.UUID groupUuid) {
+    if (groupUuid == null) {
+      return Optional.empty();
+    }
+
+    try {
+      return byUUID.get(groupUuid.get());
+    } catch (ExecutionException e) {
+      log.warn(String.format("Cannot look up group %s by uuid", groupUuid.get()), e);
+      return Optional.empty();
+    }
+  }
+
+  @Override
+  public void onCreateGroup(AccountGroup.UUID groupUuid) throws IOException {
+    indexer.get().index(groupUuid);
+  }
+
+  static class ByIdLoader extends CacheLoader<AccountGroup.Id, Optional<InternalGroup>> {
+    private final SchemaFactory<ReviewDb> schema;
+    private final BooleanSupplier hasGroupIndex;
+    private final Provider<InternalGroupQuery> groupQueryProvider;
+
+    @Inject
+    ByIdLoader(
+        SchemaFactory<ReviewDb> schema,
+        GroupIndexCollection groupIndexCollection,
+        Provider<InternalGroupQuery> groupQueryProvider) {
+      this.schema = schema;
+      hasGroupIndex = () -> groupIndexCollection.getSearchIndex() != null;
+      this.groupQueryProvider = groupQueryProvider;
+    }
+
+    @Override
+    public Optional<InternalGroup> load(AccountGroup.Id key) throws Exception {
+      if (hasGroupIndex.getAsBoolean()) {
+        return groupQueryProvider.get().byId(key);
+      }
+
+      try (ReviewDb db = schema.open()) {
+        return Groups.getGroupFromReviewDb(db, key);
+      }
+    }
+  }
+
+  static class ByNameLoader extends CacheLoader<String, Optional<InternalGroup>> {
+    private final Provider<InternalGroupQuery> groupQueryProvider;
+
+    @Inject
+    ByNameLoader(Provider<InternalGroupQuery> groupQueryProvider) {
+      this.groupQueryProvider = groupQueryProvider;
+    }
+
+    @Override
+    public Optional<InternalGroup> load(String name) throws Exception {
+      return groupQueryProvider.get().byName(new AccountGroup.NameKey(name));
+    }
+  }
+
+  static class ByUUIDLoader extends CacheLoader<String, Optional<InternalGroup>> {
+    private final SchemaFactory<ReviewDb> schema;
+    private final Groups groups;
+
+    @Inject
+    ByUUIDLoader(SchemaFactory<ReviewDb> sf, Groups groups) {
+      schema = sf;
+      this.groups = groups;
+    }
+
+    @Override
+    public Optional<InternalGroup> load(String uuid) throws Exception {
+      try (ReviewDb db = schema.open()) {
+        return groups.getGroup(db, new AccountGroup.UUID(uuid));
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/GroupControl.java b/java/com/google/gerrit/server/account/GroupControl.java
new file mode 100644
index 0000000..119dc5b
--- /dev/null
+++ b/java/com/google/gerrit/server/account/GroupControl.java
@@ -0,0 +1,199 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.InternalGroupDescription;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Optional;
+
+/** Access control management for a group of accounts managed in Gerrit. */
+public class GroupControl {
+
+  @Singleton
+  public static class GenericFactory {
+    private final PermissionBackend permissionBackend;
+    private final GroupBackend groupBackend;
+
+    @Inject
+    GenericFactory(PermissionBackend permissionBackend, GroupBackend gb) {
+      this.permissionBackend = permissionBackend;
+      groupBackend = gb;
+    }
+
+    public GroupControl controlFor(CurrentUser who, AccountGroup.UUID groupId)
+        throws NoSuchGroupException {
+      GroupDescription.Basic group = groupBackend.get(groupId);
+      if (group == null) {
+        throw new NoSuchGroupException(groupId);
+      }
+      return new GroupControl(who, group, permissionBackend, groupBackend);
+    }
+  }
+
+  public static class Factory {
+    private final PermissionBackend permissionBackend;
+    private final GroupCache groupCache;
+    private final Provider<CurrentUser> user;
+    private final GroupBackend groupBackend;
+
+    @Inject
+    Factory(
+        PermissionBackend permissionBackend,
+        GroupCache gc,
+        Provider<CurrentUser> cu,
+        GroupBackend gb) {
+      this.permissionBackend = permissionBackend;
+      groupCache = gc;
+      user = cu;
+      groupBackend = gb;
+    }
+
+    public GroupControl controlFor(AccountGroup.Id groupId) throws NoSuchGroupException {
+      Optional<InternalGroup> group = groupCache.get(groupId);
+      return group
+          .map(InternalGroupDescription::new)
+          .map(this::controlFor)
+          .orElseThrow(() -> new NoSuchGroupException(groupId));
+    }
+
+    public GroupControl controlFor(AccountGroup.UUID groupId) throws NoSuchGroupException {
+      final GroupDescription.Basic group = groupBackend.get(groupId);
+      if (group == null) {
+        throw new NoSuchGroupException(groupId);
+      }
+      return controlFor(group);
+    }
+
+    public GroupControl controlFor(GroupDescription.Basic group) {
+      return new GroupControl(user.get(), group, permissionBackend, groupBackend);
+    }
+
+    public GroupControl validateFor(AccountGroup.UUID groupUUID) throws NoSuchGroupException {
+      final GroupControl c = controlFor(groupUUID);
+      if (!c.isVisible()) {
+        throw new NoSuchGroupException(groupUUID);
+      }
+      return c;
+    }
+  }
+
+  private final CurrentUser user;
+  private final GroupDescription.Basic group;
+  private Boolean isOwner;
+  private final PermissionBackend.WithUser perm;
+  private final GroupBackend groupBackend;
+
+  GroupControl(
+      CurrentUser who,
+      GroupDescription.Basic gd,
+      PermissionBackend permissionBackend,
+      GroupBackend gb) {
+    user = who;
+    group = gd;
+    this.perm = permissionBackend.user(user);
+    groupBackend = gb;
+  }
+
+  public GroupDescription.Basic getGroup() {
+    return group;
+  }
+
+  public CurrentUser getUser() {
+    return user;
+  }
+
+  /** Can this user see this group exists? */
+  public boolean isVisible() {
+    /* Check for canAdministrateServer may seem redundant, but allows
+     * for visibility of all groups that are not an internal group to
+     * server administrators.
+     */
+    return user.isInternalUser()
+        || groupBackend.isVisibleToAll(group.getGroupUUID())
+        || user.getEffectiveGroups().contains(group.getGroupUUID())
+        || isOwner()
+        || canAdministrateServer();
+  }
+
+  public boolean isOwner() {
+    if (isOwner != null) {
+      return isOwner;
+    }
+
+    // Keep this logic in sync with VisibleRefFilter#isOwner(...).
+    if (group instanceof GroupDescription.Internal) {
+      AccountGroup.UUID ownerUUID = ((GroupDescription.Internal) group).getOwnerGroupUUID();
+      isOwner = getUser().getEffectiveGroups().contains(ownerUUID) || canAdministrateServer();
+    } else {
+      isOwner = false;
+    }
+    return isOwner;
+  }
+
+  private boolean canAdministrateServer() {
+    try {
+      perm.check(GlobalPermission.ADMINISTRATE_SERVER);
+      return true;
+    } catch (AuthException | PermissionBackendException denied) {
+      return false;
+    }
+  }
+
+  public boolean canAddMember() {
+    return isOwner();
+  }
+
+  public boolean canRemoveMember() {
+    return isOwner();
+  }
+
+  public boolean canSeeMember(Account.Id id) {
+    if (user.isIdentifiedUser() && user.getAccountId().equals(id)) {
+      return true;
+    }
+    return canSeeMembers();
+  }
+
+  public boolean canAddGroup() {
+    return isOwner();
+  }
+
+  public boolean canRemoveGroup() {
+    return isOwner();
+  }
+
+  public boolean canSeeGroup() {
+    return canSeeMembers();
+  }
+
+  private boolean canSeeMembers() {
+    if (group instanceof GroupDescription.Internal) {
+      return ((GroupDescription.Internal) group).isVisibleToAll() || isOwner();
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCache.java b/java/com/google/gerrit/server/account/GroupIncludeCache.java
new file mode 100644
index 0000000..612730b
--- /dev/null
+++ b/java/com/google/gerrit/server/account/GroupIncludeCache.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.util.Collection;
+
+/** Tracks group inclusions in memory for efficient access. */
+public interface GroupIncludeCache {
+
+  /**
+   * Returns the UUIDs of all groups of which the specified account is a direct member.
+   *
+   * @param memberId the ID of the account
+   * @return the UUIDs of all groups having the account as member
+   */
+  Collection<AccountGroup.UUID> getGroupsWithMember(Account.Id memberId);
+
+  /**
+   * Returns the parent groups of a subgroup.
+   *
+   * @param groupId the UUID of the subgroup
+   * @return the UUIDs of all direct parent groups
+   */
+  Collection<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId);
+
+  /** @return set of any UUIDs that are not internal groups. */
+  Collection<AccountGroup.UUID> allExternalMembers();
+
+  void evictGroupsWithMember(Account.Id memberId);
+
+  void evictParentGroupsOf(AccountGroup.UUID groupId);
+}
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
new file mode 100644
index 0000000..9ecb491
--- /dev/null
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -0,0 +1,242 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.index.group.GroupField;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.query.group.InternalGroupQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.concurrent.ExecutionException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Tracks group inclusions in memory for efficient access. */
+@Singleton
+public class GroupIncludeCacheImpl implements GroupIncludeCache {
+  private static final Logger log = LoggerFactory.getLogger(GroupIncludeCacheImpl.class);
+  private static final String PARENT_GROUPS_NAME = "groups_bysubgroup";
+  private static final String GROUPS_WITH_MEMBER_NAME = "groups_bymember";
+  private static final String EXTERNAL_NAME = "groups_external";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        cache(
+                GROUPS_WITH_MEMBER_NAME,
+                Account.Id.class,
+                new TypeLiteral<ImmutableSet<AccountGroup.UUID>>() {})
+            .loader(GroupsWithMemberLoader.class);
+
+        cache(
+                PARENT_GROUPS_NAME,
+                AccountGroup.UUID.class,
+                new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
+            .loader(ParentGroupsLoader.class);
+
+        cache(EXTERNAL_NAME, String.class, new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
+            .loader(AllExternalLoader.class);
+
+        bind(GroupIncludeCacheImpl.class);
+        bind(GroupIncludeCache.class).to(GroupIncludeCacheImpl.class);
+      }
+    };
+  }
+
+  private final LoadingCache<Account.Id, ImmutableSet<AccountGroup.UUID>> groupsWithMember;
+  private final LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> parentGroups;
+  private final LoadingCache<String, ImmutableList<AccountGroup.UUID>> external;
+
+  @Inject
+  GroupIncludeCacheImpl(
+      @Named(GROUPS_WITH_MEMBER_NAME)
+          LoadingCache<Account.Id, ImmutableSet<AccountGroup.UUID>> groupsWithMember,
+      @Named(PARENT_GROUPS_NAME)
+          LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> parentGroups,
+      @Named(EXTERNAL_NAME) LoadingCache<String, ImmutableList<AccountGroup.UUID>> external) {
+    this.groupsWithMember = groupsWithMember;
+    this.parentGroups = parentGroups;
+    this.external = external;
+  }
+
+  @Override
+  public Collection<AccountGroup.UUID> getGroupsWithMember(Account.Id memberId) {
+    try {
+      return groupsWithMember.get(memberId);
+    } catch (ExecutionException e) {
+      log.warn(String.format("Cannot load groups containing %d as member", memberId.get()));
+      return ImmutableSet.of();
+    }
+  }
+
+  @Override
+  public Collection<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId) {
+    try {
+      return parentGroups.get(groupId);
+    } catch (ExecutionException e) {
+      log.warn("Cannot load included groups", e);
+      return Collections.emptySet();
+    }
+  }
+
+  @Override
+  public void evictGroupsWithMember(Account.Id memberId) {
+    if (memberId != null) {
+      groupsWithMember.invalidate(memberId);
+    }
+  }
+
+  @Override
+  public void evictParentGroupsOf(AccountGroup.UUID groupId) {
+    if (groupId != null) {
+      parentGroups.invalidate(groupId);
+
+      if (!AccountGroup.isInternalGroup(groupId)) {
+        external.invalidate(EXTERNAL_NAME);
+      }
+    }
+  }
+
+  @Override
+  public Collection<AccountGroup.UUID> allExternalMembers() {
+    try {
+      return external.get(EXTERNAL_NAME);
+    } catch (ExecutionException e) {
+      log.warn("Cannot load set of non-internal groups", e);
+      return ImmutableList.of();
+    }
+  }
+
+  static class GroupsWithMemberLoader
+      extends CacheLoader<Account.Id, ImmutableSet<AccountGroup.UUID>> {
+    private final SchemaFactory<ReviewDb> schema;
+    private final Provider<GroupIndex> groupIndexProvider;
+    private final Provider<InternalGroupQuery> groupQueryProvider;
+    private final GroupCache groupCache;
+
+    @Inject
+    GroupsWithMemberLoader(
+        SchemaFactory<ReviewDb> schema,
+        GroupIndexCollection groupIndexCollection,
+        Provider<InternalGroupQuery> groupQueryProvider,
+        GroupCache groupCache) {
+      this.schema = schema;
+      groupIndexProvider = groupIndexCollection::getSearchIndex;
+      this.groupQueryProvider = groupQueryProvider;
+      this.groupCache = groupCache;
+    }
+
+    @Override
+    public ImmutableSet<AccountGroup.UUID> load(Account.Id memberId) throws OrmException {
+      GroupIndex groupIndex = groupIndexProvider.get();
+      if (groupIndex != null && groupIndex.getSchema().hasField(GroupField.MEMBER)) {
+        return groupQueryProvider
+            .get()
+            .byMember(memberId)
+            .stream()
+            .map(InternalGroup::getGroupUUID)
+            .collect(toImmutableSet());
+      }
+      try (ReviewDb db = schema.open()) {
+        return Groups.getGroupsWithMemberFromReviewDb(db, memberId)
+            .map(groupCache::get)
+            .flatMap(Streams::stream)
+            .map(InternalGroup::getGroupUUID)
+            .collect(toImmutableSet());
+      }
+    }
+  }
+
+  static class ParentGroupsLoader
+      extends CacheLoader<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> {
+    private final SchemaFactory<ReviewDb> schema;
+    private final Provider<GroupIndex> groupIndexProvider;
+    private final Provider<InternalGroupQuery> groupQueryProvider;
+    private final GroupCache groupCache;
+
+    @Inject
+    ParentGroupsLoader(
+        SchemaFactory<ReviewDb> sf,
+        GroupIndexCollection groupIndexCollection,
+        Provider<InternalGroupQuery> groupQueryProvider,
+        GroupCache groupCache) {
+      schema = sf;
+      this.groupIndexProvider = groupIndexCollection::getSearchIndex;
+      this.groupQueryProvider = groupQueryProvider;
+      this.groupCache = groupCache;
+    }
+
+    @Override
+    public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) throws OrmException {
+      if (groupIndexProvider.get().getSchema().hasField(GroupField.SUBGROUP)) {
+        return groupQueryProvider
+            .get()
+            .bySubgroup(key)
+            .stream()
+            .map(InternalGroup::getGroupUUID)
+            .collect(toImmutableList());
+      }
+      try (ReviewDb db = schema.open()) {
+        return Groups.getParentGroupsFromReviewDb(db, key)
+            .map(groupCache::get)
+            .flatMap(Streams::stream)
+            .map(InternalGroup::getGroupUUID)
+            .collect(toImmutableList());
+      }
+    }
+  }
+
+  static class AllExternalLoader extends CacheLoader<String, ImmutableList<AccountGroup.UUID>> {
+    private final SchemaFactory<ReviewDb> schema;
+    private final Groups groups;
+
+    @Inject
+    AllExternalLoader(SchemaFactory<ReviewDb> sf, Groups groups) {
+      schema = sf;
+      this.groups = groups;
+    }
+
+    @Override
+    public ImmutableList<AccountGroup.UUID> load(String key) throws Exception {
+      try (ReviewDb db = schema.open()) {
+        return groups.getExternalGroups(db).collect(toImmutableList());
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/GroupMembers.java b/java/com/google/gerrit/server/account/GroupMembers.java
new file mode 100644
index 0000000..a5876d8
--- /dev/null
+++ b/java/com/google/gerrit/server/account/GroupMembers.java
@@ -0,0 +1,142 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+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.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+
+public class GroupMembers {
+
+  private final GroupCache groupCache;
+  private final GroupControl.Factory groupControlFactory;
+  private final AccountCache accountCache;
+  private final ProjectCache projectCache;
+
+  @Inject
+  GroupMembers(
+      GroupCache groupCache,
+      GroupControl.Factory groupControlFactory,
+      AccountCache accountCache,
+      ProjectCache projectCache) {
+    this.groupCache = groupCache;
+    this.groupControlFactory = groupControlFactory;
+    this.accountCache = accountCache;
+    this.projectCache = projectCache;
+  }
+
+  /**
+   * Recursively enumerate the members of the given group. Should not be used with the
+   * PROJECT_OWNERS magical group.
+   */
+  public Set<Account> listAccounts(AccountGroup.UUID groupUUID) throws IOException {
+    if (SystemGroupBackend.PROJECT_OWNERS.equals(groupUUID)) {
+      throw new IllegalStateException("listAccounts called with PROJECT_OWNERS argument");
+    }
+    try {
+      return listAccounts(groupUUID, null, new HashSet<AccountGroup.UUID>());
+    } catch (NoSuchProjectException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  /**
+   * Recursively enumerate the members of the given group. The project should be specified so the
+   * PROJECT_OWNERS magical group can be expanded.
+   */
+  public Set<Account> listAccounts(AccountGroup.UUID groupUUID, Project.NameKey project)
+      throws NoSuchProjectException, IOException {
+    return listAccounts(groupUUID, project, new HashSet<AccountGroup.UUID>());
+  }
+
+  private Set<Account> listAccounts(
+      final AccountGroup.UUID groupUUID,
+      @Nullable final Project.NameKey project,
+      final Set<AccountGroup.UUID> seen)
+      throws NoSuchProjectException, IOException {
+    if (SystemGroupBackend.PROJECT_OWNERS.equals(groupUUID)) {
+      return getProjectOwners(project, seen);
+    }
+    Optional<InternalGroup> group = groupCache.get(groupUUID);
+    if (group.isPresent()) {
+      return getGroupMembers(group.get(), project, seen);
+    }
+    return Collections.emptySet();
+  }
+
+  private Set<Account> getProjectOwners(final Project.NameKey project, Set<AccountGroup.UUID> seen)
+      throws NoSuchProjectException, IOException {
+    seen.add(SystemGroupBackend.PROJECT_OWNERS);
+    if (project == null) {
+      return Collections.emptySet();
+    }
+
+    ProjectState projectState = projectCache.checkedGet(project);
+    if (projectState == null) {
+      throw new NoSuchProjectException(project);
+    }
+
+    final HashSet<Account> projectOwners = new HashSet<>();
+    for (AccountGroup.UUID ownerGroup : projectState.getAllOwners()) {
+      if (!seen.contains(ownerGroup)) {
+        projectOwners.addAll(listAccounts(ownerGroup, project, seen));
+      }
+    }
+    return projectOwners;
+  }
+
+  private Set<Account> getGroupMembers(
+      InternalGroup group, @Nullable Project.NameKey project, Set<AccountGroup.UUID> seen)
+      throws NoSuchProjectException, IOException {
+    seen.add(group.getGroupUUID());
+    GroupControl groupControl = groupControlFactory.controlFor(new InternalGroupDescription(group));
+
+    Set<Account> directMembers =
+        group
+            .getMembers()
+            .stream()
+            .filter(groupControl::canSeeMember)
+            .map(accountCache::get)
+            .map(AccountState::getAccount)
+            .collect(toImmutableSet());
+
+    Set<Account> indirectMembers = new HashSet<>();
+    if (groupControl.canSeeGroup()) {
+      for (AccountGroup.UUID subgroupUuid : group.getSubgroups()) {
+        if (!seen.contains(subgroupUuid)) {
+          indirectMembers.addAll(listAccounts(subgroupUuid, project, seen));
+        }
+      }
+    }
+
+    return Sets.union(directMembers, indirectMembers);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembership.java b/java/com/google/gerrit/server/account/GroupMembership.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembership.java
rename to java/com/google/gerrit/server/account/GroupMembership.java
diff --git a/java/com/google/gerrit/server/account/GroupUUID.java b/java/com/google/gerrit/server/account/GroupUUID.java
new file mode 100644
index 0000000..a7b32a1
--- /dev/null
+++ b/java/com/google/gerrit/server/account/GroupUUID.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.security.MessageDigest;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+
+public class GroupUUID {
+  public static AccountGroup.UUID make(String groupName, PersonIdent creator) {
+    MessageDigest md = Constants.newMessageDigest();
+    md.update(Constants.encode("group " + groupName + "\n"));
+    md.update(Constants.encode("creator " + creator.toExternalString() + "\n"));
+    md.update(Constants.encode(String.valueOf(Math.random())));
+    return new AccountGroup.UUID(ObjectId.fromRaw(md.digest()).name());
+  }
+
+  private GroupUUID() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/HashedPassword.java b/java/com/google/gerrit/server/account/HashedPassword.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/HashedPassword.java
rename to java/com/google/gerrit/server/account/HashedPassword.java
diff --git a/java/com/google/gerrit/server/account/IncludingGroupMembership.java b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
new file mode 100644
index 0000000..a077629
--- /dev/null
+++ b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
@@ -0,0 +1,158 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Group membership checker for the internal group system.
+ *
+ * <p>Groups the user is directly a member of are pulled from the in-memory AccountCache by way of
+ * the IdentifiedUser. Transitive group memberhips are resolved on demand starting from the
+ * requested group and looking for a path to a group the user is a member of. Other group backends
+ * are supported by recursively invoking the universal GroupMembership.
+ */
+public class IncludingGroupMembership implements GroupMembership {
+  public interface Factory {
+    IncludingGroupMembership create(IdentifiedUser user);
+  }
+
+  private final GroupCache groupCache;
+  private final GroupIncludeCache includeCache;
+  private final IdentifiedUser user;
+  private final Map<AccountGroup.UUID, Boolean> memberOf;
+  private Set<AccountGroup.UUID> knownGroups;
+
+  @Inject
+  IncludingGroupMembership(
+      GroupCache groupCache, GroupIncludeCache includeCache, @Assisted IdentifiedUser user) {
+    this.groupCache = groupCache;
+    this.includeCache = includeCache;
+    this.user = user;
+    memberOf = new ConcurrentHashMap<>();
+  }
+
+  @Override
+  public boolean contains(AccountGroup.UUID id) {
+    if (id == null) {
+      return false;
+    }
+
+    Boolean b = memberOf.get(id);
+    return b != null ? b : containsAnyOf(ImmutableSet.of(id));
+  }
+
+  @Override
+  public boolean containsAnyOf(Iterable<AccountGroup.UUID> queryIds) {
+    // Prefer lookup of a cached result over expanding includes.
+    boolean tryExpanding = false;
+    for (AccountGroup.UUID id : queryIds) {
+      Boolean b = memberOf.get(id);
+      if (b == null) {
+        tryExpanding = true;
+      } else if (b) {
+        return true;
+      }
+    }
+
+    if (tryExpanding) {
+      for (AccountGroup.UUID id : queryIds) {
+        if (memberOf.containsKey(id)) {
+          // Membership was earlier proven to be false.
+          continue;
+        }
+
+        memberOf.put(id, false);
+        Optional<InternalGroup> group = groupCache.get(id);
+        if (!group.isPresent()) {
+          continue;
+        }
+        if (group.get().getMembers().contains(user.getAccountId())) {
+          memberOf.put(id, true);
+          return true;
+        }
+        if (search(group.get().getSubgroups())) {
+          memberOf.put(id, true);
+          return true;
+        }
+      }
+    }
+
+    return false;
+  }
+
+  @Override
+  public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> groupIds) {
+    Set<AccountGroup.UUID> r = new HashSet<>();
+    for (AccountGroup.UUID id : groupIds) {
+      if (contains(id)) {
+        r.add(id);
+      }
+    }
+    return r;
+  }
+
+  private boolean search(Iterable<AccountGroup.UUID> ids) {
+    return user.getEffectiveGroups().containsAnyOf(ids);
+  }
+
+  private ImmutableSet<AccountGroup.UUID> computeKnownGroups() {
+    GroupMembership membership = user.getEffectiveGroups();
+    Collection<AccountGroup.UUID> direct = includeCache.getGroupsWithMember(user.getAccountId());
+    direct.forEach(groupUuid -> memberOf.put(groupUuid, true));
+    Set<AccountGroup.UUID> r = Sets.newHashSet(direct);
+    r.remove(null);
+
+    List<AccountGroup.UUID> q = Lists.newArrayList(r);
+    for (AccountGroup.UUID g : membership.intersection(includeCache.allExternalMembers())) {
+      if (g != null && r.add(g)) {
+        q.add(g);
+      }
+    }
+
+    while (!q.isEmpty()) {
+      AccountGroup.UUID id = q.remove(q.size() - 1);
+      for (AccountGroup.UUID g : includeCache.parentGroupsOf(id)) {
+        if (g != null && r.add(g)) {
+          q.add(g);
+          memberOf.put(g, true);
+        }
+      }
+    }
+    return ImmutableSet.copyOf(r);
+  }
+
+  @Override
+  public Set<AccountGroup.UUID> getKnownGroups() {
+    if (knownGroups == null) {
+      knownGroups = computeKnownGroups();
+    }
+    return knownGroups;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
rename to java/com/google/gerrit/server/account/InternalAccountDirectory.java
diff --git a/java/com/google/gerrit/server/account/InternalAccountUpdate.java b/java/com/google/gerrit/server/account/InternalAccountUpdate.java
new file mode 100644
index 0000000..05c431e
--- /dev/null
+++ b/java/com/google/gerrit/server/account/InternalAccountUpdate.java
@@ -0,0 +1,525 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the Licens
+
+package com.google.gerrit.server.account;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Class to prepare updates to an account.
+ *
+ * <p>The getters in this class and the setters in the {@link Builder} correspond to fields in
+ * {@link Account}. The account ID and the registration date cannot be updated.
+ */
+@AutoValue
+public abstract class InternalAccountUpdate {
+  public static Builder builder() {
+    return new Builder.WrapperThatConvertsNullStringArgsToEmptyStrings(
+        new AutoValue_InternalAccountUpdate.Builder());
+  }
+
+  /**
+   * Returns the new value for the full name.
+   *
+   * @return the new value for the full name, {@code Optional#empty()} if the full name is not being
+   *     updated, {@code Optional#of("")} if the full name is unset, the wrapped value is never
+   *     {@code null}
+   */
+  public abstract Optional<String> getFullName();
+
+  /**
+   * Returns the new value for the preferred email.
+   *
+   * @return the new value for the preferred email, {@code Optional#empty()} if the preferred email
+   *     is not being updated, {@code Optional#of("")} if the preferred email is unset, the wrapped
+   *     value is never {@code null}
+   */
+  public abstract Optional<String> getPreferredEmail();
+
+  /**
+   * Returns the new value for the active flag.
+   *
+   * @return the new value for the active flag, {@code Optional#empty()} if the active flag is not
+   *     being updated, the wrapped value is never {@code null}
+   */
+  public abstract Optional<Boolean> getActive();
+
+  /**
+   * Returns the new value for the status.
+   *
+   * @return the new value for the status, {@code Optional#empty()} if the status is not being
+   *     updated, {@code Optional#of("")} if the status is unset, the wrapped value is never {@code
+   *     null}
+   */
+  public abstract Optional<String> getStatus();
+
+  /**
+   * Returns external IDs that should be newly created for the account.
+   *
+   * @return external IDs that should be newly created for the account
+   */
+  public abstract ImmutableSet<ExternalId> getCreatedExternalIds();
+
+  /**
+   * Returns external IDs that should be updated for the account.
+   *
+   * @return external IDs that should be updated for the account
+   */
+  public abstract ImmutableSet<ExternalId> getUpdatedExternalIds();
+
+  /**
+   * Returns external IDs that should be deleted for the account.
+   *
+   * @return external IDs that should be deleted for the account
+   */
+  public abstract ImmutableSet<ExternalId> getDeletedExternalIds();
+
+  /**
+   * Returns external IDs that should be updated for the account.
+   *
+   * @return external IDs that should be updated for the account
+   */
+  public abstract ImmutableMap<ProjectWatchKey, Set<NotifyType>> getUpdatedProjectWatches();
+
+  /**
+   * Returns project watches that should be deleted for the account.
+   *
+   * @return project watches that should be deleted for the account
+   */
+  public abstract ImmutableSet<ProjectWatchKey> getDeletedProjectWatches();
+
+  /**
+   * Returns the new value for the general preferences.
+   *
+   * <p>Only preferences that are non-null in the returned GeneralPreferencesInfo should be updated.
+   *
+   * @return the new value for the general preferences, {@code Optional#empty()} if the general
+   *     preferences are not being updated, the wrapped value is never {@code null}
+   */
+  public abstract Optional<GeneralPreferencesInfo> getGeneralPreferences();
+
+  /**
+   * Class to build an account update.
+   *
+   * <p>Account data is only updated if the corresponding setter is invoked. If a setter is not
+   * invoked the corresponding data stays unchanged. To unset string values the setter can be
+   * invoked with either {@code null} or an empty string ({@code null} is converted to an empty
+   * string by using the {@link WrapperThatConvertsNullStringArgsToEmptyStrings} wrapper, see {@link
+   * InternalAccountUpdate#builder()}).
+   */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    /**
+     * Sets a new full name for the account.
+     *
+     * @param fullName the new full name, if {@code null} or empty string the full name is unset
+     * @return the builder
+     */
+    public abstract Builder setFullName(String fullName);
+
+    /**
+     * Sets a new preferred email for the account.
+     *
+     * @param preferredEmail the new preferred email, if {@code null} or empty string the preferred
+     *     email is unset
+     * @return the builder
+     */
+    public abstract Builder setPreferredEmail(String preferredEmail);
+
+    /**
+     * Sets the active flag for the account.
+     *
+     * @param active {@code true} if the account should be set to active, {@code false} if the
+     *     account should be set to inactive
+     * @return the builder
+     */
+    public abstract Builder setActive(boolean active);
+
+    /**
+     * Sets a new status for the account.
+     *
+     * @param status the new status, if {@code null} or empty string the status is unset
+     * @return the builder
+     */
+    public abstract Builder setStatus(String status);
+
+    /**
+     * Returns a builder for the set of created external IDs.
+     *
+     * @return builder for the set of created external IDs.
+     */
+    abstract ImmutableSet.Builder<ExternalId> createdExternalIdsBuilder();
+
+    /**
+     * Adds a new external ID for the account.
+     *
+     * <p>The account ID of the external ID must match the account ID of the account that is
+     * updated.
+     *
+     * <p>If an external ID with the same ID already exists the account update will fail with {@link
+     * DuplicateExternalIdKeyException}.
+     *
+     * @param extId external ID that should be added
+     * @return the builder
+     */
+    public Builder addExternalId(ExternalId extId) {
+      return addExternalIds(ImmutableSet.of(extId));
+    }
+
+    /**
+     * Adds new external IDs for the account.
+     *
+     * <p>The account IDs of the external IDs must match the account ID of the account that is
+     * updated.
+     *
+     * <p>If any of the external ID keys already exists, the insert fails with {@link
+     * DuplicateExternalIdKeyException}.
+     *
+     * @param extIds external IDs that should be added
+     * @return the builder
+     */
+    public Builder addExternalIds(Collection<ExternalId> extIds) {
+      createdExternalIdsBuilder().addAll(extIds);
+      return this;
+    }
+
+    /**
+     * Returns a builder for the set of updated external IDs.
+     *
+     * @return builder for the set of updated external IDs.
+     */
+    abstract ImmutableSet.Builder<ExternalId> updatedExternalIdsBuilder();
+
+    /**
+     * Updates an external ID for the account.
+     *
+     * <p>The account ID of the external ID must match the account ID of the account that is
+     * updated.
+     *
+     * <p>If no external ID with the ID exists the external ID is created.
+     *
+     * @param extId external ID that should be updated
+     * @return the builder
+     */
+    public Builder updateExternalId(ExternalId extId) {
+      return updateExternalIds(ImmutableSet.of(extId));
+    }
+
+    /**
+     * Updates external IDs for the account.
+     *
+     * <p>The account IDs of the external IDs must match the account ID of the account that is
+     * updated.
+     *
+     * <p>If any of the external IDs already exists, it is overwritten. New external IDs are
+     * inserted.
+     *
+     * @param extIds external IDs that should be updated
+     * @return the builder
+     */
+    public Builder updateExternalIds(Collection<ExternalId> extIds) {
+      updatedExternalIdsBuilder().addAll(extIds);
+      return this;
+    }
+
+    /**
+     * Returns a builder for the set of deleted external IDs.
+     *
+     * @return builder for the set of deleted external IDs.
+     */
+    abstract ImmutableSet.Builder<ExternalId> deletedExternalIdsBuilder();
+
+    /**
+     * Deletes an external ID for the account.
+     *
+     * <p>The account ID of the external ID must match the account ID of the account that is
+     * updated.
+     *
+     * <p>If no external ID with the ID exists this is a no-op.
+     *
+     * @param extId external ID that should be deleted
+     * @return the builder
+     */
+    public Builder deleteExternalId(ExternalId extId) {
+      return deleteExternalIds(ImmutableSet.of(extId));
+    }
+
+    /**
+     * Deletes external IDs for the account.
+     *
+     * <p>The account IDs of the external IDs must match the account ID of the account that is
+     * updated.
+     *
+     * <p>For non-existing external IDs this is a no-op.
+     *
+     * @param extIds external IDs that should be deleted
+     * @return the builder
+     */
+    public Builder deleteExternalIds(Collection<ExternalId> extIds) {
+      deletedExternalIdsBuilder().addAll(extIds);
+      return this;
+    }
+
+    /**
+     * Replaces an external ID.
+     *
+     * @param extIdToDelete external ID that should be deleted
+     * @param extIdToAdd external ID that should be added
+     * @return the builder
+     */
+    public Builder replaceExternalId(ExternalId extIdToDelete, ExternalId extIdToAdd) {
+      return replaceExternalIds(ImmutableSet.of(extIdToDelete), ImmutableSet.of(extIdToAdd));
+    }
+
+    /**
+     * Replaces an external IDs.
+     *
+     * @param extIdsToDelete external IDs that should be deleted
+     * @param extIdsToAdd external IDs that should be added
+     * @return the builder
+     */
+    public Builder replaceExternalIds(
+        Collection<ExternalId> extIdsToDelete, Collection<ExternalId> extIdsToAdd) {
+      return deleteExternalIds(extIdsToDelete).addExternalIds(extIdsToAdd);
+    }
+
+    /**
+     * Returns a builder for the map of updated project watches.
+     *
+     * @return builder for the map of updated project watches.
+     */
+    abstract ImmutableMap.Builder<ProjectWatchKey, Set<NotifyType>> updatedProjectWatchesBuilder();
+
+    /**
+     * Updates a project watch for the account.
+     *
+     * <p>If no project watch with the key exists the project watch is created.
+     *
+     * @param projectWatchKey key of the project watch that should be updated
+     * @param notifyTypes the notify types that should be set for the project watch
+     * @return the builder
+     */
+    public Builder updateProjectWatch(
+        ProjectWatchKey projectWatchKey, Set<NotifyType> notifyTypes) {
+      return updateProjectWatches(ImmutableMap.of(projectWatchKey, notifyTypes));
+    }
+
+    /**
+     * Updates project watches for the account.
+     *
+     * <p>If any of the project watches already exists, it is overwritten. New project watches are
+     * inserted.
+     *
+     * @param projectWatches project watches that should be updated
+     * @return the builder
+     */
+    public Builder updateProjectWatches(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
+      updatedProjectWatchesBuilder().putAll(projectWatches);
+      return this;
+    }
+
+    /**
+     * Returns a builder for the set of deleted project watches.
+     *
+     * @return builder for the set of deleted project watches.
+     */
+    abstract ImmutableSet.Builder<ProjectWatchKey> deletedProjectWatchesBuilder();
+
+    /**
+     * Deletes a project watch for the account.
+     *
+     * <p>If no project watch with the ID exists this is a no-op.
+     *
+     * @param projectWatch project watch that should be deleted
+     * @return the builder
+     */
+    public Builder deleteProjectWatch(ProjectWatchKey projectWatch) {
+      return deleteProjectWatches(ImmutableSet.of(projectWatch));
+    }
+
+    /**
+     * Deletes project watches for the account.
+     *
+     * <p>For non-existing project watches this is a no-op.
+     *
+     * @param projectWatches project watches that should be deleted
+     * @return the builder
+     */
+    public Builder deleteProjectWatches(Collection<ProjectWatchKey> projectWatches) {
+      deletedProjectWatchesBuilder().addAll(projectWatches);
+      return this;
+    }
+
+    /**
+     * Sets the general preferences for the account.
+     *
+     * <p>Updates any preference that is non-null in the provided GeneralPreferencesInfo.
+     *
+     * @param generalPreferences the general preferences that should be set
+     * @return the builder
+     */
+    public abstract Builder setGeneralPreferences(GeneralPreferencesInfo generalPreferences);
+
+    /**
+     * Builds the account update.
+     *
+     * @return the account update
+     */
+    public abstract InternalAccountUpdate build();
+
+    /**
+     * Wrapper for {@link Builder} that converts {@code null} string arguments to empty strings for
+     * all setter methods. This allows us to treat setter invocations with a {@code null} string
+     * argument as signal to unset the corresponding field. E.g. for a builder method {@code
+     * setX(String)} the following semantics apply:
+     *
+     * <ul>
+     *   <li>Method is not invoked: X stays unchanged, X is stored as {@code Optional.empty()}.
+     *   <li>Argument is a non-empty string Y: X is updated to the Y, X is stored as {@code
+     *       Optional.of(Y)}.
+     *   <li>Argument is an empty string: X is unset, X is stored as {@code Optional.of("")}
+     *   <li>Argument is {@code null}: X is unset, X is stored as {@code Optional.of("")} (since the
+     *       wrapper converts {@code null} to an empty string)
+     * </ul>
+     *
+     * Without the wrapper calling {@code setX(null)} would fail with a {@link
+     * NullPointerException}. Hence all callers would need to take care to call {@link
+     * Strings#nullToEmpty(String)} for all string arguments and likely it would be forgotten in
+     * some places.
+     *
+     * <p>This means the stored values are interpreted like this:
+     *
+     * <ul>
+     *   <li>{@code Optional.empty()}: property stays unchanged
+     *   <li>{@code Optional.of(<non-empty-string>)}: property is updated
+     *   <li>{@code Optional.of("")}: property is unset
+     * </ul>
+     *
+     * This wrapper forwards all method invocations to the wrapped {@link Builder} instance that was
+     * created by AutoValue. For methods that return the AutoValue {@link Builder} instance the
+     * return value is replaced with the wrapper instance so that all chained calls go through the
+     * wrapper.
+     */
+    private static class WrapperThatConvertsNullStringArgsToEmptyStrings extends Builder {
+      private final Builder delegate;
+
+      private WrapperThatConvertsNullStringArgsToEmptyStrings(Builder delegate) {
+        this.delegate = delegate;
+      }
+
+      @Override
+      public Builder setFullName(String fullName) {
+        delegate.setFullName(Strings.nullToEmpty(fullName));
+        return this;
+      }
+
+      @Override
+      public Builder setPreferredEmail(String preferredEmail) {
+        delegate.setPreferredEmail(Strings.nullToEmpty(preferredEmail));
+        return this;
+      }
+
+      @Override
+      public Builder setActive(boolean active) {
+        delegate.setActive(active);
+        return this;
+      }
+
+      @Override
+      public Builder setStatus(String status) {
+        delegate.setStatus(Strings.nullToEmpty(status));
+        return this;
+      }
+
+      @Override
+      public InternalAccountUpdate build() {
+        return delegate.build();
+      }
+
+      @Override
+      ImmutableSet.Builder<ExternalId> createdExternalIdsBuilder() {
+        return delegate.createdExternalIdsBuilder();
+      }
+
+      @Override
+      public Builder addExternalIds(Collection<ExternalId> extIds) {
+        delegate.addExternalIds(extIds);
+        return this;
+      }
+
+      @Override
+      ImmutableSet.Builder<ExternalId> updatedExternalIdsBuilder() {
+        return delegate.updatedExternalIdsBuilder();
+      }
+
+      @Override
+      public Builder updateExternalIds(Collection<ExternalId> extIds) {
+        delegate.updateExternalIds(extIds);
+        return this;
+      }
+
+      @Override
+      ImmutableSet.Builder<ExternalId> deletedExternalIdsBuilder() {
+        return delegate.deletedExternalIdsBuilder();
+      }
+
+      @Override
+      public Builder deleteExternalIds(Collection<ExternalId> extIds) {
+        delegate.deleteExternalIds(extIds);
+        return this;
+      }
+
+      @Override
+      ImmutableMap.Builder<ProjectWatchKey, Set<NotifyType>> updatedProjectWatchesBuilder() {
+        return delegate.updatedProjectWatchesBuilder();
+      }
+
+      @Override
+      public Builder updateProjectWatches(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
+        delegate.updateProjectWatches(projectWatches);
+        return this;
+      }
+
+      @Override
+      ImmutableSet.Builder<ProjectWatchKey> deletedProjectWatchesBuilder() {
+        return delegate.deletedProjectWatchesBuilder();
+      }
+
+      @Override
+      public Builder deleteProjectWatches(Collection<ProjectWatchKey> projectWatches) {
+        delegate.deleteProjectWatches(projectWatches);
+        return this;
+      }
+
+      @Override
+      public Builder setGeneralPreferences(GeneralPreferencesInfo generalPreferences) {
+        delegate.setGeneralPreferences(generalPreferences);
+        return this;
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/InternalGroupBackend.java b/java/com/google/gerrit/server/account/InternalGroupBackend.java
new file mode 100644
index 0000000..4547807b
--- /dev/null
+++ b/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.InternalGroupDescription;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.group.db.GroupsNoteDbConsistencyChecker;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Implementation of GroupBackend for the internal group system. */
+@Singleton
+public class InternalGroupBackend implements GroupBackend {
+  private final GroupControl.Factory groupControlFactory;
+  private final GroupCache groupCache;
+  private final Groups groups;
+  private final SchemaFactory<ReviewDb> schema;
+  private final IncludingGroupMembership.Factory groupMembershipFactory;
+
+  @Inject
+  InternalGroupBackend(
+      GroupControl.Factory groupControlFactory,
+      GroupCache groupCache,
+      Groups groups,
+      SchemaFactory<ReviewDb> schema,
+      IncludingGroupMembership.Factory groupMembershipFactory) {
+    this.groupControlFactory = groupControlFactory;
+    this.groupCache = groupCache;
+    this.groups = groups;
+    this.schema = schema;
+    this.groupMembershipFactory = groupMembershipFactory;
+  }
+
+  @Override
+  public boolean handles(AccountGroup.UUID uuid) {
+    // See AccountGroup.isInternalGroup
+    return ObjectId.isId(uuid.get()); // [0-9a-f]{40};
+  }
+
+  @Override
+  public GroupDescription.Internal get(AccountGroup.UUID uuid) {
+    if (!handles(uuid)) {
+      return null;
+    }
+
+    return groupCache.get(uuid).map(InternalGroupDescription::new).orElse(null);
+  }
+
+  @Override
+  public Collection<GroupReference> suggest(String name, ProjectState project) {
+    try (ReviewDb db = schema.open()) {
+      return groups
+          .getAllGroupReferences(db)
+          .filter(group -> startsWithIgnoreCase(group, name))
+          .filter(this::isVisible)
+          .collect(toList());
+    } catch (OrmException | IOException | ConfigInvalidException e) {
+      return ImmutableList.of();
+    }
+  }
+
+  private static boolean startsWithIgnoreCase(GroupReference group, String name) {
+    return group.getName().regionMatches(true, 0, name, 0, name.length());
+  }
+
+  private boolean isVisible(GroupReference groupReference) {
+    Optional<InternalGroup> group = groupCache.get(groupReference.getUUID());
+    if (!group.isPresent()) {
+      // groupRefs are read from group name notes. There is an inconsistency if this lookup fails.
+      GroupsNoteDbConsistencyChecker.logFailToLoadFromGroupRefAsWarning(groupReference.getUUID());
+      return false;
+    }
+    return groupControlFactory.controlFor(new InternalGroupDescription(group.get())).isVisible();
+  }
+
+  @Override
+  public GroupMembership membershipsOf(IdentifiedUser user) {
+    return groupMembershipFactory.create(user);
+  }
+
+  @Override
+  public boolean isVisibleToAll(AccountGroup.UUID uuid) {
+    GroupDescription.Internal g = get(uuid);
+    return g != null && g.isVisibleToAll();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InvalidUserNameException.java b/java/com/google/gerrit/server/account/InvalidUserNameException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/InvalidUserNameException.java
rename to java/com/google/gerrit/server/account/InvalidUserNameException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ListGroupMembership.java b/java/com/google/gerrit/server/account/ListGroupMembership.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/ListGroupMembership.java
rename to java/com/google/gerrit/server/account/ListGroupMembership.java
diff --git a/java/com/google/gerrit/server/account/PreferencesConfig.java b/java/com/google/gerrit/server/account/PreferencesConfig.java
new file mode 100644
index 0000000..32df659
--- /dev/null
+++ b/java/com/google/gerrit/server/account/PreferencesConfig.java
@@ -0,0 +1,368 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
+import static com.google.gerrit.server.config.ConfigUtil.storeSection;
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_MATCH;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_TOKEN;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
+import static com.google.gerrit.server.git.UserConfigSections.URL_ALIAS;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.git.VersionedMetaData;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PreferencesConfig {
+  private static final Logger log = LoggerFactory.getLogger(PreferencesConfig.class);
+
+  public static final String PREFERENCES_CONFIG = "preferences.config";
+
+  private final Account.Id accountId;
+  private final Config cfg;
+  private final Config defaultCfg;
+  private final ValidationError.Sink validationErrorSink;
+
+  private GeneralPreferencesInfo generalPreferences;
+
+  public PreferencesConfig(
+      Account.Id accountId,
+      Config cfg,
+      Config defaultCfg,
+      ValidationError.Sink validationErrorSink) {
+    this.accountId = checkNotNull(accountId, "accountId");
+    this.cfg = checkNotNull(cfg, "cfg");
+    this.defaultCfg = checkNotNull(defaultCfg, "defaultCfg");
+    this.validationErrorSink = checkNotNull(validationErrorSink, "validationErrorSink");
+  }
+
+  public GeneralPreferencesInfo getGeneralPreferences() {
+    if (generalPreferences == null) {
+      parse();
+    }
+    return generalPreferences;
+  }
+
+  public void parse() {
+    generalPreferences = parse(null);
+  }
+
+  public Config saveGeneralPreferences(GeneralPreferencesInfo input) throws ConfigInvalidException {
+    // merge configs
+    input = parse(input);
+
+    storeSection(
+        cfg, UserConfigSections.GENERAL, null, input, parseDefaultPreferences(defaultCfg, null));
+    setChangeTable(cfg, input.changeTable);
+    setMy(cfg, input.my);
+    setUrlAliases(cfg, input.urlAliases);
+
+    // evict the cached general preferences
+    this.generalPreferences = null;
+
+    return cfg;
+  }
+
+  private GeneralPreferencesInfo parse(@Nullable GeneralPreferencesInfo input) {
+    try {
+      return parse(cfg, defaultCfg, input);
+    } catch (ConfigInvalidException e) {
+      validationErrorSink.error(
+          new ValidationError(
+              PREFERENCES_CONFIG,
+              String.format(
+                  "Invalid general preferences for account %d: %s",
+                  accountId.get(), e.getMessage())));
+      return new GeneralPreferencesInfo();
+    }
+  }
+
+  private static GeneralPreferencesInfo parse(
+      Config cfg, @Nullable Config defaultCfg, @Nullable GeneralPreferencesInfo input)
+      throws ConfigInvalidException {
+    GeneralPreferencesInfo r =
+        loadSection(
+            cfg,
+            UserConfigSections.GENERAL,
+            null,
+            new GeneralPreferencesInfo(),
+            defaultCfg != null
+                ? parseDefaultPreferences(defaultCfg, input)
+                : GeneralPreferencesInfo.defaults(),
+            input);
+    if (input != null) {
+      r.changeTable = input.changeTable;
+      r.my = input.my;
+      r.urlAliases = input.urlAliases;
+    } else {
+      r.changeTable = parseChangeTableColumns(cfg, defaultCfg);
+      r.my = parseMyMenus(cfg, defaultCfg);
+      r.urlAliases = parseUrlAliases(cfg, defaultCfg);
+    }
+    return r;
+  }
+
+  private static GeneralPreferencesInfo parseDefaultPreferences(
+      Config defaultCfg, GeneralPreferencesInfo input) throws ConfigInvalidException {
+    GeneralPreferencesInfo allUserPrefs = new GeneralPreferencesInfo();
+    loadSection(
+        defaultCfg,
+        UserConfigSections.GENERAL,
+        null,
+        allUserPrefs,
+        GeneralPreferencesInfo.defaults(),
+        input);
+    return updateDefaults(allUserPrefs);
+  }
+
+  private static GeneralPreferencesInfo updateDefaults(GeneralPreferencesInfo input) {
+    GeneralPreferencesInfo result = GeneralPreferencesInfo.defaults();
+    try {
+      for (Field field : input.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        Object newVal = field.get(input);
+        if (newVal != null) {
+          field.set(result, newVal);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      log.error("Failed to apply default general preferences", e);
+      return GeneralPreferencesInfo.defaults();
+    }
+    return result;
+  }
+
+  private static List<String> parseChangeTableColumns(Config cfg, @Nullable Config defaultCfg) {
+    List<String> changeTable = changeTable(cfg);
+    if (changeTable == null && defaultCfg != null) {
+      changeTable = changeTable(defaultCfg);
+    }
+    return changeTable;
+  }
+
+  private static List<MenuItem> parseMyMenus(Config cfg, @Nullable Config defaultCfg) {
+    List<MenuItem> my = my(cfg);
+    if (my.isEmpty() && defaultCfg != null) {
+      my = my(defaultCfg);
+    }
+    if (my.isEmpty()) {
+      my.add(new MenuItem("Changes", "#/dashboard/self", null));
+      my.add(new MenuItem("Draft Comments", "#/q/has:draft", null));
+      my.add(new MenuItem("Edits", "#/q/has:edit", null));
+      my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open", null));
+      my.add(new MenuItem("Starred Changes", "#/q/is:starred", null));
+      my.add(new MenuItem("Groups", "#/groups/self", null));
+    }
+    return my;
+  }
+
+  private static Map<String, String> parseUrlAliases(Config cfg, @Nullable Config defaultCfg) {
+    Map<String, String> urlAliases = urlAliases(cfg);
+    if (urlAliases == null && defaultCfg != null) {
+      urlAliases = urlAliases(defaultCfg);
+    }
+    return urlAliases;
+  }
+
+  public static GeneralPreferencesInfo readDefaultPreferences(Repository allUsersRepo)
+      throws IOException, ConfigInvalidException {
+    return parse(readDefaultConfig(allUsersRepo), null, null);
+  }
+
+  static Config readDefaultConfig(Repository allUsersRepo)
+      throws IOException, ConfigInvalidException {
+    VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
+    defaultPrefs.load(allUsersRepo);
+    return defaultPrefs.getConfig();
+  }
+
+  public static GeneralPreferencesInfo updateDefaultPreferences(
+      MetaDataUpdate md, GeneralPreferencesInfo input) throws IOException, ConfigInvalidException {
+    VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
+    defaultPrefs.load(md);
+    storeSection(
+        defaultPrefs.getConfig(),
+        UserConfigSections.GENERAL,
+        null,
+        input,
+        GeneralPreferencesInfo.defaults());
+    setMy(defaultPrefs.getConfig(), input.my);
+    setChangeTable(defaultPrefs.getConfig(), input.changeTable);
+    setUrlAliases(defaultPrefs.getConfig(), input.urlAliases);
+    defaultPrefs.commit(md);
+
+    return parse(defaultPrefs.getConfig(), null, null);
+  }
+
+  private static List<String> changeTable(Config cfg) {
+    return Lists.newArrayList(cfg.getStringList(CHANGE_TABLE, null, CHANGE_TABLE_COLUMN));
+  }
+
+  private static void setChangeTable(Config cfg, List<String> changeTable) {
+    if (changeTable != null) {
+      unsetSection(cfg, UserConfigSections.CHANGE_TABLE);
+      cfg.setStringList(UserConfigSections.CHANGE_TABLE, null, CHANGE_TABLE_COLUMN, changeTable);
+    }
+  }
+
+  private static List<MenuItem> my(Config cfg) {
+    List<MenuItem> my = new ArrayList<>();
+    for (String subsection : cfg.getSubsections(UserConfigSections.MY)) {
+      String url = my(cfg, subsection, KEY_URL, "#/");
+      String target = my(cfg, subsection, KEY_TARGET, url.startsWith("#") ? null : "_blank");
+      my.add(new MenuItem(subsection, url, target, my(cfg, subsection, KEY_ID, null)));
+    }
+    return my;
+  }
+
+  private static String my(Config cfg, String subsection, String key, String defaultValue) {
+    String val = cfg.getString(UserConfigSections.MY, subsection, key);
+    return !Strings.isNullOrEmpty(val) ? val : defaultValue;
+  }
+
+  private static void setMy(Config cfg, List<MenuItem> my) {
+    if (my != null) {
+      unsetSection(cfg, UserConfigSections.MY);
+      for (MenuItem item : my) {
+        checkState(!isNullOrEmpty(item.name), "MenuItem.name must not be null or empty");
+        checkState(!isNullOrEmpty(item.url), "MenuItem.url must not be null or empty");
+
+        setMy(cfg, item.name, KEY_URL, item.url);
+        setMy(cfg, item.name, KEY_TARGET, item.target);
+        setMy(cfg, item.name, KEY_ID, item.id);
+      }
+    }
+  }
+
+  public static void validateMy(List<MenuItem> my) throws BadRequestException {
+    if (my == null) {
+      return;
+    }
+    for (MenuItem item : my) {
+      checkRequiredMenuItemField(item.name, "name");
+      checkRequiredMenuItemField(item.url, "URL");
+    }
+  }
+
+  private static void checkRequiredMenuItemField(String value, String name)
+      throws BadRequestException {
+    if (isNullOrEmpty(value)) {
+      throw new BadRequestException(name + " for menu item is required");
+    }
+  }
+
+  private static boolean isNullOrEmpty(String value) {
+    return value == null || value.trim().isEmpty();
+  }
+
+  private static void setMy(Config cfg, String section, String key, @Nullable String val) {
+    if (val == null || val.trim().isEmpty()) {
+      cfg.unset(UserConfigSections.MY, section.trim(), key);
+    } else {
+      cfg.setString(UserConfigSections.MY, section.trim(), key, val.trim());
+    }
+  }
+
+  private static Map<String, String> urlAliases(Config cfg) {
+    HashMap<String, String> urlAliases = new HashMap<>();
+    for (String subsection : cfg.getSubsections(URL_ALIAS)) {
+      urlAliases.put(
+          cfg.getString(URL_ALIAS, subsection, KEY_MATCH),
+          cfg.getString(URL_ALIAS, subsection, KEY_TOKEN));
+    }
+    return !urlAliases.isEmpty() ? urlAliases : null;
+  }
+
+  private static void setUrlAliases(Config cfg, Map<String, String> urlAliases) {
+    if (urlAliases != null) {
+      for (String subsection : cfg.getSubsections(URL_ALIAS)) {
+        cfg.unsetSection(URL_ALIAS, subsection);
+      }
+
+      int i = 1;
+      for (Entry<String, String> e : urlAliases.entrySet()) {
+        cfg.setString(URL_ALIAS, URL_ALIAS + i, KEY_MATCH, e.getKey());
+        cfg.setString(URL_ALIAS, URL_ALIAS + i, KEY_TOKEN, e.getValue());
+        i++;
+      }
+    }
+  }
+
+  private static void unsetSection(Config cfg, String section) {
+    cfg.unsetSection(section, null);
+    for (String subsection : cfg.getSubsections(section)) {
+      cfg.unsetSection(section, subsection);
+    }
+  }
+
+  private static class VersionedDefaultPreferences extends VersionedMetaData {
+    private Config cfg;
+
+    @Override
+    protected String getRefName() {
+      return RefNames.REFS_USERS_DEFAULT;
+    }
+
+    private Config getConfig() {
+      checkState(cfg != null, "Default preferences not loaded yet.");
+      return cfg;
+    }
+
+    @Override
+    protected void onLoad() throws IOException, ConfigInvalidException {
+      cfg = readConfig(PREFERENCES_CONFIG);
+    }
+
+    @Override
+    protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+      if (Strings.isNullOrEmpty(commit.getMessage())) {
+        commit.setMessage("Update default preferences\n");
+      }
+      saveConfig(PREFERENCES_CONFIG, cfg);
+      return true;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java b/java/com/google/gerrit/server/account/Realm.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java
rename to java/com/google/gerrit/server/account/Realm.java
diff --git a/java/com/google/gerrit/server/account/SetInactiveFlag.java b/java/com/google/gerrit/server/account/SetInactiveFlag.java
new file mode 100644
index 0000000..f8cd650
--- /dev/null
+++ b/java/com/google/gerrit/server/account/SetInactiveFlag.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class SetInactiveFlag {
+
+  private final AccountsUpdate.Server accountsUpdate;
+
+  @Inject
+  SetInactiveFlag(AccountsUpdate.Server accountsUpdate) {
+    this.accountsUpdate = accountsUpdate;
+  }
+
+  public Response<?> deactivate(Account.Id accountId)
+      throws RestApiException, IOException, ConfigInvalidException, OrmException {
+    AtomicBoolean alreadyInactive = new AtomicBoolean(false);
+    Account account =
+        accountsUpdate
+            .create()
+            .update(
+                "Deactivate Account via API",
+                accountId,
+                (a, u) -> {
+                  if (!a.isActive()) {
+                    alreadyInactive.set(true);
+                  } else {
+                    u.setActive(false);
+                  }
+                });
+    if (account == null) {
+      throw new ResourceNotFoundException("account not found");
+    }
+    if (alreadyInactive.get()) {
+      throw new ResourceConflictException("account not active");
+    }
+    return Response.none();
+  }
+
+  public Response<String> activate(Account.Id accountId)
+      throws ResourceNotFoundException, IOException, ConfigInvalidException, OrmException {
+    AtomicBoolean alreadyActive = new AtomicBoolean(false);
+    Account account =
+        accountsUpdate
+            .create()
+            .update(
+                "Activate Account via API",
+                accountId,
+                (a, u) -> {
+                  if (a.isActive()) {
+                    alreadyActive.set(true);
+                  } else {
+                    u.setActive(true);
+                  }
+                });
+    if (account == null) {
+      throw new ResourceNotFoundException("account not found");
+    }
+    return alreadyActive.get() ? Response.ok("") : Response.created("");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/UniversalGroupBackend.java
rename to java/com/google/gerrit/server/account/UniversalGroupBackend.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountDestinations.java b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
rename to java/com/google/gerrit/server/account/VersionedAccountDestinations.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountPreferences.java b/java/com/google/gerrit/server/account/VersionedAccountPreferences.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountPreferences.java
rename to java/com/google/gerrit/server/account/VersionedAccountPreferences.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountQueries.java b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountQueries.java
rename to java/com/google/gerrit/server/account/VersionedAccountQueries.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
rename to java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
diff --git a/java/com/google/gerrit/server/account/WatchConfig.java b/java/com/google/gerrit/server/account/WatchConfig.java
new file mode 100644
index 0000000..7adadf7
--- /dev/null
+++ b/java/com/google/gerrit/server/account/WatchConfig.java
@@ -0,0 +1,240 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Enums;
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.ValidationError;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * ‘watch.config’ file in the user branch in the All-Users repository that contains the watch
+ * configuration of the user.
+ *
+ * <p>The 'watch.config' file is a git config file that has one 'project' section for all project
+ * watches of a project.
+ *
+ * <p>The project name is used as subsection name and the filters with the notify types that decide
+ * for which events email notifications should be sent are represented as 'notify' values in the
+ * subsection. A 'notify' value is formatted as {@code <filter>
+ * [<comma-separated-list-of-notify-types>]}:
+ *
+ * <pre>
+ *   [project "foo"]
+ *     notify = * [ALL_COMMENTS]
+ *     notify = branch:master [ALL_COMMENTS, NEW_PATCHSETS]
+ *     notify = branch:master owner:self [SUBMITTED_CHANGES]
+ * </pre>
+ *
+ * <p>If two notify values in the same subsection have the same filter they are merged on the next
+ * save, taking the union of the notify types.
+ *
+ * <p>For watch configurations that notify on no event the list of notify types is empty:
+ *
+ * <pre>
+ *   [project "foo"]
+ *     notify = branch:master []
+ * </pre>
+ *
+ * <p>Unknown notify types are ignored and removed on save.
+ */
+public class WatchConfig {
+  @AutoValue
+  public abstract static class ProjectWatchKey {
+    public static ProjectWatchKey create(Project.NameKey project, @Nullable String filter) {
+      return new AutoValue_WatchConfig_ProjectWatchKey(project, Strings.emptyToNull(filter));
+    }
+
+    public abstract Project.NameKey project();
+
+    public abstract @Nullable String filter();
+  }
+
+  public enum NotifyType {
+    // sort by name, except 'ALL' which should stay last
+    ABANDONED_CHANGES,
+    ALL_COMMENTS,
+    NEW_CHANGES,
+    NEW_PATCHSETS,
+    SUBMITTED_CHANGES,
+
+    ALL
+  }
+
+  public static final String FILTER_ALL = "*";
+
+  public static final String WATCH_CONFIG = "watch.config";
+  public static final String PROJECT = "project";
+  public static final String KEY_NOTIFY = "notify";
+
+  private final Account.Id accountId;
+  private final Config cfg;
+  private final ValidationError.Sink validationErrorSink;
+
+  private Map<ProjectWatchKey, Set<NotifyType>> projectWatches;
+
+  public WatchConfig(Account.Id accountId, Config cfg, ValidationError.Sink validationErrorSink) {
+    this.accountId = checkNotNull(accountId, "accountId");
+    this.cfg = checkNotNull(cfg, "cfg");
+    this.validationErrorSink = checkNotNull(validationErrorSink, "validationErrorSink");
+  }
+
+  public Map<ProjectWatchKey, Set<NotifyType>> getProjectWatches() {
+    if (projectWatches == null) {
+      parse();
+    }
+    return projectWatches;
+  }
+
+  public void parse() {
+    projectWatches = parse(accountId, cfg, validationErrorSink);
+  }
+
+  @VisibleForTesting
+  public static Map<ProjectWatchKey, Set<NotifyType>> parse(
+      Account.Id accountId, Config cfg, ValidationError.Sink validationErrorSink) {
+    Map<ProjectWatchKey, Set<NotifyType>> projectWatches = new HashMap<>();
+    for (String projectName : cfg.getSubsections(PROJECT)) {
+      String[] notifyValues = cfg.getStringList(PROJECT, projectName, KEY_NOTIFY);
+      for (String nv : notifyValues) {
+        if (Strings.isNullOrEmpty(nv)) {
+          continue;
+        }
+
+        NotifyValue notifyValue =
+            NotifyValue.parse(accountId, projectName, nv, validationErrorSink);
+        if (notifyValue == null) {
+          continue;
+        }
+
+        ProjectWatchKey key =
+            ProjectWatchKey.create(new Project.NameKey(projectName), notifyValue.filter());
+        if (!projectWatches.containsKey(key)) {
+          projectWatches.put(key, EnumSet.noneOf(NotifyType.class));
+        }
+        projectWatches.get(key).addAll(notifyValue.notifyTypes());
+      }
+    }
+    return projectWatches;
+  }
+
+  public Config save(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
+    this.projectWatches = projectWatches;
+
+    for (String projectName : cfg.getSubsections(PROJECT)) {
+      cfg.unsetSection(PROJECT, projectName);
+    }
+
+    ListMultimap<String, String> notifyValuesByProject =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+    for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : projectWatches.entrySet()) {
+      NotifyValue notifyValue = NotifyValue.create(e.getKey().filter(), e.getValue());
+      notifyValuesByProject.put(e.getKey().project().get(), notifyValue.toString());
+    }
+
+    for (Map.Entry<String, Collection<String>> e : notifyValuesByProject.asMap().entrySet()) {
+      cfg.setStringList(PROJECT, e.getKey(), KEY_NOTIFY, new ArrayList<>(e.getValue()));
+    }
+
+    return cfg;
+  }
+
+  @AutoValue
+  public abstract static class NotifyValue {
+    public static NotifyValue parse(
+        Account.Id accountId,
+        String project,
+        String notifyValue,
+        ValidationError.Sink validationErrorSink) {
+      notifyValue = notifyValue.trim();
+      int i = notifyValue.lastIndexOf('[');
+      if (i < 0 || notifyValue.charAt(notifyValue.length() - 1) != ']') {
+        validationErrorSink.error(
+            new ValidationError(
+                WATCH_CONFIG,
+                String.format(
+                    "Invalid project watch of account %d for project %s: %s",
+                    accountId.get(), project, notifyValue)));
+        return null;
+      }
+      String filter = notifyValue.substring(0, i).trim();
+      if (filter.isEmpty() || FILTER_ALL.equals(filter)) {
+        filter = null;
+      }
+
+      Set<NotifyType> notifyTypes = EnumSet.noneOf(NotifyType.class);
+      if (i + 1 < notifyValue.length() - 2) {
+        for (String nt :
+            Splitter.on(',')
+                .trimResults()
+                .splitToList(notifyValue.substring(i + 1, notifyValue.length() - 1))) {
+          NotifyType notifyType = Enums.getIfPresent(NotifyType.class, nt).orNull();
+          if (notifyType == null) {
+            validationErrorSink.error(
+                new ValidationError(
+                    WATCH_CONFIG,
+                    String.format(
+                        "Invalid notify type %s in project watch "
+                            + "of account %d for project %s: %s",
+                        nt, accountId.get(), project, notifyValue)));
+            continue;
+          }
+          notifyTypes.add(notifyType);
+        }
+      }
+      return create(filter, notifyTypes);
+    }
+
+    public static NotifyValue create(@Nullable String filter, Collection<NotifyType> notifyTypes) {
+      return new AutoValue_WatchConfig_NotifyValue(
+          Strings.emptyToNull(filter), Sets.immutableEnumSet(notifyTypes));
+    }
+
+    public abstract @Nullable String filter();
+
+    public abstract ImmutableSet<NotifyType> notifyTypes();
+
+    @Override
+    public String toString() {
+      List<NotifyType> notifyTypes = new ArrayList<>(notifyTypes());
+      StringBuilder notifyValue = new StringBuilder();
+      notifyValue.append(firstNonNull(filter(), FILTER_ALL)).append(" [");
+      Joiner.on(", ").appendTo(notifyValue, notifyTypes);
+      notifyValue.append("]");
+      return notifyValue.toString();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
new file mode 100644
index 0000000..c1ef261
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.io.IOException;
+import java.util.Collection;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class DisabledExternalIdCache implements ExternalIdCache {
+  public static Module module() {
+    return new AbstractModule() {
+
+      @Override
+      protected void configure() {
+        bind(ExternalIdCache.class).to(DisabledExternalIdCache.class);
+      }
+    };
+  }
+
+  @Override
+  public void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
+
+  @Override
+  public void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd) {}
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd) {}
+
+  @Override
+  public void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
+
+  @Override
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/DuplicateExternalIdKeyException.java b/java/com/google/gerrit/server/account/externalids/DuplicateExternalIdKeyException.java
new file mode 100644
index 0000000..b4c82d0
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/DuplicateExternalIdKeyException.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.account.externalids;
+
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+
+/**
+ * Exception that is thrown if an external ID cannot be inserted because an external ID with the
+ * same key already exists.
+ */
+public class DuplicateExternalIdKeyException extends OrmDuplicateKeyException {
+  private static final long serialVersionUID = 1L;
+
+  private final ExternalId.Key duplicateKey;
+
+  public DuplicateExternalIdKeyException(ExternalId.Key duplicateKey) {
+    super("Duplicate external ID key: " + duplicateKey.get());
+    this.duplicateKey = duplicateKey;
+  }
+
+  public ExternalId.Key getDuplicateKey() {
+    return duplicateKey;
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
new file mode 100644
index 0000000..8be7092
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -0,0 +1,390 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.HashedPassword;
+import java.io.Serializable;
+import java.util.Collection;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+
+@AutoValue
+public abstract class ExternalId implements Serializable {
+  private static final Pattern USER_NAME_PATTERN = Pattern.compile(Account.USER_NAME_PATTERN);
+
+  public static boolean isValidUsername(String username) {
+    return USER_NAME_PATTERN.matcher(username).matches();
+  }
+
+  private static final long serialVersionUID = 1L;
+
+  private static final String EXTERNAL_ID_SECTION = "externalId";
+  private static final String ACCOUNT_ID_KEY = "accountId";
+  private static final String EMAIL_KEY = "email";
+  private static final String PASSWORD_KEY = "password";
+
+  /**
+   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link
+   * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
+   *
+   * <p>The name {@code gerrit:} was a very poor choice.
+   */
+  public static final String SCHEME_GERRIT = "gerrit";
+
+  /** Scheme used for randomly created identities constructed by a UUID. */
+  public static final String SCHEME_UUID = "uuid";
+
+  /** Scheme used to represent only an email address. */
+  public static final String SCHEME_MAILTO = "mailto";
+
+  /** Scheme for the username used to authenticate an account, e.g. over SSH. */
+  public static final String SCHEME_USERNAME = "username";
+
+  /** Scheme used for GPG public keys. */
+  public static final String SCHEME_GPGKEY = "gpgkey";
+
+  /** Scheme for external auth used during authentication, e.g. OAuth Token */
+  public static final String SCHEME_EXTERNAL = "external";
+
+  @AutoValue
+  public abstract static class Key implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    public static Key create(@Nullable String scheme, String id) {
+      return new AutoValue_ExternalId_Key(Strings.emptyToNull(scheme), id);
+    }
+
+    /**
+     * Parses an external ID key from a string in the format "scheme:id" or "id".
+     *
+     * @return the parsed external ID key
+     */
+    public static Key parse(String externalId) {
+      int c = externalId.indexOf(':');
+      if (c < 1 || c >= externalId.length() - 1) {
+        return create(null, externalId);
+      }
+      return create(externalId.substring(0, c), externalId.substring(c + 1));
+    }
+
+    public abstract @Nullable String scheme();
+
+    public abstract String id();
+
+    public boolean isScheme(String scheme) {
+      return scheme.equals(scheme());
+    }
+
+    /**
+     * Returns the SHA1 of the external ID that is used as note ID in the refs/meta/external-ids
+     * notes branch.
+     */
+    @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
+    public ObjectId sha1() {
+      return ObjectId.fromRaw(Hashing.sha1().hashString(get(), UTF_8).asBytes());
+    }
+
+    /**
+     * Exports this external ID key as string with the format "scheme:id", or "id" if scheme is
+     * null.
+     *
+     * <p>This string representation is used as subsection name in the Git config file that stores
+     * the external ID.
+     */
+    public String get() {
+      if (scheme() != null) {
+        return scheme() + ":" + id();
+      }
+      return id();
+    }
+
+    @Override
+    public String toString() {
+      return get();
+    }
+
+    public static ImmutableSet<ExternalId.Key> from(Collection<ExternalId> extIds) {
+      return extIds.stream().map(ExternalId::key).collect(toImmutableSet());
+    }
+  }
+
+  public static ExternalId create(String scheme, String id, Account.Id accountId) {
+    return create(Key.create(scheme, id), accountId, null, null);
+  }
+
+  public static ExternalId create(
+      String scheme,
+      String id,
+      Account.Id accountId,
+      @Nullable String email,
+      @Nullable String hashedPassword) {
+    return create(Key.create(scheme, id), accountId, email, hashedPassword);
+  }
+
+  public static ExternalId create(Key key, Account.Id accountId) {
+    return create(key, accountId, null, null);
+  }
+
+  public static ExternalId create(
+      Key key, Account.Id accountId, @Nullable String email, @Nullable String hashedPassword) {
+    return create(
+        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), null);
+  }
+
+  public static ExternalId createWithPassword(
+      Key key, Account.Id accountId, @Nullable String email, String plainPassword) {
+    plainPassword = Strings.emptyToNull(plainPassword);
+    String hashedPassword =
+        plainPassword != null ? HashedPassword.fromPassword(plainPassword).encode() : null;
+    return create(key, accountId, email, hashedPassword);
+  }
+
+  public static ExternalId createUsername(String id, Account.Id accountId, String plainPassword) {
+    return createWithPassword(Key.create(SCHEME_USERNAME, id), accountId, null, plainPassword);
+  }
+
+  public static ExternalId createWithEmail(
+      String scheme, String id, Account.Id accountId, @Nullable String email) {
+    return createWithEmail(Key.create(scheme, id), accountId, email);
+  }
+
+  public static ExternalId createWithEmail(Key key, Account.Id accountId, @Nullable String email) {
+    return create(key, accountId, Strings.emptyToNull(email), null);
+  }
+
+  public static ExternalId createEmail(Account.Id accountId, String email) {
+    return createWithEmail(SCHEME_MAILTO, email, accountId, checkNotNull(email));
+  }
+
+  static ExternalId create(ExternalId extId, @Nullable ObjectId blobId) {
+    return new AutoValue_ExternalId(
+        extId.key(), extId.accountId(), extId.email(), extId.password(), blobId);
+  }
+
+  @VisibleForTesting
+  public static ExternalId create(
+      Key key,
+      Account.Id accountId,
+      @Nullable String email,
+      @Nullable String hashedPassword,
+      @Nullable ObjectId blobId) {
+    return new AutoValue_ExternalId(
+        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), blobId);
+  }
+
+  /**
+   * Parses an external ID from a byte array that contain the external ID as an Git config file
+   * text.
+   *
+   * <p>The Git config must have exactly one externalId subsection with an accountId and optionally
+   * email and password:
+   *
+   * <pre>
+   * [externalId "username:jdoe"]
+   *   accountId = 1003407
+   *   email = jdoe@example.com
+   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+   * </pre>
+   */
+  public static ExternalId parse(String noteId, byte[] raw, ObjectId blobId)
+      throws ConfigInvalidException {
+    checkNotNull(blobId);
+
+    Config externalIdConfig = new Config();
+    try {
+      externalIdConfig.fromText(new String(raw, UTF_8));
+    } catch (ConfigInvalidException e) {
+      throw invalidConfig(noteId, e.getMessage());
+    }
+
+    Set<String> externalIdKeys = externalIdConfig.getSubsections(EXTERNAL_ID_SECTION);
+    if (externalIdKeys.size() != 1) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Expected exactly 1 '%s' section, found %d",
+              EXTERNAL_ID_SECTION, externalIdKeys.size()));
+    }
+
+    String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
+    Key externalIdKey = Key.parse(externalIdKeyStr);
+    if (externalIdKey == null) {
+      throw invalidConfig(noteId, String.format("External ID %s is invalid", externalIdKeyStr));
+    }
+
+    if (!externalIdKey.sha1().getName().equals(noteId)) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
+    }
+
+    String email = externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, EMAIL_KEY);
+    String password =
+        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, PASSWORD_KEY);
+    int accountId = readAccountId(noteId, externalIdConfig, externalIdKeyStr);
+
+    return create(
+        externalIdKey,
+        new Account.Id(accountId),
+        Strings.emptyToNull(email),
+        Strings.emptyToNull(password),
+        blobId);
+  }
+
+  private static int readAccountId(String noteId, Config externalIdConfig, String externalIdKeyStr)
+      throws ConfigInvalidException {
+    String accountIdStr =
+        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY);
+    if (accountIdStr == null) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Value for '%s.%s.%s' is missing, expected account ID",
+              EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+    }
+
+    try {
+      int accountId =
+          externalIdConfig.getInt(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY, -1);
+      if (accountId < 0) {
+        throw invalidConfig(
+            noteId,
+            String.format(
+                "Value %s for '%s.%s.%s' is invalid, expected account ID",
+                accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+      }
+      return accountId;
+    } catch (IllegalArgumentException e) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Value %s for '%s.%s.%s' is invalid, expected account ID",
+              accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+    }
+  }
+
+  private static ConfigInvalidException invalidConfig(String noteId, String message) {
+    return new ConfigInvalidException(
+        String.format("Invalid external ID config for note '%s': %s", noteId, message));
+  }
+
+  public abstract Key key();
+
+  public abstract Account.Id accountId();
+
+  public abstract @Nullable String email();
+
+  public abstract @Nullable String password();
+
+  /**
+   * ID of the note blob in the external IDs branch that stores this external ID. {@code null} if
+   * the external ID was created in code and is not yet stored in Git.
+   */
+  public abstract @Nullable ObjectId blobId();
+
+  public void checkThatBlobIdIsSet() {
+    checkState(blobId() != null, "No blob ID set for external ID %s", key().get());
+  }
+
+  public boolean isScheme(String scheme) {
+    return key().isScheme(scheme);
+  }
+
+  public byte[] toByteArray() {
+    checkState(blobId() != null, "Missing blobId in external ID %s", key().get());
+    byte[] b = new byte[2 * Constants.OBJECT_ID_STRING_LENGTH + 1];
+    key().sha1().copyTo(b, 0);
+    b[Constants.OBJECT_ID_STRING_LENGTH] = ':';
+    blobId().copyTo(b, Constants.OBJECT_ID_STRING_LENGTH + 1);
+    return b;
+  }
+
+  /**
+   * For checking if two external IDs are equals the blobId is excluded and external IDs that have
+   * different blob IDs but identical other fields are considered equal. This way an external ID
+   * that was loaded from Git can be equal with an external ID that was created from code.
+   */
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof ExternalId)) {
+      return false;
+    }
+    ExternalId o = (ExternalId) obj;
+    return Objects.equals(key(), o.key())
+        && Objects.equals(accountId(), o.accountId())
+        && Objects.equals(email(), o.email())
+        && Objects.equals(password(), o.password());
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(key(), accountId(), email(), password());
+  }
+
+  /**
+   * Exports this external ID as Git config file text.
+   *
+   * <p>The Git config has exactly one externalId subsection with an accountId and optionally email
+   * and password:
+   *
+   * <pre>
+   * [externalId "username:jdoe"]
+   *   accountId = 1003407
+   *   email = jdoe@example.com
+   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+   * </pre>
+   */
+  @Override
+  public String toString() {
+    Config c = new Config();
+    writeToConfig(c);
+    return c.toText();
+  }
+
+  public void writeToConfig(Config c) {
+    String externalIdKey = key().get();
+    // Do not use c.setInt(...) to write the account ID because c.setInt(...) persists integers
+    // that can be expressed in KiB as a unit strings, e.g. "1024000" is stored as "100k". Using
+    // c.setString(...) ensures that account IDs are human readable.
+    c.setString(
+        EXTERNAL_ID_SECTION, externalIdKey, ACCOUNT_ID_KEY, Integer.toString(accountId().get()));
+    if (email() != null) {
+      c.setString(EXTERNAL_ID_SECTION, externalIdKey, EMAIL_KEY, email());
+    }
+    if (password() != null) {
+      c.setString(EXTERNAL_ID_SECTION, externalIdKey, PASSWORD_KEY, password());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
new file mode 100644
index 0000000..7878052
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.gerrit.reviewdb.client.Account;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Caches external IDs of all accounts.
+ *
+ * <p>On each cache access the SHA1 of the refs/meta/external-ids branch is read to verify that the
+ * cache is up to date.
+ */
+interface ExternalIdCache {
+  void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
+      throws IOException;
+
+  void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
+      throws IOException;
+
+  void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException;
+
+  void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException;
+
+  void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
+      throws IOException;
+
+  ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
+
+  ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException;
+
+  ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException;
+
+  ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException;
+
+  ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException;
+
+  default ImmutableSet<ExternalId> byEmail(String email) throws IOException {
+    return byEmails(email).get(email);
+  }
+
+  default void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId extId)
+      throws IOException {
+    onCreate(oldNotesRev, newNotesRev, Collections.singleton(extId));
+  }
+
+  default void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId extId)
+      throws IOException {
+    onRemove(oldNotesRev, newNotesRev, Collections.singleton(extId));
+  }
+
+  default void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId updatedExtId)
+      throws IOException {
+    onUpdate(oldNotesRev, newNotesRev, Collections.singleton(updatedExtId));
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
new file mode 100644
index 0000000..814f19e
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -0,0 +1,273 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static com.google.common.collect.ImmutableSetMultimap.toImmutableSetMultimap;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Consumer;
+import org.eclipse.jgit.lib.ObjectId;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Caches external IDs of all accounts. The external IDs are always loaded from NoteDb. */
+@Singleton
+class ExternalIdCacheImpl implements ExternalIdCache {
+  private static final Logger log = LoggerFactory.getLogger(ExternalIdCacheImpl.class);
+
+  private final LoadingCache<ObjectId, AllExternalIds> extIdsByAccount;
+  private final ExternalIdReader externalIdReader;
+  private final Lock lock;
+
+  @Inject
+  ExternalIdCacheImpl(ExternalIdReader externalIdReader) {
+    this.extIdsByAccount =
+        CacheBuilder.newBuilder()
+            // The cached data is potentially pretty large and we are always only interested
+            // in the latest value, hence the maximum cache size is set to 1.
+            // This can lead to extra cache loads in case of the following race:
+            // 1. thread 1 reads the notes ref at revision A
+            // 2. thread 2 updates the notes ref to revision B and stores the derived value
+            //    for B in the cache
+            // 3. thread 1 attempts to read the data for revision A from the cache, and misses
+            // 4. later threads attempt to read at B
+            // In this race unneeded reloads are done in step 3 (reload from revision A) and
+            // step 4 (reload from revision B, because the value for revision B was lost when the
+            // reload from revision A was done, since the cache can hold only one entry).
+            // These reloads could be avoided by increasing the cache size to 2. However the race
+            // window between reading the ref and looking it up in the cache is small so that
+            // it's rare that this race happens. Therefore it's not worth to double the memory
+            // usage of this cache, just to avoid this.
+            .maximumSize(1)
+            .build(new Loader(externalIdReader));
+    this.externalIdReader = externalIdReader;
+    this.lock = new ReentrantLock(true /* fair */);
+  }
+
+  @Override
+  public void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extIds)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : extIds) {
+            extId.checkThatBlobIdIsSet();
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extIds)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : extIds) {
+            m.remove(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onUpdate(
+      ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> updatedExtIds)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          removeKeys(m.values(), updatedExtIds.stream().map(e -> e.key()).collect(toSet()));
+          for (ExternalId updatedExtId : updatedExtIds) {
+            updatedExtId.checkThatBlobIdIsSet();
+            m.put(updatedExtId.accountId(), updatedExtId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException {
+    ExternalIdNotes.checkSameAccount(Iterables.concat(toRemove, toAdd), accountId);
+
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : toRemove) {
+            m.remove(extId.accountId(), extId);
+          }
+          for (ExternalId extId : toAdd) {
+            extId.checkThatBlobIdIsSet();
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : toRemove) {
+            m.remove(extId.accountId(), extId);
+          }
+          for (ExternalId extId : toAdd) {
+            extId.checkThatBlobIdIsSet();
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException {
+    return get().byAccount().get(accountId);
+  }
+
+  @Override
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
+    return get(rev).byAccount().get(accountId);
+  }
+
+  @Override
+  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
+    return get().byAccount();
+  }
+
+  @Override
+  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
+    AllExternalIds allExternalIds = get();
+    ImmutableSetMultimap.Builder<String, ExternalId> byEmails = ImmutableSetMultimap.builder();
+    for (String email : emails) {
+      byEmails.putAll(email, allExternalIds.byEmail().get(email));
+    }
+    return byEmails.build();
+  }
+
+  @Override
+  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
+    return get().byEmail();
+  }
+
+  private AllExternalIds get() throws IOException {
+    return get(externalIdReader.readRevision());
+  }
+
+  private AllExternalIds get(ObjectId rev) throws IOException {
+    try {
+      return extIdsByAccount.get(rev);
+    } catch (ExecutionException e) {
+      throw new IOException("Cannot load external ids", e);
+    }
+  }
+
+  private void updateCache(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Consumer<Multimap<Account.Id, ExternalId>> update) {
+    lock.lock();
+    try {
+      ListMultimap<Account.Id, ExternalId> m;
+      if (!ObjectId.zeroId().equals(oldNotesRev)) {
+        m =
+            MultimapBuilder.hashKeys()
+                .arrayListValues()
+                .build(extIdsByAccount.get(oldNotesRev).byAccount());
+      } else {
+        m = MultimapBuilder.hashKeys().arrayListValues().build();
+      }
+      update.accept(m);
+      extIdsByAccount.put(newNotesRev, AllExternalIds.create(m));
+    } catch (ExecutionException e) {
+      log.warn("Cannot update external IDs", e);
+    } finally {
+      lock.unlock();
+    }
+  }
+
+  private static void removeKeys(Collection<ExternalId> ids, Collection<ExternalId.Key> toRemove) {
+    Collections2.transform(ids, e -> e.key()).removeAll(toRemove);
+  }
+
+  private static class Loader extends CacheLoader<ObjectId, AllExternalIds> {
+    private final ExternalIdReader externalIdReader;
+
+    Loader(ExternalIdReader externalIdReader) {
+      this.externalIdReader = externalIdReader;
+    }
+
+    @Override
+    public AllExternalIds load(ObjectId notesRev) throws Exception {
+      Multimap<Account.Id, ExternalId> extIdsByAccount =
+          MultimapBuilder.hashKeys().arrayListValues().build();
+      for (ExternalId extId : externalIdReader.all(notesRev)) {
+        extId.checkThatBlobIdIsSet();
+        extIdsByAccount.put(extId.accountId(), extId);
+      }
+      return AllExternalIds.create(extIdsByAccount);
+    }
+  }
+
+  @AutoValue
+  abstract static class AllExternalIds {
+    static AllExternalIds create(Multimap<Account.Id, ExternalId> byAccount) {
+      ImmutableSetMultimap<String, ExternalId> byEmail =
+          byAccount
+              .values()
+              .stream()
+              .filter(e -> !Strings.isNullOrEmpty(e.email()))
+              .collect(toImmutableSetMultimap(ExternalId::email, e -> e));
+      return new AutoValue_ExternalIdCacheImpl_AllExternalIds(
+          ImmutableSetMultimap.copyOf(byAccount), byEmail);
+    }
+
+    public abstract ImmutableSetMultimap<Account.Id, ExternalId> byAccount();
+
+    public abstract ImmutableSetMultimap<String, ExternalId> byEmail();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
rename to java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
new file mode 100644
index 0000000..8e69f2e
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -0,0 +1,797 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toSet;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BlobBasedConfig;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * {@link VersionedMetaData} subclass to update external IDs.
+ *
+ * <p>On load the note map from {@code refs/meta/external-ids} is read, but the external IDs are not
+ * parsed yet (see {@link #onLoad()}).
+ *
+ * <p>After loading the note map callers can access single or all external IDs. Only now the
+ * requested external IDs are parsed.
+ *
+ * <p>After loading the note map callers can stage various external ID updates (insert, upsert,
+ * delete, replace).
+ *
+ * <p>On save the staged external ID updates are performed (see {@link #onSave(CommitBuilder)}).
+ *
+ * <p>After committing the external IDs a cache update can be requested which also reindexes the
+ * accounts for which external IDs have been updated (see {@link #updateCaches()}).
+ */
+public class ExternalIdNotes extends VersionedMetaData {
+  private static final Logger log = LoggerFactory.getLogger(ExternalIdNotes.class);
+
+  private static final int MAX_NOTE_SZ = 1 << 19;
+
+  public interface ExternalIdNotesLoader {
+    ExternalIdNotes load(Repository allUsersRepo) throws IOException, ConfigInvalidException;
+  }
+
+  @Singleton
+  public static class Factory implements ExternalIdNotesLoader {
+    private final ExternalIdCache externalIdCache;
+    private final AccountCache accountCache;
+
+    @Inject
+    Factory(ExternalIdCache externalIdCache, AccountCache accountCache) {
+      this.externalIdCache = externalIdCache;
+      this.accountCache = accountCache;
+    }
+
+    @Override
+    public ExternalIdNotes load(Repository allUsersRepo)
+        throws IOException, ConfigInvalidException {
+      return new ExternalIdNotes(externalIdCache, accountCache, allUsersRepo).load();
+    }
+  }
+
+  @Singleton
+  public static class FactoryNoReindex implements ExternalIdNotesLoader {
+    private final ExternalIdCache externalIdCache;
+
+    @Inject
+    FactoryNoReindex(ExternalIdCache externalIdCache) {
+      this.externalIdCache = externalIdCache;
+    }
+
+    @Override
+    public ExternalIdNotes load(Repository allUsersRepo)
+        throws IOException, ConfigInvalidException {
+      return new ExternalIdNotes(externalIdCache, null, allUsersRepo).load();
+    }
+  }
+
+  /**
+   * Loads the external ID notes for reading only. The external ID notes are loaded from the current
+   * HEAD revision of the {@code refs/meta/external-ids} branch.
+   *
+   * @return read-only {@link ExternalIdNotes} instance
+   */
+  public static ExternalIdNotes loadReadOnly(Repository allUsersRepo)
+      throws IOException, ConfigInvalidException {
+    return new ExternalIdNotes(new DisabledExternalIdCache(), null, allUsersRepo)
+        .setReadOnly()
+        .load();
+  }
+
+  /**
+   * Loads the external ID notes for reading only. The external ID notes are loaded from the
+   * specified revision of the {@code refs/meta/external-ids} branch.
+   *
+   * @param rev the revision from which the external ID notes should be loaded, if {@code null} the
+   *     external ID notes are loaded from the current HEAD revision
+   * @return read-only {@link ExternalIdNotes} instance
+   */
+  public static ExternalIdNotes loadReadOnly(Repository allUsersRepo, @Nullable ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    return new ExternalIdNotes(new DisabledExternalIdCache(), null, allUsersRepo)
+        .setReadOnly()
+        .load(rev);
+  }
+
+  /**
+   * Loads the external ID notes for updates without cache evictions. The external ID notes are
+   * loaded from the current HEAD revision of the {@code refs/meta/external-ids} branch.
+   *
+   * @return {@link ExternalIdNotes} instance that doesn't updates caches on save
+   */
+  public static ExternalIdNotes loadNoCacheUpdate(Repository allUsersRepo)
+      throws IOException, ConfigInvalidException {
+    return new ExternalIdNotes(new DisabledExternalIdCache(), null, allUsersRepo).load();
+  }
+
+  private final ExternalIdCache externalIdCache;
+  @Nullable private final AccountCache accountCache;
+  private final Repository repo;
+
+  private NoteMap noteMap;
+  private ObjectId oldRev;
+
+  // Staged note map updates that should be executed on save.
+  private List<NoteMapUpdate> noteMapUpdates = new ArrayList<>();
+
+  // Staged cache updates that should be executed after external ID changes have been committed.
+  private List<CacheUpdate> cacheUpdates = new ArrayList<>();
+
+  private Runnable afterReadRevision;
+  private boolean readOnly = false;
+
+  private ExternalIdNotes(
+      ExternalIdCache externalIdCache,
+      @Nullable AccountCache accountCache,
+      Repository allUsersRepo) {
+    this.externalIdCache = checkNotNull(externalIdCache, "externalIdCache");
+    this.accountCache = accountCache;
+    this.repo = checkNotNull(allUsersRepo, "allUsersRepo");
+  }
+
+  public ExternalIdNotes setAfterReadRevision(Runnable afterReadRevision) {
+    this.afterReadRevision = afterReadRevision;
+    return this;
+  }
+
+  private ExternalIdNotes setReadOnly() {
+    this.readOnly = true;
+    return this;
+  }
+
+  public Repository getRepository() {
+    return repo;
+  }
+
+  @Override
+  protected String getRefName() {
+    return RefNames.REFS_EXTERNAL_IDS;
+  }
+
+  /**
+   * Loads the external ID notes from the current HEAD revision of the {@code
+   * refs/meta/external-ids} branch.
+   *
+   * @return {@link ExternalIdNotes} instance for chaining
+   */
+  private ExternalIdNotes load() throws IOException, ConfigInvalidException {
+    load(repo);
+    return this;
+  }
+
+  /**
+   * Loads the external ID notes from the specified revision of the {@code refs/meta/external-ids}
+   * branch.
+   *
+   * @param rev the revision from which the external ID notes should be loaded, if {@code null} the
+   *     external ID notes are loaded from the current HEAD revision
+   * @return {@link ExternalIdNotes} instance for chaining
+   */
+  ExternalIdNotes load(@Nullable ObjectId rev) throws IOException, ConfigInvalidException {
+    if (rev == null) {
+      return load();
+    }
+    load(repo, rev);
+    return this;
+  }
+
+  /**
+   * Parses and returns the specified external ID.
+   *
+   * @param key the key of the external ID
+   * @return the external ID, {@code Optional.empty()} if it doesn't exist
+   */
+  public Optional<ExternalId> get(ExternalId.Key key) throws IOException, ConfigInvalidException {
+    checkLoaded();
+    ObjectId noteId = key.sha1();
+    if (!noteMap.contains(noteId)) {
+      return Optional.empty();
+    }
+
+    try (RevWalk rw = new RevWalk(repo)) {
+      ObjectId noteDataId = noteMap.get(noteId);
+      byte[] raw = readNoteData(rw, noteDataId);
+      return Optional.of(ExternalId.parse(noteId.name(), raw, noteDataId));
+    }
+  }
+
+  /**
+   * Parses and returns the specified external IDs.
+   *
+   * @param keys the keys of the external IDs
+   * @return the external IDs
+   */
+  public Set<ExternalId> get(Collection<ExternalId.Key> keys)
+      throws IOException, ConfigInvalidException {
+    checkLoaded();
+    HashSet<ExternalId> externalIds = Sets.newHashSetWithExpectedSize(keys.size());
+    for (ExternalId.Key key : keys) {
+      get(key).ifPresent(externalIds::add);
+    }
+    return externalIds;
+  }
+
+  /**
+   * Parses and returns all external IDs.
+   *
+   * <p>Invalid external IDs are ignored.
+   *
+   * @return all external IDs
+   */
+  public Set<ExternalId> all() throws IOException {
+    checkLoaded();
+    try (RevWalk rw = new RevWalk(repo)) {
+      Set<ExternalId> extIds = new HashSet<>();
+      for (Note note : noteMap) {
+        byte[] raw = readNoteData(rw, note.getData());
+        try {
+          extIds.add(ExternalId.parse(note.getName(), raw, note.getData()));
+        } catch (ConfigInvalidException | RuntimeException e) {
+          log.error(String.format("Ignoring invalid external ID note %s", note.getName()), e);
+        }
+      }
+      return extIds;
+    }
+  }
+
+  NoteMap getNoteMap() {
+    checkLoaded();
+    return noteMap;
+  }
+
+  static byte[] readNoteData(RevWalk rw, ObjectId noteDataId) throws IOException {
+    return rw.getObjectReader().open(noteDataId, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+  }
+
+  /**
+   * Inserts a new external ID.
+   *
+   * @throws IOException on IO error while checking if external ID already exists
+   * @throws DuplicateExternalIdKeyException if the external ID already exists
+   */
+  public void insert(ExternalId extId) throws IOException, DuplicateExternalIdKeyException {
+    insert(Collections.singleton(extId));
+  }
+
+  /**
+   * Inserts new external IDs.
+   *
+   * @throws IOException on IO error while checking if external IDs already exist
+   * @throws DuplicateExternalIdKeyException if any of the external ID already exists
+   */
+  public void insert(Collection<ExternalId> extIds)
+      throws IOException, DuplicateExternalIdKeyException {
+    checkLoaded();
+    checkExternalIdsDontExist(extIds);
+
+    Set<ExternalId> newExtIds = new HashSet<>();
+    noteMapUpdates.add(
+        (rw, n) -> {
+          for (ExternalId extId : extIds) {
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            newExtIds.add(insertedExtId);
+          }
+        });
+    cacheUpdates.add(cu -> cu.add(newExtIds));
+  }
+
+  /**
+   * Inserts or updates an external ID.
+   *
+   * <p>If the external ID already exists, it is overwritten, otherwise it is inserted.
+   */
+  public void upsert(ExternalId extId) throws IOException, ConfigInvalidException {
+    upsert(Collections.singleton(extId));
+  }
+
+  /**
+   * Inserts or updates external IDs.
+   *
+   * <p>If any of the external IDs already exists, it is overwritten. New external IDs are inserted.
+   */
+  public void upsert(Collection<ExternalId> extIds) throws IOException, ConfigInvalidException {
+    checkLoaded();
+    Set<ExternalId> removedExtIds = get(ExternalId.Key.from(extIds));
+    Set<ExternalId> updatedExtIds = new HashSet<>();
+    noteMapUpdates.add(
+        (rw, n) -> {
+          for (ExternalId extId : extIds) {
+            ExternalId updatedExtId = upsert(rw, inserter, noteMap, extId);
+            updatedExtIds.add(updatedExtId);
+          }
+        });
+    cacheUpdates.add(cu -> cu.remove(removedExtIds).add(updatedExtIds));
+  }
+
+  /**
+   * Deletes an external ID.
+   *
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key, but otherwise doesn't match the specified external ID.
+   */
+  public void delete(ExternalId extId) {
+    delete(Collections.singleton(extId));
+  }
+
+  /**
+   * Deletes external IDs.
+   *
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key as any of the external IDs that should be deleted, but otherwise doesn't match the that
+   *     external ID.
+   */
+  public void delete(Collection<ExternalId> extIds) {
+    checkLoaded();
+    Set<ExternalId> removedExtIds = new HashSet<>();
+    noteMapUpdates.add(
+        (rw, n) -> {
+          for (ExternalId extId : extIds) {
+            remove(rw, noteMap, extId);
+            removedExtIds.add(extId);
+          }
+        });
+    cacheUpdates.add(cu -> cu.remove(removedExtIds));
+  }
+
+  /**
+   * Delete an external ID by key.
+   *
+   * @throws IllegalStateException is thrown if the external ID does not belong to the specified
+   *     account.
+   */
+  public void delete(Account.Id accountId, ExternalId.Key extIdKey) {
+    delete(accountId, Collections.singleton(extIdKey));
+  }
+
+  /**
+   * Delete external IDs by external ID key.
+   *
+   * @throws IllegalStateException is thrown if any of the external IDs does not belong to the
+   *     specified account.
+   */
+  public void delete(Account.Id accountId, Collection<ExternalId.Key> extIdKeys) {
+    checkLoaded();
+    Set<ExternalId> removedExtIds = new HashSet<>();
+    noteMapUpdates.add(
+        (rw, n) -> {
+          for (ExternalId.Key extIdKey : extIdKeys) {
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, accountId);
+            removedExtIds.add(removedExtId);
+          }
+        });
+    cacheUpdates.add(cu -> cu.remove(removedExtIds));
+  }
+
+  /**
+   * Delete external IDs by external ID key.
+   *
+   * <p>The external IDs are deleted regardless of which account they belong to.
+   */
+  public void deleteByKeys(Collection<ExternalId.Key> extIdKeys) {
+    checkLoaded();
+    Set<ExternalId> removedExtIds = new HashSet<>();
+    noteMapUpdates.add(
+        (rw, n) -> {
+          for (ExternalId.Key extIdKey : extIdKeys) {
+            ExternalId extId = remove(rw, noteMap, extIdKey, null);
+            removedExtIds.add(extId);
+          }
+        });
+    cacheUpdates.add(cu -> cu.remove(removedExtIds));
+  }
+
+  /**
+   * Replaces external IDs for an account by external ID keys.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID key is specified for deletion and an external ID with the same key is specified to
+   * be added, the old external ID with that key is deleted first and then the new external ID is
+   * added (so the external ID for that key is replaced).
+   *
+   * @throws IllegalStateException is thrown if any of the specified external IDs does not belong to
+   *     the specified account.
+   */
+  public void replace(
+      Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, DuplicateExternalIdKeyException {
+    checkLoaded();
+    checkSameAccount(toAdd, accountId);
+    checkExternalIdKeysDontExist(ExternalId.Key.from(toAdd), toDelete);
+
+    Set<ExternalId> removedExtIds = new HashSet<>();
+    Set<ExternalId> updatedExtIds = new HashSet<>();
+    noteMapUpdates.add(
+        (rw, n) -> {
+          for (ExternalId.Key extIdKey : toDelete) {
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, accountId);
+            if (removedExtId != null) {
+              removedExtIds.add(removedExtId);
+            }
+          }
+
+          for (ExternalId extId : toAdd) {
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            updatedExtIds.add(insertedExtId);
+          }
+        });
+    cacheUpdates.add(cu -> cu.add(updatedExtIds).remove(removedExtIds));
+  }
+
+  /**
+   * Replaces external IDs for an account by external ID keys.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID key is specified for deletion and an external ID with the same key is specified to
+   * be added, the old external ID with that key is deleted first and then the new external ID is
+   * added (so the external ID for that key is replaced).
+   *
+   * <p>The external IDs are replaced regardless of which account they belong to.
+   */
+  public void replaceByKeys(Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, DuplicateExternalIdKeyException {
+    checkLoaded();
+    checkExternalIdKeysDontExist(ExternalId.Key.from(toAdd), toDelete);
+
+    Set<ExternalId> removedExtIds = new HashSet<>();
+    Set<ExternalId> updatedExtIds = new HashSet<>();
+    noteMapUpdates.add(
+        (rw, n) -> {
+          for (ExternalId.Key extIdKey : toDelete) {
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, null);
+            removedExtIds.add(removedExtId);
+          }
+
+          for (ExternalId extId : toAdd) {
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            updatedExtIds.add(insertedExtId);
+          }
+        });
+    cacheUpdates.add(cu -> cu.add(updatedExtIds).remove(removedExtIds));
+  }
+
+  /**
+   * Replaces an external ID.
+   *
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
+   */
+  public void replace(ExternalId toDelete, ExternalId toAdd)
+      throws IOException, DuplicateExternalIdKeyException {
+    replace(Collections.singleton(toDelete), Collections.singleton(toAdd));
+  }
+
+  /**
+   * Replaces external IDs.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID is specified for deletion and an external ID with the same key is specified to be
+   * added, the old external ID with that key is deleted first and then the new external ID is added
+   * (so the external ID for that key is replaced).
+   *
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
+   */
+  public void replace(Collection<ExternalId> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, DuplicateExternalIdKeyException {
+    Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd));
+    if (accountId == null) {
+      // toDelete and toAdd are empty -> nothing to do
+      return;
+    }
+
+    replace(accountId, toDelete.stream().map(e -> e.key()).collect(toSet()), toAdd);
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    noteMap = revision != null ? NoteMap.read(reader, revision) : NoteMap.newEmptyMap();
+
+    if (afterReadRevision != null) {
+      afterReadRevision.run();
+    }
+  }
+
+  @Override
+  public RevCommit commit(MetaDataUpdate update) throws IOException {
+    oldRev = revision != null ? revision.copy() : ObjectId.zeroId();
+    return super.commit(update);
+  }
+
+  /**
+   * Updates the caches (external ID cache, account cache) and reindexes the accounts for which
+   * external IDs were modified.
+   *
+   * <p>Must only be called after committing changes.
+   *
+   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(Repository)}.
+   *
+   * <p>No eviction from account cache if this instance was created by {@link FactoryNoReindex}.
+   */
+  public void updateCaches() throws IOException {
+    checkState(oldRev != null, "no changes committed yet");
+
+    ExternalIdCacheUpdates externalIdCacheUpdates = new ExternalIdCacheUpdates();
+    for (CacheUpdate cacheUpdate : cacheUpdates) {
+      cacheUpdate.execute(externalIdCacheUpdates);
+    }
+
+    externalIdCache.onReplace(
+        oldRev,
+        getRevision(),
+        externalIdCacheUpdates.getRemoved(),
+        externalIdCacheUpdates.getAdded());
+
+    if (accountCache != null) {
+      for (Account.Id id :
+          Streams.concat(
+                  externalIdCacheUpdates.getAdded().stream(),
+                  externalIdCacheUpdates.getRemoved().stream())
+              .map(ExternalId::accountId)
+              .collect(toSet())) {
+        accountCache.evict(id);
+      }
+    }
+
+    cacheUpdates.clear();
+    oldRev = null;
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    checkState(!readOnly, "Updating external IDs is disabled");
+
+    if (noteMapUpdates.isEmpty()) {
+      return false;
+    }
+
+    if (Strings.isNullOrEmpty(commit.getMessage())) {
+      commit.setMessage("Update external IDs\n");
+    }
+
+    try (RevWalk rw = new RevWalk(repo)) {
+      for (NoteMapUpdate noteMapUpdate : noteMapUpdates) {
+        try {
+          noteMapUpdate.execute(rw, noteMap);
+        } catch (DuplicateExternalIdKeyException e) {
+          throw new IOException(e);
+        }
+      }
+      noteMapUpdates.clear();
+
+      RevTree oldTree = revision != null ? rw.parseTree(revision) : null;
+      ObjectId newTreeId = noteMap.writeTree(inserter);
+      if (newTreeId.equals(oldTree)) {
+        return false;
+      }
+
+      commit.setTreeId(newTreeId);
+      return true;
+    }
+  }
+
+  /**
+   * Checks that all specified external IDs belong to the same account.
+   *
+   * @return the ID of the account to which all specified external IDs belong.
+   */
+  private static Account.Id checkSameAccount(Iterable<ExternalId> extIds) {
+    return checkSameAccount(extIds, null);
+  }
+
+  /**
+   * Checks that all specified external IDs belong to specified account. If no account is specified
+   * it is checked that all specified external IDs belong to the same account.
+   *
+   * @return the ID of the account to which all specified external IDs belong.
+   */
+  public static Account.Id checkSameAccount(
+      Iterable<ExternalId> extIds, @Nullable Account.Id accountId) {
+    for (ExternalId extId : extIds) {
+      if (accountId == null) {
+        accountId = extId.accountId();
+        continue;
+      }
+      checkState(
+          accountId.equals(extId.accountId()),
+          "external id %s belongs to account %s, expected account %s",
+          extId.key().get(),
+          extId.accountId().get(),
+          accountId.get());
+    }
+    return accountId;
+  }
+
+  /**
+   * Insert or updates an new external ID and sets it in the note map.
+   *
+   * <p>If the external ID already exists it is overwritten.
+   */
+  private static ExternalId upsert(
+      RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
+      throws IOException, ConfigInvalidException {
+    ObjectId noteId = extId.key().sha1();
+    Config c = new Config();
+    if (noteMap.contains(extId.key().sha1())) {
+      byte[] raw = readNoteData(rw, noteMap.get(noteId));
+      try {
+        c = new BlobBasedConfig(null, raw);
+      } catch (ConfigInvalidException e) {
+        throw new ConfigInvalidException(
+            String.format("Invalid external id config for note %s: %s", noteId, e.getMessage()));
+      }
+    }
+    extId.writeToConfig(c);
+    byte[] raw = c.toText().getBytes(UTF_8);
+    ObjectId noteData = ins.insert(OBJ_BLOB, raw);
+    noteMap.set(noteId, noteData);
+    return ExternalId.create(extId, noteData);
+  }
+
+  /**
+   * Removes an external ID from the note map.
+   *
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key, but otherwise doesn't match the specified external ID.
+   */
+  private static ExternalId remove(RevWalk rw, NoteMap noteMap, ExternalId extId)
+      throws IOException, ConfigInvalidException {
+    ObjectId noteId = extId.key().sha1();
+    if (!noteMap.contains(noteId)) {
+      return null;
+    }
+
+    ObjectId noteDataId = noteMap.get(noteId);
+    byte[] raw = readNoteData(rw, noteDataId);
+    ExternalId actualExtId = ExternalId.parse(noteId.name(), raw, noteDataId);
+    checkState(
+        extId.equals(actualExtId),
+        "external id %s should be removed, but it's not matching the actual external id %s",
+        extId.toString(),
+        actualExtId.toString());
+    noteMap.remove(noteId);
+    return actualExtId;
+  }
+
+  /**
+   * Removes an external ID from the note map by external ID key.
+   *
+   * @throws IllegalStateException is thrown if an expected account ID is provided and an external
+   *     ID with the specified key exists, but belongs to another account.
+   * @return the external ID that was removed, {@code null} if no external ID with the specified key
+   *     exists
+   */
+  private static ExternalId remove(
+      RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
+      throws IOException, ConfigInvalidException {
+    ObjectId noteId = extIdKey.sha1();
+    if (!noteMap.contains(noteId)) {
+      return null;
+    }
+
+    ObjectId noteDataId = noteMap.get(noteId);
+    byte[] raw = readNoteData(rw, noteDataId);
+    ExternalId extId = ExternalId.parse(noteId.name(), raw, noteDataId);
+    if (expectedAccountId != null) {
+      checkState(
+          expectedAccountId.equals(extId.accountId()),
+          "external id %s should be removed for account %s,"
+              + " but external id belongs to account %s",
+          extIdKey.get(),
+          expectedAccountId.get(),
+          extId.accountId().get());
+    }
+    noteMap.remove(noteId);
+    return extId;
+  }
+
+  private void checkExternalIdsDontExist(Collection<ExternalId> extIds)
+      throws DuplicateExternalIdKeyException, IOException {
+    checkExternalIdKeysDontExist(ExternalId.Key.from(extIds));
+  }
+
+  private void checkExternalIdKeysDontExist(
+      Collection<ExternalId.Key> extIdKeysToAdd, Collection<ExternalId.Key> extIdKeysToDelete)
+      throws DuplicateExternalIdKeyException, IOException {
+    HashSet<ExternalId.Key> newKeys = new HashSet<>(extIdKeysToAdd);
+    newKeys.removeAll(extIdKeysToDelete);
+    checkExternalIdKeysDontExist(newKeys);
+  }
+
+  private void checkExternalIdKeysDontExist(Collection<ExternalId.Key> extIdKeys)
+      throws IOException, DuplicateExternalIdKeyException {
+    for (ExternalId.Key extIdKey : extIdKeys) {
+      if (noteMap.contains(extIdKey.sha1())) {
+        throw new DuplicateExternalIdKeyException(extIdKey);
+      }
+    }
+  }
+
+  private void checkLoaded() {
+    checkState(noteMap != null, "External IDs not loaded yet");
+  }
+
+  @FunctionalInterface
+  private interface NoteMapUpdate {
+    void execute(RevWalk rw, NoteMap noteMap)
+        throws IOException, ConfigInvalidException, DuplicateExternalIdKeyException;
+  }
+
+  @FunctionalInterface
+  private interface CacheUpdate {
+    void execute(ExternalIdCacheUpdates cacheUpdates) throws IOException;
+  }
+
+  private static class ExternalIdCacheUpdates {
+    private final Set<ExternalId> added = new HashSet<>();
+    private final Set<ExternalId> removed = new HashSet<>();
+
+    ExternalIdCacheUpdates add(Collection<ExternalId> extIds) {
+      this.added.addAll(extIds);
+      return this;
+    }
+
+    public Set<ExternalId> getAdded() {
+      return ImmutableSet.copyOf(added);
+    }
+
+    ExternalIdCacheUpdates remove(Collection<ExternalId> extIds) {
+      this.removed.addAll(extIds);
+      return this;
+    }
+
+    public Set<ExternalId> getRemoved() {
+      return ImmutableSet.copyOf(removed);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
new file mode 100644
index 0000000..b4ff539
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -0,0 +1,147 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Class to read external IDs from NoteDb.
+ *
+ * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
+ * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
+ * is a git config file that contains an external ID. It has exactly one externalId subsection with
+ * an accountId and optionally email and password:
+ *
+ * <pre>
+ * [externalId "username:jdoe"]
+ *   accountId = 1003407
+ *   email = jdoe@example.com
+ *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+ * </pre>
+ */
+@Singleton
+public class ExternalIdReader {
+  public static ObjectId readRevision(Repository repo) throws IOException {
+    Ref ref = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
+    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
+  }
+
+  public static NoteMap readNoteMap(RevWalk rw, ObjectId rev) throws IOException {
+    if (!rev.equals(ObjectId.zeroId())) {
+      return NoteMap.read(rw.getObjectReader(), rw.parseCommit(rev));
+    }
+    return NoteMap.newEmptyMap();
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private boolean failOnLoad = false;
+  private final Timer0 readAllLatency;
+
+  @Inject
+  ExternalIdReader(
+      GitRepositoryManager repoManager, AllUsersName allUsersName, MetricMaker metricMaker) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.readAllLatency =
+        metricMaker.newTimer(
+            "notedb/read_all_external_ids_latency",
+            new Description("Latency for reading all external IDs from NoteDb.")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS));
+  }
+
+  @VisibleForTesting
+  public void setFailOnLoad(boolean failOnLoad) {
+    this.failOnLoad = failOnLoad;
+  }
+
+  ObjectId readRevision() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return readRevision(repo);
+    }
+  }
+
+  /** Reads and returns all external IDs. */
+  Set<ExternalId> all() throws IOException, ConfigInvalidException {
+    checkReadEnabled();
+
+    try (Timer0.Context ctx = readAllLatency.start();
+        Repository repo = repoManager.openRepository(allUsersName)) {
+      return ExternalIdNotes.loadReadOnly(repo).all();
+    }
+  }
+
+  /**
+   * Reads and returns all external IDs from the specified revision of the {@code
+   * refs/meta/external-ids} branch.
+   *
+   * @param rev the revision from which the external IDs should be read, if {@code null} the
+   *     external IDs are read from the current HEAD revision
+   * @return all external IDs that were read from the specified revision
+   */
+  Set<ExternalId> all(@Nullable ObjectId rev) throws IOException, ConfigInvalidException {
+    checkReadEnabled();
+
+    try (Timer0.Context ctx = readAllLatency.start();
+        Repository repo = repoManager.openRepository(allUsersName)) {
+      return ExternalIdNotes.loadReadOnly(repo, rev).all();
+    }
+  }
+
+  /** Reads and returns the specified external ID. */
+  @Nullable
+  ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
+    checkReadEnabled();
+
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return ExternalIdNotes.loadReadOnly(repo).get(key).orElse(null);
+    }
+  }
+
+  /** Reads and returns the specified external ID from the given revision. */
+  @Nullable
+  ExternalId get(ExternalId.Key key, ObjectId rev) throws IOException, ConfigInvalidException {
+    checkReadEnabled();
+
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return ExternalIdNotes.loadReadOnly(repo, rev).get(key).orElse(null);
+    }
+  }
+
+  private void checkReadEnabled() throws IOException {
+    if (failOnLoad) {
+      throw new IOException("Reading from external IDs is disabled");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
new file mode 100644
index 0000000..a8ecc40
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Class to access external IDs.
+ *
+ * <p>The external IDs are either read from NoteDb or retrieved from the cache.
+ */
+@Singleton
+public class ExternalIds {
+  private final ExternalIdReader externalIdReader;
+  private final ExternalIdCache externalIdCache;
+
+  @Inject
+  public ExternalIds(ExternalIdReader externalIdReader, ExternalIdCache externalIdCache) {
+    this.externalIdReader = externalIdReader;
+    this.externalIdCache = externalIdCache;
+  }
+
+  /** Returns all external IDs. */
+  public Set<ExternalId> all() throws IOException, ConfigInvalidException {
+    return externalIdReader.all();
+  }
+
+  /** Returns all external IDs from the specified revision of the refs/meta/external-ids branch. */
+  public Set<ExternalId> all(ObjectId rev) throws IOException, ConfigInvalidException {
+    return externalIdReader.all(rev);
+  }
+
+  /** Returns the specified external ID. */
+  @Nullable
+  public ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
+    return externalIdReader.get(key);
+  }
+
+  /** Returns the specified external ID from the given revision. */
+  @Nullable
+  public ExternalId get(ExternalId.Key key, ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    return externalIdReader.get(key, rev);
+  }
+
+  /** Returns the external IDs of the specified account. */
+  public Set<ExternalId> byAccount(Account.Id accountId) throws IOException {
+    return externalIdCache.byAccount(accountId);
+  }
+
+  /** Returns the external IDs of the specified account that have the given scheme. */
+  public Set<ExternalId> byAccount(Account.Id accountId, String scheme) throws IOException {
+    return byAccount(accountId).stream().filter(e -> e.key().isScheme(scheme)).collect(toSet());
+  }
+
+  /** Returns the external IDs of the specified account. */
+  public Set<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
+    return externalIdCache.byAccount(accountId, rev);
+  }
+
+  /** Returns the external IDs of the specified account that have the given scheme. */
+  public Set<ExternalId> byAccount(Account.Id accountId, String scheme, ObjectId rev)
+      throws IOException {
+    return byAccount(accountId, rev)
+        .stream()
+        .filter(e -> e.key().isScheme(scheme))
+        .collect(toSet());
+  }
+
+  /** Returns all external IDs by account. */
+  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
+    return externalIdCache.allByAccount();
+  }
+
+  /**
+   * Returns the external ID with the given email.
+   *
+   * <p>Each email should belong to a single external ID only. This means if more than one external
+   * ID is returned there is an inconsistency in the external IDs.
+   *
+   * <p>The external IDs are retrieved from the external ID cache. Each access to the external ID
+   * cache requires reading the SHA1 of the refs/meta/external-ids branch. If external IDs for
+   * multiple emails are needed it is more efficient to use {@link #byEmails(String...)} as this
+   * method reads the SHA1 of the refs/meta/external-ids branch only once (and not once per email).
+   *
+   * @see #byEmails(String...)
+   */
+  public Set<ExternalId> byEmail(String email) throws IOException {
+    return externalIdCache.byEmail(email);
+  }
+
+  /**
+   * Returns the external IDs for the given emails.
+   *
+   * <p>Each email should belong to a single external ID only. This means if more than one external
+   * ID for an email is returned there is an inconsistency in the external IDs.
+   *
+   * <p>The external IDs are retrieved from the external ID cache. Each access to the external ID
+   * cache requires reading the SHA1 of the refs/meta/external-ids branch. If external IDs for
+   * multiple emails are needed it is more efficient to use this method instead of {@link
+   * #byEmail(String)} as this method reads the SHA1 of the refs/meta/external-ids branch only once
+   * (and not once per email).
+   *
+   * @see #byEmail(String)
+   */
+  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
+    return externalIdCache.byEmails(emails);
+  }
+
+  /** Returns all external IDs by email. */
+  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
+    return externalIdCache.allByEmail();
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
new file mode 100644
index 0000000..91619a3
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -0,0 +1,152 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.HashedPassword;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.codec.DecoderException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class ExternalIdsConsistencyChecker {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
+  private final AccountCache accountCache;
+  private final OutgoingEmailValidator validator;
+
+  @Inject
+  ExternalIdsConsistencyChecker(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      AccountCache accountCache,
+      OutgoingEmailValidator validator) {
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+    this.accountCache = accountCache;
+    this.validator = validator;
+  }
+
+  public List<ConsistencyProblemInfo> check() throws IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return check(ExternalIdNotes.loadReadOnly(repo));
+    }
+  }
+
+  public List<ConsistencyProblemInfo> check(ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return check(ExternalIdNotes.loadReadOnly(repo, rev));
+    }
+  }
+
+  private List<ConsistencyProblemInfo> check(ExternalIdNotes extIdNotes) throws IOException {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    ListMultimap<String, ExternalId.Key> emails =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+
+    try (RevWalk rw = new RevWalk(extIdNotes.getRepository())) {
+      NoteMap noteMap = extIdNotes.getNoteMap();
+      for (Note note : noteMap) {
+        byte[] raw = ExternalIdNotes.readNoteData(rw, note.getData());
+        try {
+          ExternalId extId = ExternalId.parse(note.getName(), raw, note.getData());
+          problems.addAll(validateExternalId(extId));
+
+          if (extId.email() != null) {
+            emails.put(extId.email(), extId.key());
+          }
+        } catch (ConfigInvalidException e) {
+          addError(String.format(e.getMessage()), problems);
+        }
+      }
+    }
+
+    emails
+        .asMap()
+        .entrySet()
+        .stream()
+        .filter(e -> e.getValue().size() > 1)
+        .forEach(
+            e ->
+                addError(
+                    String.format(
+                        "Email '%s' is not unique, it's used by the following external IDs: %s",
+                        e.getKey(),
+                        e.getValue()
+                            .stream()
+                            .map(k -> "'" + k.get() + "'")
+                            .sorted()
+                            .collect(joining(", "))),
+                    problems));
+
+    return problems;
+  }
+
+  private List<ConsistencyProblemInfo> validateExternalId(ExternalId extId) {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    if (accountCache.getOrNull(extId.accountId()) == null) {
+      addError(
+          String.format(
+              "External ID '%s' belongs to account that doesn't exist: %s",
+              extId.key().get(), extId.accountId().get()),
+          problems);
+    }
+
+    if (extId.email() != null && !validator.isValid(extId.email())) {
+      addError(
+          String.format(
+              "External ID '%s' has an invalid email: %s", extId.key().get(), extId.email()),
+          problems);
+    }
+
+    if (extId.password() != null && extId.isScheme(SCHEME_USERNAME)) {
+      try {
+        HashedPassword.decode(extId.password());
+      } catch (DecoderException e) {
+        addError(
+            String.format(
+                "External ID '%s' has an invalid password: %s", extId.key().get(), e.getMessage()),
+            problems);
+      }
+    }
+
+    return problems;
+  }
+
+  private static void addError(String error, List<ConsistencyProblemInfo> problems) {
+    problems.add(new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, error));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/ApiUtil.java b/java/com/google/gerrit/server/api/ApiUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/api/ApiUtil.java
rename to java/com/google/gerrit/server/api/ApiUtil.java
diff --git a/java/com/google/gerrit/server/api/BUILD b/java/com/google/gerrit/server/api/BUILD
new file mode 100644
index 0000000..910ecd3
--- /dev/null
+++ b/java/com/google/gerrit/server/api/BUILD
@@ -0,0 +1,22 @@
+java_library(
+    name = "api",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/restapi",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:servlet-api-3_1",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/GerritApiImpl.java b/java/com/google/gerrit/server/api/GerritApiImpl.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/api/GerritApiImpl.java
rename to java/com/google/gerrit/server/api/GerritApiImpl.java
diff --git a/java/com/google/gerrit/server/api/GerritApiModule.java b/java/com/google/gerrit/server/api/GerritApiModule.java
new file mode 100644
index 0000000..9e60107
--- /dev/null
+++ b/java/com/google/gerrit/server/api/GerritApiModule.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api;
+
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.config.FactoryModule;
+
+public class GerritApiModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    bind(GerritApi.class).to(GerritApiImpl.class);
+
+    install(new com.google.gerrit.server.api.accounts.Module());
+    install(new com.google.gerrit.server.api.changes.Module());
+    install(new com.google.gerrit.server.api.config.Module());
+    install(new com.google.gerrit.server.api.groups.Module());
+    install(new com.google.gerrit.server.api.projects.Module());
+  }
+}
diff --git a/java/com/google/gerrit/server/api/PluginApiModule.java b/java/com/google/gerrit/server/api/PluginApiModule.java
new file mode 100644
index 0000000..8d3822b
--- /dev/null
+++ b/java/com/google/gerrit/server/api/PluginApiModule.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.api;
+
+import com.google.gerrit.extensions.api.plugins.Plugins;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.api.plugins.PluginApiImpl;
+import com.google.gerrit.server.api.plugins.PluginsImpl;
+
+public class PluginApiModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    bind(Plugins.class).to(PluginsImpl.class);
+    factory(PluginApiImpl.Factory.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
new file mode 100644
index 0000000..366ebfb
--- /dev/null
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -0,0 +1,524 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.accounts;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.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;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.common.AccountExternalIdInfo;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.AgreementInfo;
+import com.google.gerrit.extensions.common.AgreementInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.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;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.GpgApiAdapter;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.restapi.account.AddSshKey;
+import com.google.gerrit.server.restapi.account.CreateEmail;
+import com.google.gerrit.server.restapi.account.DeleteActive;
+import com.google.gerrit.server.restapi.account.DeleteEmail;
+import com.google.gerrit.server.restapi.account.DeleteExternalIds;
+import com.google.gerrit.server.restapi.account.DeleteSshKey;
+import com.google.gerrit.server.restapi.account.DeleteWatchedProjects;
+import com.google.gerrit.server.restapi.account.GetActive;
+import com.google.gerrit.server.restapi.account.GetAgreements;
+import com.google.gerrit.server.restapi.account.GetAvatar;
+import com.google.gerrit.server.restapi.account.GetDiffPreferences;
+import com.google.gerrit.server.restapi.account.GetEditPreferences;
+import com.google.gerrit.server.restapi.account.GetEmails;
+import com.google.gerrit.server.restapi.account.GetExternalIds;
+import com.google.gerrit.server.restapi.account.GetGroups;
+import com.google.gerrit.server.restapi.account.GetPreferences;
+import com.google.gerrit.server.restapi.account.GetSshKeys;
+import com.google.gerrit.server.restapi.account.GetWatchedProjects;
+import com.google.gerrit.server.restapi.account.Index;
+import com.google.gerrit.server.restapi.account.PostWatchedProjects;
+import com.google.gerrit.server.restapi.account.PutActive;
+import com.google.gerrit.server.restapi.account.PutAgreement;
+import com.google.gerrit.server.restapi.account.PutStatus;
+import com.google.gerrit.server.restapi.account.SetDiffPreferences;
+import com.google.gerrit.server.restapi.account.SetEditPreferences;
+import com.google.gerrit.server.restapi.account.SetPreferences;
+import com.google.gerrit.server.restapi.account.SshKeys;
+import com.google.gerrit.server.restapi.account.StarredChanges;
+import com.google.gerrit.server.restapi.account.Stars;
+import com.google.gerrit.server.restapi.change.ChangesCollection;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedSet;
+
+public class AccountApiImpl implements AccountApi {
+  interface Factory {
+    AccountApiImpl create(AccountResource account);
+  }
+
+  private final AccountResource account;
+  private final ChangesCollection changes;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final GetAvatar getAvatar;
+  private final GetPreferences getPreferences;
+  private final SetPreferences setPreferences;
+  private final GetDiffPreferences getDiffPreferences;
+  private final SetDiffPreferences setDiffPreferences;
+  private final GetEditPreferences getEditPreferences;
+  private final SetEditPreferences setEditPreferences;
+  private final GetWatchedProjects getWatchedProjects;
+  private final PostWatchedProjects postWatchedProjects;
+  private final DeleteWatchedProjects deleteWatchedProjects;
+  private final StarredChanges.Create starredChangesCreate;
+  private final StarredChanges.Delete starredChangesDelete;
+  private final Stars stars;
+  private final Stars.Get starsGet;
+  private final Stars.Post starsPost;
+  private final GetEmails getEmails;
+  private final CreateEmail.Factory createEmailFactory;
+  private final DeleteEmail deleteEmail;
+  private final GpgApiAdapter gpgApiAdapter;
+  private final GetSshKeys getSshKeys;
+  private final AddSshKey addSshKey;
+  private final DeleteSshKey deleteSshKey;
+  private final SshKeys sshKeys;
+  private final GetAgreements getAgreements;
+  private final PutAgreement putAgreement;
+  private final GetActive getActive;
+  private final PutActive putActive;
+  private final DeleteActive deleteActive;
+  private final Index index;
+  private final GetExternalIds getExternalIds;
+  private final DeleteExternalIds deleteExternalIds;
+  private final PutStatus putStatus;
+  private final GetGroups getGroups;
+
+  @Inject
+  AccountApiImpl(
+      AccountLoader.Factory ailf,
+      ChangesCollection changes,
+      GetAvatar getAvatar,
+      GetPreferences getPreferences,
+      SetPreferences setPreferences,
+      GetDiffPreferences getDiffPreferences,
+      SetDiffPreferences setDiffPreferences,
+      GetEditPreferences getEditPreferences,
+      SetEditPreferences setEditPreferences,
+      GetWatchedProjects getWatchedProjects,
+      PostWatchedProjects postWatchedProjects,
+      DeleteWatchedProjects deleteWatchedProjects,
+      StarredChanges.Create starredChangesCreate,
+      StarredChanges.Delete starredChangesDelete,
+      Stars stars,
+      Stars.Get starsGet,
+      Stars.Post starsPost,
+      GetEmails getEmails,
+      CreateEmail.Factory createEmailFactory,
+      DeleteEmail deleteEmail,
+      GpgApiAdapter gpgApiAdapter,
+      GetSshKeys getSshKeys,
+      AddSshKey addSshKey,
+      DeleteSshKey deleteSshKey,
+      SshKeys sshKeys,
+      GetAgreements getAgreements,
+      PutAgreement putAgreement,
+      GetActive getActive,
+      PutActive putActive,
+      DeleteActive deleteActive,
+      Index index,
+      GetExternalIds getExternalIds,
+      DeleteExternalIds deleteExternalIds,
+      PutStatus putStatus,
+      GetGroups getGroups,
+      @Assisted AccountResource account) {
+    this.account = account;
+    this.accountLoaderFactory = ailf;
+    this.changes = changes;
+    this.getAvatar = getAvatar;
+    this.getPreferences = getPreferences;
+    this.setPreferences = setPreferences;
+    this.getDiffPreferences = getDiffPreferences;
+    this.setDiffPreferences = setDiffPreferences;
+    this.getEditPreferences = getEditPreferences;
+    this.setEditPreferences = setEditPreferences;
+    this.getWatchedProjects = getWatchedProjects;
+    this.postWatchedProjects = postWatchedProjects;
+    this.deleteWatchedProjects = deleteWatchedProjects;
+    this.starredChangesCreate = starredChangesCreate;
+    this.starredChangesDelete = starredChangesDelete;
+    this.stars = stars;
+    this.starsGet = starsGet;
+    this.starsPost = starsPost;
+    this.getEmails = getEmails;
+    this.createEmailFactory = createEmailFactory;
+    this.deleteEmail = deleteEmail;
+    this.getSshKeys = getSshKeys;
+    this.addSshKey = addSshKey;
+    this.deleteSshKey = deleteSshKey;
+    this.sshKeys = sshKeys;
+    this.gpgApiAdapter = gpgApiAdapter;
+    this.getAgreements = getAgreements;
+    this.putAgreement = putAgreement;
+    this.getActive = getActive;
+    this.putActive = putActive;
+    this.deleteActive = deleteActive;
+    this.index = index;
+    this.getExternalIds = getExternalIds;
+    this.deleteExternalIds = deleteExternalIds;
+    this.putStatus = putStatus;
+    this.getGroups = getGroups;
+  }
+
+  @Override
+  public com.google.gerrit.extensions.common.AccountInfo get() throws RestApiException {
+    AccountLoader accountLoader = accountLoaderFactory.create(true);
+    try {
+      AccountInfo ai = accountLoader.get(account.getUser().getAccountId());
+      accountLoader.fill();
+      return ai;
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse change", e);
+    }
+  }
+
+  @Override
+  public boolean getActive() throws RestApiException {
+    Response<String> result = getActive.apply(account);
+    return result.statusCode() == SC_OK && result.value().equals("ok");
+  }
+
+  @Override
+  public void setActive(boolean active) throws RestApiException {
+    try {
+      if (active) {
+        putActive.apply(account, new Input());
+      } else {
+        deleteActive.apply(account, new Input());
+      }
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set active", e);
+    }
+  }
+
+  @Override
+  public String getAvatarUrl(int size) throws RestApiException {
+    getAvatar.setSize(size);
+    return getAvatar.apply(account).location();
+  }
+
+  @Override
+  public GeneralPreferencesInfo getPreferences() throws RestApiException {
+    try {
+      return getPreferences.apply(account);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get preferences", e);
+    }
+  }
+
+  @Override
+  public GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in) throws RestApiException {
+    try {
+      return setPreferences.apply(account, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set preferences", e);
+    }
+  }
+
+  @Override
+  public DiffPreferencesInfo getDiffPreferences() throws RestApiException {
+    try {
+      return getDiffPreferences.apply(account);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query diff preferences", e);
+    }
+  }
+
+  @Override
+  public DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in) throws RestApiException {
+    try {
+      return setDiffPreferences.apply(account, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set diff preferences", e);
+    }
+  }
+
+  @Override
+  public EditPreferencesInfo getEditPreferences() throws RestApiException {
+    try {
+      return getEditPreferences.apply(account);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query edit preferences", e);
+    }
+  }
+
+  @Override
+  public EditPreferencesInfo setEditPreferences(EditPreferencesInfo in) throws RestApiException {
+    try {
+      return setEditPreferences.apply(account, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set edit preferences", e);
+    }
+  }
+
+  @Override
+  public List<ProjectWatchInfo> getWatchedProjects() throws RestApiException {
+    try {
+      return getWatchedProjects.apply(account);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get watched projects", e);
+    }
+  }
+
+  @Override
+  public List<ProjectWatchInfo> setWatchedProjects(List<ProjectWatchInfo> in)
+      throws RestApiException {
+    try {
+      return postWatchedProjects.apply(account, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update watched projects", e);
+    }
+  }
+
+  @Override
+  public void deleteWatchedProjects(List<ProjectWatchInfo> in) throws RestApiException {
+    try {
+      deleteWatchedProjects.apply(account, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete watched projects", e);
+    }
+  }
+
+  @Override
+  public void starChange(String changeId) throws RestApiException {
+    try {
+      ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
+      starredChangesCreate.setChange(rsrc);
+      starredChangesCreate.apply(account, new StarredChanges.EmptyInput());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot star change", e);
+    }
+  }
+
+  @Override
+  public void unstarChange(String changeId) throws RestApiException {
+    try {
+      ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
+      AccountResource.StarredChange starredChange =
+          new AccountResource.StarredChange(account.getUser(), rsrc);
+      starredChangesDelete.apply(starredChange, new StarredChanges.EmptyInput());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot unstar change", e);
+    }
+  }
+
+  @Override
+  public void setStars(String changeId, StarsInput input) throws RestApiException {
+    try {
+      AccountResource.Star rsrc = stars.parse(account, IdString.fromUrl(changeId));
+      starsPost.apply(rsrc, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot post stars", e);
+    }
+  }
+
+  @Override
+  public SortedSet<String> getStars(String changeId) throws RestApiException {
+    try {
+      AccountResource.Star rsrc = stars.parse(account, IdString.fromUrl(changeId));
+      return starsGet.apply(rsrc);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get stars", e);
+    }
+  }
+
+  @Override
+  public List<ChangeInfo> getStarredChanges() throws RestApiException {
+    try {
+      return stars.list().apply(account);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get starred changes", e);
+    }
+  }
+
+  @Override
+  public List<GroupInfo> getGroups() throws RestApiException {
+    try {
+      return getGroups.apply(account);
+    } catch (OrmException e) {
+      throw asRestApiException("Cannot get groups", e);
+    }
+  }
+
+  @Override
+  public List<EmailInfo> getEmails() throws RestApiException {
+    try {
+      return getEmails.apply(account);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get emails", e);
+    }
+  }
+
+  @Override
+  public void addEmail(EmailInput input) throws RestApiException {
+    AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), input.email);
+    try {
+      createEmailFactory.create(input.email).apply(rsrc, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add email", e);
+    }
+  }
+
+  @Override
+  public void deleteEmail(String email) throws RestApiException {
+    AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), email);
+    try {
+      deleteEmail.apply(rsrc, null);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete email", e);
+    }
+  }
+
+  @Override
+  public void setStatus(String status) throws RestApiException {
+    StatusInput in = new StatusInput(status);
+    try {
+      putStatus.apply(account, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set status", e);
+    }
+  }
+
+  @Override
+  public List<SshKeyInfo> listSshKeys() throws RestApiException {
+    try {
+      return getSshKeys.apply(account);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list SSH keys", e);
+    }
+  }
+
+  @Override
+  public SshKeyInfo addSshKey(String key) throws RestApiException {
+    SshKeyInput in = new SshKeyInput();
+    in.raw = RawInputUtil.create(key);
+    try {
+      return addSshKey.apply(account, in).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add SSH key", e);
+    }
+  }
+
+  @Override
+  public void deleteSshKey(int seq) throws RestApiException {
+    try {
+      AccountResource.SshKey sshKeyRes =
+          sshKeys.parse(account, IdString.fromDecoded(Integer.toString(seq)));
+      deleteSshKey.apply(sshKeyRes, null);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete SSH key", e);
+    }
+  }
+
+  @Override
+  public Map<String, GpgKeyInfo> listGpgKeys() throws RestApiException {
+    try {
+      return gpgApiAdapter.listGpgKeys(account);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list GPG keys", e);
+    }
+  }
+
+  @Override
+  public Map<String, GpgKeyInfo> putGpgKeys(List<String> add, List<String> delete)
+      throws RestApiException {
+    try {
+      return gpgApiAdapter.putGpgKeys(account, add, delete);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add GPG key", e);
+    }
+  }
+
+  @Override
+  public GpgKeyApi gpgKey(String id) throws RestApiException {
+    try {
+      return gpgApiAdapter.gpgKey(account, IdString.fromDecoded(id));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get PGP key", e);
+    }
+  }
+
+  @Override
+  public List<AgreementInfo> listAgreements() throws RestApiException {
+    return getAgreements.apply(account);
+  }
+
+  @Override
+  public void signAgreement(String agreementName) throws RestApiException {
+    try {
+      AgreementInput input = new AgreementInput();
+      input.name = agreementName;
+      putAgreement.apply(account, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot sign agreement", e);
+    }
+  }
+
+  @Override
+  public void index() throws RestApiException {
+    try {
+      index.apply(account, new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot index account", e);
+    }
+  }
+
+  @Override
+  public List<AccountExternalIdInfo> getExternalIds() throws RestApiException {
+    try {
+      return getExternalIds.apply(account);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get external IDs", e);
+    }
+  }
+
+  @Override
+  public void deleteExternalIds(List<String> externalIds) throws RestApiException {
+    try {
+      deleteExternalIds.apply(account, externalIds);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete external IDs", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/accounts/AccountsImpl.java b/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
new file mode 100644
index 0000000..f5f1a34
--- /dev/null
+++ b/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
@@ -0,0 +1,168 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.accounts;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.accounts.AccountApi;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.accounts.Accounts;
+import com.google.gerrit.extensions.client.ListAccountsOption;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.restapi.account.AccountsCollection;
+import com.google.gerrit.server.restapi.account.CreateAccount;
+import com.google.gerrit.server.restapi.account.QueryAccounts;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.List;
+
+@Singleton
+public class AccountsImpl implements Accounts {
+  private final AccountsCollection accounts;
+  private final AccountApiImpl.Factory api;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> self;
+  private final CreateAccount.Factory createAccount;
+  private final Provider<QueryAccounts> queryAccountsProvider;
+
+  @Inject
+  AccountsImpl(
+      AccountsCollection accounts,
+      AccountApiImpl.Factory api,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> self,
+      CreateAccount.Factory createAccount,
+      Provider<QueryAccounts> queryAccountsProvider) {
+    this.accounts = accounts;
+    this.api = api;
+    this.permissionBackend = permissionBackend;
+    this.self = self;
+    this.createAccount = createAccount;
+    this.queryAccountsProvider = queryAccountsProvider;
+  }
+
+  @Override
+  public AccountApi id(String id) throws RestApiException {
+    try {
+      return api.create(accounts.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(id)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse change", e);
+    }
+  }
+
+  @Override
+  public AccountApi id(int id) throws RestApiException {
+    return id(String.valueOf(id));
+  }
+
+  @Override
+  public AccountApi self() throws RestApiException {
+    if (!self.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    return api.create(new AccountResource(self.get().asIdentifiedUser()));
+  }
+
+  @Override
+  public AccountApi create(String username) throws RestApiException {
+    AccountInput in = new AccountInput();
+    in.username = username;
+    return create(in);
+  }
+
+  @Override
+  public AccountApi create(AccountInput in) throws RestApiException {
+    if (checkNotNull(in, "AccountInput").username == null) {
+      throw new BadRequestException("AccountInput must specify username");
+    }
+    try {
+      CreateAccount impl = createAccount.create(in.username);
+      permissionBackend.user(self).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
+      AccountInfo info = impl.apply(TopLevelResource.INSTANCE, in).value();
+      return id(info._accountId);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create account " + in.username, e);
+    }
+  }
+
+  @Override
+  public SuggestAccountsRequest suggestAccounts() throws RestApiException {
+    return new SuggestAccountsRequest() {
+      @Override
+      public List<AccountInfo> get() throws RestApiException {
+        return AccountsImpl.this.suggestAccounts(this);
+      }
+    };
+  }
+
+  @Override
+  public SuggestAccountsRequest suggestAccounts(String query) throws RestApiException {
+    return suggestAccounts().withQuery(query);
+  }
+
+  private List<AccountInfo> suggestAccounts(SuggestAccountsRequest r) throws RestApiException {
+    try {
+      QueryAccounts myQueryAccounts = queryAccountsProvider.get();
+      myQueryAccounts.setSuggest(true);
+      myQueryAccounts.setQuery(r.getQuery());
+      myQueryAccounts.setLimit(r.getLimit());
+      return myQueryAccounts.apply(TopLevelResource.INSTANCE);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve suggested accounts", e);
+    }
+  }
+
+  @Override
+  public QueryRequest query() throws RestApiException {
+    return new QueryRequest() {
+      @Override
+      public List<AccountInfo> get() throws RestApiException {
+        return AccountsImpl.this.query(this);
+      }
+    };
+  }
+
+  @Override
+  public QueryRequest query(String query) throws RestApiException {
+    return query().withQuery(query);
+  }
+
+  private List<AccountInfo> query(QueryRequest r) throws RestApiException {
+    try {
+      QueryAccounts myQueryAccounts = queryAccountsProvider.get();
+      myQueryAccounts.setQuery(r.getQuery());
+      myQueryAccounts.setLimit(r.getLimit());
+      myQueryAccounts.setStart(r.getStart());
+      myQueryAccounts.setSuggest(r.getSuggest());
+      for (ListAccountsOption option : r.getOptions()) {
+        myQueryAccounts.addOption(option);
+      }
+      return myQueryAccounts.apply(TopLevelResource.INSTANCE);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve suggested accounts", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/Module.java b/java/com/google/gerrit/server/api/accounts/Module.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/Module.java
rename to java/com/google/gerrit/server/api/accounts/Module.java
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
new file mode 100644
index 0000000..baf6d36
--- /dev/null
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -0,0 +1,710 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.AbandonInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.ChangeEditApi;
+import com.google.gerrit.extensions.api.changes.Changes;
+import com.google.gerrit.extensions.api.changes.FixInput;
+import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.api.changes.MoveInput;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.RestoreInput;
+import com.google.gerrit.extensions.api.changes.RevertInput;
+import com.google.gerrit.extensions.api.changes.ReviewerApi;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
+import com.google.gerrit.extensions.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;
+import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.PureRevert;
+import com.google.gerrit.server.change.Revisions;
+import com.google.gerrit.server.change.WorkInProgressOp;
+import com.google.gerrit.server.restapi.change.Abandon;
+import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
+import com.google.gerrit.server.restapi.change.Check;
+import com.google.gerrit.server.restapi.change.CreateMergePatchSet;
+import com.google.gerrit.server.restapi.change.DeleteAssignee;
+import com.google.gerrit.server.restapi.change.DeleteChange;
+import com.google.gerrit.server.restapi.change.DeletePrivate;
+import com.google.gerrit.server.restapi.change.GetAssignee;
+import com.google.gerrit.server.restapi.change.GetHashtags;
+import com.google.gerrit.server.restapi.change.GetPastAssignees;
+import com.google.gerrit.server.restapi.change.GetTopic;
+import com.google.gerrit.server.restapi.change.Ignore;
+import com.google.gerrit.server.restapi.change.Index;
+import com.google.gerrit.server.restapi.change.ListChangeComments;
+import com.google.gerrit.server.restapi.change.ListChangeDrafts;
+import com.google.gerrit.server.restapi.change.ListChangeRobotComments;
+import com.google.gerrit.server.restapi.change.MarkAsReviewed;
+import com.google.gerrit.server.restapi.change.MarkAsUnreviewed;
+import com.google.gerrit.server.restapi.change.Move;
+import com.google.gerrit.server.restapi.change.PostHashtags;
+import com.google.gerrit.server.restapi.change.PostPrivate;
+import com.google.gerrit.server.restapi.change.PostReviewers;
+import com.google.gerrit.server.restapi.change.PutAssignee;
+import com.google.gerrit.server.restapi.change.PutMessage;
+import com.google.gerrit.server.restapi.change.PutTopic;
+import com.google.gerrit.server.restapi.change.Rebase;
+import com.google.gerrit.server.restapi.change.Restore;
+import com.google.gerrit.server.restapi.change.Revert;
+import com.google.gerrit.server.restapi.change.Reviewers;
+import com.google.gerrit.server.restapi.change.SetPrivateOp;
+import com.google.gerrit.server.restapi.change.SetReadyForReview;
+import com.google.gerrit.server.restapi.change.SetWorkInProgress;
+import com.google.gerrit.server.restapi.change.SubmittedTogether;
+import com.google.gerrit.server.restapi.change.SuggestChangeReviewers;
+import com.google.gerrit.server.restapi.change.Unignore;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+class ChangeApiImpl implements ChangeApi {
+  interface Factory {
+    ChangeApiImpl create(ChangeResource change);
+  }
+
+  private final Changes changeApi;
+  private final Reviewers reviewers;
+  private final Revisions revisions;
+  private final ReviewerApiImpl.Factory reviewerApi;
+  private final RevisionApiImpl.Factory revisionApi;
+  private final SuggestChangeReviewers suggestReviewers;
+  private final ChangeResource change;
+  private final Abandon abandon;
+  private final Revert revert;
+  private final Restore restore;
+  private final CreateMergePatchSet updateByMerge;
+  private final Provider<SubmittedTogether> submittedTogether;
+  private final Rebase.CurrentRevision rebase;
+  private final DeleteChange deleteChange;
+  private final GetTopic getTopic;
+  private final PutTopic putTopic;
+  private final ChangeIncludedIn includedIn;
+  private final PostReviewers postReviewers;
+  private final ChangeJson.Factory changeJson;
+  private final PostHashtags postHashtags;
+  private final GetHashtags getHashtags;
+  private final PutAssignee putAssignee;
+  private final GetAssignee getAssignee;
+  private final GetPastAssignees getPastAssignees;
+  private final DeleteAssignee deleteAssignee;
+  private final ListChangeComments listComments;
+  private final ListChangeRobotComments listChangeRobotComments;
+  private final ListChangeDrafts listDrafts;
+  private final ChangeEditApiImpl.Factory changeEditApi;
+  private final Check check;
+  private final Index index;
+  private final Move move;
+  private final PostPrivate postPrivate;
+  private final DeletePrivate deletePrivate;
+  private final Ignore ignore;
+  private final Unignore unignore;
+  private final MarkAsReviewed markAsReviewed;
+  private final MarkAsUnreviewed markAsUnreviewed;
+  private final SetWorkInProgress setWip;
+  private final SetReadyForReview setReady;
+  private final PutMessage putMessage;
+  private final PureRevert pureRevert;
+  private final StarredChangesUtil stars;
+
+  @Inject
+  ChangeApiImpl(
+      Changes changeApi,
+      Reviewers reviewers,
+      Revisions revisions,
+      ReviewerApiImpl.Factory reviewerApi,
+      RevisionApiImpl.Factory revisionApi,
+      SuggestChangeReviewers suggestReviewers,
+      Abandon abandon,
+      Revert revert,
+      Restore restore,
+      CreateMergePatchSet updateByMerge,
+      Provider<SubmittedTogether> submittedTogether,
+      Rebase.CurrentRevision rebase,
+      DeleteChange deleteChange,
+      GetTopic getTopic,
+      PutTopic putTopic,
+      ChangeIncludedIn includedIn,
+      PostReviewers postReviewers,
+      ChangeJson.Factory changeJson,
+      PostHashtags postHashtags,
+      GetHashtags getHashtags,
+      PutAssignee putAssignee,
+      GetAssignee getAssignee,
+      GetPastAssignees getPastAssignees,
+      DeleteAssignee deleteAssignee,
+      ListChangeComments listComments,
+      ListChangeRobotComments listChangeRobotComments,
+      ListChangeDrafts listDrafts,
+      ChangeEditApiImpl.Factory changeEditApi,
+      Check check,
+      Index index,
+      Move move,
+      PostPrivate postPrivate,
+      DeletePrivate deletePrivate,
+      Ignore ignore,
+      Unignore unignore,
+      MarkAsReviewed markAsReviewed,
+      MarkAsUnreviewed markAsUnreviewed,
+      SetWorkInProgress setWip,
+      SetReadyForReview setReady,
+      PutMessage putMessage,
+      PureRevert pureRevert,
+      StarredChangesUtil stars,
+      @Assisted ChangeResource change) {
+    this.changeApi = changeApi;
+    this.revert = revert;
+    this.reviewers = reviewers;
+    this.revisions = revisions;
+    this.reviewerApi = reviewerApi;
+    this.revisionApi = revisionApi;
+    this.suggestReviewers = suggestReviewers;
+    this.abandon = abandon;
+    this.restore = restore;
+    this.updateByMerge = updateByMerge;
+    this.submittedTogether = submittedTogether;
+    this.rebase = rebase;
+    this.deleteChange = deleteChange;
+    this.getTopic = getTopic;
+    this.putTopic = putTopic;
+    this.includedIn = includedIn;
+    this.postReviewers = postReviewers;
+    this.changeJson = changeJson;
+    this.postHashtags = postHashtags;
+    this.getHashtags = getHashtags;
+    this.putAssignee = putAssignee;
+    this.getAssignee = getAssignee;
+    this.getPastAssignees = getPastAssignees;
+    this.deleteAssignee = deleteAssignee;
+    this.listComments = listComments;
+    this.listChangeRobotComments = listChangeRobotComments;
+    this.listDrafts = listDrafts;
+    this.changeEditApi = changeEditApi;
+    this.check = check;
+    this.index = index;
+    this.move = move;
+    this.postPrivate = postPrivate;
+    this.deletePrivate = deletePrivate;
+    this.ignore = ignore;
+    this.unignore = unignore;
+    this.markAsReviewed = markAsReviewed;
+    this.markAsUnreviewed = markAsUnreviewed;
+    this.setWip = setWip;
+    this.setReady = setReady;
+    this.putMessage = putMessage;
+    this.pureRevert = pureRevert;
+    this.stars = stars;
+    this.change = change;
+  }
+
+  @Override
+  public String id() {
+    return Integer.toString(change.getId().get());
+  }
+
+  @Override
+  public RevisionApi current() throws RestApiException {
+    return revision("current");
+  }
+
+  @Override
+  public RevisionApi revision(int id) throws RestApiException {
+    return revision(String.valueOf(id));
+  }
+
+  @Override
+  public RevisionApi revision(String id) throws RestApiException {
+    try {
+      return revisionApi.create(revisions.parse(change, IdString.fromDecoded(id)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse revision", e);
+    }
+  }
+
+  @Override
+  public ReviewerApi reviewer(String id) throws RestApiException {
+    try {
+      return reviewerApi.create(reviewers.parse(change, IdString.fromDecoded(id)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse reviewer", e);
+    }
+  }
+
+  @Override
+  public void abandon() throws RestApiException {
+    abandon(new AbandonInput());
+  }
+
+  @Override
+  public void abandon(AbandonInput in) throws RestApiException {
+    try {
+      abandon.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot abandon change", e);
+    }
+  }
+
+  @Override
+  public void restore() throws RestApiException {
+    restore(new RestoreInput());
+  }
+
+  @Override
+  public void restore(RestoreInput in) throws RestApiException {
+    try {
+      restore.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot restore change", e);
+    }
+  }
+
+  @Override
+  public void move(String destination) throws RestApiException {
+    MoveInput in = new MoveInput();
+    in.destinationBranch = destination;
+    move(in);
+  }
+
+  @Override
+  public void move(MoveInput in) throws RestApiException {
+    try {
+      move.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot move change", e);
+    }
+  }
+
+  @Override
+  public void setPrivate(boolean value, @Nullable String message) throws RestApiException {
+    try {
+      SetPrivateOp.Input input = new SetPrivateOp.Input(message);
+      if (value) {
+        postPrivate.apply(change, input);
+      } else {
+        deletePrivate.apply(change, input);
+      }
+    } catch (Exception e) {
+      throw asRestApiException("Cannot change private status", e);
+    }
+  }
+
+  @Override
+  public void setWorkInProgress(String message) throws RestApiException {
+    try {
+      setWip.apply(change, new WorkInProgressOp.Input(message));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set work in progress state", e);
+    }
+  }
+
+  @Override
+  public void setReadyForReview(String message) throws RestApiException {
+    try {
+      setReady.apply(change, new WorkInProgressOp.Input(message));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set ready for review state", e);
+    }
+  }
+
+  @Override
+  public ChangeApi revert() throws RestApiException {
+    return revert(new RevertInput());
+  }
+
+  @Override
+  public ChangeApi revert(RevertInput in) throws RestApiException {
+    try {
+      return changeApi.id(revert.apply(change, in)._number);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot revert change", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException {
+    try {
+      return updateByMerge.apply(change, in).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update change by merge", e);
+    }
+  }
+
+  @Override
+  public List<ChangeInfo> submittedTogether() throws RestApiException {
+    SubmittedTogetherInfo info =
+        submittedTogether(
+            EnumSet.noneOf(ListChangesOption.class), EnumSet.noneOf(SubmittedTogetherOption.class));
+    return info.changes;
+  }
+
+  @Override
+  public SubmittedTogetherInfo submittedTogether(EnumSet<SubmittedTogetherOption> options)
+      throws RestApiException {
+    return submittedTogether(EnumSet.noneOf(ListChangesOption.class), options);
+  }
+
+  @Override
+  public SubmittedTogetherInfo submittedTogether(
+      EnumSet<ListChangesOption> listOptions, EnumSet<SubmittedTogetherOption> submitOptions)
+      throws RestApiException {
+    try {
+      return submittedTogether
+          .get()
+          .addListChangesOption(listOptions)
+          .addSubmittedTogetherOption(submitOptions)
+          .applyInfo(change);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query submittedTogether", e);
+    }
+  }
+
+  @Deprecated
+  @Override
+  public void publish() throws RestApiException {
+    throw new UnsupportedOperationException("draft workflow is discontinued");
+  }
+
+  @Override
+  public void rebase() throws RestApiException {
+    rebase(new RebaseInput());
+  }
+
+  @Override
+  public void rebase(RebaseInput in) throws RestApiException {
+    try {
+      rebase.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase change", e);
+    }
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      deleteChange.apply(change, null);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete change", e);
+    }
+  }
+
+  @Override
+  public String topic() throws RestApiException {
+    return getTopic.apply(change);
+  }
+
+  @Override
+  public void topic(String topic) throws RestApiException {
+    TopicInput in = new TopicInput();
+    in.topic = topic;
+    try {
+      putTopic.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set topic", e);
+    }
+  }
+
+  @Override
+  public IncludedInInfo includedIn() throws RestApiException {
+    try {
+      return includedIn.apply(change);
+    } catch (Exception e) {
+      throw asRestApiException("Could not extract IncludedIn data", e);
+    }
+  }
+
+  @Override
+  public AddReviewerResult addReviewer(String reviewer) throws RestApiException {
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = reviewer;
+    return addReviewer(in);
+  }
+
+  @Override
+  public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
+    try {
+      return postReviewers.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add change reviewer", e);
+    }
+  }
+
+  @Override
+  public SuggestedReviewersRequest suggestReviewers() throws RestApiException {
+    return new SuggestedReviewersRequest() {
+      @Override
+      public List<SuggestedReviewerInfo> get() throws RestApiException {
+        return ChangeApiImpl.this.suggestReviewers(this);
+      }
+    };
+  }
+
+  @Override
+  public SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException {
+    return suggestReviewers().withQuery(query);
+  }
+
+  private List<SuggestedReviewerInfo> suggestReviewers(SuggestedReviewersRequest r)
+      throws RestApiException {
+    try {
+      suggestReviewers.setQuery(r.getQuery());
+      suggestReviewers.setLimit(r.getLimit());
+      return suggestReviewers.apply(change);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve suggested reviewers", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo get(EnumSet<ListChangesOption> s) throws RestApiException {
+    try {
+      return changeJson.create(s).format(change);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve change", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo get() throws RestApiException {
+    return get(EnumSet.complementOf(EnumSet.of(ListChangesOption.CHECK)));
+  }
+
+  @Override
+  public EditInfo getEdit() throws RestApiException {
+    return edit().get().orElse(null);
+  }
+
+  @Override
+  public ChangeEditApi edit() throws RestApiException {
+    return changeEditApi.create(change);
+  }
+
+  @Override
+  public void setMessage(String msg) throws RestApiException {
+    CommitMessageInput in = new CommitMessageInput();
+    in.message = msg;
+    setMessage(in);
+  }
+
+  @Override
+  public void setMessage(CommitMessageInput in) throws RestApiException {
+    try {
+      putMessage.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot edit commit message", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo info() throws RestApiException {
+    return get(EnumSet.noneOf(ListChangesOption.class));
+  }
+
+  @Override
+  public void setHashtags(HashtagsInput input) throws RestApiException {
+    try {
+      postHashtags.apply(change, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot post hashtags", e);
+    }
+  }
+
+  @Override
+  public Set<String> getHashtags() throws RestApiException {
+    try {
+      return getHashtags.apply(change).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get hashtags", e);
+    }
+  }
+
+  @Override
+  public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
+    try {
+      return putAssignee.apply(change, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set assignee", e);
+    }
+  }
+
+  @Override
+  public AccountInfo getAssignee() throws RestApiException {
+    try {
+      Response<AccountInfo> r = getAssignee.apply(change);
+      return r.isNone() ? null : r.value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get assignee", e);
+    }
+  }
+
+  @Override
+  public List<AccountInfo> getPastAssignees() throws RestApiException {
+    try {
+      return getPastAssignees.apply(change).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get past assignees", e);
+    }
+  }
+
+  @Override
+  public AccountInfo deleteAssignee() throws RestApiException {
+    try {
+      Response<AccountInfo> r = deleteAssignee.apply(change, null);
+      return r.isNone() ? null : r.value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete assignee", e);
+    }
+  }
+
+  @Override
+  public Map<String, List<CommentInfo>> comments() throws RestApiException {
+    try {
+      return listComments.apply(change);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get comments", e);
+    }
+  }
+
+  @Override
+  public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
+    try {
+      return listChangeRobotComments.apply(change);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get robot comments", e);
+    }
+  }
+
+  @Override
+  public Map<String, List<CommentInfo>> drafts() throws RestApiException {
+    try {
+      return listDrafts.apply(change);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get drafts", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo check() throws RestApiException {
+    try {
+      return check.apply(change).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check change", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo check(FixInput fix) throws RestApiException {
+    try {
+      // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
+      // ConsistencyChecker.
+      return check.apply(change, fix).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check change", e);
+    }
+  }
+
+  @Override
+  public void index() throws RestApiException {
+    try {
+      index.apply(change, new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot index change", e);
+    }
+  }
+
+  @Override
+  public void ignore(boolean ignore) throws RestApiException {
+    // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
+    // StarredChangesUtil.
+    try {
+      if (ignore) {
+        this.ignore.apply(change, new Input());
+      } else {
+        unignore.apply(change, new Input());
+      }
+    } catch (OrmException | IllegalLabelException e) {
+      throw asRestApiException("Cannot ignore change", e);
+    }
+  }
+
+  @Override
+  public boolean ignored() throws RestApiException {
+    try {
+      return stars.isIgnored(change);
+    } catch (OrmException e) {
+      throw asRestApiException("Cannot check if ignored", e);
+    }
+  }
+
+  @Override
+  public void markAsReviewed(boolean reviewed) throws RestApiException {
+    // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
+    // StarredChangesUtil.
+    try {
+      if (reviewed) {
+        markAsReviewed.apply(change, new Input());
+      } else {
+        markAsUnreviewed.apply(change, new Input());
+      }
+    } catch (OrmException | IllegalLabelException e) {
+      throw asRestApiException(
+          "Cannot mark change as " + (reviewed ? "reviewed" : "unreviewed"), e);
+    }
+  }
+
+  @Override
+  public PureRevertInfo pureRevert() throws RestApiException {
+    return pureRevert(null);
+  }
+
+  @Override
+  public PureRevertInfo pureRevert(@Nullable String claimedOriginal) throws RestApiException {
+    try {
+      return pureRevert.get(change.getNotes(), claimedOriginal);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot compute pure revert", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
new file mode 100644
index 0000000..92aae03
--- /dev/null
+++ b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
@@ -0,0 +1,217 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.changes.ChangeEditApi;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.common.EditInfo;
+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;
+import com.google.gerrit.extensions.restapi.RawInput;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.ChangeEditResource;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.restapi.change.ChangeEdits;
+import com.google.gerrit.server.restapi.change.DeleteChangeEdit;
+import com.google.gerrit.server.restapi.change.PublishChangeEdit;
+import com.google.gerrit.server.restapi.change.RebaseChangeEdit;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.Optional;
+
+public class ChangeEditApiImpl implements ChangeEditApi {
+  interface Factory {
+    ChangeEditApiImpl create(ChangeResource changeResource);
+  }
+
+  private final ChangeEdits.Detail editDetail;
+  private final ChangeEdits.Post changeEditsPost;
+  private final DeleteChangeEdit deleteChangeEdit;
+  private final RebaseChangeEdit.Rebase rebaseChangeEdit;
+  private final PublishChangeEdit.Publish publishChangeEdit;
+  private final ChangeEdits.Get changeEditsGet;
+  private final ChangeEdits.Put changeEditsPut;
+  private final ChangeEdits.DeleteContent changeEditDeleteContent;
+  private final ChangeEdits.GetMessage getChangeEditCommitMessage;
+  private final ChangeEdits.EditMessage modifyChangeEditCommitMessage;
+  private final ChangeEdits changeEdits;
+  private final ChangeResource changeResource;
+
+  @Inject
+  public ChangeEditApiImpl(
+      ChangeEdits.Detail editDetail,
+      ChangeEdits.Post changeEditsPost,
+      DeleteChangeEdit deleteChangeEdit,
+      RebaseChangeEdit.Rebase rebaseChangeEdit,
+      PublishChangeEdit.Publish publishChangeEdit,
+      ChangeEdits.Get changeEditsGet,
+      ChangeEdits.Put changeEditsPut,
+      ChangeEdits.DeleteContent changeEditDeleteContent,
+      ChangeEdits.GetMessage getChangeEditCommitMessage,
+      ChangeEdits.EditMessage modifyChangeEditCommitMessage,
+      ChangeEdits changeEdits,
+      @Assisted ChangeResource changeResource) {
+    this.editDetail = editDetail;
+    this.changeEditsPost = changeEditsPost;
+    this.deleteChangeEdit = deleteChangeEdit;
+    this.rebaseChangeEdit = rebaseChangeEdit;
+    this.publishChangeEdit = publishChangeEdit;
+    this.changeEditsGet = changeEditsGet;
+    this.changeEditsPut = changeEditsPut;
+    this.changeEditDeleteContent = changeEditDeleteContent;
+    this.getChangeEditCommitMessage = getChangeEditCommitMessage;
+    this.modifyChangeEditCommitMessage = modifyChangeEditCommitMessage;
+    this.changeEdits = changeEdits;
+    this.changeResource = changeResource;
+  }
+
+  @Override
+  public Optional<EditInfo> get() throws RestApiException {
+    try {
+      Response<EditInfo> edit = editDetail.apply(changeResource);
+      return edit.isNone() ? Optional.empty() : Optional.of(edit.value());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve change edit", e);
+    }
+  }
+
+  @Override
+  public void create() throws RestApiException {
+    try {
+      changeEditsPost.apply(changeResource, null);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create change edit", e);
+    }
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      deleteChangeEdit.apply(changeResource, new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete change edit", e);
+    }
+  }
+
+  @Override
+  public void rebase() throws RestApiException {
+    try {
+      rebaseChangeEdit.apply(changeResource, null);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase change edit", e);
+    }
+  }
+
+  @Override
+  public void publish() throws RestApiException {
+    publish(null);
+  }
+
+  @Override
+  public void publish(PublishChangeEditInput publishChangeEditInput) throws RestApiException {
+    try {
+      publishChangeEdit.apply(changeResource, publishChangeEditInput);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot publish change edit", e);
+    }
+  }
+
+  @Override
+  public Optional<BinaryResult> getFile(String filePath) throws RestApiException {
+    try {
+      ChangeEditResource changeEditResource = getChangeEditResource(filePath);
+      Response<BinaryResult> fileResponse = changeEditsGet.apply(changeEditResource);
+      return fileResponse.isNone() ? Optional.empty() : Optional.of(fileResponse.value());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve file of change edit", e);
+    }
+  }
+
+  @Override
+  public void renameFile(String oldFilePath, String newFilePath) throws RestApiException {
+    try {
+      ChangeEdits.Post.Input renameInput = new ChangeEdits.Post.Input();
+      renameInput.oldPath = oldFilePath;
+      renameInput.newPath = newFilePath;
+      changeEditsPost.apply(changeResource, renameInput);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rename file of change edit", e);
+    }
+  }
+
+  @Override
+  public void restoreFile(String filePath) throws RestApiException {
+    try {
+      ChangeEdits.Post.Input restoreInput = new ChangeEdits.Post.Input();
+      restoreInput.restorePath = filePath;
+      changeEditsPost.apply(changeResource, restoreInput);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot restore file of change edit", e);
+    }
+  }
+
+  @Override
+  public void modifyFile(String filePath, RawInput newContent) throws RestApiException {
+    try {
+      changeEditsPut.apply(changeResource, filePath, newContent);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot modify file of change edit", e);
+    }
+  }
+
+  @Override
+  public void deleteFile(String filePath) throws RestApiException {
+    try {
+      changeEditDeleteContent.apply(changeResource, filePath);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete file of change edit", e);
+    }
+  }
+
+  @Override
+  public String getCommitMessage() throws RestApiException {
+    try {
+      try (BinaryResult binaryResult = getChangeEditCommitMessage.apply(changeResource)) {
+        return binaryResult.asString();
+      }
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get commit message of change edit", e);
+    }
+  }
+
+  @Override
+  public void modifyCommitMessage(String newCommitMessage) throws RestApiException {
+    ChangeEdits.EditMessage.Input input = new ChangeEdits.EditMessage.Input();
+    input.message = newCommitMessage;
+    try {
+      modifyChangeEditCommitMessage.apply(changeResource, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot modify commit message of change edit", e);
+    }
+  }
+
+  private ChangeEditResource getChangeEditResource(String filePath)
+      throws ResourceNotFoundException, AuthException, IOException, OrmException {
+    return changeEdits.parse(changeResource, IdString.fromDecoded(filePath));
+  }
+}
diff --git a/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
new file mode 100644
index 0000000..cefabf4
--- /dev/null
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -0,0 +1,141 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.Changes;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.restapi.change.ChangesCollection;
+import com.google.gerrit.server.restapi.change.CreateChange;
+import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.List;
+
+@Singleton
+class ChangesImpl implements Changes {
+  private final ChangesCollection changes;
+  private final ChangeApiImpl.Factory api;
+  private final CreateChange createChange;
+  private final Provider<QueryChanges> queryProvider;
+
+  @Inject
+  ChangesImpl(
+      ChangesCollection changes,
+      ChangeApiImpl.Factory api,
+      CreateChange createChange,
+      Provider<QueryChanges> queryProvider) {
+    this.changes = changes;
+    this.api = api;
+    this.createChange = createChange;
+    this.queryProvider = queryProvider;
+  }
+
+  @Override
+  public ChangeApi id(int id) throws RestApiException {
+    return id(String.valueOf(id));
+  }
+
+  @Override
+  public ChangeApi id(String project, String branch, String id) throws RestApiException {
+    return id(
+        Joiner.on('~')
+            .join(ImmutableList.of(Url.encode(project), Url.encode(branch), Url.encode(id))));
+  }
+
+  @Override
+  public ChangeApi id(String id) throws RestApiException {
+    try {
+      return api.create(changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(id)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse change", e);
+    }
+  }
+
+  @Override
+  public ChangeApi id(String project, int id) throws RestApiException {
+    return id(
+        Joiner.on('~').join(ImmutableList.of(Url.encode(project), Url.encode(String.valueOf(id)))));
+  }
+
+  @Override
+  public ChangeApi create(ChangeInput in) throws RestApiException {
+    try {
+      ChangeInfo out = createChange.apply(TopLevelResource.INSTANCE, in).value();
+      return api.create(changes.parse(new Change.Id(out._number)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create change", e);
+    }
+  }
+
+  @Override
+  public QueryRequest query() {
+    return new QueryRequest() {
+      @Override
+      public List<ChangeInfo> get() throws RestApiException {
+        return ChangesImpl.this.get(this);
+      }
+    };
+  }
+
+  @Override
+  public QueryRequest query(String query) {
+    return query().withQuery(query);
+  }
+
+  private List<ChangeInfo> get(QueryRequest q) throws RestApiException {
+    QueryChanges qc = queryProvider.get();
+    if (q.getQuery() != null) {
+      qc.addQuery(q.getQuery());
+    }
+    qc.setLimit(q.getLimit());
+    qc.setStart(q.getStart());
+    for (ListChangesOption option : q.getOptions()) {
+      qc.addOption(option);
+    }
+
+    try {
+      List<?> result = qc.apply(TopLevelResource.INSTANCE);
+      if (result.isEmpty()) {
+        return ImmutableList.of();
+      }
+
+      // Check type safety of result; the extension API should be safer than the
+      // REST API in this case, since it's intended to be used in Java.
+      Object first = checkNotNull(result.iterator().next());
+      checkState(first instanceof ChangeInfo);
+      @SuppressWarnings("unchecked")
+      List<ChangeInfo> infos = (List<ChangeInfo>) result;
+
+      return ImmutableList.copyOf(infos);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query changes", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/changes/CommentApiImpl.java b/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
new file mode 100644
index 0000000..418187d
--- /dev/null
+++ b/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.changes.CommentApi;
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.restapi.change.DeleteComment;
+import com.google.gerrit.server.restapi.change.GetComment;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+class CommentApiImpl implements CommentApi {
+  interface Factory {
+    CommentApiImpl create(CommentResource c);
+  }
+
+  private final GetComment getComment;
+  private final DeleteComment deleteComment;
+  private final CommentResource comment;
+
+  @Inject
+  CommentApiImpl(
+      GetComment getComment, DeleteComment deleteComment, @Assisted CommentResource comment) {
+    this.getComment = getComment;
+    this.deleteComment = deleteComment;
+    this.comment = comment;
+  }
+
+  @Override
+  public CommentInfo get() throws RestApiException {
+    try {
+      return getComment.apply(comment);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve comment", e);
+    }
+  }
+
+  @Override
+  public CommentInfo delete(DeleteCommentInput input) throws RestApiException {
+    try {
+      return deleteComment.apply(comment, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete comment", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/changes/DraftApiImpl.java b/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
new file mode 100644
index 0000000..4d26b11
--- /dev/null
+++ b/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
+import com.google.gerrit.extensions.api.changes.DraftApi;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.DraftCommentResource;
+import com.google.gerrit.server.restapi.change.DeleteDraftComment;
+import com.google.gerrit.server.restapi.change.GetDraftComment;
+import com.google.gerrit.server.restapi.change.PutDraftComment;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+class DraftApiImpl implements DraftApi {
+  interface Factory {
+    DraftApiImpl create(DraftCommentResource d);
+  }
+
+  private final DeleteDraftComment deleteDraft;
+  private final GetDraftComment getDraft;
+  private final PutDraftComment putDraft;
+  private final DraftCommentResource draft;
+
+  @Inject
+  DraftApiImpl(
+      DeleteDraftComment deleteDraft,
+      GetDraftComment getDraft,
+      PutDraftComment putDraft,
+      @Assisted DraftCommentResource draft) {
+    this.deleteDraft = deleteDraft;
+    this.getDraft = getDraft;
+    this.putDraft = putDraft;
+    this.draft = draft;
+  }
+
+  @Override
+  public CommentInfo get() throws RestApiException {
+    try {
+      return getDraft.apply(draft);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve draft", e);
+    }
+  }
+
+  @Override
+  public CommentInfo update(DraftInput in) throws RestApiException {
+    try {
+      return putDraft.apply(draft, in).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update draft", e);
+    }
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      deleteDraft.apply(draft, null);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete draft", e);
+    }
+  }
+
+  @Override
+  public CommentInfo delete(DeleteCommentInput input) {
+    throw new NotImplementedException();
+  }
+}
diff --git a/java/com/google/gerrit/server/api/changes/FileApiImpl.java b/java/com/google/gerrit/server/api/changes/FileApiImpl.java
new file mode 100644
index 0000000..6e18bb8
--- /dev/null
+++ b/java/com/google/gerrit/server/api/changes/FileApiImpl.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.changes.FileApi;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.FileResource;
+import com.google.gerrit.server.restapi.change.GetContent;
+import com.google.gerrit.server.restapi.change.GetDiff;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+class FileApiImpl implements FileApi {
+  interface Factory {
+    FileApiImpl create(FileResource r);
+  }
+
+  private final GetContent getContent;
+  private final GetDiff getDiff;
+  private final FileResource file;
+
+  @Inject
+  FileApiImpl(GetContent getContent, GetDiff getDiff, @Assisted FileResource file) {
+    this.getContent = getContent;
+    this.getDiff = getDiff;
+    this.file = file;
+  }
+
+  @Override
+  public BinaryResult content() throws RestApiException {
+    try {
+      return getContent.apply(file);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve file content", e);
+    }
+  }
+
+  @Override
+  public DiffInfo diff() throws RestApiException {
+    try {
+      return getDiff.apply(file).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve diff", e);
+    }
+  }
+
+  @Override
+  public DiffInfo diff(String base) throws RestApiException {
+    try {
+      return getDiff.setBase(base).apply(file).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve diff", e);
+    }
+  }
+
+  @Override
+  public DiffInfo diff(int parent) throws RestApiException {
+    try {
+      return getDiff.setParent(parent).apply(file).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve diff", e);
+    }
+  }
+
+  @Override
+  public DiffRequest diffRequest() {
+    return new DiffRequest() {
+      @Override
+      public DiffInfo get() throws RestApiException {
+        return FileApiImpl.this.get(this);
+      }
+    };
+  }
+
+  private DiffInfo get(DiffRequest r) throws RestApiException {
+    if (r.getBase() != null) {
+      getDiff.setBase(r.getBase());
+    }
+    if (r.getContext() != null) {
+      getDiff.setContext(r.getContext());
+    }
+    if (r.getIntraline() != null) {
+      getDiff.setIntraline(r.getIntraline());
+    }
+    if (r.getWhitespace() != null) {
+      getDiff.setWhitespace(r.getWhitespace());
+    }
+    r.getParent().ifPresent(getDiff::setParent);
+    try {
+      return getDiff.apply(file).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve diff", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java b/java/com/google/gerrit/server/api/changes/Module.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java
rename to java/com/google/gerrit/server/api/changes/Module.java
diff --git a/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java b/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
new file mode 100644
index 0000000..11536cb
--- /dev/null
+++ b/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.api.changes.ReviewerApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.change.VoteResource;
+import com.google.gerrit.server.restapi.change.DeleteReviewer;
+import com.google.gerrit.server.restapi.change.DeleteVote;
+import com.google.gerrit.server.restapi.change.Votes;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Map;
+
+public class ReviewerApiImpl implements ReviewerApi {
+  interface Factory {
+    ReviewerApiImpl create(ReviewerResource r);
+  }
+
+  private final ReviewerResource reviewer;
+  private final Votes.List listVotes;
+  private final DeleteVote deleteVote;
+  private final DeleteReviewer deleteReviewer;
+
+  @Inject
+  ReviewerApiImpl(
+      Votes.List listVotes,
+      DeleteVote deleteVote,
+      DeleteReviewer deleteReviewer,
+      @Assisted ReviewerResource reviewer) {
+    this.listVotes = listVotes;
+    this.deleteVote = deleteVote;
+    this.deleteReviewer = deleteReviewer;
+    this.reviewer = reviewer;
+  }
+
+  @Override
+  public Map<String, Short> votes() throws RestApiException {
+    try {
+      return listVotes.apply(reviewer);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list votes", e);
+    }
+  }
+
+  @Override
+  public void deleteVote(String label) throws RestApiException {
+    try {
+      deleteVote.apply(new VoteResource(reviewer, label), null);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete vote", e);
+    }
+  }
+
+  @Override
+  public void deleteVote(DeleteVoteInput input) throws RestApiException {
+    try {
+      deleteVote.apply(new VoteResource(reviewer, input.label), input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete vote", e);
+    }
+  }
+
+  @Override
+  public void remove() throws RestApiException {
+    remove(new DeleteReviewerInput());
+  }
+
+  @Override
+  public void remove(DeleteReviewerInput input) throws RestApiException {
+    try {
+      deleteReviewer.apply(reviewer, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot remove reviewer", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
new file mode 100644
index 0000000..8357568
--- /dev/null
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -0,0 +1,588 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.Changes;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.CommentApi;
+import com.google.gerrit.extensions.api.changes.DraftApi;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.FileApi;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.RevisionReviewerApi;
+import com.google.gerrit.extensions.api.changes.RobotCommentApi;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.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;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.FileResource;
+import com.google.gerrit.server.change.RebaseUtil;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.restapi.change.ApplyFix;
+import com.google.gerrit.server.restapi.change.CherryPick;
+import com.google.gerrit.server.restapi.change.Comments;
+import com.google.gerrit.server.restapi.change.CreateDraftComment;
+import com.google.gerrit.server.restapi.change.DraftComments;
+import com.google.gerrit.server.restapi.change.Files;
+import com.google.gerrit.server.restapi.change.Fixes;
+import com.google.gerrit.server.restapi.change.GetCommit;
+import com.google.gerrit.server.restapi.change.GetDescription;
+import com.google.gerrit.server.restapi.change.GetMergeList;
+import com.google.gerrit.server.restapi.change.GetPatch;
+import com.google.gerrit.server.restapi.change.GetRevisionActions;
+import com.google.gerrit.server.restapi.change.ListRevisionComments;
+import com.google.gerrit.server.restapi.change.ListRevisionDrafts;
+import com.google.gerrit.server.restapi.change.ListRobotComments;
+import com.google.gerrit.server.restapi.change.Mergeable;
+import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.restapi.change.PreviewSubmit;
+import com.google.gerrit.server.restapi.change.PutDescription;
+import com.google.gerrit.server.restapi.change.Rebase;
+import com.google.gerrit.server.restapi.change.Reviewed;
+import com.google.gerrit.server.restapi.change.RevisionReviewers;
+import com.google.gerrit.server.restapi.change.RobotComments;
+import com.google.gerrit.server.restapi.change.Submit;
+import com.google.gerrit.server.restapi.change.TestSubmitType;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+class RevisionApiImpl implements RevisionApi {
+  interface Factory {
+    RevisionApiImpl create(RevisionResource r);
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final Changes changes;
+  private final RevisionReviewers revisionReviewers;
+  private final RevisionReviewerApiImpl.Factory revisionReviewerApi;
+  private final CherryPick cherryPick;
+  private final Rebase rebase;
+  private final RebaseUtil rebaseUtil;
+  private final Submit submit;
+  private final PreviewSubmit submitPreview;
+  private final Reviewed.PutReviewed putReviewed;
+  private final Reviewed.DeleteReviewed deleteReviewed;
+  private final RevisionResource revision;
+  private final Files files;
+  private final Files.ListFiles listFiles;
+  private final GetCommit getCommit;
+  private final GetPatch getPatch;
+  private final PostReview review;
+  private final Mergeable mergeable;
+  private final FileApiImpl.Factory fileApi;
+  private final ListRevisionComments listComments;
+  private final ListRobotComments listRobotComments;
+  private final ApplyFix applyFix;
+  private final Fixes fixes;
+  private final ListRevisionDrafts listDrafts;
+  private final CreateDraftComment createDraft;
+  private final DraftComments drafts;
+  private final DraftApiImpl.Factory draftFactory;
+  private final Comments comments;
+  private final CommentApiImpl.Factory commentFactory;
+  private final RobotComments robotComments;
+  private final RobotCommentApiImpl.Factory robotCommentFactory;
+  private final GetRevisionActions revisionActions;
+  private final TestSubmitType testSubmitType;
+  private final TestSubmitType.Get getSubmitType;
+  private final Provider<GetMergeList> getMergeList;
+  private final PutDescription putDescription;
+  private final GetDescription getDescription;
+
+  @Inject
+  RevisionApiImpl(
+      GitRepositoryManager repoManager,
+      Changes changes,
+      RevisionReviewers revisionReviewers,
+      RevisionReviewerApiImpl.Factory revisionReviewerApi,
+      CherryPick cherryPick,
+      Rebase rebase,
+      RebaseUtil rebaseUtil,
+      Submit submit,
+      PreviewSubmit submitPreview,
+      Reviewed.PutReviewed putReviewed,
+      Reviewed.DeleteReviewed deleteReviewed,
+      Files files,
+      Files.ListFiles listFiles,
+      GetCommit getCommit,
+      GetPatch getPatch,
+      PostReview review,
+      Mergeable mergeable,
+      FileApiImpl.Factory fileApi,
+      ListRevisionComments listComments,
+      ListRobotComments listRobotComments,
+      ApplyFix applyFix,
+      Fixes fixes,
+      ListRevisionDrafts listDrafts,
+      CreateDraftComment createDraft,
+      DraftComments drafts,
+      DraftApiImpl.Factory draftFactory,
+      Comments comments,
+      CommentApiImpl.Factory commentFactory,
+      RobotComments robotComments,
+      RobotCommentApiImpl.Factory robotCommentFactory,
+      GetRevisionActions revisionActions,
+      TestSubmitType testSubmitType,
+      TestSubmitType.Get getSubmitType,
+      Provider<GetMergeList> getMergeList,
+      PutDescription putDescription,
+      GetDescription getDescription,
+      @Assisted RevisionResource r) {
+    this.repoManager = repoManager;
+    this.changes = changes;
+    this.revisionReviewers = revisionReviewers;
+    this.revisionReviewerApi = revisionReviewerApi;
+    this.cherryPick = cherryPick;
+    this.rebase = rebase;
+    this.rebaseUtil = rebaseUtil;
+    this.review = review;
+    this.submit = submit;
+    this.submitPreview = submitPreview;
+    this.files = files;
+    this.putReviewed = putReviewed;
+    this.deleteReviewed = deleteReviewed;
+    this.listFiles = listFiles;
+    this.getCommit = getCommit;
+    this.getPatch = getPatch;
+    this.mergeable = mergeable;
+    this.fileApi = fileApi;
+    this.listComments = listComments;
+    this.robotComments = robotComments;
+    this.listRobotComments = listRobotComments;
+    this.applyFix = applyFix;
+    this.fixes = fixes;
+    this.listDrafts = listDrafts;
+    this.createDraft = createDraft;
+    this.drafts = drafts;
+    this.draftFactory = draftFactory;
+    this.comments = comments;
+    this.commentFactory = commentFactory;
+    this.robotCommentFactory = robotCommentFactory;
+    this.revisionActions = revisionActions;
+    this.testSubmitType = testSubmitType;
+    this.getSubmitType = getSubmitType;
+    this.getMergeList = getMergeList;
+    this.putDescription = putDescription;
+    this.getDescription = getDescription;
+    this.revision = r;
+  }
+
+  @Override
+  public ReviewResult review(ReviewInput in) throws RestApiException {
+    try {
+      return review.apply(revision, in).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot post review", e);
+    }
+  }
+
+  @Override
+  public void submit() throws RestApiException {
+    SubmitInput in = new SubmitInput();
+    submit(in);
+  }
+
+  @Override
+  public void submit(SubmitInput in) throws RestApiException {
+    try {
+      submit.apply(revision, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot submit change", e);
+    }
+  }
+
+  @Override
+  public BinaryResult submitPreview() throws RestApiException {
+    return submitPreview("zip");
+  }
+
+  @Override
+  public BinaryResult submitPreview(String format) throws RestApiException {
+    try {
+      submitPreview.setFormat(format);
+      return submitPreview.apply(revision);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get submit preview", e);
+    }
+  }
+
+  @Override
+  public void publish() throws RestApiException {
+    throw new UnsupportedOperationException("draft workflow is discontinued");
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    throw new UnsupportedOperationException("draft workflow is discontinued");
+  }
+
+  @Override
+  public ChangeApi rebase() throws RestApiException {
+    RebaseInput in = new RebaseInput();
+    return rebase(in);
+  }
+
+  @Override
+  public ChangeApi rebase(RebaseInput in) throws RestApiException {
+    try {
+      return changes.id(rebase.apply(revision, in)._number);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase ps", e);
+    }
+  }
+
+  @Override
+  public boolean canRebase() throws RestApiException {
+    try (Repository repo = repoManager.openRepository(revision.getProject());
+        RevWalk rw = new RevWalk(repo)) {
+      return rebaseUtil.canRebase(revision.getPatchSet(), revision.getChange().getDest(), repo, rw);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check if rebase is possible", e);
+    }
+  }
+
+  @Override
+  public ChangeApi cherryPick(CherryPickInput in) throws RestApiException {
+    try {
+      return changes.id(cherryPick.apply(revision, in)._number);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot cherry pick", e);
+    }
+  }
+
+  @Override
+  public RevisionReviewerApi reviewer(String id) throws RestApiException {
+    try {
+      return revisionReviewerApi.create(
+          revisionReviewers.parse(revision, IdString.fromDecoded(id)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse reviewer", e);
+    }
+  }
+
+  @Override
+  public void setReviewed(String path, boolean reviewed) throws RestApiException {
+    try {
+      RestModifyView<FileResource, Input> view;
+      if (reviewed) {
+        view = putReviewed;
+      } else {
+        view = deleteReviewed;
+      }
+      view.apply(files.parse(revision, IdString.fromDecoded(path)), new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update reviewed flag", e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public Set<String> reviewed() throws RestApiException {
+    try {
+      return ImmutableSet.copyOf(
+          (Iterable<String>) listFiles.setReviewed(true).apply(revision).value());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list reviewed files", e);
+    }
+  }
+
+  @Override
+  public MergeableInfo mergeable() throws RestApiException {
+    try {
+      return mergeable.apply(revision);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check mergeability", e);
+    }
+  }
+
+  @Override
+  public MergeableInfo mergeableOtherBranches() throws RestApiException {
+    try {
+      mergeable.setOtherBranches(true);
+      return mergeable.apply(revision);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check mergeability", e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public Map<String, FileInfo> files() throws RestApiException {
+    try {
+      return (Map<String, FileInfo>) listFiles.apply(revision).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public Map<String, FileInfo> files(String base) throws RestApiException {
+    try {
+      return (Map<String, FileInfo>) listFiles.setBase(base).apply(revision).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public Map<String, FileInfo> files(int parentNum) throws RestApiException {
+    try {
+      return (Map<String, FileInfo>) listFiles.setParent(parentNum).apply(revision).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public List<String> queryFiles(String query) throws RestApiException {
+    try {
+      checkArgument(query != null, "no query provided");
+      return (List<String>) listFiles.setQuery(query).apply(revision).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
+    }
+  }
+
+  @Override
+  public FileApi file(String path) {
+    return fileApi.create(files.parse(revision, IdString.fromDecoded(path)));
+  }
+
+  @Override
+  public CommitInfo commit(boolean addLinks) throws RestApiException {
+    try {
+      return getCommit.setAddLinks(addLinks).apply(revision).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve commit", e);
+    }
+  }
+
+  @Override
+  public Map<String, List<CommentInfo>> comments() throws RestApiException {
+    try {
+      return listComments.apply(revision);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve comments", e);
+    }
+  }
+
+  @Override
+  public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
+    try {
+      return listRobotComments.apply(revision);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve robot comments", e);
+    }
+  }
+
+  @Override
+  public List<CommentInfo> commentsAsList() throws RestApiException {
+    try {
+      return listComments.getComments(revision);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve comments", e);
+    }
+  }
+
+  @Override
+  public Map<String, List<CommentInfo>> drafts() throws RestApiException {
+    try {
+      return listDrafts.apply(revision);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve drafts", e);
+    }
+  }
+
+  @Override
+  public List<RobotCommentInfo> robotCommentsAsList() throws RestApiException {
+    try {
+      return listRobotComments.getComments(revision);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve robot comments", e);
+    }
+  }
+
+  @Override
+  public EditInfo applyFix(String fixId) throws RestApiException {
+    try {
+      return applyFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot apply fix", e);
+    }
+  }
+
+  @Override
+  public List<CommentInfo> draftsAsList() throws RestApiException {
+    try {
+      return listDrafts.getComments(revision);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve drafts", e);
+    }
+  }
+
+  @Override
+  public DraftApi draft(String id) throws RestApiException {
+    try {
+      return draftFactory.create(drafts.parse(revision, IdString.fromDecoded(id)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve draft", e);
+    }
+  }
+
+  @Override
+  public DraftApi createDraft(DraftInput in) throws RestApiException {
+    try {
+      String id = createDraft.apply(revision, in).value().id;
+      // Reread change to pick up new notes refs.
+      return changes
+          .id(revision.getChange().getId().get())
+          .revision(revision.getPatchSet().getId().get())
+          .draft(id);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create draft", e);
+    }
+  }
+
+  @Override
+  public CommentApi comment(String id) throws RestApiException {
+    try {
+      return commentFactory.create(comments.parse(revision, IdString.fromDecoded(id)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve comment", e);
+    }
+  }
+
+  @Override
+  public RobotCommentApi robotComment(String id) throws RestApiException {
+    try {
+      return robotCommentFactory.create(robotComments.parse(revision, IdString.fromDecoded(id)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve robot comment", e);
+    }
+  }
+
+  @Override
+  public BinaryResult patch() throws RestApiException {
+    try {
+      return getPatch.apply(revision);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get patch", e);
+    }
+  }
+
+  @Override
+  public BinaryResult patch(String path) throws RestApiException {
+    try {
+      return getPatch.setPath(path).apply(revision);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get patch", e);
+    }
+  }
+
+  @Override
+  public Map<String, ActionInfo> actions() throws RestApiException {
+    try {
+      return revisionActions.apply(revision).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get actions", e);
+    }
+  }
+
+  @Override
+  public SubmitType submitType() throws RestApiException {
+    try {
+      return getSubmitType.apply(revision);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get submit type", e);
+    }
+  }
+
+  @Override
+  public SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException {
+    try {
+      return testSubmitType.apply(revision, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot test submit type", e);
+    }
+  }
+
+  @Override
+  public MergeListRequest getMergeList() throws RestApiException {
+    return new MergeListRequest() {
+      @Override
+      public List<CommitInfo> get() throws RestApiException {
+        try {
+          GetMergeList gml = getMergeList.get();
+          gml.setUninterestingParent(getUninterestingParent());
+          gml.setAddLinks(getAddLinks());
+          return gml.apply(revision).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get merge list", e);
+        }
+      }
+    };
+  }
+
+  @Override
+  public void description(String description) throws RestApiException {
+    DescriptionInput in = new DescriptionInput();
+    in.description = description;
+    try {
+      putDescription.apply(revision, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set description", e);
+    }
+  }
+
+  @Override
+  public String description() throws RestApiException {
+    return getDescription.apply(revision);
+  }
+
+  @Override
+  public String etag() throws RestApiException {
+    return revisionActions.getETag(revision);
+  }
+}
diff --git a/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
new file mode 100644
index 0000000..8cad507
--- /dev/null
+++ b/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.api.changes.RevisionReviewerApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.change.VoteResource;
+import com.google.gerrit.server.restapi.change.DeleteVote;
+import com.google.gerrit.server.restapi.change.Votes;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Map;
+
+public class RevisionReviewerApiImpl implements RevisionReviewerApi {
+  interface Factory {
+    RevisionReviewerApiImpl create(ReviewerResource r);
+  }
+
+  private final ReviewerResource reviewer;
+  private final Votes.List listVotes;
+  private final DeleteVote deleteVote;
+
+  @Inject
+  RevisionReviewerApiImpl(
+      Votes.List listVotes, DeleteVote deleteVote, @Assisted ReviewerResource reviewer) {
+    this.listVotes = listVotes;
+    this.deleteVote = deleteVote;
+    this.reviewer = reviewer;
+  }
+
+  @Override
+  public Map<String, Short> votes() throws RestApiException {
+    try {
+      return listVotes.apply(reviewer);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list votes", e);
+    }
+  }
+
+  @Override
+  public void deleteVote(String label) throws RestApiException {
+    try {
+      deleteVote.apply(new VoteResource(reviewer, label), null);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete vote", e);
+    }
+  }
+
+  @Override
+  public void deleteVote(DeleteVoteInput input) throws RestApiException {
+    try {
+      deleteVote.apply(new VoteResource(reviewer, input.label), input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete vote", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java b/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
new file mode 100644
index 0000000..37a56fe
--- /dev/null
+++ b/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.changes.RobotCommentApi;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.RobotCommentResource;
+import com.google.gerrit.server.restapi.change.GetRobotComment;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class RobotCommentApiImpl implements RobotCommentApi {
+  interface Factory {
+    RobotCommentApiImpl create(RobotCommentResource c);
+  }
+
+  private final GetRobotComment getComment;
+  private final RobotCommentResource comment;
+
+  @Inject
+  RobotCommentApiImpl(GetRobotComment getComment, @Assisted RobotCommentResource comment) {
+    this.getComment = getComment;
+    this.comment = comment;
+  }
+
+  @Override
+  public RobotCommentInfo get() throws RestApiException {
+    try {
+      return getComment.apply(comment);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve robot comment", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ConfigImpl.java b/java/com/google/gerrit/server/api/config/ConfigImpl.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/api/config/ConfigImpl.java
rename to java/com/google/gerrit/server/api/config/ConfigImpl.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/Module.java b/java/com/google/gerrit/server/api/config/Module.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/api/config/Module.java
rename to java/com/google/gerrit/server/api/config/Module.java
diff --git a/java/com/google/gerrit/server/api/config/ServerImpl.java b/java/com/google/gerrit/server/api/config/ServerImpl.java
new file mode 100644
index 0000000..ce87d1c
--- /dev/null
+++ b/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -0,0 +1,123 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.config;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.restapi.config.CheckConsistency;
+import com.google.gerrit.server.restapi.config.GetDiffPreferences;
+import com.google.gerrit.server.restapi.config.GetPreferences;
+import com.google.gerrit.server.restapi.config.GetServerInfo;
+import com.google.gerrit.server.restapi.config.SetDiffPreferences;
+import com.google.gerrit.server.restapi.config.SetPreferences;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ServerImpl implements Server {
+  private final GetPreferences getPreferences;
+  private final SetPreferences setPreferences;
+  private final GetDiffPreferences getDiffPreferences;
+  private final SetDiffPreferences setDiffPreferences;
+  private final GetServerInfo getServerInfo;
+  private final Provider<CheckConsistency> checkConsistency;
+
+  @Inject
+  ServerImpl(
+      GetPreferences getPreferences,
+      SetPreferences setPreferences,
+      GetDiffPreferences getDiffPreferences,
+      SetDiffPreferences setDiffPreferences,
+      GetServerInfo getServerInfo,
+      Provider<CheckConsistency> checkConsistency) {
+    this.getPreferences = getPreferences;
+    this.setPreferences = setPreferences;
+    this.getDiffPreferences = getDiffPreferences;
+    this.setDiffPreferences = setDiffPreferences;
+    this.getServerInfo = getServerInfo;
+    this.checkConsistency = checkConsistency;
+  }
+
+  @Override
+  public String getVersion() throws RestApiException {
+    return Version.getVersion();
+  }
+
+  @Override
+  public ServerInfo getInfo() throws RestApiException {
+    try {
+      return getServerInfo.apply(new ConfigResource());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get server info", e);
+    }
+  }
+
+  @Override
+  public GeneralPreferencesInfo getDefaultPreferences() throws RestApiException {
+    try {
+      return getPreferences.apply(new ConfigResource());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get default general preferences", e);
+    }
+  }
+
+  @Override
+  public GeneralPreferencesInfo setDefaultPreferences(GeneralPreferencesInfo in)
+      throws RestApiException {
+    try {
+      return setPreferences.apply(new ConfigResource(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set default general preferences", e);
+    }
+  }
+
+  @Override
+  public DiffPreferencesInfo getDefaultDiffPreferences() throws RestApiException {
+    try {
+      return getDiffPreferences.apply(new ConfigResource());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get default diff preferences", e);
+    }
+  }
+
+  @Override
+  public DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in)
+      throws RestApiException {
+    try {
+      return setDiffPreferences.apply(new ConfigResource(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set default diff preferences", e);
+    }
+  }
+
+  @Override
+  public ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException {
+    try {
+      return checkConsistency.get().apply(new ConfigResource(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check consistency", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
new file mode 100644
index 0000000..9909ed7
--- /dev/null
+++ b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -0,0 +1,281 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.groups;
+
+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.GroupResource;
+import com.google.gerrit.server.restapi.group.AddMembers;
+import com.google.gerrit.server.restapi.group.AddSubgroups;
+import com.google.gerrit.server.restapi.group.DeleteMembers;
+import com.google.gerrit.server.restapi.group.DeleteSubgroups;
+import com.google.gerrit.server.restapi.group.GetAuditLog;
+import com.google.gerrit.server.restapi.group.GetDescription;
+import com.google.gerrit.server.restapi.group.GetDetail;
+import com.google.gerrit.server.restapi.group.GetGroup;
+import com.google.gerrit.server.restapi.group.GetName;
+import com.google.gerrit.server.restapi.group.GetOptions;
+import com.google.gerrit.server.restapi.group.GetOwner;
+import com.google.gerrit.server.restapi.group.Index;
+import com.google.gerrit.server.restapi.group.ListMembers;
+import com.google.gerrit.server.restapi.group.ListSubgroups;
+import com.google.gerrit.server.restapi.group.PutDescription;
+import com.google.gerrit.server.restapi.group.PutName;
+import com.google.gerrit.server.restapi.group.PutOptions;
+import com.google.gerrit.server.restapi.group.PutOwner;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Arrays;
+import java.util.List;
+
+class GroupApiImpl implements GroupApi {
+  interface Factory {
+    GroupApiImpl create(GroupResource rsrc);
+  }
+
+  private final GetGroup getGroup;
+  private final GetDetail getDetail;
+  private final GetName getName;
+  private final PutName putName;
+  private final GetOwner getOwner;
+  private final PutOwner putOwner;
+  private final GetDescription getDescription;
+  private final PutDescription putDescription;
+  private final GetOptions getOptions;
+  private final PutOptions putOptions;
+  private final ListMembers listMembers;
+  private final AddMembers addMembers;
+  private final DeleteMembers deleteMembers;
+  private final ListSubgroups listSubgroups;
+  private final AddSubgroups addSubgroups;
+  private final DeleteSubgroups deleteSubgroups;
+  private final GetAuditLog getAuditLog;
+  private final GroupResource rsrc;
+  private final Index index;
+
+  @Inject
+  GroupApiImpl(
+      GetGroup getGroup,
+      GetDetail getDetail,
+      GetName getName,
+      PutName putName,
+      GetOwner getOwner,
+      PutOwner putOwner,
+      GetDescription getDescription,
+      PutDescription putDescription,
+      GetOptions getOptions,
+      PutOptions putOptions,
+      ListMembers listMembers,
+      AddMembers addMembers,
+      DeleteMembers deleteMembers,
+      ListSubgroups listSubgroups,
+      AddSubgroups addSubgroups,
+      DeleteSubgroups deleteSubgroups,
+      GetAuditLog getAuditLog,
+      Index index,
+      @Assisted GroupResource rsrc) {
+    this.getGroup = getGroup;
+    this.getDetail = getDetail;
+    this.getName = getName;
+    this.putName = putName;
+    this.getOwner = getOwner;
+    this.putOwner = putOwner;
+    this.getDescription = getDescription;
+    this.putDescription = putDescription;
+    this.getOptions = getOptions;
+    this.putOptions = putOptions;
+    this.listMembers = listMembers;
+    this.addMembers = addMembers;
+    this.deleteMembers = deleteMembers;
+    this.listSubgroups = listSubgroups;
+    this.addSubgroups = addSubgroups;
+    this.deleteSubgroups = deleteSubgroups;
+    this.getAuditLog = getAuditLog;
+    this.index = index;
+    this.rsrc = rsrc;
+  }
+
+  @Override
+  public GroupInfo get() throws RestApiException {
+    try {
+      return getGroup.apply(rsrc);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve group", e);
+    }
+  }
+
+  @Override
+  public GroupInfo detail() throws RestApiException {
+    try {
+      return getDetail.apply(rsrc);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve group", e);
+    }
+  }
+
+  @Override
+  public String name() throws RestApiException {
+    return getName.apply(rsrc);
+  }
+
+  @Override
+  public void name(String name) throws RestApiException {
+    NameInput in = new NameInput();
+    in.name = name;
+    try {
+      putName.apply(rsrc, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put group name", e);
+    }
+  }
+
+  @Override
+  public GroupInfo owner() throws RestApiException {
+    try {
+      return getOwner.apply(rsrc);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get group owner", e);
+    }
+  }
+
+  @Override
+  public void owner(String owner) throws RestApiException {
+    OwnerInput in = new OwnerInput();
+    in.owner = owner;
+    try {
+      putOwner.apply(rsrc, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put group owner", e);
+    }
+  }
+
+  @Override
+  public String description() throws RestApiException {
+    return getDescription.apply(rsrc);
+  }
+
+  @Override
+  public void description(String description) throws RestApiException {
+    DescriptionInput in = new DescriptionInput();
+    in.description = description;
+    try {
+      putDescription.apply(rsrc, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put group description", e);
+    }
+  }
+
+  @Override
+  public GroupOptionsInfo options() throws RestApiException {
+    return getOptions.apply(rsrc);
+  }
+
+  @Override
+  public void options(GroupOptionsInfo options) throws RestApiException {
+    try {
+      putOptions.apply(rsrc, options);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put group options", e);
+    }
+  }
+
+  @Override
+  public List<AccountInfo> members() throws RestApiException {
+    return members(false);
+  }
+
+  @Override
+  public List<AccountInfo> members(boolean recursive) throws RestApiException {
+    listMembers.setRecursive(recursive);
+    try {
+      return listMembers.apply(rsrc);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list group members", e);
+    }
+  }
+
+  @Override
+  public void addMembers(String... members) throws RestApiException {
+    try {
+      addMembers.apply(rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add group members", e);
+    }
+  }
+
+  @Override
+  public void removeMembers(String... members) throws RestApiException {
+    try {
+      deleteMembers.apply(rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot remove group members", e);
+    }
+  }
+
+  @Override
+  public List<GroupInfo> includedGroups() throws RestApiException {
+    try {
+      return listSubgroups.apply(rsrc);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list subgroups", e);
+    }
+  }
+
+  @Override
+  public void addGroups(String... groups) throws RestApiException {
+    try {
+      addSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(Arrays.asList(groups)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add subgroups", e);
+    }
+  }
+
+  @Override
+  public void removeGroups(String... groups) throws RestApiException {
+    try {
+      deleteSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(Arrays.asList(groups)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot remove subgroups", e);
+    }
+  }
+
+  @Override
+  public List<? extends GroupAuditEventInfo> auditLog() throws RestApiException {
+    try {
+      return getAuditLog.apply(rsrc);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get audit log", e);
+    }
+  }
+
+  @Override
+  public void index() throws RestApiException {
+    try {
+      index.apply(rsrc, new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot index group", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/groups/GroupsImpl.java b/java/com/google/gerrit/server/api/groups/GroupsImpl.java
new file mode 100644
index 0000000..0b3bc64
--- /dev/null
+++ b/java/com/google/gerrit/server/api/groups/GroupsImpl.java
@@ -0,0 +1,190 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.groups;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.groups.GroupApi;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.api.groups.Groups;
+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.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.account.AccountsCollection;
+import com.google.gerrit.server.restapi.group.CreateGroup;
+import com.google.gerrit.server.restapi.group.GroupsCollection;
+import com.google.gerrit.server.restapi.group.ListGroups;
+import com.google.gerrit.server.restapi.group.QueryGroups;
+import com.google.gerrit.server.restapi.project.ProjectsCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.List;
+import java.util.SortedMap;
+
+@Singleton
+class GroupsImpl implements Groups {
+  private final AccountsCollection accounts;
+  private final GroupsCollection groups;
+  private final ProjectsCollection projects;
+  private final Provider<ListGroups> listGroups;
+  private final Provider<QueryGroups> queryGroups;
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final CreateGroup.Factory createGroup;
+  private final GroupApiImpl.Factory api;
+
+  @Inject
+  GroupsImpl(
+      AccountsCollection accounts,
+      GroupsCollection groups,
+      ProjectsCollection projects,
+      Provider<ListGroups> listGroups,
+      Provider<QueryGroups> queryGroups,
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      CreateGroup.Factory createGroup,
+      GroupApiImpl.Factory api) {
+    this.accounts = accounts;
+    this.groups = groups;
+    this.projects = projects;
+    this.listGroups = listGroups;
+    this.queryGroups = queryGroups;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.createGroup = createGroup;
+    this.api = api;
+  }
+
+  @Override
+  public GroupApi id(String id) throws RestApiException {
+    return api.create(groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(id)));
+  }
+
+  @Override
+  public GroupApi create(String name) throws RestApiException {
+    GroupInput in = new GroupInput();
+    in.name = name;
+    return create(in);
+  }
+
+  @Override
+  public GroupApi create(GroupInput in) throws RestApiException {
+    if (checkNotNull(in, "GroupInput").name == null) {
+      throw new BadRequestException("GroupInput must specify name");
+    }
+    try {
+      CreateGroup impl = createGroup.create(in.name);
+      permissionBackend.user(user).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
+      GroupInfo info = impl.apply(TopLevelResource.INSTANCE, in);
+      return id(info.id);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create group " + in.name, e);
+    }
+  }
+
+  @Override
+  public ListRequest list() {
+    return new ListRequest() {
+      @Override
+      public SortedMap<String, GroupInfo> getAsMap() throws RestApiException {
+        return list(this);
+      }
+    };
+  }
+
+  private SortedMap<String, GroupInfo> list(ListRequest req) throws RestApiException {
+    TopLevelResource tlr = TopLevelResource.INSTANCE;
+    ListGroups list = listGroups.get();
+    list.setOptions(req.getOptions());
+
+    for (String project : req.getProjects()) {
+      try {
+        ProjectResource rsrc = projects.parse(tlr, IdString.fromDecoded(project));
+        list.addProject(rsrc.getProjectState());
+      } catch (Exception e) {
+        throw asRestApiException("Error looking up project " + project, e);
+      }
+    }
+
+    for (String group : req.getGroups()) {
+      list.addGroup(groups.parse(group).getGroupUUID());
+    }
+
+    list.setVisibleToAll(req.getVisibleToAll());
+
+    if (req.getOwnedBy() != null) {
+      list.setOwnedBy(req.getOwnedBy());
+    }
+
+    if (req.getUser() != null) {
+      try {
+        list.setUser(accounts.parse(req.getUser()).getAccountId());
+      } catch (Exception e) {
+        throw asRestApiException("Error looking up user " + req.getUser(), e);
+      }
+    }
+
+    list.setOwned(req.getOwned());
+    list.setLimit(req.getLimit());
+    list.setStart(req.getStart());
+    list.setMatchSubstring(req.getSubstring());
+    list.setMatchRegex(req.getRegex());
+    list.setSuggest(req.getSuggest());
+    try {
+      return list.apply(tlr);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list groups", e);
+    }
+  }
+
+  @Override
+  public QueryRequest query() {
+    return new QueryRequest() {
+      @Override
+      public List<GroupInfo> get() throws RestApiException {
+        return GroupsImpl.this.query(this);
+      }
+    };
+  }
+
+  @Override
+  public QueryRequest query(String query) {
+    return query().withQuery(query);
+  }
+
+  private List<GroupInfo> query(QueryRequest r) throws RestApiException {
+    try {
+      QueryGroups myQueryGroups = queryGroups.get();
+      myQueryGroups.setQuery(r.getQuery());
+      myQueryGroups.setLimit(r.getLimit());
+      myQueryGroups.setStart(r.getStart());
+      for (ListGroupsOption option : r.getOptions()) {
+        myQueryGroups.addOption(option);
+      }
+      return myQueryGroups.apply(TopLevelResource.INSTANCE);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query groups", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/Module.java b/java/com/google/gerrit/server/api/groups/Module.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/api/groups/Module.java
rename to java/com/google/gerrit/server/api/groups/Module.java
diff --git a/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java b/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
new file mode 100644
index 0000000..71f7832
--- /dev/null
+++ b/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.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;
+import com.google.gerrit.server.plugins.EnablePlugin;
+import com.google.gerrit.server.plugins.GetStatus;
+import com.google.gerrit.server.plugins.PluginResource;
+import com.google.gerrit.server.plugins.ReloadPlugin;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class PluginApiImpl implements PluginApi {
+  public interface Factory {
+    PluginApiImpl create(PluginResource resource);
+  }
+
+  private final GetStatus getStatus;
+  private final EnablePlugin enable;
+  private final DisablePlugin disable;
+  private final ReloadPlugin reload;
+  private final PluginResource resource;
+
+  @Inject
+  PluginApiImpl(
+      GetStatus getStatus,
+      EnablePlugin enable,
+      DisablePlugin disable,
+      ReloadPlugin reload,
+      @Assisted PluginResource resource) {
+    this.getStatus = getStatus;
+    this.enable = enable;
+    this.disable = disable;
+    this.reload = reload;
+    this.resource = resource;
+  }
+
+  @Override
+  public PluginInfo get() throws RestApiException {
+    return getStatus.apply(resource);
+  }
+
+  @Override
+  public void enable() throws RestApiException {
+    enable.apply(resource, new Input());
+  }
+
+  @Override
+  public void disable() throws RestApiException {
+    disable.apply(resource, new Input());
+  }
+
+  @Override
+  public void reload() throws RestApiException {
+    reload.apply(resource, new Input());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginsImpl.java b/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
rename to java/com/google/gerrit/server/api/plugins/PluginsImpl.java
diff --git a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
new file mode 100644
index 0000000..78b34b8
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.projects.BranchApi;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+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;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.gerrit.server.project.FileResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.BranchesCollection;
+import com.google.gerrit.server.restapi.project.CreateBranch;
+import com.google.gerrit.server.restapi.project.DeleteBranch;
+import com.google.gerrit.server.restapi.project.FilesCollection;
+import com.google.gerrit.server.restapi.project.GetBranch;
+import com.google.gerrit.server.restapi.project.GetContent;
+import com.google.gerrit.server.restapi.project.GetReflog;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.List;
+
+public class BranchApiImpl implements BranchApi {
+  interface Factory {
+    BranchApiImpl create(ProjectResource project, String ref);
+  }
+
+  private final BranchesCollection branches;
+  private final CreateBranch.Factory createBranchFactory;
+  private final DeleteBranch deleteBranch;
+  private final FilesCollection filesCollection;
+  private final GetBranch getBranch;
+  private final GetContent getContent;
+  private final GetReflog getReflog;
+  private final String ref;
+  private final ProjectResource project;
+
+  @Inject
+  BranchApiImpl(
+      BranchesCollection branches,
+      CreateBranch.Factory createBranchFactory,
+      DeleteBranch deleteBranch,
+      FilesCollection filesCollection,
+      GetBranch getBranch,
+      GetContent getContent,
+      GetReflog getReflog,
+      @Assisted ProjectResource project,
+      @Assisted String ref) {
+    this.branches = branches;
+    this.createBranchFactory = createBranchFactory;
+    this.deleteBranch = deleteBranch;
+    this.filesCollection = filesCollection;
+    this.getBranch = getBranch;
+    this.getContent = getContent;
+    this.getReflog = getReflog;
+    this.project = project;
+    this.ref = ref;
+  }
+
+  @Override
+  public BranchApi create(BranchInput input) throws RestApiException {
+    try {
+      createBranchFactory.create(ref).apply(project, input);
+      return this;
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create branch", e);
+    }
+  }
+
+  @Override
+  public BranchInfo get() throws RestApiException {
+    try {
+      return getBranch.apply(resource());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot read branch", e);
+    }
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      deleteBranch.apply(resource(), new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete branch", e);
+    }
+  }
+
+  @Override
+  public BinaryResult file(String path) throws RestApiException {
+    try {
+      FileResource resource = filesCollection.parse(resource(), IdString.fromDecoded(path));
+      return getContent.apply(resource);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve file", e);
+    }
+  }
+
+  @Override
+  public List<ReflogEntryInfo> reflog() throws RestApiException {
+    try {
+      return getReflog.apply(resource());
+    } catch (IOException | PermissionBackendException e) {
+      throw new RestApiException("Cannot retrieve reflog", e);
+    }
+  }
+
+  private BranchResource resource()
+      throws RestApiException, IOException, PermissionBackendException {
+    return branches.parse(project, IdString.fromDecoded(ref));
+  }
+}
diff --git a/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
new file mode 100644
index 0000000..d7c9bc7
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import com.google.gerrit.extensions.api.projects.ChildProjectApi;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.project.ChildProjectResource;
+import com.google.gerrit.server.restapi.project.GetChildProject;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class ChildProjectApiImpl implements ChildProjectApi {
+  interface Factory {
+    ChildProjectApiImpl create(ChildProjectResource rsrc);
+  }
+
+  private final GetChildProject getChildProject;
+  private final ChildProjectResource rsrc;
+
+  @Inject
+  ChildProjectApiImpl(GetChildProject getChildProject, @Assisted ChildProjectResource rsrc) {
+    this.getChildProject = getChildProject;
+    this.rsrc = rsrc;
+  }
+
+  @Override
+  public ProjectInfo get() throws RestApiException {
+    return get(false);
+  }
+
+  @Override
+  public ProjectInfo get(boolean recursive) throws RestApiException {
+    getChildProject.setRecursive(recursive);
+    return getChildProject.apply(rsrc);
+  }
+}
diff --git a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
new file mode 100644
index 0000000..a81e0de
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.Changes;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.projects.CommitApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.gerrit.server.restapi.change.CherryPickCommit;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class CommitApiImpl implements CommitApi {
+  public interface Factory {
+    CommitApiImpl create(CommitResource r);
+  }
+
+  private final Changes changes;
+  private final CherryPickCommit cherryPickCommit;
+  private final CommitResource commitResource;
+
+  @Inject
+  CommitApiImpl(
+      Changes changes, CherryPickCommit cherryPickCommit, @Assisted CommitResource commitResource) {
+    this.changes = changes;
+    this.cherryPickCommit = cherryPickCommit;
+    this.commitResource = commitResource;
+  }
+
+  @Override
+  public ChangeApi cherryPick(CherryPickInput input) throws RestApiException {
+    try {
+      return changes.id(cherryPickCommit.apply(commitResource, input)._number);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot cherry pick", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java b/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
new file mode 100644
index 0000000..016a593
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.projects.DashboardApi;
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.common.SetDashboardInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.DashboardResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.DashboardsCollection;
+import com.google.gerrit.server.restapi.project.GetDashboard;
+import com.google.gerrit.server.restapi.project.SetDashboard;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public class DashboardApiImpl implements DashboardApi {
+  interface Factory {
+    DashboardApiImpl create(ProjectResource project, String id);
+  }
+
+  private final DashboardsCollection dashboards;
+  private final Provider<GetDashboard> get;
+  private final SetDashboard set;
+  private final ProjectResource project;
+  private final String id;
+
+  @Inject
+  DashboardApiImpl(
+      DashboardsCollection dashboards,
+      Provider<GetDashboard> get,
+      SetDashboard set,
+      @Assisted ProjectResource project,
+      @Assisted @Nullable String id) {
+    this.dashboards = dashboards;
+    this.get = get;
+    this.set = set;
+    this.project = project;
+    this.id = id;
+  }
+
+  @Override
+  public DashboardInfo get() throws RestApiException {
+    return get(false);
+  }
+
+  @Override
+  public DashboardInfo get(boolean inherited) throws RestApiException {
+    try {
+      return get.get().setInherited(inherited).apply(resource());
+    } catch (IOException | PermissionBackendException | ConfigInvalidException e) {
+      throw asRestApiException("Cannot read dashboard", e);
+    }
+  }
+
+  @Override
+  public void setDefault() throws RestApiException {
+    SetDashboardInput input = new SetDashboardInput();
+    input.id = id;
+    try {
+      set.apply(
+          DashboardResource.projectDefault(project.getProjectState(), project.getUser()), input);
+    } catch (Exception e) {
+      String msg = String.format("Cannot %s default dashboard", id != null ? "set" : "remove");
+      throw asRestApiException(msg, e);
+    }
+  }
+
+  private DashboardResource resource()
+      throws ResourceNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    return dashboards.parse(project, IdString.fromDecoded(id));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java b/java/com/google/gerrit/server/api/projects/Module.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java
rename to java/com/google/gerrit/server/api/projects/Module.java
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
new file mode 100644
index 0000000..501b3a4
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -0,0 +1,611 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+import static com.google.gerrit.server.restapi.project.DashboardsCollection.DEFAULT_DASHBOARD_NAME;
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.api.config.AccessCheckInfo;
+import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.api.projects.BranchApi;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.ChildProjectApi;
+import com.google.gerrit.extensions.api.projects.CommitApi;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.DashboardApi;
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
+import com.google.gerrit.extensions.api.projects.DescriptionInput;
+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;
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectJson;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.CheckAccess;
+import com.google.gerrit.server.restapi.project.ChildProjectsCollection;
+import com.google.gerrit.server.restapi.project.CommitsCollection;
+import com.google.gerrit.server.restapi.project.CreateAccessChange;
+import com.google.gerrit.server.restapi.project.CreateProject;
+import com.google.gerrit.server.restapi.project.DeleteBranches;
+import com.google.gerrit.server.restapi.project.DeleteTags;
+import com.google.gerrit.server.restapi.project.GetAccess;
+import com.google.gerrit.server.restapi.project.GetConfig;
+import com.google.gerrit.server.restapi.project.GetDescription;
+import com.google.gerrit.server.restapi.project.GetHead;
+import com.google.gerrit.server.restapi.project.GetParent;
+import com.google.gerrit.server.restapi.project.ListBranches;
+import com.google.gerrit.server.restapi.project.ListChildProjects;
+import com.google.gerrit.server.restapi.project.ListDashboards;
+import com.google.gerrit.server.restapi.project.ListTags;
+import com.google.gerrit.server.restapi.project.ProjectsCollection;
+import com.google.gerrit.server.restapi.project.PutConfig;
+import com.google.gerrit.server.restapi.project.PutDescription;
+import com.google.gerrit.server.restapi.project.SetAccess;
+import com.google.gerrit.server.restapi.project.SetHead;
+import com.google.gerrit.server.restapi.project.SetParent;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.util.Collections;
+import java.util.List;
+
+public class ProjectApiImpl implements ProjectApi {
+  interface Factory {
+    ProjectApiImpl create(ProjectResource project);
+
+    ProjectApiImpl create(String name);
+  }
+
+  private final CurrentUser user;
+  private final PermissionBackend permissionBackend;
+  private final CreateProject.Factory createProjectFactory;
+  private final ProjectApiImpl.Factory projectApi;
+  private final ProjectsCollection projects;
+  private final GetDescription getDescription;
+  private final PutDescription putDescription;
+  private final ChildProjectApiImpl.Factory childApi;
+  private final ChildProjectsCollection children;
+  private final ProjectResource project;
+  private final ProjectJson projectJson;
+  private final String name;
+  private final BranchApiImpl.Factory branchApi;
+  private final TagApiImpl.Factory tagApi;
+  private final GetAccess getAccess;
+  private final SetAccess setAccess;
+  private final CreateAccessChange createAccessChange;
+  private final GetConfig getConfig;
+  private final PutConfig putConfig;
+  private final Provider<ListBranches> listBranches;
+  private final Provider<ListTags> listTags;
+  private final DeleteBranches deleteBranches;
+  private final DeleteTags deleteTags;
+  private final CommitsCollection commitsCollection;
+  private final CommitApiImpl.Factory commitApi;
+  private final DashboardApiImpl.Factory dashboardApi;
+  private final CheckAccess checkAccess;
+  private final Provider<ListDashboards> listDashboards;
+  private final GetHead getHead;
+  private final SetHead setHead;
+  private final GetParent getParent;
+  private final SetParent setParent;
+
+  @AssistedInject
+  ProjectApiImpl(
+      CurrentUser user,
+      PermissionBackend permissionBackend,
+      CreateProject.Factory createProjectFactory,
+      ProjectApiImpl.Factory projectApi,
+      ProjectsCollection projects,
+      GetDescription getDescription,
+      PutDescription putDescription,
+      ChildProjectApiImpl.Factory childApi,
+      ChildProjectsCollection children,
+      ProjectJson projectJson,
+      BranchApiImpl.Factory branchApiFactory,
+      TagApiImpl.Factory tagApiFactory,
+      GetAccess getAccess,
+      SetAccess setAccess,
+      CreateAccessChange createAccessChange,
+      GetConfig getConfig,
+      PutConfig putConfig,
+      Provider<ListBranches> listBranches,
+      Provider<ListTags> listTags,
+      DeleteBranches deleteBranches,
+      DeleteTags deleteTags,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
+      DashboardApiImpl.Factory dashboardApi,
+      CheckAccess checkAccess,
+      Provider<ListDashboards> listDashboards,
+      GetHead getHead,
+      SetHead setHead,
+      GetParent getParent,
+      SetParent setParent,
+      @Assisted ProjectResource project) {
+    this(
+        user,
+        permissionBackend,
+        createProjectFactory,
+        projectApi,
+        projects,
+        getDescription,
+        putDescription,
+        childApi,
+        children,
+        projectJson,
+        branchApiFactory,
+        tagApiFactory,
+        getAccess,
+        setAccess,
+        createAccessChange,
+        getConfig,
+        putConfig,
+        listBranches,
+        listTags,
+        deleteBranches,
+        deleteTags,
+        project,
+        commitsCollection,
+        commitApi,
+        dashboardApi,
+        checkAccess,
+        listDashboards,
+        getHead,
+        setHead,
+        getParent,
+        setParent,
+        null);
+  }
+
+  @AssistedInject
+  ProjectApiImpl(
+      CurrentUser user,
+      PermissionBackend permissionBackend,
+      CreateProject.Factory createProjectFactory,
+      ProjectApiImpl.Factory projectApi,
+      ProjectsCollection projects,
+      GetDescription getDescription,
+      PutDescription putDescription,
+      ChildProjectApiImpl.Factory childApi,
+      ChildProjectsCollection children,
+      ProjectJson projectJson,
+      BranchApiImpl.Factory branchApiFactory,
+      TagApiImpl.Factory tagApiFactory,
+      GetAccess getAccess,
+      SetAccess setAccess,
+      CreateAccessChange createAccessChange,
+      GetConfig getConfig,
+      PutConfig putConfig,
+      Provider<ListBranches> listBranches,
+      Provider<ListTags> listTags,
+      DeleteBranches deleteBranches,
+      DeleteTags deleteTags,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
+      DashboardApiImpl.Factory dashboardApi,
+      CheckAccess checkAccess,
+      Provider<ListDashboards> listDashboards,
+      GetHead getHead,
+      SetHead setHead,
+      GetParent getParent,
+      SetParent setParent,
+      @Assisted String name) {
+    this(
+        user,
+        permissionBackend,
+        createProjectFactory,
+        projectApi,
+        projects,
+        getDescription,
+        putDescription,
+        childApi,
+        children,
+        projectJson,
+        branchApiFactory,
+        tagApiFactory,
+        getAccess,
+        setAccess,
+        createAccessChange,
+        getConfig,
+        putConfig,
+        listBranches,
+        listTags,
+        deleteBranches,
+        deleteTags,
+        null,
+        commitsCollection,
+        commitApi,
+        dashboardApi,
+        checkAccess,
+        listDashboards,
+        getHead,
+        setHead,
+        getParent,
+        setParent,
+        name);
+  }
+
+  private ProjectApiImpl(
+      CurrentUser user,
+      PermissionBackend permissionBackend,
+      CreateProject.Factory createProjectFactory,
+      ProjectApiImpl.Factory projectApi,
+      ProjectsCollection projects,
+      GetDescription getDescription,
+      PutDescription putDescription,
+      ChildProjectApiImpl.Factory childApi,
+      ChildProjectsCollection children,
+      ProjectJson projectJson,
+      BranchApiImpl.Factory branchApiFactory,
+      TagApiImpl.Factory tagApiFactory,
+      GetAccess getAccess,
+      SetAccess setAccess,
+      CreateAccessChange createAccessChange,
+      GetConfig getConfig,
+      PutConfig putConfig,
+      Provider<ListBranches> listBranches,
+      Provider<ListTags> listTags,
+      DeleteBranches deleteBranches,
+      DeleteTags deleteTags,
+      ProjectResource project,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
+      DashboardApiImpl.Factory dashboardApi,
+      CheckAccess checkAccess,
+      Provider<ListDashboards> listDashboards,
+      GetHead getHead,
+      SetHead setHead,
+      GetParent getParent,
+      SetParent setParent,
+      String name) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.createProjectFactory = createProjectFactory;
+    this.projectApi = projectApi;
+    this.projects = projects;
+    this.getDescription = getDescription;
+    this.putDescription = putDescription;
+    this.childApi = childApi;
+    this.children = children;
+    this.projectJson = projectJson;
+    this.project = project;
+    this.branchApi = branchApiFactory;
+    this.tagApi = tagApiFactory;
+    this.getAccess = getAccess;
+    this.setAccess = setAccess;
+    this.getConfig = getConfig;
+    this.putConfig = putConfig;
+    this.listBranches = listBranches;
+    this.listTags = listTags;
+    this.deleteBranches = deleteBranches;
+    this.deleteTags = deleteTags;
+    this.commitsCollection = commitsCollection;
+    this.commitApi = commitApi;
+    this.createAccessChange = createAccessChange;
+    this.dashboardApi = dashboardApi;
+    this.checkAccess = checkAccess;
+    this.listDashboards = listDashboards;
+    this.getHead = getHead;
+    this.setHead = setHead;
+    this.getParent = getParent;
+    this.setParent = setParent;
+    this.name = name;
+  }
+
+  @Override
+  public ProjectApi create() throws RestApiException {
+    return create(new ProjectInput());
+  }
+
+  @Override
+  public ProjectApi create(ProjectInput in) throws RestApiException {
+    try {
+      if (name == null) {
+        throw new ResourceConflictException("Project already exists");
+      }
+      if (in.name != null && !name.equals(in.name)) {
+        throw new BadRequestException("name must match input.name");
+      }
+      CreateProject impl = createProjectFactory.create(name);
+      permissionBackend.user(user).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
+      impl.apply(TopLevelResource.INSTANCE, in);
+      return projectApi.create(projects.parse(name));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create project: " + e.getMessage(), e);
+    }
+  }
+
+  @Override
+  public ProjectInfo get() throws RestApiException {
+    if (project == null) {
+      throw new ResourceNotFoundException(name);
+    }
+    return projectJson.format(project.getProjectState());
+  }
+
+  @Override
+  public String description() throws RestApiException {
+    return getDescription.apply(checkExists());
+  }
+
+  @Override
+  public ProjectAccessInfo access() throws RestApiException {
+    try {
+      return getAccess.apply(checkExists());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get access rights", e);
+    }
+  }
+
+  @Override
+  public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
+    try {
+      return checkAccess.apply(checkExists(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check access rights", e);
+    }
+  }
+
+  @Override
+  public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
+    try {
+      return setAccess.apply(checkExists(), p);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put access rights", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo accessChange(ProjectAccessInput p) throws RestApiException {
+    try {
+      return createAccessChange.apply(checkExists(), p).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put access right change", e);
+    }
+  }
+
+  @Override
+  public void description(DescriptionInput in) throws RestApiException {
+    try {
+      putDescription.apply(checkExists(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put project description", e);
+    }
+  }
+
+  @Override
+  public ConfigInfo config() throws RestApiException {
+    return getConfig.apply(checkExists());
+  }
+
+  @Override
+  public ConfigInfo config(ConfigInput in) throws RestApiException {
+    try {
+      return putConfig.apply(checkExists(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list tags", e);
+    }
+  }
+
+  @Override
+  public ListRefsRequest<BranchInfo> branches() {
+    return new ListRefsRequest<BranchInfo>() {
+      @Override
+      public List<BranchInfo> get() throws RestApiException {
+        try {
+          return listBranches.get().request(this).apply(checkExists());
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list branches", e);
+        }
+      }
+    };
+  }
+
+  @Override
+  public ListRefsRequest<TagInfo> tags() {
+    return new ListRefsRequest<TagInfo>() {
+      @Override
+      public List<TagInfo> get() throws RestApiException {
+        try {
+          return listTags.get().request(this).apply(checkExists());
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list tags", e);
+        }
+      }
+    };
+  }
+
+  @Override
+  public List<ProjectInfo> children() throws RestApiException {
+    return children(false);
+  }
+
+  @Override
+  public List<ProjectInfo> children(boolean recursive) throws RestApiException {
+    ListChildProjects list = children.list();
+    list.setRecursive(recursive);
+    try {
+      return list.apply(checkExists());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list children", e);
+    }
+  }
+
+  @Override
+  public ChildProjectApi child(String name) throws RestApiException {
+    try {
+      return childApi.create(children.parse(checkExists(), IdString.fromDecoded(name)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse child project", e);
+    }
+  }
+
+  @Override
+  public BranchApi branch(String ref) throws ResourceNotFoundException {
+    return branchApi.create(checkExists(), ref);
+  }
+
+  @Override
+  public TagApi tag(String ref) throws ResourceNotFoundException {
+    return tagApi.create(checkExists(), ref);
+  }
+
+  @Override
+  public void deleteBranches(DeleteBranchesInput in) throws RestApiException {
+    try {
+      deleteBranches.apply(checkExists(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete branches", e);
+    }
+  }
+
+  @Override
+  public void deleteTags(DeleteTagsInput in) throws RestApiException {
+    try {
+      deleteTags.apply(checkExists(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete tags", e);
+    }
+  }
+
+  @Override
+  public CommitApi commit(String commit) throws RestApiException {
+    try {
+      return commitApi.create(commitsCollection.parse(checkExists(), IdString.fromDecoded(commit)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse commit", e);
+    }
+  }
+
+  @Override
+  public DashboardApi dashboard(String name) throws RestApiException {
+    try {
+      return dashboardApi.create(checkExists(), name);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse dashboard", e);
+    }
+  }
+
+  @Override
+  public DashboardApi defaultDashboard() throws RestApiException {
+    return dashboard(DEFAULT_DASHBOARD_NAME);
+  }
+
+  @Override
+  public void defaultDashboard(String name) throws RestApiException {
+    try {
+      dashboardApi.create(checkExists(), name).setDefault();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set default dashboard", e);
+    }
+  }
+
+  @Override
+  public void removeDefaultDashboard() throws RestApiException {
+    try {
+      dashboardApi.create(checkExists(), null).setDefault();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot remove default dashboard", e);
+    }
+  }
+
+  @Override
+  public ListDashboardsRequest dashboards() throws RestApiException {
+    return new ListDashboardsRequest() {
+      @Override
+      public List<DashboardInfo> get() throws RestApiException {
+        try {
+          List<?> r = listDashboards.get().apply(checkExists());
+          if (r.isEmpty()) {
+            return Collections.emptyList();
+          }
+          if (r.get(0) instanceof DashboardInfo) {
+            return r.stream().map(i -> (DashboardInfo) i).collect(toList());
+          }
+          throw new NotImplementedException("list with inheritance");
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list dashboards", e);
+        }
+      }
+    };
+  }
+
+  @Override
+  public String head() throws RestApiException {
+    try {
+      return getHead.apply(checkExists());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get HEAD", e);
+    }
+  }
+
+  @Override
+  public void head(String head) throws RestApiException {
+    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);
+    }
+    return project;
+  }
+}
diff --git a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
new file mode 100644
index 0000000..4552e7a
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
@@ -0,0 +1,166 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.api.projects.Projects;
+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.restapi.project.ListProjects;
+import com.google.gerrit.server.restapi.project.ListProjects.FilterType;
+import com.google.gerrit.server.restapi.project.ProjectsCollection;
+import com.google.gerrit.server.restapi.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
+class ProjectsImpl implements Projects {
+  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<QueryProjects> queryProvider) {
+    this.projects = projects;
+    this.api = api;
+    this.listProvider = listProvider;
+    this.queryProvider = queryProvider;
+  }
+
+  @Override
+  public ProjectApi name(String name) throws RestApiException {
+    try {
+      return api.create(projects.parse(name));
+    } catch (UnprocessableEntityException e) {
+      return api.create(name);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve project", e);
+    }
+  }
+
+  @Override
+  public ProjectApi create(String name) throws RestApiException {
+    ProjectInput in = new ProjectInput();
+    in.name = name;
+    return create(in);
+  }
+
+  @Override
+  public ProjectApi create(ProjectInput in) throws RestApiException {
+    if (in.name == null) {
+      throw new BadRequestException("input.name is required");
+    }
+    return name(in.name).create(in);
+  }
+
+  @Override
+  public ListRequest list() {
+    return new ListRequest() {
+      @Override
+      public SortedMap<String, ProjectInfo> getAsMap() throws RestApiException {
+        try {
+          return list(this);
+        } catch (Exception e) {
+          throw asRestApiException("project list unavailable", e);
+        }
+      }
+    };
+  }
+
+  private SortedMap<String, ProjectInfo> list(ListRequest request)
+      throws RestApiException, PermissionBackendException {
+    ListProjects lp = listProvider.get();
+    lp.setShowDescription(request.getDescription());
+    lp.setLimit(request.getLimit());
+    lp.setStart(request.getStart());
+    lp.setMatchPrefix(request.getPrefix());
+
+    lp.setMatchSubstring(request.getSubstring());
+    lp.setMatchRegex(request.getRegex());
+    lp.setShowTree(request.getShowTree());
+    for (String branch : request.getBranches()) {
+      lp.addShowBranch(branch);
+    }
+
+    FilterType type;
+    switch (request.getFilterType()) {
+      case ALL:
+        type = FilterType.ALL;
+        break;
+      case CODE:
+        type = FilterType.CODE;
+        break;
+      case PARENT_CANDIDATES:
+        type = FilterType.PARENT_CANDIDATES;
+        break;
+      case PERMISSIONS:
+        type = FilterType.PERMISSIONS;
+        break;
+      default:
+        throw new BadRequestException("Unknown filter type: " + request.getFilterType());
+    }
+    lp.setFilterType(type);
+
+    lp.setAll(request.isAll());
+
+    lp.setState(request.getState());
+
+    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/java/com/google/gerrit/server/api/projects/TagApiImpl.java b/java/com/google/gerrit/server/api/projects/TagApiImpl.java
new file mode 100644
index 0000000..84af4e8
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/TagApiImpl.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.projects.TagApi;
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.api.projects.TagInput;
+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.ProjectResource;
+import com.google.gerrit.server.project.TagResource;
+import com.google.gerrit.server.restapi.project.CreateTag;
+import com.google.gerrit.server.restapi.project.DeleteTag;
+import com.google.gerrit.server.restapi.project.ListTags;
+import com.google.gerrit.server.restapi.project.TagsCollection;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+
+public class TagApiImpl implements TagApi {
+  interface Factory {
+    TagApiImpl create(ProjectResource project, String ref);
+  }
+
+  private final ListTags listTags;
+  private final CreateTag.Factory createTagFactory;
+  private final DeleteTag deleteTag;
+  private final TagsCollection tags;
+  private final String ref;
+  private final ProjectResource project;
+
+  @Inject
+  TagApiImpl(
+      ListTags listTags,
+      CreateTag.Factory createTagFactory,
+      DeleteTag deleteTag,
+      TagsCollection tags,
+      @Assisted ProjectResource project,
+      @Assisted String ref) {
+    this.listTags = listTags;
+    this.createTagFactory = createTagFactory;
+    this.deleteTag = deleteTag;
+    this.tags = tags;
+    this.project = project;
+    this.ref = ref;
+  }
+
+  @Override
+  public TagApi create(TagInput input) throws RestApiException {
+    try {
+      createTagFactory.create(ref).apply(project, input);
+      return this;
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create tag", e);
+    }
+  }
+
+  @Override
+  public TagInfo get() throws RestApiException {
+    try {
+      return listTags.get(project, IdString.fromDecoded(ref));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get tag", e);
+    }
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      deleteTag.apply(resource(), new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete tag", e);
+    }
+  }
+
+  private TagResource resource() throws RestApiException, IOException {
+    return tags.parse(project, IdString.fromDecoded(ref));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
rename to java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
rename to java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
rename to java/com/google/gerrit/server/args4j/AccountIdHandler.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ChangeIdHandler.java b/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
rename to java/com/google/gerrit/server/args4j/ChangeIdHandler.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ObjectIdHandler.java b/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
rename to java/com/google/gerrit/server/args4j/ObjectIdHandler.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java b/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
rename to java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
diff --git a/java/com/google/gerrit/server/args4j/ProjectHandler.java b/java/com/google/gerrit/server/args4j/ProjectHandler.java
new file mode 100644
index 0000000..8959d97
--- /dev/null
+++ b/java/com/google/gerrit/server/args4j/ProjectHandler.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.args4j;
+
+import com.google.gerrit.common.ProjectUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.OptionDef;
+import org.kohsuke.args4j.spi.OptionHandler;
+import org.kohsuke.args4j.spi.Parameters;
+import org.kohsuke.args4j.spi.Setter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ProjectHandler extends OptionHandler<ProjectState> {
+  private static final Logger log = LoggerFactory.getLogger(ProjectHandler.class);
+
+  private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+
+  @Inject
+  public ProjectHandler(
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      @Assisted final CmdLineParser parser,
+      @Assisted final OptionDef option,
+      @Assisted final Setter<ProjectState> setter) {
+    super(parser, option, setter);
+    this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+  }
+
+  @Override
+  public final int parseArguments(Parameters params) throws CmdLineException {
+    String projectName = params.getParameter(0);
+
+    while (projectName.endsWith("/")) {
+      projectName = projectName.substring(0, projectName.length() - 1);
+    }
+
+    while (projectName.startsWith("/")) {
+      // Be nice and drop the leading "/" if supplied by an absolute path.
+      // We don't have a file system hierarchy, just a flat namespace in
+      // the database's Project entities. We never encode these with a
+      // leading '/' but users might accidentally include them in Git URLs.
+      //
+      projectName = projectName.substring(1);
+    }
+
+    String nameWithoutSuffix = ProjectUtil.stripGitSuffix(projectName);
+    Project.NameKey nameKey = new Project.NameKey(nameWithoutSuffix);
+
+    ProjectState state;
+    try {
+      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 (PermissionBackendException | IOException e) {
+      log.warn("Cannot load project " + nameWithoutSuffix, e);
+      throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
+    }
+
+    setter.addValue(state);
+    return 1;
+  }
+
+  @Override
+  public final String getDefaultMetaVariable() {
+    return "PROJECT";
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SocketAddressHandler.java b/java/com/google/gerrit/server/args4j/SocketAddressHandler.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/args4j/SocketAddressHandler.java
rename to java/com/google/gerrit/server/args4j/SocketAddressHandler.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SubcommandHandler.java b/java/com/google/gerrit/server/args4j/SubcommandHandler.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/args4j/SubcommandHandler.java
rename to java/com/google/gerrit/server/args4j/SubcommandHandler.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/TimestampHandler.java b/java/com/google/gerrit/server/args4j/TimestampHandler.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/args4j/TimestampHandler.java
rename to java/com/google/gerrit/server/args4j/TimestampHandler.java
diff --git a/java/com/google/gerrit/server/audit/AuditEvent.java b/java/com/google/gerrit/server/audit/AuditEvent.java
new file mode 100644
index 0000000..4abdfd9
--- /dev/null
+++ b/java/com/google/gerrit/server/audit/AuditEvent.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.audit;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.server.CurrentUser;
+
+public class AuditEvent {
+
+  public static final String UNKNOWN_SESSION_ID = "000000000000000000000000000";
+  protected static final ImmutableListMultimap<String, ?> EMPTY_PARAMS = ImmutableListMultimap.of();
+
+  public final String sessionId;
+  public final CurrentUser who;
+  public final long when;
+  public final String what;
+  public final ListMultimap<String, ?> params;
+  public final Object result;
+  public final long timeAtStart;
+  public final long elapsed;
+  public final UUID uuid;
+
+  @AutoValue
+  public abstract static class UUID {
+    private static UUID create() {
+      return new AutoValue_AuditEvent_UUID(
+          String.format("audit:%s", java.util.UUID.randomUUID().toString()));
+    }
+
+    public abstract String uuid();
+  }
+
+  /**
+   * Creates a new audit event with results
+   *
+   * @param sessionId session id the event belongs to
+   * @param who principal that has generated the event
+   * @param what object of the event
+   * @param when time-stamp of when the event started
+   * @param params parameters of the event
+   * @param result result of the event
+   */
+  public AuditEvent(
+      String sessionId,
+      CurrentUser who,
+      String what,
+      long when,
+      ListMultimap<String, ?> params,
+      Object result) {
+    Preconditions.checkNotNull(what, "what is a mandatory not null param !");
+
+    this.sessionId = MoreObjects.firstNonNull(sessionId, UNKNOWN_SESSION_ID);
+    this.who = who;
+    this.what = what;
+    this.when = when;
+    this.timeAtStart = this.when;
+    this.params = MoreObjects.firstNonNull(params, EMPTY_PARAMS);
+    this.uuid = UUID.create();
+    this.result = result;
+    this.elapsed = TimeUtil.nowMs() - timeAtStart;
+  }
+
+  @Override
+  public int hashCode() {
+    return uuid.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (this == obj) {
+      return true;
+    }
+    if (obj == null) {
+      return false;
+    }
+    if (getClass() != obj.getClass()) {
+      return false;
+    }
+
+    AuditEvent other = (AuditEvent) obj;
+    return this.uuid.equals(other.uuid);
+  }
+
+  @Override
+  public String toString() {
+    return String.format(
+        "AuditEvent UUID:%s, SID:%s, TS:%d, who:%s, what:%s",
+        uuid.uuid(), sessionId, when, who, what);
+  }
+}
diff --git a/java/com/google/gerrit/server/audit/AuditListener.java b/java/com/google/gerrit/server/audit/AuditListener.java
new file mode 100644
index 0000000..3f8c298
--- /dev/null
+++ b/java/com/google/gerrit/server/audit/AuditListener.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.audit;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+@ExtensionPoint
+public interface AuditListener {
+
+  void onAuditableAction(AuditEvent action);
+}
diff --git a/java/com/google/gerrit/server/audit/AuditModule.java b/java/com/google/gerrit/server/audit/AuditModule.java
new file mode 100644
index 0000000..c4d0f4d
--- /dev/null
+++ b/java/com/google/gerrit/server/audit/AuditModule.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.audit;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.inject.AbstractModule;
+
+public class AuditModule extends AbstractModule {
+
+  @Override
+  protected void configure() {
+    DynamicSet.setOf(binder(), AuditListener.class);
+    DynamicSet.setOf(binder(), GroupMemberAuditListener.class);
+    bind(AuditService.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/audit/AuditService.java b/java/com/google/gerrit/server/audit/AuditService.java
new file mode 100644
index 0000000..eb54fbc
--- /dev/null
+++ b/java/com/google/gerrit/server/audit/AuditService.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.audit;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
+import java.util.Collection;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class AuditService {
+  private static final Logger log = LoggerFactory.getLogger(AuditService.class);
+
+  private final DynamicSet<AuditListener> auditListeners;
+  private final DynamicSet<GroupMemberAuditListener> groupMemberAuditListeners;
+
+  @Inject
+  public AuditService(
+      DynamicSet<AuditListener> auditListeners,
+      DynamicSet<GroupMemberAuditListener> groupMemberAuditListeners) {
+    this.auditListeners = auditListeners;
+    this.groupMemberAuditListeners = groupMemberAuditListeners;
+  }
+
+  public void dispatch(AuditEvent action) {
+    for (AuditListener auditListener : auditListeners) {
+      auditListener.onAuditableAction(action);
+    }
+  }
+
+  public void dispatchAddAccountsToGroup(
+      Account.Id actor, Collection<AccountGroupMember> added, Timestamp addedOn) {
+    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
+      try {
+        auditListener.onAddAccountsToGroup(actor, added, addedOn);
+      } catch (RuntimeException e) {
+        log.error("failed to log add accounts to group event", e);
+      }
+    }
+  }
+
+  public void dispatchDeleteAccountsFromGroup(
+      Account.Id actor, Collection<AccountGroupMember> removed, Timestamp removedOn) {
+    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
+      try {
+        auditListener.onDeleteAccountsFromGroup(actor, removed, removedOn);
+      } catch (RuntimeException e) {
+        log.error("failed to log delete accounts from group event", e);
+      }
+    }
+  }
+
+  public void dispatchAddGroupsToGroup(
+      Account.Id actor, Collection<AccountGroupById> added, Timestamp addedOn) {
+    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
+      try {
+        auditListener.onAddGroupsToGroup(actor, added, addedOn);
+      } catch (RuntimeException e) {
+        log.error("failed to log add groups to group event", e);
+      }
+    }
+  }
+
+  public void dispatchDeleteGroupsFromGroup(
+      Account.Id actor, Collection<AccountGroupById> removed, Timestamp removedOn) {
+    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
+      try {
+        auditListener.onDeleteGroupsFromGroup(actor, removed, removedOn);
+      } catch (RuntimeException e) {
+        log.error("failed to log delete groups from group event", e);
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/audit/ExtendedHttpAuditEvent.java b/java/com/google/gerrit/server/audit/ExtendedHttpAuditEvent.java
new file mode 100644
index 0000000..29629f0
--- /dev/null
+++ b/java/com/google/gerrit/server/audit/ExtendedHttpAuditEvent.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.audit;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import javax.servlet.http.HttpServletRequest;
+
+/** Extended audit event. Adds request, resource and view data to HttpAuditEvent. */
+public class ExtendedHttpAuditEvent extends HttpAuditEvent {
+  public final HttpServletRequest httpRequest;
+  public final RestResource resource;
+  public final RestView<? extends RestResource> view;
+
+  /**
+   * Creates a new audit event with results
+   *
+   * @param sessionId session id the event belongs to
+   * @param who principal that has generated the event
+   * @param httpRequest the HttpServletRequest
+   * @param when time-stamp of when the event started
+   * @param params parameters of the event
+   * @param input input
+   * @param status HTTP status
+   * @param result result of the event
+   * @param resource REST resource data
+   * @param view view rendering object
+   */
+  public ExtendedHttpAuditEvent(
+      String sessionId,
+      CurrentUser who,
+      HttpServletRequest httpRequest,
+      long when,
+      ListMultimap<String, ?> params,
+      Object input,
+      int status,
+      Object result,
+      RestResource resource,
+      RestView<RestResource> view) {
+    super(
+        sessionId,
+        who,
+        httpRequest.getRequestURI(),
+        when,
+        params,
+        httpRequest.getMethod(),
+        input,
+        status,
+        result);
+    this.httpRequest = Preconditions.checkNotNull(httpRequest);
+    this.resource = resource;
+    this.view = view;
+  }
+}
diff --git a/java/com/google/gerrit/server/audit/GroupMemberAuditListener.java b/java/com/google/gerrit/server/audit/GroupMemberAuditListener.java
new file mode 100644
index 0000000..d820386
--- /dev/null
+++ b/java/com/google/gerrit/server/audit/GroupMemberAuditListener.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.audit;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import java.sql.Timestamp;
+import java.util.Collection;
+
+@ExtensionPoint
+public interface GroupMemberAuditListener {
+
+  void onAddAccountsToGroup(
+      Account.Id actor, Collection<AccountGroupMember> added, Timestamp addedOn);
+
+  void onDeleteAccountsFromGroup(
+      Account.Id actor, Collection<AccountGroupMember> removed, Timestamp removedOn);
+
+  void onAddGroupsToGroup(Account.Id actor, Collection<AccountGroupById> added, Timestamp addedOn);
+
+  void onDeleteGroupsFromGroup(
+      Account.Id actor, Collection<AccountGroupById> deleted, Timestamp removedOn);
+}
diff --git a/java/com/google/gerrit/server/audit/HttpAuditEvent.java b/java/com/google/gerrit/server/audit/HttpAuditEvent.java
new file mode 100644
index 0000000..11a6b63
--- /dev/null
+++ b/java/com/google/gerrit/server/audit/HttpAuditEvent.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.audit;
+
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.server.CurrentUser;
+
+public class HttpAuditEvent extends AuditEvent {
+  public final String httpMethod;
+  public final int httpStatus;
+  public final Object input;
+
+  /**
+   * Creates a new audit event with results
+   *
+   * @param sessionId session id the event belongs to
+   * @param who principal that has generated the event
+   * @param what object of the event
+   * @param when time-stamp of when the event started
+   * @param params parameters of the event
+   * @param httpMethod HTTP method
+   * @param input input
+   * @param status HTTP status
+   * @param result result of the event
+   */
+  public HttpAuditEvent(
+      String sessionId,
+      CurrentUser who,
+      String what,
+      long when,
+      ListMultimap<String, ?> params,
+      String httpMethod,
+      Object input,
+      int status,
+      Object result) {
+    super(sessionId, who, what, when, params, result);
+    this.httpMethod = httpMethod;
+    this.input = input;
+    this.httpStatus = status;
+  }
+}
diff --git a/java/com/google/gerrit/server/audit/RpcAuditEvent.java b/java/com/google/gerrit/server/audit/RpcAuditEvent.java
new file mode 100644
index 0000000..6c53bb2
--- /dev/null
+++ b/java/com/google/gerrit/server/audit/RpcAuditEvent.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.audit;
+
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.server.CurrentUser;
+
+public class RpcAuditEvent extends HttpAuditEvent {
+
+  /**
+   * Creates a new audit event with results
+   *
+   * @param sessionId session id the event belongs to
+   * @param who principal that has generated the event
+   * @param what object of the event
+   * @param when time-stamp of when the event started
+   * @param params parameters of the event
+   * @param httpMethod HTTP method
+   * @param input input
+   * @param status HTTP status
+   * @param result result of the event
+   */
+  public RpcAuditEvent(
+      String sessionId,
+      CurrentUser who,
+      String what,
+      long when,
+      ListMultimap<String, ?> params,
+      String httpMethod,
+      Object input,
+      int status,
+      Object result) {
+    super(sessionId, who, what, when, params, httpMethod, input, status, result);
+  }
+}
diff --git a/java/com/google/gerrit/server/audit/SshAuditEvent.java b/java/com/google/gerrit/server/audit/SshAuditEvent.java
new file mode 100644
index 0000000..89f01ac
--- /dev/null
+++ b/java/com/google/gerrit/server/audit/SshAuditEvent.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.audit;
+
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.server.CurrentUser;
+
+public class SshAuditEvent extends AuditEvent {
+
+  public SshAuditEvent(
+      String sessionId,
+      CurrentUser who,
+      String what,
+      long when,
+      ListMultimap<String, ?> params,
+      Object result) {
+    super(sessionId, who, what, when, params, result);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthBackend.java b/java/com/google/gerrit/server/auth/AuthBackend.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthBackend.java
rename to java/com/google/gerrit/server/auth/AuthBackend.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthException.java b/java/com/google/gerrit/server/auth/AuthException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthException.java
rename to java/com/google/gerrit/server/auth/AuthException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthRequest.java b/java/com/google/gerrit/server/auth/AuthRequest.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthRequest.java
rename to java/com/google/gerrit/server/auth/AuthRequest.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthUser.java b/java/com/google/gerrit/server/auth/AuthUser.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthUser.java
rename to java/com/google/gerrit/server/auth/AuthUser.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthenticationUnavailableException.java b/java/com/google/gerrit/server/auth/AuthenticationUnavailableException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthenticationUnavailableException.java
rename to java/com/google/gerrit/server/auth/AuthenticationUnavailableException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/InternalAuthBackend.java b/java/com/google/gerrit/server/auth/InternalAuthBackend.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/InternalAuthBackend.java
rename to java/com/google/gerrit/server/auth/InternalAuthBackend.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/InvalidCredentialsException.java b/java/com/google/gerrit/server/auth/InvalidCredentialsException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/InvalidCredentialsException.java
rename to java/com/google/gerrit/server/auth/InvalidCredentialsException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/MissingCredentialsException.java b/java/com/google/gerrit/server/auth/MissingCredentialsException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/MissingCredentialsException.java
rename to java/com/google/gerrit/server/auth/MissingCredentialsException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/NoSuchUserException.java b/java/com/google/gerrit/server/auth/NoSuchUserException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/NoSuchUserException.java
rename to java/com/google/gerrit/server/auth/NoSuchUserException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/UniversalAuthBackend.java b/java/com/google/gerrit/server/auth/UniversalAuthBackend.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/UniversalAuthBackend.java
rename to java/com/google/gerrit/server/auth/UniversalAuthBackend.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/UnknownUserException.java b/java/com/google/gerrit/server/auth/UnknownUserException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/UnknownUserException.java
rename to java/com/google/gerrit/server/auth/UnknownUserException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/UserNotAllowedException.java b/java/com/google/gerrit/server/auth/UserNotAllowedException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/UserNotAllowedException.java
rename to java/com/google/gerrit/server/auth/UserNotAllowedException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java b/java/com/google/gerrit/server/auth/ldap/Helper.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java
rename to java/com/google/gerrit/server/auth/ldap/Helper.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java b/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
rename to java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
rename to java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java b/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
rename to java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapModule.java b/java/com/google/gerrit/server/auth/ldap/LdapModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapModule.java
rename to java/com/google/gerrit/server/auth/ldap/LdapModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapQuery.java b/java/com/google/gerrit/server/auth/ldap/LdapQuery.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapQuery.java
rename to java/com/google/gerrit/server/auth/ldap/LdapQuery.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
rename to java/com/google/gerrit/server/auth/ldap/LdapRealm.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapType.java b/java/com/google/gerrit/server/auth/ldap/LdapType.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapType.java
rename to java/com/google/gerrit/server/auth/ldap/LdapType.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/SearchScope.java b/java/com/google/gerrit/server/auth/ldap/SearchScope.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/SearchScope.java
rename to java/com/google/gerrit/server/auth/ldap/SearchScope.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java b/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
rename to java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
rename to java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java b/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
rename to java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java b/java/com/google/gerrit/server/avatar/AvatarProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java
rename to java/com/google/gerrit/server/avatar/AvatarProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheBinding.java b/java/com/google/gerrit/server/cache/CacheBinding.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheBinding.java
rename to java/com/google/gerrit/server/cache/CacheBinding.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java b/java/com/google/gerrit/server/cache/CacheMetrics.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
rename to java/com/google/gerrit/server/cache/CacheMetrics.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheModule.java b/java/com/google/gerrit/server/cache/CacheModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheModule.java
rename to java/com/google/gerrit/server/cache/CacheModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheProvider.java b/java/com/google/gerrit/server/cache/CacheProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheProvider.java
rename to java/com/google/gerrit/server/cache/CacheProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheRemovalListener.java b/java/com/google/gerrit/server/cache/CacheRemovalListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheRemovalListener.java
rename to java/com/google/gerrit/server/cache/CacheRemovalListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java b/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
rename to java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/MemoryCacheFactory.java b/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
rename to java/com/google/gerrit/server/cache/MemoryCacheFactory.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/PersistentCache.java b/java/com/google/gerrit/server/cache/PersistentCache.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/cache/PersistentCache.java
rename to java/com/google/gerrit/server/cache/PersistentCache.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/PersistentCacheFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
rename to java/com/google/gerrit/server/cache/PersistentCacheFactory.java
diff --git a/java/com/google/gerrit/server/cache/h2/BUILD b/java/com/google/gerrit/server/cache/h2/BUILD
new file mode 100644
index 0000000..f8d0105
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/h2/BUILD
@@ -0,0 +1,16 @@
+java_library(
+    name = "h2",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib:h2",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
diff --git a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/DefaultCacheFactory.java b/java/com/google/gerrit/server/cache/h2/DefaultCacheFactory.java
similarity index 100%
rename from gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/DefaultCacheFactory.java
rename to java/com/google/gerrit/server/cache/h2/DefaultCacheFactory.java
diff --git a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
similarity index 100%
rename from gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
rename to java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
diff --git a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
similarity index 100%
rename from gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
rename to java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
new file mode 100644
index 0000000..cbe5e2b
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -0,0 +1,139 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.ChangeAbandoned;
+import com.google.gerrit.server.mail.send.AbandonedSender;
+import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class AbandonOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(AbandonOp.class);
+
+  private final AbandonedSender.Factory abandonedSenderFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeAbandoned changeAbandoned;
+
+  private final String msgTxt;
+  private final NotifyHandling notifyHandling;
+  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
+  private final Account account;
+
+  private Change change;
+  private PatchSet patchSet;
+  private ChangeMessage message;
+
+  public interface Factory {
+    AbandonOp create(
+        @Assisted @Nullable Account account,
+        @Assisted @Nullable String msgTxt,
+        @Assisted NotifyHandling notifyHandling,
+        @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify);
+  }
+
+  @Inject
+  AbandonOp(
+      AbandonedSender.Factory abandonedSenderFactory,
+      ChangeMessagesUtil cmUtil,
+      PatchSetUtil psUtil,
+      ChangeAbandoned changeAbandoned,
+      @Assisted @Nullable Account account,
+      @Assisted @Nullable String msgTxt,
+      @Assisted NotifyHandling notifyHandling,
+      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    this.abandonedSenderFactory = abandonedSenderFactory;
+    this.cmUtil = cmUtil;
+    this.psUtil = psUtil;
+    this.changeAbandoned = changeAbandoned;
+
+    this.account = account;
+    this.msgTxt = Strings.nullToEmpty(msgTxt);
+    this.notifyHandling = notifyHandling;
+    this.accountsToNotify = accountsToNotify;
+  }
+
+  @Nullable
+  public Change getChange() {
+    return change;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws OrmException, ResourceConflictException {
+    change = ctx.getChange();
+    PatchSet.Id psId = change.currentPatchSetId();
+    ChangeUpdate update = ctx.getUpdate(psId);
+    if (!change.getStatus().isOpen()) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+    }
+    patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+    change.setStatus(Change.Status.ABANDONED);
+    change.setLastUpdatedOn(ctx.getWhen());
+
+    update.setStatus(change.getStatus());
+    message = newMessage(ctx);
+    cmUtil.addChangeMessage(ctx.getDb(), update, message);
+    return true;
+  }
+
+  private ChangeMessage newMessage(ChangeContext ctx) {
+    StringBuilder msg = new StringBuilder();
+    msg.append("Abandoned");
+    if (!Strings.nullToEmpty(msgTxt).trim().isEmpty()) {
+      msg.append("\n\n");
+      msg.append(msgTxt.trim());
+    }
+
+    return ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_ABANDON);
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws OrmException {
+    try {
+      ReplyToChangeSender cm = abandonedSenderFactory.create(ctx.getProject(), change.getId());
+      if (account != null) {
+        cm.setFrom(account.getId());
+      }
+      cm.setChangeMessage(message.getMessage(), ctx.getWhen());
+      cm.setNotify(notifyHandling);
+      cm.setAccountsToNotify(accountsToNotify);
+      cm.send();
+    } catch (Exception e) {
+      log.error("Cannot email update for change " + change.getId(), e);
+    }
+    changeAbandoned.fire(change, patchSet, account, msgTxt, ctx.getWhen(), notifyHandling);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
new file mode 100644
index 0000000..9866ea9
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.config.ChangeCleanupConfig;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class AbandonUtil {
+  private static final Logger log = LoggerFactory.getLogger(AbandonUtil.class);
+
+  private final ChangeCleanupConfig cfg;
+  private final Provider<ChangeQueryProcessor> queryProvider;
+  private final ChangeQueryBuilder queryBuilder;
+  private final BatchAbandon batchAbandon;
+  private final InternalUser internalUser;
+
+  @Inject
+  AbandonUtil(
+      ChangeCleanupConfig cfg,
+      InternalUser.Factory internalUserFactory,
+      Provider<ChangeQueryProcessor> queryProvider,
+      ChangeQueryBuilder queryBuilder,
+      BatchAbandon batchAbandon) {
+    this.cfg = cfg;
+    this.queryProvider = queryProvider;
+    this.queryBuilder = queryBuilder;
+    this.batchAbandon = batchAbandon;
+    internalUser = internalUserFactory.create();
+  }
+
+  public void abandonInactiveOpenChanges(BatchUpdate.Factory updateFactory) {
+    if (cfg.getAbandonAfter() <= 0) {
+      return;
+    }
+
+    try {
+      String query =
+          "status:new age:" + TimeUnit.MILLISECONDS.toMinutes(cfg.getAbandonAfter()) + "m";
+      if (!cfg.getAbandonIfMergeable()) {
+        query += " -is:mergeable";
+      }
+
+      List<ChangeData> changesToAbandon =
+          queryProvider.get().enforceVisibility(false).query(queryBuilder.parse(query)).entities();
+      ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
+          ImmutableListMultimap.builder();
+      for (ChangeData cd : changesToAbandon) {
+        builder.put(cd.project(), cd);
+      }
+
+      int count = 0;
+      ListMultimap<Project.NameKey, ChangeData> abandons = builder.build();
+      String message = cfg.getAbandonMessage();
+      for (Project.NameKey project : abandons.keySet()) {
+        Collection<ChangeData> changes = getValidChanges(abandons.get(project), query);
+        try {
+          batchAbandon.batchAbandon(updateFactory, project, internalUser, changes, message);
+          count += changes.size();
+        } catch (Throwable e) {
+          StringBuilder msg = new StringBuilder("Failed to auto-abandon inactive change(s):");
+          for (ChangeData change : changes) {
+            msg.append(" ").append(change.getId().get());
+          }
+          msg.append(".");
+          log.error(msg.toString(), e);
+        }
+      }
+      log.info(String.format("Auto-Abandoned %d of %d changes.", count, changesToAbandon.size()));
+    } catch (QueryParseException | OrmException e) {
+      log.error("Failed to query inactive open changes for auto-abandoning.", e);
+    }
+  }
+
+  private Collection<ChangeData> getValidChanges(Collection<ChangeData> changes, String query)
+      throws OrmException, QueryParseException {
+    Collection<ChangeData> validChanges = new ArrayList<>();
+    for (ChangeData cd : changes) {
+      String newQuery = query + " change:" + cd.getId();
+      List<ChangeData> changesToAbandon =
+          queryProvider
+              .get()
+              .enforceVisibility(false)
+              .query(queryBuilder.parse(newQuery))
+              .entities();
+      if (!changesToAbandon.isEmpty()) {
+        validChanges.add(cd);
+      } else {
+        log.debug(
+            "Change data with id \"{}\" does not satisfy the query \"{}\""
+                + " any more, hence skipping it in clean up",
+            cd.getId(),
+            query);
+      }
+    }
+    return validChanges;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/AccountPatchReviewStore.java b/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
new file mode 100644
index 0000000..69825ea
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwtorm.server.OrmException;
+import java.util.Collection;
+import java.util.Optional;
+
+/**
+ * Store for reviewed flags on changes.
+ *
+ * <p>A reviewed flag is a tuple of (patch set ID, file, account ID) and records whether the user
+ * has reviewed a file in a patch set. Each user can easily have thousands of reviewed flags and the
+ * number of reviewed flags is growing without bound. The store must be able handle this data volume
+ * efficiently.
+ *
+ * <p>For a multi-master setup the store must replicate the data between the masters.
+ */
+public interface AccountPatchReviewStore {
+
+  /** Represents patch set id with reviewed files. */
+  @AutoValue
+  abstract class PatchSetWithReviewedFiles {
+    public abstract PatchSet.Id patchSetId();
+
+    public abstract ImmutableSet<String> files();
+
+    public static PatchSetWithReviewedFiles create(PatchSet.Id id, ImmutableSet<String> files) {
+      return new AutoValue_AccountPatchReviewStore_PatchSetWithReviewedFiles(id, files);
+    }
+  }
+
+  /**
+   * Marks the given file in the given patch set as reviewed by the given user.
+   *
+   * @param psId patch set ID
+   * @param accountId account ID of the user
+   * @param path file path
+   * @return {@code true} if the reviewed flag was updated, {@code false} if the reviewed flag was
+   *     already set
+   * @throws OrmException thrown if updating the reviewed flag failed
+   */
+  boolean markReviewed(PatchSet.Id psId, Account.Id accountId, String path) throws OrmException;
+
+  /**
+   * Marks the given files in the given patch set as reviewed by the given user.
+   *
+   * @param psId patch set ID
+   * @param accountId account ID of the user
+   * @param paths file paths
+   * @throws OrmException thrown if updating the reviewed flag failed
+   */
+  void markReviewed(PatchSet.Id psId, Account.Id accountId, Collection<String> paths)
+      throws OrmException;
+
+  /**
+   * Clears the reviewed flag for the given file in the given patch set for the given user.
+   *
+   * @param psId patch set ID
+   * @param accountId account ID of the user
+   * @param path file path
+   * @throws OrmException thrown if clearing the reviewed flag failed
+   */
+  void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path) throws OrmException;
+
+  /**
+   * Clears the reviewed flags for all files in the given patch set for all users.
+   *
+   * @param psId patch set ID
+   * @throws OrmException thrown if clearing the reviewed flags failed
+   */
+  void clearReviewed(PatchSet.Id psId) throws OrmException;
+
+  /**
+   * Find the latest patch set, that is smaller or equals to the given patch set, where at least,
+   * one file has been reviewed by the given user.
+   *
+   * @param psId patch set ID
+   * @param accountId account ID of the user
+   * @return optionally, all files the have been reviewed by the given user that belong to the patch
+   *     set that is smaller or equals to the given patch set
+   * @throws OrmException thrown if accessing the reviewed flags failed
+   */
+  Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId)
+      throws OrmException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
rename to java/com/google/gerrit/server/change/ActionJson.java
diff --git a/java/com/google/gerrit/server/change/ArchiveFormat.java b/java/com/google/gerrit/server/change/ArchiveFormat.java
new file mode 100644
index 0000000..0316c5f
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ArchiveFormat.java
@@ -0,0 +1,77 @@
+// Copyright 2013 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.OutputStream;
+import org.apache.commons.compress.archivers.ArchiveOutputStream;
+import org.eclipse.jgit.api.ArchiveCommand;
+import org.eclipse.jgit.api.ArchiveCommand.Format;
+import org.eclipse.jgit.archive.TarFormat;
+import org.eclipse.jgit.archive.Tbz2Format;
+import org.eclipse.jgit.archive.TgzFormat;
+import org.eclipse.jgit.archive.TxzFormat;
+import org.eclipse.jgit.archive.ZipFormat;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectLoader;
+
+public enum ArchiveFormat {
+  TGZ("application/x-gzip", new TgzFormat()),
+  TAR("application/x-tar", new TarFormat()),
+  TBZ2("application/x-bzip2", new Tbz2Format()),
+  TXZ("application/x-xz", new TxzFormat()),
+  ZIP("application/x-zip", new ZipFormat());
+
+  private final ArchiveCommand.Format<?> format;
+  private final String mimeType;
+
+  ArchiveFormat(String mimeType, ArchiveCommand.Format<?> format) {
+    this.format = format;
+    this.mimeType = mimeType;
+    ArchiveCommand.registerFormat(name(), format);
+  }
+
+  public String getShortName() {
+    return name().toLowerCase();
+  }
+
+  public String getMimeType() {
+    return mimeType;
+  }
+
+  public String getDefaultSuffix() {
+    return getSuffixes().iterator().next();
+  }
+
+  public Iterable<String> getSuffixes() {
+    return format.suffixes();
+  }
+
+  public ArchiveOutputStream createArchiveOutputStream(OutputStream o) throws IOException {
+    return (ArchiveOutputStream) this.format.createArchiveOutputStream(o);
+  }
+
+  public <T extends Closeable> void putEntry(T out, String path, byte[] data) throws IOException {
+    @SuppressWarnings("unchecked")
+    ArchiveCommand.Format<T> fmt = (Format<T>) format;
+    fmt.putEntry(
+        out,
+        null,
+        path,
+        FileMode.REGULAR_FILE,
+        new ObjectLoader.SmallObject(FileMode.TYPE_FILE, data));
+  }
+}
diff --git a/java/com/google/gerrit/server/change/BatchAbandon.java b/java/com/google/gerrit/server/change/BatchAbandon.java
new file mode 100644
index 0000000..059f110
--- /dev/null
+++ b/java/com/google/gerrit/server/change/BatchAbandon.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Collection;
+
+@Singleton
+public class BatchAbandon {
+  private final Provider<ReviewDb> dbProvider;
+  private final AbandonOp.Factory abandonOpFactory;
+
+  @Inject
+  BatchAbandon(Provider<ReviewDb> dbProvider, AbandonOp.Factory abandonOpFactory) {
+    this.dbProvider = dbProvider;
+    this.abandonOpFactory = abandonOpFactory;
+  }
+
+  /**
+   * If an extension has more than one changes to abandon that belong to the same project, they
+   * should use the batch instead of abandoning one by one.
+   *
+   * <p>It's the caller's responsibility to ensure that all jobs inside the same batch have the
+   * matching project from its ChangeData. Violations will result in a ResourceConflictException.
+   */
+  public void batchAbandon(
+      BatchUpdate.Factory updateFactory,
+      Project.NameKey project,
+      CurrentUser user,
+      Collection<ChangeData> changes,
+      String msgTxt,
+      NotifyHandling notifyHandling,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify)
+      throws RestApiException, UpdateException {
+    if (changes.isEmpty()) {
+      return;
+    }
+    Account account = user.isIdentifiedUser() ? user.asIdentifiedUser().getAccount() : null;
+    try (BatchUpdate u = updateFactory.create(dbProvider.get(), project, user, TimeUtil.nowTs())) {
+      for (ChangeData change : changes) {
+        if (!project.equals(change.project())) {
+          throw new ResourceConflictException(
+              String.format(
+                  "Project name \"%s\" doesn't match \"%s\"",
+                  change.project().get(), project.get()));
+        }
+        u.addOp(
+            change.getId(),
+            abandonOpFactory.create(account, msgTxt, notifyHandling, accountsToNotify));
+      }
+      u.execute();
+    }
+  }
+
+  public void batchAbandon(
+      BatchUpdate.Factory updateFactory,
+      Project.NameKey project,
+      CurrentUser user,
+      Collection<ChangeData> changes,
+      String msgTxt)
+      throws RestApiException, UpdateException {
+    batchAbandon(
+        updateFactory,
+        project,
+        user,
+        changes,
+        msgTxt,
+        NotifyHandling.ALL,
+        ImmutableListMultimap.of());
+  }
+
+  public void batchAbandon(
+      BatchUpdate.Factory updateFactory,
+      Project.NameKey project,
+      CurrentUser user,
+      Collection<ChangeData> changes)
+      throws RestApiException, UpdateException {
+    batchAbandon(
+        updateFactory, project, user, changes, "", NotifyHandling.ALL, ImmutableListMultimap.of());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
rename to java/com/google/gerrit/server/change/ChangeCleanupRunner.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java b/java/com/google/gerrit/server/change/ChangeEditResource.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java
rename to java/com/google/gerrit/server/change/ChangeEditResource.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
rename to java/com/google/gerrit/server/change/ChangeInserter.java
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
new file mode 100644
index 0000000..81558e3
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -0,0 +1,1492 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_COMMITS;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_FILES;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
+import static com.google.gerrit.extensions.client.ListChangesOption.COMMIT_FOOTERS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_FILES;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.DOWNLOAD_COMMANDS;
+import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
+import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
+import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
+import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
+import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
+import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
+import static com.google.gerrit.server.CommonConverters.toGitPerson;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Joiner;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Throwables;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.LinkedHashMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Table;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.extensions.api.changes.FixInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FetchInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.ProblemInfo;
+import com.google.gerrit.extensions.common.PushCertificateInfo;
+import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.TrackingIdInfo;
+import com.google.gerrit.extensions.common.VotingRangeInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.config.DownloadCommand;
+import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ReviewerByEmailSet;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.WebLinks;
+import com.google.gerrit.server.account.AccountInfoComparator;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.GpgApiAdapter;
+import com.google.gerrit.server.config.TrackingFooters;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.RemoveReviewerControl;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
+import com.google.gerrit.server.query.change.PluginDefinedAttributesFactory;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ChangeJson {
+  private static final Logger log = LoggerFactory.getLogger(ChangeJson.class);
+
+  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
+      ChangeField.SUBMIT_RULE_OPTIONS_LENIENT.toBuilder().build();
+
+  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
+      ChangeField.SUBMIT_RULE_OPTIONS_STRICT.toBuilder().build();
+
+  public static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
+      ImmutableSet.of(
+          ALL_COMMITS,
+          ALL_REVISIONS,
+          CHANGE_ACTIONS,
+          CHECK,
+          COMMIT_FOOTERS,
+          CURRENT_ACTIONS,
+          CURRENT_COMMIT,
+          MESSAGES);
+
+  @Singleton
+  public static class Factory {
+    private final AssistedFactory factory;
+
+    @Inject
+    Factory(AssistedFactory factory) {
+      this.factory = factory;
+    }
+
+    public ChangeJson noOptions() {
+      return create(ImmutableSet.of());
+    }
+
+    public ChangeJson create(Iterable<ListChangesOption> options) {
+      return factory.create(options);
+    }
+
+    public ChangeJson create(ListChangesOption first, ListChangesOption... rest) {
+      return create(Sets.immutableEnumSet(first, rest));
+    }
+  }
+
+  public interface AssistedFactory {
+    ChangeJson create(Iterable<ListChangesOption> options);
+  }
+
+  private final Provider<ReviewDb> db;
+  private final Provider<CurrentUser> userProvider;
+  private final AnonymousUser anonymous;
+  private final PermissionBackend permissionBackend;
+  private final GitRepositoryManager repoManager;
+  private final ProjectCache projectCache;
+  private final MergeUtil.Factory mergeUtilFactory;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final FileInfoJson fileInfoJson;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final DynamicMap<DownloadScheme> downloadSchemes;
+  private final DynamicMap<DownloadCommand> downloadCommands;
+  private final WebLinks webLinks;
+  private final ImmutableSet<ListChangesOption> options;
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ConsistencyChecker> checkerProvider;
+  private final ActionJson actionJson;
+  private final GpgApiAdapter gpgApi;
+  private final ChangeNotes.Factory notesFactory;
+  private final ChangeResource.Factory changeResourceFactory;
+  private final ChangeKindCache changeKindCache;
+  private final ChangeIndexCollection indexes;
+  private final ApprovalsUtil approvalsUtil;
+  private final RemoveReviewerControl removeReviewerControl;
+  private final TrackingFooters trackingFooters;
+  private boolean lazyLoad = true;
+  private AccountLoader accountLoader;
+  private FixInput fix;
+  private PluginDefinedAttributesFactory pluginDefinedAttributesFactory;
+
+  @Inject
+  ChangeJson(
+      Provider<ReviewDb> db,
+      Provider<CurrentUser> user,
+      AnonymousUser au,
+      PermissionBackend permissionBackend,
+      GitRepositoryManager repoManager,
+      ProjectCache projectCache,
+      MergeUtil.Factory mergeUtilFactory,
+      IdentifiedUser.GenericFactory uf,
+      ChangeData.Factory cdf,
+      FileInfoJson fileInfoJson,
+      AccountLoader.Factory ailf,
+      DynamicMap<DownloadScheme> downloadSchemes,
+      DynamicMap<DownloadCommand> downloadCommands,
+      WebLinks webLinks,
+      ChangeMessagesUtil cmUtil,
+      Provider<ConsistencyChecker> checkerProvider,
+      ActionJson actionJson,
+      GpgApiAdapter gpgApi,
+      ChangeNotes.Factory notesFactory,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeKindCache changeKindCache,
+      ChangeIndexCollection indexes,
+      ApprovalsUtil approvalsUtil,
+      RemoveReviewerControl removeReviewerControl,
+      TrackingFooters trackingFooters,
+      @Assisted Iterable<ListChangesOption> options) {
+    this.db = db;
+    this.userProvider = user;
+    this.anonymous = au;
+    this.changeDataFactory = cdf;
+    this.permissionBackend = permissionBackend;
+    this.repoManager = repoManager;
+    this.userFactory = uf;
+    this.projectCache = projectCache;
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.fileInfoJson = fileInfoJson;
+    this.accountLoaderFactory = ailf;
+    this.downloadSchemes = downloadSchemes;
+    this.downloadCommands = downloadCommands;
+    this.webLinks = webLinks;
+    this.cmUtil = cmUtil;
+    this.checkerProvider = checkerProvider;
+    this.actionJson = actionJson;
+    this.gpgApi = gpgApi;
+    this.notesFactory = notesFactory;
+    this.changeResourceFactory = changeResourceFactory;
+    this.changeKindCache = changeKindCache;
+    this.indexes = indexes;
+    this.approvalsUtil = approvalsUtil;
+    this.removeReviewerControl = removeReviewerControl;
+    this.options = Sets.immutableEnumSet(options);
+    this.trackingFooters = trackingFooters;
+  }
+
+  public ChangeJson lazyLoad(boolean load) {
+    lazyLoad = load;
+    return this;
+  }
+
+  public ChangeJson fix(FixInput fix) {
+    this.fix = fix;
+    return this;
+  }
+
+  public void setPluginDefinedAttributesFactory(PluginDefinedAttributesFactory pluginsFactory) {
+    this.pluginDefinedAttributesFactory = pluginsFactory;
+  }
+
+  public ChangeInfo format(ChangeResource rsrc) throws OrmException {
+    return format(changeDataFactory.create(db.get(), rsrc.getNotes()));
+  }
+
+  public ChangeInfo format(Change change) throws OrmException {
+    return format(changeDataFactory.create(db.get(), change));
+  }
+
+  public ChangeInfo format(Project.NameKey project, Change.Id id) throws OrmException {
+    ChangeNotes notes;
+    try {
+      notes = notesFactory.createChecked(db.get(), project, id);
+    } catch (OrmException e) {
+      if (!has(CHECK)) {
+        throw e;
+      }
+      return checkOnly(changeDataFactory.create(db.get(), project, id));
+    }
+    return format(changeDataFactory.create(db.get(), notes));
+  }
+
+  public ChangeInfo format(ChangeData cd) throws OrmException {
+    return format(cd, Optional.empty(), true);
+  }
+
+  private ChangeInfo format(
+      ChangeData cd, Optional<PatchSet.Id> limitToPsId, boolean fillAccountLoader)
+      throws OrmException {
+    try {
+      if (fillAccountLoader) {
+        accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
+        ChangeInfo res = toChangeInfo(cd, limitToPsId);
+        accountLoader.fill();
+        return res;
+      }
+      return toChangeInfo(cd, limitToPsId);
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | OrmException
+        | IOException
+        | PermissionBackendException
+        | NoSuchProjectException
+        | RuntimeException e) {
+      if (!has(CHECK)) {
+        Throwables.throwIfInstanceOf(e, OrmException.class);
+        throw new OrmException(e);
+      }
+      return checkOnly(cd);
+    }
+  }
+
+  public ChangeInfo format(RevisionResource rsrc) throws OrmException {
+    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
+    return format(cd, Optional.of(rsrc.getPatchSet().getId()), true);
+  }
+
+  public List<List<ChangeInfo>> formatQueryResults(List<QueryResult<ChangeData>> in)
+      throws OrmException {
+    accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
+    ensureLoaded(FluentIterable.from(in).transformAndConcat(QueryResult::entities));
+
+    List<List<ChangeInfo>> res = Lists.newArrayListWithCapacity(in.size());
+    Map<Change.Id, ChangeInfo> out = new HashMap<>();
+    for (QueryResult<ChangeData> r : in) {
+      List<ChangeInfo> infos = toChangeInfo(out, r.entities());
+      if (!infos.isEmpty() && r.more()) {
+        infos.get(infos.size() - 1)._moreChanges = true;
+      }
+      res.add(infos);
+    }
+    accountLoader.fill();
+    return res;
+  }
+
+  public List<ChangeInfo> formatChangeDatas(Collection<ChangeData> in) throws OrmException {
+    accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
+    ensureLoaded(in);
+    List<ChangeInfo> out = new ArrayList<>(in.size());
+    for (ChangeData cd : in) {
+      out.add(format(cd));
+    }
+    accountLoader.fill();
+    return out;
+  }
+
+  private void ensureLoaded(Iterable<ChangeData> all) throws OrmException {
+    if (lazyLoad) {
+      ChangeData.ensureChangeLoaded(all);
+      if (has(ALL_REVISIONS)) {
+        ChangeData.ensureAllPatchSetsLoaded(all);
+      } else if (has(CURRENT_REVISION) || has(MESSAGES)) {
+        ChangeData.ensureCurrentPatchSetLoaded(all);
+      }
+      if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
+        ChangeData.ensureReviewedByLoadedForOpenChanges(all);
+      }
+      ChangeData.ensureCurrentApprovalsLoaded(all);
+    } else {
+      for (ChangeData cd : all) {
+        cd.setLazyLoad(false);
+      }
+    }
+  }
+
+  private boolean has(ListChangesOption option) {
+    return options.contains(option);
+  }
+
+  private List<ChangeInfo> toChangeInfo(Map<Change.Id, ChangeInfo> out, List<ChangeData> changes) {
+    List<ChangeInfo> info = Lists.newArrayListWithCapacity(changes.size());
+    for (ChangeData cd : changes) {
+      ChangeInfo i = out.get(cd.getId());
+      if (i == null) {
+        try {
+          i = toChangeInfo(cd, Optional.empty());
+        } catch (PatchListNotAvailableException
+            | GpgException
+            | OrmException
+            | IOException
+            | PermissionBackendException
+            | NoSuchProjectException
+            | RuntimeException e) {
+          if (has(CHECK)) {
+            i = checkOnly(cd);
+          } else if (e instanceof NoSuchChangeException) {
+            log.info(
+                "NoSuchChangeException: Omitting corrupt change "
+                    + cd.getId()
+                    + " from results. Seems to be stale in the index.");
+            continue;
+          } else {
+            log.warn("Omitting corrupt change " + cd.getId() + " from results", e);
+            continue;
+          }
+        }
+        out.put(cd.getId(), i);
+      }
+      info.add(i);
+    }
+    return info;
+  }
+
+  private ChangeInfo checkOnly(ChangeData cd) {
+    ChangeNotes notes;
+    try {
+      notes = cd.notes();
+    } catch (OrmException e) {
+      String msg = "Error loading change";
+      log.warn(msg + " " + cd.getId(), e);
+      ChangeInfo info = new ChangeInfo();
+      info._number = cd.getId().get();
+      ProblemInfo p = new ProblemInfo();
+      p.message = msg;
+      info.problems = Lists.newArrayList(p);
+      return info;
+    }
+
+    ConsistencyChecker.Result result = checkerProvider.get().check(notes, fix);
+    ChangeInfo info;
+    Change c = result.change();
+    if (c != null) {
+      info = new ChangeInfo();
+      info.project = c.getProject().get();
+      info.branch = c.getDest().getShortName();
+      info.topic = c.getTopic();
+      info.changeId = c.getKey().get();
+      info.subject = c.getSubject();
+      info.status = c.getStatus().asChangeStatus();
+      info.owner = new AccountInfo(c.getOwner().get());
+      info.created = c.getCreatedOn();
+      info.updated = c.getLastUpdatedOn();
+      info._number = c.getId().get();
+      info.problems = result.problems();
+      info.isPrivate = c.isPrivate() ? true : null;
+      info.workInProgress = c.isWorkInProgress() ? true : null;
+      info.hasReviewStarted = c.hasReviewStarted();
+      finish(info);
+    } else {
+      info = new ChangeInfo();
+      info._number = result.id().get();
+      info.problems = result.problems();
+    }
+    return info;
+  }
+
+  private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
+          PermissionBackendException, NoSuchProjectException {
+    ChangeInfo out = new ChangeInfo();
+    CurrentUser user = userProvider.get();
+
+    if (has(CHECK)) {
+      out.problems = checkerProvider.get().check(cd.notes(), fix).problems();
+      // If any problems were fixed, the ChangeData needs to be reloaded.
+      for (ProblemInfo p : out.problems) {
+        if (p.status == ProblemInfo.Status.FIXED) {
+          cd = changeDataFactory.create(cd.db(), cd.project(), cd.getId());
+          break;
+        }
+      }
+    }
+
+    PermissionBackend.ForChange perm = permissionBackendForChange(user, cd);
+    Change in = cd.change();
+    out.project = in.getProject().get();
+    out.branch = in.getDest().getShortName();
+    out.topic = in.getTopic();
+    if (indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE)) {
+      if (in.getAssignee() != null) {
+        out.assignee = accountLoader.get(in.getAssignee());
+      }
+    }
+    out.hashtags = cd.hashtags();
+    out.changeId = in.getKey().get();
+    if (in.getStatus().isOpen()) {
+      SubmitTypeRecord str = cd.submitTypeRecord();
+      if (str.isOk()) {
+        out.submitType = str.type;
+      }
+      out.mergeable = cd.isMergeable();
+      if (has(SUBMITTABLE)) {
+        out.submittable = submittable(cd);
+      }
+    }
+    Optional<ChangedLines> changedLines = cd.changedLines();
+    if (changedLines.isPresent()) {
+      out.insertions = changedLines.get().insertions;
+      out.deletions = changedLines.get().deletions;
+    }
+    out.isPrivate = in.isPrivate() ? true : null;
+    out.workInProgress = in.isWorkInProgress() ? true : null;
+    out.hasReviewStarted = in.hasReviewStarted();
+    out.subject = in.getSubject();
+    out.status = in.getStatus().asChangeStatus();
+    out.owner = accountLoader.get(in.getOwner());
+    out.created = in.getCreatedOn();
+    out.updated = in.getLastUpdatedOn();
+    out._number = in.getId().get();
+    out.unresolvedCommentCount = cd.unresolvedCommentCount();
+
+    if (user.isIdentifiedUser()) {
+      Collection<String> stars = cd.stars(user.getAccountId());
+      out.starred = stars.contains(StarredChangesUtil.DEFAULT_LABEL) ? true : null;
+      if (!stars.isEmpty()) {
+        out.stars = stars;
+      }
+    }
+
+    if (in.getStatus().isOpen() && has(REVIEWED) && user.isIdentifiedUser()) {
+      out.reviewed = cd.isReviewedBy(user.getAccountId()) ? true : null;
+    }
+
+    out.labels = labelsFor(perm, cd, has(LABELS), has(DETAILED_LABELS));
+
+    if (out.labels != null && has(DETAILED_LABELS)) {
+      // If limited to specific patch sets but not the current patch set, don't
+      // list permitted labels, since users can't vote on those patch sets.
+      if (user.isIdentifiedUser()
+          && (!limitToPsId.isPresent() || limitToPsId.get().equals(in.currentPatchSetId()))) {
+        out.permittedLabels =
+            cd.change().getStatus() != Change.Status.ABANDONED
+                ? permittedLabels(perm, cd)
+                : ImmutableMap.of();
+      }
+
+      out.reviewers = reviewerMap(cd.reviewers(), cd.reviewersByEmail(), false);
+      out.pendingReviewers = reviewerMap(cd.pendingReviewers(), cd.pendingReviewersByEmail(), true);
+      out.removableReviewers = removableReviewers(cd, out);
+    }
+
+    setSubmitter(cd, out);
+    out.plugins =
+        pluginDefinedAttributesFactory != null ? pluginDefinedAttributesFactory.create(cd) : null;
+    out.revertOf = cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null;
+
+    if (has(REVIEWER_UPDATES)) {
+      out.reviewerUpdates = reviewerUpdates(cd);
+    }
+
+    boolean needMessages = has(MESSAGES);
+    boolean needRevisions = has(ALL_REVISIONS) || has(CURRENT_REVISION) || limitToPsId.isPresent();
+    Map<PatchSet.Id, PatchSet> src;
+    if (needMessages || needRevisions) {
+      src = loadPatchSets(cd, limitToPsId);
+    } else {
+      src = null;
+    }
+
+    if (needMessages) {
+      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(cd, src, limitToPsId, out);
+      if (out.revisions != null) {
+        for (Map.Entry<String, RevisionInfo> entry : out.revisions.entrySet()) {
+          if (entry.getValue().isCurrent) {
+            out.currentRevision = entry.getKey();
+            break;
+          }
+        }
+      }
+    }
+
+    if (has(CURRENT_ACTIONS) || has(CHANGE_ACTIONS)) {
+      actionJson.addChangeActions(out, cd.notes());
+    }
+
+    if (has(TRACKING_IDS)) {
+      ListMultimap<String, String> set = trackingFooters.extract(cd.commitFooters());
+      out.trackingIds =
+          set.entries()
+              .stream()
+              .map(e -> new TrackingIdInfo(e.getKey(), e.getValue()))
+              .collect(toList());
+    }
+
+    return out;
+  }
+
+  private Map<ReviewerState, Collection<AccountInfo>> reviewerMap(
+      ReviewerSet reviewers, ReviewerByEmailSet reviewersByEmail, boolean includeRemoved) {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap = new HashMap<>();
+    for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
+      if (!includeRemoved && state == ReviewerStateInternal.REMOVED) {
+        continue;
+      }
+      Collection<AccountInfo> reviewersByState = toAccountInfo(reviewers.byState(state));
+      reviewersByState.addAll(toAccountInfoByEmail(reviewersByEmail.byState(state)));
+      if (!reviewersByState.isEmpty()) {
+        reviewerMap.put(state.asReviewerState(), reviewersByState);
+      }
+    }
+    return reviewerMap;
+  }
+
+  private Collection<ReviewerUpdateInfo> reviewerUpdates(ChangeData cd) throws OrmException {
+    List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
+    List<ReviewerUpdateInfo> result = new ArrayList<>(reviewerUpdates.size());
+    for (ReviewerStatusUpdate c : reviewerUpdates) {
+      ReviewerUpdateInfo change = new ReviewerUpdateInfo();
+      change.updated = c.date();
+      change.state = c.state().asReviewerState();
+      change.updatedBy = accountLoader.get(c.updatedBy());
+      change.reviewer = accountLoader.get(c.reviewer());
+      result.add(change);
+    }
+    return result;
+  }
+
+  private boolean submittable(ChangeData cd) throws OrmException {
+    return SubmitRecord.findOkRecord(cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)).isPresent();
+  }
+
+  private List<SubmitRecord> submitRecords(ChangeData cd) throws OrmException {
+    return cd.submitRecords(SUBMIT_RULE_OPTIONS_LENIENT);
+  }
+
+  private Map<String, LabelInfo> labelsFor(
+      PermissionBackend.ForChange perm, ChangeData cd, boolean standard, boolean detailed)
+      throws OrmException, PermissionBackendException {
+    if (!standard && !detailed) {
+      return null;
+    }
+
+    LabelTypes labelTypes = cd.getLabelTypes();
+    Map<String, LabelWithStatus> withStatus =
+        cd.change().getStatus() == Change.Status.MERGED
+            ? labelsForSubmittedChange(perm, cd, labelTypes, standard, detailed)
+            : labelsForUnsubmittedChange(perm, cd, labelTypes, standard, detailed);
+    return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
+  }
+
+  private Map<String, LabelWithStatus> labelsForUnsubmittedChange(
+      PermissionBackend.ForChange perm,
+      ChangeData cd,
+      LabelTypes labelTypes,
+      boolean standard,
+      boolean detailed)
+      throws OrmException, PermissionBackendException {
+    Map<String, LabelWithStatus> labels = initLabels(cd, labelTypes, standard);
+    if (detailed) {
+      setAllApprovals(perm, cd, labels);
+    }
+    for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
+      LabelType type = labelTypes.byLabel(e.getKey());
+      if (type == null) {
+        continue;
+      }
+      if (standard) {
+        for (PatchSetApproval psa : cd.currentApprovals()) {
+          if (type.matches(psa)) {
+            short val = psa.getValue();
+            Account.Id accountId = psa.getAccountId();
+            setLabelScores(type, e.getValue(), val, accountId);
+          }
+        }
+      }
+      if (detailed) {
+        setLabelValues(type, e.getValue());
+      }
+    }
+    return labels;
+  }
+
+  private Map<String, LabelWithStatus> initLabels(
+      ChangeData cd, LabelTypes labelTypes, boolean standard) throws OrmException {
+    Map<String, LabelWithStatus> labels = new TreeMap<>(labelTypes.nameComparator());
+    for (SubmitRecord rec : submitRecords(cd)) {
+      if (rec.labels == null) {
+        continue;
+      }
+      for (SubmitRecord.Label r : rec.labels) {
+        LabelWithStatus p = labels.get(r.label);
+        if (p == null || p.status().compareTo(r.status) < 0) {
+          LabelInfo n = new LabelInfo();
+          if (standard) {
+            switch (r.status) {
+              case OK:
+                n.approved = accountLoader.get(r.appliedBy);
+                break;
+              case REJECT:
+                n.rejected = accountLoader.get(r.appliedBy);
+                n.blocking = true;
+                break;
+              case IMPOSSIBLE:
+              case MAY:
+              case NEED:
+              default:
+                break;
+            }
+          }
+
+          n.optional = r.status == SubmitRecord.Label.Status.MAY ? true : null;
+          labels.put(r.label, LabelWithStatus.create(n, r.status));
+        }
+      }
+    }
+    return labels;
+  }
+
+  private void setLabelScores(
+      LabelType type, LabelWithStatus l, short score, Account.Id accountId) {
+    if (l.label().approved != null || l.label().rejected != null) {
+      return;
+    }
+
+    if (type.getMin() == null || type.getMax() == null) {
+      // Can't set score for unknown or misconfigured type.
+      return;
+    }
+
+    if (score != 0) {
+      if (score == type.getMin().getValue()) {
+        l.label().rejected = accountLoader.get(accountId);
+      } else if (score == type.getMax().getValue()) {
+        l.label().approved = accountLoader.get(accountId);
+      } else if (score < 0) {
+        l.label().disliked = accountLoader.get(accountId);
+        l.label().value = score;
+      } else if (score > 0 && l.label().disliked == null) {
+        l.label().recommended = accountLoader.get(accountId);
+        l.label().value = score;
+      }
+    }
+  }
+
+  private void setAllApprovals(
+      PermissionBackend.ForChange basePerm, ChangeData cd, Map<String, LabelWithStatus> labels)
+      throws OrmException, PermissionBackendException {
+    Change.Status status = cd.change().getStatus();
+    checkState(
+        status != Change.Status.MERGED, "should not call setAllApprovals on %s change", status);
+
+    // Include a user in the output for this label if either:
+    //  - They are an explicit reviewer.
+    //  - They ever voted on this change.
+    Set<Account.Id> allUsers = new HashSet<>();
+    allUsers.addAll(cd.reviewers().byState(ReviewerStateInternal.REVIEWER));
+    for (PatchSetApproval psa : cd.approvals().values()) {
+      allUsers.add(psa.getAccountId());
+    }
+
+    Table<Account.Id, String, PatchSetApproval> current =
+        HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size());
+    for (PatchSetApproval psa : cd.currentApprovals()) {
+      current.put(psa.getAccountId(), psa.getLabel(), psa);
+    }
+
+    LabelTypes labelTypes = cd.getLabelTypes();
+    for (Account.Id accountId : allUsers) {
+      PermissionBackend.ForChange perm = basePerm.user(userFactory.create(accountId));
+      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
+      for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
+        LabelType lt = labelTypes.byLabel(e.getKey());
+        if (lt == null) {
+          // Ignore submit record for undefined label; likely the submit rule
+          // author didn't intend for the label to show up in the table.
+          continue;
+        }
+        Integer value;
+        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.getName(), null);
+        String tag = null;
+        Timestamp date = null;
+        PatchSetApproval psa = current.get(accountId, lt.getName());
+        if (psa != null) {
+          value = Integer.valueOf(psa.getValue());
+          if (value == 0) {
+            // This may be a dummy approval that was inserted when the reviewer
+            // was added. Explicitly check whether the user can vote on this
+            // label.
+            value = perm.test(new LabelPermission(lt)) ? 0 : null;
+          }
+          tag = psa.getTag();
+          date = psa.getGranted();
+          if (psa.isPostSubmit()) {
+            log.warn("unexpected post-submit approval on open change: {}", psa);
+          }
+        } else {
+          // Either the user cannot vote on this label, or they were added as a
+          // reviewer but have not responded yet. Explicitly check whether the
+          // user can vote on this label.
+          value = perm.test(new LabelPermission(lt)) ? 0 : null;
+        }
+        addApproval(
+            e.getValue().label(), approvalInfo(accountId, value, permittedVotingRange, tag, date));
+      }
+    }
+  }
+
+  private Map<String, VotingRangeInfo> getPermittedVotingRanges(
+      Map<String, Collection<String>> permittedLabels) {
+    Map<String, VotingRangeInfo> permittedVotingRanges =
+        Maps.newHashMapWithExpectedSize(permittedLabels.size());
+    for (String label : permittedLabels.keySet()) {
+      List<Integer> permittedVotingRange =
+          permittedLabels
+              .get(label)
+              .stream()
+              .map(this::parseRangeValue)
+              .filter(java.util.Objects::nonNull)
+              .sorted()
+              .collect(toList());
+
+      if (permittedVotingRange.isEmpty()) {
+        permittedVotingRanges.put(label, null);
+      } else {
+        int minPermittedValue = permittedVotingRange.get(0);
+        int maxPermittedValue = Iterables.getLast(permittedVotingRange);
+        permittedVotingRanges.put(label, new VotingRangeInfo(minPermittedValue, maxPermittedValue));
+      }
+    }
+    return permittedVotingRanges;
+  }
+
+  private Integer parseRangeValue(String value) {
+    if (value.startsWith("+")) {
+      value = value.substring(1);
+    } else if (value.startsWith(" ")) {
+      value = value.trim();
+    }
+    return Ints.tryParse(value);
+  }
+
+  private void setSubmitter(ChangeData cd, ChangeInfo out) throws OrmException {
+    Optional<PatchSetApproval> s = cd.getSubmitApproval();
+    if (!s.isPresent()) {
+      return;
+    }
+    out.submitted = s.get().getGranted();
+    out.submitter = accountLoader.get(s.get().getAccountId());
+  }
+
+  private Map<String, LabelWithStatus> labelsForSubmittedChange(
+      PermissionBackend.ForChange basePerm,
+      ChangeData cd,
+      LabelTypes labelTypes,
+      boolean standard,
+      boolean detailed)
+      throws OrmException, PermissionBackendException {
+    Set<Account.Id> allUsers = new HashSet<>();
+    if (detailed) {
+      // Users expect to see all reviewers on closed changes, even if they
+      // didn't vote on the latest patch set. If we don't need detailed labels,
+      // we aren't including 0 votes for all users below, so we can just look at
+      // the latest patch set (in the next loop).
+      for (PatchSetApproval psa : cd.approvals().values()) {
+        allUsers.add(psa.getAccountId());
+      }
+    }
+
+    Set<String> labelNames = new HashSet<>();
+    SetMultimap<Account.Id, PatchSetApproval> current =
+        MultimapBuilder.hashKeys().hashSetValues().build();
+    for (PatchSetApproval a : cd.currentApprovals()) {
+      allUsers.add(a.getAccountId());
+      LabelType type = labelTypes.byLabel(a.getLabelId());
+      if (type != null) {
+        labelNames.add(type.getName());
+        // Not worth the effort to distinguish between votable/non-votable for 0
+        // values on closed changes, since they can't vote anyway.
+        current.put(a.getAccountId(), a);
+      }
+    }
+
+    // Since voting on merged changes is allowed all labels which apply to
+    // the change must be returned. All applying labels can be retrieved from
+    // the submit records, which is what initLabels does.
+    // It's not possible to only compute the labels based on the approvals
+    // since merged changes may not have approvals for all labels (e.g. if not
+    // all labels are required for submit or if the change was auto-closed due
+    // to direct push or if new labels were defined after the change was
+    // merged).
+    Map<String, LabelWithStatus> labels;
+    labels = initLabels(cd, labelTypes, standard);
+
+    // Also include all labels for which approvals exists. E.g. there can be
+    // approvals for labels that are ignored by a Prolog submit rule and hence
+    // it wouldn't be included in the submit records.
+    for (String name : labelNames) {
+      if (!labels.containsKey(name)) {
+        labels.put(name, LabelWithStatus.create(new LabelInfo(), null));
+      }
+    }
+
+    if (detailed) {
+      labels
+          .entrySet()
+          .stream()
+          .filter(e -> labelTypes.byLabel(e.getKey()) != null)
+          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()), e.getValue()));
+    }
+
+    for (Account.Id accountId : allUsers) {
+      Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
+      Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
+      if (detailed) {
+        PermissionBackend.ForChange perm = basePerm.user(userFactory.create(accountId));
+        pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
+        for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
+          ApprovalInfo ai = approvalInfo(accountId, 0, null, null, null);
+          byLabel.put(entry.getKey(), ai);
+          addApproval(entry.getValue().label(), ai);
+        }
+      }
+      for (PatchSetApproval psa : current.get(accountId)) {
+        LabelType type = labelTypes.byLabel(psa.getLabelId());
+        if (type == null) {
+          continue;
+        }
+
+        short val = psa.getValue();
+        ApprovalInfo info = byLabel.get(type.getName());
+        if (info != null) {
+          info.value = Integer.valueOf(val);
+          info.permittedVotingRange = pvr.getOrDefault(type.getName(), null);
+          info.date = psa.getGranted();
+          info.tag = psa.getTag();
+          if (psa.isPostSubmit()) {
+            info.postSubmit = true;
+          }
+        }
+        if (!standard) {
+          continue;
+        }
+
+        setLabelScores(type, labels.get(type.getName()), val, accountId);
+      }
+    }
+    return labels;
+  }
+
+  private ApprovalInfo approvalInfo(
+      Account.Id id,
+      Integer value,
+      VotingRangeInfo permittedVotingRange,
+      String tag,
+      Timestamp date) {
+    ApprovalInfo ai = getApprovalInfo(id, value, permittedVotingRange, tag, date);
+    accountLoader.put(ai);
+    return ai;
+  }
+
+  public static ApprovalInfo getApprovalInfo(
+      Account.Id id,
+      Integer value,
+      VotingRangeInfo permittedVotingRange,
+      String tag,
+      Timestamp date) {
+    ApprovalInfo ai = new ApprovalInfo(id.get());
+    ai.value = value;
+    ai.permittedVotingRange = permittedVotingRange;
+    ai.date = date;
+    ai.tag = tag;
+    return ai;
+  }
+
+  private static boolean isOnlyZero(Collection<String> values) {
+    return values.isEmpty() || (values.size() == 1 && values.contains(" 0"));
+  }
+
+  private void setLabelValues(LabelType type, LabelWithStatus l) {
+    l.label().defaultValue = type.getDefaultValue();
+    l.label().values = new LinkedHashMap<>();
+    for (LabelValue v : type.getValues()) {
+      l.label().values.put(v.formatValue(), v.getText());
+    }
+    if (isOnlyZero(l.label().values.keySet())) {
+      l.label().values = null;
+    }
+  }
+
+  private Map<String, Collection<String>> permittedLabels(
+      PermissionBackend.ForChange perm, ChangeData cd)
+      throws OrmException, PermissionBackendException {
+    boolean isMerged = cd.change().getStatus() == Change.Status.MERGED;
+    LabelTypes labelTypes = cd.getLabelTypes();
+    Map<String, LabelType> toCheck = new HashMap<>();
+    for (SubmitRecord rec : submitRecords(cd)) {
+      if (rec.labels != null) {
+        for (SubmitRecord.Label r : rec.labels) {
+          LabelType type = labelTypes.byLabel(r.label);
+          if (type != null && (!isMerged || type.allowPostSubmit())) {
+            toCheck.put(type.getName(), type);
+          }
+        }
+      }
+    }
+
+    Map<String, Short> labels = null;
+    Set<LabelPermission.WithValue> can = perm.testLabels(toCheck.values());
+    SetMultimap<String, String> permitted = LinkedHashMultimap.create();
+    for (SubmitRecord rec : submitRecords(cd)) {
+      if (rec.labels == null) {
+        continue;
+      }
+      for (SubmitRecord.Label r : rec.labels) {
+        LabelType type = labelTypes.byLabel(r.label);
+        if (type == null || (isMerged && !type.allowPostSubmit())) {
+          continue;
+        }
+
+        for (LabelValue v : type.getValues()) {
+          boolean ok = can.contains(new LabelPermission.WithValue(type, v));
+          if (isMerged) {
+            if (labels == null) {
+              labels = currentLabels(perm, cd);
+            }
+            short prev = labels.getOrDefault(type.getName(), (short) 0);
+            ok &= v.getValue() >= prev;
+          }
+          if (ok) {
+            permitted.put(r.label, v.formatValue());
+          }
+        }
+      }
+    }
+
+    List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
+    for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
+      if (isOnlyZero(e.getValue())) {
+        toClear.add(e.getKey());
+      }
+    }
+    for (String label : toClear) {
+      permitted.removeAll(label);
+    }
+    return permitted.asMap();
+  }
+
+  private Map<String, Short> currentLabels(PermissionBackend.ForChange perm, ChangeData cd)
+      throws OrmException {
+    IdentifiedUser user = perm.user().asIdentifiedUser();
+    Map<String, Short> result = new HashMap<>();
+    for (PatchSetApproval psa :
+        approvalsUtil.byPatchSetUser(
+            db.get(),
+            lazyLoad ? cd.notes() : notesFactory.createFromIndexedChange(cd.change()),
+            user,
+            cd.change().currentPatchSetId(),
+            user.getAccountId(),
+            null,
+            null)) {
+      result.put(psa.getLabel(), psa.getValue());
+    }
+    return result;
+  }
+
+  private Collection<ChangeMessageInfo> messages(ChangeData cd) throws OrmException {
+    List<ChangeMessage> messages = cmUtil.byChange(db.get(), cd.notes());
+    if (messages.isEmpty()) {
+      return Collections.emptyList();
+    }
+
+    List<ChangeMessageInfo> result = Lists.newArrayListWithCapacity(messages.size());
+    for (ChangeMessage message : messages) {
+      PatchSet.Id patchNum = message.getPatchSetId();
+      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, NoSuchProjectException, OrmException, IOException {
+    // Although this is called removableReviewers, this method also determines
+    // which CCs are removable.
+    //
+    // For reviewers, we need to look at each approval, because the reviewer
+    // should only be considered removable if *all* of their approvals can be
+    // removed. First, add all reviewers with *any* removable approval to the
+    // "removable" set. Along the way, if we encounter a non-removable approval,
+    // add the reviewer to the "fixed" set. Before we return, remove all members
+    // of "fixed" from "removable", because not all of their approvals can be
+    // removed.
+    Collection<LabelInfo> labels = out.labels.values();
+    Set<Account.Id> fixed = Sets.newHashSetWithExpectedSize(labels.size());
+    Set<Account.Id> removable = Sets.newHashSetWithExpectedSize(labels.size());
+    for (LabelInfo label : labels) {
+      if (label.all == null) {
+        continue;
+      }
+      for (ApprovalInfo ai : label.all) {
+        Account.Id id = new Account.Id(ai._accountId);
+
+        if (removeReviewerControl.testRemoveReviewer(
+            cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
+          removable.add(id);
+        } else {
+          fixed.add(id);
+        }
+      }
+    }
+
+    // CCs are simpler than reviewers. They are removable if the ChangeControl
+    // would permit a non-negative approval by that account to be removed, in
+    // which case add them to removable. We don't need to add unremovable CCs to
+    // "fixed" because we only visit each CC once here.
+    Collection<AccountInfo> ccs = out.reviewers.get(ReviewerState.CC);
+    if (ccs != null) {
+      for (AccountInfo ai : ccs) {
+        if (ai._accountId != null) {
+          Account.Id id = new Account.Id(ai._accountId);
+          if (removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
+            removable.add(id);
+          }
+        }
+      }
+    }
+
+    // Subtract any reviewers with non-removable approvals from the "removable"
+    // set. This also subtracts any CCs that for some reason also hold
+    // unremovable approvals.
+    removable.removeAll(fixed);
+
+    List<AccountInfo> result = Lists.newArrayListWithCapacity(removable.size());
+    for (Account.Id id : removable) {
+      result.add(accountLoader.get(id));
+    }
+    // Reviewers added by email are always removable
+    for (Collection<AccountInfo> infos : out.reviewers.values()) {
+      for (AccountInfo info : infos) {
+        if (info._accountId == null) {
+          result.add(info);
+        }
+      }
+    }
+    return result;
+  }
+
+  private Collection<AccountInfo> toAccountInfo(Collection<Account.Id> accounts) {
+    return accounts
+        .stream()
+        .map(accountLoader::get)
+        .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
+        .collect(toList());
+  }
+
+  private Collection<AccountInfo> toAccountInfoByEmail(Collection<Address> addresses) {
+    return addresses
+        .stream()
+        .map(a -> new AccountInfo(a.getName(), a.getEmail()))
+        .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
+        .collect(toList());
+  }
+
+  @Nullable
+  private Repository openRepoIfNecessary(Project.NameKey project) throws IOException {
+    if (has(ALL_COMMITS) || has(CURRENT_COMMIT) || has(COMMIT_FOOTERS)) {
+      return repoManager.openRepository(project);
+    }
+    return null;
+  }
+
+  @Nullable
+  private RevWalk newRevWalk(@Nullable Repository repo) {
+    return repo != null ? new RevWalk(repo) : null;
+  }
+
+  private Map<String, RevisionInfo> revisions(
+      ChangeData cd,
+      Map<PatchSet.Id, PatchSet> map,
+      Optional<PatchSet.Id> limitToPsId,
+      ChangeInfo changeInfo)
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
+          PermissionBackendException {
+    Map<String, RevisionInfo> res = new LinkedHashMap<>();
+    Boolean isWorldReadable = null;
+    try (Repository repo = openRepoIfNecessary(cd.project());
+        RevWalk rw = newRevWalk(repo)) {
+      for (PatchSet in : map.values()) {
+        PatchSet.Id id = in.getId();
+        boolean want = false;
+        if (has(ALL_REVISIONS)) {
+          want = true;
+        } else if (limitToPsId.isPresent()) {
+          want = id.equals(limitToPsId.get());
+        } else {
+          want = id.equals(cd.change().currentPatchSetId());
+        }
+        if (want) {
+          if (isWorldReadable == null) {
+            isWorldReadable = isWorldReadable(cd);
+          }
+          res.put(
+              in.getRevision().get(),
+              toRevisionInfo(cd, in, repo, rw, false, changeInfo, isWorldReadable));
+        }
+      }
+      return res;
+    }
+  }
+
+  private Map<PatchSet.Id, PatchSet> loadPatchSets(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+      throws OrmException {
+    Collection<PatchSet> src;
+    if (has(ALL_REVISIONS) || has(MESSAGES)) {
+      src = cd.patchSets();
+    } else {
+      PatchSet ps;
+      if (limitToPsId.isPresent()) {
+        ps = cd.patchSet(limitToPsId.get());
+        if (ps == null) {
+          throw new OrmException("missing patch set " + limitToPsId.get());
+        }
+      } else {
+        ps = cd.currentPatchSet();
+        if (ps == null) {
+          throw new OrmException("missing current patch set for change " + cd.getId());
+        }
+      }
+      src = Collections.singletonList(ps);
+    }
+    Map<PatchSet.Id, PatchSet> map = Maps.newHashMapWithExpectedSize(src.size());
+    for (PatchSet patchSet : src) {
+      map.put(patchSet.getId(), patchSet);
+    }
+    return map;
+  }
+
+  public RevisionInfo getRevisionInfo(ChangeData cd, PatchSet in)
+      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, isWorldReadable(cd));
+      accountLoader.fill();
+      return rev;
+    }
+  }
+
+  private RevisionInfo toRevisionInfo(
+      ChangeData cd,
+      PatchSet in,
+      @Nullable Repository repo,
+      @Nullable RevWalk rw,
+      boolean fillCommit,
+      @Nullable ChangeInfo changeInfo,
+      boolean isWorldReadable)
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
+    Change c = cd.change();
+    RevisionInfo out = new RevisionInfo();
+    out.isCurrent = in.getId().equals(c.currentPatchSetId());
+    out._number = in.getId().get();
+    out.ref = in.getRefName();
+    out.created = in.getCreatedOn();
+    out.uploader = accountLoader.get(in.getUploader());
+    out.fetch = makeFetchMap(cd, in, isWorldReadable);
+    out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
+    out.description = in.getDescription();
+
+    boolean setCommit = has(ALL_COMMITS) || (out.isCurrent && has(CURRENT_COMMIT));
+    boolean addFooters = out.isCurrent && has(COMMIT_FOOTERS);
+    if (setCommit || addFooters) {
+      checkState(rw != null);
+      checkState(repo != null);
+      Project.NameKey project = c.getProject();
+      String rev = in.getRevision().get();
+      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
+      rw.parseBody(commit);
+      if (setCommit) {
+        out.commit = toCommit(project, rw, commit, has(WEB_LINKS), fillCommit);
+      }
+      if (addFooters) {
+        Ref ref = repo.exactRef(cd.change().getDest().get());
+        RevCommit mergeTip = null;
+        if (ref != null) {
+          mergeTip = rw.parseCommit(ref.getObjectId());
+          rw.parseBody(mergeTip);
+        }
+        out.commitWithFooters =
+            mergeUtilFactory
+                .create(projectCache.get(project))
+                .createCommitMessageOnSubmit(
+                    commit, mergeTip, cd.notes(), userProvider.get(), in.getId());
+      }
+    }
+
+    if (has(ALL_FILES) || (out.isCurrent && has(CURRENT_FILES))) {
+      out.files = fileInfoJson.toFileInfoMap(c, in);
+      out.files.remove(Patch.COMMIT_MSG);
+      out.files.remove(Patch.MERGE_LIST);
+    }
+
+    if (out.isCurrent && has(CURRENT_ACTIONS) && userProvider.get().isIdentifiedUser()) {
+
+      actionJson.addRevisionActions(
+          changeInfo,
+          out,
+          new RevisionResource(changeResourceFactory.create(cd.notes(), userProvider.get()), in));
+    }
+
+    if (gpgApi.isEnabled() && has(PUSH_CERTIFICATES)) {
+      if (in.getPushCertificate() != null) {
+        out.pushCertificate =
+            gpgApi.checkPushCertificate(
+                in.getPushCertificate(), userFactory.create(in.getUploader()));
+      } else {
+        out.pushCertificate = new PushCertificateInfo();
+      }
+    }
+
+    return out;
+  }
+
+  public CommitInfo toCommit(
+      Project.NameKey project, RevWalk rw, RevCommit commit, boolean addLinks, boolean fillCommit)
+      throws IOException {
+    CommitInfo info = new CommitInfo();
+    if (fillCommit) {
+      info.commit = commit.name();
+    }
+    info.parents = new ArrayList<>(commit.getParentCount());
+    info.author = toGitPerson(commit.getAuthorIdent());
+    info.committer = toGitPerson(commit.getCommitterIdent());
+    info.subject = commit.getShortMessage();
+    info.message = commit.getFullMessage();
+
+    if (addLinks) {
+      List<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
+      info.webLinks = links.isEmpty() ? null : links;
+    }
+
+    for (RevCommit parent : commit.getParents()) {
+      rw.parseBody(parent);
+      CommitInfo i = new CommitInfo();
+      i.commit = parent.name();
+      i.subject = parent.getShortMessage();
+      if (addLinks) {
+        List<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
+        i.webLinks = parentLinks.isEmpty() ? null : parentLinks;
+      }
+      info.parents.add(i);
+    }
+    return info;
+  }
+
+  private Map<String, FetchInfo> makeFetchMap(ChangeData cd, PatchSet in, boolean isWorldReadable) {
+    Map<String, FetchInfo> r = new LinkedHashMap<>();
+    for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
+      String schemeName = e.getExportName();
+      DownloadScheme scheme = e.getProvider().get();
+      if (!scheme.isEnabled()
+          || (scheme.isAuthRequired() && !userProvider.get().isIdentifiedUser())) {
+        continue;
+      }
+      if (!scheme.isAuthSupported() && !isWorldReadable) {
+        continue;
+      }
+
+      String projectName = cd.project().get();
+      String url = scheme.getUrl(projectName);
+      String refName = in.getRefName();
+      FetchInfo fetchInfo = new FetchInfo(url, refName);
+      r.put(schemeName, fetchInfo);
+
+      if (has(DOWNLOAD_COMMANDS)) {
+        populateFetchMap(scheme, downloadCommands, projectName, refName, fetchInfo);
+      }
+    }
+
+    return r;
+  }
+
+  public static void populateFetchMap(
+      DownloadScheme scheme,
+      DynamicMap<DownloadCommand> commands,
+      String projectName,
+      String refName,
+      FetchInfo fetchInfo) {
+    for (DynamicMap.Entry<DownloadCommand> e2 : commands) {
+      String commandName = e2.getExportName();
+      DownloadCommand command = e2.getProvider().get();
+      String c = command.getCommand(scheme, projectName, refName);
+      if (c != null) {
+        addCommand(fetchInfo, commandName, c);
+      }
+    }
+  }
+
+  private static void addCommand(FetchInfo fetchInfo, String commandName, String c) {
+    if (fetchInfo.commands == null) {
+      fetchInfo.commands = new TreeMap<>();
+    }
+    fetchInfo.commands.put(commandName, c);
+  }
+
+  static void finish(ChangeInfo info) {
+    info.id =
+        Joiner.on('~')
+            .join(Url.encode(info.project), Url.encode(info.branch), Url.encode(info.changeId));
+  }
+
+  private static void addApproval(LabelInfo label, ApprovalInfo approval) {
+    if (label.all == null) {
+      label.all = new ArrayList<>();
+    }
+    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) {
+      return new AutoValue_ChangeJson_LabelWithStatus(label, status);
+    }
+
+    abstract LabelInfo label();
+
+    @Nullable
+    abstract SubmitRecord.Label.Status status();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java b/java/com/google/gerrit/server/change/ChangeKindCache.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
rename to java/com/google/gerrit/server/change/ChangeKindCache.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
rename to java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java b/java/com/google/gerrit/server/change/ChangeMessages.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
rename to java/com/google/gerrit/server/change/ChangeMessages.java
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
new file mode 100644
index 0000000..8c40ad1
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -0,0 +1,218 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.hash.Hasher;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestResource.HasETag;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.TypeLiteral;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ChangeResource implements RestResource, HasETag {
+  private static final Logger log = LoggerFactory.getLogger(ChangeResource.class);
+
+  /**
+   * JSON format version number for ETag computations.
+   *
+   * <p>Should be bumped on any JSON format change (new fields, etc.) so that otherwise unmodified
+   * changes get new ETags.
+   */
+  public static final int JSON_FORMAT_VERSION = 1;
+
+  public static final TypeLiteral<RestView<ChangeResource>> CHANGE_KIND =
+      new TypeLiteral<RestView<ChangeResource>>() {};
+
+  public interface Factory {
+    ChangeResource create(ChangeNotes notes, CurrentUser user);
+  }
+
+  private static final String ZERO_ID_STRING = ObjectId.zeroId().name();
+
+  private final Provider<ReviewDb> db;
+  private final AccountCache accountCache;
+  private final ApprovalsUtil approvalUtil;
+  private final PatchSetUtil patchSetUtil;
+  private final PermissionBackend permissionBackend;
+  private final StarredChangesUtil starredChangesUtil;
+  private final ProjectCache projectCache;
+  private final ChangeNotes notes;
+  private final CurrentUser user;
+
+  @Inject
+  ChangeResource(
+      Provider<ReviewDb> db,
+      AccountCache accountCache,
+      ApprovalsUtil approvalUtil,
+      PatchSetUtil patchSetUtil,
+      PermissionBackend permissionBackend,
+      StarredChangesUtil starredChangesUtil,
+      ProjectCache projectCache,
+      @Assisted ChangeNotes notes,
+      @Assisted CurrentUser user) {
+    this.db = db;
+    this.accountCache = accountCache;
+    this.approvalUtil = approvalUtil;
+    this.patchSetUtil = patchSetUtil;
+    this.permissionBackend = permissionBackend;
+    this.starredChangesUtil = starredChangesUtil;
+    this.projectCache = projectCache;
+    this.notes = notes;
+    this.user = user;
+  }
+
+  public PermissionBackend.ForChange permissions() {
+    return permissionBackend.user(user).database(db).change(notes);
+  }
+
+  public CurrentUser getUser() {
+    return user;
+  }
+
+  public Change.Id getId() {
+    return notes.getChangeId();
+  }
+
+  /** @return true if {@link #getUser()} is the change's owner. */
+  public boolean isUserOwner() {
+    Account.Id owner = getChange().getOwner();
+    return user.isIdentifiedUser() && user.asIdentifiedUser().getAccountId().equals(owner);
+  }
+
+  public Change getChange() {
+    return notes.getChange();
+  }
+
+  public Project.NameKey getProject() {
+    return getChange().getProject();
+  }
+
+  public ChangeNotes getNotes() {
+    return notes;
+  }
+
+  // This includes all information relevant for ETag computation
+  // unrelated to the UI.
+  public void prepareETag(Hasher h, CurrentUser user) {
+    h.putInt(JSON_FORMAT_VERSION)
+        .putLong(getChange().getLastUpdatedOn().getTime())
+        .putInt(getChange().getRowVersion())
+        .putInt(user.isIdentifiedUser() ? user.getAccountId().get() : 0);
+
+    if (user.isIdentifiedUser()) {
+      for (AccountGroup.UUID uuid : user.getEffectiveGroups().getKnownGroups()) {
+        h.putBytes(uuid.get().getBytes(UTF_8));
+      }
+    }
+
+    byte[] buf = new byte[20];
+    Set<Account.Id> accounts = new HashSet<>();
+    accounts.add(getChange().getOwner());
+    if (getChange().getAssignee() != null) {
+      accounts.add(getChange().getAssignee());
+    }
+    try {
+      patchSetUtil
+          .byChange(db.get(), notes)
+          .stream()
+          .map(ps -> ps.getUploader())
+          .forEach(accounts::add);
+
+      // It's intentional to include the states for *all* reviewers into the ETag computation.
+      // We need the states of all current reviewers and CCs because they are part of ChangeInfo.
+      // Including removed reviewers is a cheap way of making sure that the states of accounts that
+      // posted a message on the change are included. Loading all change messages to find the exact
+      // set of accounts that posted a message is too expensive. However everyone who posts a
+      // message is automatically added as reviewer. Hence if we include removed reviewers we can
+      // be sure that we have all accounts that posted messages on the change.
+      accounts.addAll(approvalUtil.getReviewers(db.get(), notes).all());
+    } catch (OrmException e) {
+      // This ETag will be invalidated if it loads next time.
+    }
+    accounts.stream().forEach(a -> hashAccount(h, accountCache.get(a), buf));
+
+    ObjectId noteId;
+    try {
+      noteId = notes.loadRevision();
+    } catch (OrmException e) {
+      noteId = null; // This ETag will be invalidated if it loads next time.
+    }
+    hashObjectId(h, noteId, buf);
+    // TODO(dborowitz): Include more NoteDb and other related refs, e.g. drafts
+    // and edits.
+
+    Iterable<ProjectState> projectStateTree;
+    try {
+      projectStateTree = projectCache.checkedGet(getProject()).tree();
+    } catch (IOException e) {
+      log.error(String.format("could not load project %s while computing etag", getProject()));
+      projectStateTree = ImmutableList.of();
+    }
+
+    for (ProjectState p : projectStateTree) {
+      hashObjectId(h, p.getConfig().getRevision(), buf);
+    }
+  }
+
+  @Override
+  public String getETag() {
+    Hasher h = Hashing.murmur3_128().newHasher();
+    if (user.isIdentifiedUser()) {
+      h.putString(starredChangesUtil.getObjectId(user.getAccountId(), getId()).name(), UTF_8);
+    }
+    prepareETag(h, user);
+    return h.hash().toString();
+  }
+
+  private void hashObjectId(Hasher h, ObjectId id, byte[] buf) {
+    MoreObjects.firstNonNull(id, ObjectId.zeroId()).copyRawTo(buf, 0);
+    h.putBytes(buf);
+  }
+
+  private void hashAccount(Hasher h, AccountState accountState, byte[] buf) {
+    h.putString(
+        MoreObjects.firstNonNull(accountState.getAccount().getMetaId(), ZERO_ID_STRING), UTF_8);
+    accountState.getExternalIds().stream().forEach(e -> hashObjectId(h, e.blobId(), buf));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeTriplet.java b/java/com/google/gerrit/server/change/ChangeTriplet.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeTriplet.java
rename to java/com/google/gerrit/server/change/ChangeTriplet.java
diff --git a/java/com/google/gerrit/server/change/CommentResource.java b/java/com/google/gerrit/server/change/CommentResource.java
new file mode 100644
index 0000000..1b7cbf8
--- /dev/null
+++ b/java/com/google/gerrit/server/change/CommentResource.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.inject.TypeLiteral;
+
+public class CommentResource implements RestResource {
+  public static final TypeLiteral<RestView<CommentResource>> COMMENT_KIND =
+      new TypeLiteral<RestView<CommentResource>>() {};
+
+  private final RevisionResource rev;
+  private final Comment comment;
+
+  public CommentResource(RevisionResource rev, Comment c) {
+    this.rev = rev;
+    this.comment = c;
+  }
+
+  public PatchSet getPatchSet() {
+    return rev.getPatchSet();
+  }
+
+  public Comment getComment() {
+    return comment;
+  }
+
+  public String getId() {
+    return comment.key.uuid;
+  }
+
+  public Account.Id getAuthorId() {
+    return comment.author.getId();
+  }
+
+  public RevisionResource getRevisionResource() {
+    return rev;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
new file mode 100644
index 0000000..e10197f
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -0,0 +1,786 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
+import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.FixInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.ProblemInfo;
+import com.google.gerrit.extensions.common.ProblemInfo.Status;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.PatchSetState;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdateReviewDb;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Checks changes for various kinds of inconsistency and corruption.
+ *
+ * <p>A single instance may be reused for checking multiple changes, but not concurrently.
+ */
+public class ConsistencyChecker {
+  private static final Logger log = LoggerFactory.getLogger(ConsistencyChecker.class);
+
+  @AutoValue
+  public abstract static class Result {
+    private static Result create(ChangeNotes notes, List<ProblemInfo> problems) {
+      return new AutoValue_ConsistencyChecker_Result(
+          notes.getChangeId(), notes.getChange(), problems);
+    }
+
+    public abstract Change.Id id();
+
+    @Nullable
+    public abstract Change change();
+
+    public abstract List<ProblemInfo> problems();
+  }
+
+  private final ChangeNotes.Factory notesFactory;
+  private final Accounts accounts;
+  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
+  private final GitRepositoryManager repoManager;
+  private final PatchSetInfoFactory patchSetInfoFactory;
+  private final PatchSetInserter.Factory patchSetInserterFactory;
+  private final PatchSetUtil psUtil;
+  private final Provider<CurrentUser> user;
+  private final Provider<PersonIdent> serverIdent;
+  private final Provider<ReviewDb> db;
+  private final RetryHelper retryHelper;
+
+  private BatchUpdate.Factory updateFactory;
+  private FixInput fix;
+  private ChangeNotes notes;
+  private Repository repo;
+  private RevWalk rw;
+  private ObjectInserter oi;
+
+  private RevCommit tip;
+  private SetMultimap<ObjectId, PatchSet> patchSetsBySha;
+  private PatchSet currPs;
+  private RevCommit currPsCommit;
+
+  private List<ProblemInfo> problems;
+
+  @Inject
+  ConsistencyChecker(
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      ChangeNotes.Factory notesFactory,
+      Accounts accounts,
+      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
+      GitRepositoryManager repoManager,
+      PatchSetInfoFactory patchSetInfoFactory,
+      PatchSetInserter.Factory patchSetInserterFactory,
+      PatchSetUtil psUtil,
+      Provider<CurrentUser> user,
+      Provider<ReviewDb> db,
+      RetryHelper retryHelper) {
+    this.accounts = accounts;
+    this.accountPatchReviewStore = accountPatchReviewStore;
+    this.db = db;
+    this.notesFactory = notesFactory;
+    this.patchSetInfoFactory = patchSetInfoFactory;
+    this.patchSetInserterFactory = patchSetInserterFactory;
+    this.psUtil = psUtil;
+    this.repoManager = repoManager;
+    this.retryHelper = retryHelper;
+    this.serverIdent = serverIdent;
+    this.user = user;
+    reset();
+  }
+
+  private void reset() {
+    updateFactory = null;
+    notes = null;
+    repo = null;
+    rw = null;
+    problems = new ArrayList<>();
+  }
+
+  private Change change() {
+    return notes.getChange();
+  }
+
+  public Result check(ChangeNotes notes, @Nullable FixInput f) {
+    checkNotNull(notes);
+    try {
+      return retryHelper.execute(
+          buf -> {
+            try {
+              reset();
+              this.updateFactory = buf;
+              this.notes = notes;
+              fix = f;
+              checkImpl();
+              return result();
+            } finally {
+              if (rw != null) {
+                rw.getObjectReader().close();
+                rw.close();
+                oi.close();
+              }
+              if (repo != null) {
+                repo.close();
+              }
+            }
+          });
+    } catch (RestApiException e) {
+      return logAndReturnOneProblem(e, notes, "Error checking change: " + e.getMessage());
+    } catch (UpdateException e) {
+      return logAndReturnOneProblem(e, notes, "Error checking change");
+    }
+  }
+
+  private Result logAndReturnOneProblem(Exception e, ChangeNotes notes, String problem) {
+    log.warn("Error checking change " + notes.getChangeId(), e);
+    return Result.create(notes, ImmutableList.of(problem(problem)));
+  }
+
+  private void checkImpl() {
+    checkOwner();
+    checkCurrentPatchSetEntity();
+
+    // All checks that require the repo.
+    if (!openRepo()) {
+      return;
+    }
+    if (!checkPatchSets()) {
+      return;
+    }
+    checkMerged();
+  }
+
+  private void checkOwner() {
+    try {
+      if (accounts.get(change().getOwner()) == null) {
+        problem("Missing change owner: " + change().getOwner());
+      }
+    } catch (IOException | ConfigInvalidException e) {
+      error("Failed to look up owner", e);
+    }
+  }
+
+  private void checkCurrentPatchSetEntity() {
+    try {
+      currPs = psUtil.current(db.get(), notes);
+      if (currPs == null) {
+        problem(
+            String.format("Current patch set %d not found", change().currentPatchSetId().get()));
+      }
+    } catch (OrmException e) {
+      error("Failed to look up current patch set", e);
+    }
+  }
+
+  private boolean openRepo() {
+    Project.NameKey project = change().getDest().getParentKey();
+    try {
+      repo = repoManager.openRepository(project);
+      oi = repo.newObjectInserter();
+      rw = new RevWalk(oi.newReader());
+      return true;
+    } catch (RepositoryNotFoundException e) {
+      return error("Destination repository not found: " + project, e);
+    } catch (IOException e) {
+      return error("Failed to open repository: " + project, e);
+    }
+  }
+
+  private boolean checkPatchSets() {
+    List<PatchSet> all;
+    try {
+      // Iterate in descending order.
+      all = PS_ID_ORDER.sortedCopy(psUtil.byChange(db.get(), notes));
+    } catch (OrmException e) {
+      return error("Failed to look up patch sets", e);
+    }
+    patchSetsBySha = MultimapBuilder.hashKeys(all.size()).treeSetValues(PS_ID_ORDER).build();
+
+    Map<String, Ref> refs;
+    try {
+      refs =
+          repo.getRefDatabase()
+              .exactRef(all.stream().map(ps -> ps.getId().toRefName()).toArray(String[]::new));
+    } catch (IOException e) {
+      error("error reading refs", e);
+      refs = Collections.emptyMap();
+    }
+
+    List<DeletePatchSetFromDbOp> deletePatchSetOps = new ArrayList<>();
+    for (PatchSet ps : all) {
+      // Check revision format.
+      int psNum = ps.getId().get();
+      String refName = ps.getId().toRefName();
+      ObjectId objId = parseObjectId(ps.getRevision().get(), "patch set " + psNum);
+      if (objId == null) {
+        continue;
+      }
+      patchSetsBySha.put(objId, ps);
+
+      // Check ref existence.
+      ProblemInfo refProblem = null;
+      Ref ref = refs.get(refName);
+      if (ref == null) {
+        refProblem = problem("Ref missing: " + refName);
+      } else if (!objId.equals(ref.getObjectId())) {
+        String actual = ref.getObjectId() != null ? ref.getObjectId().name() : "null";
+        refProblem =
+            problem(
+                String.format(
+                    "Expected %s to point to %s, found %s", ref.getName(), objId.name(), actual));
+      }
+
+      // Check object existence.
+      RevCommit psCommit = parseCommit(objId, String.format("patch set %d", psNum));
+      if (psCommit == null) {
+        if (fix != null && fix.deletePatchSetIfCommitMissing) {
+          deletePatchSetOps.add(new DeletePatchSetFromDbOp(lastProblem(), ps.getId()));
+        }
+        continue;
+      } else if (refProblem != null && fix != null) {
+        fixPatchSetRef(refProblem, ps);
+      }
+      if (ps.getId().equals(change().currentPatchSetId())) {
+        currPsCommit = psCommit;
+      }
+    }
+
+    // Delete any bad patch sets found above, in a single update.
+    deletePatchSets(deletePatchSetOps);
+
+    // Check for duplicates.
+    for (Map.Entry<ObjectId, Collection<PatchSet>> e : patchSetsBySha.asMap().entrySet()) {
+      if (e.getValue().size() > 1) {
+        problem(
+            String.format(
+                "Multiple patch sets pointing to %s: %s",
+                e.getKey().name(), Collections2.transform(e.getValue(), PatchSet::getPatchSetId)));
+      }
+    }
+
+    return currPs != null && currPsCommit != null;
+  }
+
+  private void checkMerged() {
+    String refName = change().getDest().get();
+    Ref dest;
+    try {
+      dest = repo.getRefDatabase().exactRef(refName);
+    } catch (IOException e) {
+      problem("Failed to look up destination ref: " + refName);
+      return;
+    }
+    if (dest == null) {
+      problem("Destination ref not found (may be new branch): " + refName);
+      return;
+    }
+    tip = parseCommit(dest.getObjectId(), "destination ref " + refName);
+    if (tip == null) {
+      return;
+    }
+
+    if (fix != null && fix.expectMergedAs != null) {
+      checkExpectMergedAs();
+    } else {
+      boolean merged;
+      try {
+        merged = rw.isMergedInto(currPsCommit, tip);
+      } catch (IOException e) {
+        problem("Error checking whether patch set " + currPs.getId().get() + " is merged");
+        return;
+      }
+      checkMergedBitMatchesStatus(currPs.getId(), currPsCommit, merged);
+    }
+  }
+
+  private ProblemInfo wrongChangeStatus(PatchSet.Id psId, RevCommit commit) {
+    String refName = change().getDest().get();
+    return problem(
+        String.format(
+            "Patch set %d (%s) is merged into destination ref %s (%s), but change"
+                + " status is %s",
+            psId.get(), commit.name(), refName, tip.name(), change().getStatus()));
+  }
+
+  private void checkMergedBitMatchesStatus(PatchSet.Id psId, RevCommit commit, boolean merged) {
+    String refName = change().getDest().get();
+    if (merged && change().getStatus() != Change.Status.MERGED) {
+      ProblemInfo p = wrongChangeStatus(psId, commit);
+      if (fix != null) {
+        fixMerged(p);
+      }
+    } else if (!merged && change().getStatus() == Change.Status.MERGED) {
+      problem(
+          String.format(
+              "Patch set %d (%s) is not merged into"
+                  + " destination ref %s (%s), but change status is %s",
+              currPs.getId().get(), commit.name(), refName, tip.name(), change().getStatus()));
+    }
+  }
+
+  private void checkExpectMergedAs() {
+    ObjectId objId = parseObjectId(fix.expectMergedAs, "expected merged commit");
+    RevCommit commit = parseCommit(objId, "expected merged commit");
+    if (commit == null) {
+      return;
+    }
+
+    try {
+      if (!rw.isMergedInto(commit, tip)) {
+        problem(
+            String.format(
+                "Expected merged commit %s is not merged into destination ref %s (%s)",
+                commit.name(), change().getDest().get(), tip.name()));
+        return;
+      }
+
+      List<PatchSet.Id> thisCommitPsIds = new ArrayList<>();
+      for (Ref ref : repo.getRefDatabase().getRefs(REFS_CHANGES).values()) {
+        if (!ref.getObjectId().equals(commit)) {
+          continue;
+        }
+        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+        if (psId == null) {
+          continue;
+        }
+        try {
+          Change c =
+              notesFactory
+                  .createChecked(db.get(), change().getProject(), psId.getParentKey())
+                  .getChange();
+          if (!c.getDest().equals(change().getDest())) {
+            continue;
+          }
+        } catch (OrmException e) {
+          warn(e);
+          // Include this patch set; should cause an error below, which is good.
+        }
+        thisCommitPsIds.add(psId);
+      }
+      switch (thisCommitPsIds.size()) {
+        case 0:
+          // No patch set for this commit; insert one.
+          rw.parseBody(commit);
+          String changeId =
+              Iterables.getFirst(commit.getFooterLines(FooterConstants.CHANGE_ID), null);
+          // Missing Change-Id footer is ok, but mismatched is not.
+          if (changeId != null && !changeId.equals(change().getKey().get())) {
+            problem(
+                String.format(
+                    "Expected merged commit %s has Change-Id: %s, but expected %s",
+                    commit.name(), changeId, change().getKey().get()));
+            return;
+          }
+          insertMergedPatchSet(commit, null, false);
+          break;
+
+        case 1:
+          // Existing patch set ref pointing to this commit.
+          PatchSet.Id id = thisCommitPsIds.get(0);
+          if (id.equals(change().currentPatchSetId())) {
+            // If it's the current patch set, we can just fix the status.
+            fixMerged(wrongChangeStatus(id, commit));
+          } else if (id.get() > change().currentPatchSetId().get()) {
+            // If it's newer than the current patch set, reuse this patch set
+            // ID when inserting a new merged patch set.
+            insertMergedPatchSet(commit, id, true);
+          } else {
+            // If it's older than the current patch set, just delete the old
+            // ref, and use a new ID when inserting a new merged patch set.
+            insertMergedPatchSet(commit, id, false);
+          }
+          break;
+
+        default:
+          problem(
+              String.format(
+                  "Multiple patch sets for expected merged commit %s: %s",
+                  commit.name(), intKeyOrdering().sortedCopy(thisCommitPsIds)));
+          break;
+      }
+    } catch (IOException e) {
+      error("Error looking up expected merged commit " + fix.expectMergedAs, e);
+    }
+  }
+
+  private void insertMergedPatchSet(
+      final RevCommit commit, @Nullable PatchSet.Id psIdToDelete, boolean reuseOldPsId) {
+    ProblemInfo notFound = problem("No patch set found for merged commit " + commit.name());
+    if (!user.get().isIdentifiedUser()) {
+      notFound.status = Status.FIX_FAILED;
+      notFound.outcome = "Must be called by an identified user to insert new patch set";
+      return;
+    }
+    ProblemInfo insertPatchSetProblem;
+    ProblemInfo deleteOldPatchSetProblem;
+
+    if (psIdToDelete == null) {
+      insertPatchSetProblem =
+          problem(
+              String.format(
+                  "Expected merged commit %s has no associated patch set", commit.name()));
+      deleteOldPatchSetProblem = null;
+    } else {
+      String msg =
+          String.format(
+              "Expected merge commit %s corresponds to patch set %s,"
+                  + " not the current patch set %s",
+              commit.name(), psIdToDelete.get(), change().currentPatchSetId().get());
+      // Maybe an identical problem, but different fix.
+      deleteOldPatchSetProblem = reuseOldPsId ? null : problem(msg);
+      insertPatchSetProblem = problem(msg);
+    }
+
+    List<ProblemInfo> currProblems = new ArrayList<>(3);
+    currProblems.add(notFound);
+    if (deleteOldPatchSetProblem != null) {
+      currProblems.add(insertPatchSetProblem);
+    }
+    currProblems.add(insertPatchSetProblem);
+
+    try {
+      PatchSet.Id psId =
+          (psIdToDelete != null && reuseOldPsId)
+              ? psIdToDelete
+              : ChangeUtil.nextPatchSetId(repo, change().currentPatchSetId());
+      PatchSetInserter inserter = patchSetInserterFactory.create(notes, psId, commit);
+      try (BatchUpdate bu = newBatchUpdate()) {
+        bu.setRepository(repo, rw, oi);
+
+        if (psIdToDelete != null) {
+          // Delete the given patch set ref. If reuseOldPsId is true,
+          // PatchSetInserter will reinsert the same ref, making it a no-op.
+          bu.addOp(
+              notes.getChangeId(),
+              new BatchUpdateOp() {
+                @Override
+                public void updateRepo(RepoContext ctx) throws IOException {
+                  ctx.addRefUpdate(commit, ObjectId.zeroId(), psIdToDelete.toRefName());
+                }
+              });
+          if (!reuseOldPsId) {
+            bu.addOp(
+                notes.getChangeId(),
+                new DeletePatchSetFromDbOp(checkNotNull(deleteOldPatchSetProblem), psIdToDelete));
+          }
+        }
+
+        bu.addOp(
+            notes.getChangeId(),
+            inserter
+                .setValidate(false)
+                .setFireRevisionCreated(false)
+                .setNotify(NotifyHandling.NONE)
+                .setAllowClosed(true)
+                .setMessage("Patch set for merged commit inserted by consistency checker"));
+        bu.addOp(notes.getChangeId(), new FixMergedOp(notFound));
+        bu.execute();
+      }
+      notes = notesFactory.createChecked(db.get(), inserter.getChange());
+      insertPatchSetProblem.status = Status.FIXED;
+      insertPatchSetProblem.outcome = "Inserted as patch set " + psId.get();
+    } catch (OrmException | IOException | UpdateException | RestApiException e) {
+      warn(e);
+      for (ProblemInfo pi : currProblems) {
+        pi.status = Status.FIX_FAILED;
+        pi.outcome = "Error inserting merged patch set";
+      }
+      return;
+    }
+  }
+
+  private static class FixMergedOp implements BatchUpdateOp {
+    private final ProblemInfo p;
+
+    private FixMergedOp(ProblemInfo p) {
+      this.p = p;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws OrmException {
+      ctx.getChange().setStatus(Change.Status.MERGED);
+      ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
+      p.status = Status.FIXED;
+      p.outcome = "Marked change as merged";
+      return true;
+    }
+  }
+
+  private void fixMerged(ProblemInfo p) {
+    try (BatchUpdate bu = newBatchUpdate()) {
+      bu.setRepository(repo, rw, oi);
+      bu.addOp(notes.getChangeId(), new FixMergedOp(p));
+      bu.execute();
+    } catch (UpdateException | RestApiException e) {
+      log.warn("Error marking " + notes.getChangeId() + "as merged", e);
+      p.status = Status.FIX_FAILED;
+      p.outcome = "Error updating status to merged";
+    }
+  }
+
+  private BatchUpdate newBatchUpdate() {
+    return updateFactory.create(db.get(), change().getProject(), user.get(), TimeUtil.nowTs());
+  }
+
+  private void fixPatchSetRef(ProblemInfo p, PatchSet ps) {
+    try {
+      RefUpdate ru = repo.updateRef(ps.getId().toRefName());
+      ru.setForceUpdate(true);
+      ru.setNewObjectId(ObjectId.fromString(ps.getRevision().get()));
+      ru.setRefLogIdent(newRefLogIdent());
+      ru.setRefLogMessage("Repair patch set ref", true);
+      RefUpdate.Result result = ru.update();
+      switch (result) {
+        case NEW:
+        case FORCED:
+        case FAST_FORWARD:
+        case NO_CHANGE:
+          p.status = Status.FIXED;
+          p.outcome = "Repaired patch set ref";
+          return;
+        case IO_FAILURE:
+        case LOCK_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case RENAMED:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
+          p.status = Status.FIX_FAILED;
+          p.outcome = "Failed to update patch set ref: " + result;
+          return;
+      }
+    } catch (IOException e) {
+      String msg = "Error fixing patch set ref";
+      log.warn(msg + ' ' + ps.getId().toRefName(), e);
+      p.status = Status.FIX_FAILED;
+      p.outcome = msg;
+    }
+  }
+
+  private void deletePatchSets(List<DeletePatchSetFromDbOp> ops) {
+    try (BatchUpdate bu = newBatchUpdate()) {
+      bu.setRepository(repo, rw, oi);
+      for (DeletePatchSetFromDbOp op : ops) {
+        checkArgument(op.psId.getParentKey().equals(notes.getChangeId()));
+        bu.addOp(notes.getChangeId(), op);
+      }
+      bu.addOp(notes.getChangeId(), new UpdateCurrentPatchSetOp(ops));
+      bu.execute();
+    } catch (NoPatchSetsWouldRemainException e) {
+      for (DeletePatchSetFromDbOp op : ops) {
+        op.p.status = Status.FIX_FAILED;
+        op.p.outcome = e.getMessage();
+      }
+    } catch (UpdateException | RestApiException e) {
+      String msg = "Error deleting patch set";
+      log.warn(msg + " of change " + ops.get(0).psId.getParentKey(), e);
+      for (DeletePatchSetFromDbOp op : ops) {
+        // Overwrite existing statuses that were set before the transaction was
+        // rolled back.
+        op.p.status = Status.FIX_FAILED;
+        op.p.outcome = msg;
+      }
+    }
+  }
+
+  private class DeletePatchSetFromDbOp implements BatchUpdateOp {
+    private final ProblemInfo p;
+    private final PatchSet.Id psId;
+
+    private DeletePatchSetFromDbOp(ProblemInfo p, PatchSet.Id psId) {
+      this.p = p;
+      this.psId = psId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws OrmException, PatchSetInfoNotAvailableException {
+      // Delete dangling key references.
+      ReviewDb db = BatchUpdateReviewDb.unwrap(ctx.getDb());
+      accountPatchReviewStore.get().clearReviewed(psId);
+      db.changeMessages().delete(db.changeMessages().byChange(psId.getParentKey()));
+      db.patchSetApprovals().delete(db.patchSetApprovals().byPatchSet(psId));
+      db.patchComments().delete(db.patchComments().byPatchSet(psId));
+      db.patchSets().deleteKeys(Collections.singleton(psId));
+
+      // NoteDb requires no additional fiddling; setting the state to deleted is
+      // sufficient to filter everything else out.
+      ctx.getUpdate(psId).setPatchSetState(PatchSetState.DELETED);
+
+      p.status = Status.FIXED;
+      p.outcome = "Deleted patch set";
+      return true;
+    }
+  }
+
+  private static class NoPatchSetsWouldRemainException extends RestApiException {
+    private static final long serialVersionUID = 1L;
+
+    private NoPatchSetsWouldRemainException() {
+      super("Cannot delete patch set; no patch sets would remain");
+    }
+  }
+
+  private class UpdateCurrentPatchSetOp implements BatchUpdateOp {
+    private final Set<PatchSet.Id> toDelete;
+
+    private UpdateCurrentPatchSetOp(List<DeletePatchSetFromDbOp> deleteOps) {
+      toDelete = new HashSet<>();
+      for (DeletePatchSetFromDbOp op : deleteOps) {
+        toDelete.add(op.psId);
+      }
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws OrmException, PatchSetInfoNotAvailableException, NoPatchSetsWouldRemainException {
+      if (!toDelete.contains(ctx.getChange().currentPatchSetId())) {
+        return false;
+      }
+      Set<PatchSet.Id> all = new HashSet<>();
+      // Doesn't make any assumptions about the order in which deletes happen
+      // and whether they are seen by this op; we are already given the full set
+      // of patch sets that will eventually be deleted in this update.
+      for (PatchSet ps : psUtil.byChange(ctx.getDb(), ctx.getNotes())) {
+        if (!toDelete.contains(ps.getId())) {
+          all.add(ps.getId());
+        }
+      }
+      if (all.isEmpty()) {
+        throw new NoPatchSetsWouldRemainException();
+      }
+      PatchSet.Id latest = ReviewDbUtil.intKeyOrdering().max(all);
+      ctx.getChange()
+          .setCurrentPatchSet(patchSetInfoFactory.get(ctx.getDb(), ctx.getNotes(), latest));
+      return true;
+    }
+  }
+
+  private PersonIdent newRefLogIdent() {
+    CurrentUser u = user.get();
+    if (u.isIdentifiedUser()) {
+      return u.asIdentifiedUser().newRefLogIdent();
+    }
+    return serverIdent.get();
+  }
+
+  private ObjectId parseObjectId(String objIdStr, String desc) {
+    try {
+      return ObjectId.fromString(objIdStr);
+    } catch (IllegalArgumentException e) {
+      problem(String.format("Invalid revision on %s: %s", desc, objIdStr));
+      return null;
+    }
+  }
+
+  private RevCommit parseCommit(ObjectId objId, String desc) {
+    try {
+      return rw.parseCommit(objId);
+    } catch (MissingObjectException e) {
+      problem(String.format("Object missing: %s: %s", desc, objId.name()));
+    } catch (IncorrectObjectTypeException e) {
+      problem(String.format("Not a commit: %s: %s", desc, objId.name()));
+    } catch (IOException e) {
+      problem(String.format("Failed to look up: %s: %s", desc, objId.name()));
+    }
+    return null;
+  }
+
+  private ProblemInfo problem(String msg) {
+    ProblemInfo p = new ProblemInfo();
+    p.message = checkNotNull(msg);
+    problems.add(p);
+    return p;
+  }
+
+  private ProblemInfo lastProblem() {
+    return problems.get(problems.size() - 1);
+  }
+
+  private boolean error(String msg, Throwable t) {
+    problem(msg);
+    // TODO(dborowitz): Expose stack trace to administrators.
+    warn(t);
+    return false;
+  }
+
+  private void warn(Throwable t) {
+    log.warn("Error in consistency check of change " + notes.getChangeId(), t);
+  }
+
+  private Result result() {
+    return Result.create(notes, problems);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/DraftCommentResource.java b/java/com/google/gerrit/server/change/DraftCommentResource.java
new file mode 100644
index 0000000..ef31725
--- /dev/null
+++ b/java/com/google/gerrit/server/change/DraftCommentResource.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.CurrentUser;
+import com.google.inject.TypeLiteral;
+
+public class DraftCommentResource implements RestResource {
+  public static final TypeLiteral<RestView<DraftCommentResource>> DRAFT_COMMENT_KIND =
+      new TypeLiteral<RestView<DraftCommentResource>>() {};
+
+  private final RevisionResource rev;
+  private final Comment comment;
+
+  public DraftCommentResource(RevisionResource rev, Comment c) {
+    this.rev = rev;
+    this.comment = c;
+  }
+
+  public CurrentUser getUser() {
+    return rev.getUser();
+  }
+
+  public Change getChange() {
+    return rev.getChange();
+  }
+
+  public PatchSet getPatchSet() {
+    return rev.getPatchSet();
+  }
+
+  public Comment getComment() {
+    return comment;
+  }
+
+  public String getId() {
+    return comment.key.uuid;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
rename to java/com/google/gerrit/server/change/EmailReviewComments.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java b/java/com/google/gerrit/server/change/FileContentUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
rename to java/com/google/gerrit/server/change/FileContentUtil.java
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
new file mode 100644
index 0000000..f4b7457
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Map;
+import java.util.TreeMap;
+import org.eclipse.jgit.lib.ObjectId;
+
+@Singleton
+public class FileInfoJson {
+  private final PatchListCache patchListCache;
+
+  @Inject
+  FileInfoJson(PatchListCache patchListCache) {
+    this.patchListCache = patchListCache;
+  }
+
+  public Map<String, FileInfo> toFileInfoMap(Change change, PatchSet patchSet)
+      throws PatchListNotAvailableException {
+    return toFileInfoMap(change, patchSet.getRevision(), null);
+  }
+
+  public Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, @Nullable PatchSet base)
+      throws PatchListNotAvailableException {
+    ObjectId objectId = ObjectId.fromString(revision.get());
+    return toFileInfoMap(change, objectId, base);
+  }
+
+  public Map<String, FileInfo> toFileInfoMap(
+      Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws PatchListNotAvailableException {
+    ObjectId a = (base == null) ? null : ObjectId.fromString(base.getRevision().get());
+    return toFileInfoMap(change, PatchListKey.againstCommit(a, objectId, Whitespace.IGNORE_NONE));
+  }
+
+  public Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, int parent)
+      throws PatchListNotAvailableException {
+    ObjectId b = ObjectId.fromString(revision.get());
+    return toFileInfoMap(
+        change, PatchListKey.againstParentNum(parent + 1, b, Whitespace.IGNORE_NONE));
+  }
+
+  private Map<String, FileInfo> toFileInfoMap(Change change, PatchListKey key)
+      throws PatchListNotAvailableException {
+    PatchList list = patchListCache.get(key, change.getProject());
+
+    Map<String, FileInfo> files = new TreeMap<>();
+    for (PatchListEntry e : list.getPatches()) {
+      FileInfo d = new FileInfo();
+      d.status =
+          e.getChangeType() != Patch.ChangeType.MODIFIED ? e.getChangeType().getCode() : null;
+      d.oldPath = e.getOldName();
+      d.sizeDelta = e.getSizeDelta();
+      d.size = e.getSize();
+      if (e.getPatchType() == Patch.PatchType.BINARY) {
+        d.binary = true;
+      } else {
+        d.linesInserted = e.getInsertions() > 0 ? e.getInsertions() : null;
+        d.linesDeleted = e.getDeletions() > 0 ? e.getDeletions() : null;
+      }
+
+      FileInfo o = files.put(e.getNewName(), d);
+      if (o != null) {
+        // This should only happen on a delete-add break created by JGit
+        // when the file was rewritten and too little content survived. Write
+        // a single record with data from both sides.
+        d.status = Patch.ChangeType.REWRITE.getCode();
+        d.sizeDelta = o.sizeDelta;
+        d.size = o.size;
+        if (o.binary != null && o.binary) {
+          d.binary = true;
+        }
+        if (o.linesInserted != null) {
+          d.linesInserted = o.linesInserted;
+        }
+        if (o.linesDeleted != null) {
+          d.linesDeleted = o.linesDeleted;
+        }
+      }
+    }
+    return files;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/FileResource.java b/java/com/google/gerrit/server/change/FileResource.java
new file mode 100644
index 0000000..bd7557f
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FileResource.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.inject.TypeLiteral;
+
+public class FileResource implements RestResource {
+  public static final TypeLiteral<RestView<FileResource>> FILE_KIND =
+      new TypeLiteral<RestView<FileResource>>() {};
+
+  private final RevisionResource rev;
+  private final Patch.Key key;
+
+  public FileResource(RevisionResource rev, String name) {
+    this.rev = rev;
+    this.key = new Patch.Key(rev.getPatchSet().getId(), name);
+  }
+
+  public Patch.Key getPatchKey() {
+    return key;
+  }
+
+  public boolean isCacheable() {
+    return rev.isCacheable();
+  }
+
+  public Account.Id getAccountId() {
+    return rev.getAccountId();
+  }
+
+  public RevisionResource getRevision() {
+    return rev;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FixResource.java b/java/com/google/gerrit/server/change/FixResource.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/FixResource.java
rename to java/com/google/gerrit/server/change/FixResource.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/HashtagsUtil.java b/java/com/google/gerrit/server/change/HashtagsUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/HashtagsUtil.java
rename to java/com/google/gerrit/server/change/HashtagsUtil.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedIn.java b/java/com/google/gerrit/server/change/IncludedIn.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedIn.java
rename to java/com/google/gerrit/server/change/IncludedIn.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedInResolver.java b/java/com/google/gerrit/server/change/IncludedInResolver.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedInResolver.java
rename to java/com/google/gerrit/server/change/IncludedInResolver.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCache.java b/java/com/google/gerrit/server/change/MergeabilityCache.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCache.java
rename to java/com/google/gerrit/server/change/MergeabilityCache.java
diff --git a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
new file mode 100644
index 0000000..7ba18e8
--- /dev/null
+++ b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -0,0 +1,240 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.ioutil.BasicSerialization.readString;
+import static com.google.gerrit.server.ioutil.BasicSerialization.writeString;
+import static org.eclipse.jgit.lib.ObjectIdSerialization.readNotNull;
+import static org.eclipse.jgit.lib.ObjectIdSerialization.writeNotNull;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.cache.Cache;
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableBiMap;
+import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.strategy.SubmitDryRun;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class MergeabilityCacheImpl implements MergeabilityCache {
+  private static final Logger log = LoggerFactory.getLogger(MergeabilityCacheImpl.class);
+
+  private static final String CACHE_NAME = "mergeability";
+
+  public static final ImmutableBiMap<SubmitType, Character> SUBMIT_TYPES =
+      new ImmutableBiMap.Builder<SubmitType, Character>()
+          .put(SubmitType.INHERIT, 'I')
+          .put(SubmitType.FAST_FORWARD_ONLY, 'F')
+          .put(SubmitType.MERGE_IF_NECESSARY, 'M')
+          .put(SubmitType.REBASE_ALWAYS, 'P')
+          .put(SubmitType.REBASE_IF_NECESSARY, 'R')
+          .put(SubmitType.MERGE_ALWAYS, 'A')
+          .put(SubmitType.CHERRY_PICK, 'C')
+          .build();
+
+  static {
+    checkState(
+        SUBMIT_TYPES.size() == SubmitType.values().length,
+        "SubmitType <-> char BiMap needs updating");
+  }
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        persist(CACHE_NAME, EntryKey.class, Boolean.class)
+            .maximumWeight(1 << 20)
+            .weigher(MergeabilityWeigher.class);
+        bind(MergeabilityCache.class).to(MergeabilityCacheImpl.class);
+      }
+    };
+  }
+
+  public static ObjectId toId(Ref ref) {
+    return ref != null && ref.getObjectId() != null ? ref.getObjectId() : ObjectId.zeroId();
+  }
+
+  public static class EntryKey implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private ObjectId commit;
+    private ObjectId into;
+    private SubmitType submitType;
+    private String mergeStrategy;
+
+    public EntryKey(ObjectId commit, ObjectId into, SubmitType submitType, String mergeStrategy) {
+      checkArgument(
+          submitType != SubmitType.INHERIT,
+          "Cannot cache %s.%s",
+          SubmitType.class.getSimpleName(),
+          submitType);
+      this.commit = checkNotNull(commit, "commit");
+      this.into = checkNotNull(into, "into");
+      this.submitType = checkNotNull(submitType, "submitType");
+      this.mergeStrategy = checkNotNull(mergeStrategy, "mergeStrategy");
+    }
+
+    public ObjectId getCommit() {
+      return commit;
+    }
+
+    public ObjectId getInto() {
+      return into;
+    }
+
+    public SubmitType getSubmitType() {
+      return submitType;
+    }
+
+    public String getMergeStrategy() {
+      return mergeStrategy;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof EntryKey) {
+        EntryKey k = (EntryKey) o;
+        return commit.equals(k.commit)
+            && into.equals(k.into)
+            && submitType == k.submitType
+            && mergeStrategy.equals(k.mergeStrategy);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(commit, into, submitType, mergeStrategy);
+    }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this)
+          .add("commit", commit.name())
+          .add("into", into.name())
+          .addValue(submitType)
+          .addValue(mergeStrategy)
+          .toString();
+    }
+
+    private void writeObject(ObjectOutputStream out) throws IOException {
+      writeNotNull(out, commit);
+      writeNotNull(out, into);
+      Character c = SUBMIT_TYPES.get(submitType);
+      if (c == null) {
+        throw new IOException("Invalid submit type: " + submitType);
+      }
+      out.writeChar(c);
+      writeString(out, mergeStrategy);
+    }
+
+    private void readObject(ObjectInputStream in) throws IOException {
+      commit = readNotNull(in);
+      into = readNotNull(in);
+      char t = in.readChar();
+      submitType = SUBMIT_TYPES.inverse().get(t);
+      if (submitType == null) {
+        throw new IOException("Invalid submit type code: " + t);
+      }
+      mergeStrategy = readString(in);
+    }
+  }
+
+  public static class MergeabilityWeigher implements Weigher<EntryKey, Boolean> {
+    @Override
+    public int weigh(EntryKey k, Boolean v) {
+      return 16
+          + 2 * (16 + 20)
+          + 3 * 8 // Size of EntryKey, 64-bit JVM.
+          + 8; // Size of Boolean.
+    }
+  }
+
+  private final SubmitDryRun submitDryRun;
+  private final Cache<EntryKey, Boolean> cache;
+
+  @Inject
+  MergeabilityCacheImpl(
+      SubmitDryRun submitDryRun, @Named(CACHE_NAME) Cache<EntryKey, Boolean> cache) {
+    this.submitDryRun = submitDryRun;
+    this.cache = cache;
+  }
+
+  @Override
+  public boolean get(
+      ObjectId commit,
+      Ref intoRef,
+      SubmitType submitType,
+      String mergeStrategy,
+      Branch.NameKey dest,
+      Repository repo) {
+    ObjectId into = intoRef != null ? intoRef.getObjectId() : ObjectId.zeroId();
+    EntryKey key = new EntryKey(commit, into, submitType, mergeStrategy);
+    try {
+      return cache.get(
+          key,
+          () -> {
+            if (key.into.equals(ObjectId.zeroId())) {
+              return true; // Assume yes on new branch.
+            }
+            try (CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
+              Set<RevCommit> accepted = SubmitDryRun.getAlreadyAccepted(repo, rw);
+              accepted.add(rw.parseCommit(key.into));
+              accepted.addAll(Arrays.asList(rw.parseCommit(key.commit).getParents()));
+              return submitDryRun.run(
+                  key.submitType, repo, rw, dest, key.into, key.commit, accepted);
+            }
+          });
+    } catch (ExecutionException | UncheckedExecutionException e) {
+      log.error(
+          String.format(
+              "Error checking mergeability of %s into %s (%s)",
+              key.commit.name(), key.into.name(), key.submitType.name()),
+          e.getCause());
+      return false;
+    }
+  }
+
+  @Override
+  public Boolean getIfPresent(
+      ObjectId commit, Ref intoRef, SubmitType submitType, String mergeStrategy) {
+    return cache.getIfPresent(new EntryKey(commit, toId(intoRef), submitType, mergeStrategy));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/NotifyUtil.java b/java/com/google/gerrit/server/change/NotifyUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/NotifyUtil.java
rename to java/com/google/gerrit/server/change/NotifyUtil.java
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
new file mode 100644
index 0000000..3cf0dc5
--- /dev/null
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -0,0 +1,354 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalCopier;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.extensions.events.RevisionCreated;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.ssh.NoSshInfo;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PatchSetInserter implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(PatchSetInserter.class);
+
+  public interface Factory {
+    PatchSetInserter create(ChangeNotes notes, PatchSet.Id psId, ObjectId commitId);
+  }
+
+  // Injected fields.
+  private final PermissionBackend permissionBackend;
+  private final PatchSetInfoFactory patchSetInfoFactory;
+  private final CommitValidators.Factory commitValidatorsFactory;
+  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+  private final ProjectCache projectCache;
+  private final RevisionCreated revisionCreated;
+  private final ApprovalsUtil approvalsUtil;
+  private final ApprovalCopier approvalCopier;
+  private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
+
+  // Assisted-injected fields.
+  private final PatchSet.Id psId;
+  private final ObjectId commitId;
+  // Read prior to running the batch update, so must only be used during
+  // updateRepo; updateChange and later must use the notes from the
+  // ChangeContext.
+  private final ChangeNotes origNotes;
+
+  // Fields exposed as setters.
+  private String message;
+  private String description;
+  private boolean validate = true;
+  private boolean checkAddPatchSetPermission = true;
+  private List<String> groups = Collections.emptyList();
+  private boolean fireRevisionCreated = true;
+  private NotifyHandling notify = NotifyHandling.ALL;
+  private ListMultimap<RecipientType, Account.Id> accountsToNotify = ImmutableListMultimap.of();
+  private boolean allowClosed;
+  private boolean copyApprovals = true;
+
+  // Fields set during some phase of BatchUpdate.Op.
+  private Change change;
+  private PatchSet patchSet;
+  private PatchSetInfo patchSetInfo;
+  private ChangeMessage changeMessage;
+  private ReviewerSet oldReviewers;
+
+  @Inject
+  public PatchSetInserter(
+      PermissionBackend permissionBackend,
+      ApprovalsUtil approvalsUtil,
+      ApprovalCopier approvalCopier,
+      ChangeMessagesUtil cmUtil,
+      PatchSetInfoFactory patchSetInfoFactory,
+      CommitValidators.Factory commitValidatorsFactory,
+      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      PatchSetUtil psUtil,
+      RevisionCreated revisionCreated,
+      ProjectCache projectCache,
+      @Assisted ChangeNotes notes,
+      @Assisted PatchSet.Id psId,
+      @Assisted ObjectId commitId) {
+    this.permissionBackend = permissionBackend;
+    this.approvalsUtil = approvalsUtil;
+    this.approvalCopier = approvalCopier;
+    this.cmUtil = cmUtil;
+    this.patchSetInfoFactory = patchSetInfoFactory;
+    this.commitValidatorsFactory = commitValidatorsFactory;
+    this.replacePatchSetFactory = replacePatchSetFactory;
+    this.psUtil = psUtil;
+    this.revisionCreated = revisionCreated;
+    this.projectCache = projectCache;
+
+    this.origNotes = notes;
+    this.psId = psId;
+    this.commitId = commitId.copy();
+  }
+
+  public PatchSet.Id getPatchSetId() {
+    return psId;
+  }
+
+  public PatchSetInserter setMessage(String message) {
+    this.message = message;
+    return this;
+  }
+
+  public PatchSetInserter setDescription(String description) {
+    this.description = description;
+    return this;
+  }
+
+  public PatchSetInserter setValidate(boolean validate) {
+    this.validate = validate;
+    return this;
+  }
+
+  public PatchSetInserter setCheckAddPatchSetPermission(boolean checkAddPatchSetPermission) {
+    this.checkAddPatchSetPermission = checkAddPatchSetPermission;
+    return this;
+  }
+
+  public PatchSetInserter setGroups(List<String> groups) {
+    checkNotNull(groups, "groups may not be null");
+    this.groups = groups;
+    return this;
+  }
+
+  public PatchSetInserter setFireRevisionCreated(boolean fireRevisionCreated) {
+    this.fireRevisionCreated = fireRevisionCreated;
+    return this;
+  }
+
+  public PatchSetInserter setNotify(NotifyHandling notify) {
+    this.notify = Preconditions.checkNotNull(notify);
+    return this;
+  }
+
+  public PatchSetInserter setAccountsToNotify(
+      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    this.accountsToNotify = checkNotNull(accountsToNotify);
+    return this;
+  }
+
+  public PatchSetInserter setAllowClosed(boolean allowClosed) {
+    this.allowClosed = allowClosed;
+    return this;
+  }
+
+  public PatchSetInserter setCopyApprovals(boolean copyApprovals) {
+    this.copyApprovals = copyApprovals;
+    return this;
+  }
+
+  public Change getChange() {
+    checkState(change != null, "getChange() only valid after executing update");
+    return change;
+  }
+
+  public PatchSet getPatchSet() {
+    checkState(patchSet != null, "getPatchSet() only valid after executing update");
+    return patchSet;
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx)
+      throws AuthException, ResourceConflictException, IOException, OrmException,
+          PermissionBackendException {
+    validate(ctx);
+    ctx.addRefUpdate(ObjectId.zeroId(), commitId, getPatchSetId().toRefName());
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws ResourceConflictException, OrmException, IOException {
+    ReviewDb db = ctx.getDb();
+
+    change = ctx.getChange();
+    ChangeUpdate update = ctx.getUpdate(psId);
+    update.setSubjectForCommit("Create patch set " + psId.get());
+
+    if (!change.getStatus().isOpen() && !allowClosed) {
+      throw new ResourceConflictException(
+          String.format(
+              "Cannot create new patch set of change %s because it is %s",
+              change.getId(), ChangeUtil.status(change)));
+    }
+
+    List<String> newGroups = groups;
+    if (newGroups.isEmpty()) {
+      PatchSet prevPs = psUtil.current(db, ctx.getNotes());
+      if (prevPs != null) {
+        newGroups = prevPs.getGroups();
+      }
+    }
+    patchSet =
+        psUtil.insert(
+            db,
+            ctx.getRevWalk(),
+            ctx.getUpdate(psId),
+            psId,
+            commitId,
+            newGroups,
+            null,
+            description);
+
+    if (notify != NotifyHandling.NONE) {
+      oldReviewers = approvalsUtil.getReviewers(db, ctx.getNotes());
+    }
+
+    if (message != null) {
+      changeMessage =
+          ChangeMessagesUtil.newMessage(
+              patchSet.getId(),
+              ctx.getUser(),
+              ctx.getWhen(),
+              message,
+              ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
+      changeMessage.setMessage(message);
+    }
+
+    patchSetInfo =
+        patchSetInfoFactory.get(ctx.getRevWalk(), ctx.getRevWalk().parseCommit(commitId), psId);
+    if (!allowClosed) {
+      change.setStatus(Change.Status.NEW);
+    }
+    change.setCurrentPatchSet(patchSetInfo);
+    if (copyApprovals) {
+      approvalCopier.copyInReviewDb(
+          db,
+          ctx.getNotes(),
+          ctx.getUser(),
+          patchSet,
+          ctx.getRevWalk(),
+          ctx.getRepoView().getConfig());
+    }
+    if (changeMessage != null) {
+      cmUtil.addChangeMessage(db, update, changeMessage);
+    }
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws OrmException {
+    if (notify != NotifyHandling.NONE || !accountsToNotify.isEmpty()) {
+      try {
+        ReplacePatchSetSender cm = replacePatchSetFactory.create(ctx.getProject(), change.getId());
+        cm.setFrom(ctx.getAccountId());
+        cm.setPatchSet(patchSet, patchSetInfo);
+        cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
+        cm.addReviewers(oldReviewers.byState(REVIEWER));
+        cm.addExtraCC(oldReviewers.byState(CC));
+        cm.setNotify(notify);
+        cm.setAccountsToNotify(accountsToNotify);
+        cm.send();
+      } catch (Exception err) {
+        log.error("Cannot send email for new patch set on change " + change.getId(), err);
+      }
+    }
+
+    if (fireRevisionCreated) {
+      revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
+    }
+  }
+
+  private void validate(RepoContext ctx)
+      throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
+    if (checkAddPatchSetPermission) {
+      permissionBackend
+          .user(ctx.getUser())
+          .database(ctx.getDb())
+          .change(origNotes)
+          .check(ChangePermission.ADD_PATCH_SET);
+    }
+    projectCache.checkedGet(ctx.getProject()).checkStatePermitsWrite();
+    if (!validate) {
+      return;
+    }
+
+    PermissionBackend.ForRef perm =
+        permissionBackend.user(ctx.getUser()).ref(origNotes.getChange().getDest());
+
+    String refName = getPatchSetId().toRefName();
+    try (CommitReceivedEvent event =
+        new CommitReceivedEvent(
+            new ReceiveCommand(
+                ObjectId.zeroId(),
+                commitId,
+                refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
+            projectCache.checkedGet(origNotes.getProjectName()).getProject(),
+            origNotes.getChange().getDest().get(),
+            ctx.getRevWalk().getObjectReader(),
+            commitId,
+            ctx.getIdentifiedUser())) {
+      commitValidatorsFactory
+          .forGerritCommits(
+              perm,
+              origNotes.getChange().getDest(),
+              ctx.getIdentifiedUser(),
+              new NoSshInfo(),
+              ctx.getRevWalk())
+          .validate(event);
+    } catch (CommitValidationException e) {
+      throw new ResourceConflictException(e.getFullMessage());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/PureRevert.java b/java/com/google/gerrit/server/change/PureRevert.java
new file mode 100644
index 0000000..850f33a
--- /dev/null
+++ b/java/com/google/gerrit/server/change/PureRevert.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.ThreeWayMerger;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class PureRevert {
+  private final MergeUtil.Factory mergeUtilFactory;
+  private final GitRepositoryManager repoManager;
+  private final ProjectCache projectCache;
+  private final ChangeNotes.Factory notesFactory;
+  private final Provider<ReviewDb> dbProvider;
+  private final PatchSetUtil psUtil;
+
+  @Inject
+  PureRevert(
+      MergeUtil.Factory mergeUtilFactory,
+      GitRepositoryManager repoManager,
+      ProjectCache projectCache,
+      ChangeNotes.Factory notesFactory,
+      Provider<ReviewDb> dbProvider,
+      PatchSetUtil psUtil) {
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.repoManager = repoManager;
+    this.projectCache = projectCache;
+    this.notesFactory = notesFactory;
+    this.dbProvider = dbProvider;
+    this.psUtil = psUtil;
+  }
+
+  public PureRevertInfo get(ChangeNotes notes, @Nullable String claimedOriginal)
+      throws OrmException, IOException, BadRequestException, ResourceConflictException {
+    PatchSet currentPatchSet = psUtil.current(dbProvider.get(), notes);
+    if (currentPatchSet == null) {
+      throw new ResourceConflictException("current revision is missing");
+    }
+
+    if (claimedOriginal == null) {
+      if (notes.getChange().getRevertOf() == null) {
+        throw new BadRequestException("no ID was provided and change isn't a revert");
+      }
+      PatchSet ps =
+          psUtil.current(
+              dbProvider.get(),
+              notesFactory.createChecked(
+                  dbProvider.get(), notes.getProjectName(), notes.getChange().getRevertOf()));
+      claimedOriginal = ps.getRevision().get();
+    }
+
+    try (Repository repo = repoManager.openRepository(notes.getProjectName());
+        ObjectInserter oi = repo.newObjectInserter();
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit claimedOriginalCommit;
+      try {
+        claimedOriginalCommit = rw.parseCommit(ObjectId.fromString(claimedOriginal));
+      } catch (InvalidObjectIdException | MissingObjectException e) {
+        throw new BadRequestException("invalid object ID");
+      }
+      if (claimedOriginalCommit.getParentCount() == 0) {
+        throw new BadRequestException("can't check against initial commit");
+      }
+      RevCommit claimedRevertCommit =
+          rw.parseCommit(ObjectId.fromString(currentPatchSet.getRevision().get()));
+      if (claimedRevertCommit.getParentCount() == 0) {
+        throw new BadRequestException("claimed revert has no parents");
+      }
+      // Rebase claimed revert onto claimed original
+      ThreeWayMerger merger =
+          mergeUtilFactory
+              .create(projectCache.checkedGet(notes.getProjectName()))
+              .newThreeWayMerger(oi, repo.getConfig());
+      merger.setBase(claimedRevertCommit.getParent(0));
+      merger.merge(claimedRevertCommit, claimedOriginalCommit);
+      if (merger.getResultTreeId() == null) {
+        // Merge conflict during rebase
+        return new PureRevertInfo(false);
+      }
+
+      // Any differences between claimed original's parent and the rebase result indicate that the
+      // claimedRevert is not a pure revert but made content changes
+      try (DiffFormatter df = new DiffFormatter(new ByteArrayOutputStream())) {
+        df.setRepository(repo);
+        List<DiffEntry> entries =
+            df.scan(claimedOriginalCommit.getParent(0), merger.getResultTreeId());
+        return new PureRevertInfo(entries.isEmpty());
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
rename to java/com/google/gerrit/server/change/RebaseChangeOp.java
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
new file mode 100644
index 0000000..bfb1692
--- /dev/null
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -0,0 +1,208 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Utility methods related to rebasing changes. */
+public class RebaseUtil {
+  private static final Logger log = LoggerFactory.getLogger(RebaseUtil.class);
+
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeNotes.Factory notesFactory;
+  private final Provider<ReviewDb> dbProvider;
+  private final PatchSetUtil psUtil;
+
+  @Inject
+  RebaseUtil(
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeNotes.Factory notesFactory,
+      Provider<ReviewDb> dbProvider,
+      PatchSetUtil psUtil) {
+    this.queryProvider = queryProvider;
+    this.notesFactory = notesFactory;
+    this.dbProvider = dbProvider;
+    this.psUtil = psUtil;
+  }
+
+  public boolean canRebase(PatchSet patchSet, Branch.NameKey dest, Repository git, RevWalk rw) {
+    try {
+      findBaseRevision(patchSet, dest, git, rw);
+      return true;
+    } catch (RestApiException e) {
+      return false;
+    } catch (OrmException | IOException e) {
+      log.warn(
+          String.format(
+              "Error checking if patch set %s on %s can be rebased", patchSet.getId(), dest),
+          e);
+      return false;
+    }
+  }
+
+  @AutoValue
+  public abstract static class Base {
+    private static Base create(ChangeNotes notes, PatchSet ps) {
+      if (notes == null) {
+        return null;
+      }
+      return new AutoValue_RebaseUtil_Base(notes, ps);
+    }
+
+    public abstract ChangeNotes notes();
+
+    public abstract PatchSet patchSet();
+  }
+
+  public Base parseBase(RevisionResource rsrc, String base) throws OrmException {
+    ReviewDb db = dbProvider.get();
+
+    // Try parsing the base as a ref string.
+    PatchSet.Id basePatchSetId = PatchSet.Id.fromRef(base);
+    if (basePatchSetId != null) {
+      Change.Id baseChangeId = basePatchSetId.getParentKey();
+      ChangeNotes baseNotes = notesFor(rsrc, baseChangeId);
+      if (baseNotes != null) {
+        return Base.create(
+            notesFor(rsrc, basePatchSetId.getParentKey()),
+            psUtil.get(db, baseNotes, basePatchSetId));
+      }
+    }
+
+    // Try parsing base as a change number (assume current patch set).
+    Integer baseChangeId = Ints.tryParse(base);
+    if (baseChangeId != null) {
+      ChangeNotes baseNotes = notesFor(rsrc, new Change.Id(baseChangeId));
+      if (baseNotes != null) {
+        return Base.create(baseNotes, psUtil.current(db, baseNotes));
+      }
+    }
+
+    // Try parsing as SHA-1.
+    Base ret = null;
+    for (ChangeData cd : queryProvider.get().byProjectCommit(rsrc.getProject(), base)) {
+      for (PatchSet ps : cd.patchSets()) {
+        if (!ps.getRevision().matches(base)) {
+          continue;
+        }
+        if (ret == null || ret.patchSet().getId().get() < ps.getId().get()) {
+          ret = Base.create(cd.notes(), ps);
+        }
+      }
+    }
+    return ret;
+  }
+
+  private ChangeNotes notesFor(RevisionResource rsrc, Change.Id id) throws OrmException {
+    if (rsrc.getChange().getId().equals(id)) {
+      return rsrc.getNotes();
+    }
+    return notesFactory.createChecked(dbProvider.get(), rsrc.getProject(), id);
+  }
+
+  /**
+   * Find the commit onto which a patch set should be rebased.
+   *
+   * <p>This is defined as the latest patch set of the change corresponding to this commit's parent,
+   * or the destination branch tip in the case where the parent's change is merged.
+   *
+   * @param patchSet patch set for which the new base commit should be found.
+   * @param destBranch the destination branch.
+   * @param git the repository.
+   * @param rw the RevWalk.
+   * @return the commit onto which the patch set should be rebased.
+   * @throws RestApiException if rebase is not possible.
+   * @throws IOException if accessing the repository fails.
+   * @throws OrmException if accessing the database fails.
+   */
+  public ObjectId findBaseRevision(
+      PatchSet patchSet, Branch.NameKey destBranch, Repository git, RevWalk rw)
+      throws RestApiException, IOException, OrmException {
+    String baseRev = null;
+    RevCommit commit = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
+
+    if (commit.getParentCount() > 1) {
+      throw new UnprocessableEntityException("Cannot rebase a change with multiple parents.");
+    } else if (commit.getParentCount() == 0) {
+      throw new UnprocessableEntityException(
+          "Cannot rebase a change without any parents (is this the initial commit?).");
+    }
+
+    RevId parentRev = new RevId(commit.getParent(0).name());
+
+    CHANGES:
+    for (ChangeData cd : queryProvider.get().byBranchCommit(destBranch, parentRev.get())) {
+      for (PatchSet depPatchSet : cd.patchSets()) {
+        if (!depPatchSet.getRevision().equals(parentRev)) {
+          continue;
+        }
+        Change depChange = cd.change();
+        if (depChange.getStatus() == Status.ABANDONED) {
+          throw new ResourceConflictException(
+              "Cannot rebase a change with an abandoned parent: " + depChange.getKey());
+        }
+
+        if (depChange.getStatus().isOpen()) {
+          if (depPatchSet.getId().equals(depChange.currentPatchSetId())) {
+            throw new ResourceConflictException(
+                "Change is already based on the latest patch set of the dependent change.");
+          }
+          baseRev = cd.currentPatchSet().getRevision().get();
+        }
+        break CHANGES;
+      }
+    }
+
+    if (baseRev == null) {
+      // We are dependent on a merged PatchSet or have no PatchSet
+      // dependencies at all.
+      Ref destRef = git.getRefDatabase().exactRef(destBranch.get());
+      if (destRef == null) {
+        throw new UnprocessableEntityException(
+            "The destination branch does not exist: " + destBranch.get());
+      }
+      baseRev = destRef.getObjectId().getName();
+      if (baseRev.equals(parentRev.get())) {
+        throw new ResourceConflictException("Change is already up to date.");
+      }
+    }
+    return ObjectId.fromString(baseRev);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ReviewerResource.java b/java/com/google/gerrit/server/change/ReviewerResource.java
new file mode 100644
index 0000000..778897e
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.Address;
+import com.google.inject.TypeLiteral;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+public class ReviewerResource implements RestResource {
+  public static final TypeLiteral<RestView<ReviewerResource>> REVIEWER_KIND =
+      new TypeLiteral<RestView<ReviewerResource>>() {};
+
+  public interface Factory {
+    ReviewerResource create(ChangeResource change, Account.Id id);
+
+    ReviewerResource create(RevisionResource revision, Account.Id id);
+  }
+
+  private final ChangeResource change;
+  private final RevisionResource revision;
+  @Nullable private final IdentifiedUser user;
+  @Nullable private final Address address;
+
+  @AssistedInject
+  ReviewerResource(
+      IdentifiedUser.GenericFactory userFactory,
+      @Assisted ChangeResource change,
+      @Assisted Account.Id id) {
+    this.change = change;
+    this.user = userFactory.create(id);
+    this.revision = null;
+    this.address = null;
+  }
+
+  @AssistedInject
+  ReviewerResource(
+      IdentifiedUser.GenericFactory userFactory,
+      @Assisted RevisionResource revision,
+      @Assisted Account.Id id) {
+    this.revision = revision;
+    this.change = revision.getChangeResource();
+    this.user = userFactory.create(id);
+    this.address = null;
+  }
+
+  public ReviewerResource(ChangeResource change, Address address) {
+    this.change = change;
+    this.address = address;
+    this.revision = null;
+    this.user = null;
+  }
+
+  public ReviewerResource(RevisionResource revision, Address address) {
+    this.revision = revision;
+    this.change = revision.getChangeResource();
+    this.address = address;
+    this.user = null;
+  }
+
+  public ChangeResource getChangeResource() {
+    return change;
+  }
+
+  public RevisionResource getRevisionResource() {
+    return revision;
+  }
+
+  public Change.Id getChangeId() {
+    return change.getId();
+  }
+
+  public Change getChange() {
+    return change.getChange();
+  }
+
+  public IdentifiedUser getReviewerUser() {
+    checkArgument(user != null, "no user provided");
+    return user;
+  }
+
+  public Address getReviewerByEmail() {
+    checkArgument(address != null, "no address provided");
+    return address;
+  }
+
+  /**
+   * Check if this resource was constructed by email or by {@code Account.Id}.
+   *
+   * @return true if the resource was constructed by providing an {@code Address}; false if the
+   *     resource was constructed by providing an {@code Account.Id}.
+   */
+  public boolean isByEmail() {
+    return user == null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerSuggestion.java b/java/com/google/gerrit/server/change/ReviewerSuggestion.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerSuggestion.java
rename to java/com/google/gerrit/server/change/ReviewerSuggestion.java
diff --git a/java/com/google/gerrit/server/change/RevisionResource.java b/java/com/google/gerrit/server/change/RevisionResource.java
new file mode 100644
index 0000000..3ddfc63
--- /dev/null
+++ b/java/com/google/gerrit/server/change/RevisionResource.java
@@ -0,0 +1,123 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.hash.Hasher;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestResource.HasETag;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.inject.TypeLiteral;
+import java.util.Optional;
+
+public class RevisionResource implements RestResource, HasETag {
+  public static final TypeLiteral<RestView<RevisionResource>> REVISION_KIND =
+      new TypeLiteral<RestView<RevisionResource>>() {};
+
+  private final ChangeResource change;
+  private final PatchSet ps;
+  private final Optional<ChangeEdit> edit;
+  private boolean cacheable = true;
+
+  public RevisionResource(ChangeResource change, PatchSet ps) {
+    this(change, ps, Optional.empty());
+  }
+
+  public RevisionResource(ChangeResource change, PatchSet ps, Optional<ChangeEdit> edit) {
+    this.change = change;
+    this.ps = ps;
+    this.edit = edit;
+  }
+
+  public boolean isCacheable() {
+    return cacheable;
+  }
+
+  public PermissionBackend.ForChange permissions() {
+    return change.permissions();
+  }
+
+  public ChangeResource getChangeResource() {
+    return change;
+  }
+
+  public Change getChange() {
+    return getChangeResource().getChange();
+  }
+
+  public Project.NameKey getProject() {
+    return getChange().getProject();
+  }
+
+  public ChangeNotes getNotes() {
+    return getChangeResource().getNotes();
+  }
+
+  public PatchSet getPatchSet() {
+    return ps;
+  }
+
+  @Override
+  public String getETag() {
+    Hasher h = Hashing.murmur3_128().newHasher();
+    prepareETag(h, getUser());
+    return h.hash().toString();
+  }
+
+  public void prepareETag(Hasher h, CurrentUser user) {
+    // Conservative estimate: refresh the revision if its parent change has changed, so we don't
+    // have to check whether a given modification affected this revision specifically.
+    change.prepareETag(h, user);
+  }
+
+  public Account.Id getAccountId() {
+    return getUser().getAccountId();
+  }
+
+  public CurrentUser getUser() {
+    return getChangeResource().getUser();
+  }
+
+  public RevisionResource doNotCache() {
+    // TODO(hanwen): return a copy so cacheable can be final.
+    cacheable = false;
+    return this;
+  }
+
+  public Optional<ChangeEdit> getEdit() {
+    return edit;
+  }
+
+  @Override
+  public String toString() {
+    String s = ps.getId().toString();
+    if (edit.isPresent()) {
+      s = "edit:" + s;
+    }
+    return s;
+  }
+
+  public boolean isCurrent() {
+    return ps.getId().equals(getChange().currentPatchSetId());
+  }
+}
diff --git a/java/com/google/gerrit/server/change/Revisions.java b/java/com/google/gerrit/server/change/Revisions.java
new file mode 100644
index 0000000..084bc25
--- /dev/null
+++ b/java/com/google/gerrit/server/change/Revisions.java
@@ -0,0 +1,171 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.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;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+@Singleton
+public class Revisions implements ChildCollection<ChangeResource, RevisionResource> {
+  private final DynamicMap<RestView<RevisionResource>> views;
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeEditUtil editUtil;
+  private final PatchSetUtil psUtil;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  Revisions(
+      DynamicMap<RestView<RevisionResource>> views,
+      Provider<ReviewDb> dbProvider,
+      ChangeEditUtil editUtil,
+      PatchSetUtil psUtil,
+      PermissionBackend permissionBackend) {
+    this.views = views;
+    this.dbProvider = dbProvider;
+    this.editUtil = editUtil;
+    this.psUtil = psUtil;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public DynamicMap<RestView<RevisionResource>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<ChangeResource> list() throws ResourceNotFoundException {
+    throw new ResourceNotFoundException();
+  }
+
+  @Override
+  public RevisionResource parse(ChangeResource change, IdString id)
+      throws ResourceNotFoundException, AuthException, OrmException, IOException,
+          PermissionBackendException {
+    if (id.get().equals("current")) {
+      PatchSet ps = psUtil.current(dbProvider.get(), change.getNotes());
+      if (ps != null && visible(change)) {
+        return new RevisionResource(change, ps).doNotCache();
+      }
+      throw new ResourceNotFoundException(id);
+    }
+
+    List<RevisionResource> match = Lists.newArrayListWithExpectedSize(2);
+    for (RevisionResource rsrc : find(change, id.get())) {
+      if (visible(change)) {
+        match.add(rsrc);
+      }
+    }
+    switch (match.size()) {
+      case 0:
+        throw new ResourceNotFoundException(id);
+      case 1:
+        return match.get(0);
+      default:
+        throw new ResourceNotFoundException(
+            "Multiple patch sets for \"" + id.get() + "\": " + Joiner.on("; ").join(match));
+    }
+  }
+
+  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)
+      throws OrmException, IOException, AuthException {
+    if (id.equals("0") || id.equals("edit")) {
+      return loadEdit(change, null);
+    } else if (id.length() < 6 && id.matches("^[1-9][0-9]{0,4}$")) {
+      // Legacy patch set number syntax.
+      return byLegacyPatchSetId(change, id);
+    } else if (id.length() < 4 || id.length() > RevId.LEN) {
+      // Require a minimum of 4 digits.
+      // Impossibly long identifier will never match.
+      return Collections.emptyList();
+    } else {
+      List<RevisionResource> out = new ArrayList<>();
+      for (PatchSet ps : psUtil.byChange(dbProvider.get(), change.getNotes())) {
+        if (ps.getRevision() != null && ps.getRevision().get().startsWith(id)) {
+          out.add(new RevisionResource(change, ps));
+        }
+      }
+      // Not an existing patch set on a change, but might be an edit.
+      if (out.isEmpty() && id.length() == RevId.LEN) {
+        return loadEdit(change, new RevId(id));
+      }
+      return out;
+    }
+  }
+
+  private List<RevisionResource> byLegacyPatchSetId(ChangeResource change, String id)
+      throws OrmException {
+    PatchSet ps =
+        psUtil.get(
+            dbProvider.get(),
+            change.getNotes(),
+            new PatchSet.Id(change.getId(), Integer.parseInt(id)));
+    if (ps != null) {
+      return Collections.singletonList(new RevisionResource(change, ps));
+    }
+    return Collections.emptyList();
+  }
+
+  private List<RevisionResource> loadEdit(ChangeResource change, RevId revid)
+      throws AuthException, IOException {
+    Optional<ChangeEdit> edit = editUtil.byChange(change.getNotes(), change.getUser());
+    if (edit.isPresent()) {
+      PatchSet ps = new PatchSet(new PatchSet.Id(change.getId(), 0));
+      RevId editRevId = new RevId(ObjectId.toString(edit.get().getEditCommit()));
+      ps.setRevision(editRevId);
+      if (revid == null || editRevId.equals(revid)) {
+        return Collections.singletonList(new RevisionResource(change, ps, edit));
+      }
+    }
+    return Collections.emptyList();
+  }
+}
diff --git a/java/com/google/gerrit/server/change/RobotCommentResource.java b/java/com/google/gerrit/server/change/RobotCommentResource.java
new file mode 100644
index 0000000..c4fab58
--- /dev/null
+++ b/java/com/google/gerrit/server/change/RobotCommentResource.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.inject.TypeLiteral;
+
+public class RobotCommentResource implements RestResource {
+  public static final TypeLiteral<RestView<RobotCommentResource>> ROBOT_COMMENT_KIND =
+      new TypeLiteral<RestView<RobotCommentResource>>() {};
+
+  private final RevisionResource rev;
+  private final RobotComment comment;
+
+  public RobotCommentResource(RevisionResource rev, RobotComment c) {
+    this.rev = rev;
+    this.comment = c;
+  }
+
+  public PatchSet getPatchSet() {
+    return rev.getPatchSet();
+  }
+
+  public RobotComment getComment() {
+    return comment;
+  }
+
+  public String getId() {
+    return comment.key.uuid;
+  }
+
+  public Account.Id getAuthorId() {
+    return comment.author.getId();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
rename to java/com/google/gerrit/server/change/SetAssigneeOp.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java b/java/com/google/gerrit/server/change/SetHashtagsOp.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
rename to java/com/google/gerrit/server/change/SetHashtagsOp.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestedReviewer.java b/java/com/google/gerrit/server/change/SuggestedReviewer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestedReviewer.java
rename to java/com/google/gerrit/server/change/SuggestedReviewer.java
diff --git a/java/com/google/gerrit/server/change/TestSubmitInput.java b/java/com/google/gerrit/server/change/TestSubmitInput.java
new file mode 100644
index 0000000..b681bf8
--- /dev/null
+++ b/java/com/google/gerrit/server/change/TestSubmitInput.java
@@ -0,0 +1,20 @@
+package com.google.gerrit.server.change;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import java.util.Queue;
+
+/**
+ * Subclass of {@link SubmitInput} with special bits that may be flipped for testing purposes only.
+ */
+@VisibleForTesting
+public class TestSubmitInput extends SubmitInput {
+  public boolean failAfterRefUpdates;
+
+  /**
+   * For each change being submitted, an element is removed from this queue and, if the value is
+   * true, a bogus ref update is added to the batch, in order to generate a lock failure during
+   * execution.
+   */
+  public Queue<Boolean> generateLockFailures;
+}
diff --git a/java/com/google/gerrit/server/change/VoteResource.java b/java/com/google/gerrit/server/change/VoteResource.java
new file mode 100644
index 0000000..27b5bec
--- /dev/null
+++ b/java/com/google/gerrit/server/change/VoteResource.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class VoteResource implements RestResource {
+  public static final TypeLiteral<RestView<VoteResource>> VOTE_KIND =
+      new TypeLiteral<RestView<VoteResource>>() {};
+
+  private final ReviewerResource reviewer;
+  private final String label;
+
+  public VoteResource(ReviewerResource reviewer, String label) {
+    this.reviewer = reviewer;
+    this.label = label;
+  }
+
+  public ReviewerResource getReviewer() {
+    return reviewer;
+  }
+
+  public String getLabel() {
+    return label;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/WalkSorter.java b/java/com/google/gerrit/server/change/WalkSorter.java
new file mode 100644
index 0000000..509f774
--- /dev/null
+++ b/java/com/google/gerrit/server/change/WalkSorter.java
@@ -0,0 +1,269 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevFlag;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Helper to sort {@link ChangeData}s based on {@link RevWalk} ordering.
+ *
+ * <p>Split changes by project, and map each change to a single commit based on the latest patch
+ * set. The set of patch sets considered may be limited by calling {@link
+ * #includePatchSets(Iterable)}. Perform a standard {@link RevWalk} on each project repository, do
+ * an approximate topo sort, and record the order in which each change's commit is seen.
+ *
+ * <p>Once an order within each project is determined, groups of changes are sorted based on the
+ * project name. This is slightly more stable than sorting on something like the commit or change
+ * timestamp, as it will not unexpectedly reorder large groups of changes on subsequent calls if one
+ * of the changes was updated.
+ */
+public class WalkSorter {
+  private static final Logger log = LoggerFactory.getLogger(WalkSorter.class);
+
+  private static final Ordering<List<PatchSetData>> PROJECT_LIST_SORTER =
+      Ordering.natural()
+          .nullsFirst()
+          .onResultOf(
+              (List<PatchSetData> in) -> {
+                if (in == null || in.isEmpty()) {
+                  return null;
+                }
+                try {
+                  return in.get(0).data().change().getProject();
+                } catch (OrmException e) {
+                  throw new IllegalStateException(e);
+                }
+              });
+
+  private final GitRepositoryManager repoManager;
+  private final Set<PatchSet.Id> includePatchSets;
+  private boolean retainBody;
+
+  @Inject
+  WalkSorter(GitRepositoryManager repoManager) {
+    this.repoManager = repoManager;
+    includePatchSets = new HashSet<>();
+  }
+
+  public WalkSorter includePatchSets(Iterable<PatchSet.Id> patchSets) {
+    Iterables.addAll(includePatchSets, patchSets);
+    return this;
+  }
+
+  public WalkSorter setRetainBody(boolean retainBody) {
+    this.retainBody = retainBody;
+    return this;
+  }
+
+  public Iterable<PatchSetData> sort(Iterable<ChangeData> in) throws OrmException, IOException {
+    ListMultimap<Project.NameKey, ChangeData> byProject =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+    for (ChangeData cd : in) {
+      byProject.put(cd.change().getProject(), cd);
+    }
+
+    List<List<PatchSetData>> sortedByProject = new ArrayList<>(byProject.keySet().size());
+    for (Map.Entry<Project.NameKey, Collection<ChangeData>> e : byProject.asMap().entrySet()) {
+      sortedByProject.add(sortProject(e.getKey(), e.getValue()));
+    }
+    Collections.sort(sortedByProject, PROJECT_LIST_SORTER);
+    return Iterables.concat(sortedByProject);
+  }
+
+  private List<PatchSetData> sortProject(Project.NameKey project, Collection<ChangeData> in)
+      throws OrmException, IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      rw.setRetainBody(retainBody);
+      ListMultimap<RevCommit, PatchSetData> byCommit = byCommit(rw, in);
+      if (byCommit.isEmpty()) {
+        return ImmutableList.of();
+      } else if (byCommit.size() == 1) {
+        return ImmutableList.of(byCommit.values().iterator().next());
+      }
+
+      // Walk from all patch set SHA-1s, and terminate as soon as we've found
+      // everything we're looking for. This is equivalent to just sorting the
+      // list of commits by the RevWalk's configured order.
+      //
+      // Partially topo sort the list, ensuring no parent is emitted before a
+      // direct child that is also in the input set. This preserves the stable,
+      // expected sort in the case where many commits share the same timestamp,
+      // e.g. a quick rebase. It also avoids JGit's topo sort, which slurps all
+      // interesting commits at the beginning, which is a problem since we don't
+      // know which commits to mark as uninteresting. Finding a reasonable set
+      // of commits to mark uninteresting (the "rootmost" set) is at least as
+      // difficult as just implementing this partial topo sort ourselves.
+      //
+      // (This is slightly less efficient than JGit's topo sort, which uses a
+      // private in-degree field in RevCommit rather than multimaps. We assume
+      // the input size is small enough that this is not an issue.)
+
+      Set<RevCommit> commits = byCommit.keySet();
+      ListMultimap<RevCommit, RevCommit> children = collectChildren(commits);
+      ListMultimap<RevCommit, RevCommit> pending =
+          MultimapBuilder.hashKeys().arrayListValues().build();
+      Deque<RevCommit> todo = new ArrayDeque<>();
+
+      RevFlag done = rw.newFlag("done");
+      markStart(rw, commits);
+      int expected = commits.size();
+      int found = 0;
+      RevCommit c;
+      List<PatchSetData> result = new ArrayList<>(expected);
+      while (found < expected && (c = rw.next()) != null) {
+        if (!commits.contains(c)) {
+          continue;
+        }
+        todo.clear();
+        todo.add(c);
+        int i = 0;
+        while (!todo.isEmpty()) {
+          // Sanity check: we can't pop more than N pending commits, otherwise
+          // we have an infinite loop due to programmer error or something.
+          checkState(++i <= commits.size(), "Too many pending steps while sorting %s", commits);
+          RevCommit t = todo.removeFirst();
+          if (t.has(done)) {
+            continue;
+          }
+          boolean ready = true;
+          for (RevCommit child : children.get(t)) {
+            if (!child.has(done)) {
+              pending.put(child, t);
+              ready = false;
+            }
+          }
+          if (ready) {
+            found += emit(t, byCommit, result, done);
+            todo.addAll(pending.get(t));
+          }
+        }
+      }
+      return result;
+    }
+  }
+
+  private static ListMultimap<RevCommit, RevCommit> collectChildren(Set<RevCommit> commits) {
+    ListMultimap<RevCommit, RevCommit> children =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+    for (RevCommit c : commits) {
+      for (RevCommit p : c.getParents()) {
+        if (commits.contains(p)) {
+          children.put(p, c);
+        }
+      }
+    }
+    return children;
+  }
+
+  private static int emit(
+      RevCommit c,
+      ListMultimap<RevCommit, PatchSetData> byCommit,
+      List<PatchSetData> result,
+      RevFlag done) {
+    if (c.has(done)) {
+      return 0;
+    }
+    c.add(done);
+    Collection<PatchSetData> psds = byCommit.get(c);
+    if (!psds.isEmpty()) {
+      result.addAll(psds);
+      return 1;
+    }
+    return 0;
+  }
+
+  private ListMultimap<RevCommit, PatchSetData> byCommit(RevWalk rw, Collection<ChangeData> in)
+      throws OrmException, IOException {
+    ListMultimap<RevCommit, PatchSetData> byCommit =
+        MultimapBuilder.hashKeys(in.size()).arrayListValues(1).build();
+    for (ChangeData cd : in) {
+      PatchSet maxPs = null;
+      for (PatchSet ps : cd.patchSets()) {
+        if (shouldInclude(ps) && (maxPs == null || ps.getId().get() > maxPs.getId().get())) {
+          maxPs = ps;
+        }
+      }
+      if (maxPs == null) {
+        continue; // No patch sets matched.
+      }
+      ObjectId id = ObjectId.fromString(maxPs.getRevision().get());
+      try {
+        RevCommit c = rw.parseCommit(id);
+        byCommit.put(c, PatchSetData.create(cd, maxPs, c));
+      } catch (MissingObjectException | IncorrectObjectTypeException e) {
+        log.warn("missing commit " + id.name() + " for patch set " + maxPs.getId(), e);
+      }
+    }
+    return byCommit;
+  }
+
+  private boolean shouldInclude(PatchSet ps) {
+    return includePatchSets.isEmpty() || includePatchSets.contains(ps.getId());
+  }
+
+  private static void markStart(RevWalk rw, Iterable<RevCommit> commits) throws IOException {
+    for (RevCommit c : commits) {
+      rw.markStart(c);
+    }
+  }
+
+  @AutoValue
+  public abstract static class PatchSetData {
+    @VisibleForTesting
+    static PatchSetData create(ChangeData cd, PatchSet ps, RevCommit commit) {
+      return new AutoValue_WalkSorter_PatchSetData(cd, ps, commit);
+    }
+
+    public abstract ChangeData data();
+
+    abstract PatchSet patchSet();
+
+    abstract RevCommit commit();
+  }
+}
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
new file mode 100644
index 0000000..75a9323
--- /dev/null
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -0,0 +1,145 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/* Set work in progress or ready for review state on a change */
+public class WorkInProgressOp implements BatchUpdateOp {
+  public static class Input {
+    @Nullable public String message;
+
+    @Nullable public NotifyHandling notify;
+
+    public Input() {}
+
+    public Input(String message) {
+      this.message = message;
+    }
+  }
+
+  public interface Factory {
+    WorkInProgressOp create(boolean workInProgress, Input in);
+  }
+
+  private final ChangeMessagesUtil cmUtil;
+  private final EmailReviewComments.Factory email;
+  private final PatchSetUtil psUtil;
+  private final boolean workInProgress;
+  private final Input in;
+  private final NotifyHandling notify;
+  private final WorkInProgressStateChanged stateChanged;
+
+  private Change change;
+  private ChangeNotes notes;
+  private PatchSet ps;
+  private ChangeMessage cmsg;
+
+  @Inject
+  WorkInProgressOp(
+      ChangeMessagesUtil cmUtil,
+      EmailReviewComments.Factory email,
+      PatchSetUtil psUtil,
+      WorkInProgressStateChanged stateChanged,
+      @Assisted boolean workInProgress,
+      @Assisted Input in) {
+    this.cmUtil = cmUtil;
+    this.email = email;
+    this.psUtil = psUtil;
+    this.stateChanged = stateChanged;
+    this.workInProgress = workInProgress;
+    this.in = in;
+    notify =
+        MoreObjects.firstNonNull(
+            in.notify, workInProgress ? NotifyHandling.NONE : NotifyHandling.ALL);
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws OrmException {
+    change = ctx.getChange();
+    notes = ctx.getNotes();
+    ps = psUtil.get(ctx.getDb(), ctx.getNotes(), change.currentPatchSetId());
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    change.setWorkInProgress(workInProgress);
+    if (!change.hasReviewStarted() && !workInProgress) {
+      change.setReviewStarted(true);
+    }
+    change.setLastUpdatedOn(ctx.getWhen());
+    update.setWorkInProgress(workInProgress);
+    addMessage(ctx, update);
+    return true;
+  }
+
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
+    Change c = ctx.getChange();
+    StringBuilder buf =
+        new StringBuilder(c.isWorkInProgress() ? "Set Work In Progress" : "Set Ready For Review");
+
+    String m = Strings.nullToEmpty(in == null ? null : in.message).trim();
+    if (!m.isEmpty()) {
+      buf.append("\n\n");
+      buf.append(m);
+    }
+
+    cmsg =
+        ChangeMessagesUtil.newMessage(
+            ctx,
+            buf.toString(),
+            c.isWorkInProgress()
+                ? ChangeMessagesUtil.TAG_SET_WIP
+                : ChangeMessagesUtil.TAG_SET_READY);
+
+    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    stateChanged.fire(change, ctx.getAccount(), ctx.getWhen());
+    if (workInProgress || notify.ordinal() < NotifyHandling.OWNER_REVIEWERS.ordinal()) {
+      return;
+    }
+    email
+        .create(
+            notify,
+            ImmutableListMultimap.of(),
+            notes,
+            ps,
+            ctx.getIdentifiedUser(),
+            cmsg,
+            ImmutableList.of(),
+            cmsg.getMessage(),
+            ImmutableList.of())
+        .sendAsync();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroups.java b/java/com/google/gerrit/server/config/AdministrateServerGroups.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroups.java
rename to java/com/google/gerrit/server/config/AdministrateServerGroups.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java b/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
rename to java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsName.java b/java/com/google/gerrit/server/config/AllProjectsName.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsName.java
rename to java/com/google/gerrit/server/config/AllProjectsName.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsNameProvider.java b/java/com/google/gerrit/server/config/AllProjectsNameProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsNameProvider.java
rename to java/com/google/gerrit/server/config/AllProjectsNameProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersName.java b/java/com/google/gerrit/server/config/AllUsersName.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersName.java
rename to java/com/google/gerrit/server/config/AllUsersName.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersNameProvider.java b/java/com/google/gerrit/server/config/AllUsersNameProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersNameProvider.java
rename to java/com/google/gerrit/server/config/AllUsersNameProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardName.java b/java/com/google/gerrit/server/config/AnonymousCowardName.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardName.java
rename to java/com/google/gerrit/server/config/AnonymousCowardName.java
diff --git a/java/com/google/gerrit/server/config/AnonymousCowardNameProvider.java b/java/com/google/gerrit/server/config/AnonymousCowardNameProvider.java
new file mode 100644
index 0000000..6847562
--- /dev/null
+++ b/java/com/google/gerrit/server/config/AnonymousCowardNameProvider.java
@@ -0,0 +1,40 @@
+// 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.config;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.eclipse.jgit.lib.Config;
+
+public class AnonymousCowardNameProvider implements Provider<String> {
+  public static final String DEFAULT = "Name of user not set";
+
+  private final String anonymousCoward;
+
+  @Inject
+  public AnonymousCowardNameProvider(@GerritServerConfig Config cfg) {
+    String anonymousCoward = cfg.getString("user", null, "anonymousCoward");
+    if (anonymousCoward == null) {
+      anonymousCoward = DEFAULT;
+    }
+
+    this.anonymousCoward = anonymousCoward;
+  }
+
+  @Override
+  public String get() {
+    return anonymousCoward;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java b/java/com/google/gerrit/server/config/AuthConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
rename to java/com/google/gerrit/server/config/AuthConfig.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfigModule.java b/java/com/google/gerrit/server/config/AuthConfigModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfigModule.java
rename to java/com/google/gerrit/server/config/AuthConfigModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java b/java/com/google/gerrit/server/config/AuthModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java
rename to java/com/google/gerrit/server/config/AuthModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java b/java/com/google/gerrit/server/config/CacheResource.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java
rename to java/com/google/gerrit/server/config/CacheResource.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrl.java b/java/com/google/gerrit/server/config/CanonicalWebUrl.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrl.java
rename to java/com/google/gerrit/server/config/CanonicalWebUrl.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlModule.java b/java/com/google/gerrit/server/config/CanonicalWebUrlModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlModule.java
rename to java/com/google/gerrit/server/config/CanonicalWebUrlModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlProvider.java b/java/com/google/gerrit/server/config/CanonicalWebUrlProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlProvider.java
rename to java/com/google/gerrit/server/config/CanonicalWebUrlProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CapabilityConstants.java b/java/com/google/gerrit/server/config/CapabilityConstants.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/CapabilityConstants.java
rename to java/com/google/gerrit/server/config/CapabilityConstants.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CapabilityResource.java b/java/com/google/gerrit/server/config/CapabilityResource.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/CapabilityResource.java
rename to java/com/google/gerrit/server/config/CapabilityResource.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ChangeCleanupConfig.java b/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
rename to java/com/google/gerrit/server/config/ChangeCleanupConfig.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigResource.java b/java/com/google/gerrit/server/config/ConfigResource.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigResource.java
rename to java/com/google/gerrit/server/config/ConfigResource.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigSection.java b/java/com/google/gerrit/server/config/ConfigSection.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigSection.java
rename to java/com/google/gerrit/server/config/ConfigSection.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigUtil.java
rename to java/com/google/gerrit/server/config/ConfigUtil.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/DisableReverseDnsLookup.java b/java/com/google/gerrit/server/config/DisableReverseDnsLookup.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/DisableReverseDnsLookup.java
rename to java/com/google/gerrit/server/config/DisableReverseDnsLookup.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/DisableReverseDnsLookupProvider.java b/java/com/google/gerrit/server/config/DisableReverseDnsLookupProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/DisableReverseDnsLookupProvider.java
rename to java/com/google/gerrit/server/config/DisableReverseDnsLookupProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/DownloadConfig.java b/java/com/google/gerrit/server/config/DownloadConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/DownloadConfig.java
rename to java/com/google/gerrit/server/config/DownloadConfig.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/EmailExpanderProvider.java b/java/com/google/gerrit/server/config/EmailExpanderProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/EmailExpanderProvider.java
rename to java/com/google/gerrit/server/config/EmailExpanderProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GcConfig.java b/java/com/google/gerrit/server/config/GcConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GcConfig.java
rename to java/com/google/gerrit/server/config/GcConfig.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritConfig.java b/java/com/google/gerrit/server/config/GerritConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GerritConfig.java
rename to java/com/google/gerrit/server/config/GerritConfig.java
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
new file mode 100644
index 0000000..74e19b6
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -0,0 +1,430 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static com.google.inject.Scopes.SINGLETON;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.api.changes.ActionVisitor;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
+import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
+import com.google.gerrit.extensions.common.AccountVisibility;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.gerrit.extensions.config.CloneCommand;
+import com.google.gerrit.extensions.config.DownloadCommand;
+import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.config.ExternalIncludedIn;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.AccountIndexedListener;
+import com.google.gerrit.extensions.events.AgreementSignupListener;
+import com.google.gerrit.extensions.events.AssigneeChangedListener;
+import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+import com.google.gerrit.extensions.events.ChangeMergedListener;
+import com.google.gerrit.extensions.events.ChangeRestoredListener;
+import com.google.gerrit.extensions.events.ChangeRevertedListener;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.GarbageCollectorListener;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.GroupIndexedListener;
+import com.google.gerrit.extensions.events.HashtagsEditedListener;
+import com.google.gerrit.extensions.events.HeadUpdatedListener;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.events.NewProjectCreatedListener;
+import com.google.gerrit.extensions.events.PluginEventListener;
+import com.google.gerrit.extensions.events.PrivateStateChangedListener;
+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;
+import com.google.gerrit.extensions.events.TopicEditedListener;
+import com.google.gerrit.extensions.events.UsageDataPublishedListener;
+import com.google.gerrit.extensions.events.VoteDeletedListener;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.systemstatus.MessageOfTheDay;
+import com.google.gerrit.extensions.webui.BranchWebLink;
+import com.google.gerrit.extensions.webui.DiffWebLink;
+import com.google.gerrit.extensions.webui.FileHistoryWebLink;
+import com.google.gerrit.extensions.webui.FileWebLink;
+import com.google.gerrit.extensions.webui.ParentWebLink;
+import com.google.gerrit.extensions.webui.PatchSetWebLink;
+import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.TagWebLink;
+import com.google.gerrit.extensions.webui.TopMenu;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeFinder;
+import com.google.gerrit.server.CmdLineParserModule;
+import com.google.gerrit.server.CreateGroupPermissionSyncer;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PluginUser;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountCacheImpl;
+import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.account.AccountDeactivator;
+import com.google.gerrit.server.account.AccountExternalIdCreator;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountVisibilityProvider;
+import com.google.gerrit.server.account.CapabilityCollection;
+import com.google.gerrit.server.account.EmailExpander;
+import com.google.gerrit.server.account.GroupCacheImpl;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.account.GroupIncludeCacheImpl;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalIdModule;
+import com.google.gerrit.server.audit.AuditModule;
+import com.google.gerrit.server.auth.AuthBackend;
+import com.google.gerrit.server.auth.UniversalAuthBackend;
+import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
+import com.google.gerrit.server.avatar.AvatarProvider;
+import com.google.gerrit.server.cache.CacheRemovalListener;
+import com.google.gerrit.server.change.AbandonOp;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeKindCacheImpl;
+import com.google.gerrit.server.change.MergeabilityCacheImpl;
+import com.google.gerrit.server.change.ReviewerSuggestion;
+import com.google.gerrit.server.events.EventFactory;
+import com.google.gerrit.server.events.EventListener;
+import com.google.gerrit.server.events.EventsMetrics;
+import com.google.gerrit.server.events.UserScopedEventListener;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.extensions.webui.UiActions;
+import com.google.gerrit.server.git.ChangeMessageModifier;
+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;
+import com.google.gerrit.server.git.ReceivePackInitializer;
+import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.git.receive.ReceiveCommitsModule;
+import com.google.gerrit.server.git.strategy.SubmitStrategy;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.MergeValidationListener;
+import com.google.gerrit.server.git.validators.MergeValidators;
+import com.google.gerrit.server.git.validators.MergeValidators.AccountMergeValidator;
+import com.google.gerrit.server.git.validators.MergeValidators.GroupMergeValidator;
+import com.google.gerrit.server.git.validators.MergeValidators.ProjectConfigValidator;
+import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
+import com.google.gerrit.server.git.validators.OnSubmitValidators;
+import com.google.gerrit.server.git.validators.RefOperationValidationListener;
+import com.google.gerrit.server.git.validators.RefOperationValidators;
+import com.google.gerrit.server.git.validators.UploadValidationListener;
+import com.google.gerrit.server.git.validators.UploadValidators;
+import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
+import com.google.gerrit.server.mail.EmailModule;
+import com.google.gerrit.server.mail.ListMailFilter;
+import com.google.gerrit.server.mail.MailFilter;
+import com.google.gerrit.server.mail.send.AddKeySender;
+import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.FromAddressGenerator;
+import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
+import com.google.gerrit.server.mail.send.MailSoyTofuProvider;
+import com.google.gerrit.server.mail.send.MailTemplates;
+import com.google.gerrit.server.mail.send.MergedSender;
+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.mime.FileTypeRegistry;
+import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gerrit.server.notedb.NoteDbModule;
+import com.google.gerrit.server.patch.PatchListCacheImpl;
+import com.google.gerrit.server.patch.PatchScriptFactory;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.plugins.ReloadPluginListener;
+import com.google.gerrit.server.project.AccessControlModule;
+import com.google.gerrit.server.project.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.ProjectState;
+import com.google.gerrit.server.project.SectionSortCache;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
+import com.google.gerrit.server.query.change.ConflictsCacheImpl;
+import com.google.gerrit.server.restapi.config.ConfigRestModule;
+import com.google.gerrit.server.restapi.group.GroupModule;
+import com.google.gerrit.server.rules.PrologModule;
+import com.google.gerrit.server.rules.RulesCache;
+import com.google.gerrit.server.ssh.SshAddressesModule;
+import com.google.gerrit.server.tools.ToolsCatalog;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.IdGenerator;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.server.validators.AssigneeValidationListener;
+import com.google.gerrit.server.validators.GroupCreationValidationListener;
+import com.google.gerrit.server.validators.HashtagValidationListener;
+import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gitiles.blame.cache.BlameCache;
+import com.google.gitiles.blame.cache.BlameCacheImpl;
+import com.google.inject.Inject;
+import com.google.inject.TypeLiteral;
+import com.google.inject.internal.UniqueAnnotations;
+import com.google.template.soy.tofu.SoyTofu;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.transport.PostReceiveHook;
+import org.eclipse.jgit.transport.PostUploadHook;
+import org.eclipse.jgit.transport.PreUploadHook;
+
+/** Starts global state with standard dependencies. */
+public class GerritGlobalModule extends FactoryModule {
+  private final Config cfg;
+  private final AuthModule authModule;
+  private final GroupsMigration groupsMigration;
+
+  @Inject
+  GerritGlobalModule(
+      @GerritServerConfig Config cfg, AuthModule authModule, GroupsMigration groupsMigration) {
+    this.cfg = cfg;
+    this.authModule = authModule;
+    this.groupsMigration = groupsMigration;
+  }
+
+  @Override
+  protected void configure() {
+    bind(EmailExpander.class).toProvider(EmailExpanderProvider.class).in(SINGLETON);
+
+    bind(IdGenerator.class);
+    bind(RulesCache.class);
+    bind(BlameCache.class).to(BlameCacheImpl.class);
+    bind(Sequences.class);
+    install(authModule);
+    install(AccountCacheImpl.module());
+    install(BatchUpdate.module());
+    install(ChangeKindCacheImpl.module());
+    install(ChangeFinder.module());
+    install(ConflictsCacheImpl.module());
+    install(GroupCacheImpl.module());
+    install(GroupIncludeCacheImpl.module());
+    install(MergeabilityCacheImpl.module());
+    install(PatchListCacheImpl.module());
+    install(ProjectCacheImpl.module());
+    install(SectionSortCache.module());
+    install(SubmitStrategy.module());
+    install(TagCache.module());
+    install(OAuthTokenCache.module());
+
+    install(new AccessControlModule());
+    install(new CmdLineParserModule());
+    install(new EmailModule());
+    install(new ExternalIdModule());
+    install(new GitModule());
+    install(new GroupModule());
+    install(new NoteDbModule(cfg));
+    install(new PrologModule());
+    install(new ReceiveCommitsModule());
+    install(new SshAddressesModule());
+    install(ThreadLocalRequestContext.module());
+
+    bind(AccountResolver.class);
+
+    factory(AddReviewerSender.Factory.class);
+    factory(DeleteReviewerSender.Factory.class);
+    factory(AddKeySender.Factory.class);
+    factory(CapabilityCollection.Factory.class);
+    factory(ChangeData.AssistedFactory.class);
+    factory(ChangeJson.AssistedFactory.class);
+    factory(CreateChangeSender.Factory.class);
+    factory(EmailMerge.Factory.class);
+    factory(MergedSender.Factory.class);
+    factory(MergeUtil.Factory.class);
+    factory(PatchScriptFactory.Factory.class);
+    factory(PluginUser.Factory.class);
+    factory(ProjectState.Factory.class);
+    factory(RegisterNewEmailSender.Factory.class);
+    factory(ReplacePatchSetSender.Factory.class);
+    factory(SetAssigneeSender.Factory.class);
+    factory(VisibleRefFilter.Factory.class);
+    bind(PermissionCollection.Factory.class);
+    bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
+    factory(ProjectOwnerGroupsProvider.Factory.class);
+    factory(SubmitRuleEvaluator.Factory.class);
+
+    bind(AuthBackend.class).to(UniversalAuthBackend.class).in(SINGLETON);
+    DynamicSet.setOf(binder(), AuthBackend.class);
+
+    bind(GroupControl.Factory.class).in(SINGLETON);
+    bind(GroupControl.GenericFactory.class).in(SINGLETON);
+
+    bind(FileTypeRegistry.class).to(MimeUtilFileTypeRegistry.class);
+    bind(ToolsCatalog.class);
+    bind(EventFactory.class);
+    bind(TransferConfig.class);
+
+    bind(GcConfig.class);
+    bind(ChangeCleanupConfig.class);
+    bind(AccountDeactivator.class);
+
+    bind(ApprovalsUtil.class);
+
+    bind(SoyTofu.class).annotatedWith(MailTemplates.class).toProvider(MailSoyTofuProvider.class);
+    bind(FromAddressGenerator.class).toProvider(FromAddressGeneratorProvider.class).in(SINGLETON);
+    bind(Boolean.class)
+        .annotatedWith(DisableReverseDnsLookup.class)
+        .toProvider(DisableReverseDnsLookupProvider.class)
+        .in(SINGLETON);
+
+    bind(PatchSetInfoFactory.class);
+    bind(IdentifiedUser.GenericFactory.class).in(SINGLETON);
+    bind(AccountControl.Factory.class);
+
+    install(new AuditModule());
+    bind(UiActions.class);
+    install(new com.google.gerrit.server.restapi.access.Module());
+    install(new ConfigRestModule());
+    install(new com.google.gerrit.server.restapi.change.Module());
+    install(new com.google.gerrit.server.group.Module(groupsMigration));
+    install(new com.google.gerrit.server.restapi.account.Module());
+    install(new com.google.gerrit.server.restapi.project.Module());
+    install(new com.google.gerrit.server.restapi.group.Module());
+
+    bind(GitReferenceUpdated.class);
+    DynamicMap.mapOf(binder(), new TypeLiteral<Cache<?, ?>>() {});
+    DynamicSet.setOf(binder(), CacheRemovalListener.class);
+    DynamicMap.mapOf(binder(), CapabilityDefinition.class);
+    DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
+    DynamicSet.setOf(binder(), AssigneeChangedListener.class);
+    DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
+    DynamicSet.setOf(binder(), CommentAddedListener.class);
+    DynamicSet.setOf(binder(), HashtagsEditedListener.class);
+    DynamicSet.setOf(binder(), ChangeMergedListener.class);
+    bind(ChangeMergedListener.class)
+        .annotatedWith(Exports.named("CreateGroupPermissionSyncer"))
+        .to(CreateGroupPermissionSyncer.class);
+
+    DynamicSet.setOf(binder(), ChangeRestoredListener.class);
+    DynamicSet.setOf(binder(), ChangeRevertedListener.class);
+    DynamicSet.setOf(binder(), PrivateStateChangedListener.class);
+    DynamicSet.setOf(binder(), ReviewerAddedListener.class);
+    DynamicSet.setOf(binder(), ReviewerDeletedListener.class);
+    DynamicSet.setOf(binder(), VoteDeletedListener.class);
+    DynamicSet.setOf(binder(), WorkInProgressStateChangedListener.class);
+    DynamicSet.setOf(binder(), RevisionCreatedListener.class);
+    DynamicSet.setOf(binder(), TopicEditedListener.class);
+    DynamicSet.setOf(binder(), AgreementSignupListener.class);
+    DynamicSet.setOf(binder(), PluginEventListener.class);
+    DynamicSet.setOf(binder(), ReceivePackInitializer.class);
+    DynamicSet.setOf(binder(), PostReceiveHook.class);
+    DynamicSet.setOf(binder(), PreUploadHook.class);
+    DynamicSet.setOf(binder(), PostUploadHook.class);
+    DynamicSet.setOf(binder(), AccountIndexedListener.class);
+    DynamicSet.setOf(binder(), ChangeIndexedListener.class);
+    DynamicSet.setOf(binder(), GroupIndexedListener.class);
+    DynamicSet.setOf(binder(), ProjectIndexedListener.class);
+    DynamicSet.setOf(binder(), NewProjectCreatedListener.class);
+    DynamicSet.setOf(binder(), ProjectDeletedListener.class);
+    DynamicSet.setOf(binder(), GarbageCollectorListener.class);
+    DynamicSet.setOf(binder(), HeadUpdatedListener.class);
+    DynamicSet.setOf(binder(), UsageDataPublishedListener.class);
+    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(ReindexAfterRefUpdate.class);
+    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
+        .to(ProjectConfigEntry.UpdateChecker.class);
+    DynamicSet.setOf(binder(), EventListener.class);
+    DynamicSet.bind(binder(), EventListener.class).to(EventsMetrics.class);
+    DynamicSet.setOf(binder(), UserScopedEventListener.class);
+    DynamicSet.setOf(binder(), CommitValidationListener.class);
+    DynamicSet.setOf(binder(), ChangeMessageModifier.class);
+    DynamicSet.setOf(binder(), RefOperationValidationListener.class);
+    DynamicSet.setOf(binder(), OnSubmitValidationListener.class);
+    DynamicSet.setOf(binder(), MergeValidationListener.class);
+    DynamicSet.setOf(binder(), ProjectCreationValidationListener.class);
+    DynamicSet.setOf(binder(), GroupCreationValidationListener.class);
+    DynamicSet.setOf(binder(), HashtagValidationListener.class);
+    DynamicSet.setOf(binder(), OutgoingEmailValidationListener.class);
+    DynamicItem.itemOf(binder(), AvatarProvider.class);
+    DynamicSet.setOf(binder(), LifecycleListener.class);
+    DynamicSet.setOf(binder(), TopMenu.class);
+    DynamicSet.setOf(binder(), MessageOfTheDay.class);
+    DynamicMap.mapOf(binder(), DownloadScheme.class);
+    DynamicMap.mapOf(binder(), DownloadCommand.class);
+    DynamicMap.mapOf(binder(), CloneCommand.class);
+    DynamicMap.mapOf(binder(), ReviewerSuggestion.class);
+    DynamicSet.setOf(binder(), ExternalIncludedIn.class);
+    DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
+    DynamicSet.setOf(binder(), PatchSetWebLink.class);
+    DynamicSet.setOf(binder(), ParentWebLink.class);
+    DynamicSet.setOf(binder(), FileWebLink.class);
+    DynamicSet.setOf(binder(), FileHistoryWebLink.class);
+    DynamicSet.setOf(binder(), DiffWebLink.class);
+    DynamicSet.setOf(binder(), ProjectWebLink.class);
+    DynamicSet.setOf(binder(), BranchWebLink.class);
+    DynamicSet.setOf(binder(), TagWebLink.class);
+    DynamicMap.mapOf(binder(), OAuthLoginProvider.class);
+    DynamicItem.itemOf(binder(), OAuthTokenEncrypter.class);
+    DynamicSet.setOf(binder(), AccountExternalIdCreator.class);
+    DynamicSet.setOf(binder(), WebUiPlugin.class);
+    DynamicItem.itemOf(binder(), AccountPatchReviewStore.class);
+    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);
+
+    factory(UploadValidators.Factory.class);
+    DynamicSet.setOf(binder(), UploadValidationListener.class);
+
+    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
+    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
+    DynamicMap.mapOf(binder(), ChangeQueryProcessor.ChangeAttributeFactory.class);
+
+    install(new GitwebConfig.LegacyModule(cfg));
+
+    bind(AnonymousUser.class);
+
+    factory(AbandonOp.Factory.class);
+    factory(AccountMergeValidator.Factory.class);
+    factory(GroupMergeValidator.Factory.class);
+    factory(RefOperationValidators.Factory.class);
+    factory(OnSubmitValidators.Factory.class);
+    factory(MergeValidators.Factory.class);
+    factory(ProjectConfigValidator.Factory.class);
+    factory(NotesBranchUtil.Factory.class);
+    factory(MergedByPushOp.Factory.class);
+    factory(GitModules.Factory.class);
+    factory(VersionedAuthorizedKeys.Factory.class);
+
+    bind(AccountManager.class);
+
+    bind(new TypeLiteral<List<CommentLinkInfo>>() {})
+        .toProvider(CommentLinkProvider.class)
+        .in(SINGLETON);
+
+    bind(ReloadPluginListener.class)
+        .annotatedWith(UniqueAnnotations.create())
+        .to(PluginConfigFactory.class);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritOptions.java b/java/com/google/gerrit/server/config/GerritOptions.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GerritOptions.java
rename to java/com/google/gerrit/server/config/GerritOptions.java
diff --git a/java/com/google/gerrit/server/config/GerritRequestModule.java b/java/com/google/gerrit/server/config/GerritRequestModule.java
new file mode 100644
index 0000000..3c9168a
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritRequestModule.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static com.google.inject.Scopes.SINGLETON;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.RequestCleanup;
+import com.google.gerrit.server.project.PerRequestProjectControlCache;
+import com.google.inject.servlet.RequestScoped;
+
+/** Bindings for {@link RequestScoped} entities. */
+public class GerritRequestModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    bind(RequestCleanup.class).in(RequestScoped.class);
+    bind(RequestScopedReviewDbProvider.class);
+    bind(IdentifiedUser.RequestFactory.class).in(SINGLETON);
+
+    bind(PerRequestProjectControlCache.class).in(RequestScoped.class);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfig.java b/java/com/google/gerrit/server/config/GerritServerConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfig.java
rename to java/com/google/gerrit/server/config/GerritServerConfig.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigModule.java b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigModule.java
rename to java/com/google/gerrit/server/config/GerritServerConfigModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigProvider.java b/java/com/google/gerrit/server/config/GerritServerConfigProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigProvider.java
rename to java/com/google/gerrit/server/config/GerritServerConfigProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerId.java b/java/com/google/gerrit/server/config/GerritServerId.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerId.java
rename to java/com/google/gerrit/server/config/GerritServerId.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerIdProvider.java b/java/com/google/gerrit/server/config/GerritServerIdProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerIdProvider.java
rename to java/com/google/gerrit/server/config/GerritServerIdProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroups.java b/java/com/google/gerrit/server/config/GitReceivePackGroups.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroups.java
rename to java/com/google/gerrit/server/config/GitReceivePackGroups.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroupsProvider.java b/java/com/google/gerrit/server/config/GitReceivePackGroupsProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroupsProvider.java
rename to java/com/google/gerrit/server/config/GitReceivePackGroupsProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroups.java b/java/com/google/gerrit/server/config/GitUploadPackGroups.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroups.java
rename to java/com/google/gerrit/server/config/GitUploadPackGroups.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroupsProvider.java b/java/com/google/gerrit/server/config/GitUploadPackGroupsProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroupsProvider.java
rename to java/com/google/gerrit/server/config/GitUploadPackGroupsProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebCgiConfig.java b/java/com/google/gerrit/server/config/GitwebCgiConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebCgiConfig.java
rename to java/com/google/gerrit/server/config/GitwebCgiConfig.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebConfig.java
rename to java/com/google/gerrit/server/config/GitwebConfig.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GlobalPluginConfig.java b/java/com/google/gerrit/server/config/GlobalPluginConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GlobalPluginConfig.java
rename to java/com/google/gerrit/server/config/GlobalPluginConfig.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GroupSetProvider.java b/java/com/google/gerrit/server/config/GroupSetProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/GroupSetProvider.java
rename to java/com/google/gerrit/server/config/GroupSetProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfig.java b/java/com/google/gerrit/server/config/PluginConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfig.java
rename to java/com/google/gerrit/server/config/PluginConfig.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfigFactory.java
rename to java/com/google/gerrit/server/config/PluginConfigFactory.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java
rename to java/com/google/gerrit/server/config/ProjectConfigEntry.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java b/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
rename to java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/RepositoryConfig.java b/java/com/google/gerrit/server/config/RepositoryConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/RepositoryConfig.java
rename to java/com/google/gerrit/server/config/RepositoryConfig.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java b/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
rename to java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
diff --git a/java/com/google/gerrit/server/config/ScheduleConfig.java b/java/com/google/gerrit/server/config/ScheduleConfig.java
new file mode 100644
index 0000000..f7f8238
--- /dev/null
+++ b/java/com/google/gerrit/server/config/ScheduleConfig.java
@@ -0,0 +1,189 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static java.time.ZoneId.systemDefault;
+
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ScheduleConfig {
+  private static final Logger log = LoggerFactory.getLogger(ScheduleConfig.class);
+  public static final long MISSING_CONFIG = -1L;
+  public static final long INVALID_CONFIG = -2L;
+  private static final String KEY_INTERVAL = "interval";
+  private static final String KEY_STARTTIME = "startTime";
+
+  private final Config rc;
+  private final String section;
+  private final String subsection;
+  private final String keyInterval;
+  private final String keyStartTime;
+  private final long initialDelay;
+  private final long interval;
+
+  public ScheduleConfig(Config rc, String section) {
+    this(rc, section, null);
+  }
+
+  public ScheduleConfig(Config rc, String section, String subsection) {
+    this(rc, section, subsection, ZonedDateTime.now(systemDefault()));
+  }
+
+  public ScheduleConfig(
+      Config rc, String section, String subsection, String keyInterval, String keyStartTime) {
+    this(rc, section, subsection, keyInterval, keyStartTime, ZonedDateTime.now(systemDefault()));
+  }
+
+  @VisibleForTesting
+  ScheduleConfig(Config rc, String section, String subsection, ZonedDateTime now) {
+    this(rc, section, subsection, KEY_INTERVAL, KEY_STARTTIME, now);
+  }
+
+  @VisibleForTesting
+  ScheduleConfig(
+      Config rc,
+      String section,
+      String subsection,
+      String keyInterval,
+      String keyStartTime,
+      ZonedDateTime now) {
+    this.rc = rc;
+    this.section = section;
+    this.subsection = subsection;
+    this.keyInterval = keyInterval;
+    this.keyStartTime = keyStartTime;
+    this.interval = interval(rc, section, subsection, keyInterval);
+    if (interval > 0) {
+      this.initialDelay = initialDelay(rc, section, subsection, keyStartTime, now, interval);
+    } else {
+      this.initialDelay = interval;
+    }
+  }
+
+  /**
+   * Milliseconds between constructor invocation and first event time.
+   *
+   * <p>If there is any lag between the constructor invocation and queuing the object into an
+   * executor the event will run later, as there is no method to adjust for the scheduling delay.
+   */
+  public long getInitialDelay() {
+    return initialDelay;
+  }
+
+  /** Number of milliseconds between events. */
+  public long getInterval() {
+    return interval;
+  }
+
+  private static long interval(Config rc, String section, String subsection, String keyInterval) {
+    long interval = MISSING_CONFIG;
+    try {
+      interval =
+          ConfigUtil.getTimeUnit(rc, section, subsection, keyInterval, -1, TimeUnit.MILLISECONDS);
+      if (interval == MISSING_CONFIG) {
+        log.info(
+            MessageFormat.format(
+                "{0} schedule parameter \"{0}.{1}\" is not configured", section, keyInterval));
+      }
+    } catch (IllegalArgumentException e) {
+      log.error(
+          MessageFormat.format("Invalid {0} schedule parameter \"{0}.{1}\"", section, keyInterval),
+          e);
+      interval = INVALID_CONFIG;
+    }
+    return interval;
+  }
+
+  private static long initialDelay(
+      Config rc,
+      String section,
+      String subsection,
+      String keyStartTime,
+      ZonedDateTime now,
+      long interval) {
+    long delay = MISSING_CONFIG;
+    String start = rc.getString(section, subsection, keyStartTime);
+    try {
+      if (start != null) {
+        DateTimeFormatter formatter =
+            DateTimeFormatter.ofPattern("[E ]HH:mm").withLocale(Locale.US);
+        LocalTime firstStartTime = LocalTime.parse(start, formatter);
+        ZonedDateTime startTime = now.with(firstStartTime);
+        try {
+          DayOfWeek dayOfWeek = formatter.parse(start, DayOfWeek::from);
+          startTime = startTime.with(dayOfWeek);
+        } catch (DateTimeParseException ignored) {
+          // Day of week is an optional parameter.
+        }
+        startTime = startTime.truncatedTo(ChronoUnit.MINUTES);
+        delay = Duration.between(now, startTime).toMillis() % interval;
+        if (delay <= 0) {
+          delay += interval;
+        }
+      } else {
+        log.info(
+            MessageFormat.format(
+                "{0} schedule parameter \"{0}.{1}\" is not configured", section, keyStartTime));
+      }
+    } catch (IllegalArgumentException e2) {
+      log.error(
+          MessageFormat.format("Invalid {0} schedule parameter \"{0}.{1}\"", section, keyStartTime),
+          e2);
+      delay = INVALID_CONFIG;
+    }
+    return delay;
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder b = new StringBuilder();
+    b.append(formatValue(keyInterval));
+    b.append(", ");
+    b.append(formatValue(keyStartTime));
+    return b.toString();
+  }
+
+  private String formatValue(String key) {
+    StringBuilder b = new StringBuilder();
+    b.append(section);
+    if (subsection != null) {
+      b.append(".");
+      b.append(subsection);
+    }
+    b.append(".");
+    b.append(key);
+    String value = rc.getString(section, subsection, key);
+    if (value != null) {
+      b.append(" = ");
+      b.append(value);
+    } else {
+      b.append(": NA");
+    }
+    return b.toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePath.java b/java/com/google/gerrit/server/config/SitePath.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/SitePath.java
rename to java/com/google/gerrit/server/config/SitePath.java
diff --git a/java/com/google/gerrit/server/config/SitePaths.java b/java/com/google/gerrit/server/config/SitePaths.java
new file mode 100644
index 0000000..11ec50c
--- /dev/null
+++ b/java/com/google/gerrit/server/config/SitePaths.java
@@ -0,0 +1,150 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.common.collect.Iterables;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+
+/** Important paths within a {@link SitePath}. */
+@Singleton
+public final class SitePaths {
+  public static final String CSS_FILENAME = "GerritSite.css";
+  public static final String HEADER_FILENAME = "GerritSiteHeader.html";
+  public static final String FOOTER_FILENAME = "GerritSiteFooter.html";
+  public static final String THEME_FILENAME = "gerrit-theme.html";
+
+  public final Path site_path;
+  public final Path bin_dir;
+  public final Path etc_dir;
+  public final Path lib_dir;
+  public final Path tmp_dir;
+  public final Path logs_dir;
+  public final Path plugins_dir;
+  public final Path db_dir;
+  public final Path data_dir;
+  public final Path mail_dir;
+  public final Path hooks_dir;
+  public final Path static_dir;
+  public final Path themes_dir;
+  public final Path index_dir;
+
+  public final Path gerrit_sh;
+  public final Path gerrit_service;
+  public final Path gerrit_socket;
+  public final Path gerrit_war;
+
+  public final Path gerrit_config;
+  public final Path secure_config;
+  public final Path notedb_config;
+
+  public final Path ssl_keystore;
+  public final Path ssh_key;
+  public final Path ssh_rsa;
+  public final Path ssh_ecdsa_256;
+  public final Path ssh_ecdsa_384;
+  public final Path ssh_ecdsa_521;
+  public final Path ssh_ed25519;
+  public final Path peer_keys;
+
+  public final Path site_css;
+  public final Path site_header;
+  public final Path site_footer;
+  // For PolyGerrit UI only.
+  public final Path site_theme;
+  public final Path site_gitweb;
+
+  /** {@code true} if {@link #site_path} has not been initialized. */
+  public final boolean isNew;
+
+  @Inject
+  public SitePaths(@SitePath Path sitePath) throws IOException {
+    site_path = sitePath;
+    Path p = sitePath;
+
+    bin_dir = p.resolve("bin");
+    etc_dir = p.resolve("etc");
+    lib_dir = p.resolve("lib");
+    tmp_dir = p.resolve("tmp");
+    plugins_dir = p.resolve("plugins");
+    db_dir = p.resolve("db");
+    data_dir = p.resolve("data");
+    logs_dir = p.resolve("logs");
+    mail_dir = etc_dir.resolve("mail");
+    hooks_dir = p.resolve("hooks");
+    static_dir = p.resolve("static");
+    themes_dir = p.resolve("themes");
+    index_dir = p.resolve("index");
+
+    gerrit_sh = bin_dir.resolve("gerrit.sh");
+    gerrit_service = bin_dir.resolve("gerrit.service");
+    gerrit_socket = bin_dir.resolve("gerrit.socket");
+    gerrit_war = bin_dir.resolve("gerrit.war");
+
+    gerrit_config = etc_dir.resolve("gerrit.config");
+    secure_config = etc_dir.resolve("secure.config");
+    notedb_config = etc_dir.resolve("notedb.config");
+
+    ssl_keystore = etc_dir.resolve("keystore");
+    ssh_key = etc_dir.resolve("ssh_host_key");
+    ssh_rsa = etc_dir.resolve("ssh_host_rsa_key");
+    ssh_ecdsa_256 = etc_dir.resolve("ssh_host_ecdsa_key");
+    ssh_ecdsa_384 = etc_dir.resolve("ssh_host_ecdsa_384_key");
+    ssh_ecdsa_521 = etc_dir.resolve("ssh_host_ecdsa_521_key");
+    ssh_ed25519 = etc_dir.resolve("ssh_host_ed25519_key");
+    peer_keys = etc_dir.resolve("peer_keys");
+
+    site_css = etc_dir.resolve(CSS_FILENAME);
+    site_header = etc_dir.resolve(HEADER_FILENAME);
+    site_footer = etc_dir.resolve(FOOTER_FILENAME);
+    site_gitweb = etc_dir.resolve("gitweb_config.perl");
+
+    // For PolyGerrit UI.
+    site_theme = static_dir.resolve(THEME_FILENAME);
+
+    boolean isNew;
+    try (DirectoryStream<Path> files = Files.newDirectoryStream(site_path)) {
+      isNew = Iterables.isEmpty(files);
+    } catch (NoSuchFileException e) {
+      isNew = true;
+    }
+    this.isNew = isNew;
+  }
+
+  /**
+   * Resolve an absolute or relative path.
+   *
+   * <p>Relative paths are resolved relative to the {@link #site_path}.
+   *
+   * @param path the path string to resolve. May be null.
+   * @return the resolved path; null if {@code path} was null or empty.
+   */
+  public Path resolve(String path) {
+    if (path != null && !path.isEmpty()) {
+      Path loc = site_path.resolve(path).normalize();
+      try {
+        return loc.toRealPath();
+      } catch (IOException e) {
+        return loc.toAbsolutePath();
+      }
+    }
+    return null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TaskResource.java b/java/com/google/gerrit/server/config/TaskResource.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/TaskResource.java
rename to java/com/google/gerrit/server/config/TaskResource.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ThreadSettingsConfig.java b/java/com/google/gerrit/server/config/ThreadSettingsConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/ThreadSettingsConfig.java
rename to java/com/google/gerrit/server/config/ThreadSettingsConfig.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TopMenuResource.java b/java/com/google/gerrit/server/config/TopMenuResource.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/TopMenuResource.java
rename to java/com/google/gerrit/server/config/TopMenuResource.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooter.java b/java/com/google/gerrit/server/config/TrackingFooter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooter.java
rename to java/com/google/gerrit/server/config/TrackingFooter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooters.java b/java/com/google/gerrit/server/config/TrackingFooters.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooters.java
rename to java/com/google/gerrit/server/config/TrackingFooters.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFootersProvider.java b/java/com/google/gerrit/server/config/TrackingFootersProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFootersProvider.java
rename to java/com/google/gerrit/server/config/TrackingFootersProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/VerboseSuperprojectUpdate.java b/java/com/google/gerrit/server/config/VerboseSuperprojectUpdate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/config/VerboseSuperprojectUpdate.java
rename to java/com/google/gerrit/server/config/VerboseSuperprojectUpdate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/AccountAttribute.java b/java/com/google/gerrit/server/data/AccountAttribute.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/data/AccountAttribute.java
rename to java/com/google/gerrit/server/data/AccountAttribute.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/ApprovalAttribute.java b/java/com/google/gerrit/server/data/ApprovalAttribute.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/data/ApprovalAttribute.java
rename to java/com/google/gerrit/server/data/ApprovalAttribute.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java b/java/com/google/gerrit/server/data/ChangeAttribute.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
rename to java/com/google/gerrit/server/data/ChangeAttribute.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/DependencyAttribute.java b/java/com/google/gerrit/server/data/DependencyAttribute.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/data/DependencyAttribute.java
rename to java/com/google/gerrit/server/data/DependencyAttribute.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/MessageAttribute.java b/java/com/google/gerrit/server/data/MessageAttribute.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/data/MessageAttribute.java
rename to java/com/google/gerrit/server/data/MessageAttribute.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchAttribute.java b/java/com/google/gerrit/server/data/PatchAttribute.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/data/PatchAttribute.java
rename to java/com/google/gerrit/server/data/PatchAttribute.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java b/java/com/google/gerrit/server/data/PatchSetAttribute.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java
rename to java/com/google/gerrit/server/data/PatchSetAttribute.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetCommentAttribute.java b/java/com/google/gerrit/server/data/PatchSetCommentAttribute.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetCommentAttribute.java
rename to java/com/google/gerrit/server/data/PatchSetCommentAttribute.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/QueryStatsAttribute.java b/java/com/google/gerrit/server/data/QueryStatsAttribute.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/data/QueryStatsAttribute.java
rename to java/com/google/gerrit/server/data/QueryStatsAttribute.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/RefUpdateAttribute.java b/java/com/google/gerrit/server/data/RefUpdateAttribute.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/data/RefUpdateAttribute.java
rename to java/com/google/gerrit/server/data/RefUpdateAttribute.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/SubmitLabelAttribute.java b/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
rename to java/com/google/gerrit/server/data/SubmitLabelAttribute.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/SubmitRecordAttribute.java b/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
rename to java/com/google/gerrit/server/data/SubmitRecordAttribute.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/TrackingIdAttribute.java b/java/com/google/gerrit/server/data/TrackingIdAttribute.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/data/TrackingIdAttribute.java
rename to java/com/google/gerrit/server/data/TrackingIdAttribute.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/documentation/Constants.java b/java/com/google/gerrit/server/documentation/Constants.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/documentation/Constants.java
rename to java/com/google/gerrit/server/documentation/Constants.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
rename to java/com/google/gerrit/server/documentation/MarkdownFormatter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
rename to java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java b/java/com/google/gerrit/server/edit/ChangeEdit.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java
rename to java/com/google/gerrit/server/edit/ChangeEdit.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java b/java/com/google/gerrit/server/edit/ChangeEditJson.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java
rename to java/com/google/gerrit/server/edit/ChangeEditJson.java
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
new file mode 100644
index 0000000..64f5ae7
--- /dev/null
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -0,0 +1,622 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.edit;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.RawInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
+import com.google.gerrit.server.edit.tree.DeleteFileModification;
+import com.google.gerrit.server.edit.tree.RenameFileModification;
+import com.google.gerrit.server.edit.tree.RestoreFileModification;
+import com.google.gerrit.server.edit.tree.TreeCreator;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.util.CommitMessageUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Optional;
+import java.util.TimeZone;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.MergeStrategy;
+import org.eclipse.jgit.merge.ThreeWayMerger;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * Utility functions to manipulate change edits.
+ *
+ * <p>This class contains methods to modify edit's content. For retrieving, publishing and deleting
+ * edit see {@link ChangeEditUtil}.
+ *
+ * <p>
+ */
+@Singleton
+public class ChangeEditModifier {
+
+  private final TimeZone tz;
+  private final ChangeIndexer indexer;
+  private final Provider<ReviewDb> reviewDb;
+  private final Provider<CurrentUser> currentUser;
+  private final PermissionBackend permissionBackend;
+  private final ChangeEditUtil changeEditUtil;
+  private final PatchSetUtil patchSetUtil;
+  private final ProjectCache projectCache;
+
+  @Inject
+  ChangeEditModifier(
+      @GerritPersonIdent PersonIdent gerritIdent,
+      ChangeIndexer indexer,
+      Provider<ReviewDb> reviewDb,
+      Provider<CurrentUser> currentUser,
+      PermissionBackend permissionBackend,
+      ChangeEditUtil changeEditUtil,
+      PatchSetUtil patchSetUtil,
+      ProjectCache projectCache) {
+    this.indexer = indexer;
+    this.reviewDb = reviewDb;
+    this.currentUser = currentUser;
+    this.permissionBackend = permissionBackend;
+    this.tz = gerritIdent.getTimeZone();
+    this.changeEditUtil = changeEditUtil;
+    this.patchSetUtil = patchSetUtil;
+    this.projectCache = projectCache;
+  }
+
+  /**
+   * Creates a new change edit.
+   *
+   * @param repository the affected Git repository
+   * @param notes the {@link ChangeNotes} of the change for which the change edit should be created
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if a change edit already existed for the change
+   * @throws PermissionBackendException
+   */
+  public void createEdit(Repository repository, ChangeNotes notes)
+      throws AuthException, IOException, InvalidChangeOperationException, OrmException,
+          PermissionBackendException, ResourceConflictException {
+    assertCanEdit(notes);
+
+    Optional<ChangeEdit> changeEdit = lookupChangeEdit(notes);
+    if (changeEdit.isPresent()) {
+      throw new InvalidChangeOperationException(
+          String.format("A change edit already exists for change %s", notes.getChangeId()));
+    }
+
+    PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
+    ObjectId patchSetCommitId = getPatchSetCommitId(currentPatchSet);
+    createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
+  }
+
+  /**
+   * Rebase change edit on latest patch set
+   *
+   * @param repository the affected Git repository
+   * @param notes the {@link ChangeNotes} of the change whose change edit should be rebased
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if a change edit doesn't exist for the specified
+   *     change, the change edit is already based on the latest patch set, or the change represents
+   *     the root commit
+   * @throws MergeConflictException if rebase fails due to merge conflicts
+   * @throws PermissionBackendException
+   */
+  public void rebaseEdit(Repository repository, ChangeNotes notes)
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+          MergeConflictException, PermissionBackendException, ResourceConflictException {
+    assertCanEdit(notes);
+
+    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
+    if (!optionalChangeEdit.isPresent()) {
+      throw new InvalidChangeOperationException(
+          String.format("No change edit exists for change %s", notes.getChangeId()));
+    }
+    ChangeEdit changeEdit = optionalChangeEdit.get();
+
+    PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
+    if (isBasedOn(changeEdit, currentPatchSet)) {
+      throw new InvalidChangeOperationException(
+          String.format(
+              "Change edit for change %s is already based on latest patch set %s",
+              notes.getChangeId(), currentPatchSet.getId()));
+    }
+
+    rebase(repository, changeEdit, currentPatchSet);
+  }
+
+  private void rebase(Repository repository, ChangeEdit changeEdit, PatchSet currentPatchSet)
+      throws IOException, MergeConflictException, InvalidChangeOperationException, OrmException {
+    RevCommit currentEditCommit = changeEdit.getEditCommit();
+    if (currentEditCommit.getParentCount() == 0) {
+      throw new InvalidChangeOperationException(
+          "Rebase change edit against root commit not supported");
+    }
+
+    Change change = changeEdit.getChange();
+    RevCommit basePatchSetCommit = lookupCommit(repository, currentPatchSet);
+    RevTree basePatchSetTree = basePatchSetCommit.getTree();
+
+    ObjectId newTreeId = merge(repository, changeEdit, basePatchSetTree);
+    Timestamp nowTimestamp = TimeUtil.nowTs();
+    String commitMessage = currentEditCommit.getFullMessage();
+    ObjectId newEditCommitId =
+        createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
+
+    String newEditRefName = getEditRefName(change, currentPatchSet);
+    updateReferenceWithNameChange(
+        repository,
+        changeEdit.getRefName(),
+        currentEditCommit,
+        newEditRefName,
+        newEditCommitId,
+        nowTimestamp);
+    reindex(change);
+  }
+
+  /**
+   * Modifies the commit message of a change edit. If the change edit doesn't exist, a new one will
+   * be created based on the current patch set.
+   *
+   * @param repository the affected Git repository
+   * @param notes the {@link ChangeNotes} of the change whose change edit's message should be
+   *     modified
+   * @param newCommitMessage the new commit message
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws UnchangedCommitMessageException if the commit message is the same as before
+   * @throws PermissionBackendException
+   * @throws BadRequestException if the commit message is malformed
+   */
+  public void modifyMessage(Repository repository, ChangeNotes notes, String newCommitMessage)
+      throws AuthException, IOException, UnchangedCommitMessageException, OrmException,
+          PermissionBackendException, BadRequestException, ResourceConflictException {
+    assertCanEdit(notes);
+    newCommitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(newCommitMessage);
+
+    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
+    PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, notes);
+    RevCommit basePatchSetCommit = lookupCommit(repository, basePatchSet);
+    RevCommit baseCommit =
+        optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(basePatchSetCommit);
+
+    String currentCommitMessage = baseCommit.getFullMessage();
+    if (newCommitMessage.equals(currentCommitMessage)) {
+      throw new UnchangedCommitMessageException();
+    }
+
+    RevTree baseTree = baseCommit.getTree();
+    Timestamp nowTimestamp = TimeUtil.nowTs();
+    ObjectId newEditCommit =
+        createCommit(repository, basePatchSetCommit, baseTree, newCommitMessage, nowTimestamp);
+
+    if (optionalChangeEdit.isPresent()) {
+      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+    } else {
+      createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
+    }
+  }
+
+  /**
+   * Modifies the contents of a file of a change edit. If the change edit doesn't exist, a new one
+   * will be created based on the current patch set.
+   *
+   * @param repository the affected Git repository
+   * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
+   * @param filePath the path of the file whose contents should be modified
+   * @param newContent the new file content
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if the file already had the specified content
+   * @throws PermissionBackendException
+   * @throws ResourceConflictException if the project state does not permit the operation
+   */
+  public void modifyFile(
+      Repository repository, ChangeNotes notes, String filePath, RawInput newContent)
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+          PermissionBackendException, ResourceConflictException {
+    modifyTree(repository, notes, new ChangeFileContentModification(filePath, newContent));
+  }
+
+  /**
+   * Deletes a file from the Git tree of a change edit. If the change edit doesn't exist, a new one
+   * will be created based on the current patch set.
+   *
+   * @param repository the affected Git repository
+   * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
+   * @param file path of the file which should be deleted
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if the file does not exist
+   * @throws PermissionBackendException
+   * @throws ResourceConflictException if the project state does not permit the operation
+   */
+  public void deleteFile(Repository repository, ChangeNotes notes, String file)
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+          PermissionBackendException, ResourceConflictException {
+    modifyTree(repository, notes, new DeleteFileModification(file));
+  }
+
+  /**
+   * Renames a file of a change edit or moves it to another directory. If the change edit doesn't
+   * exist, a new one will be created based on the current patch set.
+   *
+   * @param repository the affected Git repository
+   * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
+   * @param currentFilePath the current path/name of the file
+   * @param newFilePath the desired path/name of the file
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if the file was already renamed to the specified new
+   *     name
+   * @throws PermissionBackendException
+   * @throws ResourceConflictException if the project state does not permit the operation
+   */
+  public void renameFile(
+      Repository repository, ChangeNotes notes, String currentFilePath, String newFilePath)
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+          PermissionBackendException, ResourceConflictException {
+    modifyTree(repository, notes, new RenameFileModification(currentFilePath, newFilePath));
+  }
+
+  /**
+   * Restores a file of a change edit to the state it was in before the patch set on which the
+   * change edit is based. If the change edit doesn't exist, a new one will be created based on the
+   * current patch set.
+   *
+   * @param repository the affected Git repository
+   * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
+   * @param file the path of the file which should be restored
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if the file was already restored
+   * @throws PermissionBackendException
+   */
+  public void restoreFile(Repository repository, ChangeNotes notes, String file)
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+          PermissionBackendException, ResourceConflictException {
+    modifyTree(repository, notes, new RestoreFileModification(file));
+  }
+
+  private void modifyTree(
+      Repository repository, ChangeNotes notes, TreeModification treeModification)
+      throws AuthException, IOException, OrmException, InvalidChangeOperationException,
+          PermissionBackendException, ResourceConflictException {
+    assertCanEdit(notes);
+
+    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
+    PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, notes);
+    RevCommit basePatchSetCommit = lookupCommit(repository, basePatchSet);
+    RevCommit baseCommit =
+        optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(basePatchSetCommit);
+
+    ObjectId newTreeId = createNewTree(repository, baseCommit, ImmutableList.of(treeModification));
+
+    String commitMessage = baseCommit.getFullMessage();
+    Timestamp nowTimestamp = TimeUtil.nowTs();
+    ObjectId newEditCommit =
+        createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
+
+    if (optionalChangeEdit.isPresent()) {
+      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+    } else {
+      createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
+    }
+  }
+
+  /**
+   * Applies the indicated modifications to the specified patch set. If a change edit exists and is
+   * based on the same patch set, the modified patch set tree is merged with the change edit. If the
+   * change edit doesn't exist, a new one will be created.
+   *
+   * @param repository the affected Git repository
+   * @param notes the {@link ChangeNotes} of the change to which the patch set belongs
+   * @param patchSet the {@code PatchSet} which should be modified
+   * @param treeModifications the modifications which should be applied
+   * @return the resulting {@code ChangeEdit}
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if the existing change edit is based on another patch
+   *     set or no change edit exists but the specified patch set isn't the current one
+   * @throws MergeConflictException if the modified patch set tree can't be merged with an existing
+   *     change edit
+   */
+  public ChangeEdit combineWithModifiedPatchSetTree(
+      Repository repository,
+      ChangeNotes notes,
+      PatchSet patchSet,
+      List<TreeModification> treeModifications)
+      throws AuthException, IOException, InvalidChangeOperationException, MergeConflictException,
+          OrmException, PermissionBackendException, ResourceConflictException {
+    assertCanEdit(notes);
+
+    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
+    ensureAllowedPatchSet(notes, optionalChangeEdit, patchSet);
+
+    RevCommit patchSetCommit = lookupCommit(repository, patchSet);
+    ObjectId newTreeId = createNewTree(repository, patchSetCommit, treeModifications);
+
+    if (optionalChangeEdit.isPresent()) {
+      ChangeEdit changeEdit = optionalChangeEdit.get();
+      newTreeId = merge(repository, changeEdit, newTreeId);
+      if (ObjectId.equals(newTreeId, changeEdit.getEditCommit().getTree())) {
+        // Modifications are already contained in the change edit.
+        return changeEdit;
+      }
+    }
+
+    String commitMessage =
+        optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(patchSetCommit).getFullMessage();
+    Timestamp nowTimestamp = TimeUtil.nowTs();
+    ObjectId newEditCommit =
+        createCommit(repository, patchSetCommit, newTreeId, commitMessage, nowTimestamp);
+
+    if (optionalChangeEdit.isPresent()) {
+      return updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+    }
+    return createEdit(repository, notes, patchSet, newEditCommit, nowTimestamp);
+  }
+
+  private void assertCanEdit(ChangeNotes notes)
+      throws AuthException, PermissionBackendException, IOException, ResourceConflictException {
+    if (!currentUser.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    try {
+      permissionBackend
+          .user(currentUser)
+          .database(reviewDb)
+          .change(notes)
+          .check(ChangePermission.ADD_PATCH_SET);
+      projectCache.checkedGet(notes.getProjectName()).checkStatePermitsWrite();
+    } catch (AuthException denied) {
+      throw new AuthException("edit not permitted", denied);
+    }
+  }
+
+  private static void ensureAllowedPatchSet(
+      ChangeNotes notes, Optional<ChangeEdit> optionalChangeEdit, PatchSet patchSet)
+      throws InvalidChangeOperationException {
+    if (optionalChangeEdit.isPresent()) {
+      ChangeEdit changeEdit = optionalChangeEdit.get();
+      if (!isBasedOn(changeEdit, patchSet)) {
+        throw new InvalidChangeOperationException(
+            String.format(
+                "Only the patch set %s on which the existing change edit is based may be modified "
+                    + "(specified patch set: %s)",
+                changeEdit.getBasePatchSet().getId(), patchSet.getId()));
+      }
+    } else {
+      PatchSet.Id patchSetId = patchSet.getId();
+      PatchSet.Id currentPatchSetId = notes.getChange().currentPatchSetId();
+      if (!patchSetId.equals(currentPatchSetId)) {
+        throw new InvalidChangeOperationException(
+            String.format(
+                "A change edit may only be created for the current patch set %s (and not for %s)",
+                currentPatchSetId, patchSetId));
+      }
+    }
+  }
+
+  private Optional<ChangeEdit> lookupChangeEdit(ChangeNotes notes)
+      throws AuthException, IOException {
+    return changeEditUtil.byChange(notes);
+  }
+
+  private PatchSet getBasePatchSet(Optional<ChangeEdit> optionalChangeEdit, ChangeNotes notes)
+      throws OrmException {
+    Optional<PatchSet> editBasePatchSet = optionalChangeEdit.map(ChangeEdit::getBasePatchSet);
+    return editBasePatchSet.isPresent() ? editBasePatchSet.get() : lookupCurrentPatchSet(notes);
+  }
+
+  private PatchSet lookupCurrentPatchSet(ChangeNotes notes) throws OrmException {
+    return patchSetUtil.current(reviewDb.get(), notes);
+  }
+
+  private static boolean isBasedOn(ChangeEdit changeEdit, PatchSet patchSet) {
+    PatchSet editBasePatchSet = changeEdit.getBasePatchSet();
+    return editBasePatchSet.getId().equals(patchSet.getId());
+  }
+
+  private static RevCommit lookupCommit(Repository repository, PatchSet patchSet)
+      throws IOException {
+    ObjectId patchSetCommitId = getPatchSetCommitId(patchSet);
+    return lookupCommit(repository, patchSetCommitId);
+  }
+
+  private static RevCommit lookupCommit(Repository repository, ObjectId commitId)
+      throws IOException {
+    try (RevWalk revWalk = new RevWalk(repository)) {
+      return revWalk.parseCommit(commitId);
+    }
+  }
+
+  private static ObjectId createNewTree(
+      Repository repository, RevCommit baseCommit, List<TreeModification> treeModifications)
+      throws IOException, InvalidChangeOperationException {
+    TreeCreator treeCreator = new TreeCreator(baseCommit);
+    treeCreator.addTreeModifications(treeModifications);
+    ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+    if (ObjectId.equals(newTreeId, baseCommit.getTree())) {
+      throw new InvalidChangeOperationException("no changes were made");
+    }
+    return newTreeId;
+  }
+
+  private static ObjectId merge(Repository repository, ChangeEdit changeEdit, ObjectId newTreeId)
+      throws IOException, MergeConflictException {
+    PatchSet basePatchSet = changeEdit.getBasePatchSet();
+    ObjectId basePatchSetCommitId = getPatchSetCommitId(basePatchSet);
+    ObjectId editCommitId = changeEdit.getEditCommit();
+
+    ThreeWayMerger threeWayMerger = MergeStrategy.RESOLVE.newMerger(repository, true);
+    threeWayMerger.setBase(basePatchSetCommitId);
+    boolean successful = threeWayMerger.merge(newTreeId, editCommitId);
+
+    if (!successful) {
+      throw new MergeConflictException(
+          "The existing change edit could not be merged with another tree.");
+    }
+    return threeWayMerger.getResultTreeId();
+  }
+
+  private ObjectId createCommit(
+      Repository repository,
+      RevCommit basePatchSetCommit,
+      ObjectId tree,
+      String commitMessage,
+      Timestamp timestamp)
+      throws IOException {
+    try (ObjectInserter objectInserter = repository.newObjectInserter()) {
+      CommitBuilder builder = new CommitBuilder();
+      builder.setTreeId(tree);
+      builder.setParentIds(basePatchSetCommit.getParents());
+      builder.setAuthor(basePatchSetCommit.getAuthorIdent());
+      builder.setCommitter(getCommitterIdent(timestamp));
+      builder.setMessage(commitMessage);
+      ObjectId newCommitId = objectInserter.insert(builder);
+      objectInserter.flush();
+      return newCommitId;
+    }
+  }
+
+  private PersonIdent getCommitterIdent(Timestamp commitTimestamp) {
+    IdentifiedUser user = currentUser.get().asIdentifiedUser();
+    return user.newCommitterIdent(commitTimestamp, tz);
+  }
+
+  private static ObjectId getPatchSetCommitId(PatchSet patchSet) {
+    return ObjectId.fromString(patchSet.getRevision().get());
+  }
+
+  private ChangeEdit createEdit(
+      Repository repository,
+      ChangeNotes notes,
+      PatchSet basePatchSet,
+      ObjectId newEditCommitId,
+      Timestamp timestamp)
+      throws IOException, OrmException {
+    Change change = notes.getChange();
+    String editRefName = getEditRefName(change, basePatchSet);
+    updateReference(repository, editRefName, ObjectId.zeroId(), newEditCommitId, timestamp);
+    reindex(change);
+
+    RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
+    return new ChangeEdit(change, editRefName, newEditCommit, basePatchSet);
+  }
+
+  private String getEditRefName(Change change, PatchSet basePatchSet) {
+    IdentifiedUser me = currentUser.get().asIdentifiedUser();
+    return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchSet.getId());
+  }
+
+  private ChangeEdit updateEdit(
+      Repository repository, ChangeEdit changeEdit, ObjectId newEditCommitId, Timestamp timestamp)
+      throws IOException, OrmException {
+    String editRefName = changeEdit.getRefName();
+    RevCommit currentEditCommit = changeEdit.getEditCommit();
+    updateReference(repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
+    reindex(changeEdit.getChange());
+
+    RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
+    return new ChangeEdit(
+        changeEdit.getChange(), editRefName, newEditCommit, changeEdit.getBasePatchSet());
+  }
+
+  private void updateReference(
+      Repository repository,
+      String refName,
+      ObjectId currentObjectId,
+      ObjectId targetObjectId,
+      Timestamp timestamp)
+      throws IOException {
+    RefUpdate ru = repository.updateRef(refName);
+    ru.setExpectedOldObjectId(currentObjectId);
+    ru.setNewObjectId(targetObjectId);
+    ru.setRefLogIdent(getRefLogIdent(timestamp));
+    ru.setRefLogMessage("inline edit (amend)", false);
+    ru.setForceUpdate(true);
+    try (RevWalk revWalk = new RevWalk(repository)) {
+      RefUpdate.Result res = ru.update(revWalk);
+      if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
+        throw new IOException(
+            "cannot update "
+                + ru.getName()
+                + " in "
+                + repository.getDirectory()
+                + ": "
+                + ru.getResult());
+      }
+    }
+  }
+
+  private void updateReferenceWithNameChange(
+      Repository repository,
+      String currentRefName,
+      ObjectId currentObjectId,
+      String newRefName,
+      ObjectId targetObjectId,
+      Timestamp timestamp)
+      throws IOException {
+    BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
+    batchRefUpdate.addCommand(new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
+    batchRefUpdate.addCommand(
+        new ReceiveCommand(currentObjectId, ObjectId.zeroId(), currentRefName));
+    batchRefUpdate.setRefLogMessage("rebase edit", false);
+    batchRefUpdate.setRefLogIdent(getRefLogIdent(timestamp));
+    try (RevWalk revWalk = new RevWalk(repository)) {
+      batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
+    }
+    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        throw new IOException("failed: " + cmd);
+      }
+    }
+  }
+
+  private PersonIdent getRefLogIdent(Timestamp timestamp) {
+    IdentifiedUser user = currentUser.get().asIdentifiedUser();
+    return user.newRefLogIdent(timestamp, tz);
+  }
+
+  private void reindex(Change change) throws IOException, OrmException {
+    indexer.index(reviewDb.get(), change);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
rename to java/com/google/gerrit/server/edit/ChangeEditUtil.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/UnchangedCommitMessageException.java b/java/com/google/gerrit/server/edit/UnchangedCommitMessageException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/edit/UnchangedCommitMessageException.java
rename to java/com/google/gerrit/server/edit/UnchangedCommitMessageException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/AddPath.java b/java/com/google/gerrit/server/edit/tree/AddPath.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/AddPath.java
rename to java/com/google/gerrit/server/edit/tree/AddPath.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
rename to java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java b/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
rename to java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java b/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
rename to java/com/google/gerrit/server/edit/tree/RenameFileModification.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java b/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
rename to java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java
rename to java/com/google/gerrit/server/edit/tree/TreeCreator.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java b/java/com/google/gerrit/server/edit/tree/TreeModification.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java
rename to java/com/google/gerrit/server/edit/tree/TreeModification.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/AssigneeChangedEvent.java b/java/com/google/gerrit/server/events/AssigneeChangedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/AssigneeChangedEvent.java
rename to java/com/google/gerrit/server/events/AssigneeChangedEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeAbandonedEvent.java b/java/com/google/gerrit/server/events/ChangeAbandonedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeAbandonedEvent.java
rename to java/com/google/gerrit/server/events/ChangeAbandonedEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeEvent.java b/java/com/google/gerrit/server/events/ChangeEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeEvent.java
rename to java/com/google/gerrit/server/events/ChangeEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeMergedEvent.java b/java/com/google/gerrit/server/events/ChangeMergedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeMergedEvent.java
rename to java/com/google/gerrit/server/events/ChangeMergedEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeRestoredEvent.java b/java/com/google/gerrit/server/events/ChangeRestoredEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeRestoredEvent.java
rename to java/com/google/gerrit/server/events/ChangeRestoredEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/CommentAddedEvent.java b/java/com/google/gerrit/server/events/CommentAddedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/CommentAddedEvent.java
rename to java/com/google/gerrit/server/events/CommentAddedEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
rename to java/com/google/gerrit/server/events/CommitReceivedEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/Event.java b/java/com/google/gerrit/server/events/Event.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/Event.java
rename to java/com/google/gerrit/server/events/Event.java
diff --git a/java/com/google/gerrit/server/events/EventBroker.java b/java/com/google/gerrit/server/events/EventBroker.java
new file mode 100644
index 0000000..af96619
--- /dev/null
+++ b/java/com/google/gerrit/server/events/EventBroker.java
@@ -0,0 +1,209 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+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.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Distributes Events to listeners if they are allowed to see them */
+@Singleton
+public class EventBroker implements EventDispatcher {
+  private static final Logger log = LoggerFactory.getLogger(EventBroker.class);
+
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      DynamicItem.itemOf(binder(), EventDispatcher.class);
+      DynamicItem.bind(binder(), EventDispatcher.class).to(EventBroker.class);
+    }
+  }
+
+  /** Listeners to receive changes as they happen (limited by visibility of user). */
+  protected final DynamicSet<UserScopedEventListener> listeners;
+
+  /** Listeners to receive all changes as they happen. */
+  protected final DynamicSet<EventListener> unrestrictedListeners;
+
+  private final PermissionBackend permissionBackend;
+  protected final ProjectCache projectCache;
+
+  protected final ChangeNotes.Factory notesFactory;
+
+  protected final Provider<ReviewDb> dbProvider;
+
+  @Inject
+  public EventBroker(
+      DynamicSet<UserScopedEventListener> listeners,
+      DynamicSet<EventListener> unrestrictedListeners,
+      PermissionBackend permissionBackend,
+      ProjectCache projectCache,
+      ChangeNotes.Factory notesFactory,
+      Provider<ReviewDb> dbProvider) {
+    this.listeners = listeners;
+    this.unrestrictedListeners = unrestrictedListeners;
+    this.permissionBackend = permissionBackend;
+    this.projectCache = projectCache;
+    this.notesFactory = notesFactory;
+    this.dbProvider = dbProvider;
+  }
+
+  @Override
+  public void postEvent(Change change, ChangeEvent event)
+      throws OrmException, PermissionBackendException {
+    fireEvent(change, event);
+  }
+
+  @Override
+  public void postEvent(Branch.NameKey branchName, RefEvent event)
+      throws PermissionBackendException {
+    fireEvent(branchName, event);
+  }
+
+  @Override
+  public void postEvent(Project.NameKey projectName, ProjectEvent event) {
+    fireEvent(projectName, event);
+  }
+
+  @Override
+  public void postEvent(Event event) throws OrmException, PermissionBackendException {
+    fireEvent(event);
+  }
+
+  protected void fireEventForUnrestrictedListeners(Event event) {
+    for (EventListener listener : unrestrictedListeners) {
+      listener.onEvent(event);
+    }
+  }
+
+  protected void fireEvent(Change change, ChangeEvent event)
+      throws OrmException, PermissionBackendException {
+    for (UserScopedEventListener listener : listeners) {
+      if (isVisibleTo(change, listener.getUser())) {
+        listener.onEvent(event);
+      }
+    }
+    fireEventForUnrestrictedListeners(event);
+  }
+
+  protected void fireEvent(Project.NameKey project, ProjectEvent event) {
+    for (UserScopedEventListener listener : listeners) {
+      if (isVisibleTo(project, listener.getUser())) {
+        listener.onEvent(event);
+      }
+    }
+    fireEventForUnrestrictedListeners(event);
+  }
+
+  protected void fireEvent(Branch.NameKey branchName, RefEvent event)
+      throws PermissionBackendException {
+    for (UserScopedEventListener listener : listeners) {
+      if (isVisibleTo(branchName, listener.getUser())) {
+        listener.onEvent(event);
+      }
+    }
+    fireEventForUnrestrictedListeners(event);
+  }
+
+  protected void fireEvent(Event event) throws OrmException, PermissionBackendException {
+    for (UserScopedEventListener listener : listeners) {
+      if (isVisibleTo(event, listener.getUser())) {
+        listener.onEvent(event);
+      }
+    }
+    fireEventForUnrestrictedListeners(event);
+  }
+
+  protected boolean isVisibleTo(Project.NameKey project, CurrentUser user) {
+    try {
+      permissionBackend.user(user).project(project).check(ProjectPermission.ACCESS);
+      return true;
+    } catch (AuthException | PermissionBackendException e) {
+      return false;
+    }
+  }
+
+  protected boolean isVisibleTo(Change change, CurrentUser user)
+      throws OrmException, PermissionBackendException {
+    if (change == null) {
+      return false;
+    }
+    ProjectState pe = projectCache.get(change.getProject());
+    if (pe == null) {
+      return false;
+    }
+    ReviewDb db = dbProvider.get();
+    return permissionBackend
+        .user(user)
+        .change(notesFactory.createChecked(db, change))
+        .database(db)
+        .test(ChangePermission.READ);
+  }
+
+  protected boolean isVisibleTo(Branch.NameKey branchName, CurrentUser user)
+      throws PermissionBackendException {
+    ProjectState pe = projectCache.get(branchName.getParentKey());
+    if (pe == null) {
+      return false;
+    }
+    return permissionBackend.user(user).ref(branchName).test(RefPermission.READ);
+  }
+
+  protected boolean isVisibleTo(Event event, CurrentUser user)
+      throws OrmException, PermissionBackendException {
+    if (event instanceof RefEvent) {
+      RefEvent refEvent = (RefEvent) event;
+      String ref = refEvent.getRefName();
+      if (PatchSet.isChangeRef(ref)) {
+        Change.Id cid = PatchSet.Id.fromRef(ref).getParentKey();
+        try {
+          Change change =
+              notesFactory
+                  .createChecked(dbProvider.get(), refEvent.getProjectNameKey(), cid)
+                  .getChange();
+          return isVisibleTo(change, user);
+        } catch (NoSuchChangeException e) {
+          log.debug("Change {} cannot be found, falling back on ref visibility check", cid.id);
+        }
+      }
+      return isVisibleTo(refEvent.getBranchNameKey(), user);
+    } else if (event instanceof ProjectEvent) {
+      return isVisibleTo(((ProjectEvent) event).getProjectNameKey(), user);
+    }
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventDeserializer.java b/java/com/google/gerrit/server/events/EventDeserializer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/EventDeserializer.java
rename to java/com/google/gerrit/server/events/EventDeserializer.java
diff --git a/java/com/google/gerrit/server/events/EventDispatcher.java b/java/com/google/gerrit/server/events/EventDispatcher.java
new file mode 100644
index 0000000..cbf547e
--- /dev/null
+++ b/java/com/google/gerrit/server/events/EventDispatcher.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+
+/** Interface for posting (dispatching) Events */
+public interface EventDispatcher {
+  /**
+   * Post a stream event that is related to a change
+   *
+   * @param change The change that the event is related to
+   * @param event The event to post
+   * @throws OrmException on failure to post the event due to DB error
+   * @throws PermissionBackendException on failure of permission checks
+   */
+  void postEvent(Change change, ChangeEvent event) throws OrmException, PermissionBackendException;
+
+  /**
+   * Post a stream event that is related to a branch
+   *
+   * @param branchName The branch that the event is related to
+   * @param event The event to post
+   * @throws PermissionBackendException on failure of permission checks
+   */
+  void postEvent(Branch.NameKey branchName, RefEvent event) throws PermissionBackendException;
+
+  /**
+   * Post a stream event that is related to a project.
+   *
+   * @param projectName The project that the event is related to.
+   * @param event The event to post.
+   */
+  void postEvent(Project.NameKey projectName, ProjectEvent event);
+
+  /**
+   * Post a stream event generically.
+   *
+   * <p>If you are creating a RefEvent or ChangeEvent from scratch, it is more efficient to use the
+   * specific postEvent methods for those use cases.
+   *
+   * @param event The event to post.
+   * @throws OrmException on failure to post the event due to DB error
+   * @throws PermissionBackendException on failure of permission checks
+   */
+  void postEvent(Event event) throws OrmException, PermissionBackendException;
+}
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
new file mode 100644
index 0000000..a292f9e
--- /dev/null
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -0,0 +1,661 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Comparator.comparing;
+
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.UserIdentity;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.data.AccountAttribute;
+import com.google.gerrit.server.data.ApprovalAttribute;
+import com.google.gerrit.server.data.ChangeAttribute;
+import com.google.gerrit.server.data.DependencyAttribute;
+import com.google.gerrit.server.data.MessageAttribute;
+import com.google.gerrit.server.data.PatchAttribute;
+import com.google.gerrit.server.data.PatchSetAttribute;
+import com.google.gerrit.server.data.PatchSetCommentAttribute;
+import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.data.SubmitLabelAttribute;
+import com.google.gerrit.server.data.SubmitRecordAttribute;
+import com.google.gerrit.server.data.TrackingIdAttribute;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.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;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class EventFactory {
+  private static final Logger log = LoggerFactory.getLogger(EventFactory.class);
+
+  private final AccountCache accountCache;
+  private final Emails emails;
+  private final Provider<String> urlProvider;
+  private final PatchListCache patchListCache;
+  private final PersonIdent myIdent;
+  private final ChangeData.Factory changeDataFactory;
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeKindCache changeKindCache;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final SchemaFactory<ReviewDb> schema;
+  private final IndexConfig indexConfig;
+
+  @Inject
+  EventFactory(
+      AccountCache accountCache,
+      Emails emails,
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      PatchListCache patchListCache,
+      @GerritPersonIdent PersonIdent myIdent,
+      ChangeData.Factory changeDataFactory,
+      ApprovalsUtil approvalsUtil,
+      ChangeKindCache changeKindCache,
+      Provider<InternalChangeQuery> queryProvider,
+      SchemaFactory<ReviewDb> schema,
+      IndexConfig indexConfig) {
+    this.accountCache = accountCache;
+    this.emails = emails;
+    this.urlProvider = urlProvider;
+    this.patchListCache = patchListCache;
+    this.myIdent = myIdent;
+    this.changeDataFactory = changeDataFactory;
+    this.approvalsUtil = approvalsUtil;
+    this.changeKindCache = changeKindCache;
+    this.queryProvider = queryProvider;
+    this.schema = schema;
+    this.indexConfig = indexConfig;
+  }
+
+  /**
+   * Create a ChangeAttribute for the given change suitable for serialization to JSON.
+   *
+   * @param change
+   * @return object suitable for serialization to JSON
+   */
+  public ChangeAttribute asChangeAttribute(Change change) {
+    try (ReviewDb db = schema.open()) {
+      return asChangeAttribute(db, change);
+    } catch (OrmException e) {
+      log.error("Cannot open database connection", e);
+      return new ChangeAttribute();
+    }
+  }
+
+  /**
+   * Create a ChangeAttribute for the given change suitable for serialization to JSON.
+   *
+   * @param db Review database
+   * @param change
+   * @return object suitable for serialization to JSON
+   */
+  public ChangeAttribute asChangeAttribute(ReviewDb db, Change change) {
+    ChangeAttribute a = new ChangeAttribute();
+    a.project = change.getProject().get();
+    a.branch = change.getDest().getShortName();
+    a.topic = change.getTopic();
+    a.id = change.getKey().get();
+    a.number = change.getId().get();
+    a.subject = change.getSubject();
+    try {
+      a.commitMessage = changeDataFactory.create(db, change).commitMessage();
+    } catch (Exception e) {
+      log.error("Error while getting full commit message for change " + a.number);
+    }
+    a.url = getChangeUrl(change);
+    a.owner = asAccountAttribute(change.getOwner());
+    a.assignee = asAccountAttribute(change.getAssignee());
+    a.status = change.getStatus();
+    a.createdOn = change.getCreatedOn().getTime() / 1000L;
+    a.wip = change.isWorkInProgress() ? true : null;
+    a.isPrivate = change.isPrivate() ? true : null;
+    return a;
+  }
+
+  /**
+   * Create a RefUpdateAttribute for the given old ObjectId, new ObjectId, and branch that is
+   * suitable for serialization to JSON.
+   *
+   * @param oldId
+   * @param newId
+   * @param refName
+   * @return object suitable for serialization to JSON
+   */
+  public RefUpdateAttribute asRefUpdateAttribute(
+      ObjectId oldId, ObjectId newId, Branch.NameKey refName) {
+    RefUpdateAttribute ru = new RefUpdateAttribute();
+    ru.newRev = newId != null ? newId.getName() : ObjectId.zeroId().getName();
+    ru.oldRev = oldId != null ? oldId.getName() : ObjectId.zeroId().getName();
+    ru.project = refName.getParentKey().get();
+    ru.refName = refName.get();
+    return ru;
+  }
+
+  /**
+   * Extend the existing ChangeAttribute with additional fields.
+   *
+   * @param a
+   * @param change
+   */
+  public void extend(ChangeAttribute a, Change change) {
+    a.lastUpdated = change.getLastUpdatedOn().getTime() / 1000L;
+    a.open = change.getStatus().isOpen();
+  }
+
+  /**
+   * Add allReviewers to an existing ChangeAttribute.
+   *
+   * @param a
+   * @param notes
+   */
+  public void addAllReviewers(ReviewDb db, ChangeAttribute a, ChangeNotes notes)
+      throws OrmException {
+    Collection<Account.Id> reviewers = approvalsUtil.getReviewers(db, notes).all();
+    if (!reviewers.isEmpty()) {
+      a.allReviewers = Lists.newArrayListWithCapacity(reviewers.size());
+      for (Account.Id id : reviewers) {
+        a.allReviewers.add(asAccountAttribute(id));
+      }
+    }
+  }
+
+  /**
+   * Add submitRecords to an existing ChangeAttribute.
+   *
+   * @param ca
+   * @param submitRecords
+   */
+  public void addSubmitRecords(ChangeAttribute ca, List<SubmitRecord> submitRecords) {
+    ca.submitRecords = new ArrayList<>();
+
+    for (SubmitRecord submitRecord : submitRecords) {
+      SubmitRecordAttribute sa = new SubmitRecordAttribute();
+      sa.status = submitRecord.status.name();
+      if (submitRecord.status != SubmitRecord.Status.RULE_ERROR) {
+        addSubmitRecordLabels(submitRecord, sa);
+      }
+      ca.submitRecords.add(sa);
+    }
+    // Remove empty lists so a confusing label won't be displayed in the output.
+    if (ca.submitRecords.isEmpty()) {
+      ca.submitRecords = null;
+    }
+  }
+
+  private void addSubmitRecordLabels(SubmitRecord submitRecord, SubmitRecordAttribute sa) {
+    if (submitRecord.labels != null && !submitRecord.labels.isEmpty()) {
+      sa.labels = new ArrayList<>();
+      for (SubmitRecord.Label lbl : submitRecord.labels) {
+        SubmitLabelAttribute la = new SubmitLabelAttribute();
+        la.label = lbl.label;
+        la.status = lbl.status.name();
+        if (lbl.appliedBy != null) {
+          Account a = accountCache.get(lbl.appliedBy).getAccount();
+          la.by = asAccountAttribute(a);
+        }
+        sa.labels.add(la);
+      }
+    }
+  }
+
+  public void addDependencies(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs) {
+    if (change == null || currentPs == null) {
+      return;
+    }
+    ca.dependsOn = new ArrayList<>();
+    ca.neededBy = new ArrayList<>();
+    try {
+      addDependsOn(rw, ca, change, currentPs);
+      addNeededBy(rw, ca, change, currentPs);
+    } catch (OrmException | IOException e) {
+      // Squash DB exceptions and leave dependency lists partially filled.
+    }
+    // Remove empty lists so a confusing label won't be displayed in the output.
+    if (ca.dependsOn.isEmpty()) {
+      ca.dependsOn = null;
+    }
+    if (ca.neededBy.isEmpty()) {
+      ca.neededBy = null;
+    }
+  }
+
+  private void addDependsOn(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs)
+      throws OrmException, IOException {
+    RevCommit commit = rw.parseCommit(ObjectId.fromString(currentPs.getRevision().get()));
+    final List<String> parentNames = new ArrayList<>(commit.getParentCount());
+    for (RevCommit p : commit.getParents()) {
+      parentNames.add(p.name());
+    }
+
+    // Find changes in this project having a patch set matching any parent of
+    // this patch set's revision.
+    for (ChangeData cd : queryProvider.get().byProjectCommits(change.getProject(), parentNames)) {
+      for (PatchSet ps : cd.patchSets()) {
+        for (String p : parentNames) {
+          if (!ps.getRevision().get().equals(p)) {
+            continue;
+          }
+          ca.dependsOn.add(newDependsOn(checkNotNull(cd.change()), ps));
+        }
+      }
+    }
+    // Sort by original parent order.
+    Collections.sort(
+        ca.dependsOn,
+        comparing(
+            (DependencyAttribute d) -> {
+              for (int i = 0; i < parentNames.size(); i++) {
+                if (parentNames.get(i).equals(d.revision)) {
+                  return i;
+                }
+              }
+              return parentNames.size() + 1;
+            }));
+  }
+
+  private void addNeededBy(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs)
+      throws OrmException, IOException {
+    if (currentPs.getGroups().isEmpty()) {
+      return;
+    }
+    String rev = currentPs.getRevision().get();
+    // Find changes in the same related group as this patch set, having a patch
+    // set whose parent matches this patch set's revision.
+    for (ChangeData cd :
+        InternalChangeQuery.byProjectGroups(
+            queryProvider, indexConfig, change.getProject(), currentPs.getGroups())) {
+      PATCH_SETS:
+      for (PatchSet ps : cd.patchSets()) {
+        RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+        for (RevCommit p : commit.getParents()) {
+          if (!p.name().equals(rev)) {
+            continue;
+          }
+          ca.neededBy.add(newNeededBy(checkNotNull(cd.change()), ps));
+          continue PATCH_SETS;
+        }
+      }
+    }
+  }
+
+  private DependencyAttribute newDependsOn(Change c, PatchSet ps) {
+    DependencyAttribute d = newDependencyAttribute(c, ps);
+    d.isCurrentPatchSet = ps.getId().equals(c.currentPatchSetId());
+    return d;
+  }
+
+  private DependencyAttribute newNeededBy(Change c, PatchSet ps) {
+    return newDependencyAttribute(c, ps);
+  }
+
+  private DependencyAttribute newDependencyAttribute(Change c, PatchSet ps) {
+    DependencyAttribute d = new DependencyAttribute();
+    d.number = c.getId().get();
+    d.id = c.getKey().toString();
+    d.revision = ps.getRevision().get();
+    d.ref = ps.getRefName();
+    return d;
+  }
+
+  public void addTrackingIds(ChangeAttribute a, ListMultimap<String, String> set) {
+    if (!set.isEmpty()) {
+      a.trackingIds = new ArrayList<>(set.size());
+      for (Map.Entry<String, Collection<String>> e : set.asMap().entrySet()) {
+        for (String id : e.getValue()) {
+          TrackingIdAttribute t = new TrackingIdAttribute();
+          t.system = e.getKey();
+          t.id = id;
+          a.trackingIds.add(t);
+        }
+      }
+    }
+  }
+
+  public void addCommitMessage(ChangeAttribute a, String commitMessage) {
+    a.commitMessage = commitMessage;
+  }
+
+  public void addPatchSets(
+      ReviewDb db,
+      RevWalk revWalk,
+      ChangeAttribute ca,
+      Collection<PatchSet> ps,
+      Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
+      LabelTypes labelTypes) {
+    addPatchSets(db, revWalk, ca, ps, approvals, false, null, labelTypes);
+  }
+
+  public void addPatchSets(
+      ReviewDb db,
+      RevWalk revWalk,
+      ChangeAttribute ca,
+      Collection<PatchSet> ps,
+      Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
+      boolean includeFiles,
+      Change change,
+      LabelTypes labelTypes) {
+    if (!ps.isEmpty()) {
+      ca.patchSets = new ArrayList<>(ps.size());
+      for (PatchSet p : ps) {
+        PatchSetAttribute psa = asPatchSetAttribute(db, revWalk, change, p);
+        if (approvals != null) {
+          addApprovals(psa, p.getId(), approvals, labelTypes);
+        }
+        ca.patchSets.add(psa);
+        if (includeFiles) {
+          addPatchSetFileNames(psa, change, p);
+        }
+      }
+    }
+  }
+
+  public void addPatchSetComments(
+      PatchSetAttribute patchSetAttribute, Collection<Comment> comments) {
+    for (Comment comment : comments) {
+      if (comment.key.patchSetId == patchSetAttribute.number) {
+        if (patchSetAttribute.comments == null) {
+          patchSetAttribute.comments = new ArrayList<>();
+        }
+        patchSetAttribute.comments.add(asPatchSetLineAttribute(comment));
+      }
+    }
+  }
+
+  public void addPatchSetFileNames(
+      PatchSetAttribute patchSetAttribute, Change change, PatchSet patchSet) {
+    try {
+      PatchList patchList = patchListCache.get(change, patchSet);
+      for (PatchListEntry patch : patchList.getPatches()) {
+        if (patchSetAttribute.files == null) {
+          patchSetAttribute.files = new ArrayList<>();
+        }
+
+        PatchAttribute p = new PatchAttribute();
+        p.file = patch.getNewName();
+        p.fileOld = patch.getOldName();
+        p.type = patch.getChangeType();
+        p.deletions -= patch.getDeletions();
+        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);
+    }
+  }
+
+  public void addComments(ChangeAttribute ca, Collection<ChangeMessage> messages) {
+    if (!messages.isEmpty()) {
+      ca.comments = new ArrayList<>();
+      for (ChangeMessage message : messages) {
+        ca.comments.add(asMessageAttribute(message));
+      }
+    }
+  }
+
+  /**
+   * Create a PatchSetAttribute for the given patchset suitable for serialization to JSON.
+   *
+   * @param revWalk
+   * @param patchSet
+   * @return object suitable for serialization to JSON
+   */
+  public PatchSetAttribute asPatchSetAttribute(RevWalk revWalk, Change change, PatchSet patchSet) {
+    try (ReviewDb db = schema.open()) {
+      return asPatchSetAttribute(db, revWalk, change, patchSet);
+    } catch (OrmException e) {
+      log.error("Cannot open database connection", e);
+      return new PatchSetAttribute();
+    }
+  }
+
+  /**
+   * Create a PatchSetAttribute for the given patchset suitable for serialization to JSON.
+   *
+   * @param db Review database
+   * @param patchSet
+   * @return object suitable for serialization to JSON
+   */
+  public PatchSetAttribute asPatchSetAttribute(
+      ReviewDb db, RevWalk revWalk, Change change, PatchSet patchSet) {
+    PatchSetAttribute p = new PatchSetAttribute();
+    p.revision = patchSet.getRevision().get();
+    p.number = patchSet.getPatchSetId();
+    p.ref = patchSet.getRefName();
+    p.uploader = asAccountAttribute(patchSet.getUploader());
+    p.createdOn = patchSet.getCreatedOn().getTime() / 1000L;
+    PatchSet.Id pId = patchSet.getId();
+    try {
+      p.parents = new ArrayList<>();
+      RevCommit c = revWalk.parseCommit(ObjectId.fromString(p.revision));
+      for (RevCommit parent : c.getParents()) {
+        p.parents.add(parent.name());
+      }
+
+      UserIdentity author = toUserIdentity(c.getAuthorIdent());
+      if (author.getAccount() == null) {
+        p.author = new AccountAttribute();
+        p.author.email = author.getEmail();
+        p.author.name = author.getName();
+        p.author.username = "";
+      } else {
+        p.author = asAccountAttribute(author.getAccount());
+      }
+
+      List<Patch> list = patchListCache.get(change, patchSet).toPatchList(pId);
+      for (Patch pe : list) {
+        if (!Patch.isMagic(pe.getFileName())) {
+          p.sizeDeletions -= pe.getDeletions();
+          p.sizeInsertions += pe.getInsertions();
+        }
+      }
+      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);
+    }
+    return p;
+  }
+
+  // TODO: The same method exists in PatchSetInfoFactory, find a common place
+  // for it
+  private UserIdentity toUserIdentity(PersonIdent who) throws IOException, OrmException {
+    UserIdentity u = new UserIdentity();
+    u.setName(who.getName());
+    u.setEmail(who.getEmailAddress());
+    u.setDate(new Timestamp(who.getWhen().getTime()));
+    u.setTimeZone(who.getTimeZoneOffset());
+
+    // If only one account has access to this email address, select it
+    // as the identity of the user.
+    //
+    Set<Account.Id> a = emails.getAccountFor(u.getEmail());
+    if (a.size() == 1) {
+      u.setAccount(a.iterator().next());
+    }
+
+    return u;
+  }
+
+  public void addApprovals(
+      PatchSetAttribute p,
+      PatchSet.Id id,
+      Map<PatchSet.Id, Collection<PatchSetApproval>> all,
+      LabelTypes labelTypes) {
+    Collection<PatchSetApproval> list = all.get(id);
+    if (list != null) {
+      addApprovals(p, list, labelTypes);
+    }
+  }
+
+  public void addApprovals(
+      PatchSetAttribute p, Collection<PatchSetApproval> list, LabelTypes labelTypes) {
+    if (!list.isEmpty()) {
+      p.approvals = new ArrayList<>(list.size());
+      for (PatchSetApproval a : list) {
+        if (a.getValue() != 0) {
+          p.approvals.add(asApprovalAttribute(a, labelTypes));
+        }
+      }
+      if (p.approvals.isEmpty()) {
+        p.approvals = null;
+      }
+    }
+  }
+
+  /**
+   * Create an AuthorAttribute for the given account suitable for serialization to JSON.
+   *
+   * @param id
+   * @return object suitable for serialization to JSON
+   */
+  public AccountAttribute asAccountAttribute(Account.Id id) {
+    if (id == null) {
+      return null;
+    }
+    return asAccountAttribute(accountCache.get(id).getAccount());
+  }
+
+  /**
+   * Create an AuthorAttribute for the given account suitable for serialization to JSON.
+   *
+   * @param account
+   * @return object suitable for serialization to JSON
+   */
+  public AccountAttribute asAccountAttribute(Account account) {
+    if (account == null) {
+      return null;
+    }
+
+    AccountAttribute who = new AccountAttribute();
+    who.name = account.getFullName();
+    who.email = account.getPreferredEmail();
+    who.username = account.getUserName();
+    return who;
+  }
+
+  /**
+   * Create an AuthorAttribute for the given person ident suitable for serialization to JSON.
+   *
+   * @param ident
+   * @return object suitable for serialization to JSON
+   */
+  public AccountAttribute asAccountAttribute(PersonIdent ident) {
+    AccountAttribute who = new AccountAttribute();
+    who.name = ident.getName();
+    who.email = ident.getEmailAddress();
+    return who;
+  }
+
+  /**
+   * Create an ApprovalAttribute for the given approval suitable for serialization to JSON.
+   *
+   * @param approval
+   * @param labelTypes label types for the containing project
+   * @return object suitable for serialization to JSON
+   */
+  public ApprovalAttribute asApprovalAttribute(PatchSetApproval approval, LabelTypes labelTypes) {
+    ApprovalAttribute a = new ApprovalAttribute();
+    a.type = approval.getLabelId().get();
+    a.value = Short.toString(approval.getValue());
+    a.by = asAccountAttribute(approval.getAccountId());
+    a.grantedOn = approval.getGranted().getTime() / 1000L;
+    a.oldValue = null;
+
+    LabelType lt = labelTypes.byLabel(approval.getLabelId());
+    if (lt != null) {
+      a.description = lt.getName();
+    }
+    return a;
+  }
+
+  public MessageAttribute asMessageAttribute(ChangeMessage message) {
+    MessageAttribute a = new MessageAttribute();
+    a.timestamp = message.getWrittenOn().getTime() / 1000L;
+    a.reviewer =
+        message.getAuthor() != null
+            ? asAccountAttribute(message.getAuthor())
+            : asAccountAttribute(myIdent);
+    a.message = message.getMessage();
+    return a;
+  }
+
+  public PatchSetCommentAttribute asPatchSetLineAttribute(Comment c) {
+    PatchSetCommentAttribute a = new PatchSetCommentAttribute();
+    a.reviewer = asAccountAttribute(c.author.getId());
+    a.file = c.key.filename;
+    a.line = c.lineNbr;
+    a.message = c.message;
+    return a;
+  }
+
+  /** Get a link to the change; null if the server doesn't know its own address. */
+  private String getChangeUrl(Change change) {
+    if (change != null && urlProvider.get() != null) {
+      StringBuilder r = new StringBuilder();
+      r.append(urlProvider.get());
+      r.append(change.getChangeId());
+      return r.toString();
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/events/EventListener.java b/java/com/google/gerrit/server/events/EventListener.java
new file mode 100644
index 0000000..8abca12
--- /dev/null
+++ b/java/com/google/gerrit/server/events/EventListener.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * Allows to listen to events without user visibility restrictions. To listen to events visible to a
+ * specific user, use {@link UserScopedEventListener}.
+ */
+@ExtensionPoint
+public interface EventListener {
+  void onEvent(Event event);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java b/java/com/google/gerrit/server/events/EventTypes.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
rename to java/com/google/gerrit/server/events/EventTypes.java
diff --git a/java/com/google/gerrit/server/events/EventsMetrics.java b/java/com/google/gerrit/server/events/EventsMetrics.java
new file mode 100644
index 0000000..f73d6de
--- /dev/null
+++ b/java/com/google/gerrit/server/events/EventsMetrics.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class EventsMetrics implements EventListener {
+  private final Counter1<String> events;
+
+  @Inject
+  public EventsMetrics(MetricMaker metricMaker) {
+    events =
+        metricMaker.newCounter(
+            "events",
+            new Description("Triggered events").setRate().setUnit("triggered events"),
+            Field.ofString("type"));
+  }
+
+  @Override
+  public void onEvent(com.google.gerrit.server.events.Event event) {
+    events.increment(event.getType());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/HashtagsChangedEvent.java b/java/com/google/gerrit/server/events/HashtagsChangedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/HashtagsChangedEvent.java
rename to java/com/google/gerrit/server/events/HashtagsChangedEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/PatchSetCreatedEvent.java b/java/com/google/gerrit/server/events/PatchSetCreatedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/PatchSetCreatedEvent.java
rename to java/com/google/gerrit/server/events/PatchSetCreatedEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/PatchSetEvent.java b/java/com/google/gerrit/server/events/PatchSetEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/PatchSetEvent.java
rename to java/com/google/gerrit/server/events/PatchSetEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java b/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
rename to java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectCreatedEvent.java b/java/com/google/gerrit/server/events/ProjectCreatedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectCreatedEvent.java
rename to java/com/google/gerrit/server/events/ProjectCreatedEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectEvent.java b/java/com/google/gerrit/server/events/ProjectEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectEvent.java
rename to java/com/google/gerrit/server/events/ProjectEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java b/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java
rename to java/com/google/gerrit/server/events/ProjectNameKeySerializer.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/RefEvent.java b/java/com/google/gerrit/server/events/RefEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/RefEvent.java
rename to java/com/google/gerrit/server/events/RefEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/RefReceivedEvent.java b/java/com/google/gerrit/server/events/RefReceivedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/RefReceivedEvent.java
rename to java/com/google/gerrit/server/events/RefReceivedEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/RefUpdatedEvent.java b/java/com/google/gerrit/server/events/RefUpdatedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/RefUpdatedEvent.java
rename to java/com/google/gerrit/server/events/RefUpdatedEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerAddedEvent.java b/java/com/google/gerrit/server/events/ReviewerAddedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerAddedEvent.java
rename to java/com/google/gerrit/server/events/ReviewerAddedEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java b/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java
rename to java/com/google/gerrit/server/events/ReviewerDeletedEvent.java
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
new file mode 100644
index 0000000..a63e1f8
--- /dev/null
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -0,0 +1,521 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.AssigneeChangedListener;
+import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeMergedListener;
+import com.google.gerrit.extensions.events.ChangeRestoredListener;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.HashtagsEditedListener;
+import com.google.gerrit.extensions.events.NewProjectCreatedListener;
+import com.google.gerrit.extensions.events.PrivateStateChangedListener;
+import com.google.gerrit.extensions.events.ReviewerAddedListener;
+import com.google.gerrit.extensions.events.ReviewerDeletedListener;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.events.TopicEditedListener;
+import com.google.gerrit.extensions.events.VoteDeletedListener;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.data.AccountAttribute;
+import com.google.gerrit.server.data.ApprovalAttribute;
+import com.google.gerrit.server.data.ChangeAttribute;
+import com.google.gerrit.server.data.PatchSetAttribute;
+import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class StreamEventsApiListener
+    implements AssigneeChangedListener,
+        ChangeAbandonedListener,
+        ChangeMergedListener,
+        ChangeRestoredListener,
+        WorkInProgressStateChangedListener,
+        PrivateStateChangedListener,
+        CommentAddedListener,
+        GitReferenceUpdatedListener,
+        HashtagsEditedListener,
+        NewProjectCreatedListener,
+        ReviewerAddedListener,
+        ReviewerDeletedListener,
+        RevisionCreatedListener,
+        TopicEditedListener,
+        VoteDeletedListener {
+  private static final Logger log = LoggerFactory.getLogger(StreamEventsApiListener.class);
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      DynamicSet.bind(binder(), AssigneeChangedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ChangeAbandonedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ChangeMergedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ChangeRestoredListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), CommentAddedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
+          .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), HashtagsEditedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), NewProjectCreatedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), PrivateStateChangedListener.class)
+          .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ReviewerAddedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ReviewerDeletedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), RevisionCreatedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), TopicEditedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), VoteDeletedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), WorkInProgressStateChangedListener.class)
+          .to(StreamEventsApiListener.class);
+    }
+  }
+
+  private final DynamicItem<EventDispatcher> dispatcher;
+  private final Provider<ReviewDb> db;
+  private final EventFactory eventFactory;
+  private final ProjectCache projectCache;
+  private final GitRepositoryManager repoManager;
+  private final PatchSetUtil psUtil;
+  private final ChangeNotes.Factory changeNotesFactory;
+
+  @Inject
+  StreamEventsApiListener(
+      DynamicItem<EventDispatcher> dispatcher,
+      Provider<ReviewDb> db,
+      EventFactory eventFactory,
+      ProjectCache projectCache,
+      GitRepositoryManager repoManager,
+      PatchSetUtil psUtil,
+      ChangeNotes.Factory changeNotesFactory) {
+    this.dispatcher = dispatcher;
+    this.db = db;
+    this.eventFactory = eventFactory;
+    this.projectCache = projectCache;
+    this.repoManager = repoManager;
+    this.psUtil = psUtil;
+    this.changeNotesFactory = changeNotesFactory;
+  }
+
+  private ChangeNotes getNotes(ChangeInfo info) throws OrmException {
+    try {
+      return changeNotesFactory.createChecked(new Change.Id(info._number));
+    } catch (NoSuchChangeException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  private Change getChange(ChangeInfo info) throws OrmException {
+    return getNotes(info).getChange();
+  }
+
+  private PatchSet getPatchSet(ChangeNotes notes, RevisionInfo info) throws OrmException {
+    return psUtil.get(db.get(), notes, PatchSet.Id.fromRef(info.ref));
+  }
+
+  private Supplier<ChangeAttribute> changeAttributeSupplier(Change change) {
+    return Suppliers.memoize(
+        new Supplier<ChangeAttribute>() {
+          @Override
+          public ChangeAttribute get() {
+            return eventFactory.asChangeAttribute(change);
+          }
+        });
+  }
+
+  private Supplier<AccountAttribute> accountAttributeSupplier(AccountInfo account) {
+    return Suppliers.memoize(
+        new Supplier<AccountAttribute>() {
+          @Override
+          public AccountAttribute get() {
+            return account != null
+                ? eventFactory.asAccountAttribute(new Account.Id(account._accountId))
+                : null;
+          }
+        });
+  }
+
+  private Supplier<PatchSetAttribute> patchSetAttributeSupplier(
+      final Change change, PatchSet patchSet) {
+    return Suppliers.memoize(
+        new Supplier<PatchSetAttribute>() {
+          @Override
+          public PatchSetAttribute get() {
+            try (Repository repo = repoManager.openRepository(change.getProject());
+                RevWalk revWalk = new RevWalk(repo)) {
+              return eventFactory.asPatchSetAttribute(revWalk, change, patchSet);
+            } catch (IOException e) {
+              throw new RuntimeException(e);
+            }
+          }
+        });
+  }
+
+  private static Map<String, Short> convertApprovalsMap(Map<String, ApprovalInfo> approvals) {
+    Map<String, Short> result = new HashMap<>();
+    for (Entry<String, ApprovalInfo> e : approvals.entrySet()) {
+      Short value = e.getValue().value == null ? null : e.getValue().value.shortValue();
+      result.put(e.getKey(), value);
+    }
+    return result;
+  }
+
+  private ApprovalAttribute getApprovalAttribute(
+      LabelTypes labelTypes, Entry<String, Short> approval, Map<String, Short> oldApprovals) {
+    ApprovalAttribute a = new ApprovalAttribute();
+    a.type = approval.getKey();
+
+    if (oldApprovals != null && !oldApprovals.isEmpty()) {
+      if (oldApprovals.get(approval.getKey()) != null) {
+        a.oldValue = Short.toString(oldApprovals.get(approval.getKey()));
+      }
+    }
+    LabelType lt = labelTypes.byLabel(approval.getKey());
+    if (lt != null) {
+      a.description = lt.getName();
+    }
+    if (approval.getValue() != null) {
+      a.value = Short.toString(approval.getValue());
+    }
+    return a;
+  }
+
+  private Supplier<ApprovalAttribute[]> approvalsAttributeSupplier(
+      final Change change,
+      Map<String, ApprovalInfo> newApprovals,
+      final Map<String, ApprovalInfo> oldApprovals) {
+    final Map<String, Short> approvals = convertApprovalsMap(newApprovals);
+    return Suppliers.memoize(
+        new Supplier<ApprovalAttribute[]>() {
+          @Override
+          public ApprovalAttribute[] get() {
+            LabelTypes labelTypes = projectCache.get(change.getProject()).getLabelTypes();
+            if (approvals.size() > 0) {
+              ApprovalAttribute[] r = new ApprovalAttribute[approvals.size()];
+              int i = 0;
+              for (Map.Entry<String, Short> approval : approvals.entrySet()) {
+                r[i++] =
+                    getApprovalAttribute(labelTypes, approval, convertApprovalsMap(oldApprovals));
+              }
+              return r;
+            }
+            return null;
+          }
+        });
+  }
+
+  String[] hashtagArray(Collection<String> hashtags) {
+    if (hashtags != null && hashtags.size() > 0) {
+      return Sets.newHashSet(hashtags).toArray(new String[hashtags.size()]);
+    }
+    return null;
+  }
+
+  @Override
+  public void onAssigneeChanged(AssigneeChangedListener.Event ev) {
+    try {
+      Change change = getChange(ev.getChange());
+      AssigneeChangedEvent event = new AssigneeChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.changer = accountAttributeSupplier(ev.getWho());
+      event.oldAssignee = accountAttributeSupplier(ev.getOldAssignee());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onTopicEdited(TopicEditedListener.Event ev) {
+    try {
+      Change change = getChange(ev.getChange());
+      TopicChangedEvent event = new TopicChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.changer = accountAttributeSupplier(ev.getWho());
+      event.oldTopic = ev.getOldTopic();
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onRevisionCreated(RevisionCreatedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      PatchSet patchSet = getPatchSet(notes, ev.getRevision());
+      PatchSetCreatedEvent event = new PatchSetCreatedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.patchSet = patchSetAttributeSupplier(change, patchSet);
+      event.uploader = accountAttributeSupplier(ev.getWho());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onReviewerDeleted(ReviewerDeletedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ReviewerDeletedEvent event = new ReviewerDeletedEvent(change);
+      event.change = changeAttributeSupplier(change);
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
+      event.reviewer = accountAttributeSupplier(ev.getReviewer());
+      event.remover = accountAttributeSupplier(ev.getWho());
+      event.comment = ev.getComment();
+      event.approvals =
+          approvalsAttributeSupplier(change, ev.getNewApprovals(), ev.getOldApprovals());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onReviewersAdded(ReviewerAddedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ReviewerAddedEvent event = new ReviewerAddedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
+      for (AccountInfo reviewer : ev.getReviewers()) {
+        event.reviewer = accountAttributeSupplier(reviewer);
+        dispatcher.get().postEvent(change, event);
+      }
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onNewProjectCreated(NewProjectCreatedListener.Event ev) {
+    ProjectCreatedEvent event = new ProjectCreatedEvent();
+    event.projectName = ev.getProjectName();
+    event.headName = ev.getHeadName();
+
+    dispatcher.get().postEvent(event.getProjectNameKey(), event);
+  }
+
+  @Override
+  public void onHashtagsEdited(HashtagsEditedListener.Event ev) {
+    try {
+      Change change = getChange(ev.getChange());
+      HashtagsChangedEvent event = new HashtagsChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.editor = accountAttributeSupplier(ev.getWho());
+      event.hashtags = hashtagArray(ev.getHashtags());
+      event.added = hashtagArray(ev.getAddedHashtags());
+      event.removed = hashtagArray(ev.getRemovedHashtags());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event ev) {
+    RefUpdatedEvent event = new RefUpdatedEvent();
+    if (ev.getUpdater() != null) {
+      event.submitter = accountAttributeSupplier(ev.getUpdater());
+    }
+    final Branch.NameKey refName = new Branch.NameKey(ev.getProjectName(), ev.getRefName());
+    event.refUpdate =
+        Suppliers.memoize(
+            new Supplier<RefUpdateAttribute>() {
+              @Override
+              public RefUpdateAttribute get() {
+                return eventFactory.asRefUpdateAttribute(
+                    ObjectId.fromString(ev.getOldObjectId()),
+                    ObjectId.fromString(ev.getNewObjectId()),
+                    refName);
+              }
+            });
+    try {
+      dispatcher.get().postEvent(refName, event);
+    } catch (PermissionBackendException e) {
+      log.error("error while posting event", e);
+    }
+  }
+
+  @Override
+  public void onCommentAdded(CommentAddedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      PatchSet ps = getPatchSet(notes, ev.getRevision());
+      CommentAddedEvent event = new CommentAddedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.author = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, ps);
+      event.comment = ev.getComment();
+      event.approvals = approvalsAttributeSupplier(change, ev.getApprovals(), ev.getOldApprovals());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onChangeRestored(ChangeRestoredListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ChangeRestoredEvent event = new ChangeRestoredEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.restorer = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
+      event.reason = ev.getReason();
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onChangeMerged(ChangeMergedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ChangeMergedEvent event = new ChangeMergedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.submitter = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
+      event.newRev = ev.getNewRevisionId();
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onChangeAbandoned(ChangeAbandonedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ChangeAbandonedEvent event = new ChangeAbandonedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.abandoner = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
+      event.reason = ev.getReason();
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event ev) {
+    try {
+      Change change = getChange(ev.getChange());
+      WorkInProgressStateChangedEvent event = new WorkInProgressStateChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.changer = accountAttributeSupplier(ev.getWho());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onPrivateStateChanged(PrivateStateChangedListener.Event ev) {
+    try {
+      Change change = getChange(ev.getChange());
+      PrivateStateChangedEvent event = new PrivateStateChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.changer = accountAttributeSupplier(ev.getWho());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onVoteDeleted(VoteDeletedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      VoteDeletedEvent event = new VoteDeletedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
+      event.comment = ev.getMessage();
+      event.reviewer = accountAttributeSupplier(ev.getReviewer());
+      event.remover = accountAttributeSupplier(ev.getWho());
+      event.approvals = approvalsAttributeSupplier(change, ev.getApprovals(), ev.getOldApprovals());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierDeserializer.java b/java/com/google/gerrit/server/events/SupplierDeserializer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierDeserializer.java
rename to java/com/google/gerrit/server/events/SupplierDeserializer.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierSerializer.java b/java/com/google/gerrit/server/events/SupplierSerializer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierSerializer.java
rename to java/com/google/gerrit/server/events/SupplierSerializer.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/TopicChangedEvent.java b/java/com/google/gerrit/server/events/TopicChangedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/TopicChangedEvent.java
rename to java/com/google/gerrit/server/events/TopicChangedEvent.java
diff --git a/java/com/google/gerrit/server/events/UserScopedEventListener.java b/java/com/google/gerrit/server/events/UserScopedEventListener.java
new file mode 100644
index 0000000..2be1fd7
--- /dev/null
+++ b/java/com/google/gerrit/server/events/UserScopedEventListener.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.CurrentUser;
+
+/**
+ * Allows to listen to events visible to the specified user. To listen to events without user
+ * visibility restrictions, use {@link EventListener}.
+ */
+@ExtensionPoint
+public interface UserScopedEventListener extends EventListener {
+  CurrentUser getUser();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/VoteDeletedEvent.java b/java/com/google/gerrit/server/events/VoteDeletedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/VoteDeletedEvent.java
rename to java/com/google/gerrit/server/events/VoteDeletedEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java b/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
rename to java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java b/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
rename to java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractNoNotifyEvent.java b/java/com/google/gerrit/server/extensions/events/AbstractNoNotifyEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractNoNotifyEvent.java
rename to java/com/google/gerrit/server/extensions/events/AbstractNoNotifyEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java b/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
rename to java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java b/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
rename to java/com/google/gerrit/server/extensions/events/AgreementSignup.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
rename to java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
new file mode 100644
index 0000000..ef69616
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.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;
+import java.sql.Timestamp;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ChangeAbandoned {
+  private static final Logger log = LoggerFactory.getLogger(ChangeAbandoned.class);
+
+  private final DynamicSet<ChangeAbandonedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ChangeAbandoned(DynamicSet<ChangeAbandonedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(
+      Change change,
+      PatchSet ps,
+      Account abandoner,
+      String reason,
+      Timestamp when,
+      NotifyHandling notifyHandling) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), ps),
+              util.accountInfo(abandoner),
+              reason,
+              when,
+              notifyHandling);
+      for (ChangeAbandonedListener l : listeners) {
+        try {
+          l.onChangeAbandoned(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | OrmException
+        | PermissionBackendException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements ChangeAbandonedListener.Event {
+    private final String reason;
+
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        AccountInfo abandoner,
+        String reason,
+        Timestamp when,
+        NotifyHandling notifyHandling) {
+      super(change, revision, abandoner, when, notifyHandling);
+      this.reason = reason;
+    }
+
+    @Override
+    public String getReason() {
+      return reason;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
new file mode 100644
index 0000000..e9ae356
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.ChangeMergedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.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;
+import java.sql.Timestamp;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ChangeMerged {
+  private static final Logger log = LoggerFactory.getLogger(ChangeMerged.class);
+
+  private final DynamicSet<ChangeMergedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ChangeMerged(DynamicSet<ChangeMergedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(
+      Change change, PatchSet ps, Account merger, String newRevisionId, Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), ps),
+              util.accountInfo(merger),
+              newRevisionId,
+              when);
+      for (ChangeMergedListener l : listeners) {
+        try {
+          l.onChangeMerged(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | OrmException
+        | PermissionBackendException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent implements ChangeMergedListener.Event {
+    private final String newRevisionId;
+
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        AccountInfo merger,
+        String newRevisionId,
+        Timestamp when) {
+      super(change, revision, merger, when, NotifyHandling.ALL);
+      this.newRevisionId = newRevisionId;
+    }
+
+    @Override
+    public String getNewRevisionId() {
+      return newRevisionId;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
new file mode 100644
index 0000000..c25deab
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.ChangeRestoredListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.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;
+import java.sql.Timestamp;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ChangeRestored {
+  private static final Logger log = LoggerFactory.getLogger(ChangeRestored.class);
+
+  private final DynamicSet<ChangeRestoredListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ChangeRestored(DynamicSet<ChangeRestoredListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, PatchSet ps, Account restorer, String reason, Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), ps),
+              util.accountInfo(restorer),
+              reason,
+              when);
+      for (ChangeRestoredListener l : listeners) {
+        try {
+          l.onChangeRestored(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | OrmException
+        | PermissionBackendException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent implements ChangeRestoredListener.Event {
+
+    private String reason;
+
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        AccountInfo restorer,
+        String reason,
+        Timestamp when) {
+      super(change, revision, restorer, when, NotifyHandling.ALL);
+      this.reason = reason;
+    }
+
+    @Override
+    public String getReason() {
+      return reason;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeReverted.java b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
rename to java/com/google/gerrit/server/extensions/events/ChangeReverted.java
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
new file mode 100644
index 0000000..77cd1a8
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -0,0 +1,125 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.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;
+import java.sql.Timestamp;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class CommentAdded {
+  private static final Logger log = LoggerFactory.getLogger(CommentAdded.class);
+
+  private final DynamicSet<CommentAddedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  CommentAdded(DynamicSet<CommentAddedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(
+      Change change,
+      PatchSet ps,
+      Account author,
+      String comment,
+      Map<String, Short> approvals,
+      Map<String, Short> oldApprovals,
+      Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), ps),
+              util.accountInfo(author),
+              comment,
+              util.approvals(author, approvals, when),
+              util.approvals(author, oldApprovals, when),
+              when);
+      for (CommentAddedListener l : listeners) {
+        try {
+          l.onCommentAdded(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | OrmException
+        | PermissionBackendException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent implements CommentAddedListener.Event {
+
+    private final String comment;
+    private final Map<String, ApprovalInfo> approvals;
+    private final Map<String, ApprovalInfo> oldApprovals;
+
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        AccountInfo author,
+        String comment,
+        Map<String, ApprovalInfo> approvals,
+        Map<String, ApprovalInfo> oldApprovals,
+        Timestamp when) {
+      super(change, revision, author, when, NotifyHandling.ALL);
+      this.comment = comment;
+      this.approvals = approvals;
+      this.oldApprovals = oldApprovals;
+    }
+
+    @Override
+    public String getComment() {
+      return comment;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getApprovals() {
+      return approvals;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getOldApprovals() {
+      return oldApprovals;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
new file mode 100644
index 0000000..95d7132
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -0,0 +1,140 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.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 java.io.IOException;
+import java.sql.Timestamp;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class EventUtil {
+  private static final Logger log = LoggerFactory.getLogger(EventUtil.class);
+
+  private static final ImmutableSet<ListChangesOption> CHANGE_OPTIONS;
+
+  static {
+    EnumSet<ListChangesOption> opts = EnumSet.allOf(ListChangesOption.class);
+
+    // Some options, like actions, are expensive to compute because they potentially have to walk
+    // lots of history and inspect lots of other changes.
+    opts.remove(ListChangesOption.CHANGE_ACTIONS);
+    opts.remove(ListChangesOption.CURRENT_ACTIONS);
+
+    // CHECK suppresses some exceptions on corrupt changes, which is not appropriate for passing
+    // through the event system as we would rather let them propagate.
+    opts.remove(ListChangesOption.CHECK);
+
+    CHANGE_OPTIONS = Sets.immutableEnumSet(opts);
+  }
+
+  private final ChangeData.Factory changeDataFactory;
+  private final Provider<ReviewDb> db;
+  private final ChangeJson changeJson;
+
+  @Inject
+  EventUtil(
+      ChangeJson.Factory changeJsonFactory,
+      ChangeData.Factory changeDataFactory,
+      Provider<ReviewDb> db) {
+    this.changeDataFactory = changeDataFactory;
+    this.db = db;
+    this.changeJson = changeJsonFactory.create(CHANGE_OPTIONS);
+  }
+
+  public ChangeInfo changeInfo(Change change) throws OrmException {
+    return changeJson.format(change);
+  }
+
+  public RevisionInfo revisionInfo(Project project, PatchSet ps)
+      throws OrmException, PatchListNotAvailableException, GpgException, IOException,
+          PermissionBackendException {
+    return revisionInfo(project.getNameKey(), ps);
+  }
+
+  public RevisionInfo revisionInfo(Project.NameKey project, PatchSet ps)
+      throws OrmException, PatchListNotAvailableException, GpgException, IOException,
+          PermissionBackendException {
+    ChangeData cd = changeDataFactory.create(db.get(), project, ps.getId().getParentKey());
+    return changeJson.getRevisionInfo(cd, ps);
+  }
+
+  public AccountInfo accountInfo(Account a) {
+    if (a == null || a.getId() == null) {
+      return null;
+    }
+    AccountInfo accountInfo = new AccountInfo(a.getId().get());
+    accountInfo.email = a.getPreferredEmail();
+    accountInfo.name = a.getFullName();
+    accountInfo.username = a.getUserName();
+    return accountInfo;
+  }
+
+  public Map<String, ApprovalInfo> approvals(
+      Account a, Map<String, Short> approvals, Timestamp ts) {
+    Map<String, ApprovalInfo> result = new HashMap<>();
+    for (Map.Entry<String, Short> e : approvals.entrySet()) {
+      Integer value = e.getValue() != null ? Integer.valueOf(e.getValue()) : null;
+      result.put(e.getKey(), ChangeJson.getApprovalInfo(a.getId(), value, null, null, ts));
+    }
+    return result;
+  }
+
+  public void logEventListenerError(Object event, Object listener, Exception error) {
+    if (log.isDebugEnabled()) {
+      log.debug(
+          String.format(
+              "Error in event listener %s for event %s",
+              listener.getClass().getName(), event.getClass().getName()),
+          error);
+    } else {
+      log.warn(
+          "Error in listener {} for event {}: {}",
+          listener.getClass().getName(),
+          event.getClass().getName(),
+          error.getMessage());
+    }
+  }
+
+  public static void logEventListenerError(Object listener, Exception error) {
+    if (log.isDebugEnabled()) {
+      log.debug(String.format("Error in event listener %s", listener.getClass().getName()), error);
+    } else {
+      log.warn("Error in listener {}: {}", listener.getClass().getName(), error.getMessage());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
rename to java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
rename to java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PluginEvent.java b/java/com/google/gerrit/server/extensions/events/PluginEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PluginEvent.java
rename to java/com/google/gerrit/server/extensions/events/PluginEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
rename to java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
new file mode 100644
index 0000000..fc6881d
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.ReviewerAddedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.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;
+import java.sql.Timestamp;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ReviewerAdded {
+  private static final Logger log = LoggerFactory.getLogger(ReviewerAdded.class);
+
+  private final DynamicSet<ReviewerAddedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ReviewerAdded(DynamicSet<ReviewerAddedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(
+      Change change, PatchSet patchSet, List<Account> reviewers, Account adder, Timestamp when) {
+    if (!listeners.iterator().hasNext() || reviewers.isEmpty()) {
+      return;
+    }
+
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              Lists.transform(reviewers, util::accountInfo),
+              util.accountInfo(adder),
+              when);
+      for (ReviewerAddedListener l : listeners) {
+        try {
+          l.onReviewersAdded(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, 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);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent implements ReviewerAddedListener.Event {
+    private final List<AccountInfo> reviewers;
+
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        List<AccountInfo> reviewers,
+        AccountInfo adder,
+        Timestamp when) {
+      super(change, revision, adder, when, NotifyHandling.ALL);
+      this.reviewers = reviewers;
+    }
+
+    @Override
+    public List<AccountInfo> getReviewers() {
+      return reviewers;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
new file mode 100644
index 0000000..28e07a9
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -0,0 +1,138 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.ReviewerDeletedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.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;
+import java.sql.Timestamp;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ReviewerDeleted {
+  private static final Logger log = LoggerFactory.getLogger(ReviewerDeleted.class);
+
+  private final DynamicSet<ReviewerDeletedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ReviewerDeleted(DynamicSet<ReviewerDeletedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(
+      Change change,
+      PatchSet patchSet,
+      Account reviewer,
+      Account remover,
+      String message,
+      Map<String, Short> newApprovals,
+      Map<String, Short> oldApprovals,
+      NotifyHandling notify,
+      Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(reviewer),
+              util.accountInfo(remover),
+              message,
+              util.approvals(reviewer, newApprovals, when),
+              util.approvals(reviewer, oldApprovals, when),
+              notify,
+              when);
+      for (ReviewerDeletedListener listener : listeners) {
+        try {
+          listener.onReviewerDeleted(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, listener, 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);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements ReviewerDeletedListener.Event {
+    private final AccountInfo reviewer;
+    private final String comment;
+    private final Map<String, ApprovalInfo> newApprovals;
+    private final Map<String, ApprovalInfo> oldApprovals;
+
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        AccountInfo reviewer,
+        AccountInfo remover,
+        String comment,
+        Map<String, ApprovalInfo> newApprovals,
+        Map<String, ApprovalInfo> oldApprovals,
+        NotifyHandling notify,
+        Timestamp when) {
+      super(change, revision, remover, when, notify);
+      this.reviewer = reviewer;
+      this.comment = comment;
+      this.newApprovals = newApprovals;
+      this.oldApprovals = oldApprovals;
+    }
+
+    @Override
+    public AccountInfo getReviewer() {
+      return reviewer;
+    }
+
+    @Override
+    public String getComment() {
+      return comment;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getNewApprovals() {
+      return newApprovals;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getOldApprovals() {
+      return oldApprovals;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
new file mode 100644
index 0000000..76779ca
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.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;
+import java.sql.Timestamp;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class RevisionCreated {
+  private static final Logger log = LoggerFactory.getLogger(RevisionCreated.class);
+
+  private final DynamicSet<RevisionCreatedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  RevisionCreated(DynamicSet<RevisionCreatedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(
+      Change change, PatchSet patchSet, Account uploader, Timestamp when, NotifyHandling notify) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(uploader),
+              when,
+              notify);
+      for (RevisionCreatedListener l : listeners) {
+        try {
+          l.onRevisionCreated(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | OrmException
+        | PermissionBackendException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements RevisionCreatedListener.Event {
+
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        AccountInfo uploader,
+        Timestamp when,
+        NotifyHandling notify) {
+      super(change, revision, uploader, when, notify);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
rename to java/com/google/gerrit/server/extensions/events/TopicEdited.java
diff --git a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
new file mode 100644
index 0000000..8944698
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.VoteDeletedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.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;
+import java.sql.Timestamp;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class VoteDeleted {
+  private static final Logger log = LoggerFactory.getLogger(VoteDeleted.class);
+
+  private final DynamicSet<VoteDeletedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  VoteDeleted(DynamicSet<VoteDeletedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(
+      Change change,
+      PatchSet ps,
+      Account reviewer,
+      Map<String, Short> approvals,
+      Map<String, Short> oldApprovals,
+      NotifyHandling notify,
+      String message,
+      Account remover,
+      Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), ps),
+              util.accountInfo(reviewer),
+              util.approvals(remover, approvals, when),
+              util.approvals(remover, oldApprovals, when),
+              notify,
+              message,
+              util.accountInfo(remover),
+              when);
+      for (VoteDeletedListener l : listeners) {
+        try {
+          l.onVoteDeleted(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | OrmException
+        | PermissionBackendException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent implements VoteDeletedListener.Event {
+    private final AccountInfo reviewer;
+    private final Map<String, ApprovalInfo> approvals;
+    private final Map<String, ApprovalInfo> oldApprovals;
+    private final String message;
+
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        AccountInfo reviewer,
+        Map<String, ApprovalInfo> approvals,
+        Map<String, ApprovalInfo> oldApprovals,
+        NotifyHandling notify,
+        String message,
+        AccountInfo remover,
+        Timestamp when) {
+      super(change, revision, remover, when, notify);
+      this.reviewer = reviewer;
+      this.approvals = approvals;
+      this.oldApprovals = oldApprovals;
+      this.message = message;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getApprovals() {
+      return approvals;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getOldApprovals() {
+      return oldApprovals;
+    }
+
+    @Override
+    public String getMessage() {
+      return message;
+    }
+
+    @Override
+    public AccountInfo getReviewer() {
+      return reviewer;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
rename to java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java b/java/com/google/gerrit/server/extensions/webui/UiActions.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
rename to java/com/google/gerrit/server/extensions/webui/UiActions.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java b/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
rename to java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/fixes/LineIdentifier.java b/java/com/google/gerrit/server/fixes/LineIdentifier.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/fixes/LineIdentifier.java
rename to java/com/google/gerrit/server/fixes/LineIdentifier.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/fixes/StringModifier.java b/java/com/google/gerrit/server/fixes/StringModifier.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/fixes/StringModifier.java
rename to java/com/google/gerrit/server/fixes/StringModifier.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/AccountsSection.java b/java/com/google/gerrit/server/git/AccountsSection.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/AccountsSection.java
rename to java/com/google/gerrit/server/git/AccountsSection.java
diff --git a/java/com/google/gerrit/server/git/BanCommit.java b/java/com/google/gerrit/server/git/BanCommit.java
new file mode 100644
index 0000000..4991715
--- /dev/null
+++ b/java/com/google/gerrit/server/git/BanCommit.java
@@ -0,0 +1,174 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_REJECT_COMMITS;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+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.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Date;
+import java.util.List;
+import java.util.TimeZone;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class BanCommit {
+  /**
+   * Loads a list of commits to reject from {@code refs/meta/reject-commits}.
+   *
+   * @param repo repository from which the rejected commits should be loaded
+   * @param walk open revwalk on repo.
+   * @return NoteMap of commits to be rejected, null if there are none.
+   * @throws IOException the map cannot be loaded.
+   */
+  public static NoteMap loadRejectCommitsMap(Repository repo, RevWalk walk) throws IOException {
+    try {
+      Ref ref = repo.getRefDatabase().exactRef(RefNames.REFS_REJECT_COMMITS);
+      if (ref == null) {
+        return NoteMap.newEmptyMap();
+      }
+
+      RevCommit map = walk.parseCommit(ref.getObjectId());
+      return NoteMap.read(walk.getObjectReader(), map);
+    } catch (IOException badMap) {
+      throw new IOException("Cannot load " + RefNames.REFS_REJECT_COMMITS, badMap);
+    }
+  }
+
+  private final Provider<IdentifiedUser> currentUser;
+  private final GitRepositoryManager repoManager;
+  private final TimeZone tz;
+  private final PermissionBackend permissionBackend;
+  private NotesBranchUtil.Factory notesBranchUtilFactory;
+
+  @Inject
+  BanCommit(
+      Provider<IdentifiedUser> currentUser,
+      GitRepositoryManager repoManager,
+      @GerritPersonIdent PersonIdent gerritIdent,
+      NotesBranchUtil.Factory notesBranchUtilFactory,
+      PermissionBackend permissionBackend) {
+    this.currentUser = currentUser;
+    this.repoManager = repoManager;
+    this.notesBranchUtilFactory = notesBranchUtilFactory;
+    this.permissionBackend = permissionBackend;
+    this.tz = gerritIdent.getTimeZone();
+  }
+
+  public BanCommitResult ban(
+      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.
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo);
+        ObjectInserter inserter = repo.newObjectInserter()) {
+      ObjectId noteId = null;
+      for (ObjectId commitToBan : commitsToBan) {
+        try {
+          revWalk.parseCommit(commitToBan);
+        } catch (MissingObjectException e) {
+          // Ignore exception, non-existing commits can be banned.
+        } catch (IncorrectObjectTypeException e) {
+          result.notACommit(commitToBan);
+          continue;
+        }
+        if (noteId == null) {
+          noteId = createNoteContent(reason, inserter);
+        }
+        banCommitNotes.set(commitToBan, noteId);
+      }
+      NotesBranchUtil notesBranchUtil = notesBranchUtilFactory.create(project, repo, inserter);
+      NoteMap newlyCreated =
+          notesBranchUtil.commitNewNotes(
+              banCommitNotes,
+              REFS_REJECT_COMMITS,
+              createPersonIdent(),
+              buildCommitMessage(commitsToBan, reason));
+
+      for (Note n : banCommitNotes) {
+        if (newlyCreated.contains(n)) {
+          result.commitBanned(n);
+        } else {
+          result.commitAlreadyBanned(n);
+        }
+      }
+      return result;
+    }
+  }
+
+  private ObjectId createNoteContent(String reason, ObjectInserter inserter) throws IOException {
+    String noteContent = reason != null ? reason : "";
+    if (noteContent.length() > 0 && !noteContent.endsWith("\n")) {
+      noteContent = noteContent + "\n";
+    }
+    return inserter.insert(Constants.OBJ_BLOB, noteContent.getBytes(UTF_8));
+  }
+
+  private PersonIdent createPersonIdent() {
+    Date now = new Date();
+    return currentUser.get().newCommitterIdent(now, tz);
+  }
+
+  private static String buildCommitMessage(List<ObjectId> bannedCommits, String reason) {
+    final StringBuilder commitMsg = new StringBuilder();
+    commitMsg.append("Banning ");
+    commitMsg.append(bannedCommits.size());
+    commitMsg.append(" ");
+    commitMsg.append(bannedCommits.size() == 1 ? "commit" : "commits");
+    commitMsg.append("\n\n");
+    if (reason != null) {
+      commitMsg.append("Reason: ");
+      commitMsg.append(reason);
+      commitMsg.append("\n\n");
+    }
+    commitMsg.append("The following commits are banned:\n");
+    final StringBuilder commitList = new StringBuilder();
+    for (ObjectId c : bannedCommits) {
+      if (commitList.length() > 0) {
+        commitList.append(",\n");
+      }
+      commitList.append(c.getName());
+    }
+    commitMsg.append(commitList);
+    return commitMsg.toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommitResult.java b/java/com/google/gerrit/server/git/BanCommitResult.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommitResult.java
rename to java/com/google/gerrit/server/git/BanCommitResult.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BranchOrderSection.java b/java/com/google/gerrit/server/git/BranchOrderSection.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/BranchOrderSection.java
rename to java/com/google/gerrit/server/git/BranchOrderSection.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeAlreadyMergedException.java b/java/com/google/gerrit/server/git/ChangeAlreadyMergedException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeAlreadyMergedException.java
rename to java/com/google/gerrit/server/git/ChangeAlreadyMergedException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeMessageModifier.java b/java/com/google/gerrit/server/git/ChangeMessageModifier.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeMessageModifier.java
rename to java/com/google/gerrit/server/git/ChangeMessageModifier.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeReportFormatter.java b/java/com/google/gerrit/server/git/ChangeReportFormatter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeReportFormatter.java
rename to java/com/google/gerrit/server/git/ChangeReportFormatter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java b/java/com/google/gerrit/server/git/ChangeSet.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
rename to java/com/google/gerrit/server/git/ChangeSet.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java b/java/com/google/gerrit/server/git/CodeReviewCommit.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java
rename to java/com/google/gerrit/server/git/CodeReviewCommit.java
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
new file mode 100644
index 0000000..b0f10f2
--- /dev/null
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.server.CommonConverters;
+import java.io.IOException;
+import java.util.ArrayList;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Static utilities for working with {@link RevCommit}s. */
+public class CommitUtil {
+  public static CommitInfo toCommitInfo(RevCommit commit) throws IOException {
+    return toCommitInfo(commit, null);
+  }
+
+  public static CommitInfo toCommitInfo(RevCommit commit, @Nullable RevWalk walk)
+      throws IOException {
+    CommitInfo info = new CommitInfo();
+    info.commit = commit.getName();
+    info.author = CommonConverters.toGitPerson(commit.getAuthorIdent());
+    info.committer = CommonConverters.toGitPerson(commit.getCommitterIdent());
+    info.subject = commit.getShortMessage();
+    info.message = commit.getFullMessage();
+    info.parents = new ArrayList<>(commit.getParentCount());
+    for (int i = 0; i < commit.getParentCount(); i++) {
+      RevCommit p = walk == null ? commit.getParent(i) : walk.parseCommit(commit.getParent(i));
+      CommitInfo parentInfo = new CommitInfo();
+      parentInfo.commit = p.getName();
+      parentInfo.subject = p.getShortMessage();
+      info.parents.add(parentInfo);
+    }
+    return info;
+  }
+
+  private CommitUtil() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ConfiguredMimeTypes.java b/java/com/google/gerrit/server/git/ConfiguredMimeTypes.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/ConfiguredMimeTypes.java
rename to java/com/google/gerrit/server/git/ConfiguredMimeTypes.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
rename to java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultQueueOp.java b/java/com/google/gerrit/server/git/DefaultQueueOp.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultQueueOp.java
rename to java/com/google/gerrit/server/git/DefaultQueueOp.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java b/java/com/google/gerrit/server/git/DestinationList.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java
rename to java/com/google/gerrit/server/git/DestinationList.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/EmailMerge.java b/java/com/google/gerrit/server/git/EmailMerge.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/EmailMerge.java
rename to java/com/google/gerrit/server/git/EmailMerge.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollection.java b/java/com/google/gerrit/server/git/GarbageCollection.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollection.java
rename to java/com/google/gerrit/server/git/GarbageCollection.java
diff --git a/java/com/google/gerrit/server/git/GarbageCollectionLogFile.java b/java/com/google/gerrit/server/git/GarbageCollectionLogFile.java
new file mode 100644
index 0000000..e03ef67
--- /dev/null
+++ b/java/com/google/gerrit/server/git/GarbageCollectionLogFile.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.SystemLog;
+import com.google.inject.Inject;
+import java.nio.file.Path;
+import org.apache.log4j.LogManager;
+import org.apache.log4j.Logger;
+import org.apache.log4j.PatternLayout;
+import org.eclipse.jgit.lib.Config;
+
+public class GarbageCollectionLogFile implements LifecycleListener {
+  @Inject
+  public GarbageCollectionLogFile(SitePaths sitePaths, @GerritServerConfig Config config) {
+    if (SystemLog.shouldConfigure()) {
+      initLogSystem(sitePaths.logs_dir, config.getBoolean("log", "rotate", true));
+    }
+  }
+
+  @Override
+  public void start() {}
+
+  @Override
+  public void stop() {
+    LogManager.getLogger(GarbageCollection.LOG_NAME).removeAllAppenders();
+  }
+
+  private static void initLogSystem(Path logdir, boolean rotate) {
+    Logger gcLogger = LogManager.getLogger(GarbageCollection.LOG_NAME);
+    gcLogger.removeAllAppenders();
+    gcLogger.addAppender(
+        SystemLog.createAppender(
+            logdir, GarbageCollection.LOG_NAME, new PatternLayout("[%d] %-5p %x: %m%n"), rotate));
+    gcLogger.setAdditivity(false);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionModule.java b/java/com/google/gerrit/server/git/GarbageCollectionModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionModule.java
rename to java/com/google/gerrit/server/git/GarbageCollectionModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionQueue.java b/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
rename to java/com/google/gerrit/server/git/GarbageCollectionQueue.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionRunner.java b/java/com/google/gerrit/server/git/GarbageCollectionRunner.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionRunner.java
rename to java/com/google/gerrit/server/git/GarbageCollectionRunner.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModule.java b/java/com/google/gerrit/server/git/GitModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/GitModule.java
rename to java/com/google/gerrit/server/git/GitModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java b/java/com/google/gerrit/server/git/GitModules.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
rename to java/com/google/gerrit/server/git/GitModules.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java b/java/com/google/gerrit/server/git/GitRepositoryManager.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
rename to java/com/google/gerrit/server/git/GitRepositoryManager.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java b/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
rename to java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
rename to java/com/google/gerrit/server/git/GroupCollector.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java b/java/com/google/gerrit/server/git/GroupList.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java
rename to java/com/google/gerrit/server/git/GroupList.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/HackPushNegotiateHook.java b/java/com/google/gerrit/server/git/HackPushNegotiateHook.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/HackPushNegotiateHook.java
rename to java/com/google/gerrit/server/git/HackPushNegotiateHook.java
diff --git a/java/com/google/gerrit/server/git/HookUtil.java b/java/com/google/gerrit/server/git/HookUtil.java
new file mode 100644
index 0000000..1762b95
--- /dev/null
+++ b/java/com/google/gerrit/server/git/HookUtil.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import java.io.IOException;
+import java.util.Map;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.transport.BaseReceivePack;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+
+/** Static utilities for writing 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.
+   *
+   * @param rp receive-pack handler.
+   * @return map of refs that were advertised.
+   * @throws ServiceMayNotContinueException if a problem occurred.
+   */
+  public static Map<String, Ref> ensureAllRefsAdvertised(BaseReceivePack rp)
+      throws ServiceMayNotContinueException {
+    Map<String, Ref> refs = rp.getAdvertisedRefs();
+    if (refs != null) {
+      return refs;
+    }
+    try {
+      refs = rp.getRepository().getRefDatabase().getRefs(RefDatabase.ALL);
+    } catch (ServiceMayNotContinueException e) {
+      throw e;
+    } catch (IOException e) {
+      ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
+      ex.initCause(e);
+      throw ex;
+    }
+    rp.setAdvertisedRefs(refs, rp.getAdvertisedObjects());
+    return refs;
+  }
+
+  private HookUtil() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/InMemoryInserter.java b/java/com/google/gerrit/server/git/InMemoryInserter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/InMemoryInserter.java
rename to java/com/google/gerrit/server/git/InMemoryInserter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/InsertedObject.java b/java/com/google/gerrit/server/git/InsertedObject.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/InsertedObject.java
rename to java/com/google/gerrit/server/git/InsertedObject.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/IntegrationException.java b/java/com/google/gerrit/server/git/IntegrationException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/IntegrationException.java
rename to java/com/google/gerrit/server/git/IntegrationException.java
diff --git a/java/com/google/gerrit/server/git/LabelNormalizer.java b/java/com/google/gerrit/server/git/LabelNormalizer.java
new file mode 100644
index 0000000..73cda7f
--- /dev/null
+++ b/java/com/google/gerrit/server/git/LabelNormalizer.java
@@ -0,0 +1,152 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 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. 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 {
+  @AutoValue
+  public abstract static class Result {
+    @VisibleForTesting
+    static Result create(
+        List<PatchSetApproval> unchanged,
+        List<PatchSetApproval> updated,
+        List<PatchSetApproval> deleted) {
+      return new AutoValue_LabelNormalizer_Result(
+          ImmutableList.copyOf(unchanged),
+          ImmutableList.copyOf(updated),
+          ImmutableList.copyOf(deleted));
+    }
+
+    public abstract ImmutableList<PatchSetApproval> unchanged();
+
+    public abstract ImmutableList<PatchSetApproval> updated();
+
+    public abstract ImmutableList<PatchSetApproval> deleted();
+
+    public Iterable<PatchSetApproval> getNormalized() {
+      return Iterables.concat(unchanged(), updated());
+    }
+  }
+
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final ProjectCache projectCache;
+
+  @Inject
+  LabelNormalizer(IdentifiedUser.GenericFactory userFactory, ProjectCache projectCache) {
+    this.userFactory = userFactory;
+    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. Approvals for
+   *     unknown labels are not included in the output.
+   * @throws OrmException
+   */
+  public Result normalize(ChangeNotes notes, Collection<PatchSetApproval> approvals)
+      throws OrmException, IOException {
+    IdentifiedUser user = userFactory.create(notes.getChange().getOwner());
+    return normalize(notes, user, approvals);
+  }
+
+  /**
+   * @param notes change notes containing the given approvals.
+   * @param user current user.
+   * @param approvals list of approvals.
+   * @return copies of approvals normalized to the defined ranges for the label type. Approvals for
+   *     unknown labels are not included in the output.
+   */
+  public Result normalize(
+      ChangeNotes notes, CurrentUser user, Collection<PatchSetApproval> approvals)
+      throws IOException {
+    List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
+    List<PatchSetApproval> updated = Lists.newArrayListWithCapacity(approvals.size());
+    List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
+    LabelTypes labelTypes =
+        projectCache.checkedGet(notes.getProjectName()).getLabelTypes(notes, user);
+    for (PatchSetApproval psa : approvals) {
+      Change.Id changeId = psa.getKey().getParentKey().getParentKey();
+      checkArgument(
+          changeId.equals(notes.getChangeId()),
+          "Approval %s does not match change %s",
+          psa.getKey(),
+          notes.getChange().getKey());
+      if (psa.isLegacySubmit()) {
+        unchanged.add(psa);
+        continue;
+      }
+      LabelType label = labelTypes.byLabel(psa.getLabelId());
+      if (label == null) {
+        deleted.add(psa);
+        continue;
+      }
+      PatchSetApproval copy = copy(psa);
+      applyTypeFloor(label, copy);
+      if (copy.getValue() != psa.getValue()) {
+        updated.add(copy);
+      } else {
+        unchanged.add(psa);
+      }
+    }
+    return Result.create(unchanged, updated, deleted);
+  }
+
+  private PatchSetApproval copy(PatchSetApproval src) {
+    return new PatchSetApproval(src.getPatchSetId(), src);
+  }
+
+  private void applyTypeFloor(LabelType lt, PatchSetApproval a) {
+    LabelValue atMin = lt.getMin();
+    if (atMin != null && a.getValue() < atMin.getValue()) {
+      a.setValue(atMin.getValue());
+    }
+    LabelValue atMax = lt.getMax();
+    if (atMax != null && a.getValue() > atMax.getValue()) {
+      a.setValue(atMax.getValue());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LargeObjectException.java b/java/com/google/gerrit/server/git/LargeObjectException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/LargeObjectException.java
rename to java/com/google/gerrit/server/git/LargeObjectException.java
diff --git a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
new file mode 100644
index 0000000..23f2526
--- /dev/null
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -0,0 +1,313 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.FileVisitOption;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ConfigConstants;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.lib.RepositoryCacheConfig;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.storage.file.WindowCacheConfig;
+import org.eclipse.jgit.util.FS;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Manages Git repositories stored on the local filesystem. */
+@Singleton
+public class LocalDiskRepositoryManager implements GitRepositoryManager {
+  private static final Logger log = LoggerFactory.getLogger(LocalDiskRepositoryManager.class);
+
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(LocalDiskRepositoryManager.Lifecycle.class);
+    }
+  }
+
+  public static class Lifecycle implements LifecycleListener {
+    private final Config serverConfig;
+
+    @Inject
+    Lifecycle(@GerritServerConfig Config cfg) {
+      this.serverConfig = cfg;
+    }
+
+    @Override
+    public void start() {
+      RepositoryCacheConfig repoCacheCfg = new RepositoryCacheConfig();
+      repoCacheCfg.fromConfig(serverConfig);
+      repoCacheCfg.install();
+
+      WindowCacheConfig cfg = new WindowCacheConfig();
+      cfg.fromConfig(serverConfig);
+      if (serverConfig.getString("core", null, "streamFileThreshold") == null) {
+        long mx = Runtime.getRuntime().maxMemory();
+        int limit =
+            (int)
+                Math.min(
+                    mx / 4, // don't use more than 1/4 of the heap.
+                    2047 << 20); // cannot exceed array length
+        if ((5 << 20) < limit && limit % (1 << 20) != 0) {
+          // If the limit is at least 5 MiB but is not a whole multiple
+          // of MiB round up to the next one full megabyte. This is a very
+          // tiny memory increase in exchange for nice round units.
+          limit = ((limit / (1 << 20)) + 1) << 20;
+        }
+
+        String desc;
+        if (limit % (1 << 20) == 0) {
+          desc = String.format("%dm", limit / (1 << 20));
+        } else if (limit % (1 << 10) == 0) {
+          desc = String.format("%dk", limit / (1 << 10));
+        } else {
+          desc = String.format("%d", limit);
+        }
+        log.info(String.format("Defaulting core.streamFileThreshold to %s", desc));
+        cfg.setStreamFileThreshold(limit);
+      }
+      cfg.install();
+    }
+
+    @Override
+    public void stop() {}
+  }
+
+  private final Path basePath;
+
+  @Inject
+  LocalDiskRepositoryManager(SitePaths site, @GerritServerConfig Config cfg) {
+    basePath = site.resolve(cfg.getString("gerrit", null, "basePath"));
+    if (basePath == null) {
+      throw new IllegalStateException("gerrit.basePath must be configured");
+    }
+  }
+
+  /**
+   * Return the basePath under which the specified project is stored.
+   *
+   * @param name the name of the project
+   * @return base directory
+   */
+  public Path getBasePath(Project.NameKey name) {
+    return basePath;
+  }
+
+  @Override
+  public Repository openRepository(Project.NameKey name) throws RepositoryNotFoundException {
+    return openRepository(getBasePath(name), name);
+  }
+
+  private Repository openRepository(Path path, Project.NameKey name)
+      throws RepositoryNotFoundException {
+    if (isUnreasonableName(name)) {
+      throw new RepositoryNotFoundException("Invalid name: " + name);
+    }
+    FileKey loc = FileKey.lenient(path.resolve(name.get()).toFile(), FS.DETECTED);
+    try {
+      return RepositoryCache.open(loc);
+    } catch (IOException e1) {
+      final RepositoryNotFoundException e2;
+      e2 = new RepositoryNotFoundException("Cannot open repository " + name);
+      e2.initCause(e1);
+      throw e2;
+    }
+  }
+
+  @Override
+  public Repository createRepository(Project.NameKey name)
+      throws RepositoryNotFoundException, RepositoryCaseMismatchException, IOException {
+    Path path = getBasePath(name);
+    if (isUnreasonableName(name)) {
+      throw new RepositoryNotFoundException("Invalid name: " + name);
+    }
+
+    File dir = FileKey.resolve(path.resolve(name.get()).toFile(), FS.DETECTED);
+    if (dir != null) {
+      // Already exists on disk, use the repository we found.
+      //
+      Project.NameKey onDiskName = getProjectName(path, dir.getCanonicalFile().toPath());
+
+      if (!onDiskName.equals(name)) {
+        throw new RepositoryCaseMismatchException(name);
+      }
+
+      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 */);
+
+      StoredConfig config = db.getConfig();
+      config.setBoolean(
+          ConfigConstants.CONFIG_CORE_SECTION,
+          null,
+          ConfigConstants.CONFIG_KEY_LOGALLREFUPDATES,
+          true);
+      config.save();
+
+      // JGit only writes to the reflog for refs/meta/config if the log file
+      // already exists.
+      //
+      File metaConfigLog = new File(db.getDirectory(), "logs/" + RefNames.REFS_CONFIG);
+      if (!metaConfigLog.getParentFile().mkdirs() || !metaConfigLog.createNewFile()) {
+        log.error(
+            String.format(
+                "Failed to create ref log for %s in repository %s", RefNames.REFS_CONFIG, name));
+      }
+
+      return db;
+    } catch (IOException e1) {
+      final RepositoryNotFoundException e2;
+      e2 = new RepositoryNotFoundException("Cannot create repository " + name);
+      e2.initCause(e1);
+      throw e2;
+    }
+  }
+
+  private boolean isUnreasonableName(Project.NameKey nameKey) {
+    final String name = nameKey.get();
+
+    return name.length() == 0 // no empty paths
+        || name.charAt(name.length() - 1) == '/' // no suffix
+        || name.indexOf('\\') >= 0 // no windows/dos style paths
+        || name.charAt(0) == '/' // no absolute paths
+        || new File(name).isAbsolute() // no absolute paths
+        || name.startsWith("../") // no "l../etc/passwd"
+        || name.contains("/../") // no "foo/../etc/passwd"
+        || name.contains("/./") // "foo/./foo" is insane to ask
+        || name.contains("//") // windows UNC path can be "//..."
+        || name.contains(".git/") // no path segments that end with '.git' as "foo.git/bar"
+        || name.contains("?") // common unix wildcard
+        || name.contains("%") // wildcard or string parameter
+        || name.contains("*") // wildcard
+        || name.contains(":") // Could be used for absolute paths in windows?
+        || name.contains("<") // redirect input
+        || name.contains(">") // redirect output
+        || name.contains("|") // pipe
+        || name.contains("$") // dollar sign
+        || name.contains("\r") // carriage return
+        || name.contains("/+") // delimiter in /changes/
+        || name.contains("~"); // delimiter in /changes/
+  }
+
+  @Override
+  public SortedSet<Project.NameKey> list() {
+    ProjectVisitor visitor = new ProjectVisitor(basePath);
+    scanProjects(visitor);
+    return Collections.unmodifiableSortedSet(visitor.found);
+  }
+
+  protected void scanProjects(ProjectVisitor visitor) {
+    try {
+      Files.walkFileTree(
+          visitor.startFolder,
+          EnumSet.of(FileVisitOption.FOLLOW_LINKS),
+          Integer.MAX_VALUE,
+          visitor);
+    } catch (IOException e) {
+      log.error("Error walking repository tree " + visitor.startFolder.toAbsolutePath(), e);
+    }
+  }
+
+  private static Project.NameKey getProjectName(Path startFolder, Path p) {
+    String projectName = startFolder.relativize(p).toString();
+    if (File.separatorChar != '/') {
+      projectName = projectName.replace(File.separatorChar, '/');
+    }
+    if (projectName.endsWith(Constants.DOT_GIT_EXT)) {
+      int newLen = projectName.length() - Constants.DOT_GIT_EXT.length();
+      projectName = projectName.substring(0, newLen);
+    }
+    return new Project.NameKey(projectName);
+  }
+
+  protected class ProjectVisitor extends SimpleFileVisitor<Path> {
+    private final SortedSet<Project.NameKey> found = new TreeSet<>();
+    private Path startFolder;
+
+    public ProjectVisitor(Path startFolder) {
+      setStartFolder(startFolder);
+    }
+
+    public void setStartFolder(Path startFolder) {
+      this.startFolder = startFolder;
+    }
+
+    @Override
+    public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
+        throws IOException {
+      if (!dir.equals(startFolder) && isRepo(dir)) {
+        addProject(dir);
+        return FileVisitResult.SKIP_SUBTREE;
+      }
+      return FileVisitResult.CONTINUE;
+    }
+
+    @Override
+    public FileVisitResult visitFileFailed(Path file, IOException e) {
+      log.warn(e.getMessage());
+      return FileVisitResult.CONTINUE;
+    }
+
+    private boolean isRepo(Path p) {
+      String name = p.getFileName().toString();
+      return !name.equals(Constants.DOT_GIT)
+          && (name.endsWith(Constants.DOT_GIT_EXT)
+              || FileKey.isGitRepository(p.toFile(), FS.DETECTED));
+    }
+
+    private void addProject(Path p) {
+      Project.NameKey nameKey = getProjectName(startFolder, p);
+      if (getBasePath(nameKey).equals(startFolder)) {
+        if (isUnreasonableName(nameKey)) {
+          log.warn("Ignoring unreasonably named repository " + p.toAbsolutePath());
+        } else {
+          found.add(nameKey);
+        }
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/git/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/git/LocalMergeSuperSetComputation.java
new file mode 100644
index 0000000..e681145
--- /dev/null
+++ b/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/LockFailureException.java b/java/com/google/gerrit/server/git/LockFailureException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/LockFailureException.java
rename to java/com/google/gerrit/server/git/LockFailureException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java b/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java
rename to java/com/google/gerrit/server/git/MergeIdenticalTreeException.java
diff --git a/java/com/google/gerrit/server/git/MergeOp.java b/java/com/google/gerrit/server/git/MergeOp.java
new file mode 100644
index 0000000..c9ea482
--- /dev/null
+++ b/java/com/google/gerrit/server/git/MergeOp.java
@@ -0,0 +1,945 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toSet;
+
+import com.github.rholder.retry.Attempt;
+import com.github.rholder.retry.RetryListener;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.git.MergeOpRepoManager.OpenBranch;
+import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
+import com.google.gerrit.server.git.strategy.SubmitStrategy;
+import com.google.gerrit.server.git.strategy.SubmitStrategyFactory;
+import com.google.gerrit.server.git.strategy.SubmitStrategyListener;
+import com.google.gerrit.server.git.validators.MergeValidationException;
+import com.google.gerrit.server.git.validators.MergeValidators;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Merges changes in submission order into a single branch.
+ *
+ * <p>Branches are reduced to the minimum number of heads needed to merge everything. This allows
+ * commits to be entered into the queue in any order (such as ancestors before descendants) and only
+ * the most recent commit on any line of development will be merged. All unmerged commits along a
+ * line of development must be in the submission queue in order to merge the tip of that line.
+ *
+ * <p>Conflicts are handled by discarding the entire line of development and marking it as
+ * conflicting, even if an earlier commit along that same line can be merged cleanly.
+ */
+public class MergeOp implements AutoCloseable {
+  private static final Logger log = LoggerFactory.getLogger(MergeOp.class);
+
+  private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS = SubmitRuleOptions.builder().build();
+  private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_ALLOW_CLOSED =
+      SUBMIT_RULE_OPTIONS.toBuilder().allowClosed(true).build();
+
+  public static class CommitStatus {
+    private final ImmutableMap<Change.Id, ChangeData> changes;
+    private final ImmutableSetMultimap<Branch.NameKey, Change.Id> byBranch;
+    private final Map<Change.Id, CodeReviewCommit> commits;
+    private final ListMultimap<Change.Id, String> problems;
+    private final boolean allowClosed;
+
+    private CommitStatus(ChangeSet cs, boolean allowClosed) throws OrmException {
+      checkArgument(
+          !cs.furtherHiddenChanges(), "CommitStatus must not be called with hidden changes");
+      changes = cs.changesById();
+      ImmutableSetMultimap.Builder<Branch.NameKey, Change.Id> bb = ImmutableSetMultimap.builder();
+      for (ChangeData cd : cs.changes()) {
+        bb.put(cd.change().getDest(), cd.getId());
+      }
+      byBranch = bb.build();
+      commits = new HashMap<>();
+      problems = MultimapBuilder.treeKeys(comparing(Change.Id::get)).arrayListValues(1).build();
+      this.allowClosed = allowClosed;
+    }
+
+    public ImmutableSet<Change.Id> getChangeIds() {
+      return changes.keySet();
+    }
+
+    public ImmutableSet<Change.Id> getChangeIds(Branch.NameKey branch) {
+      return byBranch.get(branch);
+    }
+
+    public CodeReviewCommit get(Change.Id changeId) {
+      return commits.get(changeId);
+    }
+
+    public void put(CodeReviewCommit c) {
+      commits.put(c.change().getId(), c);
+    }
+
+    public void problem(Change.Id id, String problem) {
+      problems.put(id, problem);
+    }
+
+    public void logProblem(Change.Id id, Throwable t) {
+      String msg = "Error reading change";
+      log.error(msg + " " + id, t);
+      problems.put(id, msg);
+    }
+
+    public void logProblem(Change.Id id, String msg) {
+      log.error(msg + " " + id);
+      problems.put(id, msg);
+    }
+
+    public boolean isOk() {
+      return problems.isEmpty();
+    }
+
+    public ImmutableListMultimap<Change.Id, String> getProblems() {
+      return ImmutableListMultimap.copyOf(problems);
+    }
+
+    public List<SubmitRecord> getSubmitRecords(Change.Id id) {
+      // Use the cached submit records from the original ChangeData in the input
+      // ChangeSet, which were checked earlier in the integrate process. Even in
+      // the case of a race where the submit records may have changed, it makes
+      // more sense to store the original results of the submit rule evaluator
+      // than to fail at this point.
+      //
+      // However, do NOT expose that ChangeData directly, as it is way out of
+      // date by this point.
+      ChangeData cd = checkNotNull(changes.get(id), "ChangeData for %s", id);
+      return checkNotNull(
+          cd.getSubmitRecords(submitRuleOptions(allowClosed)),
+          "getSubmitRecord only valid after submit rules are evalutated");
+    }
+
+    public void maybeFailVerbose() throws ResourceConflictException {
+      if (isOk()) {
+        return;
+      }
+      String msg =
+          "Failed to submit "
+              + changes.size()
+              + " change"
+              + (changes.size() > 1 ? "s" : "")
+              + " due to the following problems:\n";
+      List<String> ps = new ArrayList<>(problems.keySet().size());
+      for (Change.Id id : problems.keySet()) {
+        ps.add("Change " + id + ": " + Joiner.on("; ").join(problems.get(id)));
+      }
+      throw new ResourceConflictException(msg + Joiner.on('\n').join(ps));
+    }
+
+    public void maybeFail(String msgPrefix) throws ResourceConflictException {
+      if (isOk()) {
+        return;
+      }
+      StringBuilder msg = new StringBuilder(msgPrefix).append(" of change");
+      Set<Change.Id> ids = problems.keySet();
+      if (ids.size() == 1) {
+        msg.append(" ").append(ids.iterator().next());
+      } else {
+        msg.append("s ").append(Joiner.on(", ").join(ids));
+      }
+      throw new ResourceConflictException(msg.toString());
+    }
+  }
+
+  private final ChangeMessagesUtil cmUtil;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final InternalUser.Factory internalUserFactory;
+  private final MergeSuperSet mergeSuperSet;
+  private final MergeValidators.Factory mergeValidatorsFactory;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final SubmitStrategyFactory submitStrategyFactory;
+  private final SubmoduleOp.Factory subOpFactory;
+  private final Provider<MergeOpRepoManager> ormProvider;
+  private final NotifyUtil notifyUtil;
+  private final RetryHelper retryHelper;
+
+  private Timestamp ts;
+  private RequestId submissionId;
+  private IdentifiedUser caller;
+
+  private MergeOpRepoManager orm;
+  private CommitStatus commitStatus;
+  private ReviewDb db;
+  private SubmitInput submitInput;
+  private ListMultimap<RecipientType, Account.Id> accountsToNotify;
+  private Set<Project.NameKey> allProjects;
+  private boolean dryrun;
+  private TopicMetrics topicMetrics;
+
+  @Inject
+  MergeOp(
+      ChangeMessagesUtil cmUtil,
+      BatchUpdate.Factory batchUpdateFactory,
+      InternalUser.Factory internalUserFactory,
+      MergeSuperSet mergeSuperSet,
+      MergeValidators.Factory mergeValidatorsFactory,
+      Provider<InternalChangeQuery> queryProvider,
+      SubmitStrategyFactory submitStrategyFactory,
+      SubmoduleOp.Factory subOpFactory,
+      Provider<MergeOpRepoManager> ormProvider,
+      NotifyUtil notifyUtil,
+      TopicMetrics topicMetrics,
+      RetryHelper retryHelper) {
+    this.cmUtil = cmUtil;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.internalUserFactory = internalUserFactory;
+    this.mergeSuperSet = mergeSuperSet;
+    this.mergeValidatorsFactory = mergeValidatorsFactory;
+    this.queryProvider = queryProvider;
+    this.submitStrategyFactory = submitStrategyFactory;
+    this.subOpFactory = subOpFactory;
+    this.ormProvider = ormProvider;
+    this.notifyUtil = notifyUtil;
+    this.retryHelper = retryHelper;
+    this.topicMetrics = topicMetrics;
+  }
+
+  @Override
+  public void close() {
+    if (orm != null) {
+      orm.close();
+    }
+  }
+
+  public static void checkSubmitRule(ChangeData cd, boolean allowClosed)
+      throws ResourceConflictException, OrmException {
+    PatchSet patchSet = cd.currentPatchSet();
+    if (patchSet == null) {
+      throw new ResourceConflictException("missing current patch set for change " + cd.getId());
+    }
+    List<SubmitRecord> results = getSubmitRecords(cd, allowClosed);
+    if (SubmitRecord.findOkRecord(results).isPresent()) {
+      // Rules supplied a valid solution.
+      return;
+    } else if (results.isEmpty()) {
+      throw new IllegalStateException(
+          String.format(
+              "SubmitRuleEvaluator.evaluate for change %s returned empty list for %s in %s",
+              cd.getId(), patchSet.getId(), cd.change().getProject().get()));
+    }
+
+    for (SubmitRecord record : results) {
+      switch (record.status) {
+        case CLOSED:
+          throw new ResourceConflictException("change is closed");
+
+        case RULE_ERROR:
+          throw new ResourceConflictException("submit rule error: " + record.errorMessage);
+
+        case NOT_READY:
+          throw new ResourceConflictException(describeLabels(cd, record.labels));
+
+        case FORCED:
+        case OK:
+        default:
+          throw new IllegalStateException(
+              String.format(
+                  "Unexpected SubmitRecord status %s for %s in %s",
+                  record.status, patchSet.getId().getId(), cd.change().getProject().get()));
+      }
+    }
+    throw new IllegalStateException();
+  }
+
+  private static SubmitRuleOptions submitRuleOptions(boolean allowClosed) {
+    return allowClosed ? SUBMIT_RULE_OPTIONS_ALLOW_CLOSED : SUBMIT_RULE_OPTIONS;
+  }
+
+  private static List<SubmitRecord> getSubmitRecords(ChangeData cd, boolean allowClosed)
+      throws OrmException {
+    return cd.submitRecords(submitRuleOptions(allowClosed));
+  }
+
+  private static String describeLabels(ChangeData cd, List<SubmitRecord.Label> labels)
+      throws OrmException {
+    List<String> labelResults = new ArrayList<>();
+    for (SubmitRecord.Label lbl : labels) {
+      switch (lbl.status) {
+        case OK:
+        case MAY:
+          break;
+
+        case REJECT:
+          labelResults.add("blocked by " + lbl.label);
+          break;
+
+        case NEED:
+          labelResults.add("needs " + lbl.label);
+          break;
+
+        case IMPOSSIBLE:
+          labelResults.add("needs " + lbl.label + " (check project access)");
+          break;
+
+        default:
+          throw new IllegalStateException(
+              String.format(
+                  "Unsupported SubmitRecord.Label %s for %s in %s",
+                  lbl, cd.change().currentPatchSetId(), cd.change().getProject()));
+      }
+    }
+    return Joiner.on("; ").join(labelResults);
+  }
+
+  private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
+      throws ResourceConflictException {
+    checkArgument(
+        !cs.furtherHiddenChanges(), "checkSubmitRulesAndState called for topic with hidden change");
+    for (ChangeData cd : cs.changes()) {
+      try {
+        Change.Status status = cd.change().getStatus();
+        if (status != Change.Status.NEW) {
+          if (!(status == Change.Status.MERGED && allowMerged)) {
+            commitStatus.problem(
+                cd.getId(),
+                "Change " + cd.getId() + " is " + cd.change().getStatus().toString().toLowerCase());
+          }
+        } else if (cd.change().isWorkInProgress()) {
+          commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
+        } else {
+          checkSubmitRule(cd, allowMerged);
+        }
+      } catch (ResourceConflictException e) {
+        commitStatus.problem(cd.getId(), e.getMessage());
+      } catch (OrmException e) {
+        String msg = "Error checking submit rules for change";
+        log.warn(msg + " " + cd.getId(), e);
+        commitStatus.problem(cd.getId(), msg);
+      }
+    }
+    commitStatus.maybeFailVerbose();
+  }
+
+  private void bypassSubmitRules(ChangeSet cs, boolean allowClosed) throws OrmException {
+    checkArgument(
+        !cs.furtherHiddenChanges(), "cannot bypass submit rules for topic with hidden change");
+    for (ChangeData cd : cs.changes()) {
+      List<SubmitRecord> records = new ArrayList<>(getSubmitRecords(cd, allowClosed));
+      SubmitRecord forced = new SubmitRecord();
+      forced.status = SubmitRecord.Status.FORCED;
+      records.add(forced);
+      cd.setSubmitRecords(submitRuleOptions(allowClosed), records);
+    }
+  }
+
+  /**
+   * Merges the given change.
+   *
+   * <p>Depending on the server configuration, more changes may be affected, e.g. by submission of a
+   * topic or via superproject subscriptions. All affected changes are integrated using the projects
+   * integration strategy.
+   *
+   * @param db the review database.
+   * @param change the change to be merged.
+   * @param caller the identity of the caller
+   * @param checkSubmitRules whether the prolog submit rules should be evaluated
+   * @param submitInput parameters regarding the merge
+   * @throws OrmException an error occurred reading or writing the database.
+   * @throws RestApiException if an error occurred.
+   * @throws PermissionBackendException if permissions can't be checked
+   * @throws IOException an error occurred reading from NoteDb.
+   */
+  public void merge(
+      ReviewDb db,
+      Change change,
+      IdentifiedUser caller,
+      boolean checkSubmitRules,
+      SubmitInput submitInput,
+      boolean dryrun)
+      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    this.submitInput = submitInput;
+    this.accountsToNotify = notifyUtil.resolveAccounts(submitInput.notifyDetails);
+    this.dryrun = dryrun;
+    this.caller = caller;
+    this.ts = TimeUtil.nowTs();
+    submissionId = RequestId.forChange(change);
+    this.db = db;
+    openRepoManager();
+
+    logDebug("Beginning integration of {}", change);
+    try {
+      ChangeSet cs = mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(db, change, caller);
+      checkState(
+          cs.ids().contains(change.getId()), "change %s missing from %s", change.getId(), cs);
+      if (cs.furtherHiddenChanges()) {
+        throw new AuthException(
+            "A change to be submitted with " + change.getId() + " is not visible");
+      }
+      logDebug("Calculated to merge {}", cs);
+
+      // Count cross-project submissions outside of the retry loop. The chance of a single project
+      // failing increases with the number of projects, so the failure count would be inflated if
+      // this metric were incremented inside of integrateIntoHistory.
+      int projects = cs.projects().size();
+      if (projects > 1) {
+        topicMetrics.topicSubmissions.increment();
+      }
+
+      RetryTracker retryTracker = new RetryTracker();
+      retryHelper.execute(
+          updateFactory -> {
+            long attempt = retryTracker.lastAttemptNumber + 1;
+            boolean isRetry = attempt > 1;
+            if (isRetry) {
+              logDebug("Retrying, attempt #{}; skipping merged changes", attempt);
+              this.ts = TimeUtil.nowTs();
+              openRepoManager();
+            }
+            this.commitStatus = new CommitStatus(cs, isRetry);
+            MergeSuperSet.reloadChanges(cs);
+            if (checkSubmitRules) {
+              logDebug("Checking submit rules and state");
+              checkSubmitRulesAndState(cs, isRetry);
+            } else {
+              logDebug("Bypassing submit rules");
+              bypassSubmitRules(cs, isRetry);
+            }
+            try {
+              integrateIntoHistory(cs);
+            } catch (IntegrationException e) {
+              logError("Error from integrateIntoHistory", e);
+              throw new ResourceConflictException(e.getMessage(), e);
+            }
+            return null;
+          },
+          RetryHelper.options()
+              .listener(retryTracker)
+              // Up to the entire submit operation is retried, including possibly many projects.
+              // Multiply the timeout by the number of projects we're actually attempting to submit.
+              .timeout(retryHelper.getDefaultTimeout().multipliedBy(cs.projects().size()))
+              .build());
+
+      if (projects > 1) {
+        topicMetrics.topicSubmissionsCompleted.increment();
+      }
+    } catch (IOException e) {
+      // Anything before the merge attempt is an error
+      throw new OrmException(e);
+    }
+  }
+
+  private void openRepoManager() {
+    if (orm != null) {
+      orm.close();
+    }
+    orm = ormProvider.get();
+    orm.setContext(db, ts, caller, submissionId);
+  }
+
+  private class RetryTracker implements RetryListener {
+    long lastAttemptNumber;
+
+    @Override
+    public <V> void onRetry(Attempt<V> attempt) {
+      lastAttemptNumber = attempt.getAttemptNumber();
+    }
+  }
+
+  @Singleton
+  private static class TopicMetrics {
+    final Counter0 topicSubmissions;
+    final Counter0 topicSubmissionsCompleted;
+
+    @Inject
+    TopicMetrics(MetricMaker metrics) {
+      topicSubmissions =
+          metrics.newCounter(
+              "topic/cross_project_submit",
+              new Description("Attempts at cross project topic submission").setRate());
+      topicSubmissionsCompleted =
+          metrics.newCounter(
+              "topic/cross_project_submit_completed",
+              new Description("Cross project topic submissions that concluded successfully")
+                  .setRate());
+    }
+  }
+
+  private void integrateIntoHistory(ChangeSet cs)
+      throws IntegrationException, RestApiException, UpdateException {
+    checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
+    logDebug("Beginning merge attempt on {}", cs);
+    Map<Branch.NameKey, BranchBatch> toSubmit = new HashMap<>();
+
+    ListMultimap<Branch.NameKey, ChangeData> cbb;
+    try {
+      cbb = cs.changesByBranch();
+    } catch (OrmException e) {
+      throw new IntegrationException("Error reading changes to submit", e);
+    }
+    Set<Branch.NameKey> branches = cbb.keySet();
+
+    for (Branch.NameKey branch : branches) {
+      OpenRepo or = openRepo(branch.getParentKey());
+      if (or != null) {
+        toSubmit.put(branch, validateChangeList(or, cbb.get(branch)));
+      }
+    }
+
+    // Done checks that don't involve running submit strategies.
+    commitStatus.maybeFailVerbose();
+
+    try {
+      SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
+      List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit, submoduleOp, dryrun);
+      this.allProjects = submoduleOp.getProjectsInOrder();
+      batchUpdateFactory.execute(
+          orm.batchUpdates(allProjects),
+          new SubmitStrategyListener(submitInput, strategies, commitStatus),
+          submissionId,
+          dryrun);
+    } catch (NoSuchProjectException e) {
+      throw new ResourceNotFoundException(e.getMessage());
+    } catch (IOException | SubmoduleException e) {
+      throw new IntegrationException(e);
+    } catch (UpdateException e) {
+      if (e.getCause() instanceof LockFailureException) {
+        // Lock failures are a special case: RetryHelper depends on this specific causal chain in
+        // order to trigger a retry. The downside of throwing here is we will not get the nicer
+        // error message constructed below, in the case where this is the final attempt and the
+        // operation is not retried further. This is not a huge downside, and is hopefully so rare
+        // as to be unnoticeable, assuming RetryHelper is retrying sufficiently.
+        throw e;
+      }
+
+      // BatchUpdate may have inadvertently wrapped an IntegrationException
+      // thrown by some legacy SubmitStrategyOp code that intended the error
+      // message to be user-visible. Copy the message from the wrapped
+      // exception.
+      //
+      // If you happen across one of these, the correct fix is to convert the
+      // inner IntegrationException to a ResourceConflictException.
+      String msg;
+      if (e.getCause() instanceof IntegrationException) {
+        msg = e.getCause().getMessage();
+      } else {
+        msg = genericMergeError(cs);
+      }
+      throw new IntegrationException(msg, e);
+    }
+  }
+
+  public Set<Project.NameKey> getAllProjects() {
+    return allProjects;
+  }
+
+  public MergeOpRepoManager getMergeOpRepoManager() {
+    return orm;
+  }
+
+  private List<SubmitStrategy> getSubmitStrategies(
+      Map<Branch.NameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp, boolean dryrun)
+      throws IntegrationException, NoSuchProjectException, IOException {
+    List<SubmitStrategy> strategies = new ArrayList<>();
+    Set<Branch.NameKey> allBranches = submoduleOp.getBranchesInOrder();
+    Set<CodeReviewCommit> allCommits =
+        toSubmit.values().stream().map(BranchBatch::commits).flatMap(Set::stream).collect(toSet());
+    for (Branch.NameKey branch : allBranches) {
+      OpenRepo or = orm.getRepo(branch.getParentKey());
+      if (toSubmit.containsKey(branch)) {
+        BranchBatch submitting = toSubmit.get(branch);
+        OpenBranch ob = or.getBranch(branch);
+        checkNotNull(
+            submitting.submitType(),
+            "null submit type for %s; expected to previously fail fast",
+            submitting);
+        Set<CodeReviewCommit> commitsToSubmit = submitting.commits();
+        ob.mergeTip = new MergeTip(ob.oldTip, commitsToSubmit);
+        SubmitStrategy strategy =
+            submitStrategyFactory.create(
+                submitting.submitType(),
+                db,
+                or.rw,
+                or.canMergeFlag,
+                getAlreadyAccepted(or, ob.oldTip),
+                allCommits,
+                branch,
+                caller,
+                ob.mergeTip,
+                commitStatus,
+                submissionId,
+                submitInput,
+                accountsToNotify,
+                submoduleOp,
+                dryrun);
+        strategies.add(strategy);
+        strategy.addOps(or.getUpdate(), commitsToSubmit);
+        if (submitting.submitType().equals(SubmitType.FAST_FORWARD_ONLY)
+            && submoduleOp.hasSubscription(branch)) {
+          submoduleOp.addOp(or.getUpdate(), branch);
+        }
+      } else {
+        // no open change for this branch
+        // add submodule triggered op into BatchUpdate
+        submoduleOp.addOp(or.getUpdate(), branch);
+      }
+    }
+    return strategies;
+  }
+
+  private Set<RevCommit> getAlreadyAccepted(OpenRepo or, CodeReviewCommit branchTip)
+      throws IntegrationException {
+    Set<RevCommit> alreadyAccepted = new HashSet<>();
+
+    if (branchTip != null) {
+      alreadyAccepted.add(branchTip);
+    }
+
+    try {
+      for (Ref r : or.repo.getRefDatabase().getRefs(Constants.R_HEADS).values()) {
+        try {
+          CodeReviewCommit aac = or.rw.parseCommit(r.getObjectId());
+          if (!commitStatus.commits.values().contains(aac)) {
+            alreadyAccepted.add(aac);
+          }
+        } catch (IncorrectObjectTypeException iote) {
+          // Not a commit? Skip over it.
+        }
+      }
+    } catch (IOException e) {
+      throw new IntegrationException("Failed to determine already accepted commits.", e);
+    }
+
+    logDebug("Found {} existing heads", alreadyAccepted.size());
+    return alreadyAccepted;
+  }
+
+  @AutoValue
+  abstract static class BranchBatch {
+    @Nullable
+    abstract SubmitType submitType();
+
+    abstract Set<CodeReviewCommit> commits();
+  }
+
+  private BranchBatch validateChangeList(OpenRepo or, Collection<ChangeData> submitted)
+      throws IntegrationException {
+    logDebug("Validating {} changes", submitted.size());
+    Set<CodeReviewCommit> toSubmit = new LinkedHashSet<>(submitted.size());
+    SetMultimap<ObjectId, PatchSet.Id> revisions = getRevisions(or, submitted);
+
+    SubmitType submitType = null;
+    ChangeData choseSubmitTypeFrom = null;
+    for (ChangeData cd : submitted) {
+      Change.Id changeId = cd.getId();
+      ChangeNotes notes;
+      Change chg;
+      SubmitType st;
+      try {
+        notes = cd.notes();
+        chg = cd.change();
+        st = getSubmitType(cd);
+      } catch (OrmException e) {
+        commitStatus.logProblem(changeId, e);
+        continue;
+      }
+
+      if (st == null) {
+        commitStatus.logProblem(changeId, "No submit type for change");
+        continue;
+      }
+      if (submitType == null) {
+        submitType = st;
+        choseSubmitTypeFrom = cd;
+      } else if (st != submitType) {
+        commitStatus.problem(
+            changeId,
+            String.format(
+                "Change has submit type %s, but previously chose submit type %s "
+                    + "from change %s in the same batch",
+                st, submitType, choseSubmitTypeFrom.getId()));
+        continue;
+      }
+      if (chg.currentPatchSetId() == null) {
+        String msg = "Missing current patch set on change";
+        logError(msg + " " + changeId);
+        commitStatus.problem(changeId, msg);
+        continue;
+      }
+
+      PatchSet ps;
+      Branch.NameKey destBranch = chg.getDest();
+      try {
+        ps = cd.currentPatchSet();
+      } catch (OrmException e) {
+        commitStatus.logProblem(changeId, e);
+        continue;
+      }
+      if (ps == null || ps.getRevision() == null || ps.getRevision().get() == null) {
+        commitStatus.logProblem(changeId, "Missing patch set or revision on change");
+        continue;
+      }
+
+      String idstr = ps.getRevision().get();
+      ObjectId id;
+      try {
+        id = ObjectId.fromString(idstr);
+      } catch (IllegalArgumentException e) {
+        commitStatus.logProblem(changeId, e);
+        continue;
+      }
+
+      if (!revisions.containsEntry(id, ps.getId())) {
+        // TODO this is actually an error, the branch is gone but we
+        // want to merge the issue. We can't safely do that if the
+        // tip is not reachable.
+        //
+        commitStatus.logProblem(
+            changeId,
+            "Revision "
+                + idstr
+                + " of patch set "
+                + ps.getPatchSetId()
+                + " does not match "
+                + ps.getId().toRefName()
+                + " for change");
+        continue;
+      }
+
+      CodeReviewCommit commit;
+      try {
+        commit = or.rw.parseCommit(id);
+      } catch (IOException e) {
+        commitStatus.logProblem(changeId, e);
+        continue;
+      }
+
+      commit.setNotes(notes);
+      commit.setPatchsetId(ps.getId());
+      commitStatus.put(commit);
+
+      MergeValidators mergeValidators = mergeValidatorsFactory.create();
+      try {
+        mergeValidators.validatePreMerge(
+            or.repo, commit, or.project, destBranch, ps.getId(), caller);
+      } catch (MergeValidationException mve) {
+        commitStatus.problem(changeId, mve.getMessage());
+        continue;
+      }
+      commit.add(or.canMergeFlag);
+      toSubmit.add(commit);
+    }
+    logDebug("Submitting on this run: {}", toSubmit);
+    return new AutoValue_MergeOp_BranchBatch(submitType, toSubmit);
+  }
+
+  private SetMultimap<ObjectId, PatchSet.Id> getRevisions(OpenRepo or, Collection<ChangeData> cds)
+      throws IntegrationException {
+    try {
+      List<String> refNames = new ArrayList<>(cds.size());
+      for (ChangeData cd : cds) {
+        Change c = cd.change();
+        if (c != null) {
+          refNames.add(c.currentPatchSetId().toRefName());
+        }
+      }
+      SetMultimap<ObjectId, PatchSet.Id> revisions =
+          MultimapBuilder.hashKeys(cds.size()).hashSetValues(1).build();
+      for (Map.Entry<String, Ref> e :
+          or.repo
+              .getRefDatabase()
+              .exactRef(refNames.toArray(new String[refNames.size()]))
+              .entrySet()) {
+        revisions.put(e.getValue().getObjectId(), PatchSet.Id.fromRef(e.getKey()));
+      }
+      return revisions;
+    } catch (IOException | OrmException e) {
+      throw new IntegrationException("Failed to validate changes", e);
+    }
+  }
+
+  private SubmitType getSubmitType(ChangeData cd) throws OrmException {
+    SubmitTypeRecord str = cd.submitTypeRecord();
+    return str.isOk() ? str.type : null;
+  }
+
+  private OpenRepo openRepo(Project.NameKey project) throws IntegrationException {
+    try {
+      return orm.getRepo(project);
+    } catch (NoSuchProjectException e) {
+      logWarn("Project " + project + " no longer exists, abandoning open changes.");
+      abandonAllOpenChangeForDeletedProject(project);
+    } catch (IOException e) {
+      throw new IntegrationException("Error opening project " + project, e);
+    }
+    return null;
+  }
+
+  private void abandonAllOpenChangeForDeletedProject(Project.NameKey destProject) {
+    try {
+      for (ChangeData cd : queryProvider.get().byProjectOpen(destProject)) {
+        try (BatchUpdate bu =
+            batchUpdateFactory.create(db, destProject, internalUserFactory.create(), ts)) {
+          bu.setRequestId(submissionId);
+          bu.addOp(
+              cd.getId(),
+              new BatchUpdateOp() {
+                @Override
+                public boolean updateChange(ChangeContext ctx) throws OrmException {
+                  Change change = ctx.getChange();
+                  if (!change.getStatus().isOpen()) {
+                    return false;
+                  }
+
+                  change.setStatus(Change.Status.ABANDONED);
+
+                  ChangeMessage msg =
+                      ChangeMessagesUtil.newMessage(
+                          change.currentPatchSetId(),
+                          internalUserFactory.create(),
+                          change.getLastUpdatedOn(),
+                          ChangeMessagesUtil.TAG_MERGED,
+                          "Project was deleted.");
+                  cmUtil.addChangeMessage(
+                      ctx.getDb(), ctx.getUpdate(change.currentPatchSetId()), msg);
+
+                  return true;
+                }
+              });
+          try {
+            bu.execute();
+          } catch (UpdateException | RestApiException e) {
+            logWarn("Cannot abandon changes for deleted project " + destProject, e);
+          }
+        }
+      }
+    } catch (OrmException e) {
+      logWarn("Cannot abandon changes for deleted project " + destProject, e);
+    }
+  }
+
+  private String genericMergeError(ChangeSet cs) {
+    int c = cs.size();
+    if (c == 1) {
+      return "Error submitting change";
+    }
+    int p = cs.projects().size();
+    if (p == 1) {
+      // Fused updates: it's correct to say that none of the n changes were submitted.
+      return "Error submitting " + c + " changes";
+    }
+    // Multiple projects involved, but we don't know at this point what failed. At least give the
+    // user a heads up that some changes may be unsubmitted, even if the change screen they land on
+    // after the error message says that this particular change was submitted.
+    return "Error submitting some of the "
+        + c
+        + " changes to one or more of the "
+        + p
+        + " projects involved; some projects may have submitted successfully, but others may have"
+        + " failed";
+  }
+
+  private void logDebug(String msg, Object... args) {
+    if (log.isDebugEnabled()) {
+      log.debug(submissionId + msg, args);
+    }
+  }
+
+  private void logWarn(String msg, Throwable t) {
+    if (log.isWarnEnabled()) {
+      log.warn(submissionId + msg, t);
+    }
+  }
+
+  private void logWarn(String msg) {
+    if (log.isWarnEnabled()) {
+      log.warn(submissionId + msg);
+    }
+  }
+
+  private void logError(String msg, Throwable t) {
+    if (log.isErrorEnabled()) {
+      if (t != null) {
+        log.error(submissionId + msg, t);
+      } else {
+        log.error(submissionId + msg);
+      }
+    }
+  }
+
+  private void logError(String msg) {
+    logError(msg, null);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java b/java/com/google/gerrit/server/git/MergeOpRepoManager.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
rename to java/com/google/gerrit/server/git/MergeOpRepoManager.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java b/java/com/google/gerrit/server/git/MergeSorter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java
rename to java/com/google/gerrit/server/git/MergeSorter.java
diff --git a/java/com/google/gerrit/server/git/MergeSuperSet.java b/java/com/google/gerrit/server/git/MergeSuperSet.java
new file mode 100644
index 0000000..84676d7
--- /dev/null
+++ b/java/com/google/gerrit/server/git/MergeSuperSet.java
@@ -0,0 +1,214 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
+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.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Calculates the minimal superset of changes required to be merged.
+ *
+ * <p>This includes all parents between a change and the tip of its target branch for the
+ * merging/rebasing submit strategies. For the cherry-pick strategy no additional changes are
+ * included.
+ *
+ * <p>If change.submitWholeTopic is enabled, also all changes of the topic and their parents are
+ * included.
+ */
+public class MergeSuperSet {
+
+  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);
+    }
+  }
+
+  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().
+    return q.setRequestedFields(ChangeField.CHANGE, ChangeField.PATCH_SET, ChangeField.MERGEABLE);
+  }
+
+  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 MergeOpRepoManager orm;
+  private boolean closeOrm;
+
+  @Inject
+  MergeSuperSet(
+      @GerritServerConfig Config cfg,
+      ChangeData.Factory changeDataFactory,
+      Provider<InternalChangeQuery> queryProvider,
+      Provider<MergeOpRepoManager> repoManagerProvider,
+      DynamicItem<MergeSuperSetComputation> mergeSuperSetComputation,
+      PermissionBackend permissionBackend) {
+    this.cfg = cfg;
+    this.changeDataFactory = changeDataFactory;
+    this.queryProvider = queryProvider;
+    this.repoManagerProvider = repoManagerProvider;
+    this.mergeSuperSetComputation = mergeSuperSetComputation;
+    this.permissionBackend = permissionBackend;
+  }
+
+  public static boolean wholeTopicEnabled(Config config) {
+    return config.getBoolean("change", null, "submitWholeTopic", false);
+  }
+
+  public MergeSuperSet setMergeOpRepoManager(MergeOpRepoManager orm) {
+    checkState(this.orm == null);
+    this.orm = checkNotNull(orm);
+    closeOrm = false;
+    return this;
+  }
+
+  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 changeSet =
+          new ChangeSet(
+              cd, permissionBackend.user(user).change(cd).database(db).test(ChangePermission.READ));
+      if (wholeTopicEnabled(cfg)) {
+        return completeChangeSetIncludingTopics(db, changeSet, user);
+      }
+      return mergeSuperSetComputation.get().completeWithoutTopic(db, orm, changeSet, user);
+    } finally {
+      if (closeOrm && orm != null) {
+        orm.close();
+        orm = null;
+      }
+    }
+  }
+
+  /**
+   * Completes {@code changeSet} with any additional changes from its topics
+   *
+   * <p>{@link #completeChangeSetIncludingTopics} calls this repeatedly, alternating with {@link
+   * 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.
+   *
+   * @return the resulting larger {@link ChangeSet}
+   */
+  private ChangeSet topicClosure(
+      ReviewDb db,
+      ChangeSet changeSet,
+      CurrentUser user,
+      Set<String> topicsSeen,
+      Set<String> visibleTopicsSeen)
+      throws OrmException, PermissionBackendException {
+    List<ChangeData> visibleChanges = new ArrayList<>();
+    List<ChangeData> nonVisibleChanges = new ArrayList<>();
+
+    for (ChangeData cd : changeSet.changes()) {
+      visibleChanges.add(cd);
+      String topic = cd.change().getTopic();
+      if (Strings.isNullOrEmpty(topic) || visibleTopicsSeen.contains(topic)) {
+        continue;
+      }
+      for (ChangeData topicCd : byTopicOpen(topic)) {
+        if (canRead(db, user, topicCd)) {
+          visibleChanges.add(topicCd);
+        } else {
+          nonVisibleChanges.add(topicCd);
+        }
+      }
+      topicsSeen.add(topic);
+      visibleTopicsSeen.add(topic);
+    }
+    for (ChangeData cd : changeSet.nonVisibleChanges()) {
+      nonVisibleChanges.add(cd);
+      String topic = cd.change().getTopic();
+      if (Strings.isNullOrEmpty(topic) || topicsSeen.contains(topic)) {
+        continue;
+      }
+      for (ChangeData topicCd : byTopicOpen(topic)) {
+        nonVisibleChanges.add(topicCd);
+      }
+      topicsSeen.add(topic);
+    }
+    return new ChangeSet(visibleChanges, nonVisibleChanges);
+  }
+
+  private ChangeSet completeChangeSetIncludingTopics(
+      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;
+      changeSet = mergeSuperSetComputation.get().completeWithoutTopic(db, orm, changeSet, user);
+      changeSet = topicClosure(db, changeSet, user, topicsSeen, visibleTopicsSeen);
+      seen = topicsSeen.size() + visibleTopicsSeen.size();
+    } while (seen != oldSeen);
+    return changeSet;
+  }
+
+  private List<ChangeData> byTopicOpen(String topic) throws OrmException {
+    return query(queryProvider.get()).byTopicOpen(topic);
+  }
+
+  private boolean canRead(ReviewDb db, CurrentUser user, ChangeData cd)
+      throws PermissionBackendException {
+    return permissionBackend.user(user).change(cd).database(db).test(ChangePermission.READ);
+  }
+}
diff --git a/java/com/google/gerrit/server/git/MergeSuperSetComputation.java b/java/com/google/gerrit/server/git/MergeSuperSetComputation.java
new file mode 100644
index 0000000..63405ba
--- /dev/null
+++ b/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/MergeTip.java b/java/com/google/gerrit/server/git/MergeTip.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/MergeTip.java
rename to java/com/google/gerrit/server/git/MergeTip.java
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
new file mode 100644
index 0000000..f0712f3
--- /dev/null
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -0,0 +1,881 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSet.Id;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.strategy.CommitMergeStatus;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import org.eclipse.jgit.errors.AmbiguousObjectException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.LargeObjectException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.NoMergeBaseException;
+import org.eclipse.jgit.errors.NoMergeBaseException.MergeBaseFailureReason;
+import org.eclipse.jgit.errors.RevisionSyntaxException;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.MergeStrategy;
+import org.eclipse.jgit.merge.Merger;
+import org.eclipse.jgit.merge.ResolveMerger;
+import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
+import org.eclipse.jgit.merge.ThreeWayMerger;
+import org.eclipse.jgit.revwalk.FooterKey;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevFlag;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Utility methods used during the merge process.
+ *
+ * <p><strong>Note:</strong> Unless otherwise specified, the methods in this class <strong>do
+ * not</strong> flush {@link ObjectInserter}s. Callers that want to read back objects before
+ * flushing should use {@link ObjectInserter#newReader()}. This is already the default behavior of
+ * {@code BatchUpdate}.
+ */
+public class MergeUtil {
+  private static final Logger log = LoggerFactory.getLogger(MergeUtil.class);
+
+  static class PluggableCommitMessageGenerator {
+    private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+
+    @Inject
+    PluggableCommitMessageGenerator(DynamicSet<ChangeMessageModifier> changeMessageModifiers) {
+      this.changeMessageModifiers = changeMessageModifiers;
+    }
+
+    public String generate(
+        RevCommit original, RevCommit mergeTip, Branch.NameKey dest, String current) {
+      checkNotNull(original.getRawBuffer());
+      if (mergeTip != null) {
+        checkNotNull(mergeTip.getRawBuffer());
+      }
+      for (ChangeMessageModifier changeMessageModifier : changeMessageModifiers) {
+        current = changeMessageModifier.onSubmit(current, original, mergeTip, dest);
+        checkNotNull(
+            current,
+            changeMessageModifier.getClass().getName()
+                + ".OnSubmit returned null instead of new commit message");
+      }
+      return current;
+    }
+  }
+
+  private static final String R_HEADS_MASTER = Constants.R_HEADS + Constants.MASTER;
+
+  public static boolean useRecursiveMerge(Config cfg) {
+    return cfg.getBoolean("core", null, "useRecursiveMerge", true);
+  }
+
+  public static ThreeWayMergeStrategy getMergeStrategy(Config cfg) {
+    return useRecursiveMerge(cfg) ? MergeStrategy.RECURSIVE : MergeStrategy.RESOLVE;
+  }
+
+  public interface Factory {
+    MergeUtil create(ProjectState project);
+
+    MergeUtil create(ProjectState project, boolean useContentMerge);
+  }
+
+  private final Provider<ReviewDb> db;
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final Provider<String> urlProvider;
+  private final ApprovalsUtil approvalsUtil;
+  private final ProjectState project;
+  private final boolean useContentMerge;
+  private final boolean useRecursiveMerge;
+  private final PluggableCommitMessageGenerator commitMessageGenerator;
+
+  @AssistedInject
+  MergeUtil(
+      @GerritServerConfig Config serverConfig,
+      Provider<ReviewDb> db,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      ApprovalsUtil approvalsUtil,
+      PluggableCommitMessageGenerator commitMessageGenerator,
+      @Assisted ProjectState project) {
+    this(
+        serverConfig,
+        db,
+        identifiedUserFactory,
+        urlProvider,
+        approvalsUtil,
+        project,
+        commitMessageGenerator,
+        project.is(BooleanProjectConfig.USE_CONTENT_MERGE));
+  }
+
+  @AssistedInject
+  MergeUtil(
+      @GerritServerConfig Config serverConfig,
+      Provider<ReviewDb> db,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      ApprovalsUtil approvalsUtil,
+      @Assisted ProjectState project,
+      PluggableCommitMessageGenerator commitMessageGenerator,
+      @Assisted boolean useContentMerge) {
+    this.db = db;
+    this.identifiedUserFactory = identifiedUserFactory;
+    this.urlProvider = urlProvider;
+    this.approvalsUtil = approvalsUtil;
+    this.project = project;
+    this.useContentMerge = useContentMerge;
+    this.useRecursiveMerge = useRecursiveMerge(serverConfig);
+    this.commitMessageGenerator = commitMessageGenerator;
+  }
+
+  public CodeReviewCommit getFirstFastForward(
+      CodeReviewCommit mergeTip, RevWalk rw, List<CodeReviewCommit> toMerge)
+      throws IntegrationException {
+    for (Iterator<CodeReviewCommit> i = toMerge.iterator(); i.hasNext(); ) {
+      try {
+        final CodeReviewCommit n = i.next();
+        if (mergeTip == null || rw.isMergedInto(mergeTip, n)) {
+          i.remove();
+          return n;
+        }
+      } catch (IOException e) {
+        throw new IntegrationException("Cannot fast-forward test during merge", e);
+      }
+    }
+    return mergeTip;
+  }
+
+  public List<CodeReviewCommit> reduceToMinimalMerge(
+      MergeSorter mergeSorter, Collection<CodeReviewCommit> toSort) throws IntegrationException {
+    List<CodeReviewCommit> result = new ArrayList<>();
+    try {
+      result.addAll(mergeSorter.sort(toSort));
+    } catch (IOException e) {
+      throw new IntegrationException("Branch head sorting failed", e);
+    }
+    Collections.sort(result, CodeReviewCommit.ORDER);
+    return result;
+  }
+
+  public CodeReviewCommit createCherryPickFromCommit(
+      ObjectInserter inserter,
+      Config repoConfig,
+      RevCommit mergeTip,
+      RevCommit originalCommit,
+      PersonIdent cherryPickCommitterIdent,
+      String commitMsg,
+      CodeReviewRevWalk rw,
+      int parentIndex,
+      boolean ignoreIdenticalTree)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException,
+          MergeIdenticalTreeException, MergeConflictException {
+
+    final ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
+
+    m.setBase(originalCommit.getParent(parentIndex));
+    if (m.merge(mergeTip, originalCommit)) {
+      ObjectId tree = m.getResultTreeId();
+      if (tree.equals(mergeTip.getTree()) && !ignoreIdenticalTree) {
+        throw new MergeIdenticalTreeException("identical tree");
+      }
+
+      CommitBuilder mergeCommit = new CommitBuilder();
+      mergeCommit.setTreeId(tree);
+      mergeCommit.setParentId(mergeTip);
+      mergeCommit.setAuthor(originalCommit.getAuthorIdent());
+      mergeCommit.setCommitter(cherryPickCommitterIdent);
+      mergeCommit.setMessage(commitMsg);
+      matchAuthorToCommitterDate(project, mergeCommit);
+      return rw.parseCommit(inserter.insert(mergeCommit));
+    }
+    throw new MergeConflictException("merge conflict");
+  }
+
+  public static RevCommit createMergeCommit(
+      ObjectInserter inserter,
+      Config repoConfig,
+      RevCommit mergeTip,
+      RevCommit originalCommit,
+      String mergeStrategy,
+      PersonIdent committerIndent,
+      String commitMsg,
+      RevWalk rw)
+      throws IOException, MergeIdenticalTreeException, MergeConflictException {
+
+    if (!MergeStrategy.THEIRS.getName().equals(mergeStrategy)
+        && rw.isMergedInto(originalCommit, mergeTip)) {
+      throw new ChangeAlreadyMergedException(
+          "'" + originalCommit.getName() + "' has already been merged");
+    }
+
+    Merger m = newMerger(inserter, repoConfig, mergeStrategy);
+    if (m.merge(false, mergeTip, originalCommit)) {
+      ObjectId tree = m.getResultTreeId();
+
+      CommitBuilder mergeCommit = new CommitBuilder();
+      mergeCommit.setTreeId(tree);
+      mergeCommit.setParentIds(mergeTip, originalCommit);
+      mergeCommit.setAuthor(committerIndent);
+      mergeCommit.setCommitter(committerIndent);
+      mergeCommit.setMessage(commitMsg);
+      return rw.parseCommit(inserter.insert(mergeCommit));
+    }
+    List<String> conflicts = ImmutableList.of();
+    if (m instanceof ResolveMerger) {
+      conflicts = ((ResolveMerger) m).getUnmergedPaths();
+    }
+    throw new MergeConflictException(createConflictMessage(conflicts));
+  }
+
+  public static String createConflictMessage(List<String> conflicts) {
+    StringBuilder sb = new StringBuilder("merge conflict(s)");
+    for (String c : conflicts) {
+      sb.append('\n' + c);
+    }
+    return sb.toString();
+  }
+
+  /**
+   * Adds footers to existing commit message based on the state of the change.
+   *
+   * <p>This adds the following footers if they are missing:
+   *
+   * <ul>
+   *   <li>Reviewed-on: <i>url</i>
+   *   <li>Reviewed-by | Tested-by | <i>Other-Label-Name</i>: <i>reviewer</i>
+   *   <li>Change-Id
+   * </ul>
+   *
+   * @param n
+   * @param notes
+   * @param user
+   * @param psId
+   * @return new message
+   */
+  private String createDetailedCommitMessage(
+      RevCommit n, ChangeNotes notes, CurrentUser user, PatchSet.Id psId) {
+    Change c = notes.getChange();
+    final List<FooterLine> footers = n.getFooterLines();
+    final StringBuilder msgbuf = new StringBuilder();
+    msgbuf.append(n.getFullMessage());
+
+    if (msgbuf.length() == 0) {
+      // WTF, an empty commit message?
+      msgbuf.append("<no commit message provided>");
+    }
+    if (msgbuf.charAt(msgbuf.length() - 1) != '\n') {
+      // Missing a trailing LF? Correct it (perhaps the editor was broken).
+      msgbuf.append('\n');
+    }
+    if (footers.isEmpty()) {
+      // Doesn't end in a "Signed-off-by: ..." style line? Add another line
+      // break to start a new paragraph for the reviewed-by tag lines.
+      //
+      msgbuf.append('\n');
+    }
+
+    if (!contains(footers, FooterConstants.CHANGE_ID, c.getKey().get())) {
+      msgbuf.append(FooterConstants.CHANGE_ID.getName());
+      msgbuf.append(": ");
+      msgbuf.append(c.getKey().get());
+      msgbuf.append('\n');
+    }
+
+    final String siteUrl = urlProvider.get();
+    if (siteUrl != null) {
+      final String url = siteUrl + c.getId().get();
+      if (!contains(footers, FooterConstants.REVIEWED_ON, url)) {
+        msgbuf.append(FooterConstants.REVIEWED_ON.getName());
+        msgbuf.append(": ");
+        msgbuf.append(url);
+        msgbuf.append('\n');
+      }
+    }
+
+    PatchSetApproval submitAudit = null;
+
+    for (PatchSetApproval a : safeGetApprovals(notes, user, psId)) {
+      if (a.getValue() <= 0) {
+        // Negative votes aren't counted.
+        continue;
+      }
+
+      if (a.isLegacySubmit()) {
+        // Submit is treated specially, below (becomes committer)
+        //
+        if (submitAudit == null || a.getGranted().compareTo(submitAudit.getGranted()) > 0) {
+          submitAudit = a;
+        }
+        continue;
+      }
+
+      final Account acc = identifiedUserFactory.create(a.getAccountId()).getAccount();
+      final StringBuilder identbuf = new StringBuilder();
+      if (acc.getFullName() != null && acc.getFullName().length() > 0) {
+        if (identbuf.length() > 0) {
+          identbuf.append(' ');
+        }
+        identbuf.append(acc.getFullName());
+      }
+      if (acc.getPreferredEmail() != null && acc.getPreferredEmail().length() > 0) {
+        if (isSignedOffBy(footers, acc.getPreferredEmail())) {
+          continue;
+        }
+        if (identbuf.length() > 0) {
+          identbuf.append(' ');
+        }
+        identbuf.append('<');
+        identbuf.append(acc.getPreferredEmail());
+        identbuf.append('>');
+      }
+      if (identbuf.length() == 0) {
+        // Nothing reasonable to describe them by? Ignore them.
+        continue;
+      }
+
+      final String tag;
+      if (isCodeReview(a.getLabelId())) {
+        tag = "Reviewed-by";
+      } else if (isVerified(a.getLabelId())) {
+        tag = "Tested-by";
+      } else {
+        final LabelType lt = project.getLabelTypes().byLabel(a.getLabelId());
+        if (lt == null) {
+          continue;
+        }
+        tag = lt.getName();
+      }
+
+      if (!contains(footers, new FooterKey(tag), identbuf.toString())) {
+        msgbuf.append(tag);
+        msgbuf.append(": ");
+        msgbuf.append(identbuf);
+        msgbuf.append('\n');
+      }
+    }
+    return msgbuf.toString();
+  }
+
+  public String createCommitMessageOnSubmit(CodeReviewCommit n, RevCommit mergeTip) {
+    return createCommitMessageOnSubmit(
+        n,
+        mergeTip,
+        n.notes(),
+        identifiedUserFactory.create(n.notes().getChange().getOwner()),
+        n.getPatchsetId());
+  }
+
+  /**
+   * Creates a commit message for a change, which can be customized by plugins.
+   *
+   * <p>By default, adds footers to existing commit message based on the state of the change.
+   * Plugins implementing {@link ChangeMessageModifier} can modify the resulting commit message
+   * arbitrarily.
+   *
+   * @param n
+   * @param mergeTip
+   * @param notes
+   * @param user
+   * @param id
+   * @return new message
+   */
+  public String createCommitMessageOnSubmit(
+      RevCommit n, RevCommit mergeTip, ChangeNotes notes, CurrentUser user, Id id) {
+    return commitMessageGenerator.generate(
+        n, mergeTip, notes.getChange().getDest(), createDetailedCommitMessage(n, notes, user, id));
+  }
+
+  private static boolean isCodeReview(LabelId id) {
+    return "Code-Review".equalsIgnoreCase(id.get());
+  }
+
+  private static boolean isVerified(LabelId id) {
+    return "Verified".equalsIgnoreCase(id.get());
+  }
+
+  private Iterable<PatchSetApproval> safeGetApprovals(
+      ChangeNotes notes, CurrentUser user, PatchSet.Id psId) {
+    try {
+      return approvalsUtil.byPatchSet(db.get(), notes, user, psId, null, null);
+    } catch (OrmException e) {
+      log.error("Can't read approval records for " + psId, e);
+      return Collections.emptyList();
+    }
+  }
+
+  private static boolean contains(List<FooterLine> footers, FooterKey key, String val) {
+    for (FooterLine line : footers) {
+      if (line.matches(key) && val.equals(line.getValue())) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static boolean isSignedOffBy(List<FooterLine> footers, String email) {
+    for (FooterLine line : footers) {
+      if (line.matches(FooterKey.SIGNED_OFF_BY) && email.equals(line.getEmailAddress())) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public boolean canMerge(
+      MergeSorter mergeSorter, Repository repo, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+      throws IntegrationException {
+    if (hasMissingDependencies(mergeSorter, toMerge)) {
+      return false;
+    }
+
+    try (ObjectInserter ins = new InMemoryInserter(repo)) {
+      return newThreeWayMerger(ins, repo.getConfig()).merge(new AnyObjectId[] {mergeTip, toMerge});
+    } catch (LargeObjectException e) {
+      log.warn("Cannot merge due to LargeObjectException: " + toMerge.name());
+      return false;
+    } catch (NoMergeBaseException e) {
+      return false;
+    } catch (IOException e) {
+      throw new IntegrationException("Cannot merge " + toMerge.name(), e);
+    }
+  }
+
+  public boolean canFastForward(
+      MergeSorter mergeSorter,
+      CodeReviewCommit mergeTip,
+      CodeReviewRevWalk rw,
+      CodeReviewCommit toMerge)
+      throws IntegrationException {
+    if (hasMissingDependencies(mergeSorter, toMerge)) {
+      return false;
+    }
+
+    try {
+      return mergeTip == null
+          || rw.isMergedInto(mergeTip, toMerge)
+          || rw.isMergedInto(toMerge, mergeTip);
+    } catch (IOException e) {
+      throw new IntegrationException("Cannot fast-forward test during merge", e);
+    }
+  }
+
+  public boolean canCherryPick(
+      MergeSorter mergeSorter,
+      Repository repo,
+      CodeReviewCommit mergeTip,
+      CodeReviewRevWalk rw,
+      CodeReviewCommit toMerge)
+      throws IntegrationException {
+    if (mergeTip == null) {
+      // The branch is unborn. Fast-forward is possible.
+      //
+      return true;
+    }
+
+    if (toMerge.getParentCount() == 0) {
+      // Refuse to merge a root commit into an existing branch,
+      // we cannot obtain a delta for the cherry-pick to apply.
+      //
+      return false;
+    }
+
+    if (toMerge.getParentCount() == 1) {
+      // If there is only one parent, a cherry-pick can be done by
+      // taking the delta relative to that one parent and redoing
+      // that on the current merge tip.
+      //
+      try (ObjectInserter ins = new InMemoryInserter(repo)) {
+        ThreeWayMerger m = newThreeWayMerger(ins, repo.getConfig());
+        m.setBase(toMerge.getParent(0));
+        return m.merge(mergeTip, toMerge);
+      } catch (IOException e) {
+        throw new IntegrationException(
+            String.format(
+                "Cannot merge commit %s with mergetip %s", toMerge.name(), mergeTip.name()),
+            e);
+      }
+    }
+
+    // There are multiple parents, so this is a merge commit. We
+    // don't want to cherry-pick the merge as clients can't easily
+    // rebase their history with that merge present and replaced
+    // by an equivalent merge with a different first parent. So
+    // instead behave as though MERGE_IF_NECESSARY was configured.
+    //
+    return canFastForward(mergeSorter, mergeTip, rw, toMerge)
+        || canMerge(mergeSorter, repo, mergeTip, toMerge);
+  }
+
+  public boolean hasMissingDependencies(MergeSorter mergeSorter, CodeReviewCommit toMerge)
+      throws IntegrationException {
+    try {
+      return !mergeSorter.sort(Collections.singleton(toMerge)).contains(toMerge);
+    } catch (IOException e) {
+      throw new IntegrationException("Branch head sorting failed", e);
+    }
+  }
+
+  public CodeReviewCommit mergeOneCommit(
+      PersonIdent author,
+      PersonIdent committer,
+      CodeReviewRevWalk rw,
+      ObjectInserter inserter,
+      Config repoConfig,
+      Branch.NameKey destBranch,
+      CodeReviewCommit mergeTip,
+      CodeReviewCommit n)
+      throws IntegrationException {
+    ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
+    try {
+      if (m.merge(new AnyObjectId[] {mergeTip, n})) {
+        return writeMergeCommit(
+            author, committer, rw, inserter, destBranch, mergeTip, m.getResultTreeId(), n);
+      }
+      failed(rw, mergeTip, n, CommitMergeStatus.PATH_CONFLICT);
+    } catch (NoMergeBaseException e) {
+      try {
+        failed(rw, mergeTip, n, getCommitMergeStatus(e.getReason()));
+      } catch (IOException e2) {
+        throw new IntegrationException("Cannot merge " + n.name(), e);
+      }
+    } catch (IOException e) {
+      throw new IntegrationException("Cannot merge " + n.name(), e);
+    }
+    return mergeTip;
+  }
+
+  private static CommitMergeStatus getCommitMergeStatus(MergeBaseFailureReason reason) {
+    switch (reason) {
+      case MULTIPLE_MERGE_BASES_NOT_SUPPORTED:
+      case TOO_MANY_MERGE_BASES:
+      default:
+        return CommitMergeStatus.MANUAL_RECURSIVE_MERGE;
+      case CONFLICTS_DURING_MERGE_BASE_CALCULATION:
+        return CommitMergeStatus.PATH_CONFLICT;
+    }
+  }
+
+  private static CodeReviewCommit failed(
+      CodeReviewRevWalk rw,
+      CodeReviewCommit mergeTip,
+      CodeReviewCommit n,
+      CommitMergeStatus failure)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException {
+    rw.reset();
+    rw.markStart(n);
+    rw.markUninteresting(mergeTip);
+    CodeReviewCommit failed;
+    while ((failed = rw.next()) != null) {
+      failed.setStatusCode(failure);
+    }
+    return failed;
+  }
+
+  public CodeReviewCommit writeMergeCommit(
+      PersonIdent author,
+      PersonIdent committer,
+      CodeReviewRevWalk rw,
+      ObjectInserter inserter,
+      Branch.NameKey destBranch,
+      CodeReviewCommit mergeTip,
+      ObjectId treeId,
+      CodeReviewCommit n)
+      throws IOException, MissingObjectException, IncorrectObjectTypeException {
+    final List<CodeReviewCommit> merged = new ArrayList<>();
+    rw.reset();
+    rw.markStart(n);
+    rw.markUninteresting(mergeTip);
+    CodeReviewCommit crc;
+    while ((crc = rw.next()) != null) {
+      if (crc.getPatchsetId() != null) {
+        merged.add(crc);
+      }
+    }
+
+    StringBuilder msgbuf = new StringBuilder().append(summarize(rw, merged));
+    if (!R_HEADS_MASTER.equals(destBranch.get())) {
+      msgbuf.append(" into ");
+      msgbuf.append(destBranch.getShortName());
+    }
+
+    if (merged.size() > 1) {
+      msgbuf.append("\n\n* changes:\n");
+      for (CodeReviewCommit c : merged) {
+        rw.parseBody(c);
+        msgbuf.append("  ");
+        msgbuf.append(c.getShortMessage());
+        msgbuf.append("\n");
+      }
+    }
+
+    final CommitBuilder mergeCommit = new CommitBuilder();
+    mergeCommit.setTreeId(treeId);
+    mergeCommit.setParentIds(mergeTip, n);
+    mergeCommit.setAuthor(author);
+    mergeCommit.setCommitter(committer);
+    mergeCommit.setMessage(msgbuf.toString());
+
+    CodeReviewCommit mergeResult = rw.parseCommit(inserter.insert(mergeCommit));
+    mergeResult.setNotes(n.getNotes());
+    return mergeResult;
+  }
+
+  private String summarize(RevWalk rw, List<CodeReviewCommit> merged) throws IOException {
+    if (merged.size() == 1) {
+      CodeReviewCommit c = merged.get(0);
+      rw.parseBody(c);
+      return String.format("Merge \"%s\"", c.getShortMessage());
+    }
+
+    LinkedHashSet<String> topics = new LinkedHashSet<>(4);
+    for (CodeReviewCommit c : merged) {
+      if (!Strings.isNullOrEmpty(c.change().getTopic())) {
+        topics.add(c.change().getTopic());
+      }
+    }
+
+    if (topics.size() == 1) {
+      return String.format("Merge changes from topic \"%s\"", Iterables.getFirst(topics, null));
+    } else if (topics.size() > 1) {
+      return String.format("Merge changes from topics \"%s\"", Joiner.on("\", \"").join(topics));
+    } else {
+      return String.format(
+          "Merge changes %s%s",
+          FluentIterable.from(merged)
+              .limit(5)
+              .transform(c -> c.change().getKey().abbreviate())
+              .join(Joiner.on(',')),
+          merged.size() > 5 ? ", ..." : "");
+    }
+  }
+
+  public ThreeWayMerger newThreeWayMerger(ObjectInserter inserter, Config repoConfig) {
+    return newThreeWayMerger(inserter, repoConfig, mergeStrategyName());
+  }
+
+  public String mergeStrategyName() {
+    return mergeStrategyName(useContentMerge, useRecursiveMerge);
+  }
+
+  public static String mergeStrategyName(boolean useContentMerge, boolean useRecursiveMerge) {
+    if (useContentMerge) {
+      // Settings for this project allow us to try and automatically resolve
+      // conflicts within files if needed. Use either the old resolve merger or
+      // new recursive merger, and instruct to operate in core.
+      if (useRecursiveMerge) {
+        return MergeStrategy.RECURSIVE.getName();
+      }
+      return MergeStrategy.RESOLVE.getName();
+    }
+    // No auto conflict resolving allowed. If any of the
+    // affected files was modified, merge will fail.
+    return MergeStrategy.SIMPLE_TWO_WAY_IN_CORE.getName();
+  }
+
+  public static ThreeWayMerger newThreeWayMerger(
+      ObjectInserter inserter, Config repoConfig, String strategyName) {
+    Merger m = newMerger(inserter, repoConfig, strategyName);
+    checkArgument(
+        m instanceof ThreeWayMerger,
+        "merge strategy %s does not support three-way merging",
+        strategyName);
+    return (ThreeWayMerger) m;
+  }
+
+  public static Merger newMerger(ObjectInserter inserter, Config repoConfig, String strategyName) {
+    MergeStrategy strategy = MergeStrategy.get(strategyName);
+    checkArgument(strategy != null, "invalid merge strategy: %s", strategyName);
+    return strategy.newMerger(
+        new ObjectInserter.Filter() {
+          @Override
+          protected ObjectInserter delegate() {
+            return inserter;
+          }
+
+          @Override
+          public void flush() {}
+
+          @Override
+          public void close() {}
+        },
+        repoConfig);
+  }
+
+  public void markCleanMerges(
+      RevWalk rw, RevFlag canMergeFlag, CodeReviewCommit mergeTip, Set<RevCommit> alreadyAccepted)
+      throws IntegrationException {
+    if (mergeTip == null) {
+      // If mergeTip is null here, branchTip was null, indicating a new branch
+      // at the start of the merge process. We also elected to merge nothing,
+      // probably due to missing dependencies. Nothing was cleanly merged.
+      //
+      return;
+    }
+
+    try {
+      rw.resetRetain(canMergeFlag);
+      rw.sort(RevSort.TOPO);
+      rw.sort(RevSort.REVERSE, true);
+      rw.markStart(mergeTip);
+      for (RevCommit c : alreadyAccepted) {
+        // If branch was not created by this submit.
+        if (!Objects.equals(c, mergeTip)) {
+          rw.markUninteresting(c);
+        }
+      }
+
+      CodeReviewCommit c;
+      while ((c = (CodeReviewCommit) rw.next()) != null) {
+        if (c.getPatchsetId() != null && c.getStatusCode() == null) {
+          c.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
+        }
+      }
+    } catch (IOException e) {
+      throw new IntegrationException("Cannot mark clean merges", e);
+    }
+  }
+
+  public Set<Change.Id> findUnmergedChanges(
+      Set<Change.Id> expected,
+      CodeReviewRevWalk rw,
+      RevFlag canMergeFlag,
+      CodeReviewCommit oldTip,
+      CodeReviewCommit mergeTip,
+      Iterable<Change.Id> alreadyMerged)
+      throws IntegrationException {
+    if (mergeTip == null) {
+      return expected;
+    }
+
+    try {
+      Set<Change.Id> found = Sets.newHashSetWithExpectedSize(expected.size());
+      Iterables.addAll(found, alreadyMerged);
+      rw.resetRetain(canMergeFlag);
+      rw.sort(RevSort.TOPO);
+      rw.markStart(mergeTip);
+      if (oldTip != null) {
+        rw.markUninteresting(oldTip);
+      }
+
+      CodeReviewCommit c;
+      while ((c = rw.next()) != null) {
+        if (c.getPatchsetId() == null) {
+          continue;
+        }
+        Change.Id id = c.getPatchsetId().getParentKey();
+        if (!expected.contains(id)) {
+          continue;
+        }
+        found.add(id);
+        if (found.size() == expected.size()) {
+          return Collections.emptySet();
+        }
+      }
+      return Sets.difference(expected, found);
+    } catch (IOException e) {
+      throw new IntegrationException("Cannot check if changes were merged", e);
+    }
+  }
+
+  public static CodeReviewCommit findAnyMergedInto(
+      CodeReviewRevWalk rw, Iterable<CodeReviewCommit> commits, CodeReviewCommit tip)
+      throws IOException {
+    for (CodeReviewCommit c : commits) {
+      // TODO(dborowitz): Seems like this could get expensive for many patch
+      // sets. Is there a more efficient implementation?
+      if (rw.isMergedInto(c, tip)) {
+        return c;
+      }
+    }
+    return null;
+  }
+
+  public static RevCommit resolveCommit(Repository repo, RevWalk rw, String str)
+      throws BadRequestException, ResourceNotFoundException, IOException {
+    try {
+      ObjectId commitId = repo.resolve(str);
+      if (commitId == null) {
+        throw new BadRequestException("Cannot resolve '" + str + "' to a commit");
+      }
+      return rw.parseCommit(commitId);
+    } catch (AmbiguousObjectException | IncorrectObjectTypeException | RevisionSyntaxException e) {
+      throw new BadRequestException(e.getMessage());
+    } catch (MissingObjectException e) {
+      throw new ResourceNotFoundException(e.getMessage());
+    }
+  }
+
+  private static void matchAuthorToCommitterDate(ProjectState project, CommitBuilder commit) {
+    if (project.is(BooleanProjectConfig.MATCH_AUTHOR_TO_COMMITTER_DATE)) {
+      commit.setAuthor(
+          new PersonIdent(
+              commit.getAuthor(),
+              commit.getCommitter().getWhen(),
+              commit.getCommitter().getTimeZone()));
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
rename to java/com/google/gerrit/server/git/MergedByPushOp.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java b/java/com/google/gerrit/server/git/MetaDataUpdate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
rename to java/com/google/gerrit/server/git/MetaDataUpdate.java
diff --git a/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
new file mode 100644
index 0000000..6fafe4e
--- /dev/null
+++ b/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.RepositoryConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.nio.file.Path;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class MultiBaseLocalDiskRepositoryManager extends LocalDiskRepositoryManager {
+
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      bind(GitRepositoryManager.class).to(MultiBaseLocalDiskRepositoryManager.class);
+      listener().to(MultiBaseLocalDiskRepositoryManager.Lifecycle.class);
+    }
+  }
+
+  private final RepositoryConfig config;
+
+  @Inject
+  MultiBaseLocalDiskRepositoryManager(
+      SitePaths site, @GerritServerConfig Config cfg, RepositoryConfig config) {
+    super(site, cfg);
+    this.config = config;
+
+    for (Path alternateBasePath : config.getAllBasePaths()) {
+      checkState(
+          alternateBasePath.isAbsolute(),
+          "repository.<name>.basePath must be absolute: %s",
+          alternateBasePath);
+    }
+  }
+
+  @Override
+  public Path getBasePath(NameKey name) {
+    Path alternateBasePath = config.getBasePath(name);
+    return alternateBasePath != null ? alternateBasePath : super.getBasePath(name);
+  }
+
+  @Override
+  protected void scanProjects(ProjectVisitor visitor) {
+    super.scanProjects(visitor);
+    for (Path path : config.getAllBasePaths()) {
+      visitor.setStartFolder(path);
+      super.scanProjects(visitor);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
rename to java/com/google/gerrit/server/git/MultiProgressMonitor.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java b/java/com/google/gerrit/server/git/NotesBranchUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java
rename to java/com/google/gerrit/server/git/NotesBranchUtil.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/NotifyConfig.java b/java/com/google/gerrit/server/git/NotifyConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/NotifyConfig.java
rename to java/com/google/gerrit/server/git/NotifyConfig.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java b/java/com/google/gerrit/server/git/PerThreadRequestScope.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
rename to java/com/google/gerrit/server/git/PerThreadRequestScope.java
diff --git a/java/com/google/gerrit/server/git/ProjectConfig.java b/java/com/google/gerrit/server/git/ProjectConfig.java
new file mode 100644
index 0000000..fec1ae3
--- /dev/null
+++ b/java/com/google/gerrit/server/git/ProjectConfig.java
@@ -0,0 +1,1435 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.common.data.Permission.isPermission;
+import static com.google.gerrit.reviewdb.client.Project.DEFAULT_SUBMIT_TYPE;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import com.google.common.primitives.Shorts;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.ContributorAgreement;
+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;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.common.data.RefConfigSection;
+import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.project.CommentLinkInfoImpl;
+import com.google.gerrit.server.project.RefPattern;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+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;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.util.StringUtils;
+
+public class ProjectConfig extends VersionedMetaData implements ValidationError.Sink {
+  public static final String COMMENTLINK = "commentlink";
+  private static final String KEY_MATCH = "match";
+  private static final String KEY_HTML = "html";
+  private static final String KEY_LINK = "link";
+  private static final String KEY_ENABLED = "enabled";
+
+  public static final String PROJECT_CONFIG = "project.config";
+
+  private static final String PROJECT = "project";
+  private static final String KEY_DESCRIPTION = "description";
+
+  public static final String ACCESS = "access";
+  private static final String KEY_INHERIT_FROM = "inheritFrom";
+  private static final String KEY_GROUP_PERMISSIONS = "exclusiveGroupPermissions";
+
+  private static final String ACCOUNTS = "accounts";
+  private static final String KEY_SAME_GROUP_VISIBILITY = "sameGroupVisibility";
+
+  private static final String BRANCH_ORDER = "branchOrder";
+  private static final String BRANCH = "branch";
+
+  private static final String CONTRIBUTOR_AGREEMENT = "contributor-agreement";
+  private static final String KEY_ACCEPTED = "accepted";
+  private static final String KEY_AUTO_VERIFY = "autoVerify";
+  private static final String KEY_AGREEMENT_URL = "agreementUrl";
+
+  private static final String NOTIFY = "notify";
+  private static final String KEY_EMAIL = "email";
+  private static final String KEY_FILTER = "filter";
+  private static final String KEY_TYPE = "type";
+  private static final String KEY_HEADER = "header";
+
+  private static final String CAPABILITY = "capability";
+
+  private static final String RECEIVE = "receive";
+  private static final String KEY_CHECK_RECEIVED_OBJECTS = "checkReceivedObjects";
+
+  private static final String SUBMIT = "submit";
+  private static final String KEY_ACTION = "action";
+  private static final String KEY_STATE = "state";
+
+  private static final String KEY_MAX_OBJECT_SIZE_LIMIT = "maxObjectSizeLimit";
+
+  private static final String SUBSCRIBE_SECTION = "allowSuperproject";
+  private static final String SUBSCRIBE_MATCH_REFS = "matching";
+  private static final String SUBSCRIBE_MULTI_MATCH_REFS = "all";
+
+  private static final String DASHBOARD = "dashboard";
+  private static final String KEY_DEFAULT = "default";
+  private static final String KEY_LOCAL_DEFAULT = "local-default";
+
+  private static final String LABEL = "label";
+  private static final String KEY_FUNCTION = "function";
+  private static final String KEY_DEFAULT_VALUE = "defaultValue";
+  private static final String KEY_COPY_MIN_SCORE = "copyMinScore";
+  private static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
+  private static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
+  private static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
+      "copyAllScoresOnMergeFirstParentUpdate";
+  private static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE =
+      "copyAllScoresOnTrivialRebase";
+  private static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
+  private static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+  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 String LEGACY_PERMISSION_PUSH_TAG = "pushTag";
+  private static final String LEGACY_PERMISSION_PUSH_SIGNED_TAG = "pushSignedTag";
+
+  private static final String PLUGIN = "plugin";
+
+  private static final ProjectState DEFAULT_STATE_VALUE = ProjectState.ACTIVE;
+
+  private static final String EXTENSION_PANELS = "extension-panels";
+  private static final String KEY_PANEL = "panel";
+
+  private Project.NameKey projectName;
+  private Project project;
+  private AccountsSection accountsSection;
+  private GroupList groupList;
+  private Map<String, AccessSection> accessSections;
+  private BranchOrderSection branchOrderSection;
+  private Map<String, ContributorAgreement> contributorAgreements;
+  private Map<String, NotifyConfig> notifySections;
+  private Map<String, LabelType> labelSections;
+  private ConfiguredMimeTypes mimeTypes;
+  private Map<Project.NameKey, SubscribeSection> subscribeSections;
+  private List<CommentLinkInfoImpl> commentLinkSections;
+  private List<ValidationError> validationErrors;
+  private ObjectId rulesId;
+  private long maxObjectSizeLimit;
+  private Map<String, Config> pluginConfigs;
+  private boolean checkReceivedObjects;
+  private Set<String> sectionsWithUnknownPermissions;
+  private boolean hasLegacyPermissions;
+  private Map<String, List<String>> extensionPanelSections;
+  private Map<String, GroupReference> groupsByName;
+
+  public static ProjectConfig read(MetaDataUpdate update)
+      throws IOException, ConfigInvalidException {
+    ProjectConfig r = new ProjectConfig(update.getProjectName());
+    r.load(update);
+    return r;
+  }
+
+  public static ProjectConfig read(MetaDataUpdate update, ObjectId id)
+      throws IOException, ConfigInvalidException {
+    ProjectConfig r = new ProjectConfig(update.getProjectName());
+    r.load(update, id);
+    return r;
+  }
+
+  public static CommentLinkInfoImpl buildCommentLink(Config cfg, String name, boolean allowRaw)
+      throws IllegalArgumentException {
+    String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
+    if (match != null) {
+      // Unfortunately this validation isn't entirely complete. Clients
+      // can have exceptions trying to evaluate the pattern if they don't
+      // support a token used, even if the server does support the token.
+      //
+      // At the minimum, we can trap problems related to unmatched groups.
+      Pattern.compile(match);
+    }
+
+    String link = cfg.getString(COMMENTLINK, name, KEY_LINK);
+    String html = cfg.getString(COMMENTLINK, name, KEY_HTML);
+    boolean hasHtml = !Strings.isNullOrEmpty(html);
+
+    String rawEnabled = cfg.getString(COMMENTLINK, name, KEY_ENABLED);
+    Boolean enabled;
+    if (rawEnabled != null) {
+      enabled = cfg.getBoolean(COMMENTLINK, name, KEY_ENABLED, true);
+    } else {
+      enabled = null;
+    }
+    checkArgument(allowRaw || !hasHtml, "Raw html replacement not allowed");
+
+    if (Strings.isNullOrEmpty(match)
+        && Strings.isNullOrEmpty(link)
+        && !hasHtml
+        && enabled != null) {
+      if (enabled) {
+        return new CommentLinkInfoImpl.Enabled(name);
+      }
+      return new CommentLinkInfoImpl.Disabled(name);
+    }
+    return new CommentLinkInfoImpl(name, match, link, html, enabled);
+  }
+
+  public ProjectConfig(Project.NameKey projectName) {
+    this.projectName = projectName;
+  }
+
+  public Project.NameKey getName() {
+    return projectName;
+  }
+
+  public Project getProject() {
+    return project;
+  }
+
+  public AccountsSection getAccountsSection() {
+    return accountsSection;
+  }
+
+  public Map<String, List<String>> getExtensionPanelSections() {
+    return extensionPanelSections;
+  }
+
+  public AccessSection getAccessSection(String name) {
+    return getAccessSection(name, false);
+  }
+
+  public AccessSection getAccessSection(String name, boolean create) {
+    AccessSection as = accessSections.get(name);
+    if (as == null && create) {
+      as = new AccessSection(name);
+      accessSections.put(name, as);
+    }
+    return as;
+  }
+
+  public Collection<AccessSection> getAccessSections() {
+    return sort(accessSections.values());
+  }
+
+  public BranchOrderSection getBranchOrderSection() {
+    return branchOrderSection;
+  }
+
+  public Map<Project.NameKey, SubscribeSection> getSubscribeSections() {
+    return subscribeSections;
+  }
+
+  public Collection<SubscribeSection> getSubscribeSections(Branch.NameKey branch) {
+    Collection<SubscribeSection> ret = new ArrayList<>();
+    for (SubscribeSection s : subscribeSections.values()) {
+      if (s.appliesTo(branch)) {
+        ret.add(s);
+      }
+    }
+    return ret;
+  }
+
+  public void addSubscribeSection(SubscribeSection s) {
+    subscribeSections.put(s.getProject(), s);
+  }
+
+  public void remove(AccessSection section) {
+    if (section != null) {
+      String name = section.getName();
+      if (sectionsWithUnknownPermissions.contains(name)) {
+        AccessSection a = accessSections.get(name);
+        a.setPermissions(new ArrayList<Permission>());
+      } else {
+        accessSections.remove(name);
+      }
+    }
+  }
+
+  public void remove(AccessSection section, Permission permission) {
+    if (permission == null) {
+      remove(section);
+    } else if (section != null) {
+      AccessSection a = accessSections.get(section.getName());
+      a.remove(permission);
+      if (a.getPermissions().isEmpty()) {
+        remove(a);
+      }
+    }
+  }
+
+  public void remove(AccessSection section, Permission permission, PermissionRule rule) {
+    if (rule == null) {
+      remove(section, permission);
+    } else if (section != null && permission != null) {
+      AccessSection a = accessSections.get(section.getName());
+      if (a == null) {
+        return;
+      }
+      Permission p = a.getPermission(permission.getName());
+      if (p == null) {
+        return;
+      }
+      p.remove(rule);
+      if (p.getRules().isEmpty()) {
+        a.remove(permission);
+      }
+      if (a.getPermissions().isEmpty()) {
+        remove(a);
+      }
+    }
+  }
+
+  public void replace(AccessSection section) {
+    for (Permission permission : section.getPermissions()) {
+      for (PermissionRule rule : permission.getRules()) {
+        rule.setGroup(resolve(rule.getGroup()));
+      }
+    }
+
+    accessSections.put(section.getName(), section);
+  }
+
+  public ContributorAgreement getContributorAgreement(String name) {
+    return getContributorAgreement(name, false);
+  }
+
+  public ContributorAgreement getContributorAgreement(String name, boolean create) {
+    ContributorAgreement ca = contributorAgreements.get(name);
+    if (ca == null && create) {
+      ca = new ContributorAgreement(name);
+      contributorAgreements.put(name, ca);
+    }
+    return ca;
+  }
+
+  public Collection<ContributorAgreement> getContributorAgreements() {
+    return sort(contributorAgreements.values());
+  }
+
+  public void remove(ContributorAgreement section) {
+    if (section != null) {
+      accessSections.remove(section.getName());
+    }
+  }
+
+  public void replace(ContributorAgreement section) {
+    section.setAutoVerify(resolve(section.getAutoVerify()));
+    for (PermissionRule rule : section.getAccepted()) {
+      rule.setGroup(resolve(rule.getGroup()));
+    }
+
+    contributorAgreements.put(section.getName(), section);
+  }
+
+  public Collection<NotifyConfig> getNotifyConfigs() {
+    return notifySections.values();
+  }
+
+  public void putNotifyConfig(String name, NotifyConfig nc) {
+    notifySections.put(name, nc);
+  }
+
+  public Map<String, LabelType> getLabelSections() {
+    return labelSections;
+  }
+
+  public Collection<CommentLinkInfoImpl> getCommentLinkSections() {
+    return commentLinkSections;
+  }
+
+  public ConfiguredMimeTypes getMimeTypes() {
+    return mimeTypes;
+  }
+
+  public GroupReference resolve(AccountGroup group) {
+    return resolve(GroupReference.forGroup(group));
+  }
+
+  public GroupReference resolve(GroupReference group) {
+    GroupReference groupRef = groupList.resolve(group);
+    if (groupRef != null
+        && groupRef.getUUID() != null
+        && !groupsByName.containsKey(groupRef.getName())) {
+      groupsByName.put(groupRef.getName(), groupRef);
+    }
+    return groupRef;
+  }
+
+  /** @return the group reference, if the group is used by at least one rule. */
+  public GroupReference getGroup(AccountGroup.UUID uuid) {
+    return groupList.byUUID(uuid);
+  }
+
+  /**
+   * @return the group reference corresponding to the specified group name if the group is used by
+   *     at least one rule or plugin value.
+   */
+  public GroupReference getGroup(String groupName) {
+    return groupsByName.get(groupName);
+  }
+
+  /** @return set of all groups used by this configuration. */
+  public Set<AccountGroup.UUID> getAllGroupUUIDs() {
+    return groupList.uuids();
+  }
+
+  /**
+   * @return the project's rules.pl ObjectId, if present in the branch. Null if it doesn't exist.
+   */
+  public ObjectId getRulesId() {
+    return rulesId;
+  }
+
+  /**
+   * @return the maxObjectSizeLimit for this project, if set. Zero if this project doesn't define
+   *     own maxObjectSizeLimit.
+   */
+  public long getMaxObjectSizeLimit() {
+    return maxObjectSizeLimit;
+  }
+
+  /** @return the checkReceivedObjects for this project, default is true. */
+  public boolean getCheckReceivedObjects() {
+    return checkReceivedObjects;
+  }
+
+  /**
+   * Check all GroupReferences use current group name, repairing stale ones.
+   *
+   * @param groupBackend cache to use when looking up group information by UUID.
+   * @return true if one or more group names was stale.
+   */
+  public boolean updateGroupNames(GroupBackend groupBackend) {
+    boolean dirty = false;
+    for (GroupReference ref : groupList.references()) {
+      GroupDescription.Basic g = groupBackend.get(ref.getUUID());
+      if (g != null && !g.getName().equals(ref.getName())) {
+        dirty = true;
+        ref.setName(g.getName());
+      }
+    }
+    return dirty;
+  }
+
+  /**
+   * Get the validation errors, if any were discovered during load.
+   *
+   * @return list of errors; empty list if there are no errors.
+   */
+  public List<ValidationError> getValidationErrors() {
+    if (validationErrors != null) {
+      return Collections.unmodifiableList(validationErrors);
+    }
+    return Collections.emptyList();
+  }
+
+  @Override
+  protected String getRefName() {
+    return RefNames.REFS_CONFIG;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    readGroupList();
+    groupsByName = mapGroupReferences();
+
+    rulesId = getObjectId("rules.pl");
+    Config rc = readConfig(PROJECT_CONFIG);
+    project = new Project(projectName);
+
+    Project p = project;
+    p.setDescription(rc.getString(PROJECT, null, KEY_DESCRIPTION));
+    if (p.getDescription() == null) {
+      p.setDescription("");
+    }
+
+    if (rc.getStringList(ACCESS, null, KEY_INHERIT_FROM).length > 1) {
+      // The config must not contain more than one parent to inherit from
+      // as there is no guarantee which of the parents would be used then.
+      error(new ValidationError(PROJECT_CONFIG, "Cannot inherit from multiple projects"));
+    }
+    p.setParentName(rc.getString(ACCESS, null, KEY_INHERIT_FROM));
+
+    for (BooleanProjectConfig config : BooleanProjectConfig.values()) {
+      p.setBooleanConfig(
+          config,
+          getEnum(
+              rc,
+              config.getSection(),
+              config.getSubSection(),
+              config.getName(),
+              InheritableBoolean.INHERIT));
+    }
+
+    p.setMaxObjectSizeLimit(rc.getString(RECEIVE, null, KEY_MAX_OBJECT_SIZE_LIMIT));
+
+    p.setSubmitType(getEnum(rc, SUBMIT, null, KEY_ACTION, DEFAULT_SUBMIT_TYPE));
+    p.setState(getEnum(rc, PROJECT, null, KEY_STATE, DEFAULT_STATE_VALUE));
+
+    p.setDefaultDashboard(rc.getString(DASHBOARD, null, KEY_DEFAULT));
+    p.setLocalDefaultDashboard(rc.getString(DASHBOARD, null, KEY_LOCAL_DEFAULT));
+
+    loadAccountsSection(rc);
+    loadContributorAgreements(rc);
+    loadAccessSections(rc);
+    loadBranchOrderSection(rc);
+    loadNotifySections(rc);
+    loadLabelSections(rc);
+    loadCommentLinkSections(rc);
+    loadSubscribeSections(rc);
+    mimeTypes = new ConfiguredMimeTypes(projectName.get(), rc);
+    loadPluginSections(rc);
+    loadReceiveSection(rc);
+    loadExtensionPanelSections(rc);
+  }
+
+  private void loadAccountsSection(Config rc) {
+    accountsSection = new AccountsSection();
+    accountsSection.setSameGroupVisibility(
+        loadPermissionRules(rc, ACCOUNTS, null, KEY_SAME_GROUP_VISIBILITY, groupsByName, false));
+  }
+
+  private void loadExtensionPanelSections(Config rc) {
+    Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
+    extensionPanelSections = Maps.newLinkedHashMap();
+    for (String name : rc.getSubsections(EXTENSION_PANELS)) {
+      String lower = name.toLowerCase();
+      if (lowerNames.containsKey(lower)) {
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                String.format(
+                    "Extension Panels \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
+      }
+      lowerNames.put(lower, name);
+      extensionPanelSections.put(
+          name,
+          new ArrayList<>(Arrays.asList(rc.getStringList(EXTENSION_PANELS, name, KEY_PANEL))));
+    }
+  }
+
+  private void loadContributorAgreements(Config rc) {
+    contributorAgreements = new HashMap<>();
+    for (String name : rc.getSubsections(CONTRIBUTOR_AGREEMENT)) {
+      ContributorAgreement ca = getContributorAgreement(name, true);
+      ca.setDescription(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_DESCRIPTION));
+      ca.setAgreementUrl(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_AGREEMENT_URL));
+      ca.setAccepted(
+          loadPermissionRules(rc, CONTRIBUTOR_AGREEMENT, name, KEY_ACCEPTED, groupsByName, false));
+
+      List<PermissionRule> rules =
+          loadPermissionRules(
+              rc, CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY, groupsByName, false);
+      if (rules.isEmpty()) {
+        ca.setAutoVerify(null);
+      } else if (rules.size() > 1) {
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                "Invalid rule in "
+                    + CONTRIBUTOR_AGREEMENT
+                    + "."
+                    + name
+                    + "."
+                    + KEY_AUTO_VERIFY
+                    + ": at most one group may be set"));
+      } else if (rules.get(0).getAction() != Action.ALLOW) {
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                "Invalid rule in "
+                    + CONTRIBUTOR_AGREEMENT
+                    + "."
+                    + name
+                    + "."
+                    + KEY_AUTO_VERIFY
+                    + ": the group must be allowed"));
+      } else {
+        ca.setAutoVerify(rules.get(0).getGroup());
+      }
+    }
+  }
+
+  /**
+   * Parses the [notify] sections out of the configuration file.
+   *
+   * <pre>
+   *   [notify "reviewers"]
+   *     email = group Reviewers
+   *     type = new_changes
+   *
+   *   [notify "dev-team"]
+   *     email = dev-team@example.com
+   *     filter = branch:master
+   *
+   *   [notify "qa"]
+   *     email = qa@example.com
+   *     filter = branch:\"^(maint|stable)-.*\"
+   *     type = submitted_changes
+   * </pre>
+   */
+  private void loadNotifySections(Config rc) {
+    notifySections = new HashMap<>();
+    for (String sectionName : rc.getSubsections(NOTIFY)) {
+      NotifyConfig n = new NotifyConfig();
+      n.setName(sectionName);
+      n.setFilter(rc.getString(NOTIFY, sectionName, KEY_FILTER));
+
+      EnumSet<NotifyType> types = EnumSet.noneOf(NotifyType.class);
+      types.addAll(ConfigUtil.getEnumList(rc, NOTIFY, sectionName, KEY_TYPE, NotifyType.ALL));
+      n.setTypes(types);
+      n.setHeader(rc.getEnum(NOTIFY, sectionName, KEY_HEADER, NotifyConfig.Header.BCC));
+
+      for (String dst : rc.getStringList(NOTIFY, sectionName, KEY_EMAIL)) {
+        String groupName = GroupReference.extractGroupName(dst);
+        if (groupName != null) {
+          GroupReference ref = groupsByName.get(groupName);
+          if (ref == null) {
+            ref = new GroupReference(null, groupName);
+            groupsByName.put(ref.getName(), ref);
+          }
+          if (ref.getUUID() != null) {
+            n.addEmail(ref);
+          } else {
+            error(
+                new ValidationError(
+                    PROJECT_CONFIG,
+                    "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
+          }
+        } else if (dst.startsWith("user ")) {
+          error(new ValidationError(PROJECT_CONFIG, dst + " not supported"));
+        } else {
+          try {
+            n.addEmail(Address.parse(dst));
+          } catch (IllegalArgumentException err) {
+            error(
+                new ValidationError(
+                    PROJECT_CONFIG,
+                    "notify section \"" + sectionName + "\" has invalid email \"" + dst + "\""));
+          }
+        }
+      }
+      notifySections.put(sectionName, n);
+    }
+  }
+
+  private void loadAccessSections(Config rc) {
+    accessSections = new HashMap<>();
+    sectionsWithUnknownPermissions = new HashSet<>();
+    for (String refName : rc.getSubsections(ACCESS)) {
+      if (RefConfigSection.isValid(refName) && isValidRegex(refName)) {
+        AccessSection as = getAccessSection(refName, true);
+
+        for (String varName : rc.getStringList(ACCESS, refName, KEY_GROUP_PERMISSIONS)) {
+          for (String n : varName.split("[, \t]{1,}")) {
+            n = convertLegacyPermission(n);
+            if (isPermission(n)) {
+              as.getPermission(n, true).setExclusiveGroup(true);
+            }
+          }
+        }
+
+        for (String varName : rc.getNames(ACCESS, refName)) {
+          String convertedName = convertLegacyPermission(varName);
+          if (isPermission(convertedName)) {
+            Permission perm = as.getPermission(convertedName, true);
+            loadPermissionRules(
+                rc,
+                ACCESS,
+                refName,
+                varName,
+                groupsByName,
+                perm,
+                Permission.hasRange(convertedName));
+          } else {
+            sectionsWithUnknownPermissions.add(as.getName());
+          }
+        }
+      }
+    }
+
+    AccessSection capability = null;
+    for (String varName : rc.getNames(CAPABILITY)) {
+      if (capability == null) {
+        capability = new AccessSection(AccessSection.GLOBAL_CAPABILITIES);
+        accessSections.put(AccessSection.GLOBAL_CAPABILITIES, capability);
+      }
+      Permission perm = capability.getPermission(varName, true);
+      loadPermissionRules(
+          rc, CAPABILITY, null, varName, groupsByName, perm, GlobalCapability.hasRange(varName));
+    }
+  }
+
+  private boolean isValidRegex(String refPattern) {
+    try {
+      RefPattern.validateRegExp(refPattern);
+    } catch (InvalidNameException e) {
+      error(new ValidationError(PROJECT_CONFIG, "Invalid ref name: " + e.getMessage()));
+      return false;
+    }
+    return true;
+  }
+
+  private void loadBranchOrderSection(Config rc) {
+    if (rc.getSections().contains(BRANCH_ORDER)) {
+      branchOrderSection = new BranchOrderSection(rc.getStringList(BRANCH_ORDER, null, BRANCH));
+    }
+  }
+
+  private List<PermissionRule> loadPermissionRules(
+      Config rc,
+      String section,
+      String subsection,
+      String varName,
+      Map<String, GroupReference> groupsByName,
+      boolean useRange) {
+    Permission perm = new Permission(varName);
+    loadPermissionRules(rc, section, subsection, varName, groupsByName, perm, useRange);
+    return perm.getRules();
+  }
+
+  private void loadPermissionRules(
+      Config rc,
+      String section,
+      String subsection,
+      String varName,
+      Map<String, GroupReference> groupsByName,
+      Permission perm,
+      boolean useRange) {
+    for (String ruleString : rc.getStringList(section, subsection, varName)) {
+      PermissionRule rule;
+      try {
+        rule = PermissionRule.fromString(ruleString, useRange);
+      } catch (IllegalArgumentException notRule) {
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                "Invalid rule in "
+                    + section
+                    + (subsection != null ? "." + subsection : "")
+                    + "."
+                    + varName
+                    + ": "
+                    + notRule.getMessage()));
+        continue;
+      }
+
+      GroupReference ref = groupsByName.get(rule.getGroup().getName());
+      if (ref == null) {
+        // The group wasn't mentioned in the groups table, so there is
+        // no valid UUID for it. Pool the reference anyway so at least
+        // all rules in the same file share the same GroupReference.
+        //
+        ref = rule.getGroup();
+        groupsByName.put(ref.getName(), ref);
+        error(
+            new ValidationError(
+                PROJECT_CONFIG, "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
+      }
+
+      rule.setGroup(ref);
+      perm.add(rule);
+    }
+  }
+
+  private static LabelValue parseLabelValue(String src) {
+    List<String> parts =
+        ImmutableList.copyOf(
+            Splitter.on(CharMatcher.whitespace()).omitEmptyStrings().limit(2).split(src));
+    if (parts.isEmpty()) {
+      throw new IllegalArgumentException("empty value");
+    }
+    String valueText = parts.size() > 1 ? parts.get(1) : "";
+    return new LabelValue(Shorts.checkedCast(PermissionRule.parseInt(parts.get(0))), valueText);
+  }
+
+  private void loadLabelSections(Config rc) {
+    Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
+    labelSections = new LinkedHashMap<>();
+    for (String name : rc.getSubsections(LABEL)) {
+      String lower = name.toLowerCase();
+      if (lowerNames.containsKey(lower)) {
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                String.format("Label \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
+      }
+      lowerNames.put(lower, name);
+
+      List<LabelValue> values = new ArrayList<>();
+      for (String value : rc.getStringList(LABEL, name, KEY_VALUE)) {
+        try {
+          values.add(parseLabelValue(value));
+        } catch (IllegalArgumentException notValue) {
+          error(
+              new ValidationError(
+                  PROJECT_CONFIG,
+                  String.format(
+                      "Invalid %s \"%s\" for label \"%s\": %s",
+                      KEY_VALUE, value, name, notValue.getMessage())));
+        }
+      }
+
+      LabelType label;
+      try {
+        label = new LabelType(name, values);
+      } catch (IllegalArgumentException badName) {
+        error(new ValidationError(PROJECT_CONFIG, String.format("Invalid label \"%s\"", name)));
+        continue;
+      }
+
+      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(LabelFunction.ALL.keySet()))));
+      }
+      label.setFunction(function.orElse(null));
+
+      if (!values.isEmpty()) {
+        short dv = (short) rc.getInt(LABEL, name, KEY_DEFAULT_VALUE, 0);
+        if (isInRange(dv, values)) {
+          label.setDefaultValue(dv);
+        } else {
+          error(
+              new ValidationError(
+                  PROJECT_CONFIG,
+                  String.format(
+                      "Invalid %s \"%s\" for label \"%s\"", KEY_DEFAULT_VALUE, dv, name)));
+        }
+      }
+      label.setAllowPostSubmit(
+          rc.getBoolean(LABEL, name, KEY_ALLOW_POST_SUBMIT, LabelType.DEF_ALLOW_POST_SUBMIT));
+      label.setCopyMinScore(
+          rc.getBoolean(LABEL, name, KEY_COPY_MIN_SCORE, LabelType.DEF_COPY_MIN_SCORE));
+      label.setCopyMaxScore(
+          rc.getBoolean(LABEL, name, KEY_COPY_MAX_SCORE, LabelType.DEF_COPY_MAX_SCORE));
+      label.setCopyAllScoresOnMergeFirstParentUpdate(
+          rc.getBoolean(
+              LABEL,
+              name,
+              KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+              LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE));
+      label.setCopyAllScoresOnTrivialRebase(
+          rc.getBoolean(
+              LABEL,
+              name,
+              KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+              LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE));
+      label.setCopyAllScoresIfNoCodeChange(
+          rc.getBoolean(
+              LABEL,
+              name,
+              KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+              LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE));
+      label.setCopyAllScoresIfNoChange(
+          rc.getBoolean(
+              LABEL,
+              name,
+              KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE));
+      label.setCanOverride(
+          rc.getBoolean(LABEL, name, KEY_CAN_OVERRIDE, LabelType.DEF_CAN_OVERRIDE));
+      label.setRefPatterns(getStringListOrNull(rc, LABEL, name, KEY_BRANCH));
+      labelSections.put(name, label);
+    }
+  }
+
+  private boolean isInRange(short value, List<LabelValue> labelValues) {
+    for (LabelValue lv : labelValues) {
+      if (lv.getValue() == value) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private List<String> getStringListOrNull(
+      Config rc, String section, String subSection, String name) {
+    String[] ac = rc.getStringList(section, subSection, name);
+    return ac.length == 0 ? null : Arrays.asList(ac);
+  }
+
+  private void loadCommentLinkSections(Config rc) {
+    Set<String> subsections = rc.getSubsections(COMMENTLINK);
+    commentLinkSections = Lists.newArrayListWithCapacity(subsections.size());
+    for (String name : subsections) {
+      try {
+        commentLinkSections.add(buildCommentLink(rc, name, false));
+      } catch (PatternSyntaxException e) {
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                String.format(
+                    "Invalid pattern \"%s\" in commentlink.%s.match: %s",
+                    rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
+      } catch (IllegalArgumentException e) {
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                String.format(
+                    "Error in pattern \"%s\" in commentlink.%s.match: %s",
+                    rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
+      }
+    }
+    commentLinkSections = ImmutableList.copyOf(commentLinkSections);
+  }
+
+  private void loadSubscribeSections(Config rc) throws ConfigInvalidException {
+    Set<String> subsections = rc.getSubsections(SUBSCRIBE_SECTION);
+    subscribeSections = new HashMap<>();
+    try {
+      for (String projectName : subsections) {
+        Project.NameKey p = new Project.NameKey(projectName);
+        SubscribeSection ss = new SubscribeSection(p);
+        for (String s :
+            rc.getStringList(SUBSCRIBE_SECTION, projectName, SUBSCRIBE_MULTI_MATCH_REFS)) {
+          ss.addMultiMatchRefSpec(s);
+        }
+        for (String s : rc.getStringList(SUBSCRIBE_SECTION, projectName, SUBSCRIBE_MATCH_REFS)) {
+          ss.addMatchingRefSpec(s);
+        }
+        subscribeSections.put(p, ss);
+      }
+    } catch (IllegalArgumentException e) {
+      throw new ConfigInvalidException(e.getMessage());
+    }
+  }
+
+  private void loadReceiveSection(Config rc) {
+    checkReceivedObjects = rc.getBoolean(RECEIVE, KEY_CHECK_RECEIVED_OBJECTS, true);
+    maxObjectSizeLimit = rc.getLong(RECEIVE, null, KEY_MAX_OBJECT_SIZE_LIMIT, 0);
+  }
+
+  private void loadPluginSections(Config rc) {
+    pluginConfigs = new HashMap<>();
+    for (String plugin : rc.getSubsections(PLUGIN)) {
+      Config pluginConfig = new Config();
+      pluginConfigs.put(plugin, pluginConfig);
+      for (String name : rc.getNames(PLUGIN, plugin)) {
+        String value = rc.getString(PLUGIN, plugin, name);
+        String groupName = GroupReference.extractGroupName(value);
+        if (groupName != null) {
+          GroupReference ref = groupsByName.get(groupName);
+          if (ref == null) {
+            error(
+                new ValidationError(
+                    PROJECT_CONFIG, "group \"" + groupName + "\" not in " + GroupList.FILE_NAME));
+          }
+          rc.setString(PLUGIN, plugin, name, value);
+        }
+        pluginConfig.setStringList(
+            PLUGIN, plugin, name, Arrays.asList(rc.getStringList(PLUGIN, plugin, name)));
+      }
+    }
+  }
+
+  public PluginConfig getPluginConfig(String pluginName) {
+    Config pluginConfig = pluginConfigs.get(pluginName);
+    if (pluginConfig == null) {
+      pluginConfig = new Config();
+      pluginConfigs.put(pluginName, pluginConfig);
+    }
+    return new PluginConfig(pluginName, pluginConfig, this);
+  }
+
+  private void readGroupList() throws IOException {
+    groupList = GroupList.parse(projectName, readUTF8(GroupList.FILE_NAME), this);
+  }
+
+  private Map<String, GroupReference> mapGroupReferences() {
+    Collection<GroupReference> references = groupList.references();
+    Map<String, GroupReference> result = new HashMap<>(references.size());
+    for (GroupReference ref : references) {
+      result.put(ref.getName(), ref);
+    }
+
+    return result;
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    if (commit.getMessage() == null || "".equals(commit.getMessage())) {
+      commit.setMessage("Updated project configuration\n");
+    }
+
+    Config rc = readConfig(PROJECT_CONFIG);
+    Project p = project;
+
+    if (p.getDescription() != null && !p.getDescription().isEmpty()) {
+      rc.setString(PROJECT, null, KEY_DESCRIPTION, p.getDescription());
+    } else {
+      rc.unset(PROJECT, null, KEY_DESCRIPTION);
+    }
+    set(rc, ACCESS, null, KEY_INHERIT_FROM, p.getParentName());
+
+    for (BooleanProjectConfig config : BooleanProjectConfig.values()) {
+      set(
+          rc,
+          config.getSection(),
+          config.getSubSection(),
+          config.getName(),
+          p.getBooleanConfig(config),
+          InheritableBoolean.INHERIT);
+    }
+
+    set(
+        rc,
+        RECEIVE,
+        null,
+        KEY_MAX_OBJECT_SIZE_LIMIT,
+        validMaxObjectSizeLimit(p.getMaxObjectSizeLimit()));
+
+    set(rc, SUBMIT, null, KEY_ACTION, p.getConfiguredSubmitType(), DEFAULT_SUBMIT_TYPE);
+
+    set(rc, PROJECT, null, KEY_STATE, p.getState(), DEFAULT_STATE_VALUE);
+
+    set(rc, DASHBOARD, null, KEY_DEFAULT, p.getDefaultDashboard());
+    set(rc, DASHBOARD, null, KEY_LOCAL_DEFAULT, p.getLocalDefaultDashboard());
+
+    Set<AccountGroup.UUID> keepGroups = new HashSet<>();
+    saveAccountsSection(rc, keepGroups);
+    saveContributorAgreements(rc, keepGroups);
+    saveAccessSections(rc, keepGroups);
+    saveNotifySections(rc, keepGroups);
+    savePluginSections(rc, keepGroups);
+    groupList.retainUUIDs(keepGroups);
+    saveLabelSections(rc);
+    saveSubscribeSections(rc);
+
+    saveConfig(PROJECT_CONFIG, rc);
+    saveGroupList();
+    return true;
+  }
+
+  public static String validMaxObjectSizeLimit(String value) throws ConfigInvalidException {
+    if (value == null) {
+      return null;
+    }
+    value = value.trim();
+    if (value.isEmpty()) {
+      return null;
+    }
+    Config cfg = new Config();
+    cfg.fromText("[s]\nn=" + value);
+    try {
+      long s = cfg.getLong("s", "n", 0);
+      if (s < 0) {
+        throw new ConfigInvalidException(
+            String.format(
+                "Negative value '%s' not allowed as %s", value, KEY_MAX_OBJECT_SIZE_LIMIT));
+      }
+      if (s == 0) {
+        // return null for the default so that it is not persisted
+        return null;
+      }
+      return value;
+    } catch (IllegalArgumentException e) {
+      throw new ConfigInvalidException(
+          String.format("Value '%s' not parseable as a Long", value), e);
+    }
+  }
+
+  private void saveAccountsSection(Config rc, Set<AccountGroup.UUID> keepGroups) {
+    if (accountsSection != null) {
+      rc.setStringList(
+          ACCOUNTS,
+          null,
+          KEY_SAME_GROUP_VISIBILITY,
+          ruleToStringList(accountsSection.getSameGroupVisibility(), keepGroups));
+    }
+  }
+
+  private void saveContributorAgreements(Config rc, Set<AccountGroup.UUID> keepGroups) {
+    for (ContributorAgreement ca : sort(contributorAgreements.values())) {
+      set(rc, CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_DESCRIPTION, ca.getDescription());
+      set(rc, CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_AGREEMENT_URL, ca.getAgreementUrl());
+
+      if (ca.getAutoVerify() != null) {
+        if (ca.getAutoVerify().getUUID() != null) {
+          keepGroups.add(ca.getAutoVerify().getUUID());
+        }
+        String autoVerify = new PermissionRule(ca.getAutoVerify()).asString(false);
+        set(rc, CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_AUTO_VERIFY, autoVerify);
+      } else {
+        rc.unset(CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_AUTO_VERIFY);
+      }
+
+      rc.setStringList(
+          CONTRIBUTOR_AGREEMENT,
+          ca.getName(),
+          KEY_ACCEPTED,
+          ruleToStringList(ca.getAccepted(), keepGroups));
+    }
+  }
+
+  private void saveNotifySections(Config rc, Set<AccountGroup.UUID> keepGroups) {
+    for (NotifyConfig nc : sort(notifySections.values())) {
+      List<String> email = new ArrayList<>();
+      for (GroupReference gr : nc.getGroups()) {
+        if (gr.getUUID() != null) {
+          keepGroups.add(gr.getUUID());
+        }
+        email.add(new PermissionRule(gr).asString(false));
+      }
+      Collections.sort(email);
+
+      List<String> addrs = new ArrayList<>();
+      for (Address addr : nc.getAddresses()) {
+        addrs.add(addr.toString());
+      }
+      Collections.sort(addrs);
+      email.addAll(addrs);
+
+      set(rc, NOTIFY, nc.getName(), KEY_HEADER, nc.getHeader(), NotifyConfig.Header.BCC);
+      if (email.isEmpty()) {
+        rc.unset(NOTIFY, nc.getName(), KEY_EMAIL);
+      } else {
+        rc.setStringList(NOTIFY, nc.getName(), KEY_EMAIL, email);
+      }
+
+      if (nc.getNotify().equals(EnumSet.of(NotifyType.ALL))) {
+        rc.unset(NOTIFY, nc.getName(), KEY_TYPE);
+      } else {
+        List<String> types = Lists.newArrayListWithCapacity(4);
+        for (NotifyType t : NotifyType.values()) {
+          if (nc.isNotify(t)) {
+            types.add(StringUtils.toLowerCase(t.name()));
+          }
+        }
+        rc.setStringList(NOTIFY, nc.getName(), KEY_TYPE, types);
+      }
+
+      set(rc, NOTIFY, nc.getName(), KEY_FILTER, nc.getFilter());
+    }
+  }
+
+  private List<String> ruleToStringList(
+      List<PermissionRule> list, Set<AccountGroup.UUID> keepGroups) {
+    List<String> rules = new ArrayList<>();
+    for (PermissionRule rule : sort(list)) {
+      if (rule.getGroup().getUUID() != null) {
+        keepGroups.add(rule.getGroup().getUUID());
+      }
+      rules.add(rule.asString(false));
+    }
+    return rules;
+  }
+
+  private void saveAccessSections(Config rc, Set<AccountGroup.UUID> keepGroups) {
+    AccessSection capability = accessSections.get(AccessSection.GLOBAL_CAPABILITIES);
+    if (capability != null) {
+      Set<String> have = new HashSet<>();
+      for (Permission permission : sort(capability.getPermissions())) {
+        have.add(permission.getName().toLowerCase());
+
+        boolean needRange = GlobalCapability.hasRange(permission.getName());
+        List<String> rules = new ArrayList<>();
+        for (PermissionRule rule : sort(permission.getRules())) {
+          GroupReference group = resolve(rule.getGroup());
+          if (group.getUUID() != null) {
+            keepGroups.add(group.getUUID());
+          }
+          rules.add(rule.asString(needRange));
+        }
+        rc.setStringList(CAPABILITY, null, permission.getName(), rules);
+      }
+      for (String varName : rc.getNames(CAPABILITY)) {
+        if (!have.contains(varName.toLowerCase())) {
+          rc.unset(CAPABILITY, null, varName);
+        }
+      }
+    } else {
+      rc.unsetSection(CAPABILITY, null);
+    }
+
+    for (AccessSection as : sort(accessSections.values())) {
+      String refName = as.getName();
+      if (AccessSection.GLOBAL_CAPABILITIES.equals(refName)) {
+        continue;
+      }
+
+      StringBuilder doNotInherit = new StringBuilder();
+      for (Permission perm : sort(as.getPermissions())) {
+        if (perm.getExclusiveGroup()) {
+          if (0 < doNotInherit.length()) {
+            doNotInherit.append(' ');
+          }
+          doNotInherit.append(perm.getName());
+        }
+      }
+      if (0 < doNotInherit.length()) {
+        rc.setString(ACCESS, refName, KEY_GROUP_PERMISSIONS, doNotInherit.toString());
+      } else {
+        rc.unset(ACCESS, refName, KEY_GROUP_PERMISSIONS);
+      }
+
+      Set<String> have = new HashSet<>();
+      for (Permission permission : sort(as.getPermissions())) {
+        have.add(permission.getName().toLowerCase());
+
+        boolean needRange = Permission.hasRange(permission.getName());
+        List<String> rules = new ArrayList<>();
+        for (PermissionRule rule : sort(permission.getRules())) {
+          GroupReference group = resolve(rule.getGroup());
+          if (group.getUUID() != null) {
+            keepGroups.add(group.getUUID());
+          }
+          rules.add(rule.asString(needRange));
+        }
+        rc.setStringList(ACCESS, refName, permission.getName(), rules);
+      }
+
+      for (String varName : rc.getNames(ACCESS, refName)) {
+        if (isPermission(convertLegacyPermission(varName))
+            && !have.contains(varName.toLowerCase())) {
+          rc.unset(ACCESS, refName, varName);
+        }
+      }
+    }
+
+    for (String name : rc.getSubsections(ACCESS)) {
+      if (RefConfigSection.isValid(name) && !accessSections.containsKey(name)) {
+        rc.unsetSection(ACCESS, name);
+      }
+    }
+  }
+
+  private void saveLabelSections(Config rc) {
+    List<String> existing = Lists.newArrayList(rc.getSubsections(LABEL));
+    if (!Lists.newArrayList(labelSections.keySet()).equals(existing)) {
+      // Order of sections changed, remove and rewrite them all.
+      for (String name : existing) {
+        rc.unsetSection(LABEL, name);
+      }
+    }
+
+    Set<String> toUnset = Sets.newHashSet(existing);
+    for (Map.Entry<String, LabelType> e : labelSections.entrySet()) {
+      String name = e.getKey();
+      LabelType label = e.getValue();
+      toUnset.remove(name);
+      rc.setString(LABEL, name, KEY_FUNCTION, label.getFunction().getFunctionName());
+      rc.setInt(LABEL, name, KEY_DEFAULT_VALUE, label.getDefaultValue());
+
+      setBooleanConfigKey(
+          rc,
+          LABEL,
+          name,
+          KEY_ALLOW_POST_SUBMIT,
+          label.allowPostSubmit(),
+          LabelType.DEF_ALLOW_POST_SUBMIT);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
+          name,
+          KEY_COPY_MIN_SCORE,
+          label.isCopyMinScore(),
+          LabelType.DEF_COPY_MIN_SCORE);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
+          name,
+          KEY_COPY_MAX_SCORE,
+          label.isCopyMaxScore(),
+          LabelType.DEF_COPY_MAX_SCORE);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
+          name,
+          KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+          label.isCopyAllScoresOnTrivialRebase(),
+          LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
+          name,
+          KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+          label.isCopyAllScoresIfNoCodeChange(),
+          LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
+          name,
+          KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+          label.isCopyAllScoresIfNoChange(),
+          LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
+          name,
+          KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+          label.isCopyAllScoresOnMergeFirstParentUpdate(),
+          LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+      setBooleanConfigKey(
+          rc, LABEL, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
+      List<String> values = Lists.newArrayListWithCapacity(label.getValues().size());
+      for (LabelValue value : label.getValues()) {
+        values.add(value.format().trim());
+      }
+      rc.setStringList(LABEL, name, KEY_VALUE, values);
+    }
+
+    for (String name : toUnset) {
+      rc.unsetSection(LABEL, name);
+    }
+  }
+
+  private static void setBooleanConfigKey(
+      Config rc, String section, String name, String key, boolean value, boolean defaultValue) {
+    if (value == defaultValue) {
+      rc.unset(section, name, key);
+    } else {
+      rc.setBoolean(section, name, key, value);
+    }
+  }
+
+  private void savePluginSections(Config rc, Set<AccountGroup.UUID> keepGroups) {
+    List<String> existing = Lists.newArrayList(rc.getSubsections(PLUGIN));
+    for (String name : existing) {
+      rc.unsetSection(PLUGIN, name);
+    }
+
+    for (Entry<String, Config> e : pluginConfigs.entrySet()) {
+      String plugin = e.getKey();
+      Config pluginConfig = e.getValue();
+      for (String name : pluginConfig.getNames(PLUGIN, plugin)) {
+        String value = pluginConfig.getString(PLUGIN, plugin, name);
+        String groupName = GroupReference.extractGroupName(value);
+        if (groupName != null) {
+          GroupReference ref = groupsByName.get(groupName);
+          if (ref != null && ref.getUUID() != null) {
+            keepGroups.add(ref.getUUID());
+            pluginConfig.setString(PLUGIN, plugin, name, "group " + ref.getName());
+          }
+        }
+        rc.setStringList(
+            PLUGIN, plugin, name, Arrays.asList(pluginConfig.getStringList(PLUGIN, plugin, name)));
+      }
+    }
+  }
+
+  private void saveGroupList() throws IOException {
+    saveUTF8(GroupList.FILE_NAME, groupList.asText());
+  }
+
+  private void saveSubscribeSections(Config rc) {
+    for (Project.NameKey p : subscribeSections.keySet()) {
+      SubscribeSection s = subscribeSections.get(p);
+      List<String> matchings = new ArrayList<>();
+      for (RefSpec r : s.getMatchingRefSpecs()) {
+        matchings.add(r.toString());
+      }
+      rc.setStringList(SUBSCRIBE_SECTION, p.get(), SUBSCRIBE_MATCH_REFS, matchings);
+
+      List<String> multimatchs = new ArrayList<>();
+      for (RefSpec r : s.getMultiMatchRefSpecs()) {
+        multimatchs.add(r.toString());
+      }
+      rc.setStringList(SUBSCRIBE_SECTION, p.get(), SUBSCRIBE_MULTI_MATCH_REFS, multimatchs);
+    }
+  }
+
+  private <E extends Enum<?>> E getEnum(
+      Config rc, String section, String subsection, String name, E defaultValue) {
+    try {
+      return rc.getEnum(section, subsection, name, defaultValue);
+    } catch (IllegalArgumentException err) {
+      error(new ValidationError(PROJECT_CONFIG, err.getMessage()));
+      return defaultValue;
+    }
+  }
+
+  @Override
+  public void error(ValidationError error) {
+    if (validationErrors == null) {
+      validationErrors = new ArrayList<>(4);
+    }
+    validationErrors.add(error);
+  }
+
+  private static <T extends Comparable<? super T>> List<T> sort(Collection<T> m) {
+    ArrayList<T> r = new ArrayList<>(m);
+    Collections.sort(r);
+    return r;
+  }
+
+  public boolean hasLegacyPermissions() {
+    return hasLegacyPermissions;
+  }
+
+  private String convertLegacyPermission(String permissionName) {
+    switch (permissionName) {
+      case LEGACY_PERMISSION_PUSH_TAG:
+        hasLegacyPermissions = true;
+        return Permission.CREATE_TAG;
+      case LEGACY_PERMISSION_PUSH_SIGNED_TAG:
+        hasLegacyPermissions = true;
+        return Permission.CREATE_SIGNED_TAG;
+      default:
+        return permissionName;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectLevelConfig.java b/java/com/google/gerrit/server/git/ProjectLevelConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectLevelConfig.java
rename to java/com/google/gerrit/server/git/ProjectLevelConfig.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectRunnable.java b/java/com/google/gerrit/server/git/ProjectRunnable.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectRunnable.java
rename to java/com/google/gerrit/server/git/ProjectRunnable.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueryList.java b/java/com/google/gerrit/server/git/QueryList.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/QueryList.java
rename to java/com/google/gerrit/server/git/QueryList.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java b/java/com/google/gerrit/server/git/QueueProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java
rename to java/com/google/gerrit/server/git/QueueProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java b/java/com/google/gerrit/server/git/RebaseSorter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
rename to java/com/google/gerrit/server/git/RebaseSorter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceivePackInitializer.java b/java/com/google/gerrit/server/git/ReceivePackInitializer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/ReceivePackInitializer.java
rename to java/com/google/gerrit/server/git/ReceivePackInitializer.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RefCache.java b/java/com/google/gerrit/server/git/RefCache.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/RefCache.java
rename to java/com/google/gerrit/server/git/RefCache.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RenameGroupOp.java b/java/com/google/gerrit/server/git/RenameGroupOp.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/RenameGroupOp.java
rename to java/com/google/gerrit/server/git/RenameGroupOp.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java b/java/com/google/gerrit/server/git/RepoRefCache.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
rename to java/com/google/gerrit/server/git/RepoRefCache.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java b/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
rename to java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReviewNoteMerger.java b/java/com/google/gerrit/server/git/ReviewNoteMerger.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/ReviewNoteMerger.java
rename to java/com/google/gerrit/server/git/ReviewNoteMerger.java
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
new file mode 100644
index 0000000..6ae8b91
--- /dev/null
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -0,0 +1,162 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import com.google.inject.util.Providers;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class SearchingChangeCacheImpl implements GitReferenceUpdatedListener {
+  private static final Logger log = LoggerFactory.getLogger(SearchingChangeCacheImpl.class);
+  static final String ID_CACHE = "changes";
+
+  public static class Module extends CacheModule {
+    private final boolean slave;
+
+    public Module() {
+      this(false);
+    }
+
+    public Module(boolean slave) {
+      this.slave = slave;
+    }
+
+    @Override
+    protected void configure() {
+      if (slave) {
+        bind(SearchingChangeCacheImpl.class)
+            .toProvider(Providers.<SearchingChangeCacheImpl>of(null));
+      } else {
+        cache(ID_CACHE, Project.NameKey.class, new TypeLiteral<List<CachedChange>>() {})
+            .maximumWeight(0)
+            .loader(Loader.class);
+
+        bind(SearchingChangeCacheImpl.class);
+        DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
+            .to(SearchingChangeCacheImpl.class);
+      }
+    }
+  }
+
+  @AutoValue
+  abstract static class CachedChange {
+    // Subset of fields in ChangeData, specifically fields needed to serve
+    // VisibleRefFilter without touching the database. More can be added as
+    // necessary.
+    abstract Change change();
+
+    @Nullable
+    abstract ReviewerSet reviewers();
+  }
+
+  private final LoadingCache<Project.NameKey, List<CachedChange>> cache;
+  private final ChangeData.Factory changeDataFactory;
+
+  @Inject
+  SearchingChangeCacheImpl(
+      @Named(ID_CACHE) LoadingCache<Project.NameKey, List<CachedChange>> cache,
+      ChangeData.Factory changeDataFactory) {
+    this.cache = cache;
+    this.changeDataFactory = changeDataFactory;
+  }
+
+  /**
+   * Read changes for the project from the secondary index.
+   *
+   * <p>Returned changes only include the {@code Change} object (with id, branch) and the reviewers.
+   * Additional stored fields are not loaded from the index.
+   *
+   * @param db database handle to populate missing change data (probably unused).
+   * @param project project to read.
+   * @return list of known changes; empty if no changes.
+   */
+  public List<ChangeData> getChangeData(ReviewDb db, Project.NameKey project) {
+    try {
+      List<CachedChange> cached = cache.get(project);
+      List<ChangeData> cds = new ArrayList<>(cached.size());
+      for (CachedChange cc : cached) {
+        ChangeData cd = changeDataFactory.create(db, cc.change());
+        cd.setReviewers(cc.reviewers());
+        cds.add(cd);
+      }
+      return Collections.unmodifiableList(cds);
+    } catch (ExecutionException e) {
+      log.warn("Cannot fetch changes for " + project, e);
+      return Collections.emptyList();
+    }
+  }
+
+  @Override
+  public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
+    if (event.getRefName().startsWith(RefNames.REFS_CHANGES)) {
+      cache.invalidate(new Project.NameKey(event.getProjectName()));
+    }
+  }
+
+  static class Loader extends CacheLoader<Project.NameKey, List<CachedChange>> {
+    private final OneOffRequestContext requestContext;
+    private final Provider<InternalChangeQuery> queryProvider;
+
+    @Inject
+    Loader(OneOffRequestContext requestContext, Provider<InternalChangeQuery> queryProvider) {
+      this.requestContext = requestContext;
+      this.queryProvider = queryProvider;
+    }
+
+    @Override
+    public List<CachedChange> load(Project.NameKey key) throws Exception {
+      try (ManualRequestContext ctx = requestContext.open()) {
+        List<ChangeData> cds =
+            queryProvider
+                .get()
+                .setRequestedFields(ChangeField.CHANGE, ChangeField.REVIEWER)
+                .byProject(key);
+        List<CachedChange> result = new ArrayList<>(cds.size());
+        for (ChangeData cd : cds) {
+          result.add(
+              new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.getReviewers()));
+        }
+        return Collections.unmodifiableList(result);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SendEmailExecutor.java b/java/com/google/gerrit/server/git/SendEmailExecutor.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/SendEmailExecutor.java
rename to java/com/google/gerrit/server/git/SendEmailExecutor.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleException.java b/java/com/google/gerrit/server/git/SubmoduleException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleException.java
rename to java/com/google/gerrit/server/git/SubmoduleException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java b/java/com/google/gerrit/server/git/SubmoduleOp.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
rename to java/com/google/gerrit/server/git/SubmoduleOp.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java b/java/com/google/gerrit/server/git/TabFile.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java
rename to java/com/google/gerrit/server/git/TabFile.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TagCache.java b/java/com/google/gerrit/server/git/TagCache.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/TagCache.java
rename to java/com/google/gerrit/server/git/TagCache.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TagMatcher.java b/java/com/google/gerrit/server/git/TagMatcher.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/TagMatcher.java
rename to java/com/google/gerrit/server/git/TagMatcher.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/TagSet.java
rename to java/com/google/gerrit/server/git/TagSet.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TagSetHolder.java b/java/com/google/gerrit/server/git/TagSetHolder.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/TagSetHolder.java
rename to java/com/google/gerrit/server/git/TagSetHolder.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TaskInfoFactory.java b/java/com/google/gerrit/server/git/TaskInfoFactory.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/TaskInfoFactory.java
rename to java/com/google/gerrit/server/git/TaskInfoFactory.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TransferConfig.java b/java/com/google/gerrit/server/git/TransferConfig.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/TransferConfig.java
rename to java/com/google/gerrit/server/git/TransferConfig.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackInitializer.java b/java/com/google/gerrit/server/git/UploadPackInitializer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackInitializer.java
rename to java/com/google/gerrit/server/git/UploadPackInitializer.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackMetricsHook.java b/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
rename to java/com/google/gerrit/server/git/UploadPackMetricsHook.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/UserConfigSections.java b/java/com/google/gerrit/server/git/UserConfigSections.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/UserConfigSections.java
rename to java/com/google/gerrit/server/git/UserConfigSections.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ValidationError.java b/java/com/google/gerrit/server/git/ValidationError.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/ValidationError.java
rename to java/com/google/gerrit/server/git/ValidationError.java
diff --git a/java/com/google/gerrit/server/git/VersionedMetaData.java b/java/com/google/gerrit/server/git/VersionedMetaData.java
new file mode 100644
index 0000000..812e693
--- /dev/null
+++ b/java/com/google/gerrit/server/git/VersionedMetaData.java
@@ -0,0 +1,566 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.Nullable;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.util.ChangeIdUtil;
+import org.eclipse.jgit.util.RawParseUtils;
+
+/**
+ * Support for metadata stored within a version controlled branch.
+ *
+ * <p>Implementors are responsible for supplying implementations of the onLoad and onSave methods to
+ * read from the repository, or format an update that can later be written back to the repository.
+ */
+public abstract class VersionedMetaData {
+  /**
+   * Path information that does not hold references to any repository data structures, allowing the
+   * application to retain this object for long periods of time.
+   */
+  public static class PathInfo {
+    public final FileMode fileMode;
+    public final String path;
+    public final ObjectId objectId;
+
+    protected PathInfo(TreeWalk tw) {
+      fileMode = tw.getFileMode(0);
+      path = tw.getPathString();
+      objectId = tw.getObjectId(0);
+    }
+  }
+
+  /** The revision at which the data was loaded. Is null for data yet to be created. */
+  @Nullable protected RevCommit revision;
+
+  protected RevWalk rw;
+  protected ObjectReader reader;
+  protected ObjectInserter inserter;
+  protected DirCache newTree;
+
+  /** @return name of the reference storing this configuration. */
+  protected abstract String getRefName();
+
+  /** Set up the metadata, parsing any state from the loaded revision. */
+  protected abstract void onLoad() throws IOException, ConfigInvalidException;
+
+  /**
+   * Save any changes to the metadata in a commit.
+   *
+   * @return true if the commit should proceed, false to abort.
+   * @throws IOException
+   * @throws ConfigInvalidException
+   */
+  protected abstract boolean onSave(CommitBuilder commit)
+      throws IOException, ConfigInvalidException;
+
+  /** @return revision of the metadata that was loaded. */
+  public ObjectId getRevision() {
+    return revision != null ? revision.copy() : null;
+  }
+
+  /**
+   * Load the current version from the branch.
+   *
+   * <p>The repository is not held after the call completes, allowing the application to retain this
+   * object for long periods of time.
+   *
+   * @param db repository to access.
+   * @throws IOException
+   * @throws ConfigInvalidException
+   */
+  public void load(Repository db) throws IOException, ConfigInvalidException {
+    Ref ref = db.getRefDatabase().exactRef(getRefName());
+    load(db, ref != null ? ref.getObjectId() : null);
+  }
+
+  /**
+   * Load a specific version from the repository.
+   *
+   * <p>This method is primarily useful for applying updates to a specific revision that was shown
+   * to an end-user in the user interface. If there are conflicts with another user's concurrent
+   * changes, these will be automatically detected at commit time.
+   *
+   * <p>The repository is not held after the call completes, allowing the application to retain this
+   * object for long periods of time.
+   *
+   * @param db repository to access.
+   * @param id revision to load.
+   * @throws IOException
+   * @throws ConfigInvalidException
+   */
+  public void load(Repository db, @Nullable ObjectId id)
+      throws IOException, ConfigInvalidException {
+    try (RevWalk walk = new RevWalk(db)) {
+      load(walk, id);
+    }
+  }
+
+  /**
+   * Load a specific version from an open walk.
+   *
+   * <p>This method is primarily useful for applying updates to a specific revision that was shown
+   * to an end-user in the user interface. If there are conflicts with another user's concurrent
+   * changes, these will be automatically detected at commit time.
+   *
+   * <p>The caller retains ownership of the walk and is responsible for closing it. However, this
+   * instance does not hold a reference to the walk or the repository after the call completes,
+   * allowing the application to retain this object for long periods of time.
+   *
+   * @param walk open walk to access to access.
+   * @param id revision to load.
+   * @throws IOException
+   * @throws ConfigInvalidException
+   */
+  public void load(RevWalk walk, ObjectId id) throws IOException, ConfigInvalidException {
+    this.rw = walk;
+    this.reader = walk.getObjectReader();
+    try {
+      revision = id != null ? walk.parseCommit(id) : null;
+      onLoad();
+    } finally {
+      this.rw = null;
+      this.reader = null;
+    }
+  }
+
+  public void load(MetaDataUpdate update) throws IOException, ConfigInvalidException {
+    load(update.getRepository());
+  }
+
+  public void load(MetaDataUpdate update, ObjectId id) throws IOException, ConfigInvalidException {
+    load(update.getRepository(), id);
+  }
+
+  /**
+   * Update this metadata branch, recording a new commit on its reference.
+   *
+   * @param update helper information to define the update that will occur.
+   * @return the commit that was created
+   * @throws IOException if there is a storage problem and the update cannot be executed as
+   *     requested or if it failed because of a concurrent update to the same reference
+   */
+  public RevCommit commit(MetaDataUpdate update) throws IOException {
+    try (BatchMetaDataUpdate batch = openUpdate(update)) {
+      batch.write(update.getCommitBuilder());
+      return batch.commit();
+    }
+  }
+
+  /**
+   * Creates a new commit and a new ref based on this commit.
+   *
+   * @param update helper information to define the update that will occur.
+   * @param refName name of the ref that should be created
+   * @return the commit that was created
+   * @throws IOException if there is a storage problem and the update cannot be executed as
+   *     requested or if it failed because of a concurrent update to the same reference
+   */
+  public RevCommit commitToNewRef(MetaDataUpdate update, String refName) throws IOException {
+    try (BatchMetaDataUpdate batch = openUpdate(update)) {
+      batch.write(update.getCommitBuilder());
+      return batch.createRef(refName);
+    }
+  }
+
+  public interface BatchMetaDataUpdate extends AutoCloseable {
+    void write(CommitBuilder commit) throws IOException;
+
+    void write(VersionedMetaData config, CommitBuilder commit) throws IOException;
+
+    RevCommit createRef(String refName) throws IOException;
+
+    RevCommit commit() throws IOException;
+
+    RevCommit commitAt(ObjectId revision) throws IOException;
+
+    @Override
+    void close();
+  }
+
+  /**
+   * Open a batch of updates to the same metadata ref.
+   *
+   * <p>This allows making multiple commits to a single metadata ref, at the end of which is a
+   * single ref update. For batching together updates to multiple refs (each consisting of one or
+   * more commits against their respective refs), create the {@link MetaDataUpdate} with a {@link
+   * BatchRefUpdate}.
+   *
+   * <p>A ref update produced by this {@link BatchMetaDataUpdate} is only committed if there is no
+   * associated {@link BatchRefUpdate}. As a result, the configured ref updated event is not fired
+   * if there is an associated batch.
+   *
+   * @param update helper info about the update.
+   * @throws IOException if the update failed.
+   */
+  public BatchMetaDataUpdate openUpdate(MetaDataUpdate update) throws IOException {
+    final Repository db = update.getRepository();
+
+    inserter = db.newObjectInserter();
+    reader = inserter.newReader();
+    final RevWalk rw = new RevWalk(reader);
+    final RevTree tree = revision != null ? rw.parseTree(revision) : null;
+    newTree = readTree(tree);
+    return new BatchMetaDataUpdate() {
+      RevCommit src = revision;
+      AnyObjectId srcTree = tree;
+
+      @Override
+      public void write(CommitBuilder commit) throws IOException {
+        write(VersionedMetaData.this, commit);
+      }
+
+      private boolean doSave(VersionedMetaData config, CommitBuilder commit) throws IOException {
+        DirCache nt = config.newTree;
+        ObjectReader r = config.reader;
+        ObjectInserter i = config.inserter;
+        RevCommit c = config.revision;
+        try {
+          config.newTree = newTree;
+          config.reader = reader;
+          config.inserter = inserter;
+          config.revision = src;
+          return config.onSave(commit);
+        } catch (ConfigInvalidException e) {
+          throw new IOException(
+              "Cannot update " + getRefName() + " in " + db.getDirectory() + ": " + e.getMessage(),
+              e);
+        } finally {
+          config.newTree = nt;
+          config.reader = r;
+          config.inserter = i;
+          config.revision = c;
+        }
+      }
+
+      @Override
+      public void write(VersionedMetaData config, CommitBuilder commit) throws IOException {
+        checkSameRef(config);
+        if (!doSave(config, commit)) {
+          return;
+        }
+
+        ObjectId res = newTree.writeTree(inserter);
+        if (res.equals(srcTree) && !update.allowEmpty() && (commit.getTreeId() == null)) {
+          // If there are no changes to the content, don't create the commit.
+          return;
+        }
+
+        // If changes are made to the DirCache and those changes are written as
+        // a commit and then the tree ID is set for the CommitBuilder, then
+        // those previous DirCache changes will be ignored and the commit's
+        // tree will be replaced with the ID in the CommitBuilder. The same is
+        // true if you explicitly set tree ID in a commit and then make changes
+        // to the DirCache; that tree ID will be ignored and replaced by that of
+        // the tree for the updated DirCache.
+        if (commit.getTreeId() == null) {
+          commit.setTreeId(res);
+        } else {
+          // In this case, the caller populated the tree without using DirCache.
+          res = commit.getTreeId();
+        }
+
+        if (src != null) {
+          commit.addParentId(src);
+        }
+
+        if (update.insertChangeId()) {
+          ObjectId id =
+              ChangeIdUtil.computeChangeId(
+                  res,
+                  getRevision(),
+                  commit.getAuthor(),
+                  commit.getCommitter(),
+                  commit.getMessage());
+          commit.setMessage(ChangeIdUtil.insertId(commit.getMessage(), id));
+        }
+
+        src = rw.parseCommit(inserter.insert(commit));
+        srcTree = res;
+      }
+
+      private void checkSameRef(VersionedMetaData other) {
+        String thisRef = VersionedMetaData.this.getRefName();
+        String otherRef = other.getRefName();
+        checkArgument(
+            otherRef.equals(thisRef),
+            "cannot add %s for %s to %s on %s",
+            other.getClass().getSimpleName(),
+            otherRef,
+            BatchMetaDataUpdate.class.getSimpleName(),
+            thisRef);
+      }
+
+      @Override
+      public RevCommit createRef(String refName) throws IOException {
+        if (Objects.equals(src, revision)) {
+          return revision;
+        }
+        return updateRef(ObjectId.zeroId(), src, refName);
+      }
+
+      @Override
+      public RevCommit commit() throws IOException {
+        return commitAt(revision);
+      }
+
+      @Override
+      public RevCommit commitAt(ObjectId expected) throws IOException {
+        if (Objects.equals(src, expected)) {
+          return revision;
+        }
+        return updateRef(MoreObjects.firstNonNull(expected, ObjectId.zeroId()), src, getRefName());
+      }
+
+      @Override
+      public void close() {
+        newTree = null;
+
+        rw.close();
+        if (inserter != null) {
+          inserter.close();
+          inserter = null;
+        }
+
+        if (reader != null) {
+          reader.close();
+          reader = null;
+        }
+      }
+
+      private RevCommit updateRef(AnyObjectId oldId, AnyObjectId newId, String refName)
+          throws IOException {
+        BatchRefUpdate bru = update.getBatch();
+        if (bru != null) {
+          bru.addCommand(new ReceiveCommand(oldId.toObjectId(), newId.toObjectId(), refName));
+          inserter.flush();
+          revision = rw.parseCommit(newId);
+          return revision;
+        }
+
+        RefUpdate ru = db.updateRef(refName);
+        ru.setExpectedOldObjectId(oldId);
+        ru.setNewObjectId(newId);
+        ru.setRefLogIdent(update.getCommitBuilder().getAuthor());
+        String message = update.getCommitBuilder().getMessage();
+        if (message == null) {
+          message = "meta data update";
+        }
+        try (BufferedReader reader = new BufferedReader(new StringReader(message))) {
+          // read the subject line and use it as reflog message
+          ru.setRefLogMessage("commit: " + reader.readLine(), true);
+        }
+        inserter.flush();
+        RefUpdate.Result result = ru.update();
+        switch (result) {
+          case NEW:
+          case FAST_FORWARD:
+            revision = rw.parseCommit(ru.getNewObjectId());
+            update.fireGitRefUpdatedEvent(ru);
+            return revision;
+          case LOCK_FAILURE:
+            throw new LockFailureException(
+                "Cannot update "
+                    + ru.getName()
+                    + " in "
+                    + db.getDirectory()
+                    + ": "
+                    + ru.getResult(),
+                ru);
+          case FORCED:
+          case IO_FAILURE:
+          case NOT_ATTEMPTED:
+          case NO_CHANGE:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+          default:
+            throw new IOException(
+                "Cannot update "
+                    + ru.getName()
+                    + " in "
+                    + db.getDirectory()
+                    + ": "
+                    + ru.getResult());
+        }
+      }
+    };
+  }
+
+  protected DirCache readTree(RevTree tree)
+      throws IOException, MissingObjectException, IncorrectObjectTypeException {
+    DirCache dc = DirCache.newInCore();
+    if (tree != null) {
+      DirCacheBuilder b = dc.builder();
+      b.addTree(new byte[0], DirCacheEntry.STAGE_0, reader, tree);
+      b.finish();
+    }
+    return dc;
+  }
+
+  protected Config readConfig(String fileName) throws IOException, ConfigInvalidException {
+    Config rc = new Config();
+    String text = readUTF8(fileName);
+    if (!text.isEmpty()) {
+      try {
+        rc.fromText(text);
+      } catch (ConfigInvalidException err) {
+        StringBuilder msg =
+            new StringBuilder("Invalid config file ")
+                .append(fileName)
+                .append(" in commit ")
+                .append(revision.name());
+        if (err.getCause() != null) {
+          msg.append(": ").append(err.getCause());
+        }
+        throw new ConfigInvalidException(msg.toString(), err);
+      }
+    }
+    return rc;
+  }
+
+  protected String readUTF8(String fileName) throws IOException {
+    byte[] raw = readFile(fileName);
+    return raw.length != 0 ? RawParseUtils.decode(raw) : "";
+  }
+
+  protected byte[] readFile(String fileName) throws IOException {
+    if (revision == null) {
+      return new byte[] {};
+    }
+
+    TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree());
+    if (tw != null) {
+      ObjectLoader obj = reader.open(tw.getObjectId(0), Constants.OBJ_BLOB);
+      return obj.getCachedBytes(Integer.MAX_VALUE);
+    }
+    return new byte[] {};
+  }
+
+  protected ObjectId getObjectId(String fileName) throws IOException {
+    if (revision == null) {
+      return null;
+    }
+
+    TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree());
+    if (tw != null) {
+      return tw.getObjectId(0);
+    }
+
+    return null;
+  }
+
+  public List<PathInfo> getPathInfos(boolean recursive) throws IOException {
+    try (TreeWalk tw = new TreeWalk(reader)) {
+      tw.addTree(revision.getTree());
+      tw.setRecursive(recursive);
+      List<PathInfo> paths = new ArrayList<>();
+      while (tw.next()) {
+        paths.add(new PathInfo(tw));
+      }
+      return paths;
+    }
+  }
+
+  protected static void set(
+      Config rc, String section, String subsection, String name, String value) {
+    if (value != null) {
+      rc.setString(section, subsection, name, value);
+    } else {
+      rc.unset(section, subsection, name);
+    }
+  }
+
+  protected static void set(
+      Config rc, String section, String subsection, String name, boolean value) {
+    if (value) {
+      rc.setBoolean(section, subsection, name, value);
+    } else {
+      rc.unset(section, subsection, name);
+    }
+  }
+
+  protected static <E extends Enum<?>> void set(
+      Config rc, String section, String subsection, String name, E value, E defaultValue) {
+    if (value != defaultValue) {
+      rc.setEnum(section, subsection, name, value);
+    } else {
+      rc.unset(section, subsection, name);
+    }
+  }
+
+  protected void saveConfig(String fileName, Config cfg) throws IOException {
+    saveUTF8(fileName, cfg.toText());
+  }
+
+  protected void saveUTF8(String fileName, String text) throws IOException {
+    saveFile(fileName, text != null ? Constants.encode(text) : null);
+  }
+
+  protected void saveFile(String fileName, byte[] raw) throws IOException {
+    DirCacheEditor editor = newTree.editor();
+    if (raw != null && 0 < raw.length) {
+      final ObjectId blobId = inserter.insert(Constants.OBJ_BLOB, raw);
+      editor.add(
+          new PathEdit(fileName) {
+            @Override
+            public void apply(DirCacheEntry ent) {
+              ent.setFileMode(FileMode.REGULAR_FILE);
+              ent.setObjectId(blobId);
+            }
+          });
+    } else {
+      editor.add(new DeletePath(fileName));
+    }
+    editor.finish();
+  }
+}
diff --git a/java/com/google/gerrit/server/git/VisibleRefFilter.java b/java/com/google/gerrit/server/git/VisibleRefFilter.java
new file mode 100644
index 0000000..deede71
--- /dev/null
+++ b/java/com/google/gerrit/server/git/VisibleRefFilter.java
@@ -0,0 +1,410 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CACHE_AUTOMERGE;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS_SELF;
+import static java.util.stream.Collectors.toMap;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.SymbolicRef;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.AbstractAdvertiseRefsHook;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class VisibleRefFilter extends AbstractAdvertiseRefsHook {
+  private static final Logger log = LoggerFactory.getLogger(VisibleRefFilter.class);
+
+  public interface Factory {
+    VisibleRefFilter create(ProjectState projectState, Repository git);
+  }
+
+  private final TagCache tagCache;
+  private final ChangeNotes.Factory changeNotesFactory;
+  @Nullable private final SearchingChangeCacheImpl changeCache;
+  private final Provider<ReviewDb> db;
+  private final Provider<CurrentUser> user;
+  private final GroupCache groupCache;
+  private final PermissionBackend permissionBackend;
+  private final PermissionBackend.ForProject perm;
+  private final ProjectState projectState;
+  private final Repository git;
+  private boolean showMetadata = true;
+  private String userEditPrefix;
+  private Map<Change.Id, Branch.NameKey> visibleChanges;
+
+  @Inject
+  VisibleRefFilter(
+      TagCache tagCache,
+      ChangeNotes.Factory changeNotesFactory,
+      @Nullable SearchingChangeCacheImpl changeCache,
+      Provider<ReviewDb> db,
+      Provider<CurrentUser> user,
+      GroupCache groupCache,
+      PermissionBackend permissionBackend,
+      @Assisted ProjectState projectState,
+      @Assisted Repository git) {
+    this.tagCache = tagCache;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeCache = changeCache;
+    this.db = db;
+    this.user = user;
+    this.groupCache = groupCache;
+    this.permissionBackend = permissionBackend;
+    this.perm =
+        permissionBackend.user(user).database(db).project(projectState.getProject().getNameKey());
+    this.projectState = projectState;
+    this.git = git;
+  }
+
+  /** Show change references. Default is {@code true}. */
+  public VisibleRefFilter setShowMetadata(boolean show) {
+    showMetadata = show;
+    return this;
+  }
+
+  public Map<String, Ref> filter(Map<String, Ref> refs, boolean filterTagsSeparately) {
+    if (projectState.isAllUsers()) {
+      refs = addUsersSelfSymref(refs);
+    }
+
+    PermissionBackend.WithUser withUser = permissionBackend.user(user);
+    PermissionBackend.ForProject forProject = withUser.project(projectState.getNameKey());
+    if (!projectState.isAllUsers()) {
+      if (checkProjectPermission(forProject, ProjectPermission.READ)) {
+        return refs;
+      } else if (checkProjectPermission(forProject, ProjectPermission.READ_NO_CONFIG)) {
+        return fastHideRefsMetaConfig(refs);
+      }
+    }
+
+    boolean viewMetadata;
+    boolean isAdmin;
+    Account.Id userId;
+    IdentifiedUser identifiedUser;
+    if (user.get().isIdentifiedUser()) {
+      viewMetadata = withUser.testOrFalse(GlobalPermission.ACCESS_DATABASE);
+      isAdmin = withUser.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
+      identifiedUser = user.get().asIdentifiedUser();
+      userId = identifiedUser.getAccountId();
+      userEditPrefix = RefNames.refsEditPrefix(userId);
+    } else {
+      viewMetadata = false;
+      isAdmin = false;
+      userId = null;
+      identifiedUser = null;
+    }
+
+    Map<String, Ref> result = new HashMap<>();
+    List<Ref> deferredTags = new ArrayList<>();
+
+    for (Ref ref : refs.values()) {
+      String name = ref.getName();
+      Change.Id changeId;
+      Account.Id accountId;
+      AccountGroup.UUID accountGroupUuid;
+      if (name.startsWith(REFS_CACHE_AUTOMERGE) || (!showMetadata && isMetadata(name))) {
+        continue;
+      } else if (RefNames.isRefsEdit(name)) {
+        // Edits are visible only to the owning user, if change is visible.
+        if (viewMetadata || visibleEdit(name)) {
+          result.put(name, ref);
+        }
+      } else if ((changeId = Change.Id.fromRef(name)) != null) {
+        // Change ref is visible only if the change is visible.
+        if (viewMetadata || visible(changeId)) {
+          result.put(name, ref);
+        }
+      } else if ((accountId = Account.Id.fromRef(name)) != null) {
+        // Account ref is visible only to the corresponding account.
+        if (viewMetadata || (accountId.equals(userId) && canReadRef(name))) {
+          result.put(name, ref);
+        }
+      } else if ((accountGroupUuid = AccountGroup.UUID.fromRef(name)) != null) {
+        // Group ref is visible only to the corresponding owner group.
+        InternalGroup group = groupCache.get(accountGroupUuid).orElse(null);
+        if (viewMetadata
+            || (group != null
+                && isGroupOwner(group, identifiedUser, isAdmin)
+                && canReadRef(name))) {
+          result.put(name, ref);
+        }
+      } else if (isTag(ref)) {
+        // If its a tag, consider it later.
+        if (ref.getObjectId() != null) {
+          deferredTags.add(ref);
+        }
+      } else if (name.startsWith(RefNames.REFS_SEQUENCES)) {
+        // Sequences are internal database implementation details.
+        if (viewMetadata) {
+          result.put(name, ref);
+        }
+      } else if (projectState.isAllUsers()
+          && (name.equals(RefNames.REFS_EXTERNAL_IDS) || name.equals(RefNames.REFS_GROUPNAMES))) {
+        // The notes branches with the external IDs / group names must not be exposed to normal
+        // users.
+        if (viewMetadata) {
+          result.put(name, ref);
+        }
+      } else if (canReadRef(ref.getLeaf().getName())) {
+        // Use the leaf to lookup the control data. If the reference is
+        // symbolic we want the control around the final target. If its
+        // not symbolic then getLeaf() is a no-op returning ref itself.
+        result.put(name, ref);
+      } else if (isRefsUsersSelf(ref)) {
+        // viewMetadata allows to see all account refs, hence refs/users/self should be included as
+        // well
+        if (viewMetadata) {
+          result.put(name, ref);
+        }
+      }
+    }
+
+    // If we have tags that were deferred, we need to do a revision walk
+    // to identify what tags we can actually reach, and what we cannot.
+    //
+    if (!deferredTags.isEmpty() && (!result.isEmpty() || filterTagsSeparately)) {
+      TagMatcher tags =
+          tagCache
+              .get(projectState.getNameKey())
+              .matcher(
+                  tagCache,
+                  git,
+                  filterTagsSeparately ? filter(git.getAllRefs()).values() : result.values());
+      for (Ref tag : deferredTags) {
+        if (tags.isReachable(tag)) {
+          result.put(tag.getName(), tag);
+        }
+      }
+    }
+
+    return result;
+  }
+
+  private Map<String, Ref> fastHideRefsMetaConfig(Map<String, Ref> refs) {
+    if (refs.containsKey(REFS_CONFIG) && !canReadRef(REFS_CONFIG)) {
+      Map<String, Ref> r = new HashMap<>(refs);
+      r.remove(REFS_CONFIG);
+      return r;
+    }
+    return refs;
+  }
+
+  private Map<String, Ref> addUsersSelfSymref(Map<String, Ref> refs) {
+    if (user.get().isIdentifiedUser()) {
+      Ref r = refs.get(RefNames.refsUsers(user.get().getAccountId()));
+      if (r != null) {
+        SymbolicRef s = new SymbolicRef(REFS_USERS_SELF, r);
+        refs = new HashMap<>(refs);
+        refs.put(s.getName(), s);
+      }
+    }
+    return refs;
+  }
+
+  @Override
+  protected Map<String, Ref> getAdvertisedRefs(Repository repository, RevWalk revWalk)
+      throws ServiceMayNotContinueException {
+    try {
+      return filter(repository.getRefDatabase().getRefs(RefDatabase.ALL));
+    } catch (ServiceMayNotContinueException e) {
+      throw e;
+    } catch (IOException e) {
+      ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
+      ex.initCause(e);
+      throw ex;
+    }
+  }
+
+  private Map<String, Ref> filter(Map<String, Ref> refs) {
+    return filter(refs, false);
+  }
+
+  private boolean visible(Change.Id changeId) {
+    if (visibleChanges == null) {
+      if (changeCache == null) {
+        visibleChanges = visibleChangesByScan();
+      } else {
+        visibleChanges = visibleChangesBySearch();
+      }
+    }
+    return visibleChanges.containsKey(changeId);
+  }
+
+  private boolean visibleEdit(String name) {
+    Change.Id id = Change.Id.fromEditRefPart(name);
+    // Initialize if it wasn't yet
+    if (visibleChanges == null) {
+      visible(id);
+    }
+    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;
+  }
+
+  private Map<Change.Id, Branch.NameKey> visibleChangesBySearch() {
+    Project.NameKey project = projectState.getNameKey();
+    try {
+      Map<Change.Id, Branch.NameKey> visibleChanges = new HashMap<>();
+      for (ChangeData cd : changeCache.getChangeData(db.get(), project)) {
+        ChangeNotes notes = changeNotesFactory.createFromIndexedChange(cd.change());
+        if (perm.indexedChange(cd, notes).test(ChangePermission.READ)) {
+          visibleChanges.put(cd.getId(), cd.change().getDest());
+        }
+      }
+      return visibleChanges;
+    } catch (OrmException | PermissionBackendException e) {
+      log.error(
+          "Cannot load changes for project " + project + ", assuming no changes are visible", e);
+      return Collections.emptyMap();
+    }
+  }
+
+  private Map<Change.Id, Branch.NameKey> visibleChangesByScan() {
+    Project.NameKey p = projectState.getNameKey();
+    Stream<ChangeNotesResult> s;
+    try {
+      s = changeNotesFactory.scan(git, db.get(), p);
+    } catch (IOException e) {
+      log.error("Cannot load changes for project " + p + ", assuming no changes are visible", e);
+      return Collections.emptyMap();
+    }
+    return s.map(r -> toNotes(p, r))
+        .filter(Objects::nonNull)
+        .collect(toMap(n -> n.getChangeId(), n -> n.getChange().getDest()));
+  }
+
+  @Nullable
+  private ChangeNotes toNotes(Project.NameKey p, ChangeNotesResult r) {
+    if (r.error().isPresent()) {
+      log.warn("Failed to load change " + r.id() + " in " + p, r.error().get());
+      return null;
+    }
+    try {
+      if (perm.change(r.notes()).test(ChangePermission.READ)) {
+        return r.notes();
+      }
+    } catch (PermissionBackendException e) {
+      log.warn("Failed to check permission for " + r.id() + " in " + p, e);
+    }
+    return null;
+  }
+
+  private boolean isMetadata(String name) {
+    return name.startsWith(REFS_CHANGES) || RefNames.isRefsEdit(name);
+  }
+
+  private static boolean isTag(Ref ref) {
+    return ref.getLeaf().getName().startsWith(Constants.R_TAGS);
+  }
+
+  private static boolean isRefsUsersSelf(Ref ref) {
+    return ref.getName().startsWith(REFS_USERS_SELF);
+  }
+
+  private boolean canReadRef(String ref) {
+    try {
+      perm.ref(ref).check(RefPermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    } catch (PermissionBackendException e) {
+      log.error("unable to check permissions", e);
+      return false;
+    }
+  }
+
+  private boolean checkProjectPermission(
+      PermissionBackend.ForProject forProject, ProjectPermission perm) {
+    try {
+      forProject.check(perm);
+    } catch (AuthException e) {
+      return false;
+    } catch (PermissionBackendException e) {
+      log.error(
+          String.format(
+              "Can't check permission for user %s on project %s",
+              user.get(), projectState.getName()),
+          e);
+      return false;
+    }
+    return true;
+  }
+
+  private boolean isGroupOwner(
+      InternalGroup group, @Nullable IdentifiedUser user, boolean isAdmin) {
+    checkNotNull(group);
+
+    // Keep this logic in sync with GroupControl#isOwner().
+    return isAdmin
+        || (user != null && user.getEffectiveGroups().contains(group.getOwnerGroupUUID()));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
rename to java/com/google/gerrit/server/git/WorkQueue.java
diff --git a/java/com/google/gerrit/server/git/receive/AllRefsWatcher.java b/java/com/google/gerrit/server/git/receive/AllRefsWatcher.java
new file mode 100644
index 0000000..c092c43
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/AllRefsWatcher.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+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;
+import org.eclipse.jgit.transport.BaseReceivePack;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+import org.eclipse.jgit.transport.UploadPack;
+
+/**
+ * Hook that scans all refs and holds onto the results reference.
+ *
+ * <p>This allows a caller who has an {@code AllRefsWatcher} instance to get the full map of refs in
+ * the repo, even if refs are filtered by a later hook or filter.
+ */
+class AllRefsWatcher implements AdvertiseRefsHook {
+  private Map<String, Ref> allRefs;
+
+  @Override
+  public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
+    allRefs = HookUtil.ensureAllRefsAdvertised(rp);
+  }
+
+  @Override
+  public void advertiseRefs(UploadPack uploadPack) {
+    throw new UnsupportedOperationException();
+  }
+
+  Map<String, Ref> getAllRefs() {
+    checkState(allRefs != null, "getAllRefs() only valid after refs were advertised");
+    return allRefs;
+  }
+}
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
new file mode 100644
index 0000000..6eba282
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -0,0 +1,293 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import com.google.common.collect.SetMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.HackPushNegotiateHook;
+import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.ProjectRunnable;
+import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.inject.Inject;
+import com.google.inject.PrivateModule;
+import com.google.inject.Provider;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.AdvertiseRefsHook;
+import org.eclipse.jgit.transport.AdvertiseRefsHookChain;
+import org.eclipse.jgit.transport.PreReceiveHook;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.ReceiveCommand.Result;
+import org.eclipse.jgit.transport.ReceivePack;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Hook that delegates to {@link ReceiveCommits} in a worker thread. */
+public class AsyncReceiveCommits implements PreReceiveHook {
+  private static final Logger log = LoggerFactory.getLogger(AsyncReceiveCommits.class);
+
+  private static final String TIMEOUT_NAME = "ReceiveCommitsOverallTimeout";
+
+  public interface Factory {
+    AsyncReceiveCommits create(
+        ProjectState projectState,
+        IdentifiedUser user,
+        Repository repository,
+        @Nullable MessageSender messageSender,
+        SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers);
+  }
+
+  public static class Module extends PrivateModule {
+    @Override
+    public void configure() {
+      install(new FactoryModuleBuilder().build(AsyncReceiveCommits.Factory.class));
+      expose(AsyncReceiveCommits.Factory.class);
+      // Don't expose the binding for ReceiveCommits.Factory. All callers should
+      // be using AsyncReceiveCommits.Factory instead.
+      install(new FactoryModuleBuilder().build(ReceiveCommits.Factory.class));
+    }
+
+    @Provides
+    @Singleton
+    @Named(TIMEOUT_NAME)
+    long getTimeoutMillis(@GerritServerConfig Config cfg) {
+      return ConfigUtil.getTimeUnit(
+          cfg, "receive", null, "timeout", TimeUnit.MINUTES.toMillis(4), TimeUnit.MILLISECONDS);
+    }
+  }
+
+  private class Worker implements ProjectRunnable {
+    final MultiProgressMonitor progress;
+
+    private final Collection<ReceiveCommand> commands;
+    private final ReceiveCommits rc;
+
+    private Worker(Collection<ReceiveCommand> commands) {
+      this.commands = commands;
+      rc = factory.create(projectState, user, rp, allRefsWatcher, extraReviewers);
+      rc.init();
+      rc.setMessageSender(messageSender);
+      progress = new MultiProgressMonitor(new MessageSenderOutputStream(), "Processing changes");
+    }
+
+    @Override
+    public void run() {
+      rc.processCommands(commands, progress);
+    }
+
+    @Override
+    public Project.NameKey getProjectNameKey() {
+      return rc.getProject().getNameKey();
+    }
+
+    @Override
+    public String getRemoteName() {
+      return null;
+    }
+
+    @Override
+    public boolean hasCustomizedPrint() {
+      return true;
+    }
+
+    @Override
+    public String toString() {
+      return "receive-commits";
+    }
+
+    void sendMessages() {
+      rc.sendMessages();
+    }
+
+    private class MessageSenderOutputStream extends OutputStream {
+      @Override
+      public void write(int b) {
+        rc.getMessageSender().sendBytes(new byte[] {(byte) b});
+      }
+
+      @Override
+      public void write(byte[] what, int off, int len) {
+        rc.getMessageSender().sendBytes(what, off, len);
+      }
+
+      @Override
+      public void write(byte[] what) {
+        rc.getMessageSender().sendBytes(what);
+      }
+
+      @Override
+      public void flush() {
+        rc.getMessageSender().flush();
+      }
+    }
+  }
+
+  private final ReceiveCommits.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 ProjectState projectState;
+  private final IdentifiedUser user;
+  private final Repository repo;
+  private final MessageSender messageSender;
+  private final SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers;
+  private final AllRefsWatcher allRefsWatcher;
+
+  @Inject
+  AsyncReceiveCommits(
+      ReceiveCommits.Factory factory,
+      PermissionBackend permissionBackend,
+      VisibleRefFilter.Factory refFilterFactory,
+      Provider<InternalChangeQuery> queryProvider,
+      @ReceiveCommitsExecutor ExecutorService executor,
+      RequestScopePropagator scopePropagator,
+      ReceiveConfig receiveConfig,
+      TransferConfig transferConfig,
+      Provider<LazyPostReceiveHookChain> lazyPostReceive,
+      ContributorAgreementsChecker contributorAgreements,
+      @Named(TIMEOUT_NAME) long timeoutMillis,
+      @Assisted ProjectState projectState,
+      @Assisted IdentifiedUser user,
+      @Assisted Repository repo,
+      @Assisted @Nullable MessageSender messageSender,
+      @Assisted SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers)
+      throws PermissionBackendException {
+    this.factory = factory;
+    this.executor = executor;
+    this.scopePropagator = scopePropagator;
+    this.receiveConfig = receiveConfig;
+    this.contributorAgreements = contributorAgreements;
+    this.timeoutMillis = timeoutMillis;
+    this.projectState = projectState;
+    this.user = user;
+    this.repo = repo;
+    this.messageSender = messageSender;
+    this.extraReviewers = extraReviewers;
+
+    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(projectState));
+    rp.setCheckReceivedObjects(projectState.getConfig().getCheckReceivedObjects());
+    rp.setRefFilter(new ReceiveRefFilter());
+    rp.setAllowPushOptions(true);
+    rp.setPreReceiveHook(this);
+    rp.setPostReceiveHook(lazyPostReceive.get());
+
+    // If the user lacks READ permission, some references may be filtered and hidden from view.
+    // Check objects mentioned inside the incoming pack file are reachable from visible refs.
+    this.perm = permissionBackend.user(user).project(projectName);
+    try {
+      this.perm.check(ProjectPermission.READ);
+    } catch (AuthException e) {
+      rp.setCheckReferencedObjectsAreReachable(receiveConfig.checkReferencedObjectsAreReachable);
+    }
+
+    List<AdvertiseRefsHook> advHooks = new ArrayList<>(4);
+    allRefsWatcher = new AllRefsWatcher();
+    advHooks.add(allRefsWatcher);
+    advHooks.add(refFilterFactory.create(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, 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(projectState.getNameKey(), user);
+    } catch (AuthException e) {
+      return new Capable(e.getMessage());
+    }
+
+    if (receiveConfig.checkMagicRefs) {
+      return MagicBranch.checkMagicBranchRefs(repo, projectState.getProject());
+    }
+    return Capable.OK;
+  }
+
+  @Override
+  public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
+    Worker w = new Worker(commands);
+    try {
+      w.progress.waitFor(
+          executor.submit(scopePropagator.wrap(w)), timeoutMillis, TimeUnit.MILLISECONDS);
+    } catch (ExecutionException e) {
+      log.warn(
+          String.format(
+              "Error in ReceiveCommits while processing changes for project %s",
+              projectState.getName()),
+          e);
+      rp.sendError("internal error while processing changes");
+      // ReceiveCommits has tried its best to catch errors, so anything at this
+      // point is very bad.
+      for (ReceiveCommand c : commands) {
+        if (c.getResult() == Result.NOT_ATTEMPTED) {
+          c.setResult(Result.REJECTED_OTHER_REASON, "internal error");
+        }
+      }
+    } finally {
+      w.sendMessages();
+    }
+  }
+
+  public ReceivePack getReceivePack() {
+    return rp;
+  }
+}
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
new file mode 100644
index 0000000..52b2372
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -0,0 +1,21 @@
+java_library(
+    name = "receive",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/util/cli",
+        "//lib:args4j",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib/auto:auto-value",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ChangeProgressOp.java b/java/com/google/gerrit/server/git/receive/ChangeProgressOp.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ChangeProgressOp.java
rename to java/com/google/gerrit/server/git/receive/ChangeProgressOp.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java b/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
rename to java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/MessageSender.java b/java/com/google/gerrit/server/git/receive/MessageSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/receive/MessageSender.java
rename to java/com/google/gerrit/server/git/receive/MessageSender.java
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
new file mode 100644
index 0000000..949e0a6
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -0,0 +1,3058 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
+import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
+import static com.google.gerrit.server.git.receive.ReceiveConstants.COMMAND_REJECTION_MESSAGE_FOOTER;
+import static com.google.gerrit.server.git.receive.ReceiveConstants.ONLY_OWNER_CAN_MODIFY_WIP;
+import static com.google.gerrit.server.git.receive.ReceiveConstants.PUSH_OPTION_SKIP_VALIDATION;
+import static com.google.gerrit.server.git.receive.ReceiveConstants.SAME_CHANGE_ID_IN_MULTIPLE_CHANGES;
+import static com.google.gerrit.server.git.validators.CommitValidators.NEW_PATCHSET_PATTERN;
+import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.comparingInt;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED;
+import static org.eclipse.jgit.transport.ReceiveCommand.Result.OK;
+import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_MISSING_OBJECT;
+import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_NONFASTFORWARD;
+import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
+
+import com.google.common.base.Function;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Sets;
+import com.google.common.collect.SortedSetMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicMap.Entry;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CreateGroupPermissionSyncer;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.SetHashtagsOp;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.BanCommit;
+import com.google.gerrit.server.git.ChangeReportFormatter;
+import com.google.gerrit.server.git.GroupCollector;
+import com.google.gerrit.server.git.MergeOp;
+import com.google.gerrit.server.git.MergeOpRepoManager;
+import com.google.gerrit.server.git.MergedByPushOp;
+import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.MultiProgressMonitor.Task;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.ReceivePackInitializer;
+import com.google.gerrit.server.git.SubmoduleException;
+import com.google.gerrit.server.git.SubmoduleOp;
+import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.git.validators.RefOperationValidationException;
+import com.google.gerrit.server.git.validators.RefOperationValidators;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.mail.MailUtil.MailRecipients;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.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.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.ssh.SshInfo;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.RepoOnlyOp;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryHelper.ActionType;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gerrit.util.cli.CmdLineParser;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.util.Providers;
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.regex.Matcher;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.revwalk.filter.RevFilter;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.ReceiveCommand.Result;
+import org.eclipse.jgit.transport.ReceivePack;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Receives change upload using the Git receive-pack protocol. */
+class ReceiveCommits {
+  private static final Logger log = LoggerFactory.getLogger(ReceiveCommits.class);
+
+  private enum ReceiveError {
+    CONFIG_UPDATE(
+        "You are not allowed to perform this operation.\n"
+            + "Configuration changes can only be pushed by project owners\n"
+            + "who also have 'Push' rights on "
+            + RefNames.REFS_CONFIG),
+    UPDATE(
+        "You are not allowed to perform this operation.\n"
+            + "To push into this reference you need 'Push' rights."),
+    DELETE(
+        "You need 'Delete Reference' rights or 'Push' rights with the \n"
+            + "'Force Push' flag set to delete references."),
+    DELETE_CHANGES("Cannot delete from '" + REFS_CHANGES + "'"),
+    CODE_REVIEW(
+        "You need 'Push' rights to upload code review requests.\n"
+            + "Verify that you are pushing to the right branch.");
+
+    private final String value;
+
+    ReceiveError(String value) {
+      this.value = value;
+    }
+
+    String get() {
+      return value;
+    }
+  }
+
+  interface Factory {
+    ReceiveCommits create(
+        ProjectState projectState,
+        IdentifiedUser user,
+        ReceivePack receivePack,
+        AllRefsWatcher allRefsWatcher,
+        SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers);
+  }
+
+  private class ReceivePackMessageSender implements MessageSender {
+    @Override
+    public void sendMessage(String what) {
+      rp.sendMessage(what);
+    }
+
+    @Override
+    public void sendError(String what) {
+      rp.sendError(what);
+    }
+
+    @Override
+    public void sendBytes(byte[] what) {
+      sendBytes(what, 0, what.length);
+    }
+
+    @Override
+    public void sendBytes(byte[] what, int off, int len) {
+      try {
+        rp.getMessageOutputStream().write(what, off, len);
+      } catch (IOException e) {
+        // Ignore write failures (matching JGit behavior).
+      }
+    }
+
+    @Override
+    public void flush() {
+      try {
+        rp.getMessageOutputStream().flush();
+      } catch (IOException e) {
+        // Ignore write failures (matching JGit behavior).
+      }
+    }
+  }
+
+  private static final Function<Exception, RestApiException> INSERT_EXCEPTION =
+      new Function<Exception, RestApiException>() {
+        @Override
+        public RestApiException apply(Exception input) {
+          if (input instanceof RestApiException) {
+            return (RestApiException) input;
+          } else if ((input instanceof ExecutionException)
+              && (input.getCause() instanceof RestApiException)) {
+            return (RestApiException) input.getCause();
+          }
+          return new RestApiException("Error inserting change/patchset", input);
+        }
+      };
+
+  // ReceiveCommits has a lot of fields, sorry. Here and in the constructor they are split up
+  // somewhat, and kept sorted lexicographically within sections, except where later assignments
+  // depend on previous ones.
+
+  // Injected fields.
+  private final AccountResolver accountResolver;
+  private final AccountsUpdate.Server accountsUpdate;
+  private final AllProjectsName allProjectsName;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ChangeEditUtil editUtil;
+  private final ChangeIndexer indexer;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final ChangeNotes.Factory notesFactory;
+  private final ChangeReportFormatter changeFormatter;
+  private final CmdLineParser.Factory optionParserFactory;
+  private final CommitValidators.Factory commitValidatorsFactory;
+  private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
+  private final CreateRefControl createRefControl;
+  private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+  private final DynamicSet<ReceivePackInitializer> initializers;
+  private final MergedByPushOp.Factory mergedByPushOpFactory;
+  private final NotesMigration notesMigration;
+  private final PatchSetInfoFactory patchSetInfoFactory;
+  private final PatchSetUtil psUtil;
+  private final PermissionBackend permissionBackend;
+  private final ProjectCache projectCache;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Provider<MergeOp> mergeOpProvider;
+  private final Provider<MergeOpRepoManager> ormProvider;
+  private final ReceiveConfig receiveConfig;
+  private final RefOperationValidators.Factory refValidatorsFactory;
+  private final ReplaceOp.Factory replaceOpFactory;
+  private final RetryHelper retryHelper;
+  private final RequestScopePropagator requestScopePropagator;
+  private final ReviewDb db;
+  private final Sequences seq;
+  private final SetHashtagsOp.Factory hashtagsFactory;
+  private final SshInfo sshInfo;
+  private final SubmoduleOp.Factory subOpFactory;
+  private final TagCache tagCache;
+
+  // Assisted injected fields.
+  private final AllRefsWatcher allRefsWatcher;
+  private final ImmutableSetMultimap<ReviewerStateInternal, Account.Id> extraReviewers;
+  private final ProjectState projectState;
+  private final IdentifiedUser user;
+  private final ReceivePack rp;
+
+  // Immutable fields derived from constructor arguments.
+  private final LabelTypes labelTypes;
+  private final NoteMap rejectCommits;
+  private final PermissionBackend.ForProject permissions;
+  private final Project project;
+  private final Repository repo;
+  private final RequestId receiveId;
+
+  // Collections populated during processing.
+  private final List<UpdateGroupsRequest> updateGroups;
+  private final List<ValidationMessage> messages;
+  private final ListMultimap<ReceiveError, String> errors;
+  private final ListMultimap<String, String> pushOptions;
+  private final Map<Change.Id, ReplaceRequest> replaceByChange;
+  private final Set<ObjectId> validCommits;
+
+  /**
+   * Actual commands to be executed, as opposed to the mix of actual and magic commands that were
+   * provided over the wire.
+   *
+   * <p>Excludes commands executed implicitly as part of other {@link BatchUpdateOp}s, such as
+   * creating patch set refs.
+   */
+  private final List<ReceiveCommand> actualCommands;
+
+  // Collections lazily populated during processing.
+  private List<CreateRequest> newChanges;
+  private ListMultimap<Change.Id, Ref> refsByChange;
+  private ListMultimap<ObjectId, Ref> refsById;
+
+  // Other settings populated during processing.
+  private MagicBranchInput magicBranch;
+  private boolean newChangeForAllNotInTarget;
+  private String setFullNameTo;
+  private boolean setChangeAsPrivate;
+
+  // Handles for outputting back over the wire to the end user.
+  private Task newProgress;
+  private Task replaceProgress;
+  private Task closeProgress;
+  private Task commandProgress;
+  private MessageSender messageSender;
+
+  @Inject
+  ReceiveCommits(
+      AccountResolver accountResolver,
+      AccountsUpdate.Server accountsUpdate,
+      AllProjectsName allProjectsName,
+      BatchUpdate.Factory batchUpdateFactory,
+      ChangeEditUtil editUtil,
+      ChangeIndexer indexer,
+      ChangeInserter.Factory changeInserterFactory,
+      ChangeNotes.Factory notesFactory,
+      DynamicItem<ChangeReportFormatter> changeFormatterProvider,
+      CmdLineParser.Factory optionParserFactory,
+      CommitValidators.Factory commitValidatorsFactory,
+      CreateGroupPermissionSyncer createGroupPermissionSyncer,
+      CreateRefControl createRefControl,
+      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      DynamicSet<ReceivePackInitializer> initializers,
+      MergedByPushOp.Factory mergedByPushOpFactory,
+      NotesMigration notesMigration,
+      PatchSetInfoFactory patchSetInfoFactory,
+      PatchSetUtil psUtil,
+      PermissionBackend permissionBackend,
+      ProjectCache projectCache,
+      Provider<InternalChangeQuery> queryProvider,
+      Provider<MergeOp> mergeOpProvider,
+      Provider<MergeOpRepoManager> ormProvider,
+      ReceiveConfig receiveConfig,
+      RefOperationValidators.Factory refValidatorsFactory,
+      ReplaceOp.Factory replaceOpFactory,
+      RetryHelper retryHelper,
+      RequestScopePropagator requestScopePropagator,
+      ReviewDb db,
+      Sequences seq,
+      SetHashtagsOp.Factory hashtagsFactory,
+      SshInfo sshInfo,
+      SubmoduleOp.Factory subOpFactory,
+      TagCache tagCache,
+      @Assisted ProjectState projectState,
+      @Assisted IdentifiedUser user,
+      @Assisted ReceivePack rp,
+      @Assisted AllRefsWatcher allRefsWatcher,
+      @Assisted SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers)
+      throws IOException {
+    // Injected fields.
+    this.accountResolver = accountResolver;
+    this.accountsUpdate = accountsUpdate;
+    this.allProjectsName = allProjectsName;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.changeFormatter = changeFormatterProvider.get();
+    this.changeInserterFactory = changeInserterFactory;
+    this.commitValidatorsFactory = commitValidatorsFactory;
+    this.createRefControl = createRefControl;
+    this.createGroupPermissionSyncer = createGroupPermissionSyncer;
+    this.db = db;
+    this.editUtil = editUtil;
+    this.hashtagsFactory = hashtagsFactory;
+    this.indexer = indexer;
+    this.initializers = initializers;
+    this.mergeOpProvider = mergeOpProvider;
+    this.mergedByPushOpFactory = mergedByPushOpFactory;
+    this.notesFactory = notesFactory;
+    this.notesMigration = notesMigration;
+    this.optionParserFactory = optionParserFactory;
+    this.ormProvider = ormProvider;
+    this.patchSetInfoFactory = patchSetInfoFactory;
+    this.permissionBackend = permissionBackend;
+    this.pluginConfigEntries = pluginConfigEntries;
+    this.projectCache = projectCache;
+    this.psUtil = psUtil;
+    this.queryProvider = queryProvider;
+    this.receiveConfig = receiveConfig;
+    this.refValidatorsFactory = refValidatorsFactory;
+    this.replaceOpFactory = replaceOpFactory;
+    this.retryHelper = retryHelper;
+    this.requestScopePropagator = requestScopePropagator;
+    this.seq = seq;
+    this.sshInfo = sshInfo;
+    this.subOpFactory = subOpFactory;
+    this.tagCache = tagCache;
+
+    // Assisted injected fields.
+    this.allRefsWatcher = allRefsWatcher;
+    this.extraReviewers = ImmutableSetMultimap.copyOf(extraReviewers);
+    this.projectState = projectState;
+    this.user = user;
+    this.rp = rp;
+
+    // Immutable fields derived from constructor arguments.
+    repo = rp.getRepository();
+    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());
+
+    // Collections populated during processing.
+    actualCommands = new ArrayList<>();
+    errors = LinkedListMultimap.create();
+    messages = new ArrayList<>();
+    pushOptions = LinkedListMultimap.create();
+    replaceByChange = new LinkedHashMap<>();
+    updateGroups = new ArrayList<>();
+    validCommits = new HashSet<>();
+
+    // Collections lazily populated during processing.
+    newChanges = Collections.emptyList();
+
+    // Other settings populated during processing.
+    newChangeForAllNotInTarget =
+        projectState.is(BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET);
+
+    // Handles for outputting back over the wire to the end user.
+    messageSender = new ReceivePackMessageSender();
+  }
+
+  void init() {
+    for (ReceivePackInitializer i : initializers) {
+      i.init(projectState.getNameKey(), rp);
+    }
+  }
+
+  /** Set a message sender for this operation. */
+  void setMessageSender(MessageSender ms) {
+    messageSender = ms != null ? ms : new ReceivePackMessageSender();
+  }
+
+  MessageSender getMessageSender() {
+    if (messageSender == null) {
+      setMessageSender(null);
+    }
+    return messageSender;
+  }
+
+  Project getProject() {
+    return project;
+  }
+
+  private void addMessage(String message) {
+    messages.add(new CommitValidationMessage(message, false));
+  }
+
+  void addError(String error) {
+    messages.add(new CommitValidationMessage(error, true));
+  }
+
+  void sendMessages() {
+    for (ValidationMessage m : messages) {
+      if (m.isError()) {
+        messageSender.sendError(m.getMessage());
+      } else {
+        messageSender.sendMessage(m.getMessage());
+      }
+    }
+  }
+
+  void processCommands(Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
+    newProgress = progress.beginSubTask("new", UNKNOWN);
+    replaceProgress = progress.beginSubTask("updated", UNKNOWN);
+    closeProgress = progress.beginSubTask("closed", UNKNOWN);
+    commandProgress = progress.beginSubTask("refs", UNKNOWN);
+
+    try {
+      parseCommands(commands);
+    } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
+      for (ReceiveCommand cmd : actualCommands) {
+        if (cmd.getResult() == NOT_ATTEMPTED) {
+          cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
+        }
+      }
+      logError(String.format("Failed to process refs in %s", project.getName()), err);
+    }
+    if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
+      selectNewAndReplacedChangesFromMagicBranch();
+    }
+    preparePatchSetsForReplace();
+    insertChangesAndPatchSets();
+    newProgress.end();
+    replaceProgress.end();
+
+    if (!errors.isEmpty()) {
+      logDebug("Handling error conditions: {}", errors.keySet());
+      for (ReceiveError error : errors.keySet()) {
+        rp.sendMessage(buildError(error, errors.get(error)));
+      }
+      rp.sendMessage(String.format("User: %s", displayName(user)));
+      rp.sendMessage(COMMAND_REJECTION_MESSAGE_FOOTER);
+    }
+
+    Set<Branch.NameKey> branches = new HashSet<>();
+    for (ReceiveCommand c : actualCommands) {
+      // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps that
+      // should happen in this loop are things that can't happen within one BatchUpdate because they
+      // involve kicking off an additional BatchUpdate.
+      if (c.getResult() != OK) {
+        continue;
+      }
+      if (isHead(c) || isConfig(c)) {
+        switch (c.getType()) {
+          case CREATE:
+          case UPDATE:
+          case UPDATE_NONFASTFORWARD:
+            autoCloseChanges(c);
+            branches.add(new Branch.NameKey(project.getNameKey(), c.getRefName()));
+            break;
+
+          case DELETE:
+            break;
+        }
+      }
+    }
+
+    // Update superproject gitlinks if required.
+    if (!branches.isEmpty()) {
+      try (MergeOpRepoManager orm = ormProvider.get()) {
+        orm.setContext(db, TimeUtil.nowTs(), user, receiveId);
+        SubmoduleOp op = subOpFactory.create(branches, orm);
+        op.updateSuperProjects();
+      } catch (SubmoduleException e) {
+        logError("Can't update the superprojects", e);
+      }
+    }
+
+    // Update account info with details discovered during commit walking.
+    updateAccountInfo();
+
+    closeProgress.end();
+    commandProgress.end();
+    progress.end();
+    reportMessages();
+  }
+
+  private void reportMessages() {
+    List<CreateRequest> created =
+        newChanges.stream().filter(r -> r.change != null).collect(toList());
+    if (!created.isEmpty()) {
+      addMessage("");
+      addMessage("New Changes:");
+      for (CreateRequest c : created) {
+        addMessage(
+            changeFormatter.newChange(
+                ChangeReportFormatter.Input.builder().setChange(c.change).build()));
+      }
+      addMessage("");
+    }
+
+    List<ReplaceRequest> updated =
+        replaceByChange
+            .values()
+            .stream()
+            .filter(r -> !r.skip && r.inputCommand.getResult() == OK)
+            .sorted(comparingInt(r -> r.notes.getChangeId().get()))
+            .collect(toList());
+    if (!updated.isEmpty()) {
+      addMessage("");
+      addMessage("Updated Changes:");
+      boolean edit = magicBranch != null && (magicBranch.edit || magicBranch.draft);
+      Boolean isPrivate = null;
+      Boolean wip = null;
+      if (magicBranch != null) {
+        if (magicBranch.isPrivate) {
+          isPrivate = true;
+        } else if (magicBranch.removePrivate) {
+          isPrivate = false;
+        }
+        if (magicBranch.workInProgress) {
+          wip = true;
+        } else if (magicBranch.ready) {
+          wip = false;
+        }
+      }
+      for (ReplaceRequest u : updated) {
+        String subject;
+        if (edit) {
+          try {
+            subject = rp.getRevWalk().parseCommit(u.newCommitId).getShortMessage();
+          } catch (IOException e) {
+            // Log and fall back to original change subject
+            logWarn("failed to get subject for edit patch set", e);
+            subject = u.notes.getChange().getSubject();
+          }
+        } else {
+          subject = u.info.getSubject();
+        }
+
+        if (isPrivate == null) {
+          isPrivate = u.notes.getChange().isPrivate();
+        }
+        if (wip == null) {
+          wip = u.notes.getChange().isWorkInProgress();
+        }
+
+        ChangeReportFormatter.Input input =
+            ChangeReportFormatter.Input.builder()
+                .setChange(u.notes.getChange())
+                .setSubject(subject)
+                .setIsEdit(edit)
+                .setIsPrivate(isPrivate)
+                .setIsWorkInProgress(wip)
+                .build();
+        addMessage(changeFormatter.changeUpdated(input));
+      }
+      addMessage("");
+    }
+
+    // TODO(xchangcheng): remove after migrating tools which are using this magic branch.
+    if (magicBranch != null && magicBranch.publish) {
+      addMessage("Pushing to refs/publish/* is deprecated, use refs/for/* instead.");
+    }
+  }
+
+  private void insertChangesAndPatchSets() {
+    ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
+    if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
+      logWarn(
+          String.format(
+              "Skipping change updates on %s because ref update failed: %s %s",
+              project.getName(),
+              magicBranchCmd.getResult(),
+              Strings.nullToEmpty(magicBranchCmd.getMessage())));
+      return;
+    }
+
+    try (BatchUpdate bu =
+            batchUpdateFactory.create(
+                db, project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo, rw, ins).updateChangesInParallel();
+      bu.setRequestId(receiveId);
+      bu.setRefLogMessage("push");
+
+      logDebug("Adding {} replace requests", newChanges.size());
+      for (ReplaceRequest replace : replaceByChange.values()) {
+        replace.addOps(bu, replaceProgress);
+      }
+
+      logDebug("Adding {} create requests", newChanges.size());
+      for (CreateRequest create : newChanges) {
+        create.addOps(bu);
+      }
+
+      logDebug("Adding {} group update requests", newChanges.size());
+      updateGroups.forEach(r -> r.addOps(bu));
+
+      logDebug("Adding {} additional ref updates", actualCommands.size());
+      actualCommands.forEach(c -> bu.addRepoOnlyOp(new UpdateOneRefOp(c)));
+
+      logDebug("Executing batch");
+      try {
+        bu.execute();
+      } catch (UpdateException e) {
+        throw INSERT_EXCEPTION.apply(e);
+      }
+      if (magicBranchCmd != null) {
+        magicBranchCmd.setResult(OK);
+      }
+      for (ReplaceRequest replace : replaceByChange.values()) {
+        String rejectMessage = replace.getRejectMessage();
+        if (rejectMessage == null) {
+          if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
+            // Not necessarily the magic branch, so need to set OK on the original value.
+            replace.inputCommand.setResult(OK);
+          }
+        } else {
+          logDebug("Rejecting due to message from ReplaceOp");
+          reject(replace.inputCommand, rejectMessage);
+        }
+      }
+
+    } catch (ResourceConflictException e) {
+      addMessage(e.getMessage());
+      reject(magicBranchCmd, "conflict");
+    } catch (RestApiException | IOException err) {
+      logError("Can't insert change/patch set for " + project.getName(), err);
+      reject(magicBranchCmd, "internal server error: " + err.getMessage());
+    }
+
+    if (magicBranch != null && magicBranch.submit) {
+      try {
+        submit(newChanges, replaceByChange.values());
+      } catch (ResourceConflictException e) {
+        addMessage(e.getMessage());
+        reject(magicBranchCmd, "conflict");
+      } catch (RestApiException
+          | OrmException
+          | UpdateException
+          | IOException
+          | ConfigInvalidException
+          | PermissionBackendException e) {
+        logError("Error submitting changes to " + project.getName(), e);
+        reject(magicBranchCmd, "error during submit");
+      }
+    }
+  }
+
+  private String buildError(ReceiveError error, List<String> branches) {
+    StringBuilder sb = new StringBuilder();
+    if (branches.size() == 1) {
+      sb.append("Branch ").append(branches.get(0)).append(":\n");
+      sb.append(error.get());
+      return sb.toString();
+    }
+    sb.append("Branches");
+    String delim = " ";
+    for (String branch : branches) {
+      sb.append(delim).append(branch);
+      delim = ", ";
+    }
+    return sb.append(":\n").append(error.get()).toString();
+  }
+
+  private static String displayName(IdentifiedUser user) {
+    String displayName = user.getUserName();
+    if (displayName == null) {
+      displayName = user.getAccount().getPreferredEmail();
+    }
+    return displayName;
+  }
+
+  private void parseCommands(Collection<ReceiveCommand> commands)
+      throws PermissionBackendException, NoSuchProjectException, IOException {
+    List<String> optionList = rp.getPushOptions();
+    if (optionList != null) {
+      for (String option : optionList) {
+        int e = option.indexOf('=');
+        if (e > 0) {
+          pushOptions.put(option.substring(0, e), option.substring(e + 1));
+        } else {
+          pushOptions.put(option, "");
+        }
+      }
+    }
+
+    logDebug("Parsing {} commands", commands.size());
+    for (ReceiveCommand cmd : commands) {
+      if (cmd.getResult() != NOT_ATTEMPTED) {
+        // Already rejected by the core receive process.
+        logDebug("Already processed by core: {} {}", cmd.getResult(), cmd);
+        continue;
+      }
+
+      if (!Repository.isValidRefName(cmd.getRefName()) || cmd.getRefName().contains("//")) {
+        reject(cmd, "not valid ref");
+        continue;
+      }
+
+      if (MagicBranch.isMagicBranch(cmd.getRefName())) {
+        parseMagicBranch(cmd);
+        continue;
+      }
+
+      if (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;
+        cmd =
+            new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), newName, cmd.getType()) {
+              @Override
+              public void setResult(Result s, String m) {
+                super.setResult(s, m);
+                orgCmd.setResult(s, m);
+              }
+            };
+      }
+
+      Matcher m = NEW_PATCHSET_PATTERN.matcher(cmd.getRefName());
+      if (m.matches()) {
+        // The referenced change must exist and must still be open.
+        //
+        Change.Id changeId = Change.Id.parse(m.group(1));
+        parseReplaceCommand(cmd, changeId);
+        continue;
+      }
+
+      switch (cmd.getType()) {
+        case CREATE:
+          parseCreate(cmd);
+          break;
+
+        case UPDATE:
+          parseUpdate(cmd);
+          break;
+
+        case DELETE:
+          parseDelete(cmd);
+          break;
+
+        case UPDATE_NONFASTFORWARD:
+          parseRewind(cmd);
+          break;
+
+        default:
+          reject(cmd, "prohibited by Gerrit: unknown command type " + cmd.getType());
+          continue;
+      }
+
+      if (cmd.getResult() != NOT_ATTEMPTED) {
+        continue;
+      }
+
+      if (isConfig(cmd)) {
+        logDebug("Processing {} command", cmd.getRefName());
+        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;
+        }
+
+        switch (cmd.getType()) {
+          case CREATE:
+          case UPDATE:
+          case UPDATE_NONFASTFORWARD:
+            try {
+              ProjectConfig cfg = new ProjectConfig(project.getNameKey());
+              cfg.load(rp.getRevWalk(), cmd.getNewId());
+              if (!cfg.getValidationErrors().isEmpty()) {
+                addError("Invalid project configuration:");
+                for (ValidationError err : cfg.getValidationErrors()) {
+                  addError("  " + err.getMessage());
+                }
+                reject(cmd, "invalid project configuration");
+                logError(
+                    "User "
+                        + user.getUserName()
+                        + " tried to push invalid project configuration "
+                        + cmd.getNewId().name()
+                        + " for "
+                        + project.getName());
+                continue;
+              }
+              Project.NameKey newParent = cfg.getProject().getParent(allProjectsName);
+              Project.NameKey oldParent = project.getParent(allProjectsName);
+              if (oldParent == null) {
+                // update of the 'All-Projects' project
+                if (newParent != null) {
+                  reject(cmd, "invalid project configuration: root project cannot have parent");
+                  continue;
+                }
+              } else {
+                if (!oldParent.equals(newParent)) {
+                  try {
+                    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+                  } catch (AuthException e) {
+                    reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
+                    continue;
+                  }
+                }
+
+                if (projectCache.get(newParent) == null) {
+                  reject(cmd, "invalid project configuration: parent does not exist");
+                  continue;
+                }
+              }
+
+              for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
+                PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName());
+                ProjectConfigEntry configEntry = e.getProvider().get();
+                String value = pluginCfg.getString(e.getExportName());
+                String oldValue =
+                    projectState
+                        .getConfig()
+                        .getPluginConfig(e.getPluginName())
+                        .getString(e.getExportName());
+                if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
+                  oldValue =
+                      Arrays.stream(
+                              projectState
+                                  .getConfig()
+                                  .getPluginConfig(e.getPluginName())
+                                  .getStringList(e.getExportName()))
+                          .collect(joining("\n"));
+                }
+
+                if ((value == null ? oldValue != null : !value.equals(oldValue))
+                    && !configEntry.isEditable(projectState)) {
+                  reject(
+                      cmd,
+                      String.format(
+                          "invalid project configuration: Not allowed to set parameter"
+                              + " '%s' of plugin '%s' on project '%s'.",
+                          e.getExportName(), e.getPluginName(), project.getName()));
+                  continue;
+                }
+
+                if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
+                    && value != null
+                    && !configEntry.getPermittedValues().contains(value)) {
+                  reject(
+                      cmd,
+                      String.format(
+                          "invalid project configuration: The value '%s' is "
+                              + "not permitted for parameter '%s' of plugin '%s'.",
+                          value, e.getExportName(), e.getPluginName()));
+                }
+              }
+            } catch (Exception e) {
+              reject(cmd, "invalid project configuration");
+              logError(
+                  "User "
+                      + user.getUserName()
+                      + " tried to push invalid project configuration "
+                      + cmd.getNewId().name()
+                      + " for "
+                      + project.getName(),
+                  e);
+              continue;
+            }
+            break;
+
+          case DELETE:
+            break;
+
+          default:
+            reject(
+                cmd,
+                "prohibited by Gerrit: don't know how to handle config update of type "
+                    + cmd.getType());
+            continue;
+        }
+      }
+    }
+  }
+
+  private void parseCreate(ReceiveCommand cmd)
+      throws PermissionBackendException, NoSuchProjectException, IOException {
+    RevObject obj;
+    try {
+      obj = rp.getRevWalk().parseAny(cmd.getNewId());
+    } catch (IOException err) {
+      logError(
+          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " creation",
+          err);
+      reject(cmd, "invalid object");
+      return;
+    }
+    logDebug("Creating {}", cmd);
+
+    if (isHead(cmd) && !isCommit(cmd)) {
+      return;
+    }
+
+    Branch.NameKey branch = new Branch.NameKey(project.getName(), cmd.getRefName());
+    try {
+      // Must pass explicit user instead of injecting a provider into CreateRefControl, since
+      // Provider<CurrentUser> within ReceiveCommits will always return anonymous.
+      createRefControl.checkCreateRef(Providers.of(user), rp.getRepository(), branch, obj);
+    } catch (AuthException | ResourceConflictException denied) {
+      reject(cmd, "prohibited by Gerrit: " + denied.getMessage());
+      return;
+    }
+
+    if (!validRefOperation(cmd)) {
+      // validRefOperation sets messages, so no need to provide more feedback.
+      return;
+    }
+
+    validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
+    actualCommands.add(cmd);
+  }
+
+  private void parseUpdate(ReceiveCommand cmd) throws PermissionBackendException {
+    logDebug("Updating {}", cmd);
+    boolean ok;
+    try {
+      permissions.ref(cmd.getRefName()).check(RefPermission.UPDATE);
+      ok = true;
+    } catch (AuthException err) {
+      ok = false;
+    }
+    if (ok) {
+      if (isHead(cmd) && !isCommit(cmd)) {
+        return;
+      }
+      if (!validRefOperation(cmd)) {
+        return;
+      }
+      validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
+      actualCommands.add(cmd);
+    } else {
+      if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
+        errors.put(ReceiveError.CONFIG_UPDATE, RefNames.REFS_CONFIG);
+      } else {
+        errors.put(ReceiveError.UPDATE, cmd.getRefName());
+      }
+      reject(cmd, "prohibited by Gerrit: ref update access denied");
+    }
+  }
+
+  private boolean isCommit(ReceiveCommand cmd) {
+    RevObject obj;
+    try {
+      obj = rp.getRevWalk().parseAny(cmd.getNewId());
+    } catch (IOException err) {
+      logError("Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName(), err);
+      reject(cmd, "invalid object");
+      return false;
+    }
+
+    if (obj instanceof RevCommit) {
+      return true;
+    }
+    reject(cmd, "not a commit");
+    return false;
+  }
+
+  private void parseDelete(ReceiveCommand cmd) throws PermissionBackendException {
+    logDebug("Deleting {}", cmd);
+    if (cmd.getRefName().startsWith(REFS_CHANGES)) {
+      errors.put(ReceiveError.DELETE_CHANGES, cmd.getRefName());
+      reject(cmd, "cannot delete changes");
+    } else if (canDelete(cmd)) {
+      if (!validRefOperation(cmd)) {
+        return;
+      }
+      actualCommands.add(cmd);
+    } else if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
+      reject(cmd, "cannot delete project configuration");
+    } else {
+      errors.put(ReceiveError.DELETE, cmd.getRefName());
+      reject(cmd, "cannot delete references");
+    }
+  }
+
+  private boolean canDelete(ReceiveCommand cmd) throws PermissionBackendException {
+    try {
+      permissions.ref(cmd.getRefName()).check(RefPermission.DELETE);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
+  }
+
+  private void parseRewind(ReceiveCommand cmd) throws PermissionBackendException {
+    RevCommit newObject;
+    try {
+      newObject = rp.getRevWalk().parseCommit(cmd.getNewId());
+    } catch (IncorrectObjectTypeException notCommit) {
+      newObject = null;
+    } catch (IOException err) {
+      logError(
+          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " forced update",
+          err);
+      reject(cmd, "invalid object");
+      return;
+    }
+    logDebug("Rewinding {}", cmd);
+
+    if (newObject != null) {
+      validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
+      if (cmd.getResult() != NOT_ATTEMPTED) {
+        return;
+      }
+    }
+
+    boolean ok;
+    try {
+      permissions.ref(cmd.getRefName()).check(RefPermission.FORCE_UPDATE);
+      ok = true;
+    } catch (AuthException err) {
+      ok = false;
+    }
+    if (ok) {
+      if (!validRefOperation(cmd)) {
+        return;
+      }
+      actualCommands.add(cmd);
+    } else {
+      cmd.setResult(
+          REJECTED_NONFASTFORWARD, " need '" + PermissionRule.FORCE_PUSH + "' privilege.");
+    }
+  }
+
+  static class MagicBranchInput {
+    private static final Splitter COMMAS = Splitter.on(',').omitEmptyStrings();
+
+    final ReceiveCommand cmd;
+    final LabelTypes labelTypes;
+    final NotesMigration notesMigration;
+    private final boolean defaultPublishComments;
+    Branch.NameKey dest;
+    PermissionBackend.ForRef perm;
+    Set<Account.Id> reviewer = Sets.newLinkedHashSet();
+    Set<Account.Id> cc = Sets.newLinkedHashSet();
+    Map<String, Short> labels = new HashMap<>();
+    String message;
+    List<RevCommit> baseCommit;
+    CmdLineParser clp;
+    Set<String> hashtags = new HashSet<>();
+
+    @Option(name = "--base", metaVar = "BASE", usage = "merge base of changes")
+    List<ObjectId> base;
+
+    @Option(name = "--topic", metaVar = "NAME", usage = "attach topic to changes")
+    String topic;
+
+    @Option(
+      name = "--draft",
+      usage =
+          "Will be removed. Before that, this option will be mapped to '--private'"
+              + "for new changes and '--edit' for existing changes"
+    )
+    boolean draft;
+
+    boolean publish;
+
+    @Option(name = "--private", usage = "mark new/updated change as private")
+    boolean isPrivate;
+
+    @Option(name = "--remove-private", usage = "remove privacy flag from updated change")
+    boolean removePrivate;
+
+    @Option(
+      name = "--wip",
+      aliases = {"-work-in-progress"},
+      usage = "mark change as work in progress"
+    )
+    boolean workInProgress;
+
+    @Option(name = "--ready", usage = "mark change as ready")
+    boolean ready;
+
+    @Option(
+      name = "--edit",
+      aliases = {"-e"},
+      usage = "upload as change edit"
+    )
+    boolean edit;
+
+    @Option(name = "--submit", usage = "immediately submit the change")
+    boolean submit;
+
+    @Option(name = "--merged", usage = "create single change for a merged commit")
+    boolean merged;
+
+    @Option(name = "--publish-comments", usage = "publish all draft comments on updated changes")
+    private boolean publishComments;
+
+    @Option(
+      name = "--no-publish-comments",
+      aliases = {"--np"},
+      usage = "do not publish draft comments"
+    )
+    private boolean noPublishComments;
+
+    @Option(
+      name = "--notify",
+      usage =
+          "Notify handling that defines to whom email notifications "
+              + "should be sent. Allowed values are NONE, OWNER, "
+              + "OWNER_REVIEWERS, ALL. If not set, the default is ALL."
+    )
+    private NotifyHandling notify;
+
+    @Option(name = "--notify-to", metaVar = "USER", usage = "user that should be notified")
+    List<Account.Id> tos = new ArrayList<>();
+
+    @Option(name = "--notify-cc", metaVar = "USER", usage = "user that should be CC'd")
+    List<Account.Id> ccs = new ArrayList<>();
+
+    @Option(name = "--notify-bcc", metaVar = "USER", usage = "user that should be BCC'd")
+    List<Account.Id> bccs = new ArrayList<>();
+
+    @Option(
+      name = "--reviewer",
+      aliases = {"-r"},
+      metaVar = "EMAIL",
+      usage = "add reviewer to changes"
+    )
+    void reviewer(Account.Id id) {
+      reviewer.add(id);
+    }
+
+    @Option(name = "--cc", metaVar = "EMAIL", usage = "notify user by CC")
+    void cc(Account.Id id) {
+      cc.add(id);
+    }
+
+    @Option(
+      name = "--label",
+      aliases = {"-l"},
+      metaVar = "LABEL+VALUE",
+      usage = "label(s) to assign (defaults to +1 if no value provided"
+    )
+    void addLabel(String token) throws CmdLineException {
+      LabelVote v = LabelVote.parse(token);
+      try {
+        LabelType.checkName(v.label());
+        ApprovalsUtil.checkLabel(labelTypes, v.label(), v.value());
+      } catch (BadRequestException e) {
+        throw clp.reject(e.getMessage());
+      }
+      labels.put(v.label(), v.value());
+    }
+
+    @Option(
+      name = "--message",
+      aliases = {"-m"},
+      metaVar = "MESSAGE",
+      usage = "Comment message to apply to the review"
+    )
+    void addMessage(String token) {
+      // Many characters have special meaning in the context of a git ref.
+      //
+      // Clients can use underscores to represent spaces.
+      message = token.replace("_", " ");
+      try {
+        // Other characters can be represented using percent-encoding.
+        message = URLDecoder.decode(message, UTF_8.name());
+      } catch (IllegalArgumentException e) {
+        // Ignore decoding errors; leave message as percent-encoded.
+      } catch (UnsupportedEncodingException e) {
+        // This shouldn't happen; surely URLDecoder recognizes UTF-8.
+        throw new IllegalStateException(e);
+      }
+    }
+
+    @Option(
+      name = "--hashtag",
+      aliases = {"-t"},
+      metaVar = "HASHTAG",
+      usage = "add hashtag to changes"
+    )
+    void addHashtag(String token) throws CmdLineException {
+      if (!notesMigration.readChanges()) {
+        throw clp.reject("cannot add hashtags; noteDb is disabled");
+      }
+      String hashtag = cleanupHashtag(token);
+      if (!hashtag.isEmpty()) {
+        hashtags.add(hashtag);
+      }
+      // TODO(dpursehouse): validate hashtags
+    }
+
+    MagicBranchInput(
+        IdentifiedUser user,
+        ReceiveCommand cmd,
+        LabelTypes labelTypes,
+        NotesMigration notesMigration) {
+      this.cmd = cmd;
+      this.draft = cmd.getRefName().startsWith(MagicBranch.NEW_DRAFT_CHANGE);
+      this.publish = cmd.getRefName().startsWith(MagicBranch.NEW_PUBLISH_CHANGE);
+      this.labelTypes = labelTypes;
+      this.notesMigration = notesMigration;
+      GeneralPreferencesInfo prefs = user.state().getGeneralPreferences();
+      this.defaultPublishComments =
+          prefs != null
+              ? firstNonNull(user.state().getGeneralPreferences().publishCommentsOnPush, false)
+              : false;
+    }
+
+    MailRecipients getMailRecipients() {
+      return new MailRecipients(reviewer, cc);
+    }
+
+    ListMultimap<RecipientType, Account.Id> getAccountsToNotify() {
+      ListMultimap<RecipientType, Account.Id> accountsToNotify =
+          MultimapBuilder.hashKeys().arrayListValues().build();
+      accountsToNotify.putAll(RecipientType.TO, tos);
+      accountsToNotify.putAll(RecipientType.CC, ccs);
+      accountsToNotify.putAll(RecipientType.BCC, bccs);
+      return accountsToNotify;
+    }
+
+    boolean shouldPublishComments() {
+      if (publishComments) {
+        return true;
+      } else if (noPublishComments) {
+        return false;
+      }
+      return defaultPublishComments;
+    }
+
+    String parse(
+        CmdLineParser clp,
+        Repository repo,
+        Set<String> refs,
+        ListMultimap<String, String> pushOptions)
+        throws CmdLineException {
+      String ref = RefNames.fullName(MagicBranch.getDestBranchName(cmd.getRefName()));
+
+      ListMultimap<String, String> options = LinkedListMultimap.create(pushOptions);
+      int optionStart = ref.indexOf('%');
+      if (0 < optionStart) {
+        for (String s : COMMAS.split(ref.substring(optionStart + 1))) {
+          int e = s.indexOf('=');
+          if (0 < e) {
+            options.put(s.substring(0, e), s.substring(e + 1));
+          } else {
+            options.put(s, "");
+          }
+        }
+        ref = ref.substring(0, optionStart);
+      }
+
+      if (!options.isEmpty()) {
+        clp.parseOptionMap(options);
+      }
+
+      // Split the destination branch by branch and topic. The topic
+      // suffix is entirely optional, so it might not even exist.
+      String head = readHEAD(repo);
+      int split = ref.length();
+      for (; ; ) {
+        String name = ref.substring(0, split);
+        if (refs.contains(name) || name.equals(head)) {
+          break;
+        }
+
+        split = name.lastIndexOf('/', split - 1);
+        if (split <= Constants.R_REFS.length()) {
+          return ref;
+        }
+      }
+      if (split < ref.length()) {
+        topic = Strings.emptyToNull(ref.substring(split + 1));
+      }
+      return ref.substring(0, split);
+    }
+
+    NotifyHandling getNotify() {
+      if (notify != null) {
+        return notify;
+      }
+      if (workInProgress) {
+        return NotifyHandling.OWNER;
+      }
+      return NotifyHandling.ALL;
+    }
+
+    NotifyHandling getNotify(ChangeNotes notes) {
+      if (notify != null) {
+        return notify;
+      }
+      if (workInProgress || (!ready && notes.getChange().isWorkInProgress())) {
+        return NotifyHandling.OWNER;
+      }
+      return NotifyHandling.ALL;
+    }
+  }
+
+  /**
+   * Gets an unmodifiable view of the pushOptions.
+   *
+   * <p>The collection is empty if the client does not support push options, or if the client did
+   * not send any options.
+   *
+   * @return an unmodifiable view of pushOptions.
+   */
+  @Nullable
+  ListMultimap<String, String> getPushOptions() {
+    return ImmutableListMultimap.copyOf(pushOptions);
+  }
+
+  private void parseMagicBranch(ReceiveCommand cmd) throws PermissionBackendException {
+    // Permit exactly one new change request per push.
+    if (magicBranch != null) {
+      reject(cmd, "duplicate request");
+      return;
+    }
+
+    logDebug("Found magic branch {}", cmd.getRefName());
+    magicBranch = new MagicBranchInput(user, cmd, labelTypes, notesMigration);
+    magicBranch.reviewer.addAll(extraReviewers.get(ReviewerStateInternal.REVIEWER));
+    magicBranch.cc.addAll(extraReviewers.get(ReviewerStateInternal.CC));
+
+    String ref;
+    CmdLineParser clp = optionParserFactory.create(magicBranch);
+    magicBranch.clp = clp;
+
+    try {
+      ref = magicBranch.parse(clp, repo, rp.getAdvertisedRefs().keySet(), pushOptions);
+    } catch (CmdLineException e) {
+      if (!clp.wasHelpRequestedByOption()) {
+        logDebug("Invalid branch syntax");
+        reject(cmd, e.getMessage());
+        return;
+      }
+      ref = null; // never happen
+    }
+
+    if (magicBranch.topic != null && magicBranch.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
+      reject(
+          cmd, String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
+    }
+
+    if (clp.wasHelpRequestedByOption()) {
+      StringWriter w = new StringWriter();
+      w.write("\nHelp for refs/for/branch:\n\n");
+      clp.printUsage(w, null);
+      addMessage(w.toString());
+      reject(cmd, "see help");
+      return;
+    }
+    if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
+      logDebug("Handling {}", RefNames.REFS_USERS_SELF);
+      ref = RefNames.refsUsers(user.getAccountId());
+    }
+    if (!rp.getAdvertisedRefs().containsKey(ref)
+        && !ref.equals(readHEAD(repo))
+        && !ref.equals(RefNames.REFS_CONFIG)) {
+      logDebug("Ref {} not found", ref);
+      if (ref.startsWith(Constants.R_HEADS)) {
+        String n = ref.substring(Constants.R_HEADS.length());
+        reject(cmd, "branch " + n + " not found");
+      } else {
+        reject(cmd, ref + " not found");
+      }
+      return;
+    }
+
+    magicBranch.dest = new Branch.NameKey(project.getNameKey(), ref);
+    magicBranch.perm = permissions.ref(ref);
+    if (!projectState.getProject().getState().permitsWrite()) {
+      reject(cmd, "project state does not permit write");
+      return;
+    }
+
+    try {
+      magicBranch.perm.check(RefPermission.CREATE_CHANGE);
+    } catch (AuthException denied) {
+      errors.put(ReceiveError.CODE_REVIEW, ref);
+      reject(cmd, denied.getMessage());
+      return;
+    }
+
+    if (magicBranch.isPrivate && magicBranch.removePrivate) {
+      reject(cmd, "the options 'private' and 'remove-private' are mutually exclusive");
+      return;
+    }
+
+    boolean privateByDefault =
+        projectCache.get(project.getNameKey()).is(BooleanProjectConfig.PRIVATE_BY_DEFAULT);
+    setChangeAsPrivate =
+        magicBranch.draft
+            || magicBranch.isPrivate
+            || (privateByDefault && !magicBranch.removePrivate);
+
+    if (receiveConfig.disablePrivateChanges && setChangeAsPrivate) {
+      reject(cmd, "private changes are disabled");
+      return;
+    }
+
+    if (magicBranch.workInProgress && magicBranch.ready) {
+      reject(cmd, "the options 'wip' and 'ready' are mutually exclusive");
+      return;
+    }
+    if (magicBranch.publishComments && magicBranch.noPublishComments) {
+      reject(
+          cmd, "the options 'publish-comments' and 'no-publish-comments' are mutually exclusive");
+      return;
+    }
+
+    if (magicBranch.submit) {
+      try {
+        permissions.ref(ref).check(RefPermission.UPDATE_BY_SUBMIT);
+      } catch (AuthException e) {
+        reject(cmd, e.getMessage());
+        return;
+      }
+    }
+
+    RevWalk walk = rp.getRevWalk();
+    RevCommit tip;
+    try {
+      tip = walk.parseCommit(magicBranch.cmd.getNewId());
+      logDebug("Tip of push: {}", tip.name());
+    } catch (IOException ex) {
+      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
+      logError("Invalid pack upload; one or more objects weren't sent", ex);
+      return;
+    }
+
+    String destBranch = magicBranch.dest.get();
+    try {
+      if (magicBranch.merged) {
+        if (magicBranch.base != null) {
+          reject(cmd, "cannot use merged with base");
+          return;
+        }
+        RevCommit branchTip = readBranchTip(cmd, magicBranch.dest);
+        if (branchTip == null) {
+          return; // readBranchTip already rejected cmd.
+        }
+        if (!walk.isMergedInto(tip, branchTip)) {
+          reject(cmd, "not merged into branch");
+          return;
+        }
+      }
+
+      // If tip is a merge commit, or the root commit or
+      // if %base or %merged was specified, ignore newChangeForAllNotInTarget.
+      if (tip.getParentCount() > 1
+          || magicBranch.base != null
+          || magicBranch.merged
+          || tip.getParentCount() == 0) {
+        logDebug("Forcing newChangeForAllNotInTarget = false");
+        newChangeForAllNotInTarget = false;
+      }
+
+      if (magicBranch.base != null) {
+        logDebug("Handling %base: {}", magicBranch.base);
+        magicBranch.baseCommit = Lists.newArrayListWithCapacity(magicBranch.base.size());
+        for (ObjectId id : magicBranch.base) {
+          try {
+            magicBranch.baseCommit.add(walk.parseCommit(id));
+          } catch (IncorrectObjectTypeException notCommit) {
+            reject(cmd, "base must be a commit");
+            return;
+          } catch (MissingObjectException e) {
+            reject(cmd, "base not found");
+            return;
+          } catch (IOException e) {
+            logWarn(String.format("Project %s cannot read %s", project.getName(), id.name()), e);
+            reject(cmd, "internal server error");
+            return;
+          }
+        }
+      } else if (newChangeForAllNotInTarget) {
+        RevCommit branchTip = readBranchTip(cmd, magicBranch.dest);
+        if (branchTip == null) {
+          return; // readBranchTip already rejected cmd.
+        }
+        magicBranch.baseCommit = Collections.singletonList(branchTip);
+        logDebug("Set baseCommit = {}", magicBranch.baseCommit.get(0).name());
+      }
+    } catch (IOException ex) {
+      logWarn(
+          String.format("Error walking to %s in project %s", destBranch, project.getName()), ex);
+      reject(cmd, "internal server error");
+      return;
+    }
+
+    // Validate that the new commits are connected with the target
+    // branch.  If they aren't, we want to abort. We do this check by
+    // looking to see if we can compute a merge base between the new
+    // commits and the target branch head.
+    //
+    try {
+      Ref targetRef = rp.getAdvertisedRefs().get(magicBranch.dest.get());
+      if (targetRef == null || targetRef.getObjectId() == null) {
+        // The destination branch does not yet exist. Assume the
+        // history being sent for review will start it and thus
+        // is "connected" to the branch.
+        logDebug("Branch is unborn");
+        return;
+      }
+      RevCommit h = walk.parseCommit(targetRef.getObjectId());
+      logDebug("Current branch tip: {}", h.name());
+      RevFilter oldRevFilter = walk.getRevFilter();
+      try {
+        walk.reset();
+        walk.setRevFilter(RevFilter.MERGE_BASE);
+        walk.markStart(tip);
+        walk.markStart(h);
+        if (walk.next() == null) {
+          reject(magicBranch.cmd, "no common ancestry");
+        }
+      } finally {
+        walk.reset();
+        walk.setRevFilter(oldRevFilter);
+      }
+    } catch (IOException e) {
+      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
+      logError("Invalid pack upload; one or more objects weren't sent", e);
+    }
+  }
+
+  private static String readHEAD(Repository repo) {
+    try {
+      return repo.getFullBranch();
+    } catch (IOException e) {
+      log.error("Cannot read HEAD symref", e);
+      return null;
+    }
+  }
+
+  private RevCommit readBranchTip(ReceiveCommand cmd, Branch.NameKey branch) throws IOException {
+    Ref r = allRefs().get(branch.get());
+    if (r == null) {
+      reject(cmd, branch.get() + " not found");
+      return null;
+    }
+    return rp.getRevWalk().parseCommit(r.getObjectId());
+  }
+
+  private void parseReplaceCommand(ReceiveCommand cmd, Change.Id changeId) {
+    logDebug("Parsing replace command");
+    if (cmd.getType() != ReceiveCommand.Type.CREATE) {
+      reject(cmd, "invalid usage");
+      return;
+    }
+
+    RevCommit newCommit;
+    try {
+      newCommit = rp.getRevWalk().parseCommit(cmd.getNewId());
+      logDebug("Replacing with {}", newCommit);
+    } catch (IOException e) {
+      logError("Cannot parse " + cmd.getNewId().name() + " as commit", e);
+      reject(cmd, "invalid commit");
+      return;
+    }
+
+    Change changeEnt;
+    try {
+      changeEnt = notesFactory.createChecked(db, project.getNameKey(), changeId).getChange();
+    } catch (NoSuchChangeException e) {
+      logError("Change not found " + changeId, e);
+      reject(cmd, "change " + changeId + " not found");
+      return;
+    } catch (OrmException e) {
+      logError("Cannot lookup existing change " + changeId, e);
+      reject(cmd, "database error");
+      return;
+    }
+    if (!project.getNameKey().equals(changeEnt.getProject())) {
+      reject(cmd, "change " + changeId + " does not belong to project " + project.getName());
+      return;
+    }
+
+    logDebug("Replacing change {}", changeEnt.getId());
+    requestReplace(cmd, true, changeEnt, newCommit);
+  }
+
+  private boolean requestReplace(
+      ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit) {
+    if (change.getStatus().isClosed()) {
+      reject(
+          cmd,
+          changeFormatter.changeClosed(
+              ChangeReportFormatter.Input.builder().setChange(change).build()));
+      return false;
+    }
+
+    ReplaceRequest req = new ReplaceRequest(change.getId(), newCommit, cmd, checkMergedInto);
+    if (replaceByChange.containsKey(req.ontoChange)) {
+      reject(cmd, "duplicate request");
+      return false;
+    }
+    replaceByChange.put(req.ontoChange, req);
+    return true;
+  }
+
+  private void selectNewAndReplacedChangesFromMagicBranch() {
+    logDebug("Finding new and replaced changes");
+    newChanges = new ArrayList<>();
+
+    ListMultimap<ObjectId, Ref> existing = changeRefsById();
+    GroupCollector groupCollector =
+        GroupCollector.create(changeRefsById(), db, psUtil, notesFactory, project.getNameKey());
+
+    try {
+      RevCommit start = setUpWalkForSelectingChanges();
+      if (start == null) {
+        return;
+      }
+
+      LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>();
+      Set<Change.Key> newChangeIds = new HashSet<>();
+      int maxBatchChanges = receiveConfig.getEffectiveMaxBatchChangesLimit(user);
+      int total = 0;
+      int alreadyTracked = 0;
+      boolean rejectImplicitMerges =
+          start.getParentCount() == 1
+              && projectCache
+                  .get(project.getNameKey())
+                  .is(BooleanProjectConfig.REJECT_IMPLICIT_MERGES)
+              // Don't worry about implicit merges when creating changes for
+              // already-merged commits; they're already in history, so it's too
+              // late.
+              && !magicBranch.merged;
+      Set<RevCommit> mergedParents;
+      if (rejectImplicitMerges) {
+        mergedParents = new HashSet<>();
+      } else {
+        mergedParents = null;
+      }
+
+      for (; ; ) {
+        RevCommit c = rp.getRevWalk().next();
+        if (c == null) {
+          break;
+        }
+        total++;
+        rp.getRevWalk().parseBody(c);
+        String name = c.name();
+        groupCollector.visit(c);
+        Collection<Ref> existingRefs = existing.get(c);
+
+        if (rejectImplicitMerges) {
+          Collections.addAll(mergedParents, c.getParents());
+          mergedParents.remove(c);
+        }
+
+        boolean commitAlreadyTracked = !existingRefs.isEmpty();
+        if (commitAlreadyTracked) {
+          alreadyTracked++;
+          // Corner cases where an existing commit might need a new group:
+          // A) Existing commit has a null group; wasn't assigned during schema
+          //    upgrade, or schema upgrade is performed on a running server.
+          // B) Let A<-B<-C, then:
+          //      1. Push A to refs/heads/master
+          //      2. Push B to refs/for/master
+          //      3. Force push A~ to refs/heads/master
+          //      4. Push C to refs/for/master.
+          //      B will be in existing so we aren't replacing the patch set. It
+          //      used to have its own group, but now needs to to be changed to
+          //      A's group.
+          // C) Commit is a PatchSet of a pre-existing change uploaded with a
+          //    different target branch.
+          for (Ref ref : existingRefs) {
+            updateGroups.add(new UpdateGroupsRequest(ref, c));
+          }
+          if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
+            continue;
+          }
+        }
+
+        List<String> idList = c.getFooterLines(CHANGE_ID);
+
+        String idStr = !idList.isEmpty() ? idList.get(idList.size() - 1).trim() : null;
+
+        if (idStr != null) {
+          pending.put(c, new ChangeLookup(c, new Change.Key(idStr)));
+        } else {
+          pending.put(c, new ChangeLookup(c));
+        }
+        int n = pending.size() + newChanges.size();
+        if (maxBatchChanges != 0 && n > maxBatchChanges) {
+          logDebug("{} changes exceeds limit of {}", n, maxBatchChanges);
+          reject(
+              magicBranch.cmd,
+              "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
+          newChanges = Collections.emptyList();
+          return;
+        }
+
+        if (commitAlreadyTracked) {
+          boolean changeExistsOnDestBranch = false;
+          for (ChangeData cd : pending.get(c).destChanges) {
+            if (cd.change().getDest().equals(magicBranch.dest)) {
+              changeExistsOnDestBranch = true;
+              break;
+            }
+          }
+          if (changeExistsOnDestBranch) {
+            continue;
+          }
+
+          logDebug("Creating new change for {} even though it is already tracked", name);
+        }
+
+        if (!validCommit(rp.getRevWalk(), magicBranch.perm, magicBranch.dest, magicBranch.cmd, c)) {
+          // Not a change the user can propose? Abort as early as possible.
+          newChanges = Collections.emptyList();
+          logDebug("Aborting early due to invalid commit");
+          return;
+        }
+
+        // Don't allow merges to be uploaded in commit chain via all-not-in-target
+        if (newChangeForAllNotInTarget && c.getParentCount() > 1) {
+          reject(
+              magicBranch.cmd,
+              "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
+                  + "to override please set the base manually");
+          logDebug("Rejecting merge commit {} with newChangeForAllNotInTarget", name);
+          // TODO(dborowitz): Should we early return here?
+        }
+
+        if (idList.isEmpty()) {
+          newChanges.add(new CreateRequest(c, magicBranch.dest.get()));
+          continue;
+        }
+      }
+      logDebug(
+          "Finished initial RevWalk with {} commits total: {} already"
+              + " tracked, {} new changes with no Change-Id, and {} deferred"
+              + " lookups",
+          total,
+          alreadyTracked,
+          newChanges.size(),
+          pending.size());
+
+      if (rejectImplicitMerges) {
+        rejectImplicitMerges(mergedParents);
+      }
+
+      for (Iterator<ChangeLookup> itr = pending.values().iterator(); itr.hasNext(); ) {
+        ChangeLookup p = itr.next();
+        if (p.changeKey == null) {
+          continue;
+        }
+
+        if (newChangeIds.contains(p.changeKey)) {
+          logDebug("Multiple commits with Change-Id {}", p.changeKey);
+          reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
+          newChanges = Collections.emptyList();
+          return;
+        }
+
+        List<ChangeData> changes = p.destChanges;
+        if (changes.size() > 1) {
+          logDebug(
+              "Multiple changes in branch {} with Change-Id {}: {}",
+              magicBranch.dest,
+              p.changeKey,
+              changes.stream().map(cd -> cd.getId().toString()).collect(joining()));
+          // WTF, multiple changes in this branch have the same key?
+          // Since the commit is new, the user should recreate it with
+          // a different Change-Id. In practice, we should never see
+          // this error message as Change-Id should be unique per branch.
+          //
+          reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
+          newChanges = Collections.emptyList();
+          return;
+        }
+
+        if (changes.size() == 1) {
+          // Schedule as a replacement to this one matching change.
+          //
+
+          RevId currentPs = changes.get(0).currentPatchSet().getRevision();
+          // If Commit is already current PatchSet of target Change.
+          if (p.commit.name().equals(currentPs.get())) {
+            if (pending.size() == 1) {
+              // There are no commits left to check, all commits in pending were already
+              // current PatchSet of the corresponding target changes.
+              reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
+            } else {
+              // Commit is already current PatchSet.
+              // Remove from pending and try next commit.
+              itr.remove();
+              continue;
+            }
+          }
+          if (requestReplace(magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
+            continue;
+          }
+          newChanges = Collections.emptyList();
+          return;
+        }
+
+        if (changes.size() == 0) {
+          if (!isValidChangeId(p.changeKey.get())) {
+            reject(magicBranch.cmd, "invalid Change-Id");
+            newChanges = Collections.emptyList();
+            return;
+          }
+
+          // In case the change look up from the index failed,
+          // double check against the existing refs
+          if (foundInExistingRef(existing.get(p.commit))) {
+            if (pending.size() == 1) {
+              reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
+              newChanges = Collections.emptyList();
+              return;
+            }
+            itr.remove();
+            continue;
+          }
+          newChangeIds.add(p.changeKey);
+        }
+        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get()));
+      }
+      logDebug(
+          "Finished deferred lookups with {} updates and {} new changes",
+          replaceByChange.size(),
+          newChanges.size());
+    } catch (IOException e) {
+      // Should never happen, the core receive process would have
+      // identified the missing object earlier before we got control.
+      //
+      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
+      logError("Invalid pack upload; one or more objects weren't sent", e);
+      newChanges = Collections.emptyList();
+      return;
+    } catch (OrmException e) {
+      logError("Cannot query database to locate prior changes", e);
+      reject(magicBranch.cmd, "database error");
+      newChanges = Collections.emptyList();
+      return;
+    }
+
+    if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
+      reject(magicBranch.cmd, "no new changes");
+      return;
+    }
+    if (!newChanges.isEmpty() && magicBranch.edit) {
+      reject(magicBranch.cmd, "edit is not supported for new changes");
+      return;
+    }
+
+    try {
+      SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
+      List<Integer> newIds = seq.nextChangeIds(newChanges.size());
+      for (int i = 0; i < newChanges.size(); i++) {
+        CreateRequest create = newChanges.get(i);
+        create.setChangeId(newIds.get(i));
+        create.groups = ImmutableList.copyOf(groups.get(create.commit));
+      }
+      for (ReplaceRequest replace : replaceByChange.values()) {
+        replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
+      }
+      for (UpdateGroupsRequest update : updateGroups) {
+        update.groups = ImmutableList.copyOf((groups.get(update.commit)));
+      }
+      logDebug("Finished updating groups from GroupCollector");
+    } catch (OrmException e) {
+      logError("Error collecting groups for changes", e);
+      reject(magicBranch.cmd, "internal server error");
+      return;
+    }
+  }
+
+  private boolean foundInExistingRef(Collection<Ref> existingRefs) throws OrmException {
+    for (Ref ref : existingRefs) {
+      ChangeNotes notes =
+          notesFactory.create(db, project.getNameKey(), Change.Id.fromRef(ref.getName()));
+      Change change = notes.getChange();
+      if (change.getDest().equals(magicBranch.dest)) {
+        logDebug("Found change {} from existing refs.", change.getKey());
+        // Reindex the change asynchronously, ignoring errors.
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError = indexer.indexAsync(project.getNameKey(), change.getId());
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private RevCommit setUpWalkForSelectingChanges() throws IOException {
+    RevWalk rw = rp.getRevWalk();
+    RevCommit start = rw.parseCommit(magicBranch.cmd.getNewId());
+
+    rw.reset();
+    rw.sort(RevSort.TOPO);
+    rw.sort(RevSort.REVERSE, true);
+    rp.getRevWalk().markStart(start);
+    if (magicBranch.baseCommit != null) {
+      markExplicitBasesUninteresting();
+    } else if (magicBranch.merged) {
+      logDebug("Marking parents of merged commit {} uninteresting", start.name());
+      for (RevCommit c : start.getParents()) {
+        rw.markUninteresting(c);
+      }
+    } else {
+      markHeadsAsUninteresting(rw, magicBranch.dest != null ? magicBranch.dest.get() : null);
+    }
+    return start;
+  }
+
+  private void markExplicitBasesUninteresting() throws IOException {
+    logDebug("Marking {} base commits uninteresting", magicBranch.baseCommit.size());
+    for (RevCommit c : magicBranch.baseCommit) {
+      rp.getRevWalk().markUninteresting(c);
+    }
+    Ref targetRef = allRefs().get(magicBranch.dest.get());
+    if (targetRef != null) {
+      logDebug(
+          "Marking target ref {} ({}) uninteresting",
+          magicBranch.dest.get(),
+          targetRef.getObjectId().name());
+      rp.getRevWalk().markUninteresting(rp.getRevWalk().parseCommit(targetRef.getObjectId()));
+    }
+  }
+
+  private void rejectImplicitMerges(Set<RevCommit> mergedParents) throws IOException {
+    if (!mergedParents.isEmpty()) {
+      Ref targetRef = allRefs().get(magicBranch.dest.get());
+      if (targetRef != null) {
+        RevWalk rw = rp.getRevWalk();
+        RevCommit tip = rw.parseCommit(targetRef.getObjectId());
+        boolean containsImplicitMerges = true;
+        for (RevCommit p : mergedParents) {
+          containsImplicitMerges &= !rw.isMergedInto(p, tip);
+        }
+
+        if (containsImplicitMerges) {
+          rw.reset();
+          for (RevCommit p : mergedParents) {
+            rw.markStart(p);
+          }
+          rw.markUninteresting(tip);
+          RevCommit c;
+          while ((c = rw.next()) != null) {
+            rw.parseBody(c);
+            messages.add(
+                new CommitValidationMessage(
+                    "ERROR: Implicit Merge of "
+                        + c.abbreviate(7).name()
+                        + " "
+                        + c.getShortMessage(),
+                    false));
+          }
+          reject(magicBranch.cmd, "implicit merges detected");
+        }
+      }
+    }
+  }
+
+  private void markHeadsAsUninteresting(RevWalk rw, @Nullable String forRef) {
+    int i = 0;
+    for (Ref ref : allRefs().values()) {
+      if ((ref.getName().startsWith(R_HEADS) || ref.getName().equals(forRef))
+          && ref.getObjectId() != null) {
+        try {
+          rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
+          i++;
+        } catch (IOException e) {
+          logWarn(String.format("Invalid ref %s in %s", ref.getName(), project.getName()), e);
+        }
+      }
+    }
+    logDebug("Marked {} heads as uninteresting", i);
+  }
+
+  private static boolean isValidChangeId(String idStr) {
+    return idStr.matches("^I[0-9a-fA-F]{40}$") && !idStr.matches("^I00*$");
+  }
+
+  private class ChangeLookup {
+    final RevCommit commit;
+    final Change.Key changeKey;
+    final List<ChangeData> destChanges;
+
+    ChangeLookup(RevCommit c, Change.Key key) throws OrmException {
+      commit = c;
+      changeKey = key;
+      destChanges = queryProvider.get().byBranchKey(magicBranch.dest, key);
+    }
+
+    ChangeLookup(RevCommit c) throws OrmException {
+      commit = c;
+      destChanges = queryProvider.get().byBranchCommit(magicBranch.dest, c.getName());
+      changeKey = null;
+    }
+  }
+
+  private class CreateRequest {
+    final RevCommit commit;
+    private final String refName;
+
+    Change.Id changeId;
+    ReceiveCommand cmd;
+    ChangeInserter ins;
+    List<String> groups = ImmutableList.of();
+
+    Change change;
+
+    CreateRequest(RevCommit commit, String refName) {
+      this.commit = commit;
+      this.refName = refName;
+    }
+
+    private void setChangeId(int id) {
+
+      changeId = new Change.Id(id);
+      ins =
+          changeInserterFactory
+              .create(changeId, commit, refName)
+              .setTopic(magicBranch.topic)
+              .setPrivate(setChangeAsPrivate)
+              .setWorkInProgress(magicBranch.workInProgress)
+              // Changes already validated in validateNewCommits.
+              .setValidate(false);
+
+      if (magicBranch.merged) {
+        ins.setStatus(Change.Status.MERGED);
+      }
+      cmd = new ReceiveCommand(ObjectId.zeroId(), commit, ins.getPatchSetId().toRefName());
+      if (rp.getPushCertificate() != null) {
+        ins.setPushCertificate(rp.getPushCertificate().toTextWithSignature());
+      }
+    }
+
+    private void addOps(BatchUpdate bu) throws RestApiException {
+      checkState(changeId != null, "must call setChangeId before addOps");
+      try {
+        RevWalk rw = rp.getRevWalk();
+        rw.parseBody(commit);
+        final PatchSet.Id psId = ins.setGroups(groups).getPatchSetId();
+        Account.Id me = user.getAccountId();
+        List<FooterLine> footerLines = commit.getFooterLines();
+        MailRecipients recipients = new MailRecipients();
+        Map<String, Short> approvals = new HashMap<>();
+        checkNotNull(magicBranch);
+        recipients.add(magicBranch.getMailRecipients());
+        approvals = magicBranch.labels;
+        recipients.add(getRecipientsFromFooters(accountResolver, footerLines));
+        recipients.remove(me);
+        StringBuilder msg =
+            new StringBuilder(
+                ApprovalsUtil.renderMessageWithApprovals(
+                    psId.get(), approvals, Collections.<String, PatchSetApproval>emptyMap()));
+        msg.append('.');
+        if (!Strings.isNullOrEmpty(magicBranch.message)) {
+          msg.append("\n").append(magicBranch.message);
+        }
+
+        bu.insertChange(
+            ins.setReviewers(recipients.getReviewers())
+                .setExtraCC(recipients.getCcOnly())
+                .setApprovals(approvals)
+                .setMessage(msg.toString())
+                .setNotify(magicBranch.getNotify())
+                .setAccountsToNotify(magicBranch.getAccountsToNotify())
+                .setRequestScopePropagator(requestScopePropagator)
+                .setSendMail(true)
+                .setPatchSetDescription(magicBranch.message));
+        if (!magicBranch.hashtags.isEmpty()) {
+          // Any change owner is allowed to add hashtags when creating a change.
+          bu.addOp(
+              changeId,
+              hashtagsFactory.create(new HashtagsInput(magicBranch.hashtags)).setFireEvent(false));
+        }
+        if (!Strings.isNullOrEmpty(magicBranch.topic)) {
+          bu.addOp(
+              changeId,
+              new BatchUpdateOp() {
+                @Override
+                public boolean updateChange(ChangeContext ctx) {
+                  ctx.getUpdate(psId).setTopic(magicBranch.topic);
+                  return true;
+                }
+              });
+        }
+        bu.addOp(
+            changeId,
+            new BatchUpdateOp() {
+              @Override
+              public boolean updateChange(ChangeContext ctx) {
+                change = ctx.getChange();
+                return false;
+              }
+            });
+        bu.addOp(changeId, new ChangeProgressOp(newProgress));
+      } catch (Exception e) {
+        throw INSERT_EXCEPTION.apply(e);
+      }
+    }
+  }
+
+  private void submit(Collection<CreateRequest> create, Collection<ReplaceRequest> replace)
+      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    Map<ObjectId, Change> bySha = Maps.newHashMapWithExpectedSize(create.size() + replace.size());
+    for (CreateRequest r : create) {
+      checkNotNull(r.change, "cannot submit new change %s; op may not have run", r.changeId);
+      bySha.put(r.commit, r.change);
+    }
+    for (ReplaceRequest r : replace) {
+      bySha.put(r.newCommitId, r.notes.getChange());
+    }
+    Change tipChange = bySha.get(magicBranch.cmd.getNewId());
+    checkNotNull(
+        tipChange, "tip of push does not correspond to a change; found these changes: %s", bySha);
+    logDebug(
+        "Processing submit with tip change {} ({})", tipChange.getId(), magicBranch.cmd.getNewId());
+    try (MergeOp op = mergeOpProvider.get()) {
+      op.merge(db, tipChange, user, false, new SubmitInput(), false);
+    }
+  }
+
+  private void preparePatchSetsForReplace() {
+    try {
+      readChangesForReplace();
+      for (Iterator<ReplaceRequest> itr = replaceByChange.values().iterator(); itr.hasNext(); ) {
+        ReplaceRequest req = itr.next();
+        if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
+          req.validate(false);
+          if (req.skip && req.cmd == null) {
+            itr.remove();
+          }
+        }
+      }
+    } catch (OrmException err) {
+      logError(
+          String.format(
+              "Cannot read database before replacement for project %s", project.getName()),
+          err);
+      for (ReplaceRequest req : replaceByChange.values()) {
+        if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
+          req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
+        }
+      }
+    } catch (IOException | PermissionBackendException err) {
+      logError(
+          String.format(
+              "Cannot read repository before replacement for project %s", project.getName()),
+          err);
+      for (ReplaceRequest req : replaceByChange.values()) {
+        if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
+          req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
+        }
+      }
+    }
+    logDebug("Read {} changes to replace", replaceByChange.size());
+
+    if (magicBranch != null && magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
+      // Cancel creations tied to refs/for/ or refs/drafts/ command.
+      for (ReplaceRequest req : replaceByChange.values()) {
+        if (req.inputCommand == magicBranch.cmd && req.cmd != null) {
+          req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
+        }
+      }
+      for (CreateRequest req : newChanges) {
+        req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
+      }
+    }
+  }
+
+  private void readChangesForReplace() throws OrmException {
+    Collection<ChangeNotes> allNotes =
+        notesFactory.create(
+            db, replaceByChange.values().stream().map(r -> r.ontoChange).collect(toList()));
+    for (ChangeNotes notes : allNotes) {
+      replaceByChange.get(notes.getChangeId()).notes = notes;
+    }
+  }
+
+  private class ReplaceRequest {
+    final Change.Id ontoChange;
+    final ObjectId newCommitId;
+    final ReceiveCommand inputCommand;
+    final boolean checkMergedInto;
+    ChangeNotes notes;
+    BiMap<RevCommit, PatchSet.Id> revisions;
+    PatchSet.Id psId;
+    ReceiveCommand prev;
+    ReceiveCommand cmd;
+    PatchSetInfo info;
+    boolean skip;
+    private PatchSet.Id priorPatchSet;
+    List<String> groups = ImmutableList.of();
+    private ReplaceOp replaceOp;
+
+    ReplaceRequest(
+        Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto) {
+      this.ontoChange = toChange;
+      this.newCommitId = newCommit.copy();
+      this.inputCommand = checkNotNull(cmd);
+      this.checkMergedInto = checkMergedInto;
+
+      revisions = HashBiMap.create();
+      for (Ref ref : refs(toChange)) {
+        try {
+          revisions.forcePut(
+              rp.getRevWalk().parseCommit(ref.getObjectId()), PatchSet.Id.fromRef(ref.getName()));
+        } catch (IOException err) {
+          logWarn(
+              String.format(
+                  "Project %s contains invalid change ref %s", project.getName(), ref.getName()),
+              err);
+        }
+      }
+    }
+
+    /**
+     * Validate the new patch set commit for this change.
+     *
+     * <p><strong>Side effects:</strong>
+     *
+     * <ul>
+     *   <li>May add error or warning messages to the progress monitor
+     *   <li>Will reject {@code cmd} prior to returning false
+     *   <li>May reset {@code rp.getRevWalk()}; do not call in the middle of a walk.
+     * </ul>
+     *
+     * @param autoClose whether the caller intends to auto-close the change after adding a new patch
+     *     set.
+     * @return whether the new commit is valid
+     * @throws IOException
+     * @throws OrmException
+     * @throws PermissionBackendException
+     */
+    boolean validate(boolean autoClose)
+        throws IOException, OrmException, PermissionBackendException {
+      if (!autoClose && inputCommand.getResult() != NOT_ATTEMPTED) {
+        return false;
+      } else if (notes == null) {
+        reject(inputCommand, "change " + ontoChange + " not found");
+        return false;
+      }
+
+      Change change = notes.getChange();
+      priorPatchSet = change.currentPatchSetId();
+      if (!revisions.containsValue(priorPatchSet)) {
+        reject(inputCommand, "change " + ontoChange + " missing revisions");
+        return false;
+      }
+
+      RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
+      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+      try {
+        permissions.change(notes).database(db).check(ChangePermission.ADD_PATCH_SET);
+      } catch (AuthException no) {
+        reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
+        return false;
+      }
+
+      if (!projectState.statePermitsWrite()) {
+        reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
+        return false;
+      }
+      if (change.getStatus().isClosed()) {
+        reject(inputCommand, "change " + ontoChange + " closed");
+        return false;
+      } else if (revisions.containsKey(newCommit)) {
+        reject(inputCommand, "commit already exists (in the change)");
+        return false;
+      }
+
+      for (Ref r : rp.getRepository().getRefDatabase().getRefs("refs/changes").values()) {
+        if (r.getObjectId().equals(newCommit)) {
+          reject(inputCommand, "commit already exists (in the project)");
+          return false;
+        }
+      }
+
+      for (RevCommit prior : revisions.keySet()) {
+        // Don't allow a change to directly depend upon itself. This is a
+        // very common error due to users making a new commit rather than
+        // amending when trying to address review comments.
+        if (rp.getRevWalk().isMergedInto(prior, newCommit)) {
+          reject(inputCommand, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
+          return false;
+        }
+      }
+
+      PermissionBackend.ForRef perm = permissions.ref(change.getDest().get());
+      if (!validCommit(rp.getRevWalk(), perm, change.getDest(), inputCommand, newCommit)) {
+        return false;
+      }
+      rp.getRevWalk().parseBody(priorCommit);
+
+      // Don't allow the same tree if the commit message is unmodified
+      // or no parents were updated (rebase), else warn that only part
+      // of the commit was modified.
+      if (newCommit.getTree().equals(priorCommit.getTree())) {
+        boolean messageEq = eq(newCommit.getFullMessage(), priorCommit.getFullMessage());
+        boolean parentsEq = parentsEqual(newCommit, priorCommit);
+        boolean authorEq = authorEqual(newCommit, priorCommit);
+        ObjectReader reader = rp.getRevWalk().getObjectReader();
+
+        if (messageEq && parentsEq && authorEq && !autoClose) {
+          addMessage(
+              String.format(
+                  "(W) No changes between prior commit %s and new commit %s",
+                  reader.abbreviate(priorCommit).name(), reader.abbreviate(newCommit).name()));
+        } else {
+          StringBuilder msg = new StringBuilder();
+          msg.append("(I) ");
+          msg.append(reader.abbreviate(newCommit).name());
+          msg.append(":");
+          msg.append(" no files changed");
+          if (!authorEq) {
+            msg.append(", author changed");
+          }
+          if (!messageEq) {
+            msg.append(", message updated");
+          }
+          if (!parentsEq) {
+            msg.append(", was rebased");
+          }
+          addMessage(msg.toString());
+        }
+      }
+
+      if (magicBranch != null
+          && (magicBranch.workInProgress || magicBranch.ready)
+          && magicBranch.workInProgress != change.isWorkInProgress()
+          && !user.getAccountId().equals(change.getOwner())) {
+        reject(inputCommand, ONLY_OWNER_CAN_MODIFY_WIP);
+        return false;
+      }
+
+      if (magicBranch != null && (magicBranch.edit || magicBranch.draft)) {
+        return newEdit();
+      }
+
+      newPatchSet();
+      return true;
+    }
+
+    private boolean newEdit() {
+      psId = notes.getChange().currentPatchSetId();
+      Optional<ChangeEdit> edit = null;
+
+      try {
+        edit = editUtil.byChange(notes, user);
+      } catch (AuthException | IOException e) {
+        logError("Cannot retrieve edit", e);
+        return false;
+      }
+
+      if (edit.isPresent()) {
+        if (edit.get().getBasePatchSet().getId().equals(psId)) {
+          // replace edit
+          cmd =
+              new ReceiveCommand(edit.get().getEditCommit(), newCommitId, edit.get().getRefName());
+        } else {
+          // delete old edit ref on rebase
+          prev =
+              new ReceiveCommand(
+                  edit.get().getEditCommit(), ObjectId.zeroId(), edit.get().getRefName());
+          createEditCommand();
+        }
+      } else {
+        createEditCommand();
+      }
+
+      return true;
+    }
+
+    private void createEditCommand() {
+      // create new edit
+      cmd =
+          new ReceiveCommand(
+              ObjectId.zeroId(),
+              newCommitId,
+              RefNames.refsEdit(user.getAccountId(), notes.getChangeId(), psId));
+    }
+
+    private void newPatchSet() throws IOException, OrmException {
+      RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
+      psId =
+          ChangeUtil.nextPatchSetIdFromAllRefsMap(allRefs(), notes.getChange().currentPatchSetId());
+      info = patchSetInfoFactory.get(rp.getRevWalk(), newCommit, psId);
+      cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName());
+    }
+
+    void addOps(BatchUpdate bu, @Nullable Task progress) throws IOException {
+      if (magicBranch != null && (magicBranch.edit || magicBranch.draft)) {
+        bu.addOp(notes.getChangeId(), new ReindexOnlyOp());
+        if (prev != null) {
+          bu.addRepoOnlyOp(new UpdateOneRefOp(prev));
+        }
+        bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
+        return;
+      }
+      RevWalk rw = rp.getRevWalk();
+      // TODO(dborowitz): Move to ReplaceOp#updateRepo.
+      RevCommit newCommit = rw.parseCommit(newCommitId);
+      rw.parseBody(newCommit);
+
+      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+      replaceOp =
+          replaceOpFactory
+              .create(
+                  projectState,
+                  notes.getChange().getDest(),
+                  checkMergedInto,
+                  priorPatchSet,
+                  priorCommit,
+                  psId,
+                  newCommit,
+                  info,
+                  groups,
+                  magicBranch,
+                  rp.getPushCertificate())
+              .setRequestScopePropagator(requestScopePropagator);
+      bu.addOp(notes.getChangeId(), replaceOp);
+      if (progress != null) {
+        bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
+      }
+    }
+
+    String getRejectMessage() {
+      return replaceOp != null ? replaceOp.getRejectMessage() : null;
+    }
+  }
+
+  private class UpdateGroupsRequest {
+    private final PatchSet.Id psId;
+    private final RevCommit commit;
+    List<String> groups = ImmutableList.of();
+
+    UpdateGroupsRequest(Ref ref, RevCommit commit) {
+      this.psId = checkNotNull(PatchSet.Id.fromRef(ref.getName()));
+      this.commit = commit;
+    }
+
+    private void addOps(BatchUpdate bu) {
+      bu.addOp(
+          psId.getParentKey(),
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws OrmException {
+              PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+              List<String> oldGroups = ps.getGroups();
+              if (oldGroups == null) {
+                if (groups == null) {
+                  return false;
+                }
+              } else if (sameGroups(oldGroups, groups)) {
+                return false;
+              }
+              psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, groups);
+              return true;
+            }
+          });
+    }
+
+    private boolean sameGroups(List<String> a, List<String> b) {
+      return Sets.newHashSet(a).equals(Sets.newHashSet(b));
+    }
+  }
+
+  private class UpdateOneRefOp implements RepoOnlyOp {
+    private final ReceiveCommand cmd;
+
+    private UpdateOneRefOp(ReceiveCommand cmd) {
+      this.cmd = checkNotNull(cmd);
+    }
+
+    @Override
+    public void updateRepo(RepoContext ctx) throws IOException {
+      ctx.addRefUpdate(cmd);
+    }
+
+    @Override
+    public void postUpdate(Context ctx) {
+      String refName = cmd.getRefName();
+      if (cmd.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
+        logDebug("Updating tag cache on fast-forward of {}", cmd.getRefName());
+        tagCache.updateFastForward(project.getNameKey(), refName, cmd.getOldId(), cmd.getNewId());
+      }
+      if (isConfig(cmd)) {
+        logDebug("Reloading project in cache");
+        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");
+          repo.setGitwebDescription(ps.getProject().getDescription());
+        } catch (IOException e) {
+          log.warn("cannot update description of " + project.getName(), e);
+        }
+        if (allProjectsName.equals(project.getNameKey())) {
+          try {
+            createGroupPermissionSyncer.syncIfNeeded();
+          } catch (IOException | ConfigInvalidException e) {
+            log.error("Can't sync create group permissions", e);
+          }
+        }
+      }
+    }
+  }
+
+  private static class ReindexOnlyOp implements BatchUpdateOp {
+    @Override
+    public boolean updateChange(ChangeContext ctx) {
+      // Trigger reindexing even though change isn't actually updated.
+      return true;
+    }
+  }
+
+  private List<Ref> refs(Change.Id changeId) {
+    return refsByChange().get(changeId);
+  }
+
+  private void initChangeRefMaps() {
+    if (refsByChange == null) {
+      int estRefsPerChange = 4;
+      refsById = MultimapBuilder.hashKeys().arrayListValues().build();
+      refsByChange =
+          MultimapBuilder.hashKeys(allRefs().size() / estRefsPerChange)
+              .arrayListValues(estRefsPerChange)
+              .build();
+      for (Ref ref : allRefs().values()) {
+        ObjectId obj = ref.getObjectId();
+        if (obj != null) {
+          PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+          if (psId != null) {
+            refsById.put(obj, ref);
+            refsByChange.put(psId.getParentKey(), ref);
+          }
+        }
+      }
+    }
+  }
+
+  private ListMultimap<Change.Id, Ref> refsByChange() {
+    initChangeRefMaps();
+    return refsByChange;
+  }
+
+  private ListMultimap<ObjectId, Ref> changeRefsById() {
+    initChangeRefMaps();
+    return refsById;
+  }
+
+  static boolean parentsEqual(RevCommit a, RevCommit b) {
+    if (a.getParentCount() != b.getParentCount()) {
+      return false;
+    }
+    for (int i = 0; i < a.getParentCount(); i++) {
+      if (!a.getParent(i).equals(b.getParent(i))) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  static boolean authorEqual(RevCommit a, RevCommit b) {
+    PersonIdent aAuthor = a.getAuthorIdent();
+    PersonIdent bAuthor = b.getAuthorIdent();
+
+    if (aAuthor == null && bAuthor == null) {
+      return true;
+    } else if (aAuthor == null || bAuthor == null) {
+      return false;
+    }
+
+    return eq(aAuthor.getName(), bAuthor.getName())
+        && eq(aAuthor.getEmailAddress(), bAuthor.getEmailAddress());
+  }
+
+  static boolean eq(String a, String b) {
+    if (a == null && b == null) {
+      return true;
+    } else if (a == null || b == null) {
+      return false;
+    } else {
+      return a.equals(b);
+    }
+  }
+
+  private boolean validRefOperation(ReceiveCommand cmd) {
+    RefOperationValidators refValidators = refValidatorsFactory.create(getProject(), user, cmd);
+
+    try {
+      messages.addAll(refValidators.validateForRefOperation());
+    } catch (RefOperationValidationException e) {
+      messages.addAll(Lists.newArrayList(e.getMessages()));
+      reject(cmd, e.getMessage());
+      return false;
+    }
+
+    return true;
+  }
+
+  private void validateNewCommits(Branch.NameKey branch, ReceiveCommand cmd)
+      throws PermissionBackendException {
+    PermissionBackend.ForRef perm = permissions.ref(branch.get());
+    if (!RefNames.REFS_CONFIG.equals(cmd.getRefName())
+        && !(MagicBranch.isMagicBranch(cmd.getRefName())
+            || NEW_PATCHSET_PATTERN.matcher(cmd.getRefName()).matches())
+        && pushOptions.containsKey(PUSH_OPTION_SKIP_VALIDATION)) {
+      try {
+        perm.check(RefPermission.SKIP_VALIDATION);
+        if (!Iterables.isEmpty(rejectCommits)) {
+          throw new AuthException("reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
+        }
+        logDebug("Short-circuiting new commit validation");
+      } catch (AuthException denied) {
+        reject(cmd, denied.getMessage());
+      }
+      return;
+    }
+
+    boolean missingFullName = Strings.isNullOrEmpty(user.getAccount().getFullName());
+    RevWalk walk = rp.getRevWalk();
+    walk.reset();
+    walk.sort(RevSort.NONE);
+    try {
+      RevObject parsedObject = walk.parseAny(cmd.getNewId());
+      if (!(parsedObject instanceof RevCommit)) {
+        return;
+      }
+      ListMultimap<ObjectId, Ref> existing = changeRefsById();
+      walk.markStart((RevCommit) parsedObject);
+      markHeadsAsUninteresting(walk, cmd.getRefName());
+      int limit = receiveConfig.maxBatchCommits;
+      int n = 0;
+      for (RevCommit c; (c = walk.next()) != null; ) {
+        if (++n > limit) {
+          logDebug("Number of new commits exceeds limit of {}", limit);
+          addMessage(
+              "Cannot push more than "
+                  + limit
+                  + " commits to "
+                  + branch.get()
+                  + " without "
+                  + PUSH_OPTION_SKIP_VALIDATION
+                  + " option");
+          reject(cmd, "too many commits");
+          return;
+        }
+        if (existing.keySet().contains(c)) {
+          continue;
+        } else if (!validCommit(walk, perm, branch, cmd, c)) {
+          break;
+        }
+
+        if (missingFullName && user.hasEmailAddress(c.getCommitterIdent().getEmailAddress())) {
+          logDebug("Will update full name of caller");
+          setFullNameTo = c.getCommitterIdent().getName();
+          missingFullName = false;
+        }
+      }
+      logDebug("Validated {} new commits", n);
+    } catch (IOException err) {
+      cmd.setResult(REJECTED_MISSING_OBJECT);
+      logError("Invalid pack upload; one or more objects weren't sent", err);
+    }
+  }
+
+  private boolean validCommit(
+      RevWalk rw,
+      PermissionBackend.ForRef perm,
+      Branch.NameKey branch,
+      ReceiveCommand cmd,
+      ObjectId id)
+      throws IOException {
+
+    if (validCommits.contains(id)) {
+      return true;
+    }
+
+    RevCommit c = rw.parseCommit(id);
+    rw.parseBody(c);
+
+    try (CommitReceivedEvent receiveEvent =
+        new CommitReceivedEvent(cmd, project, branch.get(), rw.getObjectReader(), c, user)) {
+      boolean isMerged =
+          magicBranch != null
+              && cmd.getRefName().equals(magicBranch.cmd.getRefName())
+              && magicBranch.merged;
+      CommitValidators validators =
+          isMerged
+              ? commitValidatorsFactory.forMergedCommits(perm, user.asIdentifiedUser())
+              : commitValidatorsFactory.forReceiveCommits(
+                  perm, branch, user.asIdentifiedUser(), sshInfo, repo, rw);
+      messages.addAll(validators.validate(receiveEvent));
+    } catch (CommitValidationException e) {
+      logDebug("Commit validation failed on {}", c.name());
+      messages.addAll(e.getMessages());
+      reject(cmd, e.getMessage());
+      return false;
+    }
+    validCommits.add(c.copy());
+    return true;
+  }
+
+  private void autoCloseChanges(ReceiveCommand cmd) {
+    logDebug("Starting auto-closing of changes");
+    String refName = cmd.getRefName();
+    checkState(
+        !MagicBranch.isMagicBranch(refName),
+        "shouldn't be auto-closing changes on magic branch %s",
+        refName);
+    // TODO(dborowitz): Combine this BatchUpdate with the main one in
+    // insertChangesAndPatchSets.
+    try {
+      retryHelper.execute(
+          updateFactory -> {
+            try (BatchUpdate bu =
+                    updateFactory.create(db, projectState.getNameKey(), user, TimeUtil.nowTs());
+                ObjectInserter ins = repo.newObjectInserter();
+                ObjectReader reader = ins.newReader();
+                RevWalk rw = new RevWalk(reader)) {
+              bu.setRepository(repo, rw, ins).updateChangesInParallel();
+              bu.setRequestId(receiveId);
+              // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
+
+              RevCommit newTip = rw.parseCommit(cmd.getNewId());
+              Branch.NameKey branch = new Branch.NameKey(project.getNameKey(), refName);
+
+              rw.reset();
+              rw.markStart(newTip);
+              if (!ObjectId.zeroId().equals(cmd.getOldId())) {
+                rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
+              }
+
+              ListMultimap<ObjectId, Ref> byCommit = changeRefsById();
+              Map<Change.Key, ChangeNotes> byKey = null;
+              List<ReplaceRequest> replaceAndClose = new ArrayList<>();
+
+              int existingPatchSets = 0;
+              int newPatchSets = 0;
+              COMMIT:
+              for (RevCommit c; (c = rw.next()) != null; ) {
+                rw.parseBody(c);
+
+                for (Ref ref : byCommit.get(c.copy())) {
+                  PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+                  Optional<ChangeData> cd =
+                      retryHelper.execute(
+                          ActionType.CHANGE_QUERY,
+                          () -> byLegacyId(psId.getParentKey()),
+                          t -> t instanceof OrmException);
+                  if (cd.isPresent() && cd.get().change().getDest().equals(branch)) {
+                    existingPatchSets++;
+                    bu.addOp(
+                        psId.getParentKey(),
+                        mergedByPushOpFactory.create(requestScopePropagator, psId, refName));
+                    continue COMMIT;
+                  }
+                }
+
+                for (String changeId : c.getFooterLines(CHANGE_ID)) {
+                  if (byKey == null) {
+                    byKey =
+                        retryHelper.execute(
+                            ActionType.CHANGE_QUERY,
+                            () -> openChangesByKeyByBranch(branch),
+                            t -> t instanceof OrmException);
+                  }
+
+                  ChangeNotes onto = byKey.get(new Change.Key(changeId.trim()));
+                  if (onto != null) {
+                    newPatchSets++;
+                    // Hold onto this until we're done with the walk, as the call to
+                    // req.validate below calls isMergedInto which resets the walk.
+                    ReplaceRequest req = new ReplaceRequest(onto.getChangeId(), c, cmd, false);
+                    req.notes = onto;
+                    replaceAndClose.add(req);
+                    continue COMMIT;
+                  }
+                }
+              }
+
+              for (ReplaceRequest req : replaceAndClose) {
+                Change.Id id = req.notes.getChangeId();
+                if (!req.validate(true)) {
+                  logDebug("Not closing {} because validation failed", id);
+                  continue;
+                }
+                req.addOps(bu, null);
+                bu.addOp(
+                    id,
+                    mergedByPushOpFactory
+                        .create(requestScopePropagator, req.psId, refName)
+                        .setPatchSetProvider(
+                            new Provider<PatchSet>() {
+                              @Override
+                              public PatchSet get() {
+                                return req.replaceOp.getPatchSet();
+                              }
+                            }));
+                bu.addOp(id, new ChangeProgressOp(closeProgress));
+              }
+
+              logDebug(
+                  "Auto-closing {} changes with existing patch sets and {} with new patch sets",
+                  existingPatchSets,
+                  newPatchSets);
+              bu.execute();
+            } catch (IOException | OrmException | PermissionBackendException e) {
+              logError("Failed to auto-close changes", e);
+            }
+            return null;
+          },
+          // Use a multiple of the default timeout to account for inner retries that may otherwise
+          // eat up the whole timeout so that no time is left to retry this outer action.
+          RetryHelper.options().timeout(retryHelper.getDefaultTimeout().multipliedBy(5)).build());
+    } catch (RestApiException e) {
+      logError("Can't insert patchset", e);
+    } catch (UpdateException e) {
+      logError("Failed to auto-close changes", e);
+    }
+  }
+
+  private void updateAccountInfo() {
+    if (setFullNameTo == null) {
+      return;
+    }
+    logDebug("Updating full name of caller");
+    try {
+      Account account =
+          accountsUpdate
+              .create()
+              .update(
+                  "Set Full Name on Receive Commits",
+                  user.getAccountId(),
+                  (a, u) -> {
+                    if (Strings.isNullOrEmpty(a.getFullName())) {
+                      u.setFullName(setFullNameTo);
+                    }
+                  });
+      if (account != null) {
+        user.getAccount().setFullName(account.getFullName());
+      }
+    } catch (OrmException | IOException | ConfigInvalidException e) {
+      logWarn("Failed to update full name of caller", e);
+    }
+  }
+
+  private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(Branch.NameKey branch)
+      throws OrmException {
+    Map<Change.Key, ChangeNotes> r = new HashMap<>();
+    for (ChangeData cd : queryProvider.get().byBranchOpen(branch)) {
+      try {
+        r.put(cd.change().getKey(), cd.notes());
+      } catch (NoSuchChangeException e) {
+        // Ignore deleted change
+      }
+    }
+    return r;
+  }
+
+  private Optional<ChangeData> byLegacyId(Change.Id legacyId) throws OrmException {
+    List<ChangeData> res = queryProvider.get().byLegacyChangeId(legacyId);
+    if (res.isEmpty()) {
+      return Optional.empty();
+    }
+    return Optional.of(res.get(0));
+  }
+
+  private Map<String, Ref> allRefs() {
+    return allRefsWatcher.getAllRefs();
+  }
+
+  private void reject(@Nullable ReceiveCommand cmd, String why) {
+    if (cmd != null) {
+      cmd.setResult(REJECTED_OTHER_REASON, why);
+      commandProgress.update(1);
+    }
+  }
+
+  private static boolean isHead(ReceiveCommand cmd) {
+    return cmd.getRefName().startsWith(Constants.R_HEADS);
+  }
+
+  private static boolean isConfig(ReceiveCommand cmd) {
+    return cmd.getRefName().equals(RefNames.REFS_CONFIG);
+  }
+
+  private void logDebug(String msg, Object... args) {
+    if (log.isDebugEnabled()) {
+      log.debug(receiveId + msg, args);
+    }
+  }
+
+  private void logWarn(String msg, Throwable t) {
+    if (log.isWarnEnabled()) {
+      if (t != null) {
+        log.warn(receiveId + msg, t);
+      } else {
+        log.warn(receiveId + msg);
+      }
+    }
+  }
+
+  private void logWarn(String msg) {
+    logWarn(msg, null);
+  }
+
+  private void logError(String msg, Throwable t) {
+    if (log.isErrorEnabled()) {
+      if (t != null) {
+        log.error(receiveId + msg, t);
+      } else {
+        log.error(receiveId + msg);
+      }
+    }
+  }
+
+  private void logError(String msg) {
+    logError(msg, null);
+  }
+}
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
new file mode 100644
index 0000000..723fef4
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.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;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.transport.AdvertiseRefsHook;
+import org.eclipse.jgit.transport.BaseReceivePack;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+import org.eclipse.jgit.transport.UploadPack;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Exposes only the non refs/changes/ reference names. */
+public class ReceiveCommitsAdvertiseRefsHook implements AdvertiseRefsHook {
+  private static final Logger log = LoggerFactory.getLogger(ReceiveCommitsAdvertiseRefsHook.class);
+
+  @VisibleForTesting
+  @AutoValue
+  public abstract static class Result {
+    public abstract Map<String, Ref> allRefs();
+
+    public abstract Set<ObjectId> additionalHaves();
+  }
+
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Project.NameKey projectName;
+
+  public ReceiveCommitsAdvertiseRefsHook(
+      Provider<InternalChangeQuery> queryProvider, Project.NameKey projectName) {
+    this.queryProvider = queryProvider;
+    this.projectName = projectName;
+  }
+
+  @Override
+  public void advertiseRefs(UploadPack us) {
+    throw new UnsupportedOperationException(
+        "ReceiveCommitsAdvertiseRefsHook cannot be used for UploadPack");
+  }
+
+  @Override
+  public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
+    Result r = advertiseRefs(HookUtil.ensureAllRefsAdvertised(rp));
+    rp.setAdvertisedRefs(r.allRefs(), r.additionalHaves());
+  }
+
+  @VisibleForTesting
+  public Result advertiseRefs(Map<String, Ref> oldRefs) {
+    Map<String, Ref> r = Maps.newHashMapWithExpectedSize(oldRefs.size());
+    Set<ObjectId> allPatchSets = Sets.newHashSetWithExpectedSize(oldRefs.size());
+    for (Map.Entry<String, Ref> e : oldRefs.entrySet()) {
+      String name = e.getKey();
+      if (!skip(name)) {
+        r.put(name, e.getValue());
+      }
+      if (name.startsWith(RefNames.REFS_CHANGES)) {
+        allPatchSets.add(e.getValue().getObjectId());
+      }
+    }
+    return new AutoValue_ReceiveCommitsAdvertiseRefsHook_Result(
+        r, advertiseOpenChanges(allPatchSets));
+  }
+
+  private Set<ObjectId> advertiseOpenChanges(Set<ObjectId> allPatchSets) {
+    // Advertise some recent open changes, in case a commit is based on one.
+    int limit = 32;
+    try {
+      Set<ObjectId> r = Sets.newHashSetWithExpectedSize(limit);
+      for (ChangeData cd :
+          queryProvider
+              .get()
+              .setRequestedFields(
+                  // Required for ChangeIsVisibleToPrdicate.
+                  ChangeField.CHANGE,
+                  ChangeField.REVIEWER,
+                  // Required during advertiseOpenChanges.
+                  ChangeField.PATCH_SET)
+              .enforceVisibility(true)
+              .setLimit(limit)
+              .byProjectOpen(projectName)) {
+        PatchSet ps = cd.currentPatchSet();
+        if (ps != null) {
+          ObjectId id = ObjectId.fromString(ps.getRevision().get());
+          // Ensure we actually observed a patch set ref pointing to this
+          // object, in case the database is out of sync with the repo and the
+          // object doesn't actually exist.
+          if (allPatchSets.contains(id)) {
+            r.add(id);
+          }
+        }
+      }
+      return r;
+    } catch (OrmException err) {
+      log.error("Cannot list open changes of " + projectName, err);
+      return Collections.emptySet();
+    }
+  }
+
+  private static boolean skip(String name) {
+    return name.startsWith(RefNames.REFS_CHANGES)
+        || name.startsWith(RefNames.REFS_CACHE_AUTOMERGE)
+        || MagicBranch.isMagicBranch(name);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutor.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutor.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutor.java
rename to java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutor.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutorModule.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutorModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutorModule.java
rename to java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutorModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsModule.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsModule.java
rename to java/com/google/gerrit/server/git/receive/ReceiveCommitsModule.java
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveConfig.java b/java/com/google/gerrit/server/git/receive/ReceiveConfig.java
new file mode 100644
index 0000000..cdbf310
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/ReceiveConfig.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import static com.google.gerrit.common.data.GlobalCapability.BATCH_CHANGES_LIMIT;
+
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountLimits;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+class ReceiveConfig {
+  final boolean checkMagicRefs;
+  final boolean checkReferencedObjectsAreReachable;
+  final int maxBatchCommits;
+  final boolean disablePrivateChanges;
+  private final int systemMaxBatchChanges;
+  private final AccountLimits.Factory limitsFactory;
+
+  @Inject
+  ReceiveConfig(@GerritServerConfig Config config, AccountLimits.Factory limitsFactory) {
+    checkMagicRefs = config.getBoolean("receive", null, "checkMagicRefs", true);
+    checkReferencedObjectsAreReachable =
+        config.getBoolean("receive", null, "checkReferencedObjectsAreReachable", true);
+    maxBatchCommits = config.getInt("receive", null, "maxBatchCommits", 10000);
+    systemMaxBatchChanges = config.getInt("receive", "maxBatchChanges", 0);
+    disablePrivateChanges = config.getBoolean("change", null, "disablePrivateChanges", false);
+    this.limitsFactory = limitsFactory;
+  }
+
+  public int getEffectiveMaxBatchChangesLimit(CurrentUser user) {
+    AccountLimits limits = limitsFactory.create(user);
+    if (limits.hasExplicitRange(BATCH_CHANGES_LIMIT)) {
+      return limits.getRange(BATCH_CHANGES_LIMIT).getMax();
+    }
+    return systemMaxBatchChanges;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConstants.java b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
rename to java/com/google/gerrit/server/git/receive/ReceiveConstants.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveRefFilter.java b/java/com/google/gerrit/server/git/receive/ReceiveRefFilter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveRefFilter.java
rename to java/com/google/gerrit/server/git/receive/ReceiveRefFilter.java
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
new file mode 100644
index 0000000..9220bc9
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -0,0 +1,603 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
+import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
+import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.server.ApprovalCopier;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.extensions.events.RevisionCreated;
+import com.google.gerrit.server.git.MergedByPushOp;
+import com.google.gerrit.server.git.SendEmailExecutor;
+import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
+import com.google.gerrit.server.mail.MailUtil.MailRecipients;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.util.Providers;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushCertificate;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ReplaceOp implements BatchUpdateOp {
+  public interface Factory {
+    ReplaceOp create(
+        ProjectState projectState,
+        Branch.NameKey dest,
+        boolean checkMergedInto,
+        @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
+        @Assisted("priorCommitId") ObjectId priorCommit,
+        @Assisted("patchSetId") PatchSet.Id patchSetId,
+        @Assisted("commitId") ObjectId commitId,
+        PatchSetInfo info,
+        List<String> groups,
+        @Nullable MagicBranchInput magicBranch,
+        @Nullable PushCertificate pushCertificate);
+  }
+
+  private static final Logger log = LoggerFactory.getLogger(ReplaceOp.class);
+
+  private static final String CHANGE_IS_CLOSED = "change is closed";
+
+  private final AccountResolver accountResolver;
+  private final ApprovalCopier approvalCopier;
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeKindCache changeKindCache;
+  private final ChangeMessagesUtil cmUtil;
+  private final CommentsUtil commentsUtil;
+  private final EmailReviewComments.Factory emailCommentsFactory;
+  private final ExecutorService sendEmailExecutor;
+  private final RevisionCreated revisionCreated;
+  private final CommentAdded commentAdded;
+  private final MergedByPushOp.Factory mergedByPushOpFactory;
+  private final PatchSetUtil psUtil;
+  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+  private final ProjectCache projectCache;
+
+  private final ProjectState projectState;
+  private final Branch.NameKey dest;
+  private final boolean checkMergedInto;
+  private final PatchSet.Id priorPatchSetId;
+  private final ObjectId priorCommitId;
+  private final PatchSet.Id patchSetId;
+  private final ObjectId commitId;
+  private final PatchSetInfo info;
+  private final MagicBranchInput magicBranch;
+  private final PushCertificate pushCertificate;
+  private List<String> groups = ImmutableList.of();
+
+  private final Map<String, Short> approvals = new HashMap<>();
+  private final MailRecipients recipients = new MailRecipients();
+  private RevCommit commit;
+  private ReceiveCommand cmd;
+  private ChangeNotes notes;
+  private PatchSet newPatchSet;
+  private ChangeKind changeKind;
+  private ChangeMessage msg;
+  private List<Comment> comments = ImmutableList.of();
+  private String rejectMessage;
+  private MergedByPushOp mergedByPushOp;
+  private RequestScopePropagator requestScopePropagator;
+
+  @Inject
+  ReplaceOp(
+      AccountResolver accountResolver,
+      ApprovalCopier approvalCopier,
+      ApprovalsUtil approvalsUtil,
+      ChangeData.Factory changeDataFactory,
+      ChangeKindCache changeKindCache,
+      ChangeMessagesUtil cmUtil,
+      CommentsUtil commentsUtil,
+      EmailReviewComments.Factory emailCommentsFactory,
+      RevisionCreated revisionCreated,
+      CommentAdded commentAdded,
+      MergedByPushOp.Factory mergedByPushOpFactory,
+      PatchSetUtil psUtil,
+      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      ProjectCache projectCache,
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      @Assisted ProjectState projectState,
+      @Assisted Branch.NameKey dest,
+      @Assisted boolean checkMergedInto,
+      @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
+      @Assisted("priorCommitId") ObjectId priorCommitId,
+      @Assisted("patchSetId") PatchSet.Id patchSetId,
+      @Assisted("commitId") ObjectId commitId,
+      @Assisted PatchSetInfo info,
+      @Assisted List<String> groups,
+      @Assisted @Nullable MagicBranchInput magicBranch,
+      @Assisted @Nullable PushCertificate pushCertificate) {
+    this.accountResolver = accountResolver;
+    this.approvalCopier = approvalCopier;
+    this.approvalsUtil = approvalsUtil;
+    this.changeDataFactory = changeDataFactory;
+    this.changeKindCache = changeKindCache;
+    this.cmUtil = cmUtil;
+    this.commentsUtil = commentsUtil;
+    this.emailCommentsFactory = emailCommentsFactory;
+    this.revisionCreated = revisionCreated;
+    this.commentAdded = commentAdded;
+    this.mergedByPushOpFactory = mergedByPushOpFactory;
+    this.psUtil = psUtil;
+    this.replacePatchSetFactory = replacePatchSetFactory;
+    this.projectCache = projectCache;
+    this.sendEmailExecutor = sendEmailExecutor;
+
+    this.projectState = projectState;
+    this.dest = dest;
+    this.checkMergedInto = checkMergedInto;
+    this.priorPatchSetId = priorPatchSetId;
+    this.priorCommitId = priorCommitId.copy();
+    this.patchSetId = patchSetId;
+    this.commitId = commitId.copy();
+    this.info = info;
+    this.groups = groups;
+    this.magicBranch = magicBranch;
+    this.pushCertificate = pushCertificate;
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx) throws Exception {
+    commit = ctx.getRevWalk().parseCommit(commitId);
+    ctx.getRevWalk().parseBody(commit);
+    changeKind =
+        changeKindCache.getChangeKind(
+            projectState.getNameKey(),
+            ctx.getRevWalk(),
+            ctx.getRepoView().getConfig(),
+            priorCommitId,
+            commitId);
+
+    if (checkMergedInto) {
+      String mergedInto = findMergedInto(ctx, dest.get(), commit);
+      if (mergedInto != null) {
+        mergedByPushOp =
+            mergedByPushOpFactory.create(requestScopePropagator, patchSetId, mergedInto);
+      }
+    }
+
+    cmd = new ReceiveCommand(ObjectId.zeroId(), commitId, patchSetId.toRefName());
+    ctx.addRefUpdate(cmd);
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws RestApiException, OrmException, IOException, PermissionBackendException {
+    notes = ctx.getNotes();
+    Change change = notes.getChange();
+    if (change == null || change.getStatus().isClosed()) {
+      rejectMessage = CHANGE_IS_CLOSED;
+      return false;
+    }
+    if (groups.isEmpty()) {
+      PatchSet prevPs = psUtil.current(ctx.getDb(), notes);
+      groups = prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of();
+    }
+
+    ChangeUpdate update = ctx.getUpdate(patchSetId);
+    update.setSubjectForCommit("Create patch set " + patchSetId.get());
+
+    String reviewMessage = null;
+    String psDescription = null;
+    if (magicBranch != null) {
+      recipients.add(magicBranch.getMailRecipients());
+      reviewMessage = magicBranch.message;
+      psDescription = magicBranch.message;
+      approvals.putAll(magicBranch.labels);
+      Set<String> hashtags = magicBranch.hashtags;
+      if (hashtags != null && !hashtags.isEmpty()) {
+        hashtags.addAll(notes.getHashtags());
+        update.setHashtags(hashtags);
+      }
+      if (magicBranch.topic != null && !magicBranch.topic.equals(ctx.getChange().getTopic())) {
+        update.setTopic(magicBranch.topic);
+      }
+      if (magicBranch.removePrivate) {
+        change.setPrivate(false);
+        update.setPrivate(false);
+      } else if (magicBranch.isPrivate) {
+        change.setPrivate(true);
+        update.setPrivate(true);
+      }
+      if (magicBranch.ready) {
+        change.setWorkInProgress(false);
+        change.setReviewStarted(true);
+        update.setWorkInProgress(false);
+      } else if (magicBranch.workInProgress) {
+        change.setWorkInProgress(true);
+        update.setWorkInProgress(true);
+      }
+      if (shouldPublishComments()) {
+        boolean workInProgress = change.isWorkInProgress();
+        if (magicBranch != null && magicBranch.workInProgress) {
+          workInProgress = true;
+        }
+        comments = publishComments(ctx, workInProgress);
+      }
+    }
+
+    newPatchSet =
+        psUtil.insert(
+            ctx.getDb(),
+            ctx.getRevWalk(),
+            update,
+            patchSetId,
+            commitId,
+            groups,
+            pushCertificate != null ? pushCertificate.toTextWithSignature() : null,
+            psDescription);
+
+    update.setPsDescription(psDescription);
+    recipients.add(getRecipientsFromFooters(accountResolver, commit.getFooterLines()));
+    recipients.remove(ctx.getAccountId());
+    ChangeData cd = changeDataFactory.create(ctx.getDb(), ctx.getNotes());
+    MailRecipients oldRecipients = getRecipientsFromReviewers(cd.reviewers());
+    Iterable<PatchSetApproval> newApprovals =
+        approvalsUtil.addApprovalsForNewPatchSet(
+            ctx.getDb(),
+            update,
+            projectState.getLabelTypes(),
+            newPatchSet,
+            ctx.getUser(),
+            approvals);
+    approvalCopier.copyInReviewDb(
+        ctx.getDb(),
+        ctx.getNotes(),
+        ctx.getUser(),
+        newPatchSet,
+        ctx.getRevWalk(),
+        ctx.getRepoView().getConfig(),
+        newApprovals);
+    approvalsUtil.addReviewers(
+        ctx.getDb(),
+        update,
+        projectState.getLabelTypes(),
+        change,
+        newPatchSet,
+        info,
+        recipients.getReviewers(),
+        oldRecipients.getAll());
+
+    // Check if approvals are changing in with this update. If so, add current user to reviewers.
+    // Note that this is done separately as addReviewers is filtering out the change owner as
+    // reviewer which is needed in several other code paths.
+    if (magicBranch != null && !magicBranch.labels.isEmpty()) {
+      update.putReviewer(ctx.getAccountId(), REVIEWER);
+    }
+
+    recipients.add(oldRecipients);
+
+    msg = createChangeMessage(ctx, reviewMessage);
+    cmUtil.addChangeMessage(ctx.getDb(), update, msg);
+
+    if (mergedByPushOp == null) {
+      resetChange(ctx);
+    } else {
+      mergedByPushOp.setPatchSetProvider(Providers.of(newPatchSet)).updateChange(ctx);
+    }
+
+    return true;
+  }
+
+  private ChangeMessage createChangeMessage(ChangeContext ctx, String reviewMessage)
+      throws OrmException, IOException {
+    String approvalMessage =
+        ApprovalsUtil.renderMessageWithApprovals(
+            patchSetId.get(), approvals, scanLabels(ctx, approvals));
+    String kindMessage = changeKindMessage(changeKind);
+    StringBuilder message = new StringBuilder(approvalMessage);
+    if (!Strings.isNullOrEmpty(kindMessage)) {
+      message.append(kindMessage);
+    } else {
+      message.append('.');
+    }
+    if (comments.size() == 1) {
+      message.append("\n\n(1 comment)");
+    } else if (comments.size() > 1) {
+      message.append(String.format("\n\n(%d comments)", comments.size()));
+    }
+    if (!Strings.isNullOrEmpty(reviewMessage)) {
+      message.append("\n\n").append(reviewMessage);
+    }
+    boolean workInProgress = ctx.getChange().isWorkInProgress();
+    if (magicBranch != null && magicBranch.workInProgress) {
+      workInProgress = true;
+    }
+    return ChangeMessagesUtil.newMessage(
+        patchSetId,
+        ctx.getUser(),
+        ctx.getWhen(),
+        message.toString(),
+        ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
+  }
+
+  private String changeKindMessage(ChangeKind changeKind) {
+    switch (changeKind) {
+      case MERGE_FIRST_PARENT_UPDATE:
+      case TRIVIAL_REBASE:
+      case NO_CHANGE:
+        return ": Patch Set " + priorPatchSetId.get() + " was rebased.";
+      case NO_CODE_CHANGE:
+        return ": Commit message was updated.";
+      case REWORK:
+      default:
+        return null;
+    }
+  }
+
+  private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx, Map<String, Short> approvals)
+      throws OrmException, IOException {
+    Map<String, PatchSetApproval> current = new HashMap<>();
+    // We optimize here and only retrieve current when approvals provided
+    if (!approvals.isEmpty()) {
+      for (PatchSetApproval a :
+          approvalsUtil.byPatchSetUser(
+              ctx.getDb(),
+              ctx.getNotes(),
+              ctx.getUser(),
+              priorPatchSetId,
+              ctx.getAccountId(),
+              ctx.getRevWalk(),
+              ctx.getRepoView().getConfig())) {
+        if (a.isLegacySubmit()) {
+          continue;
+        }
+
+        LabelType lt = projectState.getLabelTypes().byLabel(a.getLabelId());
+        if (lt != null) {
+          current.put(lt.getName(), a);
+        }
+      }
+    }
+    return current;
+  }
+
+  private void resetChange(ChangeContext ctx) {
+    Change change = ctx.getChange();
+    if (!change.currentPatchSetId().equals(priorPatchSetId)) {
+      return;
+    }
+
+    if (magicBranch != null && magicBranch.topic != null) {
+      change.setTopic(magicBranch.topic);
+    }
+    change.setStatus(Change.Status.NEW);
+    change.setCurrentPatchSet(info);
+
+    List<String> idList = commit.getFooterLines(CHANGE_ID);
+    if (idList.isEmpty()) {
+      change.setKey(new Change.Key("I" + commitId.name()));
+    } else {
+      change.setKey(new Change.Key(idList.get(idList.size() - 1).trim()));
+    }
+  }
+
+  private List<Comment> publishComments(ChangeContext ctx, boolean workInProgress)
+      throws OrmException {
+    List<Comment> comments =
+        commentsUtil.draftByChangeAuthor(ctx.getDb(), ctx.getNotes(), ctx.getUser().getAccountId());
+    commentsUtil.publish(
+        ctx, patchSetId, comments, ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
+    return comments;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws Exception {
+    if (changeKind != ChangeKind.TRIVIAL_REBASE) {
+      // TODO(dborowitz): Merge email templates so we only have to send one.
+      Runnable e = new ReplaceEmailTask(ctx);
+      if (requestScopePropagator != null) {
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError = sendEmailExecutor.submit(requestScopePropagator.wrap(e));
+      } else {
+        e.run();
+      }
+    }
+
+    NotifyHandling notify = magicBranch != null ? magicBranch.getNotify(notes) : NotifyHandling.ALL;
+
+    if (shouldPublishComments()) {
+      emailCommentsFactory
+          .create(
+              notify,
+              magicBranch != null ? magicBranch.getAccountsToNotify() : ImmutableListMultimap.of(),
+              notes,
+              newPatchSet,
+              ctx.getUser().asIdentifiedUser(),
+              msg,
+              comments,
+              msg.getMessage(),
+              ImmutableList.of()) // TODO(dborowitz): Include labels.
+          .sendAsync();
+    }
+
+    revisionCreated.fire(notes.getChange(), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
+    try {
+      fireCommentAddedEvent(ctx);
+    } catch (Exception e) {
+      log.warn("comment-added event invocation failed", e);
+    }
+    if (mergedByPushOp != null) {
+      mergedByPushOp.postUpdate(ctx);
+    }
+  }
+
+  private class ReplaceEmailTask implements Runnable {
+    private final Context ctx;
+
+    private ReplaceEmailTask(Context ctx) {
+      this.ctx = ctx;
+    }
+
+    @Override
+    public void run() {
+      try {
+        ReplacePatchSetSender cm =
+            replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
+        cm.setFrom(ctx.getAccount().getId());
+        cm.setPatchSet(newPatchSet, info);
+        cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
+        if (magicBranch != null) {
+          cm.setNotify(magicBranch.getNotify(notes));
+          cm.setAccountsToNotify(magicBranch.getAccountsToNotify());
+        }
+        cm.addReviewers(recipients.getReviewers());
+        cm.addExtraCC(recipients.getCcOnly());
+        cm.send();
+      } catch (Exception e) {
+        log.error("Cannot send email for new patch set " + newPatchSet.getId(), e);
+      }
+    }
+
+    @Override
+    public String toString() {
+      return "send-email newpatchset";
+    }
+  }
+
+  private void fireCommentAddedEvent(Context ctx) throws IOException {
+    if (approvals.isEmpty()) {
+      return;
+    }
+
+    /* For labels that are not set in this operation, show the "current" value
+     * of 0, and no oldValue as the value was not modified by this operation.
+     * For labels that are set in this operation, the value was modified, so
+     * show a transition from an oldValue of 0 to the new value.
+     */
+    List<LabelType> labels =
+        projectCache
+            .checkedGet(ctx.getProject())
+            .getLabelTypes(notes, ctx.getUser())
+            .getLabelTypes();
+    Map<String, Short> allApprovals = new HashMap<>();
+    Map<String, Short> oldApprovals = new HashMap<>();
+    for (LabelType lt : labels) {
+      allApprovals.put(lt.getName(), (short) 0);
+      oldApprovals.put(lt.getName(), null);
+    }
+    for (Map.Entry<String, Short> entry : approvals.entrySet()) {
+      if (entry.getValue() != 0) {
+        allApprovals.put(entry.getKey(), entry.getValue());
+        oldApprovals.put(entry.getKey(), (short) 0);
+      }
+    }
+
+    commentAdded.fire(
+        notes.getChange(),
+        newPatchSet,
+        ctx.getAccount(),
+        null,
+        allApprovals,
+        oldApprovals,
+        ctx.getWhen());
+  }
+
+  public PatchSet getPatchSet() {
+    return newPatchSet;
+  }
+
+  public Change getChange() {
+    return notes.getChange();
+  }
+
+  public String getRejectMessage() {
+    return rejectMessage;
+  }
+
+  public ReceiveCommand getCommand() {
+    return cmd;
+  }
+
+  public ReplaceOp setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
+    this.requestScopePropagator = requestScopePropagator;
+    return this;
+  }
+
+  private static String findMergedInto(Context ctx, String first, RevCommit commit) {
+    try {
+      RevWalk rw = ctx.getRevWalk();
+      Optional<ObjectId> firstId = ctx.getRepoView().getRef(first);
+      if (firstId.isPresent() && rw.isMergedInto(commit, rw.parseCommit(firstId.get()))) {
+        return first;
+      }
+
+      for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(R_HEADS).entrySet()) {
+        if (rw.isMergedInto(commit, rw.parseCommit(e.getValue()))) {
+          return R_HEADS + e.getKey();
+        }
+      }
+      return null;
+    } catch (IOException e) {
+      log.warn("Can't check for already submitted change", e);
+      return null;
+    }
+  }
+
+  private boolean shouldPublishComments() {
+    return magicBranch != null && magicBranch.shouldPublishComments();
+  }
+}
diff --git a/java/com/google/gerrit/server/git/strategy/CherryPick.java b/java/com/google/gerrit/server/git/strategy/CherryPick.java
new file mode 100644
index 0000000..7367a92
--- /dev/null
+++ b/java/com/google/gerrit/server/git/strategy/CherryPick.java
@@ -0,0 +1,225 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.git.strategy.CommitMergeStatus.EMPTY_COMMIT;
+import static com.google.gerrit.server.git.strategy.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.MergeIdenticalTreeException;
+import com.google.gerrit.server.git.MergeTip;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+public class CherryPick extends SubmitStrategy {
+
+  CherryPick(SubmitStrategy.Arguments args) {
+    super(args);
+  }
+
+  @Override
+  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
+      throws IntegrationException {
+    List<CodeReviewCommit> sorted = CodeReviewCommit.ORDER.sortedCopy(toMerge);
+    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    boolean first = true;
+    while (!sorted.isEmpty()) {
+      CodeReviewCommit n = sorted.remove(0);
+      if (first && args.mergeTip.getInitialTip() == null) {
+        ops.add(new FastForwardOp(args, n));
+      } else if (n.getParentCount() == 0) {
+        ops.add(new CherryPickRootOp(n));
+      } else if (n.getParentCount() == 1) {
+        ops.add(new CherryPickOneOp(n));
+      } else {
+        ops.add(new CherryPickMultipleParentsOp(n));
+      }
+      first = false;
+    }
+    return ops;
+  }
+
+  private class CherryPickRootOp extends SubmitStrategyOp {
+    private CherryPickRootOp(CodeReviewCommit toMerge) {
+      super(CherryPick.this.args, toMerge);
+    }
+
+    @Override
+    public void updateRepoImpl(RepoContext ctx) {
+      // Refuse to merge a root commit into an existing branch, we cannot obtain
+      // a delta for the cherry-pick to apply.
+      toMerge.setStatusCode(CommitMergeStatus.CANNOT_CHERRY_PICK_ROOT);
+    }
+  }
+
+  private class CherryPickOneOp extends SubmitStrategyOp {
+    private PatchSet.Id psId;
+    private CodeReviewCommit newCommit;
+    private PatchSetInfo patchSetInfo;
+
+    private CherryPickOneOp(CodeReviewCommit toMerge) {
+      super(CherryPick.this.args, toMerge);
+    }
+
+    @Override
+    protected void updateRepoImpl(RepoContext ctx)
+        throws IntegrationException, IOException, OrmException {
+      // If there is only one parent, a cherry-pick can be done by taking the
+      // delta relative to that one parent and redoing that on the current merge
+      // tip.
+      args.rw.parseBody(toMerge);
+      psId =
+          ChangeUtil.nextPatchSetIdFromChangeRefsMap(
+              ctx.getRepoView().getRefs(getId().toRefPrefix()),
+              toMerge.change().currentPatchSetId());
+      RevCommit mergeTip = args.mergeTip.getCurrentTip();
+      args.rw.parseBody(mergeTip);
+      String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
+
+      PersonIdent committer =
+          args.caller.newCommitterIdent(ctx.getWhen(), args.serverIdent.getTimeZone());
+      try {
+        newCommit =
+            args.mergeUtil.createCherryPickFromCommit(
+                ctx.getInserter(),
+                ctx.getRepoView().getConfig(),
+                args.mergeTip.getCurrentTip(),
+                toMerge,
+                committer,
+                cherryPickCmtMsg,
+                args.rw,
+                0,
+                false);
+      } catch (MergeConflictException mce) {
+        // Keep going in the case of a single merge failure; the goal is to
+        // cherry-pick as many commits as possible.
+        toMerge.setStatusCode(CommitMergeStatus.PATH_CONFLICT);
+        return;
+      } catch (MergeIdenticalTreeException mie) {
+        if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)) {
+          toMerge.setStatusCode(EMPTY_COMMIT);
+          return;
+        }
+        toMerge.setStatusCode(SKIPPED_IDENTICAL_TREE);
+        return;
+      }
+      // Initial copy doesn't have new patch set ID since change hasn't been
+      // updated yet.
+      newCommit = amendGitlink(newCommit);
+      newCommit.copyFrom(toMerge);
+      newCommit.setPatchsetId(psId);
+      newCommit.setStatusCode(CommitMergeStatus.CLEAN_PICK);
+      args.mergeTip.moveTipTo(newCommit, newCommit);
+      args.commitStatus.put(newCommit);
+
+      ctx.addRefUpdate(ObjectId.zeroId(), newCommit, psId.toRefName());
+      patchSetInfo = args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, psId);
+    }
+
+    @Override
+    public PatchSet updateChangeImpl(ChangeContext ctx)
+        throws OrmException, NoSuchChangeException, IOException {
+      if (newCommit == null && toMerge.getStatusCode() == SKIPPED_IDENTICAL_TREE) {
+        return null;
+      }
+      checkNotNull(
+          newCommit,
+          "no new commit produced by CherryPick of %s, expected to fail fast",
+          toMerge.change().getId());
+      PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
+      PatchSet newPs =
+          args.psUtil.insert(
+              ctx.getDb(),
+              ctx.getRevWalk(),
+              ctx.getUpdate(psId),
+              psId,
+              newCommit,
+              prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of(),
+              null,
+              null);
+      ctx.getChange().setCurrentPatchSet(patchSetInfo);
+
+      // Don't copy approvals, as this is already taken care of by
+      // SubmitStrategyOp.
+
+      newCommit.setNotes(ctx.getNotes());
+      return newPs;
+    }
+  }
+
+  private class CherryPickMultipleParentsOp extends SubmitStrategyOp {
+    private CherryPickMultipleParentsOp(CodeReviewCommit toMerge) {
+      super(CherryPick.this.args, toMerge);
+    }
+
+    @Override
+    public void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
+      if (args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)) {
+        // One or more dependencies were not met. The status was already marked
+        // on the commit so we have nothing further to perform at this time.
+        return;
+      }
+      // There are multiple parents, so this is a merge commit. We don't want
+      // to cherry-pick the merge as clients can't easily rebase their history
+      // with that merge present and replaced by an equivalent merge with a
+      // different first parent. So instead behave as though MERGE_IF_NECESSARY
+      // was configured.
+      MergeTip mergeTip = args.mergeTip;
+      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)
+          && !args.submoduleOp.hasSubscription(args.destBranch)) {
+        mergeTip.moveTipTo(toMerge, toMerge);
+      } else {
+        PersonIdent myIdent = new PersonIdent(args.serverIdent, ctx.getWhen());
+        CodeReviewCommit result =
+            args.mergeUtil.mergeOneCommit(
+                myIdent,
+                myIdent,
+                args.rw,
+                ctx.getInserter(),
+                ctx.getRepoView().getConfig(),
+                args.destBranch,
+                mergeTip.getCurrentTip(),
+                toMerge);
+        result = amendGitlink(result);
+        mergeTip.moveTipTo(result, toMerge);
+        args.mergeUtil.markCleanMerges(
+            args.rw, args.canMergeFlag, mergeTip.getCurrentTip(), args.alreadyAccepted);
+      }
+    }
+  }
+
+  static boolean dryRun(
+      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+      throws IntegrationException {
+    return args.mergeUtil.canCherryPick(args.mergeSorter, args.repo, mergeTip, args.rw, toMerge);
+  }
+}
diff --git a/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java b/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java
new file mode 100644
index 0000000..634c909
--- /dev/null
+++ b/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+/**
+ * Status codes set on {@link com.google.gerrit.server.git.CodeReviewCommit}s by {@link
+ * SubmitStrategy} implementations.
+ */
+public enum CommitMergeStatus {
+  CLEAN_MERGE("Change has been successfully merged"),
+
+  CLEAN_PICK("Change has been successfully cherry-picked"),
+
+  CLEAN_REBASE("Change has been successfully rebased and submitted"),
+
+  ALREADY_MERGED(""),
+
+  PATH_CONFLICT(
+      "Change could not be merged due to a path conflict.\n"
+          + "\n"
+          + "Please rebase the change locally and upload the rebased commit for review."),
+
+  REBASE_MERGE_CONFLICT(
+      "Change could not be merged due to a conflict.\n"
+          + "\n"
+          + "Please rebase the change locally and upload the rebased commit for review."),
+
+  SKIPPED_IDENTICAL_TREE(
+      "Marking change merged without cherry-picking to branch, as the resulting commit would be empty."),
+
+  MISSING_DEPENDENCY(""),
+
+  MANUAL_RECURSIVE_MERGE(
+      "The change requires a local merge to resolve.\n"
+          + "\n"
+          + "Please merge (or rebase) the change locally and upload the resolution for review."),
+
+  CANNOT_CHERRY_PICK_ROOT(
+      "Cannot cherry-pick an initial commit onto an existing branch.\n"
+          + "\n"
+          + "Please merge the change locally and upload the merge commit for review."),
+
+  CANNOT_REBASE_ROOT(
+      "Cannot rebase an initial commit onto an existing branch.\n"
+          + "\n"
+          + "Please merge the change locally and upload the merge commit for review."),
+
+  NOT_FAST_FORWARD(
+      "Project policy requires all submissions to be a fast-forward.\n"
+          + "\n"
+          + "Please rebase the change locally and upload again for review."),
+
+  EMPTY_COMMIT(
+      "Change could not be merged because the commit is empty.\n"
+          + "\n"
+          + "Project policy requires all commits to contain modifications to at least one file.");
+
+  private final String message;
+
+  CommitMergeStatus(String message) {
+    this.message = message;
+  }
+
+  public String getMessage() {
+    return message;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java b/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
rename to java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
diff --git a/java/com/google/gerrit/server/git/strategy/FastForwardOp.java b/java/com/google/gerrit/server/git/strategy/FastForwardOp.java
new file mode 100644
index 0000000..50c75ef
--- /dev/null
+++ b/java/com/google/gerrit/server/git/strategy/FastForwardOp.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import static com.google.gerrit.server.git.strategy.CommitMergeStatus.EMPTY_COMMIT;
+
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.update.RepoContext;
+
+class FastForwardOp extends SubmitStrategyOp {
+  FastForwardOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
+    super(args, toMerge);
+  }
+
+  @Override
+  protected void updateRepoImpl(RepoContext ctx) throws IntegrationException {
+    if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
+        && toMerge.getTree().equals(toMerge.getParent(0).getTree())) {
+      toMerge.setStatusCode(EMPTY_COMMIT);
+      return;
+    }
+
+    args.mergeTip.moveTipTo(toMerge, toMerge);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/ImplicitIntegrateOp.java b/java/com/google/gerrit/server/git/strategy/ImplicitIntegrateOp.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/ImplicitIntegrateOp.java
rename to java/com/google/gerrit/server/git/strategy/ImplicitIntegrateOp.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java b/java/com/google/gerrit/server/git/strategy/MergeAlways.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
rename to java/com/google/gerrit/server/git/strategy/MergeAlways.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java b/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
rename to java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
diff --git a/java/com/google/gerrit/server/git/strategy/MergeOneOp.java b/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
new file mode 100644
index 0000000..b6d97b9
--- /dev/null
+++ b/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import static com.google.gerrit.server.git.strategy.CommitMergeStatus.EMPTY_COMMIT;
+
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.update.RepoContext;
+import java.io.IOException;
+import org.eclipse.jgit.lib.PersonIdent;
+
+class MergeOneOp extends SubmitStrategyOp {
+  MergeOneOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
+    super(args, toMerge);
+  }
+
+  @Override
+  public void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
+    PersonIdent caller =
+        ctx.getIdentifiedUser()
+            .newCommitterIdent(args.serverIdent.getWhen(), args.serverIdent.getTimeZone());
+    if (args.mergeTip.getCurrentTip() == null) {
+      throw new IllegalStateException(
+          "cannot merge commit "
+              + toMerge.name()
+              + " onto a null tip; expected at least one fast-forward prior to"
+              + " this operation");
+    }
+    CodeReviewCommit merged =
+        args.mergeUtil.mergeOneCommit(
+            caller,
+            args.serverIdent,
+            args.rw,
+            ctx.getInserter(),
+            ctx.getRepoView().getConfig(),
+            args.destBranch,
+            args.mergeTip.getCurrentTip(),
+            toMerge);
+    if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
+        && merged.getTree().equals(merged.getParent(0).getTree())) {
+      toMerge.setStatusCode(EMPTY_COMMIT);
+      return;
+    }
+    args.mergeTip.moveTipTo(amendGitlink(merged), toMerge);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseAlways.java b/java/com/google/gerrit/server/git/strategy/RebaseAlways.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseAlways.java
rename to java/com/google/gerrit/server/git/strategy/RebaseAlways.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java b/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
rename to java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
diff --git a/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
new file mode 100644
index 0000000..80107e8
--- /dev/null
+++ b/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
@@ -0,0 +1,317 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.git.strategy.CommitMergeStatus.EMPTY_COMMIT;
+import static com.google.gerrit.server.git.strategy.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.MergeIdenticalTreeException;
+import com.google.gerrit.server.git.MergeTip;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/** This strategy covers RebaseAlways and RebaseIfNecessary ones. */
+public class RebaseSubmitStrategy extends SubmitStrategy {
+  private final boolean rebaseAlways;
+
+  RebaseSubmitStrategy(SubmitStrategy.Arguments args, boolean rebaseAlways) {
+    super(args);
+    this.rebaseAlways = rebaseAlways;
+  }
+
+  @Override
+  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
+      throws IntegrationException {
+    List<CodeReviewCommit> sorted;
+    try {
+      sorted = args.rebaseSorter.sort(toMerge);
+    } catch (IOException e) {
+      throw new IntegrationException("Commit sorting failed", e);
+    }
+    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    boolean first = true;
+
+    for (CodeReviewCommit c : sorted) {
+      if (c.getParentCount() > 1) {
+        // Since there is a merge commit, sort and prune again using
+        // MERGE_IF_NECESSARY semantics to avoid creating duplicate
+        // commits.
+        //
+        sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted);
+        break;
+      }
+    }
+
+    while (!sorted.isEmpty()) {
+      CodeReviewCommit n = sorted.remove(0);
+      if (first && args.mergeTip.getInitialTip() == null) {
+        // TODO(tandrii): Cherry-Pick strategy does this too, but it's wrong
+        // and can be fixed.
+        ops.add(new FastForwardOp(args, n));
+      } else if (n.getParentCount() == 0) {
+        ops.add(new RebaseRootOp(n));
+      } else if (n.getParentCount() == 1) {
+        ops.add(new RebaseOneOp(n));
+      } else {
+        ops.add(new RebaseMultipleParentsOp(n));
+      }
+      first = false;
+    }
+    return ops;
+  }
+
+  private class RebaseRootOp extends SubmitStrategyOp {
+    private RebaseRootOp(CodeReviewCommit toMerge) {
+      super(RebaseSubmitStrategy.this.args, toMerge);
+    }
+
+    @Override
+    public void updateRepoImpl(RepoContext ctx) {
+      // Refuse to merge a root commit into an existing branch, we cannot obtain
+      // a delta for the cherry-pick to apply.
+      toMerge.setStatusCode(CommitMergeStatus.CANNOT_REBASE_ROOT);
+    }
+  }
+
+  private class RebaseOneOp extends SubmitStrategyOp {
+    private RebaseChangeOp rebaseOp;
+    private CodeReviewCommit newCommit;
+    private PatchSet.Id newPatchSetId;
+
+    private RebaseOneOp(CodeReviewCommit toMerge) {
+      super(RebaseSubmitStrategy.this.args, toMerge);
+    }
+
+    @Override
+    public void updateRepoImpl(RepoContext ctx)
+        throws IntegrationException, InvalidChangeOperationException, RestApiException, IOException,
+            OrmException, PermissionBackendException {
+      if (args.mergeUtil.canFastForward(
+          args.mergeSorter, args.mergeTip.getCurrentTip(), args.rw, toMerge)) {
+        if (!rebaseAlways) {
+          if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
+              && toMerge.getTree().equals(toMerge.getParent(0).getTree())) {
+            toMerge.setStatusCode(EMPTY_COMMIT);
+            return;
+          }
+
+          args.mergeTip.moveTipTo(amendGitlink(toMerge), toMerge);
+          toMerge.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
+          acceptMergeTip(args.mergeTip);
+          return;
+        }
+        // RebaseAlways means we modify commit message.
+        args.rw.parseBody(toMerge);
+        newPatchSetId =
+            ChangeUtil.nextPatchSetIdFromChangeRefsMap(
+                ctx.getRepoView().getRefs(getId().toRefPrefix()),
+                toMerge.change().currentPatchSetId());
+        RevCommit mergeTip = args.mergeTip.getCurrentTip();
+        args.rw.parseBody(mergeTip);
+        String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
+        PersonIdent committer =
+            args.caller.newCommitterIdent(ctx.getWhen(), args.serverIdent.getTimeZone());
+        try {
+          newCommit =
+              args.mergeUtil.createCherryPickFromCommit(
+                  ctx.getInserter(),
+                  ctx.getRepoView().getConfig(),
+                  args.mergeTip.getCurrentTip(),
+                  toMerge,
+                  committer,
+                  cherryPickCmtMsg,
+                  args.rw,
+                  0,
+                  true);
+        } catch (MergeConflictException mce) {
+          // Unlike in Cherry-pick case, this should never happen.
+          toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
+          throw new IllegalStateException("MergeConflictException on message edit must not happen");
+        } catch (MergeIdenticalTreeException mie) {
+          // this should not happen
+          toMerge.setStatusCode(SKIPPED_IDENTICAL_TREE);
+          return;
+        }
+        ctx.addRefUpdate(ObjectId.zeroId(), newCommit, newPatchSetId.toRefName());
+      } else {
+        // Stale read of patch set is ok; see comments in RebaseChangeOp.
+        PatchSet origPs = args.psUtil.get(ctx.getDb(), toMerge.getNotes(), toMerge.getPatchsetId());
+        rebaseOp =
+            args.rebaseFactory
+                .create(toMerge.notes(), origPs, args.mergeTip.getCurrentTip())
+                .setFireRevisionCreated(false)
+                // Bypass approval copier since SubmitStrategyOp copy all approvals
+                // later anyway.
+                .setCopyApprovals(false)
+                .setValidate(false)
+                .setCheckAddPatchSetPermission(false)
+                // RebaseAlways should set always modify commit message like
+                // Cherry-Pick strategy.
+                .setDetailedCommitMessage(rebaseAlways)
+                // Do not post message after inserting new patchset because there
+                // will be one about change being merged already.
+                .setPostMessage(false)
+                .setMatchAuthorToCommitterDate(
+                    args.project.is(BooleanProjectConfig.MATCH_AUTHOR_TO_COMMITTER_DATE));
+        try {
+          rebaseOp.updateRepo(ctx);
+        } catch (MergeConflictException | NoSuchChangeException e) {
+          toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
+          throw new IntegrationException(
+              "Cannot rebase " + toMerge.name() + ": " + e.getMessage(), e);
+        }
+        newCommit = args.rw.parseCommit(rebaseOp.getRebasedCommit());
+        newPatchSetId = rebaseOp.getPatchSetId();
+      }
+      if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
+          && newCommit.getTree().equals(newCommit.getParent(0).getTree())) {
+        toMerge.setStatusCode(EMPTY_COMMIT);
+        return;
+      }
+      newCommit = amendGitlink(newCommit);
+      newCommit.copyFrom(toMerge);
+      newCommit.setPatchsetId(newPatchSetId);
+      newCommit.setStatusCode(CommitMergeStatus.CLEAN_REBASE);
+      args.mergeTip.moveTipTo(newCommit, newCommit);
+      args.commitStatus.put(args.mergeTip.getCurrentTip());
+      acceptMergeTip(args.mergeTip);
+    }
+
+    @Override
+    public PatchSet updateChangeImpl(ChangeContext ctx)
+        throws NoSuchChangeException, ResourceConflictException, OrmException, IOException {
+      if (newCommit == null) {
+        checkState(!rebaseAlways, "RebaseAlways must never fast forward");
+        // otherwise, took the fast-forward option, nothing to do.
+        return null;
+      }
+
+      PatchSet newPs;
+      if (rebaseOp != null) {
+        rebaseOp.updateChange(ctx);
+        newPs = rebaseOp.getPatchSet();
+      } else {
+        // CherryPick
+        PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
+        newPs =
+            args.psUtil.insert(
+                ctx.getDb(),
+                ctx.getRevWalk(),
+                ctx.getUpdate(newPatchSetId),
+                newPatchSetId,
+                newCommit,
+                prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of(),
+                null,
+                null);
+      }
+      ctx.getChange()
+          .setCurrentPatchSet(
+              args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, newPatchSetId));
+      newCommit.setNotes(ctx.getNotes());
+      return newPs;
+    }
+
+    @Override
+    public void postUpdateImpl(Context ctx) throws OrmException {
+      if (rebaseOp != null) {
+        rebaseOp.postUpdate(ctx);
+      }
+    }
+  }
+
+  private class RebaseMultipleParentsOp extends SubmitStrategyOp {
+    private RebaseMultipleParentsOp(CodeReviewCommit toMerge) {
+      super(RebaseSubmitStrategy.this.args, toMerge);
+    }
+
+    @Override
+    public void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
+      // There are multiple parents, so this is a merge commit. We don't want
+      // to rebase the merge as clients can't easily rebase their history with
+      // that merge present and replaced by an equivalent merge with a different
+      // first parent. So instead behave as though MERGE_IF_NECESSARY was
+      // configured.
+      // TODO(tandrii): this is not in spirit of RebaseAlways strategy because
+      // the commit messages can not be modified in the process. It's also
+      // possible to implement rebasing of merge commits. E.g., the Cherry Pick
+      // REST endpoint already supports cherry-picking of merge commits.
+      // For now, users of RebaseAlways strategy for whom changed commit footers
+      // are important would be well advised to prohibit uploading patches with
+      // merge commits.
+      MergeTip mergeTip = args.mergeTip;
+      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)
+          && !args.submoduleOp.hasSubscription(args.destBranch)) {
+        mergeTip.moveTipTo(toMerge, toMerge);
+      } else {
+        PersonIdent caller =
+            ctx.getIdentifiedUser().newCommitterIdent(ctx.getWhen(), ctx.getTimeZone());
+        CodeReviewCommit newTip =
+            args.mergeUtil.mergeOneCommit(
+                caller,
+                caller,
+                args.rw,
+                ctx.getInserter(),
+                ctx.getRepoView().getConfig(),
+                args.destBranch,
+                mergeTip.getCurrentTip(),
+                toMerge);
+        mergeTip.moveTipTo(amendGitlink(newTip), toMerge);
+      }
+      args.mergeUtil.markCleanMerges(
+          args.rw, args.canMergeFlag, mergeTip.getCurrentTip(), args.alreadyAccepted);
+      acceptMergeTip(mergeTip);
+    }
+  }
+
+  private void acceptMergeTip(MergeTip mergeTip) {
+    args.alreadyAccepted.add(mergeTip.getCurrentTip());
+  }
+
+  static boolean dryRun(
+      SubmitDryRun.Arguments args,
+      Repository repo,
+      CodeReviewCommit mergeTip,
+      CodeReviewCommit toMerge)
+      throws IntegrationException {
+    // Test for merge instead of cherry pick to avoid false negatives
+    // on commit chains.
+    return !args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)
+        && args.mergeUtil.canMerge(args.mergeSorter, repo, mergeTip, toMerge);
+  }
+}
diff --git a/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java b/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
new file mode 100644
index 0000000..585361c
--- /dev/null
+++ b/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.MergeSorter;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevFlag;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Dry run of a submit strategy. */
+public class SubmitDryRun {
+  private static final Logger log = LoggerFactory.getLogger(SubmitDryRun.class);
+
+  static class Arguments {
+    final Repository repo;
+    final CodeReviewRevWalk rw;
+    final MergeUtil mergeUtil;
+    final MergeSorter mergeSorter;
+
+    Arguments(Repository repo, CodeReviewRevWalk rw, MergeUtil mergeUtil, MergeSorter mergeSorter) {
+      this.repo = repo;
+      this.rw = rw;
+      this.mergeUtil = mergeUtil;
+      this.mergeSorter = mergeSorter;
+    }
+  }
+
+  public static Set<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
+    return Streams.concat(
+            repo.getRefDatabase().getRefs(Constants.R_HEADS).values().stream(),
+            repo.getRefDatabase().getRefs(Constants.R_TAGS).values().stream())
+        .map(Ref::getObjectId)
+        .filter(o -> o != null)
+        .collect(toSet());
+  }
+
+  public static Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw) throws IOException {
+    Set<RevCommit> accepted = new HashSet<>();
+    addCommits(getAlreadyAccepted(repo), rw, accepted);
+    return accepted;
+  }
+
+  public static void addCommits(Iterable<ObjectId> ids, RevWalk rw, Collection<RevCommit> out)
+      throws IOException {
+    for (ObjectId id : ids) {
+      RevObject obj = rw.parseAny(id);
+      if (obj instanceof RevTag) {
+        obj = rw.peel(obj);
+      }
+      if (obj instanceof RevCommit) {
+        out.add((RevCommit) obj);
+      }
+    }
+  }
+
+  private final ProjectCache projectCache;
+  private final MergeUtil.Factory mergeUtilFactory;
+
+  @Inject
+  SubmitDryRun(ProjectCache projectCache, MergeUtil.Factory mergeUtilFactory) {
+    this.projectCache = projectCache;
+    this.mergeUtilFactory = mergeUtilFactory;
+  }
+
+  public boolean run(
+      SubmitType submitType,
+      Repository repo,
+      CodeReviewRevWalk rw,
+      Branch.NameKey destBranch,
+      ObjectId tip,
+      ObjectId toMerge,
+      Set<RevCommit> alreadyAccepted)
+      throws IntegrationException, NoSuchProjectException, IOException {
+    CodeReviewCommit tipCommit = rw.parseCommit(tip);
+    CodeReviewCommit toMergeCommit = rw.parseCommit(toMerge);
+    RevFlag canMerge = rw.newFlag("CAN_MERGE");
+    toMergeCommit.add(canMerge);
+    Arguments args =
+        new Arguments(
+            repo,
+            rw,
+            mergeUtilFactory.create(getProject(destBranch)),
+            new MergeSorter(rw, alreadyAccepted, canMerge, ImmutableSet.of(toMergeCommit)));
+
+    switch (submitType) {
+      case CHERRY_PICK:
+        return CherryPick.dryRun(args, tipCommit, toMergeCommit);
+      case FAST_FORWARD_ONLY:
+        return FastForwardOnly.dryRun(args, tipCommit, toMergeCommit);
+      case MERGE_ALWAYS:
+        return MergeAlways.dryRun(args, tipCommit, toMergeCommit);
+      case MERGE_IF_NECESSARY:
+        return MergeIfNecessary.dryRun(args, tipCommit, toMergeCommit);
+      case REBASE_IF_NECESSARY:
+        return RebaseIfNecessary.dryRun(args, repo, tipCommit, toMergeCommit);
+      case REBASE_ALWAYS:
+        return RebaseAlways.dryRun(args, repo, tipCommit, toMergeCommit);
+      case INHERIT:
+      default:
+        String errorMsg = "No submit strategy for: " + submitType;
+        log.error(errorMsg);
+        throw new IntegrationException(errorMsg);
+    }
+  }
+
+  private ProjectState getProject(Branch.NameKey branch) throws NoSuchProjectException {
+    ProjectState p = projectCache.get(branch.getParentKey());
+    if (p == null) {
+      throw new NoSuchProjectException(branch.getParentKey());
+    }
+    return p;
+  }
+}
diff --git a/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java b/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
new file mode 100644
index 0000000..5a5a751
--- /dev/null
+++ b/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
@@ -0,0 +1,272 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Sets;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.change.TestSubmitInput;
+import com.google.gerrit.server.extensions.events.ChangeMerged;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.EmailMerge;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.LabelNormalizer;
+import com.google.gerrit.server.git.MergeOp.CommitStatus;
+import com.google.gerrit.server.git.MergeSorter;
+import com.google.gerrit.server.git.MergeTip;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.RebaseSorter;
+import com.google.gerrit.server.git.SubmoduleOp;
+import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.git.validators.OnSubmitValidators;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.RequestId;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevFlag;
+
+/**
+ * Base class that submit strategies must extend.
+ *
+ * <p>A submit strategy for a certain {@link SubmitType} defines how the submitted commits should be
+ * merged.
+ */
+public abstract class SubmitStrategy {
+  public static Module module() {
+    return new FactoryModule() {
+      @Override
+      protected void configure() {
+        factory(SubmitStrategy.Arguments.Factory.class);
+      }
+    };
+  }
+
+  static class Arguments {
+    interface Factory {
+      Arguments create(
+          SubmitType submitType,
+          Branch.NameKey destBranch,
+          CommitStatus commitStatus,
+          CodeReviewRevWalk rw,
+          IdentifiedUser caller,
+          MergeTip mergeTip,
+          RevFlag canMergeFlag,
+          ReviewDb db,
+          Set<RevCommit> alreadyAccepted,
+          Set<CodeReviewCommit> incoming,
+          RequestId submissionId,
+          SubmitInput submitInput,
+          ListMultimap<RecipientType, Account.Id> accountsToNotify,
+          SubmoduleOp submoduleOp,
+          boolean dryrun);
+    }
+
+    final AccountCache accountCache;
+    final ApprovalsUtil approvalsUtil;
+    final ChangeMerged changeMerged;
+    final ChangeMessagesUtil cmUtil;
+    final EmailMerge.Factory mergedSenderFactory;
+    final GitRepositoryManager repoManager;
+    final LabelNormalizer labelNormalizer;
+    final PatchSetInfoFactory patchSetInfoFactory;
+    final PatchSetUtil psUtil;
+    final ProjectCache projectCache;
+    final PersonIdent serverIdent;
+    final RebaseChangeOp.Factory rebaseFactory;
+    final OnSubmitValidators.Factory onSubmitValidatorsFactory;
+    final TagCache tagCache;
+    final Provider<InternalChangeQuery> queryProvider;
+
+    final Branch.NameKey destBranch;
+    final CodeReviewRevWalk rw;
+    final CommitStatus commitStatus;
+    final IdentifiedUser caller;
+    final MergeTip mergeTip;
+    final RevFlag canMergeFlag;
+    final ReviewDb db;
+    final Set<RevCommit> alreadyAccepted;
+    final RequestId submissionId;
+    final SubmitType submitType;
+    final SubmitInput submitInput;
+    final ListMultimap<RecipientType, Account.Id> accountsToNotify;
+    final SubmoduleOp submoduleOp;
+
+    final ProjectState project;
+    final MergeSorter mergeSorter;
+    final RebaseSorter rebaseSorter;
+    final MergeUtil mergeUtil;
+    final boolean dryrun;
+
+    @Inject
+    Arguments(
+        AccountCache accountCache,
+        ApprovalsUtil approvalsUtil,
+        ChangeMerged changeMerged,
+        ChangeMessagesUtil cmUtil,
+        EmailMerge.Factory mergedSenderFactory,
+        GitRepositoryManager repoManager,
+        LabelNormalizer labelNormalizer,
+        MergeUtil.Factory mergeUtilFactory,
+        PatchSetInfoFactory patchSetInfoFactory,
+        PatchSetUtil psUtil,
+        @GerritPersonIdent PersonIdent serverIdent,
+        ProjectCache projectCache,
+        RebaseChangeOp.Factory rebaseFactory,
+        OnSubmitValidators.Factory onSubmitValidatorsFactory,
+        TagCache tagCache,
+        Provider<InternalChangeQuery> queryProvider,
+        @Assisted Branch.NameKey destBranch,
+        @Assisted CommitStatus commitStatus,
+        @Assisted CodeReviewRevWalk rw,
+        @Assisted IdentifiedUser caller,
+        @Assisted MergeTip mergeTip,
+        @Assisted RevFlag canMergeFlag,
+        @Assisted ReviewDb db,
+        @Assisted Set<RevCommit> alreadyAccepted,
+        @Assisted Set<CodeReviewCommit> incoming,
+        @Assisted RequestId submissionId,
+        @Assisted SubmitType submitType,
+        @Assisted SubmitInput submitInput,
+        @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify,
+        @Assisted SubmoduleOp submoduleOp,
+        @Assisted boolean dryrun) {
+      this.accountCache = accountCache;
+      this.approvalsUtil = approvalsUtil;
+      this.changeMerged = changeMerged;
+      this.mergedSenderFactory = mergedSenderFactory;
+      this.repoManager = repoManager;
+      this.cmUtil = cmUtil;
+      this.labelNormalizer = labelNormalizer;
+      this.patchSetInfoFactory = patchSetInfoFactory;
+      this.psUtil = psUtil;
+      this.projectCache = projectCache;
+      this.rebaseFactory = rebaseFactory;
+      this.tagCache = tagCache;
+      this.queryProvider = queryProvider;
+
+      this.serverIdent = serverIdent;
+      this.destBranch = destBranch;
+      this.commitStatus = commitStatus;
+      this.rw = rw;
+      this.caller = caller;
+      this.mergeTip = mergeTip;
+      this.canMergeFlag = canMergeFlag;
+      this.db = db;
+      this.alreadyAccepted = alreadyAccepted;
+      this.submissionId = submissionId;
+      this.submitType = submitType;
+      this.submitInput = submitInput;
+      this.accountsToNotify = accountsToNotify;
+      this.submoduleOp = submoduleOp;
+      this.dryrun = dryrun;
+
+      this.project =
+          checkNotNull(
+              projectCache.get(destBranch.getParentKey()),
+              "project not found: %s",
+              destBranch.getParentKey());
+      this.mergeSorter = new MergeSorter(rw, alreadyAccepted, canMergeFlag, incoming);
+      this.rebaseSorter =
+          new RebaseSorter(
+              rw, mergeTip.getInitialTip(), alreadyAccepted, canMergeFlag, queryProvider, incoming);
+      this.mergeUtil = mergeUtilFactory.create(project);
+      this.onSubmitValidatorsFactory = onSubmitValidatorsFactory;
+    }
+  }
+
+  final Arguments args;
+
+  SubmitStrategy(Arguments args) {
+    this.args = checkNotNull(args);
+  }
+
+  /**
+   * Add operations to a batch update that execute this submit strategy.
+   *
+   * <p>Guarantees exactly one op is added to the update for each change in the input set.
+   *
+   * @param bu batch update to add operations to.
+   * @param toMerge the set of submitted commits that should be merged using this submit strategy.
+   *     Implementations are responsible for ordering of commits, and will not modify the input in
+   *     place.
+   * @throws IntegrationException if an error occurred initializing the operations (as opposed to an
+   *     error during execution, which will be reported only when the batch update executes the
+   *     operations).
+   */
+  public final void addOps(BatchUpdate bu, Set<CodeReviewCommit> toMerge)
+      throws IntegrationException {
+    List<SubmitStrategyOp> ops = buildOps(toMerge);
+    Set<CodeReviewCommit> added = Sets.newHashSetWithExpectedSize(ops.size());
+
+    for (SubmitStrategyOp op : ops) {
+      added.add(op.getCommit());
+    }
+
+    // First add ops for any implicitly merged changes.
+    List<CodeReviewCommit> difference = new ArrayList<>(Sets.difference(toMerge, added));
+    Collections.reverse(difference);
+    for (CodeReviewCommit c : difference) {
+      Change.Id id = c.change().getId();
+      bu.addOp(id, new ImplicitIntegrateOp(args, c));
+      maybeAddTestHelperOp(bu, id);
+    }
+
+    // Then ops for explicitly merged changes
+    for (SubmitStrategyOp op : ops) {
+      bu.addOp(op.getId(), op);
+      maybeAddTestHelperOp(bu, op.getId());
+    }
+  }
+
+  private void maybeAddTestHelperOp(BatchUpdate bu, Change.Id changeId) {
+    if (args.submitInput instanceof TestSubmitInput) {
+      bu.addOp(changeId, new TestHelperOp(changeId, args));
+    }
+  }
+
+  protected abstract List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
+      throws IntegrationException;
+}
diff --git a/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java b/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
new file mode 100644
index 0000000..8600322
--- /dev/null
+++ b/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.MergeOp.CommitStatus;
+import com.google.gerrit.server.git.MergeTip;
+import com.google.gerrit.server.git.SubmoduleOp;
+import com.google.gerrit.server.util.RequestId;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Set;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevFlag;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Factory to create a {@link SubmitStrategy} for a {@link SubmitType}. */
+@Singleton
+public class SubmitStrategyFactory {
+  private static final Logger log = LoggerFactory.getLogger(SubmitStrategyFactory.class);
+
+  private final SubmitStrategy.Arguments.Factory argsFactory;
+
+  @Inject
+  SubmitStrategyFactory(SubmitStrategy.Arguments.Factory argsFactory) {
+    this.argsFactory = argsFactory;
+  }
+
+  public SubmitStrategy create(
+      SubmitType submitType,
+      ReviewDb db,
+      CodeReviewRevWalk rw,
+      RevFlag canMergeFlag,
+      Set<RevCommit> alreadyAccepted,
+      Set<CodeReviewCommit> incoming,
+      Branch.NameKey destBranch,
+      IdentifiedUser caller,
+      MergeTip mergeTip,
+      CommitStatus commitStatus,
+      RequestId submissionId,
+      SubmitInput submitInput,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      SubmoduleOp submoduleOp,
+      boolean dryrun)
+      throws IntegrationException {
+    SubmitStrategy.Arguments args =
+        argsFactory.create(
+            submitType,
+            destBranch,
+            commitStatus,
+            rw,
+            caller,
+            mergeTip,
+            canMergeFlag,
+            db,
+            alreadyAccepted,
+            incoming,
+            submissionId,
+            submitInput,
+            accountsToNotify,
+            submoduleOp,
+            dryrun);
+    switch (submitType) {
+      case CHERRY_PICK:
+        return new CherryPick(args);
+      case FAST_FORWARD_ONLY:
+        return new FastForwardOnly(args);
+      case MERGE_ALWAYS:
+        return new MergeAlways(args);
+      case MERGE_IF_NECESSARY:
+        return new MergeIfNecessary(args);
+      case REBASE_IF_NECESSARY:
+        return new RebaseIfNecessary(args);
+      case REBASE_ALWAYS:
+        return new RebaseAlways(args);
+      case INHERIT:
+      default:
+        String errorMsg = "No submit strategy for: " + submitType;
+        log.error(errorMsg);
+        throw new IntegrationException(errorMsg);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java b/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java
new file mode 100644
index 0000000..271e392
--- /dev/null
+++ b/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.change.TestSubmitInput;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.MergeOp.CommitStatus;
+import com.google.gerrit.server.update.BatchUpdateListener;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+public class SubmitStrategyListener implements BatchUpdateListener {
+  private final Collection<SubmitStrategy> strategies;
+  private final CommitStatus commitStatus;
+  private final boolean failAfterRefUpdates;
+
+  public SubmitStrategyListener(
+      SubmitInput input, Collection<SubmitStrategy> strategies, CommitStatus commitStatus) {
+    this.strategies = strategies;
+    this.commitStatus = commitStatus;
+    if (input instanceof TestSubmitInput) {
+      failAfterRefUpdates = ((TestSubmitInput) input).failAfterRefUpdates;
+    } else {
+      failAfterRefUpdates = false;
+    }
+  }
+
+  @Override
+  public void afterUpdateRepos() throws ResourceConflictException {
+    try {
+      markCleanMerges();
+      List<Change.Id> alreadyMerged = checkCommitStatus();
+      findUnmergedChanges(alreadyMerged);
+    } catch (IntegrationException e) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    }
+  }
+
+  @Override
+  public void afterUpdateRefs() throws ResourceConflictException {
+    if (failAfterRefUpdates) {
+      throw new ResourceConflictException("Failing after ref updates");
+    }
+  }
+
+  private void findUnmergedChanges(List<Change.Id> alreadyMerged)
+      throws ResourceConflictException, IntegrationException {
+    for (SubmitStrategy strategy : strategies) {
+      if (strategy instanceof CherryPick) {
+        // Can't do this sanity check for CherryPick since:
+        // * CherryPick might have picked a subset of changes
+        // * CherryPick might have status SKIPPED_IDENTICAL_TREE
+        continue;
+      }
+      SubmitStrategy.Arguments args = strategy.args;
+      Set<Change.Id> unmerged =
+          args.mergeUtil.findUnmergedChanges(
+              args.commitStatus.getChangeIds(args.destBranch),
+              args.rw,
+              args.canMergeFlag,
+              args.mergeTip.getInitialTip(),
+              args.mergeTip.getCurrentTip(),
+              alreadyMerged);
+      for (Change.Id id : unmerged) {
+        commitStatus.problem(id, "internal error: change not reachable from new branch tip");
+      }
+    }
+    commitStatus.maybeFailVerbose();
+  }
+
+  private void markCleanMerges() throws IntegrationException {
+    for (SubmitStrategy strategy : strategies) {
+      SubmitStrategy.Arguments args = strategy.args;
+      RevCommit initialTip = args.mergeTip.getInitialTip();
+      args.mergeUtil.markCleanMerges(
+          args.rw,
+          args.canMergeFlag,
+          args.mergeTip.getCurrentTip(),
+          initialTip == null ? ImmutableSet.<RevCommit>of() : ImmutableSet.of(initialTip));
+    }
+  }
+
+  private List<Change.Id> checkCommitStatus() throws ResourceConflictException {
+    List<Change.Id> alreadyMerged = new ArrayList<>(commitStatus.getChangeIds().size());
+    for (Change.Id id : commitStatus.getChangeIds()) {
+      CodeReviewCommit commit = commitStatus.get(id);
+      CommitMergeStatus s = commit != null ? commit.getStatusCode() : null;
+      if (s == null) {
+        commitStatus.problem(id, "internal error: change not processed by merge strategy");
+        continue;
+      }
+      switch (s) {
+        case CLEAN_MERGE:
+        case CLEAN_REBASE:
+        case CLEAN_PICK:
+        case SKIPPED_IDENTICAL_TREE:
+          break; // Merge strategy accepted this change.
+
+        case ALREADY_MERGED:
+          // Already an ancestor of tip.
+          alreadyMerged.add(commit.getPatchsetId().getParentKey());
+          break;
+
+        case PATH_CONFLICT:
+        case REBASE_MERGE_CONFLICT:
+        case MANUAL_RECURSIVE_MERGE:
+        case CANNOT_CHERRY_PICK_ROOT:
+        case CANNOT_REBASE_ROOT:
+        case NOT_FAST_FORWARD:
+        case EMPTY_COMMIT:
+          // TODO(dborowitz): Reformat these messages to be more appropriate for
+          // short problem descriptions.
+          commitStatus.problem(id, CharMatcher.is('\n').collapseFrom(s.getMessage(), ' '));
+          break;
+
+        case MISSING_DEPENDENCY:
+          commitStatus.problem(id, "depends on change that was not submitted");
+          break;
+
+        default:
+          commitStatus.problem(id, "unspecified merge failure: " + s);
+          break;
+      }
+    }
+    commitStatus.maybeFailVerbose();
+    return alreadyMerged;
+  }
+
+  @Override
+  public void afterUpdateChanges() throws ResourceConflictException {
+    commitStatus.maybeFail("Error updating status");
+  }
+}
diff --git a/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java b/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
new file mode 100644
index 0000000..bd095ef
--- /dev/null
+++ b/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
@@ -0,0 +1,627 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+
+import com.google.common.base.Function;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.GroupCollector;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.LabelNormalizer;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.SubmoduleException;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+abstract class SubmitStrategyOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(SubmitStrategyOp.class);
+
+  protected final SubmitStrategy.Arguments args;
+  protected final CodeReviewCommit toMerge;
+
+  private ReceiveCommand command;
+  private PatchSetApproval submitter;
+  private ObjectId mergeResultRev;
+  private PatchSet mergedPatchSet;
+  private Change updatedChange;
+  private CodeReviewCommit alreadyMergedCommit;
+  private boolean changeAlreadyMerged;
+
+  protected SubmitStrategyOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
+    this.args = args;
+    this.toMerge = toMerge;
+  }
+
+  final Change.Id getId() {
+    return toMerge.change().getId();
+  }
+
+  final CodeReviewCommit getCommit() {
+    return toMerge;
+  }
+
+  protected final Branch.NameKey getDest() {
+    return toMerge.change().getDest();
+  }
+
+  protected final Project.NameKey getProject() {
+    return getDest().getParentKey();
+  }
+
+  @Override
+  public final void updateRepo(RepoContext ctx) throws Exception {
+    logDebug("{}#updateRepo for change {}", getClass().getSimpleName(), toMerge.change().getId());
+    checkState(
+        ctx.getRevWalk() == args.rw,
+        "SubmitStrategyOp requires callers to call BatchUpdate#setRepository with exactly the same"
+            + " CodeReviewRevWalk instance from the SubmitStrategy.Arguments: %s != %s",
+        ctx.getRevWalk(),
+        args.rw);
+    // Run the submit strategy implementation and record the merge tip state so
+    // we can create the ref update.
+    CodeReviewCommit tipBefore = args.mergeTip.getCurrentTip();
+    alreadyMergedCommit = getAlreadyMergedCommit(ctx);
+    if (alreadyMergedCommit == null) {
+      updateRepoImpl(ctx);
+    } else {
+      logDebug("Already merged as {}", alreadyMergedCommit.name());
+    }
+    CodeReviewCommit tipAfter = args.mergeTip.getCurrentTip();
+
+    if (Objects.equals(tipBefore, tipAfter)) {
+      logDebug("Did not move tip", getClass().getSimpleName());
+      return;
+    } else if (tipAfter == null) {
+      logDebug("No merge tip, no update to perform");
+      return;
+    }
+    logDebug("Moved tip from {} to {}", tipBefore, tipAfter);
+
+    checkProjectConfig(ctx, tipAfter);
+
+    // Needed by postUpdate, at which point mergeTip will have advanced further,
+    // so it's easier to just snapshot the command.
+    command =
+        new ReceiveCommand(firstNonNull(tipBefore, ObjectId.zeroId()), tipAfter, getDest().get());
+    ctx.addRefUpdate(command);
+    args.submoduleOp.addBranchTip(getDest(), tipAfter);
+  }
+
+  private void checkProjectConfig(RepoContext ctx, CodeReviewCommit commit)
+      throws IntegrationException {
+    String refName = getDest().get();
+    if (RefNames.REFS_CONFIG.equals(refName)) {
+      logDebug("Loading new configuration from {}", RefNames.REFS_CONFIG);
+      try {
+        ProjectConfig cfg = new ProjectConfig(getProject());
+        cfg.load(ctx.getRevWalk(), commit);
+      } catch (Exception e) {
+        throw new IntegrationException(
+            "Submit would store invalid"
+                + " project configuration "
+                + commit.name()
+                + " for "
+                + getProject(),
+            e);
+      }
+    }
+  }
+
+  private CodeReviewCommit getAlreadyMergedCommit(RepoContext ctx) throws IOException {
+    CodeReviewCommit tip = args.mergeTip.getInitialTip();
+    if (tip == null) {
+      return null;
+    }
+    CodeReviewRevWalk rw = (CodeReviewRevWalk) ctx.getRevWalk();
+    Change.Id id = getId();
+    String refPrefix = id.toRefPrefix();
+
+    Map<String, ObjectId> refs = ctx.getRepoView().getRefs(refPrefix);
+    List<CodeReviewCommit> commits = new ArrayList<>(refs.size());
+    for (Map.Entry<String, ObjectId> e : refs.entrySet()) {
+      PatchSet.Id psId = PatchSet.Id.fromRef(refPrefix + e.getKey());
+      if (psId == null) {
+        continue;
+      }
+      try {
+        CodeReviewCommit c = rw.parseCommit(e.getValue());
+        c.setPatchsetId(psId);
+        commits.add(c);
+      } catch (MissingObjectException | IncorrectObjectTypeException ex) {
+        continue; // Bogus ref, can't be merged into tip so we don't care.
+      }
+    }
+    Collections.sort(
+        commits, ReviewDbUtil.intKeyOrdering().reverse().onResultOf(c -> c.getPatchsetId()));
+    CodeReviewCommit result = MergeUtil.findAnyMergedInto(rw, commits, tip);
+    if (result == null) {
+      return null;
+    }
+
+    // Some patch set of this change is actually merged into the target
+    // branch, most likely because a previous run of MergeOp failed after
+    // updateRepo, during updateChange.
+    //
+    // Do the best we can to clean this up: mark the change as merged and set
+    // the current patch set. Don't touch the dest branch at all. This can
+    // lead to some odd situations like another change in the set merging in
+    // a different patch set of this change, but that's unavoidable at this
+    // point.  At least the change will end up in the right state.
+    //
+    // TODO(dborowitz): Consider deleting later junk patch set refs. They
+    // presumably don't have PatchSets pointing to them.
+    rw.parseBody(result);
+    result.add(args.canMergeFlag);
+    PatchSet.Id psId = result.getPatchsetId();
+    result.copyFrom(toMerge);
+    result.setPatchsetId(psId); // Got overwriten by copyFrom.
+    result.setStatusCode(CommitMergeStatus.ALREADY_MERGED);
+    args.commitStatus.put(result);
+    return result;
+  }
+
+  @Override
+  public final boolean updateChange(ChangeContext ctx) throws Exception {
+    logDebug("{}#updateChange for change {}", getClass().getSimpleName(), toMerge.change().getId());
+    toMerge.setNotes(ctx.getNotes()); // Update change and notes from ctx.
+    PatchSet.Id oldPsId = checkNotNull(toMerge.getPatchsetId());
+    PatchSet.Id newPsId;
+
+    if (ctx.getChange().getStatus() == Change.Status.MERGED) {
+      // Either another thread won a race, or we are retrying a whole topic submission after one
+      // repo failed with lock failure.
+      if (alreadyMergedCommit == null) {
+        logDebug(
+            "Change is already merged according to its status, but we were unable to find it"
+                + " merged into the current tip ({})",
+            args.mergeTip.getCurrentTip().name());
+      } else {
+        logDebug("Change is already merged");
+      }
+      changeAlreadyMerged = true;
+      return false;
+    }
+
+    if (alreadyMergedCommit != null) {
+      alreadyMergedCommit.setNotes(ctx.getNotes());
+      mergedPatchSet = getOrCreateAlreadyMergedPatchSet(ctx);
+      newPsId = mergedPatchSet.getId();
+    } else {
+      PatchSet newPatchSet = updateChangeImpl(ctx);
+      newPsId = checkNotNull(ctx.getChange().currentPatchSetId());
+      if (newPatchSet == null) {
+        checkState(
+            oldPsId.equals(newPsId),
+            "patch set advanced from %s to %s but updateChangeImpl did not"
+                + " return new patch set instance",
+            oldPsId,
+            newPsId);
+        // Ok to use stale notes to get the old patch set, which didn't change
+        // during the submit strategy.
+        mergedPatchSet =
+            checkNotNull(
+                args.psUtil.get(ctx.getDb(), ctx.getNotes(), oldPsId),
+                "missing old patch set %s",
+                oldPsId);
+      } else {
+        PatchSet.Id n = newPatchSet.getId();
+        checkState(
+            !n.equals(oldPsId) && n.equals(newPsId),
+            "current patch was %s and is now %s, but updateChangeImpl returned"
+                + " new patch set instance at %s",
+            oldPsId,
+            newPsId,
+            n);
+        mergedPatchSet = newPatchSet;
+      }
+    }
+
+    Change c = ctx.getChange();
+    Change.Id id = c.getId();
+    CodeReviewCommit commit = args.commitStatus.get(id);
+    checkNotNull(commit, "missing commit for change " + id);
+    CommitMergeStatus s = commit.getStatusCode();
+    checkNotNull(s, "status not set for change " + id + " expected to previously fail fast");
+    logDebug("Status of change {} ({}) on {}: {}", id, commit.name(), c.getDest(), s);
+    setApproval(ctx, args.caller);
+
+    mergeResultRev =
+        alreadyMergedCommit == null
+            ? args.mergeTip.getMergeResults().get(commit)
+            // Our fixup code is not smart enough to find a merge commit
+            // corresponding to the merge result. This results in a different
+            // ChangeMergedEvent in the fixup case, but we'll just live with that.
+            : alreadyMergedCommit;
+    try {
+      setMerged(ctx, message(ctx, commit, s));
+    } catch (OrmException err) {
+      String msg = "Error updating change status for " + id;
+      log.error(msg, err);
+      args.commitStatus.logProblem(id, msg);
+      // It's possible this happened before updating anything in the db, but
+      // it's hard to know for sure, so just return true below to be safe.
+    }
+    updatedChange = c;
+    return true;
+  }
+
+  private PatchSet getOrCreateAlreadyMergedPatchSet(ChangeContext ctx)
+      throws IOException, OrmException {
+    PatchSet.Id psId = alreadyMergedCommit.getPatchsetId();
+    logDebug("Fixing up already-merged patch set {}", psId);
+    PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
+    ctx.getRevWalk().parseBody(alreadyMergedCommit);
+    ctx.getChange()
+        .setCurrentPatchSet(
+            psId, alreadyMergedCommit.getShortMessage(), ctx.getChange().getOriginalSubject());
+    PatchSet existing = args.psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+    if (existing != null) {
+      logDebug("Patch set row exists, only updating change");
+      return existing;
+    }
+    // No patch set for the already merged commit, although we know it came form
+    // a patch set ref. Fix up the database. Note that this uses the current
+    // user as the uploader, which is as good a guess as any.
+    List<String> groups =
+        prevPs != null ? prevPs.getGroups() : GroupCollector.getDefaultGroups(alreadyMergedCommit);
+    return args.psUtil.insert(
+        ctx.getDb(),
+        ctx.getRevWalk(),
+        ctx.getUpdate(psId),
+        psId,
+        alreadyMergedCommit,
+        groups,
+        null,
+        null);
+  }
+
+  private void setApproval(ChangeContext ctx, IdentifiedUser user)
+      throws OrmException, IOException {
+    Change.Id id = ctx.getChange().getId();
+    List<SubmitRecord> records = args.commitStatus.getSubmitRecords(id);
+    PatchSet.Id oldPsId = toMerge.getPatchsetId();
+    PatchSet.Id newPsId = ctx.getChange().currentPatchSetId();
+
+    logDebug("Add approval for " + id);
+    ChangeUpdate origPsUpdate = ctx.getUpdate(oldPsId);
+    origPsUpdate.putReviewer(user.getAccountId(), REVIEWER);
+    LabelNormalizer.Result normalized = approve(ctx, origPsUpdate);
+
+    ChangeUpdate newPsUpdate = ctx.getUpdate(newPsId);
+    newPsUpdate.merge(args.submissionId, records);
+    // If the submit strategy created a new revision (rebase, cherry-pick), copy
+    // approvals as well.
+    if (!newPsId.equals(oldPsId)) {
+      saveApprovals(normalized, ctx, newPsUpdate, true);
+      submitter = convertPatchSet(newPsId).apply(submitter);
+    }
+  }
+
+  private LabelNormalizer.Result approve(ChangeContext ctx, ChangeUpdate update)
+      throws OrmException, IOException {
+    PatchSet.Id psId = update.getPatchSetId();
+    Map<PatchSetApproval.Key, PatchSetApproval> byKey = new HashMap<>();
+    for (PatchSetApproval psa :
+        args.approvalsUtil.byPatchSet(
+            ctx.getDb(),
+            ctx.getNotes(),
+            ctx.getUser(),
+            psId,
+            ctx.getRevWalk(),
+            ctx.getRepoView().getConfig())) {
+      byKey.put(psa.getKey(), psa);
+    }
+
+    submitter =
+        ApprovalsUtil.newApproval(psId, ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen());
+    byKey.put(submitter.getKey(), submitter);
+
+    // Flatten out existing approvals for this patch set based upon the current
+    // permissions. Once the change is closed the approvals are not updated at
+    // presentation view time, except for zero votes used to indicate a reviewer
+    // was added. So we need to make sure votes are accurate now. This way if
+    // permissions get modified in the future, historical records stay accurate.
+    LabelNormalizer.Result normalized =
+        args.labelNormalizer.normalize(ctx.getNotes(), ctx.getUser(), byKey.values());
+    update.putApproval(submitter.getLabel(), submitter.getValue());
+    saveApprovals(normalized, ctx, update, false);
+    return normalized;
+  }
+
+  private void saveApprovals(
+      LabelNormalizer.Result normalized,
+      ChangeContext ctx,
+      ChangeUpdate update,
+      boolean includeUnchanged)
+      throws OrmException {
+    PatchSet.Id psId = update.getPatchSetId();
+    ctx.getDb().patchSetApprovals().upsert(convertPatchSet(normalized.getNormalized(), psId));
+    ctx.getDb().patchSetApprovals().upsert(zero(convertPatchSet(normalized.deleted(), psId)));
+    for (PatchSetApproval psa : normalized.updated()) {
+      update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
+    }
+    for (PatchSetApproval psa : normalized.deleted()) {
+      update.removeApprovalFor(psa.getAccountId(), psa.getLabel());
+    }
+
+    // TODO(dborowitz): Don't use a label in NoteDb; just check when status
+    // change happened.
+    for (PatchSetApproval psa : normalized.unchanged()) {
+      if (includeUnchanged || psa.isLegacySubmit()) {
+        logDebug("Adding submit label " + psa);
+        update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
+      }
+    }
+  }
+
+  private static Function<PatchSetApproval, PatchSetApproval> convertPatchSet(
+      final PatchSet.Id psId) {
+    return psa -> {
+      if (psa.getPatchSetId().equals(psId)) {
+        return psa;
+      }
+      return new PatchSetApproval(psId, psa);
+    };
+  }
+
+  private static Iterable<PatchSetApproval> convertPatchSet(
+      Iterable<PatchSetApproval> approvals, PatchSet.Id psId) {
+    return Iterables.transform(approvals, convertPatchSet(psId));
+  }
+
+  private static Iterable<PatchSetApproval> zero(Iterable<PatchSetApproval> approvals) {
+    return Iterables.transform(
+        approvals,
+        a -> {
+          PatchSetApproval copy = new PatchSetApproval(a.getPatchSetId(), a);
+          copy.setValue((short) 0);
+          return copy;
+        });
+  }
+
+  private String getByAccountName() {
+    checkNotNull(submitter, "getByAccountName called before submitter populated");
+    Account account = args.accountCache.get(submitter.getAccountId()).getAccount();
+    if (account != null && account.getFullName() != null) {
+      return " by " + account.getFullName();
+    }
+    return "";
+  }
+
+  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s)
+      throws OrmException {
+    checkNotNull(s, "CommitMergeStatus may not be null");
+    String txt = s.getMessage();
+    if (s == CommitMergeStatus.CLEAN_MERGE) {
+      return message(ctx, commit.getPatchsetId(), txt + getByAccountName());
+    } else if (s == CommitMergeStatus.CLEAN_REBASE || s == CommitMergeStatus.CLEAN_PICK) {
+      return message(
+          ctx, commit.getPatchsetId(), txt + " as " + commit.name() + getByAccountName());
+    } else if (s == CommitMergeStatus.SKIPPED_IDENTICAL_TREE) {
+      return message(ctx, commit.getPatchsetId(), txt);
+    } else if (s == CommitMergeStatus.ALREADY_MERGED) {
+      // Best effort to mimic the message that would have happened had this
+      // succeeded the first time around.
+      switch (args.submitType) {
+        case FAST_FORWARD_ONLY:
+        case MERGE_ALWAYS:
+        case MERGE_IF_NECESSARY:
+          return message(ctx, commit, CommitMergeStatus.CLEAN_MERGE);
+        case CHERRY_PICK:
+          return message(ctx, commit, CommitMergeStatus.CLEAN_PICK);
+        case REBASE_IF_NECESSARY:
+        case REBASE_ALWAYS:
+          return message(ctx, commit, CommitMergeStatus.CLEAN_REBASE);
+        case INHERIT:
+        default:
+          throw new IllegalStateException(
+              "unexpected submit type "
+                  + args.submitType.toString()
+                  + " for change "
+                  + commit.change().getId());
+      }
+    } else {
+      throw new IllegalStateException(
+          "unexpected status "
+              + s
+              + " for change "
+              + commit.change().getId()
+              + "; expected to previously fail fast");
+    }
+  }
+
+  private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId, String body) {
+    return ChangeMessagesUtil.newMessage(
+        psId, ctx.getUser(), ctx.getWhen(), body, ChangeMessagesUtil.TAG_MERGED);
+  }
+
+  private void setMerged(ChangeContext ctx, ChangeMessage msg) throws OrmException {
+    Change c = ctx.getChange();
+    ReviewDb db = ctx.getDb();
+    logDebug("Setting change {} merged", c.getId());
+    c.setStatus(Change.Status.MERGED);
+    c.setSubmissionId(args.submissionId.toStringForStorage());
+
+    // TODO(dborowitz): We need to be able to change the author of the message,
+    // which is not the user from the update context. addMergedMessage was able
+    // to do this in the past.
+    if (msg != null) {
+      args.cmUtil.addChangeMessage(db, ctx.getUpdate(msg.getPatchSetId()), msg);
+    }
+  }
+
+  @Override
+  public final void postUpdate(Context ctx) throws Exception {
+    if (changeAlreadyMerged) {
+      // TODO(dborowitz): This is suboptimal behavior in the presence of retries: postUpdate steps
+      // will never get run for changes that submitted successfully on any but the final attempt.
+      // This is primarily a temporary workaround for the fact that the submitter field is not
+      // populated in the changeAlreadyMerged case.
+      //
+      // If we naively execute postUpdate even if the change is already merged when updateChange
+      // being, then we are subject to a race where postUpdate steps are run twice if two submit
+      // processes run at the same time.
+      logDebug("Skipping post-update steps for change {}", getId());
+      return;
+    }
+    postUpdateImpl(ctx);
+
+    if (command != null) {
+      args.tagCache.updateFastForward(
+          getProject(), command.getRefName(), command.getOldId(), command.getNewId());
+      // TODO(dborowitz): Move to BatchUpdate? Would also allow us to run once
+      // per project even if multiple changes to refs/meta/config are submitted.
+      if (RefNames.REFS_CONFIG.equals(getDest().get())) {
+        args.projectCache.evict(getProject());
+        ProjectState p = args.projectCache.get(getProject());
+        try (Repository git = args.repoManager.openRepository(getProject())) {
+          git.setGitwebDescription(p.getProject().getDescription());
+        } catch (IOException e) {
+          log.error("cannot update description of " + p.getName(), e);
+        }
+      }
+    }
+
+    // Assume the change must have been merged at this point, otherwise we would
+    // have failed fast in one of the other steps.
+    try {
+      args.mergedSenderFactory
+          .create(
+              ctx.getProject(),
+              getId(),
+              submitter.getAccountId(),
+              args.submitInput.notify,
+              args.accountsToNotify)
+          .sendAsync();
+    } catch (Exception e) {
+      log.error("Cannot email merged notification for " + getId(), e);
+    }
+    if (mergeResultRev != null && !args.dryrun) {
+      args.changeMerged.fire(
+          updatedChange,
+          mergedPatchSet,
+          args.accountCache.get(submitter.getAccountId()).getAccount(),
+          args.mergeTip.getCurrentTip().name(),
+          ctx.getWhen());
+    }
+  }
+
+  /**
+   * @see #updateRepo(RepoContext)
+   * @param ctx
+   */
+  protected void updateRepoImpl(RepoContext ctx) throws Exception {}
+
+  /**
+   * @see #updateChange(ChangeContext)
+   * @param ctx
+   * @return a new patch set if one was created by the submit strategy, or null if not.
+   */
+  protected PatchSet updateChangeImpl(ChangeContext ctx) throws Exception {
+    return null;
+  }
+
+  /**
+   * @see #postUpdate(Context)
+   * @param ctx
+   */
+  protected void postUpdateImpl(Context ctx) throws Exception {}
+
+  /**
+   * Amend the commit with gitlink update
+   *
+   * @param commit
+   */
+  protected CodeReviewCommit amendGitlink(CodeReviewCommit commit) throws IntegrationException {
+    if (!args.submoduleOp.hasSubscription(args.destBranch)) {
+      return commit;
+    }
+
+    // Modify the commit with gitlink update
+    try {
+      return args.submoduleOp.composeGitlinksCommit(args.destBranch, commit);
+    } catch (SubmoduleException | IOException e) {
+      throw new IntegrationException(
+          "cannot update gitlink for the commit at branch: " + args.destBranch);
+    }
+  }
+
+  protected final void logDebug(String msg, Object... args) {
+    if (log.isDebugEnabled()) {
+      log.debug(this.args.submissionId + msg, args);
+    }
+  }
+
+  protected final void logWarn(String msg, Throwable t) {
+    if (log.isWarnEnabled()) {
+      log.warn(args.submissionId + msg, t);
+    }
+  }
+
+  protected void logError(String msg, Throwable t) {
+    if (log.isErrorEnabled()) {
+      if (t != null) {
+        log.error(args.submissionId + msg, t);
+      } else {
+        log.error(args.submissionId + msg);
+      }
+    }
+  }
+
+  protected void logError(String msg) {
+    logError(msg, null);
+  }
+}
diff --git a/java/com/google/gerrit/server/git/strategy/TestHelperOp.java b/java/com/google/gerrit/server/git/strategy/TestHelperOp.java
new file mode 100644
index 0000000..a0ebb4c
--- /dev/null
+++ b/java/com/google/gerrit/server/git/strategy/TestHelperOp.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.change.TestSubmitInput;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.util.RequestId;
+import java.io.IOException;
+import java.util.Queue;
+import org.eclipse.jgit.lib.ObjectId;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class TestHelperOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(TestHelperOp.class);
+
+  private final Change.Id changeId;
+  private final TestSubmitInput input;
+  private final RequestId submissionId;
+
+  TestHelperOp(Change.Id changeId, SubmitStrategy.Arguments args) {
+    this.changeId = changeId;
+    this.input = (TestSubmitInput) args.submitInput;
+    this.submissionId = args.submissionId;
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx) throws IOException {
+    Queue<Boolean> q = input.generateLockFailures;
+    if (q != null && !q.isEmpty() && q.remove()) {
+      logDebug("Adding bogus ref update to trigger lock failure, via change {}", changeId);
+      ctx.addRefUpdate(
+          ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+          ObjectId.zeroId(),
+          "refs/test/" + getClass().getSimpleName());
+    }
+  }
+
+  private void logDebug(String msg, Object... args) {
+    if (log.isDebugEnabled()) {
+      log.debug(submissionId + msg, args);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/git/validators/AccountValidator.java b/java/com/google/gerrit/server/git/validators/AccountValidator.java
new file mode 100644
index 0000000..bba49ea
--- /dev/null
+++ b/java/com/google/gerrit/server/git/validators/AccountValidator.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.validators;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class AccountValidator {
+
+  private final Provider<IdentifiedUser> self;
+  private final OutgoingEmailValidator emailValidator;
+
+  @Inject
+  public AccountValidator(Provider<IdentifiedUser> self, OutgoingEmailValidator emailValidator) {
+    this.self = self;
+    this.emailValidator = emailValidator;
+  }
+
+  public List<String> validate(
+      Account.Id accountId, Repository repo, RevWalk rw, @Nullable ObjectId oldId, ObjectId newId)
+      throws IOException {
+    Optional<Account> oldAccount = Optional.empty();
+    if (oldId != null && !ObjectId.zeroId().equals(oldId)) {
+      try {
+        oldAccount = loadAccount(accountId, repo, rw, oldId, null);
+      } catch (ConfigInvalidException e) {
+        // ignore, maybe the new commit is repairing it now
+      }
+    }
+
+    List<String> messages = new ArrayList<>();
+    Optional<Account> newAccount;
+    try {
+      newAccount = loadAccount(accountId, repo, rw, newId, messages);
+    } catch (ConfigInvalidException e) {
+      return ImmutableList.of(
+          String.format(
+              "commit '%s' has an invalid '%s' file for account '%s': %s",
+              newId.name(), AccountConfig.ACCOUNT_CONFIG, accountId.get(), e.getMessage()));
+    }
+
+    if (!newAccount.isPresent()) {
+      return ImmutableList.of(String.format("account '%s' does not exist", accountId.get()));
+    }
+
+    if (accountId.equals(self.get().getAccountId()) && !newAccount.get().isActive()) {
+      messages.add("cannot deactivate own account");
+    }
+
+    String newPreferredEmail = newAccount.get().getPreferredEmail();
+    if (newPreferredEmail != null
+        && (!oldAccount.isPresent()
+            || !newPreferredEmail.equals(oldAccount.get().getPreferredEmail()))) {
+      if (!emailValidator.isValid(newPreferredEmail)) {
+        messages.add(
+            String.format(
+                "invalid preferred email '%s' for account '%s'",
+                newPreferredEmail, accountId.get()));
+      }
+    }
+
+    return ImmutableList.copyOf(messages);
+  }
+
+  private Optional<Account> loadAccount(
+      Account.Id accountId,
+      Repository repo,
+      RevWalk rw,
+      ObjectId commit,
+      @Nullable List<String> messages)
+      throws IOException, ConfigInvalidException {
+    rw.reset();
+    AccountConfig accountConfig = new AccountConfig(accountId, repo);
+    accountConfig.setEagerParsing(true).load(rw, commit);
+    if (messages != null) {
+      messages.addAll(
+          accountConfig
+              .getValidationErrors()
+              .stream()
+              .map(ValidationError::getMessage)
+              .collect(toSet()));
+    }
+    return accountConfig.getLoadedAccount();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java b/java/com/google/gerrit/server/git/validators/CommitValidationException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
rename to java/com/google/gerrit/server/git/validators/CommitValidationException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationListener.java b/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
rename to java/com/google/gerrit/server/git/validators/CommitValidationListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationMessage.java b/java/com/google/gerrit/server/git/validators/CommitValidationMessage.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationMessage.java
rename to java/com/google/gerrit/server/git/validators/CommitValidationMessage.java
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
new file mode 100644
index 0000000..40bb9c9
--- /dev/null
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -0,0 +1,868 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.validators;
+
+import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.BanCommit;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.ssh.SshInfo;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.jcraft.jsch.HostKey;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.FooterKey;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.SystemReader;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class CommitValidators {
+  private static final Logger log = LoggerFactory.getLogger(CommitValidators.class);
+
+  public static final Pattern NEW_PATCHSET_PATTERN =
+      Pattern.compile("^" + REFS_CHANGES + "(?:[0-9][0-9]/)?([1-9][0-9]*)(?:/new)?$");
+
+  @Singleton
+  public static class Factory {
+    private final PersonIdent gerritIdent;
+    private final String canonicalWebUrl;
+    private final DynamicSet<CommitValidationListener> pluginValidators;
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsers;
+    private final AllProjectsName allProjects;
+    private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
+    private final AccountValidator accountValidator;
+    private final String installCommitMsgHookCommand;
+    private final ProjectCache projectCache;
+
+    @Inject
+    Factory(
+        @GerritPersonIdent PersonIdent gerritIdent,
+        @CanonicalWebUrl @Nullable String canonicalWebUrl,
+        @GerritServerConfig Config cfg,
+        DynamicSet<CommitValidationListener> pluginValidators,
+        GitRepositoryManager repoManager,
+        AllUsersName allUsers,
+        AllProjectsName allProjects,
+        ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
+        AccountValidator accountValidator,
+        ProjectCache projectCache) {
+      this.gerritIdent = gerritIdent;
+      this.canonicalWebUrl = canonicalWebUrl;
+      this.pluginValidators = pluginValidators;
+      this.repoManager = repoManager;
+      this.allUsers = allUsers;
+      this.allProjects = allProjects;
+      this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
+      this.accountValidator = accountValidator;
+      this.installCommitMsgHookCommand =
+          cfg != null ? cfg.getString("gerrit", null, "installCommitMsgHookCommand") : null;
+      this.projectCache = projectCache;
+    }
+
+    public CommitValidators forReceiveCommits(
+        PermissionBackend.ForRef perm,
+        Branch.NameKey branch,
+        IdentifiedUser user,
+        SshInfo sshInfo,
+        Repository repo,
+        RevWalk rw)
+        throws IOException {
+      NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw);
+      ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
+      return new CommitValidators(
+          ImmutableList.of(
+              new UploadMergesPermissionValidator(perm),
+              new AmendedGerritMergeCommitValidationListener(perm, gerritIdent),
+              new AuthorUploaderValidator(user, perm, canonicalWebUrl),
+              new CommitterUploaderValidator(user, perm, canonicalWebUrl),
+              new SignedOffByValidator(user, perm, projectState),
+              new ChangeIdValidator(
+                  projectState, user, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
+              new ConfigValidator(branch, user, rw, allUsers, allProjects),
+              new BannedCommitsValidator(rejectCommits),
+              new PluginCommitValidationListener(pluginValidators),
+              new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker),
+              new AccountCommitValidator(repoManager, allUsers, accountValidator),
+              new GroupCommitValidator(allUsers)));
+    }
+
+    public CommitValidators forGerritCommits(
+        PermissionBackend.ForRef perm,
+        Branch.NameKey branch,
+        IdentifiedUser user,
+        SshInfo sshInfo,
+        RevWalk rw)
+        throws IOException {
+      return new CommitValidators(
+          ImmutableList.of(
+              new UploadMergesPermissionValidator(perm),
+              new AmendedGerritMergeCommitValidationListener(perm, gerritIdent),
+              new AuthorUploaderValidator(user, perm, canonicalWebUrl),
+              new SignedOffByValidator(user, perm, projectCache.checkedGet(branch.getParentKey())),
+              new ChangeIdValidator(
+                  projectCache.checkedGet(branch.getParentKey()),
+                  user,
+                  canonicalWebUrl,
+                  installCommitMsgHookCommand,
+                  sshInfo),
+              new ConfigValidator(branch, user, rw, allUsers, allProjects),
+              new PluginCommitValidationListener(pluginValidators),
+              new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker),
+              new AccountCommitValidator(repoManager, allUsers, accountValidator),
+              new GroupCommitValidator(allUsers)));
+    }
+
+    public CommitValidators forMergedCommits(PermissionBackend.ForRef perm, IdentifiedUser user) {
+      // Generally only include validators that are based on permissions of the
+      // user creating a change for a merged commit; generally exclude
+      // validators that would require amending the change in order to correct.
+      //
+      // Examples:
+      //  - Change-Id and Signed-off-by can't be added to an already-merged
+      //    commit.
+      //  - If the commit is banned, we can't ban it here. In fact, creating a
+      //    review of a previously merged and recently-banned commit is a use
+      //    case for post-commit code review: so reviewers have a place to
+      //    discuss what to do about it.
+      //  - Plugin validators may do things like require certain commit message
+      //    formats, so we play it safe and exclude them.
+      return new CommitValidators(
+          ImmutableList.of(
+              new UploadMergesPermissionValidator(perm),
+              new AuthorUploaderValidator(user, perm, canonicalWebUrl),
+              new CommitterUploaderValidator(user, perm, canonicalWebUrl)));
+    }
+  }
+
+  private final List<CommitValidationListener> validators;
+
+  CommitValidators(List<CommitValidationListener> validators) {
+    this.validators = validators;
+  }
+
+  public List<CommitValidationMessage> validate(CommitReceivedEvent receiveEvent)
+      throws CommitValidationException {
+    List<CommitValidationMessage> messages = new ArrayList<>();
+    try {
+      for (CommitValidationListener commitValidator : validators) {
+        messages.addAll(commitValidator.onCommitReceived(receiveEvent));
+      }
+    } catch (CommitValidationException e) {
+      // Keep the old messages (and their order) in case of an exception
+      messages.addAll(e.getMessages());
+      throw new CommitValidationException(e.getMessage(), messages);
+    }
+    return messages;
+  }
+
+  public static class ChangeIdValidator implements CommitValidationListener {
+    private static final String CHANGE_ID_PREFIX = FooterConstants.CHANGE_ID.getName() + ":";
+    private static final String MISSING_CHANGE_ID_MSG =
+        "[%s] missing " + FooterConstants.CHANGE_ID.getName() + " in commit message footer";
+    private static final String MISSING_SUBJECT_MSG =
+        "[%s] missing subject; "
+            + FooterConstants.CHANGE_ID.getName()
+            + " must be in commit message footer";
+    private static final String MULTIPLE_CHANGE_ID_MSG =
+        "[%s] multiple " + FooterConstants.CHANGE_ID.getName() + " lines in commit message footer";
+    private static final String INVALID_CHANGE_ID_MSG =
+        "[%s] invalid "
+            + FooterConstants.CHANGE_ID.getName()
+            + " line format in commit message footer";
+    private static final Pattern CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
+
+    private final ProjectState projectState;
+    private final String canonicalWebUrl;
+    private final String installCommitMsgHookCommand;
+    private final SshInfo sshInfo;
+    private final IdentifiedUser user;
+
+    public ChangeIdValidator(
+        ProjectState projectState,
+        IdentifiedUser user,
+        String canonicalWebUrl,
+        String installCommitMsgHookCommand,
+        SshInfo sshInfo) {
+      this.projectState = projectState;
+      this.canonicalWebUrl = canonicalWebUrl;
+      this.installCommitMsgHookCommand = installCommitMsgHookCommand;
+      this.sshInfo = sshInfo;
+      this.user = user;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      if (!shouldValidateChangeId(receiveEvent)) {
+        return Collections.emptyList();
+      }
+      RevCommit commit = receiveEvent.commit;
+      List<CommitValidationMessage> messages = new ArrayList<>();
+      List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
+      String sha1 = commit.abbreviate(RevId.ABBREV_LEN).name();
+
+      if (idList.isEmpty()) {
+        if (projectState.is(BooleanProjectConfig.REQUIRE_CHANGE_ID)) {
+          String shortMsg = commit.getShortMessage();
+          if (shortMsg.startsWith(CHANGE_ID_PREFIX)
+              && CHANGE_ID
+                  .matcher(shortMsg.substring(CHANGE_ID_PREFIX.length()).trim())
+                  .matches()) {
+            String errMsg = String.format(MISSING_SUBJECT_MSG, sha1);
+            throw new CommitValidationException(errMsg);
+          }
+          String errMsg = String.format(MISSING_CHANGE_ID_MSG, sha1);
+          messages.add(getMissingChangeIdErrorMsg(errMsg, commit));
+          throw new CommitValidationException(errMsg, messages);
+        }
+      } else if (idList.size() > 1) {
+        String errMsg = String.format(MULTIPLE_CHANGE_ID_MSG, sha1);
+        throw new CommitValidationException(errMsg, messages);
+      } else {
+        String v = idList.get(idList.size() - 1).trim();
+        // Reject Change-Ids with wrong format and invalid placeholder ID from
+        // Egit (I0000000000000000000000000000000000000000).
+        if (!CHANGE_ID.matcher(v).matches() || v.matches("^I00*$")) {
+          String errMsg = String.format(INVALID_CHANGE_ID_MSG, sha1);
+          messages.add(getMissingChangeIdErrorMsg(errMsg, receiveEvent.commit));
+          throw new CommitValidationException(errMsg, messages);
+        }
+      }
+      return Collections.emptyList();
+    }
+
+    private static boolean shouldValidateChangeId(CommitReceivedEvent event) {
+      return MagicBranch.isMagicBranch(event.command.getRefName())
+          || NEW_PATCHSET_PATTERN.matcher(event.command.getRefName()).matches();
+    }
+
+    private CommitValidationMessage getMissingChangeIdErrorMsg(String errMsg, RevCommit c) {
+      StringBuilder sb = new StringBuilder();
+      sb.append("ERROR: ").append(errMsg);
+
+      if (c.getFullMessage().indexOf(CHANGE_ID_PREFIX) >= 0) {
+        String[] lines = c.getFullMessage().trim().split("\n");
+        String lastLine = lines.length > 0 ? lines[lines.length - 1] : "";
+
+        if (lastLine.indexOf(CHANGE_ID_PREFIX) == -1) {
+          sb.append('\n');
+          sb.append('\n');
+          sb.append("Hint: A potential ");
+          sb.append(FooterConstants.CHANGE_ID.getName());
+          sb.append("Change-Id was found, but it was not in the ");
+          sb.append("footer (last paragraph) of the commit message.");
+        }
+      }
+      sb.append('\n');
+      sb.append('\n');
+      sb.append("Hint: To automatically insert ");
+      sb.append(FooterConstants.CHANGE_ID.getName());
+      sb.append(", install the hook:\n");
+      sb.append(getCommitMessageHookInstallationHint());
+      sb.append('\n');
+      sb.append("And then amend the commit:\n");
+      sb.append("  git commit --amend\n");
+
+      return new CommitValidationMessage(sb.toString(), false);
+    }
+
+    private String getCommitMessageHookInstallationHint() {
+      if (installCommitMsgHookCommand != null) {
+        return installCommitMsgHookCommand;
+      }
+      final List<HostKey> hostKeys = sshInfo.getHostKeys();
+
+      // If there are no SSH keys, the commit-msg hook must be installed via
+      // HTTP(S)
+      if (hostKeys.isEmpty()) {
+        String p = "${gitdir}/hooks/commit-msg";
+        return String.format(
+            "  gitdir=$(git rev-parse --git-dir); curl -o %s %s/tools/hooks/commit-msg ; chmod +x %s",
+            p, getGerritUrl(canonicalWebUrl), p);
+      }
+
+      // SSH keys exist, so the hook can be installed with scp.
+      String sshHost;
+      int sshPort;
+      String host = hostKeys.get(0).getHost();
+      int c = host.lastIndexOf(':');
+      if (0 <= c) {
+        if (host.startsWith("*:")) {
+          sshHost = getGerritHost(canonicalWebUrl);
+        } else {
+          sshHost = host.substring(0, c);
+        }
+        sshPort = Integer.parseInt(host.substring(c + 1));
+      } else {
+        sshHost = host;
+        sshPort = 22;
+      }
+
+      return String.format(
+          "  gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/",
+          sshPort, user.getUserName(), sshHost);
+    }
+  }
+
+  /** If this is the special project configuration branch, validate the config. */
+  public static class ConfigValidator implements CommitValidationListener {
+    private final Branch.NameKey branch;
+    private final IdentifiedUser user;
+    private final RevWalk rw;
+    private final AllUsersName allUsers;
+    private final AllProjectsName allProjects;
+
+    public ConfigValidator(
+        Branch.NameKey branch,
+        IdentifiedUser user,
+        RevWalk rw,
+        AllUsersName allUsers,
+        AllProjectsName allProjects) {
+      this.branch = branch;
+      this.user = user;
+      this.rw = rw;
+      this.allProjects = allProjects;
+      this.allUsers = allUsers;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      if (REFS_CONFIG.equals(branch.get())) {
+        List<CommitValidationMessage> messages = new ArrayList<>();
+
+        try {
+          ProjectConfig cfg = new ProjectConfig(receiveEvent.project.getNameKey());
+          cfg.load(rw, receiveEvent.command.getNewId());
+          if (!cfg.getValidationErrors().isEmpty()) {
+            addError("Invalid project configuration:", messages);
+            for (ValidationError err : cfg.getValidationErrors()) {
+              addError("  " + err.getMessage(), messages);
+            }
+            throw new ConfigInvalidException("invalid project configuration");
+          }
+          if (allUsers.equals(receiveEvent.project.getNameKey())
+              && !allProjects.equals(cfg.getProject().getParent(allProjects))) {
+            addError("Invalid project configuration:", messages);
+            addError(
+                String.format("  %s must inherit from %s", allUsers.get(), allProjects.get()),
+                messages);
+            throw new ConfigInvalidException("invalid project configuration");
+          }
+        } catch (ConfigInvalidException | IOException e) {
+          log.error(
+              "User "
+                  + user.getUserName()
+                  + " tried to push an invalid project configuration "
+                  + receiveEvent.command.getNewId().name()
+                  + " for project "
+                  + receiveEvent.project,
+              e);
+          throw new CommitValidationException("invalid project configuration", messages);
+        }
+      }
+
+      return Collections.emptyList();
+    }
+  }
+
+  /** Require permission to upload merge commits. */
+  public static class UploadMergesPermissionValidator implements CommitValidationListener {
+    private final PermissionBackend.ForRef perm;
+
+    public UploadMergesPermissionValidator(PermissionBackend.ForRef perm) {
+      this.perm = perm;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      if (receiveEvent.commit.getParentCount() <= 1) {
+        return Collections.emptyList();
+      }
+      try {
+        perm.check(RefPermission.MERGE);
+        return Collections.emptyList();
+      } catch (AuthException e) {
+        throw new CommitValidationException("you are not allowed to upload merges");
+      } catch (PermissionBackendException e) {
+        log.error("cannot check MERGE", e);
+        throw new CommitValidationException("internal auth error");
+      }
+    }
+  }
+
+  /** Execute commit validation plug-ins */
+  public static class PluginCommitValidationListener implements CommitValidationListener {
+    private final DynamicSet<CommitValidationListener> commitValidationListeners;
+
+    public PluginCommitValidationListener(
+        final DynamicSet<CommitValidationListener> commitValidationListeners) {
+      this.commitValidationListeners = commitValidationListeners;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      List<CommitValidationMessage> messages = new ArrayList<>();
+
+      for (CommitValidationListener validator : commitValidationListeners) {
+        try {
+          messages.addAll(validator.onCommitReceived(receiveEvent));
+        } catch (CommitValidationException e) {
+          messages.addAll(e.getMessages());
+          throw new CommitValidationException(e.getMessage(), messages);
+        }
+      }
+      return messages;
+    }
+  }
+
+  public static class SignedOffByValidator implements CommitValidationListener {
+    private final IdentifiedUser user;
+    private final PermissionBackend.ForRef perm;
+    private final ProjectState state;
+
+    public SignedOffByValidator(
+        IdentifiedUser user, PermissionBackend.ForRef perm, ProjectState state) {
+      this.user = user;
+      this.perm = perm;
+      this.state = state;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      if (!state.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) {
+        return Collections.emptyList();
+      }
+
+      RevCommit commit = receiveEvent.commit;
+      PersonIdent committer = commit.getCommitterIdent();
+      PersonIdent author = commit.getAuthorIdent();
+
+      boolean sboAuthor = false;
+      boolean sboCommitter = false;
+      boolean sboMe = false;
+      for (FooterLine footer : commit.getFooterLines()) {
+        if (footer.matches(FooterKey.SIGNED_OFF_BY)) {
+          String e = footer.getEmailAddress();
+          if (e != null) {
+            sboAuthor |= author.getEmailAddress().equals(e);
+            sboCommitter |= committer.getEmailAddress().equals(e);
+            sboMe |= user.hasEmailAddress(e);
+          }
+        }
+      }
+      if (!sboAuthor && !sboCommitter && !sboMe) {
+        try {
+          perm.check(RefPermission.FORGE_COMMITTER);
+        } catch (AuthException denied) {
+          throw new CommitValidationException(
+              "not Signed-off-by author/committer/uploader in commit message footer");
+        } catch (PermissionBackendException e) {
+          log.error("cannot check FORGE_COMMITTER", e);
+          throw new CommitValidationException("internal auth error");
+        }
+      }
+      return Collections.emptyList();
+    }
+  }
+
+  /** Require that author matches the uploader. */
+  public static class AuthorUploaderValidator implements CommitValidationListener {
+    private final IdentifiedUser user;
+    private final PermissionBackend.ForRef perm;
+    private final String canonicalWebUrl;
+
+    public AuthorUploaderValidator(
+        IdentifiedUser user, PermissionBackend.ForRef perm, String canonicalWebUrl) {
+      this.user = user;
+      this.perm = perm;
+      this.canonicalWebUrl = canonicalWebUrl;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      PersonIdent author = receiveEvent.commit.getAuthorIdent();
+      if (user.hasEmailAddress(author.getEmailAddress())) {
+        return Collections.emptyList();
+      }
+      try {
+        perm.check(RefPermission.FORGE_AUTHOR);
+        return Collections.emptyList();
+      } catch (AuthException e) {
+        throw new CommitValidationException(
+            "invalid author",
+            invalidEmail(receiveEvent.commit, "author", author, user, canonicalWebUrl));
+      } catch (PermissionBackendException e) {
+        log.error("cannot check FORGE_AUTHOR", e);
+        throw new CommitValidationException("internal auth error");
+      }
+    }
+  }
+
+  /** Require that committer matches the uploader. */
+  public static class CommitterUploaderValidator implements CommitValidationListener {
+    private final IdentifiedUser user;
+    private final PermissionBackend.ForRef perm;
+    private final String canonicalWebUrl;
+
+    public CommitterUploaderValidator(
+        IdentifiedUser user, PermissionBackend.ForRef perm, String canonicalWebUrl) {
+      this.user = user;
+      this.perm = perm;
+      this.canonicalWebUrl = canonicalWebUrl;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      PersonIdent committer = receiveEvent.commit.getCommitterIdent();
+      if (user.hasEmailAddress(committer.getEmailAddress())) {
+        return Collections.emptyList();
+      }
+      try {
+        perm.check(RefPermission.FORGE_COMMITTER);
+        return Collections.emptyList();
+      } catch (AuthException e) {
+        throw new CommitValidationException(
+            "invalid committer",
+            invalidEmail(receiveEvent.commit, "committer", committer, user, canonicalWebUrl));
+      } catch (PermissionBackendException e) {
+        log.error("cannot check FORGE_COMMITTER", e);
+        throw new CommitValidationException("internal auth error");
+      }
+    }
+  }
+
+  /**
+   * Don't allow the user to amend a merge created by Gerrit Code Review. This seems to happen all
+   * too often, due to users not paying any attention to what they are doing.
+   */
+  public static class AmendedGerritMergeCommitValidationListener
+      implements CommitValidationListener {
+    private final PermissionBackend.ForRef perm;
+    private final PersonIdent gerritIdent;
+
+    public AmendedGerritMergeCommitValidationListener(
+        PermissionBackend.ForRef perm, PersonIdent gerritIdent) {
+      this.perm = perm;
+      this.gerritIdent = gerritIdent;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      PersonIdent author = receiveEvent.commit.getAuthorIdent();
+      if (receiveEvent.commit.getParentCount() > 1
+          && author.getName().equals(gerritIdent.getName())
+          && author.getEmailAddress().equals(gerritIdent.getEmailAddress())) {
+        try {
+          // Stop authors from amending the merge commits that Gerrit itself creates.
+          perm.check(RefPermission.FORGE_SERVER);
+        } catch (AuthException denied) {
+          throw new CommitValidationException(
+              String.format(
+                  "pushing merge commit %s by %s requires '%s' permission",
+                  receiveEvent.commit.getId(),
+                  gerritIdent.getEmailAddress(),
+                  RefPermission.FORGE_SERVER.name()));
+        } catch (PermissionBackendException e) {
+          log.error("cannot check FORGE_SERVER", e);
+          throw new CommitValidationException("internal auth error");
+        }
+      }
+      return Collections.emptyList();
+    }
+  }
+
+  /** Reject banned commits. */
+  public static class BannedCommitsValidator implements CommitValidationListener {
+    private final NoteMap rejectCommits;
+
+    public BannedCommitsValidator(NoteMap rejectCommits) {
+      this.rejectCommits = rejectCommits;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      try {
+        if (rejectCommits.contains(receiveEvent.commit)) {
+          throw new CommitValidationException(
+              "contains banned commit " + receiveEvent.commit.getName());
+        }
+        return Collections.emptyList();
+      } catch (IOException e) {
+        String m = "error checking banned commits";
+        log.warn(m, e);
+        throw new CommitValidationException(m, e);
+      }
+    }
+  }
+
+  /** Validates updates to refs/meta/external-ids. */
+  public static class ExternalIdUpdateListener implements CommitValidationListener {
+    private final AllUsersName allUsers;
+    private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
+
+    public ExternalIdUpdateListener(
+        AllUsersName allUsers, ExternalIdsConsistencyChecker externalIdsConsistencyChecker) {
+      this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
+      this.allUsers = allUsers;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      if (allUsers.equals(receiveEvent.project.getNameKey())
+          && RefNames.REFS_EXTERNAL_IDS.equals(receiveEvent.refName)) {
+        try {
+          List<ConsistencyProblemInfo> problems =
+              externalIdsConsistencyChecker.check(receiveEvent.commit);
+          List<CommitValidationMessage> msgs =
+              problems
+                  .stream()
+                  .map(
+                      p ->
+                          new CommitValidationMessage(
+                              p.message, p.status == ConsistencyProblemInfo.Status.ERROR))
+                  .collect(toList());
+          if (msgs.stream().anyMatch(m -> m.isError())) {
+            throw new CommitValidationException("invalid external IDs", msgs);
+          }
+          return msgs;
+        } catch (IOException | ConfigInvalidException e) {
+          String m = "error validating external IDs";
+          log.warn(m, e);
+          throw new CommitValidationException(m, e);
+        }
+      }
+      return Collections.emptyList();
+    }
+  }
+
+  public static class AccountCommitValidator implements CommitValidationListener {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsers;
+    private final AccountValidator accountValidator;
+
+    public AccountCommitValidator(
+        GitRepositoryManager repoManager,
+        AllUsersName allUsers,
+        AccountValidator accountValidator) {
+      this.repoManager = repoManager;
+      this.allUsers = allUsers;
+      this.accountValidator = accountValidator;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      if (!allUsers.equals(receiveEvent.project.getNameKey())) {
+        return Collections.emptyList();
+      }
+
+      if (receiveEvent.command.getRefName().startsWith(MagicBranch.NEW_CHANGE)) {
+        // no validation on push for review, will be checked on submit by
+        // MergeValidators.AccountMergeValidator
+        return Collections.emptyList();
+      }
+
+      Account.Id accountId = Account.Id.fromRef(receiveEvent.refName);
+      if (accountId == null) {
+        return Collections.emptyList();
+      }
+
+      try (Repository repo = repoManager.openRepository(allUsers)) {
+        List<String> errorMessages =
+            accountValidator.validate(
+                accountId,
+                repo,
+                receiveEvent.revWalk,
+                receiveEvent.command.getOldId(),
+                receiveEvent.commit);
+        if (!errorMessages.isEmpty()) {
+          throw new CommitValidationException(
+              "invalid account configuration",
+              errorMessages
+                  .stream()
+                  .map(m -> new CommitValidationMessage(m, true))
+                  .collect(toList()));
+        }
+      } catch (IOException e) {
+        String m = String.format("Validating update for account %s failed", accountId.get());
+        log.error(m, e);
+        throw new CommitValidationException(m, e);
+      }
+      return Collections.emptyList();
+    }
+  }
+
+  /** Rejects updates to group branches. */
+  public static class GroupCommitValidator implements CommitValidationListener {
+    private final AllUsersName allUsers;
+
+    public GroupCommitValidator(AllUsersName allUsers) {
+      this.allUsers = allUsers;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      // Groups are stored inside the 'All-Users' repository.
+      if (!allUsers.equals(receiveEvent.project.getNameKey())) {
+        return Collections.emptyList();
+      }
+
+      if (receiveEvent.command.getRefName().startsWith(MagicBranch.NEW_CHANGE)) {
+        // no validation on push for review, will be checked on submit by
+        // MergeValidators.GroupMergeValidator
+        return Collections.emptyList();
+      }
+
+      if (RefNames.isGroupRef(receiveEvent.command.getRefName())) {
+        throw new CommitValidationException("group update not allowed");
+      }
+      return Collections.emptyList();
+    }
+  }
+
+  private static CommitValidationMessage invalidEmail(
+      RevCommit c,
+      String type,
+      PersonIdent who,
+      IdentifiedUser currentUser,
+      String canonicalWebUrl) {
+    StringBuilder sb = new StringBuilder();
+    sb.append("\n");
+    sb.append("ERROR:  In commit ").append(c.name()).append("\n");
+    sb.append("ERROR:  ")
+        .append(type)
+        .append(" email address ")
+        .append(who.getEmailAddress())
+        .append("\n");
+    sb.append("ERROR:  does not match your user account and you have no 'forge ")
+        .append(type)
+        .append("' permission.\n");
+    sb.append("ERROR:\n");
+    if (currentUser.getEmailAddresses().isEmpty()) {
+      sb.append("ERROR:  You have not registered any email addresses.\n");
+    } else {
+      sb.append("ERROR:  The following addresses are currently registered:\n");
+      for (String address : currentUser.getEmailAddresses()) {
+        sb.append("ERROR:    ").append(address).append("\n");
+      }
+    }
+    sb.append("ERROR:\n");
+    if (canonicalWebUrl != null) {
+      sb.append("ERROR:  To register an email address, please visit:\n");
+      sb.append("ERROR:  ")
+          .append(canonicalWebUrl)
+          .append("#")
+          .append(PageLinks.SETTINGS_CONTACT)
+          .append("\n");
+    }
+    sb.append("\n");
+    return new CommitValidationMessage(sb.toString(), false);
+  }
+
+  /**
+   * Get the Gerrit URL.
+   *
+   * @return the canonical URL (with any trailing slash removed) if it is configured, otherwise fall
+   *     back to "http://hostname" where hostname is the value returned by {@link
+   *     #getGerritHost(String)}.
+   */
+  private static String getGerritUrl(String canonicalWebUrl) {
+    if (canonicalWebUrl != null) {
+      return CharMatcher.is('/').trimTrailingFrom(canonicalWebUrl);
+    }
+    return "http://" + getGerritHost(canonicalWebUrl);
+  }
+
+  /**
+   * Get the Gerrit hostname.
+   *
+   * @return the hostname from the canonical URL if it is configured, otherwise whatever the OS says
+   *     the hostname is.
+   */
+  private static String getGerritHost(String canonicalWebUrl) {
+    String host;
+    if (canonicalWebUrl != null) {
+      try {
+        host = new URL(canonicalWebUrl).getHost();
+      } catch (MalformedURLException e) {
+        host = SystemReader.getInstance().getHostname();
+      }
+    } else {
+      host = SystemReader.getInstance().getHostname();
+    }
+    return host;
+  }
+
+  private static void addError(String error, List<CommitValidationMessage> messages) {
+    messages.add(new CommitValidationMessage(error, true));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationException.java b/java/com/google/gerrit/server/git/validators/MergeValidationException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationException.java
rename to java/com/google/gerrit/server/git/validators/MergeValidationException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationListener.java b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
rename to java/com/google/gerrit/server/git/validators/MergeValidationListener.java
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
new file mode 100644
index 0000000..5b20ff6
--- /dev/null
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -0,0 +1,330 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.validators;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicMap.Entry;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class MergeValidators {
+  private static final Logger log = LoggerFactory.getLogger(MergeValidators.class);
+
+  private final DynamicSet<MergeValidationListener> mergeValidationListeners;
+  private final ProjectConfigValidator.Factory projectConfigValidatorFactory;
+  private final AccountMergeValidator.Factory accountValidatorFactory;
+  private final GroupMergeValidator.Factory groupValidatorFactory;
+
+  public interface Factory {
+    MergeValidators create();
+  }
+
+  @Inject
+  MergeValidators(
+      DynamicSet<MergeValidationListener> mergeValidationListeners,
+      ProjectConfigValidator.Factory projectConfigValidatorFactory,
+      AccountMergeValidator.Factory accountValidatorFactory,
+      GroupMergeValidator.Factory groupValidatorFactory) {
+    this.mergeValidationListeners = mergeValidationListeners;
+    this.projectConfigValidatorFactory = projectConfigValidatorFactory;
+    this.accountValidatorFactory = accountValidatorFactory;
+    this.groupValidatorFactory = groupValidatorFactory;
+  }
+
+  public void validatePreMerge(
+      Repository repo,
+      CodeReviewCommit commit,
+      ProjectState destProject,
+      Branch.NameKey destBranch,
+      PatchSet.Id patchSetId,
+      IdentifiedUser caller)
+      throws MergeValidationException {
+    List<MergeValidationListener> validators =
+        ImmutableList.of(
+            new PluginMergeValidationListener(mergeValidationListeners),
+            projectConfigValidatorFactory.create(),
+            accountValidatorFactory.create(),
+            groupValidatorFactory.create());
+
+    for (MergeValidationListener validator : validators) {
+      validator.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller);
+    }
+  }
+
+  public static class ProjectConfigValidator implements MergeValidationListener {
+    private static final String INVALID_CONFIG =
+        "Change contains an invalid project configuration.";
+    private static final String PARENT_NOT_FOUND =
+        "Change contains an invalid project configuration:\nParent project does not exist.";
+    private static final String PLUGIN_VALUE_NOT_EDITABLE =
+        "Change contains an invalid project configuration:\n"
+            + "One of the plugin configuration parameters is not editable.";
+    private static final String PLUGIN_VALUE_NOT_PERMITTED =
+        "Change contains an invalid project configuration:\n"
+            + "One of the plugin configuration parameters has a value that is not"
+            + " permitted.";
+    private static final String ROOT_NO_PARENT =
+        "Change contains an invalid project configuration:\n"
+            + "The root project cannot have a parent.";
+    private static final String SET_BY_ADMIN =
+        "Change contains a project configuration that changes the parent"
+            + " project.\n"
+            + "The change must be submitted by a Gerrit administrator.";
+
+    private final AllProjectsName allProjectsName;
+    private final AllUsersName allUsersName;
+    private final ProjectCache projectCache;
+    private final PermissionBackend permissionBackend;
+    private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+
+    public interface Factory {
+      ProjectConfigValidator create();
+    }
+
+    @Inject
+    public ProjectConfigValidator(
+        AllProjectsName allProjectsName,
+        AllUsersName allUsersName,
+        ProjectCache projectCache,
+        PermissionBackend permissionBackend,
+        DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
+      this.allProjectsName = allProjectsName;
+      this.allUsersName = allUsersName;
+      this.projectCache = projectCache;
+      this.permissionBackend = permissionBackend;
+      this.pluginConfigEntries = pluginConfigEntries;
+    }
+
+    @Override
+    public void onPreMerge(
+        final Repository repo,
+        final CodeReviewCommit commit,
+        final ProjectState destProject,
+        final Branch.NameKey destBranch,
+        final PatchSet.Id patchSetId,
+        IdentifiedUser caller)
+        throws MergeValidationException {
+      if (RefNames.REFS_CONFIG.equals(destBranch.get())) {
+        final Project.NameKey newParent;
+        try {
+          ProjectConfig cfg = new ProjectConfig(destProject.getNameKey());
+          cfg.load(repo, commit);
+          newParent = cfg.getProject().getParent(allProjectsName);
+          final Project.NameKey oldParent = destProject.getProject().getParent(allProjectsName);
+          if (oldParent == null) {
+            // update of the 'All-Projects' project
+            if (newParent != null) {
+              throw new MergeValidationException(ROOT_NO_PARENT);
+            }
+          } else {
+            if (!oldParent.equals(newParent)) {
+              try {
+                permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
+              } catch (AuthException e) {
+                throw new MergeValidationException(SET_BY_ADMIN);
+              } catch (PermissionBackendException e) {
+                log.warn("Cannot check ADMINISTRATE_SERVER", e);
+                throw new MergeValidationException("validation unavailable");
+              }
+              if (allUsersName.equals(destProject.getNameKey())
+                  && !allProjectsName.equals(newParent)) {
+                throw new MergeValidationException(
+                    String.format(
+                        " %s must inherit from %s", allUsersName.get(), allProjectsName.get()));
+              }
+              if (projectCache.get(newParent) == null) {
+                throw new MergeValidationException(PARENT_NOT_FOUND);
+              }
+            }
+          }
+
+          for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
+            PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName());
+            ProjectConfigEntry configEntry = e.getProvider().get();
+
+            String value = pluginCfg.getString(e.getExportName());
+            String oldValue =
+                destProject
+                    .getConfig()
+                    .getPluginConfig(e.getPluginName())
+                    .getString(e.getExportName());
+
+            if ((value == null ? oldValue != null : !value.equals(oldValue))
+                && !configEntry.isEditable(destProject)) {
+              throw new MergeValidationException(PLUGIN_VALUE_NOT_EDITABLE);
+            }
+
+            if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
+                && value != null
+                && !configEntry.getPermittedValues().contains(value)) {
+              throw new MergeValidationException(PLUGIN_VALUE_NOT_PERMITTED);
+            }
+          }
+        } catch (ConfigInvalidException | IOException e) {
+          throw new MergeValidationException(INVALID_CONFIG);
+        }
+      }
+    }
+  }
+
+  /** Execute merge validation plug-ins */
+  public static class PluginMergeValidationListener implements MergeValidationListener {
+    private final DynamicSet<MergeValidationListener> mergeValidationListeners;
+
+    public PluginMergeValidationListener(
+        DynamicSet<MergeValidationListener> mergeValidationListeners) {
+      this.mergeValidationListeners = mergeValidationListeners;
+    }
+
+    @Override
+    public void onPreMerge(
+        Repository repo,
+        CodeReviewCommit commit,
+        ProjectState destProject,
+        Branch.NameKey destBranch,
+        PatchSet.Id patchSetId,
+        IdentifiedUser caller)
+        throws MergeValidationException {
+      for (MergeValidationListener validator : mergeValidationListeners) {
+        validator.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller);
+      }
+    }
+  }
+
+  public static class AccountMergeValidator implements MergeValidationListener {
+    public interface Factory {
+      AccountMergeValidator create();
+    }
+
+    private final Provider<ReviewDb> dbProvider;
+    private final AllUsersName allUsersName;
+    private final ChangeData.Factory changeDataFactory;
+    private final AccountValidator accountValidator;
+
+    @Inject
+    public AccountMergeValidator(
+        Provider<ReviewDb> dbProvider,
+        AllUsersName allUsersName,
+        ChangeData.Factory changeDataFactory,
+        AccountValidator accountValidator) {
+      this.dbProvider = dbProvider;
+      this.allUsersName = allUsersName;
+      this.changeDataFactory = changeDataFactory;
+      this.accountValidator = accountValidator;
+    }
+
+    @Override
+    public void onPreMerge(
+        Repository repo,
+        CodeReviewCommit commit,
+        ProjectState destProject,
+        Branch.NameKey destBranch,
+        PatchSet.Id patchSetId,
+        IdentifiedUser caller)
+        throws MergeValidationException {
+      Account.Id accountId = Account.Id.fromRef(destBranch.get());
+      if (!allUsersName.equals(destProject.getNameKey()) || accountId == null) {
+        return;
+      }
+
+      ChangeData cd =
+          changeDataFactory.create(
+              dbProvider.get(), destProject.getProject().getNameKey(), patchSetId.getParentKey());
+      try {
+        if (!cd.currentFilePaths().contains(AccountConfig.ACCOUNT_CONFIG)) {
+          return;
+        }
+      } catch (IOException | OrmException e) {
+        log.error("Cannot validate account update", e);
+        throw new MergeValidationException("account validation unavailable");
+      }
+
+      try (RevWalk rw = new RevWalk(repo)) {
+        List<String> errorMessages = accountValidator.validate(accountId, repo, rw, null, commit);
+        if (!errorMessages.isEmpty()) {
+          throw new MergeValidationException(
+              "invalid account configuration: " + Joiner.on("; ").join(errorMessages));
+        }
+      } catch (IOException e) {
+        log.error("Cannot validate account update", e);
+        throw new MergeValidationException("account validation unavailable");
+      }
+    }
+  }
+
+  public static class GroupMergeValidator implements MergeValidationListener {
+    public interface Factory {
+      GroupMergeValidator create();
+    }
+
+    private final AllUsersName allUsersName;
+
+    @Inject
+    public GroupMergeValidator(AllUsersName allUsersName) {
+      this.allUsersName = allUsersName;
+    }
+
+    @Override
+    public void onPreMerge(
+        Repository repo,
+        CodeReviewCommit commit,
+        ProjectState destProject,
+        Branch.NameKey destBranch,
+        PatchSet.Id patchSetId,
+        IdentifiedUser caller)
+        throws MergeValidationException {
+      // Groups are stored inside the 'All-Users' repository.
+      if (!allUsersName.equals(destProject.getNameKey())
+          || !RefNames.isGroupRef(destBranch.get())) {
+        return;
+      }
+
+      throw new MergeValidationException("group update not allowed");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java b/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
rename to java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java b/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
rename to java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java b/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
rename to java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidationListener.java b/java/com/google/gerrit/server/git/validators/RefOperationValidationListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidationListener.java
rename to java/com/google/gerrit/server/git/validators/RefOperationValidationListener.java
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
new file mode 100644
index 0000000..677f39f
--- /dev/null
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -0,0 +1,163 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.git.validators;
+
+import com.google.common.base.Predicate;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.events.RefReceivedEvent;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class RefOperationValidators {
+  private static final GetErrorMessages GET_ERRORS = new GetErrorMessages();
+  private static final Logger LOG = LoggerFactory.getLogger(RefOperationValidators.class);
+
+  public interface Factory {
+    RefOperationValidators create(Project project, IdentifiedUser user, ReceiveCommand cmd);
+  }
+
+  public static ReceiveCommand getCommand(RefUpdate update, ReceiveCommand.Type type) {
+    return new ReceiveCommand(
+        update.getExpectedOldObjectId(), update.getNewObjectId(), update.getName(), type);
+  }
+
+  private final PermissionBackend.WithUser perm;
+  private final AllUsersName allUsersName;
+  private final DynamicSet<RefOperationValidationListener> refOperationValidationListeners;
+  private final RefReceivedEvent event;
+
+  @Inject
+  RefOperationValidators(
+      PermissionBackend permissionBackend,
+      AllUsersName allUsersName,
+      DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
+      @Assisted Project project,
+      @Assisted IdentifiedUser user,
+      @Assisted ReceiveCommand cmd) {
+    this.perm = permissionBackend.user(user);
+    this.allUsersName = allUsersName;
+    this.refOperationValidationListeners = refOperationValidationListeners;
+    event = new RefReceivedEvent();
+    event.command = cmd;
+    event.project = project;
+    event.user = user;
+  }
+
+  public List<ValidationMessage> validateForRefOperation() throws RefOperationValidationException {
+    List<ValidationMessage> messages = new ArrayList<>();
+    boolean withException = false;
+    List<RefOperationValidationListener> listeners = new ArrayList<>();
+    listeners.add(new DisallowCreationAndDeletionOfUserBranches(perm, allUsersName));
+    refOperationValidationListeners.forEach(l -> listeners.add(l));
+    try {
+      for (RefOperationValidationListener listener : listeners) {
+        messages.addAll(listener.onRefOperation(event));
+      }
+    } catch (ValidationException e) {
+      messages.add(new ValidationMessage(e.getMessage(), true));
+      withException = true;
+    }
+
+    if (withException) {
+      throwException(messages, event);
+    }
+
+    return messages;
+  }
+
+  private void throwException(Iterable<ValidationMessage> messages, RefReceivedEvent event)
+      throws RefOperationValidationException {
+    Iterable<ValidationMessage> errors = Iterables.filter(messages, GET_ERRORS);
+    String header =
+        String.format(
+            "Ref \"%s\" %S in project %s validation failed",
+            event.command.getRefName(), event.command.getType(), event.project.getName());
+    LOG.error(header);
+    throw new RefOperationValidationException(header, errors);
+  }
+
+  private static class GetErrorMessages implements Predicate<ValidationMessage> {
+    @Override
+    public boolean apply(ValidationMessage input) {
+      return input.isError();
+    }
+  }
+
+  private static class DisallowCreationAndDeletionOfUserBranches
+      implements RefOperationValidationListener {
+    private final PermissionBackend.WithUser perm;
+    private final AllUsersName allUsersName;
+
+    DisallowCreationAndDeletionOfUserBranches(
+        PermissionBackend.WithUser perm, AllUsersName allUsersName) {
+      this.perm = perm;
+      this.allUsersName = allUsersName;
+    }
+
+    @Override
+    public List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
+        throws ValidationException {
+      if (refEvent.project.getNameKey().equals(allUsersName)) {
+        if (refEvent.command.getRefName().startsWith(RefNames.REFS_USERS)
+            && !refEvent.command.getRefName().equals(RefNames.REFS_USERS_DEFAULT)) {
+          if (refEvent.command.getType().equals(ReceiveCommand.Type.CREATE)) {
+            try {
+              perm.check(GlobalPermission.ACCESS_DATABASE);
+            } catch (AuthException | PermissionBackendException e) {
+              throw new ValidationException("Not allowed to create user branch.");
+            }
+            if (Account.Id.fromRef(refEvent.command.getRefName()) == null) {
+              throw new ValidationException(
+                  String.format(
+                      "Not allowed to create non-user branch under %s.", RefNames.REFS_USERS));
+            }
+          } else if (refEvent.command.getType().equals(ReceiveCommand.Type.DELETE)) {
+            try {
+              perm.check(GlobalPermission.ACCESS_DATABASE);
+            } catch (AuthException | PermissionBackendException e) {
+              throw new ValidationException("Not allowed to delete user branch.");
+            }
+          }
+        }
+
+        if (RefNames.isGroupRef(refEvent.command.getRefName())) {
+          if (refEvent.command.getType().equals(ReceiveCommand.Type.CREATE)) {
+            throw new ValidationException("Not allowed to create group branch.");
+          } else if (refEvent.command.getType().equals(ReceiveCommand.Type.DELETE)) {
+            throw new ValidationException("Not allowed to delete group branch.");
+          }
+        }
+      }
+      return ImmutableList.of();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationException.java b/java/com/google/gerrit/server/git/validators/UploadValidationException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationException.java
rename to java/com/google/gerrit/server/git/validators/UploadValidationException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java b/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
rename to java/com/google/gerrit/server/git/validators/UploadValidationListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidators.java b/java/com/google/gerrit/server/git/validators/UploadValidators.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidators.java
rename to java/com/google/gerrit/server/git/validators/UploadValidators.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/ValidationMessage.java b/java/com/google/gerrit/server/git/validators/ValidationMessage.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/validators/ValidationMessage.java
rename to java/com/google/gerrit/server/git/validators/ValidationMessage.java
diff --git a/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java b/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java
new file mode 100644
index 0000000..0379012
--- /dev/null
+++ b/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java
@@ -0,0 +1,201 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
+
+import com.google.common.base.Joiner;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.UniversalGroupBackend;
+import com.google.gerrit.server.audit.GroupMemberAuditListener;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.slf4j.Logger;
+
+class DbGroupMemberAuditListener implements GroupMemberAuditListener {
+  private static final Logger log =
+      org.slf4j.LoggerFactory.getLogger(DbGroupMemberAuditListener.class);
+
+  private final SchemaFactory<ReviewDb> schema;
+  private final AccountCache accountCache;
+  private final GroupCache groupCache;
+  private final UniversalGroupBackend groupBackend;
+
+  @Inject
+  DbGroupMemberAuditListener(
+      SchemaFactory<ReviewDb> schema,
+      AccountCache accountCache,
+      GroupCache groupCache,
+      UniversalGroupBackend groupBackend) {
+    this.schema = schema;
+    this.accountCache = accountCache;
+    this.groupCache = groupCache;
+    this.groupBackend = groupBackend;
+  }
+
+  @Override
+  public void onAddAccountsToGroup(
+      Account.Id me, Collection<AccountGroupMember> added, Timestamp addedOn) {
+    List<AccountGroupMemberAudit> auditInserts = new ArrayList<>();
+    for (AccountGroupMember m : added) {
+      AccountGroupMemberAudit audit = new AccountGroupMemberAudit(m, me, addedOn);
+      auditInserts.add(audit);
+    }
+    try (ReviewDb db = unwrapDb(schema.open())) {
+      db.accountGroupMembersAudit().insert(auditInserts);
+    } catch (OrmException e) {
+      logOrmExceptionForAccounts(
+          "Cannot log add accounts to group event performed by user", me, added, e);
+    }
+  }
+
+  @Override
+  public void onDeleteAccountsFromGroup(
+      Account.Id me, Collection<AccountGroupMember> removed, Timestamp removedOn) {
+    List<AccountGroupMemberAudit> auditInserts = new ArrayList<>();
+    List<AccountGroupMemberAudit> auditUpdates = new ArrayList<>();
+    try (ReviewDb db = unwrapDb(schema.open())) {
+      for (AccountGroupMember m : removed) {
+        AccountGroupMemberAudit audit = null;
+        for (AccountGroupMemberAudit a :
+            db.accountGroupMembersAudit().byGroupAccount(m.getAccountGroupId(), m.getAccountId())) {
+          if (a.isActive()) {
+            audit = a;
+            break;
+          }
+        }
+
+        if (audit != null) {
+          audit.removed(me, removedOn);
+          auditUpdates.add(audit);
+        } else {
+          audit = new AccountGroupMemberAudit(m, me, removedOn);
+          audit.removedLegacy();
+          auditInserts.add(audit);
+        }
+      }
+      db.accountGroupMembersAudit().update(auditUpdates);
+      db.accountGroupMembersAudit().insert(auditInserts);
+    } catch (OrmException e) {
+      logOrmExceptionForAccounts(
+          "Cannot log delete accounts from group event performed by user", me, removed, e);
+    }
+  }
+
+  @Override
+  public void onAddGroupsToGroup(
+      Account.Id me, Collection<AccountGroupById> added, Timestamp addedOn) {
+    List<AccountGroupByIdAud> includesAudit = new ArrayList<>();
+    for (AccountGroupById groupInclude : added) {
+      AccountGroupByIdAud audit = new AccountGroupByIdAud(groupInclude, me, addedOn);
+      includesAudit.add(audit);
+    }
+    try (ReviewDb db = unwrapDb(schema.open())) {
+      db.accountGroupByIdAud().insert(includesAudit);
+    } catch (OrmException e) {
+      logOrmExceptionForGroups(
+          "Cannot log add groups to group event performed by user", me, added, e);
+    }
+  }
+
+  @Override
+  public void onDeleteGroupsFromGroup(
+      Account.Id me, Collection<AccountGroupById> removed, Timestamp removedOn) {
+    final List<AccountGroupByIdAud> auditUpdates = new ArrayList<>();
+    try (ReviewDb db = unwrapDb(schema.open())) {
+      for (AccountGroupById g : removed) {
+        AccountGroupByIdAud audit = null;
+        for (AccountGroupByIdAud a :
+            db.accountGroupByIdAud().byGroupInclude(g.getGroupId(), g.getIncludeUUID())) {
+          if (a.isActive()) {
+            audit = a;
+            break;
+          }
+        }
+
+        if (audit != null) {
+          audit.removed(me, removedOn);
+          auditUpdates.add(audit);
+        }
+      }
+      db.accountGroupByIdAud().update(auditUpdates);
+    } catch (OrmException e) {
+      logOrmExceptionForGroups(
+          "Cannot log delete groups from group event performed by user", me, removed, e);
+    }
+  }
+
+  private void logOrmExceptionForAccounts(
+      String header, Account.Id me, Collection<AccountGroupMember> values, OrmException e) {
+    List<String> descriptions = new ArrayList<>();
+    for (AccountGroupMember m : values) {
+      Account.Id accountId = m.getAccountId();
+      String userName = accountCache.get(accountId).getUserName();
+      AccountGroup.Id groupId = m.getAccountGroupId();
+      String groupName = getGroupName(groupId);
+
+      descriptions.add(
+          MessageFormat.format(
+              "account {0}/{1}, group {2}/{3}", accountId, userName, groupId, groupName));
+    }
+    logOrmException(header, me, descriptions, e);
+  }
+
+  private void logOrmExceptionForGroups(
+      String header, Account.Id me, Collection<AccountGroupById> values, OrmException e) {
+    List<String> descriptions = new ArrayList<>();
+    for (AccountGroupById m : values) {
+      AccountGroup.UUID groupUuid = m.getIncludeUUID();
+      String groupName = groupBackend.get(groupUuid).getName();
+      AccountGroup.Id targetGroupId = m.getGroupId();
+      String targetGroupName = getGroupName(targetGroupId);
+
+      descriptions.add(
+          MessageFormat.format(
+              "group {0}/{1}, group {2}/{3}",
+              groupUuid, groupName, targetGroupId, targetGroupName));
+    }
+    logOrmException(header, me, descriptions, e);
+  }
+
+  private String getGroupName(AccountGroup.Id groupId) {
+    return groupCache.get(groupId).map(InternalGroup::getName).orElse("Deleted group " + groupId);
+  }
+
+  private void logOrmException(String header, Account.Id me, Iterable<?> values, OrmException e) {
+    StringBuilder message = new StringBuilder(header);
+    message.append(" ");
+    message.append(me);
+    message.append("/");
+    message.append(accountCache.get(me).getUserName());
+    message.append(": ");
+    message.append(Joiner.on("; ").join(values));
+    log.error(message.toString(), e);
+  }
+}
diff --git a/java/com/google/gerrit/server/group/GroupResource.java b/java/com/google/gerrit/server/group/GroupResource.java
new file mode 100644
index 0000000..1050314
--- /dev/null
+++ b/java/com/google/gerrit/server/group/GroupResource.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.inject.TypeLiteral;
+import java.util.Optional;
+
+public class GroupResource implements RestResource {
+  public static final TypeLiteral<RestView<GroupResource>> GROUP_KIND =
+      new TypeLiteral<RestView<GroupResource>>() {};
+
+  private final GroupControl control;
+
+  public GroupResource(GroupControl control) {
+    this.control = control;
+  }
+
+  GroupResource(GroupResource rsrc) {
+    this.control = rsrc.getControl();
+  }
+
+  public GroupDescription.Basic getGroup() {
+    return control.getGroup();
+  }
+
+  public String getName() {
+    return getGroup().getName();
+  }
+
+  public boolean isInternalGroup() {
+    GroupDescription.Basic group = getGroup();
+    return group instanceof GroupDescription.Internal;
+  }
+
+  public Optional<GroupDescription.Internal> asInternalGroup() {
+    GroupDescription.Basic group = getGroup();
+    if (group instanceof GroupDescription.Internal) {
+      return Optional.of((GroupDescription.Internal) group);
+    }
+    return Optional.empty();
+  }
+
+  public GroupControl getControl() {
+    return control;
+  }
+}
diff --git a/java/com/google/gerrit/server/group/InternalGroup.java b/java/com/google/gerrit/server/group/InternalGroup.java
new file mode 100644
index 0000000..7828586
--- /dev/null
+++ b/java/com/google/gerrit/server/group/InternalGroup.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.group;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.io.Serializable;
+import java.sql.Timestamp;
+import org.eclipse.jgit.lib.ObjectId;
+
+@AutoValue
+public abstract class InternalGroup implements Serializable {
+  private static final long serialVersionUID = 1L;
+
+  public static InternalGroup create(
+      AccountGroup accountGroup,
+      ImmutableSet<Account.Id> members,
+      ImmutableSet<AccountGroup.UUID> subgroups) {
+    return create(accountGroup, members, subgroups, null);
+  }
+
+  public static InternalGroup create(
+      AccountGroup accountGroup,
+      ImmutableSet<Account.Id> members,
+      ImmutableSet<AccountGroup.UUID> subgroups,
+      ObjectId refState) {
+    return builder()
+        .setId(accountGroup.getId())
+        .setNameKey(accountGroup.getNameKey())
+        .setDescription(accountGroup.getDescription())
+        .setOwnerGroupUUID(accountGroup.getOwnerGroupUUID())
+        .setVisibleToAll(accountGroup.isVisibleToAll())
+        .setGroupUUID(accountGroup.getGroupUUID())
+        .setCreatedOn(accountGroup.getCreatedOn())
+        .setMembers(members)
+        .setSubgroups(subgroups)
+        .setRefState(refState)
+        .build();
+  }
+
+  public abstract AccountGroup.Id getId();
+
+  public String getName() {
+    return getNameKey().get();
+  }
+
+  public abstract AccountGroup.NameKey getNameKey();
+
+  @Nullable
+  public abstract String getDescription();
+
+  public abstract AccountGroup.UUID getOwnerGroupUUID();
+
+  public abstract boolean isVisibleToAll();
+
+  public abstract AccountGroup.UUID getGroupUUID();
+
+  public abstract Timestamp getCreatedOn();
+
+  public abstract ImmutableSet<Account.Id> getMembers();
+
+  public abstract ImmutableSet<AccountGroup.UUID> getSubgroups();
+
+  @Nullable
+  public abstract ObjectId getRefState();
+
+  public abstract Builder toBuilder();
+
+  public static Builder builder() {
+    return new AutoValue_InternalGroup.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setId(AccountGroup.Id id);
+
+    public abstract Builder setNameKey(AccountGroup.NameKey name);
+
+    public abstract Builder setDescription(@Nullable String description);
+
+    public abstract Builder setOwnerGroupUUID(AccountGroup.UUID ownerGroupUuid);
+
+    public abstract Builder setVisibleToAll(boolean visibleToAll);
+
+    public abstract Builder setGroupUUID(AccountGroup.UUID groupUuid);
+
+    public abstract Builder setCreatedOn(Timestamp createdOn);
+
+    public abstract Builder setMembers(ImmutableSet<Account.Id> members);
+
+    public abstract Builder setSubgroups(ImmutableSet<AccountGroup.UUID> subgroups);
+
+    public abstract Builder setRefState(ObjectId refState);
+
+    public abstract InternalGroup build();
+  }
+}
diff --git a/java/com/google/gerrit/server/group/InternalGroupDescription.java b/java/com/google/gerrit/server/group/InternalGroupDescription.java
new file mode 100644
index 0000000..3981b70
--- /dev/null
+++ b/java/com/google/gerrit/server/group/InternalGroupDescription.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.sql.Timestamp;
+
+public class InternalGroupDescription implements GroupDescription.Internal {
+
+  private final InternalGroup internalGroup;
+
+  public InternalGroupDescription(InternalGroup internalGroup) {
+    this.internalGroup = checkNotNull(internalGroup);
+  }
+
+  @Override
+  public AccountGroup.UUID getGroupUUID() {
+    return internalGroup.getGroupUUID();
+  }
+
+  @Override
+  public String getName() {
+    return internalGroup.getName();
+  }
+
+  @Nullable
+  @Override
+  public String getEmailAddress() {
+    return null;
+  }
+
+  @Nullable
+  @Override
+  public String getUrl() {
+    return "#" + PageLinks.toGroup(getGroupUUID());
+  }
+
+  @Override
+  public AccountGroup.Id getId() {
+    return internalGroup.getId();
+  }
+
+  @Override
+  @Nullable
+  public String getDescription() {
+    return internalGroup.getDescription();
+  }
+
+  @Override
+  public AccountGroup.UUID getOwnerGroupUUID() {
+    return internalGroup.getOwnerGroupUUID();
+  }
+
+  @Override
+  public boolean isVisibleToAll() {
+    return internalGroup.isVisibleToAll();
+  }
+
+  @Override
+  public Timestamp getCreatedOn() {
+    return internalGroup.getCreatedOn();
+  }
+
+  @Override
+  public ImmutableSet<Account.Id> getMembers() {
+    return internalGroup.getMembers();
+  }
+
+  @Override
+  public ImmutableSet<AccountGroup.UUID> getSubgroups() {
+    return internalGroup.getSubgroups();
+  }
+}
diff --git a/java/com/google/gerrit/server/group/MemberResource.java b/java/com/google/gerrit/server/group/MemberResource.java
new file mode 100644
index 0000000..b12cadd
--- /dev/null
+++ b/java/com/google/gerrit/server/group/MemberResource.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.inject.TypeLiteral;
+
+public class MemberResource extends GroupResource {
+  public static final TypeLiteral<RestView<MemberResource>> MEMBER_KIND =
+      new TypeLiteral<RestView<MemberResource>>() {};
+
+  private final IdentifiedUser user;
+
+  public MemberResource(GroupResource group, IdentifiedUser user) {
+    super(group);
+    this.user = user;
+  }
+
+  public IdentifiedUser getMember() {
+    return user;
+  }
+}
diff --git a/java/com/google/gerrit/server/group/Module.java b/java/com/google/gerrit/server/group/Module.java
new file mode 100644
index 0000000..f40e3b9
--- /dev/null
+++ b/java/com/google/gerrit/server/group/Module.java
@@ -0,0 +1,25 @@
+package com.google.gerrit.server.group;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.audit.GroupMemberAuditListener;
+import com.google.gerrit.server.notedb.GroupsMigration;
+
+public class Module extends FactoryModule {
+  private final GroupsMigration groupsMigration;
+
+  public Module(GroupsMigration groupsMigration) {
+    this.groupsMigration = groupsMigration;
+  }
+
+  @Override
+  protected void configure() {
+    if (!groupsMigration.disableGroupReviewDb()) {
+      // DbGroupMemberAuditListener is used solely for the ReviewDb audit log. It does not respect
+      // ReviewDb wrappers that disable reads. Hence, we don't want to bind it if ReviewDb is
+      // disabled.
+      DynamicSet.bind(binder(), GroupMemberAuditListener.class)
+          .to(DbGroupMemberAuditListener.class);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ServerInitiated.java b/java/com/google/gerrit/server/group/ServerInitiated.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/group/ServerInitiated.java
rename to java/com/google/gerrit/server/group/ServerInitiated.java
diff --git a/java/com/google/gerrit/server/group/SubgroupResource.java b/java/com/google/gerrit/server/group/SubgroupResource.java
new file mode 100644
index 0000000..a33e96b
--- /dev/null
+++ b/java/com/google/gerrit/server/group/SubgroupResource.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.inject.TypeLiteral;
+
+public class SubgroupResource extends GroupResource {
+  public static final TypeLiteral<RestView<SubgroupResource>> SUBGROUP_KIND =
+      new TypeLiteral<RestView<SubgroupResource>>() {};
+
+  private final GroupDescription.Basic member;
+
+  public SubgroupResource(GroupResource group, GroupDescription.Basic member) {
+    super(group);
+    this.member = member;
+  }
+
+  public AccountGroup.UUID getMember() {
+    return getMemberDescription().getGroupUUID();
+  }
+
+  public GroupDescription.Basic getMemberDescription() {
+    return member;
+  }
+}
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
new file mode 100644
index 0000000..91cc11c
--- /dev/null
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -0,0 +1,255 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StartupCheck;
+import com.google.gerrit.server.StartupException;
+import com.google.gerrit.server.account.AbstractGroupBackend;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.ListGroupMembership;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class SystemGroupBackend extends AbstractGroupBackend {
+  public static final String SYSTEM_GROUP_SCHEME = "global:";
+
+  /** Common UUID assigned to the "Anonymous Users" group. */
+  public static final AccountGroup.UUID ANONYMOUS_USERS =
+      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Anonymous-Users");
+
+  /** Common UUID assigned to the "Registered Users" group. */
+  public static final AccountGroup.UUID REGISTERED_USERS =
+      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Registered-Users");
+
+  /** Common UUID assigned to the "Project Owners" placeholder group. */
+  public static final AccountGroup.UUID PROJECT_OWNERS =
+      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Project-Owners");
+
+  /** Common UUID assigned to the "Change Owner" placeholder group. */
+  public static final AccountGroup.UUID CHANGE_OWNER =
+      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Change-Owner");
+
+  private static final AccountGroup.UUID[] all = {
+    ANONYMOUS_USERS, REGISTERED_USERS, PROJECT_OWNERS, CHANGE_OWNER,
+  };
+
+  public static boolean isSystemGroup(AccountGroup.UUID uuid) {
+    return uuid.get().startsWith(SYSTEM_GROUP_SCHEME);
+  }
+
+  public static boolean isAnonymousOrRegistered(GroupReference ref) {
+    return isAnonymousOrRegistered(ref.getUUID());
+  }
+
+  public static boolean isAnonymousOrRegistered(AccountGroup.UUID uuid) {
+    return ANONYMOUS_USERS.equals(uuid) || REGISTERED_USERS.equals(uuid);
+  }
+
+  private final ImmutableSet<String> reservedNames;
+  private final SortedMap<String, GroupReference> namesToGroups;
+  private final ImmutableSet<String> names;
+  private final ImmutableMap<AccountGroup.UUID, GroupReference> uuids;
+
+  @Inject
+  @VisibleForTesting
+  public SystemGroupBackend(@GerritServerConfig Config cfg) {
+    SortedMap<String, GroupReference> n = new TreeMap<>();
+    ImmutableMap.Builder<AccountGroup.UUID, GroupReference> u = ImmutableMap.builder();
+
+    ImmutableSet.Builder<String> reservedNamesBuilder = ImmutableSet.builder();
+    for (AccountGroup.UUID uuid : all) {
+      int c = uuid.get().indexOf(':');
+      String defaultName = uuid.get().substring(c + 1).replace('-', ' ');
+      reservedNamesBuilder.add(defaultName);
+      String configuredName = cfg.getString("groups", uuid.get(), "name");
+      GroupReference ref =
+          new GroupReference(uuid, MoreObjects.firstNonNull(configuredName, defaultName));
+      n.put(ref.getName().toLowerCase(Locale.US), ref);
+      u.put(ref.getUUID(), ref);
+    }
+    reservedNames = reservedNamesBuilder.build();
+    namesToGroups = Collections.unmodifiableSortedMap(n);
+    names =
+        ImmutableSet.copyOf(namesToGroups.values().stream().map(r -> r.getName()).collect(toSet()));
+    uuids = u.build();
+  }
+
+  public GroupReference getGroup(AccountGroup.UUID uuid) {
+    return checkNotNull(uuids.get(uuid), "group %s not found", uuid.get());
+  }
+
+  public Set<String> getNames() {
+    return names;
+  }
+
+  public Set<String> getReservedNames() {
+    return reservedNames;
+  }
+
+  @Override
+  public boolean handles(AccountGroup.UUID uuid) {
+    return isSystemGroup(uuid);
+  }
+
+  @Override
+  public GroupDescription.Basic get(AccountGroup.UUID uuid) {
+    final GroupReference ref = uuids.get(uuid);
+    if (ref == null) {
+      return null;
+    }
+    return new GroupDescription.Basic() {
+      @Override
+      public String getName() {
+        return ref.getName();
+      }
+
+      @Override
+      public AccountGroup.UUID getGroupUUID() {
+        return ref.getUUID();
+      }
+
+      @Override
+      public String getUrl() {
+        return null;
+      }
+
+      @Override
+      public String getEmailAddress() {
+        return null;
+      }
+    };
+  }
+
+  @Override
+  public Collection<GroupReference> suggest(String name, ProjectState project) {
+    String nameLC = name.toLowerCase(Locale.US);
+    SortedMap<String, GroupReference> matches = namesToGroups.tailMap(nameLC);
+    if (matches.isEmpty()) {
+      return Collections.emptyList();
+    }
+
+    List<GroupReference> r = new ArrayList<>(matches.size());
+    for (Map.Entry<String, GroupReference> e : matches.entrySet()) {
+      if (e.getKey().startsWith(nameLC)) {
+        r.add(e.getValue());
+      } else {
+        break;
+      }
+    }
+    return r;
+  }
+
+  @Override
+  public GroupMembership membershipsOf(IdentifiedUser user) {
+    return new ListGroupMembership(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS));
+  }
+
+  public static class NameCheck implements StartupCheck {
+    private final Config cfg;
+    private final Groups groups;
+    private final SchemaFactory<ReviewDb> schema;
+
+    @Inject
+    NameCheck(@GerritServerConfig Config cfg, Groups groups, SchemaFactory<ReviewDb> schema) {
+      this.cfg = cfg;
+      this.groups = groups;
+      this.schema = schema;
+    }
+
+    @Override
+    public void check() throws StartupException {
+      Map<AccountGroup.UUID, String> configuredNames = new HashMap<>();
+      Map<String, AccountGroup.UUID> byLowerCaseConfiguredName = new HashMap<>();
+      for (AccountGroup.UUID uuid : all) {
+        String configuredName = cfg.getString("groups", uuid.get(), "name");
+        if (configuredName != null) {
+          configuredNames.put(uuid, configuredName);
+          byLowerCaseConfiguredName.put(configuredName.toLowerCase(Locale.US), uuid);
+        }
+      }
+      if (configuredNames.isEmpty()) {
+        return;
+      }
+
+      Optional<GroupReference> conflictingGroup;
+      try (ReviewDb db = schema.open()) {
+        conflictingGroup =
+            groups
+                .getAllGroupReferences(db)
+                .filter(group -> hasConfiguredName(byLowerCaseConfiguredName, group))
+                .findAny();
+
+      } catch (OrmException | IOException | ConfigInvalidException ignored) {
+        return;
+      }
+
+      if (conflictingGroup.isPresent()) {
+        GroupReference group = conflictingGroup.get();
+        String groupName = group.getName();
+        AccountGroup.UUID systemGroupUuid = byLowerCaseConfiguredName.get(groupName);
+        throw new StartupException(
+            getAmbiguousNameMessage(groupName, group.getUUID(), systemGroupUuid));
+      }
+    }
+
+    private static boolean hasConfiguredName(
+        Map<String, AccountGroup.UUID> byLowerCaseConfiguredName, GroupReference group) {
+      String name = group.getName().toLowerCase(Locale.US);
+      return byLowerCaseConfiguredName.keySet().contains(name);
+    }
+
+    private static String getAmbiguousNameMessage(
+        String groupName, AccountGroup.UUID groupUuid, AccountGroup.UUID systemGroupUuid) {
+      return String.format(
+          "The configured name '%s' for system group '%s' is ambiguous"
+              + " with the name '%s' of existing group '%s'."
+              + " Please remove/change the value for groups.%s.name in"
+              + " gerrit.config.",
+          groupName, systemGroupUuid.get(), groupName, groupUuid.get(), systemGroupUuid.get());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/UserInitiated.java b/java/com/google/gerrit/server/group/UserInitiated.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/group/UserInitiated.java
rename to java/com/google/gerrit/server/group/UserInitiated.java
diff --git a/java/com/google/gerrit/server/group/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
new file mode 100644
index 0000000..c7add92
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -0,0 +1,259 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.notedb.NoteDbUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** NoteDb reader for group audit log. */
+@Singleton
+class AuditLogReader {
+  private static final Logger log = LoggerFactory.getLogger(AuditLogReader.class);
+
+  private final String serverId;
+
+  @Inject
+  AuditLogReader(@GerritServerId String serverId) {
+    this.serverId = serverId;
+  }
+
+  // Having separate methods for reading the two types of audit records mirrors the split in
+  // ReviewDb. Once ReviewDb is gone, the audit record interface becomes more flexible and we can
+  // revisit this, e.g. to do only a single walk, or even change the record types.
+
+  ImmutableList<AccountGroupMemberAudit> getMembersAudit(Repository repo, AccountGroup.UUID uuid)
+      throws IOException, ConfigInvalidException {
+    return getMembersAudit(getGroupId(repo, uuid), parseCommits(repo, uuid));
+  }
+
+  private ImmutableList<AccountGroupMemberAudit> getMembersAudit(
+      AccountGroup.Id groupId, List<ParsedCommit> commits) {
+    ListMultimap<MemberKey, AccountGroupMemberAudit> audits =
+        MultimapBuilder.hashKeys().linkedListValues().build();
+    ImmutableList.Builder<AccountGroupMemberAudit> result = ImmutableList.builder();
+    for (ParsedCommit pc : commits) {
+      for (Account.Id id : pc.addedMembers()) {
+        MemberKey key = MemberKey.create(groupId, id);
+        AccountGroupMemberAudit audit =
+            new AccountGroupMemberAudit(
+                new AccountGroupMemberAudit.Key(id, groupId, pc.when()), pc.authorId());
+        audits.put(key, audit);
+        result.add(audit);
+      }
+      for (Account.Id id : pc.removedMembers()) {
+        List<AccountGroupMemberAudit> adds = audits.get(MemberKey.create(groupId, id));
+        if (!adds.isEmpty()) {
+          AccountGroupMemberAudit audit = adds.remove(0);
+          audit.removed(pc.authorId(), pc.when());
+        } else {
+          // Match old behavior of DbGroupMemberAuditListener and add a "legacy" add/remove pair.
+          AccountGroupMemberAudit audit =
+              new AccountGroupMemberAudit(
+                  new AccountGroupMemberAudit.Key(id, groupId, pc.when()), pc.authorId());
+          audit.removedLegacy();
+          result.add(audit);
+        }
+      }
+    }
+    return result.build();
+  }
+
+  ImmutableList<AccountGroupByIdAud> getSubgroupsAudit(Repository repo, AccountGroup.UUID uuid)
+      throws IOException, ConfigInvalidException {
+    return getSubgroupsAudit(getGroupId(repo, uuid), parseCommits(repo, uuid));
+  }
+
+  private ImmutableList<AccountGroupByIdAud> getSubgroupsAudit(
+      AccountGroup.Id groupId, List<ParsedCommit> commits) {
+    ListMultimap<SubgroupKey, AccountGroupByIdAud> audits =
+        MultimapBuilder.hashKeys().linkedListValues().build();
+    ImmutableList.Builder<AccountGroupByIdAud> result = ImmutableList.builder();
+    for (ParsedCommit pc : commits) {
+      for (AccountGroup.UUID uuid : pc.addedSubgroups()) {
+        SubgroupKey key = SubgroupKey.create(groupId, uuid);
+        AccountGroupByIdAud audit =
+            new AccountGroupByIdAud(
+                new AccountGroupByIdAud.Key(groupId, uuid, pc.when()), pc.authorId());
+        audits.put(key, audit);
+        result.add(audit);
+      }
+      for (AccountGroup.UUID uuid : pc.removedSubgroups()) {
+        List<AccountGroupByIdAud> adds = audits.get(SubgroupKey.create(groupId, uuid));
+        if (!adds.isEmpty()) {
+          AccountGroupByIdAud audit = adds.remove(0);
+          audit.removed(pc.authorId(), pc.when());
+        } else {
+          // Unlike members, DbGroupMemberAuditListener didn't insert an add/remove pair here.
+        }
+      }
+    }
+    return result.build();
+  }
+
+  private Optional<ParsedCommit> parse(AccountGroup.UUID uuid, RevCommit c) {
+    Optional<Account.Id> authorId = NoteDbUtil.parseIdent(c.getAuthorIdent(), serverId);
+    if (!authorId.isPresent()) {
+      // Only report audit events from identified users, since this is a non-nullable field in
+      // ReviewDb. May be revisited after groups are fully migrated to NoteDb.
+      return Optional.empty();
+    }
+
+    List<Account.Id> addedMembers = new ArrayList<>();
+    List<AccountGroup.UUID> addedSubgroups = new ArrayList<>();
+    List<Account.Id> removedMembers = new ArrayList<>();
+    List<AccountGroup.UUID> removedSubgroups = new ArrayList<>();
+
+    for (FooterLine line : c.getFooterLines()) {
+      if (line.matches(GroupConfig.FOOTER_ADD_MEMBER)) {
+        parseAccount(uuid, c, line).ifPresent(addedMembers::add);
+      } else if (line.matches(GroupConfig.FOOTER_REMOVE_MEMBER)) {
+        parseAccount(uuid, c, line).ifPresent(removedMembers::add);
+      } else if (line.matches(GroupConfig.FOOTER_ADD_GROUP)) {
+        parseGroup(uuid, c, line).ifPresent(addedSubgroups::add);
+      } else if (line.matches(GroupConfig.FOOTER_REMOVE_GROUP)) {
+        parseGroup(uuid, c, line).ifPresent(removedSubgroups::add);
+      }
+    }
+    return Optional.of(
+        new AutoValue_AuditLogReader_ParsedCommit(
+            authorId.get(),
+            new Timestamp(c.getAuthorIdent().getWhen().getTime()),
+            ImmutableList.copyOf(addedMembers),
+            ImmutableList.copyOf(removedMembers),
+            ImmutableList.copyOf(addedSubgroups),
+            ImmutableList.copyOf(removedSubgroups)));
+  }
+
+  private Optional<Account.Id> parseAccount(AccountGroup.UUID uuid, RevCommit c, FooterLine line) {
+    Optional<Account.Id> result =
+        Optional.ofNullable(RawParseUtils.parsePersonIdent(line.getValue()))
+            .flatMap(ident -> NoteDbUtil.parseIdent(ident, serverId));
+    if (!result.isPresent()) {
+      logInvalid(uuid, c, line);
+    }
+    return result;
+  }
+
+  private static Optional<AccountGroup.UUID> parseGroup(
+      AccountGroup.UUID uuid, RevCommit c, FooterLine line) {
+    PersonIdent ident = RawParseUtils.parsePersonIdent(line.getValue());
+    if (ident == null) {
+      logInvalid(uuid, c, line);
+      return Optional.empty();
+    }
+    return Optional.of(new AccountGroup.UUID(ident.getEmailAddress()));
+  }
+
+  private static void logInvalid(AccountGroup.UUID uuid, RevCommit c, FooterLine line) {
+    log.debug(
+        "Invalid footer line in commit {} while parsing audit log for group {}: {}",
+        c.name(),
+        uuid,
+        line);
+  }
+
+  private ImmutableList<ParsedCommit> parseCommits(Repository repo, AccountGroup.UUID uuid)
+      throws IOException {
+    try (RevWalk rw = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
+      if (ref == null) {
+        return ImmutableList.of();
+      }
+
+      rw.reset();
+      rw.markStart(rw.parseCommit(ref.getObjectId()));
+      rw.setRetainBody(true);
+      rw.sort(RevSort.COMMIT_TIME_DESC, true);
+      rw.sort(RevSort.REVERSE, true);
+
+      ImmutableList.Builder<ParsedCommit> result = ImmutableList.builder();
+      RevCommit c;
+      while ((c = rw.next()) != null) {
+        parse(uuid, c).ifPresent(result::add);
+      }
+      return result.build();
+    }
+  }
+
+  private AccountGroup.Id getGroupId(Repository repo, AccountGroup.UUID uuid)
+      throws ConfigInvalidException, IOException {
+    // TODO(dborowitz): This re-walks all commits just to find createdOn, which we don't need.
+    return GroupConfig.loadForGroup(repo, uuid).getLoadedGroup().get().getId();
+  }
+
+  @AutoValue
+  abstract static class MemberKey {
+    static MemberKey create(AccountGroup.Id groupId, Account.Id memberId) {
+      return new AutoValue_AuditLogReader_MemberKey(groupId, memberId);
+    }
+
+    abstract AccountGroup.Id groupId();
+
+    abstract Account.Id memberId();
+  }
+
+  @AutoValue
+  abstract static class SubgroupKey {
+    static SubgroupKey create(AccountGroup.Id groupId, AccountGroup.UUID subgroupUuid) {
+      return new AutoValue_AuditLogReader_SubgroupKey(groupId, subgroupUuid);
+    }
+
+    abstract AccountGroup.Id groupId();
+
+    abstract AccountGroup.UUID subgroupUuid();
+  }
+
+  @AutoValue
+  abstract static class ParsedCommit {
+    abstract Account.Id authorId();
+
+    abstract Timestamp when();
+
+    abstract ImmutableList<Account.Id> addedMembers();
+
+    abstract ImmutableList<Account.Id> removedMembers();
+
+    abstract ImmutableList<AccountGroup.UUID> addedSubgroups();
+
+    abstract ImmutableList<AccountGroup.UUID> removedSubgroups();
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/GroupBundle.java b/java/com/google/gerrit/server/group/db/GroupBundle.java
new file mode 100644
index 0000000..5d339c0
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/GroupBundle.java
@@ -0,0 +1,611 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.checkColumns;
+import static java.util.Comparator.naturalOrder;
+import static java.util.Comparator.nullsLast;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Multimaps;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A bundle of all entities rooted at a single {@link AccountGroup} entity.
+ *
+ * <p>Used primarily during the migration process. Most callers should prefer {@link InternalGroup}
+ * instead.
+ */
+@AutoValue
+public abstract class GroupBundle {
+  private static final Logger log = LoggerFactory.getLogger(GroupBundle.class);
+
+  static {
+    // Initialization-time checks that the column set hasn't changed since the
+    // last time this file was updated.
+    checkColumns(AccountGroup.NameKey.class, 1);
+    checkColumns(AccountGroup.UUID.class, 1);
+    checkColumns(AccountGroup.Id.class, 1);
+    checkColumns(AccountGroup.class, 1, 2, 4, 7, 9, 10, 11);
+
+    checkColumns(AccountGroupById.Key.class, 1, 2);
+    checkColumns(AccountGroupById.class, 1);
+
+    checkColumns(AccountGroupByIdAud.Key.class, 1, 2, 3);
+    checkColumns(AccountGroupByIdAud.class, 1, 2, 3, 4);
+
+    checkColumns(AccountGroupMember.Key.class, 1, 2);
+    checkColumns(AccountGroupMember.class, 1);
+
+    checkColumns(AccountGroupMemberAudit.Key.class, 1, 2, 3);
+    checkColumns(AccountGroupMemberAudit.class, 1, 2, 3, 4);
+  }
+
+  public enum Source {
+    REVIEW_DB("ReviewDb"),
+    NOTE_DB("NoteDb");
+
+    private final String name;
+
+    private Source(String name) {
+      this.name = name;
+    }
+
+    @Override
+    public String toString() {
+      return name;
+    }
+  }
+
+  @Singleton
+  public static class Factory {
+    private final AuditLogReader auditLogReader;
+
+    @Inject
+    Factory(AuditLogReader auditLogReader) {
+      this.auditLogReader = auditLogReader;
+    }
+
+    public GroupBundle fromReviewDb(ReviewDb db, AccountGroup.Id id) throws OrmException {
+      AccountGroup group = db.accountGroups().get(id);
+      if (group == null) {
+        throw new OrmException("Group " + id + " not found");
+      }
+      return create(
+          Source.REVIEW_DB,
+          group,
+          db.accountGroupMembers().byGroup(id).toList(),
+          db.accountGroupMembersAudit().byGroup(id).toList(),
+          db.accountGroupById().byGroup(id).toList(),
+          db.accountGroupByIdAud().byGroup(id).toList());
+    }
+
+    public GroupBundle fromNoteDb(Repository repo, AccountGroup.UUID uuid)
+        throws ConfigInvalidException, IOException {
+      GroupConfig groupConfig = GroupConfig.loadForGroup(repo, uuid);
+      InternalGroup internalGroup = groupConfig.getLoadedGroup().get();
+      AccountGroup.Id groupId = internalGroup.getId();
+
+      AccountGroup accountGroup =
+          new AccountGroup(
+              internalGroup.getNameKey(),
+              internalGroup.getId(),
+              internalGroup.getGroupUUID(),
+              internalGroup.getCreatedOn());
+      accountGroup.setDescription(internalGroup.getDescription());
+      accountGroup.setOwnerGroupUUID(internalGroup.getOwnerGroupUUID());
+      accountGroup.setVisibleToAll(internalGroup.isVisibleToAll());
+
+      return create(
+          Source.NOTE_DB,
+          accountGroup,
+          internalGroup
+              .getMembers()
+              .stream()
+              .map(
+                  accountId ->
+                      new AccountGroupMember(new AccountGroupMember.Key(accountId, groupId)))
+              .collect(toImmutableSet()),
+          auditLogReader.getMembersAudit(repo, uuid),
+          internalGroup
+              .getSubgroups()
+              .stream()
+              .map(
+                  subgroupUuid ->
+                      new AccountGroupById(new AccountGroupById.Key(groupId, subgroupUuid)))
+              .collect(toImmutableSet()),
+          auditLogReader.getSubgroupsAudit(repo, uuid));
+    }
+  }
+
+  private static final Comparator<AccountGroupMember> ACCOUNT_GROUP_MEMBER_COMPARATOR =
+      Comparator.comparingInt((AccountGroupMember m) -> m.getAccountGroupId().get())
+          .thenComparingInt(m -> m.getAccountId().get());
+
+  private static final Comparator<AccountGroupMemberAudit> ACCOUNT_GROUP_MEMBER_AUDIT_COMPARATOR =
+      Comparator.comparingInt((AccountGroupMemberAudit a) -> a.getGroupId().get())
+          .thenComparing(a -> a.getAddedOn())
+          .thenComparingInt(a -> a.getAddedBy().get())
+          .thenComparingInt(a -> a.getMemberId().get())
+          .thenComparing(
+              a -> a.getRemovedBy() != null ? a.getRemovedBy().get() : null,
+              nullsLast(naturalOrder()))
+          .thenComparing(a -> a.getRemovedOn(), nullsLast(naturalOrder()));
+
+  private static final Comparator<AccountGroupById> ACCOUNT_GROUP_BY_ID_COMPARATOR =
+      Comparator.comparingInt((AccountGroupById m) -> m.getGroupId().get())
+          .thenComparing(m -> m.getIncludeUUID());
+
+  private static final Comparator<AccountGroupByIdAud> ACCOUNT_GROUP_BY_ID_AUD_COMPARATOR =
+      Comparator.comparingInt((AccountGroupByIdAud a) -> a.getGroupId().get())
+          .thenComparing(a -> a.getAddedOn())
+          .thenComparingInt(a -> a.getAddedBy().get())
+          .thenComparing(a -> a.getIncludeUUID())
+          .thenComparing(
+              a -> a.getRemovedBy() != null ? a.getRemovedBy().get() : null,
+              nullsLast(naturalOrder()))
+          .thenComparing(a -> a.getRemovedOn(), nullsLast(naturalOrder()));
+
+  private static final Comparator<AuditEntry> AUDIT_ENTRY_COMPARATOR =
+      Comparator.comparing(AuditEntry::getTimestamp)
+          .thenComparing(AuditEntry::getAction, Comparator.comparingInt(Action::getOrder));
+
+  public static GroupBundle create(
+      Source source,
+      AccountGroup group,
+      Iterable<AccountGroupMember> members,
+      Iterable<AccountGroupMemberAudit> memberAudit,
+      Iterable<AccountGroupById> byId,
+      Iterable<AccountGroupByIdAud> byIdAudit) {
+    AccountGroup.UUID uuid = group.getGroupUUID();
+    return new AutoValue_GroupBundle.Builder()
+        .source(source)
+        .group(group)
+        .members(
+            logIfNotUnique(
+                source, uuid, members, ACCOUNT_GROUP_MEMBER_COMPARATOR, AccountGroupMember.class))
+        .memberAudit(
+            logIfNotUnique(
+                source,
+                uuid,
+                memberAudit,
+                ACCOUNT_GROUP_MEMBER_AUDIT_COMPARATOR,
+                AccountGroupMemberAudit.class))
+        .byId(
+            logIfNotUnique(
+                source, uuid, byId, ACCOUNT_GROUP_BY_ID_COMPARATOR, AccountGroupById.class))
+        .byIdAudit(
+            logIfNotUnique(
+                source,
+                uuid,
+                byIdAudit,
+                ACCOUNT_GROUP_BY_ID_AUD_COMPARATOR,
+                AccountGroupByIdAud.class))
+        .build();
+  }
+
+  private static <T> ImmutableSet<T> logIfNotUnique(
+      Source source,
+      AccountGroup.UUID uuid,
+      Iterable<T> iterable,
+      Comparator<T> comparator,
+      Class<T> clazz) {
+    List<T> list = Streams.stream(iterable).sorted(comparator).collect(toList());
+    ImmutableSet<T> set = ImmutableSet.copyOf(list);
+    if (set.size() != list.size()) {
+      // One way this can happen is that distinct audit entities can compare equal, because
+      // AccountGroup{MemberAudit,ByIdAud}.Key does not include the addedOn timestamp in its
+      // members() list. However, this particular issue only applies to pure adds, since removedOn
+      // *is* included in equality. As a result, if this happens, it means the audit log is already
+      // corrupt, and it's not clear if we can programmatically repair it. For migrating to NoteDb,
+      // we'll try our best to recreate it, but no guarantees it will match the real sequence of
+      // attempted operations, which is in any case lost in the mists of time.
+      log.warn(
+          "group {} in {} has duplicate {} entities: {}",
+          uuid,
+          source,
+          clazz.getSimpleName(),
+          iterable);
+    }
+    return set;
+  }
+
+  static Builder builder() {
+    return new AutoValue_GroupBundle.Builder().members().memberAudit().byId().byIdAudit();
+  }
+
+  public static ImmutableList<String> compareWithAudits(
+      GroupBundle reviewDbBundle, GroupBundle noteDbBundle) {
+    return compare(reviewDbBundle, noteDbBundle, true);
+  }
+
+  public static ImmutableList<String> compareWithoutAudits(
+      GroupBundle reviewDbBundle, GroupBundle noteDbBundle) {
+    return compare(reviewDbBundle, noteDbBundle, false);
+  }
+
+  private static ImmutableList<String> compare(
+      GroupBundle reviewDbBundle, GroupBundle noteDbBundle, boolean compareAudits) {
+    // Normalize the ReviewDb bundle to what we expect in NoteDb. This means that values in error
+    // messages will not reflect the actual data in ReviewDb, but it will make it easier for humans
+    // to see the difference.
+    reviewDbBundle = reviewDbBundle.truncateToSecond();
+    AccountGroup reviewDbGroup = new AccountGroup(reviewDbBundle.group());
+    reviewDbGroup.setDescription(Strings.emptyToNull(reviewDbGroup.getDescription()));
+    reviewDbBundle = reviewDbBundle.toBuilder().group(reviewDbGroup).build();
+
+    checkArgument(
+        reviewDbBundle.source() == Source.REVIEW_DB,
+        "first bundle's source must be %s: %s",
+        Source.REVIEW_DB,
+        reviewDbBundle);
+    checkArgument(
+        noteDbBundle.source() == Source.NOTE_DB,
+        "second bundle's source must be %s: %s",
+        Source.NOTE_DB,
+        noteDbBundle);
+
+    ImmutableList.Builder<String> result = ImmutableList.builder();
+    if (!reviewDbBundle.group().equals(noteDbBundle.group())) {
+      result.add(
+          "AccountGroups differ\n"
+              + ("ReviewDb: " + reviewDbBundle.group() + "\n")
+              + ("NoteDb  : " + noteDbBundle.group()));
+    }
+    if (!reviewDbBundle.members().equals(noteDbBundle.members())) {
+      result.add(
+          "AccountGroupMembers differ\n"
+              + ("ReviewDb: " + reviewDbBundle.members() + "\n")
+              + ("NoteDb  : " + noteDbBundle.members()));
+    }
+    if (compareAudits
+        && !areMemberAuditsConsideredEqual(
+            reviewDbBundle.memberAudit(), noteDbBundle.memberAudit())) {
+      result.add(
+          "AccountGroupMemberAudits differ\n"
+              + ("ReviewDb: " + reviewDbBundle.memberAudit() + "\n")
+              + ("NoteDb  : " + noteDbBundle.memberAudit()));
+    }
+    if (!reviewDbBundle.byId().equals(noteDbBundle.byId())) {
+      result.add(
+          "AccountGroupByIds differ\n"
+              + ("ReviewDb: " + reviewDbBundle.byId() + "\n")
+              + ("NoteDb  : " + noteDbBundle.byId()));
+    }
+    if (compareAudits
+        && !areByIdAuditsConsideredEqual(reviewDbBundle.byIdAudit(), noteDbBundle.byIdAudit())) {
+      result.add(
+          "AccountGroupByIdAudits differ\n"
+              + ("ReviewDb: " + reviewDbBundle.byIdAudit() + "\n")
+              + ("NoteDb  : " + noteDbBundle.byIdAudit()));
+    }
+    return result.build();
+  }
+
+  private static boolean areMemberAuditsConsideredEqual(
+      ImmutableSet<AccountGroupMemberAudit> reviewDbMemberAudits,
+      ImmutableSet<AccountGroupMemberAudit> noteDbMemberAudits) {
+    ListMultimap<String, AuditEntry> reviewDbMemberAuditsByMemberId =
+        toMemberAuditEntriesByMemberId(reviewDbMemberAudits);
+    ListMultimap<String, AuditEntry> noteDbMemberAuditsByMemberId =
+        toMemberAuditEntriesByMemberId(noteDbMemberAudits);
+
+    return areConsideredEqual(reviewDbMemberAuditsByMemberId, noteDbMemberAuditsByMemberId);
+  }
+
+  private static boolean areByIdAuditsConsideredEqual(
+      ImmutableSet<AccountGroupByIdAud> reviewDbByIdAudits,
+      ImmutableSet<AccountGroupByIdAud> noteDbByIdAudits) {
+    ListMultimap<String, AuditEntry> reviewDbByIdAuditsById =
+        toByIdAuditEntriesById(reviewDbByIdAudits);
+    ListMultimap<String, AuditEntry> noteDbByIdAuditsById =
+        toByIdAuditEntriesById(noteDbByIdAudits);
+
+    return areConsideredEqual(reviewDbByIdAuditsById, noteDbByIdAuditsById);
+  }
+
+  private static ListMultimap<String, AuditEntry> toMemberAuditEntriesByMemberId(
+      ImmutableSet<AccountGroupMemberAudit> memberAudits) {
+    return memberAudits
+        .stream()
+        .flatMap(GroupBundle::toAuditEntries)
+        .collect(
+            Multimaps.toMultimap(
+                AuditEntry::getTarget,
+                Function.identity(),
+                MultimapBuilder.hashKeys().arrayListValues()::build));
+  }
+
+  private static Stream<AuditEntry> toAuditEntries(AccountGroupMemberAudit memberAudit) {
+    AuditEntry additionAuditEntry =
+        AuditEntry.create(
+            Action.ADD,
+            memberAudit.getAddedBy(),
+            memberAudit.getMemberId(),
+            memberAudit.getAddedOn());
+    if (memberAudit.isActive()) {
+      return Stream.of(additionAuditEntry);
+    }
+
+    AuditEntry removalAuditEntry =
+        AuditEntry.create(
+            Action.REMOVE,
+            memberAudit.getRemovedBy(),
+            memberAudit.getMemberId(),
+            memberAudit.getRemovedOn());
+    return Stream.of(additionAuditEntry, removalAuditEntry);
+  }
+
+  private static ListMultimap<String, AuditEntry> toByIdAuditEntriesById(
+      ImmutableSet<AccountGroupByIdAud> byIdAudits) {
+    return byIdAudits
+        .stream()
+        .flatMap(GroupBundle::toAuditEntries)
+        .collect(
+            Multimaps.toMultimap(
+                AuditEntry::getTarget,
+                Function.identity(),
+                MultimapBuilder.hashKeys().arrayListValues()::build));
+  }
+
+  private static Stream<AuditEntry> toAuditEntries(AccountGroupByIdAud byIdAudit) {
+    AuditEntry additionAuditEntry =
+        AuditEntry.create(
+            Action.ADD, byIdAudit.getAddedBy(), byIdAudit.getIncludeUUID(), byIdAudit.getAddedOn());
+    if (byIdAudit.isActive()) {
+      return Stream.of(additionAuditEntry);
+    }
+
+    AuditEntry removalAuditEntry =
+        AuditEntry.create(
+            Action.REMOVE,
+            byIdAudit.getRemovedBy(),
+            byIdAudit.getIncludeUUID(),
+            byIdAudit.getRemovedOn());
+    return Stream.of(additionAuditEntry, removalAuditEntry);
+  }
+
+  /**
+   * Determines whether the audit log entries are equal except for redundant entries. Entries of the
+   * same type (addition/removal) which follow directly on each other according to their timestamp
+   * are considered redundant.
+   */
+  private static boolean areConsideredEqual(
+      ListMultimap<String, AuditEntry> reviewDbMemberAuditsByTarget,
+      ListMultimap<String, AuditEntry> noteDbMemberAuditsByTarget) {
+    for (String target : reviewDbMemberAuditsByTarget.keySet()) {
+      ImmutableList<AuditEntry> reviewDbAuditEntries =
+          reviewDbMemberAuditsByTarget
+              .get(target)
+              .stream()
+              .sorted(AUDIT_ENTRY_COMPARATOR)
+              .collect(toImmutableList());
+      ImmutableSet<AuditEntry> noteDbAuditEntries =
+          noteDbMemberAuditsByTarget
+              .get(target)
+              .stream()
+              .sorted(AUDIT_ENTRY_COMPARATOR)
+              .collect(toImmutableSet());
+
+      int reviewDbIndex = 0;
+      for (AuditEntry noteDbAuditEntry : noteDbAuditEntries) {
+        Set<AuditEntry> redundantReviewDbAuditEntries = new HashSet<>();
+        while (reviewDbIndex < reviewDbAuditEntries.size()) {
+          AuditEntry reviewDbAuditEntry = reviewDbAuditEntries.get(reviewDbIndex);
+          if (!reviewDbAuditEntry.getAction().equals(noteDbAuditEntry.getAction())) {
+            break;
+          }
+          redundantReviewDbAuditEntries.add(reviewDbAuditEntry);
+          reviewDbIndex++;
+        }
+
+        // The order of the entries is not perfect as ReviewDb included milliseconds for timestamps
+        // and we cut off everything below seconds due to NoteDb/git. Consequently, we don't have a
+        // way to know in this method in which exact order additions/removals within the same second
+        // happened. The best we can do is to group all additions within the same second as
+        // redundant entries and the removals afterward. To compensate that we possibly group
+        // non-redundant additions/removals, we also accept NoteDb audit entries which just occur
+        // anywhere as ReviewDb audit entries.
+        if (!redundantReviewDbAuditEntries.contains(noteDbAuditEntry)
+            && !reviewDbAuditEntries.contains(noteDbAuditEntry)) {
+          return false;
+        }
+      }
+
+      if (reviewDbIndex < reviewDbAuditEntries.size()) {
+        // Some of the ReviewDb audit log entries aren't matched by NoteDb audit log entries.
+        return false;
+      }
+    }
+    return true;
+  }
+
+  public AccountGroup.Id id() {
+    return group().getId();
+  }
+
+  public AccountGroup.UUID uuid() {
+    return group().getGroupUUID();
+  }
+
+  public abstract Source source();
+
+  public abstract AccountGroup group();
+
+  public abstract ImmutableSet<AccountGroupMember> members();
+
+  public abstract ImmutableSet<AccountGroupMemberAudit> memberAudit();
+
+  public abstract ImmutableSet<AccountGroupById> byId();
+
+  public abstract ImmutableSet<AccountGroupByIdAud> byIdAudit();
+
+  public abstract Builder toBuilder();
+
+  public GroupBundle truncateToSecond() {
+    AccountGroup newGroup = new AccountGroup(group());
+    if (newGroup.getCreatedOn() != null) {
+      newGroup.setCreatedOn(TimeUtil.truncateToSecond(newGroup.getCreatedOn()));
+    }
+    return toBuilder()
+        .group(newGroup)
+        .memberAudit(
+            memberAudit().stream().map(GroupBundle::truncateToSecond).collect(toImmutableSet()))
+        .byIdAudit(
+            byIdAudit().stream().map(GroupBundle::truncateToSecond).collect(toImmutableSet()))
+        .build();
+  }
+
+  private static AccountGroupMemberAudit truncateToSecond(AccountGroupMemberAudit a) {
+    AccountGroupMemberAudit result =
+        new AccountGroupMemberAudit(
+            new AccountGroupMemberAudit.Key(
+                a.getKey().getParentKey(),
+                a.getKey().getGroupId(),
+                TimeUtil.truncateToSecond(a.getKey().getAddedOn())),
+            a.getAddedBy());
+    if (a.getRemovedOn() != null) {
+      result.removed(a.getRemovedBy(), TimeUtil.truncateToSecond(a.getRemovedOn()));
+    }
+    return result;
+  }
+
+  private static AccountGroupByIdAud truncateToSecond(AccountGroupByIdAud a) {
+    AccountGroupByIdAud result =
+        new AccountGroupByIdAud(
+            new AccountGroupByIdAud.Key(
+                a.getKey().getParentKey(),
+                a.getKey().getIncludeUUID(),
+                TimeUtil.truncateToSecond(a.getKey().getAddedOn())),
+            a.getAddedBy());
+    if (a.getRemovedOn() != null) {
+      result.removed(a.getRemovedBy(), TimeUtil.truncateToSecond(a.getRemovedOn()));
+    }
+    return result;
+  }
+
+  public InternalGroup toInternalGroup() {
+    return InternalGroup.create(
+        group(),
+        members().stream().map(AccountGroupMember::getAccountId).collect(toImmutableSet()),
+        byId().stream().map(AccountGroupById::getIncludeUUID).collect(toImmutableSet()));
+  }
+
+  @Override
+  public int hashCode() {
+    throw new UnsupportedOperationException(
+        "hashCode is not supported because equals is not supported");
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    throw new UnsupportedOperationException("Use GroupBundle.compare(a, b) instead of equals");
+  }
+
+  @AutoValue
+  abstract static class AuditEntry {
+    private static AuditEntry create(
+        Action action, Account.Id userId, Account.Id memberId, Timestamp timestamp) {
+      return new AutoValue_GroupBundle_AuditEntry(
+          action, userId, String.valueOf(memberId.get()), timestamp);
+    }
+
+    private static AuditEntry create(
+        Action action, Account.Id userId, AccountGroup.UUID subgroupId, Timestamp timestamp) {
+      return new AutoValue_GroupBundle_AuditEntry(action, userId, subgroupId.get(), timestamp);
+    }
+
+    abstract Action getAction();
+
+    abstract Account.Id getUserId();
+
+    abstract String getTarget();
+
+    abstract Timestamp getTimestamp();
+  }
+
+  enum Action {
+    ADD(1),
+    REMOVE(2);
+
+    private int order;
+
+    Action(int order) {
+      this.order = order;
+    }
+
+    public int getOrder() {
+      return order;
+    }
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract Builder source(Source source);
+
+    abstract Builder group(AccountGroup group);
+
+    abstract Builder members(AccountGroupMember... member);
+
+    abstract Builder members(Iterable<AccountGroupMember> member);
+
+    abstract Builder memberAudit(AccountGroupMemberAudit... audit);
+
+    abstract Builder memberAudit(Iterable<AccountGroupMemberAudit> audit);
+
+    abstract Builder byId(AccountGroupById... byId);
+
+    abstract Builder byId(Iterable<AccountGroupById> byId);
+
+    abstract Builder byIdAudit(AccountGroupByIdAud... audit);
+
+    abstract Builder byIdAudit(Iterable<AccountGroupByIdAud> audit);
+
+    abstract GroupBundle build();
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
new file mode 100644
index 0000000..e57f5ce
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -0,0 +1,403 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.StringJoiner;
+import java.util.function.Function;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterKey;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+
+/**
+ * Holds code for reading and writing internal group data for a single group to/from NoteDB.
+ *
+ * <p>The configuration is spread across three files: 'group.config', which holds global properties,
+ * 'members', which has one numberic account ID per line, and 'subgroups', which has one group UUID
+ * per line. The code that does the work of parsing 'group.config' is in {@link GroupConfigEntry}.
+ *
+ * <p>TODO(aliceks): expand docs.
+ */
+public class GroupConfig extends VersionedMetaData {
+  public static final String GROUP_CONFIG_FILE = "group.config";
+
+  static final FooterKey FOOTER_ADD_MEMBER = new FooterKey("Add");
+  static final FooterKey FOOTER_REMOVE_MEMBER = new FooterKey("Remove");
+  static final FooterKey FOOTER_ADD_GROUP = new FooterKey("Add-group");
+  static final FooterKey FOOTER_REMOVE_GROUP = new FooterKey("Remove-group");
+
+  private static final String MEMBERS_FILE = "members";
+  private static final String SUBGROUPS_FILE = "subgroups";
+  private static final Pattern LINE_SEPARATOR_PATTERN = Pattern.compile("\\R");
+
+  private final AccountGroup.UUID groupUuid;
+  private final String ref;
+
+  private Optional<InternalGroup> loadedGroup = Optional.empty();
+  private Optional<InternalGroupCreation> groupCreation = Optional.empty();
+  private Optional<InternalGroupUpdate> groupUpdate = Optional.empty();
+  private Function<Account.Id, String> accountNameEmailRetriever = Account.Id::toString;
+  private Function<AccountGroup.UUID, String> groupNameRetriever = AccountGroup.UUID::get;
+  private boolean isLoaded = false;
+  private boolean allowSaveEmptyName;
+
+  private GroupConfig(AccountGroup.UUID groupUuid) {
+    this.groupUuid = checkNotNull(groupUuid);
+    ref = RefNames.refsGroups(groupUuid);
+  }
+
+  public static GroupConfig createForNewGroup(
+      Repository repository, InternalGroupCreation groupCreation)
+      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
+    GroupConfig groupConfig = new GroupConfig(groupCreation.getGroupUUID());
+    groupConfig.load(repository);
+    groupConfig.setGroupCreation(groupCreation);
+    return groupConfig;
+  }
+
+  public static GroupConfig loadForGroup(Repository repository, AccountGroup.UUID groupUuid)
+      throws IOException, ConfigInvalidException {
+    GroupConfig groupConfig = new GroupConfig(groupUuid);
+    groupConfig.load(repository);
+    return groupConfig;
+  }
+
+  /** Loads a group at a specific revision. */
+  public static GroupConfig loadForGroupSnapshot(
+      Repository repository, AccountGroup.UUID groupUuid, ObjectId commitId)
+      throws IOException, ConfigInvalidException {
+    GroupConfig groupConfig = new GroupConfig(groupUuid);
+    groupConfig.load(repository, commitId);
+    return groupConfig;
+  }
+
+  public Optional<InternalGroup> getLoadedGroup() {
+    checkLoaded();
+    return loadedGroup;
+  }
+
+  void setGroupCreation(InternalGroupCreation groupCreation) throws OrmDuplicateKeyException {
+    checkLoaded();
+    if (loadedGroup.isPresent()) {
+      throw new OrmDuplicateKeyException(String.format("Group %s already exists", groupUuid.get()));
+    }
+
+    this.groupCreation = Optional.of(groupCreation);
+  }
+
+  void setAllowSaveEmptyName() {
+    this.allowSaveEmptyName = true;
+  }
+
+  public void setGroupUpdate(
+      InternalGroupUpdate groupUpdate,
+      Function<Account.Id, String> accountNameEmailRetriever,
+      Function<AccountGroup.UUID, String> groupNameRetriever) {
+    this.groupUpdate = Optional.of(groupUpdate);
+    this.accountNameEmailRetriever = accountNameEmailRetriever;
+    this.groupNameRetriever = groupNameRetriever;
+  }
+
+  @Override
+  protected String getRefName() {
+    return ref;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    if (revision != null) {
+      rw.reset();
+      rw.markStart(revision);
+      rw.sort(RevSort.REVERSE);
+      RevCommit earliestCommit = rw.next();
+      Timestamp createdOn = new Timestamp(earliestCommit.getCommitTime() * 1000L);
+
+      Config config = readConfig(GROUP_CONFIG_FILE);
+      ImmutableSet<Account.Id> members = readMembers();
+      ImmutableSet<AccountGroup.UUID> subgroups = readSubgroups();
+      loadedGroup =
+          Optional.of(
+              createFrom(groupUuid, config, members, subgroups, createdOn, revision.toObjectId()));
+    }
+
+    isLoaded = true;
+  }
+
+  private static InternalGroup createFrom(
+      AccountGroup.UUID groupUuid,
+      Config config,
+      ImmutableSet<Account.Id> members,
+      ImmutableSet<AccountGroup.UUID> subgroups,
+      Timestamp createdOn,
+      ObjectId refState)
+      throws ConfigInvalidException {
+    InternalGroup.Builder group = InternalGroup.builder();
+    group.setGroupUUID(groupUuid);
+    for (GroupConfigEntry configEntry : GroupConfigEntry.values()) {
+      configEntry.readFromConfig(groupUuid, group, config);
+    }
+    group.setMembers(members);
+    group.setSubgroups(subgroups);
+    group.setCreatedOn(createdOn);
+    group.setRefState(refState);
+    return group.build();
+  }
+
+  @Override
+  public RevCommit commit(MetaDataUpdate update) throws IOException {
+    RevCommit c = super.commit(update);
+    loadedGroup = Optional.of(loadedGroup.get().toBuilder().setRefState(c.toObjectId()).build());
+    return c;
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    checkLoaded();
+    if (!groupCreation.isPresent() && !groupUpdate.isPresent()) {
+      // Group was neither created nor changed. -> A new commit isn't necessary.
+      return false;
+    }
+
+    if (!allowSaveEmptyName && getNewName().equals(Optional.of(""))) {
+      throw new ConfigInvalidException(
+          String.format("Name of the group %s must be defined", groupUuid.get()));
+    }
+
+    Timestamp commitTimestamp =
+        groupUpdate.flatMap(InternalGroupUpdate::getUpdatedOn).orElseGet(TimeUtil::nowTs);
+    commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp));
+    commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp));
+
+    Config config = updateGroupProperties();
+
+    ImmutableSet<Account.Id> originalMembers =
+        loadedGroup.map(InternalGroup::getMembers).orElseGet(ImmutableSet::of);
+    Optional<ImmutableSet<Account.Id>> updatedMembers = updateMembers(originalMembers);
+
+    ImmutableSet<AccountGroup.UUID> originalSubgroups =
+        loadedGroup.map(InternalGroup::getSubgroups).orElseGet(ImmutableSet::of);
+    Optional<ImmutableSet<AccountGroup.UUID>> updatedSubgroups = updateSubgroups(originalSubgroups);
+
+    Timestamp createdOn = loadedGroup.map(InternalGroup::getCreatedOn).orElse(commitTimestamp);
+
+    String commitMessage =
+        createCommitMessage(originalMembers, updatedMembers, originalSubgroups, updatedSubgroups);
+    commit.setMessage(commitMessage);
+
+    loadedGroup =
+        Optional.of(
+            createFrom(
+                groupUuid,
+                config,
+                updatedMembers.orElse(originalMembers),
+                updatedSubgroups.orElse(originalSubgroups),
+                createdOn,
+                null));
+    groupCreation = Optional.empty();
+
+    return true;
+  }
+
+  private void checkLoaded() {
+    checkState(isLoaded, "Group %s not loaded yet", groupUuid.get());
+  }
+
+  private Optional<String> getNewName() {
+    if (groupUpdate.isPresent()) {
+      return groupUpdate.get().getName().map(n -> Strings.nullToEmpty(n.get()));
+    }
+    if (groupCreation.isPresent()) {
+      return Optional.of(Strings.nullToEmpty(groupCreation.get().getNameKey().get()));
+    }
+    return Optional.empty();
+  }
+
+  private Config updateGroupProperties() throws IOException, ConfigInvalidException {
+    Config config = readConfig(GROUP_CONFIG_FILE);
+    groupCreation.ifPresent(
+        internalGroupCreation ->
+            Arrays.stream(GroupConfigEntry.values())
+                .forEach(configEntry -> configEntry.initNewConfig(config, internalGroupCreation)));
+    groupUpdate.ifPresent(
+        internalGroupUpdate ->
+            Arrays.stream(GroupConfigEntry.values())
+                .forEach(
+                    configEntry -> configEntry.updateConfigValue(config, internalGroupUpdate)));
+    saveConfig(GROUP_CONFIG_FILE, config);
+    return config;
+  }
+
+  private Optional<ImmutableSet<Account.Id>> updateMembers(ImmutableSet<Account.Id> originalMembers)
+      throws IOException {
+    Optional<ImmutableSet<Account.Id>> updatedMembers =
+        groupUpdate
+            .map(InternalGroupUpdate::getMemberModification)
+            .map(memberModification -> memberModification.apply(originalMembers))
+            .map(ImmutableSet::copyOf)
+            .filter(members -> !originalMembers.equals(members));
+    if (updatedMembers.isPresent()) {
+      saveMembers(updatedMembers.get());
+    }
+    return updatedMembers;
+  }
+
+  private Optional<ImmutableSet<AccountGroup.UUID>> updateSubgroups(
+      ImmutableSet<AccountGroup.UUID> originalSubgroups) throws IOException {
+    Optional<ImmutableSet<AccountGroup.UUID>> updatedSubgroups =
+        groupUpdate
+            .map(InternalGroupUpdate::getSubgroupModification)
+            .map(subgroupModification -> subgroupModification.apply(originalSubgroups))
+            .map(ImmutableSet::copyOf)
+            .filter(subgroups -> !originalSubgroups.equals(subgroups));
+    if (updatedSubgroups.isPresent()) {
+      saveSubgroups(updatedSubgroups.get());
+    }
+    return updatedSubgroups;
+  }
+
+  private void saveMembers(ImmutableSet<Account.Id> members) throws IOException {
+    saveToFile(MEMBERS_FILE, members, member -> String.valueOf(member.get()));
+  }
+
+  private void saveSubgroups(ImmutableSet<AccountGroup.UUID> subgroups) throws IOException {
+    saveToFile(SUBGROUPS_FILE, subgroups, AccountGroup.UUID::get);
+  }
+
+  private <E> void saveToFile(
+      String filePath, ImmutableSet<E> elements, Function<E, String> toStringFunction)
+      throws IOException {
+    String fileContent = elements.stream().map(toStringFunction).collect(joining("\n"));
+    saveUTF8(filePath, fileContent);
+  }
+
+  private ImmutableSet<Account.Id> readMembers() throws IOException, ConfigInvalidException {
+    return readFromFile(MEMBERS_FILE, entry -> new Account.Id(Integer.parseInt(entry)));
+  }
+
+  private ImmutableSet<AccountGroup.UUID> readSubgroups()
+      throws IOException, ConfigInvalidException {
+    return readFromFile(SUBGROUPS_FILE, AccountGroup.UUID::new);
+  }
+
+  private <E> ImmutableSet<E> readFromFile(String filePath, Function<String, E> fromStringFunction)
+      throws IOException, ConfigInvalidException {
+    String fileContent = readUTF8(filePath);
+    try {
+      Iterable<String> lines =
+          Splitter.on(LINE_SEPARATOR_PATTERN).trimResults().omitEmptyStrings().split(fileContent);
+      return Streams.stream(lines).map(fromStringFunction).collect(toImmutableSet());
+    } catch (NumberFormatException e) {
+      throw new ConfigInvalidException(
+          String.format("Invalid file %s for commit %s", filePath, revision.name()), e);
+    }
+  }
+
+  private String createCommitMessage(
+      ImmutableSet<Account.Id> originalMembers,
+      Optional<ImmutableSet<Account.Id>> updatedMembers,
+      ImmutableSet<AccountGroup.UUID> originalSubgroups,
+      Optional<ImmutableSet<AccountGroup.UUID>> updatedSubgroups) {
+    String summaryLine = groupCreation.isPresent() ? "Create group" : "Update group";
+
+    StringJoiner footerJoiner = new StringJoiner("\n", "\n\n", "");
+    footerJoiner.setEmptyValue("");
+    getCommitFooterForRename().ifPresent(footerJoiner::add);
+    updatedMembers.ifPresent(
+        newMembers ->
+            getCommitFootersForMemberModifications(originalMembers, newMembers)
+                .forEach(footerJoiner::add));
+    updatedSubgroups.ifPresent(
+        newSubgroups ->
+            getCommitFootersForSubgroupModifications(originalSubgroups, newSubgroups)
+                .forEach(footerJoiner::add));
+    String footer = footerJoiner.toString();
+
+    return summaryLine + footer;
+  }
+
+  private Optional<String> getCommitFooterForRename() {
+    if (!loadedGroup.isPresent()
+        || !groupUpdate.isPresent()
+        || !groupUpdate.get().getName().isPresent()) {
+      return Optional.empty();
+    }
+
+    String originalName = loadedGroup.get().getName();
+    String newName = groupUpdate.get().getName().get().get();
+    if (originalName.equals(newName)) {
+      return Optional.empty();
+    }
+    return Optional.of("Rename from " + originalName + " to " + newName);
+  }
+
+  private Stream<String> getCommitFootersForMemberModifications(
+      ImmutableSet<Account.Id> oldMembers, ImmutableSet<Account.Id> newMembers) {
+    Stream<String> removedMembers =
+        Sets.difference(oldMembers, newMembers)
+            .stream()
+            .map(accountNameEmailRetriever)
+            .map((FOOTER_REMOVE_MEMBER.getName() + ": ")::concat);
+    Stream<String> addedMembers =
+        Sets.difference(newMembers, oldMembers)
+            .stream()
+            .map(accountNameEmailRetriever)
+            .map((FOOTER_ADD_MEMBER.getName() + ": ")::concat);
+    return Stream.concat(removedMembers, addedMembers);
+  }
+
+  private Stream<String> getCommitFootersForSubgroupModifications(
+      ImmutableSet<AccountGroup.UUID> oldSubgroups, ImmutableSet<AccountGroup.UUID> newSubgroups) {
+    Stream<String> removedMembers =
+        Sets.difference(oldSubgroups, newSubgroups)
+            .stream()
+            .map(groupNameRetriever)
+            .map((FOOTER_REMOVE_GROUP.getName() + ": ")::concat);
+    Stream<String> addedMembers =
+        Sets.difference(newSubgroups, oldSubgroups)
+            .stream()
+            .map(groupNameRetriever)
+            .map((FOOTER_ADD_GROUP.getName() + ": ")::concat);
+    return Stream.concat(removedMembers, addedMembers);
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
new file mode 100644
index 0000000..9bd62d1
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
@@ -0,0 +1,164 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.group.InternalGroup;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+// TODO(aliceks): Add Javadoc descriptions to this file. Mention that this class must only be used
+// by GroupConfig and that other classes have to use InternalGroupUpdate!
+enum GroupConfigEntry {
+  ID("id") {
+    @Override
+    void readFromConfig(AccountGroup.UUID groupUuid, InternalGroup.Builder group, Config config)
+        throws ConfigInvalidException {
+      int id = config.getInt(SECTION_NAME, super.keyName, -1);
+      if (id < 0) {
+        throw new ConfigInvalidException(
+            String.format(
+                "ID of the group %s must not be negative, found %d", groupUuid.get(), id));
+      }
+      group.setId(new AccountGroup.Id(id));
+    }
+
+    @Override
+    void initNewConfig(Config config, InternalGroupCreation group) {
+      AccountGroup.Id id = group.getId();
+
+      // Do not use config.setInt(...) to write the group ID because config.setInt(...) persists
+      // integers that can be expressed in KiB as a unit strings, e.g. "1024" is stored as "1k".
+      // Using config.setString(...) ensures that group IDs are human readable.
+      config.setString(SECTION_NAME, null, super.keyName, Integer.toString(id.get()));
+    }
+
+    @Override
+    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
+      // Updating the ID is not supported.
+    }
+  },
+  NAME("name") {
+    @Override
+    void readFromConfig(AccountGroup.UUID groupUuid, InternalGroup.Builder group, Config config)
+        throws ConfigInvalidException {
+      String name = config.getString(SECTION_NAME, null, super.keyName);
+      // An empty name is invalid in NoteDb; GroupConfig will refuse to store it and it might be
+      // unusable in permissions. But, it was technically valid in the ReviewDb storage layer, and
+      // the NoteDb migration converted such groups faithfully, so we need to be able to read them
+      // back here.
+      name = Strings.nullToEmpty(name);
+      group.setNameKey(new AccountGroup.NameKey(name));
+    }
+
+    @Override
+    void initNewConfig(Config config, InternalGroupCreation group) {
+      AccountGroup.NameKey name = group.getNameKey();
+      config.setString(SECTION_NAME, null, super.keyName, name.get());
+    }
+
+    @Override
+    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
+      groupUpdate
+          .getName()
+          .ifPresent(name -> config.setString(SECTION_NAME, null, super.keyName, name.get()));
+    }
+  },
+  DESCRIPTION("description") {
+    @Override
+    void readFromConfig(AccountGroup.UUID groupUuid, InternalGroup.Builder group, Config config) {
+      String description = config.getString(SECTION_NAME, null, super.keyName);
+      group.setDescription(Strings.emptyToNull(description));
+    }
+
+    @Override
+    void initNewConfig(Config config, InternalGroupCreation group) {
+      config.setString(SECTION_NAME, null, super.keyName, null);
+    }
+
+    @Override
+    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
+      groupUpdate
+          .getDescription()
+          .ifPresent(
+              description ->
+                  config.setString(
+                      SECTION_NAME, null, super.keyName, Strings.emptyToNull(description)));
+    }
+  },
+  OWNER_GROUP_UUID("ownerGroupUuid") {
+    @Override
+    void readFromConfig(AccountGroup.UUID groupUuid, InternalGroup.Builder group, Config config)
+        throws ConfigInvalidException {
+      String ownerGroupUuid = config.getString(SECTION_NAME, null, super.keyName);
+      if (Strings.isNullOrEmpty(ownerGroupUuid)) {
+        throw new ConfigInvalidException(
+            String.format("Owner UUID of the group %s must be defined", groupUuid.get()));
+      }
+      group.setOwnerGroupUUID(new AccountGroup.UUID(ownerGroupUuid));
+    }
+
+    @Override
+    void initNewConfig(Config config, InternalGroupCreation group) {
+      config.setString(SECTION_NAME, null, super.keyName, group.getGroupUUID().get());
+    }
+
+    @Override
+    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
+      groupUpdate
+          .getOwnerGroupUUID()
+          .ifPresent(
+              ownerGroupUuid ->
+                  config.setString(SECTION_NAME, null, super.keyName, ownerGroupUuid.get()));
+    }
+  },
+  VISIBLE_TO_ALL("visibleToAll") {
+    @Override
+    void readFromConfig(AccountGroup.UUID groupUuid, InternalGroup.Builder group, Config config) {
+      boolean visibleToAll = config.getBoolean(SECTION_NAME, super.keyName, false);
+      group.setVisibleToAll(visibleToAll);
+    }
+
+    @Override
+    void initNewConfig(Config config, InternalGroupCreation group) {
+      config.setBoolean(SECTION_NAME, null, super.keyName, false);
+    }
+
+    @Override
+    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
+      groupUpdate
+          .getVisibleToAll()
+          .ifPresent(
+              visibleToAll -> config.setBoolean(SECTION_NAME, null, super.keyName, visibleToAll));
+    }
+  };
+
+  private static final String SECTION_NAME = "group";
+
+  private final String keyName;
+
+  GroupConfigEntry(String keyName) {
+    this.keyName = keyName;
+  }
+
+  abstract void readFromConfig(
+      AccountGroup.UUID groupUuid, InternalGroup.Builder group, Config config)
+      throws ConfigInvalidException;
+
+  abstract void initNewConfig(Config config, InternalGroupCreation group);
+
+  abstract void updateConfigValue(Config config, InternalGroupUpdate groupUpdate);
+}
diff --git a/java/com/google/gerrit/server/group/db/GroupNameNotes.java b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
new file mode 100644
index 0000000..775ebd6
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
@@ -0,0 +1,347 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.collect.ImmutableBiMap.toImmutableBiMap;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableBiMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * Holds code for reading and writing group names for a single group as NoteDb data. The data is
+ * stored in a refs/meta/group-names branch. The data is stored as SHA1(name) => config file with
+ * the config file holding UUID and Name.
+ *
+ * <p>TODO(aliceks): more javadoc.
+ */
+public class GroupNameNotes extends VersionedMetaData {
+  private static final String SECTION_NAME = "group";
+  private static final String UUID_PARAM = "uuid";
+  private static final String NAME_PARAM = "name";
+
+  @VisibleForTesting
+  static final String UNIQUE_REF_ERROR = "GroupReference collection must contain unique references";
+
+  public static GroupNameNotes loadForRename(
+      Repository repository,
+      AccountGroup.UUID groupUuid,
+      AccountGroup.NameKey oldName,
+      AccountGroup.NameKey newName)
+      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
+    checkNotNull(oldName);
+    checkNotNull(newName);
+
+    GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, oldName, newName);
+    groupNameNotes.load(repository);
+    groupNameNotes.ensureNewNameIsNotUsed();
+    return groupNameNotes;
+  }
+
+  public static GroupNameNotes loadForNewGroup(
+      Repository repository, AccountGroup.UUID groupUuid, AccountGroup.NameKey groupName)
+      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
+    checkNotNull(groupName);
+
+    GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, null, groupName);
+    groupNameNotes.load(repository);
+    groupNameNotes.ensureNewNameIsNotUsed();
+    return groupNameNotes;
+  }
+
+  public static Optional<GroupReference> loadOneGroupReference(
+      Repository allUsersRepo, String groupName) throws IOException, ConfigInvalidException {
+    Ref ref = allUsersRepo.exactRef(RefNames.REFS_GROUPNAMES);
+    if (ref == null) {
+      return Optional.empty();
+    }
+
+    try (RevWalk revWalk = new RevWalk(allUsersRepo);
+        ObjectReader reader = revWalk.getObjectReader()) {
+      RevCommit notesCommit = revWalk.parseCommit(ref.getObjectId());
+      NoteMap noteMap = NoteMap.read(reader, notesCommit);
+      ObjectId noteDataBlobId = noteMap.get(getNoteKey(new AccountGroup.NameKey(groupName)));
+      if (noteDataBlobId == null) {
+        return Optional.empty();
+      }
+      return Optional.of(getGroupReference(reader, noteDataBlobId));
+    }
+  }
+
+  public static ImmutableSet<GroupReference> loadAllGroupReferences(Repository repository)
+      throws IOException, ConfigInvalidException {
+    Ref ref = repository.exactRef(RefNames.REFS_GROUPNAMES);
+    if (ref == null) {
+      return ImmutableSet.of();
+    }
+    try (RevWalk revWalk = new RevWalk(repository);
+        ObjectReader reader = revWalk.getObjectReader()) {
+      RevCommit notesCommit = revWalk.parseCommit(ref.getObjectId());
+      NoteMap noteMap = NoteMap.read(reader, notesCommit);
+
+      Set<GroupReference> groupReferences = new LinkedHashSet<>();
+      for (Note note : noteMap) {
+        GroupReference groupReference = getGroupReference(reader, note.getData());
+        boolean result = groupReferences.add(groupReference);
+        if (!result) {
+          GroupsNoteDbConsistencyChecker.logConsistencyProblemAsWarning(
+              "The UUID of group %s (%s) is duplicate in group name notes",
+              groupReference.getName(), groupReference.getUUID());
+        }
+      }
+
+      return ImmutableSet.copyOf(groupReferences);
+    }
+  }
+
+  public static void updateGroupNames(
+      Repository allUsersRepo,
+      ObjectInserter inserter,
+      BatchRefUpdate bru,
+      Collection<GroupReference> groupReferences,
+      PersonIdent ident)
+      throws IOException {
+    // Not strictly necessary for iteration; throws IAE if it encounters duplicates, which is nice.
+    ImmutableBiMap<AccountGroup.UUID, String> biMap = toBiMap(groupReferences);
+
+    try (ObjectReader reader = inserter.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      // Always start from an empty map, discarding old notes.
+      NoteMap noteMap = NoteMap.newEmptyMap();
+      Ref ref = allUsersRepo.exactRef(RefNames.REFS_GROUPNAMES);
+      RevCommit oldCommit = ref != null ? rw.parseCommit(ref.getObjectId()) : null;
+
+      for (Map.Entry<AccountGroup.UUID, String> e : biMap.entrySet()) {
+        AccountGroup.NameKey nameKey = new AccountGroup.NameKey(e.getValue());
+        ObjectId noteKey = getNoteKey(nameKey);
+        noteMap.set(noteKey, getAsNoteData(e.getKey(), nameKey), inserter);
+      }
+
+      ObjectId newTreeId = noteMap.writeTree(inserter);
+      if (oldCommit != null && newTreeId.equals(oldCommit.getTree())) {
+        return;
+      }
+      CommitBuilder cb = new CommitBuilder();
+      if (oldCommit != null) {
+        cb.addParentId(oldCommit);
+      }
+      cb.setTreeId(newTreeId);
+      cb.setAuthor(ident);
+      cb.setCommitter(ident);
+      int n = groupReferences.size();
+      cb.setMessage("Store " + n + " group name" + (n != 1 ? "s" : ""));
+      ObjectId newId = inserter.insert(cb).copy();
+
+      ObjectId oldId = oldCommit != null ? oldCommit.copy() : ObjectId.zeroId();
+      bru.addCommand(new ReceiveCommand(oldId, newId, RefNames.REFS_GROUPNAMES));
+    }
+  }
+
+  // Returns UUID <=> Name bimap.
+  private static ImmutableBiMap<AccountGroup.UUID, String> toBiMap(
+      Collection<GroupReference> groupReferences) {
+    try {
+      return groupReferences
+          .stream()
+          .collect(toImmutableBiMap(gr -> gr.getUUID(), gr -> gr.getName()));
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException(UNIQUE_REF_ERROR, e);
+    }
+  }
+
+  private final AccountGroup.UUID groupUuid;
+  private final Optional<AccountGroup.NameKey> oldGroupName;
+  private final Optional<AccountGroup.NameKey> newGroupName;
+
+  private boolean nameConflicting;
+
+  private GroupNameNotes(
+      AccountGroup.UUID groupUuid,
+      @Nullable AccountGroup.NameKey oldGroupName,
+      @Nullable AccountGroup.NameKey newGroupName) {
+    this.groupUuid = checkNotNull(groupUuid);
+
+    if (Objects.equals(oldGroupName, newGroupName)) {
+      this.oldGroupName = Optional.empty();
+      this.newGroupName = Optional.empty();
+    } else {
+      this.oldGroupName = Optional.ofNullable(oldGroupName);
+      this.newGroupName = Optional.ofNullable(newGroupName);
+    }
+  }
+
+  @Override
+  protected String getRefName() {
+    return RefNames.REFS_GROUPNAMES;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    nameConflicting = false;
+
+    if (revision != null) {
+      NoteMap noteMap = NoteMap.read(reader, revision);
+      if (newGroupName.isPresent()) {
+        ObjectId newNameId = getNoteKey(newGroupName.get());
+        nameConflicting = noteMap.contains(newNameId);
+      }
+      ensureOldNameIsPresent(noteMap);
+    }
+  }
+
+  private void ensureOldNameIsPresent(NoteMap noteMap) throws IOException, ConfigInvalidException {
+    if (oldGroupName.isPresent()) {
+      AccountGroup.NameKey oldName = oldGroupName.get();
+      ObjectId noteKey = getNoteKey(oldName);
+      ObjectId noteDataBlobId = noteMap.get(noteKey);
+      if (noteDataBlobId == null) {
+        throw new ConfigInvalidException(
+            String.format("Group name '%s' doesn't exist in the list of all names", oldName));
+      }
+      GroupReference group = getGroupReference(reader, noteDataBlobId);
+      AccountGroup.UUID foundUuid = group.getUUID();
+      if (!Objects.equals(groupUuid, foundUuid)) {
+        throw new ConfigInvalidException(
+            String.format(
+                "Name '%s' points to UUID '%s' and not to '%s'", oldName, foundUuid, groupUuid));
+      }
+    }
+  }
+
+  private void ensureNewNameIsNotUsed() throws OrmDuplicateKeyException {
+    if (newGroupName.isPresent() && nameConflicting) {
+      throw new OrmDuplicateKeyException(
+          String.format("Name '%s' is already used", newGroupName.get().get()));
+    }
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    if (!oldGroupName.isPresent() && !newGroupName.isPresent()) {
+      return false;
+    }
+
+    NoteMap noteMap = revision == null ? NoteMap.newEmptyMap() : NoteMap.read(reader, revision);
+    if (oldGroupName.isPresent()) {
+      removeNote(noteMap, oldGroupName.get(), inserter);
+    }
+
+    if (newGroupName.isPresent()) {
+      addNote(noteMap, newGroupName.get(), groupUuid, inserter);
+    }
+
+    commit.setTreeId(noteMap.writeTree(inserter));
+    commit.setMessage(getCommitMessage());
+
+    return true;
+  }
+
+  private static void removeNote(
+      NoteMap noteMap, AccountGroup.NameKey groupName, ObjectInserter inserter) throws IOException {
+    ObjectId noteKey = getNoteKey(groupName);
+    noteMap.set(noteKey, null, inserter);
+  }
+
+  private static void addNote(
+      NoteMap noteMap,
+      AccountGroup.NameKey groupName,
+      AccountGroup.UUID groupUuid,
+      ObjectInserter inserter)
+      throws IOException {
+    ObjectId noteKey = getNoteKey(groupName);
+    noteMap.set(noteKey, getAsNoteData(groupUuid, groupName), inserter);
+  }
+
+  // Use the same approach as ExternalId.Key.sha1().
+  @SuppressWarnings("deprecation")
+  @VisibleForTesting
+  public static ObjectId getNoteKey(AccountGroup.NameKey groupName) {
+    return ObjectId.fromRaw(Hashing.sha1().hashString(groupName.get(), UTF_8).asBytes());
+  }
+
+  private static String getAsNoteData(AccountGroup.UUID uuid, AccountGroup.NameKey groupName) {
+    Config config = new Config();
+    config.setString(SECTION_NAME, null, UUID_PARAM, uuid.get());
+    config.setString(SECTION_NAME, null, NAME_PARAM, groupName.get());
+    return config.toText();
+  }
+
+  @VisibleForTesting
+  public static GroupReference getGroupReference(ObjectReader reader, ObjectId noteDataBlobId)
+      throws IOException, ConfigInvalidException {
+    byte[] noteData = reader.open(noteDataBlobId, OBJ_BLOB).getCachedBytes();
+    return getFromNoteData(noteData);
+  }
+
+  static GroupReference getFromNoteData(byte[] noteData) throws ConfigInvalidException {
+    Config config = new Config();
+    config.fromText(new String(noteData, UTF_8));
+
+    String uuid = config.getString(SECTION_NAME, null, UUID_PARAM);
+    String name = Strings.nullToEmpty(config.getString(SECTION_NAME, null, NAME_PARAM));
+    if (uuid == null) {
+      throw new ConfigInvalidException(String.format("UUID for group '%s' must be defined", name));
+    }
+
+    return new GroupReference(new AccountGroup.UUID(uuid), name);
+  }
+
+  private String getCommitMessage() {
+    if (oldGroupName.isPresent() && newGroupName.isPresent()) {
+      return String.format(
+          "Rename group from '%s' to '%s'", oldGroupName.get(), newGroupName.get());
+    }
+    if (newGroupName.isPresent()) {
+      return String.format("Create group '%s'", newGroupName.get());
+    }
+    if (oldGroupName.isPresent()) {
+      return String.format("Delete group '%s'", oldGroupName.get());
+    }
+    return "No-op";
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/GroupRebuilder.java b/java/com/google/gerrit/server/group/db/GroupRebuilder.java
new file mode 100644
index 0000000..e8a98f1
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/GroupRebuilder.java
@@ -0,0 +1,331 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.VersionedMetaData.BatchMetaDataUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate.MemberModification;
+import com.google.gerrit.server.group.db.InternalGroupUpdate.SubgroupModification;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.NavigableSet;
+import java.util.Optional;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/** Helper for rebuilding an entire group's NoteDb refs. */
+@Singleton
+public class GroupRebuilder {
+  private final Provider<PersonIdent> serverIdent;
+  private final AllUsersName allUsers;
+  private final MetaDataUpdate.InternalFactory metaDataUpdateFactory;
+
+  private final BiFunction<Account.Id, PersonIdent, PersonIdent> newPersonIdentFunc;
+  private final Function<Account.Id, String> getAccountNameEmailFunc;
+  private final Function<AccountGroup.UUID, String> getGroupNameFunc;
+
+  @Inject
+  GroupRebuilder(
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      @GerritServerId String serverId,
+      AllUsersName allUsers,
+      MetaDataUpdate.InternalFactory metaDataUpdateFactory,
+      AccountCache accountCache,
+      GroupBackend groupBackend) {
+    this(
+        serverIdent,
+        allUsers,
+        metaDataUpdateFactory,
+
+        // TODO(dborowitz): These probably won't work during init.
+        (id, ident) ->
+            new PersonIdent(
+                GroupsUpdate.getAccountName(accountCache, id),
+                GroupsUpdate.getEmailForAuditLog(id, serverId),
+                ident.getWhen(),
+                ident.getTimeZone()),
+        id -> GroupsUpdate.getAccountNameEmail(accountCache, id, serverId),
+        uuid -> GroupsUpdate.getGroupName(groupBackend, uuid));
+  }
+
+  @VisibleForTesting
+  GroupRebuilder(
+      Provider<PersonIdent> serverIdent,
+      AllUsersName allUsers,
+      MetaDataUpdate.InternalFactory metaDataUpdateFactory,
+      BiFunction<Account.Id, PersonIdent, PersonIdent> newPersonIdentFunc,
+      Function<Account.Id, String> getAccountNameEmailFunc,
+      Function<AccountGroup.UUID, String> getGroupNameFunc) {
+    this.serverIdent = serverIdent;
+    this.allUsers = allUsers;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.newPersonIdentFunc = newPersonIdentFunc;
+    this.getAccountNameEmailFunc = getAccountNameEmailFunc;
+    this.getGroupNameFunc = getGroupNameFunc;
+  }
+
+  public void rebuild(Repository allUsersRepo, GroupBundle bundle, @Nullable BatchRefUpdate bru)
+      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
+    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepo, bundle.uuid());
+    AccountGroup group = bundle.group();
+    groupConfig.setAllowSaveEmptyName();
+    groupConfig.setGroupCreation(
+        InternalGroupCreation.builder()
+            .setId(bundle.id())
+            .setNameKey(group.getNameKey())
+            .setGroupUUID(group.getGroupUUID())
+            .build());
+
+    InternalGroupUpdate.Builder updateBuilder =
+        InternalGroupUpdate.builder()
+            .setOwnerGroupUUID(group.getOwnerGroupUUID())
+            .setVisibleToAll(group.isVisibleToAll())
+            .setUpdatedOn(group.getCreatedOn());
+    if (bundle.group().getDescription() != null) {
+      updateBuilder.setDescription(group.getDescription());
+    }
+    groupConfig.setGroupUpdate(updateBuilder.build(), getAccountNameEmailFunc, getGroupNameFunc);
+
+    Map<Key, Collection<Event>> events = toEvents(bundle).asMap();
+    PersonIdent nowServerIdent = getServerIdent(events);
+
+    MetaDataUpdate md = metaDataUpdateFactory.create(allUsers, allUsersRepo, bru);
+
+    // Creation is done by the server (unlike later audit events).
+    PersonIdent created = new PersonIdent(nowServerIdent, group.getCreatedOn());
+    md.getCommitBuilder().setAuthor(created);
+    md.getCommitBuilder().setCommitter(created);
+
+    // Rebuild group ref.
+    try (BatchMetaDataUpdate batch = groupConfig.openUpdate(md)) {
+      batch.write(groupConfig, md.getCommitBuilder());
+
+      for (Map.Entry<Key, Collection<Event>> e : events.entrySet()) {
+        InternalGroupUpdate.Builder ub = InternalGroupUpdate.builder();
+        e.getValue().forEach(event -> event.update().accept(ub));
+        ub.setUpdatedOn(e.getKey().when());
+        groupConfig.setGroupUpdate(ub.build(), getAccountNameEmailFunc, getGroupNameFunc);
+
+        PersonIdent currServerIdent = new PersonIdent(nowServerIdent, e.getKey().when());
+        CommitBuilder cb = new CommitBuilder();
+        cb.setAuthor(
+            e.getKey()
+                .accountId()
+                .map(id -> newPersonIdentFunc.apply(id, currServerIdent))
+                .orElse(currServerIdent));
+        cb.setCommitter(currServerIdent);
+        batch.write(groupConfig, cb);
+      }
+
+      batch.createRef(groupConfig.getRefName());
+    }
+  }
+
+  private ListMultimap<Key, Event> toEvents(GroupBundle bundle) {
+    ListMultimap<Key, Event> result =
+        MultimapBuilder.treeKeys(Key.COMPARATOR).arrayListValues(1).build();
+    Event e;
+
+    for (AccountGroupMemberAudit a : bundle.memberAudit()) {
+      checkArgument(
+          a.getKey().getGroupId().equals(bundle.id()),
+          "key %s does not match group %s",
+          a.getKey(),
+          bundle.id());
+      Account.Id accountId = a.getKey().getParentKey();
+      e = event(Type.ADD_MEMBER, a.getAddedBy(), a.getKey().getAddedOn(), addMember(accountId));
+      result.put(e.key(), e);
+      if (!a.isActive()) {
+        e = event(Type.REMOVE_MEMBER, a.getRemovedBy(), a.getRemovedOn(), removeMember(accountId));
+        result.put(e.key(), e);
+      }
+    }
+
+    for (AccountGroupByIdAud a : bundle.byIdAudit()) {
+      checkArgument(
+          a.getKey().getParentKey().equals(bundle.id()),
+          "key %s does not match group %s",
+          a.getKey(),
+          bundle.id());
+      AccountGroup.UUID uuid = a.getKey().getIncludeUUID();
+      e = event(Type.ADD_GROUP, a.getAddedBy(), a.getKey().getAddedOn(), addGroup(uuid));
+      result.put(e.key(), e);
+      if (!a.isActive()) {
+        e = event(Type.REMOVE_GROUP, a.getRemovedBy(), a.getRemovedOn(), removeGroup(uuid));
+        result.put(e.key(), e);
+      }
+    }
+
+    // Due to clock skew, audit events may be in the future relative to this machine. Ensure the
+    // fixup event happens after any other events, both for the purposes of sorting Keys correctly
+    // and to avoid non-monotonic timestamps in the commit history.
+    Timestamp maxTs =
+        Stream.concat(result.keySet().stream().map(Key::when), Stream.of(TimeUtil.nowTs()))
+            .max(Comparator.naturalOrder())
+            .get();
+    Timestamp fixupTs = new Timestamp(maxTs.getTime() + 1);
+    e = serverEvent(Type.FIXUP, fixupTs, setCurrentMembership(bundle));
+    result.put(e.key(), e);
+
+    return result;
+  }
+
+  private PersonIdent getServerIdent(Map<Key, Collection<Event>> events) {
+    // Created with MultimapBuilder.treeKeys, so the keySet is navigable.
+    Key lastKey = ((NavigableSet<Key>) events.keySet()).last();
+    checkState(lastKey.type() == Type.FIXUP);
+    PersonIdent ident = serverIdent.get();
+    return new PersonIdent(
+        ident.getName(),
+        ident.getEmailAddress(),
+        Iterables.getOnlyElement(events.get(lastKey)).when(),
+        ident.getTimeZone());
+  }
+
+  private static Consumer<InternalGroupUpdate.Builder> addMember(Account.Id toAdd) {
+    return b -> {
+      MemberModification prev = b.getMemberModification();
+      b.setMemberModification(in -> Sets.union(prev.apply(in), ImmutableSet.of(toAdd)));
+    };
+  }
+
+  private static Consumer<InternalGroupUpdate.Builder> removeMember(Account.Id toRemove) {
+    return b -> {
+      MemberModification prev = b.getMemberModification();
+      b.setMemberModification(in -> Sets.difference(prev.apply(in), ImmutableSet.of(toRemove)));
+    };
+  }
+
+  private static Consumer<InternalGroupUpdate.Builder> addGroup(AccountGroup.UUID toAdd) {
+    return b -> {
+      SubgroupModification prev = b.getSubgroupModification();
+      b.setSubgroupModification(in -> Sets.union(prev.apply(in), ImmutableSet.of(toAdd)));
+    };
+  }
+
+  private static Consumer<InternalGroupUpdate.Builder> removeGroup(AccountGroup.UUID toRemove) {
+    return b -> {
+      SubgroupModification prev = b.getSubgroupModification();
+      b.setSubgroupModification(in -> Sets.difference(prev.apply(in), ImmutableSet.of(toRemove)));
+    };
+  }
+
+  private static Consumer<InternalGroupUpdate.Builder> setCurrentMembership(GroupBundle bundle) {
+    // Overwrite members and subgroups with the current values. The storage layer will do the
+    // set differences to compute the appropriate delta, if any.
+    return b ->
+        b.setMemberModification(
+                in ->
+                    bundle.members().stream().map(m -> m.getAccountId()).collect(toImmutableSet()))
+            .setSubgroupModification(
+                in ->
+                    bundle.byId().stream().map(m -> m.getIncludeUUID()).collect(toImmutableSet()));
+  }
+
+  private static Event event(
+      Type type,
+      Account.Id accountId,
+      Timestamp when,
+      Consumer<InternalGroupUpdate.Builder> update) {
+    return new AutoValue_GroupRebuilder_Event(type, Optional.of(accountId), when, update);
+  }
+
+  private static Event serverEvent(
+      Type type, Timestamp when, Consumer<InternalGroupUpdate.Builder> update) {
+    return new AutoValue_GroupRebuilder_Event(type, Optional.empty(), when, update);
+  }
+
+  @AutoValue
+  abstract static class Event {
+    abstract Type type();
+
+    abstract Optional<Account.Id> accountId();
+
+    abstract Timestamp when();
+
+    abstract Consumer<InternalGroupUpdate.Builder> update();
+
+    Key key() {
+      return new AutoValue_GroupRebuilder_Key(accountId(), when(), type());
+    }
+  }
+
+  /**
+   * Distinct event types.
+   *
+   * <p>Events at the same time by the same user are batched together by type. The types should
+   * correspond to the possible batch operations supported by {@link
+   * com.google.gerrit.server.audit.AuditService}.
+   */
+  enum Type {
+    ADD_MEMBER,
+    REMOVE_MEMBER,
+    ADD_GROUP,
+    REMOVE_GROUP,
+    FIXUP;
+  }
+
+  @AutoValue
+  abstract static class Key {
+    static final Comparator<Key> COMPARATOR =
+        Comparator.comparing(Key::when)
+            .thenComparing(
+                k -> k.accountId().map(Account.Id::get).orElse(null),
+                Comparator.nullsFirst(Comparator.naturalOrder()))
+            .thenComparing(Key::type);
+
+    abstract Optional<Account.Id> accountId();
+
+    abstract Timestamp when();
+
+    abstract Type type();
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
new file mode 100644
index 0000000..f3232be
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -0,0 +1,365 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * A database accessor for read calls related to groups.
+ *
+ * <p>All calls which read group related details from the database (either ReviewDb or NoteDb) are
+ * gathered here. Other classes should always use this class instead of accessing the database
+ * directly. There are a few exceptions though: schema classes, wrapper classes, and classes
+ * executed during init. The latter ones should use {@code GroupsOnInit} instead.
+ *
+ * <p>Most callers should not need to read groups directly from the database; they should use the
+ * {@link com.google.gerrit.server.account.GroupCache GroupCache} instead.
+ *
+ * <p>If not explicitly stated, all methods of this class refer to <em>internal</em> groups.
+ */
+@Singleton
+public class Groups {
+  private final GroupsMigration groupsMigration;
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final AuditLogReader auditLogReader;
+
+  @Inject
+  public Groups(
+      GroupsMigration groupsMigration,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      AuditLogReader auditLogReader) {
+    this.groupsMigration = groupsMigration;
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.auditLogReader = auditLogReader;
+  }
+
+  /**
+   * Returns the {@code AccountGroup} for the specified ID if it exists.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupId the ID of the group
+   * @return the found {@code AccountGroup} if it exists, or else an empty {@code Optional}
+   * @throws OrmException if the group couldn't be retrieved from ReviewDb
+   */
+  public static Optional<InternalGroup> getGroupFromReviewDb(ReviewDb db, AccountGroup.Id groupId)
+      throws OrmException {
+    AccountGroup accountGroup = db.accountGroups().get(groupId);
+    if (accountGroup == null) {
+      return Optional.empty();
+    }
+    return Optional.of(asInternalGroup(db, accountGroup));
+  }
+
+  /**
+   * Returns the {@code InternalGroup} for the specified UUID if it exists.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupUuid the UUID of the group
+   * @return the found {@code InternalGroup} if it exists, or else an empty {@code Optional}
+   * @throws OrmDuplicateKeyException if multiple groups are found for the specified UUID
+   * @throws OrmException if the group couldn't be retrieved from ReviewDb
+   * @throws IOException if the group couldn't be retrieved from NoteDb
+   * @throws ConfigInvalidException if the group couldn't be retrieved from NoteDb
+   */
+  public Optional<InternalGroup> getGroup(ReviewDb db, AccountGroup.UUID groupUuid)
+      throws OrmException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+        return getGroupFromNoteDb(allUsersRepo, groupUuid);
+      }
+    }
+
+    Optional<AccountGroup> accountGroup = getGroupFromReviewDb(db, groupUuid);
+    if (!accountGroup.isPresent()) {
+      return Optional.empty();
+    }
+    return Optional.of(asInternalGroup(db, accountGroup.get()));
+  }
+
+  private static Optional<InternalGroup> getGroupFromNoteDb(
+      Repository allUsersRepository, AccountGroup.UUID groupUuid)
+      throws IOException, ConfigInvalidException {
+    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepository, groupUuid);
+    Optional<InternalGroup> loadedGroup = groupConfig.getLoadedGroup();
+    if (loadedGroup.isPresent()) {
+      // Check consistency with group name notes.
+      GroupsNoteDbConsistencyChecker.ensureConsistentWithGroupNameNotes(
+          allUsersRepository, loadedGroup.get());
+    }
+    return loadedGroup;
+  }
+
+  public static InternalGroup asInternalGroup(ReviewDb db, AccountGroup accountGroup)
+      throws OrmException {
+    ImmutableSet<Account.Id> members =
+        getMembersFromReviewDb(db, accountGroup.getId()).collect(toImmutableSet());
+    ImmutableSet<AccountGroup.UUID> subgroups =
+        getSubgroupsFromReviewDb(db, accountGroup.getId()).collect(toImmutableSet());
+    return InternalGroup.create(accountGroup, members, subgroups);
+  }
+
+  /**
+   * Returns the {@code AccountGroup} for the specified UUID.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupUuid the UUID of the group
+   * @return the {@code AccountGroup} which has the specified UUID
+   * @throws OrmDuplicateKeyException if multiple groups are found for the specified UUID
+   * @throws OrmException if the group couldn't be retrieved from ReviewDb
+   * @throws NoSuchGroupException if a group with such a UUID doesn't exist
+   */
+  static AccountGroup getExistingGroupFromReviewDb(ReviewDb db, AccountGroup.UUID groupUuid)
+      throws OrmException, NoSuchGroupException {
+    Optional<AccountGroup> group = getGroupFromReviewDb(db, groupUuid);
+    return group.orElseThrow(() -> new NoSuchGroupException(groupUuid));
+  }
+
+  /**
+   * Returns the {@code AccountGroup} for the specified UUID if it exists.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupUuid the UUID of the group
+   * @return the found {@code AccountGroup} if it exists, or else an empty {@code Optional}
+   * @throws OrmDuplicateKeyException if multiple groups are found for the specified UUID
+   * @throws OrmException if the group couldn't be retrieved from ReviewDb
+   */
+  private static Optional<AccountGroup> getGroupFromReviewDb(
+      ReviewDb db, AccountGroup.UUID groupUuid) throws OrmException {
+    List<AccountGroup> accountGroups = db.accountGroups().byUUID(groupUuid).toList();
+    if (accountGroups.size() == 1) {
+      return Optional.of(Iterables.getOnlyElement(accountGroups));
+    } else if (accountGroups.isEmpty()) {
+      return Optional.empty();
+    } else {
+      throw new OrmDuplicateKeyException("Duplicate group UUID " + groupUuid);
+    }
+  }
+
+  /**
+   * Returns {@code GroupReference}s for all internal groups.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @return a stream of the {@code GroupReference}s of all internal groups
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   * @throws IOException if an error occurs while reading from NoteDb
+   * @throws ConfigInvalidException if the data in NoteDb is in an incorrect format
+   */
+  public Stream<GroupReference> getAllGroupReferences(ReviewDb db)
+      throws OrmException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+        return GroupNameNotes.loadAllGroupReferences(allUsersRepo).stream();
+      }
+    }
+
+    return Streams.stream(db.accountGroups().all())
+        .map(group -> new GroupReference(group.getGroupUUID(), group.getName()));
+  }
+
+  /**
+   * Returns the members (accounts) of a group.
+   *
+   * <p><strong>Note</strong>: This method doesn't check whether the accounts exist!
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupId the ID of the group
+   * @return a stream of the IDs of the members
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   */
+  public static Stream<Account.Id> getMembersFromReviewDb(ReviewDb db, AccountGroup.Id groupId)
+      throws OrmException {
+    ResultSet<AccountGroupMember> accountGroupMembers = db.accountGroupMembers().byGroup(groupId);
+    return Streams.stream(accountGroupMembers).map(AccountGroupMember::getAccountId);
+  }
+
+  /**
+   * Returns the subgroups of a group.
+   *
+   * <p>This parent group must be an internal group whereas the subgroups can either be internal or
+   * external groups.
+   *
+   * <p><strong>Note</strong>: This method doesn't check whether the subgroups exist!
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupId the ID of the group
+   * @return a stream of the UUIDs of the subgroups
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   */
+  public static Stream<AccountGroup.UUID> getSubgroupsFromReviewDb(
+      ReviewDb db, AccountGroup.Id groupId) throws OrmException {
+    ResultSet<AccountGroupById> accountGroupByIds = db.accountGroupById().byGroup(groupId);
+    return Streams.stream(accountGroupByIds).map(AccountGroupById::getIncludeUUID).distinct();
+  }
+
+  /**
+   * Returns the groups of which the specified account is a member.
+   *
+   * <p><strong>Note</strong>: This method returns an empty stream if the account doesn't exist.
+   * This method doesn't check whether the groups exist.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param accountId the ID of the account
+   * @return a stream of the IDs of the groups of which the account is a member
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   */
+  public static Stream<AccountGroup.Id> getGroupsWithMemberFromReviewDb(
+      ReviewDb db, Account.Id accountId) throws OrmException {
+    ResultSet<AccountGroupMember> accountGroupMembers =
+        db.accountGroupMembers().byAccount(accountId);
+    return Streams.stream(accountGroupMembers).map(AccountGroupMember::getAccountGroupId);
+  }
+
+  /**
+   * Returns the parent groups of the specified (sub)group.
+   *
+   * <p>The subgroup may either be an internal or an external group whereas the returned parent
+   * groups represent only internal groups.
+   *
+   * <p><strong>Note</strong>: This method returns an empty stream if the specified group doesn't
+   * exist. This method doesn't check whether the parent groups exist.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param subgroupUuid the UUID of the subgroup
+   * @return a stream of the IDs of the parent groups
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   */
+  public static Stream<AccountGroup.Id> getParentGroupsFromReviewDb(
+      ReviewDb db, AccountGroup.UUID subgroupUuid) throws OrmException {
+    ResultSet<AccountGroupById> accountGroupByIds =
+        db.accountGroupById().byIncludeUUID(subgroupUuid);
+    return Streams.stream(accountGroupByIds).map(AccountGroupById::getGroupId);
+  }
+
+  /**
+   * Returns all known external groups. External groups are 'known' when they are specified as a
+   * subgroup of an internal group.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @return a stream of the UUIDs of the known external groups
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   * @throws IOException if an error occurs while reading from NoteDb
+   * @throws ConfigInvalidException if the data in NoteDb is in an incorrect format
+   */
+  public Stream<AccountGroup.UUID> getExternalGroups(ReviewDb db)
+      throws OrmException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+        return getExternalGroupsFromNoteDb(allUsersRepo);
+      }
+    }
+
+    return Streams.stream(db.accountGroupById().all())
+        .map(AccountGroupById::getIncludeUUID)
+        .distinct()
+        .filter(groupUuid -> !AccountGroup.isInternalGroup(groupUuid));
+  }
+
+  private Stream<AccountGroup.UUID> getExternalGroupsFromNoteDb(Repository allUsersRepo)
+      throws IOException, ConfigInvalidException {
+    ImmutableSet<GroupReference> allInternalGroups =
+        GroupNameNotes.loadAllGroupReferences(allUsersRepo);
+    ImmutableSet.Builder<AccountGroup.UUID> allSubgroups = ImmutableSet.builder();
+    for (GroupReference internalGroup : allInternalGroups) {
+      Optional<InternalGroup> group = getGroupFromNoteDb(allUsersRepo, internalGroup.getUUID());
+      group.map(InternalGroup::getSubgroups).ifPresent(allSubgroups::addAll);
+    }
+    return allSubgroups
+        .build()
+        .stream()
+        .filter(groupUuid -> !AccountGroup.isInternalGroup(groupUuid));
+  }
+
+  /**
+   * Returns the membership audit records for a given group.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param repo All-Users repository.
+   * @param groupUuid the UUID of the group
+   * @return the audit records, in arbitrary order; empty if the group does not exist
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   * @throws IOException if an error occurs while reading from NoteDb
+   * @throws ConfigInvalidException if the group couldn't be retrieved from NoteDb
+   */
+  public List<AccountGroupMemberAudit> getMembersAudit(
+      ReviewDb db, Repository repo, AccountGroup.UUID groupUuid)
+      throws OrmException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      return auditLogReader.getMembersAudit(repo, groupUuid);
+    }
+    Optional<AccountGroup> group = getGroupFromReviewDb(db, groupUuid);
+    if (!group.isPresent()) {
+      return ImmutableList.of();
+    }
+
+    return db.accountGroupMembersAudit().byGroup(group.get().getId()).toList();
+  }
+
+  /**
+   * Returns the subgroup audit records for a given group.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param repo All-Users repository.
+   * @param groupUuid the UUID of the group
+   * @return the audit records, in arbitrary order; empty if the group does not exist
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   * @throws IOException if an error occurs while reading from NoteDb
+   * @throws ConfigInvalidException if the group couldn't be retrieved from NoteDb
+   */
+  public List<AccountGroupByIdAud> getSubgroupsAudit(
+      ReviewDb db, Repository repo, AccountGroup.UUID groupUuid)
+      throws OrmException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      return auditLogReader.getSubgroupsAudit(repo, groupUuid);
+    }
+    Optional<AccountGroup> group = getGroupFromReviewDb(db, groupUuid);
+    if (!group.isPresent()) {
+      return ImmutableList.of();
+    }
+
+    return db.accountGroupByIdAud().byGroup(group.get().getId()).toList();
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java
new file mode 100644
index 0000000..a0eae4a
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java
@@ -0,0 +1,171 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import static com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo.error;
+import static com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo.warning;
+
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Checks individual groups for oddities, such as cycles, non-existent subgroups, etc. Only works if
+ * we are writing to NoteDb.
+ */
+@Singleton
+public class GroupsConsistencyChecker {
+  private final AllUsersName allUsersName;
+  private final GroupBackend groupBackend;
+  private final Accounts accounts;
+  private final GitRepositoryManager repoManager;
+  private final GroupsNoteDbConsistencyChecker globalChecker;
+  private final GroupsMigration groupsMigration;
+
+  @Inject
+  GroupsConsistencyChecker(
+      AllUsersName allUsersName,
+      GroupBackend groupBackend,
+      Accounts accounts,
+      GitRepositoryManager repositoryManager,
+      GroupsNoteDbConsistencyChecker globalChecker,
+      GroupsMigration groupsMigration) {
+    this.allUsersName = allUsersName;
+    this.groupBackend = groupBackend;
+    this.accounts = accounts;
+    this.repoManager = repositoryManager;
+    this.globalChecker = globalChecker;
+    this.groupsMigration = groupsMigration;
+  }
+
+  /** Checks that all internal group references exist, and that no groups have cycles. */
+  public List<ConsistencyProblemInfo> check() throws IOException {
+    if (!groupsMigration.writeToNoteDb()) {
+      return new ArrayList<>();
+    }
+
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      GroupsNoteDbConsistencyChecker.Result result = globalChecker.check(repo);
+      if (!result.problems.isEmpty()) {
+        return result.problems;
+      }
+
+      for (InternalGroup g : result.uuidToGroupMap.values()) {
+        result.problems.addAll(checkGroup(g, result.uuidToGroupMap));
+      }
+
+      return result.problems;
+    }
+  }
+
+  /** Checks the metadata for a single group for problems. */
+  private List<ConsistencyProblemInfo> checkGroup(
+      InternalGroup g, Map<AccountGroup.UUID, InternalGroup> byUUID) throws IOException {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    problems.addAll(checkCycle(g, byUUID));
+
+    if (byUUID.get(g.getOwnerGroupUUID()) == null
+        && groupBackend.get(g.getOwnerGroupUUID()) == null) {
+      problems.add(
+          error(
+              "group %s (%s) has nonexistent owner group %s",
+              g.getName(), g.getGroupUUID(), g.getOwnerGroupUUID()));
+    }
+
+    for (AccountGroup.UUID subUuid : g.getSubgroups()) {
+      if (byUUID.get(subUuid) == null && groupBackend.get(subUuid) == null) {
+        problems.add(
+            error(
+                "group %s (%s) has nonexistent subgroup %s",
+                g.getName(), g.getGroupUUID(), subUuid));
+      }
+    }
+
+    for (Account.Id id : g.getMembers().asList()) {
+      AccountState account;
+      try {
+        account = accounts.get(id);
+      } catch (ConfigInvalidException e) {
+        problems.add(
+            error(
+                "group %s (%s) has member %s with invalid configuration: %s",
+                g.getName(), g.getGroupUUID(), id, e.getMessage()));
+        continue;
+      }
+      if (account == null) {
+        problems.add(
+            error("group %s (%s) has nonexistent member %s", g.getName(), g.getGroupUUID(), id));
+      }
+    }
+    return problems;
+  }
+
+  /** checkCycle walks through root's subgroups recursively, and checks for cycles. */
+  private List<ConsistencyProblemInfo> checkCycle(
+      InternalGroup root, Map<AccountGroup.UUID, InternalGroup> byUUID) {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+    Set<InternalGroup> todo = new LinkedHashSet<>();
+    Set<InternalGroup> seen = new HashSet<>();
+
+    todo.add(root);
+    while (!todo.isEmpty()) {
+      InternalGroup t = todo.iterator().next();
+      todo.remove(t);
+
+      if (seen.contains(t)) {
+        continue;
+      }
+      seen.add(t);
+
+      // We don't check for owner cycles, since those are normal in self-administered groups.
+      for (AccountGroup.UUID subUuid : t.getSubgroups()) {
+        InternalGroup g = byUUID.get(subUuid);
+        if (g == null) {
+          continue;
+        }
+
+        if (Objects.equals(g, root)) {
+          problems.add(
+              warning(
+                  "group %s (%s) contains a cycle: %s (%s) points to it as subgroup.",
+                  root.getName(), root.getGroupUUID(), t.getName(), t.getGroupUUID()));
+        }
+
+        todo.add(g);
+      }
+    }
+    return problems;
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
new file mode 100644
index 0000000..65ac12b
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
@@ -0,0 +1,283 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import static com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo.error;
+import static com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo.warning;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.group.InternalGroup;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Check the referential integrity of NoteDb group storage. */
+@Singleton
+public class GroupsNoteDbConsistencyChecker {
+  private static final Logger log = LoggerFactory.getLogger(GroupsNoteDbConsistencyChecker.class);
+  /**
+   * The result of a consistency check. The UUID map is only non-null if no problems were detected.
+   */
+  public static class Result {
+    public List<ConsistencyProblemInfo> problems;
+
+    @Nullable public Map<AccountGroup.UUID, InternalGroup> uuidToGroupMap;
+  }
+
+  /** Checks for problems with the given All-Users repo. */
+  public Result check(Repository repo) throws IOException {
+    Result r = doCheck(repo);
+    if (!r.problems.isEmpty()) {
+      r.uuidToGroupMap = null;
+    }
+    return r;
+  }
+
+  private Result doCheck(Repository repo) throws IOException {
+    Result result = new Result();
+    result.problems = new ArrayList<>();
+    result.uuidToGroupMap = new HashMap<>();
+
+    BiMap<AccountGroup.UUID, String> uuidNameBiMap = HashBiMap.create();
+
+    // Get all refs in an attempt to avoid seeing half committed group updates.
+    Map<String, Ref> refs = repo.getAllRefs();
+    readGroups(repo, refs, result);
+    readGroupNames(repo, refs, result, uuidNameBiMap);
+    // The sequential IDs are not keys in NoteDb, so no need to check them.
+
+    if (!result.problems.isEmpty()) {
+      return result;
+    }
+
+    // Continue checking if we could read data without problems.
+    result.problems.addAll(checkGlobalConsistency(result.uuidToGroupMap, uuidNameBiMap));
+
+    return result;
+  }
+
+  private void readGroups(Repository repo, Map<String, Ref> refs, Result result)
+      throws IOException {
+    for (Map.Entry<String, Ref> entry : refs.entrySet()) {
+      if (!entry.getKey().startsWith(RefNames.REFS_GROUPS)) {
+        continue;
+      }
+
+      AccountGroup.UUID uuid = AccountGroup.UUID.fromRef(entry.getKey());
+      if (uuid == null) {
+        result.problems.add(error("null UUID from %s", entry.getKey()));
+        continue;
+      }
+      try {
+        GroupConfig cfg =
+            GroupConfig.loadForGroupSnapshot(repo, uuid, entry.getValue().getObjectId());
+        result.uuidToGroupMap.put(uuid, cfg.getLoadedGroup().get());
+      } catch (ConfigInvalidException e) {
+        result.problems.add(error("group %s does not parse: %s", uuid, e.getMessage()));
+      }
+    }
+  }
+
+  private void readGroupNames(
+      Repository repo,
+      Map<String, Ref> refs,
+      Result result,
+      BiMap<AccountGroup.UUID, String> uuidNameBiMap)
+      throws IOException {
+    Ref ref = refs.get(RefNames.REFS_GROUPNAMES);
+    if (ref == null) {
+      String msg = String.format("ref %s does not exist", RefNames.REFS_GROUPNAMES);
+      result.problems.add(error(msg));
+      return;
+    }
+
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = rw.parseCommit(ref.getObjectId());
+      NoteMap nm = NoteMap.read(rw.getObjectReader(), c);
+
+      for (Note note : nm) {
+        ObjectLoader ld = rw.getObjectReader().open(note.getData());
+        byte[] data = ld.getCachedBytes();
+
+        GroupReference gRef;
+        try {
+          gRef = GroupNameNotes.getFromNoteData(data);
+        } catch (ConfigInvalidException e) {
+          result.problems.add(
+              error(
+                  "notename entry %s: %s does not parse: %s",
+                  note, new String(data, StandardCharsets.UTF_8), e.getMessage()));
+          continue;
+        }
+
+        ObjectId nameKey = GroupNameNotes.getNoteKey(new AccountGroup.NameKey(gRef.getName()));
+        if (!Objects.equals(nameKey, note)) {
+          result.problems.add(
+              error("notename entry %s does not match name %s", note, gRef.getName()));
+        }
+
+        // We trust SHA1 to have no collisions, so no need to check uniqueness of name.
+        uuidNameBiMap.put(gRef.getUUID(), gRef.getName());
+      }
+    }
+  }
+
+  /** Check invariants of the group refs with the group name refs. */
+  private List<ConsistencyProblemInfo> checkGlobalConsistency(
+      Map<AccountGroup.UUID, InternalGroup> uuidToGroupMap,
+      BiMap<AccountGroup.UUID, String> uuidNameBiMap) {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    // Check consistency between the data coming from different refs.
+    for (AccountGroup.UUID uuid : uuidToGroupMap.keySet()) {
+      if (!uuidNameBiMap.containsKey(uuid)) {
+        problems.add(error("group %s has no entry in name map", uuid));
+        continue;
+      }
+
+      String noteName = uuidNameBiMap.get(uuid);
+      String groupRefName = uuidToGroupMap.get(uuid).getName();
+      if (!Objects.equals(noteName, groupRefName)) {
+        problems.add(
+            error(
+                "inconsistent name for group %s (name map %s vs. group ref %s)",
+                uuid, noteName, groupRefName));
+      }
+    }
+
+    for (AccountGroup.UUID uuid : uuidNameBiMap.keySet()) {
+      if (!uuidToGroupMap.containsKey(uuid)) {
+        problems.add(
+            error(
+                "name map has entry (%s, %s), entry missing as group ref",
+                uuid, uuidNameBiMap.get(uuid)));
+      }
+    }
+
+    if (problems.isEmpty()) {
+      // Check ids.
+      Map<AccountGroup.Id, InternalGroup> groupById = new HashMap<>();
+      for (InternalGroup g : uuidToGroupMap.values()) {
+        InternalGroup before = groupById.get(g.getId());
+        if (before != null) {
+          problems.add(
+              error(
+                  "shared group id %s for %s (%s) and %s (%s)",
+                  g.getId(),
+                  before.getName(),
+                  before.getGroupUUID(),
+                  g.getName(),
+                  g.getGroupUUID()));
+        }
+        groupById.put(g.getId(), g);
+      }
+    }
+
+    return problems;
+  }
+
+  public static void ensureConsistentWithGroupNameNotes(
+      Repository allUsersRepo, InternalGroup group) throws IOException {
+    List<ConsistencyCheckInfo.ConsistencyProblemInfo> problems =
+        GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
+            allUsersRepo, group.getName(), group.getGroupUUID());
+    problems.forEach(GroupsNoteDbConsistencyChecker::logConsistencyProblem);
+  }
+
+  /**
+   * Check group 'uuid' and 'name' read from 'group.config' with group name notes.
+   *
+   * @param allUsersRepo 'All-Users' repository.
+   * @param groupName the name of the group to be checked.
+   * @param groupUUID the {@code AccountGroup.UUID} of the group to be checked.
+   * @return a list of {@code ConsistencyProblemInfo} containing the problem details.
+   */
+  @VisibleForTesting
+  static List<ConsistencyProblemInfo> checkWithGroupNameNotes(
+      Repository allUsersRepo, String groupName, AccountGroup.UUID groupUUID) throws IOException {
+    try {
+      Optional<GroupReference> groupRef =
+          GroupNameNotes.loadOneGroupReference(allUsersRepo, groupName);
+
+      if (!groupRef.isPresent()) {
+        return ImmutableList.of(
+            warning("Group with name '%s' doesn't exist in the list of all names", groupName));
+      }
+
+      AccountGroup.UUID uuid = groupRef.get().getUUID();
+      String name = groupRef.get().getName();
+
+      List<ConsistencyProblemInfo> problems = new ArrayList<>();
+      if (!Objects.equals(groupUUID, uuid)) {
+        problems.add(
+            warning(
+                "group with name '%s' has UUID '%s' in 'group.config' but '%s' in group name notes",
+                groupName, groupUUID, uuid));
+      }
+
+      if (!Objects.equals(groupName, name)) {
+        problems.add(
+            warning("group note of name '%s' claims to represent name of '%s'", groupName, name));
+      }
+      return problems;
+    } catch (ConfigInvalidException e) {
+      return ImmutableList.of(
+          warning("fail to check consistency with group name notes: %s", e.getMessage()));
+    }
+  }
+
+  public static void logConsistencyProblemAsWarning(String fmt, Object... args) {
+    logConsistencyProblem(warning(fmt, args));
+  }
+
+  public static void logConsistencyProblem(ConsistencyProblemInfo p) {
+    if (p.status == ConsistencyProblemInfo.Status.WARNING) {
+      log.warn(p.message);
+    } else {
+      log.error(p.message);
+    }
+  }
+
+  public static void logFailToLoadFromGroupRefAsWarning(AccountGroup.UUID uuid) {
+    logConsistencyProblem(
+        warning("Group with UUID %s from group name notes failed to load from group ref", uuid));
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
new file mode 100644
index 0000000..d93c8bd
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -0,0 +1,706 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.server.group.db.Groups.getExistingGroupFromReviewDb;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupName;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.GroupIncludeCache;
+import com.google.gerrit.server.audit.AuditService;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.RenameGroupOp;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * A database accessor for write calls related to groups.
+ *
+ * <p>All calls which write group related details to the database (either ReviewDb or NoteDb) are
+ * gathered here. Other classes should always use this class instead of accessing the database
+ * directly. There are a few exceptions though: schema classes, wrapper classes, and classes
+ * executed during init. The latter ones should use {@code GroupsOnInit} instead.
+ *
+ * <p>If not explicitly stated, all methods of this class refer to <em>internal</em> groups.
+ */
+public class GroupsUpdate {
+  public interface Factory {
+    /**
+     * Creates a {@code GroupsUpdate} which uses the identity of the specified user to mark database
+     * modifications executed by it. For NoteDb, this identity is used as author and committer for
+     * all related commits.
+     *
+     * <p><strong>Note</strong>: Please use this method with care and rather consider to use the
+     * correct annotation on the provider of a {@code GroupsUpdate} instead.
+     *
+     * @param currentUser the user to which modifications should be attributed, or {@code null} if
+     *     the Gerrit server identity should be used
+     */
+    GroupsUpdate create(@Nullable IdentifiedUser currentUser);
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final GroupBackend groupBackend;
+  private final GroupCache groupCache;
+  private final GroupIncludeCache groupIncludeCache;
+  private final AuditService auditService;
+  private final AccountCache accountCache;
+  private final RenameGroupOp.Factory renameGroupOpFactory;
+  private final String serverId;
+  @Nullable private final IdentifiedUser currentUser;
+  private final PersonIdent authorIdent;
+  private final MetaDataUpdateFactory metaDataUpdateFactory;
+  private final GroupsMigration groupsMigration;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final boolean reviewDbUpdatesAreBlocked;
+
+  @Inject
+  GroupsUpdate(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      GroupBackend groupBackend,
+      GroupCache groupCache,
+      GroupIncludeCache groupIncludeCache,
+      AuditService auditService,
+      AccountCache accountCache,
+      RenameGroupOp.Factory renameGroupOpFactory,
+      @GerritServerId String serverId,
+      @GerritPersonIdent PersonIdent serverIdent,
+      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
+      GroupsMigration groupsMigration,
+      @GerritServerConfig Config config,
+      GitReferenceUpdated gitRefUpdated,
+      @Assisted @Nullable IdentifiedUser currentUser) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.groupBackend = groupBackend;
+    this.groupCache = groupCache;
+    this.groupIncludeCache = groupIncludeCache;
+    this.auditService = auditService;
+    this.accountCache = accountCache;
+    this.renameGroupOpFactory = renameGroupOpFactory;
+    this.serverId = serverId;
+    this.groupsMigration = groupsMigration;
+    this.gitRefUpdated = gitRefUpdated;
+    this.currentUser = currentUser;
+    metaDataUpdateFactory =
+        getMetaDataUpdateFactory(metaDataUpdateInternalFactory, currentUser, serverIdent, serverId);
+    authorIdent = getAuthorIdent(serverIdent, currentUser);
+    reviewDbUpdatesAreBlocked = config.getBoolean("user", null, "blockReviewDbGroupUpdates", false);
+  }
+
+  private static MetaDataUpdateFactory getMetaDataUpdateFactory(
+      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
+      @Nullable IdentifiedUser currentUser,
+      PersonIdent serverIdent,
+      String serverId) {
+    return (projectName, repository, batchRefUpdate) -> {
+      MetaDataUpdate metaDataUpdate =
+          metaDataUpdateInternalFactory.create(projectName, repository, batchRefUpdate);
+      metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
+      PersonIdent authorIdent;
+      if (currentUser != null) {
+        metaDataUpdate.setAuthor(currentUser);
+        authorIdent = getAuditLogAuthorIdent(currentUser.getAccount(), serverIdent, serverId);
+      } else {
+        authorIdent = serverIdent;
+      }
+      metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
+      return metaDataUpdate;
+    };
+  }
+
+  private static PersonIdent getAuditLogAuthorIdent(
+      Account author, PersonIdent serverIdent, String serverId) {
+    return new PersonIdent(
+        author.getName(),
+        getEmailForAuditLog(author.getId(), serverId),
+        serverIdent.getWhen(),
+        serverIdent.getTimeZone());
+  }
+
+  private static PersonIdent getAuthorIdent(
+      PersonIdent serverIdent, @Nullable IdentifiedUser currentUser) {
+    return currentUser != null ? createPersonIdent(serverIdent, currentUser) : serverIdent;
+  }
+
+  private static PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
+    return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
+  }
+
+  /**
+   * Creates the specified group for the specified members (accounts).
+   *
+   * @param db the {@code ReviewDb} instance to update
+   * @param groupCreation an {@code InternalGroupCreation} which specifies all mandatory properties
+   *     of the group
+   * @param groupUpdate an {@code InternalGroupUpdate} which specifies optional properties of the
+   *     group. If this {@code InternalGroupUpdate} updates a property which was already specified
+   *     by the {@code InternalGroupCreation}, the value of this {@code InternalGroupUpdate} wins.
+   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
+   * @throws OrmDuplicateKeyException if a group with the chosen name already exists
+   * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
+   * @return the created {@code InternalGroup}
+   */
+  public InternalGroup createGroup(
+      ReviewDb db, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      throws OrmException, IOException, ConfigInvalidException {
+    if (!groupsMigration.disableGroupReviewDb()) {
+      if (!groupUpdate.getUpdatedOn().isPresent()) {
+        // Set updatedOn to a specific value so that the same timestamp is used for ReviewDb and
+        // NoteDb.
+        groupUpdate = groupUpdate.toBuilder().setUpdatedOn(TimeUtil.nowTs()).build();
+      }
+
+      InternalGroup createdGroupInReviewDb =
+          createGroupInReviewDb(ReviewDbUtil.unwrapDb(db), groupCreation, groupUpdate);
+
+      if (!groupsMigration.writeToNoteDb()) {
+        updateCachesOnGroupCreation(createdGroupInReviewDb);
+        return createdGroupInReviewDb;
+      }
+    }
+
+    // TODO(aliceks): Add retry mechanism.
+    InternalGroup createdGroup = createGroupInNoteDb(groupCreation, groupUpdate);
+    updateCachesOnGroupCreation(createdGroup);
+    return createdGroup;
+  }
+
+  /**
+   * Updates the specified group.
+   *
+   * @param db the {@code ReviewDb} instance to update
+   * @param groupUuid the UUID of the group to update
+   * @param groupUpdate an {@code InternalGroupUpdate} which indicates the desired updates on the
+   *     group
+   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
+   * @throws com.google.gwtorm.server.OrmDuplicateKeyException if the new name of the group is used
+   *     by another group
+   * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
+   * @throws NoSuchGroupException if the specified group doesn't exist
+   */
+  public void updateGroup(ReviewDb db, AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+      throws OrmException, IOException, NoSuchGroupException, ConfigInvalidException {
+    UpdateResult result = updateGroupInDb(db, groupUuid, groupUpdate);
+    updateCachesOnGroupUpdate(result);
+  }
+
+  @VisibleForTesting
+  public UpdateResult updateGroupInDb(
+      ReviewDb db, AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+      throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
+    UpdateResult reviewDbUpdateResult = null;
+    if (!groupsMigration.disableGroupReviewDb()) {
+      if (!groupUpdate.getUpdatedOn().isPresent()) {
+        // Set updatedOn to a specific value so that the same timestamp is used for ReviewDb and
+        // NoteDb.
+        groupUpdate = groupUpdate.toBuilder().setUpdatedOn(TimeUtil.nowTs()).build();
+      }
+
+      AccountGroup group = getExistingGroupFromReviewDb(ReviewDbUtil.unwrapDb(db), groupUuid);
+      reviewDbUpdateResult = updateGroupInReviewDb(ReviewDbUtil.unwrapDb(db), group, groupUpdate);
+
+      if (!groupsMigration.writeToNoteDb()) {
+        return reviewDbUpdateResult;
+      }
+    }
+
+    // TODO(aliceks): Add retry mechanism.
+    Optional<UpdateResult> noteDbUpdateResult = updateGroupInNoteDb(groupUuid, groupUpdate);
+    return noteDbUpdateResult.orElse(reviewDbUpdateResult);
+  }
+
+  private InternalGroup createGroupInReviewDb(
+      ReviewDb db, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      throws OrmException {
+    checkIfReviewDbUpdatesAreBlocked();
+
+    AccountGroupName gn = new AccountGroupName(groupCreation.getNameKey(), groupCreation.getId());
+    // first insert the group name to validate that the group name hasn't
+    // already been used to create another group
+    db.accountGroupNames().insert(ImmutableList.of(gn));
+
+    Timestamp createdOn = groupUpdate.getUpdatedOn().orElseGet(TimeUtil::nowTs);
+    AccountGroup group = createAccountGroup(groupCreation, createdOn);
+    UpdateResult updateResult = updateGroupInReviewDb(db, group, groupUpdate);
+    return InternalGroup.create(
+        group,
+        updateResult.getModifiedMembers(),
+        updateResult.getModifiedSubgroups(),
+        updateResult.getRefState());
+  }
+
+  public static AccountGroup createAccountGroup(
+      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate) {
+    Timestamp createdOn = groupUpdate.getUpdatedOn().orElseGet(TimeUtil::nowTs);
+    AccountGroup group = createAccountGroup(groupCreation, createdOn);
+    applyUpdate(group, groupUpdate);
+    return group;
+  }
+
+  private static AccountGroup createAccountGroup(
+      InternalGroupCreation groupCreation, Timestamp createdOn) {
+    return new AccountGroup(
+        groupCreation.getNameKey(), groupCreation.getId(), groupCreation.getGroupUUID(), createdOn);
+  }
+
+  private static void applyUpdate(AccountGroup group, InternalGroupUpdate groupUpdate) {
+    groupUpdate.getName().ifPresent(group::setNameKey);
+    groupUpdate.getDescription().ifPresent(d -> group.setDescription(Strings.emptyToNull(d)));
+    groupUpdate.getOwnerGroupUUID().ifPresent(group::setOwnerGroupUUID);
+    groupUpdate.getVisibleToAll().ifPresent(group::setVisibleToAll);
+  }
+
+  private UpdateResult updateGroupInReviewDb(
+      ReviewDb db, AccountGroup group, InternalGroupUpdate groupUpdate) throws OrmException {
+    checkIfReviewDbUpdatesAreBlocked();
+
+    AccountGroup.NameKey originalName = group.getNameKey();
+    applyUpdate(group, groupUpdate);
+    AccountGroup.NameKey updatedName = group.getNameKey();
+
+    // The name must be inserted first so that we stop early for already used names.
+    updateNameInReviewDb(db, group.getId(), originalName, updatedName);
+    db.accountGroups().upsert(ImmutableList.of(group));
+    ImmutableSet<Account.Id> modifiedMembers =
+        updateMembersInReviewDb(db, group.getId(), groupUpdate);
+    ImmutableSet<AccountGroup.UUID> modifiedSubgroups =
+        updateSubgroupsInReviewDb(db, group.getId(), groupUpdate);
+
+    UpdateResult.Builder resultBuilder =
+        UpdateResult.builder()
+            .setGroupUuid(group.getGroupUUID())
+            .setGroupId(group.getId())
+            .setGroupName(group.getNameKey())
+            .setModifiedMembers(modifiedMembers)
+            .setModifiedSubgroups(modifiedSubgroups);
+    if (!Objects.equals(originalName, updatedName)) {
+      resultBuilder.setPreviousGroupName(originalName);
+    }
+    return resultBuilder.build();
+  }
+
+  private static void updateNameInReviewDb(
+      ReviewDb db,
+      AccountGroup.Id groupId,
+      AccountGroup.NameKey originalName,
+      AccountGroup.NameKey updatedName)
+      throws OrmException {
+    try {
+      AccountGroupName id = new AccountGroupName(updatedName, groupId);
+      db.accountGroupNames().insert(ImmutableList.of(id));
+    } catch (OrmException e) {
+      AccountGroupName other = db.accountGroupNames().get(updatedName);
+      if (other != null) {
+        // If we are using this identity, don't report the exception.
+        if (other.getId().equals(groupId)) {
+          return;
+        }
+      }
+      throw e;
+    }
+    db.accountGroupNames().deleteKeys(ImmutableList.of(originalName));
+  }
+
+  private ImmutableSet<Account.Id> updateMembersInReviewDb(
+      ReviewDb db, AccountGroup.Id groupId, InternalGroupUpdate groupUpdate) throws OrmException {
+    Timestamp updatedOn = groupUpdate.getUpdatedOn().orElseGet(TimeUtil::nowTs);
+    ImmutableSet<Account.Id> originalMembers =
+        Groups.getMembersFromReviewDb(db, groupId).collect(toImmutableSet());
+    ImmutableSet<Account.Id> updatedMembers =
+        ImmutableSet.copyOf(groupUpdate.getMemberModification().apply(originalMembers));
+
+    Set<Account.Id> addedMembers = Sets.difference(updatedMembers, originalMembers);
+    if (!addedMembers.isEmpty()) {
+      addGroupMembersInReviewDb(db, groupId, addedMembers, updatedOn);
+    }
+
+    Set<Account.Id> removedMembers = Sets.difference(originalMembers, updatedMembers);
+    if (!removedMembers.isEmpty()) {
+      removeGroupMembersInReviewDb(db, groupId, removedMembers, updatedOn);
+    }
+
+    return Sets.union(addedMembers, removedMembers).immutableCopy();
+  }
+
+  private void addGroupMembersInReviewDb(
+      ReviewDb db, AccountGroup.Id groupId, Set<Account.Id> newMemberIds, Timestamp addedOn)
+      throws OrmException {
+    Set<AccountGroupMember> newMembers =
+        newMemberIds
+            .stream()
+            .map(accountId -> new AccountGroupMember.Key(accountId, groupId))
+            .map(AccountGroupMember::new)
+            .collect(toImmutableSet());
+
+    if (currentUser != null) {
+      auditService.dispatchAddAccountsToGroup(currentUser.getAccountId(), newMembers, addedOn);
+    }
+    db.accountGroupMembers().insert(newMembers);
+  }
+
+  private void removeGroupMembersInReviewDb(
+      ReviewDb db, AccountGroup.Id groupId, Set<Account.Id> accountIds, Timestamp removedOn)
+      throws OrmException {
+    Set<AccountGroupMember> membersToRemove =
+        accountIds
+            .stream()
+            .map(accountId -> new AccountGroupMember.Key(accountId, groupId))
+            .map(AccountGroupMember::new)
+            .collect(toImmutableSet());
+
+    if (currentUser != null) {
+      auditService.dispatchDeleteAccountsFromGroup(
+          currentUser.getAccountId(), membersToRemove, removedOn);
+    }
+    db.accountGroupMembers().delete(membersToRemove);
+  }
+
+  private ImmutableSet<AccountGroup.UUID> updateSubgroupsInReviewDb(
+      ReviewDb db, AccountGroup.Id groupId, InternalGroupUpdate groupUpdate) throws OrmException {
+    Timestamp updatedOn = groupUpdate.getUpdatedOn().orElseGet(TimeUtil::nowTs);
+    ImmutableSet<AccountGroup.UUID> originalSubgroups =
+        Groups.getSubgroupsFromReviewDb(db, groupId).collect(toImmutableSet());
+    ImmutableSet<AccountGroup.UUID> updatedSubgroups =
+        ImmutableSet.copyOf(groupUpdate.getSubgroupModification().apply(originalSubgroups));
+
+    Set<AccountGroup.UUID> addedSubgroups = Sets.difference(updatedSubgroups, originalSubgroups);
+    if (!addedSubgroups.isEmpty()) {
+      addSubgroupsInReviewDb(db, groupId, addedSubgroups, updatedOn);
+    }
+
+    Set<AccountGroup.UUID> removedSubgroups = Sets.difference(originalSubgroups, updatedSubgroups);
+    if (!removedSubgroups.isEmpty()) {
+      removeSubgroupsInReviewDb(db, groupId, removedSubgroups, updatedOn);
+    }
+
+    return Sets.union(addedSubgroups, removedSubgroups).immutableCopy();
+  }
+
+  private void addSubgroupsInReviewDb(
+      ReviewDb db,
+      AccountGroup.Id parentGroupId,
+      Set<AccountGroup.UUID> subgroupUuids,
+      Timestamp addedOn)
+      throws OrmException {
+    Set<AccountGroupById> newSubgroups =
+        subgroupUuids
+            .stream()
+            .map(subgroupUuid -> new AccountGroupById.Key(parentGroupId, subgroupUuid))
+            .map(AccountGroupById::new)
+            .collect(toImmutableSet());
+
+    if (currentUser != null) {
+      auditService.dispatchAddGroupsToGroup(currentUser.getAccountId(), newSubgroups, addedOn);
+    }
+    db.accountGroupById().insert(newSubgroups);
+  }
+
+  private void removeSubgroupsInReviewDb(
+      ReviewDb db,
+      AccountGroup.Id parentGroupId,
+      Set<AccountGroup.UUID> subgroupUuids,
+      Timestamp removedOn)
+      throws OrmException {
+    Set<AccountGroupById> subgroupsToRemove =
+        subgroupUuids
+            .stream()
+            .map(subgroupUuid -> new AccountGroupById.Key(parentGroupId, subgroupUuid))
+            .map(AccountGroupById::new)
+            .collect(toImmutableSet());
+
+    if (currentUser != null) {
+      auditService.dispatchDeleteGroupsFromGroup(
+          currentUser.getAccountId(), subgroupsToRemove, removedOn);
+    }
+    db.accountGroupById().delete(subgroupsToRemove);
+  }
+
+  private InternalGroup createGroupInNoteDb(
+      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      throws IOException, ConfigInvalidException, OrmException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+      AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
+      GroupNameNotes groupNameNotes =
+          GroupNameNotes.loadForNewGroup(allUsersRepo, groupCreation.getGroupUUID(), groupName);
+
+      GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
+      groupConfig.setGroupUpdate(groupUpdate, this::getAccountNameEmail, this::getGroupName);
+
+      commit(allUsersRepo, groupConfig, groupNameNotes);
+
+      return groupConfig
+          .getLoadedGroup()
+          .orElseThrow(
+              () -> new IllegalStateException("Created group wasn't automatically loaded"));
+    }
+  }
+
+  private Optional<UpdateResult> updateGroupInNoteDb(
+      AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+      throws IOException, ConfigInvalidException, OrmDuplicateKeyException, NoSuchGroupException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+      GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepo, groupUuid);
+      groupConfig.setGroupUpdate(groupUpdate, this::getAccountNameEmail, this::getGroupName);
+      if (!groupConfig.getLoadedGroup().isPresent()) {
+        if (groupsMigration.readFromNoteDb()) {
+          throw new NoSuchGroupException(groupUuid);
+        }
+        return Optional.empty();
+      }
+
+      InternalGroup originalGroup = groupConfig.getLoadedGroup().get();
+
+      GroupNameNotes groupNameNotes = null;
+      if (groupUpdate.getName().isPresent()) {
+        AccountGroup.NameKey oldName = originalGroup.getNameKey();
+        AccountGroup.NameKey newName = groupUpdate.getName().get();
+        groupNameNotes = GroupNameNotes.loadForRename(allUsersRepo, groupUuid, oldName, newName);
+      }
+
+      commit(allUsersRepo, groupConfig, groupNameNotes);
+
+      InternalGroup updatedGroup =
+          groupConfig
+              .getLoadedGroup()
+              .orElseThrow(
+                  () -> new IllegalStateException("Updated group wasn't automatically loaded"));
+      return Optional.of(getUpdateResult(originalGroup, updatedGroup));
+    }
+  }
+
+  private static UpdateResult getUpdateResult(
+      InternalGroup originalGroup, InternalGroup updatedGroup) {
+    Set<Account.Id> modifiedMembers =
+        Sets.symmetricDifference(originalGroup.getMembers(), updatedGroup.getMembers());
+    Set<AccountGroup.UUID> modifiedSubgroups =
+        Sets.symmetricDifference(originalGroup.getSubgroups(), updatedGroup.getSubgroups());
+
+    UpdateResult.Builder resultBuilder =
+        UpdateResult.builder()
+            .setGroupUuid(updatedGroup.getGroupUUID())
+            .setGroupId(updatedGroup.getId())
+            .setGroupName(updatedGroup.getNameKey())
+            .setModifiedMembers(modifiedMembers)
+            .setModifiedSubgroups(modifiedSubgroups)
+            .setRefState(updatedGroup.getRefState());
+    if (!Objects.equals(originalGroup.getNameKey(), updatedGroup.getNameKey())) {
+      resultBuilder.setPreviousGroupName(originalGroup.getNameKey());
+    }
+    return resultBuilder.build();
+  }
+
+  static String getAccountName(AccountCache accountCache, Account.Id accountId) {
+    AccountState accountState = accountCache.getOrNull(accountId);
+    return Optional.ofNullable(accountState)
+        .map(AccountState::getAccount)
+        .map(account -> account.getName())
+        // Historically, the database did not enforce relational integrity, so it is
+        // possible for groups to have non-existing members.
+        .orElse("No Account for Id #" + accountId);
+  }
+
+  static String getAccountNameEmail(
+      AccountCache accountCache, Account.Id accountId, String serverId) {
+    String accountName = getAccountName(accountCache, accountId);
+    return formatNameEmail(accountName, getEmailForAuditLog(accountId, serverId));
+  }
+
+  static String getEmailForAuditLog(Account.Id accountId, String serverId) {
+    return accountId.get() + "@" + serverId;
+  }
+
+  private String getAccountNameEmail(Account.Id accountId) {
+    return getAccountNameEmail(accountCache, accountId, serverId);
+  }
+
+  static String getGroupName(GroupBackend groupBackend, AccountGroup.UUID groupUuid) {
+    String uuid = groupUuid.get();
+    GroupDescription.Basic desc = groupBackend.get(groupUuid);
+    String name = desc != null ? desc.getName() : uuid;
+    return formatNameEmail(name, uuid);
+  }
+
+  private String getGroupName(AccountGroup.UUID groupUuid) {
+    return getGroupName(groupBackend, groupUuid);
+  }
+
+  private static String formatNameEmail(String name, String email) {
+    StringBuilder formattedResult = new StringBuilder();
+    PersonIdent.appendSanitized(formattedResult, name);
+    formattedResult.append(" <");
+    PersonIdent.appendSanitized(formattedResult, email);
+    formattedResult.append(">");
+    return formattedResult.toString();
+  }
+
+  private void commit(
+      Repository allUsersRepo, GroupConfig groupConfig, @Nullable GroupNameNotes groupNameNotes)
+      throws IOException {
+    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+    try (MetaDataUpdate metaDataUpdate =
+        metaDataUpdateFactory.create(allUsersName, allUsersRepo, batchRefUpdate)) {
+      groupConfig.commit(metaDataUpdate);
+    }
+    if (groupNameNotes != null) {
+      // MetaDataUpdates unfortunately can't be reused. -> Create a new one.
+      try (MetaDataUpdate metaDataUpdate =
+          metaDataUpdateFactory.create(allUsersName, allUsersRepo, batchRefUpdate)) {
+        groupNameNotes.commit(metaDataUpdate);
+      }
+    }
+
+    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
+    gitRefUpdated.fire(
+        allUsersName, batchRefUpdate, currentUser != null ? currentUser.getAccount() : null);
+  }
+
+  private void updateCachesOnGroupCreation(InternalGroup createdGroup) throws IOException {
+    groupCache.onCreateGroup(createdGroup.getGroupUUID());
+    for (Account.Id modifiedMember : createdGroup.getMembers()) {
+      groupIncludeCache.evictGroupsWithMember(modifiedMember);
+    }
+    for (AccountGroup.UUID modifiedSubgroup : createdGroup.getSubgroups()) {
+      groupIncludeCache.evictParentGroupsOf(modifiedSubgroup);
+    }
+  }
+
+  private void updateCachesOnGroupUpdate(UpdateResult result) throws IOException {
+    if (result.getPreviousGroupName().isPresent()) {
+      AccountGroup.NameKey previousName = result.getPreviousGroupName().get();
+      groupCache.evictAfterRename(previousName);
+
+      // TODO(aliceks): After switching to NoteDb, consider to use a BatchRefUpdate.
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError =
+          renameGroupOpFactory
+              .create(
+                  authorIdent,
+                  result.getGroupUuid(),
+                  previousName.get(),
+                  result.getGroupName().get())
+              .start(0, TimeUnit.MILLISECONDS);
+    }
+    groupCache.evict(result.getGroupUuid(), result.getGroupId(), result.getGroupName());
+    for (Account.Id modifiedMember : result.getModifiedMembers()) {
+      groupIncludeCache.evictGroupsWithMember(modifiedMember);
+    }
+    for (AccountGroup.UUID modifiedSubgroup : result.getModifiedSubgroups()) {
+      groupIncludeCache.evictParentGroupsOf(modifiedSubgroup);
+    }
+  }
+
+  private void checkIfReviewDbUpdatesAreBlocked() throws OrmException {
+    if (reviewDbUpdatesAreBlocked) {
+      throw new OrmException("Updates to groups in ReviewDb are blocked");
+    }
+  }
+
+  @FunctionalInterface
+  private interface MetaDataUpdateFactory {
+    MetaDataUpdate create(
+        Project.NameKey projectName, Repository repository, BatchRefUpdate batchRefUpdate)
+        throws IOException;
+  }
+
+  @AutoValue
+  abstract static class UpdateResult {
+    abstract AccountGroup.UUID getGroupUuid();
+
+    abstract AccountGroup.Id getGroupId();
+
+    abstract AccountGroup.NameKey getGroupName();
+
+    abstract Optional<AccountGroup.NameKey> getPreviousGroupName();
+
+    abstract ImmutableSet<Account.Id> getModifiedMembers();
+
+    abstract ImmutableSet<AccountGroup.UUID> getModifiedSubgroups();
+
+    @Nullable
+    public abstract ObjectId getRefState();
+
+    static Builder builder() {
+      return new AutoValue_GroupsUpdate_UpdateResult.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder setGroupUuid(AccountGroup.UUID groupUuid);
+
+      abstract Builder setGroupId(AccountGroup.Id groupId);
+
+      abstract Builder setGroupName(AccountGroup.NameKey name);
+
+      abstract Builder setPreviousGroupName(AccountGroup.NameKey previousName);
+
+      abstract Builder setModifiedMembers(Set<Account.Id> modifiedMembers);
+
+      abstract Builder setModifiedSubgroups(Set<AccountGroup.UUID> modifiedSubgroups);
+
+      public abstract Builder setRefState(ObjectId refState);
+
+      abstract UpdateResult build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/InternalGroupCreation.java b/java/com/google/gerrit/server/group/db/InternalGroupCreation.java
new file mode 100644
index 0000000..3fcf96f
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/InternalGroupCreation.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.group.db;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+
+// TODO(aliceks): Add Javadoc descriptions to this file.
+@AutoValue
+public abstract class InternalGroupCreation {
+
+  public abstract AccountGroup.Id getId();
+
+  public abstract AccountGroup.NameKey getNameKey();
+
+  public abstract AccountGroup.UUID getGroupUUID();
+
+  public static Builder builder() {
+    return new AutoValue_InternalGroupCreation.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract InternalGroupCreation.Builder setId(AccountGroup.Id id);
+
+    public abstract InternalGroupCreation.Builder setNameKey(AccountGroup.NameKey name);
+
+    public abstract InternalGroupCreation.Builder setGroupUUID(AccountGroup.UUID groupUuid);
+
+    public abstract InternalGroupCreation build();
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java b/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
new file mode 100644
index 0000000..5758297
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/InternalGroupUpdate.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.group.db;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.sql.Timestamp;
+import java.util.Optional;
+import java.util.Set;
+
+// TODO(aliceks): Add Javadoc descriptions to this file.
+@AutoValue
+public abstract class InternalGroupUpdate {
+  @FunctionalInterface
+  public interface MemberModification {
+    Set<Account.Id> apply(ImmutableSet<Account.Id> in);
+  }
+
+  @FunctionalInterface
+  public interface SubgroupModification {
+    Set<AccountGroup.UUID> apply(ImmutableSet<AccountGroup.UUID> in);
+  }
+
+  public abstract Optional<AccountGroup.NameKey> getName();
+
+  // TODO(aliceks): Mention empty string (not null!) -> unset value in Javadoc.
+  public abstract Optional<String> getDescription();
+
+  public abstract Optional<AccountGroup.UUID> getOwnerGroupUUID();
+
+  public abstract Optional<Boolean> getVisibleToAll();
+
+  public abstract MemberModification getMemberModification();
+
+  public abstract SubgroupModification getSubgroupModification();
+
+  public abstract Optional<Timestamp> getUpdatedOn();
+
+  public abstract Builder toBuilder();
+
+  public static Builder builder() {
+    return new AutoValue_InternalGroupUpdate.Builder()
+        .setMemberModification(in -> in)
+        .setSubgroupModification(in -> in);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setName(AccountGroup.NameKey name);
+
+    public abstract Builder setDescription(String description);
+
+    public abstract Builder setOwnerGroupUUID(AccountGroup.UUID ownerGroupUUID);
+
+    public abstract Builder setVisibleToAll(boolean visibleToAll);
+
+    public abstract Builder setMemberModification(MemberModification memberModification);
+
+    abstract MemberModification getMemberModification();
+
+    public abstract Builder setSubgroupModification(SubgroupModification subgroupModification);
+
+    abstract SubgroupModification getSubgroupModification();
+
+    public abstract Builder setUpdatedOn(Timestamp timestamp);
+
+    public abstract InternalGroupUpdate build();
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/testing/BUILD b/java/com/google/gerrit/server/group/db/testing/BUILD
new file mode 100644
index 0000000..6961b65
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/testing/BUILD
@@ -0,0 +1,16 @@
+package(default_visibility = ["//visibility:public"])
+
+java_library(
+    name = "testing",
+    testonly = 1,
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java b/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
new file mode 100644
index 0000000..46fd666
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
@@ -0,0 +1,123 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db.testing;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.group.db.GroupNameNotes.getGroupReference;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import java.io.IOException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Test utilities for low-level NoteDb groups. */
+public class GroupTestUtil {
+  public static ImmutableMap<String, String> readNameToUuidMap(Repository repo) throws Exception {
+    ImmutableMap.Builder<String, String> result = ImmutableMap.builder();
+    try (RevWalk rw = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(RefNames.REFS_GROUPNAMES);
+      if (ref != null) {
+        NoteMap noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(ref.getObjectId()));
+        for (Note note : noteMap) {
+          GroupReference gr = getGroupReference(rw.getObjectReader(), note.getData());
+          result.put(gr.getName(), gr.getUUID().get());
+        }
+      }
+    }
+    return result.build();
+  }
+
+  // TODO(dborowitz): Move somewhere even more common.
+  public static ImmutableList<CommitInfo> log(Repository repo, String refName) throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(refName);
+      if (ref != null) {
+        rw.sort(RevSort.REVERSE);
+        rw.markStart(rw.parseCommit(ref.getObjectId()));
+        return Streams.stream(rw)
+            .map(
+                c -> {
+                  try {
+                    return CommitUtil.toCommitInfo(c);
+                  } catch (IOException e) {
+                    throw new IllegalStateException(
+                        "unexpected state when converting commit " + c.getName(), e);
+                  }
+                })
+            .collect(toImmutableList());
+      }
+    }
+    return ImmutableList.of();
+  }
+
+  public static void updateGroupFile(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      PersonIdent serverIdent,
+      String refName,
+      String fileName,
+      String content)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      updateGroupFile(repo, serverIdent, refName, fileName, content);
+    }
+  }
+
+  public static void updateGroupFile(
+      Repository allUsersRepo,
+      PersonIdent serverIdent,
+      String refName,
+      String fileName,
+      String contents)
+      throws Exception {
+    try (RevWalk rw = new RevWalk(allUsersRepo)) {
+      TestRepository<Repository> testRepository = new TestRepository<>(allUsersRepo, rw);
+      TestRepository<Repository>.CommitBuilder builder =
+          testRepository
+              .branch(refName)
+              .commit()
+              .add(fileName, contents)
+              .message("update group file")
+              .author(serverIdent)
+              .committer(serverIdent);
+
+      Ref ref = allUsersRepo.exactRef(refName);
+      if (ref != null) {
+        RevCommit c = rw.parseCommit(ref.getObjectId());
+        if (c != null) {
+          builder.parent(c);
+        }
+      }
+      builder.create();
+    }
+  }
+
+  private GroupTestUtil() {}
+}
diff --git a/java/com/google/gerrit/server/index/DummyIndexModule.java b/java/com/google/gerrit/server/index/DummyIndexModule.java
new file mode 100644
index 0000000..85d6a7c
--- /dev/null
+++ b/java/com/google/gerrit/server/index/DummyIndexModule.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.DummyChangeIndex;
+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;
+
+public class DummyIndexModule extends AbstractModule {
+  private static class DummyChangeIndexFactory implements ChangeIndex.Factory {
+    @Override
+    public ChangeIndex create(Schema<ChangeData> schema) {
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  private static class DummyAccountIndexFactory implements AccountIndex.Factory {
+    @Override
+    public AccountIndex create(Schema<AccountState> schema) {
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  private static class DummyGroupIndexFactory implements GroupIndex.Factory {
+    @Override
+    public GroupIndex create(Schema<InternalGroup> schema) {
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  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));
+    bind(IndexConfig.class).toInstance(IndexConfig.createDefault());
+    bind(Index.class).toInstance(new DummyChangeIndex());
+    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/GerritIndexStatus.java b/java/com/google/gerrit/server/index/GerritIndexStatus.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/GerritIndexStatus.java
rename to java/com/google/gerrit/server/index/GerritIndexStatus.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexExecutor.java b/java/com/google/gerrit/server/index/IndexExecutor.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/IndexExecutor.java
rename to java/com/google/gerrit/server/index/IndexExecutor.java
diff --git a/java/com/google/gerrit/server/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
new file mode 100644
index 0000000..cb6dc3c
--- /dev/null
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -0,0 +1,253 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableCollection;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.SchemaDefinitions;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.index.account.AccountIndexDefinition;
+import com.google.gerrit.server.index.account.AccountIndexRewriter;
+import com.google.gerrit.server.index.account.AccountIndexer;
+import com.google.gerrit.server.index.account.AccountIndexerImpl;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.ChangeIndexDefinition;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.index.group.GroupIndexDefinition;
+import com.google.gerrit.server.index.group.GroupIndexRewriter;
+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.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.Provides;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Module for non-indexer-specific secondary index setup.
+ *
+ * <p>This module should not be used directly except by specific secondary indexer implementations
+ * (e.g. Lucene).
+ */
+public class IndexModule extends LifecycleModule {
+  public enum IndexType {
+    LUCENE,
+    ELASTICSEARCH
+  }
+
+  public static final ImmutableCollection<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
+      ImmutableList.<SchemaDefinitions<?>>of(
+          AccountSchemaDefinitions.INSTANCE,
+          ChangeSchemaDefinitions.INSTANCE,
+          GroupSchemaDefinitions.INSTANCE,
+          ProjectSchemaDefinitions.INSTANCE);
+
+  /** Type of secondary index. */
+  public static IndexType getIndexType(Injector injector) {
+    Config cfg = injector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+    return cfg.getEnum("index", null, "type", IndexType.LUCENE);
+  }
+
+  private final int threads;
+  private final ListeningExecutorService interactiveExecutor;
+  private final ListeningExecutorService batchExecutor;
+  private final boolean closeExecutorsOnShutdown;
+
+  public IndexModule(int threads) {
+    this.threads = threads;
+    this.interactiveExecutor = null;
+    this.batchExecutor = null;
+    this.closeExecutorsOnShutdown = true;
+  }
+
+  public IndexModule(
+      ListeningExecutorService interactiveExecutor, ListeningExecutorService batchExecutor) {
+    this.threads = -1;
+    this.interactiveExecutor = interactiveExecutor;
+    this.batchExecutor = batchExecutor;
+    this.closeExecutorsOnShutdown = false;
+  }
+
+  @Override
+  protected void configure() {
+
+    bind(AccountIndexRewriter.class);
+    bind(AccountIndexCollection.class);
+    listener().to(AccountIndexCollection.class);
+    factory(AccountIndexerImpl.Factory.class);
+
+    bind(ChangeIndexRewriter.class);
+    bind(ChangeIndexCollection.class);
+    listener().to(ChangeIndexCollection.class);
+    factory(ChangeIndexer.Factory.class);
+
+    bind(GroupIndexRewriter.class);
+    bind(GroupIndexCollection.class);
+    listener().to(GroupIndexCollection.class);
+    factory(GroupIndexerImpl.Factory.class);
+
+    bind(ProjectIndexRewriter.class);
+    bind(ProjectIndexCollection.class);
+    listener().to(ProjectIndexCollection.class);
+    factory(ProjectIndexerImpl.Factory.class);
+
+    if (closeExecutorsOnShutdown) {
+      // The executors must be shutdown _before_ closing the indexes.
+      // On Gerrit start the LifecycleListeners are invoked in the order in which they are
+      // registered, but on shutdown of Gerrit the order is reversed. This means the
+      // LifecycleListener to shutdown the executors must be registered _after_ the
+      // LifecycleListeners that close the indexes. The closing of the indexes is done by
+      // *IndexCollection which have been registered as LifecycleListener above. The
+      // registration of the ShutdownIndexExecutors LifecycleListener must happen afterwards.
+      listener().to(ShutdownIndexExecutors.class);
+    }
+
+    DynamicSet.setOf(binder(), OnlineUpgradeListener.class);
+  }
+
+  @Provides
+  Collection<IndexDefinition<?, ?, ?>> getIndexDefinitions(
+      AccountIndexDefinition accounts,
+      ChangeIndexDefinition changes,
+      GroupIndexDefinition groups,
+      ProjectIndexDefinition projects) {
+    Collection<IndexDefinition<?, ?, ?>> result =
+        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();
+    if (!expected.equals(actual)) {
+      throw new ProvisionException(
+          "need index definitions for all schemas: " + expected + " != " + actual);
+    }
+    return result;
+  }
+
+  @Provides
+  @Singleton
+  AccountIndexer getAccountIndexer(
+      AccountIndexerImpl.Factory factory, AccountIndexCollection indexes) {
+    return factory.create(indexes);
+  }
+
+  @Provides
+  @Singleton
+  ChangeIndexer getChangeIndexer(
+      @IndexExecutor(INTERACTIVE) ListeningExecutorService executor,
+      ChangeIndexer.Factory factory,
+      ChangeIndexCollection indexes) {
+    // Bind default indexer to interactive executor; callers who need a
+    // different executor can use the factory directly.
+    return factory.create(executor, indexes);
+  }
+
+  @Provides
+  @Singleton
+  GroupIndexer getGroupIndexer(GroupIndexerImpl.Factory factory, GroupIndexCollection indexes) {
+    return factory.create(indexes);
+  }
+
+  @Provides
+  @Singleton
+  ProjectIndexer getProjectIndexer(
+      ProjectIndexerImpl.Factory factory, ProjectIndexCollection indexes) {
+    return factory.create(indexes);
+  }
+
+  @Provides
+  @Singleton
+  @IndexExecutor(INTERACTIVE)
+  ListeningExecutorService getInteractiveIndexExecutor(
+      @GerritServerConfig Config config, WorkQueue workQueue) {
+    if (interactiveExecutor != null) {
+      return interactiveExecutor;
+    }
+    int threads = this.threads;
+    if (threads <= 0) {
+      threads = config.getInt("index", null, "threads", 0);
+    }
+    if (threads <= 0) {
+      threads = Runtime.getRuntime().availableProcessors() / 2 + 1;
+    }
+    return MoreExecutors.listeningDecorator(workQueue.createQueue(threads, "Index-Interactive"));
+  }
+
+  @Provides
+  @Singleton
+  @IndexExecutor(BATCH)
+  ListeningExecutorService getBatchIndexExecutor(
+      @GerritServerConfig Config config, WorkQueue workQueue) {
+    if (batchExecutor != null) {
+      return batchExecutor;
+    }
+    int threads = config.getInt("index", null, "batchThreads", 0);
+    if (threads <= 0) {
+      threads = Runtime.getRuntime().availableProcessors();
+    }
+    return MoreExecutors.listeningDecorator(workQueue.createQueue(threads, "Index-Batch"));
+  }
+
+  @Singleton
+  private static class ShutdownIndexExecutors implements LifecycleListener {
+    private final ListeningExecutorService interactiveExecutor;
+    private final ListeningExecutorService batchExecutor;
+
+    @Inject
+    ShutdownIndexExecutors(
+        @IndexExecutor(INTERACTIVE) ListeningExecutorService interactiveExecutor,
+        @IndexExecutor(BATCH) ListeningExecutorService batchExecutor) {
+      this.interactiveExecutor = interactiveExecutor;
+      this.batchExecutor = batchExecutor;
+    }
+
+    @Override
+    public void start() {}
+
+    @Override
+    public void stop() {
+      MoreExecutors.shutdownAndAwaitTermination(
+          interactiveExecutor, Long.MAX_VALUE, TimeUnit.SECONDS);
+      MoreExecutors.shutdownAndAwaitTermination(batchExecutor, Long.MAX_VALUE, TimeUnit.SECONDS);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
new file mode 100644
index 0000000..2abe876
--- /dev/null
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
+import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.account.AccountField;
+import com.google.gerrit.server.index.group.GroupField;
+import com.google.gerrit.server.index.project.ProjectField;
+import com.google.gerrit.server.query.change.SingleGroupUser;
+import java.io.IOException;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public final class IndexUtils {
+  public static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING =
+      ImmutableMap.of("_", " ", ".", " ");
+
+  public static final Function<Exception, IOException> MAPPER =
+      new Function<Exception, IOException>() {
+        @Override
+        public IOException apply(Exception in) {
+          if (in instanceof IOException) {
+            return (IOException) in;
+          } else if (in instanceof ExecutionException && in.getCause() instanceof IOException) {
+            return (IOException) in.getCause();
+          } else {
+            return new IOException(in);
+          }
+        }
+      };
+
+  public static void setReady(SitePaths sitePaths, String name, int version, boolean ready)
+      throws IOException {
+    try {
+      GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
+      cfg.setReady(name, version, ready);
+      cfg.save();
+    } catch (ConfigInvalidException e) {
+      throw new IOException(e);
+    }
+  }
+
+  public static boolean getReady(SitePaths sitePaths, String name, int version) throws IOException {
+    try {
+      GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
+      return cfg.getReady(name, version);
+    } catch (ConfigInvalidException e) {
+      throw new IOException(e);
+    }
+  }
+
+  public static Set<String> accountFields(QueryOptions opts) {
+    return accountFields(opts.fields());
+  }
+
+  public static Set<String> accountFields(Set<String> fields) {
+    return fields.contains(AccountField.ID.getName())
+        ? fields
+        : Sets.union(fields, ImmutableSet.of(AccountField.ID.getName()));
+  }
+
+  public static Set<String> changeFields(QueryOptions opts) {
+    // Ensure we request enough fields to construct a ChangeData. We need both
+    // change ID and project, which can either come via the Change field or
+    // separate fields.
+    Set<String> fs = opts.fields();
+    if (fs.contains(CHANGE.getName())) {
+      // A Change is always sufficient.
+      return fs;
+    }
+    if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID.getName())) {
+      return fs;
+    }
+    return Sets.union(fs, ImmutableSet.of(LEGACY_ID.getName(), PROJECT.getName()));
+  }
+
+  public static Set<String> groupFields(QueryOptions opts) {
+    Set<String> fs = opts.fields();
+    return fs.contains(GroupField.UUID.getName())
+        ? fs
+        : Sets.union(fs, ImmutableSet.of(GroupField.UUID.getName()));
+  }
+
+  public static String describe(CurrentUser user) {
+    if (user.isIdentifiedUser()) {
+      return user.getAccountId().toString();
+    }
+    if (user instanceof SingleGroupUser) {
+      return "group:" + user.getEffectiveGroups().getKnownGroups().iterator().next().toString();
+    }
+    return user.toString();
+  }
+
+  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/OnlineReindexer.java b/java/com/google/gerrit/server/index/OnlineReindexer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineReindexer.java
rename to java/com/google/gerrit/server/index/OnlineReindexer.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineUpgradeListener.java b/java/com/google/gerrit/server/index/OnlineUpgradeListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineUpgradeListener.java
rename to java/com/google/gerrit/server/index/OnlineUpgradeListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineUpgrader.java b/java/com/google/gerrit/server/index/OnlineUpgrader.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineUpgrader.java
rename to java/com/google/gerrit/server/index/OnlineUpgrader.java
diff --git a/java/com/google/gerrit/server/index/RefState.java b/java/com/google/gerrit/server/index/RefState.java
new file mode 100644
index 0000000..6b893f0
--- /dev/null
+++ b/java/com/google/gerrit/server/index/RefState.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.index;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Project;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+@AutoValue
+public abstract class RefState {
+  public static SetMultimap<Project.NameKey, RefState> parseStates(Iterable<byte[]> states) {
+    RefState.check(states != null, null);
+    SetMultimap<Project.NameKey, RefState> result =
+        MultimapBuilder.hashKeys().hashSetValues().build();
+    for (byte[] b : states) {
+      RefState.check(b != null, null);
+      String s = new String(b, UTF_8);
+      List<String> parts = Splitter.on(':').splitToList(s);
+      RefState.check(parts.size() == 3 && !parts.get(0).isEmpty() && !parts.get(1).isEmpty(), s);
+      result.put(new Project.NameKey(parts.get(0)), RefState.create(parts.get(1), parts.get(2)));
+    }
+    return result;
+  }
+
+  public static RefState create(String ref, String sha) {
+    return new AutoValue_RefState(ref, ObjectId.fromString(sha));
+  }
+
+  public static RefState create(String ref, @Nullable ObjectId id) {
+    return new AutoValue_RefState(ref, firstNonNull(id, ObjectId.zeroId()));
+  }
+
+  public static RefState of(Ref ref) {
+    return new AutoValue_RefState(ref.getName(), ref.getObjectId());
+  }
+
+  public byte[] toByteArray(Project.NameKey project) {
+    byte[] a = (project.toString() + ':' + ref() + ':').getBytes(UTF_8);
+    byte[] b = new byte[a.length + Constants.OBJECT_ID_STRING_LENGTH];
+    System.arraycopy(a, 0, b, 0, a.length);
+    id().copyTo(b, a.length);
+    return b;
+  }
+
+  public static void check(boolean condition, String str) {
+    checkArgument(condition, "invalid RefState: %s", str);
+  }
+
+  public abstract String ref();
+
+  public abstract ObjectId id();
+
+  public boolean match(Repository repo) throws IOException {
+    Ref ref = repo.exactRef(ref());
+    ObjectId expected = ref != null ? ref.getObjectId() : ObjectId.zeroId();
+    return id().equals(expected);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ReindexerAlreadyRunningException.java b/java/com/google/gerrit/server/index/ReindexerAlreadyRunningException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/ReindexerAlreadyRunningException.java
rename to java/com/google/gerrit/server/index/ReindexerAlreadyRunningException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/SingleVersionModule.java b/java/com/google/gerrit/server/index/SingleVersionModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/SingleVersionModule.java
rename to java/com/google/gerrit/server/index/SingleVersionModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/VersionManager.java b/java/com/google/gerrit/server/index/VersionManager.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/VersionManager.java
rename to java/com/google/gerrit/server/index/VersionManager.java
diff --git a/java/com/google/gerrit/server/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
new file mode 100644
index 0000000..da8437d
--- /dev/null
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -0,0 +1,180 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import static com.google.gerrit.index.FieldDef.exact;
+import static com.google.gerrit.index.FieldDef.integer;
+import static com.google.gerrit.index.FieldDef.prefix;
+import static com.google.gerrit.index.FieldDef.storedOnly;
+import static com.google.gerrit.index.FieldDef.timestamp;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.base.Predicates;
+import com.google.common.base.Strings;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaUtil;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.index.RefState;
+import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Locale;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Secondary index schemas for accounts. */
+public class AccountField {
+  public static final FieldDef<AccountState, Integer> ID =
+      integer("id").stored().build(a -> a.getAccount().getId().get());
+
+  /**
+   * External IDs.
+   *
+   * <p>This field includes secondary emails. Use this field only if the current user is allowed to
+   * see secondary emails (requires the {@link GlobalCapability#MODIFY_ACCOUNT} capability).
+   */
+  public static final FieldDef<AccountState, Iterable<String>> EXTERNAL_ID =
+      exact("external_id")
+          .buildRepeatable(a -> Iterables.transform(a.getExternalIds(), id -> id.key().get()));
+
+  /**
+   * Fuzzy prefix match on name and email parts.
+   *
+   * <p>This field includes parts from the secondary emails. Use this field only if the current user
+   * is allowed to see secondary emails (requires the {@link GlobalCapability#MODIFY_ACCOUNT}
+   * capability).
+   *
+   * <p>Use the {@link AccountField#NAME_PART_NO_SECONDARY_EMAIL} if the current user can't see
+   * secondary emails.
+   */
+  public static final FieldDef<AccountState, Iterable<String>> NAME_PART =
+      prefix("name")
+          .buildRepeatable(
+              a -> getNameParts(a, Iterables.transform(a.getExternalIds(), ExternalId::email)));
+
+  /**
+   * Fuzzy prefix match on name and preferred email parts. Parts of secondary emails are not
+   * included.
+   */
+  public static final FieldDef<AccountState, Iterable<String>> NAME_PART_NO_SECONDARY_EMAIL =
+      prefix("name2")
+          .buildRepeatable(a -> getNameParts(a, Arrays.asList(a.getAccount().getPreferredEmail())));
+
+  public static final FieldDef<AccountState, String> FULL_NAME =
+      exact("full_name").build(a -> a.getAccount().getFullName());
+
+  public static final FieldDef<AccountState, String> ACTIVE =
+      exact("inactive").build(a -> a.getAccount().isActive() ? "1" : "0");
+
+  /**
+   * All emails (preferred email + secondary emails). Use this field only if the current user is
+   * allowed to see secondary emails (requires the 'Modify Account' capability).
+   *
+   * <p>Use the {@link AccountField#PREFERRED_EMAIL} if the current user can't see secondary emails.
+   */
+  public static final FieldDef<AccountState, Iterable<String>> EMAIL =
+      prefix("email")
+          .buildRepeatable(
+              a ->
+                  FluentIterable.from(a.getExternalIds())
+                      .transform(ExternalId::email)
+                      .append(Collections.singleton(a.getAccount().getPreferredEmail()))
+                      .filter(Predicates.notNull())
+                      .transform(String::toLowerCase)
+                      .toSet());
+
+  public static final FieldDef<AccountState, String> PREFERRED_EMAIL =
+      prefix("preferredemail")
+          .build(
+              a -> {
+                String preferredEmail = a.getAccount().getPreferredEmail();
+                return preferredEmail != null ? preferredEmail.toLowerCase() : null;
+              });
+
+  public static final FieldDef<AccountState, String> PREFERRED_EMAIL_EXACT =
+      exact("preferredemail_exact").build(a -> a.getAccount().getPreferredEmail());
+
+  public static final FieldDef<AccountState, Timestamp> REGISTERED =
+      timestamp("registered").build(a -> a.getAccount().getRegisteredOn());
+
+  public static final FieldDef<AccountState, String> USERNAME =
+      exact("username").build(a -> Strings.nullToEmpty(a.getUserName()).toLowerCase());
+
+  public static final FieldDef<AccountState, Iterable<String>> WATCHED_PROJECT =
+      exact("watchedproject")
+          .buildRepeatable(
+              a ->
+                  FluentIterable.from(a.getProjectWatches().keySet())
+                      .transform(k -> k.project().get())
+                      .toSet());
+
+  /**
+   * All values of all refs that were used in the course of indexing this document, except the
+   * refs/meta/external-ids notes branch which is handled specially (see {@link
+   * #EXTERNAL_ID_STATE}).
+   *
+   * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
+   */
+  public static final FieldDef<AccountState, Iterable<byte[]>> REF_STATE =
+      storedOnly("ref_state")
+          .buildRepeatable(
+              a -> {
+                if (a.getAccount().getMetaId() == null) {
+                  return ImmutableList.of();
+                }
+
+                return ImmutableList.of(
+                    RefState.create(
+                            RefNames.refsUsers(a.getAccount().getId()),
+                            ObjectId.fromString(a.getAccount().getMetaId()))
+                        .toByteArray(a.getAllUsersNameForIndexing()));
+              });
+
+  /**
+   * All note values of all external IDs that were used in the course of indexing this document.
+   *
+   * <p>Emitted as UTF-8 encoded strings of the form {@code [hex sha of external ID]:[hex sha of
+   * note blob]}, or with other words {@code [note ID]:[note data ID]}.
+   */
+  public static final FieldDef<AccountState, Iterable<byte[]>> EXTERNAL_ID_STATE =
+      storedOnly("external_id_state")
+          .buildRepeatable(
+              a ->
+                  a.getExternalIds()
+                      .stream()
+                      .filter(e -> e.blobId() != null)
+                      .map(e -> e.toByteArray())
+                      .collect(toSet()));
+
+  private static final Set<String> getNameParts(AccountState a, Iterable<String> emails) {
+    String fullName = a.getAccount().getFullName();
+    Set<String> parts = SchemaUtil.getNameParts(fullName, emails);
+
+    // Additional values not currently added by getPersonParts.
+    // TODO(dborowitz): Move to getPersonParts and remove this hack.
+    if (fullName != null) {
+      parts.add(fullName.toLowerCase(Locale.US));
+    }
+    return parts;
+  }
+
+  private AccountField() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java b/java/com/google/gerrit/server/index/account/AccountIndex.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java
rename to java/com/google/gerrit/server/index/account/AccountIndex.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java b/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
rename to java/com/google/gerrit/server/index/account/AccountIndexCollection.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java b/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
rename to java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
rename to java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexer.java b/java/com/google/gerrit/server/index/account/AccountIndexer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexer.java
rename to java/com/google/gerrit/server/index/account/AccountIndexer.java
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
new file mode 100644
index 0000000..de6aa55
--- /dev/null
+++ b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.events.AccountIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.Config;
+
+public class AccountIndexerImpl implements AccountIndexer {
+  public interface Factory {
+    AccountIndexerImpl create(AccountIndexCollection indexes);
+
+    AccountIndexerImpl create(@Nullable AccountIndex index);
+  }
+
+  private final AccountCache byIdCache;
+  private final DynamicSet<AccountIndexedListener> indexedListener;
+  private final StalenessChecker stalenessChecker;
+  private final ListeningExecutorService batchExecutor;
+  private final boolean autoReindexIfStale;
+  @Nullable private final AccountIndexCollection indexes;
+  @Nullable private final AccountIndex index;
+
+  @AssistedInject
+  AccountIndexerImpl(
+      AccountCache byIdCache,
+      DynamicSet<AccountIndexedListener> indexedListener,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
+      @GerritServerConfig Config config,
+      @Assisted AccountIndexCollection indexes) {
+    this.byIdCache = byIdCache;
+    this.indexedListener = indexedListener;
+    this.stalenessChecker = stalenessChecker;
+    this.batchExecutor = batchExecutor;
+    this.autoReindexIfStale = autoReindexIfStale(config);
+    this.indexes = indexes;
+    this.index = null;
+  }
+
+  @AssistedInject
+  AccountIndexerImpl(
+      AccountCache byIdCache,
+      DynamicSet<AccountIndexedListener> indexedListener,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
+      @GerritServerConfig Config config,
+      @Assisted @Nullable AccountIndex index) {
+    this.byIdCache = byIdCache;
+    this.indexedListener = indexedListener;
+    this.stalenessChecker = stalenessChecker;
+    this.batchExecutor = batchExecutor;
+    this.autoReindexIfStale = autoReindexIfStale(config);
+    this.indexes = null;
+    this.index = index;
+  }
+
+  @Override
+  public void index(Account.Id id) throws IOException {
+    for (Index<Account.Id, AccountState> i : getWriteIndexes()) {
+      AccountState accountState = byIdCache.getOrNull(id);
+      if (accountState != null) {
+        i.replace(accountState);
+      } else {
+        i.delete(id);
+      }
+    }
+    fireAccountIndexedEvent(id.get());
+    autoReindexIfStale(id);
+  }
+
+  private static boolean autoReindexIfStale(Config cfg) {
+    return cfg.getBoolean("index", null, "autoReindexIfStale", true);
+  }
+
+  private void autoReindexIfStale(Account.Id id) {
+    if (autoReindexIfStale) {
+      // Don't retry indefinitely; if this fails the account will be stale.
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError = reindexIfStale(id);
+    }
+  }
+
+  /**
+   * Asynchronously check if a account is stale, and reindex if it is.
+   *
+   * <p>Always run on the batch executor, even if this indexer instance is configured to use a
+   * different executor.
+   *
+   * @param id the ID of the account.
+   * @return future for reindexing the account; returns true if the account was stale.
+   */
+  @SuppressWarnings("deprecation")
+  public com.google.common.util.concurrent.CheckedFuture<Boolean, IOException> reindexIfStale(
+      Account.Id id) {
+    Callable<Boolean> task =
+        () -> {
+          if (stalenessChecker.isStale(id)) {
+            index(id);
+            return true;
+          }
+          return false;
+        };
+
+    return Futures.makeChecked(
+        Futures.nonCancellationPropagating(batchExecutor.submit(task)), IndexUtils.MAPPER);
+  }
+
+  private void fireAccountIndexedEvent(int id) {
+    for (AccountIndexedListener listener : indexedListener) {
+      listener.onAccountIndexed(id);
+    }
+  }
+
+  private Collection<AccountIndex> getWriteIndexes() {
+    if (indexes != null) {
+      return indexes.getWriteIndexes();
+    }
+
+    return index != null ? Collections.singleton(index) : ImmutableSet.<AccountIndex>of();
+  }
+}
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
new file mode 100644
index 0000000..3e702f2
--- /dev/null
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import static com.google.gerrit.index.SchemaUtil.schema;
+
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaDefinitions;
+import com.google.gerrit.server.account.AccountState;
+
+public class AccountSchemaDefinitions extends SchemaDefinitions<AccountState> {
+  @Deprecated
+  static final Schema<AccountState> V4 =
+      schema(
+          AccountField.ACTIVE,
+          AccountField.EMAIL,
+          AccountField.EXTERNAL_ID,
+          AccountField.FULL_NAME,
+          AccountField.ID,
+          AccountField.NAME_PART,
+          AccountField.REGISTERED,
+          AccountField.USERNAME,
+          AccountField.WATCHED_PROJECT);
+
+  @Deprecated static final Schema<AccountState> V5 = schema(V4, AccountField.PREFERRED_EMAIL);
+
+  @Deprecated
+  static final Schema<AccountState> V6 =
+      schema(V5, AccountField.REF_STATE, AccountField.EXTERNAL_ID_STATE);
+
+  @Deprecated static final Schema<AccountState> V7 = schema(V6, AccountField.PREFERRED_EMAIL_EXACT);
+
+  static final Schema<AccountState> V8 = schema(V7, AccountField.NAME_PART_NO_SECONDARY_EMAIL);
+
+  public static final String NAME = "accounts";
+  public static final AccountSchemaDefinitions INSTANCE = new AccountSchemaDefinitions();
+
+  private AccountSchemaDefinitions() {
+    super(NAME, AccountState.class);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
rename to java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java b/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
rename to java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
diff --git a/java/com/google/gerrit/server/index/account/StalenessChecker.java b/java/com/google/gerrit/server/index/account/StalenessChecker.java
new file mode 100644
index 0000000..6403d3d
--- /dev/null
+++ b/java/com/google/gerrit/server/index/account/StalenessChecker.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.RefState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+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.lib.Repository;
+
+/**
+ * Checks if documents in the account index are stale.
+ *
+ * <p>An index document is considered stale if the stored ref state differs from the SHA1 of the
+ * user branch or if the stored external ID states don't match with the external IDs of the account
+ * from the refs/meta/external-ids branch.
+ */
+@Singleton
+public class StalenessChecker {
+  public static final ImmutableSet<String> FIELDS =
+      ImmutableSet.of(
+          AccountField.ID.getName(),
+          AccountField.REF_STATE.getName(),
+          AccountField.EXTERNAL_ID_STATE.getName());
+
+  private final AccountIndexCollection indexes;
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final ExternalIds externalIds;
+  private final IndexConfig indexConfig;
+
+  @Inject
+  StalenessChecker(
+      AccountIndexCollection indexes,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      ExternalIds externalIds,
+      IndexConfig indexConfig) {
+    this.indexes = indexes;
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.externalIds = externalIds;
+    this.indexConfig = indexConfig;
+  }
+
+  public boolean isStale(Account.Id id) throws IOException {
+    AccountIndex i = indexes.getSearchIndex();
+    if (i == null) {
+      // No index; caller couldn't do anything if it is stale.
+      return false;
+    }
+    if (!i.getSchema().hasField(AccountField.REF_STATE)
+        || !i.getSchema().hasField(AccountField.EXTERNAL_ID_STATE)) {
+      // Index version not new enough for this check.
+      return false;
+    }
+
+    Optional<FieldBundle> result =
+        i.getRaw(id, QueryOptions.create(indexConfig, 0, 1, IndexUtils.accountFields(FIELDS)));
+    if (!result.isPresent()) {
+      // The document is missing in the index.
+      try (Repository repo = repoManager.openRepository(allUsersName)) {
+        Ref ref = repo.exactRef(RefNames.refsUsers(id));
+
+        // Stale if the account actually exists.
+        return ref != null;
+      }
+    }
+
+    for (Map.Entry<Project.NameKey, RefState> e :
+        RefState.parseStates(result.get().getValue(AccountField.REF_STATE)).entries()) {
+      try (Repository repo = repoManager.openRepository(e.getKey())) {
+        if (!e.getValue().match(repo)) {
+          // Ref was modified since the account was indexed.
+          return true;
+        }
+      }
+    }
+
+    Set<ExternalId> extIds = externalIds.byAccount(id);
+    ListMultimap<ObjectId, ObjectId> extIdStates =
+        parseExternalIdStates(result.get().getValue(AccountField.EXTERNAL_ID_STATE));
+    if (extIdStates.size() != extIds.size()) {
+      // External IDs of the account were modified since the account was indexed.
+      return true;
+    }
+    for (ExternalId extId : extIds) {
+      if (!extIdStates.containsEntry(extId.key().sha1(), extId.blobId())) {
+        // External IDs of the account were modified since the account was indexed.
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  public static ListMultimap<ObjectId, ObjectId> parseExternalIdStates(
+      Iterable<byte[]> extIdStates) {
+    ListMultimap<ObjectId, ObjectId> result = MultimapBuilder.hashKeys().arrayListValues().build();
+
+    if (extIdStates == null) {
+      return result;
+    }
+
+    for (byte[] b : extIdStates) {
+      checkNotNull(b, "invalid external ID state");
+      String s = new String(b, UTF_8);
+      List<String> parts = Splitter.on(':').splitToList(s);
+      checkState(parts.size() == 2, "invalid external ID state: %s", s);
+      result.put(ObjectId.fromString(parts.get(0)), ObjectId.fromString(parts.get(1)));
+    }
+    return result;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
rename to java/com/google/gerrit/server/index/change/AllChangesIndexer.java
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
new file mode 100644
index 0000000..2444735
--- /dev/null
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -0,0 +1,786 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.index.FieldDef.exact;
+import static com.google.gerrit.index.FieldDef.fullText;
+import static com.google.gerrit.index.FieldDef.intRange;
+import static com.google.gerrit.index.FieldDef.integer;
+import static com.google.gerrit.index.FieldDef.prefix;
+import static com.google.gerrit.index.FieldDef.storedOnly;
+import static com.google.gerrit.index.FieldDef.timestamp;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Table;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.ReviewerByEmailSet;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.index.RefState;
+import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.notedb.RobotCommentNotes;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gson.Gson;
+import com.google.gwtorm.protobuf.CodecFactory;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import com.google.gwtorm.server.OrmException;
+import com.google.protobuf.CodedOutputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/**
+ * Fields indexed on change documents.
+ *
+ * <p>Each field corresponds to both a field name supported by {@link ChangeQueryBuilder} for
+ * querying that field, and a method on {@link ChangeData} used for populating the corresponding
+ * document fields in the secondary index.
+ *
+ * <p>Field names are all lowercase alphanumeric plus underscore; index implementations may create
+ * unambiguous derived field names containing other characters.
+ */
+public class ChangeField {
+  public static final int NO_ASSIGNEE = -1;
+
+  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
+
+  /** Legacy change ID. */
+  public static final FieldDef<ChangeData, Integer> LEGACY_ID =
+      integer("legacy_id").stored().build(cd -> cd.getId().get());
+
+  /** Newer style Change-Id key. */
+  public static final FieldDef<ChangeData, String> ID =
+      prefix(ChangeQueryBuilder.FIELD_CHANGE_ID).build(changeGetter(c -> c.getKey().get()));
+
+  /** Change status string, in the same format as {@code status:}. */
+  public static final FieldDef<ChangeData, String> STATUS =
+      exact(ChangeQueryBuilder.FIELD_STATUS)
+          .build(changeGetter(c -> ChangeStatusPredicate.canonicalize(c.getStatus())));
+
+  /** Project containing the change. */
+  public static final FieldDef<ChangeData, String> PROJECT =
+      exact(ChangeQueryBuilder.FIELD_PROJECT)
+          .stored()
+          .build(changeGetter(c -> c.getProject().get()));
+
+  /** Project containing the change, as a prefix field. */
+  public static final FieldDef<ChangeData, String> PROJECTS =
+      prefix(ChangeQueryBuilder.FIELD_PROJECTS).build(changeGetter(c -> c.getProject().get()));
+
+  /** Reference (aka branch) the change will submit onto. */
+  public static final FieldDef<ChangeData, String> REF =
+      exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().get()));
+
+  /** Topic, a short annotation on the branch. */
+  public static final FieldDef<ChangeData, String> EXACT_TOPIC =
+      exact("topic4").build(ChangeField::getTopic);
+
+  /** Topic, a short annotation on the branch. */
+  public static final FieldDef<ChangeData, String> FUZZY_TOPIC =
+      fullText("topic5").build(ChangeField::getTopic);
+
+  /** Submission id assigned by MergeOp. */
+  public static final FieldDef<ChangeData, String> SUBMISSIONID =
+      exact(ChangeQueryBuilder.FIELD_SUBMISSIONID).build(changeGetter(Change::getSubmissionId));
+
+  /** Last update time since January 1, 1970. */
+  public static final FieldDef<ChangeData, Timestamp> UPDATED =
+      timestamp("updated2").stored().build(changeGetter(Change::getLastUpdatedOn));
+
+  /** List of full file paths modified in the current patch set. */
+  public static final FieldDef<ChangeData, Iterable<String>> PATH =
+      // Named for backwards compatibility.
+      exact(ChangeQueryBuilder.FIELD_FILE)
+          .buildRepeatable(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
+
+  public static Set<String> getFileParts(ChangeData cd) throws OrmException {
+    List<String> paths;
+    try {
+      paths = cd.currentFilePaths();
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+
+    Splitter s = Splitter.on('/').omitEmptyStrings();
+    Set<String> r = new HashSet<>();
+    for (String path : paths) {
+      for (String part : s.split(path)) {
+        r.add(part);
+      }
+    }
+    return r;
+  }
+
+  /** Hashtags tied to a change */
+  public static final FieldDef<ChangeData, Iterable<String>> HASHTAG =
+      exact(ChangeQueryBuilder.FIELD_HASHTAG)
+          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+
+  /** Hashtags with original case. */
+  public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
+      storedOnly("_hashtag")
+          .buildRepeatable(
+              cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()));
+
+  /** Components of each file path modified in the current patch set. */
+  public static final FieldDef<ChangeData, Iterable<String>> FILE_PART =
+      exact(ChangeQueryBuilder.FIELD_FILEPART).buildRepeatable(ChangeField::getFileParts);
+
+  /** Owner/creator of the change. */
+  public static final FieldDef<ChangeData, Integer> OWNER =
+      integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get()));
+
+  /** The user assigned to the change. */
+  public static final FieldDef<ChangeData, Integer> ASSIGNEE =
+      integer(ChangeQueryBuilder.FIELD_ASSIGNEE)
+          .build(changeGetter(c -> c.getAssignee() != null ? c.getAssignee().get() : NO_ASSIGNEE));
+
+  /** Reviewer(s) associated with the change. */
+  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
+      exact("reviewer2").stored().buildRepeatable(cd -> getReviewerFieldValues(cd.reviewers()));
+
+  /** Reviewer(s) associated with the change that do not have a gerrit account. */
+  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL =
+      exact("reviewer_by_email")
+          .stored()
+          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()));
+
+  /** Reviewer(s) modified during change's current WIP phase. */
+  public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER =
+      exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER)
+          .stored()
+          .buildRepeatable(cd -> getReviewerFieldValues(cd.pendingReviewers()));
+
+  /** Reviewer(s) by email modified during change's current WIP phase. */
+  public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL =
+      exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL)
+          .stored()
+          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()));
+
+  /** References a change that this change reverts. */
+  public static final FieldDef<ChangeData, Integer> REVERT_OF =
+      integer(ChangeQueryBuilder.FIELD_REVERTOF)
+          .build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null);
+
+  @VisibleForTesting
+  static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
+    List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
+    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> c :
+        reviewers.asTable().cellSet()) {
+      String v = getReviewerFieldValue(c.getRowKey(), c.getColumnKey());
+      r.add(v);
+      r.add(v + ',' + c.getValue().getTime());
+    }
+    return r;
+  }
+
+  public static String getReviewerFieldValue(ReviewerStateInternal state, Account.Id id) {
+    return state.toString() + ',' + id;
+  }
+
+  @VisibleForTesting
+  static List<String> getReviewerByEmailFieldValues(ReviewerByEmailSet reviewersByEmail) {
+    List<String> r = new ArrayList<>(reviewersByEmail.asTable().size() * 2);
+    for (Table.Cell<ReviewerStateInternal, Address, Timestamp> c :
+        reviewersByEmail.asTable().cellSet()) {
+      String v = getReviewerByEmailFieldValue(c.getRowKey(), c.getColumnKey());
+      r.add(v);
+      if (c.getColumnKey().getName() != null) {
+        // Add another entry without the name to provide search functionality on the email
+        Address emailOnly = new Address(c.getColumnKey().getEmail());
+        r.add(getReviewerByEmailFieldValue(c.getRowKey(), emailOnly));
+      }
+      r.add(v + ',' + c.getValue().getTime());
+    }
+    return r;
+  }
+
+  public static String getReviewerByEmailFieldValue(ReviewerStateInternal state, Address adr) {
+    return state.toString() + ',' + adr;
+  }
+
+  public static ReviewerSet parseReviewerFieldValues(Iterable<String> values) {
+    ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
+        ImmutableTable.builder();
+    for (String v : values) {
+      int f = v.indexOf(',');
+      if (f < 0) {
+        continue;
+      }
+      int l = v.lastIndexOf(',');
+      if (l == f) {
+        continue;
+      }
+      b.put(
+          ReviewerStateInternal.valueOf(v.substring(0, f)),
+          Account.Id.parse(v.substring(f + 1, l)),
+          new Timestamp(Long.valueOf(v.substring(l + 1, v.length()))));
+    }
+    return ReviewerSet.fromTable(b.build());
+  }
+
+  public static ReviewerByEmailSet parseReviewerByEmailFieldValues(Iterable<String> values) {
+    ImmutableTable.Builder<ReviewerStateInternal, Address, Timestamp> b = ImmutableTable.builder();
+    for (String v : values) {
+      int f = v.indexOf(',');
+      if (f < 0) {
+        continue;
+      }
+      int l = v.lastIndexOf(',');
+      if (l == f) {
+        continue;
+      }
+      b.put(
+          ReviewerStateInternal.valueOf(v.substring(0, f)),
+          Address.parse(v.substring(f + 1, l)),
+          new Timestamp(Long.valueOf(v.substring(l + 1, v.length()))));
+    }
+    return ReviewerByEmailSet.fromTable(b.build());
+  }
+
+  /** Commit ID of any patch set on the change, using prefix match. */
+  public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
+      prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions);
+
+  /** Commit ID of any patch set on the change, using exact match. */
+  public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMIT =
+      exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT).buildRepeatable(ChangeField::getRevisions);
+
+  private static Set<String> getRevisions(ChangeData cd) throws OrmException {
+    Set<String> revisions = new HashSet<>();
+    for (PatchSet ps : cd.patchSets()) {
+      if (ps.getRevision() != null) {
+        revisions.add(ps.getRevision().get());
+      }
+    }
+    return revisions;
+  }
+
+  /** Tracking id extracted from a footer. */
+  public static final FieldDef<ChangeData, Iterable<String>> TR =
+      exact(ChangeQueryBuilder.FIELD_TR)
+          .buildRepeatable(cd -> ImmutableSet.copyOf(cd.trackingFooters().values()));
+
+  /** List of labels on the current patch set including change owner votes. */
+  public static final FieldDef<ChangeData, Iterable<String>> LABEL =
+      exact("label2").buildRepeatable(cd -> getLabels(cd, true));
+
+  private static Iterable<String> getLabels(ChangeData cd, boolean owners) throws OrmException {
+    Set<String> allApprovals = new HashSet<>();
+    Set<String> distinctApprovals = new HashSet<>();
+    for (PatchSetApproval a : cd.currentApprovals()) {
+      if (a.getValue() != 0 && !a.isLegacySubmit()) {
+        allApprovals.add(formatLabel(a.getLabel(), a.getValue(), a.getAccountId()));
+        if (owners && cd.change().getOwner().equals(a.getAccountId())) {
+          allApprovals.add(
+              formatLabel(a.getLabel(), a.getValue(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+        }
+        distinctApprovals.add(formatLabel(a.getLabel(), a.getValue()));
+      }
+    }
+    allApprovals.addAll(distinctApprovals);
+    return allApprovals;
+  }
+
+  public static Set<String> getAuthorParts(ChangeData cd) throws OrmException, IOException {
+    return SchemaUtil.getPersonParts(cd.getAuthor());
+  }
+
+  public static Set<String> getAuthorNameAndEmail(ChangeData cd) throws OrmException, IOException {
+    return getNameAndEmail(cd.getAuthor());
+  }
+
+  public static Set<String> getCommitterParts(ChangeData cd) throws OrmException, IOException {
+    return SchemaUtil.getPersonParts(cd.getCommitter());
+  }
+
+  public static Set<String> getCommitterNameAndEmail(ChangeData cd)
+      throws OrmException, IOException {
+    return getNameAndEmail(cd.getCommitter());
+  }
+
+  private static Set<String> getNameAndEmail(PersonIdent person) {
+    if (person == null) {
+      return ImmutableSet.of();
+    }
+
+    String name = person.getName().toLowerCase(Locale.US);
+    String email = person.getEmailAddress().toLowerCase(Locale.US);
+
+    StringBuilder nameEmailBuilder = new StringBuilder();
+    PersonIdent.appendSanitized(nameEmailBuilder, name);
+    nameEmailBuilder.append(" <");
+    PersonIdent.appendSanitized(nameEmailBuilder, email);
+    nameEmailBuilder.append('>');
+
+    return ImmutableSet.of(name, email, nameEmailBuilder.toString());
+  }
+
+  /**
+   * The exact email address, or any part of the author name or email address, in the current patch
+   * set.
+   */
+  public static final FieldDef<ChangeData, Iterable<String>> AUTHOR =
+      fullText(ChangeQueryBuilder.FIELD_AUTHOR).buildRepeatable(ChangeField::getAuthorParts);
+
+  /** The exact name, email address and NameEmail of the author. */
+  public static final FieldDef<ChangeData, Iterable<String>> EXACT_AUTHOR =
+      exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR)
+          .buildRepeatable(ChangeField::getAuthorNameAndEmail);
+
+  /**
+   * The exact email address, or any part of the committer name or email address, in the current
+   * patch set.
+   */
+  public static final FieldDef<ChangeData, Iterable<String>> COMMITTER =
+      fullText(ChangeQueryBuilder.FIELD_COMMITTER).buildRepeatable(ChangeField::getCommitterParts);
+
+  /** The exact name, email address, and NameEmail of the committer. */
+  public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMITTER =
+      exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER)
+          .buildRepeatable(ChangeField::getCommitterNameAndEmail);
+
+  public static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
+
+  /** Serialized change object, used for pre-populating results. */
+  public static final FieldDef<ChangeData, byte[]> CHANGE =
+      storedOnly("_change").build(changeGetter(CHANGE_CODEC::encodeToByteArray));
+
+  public static final ProtobufCodec<PatchSetApproval> APPROVAL_CODEC =
+      CodecFactory.encoder(PatchSetApproval.class);
+
+  /** Serialized approvals for the current patch set, used for pre-populating results. */
+  public static final FieldDef<ChangeData, Iterable<byte[]>> APPROVAL =
+      storedOnly("_approval")
+          .buildRepeatable(cd -> toProtos(APPROVAL_CODEC, cd.currentApprovals()));
+
+  public static String formatLabel(String label, int value) {
+    return formatLabel(label, value, null);
+  }
+
+  public static String formatLabel(String label, int value, Account.Id accountId) {
+    return label.toLowerCase()
+        + (value >= 0 ? "+" : "")
+        + value
+        + (accountId != null ? "," + formatAccount(accountId) : "");
+  }
+
+  private static String formatAccount(Account.Id accountId) {
+    if (ChangeQueryBuilder.OWNER_ACCOUNT_ID.equals(accountId)) {
+      return ChangeQueryBuilder.ARG_ID_OWNER;
+    }
+    return Integer.toString(accountId.get());
+  }
+
+  /** Commit message of the current patch set. */
+  public static final FieldDef<ChangeData, String> COMMIT_MESSAGE =
+      fullText(ChangeQueryBuilder.FIELD_MESSAGE).build(ChangeData::commitMessage);
+
+  /** Summary or inline comment. */
+  public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
+      fullText(ChangeQueryBuilder.FIELD_COMMENT)
+          .buildRepeatable(
+              cd ->
+                  Stream.concat(
+                          cd.publishedComments().stream().map(c -> c.message),
+                          cd.messages().stream().map(ChangeMessage::getMessage))
+                      .collect(toSet()));
+
+  /** Number of unresolved comments of the change. */
+  public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT =
+      intRange(ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT)
+          .stored()
+          .build(ChangeData::unresolvedCommentCount);
+
+  /** Whether the change is mergeable. */
+  public static final FieldDef<ChangeData, String> MERGEABLE =
+      exact(ChangeQueryBuilder.FIELD_MERGEABLE)
+          .stored()
+          .build(
+              cd -> {
+                Boolean m = cd.isMergeable();
+                if (m == null) {
+                  return null;
+                }
+                return m ? "1" : "0";
+              });
+
+  /** The number of inserted lines in this change. */
+  public static final FieldDef<ChangeData, Integer> ADDED =
+      intRange(ChangeQueryBuilder.FIELD_ADDED)
+          .stored()
+          .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null);
+
+  /** The number of deleted lines in this change. */
+  public static final FieldDef<ChangeData, Integer> DELETED =
+      intRange(ChangeQueryBuilder.FIELD_DELETED)
+          .stored()
+          .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null);
+
+  /** The total number of modified lines in this change. */
+  public static final FieldDef<ChangeData, Integer> DELTA =
+      intRange(ChangeQueryBuilder.FIELD_DELTA)
+          .build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null));
+
+  /** Determines if this change is private. */
+  public static final FieldDef<ChangeData, String> PRIVATE =
+      exact(ChangeQueryBuilder.FIELD_PRIVATE).build(cd -> cd.change().isPrivate() ? "1" : "0");
+
+  /** Determines if this change is work in progress. */
+  public static final FieldDef<ChangeData, String> WIP =
+      exact(ChangeQueryBuilder.FIELD_WIP).build(cd -> cd.change().isWorkInProgress() ? "1" : "0");
+
+  /** Determines if this change has started review. */
+  public static final FieldDef<ChangeData, String> STARTED =
+      exact(ChangeQueryBuilder.FIELD_STARTED)
+          .build(cd -> cd.change().hasReviewStarted() ? "1" : "0");
+
+  /** Users who have commented on this change. */
+  public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY =
+      integer(ChangeQueryBuilder.FIELD_COMMENTBY)
+          .buildRepeatable(
+              cd ->
+                  Stream.concat(
+                          cd.messages().stream().map(ChangeMessage::getAuthor),
+                          cd.publishedComments().stream().map(c -> c.author.getId()))
+                      .filter(Objects::nonNull)
+                      .map(Account.Id::get)
+                      .collect(toSet()));
+
+  /** Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt; */
+  public static final FieldDef<ChangeData, Iterable<String>> STAR =
+      exact(ChangeQueryBuilder.FIELD_STAR)
+          .stored()
+          .buildRepeatable(
+              cd ->
+                  Iterables.transform(
+                      cd.stars().entries(),
+                      e ->
+                          StarredChangesUtil.StarField.create(e.getKey(), e.getValue())
+                              .toString()));
+
+  /** Users that have starred the change with any label. */
+  public static final FieldDef<ChangeData, Iterable<Integer>> STARBY =
+      integer(ChangeQueryBuilder.FIELD_STARBY)
+          .buildRepeatable(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get));
+
+  /** Opaque group identifiers for this change's patch sets. */
+  public static final FieldDef<ChangeData, Iterable<String>> GROUP =
+      exact(ChangeQueryBuilder.FIELD_GROUP)
+          .buildRepeatable(
+              cd ->
+                  cd.patchSets().stream().flatMap(ps -> ps.getGroups().stream()).collect(toSet()));
+
+  public static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
+      CodecFactory.encoder(PatchSet.class);
+
+  /** Serialized patch set object, used for pre-populating results. */
+  public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
+      storedOnly("_patch_set").buildRepeatable(cd -> toProtos(PATCH_SET_CODEC, cd.patchSets()));
+
+  /** Users who have edits on this change. */
+  public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
+      integer(ChangeQueryBuilder.FIELD_EDITBY)
+          .buildRepeatable(cd -> cd.editsByUser().stream().map(Account.Id::get).collect(toSet()));
+
+  /** Users who have draft comments on this change. */
+  public static final FieldDef<ChangeData, Iterable<Integer>> DRAFTBY =
+      integer(ChangeQueryBuilder.FIELD_DRAFTBY)
+          .buildRepeatable(cd -> cd.draftsByUser().stream().map(Account.Id::get).collect(toSet()));
+
+  public static final Integer NOT_REVIEWED = -1;
+
+  /**
+   * Users the change was reviewed by since the last author update.
+   *
+   * <p>A change is considered reviewed by a user if the latest update by that user is newer than
+   * the latest update by the change author. Both top-level change messages and new patch sets are
+   * considered to be updates.
+   *
+   * <p>If the latest update is by the change owner, then the special value {@link #NOT_REVIEWED} is
+   * emitted.
+   */
+  public static final FieldDef<ChangeData, Iterable<Integer>> REVIEWEDBY =
+      integer(ChangeQueryBuilder.FIELD_REVIEWEDBY)
+          .stored()
+          .buildRepeatable(
+              cd -> {
+                Set<Account.Id> reviewedBy = cd.reviewedBy();
+                if (reviewedBy.isEmpty()) {
+                  return ImmutableSet.of(NOT_REVIEWED);
+                }
+                return reviewedBy.stream().map(Account.Id::get).collect(toList());
+              });
+
+  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
+      SubmitRuleOptions.builder().allowClosed(true).build();
+
+  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
+      SubmitRuleOptions.builder().build();
+
+  /**
+   * JSON type for storing SubmitRecords.
+   *
+   * <p>Stored fields need to use a stable format over a long period; this type insulates the index
+   * from implementation changes in SubmitRecord itself.
+   */
+  static class StoredSubmitRecord {
+    static class StoredLabel {
+      String label;
+      SubmitRecord.Label.Status status;
+      Integer appliedBy;
+    }
+
+    SubmitRecord.Status status;
+    List<StoredLabel> labels;
+    String errorMessage;
+
+    StoredSubmitRecord(SubmitRecord rec) {
+      this.status = rec.status;
+      this.errorMessage = rec.errorMessage;
+      if (rec.labels != null) {
+        this.labels = new ArrayList<>(rec.labels.size());
+        for (SubmitRecord.Label label : rec.labels) {
+          StoredLabel sl = new StoredLabel();
+          sl.label = label.label;
+          sl.status = label.status;
+          sl.appliedBy = label.appliedBy != null ? label.appliedBy.get() : null;
+          this.labels.add(sl);
+        }
+      }
+    }
+
+    private SubmitRecord toSubmitRecord() {
+      SubmitRecord rec = new SubmitRecord();
+      rec.status = status;
+      rec.errorMessage = errorMessage;
+      if (labels != null) {
+        rec.labels = new ArrayList<>(labels.size());
+        for (StoredLabel label : labels) {
+          SubmitRecord.Label srl = new SubmitRecord.Label();
+          srl.label = label.label;
+          srl.status = label.status;
+          srl.appliedBy = label.appliedBy != null ? new Account.Id(label.appliedBy) : null;
+          rec.labels.add(srl);
+        }
+      }
+      return rec;
+    }
+  }
+
+  public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RECORD =
+      exact("submit_record").buildRepeatable(cd -> formatSubmitRecordValues(cd));
+
+  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT =
+      storedOnly("full_submit_record_strict")
+          .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT));
+
+  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_LENIENT =
+      storedOnly("full_submit_record_lenient")
+          .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT));
+
+  public static void parseSubmitRecords(
+      Collection<String> values, SubmitRuleOptions opts, ChangeData out) {
+    List<SubmitRecord> records = parseSubmitRecords(values);
+    if (records.isEmpty()) {
+      // Assume no values means the field is not in the index;
+      // SubmitRuleEvaluator ensures the list is non-empty.
+      return;
+    }
+    out.setSubmitRecords(opts, records);
+  }
+
+  @VisibleForTesting
+  static List<SubmitRecord> parseSubmitRecords(Collection<String> values) {
+    return values
+        .stream()
+        .map(v -> GSON.fromJson(v, StoredSubmitRecord.class).toSubmitRecord())
+        .collect(toList());
+  }
+
+  @VisibleForTesting
+  static List<byte[]> storedSubmitRecords(List<SubmitRecord> records) {
+    return Lists.transform(records, r -> GSON.toJson(new StoredSubmitRecord(r)).getBytes(UTF_8));
+  }
+
+  private static Iterable<byte[]> storedSubmitRecords(ChangeData cd, SubmitRuleOptions opts)
+      throws OrmException {
+    return storedSubmitRecords(cd.submitRecords(opts));
+  }
+
+  public static List<String> formatSubmitRecordValues(ChangeData cd) throws OrmException {
+    return formatSubmitRecordValues(
+        cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT), cd.change().getOwner());
+  }
+
+  @VisibleForTesting
+  static List<String> formatSubmitRecordValues(List<SubmitRecord> records, Account.Id changeOwner) {
+    List<String> result = new ArrayList<>();
+    for (SubmitRecord rec : records) {
+      result.add(rec.status.name());
+      if (rec.labels == null) {
+        continue;
+      }
+      for (SubmitRecord.Label label : rec.labels) {
+        String sl = label.status.toString() + ',' + label.label.toLowerCase();
+        result.add(sl);
+        String slc = sl + ',';
+        if (label.appliedBy != null) {
+          result.add(slc + label.appliedBy.get());
+          if (label.appliedBy.equals(changeOwner)) {
+            result.add(slc + ChangeQueryBuilder.OWNER_ACCOUNT_ID.get());
+          }
+        }
+      }
+    }
+    return result;
+  }
+
+  /**
+   * All values of all refs that were used in the course of indexing this document.
+   *
+   * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
+   */
+  public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE =
+      storedOnly("ref_state")
+          .buildRepeatable(
+              cd -> {
+                List<byte[]> result = new ArrayList<>();
+                Project.NameKey project = cd.change().getProject();
+
+                cd.editRefs()
+                    .values()
+                    .forEach(r -> result.add(RefState.of(r).toByteArray(project)));
+                cd.starRefs()
+                    .values()
+                    .forEach(r -> result.add(RefState.of(r.ref()).toByteArray(allUsers(cd))));
+
+                if (PrimaryStorage.of(cd.change()) == PrimaryStorage.NOTE_DB) {
+                  ChangeNotes notes = cd.notes();
+                  result.add(
+                      RefState.create(notes.getRefName(), notes.getMetaId()).toByteArray(project));
+                  notes.getRobotComments(); // Force loading robot comments.
+                  RobotCommentNotes robotNotes = notes.getRobotCommentNotes();
+                  result.add(
+                      RefState.create(robotNotes.getRefName(), robotNotes.getMetaId())
+                          .toByteArray(project));
+                  cd.draftRefs()
+                      .values()
+                      .forEach(r -> result.add(RefState.of(r).toByteArray(allUsers(cd))));
+                }
+
+                return result;
+              });
+
+  /**
+   * All ref wildcard patterns that were used in the course of indexing this document.
+   *
+   * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name/*}. See {@link
+   * RefStatePattern} for the pattern format.
+   */
+  public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE_PATTERN =
+      storedOnly("ref_state_pattern")
+          .buildRepeatable(
+              cd -> {
+                Change.Id id = cd.getId();
+                Project.NameKey project = cd.change().getProject();
+                List<byte[]> result = new ArrayList<>(3);
+                result.add(
+                    RefStatePattern.create(
+                            RefNames.REFS_USERS + "*/" + RefNames.EDIT_PREFIX + id + "/*")
+                        .toByteArray(project));
+                result.add(
+                    RefStatePattern.create(RefNames.refsStarredChangesPrefix(id) + "*")
+                        .toByteArray(allUsers(cd)));
+                if (PrimaryStorage.of(cd.change()) == PrimaryStorage.NOTE_DB) {
+                  result.add(
+                      RefStatePattern.create(RefNames.refsDraftCommentsPrefix(id) + "*")
+                          .toByteArray(allUsers(cd)));
+                }
+                return result;
+              });
+
+  private static String getTopic(ChangeData cd) throws OrmException {
+    Change c = cd.change();
+    if (c == null) {
+      return null;
+    }
+    return firstNonNull(c.getTopic(), "");
+  }
+
+  private static <T> List<byte[]> toProtos(ProtobufCodec<T> codec, Collection<T> objs)
+      throws OrmException {
+    List<byte[]> result = Lists.newArrayListWithCapacity(objs.size());
+    ByteArrayOutputStream out = new ByteArrayOutputStream(256);
+    try {
+      for (T obj : objs) {
+        out.reset();
+        CodedOutputStream cos = CodedOutputStream.newInstance(out);
+        codec.encode(obj, cos);
+        cos.flush();
+        result.add(out.toByteArray());
+      }
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+    return result;
+  }
+
+  private static <T> FieldDef.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
+    return in -> in.change() != null ? func.apply(in.change()) : null;
+  }
+
+  private static AllUsersName allUsers(ChangeData cd) {
+    return cd.getAllUsersNameForIndexing();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java
rename to java/com/google/gerrit/server/index/change/ChangeIndex.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java b/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
rename to java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java b/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
rename to java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
rename to java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
new file mode 100644
index 0000000..e95470d
--- /dev/null
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -0,0 +1,467 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.gerrit.server.extensions.events.EventUtil.logEventListenerError;
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.util.concurrent.Atomics;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.OutOfScopeException;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import com.google.inject.util.Providers;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Helper for (re)indexing a change document.
+ *
+ * <p>Indexing is run in the background, as it may require substantial work to compute some of the
+ * fields and/or update the index.
+ */
+public class ChangeIndexer {
+  private static final Logger log = LoggerFactory.getLogger(ChangeIndexer.class);
+
+  public interface Factory {
+    ChangeIndexer create(ListeningExecutorService executor, ChangeIndex index);
+
+    ChangeIndexer create(ListeningExecutorService executor, ChangeIndexCollection indexes);
+  }
+
+  @SuppressWarnings("deprecation")
+  public static com.google.common.util.concurrent.CheckedFuture<?, IOException> allAsList(
+      List<? extends ListenableFuture<?>> futures) {
+    // allAsList propagates the first seen exception, wrapped in
+    // ExecutionException, so we can reuse the same mapper as for a single
+    // future. Assume the actual contents of the exception are not useful to
+    // callers. All exceptions are already logged by IndexTask.
+    return Futures.makeChecked(Futures.allAsList(futures), IndexUtils.MAPPER);
+  }
+
+  @Nullable private final ChangeIndexCollection indexes;
+  @Nullable private final ChangeIndex index;
+  private final SchemaFactory<ReviewDb> schemaFactory;
+  private final NotesMigration notesMigration;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final ThreadLocalRequestContext context;
+  private final ListeningExecutorService batchExecutor;
+  private final ListeningExecutorService executor;
+  private final DynamicSet<ChangeIndexedListener> indexedListeners;
+  private final StalenessChecker stalenessChecker;
+  private final boolean autoReindexIfStale;
+
+  @AssistedInject
+  ChangeIndexer(
+      @GerritServerConfig Config cfg,
+      SchemaFactory<ReviewDb> schemaFactory,
+      NotesMigration notesMigration,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeData.Factory changeDataFactory,
+      ThreadLocalRequestContext context,
+      DynamicSet<ChangeIndexedListener> indexedListeners,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
+      @Assisted ListeningExecutorService executor,
+      @Assisted ChangeIndex index) {
+    this.executor = executor;
+    this.schemaFactory = schemaFactory;
+    this.notesMigration = notesMigration;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeDataFactory = changeDataFactory;
+    this.context = context;
+    this.indexedListeners = indexedListeners;
+    this.stalenessChecker = stalenessChecker;
+    this.batchExecutor = batchExecutor;
+    this.autoReindexIfStale = autoReindexIfStale(cfg);
+    this.index = index;
+    this.indexes = null;
+  }
+
+  @AssistedInject
+  ChangeIndexer(
+      SchemaFactory<ReviewDb> schemaFactory,
+      @GerritServerConfig Config cfg,
+      NotesMigration notesMigration,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeData.Factory changeDataFactory,
+      ThreadLocalRequestContext context,
+      DynamicSet<ChangeIndexedListener> indexedListeners,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
+      @Assisted ListeningExecutorService executor,
+      @Assisted ChangeIndexCollection indexes) {
+    this.executor = executor;
+    this.schemaFactory = schemaFactory;
+    this.notesMigration = notesMigration;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeDataFactory = changeDataFactory;
+    this.context = context;
+    this.indexedListeners = indexedListeners;
+    this.stalenessChecker = stalenessChecker;
+    this.batchExecutor = batchExecutor;
+    this.autoReindexIfStale = autoReindexIfStale(cfg);
+    this.index = null;
+    this.indexes = indexes;
+  }
+
+  private static boolean autoReindexIfStale(Config cfg) {
+    return cfg.getBoolean("index", null, "autoReindexIfStale", true);
+  }
+
+  /**
+   * Start indexing a change.
+   *
+   * @param id change to index.
+   * @return future for the indexing task.
+   */
+  @SuppressWarnings("deprecation")
+  public com.google.common.util.concurrent.CheckedFuture<?, IOException> indexAsync(
+      Project.NameKey project, Change.Id id) {
+    return submit(new IndexTask(project, id));
+  }
+
+  /**
+   * Start indexing multiple changes in parallel.
+   *
+   * @param ids changes to index.
+   * @return future for completing indexing of all changes.
+   */
+  @SuppressWarnings("deprecation")
+  public com.google.common.util.concurrent.CheckedFuture<?, IOException> indexAsync(
+      Project.NameKey project, Collection<Change.Id> ids) {
+    List<ListenableFuture<?>> futures = new ArrayList<>(ids.size());
+    for (Change.Id id : ids) {
+      futures.add(indexAsync(project, id));
+    }
+    return allAsList(futures);
+  }
+
+  /**
+   * Synchronously index a change.
+   *
+   * @param cd change to index.
+   */
+  public void index(ChangeData cd) throws IOException {
+    for (Index<?, ChangeData> i : getWriteIndexes()) {
+      i.replace(cd);
+    }
+    fireChangeIndexedEvent(cd.getId().get());
+
+    // Always double-check whether the change might be stale immediately after
+    // interactively indexing it. This fixes up the case where two writers write
+    // to the primary storage in one order, and the corresponding index writes
+    // happen in the opposite order:
+    //  1. Writer A writes to primary storage.
+    //  2. Writer B writes to primary storage.
+    //  3. Writer B updates index.
+    //  4. Writer A updates index.
+    //
+    // Without the extra reindexIfStale step, A has no way of knowing that it's
+    // about to overwrite the index document with stale data. It doesn't work to
+    // have A check for staleness before attempting its index update, because
+    // B's index update might not have happened when it does the check.
+    //
+    // With the extra reindexIfStale step after (3)/(4), we are able to detect
+    // and fix the staleness. It doesn't matter which order the two
+    // reindexIfStale calls actually execute in; we are guaranteed that at least
+    // one of them will execute after the second index write, (4).
+    autoReindexIfStale(cd);
+  }
+
+  private void fireChangeIndexedEvent(int id) {
+    for (ChangeIndexedListener listener : indexedListeners) {
+      try {
+        listener.onChangeIndexed(id);
+      } catch (Exception e) {
+        logEventListenerError(listener, e);
+      }
+    }
+  }
+
+  private void fireChangeDeletedFromIndexEvent(int id) {
+    for (ChangeIndexedListener listener : indexedListeners) {
+      try {
+        listener.onChangeDeleted(id);
+      } catch (Exception e) {
+        logEventListenerError(listener, e);
+      }
+    }
+  }
+
+  /**
+   * Synchronously index a change.
+   *
+   * @param db review database.
+   * @param change change to index.
+   */
+  public void index(ReviewDb db, Change change) throws IOException, OrmException {
+    index(newChangeData(db, change));
+    // See comment in #index(ChangeData).
+    autoReindexIfStale(change.getProject(), change.getId());
+  }
+
+  /**
+   * Synchronously index a change.
+   *
+   * @param db review database.
+   * @param project the project to which the change belongs.
+   * @param changeId ID of the change to index.
+   */
+  public void index(ReviewDb db, Project.NameKey project, Change.Id changeId)
+      throws IOException, OrmException {
+    ChangeData cd = newChangeData(db, project, changeId);
+    index(cd);
+    // See comment in #index(ChangeData).
+    autoReindexIfStale(cd);
+  }
+
+  /**
+   * Start deleting a change.
+   *
+   * @param id change to delete.
+   * @return future for the deleting task.
+   */
+  @SuppressWarnings("deprecation")
+  public com.google.common.util.concurrent.CheckedFuture<?, IOException> deleteAsync(Change.Id id) {
+    return submit(new DeleteTask(id));
+  }
+
+  /**
+   * Synchronously delete a change.
+   *
+   * @param id change ID to delete.
+   */
+  public void delete(Change.Id id) throws IOException {
+    new DeleteTask(id).call();
+  }
+
+  /**
+   * Asynchronously check if a change is stale, and reindex if it is.
+   *
+   * <p>Always run on the batch executor, even if this indexer instance is configured to use a
+   * different executor.
+   *
+   * @param project the project to which the change belongs.
+   * @param id ID of the change to index.
+   * @return future for reindexing the change; returns true if the change was stale.
+   */
+  @SuppressWarnings("deprecation")
+  public com.google.common.util.concurrent.CheckedFuture<Boolean, IOException> reindexIfStale(
+      Project.NameKey project, Change.Id id) {
+    return submit(new ReindexIfStaleTask(project, id), batchExecutor);
+  }
+
+  private void autoReindexIfStale(ChangeData cd) {
+    autoReindexIfStale(cd.project(), cd.getId());
+  }
+
+  private void autoReindexIfStale(Project.NameKey project, Change.Id id) {
+    if (autoReindexIfStale) {
+      // Don't retry indefinitely; if this fails the change will be stale.
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError = reindexIfStale(project, id);
+    }
+  }
+
+  private Collection<ChangeIndex> getWriteIndexes() {
+    return indexes != null ? indexes.getWriteIndexes() : Collections.singleton(index);
+  }
+
+  @SuppressWarnings("deprecation")
+  private <T> com.google.common.util.concurrent.CheckedFuture<T, IOException> submit(
+      Callable<T> task) {
+    return submit(task, executor);
+  }
+
+  @SuppressWarnings("deprecation")
+  private static <T> com.google.common.util.concurrent.CheckedFuture<T, IOException> submit(
+      Callable<T> task, ListeningExecutorService executor) {
+    return Futures.makeChecked(
+        Futures.nonCancellationPropagating(executor.submit(task)), IndexUtils.MAPPER);
+  }
+
+  private abstract class AbstractIndexTask<T> implements Callable<T> {
+    protected final Project.NameKey project;
+    protected final Change.Id id;
+
+    protected AbstractIndexTask(Project.NameKey project, Change.Id id) {
+      this.project = project;
+      this.id = id;
+    }
+
+    protected abstract T callImpl(Provider<ReviewDb> db) throws Exception;
+
+    @Override
+    public abstract String toString();
+
+    @Override
+    public final T call() throws Exception {
+      try {
+        final AtomicReference<Provider<ReviewDb>> dbRef = Atomics.newReference();
+        RequestContext newCtx =
+            new RequestContext() {
+              @Override
+              public Provider<ReviewDb> getReviewDbProvider() {
+                Provider<ReviewDb> db = dbRef.get();
+                if (db == null) {
+                  try {
+                    db = Providers.of(schemaFactory.open());
+                  } catch (OrmException e) {
+                    ProvisionException pe = new ProvisionException("error opening ReviewDb");
+                    pe.initCause(e);
+                    throw pe;
+                  }
+                  dbRef.set(db);
+                }
+                return db;
+              }
+
+              @Override
+              public CurrentUser getUser() {
+                throw new OutOfScopeException("No user during ChangeIndexer");
+              }
+            };
+        RequestContext oldCtx = context.setContext(newCtx);
+        try {
+          return callImpl(newCtx.getReviewDbProvider());
+        } finally {
+          context.setContext(oldCtx);
+          Provider<ReviewDb> db = dbRef.get();
+          if (db != null) {
+            db.get().close();
+          }
+        }
+      } catch (Exception e) {
+        log.error("Failed to execute " + this, e);
+        throw e;
+      }
+    }
+  }
+
+  private class IndexTask extends AbstractIndexTask<Void> {
+    private IndexTask(Project.NameKey project, Change.Id id) {
+      super(project, id);
+    }
+
+    @Override
+    public Void callImpl(Provider<ReviewDb> db) throws Exception {
+      ChangeData cd = newChangeData(db.get(), project, id);
+      index(cd);
+      return null;
+    }
+
+    @Override
+    public String toString() {
+      return "index-change-" + id;
+    }
+  }
+
+  // Not AbstractIndexTask as it doesn't need ReviewDb.
+  private class DeleteTask implements Callable<Void> {
+    private final Change.Id id;
+
+    private DeleteTask(Change.Id id) {
+      this.id = id;
+    }
+
+    @Override
+    public Void call() throws IOException {
+      // Don't bother setting a RequestContext to provide the DB.
+      // Implementations should not need to access the DB in order to delete a
+      // change ID.
+      for (ChangeIndex i : getWriteIndexes()) {
+        i.delete(id);
+      }
+      log.info("Deleted change {} from index.", id.get());
+      fireChangeDeletedFromIndexEvent(id.get());
+      return null;
+    }
+  }
+
+  private class ReindexIfStaleTask extends AbstractIndexTask<Boolean> {
+    private ReindexIfStaleTask(Project.NameKey project, Change.Id id) {
+      super(project, id);
+    }
+
+    @Override
+    public Boolean callImpl(Provider<ReviewDb> db) throws Exception {
+      if (!stalenessChecker.isStale(id)) {
+        return false;
+      }
+      index(newChangeData(db.get(), project, id));
+      return true;
+    }
+
+    @Override
+    public String toString() {
+      return "reindex-if-stale-change-" + id;
+    }
+  }
+
+  // Avoid auto-rebuilding when reindexing if reading is disabled. This just
+  // increases contention on the meta ref from a background indexing thread
+  // with little benefit. The next actual write to the entity may still incur a
+  // less-contentious rebuild.
+  private ChangeData newChangeData(ReviewDb db, Change change) throws OrmException {
+    if (!notesMigration.readChanges()) {
+      ChangeNotes notes = changeNotesFactory.createWithAutoRebuildingDisabled(change, null);
+      return changeDataFactory.create(db, notes);
+    }
+    return changeDataFactory.create(db, change);
+  }
+
+  private ChangeData newChangeData(ReviewDb db, Project.NameKey project, Change.Id changeId)
+      throws OrmException {
+    if (!notesMigration.readChanges()) {
+      ChangeNotes notes =
+          changeNotesFactory.createWithAutoRebuildingDisabled(db, project, changeId);
+      return changeDataFactory.create(db, notes);
+    }
+    return changeDataFactory.create(db, project, changeId);
+  }
+}
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
new file mode 100644
index 0000000..5e7e4dd
--- /dev/null
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+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.query.change.ChangeData;
+
+public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
+  @Deprecated
+  static final Schema<ChangeData> V39 =
+      schema(
+          ChangeField.ADDED,
+          ChangeField.APPROVAL,
+          ChangeField.ASSIGNEE,
+          ChangeField.AUTHOR,
+          ChangeField.CHANGE,
+          ChangeField.COMMENT,
+          ChangeField.COMMENTBY,
+          ChangeField.COMMIT,
+          ChangeField.COMMITTER,
+          ChangeField.COMMIT_MESSAGE,
+          ChangeField.DELETED,
+          ChangeField.DELTA,
+          ChangeField.DRAFTBY,
+          ChangeField.EDITBY,
+          ChangeField.EXACT_COMMIT,
+          ChangeField.EXACT_TOPIC,
+          ChangeField.FILE_PART,
+          ChangeField.FUZZY_TOPIC,
+          ChangeField.GROUP,
+          ChangeField.HASHTAG,
+          ChangeField.HASHTAG_CASE_AWARE,
+          ChangeField.ID,
+          ChangeField.LABEL,
+          ChangeField.LEGACY_ID,
+          ChangeField.MERGEABLE,
+          ChangeField.OWNER,
+          ChangeField.PATCH_SET,
+          ChangeField.PATH,
+          ChangeField.PROJECT,
+          ChangeField.PROJECTS,
+          ChangeField.REF,
+          ChangeField.REF_STATE,
+          ChangeField.REF_STATE_PATTERN,
+          ChangeField.REVIEWEDBY,
+          ChangeField.REVIEWER,
+          ChangeField.STAR,
+          ChangeField.STARBY,
+          ChangeField.STATUS,
+          ChangeField.STORED_SUBMIT_RECORD_LENIENT,
+          ChangeField.STORED_SUBMIT_RECORD_STRICT,
+          ChangeField.SUBMISSIONID,
+          ChangeField.SUBMIT_RECORD,
+          ChangeField.TR,
+          ChangeField.UNRESOLVED_COMMENT_COUNT,
+          ChangeField.UPDATED);
+
+  @Deprecated static final Schema<ChangeData> V40 = schema(V39, ChangeField.PRIVATE);
+  @Deprecated static final Schema<ChangeData> V41 = schema(V40, ChangeField.REVIEWER_BY_EMAIL);
+  @Deprecated static final Schema<ChangeData> V42 = schema(V41, ChangeField.WIP);
+
+  @Deprecated
+  static final Schema<ChangeData> V43 =
+      schema(V42, ChangeField.EXACT_AUTHOR, ChangeField.EXACT_COMMITTER);
+
+  @Deprecated
+  static final Schema<ChangeData> V44 =
+      schema(
+          V43,
+          ChangeField.STARTED,
+          ChangeField.PENDING_REVIEWER,
+          ChangeField.PENDING_REVIEWER_BY_EMAIL);
+
+  @Deprecated static final Schema<ChangeData> V45 = schema(V44, ChangeField.REVERT_OF);
+
+  @Deprecated static final Schema<ChangeData> V46 = schema(V45);
+
+  // Removal of draft change workflow requires reindexing
+  @Deprecated static final Schema<ChangeData> V47 = schema(V46);
+
+  // Rename of star label 'mute' to 'reviewed' requires reindexing
+  @Deprecated static final Schema<ChangeData> V48 = schema(V47);
+
+  static final Schema<ChangeData> V49 = schema(V48);
+
+  public static final String NAME = "changes";
+  public static final ChangeSchemaDefinitions INSTANCE = new ChangeSchemaDefinitions();
+
+  private ChangeSchemaDefinitions() {
+    super(NAME, ChangeData.class);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/DummyChangeIndex.java b/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
rename to java/com/google/gerrit/server/index/change/DummyChangeIndex.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
rename to java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
rename to java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
new file mode 100644
index 0000000..e7790df
--- /dev/null
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -0,0 +1,262 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.RefState;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+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.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class StalenessChecker {
+  private static final Logger log = LoggerFactory.getLogger(StalenessChecker.class);
+
+  public static final ImmutableSet<String> FIELDS =
+      ImmutableSet.of(
+          ChangeField.CHANGE.getName(),
+          ChangeField.REF_STATE.getName(),
+          ChangeField.REF_STATE_PATTERN.getName());
+
+  private final ChangeIndexCollection indexes;
+  private final GitRepositoryManager repoManager;
+  private final IndexConfig indexConfig;
+  private final Provider<ReviewDb> db;
+
+  @Inject
+  StalenessChecker(
+      ChangeIndexCollection indexes,
+      GitRepositoryManager repoManager,
+      IndexConfig indexConfig,
+      Provider<ReviewDb> db) {
+    this.indexes = indexes;
+    this.repoManager = repoManager;
+    this.indexConfig = indexConfig;
+    this.db = db;
+  }
+
+  public boolean isStale(Change.Id id) throws IOException, OrmException {
+    ChangeIndex i = indexes.getSearchIndex();
+    if (i == null) {
+      return false; // No index; caller couldn't do anything if it is stale.
+    }
+    if (!i.getSchema().hasField(ChangeField.REF_STATE)
+        || !i.getSchema().hasField(ChangeField.REF_STATE_PATTERN)) {
+      return false; // Index version not new enough for this check.
+    }
+
+    Optional<ChangeData> result =
+        i.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, FIELDS));
+    if (!result.isPresent()) {
+      return true; // Not in index, but caller wants it to be.
+    }
+    ChangeData cd = result.get();
+    return isStale(
+        repoManager,
+        id,
+        cd.change(),
+        ChangeNotes.readOneReviewDbChange(db.get(), id),
+        parseStates(cd),
+        parsePatterns(cd));
+  }
+
+  public static boolean isStale(
+      GitRepositoryManager repoManager,
+      Change.Id id,
+      Change indexChange,
+      @Nullable Change reviewDbChange,
+      SetMultimap<Project.NameKey, RefState> states,
+      ListMultimap<Project.NameKey, RefStatePattern> patterns) {
+    return reviewDbChangeIsStale(indexChange, reviewDbChange)
+        || refsAreStale(repoManager, id, states, patterns);
+  }
+
+  @VisibleForTesting
+  static boolean refsAreStale(
+      GitRepositoryManager repoManager,
+      Change.Id id,
+      SetMultimap<Project.NameKey, RefState> states,
+      ListMultimap<Project.NameKey, RefStatePattern> patterns) {
+    Set<Project.NameKey> projects = Sets.union(states.keySet(), patterns.keySet());
+
+    for (Project.NameKey p : projects) {
+      if (refsAreStale(repoManager, id, p, states, patterns)) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  @VisibleForTesting
+  static boolean reviewDbChangeIsStale(Change indexChange, @Nullable Change reviewDbChange) {
+    checkNotNull(indexChange);
+    PrimaryStorage storageFromIndex = PrimaryStorage.of(indexChange);
+    PrimaryStorage storageFromReviewDb = PrimaryStorage.of(reviewDbChange);
+    if (reviewDbChange == null) {
+      if (storageFromIndex == PrimaryStorage.REVIEW_DB) {
+        return true; // Index says it should have been in ReviewDb, but it wasn't.
+      }
+      return false; // Not in ReviewDb, but that's ok.
+    }
+    checkArgument(
+        indexChange.getId().equals(reviewDbChange.getId()),
+        "mismatched change ID: %s != %s",
+        indexChange.getId(),
+        reviewDbChange.getId());
+    if (storageFromIndex != storageFromReviewDb) {
+      return true; // Primary storage differs, definitely stale.
+    }
+    if (storageFromReviewDb != PrimaryStorage.REVIEW_DB) {
+      return false; // Not a ReviewDb change, don't check rowVersion.
+    }
+    return reviewDbChange.getRowVersion() != indexChange.getRowVersion();
+  }
+
+  private SetMultimap<Project.NameKey, RefState> parseStates(ChangeData cd) {
+    return RefState.parseStates(cd.getRefStates());
+  }
+
+  private ListMultimap<Project.NameKey, RefStatePattern> parsePatterns(ChangeData cd) {
+    return parsePatterns(cd.getRefStatePatterns());
+  }
+
+  public static ListMultimap<Project.NameKey, RefStatePattern> parsePatterns(
+      Iterable<byte[]> patterns) {
+    RefStatePattern.check(patterns != null, null);
+    ListMultimap<Project.NameKey, RefStatePattern> result =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+    for (byte[] b : patterns) {
+      RefStatePattern.check(b != null, null);
+      String s = new String(b, UTF_8);
+      List<String> parts = Splitter.on(':').splitToList(s);
+      RefStatePattern.check(parts.size() == 2, s);
+      result.put(new Project.NameKey(parts.get(0)), RefStatePattern.create(parts.get(1)));
+    }
+    return result;
+  }
+
+  private static boolean refsAreStale(
+      GitRepositoryManager repoManager,
+      Change.Id id,
+      Project.NameKey project,
+      SetMultimap<Project.NameKey, RefState> allStates,
+      ListMultimap<Project.NameKey, RefStatePattern> allPatterns) {
+    try (Repository repo = repoManager.openRepository(project)) {
+      Set<RefState> states = allStates.get(project);
+      for (RefState state : states) {
+        if (!state.match(repo)) {
+          return true;
+        }
+      }
+      for (RefStatePattern pattern : allPatterns.get(project)) {
+        if (!pattern.match(repo, states)) {
+          return true;
+        }
+      }
+      return false;
+    } catch (IOException e) {
+      log.warn(String.format("error checking staleness of %s in %s", id, project), e);
+      return true;
+    }
+  }
+
+  /**
+   * Pattern for matching refs.
+   *
+   * <p>Similar to '*' syntax for native Git refspecs, but slightly more powerful: the pattern may
+   * contain arbitrarily many asterisks. There must be at least one '*' and the first one must
+   * immediately follow a '/'.
+   */
+  @AutoValue
+  public abstract static class RefStatePattern {
+    static RefStatePattern create(String pattern) {
+      int star = pattern.indexOf('*');
+      check(star > 0 && pattern.charAt(star - 1) == '/', pattern);
+      String prefix = pattern.substring(0, star);
+      check(Repository.isValidRefName(pattern.replace('*', 'x')), pattern);
+
+      // Quote everything except the '*'s, which become ".*".
+      String regex =
+          Streams.stream(Splitter.on('*').split(pattern))
+              .map(Pattern::quote)
+              .collect(joining(".*", "^", "$"));
+      return new AutoValue_StalenessChecker_RefStatePattern(
+          pattern, prefix, Pattern.compile(regex));
+    }
+
+    byte[] toByteArray(Project.NameKey project) {
+      return (project.toString() + ':' + pattern()).getBytes(UTF_8);
+    }
+
+    private static void check(boolean condition, String str) {
+      checkArgument(condition, "invalid RefStatePattern: %s", str);
+    }
+
+    abstract String pattern();
+
+    abstract String prefix();
+
+    abstract Pattern regex();
+
+    boolean match(String refName) {
+      return regex().matcher(refName).find();
+    }
+
+    private boolean match(Repository repo, Set<RefState> expected) throws IOException {
+      for (Ref r : repo.getRefDatabase().getRefs(prefix()).values()) {
+        if (!match(r.getName())) {
+          continue;
+        }
+        if (!expected.contains(RefState.of(r))) {
+          return false;
+        }
+      }
+      return true;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
new file mode 100644
index 0000000..aca4eab
--- /dev/null
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.index.SiteIndexer;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.group.db.GroupsNoteDbConsistencyChecker;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class AllGroupsIndexer extends SiteIndexer<AccountGroup.UUID, InternalGroup, GroupIndex> {
+  private static final Logger log = LoggerFactory.getLogger(AllGroupsIndexer.class);
+
+  private final SchemaFactory<ReviewDb> schemaFactory;
+  private final ListeningExecutorService executor;
+  private final GroupCache groupCache;
+  private final Groups groups;
+
+  @Inject
+  AllGroupsIndexer(
+      SchemaFactory<ReviewDb> schemaFactory,
+      @IndexExecutor(BATCH) ListeningExecutorService executor,
+      GroupCache groupCache,
+      Groups groups) {
+    this.schemaFactory = schemaFactory;
+    this.executor = executor;
+    this.groupCache = groupCache;
+    this.groups = groups;
+  }
+
+  @Override
+  public SiteIndexer.Result indexAll(GroupIndex index) {
+    ProgressMonitor progress = new TextProgressMonitor(new PrintWriter(progressOut));
+    progress.start(2);
+    Stopwatch sw = Stopwatch.createStarted();
+    List<AccountGroup.UUID> uuids;
+    try {
+      uuids = collectGroups(progress);
+    } catch (OrmException | IOException | ConfigInvalidException e) {
+      log.error("Error collecting groups", e);
+      return new SiteIndexer.Result(sw, false, 0, 0);
+    }
+    return reindexGroups(index, uuids, progress);
+  }
+
+  private SiteIndexer.Result reindexGroups(
+      GroupIndex index, List<AccountGroup.UUID> uuids, ProgressMonitor progress) {
+    progress.beginTask("Reindexing groups", uuids.size());
+    List<ListenableFuture<?>> futures = new ArrayList<>(uuids.size());
+    AtomicBoolean ok = new AtomicBoolean(true);
+    AtomicInteger done = new AtomicInteger();
+    AtomicInteger failed = new AtomicInteger();
+    Stopwatch sw = Stopwatch.createStarted();
+    for (AccountGroup.UUID uuid : uuids) {
+      String desc = "group " + uuid;
+      ListenableFuture<?> future =
+          executor.submit(
+              () -> {
+                try {
+                  Optional<InternalGroup> oldGroup = groupCache.get(uuid);
+                  if (oldGroup.isPresent()) {
+                    InternalGroup group = oldGroup.get();
+                    groupCache.evict(group.getGroupUUID(), group.getId(), group.getNameKey());
+                  }
+                  Optional<InternalGroup> internalGroup = groupCache.get(uuid);
+                  if (internalGroup.isPresent()) {
+                    index.replace(internalGroup.get());
+                  } else {
+                    index.delete(uuid);
+
+                    // The UUID here is read from group name notes. If it fails to load from group
+                    // cache, there exists an inconsistency.
+                    GroupsNoteDbConsistencyChecker.logFailToLoadFromGroupRefAsWarning(uuid);
+                  }
+                  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 group 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<AccountGroup.UUID> collectGroups(ProgressMonitor progress)
+      throws OrmException, IOException, ConfigInvalidException {
+    progress.beginTask("Collecting groups", ProgressMonitor.UNKNOWN);
+    try (ReviewDb db = schemaFactory.open()) {
+      return groups
+          .getAllGroupReferences(db)
+          .map(GroupReference::getUUID)
+          .collect(toImmutableList());
+    } finally {
+      progress.endTask();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/index/group/GroupField.java b/java/com/google/gerrit/server/index/group/GroupField.java
new file mode 100644
index 0000000..29e3867
--- /dev/null
+++ b/java/com/google/gerrit/server/index/group/GroupField.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.index.FieldDef.exact;
+import static com.google.gerrit.index.FieldDef.fullText;
+import static com.google.gerrit.index.FieldDef.integer;
+import static com.google.gerrit.index.FieldDef.prefix;
+import static com.google.gerrit.index.FieldDef.storedOnly;
+import static com.google.gerrit.index.FieldDef.timestamp;
+
+import com.google.common.base.MoreObjects;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.group.InternalGroup;
+import java.sql.Timestamp;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Secondary index schemas for groups. */
+public class GroupField {
+  /** Legacy group ID. */
+  public static final FieldDef<InternalGroup, Integer> ID =
+      integer("id").build(g -> g.getId().get());
+
+  /** Group UUID. */
+  public static final FieldDef<InternalGroup, String> UUID =
+      exact("uuid").stored().build(g -> g.getGroupUUID().get());
+
+  /** Group owner UUID. */
+  public static final FieldDef<InternalGroup, String> OWNER_UUID =
+      exact("owner_uuid").build(g -> g.getOwnerGroupUUID().get());
+
+  /** Timestamp indicating when this group was created. */
+  public static final FieldDef<InternalGroup, Timestamp> CREATED_ON =
+      timestamp("created_on").build(InternalGroup::getCreatedOn);
+
+  /** Group name. */
+  public static final FieldDef<InternalGroup, String> NAME =
+      exact("name").build(InternalGroup::getName);
+
+  /** Prefix match on group name parts. */
+  public static final FieldDef<InternalGroup, Iterable<String>> NAME_PART =
+      prefix("name_part").buildRepeatable(g -> SchemaUtil.getNameParts(g.getName()));
+
+  /** Group description. */
+  public static final FieldDef<InternalGroup, String> DESCRIPTION =
+      fullText("description").build(InternalGroup::getDescription);
+
+  /** Whether the group is visible to all users. */
+  public static final FieldDef<InternalGroup, String> IS_VISIBLE_TO_ALL =
+      exact("is_visible_to_all").build(g -> g.isVisibleToAll() ? "1" : "0");
+
+  public static final FieldDef<InternalGroup, Iterable<Integer>> MEMBER =
+      integer("member")
+          .buildRepeatable(
+              g -> g.getMembers().stream().map(Account.Id::get).collect(toImmutableList()));
+
+  public static final FieldDef<InternalGroup, Iterable<String>> SUBGROUP =
+      exact("subgroup")
+          .buildRepeatable(
+              g ->
+                  g.getSubgroups().stream().map(AccountGroup.UUID::get).collect(toImmutableList()));
+
+  /** ObjectId of HEAD:refs/groups/<UUID>. */
+  public static final FieldDef<InternalGroup, byte[]> REF_STATE =
+      storedOnly("ref_state")
+          .build(
+              g -> {
+                byte[] a = new byte[Constants.OBJECT_ID_STRING_LENGTH];
+                MoreObjects.firstNonNull(g.getRefState(), ObjectId.zeroId()).copyTo(a, 0);
+                return a;
+              });
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndex.java b/java/com/google/gerrit/server/index/group/GroupIndex.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndex.java
rename to java/com/google/gerrit/server/index/group/GroupIndex.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexCollection.java b/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
rename to java/com/google/gerrit/server/index/group/GroupIndexCollection.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java b/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
rename to java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java b/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
rename to java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexer.java b/java/com/google/gerrit/server/index/group/GroupIndexer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexer.java
rename to java/com/google/gerrit/server/index/group/GroupIndexer.java
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
new file mode 100644
index 0000000..b101dcb
--- /dev/null
+++ b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.events.GroupIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.Config;
+
+public class GroupIndexerImpl implements GroupIndexer {
+  public interface Factory {
+    GroupIndexerImpl create(GroupIndexCollection indexes);
+
+    GroupIndexerImpl create(@Nullable GroupIndex index);
+  }
+
+  private final GroupCache groupCache;
+  private final DynamicSet<GroupIndexedListener> indexedListener;
+  private final StalenessChecker stalenessChecker;
+  private final ListeningExecutorService batchExecutor;
+  private final boolean autoReindexIfStale;
+  @Nullable private final GroupIndexCollection indexes;
+  @Nullable private final GroupIndex index;
+
+  @AssistedInject
+  GroupIndexerImpl(
+      GroupCache groupCache,
+      DynamicSet<GroupIndexedListener> indexedListener,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
+      @GerritServerConfig Config config,
+      @Assisted GroupIndexCollection indexes) {
+    this.groupCache = groupCache;
+    this.indexedListener = indexedListener;
+    this.stalenessChecker = stalenessChecker;
+    this.batchExecutor = batchExecutor;
+    this.autoReindexIfStale = autoReindexIfStale(config);
+    this.indexes = indexes;
+    this.index = null;
+  }
+
+  @AssistedInject
+  GroupIndexerImpl(
+      GroupCache groupCache,
+      DynamicSet<GroupIndexedListener> indexedListener,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
+      @GerritServerConfig Config config,
+      @Assisted @Nullable GroupIndex index) {
+    this.groupCache = groupCache;
+    this.indexedListener = indexedListener;
+    this.stalenessChecker = stalenessChecker;
+    this.batchExecutor = batchExecutor;
+    this.autoReindexIfStale = autoReindexIfStale(config);
+    this.indexes = null;
+    this.index = index;
+  }
+
+  @Override
+  public void index(AccountGroup.UUID uuid) throws IOException {
+    for (Index<AccountGroup.UUID, InternalGroup> i : getWriteIndexes()) {
+      Optional<InternalGroup> internalGroup = groupCache.get(uuid);
+      if (internalGroup.isPresent()) {
+        i.replace(internalGroup.get());
+      } else {
+        i.delete(uuid);
+      }
+    }
+    fireGroupIndexedEvent(uuid.get());
+    autoReindexIfStale(uuid);
+  }
+
+  private static boolean autoReindexIfStale(Config cfg) {
+    return cfg.getBoolean("index", null, "autoReindexIfStale", true);
+  }
+
+  private void autoReindexIfStale(AccountGroup.UUID uuid) {
+    if (autoReindexIfStale) {
+      // Don't retry indefinitely; if this fails the group will be stale.
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError = reindexIfStale(uuid);
+    }
+  }
+
+  /**
+   * Asynchronously check if a group is stale, and reindex if it is.
+   *
+   * <p>Always run on the batch executor, even if this indexer instance is configured to use a
+   * different executor.
+   *
+   * @param uuid the unique identifier of the group.
+   * @return future for reindexing the group; returns true if the group was stale.
+   */
+  @SuppressWarnings("deprecation")
+  public com.google.common.util.concurrent.CheckedFuture<Boolean, IOException> reindexIfStale(
+      AccountGroup.UUID uuid) {
+    Callable<Boolean> task =
+        () -> {
+          if (stalenessChecker.isStale(uuid)) {
+            index(uuid);
+            return true;
+          }
+          return false;
+        };
+
+    return Futures.makeChecked(
+        Futures.nonCancellationPropagating(batchExecutor.submit(task)), IndexUtils.MAPPER);
+  }
+
+  private void fireGroupIndexedEvent(String uuid) {
+    for (GroupIndexedListener listener : indexedListener) {
+      listener.onGroupIndexed(uuid);
+    }
+  }
+
+  private Collection<GroupIndex> getWriteIndexes() {
+    if (indexes != null) {
+      return indexes.getWriteIndexes();
+    }
+
+    return index != null ? Collections.singleton(index) : ImmutableSet.of();
+  }
+}
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
new file mode 100644
index 0000000..912524f
--- /dev/null
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+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.group.InternalGroup;
+
+public class GroupSchemaDefinitions extends SchemaDefinitions<InternalGroup> {
+  @Deprecated
+  static final Schema<InternalGroup> V2 =
+      schema(
+          GroupField.DESCRIPTION,
+          GroupField.ID,
+          GroupField.IS_VISIBLE_TO_ALL,
+          GroupField.NAME,
+          GroupField.NAME_PART,
+          GroupField.OWNER_UUID,
+          GroupField.UUID);
+
+  @Deprecated static final Schema<InternalGroup> V3 = schema(V2, GroupField.CREATED_ON);
+
+  @Deprecated
+  static final Schema<InternalGroup> V4 = schema(V3, GroupField.MEMBER, GroupField.SUBGROUP);
+
+  static final Schema<InternalGroup> V5 = schema(V4, GroupField.REF_STATE);
+
+  public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
+
+  private GroupSchemaDefinitions() {
+    super("groups", InternalGroup.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
new file mode 100644
index 0000000..79f25c0
--- /dev/null
+++ b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.IndexedQuery;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.group.InternalGroup;
+import java.util.HashSet;
+import java.util.Set;
+
+public class IndexedGroupQuery extends IndexedQuery<AccountGroup.UUID, InternalGroup>
+    implements DataSource<InternalGroup> {
+
+  public static QueryOptions createOptions(
+      IndexConfig config, int start, int limit, Set<String> fields) {
+    // Always include GroupField.UUID since it is needed to load the group from NoteDb.
+    if (!fields.contains(GroupField.UUID.getName())) {
+      fields = new HashSet<>(fields);
+      fields.add(GroupField.UUID.getName());
+    }
+    return QueryOptions.create(config, start, limit, fields);
+  }
+
+  public IndexedGroupQuery(
+      Index<AccountGroup.UUID, InternalGroup> index,
+      Predicate<InternalGroup> pred,
+      QueryOptions opts)
+      throws QueryParseException {
+    super(index, pred, opts.convertForBackend());
+  }
+}
diff --git a/java/com/google/gerrit/server/index/group/StalenessChecker.java b/java/com/google/gerrit/server/index/group/StalenessChecker.java
new file mode 100644
index 0000000..418bb35
--- /dev/null
+++ b/java/com/google/gerrit/server/index/group/StalenessChecker.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Checks if documents in the group index are stale.
+ *
+ * <p>An index document is considered stale if the stored SHA1 differs from the HEAD SHA1 of the
+ * groups branch.
+ *
+ * <p>Note: This only applies to NoteDb.
+ */
+@Singleton
+public class StalenessChecker {
+  public static final ImmutableSet<String> FIELDS =
+      ImmutableSet.of(GroupField.UUID.getName(), GroupField.REF_STATE.getName());
+
+  private final GroupIndexCollection indexes;
+  private final GitRepositoryManager repoManager;
+  private final IndexConfig indexConfig;
+  private final AllUsersName allUsers;
+  private final GroupsMigration groupsMigration;
+
+  @Inject
+  StalenessChecker(
+      GroupIndexCollection indexes,
+      GitRepositoryManager repoManager,
+      IndexConfig indexConfig,
+      AllUsersName allUsers,
+      GroupsMigration groupsMigration) {
+    this.indexes = indexes;
+    this.repoManager = repoManager;
+    this.indexConfig = indexConfig;
+    this.allUsers = allUsers;
+    this.groupsMigration = groupsMigration;
+  }
+
+  public boolean isStale(AccountGroup.UUID uuid) throws IOException {
+    if (!groupsMigration.readFromNoteDb()) {
+      return false; // This class only treats staleness for groups in NoteDb.
+    }
+
+    GroupIndex i = indexes.getSearchIndex();
+    if (i == null) {
+      return false; // No index; caller couldn't do anything if it is stale.
+    }
+    if (!i.getSchema().hasField(GroupField.REF_STATE)) {
+      return false; // Index version not new enough for this check.
+    }
+
+    Optional<FieldBundle> result =
+        i.getRaw(uuid, IndexedGroupQuery.createOptions(indexConfig, 0, 1, FIELDS));
+    if (!result.isPresent()) {
+      // The document is missing in the index.
+      try (Repository repo = repoManager.openRepository(allUsers)) {
+        Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
+
+        // Stale if the group actually exists.
+        return ref != null;
+      }
+    }
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
+      ObjectId head = ref == null ? ObjectId.zeroId() : ref.getObjectId();
+      return !head.equals(ObjectId.fromString(result.get().getValue(GroupField.REF_STATE), 0));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
new file mode 100644
index 0000000..a53434e
--- /dev/null
+++ b/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/java/com/google/gerrit/server/index/project/IndexedProjectQuery.java b/java/com/google/gerrit/server/index/project/IndexedProjectQuery.java
new file mode 100644
index 0000000..41bff05
--- /dev/null
+++ b/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/java/com/google/gerrit/server/index/project/ProjectField.java b/java/com/google/gerrit/server/index/project/ProjectField.java
new file mode 100644
index 0000000..c4f8e9e
--- /dev/null
+++ b/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/java/com/google/gerrit/server/index/project/ProjectIndex.java b/java/com/google/gerrit/server/index/project/ProjectIndex.java
new file mode 100644
index 0000000..5fbdf04
--- /dev/null
+++ b/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/java/com/google/gerrit/server/index/project/ProjectIndexCollection.java b/java/com/google/gerrit/server/index/project/ProjectIndexCollection.java
new file mode 100644
index 0000000..eeebfa1
--- /dev/null
+++ b/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/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java b/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java
new file mode 100644
index 0000000..301f209
--- /dev/null
+++ b/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/java/com/google/gerrit/server/index/project/ProjectIndexRewriter.java b/java/com/google/gerrit/server/index/project/ProjectIndexRewriter.java
new file mode 100644
index 0000000..41d8820
--- /dev/null
+++ b/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/java/com/google/gerrit/server/index/project/ProjectIndexer.java b/java/com/google/gerrit/server/index/project/ProjectIndexer.java
new file mode 100644
index 0000000..e8a8183
--- /dev/null
+++ b/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/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java b/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
new file mode 100644
index 0000000..9076648
--- /dev/null
+++ b/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.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.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.gerrit.server.project.ProjectState;
+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;
+  @Nullable private final ProjectIndexCollection indexes;
+  @Nullable 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 @Nullable ProjectIndex index) {
+    this.projectCache = projectCache;
+    this.indexedListener = indexedListener;
+    this.indexes = null;
+    this.index = index;
+  }
+
+  @Override
+  public void index(Project.NameKey nameKey) throws IOException {
+    ProjectState projectState = projectCache.get(nameKey);
+    if (projectState != null) {
+      ProjectData projectData = projectState.toProjectData();
+      for (ProjectIndex i : getWriteIndexes()) {
+        i.replace(projectData);
+      }
+      fireProjectIndexedEvent(nameKey.get());
+    } else {
+      for (ProjectIndex i : getWriteIndexes()) {
+        i.delete(nameKey);
+      }
+    }
+  }
+
+  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/java/com/google/gerrit/server/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/server/index/project/ProjectSchemaDefinitions.java
new file mode 100644
index 0000000..ccece02
--- /dev/null
+++ b/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/java/com/google/gerrit/server/ioutil/BUILD b/java/com/google/gerrit/server/ioutil/BUILD
new file mode 100644
index 0000000..06843c5
--- /dev/null
+++ b/java/com/google/gerrit/server/ioutil/BUILD
@@ -0,0 +1,11 @@
+java_library(
+    name = "ioutil",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/reviewdb:client",
+        "//lib:guava",
+        "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/BasicSerialization.java b/java/com/google/gerrit/server/ioutil/BasicSerialization.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ioutil/BasicSerialization.java
rename to java/com/google/gerrit/server/ioutil/BasicSerialization.java
diff --git a/java/com/google/gerrit/server/ioutil/ColumnFormatter.java b/java/com/google/gerrit/server/ioutil/ColumnFormatter.java
new file mode 100644
index 0000000..ae855c7
--- /dev/null
+++ b/java/com/google/gerrit/server/ioutil/ColumnFormatter.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.ioutil;
+
+import java.io.PrintWriter;
+
+/**
+ * Simple output formatter for column-oriented data, writing its output to a {@link
+ * java.io.PrintWriter} object. Handles escaping of the column data so that the resulting output is
+ * unambiguous and reasonably safe and machine parsable.
+ */
+public class ColumnFormatter {
+  private char columnSeparator;
+  private boolean firstColumn;
+  private final PrintWriter out;
+
+  /**
+   * @param out The writer to which output should be sent.
+   * @param columnSeparator A character that should serve as the separator token between columns of
+   *     output. As only non-printable characters in the column text are ever escaped, the column
+   *     separator must be a non-printable character if the output needs to be unambiguously parsed.
+   */
+  public ColumnFormatter(PrintWriter out, char columnSeparator) {
+    this.out = out;
+    this.columnSeparator = columnSeparator;
+    this.firstColumn = true;
+  }
+
+  /**
+   * Adds a text string as a new column in the current line of output, taking care of escaping as
+   * necessary.
+   *
+   * @param content the string to add.
+   */
+  public void addColumn(String content) {
+    if (!firstColumn) {
+      out.print(columnSeparator);
+    }
+    out.print(StringUtil.escapeString(content));
+    firstColumn = false;
+  }
+
+  /**
+   * Finishes the output by flushing the current line and takes care of any other cleanup action.
+   */
+  public void finish() {
+    nextLine();
+    out.flush();
+  }
+
+  /**
+   * Flushes the current line of output and makes the formatter ready to start receiving new column
+   * data for a new line (or end-of-file). If the current line is empty nothing is done, i.e.
+   * consecutive calls to this method without intervening calls to {@link #addColumn} will be
+   * squashed.
+   */
+  public void nextLine() {
+    if (!firstColumn) {
+      out.print('\n');
+      firstColumn = true;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/ioutil/LimitedByteArrayOutputStream.java b/java/com/google/gerrit/server/ioutil/LimitedByteArrayOutputStream.java
new file mode 100644
index 0000000..015887b
--- /dev/null
+++ b/java/com/google/gerrit/server/ioutil/LimitedByteArrayOutputStream.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.ioutil;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/** A stream that throws an exception if it consumes data beyond a configured byte count. */
+public class LimitedByteArrayOutputStream extends OutputStream {
+
+  private final int maxSize;
+  private final ByteArrayOutputStream buffer;
+
+  /**
+   * Constructs a LimitedByteArrayOutputStream, which stores output in memory up to a certain
+   * specified size. When the output exceeds the specified size a LimitExceededException is thrown.
+   *
+   * @param max the maximum size in bytes which may be stored.
+   * @param initial the initial size. It must be smaller than the max size.
+   */
+  public LimitedByteArrayOutputStream(int max, int initial) {
+    checkArgument(initial <= max);
+    maxSize = max;
+    buffer = new ByteArrayOutputStream(initial);
+  }
+
+  private void checkOversize(int additionalSize) throws IOException {
+    if (buffer.size() + additionalSize > maxSize) {
+      throw new LimitExceededException();
+    }
+  }
+
+  @Override
+  public void write(int b) throws IOException {
+    checkOversize(1);
+    buffer.write(b);
+  }
+
+  @Override
+  public void write(byte[] b, int off, int len) throws IOException {
+    checkOversize(len);
+    buffer.write(b, off, len);
+  }
+
+  /** @return a newly allocated byte array with contents of the buffer. */
+  public byte[] toByteArray() {
+    return buffer.toByteArray();
+  }
+
+  public static class LimitExceededException extends IOException {
+    private static final long serialVersionUID = 1L;
+  }
+}
diff --git a/java/com/google/gerrit/server/ioutil/StringUtil.java b/java/com/google/gerrit/server/ioutil/StringUtil.java
new file mode 100644
index 0000000..0520dbc
--- /dev/null
+++ b/java/com/google/gerrit/server/ioutil/StringUtil.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.ioutil;
+
+public class StringUtil {
+  /**
+   * An array of the string representations that should be used in place of the non-printable
+   * characters in the beginning of the ASCII table when escaping a string. The index of each
+   * element in the array corresponds to its ASCII value, i.e. the string representation of ASCII 0
+   * is found in the first element of this array.
+   */
+  private static final String[] NON_PRINTABLE_CHARS = {
+    "\\x00", "\\x01", "\\x02", "\\x03", "\\x04", "\\x05", "\\x06", "\\a",
+    "\\b", "\\t", "\\n", "\\v", "\\f", "\\r", "\\x0e", "\\x0f",
+    "\\x10", "\\x11", "\\x12", "\\x13", "\\x14", "\\x15", "\\x16", "\\x17",
+    "\\x18", "\\x19", "\\x1a", "\\x1b", "\\x1c", "\\x1d", "\\x1e", "\\x1f",
+  };
+
+  /**
+   * Escapes the input string so that all non-printable characters (0x00-0x1f) are represented as a
+   * hex escape (\x00, \x01, ...) or as a C-style escape sequence (\a, \b, \t, \n, \v, \f, or \r).
+   * Backslashes in the input string are doubled (\\).
+   */
+  public static String escapeString(String str) {
+    // Allocate a buffer big enough to cover the case with a string needed
+    // very excessive escaping without having to reallocate the buffer.
+    final StringBuilder result = new StringBuilder(3 * str.length());
+
+    for (int i = 0; i < str.length(); i++) {
+      char c = str.charAt(i);
+      if (c < NON_PRINTABLE_CHARS.length) {
+        result.append(NON_PRINTABLE_CHARS[c]);
+      } else if (c == '\\') {
+        result.append("\\\\");
+      } else {
+        result.append(c);
+      }
+    }
+    return result.toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java b/java/com/google/gerrit/server/mail/Address.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
rename to java/com/google/gerrit/server/mail/Address.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailModule.java b/java/com/google/gerrit/server/mail/EmailModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailModule.java
rename to java/com/google/gerrit/server/mail/EmailModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSettings.java b/java/com/google/gerrit/server/mail/EmailSettings.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSettings.java
rename to java/com/google/gerrit/server/mail/EmailSettings.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailTokenVerifier.java b/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
rename to java/com/google/gerrit/server/mail/EmailTokenVerifier.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Encryption.java b/java/com/google/gerrit/server/mail/Encryption.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/Encryption.java
rename to java/com/google/gerrit/server/mail/Encryption.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ListMailFilter.java b/java/com/google/gerrit/server/mail/ListMailFilter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/ListMailFilter.java
rename to java/com/google/gerrit/server/mail/ListMailFilter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailFilter.java b/java/com/google/gerrit/server/mail/MailFilter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/MailFilter.java
rename to java/com/google/gerrit/server/mail/MailFilter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java b/java/com/google/gerrit/server/mail/MailUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
rename to java/com/google/gerrit/server/mail/MailUtil.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MetadataName.java b/java/com/google/gerrit/server/mail/MetadataName.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/MetadataName.java
rename to java/com/google/gerrit/server/mail/MetadataName.java
diff --git a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
new file mode 100644
index 0000000..18a0ecf
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gwtjsonrpc.server.SignedToken;
+import com.google.gwtjsonrpc.server.ValidToken;
+import com.google.gwtjsonrpc.server.XsrfException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.util.Base64;
+
+/** Verifies the token sent by {@link RegisterNewEmailSender}. */
+@Singleton
+public class SignedTokenEmailTokenVerifier implements EmailTokenVerifier {
+  private final SignedToken emailRegistrationToken;
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(EmailTokenVerifier.class).to(SignedTokenEmailTokenVerifier.class);
+    }
+  }
+
+  @Inject
+  SignedTokenEmailTokenVerifier(AuthConfig config) {
+    emailRegistrationToken = config.getEmailRegistrationToken();
+  }
+
+  @Override
+  public String encode(Account.Id accountId, String emailAddress) {
+    checkEmailRegistrationToken();
+    try {
+      String payload = String.format("%s:%s", accountId, emailAddress);
+      byte[] utf8 = payload.getBytes(UTF_8);
+      String base64 = Base64.encodeBytes(utf8);
+      return emailRegistrationToken.newToken(base64);
+    } catch (XsrfException e) {
+      throw new IllegalArgumentException(e);
+    }
+  }
+
+  @Override
+  public ParsedToken decode(String tokenString) throws InvalidTokenException {
+    checkEmailRegistrationToken();
+    ValidToken token;
+    try {
+      token = emailRegistrationToken.checkToken(tokenString, null);
+    } catch (XsrfException err) {
+      throw new InvalidTokenException(err);
+    }
+    if (token == null || token.getData() == null || token.getData().isEmpty()) {
+      throw new InvalidTokenException();
+    }
+
+    String payload = new String(Base64.decode(token.getData()), UTF_8);
+    Matcher matcher = Pattern.compile("^([0-9]+):(.+@.+)$").matcher(payload);
+    if (!matcher.matches()) {
+      throw new InvalidTokenException();
+    }
+
+    Account.Id id;
+    try {
+      id = Account.Id.parse(matcher.group(1));
+    } catch (IllegalArgumentException err) {
+      throw new InvalidTokenException(err);
+    }
+
+    String newEmail = matcher.group(2);
+    return new ParsedToken(id, newEmail);
+  }
+
+  private void checkEmailRegistrationToken() {
+    checkState(
+        emailRegistrationToken != null, "'auth.registerEmailPrivateKey' not set in gerrit.config");
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/receive/HtmlParser.java b/java/com/google/gerrit/server/mail/receive/HtmlParser.java
new file mode 100644
index 0000000..d68f076
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/receive/HtmlParser.java
@@ -0,0 +1,173 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterators;
+import com.google.common.collect.PeekingIterator;
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+
+/** Provides functionality for parsing the HTML part of a {@link MailMessage}. */
+public class HtmlParser {
+
+  private static final ImmutableSet<String> MAIL_PROVIDER_EXTRAS =
+      ImmutableSet.of(
+          "gmail_extra", // "On 01/01/2017 User<user@gmail.com> wrote:"
+          "gmail_quote" // Used for quoting original content
+          );
+
+  private static final ImmutableSet<String> WHITELISTED_HTML_TAGS =
+      ImmutableSet.of(
+          "div", // Most user-typed comments are contained in a <div> tag
+          "a", // We allow links to be contained in a comment
+          "font" // Some email clients like nesting input in a new font tag
+          );
+
+  private HtmlParser() {}
+
+  /**
+   * Parses comments from html email.
+   *
+   * <p>This parser goes though all html elements in the email and checks for matching patterns. It
+   * keeps track of the last file and comments it encountered to know in which context a parsed
+   * comment belongs. It uses the href attributes of <a> tags to identify comments sent out by
+   * Gerrit as these are generally more reliable then the text captions.
+   *
+   * @param email the message as received from the email service
+   * @param comments a specific set of comments as sent out in the original notification email.
+   *     Comments are expected to be in the same order as they were sent out to in the email.
+   * @param changeUrl canonical change URL that points to the change on this Gerrit instance.
+   *     Example: https://go-review.googlesource.com/#/c/91570
+   * @return list of MailComments parsed from the html part of the email
+   */
+  public static List<MailComment> parse(
+      MailMessage email, Collection<Comment> comments, String changeUrl) {
+    // TODO(hiesel) Add support for Gmail Mobile
+    // TODO(hiesel) Add tests for other popular email clients
+
+    // This parser goes though all html elements in the email and checks for
+    // matching patterns. It keeps track of the last file and comments it
+    // encountered to know in which context a parsed comment belongs.
+    // It uses the href attributes of <a> tags to identify comments sent out by
+    // Gerrit as these are generally more reliable then the text captions.
+    List<MailComment> parsedComments = new ArrayList<>();
+    Document d = Jsoup.parse(email.htmlContent());
+    PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
+
+    String lastEncounteredFileName = null;
+    Comment lastEncounteredComment = null;
+    for (Element e : d.body().getAllElements()) {
+      String elementName = e.tagName();
+      boolean isInBlockQuote =
+          e.parents()
+              .stream()
+              .anyMatch(
+                  p ->
+                      p.tagName().equals("blockquote")
+                          || MAIL_PROVIDER_EXTRAS.contains(p.className()));
+
+      if (elementName.equals("a")) {
+        String href = e.attr("href");
+        // Check if there is still a next comment that could be contained in
+        // this <a> tag
+        if (!iter.hasNext()) {
+          continue;
+        }
+        Comment perspectiveComment = iter.peek();
+        if (href.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
+          if (lastEncounteredFileName == null
+              || !lastEncounteredFileName.equals(perspectiveComment.key.filename)) {
+            // Not a file-level comment, but users could have typed a comment
+            // right after this file annotation to create a new file-level
+            // comment. If this file has a file-level comment, we have already
+            // set lastEncounteredComment to that file-level comment when we
+            // encountered the file link and should not reset it now.
+            lastEncounteredFileName = perspectiveComment.key.filename;
+            lastEncounteredComment = null;
+          } else if (perspectiveComment.lineNbr == 0) {
+            // This was originally a file-level comment
+            lastEncounteredComment = perspectiveComment;
+            iter.next();
+          }
+          continue;
+        } else if (ParserUtil.isCommentUrl(href, changeUrl, perspectiveComment)) {
+          // This is a regular inline comment
+          lastEncounteredComment = perspectiveComment;
+          iter.next();
+          continue;
+        }
+      }
+
+      if (isInBlockQuote) {
+        // There is no user-input in quoted text
+        continue;
+      }
+      if (!WHITELISTED_HTML_TAGS.contains(elementName)) {
+        // We only accept a set of whitelisted tags that can contain user input
+        continue;
+      }
+      if (elementName.equals("a") && e.attr("href").startsWith("mailto:")) {
+        // We don't accept mailto: links in general as they often appear in reply-to lines
+        // (User<user@gmail.com> wrote: ...)
+        continue;
+      }
+
+      // This is a comment typed by the user
+      // Replace non-breaking spaces and trim string
+      String content = e.ownText().replace('\u00a0', ' ').trim();
+      boolean isLink = elementName.equals("a");
+      if (!Strings.isNullOrEmpty(content)) {
+        if (lastEncounteredComment == null && lastEncounteredFileName == null) {
+          // Remove quotation line, email signature and
+          // "Sent from my xyz device"
+          content = ParserUtil.trimQuotation(content);
+          // TODO(hiesel) Add more sanitizer
+          if (!Strings.isNullOrEmpty(content)) {
+            ParserUtil.appendOrAddNewComment(
+                new MailComment(
+                    content, null, null, MailComment.CommentType.CHANGE_MESSAGE, isLink),
+                parsedComments);
+          }
+        } else if (lastEncounteredComment == null) {
+          ParserUtil.appendOrAddNewComment(
+              new MailComment(
+                  content,
+                  lastEncounteredFileName,
+                  null,
+                  MailComment.CommentType.FILE_COMMENT,
+                  isLink),
+              parsedComments);
+        } else {
+          ParserUtil.appendOrAddNewComment(
+              new MailComment(
+                  content,
+                  null,
+                  lastEncounteredComment,
+                  MailComment.CommentType.INLINE_COMMENT,
+                  isLink),
+              parsedComments);
+        }
+      }
+    }
+    return parsedComments;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java b/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
rename to java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
diff --git a/java/com/google/gerrit/server/mail/receive/MailComment.java b/java/com/google/gerrit/server/mail/receive/MailComment.java
new file mode 100644
index 0000000..8571e12
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/receive/MailComment.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.Objects;
+
+/** A comment parsed from inbound email */
+public class MailComment {
+  enum CommentType {
+    CHANGE_MESSAGE,
+    FILE_COMMENT,
+    INLINE_COMMENT
+  }
+
+  CommentType type;
+  Comment inReplyTo;
+  String fileName;
+  String message;
+  boolean isLink;
+
+  public MailComment() {}
+
+  public MailComment(
+      String message, String fileName, Comment inReplyTo, CommentType type, boolean isLink) {
+    this.message = message;
+    this.fileName = fileName;
+    this.inReplyTo = inReplyTo;
+    this.type = type;
+    this.isLink = isLink;
+  }
+
+  /**
+   * Checks if the provided comment concerns the same exact spot in the change. This is basically an
+   * equals method except that the message is not checked.
+   */
+  public boolean isSameCommentPath(MailComment c) {
+    return Objects.equals(fileName, c.fileName)
+        && Objects.equals(inReplyTo, c.inReplyTo)
+        && Objects.equals(type, c.type);
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/receive/MailMessage.java b/java/com/google/gerrit/server/mail/receive/MailMessage.java
new file mode 100644
index 0000000..0d20464
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/receive/MailMessage.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.mail.Address;
+import java.time.Instant;
+
+/**
+ * A simplified representation of an RFC 2045-2047 mime email message used for representing received
+ * emails inside Gerrit. It is populated by the MailParser after MailReceiver has received a
+ * message. Transformations done by the parser include stitching mime parts together, transforming
+ * all content to UTF-16 and removing attachments.
+ *
+ * <p>A valid {@link MailMessage} contains at least the following fields: id, from, to, subject and
+ * dateReceived.
+ */
+@AutoValue
+public abstract class MailMessage {
+  // Unique Identifier
+  public abstract String id();
+  // Envelop Information
+  public abstract Address from();
+
+  public abstract ImmutableList<Address> to();
+
+  public abstract ImmutableList<Address> cc();
+  // Metadata
+  public abstract Instant dateReceived();
+
+  public abstract ImmutableList<String> additionalHeaders();
+  // Content
+  public abstract String subject();
+
+  @Nullable
+  public abstract String textContent();
+
+  @Nullable
+  public abstract String htmlContent();
+  // Raw content as received over the wire
+  @Nullable
+  public abstract ImmutableList<Integer> rawContent();
+
+  @Nullable
+  public abstract String rawContentUTF();
+
+  public static Builder builder() {
+    return new AutoValue_MailMessage.Builder();
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder id(String val);
+
+    public abstract Builder from(Address val);
+
+    public abstract ImmutableList.Builder<Address> toBuilder();
+
+    public Builder addTo(Address val) {
+      toBuilder().add(val);
+      return this;
+    }
+
+    public abstract ImmutableList.Builder<Address> ccBuilder();
+
+    public Builder addCc(Address val) {
+      ccBuilder().add(val);
+      return this;
+    }
+
+    public abstract Builder dateReceived(Instant instant);
+
+    public abstract ImmutableList.Builder<String> additionalHeadersBuilder();
+
+    public Builder addAdditionalHeader(String val) {
+      additionalHeadersBuilder().add(val);
+      return this;
+    }
+
+    public abstract Builder subject(String val);
+
+    public abstract Builder textContent(String val);
+
+    public abstract Builder htmlContent(String val);
+
+    public abstract Builder rawContent(ImmutableList<Integer> val);
+
+    public abstract Builder rawContentUTF(String val);
+
+    public abstract MailMessage build();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMetadata.java b/java/com/google/gerrit/server/mail/receive/MailMetadata.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMetadata.java
rename to java/com/google/gerrit/server/mail/receive/MailMetadata.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailParsingException.java b/java/com/google/gerrit/server/mail/receive/MailParsingException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailParsingException.java
rename to java/com/google/gerrit/server/mail/receive/MailParsingException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java
rename to java/com/google/gerrit/server/mail/receive/MailProcessor.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java
rename to java/com/google/gerrit/server/mail/receive/MailReceiver.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailTransferException.java b/java/com/google/gerrit/server/mail/receive/MailTransferException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailTransferException.java
rename to java/com/google/gerrit/server/mail/receive/MailTransferException.java
diff --git a/java/com/google/gerrit/server/mail/receive/MetadataParser.java b/java/com/google/gerrit/server/mail/receive/MetadataParser.java
new file mode 100644
index 0000000..88c54f9
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/receive/MetadataParser.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import static com.google.gerrit.server.mail.MetadataName.toFooterWithDelimiter;
+import static com.google.gerrit.server.mail.MetadataName.toHeaderWithDelimiter;
+
+import com.google.common.base.Strings;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.server.mail.MailUtil;
+import com.google.gerrit.server.mail.MetadataName;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Parse metadata from inbound email */
+public class MetadataParser {
+  private static final Logger log = LoggerFactory.getLogger(MetadataParser.class);
+
+  public static MailMetadata parse(MailMessage m) {
+    MailMetadata metadata = new MailMetadata();
+    // Find author
+    metadata.author = m.from().getEmail();
+
+    // Check email headers for X-Gerrit-<Name>
+    for (String header : m.additionalHeaders()) {
+      if (header.startsWith(toHeaderWithDelimiter(MetadataName.CHANGE_NUMBER))) {
+        String num = header.substring(toHeaderWithDelimiter(MetadataName.CHANGE_NUMBER).length());
+        metadata.changeNumber = Ints.tryParse(num);
+      } else if (header.startsWith(toHeaderWithDelimiter(MetadataName.PATCH_SET))) {
+        String ps = header.substring(toHeaderWithDelimiter(MetadataName.PATCH_SET).length());
+        metadata.patchSet = Ints.tryParse(ps);
+      } else if (header.startsWith(toHeaderWithDelimiter(MetadataName.TIMESTAMP))) {
+        String ts = header.substring(toHeaderWithDelimiter(MetadataName.TIMESTAMP).length()).trim();
+        try {
+          metadata.timestamp = Timestamp.from(MailUtil.rfcDateformatter.parse(ts, Instant::from));
+        } catch (DateTimeParseException e) {
+          log.error("Mail: Error while parsing timestamp from header of message " + m.id(), e);
+        }
+      } else if (header.startsWith(toHeaderWithDelimiter(MetadataName.MESSAGE_TYPE))) {
+        metadata.messageType =
+            header.substring(toHeaderWithDelimiter(MetadataName.MESSAGE_TYPE).length());
+      }
+    }
+    if (metadata.hasRequiredFields()) {
+      return metadata;
+    }
+
+    // If the required fields were not yet found, continue to parse the text
+    if (!Strings.isNullOrEmpty(m.textContent())) {
+      String[] lines = m.textContent().replace("\r\n", "\n").split("\n");
+      extractFooters(lines, metadata, m);
+      if (metadata.hasRequiredFields()) {
+        return metadata;
+      }
+    }
+
+    // If the required fields were not yet found, continue to parse the HTML
+    // HTML footer are contained inside a <div> tag
+    if (!Strings.isNullOrEmpty(m.htmlContent())) {
+      String[] lines = m.htmlContent().replace("\r\n", "\n").split("</div>");
+      extractFooters(lines, metadata, m);
+      if (metadata.hasRequiredFields()) {
+        return metadata;
+      }
+    }
+
+    return metadata;
+  }
+
+  private static void extractFooters(String[] lines, MailMetadata metadata, MailMessage m) {
+    for (String line : lines) {
+      if (metadata.changeNumber == null && line.contains(MetadataName.CHANGE_NUMBER)) {
+        metadata.changeNumber =
+            Ints.tryParse(extractFooter(toFooterWithDelimiter(MetadataName.CHANGE_NUMBER), line));
+      } else if (metadata.patchSet == null && line.contains(MetadataName.PATCH_SET)) {
+        metadata.patchSet =
+            Ints.tryParse(extractFooter(toFooterWithDelimiter(MetadataName.PATCH_SET), line));
+      } else if (metadata.timestamp == null && line.contains(MetadataName.TIMESTAMP)) {
+        String ts = extractFooter(toFooterWithDelimiter(MetadataName.TIMESTAMP), line);
+        try {
+          metadata.timestamp = Timestamp.from(MailUtil.rfcDateformatter.parse(ts, Instant::from));
+        } catch (DateTimeParseException e) {
+          log.error("Mail: Error while parsing timestamp from footer of message " + m.id(), e);
+        }
+      } else if (metadata.messageType == null && line.contains(MetadataName.MESSAGE_TYPE)) {
+        metadata.messageType =
+            extractFooter(toFooterWithDelimiter(MetadataName.MESSAGE_TYPE), line);
+      }
+    }
+  }
+
+  private static String extractFooter(String key, String line) {
+    return line.substring(line.indexOf(key) + key.length(), line.length()).trim();
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/receive/ParserUtil.java b/java/com/google/gerrit/server/mail/receive/ParserUtil.java
new file mode 100644
index 0000000..b8309ab
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/receive/ParserUtil.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.List;
+import java.util.StringJoiner;
+import java.util.regex.Pattern;
+
+public class ParserUtil {
+  private static final Pattern SIMPLE_EMAIL_PATTERN =
+      Pattern.compile(
+          "[_A-Za-z0-9-\\+]+(\\.[_A-Za-z0-9-]+)*@[A-Za-z0-9-]+"
+              + "(\\.[A-Za-z0-9]+)*(\\.[A-Za-z]{2,})");
+
+  private ParserUtil() {}
+
+  /**
+   * Trims the quotation that email clients add Example: On Sun, Nov 20, 2016 at 10:33 PM,
+   * <gerrit@gerritcodereview.com> wrote:
+   *
+   * @param comment Comment parsed from an email.
+   * @return Trimmed comment.
+   */
+  public static String trimQuotation(String comment) {
+    StringJoiner j = new StringJoiner("\n");
+    String[] lines = comment.split("\n");
+    for (int i = 0; i < lines.length - 2; i++) {
+      j.add(lines[i]);
+    }
+
+    // Check if the last line contains the full quotation pattern (date + email)
+    String lastLine = lines[lines.length - 1];
+    if (containsQuotationPattern(lastLine)) {
+      if (lines.length > 1) {
+        j.add(lines[lines.length - 2]);
+      }
+      return j.toString().trim();
+    }
+
+    // Check if the second last line + the last line contain the full quotation pattern. This is
+    // necessary, as the quotation line can be split across the last two lines if it gets too long.
+    if (lines.length > 1) {
+      String lastLines = lines[lines.length - 2] + lastLine;
+      if (containsQuotationPattern(lastLines)) {
+        return j.toString().trim();
+      }
+    }
+
+    // Add the last two lines
+    if (lines.length > 1) {
+      j.add(lines[lines.length - 2]);
+    }
+    j.add(lines[lines.length - 1]);
+
+    return j.toString().trim();
+  }
+
+  /** Check if string is an inline comment url on a patch set or the base */
+  public static boolean isCommentUrl(String str, String changeUrl, Comment comment) {
+    int lineNbr = comment.range == null ? comment.lineNbr : comment.range.startLine;
+    return str.equals(filePath(changeUrl, comment) + "@" + lineNbr)
+        || str.equals(filePath(changeUrl, comment) + "@a" + lineNbr);
+  }
+
+  /** Generate the fully qualified filepath */
+  public static String filePath(String changeUrl, Comment comment) {
+    return changeUrl + "/" + comment.key.patchSetId + "/" + comment.key.filename;
+  }
+
+  /**
+   * When parsing mail content, we need to append comments prematurely since we are parsing
+   * block-by-block and never know what comes next. This can result in a comment being parsed as two
+   * comments when it spans multiple blocks. This method takes care of merging those blocks or
+   * adding a new comment to the list of appropriate.
+   */
+  public static void appendOrAddNewComment(MailComment comment, List<MailComment> comments) {
+    if (comments.isEmpty()) {
+      comments.add(comment);
+      return;
+    }
+    MailComment lastComment = Iterables.getLast(comments);
+
+    if (comment.isSameCommentPath(lastComment)) {
+      // Merge the two comments. Links should just be appended, while regular text that came from
+      // different <div> elements should be separated by a paragraph.
+      lastComment.message += (comment.isLink ? " " : "\n\n") + comment.message;
+      return;
+    }
+
+    comments.add(comment);
+  }
+
+  private static boolean containsQuotationPattern(String s) {
+    // Identifying the quotation line is hard, as it can be in any language.
+    // We identify this line by it's characteristics: It usually contains a
+    // valid email address, some digits for the date in groups of 1-4 in a row
+    // as well as some characters.
+
+    // Count occurrences of digit groups
+    int numConsecutiveDigits = 0;
+    int maxConsecutiveDigits = 0;
+    int numDigitGroups = 0;
+    for (char c : s.toCharArray()) {
+      if (c >= '0' && c <= '9') {
+        numConsecutiveDigits++;
+      } else if (numConsecutiveDigits > 0) {
+        maxConsecutiveDigits = Integer.max(maxConsecutiveDigits, numConsecutiveDigits);
+        numConsecutiveDigits = 0;
+        numDigitGroups++;
+      }
+    }
+    if (numDigitGroups < 4 || maxConsecutiveDigits > 4) {
+      return false;
+    }
+
+    // Check if the string contains an email address
+    return SIMPLE_EMAIL_PATTERN.matcher(s).find();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java b/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
rename to java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Protocol.java b/java/com/google/gerrit/server/mail/receive/Protocol.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Protocol.java
rename to java/com/google/gerrit/server/mail/receive/Protocol.java
diff --git a/java/com/google/gerrit/server/mail/receive/RawMailParser.java b/java/com/google/gerrit/server/mail/receive/RawMailParser.java
new file mode 100644
index 0000000..57fe21f
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/receive/RawMailParser.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.io.CharStreams;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.server.mail.Address;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import org.apache.james.mime4j.MimeException;
+import org.apache.james.mime4j.dom.Entity;
+import org.apache.james.mime4j.dom.Message;
+import org.apache.james.mime4j.dom.MessageBuilder;
+import org.apache.james.mime4j.dom.Multipart;
+import org.apache.james.mime4j.dom.TextBody;
+import org.apache.james.mime4j.dom.address.Mailbox;
+import org.apache.james.mime4j.message.DefaultMessageBuilder;
+
+/** Parses raw email content received through POP3 or IMAP into an internal {@link MailMessage}. */
+public class RawMailParser {
+  private static final ImmutableSet<String> MAIN_HEADERS =
+      ImmutableSet.of("to", "from", "cc", "date", "message-id", "subject", "content-type");
+
+  private RawMailParser() {}
+
+  /**
+   * Parses a MailMessage from a string.
+   *
+   * @param raw {@link String} payload as received over the wire
+   * @return parsed {@link MailMessage}
+   * @throws MailParsingException in case parsing fails
+   */
+  public static MailMessage parse(String raw) throws MailParsingException {
+    MailMessage.Builder messageBuilder = MailMessage.builder();
+    messageBuilder.rawContentUTF(raw);
+    Message mimeMessage;
+    try {
+      MessageBuilder builder = new DefaultMessageBuilder();
+      mimeMessage = builder.parseMessage(new ByteArrayInputStream(raw.getBytes(UTF_8)));
+    } catch (IOException | MimeException e) {
+      throw new MailParsingException("Can't parse email", e);
+    }
+    // Add general headers
+    if (mimeMessage.getMessageId() != null) {
+      messageBuilder.id(mimeMessage.getMessageId());
+    }
+    if (mimeMessage.getSubject() != null) {
+      messageBuilder.subject(mimeMessage.getSubject());
+    }
+    if (mimeMessage.getDate() != null) {
+      messageBuilder.dateReceived(mimeMessage.getDate().toInstant());
+    }
+
+    // Add From, To and Cc
+    if (mimeMessage.getFrom() != null && mimeMessage.getFrom().size() > 0) {
+      Mailbox from = mimeMessage.getFrom().get(0);
+      messageBuilder.from(new Address(from.getName(), from.getAddress()));
+    }
+    if (mimeMessage.getTo() != null) {
+      for (Mailbox m : mimeMessage.getTo().flatten()) {
+        messageBuilder.addTo(new Address(m.getName(), m.getAddress()));
+      }
+    }
+    if (mimeMessage.getCc() != null) {
+      for (Mailbox m : mimeMessage.getCc().flatten()) {
+        messageBuilder.addCc(new Address(m.getName(), m.getAddress()));
+      }
+    }
+
+    // Add additional headers
+    mimeMessage
+        .getHeader()
+        .getFields()
+        .stream()
+        .filter(f -> !MAIN_HEADERS.contains(f.getName().toLowerCase()))
+        .forEach(f -> messageBuilder.addAdditionalHeader(f.getName() + ": " + f.getBody()));
+
+    // Add text and html body parts
+    StringBuilder textBuilder = new StringBuilder();
+    StringBuilder htmlBuilder = new StringBuilder();
+    try {
+      handleMimePart(mimeMessage, textBuilder, htmlBuilder);
+    } catch (IOException e) {
+      throw new MailParsingException("Can't parse email", e);
+    }
+    messageBuilder.textContent(Strings.emptyToNull(textBuilder.toString()));
+    messageBuilder.htmlContent(Strings.emptyToNull(htmlBuilder.toString()));
+
+    try {
+      // build() will only succeed if all required attributes were set. We wrap
+      // the IllegalStateException in a MailParsingException indicating that
+      // required attributes are missing, so that the caller doesn't fall over.
+      return messageBuilder.build();
+    } catch (IllegalStateException e) {
+      throw new MailParsingException("Missing required attributes after email was parsed", e);
+    }
+  }
+
+  /**
+   * Parses a MailMessage from an array of characters. Note that the character array is int-typed.
+   * This method is only used by POP3, which specifies that all transferred characters are US-ASCII
+   * (RFC 6856). When reading the input in Java, io.Reader yields ints. These can be safely
+   * converted to chars as all US-ASCII characters fit in a char. If emails contain non-ASCII
+   * characters, such as UTF runes, these will be encoded in ASCII using either Base64 or
+   * quoted-printable encoding.
+   *
+   * @param chars Array as received over the wire
+   * @return Parsed {@link MailMessage}
+   * @throws MailParsingException in case parsing fails
+   */
+  public static MailMessage parse(int[] chars) throws MailParsingException {
+    StringBuilder b = new StringBuilder(chars.length);
+    for (int c : chars) {
+      b.append((char) c);
+    }
+
+    MailMessage.Builder messageBuilder = parse(b.toString()).toBuilder();
+    messageBuilder.rawContent(ImmutableList.copyOf(Ints.asList(chars)));
+    return messageBuilder.build();
+  }
+
+  /**
+   * Traverses a mime tree and parses out text and html parts. All other parts will be dropped.
+   *
+   * @param part {@code MimePart} to parse
+   * @param textBuilder {@link StringBuilder} to append all plaintext parts
+   * @param htmlBuilder {@link StringBuilder} to append all html parts
+   * @throws IOException in case of a failure while transforming the input to a {@link String}
+   */
+  private static void handleMimePart(
+      Entity part, StringBuilder textBuilder, StringBuilder htmlBuilder) throws IOException {
+    if (isPlainOrHtml(part.getMimeType()) && !isAttachment(part.getDispositionType())) {
+      TextBody tb = (TextBody) part.getBody();
+      String result =
+          CharStreams.toString(new InputStreamReader(tb.getInputStream(), tb.getMimeCharset()));
+      if (part.getMimeType().equals("text/plain")) {
+        textBuilder.append(result);
+      } else if (part.getMimeType().equals("text/html")) {
+        htmlBuilder.append(result);
+      }
+    } else if (isMultipart(part.getMimeType())) {
+      Multipart multipart = (Multipart) part.getBody();
+      for (Entity e : multipart.getBodyParts()) {
+        handleMimePart(e, textBuilder, htmlBuilder);
+      }
+    }
+  }
+
+  private static boolean isPlainOrHtml(String mimeType) {
+    return (mimeType.equals("text/plain") || mimeType.equals("text/html"));
+  }
+
+  private static boolean isMultipart(String mimeType) {
+    return mimeType.startsWith("multipart/");
+  }
+
+  private static boolean isAttachment(String dispositionType) {
+    return dispositionType != null && dispositionType.equals("attachment");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/TextParser.java b/java/com/google/gerrit/server/mail/receive/TextParser.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/TextParser.java
rename to java/com/google/gerrit/server/mail/receive/TextParser.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AbandonedSender.java b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AbandonedSender.java
rename to java/com/google/gerrit/server/mail/send/AbandonedSender.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
rename to java/com/google/gerrit/server/mail/send/AddKeySender.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddReviewerSender.java b/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
rename to java/com/google/gerrit/server/mail/send/AddReviewerSender.java
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
new file mode 100644
index 0000000..3e1dc92
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -0,0 +1,605 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.common.base.Splitter;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.template.soy.data.SoyListData;
+import com.google.template.soy.data.SoyMapData;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.text.MessageFormat;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.internal.JGitText;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.eclipse.jgit.util.TemporaryBuffer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Sends an email to one or more interested parties. */
+public abstract class ChangeEmail extends NotificationEmail {
+  private static final Logger log = LoggerFactory.getLogger(ChangeEmail.class);
+
+  protected static ChangeData newChangeData(
+      EmailArguments ea, Project.NameKey project, Change.Id id) {
+    return ea.changeDataFactory.create(ea.db.get(), project, id);
+  }
+
+  protected final Change change;
+  protected final ChangeData changeData;
+  protected ListMultimap<Account.Id, String> stars;
+  protected PatchSet patchSet;
+  protected PatchSetInfo patchSetInfo;
+  protected String changeMessage;
+  protected Timestamp timestamp;
+
+  protected ProjectState projectState;
+  protected Set<Account.Id> authors;
+  protected boolean emailOnlyAuthors;
+
+  protected ChangeEmail(EmailArguments ea, String mc, ChangeData cd) throws OrmException {
+    super(ea, mc, cd.change().getDest());
+    changeData = cd;
+    change = cd.change();
+    emailOnlyAuthors = false;
+  }
+
+  @Override
+  public void setFrom(Account.Id id) {
+    super.setFrom(id);
+
+    /** Is the from user in an email squelching group? */
+    try {
+      IdentifiedUser user = args.identifiedUserFactory.create(id);
+      args.permissionBackend.user(user).check(GlobalPermission.EMAIL_REVIEWERS);
+    } catch (AuthException | PermissionBackendException e) {
+      emailOnlyAuthors = true;
+    }
+  }
+
+  public void setPatchSet(PatchSet ps) {
+    patchSet = ps;
+  }
+
+  public void setPatchSet(PatchSet ps, PatchSetInfo psi) {
+    patchSet = ps;
+    patchSetInfo = psi;
+  }
+
+  public void setChangeMessage(String cm, Timestamp t) {
+    changeMessage = cm;
+    timestamp = t;
+  }
+
+  /** Format the message body by calling {@link #appendText(String)}. */
+  @Override
+  protected void format() throws EmailException {
+    formatChange();
+    appendText(textTemplate("ChangeFooter"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("ChangeFooterHtml"));
+    }
+    formatFooter();
+  }
+
+  /** Format the message body by calling {@link #appendText(String)}. */
+  protected abstract void formatChange() throws EmailException;
+
+  /**
+   * Format the message footer by calling {@link #appendText(String)}.
+   *
+   * @throws EmailException if an error occurred.
+   */
+  protected void formatFooter() throws EmailException {}
+
+  /** Setup the message headers and envelope (TO, CC, BCC). */
+  @Override
+  protected void init() throws EmailException {
+    if (args.projectCache != null) {
+      projectState = args.projectCache.get(change.getProject());
+    } else {
+      projectState = null;
+    }
+
+    if (patchSet == null) {
+      try {
+        patchSet = changeData.currentPatchSet();
+      } catch (OrmException err) {
+        patchSet = null;
+      }
+    }
+
+    if (patchSet != null) {
+      setHeader("X-Gerrit-PatchSet", patchSet.getPatchSetId() + "");
+      if (patchSetInfo == null) {
+        try {
+          patchSetInfo =
+              args.patchSetInfoFactory.get(args.db.get(), changeData.notes(), patchSet.getId());
+        } catch (PatchSetInfoNotAvailableException | OrmException err) {
+          patchSetInfo = null;
+        }
+      }
+    }
+    authors = getAuthors();
+
+    try {
+      stars = changeData.stars();
+    } catch (OrmException e) {
+      throw new EmailException("Failed to load stars for change " + change.getChangeId(), e);
+    }
+
+    super.init();
+    if (timestamp != null) {
+      setHeader("Date", new Date(timestamp.getTime()));
+    }
+    setChangeSubjectHeader();
+    setHeader("X-Gerrit-Change-Id", "" + change.getKey().get());
+    setHeader("X-Gerrit-Change-Number", "" + change.getChangeId());
+    setChangeUrlHeader();
+    setCommitIdHeader();
+
+    if (notify.ordinal() >= NotifyHandling.OWNER_REVIEWERS.ordinal()) {
+      try {
+        addByEmail(
+            RecipientType.CC, changeData.reviewersByEmail().byState(ReviewerStateInternal.CC));
+        addByEmail(
+            RecipientType.CC,
+            changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER));
+      } catch (OrmException e) {
+        throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
+      }
+    }
+  }
+
+  private void setChangeUrlHeader() {
+    final String u = getChangeUrl();
+    if (u != null) {
+      setHeader("X-Gerrit-ChangeURL", "<" + u + ">");
+    }
+  }
+
+  private void setCommitIdHeader() {
+    if (patchSet != null
+        && patchSet.getRevision() != null
+        && patchSet.getRevision().get() != null
+        && patchSet.getRevision().get().length() > 0) {
+      setHeader("X-Gerrit-Commit", patchSet.getRevision().get());
+    }
+  }
+
+  private void setChangeSubjectHeader() {
+    setHeader("Subject", textTemplate("ChangeSubject"));
+  }
+
+  /** Get a link to the change; null if the server doesn't know its own address. */
+  public String getChangeUrl() {
+    if (getGerritUrl() != null) {
+      final StringBuilder r = new StringBuilder();
+      r.append(getGerritUrl());
+      r.append(change.getChangeId());
+      return r.toString();
+    }
+    return null;
+  }
+
+  public String getChangeMessageThreadId() {
+    return "<gerrit."
+        + change.getCreatedOn().getTime()
+        + "."
+        + change.getKey().get()
+        + "@"
+        + this.getGerritHost()
+        + ">";
+  }
+
+  /** Format the sender's "cover letter", {@link #getCoverLetter()}. */
+  protected void formatCoverLetter() {
+    final String cover = getCoverLetter();
+    if (!"".equals(cover)) {
+      appendText(cover);
+      appendText("\n\n");
+    }
+  }
+
+  /** Get the text of the "cover letter". */
+  public String getCoverLetter() {
+    if (changeMessage != null) {
+      return changeMessage.trim();
+    }
+    return "";
+  }
+
+  /** Format the change message and the affected file list. */
+  protected void formatChangeDetail() {
+    appendText(getChangeDetail());
+  }
+
+  /** Create the change message and the affected file list. */
+  public String getChangeDetail() {
+    try {
+      StringBuilder detail = new StringBuilder();
+
+      if (patchSetInfo != null) {
+        detail.append(patchSetInfo.getMessage().trim()).append("\n");
+      } else {
+        detail.append(change.getSubject().trim()).append("\n");
+      }
+
+      if (patchSet != null) {
+        detail.append("---\n");
+        PatchList patchList = getPatchList();
+        for (PatchListEntry p : patchList.getPatches()) {
+          if (Patch.isMagic(p.getNewName())) {
+            continue;
+          }
+          detail
+              .append(p.getChangeType().getCode())
+              .append(" ")
+              .append(p.getNewName())
+              .append("\n");
+        }
+        detail.append(
+            MessageFormat.format(
+                "" //
+                    + "{0,choice,0#0 files|1#1 file|1<{0} files} changed, " //
+                    + "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
+                    + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
+                    + "\n",
+                patchList.getPatches().size() - 1, //
+                patchList.getInsertions(), //
+                patchList.getDeletions()));
+        detail.append("\n");
+      }
+      return detail.toString();
+    } catch (Exception err) {
+      log.warn("Cannot format change detail", err);
+      return "";
+    }
+  }
+
+  /** Get the patch list corresponding to this patch set. */
+  protected PatchList getPatchList() throws PatchListNotAvailableException {
+    if (patchSet != null) {
+      return args.patchListCache.get(change, patchSet);
+    }
+    throw new PatchListNotAvailableException("no patchSet specified");
+  }
+
+  /** Get the project entity the change is in; null if its been deleted. */
+  protected ProjectState getProjectState() {
+    return projectState;
+  }
+
+  /** Get the groups which own the project. */
+  protected Set<AccountGroup.UUID> getProjectOwners() {
+    final ProjectState r;
+
+    r = args.projectCache.get(change.getProject());
+    return r != null ? r.getOwners() : Collections.<AccountGroup.UUID>emptySet();
+  }
+
+  /** TO or CC all vested parties (change owner, patch set uploader, author). */
+  protected void rcptToAuthors(RecipientType rt) {
+    for (Account.Id id : authors) {
+      add(rt, id);
+    }
+  }
+
+  /** BCC any user who has starred this change. */
+  protected void bccStarredBy() {
+    if (!NotifyHandling.ALL.equals(notify)) {
+      return;
+    }
+
+    for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
+      if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
+        super.add(RecipientType.BCC, e.getKey());
+      }
+    }
+  }
+
+  protected void removeUsersThatIgnoredTheChange() {
+    for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
+      if (e.getValue().contains(StarredChangesUtil.IGNORE_LABEL)) {
+        AccountState accountState = args.accountCache.get(e.getKey());
+        if (accountState != null) {
+          removeUser(accountState.getAccount());
+        }
+      }
+    }
+  }
+
+  @Override
+  protected final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig)
+      throws OrmException {
+    if (!NotifyHandling.ALL.equals(notify)) {
+      return new Watchers();
+    }
+
+    ProjectWatch watch = new ProjectWatch(args, branch.getParentKey(), projectState, changeData);
+    return watch.getWatchers(type, includeWatchersFromNotifyConfig);
+  }
+
+  /** Any user who has published comments on this change. */
+  protected void ccAllApprovals() {
+    if (!NotifyHandling.ALL.equals(notify) && !NotifyHandling.OWNER_REVIEWERS.equals(notify)) {
+      return;
+    }
+
+    try {
+      for (Account.Id id : changeData.reviewers().all()) {
+        add(RecipientType.CC, id);
+      }
+    } catch (OrmException err) {
+      log.warn("Cannot CC users that reviewed updated change", err);
+    }
+  }
+
+  /** Users who have non-zero approval codes on the change. */
+  protected void ccExistingReviewers() {
+    if (!NotifyHandling.ALL.equals(notify) && !NotifyHandling.OWNER_REVIEWERS.equals(notify)) {
+      return;
+    }
+
+    try {
+      for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
+        add(RecipientType.CC, id);
+      }
+    } catch (OrmException err) {
+      log.warn("Cannot CC users that commented on updated change", err);
+    }
+  }
+
+  @Override
+  protected void add(RecipientType rt, Account.Id to) {
+    if (!emailOnlyAuthors || authors.contains(to)) {
+      super.add(rt, to);
+    }
+  }
+
+  @Override
+  protected boolean isVisibleTo(Account.Id to) throws OrmException, PermissionBackendException {
+    return args.permissionBackend
+        .user(args.identifiedUserFactory.create(to))
+        .change(changeData)
+        .database(args.db.get())
+        .test(ChangePermission.READ);
+  }
+
+  /** Find all users who are authors of any part of this change. */
+  protected Set<Account.Id> getAuthors() {
+    Set<Account.Id> authors = new HashSet<>();
+
+    switch (notify) {
+      case NONE:
+        break;
+      case ALL:
+      default:
+        if (patchSet != null) {
+          authors.add(patchSet.getUploader());
+        }
+        if (patchSetInfo != null) {
+          if (patchSetInfo.getAuthor().getAccount() != null) {
+            authors.add(patchSetInfo.getAuthor().getAccount());
+          }
+          if (patchSetInfo.getCommitter().getAccount() != null) {
+            authors.add(patchSetInfo.getCommitter().getAccount());
+          }
+        }
+        // $FALL-THROUGH$
+      case OWNER_REVIEWERS:
+      case OWNER:
+        authors.add(change.getOwner());
+        break;
+    }
+
+    return authors;
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+
+    soyContext.put("changeId", change.getKey().get());
+    soyContext.put("coverLetter", getCoverLetter());
+    soyContext.put("fromName", getNameFor(fromId));
+    soyContext.put("fromEmail", getNameEmailFor(fromId));
+    soyContext.put("diffLines", getDiffTemplateData());
+
+    soyContextEmailData.put("unifiedDiff", getUnifiedDiff());
+    soyContextEmailData.put("changeDetail", getChangeDetail());
+    soyContextEmailData.put("changeUrl", getChangeUrl());
+    soyContextEmailData.put("includeDiff", getIncludeDiff());
+
+    Map<String, String> changeData = new HashMap<>();
+
+    String subject = change.getSubject();
+    String originalSubject = change.getOriginalSubject();
+    changeData.put("subject", subject);
+    changeData.put("originalSubject", originalSubject);
+    changeData.put("shortSubject", shortenSubject(subject));
+    changeData.put("shortOriginalSubject", shortenSubject(originalSubject));
+
+    changeData.put("ownerName", getNameFor(change.getOwner()));
+    changeData.put("ownerEmail", getNameEmailFor(change.getOwner()));
+    changeData.put("changeNumber", Integer.toString(change.getChangeId()));
+    soyContext.put("change", changeData);
+
+    Map<String, Object> patchSetData = new HashMap<>();
+    patchSetData.put("patchSetId", patchSet.getPatchSetId());
+    patchSetData.put("refName", patchSet.getRefName());
+    soyContext.put("patchSet", patchSetData);
+
+    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());
+    footers.add("Gerrit-Change-Number: " + Integer.toString(change.getChangeId()));
+    footers.add("Gerrit-PatchSet: " + patchSet.getPatchSetId());
+    footers.add("Gerrit-Owner: " + getNameEmailFor(change.getOwner()));
+    if (change.getAssignee() != null) {
+      footers.add("Gerrit-Assignee: " + getNameEmailFor(change.getAssignee()));
+    }
+    for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
+      footers.add("Gerrit-Reviewer: " + reviewer);
+    }
+    for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
+      footers.add("Gerrit-CC: " + reviewer);
+    }
+  }
+
+  /**
+   * A shortened subject is the subject limited to 72 characters, with an ellipsis if it exceeds
+   * that limit.
+   */
+  private static String shortenSubject(String subject) {
+    if (subject.length() < 73) {
+      return subject;
+    }
+    return subject.substring(0, 69) + "...";
+  }
+
+  private Set<String> getEmailsByState(ReviewerStateInternal state) {
+    Set<String> reviewers = new TreeSet<>();
+    try {
+      for (Account.Id who : changeData.reviewers().byState(state)) {
+        reviewers.add(getNameEmailFor(who));
+      }
+    } catch (OrmException e) {
+      log.warn("Cannot get change reviewers", e);
+    }
+    return reviewers;
+  }
+
+  public boolean getIncludeDiff() {
+    return args.settings.includeDiff;
+  }
+
+  private static final int HEAP_EST_SIZE = 32 * 1024;
+
+  /** Show patch set as unified difference. */
+  public String getUnifiedDiff() {
+    PatchList patchList;
+    try {
+      patchList = getPatchList();
+      if (patchList.getOldId() == null) {
+        // Octopus merges are not well supported for diff output by Gerrit.
+        // 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 "";
+    }
+
+    int maxSize = args.settings.maximumDiffSize;
+    TemporaryBuffer.Heap buf = new TemporaryBuffer.Heap(Math.min(HEAP_EST_SIZE, maxSize), maxSize);
+    try (DiffFormatter fmt = new DiffFormatter(buf)) {
+      try (Repository git = args.server.openRepository(change.getProject())) {
+        try {
+          fmt.setRepository(git);
+          fmt.setDetectRenames(true);
+          fmt.format(patchList.getOldId(), patchList.getNewId());
+          return RawParseUtils.decode(buf.toByteArray());
+        } catch (IOException e) {
+          if (JGitText.get().inMemoryBufferLimitExceeded.equals(e.getMessage())) {
+            return "";
+          }
+          log.error("Cannot format patch", e);
+          return "";
+        }
+      } catch (IOException e) {
+        log.error("Cannot open repository to format patch", e);
+        return "";
+      }
+    }
+  }
+
+  /**
+   * Generate a Soy list of maps representing each line of the unified diff. The line maps will have
+   * a 'type' key which maps to one of 'common', 'add' or 'remove' and a 'text' key which maps to
+   * the line's content.
+   */
+  private SoyListData getDiffTemplateData() {
+    SoyListData result = new SoyListData();
+    Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
+    for (String diffLine : lineSplitter.split(getUnifiedDiff())) {
+      SoyMapData lineData = new SoyMapData();
+      lineData.put("text", diffLine);
+
+      // Skip empty lines and lines that look like diff headers.
+      if (diffLine.isEmpty() || diffLine.startsWith("---") || diffLine.startsWith("+++")) {
+        lineData.put("type", "common");
+      } else {
+        switch (diffLine.charAt(0)) {
+          case '+':
+            lineData.put("type", "add");
+            break;
+          case '-':
+            lineData.put("type", "remove");
+            break;
+          default:
+            lineData.put("type", "common");
+            break;
+        }
+      }
+      result.add(lineData);
+    }
+    return result;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentFormatter.java b/java/com/google/gerrit/server/mail/send/CommentFormatter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentFormatter.java
rename to java/com/google/gerrit/server/mail/send/CommentFormatter.java
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
new file mode 100644
index 0000000..8055273
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -0,0 +1,545 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.common.data.FilenameComparator;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.common.errors.NoSuchEntityException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.mail.MailUtil;
+import com.google.gerrit.server.mail.receive.Protocol;
+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;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Send comments, after the author of them hit used Publish Comments in the UI. */
+public class CommentSender extends ReplyToChangeSender {
+  private static final Logger log = LoggerFactory.getLogger(CommentSender.class);
+
+  public interface Factory {
+    CommentSender create(Project.NameKey project, Change.Id id);
+  }
+
+  private class FileCommentGroup {
+    public String filename;
+    public int patchSetId;
+    public PatchFile fileData;
+    public List<Comment> comments = new ArrayList<>();
+
+    /** @return a web link to the given patch set and file. */
+    public String getLink() {
+      String url = getGerritUrl();
+      if (url == null) {
+        return null;
+      }
+
+      return new StringBuilder()
+          .append(url)
+          .append("#/c/")
+          .append(change.getId())
+          .append('/')
+          .append(patchSetId)
+          .append('/')
+          .append(KeyUtil.encode(filename))
+          .toString();
+    }
+
+    /**
+     * @return A title for the group, i.e. "Commit Message", "Merge List", or "File [[filename]]".
+     */
+    public String getTitle() {
+      if (Patch.COMMIT_MSG.equals(filename)) {
+        return "Commit Message";
+      } else if (Patch.MERGE_LIST.equals(filename)) {
+        return "Merge List";
+      } else {
+        return "File " + filename;
+      }
+    }
+  }
+
+  private List<Comment> inlineComments = Collections.emptyList();
+  private String patchSetComment;
+  private List<LabelVote> labels = Collections.emptyList();
+  private final CommentsUtil commentsUtil;
+  private final boolean incomingEmailEnabled;
+  private final String replyToAddress;
+
+  @Inject
+  public CommentSender(
+      EmailArguments ea,
+      CommentsUtil commentsUtil,
+      @GerritServerConfig Config cfg,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id)
+      throws OrmException {
+    super(ea, "comment", newChangeData(ea, project, id));
+    this.commentsUtil = commentsUtil;
+    this.incomingEmailEnabled =
+        cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal()
+            > Protocol.NONE.ordinal();
+    this.replyToAddress = cfg.getString("sendemail", null, "replyToAddress");
+  }
+
+  public void setComments(List<Comment> comments) throws OrmException {
+    inlineComments = comments;
+
+    Set<String> paths = new HashSet<>();
+    for (Comment c : comments) {
+      if (!Patch.isMagic(c.key.filename)) {
+        paths.add(c.key.filename);
+      }
+    }
+    changeData.setCurrentFilePaths(Ordering.natural().sortedCopy(paths));
+  }
+
+  public void setPatchSetComment(String comment) {
+    this.patchSetComment = comment;
+  }
+
+  public void setLabels(List<LabelVote> labels) {
+    this.labels = labels;
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    if (notify.compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
+      ccAllApprovals();
+    }
+    if (notify.compareTo(NotifyHandling.ALL) >= 0) {
+      bccStarredBy();
+      includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate());
+    }
+    removeUsersThatIgnoredTheChange();
+
+    // Add header that enables identifying comments on parsed email.
+    // Grouping is currently done by timestamp.
+    setHeader("X-Gerrit-Comment-Date", timestamp);
+
+    if (incomingEmailEnabled) {
+      if (replyToAddress == null) {
+        // Remove Reply-To and use outbound SMTP (default) instead.
+        removeHeader("Reply-To");
+      } else {
+        setHeader("Reply-To", replyToAddress);
+      }
+    }
+  }
+
+  @Override
+  public void formatChange() throws EmailException {
+    appendText(textTemplate("Comment"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("CommentHtml"));
+    }
+  }
+
+  @Override
+  public void formatFooter() throws EmailException {
+    appendText(textTemplate("CommentFooter"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("CommentFooterHtml"));
+    }
+  }
+
+  /**
+   * @return a list of FileCommentGroup objects representing the inline comments grouped by the
+   *     file.
+   */
+  private List<CommentSender.FileCommentGroup> getGroupedInlineComments(Repository repo) {
+    List<CommentSender.FileCommentGroup> groups = new ArrayList<>();
+    // Get the patch list:
+    PatchList patchList = null;
+    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);
+      }
+    }
+
+    // Loop over the comments and collect them into groups based on the file
+    // location of the comment.
+    FileCommentGroup currentGroup = null;
+    for (Comment c : inlineComments) {
+      // If it's a new group:
+      if (currentGroup == null
+          || !c.key.filename.equals(currentGroup.filename)
+          || c.key.patchSetId != currentGroup.patchSetId) {
+        currentGroup = new FileCommentGroup();
+        currentGroup.filename = c.key.filename;
+        currentGroup.patchSetId = c.key.patchSetId;
+        groups.add(currentGroup);
+        if (patchList != null) {
+          try {
+            currentGroup.fileData = new PatchFile(repo, patchList, c.key.filename);
+          } catch (IOException e) {
+            log.warn(
+                String.format(
+                    "Cannot load %s from %s in %s",
+                    c.key.filename, patchList.getNewId().name(), projectState.getName()),
+                e);
+            currentGroup.fileData = null;
+          }
+        }
+      }
+
+      if (currentGroup.fileData != null) {
+        currentGroup.comments.add(c);
+      }
+    }
+
+    Collections.sort(groups, Comparator.comparing(g -> g.filename, FilenameComparator.INSTANCE));
+    return groups;
+  }
+
+  private String getCommentLinePrefix(Comment comment) {
+    int lineNbr = comment.range == null ? comment.lineNbr : comment.range.startLine;
+    StringBuilder sb = new StringBuilder();
+    sb.append("PS").append(comment.key.patchSetId);
+    if (lineNbr != 0) {
+      sb.append(", Line ").append(lineNbr);
+    }
+    sb.append(": ");
+    return sb.toString();
+  }
+
+  /**
+   * @return the lines of file content in fileData that are encompassed by range on the given side.
+   */
+  private List<String> getLinesByRange(Comment.Range range, PatchFile fileData, short side) {
+    List<String> lines = new ArrayList<>();
+
+    for (int n = range.startLine; n <= range.endLine; n++) {
+      String s = getLine(fileData, side, n);
+      if (n == range.startLine && n == range.endLine && range.startChar < range.endChar) {
+        s = s.substring(Math.min(range.startChar, s.length()), Math.min(range.endChar, s.length()));
+      } else if (n == range.startLine) {
+        s = s.substring(Math.min(range.startChar, s.length()));
+      } else if (n == range.endLine) {
+        s = s.substring(0, Math.min(range.endChar, s.length()));
+      }
+      lines.add(s);
+    }
+    return lines;
+  }
+
+  /**
+   * Get the parent comment of a given comment.
+   *
+   * @param child the comment with a potential parent comment.
+   * @return an optional comment that will be present if the given comment has a parent, and is
+   *     empty if it does not.
+   */
+  private Optional<Comment> getParent(Comment child) {
+    if (child.parentUuid == null) {
+      return Optional.empty();
+    }
+
+    Comment.Key key = new Comment.Key(child.parentUuid, child.key.filename, child.key.patchSetId);
+    try {
+      return commentsUtil.getPublished(args.db.get(), changeData.notes(), key);
+    } catch (OrmException e) {
+      log.warn("Could not find the parent of this comment: " + child.toString());
+      return Optional.empty();
+    }
+  }
+
+  /**
+   * Retrieve the file lines referred to by a comment.
+   *
+   * @param comment The comment that refers to some file contents. The comment may be a line comment
+   *     or a ranged comment.
+   * @param fileData The file on which the comment appears.
+   * @return file contents referred to by the comment. If the comment is a line comment, the result
+   *     will be a list of one string. Otherwise it will be a list of one or more strings.
+   */
+  private List<String> getLinesOfComment(Comment comment, PatchFile fileData) {
+    List<String> lines = new ArrayList<>();
+    if (comment.lineNbr == 0) {
+      // file level comment has no line
+      return lines;
+    }
+    if (comment.range == null) {
+      lines.add(getLine(fileData, comment.side, comment.lineNbr));
+    } else {
+      lines.addAll(getLinesByRange(comment.range, fileData, comment.side));
+    }
+    return lines;
+  }
+
+  /**
+   * @return a shortened version of the given comment's message. Will be shortened to 100 characters
+   *     or the first line, or following the last period within the first 100 characters, whichever
+   *     is shorter. If the message is shortened, an ellipsis is appended.
+   */
+  protected static String getShortenedCommentMessage(String message) {
+    int threshold = 100;
+    String fullMessage = message.trim();
+    String msg = fullMessage;
+
+    if (msg.length() > threshold) {
+      msg = msg.substring(0, threshold);
+    }
+
+    int lf = msg.indexOf('\n');
+    int period = msg.lastIndexOf('.');
+
+    if (lf > 0) {
+      // Truncate if a line feed appears within the threshold.
+      msg = msg.substring(0, lf);
+
+    } else if (period > 0) {
+      // Otherwise truncate if there is a period within the threshold.
+      msg = msg.substring(0, period + 1);
+    }
+
+    // Append an ellipsis if the message has been truncated.
+    if (!msg.equals(fullMessage)) {
+      msg += " […]";
+    }
+
+    return msg;
+  }
+
+  protected static String getShortenedCommentMessage(Comment comment) {
+    return getShortenedCommentMessage(comment.message);
+  }
+
+  /**
+   * @return grouped inline comment data mapped to data structures that are suitable for passing
+   *     into Soy.
+   */
+  private List<Map<String, Object>> getCommentGroupsTemplateData(Repository repo) {
+    List<Map<String, Object>> commentGroups = new ArrayList<>();
+
+    for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
+      Map<String, Object> groupData = new HashMap<>();
+      groupData.put("link", group.getLink());
+      groupData.put("title", group.getTitle());
+      groupData.put("patchSetId", group.patchSetId);
+
+      List<Map<String, Object>> commentsList = new ArrayList<>();
+      for (Comment comment : group.comments) {
+        Map<String, Object> commentData = new HashMap<>();
+        commentData.put("lines", getLinesOfComment(comment, group.fileData));
+        commentData.put("message", comment.message.trim());
+        List<CommentFormatter.Block> blocks = CommentFormatter.parse(comment.message);
+        commentData.put("messageBlocks", commentBlocksToSoyData(blocks));
+
+        // Set the prefix.
+        String prefix = getCommentLinePrefix(comment);
+        commentData.put("linePrefix", prefix);
+        commentData.put("linePrefixEmpty", Strings.padStart(": ", prefix.length(), ' '));
+
+        // Set line numbers.
+        int startLine;
+        if (comment.range == null) {
+          startLine = comment.lineNbr;
+        } else {
+          startLine = comment.range.startLine;
+          commentData.put("endLine", comment.range.endLine);
+        }
+        commentData.put("startLine", startLine);
+
+        // Set the comment link.
+        if (comment.lineNbr == 0) {
+          commentData.put("link", group.getLink());
+        } else if (comment.side == 0) {
+          commentData.put("link", group.getLink() + "@a" + startLine);
+        } else {
+          commentData.put("link", group.getLink() + '@' + startLine);
+        }
+
+        // Set robot comment data.
+        if (comment instanceof RobotComment) {
+          RobotComment robotComment = (RobotComment) comment;
+          commentData.put("isRobotComment", true);
+          commentData.put("robotId", robotComment.robotId);
+          commentData.put("robotRunId", robotComment.robotRunId);
+          commentData.put("robotUrl", robotComment.url);
+        } else {
+          commentData.put("isRobotComment", false);
+        }
+
+        // If the comment has a quote, don't bother loading the parent message.
+        if (!hasQuote(blocks)) {
+          // Set parent comment info.
+          Optional<Comment> parent = getParent(comment);
+          if (parent.isPresent()) {
+            commentData.put("parentMessage", getShortenedCommentMessage(parent.get()));
+          }
+        }
+
+        commentsList.add(commentData);
+      }
+      groupData.put("comments", commentsList);
+
+      commentGroups.add(groupData);
+    }
+    return commentGroups;
+  }
+
+  private List<Map<String, Object>> commentBlocksToSoyData(List<CommentFormatter.Block> blocks) {
+    return blocks
+        .stream()
+        .map(
+            b -> {
+              Map<String, Object> map = new HashMap<>();
+              switch (b.type) {
+                case PARAGRAPH:
+                  map.put("type", "paragraph");
+                  map.put("text", b.text);
+                  break;
+                case PRE_FORMATTED:
+                  map.put("type", "pre");
+                  map.put("text", b.text);
+                  break;
+                case QUOTE:
+                  map.put("type", "quote");
+                  map.put("quotedBlocks", commentBlocksToSoyData(b.quotedBlocks));
+                  break;
+                case LIST:
+                  map.put("type", "list");
+                  map.put("items", b.items);
+                  break;
+              }
+              return map;
+            })
+        .collect(toList());
+  }
+
+  private boolean hasQuote(List<CommentFormatter.Block> blocks) {
+    for (CommentFormatter.Block block : blocks) {
+      if (block.type == CommentFormatter.BlockType.QUOTE) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private Repository getRepository() {
+    try {
+      return args.server.openRepository(projectState.getNameKey());
+    } catch (IOException e) {
+      return null;
+    }
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    boolean hasComments = false;
+    try (Repository repo = getRepository()) {
+      List<Map<String, Object>> files = getCommentGroupsTemplateData(repo);
+      soyContext.put("commentFiles", files);
+      hasComments = !files.isEmpty();
+    }
+
+    soyContext.put(
+        "patchSetCommentBlocks", commentBlocksToSoyData(CommentFormatter.parse(patchSetComment)));
+    soyContext.put("labels", getLabelVoteSoyData(labels));
+    soyContext.put("commentCount", inlineComments.size());
+    soyContext.put("commentTimestamp", getCommentTimestamp());
+    soyContext.put(
+        "coverLetterBlocks", commentBlocksToSoyData(CommentFormatter.parse(getCoverLetter())));
+
+    footers.add("Gerrit-Comment-Date: " + getCommentTimestamp());
+    footers.add("Gerrit-HasComments: " + (hasComments ? "Yes" : "No"));
+    footers.add("Gerrit-HasLabels: " + (labels.isEmpty() ? "No" : "Yes"));
+  }
+
+  private String getLine(PatchFile fileInfo, short side, int lineNbr) {
+    try {
+      return fileInfo.getLine(side, lineNbr);
+    } catch (IOException err) {
+      // Default to the empty string if the file cannot be safely read.
+      log.warn(String.format("Failed to read file on side %d", side), err);
+      return "";
+    } catch (IndexOutOfBoundsException err) {
+      // Default to the empty string if the given line number does not appear
+      // in the file.
+      log.debug(String.format("Failed to get line number of file on side %d", side), err);
+      return "";
+    } catch (NoSuchEntityException err) {
+      // Default to the empty string if the side cannot be found.
+      log.warn(String.format("Side %d of file didn't exist", side), err);
+      return "";
+    }
+  }
+
+  private List<Map<String, Object>> getLabelVoteSoyData(List<LabelVote> votes) {
+    List<Map<String, Object>> result = new ArrayList<>();
+    for (LabelVote vote : votes) {
+      Map<String, Object> data = new HashMap<>();
+      data.put("label", vote.label());
+
+      // Soy needs the short to be cast as an int for it to get converted to the
+      // correct tamplate type.
+      data.put("value", (int) vote.value());
+      result.add(data);
+    }
+    return result;
+  }
+
+  private String getCommentTimestamp() {
+    // Grouping is currently done by timestamp.
+    return MailUtil.rfcDateformatter.format(
+        ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneId.of("UTC")));
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
new file mode 100644
index 0000000..8956f10
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+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;
+
+/** Notify interested parties of a brand new change. */
+public class CreateChangeSender extends NewChangeSender {
+  private static final Logger log = LoggerFactory.getLogger(CreateChangeSender.class);
+
+  public interface Factory {
+    CreateChangeSender create(Project.NameKey project, Change.Id id);
+  }
+
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  public CreateChangeSender(
+      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
+  protected void init() throws EmailException {
+    super.init();
+
+    try {
+      // Upgrade watching owners from CC and BCC to TO.
+      Watchers matching =
+          getWatchers(NotifyType.NEW_CHANGES, !change.isWorkInProgress() && !change.isPrivate());
+      // 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);
+      add(RecipientType.BCC, matching.bcc);
+    } catch (OrmException err) {
+      // Just don't CC everyone. Better to send a partial message to those
+      // we already have queued up then to fail deliver entirely to people
+      // who have a lower interest in the change.
+      log.warn("Cannot notify watchers for new change", err);
+    }
+
+    includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
+  }
+
+  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/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
rename to java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
rename to java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
new file mode 100644
index 0000000..83a1c25
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.common.Nullable;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mail.EmailSettings;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.ssh.SshAdvertisedAddresses;
+import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.template.soy.tofu.SoyTofu;
+import java.util.List;
+import org.eclipse.jgit.lib.PersonIdent;
+
+public class EmailArguments {
+  final GitRepositoryManager server;
+  final ProjectCache projectCache;
+  final PermissionBackend permissionBackend;
+  final GroupBackend groupBackend;
+  final AccountCache accountCache;
+  final PatchListCache patchListCache;
+  final ApprovalsUtil approvalsUtil;
+  final FromAddressGenerator fromAddressGenerator;
+  final EmailSender emailSender;
+  final PatchSetInfoFactory patchSetInfoFactory;
+  final IdentifiedUser.GenericFactory identifiedUserFactory;
+  final ChangeNotes.Factory changeNotesFactory;
+  final AnonymousUser anonymousUser;
+  final String anonymousCowardName;
+  final PersonIdent gerritPersonIdent;
+  final Provider<String> urlProvider;
+  final AllProjectsName allProjectsName;
+  final List<String> sshAddresses;
+  final SitePaths site;
+
+  final ChangeQueryBuilder queryBuilder;
+  final Provider<ReviewDb> db;
+  final ChangeData.Factory changeDataFactory;
+  final SoyTofu soyTofu;
+  final EmailSettings settings;
+  final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
+  final Provider<InternalAccountQuery> accountQueryProvider;
+  final OutgoingEmailValidator validator;
+
+  @Inject
+  EmailArguments(
+      GitRepositoryManager server,
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      GroupBackend groupBackend,
+      AccountCache accountCache,
+      PatchListCache patchListCache,
+      ApprovalsUtil approvalsUtil,
+      FromAddressGenerator fromAddressGenerator,
+      EmailSender emailSender,
+      PatchSetInfoFactory patchSetInfoFactory,
+      GenericFactory identifiedUserFactory,
+      ChangeNotes.Factory changeNotesFactory,
+      AnonymousUser anonymousUser,
+      @AnonymousCowardName String anonymousCowardName,
+      GerritPersonIdentProvider gerritPersonIdentProvider,
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      AllProjectsName allProjectsName,
+      ChangeQueryBuilder queryBuilder,
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      @MailTemplates SoyTofu soyTofu,
+      EmailSettings settings,
+      @SshAdvertisedAddresses List<String> sshAddresses,
+      SitePaths site,
+      DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners,
+      Provider<InternalAccountQuery> accountQueryProvider,
+      OutgoingEmailValidator validator) {
+    this.server = server;
+    this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+    this.groupBackend = groupBackend;
+    this.accountCache = accountCache;
+    this.patchListCache = patchListCache;
+    this.approvalsUtil = approvalsUtil;
+    this.fromAddressGenerator = fromAddressGenerator;
+    this.emailSender = emailSender;
+    this.patchSetInfoFactory = patchSetInfoFactory;
+    this.identifiedUserFactory = identifiedUserFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.anonymousUser = anonymousUser;
+    this.anonymousCowardName = anonymousCowardName;
+    this.gerritPersonIdent = gerritPersonIdentProvider.get();
+    this.urlProvider = urlProvider;
+    this.allProjectsName = allProjectsName;
+    this.queryBuilder = queryBuilder;
+    this.db = db;
+    this.changeDataFactory = changeDataFactory;
+    this.soyTofu = soyTofu;
+    this.settings = settings;
+    this.sshAddresses = sshAddresses;
+    this.site = site;
+    this.outgoingEmailValidationListeners = outgoingEmailValidationListeners;
+    this.accountQueryProvider = accountQueryProvider;
+    this.validator = validator;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailHeader.java b/java/com/google/gerrit/server/mail/send/EmailHeader.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailHeader.java
rename to java/com/google/gerrit/server/mail/send/EmailHeader.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailSender.java b/java/com/google/gerrit/server/mail/send/EmailSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailSender.java
rename to java/com/google/gerrit/server/mail/send/EmailSender.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java b/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
rename to java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
rename to java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java b/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java
rename to java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailTemplates.java b/java/com/google/gerrit/server/mail/send/MailTemplates.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailTemplates.java
rename to java/com/google/gerrit/server/mail/send/MailTemplates.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
rename to java/com/google/gerrit/server/mail/send/MergedSender.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
rename to java/com/google/gerrit/server/mail/send/NewChangeSender.java
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
new file mode 100644
index 0000000..c192dfa
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.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.Branch;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
+import com.google.gwtorm.server.OrmException;
+import java.util.HashMap;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Common class for notifications that are related to a project and branch */
+public abstract class NotificationEmail extends OutgoingEmail {
+  private static final Logger log = LoggerFactory.getLogger(NotificationEmail.class);
+
+  protected Branch.NameKey branch;
+
+  protected NotificationEmail(EmailArguments ea, String mc, Branch.NameKey branch) {
+    super(ea, mc);
+    this.branch = branch;
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+    setListIdHeader();
+  }
+
+  private void setListIdHeader() {
+    // Set a reasonable list id so that filters can be used to sort messages
+    setHeader(
+        "List-Id",
+        "<gerrit-" + branch.getParentKey().get().replace('/', '-') + "." + getGerritHost() + ">");
+    if (getSettingsUrl() != null) {
+      setHeader("List-Unsubscribe", "<" + getSettingsUrl() + ">");
+    }
+  }
+
+  /** Include users and groups that want notification of events. */
+  protected void includeWatchers(NotifyType type) {
+    includeWatchers(type, true);
+  }
+
+  /** Include users and groups that want notification of events. */
+  protected void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
+    try {
+      Watchers matching = getWatchers(type, includeWatchersFromNotifyConfig);
+      add(RecipientType.TO, matching.to);
+      add(RecipientType.CC, matching.cc);
+      add(RecipientType.BCC, matching.bcc);
+    } catch (OrmException err) {
+      // Just don't CC everyone. Better to send a partial message to those
+      // we already have queued up then to fail deliver entirely to people
+      // who have a lower interest in the change.
+      log.warn("Cannot BCC watchers for " + type, err);
+    }
+  }
+
+  /** Returns all watchers that are relevant */
+  protected abstract Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig)
+      throws OrmException;
+
+  /** Add users or email addresses to the TO, CC, or BCC list. */
+  protected void add(RecipientType type, Watchers.List list) {
+    for (Account.Id user : list.accounts) {
+      add(type, user);
+    }
+    for (Address addr : list.emails) {
+      add(type, addr);
+    }
+  }
+
+  public String getSshHost() {
+    String host = Iterables.getFirst(args.sshAddresses, null);
+    if (host == null) {
+      return null;
+    }
+    if (host.startsWith("*:")) {
+      return getGerritHost() + host.substring(1);
+    }
+    return host;
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+
+    String projectName = branch.getParentKey().get();
+    soyContext.put("projectName", projectName);
+    // shortProjectName is the project name with the path abbreviated.
+    soyContext.put("shortProjectName", projectName.replaceAll("/.*/", "..."));
+
+    soyContextEmailData.put("sshHost", getSshHost());
+
+    Map<String, String> branchData = new HashMap<>();
+    branchData.put("shortName", branch.getShortName());
+    soyContext.put("branch", branchData);
+
+    footers.add("Gerrit-Project: " + branch.getParentKey().get());
+    footers.add("Gerrit-Branch: " + branch.getShortName());
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
new file mode 100644
index 0000000..8eb55fa
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -0,0 +1,605 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.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 com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.UserIdentity;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.EmailHeader.AddressList;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.gwtorm.server.OrmException;
+import com.google.template.soy.data.SanitizedContent;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.StringJoiner;
+import org.eclipse.jgit.util.SystemReader;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Sends an email to one or more interested parties. */
+public abstract class OutgoingEmail {
+  private static final Logger log = LoggerFactory.getLogger(OutgoingEmail.class);
+
+  private static final String HDR_TO = "To";
+  private static final String HDR_CC = "CC";
+
+  protected String messageClass;
+  private final HashSet<Account.Id> rcptTo = new HashSet<>();
+  private final Map<String, EmailHeader> headers;
+  private final Set<Address> smtpRcptTo = new HashSet<>();
+  private Address smtpFromAddress;
+  private StringBuilder textBody;
+  private StringBuilder htmlBody;
+  private ListMultimap<RecipientType, Account.Id> accountsToNotify = ImmutableListMultimap.of();
+  protected Map<String, Object> soyContext;
+  protected Map<String, Object> soyContextEmailData;
+  protected List<String> footers;
+  protected final EmailArguments args;
+  protected Account.Id fromId;
+  protected NotifyHandling notify = NotifyHandling.ALL;
+
+  protected OutgoingEmail(EmailArguments ea, String mc) {
+    args = ea;
+    messageClass = mc;
+    headers = new LinkedHashMap<>();
+  }
+
+  public void setFrom(Account.Id id) {
+    fromId = id;
+  }
+
+  public void setNotify(NotifyHandling notify) {
+    this.notify = checkNotNull(notify);
+  }
+
+  public void setAccountsToNotify(ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    this.accountsToNotify = checkNotNull(accountsToNotify);
+  }
+
+  /**
+   * Format and enqueue the message for delivery.
+   *
+   * @throws EmailException
+   */
+  public void send() throws EmailException {
+    if (NotifyHandling.NONE.equals(notify) && accountsToNotify.isEmpty()) {
+      return;
+    }
+
+    if (!args.emailSender.isEnabled()) {
+      // Server has explicitly disabled email sending.
+      //
+      return;
+    }
+
+    init();
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("HeaderHtml"));
+    }
+    format();
+    appendText(textTemplate("Footer"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("FooterHtml"));
+    }
+
+    Set<Address> smtpRcptToPlaintextOnly = new HashSet<>();
+    if (shouldSendMessage()) {
+      if (fromId != null) {
+        AccountState fromUser = args.accountCache.get(fromId);
+        if (fromUser != null) {
+          GeneralPreferencesInfo senderPrefs = fromUser.getGeneralPreferences();
+          if (senderPrefs != null && senderPrefs.getEmailStrategy() == CC_ON_OWN_COMMENTS) {
+            // If we are impersonating a user, make sure they receive a CC of
+            // this message so they can always review and audit what we sent
+            // on their behalf to others.
+            //
+            add(RecipientType.CC, fromId);
+          } else if (!accountsToNotify.containsValue(fromId) && rcptTo.remove(fromId)) {
+            // If they don't want a copy, but we queued one up anyway,
+            // drop them from the recipient lists.
+            //
+            removeUser(fromUser.getAccount());
+          }
+        }
+      }
+      // Check the preferences of all recipients. If any user has disabled
+      // his email notifications then drop him from recipients' list.
+      // In addition, check if users only want to receive plaintext email.
+      for (Account.Id id : rcptTo) {
+        AccountState thisUser = args.accountCache.get(id);
+        if (thisUser != null) {
+          Account thisUserAccount = thisUser.getAccount();
+          GeneralPreferencesInfo prefs = thisUser.getGeneralPreferences();
+          if (prefs == null || prefs.getEmailStrategy() == DISABLED) {
+            removeUser(thisUserAccount);
+          } else if (useHtml() && prefs.getEmailFormat() == EmailFormat.PLAINTEXT) {
+            removeUser(thisUserAccount);
+            smtpRcptToPlaintextOnly.add(
+                new Address(thisUserAccount.getFullName(), thisUserAccount.getPreferredEmail()));
+          }
+        }
+        if (smtpRcptTo.isEmpty() && smtpRcptToPlaintextOnly.isEmpty()) {
+          return;
+        }
+      }
+
+      // Set Reply-To only if it hasn't been set by a child class
+      // Reply-To will already be populated for the message types where Gerrit supports
+      // inbound email replies.
+      if (!headers.containsKey("Reply-To")) {
+        StringJoiner j = new StringJoiner(", ");
+        if (fromId != null) {
+          Address address = toAddress(fromId);
+          if (address != null) {
+            j.add(address.getEmail());
+          }
+        }
+        smtpRcptTo.stream().forEach(a -> j.add(a.getEmail()));
+        smtpRcptToPlaintextOnly.stream().forEach(a -> j.add(a.getEmail()));
+        setHeader("Reply-To", j.toString());
+      }
+
+      String textPart = textBody.toString();
+      OutgoingEmailValidationListener.Args va = new OutgoingEmailValidationListener.Args();
+      va.messageClass = messageClass;
+      va.smtpFromAddress = smtpFromAddress;
+      va.smtpRcptTo = smtpRcptTo;
+      va.headers = headers;
+      va.body = textPart;
+
+      if (useHtml()) {
+        va.htmlBody = htmlBody.toString();
+      } else {
+        va.htmlBody = null;
+      }
+
+      for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
+        try {
+          validator.validateOutgoingEmail(va);
+        } catch (ValidationException e) {
+          return;
+        }
+      }
+
+      if (!smtpRcptTo.isEmpty()) {
+        // Send multipart message
+        args.emailSender.send(va.smtpFromAddress, va.smtpRcptTo, va.headers, va.body, va.htmlBody);
+      }
+
+      if (!smtpRcptToPlaintextOnly.isEmpty()) {
+        // Send plaintext message
+        Map<String, EmailHeader> shallowCopy = new HashMap<>();
+        shallowCopy.putAll(headers);
+        // Remove To and Cc
+        shallowCopy.remove(HDR_TO);
+        shallowCopy.remove(HDR_CC);
+        for (Address a : smtpRcptToPlaintextOnly) {
+          // Add new To
+          EmailHeader.AddressList to = new EmailHeader.AddressList();
+          to.add(a);
+          shallowCopy.put(HDR_TO, to);
+        }
+        args.emailSender.send(va.smtpFromAddress, smtpRcptToPlaintextOnly, shallowCopy, va.body);
+      }
+    }
+  }
+
+  /** Format the message body by calling {@link #appendText(String)}. */
+  protected abstract void format() throws EmailException;
+
+  /**
+   * Setup the message headers and envelope (TO, CC, BCC).
+   *
+   * @throws EmailException if an error occurred.
+   */
+  protected void init() throws EmailException {
+    setupSoyContext();
+
+    smtpFromAddress = args.fromAddressGenerator.from(fromId);
+    setHeader("Date", new Date());
+    headers.put("From", new EmailHeader.AddressList(smtpFromAddress));
+    headers.put(HDR_TO, new EmailHeader.AddressList());
+    headers.put(HDR_CC, new EmailHeader.AddressList());
+    setHeader("Message-ID", "");
+
+    for (RecipientType recipientType : accountsToNotify.keySet()) {
+      add(recipientType, accountsToNotify.get(recipientType));
+    }
+
+    setHeader("X-Gerrit-MessageType", messageClass);
+    textBody = new StringBuilder();
+    htmlBody = new StringBuilder();
+
+    if (fromId != null && args.fromAddressGenerator.isGenericAddress(fromId)) {
+      appendText(getFromLine());
+    }
+  }
+
+  protected String getFromLine() {
+    final Account account = args.accountCache.get(fromId).getAccount();
+    final String name = account.getFullName();
+    final String email = account.getPreferredEmail();
+    StringBuilder f = new StringBuilder();
+
+    if ((name != null && !name.isEmpty()) || (email != null && !email.isEmpty())) {
+      f.append("From");
+      if (name != null && !name.isEmpty()) {
+        f.append(" ").append(name);
+      }
+      if (email != null && !email.isEmpty()) {
+        f.append(" <").append(email).append(">");
+      }
+      f.append(":\n\n");
+    }
+    return f.toString();
+  }
+
+  public String getGerritHost() {
+    if (getGerritUrl() != null) {
+      try {
+        return new URL(getGerritUrl()).getHost();
+      } catch (MalformedURLException e) {
+        // Try something else.
+      }
+    }
+
+    // Fall back onto whatever the local operating system thinks
+    // this server is called. We hopefully didn't get here as a
+    // good admin would have configured the canonical url.
+    //
+    return SystemReader.getInstance().getHostname();
+  }
+
+  public String getSettingsUrl() {
+    if (getGerritUrl() != null) {
+      final StringBuilder r = new StringBuilder();
+      r.append(getGerritUrl());
+      r.append("settings");
+      return r.toString();
+    }
+    return null;
+  }
+
+  public String getGerritUrl() {
+    return args.urlProvider.get();
+  }
+
+  /** Set a header in the outgoing message. */
+  protected void setHeader(String name, String value) {
+    headers.put(name, new EmailHeader.String(value));
+  }
+
+  /** Remove a header from the outgoing message. */
+  protected void removeHeader(String name) {
+    headers.remove(name);
+  }
+
+  protected void setHeader(String name, Date date) {
+    headers.put(name, new EmailHeader.Date(date));
+  }
+
+  /** Append text to the outgoing email body. */
+  protected void appendText(String text) {
+    if (text != null) {
+      textBody.append(text);
+    }
+  }
+
+  /** Append html to the outgoing email body. */
+  protected void appendHtml(String html) {
+    if (html != null) {
+      htmlBody.append(html);
+    }
+  }
+
+  /** Lookup a human readable name for an account, usually the "full name". */
+  protected String getNameFor(Account.Id accountId) {
+    if (accountId == null) {
+      return args.gerritPersonIdent.getName();
+    }
+
+    final Account userAccount = args.accountCache.get(accountId).getAccount();
+    String name = userAccount.getFullName();
+    if (name == null) {
+      name = userAccount.getPreferredEmail();
+    }
+    if (name == null) {
+      name = args.anonymousCowardName + " #" + accountId;
+    }
+    return name;
+  }
+
+  /**
+   * Gets the human readable name and email for an account; if neither are available, returns the
+   * Anonymous Coward name.
+   *
+   * @param accountId user to fetch.
+   * @return name/email of account, or Anonymous Coward if unset.
+   */
+  public String getNameEmailFor(Account.Id accountId) {
+    AccountState who = args.accountCache.get(accountId);
+    String name = who.getAccount().getFullName();
+    String email = who.getAccount().getPreferredEmail();
+
+    if (name != null && email != null) {
+      return name + " <" + email + ">";
+
+    } else if (name != null) {
+      return name;
+    } else if (email != null) {
+      return email;
+
+    } else /* (name == null && email == null) */ {
+      return args.anonymousCowardName + " #" + accountId;
+    }
+  }
+
+  /**
+   * Gets the human readable name and email for an account; if both are unavailable, returns the
+   * username. If no username is set, this function returns null.
+   *
+   * @param accountId user to fetch.
+   * @return name/email of account, username, or null if unset.
+   */
+  public String getUserNameEmailFor(Account.Id accountId) {
+    AccountState who = args.accountCache.get(accountId);
+    String name = who.getAccount().getFullName();
+    String email = who.getAccount().getPreferredEmail();
+
+    if (name != null && email != null) {
+      return name + " <" + email + ">";
+    } else if (email != null) {
+      return email;
+    } else if (name != null) {
+      return name;
+    }
+    String username = who.getUserName();
+    if (username != null) {
+      return username;
+    }
+    return null;
+  }
+
+  protected boolean shouldSendMessage() {
+    if (textBody.length() == 0) {
+      // If we have no message body, don't send.
+      return false;
+    }
+
+    if (smtpRcptTo.isEmpty()) {
+      // If we have nobody to send this message to, then all of our
+      // selection filters previously for this type of message were
+      // unable to match a destination. Don't bother sending it.
+      return false;
+    }
+
+    if ((accountsToNotify == null || accountsToNotify.isEmpty())
+        && smtpRcptTo.size() == 1
+        && rcptTo.size() == 1
+        && rcptTo.contains(fromId)) {
+      // If the only recipient is also the sender, don't bother.
+      //
+      return false;
+    }
+
+    return true;
+  }
+
+  /** Schedule this message for delivery to the listed accounts. */
+  protected void add(RecipientType rt, Collection<Account.Id> list) {
+    add(rt, list, false);
+  }
+
+  /** Schedule this message for delivery to the listed accounts. */
+  protected void add(RecipientType rt, Collection<Account.Id> list, boolean override) {
+    for (final Account.Id id : list) {
+      add(rt, id, override);
+    }
+  }
+
+  /** Schedule this message for delivery to the listed address. */
+  protected void addByEmail(RecipientType rt, Collection<Address> list) {
+    addByEmail(rt, list, false);
+  }
+
+  /** Schedule this message for delivery to the listed address. */
+  protected void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
+    for (final Address id : list) {
+      add(rt, id, override);
+    }
+  }
+
+  protected void add(RecipientType rt, UserIdentity who) {
+    add(rt, who, false);
+  }
+
+  protected void add(RecipientType rt, UserIdentity who, boolean override) {
+    if (who != null && who.getAccount() != null) {
+      add(rt, who.getAccount(), override);
+    }
+  }
+
+  /** Schedule delivery of this message to the given account. */
+  protected void add(RecipientType rt, Account.Id to) {
+    add(rt, to, false);
+  }
+
+  protected void add(RecipientType rt, Account.Id to, boolean override) {
+    try {
+      if (!rcptTo.contains(to) && isVisibleTo(to)) {
+        rcptTo.add(to);
+        add(rt, toAddress(to), override);
+      }
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Error reading database for account: " + to, e);
+    }
+  }
+
+  /**
+   * @param to account.
+   * @throws OrmException
+   * @throws PermissionBackendException
+   * @return whether this email is visible to the given account.
+   */
+  protected boolean isVisibleTo(Account.Id to) throws OrmException, PermissionBackendException {
+    return true;
+  }
+
+  /** Schedule delivery of this message to the given account. */
+  protected void add(RecipientType rt, Address addr) {
+    add(rt, addr, false);
+  }
+
+  protected void add(RecipientType rt, Address addr, boolean override) {
+    if (addr != null && addr.getEmail() != null && addr.getEmail().length() > 0) {
+      if (!args.validator.isValid(addr.getEmail())) {
+        log.warn("Not emailing " + addr.getEmail() + " (invalid email address)");
+      } else if (!args.emailSender.canEmail(addr.getEmail())) {
+        log.warn("Not emailing " + addr.getEmail() + " (prohibited by allowrcpt)");
+      } else {
+        if (!smtpRcptTo.add(addr)) {
+          if (!override) {
+            return;
+          }
+          ((EmailHeader.AddressList) headers.get(HDR_TO)).remove(addr.getEmail());
+          ((EmailHeader.AddressList) headers.get(HDR_CC)).remove(addr.getEmail());
+        }
+        switch (rt) {
+          case TO:
+            ((EmailHeader.AddressList) headers.get(HDR_TO)).add(addr);
+            break;
+          case CC:
+            ((EmailHeader.AddressList) headers.get(HDR_CC)).add(addr);
+            break;
+          case BCC:
+            break;
+        }
+      }
+    }
+  }
+
+  private Address toAddress(Account.Id id) {
+    final Account a = args.accountCache.get(id).getAccount();
+    final String e = a.getPreferredEmail();
+    if (!a.isActive() || e == null) {
+      return null;
+    }
+    return new Address(a.getFullName(), e);
+  }
+
+  protected void setupSoyContext() {
+    soyContext = new HashMap<>();
+    footers = new ArrayList<>();
+
+    soyContext.put("messageClass", messageClass);
+    soyContext.put("footers", footers);
+
+    soyContextEmailData = new HashMap<>();
+    soyContextEmailData.put("settingsUrl", getSettingsUrl());
+    soyContextEmailData.put("gerritHost", getGerritHost());
+    soyContextEmailData.put("gerritUrl", getGerritUrl());
+    soyContext.put("email", soyContextEmailData);
+  }
+
+  private String soyTemplate(String name, SanitizedContent.ContentKind kind) {
+    return args.soyTofu
+        .newRenderer("com.google.gerrit.server.mail.template." + name)
+        .setContentKind(kind)
+        .setData(soyContext)
+        .render();
+  }
+
+  protected String textTemplate(String name) {
+    return soyTemplate(name, SanitizedContent.ContentKind.TEXT);
+  }
+
+  protected String soyHtmlTemplate(String name) {
+    return soyTemplate(name, SanitizedContent.ContentKind.HTML);
+  }
+
+  public String joinStrings(Iterable<Object> in, String joiner) {
+    return joinStrings(in.iterator(), joiner);
+  }
+
+  public String joinStrings(Iterator<Object> in, String joiner) {
+    if (!in.hasNext()) {
+      return "";
+    }
+
+    Object first = in.next();
+    if (!in.hasNext()) {
+      return safeToString(first);
+    }
+
+    StringBuilder r = new StringBuilder();
+    r.append(safeToString(first));
+    while (in.hasNext()) {
+      r.append(joiner).append(safeToString(in.next()));
+    }
+    return r.toString();
+  }
+
+  protected void removeUser(Account user) {
+    String fromEmail = user.getPreferredEmail();
+    for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext(); ) {
+      if (j.next().getEmail().equals(fromEmail)) {
+        j.remove();
+      }
+    }
+    for (Map.Entry<String, EmailHeader> entry : headers.entrySet()) {
+      // Don't remove fromEmail from the "From" header though!
+      if (entry.getValue() instanceof AddressList && !entry.getKey().equals("From")) {
+        ((AddressList) entry.getValue()).remove(fromEmail);
+      }
+    }
+  }
+
+  private static String safeToString(Object obj) {
+    return obj != null ? obj.toString() : "";
+  }
+
+  protected final boolean useHtml() {
+    return args.settings.html && supportsHtml();
+  }
+
+  /** Override this method to enable HTML in a subclass. */
+  protected boolean supportsHtml() {
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java b/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
rename to java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
new file mode 100644
index 0000000..8b0cc7f
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -0,0 +1,247 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.git.NotifyConfig;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gwtorm.server.OrmException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ProjectWatch {
+  private static final Logger log = LoggerFactory.getLogger(ProjectWatch.class);
+
+  protected final EmailArguments args;
+  protected final ProjectState projectState;
+  protected final Project.NameKey project;
+  protected final ChangeData changeData;
+
+  public ProjectWatch(
+      EmailArguments args,
+      Project.NameKey project,
+      ProjectState projectState,
+      ChangeData changeData) {
+    this.args = args;
+    this.project = project;
+    this.projectState = projectState;
+    this.changeData = changeData;
+  }
+
+  /** Returns all watchers that are relevant */
+  public final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig)
+      throws OrmException {
+    Watchers matching = new Watchers();
+    Set<Account.Id> projectWatchers = new HashSet<>();
+
+    for (AccountState a : args.accountQueryProvider.get().byWatchedProject(project)) {
+      Account.Id accountId = a.getAccount().getId();
+      for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : a.getProjectWatches().entrySet()) {
+        if (project.equals(e.getKey().project())
+            && add(matching, accountId, e.getKey(), e.getValue(), type)) {
+          // We only want to prevent matching All-Projects if this filter hits
+          projectWatchers.add(accountId);
+        }
+      }
+    }
+
+    for (AccountState a : args.accountQueryProvider.get().byWatchedProject(args.allProjectsName)) {
+      for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : a.getProjectWatches().entrySet()) {
+        if (args.allProjectsName.equals(e.getKey().project())) {
+          Account.Id accountId = a.getAccount().getId();
+          if (!projectWatchers.contains(accountId)) {
+            add(matching, accountId, e.getKey(), e.getValue(), type);
+          }
+        }
+      }
+    }
+
+    if (!includeWatchersFromNotifyConfig) {
+      return matching;
+    }
+
+    for (ProjectState state : projectState.tree()) {
+      for (NotifyConfig nc : state.getConfig().getNotifyConfigs()) {
+        if (nc.isNotify(type)) {
+          try {
+            add(matching, nc);
+          } catch (QueryParseException e) {
+            log.warn(
+                "Project {} has invalid notify {} filter \"{}\": {}",
+                state.getName(),
+                nc.getName(),
+                nc.getFilter(),
+                e.getMessage());
+          }
+        }
+      }
+    }
+
+    return matching;
+  }
+
+  public static class Watchers {
+    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:
+          return to;
+        case CC:
+          return cc;
+        default:
+        case BCC:
+          return bcc;
+      }
+    }
+  }
+
+  private void add(Watchers matching, NotifyConfig nc) throws OrmException, QueryParseException {
+    for (GroupReference ref : nc.getGroups()) {
+      CurrentUser user = new SingleGroupUser(ref.getUUID());
+      if (filterMatch(user, nc.getFilter())) {
+        deliverToMembers(matching.list(nc.getHeader()), ref.getUUID());
+      }
+    }
+
+    if (!nc.getAddresses().isEmpty()) {
+      if (filterMatch(null, nc.getFilter())) {
+        matching.list(nc.getHeader()).emails.addAll(nc.getAddresses());
+      }
+    }
+  }
+
+  private void deliverToMembers(Watchers.List matching, AccountGroup.UUID startUUID) {
+    Set<AccountGroup.UUID> seen = new HashSet<>();
+    List<AccountGroup.UUID> q = new ArrayList<>();
+
+    seen.add(startUUID);
+    q.add(startUUID);
+
+    while (!q.isEmpty()) {
+      AccountGroup.UUID uuid = q.remove(q.size() - 1);
+      GroupDescription.Basic group = args.groupBackend.get(uuid);
+      if (group == null) {
+        continue;
+      }
+      if (!Strings.isNullOrEmpty(group.getEmailAddress())) {
+        // If the group has an email address, do not expand membership.
+        matching.emails.add(new Address(group.getEmailAddress()));
+        continue;
+      }
+
+      if (!(group instanceof GroupDescription.Internal)) {
+        // Non-internal groups cannot be expanded by the server.
+        continue;
+      }
+
+      GroupDescription.Internal ig = (GroupDescription.Internal) group;
+      matching.accounts.addAll(ig.getMembers());
+      for (AccountGroup.UUID m : ig.getSubgroups()) {
+        if (seen.add(m)) {
+          q.add(m);
+        }
+      }
+    }
+  }
+
+  private boolean add(
+      Watchers matching,
+      Account.Id accountId,
+      ProjectWatchKey key,
+      Set<NotifyType> watchedTypes,
+      NotifyType type)
+      throws OrmException {
+    IdentifiedUser user = args.identifiedUserFactory.create(accountId);
+
+    try {
+      if (filterMatch(user, key.filter())) {
+        // If we are set to notify on this type, add the user.
+        // Otherwise, still return true to stop notifications for this user.
+        if (watchedTypes.contains(type)) {
+          matching.bcc.accounts.add(accountId);
+        }
+        return true;
+      }
+    } catch (QueryParseException e) {
+      // Ignore broken filter expressions.
+    }
+    return false;
+  }
+
+  private boolean filterMatch(CurrentUser user, String filter)
+      throws OrmException, QueryParseException {
+    ChangeQueryBuilder qb;
+    Predicate<ChangeData> p = null;
+
+    if (user == null) {
+      qb = args.queryBuilder.asUser(args.anonymousUser);
+    } else {
+      qb = args.queryBuilder.asUser(user);
+      p = qb.is_visible();
+    }
+
+    if (filter != null) {
+      Predicate<ChangeData> filterPredicate = qb.parse(filter);
+      if (p == null) {
+        p = filterPredicate;
+      } else {
+        p = Predicate.and(filterPredicate, p);
+      }
+    }
+    return p == null || p.asMatchable().match(changeData);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
rename to java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
rename to java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java b/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
rename to java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RestoredSender.java b/java/com/google/gerrit/server/mail/send/RestoredSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RestoredSender.java
rename to java/com/google/gerrit/server/mail/send/RestoredSender.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RevertedSender.java b/java/com/google/gerrit/server/mail/send/RevertedSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RevertedSender.java
rename to java/com/google/gerrit/server/mail/send/RevertedSender.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java b/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
rename to java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
rename to java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/DefaultFileExtensionRegistry.java b/java/com/google/gerrit/server/mime/DefaultFileExtensionRegistry.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mime/DefaultFileExtensionRegistry.java
rename to java/com/google/gerrit/server/mime/DefaultFileExtensionRegistry.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java b/java/com/google/gerrit/server/mime/FileTypeRegistry.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
rename to java/com/google/gerrit/server/mime/FileTypeRegistry.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtil2Module.java b/java/com/google/gerrit/server/mime/MimeUtil2Module.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtil2Module.java
rename to java/com/google/gerrit/server/mime/MimeUtil2Module.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java b/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
rename to java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
rename to java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
new file mode 100644
index 0000000..e907ce6
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -0,0 +1,306 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.InternalUser;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Date;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** A single delta related to a specific patch-set of a change. */
+public abstract class AbstractChangeUpdate {
+  protected final NotesMigration migration;
+  protected final ChangeNoteUtil noteUtil;
+  protected final Account.Id accountId;
+  protected final Account.Id realAccountId;
+  protected final PersonIdent authorIdent;
+  protected final Date when;
+  private final long readOnlySkewMs;
+
+  @Nullable private final ChangeNotes notes;
+  private final Change change;
+  protected final PersonIdent serverIdent;
+
+  protected PatchSet.Id psId;
+  private ObjectId result;
+  protected boolean rootOnly;
+
+  protected AbstractChangeUpdate(
+      Config cfg,
+      NotesMigration migration,
+      ChangeNotes notes,
+      CurrentUser user,
+      PersonIdent serverIdent,
+      ChangeNoteUtil noteUtil,
+      Date when) {
+    this.migration = migration;
+    this.noteUtil = noteUtil;
+    this.serverIdent = new PersonIdent(serverIdent, when);
+    this.notes = notes;
+    this.change = notes.getChange();
+    this.accountId = accountId(user);
+    Account.Id realAccountId = accountId(user.getRealUser());
+    this.realAccountId = realAccountId != null ? realAccountId : accountId;
+    this.authorIdent = ident(noteUtil, serverIdent, user, when);
+    this.when = when;
+    this.readOnlySkewMs = NoteDbChangeState.getReadOnlySkew(cfg);
+  }
+
+  protected AbstractChangeUpdate(
+      Config cfg,
+      NotesMigration migration,
+      ChangeNoteUtil noteUtil,
+      PersonIdent serverIdent,
+      @Nullable ChangeNotes notes,
+      @Nullable Change change,
+      Account.Id accountId,
+      Account.Id realAccountId,
+      PersonIdent authorIdent,
+      Date when) {
+    checkArgument(
+        (notes != null && change == null) || (notes == null && change != null),
+        "exactly one of notes or change required");
+    this.migration = migration;
+    this.noteUtil = noteUtil;
+    this.serverIdent = new PersonIdent(serverIdent, when);
+    this.notes = notes;
+    this.change = change != null ? change : notes.getChange();
+    this.accountId = accountId;
+    this.realAccountId = realAccountId;
+    this.authorIdent = authorIdent;
+    this.when = when;
+    this.readOnlySkewMs = NoteDbChangeState.getReadOnlySkew(cfg);
+  }
+
+  private static void checkUserType(CurrentUser user) {
+    checkArgument(
+        (user instanceof IdentifiedUser) || (user instanceof InternalUser),
+        "user must be IdentifiedUser or InternalUser: %s",
+        user);
+  }
+
+  private static Account.Id accountId(CurrentUser u) {
+    checkUserType(u);
+    return (u instanceof IdentifiedUser) ? u.getAccountId() : null;
+  }
+
+  private static PersonIdent ident(
+      ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Date when) {
+    checkUserType(u);
+    if (u instanceof IdentifiedUser) {
+      return noteUtil.newIdent(u.asIdentifiedUser().getAccount(), when, serverIdent);
+    } else if (u instanceof InternalUser) {
+      return serverIdent;
+    }
+    throw new IllegalStateException();
+  }
+
+  public Change.Id getId() {
+    return change.getId();
+  }
+
+  /**
+   * @return notes for the state of this change prior to this update. If this update is part of a
+   *     series managed by a {@link NoteDbUpdateManager}, then this reflects the state prior to the
+   *     first update in the series. A null return value can only happen when the change is being
+   *     rebuilt from NoteDb. A change that is in the process of being created will result in a
+   *     non-null return value from this method, but a null return value from {@link
+   *     ChangeNotes#getRevision()}.
+   */
+  @Nullable
+  public ChangeNotes getNotes() {
+    return notes;
+  }
+
+  public Change getChange() {
+    return change;
+  }
+
+  public Date getWhen() {
+    return when;
+  }
+
+  public PatchSet.Id getPatchSetId() {
+    return psId;
+  }
+
+  public void setPatchSetId(PatchSet.Id psId) {
+    checkArgument(psId == null || psId.getParentKey().equals(getId()));
+    this.psId = psId;
+  }
+
+  public Account.Id getAccountId() {
+    checkState(
+        accountId != null,
+        "author identity for %s is not from an IdentifiedUser: %s",
+        getClass().getSimpleName(),
+        authorIdent.toExternalString());
+    return accountId;
+  }
+
+  public Account.Id getNullableAccountId() {
+    return accountId;
+  }
+
+  protected PersonIdent newIdent(Account author, Date when) {
+    return noteUtil.newIdent(author, when, serverIdent);
+  }
+
+  /** Whether no updates have been done. */
+  public abstract boolean isEmpty();
+
+  /** Wether this update can only be a root commit. */
+  public boolean isRootOnly() {
+    return rootOnly;
+  }
+
+  /**
+   * @return the NameKey for the project where the update will be stored, which is not necessarily
+   *     the same as the change's project.
+   */
+  protected abstract Project.NameKey getProjectName();
+
+  protected abstract String getRefName();
+
+  /**
+   * Apply this update to the given inserter.
+   *
+   * @param rw walk for reading back any objects needed for the update.
+   * @param ins inserter to write to; callers should not flush.
+   * @param curr the current tip of the branch prior to this update.
+   * @return commit ID produced by inserting this update's commit, or null if this update is a no-op
+   *     and should be skipped. The zero ID is a valid return value, and indicates the ref should be
+   *     deleted.
+   * @throws OrmException if a Gerrit-level error occurred.
+   * @throws IOException if a lower-level error occurred.
+   */
+  final ObjectId apply(RevWalk rw, ObjectInserter ins, ObjectId curr)
+      throws OrmException, IOException {
+    if (isEmpty()) {
+      return null;
+    }
+
+    // Allow this method to proceed even if migration.failChangeWrites() = true.
+    // This may be used by an auto-rebuilding step that the caller does not plan
+    // to actually store.
+
+    checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
+    checkNotReadOnly();
+    ObjectId z = ObjectId.zeroId();
+    CommitBuilder cb = applyImpl(rw, ins, curr);
+    if (cb == null) {
+      result = z;
+      return z; // Impl intends to delete the ref.
+    } else if (cb == NO_OP_UPDATE) {
+      return null; // Impl is a no-op.
+    }
+    cb.setAuthor(authorIdent);
+    cb.setCommitter(new PersonIdent(serverIdent, when));
+    if (!curr.equals(z)) {
+      cb.setParentId(curr);
+    } else {
+      cb.setParentIds(); // Ref is currently nonexistent, commit has no parents.
+    }
+    if (cb.getTreeId() == null) {
+      if (curr.equals(z)) {
+        cb.setTreeId(emptyTree(ins)); // No parent, assume empty tree.
+      } else {
+        RevCommit p = rw.parseCommit(curr);
+        cb.setTreeId(p.getTree()); // Copy tree from parent.
+      }
+    }
+    result = ins.insert(cb);
+    return result;
+  }
+
+  protected void checkNotReadOnly() throws OrmException {
+    ChangeNotes notes = getNotes();
+    if (notes == null) {
+      // Can only happen during ChangeRebuilder, which will never include a read-only lease.
+      return;
+    }
+    Timestamp until = notes.getReadOnlyUntil();
+    if (until != null && NoteDbChangeState.timeForReadOnlyCheck(readOnlySkewMs).before(until)) {
+      throw new OrmException("change " + notes.getChangeId() + " is read-only until " + until);
+    }
+  }
+
+  /**
+   * Create a commit containing the contents of this update.
+   *
+   * @param ins inserter to write to; callers should not flush.
+   * @return a new commit builder representing this commit, or null to indicate the meta ref should
+   *     be deleted as a result of this update. The parent, author, and committer fields in the
+   *     return value are always overwritten. The tree ID may be unset by this method, which
+   *     indicates to the caller that it should be copied from the parent commit. To indicate that
+   *     this update is a no-op (but this could not be determined by {@link #isEmpty()}), return the
+   *     sentinel {@link #NO_OP_UPDATE}.
+   * @throws OrmException if a Gerrit-level error occurred.
+   * @throws IOException if a lower-level error occurred.
+   */
+  protected abstract CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
+      throws OrmException, IOException;
+
+  protected static final CommitBuilder NO_OP_UPDATE = new CommitBuilder();
+
+  ObjectId getResult() {
+    return result;
+  }
+
+  public boolean allowWriteToNewRef() {
+    return true;
+  }
+
+  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
+    return ins.insert(Constants.OBJ_TREE, new byte[] {});
+  }
+
+  protected void verifyComment(Comment c) {
+    checkArgument(c.revId != null, "RevId required for comment: %s", c);
+    checkArgument(
+        c.author.getId().equals(getAccountId()),
+        "The author for the following comment does not match the author of this %s (%s): %s",
+        getClass().getSimpleName(),
+        getAccountId(),
+        c);
+    checkArgument(
+        c.getRealAuthor().getId().equals(realAccountId),
+        "The real author for the following comment does not match the real"
+            + " author of this %s (%s): %s",
+        getClass().getSimpleName(),
+        realAccountId,
+        c);
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeBundle.java b/java/com/google/gerrit/server/notedb/ChangeBundle.java
new file mode 100644
index 0000000..7714c6e
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/ChangeBundle.java
@@ -0,0 +1,1015 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.common.TimeUtil.truncateToSecond;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.checkColumns;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
+import static com.google.gerrit.server.notedb.ChangeBundle.Source.NOTE_DB;
+import static com.google.gerrit.server.notedb.ChangeBundle.Source.REVIEW_DB;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Function;
+import com.google.common.base.Predicate;
+import com.google.common.base.Predicates;
+import com.google.common.base.Strings;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.ImmutableCollection;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
+import com.google.gwtorm.client.Column;
+import com.google.gwtorm.server.OrmException;
+import java.lang.reflect.Field;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+
+/**
+ * A bundle of all entities rooted at a single {@link Change} entity.
+ *
+ * <p>See the {@link Change} Javadoc for a depiction of this tree. Bundles may be compared using
+ * {@link #differencesFrom(ChangeBundle)}, which normalizes out the minor implementation differences
+ * between ReviewDb and NoteDb.
+ */
+public class ChangeBundle {
+  public enum Source {
+    REVIEW_DB,
+    NOTE_DB;
+  }
+
+  public static ChangeBundle fromNotes(CommentsUtil commentsUtil, ChangeNotes notes)
+      throws OrmException {
+    return new ChangeBundle(
+        notes.getChange(),
+        notes.getChangeMessages(),
+        notes.getPatchSets().values(),
+        notes.getApprovals().values(),
+        Iterables.concat(
+            CommentsUtil.toPatchLineComments(
+                notes.getChangeId(),
+                PatchLineComment.Status.DRAFT,
+                commentsUtil.draftByChange(null, notes)),
+            CommentsUtil.toPatchLineComments(
+                notes.getChangeId(),
+                PatchLineComment.Status.PUBLISHED,
+                commentsUtil.publishedByChange(null, notes))),
+        notes.getReviewers(),
+        Source.NOTE_DB);
+  }
+
+  private static Map<ChangeMessage.Key, ChangeMessage> changeMessageMap(
+      Iterable<ChangeMessage> in) {
+    Map<ChangeMessage.Key, ChangeMessage> out =
+        new TreeMap<>(
+            new Comparator<ChangeMessage.Key>() {
+              @Override
+              public int compare(ChangeMessage.Key a, ChangeMessage.Key b) {
+                return ComparisonChain.start()
+                    .compare(a.getParentKey().get(), b.getParentKey().get())
+                    .compare(a.get(), b.get())
+                    .result();
+              }
+            });
+    for (ChangeMessage cm : in) {
+      out.put(cm.getKey(), cm);
+    }
+    return out;
+  }
+
+  // Unlike the *Map comparators, which are intended to make key lists diffable,
+  // this comparator sorts first on timestamp, then on every other field.
+  private static final Ordering<ChangeMessage> CHANGE_MESSAGE_ORDER =
+      new Ordering<ChangeMessage>() {
+        final Ordering<Comparable<?>> nullsFirst = Ordering.natural().nullsFirst();
+
+        @Override
+        public int compare(ChangeMessage a, ChangeMessage b) {
+          return ComparisonChain.start()
+              .compare(a.getWrittenOn(), b.getWrittenOn())
+              .compare(a.getKey().getParentKey().get(), b.getKey().getParentKey().get())
+              .compare(psId(a), psId(b), nullsFirst)
+              .compare(a.getAuthor(), b.getAuthor(), intKeyOrdering())
+              .compare(a.getMessage(), b.getMessage(), nullsFirst)
+              .result();
+        }
+
+        private Integer psId(ChangeMessage m) {
+          return m.getPatchSetId() != null ? m.getPatchSetId().get() : null;
+        }
+      };
+
+  private static ImmutableList<ChangeMessage> changeMessageList(Iterable<ChangeMessage> in) {
+    return CHANGE_MESSAGE_ORDER.immutableSortedCopy(in);
+  }
+
+  private static TreeMap<PatchSet.Id, PatchSet> patchSetMap(Iterable<PatchSet> in) {
+    TreeMap<PatchSet.Id, PatchSet> out =
+        new TreeMap<>(
+            new Comparator<PatchSet.Id>() {
+              @Override
+              public int compare(PatchSet.Id a, PatchSet.Id b) {
+                return patchSetIdChain(a, b).result();
+              }
+            });
+    for (PatchSet ps : in) {
+      out.put(ps.getId(), ps);
+    }
+    return out;
+  }
+
+  private static Map<PatchSetApproval.Key, PatchSetApproval> patchSetApprovalMap(
+      Iterable<PatchSetApproval> in) {
+    Map<PatchSetApproval.Key, PatchSetApproval> out =
+        new TreeMap<>(
+            new Comparator<PatchSetApproval.Key>() {
+              @Override
+              public int compare(PatchSetApproval.Key a, PatchSetApproval.Key b) {
+                return patchSetIdChain(a.getParentKey(), b.getParentKey())
+                    .compare(a.getAccountId().get(), b.getAccountId().get())
+                    .compare(a.getLabelId(), b.getLabelId())
+                    .result();
+              }
+            });
+    for (PatchSetApproval psa : in) {
+      out.put(psa.getKey(), psa);
+    }
+    return out;
+  }
+
+  private static Map<PatchLineComment.Key, PatchLineComment> patchLineCommentMap(
+      Iterable<PatchLineComment> in) {
+    Map<PatchLineComment.Key, PatchLineComment> out =
+        new TreeMap<>(
+            new Comparator<PatchLineComment.Key>() {
+              @Override
+              public int compare(PatchLineComment.Key a, PatchLineComment.Key b) {
+                Patch.Key pka = a.getParentKey();
+                Patch.Key pkb = b.getParentKey();
+                return patchSetIdChain(pka.getParentKey(), pkb.getParentKey())
+                    .compare(pka.get(), pkb.get())
+                    .compare(a.get(), b.get())
+                    .result();
+              }
+            });
+    for (PatchLineComment plc : in) {
+      out.put(plc.getKey(), plc);
+    }
+    return out;
+  }
+
+  private static ComparisonChain patchSetIdChain(PatchSet.Id a, PatchSet.Id b) {
+    return ComparisonChain.start()
+        .compare(a.getParentKey().get(), b.getParentKey().get())
+        .compare(a.get(), b.get());
+  }
+
+  static {
+    // Initialization-time checks that the column set hasn't changed since the
+    // last time this file was updated.
+    checkColumns(Change.Id.class, 1);
+
+    checkColumns(
+        Change.class, 1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 20, 21, 22, 23, 101);
+    checkColumns(ChangeMessage.Key.class, 1, 2);
+    checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6, 7);
+    checkColumns(PatchSet.Id.class, 1, 2);
+    checkColumns(PatchSet.class, 1, 2, 3, 4, 6, 8, 9);
+    checkColumns(PatchSetApproval.Key.class, 1, 2, 3);
+    checkColumns(PatchSetApproval.class, 1, 2, 3, 6, 7, 8);
+    checkColumns(PatchLineComment.Key.class, 1, 2);
+    checkColumns(PatchLineComment.class, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12);
+  }
+
+  private final Change change;
+  private final ImmutableList<ChangeMessage> changeMessages;
+  private final ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
+  private final ImmutableMap<PatchSetApproval.Key, PatchSetApproval> patchSetApprovals;
+  private final ImmutableMap<PatchLineComment.Key, PatchLineComment> patchLineComments;
+  private final ReviewerSet reviewers;
+  private final Source source;
+
+  public ChangeBundle(
+      Change change,
+      Iterable<ChangeMessage> changeMessages,
+      Iterable<PatchSet> patchSets,
+      Iterable<PatchSetApproval> patchSetApprovals,
+      Iterable<PatchLineComment> patchLineComments,
+      ReviewerSet reviewers,
+      Source source) {
+    this.change = checkNotNull(change);
+    this.changeMessages = changeMessageList(changeMessages);
+    this.patchSets = ImmutableSortedMap.copyOfSorted(patchSetMap(patchSets));
+    this.patchSetApprovals = ImmutableMap.copyOf(patchSetApprovalMap(patchSetApprovals));
+    this.patchLineComments = ImmutableMap.copyOf(patchLineCommentMap(patchLineComments));
+    this.reviewers = checkNotNull(reviewers);
+    this.source = checkNotNull(source);
+
+    for (ChangeMessage m : this.changeMessages) {
+      checkArgument(m.getKey().getParentKey().equals(change.getId()));
+    }
+    for (PatchSet.Id id : this.patchSets.keySet()) {
+      checkArgument(id.getParentKey().equals(change.getId()));
+    }
+    for (PatchSetApproval.Key k : this.patchSetApprovals.keySet()) {
+      checkArgument(k.getParentKey().getParentKey().equals(change.getId()));
+    }
+    for (PatchLineComment.Key k : this.patchLineComments.keySet()) {
+      checkArgument(k.getParentKey().getParentKey().getParentKey().equals(change.getId()));
+    }
+  }
+
+  public Change getChange() {
+    return change;
+  }
+
+  public ImmutableCollection<ChangeMessage> getChangeMessages() {
+    return changeMessages;
+  }
+
+  public ImmutableCollection<PatchSet> getPatchSets() {
+    return patchSets.values();
+  }
+
+  public ImmutableCollection<PatchSetApproval> getPatchSetApprovals() {
+    return patchSetApprovals.values();
+  }
+
+  public ImmutableCollection<PatchLineComment> getPatchLineComments() {
+    return patchLineComments.values();
+  }
+
+  public ReviewerSet getReviewers() {
+    return reviewers;
+  }
+
+  public Source getSource() {
+    return source;
+  }
+
+  public ImmutableList<String> differencesFrom(ChangeBundle o) {
+    List<String> diffs = new ArrayList<>();
+    diffChanges(diffs, this, o);
+    diffChangeMessages(diffs, this, o);
+    diffPatchSets(diffs, this, o);
+    diffPatchSetApprovals(diffs, this, o);
+    diffReviewers(diffs, this, o);
+    diffPatchLineComments(diffs, this, o);
+    return ImmutableList.copyOf(diffs);
+  }
+
+  private Timestamp getFirstPatchSetTime() {
+    if (patchSets.isEmpty()) {
+      return change.getCreatedOn();
+    }
+    return patchSets.firstEntry().getValue().getCreatedOn();
+  }
+
+  private Timestamp getLatestTimestamp() {
+    Ordering<Timestamp> o = Ordering.natural().nullsFirst();
+    Timestamp ts = null;
+    for (ChangeMessage cm : filterChangeMessages()) {
+      ts = o.max(ts, cm.getWrittenOn());
+    }
+    for (PatchSet ps : getPatchSets()) {
+      ts = o.max(ts, ps.getCreatedOn());
+    }
+    for (PatchSetApproval psa : filterPatchSetApprovals().values()) {
+      ts = o.max(ts, psa.getGranted());
+    }
+    for (PatchLineComment plc : filterPatchLineComments().values()) {
+      // Ignore draft comments, as they do not show up in the change meta graph.
+      if (plc.getStatus() != PatchLineComment.Status.DRAFT) {
+        ts = o.max(ts, plc.getWrittenOn());
+      }
+    }
+    return firstNonNull(ts, change.getLastUpdatedOn());
+  }
+
+  private Map<PatchSetApproval.Key, PatchSetApproval> filterPatchSetApprovals() {
+    return limitToValidPatchSets(patchSetApprovals, PatchSetApproval.Key::getParentKey);
+  }
+
+  private Map<PatchLineComment.Key, PatchLineComment> filterPatchLineComments() {
+    return limitToValidPatchSets(patchLineComments, k -> k.getParentKey().getParentKey());
+  }
+
+  private <K, V> Map<K, V> limitToValidPatchSets(Map<K, V> in, Function<K, PatchSet.Id> func) {
+    return Maps.filterKeys(in, Predicates.compose(validPatchSetPredicate(), func));
+  }
+
+  private Predicate<PatchSet.Id> validPatchSetPredicate() {
+    return patchSets::containsKey;
+  }
+
+  private Collection<ChangeMessage> filterChangeMessages() {
+    final Predicate<PatchSet.Id> validPatchSet = validPatchSetPredicate();
+    return Collections2.filter(
+        changeMessages,
+        m -> {
+          PatchSet.Id psId = m.getPatchSetId();
+          if (psId == null) {
+            return true;
+          }
+          return validPatchSet.apply(psId);
+        });
+  }
+
+  private static void diffChanges(List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+    Change a = bundleA.change;
+    Change b = bundleB.change;
+    String desc = a.getId().equals(b.getId()) ? describe(a.getId()) : "Changes";
+
+    boolean excludeCreatedOn = false;
+    boolean excludeCurrentPatchSetId = false;
+    boolean excludeTopic = false;
+    Timestamp aCreated = a.getCreatedOn();
+    Timestamp bCreated = b.getCreatedOn();
+    Timestamp aUpdated = a.getLastUpdatedOn();
+    Timestamp bUpdated = b.getLastUpdatedOn();
+
+    boolean excludeSubject = false;
+    boolean excludeOrigSubj = false;
+    // Subject is not technically a nullable field, but we observed some null
+    // subjects in the wild on googlesource.com, so treat null as empty.
+    String aSubj = Strings.nullToEmpty(a.getSubject());
+    String bSubj = Strings.nullToEmpty(b.getSubject());
+
+    // Allow created timestamp in NoteDb to be any of:
+    //  - The created timestamp of the change.
+    //  - The timestamp of the first remaining patch set.
+    //  - The last updated timestamp, if it is less than the created timestamp.
+    //
+    // Ignore subject if the NoteDb subject starts with the ReviewDb subject.
+    // The NoteDb subject is read directly from the commit, whereas the ReviewDb
+    // subject historically may have been truncated to fit in a SQL varchar
+    // column.
+    //
+    // Ignore original subject on the ReviewDb side when comparing to NoteDb.
+    // This field may have any number of values:
+    //  - It may be null, if the change has had no new patch sets pushed since
+    //    migrating to schema 103.
+    //  - It may match the first patch set subject, if the change was created
+    //    after migrating to schema 103.
+    //  - It may match the subject of the first patch set that was pushed after
+    //    the migration to schema 103, even though that is neither the subject
+    //    of the first patch set nor the subject of the last patch set. (See
+    //    Change#setCurrentPatchSet as of 43b10f86 for this behavior.) This
+    //    subject of an intermediate patch set is not available to the
+    //    ChangeBundle; we would have to get the subject from the repo, which is
+    //    inconvenient at this point.
+    //
+    // Ignore original subject on the ReviewDb side if it equals the subject of
+    // the current patch set.
+    //
+    // For all of the above subject comparisons, first trim any leading spaces
+    // from the NoteDb strings. (We actually do represent the leading spaces
+    // faithfully during conversion, but JGit's FooterLine parser trims them
+    // when reading.)
+    //
+    // Ignore empty topic on the ReviewDb side if it is null on the NoteDb side.
+    //
+    // Ignore currentPatchSetId on NoteDb side if ReviewDb does not point to a
+    // valid patch set.
+    //
+    // Use max timestamp of all ReviewDb entities when comparing with NoteDb.
+    if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+      boolean createdOnMatchesFirstPs =
+          !timestampsDiffer(bundleA, bundleA.getFirstPatchSetTime(), bundleB, bCreated);
+      boolean createdOnMatchesLastUpdatedOn =
+          !timestampsDiffer(bundleA, aUpdated, bundleB, bCreated);
+      boolean createdAfterUpdated = aCreated.compareTo(aUpdated) > 0;
+      excludeCreatedOn =
+          createdOnMatchesFirstPs || (createdAfterUpdated && createdOnMatchesLastUpdatedOn);
+
+      aSubj = cleanReviewDbSubject(aSubj);
+      bSubj = cleanNoteDbSubject(bSubj);
+      excludeCurrentPatchSetId = !bundleA.validPatchSetPredicate().apply(a.currentPatchSetId());
+      excludeSubject = bSubj.startsWith(aSubj) || excludeCurrentPatchSetId;
+      excludeOrigSubj = true;
+      String aTopic = trimOrNull(a.getTopic());
+      excludeTopic =
+          Objects.equals(aTopic, b.getTopic()) || "".equals(aTopic) && b.getTopic() == null;
+      aUpdated = bundleA.getLatestTimestamp();
+    } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+      boolean createdOnMatchesFirstPs =
+          !timestampsDiffer(bundleA, aCreated, bundleB, bundleB.getFirstPatchSetTime());
+      boolean createdOnMatchesLastUpdatedOn =
+          !timestampsDiffer(bundleA, aCreated, bundleB, bUpdated);
+      boolean createdAfterUpdated = bCreated.compareTo(bUpdated) > 0;
+      excludeCreatedOn =
+          createdOnMatchesFirstPs || (createdAfterUpdated && createdOnMatchesLastUpdatedOn);
+
+      aSubj = cleanNoteDbSubject(aSubj);
+      bSubj = cleanReviewDbSubject(bSubj);
+      excludeCurrentPatchSetId = !bundleB.validPatchSetPredicate().apply(b.currentPatchSetId());
+      excludeSubject = aSubj.startsWith(bSubj) || excludeCurrentPatchSetId;
+      excludeOrigSubj = true;
+      String bTopic = trimOrNull(b.getTopic());
+      excludeTopic =
+          Objects.equals(bTopic, a.getTopic()) || a.getTopic() == null && "".equals(bTopic);
+      bUpdated = bundleB.getLatestTimestamp();
+    }
+
+    String subjectField = "subject";
+    String updatedField = "lastUpdatedOn";
+    List<String> exclude =
+        Lists.newArrayList(subjectField, updatedField, "noteDbState", "rowVersion");
+    if (excludeCreatedOn) {
+      exclude.add("createdOn");
+    }
+    if (excludeCurrentPatchSetId) {
+      exclude.add("currentPatchSetId");
+    }
+    if (excludeOrigSubj) {
+      exclude.add("originalSubject");
+    }
+    if (excludeTopic) {
+      exclude.add("topic");
+    }
+    diffColumnsExcluding(diffs, Change.class, desc, bundleA, a, bundleB, b, exclude);
+
+    // Allow last updated timestamps to either be exactly equal (within slop),
+    // or the NoteDb timestamp to be equal to the latest entity timestamp in the
+    // whole ReviewDb bundle (within slop).
+    if (timestampsDiffer(bundleA, a.getLastUpdatedOn(), bundleB, b.getLastUpdatedOn())) {
+      diffTimestamps(
+          diffs, desc, bundleA, aUpdated, bundleB, bUpdated, "effective last updated time");
+    }
+    if (!excludeSubject) {
+      diffValues(diffs, desc, aSubj, bSubj, subjectField);
+    }
+  }
+
+  private static String trimOrNull(String s) {
+    return s != null ? CharMatcher.whitespace().trimFrom(s) : null;
+  }
+
+  private static String cleanReviewDbSubject(String s) {
+    s = CharMatcher.is(' ').trimLeadingFrom(s);
+
+    // An old JGit bug failed to extract subjects from commits with "\r\n"
+    // terminators: https://bugs.eclipse.org/bugs/show_bug.cgi?id=400707
+    // Changes created with this bug may have "\r\n" converted to "\r " and the
+    // entire commit in the subject. The version of JGit used to read NoteDb
+    // changes parses these subjects correctly, so we need to clean up old
+    // ReviewDb subjects before comparing.
+    int rn = s.indexOf("\r \r ");
+    if (rn >= 0) {
+      s = s.substring(0, rn);
+    }
+    return ChangeNoteUtil.sanitizeFooter(s);
+  }
+
+  private static String cleanNoteDbSubject(String s) {
+    return ChangeNoteUtil.sanitizeFooter(s);
+  }
+
+  /**
+   * Set of fields that must always exactly match between ReviewDb and NoteDb.
+   *
+   * <p>Used to limit the worst-case quadratic search when pairing off matching messages below.
+   */
+  @AutoValue
+  abstract static class ChangeMessageCandidate {
+    static ChangeMessageCandidate create(ChangeMessage cm) {
+      return new AutoValue_ChangeBundle_ChangeMessageCandidate(
+          cm.getAuthor(), cm.getMessage(), cm.getTag());
+    }
+
+    @Nullable
+    abstract Account.Id author();
+
+    @Nullable
+    abstract String message();
+
+    @Nullable
+    abstract String tag();
+
+    // Exclude:
+    //  - patch set, which may be null on ReviewDb side but not NoteDb
+    //  - UUID, which is always different between ReviewDb and NoteDb
+    //  - writtenOn, which is fuzzy
+  }
+
+  private static void diffChangeMessages(
+      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+    if (bundleA.source == REVIEW_DB && bundleB.source == REVIEW_DB) {
+      // Both came from ReviewDb: check all fields exactly.
+      Map<ChangeMessage.Key, ChangeMessage> as = changeMessageMap(bundleA.filterChangeMessages());
+      Map<ChangeMessage.Key, ChangeMessage> bs = changeMessageMap(bundleB.filterChangeMessages());
+
+      for (ChangeMessage.Key k : diffKeySets(diffs, as, bs)) {
+        ChangeMessage a = as.get(k);
+        ChangeMessage b = bs.get(k);
+        String desc = describe(k);
+        diffColumns(diffs, ChangeMessage.class, desc, bundleA, a, bundleB, b);
+      }
+      return;
+    }
+    Change.Id id = bundleA.getChange().getId();
+    checkArgument(id.equals(bundleB.getChange().getId()));
+
+    // Try to pair up matching ChangeMessages from each side, and succeed only
+    // if both collections are empty at the end. Quadratic in the worst case,
+    // but easy to reason about.
+    List<ChangeMessage> as = new LinkedList<>(bundleA.filterChangeMessages());
+
+    ListMultimap<ChangeMessageCandidate, ChangeMessage> bs = LinkedListMultimap.create();
+    for (ChangeMessage b : bundleB.filterChangeMessages()) {
+      bs.put(ChangeMessageCandidate.create(b), b);
+    }
+
+    Iterator<ChangeMessage> ait = as.iterator();
+    A:
+    while (ait.hasNext()) {
+      ChangeMessage a = ait.next();
+      Iterator<ChangeMessage> bit = bs.get(ChangeMessageCandidate.create(a)).iterator();
+      while (bit.hasNext()) {
+        ChangeMessage b = bit.next();
+        if (changeMessagesMatch(bundleA, a, bundleB, b)) {
+          ait.remove();
+          bit.remove();
+          continue A;
+        }
+      }
+    }
+
+    if (as.isEmpty() && bs.isEmpty()) {
+      return;
+    }
+    StringBuilder sb =
+        new StringBuilder("ChangeMessages differ for Change.Id ").append(id).append('\n');
+    if (!as.isEmpty()) {
+      sb.append("Only in A:");
+      for (ChangeMessage cm : as) {
+        sb.append("\n  ").append(cm);
+      }
+      if (!bs.isEmpty()) {
+        sb.append('\n');
+      }
+    }
+    if (!bs.isEmpty()) {
+      sb.append("Only in B:");
+      for (ChangeMessage cm : CHANGE_MESSAGE_ORDER.sortedCopy(bs.values())) {
+        sb.append("\n  ").append(cm);
+      }
+    }
+    diffs.add(sb.toString());
+  }
+
+  private static boolean changeMessagesMatch(
+      ChangeBundle bundleA, ChangeMessage a, ChangeBundle bundleB, ChangeMessage b) {
+    List<String> tempDiffs = new ArrayList<>();
+    String temp = "temp";
+
+    // ReviewDb allows timestamps before patch set was created, but NoteDb
+    // truncates this to the patch set creation timestamp.
+    Timestamp ta = a.getWrittenOn();
+    Timestamp tb = b.getWrittenOn();
+    PatchSet psa = bundleA.patchSets.get(a.getPatchSetId());
+    PatchSet psb = bundleB.patchSets.get(b.getPatchSetId());
+    boolean excludePatchSet = false;
+    boolean excludeWrittenOn = false;
+    if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+      excludePatchSet = a.getPatchSetId() == null;
+      excludeWrittenOn =
+          psa != null
+              && psb != null
+              && ta.before(psa.getCreatedOn())
+              && tb.equals(psb.getCreatedOn());
+    } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+      excludePatchSet = b.getPatchSetId() == null;
+      excludeWrittenOn =
+          psa != null
+              && psb != null
+              && tb.before(psb.getCreatedOn())
+              && ta.equals(psa.getCreatedOn());
+    }
+
+    List<String> exclude = Lists.newArrayList("key");
+    if (excludePatchSet) {
+      exclude.add("patchset");
+    }
+    if (excludeWrittenOn) {
+      exclude.add("writtenOn");
+    }
+
+    diffColumnsExcluding(tempDiffs, ChangeMessage.class, temp, bundleA, a, bundleB, b, exclude);
+    return tempDiffs.isEmpty();
+  }
+
+  private static void diffPatchSets(
+      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+    Map<PatchSet.Id, PatchSet> as = bundleA.patchSets;
+    Map<PatchSet.Id, PatchSet> bs = bundleB.patchSets;
+    Optional<PatchSet.Id> minA = as.keySet().stream().min(intKeyOrdering());
+    Optional<PatchSet.Id> minB = bs.keySet().stream().min(intKeyOrdering());
+    Set<PatchSet.Id> ids = diffKeySets(diffs, as, bs);
+
+    // Old versions of Gerrit had a bug that created patch sets during
+    // rebase or submission with a createdOn timestamp earlier than the patch
+    // set it was replacing. (In the cases I examined, it was equal to createdOn
+    // for the change, but we're not counting on this exact behavior.)
+    //
+    // ChangeRebuilder ensures patch set events come out in order, but it's hard
+    // to predict what the resulting timestamps would look like. So, completely
+    // ignore the createdOn timestamps if both:
+    //   * ReviewDb timestamps are non-monotonic.
+    //   * NoteDb timestamps are monotonic.
+    //
+    // Allow the timestamp of the first patch set to match the creation time of
+    // the change.
+    boolean excludeAllCreatedOn = false;
+    if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+      excludeAllCreatedOn = !createdOnIsMonotonic(as, ids) && createdOnIsMonotonic(bs, ids);
+    } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+      excludeAllCreatedOn = createdOnIsMonotonic(as, ids) && !createdOnIsMonotonic(bs, ids);
+    }
+
+    for (PatchSet.Id id : ids) {
+      PatchSet a = as.get(id);
+      PatchSet b = bs.get(id);
+      String desc = describe(id);
+      String pushCertField = "pushCertificate";
+
+      boolean excludeCreatedOn = excludeAllCreatedOn;
+      boolean excludeDesc = false;
+      if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+        excludeDesc = Objects.equals(trimOrNull(a.getDescription()), b.getDescription());
+        excludeCreatedOn |=
+            Optional.of(id).equals(minB) && b.getCreatedOn().equals(bundleB.change.getCreatedOn());
+      } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+        excludeDesc = Objects.equals(a.getDescription(), trimOrNull(b.getDescription()));
+        excludeCreatedOn |=
+            Optional.of(id).equals(minA) && a.getCreatedOn().equals(bundleA.change.getCreatedOn());
+      }
+
+      List<String> exclude = Lists.newArrayList(pushCertField);
+      if (excludeCreatedOn) {
+        exclude.add("createdOn");
+      }
+      if (excludeDesc) {
+        exclude.add("description");
+      }
+
+      diffColumnsExcluding(diffs, PatchSet.class, desc, bundleA, a, bundleB, b, exclude);
+      diffValues(diffs, desc, trimPushCert(a), trimPushCert(b), pushCertField);
+    }
+  }
+
+  private static String trimPushCert(PatchSet ps) {
+    if (ps.getPushCertificate() == null) {
+      return null;
+    }
+    return CharMatcher.is('\n').trimTrailingFrom(ps.getPushCertificate());
+  }
+
+  private static boolean createdOnIsMonotonic(
+      Map<?, PatchSet> patchSets, Set<PatchSet.Id> limitToIds) {
+    List<PatchSet> orderedById =
+        patchSets
+            .values()
+            .stream()
+            .filter(ps -> limitToIds.contains(ps.getId()))
+            .sorted(ChangeUtil.PS_ID_ORDER)
+            .collect(toList());
+    return Ordering.natural().onResultOf(PatchSet::getCreatedOn).isOrdered(orderedById);
+  }
+
+  private static void diffPatchSetApprovals(
+      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+    Map<PatchSetApproval.Key, PatchSetApproval> as = bundleA.filterPatchSetApprovals();
+    Map<PatchSetApproval.Key, PatchSetApproval> bs = bundleB.filterPatchSetApprovals();
+    for (PatchSetApproval.Key k : diffKeySets(diffs, as, bs)) {
+      PatchSetApproval a = as.get(k);
+      PatchSetApproval b = bs.get(k);
+      String desc = describe(k);
+
+      // ReviewDb allows timestamps before patch set was created, but NoteDb
+      // truncates this to the patch set creation timestamp.
+      //
+      // ChangeRebuilder ensures all post-submit approvals happen after the
+      // actual submit, so the timestamps may not line up. This shouldn't really
+      // happen, because postSubmit shouldn't be set in ReviewDb until after the
+      // change is submitted in ReviewDb, but you never know.
+      //
+      // Due to a quirk of PostReview, post-submit 0 votes might not have the
+      // postSubmit bit set in ReviewDb. As these are only used for tombstone
+      // purposes, ignore the postSubmit bit in NoteDb in this case.
+      Timestamp ta = a.getGranted();
+      Timestamp tb = b.getGranted();
+      PatchSet psa = checkNotNull(bundleA.patchSets.get(a.getPatchSetId()));
+      PatchSet psb = checkNotNull(bundleB.patchSets.get(b.getPatchSetId()));
+      boolean excludeGranted = false;
+      boolean excludePostSubmit = false;
+      List<String> exclude = new ArrayList<>(1);
+      if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+        excludeGranted =
+            (ta.before(psa.getCreatedOn()) && tb.equals(psb.getCreatedOn()))
+                || ta.compareTo(tb) < 0;
+        excludePostSubmit = a.getValue() == 0 && b.isPostSubmit();
+      } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+        excludeGranted =
+            tb.before(psb.getCreatedOn()) && ta.equals(psa.getCreatedOn()) || tb.compareTo(ta) < 0;
+        excludePostSubmit = b.getValue() == 0 && a.isPostSubmit();
+      }
+
+      // Legacy submit approvals may or may not have tags associated with them,
+      // depending on whether ChangeRebuilder happened to group them with the
+      // status change.
+      boolean excludeTag =
+          bundleA.source != bundleB.source && a.isLegacySubmit() && b.isLegacySubmit();
+
+      if (excludeGranted) {
+        exclude.add("granted");
+      }
+      if (excludePostSubmit) {
+        exclude.add("postSubmit");
+      }
+      if (excludeTag) {
+        exclude.add("tag");
+      }
+
+      diffColumnsExcluding(diffs, PatchSetApproval.class, desc, bundleA, a, bundleB, b, exclude);
+    }
+  }
+
+  private static void diffReviewers(
+      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+    diffSets(diffs, bundleA.reviewers.all(), bundleB.reviewers.all(), "reviewer");
+  }
+
+  private static void diffPatchLineComments(
+      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+    Map<PatchLineComment.Key, PatchLineComment> as = bundleA.filterPatchLineComments();
+    Map<PatchLineComment.Key, PatchLineComment> bs = bundleB.filterPatchLineComments();
+    for (PatchLineComment.Key k : diffKeySets(diffs, as, bs)) {
+      PatchLineComment a = as.get(k);
+      PatchLineComment b = bs.get(k);
+      String desc = describe(k);
+      diffColumns(diffs, PatchLineComment.class, desc, bundleA, a, bundleB, b);
+    }
+  }
+
+  private static <T> Set<T> diffKeySets(List<String> diffs, Map<T, ?> a, Map<T, ?> b) {
+    if (a.isEmpty() && b.isEmpty()) {
+      return a.keySet();
+    }
+    String clazz = keyClass((!a.isEmpty() ? a.keySet() : b.keySet()).iterator().next());
+    return diffSets(diffs, a.keySet(), b.keySet(), clazz);
+  }
+
+  private static <T> Set<T> diffSets(List<String> diffs, Set<T> as, Set<T> bs, String desc) {
+    if (as.isEmpty() && bs.isEmpty()) {
+      return as;
+    }
+
+    Set<T> aNotB = Sets.difference(as, bs);
+    Set<T> bNotA = Sets.difference(bs, as);
+    if (aNotB.isEmpty() && bNotA.isEmpty()) {
+      return as;
+    }
+    diffs.add(desc + " sets differ: " + aNotB + " only in A; " + bNotA + " only in B");
+    return Sets.intersection(as, bs);
+  }
+
+  private static <T> void diffColumns(
+      List<String> diffs,
+      Class<T> clazz,
+      String desc,
+      ChangeBundle bundleA,
+      T a,
+      ChangeBundle bundleB,
+      T b) {
+    diffColumnsExcluding(diffs, clazz, desc, bundleA, a, bundleB, b);
+  }
+
+  private static <T> void diffColumnsExcluding(
+      List<String> diffs,
+      Class<T> clazz,
+      String desc,
+      ChangeBundle bundleA,
+      T a,
+      ChangeBundle bundleB,
+      T b,
+      String... exclude) {
+    diffColumnsExcluding(diffs, clazz, desc, bundleA, a, bundleB, b, Arrays.asList(exclude));
+  }
+
+  private static <T> void diffColumnsExcluding(
+      List<String> diffs,
+      Class<T> clazz,
+      String desc,
+      ChangeBundle bundleA,
+      T a,
+      ChangeBundle bundleB,
+      T b,
+      Iterable<String> exclude) {
+    Set<String> toExclude = Sets.newLinkedHashSet(exclude);
+    for (Field f : clazz.getDeclaredFields()) {
+      Column col = f.getAnnotation(Column.class);
+      if (col == null) {
+        continue;
+      } else if (toExclude.remove(f.getName())) {
+        continue;
+      }
+      f.setAccessible(true);
+      try {
+        if (Timestamp.class.isAssignableFrom(f.getType())) {
+          diffTimestamps(diffs, desc, bundleA, a, bundleB, b, f.getName());
+        } else {
+          diffValues(diffs, desc, f.get(a), f.get(b), f.getName());
+        }
+      } catch (IllegalAccessException e) {
+        throw new IllegalArgumentException(e);
+      }
+    }
+    checkArgument(
+        toExclude.isEmpty(),
+        "requested columns to exclude not present in %s: %s",
+        clazz.getSimpleName(),
+        toExclude);
+  }
+
+  private static void diffTimestamps(
+      List<String> diffs,
+      String desc,
+      ChangeBundle bundleA,
+      Object a,
+      ChangeBundle bundleB,
+      Object b,
+      String field) {
+    checkArgument(a.getClass() == b.getClass());
+    Class<?> clazz = a.getClass();
+
+    Timestamp ta;
+    Timestamp tb;
+    try {
+      Field f = clazz.getDeclaredField(field);
+      checkArgument(f.getAnnotation(Column.class) != null);
+      f.setAccessible(true);
+      ta = (Timestamp) f.get(a);
+      tb = (Timestamp) f.get(b);
+    } catch (IllegalAccessException | NoSuchFieldException | SecurityException e) {
+      throw new IllegalArgumentException(e);
+    }
+    diffTimestamps(diffs, desc, bundleA, ta, bundleB, tb, field);
+  }
+
+  private static void diffTimestamps(
+      List<String> diffs,
+      String desc,
+      ChangeBundle bundleA,
+      Timestamp ta,
+      ChangeBundle bundleB,
+      Timestamp tb,
+      String fieldDesc) {
+    if (bundleA.source == bundleB.source || ta == null || tb == null) {
+      diffValues(diffs, desc, ta, tb, fieldDesc);
+    } else if (bundleA.source == NOTE_DB) {
+      diffTimestamps(diffs, desc, bundleA.getChange(), ta, bundleB.getChange(), tb, fieldDesc);
+    } else {
+      diffTimestamps(diffs, desc, bundleB.getChange(), tb, bundleA.getChange(), ta, fieldDesc);
+    }
+  }
+
+  private static boolean timestampsDiffer(
+      ChangeBundle bundleA, Timestamp ta, ChangeBundle bundleB, Timestamp tb) {
+    List<String> tempDiffs = new ArrayList<>(1);
+    diffTimestamps(tempDiffs, "temp", bundleA, ta, bundleB, tb, "temp");
+    return !tempDiffs.isEmpty();
+  }
+
+  private static void diffTimestamps(
+      List<String> diffs,
+      String desc,
+      Change changeFromNoteDb,
+      Timestamp tsFromNoteDb,
+      Change changeFromReviewDb,
+      Timestamp tsFromReviewDb,
+      String field) {
+    // Because ChangeRebuilder may batch events together that are several
+    // seconds apart, the timestamp in NoteDb may actually be several seconds
+    // *earlier* than the timestamp in ReviewDb that it was converted from.
+    checkArgument(
+        tsFromNoteDb.equals(truncateToSecond(tsFromNoteDb)),
+        "%s from NoteDb has non-rounded %s timestamp: %s",
+        desc,
+        field,
+        tsFromNoteDb);
+
+    if (tsFromReviewDb.before(changeFromReviewDb.getCreatedOn())
+        && tsFromNoteDb.equals(changeFromNoteDb.getCreatedOn())) {
+      // Timestamp predates change creation. These are truncated to change
+      // creation time during NoteDb conversion, so allow this if the timestamp
+      // in NoteDb matches the createdOn time in NoteDb.
+      return;
+    }
+
+    long delta = tsFromReviewDb.getTime() - tsFromNoteDb.getTime();
+    long max = ChangeRebuilderImpl.MAX_WINDOW_MS;
+    if (delta < 0 || delta > max) {
+      diffs.add(
+          field
+              + " differs for "
+              + desc
+              + " in NoteDb vs. ReviewDb:"
+              + " {"
+              + tsFromNoteDb
+              + "} != {"
+              + tsFromReviewDb
+              + "}");
+    }
+  }
+
+  private static void diffValues(
+      List<String> diffs, String desc, Object va, Object vb, String name) {
+    if (!Objects.equals(va, vb)) {
+      diffs.add(name + " differs for " + desc + ": {" + va + "} != {" + vb + "}");
+    }
+  }
+
+  private static String describe(Object key) {
+    return keyClass(key) + " " + key;
+  }
+
+  private static String keyClass(Object obj) {
+    Class<?> clazz = obj.getClass();
+    String name = clazz.getSimpleName();
+    checkArgument(name.endsWith("Key") || name.endsWith("Id"), "not an Id/Key class: %s", name);
+    if (name.equals("Key") || name.equals("Id")) {
+      return clazz.getEnclosingClass().getSimpleName() + "." + name;
+    } else if (name.startsWith("AutoValue_")) {
+      return name.substring(name.lastIndexOf('_') + 1);
+    }
+    return name;
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName()
+        + "{id="
+        + change.getId()
+        + ", ChangeMessage["
+        + changeMessages.size()
+        + "]"
+        + ", PatchSet["
+        + patchSets.size()
+        + "]"
+        + ", PatchSetApproval["
+        + patchSetApprovals.size()
+        + "]"
+        + ", PatchLineComment["
+        + patchLineComments.size()
+        + "]"
+        + "}";
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundleReader.java b/java/com/google/gerrit/server/notedb/ChangeBundleReader.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundleReader.java
rename to java/com/google/gerrit/server/notedb/ChangeBundleReader.java
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
new file mode 100644
index 0000000..71c0b9e
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -0,0 +1,268 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A single delta to apply atomically to a change.
+ *
+ * <p>This delta contains only draft comments on a single patch set of a change by a single author.
+ * This delta will become a single commit in the All-Users repository.
+ *
+ * <p>This class is not thread safe.
+ */
+public class ChangeDraftUpdate extends AbstractChangeUpdate {
+  public interface Factory {
+    ChangeDraftUpdate create(
+        ChangeNotes notes,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
+
+    ChangeDraftUpdate create(
+        Change change,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
+  }
+
+  @AutoValue
+  abstract static class Key {
+    abstract String revId();
+
+    abstract Comment.Key key();
+  }
+
+  private static Key key(Comment c) {
+    return new AutoValue_ChangeDraftUpdate_Key(c.revId, c.key);
+  }
+
+  private final AllUsersName draftsProject;
+
+  private List<Comment> put = new ArrayList<>();
+  private Set<Key> delete = new HashSet<>();
+
+  @AssistedInject
+  private ChangeDraftUpdate(
+      @GerritServerConfig Config cfg,
+      @GerritPersonIdent PersonIdent serverIdent,
+      NotesMigration migration,
+      AllUsersName allUsers,
+      ChangeNoteUtil noteUtil,
+      @Assisted ChangeNotes notes,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
+      @Assisted PersonIdent authorIdent,
+      @Assisted Date when) {
+    super(
+        cfg,
+        migration,
+        noteUtil,
+        serverIdent,
+        notes,
+        null,
+        accountId,
+        realAccountId,
+        authorIdent,
+        when);
+    this.draftsProject = allUsers;
+  }
+
+  @AssistedInject
+  private ChangeDraftUpdate(
+      @GerritServerConfig Config cfg,
+      @GerritPersonIdent PersonIdent serverIdent,
+      NotesMigration migration,
+      AllUsersName allUsers,
+      ChangeNoteUtil noteUtil,
+      @Assisted Change change,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
+      @Assisted PersonIdent authorIdent,
+      @Assisted Date when) {
+    super(
+        cfg,
+        migration,
+        noteUtil,
+        serverIdent,
+        null,
+        change,
+        accountId,
+        realAccountId,
+        authorIdent,
+        when);
+    this.draftsProject = allUsers;
+  }
+
+  public void putComment(Comment c) {
+    verifyComment(c);
+    put.add(c);
+  }
+
+  public void deleteComment(Comment c) {
+    verifyComment(c);
+    delete.add(key(c));
+  }
+
+  public void deleteComment(String revId, Comment.Key key) {
+    delete.add(new AutoValue_ChangeDraftUpdate_Key(revId, key));
+  }
+
+  private CommitBuilder storeCommentsInNotes(
+      RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
+      throws ConfigInvalidException, OrmException, IOException {
+    RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
+    Set<RevId> updatedRevs = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
+    RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
+
+    for (Comment c : put) {
+      if (!delete.contains(key(c))) {
+        cache.get(new RevId(c.revId)).putComment(c);
+      }
+    }
+    for (Key k : delete) {
+      cache.get(new RevId(k.revId())).deleteComment(k.key());
+    }
+
+    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    boolean touchedAnyRevs = false;
+    boolean hasComments = false;
+    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
+      updatedRevs.add(e.getKey());
+      ObjectId id = ObjectId.fromString(e.getKey().get());
+      byte[] data = e.getValue().build(noteUtil, noteUtil.getWriteJson());
+      if (!Arrays.equals(data, e.getValue().baseRaw)) {
+        touchedAnyRevs = true;
+      }
+      if (data.length == 0) {
+        rnm.noteMap.remove(id);
+      } else {
+        hasComments = true;
+        ObjectId dataBlob = ins.insert(OBJ_BLOB, data);
+        rnm.noteMap.set(id, dataBlob);
+      }
+    }
+
+    // If we didn't touch any notes, tell the caller this was a no-op update. We
+    // couldn't have done this in isEmpty() below because we hadn't read the old
+    // data yet.
+    if (!touchedAnyRevs) {
+      return NO_OP_UPDATE;
+    }
+
+    // If we touched every revision and there are no comments left, tell the
+    // caller to delete the entire ref.
+    boolean touchedAllRevs = updatedRevs.equals(rnm.revisionNotes.keySet());
+    if (touchedAllRevs && !hasComments) {
+      return null;
+    }
+
+    cb.setTreeId(rnm.noteMap.writeTree(ins));
+    return cb;
+  }
+
+  private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr)
+      throws ConfigInvalidException, OrmException, IOException {
+    if (migration.readChanges()) {
+      // If reading from changes is enabled, then the old DraftCommentNotes
+      // already parsed the revision notes. We can reuse them as long as the ref
+      // hasn't advanced.
+      ChangeNotes changeNotes = getNotes();
+      if (changeNotes != null) {
+        DraftCommentNotes draftNotes = changeNotes.load().getDraftCommentNotes();
+        if (draftNotes != null) {
+          ObjectId idFromNotes = firstNonNull(draftNotes.getRevision(), ObjectId.zeroId());
+          RevisionNoteMap<ChangeRevisionNote> rnm = draftNotes.getRevisionNoteMap();
+          if (idFromNotes.equals(curr) && rnm != null) {
+            return rnm;
+          }
+        }
+      }
+    }
+    NoteMap noteMap;
+    if (!curr.equals(ObjectId.zeroId())) {
+      noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(curr));
+    } else {
+      noteMap = NoteMap.newEmptyMap();
+    }
+    // Even though reading from changes might not be enabled, we need to
+    // parse any existing revision notes so we can merge them.
+    return RevisionNoteMap.parse(
+        noteUtil, getId(), rw.getObjectReader(), noteMap, PatchLineComment.Status.DRAFT);
+  }
+
+  @Override
+  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
+      throws OrmException, IOException {
+    CommitBuilder cb = new CommitBuilder();
+    cb.setMessage("Update draft comments");
+    try {
+      return storeCommentsInNotes(rw, ins, curr, cb);
+    } catch (ConfigInvalidException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  @Override
+  protected Project.NameKey getProjectName() {
+    return draftsProject;
+  }
+
+  @Override
+  protected String getRefName() {
+    return RefNames.refsDraftComments(getId(), accountId);
+  }
+
+  @Override
+  public boolean isEmpty() {
+    return delete.isEmpty() && put.isEmpty();
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
new file mode 100644
index 0000000..abdb517
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -0,0 +1,633 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
+import static com.google.gerrit.server.notedb.ChangeNotes.parseException;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.CommentRange;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.inject.Inject;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.sql.Timestamp;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.FooterKey;
+import org.eclipse.jgit.util.GitDateFormatter;
+import org.eclipse.jgit.util.GitDateFormatter.Format;
+import org.eclipse.jgit.util.GitDateParser;
+import org.eclipse.jgit.util.MutableInteger;
+import org.eclipse.jgit.util.QuotedString;
+import org.eclipse.jgit.util.RawParseUtils;
+
+public class ChangeNoteUtil {
+  public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
+  public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
+  public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
+  public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
+  public static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
+  public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
+  public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
+  public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
+  public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
+  public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
+      new FooterKey("Patch-set-description");
+  public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
+  public static final FooterKey FOOTER_READ_ONLY_UNTIL = new FooterKey("Read-only-until");
+  public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
+  public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
+  public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
+  public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
+  public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
+  public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
+  public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
+  public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
+  public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
+
+  private static final String AUTHOR = "Author";
+  private static final String BASE_PATCH_SET = "Base-for-patch-set";
+  private static final String COMMENT_RANGE = "Comment-range";
+  private static final String FILE = "File";
+  private static final String LENGTH = "Bytes";
+  private static final String PARENT = "Parent";
+  private static final String PARENT_NUMBER = "Parent-number";
+  private static final String PATCH_SET = "Patch-set";
+  private static final String REAL_AUTHOR = "Real-author";
+  private static final String REVISION = "Revision";
+  private static final String UUID = "UUID";
+  private static final String UNRESOLVED = "Unresolved";
+  private static final String TAG = FOOTER_TAG.getName();
+
+  public static String formatTime(PersonIdent ident, Timestamp t) {
+    GitDateFormatter dateFormatter = new GitDateFormatter(Format.DEFAULT);
+    // TODO(dborowitz): Use a ThreadLocal or use Joda.
+    PersonIdent newIdent = new PersonIdent(ident, t);
+    return dateFormatter.formatDate(newIdent);
+  }
+
+  static Gson newGson() {
+    return new GsonBuilder()
+        .registerTypeAdapter(Timestamp.class, new CommentTimestampAdapter().nullSafe())
+        .setPrettyPrinting()
+        .create();
+  }
+
+  private final AccountCache accountCache;
+  private final PersonIdent serverIdent;
+  private final String serverId;
+  private final Gson gson = newGson();
+  private final boolean writeJson;
+
+  @Inject
+  public ChangeNoteUtil(
+      AccountCache accountCache,
+      @GerritPersonIdent PersonIdent serverIdent,
+      @GerritServerId String serverId,
+      @GerritServerConfig Config config) {
+    this.accountCache = accountCache;
+    this.serverIdent = serverIdent;
+    this.serverId = serverId;
+    this.writeJson = config.getBoolean("notedb", "writeJson", true);
+  }
+
+  @VisibleForTesting
+  public PersonIdent newIdent(Account author, Date when, PersonIdent serverIdent) {
+    return new PersonIdent(
+        author.getName(), author.getId().get() + "@" + serverId, when, serverIdent.getTimeZone());
+  }
+
+  public boolean getWriteJson() {
+    return writeJson;
+  }
+
+  public Gson getGson() {
+    return gson;
+  }
+
+  public String getServerId() {
+    return serverId;
+  }
+
+  public Account.Id parseIdent(PersonIdent ident, Change.Id changeId)
+      throws ConfigInvalidException {
+    return NoteDbUtil.parseIdent(ident, serverId)
+        .orElseThrow(
+            () ->
+                parseException(
+                    changeId,
+                    "invalid identity, expected <id>@%s: %s",
+                    serverId,
+                    ident.getEmailAddress()));
+  }
+
+  private static boolean match(byte[] note, MutableInteger p, byte[] expected) {
+    int m = RawParseUtils.match(note, p.value, expected);
+    return m == p.value + expected.length;
+  }
+
+  public List<Comment> parseNote(byte[] note, MutableInteger p, Change.Id changeId)
+      throws ConfigInvalidException {
+    if (p.value >= note.length) {
+      return ImmutableList.of();
+    }
+    Set<Comment.Key> seen = new HashSet<>();
+    List<Comment> result = new ArrayList<>();
+    int sizeOfNote = note.length;
+    byte[] psb = PATCH_SET.getBytes(UTF_8);
+    byte[] bpsb = BASE_PATCH_SET.getBytes(UTF_8);
+    byte[] bpn = PARENT_NUMBER.getBytes(UTF_8);
+
+    RevId revId = new RevId(parseStringField(note, p, changeId, REVISION));
+    String fileName = null;
+    PatchSet.Id psId = null;
+    boolean isForBase = false;
+    Integer parentNumber = null;
+
+    while (p.value < sizeOfNote) {
+      boolean matchPs = match(note, p, psb);
+      boolean matchBase = match(note, p, bpsb);
+      if (matchPs) {
+        fileName = null;
+        psId = parsePsId(note, p, changeId, PATCH_SET);
+        isForBase = false;
+      } else if (matchBase) {
+        fileName = null;
+        psId = parsePsId(note, p, changeId, BASE_PATCH_SET);
+        isForBase = true;
+        if (match(note, p, bpn)) {
+          parentNumber = parseParentNumber(note, p, changeId);
+        }
+      } else if (psId == null) {
+        throw parseException(changeId, "missing %s or %s header", PATCH_SET, BASE_PATCH_SET);
+      }
+
+      Comment c = parseComment(note, p, fileName, psId, revId, isForBase, parentNumber);
+      fileName = c.key.filename;
+      if (!seen.add(c.key)) {
+        throw parseException(changeId, "multiple comments for %s in note", c.key);
+      }
+      result.add(c);
+    }
+    return result;
+  }
+
+  private Comment parseComment(
+      byte[] note,
+      MutableInteger curr,
+      String currentFileName,
+      PatchSet.Id psId,
+      RevId revId,
+      boolean isForBase,
+      Integer parentNumber)
+      throws ConfigInvalidException {
+    Change.Id changeId = psId.getParentKey();
+
+    // Check if there is a new file.
+    boolean newFile = (RawParseUtils.match(note, curr.value, FILE.getBytes(UTF_8))) != -1;
+    if (newFile) {
+      // If so, parse the new file name.
+      currentFileName = parseFilename(note, curr, changeId);
+    } else if (currentFileName == null) {
+      throw parseException(changeId, "could not parse %s", FILE);
+    }
+
+    CommentRange range = parseCommentRange(note, curr);
+    if (range == null) {
+      throw parseException(changeId, "could not parse %s", COMMENT_RANGE);
+    }
+
+    Timestamp commentTime = parseTimestamp(note, curr, changeId);
+    Account.Id aId = parseAuthor(note, curr, changeId, AUTHOR);
+    boolean hasRealAuthor =
+        (RawParseUtils.match(note, curr.value, REAL_AUTHOR.getBytes(UTF_8))) != -1;
+    Account.Id raId = null;
+    if (hasRealAuthor) {
+      raId = parseAuthor(note, curr, changeId, REAL_AUTHOR);
+    }
+
+    boolean hasParent = (RawParseUtils.match(note, curr.value, PARENT.getBytes(UTF_8))) != -1;
+    String parentUUID = null;
+    boolean unresolved = false;
+    if (hasParent) {
+      parentUUID = parseStringField(note, curr, changeId, PARENT);
+    }
+    boolean hasUnresolved =
+        (RawParseUtils.match(note, curr.value, UNRESOLVED.getBytes(UTF_8))) != -1;
+    if (hasUnresolved) {
+      unresolved = parseBooleanField(note, curr, changeId, UNRESOLVED);
+    }
+
+    String uuid = parseStringField(note, curr, changeId, UUID);
+
+    boolean hasTag = (RawParseUtils.match(note, curr.value, TAG.getBytes(UTF_8))) != -1;
+    String tag = null;
+    if (hasTag) {
+      tag = parseStringField(note, curr, changeId, TAG);
+    }
+
+    int commentLength = parseCommentLength(note, curr, changeId);
+
+    String message = RawParseUtils.decode(UTF_8, note, curr.value, curr.value + commentLength);
+    checkResult(message, "message contents", changeId);
+
+    Comment c =
+        new Comment(
+            new Comment.Key(uuid, currentFileName, psId.get()),
+            aId,
+            commentTime,
+            isForBase ? (short) (parentNumber == null ? 0 : -parentNumber) : (short) 1,
+            message,
+            serverId,
+            unresolved);
+    c.lineNbr = range.getEndLine();
+    c.parentUuid = parentUUID;
+    c.tag = tag;
+    c.setRevId(revId);
+    if (raId != null) {
+      c.setRealAuthor(raId);
+    }
+
+    if (range.getStartCharacter() != -1) {
+      c.setRange(range);
+    }
+
+    curr.value = RawParseUtils.nextLF(note, curr.value + commentLength);
+    curr.value = RawParseUtils.nextLF(note, curr.value);
+    return c;
+  }
+
+  private static String parseStringField(
+      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
+      throws ConfigInvalidException {
+    int endOfLine = RawParseUtils.nextLF(note, curr.value);
+    checkHeaderLineFormat(note, curr, fieldName, changeId);
+    int startOfField = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
+    curr.value = endOfLine;
+    return RawParseUtils.decode(UTF_8, note, startOfField, endOfLine - 1);
+  }
+
+  /**
+   * @return a comment range. If the comment range line in the note only has one number, we return a
+   *     CommentRange with that one number as the end line and the other fields as -1. If the
+   *     comment range line in the note contains a whole comment range, then we return a
+   *     CommentRange with all fields set. If the line is not correctly formatted, return null.
+   */
+  private static CommentRange parseCommentRange(byte[] note, MutableInteger ptr) {
+    CommentRange range = new CommentRange(-1, -1, -1, -1);
+
+    int last = ptr.value;
+    int startLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
+    if (ptr.value == last) {
+      return null;
+    } else if (note[ptr.value] == '\n') {
+      range.setEndLine(startLine);
+      ptr.value += 1;
+      return range;
+    } else if (note[ptr.value] == ':') {
+      range.setStartLine(startLine);
+      ptr.value += 1;
+    } else {
+      return null;
+    }
+
+    last = ptr.value;
+    int startChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
+    if (ptr.value == last) {
+      return null;
+    } else if (note[ptr.value] == '-') {
+      range.setStartCharacter(startChar);
+      ptr.value += 1;
+    } else {
+      return null;
+    }
+
+    last = ptr.value;
+    int endLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
+    if (ptr.value == last) {
+      return null;
+    } else if (note[ptr.value] == ':') {
+      range.setEndLine(endLine);
+      ptr.value += 1;
+    } else {
+      return null;
+    }
+
+    last = ptr.value;
+    int endChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
+    if (ptr.value == last) {
+      return null;
+    } else if (note[ptr.value] == '\n') {
+      range.setEndCharacter(endChar);
+      ptr.value += 1;
+    } else {
+      return null;
+    }
+    return range;
+  }
+
+  private static PatchSet.Id parsePsId(
+      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
+      throws ConfigInvalidException {
+    checkHeaderLineFormat(note, curr, fieldName, changeId);
+    int startOfPsId = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
+    MutableInteger i = new MutableInteger();
+    int patchSetId = RawParseUtils.parseBase10(note, startOfPsId, i);
+    int endOfLine = RawParseUtils.nextLF(note, curr.value);
+    if (i.value != endOfLine - 1) {
+      throw parseException(changeId, "could not parse %s", fieldName);
+    }
+    checkResult(patchSetId, "patchset id", changeId);
+    curr.value = endOfLine;
+    return new PatchSet.Id(changeId, patchSetId);
+  }
+
+  private static Integer parseParentNumber(byte[] note, MutableInteger curr, Change.Id changeId)
+      throws ConfigInvalidException {
+    checkHeaderLineFormat(note, curr, PARENT_NUMBER, changeId);
+
+    int start = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
+    MutableInteger i = new MutableInteger();
+    int parentNumber = RawParseUtils.parseBase10(note, start, i);
+    int endOfLine = RawParseUtils.nextLF(note, curr.value);
+    if (i.value != endOfLine - 1) {
+      throw parseException(changeId, "could not parse %s", PARENT_NUMBER);
+    }
+    checkResult(parentNumber, "parent number", changeId);
+    curr.value = endOfLine;
+    return Integer.valueOf(parentNumber);
+  }
+
+  private static String parseFilename(byte[] note, MutableInteger curr, Change.Id changeId)
+      throws ConfigInvalidException {
+    checkHeaderLineFormat(note, curr, FILE, changeId);
+    int startOfFileName = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
+    int endOfLine = RawParseUtils.nextLF(note, curr.value);
+    curr.value = endOfLine;
+    curr.value = RawParseUtils.nextLF(note, curr.value);
+    return QuotedString.GIT_PATH.dequote(
+        RawParseUtils.decode(UTF_8, note, startOfFileName, endOfLine - 1));
+  }
+
+  private static Timestamp parseTimestamp(byte[] note, MutableInteger curr, Change.Id changeId)
+      throws ConfigInvalidException {
+    int endOfLine = RawParseUtils.nextLF(note, curr.value);
+    Timestamp commentTime;
+    String dateString = RawParseUtils.decode(UTF_8, note, curr.value, endOfLine - 1);
+    try {
+      commentTime = new Timestamp(GitDateParser.parse(dateString, null, Locale.US).getTime());
+    } catch (ParseException e) {
+      throw new ConfigInvalidException("could not parse comment timestamp", e);
+    }
+    curr.value = endOfLine;
+    return checkResult(commentTime, "comment timestamp", changeId);
+  }
+
+  private Account.Id parseAuthor(
+      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
+      throws ConfigInvalidException {
+    checkHeaderLineFormat(note, curr, fieldName, changeId);
+    int startOfAccountId = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
+    PersonIdent ident = RawParseUtils.parsePersonIdent(note, startOfAccountId);
+    Account.Id aId = parseIdent(ident, changeId);
+    curr.value = RawParseUtils.nextLF(note, curr.value);
+    return checkResult(aId, fieldName, changeId);
+  }
+
+  private static int parseCommentLength(byte[] note, MutableInteger curr, Change.Id changeId)
+      throws ConfigInvalidException {
+    checkHeaderLineFormat(note, curr, LENGTH, changeId);
+    int startOfLength = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
+    MutableInteger i = new MutableInteger();
+    i.value = startOfLength;
+    int commentLength = RawParseUtils.parseBase10(note, startOfLength, i);
+    if (i.value == startOfLength) {
+      throw parseException(changeId, "could not parse %s", LENGTH);
+    }
+    int endOfLine = RawParseUtils.nextLF(note, curr.value);
+    if (i.value != endOfLine - 1) {
+      throw parseException(changeId, "could not parse %s", LENGTH);
+    }
+    curr.value = endOfLine;
+    return checkResult(commentLength, "comment length", changeId);
+  }
+
+  private boolean parseBooleanField(
+      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
+      throws ConfigInvalidException {
+    String str = parseStringField(note, curr, changeId, fieldName);
+    if ("true".equalsIgnoreCase(str)) {
+      return true;
+    } else if ("false".equalsIgnoreCase(str)) {
+      return false;
+    }
+    throw parseException(changeId, "invalid boolean for %s: %s", fieldName, str);
+  }
+
+  private static <T> T checkResult(T o, String fieldName, Change.Id changeId)
+      throws ConfigInvalidException {
+    if (o == null) {
+      throw parseException(changeId, "could not parse %s", fieldName);
+    }
+    return o;
+  }
+
+  private static int checkResult(int i, String fieldName, Change.Id changeId)
+      throws ConfigInvalidException {
+    if (i <= 0) {
+      throw parseException(changeId, "could not parse %s", fieldName);
+    }
+    return i;
+  }
+
+  private void appendHeaderField(PrintWriter writer, String field, String value) {
+    writer.print(field);
+    writer.print(": ");
+    writer.print(value);
+    writer.print('\n');
+  }
+
+  private static void checkHeaderLineFormat(
+      byte[] note, MutableInteger curr, String fieldName, Change.Id changeId)
+      throws ConfigInvalidException {
+    boolean correct = RawParseUtils.match(note, curr.value, fieldName.getBytes(UTF_8)) != -1;
+    int p = curr.value + fieldName.length();
+    correct &= (p < note.length && note[p] == ':');
+    p++;
+    correct &= (p < note.length && note[p] == ' ');
+    if (!correct) {
+      throw parseException(changeId, "could not parse %s", fieldName);
+    }
+  }
+
+  /**
+   * Build a note that contains the metadata for and the contents of all of the comments in the
+   * given comments.
+   *
+   * @param comments Comments to be written to the output stream, keyed by patch set ID; multiple
+   *     patch sets are allowed since base revisions may be shared across patch sets. All of the
+   *     comments must share the same RevId, and all the comments for a given patch set must have
+   *     the same side.
+   * @param out output stream to write to.
+   */
+  void buildNote(ListMultimap<Integer, Comment> comments, OutputStream out) {
+    if (comments.isEmpty()) {
+      return;
+    }
+
+    List<Integer> psIds = new ArrayList<>(comments.keySet());
+    Collections.sort(psIds);
+
+    OutputStreamWriter streamWriter = new OutputStreamWriter(out, UTF_8);
+    try (PrintWriter writer = new PrintWriter(streamWriter)) {
+      String revId = comments.values().iterator().next().revId;
+      appendHeaderField(writer, REVISION, revId);
+
+      for (int psId : psIds) {
+        List<Comment> psComments = COMMENT_ORDER.sortedCopy(comments.get(psId));
+        Comment first = psComments.get(0);
+
+        short side = first.side;
+        appendHeaderField(writer, side <= 0 ? BASE_PATCH_SET : PATCH_SET, Integer.toString(psId));
+        if (side < 0) {
+          appendHeaderField(writer, PARENT_NUMBER, Integer.toString(-side));
+        }
+
+        String currentFilename = null;
+
+        for (Comment c : psComments) {
+          checkArgument(
+              revId.equals(c.revId),
+              "All comments being added must have all the same RevId. The "
+                  + "comment below does not have the same RevId as the others "
+                  + "(%s).\n%s",
+              revId,
+              c);
+          checkArgument(
+              side == c.side,
+              "All comments being added must all have the same side. The "
+                  + "comment below does not have the same side as the others "
+                  + "(%s).\n%s",
+              side,
+              c);
+          String commentFilename = QuotedString.GIT_PATH.quote(c.key.filename);
+
+          if (!commentFilename.equals(currentFilename)) {
+            currentFilename = commentFilename;
+            writer.print("File: ");
+            writer.print(commentFilename);
+            writer.print("\n\n");
+          }
+
+          appendOneComment(writer, c);
+        }
+      }
+    }
+  }
+
+  private void appendOneComment(PrintWriter writer, Comment c) {
+    // The CommentRange field for a comment is allowed to be null. If it is
+    // null, then in the first line, we simply use the line number field for a
+    // comment instead. If it isn't null, we write the comment range itself.
+    Comment.Range range = c.range;
+    if (range != null) {
+      writer.print(range.startLine);
+      writer.print(':');
+      writer.print(range.startChar);
+      writer.print('-');
+      writer.print(range.endLine);
+      writer.print(':');
+      writer.print(range.endChar);
+    } else {
+      writer.print(c.lineNbr);
+    }
+    writer.print("\n");
+
+    writer.print(formatTime(serverIdent, c.writtenOn));
+    writer.print("\n");
+
+    appendIdent(writer, AUTHOR, c.author.getId(), c.writtenOn);
+    if (!c.getRealAuthor().equals(c.author)) {
+      appendIdent(writer, REAL_AUTHOR, c.getRealAuthor().getId(), c.writtenOn);
+    }
+
+    String parent = c.parentUuid;
+    if (parent != null) {
+      appendHeaderField(writer, PARENT, parent);
+    }
+
+    appendHeaderField(writer, UNRESOLVED, Boolean.toString(c.unresolved));
+    appendHeaderField(writer, UUID, c.key.uuid);
+
+    if (c.tag != null) {
+      appendHeaderField(writer, TAG, c.tag);
+    }
+
+    byte[] messageBytes = c.message.getBytes(UTF_8);
+    appendHeaderField(writer, LENGTH, Integer.toString(messageBytes.length));
+
+    writer.print(c.message);
+    writer.print("\n\n");
+  }
+
+  private void appendIdent(PrintWriter writer, String header, Account.Id id, Timestamp ts) {
+    PersonIdent ident = newIdent(accountCache.get(id).getAccount(), ts, serverIdent);
+    StringBuilder name = new StringBuilder();
+    PersonIdent.appendSanitized(name, ident.getName());
+    name.append(" <");
+    PersonIdent.appendSanitized(name, ident.getEmailAddress());
+    name.append('>');
+    appendHeaderField(writer, header, name.toString());
+  }
+
+  private static final CharMatcher INVALID_FOOTER_CHARS = CharMatcher.anyOf("\r\n\0");
+
+  static String sanitizeFooter(String value) {
+    // Remove characters that would confuse JGit's footer parser if they were
+    // included in footer values, for example by splitting the footer block into
+    // multiple paragraphs.
+    //
+    // One painful example: RevCommit#getShorMessage() might return a message
+    // containing "\r\r", which RevCommit#getFooterLines() will treat as an
+    // empty paragraph for the purposes of footer parsing.
+    return INVALID_FOOTER_CHARS.trimAndCollapseFrom(value, ' ');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
rename to java/com/google/gerrit/server/notedb/ChangeNotes.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
rename to java/com/google/gerrit/server/notedb/ChangeNotesCache.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
rename to java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
rename to java/com/google/gerrit/server/notedb/ChangeNotesParser.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
rename to java/com/google/gerrit/server/notedb/ChangeNotesState.java
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
new file mode 100644
index 0000000..35e4a12
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -0,0 +1,113 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.primitives.Bytes;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.util.MutableInteger;
+import org.eclipse.jgit.util.RawParseUtils;
+
+class ChangeRevisionNote extends RevisionNote<Comment> {
+  private static final byte[] CERT_HEADER = "certificate version ".getBytes(UTF_8);
+  // See org.eclipse.jgit.transport.PushCertificateParser.END_SIGNATURE
+  private static final byte[] END_SIGNATURE = "-----END PGP SIGNATURE-----\n".getBytes(UTF_8);
+
+  private final ChangeNoteUtil noteUtil;
+  private final Change.Id changeId;
+  private final PatchLineComment.Status status;
+  private String pushCert;
+
+  ChangeRevisionNote(
+      ChangeNoteUtil noteUtil,
+      Change.Id changeId,
+      ObjectReader reader,
+      ObjectId noteId,
+      PatchLineComment.Status status) {
+    super(reader, noteId);
+    this.noteUtil = noteUtil;
+    this.changeId = changeId;
+    this.status = status;
+  }
+
+  public String getPushCert() {
+    checkParsed();
+    return pushCert;
+  }
+
+  @Override
+  protected List<Comment> parse(byte[] raw, int offset) throws IOException, ConfigInvalidException {
+    MutableInteger p = new MutableInteger();
+    p.value = offset;
+
+    if (isJson(raw, p.value)) {
+      RevisionNoteData data = parseJson(noteUtil, raw, p.value);
+      if (status == PatchLineComment.Status.PUBLISHED) {
+        pushCert = data.pushCert;
+      } else {
+        pushCert = null;
+      }
+      return data.comments;
+    }
+
+    if (status == PatchLineComment.Status.PUBLISHED) {
+      pushCert = parsePushCert(changeId, raw, p);
+      trimLeadingEmptyLines(raw, p);
+    } else {
+      pushCert = null;
+    }
+    List<Comment> comments = noteUtil.parseNote(raw, p, changeId);
+    comments.forEach(c -> c.legacyFormat = true);
+    return comments;
+  }
+
+  private static boolean isJson(byte[] raw, int offset) {
+    return raw[offset] == '{' || raw[offset] == '[';
+  }
+
+  private RevisionNoteData parseJson(ChangeNoteUtil noteUtil, byte[] raw, int offset)
+      throws IOException {
+    try (InputStream is = new ByteArrayInputStream(raw, offset, raw.length - offset);
+        Reader r = new InputStreamReader(is, UTF_8)) {
+      return noteUtil.getGson().fromJson(r, RevisionNoteData.class);
+    }
+  }
+
+  private static String parsePushCert(Change.Id changeId, byte[] bytes, MutableInteger p)
+      throws ConfigInvalidException {
+    if (RawParseUtils.match(bytes, p.value, CERT_HEADER) < 0) {
+      return null;
+    }
+    int end = Bytes.indexOf(bytes, END_SIGNATURE);
+    if (end < 0) {
+      throw ChangeNotes.parseException(changeId, "invalid push certificate in note");
+    }
+    int start = p.value;
+    p.value = end + END_SIGNATURE.length;
+    return new String(bytes, start, p.value, UTF_8);
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
new file mode 100644
index 0000000..7d27255
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -0,0 +1,882 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.sanitizeFooter;
+import static java.util.Comparator.comparing;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Table;
+import com.google.common.collect.TreeBasedTable;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gwtorm.client.IntKey;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.FooterKey;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A delta to apply to a change.
+ *
+ * <p>This delta will become two unique commits: one in the AllUsers repo that will contain the
+ * draft comments on this change and one in the notes branch that will contain approvals, reviewers,
+ * change status, subject, submit records, the change message, and published comments. There are
+ * limitations on the set of modifications that can be handled in a single update. In particular,
+ * there is a single author and timestamp for each update.
+ *
+ * <p>This class is not thread-safe.
+ */
+public class ChangeUpdate extends AbstractChangeUpdate {
+  public interface Factory {
+    ChangeUpdate create(ChangeNotes notes, CurrentUser user);
+
+    ChangeUpdate create(ChangeNotes notes, CurrentUser user, Date when);
+
+    ChangeUpdate create(
+        Change change,
+        @Assisted("effective") @Nullable Account.Id accountId,
+        @Assisted("real") @Nullable Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when,
+        Comparator<String> labelNameComparator);
+
+    @VisibleForTesting
+    ChangeUpdate create(
+        ChangeNotes notes, CurrentUser user, Date when, Comparator<String> labelNameComparator);
+  }
+
+  private final AccountCache accountCache;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final ChangeDraftUpdate.Factory draftUpdateFactory;
+  private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
+  private final DeleteCommentRewriter.Factory deleteCommentRewriterFactory;
+
+  private final Table<String, Account.Id, Optional<Short>> approvals;
+  private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
+  private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
+  private final List<Comment> comments = new ArrayList<>();
+
+  private String commitSubject;
+  private String subject;
+  private String changeId;
+  private String branch;
+  private Change.Status status;
+  private List<SubmitRecord> submitRecords;
+  private String submissionId;
+  private String topic;
+  private String commit;
+  private Optional<Account.Id> assignee;
+  private Set<String> hashtags;
+  private String changeMessage;
+  private String tag;
+  private PatchSetState psState;
+  private Iterable<String> groups;
+  private String pushCert;
+  private boolean isAllowWriteToNewtRef;
+  private String psDescription;
+  private boolean currentPatchSet;
+  private Timestamp readOnlyUntil;
+  private Boolean isPrivate;
+  private Boolean workInProgress;
+  private Integer revertOf;
+
+  private ChangeDraftUpdate draftUpdate;
+  private RobotCommentUpdate robotCommentUpdate;
+  private DeleteCommentRewriter deleteCommentRewriter;
+
+  @AssistedInject
+  private ChangeUpdate(
+      @GerritServerConfig Config cfg,
+      @GerritPersonIdent PersonIdent serverIdent,
+      NotesMigration migration,
+      AccountCache accountCache,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
+      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
+      ProjectCache projectCache,
+      @Assisted ChangeNotes notes,
+      @Assisted CurrentUser user,
+      ChangeNoteUtil noteUtil) {
+    this(
+        cfg,
+        serverIdent,
+        migration,
+        accountCache,
+        updateManagerFactory,
+        draftUpdateFactory,
+        robotCommentUpdateFactory,
+        deleteCommentRewriterFactory,
+        projectCache,
+        notes,
+        user,
+        serverIdent.getWhen(),
+        noteUtil);
+  }
+
+  @AssistedInject
+  private ChangeUpdate(
+      @GerritServerConfig Config cfg,
+      @GerritPersonIdent PersonIdent serverIdent,
+      NotesMigration migration,
+      AccountCache accountCache,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
+      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
+      ProjectCache projectCache,
+      @Assisted ChangeNotes notes,
+      @Assisted CurrentUser user,
+      @Assisted Date when,
+      ChangeNoteUtil noteUtil) {
+    this(
+        cfg,
+        serverIdent,
+        migration,
+        accountCache,
+        updateManagerFactory,
+        draftUpdateFactory,
+        robotCommentUpdateFactory,
+        deleteCommentRewriterFactory,
+        notes,
+        user,
+        when,
+        projectCache.get(notes.getProjectName()).getLabelTypes().nameComparator(),
+        noteUtil);
+  }
+
+  private static Table<String, Account.Id, Optional<Short>> approvals(
+      Comparator<String> nameComparator) {
+    return TreeBasedTable.create(nameComparator, comparing(IntKey::get));
+  }
+
+  @AssistedInject
+  private ChangeUpdate(
+      @GerritServerConfig Config cfg,
+      @GerritPersonIdent PersonIdent serverIdent,
+      NotesMigration migration,
+      AccountCache accountCache,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
+      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
+      @Assisted ChangeNotes notes,
+      @Assisted CurrentUser user,
+      @Assisted Date when,
+      @Assisted Comparator<String> labelNameComparator,
+      ChangeNoteUtil noteUtil) {
+    super(cfg, migration, notes, user, serverIdent, noteUtil, when);
+    this.accountCache = accountCache;
+    this.updateManagerFactory = updateManagerFactory;
+    this.draftUpdateFactory = draftUpdateFactory;
+    this.robotCommentUpdateFactory = robotCommentUpdateFactory;
+    this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
+    this.approvals = approvals(labelNameComparator);
+  }
+
+  @AssistedInject
+  private ChangeUpdate(
+      @GerritServerConfig Config cfg,
+      @GerritPersonIdent PersonIdent serverIdent,
+      NotesMigration migration,
+      AccountCache accountCache,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
+      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
+      ChangeNoteUtil noteUtil,
+      @Assisted Change change,
+      @Assisted("effective") @Nullable Account.Id accountId,
+      @Assisted("real") @Nullable Account.Id realAccountId,
+      @Assisted PersonIdent authorIdent,
+      @Assisted Date when,
+      @Assisted Comparator<String> labelNameComparator) {
+    super(
+        cfg,
+        migration,
+        noteUtil,
+        serverIdent,
+        null,
+        change,
+        accountId,
+        realAccountId,
+        authorIdent,
+        when);
+    this.accountCache = accountCache;
+    this.draftUpdateFactory = draftUpdateFactory;
+    this.robotCommentUpdateFactory = robotCommentUpdateFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
+    this.approvals = approvals(labelNameComparator);
+  }
+
+  public ObjectId commit() throws IOException, OrmException {
+    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(getProjectName())) {
+      updateManager.add(this);
+      updateManager.stageAndApplyDelta(getChange());
+      updateManager.execute();
+    }
+    return getResult();
+  }
+
+  public void setChangeId(String changeId) {
+    String old = getChange().getKey().get();
+    checkArgument(
+        old.equals(changeId),
+        "The Change-Id was already set to %s, so we cannot set this Change-Id: %s",
+        old,
+        changeId);
+    this.changeId = changeId;
+  }
+
+  public void setBranch(String branch) {
+    this.branch = branch;
+  }
+
+  public void setStatus(Change.Status status) {
+    checkArgument(status != Change.Status.MERGED, "use merge(Iterable<SubmitRecord>)");
+    this.status = status;
+  }
+
+  public void fixStatus(Change.Status status) {
+    this.status = status;
+  }
+
+  public void putApproval(String label, short value) {
+    putApprovalFor(getAccountId(), label, value);
+  }
+
+  public void putApprovalFor(Account.Id reviewer, String label, short value) {
+    approvals.put(label, reviewer, Optional.of(value));
+  }
+
+  public void removeApproval(String label) {
+    removeApprovalFor(getAccountId(), label);
+  }
+
+  public void removeApprovalFor(Account.Id reviewer, String label) {
+    approvals.put(label, reviewer, Optional.empty());
+  }
+
+  public void merge(RequestId submissionId, Iterable<SubmitRecord> submitRecords) {
+    this.status = Change.Status.MERGED;
+    this.submissionId = submissionId.toStringForStorage();
+    this.submitRecords = ImmutableList.copyOf(submitRecords);
+    checkArgument(!this.submitRecords.isEmpty(), "no submit records specified at submit time");
+  }
+
+  @Deprecated // Only until we improve ChangeRebuilder to call merge().
+  public void setSubmissionId(String submissionId) {
+    this.submissionId = submissionId;
+  }
+
+  public void setSubjectForCommit(String commitSubject) {
+    this.commitSubject = commitSubject;
+  }
+
+  public void setSubject(String subject) {
+    this.subject = subject;
+  }
+
+  @VisibleForTesting
+  ObjectId getCommit() {
+    return ObjectId.fromString(commit);
+  }
+
+  public void setChangeMessage(String changeMessage) {
+    this.changeMessage = changeMessage;
+  }
+
+  public void setTag(String tag) {
+    this.tag = tag;
+  }
+
+  public void setPsDescription(String psDescription) {
+    this.psDescription = psDescription;
+  }
+
+  public void putComment(PatchLineComment.Status status, Comment c) {
+    verifyComment(c);
+    createDraftUpdateIfNull();
+    if (status == PatchLineComment.Status.DRAFT) {
+      draftUpdate.putComment(c);
+    } else {
+      comments.add(c);
+      // Always delete the corresponding comment from drafts. Published comments
+      // are immutable, meaning in normal operation we only hit this path when
+      // publishing a comment. It's exactly in that case that we have to delete
+      // the draft.
+      draftUpdate.deleteComment(c);
+    }
+  }
+
+  public void putRobotComment(RobotComment c) {
+    verifyComment(c);
+    createRobotCommentUpdateIfNull();
+    robotCommentUpdate.putComment(c);
+  }
+
+  public void deleteComment(Comment c) {
+    verifyComment(c);
+    createDraftUpdateIfNull().deleteComment(c);
+  }
+
+  public void deleteCommentByRewritingHistory(String uuid, String newMessage) {
+    deleteCommentRewriter =
+        deleteCommentRewriterFactory.create(getChange().getId(), uuid, newMessage);
+  }
+
+  @VisibleForTesting
+  ChangeDraftUpdate createDraftUpdateIfNull() {
+    if (draftUpdate == null) {
+      ChangeNotes notes = getNotes();
+      if (notes != null) {
+        draftUpdate = draftUpdateFactory.create(notes, accountId, realAccountId, authorIdent, when);
+      } else {
+        draftUpdate =
+            draftUpdateFactory.create(getChange(), accountId, realAccountId, authorIdent, when);
+      }
+    }
+    return draftUpdate;
+  }
+
+  @VisibleForTesting
+  RobotCommentUpdate createRobotCommentUpdateIfNull() {
+    if (robotCommentUpdate == null) {
+      ChangeNotes notes = getNotes();
+      if (notes != null) {
+        robotCommentUpdate =
+            robotCommentUpdateFactory.create(notes, accountId, realAccountId, authorIdent, when);
+      } else {
+        robotCommentUpdate =
+            robotCommentUpdateFactory.create(
+                getChange(), accountId, realAccountId, authorIdent, when);
+      }
+    }
+    return robotCommentUpdate;
+  }
+
+  public void setTopic(String topic) {
+    this.topic = Strings.nullToEmpty(topic);
+  }
+
+  public void setCommit(RevWalk rw, ObjectId id) throws IOException {
+    setCommit(rw, id, null);
+  }
+
+  public void setCommit(RevWalk rw, ObjectId id, String pushCert) throws IOException {
+    RevCommit commit = rw.parseCommit(id);
+    rw.parseBody(commit);
+    this.commit = commit.name();
+    subject = commit.getShortMessage();
+    this.pushCert = pushCert;
+  }
+
+  /**
+   * Set the revision without depending on the commit being present in the repository; should only
+   * be used for converting old corrupt commits.
+   */
+  public void setRevisionForMissingCommit(String id, String pushCert) {
+    commit = id;
+    this.pushCert = pushCert;
+  }
+
+  public void setHashtags(Set<String> hashtags) {
+    this.hashtags = hashtags;
+  }
+
+  public void setAssignee(Account.Id assignee) {
+    checkArgument(assignee != null, "use removeAssignee");
+    this.assignee = Optional.of(assignee);
+  }
+
+  public void removeAssignee() {
+    this.assignee = Optional.empty();
+  }
+
+  public Map<Account.Id, ReviewerStateInternal> getReviewers() {
+    return reviewers;
+  }
+
+  public void putReviewer(Account.Id reviewer, ReviewerStateInternal type) {
+    checkArgument(type != ReviewerStateInternal.REMOVED, "invalid ReviewerType");
+    reviewers.put(reviewer, type);
+  }
+
+  public void removeReviewer(Account.Id reviewer) {
+    reviewers.put(reviewer, ReviewerStateInternal.REMOVED);
+  }
+
+  public void putReviewerByEmail(Address reviewer, ReviewerStateInternal type) {
+    checkArgument(type != ReviewerStateInternal.REMOVED, "invalid ReviewerType");
+    reviewersByEmail.put(reviewer, type);
+  }
+
+  public void removeReviewerByEmail(Address reviewer) {
+    reviewersByEmail.put(reviewer, ReviewerStateInternal.REMOVED);
+  }
+
+  public void setPatchSetState(PatchSetState psState) {
+    this.psState = psState;
+  }
+
+  public void setCurrentPatchSet() {
+    this.currentPatchSet = true;
+  }
+
+  public void setGroups(List<String> groups) {
+    checkNotNull(groups, "groups may not be null");
+    this.groups = groups;
+  }
+
+  public void setRevertOf(int revertOf) {
+    int ownId = getChange().getId().get();
+    checkArgument(ownId != revertOf, "A change cannot revert itself");
+    this.revertOf = revertOf;
+    rootOnly = true;
+  }
+
+  /** @return the tree id for the updated tree */
+  private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
+      throws ConfigInvalidException, OrmException, IOException {
+    if (comments.isEmpty() && pushCert == null) {
+      return null;
+    }
+    RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
+
+    RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
+    for (Comment c : comments) {
+      c.tag = tag;
+      cache.get(new RevId(c.revId)).putComment(c);
+    }
+    if (pushCert != null) {
+      checkState(commit != null);
+      cache.get(new RevId(commit)).setPushCertificate(pushCert);
+    }
+    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    checkComments(rnm.revisionNotes, builders);
+
+    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
+      ObjectId data =
+          inserter.insert(OBJ_BLOB, e.getValue().build(noteUtil, noteUtil.getWriteJson()));
+      rnm.noteMap.set(ObjectId.fromString(e.getKey().get()), data);
+    }
+
+    return rnm.noteMap.writeTree(inserter);
+  }
+
+  private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr)
+      throws ConfigInvalidException, OrmException, IOException {
+    if (curr.equals(ObjectId.zeroId())) {
+      return RevisionNoteMap.emptyMap();
+    }
+    if (migration.readChanges()) {
+      // If reading from changes is enabled, then the old ChangeNotes may have
+      // already parsed the revision notes. We can reuse them as long as the ref
+      // hasn't advanced.
+      ChangeNotes notes = getNotes();
+      if (notes != null && notes.revisionNoteMap != null) {
+        ObjectId idFromNotes = firstNonNull(notes.load().getRevision(), ObjectId.zeroId());
+        if (idFromNotes.equals(curr)) {
+          return notes.revisionNoteMap;
+        }
+      }
+    }
+    NoteMap noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(curr));
+    // Even though reading from changes might not be enabled, we need to
+    // parse any existing revision notes so we can merge them.
+    return RevisionNoteMap.parse(
+        noteUtil, getId(), rw.getObjectReader(), noteMap, PatchLineComment.Status.PUBLISHED);
+  }
+
+  private void checkComments(
+      Map<RevId, ChangeRevisionNote> existingNotes, Map<RevId, RevisionNoteBuilder> toUpdate)
+      throws OrmException {
+    // Prohibit various kinds of illegal operations on comments.
+    Set<Comment.Key> existing = new HashSet<>();
+    for (ChangeRevisionNote rn : existingNotes.values()) {
+      for (Comment c : rn.getComments()) {
+        existing.add(c.key);
+        if (draftUpdate != null) {
+          // Take advantage of an existing update on All-Users to prune any
+          // published comments from drafts. NoteDbUpdateManager takes care of
+          // ensuring that this update is applied before its dependent draft
+          // update.
+          //
+          // Deleting aggressively in this way, combined with filtering out
+          // duplicate published/draft comments in ChangeNotes#getDraftComments,
+          // makes up for the fact that updates between the change repo and
+          // All-Users are not atomic.
+          //
+          // TODO(dborowitz): We might want to distinguish between deleted
+          // drafts that we're fixing up after the fact by putting them in a
+          // separate commit. But note that we don't care much about the commit
+          // graph of the draft ref, particularly because the ref is completely
+          // deleted when all drafts are gone.
+          draftUpdate.deleteComment(c.revId, c.key);
+        }
+      }
+    }
+
+    for (RevisionNoteBuilder b : toUpdate.values()) {
+      for (Comment c : b.put.values()) {
+        if (existing.contains(c.key)) {
+          throw new OrmException("Cannot update existing published comment: " + c);
+        }
+      }
+    }
+  }
+
+  @Override
+  protected String getRefName() {
+    return changeMetaRef(getId());
+  }
+
+  @Override
+  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
+      throws OrmException, IOException {
+    checkState(deleteCommentRewriter == null, "cannot update and rewrite ref in one BatchUpdate");
+
+    CommitBuilder cb = new CommitBuilder();
+
+    int ps = psId != null ? psId.get() : getChange().currentPatchSetId().get();
+    StringBuilder msg = new StringBuilder();
+    if (commitSubject != null) {
+      msg.append(commitSubject);
+    } else {
+      msg.append("Update patch set ").append(ps);
+    }
+    msg.append("\n\n");
+
+    if (changeMessage != null) {
+      msg.append(changeMessage);
+      msg.append("\n\n");
+    }
+
+    addPatchSetFooter(msg, ps);
+
+    if (currentPatchSet) {
+      addFooter(msg, FOOTER_CURRENT, Boolean.TRUE);
+    }
+
+    if (psDescription != null) {
+      addFooter(msg, FOOTER_PATCH_SET_DESCRIPTION, psDescription);
+    }
+
+    if (changeId != null) {
+      addFooter(msg, FOOTER_CHANGE_ID, changeId);
+    }
+
+    if (subject != null) {
+      addFooter(msg, FOOTER_SUBJECT, subject);
+    }
+
+    if (branch != null) {
+      addFooter(msg, FOOTER_BRANCH, branch);
+    }
+
+    if (status != null) {
+      addFooter(msg, FOOTER_STATUS, status.name().toLowerCase());
+    }
+
+    if (topic != null) {
+      addFooter(msg, FOOTER_TOPIC, topic);
+    }
+
+    if (commit != null) {
+      addFooter(msg, FOOTER_COMMIT, commit);
+    }
+
+    if (assignee != null) {
+      if (assignee.isPresent()) {
+        addFooter(msg, FOOTER_ASSIGNEE);
+        addIdent(msg, assignee.get()).append('\n');
+      } else {
+        addFooter(msg, FOOTER_ASSIGNEE).append('\n');
+      }
+    }
+
+    Joiner comma = Joiner.on(',');
+    if (hashtags != null) {
+      addFooter(msg, FOOTER_HASHTAGS, comma.join(hashtags));
+    }
+
+    if (tag != null) {
+      addFooter(msg, FOOTER_TAG, tag);
+    }
+
+    if (groups != null) {
+      addFooter(msg, FOOTER_GROUPS, comma.join(groups));
+    }
+
+    for (Map.Entry<Account.Id, ReviewerStateInternal> e : reviewers.entrySet()) {
+      addFooter(msg, e.getValue().getFooterKey());
+      addIdent(msg, e.getKey()).append('\n');
+    }
+
+    for (Map.Entry<Address, ReviewerStateInternal> e : reviewersByEmail.entrySet()) {
+      addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
+    }
+
+    for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
+      addFooter(msg, FOOTER_LABEL);
+      // Label names/values are safe to append without sanitizing.
+      if (!c.getValue().isPresent()) {
+        msg.append('-').append(c.getRowKey());
+      } else {
+        msg.append(LabelVote.create(c.getRowKey(), c.getValue().get()).formatWithEquals());
+      }
+      Account.Id id = c.getColumnKey();
+      if (!id.equals(getAccountId())) {
+        addIdent(msg.append(' '), id);
+      }
+      msg.append('\n');
+    }
+
+    if (submissionId != null) {
+      addFooter(msg, FOOTER_SUBMISSION_ID, submissionId);
+    }
+
+    if (submitRecords != null) {
+      for (SubmitRecord rec : submitRecords) {
+        addFooter(msg, FOOTER_SUBMITTED_WITH).append(rec.status);
+        if (rec.errorMessage != null) {
+          msg.append(' ').append(sanitizeFooter(rec.errorMessage));
+        }
+        msg.append('\n');
+
+        if (rec.labels != null) {
+          for (SubmitRecord.Label label : rec.labels) {
+            // Label names/values are safe to append without sanitizing.
+            addFooter(msg, FOOTER_SUBMITTED_WITH)
+                .append(label.status)
+                .append(": ")
+                .append(label.label);
+            if (label.appliedBy != null) {
+              msg.append(": ");
+              addIdent(msg, label.appliedBy);
+            }
+            msg.append('\n');
+          }
+        }
+      }
+    }
+
+    if (!Objects.equals(accountId, realAccountId)) {
+      addFooter(msg, FOOTER_REAL_USER);
+      addIdent(msg, realAccountId).append('\n');
+    }
+
+    if (readOnlyUntil != null) {
+      addFooter(msg, FOOTER_READ_ONLY_UNTIL, ChangeNoteUtil.formatTime(serverIdent, readOnlyUntil));
+    }
+
+    if (isPrivate != null) {
+      addFooter(msg, FOOTER_PRIVATE, isPrivate);
+    }
+
+    if (workInProgress != null) {
+      addFooter(msg, FOOTER_WORK_IN_PROGRESS, workInProgress);
+    }
+
+    if (revertOf != null) {
+      addFooter(msg, FOOTER_REVERT_OF, revertOf);
+    }
+
+    cb.setMessage(msg.toString());
+    try {
+      ObjectId treeId = storeRevisionNotes(rw, ins, curr);
+      if (treeId != null) {
+        cb.setTreeId(treeId);
+      }
+    } catch (ConfigInvalidException e) {
+      throw new OrmException(e);
+    }
+    return cb;
+  }
+
+  private void addPatchSetFooter(StringBuilder sb, int ps) {
+    addFooter(sb, FOOTER_PATCH_SET).append(ps);
+    if (psState != null) {
+      sb.append(" (").append(psState.name().toLowerCase()).append(')');
+    }
+    sb.append('\n');
+  }
+
+  @Override
+  protected Project.NameKey getProjectName() {
+    return getChange().getProject();
+  }
+
+  @Override
+  public boolean isEmpty() {
+    return commitSubject == null
+        && approvals.isEmpty()
+        && changeMessage == null
+        && comments.isEmpty()
+        && reviewers.isEmpty()
+        && reviewersByEmail.isEmpty()
+        && changeId == null
+        && branch == null
+        && status == null
+        && submissionId == null
+        && submitRecords == null
+        && assignee == null
+        && hashtags == null
+        && topic == null
+        && commit == null
+        && psState == null
+        && groups == null
+        && tag == null
+        && psDescription == null
+        && !currentPatchSet
+        && readOnlyUntil == null
+        && isPrivate == null
+        && workInProgress == null
+        && revertOf == null;
+  }
+
+  ChangeDraftUpdate getDraftUpdate() {
+    return draftUpdate;
+  }
+
+  RobotCommentUpdate getRobotCommentUpdate() {
+    return robotCommentUpdate;
+  }
+
+  public DeleteCommentRewriter getDeleteCommentRewriter() {
+    return deleteCommentRewriter;
+  }
+
+  public void setAllowWriteToNewRef(boolean allow) {
+    isAllowWriteToNewtRef = allow;
+  }
+
+  @Override
+  public boolean allowWriteToNewRef() {
+    return isAllowWriteToNewtRef;
+  }
+
+  public void setPrivate(boolean isPrivate) {
+    this.isPrivate = isPrivate;
+  }
+
+  public void setWorkInProgress(boolean workInProgress) {
+    this.workInProgress = workInProgress;
+  }
+
+  void setReadOnlyUntil(Timestamp readOnlyUntil) {
+    this.readOnlyUntil = readOnlyUntil;
+  }
+
+  private static StringBuilder addFooter(StringBuilder sb, FooterKey footer) {
+    return sb.append(footer.getName()).append(": ");
+  }
+
+  private static void addFooter(StringBuilder sb, FooterKey footer, Object... values) {
+    addFooter(sb, footer);
+    for (Object value : values) {
+      sb.append(sanitizeFooter(Objects.toString(value)));
+    }
+    sb.append('\n');
+  }
+
+  private StringBuilder addIdent(StringBuilder sb, Account.Id accountId) {
+    Account account = accountCache.get(accountId).getAccount();
+    PersonIdent ident = newIdent(account, when);
+
+    PersonIdent.appendSanitized(sb, ident.getName());
+    sb.append(" <");
+    PersonIdent.appendSanitized(sb, ident.getEmailAddress());
+    sb.append('>');
+    return sb;
+  }
+
+  @Override
+  protected void checkNotReadOnly() throws OrmException {
+    // Allow setting Read-only-until to 0 to release an existing lease.
+    if (readOnlyUntil != null && readOnlyUntil.getTime() == 0) {
+      return;
+    }
+    super.checkNotReadOnly();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java b/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
rename to java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
rename to java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
rename to java/com/google/gerrit/server/notedb/DraftCommentNotes.java
diff --git a/java/com/google/gerrit/server/notedb/GroupsMigration.java b/java/com/google/gerrit/server/notedb/GroupsMigration.java
new file mode 100644
index 0000000..293f3c6
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/GroupsMigration.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.gerrit.server.notedb.NoteDbTable.GROUPS;
+import static com.google.gerrit.server.notedb.NotesMigration.DISABLE_REVIEW_DB;
+import static com.google.gerrit.server.notedb.NotesMigration.READ;
+import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
+import static com.google.gerrit.server.notedb.NotesMigration.WRITE;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class GroupsMigration {
+  public static class Module extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(GroupsMigration.class);
+    }
+  }
+
+  private final boolean writeToNoteDb;
+  private final boolean readFromNoteDb;
+  private final boolean disableGroupReviewDb;
+
+  @Inject
+  public GroupsMigration(@GerritServerConfig Config cfg) {
+    // TODO(aliceks): Remove these flags when all other necessary TODOs for writing groups to
+    // NoteDb have been addressed.
+    // Don't flip these flags in a production setting! We only added them to spread the
+    // implementation of groups in NoteDb among several changes which are gradually merged.
+    this(
+        cfg.getBoolean(SECTION_NOTE_DB, GROUPS.key(), WRITE, false),
+        cfg.getBoolean(SECTION_NOTE_DB, GROUPS.key(), READ, false),
+        cfg.getBoolean(SECTION_NOTE_DB, GROUPS.key(), DISABLE_REVIEW_DB, false));
+  }
+
+  public GroupsMigration(
+      boolean writeToNoteDb, boolean readFromNoteDb, boolean disableGroupReviewDb) {
+    this.writeToNoteDb = writeToNoteDb;
+    this.readFromNoteDb = readFromNoteDb;
+    this.disableGroupReviewDb = disableGroupReviewDb;
+  }
+
+  public boolean writeToNoteDb() {
+    return writeToNoteDb;
+  }
+
+  public boolean readFromNoteDb() {
+    return readFromNoteDb;
+  }
+
+  public boolean disableGroupReviewDb() {
+    return disableGroupReviewDb;
+  }
+
+  public void setConfigValuesIfNotSetYet(Config cfg) {
+    Set<String> subsections = cfg.getSubsections(SECTION_NOTE_DB);
+    if (!subsections.contains(GROUPS.key())) {
+      cfg.setBoolean(SECTION_NOTE_DB, GROUPS.key(), WRITE, writeToNoteDb());
+      cfg.setBoolean(SECTION_NOTE_DB, GROUPS.key(), READ, readFromNoteDb());
+      cfg.setBoolean(SECTION_NOTE_DB, GROUPS.key(), DISABLE_REVIEW_DB, disableGroupReviewDb());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java b/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
rename to java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/MutableNotesMigration.java b/java/com/google/gerrit/server/notedb/MutableNotesMigration.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/MutableNotesMigration.java
rename to java/com/google/gerrit/server/notedb/MutableNotesMigration.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java b/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
rename to java/com/google/gerrit/server/notedb/NoteDbChangeState.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbMetrics.java b/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
rename to java/com/google/gerrit/server/notedb/NoteDbMetrics.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java b/java/com/google/gerrit/server/notedb/NoteDbModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
rename to java/com/google/gerrit/server/notedb/NoteDbModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbRewriter.java b/java/com/google/gerrit/server/notedb/NoteDbRewriter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbRewriter.java
rename to java/com/google/gerrit/server/notedb/NoteDbRewriter.java
diff --git a/java/com/google/gerrit/server/notedb/NoteDbTable.java b/java/com/google/gerrit/server/notedb/NoteDbTable.java
new file mode 100644
index 0000000..e299fdf
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/NoteDbTable.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+public enum NoteDbTable {
+  ACCOUNTS,
+  GROUPS,
+  CHANGES;
+
+  public String key() {
+    return name().toLowerCase();
+  }
+
+  @Override
+  public String toString() {
+    return key();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
rename to java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUtil.java b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
new file mode 100644
index 0000000..59c4c62
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.common.primitives.Ints;
+import com.google.gerrit.reviewdb.client.Account;
+import java.util.Optional;
+import org.eclipse.jgit.lib.PersonIdent;
+
+public class NoteDbUtil {
+  public static Optional<Account.Id> parseIdent(PersonIdent ident, String serverId) {
+    String email = ident.getEmailAddress();
+    int at = email.indexOf('@');
+    if (at >= 0) {
+      String host = email.substring(at + 1, email.length());
+      if (host.equals(serverId)) {
+        Integer id = Ints.tryParse(email.substring(0, at));
+        if (id != null) {
+          return Optional.of(new Account.Id(id));
+        }
+      }
+    }
+    return Optional.empty();
+  }
+
+  private NoteDbUtil() {}
+}
diff --git a/java/com/google/gerrit/server/notedb/NotesMigration.java b/java/com/google/gerrit/server/notedb/NotesMigration.java
new file mode 100644
index 0000000..9cee2cd
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/NotesMigration.java
@@ -0,0 +1,250 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.inject.AbstractModule;
+import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Current low-level settings of the NoteDb migration for changes.
+ *
+ * <p>This class only describes the migration state of the {@link
+ * com.google.gerrit.reviewdb.client.Change Change} entity group, since it is possible for a given
+ * site to be in different states of the Change NoteDb migration process while staying at the same
+ * ReviewDb schema version. It does <em>not</em> describe the migration state of non-Change tables;
+ * those are automatically migrated using the ReviewDb schema migration process, so the NoteDb
+ * migration state at a given ReviewDb schema cannot vary.
+ *
+ * <p>In many places, core Gerrit code should not directly care about the NoteDb migration state,
+ * and should prefer high-level APIs like {@link com.google.gerrit.server.ApprovalsUtil
+ * ApprovalsUtil} that don't require callers to inspect the migration state. The
+ * <em>implementation</em> of those utilities does care about the state, and should query the {@code
+ * NotesMigration} for the properties of the migration, for example, {@link #changePrimaryStorage()
+ * where new changes should be stored}.
+ *
+ * <p>Core Gerrit code is mostly interested in one facet of the migration at a time (reading or
+ * writing, say), but not all combinations of return values are supported or even make sense.
+ *
+ * <p>This class controls the state of the migration according to options in {@code gerrit.config}.
+ * In general, any changes to these options should only be made by adventurous administrators, who
+ * know what they're doing, on non-production data, for the purposes of testing the NoteDb
+ * implementation. Changing options quite likely requires re-running {@code MigrateToNoteDb}. For
+ * these reasons, the options remain undocumented.
+ *
+ * <p><strong>Note:</strong> Callers should not assume the values returned by {@code
+ * NotesMigration}'s methods will not change in a running server.
+ */
+public abstract class NotesMigration {
+  public static final String SECTION_NOTE_DB = "noteDb";
+  public static final String READ = "read";
+  public static final String WRITE = "write";
+  public static final String DISABLE_REVIEW_DB = "disableReviewDb";
+
+  private static final String PRIMARY_STORAGE = "primaryStorage";
+  private static final String SEQUENCE = "sequence";
+
+  public static class Module extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(MutableNotesMigration.class);
+      bind(NotesMigration.class).to(MutableNotesMigration.class);
+    }
+  }
+
+  @AutoValue
+  abstract static class Snapshot {
+    static Builder builder() {
+      // Default values are defined as what we would read from an empty config.
+      return create(new Config()).toBuilder();
+    }
+
+    static Snapshot create(Config cfg) {
+      return new AutoValue_NotesMigration_Snapshot.Builder()
+          .setWriteChanges(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, false))
+          .setReadChanges(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, false))
+          .setReadChangeSequence(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), SEQUENCE, false))
+          .setChangePrimaryStorage(
+              cfg.getEnum(
+                  SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.REVIEW_DB))
+          .setDisableChangeReviewDb(
+              cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, false))
+          .setFailOnLoadForTest(false) // Only set in tests, can't be set via config.
+          .build();
+    }
+
+    abstract boolean writeChanges();
+
+    abstract boolean readChanges();
+
+    abstract boolean readChangeSequence();
+
+    abstract PrimaryStorage changePrimaryStorage();
+
+    abstract boolean disableChangeReviewDb();
+
+    abstract boolean failOnLoadForTest();
+
+    abstract Builder toBuilder();
+
+    void setConfigValues(Config cfg) {
+      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, writeChanges());
+      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, readChanges());
+      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), SEQUENCE, readChangeSequence());
+      cfg.setEnum(SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, changePrimaryStorage());
+      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, disableChangeReviewDb());
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder setWriteChanges(boolean writeChanges);
+
+      abstract Builder setReadChanges(boolean readChanges);
+
+      abstract Builder setReadChangeSequence(boolean readChangeSequence);
+
+      abstract Builder setChangePrimaryStorage(PrimaryStorage changePrimaryStorage);
+
+      abstract Builder setDisableChangeReviewDb(boolean disableChangeReviewDb);
+
+      abstract Builder setFailOnLoadForTest(boolean failOnLoadForTest);
+
+      abstract Snapshot autoBuild();
+
+      Snapshot build() {
+        Snapshot s = autoBuild();
+        checkArgument(
+            !(s.disableChangeReviewDb() && s.changePrimaryStorage() != PrimaryStorage.NOTE_DB),
+            "cannot disable ReviewDb for changes if default change primary storage is ReviewDb");
+        return s;
+      }
+    }
+  }
+
+  protected final AtomicReference<Snapshot> snapshot;
+
+  /**
+   * Read changes from NoteDb.
+   *
+   * <p>Change data is read from NoteDb refs, but ReviewDb is still the source of truth. If the
+   * loader determines NoteDb is out of date, the change data in NoteDb will be transparently
+   * rebuilt. This means that some code paths that look read-only may in fact attempt to write.
+   *
+   * <p>If true and {@code writeChanges() = false}, changes can still be read from NoteDb, but any
+   * attempts to write will generate an error.
+   */
+  public final boolean readChanges() {
+    return snapshot.get().readChanges();
+  }
+
+  /**
+   * Write changes to NoteDb.
+   *
+   * <p>This method is awkwardly named because you should be using either {@link
+   * #commitChangeWrites()} or {@link #failChangeWrites()} instead.
+   *
+   * <p>Updates to change data are written to NoteDb refs, but ReviewDb is still the source of
+   * truth. Change data will not be written unless the NoteDb refs are already up to date, and the
+   * write path will attempt to rebuild the change if not.
+   *
+   * <p>If false, the behavior when attempting to write depends on {@code readChanges()}. If {@code
+   * readChanges() = false}, writes to NoteDb are simply ignored; if {@code true}, any attempts to
+   * write will generate an error.
+   */
+  public final boolean rawWriteChangesSetting() {
+    return snapshot.get().writeChanges();
+  }
+
+  /**
+   * Read sequential change ID numbers from NoteDb.
+   *
+   * <p>If true, change IDs are read from {@code refs/sequences/changes} in All-Projects. If false,
+   * change IDs are read from ReviewDb's native sequences.
+   */
+  public final boolean readChangeSequence() {
+    return snapshot.get().readChangeSequence();
+  }
+
+  /** @return default primary storage for new changes. */
+  public final PrimaryStorage changePrimaryStorage() {
+    return snapshot.get().changePrimaryStorage();
+  }
+
+  /**
+   * Disable ReviewDb access for changes.
+   *
+   * <p>When set, ReviewDb operations involving the Changes table become no-ops. Lookups return no
+   * results; updates do nothing, as does opening, committing, or rolling back a transaction on the
+   * Changes table.
+   */
+  public final boolean disableChangeReviewDb() {
+    return snapshot.get().disableChangeReviewDb();
+  }
+
+  /**
+   * Whether to fail when reading any data from NoteDb.
+   *
+   * <p>Used in conjunction with {@link #readChanges()} for tests.
+   */
+  public boolean failOnLoadForTest() {
+    return snapshot.get().failOnLoadForTest();
+  }
+
+  public final boolean commitChangeWrites() {
+    // It may seem odd that readChanges() without writeChanges() means we should
+    // attempt to commit writes. However, this method is used by callers to know
+    // whether or not they should short-circuit and skip attempting to read or
+    // write NoteDb refs.
+    //
+    // It is possible for commitChangeWrites() to return true and
+    // failChangeWrites() to also return true, causing an error later in the
+    // same codepath. This specific condition is used by the auto-rebuilding
+    // path to rebuild a change and stage the results, but not commit them due
+    // to failChangeWrites().
+    return rawWriteChangesSetting() || readChanges();
+  }
+
+  public final boolean failChangeWrites() {
+    return !rawWriteChangesSetting() && readChanges();
+  }
+
+  public final void setConfigValues(Config cfg) {
+    snapshot.get().setConfigValues(cfg);
+  }
+
+  @Override
+  public final boolean equals(Object o) {
+    return o instanceof NotesMigration
+        && snapshot.get().equals(((NotesMigration) o).snapshot.get());
+  }
+
+  @Override
+  public final int hashCode() {
+    return snapshot.get().hashCode();
+  }
+
+  protected NotesMigration(Snapshot snapshot) {
+    this.snapshot = new AtomicReference<>(snapshot);
+  }
+
+  final Snapshot snapshot() {
+    return snapshot.get();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigrationState.java b/java/com/google/gerrit/server/notedb/NotesMigrationState.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigrationState.java
rename to java/com/google/gerrit/server/notedb/NotesMigrationState.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PatchSetState.java b/java/com/google/gerrit/server/notedb/PatchSetState.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/PatchSetState.java
rename to java/com/google/gerrit/server/notedb/PatchSetState.java
diff --git a/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java b/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
new file mode 100644
index 0000000..e33ece9
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
@@ -0,0 +1,495 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.github.rholder.retry.WaitStrategies;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Stopwatch;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.RepoRefCache;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Helper to migrate the {@link PrimaryStorage} of individual changes. */
+@Singleton
+public class PrimaryStorageMigrator {
+  private static final Logger log = LoggerFactory.getLogger(PrimaryStorageMigrator.class);
+
+  private final AllUsersName allUsers;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeRebuilder rebuilder;
+  private final ChangeUpdate.Factory updateFactory;
+  private final GitRepositoryManager repoManager;
+  private final InternalUser.Factory internalUserFactory;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Provider<ReviewDb> db;
+  private final RetryHelper retryHelper;
+
+  private final long skewMs;
+  private final long timeoutMs;
+  private final Retryer<NoteDbChangeState> testEnsureRebuiltRetryer;
+
+  @Inject
+  PrimaryStorageMigrator(
+      @GerritServerConfig Config cfg,
+      Provider<ReviewDb> db,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      ChangeRebuilder rebuilder,
+      ChangeNotes.Factory changeNotesFactory,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeUpdate.Factory updateFactory,
+      InternalUser.Factory internalUserFactory,
+      RetryHelper retryHelper) {
+    this(
+        cfg,
+        db,
+        repoManager,
+        allUsers,
+        rebuilder,
+        null,
+        changeNotesFactory,
+        queryProvider,
+        updateFactory,
+        internalUserFactory,
+        retryHelper);
+  }
+
+  @VisibleForTesting
+  public PrimaryStorageMigrator(
+      Config cfg,
+      Provider<ReviewDb> db,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      ChangeRebuilder rebuilder,
+      @Nullable Retryer<NoteDbChangeState> testEnsureRebuiltRetryer,
+      ChangeNotes.Factory changeNotesFactory,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeUpdate.Factory updateFactory,
+      InternalUser.Factory internalUserFactory,
+      RetryHelper retryHelper) {
+    this.db = db;
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+    this.rebuilder = rebuilder;
+    this.testEnsureRebuiltRetryer = testEnsureRebuiltRetryer;
+    this.changeNotesFactory = changeNotesFactory;
+    this.queryProvider = queryProvider;
+    this.updateFactory = updateFactory;
+    this.internalUserFactory = internalUserFactory;
+    this.retryHelper = retryHelper;
+    skewMs = NoteDbChangeState.getReadOnlySkew(cfg);
+
+    String s = "notedb";
+    timeoutMs =
+        cfg.getTimeUnit(
+            s,
+            null,
+            "primaryStorageMigrationTimeout",
+            MILLISECONDS.convert(60, SECONDS),
+            MILLISECONDS);
+  }
+
+  /**
+   * Migrate a change's primary storage from ReviewDb to NoteDb.
+   *
+   * <p>This method will return only if the primary storage of the change is NoteDb afterwards. (It
+   * may return early if the primary storage was already NoteDb.)
+   *
+   * <p>If this method throws an exception, then the primary storage of the change is probably not
+   * NoteDb. (It is possible that the primary storage of the change is NoteDb in this case, but
+   * there was an error reading the state.) Moreover, after an exception, the change may be
+   * read-only until a lease expires. If the caller chooses to retry, they should wait until the
+   * read-only lease expires; this method will fail relatively quickly if called on a read-only
+   * change.
+   *
+   * <p>Note that if the change is read-only after this method throws an exception, that does not
+   * necessarily guarantee that the read-only lease was acquired during that particular method
+   * invocation; this call may have in fact failed because another thread acquired the lease first.
+   *
+   * @param id change ID.
+   * @throws OrmException if a ReviewDb-level error occurs.
+   * @throws IOException if a repo-level error occurs.
+   */
+  public void migrateToNoteDbPrimary(Change.Id id) throws OrmException, IOException {
+    // Since there are multiple non-atomic steps in this method, we need to
+    // consider what happens when there is another writer concurrent with the
+    // thread executing this method.
+    //
+    // Let:
+    // * OR = other writer writes noteDbState & new data to ReviewDb (in one
+    //        transaction)
+    // * ON = other writer writes to NoteDb
+    // * MRO = migrator sets state to read-only
+    // * MR = ensureRebuilt writes rebuilt noteDbState to ReviewDb (but does not
+    //        otherwise update ReviewDb in this transaction)
+    // * MN = ensureRebuilt writes rebuilt state to NoteDb
+    //
+    // Consider all the interleavings of these operations.
+    //
+    // * OR,ON,MRO,...
+    //   Other writer completes before migrator begins; this is not a concurrent
+    //   write.
+    // * MRO,...,OR,...
+    //   OR will fail, since it atomically checks that the noteDbState is not
+    //   read-only before proceeding. This results in an exception, but not a
+    //   concurrent write.
+    //
+    // Thus all the "interesting" interleavings start with OR,MRO, and differ on
+    // where ON falls relative to MR/MN.
+    //
+    // * OR,MRO,ON,MR,MN
+    //   The other NoteDb write succeeds despite the noteDbState being
+    //   read-only. Because the read-only state from MRO includes the update
+    //   from OR, the change is up-to-date at this point. Thus MR,MN is a no-op.
+    //   The end result is an up-to-date, read-only change.
+    //
+    // * OR,MRO,MR,ON,MN
+    //   The change is out-of-date when ensureRebuilt begins, because OR
+    //   succeeded but the corresponding ON has not happened yet. ON will
+    //   succeed, because there have been no intervening NoteDb writes. MN will
+    //   fail, because ON updated the state in NoteDb to something other than
+    //   what MR claimed. This leaves the change in an out-of-date, read-only
+    //   state.
+    //
+    //   If this method threw an exception in this case, the change would
+    //   eventually switch back to read-write when the read-only lease expires,
+    //   so this situation is recoverable. However, it would be inconvenient for
+    //   a change to be read-only for so long.
+    //
+    //   Thus, as an optimization, we have a retry loop that attempts
+    //   ensureRebuilt while still holding the same read-only lease. This
+    //   effectively results in the interleaving OR,MR,ON,MR,MN; in contrast
+    //   with the previous case, here, MR/MN actually rebuilds the change. In
+    //   the case of a write failure, MR/MN might fail and get retried again. If
+    //   it exceeds the maximum number of retries, an exception is thrown.
+    //
+    // * OR,MRO,MR,MN,ON
+    //   The change is out-of-date when ensureRebuilt begins. The change is
+    //   rebuilt, leaving a new state in NoteDb. ON will fail, because the old
+    //   NoteDb state has changed since the ref state was read when the update
+    //   began (prior to OR). This results in an exception from ON, but the end
+    //   result is still an up-to-date, read-only change. The end user that
+    //   initiated the other write observes an error, but this is no different
+    //   from other errors that need retrying, e.g. due to a backend write
+    //   failure.
+
+    Stopwatch sw = Stopwatch.createStarted();
+    Change readOnlyChange = setReadOnlyInReviewDb(id); // MRO
+    if (readOnlyChange == null) {
+      return; // Already migrated.
+    }
+
+    NoteDbChangeState rebuiltState;
+    try {
+      // MR,MN
+      rebuiltState =
+          ensureRebuiltRetryer(sw)
+              .call(
+                  () ->
+                      ensureRebuilt(
+                          readOnlyChange.getProject(),
+                          id,
+                          NoteDbChangeState.parse(readOnlyChange)));
+    } catch (RetryException | ExecutionException e) {
+      throw new OrmException(e);
+    }
+
+    // At this point, the noteDbState in ReviewDb is read-only, and it is
+    // guaranteed to match the state actually in NoteDb. Now it is safe to set
+    // the primary storage to NoteDb.
+
+    setPrimaryStorageNoteDb(id, rebuiltState);
+    log.debug("Migrated change {} to NoteDb primary in {}ms", id, sw.elapsed(MILLISECONDS));
+  }
+
+  private Change setReadOnlyInReviewDb(Change.Id id) throws OrmException {
+    AtomicBoolean alreadyMigrated = new AtomicBoolean(false);
+    Change result =
+        db().changes()
+            .atomicUpdate(
+                id,
+                new AtomicUpdate<Change>() {
+                  @Override
+                  public Change update(Change change) {
+                    NoteDbChangeState state = NoteDbChangeState.parse(change);
+                    if (state == null) {
+                      // Could rebuild the change here, but that's more complexity, and this
+                      // really shouldn't happen.
+                      throw new OrmRuntimeException(
+                          "change " + id + " has no note_db_state; rebuild it first");
+                    }
+                    // If the change is already read-only, then the lease is held by another
+                    // (likely failed) migrator thread. Fail early, as we can't take over
+                    // the lease.
+                    NoteDbChangeState.checkNotReadOnly(change, skewMs);
+                    if (state.getPrimaryStorage() != PrimaryStorage.NOTE_DB) {
+                      Timestamp now = TimeUtil.nowTs();
+                      Timestamp until = new Timestamp(now.getTime() + timeoutMs);
+                      change.setNoteDbState(state.withReadOnlyUntil(until).toString());
+                    } else {
+                      alreadyMigrated.set(true);
+                    }
+                    return change;
+                  }
+                });
+    return alreadyMigrated.get() ? null : result;
+  }
+
+  private Retryer<NoteDbChangeState> ensureRebuiltRetryer(Stopwatch sw) {
+    if (testEnsureRebuiltRetryer != null) {
+      return testEnsureRebuiltRetryer;
+    }
+    // Retry the ensureRebuilt step with backoff until half the timeout has
+    // expired, leaving the remaining half for the rest of the steps.
+    long remainingNanos = (MILLISECONDS.toNanos(timeoutMs) / 2) - sw.elapsed(NANOSECONDS);
+    remainingNanos = Math.max(remainingNanos, 0);
+    return RetryerBuilder.<NoteDbChangeState>newBuilder()
+        .retryIfException(e -> (e instanceof IOException) || (e instanceof OrmException))
+        .withWaitStrategy(
+            WaitStrategies.join(
+                WaitStrategies.exponentialWait(250, MILLISECONDS),
+                WaitStrategies.randomWait(50, MILLISECONDS)))
+        .withStopStrategy(StopStrategies.stopAfterDelay(remainingNanos, NANOSECONDS))
+        .build();
+  }
+
+  private NoteDbChangeState ensureRebuilt(
+      Project.NameKey project, Change.Id id, NoteDbChangeState readOnlyState)
+      throws IOException, OrmException, RepositoryNotFoundException {
+    try (Repository changeRepo = repoManager.openRepository(project);
+        Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      if (!readOnlyState.isUpToDate(new RepoRefCache(changeRepo), new RepoRefCache(allUsersRepo))) {
+        NoteDbUpdateManager.Result r = rebuilder.rebuildEvenIfReadOnly(db(), id);
+        checkState(
+            r.newState().getReadOnlyUntil().equals(readOnlyState.getReadOnlyUntil()),
+            "state after rebuilding has different read-only lease: %s != %s",
+            r.newState(),
+            readOnlyState);
+        readOnlyState = r.newState();
+      }
+    }
+    return readOnlyState;
+  }
+
+  private void setPrimaryStorageNoteDb(Change.Id id, NoteDbChangeState expectedState)
+      throws OrmException {
+    db().changes()
+        .atomicUpdate(
+            id,
+            new AtomicUpdate<Change>() {
+              @Override
+              public Change update(Change change) {
+                NoteDbChangeState state = NoteDbChangeState.parse(change);
+                if (!Objects.equals(state, expectedState)) {
+                  throw new OrmRuntimeException(badState(state, expectedState));
+                }
+                Timestamp until = state.getReadOnlyUntil().get();
+                if (TimeUtil.nowTs().after(until)) {
+                  throw new OrmRuntimeException(
+                      "read-only lease on change " + id + " expired at " + until);
+                }
+                change.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+                return change;
+              }
+            });
+  }
+
+  private ReviewDb db() {
+    return ReviewDbUtil.unwrapDb(db.get());
+  }
+
+  private String badState(NoteDbChangeState actual, NoteDbChangeState expected) {
+    return "state changed unexpectedly: " + actual + " != " + expected;
+  }
+
+  public void migrateToReviewDbPrimary(Change.Id id, @Nullable Project.NameKey project)
+      throws OrmException, IOException {
+    // Migrating back to ReviewDb primary is much simpler than the original migration to NoteDb
+    // primary, because when NoteDb is primary, each write only goes to one storage location rather
+    // than both. We only need to consider whether a concurrent writer (OR) conflicts with the first
+    // setReadOnlyInNoteDb step (MR) in this method.
+    //
+    // If OR wins, then either:
+    // * MR will set read-only after OR is completed, which is not a concurrent write.
+    // * MR will fail to set read-only with a lock failure. The caller will have to retry, but the
+    //   change is not in a read-only state, so behavior is not degraded in the meantime.
+    //
+    // If MR wins, then either:
+    // * OR will fail with a read-only exception (via AbstractChangeNotes#apply).
+    // * OR will fail with a lock failure.
+    //
+    // In all of these scenarios, the change is read-only if and only if MR succeeds.
+    //
+    // There will be no concurrent writes to ReviewDb for this change until
+    // setPrimaryStorageReviewDb completes, because ReviewDb writes are not attempted when primary
+    // storage is NoteDb. After the primary storage changes back, it is possible for subsequent
+    // NoteDb writes to conflict with the releaseReadOnlyLeaseInNoteDb step, but at this point,
+    // since ReviewDb is primary, we are back to ignoring them.
+    Stopwatch sw = Stopwatch.createStarted();
+    if (project == null) {
+      project = getProject(id);
+    }
+    ObjectId newMetaId = setReadOnlyInNoteDb(project, id);
+    rebuilder.rebuildReviewDb(db(), project, id);
+    setPrimaryStorageReviewDb(id, newMetaId);
+    releaseReadOnlyLeaseInNoteDb(project, id);
+    log.debug("Migrated change {} to ReviewDb primary in {}ms", id, sw.elapsed(MILLISECONDS));
+  }
+
+  private ObjectId setReadOnlyInNoteDb(Project.NameKey project, Change.Id id)
+      throws OrmException, IOException {
+    Timestamp now = TimeUtil.nowTs();
+    Timestamp until = new Timestamp(now.getTime() + timeoutMs);
+    ChangeUpdate update =
+        updateFactory.create(
+            changeNotesFactory.createChecked(db.get(), project, id), internalUserFactory.create());
+    update.setReadOnlyUntil(until);
+    return update.commit();
+  }
+
+  private void setPrimaryStorageReviewDb(Change.Id id, ObjectId newMetaId)
+      throws OrmException, IOException {
+    ImmutableMap.Builder<Account.Id, ObjectId> draftIds = ImmutableMap.builder();
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      for (Ref draftRef :
+          repo.getRefDatabase().getRefs(RefNames.refsDraftCommentsPrefix(id)).values()) {
+        Account.Id accountId = Account.Id.fromRef(draftRef.getName());
+        if (accountId != null) {
+          draftIds.put(accountId, draftRef.getObjectId().copy());
+        }
+      }
+    }
+    NoteDbChangeState newState =
+        new NoteDbChangeState(
+            id,
+            PrimaryStorage.REVIEW_DB,
+            Optional.of(RefState.create(newMetaId, draftIds.build())),
+            Optional.empty());
+    db().changes()
+        .atomicUpdate(
+            id,
+            new AtomicUpdate<Change>() {
+              @Override
+              public Change update(Change change) {
+                if (PrimaryStorage.of(change) != PrimaryStorage.NOTE_DB) {
+                  throw new OrmRuntimeException(
+                      "change " + id + " is not NoteDb primary: " + change.getNoteDbState());
+                }
+                change.setNoteDbState(newState.toString());
+                return change;
+              }
+            });
+  }
+
+  private void releaseReadOnlyLeaseInNoteDb(Project.NameKey project, Change.Id id)
+      throws OrmException {
+    // Use a BatchUpdate since ReviewDb is primary at this point, so it needs to reflect the update.
+    // (In practice retrying won't happen, since we aren't using fused updates at this point.)
+    try {
+      retryHelper.execute(
+          updateFactory -> {
+            try (BatchUpdate bu =
+                updateFactory.create(
+                    db.get(), project, internalUserFactory.create(), TimeUtil.nowTs())) {
+              bu.addOp(
+                  id,
+                  new BatchUpdateOp() {
+                    @Override
+                    public boolean updateChange(ChangeContext ctx) {
+                      ctx.getUpdate(ctx.getChange().currentPatchSetId())
+                          .setReadOnlyUntil(new Timestamp(0));
+                      return true;
+                    }
+                  });
+              bu.execute();
+              return null;
+            }
+          });
+    } catch (RestApiException | UpdateException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  private Project.NameKey getProject(Change.Id id) throws OrmException {
+    List<ChangeData> cds =
+        queryProvider.get().setRequestedFields(ChangeField.PROJECT).byLegacyChangeId(id);
+    Set<Project.NameKey> projects = new TreeSet<>();
+    for (ChangeData cd : cds) {
+      projects.add(cd.project());
+    }
+    if (projects.size() != 1) {
+      throw new OrmException(
+          "zero or multiple projects found for change "
+              + id
+              + ", must specify project explicitly: "
+              + projects);
+    }
+    return projects.iterator().next();
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java
new file mode 100644
index 0000000..3554f4b
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -0,0 +1,360 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_SEQUENCES;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.github.rholder.retry.WaitStrategies;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Predicates;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Ints;
+import com.google.common.util.concurrent.Runnables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * Class for managing an incrementing sequence backed by a git repository.
+ *
+ * <p>The current sequence number is stored as UTF-8 text in a blob pointed to by a ref in the
+ * {@code refs/sequences/*} namespace. Multiple processes can share the same sequence by
+ * incrementing the counter using normal git ref updates. To amortize the cost of these ref updates,
+ * processes can increment the counter by a larger number and hand out numbers from that range in
+ * memory until they run out. This means concurrent processes will hand out somewhat non-monotonic
+ * numbers.
+ */
+public class RepoSequence {
+  @FunctionalInterface
+  public interface Seed {
+    int get() throws OrmException;
+  }
+
+  @VisibleForTesting
+  static RetryerBuilder<RefUpdate.Result> retryerBuilder() {
+    return RetryerBuilder.<RefUpdate.Result>newBuilder()
+        .retryIfResult(Predicates.equalTo(RefUpdate.Result.LOCK_FAILURE))
+        .withWaitStrategy(
+            WaitStrategies.join(
+                WaitStrategies.exponentialWait(5, TimeUnit.SECONDS),
+                WaitStrategies.randomWait(50, TimeUnit.MILLISECONDS)))
+        .withStopStrategy(StopStrategies.stopAfterDelay(30, TimeUnit.SECONDS));
+  }
+
+  private static final Retryer<RefUpdate.Result> RETRYER = retryerBuilder().build();
+
+  private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final Project.NameKey projectName;
+  private final String refName;
+  private final Seed seed;
+  private final int batchSize;
+  private final Runnable afterReadRef;
+  private final Retryer<RefUpdate.Result> retryer;
+
+  // Protects all non-final fields.
+  private final Lock counterLock;
+
+  private int limit;
+  private int counter;
+
+  @VisibleForTesting int acquireCount;
+
+  public RepoSequence(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      Project.NameKey projectName,
+      String name,
+      Seed seed,
+      int batchSize) {
+    this(
+        repoManager,
+        gitRefUpdated,
+        projectName,
+        name,
+        seed,
+        batchSize,
+        Runnables.doNothing(),
+        RETRYER);
+  }
+
+  @VisibleForTesting
+  RepoSequence(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      Project.NameKey projectName,
+      String name,
+      Seed seed,
+      int batchSize,
+      Runnable afterReadRef,
+      Retryer<RefUpdate.Result> retryer) {
+    this.repoManager = checkNotNull(repoManager, "repoManager");
+    this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
+    this.projectName = checkNotNull(projectName, "projectName");
+
+    checkArgument(
+        name != null
+            && !name.startsWith(REFS)
+            && !name.startsWith(REFS_SEQUENCES.substring(REFS.length())),
+        "name should be a suffix to follow \"refs/sequences/\", got: %s",
+        name);
+    this.refName = RefNames.REFS_SEQUENCES + name;
+
+    this.seed = checkNotNull(seed, "seed");
+
+    checkArgument(batchSize > 0, "expected batchSize > 0, got: %s", batchSize);
+    this.batchSize = batchSize;
+    this.afterReadRef = checkNotNull(afterReadRef, "afterReadRef");
+    this.retryer = checkNotNull(retryer, "retryer");
+
+    counterLock = new ReentrantLock(true);
+  }
+
+  public int next() throws OrmException {
+    counterLock.lock();
+    try {
+      if (counter >= limit) {
+        acquire(batchSize);
+      }
+      return counter++;
+    } finally {
+      counterLock.unlock();
+    }
+  }
+
+  public ImmutableList<Integer> next(int count) throws OrmException {
+    if (count == 0) {
+      return ImmutableList.of();
+    }
+    checkArgument(count > 0, "count is negative: %s", count);
+    counterLock.lock();
+    try {
+      List<Integer> ids = new ArrayList<>(count);
+      while (counter < limit) {
+        ids.add(counter++);
+        if (ids.size() == count) {
+          return ImmutableList.copyOf(ids);
+        }
+      }
+      acquire(Math.max(count - ids.size(), batchSize));
+      while (ids.size() < count) {
+        ids.add(counter++);
+      }
+      return ImmutableList.copyOf(ids);
+    } finally {
+      counterLock.unlock();
+    }
+  }
+
+  @VisibleForTesting
+  public void set(int val) throws OrmException {
+    // Don't bother spinning. This is only for tests, and a test that calls set
+    // concurrently with other writes is doing it wrong.
+    counterLock.lock();
+    try {
+      try (Repository repo = repoManager.openRepository(projectName);
+          RevWalk rw = new RevWalk(repo)) {
+        checkResult(store(repo, rw, null, val));
+        counter = limit;
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    } finally {
+      counterLock.unlock();
+    }
+  }
+
+  public void increaseTo(int val) throws OrmException {
+    counterLock.lock();
+    try {
+      try (Repository repo = repoManager.openRepository(projectName);
+          RevWalk rw = new RevWalk(repo)) {
+        TryIncreaseTo attempt = new TryIncreaseTo(repo, rw, val);
+        checkResult(retryer.call(attempt));
+        counter = limit;
+      } catch (ExecutionException | RetryException e) {
+        if (e.getCause() != null) {
+          Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
+        }
+        throw new OrmException(e);
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    } finally {
+      counterLock.unlock();
+    }
+  }
+
+  private void acquire(int count) throws OrmException {
+    try (Repository repo = repoManager.openRepository(projectName);
+        RevWalk rw = new RevWalk(repo)) {
+      TryAcquire attempt = new TryAcquire(repo, rw, count);
+      checkResult(retryer.call(attempt));
+      counter = attempt.next;
+      limit = counter + count;
+      acquireCount++;
+    } catch (ExecutionException | RetryException e) {
+      if (e.getCause() != null) {
+        Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
+      }
+      throw new OrmException(e);
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  private void checkResult(RefUpdate.Result result) throws OrmException {
+    if (!refUpdated(result) && result != Result.NO_CHANGE) {
+      throw new OrmException("failed to update " + refName + ": " + result);
+    }
+  }
+
+  private boolean refUpdated(RefUpdate.Result result) {
+    return result == RefUpdate.Result.NEW || result == RefUpdate.Result.FORCED;
+  }
+
+  private class TryAcquire implements Callable<RefUpdate.Result> {
+    private final Repository repo;
+    private final RevWalk rw;
+    private final int count;
+
+    private int next;
+
+    private TryAcquire(Repository repo, RevWalk rw, int count) {
+      this.repo = repo;
+      this.rw = rw;
+      this.count = count;
+    }
+
+    @Override
+    public RefUpdate.Result call() throws Exception {
+      Ref ref = repo.exactRef(refName);
+      afterReadRef.run();
+      ObjectId oldId;
+      if (ref == null) {
+        oldId = ObjectId.zeroId();
+        next = seed.get();
+      } else {
+        oldId = ref.getObjectId();
+        next = parse(rw, oldId);
+      }
+      return store(repo, rw, oldId, next + count);
+    }
+  }
+
+  private class TryIncreaseTo implements Callable<RefUpdate.Result> {
+    private final Repository repo;
+    private final RevWalk rw;
+    private final int value;
+
+    private TryIncreaseTo(Repository repo, RevWalk rw, int value) {
+      this.repo = repo;
+      this.rw = rw;
+      this.value = value;
+    }
+
+    @Override
+    public RefUpdate.Result call() throws Exception {
+      Ref ref = repo.exactRef(refName);
+      afterReadRef.run();
+      ObjectId oldId;
+      if (ref == null) {
+        oldId = ObjectId.zeroId();
+      } else {
+        oldId = ref.getObjectId();
+        int next = parse(rw, oldId);
+        if (next >= value) {
+          // a concurrent write updated the ref already to this or a higher value
+          return RefUpdate.Result.NO_CHANGE;
+        }
+      }
+      return store(repo, rw, oldId, value);
+    }
+  }
+
+  private int parse(RevWalk rw, ObjectId id) throws IOException, OrmException {
+    ObjectLoader ol = rw.getObjectReader().open(id, OBJ_BLOB);
+    if (ol.getType() != OBJ_BLOB) {
+      // In theory this should be thrown by open but not all implementations
+      // may do it properly (certainly InMemoryRepository doesn't).
+      throw new IncorrectObjectTypeException(id, OBJ_BLOB);
+    }
+    String str = CharMatcher.whitespace().trimFrom(new String(ol.getCachedBytes(), UTF_8));
+    Integer val = Ints.tryParse(str);
+    if (val == null) {
+      throw new OrmException("invalid value in " + refName + " blob at " + id.name());
+    }
+    return val;
+  }
+
+  private RefUpdate.Result store(Repository repo, RevWalk rw, @Nullable ObjectId oldId, int val)
+      throws IOException {
+    ObjectId newId;
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
+      ins.flush();
+    }
+    RefUpdate ru = repo.updateRef(refName);
+    if (oldId != null) {
+      ru.setExpectedOldObjectId(oldId);
+    }
+    ru.disableRefLog();
+    ru.setNewObjectId(newId);
+    ru.setForceUpdate(true); // Required for non-commitish updates.
+    RefUpdate.Result result = ru.update(rw);
+    if (refUpdated(result)) {
+      gitRefUpdated.fire(projectName, ru, null);
+    }
+    return result;
+  }
+
+  public static ReceiveCommand storeNew(ObjectInserter ins, String name, int val)
+      throws IOException {
+    ObjectId newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
+    return new ReceiveCommand(ObjectId.zeroId(), newId, RefNames.REFS_SEQUENCES + name);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java b/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
rename to java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java b/java/com/google/gerrit/server/notedb/RevisionNote.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
rename to java/com/google/gerrit/server/notedb/RevisionNote.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
rename to java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteData.java b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteData.java
rename to java/com/google/gerrit/server/notedb/RevisionNoteData.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
rename to java/com/google/gerrit/server/notedb/RevisionNoteMap.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
rename to java/com/google/gerrit/server/notedb/RobotCommentNotes.java
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
new file mode 100644
index 0000000..c28125f
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -0,0 +1,234 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.reviewdb.client.RefNames.robotCommentsRef;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A single delta to apply atomically to a change.
+ *
+ * <p>This delta contains only robot comments on a single patch set of a change by a single author.
+ * This delta will become a single commit in the repository.
+ *
+ * <p>This class is not thread safe.
+ */
+public class RobotCommentUpdate extends AbstractChangeUpdate {
+  public interface Factory {
+    RobotCommentUpdate create(
+        ChangeNotes notes,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
+
+    RobotCommentUpdate create(
+        Change change,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
+  }
+
+  private List<RobotComment> put = new ArrayList<>();
+
+  @AssistedInject
+  private RobotCommentUpdate(
+      @GerritServerConfig Config cfg,
+      @GerritPersonIdent PersonIdent serverIdent,
+      NotesMigration migration,
+      ChangeNoteUtil noteUtil,
+      @Assisted ChangeNotes notes,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
+      @Assisted PersonIdent authorIdent,
+      @Assisted Date when) {
+    super(
+        cfg,
+        migration,
+        noteUtil,
+        serverIdent,
+        notes,
+        null,
+        accountId,
+        realAccountId,
+        authorIdent,
+        when);
+  }
+
+  @AssistedInject
+  private RobotCommentUpdate(
+      @GerritServerConfig Config cfg,
+      @GerritPersonIdent PersonIdent serverIdent,
+      NotesMigration migration,
+      ChangeNoteUtil noteUtil,
+      @Assisted Change change,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
+      @Assisted PersonIdent authorIdent,
+      @Assisted Date when) {
+    super(
+        cfg,
+        migration,
+        noteUtil,
+        serverIdent,
+        null,
+        change,
+        accountId,
+        realAccountId,
+        authorIdent,
+        when);
+  }
+
+  public void putComment(RobotComment c) {
+    verifyComment(c);
+    put.add(c);
+  }
+
+  private CommitBuilder storeCommentsInNotes(
+      RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
+      throws ConfigInvalidException, OrmException, IOException {
+    RevisionNoteMap<RobotCommentsRevisionNote> rnm = getRevisionNoteMap(rw, curr);
+    Set<RevId> updatedRevs = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
+    RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
+
+    for (RobotComment c : put) {
+      cache.get(new RevId(c.revId)).putComment(c);
+    }
+
+    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    boolean touchedAnyRevs = false;
+    boolean hasComments = false;
+    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
+      updatedRevs.add(e.getKey());
+      ObjectId id = ObjectId.fromString(e.getKey().get());
+      byte[] data = e.getValue().build(noteUtil, true);
+      if (!Arrays.equals(data, e.getValue().baseRaw)) {
+        touchedAnyRevs = true;
+      }
+      if (data.length == 0) {
+        rnm.noteMap.remove(id);
+      } else {
+        hasComments = true;
+        ObjectId dataBlob = ins.insert(OBJ_BLOB, data);
+        rnm.noteMap.set(id, dataBlob);
+      }
+    }
+
+    // If we didn't touch any notes, tell the caller this was a no-op update. We
+    // couldn't have done this in isEmpty() below because we hadn't read the old
+    // data yet.
+    if (!touchedAnyRevs) {
+      return NO_OP_UPDATE;
+    }
+
+    // If we touched every revision and there are no comments left, tell the
+    // caller to delete the entire ref.
+    boolean touchedAllRevs = updatedRevs.equals(rnm.revisionNotes.keySet());
+    if (touchedAllRevs && !hasComments) {
+      return null;
+    }
+
+    cb.setTreeId(rnm.noteMap.writeTree(ins));
+    return cb;
+  }
+
+  private RevisionNoteMap<RobotCommentsRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr)
+      throws ConfigInvalidException, OrmException, IOException {
+    if (curr.equals(ObjectId.zeroId())) {
+      return RevisionNoteMap.emptyMap();
+    }
+    if (migration.readChanges()) {
+      // If reading from changes is enabled, then the old RobotCommentNotes
+      // already parsed the revision notes. We can reuse them as long as the ref
+      // hasn't advanced.
+      ChangeNotes changeNotes = getNotes();
+      if (changeNotes != null) {
+        RobotCommentNotes robotCommentNotes = changeNotes.load().getRobotCommentNotes();
+        if (robotCommentNotes != null) {
+          ObjectId idFromNotes = firstNonNull(robotCommentNotes.getRevision(), ObjectId.zeroId());
+          RevisionNoteMap<RobotCommentsRevisionNote> rnm = robotCommentNotes.getRevisionNoteMap();
+          if (idFromNotes.equals(curr) && rnm != null) {
+            return rnm;
+          }
+        }
+      }
+    }
+    NoteMap noteMap;
+    if (!curr.equals(ObjectId.zeroId())) {
+      noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(curr));
+    } else {
+      noteMap = NoteMap.newEmptyMap();
+    }
+    // Even though reading from changes might not be enabled, we need to
+    // parse any existing revision notes so we can merge them.
+    return RevisionNoteMap.parseRobotComments(noteUtil, rw.getObjectReader(), noteMap);
+  }
+
+  @Override
+  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
+      throws OrmException, IOException {
+    CommitBuilder cb = new CommitBuilder();
+    cb.setMessage("Update robot comments");
+    try {
+      return storeCommentsInNotes(rw, ins, curr, cb);
+    } catch (ConfigInvalidException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  @Override
+  protected Project.NameKey getProjectName() {
+    return getNotes().getProjectName();
+  }
+
+  @Override
+  protected String getRefName() {
+    return robotCommentsRef(getId());
+  }
+
+  @Override
+  public boolean isEmpty() {
+    return put.isEmpty();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java b/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
rename to java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java b/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java
rename to java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java b/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
rename to java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/AbortUpdateException.java b/java/com/google/gerrit/server/notedb/rebuild/AbortUpdateException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/AbortUpdateException.java
rename to java/com/google/gerrit/server/notedb/rebuild/AbortUpdateException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java b/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java
rename to java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java b/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
rename to java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
rename to java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
diff --git a/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
new file mode 100644
index 0000000..2d1b076
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
@@ -0,0 +1,691 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableCollection;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Table;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.ChangeDraftUpdate;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.OpenRepo;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
+import com.google.gwtorm.client.Key;
+import com.google.gwtorm.server.Access;
+import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+public class ChangeRebuilderImpl extends ChangeRebuilder {
+  /**
+   * The maximum amount of time between the ReviewDb timestamp of the first and last events batched
+   * together into a single NoteDb update.
+   *
+   * <p>Used to account for the fact that different records with their own timestamps (e.g. {@link
+   * PatchSetApproval} and {@link ChangeMessage}) historically didn't necessarily use the same
+   * timestamp, and tended to call {@code System.currentTimeMillis()} independently.
+   */
+  public static final long MAX_WINDOW_MS = SECONDS.toMillis(3);
+
+  /**
+   * The maximum amount of time between two consecutive events to consider them to be in the same
+   * batch.
+   */
+  static final long MAX_DELTA_MS = SECONDS.toMillis(1);
+
+  private final AccountCache accountCache;
+  private final ChangeBundleReader bundleReader;
+  private final ChangeDraftUpdate.Factory draftUpdateFactory;
+  private final ChangeNoteUtil changeNoteUtil;
+  private final ChangeNotes.Factory notesFactory;
+  private final ChangeUpdate.Factory updateFactory;
+  private final CommentsUtil commentsUtil;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final NotesMigration migration;
+  private final PatchListCache patchListCache;
+  private final PersonIdent serverIdent;
+  private final ProjectCache projectCache;
+  private final String serverId;
+  private final long skewMs;
+
+  @Inject
+  ChangeRebuilderImpl(
+      @GerritServerConfig Config cfg,
+      SchemaFactory<ReviewDb> schemaFactory,
+      AccountCache accountCache,
+      ChangeBundleReader bundleReader,
+      ChangeDraftUpdate.Factory draftUpdateFactory,
+      ChangeNoteUtil changeNoteUtil,
+      ChangeNotes.Factory notesFactory,
+      ChangeUpdate.Factory updateFactory,
+      CommentsUtil commentsUtil,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      NotesMigration migration,
+      PatchListCache patchListCache,
+      @GerritPersonIdent PersonIdent serverIdent,
+      @Nullable ProjectCache projectCache,
+      @GerritServerId String serverId) {
+    super(schemaFactory);
+    this.accountCache = accountCache;
+    this.bundleReader = bundleReader;
+    this.draftUpdateFactory = draftUpdateFactory;
+    this.changeNoteUtil = changeNoteUtil;
+    this.notesFactory = notesFactory;
+    this.updateFactory = updateFactory;
+    this.commentsUtil = commentsUtil;
+    this.updateManagerFactory = updateManagerFactory;
+    this.migration = migration;
+    this.patchListCache = patchListCache;
+    this.serverIdent = serverIdent;
+    this.projectCache = projectCache;
+    this.serverId = serverId;
+    this.skewMs = NoteDbChangeState.getReadOnlySkew(cfg);
+  }
+
+  @Override
+  public Result rebuild(ReviewDb db, Change.Id changeId) throws IOException, OrmException {
+    return rebuild(db, changeId, true);
+  }
+
+  @Override
+  public Result rebuildEvenIfReadOnly(ReviewDb db, Change.Id changeId)
+      throws IOException, OrmException {
+    return rebuild(db, changeId, false);
+  }
+
+  private Result rebuild(ReviewDb db, Change.Id changeId, boolean checkReadOnly)
+      throws IOException, OrmException {
+    db = ReviewDbUtil.unwrapDb(db);
+    // Read change just to get project; this instance is then discarded so we can read a consistent
+    // ChangeBundle inside a transaction.
+    Change change = db.changes().get(changeId);
+    if (change == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+    try (NoteDbUpdateManager manager = updateManagerFactory.create(change.getProject())) {
+      buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
+      return execute(db, changeId, manager, checkReadOnly, true);
+    }
+  }
+
+  @Override
+  public Result rebuild(NoteDbUpdateManager manager, ChangeBundle bundle)
+      throws NoSuchChangeException, IOException, OrmException {
+    Change change = new Change(bundle.getChange());
+    buildUpdates(manager, bundle);
+    return manager.stageAndApplyDelta(change);
+  }
+
+  @Override
+  public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
+      throws IOException, OrmException {
+    db = ReviewDbUtil.unwrapDb(db);
+    Change change = checkNoteDbState(ChangeNotes.readOneReviewDbChange(db, changeId));
+    if (change == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+    NoteDbUpdateManager manager = updateManagerFactory.create(change.getProject());
+    buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
+    manager.stage();
+    return manager;
+  }
+
+  @Override
+  public Result execute(ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager)
+      throws OrmException, IOException {
+    return execute(db, changeId, manager, true, true);
+  }
+
+  public Result execute(
+      ReviewDb db,
+      Change.Id changeId,
+      NoteDbUpdateManager manager,
+      boolean checkReadOnly,
+      boolean executeManager)
+      throws OrmException, IOException {
+    db = ReviewDbUtil.unwrapDb(db);
+    Change change = checkNoteDbState(ChangeNotes.readOneReviewDbChange(db, changeId));
+    if (change == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+
+    String oldNoteDbStateStr = change.getNoteDbState();
+    Result r = manager.stageAndApplyDelta(change);
+    String newNoteDbStateStr = change.getNoteDbState();
+    if (newNoteDbStateStr == null) {
+      throw new OrmException(
+          "Rebuilding change %s produced no writes to NoteDb: "
+              + bundleReader.fromReviewDb(db, changeId));
+    }
+    NoteDbChangeState newNoteDbState =
+        checkNotNull(NoteDbChangeState.parse(changeId, newNoteDbStateStr));
+    try {
+      db.changes()
+          .atomicUpdate(
+              changeId,
+              new AtomicUpdate<Change>() {
+                @Override
+                public Change update(Change change) {
+                  if (checkReadOnly) {
+                    NoteDbChangeState.checkNotReadOnly(change, skewMs);
+                  }
+                  String currNoteDbStateStr = change.getNoteDbState();
+                  if (Objects.equals(currNoteDbStateStr, newNoteDbStateStr)) {
+                    // Another thread completed the same rebuild we were about to.
+                    throw new AbortUpdateException();
+                  } else if (!Objects.equals(oldNoteDbStateStr, currNoteDbStateStr)) {
+                    // Another thread updated the state to something else.
+                    throw new ConflictingUpdateRuntimeException(change, oldNoteDbStateStr);
+                  }
+                  change.setNoteDbState(newNoteDbStateStr);
+                  return change;
+                }
+              });
+    } catch (ConflictingUpdateRuntimeException e) {
+      // Rethrow as an OrmException so the caller knows to use staged results. Strictly speaking
+      // they are not completely up to date, but result we send to the caller is the same as if this
+      // rebuild had executed before the other thread.
+      throw new ConflictingUpdateException(e);
+    } catch (AbortUpdateException e) {
+      if (newNoteDbState.isUpToDate(
+          manager.getChangeRepo().cmds.getRepoRefCache(),
+          manager.getAllUsersRepo().cmds.getRepoRefCache())) {
+        // If the state in ReviewDb matches NoteDb at this point, it means another thread
+        // successfully completed this rebuild. It's ok to not execute the update in this case,
+        // since the object referenced in the Result was flushed to the repo by whatever thread won
+        // the race.
+        return r;
+      }
+      // If the state doesn't match, that means another thread attempted this rebuild, but
+      // failed. Fall through and try to update the ref again.
+    }
+    if (migration.failChangeWrites()) {
+      // Don't even attempt to execute if read-only, it would fail anyway. But do throw an exception
+      // to the caller so they know to use the staged results instead of reading from the repo.
+      throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
+    }
+    if (executeManager) {
+      manager.execute();
+    }
+    return r;
+  }
+
+  static Change checkNoteDbState(Change c) throws OrmException {
+    // Can only rebuild a change if its primary storage is ReviewDb.
+    NoteDbChangeState s = NoteDbChangeState.parse(c);
+    if (s != null && s.getPrimaryStorage() != PrimaryStorage.REVIEW_DB) {
+      throw new OrmException(
+          String.format("cannot rebuild change " + c.getId() + " with state " + s));
+    }
+    return c;
+  }
+
+  @Override
+  public void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle)
+      throws IOException, OrmException {
+    manager.setCheckExpectedState(false).setRefLogMessage("Rebuilding change");
+    Change change = new Change(bundle.getChange());
+    if (bundle.getPatchSets().isEmpty()) {
+      throw new NoPatchSetsException(change.getId());
+    }
+    if (change.getLastUpdatedOn().compareTo(change.getCreatedOn()) < 0) {
+      // A bug in data migration might set created_on to the time of the migration. The
+      // correct timestamps were lost, but we can at least set it so created_on is not after
+      // last_updated_on.
+      // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7397
+      change.setCreatedOn(change.getLastUpdatedOn());
+    }
+
+    // We will rebuild all events, except for draft comments, in buckets based on author and
+    // timestamp.
+    List<Event> events = new ArrayList<>();
+    ListMultimap<Account.Id, DraftCommentEvent> draftCommentEvents =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+
+    events.addAll(getHashtagsEvents(change, manager));
+
+    // Delete ref only after hashtags have been read.
+    deleteChangeMetaRef(change, manager.getChangeRepo().cmds);
+    deleteDraftRefs(change, manager.getAllUsersRepo());
+
+    Integer minPsNum = getMinPatchSetNum(bundle);
+    TreeMap<PatchSet.Id, PatchSetEvent> patchSetEvents =
+        new TreeMap<>(ReviewDbUtil.intKeyOrdering());
+
+    for (PatchSet ps : bundle.getPatchSets()) {
+      PatchSetEvent pse = new PatchSetEvent(change, ps, manager.getChangeRepo().rw);
+      patchSetEvents.put(ps.getId(), pse);
+      events.add(pse);
+      for (Comment c : getComments(bundle, serverId, Status.PUBLISHED, ps)) {
+        CommentEvent e = new CommentEvent(c, change, ps, patchListCache);
+        events.add(e.addDep(pse));
+      }
+      for (Comment c : getComments(bundle, serverId, Status.DRAFT, ps)) {
+        DraftCommentEvent e = new DraftCommentEvent(c, change, ps, patchListCache);
+        draftCommentEvents.put(c.author.getId(), e);
+      }
+    }
+    ensurePatchSetOrder(patchSetEvents);
+
+    for (PatchSetApproval psa : bundle.getPatchSetApprovals()) {
+      PatchSetEvent pse = patchSetEvents.get(psa.getPatchSetId());
+      if (pse != null) {
+        events.add(new ApprovalEvent(psa, change.getCreatedOn()).addDep(pse));
+      }
+    }
+
+    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> r :
+        bundle.getReviewers().asTable().cellSet()) {
+      events.add(new ReviewerEvent(r, change.getCreatedOn()));
+    }
+
+    Change noteDbChange = new Change(null, null, null, null, null);
+    for (ChangeMessage msg : bundle.getChangeMessages()) {
+      Event msgEvent = new ChangeMessageEvent(change, noteDbChange, msg, change.getCreatedOn());
+      if (msg.getPatchSetId() != null) {
+        PatchSetEvent pse = patchSetEvents.get(msg.getPatchSetId());
+        if (pse == null) {
+          continue; // Ignore events for missing patch sets.
+        }
+        msgEvent.addDep(pse);
+      }
+      events.add(msgEvent);
+    }
+
+    sortAndFillEvents(change, noteDbChange, bundle.getPatchSets(), events, minPsNum);
+
+    EventList<Event> el = new EventList<>();
+    for (Event e : events) {
+      if (!el.canAdd(e)) {
+        flushEventsToUpdate(manager, el, change);
+        checkState(el.canAdd(e));
+      }
+      el.add(e);
+    }
+    flushEventsToUpdate(manager, el, change);
+
+    EventList<DraftCommentEvent> plcel = new EventList<>();
+    for (Account.Id author : draftCommentEvents.keys()) {
+      for (DraftCommentEvent e : Ordering.natural().sortedCopy(draftCommentEvents.get(author))) {
+        if (!plcel.canAdd(e)) {
+          flushEventsToDraftUpdate(manager, plcel, change);
+          checkState(plcel.canAdd(e));
+        }
+        plcel.add(e);
+      }
+      flushEventsToDraftUpdate(manager, plcel, change);
+    }
+  }
+
+  private static Integer getMinPatchSetNum(ChangeBundle bundle) {
+    Integer minPsNum = null;
+    for (PatchSet ps : bundle.getPatchSets()) {
+      int n = ps.getId().get();
+      if (minPsNum == null || n < minPsNum) {
+        minPsNum = n;
+      }
+    }
+    return minPsNum;
+  }
+
+  private static void ensurePatchSetOrder(TreeMap<PatchSet.Id, PatchSetEvent> events) {
+    if (events.isEmpty()) {
+      return;
+    }
+    Iterator<PatchSetEvent> it = events.values().iterator();
+    PatchSetEvent curr = it.next();
+    while (it.hasNext()) {
+      PatchSetEvent next = it.next();
+      next.addDep(curr);
+      curr = next;
+    }
+  }
+
+  private static List<Comment> getComments(
+      ChangeBundle bundle, String serverId, PatchLineComment.Status status, PatchSet ps) {
+    return bundle
+        .getPatchLineComments()
+        .stream()
+        .filter(c -> c.getPatchSetId().equals(ps.getId()) && c.getStatus() == status)
+        .map(plc -> plc.asComment(serverId))
+        .sorted(CommentsUtil.COMMENT_ORDER)
+        .collect(toList());
+  }
+
+  private void sortAndFillEvents(
+      Change change,
+      Change noteDbChange,
+      ImmutableCollection<PatchSet> patchSets,
+      List<Event> events,
+      Integer minPsNum) {
+    Event finalUpdates = new FinalUpdatesEvent(change, noteDbChange, patchSets);
+    events.add(finalUpdates);
+    setPostSubmitDeps(events);
+    new EventSorter(events).sort();
+
+    // Ensure the first event in the list creates the change, setting the author and any required
+    // footers. Also force the creation time of the first patch set to match the creation time of
+    // the change.
+    Event first = events.get(0);
+    if (first instanceof PatchSetEvent && change.getOwner().equals(first.user)) {
+      first.when = change.getCreatedOn();
+      ((PatchSetEvent) first).createChange = true;
+    } else {
+      events.add(0, new CreateChangeEvent(change, minPsNum));
+    }
+
+    // Final pass to correct some inconsistencies.
+    //
+    // First, fill in any missing patch set IDs using the latest patch set of the change at the time
+    // of the event, because NoteDb can't represent actions with no associated patch set ID. This
+    // workaround is as if a user added a ChangeMessage on the change by replying from the latest
+    // patch set.
+    //
+    // Start with the first patch set that actually exists. If there are no patch sets at all,
+    // minPsNum will be null, so just bail and use 1 as the patch set ID.
+    //
+    // Second, ensure timestamps are nondecreasing, by copying the previous timestamp if this
+    // happens. This assumes that the only way this can happen is due to dependency constraints, and
+    // it is ok to give an event the same timestamp as one of its dependencies.
+    int ps = firstNonNull(minPsNum, 1);
+    for (int i = 0; i < events.size(); i++) {
+      Event e = events.get(i);
+      if (e.psId == null) {
+        e.psId = new PatchSet.Id(change.getId(), ps);
+      } else {
+        ps = Math.max(ps, e.psId.get());
+      }
+
+      if (i > 0) {
+        Event p = events.get(i - 1);
+        if (e.when.before(p.when)) {
+          e.when = p.when;
+        }
+      }
+    }
+  }
+
+  private void setPostSubmitDeps(List<Event> events) {
+    Optional<Event> submitEvent =
+        Lists.reverse(events).stream().filter(Event::isSubmit).findFirst();
+    if (submitEvent.isPresent()) {
+      events.stream().filter(Event::isPostSubmitApproval).forEach(e -> e.addDep(submitEvent.get()));
+    }
+  }
+
+  private void flushEventsToUpdate(
+      NoteDbUpdateManager manager, EventList<Event> events, Change change)
+      throws OrmException, IOException {
+    if (events.isEmpty()) {
+      return;
+    }
+    Comparator<String> labelNameComparator;
+    if (projectCache != null) {
+      labelNameComparator = projectCache.get(change.getProject()).getLabelTypes().nameComparator();
+    } else {
+      // No project cache available, bail and use natural ordering; there's no semantic difference
+      // anyway difference.
+      labelNameComparator = Ordering.natural();
+    }
+    ChangeUpdate update =
+        updateFactory.create(
+            change,
+            events.getAccountId(),
+            events.getRealAccountId(),
+            newAuthorIdent(events),
+            events.getWhen(),
+            labelNameComparator);
+    update.setAllowWriteToNewRef(true);
+    update.setPatchSetId(events.getPatchSetId());
+    update.setTag(events.getTag());
+    for (Event e : events) {
+      e.apply(update);
+    }
+    manager.add(update);
+    events.clear();
+  }
+
+  private void flushEventsToDraftUpdate(
+      NoteDbUpdateManager manager, EventList<DraftCommentEvent> events, Change change)
+      throws OrmException {
+    if (events.isEmpty()) {
+      return;
+    }
+    ChangeDraftUpdate update =
+        draftUpdateFactory.create(
+            change,
+            events.getAccountId(),
+            events.getRealAccountId(),
+            newAuthorIdent(events),
+            events.getWhen());
+    update.setPatchSetId(events.getPatchSetId());
+    for (DraftCommentEvent e : events) {
+      e.applyDraft(update);
+    }
+    manager.add(update);
+    events.clear();
+  }
+
+  private PersonIdent newAuthorIdent(EventList<?> events) {
+    Account.Id id = events.getAccountId();
+    if (id == null) {
+      return new PersonIdent(serverIdent, events.getWhen());
+    }
+    return changeNoteUtil.newIdent(
+        accountCache.get(id).getAccount(), events.getWhen(), serverIdent);
+  }
+
+  private List<HashtagsEvent> getHashtagsEvents(Change change, NoteDbUpdateManager manager)
+      throws IOException {
+    String refName = changeMetaRef(change.getId());
+    Optional<ObjectId> old = manager.getChangeRepo().getObjectId(refName);
+    if (!old.isPresent()) {
+      return Collections.emptyList();
+    }
+
+    RevWalk rw = manager.getChangeRepo().rw;
+    List<HashtagsEvent> events = new ArrayList<>();
+    rw.reset();
+    rw.markStart(rw.parseCommit(old.get()));
+    for (RevCommit commit : rw) {
+      Account.Id authorId;
+      try {
+        authorId = changeNoteUtil.parseIdent(commit.getAuthorIdent(), change.getId());
+      } catch (ConfigInvalidException e) {
+        continue; // Corrupt data, no valid hashtags in this commit.
+      }
+      PatchSet.Id psId = parsePatchSetId(change, commit);
+      Set<String> hashtags = parseHashtags(commit);
+      if (authorId == null || psId == null || hashtags == null) {
+        continue;
+      }
+
+      Timestamp commitTime = new Timestamp(commit.getCommitterIdent().getWhen().getTime());
+      events.add(new HashtagsEvent(psId, authorId, commitTime, hashtags, change.getCreatedOn()));
+    }
+    return events;
+  }
+
+  private Set<String> parseHashtags(RevCommit commit) {
+    List<String> hashtagsLines = commit.getFooterLines(FOOTER_HASHTAGS);
+    if (hashtagsLines.isEmpty() || hashtagsLines.size() > 1) {
+      return null;
+    }
+
+    if (hashtagsLines.get(0).isEmpty()) {
+      return ImmutableSet.of();
+    }
+    return Sets.newHashSet(Splitter.on(',').split(hashtagsLines.get(0)));
+  }
+
+  private PatchSet.Id parsePatchSetId(Change change, RevCommit commit) {
+    List<String> psIdLines = commit.getFooterLines(FOOTER_PATCH_SET);
+    if (psIdLines.size() != 1) {
+      return null;
+    }
+    Integer psId = Ints.tryParse(psIdLines.get(0));
+    if (psId == null) {
+      return null;
+    }
+    return new PatchSet.Id(change.getId(), psId);
+  }
+
+  private void deleteChangeMetaRef(Change change, ChainedReceiveCommands cmds) throws IOException {
+    String refName = changeMetaRef(change.getId());
+    Optional<ObjectId> old = cmds.get(refName);
+    if (old.isPresent()) {
+      cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), refName));
+    }
+  }
+
+  private void deleteDraftRefs(Change change, OpenRepo allUsersRepo) throws IOException {
+    for (Ref r :
+        allUsersRepo
+            .repo
+            .getRefDatabase()
+            .getRefs(RefNames.refsDraftCommentsPrefix(change.getId()))
+            .values()) {
+      allUsersRepo.cmds.add(new ReceiveCommand(r.getObjectId(), ObjectId.zeroId(), r.getName()));
+    }
+  }
+
+  static void createChange(ChangeUpdate update, Change change) {
+    update.setSubjectForCommit("Create change");
+    update.setChangeId(change.getKey().get());
+    update.setBranch(change.getDest().get());
+    update.setSubject(change.getOriginalSubject());
+    if (change.getRevertOf() != null) {
+      update.setRevertOf(change.getRevertOf().get());
+    }
+  }
+
+  @Override
+  public void rebuildReviewDb(ReviewDb db, Project.NameKey project, Change.Id changeId)
+      throws OrmException {
+    // TODO(dborowitz): Fail fast if changes tables are disabled in ReviewDb.
+    ChangeNotes notes = notesFactory.create(db, project, changeId);
+    ChangeBundle bundle = ChangeBundle.fromNotes(commentsUtil, notes);
+
+    db = ReviewDbUtil.unwrapDb(db);
+    db.changes().beginTransaction(changeId);
+    try {
+      Change c = db.changes().get(changeId);
+      if (c != null) {
+        PrimaryStorage ps = PrimaryStorage.of(c);
+        switch (ps) {
+          case REVIEW_DB:
+            return; // Nothing to do.
+          case NOTE_DB:
+            break; // Continue and rebuild.
+          default:
+            throw new OrmException("primary storage of " + changeId + " is " + ps);
+        }
+      } else {
+        c = notes.getChange();
+      }
+      db.changes().upsert(Collections.singleton(c));
+      putExactlyEntities(
+          db.changeMessages(), db.changeMessages().byChange(c.getId()), bundle.getChangeMessages());
+      putExactlyEntities(db.patchSets(), db.patchSets().byChange(c.getId()), bundle.getPatchSets());
+      putExactlyEntities(
+          db.patchSetApprovals(),
+          db.patchSetApprovals().byChange(c.getId()),
+          bundle.getPatchSetApprovals());
+      putExactlyEntities(
+          db.patchComments(),
+          db.patchComments().byChange(c.getId()),
+          bundle.getPatchLineComments());
+      db.commit();
+    } finally {
+      db.rollback();
+    }
+  }
+
+  private static <T, K extends Key<?>> void putExactlyEntities(
+      Access<T, K> access, Iterable<T> existing, Collection<T> ents) throws OrmException {
+    Set<K> toKeep = access.toMap(ents).keySet();
+    access.delete(
+        FluentIterable.from(existing).filter(e -> !toKeep.contains(access.primaryKey(e))));
+    access.upsert(ents);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java b/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
rename to java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java b/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java
rename to java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateRuntimeException.java b/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateRuntimeException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateRuntimeException.java
rename to java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateRuntimeException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java b/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java
rename to java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java b/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
rename to java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/Event.java b/java/com/google/gerrit/server/notedb/rebuild/Event.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/Event.java
rename to java/com/google/gerrit/server/notedb/rebuild/Event.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventList.java b/java/com/google/gerrit/server/notedb/rebuild/EventList.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventList.java
rename to java/com/google/gerrit/server/notedb/rebuild/EventList.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventSorter.java b/java/com/google/gerrit/server/notedb/rebuild/EventSorter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventSorter.java
rename to java/com/google/gerrit/server/notedb/rebuild/EventSorter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java b/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
rename to java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java b/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java
rename to java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/MigrationException.java b/java/com/google/gerrit/server/notedb/rebuild/MigrationException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/MigrationException.java
rename to java/com/google/gerrit/server/notedb/rebuild/MigrationException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java b/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
rename to java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NotesMigrationStateListener.java b/java/com/google/gerrit/server/notedb/rebuild/NotesMigrationStateListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NotesMigrationStateListener.java
rename to java/com/google/gerrit/server/notedb/rebuild/NotesMigrationStateListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/OnlineNoteDbMigrator.java b/java/com/google/gerrit/server/notedb/rebuild/OnlineNoteDbMigrator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/OnlineNoteDbMigrator.java
rename to java/com/google/gerrit/server/notedb/rebuild/OnlineNoteDbMigrator.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java b/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
rename to java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java b/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java
rename to java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java
rename to java/com/google/gerrit/server/patch/AutoMerger.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/CharText.java b/java/com/google/gerrit/server/patch/CharText.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/CharText.java
rename to java/com/google/gerrit/server/patch/CharText.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/CharTextComparator.java b/java/com/google/gerrit/server/patch/CharTextComparator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/CharTextComparator.java
rename to java/com/google/gerrit/server/patch/CharTextComparator.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/ComparisonType.java b/java/com/google/gerrit/server/patch/ComparisonType.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/ComparisonType.java
rename to java/com/google/gerrit/server/patch/ComparisonType.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffExecutor.java b/java/com/google/gerrit/server/patch/DiffExecutor.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffExecutor.java
rename to java/com/google/gerrit/server/patch/DiffExecutor.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffExecutorModule.java b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffExecutorModule.java
rename to java/com/google/gerrit/server/patch/DiffExecutorModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummary.java b/java/com/google/gerrit/server/patch/DiffSummary.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummary.java
rename to java/com/google/gerrit/server/patch/DiffSummary.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryKey.java b/java/com/google/gerrit/server/patch/DiffSummaryKey.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryKey.java
rename to java/com/google/gerrit/server/patch/DiffSummaryKey.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
rename to java/com/google/gerrit/server/patch/DiffSummaryLoader.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryWeigher.java b/java/com/google/gerrit/server/patch/DiffSummaryWeigher.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryWeigher.java
rename to java/com/google/gerrit/server/patch/DiffSummaryWeigher.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/EditTransformer.java b/java/com/google/gerrit/server/patch/EditTransformer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/EditTransformer.java
rename to java/com/google/gerrit/server/patch/EditTransformer.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiff.java b/java/com/google/gerrit/server/patch/IntraLineDiff.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiff.java
rename to java/com/google/gerrit/server/patch/IntraLineDiff.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java b/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
rename to java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java b/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
rename to java/com/google/gerrit/server/patch/IntraLineDiffKey.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java b/java/com/google/gerrit/server/patch/IntraLineLoader.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
rename to java/com/google/gerrit/server/patch/IntraLineLoader.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineWeigher.java b/java/com/google/gerrit/server/patch/IntraLineWeigher.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineWeigher.java
rename to java/com/google/gerrit/server/patch/IntraLineWeigher.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/MergeListBuilder.java b/java/com/google/gerrit/server/patch/MergeListBuilder.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/MergeListBuilder.java
rename to java/com/google/gerrit/server/patch/MergeListBuilder.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java b/java/com/google/gerrit/server/patch/PatchFile.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java
rename to java/com/google/gerrit/server/patch/PatchFile.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
rename to java/com/google/gerrit/server/patch/PatchList.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java b/java/com/google/gerrit/server/patch/PatchListCache.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
rename to java/com/google/gerrit/server/patch/PatchListCache.java
diff --git a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
new file mode 100644
index 0000000..8900a15
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -0,0 +1,196 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+package com.google.gerrit.server.patch;
+
+import static com.google.gerrit.server.patch.DiffSummaryLoader.toDiffSummary;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.cache.Cache;
+import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.errors.LargeObjectException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Provides a cached list of {@link PatchListEntry}. */
+@Singleton
+public class PatchListCacheImpl implements PatchListCache {
+  static final String FILE_NAME = "diff";
+  static final String INTRA_NAME = "diff_intraline";
+  static final String DIFF_SUMMARY = "diff_summary";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        factory(PatchListLoader.Factory.class);
+        persist(FILE_NAME, PatchListKey.class, PatchList.class)
+            .maximumWeight(10 << 20)
+            .weigher(PatchListWeigher.class);
+
+        factory(IntraLineLoader.Factory.class);
+        persist(INTRA_NAME, IntraLineDiffKey.class, IntraLineDiff.class)
+            .maximumWeight(10 << 20)
+            .weigher(IntraLineWeigher.class);
+
+        factory(DiffSummaryLoader.Factory.class);
+        persist(DIFF_SUMMARY, DiffSummaryKey.class, DiffSummary.class)
+            .maximumWeight(10 << 20)
+            .weigher(DiffSummaryWeigher.class)
+            .diskLimit(1 << 30);
+
+        bind(PatchListCacheImpl.class);
+        bind(PatchListCache.class).to(PatchListCacheImpl.class);
+      }
+    };
+  }
+
+  private final Cache<PatchListKey, PatchList> fileCache;
+  private final Cache<IntraLineDiffKey, IntraLineDiff> intraCache;
+  private final Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
+  private final PatchListLoader.Factory fileLoaderFactory;
+  private final IntraLineLoader.Factory intraLoaderFactory;
+  private final DiffSummaryLoader.Factory diffSummaryLoaderFactory;
+  private final boolean computeIntraline;
+
+  @Inject
+  PatchListCacheImpl(
+      @Named(FILE_NAME) Cache<PatchListKey, PatchList> fileCache,
+      @Named(INTRA_NAME) Cache<IntraLineDiffKey, IntraLineDiff> intraCache,
+      @Named(DIFF_SUMMARY) Cache<DiffSummaryKey, DiffSummary> diffSummaryCache,
+      PatchListLoader.Factory fileLoaderFactory,
+      IntraLineLoader.Factory intraLoaderFactory,
+      DiffSummaryLoader.Factory diffSummaryLoaderFactory,
+      @GerritServerConfig Config cfg) {
+    this.fileCache = fileCache;
+    this.intraCache = intraCache;
+    this.diffSummaryCache = diffSummaryCache;
+    this.fileLoaderFactory = fileLoaderFactory;
+    this.intraLoaderFactory = intraLoaderFactory;
+    this.diffSummaryLoaderFactory = diffSummaryLoaderFactory;
+
+    this.computeIntraline =
+        cfg.getBoolean(
+            "cache", INTRA_NAME, "enabled", cfg.getBoolean("cache", "diff", "intraline", true));
+  }
+
+  @Override
+  public PatchList get(PatchListKey key, Project.NameKey project)
+      throws PatchListNotAvailableException {
+    try {
+      PatchList pl = fileCache.get(key, fileLoaderFactory.create(key, project));
+      if (pl instanceof LargeObjectTombstone) {
+        throw new PatchListObjectTooLargeException(
+            "Error computing " + key + ". Previous attempt failed with LargeObjectException");
+      }
+      if (key.getAlgorithm() == PatchListKey.Algorithm.OPTIMIZED_DIFF) {
+        diffSummaryCache.put(DiffSummaryKey.fromPatchListKey(key), toDiffSummary(pl));
+      }
+      return pl;
+    } catch (ExecutionException e) {
+      PatchListLoader.log.warn("Error computing " + key, e);
+      throw new PatchListNotAvailableException(e);
+    } catch (UncheckedExecutionException e) {
+      if (e.getCause() instanceof LargeObjectException) {
+        // Cache negative result so we don't need to redo expensive computations that would yield
+        // the same result.
+        fileCache.put(key, new LargeObjectTombstone());
+        PatchListLoader.log.warn("Error computing " + key, e);
+        throw new PatchListNotAvailableException(e);
+      }
+      throw e;
+    }
+  }
+
+  @Override
+  public PatchList get(Change change, PatchSet patchSet) throws PatchListNotAvailableException {
+    return get(change, patchSet, null);
+  }
+
+  @Override
+  public ObjectId getOldId(Change change, PatchSet patchSet, Integer parentNum)
+      throws PatchListNotAvailableException {
+    return get(change, patchSet, parentNum).getOldId();
+  }
+
+  private PatchList get(Change change, PatchSet patchSet, Integer parentNum)
+      throws PatchListNotAvailableException {
+    Project.NameKey project = change.getProject();
+    if (patchSet.getRevision() == null) {
+      throw new PatchListNotAvailableException("revision is null for " + patchSet.getId());
+    }
+    ObjectId b = ObjectId.fromString(patchSet.getRevision().get());
+    Whitespace ws = Whitespace.IGNORE_NONE;
+    if (parentNum != null) {
+      return get(PatchListKey.againstParentNum(parentNum, b, ws), project);
+    }
+    return get(PatchListKey.againstDefaultBase(b, ws), project);
+  }
+
+  @Override
+  public IntraLineDiff getIntraLineDiff(IntraLineDiffKey key, IntraLineDiffArgs args) {
+    if (computeIntraline) {
+      try {
+        return intraCache.get(key, intraLoaderFactory.create(key, args));
+      } catch (ExecutionException | LargeObjectException e) {
+        IntraLineLoader.log.warn("Error computing " + key, e);
+        return new IntraLineDiff(IntraLineDiff.Status.ERROR);
+      }
+    }
+    return new IntraLineDiff(IntraLineDiff.Status.DISABLED);
+  }
+
+  @Override
+  public DiffSummary getDiffSummary(DiffSummaryKey key, Project.NameKey project)
+      throws PatchListNotAvailableException {
+    try {
+      return diffSummaryCache.get(key, diffSummaryLoaderFactory.create(key, project));
+    } catch (ExecutionException e) {
+      PatchListLoader.log.warn("Error computing " + key, e);
+      throw new PatchListNotAvailableException(e);
+    } catch (UncheckedExecutionException e) {
+      if (e.getCause() instanceof LargeObjectException) {
+        PatchListLoader.log.warn("Error computing " + key, e);
+        throw new PatchListNotAvailableException(e);
+      }
+      throw e;
+    }
+  }
+
+  /** Used to cache negative results in {@code fileCache}. */
+  @VisibleForTesting
+  public static class LargeObjectTombstone extends PatchList {
+    private static final long serialVersionUID = 1L;
+
+    @VisibleForTesting
+    public LargeObjectTombstone() {
+      // Initialize super class with valid values. We don't care about the inner state, but need to
+      // pass valid values that don't break (de)serialization.
+      super(
+          null, ObjectId.zeroId(), false, ComparisonType.againstAutoMerge(), new PatchListEntry[0]);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java b/java/com/google/gerrit/server/patch/PatchListEntry.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java
rename to java/com/google/gerrit/server/patch/PatchListEntry.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java b/java/com/google/gerrit/server/patch/PatchListKey.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
rename to java/com/google/gerrit/server/patch/PatchListKey.java
diff --git a/java/com/google/gerrit/server/patch/PatchListLoader.java b/java/com/google/gerrit/server/patch/PatchListLoader.java
new file mode 100644
index 0000000..bf6e345
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -0,0 +1,605 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+package com.google.gerrit.server.patch;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toSet;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.InMemoryInserter;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.patch.EditTransformer.ContextAwareEdit;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.diff.EditList;
+import org.eclipse.jgit.diff.HistogramDiff;
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
+import org.eclipse.jgit.patch.FileHeader;
+import org.eclipse.jgit.patch.FileHeader.PatchType;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PatchListLoader implements Callable<PatchList> {
+  static final Logger log = LoggerFactory.getLogger(PatchListLoader.class);
+
+  public interface Factory {
+    PatchListLoader create(PatchListKey key, Project.NameKey project);
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final PatchListCache patchListCache;
+  private final ThreeWayMergeStrategy mergeStrategy;
+  private final ExecutorService diffExecutor;
+  private final AutoMerger autoMerger;
+  private final PatchListKey key;
+  private final Project.NameKey project;
+  private final long timeoutMillis;
+  private final boolean save;
+
+  @Inject
+  PatchListLoader(
+      GitRepositoryManager mgr,
+      PatchListCache plc,
+      @GerritServerConfig Config cfg,
+      @DiffExecutor ExecutorService de,
+      AutoMerger am,
+      @Assisted PatchListKey k,
+      @Assisted Project.NameKey p) {
+    repoManager = mgr;
+    patchListCache = plc;
+    mergeStrategy = MergeUtil.getMergeStrategy(cfg);
+    diffExecutor = de;
+    autoMerger = am;
+    key = k;
+    project = p;
+    timeoutMillis =
+        ConfigUtil.getTimeUnit(
+            cfg,
+            "cache",
+            PatchListCacheImpl.FILE_NAME,
+            "timeout",
+            TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
+            TimeUnit.MILLISECONDS);
+    save = AutoMerger.cacheAutomerge(cfg);
+  }
+
+  @Override
+  public PatchList call() throws IOException, PatchListNotAvailableException {
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = newInserter(repo);
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      return readPatchList(repo, rw, ins);
+    }
+  }
+
+  private static RawTextComparator comparatorFor(Whitespace ws) {
+    switch (ws) {
+      case IGNORE_ALL:
+        return RawTextComparator.WS_IGNORE_ALL;
+
+      case IGNORE_TRAILING:
+        return RawTextComparator.WS_IGNORE_TRAILING;
+
+      case IGNORE_LEADING_AND_TRAILING:
+        return RawTextComparator.WS_IGNORE_CHANGE;
+
+      case IGNORE_NONE:
+      default:
+        return RawTextComparator.DEFAULT;
+    }
+  }
+
+  private ObjectInserter newInserter(Repository repo) {
+    return save ? repo.newObjectInserter() : new InMemoryInserter(repo);
+  }
+
+  private PatchList readPatchList(Repository repo, RevWalk rw, ObjectInserter ins)
+      throws IOException, PatchListNotAvailableException {
+    ObjectReader reader = rw.getObjectReader();
+    checkArgument(reader.getCreatedFromInserter() == ins);
+    RawTextComparator cmp = comparatorFor(key.getWhitespace());
+    try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+      RevCommit b = rw.parseCommit(key.getNewId());
+      RevObject a = aFor(key, repo, rw, ins, b);
+
+      if (a == null) {
+        // TODO(sop) Remove this case.
+        // This is an octopus merge commit which should be compared against the
+        // auto-merge. However since we don't support computing the auto-merge
+        // for octopus merge commits, we fall back to diffing against the first
+        // parent, even though this wasn't what was requested.
+        //
+        ComparisonType comparisonType = ComparisonType.againstParent(1);
+        PatchListEntry[] entries = new PatchListEntry[2];
+        entries[0] = newCommitMessage(cmp, reader, null, b);
+        entries[1] = newMergeList(cmp, reader, null, b, comparisonType);
+        return new PatchList(a, b, true, comparisonType, entries);
+      }
+
+      ComparisonType comparisonType = getComparisonType(a, b);
+
+      RevCommit aCommit = a instanceof RevCommit ? (RevCommit) a : null;
+      RevTree aTree = rw.parseTree(a);
+      RevTree bTree = b.getTree();
+
+      df.setReader(reader, repo.getConfig());
+      df.setDiffComparator(cmp);
+      df.setDetectRenames(true);
+      List<DiffEntry> diffEntries = df.scan(aTree, bTree);
+
+      Multimap<String, ContextAwareEdit> editsDueToRebasePerFilePath = ImmutableMultimap.of();
+      if (key.getAlgorithm() == PatchListKey.Algorithm.OPTIMIZED_DIFF) {
+        EditsDueToRebaseResult editsDueToRebaseResult =
+            determineEditsDueToRebase(aCommit, b, diffEntries, df, rw);
+        diffEntries = editsDueToRebaseResult.getRelevantOriginalDiffEntries();
+        editsDueToRebasePerFilePath = editsDueToRebaseResult.getEditsDueToRebasePerFilePath();
+      }
+
+      List<PatchListEntry> entries = new ArrayList<>();
+      entries.add(
+          newCommitMessage(
+              cmp, reader, comparisonType.isAgainstParentOrAutoMerge() ? null : aCommit, b));
+      boolean isMerge = b.getParentCount() > 1;
+      if (isMerge) {
+        entries.add(
+            newMergeList(
+                cmp,
+                reader,
+                comparisonType.isAgainstParentOrAutoMerge() ? null : aCommit,
+                b,
+                comparisonType));
+      }
+      for (DiffEntry diffEntry : diffEntries) {
+        Set<ContextAwareEdit> editsDueToRebase =
+            getEditsDueToRebase(editsDueToRebasePerFilePath, diffEntry);
+        Optional<PatchListEntry> patchListEntry =
+            getPatchListEntry(reader, df, diffEntry, aTree, bTree, editsDueToRebase);
+        patchListEntry.ifPresent(entries::add);
+      }
+      return new PatchList(
+          a, b, isMerge, comparisonType, entries.toArray(new PatchListEntry[entries.size()]));
+    }
+  }
+
+  /**
+   * Identifies the edits which are present between {@code commitA} and {@code commitB} due to other
+   * commits in between those two. Edits which cannot be clearly attributed to those other commits
+   * (because they overlap with modifications introduced by {@code commitA} or {@code commitB}) are
+   * omitted from the result. The edits are expressed as differences between {@code treeA} of {@code
+   * commitA} and {@code treeB} of {@code commitB}.
+   *
+   * <p><b>Note:</b> If one of the commits is a merge commit, an empty {@code Multimap} will be
+   * returned.
+   *
+   * <p><b>Warning:</b> This method assumes that commitA and commitB are either a parent and child
+   * commit or represent two patch sets which belong to the same change. No checks are made to
+   * confirm this assumption! Passing arbitrary commits to this method may lead to strange results
+   * or take very long.
+   *
+   * <p>This logic could be expanded to arbitrary commits if the following adjustments were applied:
+   *
+   * <ul>
+   *   <li>If {@code commitA} is an ancestor of {@code commitB} (or the other way around), {@code
+   *       commitA} (or {@code commitB}) is used instead of its parent in this method.
+   *   <li>Special handling for merge commits is added. If only one of them is a merge commit, the
+   *       whole computation has to be done between the single parent and all parents of the merge
+   *       commit. If both of them are merge commits, all combinations of parents have to be
+   *       considered. Alternatively, we could decide to not support this feature for merge commits
+   *       (or just for specific types of merge commits).
+   * </ul>
+   *
+   * @param commitA the commit defining {@code treeA}
+   * @param commitB the commit defining {@code treeB}
+   * @param diffEntries the list of {@code DiffEntries} for the diff between {@code commitA} and
+   *     {@code commitB}
+   * @param df the {@code DiffFormatter}
+   * @param rw the current {@code RevWalk}
+   * @return an aggregated result of the computation
+   * @throws PatchListNotAvailableException if the edits can't be identified
+   * @throws IOException if an error occurred while accessing the repository
+   */
+  private EditsDueToRebaseResult determineEditsDueToRebase(
+      RevCommit commitA,
+      RevCommit commitB,
+      List<DiffEntry> diffEntries,
+      DiffFormatter df,
+      RevWalk rw)
+      throws PatchListNotAvailableException, IOException {
+    if (commitA == null
+        || isRootOrMergeCommit(commitA)
+        || isRootOrMergeCommit(commitB)
+        || areParentChild(commitA, commitB)
+        || haveCommonParent(commitA, commitB)) {
+      return EditsDueToRebaseResult.create(diffEntries, ImmutableMultimap.of());
+    }
+
+    PatchListKey oldKey = PatchListKey.againstDefaultBase(key.getOldId(), key.getWhitespace());
+    PatchList oldPatchList = patchListCache.get(oldKey, project);
+    PatchListKey newKey = PatchListKey.againstDefaultBase(key.getNewId(), key.getWhitespace());
+    PatchList newPatchList = patchListCache.get(newKey, project);
+
+    List<PatchListEntry> oldPatches = oldPatchList.getPatches();
+    List<PatchListEntry> newPatches = newPatchList.getPatches();
+    // TODO(aliceks): Have separate but more limited lists for parents and patch sets (but don't
+    // mess up renames/copies).
+    Set<String> touchedFilePaths = new HashSet<>();
+    for (PatchListEntry patchListEntry : oldPatches) {
+      touchedFilePaths.addAll(getTouchedFilePaths(patchListEntry));
+    }
+    for (PatchListEntry patchListEntry : newPatches) {
+      touchedFilePaths.addAll(getTouchedFilePaths(patchListEntry));
+    }
+
+    List<DiffEntry> relevantDiffEntries =
+        diffEntries
+            .stream()
+            .filter(diffEntry -> isTouched(touchedFilePaths, diffEntry))
+            .collect(toImmutableList());
+
+    RevCommit parentCommitA = commitA.getParent(0);
+    rw.parseBody(parentCommitA);
+    RevCommit parentCommitB = commitB.getParent(0);
+    rw.parseBody(parentCommitB);
+    List<DiffEntry> parentDiffEntries = df.scan(parentCommitA, parentCommitB);
+    // TODO(aliceks): Find a way to not construct a PatchListEntry as it contains many unnecessary
+    // details and we don't fill all of them properly.
+    List<PatchListEntry> parentPatchListEntries =
+        getRelevantPatchListEntries(
+            parentDiffEntries, parentCommitA, parentCommitB, touchedFilePaths, df);
+
+    EditTransformer editTransformer = new EditTransformer(parentPatchListEntries);
+    editTransformer.transformReferencesOfSideA(oldPatches);
+    editTransformer.transformReferencesOfSideB(newPatches);
+    return EditsDueToRebaseResult.create(
+        relevantDiffEntries, editTransformer.getEditsPerFilePath());
+  }
+
+  private static boolean isRootOrMergeCommit(RevCommit commit) {
+    return commit.getParentCount() != 1;
+  }
+
+  private static boolean areParentChild(RevCommit commitA, RevCommit commitB) {
+    return ObjectId.equals(commitA.getParent(0), commitB)
+        || ObjectId.equals(commitB.getParent(0), commitA);
+  }
+
+  private static boolean haveCommonParent(RevCommit commitA, RevCommit commitB) {
+    return ObjectId.equals(commitA.getParent(0), commitB.getParent(0));
+  }
+
+  private static Set<String> getTouchedFilePaths(PatchListEntry patchListEntry) {
+    String oldFilePath = patchListEntry.getOldName();
+    String newFilePath = patchListEntry.getNewName();
+
+    return oldFilePath == null
+        ? ImmutableSet.of(newFilePath)
+        : ImmutableSet.of(oldFilePath, newFilePath);
+  }
+
+  private static boolean isTouched(Set<String> touchedFilePaths, DiffEntry diffEntry) {
+    String oldFilePath = diffEntry.getOldPath();
+    String newFilePath = diffEntry.getNewPath();
+    // One of the above file paths could be /dev/null but we need not explicitly check for this
+    // value as the set of file paths shouldn't contain it.
+    return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
+  }
+
+  private List<PatchListEntry> getRelevantPatchListEntries(
+      List<DiffEntry> parentDiffEntries,
+      RevCommit parentCommitA,
+      RevCommit parentCommitB,
+      Set<String> touchedFilePaths,
+      DiffFormatter diffFormatter)
+      throws IOException {
+    List<PatchListEntry> parentPatchListEntries = new ArrayList<>(parentDiffEntries.size());
+    for (DiffEntry parentDiffEntry : parentDiffEntries) {
+      if (!isTouched(touchedFilePaths, parentDiffEntry)) {
+        continue;
+      }
+      FileHeader fileHeader = toFileHeader(parentCommitB, diffFormatter, parentDiffEntry);
+      // The code which uses this PatchListEntry doesn't care about the last three parameters. As
+      // they are expensive to compute, we use arbitrary values for them.
+      PatchListEntry patchListEntry =
+          newEntry(parentCommitA.getTree(), fileHeader, ImmutableSet.of(), 0, 0);
+      parentPatchListEntries.add(patchListEntry);
+    }
+    return parentPatchListEntries;
+  }
+
+  private static Set<ContextAwareEdit> getEditsDueToRebase(
+      Multimap<String, ContextAwareEdit> editsDueToRebasePerFilePath, DiffEntry diffEntry) {
+    if (editsDueToRebasePerFilePath.isEmpty()) {
+      return ImmutableSet.of();
+    }
+
+    String filePath = diffEntry.getNewPath();
+    if (diffEntry.getChangeType() == ChangeType.DELETE) {
+      filePath = diffEntry.getOldPath();
+    }
+    return ImmutableSet.copyOf(editsDueToRebasePerFilePath.get(filePath));
+  }
+
+  private Optional<PatchListEntry> getPatchListEntry(
+      ObjectReader objectReader,
+      DiffFormatter diffFormatter,
+      DiffEntry diffEntry,
+      RevTree treeA,
+      RevTree treeB,
+      Set<ContextAwareEdit> editsDueToRebase)
+      throws IOException {
+    FileHeader fileHeader = toFileHeader(key.getNewId(), diffFormatter, diffEntry);
+    long oldSize = getFileSize(objectReader, diffEntry.getOldMode(), diffEntry.getOldPath(), treeA);
+    long newSize = getFileSize(objectReader, diffEntry.getNewMode(), diffEntry.getNewPath(), treeB);
+    Set<Edit> contentEditsDueToRebase = getContentEdits(editsDueToRebase);
+    PatchListEntry patchListEntry =
+        newEntry(treeA, fileHeader, contentEditsDueToRebase, newSize, newSize - oldSize);
+    // All edits in a file are due to rebase -> exclude the file from the diff.
+    if (EditTransformer.toEdits(patchListEntry).allMatch(editsDueToRebase::contains)) {
+      return Optional.empty();
+    }
+    return Optional.of(patchListEntry);
+  }
+
+  private static Set<Edit> getContentEdits(Set<ContextAwareEdit> editsDueToRebase) {
+    return editsDueToRebase
+        .stream()
+        .map(ContextAwareEdit::toEdit)
+        .filter(Optional::isPresent)
+        .map(Optional::get)
+        .collect(toSet());
+  }
+
+  private ComparisonType getComparisonType(RevObject a, RevCommit b) {
+    for (int i = 0; i < b.getParentCount(); i++) {
+      if (b.getParent(i).equals(a)) {
+        return ComparisonType.againstParent(i + 1);
+      }
+    }
+
+    if (key.getOldId() == null && b.getParentCount() > 0) {
+      return ComparisonType.againstAutoMerge();
+    }
+
+    return ComparisonType.againstOtherPatchSet();
+  }
+
+  private static long getFileSize(ObjectReader reader, FileMode mode, String path, RevTree t)
+      throws IOException {
+    if (!isBlob(mode)) {
+      return 0;
+    }
+    try (TreeWalk tw = TreeWalk.forPath(reader, path, t)) {
+      return tw != null ? reader.open(tw.getObjectId(0), OBJ_BLOB).getSize() : 0;
+    }
+  }
+
+  private static boolean isBlob(FileMode mode) {
+    int t = mode.getBits() & FileMode.TYPE_MASK;
+    return t == FileMode.TYPE_FILE || t == FileMode.TYPE_SYMLINK;
+  }
+
+  private FileHeader toFileHeader(
+      ObjectId commitB, DiffFormatter diffFormatter, DiffEntry diffEntry) throws IOException {
+
+    Future<FileHeader> result =
+        diffExecutor.submit(
+            () -> {
+              synchronized (diffEntry) {
+                return diffFormatter.toFileHeader(diffEntry);
+              }
+            });
+
+    try {
+      return result.get(timeoutMillis, TimeUnit.MILLISECONDS);
+    } catch (InterruptedException | TimeoutException e) {
+      log.warn(
+          timeoutMillis
+              + " ms timeout reached for Diff loader"
+              + " in project "
+              + project
+              + " on commit "
+              + commitB.name()
+              + " on path "
+              + diffEntry.getNewPath()
+              + " comparing "
+              + diffEntry.getOldId().name()
+              + ".."
+              + diffEntry.getNewId().name());
+      result.cancel(true);
+      synchronized (diffEntry) {
+        return toFileHeaderWithoutMyersDiff(diffFormatter, diffEntry);
+      }
+    } catch (ExecutionException e) {
+      // If there was an error computing the result, carry it
+      // up to the caller so the cache knows this key is invalid.
+      Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
+      throw new IOException(e.getMessage(), e.getCause());
+    }
+  }
+
+  private FileHeader toFileHeaderWithoutMyersDiff(DiffFormatter diffFormatter, DiffEntry diffEntry)
+      throws IOException {
+    HistogramDiff histogramDiff = new HistogramDiff();
+    histogramDiff.setFallbackAlgorithm(null);
+    diffFormatter.setDiffAlgorithm(histogramDiff);
+    return diffFormatter.toFileHeader(diffEntry);
+  }
+
+  private PatchListEntry newCommitMessage(
+      RawTextComparator cmp, ObjectReader reader, RevCommit aCommit, RevCommit bCommit)
+      throws IOException {
+    Text aText = aCommit != null ? Text.forCommit(reader, aCommit) : Text.EMPTY;
+    Text bText = Text.forCommit(reader, bCommit);
+    return createPatchListEntry(cmp, aCommit, aText, bText, Patch.COMMIT_MSG);
+  }
+
+  private PatchListEntry newMergeList(
+      RawTextComparator cmp,
+      ObjectReader reader,
+      RevCommit aCommit,
+      RevCommit bCommit,
+      ComparisonType comparisonType)
+      throws IOException {
+    Text aText = aCommit != null ? Text.forMergeList(comparisonType, reader, aCommit) : Text.EMPTY;
+    Text bText = Text.forMergeList(comparisonType, reader, bCommit);
+    return createPatchListEntry(cmp, aCommit, aText, bText, Patch.MERGE_LIST);
+  }
+
+  private static PatchListEntry createPatchListEntry(
+      RawTextComparator cmp, RevCommit aCommit, Text aText, Text bText, String fileName) {
+    byte[] rawHdr = getRawHeader(aCommit != null, fileName);
+    byte[] aContent = aText.getContent();
+    byte[] bContent = bText.getContent();
+    long size = bContent.length;
+    long sizeDelta = size - aContent.length;
+    RawText aRawText = new RawText(aContent);
+    RawText bRawText = new RawText(bContent);
+    EditList edits = new HistogramDiff().diff(cmp, aRawText, bRawText);
+    FileHeader fh = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
+    return new PatchListEntry(fh, edits, ImmutableSet.of(), size, sizeDelta);
+  }
+
+  private static byte[] getRawHeader(boolean hasA, String fileName) {
+    StringBuilder hdr = new StringBuilder();
+    hdr.append("diff --git");
+    if (hasA) {
+      hdr.append(" a/").append(fileName);
+    } else {
+      hdr.append(" ").append(FileHeader.DEV_NULL);
+    }
+    hdr.append(" b/").append(fileName);
+    hdr.append("\n");
+
+    if (hasA) {
+      hdr.append("--- a/").append(fileName).append("\n");
+    } else {
+      hdr.append("--- ").append(FileHeader.DEV_NULL).append("\n");
+    }
+    hdr.append("+++ b/").append(fileName).append("\n");
+    return hdr.toString().getBytes(UTF_8);
+  }
+
+  private static PatchListEntry newEntry(
+      RevTree aTree, FileHeader fileHeader, Set<Edit> editsDueToRebase, long size, long sizeDelta) {
+    if (aTree == null // want combined diff
+        || fileHeader.getPatchType() != PatchType.UNIFIED
+        || fileHeader.getHunks().isEmpty()) {
+      return new PatchListEntry(fileHeader, ImmutableList.of(), ImmutableSet.of(), size, sizeDelta);
+    }
+
+    List<Edit> edits = fileHeader.toEditList();
+    if (edits.isEmpty()) {
+      return new PatchListEntry(fileHeader, ImmutableList.of(), ImmutableSet.of(), size, sizeDelta);
+    }
+    return new PatchListEntry(fileHeader, edits, editsDueToRebase, size, sizeDelta);
+  }
+
+  private RevObject aFor(
+      PatchListKey key, Repository repo, RevWalk rw, ObjectInserter ins, RevCommit b)
+      throws IOException {
+    if (key.getOldId() != null) {
+      return rw.parseAny(key.getOldId());
+    }
+
+    switch (b.getParentCount()) {
+      case 0:
+        return rw.parseAny(emptyTree(ins));
+      case 1:
+        {
+          RevCommit r = b.getParent(0);
+          rw.parseBody(r);
+          return r;
+        }
+      case 2:
+        if (key.getParentNum() != null) {
+          RevCommit r = b.getParent(key.getParentNum() - 1);
+          rw.parseBody(r);
+          return r;
+        }
+        return autoMerger.merge(repo, rw, ins, b, mergeStrategy);
+      default:
+        // TODO(sop) handle an octopus merge.
+        return null;
+    }
+  }
+
+  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
+    ObjectId id = ins.insert(Constants.OBJ_TREE, new byte[] {});
+    ins.flush();
+    return id;
+  }
+
+  @AutoValue
+  abstract static class EditsDueToRebaseResult {
+    public static EditsDueToRebaseResult create(
+        List<DiffEntry> relevantDiffEntries,
+        Multimap<String, ContextAwareEdit> editsDueToRebasePerFilePath) {
+      return new AutoValue_PatchListLoader_EditsDueToRebaseResult(
+          relevantDiffEntries, editsDueToRebasePerFilePath);
+    }
+
+    public abstract List<DiffEntry> getRelevantOriginalDiffEntries();
+
+    /** Returns the edits per file path they modify in {@code treeB}. */
+    public abstract Multimap<String, ContextAwareEdit> getEditsDueToRebasePerFilePath();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListNotAvailableException.java b/java/com/google/gerrit/server/patch/PatchListNotAvailableException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListNotAvailableException.java
rename to java/com/google/gerrit/server/patch/PatchListNotAvailableException.java
diff --git a/java/com/google/gerrit/server/patch/PatchListObjectTooLargeException.java b/java/com/google/gerrit/server/patch/PatchListObjectTooLargeException.java
new file mode 100644
index 0000000..54e0e6c
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/PatchListObjectTooLargeException.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+/**
+ * Exception thrown when the PatchList could not be computed because previous attempts failed with
+ * {@code LargeObjectException}. This is not thrown on the first computation.
+ */
+public class PatchListObjectTooLargeException extends PatchListNotAvailableException {
+  private static final long serialVersionUID = 1L;
+
+  public PatchListObjectTooLargeException(String message) {
+    super(message);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListWeigher.java b/java/com/google/gerrit/server/patch/PatchListWeigher.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListWeigher.java
rename to java/com/google/gerrit/server/patch/PatchListWeigher.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
rename to java/com/google/gerrit/server/patch/PatchScriptBuilder.java
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
new file mode 100644
index 0000000..fe158f8
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -0,0 +1,412 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.CommentDetail;
+import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.Patch.ChangeType;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PatchScriptFactory implements Callable<PatchScript> {
+  public interface Factory {
+    PatchScriptFactory create(
+        ChangeNotes notes,
+        String fileName,
+        @Assisted("patchSetA") PatchSet.Id patchSetA,
+        @Assisted("patchSetB") PatchSet.Id patchSetB,
+        DiffPreferencesInfo diffPrefs);
+
+    PatchScriptFactory create(
+        ChangeNotes notes,
+        String fileName,
+        int parentNum,
+        PatchSet.Id patchSetB,
+        DiffPreferencesInfo diffPrefs);
+  }
+
+  private static final Logger log = LoggerFactory.getLogger(PatchScriptFactory.class);
+
+  private final GitRepositoryManager repoManager;
+  private final PatchSetUtil psUtil;
+  private final Provider<PatchScriptBuilder> builderFactory;
+  private final PatchListCache patchListCache;
+  private final ReviewDb db;
+  private final CommentsUtil commentsUtil;
+
+  private final String fileName;
+  @Nullable private final PatchSet.Id psa;
+  private final int parentNum;
+  private final PatchSet.Id psb;
+  private final DiffPreferencesInfo diffPrefs;
+  private final ChangeEditUtil editReader;
+  private final Provider<CurrentUser> userProvider;
+  private final PermissionBackend permissionBackend;
+  private Optional<ChangeEdit> edit;
+
+  private final Change.Id changeId;
+  private boolean loadHistory = true;
+  private boolean loadComments = true;
+
+  private ChangeNotes notes;
+  private ObjectId aId;
+  private ObjectId bId;
+  private List<Patch> history;
+  private CommentDetail comments;
+
+  @AssistedInject
+  PatchScriptFactory(
+      GitRepositoryManager grm,
+      PatchSetUtil psUtil,
+      Provider<PatchScriptBuilder> builderFactory,
+      PatchListCache patchListCache,
+      ReviewDb db,
+      CommentsUtil commentsUtil,
+      ChangeEditUtil editReader,
+      Provider<CurrentUser> userProvider,
+      PermissionBackend permissionBackend,
+      @Assisted ChangeNotes notes,
+      @Assisted String fileName,
+      @Assisted("patchSetA") @Nullable PatchSet.Id patchSetA,
+      @Assisted("patchSetB") PatchSet.Id patchSetB,
+      @Assisted DiffPreferencesInfo diffPrefs) {
+    this.repoManager = grm;
+    this.psUtil = psUtil;
+    this.builderFactory = builderFactory;
+    this.patchListCache = patchListCache;
+    this.db = db;
+    this.notes = notes;
+    this.commentsUtil = commentsUtil;
+    this.editReader = editReader;
+    this.userProvider = userProvider;
+    this.permissionBackend = permissionBackend;
+
+    this.fileName = fileName;
+    this.psa = patchSetA;
+    this.parentNum = -1;
+    this.psb = patchSetB;
+    this.diffPrefs = diffPrefs;
+
+    changeId = patchSetB.getParentKey();
+  }
+
+  @AssistedInject
+  PatchScriptFactory(
+      GitRepositoryManager grm,
+      PatchSetUtil psUtil,
+      Provider<PatchScriptBuilder> builderFactory,
+      PatchListCache patchListCache,
+      ReviewDb db,
+      CommentsUtil commentsUtil,
+      ChangeEditUtil editReader,
+      Provider<CurrentUser> userProvider,
+      PermissionBackend permissionBackend,
+      @Assisted ChangeNotes notes,
+      @Assisted String fileName,
+      @Assisted int parentNum,
+      @Assisted PatchSet.Id patchSetB,
+      @Assisted DiffPreferencesInfo diffPrefs) {
+    this.repoManager = grm;
+    this.psUtil = psUtil;
+    this.builderFactory = builderFactory;
+    this.patchListCache = patchListCache;
+    this.db = db;
+    this.notes = notes;
+    this.commentsUtil = commentsUtil;
+    this.editReader = editReader;
+    this.userProvider = userProvider;
+    this.permissionBackend = permissionBackend;
+
+    this.fileName = fileName;
+    this.psa = null;
+    this.parentNum = parentNum;
+    this.psb = patchSetB;
+    this.diffPrefs = diffPrefs;
+
+    changeId = patchSetB.getParentKey();
+    checkArgument(parentNum >= 0, "parentNum must be >= 0");
+  }
+
+  public void setLoadHistory(boolean load) {
+    loadHistory = load;
+  }
+
+  public void setLoadComments(boolean load) {
+    loadComments = load;
+  }
+
+  @Override
+  public PatchScript call()
+      throws OrmException, LargeObjectException, AuthException, InvalidChangeOperationException,
+          IOException, PermissionBackendException {
+    if (parentNum < 0) {
+      validatePatchSetId(psa);
+    }
+    validatePatchSetId(psb);
+
+    PatchSet psEntityA = psa != null ? psUtil.get(db, notes, psa) : null;
+    PatchSet psEntityB = psb.get() == 0 ? new PatchSet(psb) : psUtil.get(db, notes, psb);
+    if (psEntityA != null || psEntityB != null) {
+      try {
+        permissionBackend
+            .user(userProvider)
+            .change(notes)
+            .database(db)
+            .check(ChangePermission.READ);
+      } catch (AuthException e) {
+        throw new NoSuchChangeException(changeId);
+      }
+    }
+
+    try (Repository git = repoManager.openRepository(notes.getProjectName())) {
+      bId = toObjectId(psEntityB);
+      if (parentNum < 0) {
+        aId = psEntityA != null ? toObjectId(psEntityA) : null;
+      }
+
+      try {
+        final PatchList list = listFor(keyFor(diffPrefs.ignoreWhitespace));
+        final PatchScriptBuilder b = newBuilder(list, git);
+        final PatchListEntry content = list.get(fileName);
+
+        loadCommentsAndHistory(content.getChangeType(), content.getOldName(), content.getNewName());
+
+        return b.toPatchScript(content, comments, history);
+      } catch (PatchListNotAvailableException e) {
+        throw new NoSuchChangeException(changeId, e);
+      } catch (IOException e) {
+        log.error("File content unavailable", e);
+        throw new NoSuchChangeException(changeId, e);
+      } catch (org.eclipse.jgit.errors.LargeObjectException err) {
+        throw new LargeObjectException("File content is too large", err);
+      }
+    } catch (RepositoryNotFoundException e) {
+      log.error("Repository " + notes.getProjectName() + " not found", e);
+      throw new NoSuchChangeException(changeId, e);
+    } catch (IOException e) {
+      log.error("Cannot open repository " + notes.getProjectName(), e);
+      throw new NoSuchChangeException(changeId, e);
+    }
+  }
+
+  private PatchListKey keyFor(Whitespace whitespace) {
+    if (parentNum < 0) {
+      return PatchListKey.againstCommit(aId, bId, whitespace);
+    }
+    return PatchListKey.againstParentNum(parentNum + 1, bId, whitespace);
+  }
+
+  private PatchList listFor(PatchListKey key) throws PatchListNotAvailableException {
+    return patchListCache.get(key, notes.getProjectName());
+  }
+
+  private PatchScriptBuilder newBuilder(PatchList list, Repository git) {
+    final PatchScriptBuilder b = builderFactory.get();
+    b.setRepository(git, notes.getProjectName());
+    b.setChange(notes.getChange());
+    b.setDiffPrefs(diffPrefs);
+    b.setTrees(list.getComparisonType(), list.getOldId(), list.getNewId());
+    return b;
+  }
+
+  private ObjectId toObjectId(PatchSet ps) throws AuthException, IOException, OrmException {
+    if (ps.getId().get() == 0) {
+      return getEditRev();
+    }
+    if (ps.getRevision() == null || ps.getRevision().get() == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+
+    try {
+      return ObjectId.fromString(ps.getRevision().get());
+    } catch (IllegalArgumentException e) {
+      log.error("Patch set " + ps.getId() + " has invalid revision");
+      throw new NoSuchChangeException(changeId, e);
+    }
+  }
+
+  private ObjectId getEditRev() throws AuthException, IOException, OrmException {
+    edit = editReader.byChange(notes);
+    if (edit.isPresent()) {
+      return edit.get().getEditCommit();
+    }
+    throw new NoSuchChangeException(notes.getChangeId());
+  }
+
+  private void validatePatchSetId(PatchSet.Id psId) throws NoSuchChangeException {
+    if (psId == null) { // OK, means use base;
+    } else if (changeId.equals(psId.getParentKey())) { // OK, same change;
+    } else {
+      throw new NoSuchChangeException(changeId);
+    }
+  }
+
+  private void loadCommentsAndHistory(ChangeType changeType, String oldName, String newName)
+      throws OrmException {
+    Map<Patch.Key, Patch> byKey = new HashMap<>();
+
+    if (loadHistory) {
+      // This seems like a cheap trick. It doesn't properly account for a
+      // file that gets renamed between patch set 1 and patch set 2. We
+      // will wind up packing the wrong Patch object because we didn't do
+      // proper rename detection between the patch sets.
+      //
+      history = new ArrayList<>();
+      for (PatchSet ps : psUtil.byChange(db, notes)) {
+        String name = fileName;
+        if (psa != null) {
+          switch (changeType) {
+            case COPIED:
+            case RENAMED:
+              if (ps.getId().equals(psa)) {
+                name = oldName;
+              }
+              break;
+
+            case MODIFIED:
+            case DELETED:
+            case ADDED:
+            case REWRITE:
+              break;
+          }
+        }
+
+        Patch p = new Patch(new Patch.Key(ps.getId(), name));
+        history.add(p);
+        byKey.put(p.getKey(), p);
+      }
+      if (edit != null && edit.isPresent()) {
+        Patch p = new Patch(new Patch.Key(new PatchSet.Id(psb.getParentKey(), 0), fileName));
+        history.add(p);
+        byKey.put(p.getKey(), p);
+      }
+    }
+
+    if (loadComments && edit == null) {
+      comments = new CommentDetail(psa, psb);
+      switch (changeType) {
+        case ADDED:
+        case MODIFIED:
+          loadPublished(byKey, newName);
+          break;
+
+        case DELETED:
+          loadPublished(byKey, newName);
+          break;
+
+        case COPIED:
+        case RENAMED:
+          if (psa != null) {
+            loadPublished(byKey, oldName);
+          }
+          loadPublished(byKey, newName);
+          break;
+
+        case REWRITE:
+          break;
+      }
+
+      CurrentUser user = userProvider.get();
+      if (user.isIdentifiedUser()) {
+        Account.Id me = user.getAccountId();
+        switch (changeType) {
+          case ADDED:
+          case MODIFIED:
+            loadDrafts(byKey, me, newName);
+            break;
+
+          case DELETED:
+            loadDrafts(byKey, me, newName);
+            break;
+
+          case COPIED:
+          case RENAMED:
+            if (psa != null) {
+              loadDrafts(byKey, me, oldName);
+            }
+            loadDrafts(byKey, me, newName);
+            break;
+
+          case REWRITE:
+            break;
+        }
+      }
+    }
+  }
+
+  private void loadPublished(Map<Patch.Key, Patch> byKey, String file) throws OrmException {
+    for (Comment c : commentsUtil.publishedByChangeFile(db, notes, changeId, file)) {
+      comments.include(notes.getChangeId(), c);
+      PatchSet.Id psId = new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
+      Patch.Key pKey = new Patch.Key(psId, c.key.filename);
+      Patch p = byKey.get(pKey);
+      if (p != null) {
+        p.setCommentCount(p.getCommentCount() + 1);
+      }
+    }
+  }
+
+  private void loadDrafts(Map<Patch.Key, Patch> byKey, Account.Id me, String file)
+      throws OrmException {
+    for (Comment c : commentsUtil.draftByChangeFileAuthor(db, notes, file, me)) {
+      comments.include(notes.getChangeId(), c);
+      PatchSet.Id psId = new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
+      Patch.Key pKey = new Patch.Key(psId, c.key.filename);
+      Patch p = byKey.get(pKey);
+      if (p != null) {
+        p.setDraftCount(p.getDraftCount() + 1);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java b/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
rename to java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoNotAvailableException.java b/java/com/google/gerrit/server/patch/PatchSetInfoNotAvailableException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoNotAvailableException.java
rename to java/com/google/gerrit/server/patch/PatchSetInfoNotAvailableException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/Text.java b/java/com/google/gerrit/server/patch/Text.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/patch/Text.java
rename to java/com/google/gerrit/server/patch/Text.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermission.java
rename to java/com/google/gerrit/server/permissions/ChangePermission.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
rename to java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
rename to java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/permissions/GlobalPermission.java
rename to java/com/google/gerrit/server/permissions/GlobalPermission.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/LabelPermission.java b/java/com/google/gerrit/server/permissions/LabelPermission.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/permissions/LabelPermission.java
rename to java/com/google/gerrit/server/permissions/LabelPermission.java
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
new file mode 100644
index 0000000..c87e8e4
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -0,0 +1,433 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.DefaultPermissionBackend;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.ImplementedBy;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Iterator;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Checks authorization to perform an action on a project, reference, or change.
+ *
+ * <p>{@code check} methods should be used during action handlers to verify the user is allowed to
+ * exercise the specified permission. For convenience in implementation {@code check} methods throw
+ * {@link AuthException} if the permission is denied.
+ *
+ * <p>{@code test} methods should be used when constructing replies to the client and the result
+ * object needs to include a true/false hint indicating the user's ability to exercise the
+ * permission. This is suitable for configuring UI button state, but should not be relied upon to
+ * guard handlers before making state changes.
+ *
+ * <p>{@code PermissionBackend} is a singleton for the server, acting as a factory for lightweight
+ * request instances. Implementation classes may cache supporting data inside of {@link WithUser},
+ * {@link ForProject}, {@link ForRef}, and {@link ForChange} instances, in addition to storing
+ * within {@link CurrentUser} using a {@link com.google.gerrit.server.CurrentUser.PropertyKey}.
+ * {@link GlobalPermission} caching for {@link WithUser} may best cached inside {@link CurrentUser}
+ * as {@link WithUser} instances are frequently created.
+ *
+ * <p>Example use:
+ *
+ * <pre>
+ *   private final PermissionBackend permissions;
+ *   private final Provider<CurrentUser> user;
+ *
+ *   @Inject
+ *   Foo(PermissionBackend permissions, Provider<CurrentUser> user) {
+ *     this.permissions = permissions;
+ *     this.user = user;
+ *   }
+ *
+ *   public void apply(...) {
+ *     permissions.user(user).change(cd).check(ChangePermission.SUBMIT);
+ *   }
+ *
+ *   public UiAction.Description getDescription(ChangeResource rsrc) {
+ *     return new UiAction.Description()
+ *       .setLabel("Submit")
+ *       .setVisible(rsrc.permissions().testCond(ChangePermission.SUBMIT));
+ * }
+ * </pre>
+ */
+@ImplementedBy(DefaultPermissionBackend.class)
+public abstract class PermissionBackend {
+  private static final Logger logger = LoggerFactory.getLogger(PermissionBackend.class);
+
+  /** @return lightweight factory scoped to answer for the specified user. */
+  public abstract WithUser user(CurrentUser user);
+
+  /** @return lightweight factory scoped to answer for the specified user. */
+  public <U extends CurrentUser> WithUser user(Provider<U> user) {
+    return user(checkNotNull(user, "Provider<CurrentUser>").get());
+  }
+
+  /**
+   * Bulk evaluate a collection of {@link PermissionBackendCondition} for view handling.
+   *
+   * <p>Overridden implementations should call {@link PermissionBackendCondition#set(boolean)} to
+   * cache the result of {@code testOrFalse} in the condition for later evaluation. Caching the
+   * result will bypass the usual invocation of {@code testOrFalse}.
+   *
+   * <p>{@code conds} may contain duplicate entries (such as same user, resource, permission
+   * triplet). When duplicates exist, implementations should set a result into all instances to
+   * ensure {@code testOrFalse} does not get invoked during evaluation of the containing condition.
+   *
+   * @param conds conditions to consider.
+   */
+  public void bulkEvaluateTest(Collection<PermissionBackendCondition> conds) {
+    // Do nothing by default. The default implementation of PermissionBackendCondition
+    // delegates to the appropriate testOrFalse method in PermissionBackend.
+  }
+
+  /** PermissionBackend with an optional per-request ReviewDb handle. */
+  public abstract static class AcceptsReviewDb<T> {
+    protected Provider<ReviewDb> db;
+
+    public T database(Provider<ReviewDb> db) {
+      if (db != null) {
+        this.db = db;
+      }
+      return self();
+    }
+
+    public T database(ReviewDb db) {
+      return database(Providers.of(checkNotNull(db, "ReviewDb")));
+    }
+
+    @SuppressWarnings("unchecked")
+    private T self() {
+      return (T) this;
+    }
+  }
+
+  /** PermissionBackend scoped to a specific user. */
+  public abstract static class WithUser extends AcceptsReviewDb<WithUser> {
+    /** @return instance scoped for the specified project. */
+    public abstract ForProject project(Project.NameKey project);
+
+    /** @return instance scoped for the {@code ref}, and its parent project. */
+    public ForRef ref(Branch.NameKey ref) {
+      return project(ref.getParentKey()).ref(ref.get()).database(db);
+    }
+
+    /** @return instance scoped for the change, and its destination ref and project. */
+    public ForChange change(ChangeData cd) {
+      try {
+        return ref(cd.change().getDest()).change(cd);
+      } catch (OrmException e) {
+        return FailedPermissionBackend.change("unavailable", e);
+      }
+    }
+
+    /** @return instance scoped for the change, and its destination ref and project. */
+    public ForChange change(ChangeNotes notes) {
+      return ref(notes.getChange().getDest()).change(notes);
+    }
+
+    /**
+     * @return instance scoped for the change loaded from index, and its destination ref and
+     *     project. This method should only be used when database access is harmful and potentially
+     *     stale data from the index is acceptable.
+     */
+    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
+      return ref(notes.getChange().getDest()).indexedChange(cd, notes);
+    }
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(GlobalOrPluginPermission perm)
+        throws AuthException, PermissionBackendException;
+
+    /**
+     * Verify scoped user can perform at least one listed permission.
+     *
+     * <p>If {@code any} is empty, the method completes normally and allows the caller to continue.
+     * Since no permissions were supplied to check, its assumed no permissions are necessary to
+     * continue with the caller's operation.
+     *
+     * <p>If the user has at least one of the permissions in {@code any}, the method completes
+     * normally, possibly without checking all listed permissions.
+     *
+     * <p>If {@code any} is non-empty and the user has none, {@link AuthException} is thrown for one
+     * of the failed permissions.
+     *
+     * @param any set of permissions to check.
+     */
+    public void checkAny(Set<GlobalOrPluginPermission> any)
+        throws PermissionBackendException, AuthException {
+      for (Iterator<GlobalOrPluginPermission> itr = any.iterator(); itr.hasNext(); ) {
+        try {
+          check(itr.next());
+          return;
+        } catch (AuthException err) {
+          if (!itr.hasNext()) {
+            throw err;
+          }
+        }
+      }
+    }
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(GlobalOrPluginPermission perm) throws PermissionBackendException {
+      return test(Collections.singleton(perm)).contains(perm);
+    }
+
+    public boolean testOrFalse(GlobalOrPluginPermission perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+
+    public BooleanCondition testCond(GlobalOrPluginPermission perm) {
+      return new PermissionBackendCondition.WithUser(this, perm);
+    }
+
+    /**
+     * Filter a set of projects using {@code check(perm)}.
+     *
+     * @param perm required permission in a project to be included in result.
+     * @param projects candidate set of projects; may be empty.
+     * @return filtered set of {@code projects} where {@code check(perm)} was successful.
+     * @throws PermissionBackendException backend cannot access its internal state.
+     */
+    public Set<Project.NameKey> filter(ProjectPermission perm, Collection<Project.NameKey> projects)
+        throws PermissionBackendException {
+      checkNotNull(perm, "ProjectPermission");
+      checkNotNull(projects, "projects");
+      Set<Project.NameKey> allowed = Sets.newHashSetWithExpectedSize(projects.size());
+      for (Project.NameKey project : projects) {
+        try {
+          project(project).check(perm);
+          allowed.add(project);
+        } catch (AuthException e) {
+          // Do not include this project in allowed.
+        }
+      }
+      return allowed;
+    }
+  }
+
+  /** PermissionBackend scoped to a user and project. */
+  public abstract static class ForProject extends AcceptsReviewDb<ForProject> {
+    /** @return new instance rescoped to same project, but different {@code user}. */
+    public abstract ForProject user(CurrentUser user);
+
+    /** @return instance scoped for {@code ref} in this project. */
+    public abstract ForRef ref(String ref);
+
+    /** @return instance scoped for the change, and its destination ref and project. */
+    public ForChange change(ChangeData cd) {
+      try {
+        return ref(cd.change().getDest().get()).change(cd);
+      } catch (OrmException e) {
+        return FailedPermissionBackend.change("unavailable", e);
+      }
+    }
+
+    /** @return instance scoped for the change, and its destination ref and project. */
+    public ForChange change(ChangeNotes notes) {
+      return ref(notes.getChange().getDest().get()).change(notes);
+    }
+
+    /**
+     * @return instance scoped for the change loaded from index, and its destination ref and
+     *     project. This method should only be used when database access is harmful and potentially
+     *     stale data from the index is acceptable.
+     */
+    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
+      return ref(notes.getChange().getDest().get()).indexedChange(cd, notes);
+    }
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(ProjectPermission perm)
+        throws AuthException, PermissionBackendException;
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(ProjectPermission perm) throws PermissionBackendException {
+      return test(EnumSet.of(perm)).contains(perm);
+    }
+
+    public boolean testOrFalse(ProjectPermission perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+
+    public BooleanCondition testCond(ProjectPermission perm) {
+      return new PermissionBackendCondition.ForProject(this, perm);
+    }
+  }
+
+  /** PermissionBackend scoped to a user, project and reference. */
+  public abstract static class ForRef extends AcceptsReviewDb<ForRef> {
+    /** @return new instance rescoped to same reference, but different {@code user}. */
+    public abstract ForRef user(CurrentUser user);
+
+    /** @return instance scoped to change. */
+    public abstract ForChange change(ChangeData cd);
+
+    /** @return instance scoped to change. */
+    public abstract ForChange change(ChangeNotes notes);
+
+    /**
+     * @return instance scoped to change loaded from index. This method should only be used when
+     *     database access is harmful and potentially stale data from the index is acceptable.
+     */
+    public abstract ForChange indexedChange(ChangeData cd, ChangeNotes notes);
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(RefPermission perm) throws AuthException, PermissionBackendException;
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract Set<RefPermission> test(Collection<RefPermission> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(RefPermission perm) throws PermissionBackendException {
+      return test(EnumSet.of(perm)).contains(perm);
+    }
+
+    /**
+     * Test if user may be able to perform the permission.
+     *
+     * <p>Similar to {@link #test(RefPermission)} except this method returns {@code false} instead
+     * of throwing an exception.
+     *
+     * @param perm the permission to test.
+     * @return true if the user might be able to perform the permission; false if the user may be
+     *     missing the necessary grants or state, or if the backend threw an exception.
+     */
+    public boolean testOrFalse(RefPermission perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+
+    public BooleanCondition testCond(RefPermission perm) {
+      return new PermissionBackendCondition.ForRef(this, perm);
+    }
+  }
+
+  /** PermissionBackend scoped to a user, project, reference and change. */
+  public abstract static class ForChange extends AcceptsReviewDb<ForChange> {
+    /** @return user this instance is scoped to. */
+    public abstract CurrentUser user();
+
+    /** @return new instance rescoped to same change, but different {@code user}. */
+    public abstract ForChange user(CurrentUser user);
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(ChangePermissionOrLabel perm)
+        throws AuthException, PermissionBackendException;
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(ChangePermissionOrLabel perm) throws PermissionBackendException {
+      return test(Collections.singleton(perm)).contains(perm);
+    }
+
+    /**
+     * Test if user may be able to perform the permission.
+     *
+     * <p>Similar to {@link #test(ChangePermissionOrLabel)} except this method returns {@code false}
+     * instead of throwing an exception.
+     *
+     * @param perm the permission to test.
+     * @return true if the user might be able to perform the permission; false if the user may be
+     *     missing the necessary grants or state, or if the backend threw an exception.
+     */
+    public boolean testOrFalse(ChangePermissionOrLabel perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+
+    public BooleanCondition testCond(ChangePermissionOrLabel perm) {
+      return new PermissionBackendCondition.ForChange(this, perm);
+    }
+
+    /**
+     * Test which values of a label the user may be able to set.
+     *
+     * @param label definition of the label to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelPermission.WithValue> test(LabelType label) throws PermissionBackendException {
+      return test(valuesOf(checkNotNull(label, "LabelType")));
+    }
+
+    /**
+     * Test which values of a group of labels the user may be able to set.
+     *
+     * @param types definition of the labels to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelPermission.WithValue> testLabels(Collection<LabelType> types)
+        throws PermissionBackendException {
+      checkNotNull(types, "LabelType");
+      return test(types.stream().flatMap((t) -> valuesOf(t).stream()).collect(toSet()));
+    }
+
+    private static Set<LabelPermission.WithValue> valuesOf(LabelType label) {
+      return label
+          .getValues()
+          .stream()
+          .map((v) -> new LabelPermission.WithValue(label, v))
+          .collect(toSet());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java b/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
rename to java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendException.java b/java/com/google/gerrit/server/permissions/PermissionBackendException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendException.java
rename to java/com/google/gerrit/server/permissions/PermissionBackendException.java
diff --git a/java/com/google/gerrit/server/permissions/ProjectPermission.java b/java/com/google/gerrit/server/permissions/ProjectPermission.java
new file mode 100644
index 0000000..6627f76
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/ProjectPermission.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import com.google.gerrit.common.data.Permission;
+import java.util.Locale;
+import java.util.Optional;
+
+public enum ProjectPermission {
+  /**
+   * Can access at least one reference or change within the repository.
+   *
+   * <p>Checking this permission instead of {@link #READ} may require filtering to hide specific
+   * references or changes, which can be expensive.
+   */
+  ACCESS,
+
+  /**
+   * Can read all references in the repository.
+   *
+   * <p>This is a stronger form of {@link #ACCESS} where no filtering is required.
+   */
+  READ(Permission.READ),
+
+  /**
+   * Can read all non-config references in the repository.
+   *
+   * <p>This is the same as {@code READ} but does not check if they user can see refs/meta/config.
+   * Therefore, callers should check {@code READ} before excluding config refs in a short-circuit.
+   */
+  READ_NO_CONFIG,
+
+  /**
+   * Can create at least one reference in the project.
+   *
+   * <p>This project level permission only validates the user may create some type of reference
+   * within the project. The exact reference name must be checked at creation:
+   *
+   * <pre>permissionBackend
+   *    .user(user)
+   *    .project(proj)
+   *    .ref(ref)
+   *    .check(RefPermission.CREATE);
+   * </pre>
+   */
+  CREATE_REF,
+
+  /**
+   * Can create at least one change in the project.
+   *
+   * <p>This project level permission only validates the user may create a change for some branch
+   * within the project. The exact reference name must be checked at creation:
+   *
+   * <pre>permissionBackend
+   *    .user(user)
+   *    .project(proj)
+   *    .ref(ref)
+   *    .check(RefPermission.CREATE_CHANGE);
+   * </pre>
+   */
+  CREATE_CHANGE,
+
+  /** Can run receive pack. */
+  RUN_RECEIVE_PACK,
+
+  /** Can run upload pack. */
+  RUN_UPLOAD_PACK,
+
+  /** 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;
+
+  ProjectPermission() {
+    name = null;
+  }
+
+  ProjectPermission(String name) {
+    this.name = name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  public Optional<String> permissionName() {
+    return Optional.ofNullable(name);
+  }
+
+  public String describeForException() {
+    return toString().toLowerCase(Locale.US).replace('_', ' ');
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/RefPermission.java b/java/com/google/gerrit/server/permissions/RefPermission.java
new file mode 100644
index 0000000..0d4d6ff
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/RefPermission.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import com.google.gerrit.common.data.Permission;
+import java.util.Locale;
+import java.util.Optional;
+
+public enum RefPermission {
+  READ(Permission.READ),
+  CREATE(Permission.CREATE),
+  DELETE(Permission.DELETE),
+  UPDATE(Permission.PUSH),
+  FORCE_UPDATE,
+  SET_HEAD,
+
+  FORGE_AUTHOR(Permission.FORGE_AUTHOR),
+  FORGE_COMMITTER(Permission.FORGE_COMMITTER),
+  FORGE_SERVER(Permission.FORGE_SERVER),
+  MERGE,
+  SKIP_VALIDATION,
+
+  /** Create a change to code review a commit. */
+  CREATE_CHANGE,
+
+  /** Create a tag. */
+  CREATE_TAG(Permission.CREATE_TAG),
+
+  /**
+   * Creates changes, then also immediately submits them during {@code push}.
+   *
+   * <p>This is similar to {@link #UPDATE} except it constructs changes first, then submits them
+   * according to the submit strategy, which may include cherry-pick or rebase. By creating changes
+   * for each commit, automatic server side rebase, and post-update review are enabled.
+   */
+  UPDATE_BY_SUBMIT,
+
+  /**
+   * 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;
+
+  RefPermission() {
+    name = null;
+  }
+
+  RefPermission(String name) {
+    this.name = name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  public Optional<String> permissionName() {
+    return Optional.ofNullable(name);
+  }
+
+  public String describeForException() {
+    return toString().toLowerCase(Locale.US).replace('_', ' ');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java b/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java
rename to java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java b/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
rename to java/com/google/gerrit/server/plugins/AutoRegisterModules.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java b/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java
rename to java/com/google/gerrit/server/plugins/AutoRegisterUtil.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/CleanupHandle.java b/java/com/google/gerrit/server/plugins/CleanupHandle.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/CleanupHandle.java
rename to java/com/google/gerrit/server/plugins/CleanupHandle.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/CopyConfigModule.java b/java/com/google/gerrit/server/plugins/CopyConfigModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/CopyConfigModule.java
rename to java/com/google/gerrit/server/plugins/CopyConfigModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java b/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java
rename to java/com/google/gerrit/server/plugins/DelegatingClassLoader.java
diff --git a/java/com/google/gerrit/server/plugins/DisablePlugin.java b/java/com/google/gerrit/server/plugins/DisablePlugin.java
new file mode 100644
index 0000000..266350f
--- /dev/null
+++ b/java/com/google/gerrit/server/plugins/DisablePlugin.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+import com.google.common.collect.ImmutableSet;
+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.extensions.restapi.RestModifyView;
+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;
+
+@Singleton
+public class DisablePlugin implements RestModifyView<PluginResource, Input> {
+
+  private final PluginLoader loader;
+  private final Provider<IdentifiedUser> user;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  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 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/java/com/google/gerrit/server/plugins/EnablePlugin.java b/java/com/google/gerrit/server/plugins/EnablePlugin.java
new file mode 100644
index 0000000..569bc39
--- /dev/null
+++ b/java/com/google/gerrit/server/plugins/EnablePlugin.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.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.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class EnablePlugin implements RestModifyView<PluginResource, Input> {
+
+  private final PluginLoader loader;
+
+  @Inject
+  EnablePlugin(PluginLoader loader) {
+    this.loader = loader;
+  }
+
+  @Override
+  public PluginInfo apply(PluginResource resource, Input input) throws RestApiException {
+    loader.checkRemoteAdminEnabled();
+    String name = resource.getName();
+    try {
+      loader.enablePlugins(ImmutableSet.of(name));
+    } catch (PluginInstallException e) {
+      StringWriter buf = new StringWriter();
+      buf.write(String.format("cannot enable %s\n", name));
+      PrintWriter pw = new PrintWriter(buf);
+      e.printStackTrace(pw);
+      pw.flush();
+      throw new ResourceConflictException(buf.toString());
+    }
+    return ListPlugins.toPluginInfo(loader.get(name));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/GetStatus.java b/java/com/google/gerrit/server/plugins/GetStatus.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/GetStatus.java
rename to java/com/google/gerrit/server/plugins/GetStatus.java
diff --git a/java/com/google/gerrit/server/plugins/InstallPlugin.java b/java/com/google/gerrit/server/plugins/InstallPlugin.java
new file mode 100644
index 0000000..ee9099e
--- /dev/null
+++ b/java/com/google/gerrit/server/plugins/InstallPlugin.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.InstallPluginInput;
+import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.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;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.net.URL;
+import java.util.zip.ZipException;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+public class InstallPlugin implements RestModifyView<TopLevelResource, InstallPluginInput> {
+  private final PluginLoader loader;
+
+  private String name;
+  private boolean created;
+
+  @Inject
+  InstallPlugin(PluginLoader loader) {
+    this.loader = loader;
+  }
+
+  public InstallPlugin setName(String name) {
+    this.name = name;
+    return this;
+  }
+
+  public InstallPlugin setCreated(boolean created) {
+    this.created = created;
+    return this;
+  }
+
+  @Override
+  public Response<PluginInfo> apply(TopLevelResource resource, InstallPluginInput input)
+      throws RestApiException, IOException {
+    loader.checkRemoteAdminEnabled();
+    try {
+      try (InputStream in = openStream(input)) {
+        String pluginName = loader.installPluginFromStream(name, in);
+        PluginInfo info = ListPlugins.toPluginInfo(loader.get(pluginName));
+        return created ? Response.created(info) : Response.ok(info);
+      }
+    } catch (PluginInstallException e) {
+      StringWriter buf = new StringWriter();
+      buf.write(String.format("cannot install %s", name));
+      if (e.getCause() instanceof ZipException) {
+        buf.write(": ");
+        buf.write(e.getCause().getMessage());
+      } else {
+        buf.write(":\n");
+        PrintWriter pw = new PrintWriter(buf);
+        e.printStackTrace(pw);
+        pw.flush();
+      }
+      throw new BadRequestException(buf.toString());
+    }
+  }
+
+  private InputStream openStream(InstallPluginInput input) throws IOException, BadRequestException {
+    if (input.raw != null) {
+      return input.raw.getInputStream();
+    }
+    try {
+      return new URL(input.url).openStream();
+    } catch (IOException e) {
+      throw new BadRequestException(e.getMessage());
+    }
+  }
+
+  @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+  static class Overwrite implements RestModifyView<PluginResource, InstallPluginInput> {
+    private final Provider<InstallPlugin> install;
+
+    @Inject
+    Overwrite(Provider<InstallPlugin> install) {
+      this.install = install;
+    }
+
+    @Override
+    public Response<PluginInfo> apply(PluginResource resource, InstallPluginInput input)
+        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/InvalidPluginException.java b/java/com/google/gerrit/server/plugins/InvalidPluginException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/InvalidPluginException.java
rename to java/com/google/gerrit/server/plugins/InvalidPluginException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarPluginProvider.java
rename to java/com/google/gerrit/server/plugins/JarPluginProvider.java
diff --git a/java/com/google/gerrit/server/plugins/JarScanner.java b/java/com/google/gerrit/server/plugins/JarScanner.java
new file mode 100644
index 0000000..1310b8c
--- /dev/null
+++ b/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -0,0 +1,347 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.Iterables.transform;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.MultimapBuilder;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.annotation.Annotation;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+import org.eclipse.jgit.util.IO;
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.Attribute;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class JarScanner implements PluginContentScanner, AutoCloseable {
+  private static final Logger log = LoggerFactory.getLogger(JarScanner.class);
+  private static final int SKIP_ALL =
+      ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES;
+  private final JarFile jarFile;
+
+  public JarScanner(Path src) throws IOException {
+    this.jarFile = new JarFile(src.toFile());
+  }
+
+  @Override
+  public Map<Class<? extends Annotation>, Iterable<ExtensionMetaData>> scan(
+      String pluginName, Iterable<Class<? extends Annotation>> annotations)
+      throws InvalidPluginException {
+    Set<String> descriptors = new HashSet<>();
+    ListMultimap<String, JarScanner.ClassData> rawMap =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+    Map<Class<? extends Annotation>, String> classObjToClassDescr = new HashMap<>();
+
+    for (Class<? extends Annotation> annotation : annotations) {
+      String descriptor = Type.getType(annotation).getDescriptor();
+      descriptors.add(descriptor);
+      classObjToClassDescr.put(annotation, descriptor);
+    }
+
+    Enumeration<JarEntry> e = jarFile.entries();
+    while (e.hasMoreElements()) {
+      JarEntry entry = e.nextElement();
+      if (skip(entry)) {
+        continue;
+      }
+
+      ClassData def = new ClassData(descriptors);
+      try {
+        new ClassReader(read(jarFile, entry)).accept(def, SKIP_ALL);
+      } catch (IOException err) {
+        throw new InvalidPluginException("Cannot auto-register", err);
+      } catch (RuntimeException err) {
+        log.warn(
+            String.format(
+                "Plugin %s has invalid class file %s inside of %s",
+                pluginName, entry.getName(), jarFile.getName()),
+            err);
+        continue;
+      }
+
+      if (!Strings.isNullOrEmpty(def.annotationName)) {
+        if (def.isConcrete()) {
+          rawMap.put(def.annotationName, def);
+        } else {
+          log.warn(
+              String.format(
+                  "Plugin %s tries to @%s(\"%s\") abstract class %s",
+                  pluginName, def.annotationName, def.annotationValue, def.className));
+        }
+      }
+    }
+
+    ImmutableMap.Builder<Class<? extends Annotation>, Iterable<ExtensionMetaData>> result =
+        ImmutableMap.builder();
+
+    for (Class<? extends Annotation> annotoation : annotations) {
+      String descr = classObjToClassDescr.get(annotoation);
+      Collection<ClassData> discoverdData = rawMap.get(descr);
+      Collection<ClassData> values = firstNonNull(discoverdData, Collections.<ClassData>emptySet());
+
+      result.put(
+          annotoation,
+          transform(values, cd -> new ExtensionMetaData(cd.className, cd.annotationValue)));
+    }
+
+    return result.build();
+  }
+
+  public List<String> findSubClassesOf(Class<?> superClass) throws IOException {
+    return findSubClassesOf(superClass.getName());
+  }
+
+  @Override
+  public void close() throws IOException {
+    jarFile.close();
+  }
+
+  private List<String> findSubClassesOf(String superClass) throws IOException {
+    String name = superClass.replace('.', '/');
+
+    List<String> classes = new ArrayList<>();
+    Enumeration<JarEntry> e = jarFile.entries();
+    while (e.hasMoreElements()) {
+      JarEntry entry = e.nextElement();
+      if (skip(entry)) {
+        continue;
+      }
+
+      ClassData def = new ClassData(Collections.<String>emptySet());
+      try {
+        new ClassReader(read(jarFile, entry)).accept(def, SKIP_ALL);
+      } catch (RuntimeException err) {
+        log.warn(
+            String.format("Jar %s has invalid class file %s", jarFile.getName(), entry.getName()),
+            err);
+        continue;
+      }
+
+      if (name.equals(def.superName)) {
+        classes.addAll(findSubClassesOf(def.className));
+        if (def.isConcrete()) {
+          classes.add(def.className);
+        }
+      }
+    }
+
+    return classes;
+  }
+
+  private static boolean skip(JarEntry entry) {
+    if (!entry.getName().endsWith(".class")) {
+      return true; // Avoid non-class resources.
+    }
+    if (entry.getSize() <= 0) {
+      return true; // Directories have 0 size.
+    }
+    if (entry.getSize() >= 1024 * 1024) {
+      return true; // Do not scan huge class files.
+    }
+    return false;
+  }
+
+  private static byte[] read(JarFile jarFile, JarEntry entry) throws IOException {
+    byte[] data = new byte[(int) entry.getSize()];
+    try (InputStream in = jarFile.getInputStream(entry)) {
+      IO.readFully(in, data, 0, data.length);
+    }
+    return data;
+  }
+
+  public static class ClassData extends ClassVisitor {
+    int access;
+    String className;
+    String superName;
+    String annotationName;
+    String annotationValue;
+    String[] interfaces;
+    Collection<String> exports;
+
+    private ClassData(Collection<String> exports) {
+      super(Opcodes.ASM6);
+      this.exports = exports;
+    }
+
+    boolean isConcrete() {
+      return (access & Opcodes.ACC_ABSTRACT) == 0 && (access & Opcodes.ACC_INTERFACE) == 0;
+    }
+
+    @Override
+    public void visit(
+        int version,
+        int access,
+        String name,
+        String signature,
+        String superName,
+        String[] interfaces) {
+      this.className = Type.getObjectType(name).getClassName();
+      this.access = access;
+      this.superName = superName;
+    }
+
+    @Override
+    public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
+      if (!visible) {
+        return null;
+      }
+      Optional<String> found = exports.stream().filter(x -> x.equals(desc)).findAny();
+      if (found.isPresent()) {
+        annotationName = desc;
+        return new AbstractAnnotationVisitor() {
+          @Override
+          public void visit(String name, Object value) {
+            annotationValue = (String) value;
+          }
+        };
+      }
+      return null;
+    }
+
+    @Override
+    public void visitSource(String arg0, String arg1) {}
+
+    @Override
+    public void visitOuterClass(String arg0, String arg1, String arg2) {}
+
+    @Override
+    public MethodVisitor visitMethod(
+        int arg0, String arg1, String arg2, String arg3, String[] arg4) {
+      return null;
+    }
+
+    @Override
+    public void visitInnerClass(String arg0, String arg1, String arg2, int arg3) {}
+
+    @Override
+    public FieldVisitor visitField(int arg0, String arg1, String arg2, String arg3, Object arg4) {
+      return null;
+    }
+
+    @Override
+    public void visitEnd() {}
+
+    @Override
+    public void visitAttribute(Attribute arg0) {}
+  }
+
+  private abstract static class AbstractAnnotationVisitor extends AnnotationVisitor {
+    AbstractAnnotationVisitor() {
+      super(Opcodes.ASM6);
+    }
+
+    @Override
+    public AnnotationVisitor visitAnnotation(String arg0, String arg1) {
+      return null;
+    }
+
+    @Override
+    public AnnotationVisitor visitArray(String arg0) {
+      return null;
+    }
+
+    @Override
+    public void visitEnum(String arg0, String arg1, String arg2) {}
+
+    @Override
+    public void visitEnd() {}
+  }
+
+  @Override
+  public Optional<PluginEntry> getEntry(String resourcePath) throws IOException {
+    JarEntry jarEntry = jarFile.getJarEntry(resourcePath);
+    if (jarEntry == null || jarEntry.getSize() == 0) {
+      return Optional.empty();
+    }
+
+    return Optional.of(resourceOf(jarEntry));
+  }
+
+  @Override
+  public Enumeration<PluginEntry> entries() {
+    return Collections.enumeration(
+        Lists.transform(
+            Collections.list(jarFile.entries()),
+            jarEntry -> {
+              try {
+                return resourceOf(jarEntry);
+              } catch (IOException e) {
+                throw new IllegalArgumentException(
+                    "Cannot convert jar entry " + jarEntry + " to a resource", e);
+              }
+            }));
+  }
+
+  @Override
+  public InputStream getInputStream(PluginEntry entry) throws IOException {
+    return jarFile.getInputStream(jarFile.getEntry(entry.getName()));
+  }
+
+  @Override
+  public Manifest getManifest() throws IOException {
+    return jarFile.getManifest();
+  }
+
+  private PluginEntry resourceOf(JarEntry jarEntry) throws IOException {
+    return new PluginEntry(
+        jarEntry.getName(),
+        jarEntry.getTime(),
+        Optional.of(jarEntry.getSize()),
+        attributesOf(jarEntry));
+  }
+
+  private Map<Object, String> attributesOf(JarEntry jarEntry) throws IOException {
+    Attributes attributes = jarEntry.getAttributes();
+    if (attributes == null) {
+      return Collections.emptyMap();
+    }
+    return Maps.transformEntries(
+        attributes,
+        new Maps.EntryTransformer<Object, Object, String>() {
+          @Override
+          public String transformEntry(Object key, Object value) {
+            return (String) value;
+          }
+        });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java b/java/com/google/gerrit/server/plugins/JsPlugin.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java
rename to java/com/google/gerrit/server/plugins/JsPlugin.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java b/java/com/google/gerrit/server/plugins/ListPlugins.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
rename to java/com/google/gerrit/server/plugins/ListPlugins.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ModuleGenerator.java b/java/com/google/gerrit/server/plugins/ModuleGenerator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/ModuleGenerator.java
rename to java/com/google/gerrit/server/plugins/ModuleGenerator.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java b/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java
rename to java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java b/java/com/google/gerrit/server/plugins/Plugin.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
rename to java/com/google/gerrit/server/plugins/Plugin.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginCleanerTask.java b/java/com/google/gerrit/server/plugins/PluginCleanerTask.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginCleanerTask.java
rename to java/com/google/gerrit/server/plugins/PluginCleanerTask.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java b/java/com/google/gerrit/server/plugins/PluginContentScanner.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java
rename to java/com/google/gerrit/server/plugins/PluginContentScanner.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java b/java/com/google/gerrit/server/plugins/PluginEntry.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java
rename to java/com/google/gerrit/server/plugins/PluginEntry.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
rename to java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginInstallException.java b/java/com/google/gerrit/server/plugins/PluginInstallException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginInstallException.java
rename to java/com/google/gerrit/server/plugins/PluginInstallException.java
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
new file mode 100644
index 0000000..954ea29
--- /dev/null
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -0,0 +1,731 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Joiner;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.LinkedHashMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.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;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.plugins.ServerPluginProvider.PluginDescription;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.AbstractMap;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Queue;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.internal.storage.file.FileSnapshot;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class PluginLoader implements LifecycleListener {
+  private static final Logger log = LoggerFactory.getLogger(PluginLoader.class);
+
+  public String getPluginName(Path srcPath) {
+    return MoreObjects.firstNonNull(getGerritPluginName(srcPath), PluginUtil.nameOf(srcPath));
+  }
+
+  private final Path pluginsDir;
+  private final Path dataDir;
+  private final Path tempDir;
+  private final PluginGuiceEnvironment env;
+  private final ServerInformationImpl srvInfoImpl;
+  private final PluginUser.Factory pluginUserFactory;
+  private final ConcurrentMap<String, Plugin> running;
+  private final ConcurrentMap<String, Plugin> disabled;
+  private final Map<String, FileSnapshot> broken;
+  private final Map<Plugin, CleanupHandle> cleanupHandles;
+  private final Queue<Plugin> toCleanup;
+  private final Provider<PluginCleanerTask> cleaner;
+  private final PluginScannerThread scanner;
+  private final Provider<String> urlProvider;
+  private final PersistentCacheFactory persistentCacheFactory;
+  private final boolean remoteAdmin;
+  private final UniversalServerPluginProvider serverPluginFactory;
+
+  @Inject
+  public PluginLoader(
+      SitePaths sitePaths,
+      PluginGuiceEnvironment pe,
+      ServerInformationImpl sii,
+      PluginUser.Factory puf,
+      Provider<PluginCleanerTask> pct,
+      @GerritServerConfig Config cfg,
+      @CanonicalWebUrl Provider<String> provider,
+      PersistentCacheFactory cacheFactory,
+      UniversalServerPluginProvider pluginFactory) {
+    pluginsDir = sitePaths.plugins_dir;
+    dataDir = sitePaths.data_dir;
+    tempDir = sitePaths.tmp_dir;
+    env = pe;
+    srvInfoImpl = sii;
+    pluginUserFactory = puf;
+    running = Maps.newConcurrentMap();
+    disabled = Maps.newConcurrentMap();
+    broken = new HashMap<>();
+    toCleanup = new ArrayDeque<>();
+    cleanupHandles = Maps.newConcurrentMap();
+    cleaner = pct;
+    urlProvider = provider;
+    persistentCacheFactory = cacheFactory;
+    serverPluginFactory = pluginFactory;
+
+    remoteAdmin = cfg.getBoolean("plugins", null, "allowRemoteAdmin", false);
+
+    long checkFrequency =
+        ConfigUtil.getTimeUnit(
+            cfg,
+            "plugins",
+            null,
+            "checkFrequency",
+            TimeUnit.MINUTES.toMillis(1),
+            TimeUnit.MILLISECONDS);
+    if (checkFrequency > 0) {
+      scanner = new PluginScannerThread(this, checkFrequency);
+    } else {
+      scanner = null;
+    }
+  }
+
+  public boolean isRemoteAdminEnabled() {
+    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) {
+      return p;
+    }
+    return disabled.get(name);
+  }
+
+  public Iterable<Plugin> getPlugins(boolean all) {
+    if (!all) {
+      return running.values();
+    }
+    List<Plugin> plugins = new ArrayList<>(running.values());
+    plugins.addAll(disabled.values());
+    return plugins;
+  }
+
+  public String installPluginFromStream(String originalName, InputStream in)
+      throws IOException, PluginInstallException {
+    checkRemoteInstall();
+
+    String fileName = originalName;
+    Path tmp = PluginUtil.asTemp(in, ".next_" + fileName + "_", ".tmp", pluginsDir);
+    String name = MoreObjects.firstNonNull(getGerritPluginName(tmp), PluginUtil.nameOf(fileName));
+    if (!originalName.equals(name)) {
+      log.warn(
+          String.format(
+              "Plugin provides its own name: <%s>, use it instead of the input name: <%s>",
+              name, originalName));
+    }
+
+    String fileExtension = getExtension(fileName);
+    Path dst = pluginsDir.resolve(name + fileExtension);
+    synchronized (this) {
+      Plugin active = running.get(name);
+      if (active != null) {
+        fileName = active.getSrcFile().getFileName().toString();
+        log.info(String.format("Replacing plugin %s", active.getName()));
+        Path old = pluginsDir.resolve(".last_" + fileName);
+        Files.deleteIfExists(old);
+        Files.move(active.getSrcFile(), old);
+      }
+
+      Files.deleteIfExists(pluginsDir.resolve(fileName + ".disabled"));
+      Files.move(tmp, dst);
+      try {
+        Plugin plugin = runPlugin(name, dst, active);
+        if (active == null) {
+          log.info(String.format("Installed plugin %s", plugin.getName()));
+        }
+      } catch (PluginInstallException e) {
+        Files.deleteIfExists(dst);
+        throw e;
+      }
+
+      cleanInBackground();
+    }
+
+    return name;
+  }
+
+  private synchronized void unloadPlugin(Plugin plugin) {
+    persistentCacheFactory.onStop(plugin);
+    String name = plugin.getName();
+    log.info(String.format("Unloading plugin %s, version %s", name, plugin.getVersion()));
+    plugin.stop(env);
+    env.onStopPlugin(plugin);
+    running.remove(name);
+    disabled.remove(name);
+    toCleanup.add(plugin);
+  }
+
+  public void disablePlugins(Set<String> names) {
+    if (!isRemoteAdminEnabled()) {
+      log.warn("Remote plugin administration is disabled, ignoring disablePlugins(" + names + ")");
+      return;
+    }
+
+    synchronized (this) {
+      for (String name : names) {
+        Plugin active = running.get(name);
+        if (active == null) {
+          continue;
+        }
+
+        log.info(String.format("Disabling plugin %s", active.getName()));
+        Path off =
+            active.getSrcFile().resolveSibling(active.getSrcFile().getFileName() + ".disabled");
+        try {
+          Files.move(active.getSrcFile(), off);
+        } catch (IOException e) {
+          log.error("Failed to disable plugin", e);
+          // In theory we could still unload the plugin even if the rename
+          // failed. However, it would be reloaded on the next server startup,
+          // which is probably not what the user expects.
+          continue;
+        }
+
+        unloadPlugin(active);
+        try {
+          FileSnapshot snapshot = FileSnapshot.save(off.toFile());
+          Plugin offPlugin = loadPlugin(name, off, snapshot);
+          disabled.put(name, offPlugin);
+        } catch (Throwable e) {
+          // This shouldn't happen, as the plugin was loaded earlier.
+          log.warn(String.format("Cannot load disabled plugin %s", active.getName()), e.getCause());
+        }
+      }
+      cleanInBackground();
+    }
+  }
+
+  public void enablePlugins(Set<String> names) throws PluginInstallException {
+    if (!isRemoteAdminEnabled()) {
+      log.warn("Remote plugin administration is disabled, ignoring enablePlugins(" + names + ")");
+      return;
+    }
+
+    synchronized (this) {
+      for (String name : names) {
+        Plugin off = disabled.get(name);
+        if (off == null) {
+          continue;
+        }
+
+        log.info(String.format("Enabling plugin %s", name));
+        String n = off.getSrcFile().toFile().getName();
+        if (n.endsWith(".disabled")) {
+          n = n.substring(0, n.lastIndexOf('.'));
+        }
+        Path on = pluginsDir.resolve(n);
+        try {
+          Files.move(off.getSrcFile(), on);
+        } catch (IOException e) {
+          log.error("Failed to move plugin " + name + " into place", e);
+          continue;
+        }
+        disabled.remove(name);
+        runPlugin(name, on, null);
+      }
+      cleanInBackground();
+    }
+  }
+
+  private void removeStalePluginFiles() {
+    DirectoryStream.Filter<Path> filter =
+        new DirectoryStream.Filter<Path>() {
+          @Override
+          public boolean accept(Path entry) throws IOException {
+            return entry.getFileName().toString().startsWith("plugin_");
+          }
+        };
+    try (DirectoryStream<Path> files = Files.newDirectoryStream(tempDir, filter)) {
+      for (Path file : files) {
+        log.info("Removing stale plugin file: " + file.toFile().getName());
+        try {
+          Files.delete(file);
+        } catch (IOException e) {
+          log.error(
+              String.format(
+                  "Failed to remove stale plugin file %s: %s",
+                  file.toFile().getName(), e.getMessage()));
+        }
+      }
+    } catch (IOException e) {
+      log.warn("Unable to discover stale plugin files: " + e.getMessage());
+    }
+  }
+
+  @Override
+  public synchronized void start() {
+    removeStalePluginFiles();
+    Path absolutePath = pluginsDir.toAbsolutePath();
+    if (!Files.exists(absolutePath)) {
+      log.info(absolutePath + " does not exist; creating");
+      try {
+        Files.createDirectories(absolutePath);
+      } catch (IOException e) {
+        log.error(String.format("Failed to create %s: %s", absolutePath, e.getMessage()));
+      }
+    }
+    log.info("Loading plugins from " + absolutePath);
+    srvInfoImpl.state = ServerInformation.State.STARTUP;
+    rescan();
+    srvInfoImpl.state = ServerInformation.State.RUNNING;
+    if (scanner != null) {
+      scanner.start();
+    }
+  }
+
+  @Override
+  public void stop() {
+    if (scanner != null) {
+      scanner.end();
+    }
+    srvInfoImpl.state = ServerInformation.State.SHUTDOWN;
+    synchronized (this) {
+      for (Plugin p : running.values()) {
+        unloadPlugin(p);
+      }
+      running.clear();
+      disabled.clear();
+      broken.clear();
+      if (!toCleanup.isEmpty()) {
+        System.gc();
+        processPendingCleanups();
+      }
+    }
+  }
+
+  public void reload(List<String> names) throws InvalidPluginException, PluginInstallException {
+    synchronized (this) {
+      List<Plugin> reload = Lists.newArrayListWithCapacity(names.size());
+      List<String> bad = Lists.newArrayListWithExpectedSize(4);
+      for (String name : names) {
+        Plugin active = running.get(name);
+        if (active != null) {
+          reload.add(active);
+        } else {
+          bad.add(name);
+        }
+      }
+      if (!bad.isEmpty()) {
+        throw new InvalidPluginException(
+            String.format("Plugin(s) \"%s\" not running", Joiner.on("\", \"").join(bad)));
+      }
+
+      for (Plugin active : reload) {
+        String name = active.getName();
+        try {
+          log.info(String.format("Reloading plugin %s", name));
+          Plugin newPlugin = runPlugin(name, active.getSrcFile(), active);
+          log.info(
+              String.format(
+                  "Reloaded plugin %s, version %s", newPlugin.getName(), newPlugin.getVersion()));
+        } catch (PluginInstallException e) {
+          log.warn(String.format("Cannot reload plugin %s", name), e.getCause());
+          throw e;
+        }
+      }
+
+      cleanInBackground();
+    }
+  }
+
+  public synchronized void rescan() {
+    SetMultimap<String, Path> pluginsFiles = prunePlugins(pluginsDir);
+    if (pluginsFiles.isEmpty()) {
+      return;
+    }
+
+    syncDisabledPlugins(pluginsFiles);
+
+    Map<String, Path> activePlugins = filterDisabled(pluginsFiles);
+    for (Map.Entry<String, Path> entry : jarsFirstSortedPluginsSet(activePlugins)) {
+      String name = entry.getKey();
+      Path path = entry.getValue();
+      String fileName = path.getFileName().toString();
+      if (!isUiPlugin(fileName) && !serverPluginFactory.handles(path)) {
+        log.warn("No Plugin provider was found that handles this file format: {}", fileName);
+        continue;
+      }
+
+      FileSnapshot brokenTime = broken.get(name);
+      if (brokenTime != null && !brokenTime.isModified(path.toFile())) {
+        continue;
+      }
+
+      Plugin active = running.get(name);
+      if (active != null && !active.isModified(path)) {
+        continue;
+      }
+
+      if (active != null) {
+        log.info(String.format("Reloading plugin %s", active.getName()));
+      }
+
+      try {
+        Plugin loadedPlugin = runPlugin(name, path, active);
+        if (!loadedPlugin.isDisabled()) {
+          log.info(
+              String.format(
+                  "%s plugin %s, version %s",
+                  active == null ? "Loaded" : "Reloaded",
+                  loadedPlugin.getName(),
+                  loadedPlugin.getVersion()));
+        }
+      } catch (PluginInstallException e) {
+        log.warn(String.format("Cannot load plugin %s", name), e.getCause());
+      }
+    }
+
+    cleanInBackground();
+  }
+
+  private void addAllEntries(Map<String, Path> from, TreeSet<Entry<String, Path>> to) {
+    Iterator<Entry<String, Path>> it = from.entrySet().iterator();
+    while (it.hasNext()) {
+      Entry<String, Path> entry = it.next();
+      to.add(new AbstractMap.SimpleImmutableEntry<>(entry.getKey(), entry.getValue()));
+    }
+  }
+
+  private TreeSet<Entry<String, Path>> jarsFirstSortedPluginsSet(Map<String, Path> activePlugins) {
+    TreeSet<Entry<String, Path>> sortedPlugins =
+        Sets.newTreeSet(
+            new Comparator<Entry<String, Path>>() {
+              @Override
+              public int compare(Entry<String, Path> e1, Entry<String, Path> e2) {
+                Path n1 = e1.getValue().getFileName();
+                Path n2 = e2.getValue().getFileName();
+                return ComparisonChain.start()
+                    .compareTrueFirst(isJar(n1), isJar(n2))
+                    .compare(n1, n2)
+                    .result();
+              }
+
+              private boolean isJar(Path n1) {
+                return n1.toString().endsWith(".jar");
+              }
+            });
+
+    addAllEntries(activePlugins, sortedPlugins);
+    return sortedPlugins;
+  }
+
+  private void syncDisabledPlugins(SetMultimap<String, Path> jars) {
+    stopRemovedPlugins(jars);
+    dropRemovedDisabledPlugins(jars);
+  }
+
+  private Plugin runPlugin(String name, Path plugin, Plugin oldPlugin)
+      throws PluginInstallException {
+    FileSnapshot snapshot = FileSnapshot.save(plugin.toFile());
+    try {
+      Plugin newPlugin = loadPlugin(name, plugin, snapshot);
+      if (newPlugin.getCleanupHandle() != null) {
+        cleanupHandles.put(newPlugin, newPlugin.getCleanupHandle());
+      }
+      /*
+       * Pluggable plugin provider may have assigned a plugin name that could be
+       * actually different from the initial one assigned during scan. It is
+       * safer then to reassign it.
+       */
+      name = newPlugin.getName();
+      boolean reload = oldPlugin != null && oldPlugin.canReload() && newPlugin.canReload();
+      if (!reload && oldPlugin != null) {
+        unloadPlugin(oldPlugin);
+      }
+      if (!newPlugin.isDisabled()) {
+        newPlugin.start(env);
+      }
+      if (reload) {
+        env.onReloadPlugin(oldPlugin, newPlugin);
+        unloadPlugin(oldPlugin);
+      } else if (!newPlugin.isDisabled()) {
+        env.onStartPlugin(newPlugin);
+      }
+      if (!newPlugin.isDisabled()) {
+        running.put(name, newPlugin);
+      } else {
+        disabled.put(name, newPlugin);
+      }
+      broken.remove(name);
+      return newPlugin;
+    } catch (Throwable err) {
+      broken.put(name, snapshot);
+      throw new PluginInstallException(err);
+    }
+  }
+
+  private void stopRemovedPlugins(SetMultimap<String, Path> jars) {
+    Set<String> unload = Sets.newHashSet(running.keySet());
+    for (Map.Entry<String, Collection<Path>> entry : jars.asMap().entrySet()) {
+      for (Path path : entry.getValue()) {
+        if (!path.getFileName().toString().endsWith(".disabled")) {
+          unload.remove(entry.getKey());
+        }
+      }
+    }
+    for (String name : unload) {
+      unloadPlugin(running.get(name));
+    }
+  }
+
+  private void dropRemovedDisabledPlugins(SetMultimap<String, Path> jars) {
+    Set<String> unload = Sets.newHashSet(disabled.keySet());
+    for (Map.Entry<String, Collection<Path>> entry : jars.asMap().entrySet()) {
+      for (Path path : entry.getValue()) {
+        if (path.getFileName().toString().endsWith(".disabled")) {
+          unload.remove(entry.getKey());
+        }
+      }
+    }
+    for (String name : unload) {
+      disabled.remove(name);
+    }
+  }
+
+  synchronized int processPendingCleanups() {
+    Iterator<Plugin> iterator = toCleanup.iterator();
+    while (iterator.hasNext()) {
+      Plugin plugin = iterator.next();
+      iterator.remove();
+
+      CleanupHandle cleanupHandle = cleanupHandles.remove(plugin);
+      if (cleanupHandle != null) {
+        cleanupHandle.cleanup();
+      }
+    }
+    return toCleanup.size();
+  }
+
+  private void cleanInBackground() {
+    int cnt = toCleanup.size();
+    if (0 < cnt) {
+      cleaner.get().clean(cnt);
+    }
+  }
+
+  private String getExtension(String name) {
+    int ext = name.lastIndexOf('.');
+    return 0 < ext ? name.substring(ext) : "";
+  }
+
+  private Plugin loadPlugin(String name, Path srcPlugin, FileSnapshot snapshot)
+      throws InvalidPluginException {
+    String pluginName = srcPlugin.getFileName().toString();
+    if (isUiPlugin(pluginName)) {
+      return loadJsPlugin(name, srcPlugin, snapshot);
+    } else if (serverPluginFactory.handles(srcPlugin)) {
+      return loadServerPlugin(srcPlugin, snapshot);
+    } else {
+      throw new InvalidPluginException(
+          String.format("Unsupported plugin type: %s", srcPlugin.getFileName()));
+    }
+  }
+
+  private Path getPluginDataDir(String name) {
+    return dataDir.resolve(name);
+  }
+
+  private String getPluginCanonicalWebUrl(String name) {
+    String canonicalWebUrl = urlProvider.get();
+    if (Strings.isNullOrEmpty(canonicalWebUrl)) {
+      return "/plugins/" + name;
+    }
+
+    String url =
+        String.format(
+            "%s/plugins/%s/", CharMatcher.is('/').trimTrailingFrom(canonicalWebUrl), name);
+    return url;
+  }
+
+  private Plugin loadJsPlugin(String name, Path srcJar, FileSnapshot snapshot) {
+    return new JsPlugin(name, srcJar, pluginUserFactory.create(name), snapshot);
+  }
+
+  private ServerPlugin loadServerPlugin(Path scriptFile, FileSnapshot snapshot)
+      throws InvalidPluginException {
+    String name = serverPluginFactory.getPluginName(scriptFile);
+    return serverPluginFactory.get(
+        scriptFile,
+        snapshot,
+        new PluginDescription(
+            pluginUserFactory.create(name),
+            getPluginCanonicalWebUrl(name),
+            getPluginDataDir(name)));
+  }
+
+  // Only one active plugin per plugin name can exist for each plugin name.
+  // Filter out disabled plugins and transform the multimap to a map
+  private Map<String, Path> filterDisabled(SetMultimap<String, Path> pluginPaths) {
+    Map<String, Path> activePlugins = Maps.newHashMapWithExpectedSize(pluginPaths.keys().size());
+    for (String name : pluginPaths.keys()) {
+      for (Path pluginPath : pluginPaths.asMap().get(name)) {
+        if (!pluginPath.getFileName().toString().endsWith(".disabled")) {
+          assert !activePlugins.containsKey(name);
+          activePlugins.put(name, pluginPath);
+        }
+      }
+    }
+    return activePlugins;
+  }
+
+  // Scan the $site_path/plugins directory and fetch all files and directories.
+  // The Key in returned multimap is the plugin name initially assigned from its filename.
+  // Values are the files. Plugins can optionally provide their name in MANIFEST file.
+  // If multiple plugin files provide the same plugin name, then only
+  // the first plugin remains active and all other plugins with the same
+  // name are disabled.
+  //
+  // NOTE: Bear in mind that the plugin name can be reassigned after load by the
+  //       Server plugin provider.
+  public SetMultimap<String, Path> prunePlugins(Path pluginsDir) {
+    List<Path> pluginPaths = scanPathsInPluginsDirectory(pluginsDir);
+    SetMultimap<String, Path> map;
+    map = asMultimap(pluginPaths);
+    for (String plugin : map.keySet()) {
+      Collection<Path> files = map.asMap().get(plugin);
+      if (files.size() == 1) {
+        continue;
+      }
+      // retrieve enabled plugins
+      Iterable<Path> enabled = filterDisabledPlugins(files);
+      // If we have only one (the winner) plugin, nothing to do
+      if (!Iterables.skip(enabled, 1).iterator().hasNext()) {
+        continue;
+      }
+      Path winner = Iterables.getFirst(enabled, null);
+      assert winner != null;
+      // Disable all loser plugins by renaming their file names to
+      // "file.disabled" and replace the disabled files in the multimap.
+      Collection<Path> elementsToRemove = new ArrayList<>();
+      Collection<Path> elementsToAdd = new ArrayList<>();
+      for (Path loser : Iterables.skip(enabled, 1)) {
+        log.warn(
+            String.format(
+                "Plugin <%s> was disabled, because"
+                    + " another plugin <%s>"
+                    + " with the same name <%s> already exists",
+                loser, winner, plugin));
+        Path disabledPlugin = Paths.get(loser + ".disabled");
+        elementsToAdd.add(disabledPlugin);
+        elementsToRemove.add(loser);
+        try {
+          Files.move(loser, disabledPlugin);
+        } catch (IOException e) {
+          log.warn("Failed to fully disable plugin " + loser, e);
+        }
+      }
+      Iterables.removeAll(files, elementsToRemove);
+      Iterables.addAll(files, elementsToAdd);
+    }
+    return map;
+  }
+
+  private List<Path> scanPathsInPluginsDirectory(Path pluginsDir) {
+    try {
+      return PluginUtil.listPlugins(pluginsDir);
+    } catch (IOException e) {
+      log.error("Cannot list " + pluginsDir.toAbsolutePath(), e);
+      return ImmutableList.of();
+    }
+  }
+
+  private Iterable<Path> filterDisabledPlugins(Collection<Path> paths) {
+    return Iterables.filter(paths, p -> !p.getFileName().toString().endsWith(".disabled"));
+  }
+
+  public String getGerritPluginName(Path srcPath) {
+    String fileName = srcPath.getFileName().toString();
+    if (isUiPlugin(fileName)) {
+      return fileName.substring(0, fileName.lastIndexOf('.'));
+    }
+    if (serverPluginFactory.handles(srcPath)) {
+      return serverPluginFactory.getPluginName(srcPath);
+    }
+    return null;
+  }
+
+  private SetMultimap<String, Path> asMultimap(List<Path> plugins) {
+    SetMultimap<String, Path> map = LinkedHashMultimap.create();
+    for (Path srcPath : plugins) {
+      map.put(getPluginName(srcPath), srcPath);
+    }
+    return map;
+  }
+
+  private boolean isUiPlugin(String name) {
+    return isPlugin(name, "js") || isPlugin(name, "html");
+  }
+
+  private boolean isPlugin(String fileName, String ext) {
+    String fullExt = "." + ext;
+    return fileName.endsWith(fullExt) || fileName.endsWith(fullExt + ".disabled");
+  }
+
+  private void checkRemoteInstall() throws PluginInstallException {
+    if (!isRemoteAdminEnabled()) {
+      throw new PluginInstallException("remote installation is disabled");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginMetricMaker.java b/java/com/google/gerrit/server/plugins/PluginMetricMaker.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginMetricMaker.java
rename to java/com/google/gerrit/server/plugins/PluginMetricMaker.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginModule.java b/java/com/google/gerrit/server/plugins/PluginModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginModule.java
rename to java/com/google/gerrit/server/plugins/PluginModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginResource.java b/java/com/google/gerrit/server/plugins/PluginResource.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginResource.java
rename to java/com/google/gerrit/server/plugins/PluginResource.java
diff --git a/java/com/google/gerrit/server/plugins/PluginRestApiModule.java b/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
new file mode 100644
index 0000000..8e162ba
--- /dev/null
+++ b/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+import static com.google.gerrit.server.plugins.PluginResource.PLUGIN_KIND;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+
+public class PluginRestApiModule extends RestApiModule {
+  @Override
+  protected void configure() {
+    bind(PluginsCollection.class);
+    DynamicMap.mapOf(binder(), PLUGIN_KIND);
+    put(PLUGIN_KIND).to(InstallPlugin.Overwrite.class);
+    delete(PLUGIN_KIND).to(DisablePlugin.class);
+    get(PLUGIN_KIND, "status").to(GetStatus.class);
+    post(PLUGIN_KIND, "disable").to(DisablePlugin.class);
+    post(PLUGIN_KIND, "enable").to(EnablePlugin.class);
+    post(PLUGIN_KIND, "reload").to(ReloadPlugin.class);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginScannerThread.java b/java/com/google/gerrit/server/plugins/PluginScannerThread.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginScannerThread.java
rename to java/com/google/gerrit/server/plugins/PluginScannerThread.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginUtil.java b/java/com/google/gerrit/server/plugins/PluginUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginUtil.java
rename to java/com/google/gerrit/server/plugins/PluginUtil.java
diff --git a/java/com/google/gerrit/server/plugins/PluginsCollection.java b/java/com/google/gerrit/server/plugins/PluginsCollection.java
new file mode 100644
index 0000000..9dbc956
--- /dev/null
+++ b/java/com/google/gerrit/server/plugins/PluginsCollection.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+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.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;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class PluginsCollection
+    implements RestCollection<TopLevelResource, PluginResource>, AcceptsCreate<TopLevelResource> {
+
+  private final DynamicMap<RestView<PluginResource>> views;
+  private final PluginLoader loader;
+  private final Provider<ListPlugins> list;
+  private final Provider<InstallPlugin> install;
+
+  @Inject
+  PluginsCollection(
+      DynamicMap<RestView<PluginResource>> views,
+      PluginLoader loader,
+      Provider<ListPlugins> list,
+      Provider<InstallPlugin> install) {
+    this.views = views;
+    this.loader = loader;
+    this.list = list;
+    this.install = install;
+  }
+
+  @Override
+  public RestView<TopLevelResource> list() throws ResourceNotFoundException {
+    return list.get();
+  }
+
+  @Override
+  public PluginResource parse(TopLevelResource parent, IdString id)
+      throws ResourceNotFoundException {
+    return parse(id.get());
+  }
+
+  public PluginResource parse(String id) throws ResourceNotFoundException {
+    Plugin p = loader.get(id);
+    if (p == null) {
+      throw new ResourceNotFoundException(id);
+    }
+    return new PluginResource(p);
+  }
+
+  @Override
+  public InstallPlugin create(TopLevelResource parent, IdString id) throws RestApiException {
+    loader.checkRemoteAdminEnabled();
+    return install.get().setName(id.get()).setCreated(true);
+  }
+
+  @Override
+  public DynamicMap<RestView<PluginResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/plugins/ReloadPlugin.java b/java/com/google/gerrit/server/plugins/ReloadPlugin.java
new file mode 100644
index 0000000..1134f50
--- /dev/null
+++ b/java/com/google/gerrit/server/plugins/ReloadPlugin.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+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.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class ReloadPlugin implements RestModifyView<PluginResource, Input> {
+
+  private final PluginLoader loader;
+
+  @Inject
+  ReloadPlugin(PluginLoader loader) {
+    this.loader = loader;
+  }
+
+  @Override
+  public PluginInfo apply(PluginResource resource, Input input) throws ResourceConflictException {
+    String name = resource.getName();
+    try {
+      loader.reload(ImmutableList.of(name));
+    } catch (InvalidPluginException e) {
+      throw new ResourceConflictException(e.getMessage());
+    } catch (PluginInstallException e) {
+      StringWriter buf = new StringWriter();
+      buf.write(String.format("cannot reload %s\n", name));
+      PrintWriter pw = new PrintWriter(buf);
+      e.printStackTrace(pw);
+      pw.flush();
+      throw new ResourceConflictException(buf.toString());
+    }
+    return ListPlugins.toPluginInfo(loader.get(name));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPluginListener.java b/java/com/google/gerrit/server/plugins/ReloadPluginListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPluginListener.java
rename to java/com/google/gerrit/server/plugins/ReloadPluginListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerInformationImpl.java b/java/com/google/gerrit/server/plugins/ServerInformationImpl.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerInformationImpl.java
rename to java/com/google/gerrit/server/plugins/ServerInformationImpl.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java b/java/com/google/gerrit/server/plugins/ServerPlugin.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
rename to java/com/google/gerrit/server/plugins/ServerPlugin.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
rename to java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginProvider.java b/java/com/google/gerrit/server/plugins/ServerPluginProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginProvider.java
rename to java/com/google/gerrit/server/plugins/ServerPluginProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StartPluginListener.java b/java/com/google/gerrit/server/plugins/StartPluginListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/StartPluginListener.java
rename to java/com/google/gerrit/server/plugins/StartPluginListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StopPluginListener.java b/java/com/google/gerrit/server/plugins/StopPluginListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/StopPluginListener.java
rename to java/com/google/gerrit/server/plugins/StopPluginListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/TestServerPlugin.java b/java/com/google/gerrit/server/plugins/TestServerPlugin.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/TestServerPlugin.java
rename to java/com/google/gerrit/server/plugins/TestServerPlugin.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java b/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
rename to java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java b/java/com/google/gerrit/server/project/AccessControlModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
rename to java/com/google/gerrit/server/project/AccessControlModule.java
diff --git a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
new file mode 100644
index 0000000..3b75256
--- /dev/null
+++ b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
@@ -0,0 +1,118 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Sets;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo.InheritedBooleanInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import java.util.Arrays;
+import java.util.HashSet;
+
+/** Provides transformations to get and set BooleanProjectConfigs from the API. */
+public class BooleanProjectConfigTransformations {
+
+  private static ImmutableMap<BooleanProjectConfig, Mapper> MAPPER =
+      ImmutableMap.<BooleanProjectConfig, Mapper>builder()
+          .put(
+              BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS,
+              new Mapper(i -> i.useContributorAgreements, (i, v) -> i.useContributorAgreements = v))
+          .put(
+              BooleanProjectConfig.USE_SIGNED_OFF_BY,
+              new Mapper(i -> i.useSignedOffBy, (i, v) -> i.useSignedOffBy = v))
+          .put(
+              BooleanProjectConfig.USE_CONTENT_MERGE,
+              new Mapper(i -> i.useContentMerge, (i, v) -> i.useContentMerge = v))
+          .put(
+              BooleanProjectConfig.REQUIRE_CHANGE_ID,
+              new Mapper(i -> i.requireChangeId, (i, v) -> i.requireChangeId = v))
+          .put(
+              BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+              new Mapper(
+                  i -> i.createNewChangeForAllNotInTarget,
+                  (i, v) -> i.createNewChangeForAllNotInTarget = v))
+          .put(
+              BooleanProjectConfig.ENABLE_SIGNED_PUSH,
+              new Mapper(i -> i.enableSignedPush, (i, v) -> i.enableSignedPush = v))
+          .put(
+              BooleanProjectConfig.REQUIRE_SIGNED_PUSH,
+              new Mapper(i -> i.requireSignedPush, (i, v) -> i.requireSignedPush = v))
+          .put(
+              BooleanProjectConfig.REJECT_IMPLICIT_MERGES,
+              new Mapper(i -> i.rejectImplicitMerges, (i, v) -> i.rejectImplicitMerges = v))
+          .put(
+              BooleanProjectConfig.PRIVATE_BY_DEFAULT,
+              new Mapper(i -> i.privateByDefault, (i, v) -> i.privateByDefault = v))
+          .put(
+              BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL,
+              new Mapper(i -> i.enableReviewerByEmail, (i, v) -> i.enableReviewerByEmail = v))
+          .put(
+              BooleanProjectConfig.MATCH_AUTHOR_TO_COMMITTER_DATE,
+              new Mapper(
+                  i -> i.matchAuthorToCommitterDate, (i, v) -> i.matchAuthorToCommitterDate = v))
+          .put(
+              BooleanProjectConfig.REJECT_EMPTY_COMMIT,
+              new Mapper(i -> i.rejectEmptyCommit, (i, v) -> i.rejectEmptyCommit = v))
+          .build();
+
+  static {
+    // Verify that each BooleanProjectConfig has to/from API mappers in BooleanProjectConfigTransformations
+    if (!Sets.symmetricDifference(
+            MAPPER.keySet(), new HashSet<>(Arrays.asList(BooleanProjectConfig.values())))
+        .isEmpty()) {
+      throw new IllegalStateException(
+          "All values of BooleanProjectConfig must have transformations associated with them");
+    }
+  }
+
+  @FunctionalInterface
+  private interface ToApi {
+    void apply(ConfigInfo info, InheritedBooleanInfo val);
+  }
+
+  @FunctionalInterface
+  private interface FromApi {
+    InheritableBoolean apply(ConfigInput input);
+  }
+
+  public static void set(BooleanProjectConfig cfg, ConfigInfo info, InheritedBooleanInfo val) {
+    MAPPER.get(cfg).set(info, val);
+  }
+
+  public static InheritableBoolean get(BooleanProjectConfig cfg, ConfigInput input) {
+    return MAPPER.get(cfg).get(input);
+  }
+
+  private static class Mapper {
+    private final FromApi fromApi;
+    private final ToApi toApi;
+
+    private Mapper(FromApi fromApi, ToApi toApi) {
+      this.fromApi = fromApi;
+      this.toApi = toApi;
+    }
+
+    public void set(ConfigInfo info, InheritedBooleanInfo val) {
+      toApi.apply(info, val);
+    }
+
+    public InheritableBoolean get(ConfigInput input) {
+      return fromApi.apply(input);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/project/BranchResource.java b/java/com/google/gerrit/server/project/BranchResource.java
new file mode 100644
index 0000000..622b1dd
--- /dev/null
+++ b/java/com/google/gerrit/server/project/BranchResource.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+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;
+
+public class BranchResource extends RefResource {
+  public static final TypeLiteral<RestView<BranchResource>> BRANCH_KIND =
+      new TypeLiteral<RestView<BranchResource>>() {};
+
+  private final String refName;
+  private final String revision;
+
+  public BranchResource(ProjectState projectState, CurrentUser user, Ref ref) {
+    super(projectState, user);
+    this.refName = ref.getName();
+    this.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
+  }
+
+  public Branch.NameKey getBranchKey() {
+    return new Branch.NameKey(getNameKey(), refName);
+  }
+
+  @Override
+  public String getRef() {
+    return refName;
+  }
+
+  @Override
+  public String getRevision() {
+    return revision;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ChangeControl.java b/java/com/google/gerrit/server/project/ChangeControl.java
new file mode 100644
index 0000000..9bc59e2
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ChangeControl.java
@@ -0,0 +1,423 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
+
+import com.google.common.collect.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.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.ChangePermissionOrLabel;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.Set;
+
+/** Access control management for a user accessing a single change. */
+class ChangeControl {
+  @Singleton
+  static class Factory {
+    private final ChangeData.Factory changeDataFactory;
+    private final ChangeNotes.Factory notesFactory;
+    private final ApprovalsUtil approvalsUtil;
+    private final PatchSetUtil patchSetUtil;
+
+    @Inject
+    Factory(
+        ChangeData.Factory changeDataFactory,
+        ChangeNotes.Factory notesFactory,
+        ApprovalsUtil approvalsUtil,
+        PatchSetUtil patchSetUtil) {
+      this.changeDataFactory = changeDataFactory;
+      this.notesFactory = notesFactory;
+      this.approvalsUtil = approvalsUtil;
+      this.patchSetUtil = patchSetUtil;
+    }
+
+    ChangeControl create(
+        RefControl refControl, ReviewDb db, Project.NameKey project, Change.Id changeId)
+        throws OrmException {
+      return create(refControl, notesFactory.create(db, project, changeId));
+    }
+
+    ChangeControl create(RefControl refControl, ChangeNotes notes) {
+      return new ChangeControl(changeDataFactory, approvalsUtil, refControl, notes, patchSetUtil);
+    }
+  }
+
+  private final ChangeData.Factory changeDataFactory;
+  private final ApprovalsUtil approvalsUtil;
+  private final RefControl refControl;
+  private final ChangeNotes notes;
+  private final PatchSetUtil patchSetUtil;
+
+  private ChangeControl(
+      ChangeData.Factory changeDataFactory,
+      ApprovalsUtil approvalsUtil,
+      RefControl refControl,
+      ChangeNotes notes,
+      PatchSetUtil patchSetUtil) {
+    this.changeDataFactory = changeDataFactory;
+    this.approvalsUtil = approvalsUtil;
+    this.refControl = refControl;
+    this.notes = notes;
+    this.patchSetUtil = patchSetUtil;
+  }
+
+  ForChange asForChange(@Nullable ChangeData cd, @Nullable Provider<ReviewDb> db) {
+    return new ForChangeImpl(cd, db);
+  }
+
+  private ChangeControl forUser(CurrentUser who) {
+    if (getUser().equals(who)) {
+      return this;
+    }
+    return new ChangeControl(
+        changeDataFactory, approvalsUtil, refControl.forUser(who), notes, patchSetUtil);
+  }
+
+  private CurrentUser getUser() {
+    return refControl.getUser();
+  }
+
+  private ProjectControl getProjectControl() {
+    return refControl.getProjectControl();
+  }
+
+  private Change getChange() {
+    return notes.getChange();
+  }
+
+  /** Can this user see this change? */
+  private boolean isVisible(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
+    if (getChange().isPrivate() && !isPrivateVisible(db, cd)) {
+      return false;
+    }
+    return refControl.isVisible();
+  }
+
+  /** Can this user abandon this change? */
+  private boolean canAbandon(ReviewDb db) throws OrmException {
+    return (isOwner() // owner (aka creator) of the change can abandon
+            || refControl.isOwner() // branch owner can abandon
+            || getProjectControl().isOwner() // project owner can abandon
+            || refControl.canAbandon() // user can abandon a specific ref
+            || getProjectControl().isAdmin())
+        && !isPatchSetLocked(db);
+  }
+
+  /** Can this user delete this change? */
+  private boolean canDelete(Change.Status status) {
+    switch (status) {
+      case NEW:
+      case ABANDONED:
+        return (isOwner() && refControl.canDeleteOwnChanges()) || getProjectControl().isAdmin();
+      case MERGED:
+      default:
+        return false;
+    }
+  }
+
+  /** Can this user rebase this change? */
+  private boolean canRebase(ReviewDb db) throws OrmException {
+    return (isOwner() || refControl.canSubmit(isOwner()) || refControl.canRebase())
+        && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE)
+        && !isPatchSetLocked(db);
+  }
+
+  /** Can this user restore this change? */
+  private boolean canRestore(ReviewDb db) throws OrmException {
+    // Anyone who can abandon the change can restore it, as long as they can create changes.
+    return canAbandon(db) && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
+  }
+
+  /** The range of permitted values associated with a label permission. */
+  private PermissionRange getRange(String permission) {
+    return refControl.getRange(permission, isOwner());
+  }
+
+  /** Can this user add a patch set to this change? */
+  private boolean canAddPatchSet(ReviewDb db) throws OrmException {
+    if (!refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE) || isPatchSetLocked(db)) {
+      return false;
+    }
+    if (isOwner()) {
+      return true;
+    }
+    return refControl.canAddPatchSet();
+  }
+
+  /** Is the current patch set locked against state changes? */
+  private boolean isPatchSetLocked(ReviewDb db) throws OrmException {
+    if (getChange().getStatus() == Change.Status.MERGED) {
+      return false;
+    }
+
+    for (PatchSetApproval ap :
+        approvalsUtil.byPatchSet(
+            db, notes, getUser(), getChange().currentPatchSetId(), null, null)) {
+      LabelType type =
+          getProjectControl()
+              .getProjectState()
+              .getLabelTypes(notes, getUser())
+              .byLabel(ap.getLabel());
+      if (type != null
+          && ap.getValue() == 1
+          && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Is this user the owner of the change? */
+  private boolean isOwner() {
+    if (getUser().isIdentifiedUser()) {
+      Account.Id id = getUser().asIdentifiedUser().getAccountId();
+      return id.equals(getChange().getOwner());
+    }
+    return false;
+  }
+
+  /** Is this user assigned to this change? */
+  private boolean isAssignee() {
+    Account.Id currentAssignee = notes.getChange().getAssignee();
+    if (currentAssignee != null && getUser().isIdentifiedUser()) {
+      Account.Id id = getUser().getAccountId();
+      return id.equals(currentAssignee);
+    }
+    return false;
+  }
+
+  /** Is this user a reviewer for the change? */
+  private boolean isReviewer(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
+    if (getUser().isIdentifiedUser()) {
+      cd = cd != null ? cd : changeDataFactory.create(db, notes);
+      Collection<Account.Id> results = cd.reviewers().all();
+      return results.contains(getUser().getAccountId());
+    }
+    return false;
+  }
+
+  /** Can this user edit the topic name? */
+  private boolean canEditTopicName() {
+    if (getChange().getStatus().isOpen()) {
+      return isOwner() // owner (aka creator) of the change can edit topic
+          || refControl.isOwner() // branch owner can edit topic
+          || getProjectControl().isOwner() // project owner can edit topic
+          || refControl.canEditTopicName() // user can edit topic on a specific ref
+          || getProjectControl().isAdmin();
+    }
+    return refControl.canForceEditTopicName();
+  }
+
+  /** Can this user edit the description? */
+  private boolean canEditDescription() {
+    if (getChange().getStatus().isOpen()) {
+      return isOwner() // owner (aka creator) of the change can edit desc
+          || refControl.isOwner() // branch owner can edit desc
+          || getProjectControl().isOwner() // project owner can edit desc
+          || getProjectControl().isAdmin();
+    }
+    return false;
+  }
+
+  private boolean canEditAssignee() {
+    return isOwner()
+        || getProjectControl().isOwner()
+        || refControl.canEditAssignee()
+        || isAssignee();
+  }
+
+  /** Can this user edit the hashtag name? */
+  private boolean canEditHashtags() {
+    return isOwner() // owner (aka creator) of the change can edit hashtags
+        || refControl.isOwner() // branch owner can edit hashtags
+        || getProjectControl().isOwner() // project owner can edit hashtags
+        || refControl.canEditHashtags() // user can edit hashtag on a specific ref
+        || getProjectControl().isAdmin();
+  }
+
+  private boolean isPrivateVisible(ReviewDb db, ChangeData cd) throws OrmException {
+    return isOwner()
+        || isReviewer(db, cd)
+        || refControl.canViewPrivateChanges()
+        || getUser().isInternalUser();
+  }
+
+  private class ForChangeImpl extends ForChange {
+    private ChangeData cd;
+    private Map<String, PermissionRange> labels;
+
+    ForChangeImpl(@Nullable ChangeData cd, @Nullable Provider<ReviewDb> db) {
+      this.cd = cd;
+      this.db = db;
+    }
+
+    private ReviewDb db() {
+      if (db != null) {
+        return db.get();
+      } else if (cd != null) {
+        return cd.db();
+      } else {
+        return null;
+      }
+    }
+
+    private ChangeData changeData() {
+      if (cd == null) {
+        ReviewDb reviewDb = db();
+        checkState(reviewDb != null, "need ReviewDb");
+        cd = changeDataFactory.create(reviewDb, notes);
+      }
+      return cd;
+    }
+
+    @Override
+    public CurrentUser user() {
+      return getUser();
+    }
+
+    @Override
+    public ForChange user(CurrentUser user) {
+      return user().equals(user) ? this : forUser(user).asForChange(cd, db);
+    }
+
+    @Override
+    public void check(ChangePermissionOrLabel perm)
+        throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted");
+      }
+    }
+
+    @Override
+    public <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException {
+      Set<T> ok = newSet(permSet);
+      for (T perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
+      if (perm instanceof ChangePermission) {
+        return can((ChangePermission) perm);
+      } else if (perm instanceof LabelPermission) {
+        return can((LabelPermission) perm);
+      } else if (perm instanceof LabelPermission.WithValue) {
+        return can((LabelPermission.WithValue) perm);
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+
+    private boolean can(ChangePermission perm) throws PermissionBackendException {
+      try {
+        switch (perm) {
+          case READ:
+            return isVisible(db(), changeData());
+          case ABANDON:
+            return canAbandon(db());
+          case DELETE:
+            return canDelete(getChange().getStatus());
+          case ADD_PATCH_SET:
+            return canAddPatchSet(db());
+          case EDIT_ASSIGNEE:
+            return canEditAssignee();
+          case EDIT_DESCRIPTION:
+            return canEditDescription();
+          case EDIT_HASHTAGS:
+            return canEditHashtags();
+          case EDIT_TOPIC_NAME:
+            return canEditTopicName();
+          case REBASE:
+            return canRebase(db());
+          case RESTORE:
+            return canRestore(db());
+          case SUBMIT:
+            return refControl.canSubmit(isOwner());
+
+          case REMOVE_REVIEWER:
+          case SUBMIT_AS:
+            return refControl.canPerform(perm.permissionName().get());
+        }
+      } catch (OrmException e) {
+        throw new PermissionBackendException("unavailable", e);
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+
+    private boolean can(LabelPermission perm) {
+      return !label(perm.permissionName().get()).isEmpty();
+    }
+
+    private boolean can(LabelPermission.WithValue perm) {
+      PermissionRange r = label(perm.permissionName().get());
+      if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
+        return false;
+      }
+      return r.contains(perm.value());
+    }
+
+    private PermissionRange label(String permission) {
+      if (labels == null) {
+        labels = Maps.newHashMapWithExpectedSize(4);
+      }
+      PermissionRange r = labels.get(permission);
+      if (r == null) {
+        r = getRange(permission);
+        labels.put(permission, r);
+      }
+      return r;
+    }
+  }
+
+  private static <T extends ChangePermissionOrLabel> Set<T> newSet(Collection<T> permSet) {
+    if (permSet instanceof EnumSet) {
+      @SuppressWarnings({"unchecked", "rawtypes"})
+      Set<T> s = ((EnumSet) permSet).clone();
+      s.clear();
+      return s;
+    }
+    return Sets.newHashSetWithExpectedSize(permSet.size());
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ChildProjectResource.java b/java/com/google/gerrit/server/project/ChildProjectResource.java
new file mode 100644
index 0000000..4b641ca
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ChildProjectResource.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class ChildProjectResource implements RestResource {
+  public static final TypeLiteral<RestView<ChildProjectResource>> CHILD_PROJECT_KIND =
+      new TypeLiteral<RestView<ChildProjectResource>>() {};
+
+  private final ProjectResource parent;
+  private final ProjectState child;
+
+  public ChildProjectResource(ProjectResource parent, ProjectState child) {
+    this.parent = parent;
+    this.child = child;
+  }
+
+  public ProjectResource getParent() {
+    return parent;
+  }
+
+  public ProjectState getChild() {
+    return child;
+  }
+
+  public boolean isDirectChild() {
+    ProjectState firstParent = Iterables.getFirst(child.parents(), null);
+    return firstParent != null && parent.getNameKey().equals(firstParent.getNameKey());
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ChildProjects.java b/java/com/google/gerrit/server/project/ChildProjects.java
new file mode 100644
index 0000000..0b174f6
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ChildProjects.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.project;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Retrieve child projects (ie. projects whose access inherits from a given parent.) */
+@Singleton
+public class ChildProjects {
+  private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final AllProjectsName allProjects;
+  private final ProjectJson json;
+
+  @Inject
+  ChildProjects(
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      AllProjectsName allProjectsName,
+      ProjectJson json) {
+    this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.allProjects = allProjectsName;
+    this.json = json;
+  }
+
+  /** Gets all child projects recursively. */
+  public List<ProjectInfo> list(Project.NameKey parent) throws PermissionBackendException {
+    Map<Project.NameKey, Project> projects = readAllProjects();
+    Multimap<Project.NameKey, Project.NameKey> children = parentToChildren(projects);
+    PermissionBackend.WithUser perm = permissionBackend.user(user);
+
+    List<ProjectInfo> results = new ArrayList<>();
+    depthFirstFormat(results, perm, projects, children, parent);
+    return results;
+  }
+
+  private Map<Project.NameKey, Project> readAllProjects() {
+    Map<Project.NameKey, Project> projects = new HashMap<>();
+    for (Project.NameKey name : projectCache.all()) {
+      ProjectState c = projectCache.get(name);
+      if (c != null) {
+        projects.put(c.getNameKey(), c.getProject());
+      }
+    }
+    return projects;
+  }
+
+  /** Map of parent project to direct child. */
+  private Multimap<Project.NameKey, Project.NameKey> parentToChildren(
+      Map<Project.NameKey, Project> projects) {
+    Multimap<Project.NameKey, Project.NameKey> m = ArrayListMultimap.create();
+    for (Map.Entry<Project.NameKey, Project> e : projects.entrySet()) {
+      if (!allProjects.equals(e.getKey())) {
+        m.put(e.getValue().getParent(allProjects), e.getKey());
+      }
+    }
+    return m;
+  }
+
+  private void depthFirstFormat(
+      List<ProjectInfo> results,
+      PermissionBackend.WithUser perm,
+      Map<Project.NameKey, Project> projects,
+      Multimap<Project.NameKey, Project.NameKey> children,
+      Project.NameKey parent)
+      throws PermissionBackendException {
+    List<Project.NameKey> canSee =
+        perm.filter(ProjectPermission.ACCESS, children.get(parent))
+            .stream()
+            .sorted()
+            .collect(toList());
+    children.removeAll(parent); // removing all entries prevents cycles.
+
+    for (Project.NameKey c : canSee) {
+      results.add(json.format(projects.get(c)));
+      depthFirstFormat(results, perm, projects, children, c);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java b/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
rename to java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkProvider.java b/java/com/google/gerrit/server/project/CommentLinkProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkProvider.java
rename to java/com/google/gerrit/server/project/CommentLinkProvider.java
diff --git a/java/com/google/gerrit/server/project/CommitResource.java b/java/com/google/gerrit/server/project/CommitResource.java
new file mode 100644
index 0000000..f71c7fe
--- /dev/null
+++ b/java/com/google/gerrit/server/project/CommitResource.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+public class CommitResource implements RestResource {
+  public static final TypeLiteral<RestView<CommitResource>> COMMIT_KIND =
+      new TypeLiteral<RestView<CommitResource>>() {};
+
+  private final ProjectResource project;
+  private final RevCommit commit;
+
+  public CommitResource(ProjectResource project, RevCommit commit) {
+    this.project = project;
+    this.commit = commit;
+  }
+
+  public ProjectState getProjectState() {
+    return project.getProjectState();
+  }
+
+  public RevCommit getCommit() {
+    return commit;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
new file mode 100644
index 0000000..2f96bd5
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.project.ProjectControl.Metrics;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+@Singleton
+public class ContributorAgreementsChecker {
+
+  private final String canonicalWebUrl;
+  private final ProjectCache projectCache;
+  private final Metrics metrics;
+
+  @Inject
+  ContributorAgreementsChecker(
+      @CanonicalWebUrl @Nullable String canonicalWebUrl,
+      ProjectCache projectCache,
+      Metrics metrics) {
+    this.canonicalWebUrl = canonicalWebUrl;
+    this.projectCache = projectCache;
+    this.metrics = metrics;
+  }
+
+  /**
+   * Checks if the user has signed a contributor agreement for the project.
+   *
+   * @throws AuthException if the user has not signed a contributor agreement for the project
+   * @throws IOException if project states could not be loaded
+   */
+  public void check(Project.NameKey project, CurrentUser user) throws IOException, AuthException {
+    metrics.claCheckCount.increment();
+
+    ProjectState projectState = projectCache.checkedGet(project);
+    if (projectState == null) {
+      throw new IOException("Can't load All-Projects");
+    }
+
+    if (!projectState.is(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS)) {
+      return;
+    }
+
+    if (!user.isIdentifiedUser()) {
+      throw new AuthException("Must be logged in to verify Contributor Agreement");
+    }
+
+    IdentifiedUser iUser = user.asIdentifiedUser();
+    Collection<ContributorAgreement> contributorAgreements =
+        projectCache.getAllProjects().getConfig().getContributorAgreements();
+    List<UUID> okGroupIds = new ArrayList<>();
+    for (ContributorAgreement ca : contributorAgreements) {
+      List<AccountGroup.UUID> groupIds;
+      groupIds = okGroupIds;
+
+      for (PermissionRule rule : ca.getAccepted()) {
+        if ((rule.getAction() == Action.ALLOW)
+            && (rule.getGroup() != null)
+            && (rule.getGroup().getUUID() != null)) {
+          groupIds.add(new AccountGroup.UUID(rule.getGroup().getUUID().get()));
+        }
+      }
+    }
+
+    if (!iUser.getEffectiveGroups().containsAnyOf(okGroupIds)) {
+      final StringBuilder msg = new StringBuilder();
+      msg.append("A Contributor Agreement must be completed before uploading");
+      if (canonicalWebUrl != null) {
+        msg.append(":\n\n  ");
+        msg.append(canonicalWebUrl);
+        msg.append("#");
+        msg.append(PageLinks.SETTINGS_AGREEMENTS);
+        msg.append("\n");
+      } else {
+        msg.append(".");
+      }
+      throw new AuthException(msg.toString());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/project/CreateProjectArgs.java b/java/com/google/gerrit/server/project/CreateProjectArgs.java
new file mode 100644
index 0000000..e4623b2
--- /dev/null
+++ b/java/com/google/gerrit/server/project/CreateProjectArgs.java
@@ -0,0 +1,65 @@
+// 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.project;
+
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import java.util.List;
+
+public class CreateProjectArgs {
+
+  private Project.NameKey projectName;
+  public List<AccountGroup.UUID> ownerIds;
+  public Project.NameKey newParent;
+  public String projectDescription;
+  public SubmitType submitType;
+  public InheritableBoolean contributorAgreements;
+  public InheritableBoolean signedOffBy;
+  public boolean permissionsOnly;
+  public List<String> branch;
+  public InheritableBoolean contentMerge;
+  public InheritableBoolean newChangeForAllNotInTarget;
+  public InheritableBoolean changeIdRequired;
+  public InheritableBoolean rejectEmptyCommit;
+  public boolean createEmptyCommit;
+  public String maxObjectSizeLimit;
+
+  public CreateProjectArgs() {
+    contributorAgreements = InheritableBoolean.INHERIT;
+    signedOffBy = InheritableBoolean.INHERIT;
+    contentMerge = InheritableBoolean.INHERIT;
+    changeIdRequired = InheritableBoolean.INHERIT;
+    newChangeForAllNotInTarget = InheritableBoolean.INHERIT;
+    submitType = SubmitType.MERGE_IF_NECESSARY;
+  }
+
+  public Project.NameKey getProject() {
+    return projectName;
+  }
+
+  public String getProjectName() {
+    return projectName != null ? projectName.get() : null;
+  }
+
+  public void setProjectName(String n) {
+    projectName = n != null ? new Project.NameKey(n) : null;
+  }
+
+  public void setProjectName(Project.NameKey n) {
+    projectName = n;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/CreateRefControl.java b/java/com/google/gerrit/server/project/CreateRefControl.java
new file mode 100644
index 0000000..d45bed9
--- /dev/null
+++ b/java/com/google/gerrit/server/project/CreateRefControl.java
@@ -0,0 +1,148 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Manages access control for creating Git references (aka branches, tags). */
+@Singleton
+public class CreateRefControl {
+  private static final Logger log = LoggerFactory.getLogger(CreateRefControl.class);
+
+  private final PermissionBackend permissionBackend;
+  private final ProjectCache projectCache;
+  private final Reachable reachable;
+
+  @Inject
+  CreateRefControl(
+      PermissionBackend permissionBackend, ProjectCache projectCache, Reachable reachable) {
+    this.permissionBackend = permissionBackend;
+    this.projectCache = projectCache;
+    this.reachable = reachable;
+  }
+
+  /**
+   * Checks whether the {@link CurrentUser} can create a new Git ref.
+   *
+   * @param user the user performing the operation
+   * @param repo repository on which user want to create
+   * @param branch the branch the new {@link RevObject} should be created on
+   * @param object the object the user will start the reference with
+   * @throws AuthException if creation is denied; the message explains the denial.
+   * @throws PermissionBackendException on failure of permission checks.
+   * @throws ResourceConflictException if the project state does not permit the operation
+   */
+  public void checkCreateRef(
+      Provider<? extends CurrentUser> user,
+      Repository repo,
+      Branch.NameKey branch,
+      RevObject object)
+      throws AuthException, PermissionBackendException, NoSuchProjectException, IOException,
+          ResourceConflictException {
+    ProjectState ps = projectCache.checkedGet(branch.getParentKey());
+    if (ps == null) {
+      throw new NoSuchProjectException(branch.getParentKey());
+    }
+    ps.checkStatePermitsWrite();
+
+    PermissionBackend.ForRef perm = permissionBackend.user(user).ref(branch);
+    if (object instanceof RevCommit) {
+      perm.check(RefPermission.CREATE);
+      checkCreateCommit(repo, (RevCommit) object, ps, perm);
+    } else if (object instanceof RevTag) {
+      RevTag tag = (RevTag) object;
+      try (RevWalk rw = new RevWalk(repo)) {
+        rw.parseBody(tag);
+      } catch (IOException e) {
+        log.error(String.format("RevWalk(%s) parsing %s:", branch.getParentKey(), tag.name()), e);
+        throw e;
+      }
+
+      // If tagger is present, require it matches the user's email.
+      PersonIdent tagger = tag.getTaggerIdent();
+      if (tagger != null
+          && (!user.get().isIdentifiedUser()
+              || !user.get().asIdentifiedUser().hasEmailAddress(tagger.getEmailAddress()))) {
+        perm.check(RefPermission.FORGE_COMMITTER);
+      }
+
+      RevObject target = tag.getObject();
+      if (target instanceof RevCommit) {
+        checkCreateCommit(repo, (RevCommit) target, ps, perm);
+      } else {
+        checkCreateRef(user, repo, branch, target);
+      }
+
+      // If the tag has a PGP signature, allow a lower level of permission
+      // than if it doesn't have a PGP signature.
+      RefControl refControl = ps.controlFor(user.get()).controlForRef(branch);
+      if (tag.getFullMessage().contains("-----BEGIN PGP SIGNATURE-----\n")) {
+        if (!refControl.canPerform(Permission.CREATE_SIGNED_TAG)) {
+          throw new AuthException(Permission.CREATE_SIGNED_TAG + " not permitted");
+        }
+      } else if (!refControl.canPerform(Permission.CREATE_TAG)) {
+        throw new AuthException(Permission.CREATE_TAG + " not permitted");
+      }
+    }
+  }
+
+  /**
+   * Check if the user is allowed to create a new commit object if this creation would introduce a
+   * new commit to the repository.
+   */
+  private void checkCreateCommit(
+      Repository repo, RevCommit commit, ProjectState projectState, PermissionBackend.ForRef forRef)
+      throws AuthException, PermissionBackendException {
+    try {
+      // If the user has update (push) permission, they can create the ref regardless
+      // of whether they are pushing any new objects along with the create.
+      forRef.check(RefPermission.UPDATE);
+      return;
+    } catch (AuthException denied) {
+      // Fall through to check reachability.
+    }
+    if (reachable.fromHeadsOrTags(projectState, repo, commit)) {
+      // If the user has no push permissions, check whether the object is
+      // merged into a branch or tag readable by this user. If so, they are
+      // not effectively "pushing" more objects, so they can create the ref
+      // even if they don't have push permission.
+      return;
+    }
+
+    throw new AuthException(
+        String.format(
+            "%s for creating new commit object not permitted",
+            RefPermission.UPDATE.describeForException()));
+  }
+}
diff --git a/java/com/google/gerrit/server/project/DashboardResource.java b/java/com/google/gerrit/server/project/DashboardResource.java
new file mode 100644
index 0000000..54f958a
--- /dev/null
+++ b/java/com/google/gerrit/server/project/DashboardResource.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+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;
+
+public class DashboardResource implements RestResource {
+  public static final TypeLiteral<RestView<DashboardResource>> DASHBOARD_KIND =
+      new TypeLiteral<RestView<DashboardResource>>() {};
+
+  public static DashboardResource projectDefault(ProjectState projectState, CurrentUser user) {
+    return new DashboardResource(projectState, user, null, null, null, true);
+  }
+
+  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(
+      ProjectState projectState,
+      CurrentUser user,
+      String refName,
+      String pathName,
+      Config config,
+      boolean projectDefault) {
+    this.projectState = projectState;
+    this.user = user;
+    this.refName = refName;
+    this.pathName = pathName;
+    this.config = config;
+    this.projectDefault = projectDefault;
+  }
+
+  public ProjectState getProjectState() {
+    return projectState;
+  }
+
+  public CurrentUser getUser() {
+    return user;
+  }
+
+  public String getRefName() {
+    return refName;
+  }
+
+  public String getPathName() {
+    return pathName;
+  }
+
+  public Config getConfig() {
+    return config;
+  }
+
+  public boolean isProjectDefault() {
+    return projectDefault;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/DefaultPermissionBackend.java b/java/com/google/gerrit/server/project/DefaultPermissionBackend.java
new file mode 100644
index 0000000..b679fca
--- /dev/null
+++ b/java/com/google/gerrit/server/project/DefaultPermissionBackend.java
@@ -0,0 +1,208 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PeerDaemonUser;
+import com.google.gerrit.server.account.CapabilityCollection;
+import com.google.gerrit.server.permissions.FailedPermissionBackend;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
+@Singleton
+public class DefaultPermissionBackend extends PermissionBackend {
+  private static final CurrentUser.PropertyKey<Boolean> IS_ADMIN = CurrentUser.PropertyKey.create();
+
+  private final ProjectCache projectCache;
+
+  @Inject
+  DefaultPermissionBackend(ProjectCache projectCache) {
+    this.projectCache = projectCache;
+  }
+
+  private CapabilityCollection capabilities() {
+    return projectCache.getAllProjects().getCapabilityCollection();
+  }
+
+  @Override
+  public WithUser user(CurrentUser user) {
+    return new WithUserImpl(checkNotNull(user, "user"));
+  }
+
+  class WithUserImpl extends WithUser {
+    private final CurrentUser user;
+    private Boolean admin;
+
+    WithUserImpl(CurrentUser user) {
+      this.user = checkNotNull(user, "user");
+    }
+
+    @Override
+    public ForProject project(Project.NameKey project) {
+      try {
+        ProjectState state = projectCache.checkedGet(project);
+        if (state != null) {
+          return state.controlFor(user).asForProject().database(db);
+        }
+        return FailedPermissionBackend.project("not found", new NoSuchProjectException(project));
+      } catch (IOException e) {
+        return FailedPermissionBackend.project("unavailable", e);
+      }
+    }
+
+    @Override
+    public void check(GlobalOrPluginPermission perm)
+        throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted");
+      }
+    }
+
+    @Override
+    public <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException {
+      Set<T> ok = newSet(permSet);
+      for (T perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(GlobalOrPluginPermission perm) throws PermissionBackendException {
+      if (perm instanceof GlobalPermission) {
+        return can((GlobalPermission) perm);
+      } else if (perm instanceof PluginPermission) {
+        PluginPermission pluginPermission = (PluginPermission) perm;
+        return has(pluginPermission.permissionName())
+            || (pluginPermission.fallBackToAdmin() && isAdmin());
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+
+    private boolean can(GlobalPermission perm) throws PermissionBackendException {
+      switch (perm) {
+        case ADMINISTRATE_SERVER:
+          return isAdmin();
+        case EMAIL_REVIEWERS:
+          return canEmailReviewers();
+
+        case FLUSH_CACHES:
+        case KILL_TASK:
+        case RUN_GC:
+        case VIEW_CACHES:
+        case VIEW_QUEUE:
+          return has(perm.permissionName()) || can(GlobalPermission.MAINTAIN_SERVER);
+
+        case CREATE_ACCOUNT:
+        case CREATE_GROUP:
+        case CREATE_PROJECT:
+        case MAINTAIN_SERVER:
+        case MODIFY_ACCOUNT:
+        case STREAM_EVENTS:
+        case VIEW_ALL_ACCOUNTS:
+        case VIEW_CONNECTIONS:
+        case VIEW_PLUGINS:
+          return has(perm.permissionName()) || isAdmin();
+
+        case ACCESS_DATABASE:
+        case RUN_AS:
+          return has(perm.permissionName());
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+
+    private boolean isAdmin() {
+      if (admin == null) {
+        admin = computeAdmin();
+      }
+      return admin;
+    }
+
+    private Boolean computeAdmin() {
+      Boolean r = user.get(IS_ADMIN);
+      if (r == null) {
+        if (user.isImpersonating()) {
+          r = false;
+        } else if (user instanceof PeerDaemonUser) {
+          r = true;
+        } else {
+          r = allow(capabilities().administrateServer);
+        }
+        user.put(IS_ADMIN, r);
+      }
+      return r;
+    }
+
+    private boolean canEmailReviewers() {
+      List<PermissionRule> email = capabilities().emailReviewers;
+      return allow(email) || notDenied(email);
+    }
+
+    private boolean has(String permissionName) {
+      return allow(capabilities().getPermission(permissionName));
+    }
+
+    private boolean allow(Collection<PermissionRule> rules) {
+      return user.getEffectiveGroups()
+          .containsAnyOf(
+              rules
+                  .stream()
+                  .filter(r -> r.getAction() == Action.ALLOW)
+                  .map(r -> r.getGroup().getUUID())
+                  .collect(toSet()));
+    }
+
+    private boolean notDenied(Collection<PermissionRule> rules) {
+      Set<AccountGroup.UUID> denied =
+          rules
+              .stream()
+              .filter(r -> r.getAction() != Action.ALLOW)
+              .map(r -> r.getGroup().getUUID())
+              .collect(toSet());
+      return denied.isEmpty() || !user.getEffectiveGroups().containsAnyOf(denied);
+    }
+  }
+
+  private static <T extends GlobalOrPluginPermission> Set<T> newSet(Collection<T> permSet) {
+    if (permSet instanceof EnumSet) {
+      @SuppressWarnings({"unchecked", "rawtypes"})
+      Set<T> s = ((EnumSet) permSet).clone();
+      s.clear();
+      return s;
+    }
+    return Sets.newHashSetWithExpectedSize(permSet.size());
+  }
+}
diff --git a/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java b/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java
new file mode 100644
index 0000000..44c5e0b
--- /dev/null
+++ b/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.inject.AbstractModule;
+
+/** Binds the default {@link PermissionBackend}. */
+public class DefaultPermissionBackendModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    install(new LegacyControlsModule());
+  }
+
+  /** Binds legacy ProjectControl, RefControl, ChangeControl. */
+  public static class LegacyControlsModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      // TODO(sop) Hide ProjectControl, RefControl, ChangeControl related bindings.
+      factory(ProjectControl.Factory.class);
+      bind(ChangeControl.Factory.class);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java b/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java
new file mode 100644
index 0000000..8c9bec7
--- /dev/null
+++ b/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/java/com/google/gerrit/server/project/FileResource.java b/java/com/google/gerrit/server/project/FileResource.java
new file mode 100644
index 0000000..6e5375a
--- /dev/null
+++ b/java/com/google/gerrit/server/project/FileResource.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.TypeLiteral;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+public class FileResource implements RestResource {
+  public static final TypeLiteral<RestView<FileResource>> FILE_KIND =
+      new TypeLiteral<RestView<FileResource>>() {};
+
+  public static FileResource create(
+      GitRepositoryManager repoManager, ProjectState projectState, ObjectId rev, String path)
+      throws ResourceNotFoundException, IOException {
+    try (Repository repo = repoManager.openRepository(projectState.getNameKey());
+        RevWalk rw = new RevWalk(repo)) {
+      RevTree tree = rw.parseTree(rev);
+      if (TreeWalk.forPath(repo, path, tree) != null) {
+        return new FileResource(projectState, rev, path);
+      }
+    }
+    throw new ResourceNotFoundException(IdString.fromDecoded(path));
+  }
+
+  private final ProjectState projectState;
+  private final ObjectId rev;
+  private final String path;
+
+  public FileResource(ProjectState projectState, ObjectId rev, String path) {
+    this.projectState = projectState;
+    this.rev = rev;
+    this.path = path;
+  }
+
+  public ProjectState getProjectState() {
+    return projectState;
+  }
+
+  public ObjectId getRev() {
+    return rev;
+  }
+
+  public String getPath() {
+    return path;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/InvalidChangeOperationException.java b/java/com/google/gerrit/server/project/InvalidChangeOperationException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/InvalidChangeOperationException.java
rename to java/com/google/gerrit/server/project/InvalidChangeOperationException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchChangeException.java b/java/com/google/gerrit/server/project/NoSuchChangeException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchChangeException.java
rename to java/com/google/gerrit/server/project/NoSuchChangeException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchProjectException.java b/java/com/google/gerrit/server/project/NoSuchProjectException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchProjectException.java
rename to java/com/google/gerrit/server/project/NoSuchProjectException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchRefException.java b/java/com/google/gerrit/server/project/NoSuchRefException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchRefException.java
rename to java/com/google/gerrit/server/project/NoSuchRefException.java
diff --git a/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java b/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java
new file mode 100644
index 0000000..b68446f
--- /dev/null
+++ b/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java
@@ -0,0 +1,56 @@
+// 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.project;
+
+import com.google.gerrit.reviewdb.client.Project;
+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;
+
+/** Caches {@link ProjectControl} objects for the current user of the request. */
+@RequestScoped
+public class PerRequestProjectControlCache {
+  private final ProjectCache projectCache;
+  private final CurrentUser user;
+  private final Map<Project.NameKey, ProjectControl> controls;
+
+  @Inject
+  PerRequestProjectControlCache(ProjectCache projectCache, CurrentUser userProvider) {
+    this.projectCache = projectCache;
+    this.user = userProvider;
+    this.controls = new HashMap<>();
+  }
+
+  ProjectControl get(Project.NameKey nameKey) throws NoSuchProjectException {
+    ProjectControl ctl = controls.get(nameKey);
+    if (ctl == null) {
+      ProjectState p = projectCache.get(nameKey);
+      if (p == null) {
+        throw new NoSuchProjectException(nameKey);
+      }
+      ctl = p.controlFor(user);
+      controls.put(nameKey, ctl);
+    }
+    return ctl;
+  }
+
+  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/PermissionCollection.java b/java/com/google/gerrit/server/project/PermissionCollection.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
rename to java/com/google/gerrit/server/project/PermissionCollection.java
diff --git a/java/com/google/gerrit/server/project/ProjectCache.java b/java/com/google/gerrit/server/project/ProjectCache.java
new file mode 100644
index 0000000..8283dce
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ProjectCache.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import java.io.IOException;
+import java.util.Set;
+
+/** Cache of project information, including access rights. */
+public interface ProjectCache {
+  /** @return the parent state for all projects on this server. */
+  ProjectState getAllProjects();
+
+  /** @return the project state of the project storing meta data for all users. */
+  ProjectState getAllUsers();
+
+  /**
+   * Get the cached data for a project by its unique name.
+   *
+   * @param projectName name of the project.
+   * @return the cached data; null if no such project exists or a error occurred.
+   * @see #checkedGet(com.google.gerrit.reviewdb.client.Project.NameKey)
+   */
+  ProjectState get(Project.NameKey projectName);
+
+  /**
+   * Get the cached data for a project by its unique name.
+   *
+   * @param projectName name of the project.
+   * @throws IOException when there was an error.
+   * @return the cached data; null if no such project exists.
+   */
+  ProjectState checkedGet(Project.NameKey projectName) throws IOException;
+
+  /**
+   * 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, 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) throws IOException;
+
+  /** @return sorted iteration of projects. */
+  ImmutableSortedSet<Project.NameKey> all();
+
+  /**
+   * @return estimated set of relevant groups extracted from hot project access rules. If the cache
+   *     is cold or too small for the entire project set of the server, this set may be incomplete.
+   */
+  Set<AccountGroup.UUID> guessRelevantGroupUUIDs();
+
+  /**
+   * Filter the set of registered project names by common prefix.
+   *
+   * @param prefix common prefix.
+   * @return sorted iteration of projects sharing the same prefix.
+   */
+  ImmutableSortedSet<Project.NameKey> byName(String prefix);
+
+  /** Notify the cache that a new project was constructed. */
+  void onCreateProject(Project.NameKey newProjectName) throws IOException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheClock.java b/java/com/google/gerrit/server/project/ProjectCacheClock.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheClock.java
rename to java/com/google/gerrit/server/project/ProjectCacheClock.java
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
new file mode 100644
index 0000000..4d20c03
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -0,0 +1,289 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.base.Throwables;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.config.AllProjectsName;
+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.name.Named;
+import java.io.IOException;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Cache of project information, including access rights. */
+@Singleton
+public class ProjectCacheImpl implements ProjectCache {
+  private static final Logger log = LoggerFactory.getLogger(ProjectCacheImpl.class);
+
+  private static final String CACHE_NAME = "projects";
+  private static final String CACHE_LIST = "project_list";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        cache(CACHE_NAME, String.class, ProjectState.class).loader(Loader.class);
+
+        cache(CACHE_LIST, ListKey.class, new TypeLiteral<ImmutableSortedSet<Project.NameKey>>() {})
+            .maximumWeight(1)
+            .loader(Lister.class);
+
+        bind(ProjectCacheImpl.class);
+        bind(ProjectCache.class).to(ProjectCacheImpl.class);
+
+        install(
+            new LifecycleModule() {
+              @Override
+              protected void configure() {
+                listener().to(ProjectCacheWarmer.class);
+                listener().to(ProjectCacheClock.class);
+              }
+            });
+      }
+    };
+  }
+
+  private final AllProjectsName allProjectsName;
+  private final AllUsersName allUsersName;
+  private final LoadingCache<String, ProjectState> byName;
+  private final LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list;
+  private final Lock listLock;
+  private final ProjectCacheClock clock;
+  private final Provider<ProjectIndexer> indexer;
+
+  @Inject
+  ProjectCacheImpl(
+      final AllProjectsName allProjectsName,
+      final AllUsersName allUsersName,
+      @Named(CACHE_NAME) LoadingCache<String, ProjectState> byName,
+      @Named(CACHE_LIST) LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list,
+      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
+  public ProjectState getAllProjects() {
+    ProjectState state = get(allProjectsName);
+    if (state == null) {
+      // This should never occur, the server must have this
+      // project to process anything.
+      throw new IllegalStateException("Missing project " + allProjectsName);
+    }
+    return state;
+  }
+
+  @Override
+  public ProjectState getAllUsers() {
+    ProjectState state = get(allUsersName);
+    if (state == null) {
+      // This should never occur.
+      throw new IllegalStateException("Missing project " + allUsersName);
+    }
+    return state;
+  }
+
+  @Override
+  public ProjectState get(Project.NameKey projectName) {
+    try {
+      return checkedGet(projectName);
+    } catch (IOException e) {
+      log.warn("Cannot read project " + projectName, e);
+      return null;
+    }
+  }
+
+  @Override
+  public ProjectState checkedGet(Project.NameKey projectName) throws IOException {
+    if (projectName == null) {
+      return null;
+    }
+    try {
+      ProjectState state = byName.get(projectName.get());
+      if (state != null && state.needsRefresh(clock.read())) {
+        byName.invalidate(projectName.get());
+        state = byName.get(projectName.get());
+      }
+      return state;
+    } catch (ExecutionException e) {
+      if (!(e.getCause() instanceof RepositoryNotFoundException)) {
+        log.warn(String.format("Cannot read project %s", projectName.get()), e);
+        Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
+        throw new IOException(e);
+      }
+      return null;
+    }
+  }
+
+  @Override
+  public void evict(Project p) throws IOException {
+    evict(p.getNameKey());
+  }
+
+  @Override
+  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) throws IOException {
+    listLock.lock();
+    try {
+      list.put(
+          ListKey.ALL,
+          ImmutableSortedSet.copyOf(
+              Sets.difference(list.get(ListKey.ALL), ImmutableSet.of(p.getNameKey()))));
+    } catch (ExecutionException e) {
+      log.warn("Cannot list available projects", e);
+    } finally {
+      listLock.unlock();
+    }
+    evict(p);
+  }
+
+  @Override
+  public void onCreateProject(Project.NameKey newProjectName) throws IOException {
+    listLock.lock();
+    try {
+      list.put(
+          ListKey.ALL,
+          ImmutableSortedSet.copyOf(
+              Sets.union(list.get(ListKey.ALL), ImmutableSet.of(newProjectName))));
+    } catch (ExecutionException e) {
+      log.warn("Cannot list available projects", e);
+    } finally {
+      listLock.unlock();
+    }
+    indexer.get().index(newProjectName);
+  }
+
+  @Override
+  public ImmutableSortedSet<Project.NameKey> all() {
+    try {
+      return list.get(ListKey.ALL);
+    } catch (ExecutionException e) {
+      log.warn("Cannot list available projects", e);
+      return ImmutableSortedSet.of();
+    }
+  }
+
+  @Override
+  public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
+    return all()
+        .stream()
+        .map(n -> byName.getIfPresent(n.get()))
+        .filter(Objects::nonNull)
+        .flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
+        // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
+        // against them just in case there is a bug or corner case.
+        .filter(id -> id != null && id.get() != null)
+        .collect(toSet());
+  }
+
+  @Override
+  public ImmutableSortedSet<Project.NameKey> byName(String pfx) {
+    Project.NameKey start = new Project.NameKey(pfx);
+    Project.NameKey end = new Project.NameKey(pfx + Character.MAX_VALUE);
+    try {
+      // Right endpoint is exclusive, but U+FFFF is a non-character so no project ends with it.
+      return list.get(ListKey.ALL).subSet(start, end);
+    } catch (ExecutionException e) {
+      log.warn("Cannot look up projects for prefix " + pfx, e);
+      return ImmutableSortedSet.of();
+    }
+  }
+
+  static class Loader extends CacheLoader<String, ProjectState> {
+    private final ProjectState.Factory projectStateFactory;
+    private final GitRepositoryManager mgr;
+    private final ProjectCacheClock clock;
+
+    @Inject
+    Loader(ProjectState.Factory psf, GitRepositoryManager g, ProjectCacheClock clock) {
+      projectStateFactory = psf;
+      mgr = g;
+      this.clock = clock;
+    }
+
+    @Override
+    public ProjectState load(String projectName) throws Exception {
+      long now = clock.read();
+      Project.NameKey key = new Project.NameKey(projectName);
+      try (Repository git = mgr.openRepository(key)) {
+        ProjectConfig cfg = new ProjectConfig(key);
+        cfg.load(git);
+
+        ProjectState state = projectStateFactory.create(cfg);
+        state.initLastCheck(now);
+        return state;
+      }
+    }
+  }
+
+  static class ListKey {
+    static final ListKey ALL = new ListKey();
+
+    private ListKey() {}
+  }
+
+  static class Lister extends CacheLoader<ListKey, ImmutableSortedSet<Project.NameKey>> {
+    private final GitRepositoryManager mgr;
+
+    @Inject
+    Lister(GitRepositoryManager mgr) {
+      this.mgr = mgr;
+    }
+
+    @Override
+    public ImmutableSortedSet<Project.NameKey> load(ListKey key) throws Exception {
+      return ImmutableSortedSet.copyOf(mgr.list());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
rename to java/com/google/gerrit/server/project/ProjectCacheWarmer.java
diff --git a/java/com/google/gerrit/server/project/ProjectControl.java b/java/com/google/gerrit/server/project/ProjectControl.java
new file mode 100644
index 0000000..68dbf86
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ProjectControl.java
@@ -0,0 +1,415 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.config.GitReceivePackGroups;
+import com.google.gerrit.server.config.GitUploadPackGroups;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.FailedPermissionBackend;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
+import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Access control management for a user accessing a project's data. */
+class ProjectControl {
+  interface Factory {
+    ProjectControl create(CurrentUser who, ProjectState ps);
+  }
+
+  @Singleton
+  protected static class Metrics {
+    final Counter0 claCheckCount;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      claCheckCount =
+          metricMaker.newCounter(
+              "license/cla_check_count",
+              new Description("Total number of CLA check requests").setRate().setUnit("requests"));
+    }
+  }
+
+  private final Set<AccountGroup.UUID> uploadGroups;
+  private final Set<AccountGroup.UUID> receiveGroups;
+  private final PermissionBackend.WithUser perm;
+  private final CurrentUser user;
+  private final ProjectState state;
+  private final ChangeControl.Factory changeControlFactory;
+  private final PermissionCollection.Factory permissionFilter;
+
+  private List<SectionMatcher> allSections;
+  private Map<String, RefControl> refControls;
+  private Boolean declaredOwner;
+
+  @Inject
+  ProjectControl(
+      @GitUploadPackGroups Set<AccountGroup.UUID> uploadGroups,
+      @GitReceivePackGroups Set<AccountGroup.UUID> receiveGroups,
+      PermissionCollection.Factory permissionFilter,
+      ChangeControl.Factory changeControlFactory,
+      PermissionBackend permissionBackend,
+      @Assisted CurrentUser who,
+      @Assisted ProjectState ps) {
+    this.changeControlFactory = changeControlFactory;
+    this.uploadGroups = uploadGroups;
+    this.receiveGroups = receiveGroups;
+    this.permissionFilter = permissionFilter;
+    this.perm = permissionBackend.user(who);
+    user = who;
+    state = ps;
+  }
+
+  ProjectControl forUser(CurrentUser who) {
+    ProjectControl r = state.controlFor(who);
+    // Not per-user, and reusing saves lookup time.
+    r.allSections = allSections;
+    return r;
+  }
+
+  ForProject asForProject() {
+    return new ForProjectImpl();
+  }
+
+  ChangeControl controlFor(ReviewDb db, Change change) throws OrmException {
+    return changeControlFactory.create(
+        controlForRef(change.getDest()), db, change.getProject(), change.getId());
+  }
+
+  ChangeControl controlFor(ChangeNotes notes) {
+    return changeControlFactory.create(controlForRef(notes.getChange().getDest()), notes);
+  }
+
+  RefControl controlForRef(Branch.NameKey ref) {
+    return controlForRef(ref.get());
+  }
+
+  public RefControl controlForRef(String refName) {
+    if (refControls == null) {
+      refControls = new HashMap<>();
+    }
+    RefControl ctl = refControls.get(refName);
+    if (ctl == null) {
+      PermissionCollection relevant = permissionFilter.filter(access(), refName, user);
+      ctl = new RefControl(this, refName, relevant);
+      refControls.put(refName, ctl);
+    }
+    return ctl;
+  }
+
+  CurrentUser getUser() {
+    return user;
+  }
+
+  ProjectState getProjectState() {
+    return state;
+  }
+
+  Project getProject() {
+    return state.getProject();
+  }
+
+  /** Is this user a project owner? */
+  boolean isOwner() {
+    return (isDeclaredOwner() && !controlForRef("refs/*").isBlocked(Permission.OWNER)) || isAdmin();
+  }
+
+  /**
+   * @return {@code Capable.OK} if the user can upload to at least one reference. Does not check
+   *     Contributor Agreements.
+   */
+  boolean canPushToAtLeastOneRef() {
+    return canPerformOnAnyRef(Permission.PUSH)
+        || canPerformOnAnyRef(Permission.CREATE_TAG)
+        || isOwner();
+  }
+
+  boolean isAdmin() {
+    try {
+      perm.check(GlobalPermission.ADMINISTRATE_SERVER);
+      return true;
+    } catch (AuthException | PermissionBackendException e) {
+      return false;
+    }
+  }
+
+  boolean match(PermissionRule rule, boolean isChangeOwner) {
+    return match(rule.getGroup().getUUID(), isChangeOwner);
+  }
+
+  /** Can the user run upload pack? */
+  private boolean canRunUploadPack() {
+    for (AccountGroup.UUID group : uploadGroups) {
+      if (match(group)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Can the user run receive pack? */
+  private boolean canRunReceivePack() {
+    for (AccountGroup.UUID group : receiveGroups) {
+      if (match(group)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private boolean allRefsAreVisible(Set<String> ignore) {
+    return user.isInternalUser() || canPerformOnAllRefs(Permission.READ, ignore);
+  }
+
+  /** Returns whether the project is hidden. */
+  private boolean isHidden() {
+    return getProject().getState().equals(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
+  }
+
+  private boolean canAddRefs() {
+    return (canPerformOnAnyRef(Permission.CREATE) || isAdmin());
+  }
+
+  private boolean canCreateChanges() {
+    for (SectionMatcher matcher : access()) {
+      AccessSection section = matcher.section;
+      if (section.getName().startsWith("refs/for/")) {
+        Permission permission = section.getPermission(Permission.PUSH);
+        if (permission != null && controlForRef(section.getName()).canPerform(Permission.PUSH)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  private boolean isDeclaredOwner() {
+    if (declaredOwner == null) {
+      GroupMembership effectiveGroups = user.getEffectiveGroups();
+      declaredOwner = effectiveGroups.containsAnyOf(state.getAllOwners());
+    }
+    return declaredOwner;
+  }
+
+  private boolean canPerformOnAnyRef(String permissionName) {
+    for (SectionMatcher matcher : access()) {
+      AccessSection section = matcher.section;
+      Permission permission = section.getPermission(permissionName);
+      if (permission == null) {
+        continue;
+      }
+
+      for (PermissionRule rule : permission.getRules()) {
+        if (rule.isBlock() || rule.isDeny() || !match(rule)) {
+          continue;
+        }
+
+        // Being in a group that was granted this permission is only an
+        // approximation.  There might be overrides and doNotInherit
+        // that would render this to be false.
+        //
+        if (controlForRef(section.getName()).canPerform(permissionName)) {
+          return true;
+        }
+        break;
+      }
+    }
+
+    return false;
+  }
+
+  private boolean canPerformOnAllRefs(String permission, Set<String> ignore) {
+    boolean canPerform = false;
+    Set<String> patterns = allRefPatterns(permission);
+    if (patterns.contains(AccessSection.ALL)) {
+      // Only possible if granted on the pattern that
+      // matches every possible reference.  Check all
+      // patterns also have the permission.
+      //
+      for (String pattern : patterns) {
+        if (controlForRef(pattern).canPerform(permission)) {
+          canPerform = true;
+        } else if (ignore.contains(pattern)) {
+          continue;
+        } else {
+          return false;
+        }
+      }
+    }
+    return canPerform;
+  }
+
+  private Set<String> allRefPatterns(String permissionName) {
+    Set<String> all = new HashSet<>();
+    for (SectionMatcher matcher : access()) {
+      AccessSection section = matcher.section;
+      Permission permission = section.getPermission(permissionName);
+      if (permission != null) {
+        all.add(section.getName());
+      }
+    }
+    return all;
+  }
+
+  private List<SectionMatcher> access() {
+    if (allSections == null) {
+      allSections = state.getAllSections();
+    }
+    return allSections;
+  }
+
+  private boolean match(PermissionRule rule) {
+    return match(rule.getGroup().getUUID());
+  }
+
+  private boolean match(AccountGroup.UUID uuid) {
+    return match(uuid, false);
+  }
+
+  private boolean match(AccountGroup.UUID uuid, boolean isChangeOwner) {
+    if (SystemGroupBackend.PROJECT_OWNERS.equals(uuid)) {
+      return isDeclaredOwner();
+    } else if (SystemGroupBackend.CHANGE_OWNER.equals(uuid)) {
+      return isChangeOwner;
+    } else {
+      return user.getEffectiveGroups().contains(uuid);
+    }
+  }
+
+  private class ForProjectImpl extends ForProject {
+    @Override
+    public ForProject user(CurrentUser user) {
+      return forUser(user).asForProject().database(db);
+    }
+
+    @Override
+    public ForRef ref(String ref) {
+      return controlForRef(ref).asForRef().database(db);
+    }
+
+    @Override
+    public ForChange change(ChangeData cd) {
+      try {
+        checkProject(cd.change());
+        return super.change(cd);
+      } catch (OrmException e) {
+        return FailedPermissionBackend.change("unavailable", e);
+      }
+    }
+
+    @Override
+    public ForChange change(ChangeNotes notes) {
+      checkProject(notes.getChange());
+      return super.change(notes);
+    }
+
+    private void checkProject(Change change) {
+      Project.NameKey project = getProject().getNameKey();
+      checkArgument(
+          project.equals(change.getProject()),
+          "expected change in project %s, not %s",
+          project,
+          change.getProject());
+    }
+
+    @Override
+    public void check(ProjectPermission perm) throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted");
+      }
+    }
+
+    @Override
+    public Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+        throws PermissionBackendException {
+      EnumSet<ProjectPermission> ok = EnumSet.noneOf(ProjectPermission.class);
+      for (ProjectPermission perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(ProjectPermission perm) throws PermissionBackendException {
+      switch (perm) {
+        case ACCESS:
+          return (!isHidden() && (user.isInternalUser() || canPerformOnAnyRef(Permission.READ)))
+              || isOwner();
+
+        case READ:
+          return !isHidden() && allRefsAreVisible(Collections.emptySet());
+
+        case READ_NO_CONFIG:
+          return !isHidden() && allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG));
+
+        case CREATE_REF:
+          return canAddRefs();
+        case CREATE_CHANGE:
+          return canCreateChanges();
+
+        case RUN_RECEIVE_PACK:
+          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/java/com/google/gerrit/server/project/ProjectData.java b/java/com/google/gerrit/server/project/ProjectData.java
new file mode 100644
index 0000000..407529d
--- /dev/null
+++ b/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/ProjectHierarchyIterator.java b/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
rename to java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java b/java/com/google/gerrit/server/project/ProjectJson.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java
rename to java/com/google/gerrit/server/project/ProjectJson.java
diff --git a/java/com/google/gerrit/server/project/ProjectNameLockManager.java b/java/com/google/gerrit/server/project/ProjectNameLockManager.java
new file mode 100644
index 0000000..4666c32
--- /dev/null
+++ b/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/ProjectRef.java b/java/com/google/gerrit/server/project/ProjectRef.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectRef.java
rename to java/com/google/gerrit/server/project/ProjectRef.java
diff --git a/java/com/google/gerrit/server/project/ProjectResource.java b/java/com/google/gerrit/server/project/ProjectResource.java
new file mode 100644
index 0000000..22b7bd9
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ProjectResource.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.inject.TypeLiteral;
+
+public class ProjectResource implements RestResource {
+  public static final TypeLiteral<RestView<ProjectResource>> PROJECT_KIND =
+      new TypeLiteral<RestView<ProjectResource>>() {};
+
+  private final ProjectState projectState;
+  private final CurrentUser user;
+
+  public ProjectResource(ProjectState projectState, CurrentUser user) {
+    this.projectState = projectState;
+    this.user = user;
+  }
+
+  ProjectResource(ProjectResource rsrc) {
+    this.projectState = rsrc.getProjectState();
+    this.user = rsrc.getUser();
+  }
+
+  public String getName() {
+    return projectState.getName();
+  }
+
+  public Project.NameKey getNameKey() {
+    return projectState.getNameKey();
+  }
+
+  public ProjectState getProjectState() {
+    return projectState;
+  }
+
+  public CurrentUser getUser() {
+    return user;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
new file mode 100644
index 0000000..e3d6078
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -0,0 +1,580 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.gerrit.common.data.PermissionRule.Action.ALLOW;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.RefConfigSection;
+import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+import com.google.gerrit.extensions.api.projects.ThemeInfo;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.CapabilityCollection;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.BranchOrderSection;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.ProjectLevelConfig;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.rules.PrologEnvironment;
+import com.google.gerrit.server.rules.RulesCache;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import com.googlecode.prolog_cafe.exceptions.CompileException;
+import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Cached information on a project. */
+public class ProjectState {
+  private static final Logger log = LoggerFactory.getLogger(ProjectState.class);
+
+  public interface Factory {
+    ProjectState create(ProjectConfig config);
+  }
+
+  private final boolean isAllProjects;
+  private final boolean isAllUsers;
+  private final SitePaths sitePaths;
+  private final AllProjectsName allProjectsName;
+  private final ProjectCache projectCache;
+  private final ProjectControl.Factory projectControlFactory;
+  private final PrologEnvironment.Factory envFactory;
+  private final GitRepositoryManager gitMgr;
+  private final RulesCache rulesCache;
+  private final List<CommentLinkInfo> commentLinks;
+
+  private final ProjectConfig config;
+  private final Map<String, ProjectLevelConfig> configs;
+  private final Set<AccountGroup.UUID> localOwners;
+
+  /** Prolog rule state. */
+  private volatile PrologMachineCopy rulesMachine;
+
+  /** Last system time the configuration's revision was examined. */
+  private volatile long lastCheckGeneration;
+
+  /** Local access sections, wrapped in SectionMatchers for faster evaluation. */
+  private volatile List<SectionMatcher> localAccessSections;
+
+  /** Theme information loaded from site_path/themes. */
+  private volatile ThemeInfo theme;
+
+  /** If this is all projects, the capabilities used by the server. */
+  private final CapabilityCollection capabilities;
+
+  /** All label types applicable to changes in this project. */
+  private LabelTypes labelTypes;
+
+  @Inject
+  public ProjectState(
+      final SitePaths sitePaths,
+      final ProjectCache projectCache,
+      final AllProjectsName allProjectsName,
+      final AllUsersName allUsersName,
+      final ProjectControl.Factory projectControlFactory,
+      final PrologEnvironment.Factory envFactory,
+      final GitRepositoryManager gitMgr,
+      final RulesCache rulesCache,
+      final List<CommentLinkInfo> commentLinks,
+      final CapabilityCollection.Factory limitsFactory,
+      @Assisted final ProjectConfig config) {
+    this.sitePaths = sitePaths;
+    this.projectCache = projectCache;
+    this.isAllProjects = config.getProject().getNameKey().equals(allProjectsName);
+    this.isAllUsers = config.getProject().getNameKey().equals(allUsersName);
+    this.allProjectsName = allProjectsName;
+    this.projectControlFactory = projectControlFactory;
+    this.envFactory = envFactory;
+    this.gitMgr = gitMgr;
+    this.rulesCache = rulesCache;
+    this.commentLinks = commentLinks;
+    this.config = config;
+    this.configs = new HashMap<>();
+    this.capabilities =
+        isAllProjects
+            ? limitsFactory.create(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
+            : null;
+
+    if (isAllProjects && !Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)) {
+      localOwners = Collections.emptySet();
+    } else {
+      HashSet<AccountGroup.UUID> groups = new HashSet<>();
+      AccessSection all = config.getAccessSection(AccessSection.ALL);
+      if (all != null) {
+        Permission owner = all.getPermission(Permission.OWNER);
+        if (owner != null) {
+          for (PermissionRule rule : owner.getRules()) {
+            GroupReference ref = rule.getGroup();
+            if (rule.getAction() == ALLOW && ref.getUUID() != null) {
+              groups.add(ref.getUUID());
+            }
+          }
+        }
+      }
+      localOwners = Collections.unmodifiableSet(groups);
+    }
+  }
+
+  void initLastCheck(long generation) {
+    lastCheckGeneration = generation;
+  }
+
+  boolean needsRefresh(long generation) {
+    if (generation <= 0) {
+      return isRevisionOutOfDate();
+    }
+    if (lastCheckGeneration != generation) {
+      lastCheckGeneration = generation;
+      return isRevisionOutOfDate();
+    }
+    return false;
+  }
+
+  private boolean isRevisionOutOfDate() {
+    try (Repository git = gitMgr.openRepository(getNameKey())) {
+      Ref ref = git.getRefDatabase().exactRef(RefNames.REFS_CONFIG);
+      if (ref == null || ref.getObjectId() == null) {
+        return true;
+      }
+      return !ref.getObjectId().equals(config.getRevision());
+    } catch (IOException gone) {
+      return true;
+    }
+  }
+
+  /**
+   * @return cached computation of all global capabilities. This should only be invoked on the state
+   *     from {@link ProjectCache#getAllProjects()}. Null on any other project.
+   */
+  public CapabilityCollection getCapabilityCollection() {
+    return capabilities;
+  }
+
+  /** @return Construct a new PrologEnvironment for the calling thread. */
+  public PrologEnvironment newPrologEnvironment() throws CompileException {
+    PrologMachineCopy pmc = rulesMachine;
+    if (pmc == null) {
+      pmc = rulesCache.loadMachine(getNameKey(), config.getRulesId());
+      rulesMachine = pmc;
+    }
+    return envFactory.create(pmc);
+  }
+
+  /**
+   * Like {@link #newPrologEnvironment()} but instead of reading the rules.pl read the provided
+   * input stream.
+   *
+   * @param name a name of the input stream. Could be any name.
+   * @param in stream to read prolog rules from
+   * @throws CompileException
+   */
+  public PrologEnvironment newPrologEnvironment(String name, Reader in) throws CompileException {
+    PrologMachineCopy pmc = rulesCache.loadMachine(name, in);
+    return envFactory.create(pmc);
+  }
+
+  public Project getProject() {
+    return config.getProject();
+  }
+
+  public Project.NameKey getNameKey() {
+    return getProject().getNameKey();
+  }
+
+  public String getName() {
+    return getNameKey().get();
+  }
+
+  public ProjectConfig getConfig() {
+    return config;
+  }
+
+  public ProjectLevelConfig getConfig(String fileName) {
+    if (configs.containsKey(fileName)) {
+      return configs.get(fileName);
+    }
+
+    ProjectLevelConfig cfg = new ProjectLevelConfig(fileName, this);
+    try (Repository git = gitMgr.openRepository(getNameKey())) {
+      cfg.load(git);
+    } catch (IOException | ConfigInvalidException e) {
+      log.warn("Failed to load " + fileName + " for " + getName(), e);
+    }
+
+    configs.put(fileName, cfg);
+    return cfg;
+  }
+
+  public long getMaxObjectSizeLimit() {
+    return config.getMaxObjectSizeLimit();
+  }
+
+  public boolean statePermitsWrite() {
+    return getProject().getState().permitsWrite();
+  }
+
+  public void checkStatePermitsWrite() throws ResourceConflictException {
+    if (!statePermitsWrite()) {
+      throw new ResourceConflictException(
+          "project state " + getProject().getState().name() + " does not permit write");
+    }
+  }
+
+  /** Get the sections that pertain only to this project. */
+  List<SectionMatcher> getLocalAccessSections() {
+    List<SectionMatcher> sm = localAccessSections;
+    if (sm == null) {
+      Collection<AccessSection> fromConfig = config.getAccessSections();
+      sm = new ArrayList<>(fromConfig.size());
+      for (AccessSection section : fromConfig) {
+        if (isAllProjects) {
+          List<Permission> copy = Lists.newArrayListWithCapacity(section.getPermissions().size());
+          for (Permission p : section.getPermissions()) {
+            if (Permission.canBeOnAllProjects(section.getName(), p.getName())) {
+              copy.add(p);
+            }
+          }
+          section = new AccessSection(section.getName());
+          section.setPermissions(copy);
+        }
+
+        SectionMatcher matcher = SectionMatcher.wrap(getNameKey(), section);
+        if (matcher != null) {
+          sm.add(matcher);
+        }
+      }
+      localAccessSections = sm;
+    }
+    return sm;
+  }
+
+  /**
+   * Obtain all local and inherited sections. This collection is looked up dynamically and is not
+   * cached. Callers should try to cache this result per-request as much as possible.
+   */
+  List<SectionMatcher> getAllSections() {
+    if (isAllProjects) {
+      return getLocalAccessSections();
+    }
+
+    List<SectionMatcher> all = new ArrayList<>();
+    for (ProjectState s : tree()) {
+      all.addAll(s.getLocalAccessSections());
+    }
+    return all;
+  }
+
+  /**
+   * @return all {@link AccountGroup}'s to which the owner privilege for 'refs/*' is assigned for
+   *     this project (the local owners), if there are no local owners the local owners of the
+   *     nearest parent project that has local owners are returned
+   */
+  public Set<AccountGroup.UUID> getOwners() {
+    for (ProjectState p : tree()) {
+      if (!p.localOwners.isEmpty()) {
+        return p.localOwners;
+      }
+    }
+    return Collections.emptySet();
+  }
+
+  /**
+   * @return all {@link AccountGroup}'s that are allowed to administrate the complete project. This
+   *     includes all groups to which the owner privilege for 'refs/*' is assigned for this project
+   *     (the local owners) and all groups to which the owner privilege for 'refs/*' is assigned for
+   *     one of the parent projects (the inherited owners).
+   */
+  public Set<AccountGroup.UUID> getAllOwners() {
+    Set<AccountGroup.UUID> result = new HashSet<>();
+
+    for (ProjectState p : tree()) {
+      result.addAll(p.localOwners);
+    }
+
+    return result;
+  }
+
+  public ProjectControl controlFor(CurrentUser user) {
+    return projectControlFactory.create(user, this);
+  }
+
+  /**
+   * @return an iterable that walks through this project and then the parents of this project.
+   *     Starts from this project and progresses up the hierarchy to All-Projects.
+   */
+  public Iterable<ProjectState> tree() {
+    return new Iterable<ProjectState>() {
+      @Override
+      public Iterator<ProjectState> iterator() {
+        return new ProjectHierarchyIterator(projectCache, allProjectsName, ProjectState.this);
+      }
+    };
+  }
+
+  /**
+   * @return an iterable that walks in-order from All-Projects through the project hierarchy to this
+   *     project.
+   */
+  public Iterable<ProjectState> treeInOrder() {
+    List<ProjectState> projects = Lists.newArrayList(tree());
+    Collections.reverse(projects);
+    return projects;
+  }
+
+  /**
+   * @return an iterable that walks through the parents of this project. Starts from the immediate
+   *     parent of this project and progresses up the hierarchy to All-Projects.
+   */
+  public FluentIterable<ProjectState> parents() {
+    return FluentIterable.from(tree()).skip(1);
+  }
+
+  public boolean isAllProjects() {
+    return isAllProjects;
+  }
+
+  public boolean isAllUsers() {
+    return isAllUsers;
+  }
+
+  public boolean is(BooleanProjectConfig config) {
+    for (ProjectState s : tree()) {
+      switch (s.getProject().getBooleanConfig(config)) {
+        case TRUE:
+          return true;
+        case FALSE:
+          return false;
+        case INHERIT:
+        default:
+          continue;
+      }
+    }
+    return false;
+  }
+
+  /** All available label types. */
+  public LabelTypes getLabelTypes() {
+    if (labelTypes == null) {
+      labelTypes = loadLabelTypes();
+    }
+    return labelTypes;
+  }
+
+  /** All available label types for this change and user. */
+  public LabelTypes getLabelTypes(ChangeNotes notes, CurrentUser user) {
+    return getLabelTypes(notes.getChange().getDest(), user);
+  }
+
+  /** All available label types for this branch and user. */
+  public LabelTypes getLabelTypes(Branch.NameKey destination, CurrentUser user) {
+    List<LabelType> all = getLabelTypes().getLabelTypes();
+
+    List<LabelType> r = Lists.newArrayListWithCapacity(all.size());
+    for (LabelType l : all) {
+      List<String> refs = l.getRefPatterns();
+      if (refs == null) {
+        r.add(l);
+      } else {
+        for (String refPattern : refs) {
+          if (RefConfigSection.isValid(refPattern) && match(destination, refPattern, user)) {
+            r.add(l);
+            break;
+          }
+        }
+      }
+    }
+
+    return new LabelTypes(r);
+  }
+
+  public List<CommentLinkInfo> getCommentLinks() {
+    Map<String, CommentLinkInfo> cls = new LinkedHashMap<>();
+    for (CommentLinkInfo cl : commentLinks) {
+      cls.put(cl.name.toLowerCase(), cl);
+    }
+    for (ProjectState s : treeInOrder()) {
+      for (CommentLinkInfoImpl cl : s.getConfig().getCommentLinkSections()) {
+        String name = cl.name.toLowerCase();
+        if (cl.isOverrideOnly()) {
+          CommentLinkInfo parent = cls.get(name);
+          if (parent == null) {
+            continue; // Ignore invalid overrides.
+          }
+          cls.put(name, cl.inherit(parent));
+        } else {
+          cls.put(name, cl);
+        }
+      }
+    }
+    return ImmutableList.copyOf(cls.values());
+  }
+
+  public BranchOrderSection getBranchOrderSection() {
+    for (ProjectState s : tree()) {
+      BranchOrderSection section = s.getConfig().getBranchOrderSection();
+      if (section != null) {
+        return section;
+      }
+    }
+    return null;
+  }
+
+  public Collection<SubscribeSection> getSubscribeSections(Branch.NameKey branch) {
+    Collection<SubscribeSection> ret = new ArrayList<>();
+    for (ProjectState s : tree()) {
+      ret.addAll(s.getConfig().getSubscribeSections(branch));
+    }
+    return ret;
+  }
+
+  public ThemeInfo getTheme() {
+    ThemeInfo theme = this.theme;
+    if (theme == null) {
+      synchronized (this) {
+        theme = this.theme;
+        if (theme == null) {
+          theme = loadTheme();
+          this.theme = theme;
+        }
+      }
+    }
+    if (theme == ThemeInfo.INHERIT) {
+      ProjectState parent = Iterables.getFirst(parents(), null);
+      return parent != null ? parent.getTheme() : null;
+    }
+    return theme;
+  }
+
+  public Set<GroupReference> getAllGroups() {
+    return getGroups(getAllSections());
+  }
+
+  public Set<GroupReference> getLocalGroups() {
+    return getGroups(getLocalAccessSections());
+  }
+
+  public SubmitType getSubmitType() {
+    for (ProjectState s : tree()) {
+      SubmitType t = s.getProject().getConfiguredSubmitType();
+      if (t != SubmitType.INHERIT) {
+        return t;
+      }
+    }
+    return Project.DEFAULT_ALL_PROJECTS_SUBMIT_TYPE;
+  }
+
+  private static Set<GroupReference> getGroups(List<SectionMatcher> sectionMatcherList) {
+    final Set<GroupReference> all = new HashSet<>();
+    for (SectionMatcher matcher : sectionMatcherList) {
+      final AccessSection section = matcher.section;
+      for (Permission permission : section.getPermissions()) {
+        for (PermissionRule rule : permission.getRules()) {
+          all.add(rule.getGroup());
+        }
+      }
+    }
+    return all;
+  }
+
+  private ThemeInfo loadTheme() {
+    String name = getConfig().getProject().getName();
+    Path dir = sitePaths.themes_dir.resolve(name);
+    if (!Files.exists(dir)) {
+      return ThemeInfo.INHERIT;
+    } else if (!Files.isDirectory(dir)) {
+      log.warn("Bad theme for {}: not a directory", name);
+      return ThemeInfo.INHERIT;
+    }
+    try {
+      return new ThemeInfo(
+          readFile(dir.resolve(SitePaths.CSS_FILENAME)),
+          readFile(dir.resolve(SitePaths.HEADER_FILENAME)),
+          readFile(dir.resolve(SitePaths.FOOTER_FILENAME)));
+    } catch (IOException e) {
+      log.error("Error reading theme for " + name, e);
+      return ThemeInfo.INHERIT;
+    }
+  }
+
+  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;
+  }
+
+  private LabelTypes loadLabelTypes() {
+    Map<String, LabelType> types = new LinkedHashMap<>();
+    for (ProjectState s : treeInOrder()) {
+      for (LabelType type : s.getConfig().getLabelSections().values()) {
+        String lower = type.getName().toLowerCase();
+        LabelType old = types.get(lower);
+        if (old == null || old.canOverride()) {
+          types.put(lower, type);
+        }
+      }
+    }
+    List<LabelType> all = Lists.newArrayListWithCapacity(types.size());
+    for (LabelType type : types.values()) {
+      if (!type.getValues().isEmpty()) {
+        all.add(type);
+      }
+    }
+    return new LabelTypes(Collections.unmodifiableList(all));
+  }
+
+  private boolean match(Branch.NameKey destination, String refPattern, CurrentUser user) {
+    return RefPatternMatcher.getMatcher(refPattern).match(destination.get(), user);
+  }
+}
diff --git a/java/com/google/gerrit/server/project/Reachable.java b/java/com/google/gerrit/server/project/Reachable.java
new file mode 100644
index 0000000..42389d3
--- /dev/null
+++ b/java/com/google/gerrit/server/project/Reachable.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.project;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Maps;
+import com.google.gerrit.server.change.IncludedInResolver;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Report whether a commit is reachable from a set of commits. This is used for checking if a user
+ * has read permissions on a commit.
+ */
+public class Reachable {
+  private final VisibleRefFilter.Factory refFilter;
+  private static final Logger log = LoggerFactory.getLogger(Reachable.class);
+
+  @Inject
+  Reachable(VisibleRefFilter.Factory refFilter) {
+    this.refFilter = refFilter;
+  }
+
+  /** @return true if a commit is reachable from a given set of refs. */
+  public boolean fromRefs(
+      ProjectState state, Repository repo, RevCommit commit, Map<String, Ref> refs) {
+    try (RevWalk rw = new RevWalk(repo)) {
+      Map<String, Ref> filtered = refFilter.create(state, repo).filter(refs, true);
+      return IncludedInResolver.includedInAny(repo, rw, commit, filtered.values());
+    } catch (IOException e) {
+      log.error(
+          String.format(
+              "Cannot verify permissions to commit object %s in repository %s",
+              commit.name(), state.getNameKey()),
+          e);
+      return false;
+    }
+  }
+
+  /** @return true if a commit is reachable from a repo's branches and tags. */
+  boolean fromHeadsOrTags(ProjectState state, Repository repo, RevCommit commit) {
+    try {
+      RefDatabase refdb = repo.getRefDatabase();
+      Collection<Ref> heads = refdb.getRefs(Constants.R_HEADS).values();
+      Collection<Ref> tags = refdb.getRefs(Constants.R_TAGS).values();
+      Map<String, Ref> refs = Maps.newHashMapWithExpectedSize(heads.size() + tags.size());
+      for (Ref r : Iterables.concat(heads, tags)) {
+        refs.put(r.getName(), r);
+      }
+      return fromRefs(state, repo, commit, refs);
+    } catch (IOException e) {
+      log.error(
+          String.format(
+              "Cannot verify permissions to commit object %s in repository %s",
+              commit.name(), state.getProject().getNameKey()),
+          e);
+      return false;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/project/RefControl.java b/java/com/google/gerrit/server/project/RefControl.java
new file mode 100644
index 0000000..2fb818d
--- /dev/null
+++ b/java/com/google/gerrit/server/project/RefControl.java
@@ -0,0 +1,590 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.FailedPermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.util.Providers;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Manages access control for Git references (aka branches, tags). */
+class RefControl {
+  private final ProjectControl projectControl;
+  private final String refName;
+
+  /** All permissions that apply to this reference. */
+  private final PermissionCollection relevant;
+
+  /** Cached set of permissions matching this user. */
+  private final Map<String, List<PermissionRule>> effective;
+
+  private Boolean owner;
+  private Boolean canForgeAuthor;
+  private Boolean canForgeCommitter;
+  private Boolean isVisible;
+
+  RefControl(ProjectControl projectControl, String ref, PermissionCollection relevant) {
+    this.projectControl = projectControl;
+    this.refName = ref;
+    this.relevant = relevant;
+    this.effective = new HashMap<>();
+  }
+
+  ProjectControl getProjectControl() {
+    return projectControl;
+  }
+
+  CurrentUser getUser() {
+    return projectControl.getUser();
+  }
+
+  RefControl forUser(CurrentUser who) {
+    ProjectControl newCtl = projectControl.forUser(who);
+    if (relevant.isUserSpecific()) {
+      return newCtl.controlForRef(refName);
+    }
+    return new RefControl(newCtl, refName, relevant);
+  }
+
+  /** Is this user a ref owner? */
+  boolean isOwner() {
+    if (owner == null) {
+      if (canPerform(Permission.OWNER)) {
+        owner = true;
+
+      } else {
+        owner = projectControl.isOwner();
+      }
+    }
+    return owner;
+  }
+
+  /** Can this user see this reference exists? */
+  boolean isVisible() {
+    if (isVisible == null) {
+      isVisible =
+          (getUser().isInternalUser() || canPerform(Permission.READ))
+              && isProjectStatePermittingRead();
+    }
+    return isVisible;
+  }
+
+  /** @return true if this user can add a new patch set to this ref */
+  boolean canAddPatchSet() {
+    return projectControl
+        .controlForRef(MagicBranch.NEW_CHANGE + refName)
+        .canPerform(Permission.ADD_PATCH_SET);
+  }
+
+  /** @return true if this user can rebase changes on this ref */
+  boolean canRebase() {
+    return canPerform(Permission.REBASE) && isProjectStatePermittingWrite();
+  }
+
+  /** @return true if this user can submit patch sets to this ref */
+  boolean canSubmit(boolean isChangeOwner) {
+    if (RefNames.REFS_CONFIG.equals(refName)) {
+      // Always allow project owners to submit configuration changes.
+      // Submitting configuration changes modifies the access control
+      // rules. Allowing this to be done by a non-project-owner opens
+      // a security hole enabling editing of access rules, and thus
+      // granting of powers beyond submitting to the configuration.
+      return projectControl.isOwner();
+    }
+    return canPerform(Permission.SUBMIT, isChangeOwner) && isProjectStatePermittingWrite();
+  }
+
+  /** @return true if this user can abandon a change for this ref */
+  boolean canAbandon() {
+    return canPerform(Permission.ABANDON);
+  }
+
+  /** @return true if this user can view private changes. */
+  boolean canViewPrivateChanges() {
+    return canPerform(Permission.VIEW_PRIVATE_CHANGES);
+  }
+
+  /** @return true if this user can delete their own changes. */
+  boolean canDeleteOwnChanges() {
+    return canPerform(Permission.DELETE_OWN_CHANGES);
+  }
+
+  /** @return true if this user can edit topic names. */
+  boolean canEditTopicName() {
+    return canPerform(Permission.EDIT_TOPIC_NAME);
+  }
+
+  /** @return true if this user can edit hashtag names. */
+  boolean canEditHashtags() {
+    return canPerform(Permission.EDIT_HASHTAGS);
+  }
+
+  boolean canEditAssignee() {
+    return canPerform(Permission.EDIT_ASSIGNEE);
+  }
+
+  /** @return true if this user can force edit topic names. */
+  boolean canForceEditTopicName() {
+    return canForcePerform(Permission.EDIT_TOPIC_NAME);
+  }
+
+  /** The range of permitted values associated with a label permission. */
+  PermissionRange getRange(String permission) {
+    return getRange(permission, false);
+  }
+
+  /** The range of permitted values associated with a label permission. */
+  PermissionRange getRange(String permission, boolean isChangeOwner) {
+    if (Permission.hasRange(permission)) {
+      return toRange(permission, access(permission, isChangeOwner));
+    }
+    return null;
+  }
+
+  /** True if the user is blocked from using this permission. */
+  boolean isBlocked(String permissionName) {
+    return !doCanPerform(permissionName, false, true);
+  }
+
+  /** True if the user has this permission. Works only for non labels. */
+  boolean canPerform(String permissionName) {
+    return canPerform(permissionName, false);
+  }
+
+  boolean canPerform(String permissionName, boolean isChangeOwner) {
+    return doCanPerform(permissionName, isChangeOwner, false);
+  }
+
+  ForRef asForRef() {
+    return new ForRefImpl();
+  }
+
+  private boolean canUpload() {
+    return projectControl.controlForRef("refs/for/" + refName).canPerform(Permission.PUSH)
+        && isProjectStatePermittingWrite();
+  }
+
+  /** @return true if this user can submit merge patch sets to this ref */
+  private boolean canUploadMerges() {
+    return projectControl.controlForRef("refs/for/" + refName).canPerform(Permission.PUSH_MERGE)
+        && isProjectStatePermittingWrite();
+  }
+
+  /** @return true if the user can update the reference as a fast-forward. */
+  private boolean canUpdate() {
+    if (RefNames.REFS_CONFIG.equals(refName) && !projectControl.isOwner()) {
+      // Pushing requires being at least project owner, in addition to push.
+      // Pushing configuration changes modifies the access control
+      // rules. Allowing this to be done by a non-project-owner opens
+      // a security hole enabling editing of access rules, and thus
+      // granting of powers beyond pushing to the configuration.
+
+      // On the AllProjects project the owner access right cannot be assigned,
+      // this why for the AllProjects project we allow administrators to push
+      // configuration changes if they have push without being project owner.
+      if (!(projectControl.getProjectState().isAllProjects() && projectControl.isAdmin())) {
+        return false;
+      }
+    }
+    return canPerform(Permission.PUSH) && isProjectStatePermittingWrite();
+  }
+
+  /** @return true if the user can rewind (force push) the reference. */
+  private boolean canForceUpdate() {
+    if (!isProjectStatePermittingWrite()) {
+      return false;
+    }
+
+    if (canPushWithForce()) {
+      return true;
+    }
+
+    switch (getUser().getAccessPath()) {
+      case GIT:
+        return false;
+
+      case JSON_RPC:
+      case REST_API:
+      case SSH_COMMAND:
+      case UNKNOWN:
+      case WEB_BROWSER:
+      default:
+        return (isOwner() && !isForceBlocked(Permission.PUSH)) || projectControl.isAdmin();
+    }
+  }
+
+  private boolean isProjectStatePermittingWrite() {
+    return getProjectControl().getProject().getState().permitsWrite();
+  }
+
+  private boolean isProjectStatePermittingRead() {
+    return getProjectControl().getProject().getState().permitsRead();
+  }
+
+  private boolean canPushWithForce() {
+    if (!isProjectStatePermittingWrite()
+        || (RefNames.REFS_CONFIG.equals(refName) && !projectControl.isOwner())) {
+      // Pushing requires being at least project owner, in addition to push.
+      // Pushing configuration changes modifies the access control
+      // rules. Allowing this to be done by a non-project-owner opens
+      // a security hole enabling editing of access rules, and thus
+      // granting of powers beyond pushing to the configuration.
+      return false;
+    }
+    return canForcePerform(Permission.PUSH);
+  }
+
+  /**
+   * Determines whether the user can delete the Git ref controlled by this object.
+   *
+   * @return {@code true} if the user specified can delete a Git ref.
+   */
+  private boolean canDelete() {
+    if (!isProjectStatePermittingWrite() || (RefNames.REFS_CONFIG.equals(refName))) {
+      // Never allow removal of the refs/meta/config branch.
+      // Deleting the branch would destroy all Gerrit specific
+      // metadata about the project, including its access rules.
+      // If a project is to be removed from Gerrit, its repository
+      // should be removed first.
+      return false;
+    }
+
+    switch (getUser().getAccessPath()) {
+      case GIT:
+        return canPushWithForce() || canPerform(Permission.DELETE);
+
+      case JSON_RPC:
+      case REST_API:
+      case SSH_COMMAND:
+      case UNKNOWN:
+      case WEB_BROWSER:
+      default:
+        return (isOwner() && !isForceBlocked(Permission.PUSH))
+            || canPushWithForce()
+            || canPerform(Permission.DELETE)
+            || projectControl.isAdmin();
+    }
+  }
+
+  /** @return true if this user can forge the author line in a commit. */
+  private boolean canForgeAuthor() {
+    if (canForgeAuthor == null) {
+      canForgeAuthor = canPerform(Permission.FORGE_AUTHOR);
+    }
+    return canForgeAuthor;
+  }
+
+  /** @return true if this user can forge the committer line in a commit. */
+  private boolean canForgeCommitter() {
+    if (canForgeCommitter == null) {
+      canForgeCommitter = canPerform(Permission.FORGE_COMMITTER);
+    }
+    return canForgeCommitter;
+  }
+
+  /** @return true if this user can forge the server on the committer line. */
+  private boolean canForgeGerritServerIdentity() {
+    return canPerform(Permission.FORGE_SERVER);
+  }
+
+  private static class AllowedRange {
+    private int allowMin;
+    private int allowMax;
+    private int blockMin = Integer.MIN_VALUE;
+    private int blockMax = Integer.MAX_VALUE;
+
+    void update(PermissionRule rule) {
+      if (rule.isBlock()) {
+        blockMin = Math.max(blockMin, rule.getMin());
+        blockMax = Math.min(blockMax, rule.getMax());
+      } else {
+        allowMin = Math.min(allowMin, rule.getMin());
+        allowMax = Math.max(allowMax, rule.getMax());
+      }
+    }
+
+    int getAllowMin() {
+      return allowMin;
+    }
+
+    int getAllowMax() {
+      return allowMax;
+    }
+
+    int getBlockMin() {
+      // ALLOW wins over BLOCK on the same project
+      return Math.min(blockMin, allowMin - 1);
+    }
+
+    int getBlockMax() {
+      // ALLOW wins over BLOCK on the same project
+      return Math.max(blockMax, allowMax + 1);
+    }
+  }
+
+  private PermissionRange toRange(String permissionName, List<PermissionRule> ruleList) {
+    Map<ProjectRef, AllowedRange> ranges = new HashMap<>();
+    for (PermissionRule rule : ruleList) {
+      ProjectRef p = relevant.getRuleProps(rule);
+      AllowedRange r = ranges.get(p);
+      if (r == null) {
+        r = new AllowedRange();
+        ranges.put(p, r);
+      }
+      r.update(rule);
+    }
+    int allowMin = 0;
+    int allowMax = 0;
+    int blockMin = Integer.MIN_VALUE;
+    int blockMax = Integer.MAX_VALUE;
+    for (AllowedRange r : ranges.values()) {
+      allowMin = Math.min(allowMin, r.getAllowMin());
+      allowMax = Math.max(allowMax, r.getAllowMax());
+      blockMin = Math.max(blockMin, r.getBlockMin());
+      blockMax = Math.min(blockMax, r.getBlockMax());
+    }
+
+    // BLOCK wins over ALLOW across projects
+    int min = Math.max(allowMin, blockMin + 1);
+    int max = Math.min(allowMax, blockMax - 1);
+    return new PermissionRange(permissionName, min, max);
+  }
+
+  private boolean doCanPerform(String permissionName, boolean isChangeOwner, boolean blockOnly) {
+    List<PermissionRule> access = access(permissionName, isChangeOwner);
+    List<PermissionRule> overridden = relevant.getOverridden(permissionName);
+    Set<ProjectRef> allows = new HashSet<>();
+    Set<ProjectRef> blocks = new HashSet<>();
+    for (PermissionRule rule : access) {
+      if (rule.isBlock() && !rule.getForce()) {
+        blocks.add(relevant.getRuleProps(rule));
+      } else {
+        allows.add(relevant.getRuleProps(rule));
+      }
+    }
+    for (PermissionRule rule : overridden) {
+      blocks.remove(relevant.getRuleProps(rule));
+    }
+    blocks.removeAll(allows);
+    return blocks.isEmpty() && (!allows.isEmpty() || blockOnly);
+  }
+
+  /** True if the user has force this permission. Works only for non labels. */
+  private boolean canForcePerform(String permissionName) {
+    List<PermissionRule> access = access(permissionName);
+    List<PermissionRule> overridden = relevant.getOverridden(permissionName);
+    Set<ProjectRef> allows = new HashSet<>();
+    Set<ProjectRef> blocks = new HashSet<>();
+    for (PermissionRule rule : access) {
+      if (rule.isBlock()) {
+        blocks.add(relevant.getRuleProps(rule));
+      } else if (rule.getForce()) {
+        allows.add(relevant.getRuleProps(rule));
+      }
+    }
+    for (PermissionRule rule : overridden) {
+      if (rule.getForce()) {
+        blocks.remove(relevant.getRuleProps(rule));
+      }
+    }
+    blocks.removeAll(allows);
+    return blocks.isEmpty() && !allows.isEmpty();
+  }
+
+  /** True if for this permission force is blocked for the user. Works only for non labels. */
+  private boolean isForceBlocked(String permissionName) {
+    List<PermissionRule> access = access(permissionName);
+    List<PermissionRule> overridden = relevant.getOverridden(permissionName);
+    Set<ProjectRef> allows = new HashSet<>();
+    Set<ProjectRef> blocks = new HashSet<>();
+    for (PermissionRule rule : access) {
+      if (rule.isBlock()) {
+        blocks.add(relevant.getRuleProps(rule));
+      } else if (rule.getForce()) {
+        allows.add(relevant.getRuleProps(rule));
+      }
+    }
+    for (PermissionRule rule : overridden) {
+      if (rule.getForce()) {
+        blocks.remove(relevant.getRuleProps(rule));
+      }
+    }
+    blocks.removeAll(allows);
+    return !blocks.isEmpty();
+  }
+
+  /** Rules for the given permission, or the empty list. */
+  private List<PermissionRule> access(String permissionName) {
+    return access(permissionName, false);
+  }
+
+  /** Rules for the given permission, or the empty list. */
+  private List<PermissionRule> access(String permissionName, boolean isChangeOwner) {
+    List<PermissionRule> rules = effective.get(permissionName);
+    if (rules != null) {
+      return rules;
+    }
+
+    rules = relevant.getPermission(permissionName);
+
+    List<PermissionRule> mine = new ArrayList<>(rules.size());
+    for (PermissionRule rule : rules) {
+      if (projectControl.match(rule, isChangeOwner)) {
+        mine.add(rule);
+      }
+    }
+
+    if (mine.isEmpty()) {
+      mine = Collections.emptyList();
+    }
+    effective.put(permissionName, mine);
+    return mine;
+  }
+
+  private class ForRefImpl extends ForRef {
+    @Override
+    public ForRef user(CurrentUser user) {
+      return forUser(user).asForRef().database(db);
+    }
+
+    @Override
+    public ForChange change(ChangeData cd) {
+      try {
+        // TODO(hiesel) Force callers to call database() and use db instead of cd.db()
+        return getProjectControl()
+            .controlFor(cd.db(), cd.change())
+            .asForChange(cd, Providers.of(cd.db()));
+      } catch (OrmException e) {
+        return FailedPermissionBackend.change("unavailable", e);
+      }
+    }
+
+    @Override
+    public ForChange change(ChangeNotes notes) {
+      Project.NameKey project = getProjectControl().getProject().getNameKey();
+      Change change = notes.getChange();
+      checkArgument(
+          project.equals(change.getProject()),
+          "expected change in project %s, not %s",
+          project,
+          change.getProject());
+      return getProjectControl().controlFor(notes).asForChange(null, db);
+    }
+
+    @Override
+    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
+      return getProjectControl().controlFor(notes).asForChange(cd, db);
+    }
+
+    @Override
+    public void check(RefPermission perm) throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted for " + refName);
+      }
+    }
+
+    @Override
+    public Set<RefPermission> test(Collection<RefPermission> permSet)
+        throws PermissionBackendException {
+      EnumSet<RefPermission> ok = EnumSet.noneOf(RefPermission.class);
+      for (RefPermission perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(RefPermission perm) throws PermissionBackendException {
+      switch (perm) {
+        case READ:
+          return isVisible();
+        case CREATE:
+          // TODO This isn't an accurate test.
+          return canPerform(perm.permissionName().get());
+        case DELETE:
+          return canDelete();
+        case UPDATE:
+          return canUpdate();
+        case FORCE_UPDATE:
+          return canForceUpdate();
+        case SET_HEAD:
+          return projectControl.isOwner();
+
+        case FORGE_AUTHOR:
+          return canForgeAuthor();
+        case FORGE_COMMITTER:
+          return canForgeCommitter();
+        case FORGE_SERVER:
+          return canForgeGerritServerIdentity();
+        case MERGE:
+          return canUploadMerges();
+
+        case CREATE_CHANGE:
+          return canUpload();
+
+        case CREATE_TAG:
+          return canPerform(Permission.CREATE_TAG);
+
+        case UPDATE_BY_SUBMIT:
+          return projectControl.controlForRef(MagicBranch.NEW_CHANGE + refName).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()
+              && canForgeGerritServerIdentity()
+              && canUploadMerges()
+              && !projectControl.getProjectState().is(BooleanProjectConfig.USE_SIGNED_OFF_BY);
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefFilter.java b/java/com/google/gerrit/server/project/RefFilter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/RefFilter.java
rename to java/com/google/gerrit/server/project/RefFilter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java b/java/com/google/gerrit/server/project/RefPattern.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java
rename to java/com/google/gerrit/server/project/RefPattern.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPatternMatcher.java b/java/com/google/gerrit/server/project/RefPatternMatcher.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/RefPatternMatcher.java
rename to java/com/google/gerrit/server/project/RefPatternMatcher.java
diff --git a/java/com/google/gerrit/server/project/RefResource.java b/java/com/google/gerrit/server/project/RefResource.java
new file mode 100644
index 0000000..ac2735d
--- /dev/null
+++ b/java/com/google/gerrit/server/project/RefResource.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.server.CurrentUser;
+
+public abstract class RefResource extends ProjectResource {
+
+  public RefResource(ProjectState projectState, CurrentUser user) {
+    super(projectState, user);
+  }
+
+  /** @return the ref's name */
+  public abstract String getRef();
+
+  /** @return the ref's revision */
+  public abstract String getRevision();
+}
diff --git a/java/com/google/gerrit/server/project/RefUtil.java b/java/com/google/gerrit/server/project/RefUtil.java
new file mode 100644
index 0000000..62e48be
--- /dev/null
+++ b/java/com/google/gerrit/server/project/RefUtil.java
@@ -0,0 +1,132 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static org.eclipse.jgit.lib.Constants.R_REFS;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import java.io.IOException;
+import java.util.Collections;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.RevisionSyntaxException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.ObjectWalk;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class RefUtil {
+  private static final Logger log = LoggerFactory.getLogger(RefUtil.class);
+
+  public static ObjectId parseBaseRevision(
+      Repository repo, Project.NameKey projectName, String baseRevision)
+      throws InvalidRevisionException {
+    try {
+      ObjectId revid = repo.resolve(baseRevision);
+      if (revid == null) {
+        throw new InvalidRevisionException();
+      }
+      return revid;
+    } catch (IOException err) {
+      log.error(
+          "Cannot resolve \"" + baseRevision + "\" in project \"" + projectName.get() + "\"", err);
+      throw new InvalidRevisionException();
+    } catch (RevisionSyntaxException err) {
+      log.error("Invalid revision syntax \"" + baseRevision + "\"", err);
+      throw new InvalidRevisionException();
+    }
+  }
+
+  public static RevWalk verifyConnected(Repository repo, ObjectId revid)
+      throws InvalidRevisionException {
+    try {
+      ObjectWalk rw = new ObjectWalk(repo);
+      try {
+        rw.markStart(rw.parseCommit(revid));
+      } catch (IncorrectObjectTypeException err) {
+        throw new InvalidRevisionException();
+      }
+      RefDatabase refDb = repo.getRefDatabase();
+      Iterable<Ref> refs =
+          Iterables.concat(
+              refDb.getRefs(Constants.R_HEADS).values(), refDb.getRefs(Constants.R_TAGS).values());
+      Ref rc = refDb.exactRef(RefNames.REFS_CONFIG);
+      if (rc != null) {
+        refs = Iterables.concat(refs, Collections.singleton(rc));
+      }
+      for (Ref r : refs) {
+        try {
+          rw.markUninteresting(rw.parseAny(r.getObjectId()));
+        } catch (MissingObjectException err) {
+          continue;
+        }
+      }
+      rw.checkConnectivity();
+      return rw;
+    } catch (IncorrectObjectTypeException | MissingObjectException err) {
+      throw new InvalidRevisionException();
+    } catch (IOException err) {
+      log.error(
+          "Repository \"" + repo.getDirectory() + "\" may be corrupt; suggest running git fsck",
+          err);
+      throw new InvalidRevisionException();
+    }
+  }
+
+  public static String getRefPrefix(String refName) {
+    int i = refName.lastIndexOf('/');
+    if (i > Constants.R_HEADS.length() - 1) {
+      return refName.substring(0, i);
+    }
+    return Constants.R_HEADS;
+  }
+
+  public static String normalizeTagRef(String tag) throws BadRequestException {
+    String result = tag;
+    while (result.startsWith("/")) {
+      result = result.substring(1);
+    }
+    if (result.startsWith(R_REFS) && !result.startsWith(R_TAGS)) {
+      throw new BadRequestException("invalid tag name \"" + result + "\"");
+    }
+    if (!result.startsWith(R_TAGS)) {
+      result = R_TAGS + result;
+    }
+    if (!Repository.isValidRefName(result)) {
+      throw new BadRequestException("invalid tag name \"" + result + "\"");
+    }
+    return result;
+  }
+
+  /** Error indicating the revision is invalid as supplied. */
+  public static class InvalidRevisionException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    public static final String MESSAGE = "Invalid Revision";
+
+    InvalidRevisionException() {
+      super(MESSAGE);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefValidationHelper.java b/java/com/google/gerrit/server/project/RefValidationHelper.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/RefValidationHelper.java
rename to java/com/google/gerrit/server/project/RefValidationHelper.java
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
new file mode 100644
index 0000000..e91d36e
--- /dev/null
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class RemoveReviewerControl {
+  private final PermissionBackend permissionBackend;
+  private final Provider<ReviewDb> dbProvider;
+  private final ProjectCache projectCache;
+
+  @Inject
+  RemoveReviewerControl(
+      PermissionBackend permissionBackend,
+      Provider<ReviewDb> dbProvider,
+      ProjectCache projectCache) {
+    this.permissionBackend = permissionBackend;
+    this.dbProvider = dbProvider;
+    this.projectCache = projectCache;
+  }
+
+  /**
+   * Checks if removing the given reviewer and patch set approval is OK.
+   *
+   * @throws AuthException if this user is not allowed to remove this approval.
+   */
+  public void checkRemoveReviewer(
+      ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
+      throws PermissionBackendException, AuthException, NoSuchProjectException, IOException {
+    checkRemoveReviewer(notes, currentUser, approval.getAccountId(), approval.getValue());
+  }
+
+  /**
+   * Checks if removing the given reviewer is OK. Does not check if removing any approvals the
+   * reviewer might have given is OK.
+   *
+   * @throws AuthException if this user is not allowed to remove this approval.
+   */
+  public void checkRemoveReviewer(ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer)
+      throws PermissionBackendException, AuthException, NoSuchProjectException, IOException {
+    checkRemoveReviewer(notes, currentUser, reviewer, 0);
+  }
+
+  /** @return true if the user is allowed to remove this reviewer. */
+  public boolean testRemoveReviewer(
+      ChangeData cd, CurrentUser currentUser, Account.Id reviewer, int value)
+      throws PermissionBackendException, NoSuchProjectException, OrmException, IOException {
+    if (canRemoveReviewerWithoutPermissionCheck(cd.change(), currentUser, reviewer, value)) {
+      return true;
+    }
+    return permissionBackend
+        .user(currentUser)
+        .change(cd)
+        .database(dbProvider)
+        .test(ChangePermission.REMOVE_REVIEWER);
+  }
+
+  private void checkRemoveReviewer(
+      ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int val)
+      throws PermissionBackendException, AuthException, NoSuchProjectException, IOException {
+    if (canRemoveReviewerWithoutPermissionCheck(notes.getChange(), currentUser, reviewer, val)) {
+      return;
+    }
+
+    permissionBackend
+        .user(currentUser)
+        .change(notes)
+        .database(dbProvider)
+        .check(ChangePermission.REMOVE_REVIEWER);
+  }
+
+  private boolean canRemoveReviewerWithoutPermissionCheck(
+      Change change, CurrentUser currentUser, Account.Id reviewer, int value)
+      throws NoSuchProjectException, IOException {
+    if (!change.getStatus().isOpen()) {
+      return false;
+    }
+
+    if (currentUser.isIdentifiedUser()) {
+      Account.Id aId = currentUser.getAccountId();
+      if (aId.equals(reviewer)) {
+        return true; // A user can always remove themselves.
+      } else if (aId.equals(change.getOwner()) && 0 <= value) {
+        return true; // The change owner may remove any zero or positive score.
+      }
+    }
+
+    // Users with the remove reviewer permission, the branch owner, project
+    // owner and site admin can remove anyone
+    // TODO(hiesel): Remove all Control usage
+    ProjectState projectState = projectCache.checkedGet(change.getProject());
+    if (projectState == null) {
+      throw new NoSuchProjectException(change.getProject());
+    }
+    ProjectControl ctl = projectState.controlFor(currentUser);
+    if (ctl.controlForRef(change.getDest()).isOwner() // branch owner
+        || ctl.isOwner() // project owner
+        || ctl.isAdmin()) { // project admin
+      return true;
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RuleEvalException.java b/java/com/google/gerrit/server/project/RuleEvalException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/RuleEvalException.java
rename to java/com/google/gerrit/server/project/RuleEvalException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SectionMatcher.java b/java/com/google/gerrit/server/project/SectionMatcher.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/SectionMatcher.java
rename to java/com/google/gerrit/server/project/SectionMatcher.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SectionSortCache.java b/java/com/google/gerrit/server/project/SectionSortCache.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/SectionSortCache.java
rename to java/com/google/gerrit/server/project/SectionSortCache.java
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
new file mode 100644
index 0000000..a8434b9
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -0,0 +1,652 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.PrologEnvironment;
+import com.google.gerrit.server.rules.StoredValues;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import com.googlecode.prolog_cafe.exceptions.CompileException;
+import com.googlecode.prolog_cafe.exceptions.ReductionLimitException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.ListTerm;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import com.googlecode.prolog_cafe.lang.VariableTerm;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Evaluates a submit-like Prolog rule found in the rules.pl file of the current project and filters
+ * the results through rules found in the parent projects, all the way up to All-Projects.
+ */
+public class SubmitRuleEvaluator {
+  private static final Logger log = LoggerFactory.getLogger(SubmitRuleEvaluator.class);
+
+  private static final String DEFAULT_MSG = "Error evaluating project rules, check server log";
+
+  public static List<SubmitRecord> defaultRuleError() {
+    return createRuleError(DEFAULT_MSG);
+  }
+
+  public static List<SubmitRecord> createRuleError(String err) {
+    SubmitRecord rec = new SubmitRecord();
+    rec.status = SubmitRecord.Status.RULE_ERROR;
+    rec.errorMessage = err;
+    return Collections.singletonList(rec);
+  }
+
+  public static SubmitTypeRecord defaultTypeError() {
+    return SubmitTypeRecord.error(DEFAULT_MSG);
+  }
+
+  /**
+   * Exception thrown when the label term of a submit record unexpectedly didn't contain a user
+   * term.
+   */
+  private static class UserTermExpected extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    UserTermExpected(SubmitRecord.Label label) {
+      super(String.format("A label with the status %s must contain a user.", label.toString()));
+    }
+  }
+
+  public interface Factory {
+    SubmitRuleEvaluator create(CurrentUser user, ChangeData cd);
+  }
+
+  private final AccountCache accountCache;
+  private final Accounts accounts;
+  private final Emails emails;
+  private final ProjectCache projectCache;
+  private final ChangeData cd;
+
+  private SubmitRuleOptions.Builder optsBuilder = SubmitRuleOptions.builder();
+  private SubmitRuleOptions opts;
+  private Change change;
+  private CurrentUser user;
+  private PatchSet patchSet;
+  private boolean logErrors = true;
+  private long reductionsConsumed;
+  private ProjectState projectState;
+
+  private Term submitRule;
+
+  @Inject
+  SubmitRuleEvaluator(
+      AccountCache accountCache,
+      Accounts accounts,
+      Emails emails,
+      ProjectCache projectCache,
+      @Assisted CurrentUser user,
+      @Assisted ChangeData cd) {
+    this.accountCache = accountCache;
+    this.accounts = accounts;
+    this.emails = emails;
+    this.projectCache = projectCache;
+    this.user = user;
+    this.cd = cd;
+  }
+
+  /**
+   * @return immutable snapshot of options configured so far. If neither {@link #getSubmitRule()}
+   *     nor {@link #getSubmitType()} have been called yet, state within this instance is still
+   *     mutable, so may change before evaluation. The instance's options are frozen at evaluation
+   *     time.
+   */
+  public SubmitRuleOptions getOptions() {
+    if (opts != null) {
+      return opts;
+    }
+    return optsBuilder.build();
+  }
+
+  public SubmitRuleEvaluator setOptions(SubmitRuleOptions opts) {
+    checkNotStarted();
+    if (opts != null) {
+      optsBuilder = opts.toBuilder();
+    } else {
+      optsBuilder = SubmitRuleOptions.builder();
+    }
+    return this;
+  }
+
+  /**
+   * @param ps patch set of the change to evaluate. If not set, the current patch set will be loaded
+   *     from {@link #evaluate()} or {@link #getSubmitType}.
+   * @return this
+   */
+  public SubmitRuleEvaluator setPatchSet(PatchSet ps) {
+    checkArgument(
+        ps.getId().getParentKey().equals(cd.getId()),
+        "Patch set %s does not match change %s",
+        ps.getId(),
+        cd.getId());
+    patchSet = ps;
+    return this;
+  }
+
+  /**
+   * @param allow whether to allow {@link #evaluate()} on closed changes.
+   * @return this
+   */
+  public SubmitRuleEvaluator setAllowClosed(boolean allow) {
+    checkNotStarted();
+    optsBuilder.allowClosed(allow);
+    return this;
+  }
+
+  /**
+   * @param skip if true, submit filter will not be applied.
+   * @return this
+   */
+  public SubmitRuleEvaluator setSkipSubmitFilters(boolean skip) {
+    checkNotStarted();
+    optsBuilder.skipFilters(skip);
+    return this;
+  }
+
+  /**
+   * @param rule custom rule to use, or null to use refs/meta/config:rules.pl.
+   * @return this
+   */
+  public SubmitRuleEvaluator setRule(@Nullable String rule) {
+    checkNotStarted();
+    optsBuilder.rule(rule);
+    return this;
+  }
+
+  /**
+   * @param log whether to log error messages in addition to returning error records. If true, error
+   *     record messages will be less descriptive.
+   */
+  public SubmitRuleEvaluator setLogErrors(boolean log) {
+    logErrors = log;
+    return this;
+  }
+
+  /** @return Prolog reductions consumed during evaluation. */
+  public long getReductionsConsumed() {
+    return reductionsConsumed;
+  }
+
+  /**
+   * Evaluate the submit rules.
+   *
+   * @return List of {@link SubmitRecord} objects returned from the evaluated rules, including any
+   *     errors.
+   */
+  public List<SubmitRecord> evaluate() {
+    initOptions();
+    try {
+      init();
+    } catch (OrmException | NoSuchProjectException e) {
+      return ruleError("Error looking up change " + cd.getId(), e);
+    }
+
+    if (!opts.allowClosed() && change.getStatus().isClosed()) {
+      SubmitRecord rec = new SubmitRecord();
+      rec.status = SubmitRecord.Status.CLOSED;
+      return Collections.singletonList(rec);
+    }
+
+    List<Term> results;
+    try {
+      results =
+          evaluateImpl(
+              "locate_submit_rule",
+              "can_submit",
+              "locate_submit_filter",
+              "filter_submit_results",
+              user);
+    } catch (RuleEvalException e) {
+      return ruleError(e.getMessage(), e);
+    }
+
+    if (results.isEmpty()) {
+      // This should never occur. A well written submit rule will always produce
+      // at least one result informing the caller of the labels that are
+      // required for this change to be submittable. Each label will indicate
+      // whether or not that is actually possible given the permissions.
+      return ruleError(
+          String.format(
+              "Submit rule '%s' for change %s of %s has no solution.",
+              getSubmitRuleName(), cd.getId(), getProjectName()));
+    }
+
+    return resultsToSubmitRecord(getSubmitRule(), results);
+  }
+
+  /**
+   * Convert the results from Prolog Cafe's format to Gerrit's common format.
+   *
+   * <p>can_submit/1 terminates when an ok(P) record is found. Therefore walk the results backwards,
+   * using only that ok(P) record if it exists. This skips partial results that occur early in the
+   * output. Later after the loop the out collection is reversed to restore it to the original
+   * ordering.
+   */
+  private List<SubmitRecord> resultsToSubmitRecord(Term submitRule, List<Term> results) {
+    List<SubmitRecord> out = new ArrayList<>(results.size());
+    for (int resultIdx = results.size() - 1; 0 <= resultIdx; resultIdx--) {
+      Term submitRecord = results.get(resultIdx);
+      SubmitRecord rec = new SubmitRecord();
+      out.add(rec);
+
+      if (!(submitRecord instanceof StructureTerm) || 1 != submitRecord.arity()) {
+        return invalidResult(submitRule, submitRecord);
+      }
+
+      if ("ok".equals(submitRecord.name())) {
+        rec.status = SubmitRecord.Status.OK;
+
+      } else if ("not_ready".equals(submitRecord.name())) {
+        rec.status = SubmitRecord.Status.NOT_READY;
+
+      } else {
+        return invalidResult(submitRule, submitRecord);
+      }
+
+      // Unpack the one argument. This should also be a structure with one
+      // argument per label that needs to be reported on to the caller.
+      //
+      submitRecord = submitRecord.arg(0);
+
+      if (!(submitRecord instanceof StructureTerm)) {
+        return invalidResult(submitRule, submitRecord);
+      }
+
+      rec.labels = new ArrayList<>(submitRecord.arity());
+
+      for (Term state : ((StructureTerm) submitRecord).args()) {
+        if (!(state instanceof StructureTerm)
+            || 2 != state.arity()
+            || !"label".equals(state.name())) {
+          return invalidResult(submitRule, submitRecord);
+        }
+
+        SubmitRecord.Label lbl = new SubmitRecord.Label();
+        rec.labels.add(lbl);
+
+        lbl.label = state.arg(0).name();
+        Term status = state.arg(1);
+
+        try {
+          if ("ok".equals(status.name())) {
+            lbl.status = SubmitRecord.Label.Status.OK;
+            appliedBy(lbl, status);
+
+          } else if ("reject".equals(status.name())) {
+            lbl.status = SubmitRecord.Label.Status.REJECT;
+            appliedBy(lbl, status);
+
+          } else if ("need".equals(status.name())) {
+            lbl.status = SubmitRecord.Label.Status.NEED;
+
+          } else if ("may".equals(status.name())) {
+            lbl.status = SubmitRecord.Label.Status.MAY;
+
+          } else if ("impossible".equals(status.name())) {
+            lbl.status = SubmitRecord.Label.Status.IMPOSSIBLE;
+
+          } else {
+            return invalidResult(submitRule, submitRecord);
+          }
+        } catch (UserTermExpected e) {
+          return invalidResult(submitRule, submitRecord, e.getMessage());
+        }
+      }
+
+      if (rec.status == SubmitRecord.Status.OK) {
+        break;
+      }
+    }
+    Collections.reverse(out);
+
+    return out;
+  }
+
+  private List<SubmitRecord> invalidResult(Term rule, Term record, String reason) {
+    return ruleError(
+        String.format(
+            "Submit rule %s for change %s of %s output invalid result: %s%s",
+            rule,
+            cd.getId(),
+            getProjectName(),
+            record,
+            (reason == null ? "" : ". Reason: " + reason)));
+  }
+
+  private List<SubmitRecord> invalidResult(Term rule, Term record) {
+    return invalidResult(rule, record, null);
+  }
+
+  private List<SubmitRecord> ruleError(String err) {
+    return ruleError(err, null);
+  }
+
+  private List<SubmitRecord> ruleError(String err, Exception e) {
+    if (logErrors) {
+      if (e == null) {
+        log.error(err);
+      } else {
+        log.error(err, e);
+      }
+      return defaultRuleError();
+    }
+    return createRuleError(err);
+  }
+
+  /**
+   * Evaluate the submit type rules to get the submit type.
+   *
+   * @return record from the evaluated rules.
+   */
+  public SubmitTypeRecord getSubmitType() {
+    initOptions();
+    try {
+      init();
+    } catch (OrmException | NoSuchProjectException e) {
+      return typeError("Error looking up change " + cd.getId(), e);
+    }
+
+    List<Term> results;
+    try {
+      results =
+          evaluateImpl(
+              "locate_submit_type",
+              "get_submit_type",
+              "locate_submit_type_filter",
+              "filter_submit_type_results",
+              // Do not include current user in submit type evaluation. This is used
+              // for mergeability checks, which are stored persistently and so must
+              // have a consistent view of the submit type.
+              null);
+    } catch (RuleEvalException e) {
+      return typeError(e.getMessage(), e);
+    }
+
+    if (results.isEmpty()) {
+      // Should never occur for a well written rule
+      return typeError(
+          "Submit rule '"
+              + getSubmitRuleName()
+              + "' for change "
+              + cd.getId()
+              + " of "
+              + getProjectName()
+              + " has no solution.");
+    }
+
+    Term typeTerm = results.get(0);
+    if (!(typeTerm instanceof SymbolTerm)) {
+      return typeError(
+          "Submit rule '"
+              + getSubmitRuleName()
+              + "' for change "
+              + cd.getId()
+              + " of "
+              + getProjectName()
+              + " did not return a symbol.");
+    }
+
+    String typeName = ((SymbolTerm) typeTerm).name();
+    try {
+      return SubmitTypeRecord.OK(SubmitType.valueOf(typeName.toUpperCase()));
+    } catch (IllegalArgumentException e) {
+      return typeError(
+          "Submit type rule "
+              + getSubmitRule()
+              + " for change "
+              + cd.getId()
+              + " of "
+              + getProjectName()
+              + " output invalid result: "
+              + typeName);
+    }
+  }
+
+  private SubmitTypeRecord typeError(String err) {
+    return typeError(err, null);
+  }
+
+  private SubmitTypeRecord typeError(String err, Exception e) {
+    if (logErrors) {
+      if (e == null) {
+        log.error(err);
+      } else {
+        log.error(err, e);
+      }
+      return defaultTypeError();
+    }
+    return SubmitTypeRecord.error(err);
+  }
+
+  private List<Term> evaluateImpl(
+      String userRuleLocatorName,
+      String userRuleWrapperName,
+      String filterRuleLocatorName,
+      String filterRuleWrapperName,
+      CurrentUser user)
+      throws RuleEvalException {
+    PrologEnvironment env = getPrologEnvironment(user);
+    try {
+      Term sr = env.once("gerrit", userRuleLocatorName, new VariableTerm());
+      List<Term> results = new ArrayList<>();
+      try {
+        for (Term[] template : env.all("gerrit", userRuleWrapperName, sr, new VariableTerm())) {
+          results.add(template[1]);
+        }
+      } catch (ReductionLimitException err) {
+        throw new RuleEvalException(
+            String.format(
+                "%s on change %d of %s", err.getMessage(), cd.getId().get(), getProjectName()));
+      } catch (RuntimeException err) {
+        throw new RuleEvalException(
+            String.format(
+                "Exception calling %s on change %d of %s", sr, cd.getId().get(), getProjectName()),
+            err);
+      } finally {
+        reductionsConsumed = env.getReductions();
+      }
+
+      Term resultsTerm = toListTerm(results);
+      if (!opts.skipFilters()) {
+        resultsTerm =
+            runSubmitFilters(resultsTerm, env, filterRuleLocatorName, filterRuleWrapperName);
+      }
+      List<Term> r;
+      if (resultsTerm instanceof ListTerm) {
+        r = new ArrayList<>();
+        for (Term t = resultsTerm; t instanceof ListTerm; ) {
+          ListTerm l = (ListTerm) t;
+          r.add(l.car().dereference());
+          t = l.cdr().dereference();
+        }
+      } else {
+        r = Collections.emptyList();
+      }
+      submitRule = sr;
+      return r;
+    } finally {
+      env.close();
+    }
+  }
+
+  private PrologEnvironment getPrologEnvironment(CurrentUser user) throws RuleEvalException {
+    PrologEnvironment env;
+    try {
+      if (opts.rule() == null) {
+        env = projectState.newPrologEnvironment();
+      } else {
+        env = projectState.newPrologEnvironment("stdin", new StringReader(opts.rule()));
+      }
+    } catch (CompileException err) {
+      String msg;
+      if (opts.rule() == null) {
+        msg = String.format("Cannot load rules.pl for %s: %s", getProjectName(), err.getMessage());
+      } else {
+        msg = err.getMessage();
+      }
+      throw new RuleEvalException(msg, err);
+    }
+    env.set(StoredValues.ACCOUNTS, accounts);
+    env.set(StoredValues.ACCOUNT_CACHE, accountCache);
+    env.set(StoredValues.EMAILS, emails);
+    env.set(StoredValues.REVIEW_DB, cd.db());
+    env.set(StoredValues.CHANGE_DATA, cd);
+    if (user != null) {
+      env.set(StoredValues.CURRENT_USER, user);
+    }
+    env.set(StoredValues.PROJECT_STATE, projectState);
+    return env;
+  }
+
+  private Term runSubmitFilters(
+      Term results,
+      PrologEnvironment env,
+      String filterRuleLocatorName,
+      String filterRuleWrapperName)
+      throws RuleEvalException {
+    PrologEnvironment childEnv = env;
+    for (ProjectState parentState : projectState.parents()) {
+      PrologEnvironment parentEnv;
+      try {
+        parentEnv = parentState.newPrologEnvironment();
+      } catch (CompileException err) {
+        throw new RuleEvalException("Cannot consult rules.pl for " + parentState.getName(), err);
+      }
+
+      parentEnv.copyStoredValues(childEnv);
+      Term filterRule = parentEnv.once("gerrit", filterRuleLocatorName, new VariableTerm());
+      try {
+        Term[] template =
+            parentEnv.once(
+                "gerrit", filterRuleWrapperName, filterRule, results, new VariableTerm());
+        results = template[2];
+      } catch (ReductionLimitException err) {
+        throw new RuleEvalException(
+            String.format(
+                "%s on change %d of %s",
+                err.getMessage(), cd.getId().get(), parentState.getName()));
+      } catch (RuntimeException err) {
+        throw new RuleEvalException(
+            String.format(
+                "Exception calling %s on change %d of %s",
+                filterRule, cd.getId().get(), parentState.getName()),
+            err);
+      } finally {
+        reductionsConsumed += env.getReductions();
+      }
+      childEnv = parentEnv;
+    }
+    return results;
+  }
+
+  private static Term toListTerm(List<Term> terms) {
+    Term list = Prolog.Nil;
+    for (int i = terms.size() - 1; i >= 0; i--) {
+      list = new ListTerm(terms.get(i), list);
+    }
+    return list;
+  }
+
+  private void appliedBy(SubmitRecord.Label label, Term status) throws UserTermExpected {
+    if (status instanceof StructureTerm && status.arity() == 1) {
+      Term who = status.arg(0);
+      if (isUser(who)) {
+        label.appliedBy = new Account.Id(((IntegerTerm) who.arg(0)).intValue());
+      } else {
+        throw new UserTermExpected(label);
+      }
+    }
+  }
+
+  private static boolean isUser(Term who) {
+    return who instanceof StructureTerm
+        && who.arity() == 1
+        && who.name().equals("user")
+        && who.arg(0) instanceof IntegerTerm;
+  }
+
+  public Term getSubmitRule() {
+    checkState(submitRule != null, "getSubmitRule() invalid before evaluation");
+    return submitRule;
+  }
+
+  public String getSubmitRuleName() {
+    return submitRule != null ? submitRule.toString() : "<unknown rule>";
+  }
+
+  private void checkNotStarted() {
+    checkState(opts == null, "cannot set options after starting evaluation");
+  }
+
+  private void initOptions() {
+    if (opts == null) {
+      opts = optsBuilder.build();
+      optsBuilder = null;
+    }
+  }
+
+  private void init() throws OrmException, NoSuchProjectException {
+    if (change == null) {
+      change = cd.change();
+      if (change == null) {
+        throw new OrmException("No change found");
+      }
+    }
+
+    if (projectState == null) {
+      projectState = projectCache.get(change.getProject());
+      if (projectState == null) {
+        throw new NoSuchProjectException(change.getProject());
+      }
+    }
+
+    if (patchSet == null) {
+      patchSet = cd.currentPatchSet();
+      if (patchSet == null) {
+        throw new OrmException("No patch set found");
+      }
+    }
+  }
+
+  private String getProjectName() {
+    return projectState.getName();
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRuleOptions.java b/java/com/google/gerrit/server/project/SubmitRuleOptions.java
new file mode 100644
index 0000000..332aa75
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRuleOptions.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+
+/**
+ * Stable identifier for options passed to a particular submit rule evaluator.
+ *
+ * <p>Used to test whether it is ok to reuse a cached list of submit records. Does not include a
+ * change or patch set ID; callers are responsible for checking those on their own.
+ */
+@AutoValue
+public abstract class SubmitRuleOptions {
+  public static Builder builder() {
+    return new AutoValue_SubmitRuleOptions.Builder()
+        .allowClosed(false)
+        .skipFilters(false)
+        .rule(null);
+  }
+
+  public abstract boolean allowClosed();
+
+  public abstract boolean skipFilters();
+
+  @Nullable
+  public abstract String rule();
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract SubmitRuleOptions.Builder allowClosed(boolean allowClosed);
+
+    public abstract SubmitRuleOptions.Builder skipFilters(boolean skipFilters);
+
+    public abstract SubmitRuleOptions.Builder rule(@Nullable String rule);
+
+    public abstract SubmitRuleOptions build();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java b/java/com/google/gerrit/server/project/SuggestParentCandidates.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java
rename to java/com/google/gerrit/server/project/SuggestParentCandidates.java
diff --git a/java/com/google/gerrit/server/project/TagResource.java b/java/com/google/gerrit/server/project/TagResource.java
new file mode 100644
index 0000000..08ef669
--- /dev/null
+++ b/java/com/google/gerrit/server/project/TagResource.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.extensions.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 {
+  public static final TypeLiteral<RestView<TagResource>> TAG_KIND =
+      new TypeLiteral<RestView<TagResource>>() {};
+
+  private final TagInfo tagInfo;
+
+  public TagResource(ProjectState projectState, CurrentUser user, TagInfo tagInfo) {
+    super(projectState, user);
+    this.tagInfo = tagInfo;
+  }
+
+  public TagInfo getTagInfo() {
+    return tagInfo;
+  }
+
+  @Override
+  public String getRef() {
+    return tagInfo.ref;
+  }
+
+  @Override
+  public String getRevision() {
+    return tagInfo.revision;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/testing/BUILD b/java/com/google/gerrit/server/project/testing/BUILD
new file mode 100644
index 0000000..ca1ffae
--- /dev/null
+++ b/java/com/google/gerrit/server/project/testing/BUILD
@@ -0,0 +1,11 @@
+java_library(
+    name = "project-test-util",
+    testonly = 1,
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+    ],
+)
diff --git a/java/com/google/gerrit/server/project/testing/Util.java b/java/com/google/gerrit/server/project/testing/Util.java
new file mode 100644
index 0000000..2851e81
--- /dev/null
+++ b/java/com/google/gerrit/server/project/testing/Util.java
@@ -0,0 +1,214 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project.testing;
+
+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;
+import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.git.ProjectConfig;
+import java.util.Arrays;
+
+public class Util {
+  public static final AccountGroup.UUID ADMIN = new AccountGroup.UUID("test.admin");
+  public static final AccountGroup.UUID DEVS = new AccountGroup.UUID("test.devs");
+
+  public static final LabelType codeReview() {
+    return category(
+        "Code-Review",
+        value(2, "Looks good to me, approved"),
+        value(1, "Looks good to me, but someone else must approve"),
+        value(0, "No score"),
+        value(-1, "I would prefer this is not merged as is"),
+        value(-2, "This shall not be merged"));
+  }
+
+  public static final LabelType verified() {
+    return category("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
+  }
+
+  public static final LabelType patchSetLock() {
+    LabelType label =
+        category("Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
+    label.setFunction(LabelFunction.PATCH_SET_LOCK);
+    return label;
+  }
+
+  public static LabelValue value(int value, String text) {
+    return new LabelValue((short) value, text);
+  }
+
+  public static LabelType category(String name, LabelValue... values) {
+    return new LabelType(name, Arrays.asList(values));
+  }
+
+  public static PermissionRule newRule(ProjectConfig project, AccountGroup.UUID groupUUID) {
+    GroupReference group = new GroupReference(groupUUID, groupUUID.get());
+    group = project.resolve(group);
+
+    return new PermissionRule(group);
+  }
+
+  public static PermissionRule allow(
+      ProjectConfig project,
+      String permissionName,
+      int min,
+      int max,
+      AccountGroup.UUID group,
+      String ref) {
+    PermissionRule rule = newRule(project, group);
+    rule.setMin(min);
+    rule.setMax(max);
+    return grant(project, permissionName, rule, ref);
+  }
+
+  public static PermissionRule block(
+      ProjectConfig project,
+      String permissionName,
+      int min,
+      int max,
+      AccountGroup.UUID group,
+      String ref) {
+    PermissionRule rule = newRule(project, group);
+    rule.setMin(min);
+    rule.setMax(max);
+    PermissionRule r = grant(project, permissionName, rule, ref);
+    r.setBlock();
+    return r;
+  }
+
+  public static PermissionRule allow(
+      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
+    return grant(project, permissionName, newRule(project, group), ref);
+  }
+
+  public static PermissionRule allow(
+      ProjectConfig project,
+      String permissionName,
+      AccountGroup.UUID group,
+      String ref,
+      boolean exclusive) {
+    return grant(project, permissionName, newRule(project, group), ref, exclusive);
+  }
+
+  public static PermissionRule allow(
+      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
+    PermissionRule rule = newRule(project, group);
+    project
+        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
+        .getPermission(capabilityName, true)
+        .add(rule);
+    if (GlobalCapability.hasRange(capabilityName)) {
+      PermissionRange.WithDefaults range = GlobalCapability.getRange(capabilityName);
+      if (range != null) {
+        rule.setRange(range.getDefaultMin(), range.getDefaultMax());
+      }
+    }
+    return rule;
+  }
+
+  public static PermissionRule remove(
+      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
+    PermissionRule rule = newRule(project, group);
+    project
+        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
+        .getPermission(capabilityName, true)
+        .remove(rule);
+    return rule;
+  }
+
+  public static PermissionRule remove(
+      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
+    PermissionRule rule = newRule(project, group);
+    project.getAccessSection(ref, true).getPermission(permissionName, true).remove(rule);
+    return rule;
+  }
+
+  public static PermissionRule block(
+      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
+    PermissionRule rule = newRule(project, group);
+    project
+        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
+        .getPermission(capabilityName, true)
+        .add(rule);
+    return rule;
+  }
+
+  public static PermissionRule block(
+      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
+    PermissionRule r = grant(project, permissionName, newRule(project, group), ref);
+    r.setBlock();
+    return r;
+  }
+
+  public static PermissionRule blockLabel(
+      ProjectConfig project, String labelName, AccountGroup.UUID group, String ref) {
+    return blockLabel(project, labelName, -1, 1, group, ref);
+  }
+
+  public static PermissionRule blockLabel(
+      ProjectConfig project,
+      String labelName,
+      int min,
+      int max,
+      AccountGroup.UUID group,
+      String ref) {
+    PermissionRule r = grant(project, Permission.LABEL + labelName, newRule(project, group), ref);
+    r.setBlock();
+    r.setRange(min, max);
+    return r;
+  }
+
+  public static PermissionRule deny(
+      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
+    PermissionRule r = grant(project, permissionName, newRule(project, group), ref);
+    r.setDeny();
+    return r;
+  }
+
+  public static void doNotInherit(ProjectConfig project, String permissionName, String ref) {
+    project
+        .getAccessSection(ref, true) //
+        .getPermission(permissionName, true) //
+        .setExclusiveGroup(true);
+  }
+
+  private static PermissionRule grant(
+      ProjectConfig project, String permissionName, PermissionRule rule, String ref) {
+    return grant(project, permissionName, rule, ref, false);
+  }
+
+  private static PermissionRule grant(
+      ProjectConfig project,
+      String permissionName,
+      PermissionRule rule,
+      String ref,
+      boolean exclusive) {
+    Permission permission = project.getAccessSection(ref, true).getPermission(permissionName, true);
+    if (exclusive) {
+      permission.setExclusiveGroup(exclusive);
+    }
+    permission.add(rule);
+    return rule;
+  }
+
+  private Util() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
rename to java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
new file mode 100644
index 0000000..22df2ce
--- /dev/null
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -0,0 +1,135 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.account;
+
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.account.AccountField;
+import java.util.List;
+
+public class AccountPredicates {
+  public static boolean hasActive(Predicate<AccountState> p) {
+    return QueryBuilder.find(p, AccountPredicate.class, AccountField.ACTIVE.getName()) != null;
+  }
+
+  public static Predicate<AccountState> andActive(Predicate<AccountState> p) {
+    return Predicate.and(p, isActive());
+  }
+
+  public static Predicate<AccountState> defaultPredicate(
+      Schema<AccountState> schema, boolean canSeeSecondaryEmails, String query) {
+    // Adapt the capacity of this list when adding more default predicates.
+    List<Predicate<AccountState>> preds = Lists.newArrayListWithCapacity(3);
+    Integer id = Ints.tryParse(query);
+    if (id != null) {
+      preds.add(id(new Account.Id(id)));
+    }
+    if (canSeeSecondaryEmails) {
+      preds.add(equalsNameIcludingSecondaryEmails(query));
+    } else {
+      if (schema.hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL)) {
+        preds.add(equalsName(query));
+      } else {
+        preds.add(AccountPredicates.fullName(query));
+        if (schema.hasField(AccountField.PREFERRED_EMAIL)) {
+          preds.add(AccountPredicates.preferredEmail(query));
+        }
+      }
+    }
+    preds.add(username(query));
+    // Adapt the capacity of the "predicates" list when adding more default
+    // predicates.
+    return Predicate.or(preds);
+  }
+
+  public static Predicate<AccountState> id(Account.Id accountId) {
+    return new AccountPredicate(
+        AccountField.ID, AccountQueryBuilder.FIELD_ACCOUNT, accountId.toString());
+  }
+
+  public static Predicate<AccountState> emailIncludingSecondaryEmails(String email) {
+    return new AccountPredicate(
+        AccountField.EMAIL, AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
+  }
+
+  public static Predicate<AccountState> preferredEmail(String email) {
+    return new AccountPredicate(
+        AccountField.PREFERRED_EMAIL,
+        AccountQueryBuilder.FIELD_PREFERRED_EMAIL,
+        email.toLowerCase());
+  }
+
+  public static Predicate<AccountState> preferredEmailExact(String email) {
+    return new AccountPredicate(
+        AccountField.PREFERRED_EMAIL_EXACT, AccountQueryBuilder.FIELD_PREFERRED_EMAIL_EXACT, email);
+  }
+
+  public static Predicate<AccountState> equalsNameIcludingSecondaryEmails(String name) {
+    return new AccountPredicate(
+        AccountField.NAME_PART, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
+  }
+
+  public static Predicate<AccountState> equalsName(String name) {
+    return new AccountPredicate(
+        AccountField.NAME_PART_NO_SECONDARY_EMAIL,
+        AccountQueryBuilder.FIELD_NAME,
+        name.toLowerCase());
+  }
+
+  public static Predicate<AccountState> externalIdIncludingSecondaryEmails(String externalId) {
+    return new AccountPredicate(AccountField.EXTERNAL_ID, externalId);
+  }
+
+  public static Predicate<AccountState> fullName(String fullName) {
+    return new AccountPredicate(AccountField.FULL_NAME, fullName);
+  }
+
+  public static Predicate<AccountState> isActive() {
+    return new AccountPredicate(AccountField.ACTIVE, "1");
+  }
+
+  public static Predicate<AccountState> isNotActive() {
+    return new AccountPredicate(AccountField.ACTIVE, "0");
+  }
+
+  public static Predicate<AccountState> username(String username) {
+    return new AccountPredicate(
+        AccountField.USERNAME, AccountQueryBuilder.FIELD_USERNAME, username.toLowerCase());
+  }
+
+  public static Predicate<AccountState> watchedProject(Project.NameKey project) {
+    return new AccountPredicate(AccountField.WATCHED_PROJECT, project.get());
+  }
+
+  static class AccountPredicate extends IndexPredicate<AccountState> {
+    AccountPredicate(FieldDef<AccountState, ?> def, String value) {
+      super(def, value);
+    }
+
+    AccountPredicate(FieldDef<AccountState, ?> def, String name, String value) {
+      super(def, name, value);
+    }
+  }
+
+  private AccountPredicates() {}
+}
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
new file mode 100644
index 0000000..055b423
--- /dev/null
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -0,0 +1,199 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.account;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.errors.NotSignedInException;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.LimitPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.account.AccountField;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+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.ProvisionException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Parses a query string meant to be applied to account objects. */
+public class AccountQueryBuilder extends QueryBuilder<AccountState> {
+  private static final Logger log = LoggerFactory.getLogger(AccountQueryBuilder.class);
+
+  public static final String FIELD_ACCOUNT = "account";
+  public static final String FIELD_EMAIL = "email";
+  public static final String FIELD_LIMIT = "limit";
+  public static final String FIELD_NAME = "name";
+  public static final String FIELD_PREFERRED_EMAIL = "preferredemail";
+  public static final String FIELD_PREFERRED_EMAIL_EXACT = "preferredemail_exact";
+  public static final String FIELD_USERNAME = "username";
+  public static final String FIELD_VISIBLETO = "visibleto";
+
+  private static final QueryBuilder.Definition<AccountState, AccountQueryBuilder> mydef =
+      new QueryBuilder.Definition<>(AccountQueryBuilder.class);
+
+  public static class Arguments {
+    private final Provider<CurrentUser> self;
+    private final AccountIndexCollection indexes;
+    private final PermissionBackend permissionBackend;
+
+    @Inject
+    public Arguments(
+        Provider<CurrentUser> self,
+        AccountIndexCollection indexes,
+        PermissionBackend permissionBackend) {
+      this.self = self;
+      this.indexes = indexes;
+      this.permissionBackend = permissionBackend;
+    }
+
+    IdentifiedUser getIdentifiedUser() throws QueryParseException {
+      try {
+        CurrentUser u = getUser();
+        if (u.isIdentifiedUser()) {
+          return u.asIdentifiedUser();
+        }
+        throw new QueryParseException(NotSignedInException.MESSAGE);
+      } catch (ProvisionException e) {
+        throw new QueryParseException(NotSignedInException.MESSAGE, e);
+      }
+    }
+
+    CurrentUser getUser() throws QueryParseException {
+      try {
+        return self.get();
+      } catch (ProvisionException e) {
+        throw new QueryParseException(NotSignedInException.MESSAGE, e);
+      }
+    }
+
+    Schema<AccountState> schema() {
+      Index<?, AccountState> index = indexes != null ? indexes.getSearchIndex() : null;
+      return index != null ? index.getSchema() : null;
+    }
+  }
+
+  private final Arguments args;
+
+  @Inject
+  AccountQueryBuilder(Arguments args) {
+    super(mydef);
+    this.args = args;
+  }
+
+  @Operator
+  public Predicate<AccountState> email(String email)
+      throws PermissionBackendException, QueryParseException {
+    if (canSeeSecondaryEmails()) {
+      return AccountPredicates.emailIncludingSecondaryEmails(email);
+    }
+
+    if (args.schema().hasField(AccountField.PREFERRED_EMAIL)) {
+      return AccountPredicates.preferredEmail(email);
+    }
+
+    throw new QueryParseException("'email' operator is not supported by account index version");
+  }
+
+  @Operator
+  public Predicate<AccountState> is(String value) throws QueryParseException {
+    if ("active".equalsIgnoreCase(value)) {
+      return AccountPredicates.isActive();
+    }
+    if ("inactive".equalsIgnoreCase(value)) {
+      return AccountPredicates.isNotActive();
+    }
+    throw error("Invalid query");
+  }
+
+  @Operator
+  public Predicate<AccountState> limit(String query) throws QueryParseException {
+    Integer limit = Ints.tryParse(query);
+    if (limit == null) {
+      throw error("Invalid limit: " + query);
+    }
+    return new LimitPredicate<>(FIELD_LIMIT, limit);
+  }
+
+  @Operator
+  public Predicate<AccountState> name(String name)
+      throws PermissionBackendException, QueryParseException {
+    if (canSeeSecondaryEmails()) {
+      return AccountPredicates.equalsNameIcludingSecondaryEmails(name);
+    }
+
+    if (args.schema().hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL)) {
+      return AccountPredicates.equalsName(name);
+    }
+
+    return AccountPredicates.fullName(name);
+  }
+
+  @Operator
+  public Predicate<AccountState> username(String username) {
+    return AccountPredicates.username(username);
+  }
+
+  public Predicate<AccountState> defaultQuery(String query) {
+    return Predicate.and(
+        Lists.transform(
+            Splitter.on(' ').omitEmptyStrings().splitToList(query), this::defaultField));
+  }
+
+  @Override
+  protected Predicate<AccountState> defaultField(String query) {
+    Predicate<AccountState> defaultPredicate =
+        AccountPredicates.defaultPredicate(args.schema(), checkedCanSeeSecondaryEmails(), query);
+    if ("self".equalsIgnoreCase(query) || "me".equalsIgnoreCase(query)) {
+      try {
+        return Predicate.or(defaultPredicate, AccountPredicates.id(self()));
+      } catch (QueryParseException e) {
+        // Skip.
+      }
+    }
+    return defaultPredicate;
+  }
+
+  private Account.Id self() throws QueryParseException {
+    return args.getIdentifiedUser().getAccountId();
+  }
+
+  private boolean canSeeSecondaryEmails() throws PermissionBackendException, QueryParseException {
+    return args.permissionBackend.user(args.getUser()).test(GlobalPermission.MODIFY_ACCOUNT);
+  }
+
+  private boolean checkedCanSeeSecondaryEmails() {
+    try {
+      return canSeeSecondaryEmails();
+    } catch (PermissionBackendException e) {
+      log.error("Permission check failed", e);
+      return false;
+    } catch (QueryParseException e) {
+      // User is not signed in.
+      return false;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
rename to java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
diff --git a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
new file mode 100644
index 0000000..f1be580
--- /dev/null
+++ b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -0,0 +1,205 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.account;
+
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.InternalQuery;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.index.account.AccountField;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Query wrapper for the account index.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
+public class InternalAccountQuery extends InternalQuery<AccountState> {
+  private static final Logger log = LoggerFactory.getLogger(InternalAccountQuery.class);
+
+  @Inject
+  InternalAccountQuery(
+      AccountQueryProcessor queryProcessor,
+      AccountIndexCollection indexes,
+      IndexConfig indexConfig) {
+    super(queryProcessor, indexes, indexConfig);
+  }
+
+  @Override
+  public InternalAccountQuery setLimit(int n) {
+    super.setLimit(n);
+    return this;
+  }
+
+  @Override
+  public InternalAccountQuery enforceVisibility(boolean enforce) {
+    super.enforceVisibility(enforce);
+    return this;
+  }
+
+  @SafeVarargs
+  @Override
+  public final InternalAccountQuery setRequestedFields(FieldDef<AccountState, ?>... fields) {
+    super.setRequestedFields(fields);
+    return this;
+  }
+
+  @Override
+  public InternalAccountQuery noFields() {
+    super.noFields();
+    return this;
+  }
+
+  public List<AccountState> byDefault(String query) throws OrmException {
+    return query(AccountPredicates.defaultPredicate(schema(), true, query));
+  }
+
+  public List<AccountState> byExternalId(String scheme, String id) throws OrmException {
+    return byExternalId(ExternalId.Key.create(scheme, id));
+  }
+
+  public List<AccountState> byExternalId(ExternalId.Key externalId) throws OrmException {
+    return query(AccountPredicates.externalIdIncludingSecondaryEmails(externalId.toString()));
+  }
+
+  public AccountState oneByExternalId(String externalId) throws OrmException {
+    return oneByExternalId(ExternalId.Key.parse(externalId));
+  }
+
+  public AccountState oneByExternalId(String scheme, String id) throws OrmException {
+    return oneByExternalId(ExternalId.Key.create(scheme, id));
+  }
+
+  public AccountState oneByExternalId(ExternalId.Key externalId) throws OrmException {
+    List<AccountState> accountStates = byExternalId(externalId);
+    if (accountStates.size() == 1) {
+      return accountStates.get(0);
+    } else if (accountStates.size() > 0) {
+      StringBuilder msg = new StringBuilder();
+      msg.append("Ambiguous external ID ").append(externalId).append(" for accounts: ");
+      Joiner.on(", ")
+          .appendTo(msg, Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
+      log.warn(msg.toString());
+    }
+    return null;
+  }
+
+  public List<AccountState> byFullName(String fullName) throws OrmException {
+    return query(AccountPredicates.fullName(fullName));
+  }
+
+  /**
+   * Queries for accounts that have a preferred email that exactly matches the given email.
+   *
+   * @param email preferred email by which accounts should be found
+   * @return list of accounts that have a preferred email that exactly matches the given email
+   * @throws OrmException if query cannot be parsed
+   */
+  public List<AccountState> byPreferredEmail(String email) throws OrmException {
+    if (hasPreferredEmailExact()) {
+      return query(AccountPredicates.preferredEmailExact(email));
+    }
+
+    if (!hasPreferredEmail()) {
+      return ImmutableList.of();
+    }
+
+    return query(AccountPredicates.preferredEmail(email))
+        .stream()
+        .filter(a -> a.getAccount().getPreferredEmail().equals(email))
+        .collect(toList());
+  }
+
+  /**
+   * Makes multiple queries for accounts by preferred email (exact match).
+   *
+   * @param emails preferred emails by which accounts should be found
+   * @return multimap of the given emails to accounts that have a preferred email that exactly
+   *     matches this email
+   * @throws OrmException if query cannot be parsed
+   */
+  public Multimap<String, AccountState> byPreferredEmail(String... emails) throws OrmException {
+    List<String> emailList = Arrays.asList(emails);
+
+    if (hasPreferredEmailExact()) {
+      List<List<AccountState>> r =
+          query(
+              emailList
+                  .stream()
+                  .map(e -> AccountPredicates.preferredEmailExact(e))
+                  .collect(toList()));
+      Multimap<String, AccountState> accountsByEmail = ArrayListMultimap.create();
+      for (int i = 0; i < emailList.size(); i++) {
+        accountsByEmail.putAll(emailList.get(i), r.get(i));
+      }
+      return accountsByEmail;
+    }
+
+    if (!hasPreferredEmail()) {
+      return ImmutableListMultimap.of();
+    }
+
+    List<List<AccountState>> r =
+        query(emailList.stream().map(e -> AccountPredicates.preferredEmail(e)).collect(toList()));
+    Multimap<String, AccountState> accountsByEmail = ArrayListMultimap.create();
+    for (int i = 0; i < emailList.size(); i++) {
+      String email = emailList.get(i);
+      Set<AccountState> matchingAccounts =
+          r.get(i)
+              .stream()
+              .filter(a -> a.getAccount().getPreferredEmail().equals(email))
+              .collect(toSet());
+      accountsByEmail.putAll(email, matchingAccounts);
+    }
+    return accountsByEmail;
+  }
+
+  public List<AccountState> byWatchedProject(Project.NameKey project) throws OrmException {
+    return query(AccountPredicates.watchedProject(project));
+  }
+
+  private boolean hasField(FieldDef<AccountState, ?> field) {
+    Schema<AccountState> s = schema();
+    return (s != null && s.hasField(field));
+  }
+
+  private boolean hasPreferredEmail() {
+    return hasField(AccountField.PREFERRED_EMAIL);
+  }
+
+  private boolean hasPreferredEmailExact() {
+    return hasField(AccountField.PREFERRED_EMAIL_EXACT);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java b/java/com/google/gerrit/server/query/change/AddedPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
rename to java/com/google/gerrit/server/query/change/AddedPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java b/java/com/google/gerrit/server/query/change/AfterPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
rename to java/com/google/gerrit/server/query/change/AfterPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java b/java/com/google/gerrit/server/query/change/AgePredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
rename to java/com/google/gerrit/server/query/change/AgePredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java b/java/com/google/gerrit/server/query/change/AndChangeSource.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java
rename to java/com/google/gerrit/server/query/change/AndChangeSource.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java b/java/com/google/gerrit/server/query/change/AssigneePredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
rename to java/com/google/gerrit/server/query/change/AssigneePredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java b/java/com/google/gerrit/server/query/change/AuthorPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
rename to java/com/google/gerrit/server/query/change/AuthorPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java b/java/com/google/gerrit/server/query/change/BeforePredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
rename to java/com/google/gerrit/server/query/change/BeforePredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BooleanPredicate.java b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/BooleanPredicate.java
rename to java/com/google/gerrit/server/query/change/BooleanPredicate.java
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
new file mode 100644
index 0000000..e73db1a
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -0,0 +1,1245 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.ApprovalsUtil.sortApprovals;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toMap;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ReviewerByEmailSet;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.StarRef;
+import com.google.gerrit.server.change.MergeabilityCache;
+import com.google.gerrit.server.change.PureRevert;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.TrackingFooters;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.patch.DiffSummary;
+import com.google.gerrit.server.patch.DiffSummaryKey;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class ChangeData {
+  private static final int BATCH_SIZE = 50;
+
+  public static List<Change> asChanges(List<ChangeData> changeDatas) throws OrmException {
+    List<Change> result = new ArrayList<>(changeDatas.size());
+    for (ChangeData cd : changeDatas) {
+      result.add(cd.change());
+    }
+    return result;
+  }
+
+  public static Map<Change.Id, ChangeData> asMap(List<ChangeData> changes) {
+    return changes.stream().collect(toMap(ChangeData::getId, cd -> cd));
+  }
+
+  public static void ensureChangeLoaded(Iterable<ChangeData> changes) throws OrmException {
+    ChangeData first = Iterables.getFirst(changes, null);
+    if (first == null) {
+      return;
+    } else if (first.notesMigration.readChanges()) {
+      for (ChangeData cd : changes) {
+        cd.change();
+      }
+      return;
+    }
+
+    Map<Change.Id, ChangeData> missing = new HashMap<>();
+    for (ChangeData cd : changes) {
+      if (cd.change == null) {
+        missing.put(cd.getId(), cd);
+      }
+    }
+    if (missing.isEmpty()) {
+      return;
+    }
+    for (ChangeNotes notes : first.notesFactory.create(first.db, missing.keySet())) {
+      missing.get(notes.getChangeId()).change = notes.getChange();
+    }
+  }
+
+  public static void ensureAllPatchSetsLoaded(Iterable<ChangeData> changes) throws OrmException {
+    ChangeData first = Iterables.getFirst(changes, null);
+    if (first == null) {
+      return;
+    } else if (first.notesMigration.readChanges()) {
+      for (ChangeData cd : changes) {
+        cd.patchSets();
+      }
+      return;
+    }
+
+    List<ResultSet<PatchSet>> results = new ArrayList<>(BATCH_SIZE);
+    for (List<ChangeData> batch : Iterables.partition(changes, BATCH_SIZE)) {
+      results.clear();
+      for (ChangeData cd : batch) {
+        if (cd.patchSets == null) {
+          results.add(cd.db.patchSets().byChange(cd.getId()));
+        } else {
+          results.add(null);
+        }
+      }
+      for (int i = 0; i < batch.size(); i++) {
+        ResultSet<PatchSet> result = results.get(i);
+        if (result != null) {
+          batch.get(i).patchSets = result.toList();
+        }
+      }
+    }
+  }
+
+  public static void ensureCurrentPatchSetLoaded(Iterable<ChangeData> changes) throws OrmException {
+    ChangeData first = Iterables.getFirst(changes, null);
+    if (first == null) {
+      return;
+    } else if (first.notesMigration.readChanges()) {
+      for (ChangeData cd : changes) {
+        cd.currentPatchSet();
+      }
+      return;
+    }
+
+    Map<PatchSet.Id, ChangeData> missing = new HashMap<>();
+    for (ChangeData cd : changes) {
+      if (cd.currentPatchSet == null && cd.patchSets == null) {
+        missing.put(cd.change().currentPatchSetId(), cd);
+      }
+    }
+    if (missing.isEmpty()) {
+      return;
+    }
+    for (PatchSet ps : first.db.patchSets().get(missing.keySet())) {
+      missing.get(ps.getId()).currentPatchSet = ps;
+    }
+  }
+
+  public static void ensureCurrentApprovalsLoaded(Iterable<ChangeData> changes)
+      throws OrmException {
+    ChangeData first = Iterables.getFirst(changes, null);
+    if (first == null) {
+      return;
+    } else if (first.notesMigration.readChanges()) {
+      for (ChangeData cd : changes) {
+        cd.currentApprovals();
+      }
+      return;
+    }
+
+    List<ResultSet<PatchSetApproval>> results = new ArrayList<>(BATCH_SIZE);
+    for (List<ChangeData> batch : Iterables.partition(changes, BATCH_SIZE)) {
+      results.clear();
+      for (ChangeData cd : batch) {
+        if (cd.currentApprovals == null) {
+          PatchSet.Id psId = cd.change().currentPatchSetId();
+          results.add(cd.db.patchSetApprovals().byPatchSet(psId));
+        } else {
+          results.add(null);
+        }
+      }
+      for (int i = 0; i < batch.size(); i++) {
+        ResultSet<PatchSetApproval> result = results.get(i);
+        if (result != null) {
+          batch.get(i).currentApprovals = sortApprovals(result);
+        }
+      }
+    }
+  }
+
+  public static void ensureMessagesLoaded(Iterable<ChangeData> changes) throws OrmException {
+    ChangeData first = Iterables.getFirst(changes, null);
+    if (first == null) {
+      return;
+    } else if (first.notesMigration.readChanges()) {
+      for (ChangeData cd : changes) {
+        cd.messages();
+      }
+      return;
+    }
+
+    List<ResultSet<ChangeMessage>> results = new ArrayList<>(BATCH_SIZE);
+    for (List<ChangeData> batch : Iterables.partition(changes, BATCH_SIZE)) {
+      results.clear();
+      for (ChangeData cd : batch) {
+        if (cd.messages == null) {
+          PatchSet.Id psId = cd.change().currentPatchSetId();
+          results.add(cd.db.changeMessages().byPatchSet(psId));
+        } else {
+          results.add(null);
+        }
+      }
+      for (int i = 0; i < batch.size(); i++) {
+        ResultSet<ChangeMessage> result = results.get(i);
+        if (result != null) {
+          batch.get(i).messages = result.toList();
+        }
+      }
+    }
+  }
+
+  public static void ensureReviewedByLoadedForOpenChanges(Iterable<ChangeData> changes)
+      throws OrmException {
+    List<ChangeData> pending = new ArrayList<>();
+    for (ChangeData cd : changes) {
+      if (cd.reviewedBy == null && cd.change().getStatus().isOpen()) {
+        pending.add(cd);
+      }
+    }
+
+    if (!pending.isEmpty()) {
+      ensureAllPatchSetsLoaded(pending);
+      ensureMessagesLoaded(pending);
+      for (ChangeData cd : pending) {
+        cd.reviewedBy();
+      }
+    }
+  }
+
+  public static class Factory {
+    private final AssistedFactory assistedFactory;
+
+    @Inject
+    Factory(AssistedFactory assistedFactory) {
+      this.assistedFactory = assistedFactory;
+    }
+
+    public ChangeData create(ReviewDb db, Project.NameKey project, Change.Id id) {
+      return assistedFactory.create(db, project, id, null, null);
+    }
+
+    public ChangeData create(ReviewDb db, Change change) {
+      return assistedFactory.create(db, change.getProject(), change.getId(), change, null);
+    }
+
+    public ChangeData create(ReviewDb db, ChangeNotes notes) {
+      return assistedFactory.create(
+          db, notes.getChange().getProject(), notes.getChangeId(), notes.getChange(), notes);
+    }
+  }
+
+  public interface AssistedFactory {
+    ChangeData create(
+        ReviewDb db,
+        Project.NameKey project,
+        Change.Id id,
+        @Nullable Change change,
+        @Nullable ChangeNotes notes);
+  }
+
+  /**
+   * Create an instance for testing only.
+   *
+   * <p>Attempting to lazy load data will fail with NPEs. Callers may consider manually setting
+   * fields that can be set.
+   *
+   * @param id change ID
+   * @return instance for testing.
+   */
+  public static ChangeData createForTest(
+      Project.NameKey project, Change.Id id, int currentPatchSetId) {
+    ChangeData cd =
+        new ChangeData(
+            null, null, null, null, null, null, null, null, null, null, null, null, null, null,
+            null, null, null, null, project, id, null, null);
+    cd.currentPatchSet = new PatchSet(new PatchSet.Id(id, currentPatchSetId));
+    return cd;
+  }
+
+  // Injected fields.
+  private @Nullable final StarredChangesUtil starredChangesUtil;
+  private final AllUsersName allUsersName;
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final ChangeNotes.Factory notesFactory;
+  private final CommentsUtil commentsUtil;
+  private final GitRepositoryManager repoManager;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeabilityCache mergeabilityCache;
+  private final NotesMigration notesMigration;
+  private final PatchListCache patchListCache;
+  private final PatchSetUtil psUtil;
+  private final ProjectCache projectCache;
+  private final TrackingFooters trackingFooters;
+  private final PureRevert pureRevert;
+  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+
+  // Required assisted injected fields.
+  private final ReviewDb db;
+  private final Project.NameKey project;
+  private final Change.Id legacyId;
+
+  // Lazily populated fields, including optional assisted injected fields.
+
+  private final Map<SubmitRuleOptions, List<SubmitRecord>> submitRecords =
+      Maps.newLinkedHashMapWithExpectedSize(1);
+
+  private boolean lazyLoad = true;
+  private Change change;
+  private ChangeNotes notes;
+  private String commitMessage;
+  private List<FooterLine> commitFooters;
+  private PatchSet currentPatchSet;
+  private Collection<PatchSet> patchSets;
+  private ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals;
+  private List<PatchSetApproval> currentApprovals;
+  private List<String> currentFiles;
+  private Optional<DiffSummary> diffSummary;
+  private Collection<Comment> publishedComments;
+  private Collection<RobotComment> robotComments;
+  private CurrentUser visibleTo;
+  private List<ChangeMessage> messages;
+  private Optional<ChangedLines> changedLines;
+  private SubmitTypeRecord submitTypeRecord;
+  private Boolean mergeable;
+  private Set<String> hashtags;
+  private Map<Account.Id, Ref> editsByUser;
+  private Set<Account.Id> reviewedBy;
+  private Map<Account.Id, Ref> draftsByUser;
+  private ImmutableListMultimap<Account.Id, String> stars;
+  private StarsOf starsOf;
+  private ImmutableMap<Account.Id, StarRef> starRefs;
+  private ReviewerSet reviewers;
+  private ReviewerByEmailSet reviewersByEmail;
+  private ReviewerSet pendingReviewers;
+  private ReviewerByEmailSet pendingReviewersByEmail;
+  private List<ReviewerStatusUpdate> reviewerUpdates;
+  private PersonIdent author;
+  private PersonIdent committer;
+  private int parentCount;
+  private Integer unresolvedCommentCount;
+  private LabelTypes labelTypes;
+
+  private ImmutableList<byte[]> refStates;
+  private ImmutableList<byte[]> refStatePatterns;
+
+  @Inject
+  private ChangeData(
+      @Nullable StarredChangesUtil starredChangesUtil,
+      ApprovalsUtil approvalsUtil,
+      AllUsersName allUsersName,
+      ChangeMessagesUtil cmUtil,
+      ChangeNotes.Factory notesFactory,
+      CommentsUtil commentsUtil,
+      GitRepositoryManager repoManager,
+      IdentifiedUser.GenericFactory userFactory,
+      MergeUtil.Factory mergeUtilFactory,
+      MergeabilityCache mergeabilityCache,
+      NotesMigration notesMigration,
+      PatchListCache patchListCache,
+      PatchSetUtil psUtil,
+      ProjectCache projectCache,
+      TrackingFooters trackingFooters,
+      PureRevert pureRevert,
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
+      @Assisted ReviewDb db,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id,
+      @Assisted @Nullable Change change,
+      @Assisted @Nullable ChangeNotes notes) {
+    this.approvalsUtil = approvalsUtil;
+    this.allUsersName = allUsersName;
+    this.cmUtil = cmUtil;
+    this.notesFactory = notesFactory;
+    this.commentsUtil = commentsUtil;
+    this.repoManager = repoManager;
+    this.userFactory = userFactory;
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.mergeabilityCache = mergeabilityCache;
+    this.notesMigration = notesMigration;
+    this.patchListCache = patchListCache;
+    this.psUtil = psUtil;
+    this.projectCache = projectCache;
+    this.starredChangesUtil = starredChangesUtil;
+    this.trackingFooters = trackingFooters;
+    this.pureRevert = pureRevert;
+    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
+
+    // May be null in tests when created via createForTest above, in which case lazy-loading will
+    // intentionally fail with NPE. Still not marked @Nullable in the constructor, to force callers
+    // using Guice to pass a non-null value.
+    this.db = db;
+
+    this.project = project;
+    this.legacyId = id;
+
+    this.change = change;
+    this.notes = notes;
+  }
+
+  public ChangeData setLazyLoad(boolean load) {
+    lazyLoad = load;
+    return this;
+  }
+
+  public ReviewDb db() {
+    return db;
+  }
+
+  public AllUsersName getAllUsersNameForIndexing() {
+    return allUsersName;
+  }
+
+  public void setCurrentFilePaths(List<String> filePaths) throws OrmException {
+    PatchSet ps = currentPatchSet();
+    if (ps != null) {
+      currentFiles = ImmutableList.copyOf(filePaths);
+    }
+  }
+
+  public List<String> currentFilePaths() throws IOException, OrmException {
+    if (currentFiles == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
+      Optional<DiffSummary> p = getDiffSummary();
+      currentFiles = p.map(DiffSummary::getPaths).orElse(Collections.emptyList());
+    }
+    return currentFiles;
+  }
+
+  private Optional<DiffSummary> getDiffSummary() throws OrmException, IOException {
+    if (diffSummary == null) {
+      if (!lazyLoad) {
+        return Optional.empty();
+      }
+
+      Change c = change();
+      PatchSet ps = currentPatchSet();
+      if (c == null || ps == null || !loadCommitData()) {
+        return Optional.empty();
+      }
+
+      ObjectId id = ObjectId.fromString(ps.getRevision().get());
+      Whitespace ws = Whitespace.IGNORE_NONE;
+      PatchListKey pk =
+          parentCount > 1
+              ? PatchListKey.againstParentNum(1, id, ws)
+              : PatchListKey.againstDefaultBase(id, ws);
+      DiffSummaryKey key = DiffSummaryKey.fromPatchListKey(pk);
+      try {
+        diffSummary = Optional.of(patchListCache.getDiffSummary(key, c.getProject()));
+      } catch (PatchListNotAvailableException e) {
+        diffSummary = Optional.empty();
+      }
+    }
+    return diffSummary;
+  }
+
+  private Optional<ChangedLines> computeChangedLines() throws OrmException, IOException {
+    Optional<DiffSummary> ds = getDiffSummary();
+    if (ds.isPresent()) {
+      return Optional.of(ds.get().getChangedLines());
+    }
+    return Optional.empty();
+  }
+
+  public Optional<ChangedLines> changedLines() throws OrmException, IOException {
+    if (changedLines == null) {
+      if (!lazyLoad) {
+        return Optional.empty();
+      }
+      changedLines = computeChangedLines();
+    }
+    return changedLines;
+  }
+
+  public void setChangedLines(int insertions, int deletions) {
+    changedLines = Optional.of(new ChangedLines(insertions, deletions));
+  }
+
+  public void setNoChangedLines() {
+    changedLines = Optional.empty();
+  }
+
+  public Change.Id getId() {
+    return legacyId;
+  }
+
+  public Project.NameKey project() {
+    return project;
+  }
+
+  boolean fastIsVisibleTo(CurrentUser user) {
+    return visibleTo == user;
+  }
+
+  void cacheVisibleTo(CurrentUser user) {
+    visibleTo = user;
+  }
+
+  public Change change() throws OrmException {
+    if (change == null && lazyLoad) {
+      reloadChange();
+    }
+    return change;
+  }
+
+  public void setChange(Change c) {
+    change = c;
+  }
+
+  public Change reloadChange() throws OrmException {
+    try {
+      notes = notesFactory.createChecked(db, project, legacyId);
+    } catch (NoSuchChangeException e) {
+      throw new OrmException("Unable to load change " + legacyId, e);
+    }
+    change = notes.getChange();
+    setPatchSets(null);
+    return change;
+  }
+
+  public LabelTypes getLabelTypes() throws OrmException {
+    if (labelTypes == null) {
+      ProjectState state;
+      try {
+        state = projectCache.checkedGet(project());
+      } catch (IOException e) {
+        throw new OrmException("project state not available", e);
+      }
+      labelTypes = state.getLabelTypes(change().getDest(), userFactory.create(change().getOwner()));
+    }
+    return labelTypes;
+  }
+
+  public ChangeNotes notes() throws OrmException {
+    if (notes == null) {
+      if (!lazyLoad) {
+        throw new OrmException("ChangeNotes not available, lazyLoad = false");
+      }
+      notes = notesFactory.create(db, project(), legacyId);
+    }
+    return notes;
+  }
+
+  public PatchSet currentPatchSet() throws OrmException {
+    if (currentPatchSet == null) {
+      Change c = change();
+      if (c == null) {
+        return null;
+      }
+      for (PatchSet p : patchSets()) {
+        if (p.getId().equals(c.currentPatchSetId())) {
+          currentPatchSet = p;
+          return p;
+        }
+      }
+    }
+    return currentPatchSet;
+  }
+
+  public List<PatchSetApproval> currentApprovals() throws OrmException {
+    if (currentApprovals == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
+      Change c = change();
+      if (c == null) {
+        currentApprovals = Collections.emptyList();
+      } else {
+        try {
+          currentApprovals =
+              ImmutableList.copyOf(
+                  approvalsUtil.byPatchSet(
+                      db,
+                      notes(),
+                      userFactory.create(c.getOwner()),
+                      c.currentPatchSetId(),
+                      null,
+                      null));
+        } catch (OrmException e) {
+          if (e.getCause() instanceof NoSuchChangeException) {
+            currentApprovals = Collections.emptyList();
+          } else {
+            throw e;
+          }
+        }
+      }
+    }
+    return currentApprovals;
+  }
+
+  public void setCurrentApprovals(List<PatchSetApproval> approvals) {
+    currentApprovals = approvals;
+  }
+
+  public String commitMessage() throws IOException, OrmException {
+    if (commitMessage == null) {
+      if (!loadCommitData()) {
+        return null;
+      }
+    }
+    return commitMessage;
+  }
+
+  public List<FooterLine> commitFooters() throws IOException, OrmException {
+    if (commitFooters == null) {
+      if (!loadCommitData()) {
+        return null;
+      }
+    }
+    return commitFooters;
+  }
+
+  public ListMultimap<String, String> trackingFooters() throws IOException, OrmException {
+    return trackingFooters.extract(commitFooters());
+  }
+
+  public PersonIdent getAuthor() throws IOException, OrmException {
+    if (author == null) {
+      if (!loadCommitData()) {
+        return null;
+      }
+    }
+    return author;
+  }
+
+  public PersonIdent getCommitter() throws IOException, OrmException {
+    if (committer == null) {
+      if (!loadCommitData()) {
+        return null;
+      }
+    }
+    return committer;
+  }
+
+  private boolean loadCommitData()
+      throws OrmException, RepositoryNotFoundException, IOException, MissingObjectException,
+          IncorrectObjectTypeException {
+    PatchSet ps = currentPatchSet();
+    if (ps == null) {
+      return false;
+    }
+    String sha1 = ps.getRevision().get();
+    try (Repository repo = repoManager.openRepository(project());
+        RevWalk walk = new RevWalk(repo)) {
+      RevCommit c = walk.parseCommit(ObjectId.fromString(sha1));
+      commitMessage = c.getFullMessage();
+      commitFooters = c.getFooterLines();
+      author = c.getAuthorIdent();
+      committer = c.getCommitterIdent();
+      parentCount = c.getParentCount();
+    }
+    return true;
+  }
+
+  /**
+   * @return patches for the change, in patch set ID order.
+   * @throws OrmException an error occurred reading the database.
+   */
+  public Collection<PatchSet> patchSets() throws OrmException {
+    if (patchSets == null) {
+      patchSets = psUtil.byChange(db, notes());
+    }
+    return patchSets;
+  }
+
+  public void setPatchSets(Collection<PatchSet> patchSets) {
+    this.currentPatchSet = null;
+    this.patchSets = patchSets;
+  }
+
+  /**
+   * @return patch with the given ID, or null if it does not exist.
+   * @throws OrmException an error occurred reading the database.
+   */
+  public PatchSet patchSet(PatchSet.Id psId) throws OrmException {
+    if (currentPatchSet != null && currentPatchSet.getId().equals(psId)) {
+      return currentPatchSet;
+    }
+    for (PatchSet ps : patchSets()) {
+      if (ps.getId().equals(psId)) {
+        return ps;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * @return all patch set approvals for the change, keyed by ID, ordered by timestamp within each
+   *     patch set.
+   * @throws OrmException an error occurred reading the database.
+   */
+  public ListMultimap<PatchSet.Id, PatchSetApproval> approvals() throws OrmException {
+    if (allApprovals == null) {
+      if (!lazyLoad) {
+        return ImmutableListMultimap.of();
+      }
+      allApprovals = approvalsUtil.byChange(db, notes());
+    }
+    return allApprovals;
+  }
+
+  /**
+   * @return The submit ('SUBM') approval label
+   * @throws OrmException an error occurred reading the database.
+   */
+  public Optional<PatchSetApproval> getSubmitApproval() throws OrmException {
+    return currentApprovals().stream().filter(PatchSetApproval::isLegacySubmit).findFirst();
+  }
+
+  public ReviewerSet reviewers() throws OrmException {
+    if (reviewers == null) {
+      if (!lazyLoad) {
+        return ReviewerSet.empty();
+      }
+      reviewers = approvalsUtil.getReviewers(notes(), approvals().values());
+    }
+    return reviewers;
+  }
+
+  public void setReviewers(ReviewerSet reviewers) {
+    this.reviewers = reviewers;
+  }
+
+  public ReviewerSet getReviewers() {
+    return reviewers;
+  }
+
+  public ReviewerByEmailSet reviewersByEmail() throws OrmException {
+    if (reviewersByEmail == null) {
+      if (!lazyLoad) {
+        return ReviewerByEmailSet.empty();
+      }
+      reviewersByEmail = notes().getReviewersByEmail();
+    }
+    return reviewersByEmail;
+  }
+
+  public void setReviewersByEmail(ReviewerByEmailSet reviewersByEmail) {
+    this.reviewersByEmail = reviewersByEmail;
+  }
+
+  public ReviewerByEmailSet getReviewersByEmail() {
+    return reviewersByEmail;
+  }
+
+  public void setPendingReviewers(ReviewerSet pendingReviewers) {
+    this.pendingReviewers = pendingReviewers;
+  }
+
+  public ReviewerSet getPendingReviewers() {
+    return this.pendingReviewers;
+  }
+
+  public ReviewerSet pendingReviewers() throws OrmException {
+    if (pendingReviewers == null) {
+      if (!lazyLoad) {
+        return ReviewerSet.empty();
+      }
+      pendingReviewers = notes().getPendingReviewers();
+    }
+    return pendingReviewers;
+  }
+
+  public void setPendingReviewersByEmail(ReviewerByEmailSet pendingReviewersByEmail) {
+    this.pendingReviewersByEmail = pendingReviewersByEmail;
+  }
+
+  public ReviewerByEmailSet getPendingReviewersByEmail() {
+    return pendingReviewersByEmail;
+  }
+
+  public ReviewerByEmailSet pendingReviewersByEmail() throws OrmException {
+    if (pendingReviewersByEmail == null) {
+      if (!lazyLoad) {
+        return ReviewerByEmailSet.empty();
+      }
+      pendingReviewersByEmail = notes().getPendingReviewersByEmail();
+    }
+    return pendingReviewersByEmail;
+  }
+
+  public List<ReviewerStatusUpdate> reviewerUpdates() throws OrmException {
+    if (reviewerUpdates == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
+      reviewerUpdates = approvalsUtil.getReviewerUpdates(notes());
+    }
+    return reviewerUpdates;
+  }
+
+  public void setReviewerUpdates(List<ReviewerStatusUpdate> reviewerUpdates) {
+    this.reviewerUpdates = reviewerUpdates;
+  }
+
+  public List<ReviewerStatusUpdate> getReviewerUpdates() {
+    return reviewerUpdates;
+  }
+
+  public Collection<Comment> publishedComments() throws OrmException {
+    if (publishedComments == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
+      publishedComments = commentsUtil.publishedByChange(db, notes());
+    }
+    return publishedComments;
+  }
+
+  public Collection<RobotComment> robotComments() throws OrmException {
+    if (robotComments == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
+      robotComments = commentsUtil.robotCommentsByChange(notes());
+    }
+    return robotComments;
+  }
+
+  public Integer unresolvedCommentCount() throws OrmException {
+    if (unresolvedCommentCount == null) {
+      if (!lazyLoad) {
+        return null;
+      }
+
+      List<Comment> comments =
+          Stream.concat(publishedComments().stream(), robotComments().stream()).collect(toList());
+      Set<String> nonLeafSet = comments.stream().map(c -> c.parentUuid).collect(toSet());
+
+      Long count =
+          comments.stream().filter(c -> (c.unresolved && !nonLeafSet.contains(c.key.uuid))).count();
+      unresolvedCommentCount = count.intValue();
+    }
+    return unresolvedCommentCount;
+  }
+
+  public void setUnresolvedCommentCount(Integer count) {
+    this.unresolvedCommentCount = count;
+  }
+
+  public List<ChangeMessage> messages() throws OrmException {
+    if (messages == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
+      messages = cmUtil.byChange(db, notes());
+    }
+    return messages;
+  }
+
+  public List<SubmitRecord> submitRecords(SubmitRuleOptions options) throws OrmException {
+    List<SubmitRecord> records = submitRecords.get(options);
+    if (records == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
+      records =
+          submitRuleEvaluatorFactory
+              .create(userFactory.create(change().getOwner()), this)
+              .setOptions(options)
+              .evaluate();
+      submitRecords.put(options, records);
+    }
+    return records;
+  }
+
+  @Nullable
+  public List<SubmitRecord> getSubmitRecords(SubmitRuleOptions options) {
+    return submitRecords.get(options);
+  }
+
+  public void setSubmitRecords(SubmitRuleOptions options, List<SubmitRecord> records) {
+    submitRecords.put(options, records);
+  }
+
+  public SubmitTypeRecord submitTypeRecord() throws OrmException {
+    if (submitTypeRecord == null) {
+      submitTypeRecord =
+          submitRuleEvaluatorFactory
+              .create(userFactory.create(change().getOwner()), this)
+              .getSubmitType();
+    }
+    return submitTypeRecord;
+  }
+
+  public void setMergeable(Boolean mergeable) {
+    this.mergeable = mergeable;
+  }
+
+  @Nullable
+  public Boolean isMergeable() throws OrmException {
+    if (mergeable == null) {
+      Change c = change();
+      if (c == null) {
+        return null;
+      }
+      if (c.getStatus() == Change.Status.MERGED) {
+        mergeable = true;
+      } else if (c.getStatus() == Change.Status.ABANDONED) {
+        return null;
+      } else if (c.isWorkInProgress()) {
+        return null;
+      } else {
+        if (!lazyLoad) {
+          return null;
+        }
+        PatchSet ps = currentPatchSet();
+        if (ps == null) {
+          return null;
+        }
+
+        try (Repository repo = repoManager.openRepository(project())) {
+          Ref ref = repo.getRefDatabase().exactRef(c.getDest().get());
+          SubmitTypeRecord str = submitTypeRecord();
+          if (!str.isOk()) {
+            // If submit type rules are broken, it's definitely not mergeable.
+            // No need to log, as SubmitRuleEvaluator already did it for us.
+            return false;
+          }
+          String mergeStrategy =
+              mergeUtilFactory.create(projectCache.get(project())).mergeStrategyName();
+          mergeable =
+              mergeabilityCache.get(
+                  ObjectId.fromString(ps.getRevision().get()),
+                  ref,
+                  str.type,
+                  mergeStrategy,
+                  c.getDest(),
+                  repo);
+        } catch (IOException e) {
+          throw new OrmException(e);
+        }
+      }
+    }
+    return mergeable;
+  }
+
+  public Set<Account.Id> editsByUser() throws OrmException {
+    return editRefs().keySet();
+  }
+
+  public Map<Account.Id, Ref> editRefs() throws OrmException {
+    if (editsByUser == null) {
+      if (!lazyLoad) {
+        return Collections.emptyMap();
+      }
+      Change c = change();
+      if (c == null) {
+        return Collections.emptyMap();
+      }
+      editsByUser = new HashMap<>();
+      Change.Id id = checkNotNull(change.getId());
+      try (Repository repo = repoManager.openRepository(project())) {
+        for (Map.Entry<String, Ref> e :
+            repo.getRefDatabase().getRefs(RefNames.REFS_USERS).entrySet()) {
+          if (id.equals(Change.Id.fromEditRefPart(e.getKey()))) {
+            editsByUser.put(Account.Id.fromRefPart(e.getKey()), e.getValue());
+          }
+        }
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    }
+    return editsByUser;
+  }
+
+  public Set<Account.Id> draftsByUser() throws OrmException {
+    return draftRefs().keySet();
+  }
+
+  public Map<Account.Id, Ref> draftRefs() throws OrmException {
+    if (draftsByUser == null) {
+      if (!lazyLoad) {
+        return Collections.emptyMap();
+      }
+      Change c = change();
+      if (c == null) {
+        return Collections.emptyMap();
+      }
+
+      draftsByUser = new HashMap<>();
+      if (notesMigration.readChanges()) {
+        for (Ref ref : commentsUtil.getDraftRefs(notes.getChangeId())) {
+          Account.Id account = Account.Id.fromRefSuffix(ref.getName());
+          if (account != null
+              // Double-check that any drafts exist for this user after
+              // filtering out zombies. If some but not all drafts in the ref
+              // were zombies, the returned Ref still includes those zombies;
+              // this is suboptimal, but is ok for the purposes of
+              // draftsByUser(), and easier than trying to rebuild the change at
+              // this point.
+              && !notes().getDraftComments(account, ref).isEmpty()) {
+            draftsByUser.put(account, ref);
+          }
+        }
+      } else {
+        for (Comment sc : commentsUtil.draftByChange(db, notes())) {
+          draftsByUser.put(sc.author.getId(), null);
+        }
+      }
+    }
+    return draftsByUser;
+  }
+
+  public boolean isReviewedBy(Account.Id accountId) throws OrmException {
+    Collection<String> stars = stars(accountId);
+
+    if (stars.contains(
+        StarredChangesUtil.REVIEWED_LABEL + "/" + currentPatchSet().getPatchSetId())) {
+      return true;
+    }
+
+    if (stars.contains(
+        StarredChangesUtil.UNREVIEWED_LABEL + "/" + currentPatchSet().getPatchSetId())) {
+      return false;
+    }
+
+    return reviewedBy().contains(accountId);
+  }
+
+  public Set<Account.Id> reviewedBy() throws OrmException {
+    if (reviewedBy == null) {
+      if (!lazyLoad) {
+        return Collections.emptySet();
+      }
+      Change c = change();
+      if (c == null) {
+        return Collections.emptySet();
+      }
+      List<ReviewedByEvent> events = new ArrayList<>();
+      for (ChangeMessage msg : messages()) {
+        if (msg.getAuthor() != null) {
+          events.add(ReviewedByEvent.create(msg));
+        }
+      }
+      events = Lists.reverse(events);
+      reviewedBy = new LinkedHashSet<>();
+      Account.Id owner = c.getOwner();
+      for (ReviewedByEvent event : events) {
+        if (owner.equals(event.author())) {
+          break;
+        }
+        reviewedBy.add(event.author());
+      }
+    }
+    return reviewedBy;
+  }
+
+  public void setReviewedBy(Set<Account.Id> reviewedBy) {
+    this.reviewedBy = reviewedBy;
+  }
+
+  public Set<String> hashtags() throws OrmException {
+    if (hashtags == null) {
+      if (!lazyLoad) {
+        return Collections.emptySet();
+      }
+      hashtags = notes().getHashtags();
+    }
+    return hashtags;
+  }
+
+  public void setHashtags(Set<String> hashtags) {
+    this.hashtags = hashtags;
+  }
+
+  public ImmutableListMultimap<Account.Id, String> stars() throws OrmException {
+    if (stars == null) {
+      if (!lazyLoad) {
+        return ImmutableListMultimap.of();
+      }
+      ImmutableListMultimap.Builder<Account.Id, String> b = ImmutableListMultimap.builder();
+      for (Map.Entry<Account.Id, StarRef> e : starRefs().entrySet()) {
+        b.putAll(e.getKey(), e.getValue().labels());
+      }
+      return b.build();
+    }
+    return stars;
+  }
+
+  public void setStars(ListMultimap<Account.Id, String> stars) {
+    this.stars = ImmutableListMultimap.copyOf(stars);
+  }
+
+  public ImmutableMap<Account.Id, StarRef> starRefs() throws OrmException {
+    if (starRefs == null) {
+      if (!lazyLoad) {
+        return ImmutableMap.of();
+      }
+      starRefs = checkNotNull(starredChangesUtil).byChange(legacyId);
+    }
+    return starRefs;
+  }
+
+  public Set<String> stars(Account.Id accountId) throws OrmException {
+    if (starsOf != null) {
+      if (!starsOf.accountId().equals(accountId)) {
+        starsOf = null;
+      }
+    }
+    if (starsOf == null) {
+      if (stars != null) {
+        starsOf = StarsOf.create(accountId, stars.get(accountId));
+      } else {
+        if (!lazyLoad) {
+          return ImmutableSet.of();
+        }
+        starsOf = StarsOf.create(accountId, starredChangesUtil.getLabels(accountId, legacyId));
+      }
+    }
+    return starsOf.stars();
+  }
+
+  /**
+   * @return {@code null} if {@code revertOf} is {@code null}; true if the change is a pure revert;
+   *     false otherwise.
+   */
+  @Nullable
+  public Boolean isPureRevert() throws OrmException {
+    if (change().getRevertOf() == null) {
+      return null;
+    }
+    try {
+      return pureRevert.get(notes(), null).isPureRevert;
+    } catch (IOException | BadRequestException | ResourceConflictException e) {
+      throw new OrmException("could not compute pure revert", e);
+    }
+  }
+
+  @Override
+  public String toString() {
+    MoreObjects.ToStringHelper h = MoreObjects.toStringHelper(this);
+    if (change != null) {
+      h.addValue(change);
+    } else {
+      h.addValue(legacyId);
+    }
+    return h.toString();
+  }
+
+  public static class ChangedLines {
+    public final int insertions;
+    public final int deletions;
+
+    public ChangedLines(int insertions, int deletions) {
+      this.insertions = insertions;
+      this.deletions = deletions;
+    }
+  }
+
+  public ImmutableList<byte[]> getRefStates() {
+    return refStates;
+  }
+
+  public void setRefStates(Iterable<byte[]> refStates) {
+    this.refStates = ImmutableList.copyOf(refStates);
+  }
+
+  public ImmutableList<byte[]> getRefStatePatterns() {
+    return refStatePatterns;
+  }
+
+  public void setRefStatePatterns(Iterable<byte[]> refStatePatterns) {
+    this.refStatePatterns = ImmutableList.copyOf(refStatePatterns);
+  }
+
+  @AutoValue
+  abstract static class ReviewedByEvent {
+    private static ReviewedByEvent create(ChangeMessage msg) {
+      return new AutoValue_ChangeData_ReviewedByEvent(msg.getAuthor(), msg.getWrittenOn());
+    }
+
+    public abstract Account.Id author();
+
+    public abstract Timestamp ts();
+  }
+
+  @AutoValue
+  abstract static class StarsOf {
+    private static StarsOf create(Account.Id accountId, Iterable<String> stars) {
+      return new AutoValue_ChangeData_StarsOf(accountId, ImmutableSortedSet.copyOf(stars));
+    }
+
+    public abstract Account.Id accountId();
+
+    public abstract ImmutableSortedSet<String> stars();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataSource.java b/java/com/google/gerrit/server/query/change/ChangeDataSource.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataSource.java
rename to java/com/google/gerrit/server/query/change/ChangeDataSource.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
rename to java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
rename to java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
new file mode 100644
index 0000000..19549d9
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.index.query.IsVisibleToPredicate;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.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.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 CurrentUser user;
+  protected final PermissionBackend permissionBackend;
+
+  public ChangeIsVisibleToPredicate(
+      Provider<ReviewDb> db,
+      ChangeNotes.Factory notesFactory,
+      CurrentUser user,
+      PermissionBackend permissionBackend) {
+    super(ChangeQueryBuilder.FIELD_VISIBLETO, IndexUtils.describe(user));
+    this.db = db;
+    this.notesFactory = notesFactory;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    if (cd.fastIsVisibleTo(user)) {
+      return true;
+    }
+    Change change = cd.change();
+    if (change == null) {
+      return false;
+    }
+
+    ChangeNotes notes = notesFactory.createFromIndexedChange(change);
+    boolean visible;
+    try {
+      visible =
+          permissionBackend
+              .user(user)
+              .indexedChange(cd, notes)
+              .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(user);
+      return true;
+    }
+    return false;
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java b/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
rename to java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
new file mode 100644
index 0000000..3f41219
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -0,0 +1,1329 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
+import static com.google.gerrit.server.query.change.ChangeData.asChanges;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Enums;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.errors.NotSignedInException;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaUtil;
+import com.google.gerrit.index.query.LimitPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryRequiresAuthException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupBackends;
+import com.google.gerrit.server.account.GroupMembers;
+import com.google.gerrit.server.account.VersionedAccountDestinations;
+import com.google.gerrit.server.account.VersionedAccountQueries;
+import com.google.gerrit.server.change.ChangeTriplet;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.strategy.SubmitDryRun;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ChildProjects;
+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.ProvisionException;
+import com.google.inject.util.Providers;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+
+/** Parses a query string meant to be applied to change objects. */
+public class ChangeQueryBuilder extends QueryBuilder<ChangeData> {
+  public interface ChangeOperatorFactory extends OperatorFactory<ChangeData, ChangeQueryBuilder> {}
+
+  /**
+   * Converts a operand (operator value) passed to an operator into a {@link Predicate}.
+   *
+   * <p>Register a ChangeOperandFactory in a config Module like this (note, for an example we are
+   * using the has predicate, when other predicate plugin operands are created they can be
+   * registered in a similar manner):
+   *
+   * <p>bind(ChangeHasOperandFactory.class) .annotatedWith(Exports.named("your has operand"))
+   * .to(YourClass.class);
+   */
+  private interface ChangeOperandFactory {
+    Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException;
+  }
+
+  public interface ChangeHasOperandFactory extends ChangeOperandFactory {}
+
+  private static final Pattern PAT_LEGACY_ID = Pattern.compile("^[1-9][0-9]*$");
+  private static final Pattern PAT_CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
+  private static final Pattern DEF_CHANGE =
+      Pattern.compile("^(?:[1-9][0-9]*|(?:[^~]+~[^~]+~)?[iI][0-9a-f]{4,}.*)$");
+
+  static final int MAX_ACCOUNTS_PER_DEFAULT_FIELD = 10;
+
+  // NOTE: As new search operations are added, please keep the
+  // SearchSuggestOracle up to date.
+
+  public static final String FIELD_ADDED = "added";
+  public static final String FIELD_AGE = "age";
+  public static final String FIELD_ASSIGNEE = "assignee";
+  public static final String FIELD_AUTHOR = "author";
+  public static final String FIELD_EXACTAUTHOR = "exactauthor";
+  public static final String FIELD_BEFORE = "before";
+  public static final String FIELD_CHANGE = "change";
+  public static final String FIELD_CHANGE_ID = "change_id";
+  public static final String FIELD_COMMENT = "comment";
+  public static final String FIELD_COMMENTBY = "commentby";
+  public static final String FIELD_COMMIT = "commit";
+  public static final String FIELD_COMMITTER = "committer";
+  public static final String FIELD_EXACTCOMMITTER = "exactcommitter";
+  public static final String FIELD_CONFLICTS = "conflicts";
+  public static final String FIELD_DELETED = "deleted";
+  public static final String FIELD_DELTA = "delta";
+  public static final String FIELD_DESTINATION = "destination";
+  public static final String FIELD_DRAFTBY = "draftby";
+  public static final String FIELD_EDITBY = "editby";
+  public static final String FIELD_EXACTCOMMIT = "exactcommit";
+  public static final String FIELD_FILE = "file";
+  public static final String FIELD_FILEPART = "filepart";
+  public static final String FIELD_GROUP = "group";
+  public static final String FIELD_HASHTAG = "hashtag";
+  public static final String FIELD_LABEL = "label";
+  public static final String FIELD_LIMIT = "limit";
+  public static final String FIELD_MERGE = "merge";
+  public static final String FIELD_MERGEABLE = "mergeable2";
+  public static final String FIELD_MESSAGE = "message";
+  public static final String FIELD_OWNER = "owner";
+  public static final String FIELD_OWNERIN = "ownerin";
+  public static final String FIELD_PARENTPROJECT = "parentproject";
+  public static final String FIELD_PATH = "path";
+  public static final String FIELD_PENDING_REVIEWER = "pendingreviewer";
+  public static final String FIELD_PENDING_REVIEWER_BY_EMAIL = "pendingreviewerbyemail";
+  public static final String FIELD_PRIVATE = "private";
+  public static final String FIELD_PROJECT = "project";
+  public static final String FIELD_PROJECTS = "projects";
+  public static final String FIELD_REF = "ref";
+  public static final String FIELD_REVIEWEDBY = "reviewedby";
+  public static final String FIELD_REVIEWER = "reviewer";
+  public static final String FIELD_REVIEWERIN = "reviewerin";
+  public static final String FIELD_STAR = "star";
+  public static final String FIELD_STARBY = "starby";
+  public static final String FIELD_STARREDBY = "starredby";
+  public static final String FIELD_STARTED = "started";
+  public static final String FIELD_STATUS = "status";
+  public static final String FIELD_SUBMISSIONID = "submissionid";
+  public static final String FIELD_TR = "tr";
+  public static final String FIELD_UNRESOLVED_COMMENT_COUNT = "unresolved";
+  public static final String FIELD_VISIBLETO = "visibleto";
+  public static final String FIELD_WATCHEDBY = "watchedby";
+  public static final String FIELD_WIP = "wip";
+  public static final String FIELD_REVERTOF = "revertof";
+
+  public static final String ARG_ID_USER = "user";
+  public static final String ARG_ID_GROUP = "group";
+  public static final String ARG_ID_OWNER = "owner";
+  public static final Account.Id OWNER_ACCOUNT_ID = new Account.Id(0);
+
+  private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> mydef =
+      new QueryBuilder.Definition<>(ChangeQueryBuilder.class);
+
+  @VisibleForTesting
+  public static class Arguments {
+    final AccountCache accountCache;
+    final AccountResolver accountResolver;
+    final AllProjectsName allProjectsName;
+    final AllUsersName allUsersName;
+    final PermissionBackend permissionBackend;
+    final ChangeData.Factory changeDataFactory;
+    final ChangeIndex index;
+    final ChangeIndexRewriter rewriter;
+    final ChangeNotes.Factory notesFactory;
+    final CommentsUtil commentsUtil;
+    final ConflictsCache conflictsCache;
+    final DynamicMap<ChangeHasOperandFactory> hasOperands;
+    final DynamicMap<ChangeOperatorFactory> opFactories;
+    final GitRepositoryManager repoManager;
+    final GroupBackend groupBackend;
+    final IdentifiedUser.GenericFactory userFactory;
+    final IndexConfig indexConfig;
+    final NotesMigration notesMigration;
+    final PatchListCache patchListCache;
+    final ProjectCache projectCache;
+    final Provider<InternalChangeQuery> queryProvider;
+    final ChildProjects childProjects;
+    final Provider<ReviewDb> db;
+    final StarredChangesUtil starredChangesUtil;
+    final SubmitDryRun submitDryRun;
+    final boolean allowsDrafts;
+    final GroupMembers groupMembers;
+
+    private final Provider<CurrentUser> self;
+
+    @Inject
+    @VisibleForTesting
+    public Arguments(
+        Provider<ReviewDb> db,
+        Provider<InternalChangeQuery> queryProvider,
+        ChangeIndexRewriter rewriter,
+        DynamicMap<ChangeOperatorFactory> opFactories,
+        DynamicMap<ChangeHasOperandFactory> hasOperands,
+        IdentifiedUser.GenericFactory userFactory,
+        Provider<CurrentUser> self,
+        PermissionBackend permissionBackend,
+        ChangeNotes.Factory notesFactory,
+        ChangeData.Factory changeDataFactory,
+        CommentsUtil commentsUtil,
+        AccountResolver accountResolver,
+        GroupBackend groupBackend,
+        AllProjectsName allProjectsName,
+        AllUsersName allUsersName,
+        PatchListCache patchListCache,
+        GitRepositoryManager repoManager,
+        ProjectCache projectCache,
+        ChildProjects childProjects,
+        ChangeIndexCollection indexes,
+        SubmitDryRun submitDryRun,
+        ConflictsCache conflictsCache,
+        IndexConfig indexConfig,
+        StarredChangesUtil starredChangesUtil,
+        AccountCache accountCache,
+        @GerritServerConfig Config cfg,
+        NotesMigration notesMigration,
+        GroupMembers groupMembers) {
+      this(
+          db,
+          queryProvider,
+          rewriter,
+          opFactories,
+          hasOperands,
+          userFactory,
+          self,
+          permissionBackend,
+          notesFactory,
+          changeDataFactory,
+          commentsUtil,
+          accountResolver,
+          groupBackend,
+          allProjectsName,
+          allUsersName,
+          patchListCache,
+          repoManager,
+          projectCache,
+          childProjects,
+          submitDryRun,
+          conflictsCache,
+          indexes != null ? indexes.getSearchIndex() : null,
+          indexConfig,
+          starredChangesUtil,
+          accountCache,
+          cfg == null ? true : cfg.getBoolean("change", "allowDrafts", true),
+          notesMigration,
+          groupMembers);
+    }
+
+    private Arguments(
+        Provider<ReviewDb> db,
+        Provider<InternalChangeQuery> queryProvider,
+        ChangeIndexRewriter rewriter,
+        DynamicMap<ChangeOperatorFactory> opFactories,
+        DynamicMap<ChangeHasOperandFactory> hasOperands,
+        IdentifiedUser.GenericFactory userFactory,
+        Provider<CurrentUser> self,
+        PermissionBackend permissionBackend,
+        ChangeNotes.Factory notesFactory,
+        ChangeData.Factory changeDataFactory,
+        CommentsUtil commentsUtil,
+        AccountResolver accountResolver,
+        GroupBackend groupBackend,
+        AllProjectsName allProjectsName,
+        AllUsersName allUsersName,
+        PatchListCache patchListCache,
+        GitRepositoryManager repoManager,
+        ProjectCache projectCache,
+        ChildProjects childProjects,
+        SubmitDryRun submitDryRun,
+        ConflictsCache conflictsCache,
+        ChangeIndex index,
+        IndexConfig indexConfig,
+        StarredChangesUtil starredChangesUtil,
+        AccountCache accountCache,
+        boolean allowsDrafts,
+        NotesMigration notesMigration,
+        GroupMembers groupMembers) {
+      this.db = db;
+      this.queryProvider = queryProvider;
+      this.rewriter = rewriter;
+      this.opFactories = opFactories;
+      this.userFactory = userFactory;
+      this.self = self;
+      this.permissionBackend = permissionBackend;
+      this.notesFactory = notesFactory;
+      this.changeDataFactory = changeDataFactory;
+      this.commentsUtil = commentsUtil;
+      this.accountResolver = accountResolver;
+      this.groupBackend = groupBackend;
+      this.allProjectsName = allProjectsName;
+      this.allUsersName = allUsersName;
+      this.patchListCache = patchListCache;
+      this.repoManager = repoManager;
+      this.projectCache = projectCache;
+      this.childProjects = childProjects;
+      this.submitDryRun = submitDryRun;
+      this.conflictsCache = conflictsCache;
+      this.index = index;
+      this.indexConfig = indexConfig;
+      this.starredChangesUtil = starredChangesUtil;
+      this.accountCache = accountCache;
+      this.allowsDrafts = allowsDrafts;
+      this.hasOperands = hasOperands;
+      this.notesMigration = notesMigration;
+      this.groupMembers = groupMembers;
+    }
+
+    Arguments asUser(CurrentUser otherUser) {
+      return new Arguments(
+          db,
+          queryProvider,
+          rewriter,
+          opFactories,
+          hasOperands,
+          userFactory,
+          Providers.of(otherUser),
+          permissionBackend,
+          notesFactory,
+          changeDataFactory,
+          commentsUtil,
+          accountResolver,
+          groupBackend,
+          allProjectsName,
+          allUsersName,
+          patchListCache,
+          repoManager,
+          projectCache,
+          childProjects,
+          submitDryRun,
+          conflictsCache,
+          index,
+          indexConfig,
+          starredChangesUtil,
+          accountCache,
+          allowsDrafts,
+          notesMigration,
+          groupMembers);
+    }
+
+    Arguments asUser(Account.Id otherId) {
+      try {
+        CurrentUser u = self.get();
+        if (u.isIdentifiedUser() && otherId.equals(u.getAccountId())) {
+          return this;
+        }
+      } catch (ProvisionException e) {
+        // Doesn't match current user, continue.
+      }
+      return asUser(userFactory.create(otherId));
+    }
+
+    IdentifiedUser getIdentifiedUser() throws QueryRequiresAuthException {
+      try {
+        CurrentUser u = getUser();
+        if (u.isIdentifiedUser()) {
+          return u.asIdentifiedUser();
+        }
+        throw new QueryRequiresAuthException(NotSignedInException.MESSAGE);
+      } catch (ProvisionException e) {
+        throw new QueryRequiresAuthException(NotSignedInException.MESSAGE, e);
+      }
+    }
+
+    CurrentUser getUser() throws QueryRequiresAuthException {
+      try {
+        return self.get();
+      } catch (ProvisionException e) {
+        throw new QueryRequiresAuthException(NotSignedInException.MESSAGE, e);
+      }
+    }
+
+    Schema<ChangeData> getSchema() {
+      return index != null ? index.getSchema() : null;
+    }
+  }
+
+  private final Arguments args;
+
+  @Inject
+  ChangeQueryBuilder(Arguments args) {
+    super(mydef);
+    this.args = args;
+    setupDynamicOperators();
+  }
+
+  @VisibleForTesting
+  protected ChangeQueryBuilder(
+      Definition<ChangeData, ? extends QueryBuilder<ChangeData>> def, Arguments args) {
+    super(def);
+    this.args = args;
+  }
+
+  private void setupDynamicOperators() {
+    for (DynamicMap.Entry<ChangeOperatorFactory> e : args.opFactories) {
+      String name = e.getExportName() + "_" + e.getPluginName();
+      opFactories.put(name, e.getProvider().get());
+    }
+  }
+
+  public Arguments getArgs() {
+    return args;
+  }
+
+  public ChangeQueryBuilder asUser(CurrentUser user) {
+    return new ChangeQueryBuilder(builderDef, args.asUser(user));
+  }
+
+  @Operator
+  public Predicate<ChangeData> age(String value) {
+    return new AgePredicate(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> before(String value) throws QueryParseException {
+    return new BeforePredicate(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> until(String value) throws QueryParseException {
+    return before(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> after(String value) throws QueryParseException {
+    return new AfterPredicate(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> since(String value) throws QueryParseException {
+    return after(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> change(String query) throws QueryParseException {
+    Optional<ChangeTriplet> triplet = ChangeTriplet.parse(query);
+    if (triplet.isPresent()) {
+      return Predicate.and(
+          project(triplet.get().project().get()),
+          branch(triplet.get().branch().get()),
+          new ChangeIdPredicate(parseChangeId(triplet.get().id().get())));
+    }
+    if (PAT_LEGACY_ID.matcher(query).matches()) {
+      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));
+    }
+
+    throw new QueryParseException("Invalid change format");
+  }
+
+  @Operator
+  public Predicate<ChangeData> comment(String value) {
+    return new CommentPredicate(args.index, value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> status(String statusName) {
+    if ("reviewed".equalsIgnoreCase(statusName)) {
+      return IsReviewedPredicate.create();
+    }
+    return ChangeStatusPredicate.parse(statusName);
+  }
+
+  public Predicate<ChangeData> status_open() {
+    return ChangeStatusPredicate.open();
+  }
+
+  @Operator
+  public Predicate<ChangeData> has(String value) throws QueryParseException {
+    if ("star".equalsIgnoreCase(value)) {
+      return starredby(self());
+    }
+
+    if ("stars".equalsIgnoreCase(value)) {
+      return new HasStarsPredicate(self());
+    }
+
+    if ("draft".equalsIgnoreCase(value)) {
+      return draftby(self());
+    }
+
+    if ("edit".equalsIgnoreCase(value)) {
+      return new EditByPredicate(self());
+    }
+
+    if ("unresolved".equalsIgnoreCase(value)) {
+      return new IsUnresolvedPredicate();
+    }
+
+    // for plugins the value will be operandName_pluginName
+    String[] names = value.split("_");
+    if (names.length == 2) {
+      ChangeHasOperandFactory op = args.hasOperands.get(names[1], names[0]);
+      if (op != null) {
+        return op.create(this);
+      }
+    }
+
+    throw new IllegalArgumentException();
+  }
+
+  @Operator
+  public Predicate<ChangeData> is(String value) throws QueryParseException {
+    if ("starred".equalsIgnoreCase(value)) {
+      return starredby(self());
+    }
+
+    if ("watched".equalsIgnoreCase(value)) {
+      return new IsWatchedByPredicate(args, false);
+    }
+
+    if ("visible".equalsIgnoreCase(value)) {
+      return is_visible();
+    }
+
+    if ("reviewed".equalsIgnoreCase(value)) {
+      return IsReviewedPredicate.create();
+    }
+
+    if ("owner".equalsIgnoreCase(value)) {
+      return new OwnerPredicate(self());
+    }
+
+    if ("reviewer".equalsIgnoreCase(value)) {
+      if (args.getSchema().hasField(ChangeField.WIP)) {
+        return Predicate.and(
+            Predicate.not(new BooleanPredicate(ChangeField.WIP)),
+            ReviewerPredicate.reviewer(args, self()));
+      }
+      return ReviewerPredicate.reviewer(args, self());
+    }
+
+    if ("cc".equalsIgnoreCase(value)) {
+      return ReviewerPredicate.cc(self());
+    }
+
+    if ("mergeable".equalsIgnoreCase(value)) {
+      return new BooleanPredicate(ChangeField.MERGEABLE);
+    }
+
+    if ("private".equalsIgnoreCase(value)) {
+      if (args.getSchema().hasField(ChangeField.PRIVATE)) {
+        return new BooleanPredicate(ChangeField.PRIVATE);
+      }
+      throw new QueryParseException(
+          "'is:private' operator is not supported by change index version");
+    }
+
+    if ("assigned".equalsIgnoreCase(value)) {
+      return Predicate.not(new AssigneePredicate(new Account.Id(ChangeField.NO_ASSIGNEE)));
+    }
+
+    if ("unassigned".equalsIgnoreCase(value)) {
+      return new AssigneePredicate(new Account.Id(ChangeField.NO_ASSIGNEE));
+    }
+
+    if ("submittable".equalsIgnoreCase(value)) {
+      return new SubmittablePredicate(SubmitRecord.Status.OK);
+    }
+
+    if ("ignored".equalsIgnoreCase(value)) {
+      return star("ignore");
+    }
+
+    if ("started".equalsIgnoreCase(value)) {
+      if (args.getSchema().hasField(ChangeField.STARTED)) {
+        return new BooleanPredicate(ChangeField.STARTED);
+      }
+      throw new QueryParseException(
+          "'is:started' operator is not supported by change index version");
+    }
+
+    if ("wip".equalsIgnoreCase(value)) {
+      if (args.getSchema().hasField(ChangeField.WIP)) {
+        return new BooleanPredicate(ChangeField.WIP);
+      }
+      throw new QueryParseException("'is:wip' operator is not supported by change index version");
+    }
+
+    return status(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> commit(String id) {
+    return new CommitPredicate(id);
+  }
+
+  @Operator
+  public Predicate<ChangeData> conflicts(String value) throws OrmException, QueryParseException {
+    List<Change> changes = parseChange(value);
+    List<Predicate<ChangeData>> or = new ArrayList<>(changes.size());
+    for (Change c : changes) {
+      or.add(ConflictsPredicate.create(args, value, c));
+    }
+    return Predicate.or(or);
+  }
+
+  @Operator
+  public Predicate<ChangeData> p(String name) {
+    return project(name);
+  }
+
+  @Operator
+  public Predicate<ChangeData> project(String name) {
+    if (name.startsWith("^")) {
+      return new RegexProjectPredicate(name);
+    }
+    return new ProjectPredicate(name);
+  }
+
+  @Operator
+  public Predicate<ChangeData> projects(String name) {
+    return new ProjectPrefixPredicate(name);
+  }
+
+  @Operator
+  public Predicate<ChangeData> parentproject(String name) {
+    return new ParentProjectPredicate(args.projectCache, args.childProjects, name);
+  }
+
+  @Operator
+  public Predicate<ChangeData> branch(String name) {
+    if (name.startsWith("^")) {
+      return ref("^" + RefNames.fullName(name.substring(1)));
+    }
+    return ref(RefNames.fullName(name));
+  }
+
+  @Operator
+  public Predicate<ChangeData> hashtag(String hashtag) {
+    return new HashtagPredicate(hashtag);
+  }
+
+  @Operator
+  public Predicate<ChangeData> topic(String name) {
+    return new ExactTopicPredicate(name);
+  }
+
+  @Operator
+  public Predicate<ChangeData> intopic(String name) {
+    if (name.startsWith("^")) {
+      return new RegexTopicPredicate(name);
+    }
+    if (name.isEmpty()) {
+      return new ExactTopicPredicate(name);
+    }
+    return new FuzzyTopicPredicate(name, args.index);
+  }
+
+  @Operator
+  public Predicate<ChangeData> ref(String ref) {
+    if (ref.startsWith("^")) {
+      return new RegexRefPredicate(ref);
+    }
+    return new RefPredicate(ref);
+  }
+
+  @Operator
+  public Predicate<ChangeData> f(String file) {
+    return file(file);
+  }
+
+  @Operator
+  public Predicate<ChangeData> file(String file) {
+    if (file.startsWith("^")) {
+      return new RegexPathPredicate(file);
+    }
+    return EqualsFilePredicate.create(args, file);
+  }
+
+  @Operator
+  public Predicate<ChangeData> path(String path) {
+    if (path.startsWith("^")) {
+      return new RegexPathPredicate(path);
+    }
+    return new EqualsPathPredicate(FIELD_PATH, path);
+  }
+
+  @Operator
+  public Predicate<ChangeData> label(String name)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    Set<Account.Id> accounts = null;
+    AccountGroup.UUID group = null;
+
+    // Parse for:
+    // label:CodeReview=1,user=jsmith or
+    // label:CodeReview=1,jsmith or
+    // label:CodeReview=1,group=android_approvers or
+    // label:CodeReview=1,android_approvers
+    // user/groups without a label will first attempt to match user
+    // Special case: votes by owners can be tracked with ",owner":
+    // label:Code-Review+2,owner
+    // label:Code-Review+2,user=owner
+    String[] splitReviewer = name.split(",", 2);
+    name = splitReviewer[0]; // remove all but the vote piece, e.g.'CodeReview=1'
+
+    if (splitReviewer.length == 2) {
+      // process the user/group piece
+      PredicateArgs lblArgs = new PredicateArgs(splitReviewer[1]);
+
+      for (Map.Entry<String, String> pair : lblArgs.keyValue.entrySet()) {
+        if (pair.getKey().equalsIgnoreCase(ARG_ID_USER)) {
+          if (pair.getValue().equals(ARG_ID_OWNER)) {
+            accounts = Collections.singleton(OWNER_ACCOUNT_ID);
+          } else {
+            accounts = parseAccount(pair.getValue());
+          }
+        } else if (pair.getKey().equalsIgnoreCase(ARG_ID_GROUP)) {
+          group = parseGroup(pair.getValue()).getUUID();
+        } else {
+          throw new QueryParseException("Invalid argument identifier '" + pair.getKey() + "'");
+        }
+      }
+
+      for (String value : lblArgs.positional) {
+        if (accounts != null || group != null) {
+          throw new QueryParseException("more than one user/group specified (" + value + ")");
+        }
+        try {
+          if (value.equals(ARG_ID_OWNER)) {
+            accounts = Collections.singleton(OWNER_ACCOUNT_ID);
+          } else {
+            accounts = parseAccount(value);
+          }
+        } catch (QueryParseException qpex) {
+          // If it doesn't match an account, see if it matches a group
+          // (accounts get precedence)
+          try {
+            group = parseGroup(value).getUUID();
+          } catch (QueryParseException e) {
+            throw error("Neither user nor group " + value + " found", e);
+          }
+        }
+      }
+    }
+
+    // expand a group predicate into multiple user predicates
+    if (group != null) {
+      Set<Account.Id> allMembers =
+          args.groupMembers.listAccounts(group).stream().map(a -> a.getId()).collect(toSet());
+
+      int maxTerms = args.indexConfig.maxTerms();
+      if (allMembers.size() > maxTerms) {
+        // limit the number of query terms otherwise Gerrit will barf
+        accounts = ImmutableSet.copyOf(Iterables.limit(allMembers, maxTerms));
+      } else {
+        accounts = allMembers;
+      }
+    }
+
+    // If the vote piece looks like Code-Review=NEED with a valid non-numeric
+    // submit record status, interpret as a submit record query.
+    int eq = name.indexOf('=');
+    if (args.getSchema().hasField(ChangeField.SUBMIT_RECORD) && eq > 0) {
+      String statusName = name.substring(eq + 1).toUpperCase();
+      if (!isInt(statusName)) {
+        SubmitRecord.Label.Status status =
+            Enums.getIfPresent(SubmitRecord.Label.Status.class, statusName).orNull();
+        if (status == null) {
+          throw error("Invalid label status " + statusName + " in " + name);
+        }
+        return SubmitRecordPredicate.create(name.substring(0, eq), status, accounts);
+      }
+    }
+
+    return new LabelPredicate(args, name, accounts, group);
+  }
+
+  private static boolean isInt(String s) {
+    if (s == null) {
+      return false;
+    }
+    if (s.startsWith("+")) {
+      s = s.substring(1);
+    }
+    return Ints.tryParse(s) != null;
+  }
+
+  @Operator
+  public Predicate<ChangeData> message(String text) {
+    return new MessagePredicate(args.index, text);
+  }
+
+  @Operator
+  public Predicate<ChangeData> star(String label) throws QueryParseException {
+    return new StarPredicate(self(), label);
+  }
+
+  @Operator
+  public Predicate<ChangeData> starredby(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    return starredby(parseAccount(who));
+  }
+
+  private Predicate<ChangeData> starredby(Set<Account.Id> who) {
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
+    for (Account.Id id : who) {
+      p.add(starredby(id));
+    }
+    return Predicate.or(p);
+  }
+
+  private Predicate<ChangeData> starredby(Account.Id who) {
+    return new StarPredicate(who, StarredChangesUtil.DEFAULT_LABEL);
+  }
+
+  @Operator
+  public Predicate<ChangeData> watchedby(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    Set<Account.Id> m = parseAccount(who);
+    List<IsWatchedByPredicate> p = Lists.newArrayListWithCapacity(m.size());
+
+    Account.Id callerId;
+    try {
+      CurrentUser caller = args.self.get();
+      callerId = caller.isIdentifiedUser() ? caller.getAccountId() : null;
+    } catch (ProvisionException e) {
+      callerId = null;
+    }
+
+    for (Account.Id id : m) {
+      // Each child IsWatchedByPredicate includes a visibility filter for the
+      // corresponding user, to ensure that predicate subtree only returns
+      // changes visible to that user. The exception is if one of the users is
+      // the caller of this method, in which case visibility is already being
+      // checked at the top level.
+      p.add(new IsWatchedByPredicate(args.asUser(id), !id.equals(callerId)));
+    }
+    return Predicate.or(p);
+  }
+
+  @Operator
+  public Predicate<ChangeData> draftby(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    Set<Account.Id> m = parseAccount(who);
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
+    for (Account.Id id : m) {
+      p.add(draftby(id));
+    }
+    return Predicate.or(p);
+  }
+
+  private Predicate<ChangeData> draftby(Account.Id who) {
+    return new HasDraftByPredicate(who);
+  }
+
+  private boolean isSelf(String who) {
+    return "self".equals(who) || "me".equals(who);
+  }
+
+  @Operator
+  public Predicate<ChangeData> visibleto(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    if (isSelf(who)) {
+      return is_visible();
+    }
+    Set<Account.Id> m = args.accountResolver.findAll(who);
+    if (!m.isEmpty()) {
+      List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
+      for (Account.Id id : m) {
+        return visibleto(args.userFactory.create(id));
+      }
+      return Predicate.or(p);
+    }
+
+    // If its not an account, maybe its a group?
+    Collection<GroupReference> suggestions = args.groupBackend.suggest(who, null);
+    if (!suggestions.isEmpty()) {
+      HashSet<AccountGroup.UUID> ids = new HashSet<>();
+      for (GroupReference ref : suggestions) {
+        ids.add(ref.getUUID());
+      }
+      return visibleto(new SingleGroupUser(ids));
+    }
+
+    throw error("No user or group matches \"" + who + "\".");
+  }
+
+  public Predicate<ChangeData> visibleto(CurrentUser user) {
+    return new ChangeIsVisibleToPredicate(args.db, args.notesFactory, user, args.permissionBackend);
+  }
+
+  public Predicate<ChangeData> is_visible() throws QueryParseException {
+    return visibleto(args.getUser());
+  }
+
+  @Operator
+  public Predicate<ChangeData> o(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    return owner(who);
+  }
+
+  @Operator
+  public Predicate<ChangeData> owner(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    return owner(parseAccount(who));
+  }
+
+  private Predicate<ChangeData> owner(Set<Account.Id> who) {
+    List<OwnerPredicate> p = Lists.newArrayListWithCapacity(who.size());
+    for (Account.Id id : who) {
+      p.add(new OwnerPredicate(id));
+    }
+    return Predicate.or(p);
+  }
+
+  private Predicate<ChangeData> ownerDefaultField(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    Set<Account.Id> accounts = parseAccount(who);
+    if (accounts.size() > MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
+      return Predicate.any();
+    }
+    return owner(accounts);
+  }
+
+  @Operator
+  public Predicate<ChangeData> assignee(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    return assignee(parseAccount(who));
+  }
+
+  private Predicate<ChangeData> assignee(Set<Account.Id> who) {
+    List<AssigneePredicate> p = Lists.newArrayListWithCapacity(who.size());
+    for (Account.Id id : who) {
+      p.add(new AssigneePredicate(id));
+    }
+    return Predicate.or(p);
+  }
+
+  @Operator
+  public Predicate<ChangeData> ownerin(String group) throws QueryParseException {
+    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
+    if (g == null) {
+      throw error("Group " + group + " not found");
+    }
+    return new OwnerinPredicate(args.userFactory, g.getUUID());
+  }
+
+  @Operator
+  public Predicate<ChangeData> r(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    return reviewer(who);
+  }
+
+  @Operator
+  public Predicate<ChangeData> reviewer(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    return reviewer(who, false);
+  }
+
+  private Predicate<ChangeData> reviewerDefaultField(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    return reviewer(who, true);
+  }
+
+  private Predicate<ChangeData> reviewer(String who, boolean forDefaultField)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    Predicate<ChangeData> byState =
+        reviewerByState(who, ReviewerStateInternal.REVIEWER, forDefaultField);
+    if (Objects.equals(byState, Predicate.<ChangeData>any())) {
+      return Predicate.any();
+    }
+    if (args.getSchema().hasField(ChangeField.WIP)) {
+      return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP)), byState);
+    }
+    return byState;
+  }
+
+  @Operator
+  public Predicate<ChangeData> cc(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    return reviewerByState(who, ReviewerStateInternal.CC, false);
+  }
+
+  @Operator
+  public Predicate<ChangeData> reviewerin(String group) throws QueryParseException {
+    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
+    if (g == null) {
+      throw error("Group " + group + " not found");
+    }
+    return new ReviewerinPredicate(args.userFactory, g.getUUID());
+  }
+
+  @Operator
+  public Predicate<ChangeData> tr(String trackingId) {
+    return new TrackingIdPredicate(trackingId);
+  }
+
+  @Operator
+  public Predicate<ChangeData> bug(String trackingId) {
+    return tr(trackingId);
+  }
+
+  @Operator
+  public Predicate<ChangeData> limit(String query) throws QueryParseException {
+    Integer limit = Ints.tryParse(query);
+    if (limit == null) {
+      throw error("Invalid limit: " + query);
+    }
+    return new LimitPredicate<>(FIELD_LIMIT, limit);
+  }
+
+  @Operator
+  public Predicate<ChangeData> added(String value) throws QueryParseException {
+    return new AddedPredicate(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> deleted(String value) throws QueryParseException {
+    return new DeletedPredicate(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> size(String value) throws QueryParseException {
+    return delta(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> delta(String value) throws QueryParseException {
+    return new DeltaPredicate(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> commentby(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    return commentby(parseAccount(who));
+  }
+
+  private Predicate<ChangeData> commentby(Set<Account.Id> who) {
+    List<CommentByPredicate> p = Lists.newArrayListWithCapacity(who.size());
+    for (Account.Id id : who) {
+      p.add(new CommentByPredicate(id));
+    }
+    return Predicate.or(p);
+  }
+
+  @Operator
+  public Predicate<ChangeData> from(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    Set<Account.Id> ownerIds = parseAccount(who);
+    return Predicate.or(owner(ownerIds), commentby(ownerIds));
+  }
+
+  @Operator
+  public Predicate<ChangeData> query(String name) throws QueryParseException {
+    try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
+      VersionedAccountQueries q = VersionedAccountQueries.forUser(self());
+      q.load(git);
+      String query = q.getQueryList().getQuery(name);
+      if (query != null) {
+        return parse(query);
+      }
+    } catch (RepositoryNotFoundException e) {
+      throw new QueryParseException(
+          "Unknown named query (no " + args.allUsersName + " repo): " + name, e);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new QueryParseException("Error parsing named query: " + name, e);
+    }
+    throw new QueryParseException("Unknown named query: " + name);
+  }
+
+  @Operator
+  public Predicate<ChangeData> reviewedby(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    return IsReviewedPredicate.create(parseAccount(who));
+  }
+
+  @Operator
+  public Predicate<ChangeData> destination(String name) throws QueryParseException {
+    try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
+      VersionedAccountDestinations d = VersionedAccountDestinations.forUser(self());
+      d.load(git);
+      Set<Branch.NameKey> destinations = d.getDestinationList().getDestinations(name);
+      if (destinations != null) {
+        return new DestinationPredicate(destinations, name);
+      }
+    } catch (RepositoryNotFoundException e) {
+      throw new QueryParseException(
+          "Unknown named destination (no " + args.allUsersName + " repo): " + name, e);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new QueryParseException("Error parsing named destination: " + name, e);
+    }
+    throw new QueryParseException("Unknown named destination: " + name);
+  }
+
+  @Operator
+  public Predicate<ChangeData> author(String who) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.EXACT_AUTHOR)) {
+      return getAuthorOrCommitterPredicate(
+          who.trim(), ExactAuthorPredicate::new, AuthorPredicate::new);
+    }
+    return getAuthorOrCommitterFullTextPredicate(who.trim(), AuthorPredicate::new);
+  }
+
+  @Operator
+  public Predicate<ChangeData> committer(String who) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.EXACT_COMMITTER)) {
+      return getAuthorOrCommitterPredicate(
+          who.trim(), ExactCommitterPredicate::new, CommitterPredicate::new);
+    }
+    return getAuthorOrCommitterFullTextPredicate(who.trim(), CommitterPredicate::new);
+  }
+
+  @Operator
+  public Predicate<ChangeData> submittable(String str) throws QueryParseException {
+    SubmitRecord.Status status =
+        Enums.getIfPresent(SubmitRecord.Status.class, str.toUpperCase()).orNull();
+    if (status == null) {
+      throw error("invalid value for submittable:" + str);
+    }
+    return new SubmittablePredicate(status);
+  }
+
+  @Operator
+  public Predicate<ChangeData> unresolved(String value) throws QueryParseException {
+    return new IsUnresolvedPredicate(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> revertof(String value) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.REVERT_OF)) {
+      return new RevertOfPredicate(value);
+    }
+    throw new QueryParseException("'revertof' operator is not supported by change index version");
+  }
+
+  @Override
+  protected Predicate<ChangeData> defaultField(String query) throws QueryParseException {
+    if (query.startsWith("refs/")) {
+      return ref(query);
+    } else if (DEF_CHANGE.matcher(query).matches()) {
+      List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(2);
+      try {
+        predicates.add(change(query));
+      } catch (QueryParseException e) {
+        // Skip.
+      }
+
+      // For PAT_LEGACY_ID, it may also be the prefix of some commits.
+      if (query.length() >= 6 && PAT_LEGACY_ID.matcher(query).matches()) {
+        predicates.add(commit(query));
+      }
+
+      return Predicate.or(predicates);
+    }
+
+    // Adapt the capacity of this list when adding more default predicates.
+    List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(11);
+    try {
+      Predicate<ChangeData> p = ownerDefaultField(query);
+      if (!Objects.equals(p, Predicate.<ChangeData>any())) {
+        predicates.add(p);
+      }
+    } catch (OrmException | IOException | ConfigInvalidException | QueryParseException e) {
+      // Skip.
+    }
+    try {
+      Predicate<ChangeData> p = reviewerDefaultField(query);
+      if (!Objects.equals(p, Predicate.<ChangeData>any())) {
+        predicates.add(p);
+      }
+    } catch (OrmException | IOException | ConfigInvalidException | QueryParseException e) {
+      // Skip.
+    }
+    predicates.add(file(query));
+    try {
+      predicates.add(label(query));
+    } catch (OrmException | IOException | ConfigInvalidException | QueryParseException e) {
+      // Skip.
+    }
+    predicates.add(commit(query));
+    predicates.add(message(query));
+    predicates.add(comment(query));
+    predicates.add(projects(query));
+    predicates.add(ref(query));
+    predicates.add(branch(query));
+    predicates.add(topic(query));
+    // Adapt the capacity of the "predicates" list when adding more default
+    // predicates.
+    return Predicate.or(predicates);
+  }
+
+  private Predicate<ChangeData> getAuthorOrCommitterPredicate(
+      String who,
+      Function<String, Predicate<ChangeData>> exactPredicateFunc,
+      Function<String, Predicate<ChangeData>> fullPredicateFunc)
+      throws QueryParseException {
+    if (Address.tryParse(who) != null) {
+      return exactPredicateFunc.apply(who);
+    }
+    return getAuthorOrCommitterFullTextPredicate(who, fullPredicateFunc);
+  }
+
+  private Predicate<ChangeData> getAuthorOrCommitterFullTextPredicate(
+      String who, Function<String, Predicate<ChangeData>> fullPredicateFunc)
+      throws QueryParseException {
+    Set<String> parts = SchemaUtil.getNameParts(who);
+    if (parts.isEmpty()) {
+      throw error("invalid value");
+    }
+
+    List<Predicate<ChangeData>> predicates =
+        parts.stream().map(fullPredicateFunc).collect(toList());
+    return Predicate.and(predicates);
+  }
+
+  private Set<Account.Id> parseAccount(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    if (isSelf(who)) {
+      return Collections.singleton(self());
+    }
+    Set<Account.Id> matches = args.accountResolver.findAll(who);
+    if (matches.isEmpty()) {
+      throw error("User " + who + " not found");
+    }
+    return matches;
+  }
+
+  private GroupReference parseGroup(String group) throws QueryParseException {
+    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
+    if (g == null) {
+      throw error("Group " + group + " not found");
+    }
+    return g;
+  }
+
+  private List<Change> parseChange(String value) throws OrmException, QueryParseException {
+    if (PAT_LEGACY_ID.matcher(value).matches()) {
+      return asChanges(args.queryProvider.get().byLegacyChangeId(Change.Id.parse(value)));
+    } else if (PAT_CHANGE_ID.matcher(value).matches()) {
+      List<Change> changes = asChanges(args.queryProvider.get().byKeyPrefix(parseChangeId(value)));
+      if (changes.isEmpty()) {
+        throw error("Change " + value + " not found");
+      }
+      return changes;
+    }
+
+    throw error("Change " + value + " not found");
+  }
+
+  private static String parseChangeId(String value) {
+    if (value.charAt(0) == 'i') {
+      value = "I" + value.substring(1);
+    }
+    return value;
+  }
+
+  private Account.Id self() throws QueryParseException {
+    return args.getIdentifiedUser().getAccountId();
+  }
+
+  public Predicate<ChangeData> reviewerByState(
+      String who, ReviewerStateInternal state, boolean forDefaultField)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    Predicate<ChangeData> reviewerByEmailPredicate = null;
+    if (args.index.getSchema().hasField(ChangeField.REVIEWER_BY_EMAIL)) {
+      Address address = Address.tryParse(who);
+      if (address != null) {
+        reviewerByEmailPredicate = ReviewerByEmailPredicate.forState(address, state);
+      }
+    }
+
+    Predicate<ChangeData> reviewerPredicate = null;
+    try {
+      Set<Account.Id> accounts = parseAccount(who);
+      if (!forDefaultField || accounts.size() <= MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
+        reviewerPredicate =
+            Predicate.or(
+                accounts
+                    .stream()
+                    .map(id -> ReviewerPredicate.forState(id, state))
+                    .collect(toList()));
+      }
+    } catch (QueryParseException e) {
+      // Propagate this exception only if we can't use 'who' to query by email
+      if (reviewerByEmailPredicate == null) {
+        throw e;
+      }
+    }
+
+    if (reviewerPredicate != null && reviewerByEmailPredicate != null) {
+      return Predicate.or(reviewerPredicate, reviewerByEmailPredicate);
+    } else if (reviewerPredicate != null) {
+      return reviewerPredicate;
+    } else if (reviewerByEmailPredicate != null) {
+      return reviewerByEmailPredicate;
+    } else {
+      return Predicate.any();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
new file mode 100644
index 0000000..b190cd2
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -0,0 +1,144 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
+
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryProcessor;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountLimits;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.change.IndexedChangeQuery;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Query processor for the change index.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
+public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
+    implements PluginDefinedAttributesFactory {
+  /**
+   * Register a ChangeAttributeFactory in a config Module like this:
+   *
+   * <p>bind(ChangeAttributeFactory.class) .annotatedWith(Exports.named("export-name"))
+   * .to(YourClass.class);
+   */
+  public interface ChangeAttributeFactory {
+    PluginDefinedInfo create(ChangeData a, ChangeQueryProcessor qp, String plugin);
+  }
+
+  private final Provider<ReviewDb> db;
+  private final Provider<CurrentUser> userProvider;
+  private final ChangeNotes.Factory notesFactory;
+  private final DynamicMap<ChangeAttributeFactory> attributeFactories;
+  private final PermissionBackend permissionBackend;
+
+  static {
+    // It is assumed that basic rewrites do not touch visibleto predicates.
+    checkState(
+        !ChangeIsVisibleToPredicate.class.isAssignableFrom(IndexPredicate.class),
+        "ChangeQueryProcessor assumes visibleto is not used by the index rewriter.");
+  }
+
+  @Inject
+  ChangeQueryProcessor(
+      Provider<CurrentUser> userProvider,
+      AccountLimits.Factory limitsFactory,
+      MetricMaker metricMaker,
+      IndexConfig indexConfig,
+      ChangeIndexCollection indexes,
+      ChangeIndexRewriter rewriter,
+      Provider<ReviewDb> db,
+      ChangeNotes.Factory notesFactory,
+      DynamicMap<ChangeAttributeFactory> attributeFactories,
+      PermissionBackend permissionBackend) {
+    super(
+        metricMaker,
+        ChangeSchemaDefinitions.INSTANCE,
+        indexConfig,
+        indexes,
+        rewriter,
+        FIELD_LIMIT,
+        () -> limitsFactory.create(userProvider.get()).getQueryLimit());
+    this.db = db;
+    this.userProvider = userProvider;
+    this.notesFactory = notesFactory;
+    this.attributeFactories = attributeFactories;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public ChangeQueryProcessor enforceVisibility(boolean enforce) {
+    super.enforceVisibility(enforce);
+    return this;
+  }
+
+  @Override
+  protected QueryOptions createOptions(
+      IndexConfig indexConfig, int start, int limit, Set<String> requestedFields) {
+    return IndexedChangeQuery.createOptions(indexConfig, start, limit, requestedFields);
+  }
+
+  @Override
+  public List<PluginDefinedInfo> create(ChangeData cd) {
+    List<PluginDefinedInfo> plugins = new ArrayList<>(attributeFactories.plugins().size());
+    for (String plugin : attributeFactories.plugins()) {
+      for (Provider<ChangeAttributeFactory> provider :
+          attributeFactories.byPlugin(plugin).values()) {
+        PluginDefinedInfo pda = null;
+        try {
+          pda = provider.get().create(cd, this, plugin);
+        } catch (RuntimeException e) {
+          /* Eat runtime exceptions so that queries don't fail. */
+        }
+        if (pda != null) {
+          pda.name = plugin;
+          plugins.add(pda);
+        }
+      }
+    }
+    if (plugins.isEmpty()) {
+      plugins = null;
+    }
+    return plugins;
+  }
+
+  @Override
+  protected Predicate<ChangeData> enforceVisibility(Predicate<ChangeData> pred) {
+    return new AndChangeSource(
+        pred,
+        new ChangeIsVisibleToPredicate(db, notesFactory, userProvider.get(), permissionBackend),
+        start);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
rename to java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
rename to java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/java/com/google/gerrit/server/query/change/CommentByPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
rename to java/com/google/gerrit/server/query/change/CommentByPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java b/java/com/google/gerrit/server/query/change/CommentPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
rename to java/com/google/gerrit/server/query/change/CommentPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java b/java/com/google/gerrit/server/query/change/CommitPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
rename to java/com/google/gerrit/server/query/change/CommitPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java b/java/com/google/gerrit/server/query/change/CommitterPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
rename to java/com/google/gerrit/server/query/change/CommitterPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictKey.java b/java/com/google/gerrit/server/query/change/ConflictKey.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictKey.java
rename to java/com/google/gerrit/server/query/change/ConflictKey.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCache.java b/java/com/google/gerrit/server/query/change/ConflictsCache.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCache.java
rename to java/com/google/gerrit/server/query/change/ConflictsCache.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java b/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
rename to java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
new file mode 100644
index 0000000..02ea3f2
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -0,0 +1,204 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.strategy.SubmitDryRun;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class ConflictsPredicate {
+  // UI code may depend on this string, so use caution when changing.
+  protected static final String TOO_MANY_FILES = "too many files to find conflicts";
+
+  private ConflictsPredicate() {}
+
+  public static Predicate<ChangeData> create(Arguments args, String value, Change c)
+      throws QueryParseException, OrmException {
+    ChangeData cd;
+    List<String> files;
+    try {
+      cd = args.changeDataFactory.create(args.db.get(), c);
+      files = cd.currentFilePaths();
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+
+    if (3 + files.size() > args.indexConfig.maxTerms()) {
+      // Short-circuit with a nice error message if we exceed the index
+      // backend's term limit. This assumes that "conflicts:foo" is the entire
+      // query; if there are more terms in the input, we might not
+      // short-circuit here, which will result in a more generic error message
+      // later on in the query parsing.
+      throw new QueryParseException(TOO_MANY_FILES);
+    }
+
+    List<Predicate<ChangeData>> filePredicates = new ArrayList<>(files.size());
+    for (String file : files) {
+      filePredicates.add(new EqualsPathPredicate(ChangeQueryBuilder.FIELD_PATH, file));
+    }
+
+    List<Predicate<ChangeData>> and = new ArrayList<>(5);
+    and.add(new ProjectPredicate(c.getProject().get()));
+    and.add(new RefPredicate(c.getDest().get()));
+    and.add(Predicate.not(new LegacyChangeIdPredicate(c.getId())));
+    and.add(Predicate.or(filePredicates));
+
+    ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
+    and.add(new CheckConflict(ChangeQueryBuilder.FIELD_CONFLICTS, value, args, c, changeDataCache));
+    return Predicate.and(and);
+  }
+
+  private static final class CheckConflict extends ChangeOperatorPredicate {
+    private final Arguments args;
+    private final Branch.NameKey dest;
+    private final ChangeDataCache changeDataCache;
+
+    CheckConflict(
+        String field, String value, Arguments args, Change c, ChangeDataCache changeDataCache) {
+      super(field, value);
+      this.args = args;
+      this.dest = c.getDest();
+      this.changeDataCache = changeDataCache;
+    }
+
+    @Override
+    public boolean match(ChangeData object) throws OrmException {
+      Change otherChange = object.change();
+      if (otherChange == null || !otherChange.getDest().equals(dest)) {
+        return false;
+      }
+
+      SubmitTypeRecord str = object.submitTypeRecord();
+      if (!str.isOk()) {
+        return false;
+      }
+
+      ProjectState projectState;
+      try {
+        projectState = changeDataCache.getProjectState();
+      } catch (NoSuchProjectException e) {
+        return false;
+      }
+
+      ObjectId other = ObjectId.fromString(object.currentPatchSet().getRevision().get());
+      ConflictKey conflictsKey =
+          new ConflictKey(
+              changeDataCache.getTestAgainst(),
+              other,
+              str.type,
+              projectState.is(BooleanProjectConfig.USE_CONTENT_MERGE));
+      Boolean conflicts = args.conflictsCache.getIfPresent(conflictsKey);
+      if (conflicts != null) {
+        return conflicts;
+      }
+
+      try (Repository repo = args.repoManager.openRepository(otherChange.getProject());
+          CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
+        conflicts =
+            !args.submitDryRun.run(
+                str.type,
+                repo,
+                rw,
+                otherChange.getDest(),
+                changeDataCache.getTestAgainst(),
+                other,
+                getAlreadyAccepted(repo, rw));
+        args.conflictsCache.put(conflictsKey, conflicts);
+        return conflicts;
+      } catch (IntegrationException | NoSuchProjectException | IOException e) {
+        throw new OrmException(e);
+      }
+    }
+
+    @Override
+    public int getCost() {
+      return 5;
+    }
+
+    private Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw)
+        throws IntegrationException {
+      try {
+        Set<RevCommit> accepted = new HashSet<>();
+        SubmitDryRun.addCommits(changeDataCache.getAlreadyAccepted(repo), rw, accepted);
+        ObjectId tip = changeDataCache.getTestAgainst();
+        if (tip != null) {
+          accepted.add(rw.parseCommit(tip));
+        }
+        return accepted;
+      } catch (OrmException | IOException e) {
+        throw new IntegrationException("Failed to determine already accepted commits.", e);
+      }
+    }
+  }
+
+  private static class ChangeDataCache {
+    private final ChangeData cd;
+    private final ProjectCache projectCache;
+
+    private ObjectId testAgainst;
+    private ProjectState projectState;
+    private Set<ObjectId> alreadyAccepted;
+
+    ChangeDataCache(ChangeData cd, ProjectCache projectCache) {
+      this.cd = cd;
+      this.projectCache = projectCache;
+    }
+
+    ObjectId getTestAgainst() throws OrmException {
+      if (testAgainst == null) {
+        testAgainst = ObjectId.fromString(cd.currentPatchSet().getRevision().get());
+      }
+      return testAgainst;
+    }
+
+    ProjectState getProjectState() throws NoSuchProjectException {
+      if (projectState == null) {
+        projectState = projectCache.get(cd.project());
+        if (projectState == null) {
+          throw new NoSuchProjectException(cd.project());
+        }
+      }
+      return projectState;
+    }
+
+    Set<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
+      if (alreadyAccepted == null) {
+        alreadyAccepted = SubmitDryRun.getAlreadyAccepted(repo);
+      }
+      return alreadyAccepted;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java b/java/com/google/gerrit/server/query/change/DeletedPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
rename to java/com/google/gerrit/server/query/change/DeletedPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java b/java/com/google/gerrit/server/query/change/DeltaPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
rename to java/com/google/gerrit/server/query/change/DeltaPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/java/com/google/gerrit/server/query/change/DestinationPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
rename to java/com/google/gerrit/server/query/change/DestinationPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java b/java/com/google/gerrit/server/query/change/EditByPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
rename to java/com/google/gerrit/server/query/change/EditByPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java b/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
rename to java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
new file mode 100644
index 0000000..785ae38
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+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.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+
+public class EqualsLabelPredicate extends ChangeIndexPredicate {
+  protected final ProjectCache projectCache;
+  protected final PermissionBackend permissionBackend;
+  protected final IdentifiedUser.GenericFactory userFactory;
+  protected final Provider<ReviewDb> dbProvider;
+  protected final String label;
+  protected final int expVal;
+  protected final Account.Id account;
+  protected final AccountGroup.UUID group;
+
+  public EqualsLabelPredicate(
+      LabelPredicate.Args args, String label, int expVal, Account.Id account) {
+    super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account));
+    this.permissionBackend = args.permissionBackend;
+    this.projectCache = args.projectCache;
+    this.userFactory = args.userFactory;
+    this.dbProvider = args.dbProvider;
+    this.group = args.group;
+    this.label = label;
+    this.expVal = expVal;
+    this.account = account;
+  }
+
+  @Override
+  public boolean match(ChangeData object) throws OrmException {
+    Change c = object.change();
+    if (c == null) {
+      // The change has disappeared.
+      //
+      return false;
+    }
+
+    ProjectState project = projectCache.get(c.getDest().getParentKey());
+    if (project == null) {
+      // The project has disappeared.
+      //
+      return false;
+    }
+
+    LabelType labelType = type(project.getLabelTypes(), label);
+    if (labelType == null) {
+      return false; // Label is not defined by this project.
+    }
+
+    boolean hasVote = false;
+    for (PatchSetApproval p : object.currentApprovals()) {
+      if (labelType.matches(p)) {
+        hasVote = true;
+        if (match(object, p.getValue(), p.getAccountId())) {
+          return true;
+        }
+      }
+    }
+
+    if (!hasVote && expVal == 0) {
+      return true;
+    }
+
+    return false;
+  }
+
+  protected static LabelType type(LabelTypes types, String toFind) {
+    if (types.byLabel(toFind) != null) {
+      return types.byLabel(toFind);
+    }
+
+    for (LabelType lt : types.getLabelTypes()) {
+      if (toFind.equalsIgnoreCase(lt.getName())) {
+        return lt;
+      }
+    }
+    return null;
+  }
+
+  protected boolean match(ChangeData cd, short value, Account.Id approver) {
+    if (value != expVal) {
+      return false;
+    }
+
+    if (account != null && !account.equals(approver)) {
+      return false;
+    }
+
+    IdentifiedUser reviewer = userFactory.create(approver);
+    if (group != null && !reviewer.getEffectiveGroups().contains(group)) {
+      return false;
+    }
+
+    // Check the user has 'READ' permission.
+    try {
+      PermissionBackend.ForChange perm =
+          permissionBackend.user(reviewer).database(dbProvider).change(cd);
+      return perm.test(ChangePermission.READ);
+    } catch (PermissionBackendException e) {
+      return false;
+    }
+  }
+
+  @Override
+  public int getCost() {
+    return 1 + (group == null ? 0 : 1);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java b/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
rename to java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java b/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
rename to java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java b/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
rename to java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java b/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
rename to java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java b/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
rename to java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java b/java/com/google/gerrit/server/query/change/GroupPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
rename to java/com/google/gerrit/server/query/change/GroupPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java b/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
rename to java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java b/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
rename to java/com/google/gerrit/server/query/change/HasStarsPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java b/java/com/google/gerrit/server/query/change/HashtagPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
rename to java/com/google/gerrit/server/query/change/HashtagPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java b/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
rename to java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
new file mode 100644
index 0000000..6e63a32
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -0,0 +1,316 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.index.query.Predicate.and;
+import static com.google.gerrit.index.query.Predicate.not;
+import static com.google.gerrit.index.query.Predicate.or;
+import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.query.InternalQuery;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Query wrapper for the change index.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
+public class InternalChangeQuery extends InternalQuery<ChangeData> {
+  private static Predicate<ChangeData> ref(Branch.NameKey branch) {
+    return new RefPredicate(branch.get());
+  }
+
+  private static Predicate<ChangeData> change(Change.Key key) {
+    return new ChangeIdPredicate(key.get());
+  }
+
+  private static Predicate<ChangeData> project(Project.NameKey project) {
+    return new ProjectPredicate(project.get());
+  }
+
+  private static Predicate<ChangeData> status(Change.Status status) {
+    return ChangeStatusPredicate.forStatus(status);
+  }
+
+  private static Predicate<ChangeData> commit(String id) {
+    return new CommitPredicate(id);
+  }
+
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeNotes.Factory notesFactory;
+
+  @Inject
+  InternalChangeQuery(
+      ChangeQueryProcessor queryProcessor,
+      ChangeIndexCollection indexes,
+      IndexConfig indexConfig,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory notesFactory) {
+    super(queryProcessor, indexes, indexConfig);
+    this.changeDataFactory = changeDataFactory;
+    this.notesFactory = notesFactory;
+  }
+
+  @Override
+  public InternalChangeQuery setLimit(int n) {
+    super.setLimit(n);
+    return this;
+  }
+
+  @Override
+  public InternalChangeQuery enforceVisibility(boolean enforce) {
+    super.enforceVisibility(enforce);
+    return this;
+  }
+
+  @SafeVarargs
+  @Override
+  public final InternalChangeQuery setRequestedFields(FieldDef<ChangeData, ?>... fields) {
+    super.setRequestedFields(fields);
+    return this;
+  }
+
+  @Override
+  public InternalChangeQuery noFields() {
+    super.noFields();
+    return this;
+  }
+
+  public List<ChangeData> byKey(Change.Key key) throws OrmException {
+    return byKeyPrefix(key.get());
+  }
+
+  public List<ChangeData> byKeyPrefix(String prefix) throws OrmException {
+    return query(new ChangeIdPredicate(prefix));
+  }
+
+  public List<ChangeData> byLegacyChangeId(Change.Id id) throws OrmException {
+    return query(new LegacyChangeIdPredicate(id));
+  }
+
+  public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) throws OrmException {
+    List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
+    for (Change.Id id : ids) {
+      preds.add(new LegacyChangeIdPredicate(id));
+    }
+    return query(or(preds));
+  }
+
+  public List<ChangeData> byBranchKey(Branch.NameKey branch, Change.Key key) throws OrmException {
+    return query(and(ref(branch), project(branch.getParentKey()), change(key)));
+  }
+
+  public List<ChangeData> byProject(Project.NameKey project) throws OrmException {
+    return query(project(project));
+  }
+
+  public List<ChangeData> byBranchOpen(Branch.NameKey branch) throws OrmException {
+    return query(and(ref(branch), project(branch.getParentKey()), open()));
+  }
+
+  public List<ChangeData> byBranchNew(Branch.NameKey branch) throws OrmException {
+    return query(and(ref(branch), project(branch.getParentKey()), status(Change.Status.NEW)));
+  }
+
+  public Iterable<ChangeData> byCommitsOnBranchNotMerged(
+      Repository repo, ReviewDb db, Branch.NameKey branch, Collection<String> hashes)
+      throws OrmException, IOException {
+    return byCommitsOnBranchNotMerged(
+        repo,
+        db,
+        branch,
+        hashes,
+        // Account for all commit predicates plus ref, project, status.
+        indexConfig.maxTerms() - 3);
+  }
+
+  @VisibleForTesting
+  Iterable<ChangeData> byCommitsOnBranchNotMerged(
+      Repository repo,
+      ReviewDb db,
+      Branch.NameKey branch,
+      Collection<String> hashes,
+      int indexLimit)
+      throws OrmException, IOException {
+    if (hashes.size() > indexLimit) {
+      return byCommitsOnBranchNotMergedFromDatabase(repo, db, branch, hashes);
+    }
+    return byCommitsOnBranchNotMergedFromIndex(branch, hashes);
+  }
+
+  private Iterable<ChangeData> byCommitsOnBranchNotMergedFromDatabase(
+      Repository repo, ReviewDb db, Branch.NameKey branch, Collection<String> hashes)
+      throws OrmException, IOException {
+    Set<Change.Id> changeIds = Sets.newHashSetWithExpectedSize(hashes.size());
+    String lastPrefix = null;
+    for (Ref ref : repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES).values()) {
+      String r = ref.getName();
+      if ((lastPrefix != null && r.startsWith(lastPrefix))
+          || !hashes.contains(ref.getObjectId().name())) {
+        continue;
+      }
+      Change.Id id = Change.Id.fromRef(r);
+      if (id == null) {
+        continue;
+      }
+      if (changeIds.add(id)) {
+        lastPrefix = r.substring(0, r.lastIndexOf('/'));
+      }
+    }
+
+    List<ChangeNotes> notes =
+        notesFactory.create(
+            db,
+            branch.getParentKey(),
+            changeIds,
+            cn -> {
+              Change c = cn.getChange();
+              return c.getDest().equals(branch) && c.getStatus() != Change.Status.MERGED;
+            });
+    return Lists.transform(notes, n -> changeDataFactory.create(db, n));
+  }
+
+  private Iterable<ChangeData> byCommitsOnBranchNotMergedFromIndex(
+      Branch.NameKey branch, Collection<String> hashes) throws OrmException {
+    return query(
+        and(
+            ref(branch),
+            project(branch.getParentKey()),
+            not(status(Change.Status.MERGED)),
+            or(commits(hashes))));
+  }
+
+  private static List<Predicate<ChangeData>> commits(Collection<String> hashes) {
+    List<Predicate<ChangeData>> commits = new ArrayList<>(hashes.size());
+    for (String s : hashes) {
+      commits.add(commit(s));
+    }
+    return commits;
+  }
+
+  public List<ChangeData> byProjectOpen(Project.NameKey project) throws OrmException {
+    return query(and(project(project), open()));
+  }
+
+  public List<ChangeData> byTopicOpen(String topic) throws OrmException {
+    return query(and(new ExactTopicPredicate(topic), open()));
+  }
+
+  public List<ChangeData> byCommit(ObjectId id) throws OrmException {
+    return byCommit(id.name());
+  }
+
+  public List<ChangeData> byCommit(String hash) throws OrmException {
+    return query(commit(hash));
+  }
+
+  public List<ChangeData> byProjectCommit(Project.NameKey project, ObjectId id)
+      throws OrmException {
+    return byProjectCommit(project, id.name());
+  }
+
+  public List<ChangeData> byProjectCommit(Project.NameKey project, String hash)
+      throws OrmException {
+    return query(and(project(project), commit(hash)));
+  }
+
+  public List<ChangeData> byProjectCommits(Project.NameKey project, List<String> hashes)
+      throws OrmException {
+    int n = indexConfig.maxTerms() - 1;
+    checkArgument(hashes.size() <= n, "cannot exceed %s commits", n);
+    return query(and(project(project), or(commits(hashes))));
+  }
+
+  public List<ChangeData> byBranchCommit(String project, String branch, String hash)
+      throws OrmException {
+    return query(and(new ProjectPredicate(project), new RefPredicate(branch), commit(hash)));
+  }
+
+  public List<ChangeData> byBranchCommit(Branch.NameKey branch, String hash) throws OrmException {
+    return byBranchCommit(branch.getParentKey().get(), branch.get(), hash);
+  }
+
+  public List<ChangeData> bySubmissionId(String cs) throws OrmException {
+    if (Strings.isNullOrEmpty(cs)) {
+      return Collections.emptyList();
+    }
+    return query(new SubmissionIdPredicate(cs));
+  }
+
+  private List<ChangeData> byProjectGroups(Project.NameKey project, Collection<String> groups)
+      throws OrmException {
+    int n = indexConfig.maxTerms() - 1;
+    checkArgument(groups.size() <= n, "cannot exceed %s groups", n);
+    List<GroupPredicate> groupPredicates = new ArrayList<>(groups.size());
+    for (String g : groups) {
+      groupPredicates.add(new GroupPredicate(g));
+    }
+    return query(and(project(project), or(groupPredicates)));
+  }
+
+  // Batching via multiple queries requires passing in a Provider since the underlying
+  // QueryProcessor instance is not reusable.
+  public static List<ChangeData> byProjectGroups(
+      Provider<InternalChangeQuery> queryProvider,
+      IndexConfig indexConfig,
+      Project.NameKey project,
+      Collection<String> groups)
+      throws OrmException {
+    int batchSize = indexConfig.maxTerms() - 1;
+    if (groups.size() <= batchSize) {
+      return queryProvider.get().enforceVisibility(true).byProjectGroups(project, groups);
+    }
+    Set<Change.Id> seen = new HashSet<>();
+    List<ChangeData> result = new ArrayList<>();
+    for (List<String> part : Iterables.partition(groups, batchSize)) {
+      for (ChangeData cd :
+          queryProvider.get().enforceVisibility(true).byProjectGroups(project, part)) {
+        if (!seen.add(cd.getId())) {
+          result.add(cd);
+        }
+      }
+    }
+    return result;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java b/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
rename to java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java b/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
rename to java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java b/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
rename to java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
new file mode 100644
index 0000000..f8bd2e3
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -0,0 +1,161 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.index.query.OrPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.RangeUtil;
+import com.google.gerrit.index.query.RangeUtil.Range;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+public class LabelPredicate extends OrPredicate<ChangeData> {
+  protected static final int MAX_LABEL_VALUE = 4;
+
+  protected static class Args {
+    protected final ProjectCache projectCache;
+    protected final PermissionBackend permissionBackend;
+    protected final IdentifiedUser.GenericFactory userFactory;
+    protected final Provider<ReviewDb> dbProvider;
+    protected final String value;
+    protected final Set<Account.Id> accounts;
+    protected final AccountGroup.UUID group;
+
+    protected Args(
+        ProjectCache projectCache,
+        PermissionBackend permissionBackend,
+        IdentifiedUser.GenericFactory userFactory,
+        Provider<ReviewDb> dbProvider,
+        String value,
+        Set<Account.Id> accounts,
+        AccountGroup.UUID group) {
+      this.projectCache = projectCache;
+      this.permissionBackend = permissionBackend;
+      this.userFactory = userFactory;
+      this.dbProvider = dbProvider;
+      this.value = value;
+      this.accounts = accounts;
+      this.group = group;
+    }
+  }
+
+  protected static class Parsed {
+    protected final String label;
+    protected final String test;
+    protected final int expVal;
+
+    protected Parsed(String label, String test, int expVal) {
+      this.label = label;
+      this.test = test;
+      this.expVal = expVal;
+    }
+  }
+
+  protected final String value;
+
+  public LabelPredicate(
+      ChangeQueryBuilder.Arguments a,
+      String value,
+      Set<Account.Id> accounts,
+      AccountGroup.UUID group) {
+    super(
+        predicates(
+            new Args(
+                a.projectCache, a.permissionBackend, a.userFactory, a.db, value, accounts, group)));
+    this.value = value;
+  }
+
+  protected static List<Predicate<ChangeData>> predicates(Args args) {
+    String v = args.value;
+    Parsed parsed = null;
+
+    try {
+      LabelVote lv = LabelVote.parse(v);
+      parsed = new Parsed(lv.label(), "=", lv.value());
+    } catch (IllegalArgumentException e) {
+      // Try next format.
+    }
+
+    try {
+      LabelVote lv = LabelVote.parseWithEquals(v);
+      parsed = new Parsed(lv.label(), "=", lv.value());
+    } catch (IllegalArgumentException e) {
+      // Try next format.
+    }
+
+    Range range;
+    if (parsed == null) {
+      range = RangeUtil.getRange(v, -MAX_LABEL_VALUE, MAX_LABEL_VALUE);
+      if (range == null) {
+        range = new Range(v, 1, 1);
+      }
+    } else {
+      range =
+          RangeUtil.getRange(
+              parsed.label, parsed.test, parsed.expVal, -MAX_LABEL_VALUE, MAX_LABEL_VALUE);
+    }
+    String prefix = range.prefix;
+    int min = range.min;
+    int max = range.max;
+
+    List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(max - min + 1);
+    for (int i = min; i <= max; i++) {
+      r.add(onePredicate(args, prefix, i));
+    }
+    return r;
+  }
+
+  protected static Predicate<ChangeData> onePredicate(Args args, String label, int expVal) {
+    if (expVal != 0) {
+      return equalsLabelPredicate(args, label, expVal);
+    }
+    return noLabelQuery(args, label);
+  }
+
+  protected static Predicate<ChangeData> noLabelQuery(Args args, String label) {
+    List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(2 * MAX_LABEL_VALUE);
+    for (int i = 1; i <= MAX_LABEL_VALUE; i++) {
+      r.add(equalsLabelPredicate(args, label, i));
+      r.add(equalsLabelPredicate(args, label, -i));
+    }
+    return not(or(r));
+  }
+
+  protected static Predicate<ChangeData> equalsLabelPredicate(Args args, String label, int expVal) {
+    if (args.accounts == null || args.accounts.isEmpty()) {
+      return new EqualsLabelPredicate(args, label, expVal, null);
+    }
+    List<Predicate<ChangeData>> r = new ArrayList<>();
+    for (Account.Id a : args.accounts) {
+      r.add(new EqualsLabelPredicate(args, label, expVal, a));
+    }
+    return or(r);
+  }
+
+  @Override
+  public String toString() {
+    return ChangeQueryBuilder.FIELD_LABEL + ":" + value;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java b/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
rename to java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java b/java/com/google/gerrit/server/query/change/MessagePredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
rename to java/com/google/gerrit/server/query/change/MessagePredicate.java
diff --git a/java/com/google/gerrit/server/query/change/OrSource.java b/java/com/google/gerrit/server/query/change/OrSource.java
new file mode 100644
index 0000000..b3e1c27
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/OrSource.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.OrPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.ListResultSet;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class OrSource extends OrPredicate<ChangeData> implements ChangeDataSource {
+  private int cardinality = -1;
+
+  public OrSource(Collection<? extends Predicate<ChangeData>> that) {
+    super(that);
+  }
+
+  @Override
+  public ResultSet<ChangeData> read() throws OrmException {
+    // TODO(spearce) This probably should be more lazy.
+    //
+    List<ChangeData> r = new ArrayList<>();
+    Set<Change.Id> have = new HashSet<>();
+    for (Predicate<ChangeData> p : getChildren()) {
+      if (p instanceof ChangeDataSource) {
+        for (ChangeData cd : ((ChangeDataSource) p).read()) {
+          if (have.add(cd.getId())) {
+            r.add(cd);
+          }
+        }
+      } else {
+        throw new OrmException("No ChangeDataSource: " + p);
+      }
+    }
+    return new ListResultSet<>(r);
+  }
+
+  @Override
+  public ResultSet<FieldBundle> readRaw() throws OrmException {
+    throw new UnsupportedOperationException("not implemented");
+  }
+
+  @Override
+  public boolean hasChange() {
+    for (Predicate<ChangeData> p : getChildren()) {
+      if (!(p instanceof ChangeDataSource) || !((ChangeDataSource) p).hasChange()) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  @Override
+  public int getCardinality() {
+    if (cardinality < 0) {
+      cardinality = 0;
+      for (Predicate<ChangeData> p : getChildren()) {
+        if (p instanceof ChangeDataSource) {
+          cardinality += ((ChangeDataSource) p).getCardinality();
+        }
+      }
+    }
+    return cardinality;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
new file mode 100644
index 0000000..185517a
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -0,0 +1,468 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.TrackingFooters;
+import com.google.gerrit.server.data.ChangeAttribute;
+import com.google.gerrit.server.data.PatchSetAttribute;
+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.SubmitRuleEvaluator;
+import com.google.gson.Gson;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Change query implementation that outputs to a stream in the style of an SSH command.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
+public class OutputStreamQuery {
+  private static final Logger log = LoggerFactory.getLogger(OutputStreamQuery.class);
+
+  private static final DateTimeFormatter dtf =
+      DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss zzz")
+          .withLocale(Locale.US)
+          .withZone(ZoneId.systemDefault());
+
+  public enum OutputFormat {
+    TEXT,
+    JSON
+  }
+
+  private final ReviewDb db;
+  private final GitRepositoryManager repoManager;
+  private final ChangeQueryBuilder queryBuilder;
+  private final ChangeQueryProcessor queryProcessor;
+  private final EventFactory eventFactory;
+  private final TrackingFooters trackingFooters;
+  private final CurrentUser user;
+  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+
+  private OutputFormat outputFormat = OutputFormat.TEXT;
+  private boolean includePatchSets;
+  private boolean includeCurrentPatchSet;
+  private boolean includeApprovals;
+  private boolean includeComments;
+  private boolean includeFiles;
+  private boolean includeCommitMessage;
+  private boolean includeDependencies;
+  private boolean includeSubmitRecords;
+  private boolean includeAllReviewers;
+
+  private OutputStream outputStream = DisabledOutputStream.INSTANCE;
+  private PrintWriter out;
+
+  @Inject
+  OutputStreamQuery(
+      ReviewDb db,
+      GitRepositoryManager repoManager,
+      ChangeQueryBuilder queryBuilder,
+      ChangeQueryProcessor queryProcessor,
+      EventFactory eventFactory,
+      TrackingFooters trackingFooters,
+      CurrentUser user,
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+    this.db = db;
+    this.repoManager = repoManager;
+    this.queryBuilder = queryBuilder;
+    this.queryProcessor = queryProcessor;
+    this.eventFactory = eventFactory;
+    this.trackingFooters = trackingFooters;
+    this.user = user;
+    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
+  }
+
+  void setLimit(int n) {
+    queryProcessor.setUserProvidedLimit(n);
+  }
+
+  public void setStart(int n) {
+    queryProcessor.setStart(n);
+  }
+
+  public void setIncludePatchSets(boolean on) {
+    includePatchSets = on;
+  }
+
+  public boolean getIncludePatchSets() {
+    return includePatchSets;
+  }
+
+  public void setIncludeCurrentPatchSet(boolean on) {
+    includeCurrentPatchSet = on;
+  }
+
+  public boolean getIncludeCurrentPatchSet() {
+    return includeCurrentPatchSet;
+  }
+
+  public void setIncludeApprovals(boolean on) {
+    includeApprovals = on;
+  }
+
+  public void setIncludeComments(boolean on) {
+    includeComments = on;
+  }
+
+  public void setIncludeFiles(boolean on) {
+    includeFiles = on;
+  }
+
+  public boolean getIncludeFiles() {
+    return includeFiles;
+  }
+
+  public void setIncludeDependencies(boolean on) {
+    includeDependencies = on;
+  }
+
+  public boolean getIncludeDependencies() {
+    return includeDependencies;
+  }
+
+  public void setIncludeCommitMessage(boolean on) {
+    includeCommitMessage = on;
+  }
+
+  public void setIncludeSubmitRecords(boolean on) {
+    includeSubmitRecords = on;
+  }
+
+  public void setIncludeAllReviewers(boolean on) {
+    includeAllReviewers = on;
+  }
+
+  public void setOutput(OutputStream out, OutputFormat fmt) {
+    this.outputStream = out;
+    this.outputFormat = fmt;
+  }
+
+  public void query(String queryString) throws IOException {
+    out =
+        new PrintWriter( //
+            new BufferedWriter( //
+                new OutputStreamWriter(outputStream, UTF_8)));
+    try {
+      if (queryProcessor.isDisabled()) {
+        ErrorMessage m = new ErrorMessage();
+        m.message = "query disabled";
+        show(m);
+        return;
+      }
+
+      try {
+        final QueryStatsAttribute stats = new QueryStatsAttribute();
+        stats.runTimeMilliseconds = TimeUtil.nowMs();
+
+        Map<Project.NameKey, Repository> repos = new HashMap<>();
+        Map<Project.NameKey, RevWalk> revWalks = new HashMap<>();
+        QueryResult<ChangeData> results = queryProcessor.query(queryBuilder.parse(queryString));
+        try {
+          for (ChangeData d : results.entities()) {
+            show(buildChangeAttribute(d, repos, revWalks));
+          }
+        } finally {
+          closeAll(revWalks.values(), repos.values());
+        }
+
+        stats.rowCount = results.entities().size();
+        stats.moreChanges = results.more();
+        stats.runTimeMilliseconds = TimeUtil.nowMs() - stats.runTimeMilliseconds;
+        show(stats);
+      } catch (OrmException err) {
+        log.error("Cannot execute query: " + queryString, err);
+
+        ErrorMessage m = new ErrorMessage();
+        m.message = "cannot query database";
+        show(m);
+
+      } catch (QueryParseException e) {
+        ErrorMessage m = new ErrorMessage();
+        m.message = e.getMessage();
+        show(m);
+      }
+    } finally {
+      try {
+        out.flush();
+      } finally {
+        out = null;
+      }
+    }
+  }
+
+  private ChangeAttribute buildChangeAttribute(
+      ChangeData d, Map<Project.NameKey, Repository> repos, Map<Project.NameKey, RevWalk> revWalks)
+      throws OrmException, IOException {
+    LabelTypes labelTypes = d.getLabelTypes();
+    ChangeAttribute c = eventFactory.asChangeAttribute(db, d.change());
+    eventFactory.extend(c, d.change());
+
+    if (!trackingFooters.isEmpty()) {
+      eventFactory.addTrackingIds(c, d.trackingFooters());
+    }
+
+    if (includeAllReviewers) {
+      eventFactory.addAllReviewers(db, c, d.notes());
+    }
+
+    if (includeSubmitRecords) {
+      eventFactory.addSubmitRecords(
+          c, submitRuleEvaluatorFactory.create(user, d).setAllowClosed(true).evaluate());
+    }
+
+    if (includeCommitMessage) {
+      eventFactory.addCommitMessage(c, d.commitMessage());
+    }
+
+    RevWalk rw = null;
+    if (includePatchSets || includeCurrentPatchSet || includeDependencies) {
+      Project.NameKey p = d.change().getProject();
+      rw = revWalks.get(p);
+      // Cache and reuse repos and revwalks.
+      if (rw == null) {
+        Repository repo = repoManager.openRepository(p);
+        checkState(repos.put(p, repo) == null);
+        rw = new RevWalk(repo);
+        revWalks.put(p, rw);
+      }
+    }
+
+    if (includePatchSets) {
+      eventFactory.addPatchSets(
+          db,
+          rw,
+          c,
+          d.patchSets(),
+          includeApprovals ? d.approvals().asMap() : null,
+          includeFiles,
+          d.change(),
+          labelTypes);
+    }
+
+    if (includeCurrentPatchSet) {
+      PatchSet current = d.currentPatchSet();
+      if (current != null) {
+        c.currentPatchSet = eventFactory.asPatchSetAttribute(db, rw, d.change(), current);
+        eventFactory.addApprovals(c.currentPatchSet, d.currentApprovals(), labelTypes);
+
+        if (includeFiles) {
+          eventFactory.addPatchSetFileNames(c.currentPatchSet, d.change(), d.currentPatchSet());
+        }
+        if (includeComments) {
+          eventFactory.addPatchSetComments(c.currentPatchSet, d.publishedComments());
+        }
+      }
+    }
+
+    if (includeComments) {
+      eventFactory.addComments(c, d.messages());
+      if (includePatchSets) {
+        eventFactory.addPatchSets(
+            db,
+            rw,
+            c,
+            d.patchSets(),
+            includeApprovals ? d.approvals().asMap() : null,
+            includeFiles,
+            d.change(),
+            labelTypes);
+        for (PatchSetAttribute attribute : c.patchSets) {
+          eventFactory.addPatchSetComments(attribute, d.publishedComments());
+        }
+      }
+    }
+
+    if (includeDependencies) {
+      eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
+    }
+
+    c.plugins = queryProcessor.create(d);
+    return c;
+  }
+
+  private static void closeAll(Iterable<RevWalk> revWalks, Iterable<Repository> repos) {
+    if (repos != null) {
+      for (Repository repo : repos) {
+        repo.close();
+      }
+    }
+    if (revWalks != null) {
+      for (RevWalk revWalk : revWalks) {
+        revWalk.close();
+      }
+    }
+  }
+
+  private void show(Object data) {
+    switch (outputFormat) {
+      default:
+      case TEXT:
+        if (data instanceof ChangeAttribute) {
+          out.print("change ");
+          out.print(((ChangeAttribute) data).id);
+          out.print("\n");
+          showText(data, 1);
+        } else {
+          showText(data, 0);
+        }
+        out.print('\n');
+        break;
+
+      case JSON:
+        out.print(new Gson().toJson(data));
+        out.print('\n');
+        break;
+    }
+  }
+
+  private void showText(Object data, int depth) {
+    for (Field f : fieldsOf(data.getClass())) {
+      Object val;
+      try {
+        val = f.get(data);
+      } catch (IllegalArgumentException err) {
+        continue;
+      } catch (IllegalAccessException err) {
+        continue;
+      }
+      if (val == null) {
+        continue;
+      }
+
+      showField(f.getName(), val, depth);
+    }
+  }
+
+  private String indent(int spaces) {
+    if (spaces == 0) {
+      return "";
+    }
+    return String.format("%" + spaces + "s", " ");
+  }
+
+  private void showField(String field, Object value, int depth) {
+    final int spacesDepthRatio = 2;
+    String indent = indent(depth * spacesDepthRatio);
+    out.print(indent);
+    out.print(field);
+    out.print(':');
+    if (value instanceof String && ((String) value).contains("\n")) {
+      out.print(' ');
+      // Idention for multi-line text is
+      // current depth indetion + length of field + length of ": "
+      indent = indent(indent.length() + field.length() + spacesDepthRatio);
+      out.print(((String) value).replaceAll("\n", "\n" + indent).trim());
+      out.print('\n');
+    } else if (value instanceof Long && isDateField(field)) {
+      out.print(' ');
+      out.print(dtf.format(Instant.ofEpochSecond((Long) value)));
+      out.print('\n');
+    } else if (isPrimitive(value)) {
+      out.print(' ');
+      out.print(value);
+      out.print('\n');
+    } else if (value instanceof Collection) {
+      out.print('\n');
+      boolean firstElement = true;
+      for (Object thing : ((Collection<?>) value)) {
+        // The name of the collection was initially printed at the beginning
+        // of this routine.  Beginning at the second sub-element, reprint
+        // the collection name so humans can separate individual elements
+        // with less strain and error.
+        //
+        if (firstElement) {
+          firstElement = false;
+        } else {
+          out.print(indent);
+          out.print(field);
+          out.print(":\n");
+        }
+        if (isPrimitive(thing)) {
+          out.print(' ');
+          out.print(value);
+          out.print('\n');
+        } else {
+          showText(thing, depth + 1);
+        }
+      }
+    } else {
+      out.print('\n');
+      showText(value, depth + 1);
+    }
+  }
+
+  private static boolean isPrimitive(Object value) {
+    return value instanceof String //
+        || value instanceof Number //
+        || value instanceof Boolean //
+        || value instanceof Enum;
+  }
+
+  private static boolean isDateField(String name) {
+    return "lastUpdated".equals(name) //
+        || "grantedOn".equals(name) //
+        || "timestamp".equals(name) //
+        || "createdOn".equals(name);
+  }
+
+  private List<Field> fieldsOf(Class<?> type) {
+    List<Field> r = new ArrayList<>();
+    if (type.getSuperclass() != null) {
+      r.addAll(fieldsOf(type.getSuperclass()));
+    }
+    r.addAll(Arrays.asList(type.getDeclaredFields()));
+    return r;
+  }
+
+  static class ErrorMessage {
+    public final String type = "error";
+    public String message;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java b/java/com/google/gerrit/server/query/change/OwnerPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
rename to java/com/google/gerrit/server/query/change/OwnerPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java b/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
rename to java/com/google/gerrit/server/query/change/OwnerinPredicate.java
diff --git a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
new file mode 100644
index 0000000..a9de2b1
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.index.query.OrPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ChildProjects;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ParentProjectPredicate extends OrPredicate<ChangeData> {
+  private static final Logger log = LoggerFactory.getLogger(ParentProjectPredicate.class);
+
+  protected final String value;
+
+  public ParentProjectPredicate(
+      ProjectCache projectCache, ChildProjects childProjects, String value) {
+    super(predicates(projectCache, childProjects, value));
+    this.value = value;
+  }
+
+  protected static List<Predicate<ChangeData>> predicates(
+      ProjectCache projectCache, ChildProjects childProjects, String value) {
+    ProjectState projectState = projectCache.get(new Project.NameKey(value));
+    if (projectState == null) {
+      return Collections.emptyList();
+    }
+
+    List<Predicate<ChangeData>> r = new ArrayList<>();
+    r.add(new ProjectPredicate(projectState.getName()));
+    try {
+      for (ProjectInfo p : childProjects.list(projectState.getNameKey())) {
+        r.add(new ProjectPredicate(p.name));
+      }
+    } catch (PermissionBackendException e) {
+      log.warn("cannot check permissions to expand child projects", e);
+    }
+    return r;
+  }
+
+  @Override
+  public String toString() {
+    return ChangeQueryBuilder.FIELD_PARENTPROJECT + ":" + value;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java
rename to java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PredicateArgs.java b/java/com/google/gerrit/server/query/change/PredicateArgs.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/PredicateArgs.java
rename to java/com/google/gerrit/server/query/change/PredicateArgs.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java b/java/com/google/gerrit/server/query/change/ProjectPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
rename to java/com/google/gerrit/server/query/change/ProjectPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java b/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
rename to java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java b/java/com/google/gerrit/server/query/change/RefPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
rename to java/com/google/gerrit/server/query/change/RefPredicate.java
diff --git a/java/com/google/gerrit/server/query/change/RegexPathPredicate.java b/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
new file mode 100644
index 0000000..3764a98
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.util.RegexListSearcher;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.List;
+
+public class RegexPathPredicate extends ChangeRegexPredicate {
+  public RegexPathPredicate(String re) {
+    super(ChangeField.PATH, re);
+  }
+
+  @Override
+  public boolean match(ChangeData object) throws OrmException {
+    List<String> files;
+    try {
+      files = object.currentFilePaths();
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+    return RegexListSearcher.ofStrings(getValue()).search(files).findAny().isPresent();
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java b/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
rename to java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
rename to java/com/google/gerrit/server/query/change/RegexRefPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java b/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
rename to java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RevertOfPredicate.java b/java/com/google/gerrit/server/query/change/RevertOfPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/RevertOfPredicate.java
rename to java/com/google/gerrit/server/query/change/RevertOfPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
rename to java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
rename to java/com/google/gerrit/server/query/change/ReviewerPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
rename to java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SingleGroupUser.java b/java/com/google/gerrit/server/query/change/SingleGroupUser.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/SingleGroupUser.java
rename to java/com/google/gerrit/server/query/change/SingleGroupUser.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java b/java/com/google/gerrit/server/query/change/StarPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
rename to java/com/google/gerrit/server/query/change/StarPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java b/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
rename to java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
rename to java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
rename to java/com/google/gerrit/server/query/change/SubmittablePredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java b/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
rename to java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java b/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
rename to java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
rename to java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupPredicates.java b/java/com/google/gerrit/server/query/group/GroupPredicates.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupPredicates.java
rename to java/com/google/gerrit/server/query/group/GroupPredicates.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
rename to java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
rename to java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/InternalGroupQuery.java b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
rename to java/com/google/gerrit/server/query/group/InternalGroupQuery.java
diff --git a/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java
new file mode 100644
index 0000000..20032ce
--- /dev/null
+++ b/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/java/com/google/gerrit/server/query/project/ProjectPredicates.java b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
new file mode 100644
index 0000000..379c564
--- /dev/null
+++ b/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/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
new file mode 100644
index 0000000..e9e9c0f
--- /dev/null
+++ b/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/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
new file mode 100644
index 0000000..1e181e5
--- /dev/null
+++ b/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/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
new file mode 100644
index 0000000..5b9800b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -0,0 +1,35 @@
+package(
+    default_visibility = ["//visibility:public"],
+)
+
+java_library(
+    name = "restapi",
+    srcs = glob(["**/*.java"]),
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/prettify:server",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/ioutil",
+        "//java/org/eclipse/jgit:server",
+        "//lib:args4j",
+        "//lib:blame-cache",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:servlet-api-3_1",
+        "//lib/auto:auto-value",
+        "//lib/commons:codec",
+        "//lib/commons:compress",
+        "//lib/commons:lang",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
diff --git a/java/com/google/gerrit/server/restapi/access/AccessCollection.java b/java/com/google/gerrit/server/restapi/access/AccessCollection.java
new file mode 100644
index 0000000..4e12291
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/access/AccessCollection.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.access;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestCollection;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.access.AccessResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class AccessCollection implements RestCollection<TopLevelResource, AccessResource> {
+  private final Provider<ListAccess> list;
+  private final DynamicMap<RestView<AccessResource>> views;
+
+  @Inject
+  AccessCollection(Provider<ListAccess> list, DynamicMap<RestView<AccessResource>> views) {
+    this.list = list;
+    this.views = views;
+  }
+
+  @Override
+  public RestView<TopLevelResource> list() {
+    return list.get();
+  }
+
+  @Override
+  public AccessResource parse(TopLevelResource parent, IdString id)
+      throws ResourceNotFoundException {
+    throw new ResourceNotFoundException(id);
+  }
+
+  @Override
+  public DynamicMap<RestView<AccessResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/access/ListAccess.java b/java/com/google/gerrit/server/restapi/access/ListAccess.java
new file mode 100644
index 0000000..a79afd2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/access/ListAccess.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.access;
+
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.project.GetAccess;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import org.kohsuke.args4j.Option;
+
+public class ListAccess implements RestReadView<TopLevelResource> {
+
+  @Option(
+    name = "--project",
+    aliases = {"-p"},
+    metaVar = "PROJECT",
+    usage = "projects for which the access rights should be returned"
+  )
+  private List<String> projects = new ArrayList<>();
+
+  private final GetAccess getAccess;
+
+  @Inject
+  public ListAccess(GetAccess getAccess) {
+    this.getAccess = getAccess;
+  }
+
+  @Override
+  public Map<String, ProjectAccessInfo> apply(TopLevelResource resource)
+      throws ResourceNotFoundException, ResourceConflictException, IOException,
+          PermissionBackendException, OrmException {
+    Map<String, ProjectAccessInfo> access = new TreeMap<>();
+    for (String p : projects) {
+      access.put(p, getAccess.apply(new Project.NameKey(p)));
+    }
+    return access;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/access/Module.java b/java/com/google/gerrit/server/restapi/access/Module.java
new file mode 100644
index 0000000..21357fa
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/access/Module.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.access;
+
+import static com.google.gerrit.server.access.AccessResource.ACCESS_KIND;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+
+public class Module extends RestApiModule {
+  @Override
+  protected void configure() {
+    bind(AccessCollection.class);
+
+    DynamicMap.mapOf(binder(), ACCESS_KIND);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
new file mode 100644
index 0000000..197dadb
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
@@ -0,0 +1,168 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestCollection;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class AccountsCollection
+    implements RestCollection<TopLevelResource, AccountResource>, AcceptsCreate<TopLevelResource> {
+  private final Provider<CurrentUser> self;
+  private final AccountResolver resolver;
+  private final AccountControl.Factory accountControlFactory;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final Provider<QueryAccounts> list;
+  private final DynamicMap<RestView<AccountResource>> views;
+  private final CreateAccount.Factory createAccountFactory;
+
+  @Inject
+  AccountsCollection(
+      Provider<CurrentUser> self,
+      AccountResolver resolver,
+      AccountControl.Factory accountControlFactory,
+      IdentifiedUser.GenericFactory userFactory,
+      Provider<QueryAccounts> list,
+      DynamicMap<RestView<AccountResource>> views,
+      CreateAccount.Factory createAccountFactory) {
+    this.self = self;
+    this.resolver = resolver;
+    this.accountControlFactory = accountControlFactory;
+    this.userFactory = userFactory;
+    this.list = list;
+    this.views = views;
+    this.createAccountFactory = createAccountFactory;
+  }
+
+  @Override
+  public AccountResource parse(TopLevelResource root, IdString id)
+      throws ResourceNotFoundException, AuthException, OrmException, IOException,
+          ConfigInvalidException {
+    IdentifiedUser user = parseId(id.get());
+    if (user == null) {
+      throw new ResourceNotFoundException(id);
+    } else if (!accountControlFactory.get().canSee(user.getAccount())) {
+      throw new ResourceNotFoundException(id);
+    }
+    return new AccountResource(user);
+  }
+
+  /**
+   * Parses a account ID from a request body and returns the user.
+   *
+   * @param id ID of the account, can be a string of the format "{@code Full Name
+   *     <email@example.com>}", just the email address, a full name if it is unique, an account ID,
+   *     a user name or "{@code self}" for the calling user
+   * @return the user, never null.
+   * @throws UnprocessableEntityException thrown if the account ID cannot be resolved or if the
+   *     account is not visible to the calling user
+   */
+  public IdentifiedUser parse(String id)
+      throws AuthException, UnprocessableEntityException, OrmException, IOException,
+          ConfigInvalidException {
+    return parseOnBehalfOf(null, id);
+  }
+
+  /**
+   * Parses an account ID and returns the user without making any permission check whether the
+   * current user can see the account.
+   *
+   * @param id ID of the account, can be a string of the format "{@code Full Name
+   *     <email@example.com>}", just the email address, a full name if it is unique, an account ID,
+   *     a user name or "{@code self}" for the calling user
+   * @return the user, null if no user is found for the given account ID
+   * @throws AuthException thrown if 'self' is used as account ID and the current user is not
+   *     authenticated
+   * @throws OrmException
+   * @throws ConfigInvalidException
+   * @throws IOException
+   */
+  public IdentifiedUser parseId(String id)
+      throws AuthException, OrmException, IOException, ConfigInvalidException {
+    return parseIdOnBehalfOf(null, id);
+  }
+
+  /**
+   * Like {@link #parse(String)}, but also sets the {@link CurrentUser#getRealUser()} on the result.
+   */
+  public IdentifiedUser parseOnBehalfOf(@Nullable CurrentUser caller, String id)
+      throws AuthException, UnprocessableEntityException, OrmException, IOException,
+          ConfigInvalidException {
+    IdentifiedUser user = parseIdOnBehalfOf(caller, id);
+    if (user == null) {
+      throw new UnprocessableEntityException(String.format("Account Not Found: %s", id));
+    } else if (!accountControlFactory.get().canSee(user.getAccount())) {
+      throw new UnprocessableEntityException(String.format("Account Not Found: %s", id));
+    }
+    return user;
+  }
+
+  private IdentifiedUser parseIdOnBehalfOf(@Nullable CurrentUser caller, String id)
+      throws AuthException, OrmException, IOException, ConfigInvalidException {
+    if (id.equals("self")) {
+      CurrentUser user = self.get();
+      if (user.isIdentifiedUser()) {
+        return user.asIdentifiedUser();
+      } else if (user instanceof AnonymousUser) {
+        throw new AuthException("Authentication required");
+      } else {
+        return null;
+      }
+    }
+
+    Account match = resolver.find(id);
+    if (match == null) {
+      return null;
+    }
+    CurrentUser realUser = caller != null ? caller.getRealUser() : null;
+    return userFactory.runAs(null, match.getId(), realUser);
+  }
+
+  @Override
+  public RestView<TopLevelResource> list() throws ResourceNotFoundException {
+    return list.get();
+  }
+
+  @Override
+  public DynamicMap<RestView<AccountResource>> views() {
+    return views;
+  }
+
+  @Override
+  public CreateAccount create(TopLevelResource parent, IdString username) {
+    return createAccountFactory.create(username.get());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/AddSshKey.java b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
new file mode 100644
index 0000000..be0ca6a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+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;
+import com.google.gerrit.extensions.restapi.RawInput;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.AccountSshKey;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.mail.send.AddKeySender;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.io.InputStream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class AddSshKey implements RestModifyView<AccountResource, SshKeyInput> {
+  private static final Logger log = LoggerFactory.getLogger(AddSshKey.class);
+
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
+  private final SshKeyCache sshKeyCache;
+  private final AddKeySender.Factory addKeyFactory;
+
+  @Inject
+  AddSshKey(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      VersionedAuthorizedKeys.Accessor authorizedKeys,
+      SshKeyCache sshKeyCache,
+      AddKeySender.Factory addKeyFactory) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.authorizedKeys = authorizedKeys;
+    this.sshKeyCache = sshKeyCache;
+    this.addKeyFactory = addKeyFactory;
+  }
+
+  @Override
+  public Response<SshKeyInfo> apply(AccountResource rsrc, SshKeyInput input)
+      throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+    return apply(rsrc.getUser(), input);
+  }
+
+  public Response<SshKeyInfo> apply(IdentifiedUser user, SshKeyInput input)
+      throws BadRequestException, IOException, ConfigInvalidException {
+    if (input == null) {
+      input = new SshKeyInput();
+    }
+    if (input.raw == null) {
+      throw new BadRequestException("SSH public key missing");
+    }
+
+    final RawInput rawKey = input.raw;
+    String sshPublicKey =
+        new ByteSource() {
+          @Override
+          public InputStream openStream() throws IOException {
+            return rawKey.getInputStream();
+          }
+        }.asCharSource(UTF_8).read();
+
+    try {
+      AccountSshKey sshKey = authorizedKeys.addKey(user.getAccountId(), sshPublicKey);
+
+      try {
+        addKeyFactory.create(user, sshKey).send();
+      } catch (EmailException e) {
+        log.error(
+            "Cannot send SSH key added message to " + user.getAccount().getPreferredEmail(), e);
+      }
+
+      sshKeyCache.evict(user.getUserName());
+      return Response.<SshKeyInfo>created(GetSshKeys.newSshKeyInfo(sshKey));
+    } catch (InvalidSshKeyException e) {
+      throw new BadRequestException(e.getMessage());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/Capabilities.java b/java/com/google/gerrit/server/restapi/account/Capabilities.java
new file mode 100644
index 0000000..e337662
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/Capabilities.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountResource.Capability;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+class Capabilities implements ChildCollection<AccountResource, AccountResource.Capability> {
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final DynamicMap<RestView<AccountResource.Capability>> views;
+  private final Provider<GetCapabilities> get;
+
+  @Inject
+  Capabilities(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      DynamicMap<RestView<AccountResource.Capability>> views,
+      Provider<GetCapabilities> get) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.views = views;
+    this.get = get;
+  }
+
+  @Override
+  public GetCapabilities list() throws ResourceNotFoundException {
+    return get.get();
+  }
+
+  @Override
+  public Capability parse(AccountResource parent, IdString id)
+      throws ResourceNotFoundException, AuthException, PermissionBackendException {
+    IdentifiedUser target = parent.getUser();
+    if (self.get() != target) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+
+    GlobalOrPluginPermission perm = parse(id);
+    if (permissionBackend.user(target).test(perm)) {
+      return new AccountResource.Capability(target, perm.permissionName());
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  private GlobalOrPluginPermission parse(IdString id) throws ResourceNotFoundException {
+    String name = id.get();
+    GlobalOrPluginPermission perm = GlobalPermission.byName(name);
+    if (perm != null) {
+      return perm;
+    }
+
+    int dash = name.lastIndexOf('-');
+    if (dash < 0) {
+      throw new ResourceNotFoundException(id);
+    }
+
+    String pluginName = name.substring(0, dash);
+    String capability = name.substring(dash + 1);
+    if (pluginName.isEmpty() || capability.isEmpty()) {
+      throw new ResourceNotFoundException(id);
+    }
+    return new PluginPermission(pluginName, capability);
+  }
+
+  @Override
+  public DynamicMap<RestView<Capability>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
new file mode 100644
index 0000000..45128f1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -0,0 +1,207 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountExternalIdCreator;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.group.UserInitiated;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.gerrit.server.restapi.group.GroupsCollection;
+import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@RequiresCapability(GlobalCapability.CREATE_ACCOUNT)
+public class CreateAccount implements RestModifyView<TopLevelResource, AccountInput> {
+  public interface Factory {
+    CreateAccount create(String username);
+  }
+
+  private final ReviewDb db;
+  private final Sequences seq;
+  private final GroupsCollection groupsCollection;
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
+  private final SshKeyCache sshKeyCache;
+  private final AccountsUpdate.User accountsUpdate;
+  private final AccountLoader.Factory infoLoader;
+  private final DynamicSet<AccountExternalIdCreator> externalIdCreators;
+  private final Provider<GroupsUpdate> groupsUpdate;
+  private final OutgoingEmailValidator validator;
+  private final String username;
+
+  @Inject
+  CreateAccount(
+      ReviewDb db,
+      Sequences seq,
+      GroupsCollection groupsCollection,
+      VersionedAuthorizedKeys.Accessor authorizedKeys,
+      SshKeyCache sshKeyCache,
+      AccountsUpdate.User accountsUpdate,
+      AccountLoader.Factory infoLoader,
+      DynamicSet<AccountExternalIdCreator> externalIdCreators,
+      @UserInitiated Provider<GroupsUpdate> groupsUpdate,
+      OutgoingEmailValidator validator,
+      @Assisted String username) {
+    this.db = db;
+    this.seq = seq;
+    this.groupsCollection = groupsCollection;
+    this.authorizedKeys = authorizedKeys;
+    this.sshKeyCache = sshKeyCache;
+    this.accountsUpdate = accountsUpdate;
+    this.infoLoader = infoLoader;
+    this.externalIdCreators = externalIdCreators;
+    this.groupsUpdate = groupsUpdate;
+    this.validator = validator;
+    this.username = username;
+  }
+
+  @Override
+  public Response<AccountInfo> apply(TopLevelResource rsrc, @Nullable AccountInput input)
+      throws BadRequestException, ResourceConflictException, UnprocessableEntityException,
+          OrmException, IOException, ConfigInvalidException {
+    return apply(input != null ? input : new AccountInput());
+  }
+
+  public Response<AccountInfo> apply(AccountInput input)
+      throws BadRequestException, ResourceConflictException, UnprocessableEntityException,
+          OrmException, IOException, ConfigInvalidException {
+    if (input.username != null && !username.equals(input.username)) {
+      throw new BadRequestException("username must match URL");
+    }
+
+    if (!username.matches(Account.USER_NAME_PATTERN)) {
+      throw new BadRequestException(
+          "Username '" + username + "' must contain only letters, numbers, _, - or .");
+    }
+
+    Set<AccountGroup.UUID> groups = parseGroups(input.groups);
+
+    Account.Id id = new Account.Id(seq.nextAccountId());
+    List<ExternalId> extIds = new ArrayList<>();
+
+    if (input.email != null) {
+      if (!validator.isValid(input.email)) {
+        throw new BadRequestException("invalid email address");
+      }
+      extIds.add(ExternalId.createEmail(id, input.email));
+    }
+
+    extIds.add(ExternalId.createUsername(username, id, input.httpPassword));
+    for (AccountExternalIdCreator c : externalIdCreators) {
+      extIds.addAll(c.create(id, username, input.email));
+    }
+
+    try {
+      accountsUpdate
+          .create()
+          .insert(
+              "Create Account via API",
+              id,
+              u -> u.setFullName(input.name).setPreferredEmail(input.email).addExternalIds(extIds));
+    } catch (DuplicateExternalIdKeyException e) {
+      if (e.getDuplicateKey().isScheme(SCHEME_USERNAME)) {
+        throw new ResourceConflictException(
+            "username '" + e.getDuplicateKey().id() + "' already exists");
+      } else if (e.getDuplicateKey().isScheme(SCHEME_MAILTO)) {
+        throw new UnprocessableEntityException(
+            "email '" + e.getDuplicateKey().id() + "' already exists");
+      } else {
+        // AccountExternalIdCreator returned an external ID that already exists
+        throw e;
+      }
+    }
+
+    for (AccountGroup.UUID groupUuid : groups) {
+      try {
+        addGroupMember(groupUuid, id);
+      } catch (NoSuchGroupException e) {
+        throw new UnprocessableEntityException(String.format("Group %s not found", groupUuid));
+      }
+    }
+
+    if (input.sshKey != null) {
+      try {
+        authorizedKeys.addKey(id, input.sshKey);
+        sshKeyCache.evict(username);
+      } catch (InvalidSshKeyException e) {
+        throw new BadRequestException(e.getMessage());
+      }
+    }
+
+    AccountLoader loader = infoLoader.create(true);
+    AccountInfo info = loader.get(id);
+    loader.fill();
+    return Response.created(info);
+  }
+
+  private Set<AccountGroup.UUID> parseGroups(List<String> groups)
+      throws UnprocessableEntityException {
+    Set<AccountGroup.UUID> groupUuids = new HashSet<>();
+    if (groups != null) {
+      for (String g : groups) {
+        GroupDescription.Internal internalGroup = groupsCollection.parseInternal(g);
+        groupUuids.add(internalGroup.getGroupUUID());
+      }
+    }
+    return groupUuids;
+  }
+
+  private void addGroupMember(AccountGroup.UUID groupUuid, Account.Id accountId)
+      throws OrmException, IOException, NoSuchGroupException, ConfigInvalidException {
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder()
+            .setMemberModification(memberIds -> Sets.union(memberIds, ImmutableSet.of(accountId)))
+            .build();
+    groupsUpdate.get().updateGroup(db, groupUuid, groupUpdate);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
new file mode 100644
index 0000000..8ec024e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -0,0 +1,158 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.gerrit.extensions.client.AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.common.EmailInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+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.AccountException;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class CreateEmail implements RestModifyView<AccountResource, EmailInput> {
+  private static final Logger log = LoggerFactory.getLogger(CreateEmail.class);
+
+  public interface Factory {
+    CreateEmail create(String email);
+  }
+
+  private final Provider<CurrentUser> self;
+  private final Realm realm;
+  private final PermissionBackend permissionBackend;
+  private final AccountManager accountManager;
+  private final RegisterNewEmailSender.Factory registerNewEmailFactory;
+  private final PutPreferred putPreferred;
+  private final OutgoingEmailValidator validator;
+  private final String email;
+  private final boolean isDevMode;
+
+  @Inject
+  CreateEmail(
+      Provider<CurrentUser> self,
+      Realm realm,
+      PermissionBackend permissionBackend,
+      AuthConfig authConfig,
+      AccountManager accountManager,
+      RegisterNewEmailSender.Factory registerNewEmailFactory,
+      PutPreferred putPreferred,
+      OutgoingEmailValidator validator,
+      @Assisted String email) {
+    this.self = self;
+    this.realm = realm;
+    this.permissionBackend = permissionBackend;
+    this.accountManager = accountManager;
+    this.registerNewEmailFactory = registerNewEmailFactory;
+    this.putPreferred = putPreferred;
+    this.validator = validator;
+    this.email = email != null ? email.trim() : null;
+    this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
+  }
+
+  @Override
+  public Response<EmailInfo> apply(AccountResource rsrc, EmailInput input)
+      throws AuthException, BadRequestException, ResourceConflictException,
+          ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
+          IOException, ConfigInvalidException, PermissionBackendException {
+    if (input == null) {
+      input = new EmailInput();
+    }
+
+    if (self.get() != rsrc.getUser() || input.noConfirmation) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+    }
+
+    if (!realm.allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL)) {
+      throw new MethodNotAllowedException("realm does not allow adding emails");
+    }
+
+    return apply(rsrc.getUser(), input);
+  }
+
+  /** To be used from plugins that want to create emails without permission checks. */
+  public Response<EmailInfo> apply(IdentifiedUser user, EmailInput input)
+      throws AuthException, BadRequestException, ResourceConflictException,
+          ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
+          IOException, ConfigInvalidException, PermissionBackendException {
+    if (input == null) {
+      input = new EmailInput();
+    }
+
+    if (input.email != null && !email.equals(input.email)) {
+      throw new BadRequestException("email address must match URL");
+    }
+
+    if (!validator.isValid(email)) {
+      throw new BadRequestException("invalid email address");
+    }
+
+    EmailInfo info = new EmailInfo();
+    info.email = email;
+    if (input.noConfirmation || isDevMode) {
+      if (isDevMode) {
+        log.warn("skipping email validation in developer mode");
+      }
+      try {
+        accountManager.link(user.getAccountId(), AuthRequest.forEmail(email));
+      } catch (AccountException e) {
+        throw new ResourceConflictException(e.getMessage());
+      }
+      if (input.preferred) {
+        putPreferred.apply(new AccountResource.Email(user, email), null);
+        info.preferred = true;
+      }
+    } else {
+      try {
+        RegisterNewEmailSender sender = registerNewEmailFactory.create(email);
+        if (!sender.isAllowed()) {
+          throw new MethodNotAllowedException("Not allowed to add email address " + email);
+        }
+        sender.send();
+        info.pendingConfirmation = true;
+      } catch (EmailException | RuntimeException e) {
+        log.error("Cannot send email verification message to " + email, e);
+        throw e;
+      }
+    }
+    return Response.created(info);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteActive.java b/java/com/google/gerrit/server/restapi/account/DeleteActive.java
new file mode 100644
index 0000000..fda28c9
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteActive.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+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.AccountResource;
+import com.google.gerrit.server.account.SetInactiveFlag;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
+@Singleton
+public class DeleteActive implements RestModifyView<AccountResource, Input> {
+
+  private final Provider<IdentifiedUser> self;
+  private final SetInactiveFlag setInactiveFlag;
+
+  @Inject
+  DeleteActive(SetInactiveFlag setInactiveFlag, Provider<IdentifiedUser> self) {
+    this.setInactiveFlag = setInactiveFlag;
+    this.self = self;
+  }
+
+  @Override
+  public Response<?> apply(AccountResource rsrc, Input input)
+      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+    if (self.get() == rsrc.getUser()) {
+      throw new ResourceConflictException("cannot deactivate own account");
+    }
+    return setInactiveFlag.deactivate(rsrc.getUser().getAccountId());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteEmail.java b/java/com/google/gerrit/server/restapi/account/DeleteEmail.java
new file mode 100644
index 0000000..d36dfe9
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteEmail.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+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;
+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.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class DeleteEmail implements RestModifyView<AccountResource.Email, Input> {
+
+  private final Provider<CurrentUser> self;
+  private final Realm realm;
+  private final PermissionBackend permissionBackend;
+  private final AccountManager accountManager;
+  private final ExternalIds externalIds;
+
+  @Inject
+  DeleteEmail(
+      Provider<CurrentUser> self,
+      Realm realm,
+      PermissionBackend permissionBackend,
+      AccountManager accountManager,
+      ExternalIds externalIds) {
+    this.self = self;
+    this.realm = realm;
+    this.permissionBackend = permissionBackend;
+    this.accountManager = accountManager;
+    this.externalIds = externalIds;
+  }
+
+  @Override
+  public Response<?> apply(AccountResource.Email rsrc, Input input)
+      throws AuthException, ResourceNotFoundException, ResourceConflictException,
+          MethodNotAllowedException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+    }
+    return apply(rsrc.getUser(), rsrc.getEmail());
+  }
+
+  public Response<?> apply(IdentifiedUser user, String email)
+      throws ResourceNotFoundException, ResourceConflictException, MethodNotAllowedException,
+          OrmException, IOException, ConfigInvalidException {
+    if (!realm.allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL)) {
+      throw new MethodNotAllowedException("realm does not allow deleting emails");
+    }
+
+    Set<ExternalId> extIds =
+        externalIds
+            .byAccount(user.getAccountId())
+            .stream()
+            .filter(e -> email.equals(e.email()))
+            .collect(toSet());
+    if (extIds.isEmpty()) {
+      throw new ResourceNotFoundException(email);
+    }
+
+    try {
+      accountManager.unlink(
+          user.getAccountId(), extIds.stream().map(e -> e.key()).collect(toSet()));
+    } catch (AccountException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java b/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java
new file mode 100644
index 0000000..3d103ec
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static java.util.stream.Collectors.toMap;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class DeleteExternalIds implements RestModifyView<AccountResource, List<String>> {
+  private final PermissionBackend permissionBackend;
+  private final AccountManager accountManager;
+  private final ExternalIds externalIds;
+  private final Provider<CurrentUser> self;
+
+  @Inject
+  DeleteExternalIds(
+      PermissionBackend permissionBackend,
+      AccountManager accountManager,
+      ExternalIds externalIds,
+      Provider<CurrentUser> self) {
+    this.permissionBackend = permissionBackend;
+    this.accountManager = accountManager;
+    this.externalIds = externalIds;
+    this.self = self;
+  }
+
+  @Override
+  public Response<?> apply(AccountResource resource, List<String> extIds)
+      throws RestApiException, IOException, OrmException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != resource.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ACCESS_DATABASE);
+    }
+
+    if (extIds == null || extIds.size() == 0) {
+      throw new BadRequestException("external IDs are required");
+    }
+
+    Map<ExternalId.Key, ExternalId> externalIdMap =
+        externalIds
+            .byAccount(resource.getUser().getAccountId())
+            .stream()
+            .collect(toMap(i -> i.key(), i -> i));
+
+    List<ExternalId> toDelete = new ArrayList<>();
+    ExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
+    for (String externalIdStr : extIds) {
+      ExternalId id = externalIdMap.get(ExternalId.Key.parse(externalIdStr));
+
+      if (id == null) {
+        throw new UnprocessableEntityException(
+            String.format("External id %s does not exist", externalIdStr));
+      }
+
+      if ((!id.isScheme(SCHEME_USERNAME))
+          && ((last == null) || (!last.get().equals(id.key().get())))) {
+        toDelete.add(id);
+      } else {
+        throw new ResourceConflictException(
+            String.format("External id %s cannot be deleted", externalIdStr));
+      }
+    }
+
+    try {
+      accountManager.unlink(
+          resource.getUser().getAccountId(), toDelete.stream().map(e -> e.key()).collect(toSet()));
+    } catch (AccountException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
new file mode 100644
index 0000000..f6f3045
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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.AccountResource;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+
+@Singleton
+public class DeleteSshKey implements RestModifyView<AccountResource.SshKey, Input> {
+
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
+  private final SshKeyCache sshKeyCache;
+
+  @Inject
+  DeleteSshKey(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      VersionedAuthorizedKeys.Accessor authorizedKeys,
+      SshKeyCache sshKeyCache) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.authorizedKeys = authorizedKeys;
+    this.sshKeyCache = sshKeyCache;
+  }
+
+  @Override
+  public Response<?> apply(AccountResource.SshKey rsrc, Input input)
+      throws AuthException, OrmException, RepositoryNotFoundException, IOException,
+          ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+
+    authorizedKeys.deleteKey(rsrc.getUser().getAccountId(), rsrc.getSshKey().getKey().get());
+    sshKeyCache.evict(rsrc.getUser().getUserName());
+
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java
new file mode 100644
index 0000000..5a1f6bf
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class DeleteWatchedProjects
+    implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
+  private final Provider<IdentifiedUser> self;
+  private final PermissionBackend permissionBackend;
+  private final AccountsUpdate.User accountsUpdate;
+
+  @Inject
+  DeleteWatchedProjects(
+      Provider<IdentifiedUser> self,
+      PermissionBackend permissionBackend,
+      AccountsUpdate.User accountsUpdate) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.accountsUpdate = accountsUpdate;
+  }
+
+  @Override
+  public Response<?> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
+      throws AuthException, UnprocessableEntityException, OrmException, IOException,
+          ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+    if (input == null) {
+      return Response.none();
+    }
+
+    Account.Id accountId = rsrc.getUser().getAccountId();
+    accountsUpdate
+        .create()
+        .update(
+            "Delete Project Watches via API",
+            accountId,
+            u ->
+                u.deleteProjectWatches(
+                    input
+                        .stream()
+                        .filter(Objects::nonNull)
+                        .map(w -> ProjectWatchKey.create(new Project.NameKey(w.project), w.filter))
+                        .collect(toList())));
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/EmailsCollection.java b/java/com/google/gerrit/server/restapi/account/EmailsCollection.java
new file mode 100644
index 0000000..d75a01a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/EmailsCollection.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountResource.Email;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class EmailsCollection
+    implements ChildCollection<AccountResource, AccountResource.Email>,
+        AcceptsCreate<AccountResource> {
+  private final DynamicMap<RestView<AccountResource.Email>> views;
+  private final GetEmails list;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final CreateEmail.Factory createEmailFactory;
+
+  @Inject
+  EmailsCollection(
+      DynamicMap<RestView<AccountResource.Email>> views,
+      GetEmails list,
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      CreateEmail.Factory createEmailFactory) {
+    this.views = views;
+    this.list = list;
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.createEmailFactory = createEmailFactory;
+  }
+
+  @Override
+  public RestView<AccountResource> list() {
+    return list;
+  }
+
+  @Override
+  public AccountResource.Email parse(AccountResource rsrc, IdString id)
+      throws ResourceNotFoundException, PermissionBackendException, AuthException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+
+    if ("preferred".equals(id.get())) {
+      String email = rsrc.getUser().getAccount().getPreferredEmail();
+      if (Strings.isNullOrEmpty(email)) {
+        throw new ResourceNotFoundException(id);
+      }
+      return new AccountResource.Email(rsrc.getUser(), email);
+    } else if (rsrc.getUser().hasEmailAddress(id.get())) {
+      return new AccountResource.Email(rsrc.getUser(), id.get());
+    } else {
+      throw new ResourceNotFoundException(id);
+    }
+  }
+
+  @Override
+  public DynamicMap<RestView<Email>> views() {
+    return views;
+  }
+
+  @Override
+  public CreateEmail create(AccountResource parent, IdString email) {
+    return createEmailFactory.create(email.get());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetAccount.java b/java/com/google/gerrit/server/restapi/account/GetAccount.java
new file mode 100644
index 0000000..0d8e25e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetAccount.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetAccount implements RestReadView<AccountResource> {
+  private final AccountLoader.Factory infoFactory;
+
+  @Inject
+  GetAccount(AccountLoader.Factory infoFactory) {
+    this.infoFactory = infoFactory;
+  }
+
+  @Override
+  public AccountInfo apply(AccountResource rsrc) throws OrmException {
+    AccountLoader loader = infoFactory.create(true);
+    AccountInfo info = loader.get(rsrc.getUser().getAccountId());
+    loader.fill();
+    return info;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetActive.java b/java/com/google/gerrit/server/restapi/account/GetActive.java
new file mode 100644
index 0000000..66493f8
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetActive.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetActive implements RestReadView<AccountResource> {
+  @Override
+  public Response<String> apply(AccountResource rsrc) {
+    if (rsrc.getUser().getAccount().isActive()) {
+      return Response.ok("ok");
+    }
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetAgreements.java b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
new file mode 100644
index 0000000..719cb21
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.common.AgreementInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.restapi.config.AgreementJson;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class GetAgreements implements RestReadView<AccountResource> {
+  private static final Logger log = LoggerFactory.getLogger(GetAgreements.class);
+
+  private final Provider<CurrentUser> self;
+  private final ProjectCache projectCache;
+  private final AgreementJson agreementJson;
+  private final boolean agreementsEnabled;
+
+  @Inject
+  GetAgreements(
+      Provider<CurrentUser> self,
+      ProjectCache projectCache,
+      AgreementJson agreementJson,
+      @GerritServerConfig Config config) {
+    this.self = self;
+    this.projectCache = projectCache;
+    this.agreementJson = agreementJson;
+    this.agreementsEnabled = config.getBoolean("auth", "contributorAgreements", false);
+  }
+
+  @Override
+  public List<AgreementInfo> apply(AccountResource resource) throws RestApiException {
+    if (!agreementsEnabled) {
+      throw new MethodNotAllowedException("contributor agreements disabled");
+    }
+
+    if (!self.get().isIdentifiedUser()) {
+      throw new AuthException("not allowed to get contributor agreements");
+    }
+
+    IdentifiedUser user = self.get().asIdentifiedUser();
+    if (user != resource.getUser()) {
+      throw new AuthException("not allowed to get contributor agreements");
+    }
+
+    List<AgreementInfo> results = new ArrayList<>();
+    Collection<ContributorAgreement> cas =
+        projectCache.getAllProjects().getConfig().getContributorAgreements();
+    for (ContributorAgreement ca : cas) {
+      List<AccountGroup.UUID> groupIds = new ArrayList<>();
+      for (PermissionRule rule : ca.getAccepted()) {
+        if ((rule.getAction() == Action.ALLOW) && (rule.getGroup() != null)) {
+          if (rule.getGroup().getUUID() != null) {
+            groupIds.add(rule.getGroup().getUUID());
+          } else {
+            log.warn(
+                "group \""
+                    + rule.getGroup().getName()
+                    + "\" does not "
+                    + "exist, referenced in CLA \""
+                    + ca.getName()
+                    + "\"");
+          }
+        }
+      }
+
+      if (user.getEffectiveGroups().containsAnyOf(groupIds)) {
+        results.add(agreementJson.format(ca));
+      }
+    }
+    return results;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetAvatar.java b/java/com/google/gerrit/server/restapi/account/GetAvatar.java
new file mode 100644
index 0000000..2f8570e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetAvatar.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.CacheControl;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.avatar.AvatarProvider;
+import com.google.inject.Inject;
+import java.util.concurrent.TimeUnit;
+import org.kohsuke.args4j.Option;
+
+public class GetAvatar implements RestReadView<AccountResource> {
+  private final DynamicItem<AvatarProvider> avatarProvider;
+
+  private int size;
+
+  @Option(
+    name = "--size",
+    aliases = {"-s"},
+    usage = "recommended size in pixels, height and width"
+  )
+  public void setSize(int s) {
+    size = s;
+  }
+
+  @Inject
+  GetAvatar(DynamicItem<AvatarProvider> avatarProvider) {
+    this.avatarProvider = avatarProvider;
+  }
+
+  @Override
+  public Response.Redirect apply(AccountResource rsrc) throws ResourceNotFoundException {
+    AvatarProvider impl = avatarProvider.get();
+    if (impl == null) {
+      throw (new ResourceNotFoundException()).caching(CacheControl.PUBLIC(1, TimeUnit.DAYS));
+    }
+
+    String url = impl.getUrl(rsrc.getUser(), size);
+    if (Strings.isNullOrEmpty(url)) {
+      throw (new ResourceNotFoundException()).caching(CacheControl.PUBLIC(1, TimeUnit.HOURS));
+    }
+    return Response.redirect(url);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java b/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java
new file mode 100644
index 0000000..904b15f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.avatar.AvatarProvider;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetAvatarChangeUrl implements RestReadView<AccountResource> {
+  private final DynamicItem<AvatarProvider> avatarProvider;
+
+  @Inject
+  GetAvatarChangeUrl(DynamicItem<AvatarProvider> avatarProvider) {
+    this.avatarProvider = avatarProvider;
+  }
+
+  @Override
+  public String apply(AccountResource rsrc) throws ResourceNotFoundException {
+    AvatarProvider impl = avatarProvider.get();
+    if (impl == null) {
+      throw new ResourceNotFoundException();
+    }
+
+    String url = impl.getChangeAvatarUrl(rsrc.getUser());
+    if (Strings.isNullOrEmpty(url)) {
+      throw new ResourceNotFoundException();
+    }
+    return url;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
new file mode 100644
index 0000000..5260bef0
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
@@ -0,0 +1,166 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.gerrit.common.data.GlobalCapability.PRIORITY;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.OptionUtil;
+import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.account.AccountLimits;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountResource.Capability;
+import com.google.gerrit.server.git.QueueProvider;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import org.kohsuke.args4j.Option;
+
+class GetCapabilities implements RestReadView<AccountResource> {
+  @Option(name = "-q", metaVar = "CAP", usage = "Capability to inspect")
+  void addQuery(String name) {
+    if (query == null) {
+      query = new HashSet<>();
+    }
+    Iterables.addAll(query, OptionUtil.splitOptionValue(name));
+  }
+
+  private Set<String> query;
+
+  private final PermissionBackend permissionBackend;
+  private final AccountLimits.Factory limitsFactory;
+  private final Provider<CurrentUser> self;
+  private final DynamicMap<CapabilityDefinition> pluginCapabilities;
+
+  @Inject
+  GetCapabilities(
+      PermissionBackend permissionBackend,
+      AccountLimits.Factory limitsFactory,
+      Provider<CurrentUser> self,
+      DynamicMap<CapabilityDefinition> pluginCapabilities) {
+    this.permissionBackend = permissionBackend;
+    this.limitsFactory = limitsFactory;
+    this.self = self;
+    this.pluginCapabilities = pluginCapabilities;
+  }
+
+  @Override
+  public Object apply(AccountResource rsrc) throws AuthException, PermissionBackendException {
+    PermissionBackend.WithUser perm = permissionBackend.user(self);
+    if (self.get() != rsrc.getUser()) {
+      perm.check(GlobalPermission.ADMINISTRATE_SERVER);
+      perm = permissionBackend.user(rsrc.getUser());
+    }
+
+    Map<String, Object> have = new LinkedHashMap<>();
+    for (GlobalOrPluginPermission p : perm.test(permissionsToTest())) {
+      have.put(p.permissionName(), true);
+    }
+
+    AccountLimits limits = limitsFactory.create(rsrc.getUser());
+    addRanges(have, limits);
+    addPriority(have, limits);
+
+    return OutputFormat.JSON
+        .newGson()
+        .toJsonTree(have, new TypeToken<Map<String, Object>>() {}.getType());
+  }
+
+  private Set<GlobalOrPluginPermission> permissionsToTest() {
+    Set<GlobalOrPluginPermission> toTest = new HashSet<>();
+    for (GlobalPermission p : GlobalPermission.values()) {
+      if (want(p.permissionName())) {
+        toTest.add(p);
+      }
+    }
+
+    for (String pluginName : pluginCapabilities.plugins()) {
+      for (String capability : pluginCapabilities.byPlugin(pluginName).keySet()) {
+        PluginPermission p = new PluginPermission(pluginName, capability);
+        if (want(p.permissionName())) {
+          toTest.add(p);
+        }
+      }
+    }
+    return toTest;
+  }
+
+  private boolean want(String name) {
+    return query == null || query.contains(name.toLowerCase());
+  }
+
+  private void addRanges(Map<String, Object> have, AccountLimits limits) {
+    for (String name : GlobalCapability.getRangeNames()) {
+      if (want(name) && limits.hasExplicitRange(name)) {
+        have.put(name, new Range(limits.getRange(name)));
+      }
+    }
+  }
+
+  private void addPriority(Map<String, Object> have, AccountLimits limits) {
+    QueueProvider.QueueType queue = limits.getQueueType();
+    if (queue != QueueProvider.QueueType.INTERACTIVE
+        || (query != null && query.contains(PRIORITY))) {
+      have.put(PRIORITY, queue);
+    }
+  }
+
+  private static class Range {
+    private transient PermissionRange range;
+
+    @SuppressWarnings("unused")
+    private int min;
+
+    @SuppressWarnings("unused")
+    private int max;
+
+    Range(PermissionRange r) {
+      range = r;
+      min = r.getMin();
+      max = r.getMax();
+    }
+
+    @Override
+    public String toString() {
+      return range.toString();
+    }
+  }
+
+  @Singleton
+  static class CheckOne implements RestReadView<AccountResource.Capability> {
+    @Override
+    public BinaryResult apply(Capability resource) {
+      return BinaryResult.create("ok\n");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetDetail.java b/java/com/google/gerrit/server/restapi/account/GetDetail.java
new file mode 100644
index 0000000..de9928c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetDetail.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.common.base.Throwables;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountDirectory.DirectoryException;
+import com.google.gerrit.server.account.AccountDirectory.FillOptions;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.InternalAccountDirectory;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
+import java.util.Collections;
+import java.util.EnumSet;
+
+@Singleton
+public class GetDetail implements RestReadView<AccountResource> {
+
+  private final InternalAccountDirectory directory;
+
+  @Inject
+  public GetDetail(InternalAccountDirectory directory) {
+    this.directory = directory;
+  }
+
+  @Override
+  public AccountDetailInfo apply(AccountResource rsrc) throws OrmException {
+    Account a = rsrc.getUser().getAccount();
+    AccountDetailInfo info = new AccountDetailInfo(a.getId().get());
+    info.registeredOn = a.getRegisteredOn();
+    info.inactive = !a.isActive() ? true : null;
+    try {
+      directory.fillAccountInfo(Collections.singleton(info), EnumSet.allOf(FillOptions.class));
+    } catch (DirectoryException e) {
+      Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
+      throw new OrmException(e);
+    }
+    return info;
+  }
+
+  public static class AccountDetailInfo extends AccountInfo {
+    public Timestamp registeredOn;
+    public Boolean inactive;
+
+    public AccountDetailInfo(Integer id) {
+      super(id);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java b/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java
new file mode 100644
index 0000000..c173079
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
+
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class GetDiffPreferences implements RestReadView<AccountResource> {
+  private static final Logger log = LoggerFactory.getLogger(GetDiffPreferences.class);
+
+  private final Provider<CurrentUser> self;
+  private final Provider<AllUsersName> allUsersName;
+  private final PermissionBackend permissionBackend;
+  private final GitRepositoryManager gitMgr;
+
+  @Inject
+  GetDiffPreferences(
+      Provider<CurrentUser> self,
+      Provider<AllUsersName> allUsersName,
+      PermissionBackend permissionBackend,
+      GitRepositoryManager gitMgr) {
+    this.self = self;
+    this.allUsersName = allUsersName;
+    this.permissionBackend = permissionBackend;
+    this.gitMgr = gitMgr;
+  }
+
+  @Override
+  public DiffPreferencesInfo apply(AccountResource rsrc)
+      throws AuthException, ConfigInvalidException, IOException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+
+    Account.Id id = rsrc.getUser().getAccountId();
+    return readFromGit(id, gitMgr, allUsersName.get(), null);
+  }
+
+  static DiffPreferencesInfo readFromGit(
+      Account.Id id, GitRepositoryManager gitMgr, AllUsersName allUsersName, DiffPreferencesInfo in)
+      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
+    try (Repository git = gitMgr.openRepository(allUsersName)) {
+      VersionedAccountPreferences p = VersionedAccountPreferences.forUser(id);
+      p.load(git);
+      DiffPreferencesInfo prefs = new DiffPreferencesInfo();
+      loadSection(
+          p.getConfig(), UserConfigSections.DIFF, null, prefs, readDefaultsFromGit(git, in), in);
+      return prefs;
+    }
+  }
+
+  static DiffPreferencesInfo readDefaultsFromGit(Repository git, DiffPreferencesInfo in)
+      throws ConfigInvalidException, IOException {
+    VersionedAccountPreferences dp = VersionedAccountPreferences.forDefault();
+    dp.load(git);
+    DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
+    loadSection(
+        dp.getConfig(),
+        UserConfigSections.DIFF,
+        null,
+        allUserPrefs,
+        DiffPreferencesInfo.defaults(),
+        in);
+    return updateDefaults(allUserPrefs);
+  }
+
+  private static DiffPreferencesInfo updateDefaults(DiffPreferencesInfo input) {
+    DiffPreferencesInfo result = DiffPreferencesInfo.defaults();
+    try {
+      for (Field field : input.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        Object newVal = field.get(input);
+        if (newVal != null) {
+          field.set(result, newVal);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      log.warn("Cannot get default diff preferences from All-Users", e);
+      return DiffPreferencesInfo.defaults();
+    }
+    return result;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java b/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java
new file mode 100644
index 0000000..95a6e7c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class GetEditPreferences implements RestReadView<AccountResource> {
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final AllUsersName allUsersName;
+  private final GitRepositoryManager gitMgr;
+
+  @Inject
+  GetEditPreferences(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      AllUsersName allUsersName,
+      GitRepositoryManager gitMgr) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.allUsersName = allUsersName;
+    this.gitMgr = gitMgr;
+  }
+
+  @Override
+  public EditPreferencesInfo apply(AccountResource rsrc)
+      throws AuthException, IOException, ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+    }
+
+    return readFromGit(rsrc.getUser().getAccountId(), gitMgr, allUsersName, null);
+  }
+
+  static EditPreferencesInfo readFromGit(
+      Account.Id id, GitRepositoryManager gitMgr, AllUsersName allUsersName, EditPreferencesInfo in)
+      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
+    try (Repository git = gitMgr.openRepository(allUsersName)) {
+      VersionedAccountPreferences p = VersionedAccountPreferences.forUser(id);
+      p.load(git);
+
+      return loadSection(
+          p.getConfig(),
+          UserConfigSections.EDIT,
+          null,
+          new EditPreferencesInfo(),
+          EditPreferencesInfo.defaults(),
+          in);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetEmail.java b/java/com/google/gerrit/server/restapi/account/GetEmail.java
new file mode 100644
index 0000000..3118380
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetEmail.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.common.EmailInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetEmail implements RestReadView<AccountResource.Email> {
+  @Inject
+  public GetEmail() {}
+
+  @Override
+  public EmailInfo apply(AccountResource.Email rsrc) {
+    EmailInfo e = new EmailInfo();
+    e.email = rsrc.getEmail();
+    e.preferred(rsrc.getUser().getAccount().getPreferredEmail());
+    return e;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetEmails.java b/java/com/google/gerrit/server/restapi/account/GetEmails.java
new file mode 100644
index 0000000..640cc64
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetEmails.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.common.EmailInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+@Singleton
+public class GetEmails implements RestReadView<AccountResource> {
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  GetEmails(Provider<CurrentUser> self, PermissionBackend permissionBackend) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public List<EmailInfo> apply(AccountResource rsrc)
+      throws AuthException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+    }
+
+    List<EmailInfo> emails = new ArrayList<>();
+    for (String email : rsrc.getUser().getEmailAddresses()) {
+      if (email != null) {
+        EmailInfo e = new EmailInfo();
+        e.email = email;
+        e.preferred(rsrc.getUser().getAccount().getPreferredEmail());
+        emails.add(e);
+      }
+    }
+    Collections.sort(
+        emails,
+        new Comparator<EmailInfo>() {
+          @Override
+          public int compare(EmailInfo a, EmailInfo b) {
+            return a.email.compareTo(b.email);
+          }
+        });
+    return emails;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
new file mode 100644
index 0000000..8e456a2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.common.AccountExternalIdInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+@Singleton
+public class GetExternalIds implements RestReadView<AccountResource> {
+  private final PermissionBackend permissionBackend;
+  private final ExternalIds externalIds;
+  private final Provider<CurrentUser> self;
+  private final AuthConfig authConfig;
+
+  @Inject
+  GetExternalIds(
+      PermissionBackend permissionBackend,
+      ExternalIds externalIds,
+      Provider<CurrentUser> self,
+      AuthConfig authConfig) {
+    this.permissionBackend = permissionBackend;
+    this.externalIds = externalIds;
+    this.self = self;
+    this.authConfig = authConfig;
+  }
+
+  @Override
+  public List<AccountExternalIdInfo> apply(AccountResource resource)
+      throws RestApiException, IOException, OrmException, PermissionBackendException {
+    if (self.get() != resource.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ACCESS_DATABASE);
+    }
+
+    Collection<ExternalId> ids = externalIds.byAccount(resource.getUser().getAccountId());
+    if (ids.isEmpty()) {
+      return ImmutableList.of();
+    }
+    List<AccountExternalIdInfo> result = Lists.newArrayListWithCapacity(ids.size());
+    for (ExternalId id : ids) {
+      AccountExternalIdInfo info = new AccountExternalIdInfo();
+      info.identity = id.key().get();
+      info.emailAddress = id.email();
+      info.trusted = toBoolean(authConfig.isIdentityTrustable(Collections.singleton(id)));
+      // The identity can be deleted only if its not the one used to
+      // establish this web session, and if only if an identity was
+      // actually used to establish this web session.
+      if (!id.isScheme(SCHEME_USERNAME)) {
+        ExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
+        info.canDelete = toBoolean(last == null || !last.get().equals(info.identity));
+      }
+      result.add(info);
+    }
+    return result;
+  }
+
+  private static Boolean toBoolean(boolean v) {
+    return v ? v : null;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetGroups.java b/java/com/google/gerrit/server/restapi/account/GetGroups.java
new file mode 100644
index 0000000..992a85a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetGroups.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.restapi.group.GroupJson;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+
+@Singleton
+public class GetGroups implements RestReadView<AccountResource> {
+  private final GroupControl.Factory groupControlFactory;
+  private final GroupJson json;
+
+  @Inject
+  GetGroups(GroupControl.Factory groupControlFactory, GroupJson json) {
+    this.groupControlFactory = groupControlFactory;
+    this.json = json;
+  }
+
+  @Override
+  public List<GroupInfo> apply(AccountResource resource) throws OrmException {
+    IdentifiedUser user = resource.getUser();
+    Account.Id userId = user.getAccountId();
+    List<GroupInfo> groups = new ArrayList<>();
+    for (AccountGroup.UUID uuid : user.getEffectiveGroups().getKnownGroups()) {
+      GroupControl ctl;
+      try {
+        ctl = groupControlFactory.controlFor(uuid);
+      } catch (NoSuchGroupException e) {
+        continue;
+      }
+      if (ctl.isVisible() && ctl.canSeeMember(userId)) {
+        groups.add(json.format(ctl.getGroup()));
+      }
+    }
+    return groups;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetName.java b/java/com/google/gerrit/server/restapi/account/GetName.java
new file mode 100644
index 0000000..bdf379e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetName.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetName implements RestReadView<AccountResource> {
+  @Override
+  public String apply(AccountResource rsrc) {
+    return Strings.nullToEmpty(rsrc.getUser().getAccount().getFullName());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java b/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java
new file mode 100644
index 0000000..43838e8
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.net.URI;
+import java.net.URISyntaxException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+class GetOAuthToken implements RestReadView<AccountResource> {
+
+  private static final String BEARER_TYPE = "bearer";
+  private static final Logger log = LoggerFactory.getLogger(GetOAuthToken.class);
+
+  private final Provider<CurrentUser> self;
+  private final OAuthTokenCache tokenCache;
+  private final Provider<String> canonicalWebUrlProvider;
+
+  @Inject
+  GetOAuthToken(
+      Provider<CurrentUser> self,
+      OAuthTokenCache tokenCache,
+      @CanonicalWebUrl Provider<String> urlProvider) {
+    this.self = self;
+    this.tokenCache = tokenCache;
+    this.canonicalWebUrlProvider = urlProvider;
+  }
+
+  @Override
+  public OAuthTokenInfo apply(AccountResource rsrc)
+      throws AuthException, ResourceNotFoundException {
+    if (self.get() != rsrc.getUser()) {
+      throw new AuthException("not allowed to get access token");
+    }
+    Account a = rsrc.getUser().getAccount();
+    OAuthToken accessToken = tokenCache.get(a.getId());
+    if (accessToken == null) {
+      throw new ResourceNotFoundException();
+    }
+    OAuthTokenInfo accessTokenInfo = new OAuthTokenInfo();
+    accessTokenInfo.username = a.getUserName();
+    accessTokenInfo.resourceHost = getHostName(canonicalWebUrlProvider.get());
+    accessTokenInfo.accessToken = accessToken.getToken();
+    accessTokenInfo.providerId = accessToken.getProviderId();
+    accessTokenInfo.expiresAt = Long.toString(accessToken.getExpiresAt());
+    accessTokenInfo.type = BEARER_TYPE;
+    return accessTokenInfo;
+  }
+
+  private static String getHostName(String canonicalWebUrl) {
+    if (canonicalWebUrl == null) {
+      log.error("No canonicalWebUrl defined in gerrit.config, OAuth may not work properly");
+      return null;
+    }
+
+    try {
+      return new URI(canonicalWebUrl).getHost();
+    } catch (URISyntaxException e) {
+      log.error("Invalid canonicalWebUrl '" + canonicalWebUrl + "'", e);
+      return null;
+    }
+  }
+
+  public static class OAuthTokenInfo {
+    public String username;
+    public String resourceHost;
+    public String accessToken;
+    public String providerId;
+    public String expiresAt;
+    public String type;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetPreferences.java b/java/com/google/gerrit/server/restapi/account/GetPreferences.java
new file mode 100644
index 0000000..46bc389
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetPreferences.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetPreferences implements RestReadView<AccountResource> {
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final AccountCache accountCache;
+
+  @Inject
+  GetPreferences(
+      Provider<CurrentUser> self, PermissionBackend permissionBackend, AccountCache accountCache) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.accountCache = accountCache;
+  }
+
+  @Override
+  public GeneralPreferencesInfo apply(AccountResource rsrc)
+      throws AuthException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+    }
+
+    Account.Id id = rsrc.getUser().getAccountId();
+    return accountCache.get(id).getGeneralPreferences();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetSshKey.java b/java/com/google/gerrit/server/restapi/account/GetSshKey.java
new file mode 100644
index 0000000..dc72663
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetSshKey.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.common.SshKeyInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountResource.SshKey;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetSshKey implements RestReadView<AccountResource.SshKey> {
+
+  @Override
+  public SshKeyInfo apply(SshKey rsrc) {
+    return GetSshKeys.newSshKeyInfo(rsrc.getSshKey());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
new file mode 100644
index 0000000..362812c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.common.SshKeyInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.AccountSshKey;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+
+@Singleton
+public class GetSshKeys implements RestReadView<AccountResource> {
+
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
+
+  @Inject
+  GetSshKeys(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      VersionedAuthorizedKeys.Accessor authorizedKeys) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.authorizedKeys = authorizedKeys;
+  }
+
+  @Override
+  public List<SshKeyInfo> apply(AccountResource rsrc)
+      throws AuthException, OrmException, RepositoryNotFoundException, IOException,
+          ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+    }
+    return apply(rsrc.getUser());
+  }
+
+  public List<SshKeyInfo> apply(IdentifiedUser user)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    return Lists.transform(authorizedKeys.getKeys(user.getAccountId()), GetSshKeys::newSshKeyInfo);
+  }
+
+  public static SshKeyInfo newSshKeyInfo(AccountSshKey sshKey) {
+    SshKeyInfo info = new SshKeyInfo();
+    info.seq = sshKey.getKey().get();
+    info.sshPublicKey = sshKey.getSshPublicKey();
+    info.encodedKey = sshKey.getEncodedKey();
+    info.algorithm = sshKey.getAlgorithm();
+    info.comment = Strings.emptyToNull(sshKey.getComment());
+    info.valid = sshKey.isValid();
+    return info;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetStatus.java b/java/com/google/gerrit/server/restapi/account/GetStatus.java
new file mode 100644
index 0000000..bc7094f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetStatus.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.restapi.account;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetStatus implements RestReadView<AccountResource> {
+  @Override
+  public String apply(AccountResource rsrc) {
+    return Strings.nullToEmpty(rsrc.getUser().getAccount().getStatus());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetUsername.java b/java/com/google/gerrit/server/restapi/account/GetUsername.java
new file mode 100644
index 0000000..34eb701
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetUsername.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetUsername implements RestReadView<AccountResource> {
+  @Inject
+  public GetUsername() {}
+
+  @Override
+  public String apply(AccountResource rsrc) throws AuthException, ResourceNotFoundException {
+    String username = rsrc.getUser().getAccount().getUserName();
+    if (username == null) {
+      throw new ResourceNotFoundException();
+    }
+    return username;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
new file mode 100644
index 0000000..ffddc7c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ComparisonChain;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class GetWatchedProjects implements RestReadView<AccountResource> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<IdentifiedUser> self;
+  private final Accounts accounts;
+
+  @Inject
+  public GetWatchedProjects(
+      PermissionBackend permissionBackend, Provider<IdentifiedUser> self, Accounts accounts) {
+    this.permissionBackend = permissionBackend;
+    this.self = self;
+    this.accounts = accounts;
+  }
+
+  @Override
+  public List<ProjectWatchInfo> apply(AccountResource rsrc)
+      throws OrmException, AuthException, IOException, ConfigInvalidException,
+          PermissionBackendException, ResourceNotFoundException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+
+    Account.Id accountId = rsrc.getUser().getAccountId();
+    AccountState account = accounts.get(accountId);
+    if (account == null) {
+      throw new ResourceNotFoundException();
+    }
+    List<ProjectWatchInfo> projectWatchInfos = new ArrayList<>();
+    for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : account.getProjectWatches().entrySet()) {
+      ProjectWatchInfo pwi = new ProjectWatchInfo();
+      pwi.filter = e.getKey().filter();
+      pwi.project = e.getKey().project().get();
+      pwi.notifyAbandonedChanges = toBoolean(e.getValue().contains(NotifyType.ABANDONED_CHANGES));
+      pwi.notifyNewChanges = toBoolean(e.getValue().contains(NotifyType.NEW_CHANGES));
+      pwi.notifyNewPatchSets = toBoolean(e.getValue().contains(NotifyType.NEW_PATCHSETS));
+      pwi.notifySubmittedChanges = toBoolean(e.getValue().contains(NotifyType.SUBMITTED_CHANGES));
+      pwi.notifyAllComments = toBoolean(e.getValue().contains(NotifyType.ALL_COMMENTS));
+      projectWatchInfos.add(pwi);
+    }
+    Collections.sort(
+        projectWatchInfos,
+        new Comparator<ProjectWatchInfo>() {
+          @Override
+          public int compare(ProjectWatchInfo pwi1, ProjectWatchInfo pwi2) {
+            return ComparisonChain.start()
+                .compare(pwi1.project, pwi2.project)
+                .compare(Strings.nullToEmpty(pwi1.filter), Strings.nullToEmpty(pwi2.filter))
+                .result();
+          }
+        });
+    return projectWatchInfos;
+  }
+
+  private static Boolean toBoolean(boolean value) {
+    return value ? true : null;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/Index.java b/java/com/google/gerrit/server/restapi/account/Index.java
new file mode 100644
index 0000000..20a381a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/Index.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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.AccountCache;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class Index implements RestModifyView<AccountResource, Input> {
+
+  private final AccountCache accountCache;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> self;
+
+  @Inject
+  Index(
+      AccountCache accountCache, PermissionBackend permissionBackend, Provider<CurrentUser> self) {
+    this.accountCache = accountCache;
+    this.permissionBackend = permissionBackend;
+    this.self = self;
+  }
+
+  @Override
+  public Response<?> apply(AccountResource rsrc, Input input)
+      throws IOException, AuthException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+    }
+
+    // evicting the account from the cache, reindexes the account
+    accountCache.evict(rsrc.getUser().getAccountId());
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/Module.java b/java/com/google/gerrit/server/restapi/account/Module.java
new file mode 100644
index 0000000..dad84e5
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/Module.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.gerrit.server.account.AccountResource.ACCOUNT_KIND;
+import static com.google.gerrit.server.account.AccountResource.CAPABILITY_KIND;
+import static com.google.gerrit.server.account.AccountResource.EMAIL_KIND;
+import static com.google.gerrit.server.account.AccountResource.SSH_KEY_KIND;
+import static com.google.gerrit.server.account.AccountResource.STARRED_CHANGE_KIND;
+import static com.google.gerrit.server.account.AccountResource.Star.STAR_KIND;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+
+public class Module extends RestApiModule {
+  @Override
+  protected void configure() {
+    bind(AccountsCollection.class);
+    bind(Capabilities.class);
+
+    DynamicMap.mapOf(binder(), ACCOUNT_KIND);
+    DynamicMap.mapOf(binder(), CAPABILITY_KIND);
+    DynamicMap.mapOf(binder(), EMAIL_KIND);
+    DynamicMap.mapOf(binder(), SSH_KEY_KIND);
+    DynamicMap.mapOf(binder(), STARRED_CHANGE_KIND);
+    DynamicMap.mapOf(binder(), STAR_KIND);
+
+    put(ACCOUNT_KIND).to(PutAccount.class);
+    get(ACCOUNT_KIND).to(GetAccount.class);
+    get(ACCOUNT_KIND, "detail").to(GetDetail.class);
+    post(ACCOUNT_KIND, "index").to(Index.class);
+    get(ACCOUNT_KIND, "name").to(GetName.class);
+    put(ACCOUNT_KIND, "name").to(PutName.class);
+    delete(ACCOUNT_KIND, "name").to(PutName.class);
+    get(ACCOUNT_KIND, "status").to(GetStatus.class);
+    put(ACCOUNT_KIND, "status").to(PutStatus.class);
+    get(ACCOUNT_KIND, "username").to(GetUsername.class);
+    put(ACCOUNT_KIND, "username").to(PutUsername.class);
+    get(ACCOUNT_KIND, "active").to(GetActive.class);
+    put(ACCOUNT_KIND, "active").to(PutActive.class);
+    delete(ACCOUNT_KIND, "active").to(DeleteActive.class);
+    child(ACCOUNT_KIND, "emails").to(EmailsCollection.class);
+    get(EMAIL_KIND).to(GetEmail.class);
+    put(EMAIL_KIND).to(PutEmail.class);
+    delete(EMAIL_KIND).to(DeleteEmail.class);
+    put(EMAIL_KIND, "preferred").to(PutPreferred.class);
+    put(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
+    delete(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
+    child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class);
+    post(ACCOUNT_KIND, "sshkeys").to(AddSshKey.class);
+    get(ACCOUNT_KIND, "watched.projects").to(GetWatchedProjects.class);
+    post(ACCOUNT_KIND, "watched.projects").to(PostWatchedProjects.class);
+    post(ACCOUNT_KIND, "watched.projects:delete").to(DeleteWatchedProjects.class);
+
+    get(SSH_KEY_KIND).to(GetSshKey.class);
+    delete(SSH_KEY_KIND).to(DeleteSshKey.class);
+
+    get(ACCOUNT_KIND, "oauthtoken").to(GetOAuthToken.class);
+
+    get(ACCOUNT_KIND, "avatar").to(GetAvatar.class);
+    get(ACCOUNT_KIND, "avatar.change.url").to(GetAvatarChangeUrl.class);
+
+    child(ACCOUNT_KIND, "capabilities").to(Capabilities.class);
+
+    get(ACCOUNT_KIND, "groups").to(GetGroups.class);
+    get(ACCOUNT_KIND, "preferences").to(GetPreferences.class);
+    put(ACCOUNT_KIND, "preferences").to(SetPreferences.class);
+    get(ACCOUNT_KIND, "preferences.diff").to(GetDiffPreferences.class);
+    put(ACCOUNT_KIND, "preferences.diff").to(SetDiffPreferences.class);
+    get(ACCOUNT_KIND, "preferences.edit").to(GetEditPreferences.class);
+    put(ACCOUNT_KIND, "preferences.edit").to(SetEditPreferences.class);
+    get(CAPABILITY_KIND).to(GetCapabilities.CheckOne.class);
+
+    get(ACCOUNT_KIND, "agreements").to(GetAgreements.class);
+    put(ACCOUNT_KIND, "agreements").to(PutAgreement.class);
+
+    child(ACCOUNT_KIND, "starred.changes").to(StarredChanges.class);
+    put(STARRED_CHANGE_KIND).to(StarredChanges.Put.class);
+    delete(STARRED_CHANGE_KIND).to(StarredChanges.Delete.class);
+    bind(StarredChanges.Create.class);
+
+    child(ACCOUNT_KIND, "stars.changes").to(Stars.class);
+    get(STAR_KIND).to(Stars.Get.class);
+    post(STAR_KIND).to(Stars.Post.class);
+
+    get(ACCOUNT_KIND, "external.ids").to(GetExternalIds.class);
+    post(ACCOUNT_KIND, "external.ids:delete").to(DeleteExternalIds.class);
+
+    factory(CreateAccount.Factory.class);
+    factory(CreateEmail.Factory.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
new file mode 100644
index 0000000..bceaaf6
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.WatchConfig;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.project.ProjectsCollection;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PostWatchedProjects
+    implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
+  private final Provider<IdentifiedUser> self;
+  private final PermissionBackend permissionBackend;
+  private final GetWatchedProjects getWatchedProjects;
+  private final ProjectsCollection projectsCollection;
+  private final AccountsUpdate.User accountsUpdate;
+
+  @Inject
+  public PostWatchedProjects(
+      Provider<IdentifiedUser> self,
+      PermissionBackend permissionBackend,
+      GetWatchedProjects getWatchedProjects,
+      ProjectsCollection projectsCollection,
+      AccountsUpdate.User accountsUpdate) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.getWatchedProjects = getWatchedProjects;
+    this.projectsCollection = projectsCollection;
+    this.accountsUpdate = accountsUpdate;
+  }
+
+  @Override
+  public List<ProjectWatchInfo> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
+      throws OrmException, RestApiException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+
+    Map<ProjectWatchKey, Set<NotifyType>> projectWatches = asMap(input);
+    accountsUpdate
+        .create()
+        .update(
+            "Update Project Watches via API",
+            rsrc.getUser().getAccountId(),
+            u -> u.updateProjectWatches(projectWatches));
+    return getWatchedProjects.apply(rsrc);
+  }
+
+  private Map<ProjectWatchKey, Set<NotifyType>> asMap(List<ProjectWatchInfo> input)
+      throws BadRequestException, UnprocessableEntityException, IOException,
+          PermissionBackendException {
+    Map<ProjectWatchKey, Set<NotifyType>> m = new HashMap<>();
+    for (ProjectWatchInfo info : input) {
+      if (info.project == null) {
+        throw new BadRequestException("project name must be specified");
+      }
+
+      ProjectWatchKey key =
+          ProjectWatchKey.create(projectsCollection.parse(info.project).getNameKey(), info.filter);
+      if (m.containsKey(key)) {
+        throw new BadRequestException(
+            "duplicate entry for project " + format(info.project, info.filter));
+      }
+
+      Set<NotifyType> notifyValues = EnumSet.noneOf(NotifyType.class);
+      if (toBoolean(info.notifyAbandonedChanges)) {
+        notifyValues.add(NotifyType.ABANDONED_CHANGES);
+      }
+      if (toBoolean(info.notifyAllComments)) {
+        notifyValues.add(NotifyType.ALL_COMMENTS);
+      }
+      if (toBoolean(info.notifyNewChanges)) {
+        notifyValues.add(NotifyType.NEW_CHANGES);
+      }
+      if (toBoolean(info.notifyNewPatchSets)) {
+        notifyValues.add(NotifyType.NEW_PATCHSETS);
+      }
+      if (toBoolean(info.notifySubmittedChanges)) {
+        notifyValues.add(NotifyType.SUBMITTED_CHANGES);
+      }
+
+      m.put(key, notifyValues);
+    }
+    return m;
+  }
+
+  private boolean toBoolean(Boolean b) {
+    return b == null ? false : b;
+  }
+
+  private static String format(String project, String filter) {
+    return project
+        + (filter != null && !WatchConfig.FILTER_ALL.equals(filter) ? " and filter " + filter : "");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/PutAccount.java b/java/com/google/gerrit/server/restapi/account/PutAccount.java
new file mode 100644
index 0000000..4c84c19
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/PutAccount.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.common.AccountInfo;
+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.server.account.AccountResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class PutAccount implements RestModifyView<AccountResource, AccountInput> {
+  @Override
+  public Response<AccountInfo> apply(AccountResource resource, AccountInput input)
+      throws ResourceConflictException {
+    throw new ResourceConflictException("account exists");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/PutActive.java b/java/com/google/gerrit/server/restapi/account/PutActive.java
new file mode 100644
index 0000000..147b20f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/PutActive.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+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.AccountResource;
+import com.google.gerrit.server.account.SetInactiveFlag;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
+@Singleton
+public class PutActive implements RestModifyView<AccountResource, Input> {
+
+  private final SetInactiveFlag setInactiveFlag;
+
+  @Inject
+  PutActive(SetInactiveFlag setInactiveFlag) {
+    this.setInactiveFlag = setInactiveFlag;
+  }
+
+  @Override
+  public Response<String> apply(AccountResource rsrc, Input input)
+      throws ResourceNotFoundException, OrmException, IOException, ConfigInvalidException {
+    return setInactiveFlag.activate(rsrc.getUser().getAccountId());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/PutAgreement.java b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
new file mode 100644
index 0000000..ae84081
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.AgreementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.AgreementSignup;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.restapi.group.AddMembers;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class PutAgreement implements RestModifyView<AccountResource, AgreementInput> {
+  private final ProjectCache projectCache;
+  private final Provider<IdentifiedUser> self;
+  private final AgreementSignup agreementSignup;
+  private final AddMembers addMembers;
+  private final boolean agreementsEnabled;
+
+  @Inject
+  PutAgreement(
+      ProjectCache projectCache,
+      Provider<IdentifiedUser> self,
+      AgreementSignup agreementSignup,
+      AddMembers addMembers,
+      @GerritServerConfig Config config) {
+    this.projectCache = projectCache;
+    this.self = self;
+    this.agreementSignup = agreementSignup;
+    this.addMembers = addMembers;
+    this.agreementsEnabled = config.getBoolean("auth", "contributorAgreements", false);
+  }
+
+  @Override
+  public Response<String> apply(AccountResource resource, AgreementInput input)
+      throws IOException, OrmException, RestApiException, ConfigInvalidException {
+    if (!agreementsEnabled) {
+      throw new MethodNotAllowedException("contributor agreements disabled");
+    }
+
+    if (self.get() != resource.getUser()) {
+      throw new AuthException("not allowed to enter contributor agreement");
+    }
+
+    String agreementName = Strings.nullToEmpty(input.name);
+    ContributorAgreement ca =
+        projectCache.getAllProjects().getConfig().getContributorAgreement(agreementName);
+    if (ca == null) {
+      throw new UnprocessableEntityException("contributor agreement not found");
+    }
+
+    if (ca.getAutoVerify() == null) {
+      throw new BadRequestException("cannot enter a non-autoVerify agreement");
+    }
+
+    AccountGroup.UUID uuid = ca.getAutoVerify().getUUID();
+    if (uuid == null) {
+      throw new ResourceConflictException("autoverify group uuid not found");
+    }
+
+    Account account = self.get().getAccount();
+    try {
+      addMembers.addMembers(uuid, ImmutableSet.of(account.getId()));
+    } catch (NoSuchGroupException e) {
+      throw new ResourceConflictException("autoverify group not found");
+    }
+    agreementSignup.fire(account, agreementName);
+
+    return Response.ok(agreementName);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/PutEmail.java b/java/com/google/gerrit/server/restapi/account/PutEmail.java
new file mode 100644
index 0000000..6ee9003
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/PutEmail.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.api.accounts.EmailInput;
+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.server.account.AccountResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class PutEmail implements RestModifyView<AccountResource.Email, EmailInput> {
+  @Override
+  public Response<?> apply(AccountResource.Email rsrc, EmailInput input)
+      throws ResourceConflictException {
+    throw new ResourceConflictException("email exists");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
new file mode 100644
index 0000000..25b3f76
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+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;
+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.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public class PutHttpPassword implements RestModifyView<AccountResource, HttpPasswordInput> {
+  private static final int LEN = 31;
+  private static final SecureRandom rng;
+
+  static {
+    try {
+      rng = SecureRandom.getInstance("SHA1PRNG");
+    } catch (NoSuchAlgorithmException e) {
+      throw new RuntimeException("Cannot create RNG for password generator", e);
+    }
+  }
+
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final ExternalIds externalIds;
+  private final AccountsUpdate.User accountsUpdate;
+
+  @Inject
+  PutHttpPassword(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      ExternalIds externalIds,
+      AccountsUpdate.User accountsUpdate) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.externalIds = externalIds;
+    this.accountsUpdate = accountsUpdate;
+  }
+
+  @Override
+  public Response<String> apply(AccountResource rsrc, HttpPasswordInput input)
+      throws AuthException, ResourceNotFoundException, ResourceConflictException, OrmException,
+          IOException, ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+
+    if (input == null) {
+      input = new HttpPasswordInput();
+    }
+    input.httpPassword = Strings.emptyToNull(input.httpPassword);
+
+    String newPassword;
+    if (input.generate) {
+      newPassword = generate();
+    } else if (input.httpPassword == null) {
+      newPassword = null;
+    } else {
+      // Only administrators can explicitly set the password.
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+      newPassword = input.httpPassword;
+    }
+    return apply(rsrc.getUser(), newPassword);
+  }
+
+  public Response<String> apply(IdentifiedUser user, String newPassword)
+      throws ResourceNotFoundException, ResourceConflictException, OrmException, IOException,
+          ConfigInvalidException {
+    if (user.getUserName() == null) {
+      throw new ResourceConflictException("username must be set");
+    }
+
+    ExternalId extId = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, user.getUserName()));
+    if (extId == null) {
+      throw new ResourceNotFoundException();
+    }
+    accountsUpdate
+        .create()
+        .update(
+            "Set HTTP Password via API",
+            extId.accountId(),
+            u ->
+                u.updateExternalId(
+                    ExternalId.createWithPassword(
+                        extId.key(), extId.accountId(), extId.email(), newPassword)));
+
+    return Strings.isNullOrEmpty(newPassword) ? Response.<String>none() : Response.ok(newPassword);
+  }
+
+  public static String generate() {
+    byte[] rand = new byte[LEN];
+    rng.nextBytes(rand);
+
+    byte[] enc = Base64.encodeBase64(rand, false);
+    StringBuilder r = new StringBuilder(enc.length);
+    for (int i = 0; i < enc.length; i++) {
+      if (enc[i] == '=') {
+        break;
+      }
+      r.append((char) enc[i]);
+    }
+    return r.toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/PutName.java b/java/com/google/gerrit/server/restapi/account/PutName.java
new file mode 100644
index 0000000..4981e7a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/PutName.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+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.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.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PutName implements RestModifyView<AccountResource, NameInput> {
+  private final Provider<CurrentUser> self;
+  private final Realm realm;
+  private final PermissionBackend permissionBackend;
+  private final AccountsUpdate.Server accountsUpdate;
+
+  @Inject
+  PutName(
+      Provider<CurrentUser> self,
+      Realm realm,
+      PermissionBackend permissionBackend,
+      AccountsUpdate.Server accountsUpdate) {
+    this.self = self;
+    this.realm = realm;
+    this.permissionBackend = permissionBackend;
+    this.accountsUpdate = accountsUpdate;
+  }
+
+  @Override
+  public Response<String> apply(AccountResource rsrc, NameInput input)
+      throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
+          IOException, PermissionBackendException, ConfigInvalidException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+    }
+    return apply(rsrc.getUser(), input);
+  }
+
+  public Response<String> apply(IdentifiedUser user, NameInput input)
+      throws MethodNotAllowedException, ResourceNotFoundException, IOException,
+          ConfigInvalidException, OrmException {
+    if (input == null) {
+      input = new NameInput();
+    }
+
+    if (!realm.allowsEdit(AccountFieldName.FULL_NAME)) {
+      throw new MethodNotAllowedException("realm does not allow editing name");
+    }
+
+    String newName = input.name;
+    Account account =
+        accountsUpdate
+            .create()
+            .update("Set Full Name via API", user.getAccountId(), u -> u.setFullName(newName));
+    if (account == null) {
+      throw new ResourceNotFoundException("account not found");
+    }
+    return Strings.isNullOrEmpty(account.getFullName())
+        ? Response.none()
+        : Response.ok(account.getFullName());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/PutPreferred.java b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
new file mode 100644
index 0000000..b66a611
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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;
+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.AccountResource;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PutPreferred implements RestModifyView<AccountResource.Email, Input> {
+
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final AccountsUpdate.Server accountsUpdate;
+
+  @Inject
+  PutPreferred(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      AccountsUpdate.Server accountsUpdate) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.accountsUpdate = accountsUpdate;
+  }
+
+  @Override
+  public Response<String> apply(AccountResource.Email rsrc, Input input)
+      throws AuthException, ResourceNotFoundException, OrmException, IOException,
+          PermissionBackendException, ConfigInvalidException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+    }
+    return apply(rsrc.getUser(), rsrc.getEmail());
+  }
+
+  public Response<String> apply(IdentifiedUser user, String email)
+      throws ResourceNotFoundException, IOException, ConfigInvalidException, OrmException {
+    AtomicBoolean alreadyPreferred = new AtomicBoolean(false);
+    Account account =
+        accountsUpdate
+            .create()
+            .update(
+                "Set Preferred Email via API",
+                user.getAccountId(),
+                (a, u) -> {
+                  if (email.equals(a.getPreferredEmail())) {
+                    alreadyPreferred.set(true);
+                  } else {
+                    u.setPreferredEmail(email);
+                  }
+                });
+    if (account == null) {
+      throw new ResourceNotFoundException("account not found");
+    }
+    return alreadyPreferred.get() ? Response.ok("") : Response.created("");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/PutStatus.java b/java/com/google/gerrit/server/restapi/account/PutStatus.java
new file mode 100644
index 0000000..23958a2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/PutStatus.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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.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.AccountResource;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PutStatus implements RestModifyView<AccountResource, StatusInput> {
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final AccountsUpdate.Server accountsUpdate;
+
+  @Inject
+  PutStatus(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      AccountsUpdate.Server accountsUpdate) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.accountsUpdate = accountsUpdate;
+  }
+
+  @Override
+  public Response<String> apply(AccountResource rsrc, StatusInput input)
+      throws AuthException, ResourceNotFoundException, OrmException, IOException,
+          PermissionBackendException, ConfigInvalidException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+    }
+    return apply(rsrc.getUser(), input);
+  }
+
+  public Response<String> apply(IdentifiedUser user, StatusInput input)
+      throws ResourceNotFoundException, IOException, ConfigInvalidException, OrmException {
+    if (input == null) {
+      input = new StatusInput();
+    }
+
+    String newStatus = input.status;
+    Account account =
+        accountsUpdate
+            .create()
+            .update("Set Status via API", user.getAccountId(), u -> u.setStatus(newStatus));
+    if (account == null) {
+      throw new ResourceNotFoundException("account not found");
+    }
+    return Strings.isNullOrEmpty(account.getStatus())
+        ? Response.none()
+        : Response.ok(account.getStatus());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/PutUsername.java b/java/com/google/gerrit/server/restapi/account/PutUsername.java
new file mode 100644
index 0000000..fc40152
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/PutUsername.java
@@ -0,0 +1,123 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.base.Strings;
+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.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.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PutUsername implements RestModifyView<AccountResource, UsernameInput> {
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final ExternalIds externalIds;
+  private final AccountsUpdate.Server accountsUpdate;
+  private final SshKeyCache sshKeyCache;
+  private final Realm realm;
+
+  @Inject
+  PutUsername(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      ExternalIds externalIds,
+      AccountsUpdate.Server accountsUpdate,
+      SshKeyCache sshKeyCache,
+      Realm realm) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.externalIds = externalIds;
+    this.accountsUpdate = accountsUpdate;
+    this.sshKeyCache = sshKeyCache;
+    this.realm = realm;
+  }
+
+  @Override
+  public String apply(AccountResource rsrc, UsernameInput input)
+      throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
+          ResourceConflictException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+
+    if (!realm.allowsEdit(AccountFieldName.USER_NAME)) {
+      throw new MethodNotAllowedException("realm does not allow editing username");
+    }
+
+    if (input == null) {
+      input = new UsernameInput();
+    }
+
+    Account.Id accountId = rsrc.getUser().getAccountId();
+    if (!externalIds.byAccount(accountId, SCHEME_USERNAME).isEmpty()) {
+      throw new MethodNotAllowedException("Username cannot be changed.");
+    }
+
+    if (Strings.isNullOrEmpty(input.username)) {
+      return input.username;
+    }
+
+    if (!ExternalId.isValidUsername(input.username)) {
+      throw new UnprocessableEntityException("invalid username");
+    }
+
+    ExternalId.Key key = ExternalId.Key.create(SCHEME_USERNAME, input.username);
+    try {
+      accountsUpdate
+          .create()
+          .update(
+              "Set Username via API",
+              accountId,
+              u -> u.addExternalId(ExternalId.create(key, accountId, null, null)));
+    } catch (OrmDuplicateKeyException dupeErr) {
+      // If we are using this identity, don't report the exception.
+      ExternalId other = externalIds.get(key);
+      if (other != null && other.accountId().equals(accountId)) {
+        return input.username;
+      }
+
+      // Otherwise, someone else has this identity.
+      throw new ResourceConflictException("username already used");
+    }
+
+    sshKeyCache.evict(input.username);
+    return input.username;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
new file mode 100644
index 0000000..fa4550d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -0,0 +1,232 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.client.ListAccountsOption;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.AccountVisibility;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountDirectory.FillOptions;
+import com.google.gerrit.server.account.AccountInfoComparator;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.account.AccountPredicates;
+import com.google.gerrit.server.query.account.AccountQueryBuilder;
+import com.google.gerrit.server.query.account.AccountQueryProcessor;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
+
+public class QueryAccounts implements RestReadView<TopLevelResource> {
+  private static final int MAX_SUGGEST_RESULTS = 100;
+
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final AccountQueryBuilder queryBuilder;
+  private final AccountQueryProcessor queryProcessor;
+  private final boolean suggestConfig;
+  private final int suggestFrom;
+
+  private AccountLoader accountLoader;
+  private boolean suggest;
+  private int suggestLimit = 10;
+  private String query;
+  private Integer start;
+  private EnumSet<ListAccountsOption> options;
+
+  @Option(name = "--suggest", metaVar = "SUGGEST", usage = "suggest users")
+  public void setSuggest(boolean suggest) {
+    this.suggest = suggest;
+  }
+
+  @Option(
+    name = "--limit",
+    aliases = {"-n"},
+    metaVar = "CNT",
+    usage = "maximum number of users to return"
+  )
+  public void setLimit(int n) {
+    queryProcessor.setUserProvidedLimit(n);
+
+    if (n < 0) {
+      suggestLimit = 10;
+    } else if (n == 0) {
+      suggestLimit = MAX_SUGGEST_RESULTS;
+    } else {
+      suggestLimit = Math.min(n, MAX_SUGGEST_RESULTS);
+    }
+  }
+
+  @Option(name = "-o", usage = "Output options per account")
+  public void addOption(ListAccountsOption o) {
+    options.add(o);
+  }
+
+  @Option(name = "-O", usage = "Output option flags, in hex")
+  void setOptionFlagsHex(String hex) {
+    options.addAll(ListAccountsOption.fromBits(Integer.parseInt(hex, 16)));
+  }
+
+  @Option(
+    name = "--query",
+    aliases = {"-q"},
+    metaVar = "QUERY",
+    usage = "match users"
+  )
+  public void setQuery(String query) {
+    this.query = query;
+  }
+
+  @Option(
+    name = "--start",
+    aliases = {"-S"},
+    metaVar = "CNT",
+    usage = "Number of accounts to skip"
+  )
+  public void setStart(int start) {
+    this.start = start;
+  }
+
+  @Inject
+  QueryAccounts(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      AccountLoader.Factory accountLoaderFactory,
+      AccountQueryBuilder queryBuilder,
+      AccountQueryProcessor queryProcessor,
+      @GerritServerConfig Config cfg) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.accountLoaderFactory = accountLoaderFactory;
+    this.queryBuilder = queryBuilder;
+    this.queryProcessor = queryProcessor;
+    this.suggestFrom = cfg.getInt("suggest", null, "from", 0);
+    this.options = EnumSet.noneOf(ListAccountsOption.class);
+
+    if ("off".equalsIgnoreCase(cfg.getString("suggest", null, "accounts"))) {
+      suggestConfig = false;
+    } else {
+      boolean suggest;
+      try {
+        AccountVisibility av = cfg.getEnum("suggest", null, "accounts", AccountVisibility.ALL);
+        suggest = (av != AccountVisibility.NONE);
+      } catch (IllegalArgumentException err) {
+        suggest = cfg.getBoolean("suggest", null, "accounts", true);
+      }
+      this.suggestConfig = suggest;
+    }
+  }
+
+  @Override
+  public List<AccountInfo> apply(TopLevelResource rsrc)
+      throws OrmException, RestApiException, PermissionBackendException {
+    if (Strings.isNullOrEmpty(query)) {
+      throw new BadRequestException("missing query field");
+    }
+
+    if (suggest && (!suggestConfig || query.length() < suggestFrom)) {
+      return Collections.emptyList();
+    }
+
+    Set<FillOptions> fillOptions = EnumSet.of(FillOptions.ID);
+    if (options.contains(ListAccountsOption.DETAILS)) {
+      fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
+    }
+    boolean modifyAccountCapabilityChecked = false;
+    if (options.contains(ListAccountsOption.ALL_EMAILS)) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+      modifyAccountCapabilityChecked = true;
+      fillOptions.add(FillOptions.EMAIL);
+      fillOptions.add(FillOptions.SECONDARY_EMAILS);
+    }
+    if (suggest) {
+      fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
+      fillOptions.add(FillOptions.EMAIL);
+
+      if (modifyAccountCapabilityChecked
+          || permissionBackend.user(self).test(GlobalPermission.MODIFY_ACCOUNT)) {
+        fillOptions.add(FillOptions.SECONDARY_EMAILS);
+      }
+    }
+    accountLoader = accountLoaderFactory.create(fillOptions);
+
+    if (queryProcessor.isDisabled()) {
+      throw new MethodNotAllowedException("query disabled");
+    }
+
+    if (start != null) {
+      queryProcessor.setStart(start);
+    }
+
+    Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>();
+    try {
+      Predicate<AccountState> queryPred;
+      if (suggest) {
+        queryPred = queryBuilder.defaultQuery(query);
+        queryProcessor.setUserProvidedLimit(suggestLimit);
+      } else {
+        queryPred = queryBuilder.parse(query);
+      }
+      if (!AccountPredicates.hasActive(queryPred)) {
+        // if neither 'is:active' nor 'is:inactive' appears in the query only
+        // active accounts should be queried
+        queryPred = AccountPredicates.andActive(queryPred);
+      }
+      QueryResult<AccountState> result = queryProcessor.query(queryPred);
+      for (AccountState accountState : result.entities()) {
+        Account.Id id = accountState.getAccount().getId();
+        matches.put(id, accountLoader.get(id));
+      }
+
+      accountLoader.fill();
+
+      List<AccountInfo> sorted =
+          AccountInfoComparator.ORDER_NULLS_LAST.sortedCopy(matches.values());
+      if (!sorted.isEmpty() && result.more()) {
+        sorted.get(sorted.size() - 1)._moreAccounts = true;
+      }
+      return sorted;
+    } catch (QueryParseException e) {
+      if (suggest) {
+        return ImmutableList.of();
+      }
+      throw new BadRequestException(e.getMessage());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java b/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java
new file mode 100644
index 0000000..c16e0f2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.storeSection;
+import static com.google.gerrit.server.restapi.account.GetDiffPreferences.readDefaultsFromGit;
+import static com.google.gerrit.server.restapi.account.GetDiffPreferences.readFromGit;
+
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+
+@Singleton
+public class SetDiffPreferences implements RestModifyView<AccountResource, DiffPreferencesInfo> {
+  private final Provider<CurrentUser> self;
+  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final AllUsersName allUsersName;
+  private final PermissionBackend permissionBackend;
+  private final GitRepositoryManager gitMgr;
+
+  @Inject
+  SetDiffPreferences(
+      Provider<CurrentUser> self,
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      AllUsersName allUsersName,
+      PermissionBackend permissionBackend,
+      GitRepositoryManager gitMgr) {
+    this.self = self;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.allUsersName = allUsersName;
+    this.permissionBackend = permissionBackend;
+    this.gitMgr = gitMgr;
+  }
+
+  @Override
+  public DiffPreferencesInfo apply(AccountResource rsrc, DiffPreferencesInfo in)
+      throws AuthException, BadRequestException, ConfigInvalidException,
+          RepositoryNotFoundException, IOException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+    }
+
+    if (in == null) {
+      throw new BadRequestException("input must be provided");
+    }
+
+    Account.Id id = rsrc.getUser().getAccountId();
+    return writeToGit(readFromGit(id, gitMgr, allUsersName, in), id);
+  }
+
+  private DiffPreferencesInfo writeToGit(DiffPreferencesInfo in, Account.Id userId)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    DiffPreferencesInfo out = new DiffPreferencesInfo();
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
+      DiffPreferencesInfo allUserPrefs = readDefaultsFromGit(md.getRepository(), null);
+      VersionedAccountPreferences prefs = VersionedAccountPreferences.forUser(userId);
+      prefs.load(md);
+      storeSection(prefs.getConfig(), UserConfigSections.DIFF, null, in, allUserPrefs);
+      prefs.commit(md);
+      loadSection(prefs.getConfig(), UserConfigSections.DIFF, null, out, allUserPrefs, null);
+    }
+    return out;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java b/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java
new file mode 100644
index 0000000..3574377
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.storeSection;
+import static com.google.gerrit.server.restapi.account.GetEditPreferences.readFromGit;
+
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+
+@Singleton
+public class SetEditPreferences implements RestModifyView<AccountResource, EditPreferencesInfo> {
+
+  private final Provider<CurrentUser> self;
+  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final PermissionBackend permissionBackend;
+  private final GitRepositoryManager gitMgr;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  SetEditPreferences(
+      Provider<CurrentUser> self,
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      PermissionBackend permissionBackend,
+      GitRepositoryManager gitMgr,
+      AllUsersName allUsersName) {
+    this.self = self;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.permissionBackend = permissionBackend;
+    this.gitMgr = gitMgr;
+    this.allUsersName = allUsersName;
+  }
+
+  @Override
+  public EditPreferencesInfo apply(AccountResource rsrc, EditPreferencesInfo in)
+      throws AuthException, BadRequestException, RepositoryNotFoundException, IOException,
+          ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+    }
+
+    if (in == null) {
+      throw new BadRequestException("input must be provided");
+    }
+
+    Account.Id accountId = rsrc.getUser().getAccountId();
+
+    VersionedAccountPreferences prefs;
+    EditPreferencesInfo out = new EditPreferencesInfo();
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
+      prefs = VersionedAccountPreferences.forUser(accountId);
+      prefs.load(md);
+      storeSection(
+          prefs.getConfig(),
+          UserConfigSections.EDIT,
+          null,
+          readFromGit(accountId, gitMgr, allUsersName, in),
+          EditPreferencesInfo.defaults());
+      prefs.commit(md);
+      out =
+          loadSection(
+              prefs.getConfig(),
+              UserConfigSections.EDIT,
+              null,
+              out,
+              EditPreferencesInfo.defaults(),
+              null);
+    }
+
+    return out;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/SetPreferences.java b/java/com/google/gerrit/server/restapi/account/SetPreferences.java
new file mode 100644
index 0000000..8584240
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/SetPreferences.java
@@ -0,0 +1,172 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_MATCH;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_TOKEN;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
+import static com.google.gerrit.server.git.UserConfigSections.URL_ALIAS;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.PreferencesConfig;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class SetPreferences implements RestModifyView<AccountResource, GeneralPreferencesInfo> {
+  private final Provider<CurrentUser> self;
+  private final AccountCache cache;
+  private final PermissionBackend permissionBackend;
+  private final AccountsUpdate.User accountsUpdate;
+  private final DynamicMap<DownloadScheme> downloadSchemes;
+
+  @Inject
+  SetPreferences(
+      Provider<CurrentUser> self,
+      AccountCache cache,
+      PermissionBackend permissionBackend,
+      AccountsUpdate.User accountsUpdate,
+      DynamicMap<DownloadScheme> downloadSchemes) {
+    this.self = self;
+    this.cache = cache;
+    this.permissionBackend = permissionBackend;
+    this.accountsUpdate = accountsUpdate;
+    this.downloadSchemes = downloadSchemes;
+  }
+
+  @Override
+  public GeneralPreferencesInfo apply(AccountResource rsrc, GeneralPreferencesInfo input)
+      throws AuthException, BadRequestException, IOException, ConfigInvalidException,
+          PermissionBackendException, OrmException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+    }
+
+    checkDownloadScheme(input.downloadScheme);
+    PreferencesConfig.validateMy(input.my);
+    Account.Id id = rsrc.getUser().getAccountId();
+
+    accountsUpdate
+        .create()
+        .update("Set Preferences via API", id, u -> u.setGeneralPreferences(input));
+    return cache.get(id).getGeneralPreferences();
+  }
+
+  public static void storeMyMenus(VersionedAccountPreferences prefs, List<MenuItem> my)
+      throws BadRequestException {
+    Config cfg = prefs.getConfig();
+    if (my != null) {
+      unsetSection(cfg, UserConfigSections.MY);
+      for (MenuItem item : my) {
+        checkRequiredMenuItemField(item.name, "name");
+        checkRequiredMenuItemField(item.url, "URL");
+
+        set(cfg, item.name, KEY_URL, item.url);
+        set(cfg, item.name, KEY_TARGET, item.target);
+        set(cfg, item.name, KEY_ID, item.id);
+      }
+    }
+  }
+
+  public static void storeMyChangeTableColumns(
+      VersionedAccountPreferences prefs, List<String> changeTable) {
+    Config cfg = prefs.getConfig();
+    if (changeTable != null) {
+      unsetSection(cfg, UserConfigSections.CHANGE_TABLE);
+      cfg.setStringList(UserConfigSections.CHANGE_TABLE, null, CHANGE_TABLE_COLUMN, changeTable);
+    }
+  }
+
+  private static void set(Config cfg, String section, String key, @Nullable String val) {
+    if (val == null || val.trim().isEmpty()) {
+      cfg.unset(UserConfigSections.MY, section.trim(), key);
+    } else {
+      cfg.setString(UserConfigSections.MY, section.trim(), key, val.trim());
+    }
+  }
+
+  private static void unsetSection(Config cfg, String section) {
+    cfg.unsetSection(section, null);
+    for (String subsection : cfg.getSubsections(section)) {
+      cfg.unsetSection(section, subsection);
+    }
+  }
+
+  public static void storeUrlAliases(
+      VersionedAccountPreferences prefs, Map<String, String> urlAliases) {
+    if (urlAliases != null) {
+      Config cfg = prefs.getConfig();
+      for (String subsection : cfg.getSubsections(URL_ALIAS)) {
+        cfg.unsetSection(URL_ALIAS, subsection);
+      }
+
+      int i = 1;
+      for (Entry<String, String> e : urlAliases.entrySet()) {
+        cfg.setString(URL_ALIAS, URL_ALIAS + i, KEY_MATCH, e.getKey());
+        cfg.setString(URL_ALIAS, URL_ALIAS + i, KEY_TOKEN, e.getValue());
+        i++;
+      }
+    }
+  }
+
+  private static void checkRequiredMenuItemField(String value, String name)
+      throws BadRequestException {
+    if (value == null || value.trim().isEmpty()) {
+      throw new BadRequestException(name + " for menu item is required");
+    }
+  }
+
+  private void checkDownloadScheme(String downloadScheme) throws BadRequestException {
+    if (Strings.isNullOrEmpty(downloadScheme)) {
+      return;
+    }
+
+    for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
+      if (e.getExportName().equals(downloadScheme) && e.getProvider().get().isEnabled()) {
+        return;
+      }
+    }
+    throw new BadRequestException("Unsupported download scheme: " + downloadScheme);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/SshKeys.java b/java/com/google/gerrit/server/restapi/account/SshKeys.java
new file mode 100644
index 0000000..20fd5cc
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/SshKeys.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.AccountSshKey;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class SshKeys implements ChildCollection<AccountResource, AccountResource.SshKey> {
+  private final DynamicMap<RestView<AccountResource.SshKey>> views;
+  private final GetSshKeys list;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
+
+  @Inject
+  SshKeys(
+      DynamicMap<RestView<AccountResource.SshKey>> views,
+      GetSshKeys list,
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      VersionedAuthorizedKeys.Accessor authorizedKeys) {
+    this.views = views;
+    this.list = list;
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.authorizedKeys = authorizedKeys;
+  }
+
+  @Override
+  public RestView<AccountResource> list() {
+    return list;
+  }
+
+  @Override
+  public AccountResource.SshKey parse(AccountResource rsrc, IdString id)
+      throws ResourceNotFoundException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      try {
+        permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+      } catch (AuthException e) {
+        // If lacking MODIFY_ACCOUNT claim the resource does not exist.
+        throw new ResourceNotFoundException();
+      }
+    }
+    return parse(rsrc.getUser(), id);
+  }
+
+  public AccountResource.SshKey parse(IdentifiedUser user, IdString id)
+      throws ResourceNotFoundException, IOException, ConfigInvalidException {
+    try {
+      int seq = Integer.parseInt(id.get(), 10);
+      AccountSshKey sshKey = authorizedKeys.getKey(user.getAccountId(), seq);
+      if (sshKey == null) {
+        throw new ResourceNotFoundException(id);
+      }
+      return new AccountResource.SshKey(user, sshKey);
+    } catch (NumberFormatException e) {
+      throw new ResourceNotFoundException(id);
+    }
+  }
+
+  @Override
+  public DynamicMap<RestView<AccountResource.SshKey>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
new file mode 100644
index 0000000..f908d9f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -0,0 +1,206 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+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.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.StarredChangesUtil.MutuallyExclusiveLabelsException;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.change.ChangesCollection;
+import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class StarredChanges
+    implements ChildCollection<AccountResource, AccountResource.StarredChange>,
+        AcceptsCreate<AccountResource> {
+  private static final Logger log = LoggerFactory.getLogger(StarredChanges.class);
+
+  private final ChangesCollection changes;
+  private final DynamicMap<RestView<AccountResource.StarredChange>> views;
+  private final Provider<Create> createProvider;
+  private final StarredChangesUtil starredChangesUtil;
+
+  @Inject
+  StarredChanges(
+      ChangesCollection changes,
+      DynamicMap<RestView<AccountResource.StarredChange>> views,
+      Provider<Create> createProvider,
+      StarredChangesUtil starredChangesUtil) {
+    this.changes = changes;
+    this.views = views;
+    this.createProvider = createProvider;
+    this.starredChangesUtil = starredChangesUtil;
+  }
+
+  @Override
+  public AccountResource.StarredChange parse(AccountResource parent, IdString id)
+      throws RestApiException, OrmException, PermissionBackendException {
+    IdentifiedUser user = parent.getUser();
+    ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
+    if (starredChangesUtil
+        .getLabels(user.getAccountId(), change.getId())
+        .contains(StarredChangesUtil.DEFAULT_LABEL)) {
+      return new AccountResource.StarredChange(user, change);
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  @Override
+  public DynamicMap<RestView<AccountResource.StarredChange>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<AccountResource> list() throws ResourceNotFoundException {
+    return new RestReadView<AccountResource>() {
+      @Override
+      public Object apply(AccountResource self)
+          throws BadRequestException, AuthException, OrmException {
+        QueryChanges query = changes.list();
+        query.addQuery("starredby:" + self.getUser().getAccountId().get());
+        return query.apply(TopLevelResource.INSTANCE);
+      }
+    };
+  }
+
+  @Override
+  public RestModifyView<AccountResource, EmptyInput> create(AccountResource parent, IdString id)
+      throws RestApiException {
+    try {
+      return createProvider.get().setChange(changes.parse(TopLevelResource.INSTANCE, id));
+    } catch (ResourceNotFoundException e) {
+      throw new UnprocessableEntityException(String.format("change %s not found", id.get()));
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("cannot resolve change", e);
+      throw new UnprocessableEntityException("internal server error");
+    }
+  }
+
+  @Singleton
+  public static class Create implements RestModifyView<AccountResource, EmptyInput> {
+    private final Provider<CurrentUser> self;
+    private final StarredChangesUtil starredChangesUtil;
+    private ChangeResource change;
+
+    @Inject
+    Create(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
+      this.self = self;
+      this.starredChangesUtil = starredChangesUtil;
+    }
+
+    public Create setChange(ChangeResource change) {
+      this.change = change;
+      return this;
+    }
+
+    @Override
+    public Response<?> apply(AccountResource rsrc, EmptyInput in)
+        throws RestApiException, OrmException, IOException {
+      if (self.get() != rsrc.getUser()) {
+        throw new AuthException("not allowed to add starred change");
+      }
+      try {
+        starredChangesUtil.star(
+            self.get().getAccountId(),
+            change.getProject(),
+            change.getId(),
+            StarredChangesUtil.DEFAULT_LABELS,
+            null);
+      } catch (MutuallyExclusiveLabelsException e) {
+        throw new ResourceConflictException(e.getMessage());
+      } catch (IllegalLabelException e) {
+        throw new BadRequestException(e.getMessage());
+      } catch (OrmDuplicateKeyException e) {
+        return Response.none();
+      }
+      return Response.none();
+    }
+  }
+
+  @Singleton
+  static class Put implements RestModifyView<AccountResource.StarredChange, EmptyInput> {
+    private final Provider<CurrentUser> self;
+
+    @Inject
+    Put(Provider<CurrentUser> self) {
+      this.self = self;
+    }
+
+    @Override
+    public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
+        throws AuthException {
+      if (self.get() != rsrc.getUser()) {
+        throw new AuthException("not allowed update starred changes");
+      }
+      return Response.none();
+    }
+  }
+
+  @Singleton
+  public static class Delete implements RestModifyView<AccountResource.StarredChange, EmptyInput> {
+    private final Provider<CurrentUser> self;
+    private final StarredChangesUtil starredChangesUtil;
+
+    @Inject
+    Delete(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
+      this.self = self;
+      this.starredChangesUtil = starredChangesUtil;
+    }
+
+    @Override
+    public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
+        throws AuthException, OrmException, IOException, IllegalLabelException {
+      if (self.get() != rsrc.getUser()) {
+        throw new AuthException("not allowed remove starred change");
+      }
+      starredChangesUtil.star(
+          self.get().getAccountId(),
+          rsrc.getChange().getProject(),
+          rsrc.getChange().getId(),
+          null,
+          StarredChangesUtil.DEFAULT_LABELS);
+      return Response.none();
+    }
+  }
+
+  public static class EmptyInput {}
+}
diff --git a/java/com/google/gerrit/server/restapi/account/Stars.java b/java/com/google/gerrit/server/restapi/account/Stars.java
new file mode 100644
index 0000000..2ee6aef
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/Stars.java
@@ -0,0 +1,160 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.api.changes.StarsInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountResource.Star;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.change.ChangesCollection;
+import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.SortedSet;
+
+@Singleton
+public class Stars implements ChildCollection<AccountResource, AccountResource.Star> {
+
+  private final ChangesCollection changes;
+  private final ListStarredChanges listStarredChanges;
+  private final StarredChangesUtil starredChangesUtil;
+  private final DynamicMap<RestView<AccountResource.Star>> views;
+
+  @Inject
+  Stars(
+      ChangesCollection changes,
+      ListStarredChanges listStarredChanges,
+      StarredChangesUtil starredChangesUtil,
+      DynamicMap<RestView<AccountResource.Star>> views) {
+    this.changes = changes;
+    this.listStarredChanges = listStarredChanges;
+    this.starredChangesUtil = starredChangesUtil;
+    this.views = views;
+  }
+
+  @Override
+  public Star parse(AccountResource parent, IdString id)
+      throws RestApiException, OrmException, PermissionBackendException {
+    IdentifiedUser user = parent.getUser();
+    ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
+    Set<String> labels = starredChangesUtil.getLabels(user.getAccountId(), change.getId());
+    return new AccountResource.Star(user, change, labels);
+  }
+
+  @Override
+  public DynamicMap<RestView<Star>> views() {
+    return views;
+  }
+
+  @Override
+  public ListStarredChanges list() {
+    return listStarredChanges;
+  }
+
+  @Singleton
+  public static class ListStarredChanges implements RestReadView<AccountResource> {
+    private final Provider<CurrentUser> self;
+    private final ChangesCollection changes;
+
+    @Inject
+    ListStarredChanges(Provider<CurrentUser> self, ChangesCollection changes) {
+      this.self = self;
+      this.changes = changes;
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public List<ChangeInfo> apply(AccountResource rsrc)
+        throws BadRequestException, AuthException, OrmException {
+      if (self.get() != rsrc.getUser()) {
+        throw new AuthException("not allowed to list stars of another account");
+      }
+      QueryChanges query = changes.list();
+      query.addQuery("has:stars");
+      return (List<ChangeInfo>) query.apply(TopLevelResource.INSTANCE);
+    }
+  }
+
+  @Singleton
+  public static class Get implements RestReadView<AccountResource.Star> {
+    private final Provider<CurrentUser> self;
+    private final StarredChangesUtil starredChangesUtil;
+
+    @Inject
+    Get(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
+      this.self = self;
+      this.starredChangesUtil = starredChangesUtil;
+    }
+
+    @Override
+    public SortedSet<String> apply(AccountResource.Star rsrc) throws AuthException, OrmException {
+      if (self.get() != rsrc.getUser()) {
+        throw new AuthException("not allowed to get stars of another account");
+      }
+      return starredChangesUtil.getLabels(self.get().getAccountId(), rsrc.getChange().getId());
+    }
+  }
+
+  @Singleton
+  public static class Post implements RestModifyView<AccountResource.Star, StarsInput> {
+    private final Provider<CurrentUser> self;
+    private final StarredChangesUtil starredChangesUtil;
+
+    @Inject
+    Post(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
+      this.self = self;
+      this.starredChangesUtil = starredChangesUtil;
+    }
+
+    @Override
+    public Collection<String> apply(AccountResource.Star rsrc, StarsInput in)
+        throws AuthException, BadRequestException, OrmException {
+      if (self.get() != rsrc.getUser()) {
+        throw new AuthException("not allowed to update stars of another account");
+      }
+      try {
+        return starredChangesUtil.star(
+            self.get().getAccountId(),
+            rsrc.getChange().getProject(),
+            rsrc.getChange().getId(),
+            in.add,
+            in.remove);
+      } catch (IllegalLabelException e) {
+        throw new BadRequestException(e.getMessage());
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
new file mode 100644
index 0000000..5e2b166
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.AbandonInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.AbandonOp;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class Abandon extends RetryingRestModifyView<ChangeResource, AbandonInput, ChangeInfo>
+    implements UiAction<ChangeResource> {
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeJson.Factory json;
+  private final AbandonOp.Factory abandonOpFactory;
+  private final NotifyUtil notifyUtil;
+
+  @Inject
+  Abandon(
+      Provider<ReviewDb> dbProvider,
+      ChangeJson.Factory json,
+      RetryHelper retryHelper,
+      AbandonOp.Factory abandonOpFactory,
+      NotifyUtil notifyUtil) {
+    super(retryHelper);
+    this.dbProvider = dbProvider;
+    this.json = json;
+    this.abandonOpFactory = abandonOpFactory;
+    this.notifyUtil = notifyUtil;
+  }
+
+  @Override
+  protected ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource req, AbandonInput input)
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException,
+          IOException, ConfigInvalidException {
+    req.permissions().database(dbProvider).check(ChangePermission.ABANDON);
+
+    NotifyHandling notify = input.notify == null ? defaultNotify(req.getChange()) : input.notify;
+    Change change =
+        abandon(
+            updateFactory,
+            req.getNotes(),
+            req.getUser(),
+            input.message,
+            notify,
+            notifyUtil.resolveAccounts(input.notifyDetails));
+    return json.noOptions().format(change);
+  }
+
+  private NotifyHandling defaultNotify(Change change) {
+    return change.hasReviewStarted() ? NotifyHandling.ALL : NotifyHandling.OWNER;
+  }
+
+  public Change abandon(BatchUpdate.Factory updateFactory, ChangeNotes notes, CurrentUser user)
+      throws RestApiException, UpdateException {
+    return abandon(
+        updateFactory,
+        notes,
+        user,
+        "",
+        defaultNotify(notes.getChange()),
+        ImmutableListMultimap.of());
+  }
+
+  public Change abandon(
+      BatchUpdate.Factory updateFactory, ChangeNotes notes, CurrentUser user, String msgTxt)
+      throws RestApiException, UpdateException {
+    return abandon(
+        updateFactory,
+        notes,
+        user,
+        msgTxt,
+        defaultNotify(notes.getChange()),
+        ImmutableListMultimap.of());
+  }
+
+  public Change abandon(
+      BatchUpdate.Factory updateFactory,
+      ChangeNotes notes,
+      CurrentUser user,
+      String msgTxt,
+      NotifyHandling notifyHandling,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify)
+      throws RestApiException, UpdateException {
+    Account account = user.isIdentifiedUser() ? user.asIdentifiedUser().getAccount() : null;
+    AbandonOp op = abandonOpFactory.create(account, msgTxt, notifyHandling, accountsToNotify);
+    try (BatchUpdate u =
+        updateFactory.create(dbProvider.get(), notes.getProjectName(), user, TimeUtil.nowTs())) {
+      u.addOp(notes.getChangeId(), op).execute();
+    }
+    return op.getChange();
+  }
+
+  @Override
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
+    return new UiAction.Description()
+        .setLabel("Abandon")
+        .setTitle("Abandon the change")
+        .setVisible(
+            and(
+                change.getStatus().isOpen(),
+                rsrc.permissions().database(dbProvider).testCond(ChangePermission.ABANDON)));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/AllowedFormats.java b/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
new file mode 100644
index 0000000..2e313a1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.gerrit.server.change.ArchiveFormat;
+import com.google.gerrit.server.config.DownloadConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+@Singleton
+public class AllowedFormats {
+  final ImmutableMap<String, ArchiveFormat> extensions;
+  final ImmutableSet<ArchiveFormat> allowed;
+
+  @Inject
+  AllowedFormats(DownloadConfig cfg) {
+    Map<String, ArchiveFormat> exts = new HashMap<>();
+    for (ArchiveFormat format : cfg.getArchiveFormats()) {
+      for (String ext : format.getSuffixes()) {
+        exts.put(ext, format);
+      }
+      exts.put(format.name().toLowerCase(), format);
+    }
+    extensions = ImmutableMap.copyOf(exts);
+
+    // Zip is not supported because it may be interpreted by a Java plugin as a
+    // valid JAR file, whose code would have access to cookies on the domain.
+    allowed =
+        Sets.immutableEnumSet(
+            Iterables.filter(cfg.getArchiveFormats(), f -> f != ArchiveFormat.ZIP));
+  }
+
+  public Set<ArchiveFormat> getAllowed() {
+    return allowed;
+  }
+
+  public ImmutableMap<String, ArchiveFormat> getExtensions() {
+    return extensions;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
new file mode 100644
index 0000000..e4940ec
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.FixResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditJson;
+import com.google.gerrit.server.edit.ChangeEditModifier;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.fixes.FixReplacementInterpreter;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class ApplyFix implements RestModifyView<FixResource, Void> {
+
+  private final GitRepositoryManager gitRepositoryManager;
+  private final FixReplacementInterpreter fixReplacementInterpreter;
+  private final ChangeEditModifier changeEditModifier;
+  private final ChangeEditJson changeEditJson;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public ApplyFix(
+      GitRepositoryManager gitRepositoryManager,
+      FixReplacementInterpreter fixReplacementInterpreter,
+      ChangeEditModifier changeEditModifier,
+      ChangeEditJson changeEditJson,
+      ProjectCache projectCache) {
+    this.gitRepositoryManager = gitRepositoryManager;
+    this.fixReplacementInterpreter = fixReplacementInterpreter;
+    this.changeEditModifier = changeEditModifier;
+    this.changeEditJson = changeEditJson;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<EditInfo> apply(FixResource fixResource, Void nothing)
+      throws AuthException, OrmException, ResourceConflictException, IOException,
+          ResourceNotFoundException, PermissionBackendException {
+    RevisionResource revisionResource = fixResource.getRevisionResource();
+    Project.NameKey project = revisionResource.getProject();
+    ProjectState projectState = projectCache.checkedGet(project);
+    PatchSet patchSet = revisionResource.getPatchSet();
+    ObjectId patchSetCommitId = ObjectId.fromString(patchSet.getRevision().get());
+
+    try (Repository repository = gitRepositoryManager.openRepository(project)) {
+      List<TreeModification> treeModifications =
+          fixReplacementInterpreter.toTreeModifications(
+              repository, projectState, patchSetCommitId, fixResource.getFixReplacements());
+      ChangeEdit changeEdit =
+          changeEditModifier.combineWithModifiedPatchSetTree(
+              repository, revisionResource.getNotes(), patchSet, treeModifications);
+      return Response.ok(changeEditJson.toEditInfo(changeEdit, false));
+    } catch (InvalidChangeOperationException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
new file mode 100644
index 0000000..334b7aa
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -0,0 +1,526 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+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;
+import com.google.gerrit.extensions.restapi.AcceptsPost;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RawInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.WebLinks;
+import com.google.gerrit.server.change.ChangeEditResource;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.change.FileInfoJson;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.change.Revisions;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditJson;
+import com.google.gerrit.server.edit.ChangeEditModifier;
+import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.edit.UnchangedCommitMessageException;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+@Singleton
+public class ChangeEdits
+    implements ChildCollection<ChangeResource, ChangeEditResource>,
+        AcceptsCreate<ChangeResource>,
+        AcceptsPost<ChangeResource>,
+        AcceptsDelete<ChangeResource> {
+  private final DynamicMap<RestView<ChangeEditResource>> views;
+  private final Create.Factory createFactory;
+  private final DeleteFile.Factory deleteFileFactory;
+  private final Provider<Detail> detail;
+  private final ChangeEditUtil editUtil;
+  private final Post post;
+
+  @Inject
+  ChangeEdits(
+      DynamicMap<RestView<ChangeEditResource>> views,
+      Create.Factory createFactory,
+      Provider<Detail> detail,
+      ChangeEditUtil editUtil,
+      Post post,
+      DeleteFile.Factory deleteFileFactory) {
+    this.views = views;
+    this.createFactory = createFactory;
+    this.detail = detail;
+    this.editUtil = editUtil;
+    this.post = post;
+    this.deleteFileFactory = deleteFileFactory;
+  }
+
+  @Override
+  public DynamicMap<RestView<ChangeEditResource>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<ChangeResource> list() {
+    return detail.get();
+  }
+
+  @Override
+  public ChangeEditResource parse(ChangeResource rsrc, IdString id)
+      throws ResourceNotFoundException, AuthException, IOException, OrmException {
+    Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
+    if (!edit.isPresent()) {
+      throw new ResourceNotFoundException(id);
+    }
+    return new ChangeEditResource(rsrc, edit.get(), id.get());
+  }
+
+  @Override
+  public Create create(ChangeResource parent, IdString id) throws RestApiException {
+    return createFactory.create(id.get());
+  }
+
+  @Override
+  public Post post(ChangeResource parent) throws RestApiException {
+    return post;
+  }
+
+  /**
+   * Create handler that is activated when collection element is accessed but doesn't exist, e. g.
+   * PUT request with a path was called but change edit wasn't created yet. Change edit is created
+   * and PUT handler is called.
+   */
+  @Override
+  public DeleteFile delete(ChangeResource parent, IdString id) throws RestApiException {
+    // It's safe to assume that id can never be null, because
+    // otherwise we would end up in dedicated endpoint for
+    // deleting of change edits and not a file in change edit
+    return deleteFileFactory.create(id.get());
+  }
+
+  public static class Create implements RestModifyView<ChangeResource, Put.Input> {
+
+    interface Factory {
+      Create create(String path);
+    }
+
+    private final Put putEdit;
+    private final String path;
+
+    @Inject
+    Create(Put putEdit, @Assisted String path) {
+      this.putEdit = putEdit;
+      this.path = path;
+    }
+
+    @Override
+    public Response<?> apply(ChangeResource resource, Put.Input input)
+        throws AuthException, ResourceConflictException, IOException, OrmException,
+            PermissionBackendException {
+      putEdit.apply(resource, path, input.content);
+      return Response.none();
+    }
+  }
+
+  public static class DeleteFile implements RestModifyView<ChangeResource, Input> {
+
+    interface Factory {
+      DeleteFile create(String path);
+    }
+
+    private final DeleteContent deleteContent;
+    private final String path;
+
+    @Inject
+    DeleteFile(DeleteContent deleteContent, @Assisted String path) {
+      this.deleteContent = deleteContent;
+      this.path = path;
+    }
+
+    @Override
+    public Response<?> apply(ChangeResource rsrc, Input in)
+        throws IOException, AuthException, ResourceConflictException, OrmException,
+            PermissionBackendException {
+      return deleteContent.apply(rsrc, path);
+    }
+  }
+
+  // TODO(davido): Turn the boolean options to ChangeEditOption enum,
+  // like it's already the case for ListChangesOption/ListGroupsOption
+  public static class Detail implements RestReadView<ChangeResource> {
+    private final ChangeEditUtil editUtil;
+    private final ChangeEditJson editJson;
+    private final FileInfoJson fileInfoJson;
+    private final Revisions revisions;
+
+    @Option(name = "--base", metaVar = "revision-id")
+    String base;
+
+    @Option(name = "--list")
+    boolean list;
+
+    @Option(name = "--download-commands")
+    boolean downloadCommands;
+
+    @Inject
+    Detail(
+        ChangeEditUtil editUtil,
+        ChangeEditJson editJson,
+        FileInfoJson fileInfoJson,
+        Revisions revisions) {
+      this.editJson = editJson;
+      this.editUtil = editUtil;
+      this.fileInfoJson = fileInfoJson;
+      this.revisions = revisions;
+    }
+
+    @Override
+    public Response<EditInfo> apply(ChangeResource rsrc)
+        throws AuthException, IOException, ResourceNotFoundException, OrmException,
+            PermissionBackendException {
+      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
+      if (!edit.isPresent()) {
+        return Response.none();
+      }
+
+      EditInfo editInfo = editJson.toEditInfo(edit.get(), downloadCommands);
+      if (list) {
+        PatchSet basePatchSet = null;
+        if (base != null) {
+          RevisionResource baseResource = revisions.parse(rsrc, IdString.fromDecoded(base));
+          basePatchSet = baseResource.getPatchSet();
+        }
+        try {
+          editInfo.files =
+              fileInfoJson.toFileInfoMap(
+                  rsrc.getChange(), edit.get().getEditCommit(), basePatchSet);
+        } catch (PatchListNotAvailableException e) {
+          throw new ResourceNotFoundException(e.getMessage());
+        }
+      }
+      return Response.ok(editInfo);
+    }
+  }
+
+  /**
+   * Post to edit collection resource. Two different operations are supported:
+   *
+   * <ul>
+   *   <li>Create non existing change edit
+   *   <li>Restore path in existing change edit
+   * </ul>
+   *
+   * The combination of two operations in one request is supported.
+   */
+  @Singleton
+  public static class Post implements RestModifyView<ChangeResource, Post.Input> {
+    public static class Input {
+      public String restorePath;
+      public String oldPath;
+      public String newPath;
+    }
+
+    private final ChangeEditModifier editModifier;
+    private final GitRepositoryManager repositoryManager;
+
+    @Inject
+    Post(ChangeEditModifier editModifier, GitRepositoryManager repositoryManager) {
+      this.editModifier = editModifier;
+      this.repositoryManager = repositoryManager;
+    }
+
+    @Override
+    public Response<?> apply(ChangeResource resource, Post.Input input)
+        throws AuthException, IOException, ResourceConflictException, OrmException,
+            PermissionBackendException {
+      Project.NameKey project = resource.getProject();
+      try (Repository repository = repositoryManager.openRepository(project)) {
+        if (isRestoreFile(input)) {
+          editModifier.restoreFile(repository, resource.getNotes(), input.restorePath);
+        } else if (isRenameFile(input)) {
+          editModifier.renameFile(repository, resource.getNotes(), input.oldPath, input.newPath);
+        } else {
+          editModifier.createEdit(repository, resource.getNotes());
+        }
+      } catch (InvalidChangeOperationException e) {
+        throw new ResourceConflictException(e.getMessage());
+      }
+      return Response.none();
+    }
+
+    private static boolean isRestoreFile(Input input) {
+      return input != null && !Strings.isNullOrEmpty(input.restorePath);
+    }
+
+    private static boolean isRenameFile(Input input) {
+      return input != null
+          && !Strings.isNullOrEmpty(input.oldPath)
+          && !Strings.isNullOrEmpty(input.newPath);
+    }
+  }
+
+  /** Put handler that is activated when PUT request is called on collection element. */
+  @Singleton
+  public static class Put implements RestModifyView<ChangeEditResource, Put.Input> {
+    public static class Input {
+      @DefaultInput public RawInput content;
+    }
+
+    private final ChangeEditModifier editModifier;
+    private final GitRepositoryManager repositoryManager;
+
+    @Inject
+    Put(ChangeEditModifier editModifier, GitRepositoryManager repositoryManager) {
+      this.editModifier = editModifier;
+      this.repositoryManager = repositoryManager;
+    }
+
+    @Override
+    public Response<?> apply(ChangeEditResource rsrc, Input input)
+        throws AuthException, ResourceConflictException, IOException, OrmException,
+            PermissionBackendException {
+      return apply(rsrc.getChangeResource(), rsrc.getPath(), input.content);
+    }
+
+    public Response<?> apply(ChangeResource rsrc, String path, RawInput newContent)
+        throws ResourceConflictException, AuthException, IOException, OrmException,
+            PermissionBackendException {
+      if (Strings.isNullOrEmpty(path) || path.charAt(0) == '/') {
+        throw new ResourceConflictException("Invalid path: " + path);
+      }
+
+      try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
+        editModifier.modifyFile(repository, rsrc.getNotes(), path, newContent);
+      } catch (InvalidChangeOperationException e) {
+        throw new ResourceConflictException(e.getMessage());
+      }
+      return Response.none();
+    }
+  }
+
+  /**
+   * Handler to delete a file.
+   *
+   * <p>This deletes the file from the repository completely. This is not the same as reverting or
+   * restoring a file to its previous contents.
+   */
+  @Singleton
+  public static class DeleteContent implements RestModifyView<ChangeEditResource, Input> {
+
+    private final ChangeEditModifier editModifier;
+    private final GitRepositoryManager repositoryManager;
+
+    @Inject
+    DeleteContent(ChangeEditModifier editModifier, GitRepositoryManager repositoryManager) {
+      this.editModifier = editModifier;
+      this.repositoryManager = repositoryManager;
+    }
+
+    @Override
+    public Response<?> apply(ChangeEditResource rsrc, Input input)
+        throws AuthException, ResourceConflictException, OrmException, IOException,
+            PermissionBackendException {
+      return apply(rsrc.getChangeResource(), rsrc.getPath());
+    }
+
+    public Response<?> apply(ChangeResource rsrc, String filePath)
+        throws AuthException, IOException, OrmException, ResourceConflictException,
+            PermissionBackendException {
+      try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
+        editModifier.deleteFile(repository, rsrc.getNotes(), filePath);
+      } catch (InvalidChangeOperationException e) {
+        throw new ResourceConflictException(e.getMessage());
+      }
+      return Response.none();
+    }
+  }
+
+  public static class Get implements RestReadView<ChangeEditResource> {
+    private final FileContentUtil fileContentUtil;
+    private final ProjectCache projectCache;
+
+    @Option(
+      name = "--base",
+      aliases = {"-b"},
+      usage = "whether to load the content on the base revision instead of the change edit"
+    )
+    private boolean base;
+
+    @Inject
+    Get(FileContentUtil fileContentUtil, ProjectCache projectCache) {
+      this.fileContentUtil = fileContentUtil;
+      this.projectCache = projectCache;
+    }
+
+    @Override
+    public Response<BinaryResult> apply(ChangeEditResource rsrc) throws IOException {
+      try {
+        ChangeEdit edit = rsrc.getChangeEdit();
+        return Response.ok(
+            fileContentUtil.getContent(
+                projectCache.checkedGet(rsrc.getChangeResource().getProject()),
+                base
+                    ? ObjectId.fromString(edit.getBasePatchSet().getRevision().get())
+                    : edit.getEditCommit(),
+                rsrc.getPath(),
+                null));
+      } catch (ResourceNotFoundException | BadRequestException e) {
+        return Response.none();
+      }
+    }
+  }
+
+  @Singleton
+  public static class GetMeta implements RestReadView<ChangeEditResource> {
+    private final WebLinks webLinks;
+
+    @Inject
+    GetMeta(WebLinks webLinks) {
+      this.webLinks = webLinks;
+    }
+
+    @Override
+    public FileInfo apply(ChangeEditResource rsrc) {
+      FileInfo r = new FileInfo();
+      ChangeEdit edit = rsrc.getChangeEdit();
+      Change change = edit.getChange();
+      List<DiffWebLinkInfo> links =
+          webLinks.getDiffLinks(
+              change.getProject().get(),
+              change.getChangeId(),
+              edit.getBasePatchSet().getPatchSetId(),
+              edit.getBasePatchSet().getRefName(),
+              rsrc.getPath(),
+              0,
+              edit.getRefName(),
+              rsrc.getPath());
+      r.webLinks = links.isEmpty() ? null : links;
+      return r;
+    }
+
+    public static class FileInfo {
+      public List<DiffWebLinkInfo> webLinks;
+    }
+  }
+
+  @Singleton
+  public static class EditMessage implements RestModifyView<ChangeResource, EditMessage.Input> {
+    public static class Input {
+      @DefaultInput public String message;
+    }
+
+    private final ChangeEditModifier editModifier;
+    private final GitRepositoryManager repositoryManager;
+
+    @Inject
+    EditMessage(ChangeEditModifier editModifier, GitRepositoryManager repositoryManager) {
+      this.editModifier = editModifier;
+      this.repositoryManager = repositoryManager;
+    }
+
+    @Override
+    public Object apply(ChangeResource rsrc, Input input)
+        throws AuthException, IOException, BadRequestException, ResourceConflictException,
+            OrmException, PermissionBackendException {
+      if (input == null || Strings.isNullOrEmpty(input.message)) {
+        throw new BadRequestException("commit message must be provided");
+      }
+
+      Project.NameKey project = rsrc.getProject();
+      try (Repository repository = repositoryManager.openRepository(project)) {
+        editModifier.modifyMessage(repository, rsrc.getNotes(), input.message);
+      } catch (UnchangedCommitMessageException e) {
+        throw new ResourceConflictException(e.getMessage());
+      }
+
+      return Response.none();
+    }
+  }
+
+  public static class GetMessage implements RestReadView<ChangeResource> {
+    private final GitRepositoryManager repoManager;
+    private final ChangeEditUtil editUtil;
+
+    @Option(
+      name = "--base",
+      aliases = {"-b"},
+      usage = "whether to load the message on the base revision instead of the change edit"
+    )
+    private boolean base;
+
+    @Inject
+    GetMessage(GitRepositoryManager repoManager, ChangeEditUtil editUtil) {
+      this.repoManager = repoManager;
+      this.editUtil = editUtil;
+    }
+
+    @Override
+    public BinaryResult apply(ChangeResource rsrc)
+        throws AuthException, IOException, ResourceNotFoundException, OrmException {
+      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
+      String msg;
+      if (edit.isPresent()) {
+        if (base) {
+          try (Repository repo = repoManager.openRepository(rsrc.getProject());
+              RevWalk rw = new RevWalk(repo)) {
+            RevCommit commit =
+                rw.parseCommit(
+                    ObjectId.fromString(edit.get().getBasePatchSet().getRevision().get()));
+            msg = commit.getFullMessage();
+          }
+        } else {
+          msg = edit.get().getEditCommit().getFullMessage();
+        }
+
+        return BinaryResult.create(msg)
+            .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
+            .base64();
+      }
+      throw new ResourceNotFoundException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java b/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java
new file mode 100644
index 0000000..12b3797
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ChangeIncludedIn.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.IncludedIn;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class ChangeIncludedIn implements RestReadView<ChangeResource> {
+  private Provider<ReviewDb> db;
+  private PatchSetUtil psUtil;
+  private IncludedIn includedIn;
+
+  @Inject
+  ChangeIncludedIn(Provider<ReviewDb> db, PatchSetUtil psUtil, IncludedIn includedIn) {
+    this.db = db;
+    this.psUtil = psUtil;
+    this.includedIn = includedIn;
+  }
+
+  @Override
+  public IncludedInInfo apply(ChangeResource rsrc)
+      throws RestApiException, OrmException, IOException {
+    PatchSet ps = psUtil.current(db.get(), rsrc.getNotes());
+    return includedIn.apply(rsrc.getProject(), ps.getRevision().get());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
new file mode 100644
index 0000000..9203134
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+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;
+import com.google.gerrit.extensions.restapi.RestCollection;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeFinder;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.List;
+
+@Singleton
+public class ChangesCollection
+    implements RestCollection<TopLevelResource, ChangeResource>, AcceptsPost<TopLevelResource> {
+  private final Provider<ReviewDb> db;
+  private final Provider<CurrentUser> user;
+  private final Provider<QueryChanges> queryFactory;
+  private final DynamicMap<RestView<ChangeResource>> views;
+  private final ChangeFinder changeFinder;
+  private final CreateChange createChange;
+  private final ChangeResource.Factory changeResourceFactory;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  ChangesCollection(
+      Provider<ReviewDb> db,
+      Provider<CurrentUser> user,
+      Provider<QueryChanges> queryFactory,
+      DynamicMap<RestView<ChangeResource>> views,
+      ChangeFinder changeFinder,
+      CreateChange createChange,
+      ChangeResource.Factory changeResourceFactory,
+      PermissionBackend permissionBackend) {
+    this.db = db;
+    this.user = user;
+    this.queryFactory = queryFactory;
+    this.views = views;
+    this.changeFinder = changeFinder;
+    this.createChange = createChange;
+    this.changeResourceFactory = changeResourceFactory;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public QueryChanges list() {
+    return queryFactory.get();
+  }
+
+  @Override
+  public DynamicMap<RestView<ChangeResource>> views() {
+    return views;
+  }
+
+  @Override
+  public ChangeResource parse(TopLevelResource root, IdString id)
+      throws RestApiException, OrmException, PermissionBackendException {
+    List<ChangeNotes> notes = changeFinder.find(id.encoded(), true);
+    if (notes.isEmpty()) {
+      throw new ResourceNotFoundException(id);
+    } else if (notes.size() != 1) {
+      throw new ResourceNotFoundException("Multiple changes found for " + id);
+    }
+
+    ChangeNotes change = notes.get(0);
+    if (!canRead(change)) {
+      throw new ResourceNotFoundException(id);
+    }
+    return changeResourceFactory.create(change, user.get());
+  }
+
+  public ChangeResource parse(Change.Id id)
+      throws ResourceNotFoundException, OrmException, PermissionBackendException {
+    List<ChangeNotes> notes = changeFinder.find(id);
+    if (notes.isEmpty()) {
+      throw new ResourceNotFoundException(toIdString(id));
+    } else if (notes.size() != 1) {
+      throw new ResourceNotFoundException("Multiple changes found for " + id);
+    }
+
+    ChangeNotes change = notes.get(0);
+    if (!canRead(change)) {
+      throw new ResourceNotFoundException(toIdString(id));
+    }
+    return changeResourceFactory.create(change, user.get());
+  }
+
+  private static IdString toIdString(Change.Id id) {
+    return IdString.fromDecoded(id.toString());
+  }
+
+  public ChangeResource parse(ChangeNotes notes, CurrentUser user) {
+    return changeResourceFactory.create(notes, user);
+  }
+
+  @Override
+  public CreateChange post(TopLevelResource parent) throws RestApiException {
+    return createChange;
+  }
+
+  private boolean canRead(ChangeNotes notes) throws PermissionBackendException {
+    try {
+      permissionBackend.user(user).change(notes).database(db).check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Check.java b/java/com/google/gerrit/server/restapi/change/Check.java
new file mode 100644
index 0000000..15f013d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Check.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.api.changes.FixInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+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.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+
+public class Check
+    implements RestReadView<ChangeResource>, RestModifyView<ChangeResource, FixInput> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final ChangeJson.Factory jsonFactory;
+
+  @Inject
+  Check(PermissionBackend permissionBackend, Provider<CurrentUser> user, ChangeJson.Factory json) {
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.jsonFactory = json;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ChangeResource rsrc) throws RestApiException, OrmException {
+    return Response.withMustRevalidate(newChangeJson().format(rsrc));
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ChangeResource rsrc, FixInput input)
+      throws RestApiException, OrmException, PermissionBackendException, NoSuchProjectException,
+          IOException {
+    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));
+  }
+
+  private ChangeJson newChangeJson() {
+    return jsonFactory.create(ListChangesOption.CHECK);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPick.java b/java/com/google/gerrit/server/restapi/change/CherryPick.java
new file mode 100644
index 0000000..c1479b7
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/CherryPick.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class CherryPick
+    extends RetryingRestModifyView<RevisionResource, CherryPickInput, ChangeInfo>
+    implements UiAction<RevisionResource> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final CherryPickChange cherryPickChange;
+  private final ChangeJson.Factory json;
+  private final ContributorAgreementsChecker contributorAgreements;
+
+  @Inject
+  CherryPick(
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      RetryHelper retryHelper,
+      CherryPickChange cherryPickChange,
+      ChangeJson.Factory json,
+      ContributorAgreementsChecker contributorAgreements) {
+    super(retryHelper);
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.cherryPickChange = cherryPickChange;
+    this.json = json;
+    this.contributorAgreements = contributorAgreements;
+  }
+
+  @Override
+  public ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource rsrc, CherryPickInput input)
+      throws OrmException, IOException, UpdateException, RestApiException,
+          PermissionBackendException, ConfigInvalidException, NoSuchProjectException {
+    input.parent = input.parent == null ? 1 : input.parent;
+    if (input.message == null || input.message.trim().isEmpty()) {
+      throw new BadRequestException("message must be non-empty");
+    } else if (input.destination == null || input.destination.trim().isEmpty()) {
+      throw new BadRequestException("destination must be non-empty");
+    }
+
+    String refName = RefNames.fullName(input.destination);
+    contributorAgreements.check(rsrc.getProject(), rsrc.getUser());
+
+    permissionBackend
+        .user(user)
+        .project(rsrc.getChange().getProject())
+        .ref(refName)
+        .check(RefPermission.CREATE_CHANGE);
+
+    try {
+      Change.Id cherryPickedChangeId =
+          cherryPickChange.cherryPick(
+              updateFactory,
+              rsrc.getChange(),
+              rsrc.getPatchSet(),
+              input,
+              new Branch.NameKey(rsrc.getProject(), refName));
+      return json.noOptions().format(rsrc.getProject(), cherryPickedChangeId);
+    } catch (InvalidChangeOperationException e) {
+      throw new BadRequestException(e.getMessage());
+    } catch (IntegrationException | NoSuchChangeException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+
+  @Override
+  public UiAction.Description getDescription(RevisionResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Cherry Pick")
+        .setTitle("Cherry pick change to a different branch")
+        .setVisible(
+            and(
+                rsrc.isCurrent(),
+                permissionBackend
+                    .user(user)
+                    .project(rsrc.getProject())
+                    .testCond(ProjectPermission.CREATE_CHANGE)));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
new file mode 100644
index 0000000..279cc57
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -0,0 +1,418 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.MergeIdenticalTreeException;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+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.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.TimeZone;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.ChangeIdUtil;
+
+@Singleton
+public class CherryPickChange {
+
+  private final Provider<ReviewDb> dbProvider;
+  private final Sequences seq;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final GitRepositoryManager gitManager;
+  private final TimeZone serverTimeZone;
+  private final Provider<IdentifiedUser> user;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final PatchSetInserter.Factory patchSetInserterFactory;
+  private final MergeUtil.Factory mergeUtilFactory;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ProjectCache projectCache;
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeMessagesUtil changeMessagesUtil;
+  private final NotifyUtil notifyUtil;
+
+  @Inject
+  CherryPickChange(
+      Provider<ReviewDb> dbProvider,
+      Sequences seq,
+      Provider<InternalChangeQuery> queryProvider,
+      @GerritPersonIdent PersonIdent myIdent,
+      GitRepositoryManager gitManager,
+      Provider<IdentifiedUser> user,
+      ChangeInserter.Factory changeInserterFactory,
+      PatchSetInserter.Factory patchSetInserterFactory,
+      MergeUtil.Factory mergeUtilFactory,
+      ChangeNotes.Factory changeNotesFactory,
+      ProjectCache projectCache,
+      ApprovalsUtil approvalsUtil,
+      ChangeMessagesUtil changeMessagesUtil,
+      NotifyUtil notifyUtil) {
+    this.dbProvider = dbProvider;
+    this.seq = seq;
+    this.queryProvider = queryProvider;
+    this.gitManager = gitManager;
+    this.serverTimeZone = myIdent.getTimeZone();
+    this.user = user;
+    this.changeInserterFactory = changeInserterFactory;
+    this.patchSetInserterFactory = patchSetInserterFactory;
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.projectCache = projectCache;
+    this.approvalsUtil = approvalsUtil;
+    this.changeMessagesUtil = changeMessagesUtil;
+    this.notifyUtil = notifyUtil;
+  }
+
+  public Change.Id cherryPick(
+      BatchUpdate.Factory batchUpdateFactory,
+      Change change,
+      PatchSet patch,
+      CherryPickInput input,
+      Branch.NameKey dest)
+      throws OrmException, IOException, InvalidChangeOperationException, IntegrationException,
+          UpdateException, RestApiException, ConfigInvalidException, NoSuchProjectException {
+    return cherryPick(
+        batchUpdateFactory,
+        change,
+        patch.getId(),
+        change.getProject(),
+        ObjectId.fromString(patch.getRevision().get()),
+        input,
+        dest);
+  }
+
+  public Change.Id cherryPick(
+      BatchUpdate.Factory batchUpdateFactory,
+      @Nullable Change sourceChange,
+      @Nullable PatchSet.Id sourcePatchId,
+      Project.NameKey project,
+      ObjectId sourceCommit,
+      CherryPickInput input,
+      Branch.NameKey dest)
+      throws OrmException, IOException, InvalidChangeOperationException, IntegrationException,
+          UpdateException, RestApiException, ConfigInvalidException, NoSuchProjectException {
+
+    IdentifiedUser identifiedUser = user.get();
+    try (Repository git = gitManager.openRepository(project);
+        // This inserter and revwalk *must* be passed to any BatchUpdates
+        // created later on, to ensure the cherry-picked commit is flushed
+        // before patch sets are updated.
+        ObjectInserter oi = git.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) {
+      Ref destRef = git.getRefDatabase().exactRef(dest.get());
+      if (destRef == null) {
+        throw new InvalidChangeOperationException(
+            String.format("Branch %s does not exist.", dest.get()));
+      }
+
+      RevCommit baseCommit = getBaseCommit(destRef, project.get(), revWalk, input.base);
+
+      CodeReviewCommit commitToCherryPick = revWalk.parseCommit(sourceCommit);
+
+      if (input.parent <= 0 || input.parent > commitToCherryPick.getParentCount()) {
+        throw new InvalidChangeOperationException(
+            String.format(
+                "Cherry Pick: Parent %s does not exist. Please specify a parent in"
+                    + " range [1, %s].",
+                input.parent, commitToCherryPick.getParentCount()));
+      }
+
+      Timestamp now = TimeUtil.nowTs();
+      PersonIdent committerIdent = identifiedUser.newCommitterIdent(now, serverTimeZone);
+
+      final ObjectId computedChangeId =
+          ChangeIdUtil.computeChangeId(
+              commitToCherryPick.getTree(),
+              baseCommit,
+              commitToCherryPick.getAuthorIdent(),
+              committerIdent,
+              input.message);
+      String commitMessage = ChangeIdUtil.insertId(input.message, computedChangeId).trim() + '\n';
+
+      CodeReviewCommit cherryPickCommit;
+      ProjectState projectState = projectCache.checkedGet(dest.getParentKey());
+      if (projectState == null) {
+        throw new NoSuchProjectException(dest.getParentKey());
+      }
+      try {
+        cherryPickCommit =
+            mergeUtilFactory
+                .create(projectState)
+                .createCherryPickFromCommit(
+                    oi,
+                    git.getConfig(),
+                    baseCommit,
+                    commitToCherryPick,
+                    committerIdent,
+                    commitMessage,
+                    revWalk,
+                    input.parent - 1,
+                    false);
+
+        Change.Key changeKey;
+        final List<String> idList = cherryPickCommit.getFooterLines(FooterConstants.CHANGE_ID);
+        if (!idList.isEmpty()) {
+          final String idStr = idList.get(idList.size() - 1).trim();
+          changeKey = new Change.Key(idStr);
+        } else {
+          changeKey = new Change.Key("I" + computedChangeId.name());
+        }
+
+        Branch.NameKey newDest = new Branch.NameKey(project, destRef.getName());
+        List<ChangeData> destChanges =
+            queryProvider.get().setLimit(2).byBranchKey(newDest, changeKey);
+        if (destChanges.size() > 1) {
+          throw new InvalidChangeOperationException(
+              "Several changes with key "
+                  + changeKey
+                  + " reside on the same branch. "
+                  + "Cannot create a new patch set.");
+        }
+        try (BatchUpdate bu =
+            batchUpdateFactory.create(dbProvider.get(), project, identifiedUser, now)) {
+          bu.setRepository(git, revWalk, oi);
+          Change.Id result;
+          if (destChanges.size() == 1) {
+            // The change key exists on the destination branch. The cherry pick
+            // will be added as a new patch set.
+            result = insertPatchSet(bu, git, destChanges.get(0).notes(), cherryPickCommit, input);
+          } else {
+            // Change key not found on destination branch. We can create a new
+            // change.
+            String newTopic = null;
+            if (sourceChange != null && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
+              newTopic = sourceChange.getTopic() + "-" + newDest.getShortName();
+            }
+            result =
+                createNewChange(
+                    bu, cherryPickCommit, dest.get(), newTopic, sourceChange, sourceCommit, input);
+
+            if (sourceChange != null && sourcePatchId != null) {
+              bu.addOp(
+                  sourceChange.getId(),
+                  new AddMessageToSourceChangeOp(
+                      changeMessagesUtil, sourcePatchId, dest.getShortName(), cherryPickCommit));
+            }
+          }
+          bu.execute();
+          return result;
+        }
+      } catch (MergeIdenticalTreeException | MergeConflictException e) {
+        throw new IntegrationException("Cherry pick failed: " + e.getMessage());
+      }
+    }
+  }
+
+  private RevCommit getBaseCommit(Ref destRef, String project, RevWalk revWalk, String base)
+      throws RestApiException, IOException, OrmException {
+    RevCommit destRefTip = revWalk.parseCommit(destRef.getObjectId());
+    // The tip commit of the destination ref is the default base for the newly created change.
+    if (Strings.isNullOrEmpty(base)) {
+      return destRefTip;
+    }
+
+    ObjectId baseObjectId;
+    try {
+      baseObjectId = ObjectId.fromString(base);
+    } catch (InvalidObjectIdException e) {
+      throw new BadRequestException(String.format("Base %s doesn't represent a valid SHA-1", base));
+    }
+
+    RevCommit baseCommit = revWalk.parseCommit(baseObjectId);
+    InternalChangeQuery changeQuery = queryProvider.get();
+    changeQuery.enforceVisibility(true);
+    List<ChangeData> changeDatas = changeQuery.byBranchCommit(project, destRef.getName(), base);
+
+    if (changeDatas.isEmpty()) {
+      if (revWalk.isMergedInto(baseCommit, destRefTip)) {
+        // The base commit is a merged commit with no change associated.
+        return baseCommit;
+      }
+      throw new UnprocessableEntityException(
+          String.format("Commit %s does not exist on branch %s", base, destRef.getName()));
+    } else if (changeDatas.size() != 1) {
+      throw new ResourceConflictException("Multiple changes found for commit " + base);
+    }
+
+    Change change = changeDatas.get(0).change();
+    Change.Status status = change.getStatus();
+    if (status == Status.NEW || status == Status.MERGED) {
+      // The base commit is a valid change revision.
+      return baseCommit;
+    }
+
+    throw new ResourceConflictException(
+        String.format(
+            "Change %s with commit %s is %s", change.getChangeId(), base, status.asChangeStatus()));
+  }
+
+  private Change.Id insertPatchSet(
+      BatchUpdate bu,
+      Repository git,
+      ChangeNotes destNotes,
+      CodeReviewCommit cherryPickCommit,
+      CherryPickInput input)
+      throws IOException, OrmException, BadRequestException, ConfigInvalidException {
+    Change destChange = destNotes.getChange();
+    PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
+    PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, cherryPickCommit);
+    inserter
+        .setMessage("Uploaded patch set " + inserter.getPatchSetId().get() + ".")
+        .setNotify(input.notify)
+        .setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+    bu.addOp(destChange.getId(), inserter);
+    return destChange.getId();
+  }
+
+  private Change.Id createNewChange(
+      BatchUpdate bu,
+      CodeReviewCommit cherryPickCommit,
+      String refName,
+      String topic,
+      @Nullable Change sourceChange,
+      ObjectId sourceCommit,
+      CherryPickInput input)
+      throws OrmException, IOException, BadRequestException, ConfigInvalidException {
+    Change.Id changeId = new Change.Id(seq.nextChangeId());
+    ChangeInserter ins = changeInserterFactory.create(changeId, cherryPickCommit, refName);
+    Branch.NameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
+    ins.setMessage(messageForDestinationChange(ins.getPatchSetId(), sourceBranch, sourceCommit))
+        .setTopic(topic)
+        .setNotify(input.notify)
+        .setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+    if (input.keepReviewers && sourceChange != null) {
+      ReviewerSet reviewerSet =
+          approvalsUtil.getReviewers(
+              dbProvider.get(), changeNotesFactory.createChecked(dbProvider.get(), sourceChange));
+      Set<Account.Id> reviewers =
+          new HashSet<>(reviewerSet.byState(ReviewerStateInternal.REVIEWER));
+      reviewers.add(sourceChange.getOwner());
+      reviewers.remove(user.get().getAccountId());
+      Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
+      ccs.remove(user.get().getAccountId());
+      ins.setReviewers(reviewers).setExtraCC(ccs);
+    }
+    bu.insertChange(ins);
+    return changeId;
+  }
+
+  private static class AddMessageToSourceChangeOp implements BatchUpdateOp {
+    private final ChangeMessagesUtil cmUtil;
+    private final PatchSet.Id psId;
+    private final String destBranch;
+    private final ObjectId cherryPickCommit;
+
+    private AddMessageToSourceChangeOp(
+        ChangeMessagesUtil cmUtil, PatchSet.Id psId, String destBranch, ObjectId cherryPickCommit) {
+      this.cmUtil = cmUtil;
+      this.psId = psId;
+      this.destBranch = destBranch;
+      this.cherryPickCommit = cherryPickCommit;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws OrmException {
+      StringBuilder sb =
+          new StringBuilder("Patch Set ")
+              .append(psId.get())
+              .append(": Cherry Picked")
+              .append("\n\n")
+              .append("This patchset was cherry picked to branch ")
+              .append(destBranch)
+              .append(" as commit ")
+              .append(cherryPickCommit.name());
+      ChangeMessage changeMessage =
+          ChangeMessagesUtil.newMessage(
+              psId,
+              ctx.getUser(),
+              ctx.getWhen(),
+              sb.toString(),
+              ChangeMessagesUtil.TAG_CHERRY_PICK_CHANGE);
+      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
+      return true;
+    }
+  }
+
+  private String messageForDestinationChange(
+      PatchSet.Id patchSetId, Branch.NameKey sourceBranch, ObjectId sourceCommit) {
+    StringBuilder stringBuilder = new StringBuilder("Patch Set ").append(patchSetId.get());
+
+    if (sourceBranch != null) {
+      stringBuilder.append(": Cherry Picked from branch ").append(sourceBranch.getShortName());
+    } else {
+      stringBuilder.append(": Cherry Picked from commit ").append(sourceCommit.getName());
+    }
+
+    return stringBuilder.append(".").toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
new file mode 100644
index 0000000..039c3ca6
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+@Singleton
+public class CherryPickCommit
+    extends RetryingRestModifyView<CommitResource, CherryPickInput, ChangeInfo> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final CherryPickChange cherryPickChange;
+  private final ChangeJson.Factory json;
+  private final ContributorAgreementsChecker contributorAgreements;
+
+  @Inject
+  CherryPickCommit(
+      RetryHelper retryHelper,
+      Provider<CurrentUser> user,
+      CherryPickChange cherryPickChange,
+      ChangeJson.Factory json,
+      PermissionBackend permissionBackend,
+      ContributorAgreementsChecker contributorAgreements) {
+    super(retryHelper);
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.cherryPickChange = cherryPickChange;
+    this.json = json;
+    this.contributorAgreements = contributorAgreements;
+  }
+
+  @Override
+  public ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, CommitResource rsrc, CherryPickInput input)
+      throws OrmException, IOException, UpdateException, RestApiException,
+          PermissionBackendException, ConfigInvalidException, NoSuchProjectException {
+    RevCommit commit = rsrc.getCommit();
+    String message = Strings.nullToEmpty(input.message).trim();
+    input.message = message.isEmpty() ? commit.getFullMessage() : message;
+    String destination = Strings.nullToEmpty(input.destination).trim();
+    input.parent = input.parent == null ? 1 : input.parent;
+    Project.NameKey projectName = rsrc.getProjectState().getNameKey();
+
+    if (destination.isEmpty()) {
+      throw new BadRequestException("destination must be non-empty");
+    }
+
+    String refName = RefNames.fullName(destination);
+    contributorAgreements.check(projectName, user.get());
+    permissionBackend
+        .user(user)
+        .project(projectName)
+        .ref(refName)
+        .check(RefPermission.CREATE_CHANGE);
+
+    try {
+      Change.Id cherryPickedChangeId =
+          cherryPickChange.cherryPick(
+              updateFactory,
+              null,
+              null,
+              projectName,
+              commit,
+              input,
+              new Branch.NameKey(rsrc.getProjectState().getNameKey(), refName));
+      return json.noOptions().format(projectName, cherryPickedChangeId);
+    } catch (InvalidChangeOperationException e) {
+      throw new BadRequestException(e.getMessage());
+    } catch (IntegrationException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
new file mode 100644
index 0000000..4d06c73
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -0,0 +1,216 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.CommentsUtil.COMMENT_INFO_ORDER;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.FluentIterable;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.Comment.Range;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.FixReplacement;
+import com.google.gerrit.reviewdb.client.FixSuggestion;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+class CommentJson {
+
+  private final AccountLoader.Factory accountLoaderFactory;
+
+  private boolean fillAccounts = true;
+  private boolean fillPatchSet;
+
+  @Inject
+  CommentJson(AccountLoader.Factory accountLoaderFactory) {
+    this.accountLoaderFactory = accountLoaderFactory;
+  }
+
+  CommentJson setFillAccounts(boolean fillAccounts) {
+    this.fillAccounts = fillAccounts;
+    return this;
+  }
+
+  CommentJson setFillPatchSet(boolean fillPatchSet) {
+    this.fillPatchSet = fillPatchSet;
+    return this;
+  }
+
+  public CommentFormatter newCommentFormatter() {
+    return new CommentFormatter();
+  }
+
+  public RobotCommentFormatter newRobotCommentFormatter() {
+    return new RobotCommentFormatter();
+  }
+
+  private abstract class BaseCommentFormatter<F extends Comment, T extends CommentInfo> {
+    public T format(F comment) throws OrmException {
+      AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
+      T info = toInfo(comment, loader);
+      if (loader != null) {
+        loader.fill();
+      }
+      return info;
+    }
+
+    public Map<String, List<T>> format(Iterable<F> comments) throws OrmException {
+      AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
+
+      Map<String, List<T>> out = new TreeMap<>();
+
+      for (F c : comments) {
+        T o = toInfo(c, loader);
+        List<T> list = out.get(o.path);
+        if (list == null) {
+          list = new ArrayList<>();
+          out.put(o.path, list);
+        }
+        o.path = null;
+        list.add(o);
+      }
+
+      for (List<T> list : out.values()) {
+        Collections.sort(list, COMMENT_INFO_ORDER);
+      }
+
+      if (loader != null) {
+        loader.fill();
+      }
+      return out;
+    }
+
+    public List<T> formatAsList(Iterable<F> comments) throws OrmException {
+      AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
+
+      List<T> out =
+          FluentIterable.from(comments)
+              .transform(c -> toInfo(c, loader))
+              .toSortedList(COMMENT_INFO_ORDER);
+
+      if (loader != null) {
+        loader.fill();
+      }
+      return out;
+    }
+
+    protected abstract T toInfo(F comment, AccountLoader loader);
+
+    protected void fillCommentInfo(Comment c, CommentInfo r, AccountLoader loader) {
+      if (fillPatchSet) {
+        r.patchSet = c.key.patchSetId;
+      }
+      r.id = Url.encode(c.key.uuid);
+      r.path = c.key.filename;
+      if (c.side <= 0) {
+        r.side = Side.PARENT;
+        if (c.side < 0) {
+          r.parent = -c.side;
+        }
+      }
+      if (c.lineNbr > 0) {
+        r.line = c.lineNbr;
+      }
+      r.inReplyTo = Url.encode(c.parentUuid);
+      r.message = Strings.emptyToNull(c.message);
+      r.updated = c.writtenOn;
+      r.range = toRange(c.range);
+      r.tag = c.tag;
+      r.unresolved = c.unresolved;
+      if (loader != null) {
+        r.author = loader.get(c.author.getId());
+      }
+    }
+
+    protected Range toRange(Comment.Range commentRange) {
+      Range range = null;
+      if (commentRange != null) {
+        range = new Range();
+        range.startLine = commentRange.startLine;
+        range.startCharacter = commentRange.startChar;
+        range.endLine = commentRange.endLine;
+        range.endCharacter = commentRange.endChar;
+      }
+      return range;
+    }
+  }
+
+  class CommentFormatter extends BaseCommentFormatter<Comment, CommentInfo> {
+    @Override
+    protected CommentInfo toInfo(Comment c, AccountLoader loader) {
+      CommentInfo ci = new CommentInfo();
+      fillCommentInfo(c, ci, loader);
+      return ci;
+    }
+
+    private CommentFormatter() {}
+  }
+
+  class RobotCommentFormatter extends BaseCommentFormatter<RobotComment, RobotCommentInfo> {
+    @Override
+    protected RobotCommentInfo toInfo(RobotComment c, AccountLoader loader) {
+      RobotCommentInfo rci = new RobotCommentInfo();
+      rci.robotId = c.robotId;
+      rci.robotRunId = c.robotRunId;
+      rci.url = c.url;
+      rci.properties = c.properties;
+      rci.fixSuggestions = toFixSuggestionInfos(c.fixSuggestions);
+      fillCommentInfo(c, rci, loader);
+      return rci;
+    }
+
+    private List<FixSuggestionInfo> toFixSuggestionInfos(
+        @Nullable List<FixSuggestion> fixSuggestions) {
+      if (fixSuggestions == null || fixSuggestions.isEmpty()) {
+        return null;
+      }
+
+      return fixSuggestions.stream().map(this::toFixSuggestionInfo).collect(toList());
+    }
+
+    private FixSuggestionInfo toFixSuggestionInfo(FixSuggestion fixSuggestion) {
+      FixSuggestionInfo fixSuggestionInfo = new FixSuggestionInfo();
+      fixSuggestionInfo.fixId = fixSuggestion.fixId;
+      fixSuggestionInfo.description = fixSuggestion.description;
+      fixSuggestionInfo.replacements =
+          fixSuggestion.replacements.stream().map(this::toFixReplacementInfo).collect(toList());
+      return fixSuggestionInfo;
+    }
+
+    private FixReplacementInfo toFixReplacementInfo(FixReplacement fixReplacement) {
+      FixReplacementInfo fixReplacementInfo = new FixReplacementInfo();
+      fixReplacementInfo.path = fixReplacement.path;
+      fixReplacementInfo.range = toRange(fixReplacement.range);
+      fixReplacementInfo.replacement = fixReplacement.replacement;
+      return fixReplacementInfo;
+    }
+
+    private RobotCommentFormatter() {}
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Comments.java b/java/com/google/gerrit/server/restapi/change/Comments.java
new file mode 100644
index 0000000..f563cc6
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Comments.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class Comments implements ChildCollection<RevisionResource, CommentResource> {
+  private final DynamicMap<RestView<CommentResource>> views;
+  private final ListRevisionComments list;
+  private final Provider<ReviewDb> dbProvider;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  Comments(
+      DynamicMap<RestView<CommentResource>> views,
+      ListRevisionComments list,
+      Provider<ReviewDb> dbProvider,
+      CommentsUtil commentsUtil) {
+    this.views = views;
+    this.list = list;
+    this.dbProvider = dbProvider;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public DynamicMap<RestView<CommentResource>> views() {
+    return views;
+  }
+
+  @Override
+  public ListRevisionComments list() {
+    return list;
+  }
+
+  @Override
+  public CommentResource parse(RevisionResource rev, IdString id)
+      throws ResourceNotFoundException, OrmException {
+    String uuid = id.get();
+    ChangeNotes notes = rev.getNotes();
+
+    for (Comment c :
+        commentsUtil.publishedByPatchSet(dbProvider.get(), notes, rev.getPatchSet().getId())) {
+      if (uuid.equals(c.key.uuid)) {
+        return new CommentResource(rev, c);
+      }
+    }
+    throw new ResourceNotFoundException(id);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
new file mode 100644
index 0000000..363feb8
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -0,0 +1,361 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeFinder;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.project.CommitsCollection;
+import com.google.gerrit.server.restapi.project.ProjectsCollection;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.sql.Timestamp;
+import java.util.Collections;
+import java.util.List;
+import java.util.TimeZone;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TreeFormatter;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.ChangeIdUtil;
+
+@Singleton
+public class CreateChange
+    extends RetryingRestModifyView<TopLevelResource, ChangeInput, Response<ChangeInfo>> {
+  private final String anonymousCowardName;
+  private final Provider<ReviewDb> db;
+  private final GitRepositoryManager gitManager;
+  private final AccountCache accountCache;
+  private final Sequences seq;
+  private final TimeZone serverTimeZone;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final ProjectsCollection projectsCollection;
+  private final CommitsCollection commits;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final ChangeJson.Factory jsonFactory;
+  private final ChangeFinder changeFinder;
+  private final PatchSetUtil psUtil;
+  private final MergeUtil.Factory mergeUtilFactory;
+  private final SubmitType submitType;
+  private final NotifyUtil notifyUtil;
+  private final ContributorAgreementsChecker contributorAgreements;
+  private final boolean disablePrivateChanges;
+
+  @Inject
+  CreateChange(
+      @AnonymousCowardName String anonymousCowardName,
+      Provider<ReviewDb> db,
+      GitRepositoryManager gitManager,
+      AccountCache accountCache,
+      Sequences seq,
+      @GerritPersonIdent PersonIdent myIdent,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      ProjectsCollection projectsCollection,
+      CommitsCollection commits,
+      ChangeInserter.Factory changeInserterFactory,
+      ChangeJson.Factory json,
+      ChangeFinder changeFinder,
+      RetryHelper retryHelper,
+      PatchSetUtil psUtil,
+      @GerritServerConfig Config config,
+      MergeUtil.Factory mergeUtilFactory,
+      NotifyUtil notifyUtil,
+      ContributorAgreementsChecker contributorAgreements) {
+    super(retryHelper);
+    this.anonymousCowardName = anonymousCowardName;
+    this.db = db;
+    this.gitManager = gitManager;
+    this.accountCache = accountCache;
+    this.seq = seq;
+    this.serverTimeZone = myIdent.getTimeZone();
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.projectsCollection = projectsCollection;
+    this.commits = commits;
+    this.changeInserterFactory = changeInserterFactory;
+    this.jsonFactory = json;
+    this.changeFinder = changeFinder;
+    this.psUtil = psUtil;
+    this.submitType = config.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
+    this.disablePrivateChanges = config.getBoolean("change", null, "disablePrivateChanges", false);
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.notifyUtil = notifyUtil;
+    this.contributorAgreements = contributorAgreements;
+  }
+
+  @Override
+  protected Response<ChangeInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, TopLevelResource parent, ChangeInput input)
+      throws OrmException, IOException, InvalidChangeOperationException, RestApiException,
+          UpdateException, PermissionBackendException, ConfigInvalidException {
+    if (Strings.isNullOrEmpty(input.project)) {
+      throw new BadRequestException("project must be non-empty");
+    }
+
+    if (Strings.isNullOrEmpty(input.branch)) {
+      throw new BadRequestException("branch must be non-empty");
+    }
+
+    if (Strings.isNullOrEmpty(input.subject)) {
+      throw new BadRequestException("commit message must be non-empty");
+    }
+
+    if (input.status != null) {
+      if (input.status != ChangeStatus.NEW) {
+        throw new BadRequestException("unsupported change status");
+      }
+    }
+
+    ProjectResource rsrc = projectsCollection.parse(input.project);
+    boolean privateByDefault = rsrc.getProjectState().is(BooleanProjectConfig.PRIVATE_BY_DEFAULT);
+    boolean isPrivate = input.isPrivate == null ? privateByDefault : input.isPrivate;
+
+    if (isPrivate && disablePrivateChanges) {
+      throw new MethodNotAllowedException("private changes are disabled");
+    }
+
+    contributorAgreements.check(rsrc.getNameKey(), rsrc.getUser());
+
+    Project.NameKey project = rsrc.getNameKey();
+    String refName = RefNames.fullName(input.branch);
+    permissionBackend.user(user).project(project).ref(refName).check(RefPermission.CREATE_CHANGE);
+
+    try (Repository git = gitManager.openRepository(project);
+        ObjectInserter oi = git.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      ObjectId parentCommit;
+      List<String> groups;
+      if (input.baseChange != null) {
+        List<ChangeNotes> notes = changeFinder.find(input.baseChange);
+        if (notes.size() != 1) {
+          throw new UnprocessableEntityException("Base change not found: " + input.baseChange);
+        }
+        ChangeNotes change = Iterables.getOnlyElement(notes);
+        try {
+          permissionBackend.user(user).change(change).database(db).check(ChangePermission.READ);
+        } catch (AuthException e) {
+          throw new UnprocessableEntityException("Read not permitted for " + input.baseChange);
+        }
+        PatchSet ps = psUtil.current(db.get(), change);
+        parentCommit = ObjectId.fromString(ps.getRevision().get());
+        groups = ps.getGroups();
+      } else {
+        Ref destRef = git.getRefDatabase().exactRef(refName);
+        if (destRef != null) {
+          if (Boolean.TRUE.equals(input.newBranch)) {
+            throw new ResourceConflictException(
+                String.format("Branch %s already exists.", refName));
+          }
+          parentCommit = destRef.getObjectId();
+        } else {
+          if (Boolean.TRUE.equals(input.newBranch)) {
+            parentCommit = null;
+          } else {
+            throw new UnprocessableEntityException(
+                String.format("Branch %s does not exist.", refName));
+          }
+        }
+        groups = Collections.emptyList();
+      }
+      RevCommit mergeTip = parentCommit == null ? null : rw.parseCommit(parentCommit);
+
+      Timestamp now = TimeUtil.nowTs();
+      IdentifiedUser me = user.get().asIdentifiedUser();
+      PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
+      AccountState account = accountCache.get(me.getAccountId());
+      GeneralPreferencesInfo info = account.getGeneralPreferences();
+
+      ObjectId treeId = mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree();
+      ObjectId id = ChangeIdUtil.computeChangeId(treeId, mergeTip, author, author, input.subject);
+      String commitMessage = ChangeIdUtil.insertId(input.subject, id);
+      if (Boolean.TRUE.equals(info.signedOffBy)) {
+        commitMessage +=
+            String.format(
+                "%s%s", SIGNED_OFF_BY_TAG, account.getAccount().getNameEmail(anonymousCowardName));
+      }
+
+      RevCommit c;
+      if (input.merge != null) {
+        // create a merge commit
+        if (!(submitType.equals(SubmitType.MERGE_ALWAYS)
+            || submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
+          throw new BadRequestException("Submit type: " + submitType + " is not supported");
+        }
+        c =
+            newMergeCommit(
+                git, oi, rw, rsrc.getProjectState(), mergeTip, input.merge, author, commitMessage);
+      } else {
+        // create an empty commit
+        c = newCommit(oi, rw, author, mergeTip, commitMessage);
+      }
+
+      Change.Id changeId = new Change.Id(seq.nextChangeId());
+      ChangeInserter ins = changeInserterFactory.create(changeId, c, refName);
+      ins.setMessage(String.format("Uploaded patch set %s.", ins.getPatchSetId().get()));
+      String topic = input.topic;
+      if (topic != null) {
+        topic = Strings.emptyToNull(topic.trim());
+      }
+      ins.setTopic(topic);
+      ins.setPrivate(isPrivate);
+      ins.setWorkInProgress(input.workInProgress != null && input.workInProgress);
+      ins.setGroups(groups);
+      ins.setNotify(input.notify);
+      ins.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+      try (BatchUpdate bu = updateFactory.create(db.get(), project, me, now)) {
+        bu.setRepository(git, rw, oi);
+        bu.insertChange(ins);
+        bu.execute();
+      }
+      ChangeJson json = jsonFactory.noOptions();
+      return Response.created(json.format(ins.getChange()));
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage());
+    }
+  }
+
+  private static RevCommit newCommit(
+      ObjectInserter oi,
+      RevWalk rw,
+      PersonIdent authorIdent,
+      RevCommit mergeTip,
+      String commitMessage)
+      throws IOException {
+    CommitBuilder commit = new CommitBuilder();
+    if (mergeTip == null) {
+      commit.setTreeId(emptyTreeId(oi));
+    } else {
+      commit.setTreeId(mergeTip.getTree().getId());
+      commit.setParentId(mergeTip);
+    }
+    commit.setAuthor(authorIdent);
+    commit.setCommitter(authorIdent);
+    commit.setMessage(commitMessage);
+    return rw.parseCommit(insert(oi, commit));
+  }
+
+  private RevCommit newMergeCommit(
+      Repository repo,
+      ObjectInserter oi,
+      RevWalk rw,
+      ProjectState projectState,
+      RevCommit mergeTip,
+      MergeInput merge,
+      PersonIdent authorIdent,
+      String commitMessage)
+      throws RestApiException, IOException {
+    if (Strings.isNullOrEmpty(merge.source)) {
+      throw new BadRequestException("merge.source must be non-empty");
+    }
+
+    RevCommit sourceCommit = MergeUtil.resolveCommit(repo, rw, merge.source);
+    if (!commits.canRead(projectState, repo, sourceCommit)) {
+      throw new BadRequestException("do not have read permission for: " + merge.source);
+    }
+
+    MergeUtil mergeUtil = mergeUtilFactory.create(projectState);
+    // default merge strategy from project settings
+    String mergeStrategy =
+        MoreObjects.firstNonNull(
+            Strings.emptyToNull(merge.strategy), mergeUtil.mergeStrategyName());
+
+    return MergeUtil.createMergeCommit(
+        oi,
+        repo.getConfig(),
+        mergeTip,
+        sourceCommit,
+        mergeStrategy,
+        authorIdent,
+        commitMessage,
+        rw);
+  }
+
+  private static ObjectId insert(ObjectInserter inserter, CommitBuilder commit)
+      throws IOException, UnsupportedEncodingException {
+    ObjectId id = inserter.insert(commit);
+    inserter.flush();
+    return id;
+  }
+
+  private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
+    return inserter.insert(new TreeFormatter());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
new file mode 100644
index 0000000..afcc8f7
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -0,0 +1,132 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Collections;
+
+@Singleton
+public class CreateDraftComment
+    extends RetryingRestModifyView<RevisionResource, DraftInput, Response<CommentInfo>> {
+  private final Provider<ReviewDb> db;
+  private final Provider<CommentJson> commentJson;
+  private final CommentsUtil commentsUtil;
+  private final PatchSetUtil psUtil;
+  private final PatchListCache patchListCache;
+
+  @Inject
+  CreateDraftComment(
+      Provider<ReviewDb> db,
+      RetryHelper retryHelper,
+      Provider<CommentJson> commentJson,
+      CommentsUtil commentsUtil,
+      PatchSetUtil psUtil,
+      PatchListCache patchListCache) {
+    super(retryHelper);
+    this.db = db;
+    this.commentJson = commentJson;
+    this.commentsUtil = commentsUtil;
+    this.psUtil = psUtil;
+    this.patchListCache = patchListCache;
+  }
+
+  @Override
+  protected Response<CommentInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource rsrc, DraftInput in)
+      throws RestApiException, UpdateException, OrmException {
+    if (Strings.isNullOrEmpty(in.path)) {
+      throw new BadRequestException("path must be non-empty");
+    } else if (in.message == null || in.message.trim().isEmpty()) {
+      throw new BadRequestException("message must be non-empty");
+    } else if (in.line != null && in.line < 0) {
+      throw new BadRequestException("line must be >= 0");
+    } else if (in.line != null && in.range != null && in.line != in.range.endLine) {
+      throw new BadRequestException("range endLine must be on the same line as the comment");
+    }
+
+    try (BatchUpdate bu =
+        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      Op op = new Op(rsrc.getPatchSet().getId(), in);
+      bu.addOp(rsrc.getChange().getId(), op);
+      bu.execute();
+      return Response.created(
+          commentJson.get().setFillAccounts(false).newCommentFormatter().format(op.comment));
+    }
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final PatchSet.Id psId;
+    private final DraftInput in;
+
+    private Comment comment;
+
+    private Op(PatchSet.Id psId, DraftInput in) {
+      this.psId = psId;
+      this.in = in;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws ResourceNotFoundException, OrmException, UnprocessableEntityException {
+      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      if (ps == null) {
+        throw new ResourceNotFoundException("patch set not found: " + psId);
+      }
+      String parentUuid = Url.decode(in.inReplyTo);
+
+      comment =
+          commentsUtil.newComment(
+              ctx, in.path, ps.getId(), in.side(), in.message.trim(), in.unresolved, parentUuid);
+      comment.setLineNbrAndRange(in.line, in.range);
+      comment.tag = in.tag;
+
+      setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
+
+      commentsUtil.putComments(
+          ctx.getDb(), ctx.getUpdate(psId), Status.DRAFT, Collections.singleton(comment));
+      ctx.dontBumpLastUpdatedOn();
+      return true;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
new file mode 100644
index 0000000..dcaba77
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -0,0 +1,267 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeFinder;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeIdenticalTreeException;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.project.CommitsCollection;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.TimeZone;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.ChangeIdUtil;
+
+@Singleton
+public class CreateMergePatchSet
+    extends RetryingRestModifyView<ChangeResource, MergePatchSetInput, Response<ChangeInfo>> {
+  private final Provider<ReviewDb> db;
+  private final GitRepositoryManager gitManager;
+  private final CommitsCollection commits;
+  private final TimeZone serverTimeZone;
+  private final Provider<CurrentUser> user;
+  private final ChangeJson.Factory jsonFactory;
+  private final PatchSetUtil psUtil;
+  private final MergeUtil.Factory mergeUtilFactory;
+  private final PatchSetInserter.Factory patchSetInserterFactory;
+  private final ProjectCache projectCache;
+  private final ChangeFinder changeFinder;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  CreateMergePatchSet(
+      Provider<ReviewDb> db,
+      GitRepositoryManager gitManager,
+      CommitsCollection commits,
+      @GerritPersonIdent PersonIdent myIdent,
+      Provider<CurrentUser> user,
+      ChangeJson.Factory json,
+      PatchSetUtil psUtil,
+      MergeUtil.Factory mergeUtilFactory,
+      RetryHelper retryHelper,
+      PatchSetInserter.Factory patchSetInserterFactory,
+      ProjectCache projectCache,
+      ChangeFinder changeFinder,
+      PermissionBackend permissionBackend) {
+    super(retryHelper);
+    this.db = db;
+    this.gitManager = gitManager;
+    this.commits = commits;
+    this.serverTimeZone = myIdent.getTimeZone();
+    this.user = user;
+    this.jsonFactory = json;
+    this.psUtil = psUtil;
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.patchSetInserterFactory = patchSetInserterFactory;
+    this.projectCache = projectCache;
+    this.changeFinder = changeFinder;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  protected Response<ChangeInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, MergePatchSetInput in)
+      throws OrmException, IOException, InvalidChangeOperationException, RestApiException,
+          UpdateException, PermissionBackendException {
+    rsrc.permissions().database(db).check(ChangePermission.ADD_PATCH_SET);
+
+    ProjectState projectState = projectCache.checkedGet(rsrc.getProject());
+    projectState.checkStatePermitsWrite();
+
+    MergeInput merge = in.merge;
+    if (merge == null || Strings.isNullOrEmpty(merge.source)) {
+      throw new BadRequestException("merge.source must be non-empty");
+    }
+    in.baseChange = Strings.nullToEmpty(in.baseChange).trim();
+
+    PatchSet ps = psUtil.current(db.get(), rsrc.getNotes());
+    Change change = rsrc.getChange();
+    Project.NameKey project = change.getProject();
+    Branch.NameKey dest = change.getDest();
+    try (Repository git = gitManager.openRepository(project);
+        ObjectInserter oi = git.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+
+      RevCommit sourceCommit = MergeUtil.resolveCommit(git, rw, merge.source);
+      if (!commits.canRead(projectState, git, sourceCommit)) {
+        throw new ResourceNotFoundException(
+            "cannot find source commit: " + merge.source + " to merge.");
+      }
+
+      RevCommit currentPsCommit;
+      List<String> groups = null;
+      if (!in.inheritParent && !in.baseChange.isEmpty()) {
+        PatchSet basePS = findBasePatchSet(in.baseChange);
+        currentPsCommit = rw.parseCommit(ObjectId.fromString(basePS.getRevision().get()));
+        groups = basePS.getGroups();
+      } else {
+        currentPsCommit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+      }
+
+      Timestamp now = TimeUtil.nowTs();
+      IdentifiedUser me = user.get().asIdentifiedUser();
+      PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
+      RevCommit newCommit =
+          createMergeCommit(
+              in,
+              projectState,
+              dest,
+              git,
+              oi,
+              rw,
+              currentPsCommit,
+              sourceCommit,
+              author,
+              ObjectId.fromString(change.getKey().get().substring(1)));
+
+      PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.getId());
+      PatchSetInserter psInserter =
+          patchSetInserterFactory.create(rsrc.getNotes(), nextPsId, newCommit);
+      try (BatchUpdate bu = updateFactory.create(db.get(), project, me, now)) {
+        bu.setRepository(git, rw, oi);
+        psInserter
+            .setMessage("Uploaded patch set " + nextPsId.get() + ".")
+            .setNotify(NotifyHandling.NONE)
+            .setCheckAddPatchSetPermission(false)
+            .setNotify(NotifyHandling.NONE);
+        if (groups != null) {
+          psInserter.setGroups(groups);
+        }
+        bu.addOp(rsrc.getId(), psInserter);
+        bu.execute();
+      }
+
+      ChangeJson json = jsonFactory.create(ListChangesOption.CURRENT_REVISION);
+      return Response.ok(json.format(psInserter.getChange()));
+    }
+  }
+
+  private PatchSet findBasePatchSet(String baseChange)
+      throws PermissionBackendException, OrmException, UnprocessableEntityException {
+    List<ChangeNotes> notes = changeFinder.find(baseChange);
+    if (notes.size() != 1) {
+      throw new UnprocessableEntityException("Base change not found: " + baseChange);
+    }
+    ChangeNotes change = Iterables.getOnlyElement(notes);
+    try {
+      permissionBackend.user(user).change(change).database(db).check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new UnprocessableEntityException("Read not permitted for " + baseChange);
+    }
+    return psUtil.current(db.get(), change);
+  }
+
+  private RevCommit createMergeCommit(
+      MergePatchSetInput in,
+      ProjectState projectState,
+      Branch.NameKey dest,
+      Repository git,
+      ObjectInserter oi,
+      RevWalk rw,
+      RevCommit currentPsCommit,
+      RevCommit sourceCommit,
+      PersonIdent author,
+      ObjectId changeId)
+      throws ResourceNotFoundException, MergeIdenticalTreeException, MergeConflictException,
+          IOException {
+
+    ObjectId parentCommit;
+    if (in.inheritParent) {
+      // inherit first parent from previous patch set
+      parentCommit = currentPsCommit.getParent(0);
+    } else if (!in.baseChange.isEmpty()) {
+      parentCommit = currentPsCommit.getId();
+    } else {
+      // get the current branch tip of destination branch
+      Ref destRef = git.getRefDatabase().exactRef(dest.get());
+      if (destRef != null) {
+        parentCommit = destRef.getObjectId();
+      } else {
+        throw new ResourceNotFoundException("cannot find destination branch");
+      }
+    }
+    RevCommit mergeTip = rw.parseCommit(parentCommit);
+
+    String commitMsg;
+    if (Strings.emptyToNull(in.subject) != null) {
+      commitMsg = ChangeIdUtil.insertId(in.subject, changeId);
+    } else {
+      // reuse previous patch set commit message
+      commitMsg = currentPsCommit.getFullMessage();
+    }
+
+    String mergeStrategy =
+        MoreObjects.firstNonNull(
+            Strings.emptyToNull(in.merge.strategy),
+            mergeUtilFactory.create(projectState).mergeStrategyName());
+
+    return MergeUtil.createMergeCommit(
+        oi, git.getConfig(), mergeTip, sourceCommit, mergeStrategy, author, commitMsg, rw);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
new file mode 100644
index 0000000..2eae7ad
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+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;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.extensions.events.AssigneeChanged;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeleteAssignee
+    extends RetryingRestModifyView<ChangeResource, Input, Response<AccountInfo>> {
+
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> db;
+  private final AssigneeChanged assigneeChanged;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final AccountLoader.Factory accountLoaderFactory;
+
+  @Inject
+  DeleteAssignee(
+      RetryHelper retryHelper,
+      ChangeMessagesUtil cmUtil,
+      Provider<ReviewDb> db,
+      AssigneeChanged assigneeChanged,
+      IdentifiedUser.GenericFactory userFactory,
+      AccountLoader.Factory accountLoaderFactory) {
+    super(retryHelper);
+    this.cmUtil = cmUtil;
+    this.db = db;
+    this.assigneeChanged = assigneeChanged;
+    this.userFactory = userFactory;
+    this.accountLoaderFactory = accountLoaderFactory;
+  }
+
+  @Override
+  protected Response<AccountInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
+    rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
+
+    try (BatchUpdate bu =
+        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      Op op = new Op();
+      bu.addOp(rsrc.getChange().getId(), op);
+      bu.execute();
+      Account.Id deletedAssignee = op.getDeletedAssignee();
+      return deletedAssignee == null
+          ? Response.none()
+          : Response.ok(accountLoaderFactory.create(true).fillOne(deletedAssignee));
+    }
+  }
+
+  private class Op implements BatchUpdateOp {
+    private Change change;
+    private Account deletedAssignee;
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws RestApiException, OrmException {
+      change = ctx.getChange();
+      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+      Account.Id currentAssigneeId = change.getAssignee();
+      if (currentAssigneeId == null) {
+        return false;
+      }
+      IdentifiedUser deletedAssigneeUser = userFactory.create(currentAssigneeId);
+      deletedAssignee = deletedAssigneeUser.getAccount();
+      // noteDb
+      update.removeAssignee();
+      // reviewDb
+      change.setAssignee(null);
+      addMessage(ctx, update, deletedAssigneeUser);
+      return true;
+    }
+
+    public Account.Id getDeletedAssignee() {
+      return deletedAssignee != null ? deletedAssignee.getId() : null;
+    }
+
+    private void addMessage(ChangeContext ctx, ChangeUpdate update, IdentifiedUser deletedAssignee)
+        throws OrmException {
+      ChangeMessage cmsg =
+          ChangeMessagesUtil.newMessage(
+              ctx,
+              "Assignee deleted: " + deletedAssignee.getNameEmail(),
+              ChangeMessagesUtil.TAG_DELETE_ASSIGNEE);
+      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+    }
+
+    @Override
+    public void postUpdate(Context ctx) throws OrmException {
+      assigneeChanged.fire(change, ctx.getAccount(), deletedAssignee, ctx.getWhen());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChange.java b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
new file mode 100644
index 0000000..e33b4a4
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+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.ChangeResource;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.Order;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeleteChange extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+    implements UiAction<ChangeResource> {
+
+  private final Provider<ReviewDb> db;
+  private final Provider<DeleteChangeOp> opProvider;
+
+  @Inject
+  public DeleteChange(
+      Provider<ReviewDb> db, RetryHelper retryHelper, Provider<DeleteChangeOp> opProvider) {
+    super(retryHelper);
+    this.db = db;
+    this.opProvider = opProvider;
+  }
+
+  @Override
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException, PermissionBackendException {
+    if (rsrc.getChange().getStatus() == Change.Status.MERGED) {
+      throw new MethodNotAllowedException("delete not permitted");
+    }
+    rsrc.permissions().database(db).check(ChangePermission.DELETE);
+
+    try (BatchUpdate bu =
+        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      Change.Id id = rsrc.getChange().getId();
+      bu.setOrder(Order.DB_BEFORE_REPO);
+      bu.addOp(id, opProvider.get());
+      bu.execute();
+    }
+    return Response.none();
+  }
+
+  @Override
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    Change.Status status = rsrc.getChange().getStatus();
+    PermissionBackend.ForChange perm = rsrc.permissions().database(db);
+    return new UiAction.Description()
+        .setLabel("Delete")
+        .setTitle("Delete change " + rsrc.getId())
+        .setVisible(and(couldDeleteWhenIn(status), perm.testCond(ChangePermission.DELETE)));
+  }
+
+  private boolean couldDeleteWhenIn(Change.Status status) {
+    switch (status) {
+      case NEW:
+      case ABANDONED:
+        // New or abandoned changes can be deleted with the right permissions.
+        return true;
+
+      case MERGED:
+        // Merged changes should never be deleted.
+        return false;
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeEdit.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeEdit.java
new file mode 100644
index 0000000..942b191
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeEdit.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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.ChangeResource;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Optional;
+
+@Singleton
+public class DeleteChangeEdit implements RestModifyView<ChangeResource, Input> {
+
+  private final ChangeEditUtil editUtil;
+
+  @Inject
+  DeleteChangeEdit(ChangeEditUtil editUtil) {
+    this.editUtil = editUtil;
+  }
+
+  @Override
+  public Response<?> apply(ChangeResource rsrc, Input input)
+      throws AuthException, ResourceNotFoundException, IOException, OrmException {
+    Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
+    if (edit.isPresent()) {
+      editUtil.delete(edit.get());
+    } else {
+      throw new ResourceNotFoundException();
+    }
+
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeOp.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeOp.java
new file mode 100644
index 0000000..1853853
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeOp.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdateReviewDb;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Order;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+class DeleteChangeOp implements BatchUpdateOp {
+  static boolean allowDrafts(Config cfg) {
+    return cfg.getBoolean("change", "allowDrafts", true);
+  }
+
+  private final PatchSetUtil psUtil;
+  private final StarredChangesUtil starredChangesUtil;
+  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
+
+  private Change.Id id;
+
+  @Inject
+  DeleteChangeOp(
+      PatchSetUtil psUtil,
+      StarredChangesUtil starredChangesUtil,
+      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
+    this.psUtil = psUtil;
+    this.starredChangesUtil = starredChangesUtil;
+    this.accountPatchReviewStore = accountPatchReviewStore;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws RestApiException, OrmException, IOException, NoSuchChangeException {
+    checkState(
+        ctx.getOrder() == Order.DB_BEFORE_REPO, "must use DeleteChangeOp with DB_BEFORE_REPO");
+    checkState(id == null, "cannot reuse DeleteChangeOp");
+
+    id = ctx.getChange().getId();
+    Collection<PatchSet> patchSets = psUtil.byChange(ctx.getDb(), ctx.getNotes());
+
+    ensureDeletable(ctx, id, patchSets);
+    // Cleaning up is only possible as long as the change and its elements are
+    // still part of the database.
+    cleanUpReferences(ctx, id, patchSets);
+    deleteChangeElementsFromDb(ctx, id);
+
+    ctx.deleteChange();
+    return true;
+  }
+
+  private void ensureDeletable(ChangeContext ctx, Change.Id id, Collection<PatchSet> patchSets)
+      throws ResourceConflictException, MethodNotAllowedException, IOException {
+    Change.Status status = ctx.getChange().getStatus();
+    if (status == Change.Status.MERGED) {
+      throw new MethodNotAllowedException("Deleting merged change " + id + " is not allowed");
+    }
+    for (PatchSet patchSet : patchSets) {
+      if (isPatchSetMerged(ctx, patchSet)) {
+        throw new ResourceConflictException(
+            String.format(
+                "Cannot delete change %s: patch set %s is already merged",
+                id, patchSet.getPatchSetId()));
+      }
+    }
+  }
+
+  private boolean isPatchSetMerged(ChangeContext ctx, PatchSet patchSet) throws IOException {
+    Optional<ObjectId> destId = ctx.getRepoView().getRef(ctx.getChange().getDest().get());
+    if (!destId.isPresent()) {
+      return false;
+    }
+
+    RevWalk revWalk = ctx.getRevWalk();
+    ObjectId objectId = ObjectId.fromString(patchSet.getRevision().get());
+    return revWalk.isMergedInto(revWalk.parseCommit(objectId), revWalk.parseCommit(destId.get()));
+  }
+
+  private void deleteChangeElementsFromDb(ChangeContext ctx, Change.Id id) throws OrmException {
+    // Only delete from ReviewDb here; deletion from NoteDb is handled in
+    // BatchUpdate.
+    //
+    // This is special. We want to delete exactly the rows that are present in
+    // the database, even when reading everything else from NoteDb, so we need
+    // to bypass the write-only wrapper.
+    ReviewDb db = BatchUpdateReviewDb.unwrap(ctx.getDb());
+    db.patchComments().delete(db.patchComments().byChange(id));
+    db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
+    db.patchSets().delete(db.patchSets().byChange(id));
+    db.changeMessages().delete(db.changeMessages().byChange(id));
+  }
+
+  private void cleanUpReferences(ChangeContext ctx, Change.Id id, Collection<PatchSet> patchSets)
+      throws OrmException, NoSuchChangeException {
+    for (PatchSet ps : patchSets) {
+      accountPatchReviewStore.get().clearReviewed(ps.getId());
+    }
+
+    // Non-atomic operation on Accounts table; not much we can do to make it
+    // atomic.
+    starredChangesUtil.unstarAll(ctx.getChange().getProject(), id);
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx) throws IOException {
+    String prefix = new PatchSet.Id(id, 1).toRefName();
+    prefix = prefix.substring(0, prefix.length() - 1);
+    for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(prefix).entrySet()) {
+      ctx.addRefUpdate(e.getValue(), ObjectId.zeroId(), prefix + e.getKey());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
new file mode 100644
index 0000000..4320cd6
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class DeleteComment
+    extends RetryingRestModifyView<CommentResource, DeleteCommentInput, CommentInfo> {
+
+  private final Provider<CurrentUser> userProvider;
+  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
+  private final CommentsUtil commentsUtil;
+  private final Provider<CommentJson> commentJson;
+  private final ChangeNotes.Factory notesFactory;
+
+  @Inject
+  public DeleteComment(
+      Provider<CurrentUser> userProvider,
+      Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend,
+      RetryHelper retryHelper,
+      CommentsUtil commentsUtil,
+      Provider<CommentJson> commentJson,
+      ChangeNotes.Factory notesFactory) {
+    super(retryHelper);
+    this.userProvider = userProvider;
+    this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
+    this.commentsUtil = commentsUtil;
+    this.commentJson = commentJson;
+    this.notesFactory = notesFactory;
+  }
+
+  @Override
+  public CommentInfo applyImpl(
+      BatchUpdate.Factory batchUpdateFactory, CommentResource rsrc, DeleteCommentInput input)
+      throws RestApiException, IOException, ConfigInvalidException, OrmException,
+          PermissionBackendException, UpdateException {
+    CurrentUser user = userProvider.get();
+    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+
+    String newMessage = getCommentNewMessage(user.asIdentifiedUser().getName(), input.reason);
+    DeleteCommentOp deleteCommentOp = new DeleteCommentOp(rsrc, newMessage);
+    try (BatchUpdate batchUpdate =
+        batchUpdateFactory.create(
+            dbProvider.get(), rsrc.getRevisionResource().getProject(), user, TimeUtil.nowTs())) {
+      batchUpdate.addOp(rsrc.getRevisionResource().getChange().getId(), deleteCommentOp).execute();
+    }
+
+    ChangeNotes updatedNotes =
+        notesFactory.createChecked(rsrc.getRevisionResource().getChange().getId());
+    List<Comment> changeComments = commentsUtil.publishedByChange(dbProvider.get(), updatedNotes);
+    Optional<Comment> updatedComment =
+        changeComments.stream().filter(c -> c.key.equals(rsrc.getComment().key)).findFirst();
+    if (!updatedComment.isPresent()) {
+      // This should not happen as this endpoint should not remove the whole comment.
+      throw new ResourceNotFoundException("comment not found: " + rsrc.getComment().key);
+    }
+
+    return commentJson.get().newCommentFormatter().format(updatedComment.get());
+  }
+
+  private static String getCommentNewMessage(String name, String reason) {
+    StringBuilder stringBuilder = new StringBuilder("Comment removed by: ").append(name);
+    if (!Strings.isNullOrEmpty(reason)) {
+      stringBuilder.append("; Reason: ").append(reason);
+    }
+    return stringBuilder.toString();
+  }
+
+  private class DeleteCommentOp implements BatchUpdateOp {
+    private final CommentResource rsrc;
+    private final String newMessage;
+
+    DeleteCommentOp(CommentResource rsrc, String newMessage) {
+      this.rsrc = rsrc;
+      this.newMessage = newMessage;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws ResourceConflictException, OrmException, ResourceNotFoundException {
+      PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+      commentsUtil.deleteCommentByRewritingHistory(
+          ctx.getDb(),
+          ctx.getUpdate(psId),
+          rsrc.getComment().key,
+          rsrc.getPatchSet().getId(),
+          newMessage);
+      return true;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
new file mode 100644
index 0000000..ee57c20
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+
+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;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.DraftCommentResource;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Collections;
+import java.util.Optional;
+
+@Singleton
+public class DeleteDraftComment
+    extends RetryingRestModifyView<DraftCommentResource, Input, Response<CommentInfo>> {
+
+  private final Provider<ReviewDb> db;
+  private final CommentsUtil commentsUtil;
+  private final PatchSetUtil psUtil;
+  private final PatchListCache patchListCache;
+
+  @Inject
+  DeleteDraftComment(
+      Provider<ReviewDb> db,
+      CommentsUtil commentsUtil,
+      PatchSetUtil psUtil,
+      RetryHelper retryHelper,
+      PatchListCache patchListCache) {
+    super(retryHelper);
+    this.db = db;
+    this.commentsUtil = commentsUtil;
+    this.psUtil = psUtil;
+    this.patchListCache = patchListCache;
+  }
+
+  @Override
+  protected Response<CommentInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, DraftCommentResource rsrc, Input input)
+      throws RestApiException, UpdateException {
+    try (BatchUpdate bu =
+        updateFactory.create(
+            db.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      Op op = new Op(rsrc.getComment().key);
+      bu.addOp(rsrc.getChange().getId(), op);
+      bu.execute();
+    }
+    return Response.none();
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final Comment.Key key;
+
+    private Op(Comment.Key key) {
+      this.key = key;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException, OrmException {
+      Optional<Comment> maybeComment =
+          commentsUtil.getDraft(ctx.getDb(), ctx.getNotes(), ctx.getIdentifiedUser(), key);
+      if (!maybeComment.isPresent()) {
+        return false; // Nothing to do.
+      }
+      PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), key.patchSetId);
+      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      if (ps == null) {
+        throw new ResourceNotFoundException("patch set not found: " + psId);
+      }
+      Comment c = maybeComment.get();
+      setCommentRevId(c, patchListCache, ctx.getChange(), ps);
+      commentsUtil.deleteComments(ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(c));
+      ctx.dontBumpLastUpdatedOn();
+      return true;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
new file mode 100644
index 0000000..4ff1b66
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeletePrivate.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.restapi.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeletePrivate
+    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, Response<String>> {
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
+  private final SetPrivateOp.Factory setPrivateOpFactory;
+
+  @Inject
+  DeletePrivate(
+      Provider<ReviewDb> dbProvider,
+      RetryHelper retryHelper,
+      ChangeMessagesUtil cmUtil,
+      PermissionBackend permissionBackend,
+      SetPrivateOp.Factory setPrivateOpFactory) {
+    super(retryHelper);
+    this.dbProvider = dbProvider;
+    this.cmUtil = cmUtil;
+    this.permissionBackend = permissionBackend;
+    this.setPrivateOpFactory = setPrivateOpFactory;
+  }
+
+  @Override
+  protected Response<String> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, SetPrivateOp.Input input)
+      throws RestApiException, UpdateException {
+    if (!canDeletePrivate(rsrc).value()) {
+      throw new AuthException("not allowed to unmark private");
+    }
+
+    if (!rsrc.getChange().isPrivate()) {
+      throw new ResourceConflictException("change is not private");
+    }
+
+    SetPrivateOp op = setPrivateOpFactory.create(cmUtil, false, input);
+    try (BatchUpdate u =
+        updateFactory.create(
+            dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      u.addOp(rsrc.getId(), op).execute();
+    }
+
+    return Response.none();
+  }
+
+  protected BooleanCondition canDeletePrivate(ChangeResource rsrc) {
+    PermissionBackend.WithUser user = permissionBackend.user(rsrc.getUser());
+    return or(rsrc.isUserOwner(), user.testCond(GlobalPermission.ADMINISTRATE_SERVER));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeletePrivateByPost.java b/java/com/google/gerrit/server/restapi/change/DeletePrivateByPost.java
new file mode 100644
index 0000000..cf0143a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeletePrivateByPost.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.restapi.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeletePrivateByPost extends DeletePrivate implements UiAction<ChangeResource> {
+  @Inject
+  DeletePrivateByPost(
+      Provider<ReviewDb> dbProvider,
+      RetryHelper retryHelper,
+      ChangeMessagesUtil cmUtil,
+      PermissionBackend permissionBackend,
+      SetPrivateOp.Factory setPrivateOpFactory) {
+    super(dbProvider, retryHelper, cmUtil, permissionBackend, setPrivateOpFactory);
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Unmark private")
+        .setTitle("Unmark change as private")
+        .setVisible(and(rsrc.getChange().isPrivate(), canDeletePrivate(rsrc)));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
new file mode 100644
index 0000000..3210a95
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeleteReviewer
+    extends RetryingRestModifyView<ReviewerResource, DeleteReviewerInput, Response<?>> {
+
+  private final Provider<ReviewDb> dbProvider;
+  private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
+  private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
+
+  @Inject
+  DeleteReviewer(
+      Provider<ReviewDb> dbProvider,
+      RetryHelper retryHelper,
+      DeleteReviewerOp.Factory deleteReviewerOpFactory,
+      DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory) {
+    super(retryHelper);
+    this.dbProvider = dbProvider;
+    this.deleteReviewerOpFactory = deleteReviewerOpFactory;
+    this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
+  }
+
+  @Override
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ReviewerResource rsrc, DeleteReviewerInput input)
+      throws RestApiException, UpdateException {
+    if (input == null) {
+      input = new DeleteReviewerInput();
+    }
+
+    try (BatchUpdate bu =
+        updateFactory.create(
+            dbProvider.get(),
+            rsrc.getChangeResource().getProject(),
+            rsrc.getChangeResource().getUser(),
+            TimeUtil.nowTs())) {
+      BatchUpdateOp op;
+      if (rsrc.isByEmail()) {
+        op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail(), input);
+      } else {
+        op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().getAccount(), input);
+      }
+      bu.addOp(rsrc.getChange().getId(), op);
+      bu.execute();
+    }
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewerByEmailOp.java
new file mode 100644
index 0000000..f06709d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewerByEmailOp.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collections;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DeleteReviewerByEmailOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(DeleteReviewerByEmailOp.class);
+
+  public interface Factory {
+    DeleteReviewerByEmailOp create(Address reviewer, DeleteReviewerInput input);
+  }
+
+  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final NotifyUtil notifyUtil;
+  private final Address reviewer;
+  private final DeleteReviewerInput input;
+
+  private ChangeMessage changeMessage;
+  private Change change;
+
+  @Inject
+  DeleteReviewerByEmailOp(
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      NotifyUtil notifyUtil,
+      @Assisted Address reviewer,
+      @Assisted DeleteReviewerInput input) {
+    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.notifyUtil = notifyUtil;
+    this.reviewer = reviewer;
+    this.input = input;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws OrmException {
+    change = ctx.getChange();
+    PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+    String msg = "Removed reviewer " + reviewer;
+    changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(change.getId(), ChangeUtil.messageUuid()),
+            ctx.getAccountId(),
+            ctx.getWhen(),
+            psId);
+    changeMessage.setMessage(msg);
+
+    ctx.getUpdate(psId).setChangeMessage(msg);
+    ctx.getUpdate(psId).removeReviewerByEmail(reviewer);
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    if (input.notify == null) {
+      if (change.isWorkInProgress()) {
+        input.notify = NotifyHandling.NONE;
+      } else {
+        input.notify = NotifyHandling.ALL;
+      }
+    }
+    if (!NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
+      return;
+    }
+    try {
+      DeleteReviewerSender cm =
+          deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
+      cm.setFrom(ctx.getAccountId());
+      cm.addReviewersByEmail(Collections.singleton(reviewer));
+      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+      cm.setNotify(input.notify);
+      cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot email update for change " + change.getId(), err);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewerOp.java
new file mode 100644
index 0000000..aac16660
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewerOp.java
@@ -0,0 +1,254 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.extensions.events.ReviewerDeleted;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.RemoveReviewerControl;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdateReviewDb;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DeleteReviewerOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(DeleteReviewerOp.class);
+
+  public interface Factory {
+    DeleteReviewerOp create(Account reviewerAccount, DeleteReviewerInput input);
+  }
+
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final ReviewerDeleted reviewerDeleted;
+  private final Provider<IdentifiedUser> user;
+  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final NotesMigration migration;
+  private final NotifyUtil notifyUtil;
+  private final RemoveReviewerControl removeReviewerControl;
+  private final ProjectCache projectCache;
+
+  private final Account reviewer;
+  private final DeleteReviewerInput input;
+
+  ChangeMessage changeMessage;
+  Change currChange;
+  PatchSet currPs;
+  Map<String, Short> newApprovals = new HashMap<>();
+  Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  DeleteReviewerOp(
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ChangeMessagesUtil cmUtil,
+      IdentifiedUser.GenericFactory userFactory,
+      ReviewerDeleted reviewerDeleted,
+      Provider<IdentifiedUser> user,
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      NotesMigration migration,
+      NotifyUtil notifyUtil,
+      RemoveReviewerControl removeReviewerControl,
+      ProjectCache projectCache,
+      @Assisted Account reviewerAccount,
+      @Assisted DeleteReviewerInput input) {
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.userFactory = userFactory;
+    this.reviewerDeleted = reviewerDeleted;
+    this.user = user;
+    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.migration = migration;
+    this.notifyUtil = notifyUtil;
+    this.removeReviewerControl = removeReviewerControl;
+    this.projectCache = projectCache;
+    this.reviewer = reviewerAccount;
+    this.input = input;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws AuthException, ResourceNotFoundException, OrmException, PermissionBackendException,
+          IOException, NoSuchProjectException {
+    Account.Id reviewerId = reviewer.getId();
+    // Check of removing this reviewer (even if there is no vote processed by the loop below) is OK
+    removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), reviewerId);
+
+    if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all().contains(reviewerId)) {
+      throw new ResourceNotFoundException();
+    }
+    currChange = ctx.getChange();
+    currPs = psUtil.current(ctx.getDb(), ctx.getNotes());
+
+    LabelTypes labelTypes =
+        projectCache.checkedGet(ctx.getProject()).getLabelTypes(ctx.getNotes(), ctx.getUser());
+    // removing a reviewer will remove all her votes
+    for (LabelType lt : labelTypes.getLabelTypes()) {
+      newApprovals.put(lt.getName(), (short) 0);
+    }
+
+    StringBuilder msg = new StringBuilder();
+    msg.append("Removed reviewer " + reviewer.getFullName());
+    StringBuilder removedVotesMsg = new StringBuilder();
+    removedVotesMsg.append(" with the following votes:\n\n");
+    List<PatchSetApproval> del = new ArrayList<>();
+    boolean votesRemoved = false;
+    for (PatchSetApproval a : approvals(ctx, reviewerId)) {
+      // Check if removing this vote is OK
+      removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
+      del.add(a);
+      if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
+        oldApprovals.put(a.getLabel(), a.getValue());
+        removedVotesMsg
+            .append("* ")
+            .append(a.getLabel())
+            .append(formatLabelValue(a.getValue()))
+            .append(" by ")
+            .append(userFactory.create(a.getAccountId()).getNameEmail())
+            .append("\n");
+        votesRemoved = true;
+      }
+    }
+
+    if (votesRemoved) {
+      msg.append(removedVotesMsg);
+    } else {
+      msg.append(".");
+    }
+    ctx.getDb().patchSetApprovals().delete(del);
+    ChangeUpdate update = ctx.getUpdate(currPs.getId());
+    update.removeReviewer(reviewerId);
+
+    changeMessage =
+        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
+    cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
+
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    if (input.notify == null) {
+      if (currChange.isWorkInProgress()) {
+        input.notify = oldApprovals.isEmpty() ? NotifyHandling.NONE : NotifyHandling.OWNER;
+      } else {
+        input.notify = NotifyHandling.ALL;
+      }
+    }
+    if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
+      emailReviewers(ctx.getProject(), currChange, changeMessage);
+    }
+    reviewerDeleted.fire(
+        currChange,
+        currPs,
+        reviewer,
+        ctx.getAccount(),
+        changeMessage.getMessage(),
+        newApprovals,
+        oldApprovals,
+        input.notify,
+        ctx.getWhen());
+  }
+
+  private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId)
+      throws OrmException {
+    Change.Id changeId = ctx.getNotes().getChangeId();
+    Iterable<PatchSetApproval> approvals;
+    PrimaryStorage r = PrimaryStorage.of(ctx.getChange());
+
+    if (migration.readChanges() && r == PrimaryStorage.REVIEW_DB) {
+      // Because NoteDb and ReviewDb have different semantics for zero-value
+      // approvals, we must fall back to ReviewDb as the source of truth here.
+      ReviewDb db = ctx.getDb();
+
+      if (db instanceof BatchUpdateReviewDb) {
+        db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
+      }
+      db = ReviewDbUtil.unwrapDb(db);
+      approvals = db.patchSetApprovals().byChange(changeId);
+    } else {
+      approvals = approvalsUtil.byChange(ctx.getDb(), ctx.getNotes()).values();
+    }
+
+    return Iterables.filter(approvals, psa -> accountId.equals(psa.getAccountId()));
+  }
+
+  private String formatLabelValue(short value) {
+    if (value > 0) {
+      return "+" + value;
+    }
+    return Short.toString(value);
+  }
+
+  private void emailReviewers(
+      Project.NameKey projectName, Change change, ChangeMessage changeMessage) {
+    Account.Id userId = user.get().getAccountId();
+    if (userId.equals(reviewer.getId())) {
+      // The user knows they removed themselves, don't bother emailing them.
+      return;
+    }
+    try {
+      DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
+      cm.setFrom(userId);
+      cm.addReviewers(Collections.singleton(reviewer.getId()));
+      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+      cm.setNotify(input.notify);
+      cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot email update for change " + change.getId(), err);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
new file mode 100644
index 0000000..268425e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -0,0 +1,267 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.change.VoteResource;
+import com.google.gerrit.server.extensions.events.VoteDeleted;
+import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.RemoveReviewerControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class DeleteVote extends RetryingRestModifyView<VoteResource, DeleteVoteInput, Response<?>> {
+  private static final Logger log = LoggerFactory.getLogger(DeleteVote.class);
+
+  private final Provider<ReviewDb> db;
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final VoteDeleted voteDeleted;
+  private final DeleteVoteSender.Factory deleteVoteSenderFactory;
+  private final NotifyUtil notifyUtil;
+  private final RemoveReviewerControl removeReviewerControl;
+  private final ProjectCache projectCache;
+
+  @Inject
+  DeleteVote(
+      Provider<ReviewDb> db,
+      RetryHelper retryHelper,
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ChangeMessagesUtil cmUtil,
+      IdentifiedUser.GenericFactory userFactory,
+      VoteDeleted voteDeleted,
+      DeleteVoteSender.Factory deleteVoteSenderFactory,
+      NotifyUtil notifyUtil,
+      RemoveReviewerControl removeReviewerControl,
+      ProjectCache projectCache) {
+    super(retryHelper);
+    this.db = db;
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.userFactory = userFactory;
+    this.voteDeleted = voteDeleted;
+    this.deleteVoteSenderFactory = deleteVoteSenderFactory;
+    this.notifyUtil = notifyUtil;
+    this.removeReviewerControl = removeReviewerControl;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, VoteResource rsrc, DeleteVoteInput input)
+      throws RestApiException, UpdateException, IOException {
+    if (input == null) {
+      input = new DeleteVoteInput();
+    }
+    if (input.label != null && !rsrc.getLabel().equals(input.label)) {
+      throw new BadRequestException("label must match URL");
+    }
+    if (input.notify == null) {
+      input.notify = NotifyHandling.ALL;
+    }
+    ReviewerResource r = rsrc.getReviewer();
+    Change change = r.getChange();
+
+    if (r.getRevisionResource() != null && !r.getRevisionResource().isCurrent()) {
+      throw new MethodNotAllowedException("Cannot delete vote on non-current patch set");
+    }
+
+    try (BatchUpdate bu =
+        updateFactory.create(
+            db.get(), change.getProject(), r.getChangeResource().getUser(), TimeUtil.nowTs())) {
+      bu.addOp(
+          change.getId(),
+          new Op(
+              projectCache.checkedGet(r.getChange().getProject()),
+              r.getReviewerUser().getAccount(),
+              rsrc.getLabel(),
+              input));
+      bu.execute();
+    }
+
+    return Response.none();
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final ProjectState projectState;
+    private final Account account;
+    private final String label;
+    private final DeleteVoteInput input;
+
+    private ChangeMessage changeMessage;
+    private Change change;
+    private PatchSet ps;
+    private Map<String, Short> newApprovals = new HashMap<>();
+    private Map<String, Short> oldApprovals = new HashMap<>();
+
+    private Op(ProjectState projectState, Account account, String label, DeleteVoteInput input) {
+      this.projectState = projectState;
+      this.account = account;
+      this.label = label;
+      this.input = input;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws OrmException, AuthException, ResourceNotFoundException, IOException,
+            PermissionBackendException, NoSuchProjectException {
+      change = ctx.getChange();
+      PatchSet.Id psId = change.currentPatchSetId();
+      ps = psUtil.current(db.get(), ctx.getNotes());
+
+      boolean found = false;
+      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
+
+      for (PatchSetApproval a :
+          approvalsUtil.byPatchSetUser(
+              ctx.getDb(),
+              ctx.getNotes(),
+              ctx.getUser(),
+              psId,
+              account.getId(),
+              ctx.getRevWalk(),
+              ctx.getRepoView().getConfig())) {
+        if (labelTypes.byLabel(a.getLabelId()) == null) {
+          continue; // Ignore undefined labels.
+        } else if (!a.getLabel().equals(label)) {
+          // Populate map for non-matching labels, needed by VoteDeleted.
+          newApprovals.put(a.getLabel(), a.getValue());
+          continue;
+        } else {
+          try {
+            removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
+          } catch (AuthException e) {
+            throw new AuthException("delete vote not permitted", e);
+          }
+        }
+        // Set the approval to 0 if vote is being removed.
+        newApprovals.put(a.getLabel(), (short) 0);
+        found = true;
+
+        // Set old value, as required by VoteDeleted.
+        oldApprovals.put(a.getLabel(), a.getValue());
+        break;
+      }
+      if (!found) {
+        throw new ResourceNotFoundException();
+      }
+
+      ctx.getUpdate(psId).removeApprovalFor(account.getId(), label);
+      ctx.getDb().patchSetApprovals().upsert(Collections.singleton(deletedApproval(ctx)));
+
+      StringBuilder msg = new StringBuilder();
+      msg.append("Removed ");
+      LabelVote.appendTo(msg, label, checkNotNull(oldApprovals.get(label)));
+      msg.append(" by ").append(userFactory.create(account.getId()).getNameEmail()).append("\n");
+      changeMessage =
+          ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
+      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
+
+      return true;
+    }
+
+    private PatchSetApproval deletedApproval(ChangeContext ctx) {
+      // Set the effective user to the account we're trying to remove, and don't
+      // set the real user; this preserves the calling user as the NoteDb
+      // committer.
+      return new PatchSetApproval(
+          new PatchSetApproval.Key(ps.getId(), account.getId(), new LabelId(label)),
+          (short) 0,
+          ctx.getWhen());
+    }
+
+    @Override
+    public void postUpdate(Context ctx) {
+      if (changeMessage == null) {
+        return;
+      }
+
+      IdentifiedUser user = ctx.getIdentifiedUser();
+      if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
+        try {
+          ReplyToChangeSender cm = deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
+          cm.setFrom(user.getAccountId());
+          cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
+          cm.setNotify(input.notify);
+          cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+          cm.send();
+        } catch (Exception e) {
+          log.error("Cannot email update for change " + change.getId(), e);
+        }
+      }
+
+      voteDeleted.fire(
+          change,
+          ps,
+          account,
+          newApprovals,
+          oldApprovals,
+          input.notify,
+          changeMessage.getMessage(),
+          user.getAccount(),
+          ctx.getWhen());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DownloadContent.java b/java/com/google/gerrit/server/restapi/change/DownloadContent.java
new file mode 100644
index 0000000..b6564c0
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DownloadContent.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.change.FileResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.kohsuke.args4j.Option;
+
+public class DownloadContent implements RestReadView<FileResource> {
+  private final FileContentUtil fileContentUtil;
+  private final ProjectCache projectCache;
+
+  @Option(name = "--parent")
+  private Integer parent;
+
+  @Inject
+  DownloadContent(FileContentUtil fileContentUtil, ProjectCache projectCache) {
+    this.fileContentUtil = fileContentUtil;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public BinaryResult apply(FileResource rsrc)
+      throws ResourceNotFoundException, IOException, NoSuchChangeException, OrmException {
+    String path = rsrc.getPatchKey().get();
+    RevisionResource rev = rsrc.getRevision();
+    ObjectId revstr = ObjectId.fromString(rev.getPatchSet().getRevision().get());
+    return fileContentUtil.downloadContent(
+        projectCache.checkedGet(rev.getProject()), revstr, path, parent);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DraftComments.java b/java/com/google/gerrit/server/restapi/change/DraftComments.java
new file mode 100644
index 0000000..b8e24a5
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DraftComments.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.DraftCommentResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DraftComments implements ChildCollection<RevisionResource, DraftCommentResource> {
+  private final DynamicMap<RestView<DraftCommentResource>> views;
+  private final Provider<CurrentUser> user;
+  private final ListRevisionDrafts list;
+  private final Provider<ReviewDb> dbProvider;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  DraftComments(
+      DynamicMap<RestView<DraftCommentResource>> views,
+      Provider<CurrentUser> user,
+      ListRevisionDrafts list,
+      Provider<ReviewDb> dbProvider,
+      CommentsUtil commentsUtil) {
+    this.views = views;
+    this.user = user;
+    this.list = list;
+    this.dbProvider = dbProvider;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public DynamicMap<RestView<DraftCommentResource>> views() {
+    return views;
+  }
+
+  @Override
+  public ListRevisionDrafts list() throws AuthException {
+    checkIdentifiedUser();
+    return list;
+  }
+
+  @Override
+  public DraftCommentResource parse(RevisionResource rev, IdString id)
+      throws ResourceNotFoundException, OrmException, AuthException {
+    checkIdentifiedUser();
+    String uuid = id.get();
+    for (Comment c :
+        commentsUtil.draftByPatchSetAuthor(
+            dbProvider.get(), rev.getPatchSet().getId(), rev.getAccountId(), rev.getNotes())) {
+      if (uuid.equals(c.key.uuid)) {
+        return new DraftCommentResource(rev, c);
+      }
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  private void checkIdentifiedUser() throws AuthException {
+    if (!(user.get().isIdentifiedUser())) {
+      throw new AuthException("drafts only available to authenticated users");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
new file mode 100644
index 0000000..8d24942
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -0,0 +1,354 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.collect.Lists;
+import com.google.common.hash.Hasher;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.CacheControl;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.ETagView;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gerrit.server.change.AccountPatchReviewStore.PatchSetWithReviewedFiles;
+import com.google.gerrit.server.change.FileInfoJson;
+import com.google.gerrit.server.change.FileResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.change.Revisions;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.PathFilterGroup;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Files implements ChildCollection<RevisionResource, FileResource> {
+  private final DynamicMap<RestView<FileResource>> views;
+  private final Provider<ListFiles> list;
+
+  @Inject
+  Files(DynamicMap<RestView<FileResource>> views, Provider<ListFiles> list) {
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public DynamicMap<RestView<FileResource>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<RevisionResource> list() throws AuthException {
+    return list.get();
+  }
+
+  @Override
+  public FileResource parse(RevisionResource rev, IdString id) {
+    return new FileResource(rev, id.get());
+  }
+
+  public static final class ListFiles implements ETagView<RevisionResource> {
+    private static final Logger log = LoggerFactory.getLogger(ListFiles.class);
+
+    @Option(name = "--base", metaVar = "revision-id")
+    String base;
+
+    @Option(name = "--parent", metaVar = "parent-number")
+    int parentNum;
+
+    @Option(name = "--reviewed")
+    boolean reviewed;
+
+    @Option(name = "-q")
+    String query;
+
+    private final Provider<ReviewDb> db;
+    private final Provider<CurrentUser> self;
+    private final FileInfoJson fileInfoJson;
+    private final Revisions revisions;
+    private final GitRepositoryManager gitManager;
+    private final PatchListCache patchListCache;
+    private final PatchSetUtil psUtil;
+    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
+
+    @Inject
+    ListFiles(
+        Provider<ReviewDb> db,
+        Provider<CurrentUser> self,
+        FileInfoJson fileInfoJson,
+        Revisions revisions,
+        GitRepositoryManager gitManager,
+        PatchListCache patchListCache,
+        PatchSetUtil psUtil,
+        DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
+      this.db = db;
+      this.self = self;
+      this.fileInfoJson = fileInfoJson;
+      this.revisions = revisions;
+      this.gitManager = gitManager;
+      this.patchListCache = patchListCache;
+      this.psUtil = psUtil;
+      this.accountPatchReviewStore = accountPatchReviewStore;
+    }
+
+    public ListFiles setReviewed(boolean r) {
+      this.reviewed = r;
+      return this;
+    }
+
+    @Override
+    public Response<?> apply(RevisionResource resource)
+        throws AuthException, BadRequestException, ResourceNotFoundException, OrmException,
+            RepositoryNotFoundException, IOException, PatchListNotAvailableException,
+            PermissionBackendException {
+      checkOptions();
+      if (reviewed) {
+        return Response.ok(reviewed(resource));
+      } else if (query != null) {
+        return Response.ok(query(resource));
+      }
+
+      Response<Map<String, FileInfo>> r;
+      if (base != null) {
+        RevisionResource baseResource =
+            revisions.parse(resource.getChangeResource(), IdString.fromDecoded(base));
+        r =
+            Response.ok(
+                fileInfoJson.toFileInfoMap(
+                    resource.getChange(),
+                    resource.getPatchSet().getRevision(),
+                    baseResource.getPatchSet()));
+      } else if (parentNum > 0) {
+        r =
+            Response.ok(
+                fileInfoJson.toFileInfoMap(
+                    resource.getChange(), resource.getPatchSet().getRevision(), parentNum - 1));
+      } else {
+        r = Response.ok(fileInfoJson.toFileInfoMap(resource.getChange(), resource.getPatchSet()));
+      }
+
+      if (resource.isCacheable()) {
+        r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+      }
+      return r;
+    }
+
+    private void checkOptions() throws BadRequestException {
+      int supplied = 0;
+      if (base != null) {
+        supplied++;
+      }
+      if (parentNum > 0) {
+        supplied++;
+      }
+      if (reviewed) {
+        supplied++;
+      }
+      if (query != null) {
+        supplied++;
+      }
+      if (supplied > 1) {
+        throw new BadRequestException("cannot combine base, parent, reviewed, query");
+      }
+    }
+
+    private List<String> query(RevisionResource resource)
+        throws RepositoryNotFoundException, IOException {
+      Project.NameKey project = resource.getChange().getProject();
+      try (Repository git = gitManager.openRepository(project);
+          ObjectReader or = git.newObjectReader();
+          RevWalk rw = new RevWalk(or);
+          TreeWalk tw = new TreeWalk(or)) {
+        RevCommit c =
+            rw.parseCommit(ObjectId.fromString(resource.getPatchSet().getRevision().get()));
+
+        tw.addTree(c.getTree());
+        tw.setRecursive(true);
+        List<String> paths = new ArrayList<>();
+        while (tw.next() && paths.size() < 20) {
+          String s = tw.getPathString();
+          if (s.contains(query)) {
+            paths.add(s);
+          }
+        }
+        return paths;
+      }
+    }
+
+    private Collection<String> reviewed(RevisionResource resource)
+        throws AuthException, OrmException {
+      CurrentUser user = self.get();
+      if (!(user.isIdentifiedUser())) {
+        throw new AuthException("Authentication required");
+      }
+
+      Account.Id userId = user.getAccountId();
+      PatchSet patchSetId = resource.getPatchSet();
+      Optional<PatchSetWithReviewedFiles> o =
+          accountPatchReviewStore.get().findReviewed(patchSetId.getId(), userId);
+
+      if (o.isPresent()) {
+        PatchSetWithReviewedFiles res = o.get();
+        if (res.patchSetId().equals(patchSetId.getId())) {
+          return res.files();
+        }
+
+        try {
+          return copy(res.files(), res.patchSetId(), resource, userId);
+        } catch (PatchListObjectTooLargeException e) {
+          log.warn("Cannot copy patch review flags: " + e.getMessage());
+        } catch (IOException | PatchListNotAvailableException e) {
+          log.warn("Cannot copy patch review flags", e);
+        }
+      }
+
+      return Collections.emptyList();
+    }
+
+    private List<String> copy(
+        Set<String> paths, PatchSet.Id old, RevisionResource resource, Account.Id userId)
+        throws IOException, PatchListNotAvailableException, OrmException {
+      Project.NameKey project = resource.getChange().getProject();
+      try (Repository git = gitManager.openRepository(project);
+          ObjectReader reader = git.newObjectReader();
+          RevWalk rw = new RevWalk(reader);
+          TreeWalk tw = new TreeWalk(reader)) {
+        Change change = resource.getChange();
+        PatchSet patchSet = psUtil.get(db.get(), resource.getNotes(), old);
+        if (patchSet == null) {
+          throw new PatchListNotAvailableException(
+              String.format(
+                  "patch set %s of change %s not found", old.get(), change.getId().get()));
+        }
+
+        PatchList oldList = patchListCache.get(change, patchSet);
+
+        PatchList curList = patchListCache.get(change, resource.getPatchSet());
+
+        int sz = paths.size();
+        List<String> pathList = Lists.newArrayListWithCapacity(sz);
+
+        tw.setFilter(PathFilterGroup.createFromStrings(paths));
+        tw.setRecursive(true);
+        int o = tw.addTree(rw.parseCommit(oldList.getNewId()).getTree());
+        int c = tw.addTree(rw.parseCommit(curList.getNewId()).getTree());
+
+        int op = -1;
+        if (oldList.getOldId() != null) {
+          op = tw.addTree(rw.parseTree(oldList.getOldId()));
+        }
+
+        int cp = -1;
+        if (curList.getOldId() != null) {
+          cp = tw.addTree(rw.parseTree(curList.getOldId()));
+        }
+
+        while (tw.next()) {
+          String path = tw.getPathString();
+          if (tw.getRawMode(o) != 0
+              && tw.getRawMode(c) != 0
+              && tw.idEqual(o, c)
+              && paths.contains(path)) {
+            // File exists in previously reviewed oldList and in curList.
+            // File content is identical.
+            pathList.add(path);
+          } else if (op >= 0
+              && cp >= 0
+              && tw.getRawMode(o) == 0
+              && tw.getRawMode(c) == 0
+              && tw.getRawMode(op) != 0
+              && tw.getRawMode(cp) != 0
+              && tw.idEqual(op, cp)
+              && paths.contains(path)) {
+            // File was deleted in previously reviewed oldList and curList.
+            // File exists in ancestor of oldList and curList.
+            // File content is identical in ancestors.
+            pathList.add(path);
+          }
+        }
+        accountPatchReviewStore
+            .get()
+            .markReviewed(resource.getPatchSet().getId(), userId, pathList);
+        return pathList;
+      }
+    }
+
+    public ListFiles setQuery(String query) {
+      this.query = query;
+      return this;
+    }
+
+    public ListFiles setBase(String base) {
+      this.base = base;
+      return this;
+    }
+
+    public ListFiles setParent(int parentNum) {
+      this.parentNum = parentNum;
+      return this;
+    }
+
+    @Override
+    public String getETag(RevisionResource resource) {
+      Hasher h = Hashing.murmur3_128().newHasher();
+      resource.prepareETag(h, resource.getUser());
+      // File list comes from the PatchListCache, so any change to the key or value should
+      // invalidate ETag.
+      h.putLong(PatchListKey.serialVersionUID);
+      return h.hash().toString();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Fixes.java b/java/com/google/gerrit/server/restapi/change/Fixes.java
new file mode 100644
index 0000000..1d8726d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Fixes.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.FixSuggestion;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.change.FixResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Objects;
+
+@Singleton
+public class Fixes implements ChildCollection<RevisionResource, FixResource> {
+
+  private final DynamicMap<RestView<FixResource>> views;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  Fixes(DynamicMap<RestView<FixResource>> views, CommentsUtil commentsUtil) {
+    this.views = views;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public RestView<RevisionResource> list() throws ResourceNotFoundException {
+    throw new ResourceNotFoundException();
+  }
+
+  @Override
+  public FixResource parse(RevisionResource revisionResource, IdString id)
+      throws ResourceNotFoundException, OrmException {
+    String fixId = id.get();
+    ChangeNotes changeNotes = revisionResource.getNotes();
+
+    List<RobotComment> robotComments =
+        commentsUtil.robotCommentsByPatchSet(changeNotes, revisionResource.getPatchSet().getId());
+    for (RobotComment robotComment : robotComments) {
+      for (FixSuggestion fixSuggestion : robotComment.fixSuggestions) {
+        if (Objects.equals(fixId, fixSuggestion.fixId)) {
+          return new FixResource(revisionResource, fixSuggestion.replacements);
+        }
+      }
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  @Override
+  public DynamicMap<RestView<FixResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetArchive.java b/java/com/google/gerrit/server/restapi/change/GetArchive.java
new file mode 100644
index 0000000..1bd1bce
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetArchive.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.ArchiveFormat;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.io.OutputStream;
+import org.eclipse.jgit.api.ArchiveCommand;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+public class GetArchive implements RestReadView<RevisionResource> {
+  private final GitRepositoryManager repoManager;
+  private final AllowedFormats allowedFormats;
+
+  @Option(name = "--format")
+  private String format;
+
+  @Inject
+  GetArchive(GitRepositoryManager repoManager, AllowedFormats allowedFormats) {
+    this.repoManager = repoManager;
+    this.allowedFormats = allowedFormats;
+  }
+
+  @Override
+  public BinaryResult apply(RevisionResource rsrc)
+      throws BadRequestException, IOException, MethodNotAllowedException {
+    if (Strings.isNullOrEmpty(format)) {
+      throw new BadRequestException("format is not specified");
+    }
+    final ArchiveFormat f = allowedFormats.extensions.get("." + format);
+    if (f == null) {
+      throw new BadRequestException("unknown archive format");
+    }
+    if (f == ArchiveFormat.ZIP) {
+      throw new MethodNotAllowedException("zip format is disabled");
+    }
+    boolean close = true;
+    final Repository repo = repoManager.openRepository(rsrc.getProject());
+    try {
+      final RevCommit commit;
+      String name;
+      try (RevWalk rw = new RevWalk(repo)) {
+        commit = rw.parseCommit(ObjectId.fromString(rsrc.getPatchSet().getRevision().get()));
+        name = name(f, rw, commit);
+      }
+
+      BinaryResult bin =
+          new BinaryResult() {
+            @Override
+            public void writeTo(OutputStream out) throws IOException {
+              try {
+                new ArchiveCommand(repo)
+                    .setFormat(f.name())
+                    .setTree(commit.getTree())
+                    .setOutputStream(out)
+                    .call();
+              } catch (GitAPIException e) {
+                throw new IOException(e);
+              }
+            }
+
+            @Override
+            public void close() throws IOException {
+              repo.close();
+            }
+          };
+
+      bin.disableGzip().setContentType(f.getMimeType()).setAttachmentName(name);
+
+      close = false;
+      return bin;
+    } finally {
+      if (close) {
+        repo.close();
+      }
+    }
+  }
+
+  private static String name(ArchiveFormat format, RevWalk rw, RevCommit commit)
+      throws IOException {
+    return String.format(
+        "%s%s", rw.getObjectReader().abbreviate(commit, 7).name(), format.getDefaultSuffix());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetAssignee.java b/java/com/google/gerrit/server/restapi/change/GetAssignee.java
new file mode 100644
index 0000000..f78fae2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetAssignee.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Optional;
+
+@Singleton
+public class GetAssignee implements RestReadView<ChangeResource> {
+  private final AccountLoader.Factory accountLoaderFactory;
+
+  @Inject
+  GetAssignee(AccountLoader.Factory accountLoaderFactory) {
+    this.accountLoaderFactory = accountLoaderFactory;
+  }
+
+  @Override
+  public Response<AccountInfo> apply(ChangeResource rsrc) throws OrmException {
+    Optional<Account.Id> assignee = Optional.ofNullable(rsrc.getChange().getAssignee());
+    if (assignee.isPresent()) {
+      return Response.ok(accountLoaderFactory.create(true).fillOne(assignee.get()));
+    }
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetBlame.java b/java/com/google/gerrit/server/restapi/change/GetBlame.java
new file mode 100644
index 0000000..6bba936
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetBlame.java
@@ -0,0 +1,171 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.extensions.common.BlameInfo;
+import com.google.gerrit.extensions.common.RangeInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.CacheControl;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.FileResource;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.patch.AutoMerger;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gitiles.blame.cache.BlameCache;
+import com.google.gitiles.blame.cache.Region;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+public class GetBlame implements RestReadView<FileResource> {
+
+  private final GitRepositoryManager repoManager;
+  private final BlameCache blameCache;
+  private final boolean allowBlame;
+  private final ThreeWayMergeStrategy mergeStrategy;
+  private final AutoMerger autoMerger;
+
+  @Option(
+    name = "--base",
+    aliases = {"-b"},
+    usage =
+        "whether to load the blame of the base revision (the direct"
+            + " parent of the change) instead of the change"
+  )
+  private boolean base;
+
+  @Inject
+  GetBlame(
+      GitRepositoryManager repoManager,
+      BlameCache blameCache,
+      @GerritServerConfig Config cfg,
+      AutoMerger autoMerger) {
+    this.repoManager = repoManager;
+    this.blameCache = blameCache;
+    this.mergeStrategy = MergeUtil.getMergeStrategy(cfg);
+    this.autoMerger = autoMerger;
+    allowBlame = cfg.getBoolean("change", "allowBlame", true);
+  }
+
+  @Override
+  public Response<List<BlameInfo>> apply(FileResource resource)
+      throws RestApiException, OrmException, IOException, InvalidChangeOperationException {
+    if (!allowBlame) {
+      throw new BadRequestException("blame is disabled");
+    }
+
+    Project.NameKey project = resource.getRevision().getChange().getProject();
+    try (Repository repository = repoManager.openRepository(project);
+        ObjectInserter ins = repository.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk revWalk = new RevWalk(reader)) {
+      String refName =
+          resource.getRevision().getEdit().isPresent()
+              ? resource.getRevision().getEdit().get().getRefName()
+              : resource.getRevision().getPatchSet().getRefName();
+
+      Ref ref = repository.findRef(refName);
+      if (ref == null) {
+        throw new ResourceNotFoundException("unknown ref " + refName);
+      }
+      ObjectId objectId = ref.getObjectId();
+      RevCommit revCommit = revWalk.parseCommit(objectId);
+      RevCommit[] parents = revCommit.getParents();
+
+      String path = resource.getPatchKey().getFileName();
+
+      List<BlameInfo> result;
+      if (!base) {
+        result = blame(revCommit, path, repository, revWalk);
+
+      } else if (parents.length == 0) {
+        throw new ResourceNotFoundException("Initial commit doesn't have base");
+
+      } else if (parents.length == 1) {
+        result = blame(parents[0], path, repository, revWalk);
+
+      } else if (parents.length == 2) {
+        ObjectId automerge = autoMerger.merge(repository, revWalk, ins, revCommit, mergeStrategy);
+        result = blame(automerge, path, repository, revWalk);
+
+      } else {
+        throw new ResourceNotFoundException(
+            "Cannot generate blame for merge commit with more than 2 parents");
+      }
+
+      Response<List<BlameInfo>> r = Response.ok(result);
+      if (resource.isCacheable()) {
+        r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+      }
+      return r;
+    }
+  }
+
+  private List<BlameInfo> blame(ObjectId id, String path, Repository repository, RevWalk revWalk)
+      throws IOException {
+    ListMultimap<BlameInfo, RangeInfo> ranges =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+    List<BlameInfo> result = new ArrayList<>();
+    if (blameCache.findLastCommit(repository, id, path) == null) {
+      return result;
+    }
+
+    List<Region> blameRegions = blameCache.get(repository, id, path);
+    int from = 1;
+    for (Region region : blameRegions) {
+      RevCommit commit = revWalk.parseCommit(region.getSourceCommit());
+      BlameInfo blameInfo = toBlameInfo(commit, region.getSourceAuthor());
+      ranges.put(blameInfo, new RangeInfo(from, from + region.getCount() - 1));
+      from += region.getCount();
+    }
+
+    for (BlameInfo key : ranges.keySet()) {
+      key.ranges = ranges.get(key);
+      result.add(key);
+    }
+    return result;
+  }
+
+  private static BlameInfo toBlameInfo(RevCommit commit, PersonIdent sourceAuthor) {
+    BlameInfo blameInfo = new BlameInfo();
+    blameInfo.author = sourceAuthor.getName();
+    blameInfo.id = commit.getName();
+    blameInfo.commitMsg = commit.getFullMessage();
+    blameInfo.time = commit.getCommitTime();
+    return blameInfo;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
new file mode 100644
index 0000000..a8f8bbb
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.EnumSet;
+import org.kohsuke.args4j.Option;
+
+public class GetChange implements RestReadView<ChangeResource> {
+  private final ChangeJson.Factory json;
+  private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
+
+  @Option(name = "-o", usage = "Output options")
+  void addOption(ListChangesOption o) {
+    options.add(o);
+  }
+
+  @Option(name = "-O", usage = "Output option flags, in hex")
+  void setOptionFlagsHex(String hex) {
+    options.addAll(ListChangesOption.fromBits(Integer.parseInt(hex, 16)));
+  }
+
+  @Inject
+  GetChange(ChangeJson.Factory json) {
+    this.json = json;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ChangeResource rsrc) throws OrmException {
+    return Response.withMustRevalidate(json.create(options).format(rsrc));
+  }
+
+  Response<ChangeInfo> apply(RevisionResource rsrc) throws OrmException {
+    return Response.withMustRevalidate(json.create(options).format(rsrc));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetComment.java b/java/com/google/gerrit/server/restapi/change/GetComment.java
new file mode 100644
index 0000000..b8db6a5
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetComment.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.CommentResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetComment implements RestReadView<CommentResource> {
+
+  private final Provider<CommentJson> commentJson;
+
+  @Inject
+  GetComment(Provider<CommentJson> commentJson) {
+    this.commentJson = commentJson;
+  }
+
+  @Override
+  public CommentInfo apply(CommentResource rsrc) throws OrmException {
+    return commentJson.get().newCommentFormatter().format(rsrc.getComment());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetCommit.java b/java/com/google/gerrit/server/restapi/change/GetCommit.java
new file mode 100644
index 0000000..645d7d1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetCommit.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.CacheControl;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+public class GetCommit implements RestReadView<RevisionResource> {
+  private final GitRepositoryManager repoManager;
+  private final ChangeJson.Factory json;
+
+  private boolean addLinks;
+
+  @Inject
+  GetCommit(GitRepositoryManager repoManager, ChangeJson.Factory json) {
+    this.repoManager = repoManager;
+    this.json = json;
+  }
+
+  @Option(name = "--links", usage = "Include weblinks")
+  public GetCommit setAddLinks(boolean addLinks) {
+    this.addLinks = addLinks;
+    return this;
+  }
+
+  @Override
+  public Response<CommitInfo> apply(RevisionResource rsrc) throws IOException {
+    Project.NameKey p = rsrc.getChange().getProject();
+    try (Repository repo = repoManager.openRepository(p);
+        RevWalk rw = new RevWalk(repo)) {
+      String rev = rsrc.getPatchSet().getRevision().get();
+      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
+      rw.parseBody(commit);
+      CommitInfo info = json.noOptions().toCommit(rsrc.getProject(), rw, commit, addLinks, true);
+      Response<CommitInfo> r = Response.ok(info);
+      if (rsrc.isCacheable()) {
+        r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+      }
+      return r;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetContent.java b/java/com/google/gerrit/server/restapi/change/GetContent.java
new file mode 100644
index 0000000..6b9bf17
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetContent.java
@@ -0,0 +1,125 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.change.FileResource;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+public class GetContent implements RestReadView<FileResource> {
+  private final Provider<ReviewDb> db;
+  private final GitRepositoryManager gitManager;
+  private final PatchSetUtil psUtil;
+  private final FileContentUtil fileContentUtil;
+  private final ProjectCache projectCache;
+
+  @Option(name = "--parent")
+  private Integer parent;
+
+  @Inject
+  GetContent(
+      Provider<ReviewDb> db,
+      GitRepositoryManager gitManager,
+      PatchSetUtil psUtil,
+      FileContentUtil fileContentUtil,
+      ProjectCache projectCache) {
+    this.db = db;
+    this.gitManager = gitManager;
+    this.psUtil = psUtil;
+    this.fileContentUtil = fileContentUtil;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public BinaryResult apply(FileResource rsrc)
+      throws ResourceNotFoundException, IOException, BadRequestException, OrmException {
+    String path = rsrc.getPatchKey().get();
+    if (Patch.COMMIT_MSG.equals(path)) {
+      String msg = getMessage(rsrc.getRevision().getChangeResource().getNotes());
+      return BinaryResult.create(msg)
+          .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
+          .base64();
+    } else if (Patch.MERGE_LIST.equals(path)) {
+      byte[] mergeList = getMergeList(rsrc.getRevision().getChangeResource().getNotes());
+      return BinaryResult.create(mergeList)
+          .setContentType(FileContentUtil.TEXT_X_GERRIT_MERGE_LIST)
+          .base64();
+    }
+    return fileContentUtil.getContent(
+        projectCache.checkedGet(rsrc.getRevision().getProject()),
+        ObjectId.fromString(rsrc.getRevision().getPatchSet().getRevision().get()),
+        path,
+        parent);
+  }
+
+  private String getMessage(ChangeNotes notes) throws OrmException, IOException {
+    Change.Id changeId = notes.getChangeId();
+    PatchSet ps = psUtil.current(db.get(), notes);
+    if (ps == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+
+    try (Repository git = gitManager.openRepository(notes.getProjectName());
+        RevWalk revWalk = new RevWalk(git)) {
+      RevCommit commit = revWalk.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+      return commit.getFullMessage();
+    } catch (RepositoryNotFoundException e) {
+      throw new NoSuchChangeException(changeId, e);
+    }
+  }
+
+  private byte[] getMergeList(ChangeNotes notes) throws OrmException, IOException {
+    Change.Id changeId = notes.getChangeId();
+    PatchSet ps = psUtil.current(db.get(), notes);
+    if (ps == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+
+    try (Repository git = gitManager.openRepository(notes.getProjectName());
+        RevWalk revWalk = new RevWalk(git)) {
+      return Text.forMergeList(
+              ComparisonType.againstAutoMerge(),
+              revWalk.getObjectReader(),
+              ObjectId.fromString(ps.getRevision().get()))
+          .getContent();
+    } catch (RepositoryNotFoundException e) {
+      throw new NoSuchChangeException(changeId, e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetDescription.java b/java/com/google/gerrit/server/restapi/change/GetDescription.java
new file mode 100644
index 0000000..1a7ec63
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetDescription.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetDescription implements RestReadView<RevisionResource> {
+  @Override
+  public String apply(RevisionResource rsrc) {
+    return Strings.nullToEmpty(rsrc.getPatchSet().getDescription());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetDetail.java b/java/com/google/gerrit/server/restapi/change/GetDetail.java
new file mode 100644
index 0000000..ab75ab7
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetDetail.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import org.kohsuke.args4j.Option;
+
+public class GetDetail implements RestReadView<ChangeResource> {
+  private final GetChange delegate;
+
+  @Option(name = "-o", usage = "Output options")
+  void addOption(ListChangesOption o) {
+    delegate.addOption(o);
+  }
+
+  @Option(name = "-O", usage = "Output option flags, in hex")
+  void setOptionFlagsHex(String hex) {
+    delegate.setOptionFlagsHex(hex);
+  }
+
+  @Inject
+  GetDetail(GetChange delegate) {
+    this.delegate = delegate;
+    delegate.addOption(ListChangesOption.LABELS);
+    delegate.addOption(ListChangesOption.DETAILED_LABELS);
+    delegate.addOption(ListChangesOption.DETAILED_ACCOUNTS);
+    delegate.addOption(ListChangesOption.MESSAGES);
+    delegate.addOption(ListChangesOption.REVIEWER_UPDATES);
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ChangeResource rsrc) throws OrmException {
+    return delegate.apply(rsrc);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
new file mode 100644
index 0000000..29ca382
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -0,0 +1,458 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.common.data.PatchScript.DisplayMethod;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.DiffInfo.ContentEntry;
+import com.google.gerrit.extensions.common.DiffInfo.FileMeta;
+import com.google.gerrit.extensions.common.DiffInfo.IntraLineStatus;
+import com.google.gerrit.extensions.common.DiffWebLinkInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.CacheControl;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.prettify.common.SparseFileContent;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.WebLinks;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.change.FileResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.change.Revisions;
+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;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.diff.ReplaceEdit;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.NamedOptionDef;
+import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.OptionDef;
+import org.kohsuke.args4j.spi.OptionHandler;
+import org.kohsuke.args4j.spi.Parameters;
+import org.kohsuke.args4j.spi.Setter;
+
+public class GetDiff implements RestReadView<FileResource> {
+  private static final ImmutableMap<Patch.ChangeType, ChangeType> CHANGE_TYPE =
+      Maps.immutableEnumMap(
+          new ImmutableMap.Builder<Patch.ChangeType, ChangeType>()
+              .put(Patch.ChangeType.ADDED, ChangeType.ADDED)
+              .put(Patch.ChangeType.MODIFIED, ChangeType.MODIFIED)
+              .put(Patch.ChangeType.DELETED, ChangeType.DELETED)
+              .put(Patch.ChangeType.RENAMED, ChangeType.RENAMED)
+              .put(Patch.ChangeType.COPIED, ChangeType.COPIED)
+              .put(Patch.ChangeType.REWRITE, ChangeType.REWRITE)
+              .build());
+
+  private final ProjectCache projectCache;
+  private final PatchScriptFactory.Factory patchScriptFactoryFactory;
+  private final Revisions revisions;
+  private final WebLinks webLinks;
+
+  @Option(name = "--base", metaVar = "REVISION")
+  String base;
+
+  @Option(name = "--parent", metaVar = "parent-number")
+  int parentNum;
+
+  @Deprecated
+  @Option(name = "--ignore-whitespace")
+  IgnoreWhitespace ignoreWhitespace;
+
+  @Option(name = "--whitespace")
+  Whitespace whitespace;
+
+  @Option(name = "--context", handler = ContextOptionHandler.class)
+  int context = DiffPreferencesInfo.DEFAULT_CONTEXT;
+
+  @Option(name = "--intraline")
+  boolean intraline;
+
+  @Option(name = "--weblinks-only")
+  boolean webLinksOnly;
+
+  @Inject
+  GetDiff(
+      ProjectCache projectCache,
+      PatchScriptFactory.Factory patchScriptFactoryFactory,
+      Revisions revisions,
+      WebLinks webLinks) {
+    this.projectCache = projectCache;
+    this.patchScriptFactoryFactory = patchScriptFactoryFactory;
+    this.revisions = revisions;
+    this.webLinks = webLinks;
+  }
+
+  @Override
+  public Response<DiffInfo> apply(FileResource resource)
+      throws ResourceConflictException, ResourceNotFoundException, OrmException, AuthException,
+          InvalidChangeOperationException, IOException, PermissionBackendException {
+    DiffPreferencesInfo prefs = new DiffPreferencesInfo();
+    if (whitespace != null) {
+      prefs.ignoreWhitespace = whitespace;
+    } else if (ignoreWhitespace != null) {
+      prefs.ignoreWhitespace = ignoreWhitespace.whitespace;
+    } else {
+      prefs.ignoreWhitespace = Whitespace.IGNORE_LEADING_AND_TRAILING;
+    }
+    prefs.context = context;
+    prefs.intralineDifference = intraline;
+
+    PatchScriptFactory psf;
+    PatchSet basePatchSet = null;
+    PatchSet.Id pId = resource.getPatchKey().getParentKey();
+    String fileName = resource.getPatchKey().getFileName();
+    ChangeNotes notes = resource.getRevision().getNotes();
+    if (base != null) {
+      RevisionResource baseResource =
+          revisions.parse(resource.getRevision().getChangeResource(), IdString.fromDecoded(base));
+      basePatchSet = baseResource.getPatchSet();
+      psf = patchScriptFactoryFactory.create(notes, fileName, basePatchSet.getId(), pId, prefs);
+    } else if (parentNum > 0) {
+      psf = patchScriptFactoryFactory.create(notes, fileName, parentNum - 1, pId, prefs);
+    } else {
+      psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs);
+    }
+
+    try {
+      psf.setLoadHistory(false);
+      psf.setLoadComments(context != DiffPreferencesInfo.WHOLE_FILE_CONTEXT);
+      PatchScript ps = psf.call();
+      Content content = new Content(ps);
+      Set<Edit> editsDueToRebase = ps.getEditsDueToRebase();
+      for (Edit edit : ps.getEdits()) {
+        if (edit.getType() == Edit.Type.EMPTY) {
+          continue;
+        }
+        content.addCommon(edit.getBeginA());
+
+        checkState(
+            content.nextA == edit.getBeginA(),
+            "nextA = %s; want %s",
+            content.nextA,
+            edit.getBeginA());
+        checkState(
+            content.nextB == edit.getBeginB(),
+            "nextB = %s; want %s",
+            content.nextB,
+            edit.getBeginB());
+        switch (edit.getType()) {
+          case DELETE:
+          case INSERT:
+          case REPLACE:
+            List<Edit> internalEdit =
+                edit instanceof ReplaceEdit ? ((ReplaceEdit) edit).getInternalEdits() : null;
+            boolean dueToRebase = editsDueToRebase.contains(edit);
+            content.addDiff(edit.getEndA(), edit.getEndB(), internalEdit, dueToRebase);
+            break;
+          case EMPTY:
+          default:
+            throw new IllegalStateException();
+        }
+      }
+      content.addCommon(ps.getA().size());
+
+      ProjectState state = projectCache.get(resource.getRevision().getChange().getProject());
+
+      DiffInfo result = new DiffInfo();
+      String revA = basePatchSet != null ? basePatchSet.getRefName() : content.commitIdA;
+      String revB =
+          resource.getRevision().getEdit().isPresent()
+              ? resource.getRevision().getEdit().get().getRefName()
+              : resource.getRevision().getPatchSet().getRefName();
+
+      List<DiffWebLinkInfo> links =
+          webLinks.getDiffLinks(
+              state.getName(),
+              resource.getPatchKey().getParentKey().getParentKey().get(),
+              basePatchSet != null ? basePatchSet.getId().get() : null,
+              revA,
+              MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
+              resource.getPatchKey().getParentKey().get(),
+              revB,
+              ps.getNewName());
+      result.webLinks = links.isEmpty() ? null : links;
+
+      if (!webLinksOnly) {
+        if (ps.isBinary()) {
+          result.binary = true;
+        }
+        if (ps.getDisplayMethodA() != DisplayMethod.NONE) {
+          result.metaA = new FileMeta();
+          result.metaA.name = MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName());
+          result.metaA.contentType =
+              FileContentUtil.resolveContentType(
+                  state, result.metaA.name, ps.getFileModeA(), ps.getMimeTypeA());
+          result.metaA.lines = ps.getA().size();
+          result.metaA.webLinks = getFileWebLinks(state.getProject(), revA, result.metaA.name);
+          result.metaA.commitId = content.commitIdA;
+        }
+
+        if (ps.getDisplayMethodB() != DisplayMethod.NONE) {
+          result.metaB = new FileMeta();
+          result.metaB.name = ps.getNewName();
+          result.metaB.contentType =
+              FileContentUtil.resolveContentType(
+                  state, result.metaB.name, ps.getFileModeB(), ps.getMimeTypeB());
+          result.metaB.lines = ps.getB().size();
+          result.metaB.webLinks = getFileWebLinks(state.getProject(), revB, result.metaB.name);
+          result.metaB.commitId = content.commitIdB;
+        }
+
+        if (intraline) {
+          if (ps.hasIntralineTimeout()) {
+            result.intralineStatus = IntraLineStatus.TIMEOUT;
+          } else if (ps.hasIntralineFailure()) {
+            result.intralineStatus = IntraLineStatus.FAILURE;
+          } else {
+            result.intralineStatus = IntraLineStatus.OK;
+          }
+        }
+
+        result.changeType = CHANGE_TYPE.get(ps.getChangeType());
+        if (result.changeType == null) {
+          throw new IllegalStateException("unknown change type: " + ps.getChangeType());
+        }
+
+        if (ps.getPatchHeader().size() > 0) {
+          result.diffHeader = ps.getPatchHeader();
+        }
+        result.content = content.lines;
+      }
+
+      Response<DiffInfo> r = Response.ok(result);
+      if (resource.isCacheable()) {
+        r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+      }
+      return r;
+    } catch (NoSuchChangeException e) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    } catch (LargeObjectException e) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    }
+  }
+
+  private List<WebLinkInfo> getFileWebLinks(Project project, String rev, String file) {
+    List<WebLinkInfo> links = webLinks.getFileLinks(project.getName(), rev, file);
+    return links.isEmpty() ? null : links;
+  }
+
+  public GetDiff setBase(String base) {
+    this.base = base;
+    return this;
+  }
+
+  public GetDiff setParent(int parentNum) {
+    this.parentNum = parentNum;
+    return this;
+  }
+
+  public GetDiff setContext(int context) {
+    this.context = context;
+    return this;
+  }
+
+  public GetDiff setIntraline(boolean intraline) {
+    this.intraline = intraline;
+    return this;
+  }
+
+  public GetDiff setWhitespace(Whitespace whitespace) {
+    this.whitespace = whitespace;
+    return this;
+  }
+
+  private static class Content {
+    final List<ContentEntry> lines;
+    final SparseFileContent fileA;
+    final SparseFileContent fileB;
+    final boolean ignoreWS;
+    final String commitIdA;
+    final String commitIdB;
+
+    int nextA;
+    int nextB;
+
+    Content(PatchScript ps) {
+      lines = Lists.newArrayListWithExpectedSize(ps.getEdits().size() + 2);
+      fileA = ps.getA();
+      fileB = ps.getB();
+      ignoreWS = ps.isIgnoreWhitespace();
+      commitIdA = ps.getCommitIdA();
+      commitIdB = ps.getCommitIdB();
+    }
+
+    void addCommon(int end) {
+      end = Math.min(end, fileA.size());
+      if (nextA >= end) {
+        return;
+      }
+
+      while (nextA < end) {
+        if (!fileA.contains(nextA)) {
+          int endRegion = Math.min(end, nextA == 0 ? fileA.first() : fileA.next(nextA - 1));
+          int len = endRegion - nextA;
+          entry().skip = len;
+          nextA = endRegion;
+          nextB += len;
+          continue;
+        }
+
+        ContentEntry e = null;
+        for (int i = nextA; i == nextA && i < end; i = fileA.next(i), nextA++, nextB++) {
+          if (ignoreWS && fileB.contains(nextB)) {
+            if (e == null || e.common == null) {
+              e = entry();
+              e.a = Lists.newArrayListWithCapacity(end - nextA);
+              e.b = Lists.newArrayListWithCapacity(end - nextA);
+              e.common = true;
+            }
+            e.a.add(fileA.get(nextA));
+            e.b.add(fileB.get(nextB));
+          } else {
+            if (e == null || e.common != null) {
+              e = entry();
+              e.ab = Lists.newArrayListWithCapacity(end - nextA);
+            }
+            e.ab.add(fileA.get(nextA));
+          }
+        }
+      }
+    }
+
+    void addDiff(int endA, int endB, List<Edit> internalEdit, boolean dueToRebase) {
+      int lenA = endA - nextA;
+      int lenB = endB - nextB;
+      checkState(lenA > 0 || lenB > 0);
+
+      ContentEntry e = entry();
+      if (lenA > 0) {
+        e.a = Lists.newArrayListWithCapacity(lenA);
+        for (; nextA < endA; nextA++) {
+          e.a.add(fileA.get(nextA));
+        }
+      }
+      if (lenB > 0) {
+        e.b = Lists.newArrayListWithCapacity(lenB);
+        for (; nextB < endB; nextB++) {
+          e.b.add(fileB.get(nextB));
+        }
+      }
+      if (internalEdit != null && !internalEdit.isEmpty()) {
+        e.editA = Lists.newArrayListWithCapacity(internalEdit.size() * 2);
+        e.editB = Lists.newArrayListWithCapacity(internalEdit.size() * 2);
+        int lastA = 0;
+        int lastB = 0;
+        for (Edit edit : internalEdit) {
+          if (edit.getBeginA() != edit.getEndA()) {
+            e.editA.add(
+                ImmutableList.of(edit.getBeginA() - lastA, edit.getEndA() - edit.getBeginA()));
+            lastA = edit.getEndA();
+          }
+          if (edit.getBeginB() != edit.getEndB()) {
+            e.editB.add(
+                ImmutableList.of(edit.getBeginB() - lastB, edit.getEndB() - edit.getBeginB()));
+            lastB = edit.getEndB();
+          }
+        }
+      }
+      e.dueToRebase = dueToRebase ? true : null;
+    }
+
+    private ContentEntry entry() {
+      ContentEntry e = new ContentEntry();
+      lines.add(e);
+      return e;
+    }
+  }
+
+  @Deprecated
+  enum IgnoreWhitespace {
+    NONE(DiffPreferencesInfo.Whitespace.IGNORE_NONE),
+    TRAILING(DiffPreferencesInfo.Whitespace.IGNORE_TRAILING),
+    CHANGED(DiffPreferencesInfo.Whitespace.IGNORE_LEADING_AND_TRAILING),
+    ALL(DiffPreferencesInfo.Whitespace.IGNORE_ALL);
+
+    private final DiffPreferencesInfo.Whitespace whitespace;
+
+    IgnoreWhitespace(DiffPreferencesInfo.Whitespace whitespace) {
+      this.whitespace = whitespace;
+    }
+  }
+
+  public static class ContextOptionHandler extends OptionHandler<Short> {
+    public ContextOptionHandler(CmdLineParser parser, OptionDef option, Setter<Short> setter) {
+      super(parser, option, setter);
+    }
+
+    @Override
+    public final int parseArguments(Parameters params) throws CmdLineException {
+      final String value = params.getParameter(0);
+      short context;
+      if ("all".equalsIgnoreCase(value)) {
+        context = DiffPreferencesInfo.WHOLE_FILE_CONTEXT;
+      } else {
+        try {
+          context = Short.parseShort(value, 10);
+          if (context < 0) {
+            throw new NumberFormatException();
+          }
+        } catch (NumberFormatException e) {
+          throw new CmdLineException(
+              owner,
+              String.format(
+                  "\"%s\" is not a valid value for \"%s\"",
+                  value, ((NamedOptionDef) option).name()));
+        }
+      }
+      setter.addValue(context);
+      return 1;
+    }
+
+    @Override
+    public final String getDefaultMetaVariable() {
+      return "ALL|# LINES";
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetDraftComment.java b/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
new file mode 100644
index 0000000..787c93e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.DraftCommentResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetDraftComment implements RestReadView<DraftCommentResource> {
+
+  private final Provider<CommentJson> commentJson;
+
+  @Inject
+  GetDraftComment(Provider<CommentJson> commentJson) {
+    this.commentJson = commentJson;
+  }
+
+  @Override
+  public CommentInfo apply(DraftCommentResource rsrc) throws OrmException {
+    return commentJson.get().newCommentFormatter().format(rsrc.getComment());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetHashtags.java b/java/com/google/gerrit/server/restapi/change/GetHashtags.java
new file mode 100644
index 0000000..8369acf
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetHashtags.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Set;
+
+@Singleton
+public class GetHashtags implements RestReadView<ChangeResource> {
+  @Override
+  public Response<Set<String>> apply(ChangeResource req)
+      throws AuthException, OrmException, IOException, BadRequestException {
+    ChangeNotes notes = req.getNotes().load();
+    Set<String> hashtags = notes.getHashtags();
+    if (hashtags == null) {
+      hashtags = Collections.emptySet();
+    }
+    return Response.ok(hashtags);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetMergeList.java b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
new file mode 100644
index 0000000..2f3b536
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.CacheControl;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.MergeListBuilder;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+public class GetMergeList implements RestReadView<RevisionResource> {
+  private final GitRepositoryManager repoManager;
+  private final ChangeJson.Factory json;
+
+  @Option(name = "--parent", usage = "Uninteresting parent (1-based, default = 1)")
+  private int uninterestingParent = 1;
+
+  @Option(name = "--links", usage = "Include weblinks")
+  private boolean addLinks;
+
+  @Inject
+  GetMergeList(GitRepositoryManager repoManager, ChangeJson.Factory json) {
+    this.repoManager = repoManager;
+    this.json = json;
+  }
+
+  public void setUninterestingParent(int uninterestingParent) {
+    this.uninterestingParent = uninterestingParent;
+  }
+
+  public void setAddLinks(boolean addLinks) {
+    this.addLinks = addLinks;
+  }
+
+  @Override
+  public Response<List<CommitInfo>> apply(RevisionResource rsrc)
+      throws BadRequestException, IOException {
+    Project.NameKey p = rsrc.getChange().getProject();
+    try (Repository repo = repoManager.openRepository(p);
+        RevWalk rw = new RevWalk(repo)) {
+      String rev = rsrc.getPatchSet().getRevision().get();
+      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
+      rw.parseBody(commit);
+
+      if (uninterestingParent < 1 || uninterestingParent > commit.getParentCount()) {
+        throw new BadRequestException("No such parent: " + uninterestingParent);
+      }
+
+      if (commit.getParentCount() < 2) {
+        return createResponse(rsrc, ImmutableList.<CommitInfo>of());
+      }
+
+      List<RevCommit> commits = MergeListBuilder.build(rw, commit, uninterestingParent);
+      List<CommitInfo> result = new ArrayList<>(commits.size());
+      ChangeJson changeJson = json.noOptions();
+      for (RevCommit c : commits) {
+        result.add(changeJson.toCommit(rsrc.getProject(), rw, c, addLinks, true));
+      }
+      return createResponse(rsrc, result);
+    }
+  }
+
+  private static Response<List<CommitInfo>> createResponse(
+      RevisionResource rsrc, List<CommitInfo> result) {
+    Response<List<CommitInfo>> r = Response.ok(result);
+    if (rsrc.isCacheable()) {
+      r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+    }
+    return r;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java b/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
new file mode 100644
index 0000000..354558b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+@Singleton
+public class GetPastAssignees implements RestReadView<ChangeResource> {
+  private final AccountLoader.Factory accountLoaderFactory;
+
+  @Inject
+  GetPastAssignees(AccountLoader.Factory accountLoaderFactory) {
+    this.accountLoaderFactory = accountLoaderFactory;
+  }
+
+  @Override
+  public Response<List<AccountInfo>> apply(ChangeResource rsrc) throws OrmException {
+
+    Set<Account.Id> pastAssignees = rsrc.getNotes().load().getPastAssignees();
+    if (pastAssignees == null) {
+      return Response.ok(Collections.emptyList());
+    }
+
+    AccountLoader accountLoader = accountLoaderFactory.create(true);
+    List<AccountInfo> infos = pastAssignees.stream().map(accountLoader::get).collect(toList());
+    accountLoader.fill();
+    return Response.ok(infos);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java
new file mode 100644
index 0000000..ccad9e0
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -0,0 +1,195 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Locale;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.filter.PathFilter;
+import org.kohsuke.args4j.Option;
+
+public class GetPatch implements RestReadView<RevisionResource> {
+  private final GitRepositoryManager repoManager;
+
+  private final String FILE_NOT_FOUND = "File not found: %s.";
+
+  @Option(name = "--zip")
+  private boolean zip;
+
+  @Option(name = "--download")
+  private boolean download;
+
+  @Option(name = "--path")
+  private String path;
+
+  @Inject
+  GetPatch(GitRepositoryManager repoManager) {
+    this.repoManager = repoManager;
+  }
+
+  @Override
+  public BinaryResult apply(RevisionResource rsrc)
+      throws ResourceConflictException, IOException, ResourceNotFoundException {
+    final Repository repo = repoManager.openRepository(rsrc.getProject());
+    boolean close = true;
+    try {
+      final RevWalk rw = new RevWalk(repo);
+      try {
+        final RevCommit commit =
+            rw.parseCommit(ObjectId.fromString(rsrc.getPatchSet().getRevision().get()));
+        RevCommit[] parents = commit.getParents();
+        if (parents.length > 1) {
+          throw new ResourceConflictException("Revision has more than 1 parent.");
+        } else if (parents.length == 0) {
+          throw new ResourceConflictException("Revision has no parent.");
+        }
+        final RevCommit base = parents[0];
+        rw.parseBody(base);
+
+        BinaryResult bin =
+            new BinaryResult() {
+              @Override
+              public void writeTo(OutputStream out) throws IOException {
+                if (zip) {
+                  ZipOutputStream zos = new ZipOutputStream(out);
+                  ZipEntry e = new ZipEntry(fileName(rw, commit));
+                  e.setTime(commit.getCommitTime() * 1000L);
+                  zos.putNextEntry(e);
+                  format(zos);
+                  zos.closeEntry();
+                  zos.finish();
+                } else {
+                  format(out);
+                }
+              }
+
+              private void format(OutputStream out) throws IOException {
+                // Only add header if no path is specified
+                if (path == null) {
+                  out.write(formatEmailHeader(commit).getBytes(UTF_8));
+                }
+                try (DiffFormatter fmt = new DiffFormatter(out)) {
+                  fmt.setRepository(repo);
+                  if (path != null) {
+                    fmt.setPathFilter(PathFilter.create(path));
+                  }
+                  fmt.format(base.getTree(), commit.getTree());
+                  fmt.flush();
+                }
+              }
+
+              @Override
+              public void close() throws IOException {
+                rw.close();
+                repo.close();
+              }
+            };
+
+        if (path != null && bin.asString().isEmpty()) {
+          throw new ResourceNotFoundException(String.format(FILE_NOT_FOUND, path));
+        }
+
+        if (zip) {
+          bin.disableGzip()
+              .setContentType("application/zip")
+              .setAttachmentName(fileName(rw, commit) + ".zip");
+        } else {
+          bin.base64()
+              .setContentType("application/mbox")
+              .setAttachmentName(download ? fileName(rw, commit) + ".base64" : null);
+        }
+
+        close = false;
+        return bin;
+      } finally {
+        if (close) {
+          rw.close();
+        }
+      }
+    } finally {
+      if (close) {
+        repo.close();
+      }
+    }
+  }
+
+  public GetPatch setPath(String path) {
+    this.path = path;
+    return this;
+  }
+
+  private static String formatEmailHeader(RevCommit commit) {
+    StringBuilder b = new StringBuilder();
+    PersonIdent author = commit.getAuthorIdent();
+    String subject = commit.getShortMessage();
+    String msg = commit.getFullMessage().substring(subject.length());
+    if (msg.startsWith("\n\n")) {
+      msg = msg.substring(2);
+    }
+    b.append("From ")
+        .append(commit.getName())
+        .append(' ')
+        .append(
+            "Mon Sep 17 00:00:00 2001\n") // Fixed timestamp to match output of C Git's format-patch
+        .append("From: ")
+        .append(author.getName())
+        .append(" <")
+        .append(author.getEmailAddress())
+        .append(">\n")
+        .append("Date: ")
+        .append(formatDate(author))
+        .append('\n')
+        .append("Subject: [PATCH] ")
+        .append(subject)
+        .append('\n')
+        .append('\n')
+        .append(msg);
+    if (!msg.endsWith("\n")) {
+      b.append('\n');
+    }
+    return b.append("---\n\n").toString();
+  }
+
+  private static String formatDate(PersonIdent author) {
+    SimpleDateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
+    df.setCalendar(Calendar.getInstance(author.getTimeZone(), Locale.US));
+    return df.format(author.getWhen());
+  }
+
+  private static String fileName(RevWalk rw, RevCommit commit) throws IOException {
+    AbbreviatedObjectId id = rw.getObjectReader().abbreviate(commit, 7);
+    return id.name() + ".diff";
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetPureRevert.java b/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
new file mode 100644
index 0000000..4b26c5c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetPureRevert.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.restapi.change;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.PureRevert;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.kohsuke.args4j.Option;
+
+public class GetPureRevert implements RestReadView<ChangeResource> {
+
+  private final PureRevert pureRevert;
+
+  @Option(
+    name = "--claimed-original",
+    aliases = {"-o"},
+    usage = "SHA1 (40 digit hex) of the original commit"
+  )
+  @Nullable
+  private String claimedOriginal;
+
+  @Inject
+  GetPureRevert(PureRevert pureRevert) {
+    this.pureRevert = pureRevert;
+  }
+
+  @Override
+  public PureRevertInfo apply(ChangeResource rsrc)
+      throws ResourceConflictException, IOException, BadRequestException, OrmException,
+          AuthException {
+    return pureRevert.get(rsrc.getNotes(), claimedOriginal);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetRelated.java b/java/com/google/gerrit/server/restapi/change/GetRelated.java
new file mode 100644
index 0000000..57ec2d9
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetRelated.java
@@ -0,0 +1,211 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommonConverters;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+@Singleton
+public class GetRelated implements RestReadView<RevisionResource> {
+  private final Provider<ReviewDb> db;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final PatchSetUtil psUtil;
+  private final RelatedChangesSorter sorter;
+  private final IndexConfig indexConfig;
+
+  @Inject
+  GetRelated(
+      Provider<ReviewDb> db,
+      Provider<InternalChangeQuery> queryProvider,
+      PatchSetUtil psUtil,
+      RelatedChangesSorter sorter,
+      IndexConfig indexConfig) {
+    this.db = db;
+    this.queryProvider = queryProvider;
+    this.psUtil = psUtil;
+    this.sorter = sorter;
+    this.indexConfig = indexConfig;
+  }
+
+  @Override
+  public RelatedInfo apply(RevisionResource rsrc)
+      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, PermissionBackendException {
+    Set<String> groups = getAllGroups(rsrc.getNotes(), db.get(), psUtil);
+    if (groups.isEmpty()) {
+      return Collections.emptyList();
+    }
+
+    List<ChangeData> cds =
+        InternalChangeQuery.byProjectGroups(
+            queryProvider, indexConfig, rsrc.getChange().getProject(), groups);
+    if (cds.isEmpty()) {
+      return Collections.emptyList();
+    }
+    if (cds.size() == 1 && cds.get(0).getId().equals(rsrc.getChange().getId())) {
+      return Collections.emptyList();
+    }
+    List<ChangeAndCommit> result = new ArrayList<>(cds.size());
+
+    boolean isEdit = rsrc.getEdit().isPresent();
+    PatchSet basePs = isEdit ? rsrc.getEdit().get().getBasePatchSet() : rsrc.getPatchSet();
+
+    reloadChangeIfStale(cds, basePs);
+
+    for (RelatedChangesSorter.PatchSetData d : sorter.sort(cds, basePs, rsrc.getUser())) {
+      PatchSet ps = d.patchSet();
+      RevCommit commit;
+      if (isEdit && ps.getId().equals(basePs.getId())) {
+        // Replace base of an edit with the edit itself.
+        ps = rsrc.getPatchSet();
+        commit = rsrc.getEdit().get().getEditCommit();
+      } else {
+        commit = d.commit();
+      }
+      result.add(new ChangeAndCommit(rsrc.getProject(), d.data().change(), ps, commit));
+    }
+
+    if (result.size() == 1) {
+      ChangeAndCommit r = result.get(0);
+      if (r.commit != null && r.commit.commit.equals(rsrc.getPatchSet().getRevision().get())) {
+        return Collections.emptyList();
+      }
+    }
+    return result;
+  }
+
+  @VisibleForTesting
+  public static Set<String> getAllGroups(ChangeNotes notes, ReviewDb db, PatchSetUtil psUtil)
+      throws OrmException {
+    return psUtil
+        .byChange(db, notes)
+        .stream()
+        .flatMap(ps -> ps.getGroups().stream())
+        .collect(toSet());
+  }
+
+  private void reloadChangeIfStale(List<ChangeData> cds, PatchSet wantedPs) throws OrmException {
+    for (ChangeData cd : cds) {
+      if (cd.getId().equals(wantedPs.getId().getParentKey())) {
+        if (cd.patchSet(wantedPs.getId()) == null) {
+          cd.reloadChange();
+        }
+      }
+    }
+  }
+
+  public static class RelatedInfo {
+    public List<ChangeAndCommit> changes;
+  }
+
+  public static class ChangeAndCommit {
+    public String project;
+    public String changeId;
+    public CommitInfo commit;
+    public Integer _changeNumber;
+    public Integer _revisionNumber;
+    public Integer _currentRevisionNumber;
+    public String status;
+
+    public ChangeAndCommit() {}
+
+    ChangeAndCommit(
+        Project.NameKey project, @Nullable Change change, @Nullable PatchSet ps, RevCommit c) {
+      this.project = project.get();
+
+      if (change != null) {
+        changeId = change.getKey().get();
+        _changeNumber = change.getChangeId();
+        _revisionNumber = ps != null ? ps.getPatchSetId() : null;
+        PatchSet.Id curr = change.currentPatchSetId();
+        _currentRevisionNumber = curr != null ? curr.get() : null;
+        status = change.getStatus().asChangeStatus().toString();
+      }
+
+      commit = new CommitInfo();
+      commit.commit = c.name();
+      commit.parents = Lists.newArrayListWithCapacity(c.getParentCount());
+      for (int i = 0; i < c.getParentCount(); i++) {
+        CommitInfo p = new CommitInfo();
+        p.commit = c.getParent(i).name();
+        commit.parents.add(p);
+      }
+      commit.author = CommonConverters.toGitPerson(c.getAuthorIdent());
+      commit.subject = c.getShortMessage();
+    }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this)
+          .add("project", project)
+          .add("changeId", changeId)
+          .add("commit", toString(commit))
+          .add("_changeNumber", _changeNumber)
+          .add("_revisionNumber", _revisionNumber)
+          .add("_currentRevisionNumber", _currentRevisionNumber)
+          .add("status", status)
+          .toString();
+    }
+
+    private static String toString(CommitInfo commit) {
+      return MoreObjects.toStringHelper(commit)
+          .add("commit", commit.commit)
+          .add("parent", commit.parents)
+          .add("author", commit.author)
+          .add("committer", commit.committer)
+          .add("subject", commit.subject)
+          .add("message", commit.message)
+          .add("webLinks", commit.webLinks)
+          .toString();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetReview.java b/java/com/google/gerrit/server/restapi/change/GetReview.java
new file mode 100644
index 0000000..40e132d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetReview.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetReview implements RestReadView<RevisionResource> {
+  private final GetChange delegate;
+
+  @Inject
+  GetReview(GetChange delegate) {
+    this.delegate = delegate;
+    delegate.addOption(ListChangesOption.DETAILED_LABELS);
+    delegate.addOption(ListChangesOption.DETAILED_ACCOUNTS);
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(RevisionResource rsrc) throws OrmException {
+    return delegate.apply(rsrc);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetReviewer.java b/java/com/google/gerrit/server/restapi/change/GetReviewer.java
new file mode 100644
index 0000000..b9b6b09
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetReviewer.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.List;
+
+@Singleton
+public class GetReviewer implements RestReadView<ReviewerResource> {
+  private final ReviewerJson json;
+
+  @Inject
+  GetReviewer(ReviewerJson json) {
+    this.json = json;
+  }
+
+  @Override
+  public List<ReviewerInfo> apply(ReviewerResource rsrc)
+      throws OrmException, PermissionBackendException {
+    return json.format(rsrc);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
new file mode 100644
index 0000000..1d6d068
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.hash.Hasher;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.restapi.ETagView;
+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.ActionJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.ChangeSet;
+import com.google.gerrit.server.git.MergeSuperSet;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class GetRevisionActions implements ETagView<RevisionResource> {
+  private final ActionJson delegate;
+  private final Config config;
+  private final Provider<ReviewDb> dbProvider;
+  private final Provider<MergeSuperSet> mergeSuperSet;
+  private final ChangeResource.Factory changeResourceFactory;
+
+  @Inject
+  GetRevisionActions(
+      ActionJson delegate,
+      Provider<ReviewDb> dbProvider,
+      Provider<MergeSuperSet> mergeSuperSet,
+      ChangeResource.Factory changeResourceFactory,
+      @GerritServerConfig Config config) {
+    this.delegate = delegate;
+    this.dbProvider = dbProvider;
+    this.mergeSuperSet = mergeSuperSet;
+    this.changeResourceFactory = changeResourceFactory;
+    this.config = config;
+  }
+
+  @Override
+  public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) throws OrmException {
+    return Response.withMustRevalidate(delegate.format(rsrc));
+  }
+
+  @Override
+  public String getETag(RevisionResource rsrc) {
+    Hasher h = Hashing.murmur3_128().newHasher();
+    CurrentUser user = rsrc.getUser();
+    try {
+      rsrc.getChangeResource().prepareETag(h, user);
+      h.putBoolean(MergeSuperSet.wholeTopicEnabled(config));
+      ReviewDb db = dbProvider.get();
+      ChangeSet cs = mergeSuperSet.get().completeChangeSet(db, rsrc.getChange(), user);
+      for (ChangeData cd : cs.changes()) {
+        changeResourceFactory.create(cd.notes(), user).prepareETag(h, user);
+      }
+      h.putBoolean(cs.furtherHiddenChanges());
+    } catch (IOException | OrmException | PermissionBackendException e) {
+      throw new OrmRuntimeException(e);
+    }
+    return h.hash().toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetRobotComment.java b/java/com/google/gerrit/server/restapi/change/GetRobotComment.java
new file mode 100644
index 0000000..bd1f66a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetRobotComment.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.RobotCommentResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetRobotComment implements RestReadView<RobotCommentResource> {
+
+  private final Provider<CommentJson> commentJson;
+
+  @Inject
+  GetRobotComment(Provider<CommentJson> commentJson) {
+    this.commentJson = commentJson;
+  }
+
+  @Override
+  public RobotCommentInfo apply(RobotCommentResource rsrc) throws OrmException {
+    return commentJson.get().newRobotCommentFormatter().format(rsrc.getComment());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetTopic.java b/java/com/google/gerrit/server/restapi/change/GetTopic.java
new file mode 100644
index 0000000..7ab1cb1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetTopic.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetTopic implements RestReadView<ChangeResource> {
+  @Override
+  public String apply(ChangeResource rsrc) {
+    return Strings.nullToEmpty(rsrc.getChange().getTopic());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Ignore.java b/java/com/google/gerrit/server/restapi/change/Ignore.java
new file mode 100644
index 0000000..d710539
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Ignore.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.StarredChangesUtil.MutuallyExclusiveLabelsException;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Ignore implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Ignore.class);
+
+  private final StarredChangesUtil stars;
+
+  @Inject
+  Ignore(StarredChangesUtil stars) {
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Ignore")
+        .setTitle("Ignore the change")
+        .setVisible(canIgnore(rsrc));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, OrmException, IllegalLabelException {
+    try {
+      if (rsrc.isUserOwner()) {
+        throw new BadRequestException("cannot ignore own change");
+      }
+
+      if (!isIgnored(rsrc)) {
+        stars.ignore(rsrc);
+      }
+      return Response.ok("");
+    } catch (MutuallyExclusiveLabelsException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+
+  private boolean canIgnore(ChangeResource rsrc) {
+    return !rsrc.isUserOwner() && !isIgnored(rsrc);
+  }
+
+  private boolean isIgnored(ChangeResource rsrc) {
+    try {
+      return stars.isIgnored(rsrc);
+    } catch (OrmException e) {
+      log.error("failed to check ignored star", e);
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Index.java b/java/com/google/gerrit/server/restapi/change/Index.java
new file mode 100644
index 0000000..55f53a6
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Index.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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.ChangeResource;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class Index extends RetryingRestModifyView<ChangeResource, Input, Response<?>> {
+
+  private final Provider<ReviewDb> db;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final ChangeIndexer indexer;
+
+  @Inject
+  Index(
+      Provider<ReviewDb> db,
+      RetryHelper retryHelper,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      ChangeIndexer indexer) {
+    super(retryHelper);
+    this.db = db;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.indexer = indexer;
+  }
+
+  @Override
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws IOException, AuthException, OrmException, PermissionBackendException {
+    permissionBackend.user(user).check(GlobalPermission.MAINTAIN_SERVER);
+    indexer.index(db.get(), rsrc.getChange());
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
new file mode 100644
index 0000000..37dc207
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.change.ChangeResource;
+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.util.List;
+import java.util.Map;
+
+@Singleton
+public class ListChangeComments implements RestReadView<ChangeResource> {
+  private final Provider<ReviewDb> db;
+  private final ChangeData.Factory changeDataFactory;
+  private final Provider<CommentJson> commentJson;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  ListChangeComments(
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      Provider<CommentJson> commentJson,
+      CommentsUtil commentsUtil) {
+    this.db = db;
+    this.changeDataFactory = changeDataFactory;
+    this.commentJson = commentJson;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public Map<String, List<CommentInfo>> apply(ChangeResource rsrc)
+      throws AuthException, OrmException {
+    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
+    return commentJson
+        .get()
+        .setFillAccounts(true)
+        .setFillPatchSet(true)
+        .newCommentFormatter()
+        .format(commentsUtil.publishedByChange(db.get(), cd.notes()));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
new file mode 100644
index 0000000..d7a102a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.change.ChangeResource;
+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.util.List;
+import java.util.Map;
+
+@Singleton
+public class ListChangeDrafts implements RestReadView<ChangeResource> {
+  private final Provider<ReviewDb> db;
+  private final ChangeData.Factory changeDataFactory;
+  private final Provider<CommentJson> commentJson;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  ListChangeDrafts(
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      Provider<CommentJson> commentJson,
+      CommentsUtil commentsUtil) {
+    this.db = db;
+    this.changeDataFactory = changeDataFactory;
+    this.commentJson = commentJson;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public Map<String, List<CommentInfo>> apply(ChangeResource rsrc)
+      throws AuthException, OrmException {
+    if (!rsrc.getUser().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
+    List<Comment> drafts =
+        commentsUtil.draftByChangeAuthor(db.get(), cd.notes(), rsrc.getUser().getAccountId());
+    return commentJson
+        .get()
+        .setFillAccounts(false)
+        .setFillPatchSet(true)
+        .newCommentFormatter()
+        .format(drafts);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
new file mode 100644
index 0000000..dd8de6f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.List;
+import java.util.Map;
+
+public class ListChangeRobotComments implements RestReadView<ChangeResource> {
+  private final Provider<ReviewDb> db;
+  private final ChangeData.Factory changeDataFactory;
+  private final Provider<CommentJson> commentJson;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  ListChangeRobotComments(
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      Provider<CommentJson> commentJson,
+      CommentsUtil commentsUtil) {
+    this.db = db;
+    this.changeDataFactory = changeDataFactory;
+    this.commentJson = commentJson;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public Map<String, List<RobotCommentInfo>> apply(ChangeResource rsrc)
+      throws AuthException, OrmException {
+    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
+    return commentJson
+        .get()
+        .setFillAccounts(true)
+        .setFillPatchSet(true)
+        .newRobotCommentFormatter()
+        .format(commentsUtil.robotCommentsByChange(cd.notes()));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ListReviewers.java b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
new file mode 100644
index 0000000..750e74f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+class ListReviewers implements RestReadView<ChangeResource> {
+  private final Provider<ReviewDb> dbProvider;
+  private final ApprovalsUtil approvalsUtil;
+  private final ReviewerJson json;
+  private final ReviewerResource.Factory resourceFactory;
+
+  @Inject
+  ListReviewers(
+      Provider<ReviewDb> dbProvider,
+      ApprovalsUtil approvalsUtil,
+      ReviewerResource.Factory resourceFactory,
+      ReviewerJson json) {
+    this.dbProvider = dbProvider;
+    this.approvalsUtil = approvalsUtil;
+    this.resourceFactory = resourceFactory;
+    this.json = json;
+  }
+
+  @Override
+  public List<ReviewerInfo> apply(ChangeResource rsrc)
+      throws OrmException, PermissionBackendException {
+    Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
+    ReviewDb db = dbProvider.get();
+    for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
+      if (!reviewers.containsKey(accountId.toString())) {
+        reviewers.put(accountId.toString(), resourceFactory.create(rsrc, accountId));
+      }
+    }
+    for (Address adr : rsrc.getNotes().getReviewersByEmail().all()) {
+      if (!reviewers.containsKey(adr.toString())) {
+        reviewers.put(adr.toString(), new ReviewerResource(rsrc, adr));
+      }
+    }
+    return json.format(reviewers.values());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
new file mode 100644
index 0000000..964e560
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ListRevisionComments extends ListRevisionDrafts {
+  @Inject
+  ListRevisionComments(
+      Provider<ReviewDb> db, Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
+    super(db, commentJson, commentsUtil);
+  }
+
+  @Override
+  protected boolean includeAuthorInfo() {
+    return true;
+  }
+
+  @Override
+  protected Iterable<Comment> listComments(RevisionResource rsrc) throws OrmException {
+    ChangeNotes notes = rsrc.getNotes();
+    return commentsUtil.publishedByPatchSet(db.get(), notes, rsrc.getPatchSet().getId());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
new file mode 100644
index 0000000..b7dc553
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.change.RevisionResource;
+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.Map;
+
+@Singleton
+public class ListRevisionDrafts implements RestReadView<RevisionResource> {
+  protected final Provider<ReviewDb> db;
+  protected final Provider<CommentJson> commentJson;
+  protected final CommentsUtil commentsUtil;
+
+  @Inject
+  ListRevisionDrafts(
+      Provider<ReviewDb> db, Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
+    this.db = db;
+    this.commentJson = commentJson;
+    this.commentsUtil = commentsUtil;
+  }
+
+  protected Iterable<Comment> listComments(RevisionResource rsrc) throws OrmException {
+    return commentsUtil.draftByPatchSetAuthor(
+        db.get(), rsrc.getPatchSet().getId(), rsrc.getAccountId(), rsrc.getNotes());
+  }
+
+  protected boolean includeAuthorInfo() {
+    return false;
+  }
+
+  @Override
+  public Map<String, List<CommentInfo>> apply(RevisionResource rsrc) throws OrmException {
+    return commentJson
+        .get()
+        .setFillAccounts(includeAuthorInfo())
+        .newCommentFormatter()
+        .format(listComments(rsrc));
+  }
+
+  public List<CommentInfo> getComments(RevisionResource rsrc) throws OrmException {
+    return commentJson
+        .get()
+        .setFillAccounts(includeAuthorInfo())
+        .newCommentFormatter()
+        .formatAsList(listComments(rsrc));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
new file mode 100644
index 0000000..d0630b7
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+class ListRevisionReviewers implements RestReadView<RevisionResource> {
+  private final Provider<ReviewDb> dbProvider;
+  private final ApprovalsUtil approvalsUtil;
+  private final ReviewerJson json;
+  private final ReviewerResource.Factory resourceFactory;
+
+  @Inject
+  ListRevisionReviewers(
+      Provider<ReviewDb> dbProvider,
+      ApprovalsUtil approvalsUtil,
+      ReviewerResource.Factory resourceFactory,
+      ReviewerJson json) {
+    this.dbProvider = dbProvider;
+    this.approvalsUtil = approvalsUtil;
+    this.resourceFactory = resourceFactory;
+    this.json = json;
+  }
+
+  @Override
+  public List<ReviewerInfo> apply(RevisionResource rsrc)
+      throws OrmException, MethodNotAllowedException, PermissionBackendException {
+    if (!rsrc.isCurrent()) {
+      throw new MethodNotAllowedException("Cannot list reviewers on non-current patch set");
+    }
+
+    Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
+    ReviewDb db = dbProvider.get();
+    for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
+      if (!reviewers.containsKey(accountId.toString())) {
+        reviewers.put(accountId.toString(), resourceFactory.create(rsrc, accountId));
+      }
+    }
+    for (Address address : rsrc.getNotes().getReviewersByEmail().all()) {
+      if (!reviewers.containsKey(address.toString())) {
+        reviewers.put(address.toString(), new ReviewerResource(rsrc, address));
+      }
+    }
+    return json.format(reviewers.values());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
new file mode 100644
index 0000000..61219d3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.change.RevisionResource;
+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.Map;
+
+@Singleton
+public class ListRobotComments implements RestReadView<RevisionResource> {
+  protected final Provider<ReviewDb> db;
+  protected final Provider<CommentJson> commentJson;
+  protected final CommentsUtil commentsUtil;
+
+  @Inject
+  ListRobotComments(
+      Provider<ReviewDb> db, Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
+    this.db = db;
+    this.commentJson = commentJson;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public Map<String, List<RobotCommentInfo>> apply(RevisionResource rsrc) throws OrmException {
+    return commentJson
+        .get()
+        .setFillAccounts(true)
+        .newRobotCommentFormatter()
+        .format(listComments(rsrc));
+  }
+
+  public List<RobotCommentInfo> getComments(RevisionResource rsrc) throws OrmException {
+    return commentJson
+        .get()
+        .setFillAccounts(true)
+        .newRobotCommentFormatter()
+        .formatAsList(listComments(rsrc));
+  }
+
+  private Iterable<RobotComment> listComments(RevisionResource rsrc) throws OrmException {
+    return commentsUtil.robotCommentsByPatchSet(rsrc.getNotes(), rsrc.getPatchSet().getId());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java b/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java
new file mode 100644
index 0000000..af64a92
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class MarkAsReviewed
+    implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(MarkAsReviewed.class);
+
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeData.Factory changeDataFactory;
+  private final StarredChangesUtil stars;
+
+  @Inject
+  MarkAsReviewed(
+      Provider<ReviewDb> dbProvider,
+      ChangeData.Factory changeDataFactory,
+      StarredChangesUtil stars) {
+    this.dbProvider = dbProvider;
+    this.changeDataFactory = changeDataFactory;
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Mark Reviewed")
+        .setTitle("Mark the change as reviewed to unhighlight it in the dashboard")
+        .setVisible(!isReviewed(rsrc));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, OrmException, IllegalLabelException {
+    stars.markAsReviewed(rsrc);
+    return Response.ok("");
+  }
+
+  private boolean isReviewed(ChangeResource rsrc) {
+    try {
+      return changeDataFactory
+          .create(dbProvider.get(), rsrc.getNotes())
+          .isReviewedBy(rsrc.getUser().asIdentifiedUser().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check if change is reviewed", e);
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java b/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java
new file mode 100644
index 0000000..2e74d61
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class MarkAsUnreviewed
+    implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(MarkAsUnreviewed.class);
+
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeData.Factory changeDataFactory;
+  private final StarredChangesUtil stars;
+
+  @Inject
+  MarkAsUnreviewed(
+      Provider<ReviewDb> dbProvider,
+      ChangeData.Factory changeDataFactory,
+      StarredChangesUtil stars) {
+    this.dbProvider = dbProvider;
+    this.changeDataFactory = changeDataFactory;
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Mark Unreviewed")
+        .setTitle("Mark the change as unreviewed to highlight it in the dashboard")
+        .setVisible(isReviewed(rsrc));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input)
+      throws OrmException, IllegalLabelException {
+    stars.markAsUnreviewed(rsrc);
+    return Response.ok("");
+  }
+
+  private boolean isReviewed(ChangeResource rsrc) {
+    try {
+      return changeDataFactory
+          .create(dbProvider.get(), rsrc.getNotes())
+          .isReviewedBy(rsrc.getUser().asIdentifiedUser().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check if change is reviewed", e);
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
new file mode 100644
index 0000000..26f755b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -0,0 +1,211 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.MergeableInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.MergeabilityCache;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.BranchOrderSection;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class Mergeable implements RestReadView<RevisionResource> {
+  private static final Logger log = LoggerFactory.getLogger(Mergeable.class);
+
+  @Option(
+    name = "--other-branches",
+    aliases = {"-o"},
+    usage = "test mergeability for other branches too"
+  )
+  private boolean otherBranches;
+
+  private final GitRepositoryManager gitManager;
+  private final ProjectCache projectCache;
+  private final MergeUtil.Factory mergeUtilFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final Provider<ReviewDb> db;
+  private final ChangeIndexer indexer;
+  private final MergeabilityCache cache;
+  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+
+  @Inject
+  Mergeable(
+      GitRepositoryManager gitManager,
+      ProjectCache projectCache,
+      MergeUtil.Factory mergeUtilFactory,
+      ChangeData.Factory changeDataFactory,
+      Provider<ReviewDb> db,
+      ChangeIndexer indexer,
+      MergeabilityCache cache,
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+    this.gitManager = gitManager;
+    this.projectCache = projectCache;
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.changeDataFactory = changeDataFactory;
+    this.db = db;
+    this.indexer = indexer;
+    this.cache = cache;
+    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
+  }
+
+  public void setOtherBranches(boolean otherBranches) {
+    this.otherBranches = otherBranches;
+  }
+
+  @Override
+  public MergeableInfo apply(RevisionResource resource)
+      throws AuthException, ResourceConflictException, BadRequestException, OrmException,
+          IOException {
+    Change change = resource.getChange();
+    PatchSet ps = resource.getPatchSet();
+    MergeableInfo result = new MergeableInfo();
+
+    if (!change.getStatus().isOpen()) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+    } else if (!ps.getId().equals(change.currentPatchSetId())) {
+      // Only the current revision is mergeable. Others always fail.
+      return result;
+    }
+
+    ChangeData cd = changeDataFactory.create(db.get(), resource.getNotes());
+    result.submitType = getSubmitType(resource.getUser(), cd, ps);
+
+    try (Repository git = gitManager.openRepository(change.getProject())) {
+      ObjectId commit = toId(ps);
+      Ref ref = git.getRefDatabase().exactRef(change.getDest().get());
+      ProjectState projectState = projectCache.get(change.getProject());
+      String strategy = mergeUtilFactory.create(projectState).mergeStrategyName();
+      result.strategy = strategy;
+      result.mergeable = isMergable(git, change, commit, ref, result.submitType, strategy);
+
+      if (otherBranches) {
+        result.mergeableInto = new ArrayList<>();
+        BranchOrderSection branchOrder = projectState.getBranchOrderSection();
+        if (branchOrder != null) {
+          int prefixLen = Constants.R_HEADS.length();
+          String[] names = branchOrder.getMoreStable(ref.getName());
+          Map<String, Ref> refs = git.getRefDatabase().exactRef(names);
+          for (String n : names) {
+            Ref other = refs.get(n);
+            if (other == null) {
+              continue;
+            }
+            if (cache.get(commit, other, SubmitType.CHERRY_PICK, strategy, change.getDest(), git)) {
+              result.mergeableInto.add(other.getName().substring(prefixLen));
+            }
+          }
+        }
+      }
+    }
+    return result;
+  }
+
+  private SubmitType getSubmitType(CurrentUser user, ChangeData cd, PatchSet patchSet)
+      throws OrmException {
+    SubmitTypeRecord rec =
+        submitRuleEvaluatorFactory.create(user, cd).setPatchSet(patchSet).getSubmitType();
+    if (rec.status != SubmitTypeRecord.Status.OK) {
+      throw new OrmException("Submit type rule failed: " + rec);
+    }
+    return rec.type;
+  }
+
+  private boolean isMergable(
+      Repository git,
+      Change change,
+      ObjectId commit,
+      Ref ref,
+      SubmitType submitType,
+      String strategy)
+      throws IOException, OrmException {
+    if (commit == null) {
+      return false;
+    }
+
+    Boolean old = cache.getIfPresent(commit, ref, submitType, strategy);
+    if (old != null) {
+      return old;
+    }
+    return refresh(change, commit, ref, submitType, strategy, git, old);
+  }
+
+  private static ObjectId toId(PatchSet ps) {
+    try {
+      return ObjectId.fromString(ps.getRevision().get());
+    } catch (IllegalArgumentException e) {
+      log.error("Invalid revision on patch set " + ps);
+      return null;
+    }
+  }
+
+  private boolean refresh(
+      final Change change,
+      ObjectId commit,
+      final Ref ref,
+      SubmitType type,
+      String strategy,
+      Repository git,
+      Boolean old)
+      throws OrmException, IOException {
+    final boolean mergeable = cache.get(commit, ref, type, strategy, change.getDest(), git);
+    if (!Objects.equals(mergeable, old)) {
+      invalidateETag(change.getId(), db.get());
+      indexer.index(db.get(), change);
+    }
+    return mergeable;
+  }
+
+  private static void invalidateETag(Change.Id id, ReviewDb db) throws OrmException {
+    // Empty update of Change to bump rowVersion, changing its ETag.
+    // TODO(dborowitz): Include cache info in ETag somehow instead.
+    db = ReviewDbUtil.unwrapDb(db);
+    Change c = db.changes().get(id);
+    if (c != null) {
+      db.changes().update(Collections.singleton(c));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
new file mode 100644
index 0000000..c18a8c5c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -0,0 +1,195 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.change.ChangeEditResource.CHANGE_EDIT_KIND;
+import static com.google.gerrit.server.change.ChangeResource.CHANGE_KIND;
+import static com.google.gerrit.server.change.CommentResource.COMMENT_KIND;
+import static com.google.gerrit.server.change.DraftCommentResource.DRAFT_COMMENT_KIND;
+import static com.google.gerrit.server.change.FileResource.FILE_KIND;
+import static com.google.gerrit.server.change.FixResource.FIX_KIND;
+import static com.google.gerrit.server.change.ReviewerResource.REVIEWER_KIND;
+import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
+import static com.google.gerrit.server.change.RobotCommentResource.ROBOT_COMMENT_KIND;
+import static com.google.gerrit.server.change.VoteResource.VOTE_KIND;
+import static com.google.gerrit.server.project.CommitResource.COMMIT_KIND;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.change.Revisions;
+import com.google.gerrit.server.change.SetAssigneeOp;
+import com.google.gerrit.server.change.SetHashtagsOp;
+import com.google.gerrit.server.change.WorkInProgressOp;
+import com.google.gerrit.server.restapi.change.Reviewed.DeleteReviewed;
+import com.google.gerrit.server.restapi.change.Reviewed.PutReviewed;
+
+public class Module extends RestApiModule {
+  @Override
+  protected void configure() {
+    bind(ChangesCollection.class);
+    bind(Revisions.class);
+    bind(Reviewers.class);
+    bind(RevisionReviewers.class);
+    bind(DraftComments.class);
+    bind(Comments.class);
+    bind(RobotComments.class);
+    bind(Fixes.class);
+    bind(Files.class);
+    bind(Votes.class);
+
+    DynamicMap.mapOf(binder(), CHANGE_KIND);
+    DynamicMap.mapOf(binder(), COMMENT_KIND);
+    DynamicMap.mapOf(binder(), ROBOT_COMMENT_KIND);
+    DynamicMap.mapOf(binder(), FIX_KIND);
+    DynamicMap.mapOf(binder(), DRAFT_COMMENT_KIND);
+    DynamicMap.mapOf(binder(), FILE_KIND);
+    DynamicMap.mapOf(binder(), REVIEWER_KIND);
+    DynamicMap.mapOf(binder(), REVISION_KIND);
+    DynamicMap.mapOf(binder(), CHANGE_EDIT_KIND);
+    DynamicMap.mapOf(binder(), VOTE_KIND);
+
+    get(CHANGE_KIND).to(GetChange.class);
+    post(CHANGE_KIND, "merge").to(CreateMergePatchSet.class);
+    get(CHANGE_KIND, "detail").to(GetDetail.class);
+    get(CHANGE_KIND, "topic").to(GetTopic.class);
+    get(CHANGE_KIND, "in").to(ChangeIncludedIn.class);
+    get(CHANGE_KIND, "assignee").to(GetAssignee.class);
+    get(CHANGE_KIND, "past_assignees").to(GetPastAssignees.class);
+    put(CHANGE_KIND, "assignee").to(PutAssignee.class);
+    delete(CHANGE_KIND, "assignee").to(DeleteAssignee.class);
+    get(CHANGE_KIND, "hashtags").to(GetHashtags.class);
+    get(CHANGE_KIND, "comments").to(ListChangeComments.class);
+    get(CHANGE_KIND, "robotcomments").to(ListChangeRobotComments.class);
+    get(CHANGE_KIND, "drafts").to(ListChangeDrafts.class);
+    get(CHANGE_KIND, "check").to(Check.class);
+    get(CHANGE_KIND, "pure_revert").to(GetPureRevert.class);
+    post(CHANGE_KIND, "check").to(Check.class);
+    put(CHANGE_KIND, "topic").to(PutTopic.class);
+    delete(CHANGE_KIND, "topic").to(PutTopic.class);
+    delete(CHANGE_KIND).to(DeleteChange.class);
+    post(CHANGE_KIND, "abandon").to(Abandon.class);
+    post(CHANGE_KIND, "hashtags").to(PostHashtags.class);
+    post(CHANGE_KIND, "restore").to(Restore.class);
+    post(CHANGE_KIND, "revert").to(Revert.class);
+    post(CHANGE_KIND, "submit").to(Submit.CurrentRevision.class);
+    get(CHANGE_KIND, "submitted_together").to(SubmittedTogether.class);
+    post(CHANGE_KIND, "rebase").to(Rebase.CurrentRevision.class);
+    post(CHANGE_KIND, "index").to(Index.class);
+    post(CHANGE_KIND, "rebuild.notedb").to(Rebuild.class);
+    post(CHANGE_KIND, "move").to(Move.class);
+    post(CHANGE_KIND, "private").to(PostPrivate.class);
+    post(CHANGE_KIND, "private.delete").to(DeletePrivateByPost.class);
+    delete(CHANGE_KIND, "private").to(DeletePrivate.class);
+    put(CHANGE_KIND, "ignore").to(Ignore.class);
+    put(CHANGE_KIND, "unignore").to(Unignore.class);
+    put(CHANGE_KIND, "reviewed").to(MarkAsReviewed.class);
+    put(CHANGE_KIND, "unreviewed").to(MarkAsUnreviewed.class);
+    post(CHANGE_KIND, "wip").to(SetWorkInProgress.class);
+    post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
+    put(CHANGE_KIND, "message").to(PutMessage.class);
+
+    post(CHANGE_KIND, "reviewers").to(PostReviewers.class);
+    get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
+    child(CHANGE_KIND, "reviewers").to(Reviewers.class);
+    get(REVIEWER_KIND).to(GetReviewer.class);
+    delete(REVIEWER_KIND).to(DeleteReviewer.class);
+    post(REVIEWER_KIND, "delete").to(DeleteReviewer.class);
+    child(REVIEWER_KIND, "votes").to(Votes.class);
+    delete(VOTE_KIND).to(DeleteVote.class);
+    post(VOTE_KIND, "delete").to(DeleteVote.class);
+
+    child(CHANGE_KIND, "revisions").to(Revisions.class);
+    get(REVISION_KIND, "actions").to(GetRevisionActions.class);
+    post(REVISION_KIND, "cherrypick").to(CherryPick.class);
+    get(REVISION_KIND, "commit").to(GetCommit.class);
+    get(REVISION_KIND, "mergeable").to(Mergeable.class);
+    get(REVISION_KIND, "related").to(GetRelated.class);
+    get(REVISION_KIND, "review").to(GetReview.class);
+    post(REVISION_KIND, "review").to(PostReview.class);
+    get(REVISION_KIND, "preview_submit").to(PreviewSubmit.class);
+    post(REVISION_KIND, "submit").to(Submit.class);
+    post(REVISION_KIND, "rebase").to(Rebase.class);
+    put(REVISION_KIND, "description").to(PutDescription.class);
+    get(REVISION_KIND, "description").to(GetDescription.class);
+    get(REVISION_KIND, "patch").to(GetPatch.class);
+    get(REVISION_KIND, "submit_type").to(TestSubmitType.Get.class);
+    post(REVISION_KIND, "test.submit_rule").to(TestSubmitRule.class);
+    post(REVISION_KIND, "test.submit_type").to(TestSubmitType.class);
+    get(REVISION_KIND, "archive").to(GetArchive.class);
+    get(REVISION_KIND, "mergelist").to(GetMergeList.class);
+
+    child(REVISION_KIND, "reviewers").to(RevisionReviewers.class);
+
+    child(REVISION_KIND, "drafts").to(DraftComments.class);
+    put(REVISION_KIND, "drafts").to(CreateDraftComment.class);
+    get(DRAFT_COMMENT_KIND).to(GetDraftComment.class);
+    put(DRAFT_COMMENT_KIND).to(PutDraftComment.class);
+    delete(DRAFT_COMMENT_KIND).to(DeleteDraftComment.class);
+
+    child(REVISION_KIND, "comments").to(Comments.class);
+    get(COMMENT_KIND).to(GetComment.class);
+    delete(COMMENT_KIND).to(DeleteComment.class);
+    post(COMMENT_KIND, "delete").to(DeleteComment.class);
+
+    child(REVISION_KIND, "robotcomments").to(RobotComments.class);
+    get(ROBOT_COMMENT_KIND).to(GetRobotComment.class);
+    child(REVISION_KIND, "fixes").to(Fixes.class);
+    post(FIX_KIND, "apply").to(ApplyFix.class);
+
+    child(REVISION_KIND, "files").to(Files.class);
+    put(FILE_KIND, "reviewed").to(PutReviewed.class);
+    delete(FILE_KIND, "reviewed").to(DeleteReviewed.class);
+    get(FILE_KIND, "content").to(GetContent.class);
+    get(FILE_KIND, "download").to(DownloadContent.class);
+    get(FILE_KIND, "diff").to(GetDiff.class);
+    get(FILE_KIND, "blame").to(GetBlame.class);
+
+    child(CHANGE_KIND, "edit").to(ChangeEdits.class);
+    delete(CHANGE_KIND, "edit").to(DeleteChangeEdit.class);
+    child(CHANGE_KIND, "edit:publish").to(PublishChangeEdit.class);
+    child(CHANGE_KIND, "edit:rebase").to(RebaseChangeEdit.class);
+    put(CHANGE_KIND, "edit:message").to(ChangeEdits.EditMessage.class);
+    get(CHANGE_KIND, "edit:message").to(ChangeEdits.GetMessage.class);
+    put(CHANGE_EDIT_KIND, "/").to(ChangeEdits.Put.class);
+    delete(CHANGE_EDIT_KIND).to(ChangeEdits.DeleteContent.class);
+    get(CHANGE_EDIT_KIND, "/").to(ChangeEdits.Get.class);
+    get(CHANGE_EDIT_KIND, "meta").to(ChangeEdits.GetMeta.class);
+    post(COMMIT_KIND, "cherrypick").to(CherryPickCommit.class);
+
+    factory(AccountLoader.Factory.class);
+    factory(ChangeEdits.Create.Factory.class);
+    factory(ChangeEdits.DeleteFile.Factory.class);
+    factory(ChangeInserter.Factory.class);
+    factory(ChangeResource.Factory.class);
+    factory(DeleteReviewerByEmailOp.Factory.class);
+    factory(DeleteReviewerOp.Factory.class);
+    factory(EmailReviewComments.Factory.class);
+    factory(PatchSetInserter.Factory.class);
+    factory(PostReviewersOp.Factory.class);
+    factory(RebaseChangeOp.Factory.class);
+    factory(ReviewerResource.Factory.class);
+    factory(SetAssigneeOp.Factory.class);
+    factory(SetHashtagsOp.Factory.class);
+    factory(SetPrivateOp.Factory.class);
+    factory(WorkInProgressOp.Factory.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
new file mode 100644
index 0000000..2607f9c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -0,0 +1,290 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.server.permissions.ChangePermission.ABANDON;
+import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
+import static com.google.gerrit.server.query.change.ChangeData.asChanges;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.api.changes.MoveInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+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;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class Move extends RetryingRestModifyView<ChangeResource, MoveInput, ChangeInfo>
+    implements UiAction<ChangeResource> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeJson.Factory json;
+  private final GitRepositoryManager repoManager;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
+  private final ApprovalsUtil approvalsUtil;
+  private final ProjectCache projectCache;
+  private final Provider<CurrentUser> userProvider;
+
+  @Inject
+  Move(
+      PermissionBackend permissionBackend,
+      Provider<ReviewDb> dbProvider,
+      ChangeJson.Factory json,
+      GitRepositoryManager repoManager,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeMessagesUtil cmUtil,
+      RetryHelper retryHelper,
+      PatchSetUtil psUtil,
+      ApprovalsUtil approvalsUtil,
+      ProjectCache projectCache,
+      Provider<CurrentUser> userProvider) {
+    super(retryHelper);
+    this.permissionBackend = permissionBackend;
+    this.dbProvider = dbProvider;
+    this.json = json;
+    this.repoManager = repoManager;
+    this.queryProvider = queryProvider;
+    this.cmUtil = cmUtil;
+    this.psUtil = psUtil;
+    this.approvalsUtil = approvalsUtil;
+    this.projectCache = projectCache;
+    this.userProvider = userProvider;
+  }
+
+  @Override
+  protected ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, MoveInput input)
+      throws RestApiException, OrmException, UpdateException, PermissionBackendException {
+    Change change = rsrc.getChange();
+    Project.NameKey project = rsrc.getProject();
+    IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
+    input.destinationBranch = RefNames.fullName(input.destinationBranch);
+
+    if (change.getStatus().isClosed()) {
+      throw new ResourceConflictException("Change is " + ChangeUtil.status(change));
+    }
+
+    Branch.NameKey newDest = new Branch.NameKey(project, input.destinationBranch);
+    if (change.getDest().equals(newDest)) {
+      throw new ResourceConflictException("Change is already destined for the specified branch");
+    }
+
+    // Move requires abandoning this change, and creating a new change.
+    try {
+      rsrc.permissions().database(dbProvider).check(ABANDON);
+      permissionBackend.user(caller).database(dbProvider).ref(newDest).check(CREATE_CHANGE);
+    } catch (AuthException denied) {
+      throw new AuthException("move not permitted", denied);
+    }
+
+    try (BatchUpdate u =
+        updateFactory.create(dbProvider.get(), project, caller, TimeUtil.nowTs())) {
+      u.addOp(change.getId(), new Op(input));
+      u.execute();
+    }
+    return json.noOptions().format(project, rsrc.getId());
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final MoveInput input;
+
+    private Change change;
+    private Branch.NameKey newDestKey;
+
+    Op(MoveInput input) {
+      this.input = input;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws OrmException, ResourceConflictException, IOException {
+      change = ctx.getChange();
+      if (change.getStatus() != Status.NEW) {
+        throw new ResourceConflictException("Change is " + ChangeUtil.status(change));
+      }
+
+      Project.NameKey projectKey = change.getProject();
+      newDestKey = new Branch.NameKey(projectKey, input.destinationBranch);
+      Branch.NameKey changePrevDest = change.getDest();
+      if (changePrevDest.equals(newDestKey)) {
+        throw new ResourceConflictException("Change is already destined for the specified branch");
+      }
+
+      final PatchSet.Id patchSetId = change.currentPatchSetId();
+      try (Repository repo = repoManager.openRepository(projectKey);
+          RevWalk revWalk = new RevWalk(repo)) {
+        RevCommit currPatchsetRevCommit =
+            revWalk.parseCommit(
+                ObjectId.fromString(
+                    psUtil.current(ctx.getDb(), ctx.getNotes()).getRevision().get()));
+        if (currPatchsetRevCommit.getParentCount() > 1) {
+          throw new ResourceConflictException("Merge commit cannot be moved");
+        }
+
+        ObjectId refId = repo.resolve(input.destinationBranch);
+        // Check if destination ref exists in project repo
+        if (refId == null) {
+          throw new ResourceConflictException(
+              "Destination " + input.destinationBranch + " not found in the project");
+        }
+        RevCommit refCommit = revWalk.parseCommit(refId);
+        if (revWalk.isMergedInto(currPatchsetRevCommit, refCommit)) {
+          throw new ResourceConflictException(
+              "Current patchset revision is reachable from tip of " + input.destinationBranch);
+        }
+      }
+
+      Change.Key changeKey = change.getKey();
+      if (!asChanges(queryProvider.get().byBranchKey(newDestKey, changeKey)).isEmpty()) {
+        throw new ResourceConflictException(
+            "Destination "
+                + newDestKey.getShortName()
+                + " has a different change with same change key "
+                + changeKey);
+      }
+
+      if (!change.currentPatchSetId().equals(patchSetId)) {
+        throw new ResourceConflictException("Patch set is not current");
+      }
+
+      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());
+      msgBuf.append(" to ");
+      msgBuf.append(newDestKey.getShortName());
+      if (!Strings.isNullOrEmpty(input.message)) {
+        msgBuf.append("\n\n");
+        msgBuf.append(input.message);
+      }
+      ChangeMessage cmsg =
+          ChangeMessagesUtil.newMessage(ctx, msgBuf.toString(), ChangeMessagesUtil.TAG_MOVE);
+      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+
+      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
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
+    return new UiAction.Description()
+        .setLabel("Move Change")
+        .setTitle("Move change to a different branch")
+        .setVisible(
+            and(
+                change.getStatus().isOpen(),
+                and(
+                    permissionBackend
+                        .user(rsrc.getUser())
+                        .ref(change.getDest())
+                        .testCond(CREATE_CHANGE),
+                    rsrc.permissions().database(dbProvider).testCond(ABANDON))));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostHashtags.java b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
new file mode 100644
index 0000000..f31d04e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetHashtagsOp;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class PostHashtags
+    extends RetryingRestModifyView<
+        ChangeResource, HashtagsInput, Response<ImmutableSortedSet<String>>>
+    implements UiAction<ChangeResource> {
+  private final Provider<ReviewDb> db;
+  private final SetHashtagsOp.Factory hashtagsFactory;
+
+  @Inject
+  PostHashtags(
+      Provider<ReviewDb> db, RetryHelper retryHelper, SetHashtagsOp.Factory hashtagsFactory) {
+    super(retryHelper);
+    this.db = db;
+    this.hashtagsFactory = hashtagsFactory;
+  }
+
+  @Override
+  protected Response<ImmutableSortedSet<String>> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource req, HashtagsInput input)
+      throws RestApiException, UpdateException, PermissionBackendException {
+    req.permissions().check(ChangePermission.EDIT_HASHTAGS);
+
+    try (BatchUpdate bu =
+        updateFactory.create(
+            db.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
+      SetHashtagsOp op = hashtagsFactory.create(input);
+      bu.addOp(req.getId(), op);
+      bu.execute();
+      return Response.<ImmutableSortedSet<String>>ok(op.getUpdatedHashtags());
+    }
+  }
+
+  @Override
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Edit Hashtags")
+        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_HASHTAGS));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostPrivate.java b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
new file mode 100644
index 0000000..5a13346
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class PostPrivate
+    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, Response<String>>
+    implements UiAction<ChangeResource> {
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
+  private final SetPrivateOp.Factory setPrivateOpFactory;
+  private final boolean disablePrivateChanges;
+
+  @Inject
+  PostPrivate(
+      Provider<ReviewDb> dbProvider,
+      RetryHelper retryHelper,
+      ChangeMessagesUtil cmUtil,
+      PermissionBackend permissionBackend,
+      SetPrivateOp.Factory setPrivateOpFactory,
+      @GerritServerConfig Config config) {
+    super(retryHelper);
+    this.dbProvider = dbProvider;
+    this.cmUtil = cmUtil;
+    this.permissionBackend = permissionBackend;
+    this.setPrivateOpFactory = setPrivateOpFactory;
+    this.disablePrivateChanges = config.getBoolean("change", null, "disablePrivateChanges", false);
+  }
+
+  @Override
+  public Response<String> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, SetPrivateOp.Input input)
+      throws RestApiException, UpdateException {
+    if (disablePrivateChanges) {
+      throw new MethodNotAllowedException("private changes are disabled");
+    }
+
+    if (!canSetPrivate(rsrc).value()) {
+      throw new AuthException("not allowed to mark private");
+    }
+
+    if (rsrc.getChange().isPrivate()) {
+      return Response.ok("");
+    }
+
+    SetPrivateOp op = setPrivateOpFactory.create(cmUtil, true, input);
+    try (BatchUpdate u =
+        updateFactory.create(
+            dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      u.addOp(rsrc.getId(), op).execute();
+    }
+
+    return Response.created("");
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
+    return new UiAction.Description()
+        .setLabel("Mark private")
+        .setTitle("Mark change as private")
+        .setVisible(and(!disablePrivateChanges && !change.isPrivate(), canSetPrivate(rsrc)));
+  }
+
+  private BooleanCondition canSetPrivate(ChangeResource rsrc) {
+    PermissionBackend.WithUser user = permissionBackend.user(rsrc.getUser());
+    return or(
+        rsrc.isUserOwner() && rsrc.getChange().getStatus() != Change.Status.MERGED,
+        user.testCond(GlobalPermission.ADMINISTRATE_SERVER));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
new file mode 100644
index 0000000..c2c5dce
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -0,0 +1,1356 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.groupingBy;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Ordering;
+import com.google.common.hash.HashCode;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.client.Comment.Range;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.FixReplacement;
+import com.google.gerrit.reviewdb.client.FixSuggestion;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.RobotComment;
+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.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.change.WorkInProgressOp;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.patch.DiffSummary;
+import com.google.gerrit.server.patch.DiffSummaryKey;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.restapi.account.AccountsCollection;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.gson.Gson;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.OptionalInt;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+
+@Singleton
+public class PostReview
+    extends RetryingRestModifyView<RevisionResource, ReviewInput, Response<ReviewResult>> {
+  public static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
+  public static final String ERROR_ONLY_OWNER_CAN_MODIFY_WORK_IN_PROGRESS =
+      "only change owner can specify work_in_progress or ready";
+  public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
+      "work_in_progress and ready are mutually exclusive";
+
+  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
+
+  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
+  private static final int DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES = 1024 * 1024;
+
+  private final Provider<ReviewDb> db;
+  private final ChangeResource.Factory changeResourceFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final CommentsUtil commentsUtil;
+  private final PatchSetUtil psUtil;
+  private final PatchListCache patchListCache;
+  private final AccountsCollection accounts;
+  private final EmailReviewComments.Factory email;
+  private final CommentAdded commentAdded;
+  private final PostReviewers postReviewers;
+  private final NotesMigration migration;
+  private final NotifyUtil notifyUtil;
+  private final Config gerritConfig;
+  private final WorkInProgressOp.Factory workInProgressOpFactory;
+  private final ProjectCache projectCache;
+
+  @Inject
+  PostReview(
+      Provider<ReviewDb> db,
+      RetryHelper retryHelper,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeData.Factory changeDataFactory,
+      ApprovalsUtil approvalsUtil,
+      ChangeMessagesUtil cmUtil,
+      CommentsUtil commentsUtil,
+      PatchSetUtil psUtil,
+      PatchListCache patchListCache,
+      AccountsCollection accounts,
+      EmailReviewComments.Factory email,
+      CommentAdded commentAdded,
+      PostReviewers postReviewers,
+      NotesMigration migration,
+      NotifyUtil notifyUtil,
+      @GerritServerConfig Config gerritConfig,
+      WorkInProgressOp.Factory workInProgressOpFactory,
+      ProjectCache projectCache) {
+    super(retryHelper);
+    this.db = db;
+    this.changeResourceFactory = changeResourceFactory;
+    this.changeDataFactory = changeDataFactory;
+    this.commentsUtil = commentsUtil;
+    this.psUtil = psUtil;
+    this.patchListCache = patchListCache;
+    this.approvalsUtil = approvalsUtil;
+    this.cmUtil = cmUtil;
+    this.accounts = accounts;
+    this.email = email;
+    this.commentAdded = commentAdded;
+    this.postReviewers = postReviewers;
+    this.migration = migration;
+    this.notifyUtil = notifyUtil;
+    this.gerritConfig = gerritConfig;
+    this.workInProgressOpFactory = workInProgressOpFactory;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  protected Response<ReviewResult> applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input)
+      throws RestApiException, UpdateException, OrmException, IOException,
+          PermissionBackendException, ConfigInvalidException, PatchListNotAvailableException {
+    return apply(updateFactory, revision, input, TimeUtil.nowTs());
+  }
+
+  public Response<ReviewResult> apply(
+      BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input, Timestamp ts)
+      throws RestApiException, UpdateException, OrmException, IOException,
+          PermissionBackendException, ConfigInvalidException, PatchListNotAvailableException {
+    // Respect timestamp, but truncate at change created-on time.
+    ts = Ordering.natural().max(ts, revision.getChange().getCreatedOn());
+    if (revision.getEdit().isPresent()) {
+      throw new ResourceConflictException("cannot post review on edit");
+    }
+    ProjectState projectState = projectCache.checkedGet(revision.getProject());
+    LabelTypes labelTypes = projectState.getLabelTypes(revision.getNotes(), revision.getUser());
+    input.drafts = firstNonNull(input.drafts, DraftHandling.KEEP);
+    if (input.onBehalfOf != null) {
+      revision = onBehalfOf(revision, labelTypes, input);
+    }
+    if (input.labels != null) {
+      checkLabels(revision, labelTypes, input.labels);
+    }
+    if (input.comments != null) {
+      cleanUpComments(input.comments);
+      checkComments(revision, input.comments);
+    }
+    if (input.robotComments != null) {
+      if (!migration.readChanges()) {
+        throw new MethodNotAllowedException("robot comments not supported");
+      }
+      checkRobotComments(revision, input.robotComments);
+    }
+
+    NotifyHandling reviewerNotify = input.notify;
+    if (input.notify == null) {
+      input.notify = defaultNotify(revision.getChange(), input);
+    }
+
+    ListMultimap<RecipientType, Account.Id> accountsToNotify =
+        notifyUtil.resolveAccounts(input.notifyDetails);
+
+    Map<String, AddReviewerResult> reviewerJsonResults = null;
+    List<PostReviewers.Addition> reviewerResults = Lists.newArrayList();
+    boolean hasError = false;
+    boolean confirm = false;
+    if (input.reviewers != null) {
+      reviewerJsonResults = Maps.newHashMap();
+      for (AddReviewerInput reviewerInput : input.reviewers) {
+        // Prevent notifications because setting reviewers is batched.
+        reviewerInput.notify = NotifyHandling.NONE;
+
+        PostReviewers.Addition result =
+            postReviewers.prepareApplication(revision.getChangeResource(), reviewerInput, true);
+        reviewerJsonResults.put(reviewerInput.reviewer, result.result);
+        if (result.result.error != null) {
+          hasError = true;
+          continue;
+        }
+        if (result.result.confirm != null) {
+          confirm = true;
+          continue;
+        }
+        reviewerResults.add(result);
+      }
+    }
+
+    ReviewResult output = new ReviewResult();
+    output.reviewers = reviewerJsonResults;
+    if (hasError || confirm) {
+      output.error = ERROR_ADDING_REVIEWER;
+      return Response.withStatusCode(SC_BAD_REQUEST, output);
+    }
+    output.labels = input.labels;
+
+    try (BatchUpdate bu =
+        updateFactory.create(db.get(), revision.getChange().getProject(), revision.getUser(), ts)) {
+      Account.Id id = revision.getUser().getAccountId();
+      boolean ccOrReviewer = false;
+      if (input.labels != null && !input.labels.isEmpty()) {
+        ccOrReviewer = input.labels.values().stream().filter(v -> v != 0).findFirst().isPresent();
+      }
+
+      if (!ccOrReviewer) {
+        // Check if user was already CCed or reviewing prior to this review.
+        ReviewerSet currentReviewers =
+            approvalsUtil.getReviewers(db.get(), revision.getChangeResource().getNotes());
+        ccOrReviewer = currentReviewers.all().contains(id);
+      }
+
+      // Apply reviewer changes first. Revision emails should be sent to the
+      // updated set of reviewers. Also keep track of whether the user added
+      // themselves as a reviewer or to the CC list.
+      for (PostReviewers.Addition reviewerResult : reviewerResults) {
+        bu.addOp(revision.getChange().getId(), reviewerResult.op);
+        if (!ccOrReviewer && reviewerResult.result.reviewers != null) {
+          for (ReviewerInfo reviewerInfo : reviewerResult.result.reviewers) {
+            if (Objects.equals(id.get(), reviewerInfo._accountId)) {
+              ccOrReviewer = true;
+              break;
+            }
+          }
+        }
+        if (!ccOrReviewer && reviewerResult.result.ccs != null) {
+          for (AccountInfo accountInfo : reviewerResult.result.ccs) {
+            if (Objects.equals(id.get(), accountInfo._accountId)) {
+              ccOrReviewer = true;
+              break;
+            }
+          }
+        }
+      }
+
+      if (!ccOrReviewer) {
+        // User posting this review isn't currently in the reviewer or CC list,
+        // isn't being explicitly added, and isn't voting on any label.
+        // Automatically CC them on this change so they receive replies.
+        PostReviewers.Addition selfAddition =
+            postReviewers.ccCurrentUser(revision.getUser(), revision);
+        bu.addOp(revision.getChange().getId(), selfAddition.op);
+      }
+
+      // Add WorkInProgressOp if requested.
+      if (input.ready || input.workInProgress) {
+        if (input.ready && input.workInProgress) {
+          output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
+          return Response.withStatusCode(SC_BAD_REQUEST, output);
+        }
+        if (!revision.getChange().getOwner().equals(revision.getUser().getAccountId())) {
+          output.error = ERROR_ONLY_OWNER_CAN_MODIFY_WORK_IN_PROGRESS;
+          return Response.withStatusCode(SC_BAD_REQUEST, output);
+        }
+        if (input.ready) {
+          output.ready = true;
+        }
+
+        // Suppress notifications in WorkInProgressOp, we'll take care of
+        // them in this endpoint.
+        WorkInProgressOp.Input wipIn = new WorkInProgressOp.Input();
+        wipIn.notify = NotifyHandling.NONE;
+        bu.addOp(
+            revision.getChange().getId(),
+            workInProgressOpFactory.create(input.workInProgress, wipIn));
+      }
+
+      // Add the review op.
+      bu.addOp(
+          revision.getChange().getId(),
+          new Op(projectState, revision.getPatchSet().getId(), input, accountsToNotify));
+
+      bu.execute();
+
+      for (PostReviewers.Addition reviewerResult : reviewerResults) {
+        reviewerResult.gatherResults();
+      }
+
+      emailReviewers(revision.getChange(), reviewerResults, reviewerNotify, accountsToNotify);
+    }
+
+    return Response.ok(output);
+  }
+
+  private NotifyHandling defaultNotify(Change c, ReviewInput in) {
+    boolean workInProgress = c.isWorkInProgress();
+    if (in.workInProgress) {
+      workInProgress = true;
+    }
+    if (in.ready) {
+      workInProgress = false;
+    }
+
+    if (ChangeMessagesUtil.isAutogenerated(in.tag)) {
+      // Autogenerated comments default to lower notify levels.
+      return workInProgress ? NotifyHandling.OWNER : NotifyHandling.OWNER_REVIEWERS;
+    }
+
+    if (workInProgress && !c.hasReviewStarted()) {
+      // If review hasn't started we want to minimize recipients, no matter who
+      // the author is.
+      return NotifyHandling.OWNER;
+    }
+
+    return NotifyHandling.ALL;
+  }
+
+  private void emailReviewers(
+      Change change,
+      List<PostReviewers.Addition> reviewerAdditions,
+      @Nullable NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    List<Account.Id> to = new ArrayList<>();
+    List<Account.Id> cc = new ArrayList<>();
+    List<Address> toByEmail = new ArrayList<>();
+    List<Address> ccByEmail = new ArrayList<>();
+    for (PostReviewers.Addition addition : reviewerAdditions) {
+      if (addition.state == ReviewerState.REVIEWER) {
+        to.addAll(addition.reviewers);
+        toByEmail.addAll(addition.reviewersByEmail);
+      } else if (addition.state == ReviewerState.CC) {
+        cc.addAll(addition.reviewers);
+        ccByEmail.addAll(addition.reviewersByEmail);
+      }
+    }
+    if (reviewerAdditions.size() > 0) {
+      reviewerAdditions
+          .get(0)
+          .op
+          .emailReviewers(change, to, cc, toByEmail, ccByEmail, notify, accountsToNotify);
+    }
+  }
+
+  private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in)
+      throws BadRequestException, AuthException, UnprocessableEntityException, OrmException,
+          PermissionBackendException, IOException, ConfigInvalidException {
+    if (in.labels == null || in.labels.isEmpty()) {
+      throw new AuthException(
+          String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
+    }
+    if (in.drafts != DraftHandling.KEEP) {
+      throw new AuthException("not allowed to modify other user's drafts");
+    }
+
+    CurrentUser caller = rev.getUser();
+    PermissionBackend.ForChange perm = rev.permissions().database(db);
+    Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
+    while (itr.hasNext()) {
+      Map.Entry<String, Short> ent = itr.next();
+      LabelType type = labelTypes.byLabel(ent.getKey());
+      if (type == null) {
+        throw new BadRequestException(
+            String.format("label \"%s\" is not a configured label", ent.getKey()));
+      }
+
+      if (!caller.isInternalUser()) {
+        try {
+          perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type, ent.getValue()));
+        } catch (AuthException e) {
+          throw new AuthException(
+              String.format(
+                  "not permitted to modify label \"%s\" on behalf of \"%s\"",
+                  type.getName(), in.onBehalfOf));
+        }
+      }
+    }
+    if (in.labels.isEmpty()) {
+      throw new AuthException(
+          String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
+    }
+
+    IdentifiedUser reviewer = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
+    try {
+      perm.user(reviewer).check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new UnprocessableEntityException(
+          String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()));
+    }
+
+    return new RevisionResource(
+        changeResourceFactory.create(rev.getNotes(), reviewer), rev.getPatchSet());
+  }
+
+  private void checkLabels(RevisionResource rsrc, LabelTypes labelTypes, Map<String, Short> labels)
+      throws BadRequestException, AuthException, PermissionBackendException {
+    PermissionBackend.ForChange perm = rsrc.permissions();
+    Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
+    while (itr.hasNext()) {
+      Map.Entry<String, Short> ent = itr.next();
+      LabelType lt = labelTypes.byLabel(ent.getKey());
+      if (lt == null) {
+        throw new BadRequestException(
+            String.format("label \"%s\" is not a configured label", ent.getKey()));
+      }
+
+      if (ent.getValue() == null || ent.getValue() == 0) {
+        // Always permit 0, even if it is not within range.
+        // Later null/0 will be deleted and revoke the label.
+        continue;
+      }
+
+      if (lt.getValue(ent.getValue()) == null) {
+        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) {
+        throw new AuthException(
+            String.format("Applying label \"%s\": %d is restricted", lt.getName(), val));
+      }
+    }
+  }
+
+  private static <T extends CommentInput> void cleanUpComments(
+      Map<String, List<T>> commentsPerPath) {
+    Iterator<List<T>> mapValueIterator = commentsPerPath.values().iterator();
+    while (mapValueIterator.hasNext()) {
+      List<T> comments = mapValueIterator.next();
+      if (comments == null) {
+        mapValueIterator.remove();
+        continue;
+      }
+
+      cleanUpComments(comments);
+      if (comments.isEmpty()) {
+        mapValueIterator.remove();
+      }
+    }
+  }
+
+  private static <T extends CommentInput> void cleanUpComments(List<T> comments) {
+    Iterator<T> commentsIterator = comments.iterator();
+    while (commentsIterator.hasNext()) {
+      T comment = commentsIterator.next();
+      if (comment == null) {
+        commentsIterator.remove();
+        continue;
+      }
+
+      comment.message = Strings.nullToEmpty(comment.message).trim();
+      if (comment.message.isEmpty()) {
+        commentsIterator.remove();
+      }
+    }
+  }
+
+  private <T extends CommentInput> void checkComments(
+      RevisionResource revision, Map<String, List<T>> commentsPerPath)
+      throws BadRequestException, PatchListNotAvailableException {
+    Set<String> revisionFilePaths = getAffectedFilePaths(revision);
+    for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
+      String path = entry.getKey();
+      PatchSet.Id patchSetId = revision.getChange().currentPatchSetId();
+      ensurePathRefersToAvailableOrMagicFile(path, revisionFilePaths, patchSetId);
+
+      List<T> comments = entry.getValue();
+      for (T comment : comments) {
+        ensureLineIsNonNegative(comment.line, path);
+        ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
+        ensureRangeIsValid(path, comment.range);
+      }
+    }
+  }
+
+  private Set<String> getAffectedFilePaths(RevisionResource revision)
+      throws PatchListNotAvailableException {
+    ObjectId newId = ObjectId.fromString(revision.getPatchSet().getRevision().get());
+    DiffSummaryKey key =
+        DiffSummaryKey.fromPatchListKey(
+            PatchListKey.againstDefaultBase(newId, Whitespace.IGNORE_NONE));
+    DiffSummary ds = patchListCache.getDiffSummary(key, revision.getProject());
+    return new HashSet<>(ds.getPaths());
+  }
+
+  private static void ensurePathRefersToAvailableOrMagicFile(
+      String path, Set<String> availableFilePaths, PatchSet.Id patchSetId)
+      throws BadRequestException {
+    if (!availableFilePaths.contains(path) && !Patch.isMagic(path)) {
+      throw new BadRequestException(
+          String.format("file %s not found in revision %s", path, patchSetId));
+    }
+  }
+
+  private static void ensureLineIsNonNegative(Integer line, String path)
+      throws BadRequestException {
+    if (line != null && line < 0) {
+      throw new BadRequestException(
+          String.format("negative line number %d not allowed on %s", line, path));
+    }
+  }
+
+  private static <T extends CommentInput> void ensureCommentNotOnMagicFilesOfAutoMerge(
+      String path, T comment) throws BadRequestException {
+    if (Patch.isMagic(path) && comment.side == Side.PARENT && comment.parent == null) {
+      throw new BadRequestException(String.format("cannot comment on %s on auto-merge", path));
+    }
+  }
+
+  private void checkRobotComments(
+      RevisionResource revision, Map<String, List<RobotCommentInput>> in)
+      throws BadRequestException, PatchListNotAvailableException {
+    cleanUpComments(in);
+    for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) {
+      String commentPath = e.getKey();
+      for (RobotCommentInput c : e.getValue()) {
+        ensureSizeOfJsonInputIsWithinBounds(c);
+        ensureRobotIdIsSet(c.robotId, commentPath);
+        ensureRobotRunIdIsSet(c.robotRunId, commentPath);
+        ensureFixSuggestionsAreAddable(c.fixSuggestions, commentPath);
+      }
+    }
+    checkComments(revision, in);
+  }
+
+  private void ensureSizeOfJsonInputIsWithinBounds(RobotCommentInput robotCommentInput)
+      throws BadRequestException {
+    OptionalInt robotCommentSizeLimit = getRobotCommentSizeLimit();
+    if (robotCommentSizeLimit.isPresent()) {
+      int sizeLimit = robotCommentSizeLimit.getAsInt();
+      byte[] robotCommentBytes = GSON.toJson(robotCommentInput).getBytes(StandardCharsets.UTF_8);
+      int robotCommentSize = robotCommentBytes.length;
+      if (robotCommentSize > sizeLimit) {
+        throw new BadRequestException(
+            String.format(
+                "Size %d (bytes) of robot comment is greater than limit %d (bytes)",
+                robotCommentSize, sizeLimit));
+      }
+    }
+  }
+
+  private OptionalInt getRobotCommentSizeLimit() {
+    int robotCommentSizeLimit =
+        gerritConfig.getInt(
+            "change", "robotCommentSizeLimit", DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES);
+    if (robotCommentSizeLimit <= 0) {
+      return OptionalInt.empty();
+    }
+    return OptionalInt.of(robotCommentSizeLimit);
+  }
+
+  private static void ensureRobotIdIsSet(String robotId, String commentPath)
+      throws BadRequestException {
+    if (robotId == null) {
+      throw new BadRequestException(
+          String.format("robotId is missing for robot comment on %s", commentPath));
+    }
+  }
+
+  private static void ensureRobotRunIdIsSet(String robotRunId, String commentPath)
+      throws BadRequestException {
+    if (robotRunId == null) {
+      throw new BadRequestException(
+          String.format("robotRunId is missing for robot comment on %s", commentPath));
+    }
+  }
+
+  private static void ensureFixSuggestionsAreAddable(
+      List<FixSuggestionInfo> fixSuggestionInfos, String commentPath) throws BadRequestException {
+    if (fixSuggestionInfos == null) {
+      return;
+    }
+
+    for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
+      ensureDescriptionIsSet(commentPath, fixSuggestionInfo.description);
+      ensureFixReplacementsAreAddable(commentPath, fixSuggestionInfo.replacements);
+    }
+  }
+
+  private static void ensureDescriptionIsSet(String commentPath, String description)
+      throws BadRequestException {
+    if (description == null) {
+      throw new BadRequestException(
+          String.format(
+              "A description is required for the suggested fix of the robot comment on %s",
+              commentPath));
+    }
+  }
+
+  private static void ensureFixReplacementsAreAddable(
+      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
+    ensureReplacementsArePresent(commentPath, fixReplacementInfos);
+
+    for (FixReplacementInfo fixReplacementInfo : fixReplacementInfos) {
+      ensureReplacementPathIsSet(commentPath, fixReplacementInfo.path);
+      ensureRangeIsSet(commentPath, fixReplacementInfo.range);
+      ensureRangeIsValid(commentPath, fixReplacementInfo.range);
+      ensureReplacementStringIsSet(commentPath, fixReplacementInfo.replacement);
+    }
+
+    Map<String, List<FixReplacementInfo>> replacementsPerFilePath =
+        fixReplacementInfos.stream().collect(groupingBy(fixReplacement -> fixReplacement.path));
+    for (List<FixReplacementInfo> sameFileReplacements : replacementsPerFilePath.values()) {
+      ensureRangesDoNotOverlap(commentPath, sameFileReplacements);
+    }
+  }
+
+  private static void ensureReplacementsArePresent(
+      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
+    if (fixReplacementInfos == null || fixReplacementInfos.isEmpty()) {
+      throw new BadRequestException(
+          String.format(
+              "At least one replacement is "
+                  + "required for the suggested fix of the robot comment on %s",
+              commentPath));
+    }
+  }
+
+  private static void ensureReplacementPathIsSet(String commentPath, String replacementPath)
+      throws BadRequestException {
+    if (replacementPath == null) {
+      throw new BadRequestException(
+          String.format(
+              "A file path must be given for the replacement of the robot comment on %s",
+              commentPath));
+    }
+  }
+
+  private static void ensureRangeIsSet(String commentPath, Range range) throws BadRequestException {
+    if (range == null) {
+      throw new BadRequestException(
+          String.format(
+              "A range must be given for the replacement of the robot comment on %s", commentPath));
+    }
+  }
+
+  private static void ensureRangeIsValid(String commentPath, Range range)
+      throws BadRequestException {
+    if (range == null) {
+      return;
+    }
+    if (!range.isValid()) {
+      throw new BadRequestException(
+          String.format(
+              "Range (%s:%s - %s:%s) is not valid for the comment on %s",
+              range.startLine,
+              range.startCharacter,
+              range.endLine,
+              range.endCharacter,
+              commentPath));
+    }
+  }
+
+  private static void ensureReplacementStringIsSet(String commentPath, String replacement)
+      throws BadRequestException {
+    if (replacement == null) {
+      throw new BadRequestException(
+          String.format(
+              "A content for replacement "
+                  + "must be indicated for the replacement of the robot comment on %s",
+              commentPath));
+    }
+  }
+
+  private static void ensureRangesDoNotOverlap(
+      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
+    List<Range> sortedRanges =
+        fixReplacementInfos
+            .stream()
+            .map(fixReplacementInfo -> fixReplacementInfo.range)
+            .sorted()
+            .collect(toList());
+
+    int previousEndLine = 0;
+    int previousOffset = -1;
+    for (Range range : sortedRanges) {
+      if (range.startLine < previousEndLine
+          || (range.startLine == previousEndLine && range.startCharacter < previousOffset)) {
+        throw new BadRequestException(
+            String.format("Replacements overlap for the robot comment on %s", commentPath));
+      }
+      previousEndLine = range.endLine;
+      previousOffset = range.endCharacter;
+    }
+  }
+
+  /** Used to compare Comments with CommentInput comments. */
+  @AutoValue
+  abstract static class CommentSetEntry {
+    private static CommentSetEntry create(
+        String filename,
+        int patchSetId,
+        Integer line,
+        Side side,
+        HashCode message,
+        Comment.Range range) {
+      return new AutoValue_PostReview_CommentSetEntry(
+          filename, patchSetId, line, side, message, range);
+    }
+
+    public static CommentSetEntry create(Comment comment) {
+      return create(
+          comment.key.filename,
+          comment.key.patchSetId,
+          comment.lineNbr,
+          Side.fromShort(comment.side),
+          Hashing.murmur3_128().hashString(comment.message, UTF_8),
+          comment.range);
+    }
+
+    abstract String filename();
+
+    abstract int patchSetId();
+
+    @Nullable
+    abstract Integer line();
+
+    abstract Side side();
+
+    abstract HashCode message();
+
+    @Nullable
+    abstract Comment.Range range();
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final ProjectState projectState;
+    private final PatchSet.Id psId;
+    private final ReviewInput in;
+    private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
+
+    private IdentifiedUser user;
+    private ChangeNotes notes;
+    private PatchSet ps;
+    private ChangeMessage message;
+    private List<Comment> comments = new ArrayList<>();
+    private List<LabelVote> labelDelta = new ArrayList<>();
+    private Map<String, Short> approvals = new HashMap<>();
+    private Map<String, Short> oldApprovals = new HashMap<>();
+
+    private Op(
+        ProjectState projectState,
+        PatchSet.Id psId,
+        ReviewInput in,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+      this.projectState = projectState;
+      this.psId = psId;
+      this.in = in;
+      this.accountsToNotify = checkNotNull(accountsToNotify);
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws OrmException, ResourceConflictException, UnprocessableEntityException, IOException {
+      user = ctx.getIdentifiedUser();
+      notes = ctx.getNotes();
+      ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      boolean dirty = false;
+      dirty |= insertComments(ctx);
+      dirty |= insertRobotComments(ctx);
+      dirty |= updateLabels(projectState, ctx);
+      dirty |= insertMessage(ctx);
+      return dirty;
+    }
+
+    @Override
+    public void postUpdate(Context ctx) throws OrmException {
+      if (message == null) {
+        return;
+      }
+      if (in.notify.compareTo(NotifyHandling.NONE) > 0 || !accountsToNotify.isEmpty()) {
+        email
+            .create(
+                in.notify,
+                accountsToNotify,
+                notes,
+                ps,
+                user,
+                message,
+                comments,
+                in.message,
+                labelDelta)
+            .sendAsync();
+      }
+      commentAdded.fire(
+          notes.getChange(),
+          ps,
+          user.getAccount(),
+          message.getMessage(),
+          approvals,
+          oldApprovals,
+          ctx.getWhen());
+    }
+
+    private boolean insertComments(ChangeContext ctx)
+        throws OrmException, UnprocessableEntityException {
+      Map<String, List<CommentInput>> map = in.comments;
+      if (map == null) {
+        map = Collections.emptyMap();
+      }
+
+      Map<String, Comment> drafts = Collections.emptyMap();
+      if (!map.isEmpty() || in.drafts != DraftHandling.KEEP) {
+        if (in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS) {
+          drafts = changeDrafts(ctx);
+        } else {
+          drafts = patchSetDrafts(ctx);
+        }
+      }
+
+      List<Comment> toPublish = new ArrayList<>();
+
+      Set<CommentSetEntry> existingIds =
+          in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
+
+      for (Map.Entry<String, List<CommentInput>> ent : map.entrySet()) {
+        String path = ent.getKey();
+        for (CommentInput c : ent.getValue()) {
+          String parent = Url.decode(c.inReplyTo);
+          Comment e = drafts.remove(Url.decode(c.id));
+          if (e == null) {
+            e = commentsUtil.newComment(ctx, path, psId, c.side(), c.message, c.unresolved, parent);
+          } else {
+            e.writtenOn = ctx.getWhen();
+            e.side = c.side();
+            e.message = c.message;
+          }
+
+          setCommentRevId(e, patchListCache, ctx.getChange(), ps);
+          e.setLineNbrAndRange(c.line, c.range);
+          e.tag = in.tag;
+
+          if (existingIds.contains(CommentSetEntry.create(e))) {
+            continue;
+          }
+          toPublish.add(e);
+        }
+      }
+
+      switch (in.drafts) {
+        case PUBLISH:
+        case PUBLISH_ALL_REVISIONS:
+          commentsUtil.publish(ctx, psId, drafts.values(), in.tag);
+          comments.addAll(drafts.values());
+          break;
+        case KEEP:
+        default:
+          break;
+      }
+      ChangeUpdate u = ctx.getUpdate(psId);
+      commentsUtil.putComments(ctx.getDb(), u, Status.PUBLISHED, toPublish);
+      comments.addAll(toPublish);
+      return !toPublish.isEmpty();
+    }
+
+    private boolean insertRobotComments(ChangeContext ctx) throws OrmException {
+      if (in.robotComments == null) {
+        return false;
+      }
+
+      List<RobotComment> newRobotComments = getNewRobotComments(ctx);
+      commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
+      comments.addAll(newRobotComments);
+      return !newRobotComments.isEmpty();
+    }
+
+    private List<RobotComment> getNewRobotComments(ChangeContext ctx) throws OrmException {
+      List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
+
+      Set<CommentSetEntry> existingIds =
+          in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
+
+      for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
+        String path = ent.getKey();
+        for (RobotCommentInput c : ent.getValue()) {
+          RobotComment e = createRobotCommentFromInput(ctx, path, c);
+          if (existingIds.contains(CommentSetEntry.create(e))) {
+            continue;
+          }
+          toAdd.add(e);
+        }
+      }
+      return toAdd;
+    }
+
+    private RobotComment createRobotCommentFromInput(
+        ChangeContext ctx, String path, RobotCommentInput robotCommentInput) throws OrmException {
+      RobotComment robotComment =
+          commentsUtil.newRobotComment(
+              ctx,
+              path,
+              psId,
+              robotCommentInput.side(),
+              robotCommentInput.message,
+              robotCommentInput.robotId,
+              robotCommentInput.robotRunId);
+      robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
+      robotComment.url = robotCommentInput.url;
+      robotComment.properties = robotCommentInput.properties;
+      robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
+      robotComment.tag = in.tag;
+      setCommentRevId(robotComment, patchListCache, ctx.getChange(), ps);
+      robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
+      return robotComment;
+    }
+
+    private List<FixSuggestion> createFixSuggestionsFromInput(
+        List<FixSuggestionInfo> fixSuggestionInfos) {
+      if (fixSuggestionInfos == null) {
+        return Collections.emptyList();
+      }
+
+      List<FixSuggestion> fixSuggestions = new ArrayList<>(fixSuggestionInfos.size());
+      for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
+        fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
+      }
+      return fixSuggestions;
+    }
+
+    private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
+      List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
+      String fixId = ChangeUtil.messageUuid();
+      return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
+    }
+
+    private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
+      return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
+    }
+
+    private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
+      Comment.Range range = new Comment.Range(fixReplacementInfo.range);
+      return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
+    }
+
+    private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) throws OrmException {
+      return commentsUtil
+          .publishedByChange(ctx.getDb(), ctx.getNotes())
+          .stream()
+          .map(CommentSetEntry::create)
+          .collect(toSet());
+    }
+
+    private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) throws OrmException {
+      return commentsUtil
+          .robotCommentsByChange(ctx.getNotes())
+          .stream()
+          .map(CommentSetEntry::create)
+          .collect(toSet());
+    }
+
+    private Map<String, Comment> changeDrafts(ChangeContext ctx) throws OrmException {
+      Map<String, Comment> drafts = new HashMap<>();
+      for (Comment c :
+          commentsUtil.draftByChangeAuthor(ctx.getDb(), ctx.getNotes(), user.getAccountId())) {
+        c.tag = in.tag;
+        drafts.put(c.key.uuid, c);
+      }
+      return drafts;
+    }
+
+    private Map<String, Comment> patchSetDrafts(ChangeContext ctx) throws OrmException {
+      Map<String, Comment> drafts = new HashMap<>();
+      for (Comment c :
+          commentsUtil.draftByPatchSetAuthor(
+              ctx.getDb(), psId, user.getAccountId(), ctx.getNotes())) {
+        drafts.put(c.key.uuid, c);
+      }
+      return drafts;
+    }
+
+    private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
+      Map<String, Short> labels = new HashMap<>();
+      for (PatchSetApproval psa : patchsetApprovals) {
+        labels.put(psa.getLabel(), psa.getValue());
+      }
+      return labels;
+    }
+
+    private Map<String, Short> getAllApprovals(
+        LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
+      Map<String, Short> allApprovals = new HashMap<>();
+      for (LabelType lt : labelTypes.getLabelTypes()) {
+        allApprovals.put(lt.getName(), (short) 0);
+      }
+      // set approvals to existing votes
+      if (current != null) {
+        allApprovals.putAll(current);
+      }
+      // set approvals to new votes
+      if (input != null) {
+        allApprovals.putAll(input);
+      }
+      return allApprovals;
+    }
+
+    private Map<String, Short> getPreviousApprovals(
+        Map<String, Short> allApprovals, Map<String, Short> current) {
+      Map<String, Short> previous = new HashMap<>();
+      for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
+        // assume vote is 0 if there is no vote
+        if (!current.containsKey(approval.getKey())) {
+          previous.put(approval.getKey(), (short) 0);
+        } else {
+          previous.put(approval.getKey(), current.get(approval.getKey()));
+        }
+      }
+      return previous;
+    }
+
+    private boolean isReviewer(ChangeContext ctx) throws OrmException {
+      if (ctx.getAccountId().equals(ctx.getChange().getOwner())) {
+        return true;
+      }
+      ChangeData cd = changeDataFactory.create(db.get(), ctx.getNotes());
+      ReviewerSet reviewers = cd.reviewers();
+      if (reviewers.byState(REVIEWER).contains(ctx.getAccountId())) {
+        return true;
+      }
+      return false;
+    }
+
+    private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
+        throws OrmException, ResourceConflictException, IOException {
+      Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
+
+      // If no labels were modified and change is closed, abort early.
+      // This avoids trying to record a modified label caused by a user
+      // losing access to a label after the change was submitted.
+      if (inLabels.isEmpty() && ctx.getChange().getStatus().isClosed()) {
+        return false;
+      }
+
+      List<PatchSetApproval> del = new ArrayList<>();
+      List<PatchSetApproval> ups = new ArrayList<>();
+      Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
+      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
+      Map<String, Short> allApprovals =
+          getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
+      Map<String, Short> previous =
+          getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
+
+      ChangeUpdate update = ctx.getUpdate(psId);
+      for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
+        String name = ent.getKey();
+        LabelType lt = checkNotNull(labelTypes.byLabel(name), name);
+
+        PatchSetApproval c = current.remove(lt.getName());
+        String normName = lt.getName();
+        approvals.put(normName, (short) 0);
+        if (ent.getValue() == null || ent.getValue() == 0) {
+          // User requested delete of this label.
+          oldApprovals.put(normName, null);
+          if (c != null) {
+            if (c.getValue() != 0) {
+              addLabelDelta(normName, (short) 0);
+              oldApprovals.put(normName, previous.get(normName));
+            }
+            del.add(c);
+            update.putApproval(normName, (short) 0);
+          }
+        } else if (c != null && c.getValue() != ent.getValue()) {
+          c.setValue(ent.getValue());
+          c.setGranted(ctx.getWhen());
+          c.setTag(in.tag);
+          ctx.getUser().updateRealAccountId(c::setRealAccountId);
+          ups.add(c);
+          addLabelDelta(normName, c.getValue());
+          oldApprovals.put(normName, previous.get(normName));
+          approvals.put(normName, c.getValue());
+          update.putApproval(normName, ent.getValue());
+        } else if (c != null && c.getValue() == ent.getValue()) {
+          current.put(normName, c);
+          oldApprovals.put(normName, null);
+          approvals.put(normName, c.getValue());
+        } else if (c == null) {
+          c = ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen());
+          c.setTag(in.tag);
+          c.setGranted(ctx.getWhen());
+          ups.add(c);
+          addLabelDelta(normName, c.getValue());
+          oldApprovals.put(normName, previous.get(normName));
+          approvals.put(normName, c.getValue());
+          update.putReviewer(user.getAccountId(), REVIEWER);
+          update.putApproval(normName, ent.getValue());
+        }
+      }
+
+      validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
+
+      // Return early if user is not a reviewer and not posting any labels.
+      // This allows us to preserve their CC status.
+      if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
+        return false;
+      }
+
+      forceCallerAsReviewer(projectState, ctx, current, ups, del);
+      ctx.getDb().patchSetApprovals().delete(del);
+      ctx.getDb().patchSetApprovals().upsert(ups);
+      return !del.isEmpty() || !ups.isEmpty();
+    }
+
+    private void validatePostSubmitLabels(
+        ChangeContext ctx,
+        LabelTypes labelTypes,
+        Map<String, Short> previous,
+        List<PatchSetApproval> ups,
+        List<PatchSetApproval> del)
+        throws ResourceConflictException {
+      if (ctx.getChange().getStatus().isOpen()) {
+        return; // Not closed, nothing to validate.
+      } else if (del.isEmpty() && ups.isEmpty()) {
+        return; // No new votes.
+      } else if (ctx.getChange().getStatus() != Change.Status.MERGED) {
+        throw new ResourceConflictException("change is closed");
+      }
+
+      // Disallow reducing votes on any labels post-submit. This assumes the
+      // high values were broadly necessary to submit, so reducing them would
+      // make it possible to take a merged change and make it no longer
+      // submittable.
+      List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
+      List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
+
+      for (PatchSetApproval psa : del) {
+        LabelType lt = checkNotNull(labelTypes.byLabel(psa.getLabel()));
+        String normName = lt.getName();
+        if (!lt.allowPostSubmit()) {
+          disallowed.add(normName);
+        }
+        Short prev = previous.get(normName);
+        if (prev != null && prev != 0) {
+          reduced.add(psa);
+        }
+      }
+
+      for (PatchSetApproval psa : ups) {
+        LabelType lt = checkNotNull(labelTypes.byLabel(psa.getLabel()));
+        String normName = lt.getName();
+        if (!lt.allowPostSubmit()) {
+          disallowed.add(normName);
+        }
+        Short prev = previous.get(normName);
+        if (prev == null) {
+          continue;
+        }
+        checkState(prev != psa.getValue()); // Should be filtered out above.
+        if (prev > psa.getValue()) {
+          reduced.add(psa);
+        } else {
+          // Set postSubmit bit in ReviewDb; not required for NoteDb, which sets
+          // it automatically.
+          psa.setPostSubmit(true);
+        }
+      }
+
+      if (!disallowed.isEmpty()) {
+        throw new ResourceConflictException(
+            "Voting on labels disallowed after submit: "
+                + disallowed.stream().distinct().sorted().collect(joining(", ")));
+      }
+      if (!reduced.isEmpty()) {
+        throw new ResourceConflictException(
+            "Cannot reduce vote on labels for closed change: "
+                + reduced
+                    .stream()
+                    .map(p -> p.getLabel())
+                    .distinct()
+                    .sorted()
+                    .collect(joining(", ")));
+      }
+    }
+
+    private void forceCallerAsReviewer(
+        ProjectState projectState,
+        ChangeContext ctx,
+        Map<String, PatchSetApproval> current,
+        List<PatchSetApproval> ups,
+        List<PatchSetApproval> del) {
+      if (current.isEmpty() && ups.isEmpty()) {
+        // TODO Find another way to link reviewers to changes.
+        if (del.isEmpty()) {
+          // If no existing label is being set to 0, hack in the caller
+          // as a reviewer by picking the first server-wide LabelType.
+          LabelId labelId =
+              projectState
+                  .getLabelTypes(ctx.getNotes(), ctx.getUser())
+                  .getLabelTypes()
+                  .get(0)
+                  .getLabelId();
+          PatchSetApproval c = ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen());
+          c.setTag(in.tag);
+          c.setGranted(ctx.getWhen());
+          ups.add(c);
+        } else {
+          // Pick a random label that is about to be deleted and keep it.
+          Iterator<PatchSetApproval> i = del.iterator();
+          PatchSetApproval c = i.next();
+          c.setValue((short) 0);
+          c.setGranted(ctx.getWhen());
+          i.remove();
+          ups.add(c);
+        }
+      }
+      ctx.getUpdate(ctx.getChange().currentPatchSetId()).putReviewer(user.getAccountId(), REVIEWER);
+    }
+
+    private Map<String, PatchSetApproval> scanLabels(
+        ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del)
+        throws OrmException, IOException {
+      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
+      Map<String, PatchSetApproval> current = new HashMap<>();
+
+      for (PatchSetApproval a :
+          approvalsUtil.byPatchSetUser(
+              ctx.getDb(),
+              ctx.getNotes(),
+              ctx.getUser(),
+              psId,
+              user.getAccountId(),
+              ctx.getRevWalk(),
+              ctx.getRepoView().getConfig())) {
+        if (a.isLegacySubmit()) {
+          continue;
+        }
+
+        LabelType lt = labelTypes.byLabel(a.getLabelId());
+        if (lt != null) {
+          current.put(lt.getName(), a);
+        } else {
+          del.add(a);
+        }
+      }
+      return current;
+    }
+
+    private boolean insertMessage(ChangeContext ctx) throws OrmException {
+      String msg = Strings.nullToEmpty(in.message).trim();
+
+      StringBuilder buf = new StringBuilder();
+      for (LabelVote d : labelDelta) {
+        buf.append(" ").append(d.format());
+      }
+      if (comments.size() == 1) {
+        buf.append("\n\n(1 comment)");
+      } else if (comments.size() > 1) {
+        buf.append(String.format("\n\n(%d comments)", comments.size()));
+      }
+      if (!msg.isEmpty()) {
+        buf.append("\n\n").append(msg);
+      } else if (in.ready) {
+        buf.append("\n\n" + START_REVIEW_MESSAGE);
+      }
+      if (buf.length() == 0) {
+        return false;
+      }
+
+      message =
+          ChangeMessagesUtil.newMessage(
+              psId, user, ctx.getWhen(), "Patch Set " + psId.get() + ":" + buf, in.tag);
+      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), message);
+      return true;
+    }
+
+    private void addLabelDelta(String name, short value) {
+      labelDelta.add(LabelVote.create(name, value));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
new file mode 100644
index 0000000..84dc3e3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -0,0 +1,480 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+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.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.GroupMembers;
+import com.google.gerrit.server.change.ChangeMessages;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.restapi.account.AccountsCollection;
+import com.google.gerrit.server.restapi.group.GroupsCollection;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class PostReviewers
+    extends RetryingRestModifyView<ChangeResource, AddReviewerInput, AddReviewerResult> {
+
+  public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
+  public static final int DEFAULT_MAX_REVIEWERS = 20;
+
+  private final AccountsCollection accounts;
+  private final ReviewerResource.Factory reviewerFactory;
+  private final PermissionBackend permissionBackend;
+
+  private final GroupsCollection groupsCollection;
+  private final GroupMembers groupMembers;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeData.Factory changeDataFactory;
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final Config cfg;
+  private final ReviewerJson json;
+  private final NotesMigration migration;
+  private final NotifyUtil notifyUtil;
+  private final ProjectCache projectCache;
+  private final Provider<AnonymousUser> anonymousProvider;
+  private final PostReviewersOp.Factory postReviewersOpFactory;
+  private final OutgoingEmailValidator validator;
+
+  @Inject
+  PostReviewers(
+      AccountsCollection accounts,
+      ReviewerResource.Factory reviewerFactory,
+      PermissionBackend permissionBackend,
+      GroupsCollection groupsCollection,
+      GroupMembers groupMembers,
+      AccountLoader.Factory accountLoaderFactory,
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      RetryHelper retryHelper,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      @GerritServerConfig Config cfg,
+      ReviewerJson json,
+      NotesMigration migration,
+      NotifyUtil notifyUtil,
+      ProjectCache projectCache,
+      Provider<AnonymousUser> anonymousProvider,
+      PostReviewersOp.Factory postReviewersOpFactory,
+      OutgoingEmailValidator validator) {
+    super(retryHelper);
+    this.accounts = accounts;
+    this.reviewerFactory = reviewerFactory;
+    this.permissionBackend = permissionBackend;
+    this.groupsCollection = groupsCollection;
+    this.groupMembers = groupMembers;
+    this.accountLoaderFactory = accountLoaderFactory;
+    this.dbProvider = db;
+    this.changeDataFactory = changeDataFactory;
+    this.identifiedUserFactory = identifiedUserFactory;
+    this.cfg = cfg;
+    this.json = json;
+    this.migration = migration;
+    this.notifyUtil = notifyUtil;
+    this.projectCache = projectCache;
+    this.anonymousProvider = anonymousProvider;
+    this.postReviewersOpFactory = postReviewersOpFactory;
+    this.validator = validator;
+  }
+
+  @Override
+  protected AddReviewerResult applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, AddReviewerInput input)
+      throws IOException, OrmException, RestApiException, UpdateException,
+          PermissionBackendException, ConfigInvalidException {
+    if (input.reviewer == null) {
+      throw new BadRequestException("missing reviewer field");
+    }
+
+    Addition addition = prepareApplication(rsrc, input, true);
+    if (addition.op == null) {
+      return addition.result;
+    }
+    try (BatchUpdate bu =
+        updateFactory.create(
+            dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      Change.Id id = rsrc.getChange().getId();
+      bu.addOp(id, addition.op);
+      bu.execute();
+      addition.gatherResults();
+    }
+    return addition.result;
+  }
+
+  public Addition prepareApplication(
+      ChangeResource rsrc, AddReviewerInput input, boolean allowGroup)
+      throws OrmException, IOException, PermissionBackendException, ConfigInvalidException {
+    String reviewer = input.reviewer;
+    ReviewerState state = input.state();
+    NotifyHandling notify = input.notify;
+    ListMultimap<RecipientType, Account.Id> accountsToNotify = null;
+    try {
+      accountsToNotify = notifyUtil.resolveAccounts(input.notifyDetails);
+    } catch (BadRequestException e) {
+      return fail(reviewer, e.getMessage());
+    }
+    boolean confirmed = input.confirmed();
+    boolean allowByEmail =
+        projectCache
+            .checkedGet(rsrc.getProject())
+            .is(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL);
+
+    Addition byAccountId =
+        addByAccountId(reviewer, rsrc, state, notify, accountsToNotify, allowGroup, allowByEmail);
+    if (byAccountId != null) {
+      return byAccountId;
+    }
+
+    Addition wholeGroup =
+        addWholeGroup(
+            reviewer, rsrc, state, notify, accountsToNotify, confirmed, allowGroup, allowByEmail);
+    if (wholeGroup != null) {
+      return wholeGroup;
+    }
+
+    return addByEmail(reviewer, rsrc, state, notify, accountsToNotify);
+  }
+
+  Addition ccCurrentUser(CurrentUser user, RevisionResource revision) {
+    return new Addition(
+        user.getUserName(),
+        revision.getChangeResource(),
+        ImmutableSet.of(user.getAccountId()),
+        null,
+        CC,
+        NotifyHandling.NONE,
+        ImmutableListMultimap.of());
+  }
+
+  @Nullable
+  private Addition addByAccountId(
+      String reviewer,
+      ChangeResource rsrc,
+      ReviewerState state,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      boolean allowGroup,
+      boolean allowByEmail)
+      throws OrmException, PermissionBackendException, IOException, ConfigInvalidException {
+    Account.Id accountId = null;
+    try {
+      accountId = accounts.parse(reviewer).getAccountId();
+    } catch (UnprocessableEntityException | AuthException e) {
+      // AuthException won't occur since the user is authenticated at this point.
+      if (!allowGroup && !allowByEmail) {
+        // Only return failure if we aren't going to try other interpretations.
+        return fail(
+            reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
+      }
+      return null;
+    }
+
+    ReviewerResource rrsrc = reviewerFactory.create(rsrc, accountId);
+    Account member = rrsrc.getReviewerUser().getAccount();
+    PermissionBackend.ForRef perm =
+        permissionBackend.user(rrsrc.getReviewerUser()).ref(rrsrc.getChange().getDest());
+    if (isValidReviewer(member, perm)) {
+      return new Addition(
+          reviewer, rsrc, ImmutableSet.of(member.getId()), null, state, notify, accountsToNotify);
+    }
+    if (!member.isActive()) {
+      if (allowByEmail && state == CC) {
+        return null;
+      }
+      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInactive, reviewer));
+    }
+    return fail(
+        reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
+  }
+
+  @Nullable
+  private Addition addWholeGroup(
+      String reviewer,
+      ChangeResource rsrc,
+      ReviewerState state,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      boolean confirmed,
+      boolean allowGroup,
+      boolean allowByEmail)
+      throws IOException, PermissionBackendException {
+    if (!allowGroup) {
+      return null;
+    }
+
+    GroupDescription.Basic group = null;
+    try {
+      group = groupsCollection.parseInternal(reviewer);
+    } catch (UnprocessableEntityException e) {
+      if (!allowByEmail) {
+        return fail(
+            reviewer,
+            MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, reviewer));
+      }
+      return null;
+    }
+
+    if (!isLegalReviewerGroup(group.getGroupUUID())) {
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
+    }
+
+    Set<Account.Id> reviewers = new HashSet<>();
+    Set<Account> members;
+    try {
+      members = groupMembers.listAccounts(group.getGroupUUID(), rsrc.getProject());
+    } catch (NoSuchProjectException e) {
+      return fail(reviewer, e.getMessage());
+    }
+
+    // if maxAllowed is set to 0, it is allowed to add any number of
+    // reviewers
+    int maxAllowed = cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
+    if (maxAllowed > 0 && members.size() > maxAllowed) {
+      return fail(
+          reviewer,
+          MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
+    }
+
+    // if maxWithoutCheck is set to 0, we never ask for confirmation
+    int maxWithoutConfirmation =
+        cfg.getInt("addreviewer", "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+    if (!confirmed && maxWithoutConfirmation > 0 && members.size() > maxWithoutConfirmation) {
+      return fail(
+          reviewer,
+          true,
+          MessageFormat.format(
+              ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
+    }
+
+    PermissionBackend.ForRef perm =
+        permissionBackend.user(rsrc.getUser()).ref(rsrc.getChange().getDest());
+    for (Account member : members) {
+      if (isValidReviewer(member, perm)) {
+        reviewers.add(member.getId());
+      }
+    }
+
+    return new Addition(reviewer, rsrc, reviewers, null, state, notify, accountsToNotify);
+  }
+
+  @Nullable
+  private Addition addByEmail(
+      String reviewer,
+      ChangeResource rsrc,
+      ReviewerState state,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify)
+      throws PermissionBackendException {
+    if (!permissionBackend
+        .user(anonymousProvider)
+        .change(rsrc.getNotes())
+        .database(dbProvider)
+        .test(ChangePermission.READ)) {
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
+    }
+    if (!migration.readChanges()) {
+      // addByEmail depends on NoteDb.
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
+    }
+    Address adr = Address.tryParse(reviewer);
+    if (adr == null || !validator.isValid(adr.getEmail())) {
+      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInvalid, reviewer));
+    }
+    return new Addition(
+        reviewer, rsrc, null, ImmutableList.of(adr), state, notify, accountsToNotify);
+  }
+
+  private boolean isValidReviewer(Account member, PermissionBackend.ForRef perm)
+      throws PermissionBackendException {
+    if (member.isActive()) {
+      IdentifiedUser user = identifiedUserFactory.create(member.getId());
+      // Does not account for draft status as a user might want to let a
+      // reviewer see a draft.
+      try {
+        perm.user(user).check(RefPermission.READ);
+        return true;
+      } catch (AuthException e) {
+        return false;
+      }
+    }
+    return false;
+  }
+
+  private Addition fail(String reviewer, String error) {
+    return fail(reviewer, false, error);
+  }
+
+  private Addition fail(String reviewer, boolean confirm, String error) {
+    Addition addition = new Addition(reviewer);
+    addition.result.confirm = confirm ? true : null;
+    addition.result.error = error;
+    return addition;
+  }
+
+  public class Addition {
+    final AddReviewerResult result;
+    final PostReviewersOp op;
+    final Set<Account.Id> reviewers;
+    final Collection<Address> reviewersByEmail;
+    final ReviewerState state;
+    final ChangeNotes notes;
+    final IdentifiedUser caller;
+
+    Addition(String reviewer) {
+      result = new AddReviewerResult(reviewer);
+      op = null;
+      reviewers = ImmutableSet.of();
+      reviewersByEmail = ImmutableSet.of();
+      state = REVIEWER;
+      notes = null;
+      caller = null;
+    }
+
+    protected Addition(
+        String reviewer,
+        ChangeResource rsrc,
+        @Nullable Set<Account.Id> reviewers,
+        @Nullable Collection<Address> reviewersByEmail,
+        ReviewerState state,
+        @Nullable NotifyHandling notify,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+      checkArgument(
+          reviewers != null || reviewersByEmail != null,
+          "must have either reviewers or reviewersByEmail");
+
+      result = new AddReviewerResult(reviewer);
+      this.reviewers = reviewers == null ? ImmutableSet.of() : reviewers;
+      this.reviewersByEmail = reviewersByEmail == null ? ImmutableList.of() : reviewersByEmail;
+      this.state = state;
+      notes = rsrc.getNotes();
+      caller = rsrc.getUser().asIdentifiedUser();
+      op =
+          postReviewersOpFactory.create(
+              rsrc, this.reviewers, this.reviewersByEmail, state, notify, accountsToNotify);
+    }
+
+    void gatherResults() throws OrmException, PermissionBackendException {
+      if (notes == null || caller == null) {
+        // When notes or caller is missing this is likely just carrying an error message
+        // in the contained AddReviewerResult.
+        return;
+      }
+
+      ChangeData cd = changeDataFactory.create(dbProvider.get(), notes);
+      PermissionBackend.ForChange perm =
+          permissionBackend.user(caller).database(dbProvider).change(cd);
+
+      // Generate result details and fill AccountLoader. This occurs outside
+      // the Op because the accounts are in a different table.
+      PostReviewersOp.Result opResult = op.getResult();
+      if (migration.readChanges() && state == CC) {
+        result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
+        for (Account.Id accountId : opResult.addedCCs()) {
+          IdentifiedUser u = identifiedUserFactory.create(accountId);
+          result.ccs.add(json.format(caller, new ReviewerInfo(accountId.get()), perm.user(u), cd));
+        }
+        accountLoaderFactory.create(true).fill(result.ccs);
+        for (Address a : reviewersByEmail) {
+          result.ccs.add(new AccountInfo(a.getName(), a.getEmail()));
+        }
+      } else {
+        result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
+        for (PatchSetApproval psa : opResult.addedReviewers()) {
+          // New reviewers have value 0, don't bother normalizing.
+          IdentifiedUser u = identifiedUserFactory.create(psa.getAccountId());
+          result.reviewers.add(
+              json.format(
+                  caller,
+                  new ReviewerInfo(psa.getAccountId().get()),
+                  perm.user(u),
+                  cd,
+                  ImmutableList.of(psa)));
+        }
+        accountLoaderFactory.create(true).fill(result.reviewers);
+        for (Address a : reviewersByEmail) {
+          result.reviewers.add(ReviewerInfo.byEmail(a.getName(), a.getEmail()));
+        }
+      }
+    }
+  }
+
+  public static boolean isLegalReviewerGroup(AccountGroup.UUID groupUUID) {
+    return !SystemGroupBackend.isSystemGroup(groupUUID);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java
new file mode 100644
index 0000000..9ec8414
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java
@@ -0,0 +1,268 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.extensions.events.ReviewerAdded;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PostReviewersOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(PostReviewersOp.class);
+
+  public interface Factory {
+    PostReviewersOp create(
+        ChangeResource rsrc,
+        Set<Account.Id> reviewers,
+        Collection<Address> reviewersByEmail,
+        ReviewerState state,
+        @Nullable NotifyHandling notify,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify);
+  }
+
+  @AutoValue
+  public abstract static class Result {
+    public abstract ImmutableList<PatchSetApproval> addedReviewers();
+
+    public abstract ImmutableList<Account.Id> addedCCs();
+
+    static Builder builder() {
+      return new AutoValue_PostReviewersOp_Result.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder setAddedReviewers(ImmutableList<PatchSetApproval> addedReviewers);
+
+      abstract Builder setAddedCCs(ImmutableList<Account.Id> addedCCs);
+
+      abstract Result build();
+    }
+  }
+
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ReviewerAdded reviewerAdded;
+  private final AccountCache accountCache;
+  private final ProjectCache projectCache;
+  private final AddReviewerSender.Factory addReviewerSenderFactory;
+  private final NotesMigration migration;
+  private final Provider<IdentifiedUser> user;
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeResource rsrc;
+  private final Set<Account.Id> reviewers;
+  private final Collection<Address> reviewersByEmail;
+  private final ReviewerState state;
+  private final NotifyHandling notify;
+  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
+
+  private List<PatchSetApproval> addedReviewers = new ArrayList<>();
+  private Collection<Account.Id> addedCCs = new ArrayList<>();
+  private Collection<Address> addedCCsByEmail = new ArrayList<>();
+  private PatchSet patchSet;
+  private Result opResult;
+
+  @Inject
+  PostReviewersOp(
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ReviewerAdded reviewerAdded,
+      AccountCache accountCache,
+      ProjectCache projectCache,
+      AddReviewerSender.Factory addReviewerSenderFactory,
+      NotesMigration migration,
+      Provider<IdentifiedUser> user,
+      Provider<ReviewDb> dbProvider,
+      @Assisted ChangeResource rsrc,
+      @Assisted Set<Account.Id> reviewers,
+      @Assisted Collection<Address> reviewersByEmail,
+      @Assisted ReviewerState state,
+      @Assisted @Nullable NotifyHandling notify,
+      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.reviewerAdded = reviewerAdded;
+    this.accountCache = accountCache;
+    this.projectCache = projectCache;
+    this.addReviewerSenderFactory = addReviewerSenderFactory;
+    this.migration = migration;
+    this.user = user;
+    this.dbProvider = dbProvider;
+
+    this.rsrc = rsrc;
+    this.reviewers = reviewers;
+    this.reviewersByEmail = reviewersByEmail;
+    this.state = state;
+    this.notify = notify;
+    this.accountsToNotify = accountsToNotify;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws RestApiException, OrmException, IOException {
+    if (!reviewers.isEmpty()) {
+      if (migration.readChanges() && state == CC) {
+        addedCCs =
+            approvalsUtil.addCcs(
+                ctx.getNotes(), ctx.getUpdate(ctx.getChange().currentPatchSetId()), reviewers);
+        if (addedCCs.isEmpty()) {
+          return false;
+        }
+      } else {
+        addedReviewers =
+            approvalsUtil.addReviewers(
+                ctx.getDb(),
+                ctx.getNotes(),
+                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+                projectCache
+                    .checkedGet(rsrc.getProject())
+                    .getLabelTypes(rsrc.getChange().getDest(), ctx.getUser()),
+                rsrc.getChange(),
+                reviewers);
+        if (addedReviewers.isEmpty()) {
+          return false;
+        }
+      }
+    }
+
+    for (Address a : reviewersByEmail) {
+      ctx.getUpdate(ctx.getChange().currentPatchSetId())
+          .putReviewerByEmail(a, ReviewerStateInternal.fromReviewerState(state));
+    }
+
+    patchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws Exception {
+    opResult =
+        Result.builder()
+            .setAddedReviewers(ImmutableList.copyOf(addedReviewers))
+            .setAddedCCs(ImmutableList.copyOf(addedCCs))
+            .build();
+    emailReviewers(
+        rsrc.getChange(),
+        Lists.transform(addedReviewers, r -> r.getAccountId()),
+        addedCCs == null ? ImmutableList.of() : addedCCs,
+        reviewersByEmail,
+        addedCCsByEmail,
+        notify,
+        accountsToNotify);
+    if (!addedReviewers.isEmpty()) {
+      List<Account> reviewers =
+          addedReviewers
+              .stream()
+              .map(r -> accountCache.get(r.getAccountId()).getAccount())
+              .collect(toList());
+      reviewerAdded.fire(rsrc.getChange(), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
+    }
+  }
+
+  public void emailReviewers(
+      Change change,
+      Collection<Account.Id> added,
+      Collection<Account.Id> copied,
+      Collection<Address> addedByEmail,
+      Collection<Address> copiedByEmail,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    if (added.isEmpty() && copied.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
+      return;
+    }
+
+    // Email the reviewers
+    //
+    // The user knows they added themselves, don't bother emailing them.
+    List<Account.Id> toMail = Lists.newArrayListWithCapacity(added.size());
+    Account.Id userId = user.get().getAccountId();
+    for (Account.Id id : added) {
+      if (!id.equals(userId)) {
+        toMail.add(id);
+      }
+    }
+    List<Account.Id> toCopy = Lists.newArrayListWithCapacity(copied.size());
+    for (Account.Id id : copied) {
+      if (!id.equals(userId)) {
+        toCopy.add(id);
+      }
+    }
+    if (toMail.isEmpty() && toCopy.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
+      return;
+    }
+
+    try {
+      AddReviewerSender cm = addReviewerSenderFactory.create(change.getProject(), change.getId());
+      // Default to silent operation on WIP changes.
+      NotifyHandling defaultNotifyHandling =
+          change.isWorkInProgress() ? NotifyHandling.NONE : NotifyHandling.ALL;
+      cm.setNotify(MoreObjects.firstNonNull(notify, defaultNotifyHandling));
+      cm.setAccountsToNotify(accountsToNotify);
+      cm.setFrom(userId);
+      cm.addReviewers(toMail);
+      cm.addReviewersByEmail(addedByEmail);
+      cm.addExtraCC(toCopy);
+      cm.addExtraCCByEmail(copiedByEmail);
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot send email to new reviewers of change " + change.getId(), err);
+    }
+  }
+
+  public Result getResult() {
+    checkState(opResult != null, "Batch update wasn't executed yet");
+    return opResult;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
new file mode 100644
index 0000000..e90c60f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
@@ -0,0 +1,194 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ArchiveFormat;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.MergeOp;
+import com.google.gerrit.server.git.MergeOpRepoManager;
+import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
+import com.google.gerrit.server.ioutil.LimitedByteArrayOutputStream;
+import com.google.gerrit.server.ioutil.LimitedByteArrayOutputStream.LimitExceededException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Collection;
+import org.apache.commons.compress.archivers.ArchiveOutputStream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.storage.pack.PackConfig;
+import org.eclipse.jgit.transport.BundleWriter;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.kohsuke.args4j.Option;
+
+@Singleton
+public class PreviewSubmit implements RestReadView<RevisionResource> {
+  private static final int MAX_DEFAULT_BUNDLE_SIZE = 100 * 1024 * 1024;
+
+  private final Provider<ReviewDb> dbProvider;
+  private final Provider<MergeOp> mergeOpProvider;
+  private final AllowedFormats allowedFormats;
+  private int maxBundleSize;
+  private String format;
+
+  @Option(name = "--format")
+  public void setFormat(String f) {
+    this.format = f;
+  }
+
+  @Inject
+  PreviewSubmit(
+      Provider<ReviewDb> dbProvider,
+      Provider<MergeOp> mergeOpProvider,
+      AllowedFormats allowedFormats,
+      @GerritServerConfig Config cfg) {
+    this.dbProvider = dbProvider;
+    this.mergeOpProvider = mergeOpProvider;
+    this.allowedFormats = allowedFormats;
+    this.maxBundleSize = cfg.getInt("download", "maxBundleSize", MAX_DEFAULT_BUNDLE_SIZE);
+  }
+
+  @Override
+  public BinaryResult apply(RevisionResource rsrc)
+      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (Strings.isNullOrEmpty(format)) {
+      throw new BadRequestException("format is not specified");
+    }
+    ArchiveFormat f = allowedFormats.extensions.get("." + format);
+    if (f == null && format.equals("tgz")) {
+      // Always allow tgz, even when the allowedFormats doesn't contain it.
+      // Then we allow at least one format even if the list of allowed
+      // formats is empty.
+      f = ArchiveFormat.TGZ;
+    }
+    if (f == null) {
+      throw new BadRequestException("unknown archive format");
+    }
+
+    Change change = rsrc.getChange();
+    if (!change.getStatus().isOpen()) {
+      throw new PreconditionFailedException("change is " + ChangeUtil.status(change));
+    }
+    if (!rsrc.getUser().isIdentifiedUser()) {
+      throw new MethodNotAllowedException("Anonymous users cannot submit");
+    }
+
+    return getBundles(rsrc, f);
+  }
+
+  private BinaryResult getBundles(RevisionResource rsrc, ArchiveFormat f)
+      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    ReviewDb db = dbProvider.get();
+    IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
+    Change change = rsrc.getChange();
+
+    @SuppressWarnings("resource") // Returned BinaryResult takes ownership and handles closing.
+    MergeOp op = mergeOpProvider.get();
+    try {
+      op.merge(db, change, caller, false, new SubmitInput(), true);
+      BinaryResult bin = new SubmitPreviewResult(op, f, maxBundleSize);
+      bin.disableGzip()
+          .setContentType(f.getMimeType())
+          .setAttachmentName("submit-preview-" + change.getChangeId() + "." + format);
+      return bin;
+    } catch (OrmException
+        | RestApiException
+        | UpdateException
+        | IOException
+        | ConfigInvalidException
+        | RuntimeException
+        | PermissionBackendException e) {
+      op.close();
+      throw e;
+    }
+  }
+
+  private static class SubmitPreviewResult extends BinaryResult {
+
+    private final MergeOp mergeOp;
+    private final ArchiveFormat archiveFormat;
+    private final int maxBundleSize;
+
+    private SubmitPreviewResult(MergeOp mergeOp, ArchiveFormat archiveFormat, int maxBundleSize) {
+      this.mergeOp = mergeOp;
+      this.archiveFormat = archiveFormat;
+      this.maxBundleSize = maxBundleSize;
+    }
+
+    @Override
+    public void writeTo(OutputStream out) throws IOException {
+      try (ArchiveOutputStream aos = archiveFormat.createArchiveOutputStream(out)) {
+        MergeOpRepoManager orm = mergeOp.getMergeOpRepoManager();
+        for (Project.NameKey p : mergeOp.getAllProjects()) {
+          OpenRepo or = orm.getRepo(p);
+          BundleWriter bw = new BundleWriter(or.getCodeReviewRevWalk().getObjectReader());
+          bw.setObjectCountCallback(null);
+          bw.setPackConfig(new PackConfig(or.getRepo()));
+          Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates().values();
+          for (ReceiveCommand r : refs) {
+            bw.include(r.getRefName(), r.getNewId());
+            ObjectId oldId = r.getOldId();
+            if (!oldId.equals(ObjectId.zeroId())
+                // Probably the client doesn't already have NoteDb data.
+                && !RefNames.isNoteDbMetaRef(r.getRefName())) {
+              bw.assume(or.getCodeReviewRevWalk().parseCommit(oldId));
+            }
+          }
+          LimitedByteArrayOutputStream bos = new LimitedByteArrayOutputStream(maxBundleSize, 1024);
+          bw.writeBundle(NullProgressMonitor.INSTANCE, bos);
+          // This naming scheme cannot produce directory/file conflicts
+          // as no projects contains ".git/":
+          String path = p.get() + ".git";
+          archiveFormat.putEntry(aos, path, bos.toByteArray());
+        }
+      } catch (LimitExceededException e) {
+        throw new NotImplementedException("The bundle is too big to generate at the server");
+      } catch (NoSuchProjectException e) {
+        throw new IOException(e);
+      }
+    }
+
+    @Override
+    public void close() throws IOException {
+      mergeOp.close();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java b/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
new file mode 100644
index 0000000..b356f18
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsPost;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.change.ChangeEditResource;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PublishChangeEdit
+    implements ChildCollection<ChangeResource, ChangeEditResource>, AcceptsPost<ChangeResource> {
+
+  private final Publish publish;
+
+  @Inject
+  PublishChangeEdit(Publish publish) {
+    this.publish = publish;
+  }
+
+  @Override
+  public DynamicMap<RestView<ChangeEditResource>> views() {
+    throw new NotImplementedException();
+  }
+
+  @Override
+  public RestView<ChangeResource> list() {
+    throw new NotImplementedException();
+  }
+
+  @Override
+  public ChangeEditResource parse(ChangeResource parent, IdString id) {
+    throw new NotImplementedException();
+  }
+
+  @Override
+  public Publish post(ChangeResource parent) throws RestApiException {
+    return publish;
+  }
+
+  @Singleton
+  public static class Publish
+      extends RetryingRestModifyView<ChangeResource, PublishChangeEditInput, Response<?>> {
+
+    private final ChangeEditUtil editUtil;
+    private final NotifyUtil notifyUtil;
+    private final ContributorAgreementsChecker contributorAgreementsChecker;
+
+    @Inject
+    Publish(
+        RetryHelper retryHelper,
+        ChangeEditUtil editUtil,
+        NotifyUtil notifyUtil,
+        ContributorAgreementsChecker contributorAgreementsChecker) {
+      super(retryHelper);
+      this.editUtil = editUtil;
+      this.notifyUtil = notifyUtil;
+      this.contributorAgreementsChecker = contributorAgreementsChecker;
+    }
+
+    @Override
+    protected Response<?> applyImpl(
+        BatchUpdate.Factory updateFactory, ChangeResource rsrc, PublishChangeEditInput in)
+        throws IOException, OrmException, RestApiException, UpdateException, ConfigInvalidException,
+            NoSuchProjectException {
+      contributorAgreementsChecker.check(rsrc.getProject(), rsrc.getUser());
+      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
+      if (!edit.isPresent()) {
+        throw new ResourceConflictException(
+            String.format("no edit exists for change %s", rsrc.getChange().getChangeId()));
+      }
+      if (in == null) {
+        in = new PublishChangeEditInput();
+      }
+      editUtil.publish(
+          updateFactory,
+          rsrc.getNotes(),
+          rsrc.getUser(),
+          edit.get(),
+          in.notify,
+          notifyUtil.resolveAccounts(in.notifyDetails));
+      return Response.none();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
new file mode 100644
index 0000000..b6fc010
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetAssigneeOp;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.account.AccountsCollection;
+import com.google.gerrit.server.restapi.change.PostReviewers.Addition;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PutAssignee extends RetryingRestModifyView<ChangeResource, AssigneeInput, AccountInfo>
+    implements UiAction<ChangeResource> {
+
+  private final AccountsCollection accounts;
+  private final SetAssigneeOp.Factory assigneeFactory;
+  private final Provider<ReviewDb> db;
+  private final PostReviewers postReviewers;
+  private final AccountLoader.Factory accountLoaderFactory;
+
+  @Inject
+  PutAssignee(
+      AccountsCollection accounts,
+      SetAssigneeOp.Factory assigneeFactory,
+      RetryHelper retryHelper,
+      Provider<ReviewDb> db,
+      PostReviewers postReviewers,
+      AccountLoader.Factory accountLoaderFactory) {
+    super(retryHelper);
+    this.accounts = accounts;
+    this.assigneeFactory = assigneeFactory;
+    this.db = db;
+    this.postReviewers = postReviewers;
+    this.accountLoaderFactory = accountLoaderFactory;
+  }
+
+  @Override
+  protected AccountInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, AssigneeInput input)
+      throws RestApiException, UpdateException, OrmException, IOException,
+          PermissionBackendException, ConfigInvalidException {
+    rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
+
+    input.assignee = Strings.nullToEmpty(input.assignee).trim();
+    if (input.assignee.isEmpty()) {
+      throw new BadRequestException("missing assignee field");
+    }
+
+    IdentifiedUser assignee = accounts.parse(input.assignee);
+    if (!assignee.getAccount().isActive()) {
+      throw new UnprocessableEntityException(input.assignee + " is not active");
+    }
+    try {
+      rsrc.permissions().database(db).user(assignee).check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new AuthException("read not permitted for " + input.assignee);
+    }
+
+    try (BatchUpdate bu =
+        updateFactory.create(
+            db.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      SetAssigneeOp op = assigneeFactory.create(assignee);
+      bu.addOp(rsrc.getId(), op);
+
+      PostReviewers.Addition reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
+      bu.addOp(rsrc.getId(), reviewersAddition.op);
+
+      bu.execute();
+      return accountLoaderFactory.create(true).fillOne(assignee.getAccountId());
+    }
+  }
+
+  private Addition addAssigneeAsCC(ChangeResource rsrc, String assignee)
+      throws OrmException, IOException, PermissionBackendException, ConfigInvalidException {
+    AddReviewerInput reviewerInput = new AddReviewerInput();
+    reviewerInput.reviewer = assignee;
+    reviewerInput.state = ReviewerState.CC;
+    reviewerInput.confirmed = true;
+    reviewerInput.notify = NotifyHandling.NONE;
+    return postReviewers.prepareApplication(rsrc, reviewerInput, false);
+  }
+
+  @Override
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Edit Assignee")
+        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_ASSIGNEE));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PutDescription.java b/java/com/google/gerrit/server/restapi/change/PutDescription.java
new file mode 100644
index 0000000..38fc2e2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PutDescription.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+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;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Collections;
+
+@Singleton
+public class PutDescription
+    extends RetryingRestModifyView<RevisionResource, DescriptionInput, Response<String>>
+    implements UiAction<RevisionResource> {
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
+
+  @Inject
+  PutDescription(
+      Provider<ReviewDb> dbProvider,
+      ChangeMessagesUtil cmUtil,
+      RetryHelper retryHelper,
+      PatchSetUtil psUtil) {
+    super(retryHelper);
+    this.dbProvider = dbProvider;
+    this.cmUtil = cmUtil;
+    this.psUtil = psUtil;
+  }
+
+  @Override
+  protected Response<String> applyImpl(
+      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 DescriptionInput(), rsrc.getPatchSet().getId());
+    try (BatchUpdate u =
+        updateFactory.create(
+            dbProvider.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      u.addOp(rsrc.getChange().getId(), op);
+      u.execute();
+    }
+    return Strings.isNullOrEmpty(op.newDescription)
+        ? Response.none()
+        : Response.ok(op.newDescription);
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final DescriptionInput input;
+    private final PatchSet.Id psId;
+
+    private String oldDescription;
+    private String newDescription;
+
+    Op(DescriptionInput input, PatchSet.Id psId) {
+      this.input = input;
+      this.psId = psId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws OrmException {
+      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      ChangeUpdate update = ctx.getUpdate(psId);
+      newDescription = Strings.nullToEmpty(input.description);
+      oldDescription = Strings.nullToEmpty(ps.getDescription());
+      if (oldDescription.equals(newDescription)) {
+        return false;
+      }
+      String summary;
+      if (oldDescription.isEmpty()) {
+        summary = "Description set to \"" + newDescription + "\"";
+      } else if (newDescription.isEmpty()) {
+        summary = "Description \"" + oldDescription + "\" removed";
+      } else {
+        summary = "Description changed to \"" + newDescription + "\"";
+      }
+
+      ps.setDescription(newDescription);
+      update.setPsDescription(newDescription);
+
+      ctx.getDb().patchSets().update(Collections.singleton(ps));
+
+      ChangeMessage cmsg =
+          ChangeMessagesUtil.newMessage(
+              psId, ctx.getUser(), ctx.getWhen(), summary, ChangeMessagesUtil.TAG_SET_DESCRIPTION);
+      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+      return true;
+    }
+  }
+
+  @Override
+  public UiAction.Description getDescription(RevisionResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Edit Description")
+        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_DESCRIPTION));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
new file mode 100644
index 0000000..3017d89
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -0,0 +1,174 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.DraftCommentResource;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
+import java.util.Collections;
+import java.util.Optional;
+
+@Singleton
+public class PutDraftComment
+    extends RetryingRestModifyView<DraftCommentResource, DraftInput, Response<CommentInfo>> {
+
+  private final Provider<ReviewDb> db;
+  private final DeleteDraftComment delete;
+  private final CommentsUtil commentsUtil;
+  private final PatchSetUtil psUtil;
+  private final Provider<CommentJson> commentJson;
+  private final PatchListCache patchListCache;
+
+  @Inject
+  PutDraftComment(
+      Provider<ReviewDb> db,
+      DeleteDraftComment delete,
+      CommentsUtil commentsUtil,
+      PatchSetUtil psUtil,
+      RetryHelper retryHelper,
+      Provider<CommentJson> commentJson,
+      PatchListCache patchListCache) {
+    super(retryHelper);
+    this.db = db;
+    this.delete = delete;
+    this.commentsUtil = commentsUtil;
+    this.psUtil = psUtil;
+    this.commentJson = commentJson;
+    this.patchListCache = patchListCache;
+  }
+
+  @Override
+  protected Response<CommentInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, DraftCommentResource rsrc, DraftInput in)
+      throws RestApiException, UpdateException, OrmException {
+    if (in == null || in.message == null || in.message.trim().isEmpty()) {
+      return delete.applyImpl(updateFactory, rsrc, null);
+    } else if (in.id != null && !rsrc.getId().equals(in.id)) {
+      throw new BadRequestException("id must match URL");
+    } else if (in.line != null && in.line < 0) {
+      throw new BadRequestException("line must be >= 0");
+    } else if (in.line != null && in.range != null && in.line != in.range.endLine) {
+      throw new BadRequestException("range endLine must be on the same line as the comment");
+    }
+
+    try (BatchUpdate bu =
+        updateFactory.create(
+            db.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      Op op = new Op(rsrc.getComment().key, in);
+      bu.addOp(rsrc.getChange().getId(), op);
+      bu.execute();
+      return Response.ok(
+          commentJson.get().setFillAccounts(false).newCommentFormatter().format(op.comment));
+    }
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final Comment.Key key;
+    private final DraftInput in;
+
+    private Comment comment;
+
+    private Op(Comment.Key key, DraftInput in) {
+      this.key = key;
+      this.in = in;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException, OrmException {
+      Optional<Comment> maybeComment =
+          commentsUtil.getDraft(ctx.getDb(), ctx.getNotes(), ctx.getIdentifiedUser(), key);
+      if (!maybeComment.isPresent()) {
+        // Disappeared out from under us. Can't easily fall back to insert,
+        // because the input might be missing required fields. Just give up.
+        throw new ResourceNotFoundException("comment not found: " + key);
+      }
+      Comment origComment = maybeComment.get();
+      comment = new Comment(origComment);
+      // Copy constructor preserved old real author; replace with current real
+      // user.
+      ctx.getUser().updateRealAccountId(comment::setRealAuthor);
+
+      PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), origComment.key.patchSetId);
+      ChangeUpdate update = ctx.getUpdate(psId);
+
+      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      if (ps == null) {
+        throw new ResourceNotFoundException("patch set not found: " + psId);
+      }
+      if (in.path != null && !in.path.equals(origComment.key.filename)) {
+        // Updating the path alters the primary key, which isn't possible.
+        // Delete then recreate the comment instead of an update.
+
+        commentsUtil.deleteComments(ctx.getDb(), update, Collections.singleton(origComment));
+        comment.key.filename = in.path;
+      }
+      setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
+      commentsUtil.putComments(
+          ctx.getDb(),
+          update,
+          Status.DRAFT,
+          Collections.singleton(update(comment, in, ctx.getWhen())));
+      ctx.dontBumpLastUpdatedOn();
+      return true;
+    }
+  }
+
+  private static Comment update(Comment e, DraftInput in, Timestamp when) {
+    if (in.side != null) {
+      e.side = in.side();
+    }
+    if (in.inReplyTo != null) {
+      e.parentUuid = Url.decode(in.inReplyTo);
+    }
+    e.setLineNbrAndRange(in.line, in.range);
+    e.message = in.message.trim();
+    e.writtenOn = when;
+    if (in.tag != null) {
+      // TODO(dborowitz): Can we support changing tags via PUT?
+      e.tag = in.tag;
+    }
+    if (in.unresolved != null) {
+      e.unresolved = in.unresolved;
+    }
+    return e;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
new file mode 100644
index 0000000..c9c43cb
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -0,0 +1,219 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.CommitMessageInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.edit.UnchangedCommitMessageException;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.CommitMessageUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.TimeZone;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class PutMessage
+    extends RetryingRestModifyView<ChangeResource, CommitMessageInput, Response<?>> {
+
+  private final GitRepositoryManager repositoryManager;
+  private final Provider<CurrentUser> currentUserProvider;
+  private final Provider<ReviewDb> db;
+  private final TimeZone tz;
+  private final PatchSetInserter.Factory psInserterFactory;
+  private final PermissionBackend permissionBackend;
+  private final PatchSetUtil psUtil;
+  private final NotifyUtil notifyUtil;
+  private final ProjectCache projectCache;
+
+  @Inject
+  PutMessage(
+      RetryHelper retryHelper,
+      GitRepositoryManager repositoryManager,
+      Provider<CurrentUser> currentUserProvider,
+      Provider<ReviewDb> db,
+      PatchSetInserter.Factory psInserterFactory,
+      PermissionBackend permissionBackend,
+      @GerritPersonIdent PersonIdent gerritIdent,
+      PatchSetUtil psUtil,
+      NotifyUtil notifyUtil,
+      ProjectCache projectCache) {
+    super(retryHelper);
+    this.repositoryManager = repositoryManager;
+    this.currentUserProvider = currentUserProvider;
+    this.db = db;
+    this.psInserterFactory = psInserterFactory;
+    this.tz = gerritIdent.getTimeZone();
+    this.permissionBackend = permissionBackend;
+    this.psUtil = psUtil;
+    this.notifyUtil = notifyUtil;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  protected Response<String> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource resource, CommitMessageInput input)
+      throws IOException, UnchangedCommitMessageException, RestApiException, UpdateException,
+          PermissionBackendException, OrmException, ConfigInvalidException {
+    PatchSet ps = psUtil.current(db.get(), resource.getNotes());
+    if (ps == null) {
+      throw new ResourceConflictException("current revision is missing");
+    }
+
+    if (input == null) {
+      throw new BadRequestException("input cannot be null");
+    }
+    String sanitizedCommitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(input.message);
+
+    ensureCanEditCommitMessage(resource.getNotes());
+    ensureChangeIdIsCorrect(
+        projectCache.checkedGet(resource.getProject()).is(BooleanProjectConfig.REQUIRE_CHANGE_ID),
+        resource.getChange().getKey().get(),
+        sanitizedCommitMessage);
+
+    NotifyHandling notify = input.notify;
+    if (notify == null) {
+      notify = resource.getChange().isWorkInProgress() ? NotifyHandling.OWNER : NotifyHandling.ALL;
+    }
+
+    try (Repository repository = repositoryManager.openRepository(resource.getProject());
+        RevWalk revWalk = new RevWalk(repository);
+        ObjectInserter objectInserter = repository.newObjectInserter()) {
+      RevCommit patchSetCommit = revWalk.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+
+      String currentCommitMessage = patchSetCommit.getFullMessage();
+      if (input.message.equals(currentCommitMessage)) {
+        throw new ResourceConflictException("new and existing commit message are the same");
+      }
+
+      Timestamp ts = TimeUtil.nowTs();
+      try (BatchUpdate bu =
+          updateFactory.create(
+              db.get(), resource.getChange().getProject(), currentUserProvider.get(), ts)) {
+        // Ensure that BatchUpdate will update the same repo
+        bu.setRepository(repository, new RevWalk(objectInserter.newReader()), objectInserter);
+
+        PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.getId());
+        ObjectId newCommit =
+            createCommit(objectInserter, patchSetCommit, sanitizedCommitMessage, ts);
+        PatchSetInserter inserter = psInserterFactory.create(resource.getNotes(), psId, newCommit);
+        inserter.setMessage(
+            String.format("Patch Set %s: Commit message was updated.", psId.getId()));
+        inserter.setDescription("Edit commit message");
+        inserter.setNotify(notify);
+        inserter.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+        bu.addOp(resource.getChange().getId(), inserter);
+        bu.execute();
+      }
+    }
+    return Response.ok("ok");
+  }
+
+  private ObjectId createCommit(
+      ObjectInserter objectInserter,
+      RevCommit basePatchSetCommit,
+      String commitMessage,
+      Timestamp timestamp)
+      throws IOException {
+    CommitBuilder builder = new CommitBuilder();
+    builder.setTreeId(basePatchSetCommit.getTree());
+    builder.setParentIds(basePatchSetCommit.getParents());
+    builder.setAuthor(basePatchSetCommit.getAuthorIdent());
+    builder.setCommitter(
+        currentUserProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, tz));
+    builder.setMessage(commitMessage);
+    ObjectId newCommitId = objectInserter.insert(builder);
+    objectInserter.flush();
+    return newCommitId;
+  }
+
+  private void ensureCanEditCommitMessage(ChangeNotes changeNotes)
+      throws AuthException, PermissionBackendException, IOException, ResourceConflictException {
+    if (!currentUserProvider.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    try {
+      permissionBackend
+          .user(currentUserProvider.get())
+          .database(db.get())
+          .change(changeNotes)
+          .check(ChangePermission.ADD_PATCH_SET);
+      projectCache.checkedGet(changeNotes.getProjectName()).checkStatePermitsWrite();
+    } catch (AuthException denied) {
+      throw new AuthException("modifying commit message not permitted", denied);
+    }
+  }
+
+  private static void ensureChangeIdIsCorrect(
+      boolean requireChangeId, String currentChangeId, String newCommitMessage)
+      throws ResourceConflictException, BadRequestException {
+    RevCommit revCommit =
+        RevCommit.parse(
+            Constants.encode("tree " + ObjectId.zeroId().name() + "\n\n" + newCommitMessage));
+
+    // Check that the commit message without footers is not empty
+    CommitMessageUtil.checkAndSanitizeCommitMessage(revCommit.getShortMessage());
+
+    List<String> changeIdFooters = revCommit.getFooterLines(FooterConstants.CHANGE_ID);
+    if (requireChangeId && changeIdFooters.isEmpty()) {
+      throw new ResourceConflictException("missing Change-Id footer");
+    }
+    if (!changeIdFooters.isEmpty() && !changeIdFooters.get(0).equals(currentChangeId)) {
+      throw new ResourceConflictException("wrong Change-Id footer");
+    }
+    if (changeIdFooters.size() > 1) {
+      throw new ResourceConflictException("multiple Change-Id footers");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
new file mode 100644
index 0000000..4685905
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -0,0 +1,139 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+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.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.extensions.events.TopicEdited;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+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;
+
+  @Inject
+  PutTopic(
+      Provider<ReviewDb> dbProvider,
+      ChangeMessagesUtil cmUtil,
+      RetryHelper retryHelper,
+      TopicEdited topicEdited) {
+    super(retryHelper);
+    this.dbProvider = dbProvider;
+    this.cmUtil = cmUtil;
+    this.topicEdited = topicEdited;
+  }
+
+  @Override
+  protected Response<String> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource req, TopicInput input)
+      throws UpdateException, RestApiException, PermissionBackendException {
+    req.permissions().check(ChangePermission.EDIT_TOPIC_NAME);
+
+    if (input != null
+        && input.topic != null
+        && input.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
+      throw new BadRequestException(
+          String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
+    }
+
+    Op op = new Op(input != null ? input : new TopicInput());
+    try (BatchUpdate u =
+        updateFactory.create(
+            dbProvider.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
+      u.addOp(req.getId(), op);
+      u.execute();
+    }
+    return Strings.isNullOrEmpty(op.newTopicName) ? Response.none() : Response.ok(op.newTopicName);
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final TopicInput input;
+
+    private Change change;
+    private String oldTopicName;
+    private String newTopicName;
+
+    Op(TopicInput input) {
+      this.input = input;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws OrmException {
+      change = ctx.getChange();
+      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+      newTopicName = Strings.nullToEmpty(input.topic);
+      oldTopicName = Strings.nullToEmpty(change.getTopic());
+      if (oldTopicName.equals(newTopicName)) {
+        return false;
+      }
+      String summary;
+      if (oldTopicName.isEmpty()) {
+        summary = "Topic set to " + newTopicName;
+      } else if (newTopicName.isEmpty()) {
+        summary = "Topic " + oldTopicName + " removed";
+      } else {
+        summary = String.format("Topic changed from %s to %s", oldTopicName, newTopicName);
+      }
+      change.setTopic(Strings.emptyToNull(newTopicName));
+      update.setTopic(change.getTopic());
+
+      ChangeMessage cmsg =
+          ChangeMessagesUtil.newMessage(ctx, summary, ChangeMessagesUtil.TAG_SET_TOPIC);
+      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+      return true;
+    }
+
+    @Override
+    public void postUpdate(Context ctx) {
+      if (change != null) {
+        topicEdited.fire(change, ctx.getAccount(), oldTopicName, ctx.getWhen());
+      }
+    }
+  }
+
+  @Override
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Edit Topic")
+        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_TOPIC_NAME));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
new file mode 100644
index 0000000..7beef20
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -0,0 +1,159 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryRequiresAuthException;
+import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.Collections;
+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;
+  private EnumSet<ListChangesOption> options;
+
+  @Option(
+    name = "--query",
+    aliases = {"-q"},
+    metaVar = "QUERY",
+    usage = "Query string"
+  )
+  private List<String> queries;
+
+  @Option(
+    name = "--limit",
+    aliases = {"-n"},
+    metaVar = "CNT",
+    usage = "Maximum number of results to return"
+  )
+  public void setLimit(int limit) {
+    imp.setUserProvidedLimit(limit);
+  }
+
+  @Option(name = "-o", usage = "Output options per change")
+  public void addOption(ListChangesOption o) {
+    options.add(o);
+  }
+
+  @Option(name = "-O", usage = "Output option flags, in hex")
+  void setOptionFlagsHex(String hex) {
+    options.addAll(ListChangesOption.fromBits(Integer.parseInt(hex, 16)));
+  }
+
+  @Option(
+    name = "--start",
+    aliases = {"-S"},
+    metaVar = "CNT",
+    usage = "Number of changes to skip"
+  )
+  public void setStart(int start) {
+    imp.setStart(start);
+  }
+
+  @Inject
+  QueryChanges(ChangeJson.Factory json, ChangeQueryBuilder qb, ChangeQueryProcessor qp) {
+    this.json = json;
+    this.qb = qb;
+    this.imp = qp;
+
+    options = EnumSet.noneOf(ListChangesOption.class);
+  }
+
+  public void addQuery(String query) {
+    if (queries == null) {
+      queries = new ArrayList<>();
+    }
+    queries.add(query);
+  }
+
+  public String getQuery(int i) {
+    return queries.get(i);
+  }
+
+  @Override
+  public List<?> apply(TopLevelResource rsrc)
+      throws BadRequestException, AuthException, OrmException {
+    List<List<ChangeInfo>> out;
+    try {
+      out = query();
+    } 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;
+  }
+
+  private List<List<ChangeInfo>> query() throws OrmException, QueryParseException {
+    if (imp.isDisabled()) {
+      throw new QueryParseException("query disabled");
+    }
+    if (queries == null || queries.isEmpty()) {
+      queries = Collections.singletonList("status:open");
+    } else if (queries.size() > 10) {
+      // Hard-code a default maximum number of queries to prevent
+      // users from submitting too much to the server in a single call.
+      throw new QueryParseException("limit of 10 queries");
+    }
+
+    int cnt = queries.size();
+    List<QueryResult<ChangeData>> results = imp.query(qb.parse(queries));
+
+    ChangeJson cjson = json.create(options);
+    cjson.setPluginDefinedAttributesFactory(this.imp);
+    List<List<ChangeInfo>> res =
+        cjson
+            .lazyLoad(containsAnyOf(options, ChangeJson.REQUIRE_LAZY_LOAD))
+            .formatQueryResults(results);
+
+    for (int n = 0; n < cnt; n++) {
+      List<ChangeInfo> info = res.get(n);
+      if (results.get(n).more() && !info.isEmpty()) {
+        Iterables.getLast(info)._moreChanges = true;
+      }
+    }
+    return res;
+  }
+
+  private static boolean containsAnyOf(
+      EnumSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
+    return !Sets.intersection(toFind, set).isEmpty();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
new file mode 100644
index 0000000..7ee5709
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -0,0 +1,254 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.change.RebaseUtil;
+import com.google.gerrit.server.change.RebaseUtil.Base;
+import com.google.gerrit.server.change.RevisionResource;
+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.NoSuchChangeException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Rebase extends RetryingRestModifyView<RevisionResource, RebaseInput, ChangeInfo>
+    implements RestModifyView<RevisionResource, RebaseInput>, UiAction<RevisionResource> {
+  private static final Logger log = LoggerFactory.getLogger(Rebase.class);
+  private static final ImmutableSet<ListChangesOption> OPTIONS =
+      Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
+
+  private final GitRepositoryManager repoManager;
+  private final RebaseChangeOp.Factory rebaseFactory;
+  private final RebaseUtil rebaseUtil;
+  private final ChangeJson.Factory json;
+  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  public Rebase(
+      RetryHelper retryHelper,
+      GitRepositoryManager repoManager,
+      RebaseChangeOp.Factory rebaseFactory,
+      RebaseUtil rebaseUtil,
+      ChangeJson.Factory json,
+      Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend) {
+    super(retryHelper);
+    this.repoManager = repoManager;
+    this.rebaseFactory = rebaseFactory;
+    this.rebaseUtil = rebaseUtil;
+    this.json = json;
+    this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  protected ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource rsrc, RebaseInput input)
+      throws EmailException, OrmException, UpdateException, RestApiException, IOException,
+          NoSuchChangeException, PermissionBackendException {
+    rsrc.permissions().database(dbProvider).check(ChangePermission.REBASE);
+
+    Change change = rsrc.getChange();
+    try (Repository repo = repoManager.openRepository(change.getProject());
+        ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader);
+        BatchUpdate bu =
+            updateFactory.create(
+                dbProvider.get(), change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      if (!change.getStatus().isOpen()) {
+        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+      } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
+        throw new ResourceConflictException(
+            "cannot rebase merge commits or commit with no ancestor");
+      }
+      bu.setRepository(repo, rw, oi);
+      bu.addOp(
+          change.getId(),
+          rebaseFactory
+              .create(rsrc.getNotes(), rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
+              .setForceContentMerge(true)
+              .setFireRevisionCreated(true));
+      bu.execute();
+    }
+    return json.create(OPTIONS).format(change.getProject(), change.getId());
+  }
+
+  private ObjectId findBaseRev(
+      Repository repo, RevWalk rw, RevisionResource rsrc, RebaseInput input)
+      throws RestApiException, OrmException, IOException, NoSuchChangeException, AuthException,
+          PermissionBackendException {
+    Branch.NameKey destRefKey = rsrc.getChange().getDest();
+    if (input == null || input.base == null) {
+      return rebaseUtil.findBaseRevision(rsrc.getPatchSet(), destRefKey, repo, rw);
+    }
+
+    Change change = rsrc.getChange();
+    String str = input.base.trim();
+    if (str.equals("")) {
+      // Remove existing dependency to other patch set.
+      Ref destRef = repo.exactRef(destRefKey.get());
+      if (destRef == null) {
+        throw new ResourceConflictException(
+            "can't rebase onto tip of branch " + destRefKey.get() + "; branch doesn't exist");
+      }
+      return destRef.getObjectId();
+    }
+
+    Base base = rebaseUtil.parseBase(rsrc, str);
+    if (base == null) {
+      throw new ResourceConflictException("base revision is missing: " + str);
+    }
+    PatchSet.Id baseId = base.patchSet().getId();
+    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(
+          "base change is in wrong project: " + baseChange.getProject());
+    } else if (!baseChange.getDest().equals(change.getDest())) {
+      throw new ResourceConflictException(
+          "base change is targeting wrong branch: " + baseChange.getDest());
+    } else if (baseChange.getStatus() == Status.ABANDONED) {
+      throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
+    } else if (isMergedInto(rw, rsrc.getPatchSet(), base.patchSet())) {
+      throw new ResourceConflictException(
+          "base change "
+              + baseChange.getKey()
+              + " is a descendant of the current change - recursion not allowed");
+    }
+    return ObjectId.fromString(base.patchSet().getRevision().get());
+  }
+
+  private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
+    ObjectId baseId = ObjectId.fromString(base.getRevision().get());
+    ObjectId tipId = ObjectId.fromString(tip.getRevision().get());
+    return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
+  }
+
+  private boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
+    // Prevent rebase of exotic changes (merge commit, no ancestor).
+    RevCommit c = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+    return c.getParentCount() == 1;
+  }
+
+  @Override
+  public UiAction.Description getDescription(RevisionResource resource) {
+    PatchSet patchSet = resource.getPatchSet();
+    Change change = resource.getChange();
+    Branch.NameKey dest = change.getDest();
+    boolean visible = change.getStatus().isOpen() && resource.isCurrent();
+    boolean enabled = false;
+
+    if (visible) {
+      try (Repository repo = repoManager.openRepository(dest.getParentKey());
+          RevWalk rw = new RevWalk(repo)) {
+        visible = hasOneParent(rw, resource.getPatchSet());
+        if (visible) {
+          enabled = rebaseUtil.canRebase(patchSet, dest, repo, rw);
+        }
+      } catch (IOException e) {
+        log.error("Failed to check if patch set can be rebased: " + resource.getPatchSet(), e);
+        visible = false;
+      }
+    }
+
+    BooleanCondition permissionCond =
+        resource.permissions().database(dbProvider).testCond(ChangePermission.REBASE);
+    return new UiAction.Description()
+        .setLabel("Rebase")
+        .setTitle("Rebase onto tip of branch or parent change")
+        .setVisible(and(visible, permissionCond))
+        .setEnabled(and(enabled, permissionCond));
+  }
+
+  public static class CurrentRevision
+      extends RetryingRestModifyView<ChangeResource, RebaseInput, ChangeInfo> {
+    private final PatchSetUtil psUtil;
+    private final Rebase rebase;
+
+    @Inject
+    CurrentRevision(RetryHelper retryHelper, PatchSetUtil psUtil, Rebase rebase) {
+      super(retryHelper);
+      this.psUtil = psUtil;
+      this.rebase = rebase;
+    }
+
+    @Override
+    protected ChangeInfo applyImpl(
+        BatchUpdate.Factory updateFactory, ChangeResource rsrc, RebaseInput input)
+        throws EmailException, OrmException, UpdateException, RestApiException, IOException,
+            PermissionBackendException {
+      PatchSet ps = psUtil.current(rebase.dbProvider.get(), rsrc.getNotes());
+      if (ps == null) {
+        throw new ResourceConflictException("current revision is missing");
+      }
+      return rebase.applyImpl(updateFactory, new RevisionResource(rsrc, ps), input);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java b/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
new file mode 100644
index 0000000..7e1bb4d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.ChangeEditResource;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.edit.ChangeEditModifier;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class RebaseChangeEdit
+    implements ChildCollection<ChangeResource, ChangeEditResource>, AcceptsPost<ChangeResource> {
+
+  private final Rebase rebase;
+
+  @Inject
+  RebaseChangeEdit(Rebase rebase) {
+    this.rebase = rebase;
+  }
+
+  @Override
+  public DynamicMap<RestView<ChangeEditResource>> views() {
+    throw new NotImplementedException();
+  }
+
+  @Override
+  public RestView<ChangeResource> list() {
+    throw new NotImplementedException();
+  }
+
+  @Override
+  public ChangeEditResource parse(ChangeResource parent, IdString id) {
+    throw new NotImplementedException();
+  }
+
+  @Override
+  public Rebase post(ChangeResource parent) throws RestApiException {
+    return rebase;
+  }
+
+  @Singleton
+  public static class Rebase implements RestModifyView<ChangeResource, Input> {
+
+    private final GitRepositoryManager repositoryManager;
+    private final ChangeEditModifier editModifier;
+
+    @Inject
+    Rebase(GitRepositoryManager repositoryManager, ChangeEditModifier editModifier) {
+      this.repositoryManager = repositoryManager;
+      this.editModifier = editModifier;
+    }
+
+    @Override
+    public Response<?> apply(ChangeResource rsrc, Input in)
+        throws AuthException, ResourceConflictException, IOException, OrmException,
+            PermissionBackendException {
+      Project.NameKey project = rsrc.getProject();
+      try (Repository repository = repositoryManager.openRepository(project)) {
+        editModifier.rebaseEdit(repository, rsrc.getNotes());
+      } catch (InvalidChangeOperationException e) {
+        throw new ResourceConflictException(e.getMessage());
+      }
+      return Response.none();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Rebuild.java b/java/com/google/gerrit/server/restapi/change/Rebuild.java
new file mode 100644
index 0000000..4508a99
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Rebuild.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+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;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+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.ChangeResource;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class Rebuild implements RestModifyView<ChangeResource, Input> {
+
+  private final Provider<ReviewDb> db;
+  private final NotesMigration migration;
+  private final ChangeRebuilder rebuilder;
+  private final ChangeBundleReader bundleReader;
+  private final CommentsUtil commentsUtil;
+  private final ChangeNotes.Factory notesFactory;
+
+  @Inject
+  Rebuild(
+      Provider<ReviewDb> db,
+      NotesMigration migration,
+      ChangeRebuilder rebuilder,
+      ChangeBundleReader bundleReader,
+      CommentsUtil commentsUtil,
+      ChangeNotes.Factory notesFactory) {
+    this.db = db;
+    this.migration = migration;
+    this.rebuilder = rebuilder;
+    this.bundleReader = bundleReader;
+    this.commentsUtil = commentsUtil;
+    this.notesFactory = notesFactory;
+  }
+
+  @Override
+  public BinaryResult apply(ChangeResource rsrc, Input input)
+      throws ResourceNotFoundException, IOException, OrmException, ConfigInvalidException {
+    if (!migration.commitChangeWrites()) {
+      throw new ResourceNotFoundException();
+    }
+    if (!migration.readChanges()) {
+      // ChangeBundle#fromNotes currently doesn't work if reading isn't enabled,
+      // so don't attempt a diff.
+      rebuild(rsrc);
+      return BinaryResult.create("Rebuilt change successfully");
+    }
+
+    // Not the same transaction as the rebuild, so may result in spurious diffs
+    // in the case of races. This should be easy enough to detect by rerunning.
+    ChangeBundle reviewDbBundle =
+        bundleReader.fromReviewDb(ReviewDbUtil.unwrapDb(db.get()), rsrc.getId());
+    rebuild(rsrc);
+    ChangeNotes notes = notesFactory.create(db.get(), rsrc.getChange().getProject(), rsrc.getId());
+    ChangeBundle noteDbBundle = ChangeBundle.fromNotes(commentsUtil, notes);
+    List<String> diffs = reviewDbBundle.differencesFrom(noteDbBundle);
+    if (diffs.isEmpty()) {
+      return BinaryResult.create("No differences between ReviewDb and NoteDb");
+    }
+    return BinaryResult.create(
+        diffs.stream().collect(joining("\n", "Differences between ReviewDb and NoteDb:\n", "\n")));
+  }
+
+  private void rebuild(ChangeResource rsrc)
+      throws ResourceNotFoundException, OrmException, IOException {
+    try {
+      rebuilder.rebuild(db.get(), rsrc.getId());
+    } catch (NoSuchChangeException e) {
+      throw new ResourceNotFoundException(IdString.fromDecoded(rsrc.getId().toString()));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
new file mode 100644
index 0000000..1261373
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
@@ -0,0 +1,275 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.MultimapBuilder;
+import com.google.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.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;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+class RelatedChangesSorter {
+  private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
+  private final Provider<ReviewDb> dbProvider;
+
+  @Inject
+  RelatedChangesSorter(
+      GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      Provider<ReviewDb> dbProvider) {
+    this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+    this.dbProvider = dbProvider;
+  }
+
+  public List<PatchSetData> sort(List<ChangeData> in, PatchSet startPs, CurrentUser user)
+      throws OrmException, IOException, PermissionBackendException {
+    checkArgument(!in.isEmpty(), "Input may not be empty");
+    // Map of all patch sets, keyed by commit SHA-1.
+    Map<String, PatchSetData> byId = collectById(in);
+    PatchSetData start = byId.get(startPs.getRevision().get());
+    checkArgument(start != null, "%s not found in %s", startPs, in);
+    PermissionBackend.WithUser perm = permissionBackend.user(user).database(dbProvider);
+
+    // Map of patch set -> immediate parent.
+    ListMultimap<PatchSetData, PatchSetData> parents =
+        MultimapBuilder.hashKeys(in.size()).arrayListValues(3).build();
+    // Map of patch set -> immediate children.
+    ListMultimap<PatchSetData, PatchSetData> children =
+        MultimapBuilder.hashKeys(in.size()).arrayListValues(3).build();
+    // All other patch sets of the same change as startPs.
+    List<PatchSetData> otherPatchSetsOfStart = new ArrayList<>();
+
+    for (ChangeData cd : in) {
+      for (PatchSet ps : cd.patchSets()) {
+        PatchSetData thisPsd = checkNotNull(byId.get(ps.getRevision().get()));
+        if (cd.getId().equals(start.id()) && !ps.getId().equals(start.psId())) {
+          otherPatchSetsOfStart.add(thisPsd);
+        }
+        for (RevCommit p : thisPsd.commit().getParents()) {
+          PatchSetData parentPsd = byId.get(p.name());
+          if (parentPsd != null) {
+            parents.put(thisPsd, parentPsd);
+            children.put(parentPsd, thisPsd);
+          }
+        }
+      }
+    }
+
+    Collection<PatchSetData> ancestors = walkAncestors(perm, parents, start);
+    List<PatchSetData> descendants =
+        walkDescendants(perm, children, start, otherPatchSetsOfStart, ancestors);
+    List<PatchSetData> result = new ArrayList<>(ancestors.size() + descendants.size() - 1);
+    result.addAll(Lists.reverse(descendants));
+    result.addAll(ancestors);
+    return result;
+  }
+
+  private Map<String, PatchSetData> collectById(List<ChangeData> in)
+      throws OrmException, IOException {
+    Project.NameKey project = in.get(0).change().getProject();
+    Map<String, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      rw.setRetainBody(true);
+      for (ChangeData cd : in) {
+        checkArgument(
+            cd.change().getProject().equals(project),
+            "Expected change %s in project %s, found %s",
+            cd.getId(),
+            project,
+            cd.change().getProject());
+        for (PatchSet ps : cd.patchSets()) {
+          String id = ps.getRevision().get();
+          RevCommit c = rw.parseCommit(ObjectId.fromString(id));
+          PatchSetData psd = PatchSetData.create(cd, ps, c);
+          result.put(id, psd);
+        }
+      }
+    }
+    return result;
+  }
+
+  private static Collection<PatchSetData> walkAncestors(
+      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, perm)) {
+        continue;
+      }
+      result.add(psd);
+      pending.addAll(Lists.reverse(parents.get(psd)));
+    }
+    return result;
+  }
+
+  private static List<PatchSetData> walkDescendants(
+      PermissionBackend.WithUser perm,
+      ListMultimap<PatchSetData, PatchSetData> children,
+      PatchSetData start,
+      List<PatchSetData> otherPatchSetsOfStart,
+      Iterable<PatchSetData> ancestors)
+      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(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(perm, alreadyEmittedChanges, children, otherPatchSetsOfStart));
+    return result;
+  }
+
+  private static void addAllChangeIds(
+      Collection<Change.Id> changeIds, Iterable<PatchSetData> psds) {
+    for (PatchSetData psd : psds) {
+      changeIds.add(psd.id());
+    }
+  }
+
+  private static List<PatchSetData> walkDescendentsImpl(
+      PermissionBackend.WithUser perm,
+      Set<Change.Id> alreadyEmittedChanges,
+      ListMultimap<PatchSetData, PatchSetData> children,
+      List<PatchSetData> start)
+      throws PermissionBackendException {
+    if (start.isEmpty()) {
+      return ImmutableList.of();
+    }
+    Map<Change.Id, PatchSet.Id> maxPatchSetIds = new HashMap<>();
+    Set<PatchSetData> seen = new HashSet<>();
+    List<PatchSetData> allPatchSets = new ArrayList<>();
+    Deque<PatchSetData> pending = new ArrayDeque<>();
+    pending.addAll(start);
+    while (!pending.isEmpty()) {
+      PatchSetData psd = pending.remove();
+      if (seen.contains(psd) || !isVisible(psd, perm)) {
+        continue;
+      }
+      seen.add(psd);
+      if (!alreadyEmittedChanges.contains(psd.id())) {
+        // Don't emit anything for changes that were previously emitted, even
+        // though different patch sets might show up later. However, do
+        // continue walking through them for the purposes of finding indirect
+        // descendants.
+        PatchSet.Id oldMax = maxPatchSetIds.get(psd.id());
+        if (oldMax == null || psd.psId().get() > oldMax.get()) {
+          maxPatchSetIds.put(psd.id(), psd.psId());
+        }
+        allPatchSets.add(psd);
+      }
+      // Depth-first search with newest children first.
+      for (PatchSetData child : children.get(psd)) {
+        pending.addFirst(child);
+      }
+    }
+
+    // If we saw the same change multiple times, prefer the latest patch set.
+    List<PatchSetData> result = new ArrayList<>(allPatchSets.size());
+    for (PatchSetData psd : allPatchSets) {
+      if (checkNotNull(maxPatchSetIds.get(psd.id())).equals(psd.psId())) {
+        result.add(psd);
+      }
+    }
+    return result;
+  }
+
+  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
+  abstract static class PatchSetData {
+    @VisibleForTesting
+    static PatchSetData create(ChangeData cd, PatchSet ps, RevCommit commit) {
+      return new AutoValue_RelatedChangesSorter_PatchSetData(cd, ps, commit);
+    }
+
+    abstract ChangeData data();
+
+    abstract PatchSet patchSet();
+
+    abstract RevCommit commit();
+
+    PatchSet.Id psId() {
+      return patchSet().getId();
+    }
+
+    Change.Id id() {
+      return psId().getParentKey();
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(patchSet().getId(), commit());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
new file mode 100644
index 0000000..4bf1254
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -0,0 +1,165 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.RestoreInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.extensions.events.ChangeRestored;
+import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.mail.send.RestoredSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Restore extends RetryingRestModifyView<ChangeResource, RestoreInput, ChangeInfo>
+    implements UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Restore.class);
+
+  private final RestoredSender.Factory restoredSenderFactory;
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeJson.Factory json;
+  private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeRestored changeRestored;
+
+  @Inject
+  Restore(
+      RestoredSender.Factory restoredSenderFactory,
+      Provider<ReviewDb> dbProvider,
+      ChangeJson.Factory json,
+      ChangeMessagesUtil cmUtil,
+      PatchSetUtil psUtil,
+      RetryHelper retryHelper,
+      ChangeRestored changeRestored) {
+    super(retryHelper);
+    this.restoredSenderFactory = restoredSenderFactory;
+    this.dbProvider = dbProvider;
+    this.json = json;
+    this.cmUtil = cmUtil;
+    this.psUtil = psUtil;
+    this.changeRestored = changeRestored;
+  }
+
+  @Override
+  protected ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource req, RestoreInput input)
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
+    req.permissions().database(dbProvider).check(ChangePermission.RESTORE);
+
+    Op op = new Op(input);
+    try (BatchUpdate u =
+        updateFactory.create(
+            dbProvider.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
+      u.addOp(req.getId(), op).execute();
+    }
+    return json.noOptions().format(op.change);
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final RestoreInput input;
+
+    private Change change;
+    private PatchSet patchSet;
+    private ChangeMessage message;
+
+    private Op(RestoreInput input) {
+      this.input = input;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws OrmException, ResourceConflictException {
+      change = ctx.getChange();
+      if (change == null || change.getStatus() != Status.ABANDONED) {
+        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+      }
+      PatchSet.Id psId = change.currentPatchSetId();
+      ChangeUpdate update = ctx.getUpdate(psId);
+      patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      change.setStatus(Status.NEW);
+      change.setLastUpdatedOn(ctx.getWhen());
+      update.setStatus(change.getStatus());
+
+      message = newMessage(ctx);
+      cmUtil.addChangeMessage(ctx.getDb(), update, message);
+      return true;
+    }
+
+    private ChangeMessage newMessage(ChangeContext ctx) {
+      StringBuilder msg = new StringBuilder();
+      msg.append("Restored");
+      if (!Strings.nullToEmpty(input.message).trim().isEmpty()) {
+        msg.append("\n\n");
+        msg.append(input.message.trim());
+      }
+      return ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_RESTORE);
+    }
+
+    @Override
+    public void postUpdate(Context ctx) throws OrmException {
+      try {
+        ReplyToChangeSender cm = restoredSenderFactory.create(ctx.getProject(), change.getId());
+        cm.setFrom(ctx.getAccountId());
+        cm.setChangeMessage(message.getMessage(), ctx.getWhen());
+        cm.send();
+      } catch (Exception e) {
+        log.error("Cannot email update for change " + change.getId(), e);
+      }
+      changeRestored.fire(
+          change, patchSet, ctx.getAccount(), Strings.emptyToNull(input.message), ctx.getWhen());
+    }
+  }
+
+  @Override
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Restore")
+        .setTitle("Restore the change")
+        .setVisible(
+            and(
+                rsrc.getChange().getStatus() == Status.ABANDONED,
+                rsrc.permissions().database(dbProvider).testCond(ChangePermission.RESTORE)));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
new file mode 100644
index 0000000..bdab012
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -0,0 +1,302 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.RevertInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeMessages;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.extensions.events.ChangeReverted;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mail.send.RevertedSender;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.text.MessageFormat;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.ChangeIdUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Revert extends RetryingRestModifyView<ChangeResource, RevertInput, ChangeInfo>
+    implements UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Revert.class);
+
+  private final Provider<ReviewDb> db;
+  private final PermissionBackend permissionBackend;
+  private final GitRepositoryManager repoManager;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final Sequences seq;
+  private final PatchSetUtil psUtil;
+  private final RevertedSender.Factory revertedSenderFactory;
+  private final ChangeJson.Factory json;
+  private final PersonIdent serverIdent;
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeReverted changeReverted;
+  private final ContributorAgreementsChecker contributorAgreements;
+
+  @Inject
+  Revert(
+      Provider<ReviewDb> db,
+      PermissionBackend permissionBackend,
+      GitRepositoryManager repoManager,
+      ChangeInserter.Factory changeInserterFactory,
+      ChangeMessagesUtil cmUtil,
+      RetryHelper retryHelper,
+      Sequences seq,
+      PatchSetUtil psUtil,
+      RevertedSender.Factory revertedSenderFactory,
+      ChangeJson.Factory json,
+      @GerritPersonIdent PersonIdent serverIdent,
+      ApprovalsUtil approvalsUtil,
+      ChangeReverted changeReverted,
+      ContributorAgreementsChecker contributorAgreements) {
+    super(retryHelper);
+    this.db = db;
+    this.permissionBackend = permissionBackend;
+    this.repoManager = repoManager;
+    this.changeInserterFactory = changeInserterFactory;
+    this.cmUtil = cmUtil;
+    this.seq = seq;
+    this.psUtil = psUtil;
+    this.revertedSenderFactory = revertedSenderFactory;
+    this.json = json;
+    this.serverIdent = serverIdent;
+    this.approvalsUtil = approvalsUtil;
+    this.changeReverted = changeReverted;
+    this.contributorAgreements = contributorAgreements;
+  }
+
+  @Override
+  public ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, RevertInput input)
+      throws IOException, OrmException, RestApiException, UpdateException, NoSuchChangeException,
+          PermissionBackendException, NoSuchProjectException {
+    Change change = rsrc.getChange();
+    if (change.getStatus() != Change.Status.MERGED) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+    }
+
+    contributorAgreements.check(rsrc.getProject(), rsrc.getUser());
+    permissionBackend.user(rsrc.getUser()).ref(change.getDest()).check(CREATE_CHANGE);
+
+    Change.Id revertId =
+        revert(updateFactory, rsrc.getNotes(), rsrc.getUser(), Strings.emptyToNull(input.message));
+    return json.noOptions().format(rsrc.getProject(), revertId);
+  }
+
+  private Change.Id revert(
+      BatchUpdate.Factory updateFactory, ChangeNotes notes, CurrentUser user, String message)
+      throws OrmException, IOException, RestApiException, UpdateException {
+    Change.Id changeIdToRevert = notes.getChangeId();
+    PatchSet.Id patchSetId = notes.getChange().currentPatchSetId();
+    PatchSet patch = psUtil.get(db.get(), notes, patchSetId);
+    if (patch == null) {
+      throw new ResourceNotFoundException(changeIdToRevert.toString());
+    }
+
+    Project.NameKey project = notes.getProjectName();
+    try (Repository git = repoManager.openRepository(project);
+        ObjectInserter oi = git.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk revWalk = new RevWalk(reader)) {
+      RevCommit commitToRevert =
+          revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
+      if (commitToRevert.getParentCount() == 0) {
+        throw new ResourceConflictException("Cannot revert initial commit");
+      }
+
+      Timestamp now = TimeUtil.nowTs();
+      PersonIdent committerIdent = new PersonIdent(serverIdent, now);
+      PersonIdent authorIdent =
+          user.asIdentifiedUser().newCommitterIdent(now, committerIdent.getTimeZone());
+
+      RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
+      revWalk.parseHeaders(parentToCommitToRevert);
+
+      CommitBuilder revertCommitBuilder = new CommitBuilder();
+      revertCommitBuilder.addParentId(commitToRevert);
+      revertCommitBuilder.setTreeId(parentToCommitToRevert.getTree());
+      revertCommitBuilder.setAuthor(authorIdent);
+      revertCommitBuilder.setCommitter(authorIdent);
+
+      Change changeToRevert = notes.getChange();
+      if (message == null) {
+        message =
+            MessageFormat.format(
+                ChangeMessages.get().revertChangeDefaultMessage,
+                changeToRevert.getSubject(),
+                patch.getRevision().get());
+      }
+
+      ObjectId computedChangeId =
+          ChangeIdUtil.computeChangeId(
+              parentToCommitToRevert.getTree(),
+              commitToRevert,
+              authorIdent,
+              committerIdent,
+              message);
+      revertCommitBuilder.setMessage(ChangeIdUtil.insertId(message, computedChangeId, true));
+
+      Change.Id changeId = new Change.Id(seq.nextChangeId());
+      ObjectId id = oi.insert(revertCommitBuilder);
+      RevCommit revertCommit = revWalk.parseCommit(id);
+
+      ChangeInserter ins =
+          changeInserterFactory
+              .create(changeId, revertCommit, notes.getChange().getDest().get())
+              .setTopic(changeToRevert.getTopic());
+      ins.setMessage("Uploaded patch set 1.");
+
+      ReviewerSet reviewerSet = approvalsUtil.getReviewers(db.get(), notes);
+
+      Set<Account.Id> reviewers = new HashSet<>();
+      reviewers.add(changeToRevert.getOwner());
+      reviewers.addAll(reviewerSet.byState(ReviewerStateInternal.REVIEWER));
+      reviewers.remove(user.getAccountId());
+      ins.setReviewers(reviewers);
+
+      Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
+      ccs.remove(user.getAccountId());
+      ins.setExtraCC(ccs);
+      ins.setRevertOf(changeIdToRevert);
+
+      try (BatchUpdate bu = updateFactory.create(db.get(), project, user, now)) {
+        bu.setRepository(git, revWalk, oi);
+        bu.insertChange(ins);
+        bu.addOp(changeId, new NotifyOp(notes.getChange(), ins));
+        bu.addOp(changeToRevert.getId(), new PostRevertedMessageOp(computedChangeId));
+        bu.execute();
+      }
+      return changeId;
+    } catch (RepositoryNotFoundException e) {
+      throw new ResourceNotFoundException(changeIdToRevert.toString(), e);
+    }
+  }
+
+  @Override
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
+    return new UiAction.Description()
+        .setLabel("Revert")
+        .setTitle("Revert the change")
+        .setVisible(
+            and(
+                change.getStatus() == Change.Status.MERGED,
+                permissionBackend
+                    .user(rsrc.getUser())
+                    .ref(change.getDest())
+                    .testCond(CREATE_CHANGE)));
+  }
+
+  private class NotifyOp implements BatchUpdateOp {
+    private final Change change;
+    private final ChangeInserter ins;
+
+    NotifyOp(Change change, ChangeInserter ins) {
+      this.change = change;
+      this.ins = ins;
+    }
+
+    @Override
+    public void postUpdate(Context ctx) throws Exception {
+      changeReverted.fire(change, ins.getChange(), ctx.getWhen());
+      Change.Id changeId = ins.getChange().getId();
+      try {
+        RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), changeId);
+        cm.setFrom(ctx.getAccountId());
+        cm.setChangeMessage(ins.getChangeMessage().getMessage(), ctx.getWhen());
+        cm.send();
+      } catch (Exception err) {
+        log.error("Cannot send email for revert change " + changeId, err);
+      }
+    }
+  }
+
+  private class PostRevertedMessageOp implements BatchUpdateOp {
+    private final ObjectId computedChangeId;
+
+    PostRevertedMessageOp(ObjectId computedChangeId) {
+      this.computedChangeId = computedChangeId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      Change change = ctx.getChange();
+      PatchSet.Id patchSetId = change.currentPatchSetId();
+      ChangeMessage changeMessage =
+          ChangeMessagesUtil.newMessage(
+              ctx,
+              "Created a revert of this change as I" + computedChangeId.name(),
+              ChangeMessagesUtil.TAG_REVERT);
+      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(patchSetId), changeMessage);
+      return true;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewed.java b/java/com/google/gerrit/server/restapi/change/Reviewed.java
new file mode 100644
index 0000000..d1a2168
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Reviewed.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gerrit.server.change.FileResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+public class Reviewed {
+
+  @Singleton
+  public static class PutReviewed implements RestModifyView<FileResource, Input> {
+    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
+
+    @Inject
+    PutReviewed(DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
+      this.accountPatchReviewStore = accountPatchReviewStore;
+    }
+
+    @Override
+    public Response<String> apply(FileResource resource, Input input) throws OrmException {
+      if (accountPatchReviewStore
+          .get()
+          .markReviewed(
+              resource.getPatchKey().getParentKey(),
+              resource.getAccountId(),
+              resource.getPatchKey().getFileName())) {
+        return Response.created("");
+      }
+      return Response.ok("");
+    }
+  }
+
+  @Singleton
+  public static class DeleteReviewed implements RestModifyView<FileResource, Input> {
+    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
+
+    @Inject
+    DeleteReviewed(DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
+      this.accountPatchReviewStore = accountPatchReviewStore;
+    }
+
+    @Override
+    public Response<?> apply(FileResource resource, Input input) throws OrmException {
+      accountPatchReviewStore
+          .get()
+          .clearReviewed(
+              resource.getPatchKey().getParentKey(),
+              resource.getAccountId(),
+              resource.getPatchKey().getFileName());
+      return Response.none();
+    }
+  }
+
+  private Reviewed() {}
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerJson.java b/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
new file mode 100644
index 0000000..228eb47
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.common.data.LabelValue.formatValue;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import java.util.List;
+import java.util.TreeMap;
+
+@Singleton
+public class ReviewerJson {
+  private final Provider<ReviewDb> db;
+  private final PermissionBackend permissionBackend;
+  private final ChangeData.Factory changeDataFactory;
+  private final ApprovalsUtil approvalsUtil;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+
+  @Inject
+  ReviewerJson(
+      Provider<ReviewDb> db,
+      PermissionBackend permissionBackend,
+      ChangeData.Factory changeDataFactory,
+      ApprovalsUtil approvalsUtil,
+      AccountLoader.Factory accountLoaderFactory,
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+    this.db = db;
+    this.permissionBackend = permissionBackend;
+    this.changeDataFactory = changeDataFactory;
+    this.approvalsUtil = approvalsUtil;
+    this.accountLoaderFactory = accountLoaderFactory;
+    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
+  }
+
+  public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs)
+      throws OrmException, PermissionBackendException {
+    List<ReviewerInfo> infos = Lists.newArrayListWithCapacity(rsrcs.size());
+    AccountLoader loader = accountLoaderFactory.create(true);
+    ChangeData cd = null;
+    for (ReviewerResource rsrc : rsrcs) {
+      if (cd == null || !cd.getId().equals(rsrc.getChangeId())) {
+        cd = changeDataFactory.create(db.get(), rsrc.getChangeResource().getNotes());
+      }
+      ReviewerInfo info =
+          format(
+              rsrc.getChangeResource().getUser(),
+              new ReviewerInfo(rsrc.getReviewerUser().getAccountId().get()),
+              permissionBackend.user(rsrc.getReviewerUser()).database(db).change(cd),
+              cd);
+      loader.put(info);
+      infos.add(info);
+    }
+    loader.fill();
+    return infos;
+  }
+
+  public List<ReviewerInfo> format(ReviewerResource rsrc)
+      throws OrmException, PermissionBackendException {
+    return format(ImmutableList.<ReviewerResource>of(rsrc));
+  }
+
+  public ReviewerInfo format(
+      CurrentUser user, ReviewerInfo out, PermissionBackend.ForChange perm, ChangeData cd)
+      throws OrmException, PermissionBackendException {
+    PatchSet.Id psId = cd.change().currentPatchSetId();
+    return format(
+        user,
+        out,
+        perm,
+        cd,
+        approvalsUtil.byPatchSetUser(
+            db.get(), cd.notes(), perm.user(), psId, new Account.Id(out._accountId), null, null));
+  }
+
+  public ReviewerInfo format(
+      CurrentUser user,
+      ReviewerInfo out,
+      PermissionBackend.ForChange perm,
+      ChangeData cd,
+      Iterable<PatchSetApproval> approvals)
+      throws OrmException, PermissionBackendException {
+    LabelTypes labelTypes = cd.getLabelTypes();
+
+    out.approvals = new TreeMap<>(labelTypes.nameComparator());
+    for (PatchSetApproval ca : approvals) {
+      LabelType at = labelTypes.byLabel(ca.getLabelId());
+      if (at != null) {
+        out.approvals.put(at.getName(), formatValue(ca.getValue()));
+      }
+    }
+
+    // Add dummy approvals for all permitted labels for the user even if they
+    // do not exist in the DB.
+    PatchSet ps = cd.currentPatchSet();
+    if (ps != null) {
+      for (SubmitRecord rec : submitRuleEvaluatorFactory.create(user, cd).evaluate()) {
+        if (rec.labels == null) {
+          continue;
+        }
+        for (SubmitRecord.Label label : rec.labels) {
+          String name = label.label;
+          LabelType type = labelTypes.byLabel(name);
+          if (!out.approvals.containsKey(name)
+              && type != null
+              && perm.test(new LabelPermission(type))) {
+            out.approvals.put(name, formatValue((short) 0));
+          }
+        }
+      }
+    }
+
+    if (out.approvals.isEmpty()) {
+      out.approvals = null;
+    }
+
+    return out;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
new file mode 100644
index 0000000..a27d376
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -0,0 +1,282 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.change.ReviewerSuggestion;
+import com.google.gerrit.server.change.SuggestedReviewer;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+import org.apache.commons.lang.mutable.MutableDouble;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ReviewerRecommender {
+  private static final Logger log = LoggerFactory.getLogger(ReviewerRecommender.class);
+  private static final double BASE_REVIEWER_WEIGHT = 10;
+  private static final double BASE_OWNER_WEIGHT = 1;
+  private static final double BASE_COMMENT_WEIGHT = 0.5;
+  private static final double[] WEIGHTS =
+      new double[] {
+        BASE_REVIEWER_WEIGHT, BASE_OWNER_WEIGHT, BASE_COMMENT_WEIGHT,
+      };
+  private static final long PLUGIN_QUERY_TIMEOUT = 500; // ms
+
+  private final ChangeQueryBuilder changeQueryBuilder;
+  private final Config config;
+  private final DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final WorkQueue workQueue;
+  private final Provider<ReviewDb> dbProvider;
+  private final ApprovalsUtil approvalsUtil;
+
+  @Inject
+  ReviewerRecommender(
+      ChangeQueryBuilder changeQueryBuilder,
+      DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap,
+      Provider<InternalChangeQuery> queryProvider,
+      WorkQueue workQueue,
+      Provider<ReviewDb> dbProvider,
+      ApprovalsUtil approvalsUtil,
+      @GerritServerConfig Config config) {
+    this.changeQueryBuilder = changeQueryBuilder;
+    this.config = config;
+    this.queryProvider = queryProvider;
+    this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap;
+    this.workQueue = workQueue;
+    this.dbProvider = dbProvider;
+    this.approvalsUtil = approvalsUtil;
+  }
+
+  public List<Account.Id> suggestReviewers(
+      ChangeNotes changeNotes,
+      SuggestReviewers suggestReviewers,
+      ProjectState projectState,
+      List<Account.Id> candidateList)
+      throws OrmException, IOException, ConfigInvalidException {
+    String query = suggestReviewers.getQuery();
+    double baseWeight = config.getInt("addReviewer", "baseWeight", 1);
+
+    Map<Account.Id, MutableDouble> reviewerScores;
+    if (Strings.isNullOrEmpty(query)) {
+      reviewerScores = baseRankingForEmptyQuery(baseWeight);
+    } else {
+      reviewerScores = baseRankingForCandidateList(candidateList, projectState, baseWeight);
+    }
+
+    // Send the query along with a candidate list to all plugins and merge the
+    // results. Plugins don't necessarily need to use the candidates list, they
+    // can also return non-candidate account ids.
+    List<Callable<Set<SuggestedReviewer>>> tasks =
+        new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
+    List<Double> weights = new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
+
+    for (DynamicMap.Entry<ReviewerSuggestion> plugin : reviewerSuggestionPluginMap) {
+      tasks.add(
+          () ->
+              plugin
+                  .getProvider()
+                  .get()
+                  .suggestReviewers(
+                      projectState.getNameKey(),
+                      changeNotes.getChangeId(),
+                      query,
+                      reviewerScores.keySet()));
+      String pluginWeight =
+          config.getString(
+              "addReviewer", plugin.getPluginName() + "-" + plugin.getExportName(), "weight");
+      if (Strings.isNullOrEmpty(pluginWeight)) {
+        pluginWeight = "1";
+      }
+      try {
+        weights.add(Double.parseDouble(pluginWeight));
+      } catch (NumberFormatException e) {
+        log.error(
+            "Exception while parsing weight for "
+                + plugin.getPluginName()
+                + "-"
+                + plugin.getExportName(),
+            e);
+        weights.add(1d);
+      }
+    }
+
+    try {
+      List<Future<Set<SuggestedReviewer>>> futures =
+          workQueue.getDefaultQueue().invokeAll(tasks, PLUGIN_QUERY_TIMEOUT, TimeUnit.MILLISECONDS);
+      Iterator<Double> weightIterator = weights.iterator();
+      for (Future<Set<SuggestedReviewer>> f : futures) {
+        double weight = weightIterator.next();
+        for (SuggestedReviewer s : f.get()) {
+          if (reviewerScores.containsKey(s.account)) {
+            reviewerScores.get(s.account).add(s.score * weight);
+          } else {
+            reviewerScores.put(s.account, new MutableDouble(s.score * weight));
+          }
+        }
+      }
+    } catch (ExecutionException | InterruptedException e) {
+      log.error("Exception while suggesting reviewers", e);
+      return ImmutableList.of();
+    }
+
+    if (changeNotes != null) {
+      // Remove change owner
+      reviewerScores.remove(changeNotes.getChange().getOwner());
+
+      // Remove existing reviewers
+      reviewerScores
+          .keySet()
+          .removeAll(approvalsUtil.getReviewers(dbProvider.get(), changeNotes).byState(REVIEWER));
+    }
+
+    // Sort results
+    Stream<Entry<Account.Id, MutableDouble>> sorted =
+        reviewerScores
+            .entrySet()
+            .stream()
+            .sorted(Collections.reverseOrder(Map.Entry.comparingByValue()));
+    List<Account.Id> sortedSuggestions = sorted.map(Map.Entry::getKey).collect(toList());
+    return sortedSuggestions;
+  }
+
+  private Map<Account.Id, MutableDouble> baseRankingForEmptyQuery(double baseWeight)
+      throws OrmException, IOException, ConfigInvalidException {
+    // Get the user's last 25 changes, check approvals
+    try {
+      List<ChangeData> result =
+          queryProvider
+              .get()
+              .setLimit(25)
+              .setRequestedFields(ChangeField.APPROVAL)
+              .query(changeQueryBuilder.owner("self"));
+      Map<Account.Id, MutableDouble> suggestions = new HashMap<>();
+      for (ChangeData cd : result) {
+        for (PatchSetApproval approval : cd.currentApprovals()) {
+          Account.Id id = approval.getAccountId();
+          if (suggestions.containsKey(id)) {
+            suggestions.get(id).add(baseWeight);
+          } else {
+            suggestions.put(id, new MutableDouble(baseWeight));
+          }
+        }
+      }
+      return suggestions;
+    } catch (QueryParseException e) {
+      // Unhandled, because owner:self will never provoke a QueryParseException
+      log.error("Exception while suggesting reviewers", e);
+      return ImmutableMap.of();
+    }
+  }
+
+  private Map<Account.Id, MutableDouble> baseRankingForCandidateList(
+      List<Account.Id> candidates, ProjectState projectState, double baseWeight)
+      throws OrmException, IOException, ConfigInvalidException {
+    // Get each reviewer's activity based on number of applied labels
+    // (weighted 10d), number of comments (weighted 0.5d) and number of owned
+    // changes (weighted 1d).
+    Map<Account.Id, MutableDouble> reviewers = new LinkedHashMap<>();
+    if (candidates.size() == 0) {
+      return reviewers;
+    }
+    List<Predicate<ChangeData>> predicates = new ArrayList<>();
+    for (Account.Id id : candidates) {
+      try {
+        Predicate<ChangeData> projectQuery = changeQueryBuilder.project(projectState.getName());
+
+        // Get all labels for this project and create a compound OR query to
+        // fetch all changes where users have applied one of these labels
+        List<LabelType> labelTypes = projectState.getLabelTypes().getLabelTypes();
+        List<Predicate<ChangeData>> labelPredicates = new ArrayList<>(labelTypes.size());
+        for (LabelType type : labelTypes) {
+          labelPredicates.add(changeQueryBuilder.label(type.getName() + ",user=" + id));
+        }
+        Predicate<ChangeData> reviewerQuery =
+            Predicate.and(projectQuery, Predicate.or(labelPredicates));
+
+        Predicate<ChangeData> ownerQuery =
+            Predicate.and(projectQuery, changeQueryBuilder.owner(id.toString()));
+        Predicate<ChangeData> commentedByQuery =
+            Predicate.and(projectQuery, changeQueryBuilder.commentby(id.toString()));
+
+        predicates.add(reviewerQuery);
+        predicates.add(ownerQuery);
+        predicates.add(commentedByQuery);
+        reviewers.put(id, new MutableDouble());
+      } catch (QueryParseException e) {
+        // Unhandled: If an exception is thrown, we won't increase the
+        // candidates's score
+        log.error("Exception while suggesting reviewers", e);
+      }
+    }
+
+    List<List<ChangeData>> result = queryProvider.get().setLimit(25).noFields().query(predicates);
+
+    Iterator<List<ChangeData>> queryResultIterator = result.iterator();
+    Iterator<Account.Id> reviewersIterator = reviewers.keySet().iterator();
+
+    int i = 0;
+    Account.Id currentId = null;
+    while (queryResultIterator.hasNext()) {
+      List<ChangeData> currentResult = queryResultIterator.next();
+      if (i % WEIGHTS.length == 0) {
+        currentId = reviewersIterator.next();
+      }
+
+      reviewers.get(currentId).add(WEIGHTS[i % WEIGHTS.length] * baseWeight * currentResult.size());
+      i++;
+    }
+    return reviewers;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewers.java b/java/com/google/gerrit/server/restapi/change/Reviewers.java
new file mode 100644
index 0000000..a4cfbd2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Reviewers.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.restapi.account.AccountsCollection;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class Reviewers implements ChildCollection<ChangeResource, ReviewerResource> {
+  private final DynamicMap<RestView<ReviewerResource>> views;
+  private final Provider<ReviewDb> dbProvider;
+  private final ApprovalsUtil approvalsUtil;
+  private final AccountsCollection accounts;
+  private final ReviewerResource.Factory resourceFactory;
+  private final ListReviewers list;
+
+  @Inject
+  Reviewers(
+      Provider<ReviewDb> dbProvider,
+      ApprovalsUtil approvalsUtil,
+      AccountsCollection accounts,
+      ReviewerResource.Factory resourceFactory,
+      DynamicMap<RestView<ReviewerResource>> views,
+      ListReviewers list) {
+    this.dbProvider = dbProvider;
+    this.approvalsUtil = approvalsUtil;
+    this.accounts = accounts;
+    this.resourceFactory = resourceFactory;
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public DynamicMap<RestView<ReviewerResource>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<ChangeResource> list() {
+    return list;
+  }
+
+  @Override
+  public ReviewerResource parse(ChangeResource rsrc, IdString id)
+      throws OrmException, ResourceNotFoundException, AuthException, IOException,
+          ConfigInvalidException {
+    Address address = Address.tryParse(id.get());
+
+    Account.Id accountId = null;
+    try {
+      accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+    } catch (ResourceNotFoundException e) {
+      if (address == null) {
+        throw e;
+      }
+    }
+    // See if the id exists as a reviewer for this change
+    if (accountId != null && fetchAccountIds(rsrc).contains(accountId)) {
+      return resourceFactory.create(rsrc, accountId);
+    }
+
+    // See if the address exists as a reviewer on the change
+    if (address != null && rsrc.getNotes().getReviewersByEmail().all().contains(address)) {
+      return new ReviewerResource(rsrc, address);
+    }
+
+    throw new ResourceNotFoundException(id);
+  }
+
+  private Collection<Account.Id> fetchAccountIds(ChangeResource rsrc) throws OrmException {
+    return approvalsUtil.getReviewers(dbProvider.get(), rsrc.getNotes()).all();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
new file mode 100644
index 0000000..7a2a148
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -0,0 +1,372 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.extensions.common.GroupBaseInfo;
+import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.account.AccountDirectory.FillOptions;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupMembers;
+import com.google.gerrit.server.index.account.AccountField;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.account.AccountPredicates;
+import com.google.gerrit.server.query.account.AccountQueryBuilder;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public class ReviewersUtil {
+  @Singleton
+  private static class Metrics {
+    final Timer0 queryAccountsLatency;
+    final Timer0 recommendAccountsLatency;
+    final Timer0 loadAccountsLatency;
+    final Timer0 queryGroupsLatency;
+    final Timer0 filterVisibility;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      queryAccountsLatency =
+          metricMaker.newTimer(
+              "reviewer_suggestion/query_accounts",
+              new Description("Latency for querying accounts for reviewer suggestion")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
+      recommendAccountsLatency =
+          metricMaker.newTimer(
+              "reviewer_suggestion/recommend_accounts",
+              new Description("Latency for recommending accounts for reviewer suggestion")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
+      loadAccountsLatency =
+          metricMaker.newTimer(
+              "reviewer_suggestion/load_accounts",
+              new Description("Latency for loading accounts for reviewer suggestion")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
+      queryGroupsLatency =
+          metricMaker.newTimer(
+              "reviewer_suggestion/query_groups",
+              new Description("Latency for querying groups for reviewer suggestion")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
+      filterVisibility =
+          metricMaker.newTimer(
+              "reviewer_suggestion/filter_visibility",
+              new Description("Latency for removing users that can't see the change")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
+    }
+  }
+
+  // Generate a candidate list at 2x the size of what the user wants to see to
+  // give the ranking algorithm a good set of candidates it can work with
+  private static final int CANDIDATE_LIST_MULTIPLIER = 2;
+
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final AccountQueryBuilder accountQueryBuilder;
+  private final GroupBackend groupBackend;
+  private final GroupMembers groupMembers;
+  private final ReviewerRecommender reviewerRecommender;
+  private final Metrics metrics;
+  private final AccountIndexCollection accountIndexes;
+  private final IndexConfig indexConfig;
+  private final AccountControl.Factory accountControlFactory;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  ReviewersUtil(
+      AccountLoader.Factory accountLoaderFactory,
+      AccountQueryBuilder accountQueryBuilder,
+      GroupBackend groupBackend,
+      GroupMembers groupMembers,
+      ReviewerRecommender reviewerRecommender,
+      Metrics metrics,
+      AccountIndexCollection accountIndexes,
+      IndexConfig indexConfig,
+      AccountControl.Factory accountControlFactory,
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend) {
+    this.accountLoaderFactory = accountLoaderFactory;
+    this.accountQueryBuilder = accountQueryBuilder;
+    this.groupBackend = groupBackend;
+    this.groupMembers = groupMembers;
+    this.reviewerRecommender = reviewerRecommender;
+    this.metrics = metrics;
+    this.accountIndexes = accountIndexes;
+    this.indexConfig = indexConfig;
+    this.accountControlFactory = accountControlFactory;
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+  }
+
+  public interface VisibilityControl {
+    boolean isVisibleTo(Account.Id account) throws OrmException;
+  }
+
+  public List<SuggestedReviewerInfo> suggestReviewers(
+      ChangeNotes changeNotes,
+      SuggestReviewers suggestReviewers,
+      ProjectState projectState,
+      VisibilityControl visibilityControl,
+      boolean excludeGroups)
+      throws IOException, OrmException, ConfigInvalidException, PermissionBackendException {
+    String query = suggestReviewers.getQuery();
+    int limit = suggestReviewers.getLimit();
+
+    if (!suggestReviewers.getSuggestAccounts()) {
+      return Collections.emptyList();
+    }
+
+    List<Account.Id> candidateList = new ArrayList<>();
+    if (!Strings.isNullOrEmpty(query)) {
+      candidateList = suggestAccounts(suggestReviewers);
+    }
+
+    List<Account.Id> sortedRecommendations =
+        recommendAccounts(changeNotes, suggestReviewers, projectState, candidateList);
+
+    // Filter accounts by visibility and enforce limit
+    List<Account.Id> filteredRecommendations = new ArrayList<>();
+    try (Timer0.Context ctx = metrics.filterVisibility.start()) {
+      for (Account.Id reviewer : sortedRecommendations) {
+        if (filteredRecommendations.size() >= limit) {
+          break;
+        }
+        // Check if change is visible to reviewer and if the current user can see reviewer
+        if (visibilityControl.isVisibleTo(reviewer)
+            && accountControlFactory.get().canSee(reviewer)) {
+          filteredRecommendations.add(reviewer);
+        }
+      }
+    }
+
+    List<SuggestedReviewerInfo> suggestedReviewer = loadAccounts(filteredRecommendations);
+    if (!excludeGroups && suggestedReviewer.size() < limit && !Strings.isNullOrEmpty(query)) {
+      // Add groups at the end as individual accounts are usually more
+      // important.
+      suggestedReviewer.addAll(
+          suggestAccountGroups(
+              suggestReviewers, projectState, visibilityControl, limit - suggestedReviewer.size()));
+    }
+
+    if (suggestedReviewer.size() <= limit) {
+      return suggestedReviewer;
+    }
+    return suggestedReviewer.subList(0, limit);
+  }
+
+  private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers) throws OrmException {
+    try (Timer0.Context ctx = metrics.queryAccountsLatency.start()) {
+      try {
+        // For performance reasons we don't use AccountQueryProvider as it would always load the
+        // complete account from the cache (or worse, from NoteDb) even though we only need the ID
+        // which we can directly get from the returned results.
+        ResultSet<FieldBundle> result =
+            accountIndexes
+                .getSearchIndex()
+                .getSource(
+                    Predicate.and(
+                        AccountPredicates.isActive(),
+                        accountQueryBuilder.defaultQuery(suggestReviewers.getQuery())),
+                    QueryOptions.create(
+                        indexConfig,
+                        0,
+                        suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER,
+                        ImmutableSet.of(AccountField.ID.getName())))
+                .readRaw();
+        return result
+            .toList()
+            .stream()
+            .map(f -> new Account.Id(f.getValue(AccountField.ID).intValue()))
+            .collect(toList());
+      } catch (QueryParseException e) {
+        return ImmutableList.of();
+      }
+    }
+  }
+
+  private List<Account.Id> recommendAccounts(
+      ChangeNotes changeNotes,
+      SuggestReviewers suggestReviewers,
+      ProjectState projectState,
+      List<Account.Id> candidateList)
+      throws OrmException, IOException, ConfigInvalidException {
+    try (Timer0.Context ctx = metrics.recommendAccountsLatency.start()) {
+      return reviewerRecommender.suggestReviewers(
+          changeNotes, suggestReviewers, projectState, candidateList);
+    }
+  }
+
+  private List<SuggestedReviewerInfo> loadAccounts(List<Account.Id> accountIds)
+      throws OrmException, PermissionBackendException {
+    Set<FillOptions> fillOptions =
+        permissionBackend.user(self).test(GlobalPermission.MODIFY_ACCOUNT)
+            ? EnumSet.of(FillOptions.SECONDARY_EMAILS)
+            : EnumSet.noneOf(FillOptions.class);
+    fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
+    AccountLoader accountLoader = accountLoaderFactory.create(fillOptions);
+
+    try (Timer0.Context ctx = metrics.loadAccountsLatency.start()) {
+      List<SuggestedReviewerInfo> reviewer =
+          accountIds
+              .stream()
+              .map(accountLoader::get)
+              .filter(Objects::nonNull)
+              .map(
+                  a -> {
+                    SuggestedReviewerInfo info = new SuggestedReviewerInfo();
+                    info.account = a;
+                    info.count = 1;
+                    return info;
+                  })
+              .collect(toList());
+      accountLoader.fill();
+      return reviewer;
+    }
+  }
+
+  private List<SuggestedReviewerInfo> suggestAccountGroups(
+      SuggestReviewers suggestReviewers,
+      ProjectState projectState,
+      VisibilityControl visibilityControl,
+      int limit)
+      throws OrmException, IOException {
+    try (Timer0.Context ctx = metrics.queryGroupsLatency.start()) {
+      List<SuggestedReviewerInfo> groups = new ArrayList<>();
+      for (GroupReference g : suggestAccountGroups(suggestReviewers, projectState)) {
+        GroupAsReviewer result =
+            suggestGroupAsReviewer(
+                suggestReviewers, projectState.getProject(), g, visibilityControl);
+        if (result.allowed || result.allowedWithConfirmation) {
+          GroupBaseInfo info = new GroupBaseInfo();
+          info.id = Url.encode(g.getUUID().get());
+          info.name = g.getName();
+          SuggestedReviewerInfo suggestedReviewerInfo = new SuggestedReviewerInfo();
+          suggestedReviewerInfo.group = info;
+          suggestedReviewerInfo.count = result.size;
+          if (result.allowedWithConfirmation) {
+            suggestedReviewerInfo.confirm = true;
+          }
+          groups.add(suggestedReviewerInfo);
+          if (groups.size() >= limit) {
+            break;
+          }
+        }
+      }
+      return groups;
+    }
+  }
+
+  private List<GroupReference> suggestAccountGroups(
+      SuggestReviewers suggestReviewers, ProjectState projectState) {
+    return Lists.newArrayList(
+        Iterables.limit(
+            groupBackend.suggest(suggestReviewers.getQuery(), projectState),
+            suggestReviewers.getLimit()));
+  }
+
+  private static class GroupAsReviewer {
+    boolean allowed;
+    boolean allowedWithConfirmation;
+    int size;
+  }
+
+  private GroupAsReviewer suggestGroupAsReviewer(
+      SuggestReviewers suggestReviewers,
+      Project project,
+      GroupReference group,
+      VisibilityControl visibilityControl)
+      throws OrmException, IOException {
+    GroupAsReviewer result = new GroupAsReviewer();
+    int maxAllowed = suggestReviewers.getMaxAllowed();
+    int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
+
+    if (!PostReviewers.isLegalReviewerGroup(group.getUUID())) {
+      return result;
+    }
+
+    try {
+      Set<Account> members = groupMembers.listAccounts(group.getUUID(), project.getNameKey());
+
+      if (members.isEmpty()) {
+        return result;
+      }
+
+      result.size = members.size();
+      if (maxAllowed > 0 && result.size > maxAllowed) {
+        return result;
+      }
+
+      boolean needsConfirmation = result.size > maxAllowedWithoutConfirmation;
+
+      // require that at least one member in the group can see the change
+      for (Account account : members) {
+        if (visibilityControl.isVisibleTo(account.getId())) {
+          if (needsConfirmation) {
+            result.allowedWithConfirmation = true;
+          } else {
+            result.allowed = true;
+          }
+          return result;
+        }
+      }
+    } catch (NoSuchProjectException e) {
+      return result;
+    }
+
+    return result;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
new file mode 100644
index 0000000..7cf30e2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.restapi.account.AccountsCollection;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class RevisionReviewers implements ChildCollection<RevisionResource, ReviewerResource> {
+  private final DynamicMap<RestView<ReviewerResource>> views;
+  private final Provider<ReviewDb> dbProvider;
+  private final ApprovalsUtil approvalsUtil;
+  private final AccountsCollection accounts;
+  private final ReviewerResource.Factory resourceFactory;
+  private final ListRevisionReviewers list;
+
+  @Inject
+  RevisionReviewers(
+      Provider<ReviewDb> dbProvider,
+      ApprovalsUtil approvalsUtil,
+      AccountsCollection accounts,
+      ReviewerResource.Factory resourceFactory,
+      DynamicMap<RestView<ReviewerResource>> views,
+      ListRevisionReviewers list) {
+    this.dbProvider = dbProvider;
+    this.approvalsUtil = approvalsUtil;
+    this.accounts = accounts;
+    this.resourceFactory = resourceFactory;
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public DynamicMap<RestView<ReviewerResource>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<RevisionResource> list() {
+    return list;
+  }
+
+  @Override
+  public ReviewerResource parse(RevisionResource rsrc, IdString id)
+      throws OrmException, ResourceNotFoundException, AuthException, MethodNotAllowedException,
+          IOException, ConfigInvalidException {
+    if (!rsrc.isCurrent()) {
+      throw new MethodNotAllowedException("Cannot access on non-current patch set");
+    }
+    Address address = Address.tryParse(id.get());
+
+    Account.Id accountId = null;
+    try {
+      accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+    } catch (ResourceNotFoundException e) {
+      if (address == null) {
+        throw e;
+      }
+    }
+    Collection<Account.Id> reviewers =
+        approvalsUtil.getReviewers(dbProvider.get(), rsrc.getNotes()).all();
+    // See if the id exists as a reviewer for this change
+    if (reviewers.contains(accountId)) {
+      return resourceFactory.create(rsrc, accountId);
+    }
+
+    // See if the address exists as a reviewer on the change
+    if (address != null && rsrc.getNotes().getReviewersByEmail().all().contains(address)) {
+      return new ReviewerResource(rsrc, address);
+    }
+
+    throw new ResourceNotFoundException(id);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/RobotComments.java b/java/com/google/gerrit/server/restapi/change/RobotComments.java
new file mode 100644
index 0000000..6570ae0
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RobotComments.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.change.RobotCommentResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class RobotComments implements ChildCollection<RevisionResource, RobotCommentResource> {
+  private final DynamicMap<RestView<RobotCommentResource>> views;
+  private final ListRobotComments list;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  RobotComments(
+      DynamicMap<RestView<RobotCommentResource>> views,
+      ListRobotComments list,
+      CommentsUtil commentsUtil) {
+    this.views = views;
+    this.list = list;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public DynamicMap<RestView<RobotCommentResource>> views() {
+    return views;
+  }
+
+  @Override
+  public ListRobotComments list() {
+    return list;
+  }
+
+  @Override
+  public RobotCommentResource parse(RevisionResource rev, IdString id)
+      throws ResourceNotFoundException, OrmException {
+    String uuid = id.get();
+    ChangeNotes notes = rev.getNotes();
+
+    for (RobotComment c : commentsUtil.robotCommentsByPatchSet(notes, rev.getPatchSet().getId())) {
+      if (uuid.equals(c.key.uuid)) {
+        return new RobotCommentResource(rev, c);
+      }
+    }
+    throw new ResourceNotFoundException(id);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java b/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
new file mode 100644
index 0000000..1b50834
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.extensions.events.PrivateStateChanged;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class SetPrivateOp implements BatchUpdateOp {
+  public static class Input {
+    String message;
+
+    public Input() {}
+
+    public Input(String message) {
+      this.message = message;
+    }
+  }
+
+  public interface Factory {
+    SetPrivateOp create(ChangeMessagesUtil cmUtil, boolean isPrivate, Input input);
+  }
+
+  private final ChangeMessagesUtil cmUtil;
+  private final boolean isPrivate;
+  private final Input input;
+  private final PrivateStateChanged privateStateChanged;
+
+  private Change change;
+
+  @Inject
+  SetPrivateOp(
+      PrivateStateChanged privateStateChanged,
+      @Assisted ChangeMessagesUtil cmUtil,
+      @Assisted boolean isPrivate,
+      @Assisted Input input) {
+    this.cmUtil = cmUtil;
+    this.isPrivate = isPrivate;
+    this.input = input;
+    this.privateStateChanged = privateStateChanged;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, OrmException {
+    change = ctx.getChange();
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    change.setPrivate(isPrivate);
+    change.setLastUpdatedOn(ctx.getWhen());
+    update.setPrivate(isPrivate);
+    addMessage(ctx, update);
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    privateStateChanged.fire(change, ctx.getAccount(), ctx.getWhen());
+  }
+
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
+    Change c = ctx.getChange();
+    StringBuilder buf = new StringBuilder(c.isPrivate() ? "Set private" : "Unset private");
+
+    String m = Strings.nullToEmpty(input == null ? null : input.message).trim();
+    if (!m.isEmpty()) {
+      buf.append("\n\n");
+      buf.append(m);
+    }
+
+    ChangeMessage cmsg =
+        ChangeMessagesUtil.newMessage(
+            ctx,
+            buf.toString(),
+            c.isPrivate()
+                ? ChangeMessagesUtil.TAG_SET_PRIVATE
+                : ChangeMessagesUtil.TAG_UNSET_PRIVATE);
+    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
new file mode 100644
index 0000000..e701bb0
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.WorkInProgressOp;
+import com.google.gerrit.server.change.WorkInProgressOp.Input;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class SetReadyForReview extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+    implements UiAction<ChangeResource> {
+  private final WorkInProgressOp.Factory opFactory;
+  private final Provider<ReviewDb> db;
+
+  @Inject
+  SetReadyForReview(
+      RetryHelper retryHelper, WorkInProgressOp.Factory opFactory, Provider<ReviewDb> db) {
+    super(retryHelper);
+    this.opFactory = opFactory;
+    this.db = db;
+  }
+
+  @Override
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException {
+    Change change = rsrc.getChange();
+    if (!rsrc.isUserOwner()) {
+      throw new AuthException("not allowed to set ready for review");
+    }
+
+    if (change.getStatus() != Status.NEW) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+    }
+
+    if (!change.isWorkInProgress()) {
+      throw new ResourceConflictException("change is not work in progress");
+    }
+
+    try (BatchUpdate bu =
+        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      bu.addOp(rsrc.getChange().getId(), opFactory.create(false, input));
+      bu.execute();
+      return Response.ok("");
+    }
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new Description()
+        .setLabel("Start Review")
+        .setTitle("Set Ready For Review")
+        .setVisible(
+            rsrc.isUserOwner()
+                && rsrc.getChange().getStatus() == Status.NEW
+                && rsrc.getChange().isWorkInProgress());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
new file mode 100644
index 0000000..9f82433
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.WorkInProgressOp;
+import com.google.gerrit.server.change.WorkInProgressOp.Input;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class SetWorkInProgress extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+    implements UiAction<ChangeResource> {
+  private final WorkInProgressOp.Factory opFactory;
+  private final Provider<ReviewDb> db;
+
+  @Inject
+  SetWorkInProgress(
+      WorkInProgressOp.Factory opFactory, RetryHelper retryHelper, Provider<ReviewDb> db) {
+    super(retryHelper);
+    this.opFactory = opFactory;
+    this.db = db;
+  }
+
+  @Override
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException {
+    Change change = rsrc.getChange();
+    if (!rsrc.isUserOwner()) {
+      throw new AuthException("not allowed to set work in progress");
+    }
+
+    if (change.getStatus() != Status.NEW) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+    }
+
+    if (change.isWorkInProgress()) {
+      throw new ResourceConflictException("change is already work in progress");
+    }
+
+    try (BatchUpdate bu =
+        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      bu.addOp(rsrc.getChange().getId(), opFactory.create(true, input));
+      bu.execute();
+      return Response.ok("");
+    }
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new Description()
+        .setLabel("WIP")
+        .setTitle("Set Work In Progress")
+        .setVisible(
+            rsrc.isUserOwner()
+                && rsrc.getChange().getStatus() == Status.NEW
+                && !rsrc.getChange().isWorkInProgress());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
new file mode 100644
index 0000000..04aafff
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -0,0 +1,516 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ProjectUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.ChangeSet;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeOp;
+import com.google.gerrit.server.git.MergeSuperSet;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.restapi.account.AccountsCollection;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+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.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Submit
+    implements RestModifyView<RevisionResource, SubmitInput>, UiAction<RevisionResource> {
+  private static final Logger log = LoggerFactory.getLogger(Submit.class);
+
+  private static final String DEFAULT_TOOLTIP = "Submit patch set ${patchSet} into ${branch}";
+  private static final String DEFAULT_TOOLTIP_ANCESTORS =
+      "Submit patch set ${patchSet} and ancestors (${submitSize} changes "
+          + "altogether) into ${branch}";
+  private static final String DEFAULT_TOPIC_TOOLTIP =
+      "Submit all ${topicSize} changes of the same topic "
+          + "(${submitSize} changes including ancestors and other "
+          + "changes related by topic)";
+  private static final String BLOCKED_SUBMIT_TOOLTIP =
+      "This change depends on other changes which are not ready";
+  private static final String BLOCKED_HIDDEN_SUBMIT_TOOLTIP =
+      "This change depends on other hidden changes which are not ready";
+  private static final String BLOCKED_WORK_IN_PROGRESS = "This change is marked work in progress";
+  private static final String CLICK_FAILURE_TOOLTIP = "Clicking the button would fail";
+  private static final String CHANGE_UNMERGEABLE = "Problems with integrating this change";
+  private static final String CHANGES_NOT_MERGEABLE = "Problems with change(s): ";
+
+  public static class Output {
+    transient Change change;
+
+    private Output(Change c) {
+      change = c;
+    }
+  }
+
+  private final Provider<ReviewDb> dbProvider;
+  private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final Provider<MergeOp> mergeOpProvider;
+  private final Provider<MergeSuperSet> mergeSuperSet;
+  private final AccountsCollection accounts;
+  private final String label;
+  private final String labelWithParents;
+  private final ParameterizedString titlePattern;
+  private final ParameterizedString titlePatternWithAncestors;
+  private final String submitTopicLabel;
+  private final ParameterizedString submitTopicTooltip;
+  private final boolean submitWholeTopic;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final PatchSetUtil psUtil;
+
+  @Inject
+  Submit(
+      Provider<ReviewDb> dbProvider,
+      GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      ChangeData.Factory changeDataFactory,
+      ChangeMessagesUtil cmUtil,
+      ChangeNotes.Factory changeNotesFactory,
+      Provider<MergeOp> mergeOpProvider,
+      Provider<MergeSuperSet> mergeSuperSet,
+      AccountsCollection accounts,
+      @GerritServerConfig Config cfg,
+      Provider<InternalChangeQuery> queryProvider,
+      PatchSetUtil psUtil) {
+    this.dbProvider = dbProvider;
+    this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+    this.changeDataFactory = changeDataFactory;
+    this.cmUtil = cmUtil;
+    this.changeNotesFactory = changeNotesFactory;
+    this.mergeOpProvider = mergeOpProvider;
+    this.mergeSuperSet = mergeSuperSet;
+    this.accounts = accounts;
+    this.label =
+        MoreObjects.firstNonNull(
+            Strings.emptyToNull(cfg.getString("change", null, "submitLabel")), "Submit");
+    this.labelWithParents =
+        MoreObjects.firstNonNull(
+            Strings.emptyToNull(cfg.getString("change", null, "submitLabelWithParents")),
+            "Submit including parents");
+    this.titlePattern =
+        new ParameterizedString(
+            MoreObjects.firstNonNull(
+                cfg.getString("change", null, "submitTooltip"), DEFAULT_TOOLTIP));
+    this.titlePatternWithAncestors =
+        new ParameterizedString(
+            MoreObjects.firstNonNull(
+                cfg.getString("change", null, "submitTooltipAncestors"),
+                DEFAULT_TOOLTIP_ANCESTORS));
+    submitWholeTopic = MergeSuperSet.wholeTopicEnabled(cfg);
+    this.submitTopicLabel =
+        MoreObjects.firstNonNull(
+            Strings.emptyToNull(cfg.getString("change", null, "submitTopicLabel")),
+            "Submit whole topic");
+    this.submitTopicTooltip =
+        new ParameterizedString(
+            MoreObjects.firstNonNull(
+                cfg.getString("change", null, "submitTopicTooltip"), DEFAULT_TOPIC_TOOLTIP));
+    this.queryProvider = queryProvider;
+    this.psUtil = psUtil;
+  }
+
+  @Override
+  public Output apply(RevisionResource rsrc, SubmitInput input)
+      throws RestApiException, RepositoryNotFoundException, IOException, OrmException,
+          PermissionBackendException, UpdateException, ConfigInvalidException {
+    input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
+    IdentifiedUser submitter;
+    if (input.onBehalfOf != null) {
+      submitter = onBehalfOf(rsrc, input);
+    } else {
+      rsrc.permissions().check(ChangePermission.SUBMIT);
+      submitter = rsrc.getUser().asIdentifiedUser();
+    }
+
+    return new Output(mergeChange(rsrc, submitter, input));
+  }
+
+  public Change mergeChange(RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
+      throws OrmException, RestApiException, IOException, UpdateException, ConfigInvalidException,
+          PermissionBackendException {
+    Change change = rsrc.getChange();
+    if (!change.getStatus().isOpen()) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+    } else if (!ProjectUtil.branchExists(repoManager, change.getDest())) {
+      throw new ResourceConflictException(
+          String.format("destination branch \"%s\" not found.", change.getDest().get()));
+    } else if (!rsrc.getPatchSet().getId().equals(change.currentPatchSetId())) {
+      // TODO Allow submitting non-current revision by changing the current.
+      throw new ResourceConflictException(
+          String.format(
+              "revision %s is not current revision", rsrc.getPatchSet().getRevision().get()));
+    }
+
+    try (MergeOp op = mergeOpProvider.get()) {
+      ReviewDb db = dbProvider.get();
+      op.merge(db, change, submitter, true, input, false);
+      try {
+        change =
+            changeNotesFactory.createChecked(db, change.getProject(), change.getId()).getChange();
+      } catch (NoSuchChangeException e) {
+        throw new ResourceConflictException("change is deleted");
+      }
+    }
+
+    switch (change.getStatus()) {
+      case MERGED:
+        return change;
+      case NEW:
+        ChangeMessage msg = getConflictMessage(rsrc);
+        if (msg != null) {
+          throw new ResourceConflictException(msg.getMessage());
+        }
+        // $FALL-THROUGH$
+      case ABANDONED:
+      default:
+        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+    }
+  }
+
+  /**
+   * @param cd the change the user is currently looking at
+   * @param cs set of changes to be submitted at once
+   * @param user the user who is checking to submit
+   * @return a reason why any of the changes is not submittable or null
+   */
+  private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) {
+    try {
+      if (cs.furtherHiddenChanges()) {
+        return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
+      }
+      for (ChangeData c : cs.changes()) {
+        Set<ChangePermission> can =
+            permissionBackend
+                .user(user)
+                .database(dbProvider)
+                .change(c)
+                .test(EnumSet.of(ChangePermission.READ, ChangePermission.SUBMIT));
+        if (!can.contains(ChangePermission.READ)) {
+          return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
+        }
+        if (!can.contains(ChangePermission.SUBMIT)) {
+          return BLOCKED_SUBMIT_TOOLTIP;
+        }
+        if (c.change().isWorkInProgress()) {
+          return BLOCKED_WORK_IN_PROGRESS;
+        }
+        MergeOp.checkSubmitRule(c, false);
+      }
+
+      Collection<ChangeData> unmergeable = unmergeableChanges(cs);
+      if (unmergeable == null) {
+        return CLICK_FAILURE_TOOLTIP;
+      } else if (!unmergeable.isEmpty()) {
+        for (ChangeData c : unmergeable) {
+          if (c.change().getKey().equals(cd.change().getKey())) {
+            return CHANGE_UNMERGEABLE;
+          }
+        }
+        return CHANGES_NOT_MERGEABLE
+            + unmergeable.stream().map(c -> c.getId().toString()).collect(joining(", "));
+      }
+    } catch (ResourceConflictException e) {
+      return BLOCKED_SUBMIT_TOOLTIP;
+    } catch (PermissionBackendException | OrmException | IOException e) {
+      log.error("Error checking if change is submittable", e);
+      throw new OrmRuntimeException("Could not determine problems for the change", e);
+    }
+    return null;
+  }
+
+  @Override
+  public UiAction.Description getDescription(RevisionResource resource) {
+    Change change = resource.getChange();
+    if (!change.getStatus().isOpen()
+        || !resource.isCurrent()
+        || !resource.permissions().testOrFalse(ChangePermission.SUBMIT)) {
+      return null; // submit not visible
+    }
+
+    ReviewDb db = dbProvider.get();
+    ChangeData cd = changeDataFactory.create(db, resource.getNotes());
+    try {
+      MergeOp.checkSubmitRule(cd, false);
+    } catch (ResourceConflictException e) {
+      return null; // submit not visible
+    } catch (OrmException e) {
+      log.error("Error checking if change is submittable", e);
+      throw new OrmRuntimeException("Could not determine problems for the change", e);
+    }
+
+    ChangeSet cs;
+    try {
+      cs = mergeSuperSet.get().completeChangeSet(db, cd.change(), resource.getUser());
+    } catch (OrmException | IOException | PermissionBackendException e) {
+      throw new OrmRuntimeException(
+          "Could not determine complete set of changes to be submitted", e);
+    }
+
+    String topic = change.getTopic();
+    int topicSize = 0;
+    if (!Strings.isNullOrEmpty(topic)) {
+      topicSize = getChangesByTopic(topic).size();
+    }
+    boolean treatWithTopic = submitWholeTopic && !Strings.isNullOrEmpty(topic) && topicSize > 1;
+
+    String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
+
+    Boolean enabled;
+    try {
+      // Recheck mergeability rather than using value stored in the index,
+      // which may be stale.
+      // TODO(dborowitz): This is ugly; consider providing a way to not read
+      // stored fields from the index in the first place.
+      // cd.setMergeable(null);
+      // That was done in unmergeableChanges which was called by
+      // problemsForSubmittingChangeset, so now it is safe to read from
+      // the cache, as it yields the same result.
+      enabled = cd.isMergeable();
+    } catch (OrmException e) {
+      throw new OrmRuntimeException("Could not determine mergeability", e);
+    }
+
+    if (submitProblems != null) {
+      return new UiAction.Description()
+          .setLabel(treatWithTopic ? submitTopicLabel : (cs.size() > 1) ? labelWithParents : label)
+          .setTitle(submitProblems)
+          .setVisible(true)
+          .setEnabled(false);
+    }
+
+    if (treatWithTopic) {
+      Map<String, String> params =
+          ImmutableMap.of(
+              "topicSize", String.valueOf(topicSize),
+              "submitSize", String.valueOf(cs.size()));
+      return new UiAction.Description()
+          .setLabel(submitTopicLabel)
+          .setTitle(Strings.emptyToNull(submitTopicTooltip.replace(params)))
+          .setVisible(true)
+          .setEnabled(Boolean.TRUE.equals(enabled));
+    }
+    RevId revId = resource.getPatchSet().getRevision();
+    Map<String, String> params =
+        ImmutableMap.of(
+            "patchSet", String.valueOf(resource.getPatchSet().getPatchSetId()),
+            "branch", change.getDest().getShortName(),
+            "commit", ObjectId.fromString(revId.get()).abbreviate(7).name(),
+            "submitSize", String.valueOf(cs.size()));
+    ParameterizedString tp = cs.size() > 1 ? titlePatternWithAncestors : titlePattern;
+    return new UiAction.Description()
+        .setLabel(cs.size() > 1 ? labelWithParents : label)
+        .setTitle(Strings.emptyToNull(tp.replace(params)))
+        .setVisible(true)
+        .setEnabled(Boolean.TRUE.equals(enabled));
+  }
+
+  /**
+   * If the merge was attempted and it failed the system usually writes a comment as a ChangeMessage
+   * and sets status to NEW. Find the relevant message and return it.
+   */
+  public ChangeMessage getConflictMessage(RevisionResource rsrc) throws OrmException {
+    return FluentIterable.from(
+            cmUtil.byPatchSet(dbProvider.get(), rsrc.getNotes(), rsrc.getPatchSet().getId()))
+        .filter(cm -> cm.getAuthor() == null)
+        .last()
+        .orNull();
+  }
+
+  public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws OrmException, IOException {
+    Set<ChangeData> mergeabilityMap = new HashSet<>();
+    for (ChangeData change : cs.changes()) {
+      mergeabilityMap.add(change);
+    }
+
+    ListMultimap<Branch.NameKey, ChangeData> cbb = cs.changesByBranch();
+    for (Branch.NameKey branch : cbb.keySet()) {
+      Collection<ChangeData> targetBranch = cbb.get(branch);
+      HashMap<Change.Id, RevCommit> commits = findCommits(targetBranch, branch.getParentKey());
+
+      Set<ObjectId> allParents = Sets.newHashSetWithExpectedSize(cs.size());
+      for (RevCommit commit : commits.values()) {
+        for (RevCommit parent : commit.getParents()) {
+          allParents.add(parent.getId());
+        }
+      }
+
+      for (ChangeData change : targetBranch) {
+        RevCommit commit = commits.get(change.getId());
+        boolean isMergeCommit = commit.getParentCount() > 1;
+        boolean isLastInChain = !allParents.contains(commit.getId());
+
+        // Recheck mergeability rather than using value stored in the index,
+        // which may be stale.
+        // TODO(dborowitz): This is ugly; consider providing a way to not read
+        // stored fields from the index in the first place.
+        change.setMergeable(null);
+        Boolean mergeable = change.isMergeable();
+        if (mergeable == null) {
+          // Skip whole check, cannot determine if mergeable
+          return null;
+        }
+        if (mergeable) {
+          mergeabilityMap.remove(change);
+        }
+
+        if (isLastInChain && isMergeCommit && mergeable) {
+          for (ChangeData c : targetBranch) {
+            mergeabilityMap.remove(c);
+          }
+          break;
+        }
+      }
+    }
+    return mergeabilityMap;
+  }
+
+  private HashMap<Change.Id, RevCommit> findCommits(
+      Collection<ChangeData> changes, Project.NameKey project) throws IOException, OrmException {
+    HashMap<Change.Id, RevCommit> commits = new HashMap<>();
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk walk = new RevWalk(repo)) {
+      for (ChangeData change : changes) {
+        RevCommit commit =
+            walk.parseCommit(
+                ObjectId.fromString(
+                    psUtil.current(dbProvider.get(), change.notes()).getRevision().get()));
+        commits.put(change.getId(), commit);
+      }
+    }
+    return commits;
+  }
+
+  private IdentifiedUser onBehalfOf(RevisionResource rsrc, SubmitInput in)
+      throws AuthException, UnprocessableEntityException, OrmException, PermissionBackendException,
+          IOException, ConfigInvalidException {
+    PermissionBackend.ForChange perm = rsrc.permissions().database(dbProvider);
+    perm.check(ChangePermission.SUBMIT);
+    perm.check(ChangePermission.SUBMIT_AS);
+
+    CurrentUser caller = rsrc.getUser();
+    IdentifiedUser submitter = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
+    try {
+      perm.user(submitter).check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new UnprocessableEntityException(
+          String.format("on_behalf_of account %s cannot see change", submitter.getAccountId()));
+    }
+    return submitter;
+  }
+
+  private List<ChangeData> getChangesByTopic(String topic) {
+    try {
+      return queryProvider.get().byTopicOpen(topic);
+    } catch (OrmException e) {
+      throw new OrmRuntimeException(e);
+    }
+  }
+
+  public static class CurrentRevision implements RestModifyView<ChangeResource, SubmitInput> {
+    private final Provider<ReviewDb> dbProvider;
+    private final Submit submit;
+    private final ChangeJson.Factory json;
+    private final PatchSetUtil psUtil;
+
+    @Inject
+    CurrentRevision(
+        Provider<ReviewDb> dbProvider,
+        Submit submit,
+        ChangeJson.Factory json,
+        PatchSetUtil psUtil) {
+      this.dbProvider = dbProvider;
+      this.submit = submit;
+      this.json = json;
+      this.psUtil = psUtil;
+    }
+
+    @Override
+    public ChangeInfo apply(ChangeResource rsrc, SubmitInput input)
+        throws RestApiException, RepositoryNotFoundException, IOException, OrmException,
+            PermissionBackendException, UpdateException, ConfigInvalidException {
+      PatchSet ps = psUtil.current(dbProvider.get(), rsrc.getNotes());
+      if (ps == null) {
+        throw new ResourceConflictException("current revision is missing");
+      }
+
+      Output out = submit.apply(new RevisionResource(rsrc, ps), input);
+      return json.noOptions().format(out.change);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
new file mode 100644
index 0000000..9e7769c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -0,0 +1,171 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.WalkSorter;
+import com.google.gerrit.server.change.WalkSorter.PatchSetData;
+import com.google.gerrit.server.git.ChangeSet;
+import com.google.gerrit.server.git.MergeSuperSet;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class SubmittedTogether implements RestReadView<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(SubmittedTogether.class);
+
+  private final EnumSet<SubmittedTogetherOption> options =
+      EnumSet.noneOf(SubmittedTogetherOption.class);
+
+  private final EnumSet<ListChangesOption> jsonOpt =
+      EnumSet.of(
+          ListChangesOption.CURRENT_REVISION,
+          ListChangesOption.CURRENT_COMMIT,
+          ListChangesOption.SUBMITTABLE);
+
+  private final ChangeJson.Factory json;
+  private final Provider<ReviewDb> dbProvider;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Provider<MergeSuperSet> mergeSuperSet;
+  private final Provider<WalkSorter> sorter;
+
+  @Option(name = "-o", usage = "Output options")
+  void addOption(String option) {
+    for (ListChangesOption o : ListChangesOption.values()) {
+      if (o.name().equalsIgnoreCase(option)) {
+        jsonOpt.add(o);
+        return;
+      }
+    }
+
+    for (SubmittedTogetherOption o : SubmittedTogetherOption.values()) {
+      if (o.name().equalsIgnoreCase(option)) {
+        options.add(o);
+        return;
+      }
+    }
+
+    throw new IllegalArgumentException("option not recognized: " + option);
+  }
+
+  @Inject
+  SubmittedTogether(
+      ChangeJson.Factory json,
+      Provider<ReviewDb> dbProvider,
+      Provider<InternalChangeQuery> queryProvider,
+      Provider<MergeSuperSet> mergeSuperSet,
+      Provider<WalkSorter> sorter) {
+    this.json = json;
+    this.dbProvider = dbProvider;
+    this.queryProvider = queryProvider;
+    this.mergeSuperSet = mergeSuperSet;
+    this.sorter = sorter;
+  }
+
+  public SubmittedTogether addListChangesOption(EnumSet<ListChangesOption> o) {
+    jsonOpt.addAll(o);
+    return this;
+  }
+
+  public SubmittedTogether addSubmittedTogetherOption(EnumSet<SubmittedTogetherOption> o) {
+    options.addAll(o);
+    return this;
+  }
+
+  @Override
+  public Object apply(ChangeResource resource)
+      throws AuthException, BadRequestException, ResourceConflictException, IOException,
+          OrmException, PermissionBackendException {
+    SubmittedTogetherInfo info = applyInfo(resource);
+    if (options.isEmpty()) {
+      return info.changes;
+    }
+    return info;
+  }
+
+  public SubmittedTogetherInfo applyInfo(ChangeResource resource)
+      throws AuthException, IOException, OrmException, PermissionBackendException {
+    Change c = resource.getChange();
+    try {
+      List<ChangeData> cds;
+      int hidden;
+
+      if (c.getStatus().isOpen()) {
+        ChangeSet cs =
+            mergeSuperSet.get().completeChangeSet(dbProvider.get(), c, resource.getUser());
+        cds = cs.changes().asList();
+        hidden = cs.nonVisibleChanges().size();
+      } else if (c.getStatus().asChangeStatus() == ChangeStatus.MERGED) {
+        cds = queryProvider.get().bySubmissionId(c.getSubmissionId());
+        hidden = 0;
+      } else {
+        cds = Collections.emptyList();
+        hidden = 0;
+      }
+
+      if (hidden != 0 && !options.contains(NON_VISIBLE_CHANGES)) {
+        throw new AuthException("change would be submitted with a change that you cannot see");
+      }
+
+      if (cds.size() <= 1 && hidden == 0) {
+        cds = Collections.emptyList();
+      } else {
+        // Skip sorting for singleton lists, to avoid WalkSorter opening the
+        // repo just to fill out the commit field in PatchSetData.
+        cds = sort(cds);
+      }
+
+      SubmittedTogetherInfo info = new SubmittedTogetherInfo();
+      info.changes = json.create(jsonOpt).formatChangeDatas(cds);
+      info.nonVisibleChanges = hidden;
+      return info;
+    } catch (OrmException | IOException e) {
+      log.error("Error on getting a ChangeSet", e);
+      throw e;
+    }
+  }
+
+  private List<ChangeData> sort(List<ChangeData> cds) throws OrmException, IOException {
+    List<ChangeData> sorted = new ArrayList<>(cds.size());
+    for (PatchSetData psd : sorter.get().sort(cds)) {
+      sorted.add(psd.data());
+    }
+    return sorted;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
new file mode 100644
index 0000000..4dc5b06
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.common.AccountVisibility;
+import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.restapi.change.ReviewersUtil.VisibilityControl;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
+
+public class SuggestChangeReviewers extends SuggestReviewers
+    implements RestReadView<ChangeResource> {
+
+  @Option(
+    name = "--exclude-groups",
+    aliases = {"-e"},
+    usage = "exclude groups from query"
+  )
+  boolean excludeGroups;
+
+  private final Provider<CurrentUser> self;
+  private final ProjectCache projectCache;
+
+  @Inject
+  SuggestChangeReviewers(
+      AccountVisibility av,
+      GenericFactory identifiedUserFactory,
+      Provider<ReviewDb> dbProvider,
+      Provider<CurrentUser> self,
+      @GerritServerConfig Config cfg,
+      ReviewersUtil reviewersUtil,
+      ProjectCache projectCache) {
+    super(av, identifiedUserFactory, dbProvider, cfg, reviewersUtil);
+    this.self = self;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public List<SuggestedReviewerInfo> apply(ChangeResource rsrc)
+      throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (!self.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    return reviewersUtil.suggestReviewers(
+        rsrc.getNotes(),
+        this,
+        projectCache.checkedGet(rsrc.getProject()),
+        getVisibility(rsrc),
+        excludeGroups);
+  }
+
+  private VisibilityControl getVisibility(ChangeResource rsrc) {
+    // Use the destination reference, not the change, as drafts may deny
+    // anyone who is not already a reviewer.
+    return account -> {
+      IdentifiedUser who = identifiedUserFactory.create(account);
+      return rsrc.permissions().user(who).testOrFalse(ChangePermission.READ);
+    };
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
new file mode 100644
index 0000000..dcb35ab
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.common.AccountVisibility;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
+
+public class SuggestReviewers {
+  private static final int DEFAULT_MAX_SUGGESTED = 10;
+
+  protected final Provider<ReviewDb> dbProvider;
+  protected final IdentifiedUser.GenericFactory identifiedUserFactory;
+  protected final ReviewersUtil reviewersUtil;
+
+  private final boolean suggestAccounts;
+  private final int maxAllowed;
+  private final int maxAllowedWithoutConfirmation;
+  protected int limit;
+  protected String query;
+  protected final int maxSuggestedReviewers;
+
+  @Option(
+    name = "--limit",
+    aliases = {"-n"},
+    metaVar = "CNT",
+    usage = "maximum number of reviewers to list"
+  )
+  public void setLimit(int l) {
+    this.limit = l <= 0 ? maxSuggestedReviewers : Math.min(l, maxSuggestedReviewers);
+  }
+
+  @Option(
+    name = "--query",
+    aliases = {"-q"},
+    metaVar = "QUERY",
+    usage = "match reviewers query"
+  )
+  public void setQuery(String q) {
+    this.query = q;
+  }
+
+  public String getQuery() {
+    return query;
+  }
+
+  public boolean getSuggestAccounts() {
+    return suggestAccounts;
+  }
+
+  public int getLimit() {
+    return limit;
+  }
+
+  public int getMaxAllowed() {
+    return maxAllowed;
+  }
+
+  public int getMaxAllowedWithoutConfirmation() {
+    return maxAllowedWithoutConfirmation;
+  }
+
+  @Inject
+  public SuggestReviewers(
+      AccountVisibility av,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      Provider<ReviewDb> dbProvider,
+      @GerritServerConfig Config cfg,
+      ReviewersUtil reviewersUtil) {
+    this.dbProvider = dbProvider;
+    this.identifiedUserFactory = identifiedUserFactory;
+    this.reviewersUtil = reviewersUtil;
+    this.maxSuggestedReviewers =
+        cfg.getInt("suggest", "maxSuggestedReviewers", DEFAULT_MAX_SUGGESTED);
+    this.limit = this.maxSuggestedReviewers;
+    String suggest = cfg.getString("suggest", null, "accounts");
+    if ("OFF".equalsIgnoreCase(suggest) || "false".equalsIgnoreCase(suggest)) {
+      this.suggestAccounts = false;
+    } else {
+      this.suggestAccounts = (av != AccountVisibility.NONE);
+    }
+
+    this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed", PostReviewers.DEFAULT_MAX_REVIEWERS);
+    this.maxAllowedWithoutConfirmation =
+        cfg.getInt(
+            "addreviewer",
+            "maxWithoutConfirmation",
+            PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
new file mode 100644
index 0000000..efb0f4d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.TestSubmitRuleInput;
+import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.RulesCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.kohsuke.args4j.Option;
+
+public class TestSubmitRule implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
+  private final Provider<ReviewDb> db;
+  private final ChangeData.Factory changeDataFactory;
+  private final RulesCache rules;
+  private final AccountLoader.Factory accountInfoFactory;
+  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+
+  @Option(name = "--filters", usage = "impact of filters in parent projects")
+  private Filters filters = Filters.RUN;
+
+  @Inject
+  TestSubmitRule(
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      RulesCache rules,
+      AccountLoader.Factory infoFactory,
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+    this.db = db;
+    this.changeDataFactory = changeDataFactory;
+    this.rules = rules;
+    this.accountInfoFactory = infoFactory;
+    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
+  }
+
+  @Override
+  public List<Record> apply(RevisionResource rsrc, TestSubmitRuleInput input)
+      throws AuthException, OrmException {
+    if (input == null) {
+      input = new TestSubmitRuleInput();
+    }
+    if (input.rule != null && !rules.isProjectRulesEnabled()) {
+      throw new AuthException("project rules are disabled");
+    }
+    input.filters = MoreObjects.firstNonNull(input.filters, filters);
+    SubmitRuleEvaluator evaluator =
+        submitRuleEvaluatorFactory.create(
+            rsrc.getUser(), changeDataFactory.create(db.get(), rsrc.getNotes()));
+
+    List<SubmitRecord> records =
+        evaluator
+            .setPatchSet(rsrc.getPatchSet())
+            .setLogErrors(false)
+            .setSkipSubmitFilters(input.filters == Filters.SKIP)
+            .setRule(input.rule)
+            .evaluate();
+    List<Record> out = Lists.newArrayListWithCapacity(records.size());
+    AccountLoader accounts = accountInfoFactory.create(true);
+    for (SubmitRecord r : records) {
+      out.add(new Record(r, accounts));
+    }
+    if (!out.isEmpty()) {
+      out.get(0).prologReductionCount = evaluator.getReductionsConsumed();
+    }
+    accounts.fill();
+    return out;
+  }
+
+  static class Record {
+    SubmitRecord.Status status;
+    String errorMessage;
+    Map<String, AccountInfo> ok;
+    Map<String, AccountInfo> reject;
+    Map<String, None> need;
+    Map<String, AccountInfo> may;
+    Map<String, None> impossible;
+    Long prologReductionCount;
+
+    Record(SubmitRecord r, AccountLoader accounts) {
+      this.status = r.status;
+      this.errorMessage = r.errorMessage;
+
+      if (r.labels != null) {
+        for (SubmitRecord.Label n : r.labels) {
+          AccountInfo who = n.appliedBy != null ? accounts.get(n.appliedBy) : new AccountInfo(null);
+          label(n, who);
+        }
+      }
+    }
+
+    private void label(SubmitRecord.Label n, AccountInfo who) {
+      switch (n.status) {
+        case OK:
+          if (ok == null) {
+            ok = new LinkedHashMap<>();
+          }
+          ok.put(n.label, who);
+          break;
+        case REJECT:
+          if (reject == null) {
+            reject = new LinkedHashMap<>();
+          }
+          reject.put(n.label, who);
+          break;
+        case NEED:
+          if (need == null) {
+            need = new LinkedHashMap<>();
+          }
+          need.put(n.label, new None());
+          break;
+        case MAY:
+          if (may == null) {
+            may = new LinkedHashMap<>();
+          }
+          may.put(n.label, who);
+          break;
+        case IMPOSSIBLE:
+          if (impossible == null) {
+            impossible = new LinkedHashMap<>();
+          }
+          impossible.put(n.label, new None());
+          break;
+      }
+    }
+  }
+
+  static class None {}
+}
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
new file mode 100644
index 0000000..2782a66
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.TestSubmitRuleInput;
+import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.RulesCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.kohsuke.args4j.Option;
+
+public class TestSubmitType implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
+  private final Provider<ReviewDb> db;
+  private final ChangeData.Factory changeDataFactory;
+  private final RulesCache rules;
+  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+
+  @Option(name = "--filters", usage = "impact of filters in parent projects")
+  private Filters filters = Filters.RUN;
+
+  @Inject
+  TestSubmitType(
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      RulesCache rules,
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+    this.db = db;
+    this.changeDataFactory = changeDataFactory;
+    this.rules = rules;
+    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
+  }
+
+  @Override
+  public SubmitType apply(RevisionResource rsrc, TestSubmitRuleInput input)
+      throws AuthException, BadRequestException, OrmException {
+    if (input == null) {
+      input = new TestSubmitRuleInput();
+    }
+    if (input.rule != null && !rules.isProjectRulesEnabled()) {
+      throw new AuthException("project rules are disabled");
+    }
+    input.filters = MoreObjects.firstNonNull(input.filters, filters);
+    SubmitRuleEvaluator evaluator =
+        submitRuleEvaluatorFactory.create(
+            rsrc.getUser(), changeDataFactory.create(db.get(), rsrc.getNotes()));
+
+    SubmitTypeRecord rec =
+        evaluator
+            .setPatchSet(rsrc.getPatchSet())
+            .setLogErrors(false)
+            .setSkipSubmitFilters(input.filters == Filters.SKIP)
+            .setRule(input.rule)
+            .getSubmitType();
+    if (rec.status != SubmitTypeRecord.Status.OK) {
+      throw new BadRequestException(
+          String.format("rule %s produced invalid result: %s", evaluator.getSubmitRuleName(), rec));
+    }
+
+    return rec.type;
+  }
+
+  public static class Get implements RestReadView<RevisionResource> {
+    private final TestSubmitType test;
+
+    @Inject
+    Get(TestSubmitType test) {
+      this.test = test;
+    }
+
+    @Override
+    public SubmitType apply(RevisionResource resource)
+        throws AuthException, BadRequestException, OrmException {
+      return test.apply(resource, null);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Unignore.java b/java/com/google/gerrit/server/restapi/change/Unignore.java
new file mode 100644
index 0000000..d1be312
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Unignore.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Unignore implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Unignore.class);
+
+  private final StarredChangesUtil stars;
+
+  @Inject
+  Unignore(StarredChangesUtil stars) {
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Unignore")
+        .setTitle("Unignore the change")
+        .setVisible(isIgnored(rsrc));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input)
+      throws OrmException, IllegalLabelException {
+    if (isIgnored(rsrc)) {
+      stars.unignore(rsrc);
+    }
+    return Response.ok("");
+  }
+
+  private boolean isIgnored(ChangeResource rsrc) {
+    try {
+      return stars.isIgnored(rsrc);
+    } catch (OrmException e) {
+      log.error("failed to check ignored star", e);
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Votes.java b/java/com/google/gerrit/server/restapi/change/Votes.java
new file mode 100644
index 0000000..b931c7e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/Votes.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.change.VoteResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Map;
+import java.util.TreeMap;
+
+@Singleton
+public class Votes implements ChildCollection<ReviewerResource, VoteResource> {
+  private final DynamicMap<RestView<VoteResource>> views;
+  private final List list;
+
+  @Inject
+  Votes(DynamicMap<RestView<VoteResource>> views, List list) {
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public DynamicMap<RestView<VoteResource>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<ReviewerResource> list() throws AuthException {
+    return list;
+  }
+
+  @Override
+  public VoteResource parse(ReviewerResource reviewer, IdString id)
+      throws ResourceNotFoundException, OrmException, AuthException, MethodNotAllowedException {
+    if (reviewer.getRevisionResource() != null && !reviewer.getRevisionResource().isCurrent()) {
+      throw new MethodNotAllowedException("Cannot access on non-current patch set");
+    }
+    return new VoteResource(reviewer, id.get());
+  }
+
+  @Singleton
+  public static class List implements RestReadView<ReviewerResource> {
+    private final Provider<ReviewDb> db;
+    private final ApprovalsUtil approvalsUtil;
+
+    @Inject
+    List(Provider<ReviewDb> db, ApprovalsUtil approvalsUtil) {
+      this.db = db;
+      this.approvalsUtil = approvalsUtil;
+    }
+
+    @Override
+    public Map<String, Short> apply(ReviewerResource rsrc)
+        throws OrmException, MethodNotAllowedException {
+      if (rsrc.getRevisionResource() != null && !rsrc.getRevisionResource().isCurrent()) {
+        throw new MethodNotAllowedException("Cannot list votes on non-current patch set");
+      }
+
+      Map<String, Short> votes = new TreeMap<>();
+      Iterable<PatchSetApproval> byPatchSetUser =
+          approvalsUtil.byPatchSetUser(
+              db.get(),
+              rsrc.getChangeResource().getNotes(),
+              rsrc.getChangeResource().getUser(),
+              rsrc.getChange().currentPatchSetId(),
+              rsrc.getReviewerUser().getAccountId(),
+              null,
+              null);
+      for (PatchSetApproval psa : byPatchSetUser) {
+        votes.put(psa.getLabel(), psa.getValue());
+      }
+      return votes;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/AgreementJson.java b/java/com/google/gerrit/server/restapi/config/AgreementJson.java
new file mode 100644
index 0000000..3ad965d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/AgreementJson.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.AgreementInfo;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.restapi.group.GroupJson;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class AgreementJson {
+  private static final Logger log = LoggerFactory.getLogger(AgreementJson.class);
+
+  private final Provider<CurrentUser> self;
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final GroupControl.GenericFactory genericGroupControlFactory;
+  private final GroupJson groupJson;
+
+  @Inject
+  AgreementJson(
+      Provider<CurrentUser> self,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      GroupControl.GenericFactory genericGroupControlFactory,
+      GroupJson groupJson) {
+    this.self = self;
+    this.identifiedUserFactory = identifiedUserFactory;
+    this.genericGroupControlFactory = genericGroupControlFactory;
+    this.groupJson = groupJson;
+  }
+
+  public AgreementInfo format(ContributorAgreement ca) {
+    AgreementInfo info = new AgreementInfo();
+    info.name = ca.getName();
+    info.description = ca.getDescription();
+    info.url = ca.getAgreementUrl();
+    GroupReference autoVerifyGroup = ca.getAutoVerify();
+    if (autoVerifyGroup != null && self.get().isIdentifiedUser()) {
+      IdentifiedUser user = identifiedUserFactory.create(self.get().getAccountId());
+      try {
+        GroupControl gc = genericGroupControlFactory.controlFor(user, autoVerifyGroup.getUUID());
+        GroupResource group = new GroupResource(gc);
+        info.autoVerifyGroup = groupJson.format(group);
+      } catch (NoSuchGroupException | OrmException e) {
+        log.warn(
+            "autoverify group \""
+                + autoVerifyGroup.getName()
+                + "\" does not exist, referenced in CLA \""
+                + ca.getName()
+                + "\"");
+      }
+    }
+    return info;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/CachesCollection.java b/java/com/google/gerrit/server/restapi/config/CachesCollection.java
new file mode 100644
index 0000000..cfdc648
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/CachesCollection.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+import static com.google.gerrit.common.data.GlobalCapability.VIEW_CACHES;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsPost;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.CacheResource;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@RequiresAnyCapability({VIEW_CACHES, MAINTAIN_SERVER})
+@Singleton
+public class CachesCollection
+    implements ChildCollection<ConfigResource, CacheResource>, AcceptsPost<ConfigResource> {
+
+  private final DynamicMap<RestView<CacheResource>> views;
+  private final Provider<ListCaches> list;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> self;
+  private final DynamicMap<Cache<?, ?>> cacheMap;
+  private final PostCaches postCaches;
+
+  @Inject
+  CachesCollection(
+      DynamicMap<RestView<CacheResource>> views,
+      Provider<ListCaches> list,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> self,
+      DynamicMap<Cache<?, ?>> cacheMap,
+      PostCaches postCaches) {
+    this.views = views;
+    this.list = list;
+    this.permissionBackend = permissionBackend;
+    this.self = self;
+    this.cacheMap = cacheMap;
+    this.postCaches = postCaches;
+  }
+
+  @Override
+  public RestView<ConfigResource> list() {
+    return list.get();
+  }
+
+  @Override
+  public CacheResource parse(ConfigResource parent, IdString id)
+      throws AuthException, ResourceNotFoundException, PermissionBackendException {
+    permissionBackend.user(self).check(GlobalPermission.VIEW_CACHES);
+
+    String cacheName = id.get();
+    String pluginName = "gerrit";
+    int i = cacheName.lastIndexOf('-');
+    if (i != -1) {
+      pluginName = cacheName.substring(0, i);
+      cacheName = cacheName.length() > i + 1 ? cacheName.substring(i + 1) : "";
+    }
+
+    Provider<Cache<?, ?>> cacheProvider = cacheMap.byPlugin(pluginName).get(cacheName);
+    if (cacheProvider == null) {
+      throw new ResourceNotFoundException(id);
+    }
+    return new CacheResource(pluginName, cacheName, cacheProvider);
+  }
+
+  @Override
+  public DynamicMap<RestView<CacheResource>> views() {
+    return views;
+  }
+
+  @Override
+  public PostCaches post(ConfigResource parent) throws RestApiException {
+    return postCaches;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/CapabilitiesCollection.java b/java/com/google/gerrit/server/restapi/config/CapabilitiesCollection.java
new file mode 100644
index 0000000..ae1278d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/CapabilitiesCollection.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.config.CapabilityResource;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class CapabilitiesCollection implements ChildCollection<ConfigResource, CapabilityResource> {
+  private final DynamicMap<RestView<CapabilityResource>> views;
+  private final ListCapabilities list;
+
+  @Inject
+  CapabilitiesCollection(DynamicMap<RestView<CapabilityResource>> views, ListCapabilities list) {
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public RestView<ConfigResource> list() throws ResourceNotFoundException {
+    return list;
+  }
+
+  @Override
+  public CapabilityResource parse(ConfigResource parent, IdString id)
+      throws ResourceNotFoundException {
+    throw new ResourceNotFoundException(id);
+  }
+
+  @Override
+  public DynamicMap<RestView<CapabilityResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/CheckConsistency.java b/java/com/google/gerrit/server/restapi/config/CheckConsistency.java
new file mode 100644
index 0000000..95b20c2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/CheckConsistency.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.CheckAccountExternalIdsResultInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.CheckAccountsResultInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.CheckGroupsResultInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountsConsistencyChecker;
+import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.group.db.GroupsConsistencyChecker;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class CheckConsistency implements RestModifyView<ConfigResource, ConsistencyCheckInput> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final AccountsConsistencyChecker accountsConsistencyChecker;
+  private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
+  private final GroupsConsistencyChecker groupsConsistencyChecker;
+
+  @Inject
+  CheckConsistency(
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      AccountsConsistencyChecker accountsConsistencyChecker,
+      ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
+      GroupsConsistencyChecker groupsChecker) {
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.accountsConsistencyChecker = accountsConsistencyChecker;
+    this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
+    this.groupsConsistencyChecker = groupsChecker;
+  }
+
+  @Override
+  public ConsistencyCheckInfo apply(ConfigResource resource, ConsistencyCheckInput input)
+      throws RestApiException, IOException, OrmException, PermissionBackendException,
+          ConfigInvalidException {
+    permissionBackend.user(user).check(GlobalPermission.ACCESS_DATABASE);
+
+    if (input == null
+        || (input.checkAccounts == null
+            && input.checkAccountExternalIds == null
+            && input.checkGroups == null)) {
+      throw new BadRequestException("input required");
+    }
+
+    ConsistencyCheckInfo consistencyCheckInfo = new ConsistencyCheckInfo();
+    if (input.checkAccounts != null) {
+      consistencyCheckInfo.checkAccountsResult =
+          new CheckAccountsResultInfo(accountsConsistencyChecker.check());
+    }
+    if (input.checkAccountExternalIds != null) {
+      consistencyCheckInfo.checkAccountExternalIdsResult =
+          new CheckAccountExternalIdsResultInfo(externalIdsConsistencyChecker.check());
+    }
+
+    if (input.checkGroups != null) {
+      consistencyCheckInfo.checkGroupsResult =
+          new CheckGroupsResultInfo(groupsConsistencyChecker.check());
+    }
+
+    return consistencyCheckInfo;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ConfigCollection.java b/java/com/google/gerrit/server/restapi/config/ConfigCollection.java
new file mode 100644
index 0000000..934dbc1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ConfigCollection.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestCollection;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ConfigCollection implements RestCollection<TopLevelResource, ConfigResource> {
+  private final DynamicMap<RestView<ConfigResource>> views;
+
+  @Inject
+  ConfigCollection(DynamicMap<RestView<ConfigResource>> views) {
+    this.views = views;
+  }
+
+  @Override
+  public RestView<TopLevelResource> list() throws ResourceNotFoundException {
+    throw new ResourceNotFoundException();
+  }
+
+  @Override
+  public DynamicMap<RestView<ConfigResource>> views() {
+    return views;
+  }
+
+  @Override
+  public ConfigResource parse(TopLevelResource root, IdString id) throws ResourceNotFoundException {
+    if (id.get().equals("server")) {
+      return new ConfigResource();
+    }
+    throw new ResourceNotFoundException(id);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ConfigRestModule.java b/java/com/google/gerrit/server/restapi/config/ConfigRestModule.java
new file mode 100644
index 0000000..71b2f9c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ConfigRestModule.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
+import static com.google.gerrit.server.config.TaskResource.TASK_KIND;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.config.CapabilityResource;
+import com.google.gerrit.server.config.TopMenuResource;
+
+public class ConfigRestModule extends RestApiModule {
+  @Override
+  protected void configure() {
+    DynamicMap.mapOf(binder(), CapabilityResource.CAPABILITY_KIND);
+    DynamicMap.mapOf(binder(), CONFIG_KIND);
+    DynamicMap.mapOf(binder(), TASK_KIND);
+    DynamicMap.mapOf(binder(), TopMenuResource.TOP_MENU_KIND);
+    child(CONFIG_KIND, "capabilities").to(CapabilitiesCollection.class);
+    child(CONFIG_KIND, "tasks").to(TasksCollection.class);
+    get(TASK_KIND).to(GetTask.class);
+    delete(TASK_KIND).to(DeleteTask.class);
+    child(CONFIG_KIND, "top-menus").to(TopMenuCollection.class);
+    get(CONFIG_KIND, "version").to(GetVersion.class);
+    get(CONFIG_KIND, "info").to(GetServerInfo.class);
+    post(CONFIG_KIND, "check.consistency").to(CheckConsistency.class);
+    get(CONFIG_KIND, "preferences").to(GetPreferences.class);
+    put(CONFIG_KIND, "preferences").to(SetPreferences.class);
+    get(CONFIG_KIND, "preferences.diff").to(GetDiffPreferences.class);
+    put(CONFIG_KIND, "preferences.diff").to(SetDiffPreferences.class);
+    put(CONFIG_KIND, "email.confirm").to(ConfirmEmail.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java b/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java
new file mode 100644
index 0000000..f6ceb68b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.mail.EmailTokenVerifier;
+import com.google.gerrit.server.restapi.config.ConfirmEmail.Input;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class ConfirmEmail implements RestModifyView<ConfigResource, Input> {
+  public static class Input {
+    @DefaultInput public String token;
+  }
+
+  private final Provider<CurrentUser> self;
+  private final EmailTokenVerifier emailTokenVerifier;
+  private final AccountManager accountManager;
+
+  @Inject
+  public ConfirmEmail(
+      Provider<CurrentUser> self,
+      EmailTokenVerifier emailTokenVerifier,
+      AccountManager accountManager) {
+    this.self = self;
+    this.emailTokenVerifier = emailTokenVerifier;
+    this.accountManager = accountManager;
+  }
+
+  @Override
+  public Response<?> apply(ConfigResource rsrc, Input input)
+      throws AuthException, UnprocessableEntityException, AccountException, OrmException,
+          IOException, ConfigInvalidException {
+    CurrentUser user = self.get();
+    if (!user.isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    if (input == null) {
+      input = new Input();
+    }
+    if (input.token == null) {
+      throw new UnprocessableEntityException("missing token");
+    }
+
+    try {
+      EmailTokenVerifier.ParsedToken token = emailTokenVerifier.decode(input.token);
+      Account.Id accId = user.getAccountId();
+      if (accId.equals(token.getAccountId())) {
+        accountManager.link(accId, token.toAuthRequest());
+        return Response.none();
+      }
+      throw new UnprocessableEntityException("invalid token");
+    } catch (EmailTokenVerifier.InvalidTokenException e) {
+      throw new UnprocessableEntityException("invalid token");
+    } catch (AccountException e) {
+      throw new UnprocessableEntityException(e.getMessage());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/DeleteTask.java b/java/com/google/gerrit/server/restapi/config/DeleteTask.java
new file mode 100644
index 0000000..a08b036
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/DeleteTask.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.common.data.GlobalCapability.KILL_TASK;
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+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.TaskResource;
+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> {
+
+  @Override
+  public Response<?> apply(TaskResource rsrc, Input input) {
+    Task<?> task = rsrc.getTask();
+    boolean taskDeleted = task.cancel(true);
+    return taskDeleted
+        ? Response.none()
+        : Response.withStatusCode(SC_INTERNAL_SERVER_ERROR, "Unable to kill task " + task);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/FlushCache.java b/java/com/google/gerrit/server/restapi/config/FlushCache.java
new file mode 100644
index 0000000..55e9dc3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/FlushCache.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.common.data.GlobalCapability.FLUSH_CACHES;
+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.CacheResource;
+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;
+
+@RequiresAnyCapability({FLUSH_CACHES, MAINTAIN_SERVER})
+@Singleton
+public class FlushCache implements RestModifyView<CacheResource, Input> {
+
+  public static final String WEB_SESSIONS = "web_sessions";
+
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> self;
+
+  @Inject
+  public FlushCache(PermissionBackend permissionBackend, Provider<CurrentUser> self) {
+    this.permissionBackend = permissionBackend;
+    this.self = self;
+  }
+
+  @Override
+  public Response<String> apply(CacheResource rsrc, Input input)
+      throws AuthException, PermissionBackendException {
+    if (WEB_SESSIONS.equals(rsrc.getName())) {
+      permissionBackend.user(self).check(GlobalPermission.MAINTAIN_SERVER);
+    }
+
+    rsrc.getCache().invalidateAll();
+    return Response.ok("");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetCache.java b/java/com/google/gerrit/server/restapi/config/GetCache.java
new file mode 100644
index 0000000..5abaf1e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/GetCache.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.CacheResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetCache implements RestReadView<CacheResource> {
+
+  @Override
+  public ListCaches.CacheInfo apply(CacheResource rsrc) {
+    return new ListCaches.CacheInfo(rsrc.getName(), rsrc.getCache());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
new file mode 100644
index 0000000..6e72503
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.UserConfigSections;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class GetDiffPreferences implements RestReadView<ConfigResource> {
+
+  private final AllUsersName allUsersName;
+  private final GitRepositoryManager gitManager;
+
+  @Inject
+  GetDiffPreferences(GitRepositoryManager gitManager, AllUsersName allUsersName) {
+    this.allUsersName = allUsersName;
+    this.gitManager = gitManager;
+  }
+
+  @Override
+  public DiffPreferencesInfo apply(ConfigResource configResource)
+      throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
+    return readFromGit(gitManager, allUsersName, null);
+  }
+
+  static DiffPreferencesInfo readFromGit(
+      GitRepositoryManager gitMgr, AllUsersName allUsersName, DiffPreferencesInfo in)
+      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
+    try (Repository git = gitMgr.openRepository(allUsersName)) {
+      // Load all users prefs.
+      VersionedAccountPreferences dp = VersionedAccountPreferences.forDefault();
+      dp.load(git);
+      DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
+      loadSection(
+          dp.getConfig(),
+          UserConfigSections.DIFF,
+          null,
+          allUserPrefs,
+          DiffPreferencesInfo.defaults(),
+          in);
+      return allUserPrefs;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetPreferences.java b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
new file mode 100644
index 0000000..3c7453c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.PreferencesConfig;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class GetPreferences implements RestReadView<ConfigResource> {
+  private final GitRepositoryManager gitMgr;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  public GetPreferences(GitRepositoryManager gitMgr, AllUsersName allUsersName) {
+    this.gitMgr = gitMgr;
+    this.allUsersName = allUsersName;
+  }
+
+  @Override
+  public GeneralPreferencesInfo apply(ConfigResource rsrc)
+      throws IOException, ConfigInvalidException {
+    try (Repository git = gitMgr.openRepository(allUsersName)) {
+      return PreferencesConfig.readDefaultPreferences(git);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
new file mode 100644
index 0000000..f31277d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -0,0 +1,401 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.extensions.client.UiType;
+import com.google.gerrit.extensions.common.AccountsInfo;
+import com.google.gerrit.extensions.common.AuthInfo;
+import com.google.gerrit.extensions.common.ChangeConfigInfo;
+import com.google.gerrit.extensions.common.DownloadInfo;
+import com.google.gerrit.extensions.common.DownloadSchemeInfo;
+import com.google.gerrit.extensions.common.GerritInfo;
+import com.google.gerrit.extensions.common.PluginConfigInfo;
+import com.google.gerrit.extensions.common.ReceiveInfo;
+import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.extensions.common.SshdInfo;
+import com.google.gerrit.extensions.common.SuggestInfo;
+import com.google.gerrit.extensions.common.UserConfigInfo;
+import com.google.gerrit.extensions.config.CloneCommand;
+import com.google.gerrit.extensions.config.DownloadCommand;
+import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
+import com.google.gerrit.server.EnableSignedPush;
+import com.google.gerrit.server.account.AccountVisibilityProvider;
+import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.avatar.AvatarProvider;
+import com.google.gerrit.server.change.ArchiveFormat;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritOptions;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
+import com.google.gerrit.server.git.MergeSuperSet;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.restapi.change.AllowedFormats;
+import com.google.inject.Inject;
+import java.net.MalformedURLException;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+
+public class GetServerInfo implements RestReadView<ConfigResource> {
+  private static final String URL_ALIAS = "urlAlias";
+  private static final String KEY_MATCH = "match";
+  private static final String KEY_TOKEN = "token";
+
+  private final Config config;
+  private final AccountVisibilityProvider accountVisibilityProvider;
+  private final AuthConfig authConfig;
+  private final Realm realm;
+  private final DynamicMap<DownloadScheme> downloadSchemes;
+  private final DynamicMap<DownloadCommand> downloadCommands;
+  private final DynamicMap<CloneCommand> cloneCommands;
+  private final DynamicSet<WebUiPlugin> plugins;
+  private final AllowedFormats archiveFormats;
+  private final AllProjectsName allProjectsName;
+  private final AllUsersName allUsersName;
+  private final String anonymousCowardName;
+  private final DynamicItem<AvatarProvider> avatar;
+  private final boolean enableSignedPush;
+  private final QueryDocumentationExecutor docSearcher;
+  private final NotesMigration migration;
+  private final ProjectCache projectCache;
+  private final AgreementJson agreementJson;
+  private final GerritOptions gerritOptions;
+  private final ChangeIndexCollection indexes;
+  private final SitePaths sitePaths;
+
+  @Inject
+  public GetServerInfo(
+      @GerritServerConfig Config config,
+      AccountVisibilityProvider accountVisibilityProvider,
+      AuthConfig authConfig,
+      Realm realm,
+      DynamicMap<DownloadScheme> downloadSchemes,
+      DynamicMap<DownloadCommand> downloadCommands,
+      DynamicMap<CloneCommand> cloneCommands,
+      DynamicSet<WebUiPlugin> webUiPlugins,
+      AllowedFormats archiveFormats,
+      AllProjectsName allProjectsName,
+      AllUsersName allUsersName,
+      @AnonymousCowardName String anonymousCowardName,
+      DynamicItem<AvatarProvider> avatar,
+      @EnableSignedPush boolean enableSignedPush,
+      QueryDocumentationExecutor docSearcher,
+      NotesMigration migration,
+      ProjectCache projectCache,
+      AgreementJson agreementJson,
+      GerritOptions gerritOptions,
+      ChangeIndexCollection indexes,
+      SitePaths sitePaths) {
+    this.config = config;
+    this.accountVisibilityProvider = accountVisibilityProvider;
+    this.authConfig = authConfig;
+    this.realm = realm;
+    this.downloadSchemes = downloadSchemes;
+    this.downloadCommands = downloadCommands;
+    this.cloneCommands = cloneCommands;
+    this.plugins = webUiPlugins;
+    this.archiveFormats = archiveFormats;
+    this.allProjectsName = allProjectsName;
+    this.allUsersName = allUsersName;
+    this.anonymousCowardName = anonymousCowardName;
+    this.avatar = avatar;
+    this.enableSignedPush = enableSignedPush;
+    this.docSearcher = docSearcher;
+    this.migration = migration;
+    this.projectCache = projectCache;
+    this.agreementJson = agreementJson;
+    this.gerritOptions = gerritOptions;
+    this.indexes = indexes;
+    this.sitePaths = sitePaths;
+  }
+
+  @Override
+  public ServerInfo apply(ConfigResource rsrc) throws MalformedURLException {
+    ServerInfo info = new ServerInfo();
+    info.accounts = getAccountsInfo(accountVisibilityProvider);
+    info.auth = getAuthInfo(authConfig, realm);
+    info.change = getChangeInfo(config);
+    info.download =
+        getDownloadInfo(downloadSchemes, downloadCommands, cloneCommands, archiveFormats);
+    info.gerrit = getGerritInfo(config, allProjectsName, allUsersName);
+    info.noteDbEnabled = toBoolean(isNoteDbEnabled());
+    info.plugin = getPluginInfo();
+    if (Files.exists(sitePaths.site_theme)) {
+      info.defaultTheme = "/static/" + SitePaths.THEME_FILENAME;
+    }
+    info.sshd = getSshdInfo(config);
+    info.suggest = getSuggestInfo(config);
+
+    Map<String, String> urlAliases = getUrlAliasesInfo(config);
+    info.urlAliases = !urlAliases.isEmpty() ? urlAliases : null;
+
+    info.user = getUserInfo(anonymousCowardName);
+    info.receive = getReceiveInfo();
+    return info;
+  }
+
+  private AccountsInfo getAccountsInfo(AccountVisibilityProvider accountVisibilityProvider) {
+    AccountsInfo info = new AccountsInfo();
+    info.visibility = accountVisibilityProvider.get();
+    return info;
+  }
+
+  private AuthInfo getAuthInfo(AuthConfig cfg, Realm realm) {
+    AuthInfo info = new AuthInfo();
+    info.authType = cfg.getAuthType();
+    info.useContributorAgreements = toBoolean(cfg.isUseContributorAgreements());
+    info.editableAccountFields = new ArrayList<>(realm.getEditableFields());
+    info.switchAccountUrl = cfg.getSwitchAccountUrl();
+    info.gitBasicAuthPolicy = cfg.getGitBasicAuthPolicy();
+
+    if (info.useContributorAgreements != null) {
+      Collection<ContributorAgreement> agreements =
+          projectCache.getAllProjects().getConfig().getContributorAgreements();
+      if (!agreements.isEmpty()) {
+        info.contributorAgreements = Lists.newArrayListWithCapacity(agreements.size());
+        for (ContributorAgreement agreement : agreements) {
+          info.contributorAgreements.add(agreementJson.format(agreement));
+        }
+      }
+    }
+
+    switch (info.authType) {
+      case LDAP:
+      case LDAP_BIND:
+        info.registerUrl = cfg.getRegisterUrl();
+        info.registerText = cfg.getRegisterText();
+        info.editFullNameUrl = cfg.getEditFullNameUrl();
+        break;
+
+      case CUSTOM_EXTENSION:
+        info.registerUrl = cfg.getRegisterUrl();
+        info.registerText = cfg.getRegisterText();
+        info.editFullNameUrl = cfg.getEditFullNameUrl();
+        info.httpPasswordUrl = cfg.getHttpPasswordUrl();
+        break;
+
+      case HTTP:
+      case HTTP_LDAP:
+        info.loginUrl = cfg.getLoginUrl();
+        info.loginText = cfg.getLoginText();
+        break;
+
+      case CLIENT_SSL_CERT_LDAP:
+      case DEVELOPMENT_BECOME_ANY_ACCOUNT:
+      case OAUTH:
+      case OPENID:
+      case OPENID_SSO:
+        break;
+    }
+    return info;
+  }
+
+  private ChangeConfigInfo getChangeInfo(Config cfg) {
+    ChangeConfigInfo info = new ChangeConfigInfo();
+    info.allowBlame = toBoolean(cfg.getBoolean("change", "allowBlame", true));
+    info.allowDrafts = toBoolean(cfg.getBoolean("change", "allowDrafts", true));
+    boolean hasAssigneeInIndex =
+        indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE);
+    info.showAssigneeInChangesTable =
+        toBoolean(
+            cfg.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
+    info.largeChange = cfg.getInt("change", "largeChange", 500);
+    info.replyTooltip =
+        Optional.ofNullable(cfg.getString("change", null, "replyTooltip")).orElse("Reply and score")
+            + " (Shortcut: a)";
+    info.replyLabel =
+        Optional.ofNullable(cfg.getString("change", null, "replyLabel")).orElse("Reply") + "\u2026";
+    info.updateDelay =
+        (int) ConfigUtil.getTimeUnit(cfg, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
+    info.submitWholeTopic = MergeSuperSet.wholeTopicEnabled(cfg);
+    info.disablePrivateChanges =
+        toBoolean(config.getBoolean("change", null, "disablePrivateChanges", false));
+    return info;
+  }
+
+  private DownloadInfo getDownloadInfo(
+      DynamicMap<DownloadScheme> downloadSchemes,
+      DynamicMap<DownloadCommand> downloadCommands,
+      DynamicMap<CloneCommand> cloneCommands,
+      AllowedFormats archiveFormats) {
+    DownloadInfo info = new DownloadInfo();
+    info.schemes = new HashMap<>();
+    for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
+      DownloadScheme scheme = e.getProvider().get();
+      if (scheme.isEnabled() && scheme.getUrl("${project}") != null) {
+        info.schemes.put(
+            e.getExportName(), getDownloadSchemeInfo(scheme, downloadCommands, cloneCommands));
+      }
+    }
+    info.archives =
+        archiveFormats.getAllowed().stream().map(ArchiveFormat::getShortName).collect(toList());
+    return info;
+  }
+
+  private DownloadSchemeInfo getDownloadSchemeInfo(
+      DownloadScheme scheme,
+      DynamicMap<DownloadCommand> downloadCommands,
+      DynamicMap<CloneCommand> cloneCommands) {
+    DownloadSchemeInfo info = new DownloadSchemeInfo();
+    info.url = scheme.getUrl("${project}");
+    info.isAuthRequired = toBoolean(scheme.isAuthRequired());
+    info.isAuthSupported = toBoolean(scheme.isAuthSupported());
+
+    info.commands = new HashMap<>();
+    for (DynamicMap.Entry<DownloadCommand> e : downloadCommands) {
+      String commandName = e.getExportName();
+      DownloadCommand command = e.getProvider().get();
+      String c = command.getCommand(scheme, "${project}", "${ref}");
+      if (c != null) {
+        info.commands.put(commandName, c);
+      }
+    }
+
+    info.cloneCommands = new HashMap<>();
+    for (DynamicMap.Entry<CloneCommand> e : cloneCommands) {
+      String commandName = e.getExportName();
+      CloneCommand command = e.getProvider().get();
+      String c = command.getCommand(scheme, "${project-path}/${project-base-name}");
+      if (c != null) {
+        c = c.replaceAll("\\$\\{project-path\\}/\\$\\{project-base-name\\}", "\\$\\{project\\}");
+        info.cloneCommands.put(commandName, c);
+      }
+    }
+
+    return info;
+  }
+
+  private GerritInfo getGerritInfo(
+      Config cfg, AllProjectsName allProjectsName, AllUsersName allUsersName) {
+    GerritInfo info = new GerritInfo();
+    info.allProjects = allProjectsName.get();
+    info.allUsers = allUsersName.get();
+    info.reportBugUrl = cfg.getString("gerrit", null, "reportBugUrl");
+    info.reportBugText = cfg.getString("gerrit", null, "reportBugText");
+    info.docUrl = getDocUrl(cfg);
+    info.docSearch = docSearcher.isAvailable();
+    info.editGpgKeys =
+        toBoolean(enableSignedPush && cfg.getBoolean("gerrit", null, "editGpgKeys", true));
+    info.webUis = EnumSet.noneOf(UiType.class);
+    if (gerritOptions.enableGwtUi()) {
+      info.webUis.add(UiType.GWT);
+    }
+    if (gerritOptions.enablePolyGerrit()) {
+      info.webUis.add(UiType.POLYGERRIT);
+    }
+    return info;
+  }
+
+  private String getDocUrl(Config cfg) {
+    String docUrl = cfg.getString("gerrit", null, "docUrl");
+    if (Strings.isNullOrEmpty(docUrl)) {
+      return null;
+    }
+    return CharMatcher.is('/').trimTrailingFrom(docUrl) + '/';
+  }
+
+  private boolean isNoteDbEnabled() {
+    return migration.readChanges();
+  }
+
+  private PluginConfigInfo getPluginInfo() {
+    PluginConfigInfo info = new PluginConfigInfo();
+    info.hasAvatars = toBoolean(avatar.get() != null);
+    info.jsResourcePaths = new ArrayList<>();
+    info.htmlResourcePaths = new ArrayList<>();
+    for (WebUiPlugin u : plugins) {
+      String path =
+          String.format("plugins/%s/%s", u.getPluginName(), u.getJavaScriptResourcePath());
+      if (path.endsWith(".html")) {
+        info.htmlResourcePaths.add(path);
+      } else {
+        info.jsResourcePaths.add(path);
+      }
+    }
+    return info;
+  }
+
+  private Map<String, String> getUrlAliasesInfo(Config cfg) {
+    Map<String, String> urlAliases = new HashMap<>();
+    for (String subsection : cfg.getSubsections(URL_ALIAS)) {
+      urlAliases.put(
+          cfg.getString(URL_ALIAS, subsection, KEY_MATCH),
+          cfg.getString(URL_ALIAS, subsection, KEY_TOKEN));
+    }
+    return urlAliases;
+  }
+
+  private SshdInfo getSshdInfo(Config cfg) {
+    String[] addr = cfg.getStringList("sshd", null, "listenAddress");
+    if (addr.length == 1 && isOff(addr[0])) {
+      return null;
+    }
+    return new SshdInfo();
+  }
+
+  private static boolean isOff(String listenHostname) {
+    return "off".equalsIgnoreCase(listenHostname)
+        || "none".equalsIgnoreCase(listenHostname)
+        || "no".equalsIgnoreCase(listenHostname);
+  }
+
+  private SuggestInfo getSuggestInfo(Config cfg) {
+    SuggestInfo info = new SuggestInfo();
+    info.from = cfg.getInt("suggest", "from", 0);
+    return info;
+  }
+
+  private UserConfigInfo getUserInfo(String anonymousCowardName) {
+    UserConfigInfo info = new UserConfigInfo();
+    info.anonymousCowardName = anonymousCowardName;
+    return info;
+  }
+
+  private ReceiveInfo getReceiveInfo() {
+    ReceiveInfo info = new ReceiveInfo();
+    info.enableSignedPush = enableSignedPush;
+    return info;
+  }
+
+  private static Boolean toBoolean(boolean v) {
+    return v ? v : null;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetSummary.java b/java/com/google/gerrit/server/restapi/config/GetSummary.java
new file mode 100644
index 0000000..26f069c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/GetSummary.java
@@ -0,0 +1,281 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.lang.management.OperatingSystemMXBean;
+import java.lang.management.RuntimeMXBean;
+import java.lang.management.ThreadInfo;
+import java.lang.management.ThreadMXBean;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.internal.storage.file.WindowCacheStatAccessor;
+import org.kohsuke.args4j.Option;
+
+@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
+public class GetSummary implements RestReadView<ConfigResource> {
+
+  private final WorkQueue workQueue;
+  private final Path sitePath;
+
+  @Option(name = "--gc", usage = "perform Java GC before retrieving memory stats")
+  private boolean gc;
+
+  public GetSummary setGc(boolean gc) {
+    this.gc = gc;
+    return this;
+  }
+
+  @Option(name = "--jvm", usage = "include details about the JVM")
+  private boolean jvm;
+
+  public GetSummary setJvm(boolean jvm) {
+    this.jvm = jvm;
+    return this;
+  }
+
+  @Inject
+  public GetSummary(WorkQueue workQueue, @SitePath Path sitePath) {
+    this.workQueue = workQueue;
+    this.sitePath = sitePath;
+  }
+
+  @Override
+  public SummaryInfo apply(ConfigResource rsrc) {
+    if (gc) {
+      System.gc();
+      System.runFinalization();
+      System.gc();
+    }
+
+    SummaryInfo summary = new SummaryInfo();
+    summary.taskSummary = getTaskSummary();
+    summary.memSummary = getMemSummary();
+    summary.threadSummary = getThreadSummary();
+    if (jvm) {
+      summary.jvmSummary = getJvmSummary();
+    }
+    return summary;
+  }
+
+  private TaskSummaryInfo getTaskSummary() {
+    Collection<Task<?>> pending = workQueue.getTasks();
+    int tasksTotal = pending.size();
+    int tasksRunning = 0;
+    int tasksReady = 0;
+    int tasksSleeping = 0;
+    for (Task<?> task : pending) {
+      switch (task.getState()) {
+        case RUNNING:
+          tasksRunning++;
+          break;
+        case READY:
+          tasksReady++;
+          break;
+        case SLEEPING:
+          tasksSleeping++;
+          break;
+        case CANCELLED:
+        case DONE:
+        case OTHER:
+          break;
+      }
+    }
+
+    TaskSummaryInfo taskSummary = new TaskSummaryInfo();
+    taskSummary.total = toInteger(tasksTotal);
+    taskSummary.running = toInteger(tasksRunning);
+    taskSummary.ready = toInteger(tasksReady);
+    taskSummary.sleeping = toInteger(tasksSleeping);
+    return taskSummary;
+  }
+
+  private MemSummaryInfo getMemSummary() {
+    Runtime r = Runtime.getRuntime();
+    long mMax = r.maxMemory();
+    long mFree = r.freeMemory();
+    long mTotal = r.totalMemory();
+    long mInuse = mTotal - mFree;
+
+    int jgitOpen = WindowCacheStatAccessor.getOpenFiles();
+    long jgitBytes = WindowCacheStatAccessor.getOpenBytes();
+
+    MemSummaryInfo memSummaryInfo = new MemSummaryInfo();
+    memSummaryInfo.total = bytes(mTotal);
+    memSummaryInfo.used = bytes(mInuse - jgitBytes);
+    memSummaryInfo.free = bytes(mFree);
+    memSummaryInfo.buffers = bytes(jgitBytes);
+    memSummaryInfo.max = bytes(mMax);
+    memSummaryInfo.openFiles = toInteger(jgitOpen);
+    return memSummaryInfo;
+  }
+
+  private ThreadSummaryInfo getThreadSummary() {
+    Runtime r = Runtime.getRuntime();
+    ThreadSummaryInfo threadInfo = new ThreadSummaryInfo();
+    threadInfo.cpus = r.availableProcessors();
+    threadInfo.threads = toInteger(ManagementFactory.getThreadMXBean().getThreadCount());
+
+    List<String> prefixes =
+        Arrays.asList(
+            "H2",
+            "HTTP",
+            "IntraLineDiff",
+            "ReceiveCommits",
+            "SSH git-receive-pack",
+            "SSH git-upload-pack",
+            "SSH-Interactive-Worker",
+            "SSH-Stream-Worker",
+            "SshCommandStart",
+            "sshd-SshServer");
+    String other = "Other";
+    ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
+
+    threadInfo.counts = new HashMap<>();
+    for (long id : threadMXBean.getAllThreadIds()) {
+      ThreadInfo info = threadMXBean.getThreadInfo(id);
+      if (info == null) {
+        continue;
+      }
+      String name = info.getThreadName();
+      Thread.State state = info.getThreadState();
+      String group = other;
+      for (String p : prefixes) {
+        if (name.startsWith(p)) {
+          group = p;
+          break;
+        }
+      }
+      Map<Thread.State, Integer> counts = threadInfo.counts.get(group);
+      if (counts == null) {
+        counts = new HashMap<>();
+        threadInfo.counts.put(group, counts);
+      }
+      Integer c = counts.get(state);
+      counts.put(state, c != null ? c + 1 : 1);
+    }
+
+    return threadInfo;
+  }
+
+  private JvmSummaryInfo getJvmSummary() {
+    OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
+    RuntimeMXBean runtimeBean = ManagementFactory.getRuntimeMXBean();
+
+    JvmSummaryInfo jvmSummary = new JvmSummaryInfo();
+    jvmSummary.vmVendor = runtimeBean.getVmVendor();
+    jvmSummary.vmName = runtimeBean.getVmName();
+    jvmSummary.vmVersion = runtimeBean.getVmVersion();
+    jvmSummary.osName = osBean.getName();
+    jvmSummary.osVersion = osBean.getVersion();
+    jvmSummary.osArch = osBean.getArch();
+    jvmSummary.user = System.getProperty("user.name");
+
+    try {
+      jvmSummary.host = InetAddress.getLocalHost().getHostName();
+    } catch (UnknownHostException e) {
+      // Ignored
+    }
+
+    jvmSummary.currentWorkingDirectory = path(Paths.get(".").toAbsolutePath().getParent());
+    jvmSummary.site = path(sitePath);
+    return jvmSummary;
+  }
+
+  private static Integer toInteger(int i) {
+    return i != 0 ? i : null;
+  }
+
+  private static String bytes(double value) {
+    value /= 1024;
+    String suffix = "k";
+
+    if (value > 1024) {
+      value /= 1024;
+      suffix = "m";
+    }
+    if (value > 1024) {
+      value /= 1024;
+      suffix = "g";
+    }
+    return String.format("%1$6.2f%2$s", value, suffix).trim();
+  }
+
+  private static String path(Path path) {
+    try {
+      return path.toRealPath().normalize().toString();
+    } catch (IOException err) {
+      return path.toAbsolutePath().normalize().toString();
+    }
+  }
+
+  public static class SummaryInfo {
+    public TaskSummaryInfo taskSummary;
+    public MemSummaryInfo memSummary;
+    public ThreadSummaryInfo threadSummary;
+    public JvmSummaryInfo jvmSummary;
+  }
+
+  public static class TaskSummaryInfo {
+    public Integer total;
+    public Integer running;
+    public Integer ready;
+    public Integer sleeping;
+  }
+
+  public static class MemSummaryInfo {
+    public String total;
+    public String used;
+    public String free;
+    public String buffers;
+    public String max;
+    public Integer openFiles;
+  }
+
+  public static class ThreadSummaryInfo {
+    public Integer cpus;
+    public Integer threads;
+    public Map<String, Map<Thread.State, Integer>> counts;
+  }
+
+  public static class JvmSummaryInfo {
+    public String vmVendor;
+    public String vmName;
+    public String vmVersion;
+    public String osName;
+    public String osVersion;
+    public String osArch;
+    public String user;
+    public String host;
+    public String currentWorkingDirectory;
+    public String site;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetTask.java b/java/com/google/gerrit/server/restapi/config/GetTask.java
new file mode 100644
index 0000000..a32f3ba
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/GetTask.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.TaskResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetTask implements RestReadView<TaskResource> {
+
+  @Override
+  public ListTasks.TaskInfo apply(TaskResource rsrc) {
+    return new ListTasks.TaskInfo(rsrc.getTask());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetVersion.java b/java/com/google/gerrit/server/restapi/config/GetVersion.java
new file mode 100644
index 0000000..8135719
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/GetVersion.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetVersion implements RestReadView<ConfigResource> {
+  @Override
+  public String apply(ConfigResource resource) throws ResourceNotFoundException {
+    String version = Version.getVersion();
+    if (version == null) {
+      throw new ResourceNotFoundException();
+    }
+    return version;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ListCaches.java b/java/com/google/gerrit/server/restapi/config/ListCaches.java
new file mode 100644
index 0000000..c0a9d71
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ListCaches.java
@@ -0,0 +1,203 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+import static com.google.gerrit.common.data.GlobalCapability.VIEW_CACHES;
+import static com.google.gerrit.server.config.CacheResource.cacheNameOf;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Joiner;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheStats;
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.cache.PersistentCache;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import org.kohsuke.args4j.Option;
+
+@RequiresAnyCapability({VIEW_CACHES, MAINTAIN_SERVER})
+public class ListCaches implements RestReadView<ConfigResource> {
+  private final DynamicMap<Cache<?, ?>> cacheMap;
+
+  public enum OutputFormat {
+    LIST,
+    TEXT_LIST
+  }
+
+  @Option(name = "--format", usage = "output format")
+  private OutputFormat format;
+
+  public ListCaches setFormat(OutputFormat format) {
+    this.format = format;
+    return this;
+  }
+
+  @Inject
+  public ListCaches(DynamicMap<Cache<?, ?>> cacheMap) {
+    this.cacheMap = cacheMap;
+  }
+
+  public Map<String, CacheInfo> getCacheInfos() {
+    Map<String, CacheInfo> cacheInfos = new TreeMap<>();
+    for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+      cacheInfos.put(
+          cacheNameOf(e.getPluginName(), e.getExportName()), new CacheInfo(e.getProvider().get()));
+    }
+    return cacheInfos;
+  }
+
+  @Override
+  public Object apply(ConfigResource rsrc) {
+    if (format == null) {
+      return getCacheInfos();
+    }
+    List<String> cacheNames = new ArrayList<>();
+    for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+      cacheNames.add(cacheNameOf(e.getPluginName(), e.getExportName()));
+    }
+    Collections.sort(cacheNames);
+
+    if (OutputFormat.TEXT_LIST.equals(format)) {
+      return BinaryResult.create(Joiner.on('\n').join(cacheNames))
+          .base64()
+          .setContentType("text/plain")
+          .setCharacterEncoding(UTF_8);
+    }
+    return cacheNames;
+  }
+
+  public enum CacheType {
+    MEM,
+    DISK
+  }
+
+  public static class CacheInfo {
+    public String name;
+    public CacheType type;
+    public EntriesInfo entries;
+    public String averageGet;
+    public HitRatioInfo hitRatio;
+
+    public CacheInfo(Cache<?, ?> cache) {
+      this(null, cache);
+    }
+
+    public CacheInfo(String name, Cache<?, ?> cache) {
+      this.name = name;
+
+      CacheStats stat = cache.stats();
+
+      entries = new EntriesInfo();
+      entries.setMem(cache.size());
+
+      averageGet = duration(stat.averageLoadPenalty());
+
+      hitRatio = new HitRatioInfo();
+      hitRatio.setMem(stat.hitCount(), stat.requestCount());
+
+      if (cache instanceof PersistentCache) {
+        type = CacheType.DISK;
+        PersistentCache.DiskStats diskStats = ((PersistentCache) cache).diskStats();
+        entries.setDisk(diskStats.size());
+        entries.setSpace(diskStats.space());
+        hitRatio.setDisk(diskStats.hitCount(), diskStats.requestCount());
+      } else {
+        type = CacheType.MEM;
+      }
+    }
+
+    private static String duration(double ns) {
+      if (ns < 0.5) {
+        return null;
+      }
+      String suffix = "ns";
+      if (ns >= 1000.0) {
+        ns /= 1000.0;
+        suffix = "us";
+      }
+      if (ns >= 1000.0) {
+        ns /= 1000.0;
+        suffix = "ms";
+      }
+      if (ns >= 1000.0) {
+        ns /= 1000.0;
+        suffix = "s";
+      }
+      return String.format("%4.1f%s", ns, suffix).trim();
+    }
+  }
+
+  public static class EntriesInfo {
+    public Long mem;
+    public Long disk;
+    public String space;
+
+    public void setMem(long mem) {
+      this.mem = mem != 0 ? mem : null;
+    }
+
+    public void setDisk(long disk) {
+      this.disk = disk != 0 ? disk : null;
+    }
+
+    public void setSpace(double value) {
+      space = bytes(value);
+    }
+
+    private static String bytes(double value) {
+      value /= 1024;
+      String suffix = "k";
+
+      if (value > 1024) {
+        value /= 1024;
+        suffix = "m";
+      }
+      if (value > 1024) {
+        value /= 1024;
+        suffix = "g";
+      }
+      return String.format("%1$6.2f%2$s", value, suffix).trim();
+    }
+  }
+
+  public static class HitRatioInfo {
+    public Integer mem;
+    public Integer disk;
+
+    public void setMem(long value, long total) {
+      mem = percent(value, total);
+    }
+
+    public void setDisk(long value, long total) {
+      disk = percent(value, total);
+    }
+
+    private static Integer percent(long value, long total) {
+      if (total <= 0) {
+        return null;
+      }
+      return (int) ((100 * value) / total);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ListCapabilities.java b/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
new file mode 100644
index 0000000..6a1e5f6
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.CapabilityConstants;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.regex.Pattern;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** List capabilities visible to the calling user. */
+@Singleton
+public class ListCapabilities implements RestReadView<ConfigResource> {
+  private static final Logger log = LoggerFactory.getLogger(ListCapabilities.class);
+  private static final Pattern PLUGIN_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9-]+$");
+
+  private final DynamicMap<CapabilityDefinition> pluginCapabilities;
+
+  @Inject
+  public ListCapabilities(DynamicMap<CapabilityDefinition> pluginCapabilities) {
+    this.pluginCapabilities = pluginCapabilities;
+  }
+
+  @Override
+  public Map<String, CapabilityInfo> apply(ConfigResource resource)
+      throws IllegalAccessException, NoSuchFieldException {
+    Map<String, CapabilityInfo> output = new TreeMap<>();
+    collectCoreCapabilities(output);
+    collectPluginCapabilities(output);
+    return output;
+  }
+
+  private void collectCoreCapabilities(Map<String, CapabilityInfo> output)
+      throws IllegalAccessException, NoSuchFieldException {
+    Class<? extends CapabilityConstants> bundleClass = CapabilityConstants.get().getClass();
+    CapabilityConstants c = CapabilityConstants.get();
+    for (String id : GlobalCapability.getAllNames()) {
+      String name = (String) bundleClass.getField(id).get(c);
+      output.put(id, new CapabilityInfo(id, name));
+    }
+  }
+
+  private void collectPluginCapabilities(Map<String, CapabilityInfo> output) {
+    for (String pluginName : pluginCapabilities.plugins()) {
+      if (!PLUGIN_NAME_PATTERN.matcher(pluginName).matches()) {
+        log.warn(
+            "Plugin name '{}' must match '{}' to use capabilities; rename the plugin",
+            pluginName,
+            PLUGIN_NAME_PATTERN.pattern());
+        continue;
+      }
+      for (Map.Entry<String, Provider<CapabilityDefinition>> entry :
+          pluginCapabilities.byPlugin(pluginName).entrySet()) {
+        String id = String.format("%s-%s", pluginName, entry.getKey());
+        output.put(id, new CapabilityInfo(id, entry.getValue().get().getDescription()));
+      }
+    }
+  }
+
+  public static class CapabilityInfo {
+    public String id;
+    public String name;
+
+    public CapabilityInfo(String id, String name) {
+      this.id = id;
+      this.name = name;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ListTasks.java b/java/com/google/gerrit/server/restapi/config/ListTasks.java
new file mode 100644
index 0000000..71ee5ad
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ListTasks.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.common.collect.ComparisonChain;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.git.TaskInfoFactory;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.ProjectTask;
+import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.util.IdGenerator;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@Singleton
+public class ListTasks implements RestReadView<ConfigResource> {
+  private final PermissionBackend permissionBackend;
+  private final WorkQueue workQueue;
+  private final Provider<CurrentUser> self;
+
+  @Inject
+  public ListTasks(
+      PermissionBackend permissionBackend, WorkQueue workQueue, Provider<CurrentUser> self) {
+    this.permissionBackend = permissionBackend;
+    this.workQueue = workQueue;
+    this.self = self;
+  }
+
+  @Override
+  public List<TaskInfo> apply(ConfigResource resource)
+      throws AuthException, PermissionBackendException {
+    CurrentUser user = self.get();
+    if (!user.isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    List<TaskInfo> allTasks = getTasks();
+    try {
+      permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
+      return allTasks;
+    } catch (AuthException e) {
+      // Fall through to filter tasks.
+    }
+
+    Map<String, Boolean> visibilityCache = new HashMap<>();
+    List<TaskInfo> visibleTasks = new ArrayList<>();
+    for (TaskInfo task : allTasks) {
+      if (task.projectName != null) {
+        Boolean visible = visibilityCache.get(task.projectName);
+        if (visible == null) {
+          try {
+            permissionBackend
+                .user(user)
+                .project(new Project.NameKey(task.projectName))
+                .check(ProjectPermission.ACCESS);
+            visible = true;
+          } catch (AuthException e) {
+            visible = false;
+          }
+          visibilityCache.put(task.projectName, visible);
+        }
+        if (visible) {
+          visibleTasks.add(task);
+        }
+      }
+    }
+    return visibleTasks;
+  }
+
+  private List<TaskInfo> getTasks() {
+    List<TaskInfo> taskInfos =
+        workQueue.getTaskInfos(
+            new TaskInfoFactory<TaskInfo>() {
+              @Override
+              public TaskInfo getTaskInfo(Task<?> task) {
+                return new TaskInfo(task);
+              }
+            });
+    Collections.sort(
+        taskInfos,
+        new Comparator<TaskInfo>() {
+          @Override
+          public int compare(TaskInfo a, TaskInfo b) {
+            return ComparisonChain.start()
+                .compare(a.state.ordinal(), b.state.ordinal())
+                .compare(a.delay, b.delay)
+                .compare(a.command, b.command)
+                .result();
+          }
+        });
+    return taskInfos;
+  }
+
+  public static class TaskInfo {
+    public String id;
+    public Task.State state;
+    public Timestamp startTime;
+    public long delay;
+    public String command;
+    public String remoteName;
+    public String projectName;
+    public String queueName;
+
+    public TaskInfo(Task<?> task) {
+      this.id = IdGenerator.format(task.getTaskId());
+      this.state = task.getState();
+      this.startTime = new Timestamp(task.getStartTime().getTime());
+      this.delay = task.getDelay(TimeUnit.MILLISECONDS);
+      this.command = task.toString();
+      this.queueName = task.getQueueName();
+
+      if (task instanceof ProjectTask) {
+        ProjectTask<?> projectTask = ((ProjectTask<?>) task);
+        Project.NameKey name = projectTask.getProjectNameKey();
+        if (name != null) {
+          this.projectName = name.get();
+        }
+        this.remoteName = projectTask.getRemoteName();
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ListTopMenus.java b/java/com/google/gerrit/server/restapi/config/ListTopMenus.java
new file mode 100644
index 0000000..7a85bcd
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ListTopMenus.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.webui.TopMenu;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+
+@Singleton
+class ListTopMenus implements RestReadView<ConfigResource> {
+  private final DynamicSet<TopMenu> extensions;
+
+  @Inject
+  ListTopMenus(DynamicSet<TopMenu> extensions) {
+    this.extensions = extensions;
+  }
+
+  @Override
+  public List<TopMenu.MenuEntry> apply(ConfigResource resource) {
+    List<TopMenu.MenuEntry> entries = new ArrayList<>();
+    for (TopMenu extension : extensions) {
+      entries.addAll(extension.getEntries());
+    }
+    return entries;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/PostCaches.java b/java/com/google/gerrit/server/restapi/config/PostCaches.java
new file mode 100644
index 0000000..f21672c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/PostCaches.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.common.data.GlobalCapability.FLUSH_CACHES;
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+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.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.config.CacheResource;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.config.PostCaches.Input;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+
+@RequiresAnyCapability({FLUSH_CACHES, MAINTAIN_SERVER})
+@Singleton
+public class PostCaches implements RestModifyView<ConfigResource, Input> {
+  public static class Input {
+    public Operation operation;
+    public List<String> caches;
+
+    public Input() {}
+
+    public Input(Operation op) {
+      this(op, null);
+    }
+
+    public Input(Operation op, List<String> c) {
+      operation = op;
+      caches = c;
+    }
+  }
+
+  public enum Operation {
+    FLUSH_ALL,
+    FLUSH
+  }
+
+  private final DynamicMap<Cache<?, ?>> cacheMap;
+  private final FlushCache flushCache;
+
+  @Inject
+  public PostCaches(DynamicMap<Cache<?, ?>> cacheMap, FlushCache flushCache) {
+    this.cacheMap = cacheMap;
+    this.flushCache = flushCache;
+  }
+
+  @Override
+  public Response<String> apply(ConfigResource rsrc, Input input)
+      throws AuthException, BadRequestException, UnprocessableEntityException,
+          PermissionBackendException {
+    if (input == null || input.operation == null) {
+      throw new BadRequestException("operation must be specified");
+    }
+
+    switch (input.operation) {
+      case FLUSH_ALL:
+        if (input.caches != null) {
+          throw new BadRequestException(
+              "specifying caches is not allowed for operation 'FLUSH_ALL'");
+        }
+        flushAll();
+        return Response.ok("");
+      case FLUSH:
+        if (input.caches == null || input.caches.isEmpty()) {
+          throw new BadRequestException("caches must be specified for operation 'FLUSH'");
+        }
+        flush(input.caches);
+        return Response.ok("");
+      default:
+        throw new BadRequestException("unsupported operation: " + input.operation);
+    }
+  }
+
+  private void flushAll() throws AuthException, PermissionBackendException {
+    for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+      CacheResource cacheResource =
+          new CacheResource(e.getPluginName(), e.getExportName(), e.getProvider());
+      if (FlushCache.WEB_SESSIONS.equals(cacheResource.getName())) {
+        continue;
+      }
+      flushCache.apply(cacheResource, null);
+    }
+  }
+
+  private void flush(List<String> cacheNames)
+      throws UnprocessableEntityException, AuthException, PermissionBackendException {
+    List<CacheResource> cacheResources = new ArrayList<>(cacheNames.size());
+
+    for (String n : cacheNames) {
+      String pluginName = "gerrit";
+      String cacheName = n;
+      int i = cacheName.lastIndexOf('-');
+      if (i != -1) {
+        pluginName = cacheName.substring(0, i);
+        cacheName = cacheName.length() > i + 1 ? cacheName.substring(i + 1) : "";
+      }
+
+      Cache<?, ?> cache = cacheMap.get(pluginName, cacheName);
+      if (cache != null) {
+        cacheResources.add(new CacheResource(pluginName, cacheName, cache));
+      } else {
+        throw new UnprocessableEntityException(String.format("cache %s not found", n));
+      }
+    }
+
+    for (CacheResource rsrc : cacheResources) {
+      flushCache.apply(rsrc, null);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java b/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java
new file mode 100644
index 0000000..7283033
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.server.config.CacheResource.CACHE_KIND;
+import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+
+public class RestCacheAdminModule extends RestApiModule {
+
+  @Override
+  protected void configure() {
+    DynamicMap.mapOf(binder(), CACHE_KIND);
+    child(CONFIG_KIND, "caches").to(CachesCollection.class);
+    get(CACHE_KIND).to(GetCache.class);
+    post(CACHE_KIND, "flush").to(FlushCache.class);
+    get(CONFIG_KIND, "summary").to(GetSummary.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java b/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java
new file mode 100644
index 0000000..a61b2aa
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
+import static com.google.gerrit.server.config.ConfigUtil.storeSection;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.UserConfigSections;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class SetDiffPreferences implements RestModifyView<ConfigResource, DiffPreferencesInfo> {
+  private static final Logger log = LoggerFactory.getLogger(SetDiffPreferences.class);
+
+  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final AllUsersName allUsersName;
+  private final GitRepositoryManager gitManager;
+
+  @Inject
+  SetDiffPreferences(
+      GitRepositoryManager gitManager,
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      AllUsersName allUsersName) {
+    this.gitManager = gitManager;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.allUsersName = allUsersName;
+  }
+
+  @Override
+  public DiffPreferencesInfo apply(ConfigResource configResource, DiffPreferencesInfo in)
+      throws BadRequestException, IOException, ConfigInvalidException {
+    if (in == null) {
+      throw new BadRequestException("input must be provided");
+    }
+    if (!hasSetFields(in)) {
+      throw new BadRequestException("unsupported option");
+    }
+    return writeToGit(GetDiffPreferences.readFromGit(gitManager, allUsersName, in));
+  }
+
+  private DiffPreferencesInfo writeToGit(DiffPreferencesInfo in)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    DiffPreferencesInfo out = new DiffPreferencesInfo();
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
+      VersionedAccountPreferences prefs = VersionedAccountPreferences.forDefault();
+      prefs.load(md);
+      DiffPreferencesInfo defaults = DiffPreferencesInfo.defaults();
+      storeSection(prefs.getConfig(), UserConfigSections.DIFF, null, in, defaults);
+      prefs.commit(md);
+      loadSection(
+          prefs.getConfig(),
+          UserConfigSections.DIFF,
+          null,
+          out,
+          DiffPreferencesInfo.defaults(),
+          null);
+    }
+    return out;
+  }
+
+  private static boolean hasSetFields(DiffPreferencesInfo in) {
+    try {
+      for (Field field : in.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        if (field.get(in) != null) {
+          return true;
+        }
+      }
+    } catch (IllegalAccessException e) {
+      log.warn("Unable to verify input", e);
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/SetPreferences.java b/java/com/google/gerrit/server/restapi/config/SetPreferences.java
new file mode 100644
index 0000000..be990e2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/SetPreferences.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.PreferencesConfig;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class SetPreferences implements RestModifyView<ConfigResource, GeneralPreferencesInfo> {
+  private static final Logger log = LoggerFactory.getLogger(SetPreferences.class);
+
+  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final AllUsersName allUsersName;
+  private final AccountCache accountCache;
+
+  @Inject
+  SetPreferences(
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      AllUsersName allUsersName,
+      AccountCache accountCache) {
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.allUsersName = allUsersName;
+    this.accountCache = accountCache;
+  }
+
+  @Override
+  public GeneralPreferencesInfo apply(ConfigResource rsrc, GeneralPreferencesInfo input)
+      throws BadRequestException, IOException, ConfigInvalidException {
+    if (!hasSetFields(input)) {
+      throw new BadRequestException("unsupported option");
+    }
+    PreferencesConfig.validateMy(input.my);
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
+      GeneralPreferencesInfo updatedPrefs = PreferencesConfig.updateDefaultPreferences(md, input);
+      accountCache.evictAllNoReindex();
+      return updatedPrefs;
+    }
+  }
+
+  private static boolean hasSetFields(GeneralPreferencesInfo in) {
+    try {
+      for (Field field : in.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        if (field.get(in) != null) {
+          return true;
+        }
+      }
+    } catch (IllegalAccessException e) {
+      log.warn("Unable to verify input", e);
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/TasksCollection.java b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
new file mode 100644
index 0000000..f5b6e56
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.TaskResource;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.ProjectTask;
+import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class TasksCollection implements ChildCollection<ConfigResource, TaskResource> {
+  private final DynamicMap<RestView<TaskResource>> views;
+  private final ListTasks list;
+  private final WorkQueue workQueue;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  TasksCollection(
+      DynamicMap<RestView<TaskResource>> views,
+      ListTasks list,
+      WorkQueue workQueue,
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend) {
+    this.views = views;
+    this.list = list;
+    this.workQueue = workQueue;
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public RestView<ConfigResource> list() {
+    return list;
+  }
+
+  @Override
+  public TaskResource parse(ConfigResource parent, IdString id)
+      throws ResourceNotFoundException, AuthException, PermissionBackendException {
+    CurrentUser user = self.get();
+    if (!user.isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    int taskId;
+    try {
+      taskId = (int) Long.parseLong(id.get(), 16);
+    } catch (NumberFormatException e) {
+      throw new ResourceNotFoundException(id);
+    }
+
+    Task<?> task = workQueue.getTask(taskId);
+    if (task instanceof ProjectTask) {
+      try {
+        permissionBackend
+            .user(user)
+            .project(((ProjectTask<?>) task).getProjectNameKey())
+            .check(ProjectPermission.ACCESS);
+        return new TaskResource(task);
+      } catch (AuthException e) {
+        // Fall through and try view queue permission.
+      }
+    }
+
+    if (task != null) {
+      try {
+        permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
+        return new TaskResource(task);
+      } catch (AuthException e) {
+        // Fall through and return not found.
+      }
+    }
+
+    throw new ResourceNotFoundException(id);
+  }
+
+  @Override
+  public DynamicMap<RestView<TaskResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/TopMenuCollection.java b/java/com/google/gerrit/server/restapi/config/TopMenuCollection.java
new file mode 100644
index 0000000..36a1b04
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/TopMenuCollection.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.TopMenuResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+class TopMenuCollection implements ChildCollection<ConfigResource, TopMenuResource> {
+  private final DynamicMap<RestView<TopMenuResource>> views;
+  private final ListTopMenus list;
+
+  @Inject
+  TopMenuCollection(DynamicMap<RestView<TopMenuResource>> views, ListTopMenus list) {
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public RestView<ConfigResource> list() throws ResourceNotFoundException {
+    return list;
+  }
+
+  @Override
+  public TopMenuResource parse(ConfigResource parent, IdString id)
+      throws ResourceNotFoundException {
+    throw new ResourceNotFoundException(id);
+  }
+
+  @Override
+  public DynamicMap<RestView<TopMenuResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/AddMembers.java b/java/com/google/gerrit/server/restapi/group/AddMembers.java
new file mode 100644
index 0000000..8971531
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/AddMembers.java
@@ -0,0 +1,256 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.MemberResource;
+import com.google.gerrit.server.group.UserInitiated;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.restapi.account.AccountsCollection;
+import com.google.gerrit.server.restapi.group.AddMembers.Input;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class AddMembers implements RestModifyView<GroupResource, Input> {
+  public static class Input {
+    @DefaultInput String _oneMember;
+
+    List<String> members;
+
+    public static Input fromMembers(List<String> members) {
+      Input in = new Input();
+      in.members = members;
+      return in;
+    }
+
+    static Input init(Input in) {
+      if (in == null) {
+        in = new Input();
+      }
+      if (in.members == null) {
+        in.members = Lists.newArrayListWithCapacity(1);
+      }
+      if (!Strings.isNullOrEmpty(in._oneMember)) {
+        in.members.add(in._oneMember);
+      }
+      return in;
+    }
+  }
+
+  private final AccountManager accountManager;
+  private final AuthType authType;
+  private final AccountsCollection accounts;
+  private final AccountResolver accountResolver;
+  private final AccountCache accountCache;
+  private final AccountLoader.Factory infoFactory;
+  private final Provider<ReviewDb> db;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
+
+  @Inject
+  AddMembers(
+      AccountManager accountManager,
+      AuthConfig authConfig,
+      AccountsCollection accounts,
+      AccountResolver accountResolver,
+      AccountCache accountCache,
+      AccountLoader.Factory infoFactory,
+      Provider<ReviewDb> db,
+      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
+    this.accountManager = accountManager;
+    this.authType = authConfig.getAuthType();
+    this.accounts = accounts;
+    this.accountResolver = accountResolver;
+    this.accountCache = accountCache;
+    this.infoFactory = infoFactory;
+    this.db = db;
+    this.groupsUpdateProvider = groupsUpdateProvider;
+  }
+
+  @Override
+  public List<AccountInfo> apply(GroupResource resource, Input input)
+      throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
+          IOException, ConfigInvalidException, ResourceNotFoundException {
+    GroupDescription.Internal internalGroup =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    input = Input.init(input);
+
+    GroupControl control = resource.getControl();
+    if (!control.canAddMember()) {
+      throw new AuthException("Cannot add members to group " + internalGroup.getName());
+    }
+
+    Set<Account.Id> newMemberIds = new LinkedHashSet<>();
+    for (String nameOrEmailOrId : input.members) {
+      Account a = findAccount(nameOrEmailOrId);
+      if (!a.isActive()) {
+        throw new UnprocessableEntityException(
+            String.format("Account Inactive: %s", nameOrEmailOrId));
+      }
+      newMemberIds.add(a.getId());
+    }
+
+    AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
+    try {
+      addMembers(groupUuid, newMemberIds);
+    } catch (NoSuchGroupException e) {
+      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+    }
+    return toAccountInfoList(newMemberIds);
+  }
+
+  Account findAccount(String nameOrEmailOrId)
+      throws AuthException, UnprocessableEntityException, OrmException, IOException,
+          ConfigInvalidException {
+    try {
+      return accounts.parse(nameOrEmailOrId).getAccount();
+    } catch (UnprocessableEntityException e) {
+      // might be because the account does not exist or because the account is
+      // not visible
+      switch (authType) {
+        case HTTP_LDAP:
+        case CLIENT_SSL_CERT_LDAP:
+        case LDAP:
+          if (accountResolver.find(nameOrEmailOrId) == null) {
+            // account does not exist, try to create it
+            Account a = createAccountByLdap(nameOrEmailOrId);
+            if (a != null) {
+              return a;
+            }
+          }
+          break;
+        case CUSTOM_EXTENSION:
+        case DEVELOPMENT_BECOME_ANY_ACCOUNT:
+        case HTTP:
+        case LDAP_BIND:
+        case OAUTH:
+        case OPENID:
+        case OPENID_SSO:
+        default:
+      }
+      throw e;
+    }
+  }
+
+  public void addMembers(AccountGroup.UUID groupUuid, Set<Account.Id> newMemberIds)
+      throws OrmException, IOException, NoSuchGroupException, ConfigInvalidException {
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder()
+            .setMemberModification(memberIds -> Sets.union(memberIds, newMemberIds))
+            .build();
+    groupsUpdateProvider.get().updateGroup(db.get(), groupUuid, groupUpdate);
+  }
+
+  private Account createAccountByLdap(String user) throws IOException {
+    if (!user.matches(Account.USER_NAME_PATTERN)) {
+      return null;
+    }
+
+    try {
+      AuthRequest req = AuthRequest.forUser(user);
+      req.setSkipAuthentication(true);
+      return accountCache.get(accountManager.authenticate(req).getAccountId()).getAccount();
+    } catch (AccountException e) {
+      return null;
+    }
+  }
+
+  private List<AccountInfo> toAccountInfoList(Set<Account.Id> accountIds) throws OrmException {
+    List<AccountInfo> result = new ArrayList<>();
+    AccountLoader loader = infoFactory.create(true);
+    for (Account.Id accId : accountIds) {
+      result.add(loader.get(accId));
+    }
+    loader.fill();
+    return result;
+  }
+
+  static class PutMember implements RestModifyView<GroupResource, Input> {
+
+    private final AddMembers put;
+    private final String id;
+
+    PutMember(AddMembers put, String id) {
+      this.put = put;
+      this.id = id;
+    }
+
+    @Override
+    public AccountInfo apply(GroupResource resource, Input input)
+        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
+            IOException, ConfigInvalidException {
+      AddMembers.Input in = new AddMembers.Input();
+      in._oneMember = id;
+      try {
+        List<AccountInfo> list = put.apply(resource, in);
+        if (list.size() == 1) {
+          return list.get(0);
+        }
+        throw new IllegalStateException();
+      } catch (UnprocessableEntityException e) {
+        throw new ResourceNotFoundException(id);
+      }
+    }
+  }
+
+  @Singleton
+  static class UpdateMember implements RestModifyView<MemberResource, Input> {
+    private final GetMember get;
+
+    @Inject
+    UpdateMember(GetMember get) {
+      this.get = get;
+    }
+
+    @Override
+    public AccountInfo apply(MemberResource resource, Input input) throws OrmException {
+      // Do nothing, the user is already a member.
+      return get.apply(resource);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
new file mode 100644
index 0000000..48e6651
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
@@ -0,0 +1,177 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.SubgroupResource;
+import com.google.gerrit.server.group.UserInitiated;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.restapi.group.AddSubgroups.Input;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class AddSubgroups implements RestModifyView<GroupResource, Input> {
+  public static class Input {
+    @DefaultInput String _oneGroup;
+
+    public List<String> groups;
+
+    public static Input fromGroups(List<String> groups) {
+      Input in = new Input();
+      in.groups = groups;
+      return in;
+    }
+
+    static Input init(Input in) {
+      if (in == null) {
+        in = new Input();
+      }
+      if (in.groups == null) {
+        in.groups = Lists.newArrayListWithCapacity(1);
+      }
+      if (!Strings.isNullOrEmpty(in._oneGroup)) {
+        in.groups.add(in._oneGroup);
+      }
+      return in;
+    }
+  }
+
+  private final GroupsCollection groupsCollection;
+  private final Provider<ReviewDb> db;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
+  private final GroupJson json;
+
+  @Inject
+  public AddSubgroups(
+      GroupsCollection groupsCollection,
+      Provider<ReviewDb> db,
+      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
+      GroupJson json) {
+    this.groupsCollection = groupsCollection;
+    this.db = db;
+    this.groupsUpdateProvider = groupsUpdateProvider;
+    this.json = json;
+  }
+
+  @Override
+  public List<GroupInfo> apply(GroupResource resource, Input input)
+      throws MethodNotAllowedException, AuthException, UnprocessableEntityException, OrmException,
+          ResourceNotFoundException, IOException, ConfigInvalidException {
+    GroupDescription.Internal group =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    input = Input.init(input);
+
+    GroupControl control = resource.getControl();
+    if (!control.canAddGroup()) {
+      throw new AuthException(String.format("Cannot add groups to group %s", group.getName()));
+    }
+
+    List<GroupInfo> result = new ArrayList<>();
+    Set<AccountGroup.UUID> subgroupUuids = new LinkedHashSet<>();
+    for (String subgroupIdentifier : input.groups) {
+      GroupDescription.Basic subgroup = groupsCollection.parse(subgroupIdentifier);
+      subgroupUuids.add(subgroup.getGroupUUID());
+      result.add(json.format(subgroup));
+    }
+
+    AccountGroup.UUID groupUuid = group.getGroupUUID();
+    try {
+      addSubgroups(groupUuid, subgroupUuids);
+    } catch (NoSuchGroupException e) {
+      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+    }
+    return result;
+  }
+
+  private void addSubgroups(
+      AccountGroup.UUID parentGroupUuid, Set<AccountGroup.UUID> newSubgroupUuids)
+      throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder()
+            .setSubgroupModification(subgroupUuids -> Sets.union(subgroupUuids, newSubgroupUuids))
+            .build();
+    groupsUpdateProvider.get().updateGroup(db.get(), parentGroupUuid, groupUpdate);
+  }
+
+  static class PutSubgroup implements RestModifyView<GroupResource, Input> {
+
+    private final AddSubgroups addSubgroups;
+    private final String id;
+
+    PutSubgroup(AddSubgroups addSubgroups, String id) {
+      this.addSubgroups = addSubgroups;
+      this.id = id;
+    }
+
+    @Override
+    public GroupInfo apply(GroupResource resource, Input input)
+        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
+            IOException, ConfigInvalidException {
+      AddSubgroups.Input in = new AddSubgroups.Input();
+      in.groups = ImmutableList.of(id);
+      try {
+        List<GroupInfo> list = addSubgroups.apply(resource, in);
+        if (list.size() == 1) {
+          return list.get(0);
+        }
+        throw new IllegalStateException();
+      } catch (UnprocessableEntityException e) {
+        throw new ResourceNotFoundException(id);
+      }
+    }
+  }
+
+  @Singleton
+  static class UpdateSubgroup implements RestModifyView<SubgroupResource, Input> {
+    private final Provider<GetSubgroup> get;
+
+    @Inject
+    UpdateSubgroup(Provider<GetSubgroup> get) {
+      this.get = get;
+    }
+
+    @Override
+    public GroupInfo apply(SubgroupResource resource, Input input) throws OrmException {
+      // Do nothing, the group is already included.
+      return get.get().apply(resource);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
new file mode 100644
index 0000000..6201a19
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -0,0 +1,231 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.client.ListGroupsOption;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.CreateGroupArgs;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.GroupUUID;
+import com.google.gerrit.server.config.GerritServerConfig;
+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.group.UserInitiated;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupCreation;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.validators.GroupCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+
+@RequiresCapability(GlobalCapability.CREATE_GROUP)
+public class CreateGroup implements RestModifyView<TopLevelResource, GroupInput> {
+  public interface Factory {
+    CreateGroup create(@Assisted String name);
+  }
+
+  private final Provider<IdentifiedUser> self;
+  private final PersonIdent serverIdent;
+  private final ReviewDb db;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
+  private final GroupCache groupCache;
+  private final GroupsCollection groups;
+  private final GroupJson json;
+  private final DynamicSet<GroupCreationValidationListener> groupCreationValidationListeners;
+  private final AddMembers addMembers;
+  private final SystemGroupBackend systemGroupBackend;
+  private final boolean defaultVisibleToAll;
+  private final String name;
+  private final Sequences sequences;
+
+  @Inject
+  CreateGroup(
+      Provider<IdentifiedUser> self,
+      @GerritPersonIdent PersonIdent serverIdent,
+      ReviewDb db,
+      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
+      GroupCache groupCache,
+      GroupsCollection groups,
+      GroupJson json,
+      DynamicSet<GroupCreationValidationListener> groupCreationValidationListeners,
+      AddMembers addMembers,
+      SystemGroupBackend systemGroupBackend,
+      @GerritServerConfig Config cfg,
+      @Assisted String name,
+      Sequences sequences) {
+    this.self = self;
+    this.serverIdent = serverIdent;
+    this.db = db;
+    this.groupsUpdateProvider = groupsUpdateProvider;
+    this.groupCache = groupCache;
+    this.groups = groups;
+    this.json = json;
+    this.groupCreationValidationListeners = groupCreationValidationListeners;
+    this.addMembers = addMembers;
+    this.systemGroupBackend = systemGroupBackend;
+    this.defaultVisibleToAll = cfg.getBoolean("groups", "newGroupsVisibleToAll", false);
+    this.name = name;
+    this.sequences = sequences;
+  }
+
+  public CreateGroup addOption(ListGroupsOption o) {
+    json.addOption(o);
+    return this;
+  }
+
+  public CreateGroup addOptions(Collection<ListGroupsOption> o) {
+    json.addOptions(o);
+    return this;
+  }
+
+  @Override
+  public GroupInfo apply(TopLevelResource resource, GroupInput input)
+      throws AuthException, BadRequestException, UnprocessableEntityException,
+          ResourceConflictException, OrmException, IOException, ConfigInvalidException,
+          ResourceNotFoundException {
+    if (input == null) {
+      input = new GroupInput();
+    }
+    if (input.name != null && !name.equals(input.name)) {
+      throw new BadRequestException("name must match URL");
+    }
+
+    AccountGroup.Id ownerId = owner(input);
+    CreateGroupArgs args = new CreateGroupArgs();
+    args.setGroupName(name);
+    args.groupDescription = Strings.emptyToNull(input.description);
+    args.visibleToAll = MoreObjects.firstNonNull(input.visibleToAll, defaultVisibleToAll);
+    args.ownerGroupId = ownerId;
+    if (input.members != null && !input.members.isEmpty()) {
+      List<Account.Id> members = new ArrayList<>();
+      for (String nameOrEmailOrId : input.members) {
+        Account a = addMembers.findAccount(nameOrEmailOrId);
+        if (!a.isActive()) {
+          throw new UnprocessableEntityException(
+              String.format("Account Inactive: %s", nameOrEmailOrId));
+        }
+        members.add(a.getId());
+      }
+      args.initialMembers = members;
+    } else {
+      args.initialMembers =
+          ownerId == null
+              ? Collections.singleton(self.get().getAccountId())
+              : Collections.<Account.Id>emptySet();
+    }
+
+    for (GroupCreationValidationListener l : groupCreationValidationListeners) {
+      try {
+        l.validateNewGroup(args);
+      } catch (ValidationException e) {
+        throw new ResourceConflictException(e.getMessage(), e);
+      }
+    }
+
+    return json.format(new InternalGroupDescription(createGroup(args)));
+  }
+
+  private AccountGroup.Id owner(GroupInput input) throws UnprocessableEntityException {
+    if (input.ownerId != null) {
+      GroupDescription.Internal d = groups.parseInternal(Url.decode(input.ownerId));
+      return d.getId();
+    }
+    return null;
+  }
+
+  private InternalGroup createGroup(CreateGroupArgs createGroupArgs)
+      throws OrmException, ResourceConflictException, IOException, ConfigInvalidException {
+
+    String nameLower = createGroupArgs.getGroupName().toLowerCase(Locale.US);
+
+    for (String name : systemGroupBackend.getNames()) {
+      if (name.toLowerCase(Locale.US).equals(nameLower)) {
+        throw new ResourceConflictException("group '" + name + "' already exists");
+      }
+    }
+
+    for (String name : systemGroupBackend.getReservedNames()) {
+      if (name.toLowerCase(Locale.US).equals(nameLower)) {
+        throw new ResourceConflictException("group name '" + name + "' is reserved");
+      }
+    }
+
+    AccountGroup.Id groupId = new AccountGroup.Id(sequences.nextGroupId());
+    AccountGroup.UUID uuid =
+        GroupUUID.make(
+            createGroupArgs.getGroupName(),
+            self.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone()));
+    InternalGroupCreation groupCreation =
+        InternalGroupCreation.builder()
+            .setGroupUUID(uuid)
+            .setNameKey(createGroupArgs.getGroup())
+            .setId(groupId)
+            .build();
+    InternalGroupUpdate.Builder groupUpdateBuilder =
+        InternalGroupUpdate.builder().setVisibleToAll(createGroupArgs.visibleToAll);
+    if (createGroupArgs.ownerGroupId != null) {
+      Optional<InternalGroup> ownerGroup = groupCache.get(createGroupArgs.ownerGroupId);
+      ownerGroup.map(InternalGroup::getGroupUUID).ifPresent(groupUpdateBuilder::setOwnerGroupUUID);
+    }
+    if (createGroupArgs.groupDescription != null) {
+      groupUpdateBuilder.setDescription(createGroupArgs.groupDescription);
+    }
+    groupUpdateBuilder.setMemberModification(
+        members -> ImmutableSet.copyOf(createGroupArgs.initialMembers));
+    try {
+      return groupsUpdateProvider.get().createGroup(db, groupCreation, groupUpdateBuilder.build());
+    } catch (OrmDuplicateKeyException e) {
+      throw new ResourceConflictException(
+          "group '" + createGroupArgs.getGroupName() + "' already exists");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
new file mode 100644
index 0000000..f8304d4
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
@@ -0,0 +1,118 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.MemberResource;
+import com.google.gerrit.server.group.UserInitiated;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.restapi.account.AccountsCollection;
+import com.google.gerrit.server.restapi.group.AddMembers.Input;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class DeleteMembers implements RestModifyView<GroupResource, Input> {
+  private final AccountsCollection accounts;
+  private final Provider<ReviewDb> db;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
+
+  @Inject
+  DeleteMembers(
+      AccountsCollection accounts,
+      Provider<ReviewDb> db,
+      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
+    this.accounts = accounts;
+    this.db = db;
+    this.groupsUpdateProvider = groupsUpdateProvider;
+  }
+
+  @Override
+  public Response<?> apply(GroupResource resource, Input input)
+      throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
+          IOException, ConfigInvalidException, ResourceNotFoundException {
+    GroupDescription.Internal internalGroup =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    input = Input.init(input);
+
+    final GroupControl control = resource.getControl();
+    if (!control.canRemoveMember()) {
+      throw new AuthException("Cannot delete members from group " + internalGroup.getName());
+    }
+
+    Set<Account.Id> membersToRemove = new HashSet<>();
+    for (String nameOrEmail : input.members) {
+      Account a = accounts.parse(nameOrEmail).getAccount();
+      membersToRemove.add(a.getId());
+    }
+    AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
+    try {
+      removeGroupMembers(groupUuid, membersToRemove);
+    } catch (NoSuchGroupException e) {
+      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+    }
+
+    return Response.none();
+  }
+
+  private void removeGroupMembers(AccountGroup.UUID groupUuid, Set<Account.Id> accountIds)
+      throws OrmException, IOException, NoSuchGroupException, ConfigInvalidException {
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder()
+            .setMemberModification(memberIds -> Sets.difference(memberIds, accountIds))
+            .build();
+    groupsUpdateProvider.get().updateGroup(db.get(), groupUuid, groupUpdate);
+  }
+
+  @Singleton
+  static class DeleteMember implements RestModifyView<MemberResource, Input> {
+
+    private final Provider<DeleteMembers> delete;
+
+    @Inject
+    DeleteMember(Provider<DeleteMembers> delete) {
+      this.delete = delete;
+    }
+
+    @Override
+    public Response<?> apply(MemberResource resource, Input input)
+        throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
+            IOException, ConfigInvalidException, ResourceNotFoundException {
+      AddMembers.Input in = new AddMembers.Input();
+      in._oneMember = resource.getMember().getAccountId().toString();
+      return delete.get().apply(resource, in);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
new file mode 100644
index 0000000..e12fe12
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.SubgroupResource;
+import com.google.gerrit.server.group.UserInitiated;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.restapi.group.AddSubgroups.Input;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class DeleteSubgroups implements RestModifyView<GroupResource, Input> {
+  private final GroupsCollection groupsCollection;
+  private final Provider<ReviewDb> db;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
+
+  @Inject
+  DeleteSubgroups(
+      GroupsCollection groupsCollection,
+      Provider<ReviewDb> db,
+      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
+    this.groupsCollection = groupsCollection;
+    this.db = db;
+    this.groupsUpdateProvider = groupsUpdateProvider;
+  }
+
+  @Override
+  public Response<?> apply(GroupResource resource, Input input)
+      throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
+          ResourceNotFoundException, IOException, ConfigInvalidException {
+    GroupDescription.Internal internalGroup =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    input = Input.init(input);
+
+    final GroupControl control = resource.getControl();
+    if (!control.canRemoveGroup()) {
+      throw new AuthException(
+          String.format("Cannot delete groups from group %s", internalGroup.getName()));
+    }
+
+    Set<AccountGroup.UUID> subgroupsToRemove = new HashSet<>();
+    for (String subgroupIdentifier : input.groups) {
+      GroupDescription.Basic subgroup = groupsCollection.parse(subgroupIdentifier);
+      subgroupsToRemove.add(subgroup.getGroupUUID());
+    }
+
+    AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
+    try {
+      removeSubgroups(groupUuid, subgroupsToRemove);
+    } catch (NoSuchGroupException e) {
+      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+    }
+
+    return Response.none();
+  }
+
+  private void removeSubgroups(
+      AccountGroup.UUID parentGroupUuid, Set<AccountGroup.UUID> removedSubgroupUuids)
+      throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder()
+            .setSubgroupModification(
+                subgroupUuids -> Sets.difference(subgroupUuids, removedSubgroupUuids))
+            .build();
+    groupsUpdateProvider.get().updateGroup(db.get(), parentGroupUuid, groupUpdate);
+  }
+
+  @Singleton
+  static class DeleteSubgroup implements RestModifyView<SubgroupResource, Input> {
+
+    private final Provider<DeleteSubgroups> delete;
+
+    @Inject
+    DeleteSubgroup(Provider<DeleteSubgroups> delete) {
+      this.delete = delete;
+    }
+
+    @Override
+    public Response<?> apply(SubgroupResource resource, Input input)
+        throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
+            ResourceNotFoundException, IOException, ConfigInvalidException {
+      AddSubgroups.Input in = new AddSubgroups.Input();
+      in.groups = ImmutableList.of(resource.getMember().get());
+      return delete.get().apply(resource, in);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
new file mode 100644
index 0000000..000df6c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
@@ -0,0 +1,147 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import static java.util.Comparator.comparing;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.GroupAuditEventInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.InternalGroupDescription;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class GetAuditLog implements RestReadView<GroupResource> {
+  private final Provider<ReviewDb> db;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final AllUsersName allUsers;
+  private final GroupCache groupCache;
+  private final GroupJson groupJson;
+  private final GroupBackend groupBackend;
+  private final Groups groups;
+  private final GitRepositoryManager repoManager;
+
+  @Inject
+  public GetAuditLog(
+      Provider<ReviewDb> db,
+      AccountLoader.Factory accountLoaderFactory,
+      AllUsersName allUsers,
+      GroupCache groupCache,
+      GroupJson groupJson,
+      GroupBackend groupBackend,
+      Groups groups,
+      GitRepositoryManager repoManager) {
+    this.db = db;
+    this.accountLoaderFactory = accountLoaderFactory;
+    this.allUsers = allUsers;
+    this.groupCache = groupCache;
+    this.groupJson = groupJson;
+    this.groupBackend = groupBackend;
+    this.groups = groups;
+    this.repoManager = repoManager;
+  }
+
+  @Override
+  public List<? extends GroupAuditEventInfo> apply(GroupResource rsrc)
+      throws AuthException, MethodNotAllowedException, OrmException, IOException,
+          ConfigInvalidException {
+    GroupDescription.Internal group =
+        rsrc.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    if (!rsrc.getControl().isOwner()) {
+      throw new AuthException("Not group owner");
+    }
+
+    AccountLoader accountLoader = accountLoaderFactory.create(true);
+
+    List<GroupAuditEventInfo> auditEvents = new ArrayList<>();
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      for (AccountGroupMemberAudit auditEvent :
+          groups.getMembersAudit(db.get(), allUsersRepo, group.getGroupUUID())) {
+        AccountInfo member = accountLoader.get(auditEvent.getMemberId());
+
+        auditEvents.add(
+            GroupAuditEventInfo.createAddUserEvent(
+                accountLoader.get(auditEvent.getAddedBy()), auditEvent.getAddedOn(), member));
+
+        if (!auditEvent.isActive()) {
+          auditEvents.add(
+              GroupAuditEventInfo.createRemoveUserEvent(
+                  accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
+        }
+      }
+
+      for (AccountGroupByIdAud auditEvent :
+          groups.getSubgroupsAudit(db.get(), allUsersRepo, group.getGroupUUID())) {
+        AccountGroup.UUID includedGroupUUID = auditEvent.getIncludeUUID();
+        Optional<InternalGroup> includedGroup = groupCache.get(includedGroupUUID);
+        GroupInfo member;
+        if (includedGroup.isPresent()) {
+          member = groupJson.format(new InternalGroupDescription(includedGroup.get()));
+        } else {
+          GroupDescription.Basic groupDescription = groupBackend.get(includedGroupUUID);
+          member = new GroupInfo();
+          member.id = Url.encode(includedGroupUUID.get());
+          member.name = groupDescription.getName();
+        }
+
+        auditEvents.add(
+            GroupAuditEventInfo.createAddGroupEvent(
+                accountLoader.get(auditEvent.getAddedBy()),
+                auditEvent.getKey().getAddedOn(),
+                member));
+
+        if (!auditEvent.isActive()) {
+          auditEvents.add(
+              GroupAuditEventInfo.createRemoveGroupEvent(
+                  accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
+        }
+      }
+    }
+
+    accountLoader.fill();
+
+    // sort by date and then reverse so that the newest audit event comes first
+    Collections.sort(auditEvents, comparing((GroupAuditEventInfo a) -> a.date).reversed());
+    return auditEvents;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/GetDescription.java b/java/com/google/gerrit/server/restapi/group/GetDescription.java
new file mode 100644
index 0000000..284ff2e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/GetDescription.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetDescription implements RestReadView<GroupResource> {
+  @Override
+  public String apply(GroupResource resource) throws MethodNotAllowedException {
+    GroupDescription.Internal group =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    return Strings.nullToEmpty(group.getDescription());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/GetDetail.java b/java/com/google/gerrit/server/restapi/group/GetDetail.java
new file mode 100644
index 0000000..e7b240e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/GetDetail.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.gerrit.extensions.client.ListGroupsOption;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetDetail implements RestReadView<GroupResource> {
+  private final GroupJson json;
+
+  @Inject
+  GetDetail(GroupJson json) {
+    this.json = json.addOption(ListGroupsOption.MEMBERS).addOption(ListGroupsOption.INCLUDES);
+  }
+
+  @Override
+  public GroupInfo apply(GroupResource rsrc) throws OrmException {
+    return json.format(rsrc);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/GetGroup.java b/java/com/google/gerrit/server/restapi/group/GetGroup.java
new file mode 100644
index 0000000..81057fd
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/GetGroup.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetGroup implements RestReadView<GroupResource> {
+  private final GroupJson json;
+
+  @Inject
+  GetGroup(GroupJson json) {
+    this.json = json;
+  }
+
+  @Override
+  public GroupInfo apply(GroupResource resource) throws OrmException {
+    return json.format(resource.getGroup());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/GetMember.java b/java/com/google/gerrit/server/restapi/group/GetMember.java
new file mode 100644
index 0000000..db33785
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/GetMember.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.group.MemberResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetMember implements RestReadView<MemberResource> {
+  private final AccountLoader.Factory infoFactory;
+
+  @Inject
+  GetMember(AccountLoader.Factory infoFactory) {
+    this.infoFactory = infoFactory;
+  }
+
+  @Override
+  public AccountInfo apply(MemberResource rsrc) throws OrmException {
+    AccountLoader loader = infoFactory.create(true);
+    AccountInfo info = loader.get(rsrc.getMember().getAccountId());
+    loader.fill();
+    return info;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/GetName.java b/java/com/google/gerrit/server/restapi/group/GetName.java
new file mode 100644
index 0000000..8cc1fe0
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/GetName.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetName implements RestReadView<GroupResource> {
+
+  @Override
+  public String apply(GroupResource resource) {
+    return resource.getName();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/GetOptions.java b/java/com/google/gerrit/server/restapi/group/GetOptions.java
new file mode 100644
index 0000000..e5bfe30
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/GetOptions.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.gerrit.extensions.common.GroupOptionsInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetOptions implements RestReadView<GroupResource> {
+
+  @Override
+  public GroupOptionsInfo apply(GroupResource resource) {
+    return GroupJson.createOptions(resource.getGroup());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/GetOwner.java b/java/com/google/gerrit/server/restapi/group/GetOwner.java
new file mode 100644
index 0000000..f46826f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/GetOwner.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetOwner implements RestReadView<GroupResource> {
+
+  private final GroupControl.Factory controlFactory;
+  private final GroupJson json;
+
+  @Inject
+  GetOwner(GroupControl.Factory controlFactory, GroupJson json) {
+    this.controlFactory = controlFactory;
+    this.json = json;
+  }
+
+  @Override
+  public GroupInfo apply(GroupResource resource)
+      throws MethodNotAllowedException, ResourceNotFoundException, OrmException {
+    GroupDescription.Internal group =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    try {
+      GroupControl c = controlFactory.validateFor(group.getOwnerGroupUUID());
+      return json.format(c.getGroup());
+    } catch (NoSuchGroupException e) {
+      throw new ResourceNotFoundException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/GetSubgroup.java b/java/com/google/gerrit/server/restapi/group/GetSubgroup.java
new file mode 100644
index 0000000..98e6ce5
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/GetSubgroup.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.group.SubgroupResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetSubgroup implements RestReadView<SubgroupResource> {
+  private final GroupJson json;
+
+  @Inject
+  GetSubgroup(GroupJson json) {
+    this.json = json;
+  }
+
+  @Override
+  public GroupInfo apply(SubgroupResource rsrc) throws OrmException {
+    return json.format(rsrc.getMemberDescription());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/GroupJson.java b/java/com/google/gerrit/server/restapi/group/GroupJson.java
new file mode 100644
index 0000000..3c7799b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/GroupJson.java
@@ -0,0 +1,135 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import static com.google.gerrit.extensions.client.ListGroupsOption.INCLUDES;
+import static com.google.gerrit.extensions.client.ListGroupsOption.MEMBERS;
+
+import com.google.common.base.Strings;
+import com.google.common.base.Suppliers;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.client.ListGroupsOption;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.common.GroupOptionsInfo;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.function.Supplier;
+
+public class GroupJson {
+  public static GroupOptionsInfo createOptions(GroupDescription.Basic group) {
+    GroupOptionsInfo options = new GroupOptionsInfo();
+    if (group instanceof GroupDescription.Internal
+        && ((GroupDescription.Internal) group).isVisibleToAll()) {
+      options.visibleToAll = true;
+    }
+    return options;
+  }
+
+  private final GroupBackend groupBackend;
+  private final GroupControl.Factory groupControlFactory;
+  private final Provider<ListMembers> listMembers;
+  private final Provider<ListSubgroups> listSubgroups;
+  private EnumSet<ListGroupsOption> options;
+
+  @Inject
+  GroupJson(
+      GroupBackend groupBackend,
+      GroupControl.Factory groupControlFactory,
+      Provider<ListMembers> listMembers,
+      Provider<ListSubgroups> listSubgroups) {
+    this.groupBackend = groupBackend;
+    this.groupControlFactory = groupControlFactory;
+    this.listMembers = listMembers;
+    this.listSubgroups = listSubgroups;
+
+    options = EnumSet.noneOf(ListGroupsOption.class);
+  }
+
+  public GroupJson addOption(ListGroupsOption o) {
+    options.add(o);
+    return this;
+  }
+
+  public GroupJson addOptions(Collection<ListGroupsOption> o) {
+    options.addAll(o);
+    return this;
+  }
+
+  public GroupInfo format(GroupResource rsrc) throws OrmException {
+    return createGroupInfo(rsrc.getGroup(), rsrc::getControl);
+  }
+
+  public GroupInfo format(GroupDescription.Basic group) throws OrmException {
+    return createGroupInfo(group, Suppliers.memoize(() -> groupControlFactory.controlFor(group)));
+  }
+
+  private GroupInfo createGroupInfo(
+      GroupDescription.Basic group, Supplier<GroupControl> groupControlSupplier)
+      throws OrmException {
+    GroupInfo info = createBasicGroupInfo(group);
+
+    if (group instanceof GroupDescription.Internal) {
+      addInternalDetails(info, (GroupDescription.Internal) group, groupControlSupplier);
+    }
+
+    return info;
+  }
+
+  private static GroupInfo createBasicGroupInfo(GroupDescription.Basic group) {
+    GroupInfo info = new GroupInfo();
+    info.id = Url.encode(group.getGroupUUID().get());
+    info.name = Strings.emptyToNull(group.getName());
+    info.url = Strings.emptyToNull(group.getUrl());
+    info.options = createOptions(group);
+    return info;
+  }
+
+  private void addInternalDetails(
+      GroupInfo info,
+      GroupDescription.Internal internalGroup,
+      Supplier<GroupControl> groupControlSupplier)
+      throws OrmException {
+    info.description = Strings.emptyToNull(internalGroup.getDescription());
+    info.groupId = internalGroup.getId().get();
+
+    AccountGroup.UUID ownerGroupUUID = internalGroup.getOwnerGroupUUID();
+    if (ownerGroupUUID != null) {
+      info.ownerId = Url.encode(ownerGroupUUID.get());
+      GroupDescription.Basic o = groupBackend.get(ownerGroupUUID);
+      if (o != null) {
+        info.owner = o.getName();
+      }
+    }
+
+    info.createdOn = internalGroup.getCreatedOn();
+
+    if (options.contains(MEMBERS)) {
+      info.members = listMembers.get().getDirectMembers(internalGroup, groupControlSupplier.get());
+    }
+
+    if (options.contains(INCLUDES)) {
+      info.includes =
+          listSubgroups.get().getDirectSubgroups(internalGroup, groupControlSupplier.get());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/GroupModule.java b/java/com/google/gerrit/server/restapi/group/GroupModule.java
new file mode 100644
index 0000000..2bf6bcc
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/GroupModule.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import static com.google.inject.Scopes.SINGLETON;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.IncludingGroupMembership;
+import com.google.gerrit.server.account.InternalGroupBackend;
+import com.google.gerrit.server.account.UniversalGroupBackend;
+import com.google.gerrit.server.group.SystemGroupBackend;
+
+public class GroupModule extends FactoryModule {
+
+  @Override
+  protected void configure() {
+    factory(InternalUser.Factory.class);
+    factory(IncludingGroupMembership.Factory.class);
+
+    bind(GroupBackend.class).to(UniversalGroupBackend.class).in(SINGLETON);
+    DynamicSet.setOf(binder(), GroupBackend.class);
+
+    bind(InternalGroupBackend.class).in(SINGLETON);
+    DynamicSet.bind(binder(), GroupBackend.class).to(SystemGroupBackend.class);
+    DynamicSet.bind(binder(), GroupBackend.class).to(InternalGroupBackend.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
new file mode 100644
index 0000000..b7a8d55
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
@@ -0,0 +1,201 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.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;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.AnonymousUser;
+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.account.GroupControl;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class GroupsCollection
+    implements RestCollection<TopLevelResource, GroupResource>,
+        AcceptsCreate<TopLevelResource>,
+        NeedsParams {
+  private final DynamicMap<RestView<GroupResource>> views;
+  private final Provider<ListGroups> list;
+  private final Provider<QueryGroups> queryGroups;
+  private final CreateGroup.Factory createGroup;
+  private final GroupControl.Factory groupControlFactory;
+  private final GroupBackend groupBackend;
+  private final Provider<CurrentUser> self;
+
+  private boolean hasQuery2;
+
+  @Inject
+  GroupsCollection(
+      DynamicMap<RestView<GroupResource>> views,
+      Provider<ListGroups> list,
+      Provider<QueryGroups> queryGroups,
+      CreateGroup.Factory createGroup,
+      GroupControl.Factory groupControlFactory,
+      GroupBackend groupBackend,
+      Provider<CurrentUser> self) {
+    this.views = views;
+    this.list = list;
+    this.queryGroups = queryGroups;
+    this.createGroup = createGroup;
+    this.groupControlFactory = groupControlFactory;
+    this.groupBackend = groupBackend;
+    this.self = self;
+  }
+
+  @Override
+  public void setParams(ListMultimap<String, String> params) throws BadRequestException {
+    if (params.containsKey("query") && params.containsKey("query2")) {
+      throw new BadRequestException("\"query\" and \"query2\" options are mutually exclusive");
+    }
+
+    // The --query2 option is defined in QueryGroups
+    this.hasQuery2 = params.containsKey("query2");
+  }
+
+  @Override
+  public RestView<TopLevelResource> list() throws ResourceNotFoundException, AuthException {
+    final CurrentUser user = self.get();
+    if (user instanceof AnonymousUser) {
+      throw new AuthException("Authentication required");
+    } else if (!(user.isIdentifiedUser())) {
+      throw new ResourceNotFoundException();
+    }
+
+    if (hasQuery2) {
+      return queryGroups.get();
+    }
+
+    return list.get();
+  }
+
+  @Override
+  public GroupResource parse(TopLevelResource parent, IdString id)
+      throws AuthException, ResourceNotFoundException {
+    final CurrentUser user = self.get();
+    if (user instanceof AnonymousUser) {
+      throw new AuthException("Authentication required");
+    } else if (!(user.isIdentifiedUser())) {
+      throw new ResourceNotFoundException(id);
+    }
+
+    GroupDescription.Basic group = parseId(id.get());
+    if (group == null) {
+      throw new ResourceNotFoundException(id.get());
+    }
+    GroupControl ctl = groupControlFactory.controlFor(group);
+    if (!ctl.isVisible()) {
+      throw new ResourceNotFoundException(id);
+    }
+    return new GroupResource(ctl);
+  }
+
+  /**
+   * Parses a group ID from a request body and returns the group.
+   *
+   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
+   * @return the group
+   * @throws UnprocessableEntityException thrown if the group ID cannot be resolved or if the group
+   *     is not visible to the calling user
+   */
+  public GroupDescription.Basic parse(String id) throws UnprocessableEntityException {
+    GroupDescription.Basic group = parseId(id);
+    if (group == null || !groupControlFactory.controlFor(group).isVisible()) {
+      throw new UnprocessableEntityException(String.format("Group Not Found: %s", id));
+    }
+    return group;
+  }
+
+  /**
+   * Parses a group ID from a request body and returns the group if it is a Gerrit internal group.
+   *
+   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
+   * @return the group
+   * @throws UnprocessableEntityException thrown if the group ID cannot be resolved, if the group is
+   *     not visible to the calling user or if it's an external group
+   */
+  public GroupDescription.Internal parseInternal(String id) throws UnprocessableEntityException {
+    GroupDescription.Basic group = parse(id);
+    if (group instanceof GroupDescription.Internal) {
+      return (GroupDescription.Internal) group;
+    }
+
+    throw new UnprocessableEntityException(String.format("External Group Not Allowed: %s", id));
+  }
+
+  /**
+   * Parses a group ID and returns the group without making any permission check whether the current
+   * user can see the group.
+   *
+   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
+   * @return the group, null if no group is found for the given group ID
+   */
+  public GroupDescription.Basic parseId(String id) {
+    AccountGroup.UUID uuid = new AccountGroup.UUID(id);
+    if (groupBackend.handles(uuid)) {
+      GroupDescription.Basic d = groupBackend.get(uuid);
+      if (d != null) {
+        return d;
+      }
+    }
+
+    // Might be a legacy AccountGroup.Id.
+    if (id.matches("^[1-9][0-9]*$")) {
+      try {
+        AccountGroup.Id legacyId = AccountGroup.Id.parse(id);
+        return groupControlFactory.controlFor(legacyId).getGroup();
+      } catch (IllegalArgumentException | NoSuchGroupException e) {
+        // Ignored
+      }
+    }
+
+    // Might be a group name, be nice and accept unique names.
+    GroupReference ref = GroupBackends.findExactSuggestion(groupBackend, id);
+    if (ref != null) {
+      GroupDescription.Basic d = groupBackend.get(ref.getUUID());
+      if (d != null) {
+        return d;
+      }
+    }
+
+    return null;
+  }
+
+  @Override
+  public CreateGroup create(TopLevelResource root, IdString name) {
+    return createGroup.create(name.get());
+  }
+
+  @Override
+  public DynamicMap<RestView<GroupResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/Index.java b/java/com/google/gerrit/server/restapi/group/Index.java
new file mode 100644
index 0000000..22003c0
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/Index.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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.GroupResource;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Optional;
+
+@Singleton
+public class Index implements RestModifyView<GroupResource, Input> {
+
+  private final GroupCache groupCache;
+
+  @Inject
+  Index(GroupCache groupCache) {
+    this.groupCache = groupCache;
+  }
+
+  @Override
+  public Response<?> apply(GroupResource rsrc, Input input)
+      throws IOException, AuthException, UnprocessableEntityException {
+    if (!rsrc.getControl().isOwner()) {
+      throw new AuthException("not allowed to index group");
+    }
+
+    AccountGroup.UUID groupUuid = rsrc.getGroup().getGroupUUID();
+    if (!rsrc.isInternalGroup()) {
+      throw new UnprocessableEntityException(
+          String.format("External Group Not Allowed: %s", groupUuid.get()));
+    }
+
+    Optional<InternalGroup> group = groupCache.get(groupUuid);
+    // evicting the group from the cache, reindexes the group
+    if (group.isPresent()) {
+      groupCache.evict(group.get().getGroupUUID(), group.get().getId(), group.get().getNameKey());
+    }
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
new file mode 100644
index 0000000..91aee6d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -0,0 +1,457 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+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;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.InternalGroupDescription;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.account.GetGroups;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+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.eclipse.jgit.errors.ConfigInvalidException;
+import org.kohsuke.args4j.Option;
+
+/** List groups visible to the calling user. */
+public class ListGroups implements RestReadView<TopLevelResource> {
+  private static final Comparator<GroupDescription.Internal> GROUP_COMPARATOR =
+      Comparator.comparing(GroupDescription.Basic::getName);
+
+  protected final GroupCache groupCache;
+
+  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;
+  private final Provider<IdentifiedUser> identifiedUser;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final GetGroups accountGetGroups;
+  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);
+  private boolean visibleToAll;
+  private Account.Id user;
+  private boolean owned;
+  private int limit;
+  private int start;
+  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(ProjectState project) {
+    projects.add(project);
+  }
+
+  @Option(
+    name = "--visible-to-all",
+    usage = "to list only groups that are visible to all registered users"
+  )
+  public void setVisibleToAll(boolean visibleToAll) {
+    this.visibleToAll = visibleToAll;
+  }
+
+  @Option(
+    name = "--user",
+    aliases = {"-u"},
+    usage = "user for which the groups should be listed"
+  )
+  public void setUser(Account.Id user) {
+    this.user = user;
+  }
+
+  @Option(
+    name = "--owned",
+    usage =
+        "to list only groups that are owned by the"
+            + " specified user or by the calling user if no user was specifed"
+  )
+  public void setOwned(boolean owned) {
+    this.owned = owned;
+  }
+
+  /**
+   * Add a group to inspect.
+   *
+   * @param uuid UUID of the group
+   * @deprecated use {@link #addGroup(AccountGroup.UUID)}.
+   */
+  @Deprecated
+  @Option(
+    name = "--query",
+    aliases = {"-q"},
+    usage = "group to inspect (deprecated: use --group/-g instead)"
+  )
+  void addGroup_Deprecated(AccountGroup.UUID uuid) {
+    addGroup(uuid);
+  }
+
+  @Option(
+    name = "--group",
+    aliases = {"-g"},
+    usage = "group to inspect"
+  )
+  public void addGroup(AccountGroup.UUID uuid) {
+    groupsToInspect.add(uuid);
+  }
+
+  @Option(
+    name = "--limit",
+    aliases = {"-n"},
+    metaVar = "CNT",
+    usage = "maximum number of groups to list"
+  )
+  public void setLimit(int limit) {
+    this.limit = limit;
+  }
+
+  @Option(
+    name = "--start",
+    aliases = {"-S"},
+    metaVar = "CNT",
+    usage = "number of groups to skip"
+  )
+  public void setStart(int start) {
+    this.start = start;
+  }
+
+  @Option(
+    name = "--match",
+    aliases = {"-m"},
+    metaVar = "MATCH",
+    usage = "match group substring"
+  )
+  public void setMatchSubstring(String matchSubstring) {
+    this.matchSubstring = matchSubstring;
+  }
+
+  @Option(
+    name = "--regex",
+    aliases = {"-r"},
+    metaVar = "REGEX",
+    usage = "match group regex"
+  )
+  public void setMatchRegex(String matchRegex) {
+    this.matchRegex = matchRegex;
+  }
+
+  @Option(
+    name = "--suggest",
+    aliases = {"-s"},
+    usage = "to get a suggestion of groups"
+  )
+  public void setSuggest(String suggest) {
+    this.suggest = suggest;
+  }
+
+  @Option(name = "-o", usage = "Output options per group")
+  void addOption(ListGroupsOption o) {
+    options.add(o);
+  }
+
+  @Option(name = "-O", usage = "Output option flags, in hex")
+  void setOptionFlagsHex(String hex) {
+    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,
+      final GroupControl.Factory groupControlFactory,
+      final GroupControl.GenericFactory genericGroupControlFactory,
+      final Provider<IdentifiedUser> identifiedUser,
+      final IdentifiedUser.GenericFactory userFactory,
+      final GetGroups accountGetGroups,
+      final GroupsCollection groupsCollection,
+      GroupJson json,
+      GroupBackend groupBackend,
+      Groups groups,
+      Provider<ReviewDb> db) {
+    this.groupCache = groupCache;
+    this.groupControlFactory = groupControlFactory;
+    this.genericGroupControlFactory = genericGroupControlFactory;
+    this.identifiedUser = identifiedUser;
+    this.userFactory = userFactory;
+    this.accountGetGroups = accountGetGroups;
+    this.json = json;
+    this.groupBackend = groupBackend;
+    this.groups = groups;
+    this.groupsCollection = groupsCollection;
+    this.db = db;
+  }
+
+  public void setOptions(EnumSet<ListGroupsOption> options) {
+    this.options = options;
+  }
+
+  public Account.Id getUser() {
+    return user;
+  }
+
+  public List<ProjectState> getProjects() {
+    return projects;
+  }
+
+  @Override
+  public SortedMap<String, GroupInfo> apply(TopLevelResource resource)
+      throws OrmException, RestApiException, IOException, ConfigInvalidException {
+    SortedMap<String, GroupInfo> output = new TreeMap<>();
+    for (GroupInfo info : get()) {
+      output.put(MoreObjects.firstNonNull(info.name, "Group " + Url.decode(info.id)), info);
+      info.name = null;
+    }
+    return output;
+  }
+
+  public List<GroupInfo> get()
+      throws OrmException, RestApiException, IOException, ConfigInvalidException {
+    if (!Strings.isNullOrEmpty(suggest)) {
+      return suggestGroups();
+    }
+
+    if (!Strings.isNullOrEmpty(matchSubstring) && !Strings.isNullOrEmpty(matchRegex)) {
+      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());
+    }
+
+    if (user != null) {
+      return accountGetGroups.apply(new AccountResource(userFactory.create(user)));
+    }
+
+    return getAllGroups();
+  }
+
+  private List<GroupInfo> getAllGroups() throws OrmException, IOException, ConfigInvalidException {
+    Pattern pattern = getRegexPattern();
+    Stream<GroupDescription.Internal> existingGroups =
+        getAllExistingGroups()
+            .filter(group -> isRelevant(pattern, group))
+            .map(this::loadGroup)
+            .flatMap(Streams::stream)
+            .filter(this::isVisible)
+            .sorted(GROUP_COMPARATOR)
+            .skip(start);
+    if (limit > 0) {
+      existingGroups = existingGroups.limit(limit);
+    }
+    List<GroupDescription.Internal> relevantGroups = existingGroups.collect(toImmutableList());
+    List<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(relevantGroups.size());
+    for (GroupDescription.Internal group : relevantGroups) {
+      groupInfos.add(json.addOptions(options).format(group));
+    }
+    return groupInfos;
+  }
+
+  private Stream<GroupReference> getAllExistingGroups()
+      throws OrmException, IOException, ConfigInvalidException {
+    if (!projects.isEmpty()) {
+      return projects
+          .stream()
+          .map(ProjectState::getAllGroups)
+          .flatMap(Collection::stream)
+          .distinct();
+    }
+    return groups.getAllGroupReferences(db.get());
+  }
+
+  private List<GroupInfo> suggestGroups() throws OrmException, BadRequestException {
+    if (conflictingSuggestParameters()) {
+      throw new BadRequestException(
+          "You should only have no more than one --project and -n with --suggest");
+    }
+    List<GroupReference> groupRefs =
+        Lists.newArrayList(
+            Iterables.limit(
+                groupBackend.suggest(suggest, projects.stream().findFirst().orElse(null)),
+                limit <= 0 ? 10 : Math.min(limit, 10)));
+
+    List<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(groupRefs.size());
+    for (GroupReference ref : groupRefs) {
+      GroupDescription.Basic desc = groupBackend.get(ref.getUUID());
+      if (desc != null) {
+        groupInfos.add(json.addOptions(options).format(desc));
+      }
+    }
+    return groupInfos;
+  }
+
+  private boolean conflictingSuggestParameters() {
+    if (Strings.isNullOrEmpty(suggest)) {
+      return false;
+    }
+    if (projects.size() > 1) {
+      return true;
+    }
+    if (visibleToAll) {
+      return true;
+    }
+    if (user != null) {
+      return true;
+    }
+    if (owned) {
+      return true;
+    }
+    if (ownedBy != null) {
+      return true;
+    }
+    if (start != 0) {
+      return true;
+    }
+    if (!groupsToInspect.isEmpty()) {
+      return true;
+    }
+    if (!Strings.isNullOrEmpty(matchSubstring)) {
+      return true;
+    }
+    if (!Strings.isNullOrEmpty(matchRegex)) {
+      return true;
+    }
+    return false;
+  }
+
+  private List<GroupInfo> filterGroupsOwnedBy(Predicate<GroupDescription.Internal> filter)
+      throws OrmException, IOException, ConfigInvalidException {
+    Pattern pattern = getRegexPattern();
+    Stream<? extends GroupDescription.Internal> foundGroups =
+        groups
+            .getAllGroupReferences(db.get())
+            .filter(group -> isRelevant(pattern, group))
+            .map(this::loadGroup)
+            .flatMap(Streams::stream)
+            .filter(this::isVisible)
+            .filter(filter)
+            .sorted(GROUP_COMPARATOR)
+            .skip(start);
+    if (limit > 0) {
+      foundGroups = foundGroups.limit(limit);
+    }
+    List<GroupDescription.Internal> ownedGroups = foundGroups.collect(toImmutableList());
+    List<GroupInfo> groupInfos = new ArrayList<>(ownedGroups.size());
+    for (GroupDescription.Internal group : ownedGroups) {
+      groupInfos.add(json.addOptions(options).format(group));
+    }
+    return groupInfos;
+  }
+
+  private Optional<GroupDescription.Internal> loadGroup(GroupReference groupReference) {
+    return groupCache.get(groupReference.getUUID()).map(InternalGroupDescription::new);
+  }
+
+  private List<GroupInfo> getGroupsOwnedBy(String id)
+      throws OrmException, RestApiException, IOException, ConfigInvalidException {
+    String uuid = groupsCollection.parse(id).getGroupUUID().get();
+    return filterGroupsOwnedBy(group -> group.getOwnerGroupUUID().get().equals(uuid));
+  }
+
+  private List<GroupInfo> getGroupsOwnedBy(IdentifiedUser user)
+      throws OrmException, IOException, ConfigInvalidException {
+    return filterGroupsOwnedBy(group -> isOwner(user, group));
+  }
+
+  private boolean isOwner(CurrentUser user, GroupDescription.Internal group) {
+    try {
+      return genericGroupControlFactory.controlFor(user, group.getGroupUUID()).isOwner();
+    } catch (NoSuchGroupException e) {
+      return false;
+    }
+  }
+
+  private Pattern getRegexPattern() {
+    return Strings.isNullOrEmpty(matchRegex) ? null : Pattern.compile(matchRegex);
+  }
+
+  private boolean isRelevant(Pattern pattern, GroupReference group) {
+    if (!Strings.isNullOrEmpty(matchSubstring)) {
+      if (!group.getName().toLowerCase(Locale.US).contains(matchSubstring.toLowerCase(Locale.US))) {
+        return false;
+      }
+    } else if (pattern != null) {
+      if (!pattern.matcher(group.getName()).matches()) {
+        return false;
+      }
+    }
+    return groupsToInspect.isEmpty() || groupsToInspect.contains(group.getUUID());
+  }
+
+  private boolean isVisible(GroupDescription.Internal group) {
+    if (visibleToAll && !group.isVisibleToAll()) {
+      return false;
+    }
+    GroupControl c = groupControlFactory.controlFor(group);
+    return c.isVisible();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/ListMembers.java b/java/com/google/gerrit/server/restapi/group/ListMembers.java
new file mode 100644
index 0000000..de0decd
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/ListMembers.java
@@ -0,0 +1,166 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.AccountInfoComparator;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.InternalGroupDescription;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.kohsuke.args4j.Option;
+
+public class ListMembers implements RestReadView<GroupResource> {
+  private final GroupCache groupCache;
+  private final GroupControl.Factory groupControlFactory;
+  private final AccountLoader accountLoader;
+
+  @Option(name = "--recursive", usage = "to resolve included groups recursively")
+  private boolean recursive;
+
+  @Inject
+  protected ListMembers(
+      GroupCache groupCache,
+      GroupControl.Factory groupControlFactory,
+      AccountLoader.Factory accountLoaderFactory) {
+    this.groupCache = groupCache;
+    this.groupControlFactory = groupControlFactory;
+    this.accountLoader = accountLoaderFactory.create(true);
+  }
+
+  public ListMembers setRecursive(boolean recursive) {
+    this.recursive = recursive;
+    return this;
+  }
+
+  @Override
+  public List<AccountInfo> apply(GroupResource resource)
+      throws MethodNotAllowedException, OrmException {
+    GroupDescription.Internal group =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    if (recursive) {
+      return getTransitiveMembers(group, resource.getControl());
+    }
+    return getDirectMembers(group, resource.getControl());
+  }
+
+  public List<AccountInfo> getTransitiveMembers(AccountGroup.UUID groupUuid) throws OrmException {
+    Optional<InternalGroup> group = groupCache.get(groupUuid);
+    if (group.isPresent()) {
+      InternalGroupDescription internalGroup = new InternalGroupDescription(group.get());
+      GroupControl groupControl = groupControlFactory.controlFor(internalGroup);
+      return getTransitiveMembers(internalGroup, groupControl);
+    }
+    return ImmutableList.of();
+  }
+
+  private List<AccountInfo> getTransitiveMembers(
+      GroupDescription.Internal group, GroupControl groupControl) throws OrmException {
+    checkSameGroup(group, groupControl);
+    Set<Account.Id> members =
+        getTransitiveMemberIds(
+            group, groupControl, new HashSet<>(ImmutableSet.of(group.getGroupUUID())));
+    return toAccountInfos(members);
+  }
+
+  public List<AccountInfo> getDirectMembers(InternalGroup group) throws OrmException {
+    InternalGroupDescription internalGroup = new InternalGroupDescription(group);
+    return getDirectMembers(internalGroup, groupControlFactory.controlFor(internalGroup));
+  }
+
+  public List<AccountInfo> getDirectMembers(
+      GroupDescription.Internal group, GroupControl groupControl) throws OrmException {
+    checkSameGroup(group, groupControl);
+    Set<Account.Id> directMembers = getDirectMemberIds(group, groupControl);
+    return toAccountInfos(directMembers);
+  }
+
+  private List<AccountInfo> toAccountInfos(Set<Account.Id> members) throws OrmException {
+    List<AccountInfo> memberInfos = new ArrayList<>(members.size());
+    for (Account.Id member : members) {
+      memberInfos.add(accountLoader.get(member));
+    }
+    accountLoader.fill();
+    memberInfos.sort(AccountInfoComparator.ORDER_NULLS_FIRST);
+    return memberInfos;
+  }
+
+  private Set<Account.Id> getTransitiveMemberIds(
+      GroupDescription.Internal group,
+      GroupControl groupControl,
+      HashSet<AccountGroup.UUID> seenGroups) {
+    Set<Account.Id> directMembers = getDirectMemberIds(group, groupControl);
+
+    if (!groupControl.canSeeGroup()) {
+      return directMembers;
+    }
+
+    Set<Account.Id> indirectMembers = getIndirectMemberIds(group, seenGroups);
+    return Sets.union(directMembers, indirectMembers);
+  }
+
+  private static Set<Account.Id> getDirectMemberIds(
+      GroupDescription.Internal group, GroupControl groupControl) {
+    return group.getMembers().stream().filter(groupControl::canSeeMember).collect(toImmutableSet());
+  }
+
+  private Set<Account.Id> getIndirectMemberIds(
+      GroupDescription.Internal group, HashSet<AccountGroup.UUID> seenGroups) {
+    Set<Account.Id> indirectMembers = new HashSet<>();
+    for (AccountGroup.UUID subgroupUuid : group.getSubgroups()) {
+      if (!seenGroups.contains(subgroupUuid)) {
+        seenGroups.add(subgroupUuid);
+
+        Set<Account.Id> subgroupMembers =
+            groupCache
+                .get(subgroupUuid)
+                .map(InternalGroupDescription::new)
+                .map(
+                    subgroup -> {
+                      GroupControl subgroupControl = groupControlFactory.controlFor(subgroup);
+                      return getTransitiveMemberIds(subgroup, subgroupControl, seenGroups);
+                    })
+                .orElseGet(ImmutableSet::of);
+        indirectMembers.addAll(subgroupMembers);
+      }
+    }
+    return indirectMembers;
+  }
+
+  private static void checkSameGroup(GroupDescription.Internal group, GroupControl groupControl) {
+    checkState(
+        group.equals(groupControl.getGroup()), "Specified group and groupControl do not match");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/ListSubgroups.java b/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
new file mode 100644
index 0000000..bea079f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import static com.google.common.base.Strings.nullToEmpty;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import org.slf4j.Logger;
+
+@Singleton
+public class ListSubgroups implements RestReadView<GroupResource> {
+  private static final Logger log = org.slf4j.LoggerFactory.getLogger(ListSubgroups.class);
+
+  private final GroupControl.Factory controlFactory;
+  private final GroupJson json;
+
+  @Inject
+  ListSubgroups(GroupControl.Factory controlFactory, GroupJson json) {
+    this.controlFactory = controlFactory;
+    this.json = json;
+  }
+
+  @Override
+  public List<GroupInfo> apply(GroupResource rsrc) throws MethodNotAllowedException, OrmException {
+    GroupDescription.Internal group =
+        rsrc.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+
+    return getDirectSubgroups(group, rsrc.getControl());
+  }
+
+  public List<GroupInfo> getDirectSubgroups(
+      GroupDescription.Internal group, GroupControl groupControl) throws OrmException {
+    boolean ownerOfParent = groupControl.isOwner();
+    List<GroupInfo> included = new ArrayList<>();
+    for (AccountGroup.UUID subgroupUuid : group.getSubgroups()) {
+      try {
+        GroupControl i = controlFactory.controlFor(subgroupUuid);
+        if (ownerOfParent || i.isVisible()) {
+          included.add(json.format(i.getGroup()));
+        }
+      } catch (NoSuchGroupException notFound) {
+        log.warn(
+            String.format(
+                "Group %s no longer available, subgroup of %s", subgroupUuid, group.getName()));
+        continue;
+      }
+    }
+    Collections.sort(
+        included,
+        new Comparator<GroupInfo>() {
+          @Override
+          public int compare(GroupInfo a, GroupInfo b) {
+            int cmp = nullToEmpty(a.name).compareTo(nullToEmpty(b.name));
+            if (cmp != 0) {
+              return cmp;
+            }
+            return nullToEmpty(a.id).compareTo(nullToEmpty(b.id));
+          }
+        });
+    return included;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/MembersCollection.java b/java/com/google/gerrit/server/restapi/group/MembersCollection.java
new file mode 100644
index 0000000..1523a343
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/MembersCollection.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.MemberResource;
+import com.google.gerrit.server.restapi.account.AccountsCollection;
+import com.google.gerrit.server.restapi.group.AddMembers.PutMember;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class MembersCollection
+    implements ChildCollection<GroupResource, MemberResource>, AcceptsCreate<GroupResource> {
+  private final DynamicMap<RestView<MemberResource>> views;
+  private final Provider<ListMembers> list;
+  private final AccountsCollection accounts;
+  private final AddMembers put;
+
+  @Inject
+  MembersCollection(
+      DynamicMap<RestView<MemberResource>> views,
+      Provider<ListMembers> list,
+      AccountsCollection accounts,
+      AddMembers put) {
+    this.views = views;
+    this.list = list;
+    this.accounts = accounts;
+    this.put = put;
+  }
+
+  @Override
+  public RestView<GroupResource> list() throws ResourceNotFoundException, AuthException {
+    return list.get();
+  }
+
+  @Override
+  public MemberResource parse(GroupResource parent, IdString id)
+      throws MethodNotAllowedException, AuthException, ResourceNotFoundException, OrmException,
+          IOException, ConfigInvalidException {
+    GroupDescription.Internal group =
+        parent.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+
+    IdentifiedUser user = accounts.parse(TopLevelResource.INSTANCE, id).getUser();
+    if (parent.getControl().canSeeMember(user.getAccountId()) && isMember(group, user)) {
+      return new MemberResource(parent, user);
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  private static boolean isMember(GroupDescription.Internal group, IdentifiedUser user) {
+    return group.getMembers().contains(user.getAccountId());
+  }
+
+  @Override
+  public PutMember create(GroupResource group, IdString id) {
+    return new PutMember(put, id.get());
+  }
+
+  @Override
+  public DynamicMap<RestView<MemberResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/Module.java b/java/com/google/gerrit/server/restapi/group/Module.java
new file mode 100644
index 0000000..cfe4efb
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/Module.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import static com.google.gerrit.server.group.GroupResource.GROUP_KIND;
+import static com.google.gerrit.server.group.MemberResource.MEMBER_KIND;
+import static com.google.gerrit.server.group.SubgroupResource.SUBGROUP_KIND;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.group.ServerInitiated;
+import com.google.gerrit.server.group.UserInitiated;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.restapi.group.AddMembers.UpdateMember;
+import com.google.gerrit.server.restapi.group.AddSubgroups.UpdateSubgroup;
+import com.google.gerrit.server.restapi.group.DeleteMembers.DeleteMember;
+import com.google.gerrit.server.restapi.group.DeleteSubgroups.DeleteSubgroup;
+import com.google.inject.Provides;
+
+public class Module extends RestApiModule {
+
+  @Override
+  protected void configure() {
+    bind(GroupsCollection.class);
+
+    DynamicMap.mapOf(binder(), GROUP_KIND);
+    DynamicMap.mapOf(binder(), MEMBER_KIND);
+    DynamicMap.mapOf(binder(), SUBGROUP_KIND);
+
+    get(GROUP_KIND).to(GetGroup.class);
+    put(GROUP_KIND).to(PutGroup.class);
+    get(GROUP_KIND, "detail").to(GetDetail.class);
+    post(GROUP_KIND, "index").to(Index.class);
+    post(GROUP_KIND, "members").to(AddMembers.class);
+    post(GROUP_KIND, "members.add").to(AddMembers.class);
+    post(GROUP_KIND, "members.delete").to(DeleteMembers.class);
+    post(GROUP_KIND, "groups").to(AddSubgroups.class);
+    post(GROUP_KIND, "groups.add").to(AddSubgroups.class);
+    post(GROUP_KIND, "groups.delete").to(DeleteSubgroups.class);
+    get(GROUP_KIND, "description").to(GetDescription.class);
+    put(GROUP_KIND, "description").to(PutDescription.class);
+    delete(GROUP_KIND, "description").to(PutDescription.class);
+    get(GROUP_KIND, "name").to(GetName.class);
+    put(GROUP_KIND, "name").to(PutName.class);
+    get(GROUP_KIND, "owner").to(GetOwner.class);
+    put(GROUP_KIND, "owner").to(PutOwner.class);
+    get(GROUP_KIND, "options").to(GetOptions.class);
+    put(GROUP_KIND, "options").to(PutOptions.class);
+    get(GROUP_KIND, "log.audit").to(GetAuditLog.class);
+    post(GROUP_KIND, "rebuild").to(Rebuild.class);
+
+    child(GROUP_KIND, "members").to(MembersCollection.class);
+    get(MEMBER_KIND).to(GetMember.class);
+    put(MEMBER_KIND).to(UpdateMember.class);
+    delete(MEMBER_KIND).to(DeleteMember.class);
+
+    child(GROUP_KIND, "groups").to(SubgroupsCollection.class);
+    get(SUBGROUP_KIND).to(GetSubgroup.class);
+    put(SUBGROUP_KIND).to(UpdateSubgroup.class);
+    delete(SUBGROUP_KIND).to(DeleteSubgroup.class);
+
+    factory(CreateGroup.Factory.class);
+    factory(GroupsUpdate.Factory.class);
+  }
+
+  @Provides
+  @ServerInitiated
+  GroupsUpdate provideServerInitiatedGroupsUpdate(GroupsUpdate.Factory groupsUpdateFactory) {
+    return groupsUpdateFactory.create(null);
+  }
+
+  @Provides
+  @UserInitiated
+  GroupsUpdate provideUserInitiatedGroupsUpdate(
+      GroupsUpdate.Factory groupsUpdateFactory, IdentifiedUser currentUser) {
+    return groupsUpdateFactory.create(currentUser);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/PutDescription.java b/java/com/google/gerrit/server/restapi/group/PutDescription.java
new file mode 100644
index 0000000..d48637d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/PutDescription.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.DescriptionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.UserInitiated;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+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.Objects;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PutDescription implements RestModifyView<GroupResource, DescriptionInput> {
+  private final Provider<ReviewDb> db;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
+
+  @Inject
+  PutDescription(
+      Provider<ReviewDb> db, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
+    this.db = db;
+    this.groupsUpdateProvider = groupsUpdateProvider;
+  }
+
+  @Override
+  public Response<String> apply(GroupResource resource, DescriptionInput input)
+      throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
+          IOException, ConfigInvalidException {
+    if (input == null) {
+      input = new DescriptionInput(); // Delete would set description to null.
+    }
+
+    GroupDescription.Internal internalGroup =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    if (!resource.getControl().isOwner()) {
+      throw new AuthException("Not group owner");
+    }
+
+    String currentDescription = Strings.nullToEmpty(internalGroup.getDescription());
+    String newDescription = Strings.nullToEmpty(input.description);
+    if (!Objects.equals(currentDescription, newDescription)) {
+      AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
+      InternalGroupUpdate groupUpdate =
+          InternalGroupUpdate.builder().setDescription(newDescription).build();
+      try {
+        groupsUpdateProvider.get().updateGroup(db.get(), groupUuid, groupUpdate);
+      } catch (NoSuchGroupException e) {
+        throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+      }
+    }
+
+    return Strings.isNullOrEmpty(input.description)
+        ? Response.<String>none()
+        : Response.ok(input.description);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/PutGroup.java b/java/com/google/gerrit/server/restapi/group/PutGroup.java
new file mode 100644
index 0000000..33dcb8d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/PutGroup.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.gerrit.extensions.api.groups.GroupInput;
+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.server.group.GroupResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class PutGroup implements RestModifyView<GroupResource, GroupInput> {
+  @Override
+  public Response<?> apply(GroupResource resource, GroupInput input)
+      throws ResourceConflictException {
+    throw new ResourceConflictException("Group already exists");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/PutName.java b/java/com/google/gerrit/server/restapi/group/PutName.java
new file mode 100644
index 0000000..a86cea0
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/PutName.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.NameInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import 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.GroupResource;
+import com.google.gerrit.server.group.UserInitiated;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PutName implements RestModifyView<GroupResource, NameInput> {
+  private final Provider<ReviewDb> db;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
+
+  @Inject
+  PutName(Provider<ReviewDb> db, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
+    this.db = db;
+    this.groupsUpdateProvider = groupsUpdateProvider;
+  }
+
+  @Override
+  public String apply(GroupResource rsrc, NameInput input)
+      throws MethodNotAllowedException, AuthException, BadRequestException,
+          ResourceConflictException, ResourceNotFoundException, OrmException, IOException,
+          ConfigInvalidException {
+    GroupDescription.Internal internalGroup =
+        rsrc.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    if (!rsrc.getControl().isOwner()) {
+      throw new AuthException("Not group owner");
+    } else if (input == null || Strings.isNullOrEmpty(input.name)) {
+      throw new BadRequestException("name is required");
+    }
+    String newName = input.name.trim();
+    if (newName.isEmpty()) {
+      throw new BadRequestException("name is required");
+    }
+
+    if (internalGroup.getName().equals(newName)) {
+      return newName;
+    }
+
+    renameGroup(internalGroup, newName);
+    return newName;
+  }
+
+  private void renameGroup(GroupDescription.Internal group, String newName)
+      throws ResourceConflictException, ResourceNotFoundException, OrmException, IOException,
+          ConfigInvalidException {
+    AccountGroup.UUID groupUuid = group.getGroupUUID();
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey(newName)).build();
+    try {
+      groupsUpdateProvider.get().updateGroup(db.get(), groupUuid, groupUpdate);
+    } catch (NoSuchGroupException e) {
+      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+    } catch (OrmDuplicateKeyException e) {
+      throw new ResourceConflictException("group with name " + newName + " already exists");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/PutOptions.java b/java/com/google/gerrit/server/restapi/group/PutOptions.java
new file mode 100644
index 0000000..c7f8552
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/PutOptions.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.GroupOptionsInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+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.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.UserInitiated;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PutOptions implements RestModifyView<GroupResource, GroupOptionsInfo> {
+  private final Provider<ReviewDb> db;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
+
+  @Inject
+  PutOptions(Provider<ReviewDb> db, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
+    this.db = db;
+    this.groupsUpdateProvider = groupsUpdateProvider;
+  }
+
+  @Override
+  public GroupOptionsInfo apply(GroupResource resource, GroupOptionsInfo input)
+      throws MethodNotAllowedException, AuthException, BadRequestException,
+          ResourceNotFoundException, OrmException, IOException, ConfigInvalidException {
+    GroupDescription.Internal internalGroup =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    if (!resource.getControl().isOwner()) {
+      throw new AuthException("Not group owner");
+    }
+
+    if (input == null) {
+      throw new BadRequestException("options are required");
+    }
+    if (input.visibleToAll == null) {
+      input.visibleToAll = false;
+    }
+
+    if (internalGroup.isVisibleToAll() != input.visibleToAll) {
+      AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
+      InternalGroupUpdate groupUpdate =
+          InternalGroupUpdate.builder().setVisibleToAll(input.visibleToAll).build();
+      try {
+        groupsUpdateProvider.get().updateGroup(db.get(), groupUuid, groupUpdate);
+      } catch (NoSuchGroupException e) {
+        throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+      }
+    }
+
+    GroupOptionsInfo options = new GroupOptionsInfo();
+    if (input.visibleToAll) {
+      options.visibleToAll = true;
+    }
+    return options;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/PutOwner.java b/java/com/google/gerrit/server/restapi/group/PutOwner.java
new file mode 100644
index 0000000..6fb3698
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/PutOwner.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.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.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.GroupResource;
+import com.google.gerrit.server.group.UserInitiated;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PutOwner implements RestModifyView<GroupResource, OwnerInput> {
+  private final GroupsCollection groupsCollection;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
+  private final Provider<ReviewDb> db;
+  private final GroupJson json;
+
+  @Inject
+  PutOwner(
+      GroupsCollection groupsCollection,
+      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
+      Provider<ReviewDb> db,
+      GroupJson json) {
+    this.groupsCollection = groupsCollection;
+    this.groupsUpdateProvider = groupsUpdateProvider;
+    this.db = db;
+    this.json = json;
+  }
+
+  @Override
+  public GroupInfo apply(GroupResource resource, OwnerInput input)
+      throws ResourceNotFoundException, MethodNotAllowedException, AuthException,
+          BadRequestException, UnprocessableEntityException, OrmException, IOException,
+          ConfigInvalidException {
+    GroupDescription.Internal internalGroup =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    if (!resource.getControl().isOwner()) {
+      throw new AuthException("Not group owner");
+    }
+
+    if (input == null || Strings.isNullOrEmpty(input.owner)) {
+      throw new BadRequestException("owner is required");
+    }
+
+    GroupDescription.Basic owner = groupsCollection.parse(input.owner);
+    if (!internalGroup.getOwnerGroupUUID().equals(owner.getGroupUUID())) {
+      AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
+      InternalGroupUpdate groupUpdate =
+          InternalGroupUpdate.builder().setOwnerGroupUUID(owner.getGroupUUID()).build();
+      try {
+        groupsUpdateProvider.get().updateGroup(db.get(), groupUuid, groupUpdate);
+      } catch (NoSuchGroupException e) {
+        throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+      }
+    }
+    return json.format(owner);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/QueryGroups.java b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
new file mode 100644
index 0000000..df04a2c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
@@ -0,0 +1,134 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.client.ListGroupsOption;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.InternalGroupDescription;
+import com.google.gerrit.server.query.group.GroupQueryBuilder;
+import com.google.gerrit.server.query.group.GroupQueryProcessor;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import org.kohsuke.args4j.Option;
+
+public class QueryGroups implements RestReadView<TopLevelResource> {
+  private final GroupQueryBuilder queryBuilder;
+  private final GroupQueryProcessor queryProcessor;
+  private final GroupJson json;
+
+  private String query;
+  private int limit;
+  private int start;
+  private EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
+
+  // TODO(ekempin): --query in ListGroups is marked as deprecated, once it is
+  // removed we want to rename --query2 to --query here.
+  /** --query (-q) is already used by {@link ListGroups} */
+  @Option(
+    name = "--query2",
+    aliases = {"-q2"},
+    usage = "group query"
+  )
+  public void setQuery(String query) {
+    this.query = query;
+  }
+
+  @Option(
+    name = "--limit",
+    aliases = {"-n"},
+    metaVar = "CNT",
+    usage = "maximum number of groups to list"
+  )
+  public void setLimit(int limit) {
+    this.limit = limit;
+  }
+
+  @Option(
+    name = "--start",
+    aliases = {"-S"},
+    metaVar = "CNT",
+    usage = "number of groups to skip"
+  )
+  public void setStart(int start) {
+    this.start = start;
+  }
+
+  @Option(name = "-o", usage = "Output options per group")
+  public void addOption(ListGroupsOption o) {
+    options.add(o);
+  }
+
+  @Option(name = "-O", usage = "Output option flags, in hex")
+  public void setOptionFlagsHex(String hex) {
+    options.addAll(ListGroupsOption.fromBits(Integer.parseInt(hex, 16)));
+  }
+
+  @Inject
+  protected QueryGroups(
+      GroupQueryBuilder queryBuilder, GroupQueryProcessor queryProcessor, GroupJson json) {
+    this.queryBuilder = queryBuilder;
+    this.queryProcessor = queryProcessor;
+    this.json = json;
+  }
+
+  @Override
+  public List<GroupInfo> apply(TopLevelResource resource)
+      throws BadRequestException, MethodNotAllowedException, OrmException {
+    if (Strings.isNullOrEmpty(query)) {
+      throw new BadRequestException("missing query field");
+    }
+
+    if (queryProcessor.isDisabled()) {
+      throw new MethodNotAllowedException("query disabled");
+    }
+
+    if (start != 0) {
+      queryProcessor.setStart(start);
+    }
+
+    if (limit != 0) {
+      queryProcessor.setUserProvidedLimit(limit);
+    }
+
+    try {
+      QueryResult<InternalGroup> result = queryProcessor.query(queryBuilder.parse(query));
+      List<InternalGroup> groups = result.entities();
+
+      ArrayList<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(groups.size());
+      json.addOptions(options);
+      for (InternalGroup group : groups) {
+        groupInfos.add(json.format(new InternalGroupDescription(group)));
+      }
+      if (!groupInfos.isEmpty() && result.more()) {
+        groupInfos.get(groupInfos.size() - 1)._moreGroups = true;
+      }
+      return groupInfos;
+    } catch (QueryParseException e) {
+      throw new BadRequestException(e.getMessage());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/Rebuild.java b/java/com/google/gerrit/server/restapi/group/Rebuild.java
new file mode 100644
index 0000000..9e8cb3f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/Rebuild.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static java.util.stream.Collectors.joining;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.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.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.db.GroupBundle;
+import com.google.gerrit.server.group.db.GroupRebuilder;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gerrit.server.restapi.group.Rebuild.Input;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class Rebuild implements RestModifyView<GroupResource, Input> {
+  public static class Input {
+    public Boolean force;
+  }
+
+  private final AllUsersName allUsers;
+  private final GitRepositoryManager repoManager;
+  private final GroupBundle.Factory bundleFactory;
+  private final GroupRebuilder rebuilder;
+  private final GroupsMigration migration;
+  private final Provider<ReviewDb> db;
+
+  @Inject
+  Rebuild(
+      AllUsersName allUsers,
+      GitRepositoryManager repoManager,
+      GroupBundle.Factory bundleFactory,
+      GroupRebuilder rebuilder,
+      GroupsMigration migration,
+      Provider<ReviewDb> db) {
+    this.allUsers = allUsers;
+    this.repoManager = repoManager;
+    this.bundleFactory = bundleFactory;
+    this.rebuilder = rebuilder;
+    this.migration = migration;
+    this.db = db;
+  }
+
+  @Override
+  public BinaryResult apply(GroupResource rsrc, Input input)
+      throws RestApiException, ConfigInvalidException, OrmException, IOException {
+    boolean force = firstNonNull(input.force, false);
+    if (!migration.writeToNoteDb()) {
+      throw new MethodNotAllowedException("NoteDb writes must be enabled");
+    }
+    if (migration.readFromNoteDb() && force) {
+      throw new MethodNotAllowedException("NoteDb reads must not be enabled when force=true");
+    }
+    if (!rsrc.isInternalGroup()) {
+      throw new MethodNotAllowedException("Not an internal group");
+    }
+
+    AccountGroup.UUID uuid = rsrc.getGroup().getGroupUUID();
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      if (force) {
+        RefUpdateUtil.deleteChecked(repo, RefNames.refsGroups(uuid));
+      }
+      GroupBundle reviewDbBundle =
+          bundleFactory.fromReviewDb(db.get(), rsrc.asInternalGroup().get().getId());
+      try {
+        rebuilder.rebuild(repo, reviewDbBundle, null);
+      } catch (OrmDuplicateKeyException e) {
+        throw new ResourceConflictException("Group already exists in NoteDb");
+      }
+
+      GroupBundle noteDbBundle = bundleFactory.fromNoteDb(repo, uuid);
+
+      List<String> diffs = GroupBundle.compareWithAudits(reviewDbBundle, noteDbBundle);
+      if (diffs.isEmpty()) {
+        return BinaryResult.create("No differences between ReviewDb and NoteDb");
+      }
+      return BinaryResult.create(
+          diffs
+              .stream()
+              .collect(joining("\n", "Differences between ReviewDb and NoteDb:\n", "\n")));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java b/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java
new file mode 100644
index 0000000..10b9e04
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.group;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.SubgroupResource;
+import com.google.gerrit.server.restapi.group.AddSubgroups.PutSubgroup;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class SubgroupsCollection
+    implements ChildCollection<GroupResource, SubgroupResource>, AcceptsCreate<GroupResource> {
+  private final DynamicMap<RestView<SubgroupResource>> views;
+  private final ListSubgroups list;
+  private final GroupsCollection groupsCollection;
+  private final AddSubgroups addSubgroups;
+
+  @Inject
+  SubgroupsCollection(
+      DynamicMap<RestView<SubgroupResource>> views,
+      ListSubgroups list,
+      GroupsCollection groupsCollection,
+      AddSubgroups addSubgroups) {
+    this.views = views;
+    this.list = list;
+    this.groupsCollection = groupsCollection;
+    this.addSubgroups = addSubgroups;
+  }
+
+  @Override
+  public RestView<GroupResource> list() {
+    return list;
+  }
+
+  @Override
+  public SubgroupResource parse(GroupResource resource, IdString id)
+      throws MethodNotAllowedException, AuthException, ResourceNotFoundException {
+    GroupDescription.Internal parent =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+
+    GroupDescription.Basic member =
+        groupsCollection.parse(TopLevelResource.INSTANCE, id).getGroup();
+    if (resource.getControl().canSeeGroup() && isSubgroup(parent, member)) {
+      return new SubgroupResource(resource, member);
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  private static boolean isSubgroup(
+      GroupDescription.Internal parent, GroupDescription.Basic member) {
+    return parent.getSubgroups().contains(member.getGroupUUID());
+  }
+
+  @Override
+  public PutSubgroup create(GroupResource group, IdString id) {
+    return new PutSubgroup(addSubgroups, id.get());
+  }
+
+  @Override
+  public DynamicMap<RestView<SubgroupResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/BanCommit.java b/java/com/google/gerrit/server/restapi/project/BanCommit.java
new file mode 100644
index 0000000..3d101b2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/BanCommit.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.collect.Lists;
+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.ProjectResource;
+import com.google.gerrit.server.restapi.project.BanCommit.BanResultInfo;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+
+@Singleton
+public class BanCommit
+    extends RetryingRestModifyView<ProjectResource, BanCommitInput, BanResultInfo> {
+  private final com.google.gerrit.server.git.BanCommit banCommit;
+
+  @Inject
+  BanCommit(RetryHelper retryHelper, com.google.gerrit.server.git.BanCommit banCommit) {
+    super(retryHelper);
+    this.banCommit = banCommit;
+  }
+
+  @Override
+  protected BanResultInfo applyImpl(
+      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());
+      for (String c : input.commits) {
+        try {
+          commitsToBan.add(ObjectId.fromString(c));
+        } catch (IllegalArgumentException e) {
+          throw new UnprocessableEntityException(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;
+  }
+
+  private static List<String> transformCommits(List<ObjectId> commits) {
+    if (commits == null || commits.isEmpty()) {
+      return null;
+    }
+    return Lists.transform(commits, ObjectId::getName);
+  }
+
+  public static class BanResultInfo {
+    public List<String> newlyBanned;
+    public List<String> alreadyBanned;
+    public List<String> ignored;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/BranchesCollection.java b/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
new file mode 100644
index 0000000..b90bd9c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class BranchesCollection
+    implements ChildCollection<ProjectResource, BranchResource>, AcceptsCreate<ProjectResource> {
+  private final DynamicMap<RestView<BranchResource>> views;
+  private final Provider<ListBranches> list;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final GitRepositoryManager repoManager;
+  private final CreateBranch.Factory createBranchFactory;
+
+  @Inject
+  BranchesCollection(
+      DynamicMap<RestView<BranchResource>> views,
+      Provider<ListBranches> list,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      GitRepositoryManager repoManager,
+      CreateBranch.Factory createBranchFactory) {
+    this.views = views;
+    this.list = list;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.repoManager = repoManager;
+    this.createBranchFactory = createBranchFactory;
+  }
+
+  @Override
+  public RestView<ProjectResource> list() {
+    return list.get();
+  }
+
+  @Override
+  public BranchResource parse(ProjectResource parent, IdString id)
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
+    Project.NameKey project = parent.getNameKey();
+    try (Repository repo = repoManager.openRepository(project)) {
+      Ref ref = repo.exactRef(RefNames.fullName(id.get()));
+      if (ref == null) {
+        throw new ResourceNotFoundException(id);
+      }
+
+      // ListBranches checks the target of a symbolic reference to determine access
+      // rights on the symbolic reference itself. This check prevents seeing a hidden
+      // branch simply because the symbolic reference name was visible.
+      permissionBackend
+          .user(user)
+          .project(project)
+          .ref(ref.isSymbolic() ? ref.getTarget().getName() : ref.getName())
+          .check(RefPermission.READ);
+      return new BranchResource(parent.getProjectState(), parent.getUser(), ref);
+    } catch (AuthException notAllowed) {
+      throw new ResourceNotFoundException(id);
+    } catch (RepositoryNotFoundException noRepo) {
+      throw new ResourceNotFoundException();
+    }
+  }
+
+  @Override
+  public DynamicMap<RestView<BranchResource>> views() {
+    return views;
+  }
+
+  @Override
+  public CreateBranch create(ProjectResource parent, IdString name) {
+    return createBranchFactory.create(name.get());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
new file mode 100644
index 0000000..deefa1a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.config.AccessCheckInfo;
+import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class CheckAccess implements RestModifyView<ProjectResource, AccessCheckInput> {
+  private final AccountResolver accountResolver;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  CheckAccess(
+      AccountResolver resolver,
+      IdentifiedUser.GenericFactory userFactory,
+      PermissionBackend permissionBackend) {
+    this.accountResolver = resolver;
+    this.userFactory = userFactory;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public AccessCheckInfo apply(ProjectResource rsrc, AccessCheckInput input)
+      throws OrmException, PermissionBackendException, RestApiException, IOException,
+          ConfigInvalidException {
+    permissionBackend.user(rsrc.getUser()).check(GlobalPermission.ADMINISTRATE_SERVER);
+
+    if (input == null) {
+      throw new BadRequestException("input is required");
+    }
+    if (Strings.isNullOrEmpty(input.account)) {
+      throw new BadRequestException("input requires 'account'");
+    }
+
+    Account match = accountResolver.find(input.account);
+    if (match == null) {
+      throw new UnprocessableEntityException(
+          String.format("cannot find account %s", input.account));
+    }
+
+    AccessCheckInfo info = new AccessCheckInfo();
+
+    IdentifiedUser user = userFactory.create(match.getId());
+    try {
+      permissionBackend.user(user).project(rsrc.getNameKey()).check(ProjectPermission.ACCESS);
+    } catch (AuthException | PermissionBackendException e) {
+      info.message =
+          String.format(
+              "user %s (%s) cannot see project %s",
+              user.getNameEmail(), user.getAccount().getId(), rsrc.getName());
+      info.status = HttpServletResponse.SC_FORBIDDEN;
+      return info;
+    }
+
+    if (!Strings.isNullOrEmpty(input.ref)) {
+      try {
+        permissionBackend
+            .user(user)
+            .ref(new Branch.NameKey(rsrc.getNameKey(), input.ref))
+            .check(RefPermission.READ);
+      } catch (AuthException | PermissionBackendException e) {
+        info.status = HttpServletResponse.SC_FORBIDDEN;
+        info.message =
+            String.format(
+                "user %s (%s) cannot see ref %s in project %s",
+                user.getNameEmail(), user.getAccount().getId(), input.ref, rsrc.getName());
+        return info;
+      }
+    }
+
+    info.status = HttpServletResponse.SC_OK;
+    return info;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
new file mode 100644
index 0000000..dd1c9a5
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.MergeableInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.InMemoryInserter;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.Merger;
+import org.eclipse.jgit.merge.ResolveMerger;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+/** Check the mergeability at current branch for a git object references expression. */
+public class CheckMergeability implements RestReadView<BranchResource> {
+  private String source;
+  private String strategy;
+  private SubmitType submitType;
+
+  @Option(
+    name = "--source",
+    metaVar = "COMMIT",
+    usage =
+        "the source reference to merge, which could be any git object "
+            + "references expression, refer to "
+            + "org.eclipse.jgit.lib.Repository#resolve(String)",
+    required = true
+  )
+  public void setSource(String source) {
+    this.source = source;
+  }
+
+  @Option(
+    name = "--strategy",
+    metaVar = "STRATEGY",
+    usage = "name of the merge strategy, refer to org.eclipse.jgit.merge.MergeStrategy"
+  )
+  public void setStrategy(String strategy) {
+    this.strategy = strategy;
+  }
+
+  private final GitRepositoryManager gitManager;
+  private final CommitsCollection commits;
+
+  @Inject
+  CheckMergeability(
+      GitRepositoryManager gitManager, CommitsCollection commits, @GerritServerConfig Config cfg) {
+    this.gitManager = gitManager;
+    this.commits = commits;
+    this.strategy = MergeUtil.getMergeStrategy(cfg).getName();
+    this.submitType = cfg.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
+  }
+
+  @Override
+  public MergeableInfo apply(BranchResource resource)
+      throws IOException, BadRequestException, ResourceNotFoundException {
+    if (!(submitType.equals(SubmitType.MERGE_ALWAYS)
+        || submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
+      throw new BadRequestException("Submit type: " + submitType + " is not supported");
+    }
+
+    MergeableInfo result = new MergeableInfo();
+    result.submitType = submitType;
+    result.strategy = strategy;
+    try (Repository git = gitManager.openRepository(resource.getNameKey());
+        RevWalk rw = new RevWalk(git);
+        ObjectInserter inserter = new InMemoryInserter(git)) {
+      Merger m = MergeUtil.newMerger(inserter, git.getConfig(), strategy);
+
+      Ref destRef = git.getRefDatabase().exactRef(resource.getRef());
+      if (destRef == null) {
+        throw new ResourceNotFoundException(resource.getRef());
+      }
+
+      RevCommit targetCommit = rw.parseCommit(destRef.getObjectId());
+      RevCommit sourceCommit = MergeUtil.resolveCommit(git, rw, source);
+
+      if (!commits.canRead(resource.getProjectState(), git, sourceCommit)) {
+        throw new BadRequestException("do not have read permission for: " + source);
+      }
+
+      if (rw.isMergedInto(sourceCommit, targetCommit)) {
+        result.mergeable = true;
+        result.commitMerged = true;
+        result.contentMerged = true;
+        return result;
+      }
+
+      if (m.merge(false, targetCommit, sourceCommit)) {
+        result.mergeable = true;
+        result.commitMerged = false;
+        result.contentMerged = m.getResultTreeId().equals(targetCommit.getTree());
+      } else {
+        result.mergeable = false;
+        if (m instanceof ResolveMerger) {
+          result.conflicts = ((ResolveMerger) m).getUnmergedPaths();
+        }
+      }
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage());
+    }
+    return result;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ChildProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ChildProjectsCollection.java
new file mode 100644
index 0000000..8f0e03c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ChildProjectsCollection.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ChildProjectResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class ChildProjectsCollection
+    implements ChildCollection<ProjectResource, ChildProjectResource> {
+  private final Provider<ListChildProjects> list;
+  private final ProjectsCollection projectsCollection;
+  private final DynamicMap<RestView<ChildProjectResource>> views;
+
+  @Inject
+  ChildProjectsCollection(
+      Provider<ListChildProjects> list,
+      ProjectsCollection projectsCollection,
+      DynamicMap<RestView<ChildProjectResource>> views) {
+    this.list = list;
+    this.projectsCollection = projectsCollection;
+    this.views = views;
+  }
+
+  @Override
+  public ListChildProjects list() throws ResourceNotFoundException, AuthException {
+    return list.get();
+  }
+
+  @Override
+  public ChildProjectResource parse(ProjectResource parent, IdString id)
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
+    ProjectResource p = projectsCollection.parse(TopLevelResource.INSTANCE, id);
+    for (ProjectState pp : p.getProjectState().parents()) {
+      if (parent.getNameKey().equals(pp.getProject().getNameKey())) {
+        return new ChildProjectResource(parent, p.getProjectState());
+      }
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  @Override
+  public DynamicMap<RestView<ChildProjectResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CommitIncludedIn.java b/java/com/google/gerrit/server/restapi/project/CommitIncludedIn.java
new file mode 100644
index 0000000..d43edfb
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CommitIncludedIn.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.IncludedIn;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+@Singleton
+class CommitIncludedIn implements RestReadView<CommitResource> {
+  private IncludedIn includedIn;
+
+  @Inject
+  CommitIncludedIn(IncludedIn includedIn) {
+    this.includedIn = includedIn;
+  }
+
+  @Override
+  public IncludedInInfo apply(CommitResource rsrc)
+      throws RestApiException, OrmException, IOException {
+    RevCommit commit = rsrc.getCommit();
+    Project.NameKey project = rsrc.getProjectState().getNameKey();
+    return includedIn.apply(project, commit.getId().getName());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
new file mode 100644
index 0000000..6aa639f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
@@ -0,0 +1,119 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.Reachable;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class CommitsCollection implements ChildCollection<ProjectResource, CommitResource> {
+  private static final Logger log = LoggerFactory.getLogger(CommitsCollection.class);
+
+  private final DynamicMap<RestView<CommitResource>> views;
+  private final GitRepositoryManager repoManager;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Reachable reachable;
+
+  @Inject
+  public CommitsCollection(
+      DynamicMap<RestView<CommitResource>> views,
+      GitRepositoryManager repoManager,
+      Provider<InternalChangeQuery> queryProvider,
+      Reachable reachable) {
+    this.views = views;
+    this.repoManager = repoManager;
+    this.queryProvider = queryProvider;
+    this.reachable = reachable;
+  }
+
+  @Override
+  public RestView<ProjectResource> list() throws ResourceNotFoundException {
+    throw new ResourceNotFoundException();
+  }
+
+  @Override
+  public CommitResource parse(ProjectResource parent, IdString id)
+      throws ResourceNotFoundException, IOException {
+    ObjectId objectId;
+    try {
+      objectId = ObjectId.fromString(id.get());
+    } catch (IllegalArgumentException e) {
+      throw new ResourceNotFoundException(id);
+    }
+
+    try (Repository repo = repoManager.openRepository(parent.getNameKey());
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(objectId);
+      rw.parseBody(commit);
+      if (!canRead(parent.getProjectState(), repo, commit)) {
+        throw new ResourceNotFoundException(id);
+      }
+      for (int i = 0; i < commit.getParentCount(); i++) {
+        rw.parseBody(rw.parseCommit(commit.getParent(i)));
+      }
+      return new CommitResource(parent, commit);
+    } catch (MissingObjectException | IncorrectObjectTypeException e) {
+      throw new ResourceNotFoundException(id);
+    }
+  }
+
+  @Override
+  public DynamicMap<RestView<CommitResource>> views() {
+    return views;
+  }
+
+  /** @return true if {@code commit} is visible to the caller. */
+  public boolean canRead(ProjectState state, Repository repo, RevCommit commit) {
+    Project.NameKey project = state.getNameKey();
+
+    // Look for changes associated with the commit.
+    try {
+      List<ChangeData> changes =
+          queryProvider.get().enforceVisibility(true).byProjectCommit(project, commit);
+      if (!changes.isEmpty()) {
+        return true;
+      }
+    } catch (OrmException e) {
+      log.error("Cannot look up change for commit " + commit.name() + " in " + project, e);
+    }
+
+    return reachable.fromRefs(state, repo, commit, repo.getAllRefs());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
new file mode 100644
index 0000000..0d52090
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+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.CommentLinkInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicMap.Entry;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+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;
+import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.extensions.webui.UiActions;
+import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.project.BooleanProjectConfigTransformations;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.TreeMap;
+
+public class ConfigInfoImpl extends ConfigInfo {
+  @SuppressWarnings("deprecation")
+  public ConfigInfoImpl(
+      boolean serverEnableSignedPush,
+      ProjectState projectState,
+      CurrentUser user,
+      TransferConfig config,
+      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      PluginConfigFactory cfgFactory,
+      AllProjectsName allProjects,
+      UiActions uiActions,
+      DynamicMap<RestView<ProjectResource>> views) {
+    Project p = projectState.getProject();
+    this.description = Strings.emptyToNull(p.getDescription());
+
+    ProjectState parentState = Iterables.getFirst(projectState.parents(), null);
+    for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
+      InheritedBooleanInfo info = new InheritedBooleanInfo();
+      info.configuredValue = p.getBooleanConfig(cfg);
+      if (parentState != null) {
+        info.inheritedValue = parentState.is(cfg);
+      }
+      BooleanProjectConfigTransformations.set(cfg, this, info);
+    }
+
+    if (!serverEnableSignedPush) {
+      this.enableSignedPush = null;
+      this.requireSignedPush = null;
+    }
+
+    MaxObjectSizeLimitInfo maxObjectSizeLimit = new MaxObjectSizeLimitInfo();
+    maxObjectSizeLimit.value =
+        config.getEffectiveMaxObjectSizeLimit(projectState) == config.getMaxObjectSizeLimit()
+            ? config.getFormattedMaxObjectSizeLimit()
+            : p.getMaxObjectSizeLimit();
+    maxObjectSizeLimit.configuredValue = p.getMaxObjectSizeLimit();
+    maxObjectSizeLimit.inheritedValue = config.getFormattedMaxObjectSizeLimit();
+    this.maxObjectSizeLimit = maxObjectSizeLimit;
+
+    this.defaultSubmitType = new SubmitTypeInfo();
+    this.defaultSubmitType.value = projectState.getSubmitType();
+    this.defaultSubmitType.configuredValue =
+        MoreObjects.firstNonNull(
+            projectState.getConfig().getProject().getConfiguredSubmitType(),
+            Project.DEFAULT_SUBMIT_TYPE);
+    ProjectState parent =
+        projectState.isAllProjects() ? projectState : projectState.parents().get(0);
+    this.defaultSubmitType.inheritedValue = parent.getSubmitType();
+
+    this.submitType = this.defaultSubmitType.value;
+
+    this.state =
+        p.getState() != com.google.gerrit.extensions.client.ProjectState.ACTIVE
+            ? p.getState()
+            : null;
+
+    this.commentlinks = new LinkedHashMap<>();
+    for (CommentLinkInfo cl : projectState.getCommentLinks()) {
+      this.commentlinks.put(cl.name, cl);
+    }
+
+    pluginConfig = getPluginConfig(projectState, pluginConfigEntries, cfgFactory, allProjects);
+
+    actions = new TreeMap<>();
+    for (UiAction.Description d : uiActions.from(views, new ProjectResource(projectState, user))) {
+      actions.put(d.getId(), new ActionInfo(d));
+    }
+    this.theme = projectState.getTheme();
+
+    this.extensionPanelNames = projectState.getConfig().getExtensionPanelSections();
+  }
+
+  private Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
+      ProjectState project,
+      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      PluginConfigFactory cfgFactory,
+      AllProjectsName allProjects) {
+    TreeMap<String, Map<String, ConfigParameterInfo>> pluginConfig = new TreeMap<>();
+    for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
+      ProjectConfigEntry configEntry = e.getProvider().get();
+      PluginConfig cfg = cfgFactory.getFromProjectConfig(project, e.getPluginName());
+      String configuredValue = cfg.getString(e.getExportName());
+      ConfigParameterInfo p = new ConfigParameterInfo();
+      p.displayName = configEntry.getDisplayName();
+      p.description = configEntry.getDescription();
+      p.warning = configEntry.getWarning(project);
+      p.type = configEntry.getType();
+      p.permittedValues = configEntry.getPermittedValues();
+      p.editable = configEntry.isEditable(project) ? true : null;
+      if (configEntry.isInheritable() && !allProjects.equals(project.getNameKey())) {
+        PluginConfig cfgWithInheritance =
+            cfgFactory.getFromProjectConfigWithInheritance(project, e.getPluginName());
+        p.inheritable = true;
+        p.value =
+            configEntry.onRead(
+                project,
+                cfgWithInheritance.getString(e.getExportName(), configEntry.getDefaultValue()));
+        p.configuredValue = configuredValue;
+        p.inheritedValue = getInheritedValue(project, cfgFactory, e);
+      } else {
+        if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
+          p.values =
+              configEntry.onRead(project, Arrays.asList(cfg.getStringList(e.getExportName())));
+        } else {
+          p.value =
+              configEntry.onRead(
+                  project,
+                  configuredValue != null ? configuredValue : configEntry.getDefaultValue());
+        }
+      }
+      Map<String, ConfigParameterInfo> pc = pluginConfig.get(e.getPluginName());
+      if (pc == null) {
+        pc = new TreeMap<>();
+        pluginConfig.put(e.getPluginName(), pc);
+      }
+      pc.put(e.getExportName(), p);
+    }
+    return !pluginConfig.isEmpty() ? pluginConfig : null;
+  }
+
+  private String getInheritedValue(
+      ProjectState project, PluginConfigFactory cfgFactory, Entry<ProjectConfigEntry> e) {
+    ProjectConfigEntry configEntry = e.getProvider().get();
+    ProjectState parent = Iterables.getFirst(project.parents(), null);
+    String inheritedValue = configEntry.getDefaultValue();
+    if (parent != null) {
+      PluginConfig parentCfgWithInheritance =
+          cfgFactory.getFromProjectConfigWithInheritance(parent, e.getPluginName());
+      inheritedValue =
+          parentCfgWithInheritance.getString(e.getExportName(), configEntry.getDefaultValue());
+    }
+    return inheritedValue;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
new file mode 100644
index 0000000..a06c8c5
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -0,0 +1,172 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.common.errors.PermissionDeniedException;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class CreateAccessChange implements RestModifyView<ProjectResource, ProjectAccessInput> {
+  private final PermissionBackend permissionBackend;
+  private final Sequences seq;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final BatchUpdate.Factory updateFactory;
+  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final Provider<ReviewDb> db;
+  private final SetAccessUtil setAccess;
+  private final ChangeJson.Factory jsonFactory;
+
+  @Inject
+  CreateAccessChange(
+      PermissionBackend permissionBackend,
+      ChangeInserter.Factory changeInserterFactory,
+      BatchUpdate.Factory updateFactory,
+      Sequences seq,
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      Provider<ReviewDb> db,
+      SetAccessUtil accessUtil,
+      ChangeJson.Factory jsonFactory) {
+    this.permissionBackend = permissionBackend;
+    this.seq = seq;
+    this.changeInserterFactory = changeInserterFactory;
+    this.updateFactory = updateFactory;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.db = db;
+    this.setAccess = accessUtil;
+    this.jsonFactory = jsonFactory;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
+      throws PermissionBackendException, PermissionDeniedException, IOException,
+          ConfigInvalidException, OrmException, InvalidNameException, UpdateException,
+          RestApiException {
+    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 (!check(forProject, ProjectPermission.WRITE_CONFIG)) {
+      try {
+        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);
+
+    try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
+      ProjectConfig config = ProjectConfig.read(md);
+
+      setAccess.validateChanges(config, removals, additions);
+      setAccess.applyChanges(config, removals, additions);
+      try {
+        setAccess.setParentName(
+            rsrc.getUser().asIdentifiedUser(),
+            config,
+            rsrc.getNameKey(),
+            newParentProjectName,
+            false);
+      } catch (AuthException e) {
+        throw new IllegalStateException(e);
+      }
+
+      md.setMessage("Review access change");
+      md.setInsertChangeId(true);
+      Change.Id changeId = new Change.Id(seq.nextChangeId());
+      RevCommit commit =
+          config.commitToNewRef(
+              md, new PatchSet.Id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
+
+      try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
+          ObjectReader objReader = objInserter.newReader();
+          RevWalk rw = new RevWalk(objReader);
+          BatchUpdate bu =
+              updateFactory.create(db.get(), rsrc.getNameKey(), rsrc.getUser(), TimeUtil.nowTs())) {
+        bu.setRepository(md.getRepository(), rw, objInserter);
+        ChangeInserter ins = newInserter(changeId, commit);
+        bu.insertChange(ins);
+        bu.execute();
+        return Response.created(jsonFactory.noOptions().format(ins.getChange()));
+      }
+    }
+  }
+
+  // ProjectConfig doesn't currently support fusing into a BatchUpdate.
+  @SuppressWarnings("deprecation")
+  private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
+    return changeInserterFactory
+        .create(changeId, commit, RefNames.REFS_CONFIG)
+        .setMessage(
+            // Same message as in ReceiveCommits.CreateRequest.
+            ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
+        .setValidate(false)
+        .setUpdateRef(false);
+  }
+
+  private boolean check(PermissionBackend.ForProject perm, ProjectPermission p)
+      throws PermissionBackendException {
+    try {
+      perm.check(p);
+      return true;
+    } catch (AuthException denied) {
+      return false;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
new file mode 100644
index 0000000..0b62c15
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -0,0 +1,197 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.CreateRefControl;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.RefUtil;
+import com.google.gerrit.server.project.RefValidationHelper;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class CreateBranch implements RestModifyView<ProjectResource, BranchInput> {
+  private static final Logger log = LoggerFactory.getLogger(CreateBranch.class);
+
+  public interface Factory {
+    CreateBranch create(String ref);
+  }
+
+  private final Provider<IdentifiedUser> identifiedUser;
+  private final PermissionBackend permissionBackend;
+  private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated referenceUpdated;
+  private final RefValidationHelper refCreationValidator;
+  private final CreateRefControl createRefControl;
+  private String ref;
+
+  @Inject
+  CreateBranch(
+      Provider<IdentifiedUser> identifiedUser,
+      PermissionBackend permissionBackend,
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated referenceUpdated,
+      RefValidationHelper.Factory refHelperFactory,
+      CreateRefControl createRefControl,
+      @Assisted String ref) {
+    this.identifiedUser = identifiedUser;
+    this.permissionBackend = permissionBackend;
+    this.repoManager = repoManager;
+    this.referenceUpdated = referenceUpdated;
+    this.refCreationValidator = refHelperFactory.create(ReceiveCommand.Type.CREATE);
+    this.createRefControl = createRefControl;
+    this.ref = ref;
+  }
+
+  @Override
+  public BranchInfo apply(ProjectResource rsrc, BranchInput input)
+      throws BadRequestException, AuthException, ResourceConflictException, IOException,
+          PermissionBackendException, NoSuchProjectException {
+    if (input == null) {
+      input = new BranchInput();
+    }
+    if (input.ref != null && !ref.equals(input.ref)) {
+      throw new BadRequestException("ref must match URL");
+    }
+    if (input.revision == null) {
+      input.revision = Constants.HEAD;
+    }
+    while (ref.startsWith("/")) {
+      ref = ref.substring(1);
+    }
+    ref = RefNames.fullName(ref);
+    if (!Repository.isValidRefName(ref)) {
+      throw new BadRequestException("invalid branch name \"" + ref + "\"");
+    }
+    if (MagicBranch.isMagicBranch(ref)) {
+      throw new BadRequestException(
+          "not allowed to create branches under \""
+              + MagicBranch.getMagicRefNamePrefix(ref)
+              + "\"");
+    }
+
+    final Branch.NameKey name = new Branch.NameKey(rsrc.getNameKey(), ref);
+    try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
+      ObjectId revid = RefUtil.parseBaseRevision(repo, rsrc.getNameKey(), input.revision);
+      RevWalk rw = RefUtil.verifyConnected(repo, revid);
+      RevObject object = rw.parseAny(revid);
+
+      if (ref.startsWith(Constants.R_HEADS)) {
+        // Ensure that what we start the branch from is a commit. If we
+        // were given a tag, deference to the commit instead.
+        //
+        try {
+          object = rw.parseCommit(object);
+        } catch (IncorrectObjectTypeException notCommit) {
+          throw new BadRequestException("\"" + input.revision + "\" not a commit");
+        }
+      }
+
+      createRefControl.checkCreateRef(identifiedUser, repo, name, object);
+
+      try {
+        final RefUpdate u = repo.updateRef(ref);
+        u.setExpectedOldObjectId(ObjectId.zeroId());
+        u.setNewObjectId(object.copy());
+        u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
+        u.setRefLogMessage("created via REST from " + input.revision, false);
+        refCreationValidator.validateRefOperation(rsrc.getName(), identifiedUser.get(), u);
+        final RefUpdate.Result result = u.update(rw);
+        switch (result) {
+          case FAST_FORWARD:
+          case NEW:
+          case NO_CHANGE:
+            referenceUpdated.fire(
+                name.getParentKey(),
+                u,
+                ReceiveCommand.Type.CREATE,
+                identifiedUser.get().getAccount());
+            break;
+          case LOCK_FAILURE:
+            if (repo.getRefDatabase().exactRef(ref) != null) {
+              throw new ResourceConflictException("branch \"" + ref + "\" already exists");
+            }
+            String refPrefix = RefUtil.getRefPrefix(ref);
+            while (!Constants.R_HEADS.equals(refPrefix)) {
+              if (repo.getRefDatabase().exactRef(refPrefix) != null) {
+                throw new ResourceConflictException(
+                    "Cannot create branch \""
+                        + ref
+                        + "\" since it conflicts with branch \""
+                        + refPrefix
+                        + "\".");
+              }
+              refPrefix = RefUtil.getRefPrefix(refPrefix);
+            }
+            // fall through
+            // $FALL-THROUGH$
+          case FORCED:
+          case IO_FAILURE:
+          case NOT_ATTEMPTED:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+          default:
+            {
+              throw new IOException(result.name());
+            }
+        }
+
+        BranchInfo info = new BranchInfo();
+        info.ref = ref;
+        info.revision = revid.getName();
+        info.canDelete =
+            permissionBackend.user(identifiedUser).ref(name).testOrFalse(RefPermission.DELETE)
+                ? true
+                : null;
+        return info;
+      } catch (IOException err) {
+        log.error("Cannot create branch \"" + name + "\"", err);
+        throw err;
+      }
+    } catch (RefUtil.InvalidRevisionException e) {
+      throw new BadRequestException("invalid revision \"" + input.revision + "\"");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
new file mode 100644
index 0000000..976ab09
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -0,0 +1,416 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.ProjectUtil;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.SubmitType;
+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;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.ProjectOwnerGroupsProvider;
+import com.google.gerrit.server.config.RepositoryConfig;
+import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectJson;
+import com.google.gerrit.server.project.ProjectNameLockManager;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.group.GroupsCollection;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.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;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@RequiresCapability(GlobalCapability.CREATE_PROJECT)
+public class CreateProject implements RestModifyView<TopLevelResource, ProjectInput> {
+  public interface Factory {
+    CreateProject create(String name);
+  }
+
+  private static final Logger log = LoggerFactory.getLogger(CreateProject.class);
+
+  private final Provider<ProjectsCollection> projectsCollection;
+  private final Provider<GroupsCollection> groupsCollection;
+  private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
+  private final ProjectJson json;
+  private final GitRepositoryManager repoManager;
+  private final DynamicSet<NewProjectCreatedListener> createdListeners;
+  private final ProjectCache projectCache;
+  private final GroupBackend groupBackend;
+  private final ProjectOwnerGroupsProvider.Factory projectOwnerGroups;
+  private final MetaDataUpdate.User metaDataUpdateFactory;
+  private final GitReferenceUpdated referenceUpdated;
+  private final RepositoryConfig repositoryCfg;
+  private final PersonIdent serverIdent;
+  private final Provider<IdentifiedUser> identifiedUser;
+  private final Provider<PutConfig> putConfig;
+  private final AllProjectsName allProjects;
+  private final DynamicItem<ProjectNameLockManager> lockManager;
+  private final String name;
+
+  @Inject
+  CreateProject(
+      Provider<ProjectsCollection> projectsCollection,
+      Provider<GroupsCollection> groupsCollection,
+      ProjectJson json,
+      DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
+      GitRepositoryManager repoManager,
+      DynamicSet<NewProjectCreatedListener> createdListeners,
+      ProjectCache projectCache,
+      GroupBackend groupBackend,
+      ProjectOwnerGroupsProvider.Factory projectOwnerGroups,
+      MetaDataUpdate.User metaDataUpdateFactory,
+      GitReferenceUpdated referenceUpdated,
+      RepositoryConfig repositoryCfg,
+      @GerritPersonIdent PersonIdent serverIdent,
+      Provider<IdentifiedUser> identifiedUser,
+      Provider<PutConfig> putConfig,
+      AllProjectsName allProjects,
+      DynamicItem<ProjectNameLockManager> lockManager,
+      @Assisted String name) {
+    this.projectsCollection = projectsCollection;
+    this.groupsCollection = groupsCollection;
+    this.projectCreationValidationListeners = projectCreationValidationListeners;
+    this.json = json;
+    this.repoManager = repoManager;
+    this.createdListeners = createdListeners;
+    this.projectCache = projectCache;
+    this.groupBackend = groupBackend;
+    this.projectOwnerGroups = projectOwnerGroups;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.referenceUpdated = referenceUpdated;
+    this.repositoryCfg = repositoryCfg;
+    this.serverIdent = serverIdent;
+    this.identifiedUser = identifiedUser;
+    this.putConfig = putConfig;
+    this.allProjects = allProjects;
+    this.lockManager = lockManager;
+    this.name = name;
+  }
+
+  @Override
+  public Response<ProjectInfo> apply(TopLevelResource resource, ProjectInput input)
+      throws BadRequestException, UnprocessableEntityException, ResourceConflictException,
+          ResourceNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (input == null) {
+      input = new ProjectInput();
+    }
+    if (input.name != null && !name.equals(input.name)) {
+      throw new BadRequestException("name must match URL");
+    }
+
+    CreateProjectArgs args = new CreateProjectArgs();
+    args.setProjectName(ProjectUtil.stripGitSuffix(name));
+
+    String parentName =
+        MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
+    args.newParent = projectsCollection.get().parse(parentName, false).getNameKey();
+    args.createEmptyCommit = input.createEmptyCommit;
+    args.permissionsOnly = input.permissionsOnly;
+    args.projectDescription = Strings.emptyToNull(input.description);
+    args.submitType = input.submitType;
+    args.branch = normalizeBranchNames(input.branches);
+    if (input.owners == null || input.owners.isEmpty()) {
+      args.ownerIds = new ArrayList<>(projectOwnerGroups.create(args.getProject()).get());
+    } else {
+      args.ownerIds = Lists.newArrayListWithCapacity(input.owners.size());
+      for (String owner : input.owners) {
+        args.ownerIds.add(groupsCollection.get().parse(owner).getGroupUUID());
+      }
+    }
+    args.contributorAgreements =
+        MoreObjects.firstNonNull(input.useContributorAgreements, InheritableBoolean.INHERIT);
+    args.signedOffBy = MoreObjects.firstNonNull(input.useSignedOffBy, InheritableBoolean.INHERIT);
+    args.contentMerge =
+        input.submitType == SubmitType.FAST_FORWARD_ONLY
+            ? InheritableBoolean.FALSE
+            : MoreObjects.firstNonNull(input.useContentMerge, InheritableBoolean.INHERIT);
+    args.newChangeForAllNotInTarget =
+        MoreObjects.firstNonNull(
+            input.createNewChangeForAllNotInTarget, InheritableBoolean.INHERIT);
+    args.changeIdRequired =
+        MoreObjects.firstNonNull(input.requireChangeId, InheritableBoolean.INHERIT);
+    args.rejectEmptyCommit =
+        MoreObjects.firstNonNull(input.rejectEmptyCommit, InheritableBoolean.INHERIT);
+    try {
+      args.maxObjectSizeLimit = ProjectConfig.validMaxObjectSizeLimit(input.maxObjectSizeLimit);
+    } catch (ConfigInvalidException e) {
+      throw new BadRequestException(e.getMessage());
+    }
+
+    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);
+      }
+      return Response.created(json.format(projectState));
+    } finally {
+      nameLock.unlock();
+    }
+  }
+
+  private ProjectState createProject(CreateProjectArgs args)
+      throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
+    final Project.NameKey nameKey = args.getProject();
+    try {
+      final String head = args.permissionsOnly ? RefNames.REFS_CONFIG : args.branch.get(0);
+      try (Repository repo = repoManager.openRepository(nameKey)) {
+        if (repo.getObjectDatabase().exists()) {
+          throw new ResourceConflictException("project \"" + nameKey + "\" exists");
+        }
+      } catch (RepositoryNotFoundException e) {
+        // It does not exist, safe to ignore.
+      }
+      try (Repository repo = repoManager.createRepository(nameKey)) {
+        RefUpdate u = repo.updateRef(Constants.HEAD);
+        u.disableRefLog();
+        u.link(head);
+
+        createProjectConfig(args);
+
+        if (!args.permissionsOnly && args.createEmptyCommit) {
+          createEmptyCommits(repo, nameKey, args.branch);
+        }
+
+        fire(nameKey, head);
+
+        return projectCache.get(nameKey);
+      }
+    } catch (RepositoryCaseMismatchException e) {
+      throw new ResourceConflictException(
+          "Cannot create "
+              + nameKey.get()
+              + " because the name is already occupied by another project."
+              + " The other project has the same name, only spelled in a"
+              + " different case.");
+    } catch (RepositoryNotFoundException badName) {
+      throw new BadRequestException("invalid project name: " + nameKey);
+    } catch (ConfigInvalidException e) {
+      String msg = "Cannot create " + nameKey;
+      log.error(msg, e);
+      throw e;
+    }
+  }
+
+  private void createProjectConfig(CreateProjectArgs args)
+      throws IOException, ConfigInvalidException {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(args.getProject())) {
+      ProjectConfig config = ProjectConfig.read(md);
+
+      Project newProject = config.getProject();
+      newProject.setDescription(args.projectDescription);
+      newProject.setSubmitType(
+          MoreObjects.firstNonNull(
+              args.submitType, repositoryCfg.getDefaultSubmitType(args.getProject())));
+      newProject.setBooleanConfig(
+          BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, args.contributorAgreements);
+      newProject.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, args.signedOffBy);
+      newProject.setBooleanConfig(BooleanProjectConfig.USE_CONTENT_MERGE, args.contentMerge);
+      newProject.setBooleanConfig(
+          BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+          args.newChangeForAllNotInTarget);
+      newProject.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, args.changeIdRequired);
+      newProject.setBooleanConfig(BooleanProjectConfig.REJECT_EMPTY_COMMIT, args.rejectEmptyCommit);
+      newProject.setMaxObjectSizeLimit(args.maxObjectSizeLimit);
+      if (args.newParent != null) {
+        newProject.setParentName(args.newParent);
+      }
+
+      if (!args.ownerIds.isEmpty()) {
+        AccessSection all = config.getAccessSection(AccessSection.ALL, true);
+        for (AccountGroup.UUID ownerId : args.ownerIds) {
+          GroupDescription.Basic g = groupBackend.get(ownerId);
+          if (g != null) {
+            GroupReference group = config.resolve(GroupReference.forGroup(g));
+            all.getPermission(Permission.OWNER, true).add(new PermissionRule(group));
+          }
+        }
+      }
+
+      md.setMessage("Created project\n");
+      config.commit(md);
+      md.getRepository().setGitwebDescription(args.projectDescription);
+    }
+    projectCache.onCreateProject(args.getProject());
+  }
+
+  private List<String> normalizeBranchNames(List<String> branches) throws BadRequestException {
+    if (branches == null || branches.isEmpty()) {
+      return Collections.singletonList(Constants.R_HEADS + Constants.MASTER);
+    }
+
+    List<String> normalizedBranches = new ArrayList<>();
+    for (String branch : branches) {
+      while (branch.startsWith("/")) {
+        branch = branch.substring(1);
+      }
+      branch = RefNames.fullName(branch);
+      if (!Repository.isValidRefName(branch)) {
+        throw new BadRequestException(String.format("Branch \"%s\" is not a valid name.", branch));
+      }
+      if (!normalizedBranches.contains(branch)) {
+        normalizedBranches.add(branch);
+      }
+    }
+    return normalizedBranches;
+  }
+
+  private void createEmptyCommits(Repository repo, Project.NameKey project, List<String> refs)
+      throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
+      cb.setAuthor(metaDataUpdateFactory.getUserPersonIdent());
+      cb.setCommitter(serverIdent);
+      cb.setMessage("Initial empty repository\n");
+
+      ObjectId id = oi.insert(cb);
+      oi.flush();
+
+      for (String ref : refs) {
+        RefUpdate ru = repo.updateRef(ref);
+        ru.setNewObjectId(id);
+        Result result = ru.update();
+        switch (result) {
+          case NEW:
+            referenceUpdated.fire(
+                project, ru, ReceiveCommand.Type.CREATE, identifiedUser.get().getAccount());
+            break;
+          case FAST_FORWARD:
+          case FORCED:
+          case IO_FAILURE:
+          case LOCK_FAILURE:
+          case NOT_ATTEMPTED:
+          case NO_CHANGE:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+          default:
+            {
+              throw new IOException(
+                  String.format("Failed to create ref \"%s\": %s", ref, result.name()));
+            }
+        }
+      }
+    } catch (IOException e) {
+      log.error("Cannot create empty commit for " + project.get(), e);
+      throw e;
+    }
+  }
+
+  private void fire(Project.NameKey name, String head) {
+    if (!createdListeners.iterator().hasNext()) {
+      return;
+    }
+
+    Event event = new Event(name, head);
+    for (NewProjectCreatedListener l : createdListeners) {
+      try {
+        l.onNewProjectCreated(event);
+      } catch (RuntimeException e) {
+        log.warn("Failure in NewProjectCreatedListener", e);
+      }
+    }
+  }
+
+  static class Event extends AbstractNoNotifyEvent implements NewProjectCreatedListener.Event {
+    private final Project.NameKey name;
+    private final String head;
+
+    Event(Project.NameKey name, String head) {
+      this.name = name;
+      this.head = head;
+    }
+
+    @Override
+    public String getProjectName() {
+      return name.get();
+    }
+
+    @Override
+    public String getHeadName() {
+      return head;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
new file mode 100644
index 0000000..ca51b44
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -0,0 +1,170 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.WebLinks;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.RefUtil;
+import com.google.gerrit.server.project.RefUtil.InvalidRevisionException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.TimeZone;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.TagCommand;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class CreateTag implements RestModifyView<ProjectResource, TagInput> {
+  private static final Logger log = LoggerFactory.getLogger(CreateTag.class);
+
+  public interface Factory {
+    CreateTag create(String ref);
+  }
+
+  private final PermissionBackend permissionBackend;
+  private final Provider<IdentifiedUser> identifiedUser;
+  private final GitRepositoryManager repoManager;
+  private final TagCache tagCache;
+  private final GitReferenceUpdated referenceUpdated;
+  private final WebLinks links;
+  private String ref;
+
+  @Inject
+  CreateTag(
+      PermissionBackend permissionBackend,
+      Provider<IdentifiedUser> identifiedUser,
+      GitRepositoryManager repoManager,
+      TagCache tagCache,
+      GitReferenceUpdated referenceUpdated,
+      WebLinks webLinks,
+      @Assisted String ref) {
+    this.permissionBackend = permissionBackend;
+    this.identifiedUser = identifiedUser;
+    this.repoManager = repoManager;
+    this.tagCache = tagCache;
+    this.referenceUpdated = referenceUpdated;
+    this.links = webLinks;
+    this.ref = ref;
+  }
+
+  @Override
+  public TagInfo apply(ProjectResource resource, TagInput input)
+      throws RestApiException, IOException, PermissionBackendException, NoSuchProjectException {
+    if (input == null) {
+      input = new TagInput();
+    }
+    if (input.ref != null && !ref.equals(input.ref)) {
+      throw new BadRequestException("ref must match URL");
+    }
+    if (input.revision == null) {
+      input.revision = Constants.HEAD;
+    }
+
+    ref = RefUtil.normalizeTagRef(ref);
+    PermissionBackend.ForRef perm =
+        permissionBackend.user(identifiedUser).project(resource.getNameKey()).ref(ref);
+
+    try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
+      ObjectId revid = RefUtil.parseBaseRevision(repo, resource.getNameKey(), input.revision);
+      RevWalk rw = RefUtil.verifyConnected(repo, revid);
+      RevObject object = rw.parseAny(revid);
+      rw.reset();
+      boolean isAnnotated = Strings.emptyToNull(input.message) != null;
+      boolean isSigned = isAnnotated && input.message.contains("-----BEGIN PGP SIGNATURE-----\n");
+      if (isSigned) {
+        throw new MethodNotAllowedException("Cannot create signed tag \"" + ref + "\"");
+      } else if (isAnnotated && !check(perm, RefPermission.CREATE_TAG)) {
+        throw new AuthException("Cannot create annotated tag \"" + ref + "\"");
+      } else {
+        perm.check(RefPermission.CREATE);
+      }
+      if (repo.getRefDatabase().exactRef(ref) != null) {
+        throw new ResourceConflictException("tag \"" + ref + "\" already exists");
+      }
+
+      try (Git git = new Git(repo)) {
+        TagCommand tag =
+            git.tag()
+                .setObjectId(object)
+                .setName(ref.substring(R_TAGS.length()))
+                .setAnnotated(isAnnotated)
+                .setSigned(isSigned);
+
+        if (isAnnotated) {
+          tag.setMessage(input.message)
+              .setTagger(
+                  identifiedUser.get().newCommitterIdent(TimeUtil.nowTs(), TimeZone.getDefault()));
+        }
+
+        Ref result = tag.call();
+        tagCache.updateFastForward(
+            resource.getNameKey(), ref, ObjectId.zeroId(), result.getObjectId());
+        referenceUpdated.fire(
+            resource.getNameKey(),
+            ref,
+            ObjectId.zeroId(),
+            result.getObjectId(),
+            identifiedUser.get().getAccount());
+        try (RevWalk w = new RevWalk(repo)) {
+          return ListTags.createTagInfo(perm, result, w, resource.getNameKey(), links);
+        }
+      }
+    } catch (InvalidRevisionException e) {
+      throw new BadRequestException("Invalid base revision");
+    } catch (GitAPIException e) {
+      log.error("Cannot create tag \"" + ref + "\"", e);
+      throw new IOException(e);
+    }
+  }
+
+  private static boolean check(PermissionBackend.ForRef perm, RefPermission permission)
+      throws PermissionBackendException {
+    try {
+      perm.check(permission);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java b/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
new file mode 100644
index 0000000..e3ffa4d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
@@ -0,0 +1,252 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
+
+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.Lists;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.UrlEncoded;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.DashboardResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.AmbiguousObjectException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.BlobBasedConfig;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class DashboardsCollection
+    implements ChildCollection<ProjectResource, DashboardResource>, AcceptsCreate<ProjectResource> {
+  public static final String DEFAULT_DASHBOARD_NAME = "default";
+
+  private final GitRepositoryManager gitManager;
+  private final DynamicMap<RestView<DashboardResource>> views;
+  private final Provider<ListDashboards> list;
+  private final Provider<SetDefaultDashboard.CreateDefault> createDefault;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  DashboardsCollection(
+      GitRepositoryManager gitManager,
+      DynamicMap<RestView<DashboardResource>> views,
+      Provider<ListDashboards> list,
+      Provider<SetDefaultDashboard.CreateDefault> createDefault,
+      PermissionBackend permissionBackend) {
+    this.gitManager = gitManager;
+    this.views = views;
+    this.list = list;
+    this.createDefault = createDefault;
+    this.permissionBackend = permissionBackend;
+  }
+
+  public static boolean isDefaultDashboard(@Nullable String id) {
+    return DEFAULT_DASHBOARD_NAME.equals(id);
+  }
+
+  public static boolean isDefaultDashboard(@Nullable IdString id) {
+    return id != null && isDefaultDashboard(id.toString());
+  }
+
+  @Override
+  public RestView<ProjectResource> list() throws ResourceNotFoundException {
+    return list.get();
+  }
+
+  @Override
+  public RestModifyView<ProjectResource, ?> create(ProjectResource parent, IdString id)
+      throws RestApiException {
+    if (isDefaultDashboard(id)) {
+      return createDefault.get();
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  @Override
+  public DashboardResource parse(ProjectResource parent, IdString id)
+      throws ResourceNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (isDefaultDashboard(id)) {
+      return DashboardResource.projectDefault(parent.getProjectState(), parent.getUser());
+    }
+
+    DashboardInfo info;
+    try {
+      info = newDashboardInfo(id.get());
+    } catch (InvalidDashboardId e) {
+      throw new ResourceNotFoundException(id);
+    }
+
+    for (ProjectState ps : parent.getProjectState().tree()) {
+      try {
+        return parse(ps, parent.getProjectState(), parent.getUser(), info);
+      } catch (AmbiguousObjectException | ConfigInvalidException | IncorrectObjectTypeException e) {
+        throw new ResourceNotFoundException(id);
+      } catch (ResourceNotFoundException e) {
+        continue;
+      }
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  public static String normalizeDashboardRef(String ref) {
+    if (!ref.startsWith(REFS_DASHBOARDS)) {
+      return REFS_DASHBOARDS + ref;
+    }
+    return ref;
+  }
+
+  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(user).project(parent.getNameKey()).ref(ref).check(RefPermission.READ);
+    } catch (AuthException e) {
+      // Don't leak the project's existence
+      throw new ResourceNotFoundException(info.id);
+    }
+    if (!Repository.isValidRefName(ref)) {
+      throw new ResourceNotFoundException(info.id);
+    }
+
+    try (Repository git = gitManager.openRepository(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(current, user, ref, info.path, cfg, false);
+    } catch (RepositoryNotFoundException e) {
+      throw new ResourceNotFoundException(info.id);
+    }
+  }
+
+  @Override
+  public DynamicMap<RestView<DashboardResource>> views() {
+    return views;
+  }
+
+  public static DashboardInfo newDashboardInfo(String ref, String path) {
+    DashboardInfo info = new DashboardInfo();
+    info.ref = ref;
+    info.path = path;
+    info.id = Joiner.on(':').join(Url.encode(ref), Url.encode(path));
+    return info;
+  }
+
+  public static class InvalidDashboardId extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    public InvalidDashboardId(String id) {
+      super(id);
+    }
+  }
+
+  static DashboardInfo newDashboardInfo(String id) throws InvalidDashboardId {
+    DashboardInfo info = new DashboardInfo();
+    List<String> parts = Lists.newArrayList(Splitter.on(':').limit(2).split(id));
+    if (parts.size() != 2) {
+      throw new InvalidDashboardId(id);
+    }
+    info.id = id;
+    info.ref = parts.get(0);
+    info.path = parts.get(1);
+    return info;
+  }
+
+  static DashboardInfo parse(
+      Project definingProject,
+      String refName,
+      String path,
+      Config config,
+      String project,
+      boolean setDefault) {
+    DashboardInfo info = newDashboardInfo(refName, path);
+    info.project = project;
+    info.definingProject = definingProject.getName();
+    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");
+
+    if (setDefault) {
+      String id = refName + ":" + path;
+      info.isDefault = id.equals(defaultOf(definingProject)) ? true : null;
+    }
+
+    UrlEncoded u = new UrlEncoded("/dashboard/");
+    u.put("title", MoreObjects.firstNonNull(info.title, info.path));
+    if (info.foreach != null) {
+      u.put("foreach", replace(project, info.foreach));
+    }
+    for (String name : config.getSubsections("section")) {
+      DashboardSectionInfo s = new DashboardSectionInfo();
+      s.name = name;
+      s.query = config.getString("section", name, "query");
+      u.put(s.name, replace(project, s.query));
+      info.sections.add(s);
+    }
+    info.url = u.toString().replace("%3A", ":");
+
+    return info;
+  }
+
+  private static String replace(String project, String input) {
+    return input == null ? input : input.replace("${project}", project);
+  }
+
+  private static String defaultOf(Project proj) {
+    final String defaultId =
+        MoreObjects.firstNonNull(
+            proj.getLocalDefaultDashboard(), Strings.nullToEmpty(proj.getDefaultDashboard()));
+    if (defaultId.startsWith(REFS_DASHBOARDS)) {
+      return defaultId.substring(REFS_DASHBOARDS.length());
+    }
+    return defaultId;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
new file mode 100644
index 0000000..09bbca9
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+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;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class DeleteBranch implements RestModifyView<BranchResource, Input> {
+
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final DeleteRef.Factory deleteRefFactory;
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  DeleteBranch(
+      Provider<InternalChangeQuery> queryProvider,
+      DeleteRef.Factory deleteRefFactory,
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend) {
+    this.queryProvider = queryProvider;
+    this.deleteRefFactory = deleteRefFactory;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public Response<?> apply(BranchResource rsrc, Input input)
+      throws RestApiException, OrmException, IOException, PermissionBackendException {
+    permissionBackend.user(user).ref(rsrc.getBranchKey()).check(RefPermission.DELETE);
+
+    if (!queryProvider.get().setLimit(1).byBranchOpen(rsrc.getBranchKey()).isEmpty()) {
+      throw new ResourceConflictException("branch " + rsrc.getBranchKey() + " has open changes");
+    }
+
+    deleteRefFactory.create(rsrc).ref(rsrc.getRef()).prefix(R_HEADS).delete();
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteBranches.java b/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
new file mode 100644
index 0000000..d8166e1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+
+import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class DeleteBranches implements RestModifyView<ProjectResource, DeleteBranchesInput> {
+  private final DeleteRef.Factory deleteRefFactory;
+
+  @Inject
+  DeleteBranches(DeleteRef.Factory deleteRefFactory) {
+    this.deleteRefFactory = deleteRefFactory;
+  }
+
+  @Override
+  public Response<?> apply(ProjectResource project, DeleteBranchesInput input)
+      throws OrmException, IOException, RestApiException, PermissionBackendException {
+    if (input == null || input.branches == null || input.branches.isEmpty()) {
+      throw new BadRequestException("branches must be specified");
+    }
+    deleteRefFactory.create(project).refs(input.branches).prefix(R_HEADS).delete();
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java b/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
new file mode 100644
index 0000000..0aa5752
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.common.SetDashboardInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.DashboardResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+class DeleteDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
+  private final Provider<SetDefaultDashboard> defaultSetter;
+
+  @Inject
+  DeleteDashboard(Provider<SetDefaultDashboard> defaultSetter) {
+    this.defaultSetter = defaultSetter;
+  }
+
+  @Override
+  public Response<DashboardInfo> apply(DashboardResource resource, SetDashboardInput input)
+      throws RestApiException, IOException, PermissionBackendException {
+    if (resource.isProjectDefault()) {
+      SetDashboardInput in = new SetDashboardInput();
+      in.commitMessage = input != null ? input.commitMessage : null;
+      return defaultSetter.get().apply(resource, in);
+    }
+
+    // TODO: Implement delete of dashboards by API.
+    throw new MethodNotAllowedException();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
new file mode 100644
index 0000000..b1b575b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -0,0 +1,281 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static java.lang.String.format;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.R_REFS;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+import static org.eclipse.jgit.transport.ReceiveCommand.Type.DELETE;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.RefValidationHelper;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.errors.LockFailedException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.ReceiveCommand.Result;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DeleteRef {
+  private static final Logger log = LoggerFactory.getLogger(DeleteRef.class);
+
+  private static final int MAX_LOCK_FAILURE_CALLS = 10;
+  private static final long SLEEP_ON_LOCK_FAILURE_MS = 15;
+
+  private final Provider<IdentifiedUser> identifiedUser;
+  private final PermissionBackend permissionBackend;
+  private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated referenceUpdated;
+  private final RefValidationHelper refDeletionValidator;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ProjectResource resource;
+  private final List<String> refsToDelete;
+  private String prefix;
+
+  public interface Factory {
+    DeleteRef create(ProjectResource r);
+  }
+
+  @Inject
+  DeleteRef(
+      Provider<IdentifiedUser> identifiedUser,
+      PermissionBackend permissionBackend,
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated referenceUpdated,
+      RefValidationHelper.Factory refDeletionValidatorFactory,
+      Provider<InternalChangeQuery> queryProvider,
+      @Assisted ProjectResource resource) {
+    this.identifiedUser = identifiedUser;
+    this.permissionBackend = permissionBackend;
+    this.repoManager = repoManager;
+    this.referenceUpdated = referenceUpdated;
+    this.refDeletionValidator = refDeletionValidatorFactory.create(DELETE);
+    this.queryProvider = queryProvider;
+    this.resource = resource;
+    this.refsToDelete = new ArrayList<>();
+  }
+
+  public DeleteRef ref(String ref) {
+    this.refsToDelete.add(ref);
+    return this;
+  }
+
+  public DeleteRef refs(List<String> refs) {
+    this.refsToDelete.addAll(refs);
+    return this;
+  }
+
+  public DeleteRef prefix(String prefix) {
+    this.prefix = prefix;
+    return this;
+  }
+
+  public void delete()
+      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
+    if (!refsToDelete.isEmpty()) {
+      try (Repository r = repoManager.openRepository(resource.getNameKey())) {
+        if (refsToDelete.size() == 1) {
+          deleteSingleRef(r);
+        } else {
+          deleteMultipleRefs(r);
+        }
+      }
+    }
+  }
+
+  private void deleteSingleRef(Repository r) throws IOException, ResourceConflictException {
+    String ref = refsToDelete.get(0);
+    if (prefix != null && !ref.startsWith(R_REFS)) {
+      ref = prefix + ref;
+    }
+    RefUpdate.Result result;
+    RefUpdate u = r.updateRef(ref);
+    u.setExpectedOldObjectId(r.exactRef(ref).getObjectId());
+    u.setNewObjectId(ObjectId.zeroId());
+    u.setForceUpdate(true);
+    refDeletionValidator.validateRefOperation(resource.getName(), identifiedUser.get(), u);
+    int remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
+    for (; ; ) {
+      try {
+        result = u.delete();
+      } catch (LockFailedException e) {
+        result = RefUpdate.Result.LOCK_FAILURE;
+      } catch (IOException e) {
+        log.error("Cannot delete " + ref, e);
+        throw e;
+      }
+      if (result == RefUpdate.Result.LOCK_FAILURE && --remainingLockFailureCalls > 0) {
+        try {
+          Thread.sleep(SLEEP_ON_LOCK_FAILURE_MS);
+        } catch (InterruptedException ie) {
+          // ignore
+        }
+      } else {
+        break;
+      }
+    }
+
+    switch (result) {
+      case NEW:
+      case NO_CHANGE:
+      case FAST_FORWARD:
+      case FORCED:
+        referenceUpdated.fire(
+            resource.getNameKey(),
+            u,
+            ReceiveCommand.Type.DELETE,
+            identifiedUser.get().getAccount());
+        break;
+
+      case REJECTED_CURRENT_BRANCH:
+        log.error("Cannot delete " + ref + ": " + result.name());
+        throw new ResourceConflictException("cannot delete current branch");
+
+      case IO_FAILURE:
+      case LOCK_FAILURE:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case RENAMED:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+      default:
+        log.error("Cannot delete " + ref + ": " + result.name());
+        throw new ResourceConflictException("cannot delete: " + result.name());
+    }
+  }
+
+  private void deleteMultipleRefs(Repository r)
+      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
+    BatchRefUpdate batchUpdate = r.getRefDatabase().newBatchUpdate();
+    batchUpdate.setAtomic(false);
+    List<String> refs =
+        prefix == null
+            ? refsToDelete
+            : refsToDelete
+                .stream()
+                .map(ref -> ref.startsWith(R_REFS) ? ref : prefix + ref)
+                .collect(toList());
+    for (String ref : refs) {
+      batchUpdate.addCommand(createDeleteCommand(resource, r, ref));
+    }
+    try (RevWalk rw = new RevWalk(r)) {
+      batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
+    }
+    StringBuilder errorMessages = new StringBuilder();
+    for (ReceiveCommand command : batchUpdate.getCommands()) {
+      if (command.getResult() == Result.OK) {
+        postDeletion(resource, command);
+      } else {
+        appendAndLogErrorMessage(errorMessages, command);
+      }
+    }
+    if (errorMessages.length() > 0) {
+      throw new ResourceConflictException(errorMessages.toString());
+    }
+  }
+
+  private ReceiveCommand createDeleteCommand(ProjectResource project, Repository r, String refName)
+      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
+    Ref ref = r.getRefDatabase().getRef(refName);
+    ReceiveCommand command;
+    if (ref == null) {
+      command = new ReceiveCommand(ObjectId.zeroId(), ObjectId.zeroId(), refName);
+      command.setResult(
+          Result.REJECTED_OTHER_REASON,
+          "it doesn't exist or you do not have permission to delete it");
+      return command;
+    }
+    command = new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName());
+
+    try {
+      permissionBackend
+          .user(identifiedUser)
+          .project(project.getNameKey())
+          .ref(refName)
+          .check(RefPermission.DELETE);
+    } catch (AuthException denied) {
+      command.setResult(
+          Result.REJECTED_OTHER_REASON,
+          "it doesn't exist or you do not have permission to delete it");
+    }
+
+    if (!refName.startsWith(R_TAGS)) {
+      Branch.NameKey branchKey = new Branch.NameKey(project.getNameKey(), ref.getName());
+      if (!queryProvider.get().setLimit(1).byBranchOpen(branchKey).isEmpty()) {
+        command.setResult(Result.REJECTED_OTHER_REASON, "it has open changes");
+      }
+    }
+
+    RefUpdate u = r.updateRef(refName);
+    u.setForceUpdate(true);
+    u.setExpectedOldObjectId(r.exactRef(refName).getObjectId());
+    u.setNewObjectId(ObjectId.zeroId());
+    refDeletionValidator.validateRefOperation(project.getName(), identifiedUser.get(), u);
+    return command;
+  }
+
+  private void appendAndLogErrorMessage(StringBuilder errorMessages, ReceiveCommand cmd) {
+    String msg = null;
+    switch (cmd.getResult()) {
+      case REJECTED_CURRENT_BRANCH:
+        msg = format("Cannot delete %s: it is the current branch", cmd.getRefName());
+        break;
+      case REJECTED_OTHER_REASON:
+        msg = format("Cannot delete %s: %s", cmd.getRefName(), cmd.getMessage());
+        break;
+      case LOCK_FAILURE:
+      case NOT_ATTEMPTED:
+      case OK:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_NOCREATE:
+      case REJECTED_NODELETE:
+      case REJECTED_NONFASTFORWARD:
+      default:
+        msg = format("Cannot delete %s: %s", cmd.getRefName(), cmd.getResult());
+        break;
+    }
+    log.error(msg);
+    errorMessages.append(msg);
+    errorMessages.append("\n");
+  }
+
+  private void postDeletion(ProjectResource project, ReceiveCommand cmd) {
+    referenceUpdated.fire(project.getNameKey(), cmd, identifiedUser.get().getAccount());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTag.java b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
new file mode 100644
index 0000000..cce7103
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.RefUtil;
+import com.google.gerrit.server.project.TagResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class DeleteTag implements RestModifyView<TagResource, Input> {
+
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final DeleteRef.Factory deleteRefFactory;
+
+  @Inject
+  DeleteTag(
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      DeleteRef.Factory deleteRefFactory) {
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.deleteRefFactory = deleteRefFactory;
+  }
+
+  @Override
+  public Response<?> apply(TagResource resource, Input input)
+      throws OrmException, RestApiException, IOException, PermissionBackendException {
+    String tag = RefUtil.normalizeTagRef(resource.getTagInfo().ref);
+    permissionBackend
+        .user(user)
+        .project(resource.getNameKey())
+        .ref(tag)
+        .check(RefPermission.DELETE);
+    deleteRefFactory.create(resource).ref(tag).delete();
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTags.java b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
new file mode 100644
index 0000000..83fa1ea
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+
+import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class DeleteTags implements RestModifyView<ProjectResource, DeleteTagsInput> {
+  private final DeleteRef.Factory deleteRefFactory;
+
+  @Inject
+  DeleteTags(DeleteRef.Factory deleteRefFactory) {
+    this.deleteRefFactory = deleteRefFactory;
+  }
+
+  @Override
+  public Response<?> apply(ProjectResource project, DeleteTagsInput input)
+      throws OrmException, RestApiException, IOException, PermissionBackendException {
+    if (input == null || input.tags == null || input.tags.isEmpty()) {
+      throw new BadRequestException("tags must be specified");
+    }
+    deleteRefFactory.create(project).refs(input.tags).prefix(R_TAGS).delete();
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/FilesCollection.java b/java/com/google/gerrit/server/restapi/project/FilesCollection.java
new file mode 100644
index 0000000..888ecf2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/FilesCollection.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.gerrit.server.project.FileResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+
+@Singleton
+public class FilesCollection implements ChildCollection<BranchResource, FileResource> {
+  private final DynamicMap<RestView<FileResource>> views;
+  private final GitRepositoryManager repoManager;
+
+  @Inject
+  FilesCollection(DynamicMap<RestView<FileResource>> views, GitRepositoryManager repoManager) {
+    this.views = views;
+    this.repoManager = repoManager;
+  }
+
+  @Override
+  public RestView<BranchResource> list() throws ResourceNotFoundException {
+    throw new ResourceNotFoundException();
+  }
+
+  @Override
+  public FileResource parse(BranchResource parent, IdString id)
+      throws ResourceNotFoundException, IOException {
+    return FileResource.create(
+        repoManager, parent.getProjectState(), ObjectId.fromString(parent.getRevision()), id.get());
+  }
+
+  @Override
+  public DynamicMap<RestView<FileResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
new file mode 100644
index 0000000..53411b8
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.gerrit.server.project.FileResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class FilesInCommitCollection implements ChildCollection<CommitResource, FileResource> {
+  private final DynamicMap<RestView<FileResource>> views;
+  private final GitRepositoryManager repoManager;
+
+  @Inject
+  FilesInCommitCollection(
+      DynamicMap<RestView<FileResource>> views, GitRepositoryManager repoManager) {
+    this.views = views;
+    this.repoManager = repoManager;
+  }
+
+  @Override
+  public RestView<CommitResource> list() throws ResourceNotFoundException {
+    throw new ResourceNotFoundException();
+  }
+
+  @Override
+  public FileResource parse(CommitResource parent, IdString id)
+      throws ResourceNotFoundException, IOException {
+    if (Patch.isMagic(id.get())) {
+      return new FileResource(parent.getProjectState(), parent.getCommit(), id.get());
+    }
+    return FileResource.create(repoManager, parent.getProjectState(), parent.getCommit(), id.get());
+  }
+
+  @Override
+  public DynamicMap<RestView<FileResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
new file mode 100644
index 0000000..ea1620b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
@@ -0,0 +1,171 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.common.data.GarbageCollectionResult;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.git.GarbageCollection;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.LocalDiskRepositoryManager;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.GarbageCollect.Input;
+import com.google.gerrit.server.util.IdGenerator;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.util.Collections;
+
+@RequiresCapability(GlobalCapability.RUN_GC)
+@Singleton
+public class GarbageCollect
+    implements RestModifyView<ProjectResource, Input>, UiAction<ProjectResource> {
+  public static class Input {
+    public boolean showProgress;
+    public boolean aggressive;
+    public boolean async;
+  }
+
+  private final boolean canGC;
+  private final GarbageCollection.Factory garbageCollectionFactory;
+  private final WorkQueue workQueue;
+  private final Provider<String> canonicalUrl;
+
+  @Inject
+  GarbageCollect(
+      GitRepositoryManager repoManager,
+      GarbageCollection.Factory garbageCollectionFactory,
+      WorkQueue workQueue,
+      @CanonicalWebUrl Provider<String> canonicalUrl) {
+    this.workQueue = workQueue;
+    this.canonicalUrl = canonicalUrl;
+    this.canGC = repoManager instanceof LocalDiskRepositoryManager;
+    this.garbageCollectionFactory = garbageCollectionFactory;
+  }
+
+  @Override
+  public Object apply(ProjectResource rsrc, Input input) {
+    Project.NameKey project = rsrc.getNameKey();
+    if (input.async) {
+      return applyAsync(project, input);
+    }
+    return applySync(project, input);
+  }
+
+  private Response.Accepted applyAsync(Project.NameKey project, Input input) {
+    Runnable job =
+        new Runnable() {
+          @Override
+          public void run() {
+            runGC(project, input, null);
+          }
+
+          @Override
+          public String toString() {
+            return "Run "
+                + (input.aggressive ? "aggressive " : "")
+                + "garbage collection on project "
+                + project.get();
+          }
+        };
+
+    @SuppressWarnings("unchecked")
+    WorkQueue.Task<Void> task = (WorkQueue.Task<Void>) workQueue.getDefaultQueue().submit(job);
+
+    String location =
+        canonicalUrl.get() + "a/config/server/tasks/" + IdGenerator.format(task.getTaskId());
+
+    return Response.accepted(location);
+  }
+
+  @SuppressWarnings("resource")
+  private BinaryResult applySync(Project.NameKey project, Input input) {
+    return new BinaryResult() {
+      @Override
+      public void writeTo(OutputStream out) throws IOException {
+        PrintWriter writer =
+            new PrintWriter(new OutputStreamWriter(out, UTF_8)) {
+              @Override
+              public void println() {
+                write('\n');
+              }
+            };
+        try {
+          PrintWriter progressWriter = input.showProgress ? writer : null;
+          GarbageCollectionResult result = runGC(project, input, progressWriter);
+          String msg = "Garbage collection completed successfully.";
+          if (result.hasErrors()) {
+            for (GarbageCollectionResult.Error e : result.getErrors()) {
+              switch (e.getType()) {
+                case REPOSITORY_NOT_FOUND:
+                  msg = "Error: project \"" + e.getProjectName() + "\" not found.";
+                  break;
+                case GC_ALREADY_SCHEDULED:
+                  msg =
+                      "Error: garbage collection for project \""
+                          + e.getProjectName()
+                          + "\" was already scheduled.";
+                  break;
+                case GC_FAILED:
+                  msg =
+                      "Error: garbage collection for project \""
+                          + e.getProjectName()
+                          + "\" failed.";
+                  break;
+                default:
+                  msg =
+                      "Error: garbage collection for project \""
+                          + e.getProjectName()
+                          + "\" failed: "
+                          + e.getType()
+                          + ".";
+              }
+            }
+          }
+          writer.println(msg);
+        } finally {
+          writer.flush();
+        }
+      }
+    }.setContentType("text/plain").setCharacterEncoding(UTF_8).disableGzip();
+  }
+
+  GarbageCollectionResult runGC(Project.NameKey project, Input input, PrintWriter progressWriter) {
+    return garbageCollectionFactory
+        .create()
+        .run(Collections.singletonList(project), input.aggressive, progressWriter);
+  }
+
+  @Override
+  public UiAction.Description getDescription(ProjectResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Run GC")
+        .setTitle("Triggers the Git Garbage Collection for this project.")
+        .setVisible(canGC);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
new file mode 100644
index 0000000..1568a4c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -0,0 +1,338 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
+import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_REF;
+import static com.google.gerrit.server.permissions.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;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.RefConfigSection;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.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.ProjectCache;
+import com.google.gerrit.server.project.ProjectJson;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.group.GroupJson;
+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.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class GetAccess implements RestReadView<ProjectResource> {
+  private static final Logger LOG = LoggerFactory.getLogger(GetAccess.class);
+
+  /** Marker value used in {@code Map<?, GroupInfo>} for groups not visible to current user. */
+  private static final GroupInfo INVISIBLE_SENTINEL = new GroupInfo();
+
+  public static final ImmutableBiMap<PermissionRule.Action, PermissionRuleInfo.Action> ACTION_TYPE =
+      ImmutableBiMap.of(
+          PermissionRule.Action.ALLOW,
+          PermissionRuleInfo.Action.ALLOW,
+          PermissionRule.Action.BATCH,
+          PermissionRuleInfo.Action.BATCH,
+          PermissionRule.Action.BLOCK,
+          PermissionRuleInfo.Action.BLOCK,
+          PermissionRule.Action.DENY,
+          PermissionRuleInfo.Action.DENY,
+          PermissionRule.Action.INTERACTIVE,
+          PermissionRuleInfo.Action.INTERACTIVE);
+
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final GroupControl.Factory groupControlFactory;
+  private final AllProjectsName allProjectsName;
+  private final ProjectJson projectJson;
+  private final ProjectCache projectCache;
+  private final MetaDataUpdate.Server metaDataUpdateFactory;
+  private final GroupBackend groupBackend;
+  private final GroupJson groupJson;
+
+  @Inject
+  public GetAccess(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      GroupControl.Factory groupControlFactory,
+      AllProjectsName allProjectsName,
+      ProjectCache projectCache,
+      MetaDataUpdate.Server metaDataUpdateFactory,
+      ProjectJson projectJson,
+      GroupBackend groupBackend,
+      GroupJson groupJson) {
+    this.user = self;
+    this.permissionBackend = permissionBackend;
+    this.groupControlFactory = groupControlFactory;
+    this.allProjectsName = allProjectsName;
+    this.projectJson = projectJson;
+    this.projectCache = projectCache;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.groupBackend = groupBackend;
+    this.groupJson = groupJson;
+  }
+
+  public ProjectAccessInfo apply(Project.NameKey nameKey)
+      throws ResourceNotFoundException, ResourceConflictException, IOException,
+          PermissionBackendException, OrmException {
+    ProjectState state = projectCache.checkedGet(nameKey);
+    if (state == null) {
+      throw new ResourceNotFoundException(nameKey.get());
+    }
+    return apply(new ProjectResource(state, user.get()));
+  }
+
+  @Override
+  public ProjectAccessInfo apply(ProjectResource rsrc)
+      throws ResourceNotFoundException, ResourceConflictException, IOException,
+          PermissionBackendException, OrmException {
+    // Load the current configuration from the repository, ensuring it's the most
+    // recent version available. If it differs from what was in the project
+    // state, force a cache flush now.
+
+    Project.NameKey projectName = rsrc.getNameKey();
+    ProjectAccessInfo info = new ProjectAccessInfo();
+    ProjectState projectState = projectCache.checkedGet(projectName);
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(projectName);
+
+    ProjectConfig config;
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
+      config = ProjectConfig.read(md);
+
+      if (config.updateGroupNames(groupBackend)) {
+        md.setMessage("Update group names\n");
+        config.commit(md);
+        projectCache.evict(config.getProject());
+        projectState = projectCache.checkedGet(projectName);
+        perm = permissionBackend.user(user).project(projectName);
+      } else if (config.getRevision() != null
+          && !config.getRevision().equals(projectState.getConfig().getRevision())) {
+        projectCache.evict(config.getProject());
+        projectState = projectCache.checkedGet(projectName);
+        perm = permissionBackend.user(user).project(projectName);
+      }
+    } catch (ConfigInvalidException e) {
+      throw new ResourceConflictException(e.getMessage());
+    } catch (RepositoryNotFoundException e) {
+      throw new ResourceNotFoundException(rsrc.getName());
+    }
+
+    // The following implementation must match the ProjectAccessFactory JSON RPC endpoint.
+
+    info.local = new HashMap<>();
+    info.ownerOf = new HashSet<>();
+    Map<AccountGroup.UUID, GroupInfo> visibleGroups = new HashMap<>();
+    boolean canReadConfig = check(perm, RefNames.REFS_CONFIG, READ);
+    boolean canWriteConfig = check(perm, ProjectPermission.WRITE_CONFIG);
+
+    for (AccessSection section : config.getAccessSections()) {
+      String name = section.getName();
+      if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
+        if (canWriteConfig) {
+          info.local.put(name, createAccessSection(visibleGroups, section));
+          info.ownerOf.add(name);
+
+        } else if (canReadConfig) {
+          info.local.put(section.getName(), createAccessSection(visibleGroups, section));
+        }
+
+      } else if (RefConfigSection.isValid(name)) {
+        if (check(perm, name, WRITE_CONFIG)) {
+          info.local.put(name, createAccessSection(visibleGroups, section));
+          info.ownerOf.add(name);
+
+        } else if (canReadConfig) {
+          info.local.put(name, createAccessSection(visibleGroups, section));
+
+        } else if (check(perm, name, READ)) {
+          // Filter the section to only add rules describing groups that
+          // are visible to the current-user. This includes any group the
+          // user is a member of, as well as groups they own or that
+          // are visible to all users.
+
+          AccessSection dst = null;
+          for (Permission srcPerm : section.getPermissions()) {
+            Permission dstPerm = null;
+
+            for (PermissionRule srcRule : srcPerm.getRules()) {
+              AccountGroup.UUID groupId = srcRule.getGroup().getUUID();
+              if (groupId == null) {
+                continue;
+              }
+
+              GroupInfo group = loadGroup(visibleGroups, groupId);
+
+              if (group != INVISIBLE_SENTINEL) {
+                if (dstPerm == null) {
+                  if (dst == null) {
+                    dst = new AccessSection(name);
+                    info.local.put(name, createAccessSection(visibleGroups, dst));
+                  }
+                  dstPerm = dst.getPermission(srcPerm.getName(), true);
+                }
+                dstPerm.add(srcRule);
+              }
+            }
+          }
+        }
+      }
+    }
+
+    if (info.ownerOf.isEmpty()
+        && permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER)) {
+      // Special case: If the section list is empty, this project has no current
+      // access control information. Fall back to site administrators.
+      info.ownerOf.add(AccessSection.ALL);
+    }
+
+    if (config.getRevision() != null) {
+      info.revision = config.getRevision().name();
+    }
+
+    ProjectState parent = Iterables.getFirst(projectState.parents(), null);
+    if (parent != null) {
+      info.inheritsFrom = projectJson.format(parent.getProject());
+    }
+
+    if (projectName.equals(allProjectsName)
+        && permissionBackend.user(user).testOrFalse(ADMINISTRATE_SERVER)) {
+      info.ownerOf.add(AccessSection.GLOBAL_CAPABILITIES);
+    }
+
+    info.isOwner = toBoolean(canWriteConfig);
+    info.canUpload =
+        toBoolean(
+            canWriteConfig
+                || (canReadConfig && perm.ref(RefNames.REFS_CONFIG).testOrFalse(CREATE_CHANGE)));
+    info.canAdd = toBoolean(perm.testOrFalse(CREATE_REF));
+    info.configVisible = canReadConfig || canWriteConfig;
+
+    info.groups =
+        visibleGroups
+            .entrySet()
+            .stream()
+            .filter(e -> e.getValue() != INVISIBLE_SENTINEL)
+            .collect(toMap(e -> e.getKey().get(), e -> e.getValue()));
+
+    return info;
+  }
+
+  private GroupInfo loadGroup(Map<AccountGroup.UUID, GroupInfo> visibleGroups, AccountGroup.UUID id)
+      throws OrmException {
+    GroupInfo group = visibleGroups.get(id);
+    if (group == null) {
+      try {
+        GroupControl control = groupControlFactory.controlFor(id);
+        group = INVISIBLE_SENTINEL;
+        if (control.isVisible()) {
+          group = groupJson.format(control.getGroup());
+          group.id = null;
+        }
+      } catch (NoSuchGroupException e) {
+        LOG.warn("NoSuchGroupException; ignoring group " + id, e);
+        group = INVISIBLE_SENTINEL;
+      }
+      visibleGroups.put(id, group);
+    }
+
+    return group;
+  }
+
+  private static boolean check(PermissionBackend.ForProject ctx, String ref, RefPermission perm)
+      throws PermissionBackendException {
+    try {
+      ctx.ref(ref).check(perm);
+      return true;
+    } catch (AuthException denied) {
+      return false;
+    }
+  }
+
+  private 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();
+    accessSectionInfo.permissions = new HashMap<>();
+    for (Permission p : section.getPermissions()) {
+      PermissionInfo pInfo = new PermissionInfo(p.getLabel(), p.getExclusiveGroup() ? true : null);
+      pInfo.rules = new HashMap<>();
+      for (PermissionRule r : p.getRules()) {
+        PermissionRuleInfo info =
+            new PermissionRuleInfo(ACTION_TYPE.get(r.getAction()), r.getForce());
+        if (r.hasRange()) {
+          info.max = r.getMax();
+          info.min = r.getMin();
+        }
+        AccountGroup.UUID group = r.getGroup().getUUID();
+        if (group != null) {
+          pInfo.rules.put(group.get(), info);
+          loadGroup(groups, group);
+        }
+      }
+      accessSectionInfo.permissions.put(p.getName(), pInfo);
+    }
+    return accessSectionInfo;
+  }
+
+  private static Boolean toBoolean(boolean value) {
+    return value ? true : null;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetBranch.java b/java/com/google/gerrit/server/restapi/project/GetBranch.java
new file mode 100644
index 0000000..7d32f3d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetBranch.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class GetBranch implements RestReadView<BranchResource> {
+  private final Provider<ListBranches> list;
+
+  @Inject
+  GetBranch(Provider<ListBranches> list) {
+    this.list = list;
+  }
+
+  @Override
+  public BranchInfo apply(BranchResource rsrc)
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
+    return list.get().toBranchInfo(rsrc);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetChildProject.java b/java/com/google/gerrit/server/restapi/project/GetChildProject.java
new file mode 100644
index 0000000..e69907e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetChildProject.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.project.ChildProjectResource;
+import com.google.gerrit.server.project.ProjectJson;
+import com.google.inject.Inject;
+import org.kohsuke.args4j.Option;
+
+public class GetChildProject implements RestReadView<ChildProjectResource> {
+  @Option(name = "--recursive", usage = "to list child projects recursively")
+  public void setRecursive(boolean recursive) {
+    this.recursive = recursive;
+  }
+
+  private final ProjectJson json;
+  private boolean recursive;
+
+  @Inject
+  GetChildProject(ProjectJson json) {
+    this.json = json;
+  }
+
+  @Override
+  public ProjectInfo apply(ChildProjectResource rsrc) throws ResourceNotFoundException {
+    if (recursive || rsrc.isDirectChild()) {
+      return json.format(rsrc.getChild().getProject());
+    }
+    throw new ResourceNotFoundException(rsrc.getChild().getName());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetCommit.java b/java/com/google/gerrit/server/restapi/project/GetCommit.java
new file mode 100644
index 0000000..1c1ae90
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetCommit.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class GetCommit implements RestReadView<CommitResource> {
+
+  @Override
+  public CommitInfo apply(CommitResource rsrc) throws IOException {
+    return CommitUtil.toCommitInfo(rsrc.getCommit());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetConfig.java b/java/com/google/gerrit/server/restapi/project/GetConfig.java
new file mode 100644
index 0000000..aafff9e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetConfig.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.EnableSignedPush;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.extensions.webui.UiActions;
+import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetConfig implements RestReadView<ProjectResource> {
+  private final boolean serverEnableSignedPush;
+  private final TransferConfig config;
+  private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+  private final PluginConfigFactory cfgFactory;
+  private final AllProjectsName allProjects;
+  private final UiActions uiActions;
+  private final DynamicMap<RestView<ProjectResource>> views;
+
+  @Inject
+  public GetConfig(
+      @EnableSignedPush boolean serverEnableSignedPush,
+      TransferConfig config,
+      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      PluginConfigFactory cfgFactory,
+      AllProjectsName allProjects,
+      UiActions uiActions,
+      DynamicMap<RestView<ProjectResource>> views) {
+    this.serverEnableSignedPush = serverEnableSignedPush;
+    this.config = config;
+    this.pluginConfigEntries = pluginConfigEntries;
+    this.allProjects = allProjects;
+    this.cfgFactory = cfgFactory;
+    this.uiActions = uiActions;
+    this.views = views;
+  }
+
+  @Override
+  public ConfigInfo apply(ProjectResource resource) {
+    return new ConfigInfoImpl(
+        serverEnableSignedPush,
+        resource.getProjectState(),
+        resource.getUser(),
+        config,
+        pluginConfigEntries,
+        cfgFactory,
+        allProjects,
+        uiActions,
+        views);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetContent.java b/java/com/google/gerrit/server/restapi/project/GetContent.java
new file mode 100644
index 0000000..132b644
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetContent.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.project.FileResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class GetContent implements RestReadView<FileResource> {
+  private final FileContentUtil fileContentUtil;
+
+  @Inject
+  GetContent(FileContentUtil fileContentUtil) {
+    this.fileContentUtil = fileContentUtil;
+  }
+
+  @Override
+  public BinaryResult apply(FileResource rsrc)
+      throws ResourceNotFoundException, BadRequestException, IOException {
+    return fileContentUtil.getContent(rsrc.getProjectState(), rsrc.getRev(), rsrc.getPath(), null);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetDashboard.java b/java/com/google/gerrit/server/restapi/project/GetDashboard.java
new file mode 100644
index 0000000..dde77e5
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetDashboard.java
@@ -0,0 +1,118 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
+import static com.google.gerrit.server.restapi.project.DashboardsCollection.isDefaultDashboard;
+
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.DashboardResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.kohsuke.args4j.Option;
+
+public class GetDashboard implements RestReadView<DashboardResource> {
+  private final DashboardsCollection dashboards;
+
+  @Option(name = "--inherited", usage = "include inherited dashboards")
+  private boolean inherited;
+
+  @Inject
+  GetDashboard(DashboardsCollection dashboards) {
+    this.dashboards = dashboards;
+  }
+
+  public GetDashboard setInherited(boolean inherited) {
+    this.inherited = inherited;
+    return this;
+  }
+
+  @Override
+  public DashboardInfo apply(DashboardResource rsrc)
+      throws RestApiException, IOException, PermissionBackendException {
+    if (inherited && !rsrc.isProjectDefault()) {
+      throw new BadRequestException("inherited flag can only be used with default");
+    }
+
+    if (rsrc.isProjectDefault()) {
+      // The default is not resolved to a definition yet.
+      try {
+        rsrc = defaultOf(rsrc.getProjectState(), rsrc.getUser());
+      } catch (ConfigInvalidException e) {
+        throw new ResourceConflictException(e.getMessage());
+      }
+    }
+
+    return DashboardsCollection.parse(
+        rsrc.getProjectState().getProject(),
+        rsrc.getRefName().substring(REFS_DASHBOARDS.length()),
+        rsrc.getPathName(),
+        rsrc.getConfig(),
+        rsrc.getProjectState().getName(),
+        true);
+  }
+
+  private DashboardResource defaultOf(ProjectState projectState, CurrentUser user)
+      throws ResourceNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    String id = projectState.getProject().getLocalDefaultDashboard();
+    if (Strings.isNullOrEmpty(id)) {
+      id = projectState.getProject().getDefaultDashboard();
+    }
+    if (isDefaultDashboard(id)) {
+      throw new ResourceNotFoundException();
+    } else if (!Strings.isNullOrEmpty(id)) {
+      return parse(projectState, user, id);
+    } else if (!inherited) {
+      throw new ResourceNotFoundException();
+    }
+
+    for (ProjectState ps : projectState.tree()) {
+      id = ps.getProject().getDefaultDashboard();
+      if (isDefaultDashboard(id)) {
+        throw new ResourceNotFoundException();
+      } else if (!Strings.isNullOrEmpty(id)) {
+        return parse(projectState, user, id);
+      }
+    }
+    throw new ResourceNotFoundException();
+  }
+
+  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(projectState, user), IdString.fromUrl(ref + ':' + path));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetDescription.java b/java/com/google/gerrit/server/restapi/project/GetDescription.java
new file mode 100644
index 0000000..d387ff1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetDescription.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetDescription implements RestReadView<ProjectResource> {
+  @Override
+  public String apply(ProjectResource rsrc) {
+    return Strings.nullToEmpty(rsrc.getProjectState().getProject().getDescription());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetHead.java b/java/com/google/gerrit/server/restapi/project/GetHead.java
new file mode 100644
index 0000000..a6533ff
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetHead.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.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.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class GetHead implements RestReadView<ProjectResource> {
+  private final GitRepositoryManager repoManager;
+  private final CommitsCollection commits;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  GetHead(
+      GitRepositoryManager repoManager,
+      CommitsCollection commits,
+      PermissionBackend permissionBackend) {
+    this.repoManager = repoManager;
+    this.commits = commits;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public String apply(ProjectResource rsrc)
+      throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
+    try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
+      Ref head = repo.getRefDatabase().exactRef(Constants.HEAD);
+      if (head == null) {
+        throw new ResourceNotFoundException(Constants.HEAD);
+      } else if (head.isSymbolic()) {
+        String n = head.getTarget().getName();
+        permissionBackend
+            .user(rsrc.getUser())
+            .project(rsrc.getNameKey())
+            .ref(n)
+            .check(RefPermission.READ);
+        return n;
+      } else if (head.getObjectId() != null) {
+        try (RevWalk rw = new RevWalk(repo)) {
+          RevCommit commit = rw.parseCommit(head.getObjectId());
+          if (commits.canRead(rsrc.getProjectState(), repo, commit)) {
+            return head.getObjectId().name();
+          }
+          throw new AuthException("not allowed to see HEAD");
+        } catch (MissingObjectException | IncorrectObjectTypeException e) {
+          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 ResourceNotFoundException(Constants.HEAD);
+    } catch (RepositoryNotFoundException e) {
+      throw new ResourceNotFoundException(rsrc.getName());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetParent.java b/java/com/google/gerrit/server/restapi/project/GetParent.java
new file mode 100644
index 0000000..a4942e3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetParent.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetParent implements RestReadView<ProjectResource> {
+  private final AllProjectsName allProjectsName;
+
+  @Inject
+  GetParent(AllProjectsName allProjectsName) {
+    this.allProjectsName = allProjectsName;
+  }
+
+  @Override
+  public String apply(ProjectResource resource) {
+    Project project = resource.getProjectState().getProject();
+    Project.NameKey parentName = project.getParent(allProjectsName);
+    return parentName != null ? parentName.get() : "";
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetProject.java b/java/com/google/gerrit/server/restapi/project/GetProject.java
new file mode 100644
index 0000000..a1b2fb1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetProject.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.project.ProjectJson;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+class GetProject implements RestReadView<ProjectResource> {
+
+  private final ProjectJson json;
+
+  @Inject
+  GetProject(ProjectJson json) {
+    this.json = json;
+  }
+
+  @Override
+  public ProjectInfo apply(ProjectResource rsrc) {
+    return json.format(rsrc.getProjectState());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetReflog.java b/java/com/google/gerrit/server/restapi/project/GetReflog.java
new file mode 100644
index 0000000..0339e15
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetReflog.java
@@ -0,0 +1,141 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
+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.RestReadView;
+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.gerrit.server.project.BranchResource;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.ReflogReader;
+import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class GetReflog implements RestReadView<BranchResource> {
+  private static final Logger log = LoggerFactory.getLogger(GetReflog.class);
+
+  private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
+
+  @Option(
+    name = "--limit",
+    aliases = {"-n"},
+    metaVar = "CNT",
+    usage = "maximum number of reflog entries to list"
+  )
+  public GetReflog setLimit(int limit) {
+    this.limit = limit;
+    return this;
+  }
+
+  @Option(
+    name = "--from",
+    metaVar = "TIMESTAMP",
+    usage =
+        "timestamp from which the reflog entries should be listed (UTC, format: "
+            + TimestampHandler.TIMESTAMP_FORMAT
+            + ")"
+  )
+  public GetReflog setFrom(Timestamp from) {
+    this.from = from;
+    return this;
+  }
+
+  @Option(
+    name = "--to",
+    metaVar = "TIMESTAMP",
+    usage =
+        "timestamp until which the reflog entries should be listed (UTC, format: "
+            + TimestampHandler.TIMESTAMP_FORMAT
+            + ")"
+  )
+  public GetReflog setTo(Timestamp to) {
+    this.to = to;
+    return this;
+  }
+
+  private int limit;
+  private Timestamp from;
+  private Timestamp to;
+
+  @Inject
+  public GetReflog(GitRepositoryManager repoManager, PermissionBackend permissionBackend) {
+    this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  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;
+      try {
+        r = repo.getReflogReader(rsrc.getRef());
+      } catch (UnsupportedOperationException e) {
+        String msg = "reflog not supported on repo " + rsrc.getNameKey().get();
+        log.error(msg);
+        throw new MethodNotAllowedException(msg);
+      }
+      if (r == null) {
+        throw new ResourceNotFoundException(rsrc.getRef());
+      }
+      List<ReflogEntry> entries;
+      if (from == null && to == null) {
+        entries = limit > 0 ? r.getReverseEntries(limit) : r.getReverseEntries();
+      } else {
+        entries = limit > 0 ? new ArrayList<>(limit) : new ArrayList<>();
+        for (ReflogEntry e : r.getReverseEntries()) {
+          Timestamp timestamp = new Timestamp(e.getWho().getWhen().getTime());
+          if ((from == null || from.before(timestamp)) && (to == null || to.after(timestamp))) {
+            entries.add(e);
+          }
+          if (limit > 0 && entries.size() >= limit) {
+            break;
+          }
+        }
+      }
+      return Lists.transform(entries, e -> newReflogEntryInfo(e));
+    }
+  }
+
+  private ReflogEntryInfo newReflogEntryInfo(ReflogEntry e) {
+    return new ReflogEntryInfo(
+        e.getOldId().getName(),
+        e.getNewId().getName(),
+        CommonConverters.toGitPerson(e.getWho()),
+        e.getComment());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetStatistics.java b/java/com/google/gerrit/server/restapi/project/GetStatistics.java
new file mode 100644
index 0000000..048c018
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetStatistics.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.api.GarbageCollectCommand;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.api.errors.JGitInternalException;
+import org.eclipse.jgit.lib.Repository;
+
+@RequiresCapability(GlobalCapability.RUN_GC)
+@Singleton
+public class GetStatistics implements RestReadView<ProjectResource> {
+
+  private final GitRepositoryManager repoManager;
+
+  @Inject
+  GetStatistics(GitRepositoryManager repoManager) {
+    this.repoManager = repoManager;
+  }
+
+  @Override
+  public RepositoryStatistics apply(ProjectResource rsrc)
+      throws ResourceNotFoundException, ResourceConflictException {
+    try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
+      GarbageCollectCommand gc = Git.wrap(repo).gc();
+      return new RepositoryStatistics(gc.getStatistics());
+    } catch (GitAPIException | JGitInternalException e) {
+      throw new ResourceConflictException(e.getMessage());
+    } catch (IOException e) {
+      throw new ResourceNotFoundException(rsrc.getName());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetTag.java b/java/com/google/gerrit/server/restapi/project/GetTag.java
new file mode 100644
index 0000000..6d5a510
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetTag.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.project.TagResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetTag implements RestReadView<TagResource> {
+
+  @Override
+  public TagInfo apply(TagResource resource) {
+    return resource.getTagInfo();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/Index.java b/java/com/google/gerrit/server/restapi/project/Index.java
new file mode 100644
index 0000000..24f32f6
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/Index.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.io.ByteStreams;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.MultiProgressMonitor.Task;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.change.AllChangesIndexer;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.util.io.NullOutputStream;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class Index implements RestModifyView<ProjectResource, ProjectInput> {
+
+  private final Provider<AllChangesIndexer> allChangesIndexerProvider;
+  private final ChangeIndexer indexer;
+  private final ListeningExecutorService executor;
+
+  @Inject
+  Index(
+      Provider<AllChangesIndexer> allChangesIndexerProvider,
+      ChangeIndexer indexer,
+      @IndexExecutor(BATCH) ListeningExecutorService executor) {
+    this.allChangesIndexerProvider = allChangesIndexerProvider;
+    this.indexer = indexer;
+    this.executor = executor;
+  }
+
+  @Override
+  public Response.Accepted apply(ProjectResource resource, ProjectInput input) {
+    Project.NameKey project = resource.getNameKey();
+    Task mpt =
+        new MultiProgressMonitor(ByteStreams.nullOutputStream(), "Reindexing project")
+            .beginSubTask("", MultiProgressMonitor.UNKNOWN);
+    AllChangesIndexer allChangesIndexer = allChangesIndexerProvider.get();
+    allChangesIndexer.setVerboseOut(NullOutputStream.INSTANCE);
+    // The REST call is just a trigger for async reindexing, so it is safe to ignore the future's
+    // return value.
+    @SuppressWarnings("unused")
+    Future<Void> ignored =
+        executor.submit(allChangesIndexer.reindexProject(indexer, project, mpt, mpt));
+    return Response.accepted("Project " + project + " submitted for reindexing");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListBranches.java b/java/com/google/gerrit/server/restapi/project/ListBranches.java
new file mode 100644
index 0000000..5675be1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ListBranches.java
@@ -0,0 +1,265 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Sets;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.WebLinks;
+import com.google.gerrit.server.extensions.webui.UiActions;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.RefFilter;
+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.Comparator;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeMap;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
+
+public class ListBranches implements RestReadView<ProjectResource> {
+  private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final DynamicMap<RestView<BranchResource>> branchViews;
+  private final UiActions uiActions;
+  private final WebLinks webLinks;
+
+  @Option(
+    name = "--limit",
+    aliases = {"-n"},
+    metaVar = "CNT",
+    usage = "maximum number of branches to list"
+  )
+  public void setLimit(int limit) {
+    this.limit = limit;
+  }
+
+  @Option(
+    name = "--start",
+    aliases = {"-S", "-s"},
+    metaVar = "CNT",
+    usage = "number of branches to skip"
+  )
+  public void setStart(int start) {
+    this.start = start;
+  }
+
+  @Option(
+    name = "--match",
+    aliases = {"-m"},
+    metaVar = "MATCH",
+    usage = "match branches substring"
+  )
+  public void setMatchSubstring(String matchSubstring) {
+    this.matchSubstring = matchSubstring;
+  }
+
+  @Option(
+    name = "--regex",
+    aliases = {"-r"},
+    metaVar = "REGEX",
+    usage = "match branches regex"
+  )
+  public void setMatchRegex(String matchRegex) {
+    this.matchRegex = matchRegex;
+  }
+
+  private int limit;
+  private int start;
+  private String matchSubstring;
+  private String matchRegex;
+
+  @Inject
+  public ListBranches(
+      GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      DynamicMap<RestView<BranchResource>> branchViews,
+      UiActions uiActions,
+      WebLinks webLinks) {
+    this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.branchViews = branchViews;
+    this.uiActions = uiActions;
+    this.webLinks = webLinks;
+  }
+
+  public ListBranches request(ListRefsRequest<BranchInfo> request) {
+    this.setLimit(request.getLimit());
+    this.setStart(request.getStart());
+    this.setMatchSubstring(request.getSubstring());
+    this.setMatchRegex(request.getRegex());
+    return this;
+  }
+
+  @Override
+  public List<BranchInfo> apply(ProjectResource rsrc)
+      throws ResourceNotFoundException, IOException, BadRequestException,
+          PermissionBackendException {
+    return new RefFilter<BranchInfo>(Constants.R_HEADS)
+        .subString(matchSubstring)
+        .regex(matchRegex)
+        .start(start)
+        .limit(limit)
+        .filter(allBranches(rsrc));
+  }
+
+  BranchInfo toBranchInfo(BranchResource rsrc)
+      throws IOException, ResourceNotFoundException, PermissionBackendException {
+    try (Repository db = repoManager.openRepository(rsrc.getNameKey())) {
+      Ref r = db.exactRef(rsrc.getRef());
+      if (r == null) {
+        throw new ResourceNotFoundException();
+      }
+      return toBranchInfo(rsrc, ImmutableList.of(r)).get(0);
+    } catch (RepositoryNotFoundException noRepo) {
+      throw new ResourceNotFoundException();
+    }
+  }
+
+  private List<BranchInfo> allBranches(ProjectResource rsrc)
+      throws IOException, ResourceNotFoundException, PermissionBackendException {
+    List<Ref> refs;
+    try (Repository db = repoManager.openRepository(rsrc.getNameKey())) {
+      Collection<Ref> heads = db.getRefDatabase().getRefs(Constants.R_HEADS).values();
+      refs = new ArrayList<>(heads.size() + 3);
+      refs.addAll(heads);
+      refs.addAll(
+          db.getRefDatabase()
+              .exactRef(Constants.HEAD, RefNames.REFS_CONFIG, RefNames.REFS_USERS_DEFAULT)
+              .values());
+    } catch (RepositoryNotFoundException noGitRepository) {
+      throw new ResourceNotFoundException();
+    }
+    return toBranchInfo(rsrc, refs);
+  }
+
+  private List<BranchInfo> toBranchInfo(ProjectResource rsrc, List<Ref> refs)
+      throws PermissionBackendException {
+    Set<String> targets = Sets.newHashSetWithExpectedSize(1);
+    for (Ref ref : refs) {
+      if (ref.isSymbolic()) {
+        targets.add(ref.getTarget().getName());
+      }
+    }
+
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(rsrc.getNameKey());
+    List<BranchInfo> branches = new ArrayList<>(refs.size());
+    for (Ref ref : refs) {
+      if (ref.isSymbolic()) {
+        // A symbolic reference to another branch, instead of
+        // showing the resolved value, show the name it references.
+        //
+        String target = ref.getTarget().getName();
+        if (!perm.ref(target).test(RefPermission.READ)) {
+          continue;
+        }
+        if (target.startsWith(Constants.R_HEADS)) {
+          target = target.substring(Constants.R_HEADS.length());
+        }
+
+        BranchInfo b = new BranchInfo();
+        b.ref = ref.getName();
+        b.revision = target;
+        branches.add(b);
+
+        if (!Constants.HEAD.equals(ref.getName())) {
+          b.canDelete = perm.ref(ref.getName()).testOrFalse(RefPermission.DELETE) ? true : null;
+        }
+        continue;
+      }
+
+      if (perm.ref(ref.getName()).test(RefPermission.READ)) {
+        branches.add(
+            createBranchInfo(
+                perm.ref(ref.getName()), ref, rsrc.getProjectState(), rsrc.getUser(), targets));
+      }
+    }
+    Collections.sort(branches, new BranchComparator());
+    return branches;
+  }
+
+  private static class BranchComparator implements Comparator<BranchInfo> {
+    @Override
+    public int compare(BranchInfo a, BranchInfo b) {
+      return ComparisonChain.start()
+          .compareTrueFirst(isHead(a), isHead(b))
+          .compareTrueFirst(isConfig(a), isConfig(b))
+          .compare(a.ref, b.ref)
+          .result();
+    }
+
+    private static boolean isHead(BranchInfo i) {
+      return Constants.HEAD.equals(i.ref);
+    }
+
+    private static boolean isConfig(BranchInfo i) {
+      return RefNames.REFS_CONFIG.equals(i.ref);
+    }
+  }
+
+  private BranchInfo createBranchInfo(
+      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(projectState, user, ref);
+    for (UiAction.Description d : uiActions.from(branchViews, rsrc)) {
+      if (info.actions == null) {
+        info.actions = new TreeMap<>();
+      }
+      info.actions.put(d.getId(), new ActionInfo(d));
+    }
+
+    List<WebLinkInfo> links = webLinks.getBranchLinks(projectState.getName(), ref.getName());
+    info.webLinks = links.isEmpty() ? null : links;
+    return info;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
new file mode 100644
index 0000000..c514f90
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ChildProjects;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectJson;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.kohsuke.args4j.Option;
+
+public class ListChildProjects implements RestReadView<ProjectResource> {
+
+  @Option(name = "--recursive", usage = "to list child projects recursively")
+  private boolean recursive;
+
+  private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final AllProjectsName allProjects;
+  private final ProjectJson json;
+  private final ChildProjects childProjects;
+
+  @Inject
+  ListChildProjects(
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      AllProjectsName allProjectsName,
+      ProjectJson json,
+      ChildProjects childProjects) {
+    this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.allProjects = allProjectsName;
+    this.json = json;
+    this.childProjects = childProjects;
+  }
+
+  public void setRecursive(boolean recursive) {
+    this.recursive = recursive;
+  }
+
+  @Override
+  public List<ProjectInfo> apply(ProjectResource rsrc) throws PermissionBackendException {
+    if (recursive) {
+      return childProjects.list(rsrc.getNameKey());
+    }
+
+    return directChildProjects(rsrc.getNameKey());
+  }
+
+  private List<ProjectInfo> directChildProjects(Project.NameKey parent)
+      throws PermissionBackendException {
+    Map<Project.NameKey, Project> children = new HashMap<>();
+    for (Project.NameKey name : projectCache.all()) {
+      ProjectState c = projectCache.get(name);
+      if (c != null && parent.equals(c.getProject().getParent(allProjects))) {
+        children.put(c.getNameKey(), c.getProject());
+      }
+    }
+    return permissionBackend
+        .user(user)
+        .filter(ProjectPermission.ACCESS, children.keySet())
+        .stream()
+        .sorted()
+        .map((p) -> json.format(children.get(p)))
+        .collect(toList());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListDashboards.java b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
new file mode 100644
index 0000000..829d409
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
@@ -0,0 +1,156 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
+
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.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.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.BlobBasedConfig;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ListDashboards implements RestReadView<ProjectResource> {
+  private static final Logger log = LoggerFactory.getLogger(ListDashboards.class);
+
+  private final GitRepositoryManager gitManager;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+
+  @Option(name = "--inherited", usage = "include inherited dashboards")
+  private boolean inherited;
+
+  @Inject
+  ListDashboards(
+      GitRepositoryManager gitManager,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user) {
+    this.gitManager = gitManager;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+  }
+
+  @Override
+  public List<?> apply(ProjectResource rsrc)
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
+    String project = rsrc.getName();
+    if (!inherited) {
+      return scan(rsrc.getProjectState(), project, true);
+    }
+
+    List<List<DashboardInfo>> all = new ArrayList<>();
+    boolean setDefault = true;
+    for (ProjectState ps : tree(rsrc)) {
+      List<DashboardInfo> list = scan(ps, project, setDefault);
+      for (DashboardInfo d : list) {
+        if (d.isDefault != null && Boolean.TRUE.equals(d.isDefault)) {
+          setDefault = false;
+        }
+      }
+      if (!list.isEmpty()) {
+        all.add(list);
+      }
+    }
+    return all;
+  }
+
+  private Collection<ProjectState> tree(ProjectResource rsrc) throws PermissionBackendException {
+    Map<Project.NameKey, ProjectState> tree = new LinkedHashMap<>();
+    for (ProjectState ps : rsrc.getProjectState().tree()) {
+      tree.put(ps.getNameKey(), ps);
+    }
+    tree.keySet()
+        .retainAll(permissionBackend.user(user).filter(ProjectPermission.ACCESS, tree.keySet()));
+    return tree.values();
+  }
+
+  private List<DashboardInfo> scan(ProjectState state, String project, boolean setDefault)
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(state.getNameKey());
+    try (Repository git = gitManager.openRepository(state.getNameKey());
+        RevWalk rw = new RevWalk(git)) {
+      List<DashboardInfo> all = new ArrayList<>();
+      for (Ref ref : git.getRefDatabase().getRefs(REFS_DASHBOARDS).values()) {
+        if (perm.ref(ref.getName()).test(RefPermission.READ)) {
+          all.addAll(scanDashboards(state.getProject(), git, rw, ref, project, setDefault));
+        }
+      }
+      return all;
+    } catch (RepositoryNotFoundException e) {
+      throw new ResourceNotFoundException();
+    }
+  }
+
+  private List<DashboardInfo> scanDashboards(
+      Project definingProject,
+      Repository git,
+      RevWalk rw,
+      Ref ref,
+      String project,
+      boolean setDefault)
+      throws IOException {
+    List<DashboardInfo> list = new ArrayList<>();
+    try (TreeWalk tw = new TreeWalk(rw.getObjectReader())) {
+      tw.addTree(rw.parseTree(ref.getObjectId()));
+      tw.setRecursive(true);
+      while (tw.next()) {
+        if (tw.getFileMode(0) == FileMode.REGULAR_FILE) {
+          try {
+            list.add(
+                DashboardsCollection.parse(
+                    definingProject,
+                    ref.getName().substring(REFS_DASHBOARDS.length()),
+                    tw.getPathString(),
+                    new BlobBasedConfig(null, git, tw.getObjectId(0)),
+                    project,
+                    setDefault));
+          } catch (ConfigInvalidException e) {
+            log.warn(
+                String.format(
+                    "Cannot parse dashboard %s:%s:%s: %s",
+                    definingProject.getName(), ref.getName(), tw.getPathString(), e.getMessage()));
+          }
+        }
+      }
+    }
+    return list;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
new file mode 100644
index 0000000..6eb5c88
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -0,0 +1,654 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.gerrit.extensions.client.ProjectState.HIDDEN;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.WebLinks;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.ioutil.StringUtil;
+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.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.group.GroupsCollection;
+import com.google.gerrit.server.util.RegexListSearcher;
+import com.google.gerrit.server.util.TreeFormatter;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
+import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** List projects visible to the calling user. */
+public class ListProjects implements RestReadView<TopLevelResource> {
+  private static final Logger log = LoggerFactory.getLogger(ListProjects.class);
+
+  public enum FilterType {
+    CODE {
+      @Override
+      boolean matches(Repository git) throws IOException {
+        return !PERMISSIONS.matches(git);
+      }
+
+      @Override
+      boolean useMatch() {
+        return true;
+      }
+    },
+    PARENT_CANDIDATES {
+      @Override
+      boolean matches(Repository git) {
+        return true;
+      }
+
+      @Override
+      boolean useMatch() {
+        return false;
+      }
+    },
+    PERMISSIONS {
+      @Override
+      boolean matches(Repository git) throws IOException {
+        Ref head = git.getRefDatabase().exactRef(Constants.HEAD);
+        return head != null
+            && head.isSymbolic()
+            && RefNames.REFS_CONFIG.equals(head.getLeaf().getName());
+      }
+
+      @Override
+      boolean useMatch() {
+        return true;
+      }
+    },
+    ALL {
+      @Override
+      boolean matches(Repository git) {
+        return true;
+      }
+
+      @Override
+      boolean useMatch() {
+        return false;
+      }
+    };
+
+    abstract boolean matches(Repository git) throws IOException;
+
+    abstract boolean useMatch();
+  }
+
+  private final CurrentUser currentUser;
+  private final ProjectCache projectCache;
+  private final GroupsCollection groupsCollection;
+  private final GroupControl.Factory groupControlFactory;
+  private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
+  private final ProjectNode.Factory projectNodeFactory;
+  private final WebLinks webLinks;
+
+  @Deprecated
+  @Option(name = "--format", usage = "(deprecated) output format")
+  private OutputFormat format = OutputFormat.TEXT;
+
+  @Option(
+    name = "--show-branch",
+    aliases = {"-b"},
+    usage = "displays the sha of each project in the specified branch"
+  )
+  public void addShowBranch(String branch) {
+    showBranch.add(branch);
+  }
+
+  @Option(
+    name = "--tree",
+    aliases = {"-t"},
+    usage =
+        "displays project inheritance in a tree-like format\n"
+            + "this option does not work together with the show-branch option"
+  )
+  public void setShowTree(boolean showTree) {
+    this.showTree = showTree;
+  }
+
+  @Option(name = "--type", usage = "type of project")
+  public void setFilterType(FilterType type) {
+    this.type = type;
+  }
+
+  @Option(
+    name = "--description",
+    aliases = {"-d"},
+    usage = "include description of project in list"
+  )
+  public void setShowDescription(boolean showDescription) {
+    this.showDescription = showDescription;
+  }
+
+  @Option(name = "--all", usage = "display all projects that are accessible by the calling user")
+  public void setAll(boolean all) {
+    this.all = all;
+  }
+
+  @Option(
+    name = "--state",
+    aliases = {"-s"},
+    usage = "filter by project state"
+  )
+  public void setState(com.google.gerrit.extensions.client.ProjectState state) {
+    this.state = state;
+  }
+
+  @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;
+  }
+
+  @Option(
+    name = "--prefix",
+    aliases = {"-p"},
+    metaVar = "PREFIX",
+    usage = "match project prefix"
+  )
+  public void setMatchPrefix(String matchPrefix) {
+    this.matchPrefix = matchPrefix;
+  }
+
+  @Option(
+    name = "--match",
+    aliases = {"-m"},
+    metaVar = "MATCH",
+    usage = "match project substring"
+  )
+  public void setMatchSubstring(String matchSubstring) {
+    this.matchSubstring = matchSubstring;
+  }
+
+  @Option(name = "-r", metaVar = "REGEX", usage = "match project regex")
+  public void setMatchRegex(String matchRegex) {
+    this.matchRegex = matchRegex;
+  }
+
+  @Option(
+    name = "--has-acl-for",
+    metaVar = "GROUP",
+    usage = "displays only projects on which access rights for this group are directly assigned"
+  )
+  public void setGroupUuid(AccountGroup.UUID groupUuid) {
+    this.groupUuid = groupUuid;
+  }
+
+  private final List<String> showBranch = new ArrayList<>();
+  private boolean showTree;
+  private FilterType type = FilterType.ALL;
+  private boolean showDescription;
+  private boolean all;
+  private com.google.gerrit.extensions.client.ProjectState state;
+  private int limit;
+  private int start;
+  private String matchPrefix;
+  private String matchSubstring;
+  private String matchRegex;
+  private AccountGroup.UUID groupUuid;
+
+  @Inject
+  protected ListProjects(
+      CurrentUser currentUser,
+      ProjectCache projectCache,
+      GroupsCollection groupsCollection,
+      GroupControl.Factory groupControlFactory,
+      GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      ProjectNode.Factory projectNodeFactory,
+      WebLinks webLinks) {
+    this.currentUser = currentUser;
+    this.projectCache = projectCache;
+    this.groupsCollection = groupsCollection;
+    this.groupControlFactory = groupControlFactory;
+    this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+    this.projectNodeFactory = projectNodeFactory;
+    this.webLinks = webLinks;
+  }
+
+  public List<String> getShowBranch() {
+    return showBranch;
+  }
+
+  public boolean isShowTree() {
+    return showTree;
+  }
+
+  public boolean isShowDescription() {
+    return showDescription;
+  }
+
+  public OutputFormat getFormat() {
+    return format;
+  }
+
+  public ListProjects setFormat(OutputFormat fmt) {
+    format = fmt;
+    return this;
+  }
+
+  @Override
+  public Object apply(TopLevelResource resource)
+      throws BadRequestException, PermissionBackendException {
+    if (format == OutputFormat.TEXT) {
+      ByteArrayOutputStream buf = new ByteArrayOutputStream();
+      display(buf);
+      return BinaryResult.create(buf.toByteArray())
+          .setContentType("text/plain")
+          .setCharacterEncoding(UTF_8);
+    }
+    return apply();
+  }
+
+  public SortedMap<String, ProjectInfo> apply()
+      throws BadRequestException, PermissionBackendException {
+    format = OutputFormat.JSON;
+    return display(null);
+  }
+
+  public SortedMap<String, ProjectInfo> display(@Nullable OutputStream displayOutputStream)
+      throws BadRequestException, PermissionBackendException {
+    if (all && state != null) {
+      throw new BadRequestException("'all' and 'state' may not be used together");
+    }
+    if (groupUuid != null) {
+      try {
+        if (!groupControlFactory.controlFor(groupUuid).isVisible()) {
+          return Collections.emptySortedMap();
+        }
+      } catch (NoSuchGroupException ex) {
+        return Collections.emptySortedMap();
+      }
+    }
+
+    PrintWriter stdout = null;
+    if (displayOutputStream != null) {
+      stdout =
+          new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
+    }
+
+    if (type == FilterType.PARENT_CANDIDATES) {
+      // Historically, PARENT_CANDIDATES implied showDescription.
+      showDescription = true;
+    }
+
+    int foundIndex = 0;
+    int found = 0;
+    TreeMap<String, ProjectInfo> output = new TreeMap<>();
+    Map<String, String> hiddenNames = new HashMap<>();
+    Map<Project.NameKey, Boolean> accessibleParents = new HashMap<>();
+    PermissionBackend.WithUser perm = permissionBackend.user(currentUser);
+    final TreeMap<Project.NameKey, ProjectNode> treeMap = new TreeMap<>();
+    try {
+      for (Project.NameKey projectName : filter(perm)) {
+        final ProjectState e = projectCache.get(projectName);
+        if (e == null || (e.getProject().getState() == HIDDEN && !all && state != HIDDEN)) {
+          // If we can't get it from the cache, pretend it's not present.
+          // If all wasn't selected, and it's HIDDEN, pretend it's not present.
+          // If state HIDDEN wasn't selected, and it's HIDDEN, pretend it's not present.
+          continue;
+        }
+
+        if (state != null && e.getProject().getState() != state) {
+          continue;
+        }
+
+        if (groupUuid != null
+            && !e.getLocalGroups()
+                .contains(GroupReference.forGroup(groupsCollection.parseId(groupUuid.get())))) {
+          continue;
+        }
+
+        ProjectInfo info = new ProjectInfo();
+        if (showTree && !format.isJson()) {
+          treeMap.put(projectName, projectNodeFactory.create(e.getProject(), true));
+          continue;
+        }
+
+        info.name = projectName.get();
+        if (showTree && format.isJson()) {
+          ProjectState parent = Iterables.getFirst(e.parents(), null);
+          if (parent != null) {
+            if (isParentAccessible(accessibleParents, perm, parent)) {
+              info.parent = parent.getName();
+            } else {
+              info.parent = hiddenNames.get(parent.getName());
+              if (info.parent == null) {
+                info.parent = "?-" + (hiddenNames.size() + 1);
+                hiddenNames.put(parent.getName(), info.parent);
+              }
+            }
+          }
+        }
+
+        if (showDescription) {
+          info.description = Strings.emptyToNull(e.getProject().getDescription());
+        }
+        info.state = e.getProject().getState();
+
+        try {
+          if (!showBranch.isEmpty()) {
+            try (Repository git = repoManager.openRepository(projectName)) {
+              if (!type.matches(git)) {
+                continue;
+              }
+              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;
+              }
+
+              for (int i = 0; i < showBranch.size(); i++) {
+                Ref ref = refs.get(i);
+                if (ref != null && ref.getObjectId() != null) {
+                  if (info.branches == null) {
+                    info.branches = new LinkedHashMap<>();
+                  }
+                  info.branches.put(showBranch.get(i), ref.getObjectId().name());
+                }
+              }
+            }
+          } else if (!showTree && type.useMatch()) {
+            try (Repository git = repoManager.openRepository(projectName)) {
+              if (!type.matches(git)) {
+                continue;
+              }
+            }
+          }
+        } catch (RepositoryNotFoundException err) {
+          // If the Git repository is gone, the project doesn't actually exist anymore.
+          continue;
+        } catch (IOException err) {
+          log.warn("Unexpected error reading " + projectName, err);
+          continue;
+        }
+
+        if (type != FilterType.PARENT_CANDIDATES) {
+          List<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
+          info.webLinks = links.isEmpty() ? null : links;
+        }
+
+        if (foundIndex++ < start) {
+          continue;
+        }
+        if (limit > 0 && ++found > limit) {
+          break;
+        }
+
+        if (stdout == null || format.isJson()) {
+          output.put(info.name, info);
+          continue;
+        }
+
+        if (!showBranch.isEmpty()) {
+          for (String name : showBranch) {
+            String ref = info.branches != null ? info.branches.get(name) : null;
+            if (ref == null) {
+              // Print stub (forty '-' symbols)
+              ref = "----------------------------------------";
+            }
+            stdout.print(ref);
+            stdout.print(' ');
+          }
+        }
+        stdout.print(info.name);
+
+        if (info.description != null) {
+          // We still want to list every project as one-liners, hence escaping \n.
+          stdout.print(" - " + StringUtil.escapeString(info.description));
+        }
+        stdout.print('\n');
+      }
+
+      for (ProjectInfo info : output.values()) {
+        info.id = Url.encode(info.name);
+        info.name = null;
+      }
+      if (stdout == null) {
+        return output;
+      } else if (format.isJson()) {
+        format
+            .newGson()
+            .toJson(output, new TypeToken<Map<String, ProjectInfo>>() {}.getType(), stdout);
+        stdout.print('\n');
+      } else if (showTree && treeMap.size() > 0) {
+        printProjectTree(stdout, treeMap);
+      }
+      return null;
+    } finally {
+      if (stdout != null) {
+        stdout.flush();
+      }
+    }
+  }
+
+  private Collection<Project.NameKey> filter(PermissionBackend.WithUser perm)
+      throws BadRequestException, PermissionBackendException {
+    Stream<Project.NameKey> matches = scan();
+    if (type == FilterType.PARENT_CANDIDATES) {
+      matches = parentsOf(matches);
+    }
+    // TODO(dborowitz): Streamified PermissionBackend#filter.
+    return perm.filter(ProjectPermission.ACCESS, matches.collect(toList()))
+        .stream()
+        .sorted()
+        .collect(toList());
+  }
+
+  private Stream<Project.NameKey> parentsOf(Stream<Project.NameKey> matches) {
+    return matches
+        .map(
+            p -> {
+              ProjectState ps = projectCache.get(p);
+              if (ps != null) {
+                Project.NameKey parent = ps.getProject().getParent();
+                if (parent != null) {
+                  if (projectCache.get(parent) != null) {
+                    return parent;
+                  }
+                  log.warn(
+                      String.format(
+                          "parent project %s of project %s not found", parent.get(), ps.getName()));
+                }
+              }
+              return null;
+            })
+        .filter(Objects::nonNull)
+        .distinct();
+  }
+
+  private boolean isParentAccessible(
+      Map<Project.NameKey, Boolean> checked, PermissionBackend.WithUser perm, ProjectState p)
+      throws PermissionBackendException {
+    Project.NameKey name = p.getNameKey();
+    Boolean b = checked.get(name);
+    if (b == null) {
+      try {
+        perm.project(name).check(ProjectPermission.ACCESS);
+        b = true;
+      } catch (AuthException denied) {
+        b = false;
+      }
+      checked.put(name, b);
+    }
+    return b;
+  }
+
+  private Stream<Project.NameKey> scan() throws BadRequestException {
+    if (matchPrefix != null) {
+      checkMatchOptions(matchSubstring == null && matchRegex == null);
+      return projectCache.byName(matchPrefix).stream();
+    } else if (matchSubstring != null) {
+      checkMatchOptions(matchPrefix == null && matchRegex == null);
+      return projectCache
+          .all()
+          .stream()
+          .filter(
+              p -> p.get().toLowerCase(Locale.US).contains(matchSubstring.toLowerCase(Locale.US)));
+    } else if (matchRegex != null) {
+      checkMatchOptions(matchPrefix == null && matchSubstring == null);
+      RegexListSearcher<Project.NameKey> searcher;
+      try {
+        searcher = new RegexListSearcher<>(matchRegex, Project.NameKey::get);
+      } catch (IllegalArgumentException e) {
+        throw new BadRequestException(e.getMessage());
+      }
+      return searcher.search(ImmutableList.copyOf(projectCache.all()));
+    } else {
+      return projectCache.all().stream();
+    }
+  }
+
+  private static void checkMatchOptions(boolean cond) throws BadRequestException {
+    if (!cond) {
+      throw new BadRequestException("specify exactly one of p/m/r");
+    }
+  }
+
+  private void printProjectTree(
+      final PrintWriter stdout, TreeMap<Project.NameKey, ProjectNode> treeMap) {
+    final SortedSet<ProjectNode> sortedNodes = new TreeSet<>();
+
+    // Builds the inheritance tree using a list.
+    //
+    for (ProjectNode key : treeMap.values()) {
+      if (key.isAllProjects()) {
+        sortedNodes.add(key);
+        continue;
+      }
+
+      ProjectNode node = treeMap.get(key.getParentName());
+      if (node != null) {
+        node.addChild(key);
+      } else {
+        sortedNodes.add(key);
+      }
+    }
+
+    final TreeFormatter treeFormatter = new TreeFormatter(stdout);
+    treeFormatter.printTree(sortedNodes);
+    stdout.flush();
+  }
+
+  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 && canReadAllRefs) {
+          result[i] = ref;
+        } else if (ref != null && ref.getObjectId() != null) {
+          try {
+            perm.ref(ref.getLeaf().getName()).check(RefPermission.READ);
+            result[i] = ref;
+          } catch (AuthException e) {
+            continue;
+          }
+        }
+      }
+    } catch (IOException | PermissionBackendException e) {
+      // Fall through and return what is available.
+    }
+    return Arrays.asList(result);
+  }
+
+  private static boolean hasValidRef(List<Ref> refs) {
+    for (Ref ref : refs) {
+      if (ref != null) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
new file mode 100644
index 0000000..a2b7082
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -0,0 +1,241 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CommonConverters;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.WebLinks;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.RefFilter;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+public class ListTags implements RestReadView<ProjectResource> {
+  private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final VisibleRefFilter.Factory refFilterFactory;
+  private final WebLinks links;
+
+  @Option(
+    name = "--limit",
+    aliases = {"-n"},
+    metaVar = "CNT",
+    usage = "maximum number of tags to list"
+  )
+  public void setLimit(int limit) {
+    this.limit = limit;
+  }
+
+  @Option(
+    name = "--start",
+    aliases = {"-S", "-s"},
+    metaVar = "CNT",
+    usage = "number of tags to skip"
+  )
+  public void setStart(int start) {
+    this.start = start;
+  }
+
+  @Option(
+    name = "--match",
+    aliases = {"-m"},
+    metaVar = "MATCH",
+    usage = "match tags substring"
+  )
+  public void setMatchSubstring(String matchSubstring) {
+    this.matchSubstring = matchSubstring;
+  }
+
+  @Option(
+    name = "--regex",
+    aliases = {"-r"},
+    metaVar = "REGEX",
+    usage = "match tags regex"
+  )
+  public void setMatchRegex(String matchRegex) {
+    this.matchRegex = matchRegex;
+  }
+
+  private int limit;
+  private int start;
+  private String matchSubstring;
+  private String matchRegex;
+
+  @Inject
+  public ListTags(
+      GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      VisibleRefFilter.Factory refFilterFactory,
+      WebLinks webLinks) {
+    this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.refFilterFactory = refFilterFactory;
+    this.links = webLinks;
+  }
+
+  public ListTags request(ListRefsRequest<TagInfo> request) {
+    this.setLimit(request.getLimit());
+    this.setStart(request.getStart());
+    this.setMatchSubstring(request.getSubstring());
+    this.setMatchRegex(request.getRegex());
+    return this;
+  }
+
+  @Override
+  public List<TagInfo> apply(ProjectResource resource)
+      throws IOException, ResourceNotFoundException, BadRequestException {
+    List<TagInfo> tags = new ArrayList<>();
+
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(resource.getNameKey());
+    try (Repository repo = getRepository(resource.getNameKey());
+        RevWalk rw = new RevWalk(repo)) {
+      Map<String, Ref> all =
+          visibleTags(
+              resource.getProjectState(), repo, repo.getRefDatabase().getRefs(Constants.R_TAGS));
+      for (Ref ref : all.values()) {
+        tags.add(createTagInfo(perm.ref(ref.getName()), ref, rw, resource.getNameKey(), links));
+      }
+    }
+
+    Collections.sort(
+        tags,
+        new Comparator<TagInfo>() {
+          @Override
+          public int compare(TagInfo a, TagInfo b) {
+            return a.ref.compareTo(b.ref);
+          }
+        });
+
+    return new RefFilter<TagInfo>(Constants.R_TAGS)
+        .start(start)
+        .limit(limit)
+        .subString(matchSubstring)
+        .regex(matchRegex)
+        .filter(tags);
+  }
+
+  public TagInfo get(ProjectResource resource, IdString id)
+      throws ResourceNotFoundException, IOException {
+    try (Repository repo = getRepository(resource.getNameKey());
+        RevWalk rw = new RevWalk(repo)) {
+      String tagName = id.get();
+      if (!tagName.startsWith(Constants.R_TAGS)) {
+        tagName = Constants.R_TAGS + tagName;
+      }
+      Ref ref = repo.getRefDatabase().exactRef(tagName);
+      if (ref != null
+          && !visibleTags(resource.getProjectState(), repo, ImmutableMap.of(ref.getName(), ref))
+              .isEmpty()) {
+        return createTagInfo(
+            permissionBackend
+                .user(resource.getUser())
+                .project(resource.getNameKey())
+                .ref(ref.getName()),
+            ref,
+            rw,
+            resource.getNameKey(),
+            links);
+      }
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  public static TagInfo createTagInfo(
+      PermissionBackend.ForRef perm,
+      Ref ref,
+      RevWalk rw,
+      Project.NameKey projectName,
+      WebLinks links)
+      throws MissingObjectException, IOException {
+    RevObject object = rw.parseAny(ref.getObjectId());
+    Boolean canDelete = perm.testOrFalse(RefPermission.DELETE) ? true : null;
+    List<WebLinkInfo> webLinks = links.getTagLinks(projectName.get(), ref.getName());
+    if (object instanceof RevTag) {
+      // Annotated or signed tag
+      RevTag tag = (RevTag) object;
+      PersonIdent tagger = tag.getTaggerIdent();
+      return new TagInfo(
+          ref.getName(),
+          tag.getName(),
+          tag.getObject().getName(),
+          tag.getFullMessage().trim(),
+          tagger != null ? CommonConverters.toGitPerson(tagger) : null,
+          canDelete,
+          webLinks.isEmpty() ? null : webLinks,
+          tagger != null ? new Timestamp(tagger.getWhen().getTime()) : null);
+    }
+
+    Timestamp timestamp =
+        object instanceof RevCommit
+            ? new Timestamp(((RevCommit) object).getCommitterIdent().getWhen().getTime())
+            : null;
+
+    // Lightweight tag
+    return new TagInfo(
+        ref.getName(),
+        ref.getObjectId().getName(),
+        canDelete,
+        webLinks.isEmpty() ? null : webLinks,
+        timestamp);
+  }
+
+  private Repository getRepository(Project.NameKey project)
+      throws ResourceNotFoundException, IOException {
+    try {
+      return repoManager.openRepository(project);
+    } catch (RepositoryNotFoundException noGitRepository) {
+      throw new ResourceNotFoundException();
+    }
+  }
+
+  private Map<String, Ref> visibleTags(ProjectState state, Repository repo, Map<String, Ref> tags) {
+    return refFilterFactory.create(state, repo).setShowMetadata(false).filter(tags, true);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
new file mode 100644
index 0000000..67380dc
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.gerrit.server.project.BranchResource.BRANCH_KIND;
+import static com.google.gerrit.server.project.ChildProjectResource.CHILD_PROJECT_KIND;
+import static com.google.gerrit.server.project.CommitResource.COMMIT_KIND;
+import static com.google.gerrit.server.project.DashboardResource.DASHBOARD_KIND;
+import static com.google.gerrit.server.project.FileResource.FILE_KIND;
+import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
+import static com.google.gerrit.server.project.TagResource.TAG_KIND;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.project.RefValidationHelper;
+
+public class Module extends RestApiModule {
+  @Override
+  protected void configure() {
+    bind(ProjectsCollection.class);
+    bind(DashboardsCollection.class);
+
+    DynamicMap.mapOf(binder(), PROJECT_KIND);
+    DynamicMap.mapOf(binder(), CHILD_PROJECT_KIND);
+    DynamicMap.mapOf(binder(), BRANCH_KIND);
+    DynamicMap.mapOf(binder(), DASHBOARD_KIND);
+    DynamicMap.mapOf(binder(), FILE_KIND);
+    DynamicMap.mapOf(binder(), COMMIT_KIND);
+    DynamicMap.mapOf(binder(), TAG_KIND);
+
+    put(PROJECT_KIND).to(PutProject.class);
+    get(PROJECT_KIND).to(GetProject.class);
+    get(PROJECT_KIND, "description").to(GetDescription.class);
+    put(PROJECT_KIND, "description").to(PutDescription.class);
+    delete(PROJECT_KIND, "description").to(PutDescription.class);
+
+    get(PROJECT_KIND, "access").to(GetAccess.class);
+    post(PROJECT_KIND, "access").to(SetAccess.class);
+    put(PROJECT_KIND, "access:review").to(CreateAccessChange.class);
+    post(PROJECT_KIND, "check.access").to(CheckAccess.class);
+
+    get(PROJECT_KIND, "parent").to(GetParent.class);
+    put(PROJECT_KIND, "parent").to(SetParent.class);
+
+    child(PROJECT_KIND, "children").to(ChildProjectsCollection.class);
+    get(CHILD_PROJECT_KIND).to(GetChildProject.class);
+
+    get(PROJECT_KIND, "HEAD").to(GetHead.class);
+    put(PROJECT_KIND, "HEAD").to(SetHead.class);
+
+    put(PROJECT_KIND, "ban").to(BanCommit.class);
+
+    get(PROJECT_KIND, "statistics.git").to(GetStatistics.class);
+    post(PROJECT_KIND, "gc").to(GarbageCollect.class);
+    post(PROJECT_KIND, "index").to(Index.class);
+
+    child(PROJECT_KIND, "branches").to(BranchesCollection.class);
+    put(BRANCH_KIND).to(PutBranch.class);
+    get(BRANCH_KIND).to(GetBranch.class);
+    delete(BRANCH_KIND).to(DeleteBranch.class);
+    post(PROJECT_KIND, "branches:delete").to(DeleteBranches.class);
+    factory(CreateBranch.Factory.class);
+    get(BRANCH_KIND, "mergeable").to(CheckMergeability.class);
+    factory(RefValidationHelper.Factory.class);
+    get(BRANCH_KIND, "reflog").to(GetReflog.class);
+    child(BRANCH_KIND, "files").to(FilesCollection.class);
+    get(FILE_KIND, "content").to(GetContent.class);
+
+    child(PROJECT_KIND, "commits").to(CommitsCollection.class);
+    get(COMMIT_KIND).to(GetCommit.class);
+    get(COMMIT_KIND, "in").to(CommitIncludedIn.class);
+    child(COMMIT_KIND, "files").to(FilesInCommitCollection.class);
+
+    child(PROJECT_KIND, "tags").to(TagsCollection.class);
+    get(TAG_KIND).to(GetTag.class);
+    put(TAG_KIND).to(PutTag.class);
+    delete(TAG_KIND).to(DeleteTag.class);
+    post(PROJECT_KIND, "tags:delete").to(DeleteTags.class);
+    factory(CreateTag.Factory.class);
+
+    child(PROJECT_KIND, "dashboards").to(DashboardsCollection.class);
+    get(DASHBOARD_KIND).to(GetDashboard.class);
+    put(DASHBOARD_KIND).to(SetDashboard.class);
+    delete(DASHBOARD_KIND).to(DeleteDashboard.class);
+    factory(CreateProject.Factory.class);
+
+    get(PROJECT_KIND, "config").to(GetConfig.class);
+    put(PROJECT_KIND, "config").to(PutConfig.class);
+
+    factory(DeleteRef.Factory.class);
+    factory(ProjectNode.Factory.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectNode.java b/java/com/google/gerrit/server/restapi/project/ProjectNode.java
new file mode 100644
index 0000000..54f7574
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ProjectNode.java
@@ -0,0 +1,87 @@
+// 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.restapi.project;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.util.TreeFormatter.TreeNode;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+/** Node of a Project in a tree formatted by {@link ListProjects}. */
+class ProjectNode implements TreeNode, Comparable<ProjectNode> {
+  interface Factory {
+    ProjectNode create(Project project, boolean isVisible);
+  }
+
+  private final AllProjectsName allProjectsName;
+  private final Project project;
+  private final boolean isVisible;
+
+  private final SortedSet<ProjectNode> children = new TreeSet<>();
+
+  @Inject
+  protected ProjectNode(
+      final AllProjectsName allProjectsName,
+      @Assisted final Project project,
+      @Assisted final boolean isVisible) {
+    this.allProjectsName = allProjectsName;
+    this.project = project;
+    this.isVisible = isVisible;
+  }
+
+  /**
+   * Returns the project parent name.
+   *
+   * @return Project parent name, {@code null} for the 'All-Projects' root project
+   */
+  Project.NameKey getParentName() {
+    return project.getParent(allProjectsName);
+  }
+
+  boolean isAllProjects() {
+    return allProjectsName.equals(project.getNameKey());
+  }
+
+  Project getProject() {
+    return project;
+  }
+
+  @Override
+  public String getDisplayName() {
+    return project.getName();
+  }
+
+  @Override
+  public boolean isVisible() {
+    return isVisible;
+  }
+
+  @Override
+  public SortedSet<? extends ProjectNode> getChildren() {
+    return children;
+  }
+
+  void addChild(ProjectNode child) {
+    children.add(child);
+  }
+
+  @Override
+  public int compareTo(ProjectNode o) {
+    return project.getNameKey().compareTo(o.project.getNameKey());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
new file mode 100644
index 0000000..186ff99
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -0,0 +1,169 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Constants;
+
+@Singleton
+public class ProjectsCollection
+    implements RestCollection<TopLevelResource, ProjectResource>,
+        AcceptsCreate<TopLevelResource>,
+        NeedsParams {
+  private final DynamicMap<RestView<ProjectResource>> views;
+  private final Provider<ListProjects> list;
+  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,
+      Provider<QueryProjects> queryProjects,
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      CreateProject.Factory factory,
+      Provider<CurrentUser> user) {
+    this.views = views;
+    this.list = list;
+    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);
+  }
+
+  @Override
+  public ProjectResource parse(TopLevelResource parent, IdString id)
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
+    ProjectResource rsrc = _parse(id.get(), true);
+    if (rsrc == null) {
+      throw new ResourceNotFoundException(id);
+    }
+    return rsrc;
+  }
+
+  /**
+   * Parses a project ID from a request body and returns the project.
+   *
+   * @param id ID of the project, can be a project name
+   * @return the project
+   * @throws UnprocessableEntityException thrown if the project ID cannot be resolved or if the
+   *     project is not visible to the calling user
+   * @throws IOException thrown when there is an error.
+   * @throws PermissionBackendException
+   */
+  public ProjectResource parse(String id)
+      throws UnprocessableEntityException, IOException, PermissionBackendException {
+    return parse(id, true);
+  }
+
+  /**
+   * Parses a project ID from a request body and returns the project.
+   *
+   * @param id ID of the project, can be a project name
+   * @param checkAccess if true, check the project is accessible by the current user
+   * @return the project
+   * @throws UnprocessableEntityException thrown if the project ID cannot be resolved or if the
+   *     project is not visible to the calling user and checkVisibility is true.
+   * @throws IOException thrown when there is an error.
+   * @throws PermissionBackendException
+   */
+  public ProjectResource parse(String id, boolean checkAccess)
+      throws UnprocessableEntityException, IOException, PermissionBackendException {
+    ProjectResource rsrc = _parse(id, checkAccess);
+    if (rsrc == null) {
+      throw new UnprocessableEntityException(String.format("Project Not Found: %s", id));
+    }
+    return rsrc;
+  }
+
+  @Nullable
+  private ProjectResource _parse(String id, boolean checkAccess)
+      throws IOException, PermissionBackendException {
+    if (id.endsWith(Constants.DOT_GIT_EXT)) {
+      id = id.substring(0, id.length() - Constants.DOT_GIT_EXT.length());
+    }
+
+    Project.NameKey nameKey = new Project.NameKey(id);
+    ProjectState state = projectCache.checkedGet(nameKey);
+    if (state == null) {
+      return null;
+    }
+
+    if (checkAccess) {
+      try {
+        permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+      } catch (AuthException e) {
+        return null; // Pretend like not found on access denied.
+      }
+    }
+    return new ProjectResource(state, user.get());
+  }
+
+  @Override
+  public DynamicMap<RestView<ProjectResource>> views() {
+    return views;
+  }
+
+  @Override
+  public CreateProject create(TopLevelResource parent, IdString name) {
+    return createProjectFactory.create(name.get());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PutBranch.java b/java/com/google/gerrit/server/restapi/project/PutBranch.java
new file mode 100644
index 0000000..fec8abf
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PutBranch.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class PutBranch implements RestModifyView<BranchResource, BranchInput> {
+
+  @Override
+  public BranchInfo apply(BranchResource rsrc, BranchInput input) throws ResourceConflictException {
+    throw new ResourceConflictException("Branch \"" + rsrc.getRef() + "\" already exists");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
new file mode 100644
index 0000000..69c4c05
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -0,0 +1,294 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.ConfigValue;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.EnableSignedPush;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.extensions.webui.UiActions;
+import com.google.gerrit.server.git.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.gerrit.server.project.BooleanProjectConfigTransformations;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class PutConfig implements RestModifyView<ProjectResource, ConfigInput> {
+  private static final Logger log = LoggerFactory.getLogger(PutConfig.class);
+  private static final Pattern PARAMETER_NAME_PATTERN =
+      Pattern.compile("^[a-zA-Z0-9]+[a-zA-Z0-9-]*$");
+
+  private final boolean serverEnableSignedPush;
+  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final ProjectCache projectCache;
+  private final ProjectState.Factory projectStateFactory;
+  private final TransferConfig config;
+  private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+  private final PluginConfigFactory cfgFactory;
+  private final AllProjectsName allProjects;
+  private final UiActions uiActions;
+  private final DynamicMap<RestView<ProjectResource>> views;
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  PutConfig(
+      @EnableSignedPush boolean serverEnableSignedPush,
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      ProjectCache projectCache,
+      ProjectState.Factory projectStateFactory,
+      TransferConfig config,
+      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      PluginConfigFactory cfgFactory,
+      AllProjectsName allProjects,
+      UiActions uiActions,
+      DynamicMap<RestView<ProjectResource>> views,
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend) {
+    this.serverEnableSignedPush = serverEnableSignedPush;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectCache = projectCache;
+    this.projectStateFactory = projectStateFactory;
+    this.config = config;
+    this.pluginConfigEntries = pluginConfigEntries;
+    this.cfgFactory = cfgFactory;
+    this.allProjects = allProjects;
+    this.uiActions = uiActions;
+    this.views = views;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  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);
+  }
+
+  public ConfigInfo apply(ProjectState projectState, ConfigInput input)
+      throws ResourceNotFoundException, BadRequestException, ResourceConflictException {
+    Project.NameKey projectName = projectState.getNameKey();
+    if (input == null) {
+      throw new BadRequestException("config is required");
+    }
+
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
+      ProjectConfig projectConfig = ProjectConfig.read(md);
+      Project p = projectConfig.getProject();
+
+      p.setDescription(Strings.emptyToNull(input.description));
+
+      for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
+        InheritableBoolean val = BooleanProjectConfigTransformations.get(cfg, input);
+        if (val != null) {
+          p.setBooleanConfig(cfg, val);
+        }
+      }
+
+      if (input.maxObjectSizeLimit != null) {
+        p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
+      }
+
+      if (input.submitType != null) {
+        p.setSubmitType(input.submitType);
+      }
+
+      if (input.state != null) {
+        p.setState(input.state);
+      }
+
+      if (input.pluginConfigValues != null) {
+        setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
+      }
+
+      md.setMessage("Modified project settings\n");
+      try {
+        projectConfig.commit(md);
+        projectCache.evict(projectConfig.getProject());
+        md.getRepository().setGitwebDescription(p.getDescription());
+      } catch (IOException e) {
+        if (e.getCause() instanceof ConfigInvalidException) {
+          throw new ResourceConflictException(
+              "Cannot update " + projectName + ": " + e.getCause().getMessage());
+        }
+        log.warn(String.format("Failed to update config of project %s.", projectName), e);
+        throw new ResourceConflictException("Cannot update " + projectName);
+      }
+
+      ProjectState state = projectStateFactory.create(projectConfig);
+      return new ConfigInfoImpl(
+          serverEnableSignedPush,
+          state,
+          user.get(),
+          config,
+          pluginConfigEntries,
+          cfgFactory,
+          allProjects,
+          uiActions,
+          views);
+    } catch (RepositoryNotFoundException notFound) {
+      throw new ResourceNotFoundException(projectName.get());
+    } catch (ConfigInvalidException err) {
+      throw new ResourceConflictException("Cannot read project " + projectName, err);
+    } catch (IOException err) {
+      throw new ResourceConflictException("Cannot update project " + projectName, err);
+    }
+  }
+
+  private void setPluginConfigValues(
+      ProjectState projectState,
+      ProjectConfig projectConfig,
+      Map<String, Map<String, ConfigValue>> pluginConfigValues)
+      throws BadRequestException {
+    for (Entry<String, Map<String, ConfigValue>> e : pluginConfigValues.entrySet()) {
+      String pluginName = e.getKey();
+      PluginConfig cfg = projectConfig.getPluginConfig(pluginName);
+      for (Entry<String, ConfigValue> v : e.getValue().entrySet()) {
+        ProjectConfigEntry projectConfigEntry = pluginConfigEntries.get(pluginName, v.getKey());
+        if (projectConfigEntry != null) {
+          if (!PARAMETER_NAME_PATTERN.matcher(v.getKey()).matches()) {
+            //TODO check why we have this restriction
+            log.warn(
+                "Parameter name '{}' must match '{}'",
+                v.getKey(),
+                PARAMETER_NAME_PATTERN.pattern());
+            continue;
+          }
+          String oldValue = cfg.getString(v.getKey());
+          String value = v.getValue().value;
+          if (projectConfigEntry.getType() == ProjectConfigEntryType.ARRAY) {
+            List<String> l = Arrays.asList(cfg.getStringList(v.getKey()));
+            oldValue = Joiner.on("\n").join(l);
+            value = Joiner.on("\n").join(v.getValue().values);
+          }
+          if (Strings.emptyToNull(value) != null) {
+            if (!value.equals(oldValue)) {
+              validateProjectConfigEntryIsEditable(
+                  projectConfigEntry, projectState, v.getKey(), pluginName);
+              v.setValue(projectConfigEntry.preUpdate(v.getValue()));
+              value = v.getValue().value;
+              try {
+                switch (projectConfigEntry.getType()) {
+                  case BOOLEAN:
+                    boolean newBooleanValue = Boolean.parseBoolean(value);
+                    cfg.setBoolean(v.getKey(), newBooleanValue);
+                    break;
+                  case INT:
+                    int newIntValue = Integer.parseInt(value);
+                    cfg.setInt(v.getKey(), newIntValue);
+                    break;
+                  case LONG:
+                    long newLongValue = Long.parseLong(value);
+                    cfg.setLong(v.getKey(), newLongValue);
+                    break;
+                  case LIST:
+                    if (!projectConfigEntry.getPermittedValues().contains(value)) {
+                      throw new BadRequestException(
+                          String.format(
+                              "The value '%s' is not permitted for parameter '%s' of plugin '"
+                                  + pluginName
+                                  + "'",
+                              value,
+                              v.getKey()));
+                    }
+                    // $FALL-THROUGH$
+                  case STRING:
+                    cfg.setString(v.getKey(), value);
+                    break;
+                  case ARRAY:
+                    cfg.setStringList(v.getKey(), v.getValue().values);
+                    break;
+                  default:
+                    log.warn(
+                        String.format(
+                            "The type '%s' of parameter '%s' is not supported.",
+                            projectConfigEntry.getType().name(), v.getKey()));
+                }
+              } catch (NumberFormatException ex) {
+                throw new BadRequestException(
+                    String.format(
+                        "The value '%s' of config parameter '%s' of plugin '%s' is invalid: %s",
+                        v.getValue(), v.getKey(), pluginName, ex.getMessage()));
+              }
+            }
+          } else {
+            if (oldValue != null) {
+              validateProjectConfigEntryIsEditable(
+                  projectConfigEntry, projectState, v.getKey(), pluginName);
+              cfg.unset(v.getKey());
+            }
+          }
+        } else {
+          throw new BadRequestException(
+              String.format(
+                  "The config parameter '%s' of plugin '%s' does not exist.",
+                  v.getKey(), pluginName));
+        }
+      }
+    }
+  }
+
+  private static void validateProjectConfigEntryIsEditable(
+      ProjectConfigEntry projectConfigEntry,
+      ProjectState projectState,
+      String parameterName,
+      String pluginName)
+      throws BadRequestException {
+    if (!projectConfigEntry.isEditable(projectState)) {
+      throw new BadRequestException(
+          String.format(
+              "Not allowed to set parameter '%s' of plugin '%s' on project '%s'.",
+              parameterName, pluginName, projectState.getName()));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PutDescription.java b/java/com/google/gerrit/server/restapi/project/PutDescription.java
new file mode 100644
index 0000000..9be9ee0
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PutDescription.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.projects.DescriptionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Project;
+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.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+
+@Singleton
+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,
+      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,
+          PermissionBackendException {
+    if (input == null) {
+      input = new DescriptionInput(); // Delete would set description to null.
+    }
+
+    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);
+      Project project = config.getProject();
+      project.setDescription(Strings.emptyToNull(input.description));
+
+      String msg =
+          MoreObjects.firstNonNull(
+              Strings.emptyToNull(input.commitMessage), "Updated description.\n");
+      if (!msg.endsWith("\n")) {
+        msg += "\n";
+      }
+      md.setAuthor(user);
+      md.setMessage(msg);
+      config.commit(md);
+      cache.evict(resource.getProjectState().getProject());
+      md.getRepository().setGitwebDescription(project.getDescription());
+
+      return Strings.isNullOrEmpty(project.getDescription())
+          ? Response.<String>none()
+          : Response.ok(project.getDescription());
+    } catch (RepositoryNotFoundException notFound) {
+      throw new ResourceNotFoundException(resource.getName());
+    } catch (ConfigInvalidException e) {
+      throw new ResourceConflictException(
+          String.format("invalid project.config: %s", e.getMessage()));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PutProject.java b/java/com/google/gerrit/server/restapi/project/PutProject.java
new file mode 100644
index 0000000..5b11143
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PutProject.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+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.server.project.ProjectResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class PutProject implements RestModifyView<ProjectResource, ProjectInput> {
+  @Override
+  public Response<?> apply(ProjectResource resource, ProjectInput input)
+      throws ResourceConflictException {
+    throw new ResourceConflictException("Project \"" + resource.getName() + "\" already exists");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PutTag.java b/java/com/google/gerrit/server/restapi/project/PutTag.java
new file mode 100644
index 0000000..06c5157
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PutTag.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.project.TagResource;
+
+public class PutTag implements RestModifyView<TagResource, TagInput> {
+
+  @Override
+  public TagInfo apply(TagResource resource, TagInput input) throws ResourceConflictException {
+    throw new ResourceConflictException("Tag \"" + resource.getTagInfo().ref + "\" already exists");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
new file mode 100644
index 0000000..9a1c36a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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.project.ProjectData;
+import com.google.gerrit.server.project.ProjectJson;
+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/java/com/google/gerrit/server/restapi/project/RepositoryStatistics.java b/java/com/google/gerrit/server/restapi/project/RepositoryStatistics.java
new file mode 100644
index 0000000..2a2fc866
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/RepositoryStatistics.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.CaseFormat;
+import java.util.Map.Entry;
+import java.util.Properties;
+import java.util.TreeMap;
+
+public class RepositoryStatistics extends TreeMap<String, Object> {
+  private static final long serialVersionUID = 1L;
+
+  RepositoryStatistics(Properties p) {
+    for (Entry<Object, Object> e : p.entrySet()) {
+      put(
+          CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getKey().toString()),
+          e.getValue());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
new file mode 100644
index 0000000..5a34522
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -0,0 +1,142 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CreateGroupPermissionSyncer;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class SetAccess implements RestModifyView<ProjectResource, ProjectAccessInput> {
+  protected final GroupBackend groupBackend;
+  private final PermissionBackend permissionBackend;
+  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final GetAccess getAccess;
+  private final ProjectCache projectCache;
+  private final Provider<IdentifiedUser> identifiedUser;
+  private final SetAccessUtil accessUtil;
+  private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
+
+  @Inject
+  private SetAccess(
+      GroupBackend groupBackend,
+      PermissionBackend permissionBackend,
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      ProjectCache projectCache,
+      GetAccess getAccess,
+      Provider<IdentifiedUser> identifiedUser,
+      SetAccessUtil accessUtil,
+      CreateGroupPermissionSyncer createGroupPermissionSyncer) {
+    this.groupBackend = groupBackend;
+    this.permissionBackend = permissionBackend;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.getAccess = getAccess;
+    this.projectCache = projectCache;
+    this.identifiedUser = identifiedUser;
+    this.accessUtil = accessUtil;
+    this.createGroupPermissionSyncer = createGroupPermissionSyncer;
+  }
+
+  @Override
+  public ProjectAccessInfo apply(ProjectResource rsrc, ProjectAccessInput input)
+      throws ResourceNotFoundException, ResourceConflictException, IOException, AuthException,
+          BadRequestException, UnprocessableEntityException, OrmException,
+          PermissionBackendException {
+    MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
+
+    ProjectConfig config;
+
+    List<AccessSection> removals = accessUtil.getAccessSections(input.remove);
+    List<AccessSection> additions = accessUtil.getAccessSections(input.add);
+    try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
+      config = ProjectConfig.read(md);
+
+      // Check that the user has the right permissions.
+      boolean checkedAdmin = false;
+      for (AccessSection section : Iterables.concat(additions, removals)) {
+        boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
+        if (isGlobalCapabilities) {
+          if (!checkedAdmin) {
+            permissionBackend.user(identifiedUser).check(GlobalPermission.ADMINISTRATE_SERVER);
+            checkedAdmin = true;
+          }
+        } else {
+          permissionBackend
+              .user(identifiedUser)
+              .project(rsrc.getNameKey())
+              .ref(section.getName())
+              .check(RefPermission.WRITE_CONFIG);
+        }
+      }
+
+      accessUtil.validateChanges(config, removals, additions);
+      accessUtil.applyChanges(config, removals, additions);
+
+      accessUtil.setParentName(
+          identifiedUser.get(),
+          config,
+          rsrc.getNameKey(),
+          input.parent == null ? null : new Project.NameKey(input.parent),
+          !checkedAdmin);
+
+      if (!Strings.isNullOrEmpty(input.message)) {
+        if (!input.message.endsWith("\n")) {
+          input.message += "\n";
+        }
+        md.setMessage(input.message);
+      } else {
+        md.setMessage("Modify access rules\n");
+      }
+
+      config.commit(md);
+      projectCache.evict(config.getProject());
+      createGroupPermissionSyncer.syncIfNeeded();
+    } catch (InvalidNameException e) {
+      throw new BadRequestException(e.toString());
+    } catch (ConfigInvalidException e) {
+      throw new ResourceConflictException(rsrc.getName());
+    }
+
+    return getAccess.apply(rsrc.getNameKey());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
new file mode 100644
index 0000000..8cefd66
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -0,0 +1,228 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.RefPattern;
+import com.google.gerrit.server.restapi.group.GroupsCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class SetAccessUtil {
+  private final GroupsCollection groupsCollection;
+  private final AllProjectsName allProjects;
+  private final Provider<SetParent> setParent;
+
+  @Inject
+  private SetAccessUtil(
+      GroupsCollection groupsCollection,
+      AllProjectsName allProjects,
+      Provider<SetParent> setParent) {
+    this.groupsCollection = groupsCollection;
+    this.allProjects = allProjects;
+    this.setParent = setParent;
+  }
+
+  List<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
+      throws UnprocessableEntityException {
+    if (sectionInfos == null) {
+      return Collections.emptyList();
+    }
+
+    List<AccessSection> sections = new ArrayList<>(sectionInfos.size());
+    for (Map.Entry<String, AccessSectionInfo> entry : sectionInfos.entrySet()) {
+      if (entry.getValue().permissions == null) {
+        continue;
+      }
+
+      AccessSection accessSection = new AccessSection(entry.getKey());
+      for (Map.Entry<String, PermissionInfo> permissionEntry :
+          entry.getValue().permissions.entrySet()) {
+        if (permissionEntry.getValue().rules == null) {
+          continue;
+        }
+
+        Permission p = new Permission(permissionEntry.getKey());
+        if (permissionEntry.getValue().exclusive != null) {
+          p.setExclusiveGroup(permissionEntry.getValue().exclusive);
+        }
+
+        for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
+            permissionEntry.getValue().rules.entrySet()) {
+          GroupDescription.Basic group = groupsCollection.parseId(permissionRuleInfoEntry.getKey());
+          if (group == null) {
+            throw new UnprocessableEntityException(
+                permissionRuleInfoEntry.getKey() + " is not a valid group ID");
+          }
+
+          PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
+          PermissionRule r = new PermissionRule(GroupReference.forGroup(group));
+          if (pri != null) {
+            if (pri.max != null) {
+              r.setMax(pri.max);
+            }
+            if (pri.min != null) {
+              r.setMin(pri.min);
+            }
+            if (pri.action != null) {
+              r.setAction(GetAccess.ACTION_TYPE.inverse().get(pri.action));
+            }
+            if (pri.force != null) {
+              r.setForce(pri.force);
+            }
+          }
+          p.add(r);
+        }
+        accessSection.getPermissions().add(p);
+      }
+      sections.add(accessSection);
+    }
+    return sections;
+  }
+
+  /**
+   * Checks that the removals and additions are logically valid, but doesn't check current user's
+   * permission.
+   */
+  void validateChanges(
+      ProjectConfig config, List<AccessSection> removals, List<AccessSection> additions)
+      throws BadRequestException, InvalidNameException {
+    // Perform permission checks
+    for (AccessSection section : Iterables.concat(additions, removals)) {
+      boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
+      if (isGlobalCapabilities) {
+        if (!allProjects.equals(config.getName())) {
+          throw new BadRequestException(
+              "Cannot edit global capabilities for projects other than " + allProjects.get());
+        }
+      }
+    }
+
+    // Perform addition checks
+    for (AccessSection section : additions) {
+      String name = section.getName();
+      boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(name);
+
+      if (!isGlobalCapabilities) {
+        if (!AccessSection.isValid(name)) {
+          throw new BadRequestException("invalid section name");
+        }
+        RefPattern.validate(name);
+      } else {
+        // Check all permissions for soundness
+        for (Permission p : section.getPermissions()) {
+          if (!GlobalCapability.isCapability(p.getName())) {
+            throw new BadRequestException(
+                "Cannot add non-global capability " + p.getName() + " to global capabilities");
+          }
+        }
+      }
+    }
+  }
+
+  void applyChanges(
+      ProjectConfig config, List<AccessSection> removals, List<AccessSection> additions) {
+    // Apply removals
+    for (AccessSection section : removals) {
+      if (section.getPermissions().isEmpty()) {
+        // Remove entire section
+        config.remove(config.getAccessSection(section.getName()));
+        continue;
+      }
+
+      // Remove specific permissions
+      for (Permission p : section.getPermissions()) {
+        if (p.getRules().isEmpty()) {
+          config.remove(config.getAccessSection(section.getName()), p);
+        } else {
+          for (PermissionRule r : p.getRules()) {
+            config.remove(config.getAccessSection(section.getName()), p, r);
+          }
+        }
+      }
+    }
+
+    // Apply additions
+    for (AccessSection section : additions) {
+      AccessSection currentAccessSection = config.getAccessSection(section.getName());
+
+      if (currentAccessSection == null) {
+        // Add AccessSection
+        config.replace(section);
+      } else {
+        for (Permission p : section.getPermissions()) {
+          Permission currentPermission = currentAccessSection.getPermission(p.getName());
+          if (currentPermission == null) {
+            // Add Permission
+            currentAccessSection.addPermission(p);
+          } else {
+            for (PermissionRule r : p.getRules()) {
+              // AddPermissionRule
+              currentPermission.add(r);
+            }
+          }
+        }
+      }
+    }
+  }
+
+  public void setParentName(
+      IdentifiedUser identifiedUser,
+      ProjectConfig config,
+      Project.NameKey projectName,
+      Project.NameKey newParentProjectName,
+      boolean checkAdmin)
+      throws ResourceConflictException, AuthException, PermissionBackendException,
+          BadRequestException {
+    if (newParentProjectName != null
+        && !config.getProject().getNameKey().equals(allProjects)
+        && !config.getProject().getParent(allProjects).equals(newParentProjectName)) {
+      try {
+        setParent
+            .get()
+            .validateParentUpdate(
+                projectName, identifiedUser, newParentProjectName.get(), checkAdmin);
+      } catch (UnprocessableEntityException e) {
+        throw new ResourceConflictException(e.getMessage(), e);
+      }
+      config.getProject().setParentName(newParentProjectName);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/SetDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDashboard.java
new file mode 100644
index 0000000..891978b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SetDashboard.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.common.SetDashboardInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.DashboardResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class SetDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
+  private final Provider<SetDefaultDashboard> defaultSetter;
+
+  @Inject
+  SetDashboard(Provider<SetDefaultDashboard> defaultSetter) {
+    this.defaultSetter = defaultSetter;
+  }
+
+  @Override
+  public Response<DashboardInfo> apply(DashboardResource resource, SetDashboardInput input)
+      throws RestApiException, IOException, PermissionBackendException {
+    if (resource.isProjectDefault()) {
+      return defaultSetter.get().apply(resource, input);
+    }
+
+    // TODO: Implement creation/update of dashboards by API.
+    throw new MethodNotAllowedException();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
new file mode 100644
index 0000000..4fd46c5
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -0,0 +1,152 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.common.SetDashboardInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.DashboardResource;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.kohsuke.args4j.Option;
+
+class SetDefaultDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
+  private final ProjectCache cache;
+  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;
+
+  @Inject
+  SetDefaultDashboard(
+      ProjectCache cache,
+      MetaDataUpdate.Server updateFactory,
+      DashboardsCollection dashboards,
+      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 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);
+
+    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(rsrc.getProjectState(), rsrc.getUser()),
+                IdString.fromUrl(input.id));
+      } catch (ResourceNotFoundException e) {
+        throw new BadRequestException("dashboard " + input.id + " not found");
+      } catch (ConfigInvalidException e) {
+        throw new ResourceConflictException(e.getMessage());
+      }
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getProjectState().getNameKey())) {
+      ProjectConfig config = ProjectConfig.read(md);
+      Project project = config.getProject();
+      if (inherited) {
+        project.setDefaultDashboard(input.id);
+      } else {
+        project.setLocalDefaultDashboard(input.id);
+      }
+
+      String msg =
+          MoreObjects.firstNonNull(
+              Strings.emptyToNull(input.commitMessage),
+              input.id == null
+                  ? "Removed default dashboard.\n"
+                  : String.format("Changed default dashboard to %s.\n", input.id));
+      if (!msg.endsWith("\n")) {
+        msg += "\n";
+      }
+      md.setAuthor(rsrc.getUser().asIdentifiedUser());
+      md.setMessage(msg);
+      config.commit(md);
+      cache.evict(rsrc.getProjectState().getProject());
+
+      if (target != null) {
+        DashboardInfo info = get.get().apply(target);
+        info.isDefault = true;
+        return Response.ok(info);
+      }
+      return Response.none();
+    } catch (RepositoryNotFoundException notFound) {
+      throw new ResourceNotFoundException(rsrc.getProjectState().getProject().getName());
+    } catch (ConfigInvalidException e) {
+      throw new ResourceConflictException(
+          String.format("invalid project.config: %s", e.getMessage()));
+    }
+  }
+
+  static class CreateDefault implements RestModifyView<ProjectResource, SetDashboardInput> {
+    private final Provider<SetDefaultDashboard> setDefault;
+
+    @Option(name = "--inherited", usage = "set dashboard inherited by children")
+    private boolean inherited;
+
+    @Inject
+    CreateDefault(Provider<SetDefaultDashboard> setDefault) {
+      this.setDefault = setDefault;
+    }
+
+    @Override
+    public Response<DashboardInfo> apply(ProjectResource resource, SetDashboardInput input)
+        throws RestApiException, IOException, PermissionBackendException {
+      SetDefaultDashboard set = setDefault.get();
+      set.inherited = inherited;
+      return set.apply(
+          DashboardResource.projectDefault(resource.getProjectState(), resource.getUser()), input);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/SetHead.java b/java/com/google/gerrit/server/restapi/project/SetHead.java
new file mode 100644
index 0000000..aa1bf63
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SetHead.java
@@ -0,0 +1,162 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.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.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Map;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class SetHead implements RestModifyView<ProjectResource, HeadInput> {
+  private static final Logger log = LoggerFactory.getLogger(SetHead.class);
+
+  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,
+      PermissionBackend permissionBackend) {
+    this.repoManager = repoManager;
+    this.identifiedUser = identifiedUser;
+    this.headUpdatedListeners = headUpdatedListeners;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public String apply(ProjectResource rsrc, HeadInput input)
+      throws AuthException, ResourceNotFoundException, BadRequestException,
+          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)) {
+        throw new UnprocessableEntityException(String.format("Ref Not Found: %s", ref));
+      }
+
+      final String oldHead = cur.get(Constants.HEAD).getTarget().getName();
+      final String newHead = ref;
+      if (!oldHead.equals(newHead)) {
+        final RefUpdate u = repo.updateRef(Constants.HEAD, true);
+        u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
+        RefUpdate.Result res = u.link(newHead);
+        switch (res) {
+          case NO_CHANGE:
+          case RENAMED:
+          case FORCED:
+          case NEW:
+            break;
+          case FAST_FORWARD:
+          case IO_FAILURE:
+          case LOCK_FAILURE:
+          case NOT_ATTEMPTED:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+          default:
+            throw new IOException("Setting HEAD failed with " + res);
+        }
+
+        fire(rsrc.getNameKey(), oldHead, newHead);
+      }
+      return ref;
+    } catch (RepositoryNotFoundException e) {
+      throw new ResourceNotFoundException(rsrc.getName());
+    }
+  }
+
+  private void fire(Project.NameKey nameKey, String oldHead, String newHead) {
+    if (!headUpdatedListeners.iterator().hasNext()) {
+      return;
+    }
+    Event event = new Event(nameKey, oldHead, newHead);
+    for (HeadUpdatedListener l : headUpdatedListeners) {
+      try {
+        l.onHeadUpdated(event);
+      } catch (RuntimeException e) {
+        log.warn("Failure in HeadUpdatedListener", e);
+      }
+    }
+  }
+
+  static class Event extends AbstractNoNotifyEvent implements HeadUpdatedListener.Event {
+    private final Project.NameKey nameKey;
+    private final String oldHead;
+    private final String newHead;
+
+    Event(Project.NameKey nameKey, String oldHead, String newHead) {
+      this.nameKey = nameKey;
+      this.oldHead = oldHead;
+      this.newHead = newHead;
+    }
+
+    @Override
+    public String getProjectName() {
+      return nameKey.get();
+    }
+
+    @Override
+    public String getOldHeadName() {
+      return oldHead;
+    }
+
+    @Override
+    public String getNewHeadName() {
+      return newHead;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
new file mode 100644
index 0000000..21fef97
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+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.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+
+@Singleton
+public class SetParent implements RestModifyView<ProjectResource, ParentInput> {
+  private final ProjectCache cache;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.Server updateFactory;
+  private final AllProjectsName allProjects;
+  private final AllUsersName allUsers;
+
+  @Inject
+  SetParent(
+      ProjectCache cache,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.Server updateFactory,
+      AllProjectsName allProjects,
+      AllUsersName allUsers) {
+    this.cache = cache;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.allProjects = allProjects;
+    this.allUsers = allUsers;
+  }
+
+  @Override
+  public String apply(ProjectResource rsrc, ParentInput input)
+      throws AuthException, ResourceConflictException, ResourceNotFoundException,
+          UnprocessableEntityException, IOException, PermissionBackendException,
+          BadRequestException {
+    return apply(rsrc, input, true);
+  }
+
+  public String apply(ProjectResource rsrc, ParentInput input, boolean checkIfAdmin)
+      throws AuthException, ResourceConflictException, ResourceNotFoundException,
+          UnprocessableEntityException, IOException, PermissionBackendException,
+          BadRequestException {
+    IdentifiedUser user = rsrc.getUser().asIdentifiedUser();
+    String parentName =
+        MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
+    validateParentUpdate(rsrc.getProjectState().getNameKey(), user, parentName, checkIfAdmin);
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
+      ProjectConfig config = ProjectConfig.read(md);
+      Project project = config.getProject();
+      project.setParentName(parentName);
+
+      String msg = Strings.emptyToNull(input.commitMessage);
+      if (msg == null) {
+        msg = String.format("Changed parent to %s.\n", parentName);
+      } else if (!msg.endsWith("\n")) {
+        msg += "\n";
+      }
+      md.setAuthor(user);
+      md.setMessage(msg);
+      config.commit(md);
+      cache.evict(rsrc.getProjectState().getProject());
+
+      Project.NameKey parent = project.getParent(allProjects);
+      checkNotNull(parent);
+      return parent.get();
+    } catch (RepositoryNotFoundException notFound) {
+      throw new ResourceNotFoundException(rsrc.getName());
+    } catch (ConfigInvalidException e) {
+      throw new ResourceConflictException(
+          String.format("invalid project.config: %s", e.getMessage()));
+    }
+  }
+
+  public void validateParentUpdate(
+      Project.NameKey project, IdentifiedUser user, String newParent, boolean checkIfAdmin)
+      throws AuthException, ResourceConflictException, UnprocessableEntityException,
+          PermissionBackendException, BadRequestException {
+    if (checkIfAdmin) {
+      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+
+    if (project.equals(allUsers) && !allProjects.get().equals(newParent)) {
+      throw new BadRequestException(
+          String.format("%s must inherit from %s", allUsers.get(), allProjects.get()));
+    }
+
+    if (project.equals(allProjects)) {
+      throw new ResourceConflictException("cannot set parent of " + allProjects.get());
+    }
+
+    newParent = Strings.emptyToNull(newParent);
+    if (newParent != null) {
+      ProjectState parent = cache.get(new Project.NameKey(newParent));
+      if (parent == null) {
+        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 -> {
+                return p.getNameKey().equals(project);
+              })
+          .isPresent()) {
+        throw new ResourceConflictException(
+            "cycle exists between " + project.get() + " and " + parent.getName());
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/TagsCollection.java b/java/com/google/gerrit/server/restapi/project/TagsCollection.java
new file mode 100644
index 0000000..a1b0395
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/TagsCollection.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.TagResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class TagsCollection
+    implements ChildCollection<ProjectResource, TagResource>, AcceptsCreate<ProjectResource> {
+  private final DynamicMap<RestView<TagResource>> views;
+  private final Provider<ListTags> list;
+  private final CreateTag.Factory createTagFactory;
+
+  @Inject
+  public TagsCollection(
+      DynamicMap<RestView<TagResource>> views,
+      Provider<ListTags> list,
+      CreateTag.Factory createTagFactory) {
+    this.views = views;
+    this.list = list;
+    this.createTagFactory = createTagFactory;
+  }
+
+  @Override
+  public RestView<ProjectResource> list() throws ResourceNotFoundException {
+    return list.get();
+  }
+
+  @Override
+  public TagResource parse(ProjectResource rsrc, IdString id)
+      throws ResourceNotFoundException, IOException {
+    return new TagResource(rsrc.getProjectState(), rsrc.getUser(), list.get().get(rsrc, id));
+  }
+
+  @Override
+  public DynamicMap<RestView<TagResource>> views() {
+    return views;
+  }
+
+  @Override
+  public CreateTag create(ProjectResource resource, IdString name) {
+    return createTagFactory.create(name.get());
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/PredicateClassLoader.java b/java/com/google/gerrit/server/rules/PredicateClassLoader.java
new file mode 100644
index 0000000..2589253
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/PredicateClassLoader.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.rules;
+
+import com.google.common.collect.LinkedHashMultimap;
+import com.google.common.collect.SetMultimap;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import java.util.Collection;
+
+/** Loads the classes for Prolog predicates. */
+public class PredicateClassLoader extends ClassLoader {
+
+  private final SetMultimap<String, ClassLoader> packageClassLoaderMap =
+      LinkedHashMultimap.create();
+
+  public PredicateClassLoader(
+      final DynamicSet<PredicateProvider> predicateProviders, ClassLoader parent) {
+    super(parent);
+
+    for (PredicateProvider predicateProvider : predicateProviders) {
+      for (String pkg : predicateProvider.getPackages()) {
+        packageClassLoaderMap.put(pkg, predicateProvider.getClass().getClassLoader());
+      }
+    }
+  }
+
+  @Override
+  protected Class<?> findClass(String className) throws ClassNotFoundException {
+    final Collection<ClassLoader> classLoaders =
+        packageClassLoaderMap.get(getPackageName(className));
+    for (ClassLoader cl : classLoaders) {
+      try {
+        return Class.forName(className, true, cl);
+      } catch (ClassNotFoundException e) {
+        // ignore
+      }
+    }
+    throw new ClassNotFoundException(className);
+  }
+
+  private static String getPackageName(String className) {
+    final int pos = className.lastIndexOf('.');
+    if (pos < 0) {
+      return "";
+    }
+    return className.substring(0, pos);
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/PredicateProvider.java b/java/com/google/gerrit/server/rules/PredicateProvider.java
new file mode 100644
index 0000000..57ca7cd
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/PredicateProvider.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.rules;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.googlecode.prolog_cafe.lang.Predicate;
+
+/**
+ * Provides additional packages that contain Prolog predicates that should be made available in the
+ * Prolog environment. The predicates can e.g. be used in the project submit rules.
+ *
+ * <p>Each Java class defining a Prolog predicate must be in one of the provided packages and its
+ * name must apply to the 'PRED_[functor]_[arity]' format. In addition it must extend {@link
+ * Predicate}.
+ */
+@ExtensionPoint
+public interface PredicateProvider {
+  /** Return set of packages that contain Prolog predicates */
+  ImmutableSet<String> getPackages();
+}
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/PrologEnvironment.java
new file mode 100644
index 0000000..170ff23
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/PrologEnvironment.java
@@ -0,0 +1,244 @@
+// 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.rules;
+
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import com.googlecode.prolog_cafe.lang.BufferingPrologControl;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.PredicateEncoder;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Per-thread Prolog interpreter.
+ *
+ * <p>This class is not thread safe.
+ *
+ * <p>A single copy of the Prolog interpreter, for the current thread.
+ */
+public class PrologEnvironment extends BufferingPrologControl {
+  private static final Logger log = LoggerFactory.getLogger(PrologEnvironment.class);
+
+  public interface Factory {
+    /**
+     * Construct a new Prolog interpreter.
+     *
+     * @param src the machine to template the new environment from.
+     * @return the new interpreter.
+     */
+    PrologEnvironment create(PrologMachineCopy src);
+  }
+
+  private final Args args;
+  private final Map<StoredValue<Object>, Object> storedValues;
+  private List<Runnable> cleanup;
+
+  @Inject
+  PrologEnvironment(Args a, @Assisted PrologMachineCopy src) {
+    super(src);
+    setEnabled(EnumSet.allOf(Prolog.Feature.class), false);
+    args = a;
+    storedValues = new HashMap<>();
+    cleanup = new LinkedList<>();
+  }
+
+  public Args getArgs() {
+    return args;
+  }
+
+  @Override
+  public void setPredicate(Predicate goal) {
+    super.setPredicate(goal);
+    setReductionLimit(args.reductionLimit(goal));
+  }
+
+  /**
+   * Lookup a stored value in the interpreter's hash manager.
+   *
+   * @param <T> type of stored Java object.
+   * @param sv unique key.
+   * @return the value; null if not stored.
+   */
+  @SuppressWarnings("unchecked")
+  public <T> T get(StoredValue<T> sv) {
+    return (T) storedValues.get(sv);
+  }
+
+  /**
+   * Set a stored value on the interpreter's hash manager.
+   *
+   * @param <T> type of stored Java object.
+   * @param sv unique key.
+   * @param obj the value to store under {@code sv}.
+   */
+  @SuppressWarnings("unchecked")
+  public <T> void set(StoredValue<T> sv, T obj) {
+    storedValues.put((StoredValue<Object>) sv, obj);
+  }
+
+  /**
+   * Copy the stored values from another interpreter to this one. Also gets the cleanup from the
+   * child interpreter
+   */
+  public void copyStoredValues(PrologEnvironment child) {
+    storedValues.putAll(child.storedValues);
+    setCleanup(child.cleanup);
+  }
+
+  /**
+   * Assign the environment a cleanup list (in order to use a centralized list) If this
+   * enivronment's list is non-empty, append its cleanup tasks to the assigning list.
+   */
+  public void setCleanup(List<Runnable> newCleanupList) {
+    newCleanupList.addAll(cleanup);
+    cleanup = newCleanupList;
+  }
+
+  /**
+   * Adds cleanup task to run when close() is called
+   *
+   * @param task is run when close() is called
+   */
+  public void addToCleanup(Runnable task) {
+    cleanup.add(task);
+  }
+
+  /** Release resources stored in interpreter's hash manager. */
+  public void close() {
+    for (Iterator<Runnable> i = cleanup.iterator(); i.hasNext(); ) {
+      try {
+        i.next().run();
+      } catch (Throwable err) {
+        log.error("Failed to execute cleanup for PrologEnvironment", err);
+      }
+      i.remove();
+    }
+  }
+
+  @Singleton
+  public static class Args {
+    private static final Class<Predicate> CONSULT_STREAM_2;
+
+    static {
+      try {
+        @SuppressWarnings("unchecked")
+        Class<Predicate> c =
+            (Class<Predicate>)
+                Class.forName(
+                    PredicateEncoder.encode(Prolog.BUILTIN, "consult_stream", 2),
+                    false,
+                    RulesCache.class.getClassLoader());
+        CONSULT_STREAM_2 = c;
+      } catch (ClassNotFoundException e) {
+        throw new LinkageError("cannot find predicate consult_stream", e);
+      }
+    }
+
+    private final ProjectCache projectCache;
+    private final PermissionBackend permissionBackend;
+    private final GitRepositoryManager repositoryManager;
+    private final PatchListCache patchListCache;
+    private final PatchSetInfoFactory patchSetInfoFactory;
+    private final IdentifiedUser.GenericFactory userFactory;
+    private final Provider<AnonymousUser> anonymousUser;
+    private final int reductionLimit;
+    private final int compileLimit;
+
+    @Inject
+    Args(
+        ProjectCache projectCache,
+        PermissionBackend permissionBackend,
+        GitRepositoryManager repositoryManager,
+        PatchListCache patchListCache,
+        PatchSetInfoFactory patchSetInfoFactory,
+        IdentifiedUser.GenericFactory userFactory,
+        Provider<AnonymousUser> anonymousUser,
+        @GerritServerConfig Config config) {
+      this.projectCache = projectCache;
+      this.permissionBackend = permissionBackend;
+      this.repositoryManager = repositoryManager;
+      this.patchListCache = patchListCache;
+      this.patchSetInfoFactory = patchSetInfoFactory;
+      this.userFactory = userFactory;
+      this.anonymousUser = anonymousUser;
+
+      int limit = config.getInt("rules", null, "reductionLimit", 100000);
+      reductionLimit = limit <= 0 ? Integer.MAX_VALUE : limit;
+
+      limit =
+          config.getInt(
+              "rules",
+              null,
+              "compileReductionLimit",
+              (int) Math.min(10L * limit, Integer.MAX_VALUE));
+      compileLimit = limit <= 0 ? Integer.MAX_VALUE : limit;
+    }
+
+    private int reductionLimit(Predicate goal) {
+      if (goal.getClass() == CONSULT_STREAM_2) {
+        return compileLimit;
+      }
+      return reductionLimit;
+    }
+
+    public ProjectCache getProjectCache() {
+      return projectCache;
+    }
+
+    public PermissionBackend getPermissionBackend() {
+      return permissionBackend;
+    }
+
+    public GitRepositoryManager getGitRepositoryManager() {
+      return repositoryManager;
+    }
+
+    public PatchListCache getPatchListCache() {
+      return patchListCache;
+    }
+
+    public PatchSetInfoFactory getPatchSetInfoFactory() {
+      return patchSetInfoFactory;
+    }
+
+    public IdentifiedUser.GenericFactory getUserFactory() {
+      return userFactory;
+    }
+
+    public AnonymousUser getAnonymousUser() {
+      return anonymousUser.get();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/PrologModule.java b/java/com/google/gerrit/server/rules/PrologModule.java
new file mode 100644
index 0000000..1a8b46c
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/PrologModule.java
@@ -0,0 +1,34 @@
+// 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.rules;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicSet;
+
+public class PrologModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    install(new EnvironmentModule());
+    bind(PrologEnvironment.Args.class);
+  }
+
+  static class EnvironmentModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      DynamicSet.setOf(binder(), PredicateProvider.class);
+      factory(PrologEnvironment.Factory.class);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/RulesCache.java b/java/com/google/gerrit/server/rules/RulesCache.java
new file mode 100644
index 0000000..d7a614d
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/RulesCache.java
@@ -0,0 +1,293 @@
+// 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.rules;
+
+import static com.googlecode.prolog_cafe.lang.PrologMachineCopy.save;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.googlecode.prolog_cafe.exceptions.CompileException;
+import com.googlecode.prolog_cafe.exceptions.SyntaxException;
+import com.googlecode.prolog_cafe.exceptions.TermException;
+import com.googlecode.prolog_cafe.lang.BufferingPrologControl;
+import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
+import com.googlecode.prolog_cafe.lang.ListTerm;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.PrologClassLoader;
+import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import java.io.IOException;
+import java.io.PushbackReader;
+import java.io.Reader;
+import java.io.StringReader;
+import java.lang.ref.Reference;
+import java.lang.ref.ReferenceQueue;
+import java.lang.ref.WeakReference;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.errors.LargeObjectException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.util.RawParseUtils;
+
+/**
+ * Manages a cache of compiled Prolog rules.
+ *
+ * <p>Rules are loaded from the {@code site_path/cache/rules/rules-SHA1.jar}, where {@code SHA1} is
+ * the SHA1 of the Prolog {@code rules.pl} in a project's {@link RefNames#REFS_CONFIG} branch.
+ */
+@Singleton
+public class RulesCache {
+  private static final ImmutableList<String> PACKAGE_LIST =
+      ImmutableList.of(Prolog.BUILTIN, "gerrit");
+
+  private static final class MachineRef extends WeakReference<PrologMachineCopy> {
+    final ObjectId key;
+
+    MachineRef(ObjectId key, PrologMachineCopy pcm, ReferenceQueue<PrologMachineCopy> queue) {
+      super(pcm, queue);
+      this.key = key;
+    }
+  }
+
+  private final boolean enableProjectRules;
+  private final int maxDbSize;
+  private final int maxSrcBytes;
+  private final Path cacheDir;
+  private final Path rulesDir;
+  private final GitRepositoryManager gitMgr;
+  private final DynamicSet<PredicateProvider> predicateProviders;
+  private final ClassLoader systemLoader;
+  private final PrologMachineCopy defaultMachine;
+  private final Map<ObjectId, MachineRef> machineCache = new HashMap<>();
+  private final ReferenceQueue<PrologMachineCopy> dead = new ReferenceQueue<>();
+
+  @Inject
+  protected RulesCache(
+      @GerritServerConfig Config config,
+      SitePaths site,
+      GitRepositoryManager gm,
+      DynamicSet<PredicateProvider> predicateProviders) {
+    maxDbSize = config.getInt("rules", null, "maxPrologDatabaseSize", 256);
+    maxSrcBytes = config.getInt("rules", null, "maxSourceBytes", 128 << 10);
+    enableProjectRules = config.getBoolean("rules", null, "enable", true) && maxSrcBytes > 0;
+    cacheDir = site.resolve(config.getString("cache", null, "directory"));
+    rulesDir = cacheDir != null ? cacheDir.resolve("rules") : null;
+    gitMgr = gm;
+    this.predicateProviders = predicateProviders;
+
+    systemLoader = getClass().getClassLoader();
+    defaultMachine = save(newEmptyMachine(systemLoader));
+  }
+
+  public boolean isProjectRulesEnabled() {
+    return enableProjectRules;
+  }
+
+  /**
+   * Locate a cached Prolog machine state, or create one if not available.
+   *
+   * @return a Prolog machine, after loading the specified rules.
+   * @throws CompileException the machine cannot be created.
+   */
+  public synchronized PrologMachineCopy loadMachine(Project.NameKey project, ObjectId rulesId)
+      throws CompileException {
+    if (!enableProjectRules || project == null || rulesId == null) {
+      return defaultMachine;
+    }
+
+    Reference<? extends PrologMachineCopy> ref = machineCache.get(rulesId);
+    if (ref != null) {
+      PrologMachineCopy pmc = ref.get();
+      if (pmc != null) {
+        return pmc;
+      }
+
+      machineCache.remove(rulesId);
+      ref.enqueue();
+    }
+
+    gc();
+
+    PrologMachineCopy pcm = createMachine(project, rulesId);
+    MachineRef newRef = new MachineRef(rulesId, pcm, dead);
+    machineCache.put(rulesId, newRef);
+    return pcm;
+  }
+
+  public PrologMachineCopy loadMachine(String name, Reader in) throws CompileException {
+    PrologMachineCopy pmc = consultRules(name, in);
+    if (pmc == null) {
+      throw new CompileException("Cannot consult rules from the stream " + name);
+    }
+    return pmc;
+  }
+
+  private void gc() {
+    Reference<?> ref;
+    while ((ref = dead.poll()) != null) {
+      ObjectId key = ((MachineRef) ref).key;
+      if (machineCache.get(key) == ref) {
+        machineCache.remove(key);
+      }
+    }
+  }
+
+  private PrologMachineCopy createMachine(Project.NameKey project, ObjectId rulesId)
+      throws CompileException {
+    // If the rules are available as a complied JAR on local disk, prefer
+    // that over dynamic consult as the bytecode will be faster.
+    //
+    if (rulesDir != null) {
+      Path jarPath = rulesDir.resolve("rules-" + rulesId.getName() + ".jar");
+      if (Files.isRegularFile(jarPath)) {
+        URL[] cp = new URL[] {toURL(jarPath)};
+        return save(newEmptyMachine(new URLClassLoader(cp, systemLoader)));
+      }
+    }
+
+    // Dynamically consult the rules into the machine's internal database.
+    //
+    String rules = read(project, rulesId);
+    PrologMachineCopy pmc = consultRules("rules.pl", new StringReader(rules));
+    if (pmc == null) {
+      throw new CompileException("Cannot consult rules of " + project);
+    }
+    return pmc;
+  }
+
+  private PrologMachineCopy consultRules(String name, Reader rules) throws CompileException {
+    BufferingPrologControl ctl = newEmptyMachine(systemLoader);
+    PushbackReader in = new PushbackReader(rules, Prolog.PUSHBACK_SIZE);
+    try {
+      if (!ctl.execute(
+          Prolog.BUILTIN, "consult_stream", SymbolTerm.intern(name), new JavaObjectTerm(in))) {
+        return null;
+      }
+    } catch (SyntaxException e) {
+      throw new CompileException(e.toString(), e);
+    } catch (TermException e) {
+      Term m = e.getMessageTerm();
+      if (m instanceof StructureTerm && "syntax_error".equals(m.name()) && m.arity() >= 1) {
+        StringBuilder msg = new StringBuilder();
+        if (m.arg(0) instanceof ListTerm) {
+          msg.append(Joiner.on(' ').join(((ListTerm) m.arg(0)).toJava()));
+        } else {
+          msg.append(m.arg(0).toString());
+        }
+        if (m.arity() == 2 && m.arg(1) instanceof StructureTerm && "at".equals(m.arg(1).name())) {
+          Term at = m.arg(1).arg(0).dereference();
+          if (at instanceof ListTerm) {
+            msg.append(" at: ");
+            msg.append(prettyProlog(at));
+          }
+        }
+        throw new CompileException(msg.toString(), e);
+      }
+      throw new CompileException("Error while consulting rules from " + name, e);
+    } catch (RuntimeException e) {
+      throw new CompileException("Error while consulting rules from " + name, e);
+    }
+    return save(ctl);
+  }
+
+  private static String prettyProlog(Term at) {
+    StringBuilder b = new StringBuilder();
+    for (Object o : ((ListTerm) at).toJava()) {
+      if (o instanceof Term) {
+        Term t = (Term) o;
+        if (!(t instanceof StructureTerm)) {
+          b.append(t.toString()).append(' ');
+          continue;
+        }
+        switch (t.name()) {
+          case "atom":
+            SymbolTerm atom = (SymbolTerm) t.arg(0);
+            b.append(atom.toString());
+            break;
+          case "var":
+            b.append(t.arg(0).toString());
+            break;
+        }
+      } else {
+        b.append(o);
+      }
+    }
+    return b.toString().trim();
+  }
+
+  private String read(Project.NameKey project, ObjectId rulesId) throws CompileException {
+    try (Repository git = gitMgr.openRepository(project)) {
+      try {
+        ObjectLoader ldr = git.open(rulesId, Constants.OBJ_BLOB);
+        byte[] raw = ldr.getCachedBytes(maxSrcBytes);
+        return RawParseUtils.decode(raw);
+      } catch (LargeObjectException e) {
+        throw new CompileException("rules of " + project + " are too large", e);
+      } catch (RuntimeException | IOException e) {
+        throw new CompileException("Cannot load rules of " + project, e);
+      }
+    } catch (IOException e) {
+      throw new CompileException("Cannot open repository " + project, e);
+    }
+  }
+
+  private BufferingPrologControl newEmptyMachine(ClassLoader cl) {
+    BufferingPrologControl ctl = new BufferingPrologControl();
+    ctl.setMaxDatabaseSize(maxDbSize);
+    ctl.setPrologClassLoader(
+        new PrologClassLoader(new PredicateClassLoader(predicateProviders, cl)));
+    ctl.setEnabled(EnumSet.allOf(Prolog.Feature.class), false);
+
+    List<String> packages = new ArrayList<>();
+    packages.addAll(PACKAGE_LIST);
+    for (PredicateProvider predicateProvider : predicateProviders) {
+      packages.addAll(predicateProvider.getPackages());
+    }
+
+    // Bootstrap the interpreter and ensure there is clean state.
+    ctl.initialize(packages.toArray(new String[packages.size()]));
+    return ctl;
+  }
+
+  private static URL toURL(Path jarPath) throws CompileException {
+    try {
+      return jarPath.toUri().toURL();
+    } catch (MalformedURLException e) {
+      throw new CompileException("Cannot create URL for " + jarPath, e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/StoredValue.java b/java/com/google/gerrit/server/rules/StoredValue.java
new file mode 100644
index 0000000..c3bc53f
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/StoredValue.java
@@ -0,0 +1,94 @@
+// 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.rules;
+
+import com.googlecode.prolog_cafe.exceptions.SystemException;
+import com.googlecode.prolog_cafe.lang.Prolog;
+
+/**
+ * Defines a value cached in a {@link PrologEnvironment}.
+ *
+ * @see StoredValues
+ */
+public class StoredValue<T> {
+  /** Construct a new unique key that does not match any other key. */
+  public static <T> StoredValue<T> create() {
+    return new StoredValue<>();
+  }
+
+  /** Construct a key based on a Java Class object, useful for singletons. */
+  public static <T> StoredValue<T> create(Class<T> clazz) {
+    return new StoredValue<>(clazz);
+  }
+
+  private final Object key;
+
+  /**
+   * Initialize a stored value key using any Java Object.
+   *
+   * @param key unique identity of the stored value. This will be the hash key in the Prolog
+   *     Environments's hash map.
+   */
+  public StoredValue(Object key) {
+    this.key = key;
+  }
+
+  /** Initializes a stored value key with a new unique key. */
+  public StoredValue() {
+    key = this;
+  }
+
+  /** Look up the value in the engine, or return null. */
+  public T getOrNull(Prolog engine) {
+    return get((PrologEnvironment) engine.control);
+  }
+  /** Get the value from the engine, or throw SystemException. */
+  public T get(Prolog engine) {
+    T obj = getOrNull(engine);
+    if (obj == null) {
+      //unless createValue() is overridden, will return null
+      obj = createValue(engine);
+      if (obj == null) {
+        throw new SystemException("No " + key + " available");
+      }
+      set(engine, obj);
+    }
+    return obj;
+  }
+
+  public void set(Prolog engine, T obj) {
+    set((PrologEnvironment) engine.control, obj);
+  }
+
+  /** Perform {@link #getOrNull(Prolog)} on the environment's interpreter. */
+  public T get(PrologEnvironment env) {
+    return env.get(this);
+  }
+
+  /** Set the value into the environment's interpreter. */
+  public void set(PrologEnvironment env, T obj) {
+    env.set(this, obj);
+  }
+
+  /**
+   * Creates a value to store, returns null by default.
+   *
+   * @param engine Prolog engine.
+   * @return new value.
+   */
+  protected T createValue(Prolog engine) {
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
new file mode 100644
index 0000000..287845d
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -0,0 +1,162 @@
+// 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.rules;
+
+import static com.google.gerrit.server.rules.StoredValue.create;
+
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.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.Accounts;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.googlecode.prolog_cafe.exceptions.SystemException;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+public final class StoredValues {
+  public static final StoredValue<Accounts> ACCOUNTS = create(Accounts.class);
+  public static final StoredValue<AccountCache> ACCOUNT_CACHE = create(AccountCache.class);
+  public static final StoredValue<Emails> EMAILS = create(Emails.class);
+  public static final StoredValue<ReviewDb> REVIEW_DB = create(ReviewDb.class);
+  public static final StoredValue<ChangeData> CHANGE_DATA = create(ChangeData.class);
+  public static final StoredValue<CurrentUser> CURRENT_USER = create(CurrentUser.class);
+  public static final StoredValue<ProjectState> PROJECT_STATE = create(ProjectState.class);
+
+  public static Change getChange(Prolog engine) throws SystemException {
+    ChangeData cd = CHANGE_DATA.get(engine);
+    try {
+      return cd.change();
+    } catch (OrmException e) {
+      throw new SystemException("Cannot load change " + cd.getId());
+    }
+  }
+
+  public static PatchSet getPatchSet(Prolog engine) throws SystemException {
+    ChangeData cd = CHANGE_DATA.get(engine);
+    try {
+      return cd.currentPatchSet();
+    } catch (OrmException e) {
+      throw new SystemException(e.getMessage());
+    }
+  }
+
+  public static final StoredValue<PatchSetInfo> PATCH_SET_INFO =
+      new StoredValue<PatchSetInfo>() {
+        @Override
+        public PatchSetInfo createValue(Prolog engine) {
+          Change change = getChange(engine);
+          PatchSet ps = getPatchSet(engine);
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          PatchSetInfoFactory patchInfoFactory = env.getArgs().getPatchSetInfoFactory();
+          try {
+            return patchInfoFactory.get(change.getProject(), ps);
+          } catch (PatchSetInfoNotAvailableException e) {
+            throw new SystemException(e.getMessage());
+          }
+        }
+      };
+
+  public static final StoredValue<PatchList> PATCH_LIST =
+      new StoredValue<PatchList>() {
+        @Override
+        public PatchList createValue(Prolog engine) {
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          PatchSet ps = getPatchSet(engine);
+          PatchListCache plCache = env.getArgs().getPatchListCache();
+          Change change = getChange(engine);
+          Project.NameKey project = change.getProject();
+          ObjectId b = ObjectId.fromString(ps.getRevision().get());
+          Whitespace ws = Whitespace.IGNORE_NONE;
+          PatchListKey plKey = PatchListKey.againstDefaultBase(b, ws);
+          PatchList patchList;
+          try {
+            patchList = plCache.get(plKey, project);
+          } catch (PatchListNotAvailableException e) {
+            throw new SystemException("Cannot create " + plKey);
+          }
+          return patchList;
+        }
+      };
+
+  public static final StoredValue<Repository> REPOSITORY =
+      new StoredValue<Repository>() {
+        @Override
+        public Repository createValue(Prolog engine) {
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          GitRepositoryManager gitMgr = env.getArgs().getGitRepositoryManager();
+          Change change = getChange(engine);
+          Project.NameKey projectKey = change.getProject();
+          Repository repo;
+          try {
+            repo = gitMgr.openRepository(projectKey);
+          } catch (IOException e) {
+            throw new SystemException(e.getMessage());
+          }
+          env.addToCleanup(repo::close);
+          return repo;
+        }
+      };
+
+  public static final StoredValue<PermissionBackend> PERMISSION_BACKEND =
+      new StoredValue<PermissionBackend>() {
+        @Override
+        protected PermissionBackend createValue(Prolog engine) {
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          return env.getArgs().getPermissionBackend();
+        }
+      };
+
+  public static final StoredValue<AnonymousUser> ANONYMOUS_USER =
+      new StoredValue<AnonymousUser>() {
+        @Override
+        protected AnonymousUser createValue(Prolog engine) {
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          return env.getArgs().getAnonymousUser();
+        }
+      };
+
+  public static final StoredValue<Map<Account.Id, IdentifiedUser>> USERS =
+      new StoredValue<Map<Account.Id, IdentifiedUser>>() {
+        @Override
+        protected Map<Account.Id, IdentifiedUser> createValue(Prolog engine) {
+          return new HashMap<>();
+        }
+      };
+
+  private StoredValues() {}
+}
diff --git a/java/com/google/gerrit/server/schema/AbstractDisabledAccess.java b/java/com/google/gerrit/server/schema/AbstractDisabledAccess.java
new file mode 100644
index 0000000..17eb56e
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/AbstractDisabledAccess.java
@@ -0,0 +1,141 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.Futures;
+import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gwtorm.client.Key;
+import com.google.gwtorm.server.Access;
+import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gwtorm.server.ListResultSet;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import java.util.Map;
+import java.util.function.Function;
+
+abstract class AbstractDisabledAccess<T, K extends Key<?>> implements Access<T, K> {
+  private static <T> ResultSet<T> empty() {
+    return new ListResultSet<>(ImmutableList.of());
+  }
+
+  @SuppressWarnings("deprecation")
+  private static <T>
+      com.google.common.util.concurrent.CheckedFuture<T, OrmException> emptyFuture() {
+    return Futures.immediateCheckedFuture(null);
+  }
+
+  // Don't even hold a reference to delegate, so it's not possible to use it
+  // accidentally.
+  private final ReviewDbWrapper wrapper;
+  private final String relationName;
+  private final int relationId;
+  private final Function<T, K> primaryKey;
+  private final Function<Iterable<T>, Map<K, T>> toMap;
+
+  AbstractDisabledAccess(ReviewDbWrapper wrapper, Access<T, K> delegate) {
+    this.wrapper = wrapper;
+    this.relationName = delegate.getRelationName();
+    this.relationId = delegate.getRelationID();
+    this.primaryKey = delegate::primaryKey;
+    this.toMap = delegate::toMap;
+  }
+
+  @Override
+  public final int getRelationID() {
+    return relationId;
+  }
+
+  @Override
+  public final String getRelationName() {
+    return relationName;
+  }
+
+  @Override
+  public final K primaryKey(T entity) {
+    return primaryKey.apply(entity);
+  }
+
+  @Override
+  public final Map<K, T> toMap(Iterable<T> iterable) {
+    return toMap.apply(iterable);
+  }
+
+  @Override
+  public final ResultSet<T> iterateAllEntities() {
+    return empty();
+  }
+
+  @SuppressWarnings("deprecation")
+  @Override
+  public final com.google.common.util.concurrent.CheckedFuture<T, OrmException> getAsync(K key) {
+    return emptyFuture();
+  }
+
+  @Override
+  public final ResultSet<T> get(Iterable<K> keys) {
+    return empty();
+  }
+
+  @Override
+  public final void insert(Iterable<T> instances) {
+    // Do nothing.
+  }
+
+  @Override
+  public final void update(Iterable<T> instances) {
+    // Do nothing.
+  }
+
+  @Override
+  public final void upsert(Iterable<T> instances) {
+    // Do nothing.
+  }
+
+  @Override
+  public final void deleteKeys(Iterable<K> keys) {
+    // Do nothing.
+  }
+
+  @Override
+  public final void delete(Iterable<T> instances) {
+    // Do nothing.
+  }
+
+  @Override
+  public final void beginTransaction(K key) {
+    // Keep track of when we've started a transaction so that we can avoid calling commit/rollback
+    // on the underlying ReviewDb. This is just a simple arm's-length approach, and may produce
+    // slightly different results from a native ReviewDb in corner cases like:
+    // * beginning transactions on different tables simultaneously
+    // * doing work between commit and rollback
+    // These kinds of things are already misuses of ReviewDb, and shouldn't be happening in current
+    // code anyway.
+    checkState(!wrapper.inTransaction(), "already in transaction");
+    wrapper.beginTransaction();
+  }
+
+  @Override
+  public final T atomicUpdate(K key, AtomicUpdate<T> update) {
+    return null;
+  }
+
+  @Override
+  public final T get(K id) {
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/AclUtil.java b/java/com/google/gerrit/server/schema/AclUtil.java
new file mode 100644
index 0000000..2a7b3b1
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/AclUtil.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.server.git.ProjectConfig;
+
+/**
+ * Contains functions to modify permissions. For all these functions, any of the groups may be null
+ * in which case it is ignored.
+ */
+public class AclUtil {
+  public static void grant(
+      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
+    grant(config, section, permission, false, groupList);
+  }
+
+  public static void grant(
+      ProjectConfig config,
+      AccessSection section,
+      String permission,
+      boolean force,
+      GroupReference... groupList) {
+    grant(config, section, permission, force, null, groupList);
+  }
+
+  public static void grant(
+      ProjectConfig config,
+      AccessSection section,
+      String permission,
+      boolean force,
+      Boolean exclusive,
+      GroupReference... groupList) {
+    Permission p = section.getPermission(permission, true);
+    if (exclusive != null) {
+      p.setExclusiveGroup(exclusive);
+    }
+    for (GroupReference group : groupList) {
+      if (group != null) {
+        PermissionRule r = rule(config, group);
+        r.setForce(force);
+        p.add(r);
+      }
+    }
+  }
+
+  public static void block(
+      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
+    Permission p = section.getPermission(permission, true);
+    for (GroupReference group : groupList) {
+      if (group != null) {
+        PermissionRule r = rule(config, group);
+        r.setBlock();
+        p.add(r);
+      }
+    }
+  }
+
+  public static void grant(
+      ProjectConfig config,
+      AccessSection section,
+      LabelType type,
+      int min,
+      int max,
+      GroupReference... groupList) {
+    grant(config, section, type, min, max, false, groupList);
+  }
+
+  public static void grant(
+      ProjectConfig config,
+      AccessSection section,
+      LabelType type,
+      int min,
+      int max,
+      boolean exclusive,
+      GroupReference... groupList) {
+    String name = Permission.LABEL + type.getName();
+    Permission p = section.getPermission(name, true);
+    p.setExclusiveGroup(exclusive);
+    for (GroupReference group : groupList) {
+      if (group != null) {
+        PermissionRule r = rule(config, group);
+        r.setRange(min, max);
+        p.add(r);
+      }
+    }
+  }
+
+  public static PermissionRule rule(ProjectConfig config, GroupReference group) {
+    return new PermissionRule(config.resolve(group));
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
new file mode 100644
index 0000000..62f8ad1
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -0,0 +1,251 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_SEQUENCES;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.grant;
+import static com.google.gerrit.server.schema.AclUtil.rule;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.Version;
+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.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.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.RepoSequence;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/** Creates the {@code All-Projects} repository and initial ACLs. */
+public class AllProjectsCreator {
+  private final GitRepositoryManager mgr;
+  private final AllProjectsName allProjectsName;
+  private final PersonIdent serverUser;
+  private final NotesMigration notesMigration;
+  private String message;
+  private int firstChangeId = ReviewDb.FIRST_CHANGE_ID;
+
+  @Nullable private GroupReference admin;
+
+  @Nullable private GroupReference batch;
+  private final GroupReference anonymous;
+  private final GroupReference registered;
+  private final GroupReference owners;
+
+  @Inject
+  AllProjectsCreator(
+      GitRepositoryManager mgr,
+      AllProjectsName allProjectsName,
+      SystemGroupBackend systemGroupBackend,
+      @GerritPersonIdent PersonIdent serverUser,
+      NotesMigration notesMigration) {
+    this.mgr = mgr;
+    this.allProjectsName = allProjectsName;
+    this.serverUser = serverUser;
+    this.notesMigration = notesMigration;
+
+    this.anonymous = systemGroupBackend.getGroup(ANONYMOUS_USERS);
+    this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
+    this.owners = systemGroupBackend.getGroup(PROJECT_OWNERS);
+  }
+
+  /** If called, grant default permissions to this admin group */
+  public AllProjectsCreator setAdministrators(GroupReference admin) {
+    this.admin = admin;
+    return this;
+  }
+
+  /** If called, grant stream-events permission and set appropriate priority for this group */
+  public AllProjectsCreator setBatchUsers(GroupReference batch) {
+    this.batch = batch;
+    return this;
+  }
+
+  public AllProjectsCreator setCommitMessage(String message) {
+    this.message = message;
+    return this;
+  }
+
+  public AllProjectsCreator setFirstChangeIdForNoteDb(int id) {
+    checkArgument(id > 0, "id must be positive: %s", id);
+    firstChangeId = id;
+    return this;
+  }
+
+  public void create() throws IOException, ConfigInvalidException {
+    try (Repository git = mgr.openRepository(allProjectsName)) {
+      initAllProjects(git);
+    } catch (RepositoryNotFoundException notFound) {
+      // A repository may be missing if this project existed only to store
+      // inheritable permissions. For example 'All-Projects'.
+      try (Repository git = mgr.createRepository(allProjectsName)) {
+        initAllProjects(git);
+        RefUpdate u = git.updateRef(Constants.HEAD);
+        u.link(RefNames.REFS_CONFIG);
+      } catch (RepositoryNotFoundException err) {
+        String name = allProjectsName.get();
+        throw new IOException("Cannot create repository " + name, err);
+      }
+    }
+  }
+
+  private void initAllProjects(Repository git) throws IOException, ConfigInvalidException {
+    BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
+    try (MetaDataUpdate md =
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, allProjectsName, git, bru)) {
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(
+          MoreObjects.firstNonNull(
+              Strings.emptyToNull(message),
+              "Initialized Gerrit Code Review " + Version.getVersion()));
+
+      ProjectConfig config = ProjectConfig.read(md);
+      Project p = config.getProject();
+      p.setDescription("Access inherited by all other projects.");
+      p.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.TRUE);
+      p.setBooleanConfig(BooleanProjectConfig.USE_CONTENT_MERGE, InheritableBoolean.TRUE);
+      p.setBooleanConfig(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, InheritableBoolean.FALSE);
+      p.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, InheritableBoolean.FALSE);
+      p.setBooleanConfig(BooleanProjectConfig.ENABLE_SIGNED_PUSH, InheritableBoolean.FALSE);
+
+      AccessSection cap = config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true);
+      AccessSection all = config.getAccessSection(AccessSection.ALL, true);
+      AccessSection heads = config.getAccessSection(AccessSection.HEADS, true);
+      AccessSection tags = config.getAccessSection("refs/tags/*", true);
+      AccessSection meta = config.getAccessSection(RefNames.REFS_CONFIG, true);
+      AccessSection refsFor = config.getAccessSection("refs/for/*", true);
+      AccessSection magic = config.getAccessSection("refs/for/" + AccessSection.ALL, true);
+
+      grant(config, cap, GlobalCapability.ADMINISTRATE_SERVER, admin);
+      grant(config, all, Permission.READ, admin, anonymous);
+      grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
+
+      if (batch != null) {
+        Permission priority = cap.getPermission(GlobalCapability.PRIORITY, true);
+        PermissionRule r = rule(config, batch);
+        r.setAction(Action.BATCH);
+        priority.add(r);
+
+        Permission stream = cap.getPermission(GlobalCapability.STREAM_EVENTS, true);
+        stream.add(rule(config, batch));
+      }
+
+      LabelType cr = initCodeReviewLabel(config);
+      grant(config, heads, cr, -1, 1, registered);
+
+      grant(config, heads, cr, -2, 2, admin, owners);
+      grant(config, heads, Permission.CREATE, admin, owners);
+      grant(config, heads, Permission.PUSH, admin, owners);
+      grant(config, heads, Permission.SUBMIT, admin, owners);
+      grant(config, heads, Permission.FORGE_AUTHOR, registered);
+      grant(config, heads, Permission.FORGE_COMMITTER, admin, owners);
+      grant(config, heads, Permission.EDIT_TOPIC_NAME, true, admin, owners);
+
+      grant(config, tags, Permission.CREATE, admin, owners);
+      grant(config, tags, Permission.CREATE_TAG, admin, owners);
+      grant(config, tags, Permission.CREATE_SIGNED_TAG, admin, owners);
+
+      grant(config, magic, Permission.PUSH, registered);
+      grant(config, magic, Permission.PUSH_MERGE, registered);
+
+      meta.getPermission(Permission.READ, true).setExclusiveGroup(true);
+      grant(config, meta, Permission.READ, admin, owners);
+      grant(config, meta, cr, -2, 2, admin, owners);
+      grant(config, meta, Permission.CREATE, admin, owners);
+      grant(config, meta, Permission.PUSH, admin, owners);
+      grant(config, meta, Permission.SUBMIT, admin, owners);
+
+      config.commitToNewRef(md, RefNames.REFS_CONFIG);
+      initSequences(git, bru);
+      execute(git, bru);
+    }
+  }
+
+  public static LabelType initCodeReviewLabel(ProjectConfig c) {
+    LabelType type =
+        new LabelType(
+            "Code-Review",
+            ImmutableList.of(
+                new LabelValue((short) 2, "Looks good to me, approved"),
+                new LabelValue((short) 1, "Looks good to me, but someone else must approve"),
+                new LabelValue((short) 0, "No score"),
+                new LabelValue((short) -1, "I would prefer this is not merged as is"),
+                new LabelValue((short) -2, "This shall not be merged")));
+    type.setCopyMinScore(true);
+    type.setCopyAllScoresOnTrivialRebase(true);
+    c.getLabelSections().put(type.getName(), type);
+    return type;
+  }
+
+  private void initSequences(Repository git, BatchRefUpdate bru) throws IOException {
+    if (notesMigration.readChangeSequence()
+        && git.exactRef(REFS_SEQUENCES + Sequences.NAME_CHANGES) == null) {
+      // Can't easily reuse the inserter from MetaDataUpdate, but this shouldn't slow down site
+      // initialization unduly.
+      try (ObjectInserter ins = git.newObjectInserter()) {
+        bru.addCommand(RepoSequence.storeNew(ins, Sequences.NAME_CHANGES, firstChangeId));
+        ins.flush();
+      }
+    }
+  }
+
+  private void execute(Repository git, BatchRefUpdate bru) throws IOException {
+    try (RevWalk rw = new RevWalk(git)) {
+      bru.execute(rw, NullProgressMonitor.INSTANCE);
+    }
+    for (ReceiveCommand cmd : bru.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        throw new IOException("Failed to initialize " + allProjectsName + " refs:\n" + bru);
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
new file mode 100644
index 0000000..8e38754
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.grant;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.Version;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.RefPattern;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+
+/** Creates the {@code All-Users} repository. */
+public class AllUsersCreator {
+  private final GitRepositoryManager mgr;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverUser;
+  private final GroupReference registered;
+
+  @Nullable private GroupReference admin;
+
+  @Inject
+  AllUsersCreator(
+      GitRepositoryManager mgr,
+      AllUsersName allUsersName,
+      SystemGroupBackend systemGroupBackend,
+      @GerritPersonIdent PersonIdent serverUser) {
+    this.mgr = mgr;
+    this.allUsersName = allUsersName;
+    this.serverUser = serverUser;
+    this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
+  }
+
+  /**
+   * If setAdministrators() is called, grant the given administrator group permissions on the
+   * default user.
+   */
+  public AllUsersCreator setAdministrators(GroupReference admin) {
+    this.admin = admin;
+    return this;
+  }
+
+  public void create() throws IOException, ConfigInvalidException {
+    try (Repository git = mgr.openRepository(allUsersName)) {
+      initAllUsers(git);
+    } catch (RepositoryNotFoundException notFound) {
+      try (Repository git = mgr.createRepository(allUsersName)) {
+        initAllUsers(git);
+        RefUpdate u = git.updateRef(Constants.HEAD);
+        u.link(RefNames.REFS_CONFIG);
+      } catch (RepositoryNotFoundException err) {
+        String name = allUsersName.get();
+        throw new IOException("Cannot create repository " + name, err);
+      }
+    }
+  }
+
+  private void initAllUsers(Repository git) throws IOException, ConfigInvalidException {
+    try (MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage("Initialized Gerrit Code Review " + Version.getVersion());
+
+      ProjectConfig config = ProjectConfig.read(md);
+      Project project = config.getProject();
+      project.setDescription("Individual user settings and preferences.");
+
+      AccessSection users =
+          config.getAccessSection(
+              RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true);
+      LabelType cr = AllProjectsCreator.initCodeReviewLabel(config);
+      grant(config, users, Permission.READ, false, true, registered);
+      grant(config, users, Permission.PUSH, false, true, registered);
+      grant(config, users, Permission.SUBMIT, false, true, registered);
+      grant(config, users, cr, -2, 2, true, registered);
+
+      if (admin != null) {
+        AccessSection defaults = config.getAccessSection(RefNames.REFS_USERS_DEFAULT, true);
+        defaults.getPermission(Permission.READ, true).setExclusiveGroup(true);
+        grant(config, defaults, Permission.READ, admin);
+        defaults.getPermission(Permission.PUSH, true).setExclusiveGroup(true);
+        grant(config, defaults, Permission.PUSH, admin);
+        defaults.getPermission(Permission.CREATE, true).setExclusiveGroup(true);
+        grant(config, defaults, Permission.CREATE, admin);
+      }
+
+      // Grant read permissions on the group branches to all users.
+      // This allows group owners to see the group refs. VisibleRefFilter ensures that read
+      // permissions for non-group-owners are ignored.
+      AccessSection groups = config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
+      grant(config, groups, Permission.READ, false, true, registered);
+
+      config.commit(md);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/BUILD b/java/com/google/gerrit/server/schema/BUILD
new file mode 100644
index 0000000..2292234
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/BUILD
@@ -0,0 +1,27 @@
+java_library(
+    name = "schema",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/org/eclipse/jgit:server",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib/auto:auto-value",
+        "//lib/commons:dbcp",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//lib/log:jsonevent-layout",
+        "//lib/log:log4j",
+    ],
+)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/BaseDataSourceType.java b/java/com/google/gerrit/server/schema/BaseDataSourceType.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/BaseDataSourceType.java
rename to java/com/google/gerrit/server/schema/BaseDataSourceType.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DB2.java b/java/com/google/gerrit/server/schema/DB2.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/DB2.java
rename to java/com/google/gerrit/server/schema/DB2.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceModule.java b/java/com/google/gerrit/server/schema/DataSourceModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceModule.java
rename to java/com/google/gerrit/server/schema/DataSourceModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java b/java/com/google/gerrit/server/schema/DataSourceProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
rename to java/com/google/gerrit/server/schema/DataSourceProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java b/java/com/google/gerrit/server/schema/DataSourceType.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java
rename to java/com/google/gerrit/server/schema/DataSourceType.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DatabaseModule.java b/java/com/google/gerrit/server/schema/DatabaseModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/DatabaseModule.java
rename to java/com/google/gerrit/server/schema/DatabaseModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Derby.java b/java/com/google/gerrit/server/schema/Derby.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Derby.java
rename to java/com/google/gerrit/server/schema/Derby.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2.java b/java/com/google/gerrit/server/schema/H2.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/H2.java
rename to java/com/google/gerrit/server/schema/H2.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
rename to java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/HANA.java b/java/com/google/gerrit/server/schema/HANA.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/HANA.java
rename to java/com/google/gerrit/server/schema/HANA.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/InMemoryAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/InMemoryAccountPatchReviewStore.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/InMemoryAccountPatchReviewStore.java
rename to java/com/google/gerrit/server/schema/InMemoryAccountPatchReviewStore.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/JDBC.java b/java/com/google/gerrit/server/schema/JDBC.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/JDBC.java
rename to java/com/google/gerrit/server/schema/JDBC.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
rename to java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/JdbcUtil.java b/java/com/google/gerrit/server/schema/JdbcUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/JdbcUtil.java
rename to java/com/google/gerrit/server/schema/JdbcUtil.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDBAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/MariaDBAccountPatchReviewStore.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDBAccountPatchReviewStore.java
rename to java/com/google/gerrit/server/schema/MariaDBAccountPatchReviewStore.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDb.java b/java/com/google/gerrit/server/schema/MariaDb.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDb.java
rename to java/com/google/gerrit/server/schema/MariaDb.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MaxDb.java b/java/com/google/gerrit/server/schema/MaxDb.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/MaxDb.java
rename to java/com/google/gerrit/server/schema/MaxDb.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MySql.java b/java/com/google/gerrit/server/schema/MySql.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/MySql.java
rename to java/com/google/gerrit/server/schema/MySql.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java
rename to java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java
diff --git a/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java b/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
new file mode 100644
index 0000000..7247490
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
@@ -0,0 +1,214 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ChangeAccess;
+import com.google.gerrit.reviewdb.server.ChangeMessageAccess;
+import com.google.gerrit.reviewdb.server.PatchLineCommentAccess;
+import com.google.gerrit.reviewdb.server.PatchSetAccess;
+import com.google.gerrit.reviewdb.server.PatchSetApprovalAccess;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gwtorm.server.ListResultSet;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+/**
+ * Wrapper for ReviewDb that never calls the underlying change tables.
+ *
+ * <p>See {@link NotesMigrationSchemaFactory} for discussion.
+ */
+class NoChangesReviewDbWrapper extends ReviewDbWrapper {
+  private static <T> ResultSet<T> empty() {
+    return new ListResultSet<>(ImmutableList.of());
+  }
+
+  private final ChangeAccess changes;
+  private final PatchSetApprovalAccess patchSetApprovals;
+  private final ChangeMessageAccess changeMessages;
+  private final PatchSetAccess patchSets;
+  private final PatchLineCommentAccess patchComments;
+
+  NoChangesReviewDbWrapper(ReviewDb db) {
+    super(db);
+    changes = new Changes(this, delegate);
+    patchSetApprovals = new PatchSetApprovals(this, delegate);
+    changeMessages = new ChangeMessages(this, delegate);
+    patchSets = new PatchSets(this, delegate);
+    patchComments = new PatchLineComments(this, delegate);
+  }
+
+  @Override
+  public ChangeAccess changes() {
+    return changes;
+  }
+
+  @Override
+  public PatchSetApprovalAccess patchSetApprovals() {
+    return patchSetApprovals;
+  }
+
+  @Override
+  public ChangeMessageAccess changeMessages() {
+    return changeMessages;
+  }
+
+  @Override
+  public PatchSetAccess patchSets() {
+    return patchSets;
+  }
+
+  @Override
+  public PatchLineCommentAccess patchComments() {
+    return patchComments;
+  }
+
+  private static class Changes extends AbstractDisabledAccess<Change, Change.Id>
+      implements ChangeAccess {
+    private Changes(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.changes());
+    }
+
+    @Override
+    public ResultSet<Change> all() {
+      return empty();
+    }
+  }
+
+  private static class ChangeMessages
+      extends AbstractDisabledAccess<ChangeMessage, ChangeMessage.Key>
+      implements ChangeMessageAccess {
+    private ChangeMessages(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.changeMessages());
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> byChange(Change.Id id) throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id) throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> all() throws OrmException {
+      return empty();
+    }
+  }
+
+  private static class PatchSets extends AbstractDisabledAccess<PatchSet, PatchSet.Id>
+      implements PatchSetAccess {
+    private PatchSets(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.patchSets());
+    }
+
+    @Override
+    public ResultSet<PatchSet> byChange(Change.Id id) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchSet> all() {
+      return empty();
+    }
+  }
+
+  private static class PatchSetApprovals
+      extends AbstractDisabledAccess<PatchSetApproval, PatchSetApproval.Key>
+      implements PatchSetApprovalAccess {
+    private PatchSetApprovals(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.patchSetApprovals());
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byChange(Change.Id id) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byPatchSetUser(PatchSet.Id patchSet, Account.Id account) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> all() {
+      return empty();
+    }
+  }
+
+  private static class PatchLineComments
+      extends AbstractDisabledAccess<PatchLineComment, PatchLineComment.Key>
+      implements PatchLineCommentAccess {
+    private PatchLineComments(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.patchComments());
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> byChange(Change.Id id) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id, String file) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> publishedByPatchSet(PatchSet.Id patchset) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByPatchSetAuthor(
+        PatchSet.Id patchset, Account.Id author) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByChangeFileAuthor(
+        Change.Id id, String file, Account.Id author) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByAuthor(Account.Id author) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> all() {
+      return empty();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NoGroupsReviewDbWrapper.java b/java/com/google/gerrit/server/schema/NoGroupsReviewDbWrapper.java
new file mode 100644
index 0000000..33c4d77
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/NoGroupsReviewDbWrapper.java
@@ -0,0 +1,202 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.AccountGroupName;
+import com.google.gerrit.reviewdb.server.AccountGroupAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupByIdAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupByIdAudAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupMemberAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupMemberAuditAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupNameAccess;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gwtorm.server.ListResultSet;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+/**
+ * Wrapper for ReviewDb that never calls the underlying groups tables.
+ *
+ * <p>See {@link NotesMigrationSchemaFactory} for discussion.
+ */
+public class NoGroupsReviewDbWrapper extends ReviewDbWrapper {
+  private static <T> ResultSet<T> empty() {
+    return new ListResultSet<>(ImmutableList.of());
+  }
+
+  private final AccountGroupAccess groups;
+  private final AccountGroupNameAccess groupNames;
+  private final AccountGroupMemberAccess members;
+  private final AccountGroupMemberAuditAccess memberAudits;
+  private final AccountGroupByIdAccess byIds;
+  private final AccountGroupByIdAudAccess byIdAudits;
+
+  protected NoGroupsReviewDbWrapper(ReviewDb db) {
+    super(db);
+    this.groups = new Groups(this, delegate);
+    this.groupNames = new GroupNames(this, delegate);
+    this.members = new Members(this, delegate);
+    this.memberAudits = new MemberAudits(this, delegate);
+    this.byIds = new ByIds(this, delegate);
+    this.byIdAudits = new ByIdAudits(this, delegate);
+  }
+
+  @Override
+  public AccountGroupAccess accountGroups() {
+    return groups;
+  }
+
+  @Override
+  public AccountGroupNameAccess accountGroupNames() {
+    return groupNames;
+  }
+
+  @Override
+  public AccountGroupMemberAccess accountGroupMembers() {
+    return members;
+  }
+
+  @Override
+  public AccountGroupMemberAuditAccess accountGroupMembersAudit() {
+    return memberAudits;
+  }
+
+  @Override
+  public AccountGroupByIdAudAccess accountGroupByIdAud() {
+    return byIdAudits;
+  }
+
+  @Override
+  public AccountGroupByIdAccess accountGroupById() {
+    return byIds;
+  }
+
+  private static class Groups extends AbstractDisabledAccess<AccountGroup, AccountGroup.Id>
+      implements AccountGroupAccess {
+    private Groups(ReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.accountGroups());
+    }
+
+    @Override
+    public ResultSet<AccountGroup> byUUID(AccountGroup.UUID uuid) throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<AccountGroup> all() throws OrmException {
+      return empty();
+    }
+  }
+
+  private static class GroupNames
+      extends AbstractDisabledAccess<AccountGroupName, AccountGroup.NameKey>
+      implements AccountGroupNameAccess {
+    private GroupNames(ReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.accountGroupNames());
+    }
+
+    @Override
+    public ResultSet<AccountGroupName> all() throws OrmException {
+      return empty();
+    }
+  }
+
+  private static class Members
+      extends AbstractDisabledAccess<AccountGroupMember, AccountGroupMember.Key>
+      implements AccountGroupMemberAccess {
+    private Members(ReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.accountGroupMembers());
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> byAccount(Account.Id id) throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> byGroup(AccountGroup.Id id) throws OrmException {
+      return empty();
+    }
+  }
+
+  private static class MemberAudits
+      extends AbstractDisabledAccess<AccountGroupMemberAudit, AccountGroupMemberAudit.Key>
+      implements AccountGroupMemberAuditAccess {
+    private MemberAudits(ReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.accountGroupMembersAudit());
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> byGroupAccount(
+        AccountGroup.Id groupId, com.google.gerrit.reviewdb.client.Account.Id accountId)
+        throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> byGroup(AccountGroup.Id groupId) throws OrmException {
+      return empty();
+    }
+  }
+
+  private static class ByIds extends AbstractDisabledAccess<AccountGroupById, AccountGroupById.Key>
+      implements AccountGroupByIdAccess {
+    private ByIds(ReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.accountGroupById());
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> byIncludeUUID(AccountGroup.UUID uuid) throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> byGroup(AccountGroup.Id id) throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> all() throws OrmException {
+      return empty();
+    }
+  }
+
+  private static class ByIdAudits
+      extends AbstractDisabledAccess<AccountGroupByIdAud, AccountGroupByIdAud.Key>
+      implements AccountGroupByIdAudAccess {
+    private ByIdAudits(ReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.accountGroupByIdAud());
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> byGroupInclude(
+        AccountGroup.Id groupId, AccountGroup.UUID incGroupUUID) throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> byGroup(AccountGroup.Id groupId) throws OrmException {
+      return empty();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java b/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
new file mode 100644
index 0000000..9bc8b61
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.server.DisallowReadFromChangesReviewDbWrapper;
+import com.google.gerrit.reviewdb.server.DisallowReadFromGroupsReviewDbWrapper;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class NotesMigrationSchemaFactory implements SchemaFactory<ReviewDb> {
+  private final SchemaFactory<ReviewDb> delegate;
+  private final NotesMigration migration;
+  private final GroupsMigration groupsMigration;
+
+  @Inject
+  NotesMigrationSchemaFactory(
+      @ReviewDbFactory SchemaFactory<ReviewDb> delegate,
+      NotesMigration migration,
+      GroupsMigration groupsMigration) {
+    this.delegate = delegate;
+    this.migration = migration;
+    this.groupsMigration = groupsMigration;
+  }
+
+  @Override
+  public ReviewDb open() throws OrmException {
+    // There are two levels at which this class disables access to Changes and related tables,
+    // corresponding to two phases of the NoteDb migration:
+    //
+    // 1. When changes are read from NoteDb but some changes might still have their primary storage
+    //    in ReviewDb, it is generally programmer error to read changes from ReviewDb. However,
+    //    since ReviewDb is still the primary storage for most or all changes, we still need to
+    //    support writing to ReviewDb. This behavior is accomplished by wrapping in a
+    //    DisallowReadFromChangesReviewDbWrapper.
+    //
+    //    Some codepaths might need to be able to read from ReviewDb if they really need to,
+    //    because they need to operate on the underlying source of truth, for example when reading
+    //    a change to determine its primary storage. To support this, ReviewDbUtil#unwrapDb can
+    //    detect and unwrap databases of this type.
+    //
+    // 2. After all changes have their primary storage in NoteDb, we can completely shut off access
+    //    to the change tables. At this point in the migration, we are by definition not using the
+    //    ReviewDb tables at all; we could even delete the tables at this point, and Gerrit would
+    //    continue to function.
+    //
+    //    This is accomplished by setting the delegate ReviewDb *underneath*
+    //    DisallowReadFromChanges to be a complete no-op, with NoChangesReviewDbWrapper. With this
+    //    wrapper, all read operations return no results, and write operations silently do nothing.
+    //    This wrapper is not a public class and nobody should ever attempt to unwrap it.
+
+    // First create the wrappers which can not be removed by ReviewDbUtil#unwrapDb(ReviewDb).
+    ReviewDb db = delegate.open();
+    if (migration.readChanges() && migration.disableChangeReviewDb()) {
+      // Disable writes to change tables in ReviewDb (ReviewDb access for changes are No-Ops).
+      db = new NoChangesReviewDbWrapper(db);
+    }
+
+    if (groupsMigration.readFromNoteDb() && groupsMigration.disableGroupReviewDb()) {
+      // Disable writes to group tables in ReviewDb (ReviewDb access for groups are No-Ops).
+      db = new NoGroupsReviewDbWrapper(db);
+    }
+
+    // Second create the wrappers which can be removed by ReviewDbUtil#unwrapDb(ReviewDb).
+    if (migration.readChanges()) {
+      // If reading changes from NoteDb is configured, changes should not be read from ReviewDb.
+      // Make sure that any attempt to read a change from ReviewDb anyway fails with an exception.
+      db = new DisallowReadFromChangesReviewDbWrapper(db);
+    }
+
+    if (groupsMigration.readFromNoteDb()) {
+      // If reading groups from NoteDb is configured, groups should not be read from ReviewDb.
+      // Make sure that any attempt to read a group from ReviewDb anyway fails with an exception.
+      db = new DisallowReadFromGroupsReviewDbWrapper(db);
+    }
+    return db;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Oracle.java b/java/com/google/gerrit/server/schema/Oracle.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Oracle.java
rename to java/com/google/gerrit/server/schema/Oracle.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgreSQL.java b/java/com/google/gerrit/server/schema/PostgreSQL.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgreSQL.java
rename to java/com/google/gerrit/server/schema/PostgreSQL.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java
rename to java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java b/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
rename to java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbDatabaseProvider.java b/java/com/google/gerrit/server/schema/ReviewDbDatabaseProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbDatabaseProvider.java
rename to java/com/google/gerrit/server/schema/ReviewDbDatabaseProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbFactory.java b/java/com/google/gerrit/server/schema/ReviewDbFactory.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbFactory.java
rename to java/com/google/gerrit/server/schema/ReviewDbFactory.java
diff --git a/java/com/google/gerrit/server/schema/SchemaCreator.java b/java/com/google/gerrit/server/schema/SchemaCreator.java
new file mode 100644
index 0000000..2004c98
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/SchemaCreator.java
@@ -0,0 +1,307 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupName;
+import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
+import com.google.gerrit.reviewdb.client.SystemConfig;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.GroupUUID;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.GroupNameNotes;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupCreation;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gwtorm.jdbc.JdbcExecutor;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Collections;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/** Creates the current database schema and populates initial code rows. */
+public class SchemaCreator {
+  @SitePath private final Path site_path;
+
+  private final GitRepositoryManager repoManager;
+  private final AllProjectsCreator allProjectsCreator;
+  private final AllUsersCreator allUsersCreator;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverUser;
+  private final DataSourceType dataSourceType;
+  private final GroupIndexCollection indexCollection;
+  private final GroupsMigration groupsMigration;
+
+  private final Config config;
+  private final MetricMaker metricMaker;
+  private final NotesMigration migration;
+  private final AllProjectsName allProjectsName;
+
+  @Inject
+  public SchemaCreator(
+      SitePaths site,
+      GitRepositoryManager repoManager,
+      AllProjectsCreator ap,
+      AllUsersCreator auc,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent au,
+      DataSourceType dst,
+      GroupIndexCollection ic,
+      GroupsMigration gm,
+      @GerritServerConfig Config config,
+      MetricMaker metricMaker,
+      NotesMigration migration,
+      AllProjectsName apName) {
+    this(
+        site.site_path,
+        repoManager,
+        ap,
+        auc,
+        allUsersName,
+        au,
+        dst,
+        ic,
+        gm,
+        config,
+        metricMaker,
+        migration,
+        apName);
+  }
+
+  public SchemaCreator(
+      @SitePath Path site,
+      GitRepositoryManager repoManager,
+      AllProjectsCreator ap,
+      AllUsersCreator auc,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent au,
+      DataSourceType dst,
+      GroupIndexCollection ic,
+      GroupsMigration gm,
+      Config config,
+      MetricMaker metricMaker,
+      NotesMigration migration,
+      AllProjectsName apName) {
+    site_path = site;
+    this.repoManager = repoManager;
+    allProjectsCreator = ap;
+    allUsersCreator = auc;
+    this.allUsersName = allUsersName;
+    serverUser = au;
+    dataSourceType = dst;
+    indexCollection = ic;
+    groupsMigration = gm;
+
+    this.config = config;
+    this.allProjectsName = apName;
+    this.migration = migration;
+    this.metricMaker = metricMaker;
+  }
+
+  public void create(ReviewDb db) throws OrmException, IOException, ConfigInvalidException {
+    final JdbcSchema jdbc = (JdbcSchema) db;
+    try (JdbcExecutor e = new JdbcExecutor(jdbc)) {
+      jdbc.updateSchema(e);
+    }
+
+    final CurrentSchemaVersion sVer = CurrentSchemaVersion.create();
+    sVer.versionNbr = SchemaVersion.getBinaryVersion();
+    db.schemaVersion().insert(Collections.singleton(sVer));
+
+    GroupReference admins = createGroupReference("Administrators");
+    GroupReference batchUsers = createGroupReference("Non-Interactive Users");
+
+    initSystemConfig(db);
+    allProjectsCreator.setAdministrators(admins).setBatchUsers(batchUsers).create();
+    // We have to create the All-Users repository before we can use it to store the groups in it.
+    allUsersCreator.setAdministrators(admins).create();
+
+    // Don't rely on injection to construct Sequences, as it requires ReviewDb.
+    Sequences seqs =
+        new Sequences(
+            config,
+            () -> db,
+            migration,
+            repoManager,
+            GitReferenceUpdated.DISABLED,
+            allProjectsName,
+            allUsersName,
+            metricMaker);
+    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+      createAdminsGroup(db, seqs, allUsersRepo, admins);
+      createBatchUsersGroup(db, seqs, allUsersRepo, batchUsers, admins.getUUID());
+    }
+
+    dataSourceType.getIndexScript().run(db);
+  }
+
+  private void createAdminsGroup(
+      ReviewDb db, Sequences seqs, Repository allUsersRepo, GroupReference groupReference)
+      throws OrmException, IOException, ConfigInvalidException {
+    InternalGroupCreation groupCreation = getGroupCreation(seqs, groupReference);
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder().setDescription("Gerrit Site Administrators").build();
+
+    createGroup(db, allUsersRepo, groupCreation, groupUpdate);
+  }
+
+  private void createBatchUsersGroup(
+      ReviewDb db,
+      Sequences seqs,
+      Repository allUsersRepo,
+      GroupReference groupReference,
+      AccountGroup.UUID adminsGroupUuid)
+      throws OrmException, IOException, ConfigInvalidException {
+    InternalGroupCreation groupCreation = getGroupCreation(seqs, groupReference);
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder()
+            .setDescription("Users who perform batch actions on Gerrit")
+            .setOwnerGroupUUID(adminsGroupUuid)
+            .build();
+
+    createGroup(db, allUsersRepo, groupCreation, groupUpdate);
+  }
+
+  private void createGroup(
+      ReviewDb db,
+      Repository allUsersRepo,
+      InternalGroupCreation groupCreation,
+      InternalGroupUpdate groupUpdate)
+      throws OrmException, ConfigInvalidException, IOException {
+    InternalGroup groupInReviewDb = createGroupInReviewDb(db, groupCreation, groupUpdate);
+
+    if (!groupsMigration.writeToNoteDb()) {
+      index(groupInReviewDb);
+      return;
+    }
+
+    InternalGroup createdGroup = createGroupInNoteDb(allUsersRepo, groupCreation, groupUpdate);
+    index(createdGroup);
+  }
+
+  private static InternalGroup createGroupInReviewDb(
+      ReviewDb db, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      throws OrmException {
+    AccountGroup group = GroupsUpdate.createAccountGroup(groupCreation, groupUpdate);
+    db.accountGroupNames().insert(ImmutableList.of(new AccountGroupName(group)));
+    db.accountGroups().insert(ImmutableList.of(group));
+    return InternalGroup.create(group, ImmutableSet.of(), ImmutableSet.of());
+  }
+
+  private InternalGroup createGroupInNoteDb(
+      Repository allUsersRepo, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      throws ConfigInvalidException, IOException, OrmDuplicateKeyException {
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
+    // We don't add any initial members or subgroups and hence the provided functions should never
+    // be called. To be on the safe side, we specify some valid functions.
+    groupConfig.setGroupUpdate(groupUpdate, Account.Id::toString, AccountGroup.UUID::get);
+
+    AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
+    GroupNameNotes groupNameNotes =
+        GroupNameNotes.loadForNewGroup(allUsersRepo, groupCreation.getGroupUUID(), groupName);
+
+    commit(allUsersRepo, groupConfig, groupNameNotes);
+
+    return groupConfig
+        .getLoadedGroup()
+        .orElseThrow(() -> new IllegalStateException("Created group wasn't automatically loaded"));
+  }
+
+  private void commit(
+      Repository allUsersRepo, GroupConfig groupConfig, GroupNameNotes groupNameNotes)
+      throws IOException {
+    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(allUsersRepo, batchRefUpdate)) {
+      groupConfig.commit(metaDataUpdate);
+    }
+    // MetaDataUpdates unfortunately can't be reused. -> Create a new one.
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(allUsersRepo, batchRefUpdate)) {
+      groupNameNotes.commit(metaDataUpdate);
+    }
+    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
+  }
+
+  private MetaDataUpdate createMetaDataUpdate(
+      Repository allUsersRepo, @Nullable BatchRefUpdate batchRefUpdate) {
+    MetaDataUpdate metaDataUpdate =
+        new MetaDataUpdate(
+            GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo, batchRefUpdate);
+    metaDataUpdate.getCommitBuilder().setAuthor(serverUser);
+    metaDataUpdate.getCommitBuilder().setCommitter(serverUser);
+    return metaDataUpdate;
+  }
+
+  private void index(InternalGroup group) throws IOException {
+    for (GroupIndex groupIndex : indexCollection.getWriteIndexes()) {
+      groupIndex.replace(group);
+    }
+  }
+
+  private GroupReference createGroupReference(String name) {
+    AccountGroup.UUID groupUuid = GroupUUID.make(name, serverUser);
+    return new GroupReference(groupUuid, name);
+  }
+
+  private InternalGroupCreation getGroupCreation(Sequences seqs, GroupReference groupReference)
+      throws OrmException {
+    int next = seqs.nextGroupId();
+    return InternalGroupCreation.builder()
+        .setNameKey(new AccountGroup.NameKey(groupReference.getName()))
+        .setId(new AccountGroup.Id(next))
+        .setGroupUUID(groupReference.getUUID())
+        .build();
+  }
+
+  private SystemConfig initSystemConfig(ReviewDb db) throws OrmException {
+    SystemConfig s = SystemConfig.create();
+    try {
+      s.sitePath = site_path.toRealPath().normalize().toString();
+    } catch (IOException e) {
+      s.sitePath = site_path.toAbsolutePath().normalize().toString();
+    }
+    db.systemConfig().insert(Collections.singleton(s));
+    return s;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaModule.java b/java/com/google/gerrit/server/schema/SchemaModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaModule.java
rename to java/com/google/gerrit/server/schema/SchemaModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java b/java/com/google/gerrit/server/schema/SchemaUpdater.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
rename to java/com/google/gerrit/server/schema/SchemaUpdater.java
diff --git a/java/com/google/gerrit/server/schema/SchemaVersion.java b/java/com/google/gerrit/server/schema/SchemaVersion.java
new file mode 100644
index 0000000..4b8c13f
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -0,0 +1,214 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Stopwatch;
+import com.google.common.collect.Lists;
+import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.jdbc.JdbcExecutor;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.StatementExecutor;
+import com.google.inject.Provider;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/** A version of the database schema. */
+public abstract class SchemaVersion {
+  /** The current schema version. */
+  public static final Class<Schema_166> C = Schema_166.class;
+
+  public static int getBinaryVersion() {
+    return guessVersion(C);
+  }
+
+  private final Provider<? extends SchemaVersion> prior;
+  private final int versionNbr;
+
+  protected SchemaVersion(Provider<? extends SchemaVersion> prior) {
+    this.prior = prior;
+    this.versionNbr = guessVersion(getClass());
+  }
+
+  public static int guessVersion(Class<?> c) {
+    String n = c.getName();
+    n = n.substring(n.lastIndexOf('_') + 1);
+    while (n.startsWith("0")) {
+      n = n.substring(1);
+    }
+    return Integer.parseInt(n);
+  }
+
+  /** @return the {@link CurrentSchemaVersion#versionNbr} this step targets. */
+  public final int getVersionNbr() {
+    return versionNbr;
+  }
+
+  @VisibleForTesting
+  public final SchemaVersion getPrior() {
+    return prior.get();
+  }
+
+  public final void check(UpdateUI ui, CurrentSchemaVersion curr, ReviewDb db)
+      throws OrmException, SQLException {
+    if (curr.versionNbr == versionNbr) {
+      // Nothing to do, we are at the correct schema.
+    } else if (curr.versionNbr > versionNbr) {
+      throw new OrmException(
+          "Cannot downgrade database schema from version "
+              + curr.versionNbr
+              + " to "
+              + versionNbr
+              + ".");
+    } else {
+      upgradeFrom(ui, curr, db);
+    }
+  }
+
+  /** Runs check on the prior schema version, and then upgrades. */
+  private void upgradeFrom(UpdateUI ui, CurrentSchemaVersion curr, ReviewDb db)
+      throws OrmException, SQLException {
+    List<SchemaVersion> pending = pending(curr.versionNbr);
+    updateSchema(pending, ui, db);
+    migrateData(pending, ui, curr, db);
+
+    JdbcSchema s = (JdbcSchema) db;
+    final List<String> pruneList = new ArrayList<>();
+    s.pruneSchema(
+        new StatementExecutor() {
+          @Override
+          public void execute(String sql) {
+            pruneList.add(sql);
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        });
+
+    try (JdbcExecutor e = new JdbcExecutor(s)) {
+      if (!pruneList.isEmpty()) {
+        ui.pruneSchema(e, pruneList);
+      }
+    }
+  }
+
+  private List<SchemaVersion> pending(int curr) {
+    List<SchemaVersion> r = Lists.newArrayListWithCapacity(versionNbr - curr);
+    for (SchemaVersion v = this; curr < v.getVersionNbr(); v = v.prior.get()) {
+      r.add(v);
+    }
+    Collections.reverse(r);
+    return r;
+  }
+
+  private void updateSchema(List<SchemaVersion> pending, UpdateUI ui, ReviewDb db)
+      throws OrmException, SQLException {
+    for (SchemaVersion v : pending) {
+      ui.message(String.format("Upgrading schema to %d ...", v.getVersionNbr()));
+      v.preUpdateSchema(db);
+    }
+
+    JdbcSchema s = (JdbcSchema) db;
+    try (JdbcExecutor e = new JdbcExecutor(s)) {
+      s.updateSchema(e);
+    }
+  }
+
+  /**
+   * Invoked before updateSchema adds new columns/tables.
+   *
+   * @param db open database handle.
+   * @throws OrmException if a Gerrit-specific exception occurred.
+   * @throws SQLException if an underlying SQL exception occurred.
+   */
+  protected void preUpdateSchema(ReviewDb db) throws OrmException, SQLException {}
+
+  private void migrateData(
+      List<SchemaVersion> pending, UpdateUI ui, CurrentSchemaVersion curr, ReviewDb db)
+      throws OrmException, SQLException {
+    for (SchemaVersion v : pending) {
+      Stopwatch sw = Stopwatch.createStarted();
+      ui.message(String.format("Migrating data to schema %d ...", v.getVersionNbr()));
+      v.migrateData(db, ui);
+      v.finish(curr, db);
+      ui.message(String.format("\t> Done (%.3f s)", sw.elapsed(TimeUnit.MILLISECONDS) / 1000d));
+    }
+  }
+
+  /**
+   * Invoked between updateSchema (adds new columns/tables) and pruneSchema (removes deleted
+   * columns/tables).
+   *
+   * @param db open database handle.
+   * @param ui interface for interacting with the user.
+   * @throws OrmException if a Gerrit-specific exception occurred.
+   * @throws SQLException if an underlying SQL exception occurred.
+   */
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {}
+
+  /** Mark the current schema version. */
+  protected void finish(CurrentSchemaVersion curr, ReviewDb db) throws OrmException {
+    curr.versionNbr = versionNbr;
+    db.schemaVersion().update(Collections.singleton(curr));
+  }
+
+  /** Rename an existing table. */
+  protected static void renameTable(ReviewDb db, String from, String to) throws OrmException {
+    JdbcSchema s = (JdbcSchema) db;
+    try (JdbcExecutor e = new JdbcExecutor(s)) {
+      s.renameTable(e, from, to);
+    }
+  }
+
+  /** Rename an existing column. */
+  protected static void renameColumn(ReviewDb db, String table, String from, String to)
+      throws OrmException {
+    JdbcSchema s = (JdbcSchema) db;
+    try (JdbcExecutor e = new JdbcExecutor(s)) {
+      s.renameColumn(e, table, from, to);
+    }
+  }
+
+  /** Execute an SQL statement. */
+  protected static void execute(ReviewDb db, String sql) throws SQLException {
+    try (Statement s = newStatement(db)) {
+      s.execute(sql);
+    }
+  }
+
+  /** Open a new single statement. */
+  protected static Statement newStatement(ReviewDb db) throws SQLException {
+    return ((JdbcSchema) db).getConnection().createStatement();
+  }
+
+  /** Open a new prepared statement. */
+  protected static PreparedStatement prepareStatement(ReviewDb db, String sql) throws SQLException {
+    return ((JdbcSchema) db).getConnection().prepareStatement(sql);
+  }
+
+  /** Open a new statement executor. */
+  protected static JdbcExecutor newExecutor(ReviewDb db) throws OrmException {
+    return new JdbcExecutor(((JdbcSchema) db).getConnection());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersionCheck.java b/java/com/google/gerrit/server/schema/SchemaVersionCheck.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersionCheck.java
rename to java/com/google/gerrit/server/schema/SchemaVersionCheck.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_100.java b/java/com/google/gerrit/server/schema/Schema_100.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_100.java
rename to java/com/google/gerrit/server/schema/Schema_100.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_101.java b/java/com/google/gerrit/server/schema/Schema_101.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_101.java
rename to java/com/google/gerrit/server/schema/Schema_101.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_102.java b/java/com/google/gerrit/server/schema/Schema_102.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_102.java
rename to java/com/google/gerrit/server/schema/Schema_102.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_103.java b/java/com/google/gerrit/server/schema/Schema_103.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_103.java
rename to java/com/google/gerrit/server/schema/Schema_103.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_104.java b/java/com/google/gerrit/server/schema/Schema_104.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_104.java
rename to java/com/google/gerrit/server/schema/Schema_104.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_105.java b/java/com/google/gerrit/server/schema/Schema_105.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_105.java
rename to java/com/google/gerrit/server/schema/Schema_105.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_106.java b/java/com/google/gerrit/server/schema/Schema_106.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_106.java
rename to java/com/google/gerrit/server/schema/Schema_106.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_107.java b/java/com/google/gerrit/server/schema/Schema_107.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_107.java
rename to java/com/google/gerrit/server/schema/Schema_107.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_108.java b/java/com/google/gerrit/server/schema/Schema_108.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_108.java
rename to java/com/google/gerrit/server/schema/Schema_108.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_109.java b/java/com/google/gerrit/server/schema/Schema_109.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_109.java
rename to java/com/google/gerrit/server/schema/Schema_109.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_110.java b/java/com/google/gerrit/server/schema/Schema_110.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_110.java
rename to java/com/google/gerrit/server/schema/Schema_110.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_111.java b/java/com/google/gerrit/server/schema/Schema_111.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_111.java
rename to java/com/google/gerrit/server/schema/Schema_111.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_112.java b/java/com/google/gerrit/server/schema/Schema_112.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_112.java
rename to java/com/google/gerrit/server/schema/Schema_112.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_113.java b/java/com/google/gerrit/server/schema/Schema_113.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_113.java
rename to java/com/google/gerrit/server/schema/Schema_113.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_114.java b/java/com/google/gerrit/server/schema/Schema_114.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_114.java
rename to java/com/google/gerrit/server/schema/Schema_114.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_115.java b/java/com/google/gerrit/server/schema/Schema_115.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_115.java
rename to java/com/google/gerrit/server/schema/Schema_115.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_116.java b/java/com/google/gerrit/server/schema/Schema_116.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_116.java
rename to java/com/google/gerrit/server/schema/Schema_116.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_117.java b/java/com/google/gerrit/server/schema/Schema_117.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_117.java
rename to java/com/google/gerrit/server/schema/Schema_117.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_118.java b/java/com/google/gerrit/server/schema/Schema_118.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_118.java
rename to java/com/google/gerrit/server/schema/Schema_118.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_119.java b/java/com/google/gerrit/server/schema/Schema_119.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_119.java
rename to java/com/google/gerrit/server/schema/Schema_119.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_120.java b/java/com/google/gerrit/server/schema/Schema_120.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_120.java
rename to java/com/google/gerrit/server/schema/Schema_120.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_121.java b/java/com/google/gerrit/server/schema/Schema_121.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_121.java
rename to java/com/google/gerrit/server/schema/Schema_121.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_122.java b/java/com/google/gerrit/server/schema/Schema_122.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_122.java
rename to java/com/google/gerrit/server/schema/Schema_122.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java b/java/com/google/gerrit/server/schema/Schema_123.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java
rename to java/com/google/gerrit/server/schema/Schema_123.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java b/java/com/google/gerrit/server/schema/Schema_124.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java
rename to java/com/google/gerrit/server/schema/Schema_124.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_125.java b/java/com/google/gerrit/server/schema/Schema_125.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_125.java
rename to java/com/google/gerrit/server/schema/Schema_125.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_126.java b/java/com/google/gerrit/server/schema/Schema_126.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_126.java
rename to java/com/google/gerrit/server/schema/Schema_126.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_127.java b/java/com/google/gerrit/server/schema/Schema_127.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_127.java
rename to java/com/google/gerrit/server/schema/Schema_127.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_128.java b/java/com/google/gerrit/server/schema/Schema_128.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_128.java
rename to java/com/google/gerrit/server/schema/Schema_128.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_129.java b/java/com/google/gerrit/server/schema/Schema_129.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_129.java
rename to java/com/google/gerrit/server/schema/Schema_129.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_130.java b/java/com/google/gerrit/server/schema/Schema_130.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_130.java
rename to java/com/google/gerrit/server/schema/Schema_130.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_131.java b/java/com/google/gerrit/server/schema/Schema_131.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_131.java
rename to java/com/google/gerrit/server/schema/Schema_131.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_132.java b/java/com/google/gerrit/server/schema/Schema_132.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_132.java
rename to java/com/google/gerrit/server/schema/Schema_132.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_133.java b/java/com/google/gerrit/server/schema/Schema_133.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_133.java
rename to java/com/google/gerrit/server/schema/Schema_133.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_134.java b/java/com/google/gerrit/server/schema/Schema_134.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_134.java
rename to java/com/google/gerrit/server/schema/Schema_134.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_135.java b/java/com/google/gerrit/server/schema/Schema_135.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_135.java
rename to java/com/google/gerrit/server/schema/Schema_135.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_136.java b/java/com/google/gerrit/server/schema/Schema_136.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_136.java
rename to java/com/google/gerrit/server/schema/Schema_136.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_137.java b/java/com/google/gerrit/server/schema/Schema_137.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_137.java
rename to java/com/google/gerrit/server/schema/Schema_137.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_138.java b/java/com/google/gerrit/server/schema/Schema_138.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_138.java
rename to java/com/google/gerrit/server/schema/Schema_138.java
diff --git a/java/com/google/gerrit/server/schema/Schema_139.java b/java/com/google/gerrit/server/schema/Schema_139.java
new file mode 100644
index 0000000..f2c30df
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_139.java
@@ -0,0 +1,211 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.InternalAccountUpdate;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class Schema_139 extends SchemaVersion {
+  private static final String MSG = "Migrate project watches to git";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_139(
+      Provider<Schema_138> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    ListMultimap<Account.Id, ProjectWatch> imports =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
+        ResultSet rs =
+            stmt.executeQuery(
+                "SELECT "
+                    + "account_id, "
+                    + "project_name, "
+                    + "filter, "
+                    + "notify_abandoned_changes, "
+                    + "notify_all_comments, "
+                    + "notify_new_changes, "
+                    + "notify_new_patch_sets, "
+                    + "notify_submitted_changes "
+                    + "FROM account_project_watches")) {
+      while (rs.next()) {
+        Account.Id accountId = new Account.Id(rs.getInt(1));
+        ProjectWatch.Builder b =
+            ProjectWatch.builder()
+                .project(new Project.NameKey(rs.getString(2)))
+                .filter(rs.getString(3))
+                .notifyAbandonedChanges(toBoolean(rs.getString(4)))
+                .notifyAllComments(toBoolean(rs.getString(5)))
+                .notifyNewChanges(toBoolean(rs.getString(6)))
+                .notifyNewPatchSets(toBoolean(rs.getString(7)))
+                .notifySubmittedChanges(toBoolean(rs.getString(8)));
+        imports.put(accountId, b.build());
+      }
+    }
+
+    if (imports.isEmpty()) {
+      return;
+    }
+
+    try (Repository git = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(git)) {
+      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
+      bru.setRefLogIdent(serverUser);
+      bru.setRefLogMessage(MSG, false);
+
+      for (Map.Entry<Account.Id, Collection<ProjectWatch>> e : imports.asMap().entrySet()) {
+        Map<ProjectWatchKey, Set<NotifyType>> projectWatches = new HashMap<>();
+        for (ProjectWatch projectWatch : e.getValue()) {
+          ProjectWatchKey key =
+              ProjectWatchKey.create(projectWatch.project(), projectWatch.filter());
+          if (projectWatches.containsKey(key)) {
+            throw new OrmDuplicateKeyException(
+                "Duplicate key for watched project: " + key.toString());
+          }
+          Set<NotifyType> notifyValues = EnumSet.noneOf(NotifyType.class);
+          if (projectWatch.notifyAbandonedChanges()) {
+            notifyValues.add(NotifyType.ABANDONED_CHANGES);
+          }
+          if (projectWatch.notifyAllComments()) {
+            notifyValues.add(NotifyType.ALL_COMMENTS);
+          }
+          if (projectWatch.notifyNewChanges()) {
+            notifyValues.add(NotifyType.NEW_CHANGES);
+          }
+          if (projectWatch.notifyNewPatchSets()) {
+            notifyValues.add(NotifyType.NEW_PATCHSETS);
+          }
+          if (projectWatch.notifySubmittedChanges()) {
+            notifyValues.add(NotifyType.SUBMITTED_CHANGES);
+          }
+          projectWatches.put(key, notifyValues);
+        }
+
+        try (MetaDataUpdate md =
+            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git, bru)) {
+          md.getCommitBuilder().setAuthor(serverUser);
+          md.getCommitBuilder().setCommitter(serverUser);
+          md.setMessage(MSG);
+
+          AccountConfig accountConfig = new AccountConfig(e.getKey(), git);
+          accountConfig.load(md);
+          accountConfig.setAccountUpdate(
+              InternalAccountUpdate.builder()
+                  .deleteProjectWatches(accountConfig.getProjectWatches().keySet())
+                  .updateProjectWatches(projectWatches)
+                  .build());
+          accountConfig.commit(md);
+        }
+      }
+      bru.execute(rw, NullProgressMonitor.INSTANCE);
+    } catch (IOException | ConfigInvalidException ex) {
+      throw new OrmException(ex);
+    }
+  }
+
+  @AutoValue
+  abstract static class ProjectWatch {
+    abstract Project.NameKey project();
+
+    abstract @Nullable String filter();
+
+    abstract boolean notifyAbandonedChanges();
+
+    abstract boolean notifyAllComments();
+
+    abstract boolean notifyNewChanges();
+
+    abstract boolean notifyNewPatchSets();
+
+    abstract boolean notifySubmittedChanges();
+
+    static Builder builder() {
+      return new AutoValue_Schema_139_ProjectWatch.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder project(Project.NameKey project);
+
+      abstract Builder filter(@Nullable String filter);
+
+      abstract Builder notifyAbandonedChanges(boolean notifyAbandonedChanges);
+
+      abstract Builder notifyAllComments(boolean notifyAllComments);
+
+      abstract Builder notifyNewChanges(boolean notifyNewChanges);
+
+      abstract Builder notifyNewPatchSets(boolean notifyNewPatchSets);
+
+      abstract Builder notifySubmittedChanges(boolean notifySubmittedChanges);
+
+      abstract ProjectWatch build();
+    }
+  }
+
+  private static boolean toBoolean(String v) {
+    Preconditions.checkState(!Strings.isNullOrEmpty(v));
+    return v.equals("Y");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_140.java b/java/com/google/gerrit/server/schema/Schema_140.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_140.java
rename to java/com/google/gerrit/server/schema/Schema_140.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_141.java b/java/com/google/gerrit/server/schema/Schema_141.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_141.java
rename to java/com/google/gerrit/server/schema/Schema_141.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java b/java/com/google/gerrit/server/schema/Schema_142.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java
rename to java/com/google/gerrit/server/schema/Schema_142.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_143.java b/java/com/google/gerrit/server/schema/Schema_143.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_143.java
rename to java/com/google/gerrit/server/schema/Schema_143.java
diff --git a/java/com/google/gerrit/server/schema/Schema_144.java b/java/com/google/gerrit/server/schema/Schema_144.java
new file mode 100644
index 0000000..98dcd39
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_144.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+public class Schema_144 extends SchemaVersion {
+  private static final String COMMIT_MSG = "Import external IDs from ReviewDb";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  Schema_144(
+      Provider<Schema_143> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    Set<ExternalId> toAdd = new HashSet<>();
+    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
+        ResultSet rs =
+            stmt.executeQuery(
+                "SELECT "
+                    + "account_id, "
+                    + "email_address, "
+                    + "password, "
+                    + "external_id "
+                    + "FROM account_external_ids")) {
+      while (rs.next()) {
+        Account.Id accountId = new Account.Id(rs.getInt(1));
+        String email = rs.getString(2);
+        String password = rs.getString(3);
+        String externalId = rs.getString(4);
+
+        toAdd.add(ExternalId.create(ExternalId.Key.parse(externalId), accountId, email, password));
+      }
+    }
+
+    try {
+      try (Repository repo = repoManager.openRepository(allUsersName)) {
+        ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+        extIdNotes.upsert(toAdd);
+        try (MetaDataUpdate metaDataUpdate =
+            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, repo)) {
+          metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
+          metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
+          metaDataUpdate.getCommitBuilder().setMessage(COMMIT_MSG);
+          extIdNotes.commit(metaDataUpdate);
+        }
+      }
+    } catch (IOException | ConfigInvalidException e) {
+      throw new OrmException("Failed to migrate external IDs to NoteDb", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_145.java b/java/com/google/gerrit/server/schema/Schema_145.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_145.java
rename to java/com/google/gerrit/server/schema/Schema_145.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_146.java b/java/com/google/gerrit/server/schema/Schema_146.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_146.java
rename to java/com/google/gerrit/server/schema/Schema_146.java
diff --git a/java/com/google/gerrit/server/schema/Schema_147.java b/java/com/google/gerrit/server/schema/Schema_147.java
new file mode 100644
index 0000000..fd85463
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_147.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+
+/** Delete user branches for which no account exists. */
+public class Schema_147 extends SchemaVersion {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  Schema_147(
+      Provider<Schema_146> prior, GitRepositoryManager repoManager, AllUsersName allUsersName) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      Set<Account.Id> accountIdsFromReviewDb = scanAccounts(db);
+      Set<Account.Id> accountIdsFromUserBranches =
+          repo.getRefDatabase()
+              .getRefs(RefNames.REFS_USERS)
+              .values()
+              .stream()
+              .map(r -> Account.Id.fromRef(r.getName()))
+              .filter(Objects::nonNull)
+              .collect(toSet());
+      accountIdsFromUserBranches.removeAll(accountIdsFromReviewDb);
+      for (Account.Id accountId : accountIdsFromUserBranches) {
+        deleteUserBranch(repo, accountId);
+      }
+    } catch (IOException e) {
+      throw new OrmException("Failed to delete user branches for non-existing accounts.", e);
+    }
+  }
+
+  private Set<Account.Id> scanAccounts(ReviewDb db) throws SQLException {
+    try (Statement stmt = newStatement(db);
+        ResultSet rs = stmt.executeQuery("SELECT account_id FROM accounts")) {
+      Set<Account.Id> ids = new HashSet<>();
+      while (rs.next()) {
+        ids.add(new Account.Id(rs.getInt(1)));
+      }
+      return ids;
+    }
+  }
+
+  private void deleteUserBranch(Repository allUsersRepo, Account.Id accountId) throws IOException {
+    String refName = RefNames.refsUsers(accountId);
+    Ref ref = allUsersRepo.exactRef(refName);
+    if (ref == null) {
+      return;
+    }
+
+    RefUpdate ru = allUsersRepo.updateRef(refName);
+    ru.setExpectedOldObjectId(ref.getObjectId());
+    ru.setNewObjectId(ObjectId.zeroId());
+    ru.setForceUpdate(true);
+    Result result = ru.delete();
+    if (result != Result.FORCED) {
+      throw new IOException(String.format("Failed to delete ref %s: %s", refName, result.name()));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/Schema_148.java b/java/com/google/gerrit/server/schema/Schema_148.java
new file mode 100644
index 0000000..0c22964
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_148.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.primitives.Ints;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.SQLException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+public class Schema_148 extends SchemaVersion {
+  private static final String COMMIT_MSG = "Make account IDs of external IDs human-readable";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_148(
+      Provider<Schema_147> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+      for (ExternalId extId : extIdNotes.all()) {
+        if (needsUpdate(extId)) {
+          extIdNotes.upsert(extId);
+        }
+      }
+
+      try (MetaDataUpdate metaDataUpdate =
+          new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, repo)) {
+        metaDataUpdate.getCommitBuilder().setAuthor(serverUser);
+        metaDataUpdate.getCommitBuilder().setCommitter(serverUser);
+        metaDataUpdate.getCommitBuilder().setMessage(COMMIT_MSG);
+        extIdNotes.commit(metaDataUpdate);
+      }
+    } catch (IOException | ConfigInvalidException e) {
+      throw new OrmException("Failed to update external IDs", e);
+    }
+  }
+
+  private static boolean needsUpdate(ExternalId extId) {
+    Config cfg = new Config();
+    cfg.setInt("externalId", extId.key().get(), "accountId", extId.accountId().get());
+    return Ints.tryParse(cfg.getString("externalId", extId.key().get(), "accountId")) == null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_149.java b/java/com/google/gerrit/server/schema/Schema_149.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_149.java
rename to java/com/google/gerrit/server/schema/Schema_149.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_150.java b/java/com/google/gerrit/server/schema/Schema_150.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_150.java
rename to java/com/google/gerrit/server/schema/Schema_150.java
diff --git a/java/com/google/gerrit/server/schema/Schema_151.java b/java/com/google/gerrit/server/schema/Schema_151.java
new file mode 100644
index 0000000..7d12e58
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_151.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+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.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/** A schema which adds the 'created on' field to groups. */
+public class Schema_151 extends SchemaVersion {
+  @Inject
+  protected Schema_151(Provider<Schema_150> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, 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);
+
+        groupUpdate.setTimestamp(1, createdOn);
+        groupUpdate.setInt(2, groupId.get());
+        groupUpdate.executeUpdate();
+      }
+    }
+  }
+
+  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_152.java b/java/com/google/gerrit/server/schema/Schema_152.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_152.java
rename to java/com/google/gerrit/server/schema/Schema_152.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_153.java b/java/com/google/gerrit/server/schema/Schema_153.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_153.java
rename to java/com/google/gerrit/server/schema/Schema_153.java
diff --git a/java/com/google/gerrit/server/schema/Schema_154.java b/java/com/google/gerrit/server/schema/Schema_154.java
new file mode 100644
index 0000000..0e5c110
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_154.java
@@ -0,0 +1,149 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static java.util.stream.Collectors.toMap;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Migrate accounts to NoteDb. */
+public class Schema_154 extends SchemaVersion {
+  private static final Logger log = LoggerFactory.getLogger(Schema_154.class);
+  private static final String TABLE = "accounts";
+  private static final ImmutableMap<String, AccountSetter> ACCOUNT_FIELDS_MAP =
+      ImmutableMap.<String, AccountSetter>builder()
+          .put("full_name", (a, rs, field) -> a.setFullName(rs.getString(field)))
+          .put("preferred_email", (a, rs, field) -> a.setPreferredEmail(rs.getString(field)))
+          .put("status", (a, rs, field) -> a.setStatus(rs.getString(field)))
+          .put("inactive", (a, rs, field) -> a.setActive(rs.getString(field).equals("N")))
+          .build();
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final Provider<PersonIdent> serverIdent;
+
+  @Inject
+  Schema_154(
+      Provider<Schema_153> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try {
+      try (Repository repo = repoManager.openRepository(allUsersName)) {
+        ProgressMonitor pm = new TextProgressMonitor();
+        pm.beginTask("Collecting accounts", ProgressMonitor.UNKNOWN);
+        Set<Account> accounts = scanAccounts(db, pm);
+        pm.endTask();
+        pm.beginTask("Migrating accounts to NoteDb", accounts.size());
+        for (Account account : accounts) {
+          updateAccountInNoteDb(repo, account);
+          pm.update(1);
+        }
+        pm.endTask();
+      }
+    } catch (IOException | ConfigInvalidException e) {
+      throw new OrmException("Migrating accounts to NoteDb failed", e);
+    }
+  }
+
+  private Set<Account> scanAccounts(ReviewDb db, ProgressMonitor pm) throws SQLException {
+    Map<String, AccountSetter> fields = getFields(db);
+    if (fields.isEmpty()) {
+      log.warn("Only account_id and registered_on fields are migrated for accounts");
+    }
+
+    List<String> queryFields = new ArrayList<>();
+    queryFields.add("account_id");
+    queryFields.add("registered_on");
+    queryFields.addAll(fields.keySet());
+    String query = "SELECT " + String.join(", ", queryFields) + String.format(" FROM %s", TABLE);
+    try (Statement stmt = newStatement(db);
+        ResultSet rs = stmt.executeQuery(query)) {
+      Set<Account> s = new HashSet<>();
+      while (rs.next()) {
+        Account a = new Account(new Account.Id(rs.getInt(1)), rs.getTimestamp(2));
+        for (Map.Entry<String, AccountSetter> field : fields.entrySet()) {
+          field.getValue().set(a, rs, field.getKey());
+        }
+        s.add(a);
+        pm.update(1);
+      }
+      return s;
+    }
+  }
+
+  private Map<String, AccountSetter> getFields(ReviewDb db) throws SQLException {
+    JdbcSchema schema = (JdbcSchema) db;
+    Connection connection = schema.getConnection();
+    Set<String> columns = schema.getDialect().listColumns(connection, TABLE);
+    return ACCOUNT_FIELDS_MAP
+        .entrySet()
+        .stream()
+        .filter(e -> columns.contains(e.getKey()))
+        .collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
+  }
+
+  private void updateAccountInNoteDb(Repository allUsersRepo, Account account)
+      throws IOException, ConfigInvalidException {
+    MetaDataUpdate md =
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo);
+    PersonIdent ident = serverIdent.get();
+    md.getCommitBuilder().setAuthor(ident);
+    md.getCommitBuilder().setCommitter(ident);
+    new AccountConfig(account.getId(), allUsersRepo).load().setAccount(account).commit(md);
+  }
+
+  @FunctionalInterface
+  private interface AccountSetter {
+    void set(Account a, ResultSet rs, String field) throws SQLException;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_155.java b/java/com/google/gerrit/server/schema/Schema_155.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_155.java
rename to java/com/google/gerrit/server/schema/Schema_155.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_156.java b/java/com/google/gerrit/server/schema/Schema_156.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_156.java
rename to java/com/google/gerrit/server/schema/Schema_156.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_157.java b/java/com/google/gerrit/server/schema/Schema_157.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_157.java
rename to java/com/google/gerrit/server/schema/Schema_157.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_158.java b/java/com/google/gerrit/server/schema/Schema_158.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_158.java
rename to java/com/google/gerrit/server/schema/Schema_158.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_159.java b/java/com/google/gerrit/server/schema/Schema_159.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_159.java
rename to java/com/google/gerrit/server/schema/Schema_159.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_160.java b/java/com/google/gerrit/server/schema/Schema_160.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_160.java
rename to java/com/google/gerrit/server/schema/Schema_160.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_161.java b/java/com/google/gerrit/server/schema/Schema_161.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_161.java
rename to java/com/google/gerrit/server/schema/Schema_161.java
diff --git a/java/com/google/gerrit/server/schema/Schema_162.java b/java/com/google/gerrit/server/schema/Schema_162.java
new file mode 100644
index 0000000..fe7361c
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_162.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+public class Schema_162 extends SchemaVersion {
+  private final GitRepositoryManager repoManager;
+  private final AllProjectsName allProjectsName;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_162(
+      Provider<Schema_161> prior,
+      GitRepositoryManager repoManager,
+      AllProjectsName allProjectsName,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allProjectsName = allProjectsName;
+    this.allUsersName = allUsersName;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    try (Repository git = repoManager.openRepository(allUsersName);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
+      ProjectConfig cfg = ProjectConfig.read(md);
+      if (allProjectsName.equals(cfg.getProject().getParent(allProjectsName))) {
+        return;
+      }
+      cfg.getProject().setParentName(allProjectsName);
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(
+          String.format("Make %s inherit from %s", allUsersName.get(), allProjectsName.get()));
+      cfg.commit(md);
+    } catch (ConfigInvalidException | IOException ex) {
+      throw new OrmException(ex);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/Schema_163.java b/java/com/google/gerrit/server/schema/Schema_163.java
new file mode 100644
index 0000000..b0a52ff
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_163.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.RepoSequence;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.sql.SQLException;
+
+/** Create group sequence in NoteDb */
+public class Schema_163 extends SchemaVersion {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  Schema_163(
+      Provider<Schema_162> prior, GitRepositoryManager repoManager, AllUsersName allUsersName) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    @SuppressWarnings("deprecation")
+    RepoSequence.Seed groupSeed = () -> db.nextAccountGroupId();
+    RepoSequence groupSeq =
+        new RepoSequence(
+            repoManager,
+            GitReferenceUpdated.DISABLED,
+            allUsersName,
+            Sequences.NAME_GROUPS,
+            groupSeed,
+            1);
+
+    // consume one account ID to ensure that the group sequence is initialized in NoteDb
+    groupSeq.next();
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/Schema_164.java b/java/com/google/gerrit/server/schema/Schema_164.java
new file mode 100644
index 0000000..fca0ae8
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_164.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.grant;
+
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.SQLException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/** Grant read on group branches */
+public class Schema_164 extends SchemaVersion {
+  private static final String COMMIT_MSG = "Grant read permissions on group branches";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final SystemGroupBackend systemGroupBackend;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_164(
+      Provider<Schema_163> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      SystemGroupBackend systemGroupBackend,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.systemGroupBackend = systemGroupBackend;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository git = repoManager.openRepository(allUsersName);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(COMMIT_MSG);
+
+      ProjectConfig config = ProjectConfig.read(md);
+      AccessSection groups = config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
+      grant(
+          config,
+          groups,
+          Permission.READ,
+          false,
+          true,
+          systemGroupBackend.getGroup(REGISTERED_USERS));
+      config.commit(md);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new OrmException("Failed to grant read permissions on group branches", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/Schema_165.java b/java/com/google/gerrit/server/schema/Schema_165.java
new file mode 100644
index 0000000..6463e3b
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_165.java
@@ -0,0 +1,142 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.RefPattern;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.SQLException;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/** Make default Label-Code-Review permission on user branches exclusive. */
+public class Schema_165 extends SchemaVersion {
+  private static final String COMMIT_MSG =
+      "Make default Label-Code-Review permission on user branches exclusive";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final SystemGroupBackend systemGroupBackend;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_165(
+      Provider<Schema_164> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      SystemGroupBackend systemGroupBackend,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.systemGroupBackend = systemGroupBackend;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository git = repoManager.openRepository(allUsersName);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
+      ProjectConfig config = ProjectConfig.read(md);
+      Optional<Permission> permission = findDefaultPermission(config);
+      if (!permission.isPresent()) {
+        // the default permission was not found, hence it cannot be fixed
+        return;
+      }
+
+      permission.get().setExclusiveGroup(true);
+
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(COMMIT_MSG);
+      config.commit(md);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new OrmException(
+          "Failed to make default Label-Code-Review permission on user branches exclusive", e);
+    }
+  }
+
+  /**
+   * Searches for the default "Label-Code-Review" permission on the user branch and returns it if it
+   * was found. If it was not found (e.g. because it was removed or modified) {@link
+   * Optional#empty()} is returned.
+   */
+  private Optional<Permission> findDefaultPermission(ProjectConfig config) {
+    AccessSection users =
+        config.getAccessSection(
+            RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", false);
+    if (users == null) {
+      // default permission was removed
+      return Optional.empty();
+    }
+
+    Permission permission = users.getPermission(Permission.LABEL + "Code-Review", false);
+    return isDefaultPermissionUntouched(permission) ? Optional.of(permission) : Optional.empty();
+  }
+
+  /**
+   * Checks whether the given permission matches the default "Label-Code-Review" permission on the
+   * user branch that was initially setup by {@link AllUsersCreator}.
+   */
+  private boolean isDefaultPermissionUntouched(Permission permission) {
+    if (permission == null) {
+      // default permission was removed
+      return false;
+    } else if (permission.getExclusiveGroup()) {
+      // default permission was modified
+      return false;
+    }
+
+    if (permission.getRules().size() != 1) {
+      // default permission was modified
+      return false;
+    }
+
+    PermissionRule rule = permission.getRule(systemGroupBackend.getGroup(REGISTERED_USERS));
+    if (rule == null) {
+      // default permission was removed
+      return false;
+    }
+
+    if (rule.getAction() != Action.ALLOW
+        || rule.getForce()
+        || rule.getMin() != -2
+        || rule.getMax() != 2) {
+      // default permission was modified
+      return false;
+    }
+
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/Schema_166.java b/java/com/google/gerrit/server/schema/Schema_166.java
new file mode 100644
index 0000000..aa6f4e6
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_166.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.SQLException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+
+/** Set HEAD for All-Users to refs/meta/config. */
+public class Schema_166 extends SchemaVersion {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  Schema_166(
+      Provider<Schema_165> prior, GitRepositoryManager repoManager, AllUsersName allUsersName) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository git = repoManager.openRepository(allUsersName)) {
+      RefUpdate u = git.updateRef(Constants.HEAD);
+      u.link(RefNames.REFS_CONFIG);
+    } catch (IOException e) {
+      throw new OrmException(String.format("Failed to update HEAD for %s", allUsersName.get()), e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_83.java b/java/com/google/gerrit/server/schema/Schema_83.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_83.java
rename to java/com/google/gerrit/server/schema/Schema_83.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_84.java b/java/com/google/gerrit/server/schema/Schema_84.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_84.java
rename to java/com/google/gerrit/server/schema/Schema_84.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_85.java b/java/com/google/gerrit/server/schema/Schema_85.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_85.java
rename to java/com/google/gerrit/server/schema/Schema_85.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_86.java b/java/com/google/gerrit/server/schema/Schema_86.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_86.java
rename to java/com/google/gerrit/server/schema/Schema_86.java
diff --git a/java/com/google/gerrit/server/schema/Schema_87.java b/java/com/google/gerrit/server/schema/Schema_87.java
new file mode 100644
index 0000000..8a3ea08
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_87.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.group.SystemGroupBackend;
+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.HashSet;
+import java.util.Optional;
+import java.util.Set;
+
+public class Schema_87 extends SchemaVersion {
+  @Inject
+  Schema_87(Provider<Schema_86> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    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 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'")) {
+      Set<AccountGroup.Id> ids = new HashSet<>();
+      while (rs.next()) {
+        ids.add(new AccountGroup.Id(rs.getInt(1)));
+      }
+      return ids;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_88.java b/java/com/google/gerrit/server/schema/Schema_88.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_88.java
rename to java/com/google/gerrit/server/schema/Schema_88.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_89.java b/java/com/google/gerrit/server/schema/Schema_89.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_89.java
rename to java/com/google/gerrit/server/schema/Schema_89.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_90.java b/java/com/google/gerrit/server/schema/Schema_90.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_90.java
rename to java/com/google/gerrit/server/schema/Schema_90.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_91.java b/java/com/google/gerrit/server/schema/Schema_91.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_91.java
rename to java/com/google/gerrit/server/schema/Schema_91.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_92.java b/java/com/google/gerrit/server/schema/Schema_92.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_92.java
rename to java/com/google/gerrit/server/schema/Schema_92.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_93.java b/java/com/google/gerrit/server/schema/Schema_93.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_93.java
rename to java/com/google/gerrit/server/schema/Schema_93.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_94.java b/java/com/google/gerrit/server/schema/Schema_94.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_94.java
rename to java/com/google/gerrit/server/schema/Schema_94.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_95.java b/java/com/google/gerrit/server/schema/Schema_95.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_95.java
rename to java/com/google/gerrit/server/schema/Schema_95.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_96.java b/java/com/google/gerrit/server/schema/Schema_96.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_96.java
rename to java/com/google/gerrit/server/schema/Schema_96.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_97.java b/java/com/google/gerrit/server/schema/Schema_97.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_97.java
rename to java/com/google/gerrit/server/schema/Schema_97.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_98.java b/java/com/google/gerrit/server/schema/Schema_98.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_98.java
rename to java/com/google/gerrit/server/schema/Schema_98.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_99.java b/java/com/google/gerrit/server/schema/Schema_99.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_99.java
rename to java/com/google/gerrit/server/schema/Schema_99.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ScriptRunner.java b/java/com/google/gerrit/server/schema/ScriptRunner.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/ScriptRunner.java
rename to java/com/google/gerrit/server/schema/ScriptRunner.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/UpdateUI.java b/java/com/google/gerrit/server/schema/UpdateUI.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/UpdateUI.java
rename to java/com/google/gerrit/server/schema/UpdateUI.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/DefaultSecureStore.java b/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
rename to java/com/google/gerrit/server/securestore/DefaultSecureStore.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStore.java b/java/com/google/gerrit/server/securestore/SecureStore.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStore.java
rename to java/com/google/gerrit/server/securestore/SecureStore.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStoreClassName.java b/java/com/google/gerrit/server/securestore/SecureStoreClassName.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStoreClassName.java
rename to java/com/google/gerrit/server/securestore/SecureStoreClassName.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStoreProvider.java b/java/com/google/gerrit/server/securestore/SecureStoreProvider.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStoreProvider.java
rename to java/com/google/gerrit/server/securestore/SecureStoreProvider.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshInfo.java b/java/com/google/gerrit/server/ssh/NoSshInfo.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshInfo.java
rename to java/com/google/gerrit/server/ssh/NoSshInfo.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshKeyCache.java b/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
rename to java/com/google/gerrit/server/ssh/NoSshKeyCache.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshModule.java b/java/com/google/gerrit/server/ssh/NoSshModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshModule.java
rename to java/com/google/gerrit/server/ssh/NoSshModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAddressesModule.java b/java/com/google/gerrit/server/ssh/SshAddressesModule.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAddressesModule.java
rename to java/com/google/gerrit/server/ssh/SshAddressesModule.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAdvertisedAddresses.java b/java/com/google/gerrit/server/ssh/SshAdvertisedAddresses.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAdvertisedAddresses.java
rename to java/com/google/gerrit/server/ssh/SshAdvertisedAddresses.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshInfo.java b/java/com/google/gerrit/server/ssh/SshInfo.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshInfo.java
rename to java/com/google/gerrit/server/ssh/SshInfo.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCache.java b/java/com/google/gerrit/server/ssh/SshKeyCache.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCache.java
rename to java/com/google/gerrit/server/ssh/SshKeyCache.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCreator.java b/java/com/google/gerrit/server/ssh/SshKeyCreator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCreator.java
rename to java/com/google/gerrit/server/ssh/SshKeyCreator.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshListenAddresses.java b/java/com/google/gerrit/server/ssh/SshListenAddresses.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshListenAddresses.java
rename to java/com/google/gerrit/server/ssh/SshListenAddresses.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/tools/ToolsCatalog.java b/java/com/google/gerrit/server/tools/ToolsCatalog.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/tools/ToolsCatalog.java
rename to java/com/google/gerrit/server/tools/ToolsCatalog.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
rename to java/com/google/gerrit/server/update/BatchUpdate.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java b/java/com/google/gerrit/server/update/BatchUpdateListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java
rename to java/com/google/gerrit/server/update/BatchUpdateListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java b/java/com/google/gerrit/server/update/BatchUpdateOp.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java
rename to java/com/google/gerrit/server/update/BatchUpdateOp.java
diff --git a/java/com/google/gerrit/server/update/BatchUpdateReviewDb.java b/java/com/google/gerrit/server/update/BatchUpdateReviewDb.java
new file mode 100644
index 0000000..3b8f871
--- /dev/null
+++ b/java/com/google/gerrit/server/update/BatchUpdateReviewDb.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ChangeAccess;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gwtorm.server.AtomicUpdate;
+
+public class BatchUpdateReviewDb extends ReviewDbWrapper {
+  private final ChangeAccess changesWrapper;
+
+  BatchUpdateReviewDb(ReviewDb delegate) {
+    super(delegate);
+    changesWrapper = new BatchUpdateChanges(delegate.changes());
+  }
+
+  /** @return the underlying delegate. Supports BatchUpdateReviewDb too. */
+  public static ReviewDb unwrap(ReviewDb db) {
+    if (db instanceof BatchUpdateReviewDb) {
+      db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
+    }
+    return ReviewDbUtil.unwrapDb(db);
+  }
+
+  public ReviewDb unsafeGetDelegate() {
+    return delegate;
+  }
+
+  @Override
+  public ChangeAccess changes() {
+    return changesWrapper;
+  }
+
+  @Override
+  public void commit() {
+    throw new UnsupportedOperationException(
+        "do not call commit; BatchUpdate always manages transactions");
+  }
+
+  @Override
+  public void rollback() {
+    throw new UnsupportedOperationException(
+        "do not call rollback; BatchUpdate always manages transactions");
+  }
+
+  private static class BatchUpdateChanges extends ChangeAccessWrapper {
+    private BatchUpdateChanges(ChangeAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public void insert(Iterable<Change> instances) {
+      throw new UnsupportedOperationException(
+          "do not call insert; change is automatically inserted");
+    }
+
+    @Override
+    public void upsert(Iterable<Change> instances) {
+      throw new UnsupportedOperationException(
+          "do not call upsert; existing changes are updated automatically,"
+              + " or use InsertChangeOp for insertion");
+    }
+
+    @Override
+    public void update(Iterable<Change> instances) {
+      throw new UnsupportedOperationException(
+          "do not call update; change is updated automatically");
+    }
+
+    @Override
+    public void beginTransaction(Change.Id key) {
+      throw new UnsupportedOperationException("updateChange is always called within a transaction");
+    }
+
+    @Override
+    public void deleteKeys(Iterable<Change.Id> keys) {
+      throw new UnsupportedOperationException(
+          "do not call deleteKeys; use ChangeContext#deleteChange()");
+    }
+
+    @Override
+    public void delete(Iterable<Change> instances) {
+      throw new UnsupportedOperationException(
+          "do not call delete; use ChangeContext#deleteChange()");
+    }
+
+    @Override
+    public Change atomicUpdate(Change.Id key, AtomicUpdate<Change> update) {
+      throw new UnsupportedOperationException(
+          "do not call atomicUpdate; updateChange is always called within a transaction");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChainedReceiveCommands.java b/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
rename to java/com/google/gerrit/server/update/ChainedReceiveCommands.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java b/java/com/google/gerrit/server/update/ChangeContext.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
rename to java/com/google/gerrit/server/update/ChangeContext.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeUpdateExecutor.java b/java/com/google/gerrit/server/update/ChangeUpdateExecutor.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeUpdateExecutor.java
rename to java/com/google/gerrit/server/update/ChangeUpdateExecutor.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java b/java/com/google/gerrit/server/update/Context.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
rename to java/com/google/gerrit/server/update/Context.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java b/java/com/google/gerrit/server/update/InsertChangeOp.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java
rename to java/com/google/gerrit/server/update/InsertChangeOp.java
diff --git a/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java b/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
new file mode 100644
index 0000000..c10ae1b
--- /dev/null
+++ b/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
@@ -0,0 +1,457 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Comparator.comparing;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.TreeMap;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * {@link BatchUpdate} implementation using only NoteDb that updates code refs and meta refs in a
+ * single {@link org.eclipse.jgit.lib.BatchRefUpdate}.
+ *
+ * <p>Used when {@code noteDb.changes.disableReviewDb=true}, at which point ReviewDb is not
+ * consulted during updates.
+ */
+public class NoteDbBatchUpdate extends BatchUpdate {
+  public interface AssistedFactory {
+    NoteDbBatchUpdate create(
+        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when);
+  }
+
+  static void execute(
+      ImmutableList<NoteDbBatchUpdate> updates,
+      BatchUpdateListener listener,
+      @Nullable RequestId requestId,
+      boolean dryrun)
+      throws UpdateException, RestApiException {
+    if (updates.isEmpty()) {
+      return;
+    }
+    setRequestIds(updates, requestId);
+
+    try {
+      @SuppressWarnings("deprecation")
+      List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
+          new ArrayList<>();
+      List<ChangesHandle> handles = new ArrayList<>(updates.size());
+      Order order = getOrder(updates, listener);
+      try {
+        switch (order) {
+          case REPO_BEFORE_DB:
+            for (NoteDbBatchUpdate u : updates) {
+              u.executeUpdateRepo();
+            }
+            listener.afterUpdateRepos();
+            for (NoteDbBatchUpdate u : updates) {
+              handles.add(u.executeChangeOps(dryrun));
+            }
+            for (ChangesHandle h : handles) {
+              h.execute();
+              indexFutures.addAll(h.startIndexFutures());
+            }
+            listener.afterUpdateRefs();
+            listener.afterUpdateChanges();
+            break;
+
+          case DB_BEFORE_REPO:
+            // Call updateChange for each op before updateRepo, but defer executing the
+            // NoteDbUpdateManager until after calling updateRepo. They share an inserter and
+            // BatchRefUpdate, so it will all execute as a single batch. But we have to let
+            // NoteDbUpdateManager actually execute the update, since it has to interleave it
+            // properly with All-Users updates.
+            //
+            // TODO(dborowitz): This may still result in multiple updates to All-Users, but that's
+            // currently not a big deal because multi-change batches generally aren't affecting
+            // drafts anyway.
+            for (NoteDbBatchUpdate u : updates) {
+              handles.add(u.executeChangeOps(dryrun));
+            }
+            for (NoteDbBatchUpdate u : updates) {
+              u.executeUpdateRepo();
+            }
+            for (ChangesHandle h : handles) {
+              // TODO(dborowitz): This isn't quite good enough: in theory updateRepo may want to
+              // see the results of change meta commands, but they aren't actually added to the
+              // BatchUpdate until the body of execute. To fix this, execute needs to be split up
+              // into a method that returns a BatchRefUpdate before execution. Not a big deal at the
+              // moment, because this order is only used for deleting changes, and those updateRepo
+              // implementations definitely don't need to observe the updated change meta refs.
+              h.execute();
+              indexFutures.addAll(h.startIndexFutures());
+            }
+            break;
+          default:
+            throw new IllegalStateException("invalid execution order: " + order);
+        }
+      } finally {
+        for (ChangesHandle h : handles) {
+          h.close();
+        }
+      }
+
+      ChangeIndexer.allAsList(indexFutures).get();
+
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates
+          .stream()
+          .filter(u -> u.batchRefUpdate != null)
+          .forEach(
+              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+
+      if (!dryrun) {
+        for (NoteDbBatchUpdate u : updates) {
+          u.executePostOps();
+        }
+      }
+    } catch (Exception e) {
+      wrapAndThrowException(e);
+    }
+  }
+
+  class ContextImpl implements Context {
+    @Override
+    public RepoView getRepoView() throws IOException {
+      return NoteDbBatchUpdate.this.getRepoView();
+    }
+
+    @Override
+    public RevWalk getRevWalk() throws IOException {
+      return getRepoView().getRevWalk();
+    }
+
+    @Override
+    public Project.NameKey getProject() {
+      return project;
+    }
+
+    @Override
+    public Timestamp getWhen() {
+      return when;
+    }
+
+    @Override
+    public TimeZone getTimeZone() {
+      return tz;
+    }
+
+    @Override
+    public ReviewDb getDb() {
+      return db;
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user;
+    }
+
+    @Override
+    public Order getOrder() {
+      return order;
+    }
+  }
+
+  private class RepoContextImpl extends ContextImpl implements RepoContext {
+    @Override
+    public ObjectInserter getInserter() throws IOException {
+      return getRepoView().getInserterWrapper();
+    }
+
+    @Override
+    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
+      getRepoView().getCommands().add(cmd);
+    }
+  }
+
+  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
+    private final ChangeNotes notes;
+    private final Map<PatchSet.Id, ChangeUpdate> updates;
+
+    private boolean deleted;
+
+    protected ChangeContextImpl(ChangeNotes notes) {
+      this.notes = checkNotNull(notes);
+      updates = new TreeMap<>(comparing(PatchSet.Id::get));
+    }
+
+    @Override
+    public ChangeUpdate getUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = updates.get(psId);
+      if (u == null) {
+        u = changeUpdateFactory.create(notes, user, when);
+        if (newChanges.containsKey(notes.getChangeId())) {
+          u.setAllowWriteToNewRef(true);
+        }
+        u.setPatchSetId(psId);
+        updates.put(psId, u);
+      }
+      return u;
+    }
+
+    @Override
+    public ChangeNotes getNotes() {
+      return notes;
+    }
+
+    @Override
+    public void dontBumpLastUpdatedOn() {
+      // Do nothing; NoteDb effectively updates timestamp if and only if a commit was written to the
+      // change meta ref.
+    }
+
+    @Override
+    public void deleteChange() {
+      deleted = true;
+    }
+  }
+
+  /** Per-change result status from {@link #executeChangeOps}. */
+  private enum ChangeResult {
+    SKIPPED,
+    UPSERTED,
+    DELETED;
+  }
+
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeUpdate.Factory changeUpdateFactory;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final ChangeIndexer indexer;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final ReviewDb db;
+
+  @Inject
+  NoteDbBatchUpdate(
+      GitRepositoryManager repoManager,
+      @GerritPersonIdent PersonIdent serverIdent,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeUpdate.Factory changeUpdateFactory,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      ChangeIndexer indexer,
+      GitReferenceUpdated gitRefUpdated,
+      @Assisted ReviewDb db,
+      @Assisted Project.NameKey project,
+      @Assisted CurrentUser user,
+      @Assisted Timestamp when) {
+    super(repoManager, serverIdent, project, user, when);
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeUpdateFactory = changeUpdateFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.indexer = indexer;
+    this.gitRefUpdated = gitRefUpdated;
+    this.db = db;
+  }
+
+  @Override
+  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
+    execute(ImmutableList.of(this), listener, requestId, false);
+  }
+
+  @Override
+  protected Context newContext() {
+    return new ContextImpl();
+  }
+
+  private void executeUpdateRepo() throws UpdateException, RestApiException {
+    try {
+      logDebug("Executing updateRepo on {} ops", ops.size());
+      RepoContextImpl ctx = new RepoContextImpl();
+      for (BatchUpdateOp op : ops.values()) {
+        op.updateRepo(ctx);
+      }
+
+      logDebug("Executing updateRepo on {} RepoOnlyOps", repoOnlyOps.size());
+      for (RepoOnlyOp op : repoOnlyOps) {
+        op.updateRepo(ctx);
+      }
+
+      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
+        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
+        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
+        // first update's executeRefUpdates has finished, hence after first repo's refs have been
+        // updated, which is too late.
+        onSubmitValidators.validate(
+            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
+      }
+    } catch (Exception e) {
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      throw new UpdateException(e);
+    }
+  }
+
+  private class ChangesHandle implements AutoCloseable {
+    private final NoteDbUpdateManager manager;
+    private final boolean dryrun;
+    private final Map<Change.Id, ChangeResult> results;
+
+    ChangesHandle(NoteDbUpdateManager manager, boolean dryrun) {
+      this.manager = manager;
+      this.dryrun = dryrun;
+      results = new HashMap<>();
+    }
+
+    @Override
+    public void close() {
+      manager.close();
+    }
+
+    void setResult(Change.Id id, ChangeResult result) {
+      ChangeResult old = results.putIfAbsent(id, result);
+      checkArgument(old == null, "result for change %s already set: %s", id, old);
+    }
+
+    void execute() throws OrmException, IOException {
+      NoteDbBatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
+    }
+
+    @SuppressWarnings("deprecation")
+    List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> startIndexFutures() {
+      if (dryrun) {
+        return ImmutableList.of();
+      }
+      logDebug("Reindexing {} changes", results.size());
+      List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
+          new ArrayList<>(results.size());
+      for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
+        Change.Id id = e.getKey();
+        switch (e.getValue()) {
+          case UPSERTED:
+            indexFutures.add(indexer.indexAsync(project, id));
+            break;
+          case DELETED:
+            indexFutures.add(indexer.deleteAsync(id));
+            break;
+          case SKIPPED:
+            break;
+          default:
+            throw new IllegalStateException("unexpected result: " + e.getValue());
+        }
+      }
+      return indexFutures;
+    }
+  }
+
+  private ChangesHandle executeChangeOps(boolean dryrun) throws Exception {
+    logDebug("Executing change ops");
+    initRepository();
+    Repository repo = repoView.getRepository();
+    checkState(
+        repo.getRefDatabase().performsAtomicTransactions(),
+        "cannot use NoteDb with a repository that does not support atomic batch ref updates: %s",
+        repo);
+
+    ChangesHandle handle =
+        new ChangesHandle(
+            updateManagerFactory
+                .create(project)
+                .setChangeRepo(
+                    repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
+            dryrun);
+    if (user.isIdentifiedUser()) {
+      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+    }
+    handle.manager.setRefLogMessage(refLogMessage);
+    handle.manager.setPushCertificate(pushCert);
+    for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
+      Change.Id id = e.getKey();
+      ChangeContextImpl ctx = newChangeContext(id);
+      boolean dirty = false;
+      logDebug("Applying {} ops for change {}", e.getValue().size(), id);
+      for (BatchUpdateOp op : e.getValue()) {
+        dirty |= op.updateChange(ctx);
+      }
+      if (!dirty) {
+        logDebug("No ops reported dirty, short-circuiting");
+        handle.setResult(id, ChangeResult.SKIPPED);
+        continue;
+      }
+      for (ChangeUpdate u : ctx.updates.values()) {
+        handle.manager.add(u);
+      }
+      if (ctx.deleted) {
+        logDebug("Change {} was deleted", id);
+        handle.manager.deleteChange(id);
+        handle.setResult(id, ChangeResult.DELETED);
+      } else {
+        handle.setResult(id, ChangeResult.UPSERTED);
+      }
+    }
+    return handle;
+  }
+
+  private ChangeContextImpl newChangeContext(Change.Id id) throws OrmException {
+    logDebug("Opening change {} for update", id);
+    Change c = newChanges.get(id);
+    boolean isNew = c != null;
+    if (!isNew) {
+      // Pass a synthetic change into ChangeNotes.Factory, which will take care of checking for
+      // existence and populating columns from the parsed notes state.
+      // TODO(dborowitz): This dance made more sense when using Reviewdb; consider a nicer way.
+      c = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
+    } else {
+      logDebug("Change {} is new", id);
+    }
+    ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
+    return new ChangeContextImpl(notes);
+  }
+
+  private void executePostOps() throws Exception {
+    ContextImpl ctx = new ContextImpl();
+    for (BatchUpdateOp op : ops.values()) {
+      op.postUpdate(ctx);
+    }
+
+    for (RepoOnlyOp op : repoOnlyOps) {
+      op.postUpdate(ctx);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/Order.java b/java/com/google/gerrit/server/update/Order.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/update/Order.java
rename to java/com/google/gerrit/server/update/Order.java
diff --git a/java/com/google/gerrit/server/update/RefUpdateUtil.java b/java/com/google/gerrit/server/update/RefUpdateUtil.java
new file mode 100644
index 0000000..3e33677
--- /dev/null
+++ b/java/com/google/gerrit/server/update/RefUpdateUtil.java
@@ -0,0 +1,152 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.server.git.LockFailureException;
+import java.io.IOException;
+import org.eclipse.jgit.internal.JGitText;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/** Static utilities for working with JGit's ref update APIs. */
+public class RefUpdateUtil {
+  /**
+   * Execute a batch ref update, throwing a checked exception if not all updates succeeded.
+   *
+   * <p>Creates a new {@link RevWalk} used only for this operation.
+   *
+   * @param bru batch update; should already have been executed.
+   * @param repo repository that created {@code bru}.
+   * @throws LockFailureException if the transaction was aborted due to lock failure; see {@link
+   *     #checkResults(BatchRefUpdate)} for details.
+   * @throws IOException if any result was not {@code OK}.
+   */
+  public static void executeChecked(BatchRefUpdate bru, Repository repo) throws IOException {
+    try (RevWalk rw = new RevWalk(repo)) {
+      executeChecked(bru, rw);
+    }
+  }
+
+  /**
+   * Execute a batch ref update, throwing a checked exception if not all updates succeeded.
+   *
+   * @param bru batch update; should already have been executed.
+   * @param rw walk for executing the update.
+   * @throws LockFailureException if the transaction was aborted due to lock failure; see {@link
+   *     #checkResults(BatchRefUpdate)} for details.
+   * @throws IOException if any result was not {@code OK}.
+   */
+  public static void executeChecked(BatchRefUpdate bru, RevWalk rw) throws IOException {
+    bru.execute(rw, NullProgressMonitor.INSTANCE);
+    checkResults(bru);
+  }
+
+  /**
+   * Check results of all commands in the update batch, reducing to a single exception if there was
+   * a failure.
+   *
+   * <p>Throws {@link LockFailureException} if at least one command failed with {@code
+   * LOCK_FAILURE}, and the entire transaction was aborted, i.e. any non-{@code LOCK_FAILURE}
+   * results, if there were any, failed with "transaction aborted".
+   *
+   * <p>In particular, if the underlying ref database does not {@link
+   * org.eclipse.jgit.lib.RefDatabase#performsAtomicTransactions() perform atomic transactions},
+   * then a combination of {@code LOCK_FAILURE} on one ref and {@code OK} or another result on other
+   * refs will <em>not</em> throw {@code LockFailureException}.
+   *
+   * @param bru batch update; should already have been executed.
+   * @throws LockFailureException if the transaction was aborted due to lock failure.
+   * @throws IOException if any result was not {@code OK}.
+   */
+  @VisibleForTesting
+  static void checkResults(BatchRefUpdate bru) throws IOException {
+    if (bru.getCommands().isEmpty()) {
+      return;
+    }
+
+    int lockFailure = 0;
+    int aborted = 0;
+    int failure = 0;
+
+    for (ReceiveCommand cmd : bru.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        failure++;
+      }
+      if (cmd.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
+        lockFailure++;
+      } else if (cmd.getResult() == ReceiveCommand.Result.REJECTED_OTHER_REASON
+          && JGitText.get().transactionAborted.equals(cmd.getMessage())) {
+        aborted++;
+      }
+    }
+
+    if (lockFailure + aborted == bru.getCommands().size()) {
+      throw new LockFailureException("Update aborted with one or more lock failures: " + bru, bru);
+    } else if (failure > 0) {
+      throw new IOException("Update failed: " + bru);
+    }
+  }
+
+  /**
+   * Delete a single ref, throwing a checked exception on failure.
+   *
+   * <p>Does not require that the ref have any particular old value. Succeeds as a no-op if the ref
+   * did not exist.
+   *
+   * @param repo repository.
+   * @param refName ref name to delete.
+   * @throws LockFailureException if a low-level lock failure (e.g. compare-and-swap failure)
+   *     occurs.
+   * @throws IOException if an error occurred.
+   */
+  public static void deleteChecked(Repository repo, String refName) throws IOException {
+    RefUpdate ru = repo.updateRef(refName);
+    ru.setForceUpdate(true);
+    switch (ru.delete()) {
+      case FORCED:
+        // Ref was deleted.
+        return;
+
+      case NEW:
+        // Ref didn't exist (yes, really).
+        return;
+
+      case LOCK_FAILURE:
+        throw new LockFailureException("Failed to delete " + refName + ": " + ru.getResult(), ru);
+
+        // Not really failures, but should not be the result of a deletion, so the best option is to
+        // throw.
+      case NO_CHANGE:
+      case FAST_FORWARD:
+      case RENAMED:
+      case NOT_ATTEMPTED:
+
+      case IO_FAILURE:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+      default:
+        throw new IOException("Failed to delete " + refName + ": " + ru.getResult());
+    }
+  }
+
+  private RefUpdateUtil() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java b/java/com/google/gerrit/server/update/RepoContext.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java
rename to java/com/google/gerrit/server/update/RepoContext.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoOnlyOp.java b/java/com/google/gerrit/server/update/RepoOnlyOp.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/update/RepoOnlyOp.java
rename to java/com/google/gerrit/server/update/RepoOnlyOp.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoView.java b/java/com/google/gerrit/server/update/RepoView.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/update/RepoView.java
rename to java/com/google/gerrit/server/update/RepoView.java
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
new file mode 100644
index 0000000..0f5b00f
--- /dev/null
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -0,0 +1,327 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.github.rholder.retry.Attempt;
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.RetryListener;
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.github.rholder.retry.WaitStrategies;
+import com.github.rholder.retry.WaitStrategy;
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Predicate;
+import com.google.common.base.Throwables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram1;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class RetryHelper {
+  @FunctionalInterface
+  public interface ChangeAction<T> {
+    T call(BatchUpdate.Factory batchUpdateFactory) throws Exception;
+  }
+
+  @FunctionalInterface
+  public interface Action<T> {
+    T call() throws Exception;
+  }
+
+  public enum ActionType {
+    ACCOUNT_UPDATE,
+    CHANGE_QUERY,
+    CHANGE_UPDATE
+  }
+
+  /**
+   * Options for retrying a single operation.
+   *
+   * <p>This class is similar in function to upstream's {@link RetryerBuilder}, but it exists as its
+   * own class in Gerrit for several reasons:
+   *
+   * <ul>
+   *   <li>Gerrit needs to support defaults for some of the options, such as a default timeout.
+   *       {@code RetryerBuilder} doesn't support calling the same setter multiple times, so doing
+   *       this with {@code RetryerBuilder} directly would not be easy.
+   *   <li>Gerrit explicitly does not want callers to have full control over all possible options,
+   *       so this class exposes a curated subset.
+   * </ul>
+   */
+  @AutoValue
+  public abstract static class Options {
+    @Nullable
+    abstract RetryListener listener();
+
+    @Nullable
+    abstract Duration timeout();
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      public abstract Builder listener(RetryListener listener);
+
+      public abstract Builder timeout(Duration timeout);
+
+      public abstract Options build();
+    }
+  }
+
+  @VisibleForTesting
+  @Singleton
+  public static class Metrics {
+    final Histogram1<ActionType> attemptCounts;
+    final Counter1<ActionType> timeoutCount;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      Field<ActionType> view = Field.ofEnum(ActionType.class, "action_type");
+      attemptCounts =
+          metricMaker.newHistogram(
+              "action/retry_attempt_counts",
+              new Description(
+                      "Distribution of number of attempts made by RetryHelper to execute an action"
+                          + " (1 == single attempt, no retry)")
+                  .setCumulative()
+                  .setUnit("attempts"),
+              view);
+      timeoutCount =
+          metricMaker.newCounter(
+              "action/retry_timeout_count",
+              new Description(
+                      "Number of action executions of RetryHelper that ultimately timed out")
+                  .setCumulative()
+                  .setUnit("timeouts"),
+              view);
+    }
+  }
+
+  public static Options.Builder options() {
+    return new AutoValue_RetryHelper_Options.Builder();
+  }
+
+  private static Options defaults() {
+    return options().build();
+  }
+
+  private final NotesMigration migration;
+  private final Metrics metrics;
+  private final BatchUpdate.Factory updateFactory;
+  private final Duration defaultTimeout;
+  private final WaitStrategy waitStrategy;
+  @Nullable private final Consumer<RetryerBuilder<?>> overwriteDefaultRetryerStrategySetup;
+
+  @Inject
+  RetryHelper(
+      @GerritServerConfig Config cfg,
+      Metrics metrics,
+      NotesMigration migration,
+      ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory,
+      NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory) {
+    this(cfg, metrics, migration, reviewDbBatchUpdateFactory, noteDbBatchUpdateFactory, null);
+  }
+
+  @VisibleForTesting
+  public RetryHelper(
+      @GerritServerConfig Config cfg,
+      Metrics metrics,
+      NotesMigration migration,
+      ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory,
+      NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory,
+      @Nullable Consumer<RetryerBuilder<?>> overwriteDefaultRetryerStrategySetup) {
+    this.metrics = metrics;
+    this.migration = migration;
+    this.updateFactory =
+        new BatchUpdate.Factory(migration, reviewDbBatchUpdateFactory, noteDbBatchUpdateFactory);
+    this.defaultTimeout =
+        Duration.ofMillis(
+            cfg.getTimeUnit("noteDb", null, "retryTimeout", SECONDS.toMillis(20), MILLISECONDS));
+    this.waitStrategy =
+        WaitStrategies.join(
+            WaitStrategies.exponentialWait(
+                cfg.getTimeUnit("noteDb", null, "retryMaxWait", SECONDS.toMillis(5), MILLISECONDS),
+                MILLISECONDS),
+            WaitStrategies.randomWait(50, MILLISECONDS));
+    this.overwriteDefaultRetryerStrategySetup = overwriteDefaultRetryerStrategySetup;
+  }
+
+  public Duration getDefaultTimeout() {
+    return defaultTimeout;
+  }
+
+  public <T> T execute(ActionType actionType, Action<T> action)
+      throws IOException, ConfigInvalidException, OrmException {
+    return execute(actionType, action, t -> t instanceof LockFailureException);
+  }
+
+  public <T> T execute(
+      ActionType actionType, Action<T> action, Predicate<Throwable> exceptionPredicate)
+      throws IOException, ConfigInvalidException, OrmException {
+    try {
+      return execute(actionType, action, defaults(), exceptionPredicate);
+    } catch (Throwable t) {
+      Throwables.throwIfUnchecked(t);
+      Throwables.throwIfInstanceOf(t, IOException.class);
+      Throwables.throwIfInstanceOf(t, ConfigInvalidException.class);
+      Throwables.throwIfInstanceOf(t, OrmException.class);
+      throw new OrmException(t);
+    }
+  }
+
+  public <T> T execute(ChangeAction<T> changeAction) throws RestApiException, UpdateException {
+    return execute(changeAction, defaults());
+  }
+
+  public <T> T execute(ChangeAction<T> changeAction, Options opts)
+      throws RestApiException, UpdateException {
+    try {
+      if (!migration.disableChangeReviewDb()) {
+        // Either we aren't full-NoteDb, or the underlying ref storage doesn't support atomic
+        // transactions. Either way, retrying a partially-failed operation is not idempotent, so
+        // don't do it automatically. Let the end user decide whether they want to retry.
+        return execute(
+            ActionType.CHANGE_UPDATE,
+            () -> changeAction.call(updateFactory),
+            RetryerBuilder.<T>newBuilder().build());
+      }
+
+      return execute(
+          ActionType.CHANGE_UPDATE,
+          () -> changeAction.call(updateFactory),
+          opts,
+          t -> {
+            if (t instanceof UpdateException) {
+              t = t.getCause();
+            }
+            return t instanceof LockFailureException;
+          });
+    } catch (Throwable t) {
+      Throwables.throwIfUnchecked(t);
+      Throwables.throwIfInstanceOf(t, UpdateException.class);
+      Throwables.throwIfInstanceOf(t, RestApiException.class);
+      throw new UpdateException(t);
+    }
+  }
+
+  /**
+   * Executes an action with a given retryer.
+   *
+   * @param actionType the type of the action
+   * @param action the action which should be executed and retried on failure
+   * @param opts options for retrying the action on failure
+   * @param exceptionPredicate predicate to control on which exception the action should be retried
+   * @return the result of executing the action
+   * @throws Throwable any error or exception that made the action fail, callers are expected to
+   *     catch and inspect this Throwable to decide carefully whether it should be re-thrown
+   */
+  private <T> T execute(
+      ActionType actionType,
+      Action<T> action,
+      Options opts,
+      Predicate<Throwable> exceptionPredicate)
+      throws Throwable {
+    MetricListener listener = new MetricListener();
+    try {
+      RetryerBuilder<T> retryerBuilder = createRetryerBuilder(opts, exceptionPredicate);
+      retryerBuilder.withRetryListener(listener);
+      return execute(actionType, action, retryerBuilder.build());
+    } finally {
+      metrics.attemptCounts.record(actionType, listener.getAttemptCount());
+    }
+  }
+
+  /**
+   * Executes an action with a given retryer.
+   *
+   * @param actionType the type of the action
+   * @param action the action which should be executed and retried on failure
+   * @param retryer the retryer
+   * @return the result of executing the action
+   * @throws Throwable any error or exception that made the action fail, callers are expected to
+   *     catch and inspect this Throwable to decide carefully whether it should be re-thrown
+   */
+  private <T> T execute(ActionType actionType, Action<T> action, Retryer<T> retryer)
+      throws Throwable {
+    try {
+      return retryer.call(() -> action.call());
+    } catch (ExecutionException | RetryException e) {
+      if (e instanceof RetryException) {
+        metrics.timeoutCount.increment(actionType);
+      }
+      if (e.getCause() != null) {
+        throw e.getCause();
+      }
+      throw e;
+    }
+  }
+
+  private <O> RetryerBuilder<O> createRetryerBuilder(
+      Options opts, Predicate<Throwable> exceptionPredicate) {
+    RetryerBuilder<O> retryerBuilder =
+        RetryerBuilder.<O>newBuilder().retryIfException(exceptionPredicate);
+    if (opts.listener() != null) {
+      retryerBuilder.withRetryListener(opts.listener());
+    }
+
+    if (overwriteDefaultRetryerStrategySetup != null) {
+      overwriteDefaultRetryerStrategySetup.accept(retryerBuilder);
+      return retryerBuilder;
+    }
+
+    return retryerBuilder
+        .withStopStrategy(
+            StopStrategies.stopAfterDelay(
+                firstNonNull(opts.timeout(), defaultTimeout).toMillis(), MILLISECONDS))
+        .withWaitStrategy(waitStrategy);
+  }
+
+  private static class MetricListener implements RetryListener {
+    private long attemptCount;
+
+    MetricListener() {
+      attemptCount = 1;
+    }
+
+    @Override
+    public <V> void onRetry(Attempt<V> attempt) {
+      attemptCount = attempt.getAttemptNumber();
+    }
+
+    long getAttemptCount() {
+      return attemptCount;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryingRestModifyView.java b/java/com/google/gerrit/server/update/RetryingRestModifyView.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/update/RetryingRestModifyView.java
rename to java/com/google/gerrit/server/update/RetryingRestModifyView.java
diff --git a/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java b/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
new file mode 100644
index 0000000..07ae04d
--- /dev/null
+++ b/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
@@ -0,0 +1,859 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Comparator.comparing;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.InsertedObject;
+import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.MismatchedStateException;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.TreeMap;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * {@link BatchUpdate} implementation that supports mixed ReviewDb/NoteDb operations, depending on
+ * the migration state specified in {@link NotesMigration}.
+ *
+ * <p>When performing change updates in a mixed ReviewDb/NoteDb environment with ReviewDb primary,
+ * the order of operations is very subtle:
+ *
+ * <ol>
+ *   <li>Stage NoteDb updates to get the new NoteDb state, but do not write to the repo.
+ *   <li>Write the new state in the Change entity, and commit this to ReviewDb.
+ *   <li>Update NoteDb, ignoring any write failures.
+ * </ol>
+ *
+ * The implementation in this class is well-tested, and it is strongly recommended that you not
+ * attempt to reimplement this logic. Use {@code BatchUpdate} if at all possible.
+ */
+public class ReviewDbBatchUpdate extends BatchUpdate {
+  private static final Logger log = LoggerFactory.getLogger(ReviewDbBatchUpdate.class);
+
+  public interface AssistedFactory {
+    ReviewDbBatchUpdate create(
+        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when);
+  }
+
+  class ContextImpl implements Context {
+    @Override
+    public RepoView getRepoView() throws IOException {
+      return ReviewDbBatchUpdate.this.getRepoView();
+    }
+
+    @Override
+    public RevWalk getRevWalk() throws IOException {
+      return getRepoView().getRevWalk();
+    }
+
+    @Override
+    public Project.NameKey getProject() {
+      return project;
+    }
+
+    @Override
+    public Timestamp getWhen() {
+      return when;
+    }
+
+    @Override
+    public TimeZone getTimeZone() {
+      return tz;
+    }
+
+    @Override
+    public ReviewDb getDb() {
+      return db;
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user;
+    }
+
+    @Override
+    public Order getOrder() {
+      return order;
+    }
+  }
+
+  private class RepoContextImpl extends ContextImpl implements RepoContext {
+    @Override
+    public ObjectInserter getInserter() throws IOException {
+      return getRepoView().getInserterWrapper();
+    }
+
+    @Override
+    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
+      initRepository();
+      repoView.getCommands().add(cmd);
+    }
+  }
+
+  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
+    private final ChangeNotes notes;
+    private final Map<PatchSet.Id, ChangeUpdate> updates;
+    private final ReviewDbWrapper dbWrapper;
+    private final Repository threadLocalRepo;
+    private final RevWalk threadLocalRevWalk;
+
+    private boolean deleted;
+    private boolean bumpLastUpdatedOn = true;
+
+    protected ChangeContextImpl(
+        ChangeNotes notes, ReviewDbWrapper dbWrapper, Repository repo, RevWalk rw) {
+      this.notes = checkNotNull(notes);
+      this.dbWrapper = dbWrapper;
+      this.threadLocalRepo = repo;
+      this.threadLocalRevWalk = rw;
+      updates = new TreeMap<>(comparing(PatchSet.Id::get));
+    }
+
+    @Override
+    public ReviewDb getDb() {
+      checkNotNull(dbWrapper);
+      return dbWrapper;
+    }
+
+    @Override
+    public RevWalk getRevWalk() {
+      return threadLocalRevWalk;
+    }
+
+    @Override
+    public ChangeUpdate getUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = updates.get(psId);
+      if (u == null) {
+        u = changeUpdateFactory.create(notes, user, when);
+        if (newChanges.containsKey(notes.getChangeId())) {
+          u.setAllowWriteToNewRef(true);
+        }
+        u.setPatchSetId(psId);
+        updates.put(psId, u);
+      }
+      return u;
+    }
+
+    @Override
+    public ChangeNotes getNotes() {
+      return notes;
+    }
+
+    @Override
+    public void dontBumpLastUpdatedOn() {
+      bumpLastUpdatedOn = false;
+    }
+
+    @Override
+    public void deleteChange() {
+      deleted = true;
+    }
+  }
+
+  @Singleton
+  private static class Metrics {
+    final Timer1<Boolean> executeChangeOpsLatency;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      executeChangeOpsLatency =
+          metricMaker.newTimer(
+              "batch_update/execute_change_ops",
+              new Description("BatchUpdate change update latency, excluding reindexing")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS),
+              Field.ofBoolean("success"));
+    }
+  }
+
+  static void execute(
+      ImmutableList<ReviewDbBatchUpdate> updates,
+      BatchUpdateListener listener,
+      @Nullable RequestId requestId,
+      boolean dryrun)
+      throws UpdateException, RestApiException {
+    if (updates.isEmpty()) {
+      return;
+    }
+    setRequestIds(updates, requestId);
+    try {
+      Order order = getOrder(updates, listener);
+      boolean updateChangesInParallel = getUpdateChangesInParallel(updates);
+      switch (order) {
+        case REPO_BEFORE_DB:
+          for (ReviewDbBatchUpdate u : updates) {
+            u.executeUpdateRepo();
+          }
+          listener.afterUpdateRepos();
+          for (ReviewDbBatchUpdate u : updates) {
+            u.executeRefUpdates(dryrun);
+          }
+          listener.afterUpdateRefs();
+          for (ReviewDbBatchUpdate u : updates) {
+            u.reindexChanges(u.executeChangeOps(updateChangesInParallel, dryrun));
+          }
+          listener.afterUpdateChanges();
+          break;
+        case DB_BEFORE_REPO:
+          for (ReviewDbBatchUpdate u : updates) {
+            u.reindexChanges(u.executeChangeOps(updateChangesInParallel, dryrun));
+          }
+          for (ReviewDbBatchUpdate u : updates) {
+            u.executeUpdateRepo();
+          }
+          for (ReviewDbBatchUpdate u : updates) {
+            u.executeRefUpdates(dryrun);
+          }
+          break;
+        default:
+          throw new IllegalStateException("invalid execution order: " + order);
+      }
+
+      ChangeIndexer.allAsList(
+              updates.stream().flatMap(u -> u.indexFutures.stream()).collect(toList()))
+          .get();
+
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates
+          .stream()
+          .filter(u -> u.batchRefUpdate != null)
+          .forEach(
+              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+
+      if (!dryrun) {
+        for (ReviewDbBatchUpdate u : updates) {
+          u.executePostOps();
+        }
+      }
+    } catch (Exception e) {
+      wrapAndThrowException(e);
+    }
+  }
+
+  private final AllUsersName allUsers;
+  private final ChangeIndexer indexer;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeUpdate.Factory changeUpdateFactory;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final ListeningExecutorService changeUpdateExector;
+  private final Metrics metrics;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final NotesMigration notesMigration;
+  private final ReviewDb db;
+  private final SchemaFactory<ReviewDb> schemaFactory;
+  private final long skewMs;
+
+  @SuppressWarnings("deprecation")
+  private final List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
+      new ArrayList<>();
+
+  @Inject
+  ReviewDbBatchUpdate(
+      @GerritServerConfig Config cfg,
+      AllUsersName allUsers,
+      ChangeIndexer indexer,
+      ChangeNotes.Factory changeNotesFactory,
+      @ChangeUpdateExecutor ListeningExecutorService changeUpdateExector,
+      ChangeUpdate.Factory changeUpdateFactory,
+      @GerritPersonIdent PersonIdent serverIdent,
+      GitReferenceUpdated gitRefUpdated,
+      GitRepositoryManager repoManager,
+      Metrics metrics,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      NotesMigration notesMigration,
+      SchemaFactory<ReviewDb> schemaFactory,
+      @Assisted ReviewDb db,
+      @Assisted Project.NameKey project,
+      @Assisted CurrentUser user,
+      @Assisted Timestamp when) {
+    super(repoManager, serverIdent, project, user, when);
+    this.allUsers = allUsers;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeUpdateExector = changeUpdateExector;
+    this.changeUpdateFactory = changeUpdateFactory;
+    this.gitRefUpdated = gitRefUpdated;
+    this.indexer = indexer;
+    this.metrics = metrics;
+    this.notesMigration = notesMigration;
+    this.schemaFactory = schemaFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.db = db;
+    skewMs = NoteDbChangeState.getReadOnlySkew(cfg);
+  }
+
+  @Override
+  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
+    execute(ImmutableList.of(this), listener, requestId, false);
+  }
+
+  @Override
+  protected Context newContext() {
+    return new ContextImpl();
+  }
+
+  private void executeUpdateRepo() throws UpdateException, RestApiException {
+    try {
+      logDebug("Executing updateRepo on {} ops", ops.size());
+      RepoContextImpl ctx = new RepoContextImpl();
+      for (BatchUpdateOp op : ops.values()) {
+        op.updateRepo(ctx);
+      }
+
+      logDebug("Executing updateRepo on {} RepoOnlyOps", repoOnlyOps.size());
+      for (RepoOnlyOp op : repoOnlyOps) {
+        op.updateRepo(ctx);
+      }
+
+      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
+        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
+        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
+        // first update's executeRefUpdates has finished, hence after first repo's refs have been
+        // updated, which is too late.
+        onSubmitValidators.validate(
+            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
+      }
+
+      if (repoView != null) {
+        logDebug("Flushing inserter");
+        repoView.getInserter().flush();
+      } else {
+        logDebug("No objects to flush");
+      }
+    } catch (Exception e) {
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      throw new UpdateException(e);
+    }
+  }
+
+  private void executeRefUpdates(boolean dryrun) throws IOException, RestApiException {
+    if (getRefUpdates().isEmpty()) {
+      logDebug("No ref updates to execute");
+      return;
+    }
+    // May not be opened if the caller added ref updates but no new objects.
+    // TODO(dborowitz): Really?
+    initRepository();
+    batchRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
+    batchRefUpdate.setPushCertificate(pushCert);
+    batchRefUpdate.setRefLogMessage(refLogMessage, true);
+    batchRefUpdate.setAllowNonFastForwards(true);
+    repoView.getCommands().addTo(batchRefUpdate);
+    if (user.isIdentifiedUser()) {
+      batchRefUpdate.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+    }
+    logDebug("Executing batch of {} ref updates", batchRefUpdate.getCommands().size());
+    if (dryrun) {
+      return;
+    }
+
+    // Force BatchRefUpdate to read newly referenced objects using a new RevWalk, rather than one
+    // that might have access to unflushed objects.
+    try (RevWalk updateRw = new RevWalk(repoView.getRepository())) {
+      batchRefUpdate.execute(updateRw, NullProgressMonitor.INSTANCE);
+    }
+    boolean ok = true;
+    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        ok = false;
+        break;
+      }
+    }
+    if (!ok) {
+      throw new RestApiException("BatchRefUpdate failed: " + batchRefUpdate);
+    }
+  }
+
+  private List<ChangeTask> executeChangeOps(boolean parallel, boolean dryrun)
+      throws UpdateException, RestApiException {
+    List<ChangeTask> tasks;
+    boolean success = false;
+    Stopwatch sw = Stopwatch.createStarted();
+    try {
+      logDebug("Executing change ops (parallel? {})", parallel);
+      ListeningExecutorService executor =
+          parallel ? changeUpdateExector : MoreExecutors.newDirectExecutorService();
+
+      tasks = new ArrayList<>(ops.keySet().size());
+      try {
+        if (notesMigration.commitChangeWrites() && repoView != null) {
+          // A NoteDb change may have been rebuilt since the repo was originally
+          // opened, so make sure we see that.
+          logDebug("Preemptively scanning for repo changes");
+          repoView.getRepository().scanForRepoChanges();
+        }
+        if (!ops.isEmpty() && notesMigration.failChangeWrites()) {
+          // Fail fast before attempting any writes if changes are read-only, as
+          // this is a programmer error.
+          logDebug("Failing early due to read-only Changes table");
+          throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
+        }
+        List<ListenableFuture<?>> futures = new ArrayList<>(ops.keySet().size());
+        for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
+          ChangeTask task =
+              new ChangeTask(e.getKey(), e.getValue(), Thread.currentThread(), dryrun);
+          tasks.add(task);
+          if (!parallel) {
+            logDebug("Direct execution of task for ops: {}", ops);
+          }
+          futures.add(executor.submit(task));
+        }
+        if (parallel) {
+          logDebug(
+              "Waiting on futures for {} ops spanning {} changes", ops.size(), ops.keySet().size());
+        }
+        Futures.allAsList(futures).get();
+
+        if (notesMigration.commitChangeWrites()) {
+          if (!dryrun) {
+            executeNoteDbUpdates(tasks);
+          }
+        }
+        success = true;
+      } catch (ExecutionException | InterruptedException e) {
+        Throwables.throwIfInstanceOf(e.getCause(), UpdateException.class);
+        Throwables.throwIfInstanceOf(e.getCause(), RestApiException.class);
+        throw new UpdateException(e);
+      } catch (OrmException | IOException e) {
+        throw new UpdateException(e);
+      }
+    } finally {
+      metrics.executeChangeOpsLatency.record(success, sw.elapsed(NANOSECONDS), NANOSECONDS);
+    }
+    return tasks;
+  }
+
+  private void reindexChanges(List<ChangeTask> tasks) {
+    // Reindex changes.
+    for (ChangeTask task : tasks) {
+      if (task.deleted) {
+        indexFutures.add(indexer.deleteAsync(task.id));
+      } else if (task.dirty) {
+        indexFutures.add(indexer.indexAsync(project, task.id));
+      }
+    }
+  }
+
+  private void executeNoteDbUpdates(List<ChangeTask> tasks)
+      throws ResourceConflictException, IOException {
+    // Aggregate together all NoteDb ref updates from the ops we executed,
+    // possibly in parallel. Each task had its own NoteDbUpdateManager instance
+    // with its own thread-local copy of the repo(s), but each of those was just
+    // used for staging updates and was never executed.
+    //
+    // Use a new BatchRefUpdate as the original batchRefUpdate field is intended
+    // for use only by the updateRepo phase.
+    //
+    // See the comments in NoteDbUpdateManager#execute() for why we execute the
+    // updates on the change repo first.
+    logDebug("Executing NoteDb updates for {} changes", tasks.size());
+    try {
+      initRepository();
+      BatchRefUpdate changeRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
+      boolean hasAllUsersCommands = false;
+      try (ObjectInserter ins = repoView.getRepository().newObjectInserter()) {
+        int objs = 0;
+        for (ChangeTask task : tasks) {
+          if (task.noteDbResult == null) {
+            logDebug("No-op update to {}", task.id);
+            continue;
+          }
+          for (ReceiveCommand cmd : task.noteDbResult.changeCommands()) {
+            changeRefUpdate.addCommand(cmd);
+          }
+          for (InsertedObject obj : task.noteDbResult.changeObjects()) {
+            objs++;
+            ins.insert(obj.type(), obj.data().toByteArray());
+          }
+          hasAllUsersCommands |= !task.noteDbResult.allUsersCommands().isEmpty();
+        }
+        logDebug(
+            "Collected {} objects and {} ref updates to change repo",
+            objs,
+            changeRefUpdate.getCommands().size());
+        executeNoteDbUpdate(getRevWalk(), ins, changeRefUpdate);
+      }
+
+      if (hasAllUsersCommands) {
+        try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+            RevWalk allUsersRw = new RevWalk(allUsersRepo);
+            ObjectInserter allUsersIns = allUsersRepo.newObjectInserter()) {
+          int objs = 0;
+          BatchRefUpdate allUsersRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+          for (ChangeTask task : tasks) {
+            for (ReceiveCommand cmd : task.noteDbResult.allUsersCommands()) {
+              allUsersRefUpdate.addCommand(cmd);
+            }
+            for (InsertedObject obj : task.noteDbResult.allUsersObjects()) {
+              allUsersIns.insert(obj.type(), obj.data().toByteArray());
+            }
+          }
+          logDebug(
+              "Collected {} objects and {} ref updates to All-Users",
+              objs,
+              allUsersRefUpdate.getCommands().size());
+          executeNoteDbUpdate(allUsersRw, allUsersIns, allUsersRefUpdate);
+        }
+      } else {
+        logDebug("No All-Users updates");
+      }
+    } catch (IOException e) {
+      if (tasks.stream().allMatch(t -> t.storage == PrimaryStorage.REVIEW_DB)) {
+        // Ignore all errors trying to update NoteDb at this point. We've already written the
+        // NoteDbChangeStates to ReviewDb, which means if any state is out of date it will be
+        // rebuilt the next time it is needed.
+        //
+        // Always log even without RequestId.
+        log.debug("Ignoring NoteDb update error after ReviewDb write", e);
+
+        // Otherwise, we can't prove it's safe to ignore the error, either because some change had
+        // NOTE_DB primary, or a task failed before determining the primary storage.
+      } else if (e instanceof LockFailureException) {
+        // LOCK_FAILURE is a special case indicating there was a conflicting write to a meta ref,
+        // although it happened too late for us to produce anything but a generic error message.
+        throw new ResourceConflictException("Updating change failed due to conflicting write", e);
+      }
+      throw e;
+    }
+  }
+
+  private void executeNoteDbUpdate(RevWalk rw, ObjectInserter ins, BatchRefUpdate bru)
+      throws IOException {
+    if (bru.getCommands().isEmpty()) {
+      logDebug("No commands, skipping flush and ref update");
+      return;
+    }
+    ins.flush();
+    bru.setAllowNonFastForwards(true);
+    bru.execute(rw, NullProgressMonitor.INSTANCE);
+    for (ReceiveCommand cmd : bru.getCommands()) {
+      // TODO(dborowitz): LOCK_FAILURE for NoteDb primary should be retried.
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        throw new IOException("Update failed: " + bru);
+      }
+    }
+  }
+
+  private class ChangeTask implements Callable<Void> {
+    final Change.Id id;
+    private final Collection<BatchUpdateOp> changeOps;
+    private final Thread mainThread;
+    private final boolean dryrun;
+
+    PrimaryStorage storage;
+    NoteDbUpdateManager.StagedResult noteDbResult;
+    boolean dirty;
+    boolean deleted;
+    private String taskId;
+
+    private ChangeTask(
+        Change.Id id, Collection<BatchUpdateOp> changeOps, Thread mainThread, boolean dryrun) {
+      this.id = id;
+      this.changeOps = changeOps;
+      this.mainThread = mainThread;
+      this.dryrun = dryrun;
+    }
+
+    @Override
+    public Void call() throws Exception {
+      taskId = id.toString() + "-" + Thread.currentThread().getId();
+      if (Thread.currentThread() == mainThread) {
+        initRepository();
+        Repository repo = repoView.getRepository();
+        try (RevWalk rw = new RevWalk(repo)) {
+          call(ReviewDbBatchUpdate.this.db, repo, rw);
+        }
+      } else {
+        // Possible optimization: allow Ops to declare whether they need to
+        // access the repo from updateChange, and don't open in this thread
+        // unless we need it. However, as of this writing the only operations
+        // that are executed in parallel are during ReceiveCommits, and they
+        // all need the repo open anyway. (The non-parallel case above does not
+        // reopen the repo.)
+        try (ReviewDb threadLocalDb = schemaFactory.open();
+            Repository repo = repoManager.openRepository(project);
+            RevWalk rw = new RevWalk(repo)) {
+          call(threadLocalDb, repo, rw);
+        }
+      }
+      return null;
+    }
+
+    private void call(ReviewDb db, Repository repo, RevWalk rw) throws Exception {
+      @SuppressWarnings("resource") // Not always opened.
+      NoteDbUpdateManager updateManager = null;
+      try {
+        db.changes().beginTransaction(id);
+        try {
+          ChangeContextImpl ctx = newChangeContext(db, repo, rw, id);
+          NoteDbChangeState oldState = NoteDbChangeState.parse(ctx.getChange());
+          NoteDbChangeState.checkNotReadOnly(oldState, skewMs);
+
+          storage = PrimaryStorage.of(oldState);
+          if (storage == PrimaryStorage.NOTE_DB && !notesMigration.readChanges()) {
+            throw new OrmException("must have NoteDb enabled to update change " + id);
+          }
+
+          // Call updateChange on each op.
+          logDebug("Calling updateChange on {} ops", changeOps.size());
+          for (BatchUpdateOp op : changeOps) {
+            dirty |= op.updateChange(ctx);
+          }
+          if (!dirty) {
+            logDebug("No ops reported dirty, short-circuiting");
+            return;
+          }
+          deleted = ctx.deleted;
+          if (deleted) {
+            logDebug("Change was deleted");
+          }
+
+          // Stage the NoteDb update and store its state in the Change.
+          if (notesMigration.commitChangeWrites()) {
+            updateManager = stageNoteDbUpdate(ctx, deleted);
+          }
+
+          if (storage == PrimaryStorage.REVIEW_DB) {
+            // If primary storage of this change is in ReviewDb, bump
+            // lastUpdatedOn or rowVersion and commit. Otherwise, don't waste
+            // time updating ReviewDb at all.
+            Iterable<Change> cs = changesToUpdate(ctx);
+            if (isNewChange(id)) {
+              // Insert rather than upsert in case of a race on change IDs.
+              logDebug("Inserting change");
+              db.changes().insert(cs);
+            } else if (deleted) {
+              logDebug("Deleting change");
+              db.changes().delete(cs);
+            } else {
+              logDebug("Updating change");
+              db.changes().update(cs);
+            }
+            if (!dryrun) {
+              db.commit();
+            }
+          } else {
+            logDebug("Skipping ReviewDb write since primary storage is {}", storage);
+          }
+        } finally {
+          db.rollback();
+        }
+
+        // Do not execute the NoteDbUpdateManager, as we don't want too much
+        // contention on the underlying repo, and we would rather use a single
+        // ObjectInserter/BatchRefUpdate later.
+        //
+        // TODO(dborowitz): May or may not be worth trying to batch together
+        // flushed inserters as well.
+        if (storage == PrimaryStorage.NOTE_DB) {
+          // Should have failed above if NoteDb is disabled.
+          checkState(notesMigration.commitChangeWrites());
+          noteDbResult = updateManager.stage().get(id);
+        } else if (notesMigration.commitChangeWrites()) {
+          try {
+            noteDbResult = updateManager.stage().get(id);
+          } catch (IOException ex) {
+            // Ignore all errors trying to update NoteDb at this point. We've
+            // already written the NoteDbChangeState to ReviewDb, which means
+            // if the state is out of date it will be rebuilt the next time it
+            // is needed.
+            log.debug("Ignoring NoteDb update error after ReviewDb write", ex);
+          }
+        }
+      } catch (Exception e) {
+        logDebug("Error updating change (should be rethrown)", e);
+        Throwables.propagateIfPossible(e, RestApiException.class);
+        throw new UpdateException(e);
+      } finally {
+        if (updateManager != null) {
+          updateManager.close();
+        }
+      }
+    }
+
+    private ChangeContextImpl newChangeContext(
+        ReviewDb db, Repository repo, RevWalk rw, Change.Id id) throws OrmException {
+      Change c = newChanges.get(id);
+      boolean isNew = c != null;
+      if (isNew) {
+        // New change: populate noteDbState.
+        checkState(c.getNoteDbState() == null, "noteDbState should not be filled in by callers");
+        if (notesMigration.changePrimaryStorage() == PrimaryStorage.NOTE_DB) {
+          c.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+        }
+      } else {
+        // Existing change.
+        c = ChangeNotes.readOneReviewDbChange(db, id);
+        if (c == null) {
+          // Not in ReviewDb, but new changes are created with default primary
+          // storage as NOTE_DB, so we can assume that a missing change is
+          // NoteDb primary. Pass a synthetic change into ChangeNotes.Factory,
+          // which lets ChangeNotes take care of the existence check.
+          //
+          // TODO(dborowitz): This assumption is potentially risky, because
+          // it means once we turn this option on and start creating changes
+          // without writing anything to ReviewDb, we can't turn this option
+          // back off without making those changes inaccessible. The problem
+          // is we have no way of distinguishing a change that only exists in
+          // NoteDb because it only ever existed in NoteDb, from a change that
+          // only exists in NoteDb because it used to exist in ReviewDb and
+          // deleting from ReviewDb succeeded but deleting from NoteDb failed.
+          //
+          // TODO(dborowitz): We actually still have that problem anyway. Maybe
+          // we need a cutoff timestamp? Or maybe we need to start leaving
+          // tombstones in ReviewDb?
+          c = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
+        }
+        NoteDbChangeState.checkNotReadOnly(c, skewMs);
+      }
+      ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
+      return new ChangeContextImpl(notes, new BatchUpdateReviewDb(db), repo, rw);
+    }
+
+    private NoteDbUpdateManager stageNoteDbUpdate(ChangeContextImpl ctx, boolean deleted)
+        throws OrmException, IOException {
+      logDebug("Staging NoteDb update");
+      NoteDbUpdateManager updateManager =
+          updateManagerFactory
+              .create(ctx.getProject())
+              .setChangeRepo(
+                  ctx.threadLocalRepo,
+                  ctx.threadLocalRevWalk,
+                  null,
+                  new ChainedReceiveCommands(ctx.threadLocalRepo));
+      if (ctx.getUser().isIdentifiedUser()) {
+        updateManager.setRefLogIdent(
+            ctx.getUser().asIdentifiedUser().newRefLogIdent(ctx.getWhen(), tz));
+      }
+      for (ChangeUpdate u : ctx.updates.values()) {
+        updateManager.add(u);
+      }
+
+      Change c = ctx.getChange();
+      if (deleted) {
+        updateManager.deleteChange(c.getId());
+      }
+      try {
+        updateManager.stageAndApplyDelta(c);
+      } catch (MismatchedStateException ex) {
+        // Refused to apply update because NoteDb was out of sync, which can
+        // only happen if ReviewDb is the primary storage for this change.
+        //
+        // Go ahead with this ReviewDb update; it's still out of sync, but this
+        // is no worse than before, and it will eventually get rebuilt.
+        logDebug("Ignoring MismatchedStateException while staging");
+      }
+
+      return updateManager;
+    }
+
+    private boolean isNewChange(Change.Id id) {
+      return newChanges.containsKey(id);
+    }
+
+    private void logDebug(String msg, Throwable t) {
+      if (log.isDebugEnabled()) {
+        ReviewDbBatchUpdate.this.logDebug("[" + taskId + "]" + msg, t);
+      }
+    }
+
+    private void logDebug(String msg, Object... args) {
+      if (log.isDebugEnabled()) {
+        ReviewDbBatchUpdate.this.logDebug("[" + taskId + "]" + msg, args);
+      }
+    }
+  }
+
+  private static Iterable<Change> changesToUpdate(ChangeContextImpl ctx) {
+    Change c = ctx.getChange();
+    if (ctx.bumpLastUpdatedOn && c.getLastUpdatedOn().before(ctx.getWhen())) {
+      c.setLastUpdatedOn(ctx.getWhen());
+    }
+    return Collections.singleton(c);
+  }
+
+  private void executePostOps() throws Exception {
+    ContextImpl ctx = new ContextImpl();
+    for (BatchUpdateOp op : ops.values()) {
+      op.postUpdate(ctx);
+    }
+
+    for (RepoOnlyOp op : repoOnlyOps) {
+      op.postUpdate(ctx);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/UpdateException.java b/java/com/google/gerrit/server/update/UpdateException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/update/UpdateException.java
rename to java/com/google/gerrit/server/update/UpdateException.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/CommitMessageUtil.java b/java/com/google/gerrit/server/util/CommitMessageUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/CommitMessageUtil.java
rename to java/com/google/gerrit/server/util/CommitMessageUtil.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/FallbackRequestContext.java b/java/com/google/gerrit/server/util/FallbackRequestContext.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/FallbackRequestContext.java
rename to java/com/google/gerrit/server/util/FallbackRequestContext.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java b/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
rename to java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/HostPlatform.java b/java/com/google/gerrit/server/util/HostPlatform.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/HostPlatform.java
rename to java/com/google/gerrit/server/util/HostPlatform.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/IdGenerator.java b/java/com/google/gerrit/server/util/IdGenerator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/IdGenerator.java
rename to java/com/google/gerrit/server/util/IdGenerator.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java b/java/com/google/gerrit/server/util/LabelVote.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
rename to java/com/google/gerrit/server/util/LabelVote.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/MagicBranch.java b/java/com/google/gerrit/server/util/MagicBranch.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/MagicBranch.java
rename to java/com/google/gerrit/server/util/MagicBranch.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/ManualRequestContext.java b/java/com/google/gerrit/server/util/ManualRequestContext.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/ManualRequestContext.java
rename to java/com/google/gerrit/server/util/ManualRequestContext.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/MostSpecificComparator.java b/java/com/google/gerrit/server/util/MostSpecificComparator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/MostSpecificComparator.java
rename to java/com/google/gerrit/server/util/MostSpecificComparator.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/OneOffRequestContext.java b/java/com/google/gerrit/server/util/OneOffRequestContext.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/OneOffRequestContext.java
rename to java/com/google/gerrit/server/util/OneOffRequestContext.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/PluginLogFile.java b/java/com/google/gerrit/server/util/PluginLogFile.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/PluginLogFile.java
rename to java/com/google/gerrit/server/util/PluginLogFile.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/PluginRequestContext.java b/java/com/google/gerrit/server/util/PluginRequestContext.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/PluginRequestContext.java
rename to java/com/google/gerrit/server/util/PluginRequestContext.java
diff --git a/java/com/google/gerrit/server/util/RegexListSearcher.java b/java/com/google/gerrit/server/util/RegexListSearcher.java
new file mode 100644
index 0000000..11543bb
--- /dev/null
+++ b/java/com/google/gerrit/server/util/RegexListSearcher.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.util;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Chars;
+import dk.brics.automaton.Automaton;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+/** Helper to search sorted lists for elements matching a {@link RegExp}. */
+public final class RegexListSearcher<T> {
+  public static RegexListSearcher<String> ofStrings(String re) {
+    return new RegexListSearcher<>(re, in -> in);
+  }
+
+  private final RunAutomaton pattern;
+  private final Function<T, String> toStringFunc;
+
+  private final String prefixBegin;
+  private final String prefixEnd;
+  private final int prefixLen;
+  private final boolean prefixOnly;
+
+  public RegexListSearcher(String re, Function<T, String> toStringFunc) {
+    this.toStringFunc = checkNotNull(toStringFunc);
+
+    if (re.startsWith("^")) {
+      re = re.substring(1);
+    }
+
+    if (re.endsWith("$") && !re.endsWith("\\$")) {
+      re = re.substring(0, re.length() - 1);
+    }
+
+    Automaton automaton = new RegExp(re).toAutomaton();
+    prefixBegin = automaton.getCommonPrefix();
+    prefixLen = prefixBegin.length();
+
+    if (0 < prefixLen) {
+      char max = Chars.checkedCast(prefixBegin.charAt(prefixLen - 1) + 1);
+      prefixEnd = prefixBegin.substring(0, prefixLen - 1) + max;
+      prefixOnly = re.equals(prefixBegin + ".*");
+    } else {
+      prefixEnd = "";
+      prefixOnly = false;
+    }
+
+    pattern = prefixOnly ? null : new RunAutomaton(automaton);
+  }
+
+  public Stream<T> search(List<T> list) {
+    checkNotNull(list);
+    int begin;
+    int end;
+
+    if (0 < prefixLen) {
+      // Assumes many consecutive elements may have the same prefix, so the cost of two binary
+      // searches is less than iterating linearly and running the regexp find the endpoints.
+      List<String> strings = Lists.transform(list, toStringFunc::apply);
+      begin = find(strings, prefixBegin);
+      end = find(strings, prefixEnd);
+    } else {
+      begin = 0;
+      end = list.size();
+    }
+    if (begin >= end) {
+      return Stream.empty();
+    }
+
+    Stream<T> result = list.subList(begin, end).stream();
+    if (!prefixOnly) {
+      result = result.filter(x -> pattern.run(toStringFunc.apply(x)));
+    }
+    return result;
+  }
+
+  private static int find(List<String> list, String p) {
+    int r = Collections.binarySearch(list, p);
+    return r < 0 ? -(r + 1) : r;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestContext.java b/java/com/google/gerrit/server/util/RequestContext.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/RequestContext.java
rename to java/com/google/gerrit/server/util/RequestContext.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestId.java b/java/com/google/gerrit/server/util/RequestId.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/RequestId.java
rename to java/com/google/gerrit/server/util/RequestId.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java b/java/com/google/gerrit/server/util/RequestScopePropagator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
rename to java/com/google/gerrit/server/util/RequestScopePropagator.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/ServerRequestContext.java b/java/com/google/gerrit/server/util/ServerRequestContext.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/ServerRequestContext.java
rename to java/com/google/gerrit/server/util/ServerRequestContext.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/SocketUtil.java b/java/com/google/gerrit/server/util/SocketUtil.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/SocketUtil.java
rename to java/com/google/gerrit/server/util/SocketUtil.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/SubmoduleSectionParser.java b/java/com/google/gerrit/server/util/SubmoduleSectionParser.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/SubmoduleSectionParser.java
rename to java/com/google/gerrit/server/util/SubmoduleSectionParser.java
diff --git a/java/com/google/gerrit/server/util/SystemLog.java b/java/com/google/gerrit/server/util/SystemLog.java
new file mode 100644
index 0000000..e1a0317
--- /dev/null
+++ b/java/com/google/gerrit/server/util/SystemLog.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.util;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Die;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.Path;
+import org.apache.log4j.Appender;
+import org.apache.log4j.AsyncAppender;
+import org.apache.log4j.DailyRollingFileAppender;
+import org.apache.log4j.FileAppender;
+import org.apache.log4j.Layout;
+import org.apache.log4j.LogManager;
+import org.apache.log4j.Logger;
+import org.apache.log4j.helpers.OnlyOnceErrorHandler;
+import org.apache.log4j.spi.ErrorHandler;
+import org.apache.log4j.spi.LoggingEvent;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class SystemLog {
+  private static final org.slf4j.Logger log = LoggerFactory.getLogger(SystemLog.class);
+
+  public static final String LOG4J_CONFIGURATION = "log4j.configuration";
+
+  private final SitePaths site;
+  private final int asyncLoggingBufferSize;
+  private final boolean rotateLogs;
+
+  @Inject
+  public SystemLog(SitePaths site, @GerritServerConfig Config config) {
+    this.site = site;
+    this.asyncLoggingBufferSize = config.getInt("core", "asyncLoggingBufferSize", 64);
+    this.rotateLogs = config.getBoolean("log", "rotate", true);
+  }
+
+  public static boolean shouldConfigure() {
+    return Strings.isNullOrEmpty(System.getProperty(LOG4J_CONFIGURATION));
+  }
+
+  public static Appender createAppender(Path logdir, String name, Layout layout, boolean rotate) {
+    final FileAppender dst = rotate ? new DailyRollingFileAppender() : new FileAppender();
+    dst.setName(name);
+    dst.setLayout(layout);
+    dst.setEncoding(UTF_8.name());
+    dst.setFile(resolve(logdir).resolve(name).toString());
+    dst.setImmediateFlush(true);
+    dst.setAppend(true);
+    dst.setErrorHandler(new DieErrorHandler());
+    dst.activateOptions();
+    dst.setErrorHandler(new OnlyOnceErrorHandler());
+    return dst;
+  }
+
+  public AsyncAppender createAsyncAppender(String name, Layout layout) {
+    return createAsyncAppender(name, layout, rotateLogs);
+  }
+
+  private AsyncAppender createAsyncAppender(String name, Layout layout, boolean rotate) {
+    AsyncAppender async = new AsyncAppender();
+    async.setName(name);
+    async.setBlocking(true);
+    async.setBufferSize(asyncLoggingBufferSize);
+    async.setLocationInfo(false);
+
+    if (shouldConfigure()) {
+      async.addAppender(createAppender(site.logs_dir, name, layout, rotate));
+    } else {
+      Appender appender = LogManager.getLogger(name).getAppender(name);
+      if (appender != null) {
+        async.addAppender(appender);
+      } else {
+        log.warn(
+            "No appender with the name: " + name + " was found. " + name + " logging is disabled");
+      }
+    }
+    async.activateOptions();
+    return async;
+  }
+
+  private static Path resolve(Path p) {
+    try {
+      return p.toRealPath().normalize();
+    } catch (IOException e) {
+      return p.toAbsolutePath().normalize();
+    }
+  }
+
+  private static final class DieErrorHandler implements ErrorHandler {
+    @Override
+    public void error(String message, Exception e, int errorCode, LoggingEvent event) {
+      error(e != null ? e.getMessage() : message);
+    }
+
+    @Override
+    public void error(String message, Exception e, int errorCode) {
+      error(e != null ? e.getMessage() : message);
+    }
+
+    @Override
+    public void error(String message) {
+      throw new Die("Cannot open log file: " + message);
+    }
+
+    @Override
+    public void activateOptions() {}
+
+    @Override
+    public void setAppender(Appender appender) {}
+
+    @Override
+    public void setBackupAppender(Appender appender) {}
+
+    @Override
+    public void setLogger(Logger logger) {}
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java b/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java
rename to java/com/google/gerrit/server/util/ThreadLocalRequestContext.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java b/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
rename to java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java b/java/com/google/gerrit/server/util/TreeFormatter.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java
rename to java/com/google/gerrit/server/util/TreeFormatter.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/AssigneeValidationListener.java b/java/com/google/gerrit/server/validators/AssigneeValidationListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/validators/AssigneeValidationListener.java
rename to java/com/google/gerrit/server/validators/AssigneeValidationListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java b/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java
rename to java/com/google/gerrit/server/validators/GroupCreationValidationListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/HashtagValidationListener.java b/java/com/google/gerrit/server/validators/HashtagValidationListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/validators/HashtagValidationListener.java
rename to java/com/google/gerrit/server/validators/HashtagValidationListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java b/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
rename to java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java b/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java
rename to java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/ValidationException.java b/java/com/google/gerrit/server/validators/ValidationException.java
similarity index 100%
rename from gerrit-server/src/main/java/com/google/gerrit/server/validators/ValidationException.java
rename to java/com/google/gerrit/server/validators/ValidationException.java
diff --git a/java/com/google/gerrit/sshd/AbstractGitCommand.java b/java/com/google/gerrit/sshd/AbstractGitCommand.java
new file mode 100644
index 0000000..710b3dc
--- /dev/null
+++ b/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.sshd.SshScope.Context;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.apache.sshd.server.Environment;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Argument;
+
+public abstract class AbstractGitCommand extends BaseCommand {
+  @Argument(index = 0, metaVar = "PROJECT.git", required = true, usage = "project name")
+  protected ProjectState projectState;
+
+  @Inject private SshScope sshScope;
+
+  @Inject private GitRepositoryManager repoManager;
+
+  @Inject private SshSession session;
+
+  @Inject private SshScope.Context context;
+
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+
+  protected Repository repo;
+  protected Project.NameKey projectName;
+  protected Project project;
+
+  @Override
+  public void start(Environment env) {
+    Context ctx = context.subContext(newSession(), context.getCommandLine());
+    final Context old = sshScope.set(ctx);
+    try {
+      startThread(
+          new ProjectCommandRunnable() {
+            @Override
+            public void executeParseCommand() throws Exception {
+              parseCommandLine();
+            }
+
+            @Override
+            public void run() throws Exception {
+              AbstractGitCommand.this.service();
+            }
+
+            @Override
+            public Project.NameKey getProjectName() {
+              return projectState.getNameKey();
+            }
+          });
+    } finally {
+      sshScope.set(old);
+    }
+  }
+
+  private SshSession newSession() {
+    SshSession n =
+        new SshSession(
+            session,
+            session.getRemoteAddress(),
+            userFactory.create(session.getRemoteAddress(), user.getAccountId()));
+    n.setAccessPath(AccessPath.GIT);
+    return n;
+  }
+
+  private void service() throws IOException, PermissionBackendException, Failure {
+    project = projectState.getProject();
+    projectName = project.getNameKey();
+
+    try {
+      repo = repoManager.openRepository(projectName);
+    } catch (RepositoryNotFoundException e) {
+      throw new Failure(1, "fatal: '" + project.getName() + "': not a git archive", e);
+    }
+
+    try {
+      runImpl();
+    } finally {
+      repo.close();
+    }
+  }
+
+  protected abstract void runImpl() throws IOException, PermissionBackendException, Failure;
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AdminHighPriorityCommand.java b/java/com/google/gerrit/sshd/AdminHighPriorityCommand.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/AdminHighPriorityCommand.java
rename to java/com/google/gerrit/sshd/AdminHighPriorityCommand.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java b/java/com/google/gerrit/sshd/AliasCommand.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
rename to java/com/google/gerrit/sshd/AliasCommand.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java b/java/com/google/gerrit/sshd/AliasCommandProvider.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
rename to java/com/google/gerrit/sshd/AliasCommandProvider.java
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
new file mode 100644
index 0000000..3ed1f2f
--- /dev/null
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -0,0 +1,41 @@
+java_library(
+    name = "sshd",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/lucene",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/h2",
+        "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/util/cli",
+        "//java/org/eclipse/jgit:server",
+        "//lib:args4j",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:jsch",
+        "//lib:servlet-api-3_1",
+        "//lib/auto:auto-value",
+        "//lib/bouncycastle:bcprov-neverlink",
+        "//lib/commons:codec",
+        "//lib/dropwizard:dropwizard-core",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",  # SSH should not depend on servlet
+        "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//lib/log:log4j",
+        "//lib/mina:core",
+        "//lib/mina:sshd",
+    ],
+)
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
new file mode 100644
index 0000000..cabc21d
--- /dev/null
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -0,0 +1,565 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.util.concurrent.Atomics;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.RequestCleanup;
+import com.google.gerrit.server.git.ProjectRunnable;
+import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.sshd.SshScope.Context;
+import com.google.gerrit.util.cli.CmdLineParser;
+import com.google.gerrit.util.cli.EndOfOptionsHandler;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.nio.charset.Charset;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.sshd.common.SshException;
+import org.apache.sshd.server.Command;
+import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.ExitCallback;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public abstract class BaseCommand implements Command {
+  private static final Logger log = LoggerFactory.getLogger(BaseCommand.class);
+  public static final Charset ENC = UTF_8;
+
+  private static final int PRIVATE_STATUS = 1 << 30;
+  static final int STATUS_CANCEL = PRIVATE_STATUS | 1;
+  static final int STATUS_NOT_FOUND = PRIVATE_STATUS | 2;
+  public static final int STATUS_NOT_ADMIN = PRIVATE_STATUS | 3;
+
+  @Option(name = "--", usage = "end of options", handler = EndOfOptionsHandler.class)
+  private boolean endOfOptions;
+
+  protected InputStream in;
+  protected OutputStream out;
+  protected OutputStream err;
+
+  private ExitCallback exit;
+
+  @Inject protected CurrentUser user;
+
+  @Inject private SshScope sshScope;
+
+  @Inject private CmdLineParser.Factory cmdLineParserFactory;
+
+  @Inject private RequestCleanup cleanup;
+
+  @Inject @CommandExecutor private ScheduledThreadPoolExecutor executor;
+
+  @Inject private PermissionBackend permissionBackend;
+
+  @Inject private SshScope.Context context;
+
+  /** Commands declared by a plugin can be scoped by the plugin name. */
+  @Inject(optional = true)
+  @PluginName
+  private String pluginName;
+
+  @Inject private Injector injector;
+
+  @Inject private DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
+
+  /** The task, as scheduled on a worker thread. */
+  private final AtomicReference<Future<?>> task;
+
+  /** Text of the command line which lead up to invoking this instance. */
+  private String commandName = "";
+
+  /** Unparsed command line options. */
+  private String[] argv;
+
+  public BaseCommand() {
+    task = Atomics.newReference();
+  }
+
+  @Override
+  public void setInputStream(InputStream in) {
+    this.in = in;
+  }
+
+  @Override
+  public void setOutputStream(OutputStream out) {
+    this.out = out;
+  }
+
+  @Override
+  public void setErrorStream(OutputStream err) {
+    this.err = err;
+  }
+
+  @Override
+  public void setExitCallback(ExitCallback callback) {
+    this.exit = callback;
+  }
+
+  @Nullable
+  protected String getPluginName() {
+    return pluginName;
+  }
+
+  protected String getName() {
+    return commandName;
+  }
+
+  void setName(String prefix) {
+    this.commandName = prefix;
+  }
+
+  public String[] getArguments() {
+    return argv;
+  }
+
+  public void setArguments(String[] argv) {
+    this.argv = argv;
+  }
+
+  @Override
+  public void destroy() {
+    Future<?> future = task.getAndSet(null);
+    if (future != null && !future.isDone()) {
+      future.cancel(true);
+    }
+  }
+
+  /**
+   * Pass all state into the command, then run its start method.
+   *
+   * <p>This method copies all critical state, like the input and output streams, into the supplied
+   * command. The caller must still invoke {@code cmd.start()} if wants to pass control to the
+   * command.
+   *
+   * @param cmd the command that will receive the current state.
+   */
+  protected void provideStateTo(Command cmd) {
+    cmd.setInputStream(in);
+    cmd.setOutputStream(out);
+    cmd.setErrorStream(err);
+    cmd.setExitCallback(exit);
+  }
+
+  /**
+   * Parses the command line argument, injecting parsed values into fields.
+   *
+   * <p>This method must be explicitly invoked to cause a parse.
+   *
+   * @throws UnloggedFailure if the command line arguments were invalid.
+   * @see Option
+   * @see Argument
+   */
+  protected void parseCommandLine() throws UnloggedFailure {
+    parseCommandLine(this);
+  }
+
+  /**
+   * Parses the command line argument, injecting parsed values into fields.
+   *
+   * <p>This method must be explicitly invoked to cause a parse.
+   *
+   * @param options object whose fields declare Option and Argument annotations to describe the
+   *     parameters of the command. Usually {@code this}.
+   * @throws UnloggedFailure if the command line arguments were invalid.
+   * @see Option
+   * @see Argument
+   */
+  protected void parseCommandLine(Object options) throws UnloggedFailure {
+    final CmdLineParser clp = newCmdLineParser(options);
+    DynamicOptions pluginOptions = new DynamicOptions(options, injector, dynamicBeans);
+    pluginOptions.parseDynamicBeans(clp);
+    pluginOptions.setDynamicBeans();
+    pluginOptions.onBeanParseStart();
+    try {
+      clp.parseArgument(argv);
+    } catch (IllegalArgumentException | CmdLineException err) {
+      if (!clp.wasHelpRequestedByOption()) {
+        throw new UnloggedFailure(1, "fatal: " + err.getMessage());
+      }
+    }
+
+    if (clp.wasHelpRequestedByOption()) {
+      StringWriter msg = new StringWriter();
+      clp.printDetailedUsage(commandName, msg);
+      msg.write(usage());
+      throw new UnloggedFailure(1, msg.toString());
+    }
+    pluginOptions.onBeanParseEnd();
+  }
+
+  protected String usage() {
+    return "";
+  }
+
+  /** Construct a new parser for this command's received command line. */
+  protected CmdLineParser newCmdLineParser(Object options) {
+    return cmdLineParserFactory.create(options);
+  }
+
+  /**
+   * Spawn a function into its own thread.
+   *
+   * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
+   *
+   * <pre>
+   * startThread(new CommandRunnable() {
+   *   public void run() throws Exception {
+   *     runImp();
+   *   }
+   * });
+   * </pre>
+   *
+   * <p>If the function throws an exception, it is translated to a simple message for the client, a
+   * non-zero exit code, and the stack trace is logged.
+   *
+   * @param thunk the runnable to execute on the thread, performing the command's logic.
+   */
+  protected void startThread(CommandRunnable thunk) {
+    final TaskThunk tt = new TaskThunk(thunk);
+
+    if (isAdminHighPriorityCommand()) {
+      // Admin commands should not block the main work threads (there
+      // might be an interactive shell there), nor should they wait
+      // for the main work threads.
+      //
+      new Thread(tt, tt.toString()).start();
+    } else {
+      task.set(executor.submit(tt));
+    }
+  }
+
+  private boolean isAdminHighPriorityCommand() {
+    if (getClass().getAnnotation(AdminHighPriorityCommand.class) != null) {
+      try {
+        permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+        return true;
+      } catch (AuthException | PermissionBackendException e) {
+        return false;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Terminate this command and return a result code to the remote client.
+   *
+   * <p>Commands should invoke this at most once. Once invoked, the command may lose access to
+   * request based resources as any callbacks previously registered with {@link RequestCleanup} will
+   * fire.
+   *
+   * @param rc exit code for the remote client.
+   */
+  protected void onExit(int rc) {
+    exit.onExit(rc);
+    if (cleanup != null) {
+      cleanup.run();
+    }
+  }
+
+  /** Wrap the supplied output stream in a UTF-8 encoded PrintWriter. */
+  protected static PrintWriter toPrintWriter(OutputStream o) {
+    return new PrintWriter(new BufferedWriter(new OutputStreamWriter(o, ENC)));
+  }
+
+  private int handleError(Throwable e) {
+    if ((e.getClass() == IOException.class && "Pipe closed".equals(e.getMessage()))
+        || //
+        (e.getClass() == SshException.class && "Already closed".equals(e.getMessage()))
+        || //
+        e.getClass() == InterruptedIOException.class) {
+      // This is sshd telling us the client just dropped off while
+      // we were waiting for a read or a write to complete. Either
+      // way its not really a fatal error. Don't log it.
+      //
+      return 127;
+    }
+
+    if (!(e instanceof UnloggedFailure)) {
+      final StringBuilder m = new StringBuilder();
+      m.append("Internal server error");
+      if (user.isIdentifiedUser()) {
+        final IdentifiedUser u = user.asIdentifiedUser();
+        m.append(" (user ");
+        m.append(u.getAccount().getUserName());
+        m.append(" account ");
+        m.append(u.getAccountId());
+        m.append(")");
+      }
+      m.append(" during ");
+      m.append(context.getCommandLine());
+      log.error(m.toString(), e);
+    }
+
+    if (e instanceof Failure) {
+      final Failure f = (Failure) e;
+      try {
+        err.write((f.getMessage() + "\n").getBytes(ENC));
+        err.flush();
+      } catch (IOException e2) {
+        // Ignored
+      } catch (Throwable e2) {
+        log.warn("Cannot send failure message to client", e2);
+      }
+      return f.exitCode;
+    }
+
+    try {
+      err.write("fatal: internal server error\n".getBytes(ENC));
+      err.flush();
+    } catch (IOException e2) {
+      // Ignored
+    } catch (Throwable e2) {
+      log.warn("Cannot send internal server error message to client", e2);
+    }
+    return 128;
+  }
+
+  protected UnloggedFailure die(String msg) {
+    return new UnloggedFailure(1, "fatal: " + msg);
+  }
+
+  protected UnloggedFailure die(Throwable why) {
+    return new UnloggedFailure(1, "fatal: " + why.getMessage(), why);
+  }
+
+  protected void writeError(String type, String msg) {
+    try {
+      err.write((type + ": " + msg + "\n").getBytes(ENC));
+    } catch (IOException e) {
+      // Ignored
+    }
+  }
+
+  protected String getTaskDescription() {
+    StringBuilder m = new StringBuilder();
+    m.append(context.getCommandLine());
+    return m.toString();
+  }
+
+  private String getTaskName() {
+    StringBuilder m = new StringBuilder();
+    m.append(getTaskDescription());
+    if (user.isIdentifiedUser()) {
+      IdentifiedUser u = user.asIdentifiedUser();
+      m.append(" (").append(u.getAccount().getUserName()).append(")");
+    }
+    return m.toString();
+  }
+
+  private final class TaskThunk implements CancelableRunnable, ProjectRunnable {
+    private final CommandRunnable thunk;
+    private final String taskName;
+    private Project.NameKey projectName;
+
+    private TaskThunk(CommandRunnable thunk) {
+      this.thunk = thunk;
+      this.taskName = getTaskName();
+    }
+
+    @Override
+    public void cancel() {
+      synchronized (this) {
+        final Context old = sshScope.set(context);
+        try {
+          onExit(STATUS_CANCEL);
+        } finally {
+          sshScope.set(old);
+        }
+      }
+    }
+
+    @Override
+    public void run() {
+      synchronized (this) {
+        final Thread thisThread = Thread.currentThread();
+        final String thisName = thisThread.getName();
+        int rc = 0;
+        final Context old = sshScope.set(context);
+        try {
+          context.started = TimeUtil.nowMs();
+          thisThread.setName("SSH " + taskName);
+
+          if (thunk instanceof ProjectCommandRunnable) {
+            ((ProjectCommandRunnable) thunk).executeParseCommand();
+            projectName = ((ProjectCommandRunnable) thunk).getProjectName();
+          }
+
+          try {
+            thunk.run();
+          } catch (NoSuchProjectException e) {
+            throw new UnloggedFailure(1, e.getMessage());
+          } catch (NoSuchChangeException e) {
+            throw new UnloggedFailure(1, e.getMessage() + " no such change");
+          }
+
+          out.flush();
+          err.flush();
+        } catch (Throwable e) {
+          try {
+            out.flush();
+          } catch (Throwable e2) {
+            // Ignored
+          }
+          try {
+            err.flush();
+          } catch (Throwable e2) {
+            // Ignored
+          }
+          rc = handleError(e);
+        } finally {
+          try {
+            onExit(rc);
+          } finally {
+            sshScope.set(old);
+            thisThread.setName(thisName);
+          }
+        }
+      }
+    }
+
+    @Override
+    public String toString() {
+      return taskName;
+    }
+
+    @Override
+    public Project.NameKey getProjectNameKey() {
+      return projectName;
+    }
+
+    @Override
+    public String getRemoteName() {
+      return null;
+    }
+
+    @Override
+    public boolean hasCustomizedPrint() {
+      return false;
+    }
+  }
+
+  /** Runnable function which can throw an exception. */
+  @FunctionalInterface
+  public interface CommandRunnable {
+    void run() throws Exception;
+  }
+
+  /** Runnable function which can retrieve a project name related to the task */
+  public interface ProjectCommandRunnable extends CommandRunnable {
+    // execute parser command before running, in order to be able to retrieve
+    // project name
+    void executeParseCommand() throws Exception;
+
+    Project.NameKey getProjectName();
+  }
+
+  /** Thrown from {@link CommandRunnable#run()} with client message and code. */
+  public static class Failure extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    final int exitCode;
+
+    /**
+     * Create a new failure.
+     *
+     * @param exitCode exit code to return the client, which indicates the failure status of this
+     *     command. Should be between 1 and 255, inclusive.
+     * @param msg message to also send to the client's stderr.
+     */
+    public Failure(int exitCode, String msg) {
+      this(exitCode, msg, null);
+    }
+
+    /**
+     * Create a new failure.
+     *
+     * @param exitCode exit code to return the client, which indicates the failure status of this
+     *     command. Should be between 1 and 255, inclusive.
+     * @param msg message to also send to the client's stderr.
+     * @param why stack trace to include in the server's log, but is not sent to the client's
+     *     stderr.
+     */
+    public Failure(int exitCode, String msg, Throwable why) {
+      super(msg, why);
+      this.exitCode = exitCode;
+    }
+  }
+
+  /** Thrown from {@link CommandRunnable#run()} with client message and code. */
+  public static class UnloggedFailure extends Failure {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * Create a new failure.
+     *
+     * @param msg message to also send to the client's stderr.
+     */
+    public UnloggedFailure(String msg) {
+      this(1, msg);
+    }
+
+    /**
+     * Create a new failure.
+     *
+     * @param exitCode exit code to return the client, which indicates the failure status of this
+     *     command. Should be between 1 and 255, inclusive.
+     * @param msg message to also send to the client's stderr.
+     */
+    public UnloggedFailure(int exitCode, String msg) {
+      this(exitCode, msg, null);
+    }
+
+    /**
+     * Create a new failure.
+     *
+     * @param exitCode exit code to return the client, which indicates the failure status of this
+     *     command. Should be between 1 and 255, inclusive.
+     * @param msg message to also send to the client's stderr.
+     * @param why stack trace to include in the server's log, but is not sent to the client's
+     *     stderr.
+     */
+    public UnloggedFailure(int exitCode, String msg, Throwable why) {
+      super(exitCode, msg, why);
+    }
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CachingPublicKeyAuthenticator.java b/java/com/google/gerrit/sshd/CachingPublicKeyAuthenticator.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/CachingPublicKeyAuthenticator.java
rename to java/com/google/gerrit/sshd/CachingPublicKeyAuthenticator.java
diff --git a/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
new file mode 100644
index 0000000..f442032
--- /dev/null
+++ b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -0,0 +1,138 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeFinder;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.change.ChangesCollection;
+import com.google.gerrit.sshd.BaseCommand.UnloggedFailure;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+public class ChangeArgumentParser {
+  private final CurrentUser currentUser;
+  private final ChangesCollection changesCollection;
+  private final ChangeFinder changeFinder;
+  private final ReviewDb db;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  ChangeArgumentParser(
+      CurrentUser currentUser,
+      ChangesCollection changesCollection,
+      ChangeFinder changeFinder,
+      ReviewDb db,
+      ChangeNotes.Factory changeNotesFactory,
+      PermissionBackend permissionBackend) {
+    this.currentUser = currentUser;
+    this.changesCollection = changesCollection;
+    this.changeFinder = changeFinder;
+    this.db = db;
+    this.changeNotesFactory = changeNotesFactory;
+    this.permissionBackend = permissionBackend;
+  }
+
+  public void addChange(String id, Map<Change.Id, ChangeResource> changes)
+      throws UnloggedFailure, OrmException, PermissionBackendException {
+    addChange(id, changes, null);
+  }
+
+  public void addChange(
+      String id, Map<Change.Id, ChangeResource> changes, ProjectState projectState)
+      throws UnloggedFailure, OrmException, PermissionBackendException {
+    addChange(id, changes, projectState, true);
+  }
+
+  public void addChange(
+      String id,
+      Map<Change.Id, ChangeResource> changes,
+      ProjectState projectState,
+      boolean useIndex)
+      throws UnloggedFailure, OrmException, PermissionBackendException {
+    List<ChangeNotes> matched = useIndex ? changeFinder.find(id) : changeFromNotesFactory(id);
+    List<ChangeNotes> toAdd = new ArrayList<>(changes.size());
+    boolean canMaintainServer;
+    try {
+      permissionBackend.user(currentUser).check(GlobalPermission.MAINTAIN_SERVER);
+      canMaintainServer = true;
+    } catch (AuthException | PermissionBackendException e) {
+      canMaintainServer = false;
+    }
+    for (ChangeNotes notes : matched) {
+      if (!changes.containsKey(notes.getChangeId())
+          && inProject(projectState, notes.getProjectName())
+          && (canMaintainServer
+              || permissionBackend
+                  .user(currentUser)
+                  .change(notes)
+                  .database(db)
+                  .test(ChangePermission.READ))) {
+        toAdd.add(notes);
+      }
+    }
+
+    if (toAdd.isEmpty()) {
+      throw new UnloggedFailure(1, "\"" + id + "\" no such change");
+    } else if (toAdd.size() > 1) {
+      throw new UnloggedFailure(1, "\"" + id + "\" matches multiple changes");
+    }
+    Change.Id cId = toAdd.get(0).getChangeId();
+    ChangeResource changeResource;
+    try {
+      changeResource = changesCollection.parse(cId);
+    } catch (ResourceNotFoundException e) {
+      throw new UnloggedFailure(1, "\"" + id + "\" no such change");
+    }
+    changes.put(cId, changeResource);
+  }
+
+  private List<ChangeNotes> changeFromNotesFactory(String id) throws OrmException, UnloggedFailure {
+    return changeNotesFactory.create(db, parseId(id));
+  }
+
+  private List<Change.Id> parseId(String id) throws UnloggedFailure {
+    try {
+      return Arrays.asList(new Change.Id(Integer.parseInt(id)));
+    } catch (NumberFormatException e) {
+      throw new UnloggedFailure(2, "Invalid change ID " + id, e);
+    }
+  }
+
+  private boolean inProject(ProjectState projectState, Project.NameKey project) {
+    if (projectState != null) {
+      return projectState.getNameKey().equals(project);
+    }
+
+    // No --project option, so they want every project.
+    return true;
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutor.java b/java/com/google/gerrit/sshd/CommandExecutor.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutor.java
rename to java/com/google/gerrit/sshd/CommandExecutor.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorProvider.java b/java/com/google/gerrit/sshd/CommandExecutorProvider.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorProvider.java
rename to java/com/google/gerrit/sshd/CommandExecutorProvider.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java b/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
rename to java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
rename to java/com/google/gerrit/sshd/CommandFactoryProvider.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandMetaData.java b/java/com/google/gerrit/sshd/CommandMetaData.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandMetaData.java
rename to java/com/google/gerrit/sshd/CommandMetaData.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandModule.java b/java/com/google/gerrit/sshd/CommandModule.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandModule.java
rename to java/com/google/gerrit/sshd/CommandModule.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandName.java b/java/com/google/gerrit/sshd/CommandName.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandName.java
rename to java/com/google/gerrit/sshd/CommandName.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandProvider.java b/java/com/google/gerrit/sshd/CommandProvider.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandProvider.java
rename to java/com/google/gerrit/sshd/CommandProvider.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/Commands.java b/java/com/google/gerrit/sshd/Commands.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/Commands.java
rename to java/com/google/gerrit/sshd/Commands.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
rename to java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java b/java/com/google/gerrit/sshd/DispatchCommand.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
rename to java/com/google/gerrit/sshd/DispatchCommand.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommandProvider.java b/java/com/google/gerrit/sshd/DispatchCommandProvider.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommandProvider.java
rename to java/com/google/gerrit/sshd/DispatchCommandProvider.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java b/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
rename to java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
diff --git a/java/com/google/gerrit/sshd/HostKeyProvider.java b/java/com/google/gerrit/sshd/HostKeyProvider.java
new file mode 100644
index 0000000..bffcfcd
--- /dev/null
+++ b/java/com/google/gerrit/sshd/HostKeyProvider.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
+
+class HostKeyProvider implements Provider<KeyPairProvider> {
+  private final SitePaths site;
+
+  @Inject
+  HostKeyProvider(SitePaths site) {
+    this.site = site;
+  }
+
+  @Override
+  public KeyPairProvider get() {
+    Path objKey = site.ssh_key;
+    Path rsaKey = site.ssh_rsa;
+    Path ecdsaKey_256 = site.ssh_ecdsa_256;
+    Path ecdsaKey_384 = site.ssh_ecdsa_384;
+    Path ecdsaKey_521 = site.ssh_ecdsa_521;
+    Path ed25519Key = site.ssh_ed25519;
+
+    final List<File> stdKeys = new ArrayList<>(6);
+    if (Files.exists(rsaKey)) {
+      stdKeys.add(rsaKey.toAbsolutePath().toFile());
+    }
+    if (Files.exists(ecdsaKey_256)) {
+      stdKeys.add(ecdsaKey_256.toAbsolutePath().toFile());
+    }
+    if (Files.exists(ecdsaKey_384)) {
+      stdKeys.add(ecdsaKey_384.toAbsolutePath().toFile());
+    }
+    if (Files.exists(ecdsaKey_521)) {
+      stdKeys.add(ecdsaKey_521.toAbsolutePath().toFile());
+    }
+    if (Files.exists(ed25519Key)) {
+      stdKeys.add(ed25519Key.toAbsolutePath().toFile());
+    }
+
+    if (Files.exists(objKey)) {
+      if (stdKeys.isEmpty()) {
+        SimpleGeneratorHostKeyProvider p = new SimpleGeneratorHostKeyProvider();
+        p.setPath(objKey.toAbsolutePath());
+        return p;
+      }
+      // Both formats of host key exist, we don't know which format
+      // should be authoritative. Complain and abort.
+      //
+      stdKeys.add(objKey.toAbsolutePath().toFile());
+      throw new ProvisionException("Multiple host keys exist: " + stdKeys);
+    }
+    if (stdKeys.isEmpty()) {
+      throw new ProvisionException("No SSH keys under " + site.etc_dir);
+    }
+    FileKeyPairProvider kp = new FileKeyPairProvider();
+    kp.setFiles(stdKeys);
+    return kp;
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/NoShell.java b/java/com/google/gerrit/sshd/NoShell.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/NoShell.java
rename to java/com/google/gerrit/sshd/NoShell.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/PluginCommandModule.java b/java/com/google/gerrit/sshd/PluginCommandModule.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/PluginCommandModule.java
rename to java/com/google/gerrit/sshd/PluginCommandModule.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SingleCommandPluginModule.java b/java/com/google/gerrit/sshd/SingleCommandPluginModule.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SingleCommandPluginModule.java
rename to java/com/google/gerrit/sshd/SingleCommandPluginModule.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java b/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
rename to java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshCommand.java
rename to java/com/google/gerrit/sshd/SshCommand.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
rename to java/com/google/gerrit/sshd/SshDaemon.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshHostKeyModule.java b/java/com/google/gerrit/sshd/SshHostKeyModule.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshHostKeyModule.java
rename to java/com/google/gerrit/sshd/SshHostKeyModule.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheEntry.java b/java/com/google/gerrit/sshd/SshKeyCacheEntry.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheEntry.java
rename to java/com/google/gerrit/sshd/SshKeyCacheEntry.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
rename to java/com/google/gerrit/sshd/SshKeyCacheImpl.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java b/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
rename to java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
diff --git a/java/com/google/gerrit/sshd/SshLog.java b/java/com/google/gerrit/sshd/SshLog.java
new file mode 100644
index 0000000..869c3bc
--- /dev/null
+++ b/java/com/google/gerrit/sshd/SshLog.java
@@ -0,0 +1,291 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PeerDaemonUser;
+import com.google.gerrit.server.audit.AuditService;
+import com.google.gerrit.server.audit.SshAuditEvent;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.util.IdGenerator;
+import com.google.gerrit.server.util.SystemLog;
+import com.google.gerrit.sshd.SshScope.Context;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.apache.log4j.AsyncAppender;
+import org.apache.log4j.Level;
+import org.apache.log4j.Logger;
+import org.apache.log4j.spi.LoggingEvent;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+class SshLog implements LifecycleListener {
+  private static final Logger log = Logger.getLogger(SshLog.class);
+  private static final String LOG_NAME = "sshd_log";
+  private static final String P_SESSION = "session";
+  private static final String P_USER_NAME = "userName";
+  private static final String P_ACCOUNT_ID = "accountId";
+  private static final String P_WAIT = "queueWaitTime";
+  private static final String P_EXEC = "executionTime";
+  private static final String P_STATUS = "status";
+  private static final String P_AGENT = "agent";
+
+  private final Provider<SshSession> session;
+  private final Provider<Context> context;
+  private final AsyncAppender async;
+  private final AuditService auditService;
+
+  @Inject
+  SshLog(
+      final Provider<SshSession> session,
+      final Provider<Context> context,
+      SystemLog systemLog,
+      @GerritServerConfig Config config,
+      AuditService auditService) {
+    this.session = session;
+    this.context = context;
+    this.auditService = auditService;
+
+    if (!config.getBoolean("sshd", "requestLog", true)) {
+      async = null;
+      return;
+    }
+    async = systemLog.createAsyncAppender(LOG_NAME, new SshLogLayout());
+  }
+
+  @Override
+  public void start() {}
+
+  @Override
+  public void stop() {
+    if (async != null) {
+      async.close();
+    }
+  }
+
+  void onLogin() {
+    LoggingEvent entry = log("LOGIN FROM " + session.get().getRemoteAddressAsString());
+    if (async != null) {
+      async.append(entry);
+    }
+    audit(context.get(), "0", "LOGIN");
+  }
+
+  void onAuthFail(SshSession sd) {
+    final LoggingEvent event =
+        new LoggingEvent( //
+            Logger.class.getName(), // fqnOfCategoryClass
+            log, // logger
+            TimeUtil.nowMs(), // when
+            Level.INFO, // level
+            "AUTH FAILURE FROM " + sd.getRemoteAddressAsString(), // message text
+            "SSHD", // thread name
+            null, // exception information
+            null, // current NDC string
+            null, // caller location
+            null // MDC properties
+            );
+
+    event.setProperty(P_SESSION, id(sd.getSessionId()));
+    event.setProperty(P_USER_NAME, sd.getUsername());
+
+    final String error = sd.getAuthenticationError();
+    if (error != null) {
+      event.setProperty(P_STATUS, error);
+    }
+    if (async != null) {
+      async.append(event);
+    }
+    audit(null, "FAIL", "AUTH");
+  }
+
+  void onExecute(DispatchCommand dcmd, int exitValue, SshSession sshSession) {
+    final Context ctx = context.get();
+    ctx.finished = TimeUtil.nowMs();
+
+    String cmd = extractWhat(dcmd);
+
+    final LoggingEvent event = log(cmd);
+    event.setProperty(P_WAIT, (ctx.started - ctx.created) + "ms");
+    event.setProperty(P_EXEC, (ctx.finished - ctx.started) + "ms");
+
+    final String status;
+    switch (exitValue) {
+      case BaseCommand.STATUS_CANCEL:
+        status = "killed";
+        break;
+
+      case BaseCommand.STATUS_NOT_FOUND:
+        status = "not-found";
+        break;
+
+      case BaseCommand.STATUS_NOT_ADMIN:
+        status = "not-admin";
+        break;
+
+      default:
+        status = String.valueOf(exitValue);
+        break;
+    }
+    event.setProperty(P_STATUS, status);
+    String peerAgent = sshSession.getPeerAgent();
+    if (peerAgent != null) {
+      event.setProperty(P_AGENT, peerAgent);
+    }
+
+    if (async != null) {
+      async.append(event);
+    }
+    audit(context.get(), status, dcmd);
+  }
+
+  private ListMultimap<String, ?> extractParameters(DispatchCommand dcmd) {
+    if (dcmd == null) {
+      return MultimapBuilder.hashKeys(0).arrayListValues(0).build();
+    }
+    String[] cmdArgs = dcmd.getArguments();
+    String paramName = null;
+    int argPos = 0;
+    ListMultimap<String, String> parms = MultimapBuilder.hashKeys().arrayListValues().build();
+    for (int i = 2; i < cmdArgs.length; i++) {
+      String arg = cmdArgs[i];
+      // -- stop parameters parsing
+      if (arg.equals("--")) {
+        for (i++; i < cmdArgs.length; i++) {
+          parms.put("$" + argPos++, cmdArgs[i]);
+        }
+        break;
+      }
+      // --param=value
+      int eqPos = arg.indexOf('=');
+      if (arg.startsWith("--") && eqPos > 0) {
+        parms.put(arg.substring(0, eqPos), arg.substring(eqPos + 1));
+        continue;
+      }
+      // -p value or --param value
+      if (arg.startsWith("-")) {
+        if (paramName != null) {
+          parms.put(paramName, null);
+        }
+        paramName = arg;
+        continue;
+      }
+      // value
+      if (paramName == null) {
+        parms.put("$" + argPos++, arg);
+      } else {
+        parms.put(paramName, arg);
+        paramName = null;
+      }
+    }
+    if (paramName != null) {
+      parms.put(paramName, null);
+    }
+    return parms;
+  }
+
+  void onLogout() {
+    LoggingEvent entry = log("LOGOUT");
+    if (async != null) {
+      async.append(entry);
+    }
+    audit(context.get(), "0", "LOGOUT");
+  }
+
+  private LoggingEvent log(String msg) {
+    final SshSession sd = session.get();
+    final CurrentUser user = sd.getUser();
+
+    final LoggingEvent event =
+        new LoggingEvent( //
+            Logger.class.getName(), // fqnOfCategoryClass
+            log, // logger
+            TimeUtil.nowMs(), // when
+            Level.INFO, // level
+            msg, // message text
+            "SSHD", // thread name
+            null, // exception information
+            null, // current NDC string
+            null, // caller location
+            null // MDC properties
+            );
+
+    event.setProperty(P_SESSION, id(sd.getSessionId()));
+
+    String userName = "-";
+    String accountId = "-";
+
+    if (user != null && user.isIdentifiedUser()) {
+      IdentifiedUser u = user.asIdentifiedUser();
+      userName = u.getAccount().getUserName();
+      accountId = "a/" + u.getAccountId().toString();
+
+    } else if (user instanceof PeerDaemonUser) {
+      userName = PeerDaemonUser.USER_NAME;
+    }
+
+    event.setProperty(P_USER_NAME, userName);
+    event.setProperty(P_ACCOUNT_ID, accountId);
+
+    return event;
+  }
+
+  private static String id(int id) {
+    return IdGenerator.format(id);
+  }
+
+  void audit(Context ctx, Object result, String cmd) {
+    audit(ctx, result, cmd, null);
+  }
+
+  void audit(Context ctx, Object result, DispatchCommand cmd) {
+    audit(ctx, result, extractWhat(cmd), extractParameters(cmd));
+  }
+
+  private void audit(Context ctx, Object result, String cmd, ListMultimap<String, ?> params) {
+    String sessionId;
+    CurrentUser currentUser;
+    long created;
+    if (ctx == null) {
+      sessionId = null;
+      currentUser = null;
+      created = TimeUtil.nowMs();
+    } else {
+      SshSession session = ctx.getSession();
+      sessionId = IdGenerator.format(session.getSessionId());
+      currentUser = session.getUser();
+      created = ctx.created;
+    }
+    auditService.dispatch(new SshAuditEvent(sessionId, currentUser, cmd, created, params, result));
+  }
+
+  private String extractWhat(DispatchCommand dcmd) {
+    if (dcmd == null) {
+      return "Command was already destroyed";
+    }
+    StringBuilder commandName = new StringBuilder(dcmd.getCommandName());
+    String[] args = dcmd.getArguments();
+    for (int i = 1; i < args.length; i++) {
+      commandName.append(".").append(args[i]);
+    }
+    return commandName.toString();
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java b/java/com/google/gerrit/sshd/SshLogLayout.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java
rename to java/com/google/gerrit/sshd/SshLogLayout.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java b/java/com/google/gerrit/sshd/SshModule.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
rename to java/com/google/gerrit/sshd/SshModule.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java b/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
rename to java/com/google/gerrit/sshd/SshPluginStarterCallback.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshRemotePeerProvider.java b/java/com/google/gerrit/sshd/SshRemotePeerProvider.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshRemotePeerProvider.java
rename to java/com/google/gerrit/sshd/SshRemotePeerProvider.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshScope.java b/java/com/google/gerrit/sshd/SshScope.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshScope.java
rename to java/com/google/gerrit/sshd/SshScope.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java b/java/com/google/gerrit/sshd/SshSession.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java
rename to java/com/google/gerrit/sshd/SshSession.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshUtil.java b/java/com/google/gerrit/sshd/SshUtil.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshUtil.java
rename to java/com/google/gerrit/sshd/SshUtil.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutor.java b/java/com/google/gerrit/sshd/StreamCommandExecutor.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutor.java
rename to java/com/google/gerrit/sshd/StreamCommandExecutor.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutorProvider.java b/java/com/google/gerrit/sshd/StreamCommandExecutorProvider.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutorProvider.java
rename to java/com/google/gerrit/sshd/StreamCommandExecutorProvider.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java b/java/com/google/gerrit/sshd/SuExec.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
rename to java/com/google/gerrit/sshd/SuExec.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java b/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
rename to java/com/google/gerrit/sshd/commands/AdminQueryShell.java
diff --git a/java/com/google/gerrit/sshd/commands/AdminSetParent.java b/java/com/google/gerrit/sshd/commands/AdminSetParent.java
new file mode 100644
index 0000000..d9a0a37
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/AdminSetParent.java
@@ -0,0 +1,236 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License
+
+package com.google.gerrit.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;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.project.ListChildProjects;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@CommandMetaData(
+  name = "set-project-parent",
+  description = "Change the project permissions are inherited from"
+)
+final class AdminSetParent extends SshCommand {
+  private static final Logger log = LoggerFactory.getLogger(AdminSetParent.class);
+
+  @Option(
+    name = "--parent",
+    aliases = {"-p"},
+    metaVar = "NAME",
+    usage = "new parent project"
+  )
+  private ProjectState newParent;
+
+  @Option(
+    name = "--children-of",
+    metaVar = "NAME",
+    usage = "parent project for which the child projects should be reparented"
+  )
+  private ProjectState oldParent;
+
+  @Option(
+    name = "--exclude",
+    metaVar = "NAME",
+    usage = "child project of old parent project which should not be reparented"
+  )
+  private List<ProjectState> excludedChildren = new ArrayList<>();
+
+  @Argument(
+    index = 0,
+    required = false,
+    multiValued = true,
+    metaVar = "NAME",
+    usage = "projects to modify"
+  )
+  private List<ProjectState> children = new ArrayList<>();
+
+  @Inject private ProjectCache projectCache;
+
+  @Inject private MetaDataUpdate.User metaDataUpdateFactory;
+
+  @Inject private AllProjectsName allProjectsName;
+
+  @Inject private ListChildProjects listChildProjects;
+
+  private Project.NameKey newParentKey;
+
+  @Override
+  protected void run() throws Failure {
+    if (oldParent == null && children.isEmpty()) {
+      throw die(
+          "child projects have to be specified as "
+              + "arguments or the --children-of option has to be set");
+    }
+    if (oldParent == null && !excludedChildren.isEmpty()) {
+      throw die("--exclude can only be used together with --children-of");
+    }
+
+    final StringBuilder err = new StringBuilder();
+    final Set<Project.NameKey> grandParents = new HashSet<>();
+
+    grandParents.add(allProjectsName);
+
+    if (newParent != null) {
+      newParentKey = newParent.getProject().getNameKey();
+
+      // Catalog all grandparents of the "parent", we want to
+      // catch a cycle in the parent pointers before it occurs.
+      //
+      Project.NameKey gp = newParent.getProject().getParent();
+      while (gp != null && grandParents.add(gp)) {
+        final ProjectState s = projectCache.get(gp);
+        if (s != null) {
+          gp = s.getProject().getParent();
+        } else {
+          break;
+        }
+      }
+    }
+
+    final List<Project.NameKey> childProjects =
+        children.stream().map(ProjectState::getNameKey).collect(toList());
+    if (oldParent != null) {
+      try {
+        childProjects.addAll(getChildrenForReparenting(oldParent));
+      } catch (PermissionBackendException e) {
+        throw new Failure(1, "permissions unavailable", e);
+      }
+    }
+
+    for (Project.NameKey nameKey : childProjects) {
+      final String name = nameKey.get();
+
+      if (allProjectsName.equals(nameKey)) {
+        // Don't allow the wild card project to have a parent.
+        //
+        err.append("error: Cannot set parent of '").append(name).append("'\n");
+        continue;
+      }
+
+      if (grandParents.contains(nameKey) || nameKey.equals(newParentKey)) {
+        // Try to avoid creating a cycle in the parent pointers.
+        //
+        err.append("error: Cycle exists between '")
+            .append(name)
+            .append("' and '")
+            .append(newParentKey != null ? newParentKey.get() : allProjectsName.get())
+            .append("'\n");
+        continue;
+      }
+
+      try (MetaDataUpdate md = metaDataUpdateFactory.create(nameKey)) {
+        ProjectConfig config = ProjectConfig.read(md);
+        config.getProject().setParentName(newParentKey);
+        md.setMessage(
+            "Inherit access from "
+                + (newParentKey != null ? newParentKey.get() : allProjectsName.get())
+                + "\n");
+        config.commit(md);
+      } catch (RepositoryNotFoundException notFound) {
+        err.append("error: Project ").append(name).append(" not found\n");
+      } catch (IOException | ConfigInvalidException e) {
+        final String msg = "Cannot update project " + name;
+        log.error(msg, e);
+        err.append("error: ").append(msg).append("\n");
+      }
+
+      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) {
+      while (err.charAt(err.length() - 1) == '\n') {
+        err.setLength(err.length() - 1);
+      }
+      throw die(err.toString());
+    }
+  }
+
+  /**
+   * Returns the children of the specified parent project that should be reparented. The returned
+   * list of child projects does not contain projects that were specified to be excluded from
+   * reparenting.
+   */
+  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 (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, user))) {
+      final Project.NameKey childName = new Project.NameKey(child.name);
+      if (!excluded.contains(childName)) {
+        if (!automaticallyExcluded.contains(childName)) {
+          childProjects.add(childName);
+        } else {
+          stdout.println(
+              "Automatically excluded '"
+                  + childName
+                  + "' "
+                  + "from reparenting because it is in the parent "
+                  + "line of the new parent '"
+                  + newParentKey
+                  + "'.");
+        }
+      }
+    }
+    return childProjects;
+  }
+
+  private Set<Project.NameKey> getAllParents(Project.NameKey projectName) {
+    ProjectState ps = projectCache.get(projectName);
+    if (ps == null) {
+      return Collections.emptySet();
+    }
+    return ps.parents().transform(s -> s.getNameKey()).toSet();
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveOption.java b/java/com/google/gerrit/sshd/commands/ApproveOption.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveOption.java
rename to java/com/google/gerrit/sshd/commands/ApproveOption.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AproposCommand.java b/java/com/google/gerrit/sshd/commands/AproposCommand.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AproposCommand.java
rename to java/com/google/gerrit/sshd/commands/AproposCommand.java
diff --git a/java/com/google/gerrit/sshd/commands/BanCommitCommand.java b/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
new file mode 100644
index 0000000..cceb16b
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER;
+
+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.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.project.BanCommit;
+import com.google.gerrit.server.restapi.project.BanCommit.BanResultInfo;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+@CommandMetaData(
+  name = "ban-commit",
+  description = "Ban a commit from a project's repository",
+  runsAt = MASTER
+)
+public class BanCommitCommand extends SshCommand {
+  @Option(
+    name = "--reason",
+    aliases = {"-r"},
+    metaVar = "REASON",
+    usage = "reason for banning the commit"
+  )
+  private String reason;
+
+  @Argument(
+    index = 0,
+    required = true,
+    metaVar = "PROJECT",
+    usage = "name of the project for which the commit should be banned"
+  )
+  private ProjectState projectState;
+
+  @Argument(
+    index = 1,
+    required = true,
+    multiValued = true,
+    metaVar = "COMMIT",
+    usage = "commit(s) that should be banned"
+  )
+  private List<ObjectId> commitsToBan = new ArrayList<>();
+
+  @Inject private BanCommit banCommit;
+
+  @Override
+  protected void run() throws Failure {
+    try {
+      BanCommitInput input =
+          BanCommitInput.fromCommits(Lists.transform(commitsToBan, ObjectId::getName));
+      input.reason = reason;
+
+      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");
+    } catch (Exception e) {
+      throw die(e);
+    }
+  }
+
+  private void printCommits(List<String> commits, String message) {
+    if (commits != null && !commits.isEmpty()) {
+      stdout.print(message + ":\n");
+      stdout.print(Joiner.on(",\n").join(commits));
+      stdout.print("\n\n");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java b/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
new file mode 100644
index 0000000..68490f7
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.gerrit.extensions.common.TestSubmitRuleInput;
+import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.change.Revisions;
+import com.google.gerrit.server.restapi.change.ChangesCollection;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.nio.ByteBuffer;
+import org.eclipse.jgit.util.IO;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+abstract class BaseTestPrologCommand extends SshCommand {
+  private TestSubmitRuleInput input = new TestSubmitRuleInput();
+
+  @Inject private ChangesCollection changes;
+
+  @Inject private Revisions revisions;
+
+  @Argument(index = 0, required = true, usage = "ChangeId to load in prolog environment")
+  protected String changeId;
+
+  @Option(
+    name = "-s",
+    usage =
+        "Read prolog script from stdin instead of reading rules.pl from the refs/meta/config branch"
+  )
+  protected boolean useStdin;
+
+  @Option(
+    name = "--no-filters",
+    aliases = {"-n"},
+    usage = "Don't run the submit_filter/2 from the parent projects"
+  )
+  void setNoFilters(boolean no) {
+    input.filters = no ? Filters.SKIP : Filters.RUN;
+  }
+
+  protected abstract RestModifyView<RevisionResource, TestSubmitRuleInput> createView();
+
+  @Override
+  protected final void run() throws UnloggedFailure {
+    try {
+      RevisionResource revision =
+          revisions.parse(
+              changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId)),
+              IdString.fromUrl("current"));
+      if (useStdin) {
+        ByteBuffer buf = IO.readWholeStream(in, 4096);
+        input.rule = RawParseUtils.decode(buf.array(), buf.arrayOffset(), buf.limit());
+      }
+      Object result = createView().apply(revision, input);
+      OutputFormat.JSON.newGson().toJson(result, stdout);
+      stdout.print('\n');
+    } catch (Exception e) {
+      throw die("Processing of prolog script failed: " + e);
+    }
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CloseConnection.java b/java/com/google/gerrit/sshd/commands/CloseConnection.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CloseConnection.java
rename to java/com/google/gerrit/sshd/commands/CloseConnection.java
diff --git a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
new file mode 100644
index 0000000..32bff8c
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.restapi.account.CreateAccount;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+/** Create a new user account. * */
+@RequiresCapability(GlobalCapability.CREATE_ACCOUNT)
+@CommandMetaData(name = "create-account", description = "Create a new batch/role account")
+final class CreateAccountCommand extends SshCommand {
+  @Option(
+    name = "--group",
+    aliases = {"-g"},
+    metaVar = "GROUP",
+    usage = "groups to add account to"
+  )
+  private List<AccountGroup.Id> groups = new ArrayList<>();
+
+  @Option(name = "--full-name", metaVar = "NAME", usage = "display name of the account")
+  private String fullName;
+
+  @Option(name = "--email", metaVar = "EMAIL", usage = "email address of the account")
+  private String email;
+
+  @Option(name = "--ssh-key", metaVar = "-|KEY", usage = "public key for SSH authentication")
+  private String sshKey;
+
+  @Option(
+    name = "--http-password",
+    metaVar = "PASSWORD",
+    usage = "password for HTTP authentication"
+  )
+  private String httpPassword;
+
+  @Argument(index = 0, required = true, metaVar = "USERNAME", usage = "name of the user account")
+  private String username;
+
+  @Inject private CreateAccount.Factory createAccountFactory;
+
+  @Override
+  protected void run() throws OrmException, IOException, ConfigInvalidException, UnloggedFailure {
+    AccountInput input = new AccountInput();
+    input.username = username;
+    input.email = email;
+    input.name = fullName;
+    input.sshKey = readSshKey();
+    input.httpPassword = httpPassword;
+    input.groups = Lists.transform(groups, AccountGroup.Id::toString);
+    try {
+      createAccountFactory.create(username).apply(TopLevelResource.INSTANCE, input);
+    } catch (RestApiException e) {
+      throw die(e.getMessage());
+    }
+  }
+
+  private String readSshKey() throws IOException {
+    if (sshKey == null) {
+      return null;
+    }
+    if ("-".equals(sshKey)) {
+      sshKey = "";
+      BufferedReader br = new BufferedReader(new InputStreamReader(in, UTF_8));
+      String line;
+      while ((line = br.readLine()) != null) {
+        sshKey += line + "\n";
+      }
+    }
+    return sshKey;
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java b/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
new file mode 100644
index 0000000..fd1e189
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+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.ProjectState;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import org.kohsuke.args4j.Argument;
+
+/** Create a new branch. * */
+@CommandMetaData(name = "create-branch", description = "Create a new branch")
+public final class CreateBranchCommand extends SshCommand {
+
+  @Argument(index = 0, required = true, metaVar = "PROJECT", usage = "name of the project")
+  private ProjectState project;
+
+  @Argument(index = 1, required = true, metaVar = "NAME", usage = "name of branch to be created")
+  private String name;
+
+  @Argument(
+    index = 2,
+    required = true,
+    metaVar = "REVISION",
+    usage = "base revision of the new branch"
+  )
+  private String revision;
+
+  @Inject GerritApi gApi;
+
+  @Override
+  protected void run() throws UnloggedFailure {
+    try {
+      BranchInput in = new BranchInput();
+      in.revision = revision;
+      gApi.projects().name(project.getName()).branch(name).create(in);
+    } catch (RestApiException e) {
+      throw die(e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
new file mode 100644
index 0000000..1e1e254
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -0,0 +1,152 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.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.api.groups.GroupInput;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.restapi.group.AddMembers;
+import com.google.gerrit.server.restapi.group.AddSubgroups;
+import com.google.gerrit.server.restapi.group.CreateGroup;
+import com.google.gerrit.server.restapi.group.GroupsCollection;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+/**
+ * Creates a new group.
+ *
+ * <p>Optionally, puts an initial set of user in the newly created group.
+ */
+@RequiresCapability(GlobalCapability.CREATE_GROUP)
+@CommandMetaData(name = "create-group", description = "Create a new account group")
+final class CreateGroupCommand extends SshCommand {
+  @Option(
+    name = "--owner",
+    aliases = {"-o"},
+    metaVar = "GROUP",
+    usage = "owning group, if not specified the group will be self-owning"
+  )
+  private AccountGroup.Id ownerGroupId;
+
+  @Option(
+    name = "--description",
+    aliases = {"-d"},
+    metaVar = "DESC",
+    usage = "description of group"
+  )
+  private String groupDescription = "";
+
+  @Argument(index = 0, required = true, metaVar = "GROUP", usage = "name of group to be created")
+  private String groupName;
+
+  private final Set<Account.Id> initialMembers = new HashSet<>();
+
+  @Option(
+    name = "--member",
+    aliases = {"-m"},
+    metaVar = "USERNAME",
+    usage = "initial set of users to become members of the group"
+  )
+  void addMember(Account.Id id) {
+    initialMembers.add(id);
+  }
+
+  @Option(name = "--visible-to-all", usage = "to make the group visible to all registered users")
+  private boolean visibleToAll;
+
+  private final Set<AccountGroup.UUID> initialGroups = new HashSet<>();
+
+  @Option(
+    name = "--group",
+    aliases = "-g",
+    metaVar = "GROUP",
+    usage = "initial set of groups to be included in the group"
+  )
+  void addGroup(AccountGroup.UUID id) {
+    initialGroups.add(id);
+  }
+
+  @Inject private CreateGroup.Factory createGroupFactory;
+
+  @Inject private GroupsCollection groups;
+
+  @Inject private AddMembers addMembers;
+
+  @Inject private AddSubgroups addSubgroups;
+
+  @Override
+  protected void run() throws Failure, OrmException, IOException, ConfigInvalidException {
+    try {
+      GroupResource rsrc = createGroup();
+
+      if (!initialMembers.isEmpty()) {
+        addMembers(rsrc);
+      }
+
+      if (!initialGroups.isEmpty()) {
+        addSubgroups(rsrc);
+      }
+    } catch (RestApiException e) {
+      throw die(e);
+    }
+  }
+
+  private GroupResource createGroup()
+      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+    GroupInput input = new GroupInput();
+    input.description = groupDescription;
+    input.visibleToAll = visibleToAll;
+
+    if (ownerGroupId != null) {
+      input.ownerId = String.valueOf(ownerGroupId.get());
+    }
+
+    GroupInfo group = createGroupFactory.create(groupName).apply(TopLevelResource.INSTANCE, input);
+    return groups.parse(TopLevelResource.INSTANCE, IdString.fromUrl(group.id));
+  }
+
+  private void addMembers(GroupResource rsrc)
+      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+    AddMembers.Input input =
+        AddMembers.Input.fromMembers(
+            initialMembers.stream().map(Object::toString).collect(toList()));
+    addMembers.apply(rsrc, input);
+  }
+
+  private void addSubgroups(GroupResource rsrc)
+      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+    AddSubgroups.Input input =
+        AddSubgroups.Input.fromGroups(
+            initialGroups.stream().map(AccountGroup.UUID::get).collect(toList()));
+    addSubgroups.apply(rsrc, input);
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
new file mode 100644
index 0000000..2051a00
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -0,0 +1,257 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.projects.ConfigValue;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+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;
+import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+/** Create a new project. * */
+@RequiresCapability(GlobalCapability.CREATE_PROJECT)
+@CommandMetaData(
+  name = "create-project",
+  description = "Create a new project and associated Git repository"
+)
+final class CreateProjectCommand extends SshCommand {
+  @Option(
+    name = "--suggest-parents",
+    aliases = {"-S"},
+    usage =
+        "suggest parent candidates, "
+            + "if this option is used all other options and arguments are ignored"
+  )
+  private boolean suggestParent;
+
+  @Option(
+    name = "--owner",
+    aliases = {"-o"},
+    usage = "owner(s) of project"
+  )
+  private List<AccountGroup.UUID> ownerIds;
+
+  @Option(
+    name = "--parent",
+    aliases = {"-p"},
+    metaVar = "NAME",
+    usage = "parent project"
+  )
+  private ProjectState newParent;
+
+  @Option(name = "--permissions-only", usage = "create project for use only as parent")
+  private boolean permissionsOnly;
+
+  @Option(
+    name = "--description",
+    aliases = {"-d"},
+    metaVar = "DESCRIPTION",
+    usage = "description of project"
+  )
+  private String projectDescription = "";
+
+  @Option(
+    name = "--submit-type",
+    aliases = {"-t"},
+    usage = "project submit type"
+  )
+  private SubmitType submitType;
+
+  @Option(name = "--contributor-agreements", usage = "if contributor agreement is required")
+  private InheritableBoolean contributorAgreements = InheritableBoolean.INHERIT;
+
+  @Option(name = "--signed-off-by", usage = "if signed-off-by is required")
+  private InheritableBoolean signedOffBy = InheritableBoolean.INHERIT;
+
+  @Option(name = "--content-merge", usage = "allow automatic conflict resolving within files")
+  private InheritableBoolean contentMerge = InheritableBoolean.INHERIT;
+
+  @Option(name = "--change-id", usage = "if change-id is required")
+  private InheritableBoolean requireChangeID = InheritableBoolean.INHERIT;
+
+  @Option(name = "--reject-empty-commit", usage = "if empty commits should be rejected on submit")
+  private InheritableBoolean rejectEmptyCommit = InheritableBoolean.INHERIT;
+
+  @Option(
+    name = "--new-change-for-all-not-in-target",
+    usage = "if a new change will be created for every commit not in target branch"
+  )
+  private InheritableBoolean createNewChangeForAllNotInTarget = InheritableBoolean.INHERIT;
+
+  @Option(
+    name = "--use-contributor-agreements",
+    aliases = {"--ca"},
+    usage = "if contributor agreement is required"
+  )
+  void setUseContributorArgreements(@SuppressWarnings("unused") boolean on) {
+    contributorAgreements = InheritableBoolean.TRUE;
+  }
+
+  @Option(
+    name = "--use-signed-off-by",
+    aliases = {"--so"},
+    usage = "if signed-off-by is required"
+  )
+  void setUseSignedOffBy(@SuppressWarnings("unused") boolean on) {
+    signedOffBy = InheritableBoolean.TRUE;
+  }
+
+  @Option(name = "--use-content-merge", usage = "allow automatic conflict resolving within files")
+  void setUseContentMerge(@SuppressWarnings("unused") boolean on) {
+    contentMerge = InheritableBoolean.TRUE;
+  }
+
+  @Option(
+    name = "--require-change-id",
+    aliases = {"--id"},
+    usage = "if change-id is required"
+  )
+  void setRequireChangeId(@SuppressWarnings("unused") boolean on) {
+    requireChangeID = InheritableBoolean.TRUE;
+  }
+
+  @Option(
+    name = "--create-new-change-for-all-not-in-target",
+    aliases = {"--ncfa"},
+    usage = "if a new change will be created for every commit not in target branch"
+  )
+  void setNewChangeForAllNotInTarget(@SuppressWarnings("unused") boolean on) {
+    createNewChangeForAllNotInTarget = InheritableBoolean.TRUE;
+  }
+
+  @Option(
+    name = "--branch",
+    aliases = {"-b"},
+    metaVar = "BRANCH",
+    usage = "initial branch name\n(default: master)"
+  )
+  private List<String> branch;
+
+  @Option(name = "--empty-commit", usage = "to create initial empty commit")
+  private boolean createEmptyCommit;
+
+  @Option(name = "--max-object-size-limit", usage = "max Git object size for this project")
+  private String maxObjectSizeLimit;
+
+  @Option(
+    name = "--plugin-config",
+    usage = "plugin configuration parameter with format '<plugin-name>.<parameter-name>=<value>'"
+  )
+  private List<String> pluginConfigValues;
+
+  @Argument(index = 0, metaVar = "NAME", usage = "name of project to be created")
+  private String projectName;
+
+  @Inject private GerritApi gApi;
+
+  @Inject private SuggestParentCandidates suggestParentCandidates;
+
+  @Override
+  protected void run() throws Failure {
+    try {
+      if (!suggestParent) {
+        if (projectName == null) {
+          throw die("Project name is required.");
+        }
+
+        ProjectInput input = new ProjectInput();
+        input.name = projectName;
+        if (ownerIds != null) {
+          input.owners = Lists.transform(ownerIds, AccountGroup.UUID::get);
+        }
+        if (newParent != null) {
+          input.parent = newParent.getName();
+        }
+        input.permissionsOnly = permissionsOnly;
+        input.description = projectDescription;
+        input.submitType = submitType;
+        input.useContributorAgreements = contributorAgreements;
+        input.useSignedOffBy = signedOffBy;
+        input.useContentMerge = contentMerge;
+        input.requireChangeId = requireChangeID;
+        input.createNewChangeForAllNotInTarget = createNewChangeForAllNotInTarget;
+        input.branches = branch;
+        input.createEmptyCommit = createEmptyCommit;
+        input.maxObjectSizeLimit = maxObjectSizeLimit;
+        input.rejectEmptyCommit = rejectEmptyCommit;
+        if (pluginConfigValues != null) {
+          input.pluginConfigValues = parsePluginConfigValues(pluginConfigValues);
+        }
+
+        gApi.projects().create(input);
+      } else {
+        for (Project.NameKey parent : suggestParentCandidates.getNameKeys()) {
+          stdout.print(parent.get() + '\n');
+        }
+      }
+    } catch (RestApiException err) {
+      throw die(err);
+    } catch (PermissionBackendException err) {
+      throw new Failure(1, "permissions unavailable", err);
+    }
+  }
+
+  @VisibleForTesting
+  Map<String, Map<String, ConfigValue>> parsePluginConfigValues(List<String> pluginConfigValues)
+      throws UnloggedFailure {
+    Map<String, Map<String, ConfigValue>> m = new HashMap<>();
+    for (String pluginConfigValue : pluginConfigValues) {
+      String[] s = pluginConfigValue.split("=");
+      String[] s2 = s[0].split("\\.");
+      if (s.length != 2 || s2.length != 2) {
+        throw die(
+            "Invalid plugin config value '"
+                + pluginConfigValue
+                + "', expected format '<plugin-name>.<parameter-name>=<value>'"
+                + " or '<plugin-name>.<parameter-name>=<value1,value2,...>'");
+      }
+      ConfigValue value = new ConfigValue();
+      String v = s[1];
+      if (v.contains(",")) {
+        value.values = Lists.newArrayList(Splitter.on(",").split(v));
+      } else {
+        value.value = v;
+      }
+      String pluginName = s2[0];
+      String paramName = s2[1];
+      Map<String, ConfigValue> l = m.get(pluginName);
+      if (l == null) {
+        l = new HashMap<>();
+        m.put(pluginName, l);
+      }
+      l.put(paramName, value);
+    }
+    return m;
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
rename to java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
diff --git a/java/com/google/gerrit/sshd/commands/FlushCaches.java b/java/com/google/gerrit/sshd/commands/FlushCaches.java
new file mode 100644
index 0000000..2271ece
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.common.data.GlobalCapability.FLUSH_CACHES;
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+import static com.google.gerrit.server.restapi.config.PostCaches.Operation.FLUSH;
+import static com.google.gerrit.server.restapi.config.PostCaches.Operation.FLUSH_ALL;
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.config.ListCaches;
+import com.google.gerrit.server.restapi.config.ListCaches.OutputFormat;
+import com.google.gerrit.server.restapi.config.PostCaches;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Option;
+
+/** Causes the caches to purge all entries and reload. */
+@RequiresAnyCapability({FLUSH_CACHES, MAINTAIN_SERVER})
+@CommandMetaData(
+  name = "flush-caches",
+  description = "Flush some/all server caches from memory",
+  runsAt = MASTER_OR_SLAVE
+)
+final class FlushCaches extends SshCommand {
+  @Option(name = "--cache", usage = "flush named cache", metaVar = "NAME")
+  private List<String> caches = new ArrayList<>();
+
+  @Option(name = "--all", usage = "flush all caches")
+  private boolean all;
+
+  @Option(name = "--list", usage = "list available caches")
+  private boolean list;
+
+  @Inject private ListCaches listCaches;
+
+  @Inject private PostCaches postCaches;
+
+  @Override
+  protected void run() throws Failure {
+    try {
+      if (list) {
+        if (all || caches.size() > 0) {
+          throw die("cannot use --list with --all or --cache");
+        }
+        doList();
+        return;
+      }
+
+      if (all && caches.size() > 0) {
+        throw die("cannot combine --all and --cache");
+      } else if (!all && caches.size() == 1 && caches.contains("all")) {
+        caches.clear();
+        all = true;
+      } else if (!all && caches.isEmpty()) {
+        all = true;
+      }
+
+      if (all) {
+        postCaches.apply(new ConfigResource(), new PostCaches.Input(FLUSH_ALL));
+      } else {
+        postCaches.apply(new ConfigResource(), new PostCaches.Input(FLUSH, caches));
+      }
+    } catch (RestApiException e) {
+      throw die(e.getMessage());
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "unavailable", e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  private void doList() {
+    for (String name :
+        (List<String>) listCaches.setFormat(OutputFormat.LIST).apply(new ConfigResource())) {
+      stderr.print(name);
+      stderr.print('\n');
+    }
+    stderr.flush();
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java b/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
new file mode 100644
index 0000000..25f0e77
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
@@ -0,0 +1,118 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+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;
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+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.ProjectState;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+/** Runs the Git garbage collection. */
+@RequiresAnyCapability({RUN_GC, MAINTAIN_SERVER})
+@CommandMetaData(name = "gc", description = "Run Git garbage collection", runsAt = MASTER_OR_SLAVE)
+public class GarbageCollectionCommand extends SshCommand {
+
+  @Option(name = "--all", usage = "runs the Git garbage collection for all projects")
+  private boolean all;
+
+  @Option(name = "--show-progress", usage = "progress information is shown")
+  private boolean showProgress;
+
+  @Option(name = "--aggressive", usage = "run aggressive garbage collection")
+  private boolean aggressive;
+
+  @Argument(
+    index = 0,
+    required = false,
+    multiValued = true,
+    metaVar = "NAME",
+    usage = "projects for which the Git garbage collection should be run"
+  )
+  private List<ProjectState> projects = new ArrayList<>();
+
+  @Inject private ProjectCache projectCache;
+
+  @Inject private GarbageCollection.Factory garbageCollectionFactory;
+
+  @Override
+  public void run() throws Exception {
+    verifyCommandLine();
+    runGC();
+  }
+
+  private void verifyCommandLine() throws UnloggedFailure {
+    if (!all && projects.isEmpty()) {
+      throw die("needs projects as command arguments or --all option");
+    }
+    if (all && !projects.isEmpty()) {
+      throw die("either specify projects as command arguments or use --all option");
+    }
+  }
+
+  private void runGC() {
+    List<Project.NameKey> projectNames;
+    if (all) {
+      projectNames = Lists.newArrayList(projectCache.all());
+    } else {
+      projectNames = projects.stream().map(ProjectState::getNameKey).collect(toList());
+    }
+
+    GarbageCollectionResult result =
+        garbageCollectionFactory
+            .create()
+            .run(projectNames, aggressive, showProgress ? stdout : null);
+    if (result.hasErrors()) {
+      for (GarbageCollectionResult.Error e : result.getErrors()) {
+        String msg;
+        switch (e.getType()) {
+          case REPOSITORY_NOT_FOUND:
+            msg = "error: project \"" + e.getProjectName() + "\" not found";
+            break;
+          case GC_ALREADY_SCHEDULED:
+            msg =
+                "error: garbage collection for project \""
+                    + e.getProjectName()
+                    + "\" was already scheduled";
+            break;
+          case GC_FAILED:
+            msg = "error: garbage collection for project \"" + e.getProjectName() + "\" failed";
+            break;
+          default:
+            msg =
+                "error: garbage collection for project \""
+                    + e.getProjectName()
+                    + "\" failed: "
+                    + e.getType();
+        }
+        stdout.print(msg + "\n");
+      }
+    }
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java b/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
rename to java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
new file mode 100644
index 0000000..3ed856b
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.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.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.change.Index;
+import com.google.gerrit.sshd.ChangeArgumentParser;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.kohsuke.args4j.Argument;
+
+@CommandMetaData(name = "changes", description = "Index changes")
+final class IndexChangesCommand extends SshCommand {
+  @Inject private Index index;
+
+  @Inject private ChangeArgumentParser changeArgumentParser;
+
+  @Argument(
+    index = 0,
+    required = true,
+    multiValued = true,
+    metaVar = "CHANGE",
+    usage = "changes to index"
+  )
+  void addChange(String token) {
+    try {
+      changeArgumentParser.addChange(token, changes, null, false);
+    } catch (UnloggedFailure | OrmException | PermissionBackendException e) {
+      writeError("warning", e.getMessage());
+    }
+  }
+
+  private Map<Change.Id, ChangeResource> changes = new LinkedHashMap<>();
+
+  @Override
+  protected void run() throws UnloggedFailure {
+    boolean ok = true;
+    for (ChangeResource rsrc : changes.values()) {
+      try {
+        index.apply(rsrc, new Input());
+      } catch (Exception e) {
+        ok = false;
+        writeError(
+            "error", String.format("failed to index change %s: %s", rsrc.getId(), e.getMessage()));
+      }
+    }
+    if (!ok) {
+      throw die("failed to index one or more changes");
+    }
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java b/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
rename to java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
diff --git a/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java b/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
new file mode 100644
index 0000000..e6abc17
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.project.Index;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Argument;
+
+@RequiresAnyCapability({MAINTAIN_SERVER})
+@CommandMetaData(name = "project", description = "Index changes of a project")
+final class IndexProjectCommand extends SshCommand {
+
+  @Inject private Index index;
+
+  @Argument(
+    index = 0,
+    required = true,
+    multiValued = true,
+    metaVar = "PROJECT",
+    usage = "projects for which the changes should be indexed"
+  )
+  private List<ProjectState> projects = new ArrayList<>();
+
+  @Override
+  protected void run() throws UnloggedFailure, Failure, Exception {
+    if (projects.isEmpty()) {
+      throw die("needs at least one project as command arguments");
+    }
+    projects.stream().forEach(this::index);
+  }
+
+  private void index(ProjectState projectState) {
+    try {
+      index.apply(new ProjectResource(projectState, user), null);
+    } catch (Exception e) {
+      writeError(
+          "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/IndexStartCommand.java b/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
rename to java/com/google/gerrit/sshd/commands/IndexStartCommand.java
diff --git a/java/com/google/gerrit/sshd/commands/KillCommand.java b/java/com/google/gerrit/sshd/commands/KillCommand.java
new file mode 100644
index 0000000..a7e751a
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/KillCommand.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.common.data.GlobalCapability.KILL_TASK;
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+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.server.config.ConfigResource;
+import com.google.gerrit.server.config.TaskResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.config.DeleteTask;
+import com.google.gerrit.server.restapi.config.TasksCollection;
+import com.google.gerrit.sshd.AdminHighPriorityCommand;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Argument;
+
+/** Kill a task in the work queue. */
+@AdminHighPriorityCommand
+@RequiresAnyCapability({KILL_TASK, MAINTAIN_SERVER})
+final class KillCommand extends SshCommand {
+  @Inject private TasksCollection tasksCollection;
+
+  @Inject private DeleteTask deleteTask;
+
+  @Argument(index = 0, multiValued = true, required = true, metaVar = "ID")
+  private final List<String> taskIds = new ArrayList<>();
+
+  @Override
+  protected void run() {
+    ConfigResource cfgRsrc = new ConfigResource();
+    for (String id : taskIds) {
+      try {
+        TaskResource taskRsrc = tasksCollection.parse(cfgRsrc, IdString.fromDecoded(id));
+        deleteTask.apply(taskRsrc, null);
+      } catch (AuthException | ResourceNotFoundException | PermissionBackendException e) {
+        stderr.print("kill: " + id + ": No such task\n");
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
new file mode 100644
index 0000000..473cb0c
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
@@ -0,0 +1,81 @@
+// 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.sshd.commands;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.ioutil.ColumnFormatter;
+import com.google.gerrit.server.restapi.group.ListGroups;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gerrit.util.cli.Options;
+import com.google.inject.Inject;
+import java.util.Optional;
+import org.kohsuke.args4j.Option;
+
+@CommandMetaData(
+  name = "ls-groups",
+  description = "List groups visible to the caller",
+  runsAt = MASTER_OR_SLAVE
+)
+public class ListGroupsCommand extends SshCommand {
+  @Inject private GroupCache groupCache;
+
+  @Inject @Options public ListGroups listGroups;
+
+  @Option(
+    name = "--verbose",
+    aliases = {"-v"},
+    usage =
+        "verbose output format with tab-separated columns for the "
+            + "group name, UUID, description, owner group name, "
+            + "owner group UUID, and whether the group is visible to all"
+  )
+  private boolean verboseOutput;
+
+  @Override
+  public void run() throws Exception {
+    if (listGroups.getUser() != null && !listGroups.getProjects().isEmpty()) {
+      throw die("--user and --project options are not compatible.");
+    }
+
+    ColumnFormatter formatter = new ColumnFormatter(stdout, '\t');
+    for (GroupInfo info : listGroups.get()) {
+      formatter.addColumn(MoreObjects.firstNonNull(info.name, "n/a"));
+      if (verboseOutput) {
+        Optional<InternalGroup> group =
+            info.ownerId != null
+                ? groupCache.get(new AccountGroup.UUID(Url.decode(info.ownerId)))
+                : Optional.empty();
+
+        formatter.addColumn(Url.decode(info.id));
+        formatter.addColumn(Strings.nullToEmpty(info.description));
+        formatter.addColumn(group.map(InternalGroup::getName).orElse("n/a"));
+        formatter.addColumn(group.map(g -> g.getGroupUUID().get()).orElse(""));
+        formatter.addColumn(
+            Boolean.toString(MoreObjects.firstNonNull(info.options.visibleToAll, Boolean.FALSE)));
+      }
+      formatter.nextLine();
+    }
+    formatter.finish();
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
rename to java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
diff --git a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
new file mode 100644
index 0000000..bf3dd44
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.ioutil.ColumnFormatter;
+import com.google.gerrit.server.restapi.group.ListMembers;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.PrintWriter;
+import java.util.List;
+import java.util.Optional;
+import org.kohsuke.args4j.Argument;
+
+/** Implements a command that allows the user to see the members of a account. */
+@CommandMetaData(
+  name = "ls-members",
+  description = "List the members of a given group",
+  runsAt = MASTER_OR_SLAVE
+)
+public class ListMembersCommand extends SshCommand {
+  @Inject ListMembersCommandImpl impl;
+
+  @Override
+  public void run() throws Exception {
+    impl.display(stdout);
+  }
+
+  @Override
+  protected void parseCommandLine() throws UnloggedFailure {
+    parseCommandLine(impl);
+  }
+
+  private static class ListMembersCommandImpl extends ListMembers {
+    @Argument(required = true, usage = "the name of the group", metaVar = "GROUPNAME")
+    private String name;
+
+    private final GroupCache groupCache;
+
+    @Inject
+    protected ListMembersCommandImpl(
+        GroupCache groupCache,
+        GroupControl.Factory groupControlFactory,
+        AccountLoader.Factory accountLoaderFactory) {
+      super(groupCache, groupControlFactory, accountLoaderFactory);
+      this.groupCache = groupCache;
+    }
+
+    void display(PrintWriter writer) throws OrmException {
+      Optional<InternalGroup> group = groupCache.get(new AccountGroup.NameKey(name));
+      String errorText = "Group not found or not visible\n";
+
+      if (!group.isPresent()) {
+        writer.write(errorText);
+        writer.flush();
+        return;
+      }
+
+      List<AccountInfo> members = getDirectMembers(group.get());
+      ColumnFormatter formatter = new ColumnFormatter(writer, '\t');
+      formatter.addColumn("id");
+      formatter.addColumn("username");
+      formatter.addColumn("full name");
+      formatter.addColumn("email");
+      formatter.nextLine();
+      for (AccountInfo member : members) {
+        if (member == null) {
+          continue;
+        }
+
+        formatter.addColumn(Integer.toString(member._accountId));
+        formatter.addColumn(MoreObjects.firstNonNull(member.username, "n/a"));
+        formatter.addColumn(MoreObjects.firstNonNull(Strings.emptyToNull(member.name), "n/a"));
+        formatter.addColumn(MoreObjects.firstNonNull(member.email, "n/a"));
+        formatter.nextLine();
+      }
+
+      formatter.finish();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
new file mode 100644
index 0000000..664f87b
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.server.restapi.project.ListProjects;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gerrit.util.cli.Options;
+import com.google.inject.Inject;
+import java.util.List;
+
+@CommandMetaData(
+  name = "ls-projects",
+  description = "List projects visible to the caller",
+  runsAt = MASTER_OR_SLAVE
+)
+public class ListProjectsCommand extends SshCommand {
+  @Inject @Options public ListProjects impl;
+
+  @Override
+  public void run() throws Exception {
+    if (!impl.getFormat().isJson()) {
+      List<String> showBranch = impl.getShowBranch();
+      if (impl.isShowTree() && (showBranch != null) && !showBranch.isEmpty()) {
+        throw die("--tree and --show-branch options are not compatible.");
+      }
+      if (impl.isShowTree() && impl.isShowDescription()) {
+        throw die("--tree and --description options are not compatible.");
+      }
+    }
+    impl.display(out);
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/LsUserRefs.java b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
new file mode 100644
index 0000000..e467cc4
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
@@ -0,0 +1,113 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+import static org.eclipse.jgit.lib.RefDatabase.ALL;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.VisibleRefFilter;
+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;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@CommandMetaData(
+  name = "ls-user-refs",
+  description = "List refs visible to a specific user",
+  runsAt = MASTER_OR_SLAVE
+)
+public class LsUserRefs extends SshCommand {
+  @Inject private AccountResolver accountResolver;
+  @Inject private OneOffRequestContext requestContext;
+  @Inject private VisibleRefFilter.Factory refFilterFactory;
+  @Inject private GitRepositoryManager repoManager;
+
+  @Option(
+    name = "--project",
+    aliases = {"-p"},
+    metaVar = "PROJECT",
+    required = true,
+    usage = "project for which the refs should be listed"
+  )
+  private ProjectState projectState;
+
+  @Option(
+    name = "--user",
+    aliases = {"-u"},
+    metaVar = "USER",
+    required = true,
+    usage = "user for which the groups should be listed"
+  )
+  private String userName;
+
+  @Option(name = "--only-refs-heads", usage = "list only refs under refs/heads")
+  private boolean onlyRefsHeads;
+
+  @Override
+  protected void run() throws Failure {
+    Account userAccount;
+    try {
+      userAccount = accountResolver.find(userName);
+    } catch (OrmException | IOException | ConfigInvalidException e) {
+      throw die(e);
+    }
+    if (userAccount == null) {
+      stdout.print("No single user could be found when searching for: " + userName + '\n');
+      stdout.flush();
+      return;
+    }
+
+    Project.NameKey projectName = projectState.getNameKey();
+    try (Repository repo = repoManager.openRepository(projectName);
+        ManualRequestContext ctx = requestContext.openAs(userAccount.getId())) {
+      try {
+        Map<String, Ref> refsMap =
+            refFilterFactory
+                .create(projectState, repo)
+                .filter(repo.getRefDatabase().getRefs(ALL), false);
+
+        for (String ref : refsMap.keySet()) {
+          if (!onlyRefsHeads || ref.startsWith(RefNames.REFS_HEADS)) {
+            stdout.println(ref);
+          }
+        }
+      } catch (IOException e) {
+        throw new Failure(1, "fatal: Error reading refs: '" + projectName, e);
+      }
+    } catch (RepositoryNotFoundException e) {
+      throw die("'" + projectName + "': not a git archive");
+    } catch (IOException | OrmException e) {
+      throw die("Error opening: '" + projectName);
+    }
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/NotSupportedInSlaveModeFailureCommand.java b/java/com/google/gerrit/sshd/commands/NotSupportedInSlaveModeFailureCommand.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/NotSupportedInSlaveModeFailureCommand.java
rename to java/com/google/gerrit/sshd/commands/NotSupportedInSlaveModeFailureCommand.java
diff --git a/java/com/google/gerrit/sshd/commands/PatchSetParser.java b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
new file mode 100644
index 0000000..9fcd201
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
@@ -0,0 +1,161 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeFinder;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.sshd.BaseCommand.UnloggedFailure;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+
+@Singleton
+public class PatchSetParser {
+  private final Provider<ReviewDb> db;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeNotes.Factory notesFactory;
+  private final PatchSetUtil psUtil;
+  private final ChangeFinder changeFinder;
+
+  @Inject
+  PatchSetParser(
+      Provider<ReviewDb> db,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeNotes.Factory notesFactory,
+      PatchSetUtil psUtil,
+      ChangeFinder changeFinder) {
+    this.db = db;
+    this.queryProvider = queryProvider;
+    this.notesFactory = notesFactory;
+    this.psUtil = psUtil;
+    this.changeFinder = changeFinder;
+  }
+
+  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 (projectState != null) {
+        Project.NameKey p = projectState.getNameKey();
+        if (branch != null) {
+          cds = query.byBranchCommit(p.get(), branch, token);
+        } else {
+          cds = query.byProjectCommit(p, token);
+        }
+      } else {
+        cds = query.byCommit(token);
+      }
+      List<PatchSet> matches = new ArrayList<>(cds.size());
+      for (ChangeData cd : cds) {
+        Change c = cd.change();
+        if (!(inProject(c, projectState) && inBranch(c, branch))) {
+          continue;
+        }
+        for (PatchSet ps : cd.patchSets()) {
+          if (ps.getRevision().matches(token)) {
+            matches.add(ps);
+          }
+        }
+      }
+
+      switch (matches.size()) {
+        case 1:
+          return matches.iterator().next();
+        case 0:
+          throw error("\"" + token + "\" no such patch set");
+        default:
+          throw error("\"" + token + "\" matches multiple patch sets");
+      }
+    }
+
+    // By older style change,patchset?
+    //
+    if (token.matches("^[1-9][0-9]*,[1-9][0-9]*$")) {
+      PatchSet.Id patchSetId;
+      try {
+        patchSetId = PatchSet.Id.parse(token);
+      } catch (IllegalArgumentException e) {
+        throw error("\"" + token + "\" is not a valid patch set");
+      }
+      ChangeNotes notes = getNotes(projectState, patchSetId.getParentKey());
+      PatchSet patchSet = psUtil.get(db.get(), notes, patchSetId);
+      if (patchSet == null) {
+        throw error("\"" + token + "\" no such patch set");
+      }
+      if (projectState != null || branch != null) {
+        Change change = notes.getChange();
+        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);
+        }
+      }
+      return patchSet;
+    }
+
+    throw error("\"" + token + "\" is not a valid patch set");
+  }
+
+  private ChangeNotes getNotes(@Nullable ProjectState projectState, Change.Id changeId)
+      throws OrmException, UnloggedFailure {
+    if (projectState != null) {
+      return notesFactory.create(db.get(), projectState.getNameKey(), changeId);
+    }
+    try {
+      ChangeNotes notes = changeFinder.findOne(changeId);
+      return notesFactory.create(db.get(), notes.getProjectName(), changeId);
+    } catch (NoSuchChangeException e) {
+      throw error("\"" + changeId + "\" no such change");
+    }
+  }
+
+  private static boolean inProject(Change change, ProjectState projectState) {
+    if (projectState == null) {
+      // No --project option, so they want every project.
+      return true;
+    }
+    return projectState.getNameKey().equals(change.getProject());
+  }
+
+  private static boolean inBranch(Change change, String branch) {
+    if (branch == null) {
+      // No --branch option, so they want every branch.
+      return true;
+    }
+    return change.getDest().get().equals(branch);
+  }
+
+  public static UnloggedFailure error(String msg) {
+    return new UnloggedFailure(1, msg);
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java b/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
new file mode 100644
index 0000000..7e32615
--- /dev/null
+++ b/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/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java b/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
new file mode 100644
index 0000000..baaf715
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.server.plugins.PluginInstallException;
+import com.google.gerrit.sshd.CommandMetaData;
+import java.util.List;
+import org.kohsuke.args4j.Argument;
+
+@CommandMetaData(name = "enable", description = "Enable plugins", runsAt = MASTER_OR_SLAVE)
+final class PluginEnableCommand extends PluginAdminSshCommand {
+  @Argument(index = 0, metaVar = "NAME", required = true, usage = "plugin(s) to enable")
+  List<String> names;
+
+  @Override
+  protected void doRun() throws UnloggedFailure {
+    if (names != null && !names.isEmpty()) {
+      try {
+        loader.enablePlugins(Sets.newHashSet(names));
+      } catch (PluginInstallException e) {
+        e.printStackTrace(stderr);
+        throw die("plugin failed to enable");
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java b/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
new file mode 100644
index 0000000..337eadb
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.server.plugins.PluginInstallException;
+import com.google.gerrit.sshd.CommandMetaData;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.Files;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+@CommandMetaData(name = "install", description = "Install/Add a plugin", runsAt = MASTER_OR_SLAVE)
+final class PluginInstallCommand extends PluginAdminSshCommand {
+  @Option(
+    name = "--name",
+    aliases = {"-n"},
+    usage = "install under name"
+  )
+  private String name;
+
+  @Option(name = "-")
+  void useInput(@SuppressWarnings("unused") boolean on) {
+    source = "-";
+  }
+
+  @Argument(index = 0, metaVar = "-|URL", usage = "JAR to load")
+  private String source;
+
+  @SuppressWarnings("resource")
+  @Override
+  protected void doRun() throws UnloggedFailure {
+    if (Strings.isNullOrEmpty(source)) {
+      throw die("Argument \"-|URL\" is required");
+    }
+    if (Strings.isNullOrEmpty(name) && "-".equalsIgnoreCase(source)) {
+      throw die("--name required when source is stdin");
+    }
+
+    if (Strings.isNullOrEmpty(name)) {
+      int s = source.lastIndexOf('/');
+      if (0 <= s) {
+        name = source.substring(s + 1);
+      } else {
+        name = source;
+      }
+    }
+
+    InputStream data;
+    if ("-".equalsIgnoreCase(source)) {
+      data = in;
+    } else if (new File(source).isFile() && source.equals(new File(source).getAbsolutePath())) {
+      try {
+        data = Files.newInputStream(new File(source).toPath());
+      } catch (IOException e) {
+        throw die("cannot read " + source);
+      }
+    } else {
+      try {
+        data = new URL(source).openStream();
+      } catch (MalformedURLException e) {
+        throw die("invalid url " + source);
+      } catch (IOException e) {
+        throw die("cannot read " + source);
+      }
+    }
+    try {
+      loader.installPluginFromStream(name, data);
+    } catch (IOException e) {
+      throw die("cannot install plugin");
+    } catch (PluginInstallException e) {
+      e.printStackTrace(stderr);
+      String msg = String.format("Plugin failed to install. Cause: %s", e.getMessage());
+      throw die(msg);
+    } finally {
+      try {
+        data.close();
+      } catch (IOException err) {
+        // Ignored
+      }
+    }
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginLsCommand.java b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
rename to java/com/google/gerrit/sshd/commands/PluginLsCommand.java
diff --git a/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java b/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
new file mode 100644
index 0000000..86a74d1
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.server.plugins.InvalidPluginException;
+import com.google.gerrit.server.plugins.PluginInstallException;
+import com.google.gerrit.sshd.CommandMetaData;
+import java.util.List;
+import org.kohsuke.args4j.Argument;
+
+@CommandMetaData(name = "reload", description = "Reload/Restart plugins", runsAt = MASTER_OR_SLAVE)
+final class PluginReloadCommand extends PluginAdminSshCommand {
+  @Argument(index = 0, metaVar = "NAME", usage = "plugins to reload/restart")
+  private List<String> names;
+
+  @Override
+  protected void doRun() throws UnloggedFailure {
+    if (names == null || names.isEmpty()) {
+      loader.rescan();
+    } else {
+      try {
+        loader.reload(names);
+      } catch (InvalidPluginException | PluginInstallException e) {
+        throw die(e.getMessage());
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java b/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
new file mode 100644
index 0000000..0119349b
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.sshd.CommandMetaData;
+import java.util.List;
+import org.kohsuke.args4j.Argument;
+
+@CommandMetaData(name = "remove", description = "Disable plugins", runsAt = MASTER_OR_SLAVE)
+final class PluginRemoveCommand extends PluginAdminSshCommand {
+  @Argument(index = 0, metaVar = "NAME", required = true, usage = "plugin to remove")
+  List<String> names;
+
+  @Override
+  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/Query.java b/java/com/google/gerrit/sshd/commands/Query.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
rename to java/com/google/gerrit/sshd/commands/Query.java
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/QueryShell.java b/java/com/google/gerrit/sshd/commands/QueryShell.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/QueryShell.java
rename to java/com/google/gerrit/sshd/commands/QueryShell.java
diff --git a/java/com/google/gerrit/sshd/commands/Receive.java b/java/com/google/gerrit/sshd/commands/Receive.java
new file mode 100644
index 0000000..b199349
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/Receive.java
@@ -0,0 +1,177 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.sshd.AbstractGitCommand;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshSession;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.errors.TooLargeObjectInPackException;
+import org.eclipse.jgit.errors.UnpackException;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.transport.AdvertiseRefsHook;
+import org.eclipse.jgit.transport.ReceivePack;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Receives change upload over SSH using the Git receive-pack protocol. */
+@CommandMetaData(
+  name = "receive-pack",
+  description = "Standard Git server side command for client side git push"
+)
+final class Receive extends AbstractGitCommand {
+  private static final Logger log = LoggerFactory.getLogger(Receive.class);
+
+  @Inject private AsyncReceiveCommits.Factory factory;
+  @Inject private IdentifiedUser currentUser;
+  @Inject private SshSession session;
+  @Inject private PermissionBackend permissionBackend;
+
+  private final SetMultimap<ReviewerStateInternal, Account.Id> reviewers =
+      MultimapBuilder.hashKeys(2).hashSetValues().build();
+
+  @Option(
+    name = "--reviewer",
+    aliases = {"--re"},
+    metaVar = "EMAIL",
+    usage = "request reviewer for change(s)"
+  )
+  void addReviewer(Account.Id id) {
+    reviewers.put(ReviewerStateInternal.REVIEWER, id);
+  }
+
+  @Option(
+    name = "--cc",
+    aliases = {},
+    metaVar = "EMAIL",
+    usage = "CC user on change(s)"
+  )
+  void addCC(Account.Id id) {
+    reviewers.put(ReviewerStateInternal.CC, id);
+  }
+
+  @Override
+  protected void runImpl() throws IOException, Failure {
+    try {
+      permissionBackend
+          .user(currentUser)
+          .project(project.getNameKey())
+          .check(ProjectPermission.RUN_RECEIVE_PACK);
+    } catch (AuthException e) {
+      throw new Failure(1, "fatal: receive-pack not permitted on this server");
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "fatal: unable to check permissions " + e);
+    }
+
+    AsyncReceiveCommits arc = factory.create(projectState, currentUser, repo, null, reviewers);
+
+    try {
+      Capable r = arc.canUpload();
+      if (r != Capable.OK) {
+        throw die(r.getMessage());
+      }
+    } catch (PermissionBackendException e) {
+      throw die(e.getMessage());
+    }
+
+    ReceivePack rp = arc.getReceivePack();
+    try {
+      rp.receive(in, out, err);
+      session.setPeerAgent(rp.getPeerUserAgent());
+    } catch (UnpackException badStream) {
+      // In case this was caused by the user pushing an object whose size
+      // is larger than the receive.maxObjectSizeLimit gerrit.config parameter
+      // 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(projectState.getName()).append("\"");
+        msg.append(" (user ");
+        msg.append(currentUser.getAccount().getUserName());
+        msg.append(" account ");
+        msg.append(currentUser.getAccountId());
+        msg.append("): ");
+        msg.append(badStream.getCause().getMessage());
+        log.info(msg.toString());
+        throw new UnloggedFailure(128, "error: " + badStream.getCause().getMessage());
+      }
+
+      // This may have been triggered by branch level access controls.
+      // Log what the heck is going on, as detailed as we can.
+      //
+      StringBuilder msg = new StringBuilder();
+      msg.append("Unpack error on project \"").append(projectState.getName()).append("\":\n");
+
+      msg.append("  AdvertiseRefsHook: ").append(rp.getAdvertiseRefsHook());
+      if (rp.getAdvertiseRefsHook() == AdvertiseRefsHook.DEFAULT) {
+        msg.append("DEFAULT");
+      } else if (rp.getAdvertiseRefsHook() instanceof VisibleRefFilter) {
+        msg.append("VisibleRefFilter");
+      } else {
+        msg.append(rp.getAdvertiseRefsHook().getClass());
+      }
+      msg.append("\n");
+
+      if (rp.getAdvertiseRefsHook() instanceof VisibleRefFilter) {
+        Map<String, Ref> adv = rp.getAdvertisedRefs();
+        msg.append("  Visible references (").append(adv.size()).append("):\n");
+        for (Ref ref : adv.values()) {
+          msg.append("  - ")
+              .append(ref.getObjectId().abbreviate(8).name())
+              .append(" ")
+              .append(ref.getName())
+              .append("\n");
+        }
+
+        Map<String, Ref> allRefs = rp.getRepository().getRefDatabase().getRefs(RefDatabase.ALL);
+        List<Ref> hidden = new ArrayList<>();
+        for (Ref ref : allRefs.values()) {
+          if (!adv.containsKey(ref.getName())) {
+            hidden.add(ref);
+          }
+        }
+
+        msg.append("  Hidden references (").append(hidden.size()).append("):\n");
+        for (Ref ref : hidden) {
+          msg.append("  - ")
+              .append(ref.getObjectId().abbreviate(8).name())
+              .append(" ")
+              .append(ref.getName())
+              .append("\n");
+        }
+      }
+
+      IOException detail = new IOException(msg.toString(), badStream);
+      throw new Failure(128, "fatal: Unpack error, check server log", detail);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java b/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
new file mode 100644
index 0000000..9e334e6
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
@@ -0,0 +1,60 @@
+// 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.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;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.restapi.group.GroupsCollection;
+import com.google.gerrit.server.restapi.group.PutName;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.kohsuke.args4j.Argument;
+
+@CommandMetaData(name = "rename-group", description = "Rename an account group")
+public class RenameGroupCommand extends SshCommand {
+  @Argument(
+    index = 0,
+    required = true,
+    metaVar = "GROUP",
+    usage = "name of the group to be renamed"
+  )
+  private String groupName;
+
+  @Argument(index = 1, required = true, metaVar = "NEWNAME", usage = "new name of the group")
+  private String newGroupName;
+
+  @Inject private GroupsCollection groups;
+
+  @Inject private PutName putName;
+
+  @Override
+  protected void run() throws Failure {
+    try {
+      GroupResource rsrc = groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName));
+      NameInput input = new NameInput();
+      input.name = newGroupName;
+      putName.apply(rsrc, input);
+    } catch (RestApiException | OrmException | IOException | ConfigInvalidException e) {
+      throw die(e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
new file mode 100644
index 0000000..1be32a8
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -0,0 +1,356 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Strings;
+import com.google.common.io.CharStreams;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.AbandonInput;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.MoveInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RestoreInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gerrit.util.cli.CmdLineParser;
+import com.google.gson.JsonSyntaxException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@CommandMetaData(name = "review", description = "Apply reviews to one or more patch sets")
+public class ReviewCommand extends SshCommand {
+  private static final Logger log = LoggerFactory.getLogger(ReviewCommand.class);
+
+  @Override
+  protected final CmdLineParser newCmdLineParser(Object options) {
+    final CmdLineParser parser = super.newCmdLineParser(options);
+    for (ApproveOption c : optionList) {
+      parser.addOption(c, c);
+    }
+    return parser;
+  }
+
+  private final Set<PatchSet> patchSets = new HashSet<>();
+
+  @Argument(
+    index = 0,
+    required = true,
+    multiValued = true,
+    metaVar = "{COMMIT | CHANGE,PATCHSET}",
+    usage = "list of commits or patch sets to review"
+  )
+  void addPatchSetId(String token) {
+    try {
+      PatchSet ps = psParser.parsePatchSet(token, projectState, branch);
+      patchSets.add(ps);
+    } catch (UnloggedFailure e) {
+      throw new IllegalArgumentException(e.getMessage(), e);
+    } catch (OrmException e) {
+      throw new IllegalArgumentException("database error", e);
+    }
+  }
+
+  @Option(
+    name = "--project",
+    aliases = "-p",
+    usage = "project containing the specified patch set(s)"
+  )
+  private ProjectState projectState;
+
+  @Option(name = "--branch", aliases = "-b", usage = "branch containing the specified patch set(s)")
+  private String branch;
+
+  @Option(
+    name = "--message",
+    aliases = "-m",
+    usage = "cover message to publish on change(s)",
+    metaVar = "MESSAGE"
+  )
+  private String changeComment;
+
+  @Option(
+    name = "--notify",
+    aliases = "-n",
+    usage = "Who to send email notifications to after the review is stored.",
+    metaVar = "NOTIFYHANDLING"
+  )
+  private NotifyHandling notify;
+
+  @Option(name = "--abandon", usage = "abandon the specified change(s)")
+  private boolean abandonChange;
+
+  @Option(name = "--restore", usage = "restore the specified abandoned change(s)")
+  private boolean restoreChange;
+
+  @Option(name = "--rebase", usage = "rebase the specified change(s)")
+  private boolean rebaseChange;
+
+  @Option(name = "--move", usage = "move the specified change(s)", metaVar = "BRANCH")
+  private String moveToBranch;
+
+  @Option(name = "--submit", aliases = "-s", usage = "submit the specified patch set(s)")
+  private boolean submitChange;
+
+  @Option(name = "--json", aliases = "-j", usage = "read review input json from stdin")
+  private boolean json;
+
+  @Option(
+    name = "--tag",
+    aliases = "-t",
+    usage = "applies a tag to the given review",
+    metaVar = "TAG"
+  )
+  private String changeTag;
+
+  @Option(
+    name = "--label",
+    aliases = "-l",
+    usage = "custom label(s) to assign",
+    metaVar = "LABEL=VALUE"
+  )
+  void addLabel(String token) {
+    LabelVote v = LabelVote.parseWithEquals(token);
+    LabelType.checkName(v.label()); // Disallow SUBM.
+    customLabels.put(v.label(), v.value());
+  }
+
+  @Inject private ProjectCache projectCache;
+
+  @Inject private AllProjectsName allProjects;
+
+  @Inject private GerritApi gApi;
+
+  @Inject private PatchSetParser psParser;
+
+  private List<ApproveOption> optionList;
+  private Map<String, Short> customLabels;
+
+  @Override
+  protected void run() throws UnloggedFailure {
+    if (abandonChange) {
+      if (restoreChange) {
+        throw die("abandon and restore actions are mutually exclusive");
+      }
+      if (submitChange) {
+        throw die("abandon and submit actions are mutually exclusive");
+      }
+      if (rebaseChange) {
+        throw die("abandon and rebase actions are mutually exclusive");
+      }
+      if (moveToBranch != null) {
+        throw die("abandon and move actions are mutually exclusive");
+      }
+    }
+    if (json) {
+      if (restoreChange) {
+        throw die("json and restore actions are mutually exclusive");
+      }
+      if (submitChange) {
+        throw die("json and submit actions are mutually exclusive");
+      }
+      if (abandonChange) {
+        throw die("json and abandon actions are mutually exclusive");
+      }
+      if (changeComment != null) {
+        throw die("json and message are mutually exclusive");
+      }
+      if (rebaseChange) {
+        throw die("json and rebase actions are mutually exclusive");
+      }
+      if (moveToBranch != null) {
+        throw die("json and move actions are mutually exclusive");
+      }
+      if (changeTag != null) {
+        throw die("json and tag actions are mutually exclusive");
+      }
+    }
+    if (rebaseChange) {
+      if (submitChange) {
+        throw die("rebase and submit actions are mutually exclusive");
+      }
+    }
+
+    boolean ok = true;
+    ReviewInput input = null;
+    if (json) {
+      input = reviewFromJson();
+    }
+
+    for (PatchSet patchSet : patchSets) {
+      try {
+        if (input != null) {
+          applyReview(patchSet, input);
+        } else {
+          reviewPatchSet(patchSet);
+        }
+      } catch (RestApiException | UnloggedFailure e) {
+        ok = false;
+        writeError("error", e.getMessage() + "\n");
+      } catch (NoSuchChangeException e) {
+        ok = false;
+        writeError("error", "no such change " + patchSet.getId().getParentKey().get());
+      } catch (Exception e) {
+        ok = false;
+        writeError("fatal", "internal server error while reviewing " + patchSet.getId() + "\n");
+        log.error("internal error while reviewing " + patchSet.getId(), e);
+      }
+    }
+
+    if (!ok) {
+      throw die("one or more reviews failed; review output above");
+    }
+  }
+
+  @Override
+  protected String getTaskDescription() {
+    return "gerrit review";
+  }
+
+  private void applyReview(PatchSet patchSet, ReviewInput review) throws RestApiException {
+    gApi.changes()
+        .id(patchSet.getId().getParentKey().get())
+        .revision(patchSet.getRevision().get())
+        .review(review);
+  }
+
+  private ReviewInput reviewFromJson() throws UnloggedFailure {
+    try (InputStreamReader r = new InputStreamReader(in, UTF_8)) {
+      return OutputFormat.JSON.newGson().fromJson(CharStreams.toString(r), ReviewInput.class);
+    } catch (IOException | JsonSyntaxException e) {
+      writeError("error", e.getMessage() + '\n');
+      throw die("internal error while reading review input");
+    }
+  }
+
+  private void reviewPatchSet(PatchSet patchSet) throws Exception {
+    if (notify == null) {
+      notify = NotifyHandling.ALL;
+    }
+
+    ReviewInput review = new ReviewInput();
+    review.message = Strings.emptyToNull(changeComment);
+    review.tag = Strings.emptyToNull(changeTag);
+    review.notify = notify;
+    review.labels = new TreeMap<>();
+    review.drafts = ReviewInput.DraftHandling.PUBLISH;
+    for (ApproveOption ao : optionList) {
+      Short v = ao.value();
+      if (v != null) {
+        review.labels.put(ao.getLabelName(), v);
+      }
+    }
+    review.labels.putAll(customLabels);
+
+    // We don't need to add the review comment when abandoning/restoring.
+    if (abandonChange || restoreChange || moveToBranch != null) {
+      review.message = null;
+    }
+
+    try {
+      if (abandonChange) {
+        AbandonInput input = new AbandonInput();
+        input.message = Strings.emptyToNull(changeComment);
+        applyReview(patchSet, review);
+        changeApi(patchSet).abandon(input);
+      } else if (restoreChange) {
+        RestoreInput input = new RestoreInput();
+        input.message = Strings.emptyToNull(changeComment);
+        changeApi(patchSet).restore(input);
+        applyReview(patchSet, review);
+      } else {
+        applyReview(patchSet, review);
+      }
+
+      if (moveToBranch != null) {
+        MoveInput moveInput = new MoveInput();
+        moveInput.destinationBranch = moveToBranch;
+        moveInput.message = Strings.emptyToNull(changeComment);
+        changeApi(patchSet).move(moveInput);
+      }
+
+      if (rebaseChange) {
+        revisionApi(patchSet).rebase();
+      }
+
+      if (submitChange) {
+        revisionApi(patchSet).submit();
+      }
+
+    } catch (IllegalStateException | RestApiException e) {
+      throw die(e);
+    }
+  }
+
+  private ChangeApi changeApi(PatchSet patchSet) throws RestApiException {
+    return gApi.changes().id(patchSet.getId().getParentKey().get());
+  }
+
+  private RevisionApi revisionApi(PatchSet patchSet) throws RestApiException {
+    return changeApi(patchSet).revision(patchSet.getRevision().get());
+  }
+
+  @Override
+  protected void parseCommandLine() throws UnloggedFailure {
+    optionList = new ArrayList<>();
+    customLabels = new HashMap<>();
+
+    ProjectState allProjectsState;
+    try {
+      allProjectsState = projectCache.checkedGet(allProjects);
+    } catch (IOException e) {
+      throw die("missing " + allProjects.get());
+    }
+
+    for (LabelType type : allProjectsState.getLabelTypes().getLabelTypes()) {
+      StringBuilder usage = new StringBuilder("score for ").append(type.getName()).append("\n");
+
+      for (LabelValue v : type.getValues()) {
+        usage.append(v.format()).append("\n");
+      }
+
+      final String name = "--" + type.getName().toLowerCase();
+      optionList.add(new ApproveOption(name, usage.toString(), type));
+    }
+
+    super.parseCommandLine();
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java b/java/com/google/gerrit/sshd/commands/ScpCommand.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
rename to java/com/google/gerrit/sshd/commands/ScpCommand.java
diff --git a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
new file mode 100644
index 0000000..a7cc790
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -0,0 +1,325 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.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;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountSshKey;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.account.AddSshKey;
+import com.google.gerrit.server.restapi.account.CreateEmail;
+import com.google.gerrit.server.restapi.account.DeleteActive;
+import com.google.gerrit.server.restapi.account.DeleteEmail;
+import com.google.gerrit.server.restapi.account.DeleteSshKey;
+import com.google.gerrit.server.restapi.account.GetEmails;
+import com.google.gerrit.server.restapi.account.GetSshKeys;
+import com.google.gerrit.server.restapi.account.PutActive;
+import com.google.gerrit.server.restapi.account.PutHttpPassword;
+import com.google.gerrit.server.restapi.account.PutName;
+import com.google.gerrit.server.restapi.account.PutPreferred;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+/** Set a user's account settings. * */
+@CommandMetaData(name = "set-account", description = "Change an account's settings")
+@RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
+final class SetAccountCommand extends SshCommand {
+
+  @Argument(
+    index = 0,
+    required = true,
+    metaVar = "USER",
+    usage = "full name, email-address, ssh username or account id"
+  )
+  private Account.Id id;
+
+  @Option(name = "--full-name", metaVar = "NAME", usage = "display name of the account")
+  private String fullName;
+
+  @Option(name = "--active", usage = "set account's state to active")
+  private boolean active;
+
+  @Option(name = "--inactive", usage = "set account's state to inactive")
+  private boolean inactive;
+
+  @Option(name = "--add-email", metaVar = "EMAIL", usage = "email addresses to add to the account")
+  private List<String> addEmails = new ArrayList<>();
+
+  @Option(
+    name = "--delete-email",
+    metaVar = "EMAIL",
+    usage = "email addresses to delete from the account"
+  )
+  private List<String> deleteEmails = new ArrayList<>();
+
+  @Option(
+    name = "--preferred-email",
+    metaVar = "EMAIL",
+    usage = "a registered email address from the account"
+  )
+  private String preferredEmail;
+
+  @Option(name = "--add-ssh-key", metaVar = "-|KEY", usage = "public keys to add to the account")
+  private List<String> addSshKeys = new ArrayList<>();
+
+  @Option(
+    name = "--delete-ssh-key",
+    metaVar = "-|KEY",
+    usage = "public keys to delete from the account"
+  )
+  private List<String> deleteSshKeys = new ArrayList<>();
+
+  @Option(
+    name = "--http-password",
+    metaVar = "PASSWORD",
+    usage = "password for HTTP authentication for the account"
+  )
+  private String httpPassword;
+
+  @Option(name = "--clear-http-password", usage = "clear HTTP password for the account")
+  private boolean clearHttpPassword;
+
+  @Inject private IdentifiedUser.GenericFactory genericUserFactory;
+
+  @Inject private CreateEmail.Factory createEmailFactory;
+
+  @Inject private GetEmails getEmails;
+
+  @Inject private DeleteEmail deleteEmail;
+
+  @Inject private PutPreferred putPreferred;
+
+  @Inject private PutName putName;
+
+  @Inject private PutHttpPassword putHttpPassword;
+
+  @Inject private PutActive putActive;
+
+  @Inject private DeleteActive deleteActive;
+
+  @Inject private AddSshKey addSshKey;
+
+  @Inject private GetSshKeys getSshKeys;
+
+  @Inject private DeleteSshKey deleteSshKey;
+
+  private AccountResource rsrc;
+
+  @Override
+  public void run() throws Exception {
+    validate();
+    setAccount();
+  }
+
+  private void validate() throws UnloggedFailure {
+    if (active && inactive) {
+      throw die("--active and --inactive options are mutually exclusive.");
+    }
+    if (clearHttpPassword && !Strings.isNullOrEmpty(httpPassword)) {
+      throw die("--http-password and --clear-http-password options are mutually exclusive.");
+    }
+    if (addSshKeys.contains("-") && deleteSshKeys.contains("-")) {
+      throw die("Only one option may use the stdin");
+    }
+    if (deleteSshKeys.contains("ALL")) {
+      deleteSshKeys = Collections.singletonList("ALL");
+    }
+    if (deleteEmails.contains("ALL")) {
+      deleteEmails = Collections.singletonList("ALL");
+    }
+    if (deleteEmails.contains(preferredEmail)) {
+      throw die(
+          "--preferred-email and --delete-email options are mutually "
+              + "exclusive for the same email address.");
+    }
+  }
+
+  private void setAccount()
+      throws OrmException, IOException, UnloggedFailure, ConfigInvalidException,
+          PermissionBackendException {
+    user = genericUserFactory.create(id);
+    rsrc = new AccountResource(user.asIdentifiedUser());
+    try {
+      for (String email : addEmails) {
+        addEmail(email);
+      }
+
+      for (String email : deleteEmails) {
+        deleteEmail(email);
+      }
+
+      if (preferredEmail != null) {
+        putPreferred(preferredEmail);
+      }
+
+      if (fullName != null) {
+        NameInput in = new NameInput();
+        in.name = fullName;
+        putName.apply(rsrc, in);
+      }
+
+      if (httpPassword != null || clearHttpPassword) {
+        HttpPasswordInput in = new HttpPasswordInput();
+        in.httpPassword = httpPassword;
+        putHttpPassword.apply(rsrc, in);
+      }
+
+      if (active) {
+        putActive.apply(rsrc, null);
+      } else if (inactive) {
+        try {
+          deleteActive.apply(rsrc, null);
+        } catch (ResourceNotFoundException e) {
+          // user is already inactive
+        }
+      }
+
+      addSshKeys = readSshKey(addSshKeys);
+      if (!addSshKeys.isEmpty()) {
+        addSshKeys(addSshKeys);
+      }
+
+      deleteSshKeys = readSshKey(deleteSshKeys);
+      if (!deleteSshKeys.isEmpty()) {
+        deleteSshKeys(deleteSshKeys);
+      }
+    } catch (RestApiException e) {
+      throw die(e.getMessage());
+    }
+  }
+
+  private void addSshKeys(List<String> sshKeys)
+      throws RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    for (String sshKey : sshKeys) {
+      SshKeyInput in = new SshKeyInput();
+      in.raw = RawInputUtil.create(sshKey.getBytes(UTF_8), "plain/text");
+      addSshKey.apply(rsrc, in);
+    }
+  }
+
+  private void deleteSshKeys(List<String> sshKeys)
+      throws RestApiException, OrmException, RepositoryNotFoundException, IOException,
+          ConfigInvalidException, PermissionBackendException {
+    List<SshKeyInfo> infos = getSshKeys.apply(rsrc);
+    if (sshKeys.contains("ALL")) {
+      for (SshKeyInfo i : infos) {
+        deleteSshKey(i);
+      }
+    } else {
+      for (String sshKey : sshKeys) {
+        for (SshKeyInfo i : infos) {
+          if (sshKey.trim().equals(i.sshPublicKey) || sshKey.trim().equals(i.comment)) {
+            deleteSshKey(i);
+          }
+        }
+      }
+    }
+  }
+
+  private void deleteSshKey(SshKeyInfo i)
+      throws AuthException, OrmException, RepositoryNotFoundException, IOException,
+          ConfigInvalidException, PermissionBackendException {
+    AccountSshKey sshKey =
+        new AccountSshKey(new AccountSshKey.Id(user.getAccountId(), i.seq), i.sshPublicKey);
+    deleteSshKey.apply(new AccountResource.SshKey(user.asIdentifiedUser(), sshKey), null);
+  }
+
+  private void addEmail(String email)
+      throws UnloggedFailure, RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    EmailInput in = new EmailInput();
+    in.email = email;
+    in.noConfirmation = true;
+    try {
+      createEmailFactory.create(email).apply(rsrc, in);
+    } catch (EmailException e) {
+      throw die(e.getMessage());
+    }
+  }
+
+  private void deleteEmail(String email)
+      throws RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (email.equals("ALL")) {
+      List<EmailInfo> emails = getEmails.apply(rsrc);
+      for (EmailInfo e : emails) {
+        deleteEmail.apply(new AccountResource.Email(user.asIdentifiedUser(), e.email), new Input());
+      }
+    } else {
+      deleteEmail.apply(new AccountResource.Email(user.asIdentifiedUser(), email), new Input());
+    }
+  }
+
+  private void putPreferred(String email)
+      throws RestApiException, OrmException, IOException, PermissionBackendException,
+          ConfigInvalidException {
+    for (EmailInfo e : getEmails.apply(rsrc)) {
+      if (e.email.equals(email)) {
+        putPreferred.apply(new AccountResource.Email(user.asIdentifiedUser(), email), null);
+        return;
+      }
+    }
+    stderr.println("preferred email not found: " + email);
+  }
+
+  private List<String> readSshKey(List<String> sshKeys)
+      throws UnsupportedEncodingException, IOException {
+    if (!sshKeys.isEmpty()) {
+      int idx = sshKeys.indexOf("-");
+      if (idx >= 0) {
+        StringBuilder sshKey = new StringBuilder();
+        BufferedReader br = new BufferedReader(new InputStreamReader(in, UTF_8));
+        String line;
+        while ((line = br.readLine()) != null) {
+          sshKey.append(line).append("\n");
+        }
+        sshKeys.set(idx, sshKey.toString());
+      }
+    }
+    return sshKeys;
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/SetHeadCommand.java b/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
new file mode 100644
index 0000000..fd7ef75
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.gerrit.extensions.api.projects.HeadInput;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.project.SetHead;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+@CommandMetaData(name = "set-head", description = "Change HEAD reference for a project")
+public class SetHeadCommand extends SshCommand {
+
+  @Argument(index = 0, required = true, metaVar = "NAME", usage = "name of the project")
+  private ProjectState project;
+
+  @Option(name = "--new-head", required = true, metaVar = "REF", usage = "new HEAD reference")
+  private String newHead;
+
+  private final SetHead setHead;
+
+  @Inject
+  SetHeadCommand(SetHead setHead) {
+    this.setHead = setHead;
+  }
+
+  @Override
+  protected void run() throws Exception {
+    HeadInput input = new HeadInput();
+    input.ref = newHead;
+    try {
+      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/SetLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
rename to java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
diff --git a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
new file mode 100644
index 0000000..fdb4b1e9
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
@@ -0,0 +1,170 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.Streams;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.restapi.group.AddMembers;
+import com.google.gerrit.server.restapi.group.AddSubgroups;
+import com.google.gerrit.server.restapi.group.DeleteMembers;
+import com.google.gerrit.server.restapi.group.DeleteSubgroups;
+import com.google.gerrit.server.restapi.group.GroupsCollection;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+@CommandMetaData(
+  name = "set-members",
+  description = "Modify members of specific group or number of groups"
+)
+public class SetMembersCommand extends SshCommand {
+
+  @Option(
+    name = "--add",
+    aliases = {"-a"},
+    metaVar = "USER",
+    usage = "users that should be added as group member"
+  )
+  private List<Account.Id> accountsToAdd = new ArrayList<>();
+
+  @Option(
+    name = "--remove",
+    aliases = {"-r"},
+    metaVar = "USER",
+    usage = "users that should be removed from the group"
+  )
+  private List<Account.Id> accountsToRemove = new ArrayList<>();
+
+  @Option(
+    name = "--include",
+    aliases = {"-i"},
+    metaVar = "GROUP",
+    usage = "group that should be included as group member"
+  )
+  private List<AccountGroup.UUID> groupsToInclude = new ArrayList<>();
+
+  @Option(
+    name = "--exclude",
+    aliases = {"-e"},
+    metaVar = "GROUP",
+    usage = "group that should be excluded from the group"
+  )
+  private List<AccountGroup.UUID> groupsToRemove = new ArrayList<>();
+
+  @Argument(
+    index = 0,
+    required = true,
+    multiValued = true,
+    metaVar = "GROUP",
+    usage = "groups to modify"
+  )
+  private List<AccountGroup.UUID> groups = new ArrayList<>();
+
+  @Inject private AddMembers addMembers;
+
+  @Inject private DeleteMembers deleteMembers;
+
+  @Inject private AddSubgroups addSubgroups;
+
+  @Inject private DeleteSubgroups deleteSubgroups;
+
+  @Inject private GroupsCollection groupsCollection;
+
+  @Inject private GroupCache groupCache;
+
+  @Inject private AccountCache accountCache;
+
+  @Override
+  protected void run() throws UnloggedFailure, Failure, Exception {
+    try {
+      for (AccountGroup.UUID groupUuid : groups) {
+        GroupResource resource =
+            groupsCollection.parse(TopLevelResource.INSTANCE, IdString.fromUrl(groupUuid.get()));
+        if (!accountsToRemove.isEmpty()) {
+          deleteMembers.apply(resource, fromMembers(accountsToRemove));
+          reportMembersAction("removed from", resource, accountsToRemove);
+        }
+        if (!groupsToRemove.isEmpty()) {
+          deleteSubgroups.apply(resource, fromGroups(groupsToRemove));
+          reportGroupsAction("excluded from", resource, groupsToRemove);
+        }
+        if (!accountsToAdd.isEmpty()) {
+          addMembers.apply(resource, fromMembers(accountsToAdd));
+          reportMembersAction("added to", resource, accountsToAdd);
+        }
+        if (!groupsToInclude.isEmpty()) {
+          addSubgroups.apply(resource, fromGroups(groupsToInclude));
+          reportGroupsAction("included to", resource, groupsToInclude);
+        }
+      }
+    } catch (RestApiException e) {
+      throw die(e.getMessage());
+    }
+  }
+
+  private void reportMembersAction(
+      String action, GroupResource group, List<Account.Id> accountIdList)
+      throws UnsupportedEncodingException, IOException {
+    String names =
+        accountIdList
+            .stream()
+            .map(
+                accountId ->
+                    MoreObjects.firstNonNull(
+                        accountCache.get(accountId).getAccount().getPreferredEmail(), "n/a"))
+            .collect(joining(", "));
+    out.write(
+        String.format("Members %s group %s: %s\n", action, group.getName(), names).getBytes(ENC));
+  }
+
+  private void reportGroupsAction(
+      String action, GroupResource group, List<AccountGroup.UUID> groupUuidList)
+      throws UnsupportedEncodingException, IOException {
+    String names =
+        groupUuidList
+            .stream()
+            .map(uuid -> groupCache.get(uuid).map(InternalGroup::getName))
+            .flatMap(Streams::stream)
+            .collect(joining(", "));
+    out.write(
+        String.format("Groups %s group %s: %s\n", action, group.getName(), names).getBytes(ENC));
+  }
+
+  private AddSubgroups.Input fromGroups(List<AccountGroup.UUID> accounts) {
+    return AddSubgroups.Input.fromGroups(accounts.stream().map(Object::toString).collect(toList()));
+  }
+
+  private AddMembers.Input fromMembers(List<Account.Id> accounts) {
+    return AddMembers.Input.fromMembers(accounts.stream().map(Object::toString).collect(toList()));
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
new file mode 100644
index 0000000..a5759f0
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
@@ -0,0 +1,167 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.restapi.RestApiException;
+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.restapi.project.PutConfig;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+@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 ProjectState projectState;
+
+  @Option(
+    name = "--description",
+    aliases = {"-d"},
+    metaVar = "DESCRIPTION",
+    usage = "description of project"
+  )
+  private String projectDescription;
+
+  @Option(
+    name = "--submit-type",
+    aliases = {"-t"},
+    usage = "project submit type\n(default: MERGE_IF_NECESSARY)"
+  )
+  private SubmitType submitType;
+
+  @Option(name = "--contributor-agreements", usage = "if contributor agreement is required")
+  private InheritableBoolean contributorAgreements;
+
+  @Option(name = "--signed-off-by", usage = "if signed-off-by is required")
+  private InheritableBoolean signedOffBy;
+
+  @Option(name = "--content-merge", usage = "allow automatic conflict resolving within files")
+  private InheritableBoolean contentMerge;
+
+  @Option(name = "--change-id", usage = "if change-id is required")
+  private InheritableBoolean requireChangeID;
+
+  @Option(
+    name = "--use-contributor-agreements",
+    aliases = {"--ca"},
+    usage = "if contributor agreement is required"
+  )
+  void setUseContributorArgreements(@SuppressWarnings("unused") boolean on) {
+    contributorAgreements = InheritableBoolean.TRUE;
+  }
+
+  @Option(
+    name = "--no-contributor-agreements",
+    aliases = {"--nca"},
+    usage = "if contributor agreement is not required"
+  )
+  void setNoContributorArgreements(@SuppressWarnings("unused") boolean on) {
+    contributorAgreements = InheritableBoolean.FALSE;
+  }
+
+  @Option(
+    name = "--use-signed-off-by",
+    aliases = {"--so"},
+    usage = "if signed-off-by is required"
+  )
+  void setUseSignedOffBy(@SuppressWarnings("unused") boolean on) {
+    signedOffBy = InheritableBoolean.TRUE;
+  }
+
+  @Option(
+    name = "--no-signed-off-by",
+    aliases = {"--nso"},
+    usage = "if signed-off-by is not required"
+  )
+  void setNoSignedOffBy(@SuppressWarnings("unused") boolean on) {
+    signedOffBy = InheritableBoolean.FALSE;
+  }
+
+  @Option(name = "--use-content-merge", usage = "allow automatic conflict resolving within files")
+  void setUseContentMerge(@SuppressWarnings("unused") boolean on) {
+    contentMerge = InheritableBoolean.TRUE;
+  }
+
+  @Option(
+    name = "--no-content-merge",
+    usage = "don't allow automatic conflict resolving within files"
+  )
+  void setNoContentMerge(@SuppressWarnings("unused") boolean on) {
+    contentMerge = InheritableBoolean.FALSE;
+  }
+
+  @Option(
+    name = "--require-change-id",
+    aliases = {"--id"},
+    usage = "if change-id is required"
+  )
+  void setRequireChangeId(@SuppressWarnings("unused") boolean on) {
+    requireChangeID = InheritableBoolean.TRUE;
+  }
+
+  @Option(
+    name = "--no-change-id",
+    aliases = {"--nid"},
+    usage = "if change-id is not required"
+  )
+  void setNoChangeId(@SuppressWarnings("unused") boolean on) {
+    requireChangeID = InheritableBoolean.FALSE;
+  }
+
+  @Option(
+    name = "--project-state",
+    aliases = {"--ps"},
+    usage = "project's visibility state"
+  )
+  private ProjectState state;
+
+  @Option(name = "--max-object-size-limit", usage = "max Git object size for this project")
+  private String maxObjectSizeLimit;
+
+  @Inject private PutConfig putConfig;
+
+  @Override
+  protected void run() throws Failure {
+    ConfigInput configInput = new ConfigInput();
+    configInput.requireChangeId = requireChangeID;
+    configInput.submitType = submitType;
+    configInput.useContentMerge = contentMerge;
+    configInput.useContributorAgreements = contributorAgreements;
+    configInput.useSignedOffBy = signedOffBy;
+    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 = projectState.getProject().getDescription();
+    }
+
+    try {
+      putConfig.apply(new ProjectResource(projectState, user), configInput);
+    } catch (RestApiException | PermissionBackendException e) {
+      throw die(e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
new file mode 100644
index 0000000..4c7d59d
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -0,0 +1,159 @@
+// 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.sshd.commands;
+
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.change.DeleteReviewer;
+import com.google.gerrit.server.restapi.change.PostReviewers;
+import com.google.gerrit.sshd.ChangeArgumentParser;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@CommandMetaData(name = "set-reviewers", description = "Add or remove reviewers on a change")
+public class SetReviewersCommand extends SshCommand {
+  private static final Logger log = LoggerFactory.getLogger(SetReviewersCommand.class);
+
+  @Option(name = "--project", aliases = "-p", usage = "project containing the change")
+  private ProjectState projectState;
+
+  @Option(
+    name = "--add",
+    aliases = {"-a"},
+    metaVar = "REVIEWER",
+    usage = "user or group that should be added as reviewer"
+  )
+  private List<String> toAdd = new ArrayList<>();
+
+  @Option(
+    name = "--remove",
+    aliases = {"-r"},
+    metaVar = "REVIEWER",
+    usage = "user that should be removed from the reviewer list"
+  )
+  void optionRemove(Account.Id who) {
+    toRemove.add(who);
+  }
+
+  @Argument(
+    index = 0,
+    required = true,
+    multiValued = true,
+    metaVar = "CHANGE",
+    usage = "changes to modify"
+  )
+  void addChange(String token) {
+    try {
+      changeArgumentParser.addChange(token, changes, projectState);
+    } catch (UnloggedFailure e) {
+      throw new IllegalArgumentException(e.getMessage(), e);
+    } catch (OrmException e) {
+      throw new IllegalArgumentException("database is down", e);
+    } catch (PermissionBackendException e) {
+      throw new IllegalArgumentException("can't check permissions", e);
+    }
+  }
+
+  @Inject private ReviewerResource.Factory reviewerFactory;
+
+  @Inject private PostReviewers postReviewers;
+
+  @Inject private DeleteReviewer deleteReviewer;
+
+  @Inject private ChangeArgumentParser changeArgumentParser;
+
+  private Set<Account.Id> toRemove = new HashSet<>();
+
+  private Map<Change.Id, ChangeResource> changes = new LinkedHashMap<>();
+
+  @Override
+  protected void run() throws UnloggedFailure {
+    boolean ok = true;
+    for (ChangeResource rsrc : changes.values()) {
+      try {
+        ok &= modifyOne(rsrc);
+      } catch (Exception err) {
+        ok = false;
+        log.error("Error updating reviewers on change " + rsrc.getId(), err);
+        writeError("fatal", "internal error while updating " + rsrc.getId());
+      }
+    }
+
+    if (!ok) {
+      throw die("one or more updates failed; review output above");
+    }
+  }
+
+  private boolean modifyOne(ChangeResource changeRsrc) throws Exception {
+    boolean ok = true;
+
+    // Remove reviewers
+    //
+    for (Account.Id reviewer : toRemove) {
+      ReviewerResource rsrc = reviewerFactory.create(changeRsrc, reviewer);
+      String error = null;
+      try {
+        deleteReviewer.apply(rsrc, new DeleteReviewerInput());
+      } catch (ResourceNotFoundException e) {
+        error = String.format("could not remove %s: not found", reviewer);
+      } catch (Exception e) {
+        error = String.format("could not remove %s: %s", reviewer, e.getMessage());
+      }
+      if (error != null) {
+        ok = false;
+        writeError("error", error);
+      }
+    }
+
+    // Add reviewers
+    //
+    for (String reviewer : toAdd) {
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = reviewer;
+      input.confirmed = true;
+      String error;
+      try {
+        error = postReviewers.apply(changeRsrc, input).error;
+      } catch (Exception e) {
+        error = String.format("could not add %s: %s", reviewer, e.getMessage());
+      }
+      if (error != null) {
+        ok = false;
+        writeError("error", error);
+      }
+    }
+
+    return ok;
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
new file mode 100644
index 0000000..a356f7f
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -0,0 +1,347 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+import static com.google.gerrit.common.data.GlobalCapability.VIEW_CACHES;
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.config.GetSummary;
+import com.google.gerrit.server.restapi.config.GetSummary.JvmSummaryInfo;
+import com.google.gerrit.server.restapi.config.GetSummary.MemSummaryInfo;
+import com.google.gerrit.server.restapi.config.GetSummary.SummaryInfo;
+import com.google.gerrit.server.restapi.config.GetSummary.TaskSummaryInfo;
+import com.google.gerrit.server.restapi.config.GetSummary.ThreadSummaryInfo;
+import com.google.gerrit.server.restapi.config.ListCaches;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheType;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gerrit.sshd.SshDaemon;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Collection;
+import java.util.Date;
+import java.util.Map;
+import java.util.Map.Entry;
+import org.apache.sshd.common.io.IoAcceptor;
+import org.apache.sshd.common.io.IoSession;
+import org.apache.sshd.common.io.mina.MinaSession;
+import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Option;
+
+/** Show the current cache states. */
+@RequiresAnyCapability({VIEW_CACHES, MAINTAIN_SERVER})
+@CommandMetaData(
+  name = "show-caches",
+  description = "Display current cache statistics",
+  runsAt = MASTER_OR_SLAVE
+)
+final class ShowCaches extends SshCommand {
+  private static volatile long serverStarted;
+
+  static class StartupListener implements LifecycleListener {
+    @Override
+    public void start() {
+      serverStarted = TimeUtil.nowMs();
+    }
+
+    @Override
+    public void stop() {}
+  }
+
+  @Option(name = "--gc", usage = "perform Java GC before printing memory stats")
+  private boolean gc;
+
+  @Option(name = "--show-jvm", usage = "show details about the JVM")
+  private boolean showJVM;
+
+  @Option(name = "--show-threads", usage = "show detailed thread counts")
+  private boolean showThreads;
+
+  @Inject private SshDaemon daemon;
+  @Inject private ListCaches listCaches;
+  @Inject private GetSummary getSummary;
+  @Inject private CurrentUser self;
+  @Inject private PermissionBackend permissionBackend;
+
+  @Option(
+    name = "--width",
+    aliases = {"-w"},
+    metaVar = "COLS",
+    usage = "width of output table"
+  )
+  private int columns = 80;
+
+  private int nw;
+
+  @Override
+  public void start(Environment env) throws IOException {
+    String s = env.getEnv().get(Environment.ENV_COLUMNS);
+    if (s != null && !s.isEmpty()) {
+      try {
+        columns = Integer.parseInt(s);
+      } catch (NumberFormatException err) {
+        columns = 80;
+      }
+    }
+    super.start(env);
+  }
+
+  @Override
+  protected void run() throws UnloggedFailure {
+    nw = columns - 50;
+    Date now = new Date();
+    stdout.format(
+        "%-25s %-20s      now  %16s\n",
+        "Gerrit Code Review",
+        Version.getVersion() != null ? Version.getVersion() : "",
+        new SimpleDateFormat("HH:mm:ss   zzz").format(now));
+    stdout.format("%-25s %-20s   uptime %16s\n", "", "", uptime(now.getTime() - serverStarted));
+    stdout.print('\n');
+
+    stdout.print(
+        String.format( //
+            "%1s %-" + nw + "s|%-21s|  %-5s |%-9s|\n" //
+            ,
+            "" //
+            ,
+            "Name" //
+            ,
+            "Entries" //
+            ,
+            "AvgGet" //
+            ,
+            "Hit Ratio" //
+            ));
+    stdout.print(
+        String.format( //
+            "%1s %-" + nw + "s|%6s %6s %7s|  %-5s  |%-4s %-4s|\n" //
+            ,
+            "" //
+            ,
+            "" //
+            ,
+            "Mem" //
+            ,
+            "Disk" //
+            ,
+            "Space" //
+            ,
+            "" //
+            ,
+            "Mem" //
+            ,
+            "Disk" //
+            ));
+    stdout.print("--");
+    for (int i = 0; i < nw; i++) {
+      stdout.print('-');
+    }
+    stdout.print("+---------------------+---------+---------+\n");
+
+    Collection<CacheInfo> caches = getCaches();
+    printMemoryCoreCaches(caches);
+    printMemoryPluginCaches(caches);
+    printDiskCaches(caches);
+    stdout.print('\n');
+
+    boolean showJvm;
+    try {
+      permissionBackend.user(self).check(GlobalPermission.MAINTAIN_SERVER);
+      showJvm = true;
+    } catch (AuthException | PermissionBackendException e) {
+      // Silently ignore and do not display detailed JVM information.
+      showJvm = false;
+    }
+    if (showJvm) {
+      sshSummary();
+
+      SummaryInfo summary = getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource());
+      taskSummary(summary.taskSummary);
+      memSummary(summary.memSummary);
+      threadSummary(summary.threadSummary);
+
+      if (showJVM && summary.jvmSummary != null) {
+        jvmSummary(summary.jvmSummary);
+      }
+    }
+
+    stdout.flush();
+  }
+
+  private Collection<CacheInfo> getCaches() {
+    @SuppressWarnings("unchecked")
+    Map<String, CacheInfo> caches = (Map<String, CacheInfo>) listCaches.apply(new ConfigResource());
+    for (Map.Entry<String, CacheInfo> entry : caches.entrySet()) {
+      CacheInfo cache = entry.getValue();
+      cache.name = entry.getKey();
+    }
+    return caches.values();
+  }
+
+  private void printMemoryCoreCaches(Collection<CacheInfo> caches) {
+    for (CacheInfo cache : caches) {
+      if (!cache.name.contains("-") && CacheType.MEM.equals(cache.type)) {
+        printCache(cache);
+      }
+    }
+  }
+
+  private void printMemoryPluginCaches(Collection<CacheInfo> caches) {
+    for (CacheInfo cache : caches) {
+      if (cache.name.contains("-") && CacheType.MEM.equals(cache.type)) {
+        printCache(cache);
+      }
+    }
+  }
+
+  private void printDiskCaches(Collection<CacheInfo> caches) {
+    for (CacheInfo cache : caches) {
+      if (CacheType.DISK.equals(cache.type)) {
+        printCache(cache);
+      }
+    }
+  }
+
+  private void printCache(CacheInfo cache) {
+    stdout.print(
+        String.format(
+            "%1s %-" + nw + "s|%6s %6s %7s| %7s |%4s %4s|\n",
+            CacheType.DISK.equals(cache.type) ? "D" : "",
+            cache.name,
+            nullToEmpty(cache.entries.mem),
+            nullToEmpty(cache.entries.disk),
+            Strings.nullToEmpty(cache.entries.space),
+            Strings.nullToEmpty(cache.averageGet),
+            formatAsPercent(cache.hitRatio.mem),
+            formatAsPercent(cache.hitRatio.disk)));
+  }
+
+  private static String nullToEmpty(Long l) {
+    return l != null ? String.valueOf(l) : "";
+  }
+
+  private static String formatAsPercent(Integer i) {
+    return i != null ? String.valueOf(i) + "%" : "";
+  }
+
+  private void memSummary(MemSummaryInfo memSummary) {
+    stdout.format(
+        "Mem: %s total = %s used + %s free + %s buffers\n",
+        memSummary.total, memSummary.used, memSummary.free, memSummary.buffers);
+    stdout.format("     %s max\n", memSummary.max);
+    stdout.format("    %8d open files\n", nullToZero(memSummary.openFiles));
+    stdout.print('\n');
+  }
+
+  private void threadSummary(ThreadSummaryInfo threadSummary) {
+    stdout.format(
+        "Threads: %d CPUs available, %d threads\n", threadSummary.cpus, threadSummary.threads);
+
+    if (showThreads) {
+      stdout.print(String.format("  %22s", ""));
+      for (Thread.State s : Thread.State.values()) {
+        stdout.print(String.format(" %14s", s.name()));
+      }
+      stdout.print('\n');
+      for (Entry<String, Map<Thread.State, Integer>> e : threadSummary.counts.entrySet()) {
+        stdout.print(String.format("  %-22s", e.getKey()));
+        for (Thread.State s : Thread.State.values()) {
+          stdout.print(String.format(" %14d", nullToZero(e.getValue().get(s))));
+        }
+        stdout.print('\n');
+      }
+    }
+    stdout.print('\n');
+  }
+
+  private void taskSummary(TaskSummaryInfo taskSummary) {
+    stdout.format(
+        "Tasks: %4d  total = %4d running +   %4d ready + %4d sleeping\n",
+        nullToZero(taskSummary.total),
+        nullToZero(taskSummary.running),
+        nullToZero(taskSummary.ready),
+        nullToZero(taskSummary.sleeping));
+  }
+
+  private static int nullToZero(Integer i) {
+    return i != null ? i : 0;
+  }
+
+  private void sshSummary() {
+    IoAcceptor acceptor = daemon.getIoAcceptor();
+    if (acceptor == null) {
+      return;
+    }
+
+    long now = TimeUtil.nowMs();
+    Collection<IoSession> list = acceptor.getManagedSessions().values();
+    long oldest = now;
+
+    for (IoSession s : list) {
+      if (s instanceof MinaSession) {
+        MinaSession minaSession = (MinaSession) s;
+        oldest = Math.min(oldest, minaSession.getSession().getCreationTime());
+      }
+    }
+
+    stdout.format(
+        "SSH:   %4d  users, oldest session started %s ago\n", list.size(), uptime(now - oldest));
+  }
+
+  private void jvmSummary(JvmSummaryInfo jvmSummary) {
+    stdout.format("JVM: %s %s %s\n", jvmSummary.vmVendor, jvmSummary.vmName, jvmSummary.vmVersion);
+    stdout.format("  on %s %s %s\n", jvmSummary.osName, jvmSummary.osVersion, jvmSummary.osArch);
+    stdout.format("  running as %s on %s\n", jvmSummary.user, Strings.nullToEmpty(jvmSummary.host));
+    stdout.format("  cwd  %s\n", jvmSummary.currentWorkingDirectory);
+    stdout.format("  site %s\n", jvmSummary.site);
+  }
+
+  private String uptime(long uptimeMillis) {
+    if (uptimeMillis < 1000) {
+      return String.format("%3d ms", uptimeMillis);
+    }
+
+    long uptime = uptimeMillis / 1000L;
+
+    long min = uptime / 60;
+    if (min < 60) {
+      return String.format("%2d min %2d sec", min, uptime - min * 60);
+    }
+
+    long hr = uptime / 3600;
+    if (hr < 24) {
+      min = (uptime - hr * 3600) / 60;
+      return String.format("%2d hrs %2d min", hr, min);
+    }
+
+    long days = uptime / (24 * 3600);
+    hr = (uptime - (days * 24 * 3600)) / 3600;
+    return String.format("%4d days %2d hrs", days, hr);
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowConnections.java
rename to java/com/google/gerrit/sshd/commands/ShowConnections.java
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
new file mode 100644
index 0000000..6d2fbb4
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -0,0 +1,212 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.config.ListTasks;
+import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
+import com.google.gerrit.sshd.AdminHighPriorityCommand;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Option;
+
+/** Display the current work queue. */
+@AdminHighPriorityCommand
+@CommandMetaData(
+  name = "show-queue",
+  description = "Display the background work queues",
+  runsAt = MASTER_OR_SLAVE
+)
+final class ShowQueue extends SshCommand {
+  @Option(
+    name = "--wide",
+    aliases = {"-w"},
+    usage = "display without line width truncation"
+  )
+  private boolean wide;
+
+  @Option(
+    name = "--by-queue",
+    aliases = {"-q"},
+    usage = "group tasks by queue and print queue info"
+  )
+  private boolean groupByQueue;
+
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private ListTasks listTasks;
+  @Inject private IdentifiedUser currentUser;
+  @Inject private WorkQueue workQueue;
+
+  private int columns = 80;
+  private int maxCommandWidth;
+
+  @Override
+  public void start(Environment env) throws IOException {
+    String s = env.getEnv().get(Environment.ENV_COLUMNS);
+    if (s != null && !s.isEmpty()) {
+      try {
+        columns = Integer.parseInt(s);
+      } catch (NumberFormatException err) {
+        columns = 80;
+      }
+    }
+    super.start(env);
+  }
+
+  @Override
+  protected void run() throws Failure {
+    maxCommandWidth = wide ? Integer.MAX_VALUE : columns - 8 - 12 - 12 - 4 - 4;
+    stdout.print(
+        String.format(
+            "%-8s %-12s %-12s %-4s %s\n", //
+            "Task", "State", "StartTime", "", "Command"));
+    stdout.print(
+        "------------------------------------------------------------------------------\n");
+
+    List<TaskInfo> tasks;
+    try {
+      tasks = listTasks.apply(new ConfigResource());
+    } catch (AuthException e) {
+      throw die(e);
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "permission backend unavailable", e);
+    }
+
+    boolean viewAll = permissionBackend.user(currentUser).testOrFalse(GlobalPermission.VIEW_QUEUE);
+    long now = TimeUtil.nowMs();
+    if (groupByQueue) {
+      ListMultimap<String, TaskInfo> byQueue = byQueue(tasks);
+      for (String queueName : byQueue.keySet()) {
+        ScheduledThreadPoolExecutor e = workQueue.getExecutor(queueName);
+        stdout.print(String.format("Queue: %s\n", queueName));
+        print(byQueue.get(queueName), now, viewAll, e.getCorePoolSize());
+      }
+    } else {
+      print(tasks, now, viewAll, 0);
+    }
+  }
+
+  private ListMultimap<String, TaskInfo> byQueue(List<TaskInfo> tasks) {
+    ListMultimap<String, TaskInfo> byQueue = LinkedListMultimap.create();
+    for (TaskInfo task : tasks) {
+      byQueue.put(task.queueName, task);
+    }
+    return byQueue;
+  }
+
+  private void print(List<TaskInfo> tasks, long now, boolean viewAll, int threadPoolSize) {
+    for (TaskInfo task : tasks) {
+      String start;
+      switch (task.state) {
+        case DONE:
+        case CANCELLED:
+        case RUNNING:
+        case READY:
+          start = format(task.state);
+          break;
+        case OTHER:
+        case SLEEPING:
+        default:
+          start = time(now, task.delay);
+          break;
+      }
+
+      // Shows information about tasks depending on the user rights
+      if (viewAll || task.projectName == null) {
+        String command =
+            task.command.length() < maxCommandWidth
+                ? task.command
+                : task.command.substring(0, maxCommandWidth);
+
+        stdout.print(
+            String.format(
+                "%8s %-12s %-12s %-4s %s\n",
+                task.id, start, startTime(task.startTime), "", command));
+      } else {
+        String remoteName =
+            task.remoteName != null ? task.remoteName + "/" + task.projectName : task.projectName;
+
+        stdout.print(
+            String.format(
+                "%8s %-12s %-4s %s\n",
+                task.id,
+                start,
+                startTime(task.startTime),
+                MoreObjects.firstNonNull(remoteName, "n/a")));
+      }
+    }
+    stdout.print(
+        "------------------------------------------------------------------------------\n");
+    stdout.print("  " + tasks.size() + " tasks");
+    if (threadPoolSize > 0) {
+      stdout.print(", " + threadPoolSize + " worker threads");
+    }
+    stdout.print("\n\n");
+  }
+
+  private static String time(long now, long delay) {
+    Date when = new Date(now + delay);
+    return format(when, delay);
+  }
+
+  private static String startTime(Date when) {
+    return format(when, TimeUtil.nowMs() - when.getTime());
+  }
+
+  private static String format(Date when, long timeFromNow) {
+    if (timeFromNow < 24 * 60 * 60 * 1000L) {
+      return new SimpleDateFormat("HH:mm:ss.SSS").format(when);
+    }
+    return new SimpleDateFormat("MMM-dd HH:mm").format(when);
+  }
+
+  private static String format(Task.State state) {
+    switch (state) {
+      case DONE:
+        return "....... done";
+      case CANCELLED:
+        return "..... killed";
+      case RUNNING:
+        return "";
+      case READY:
+        return "waiting ....";
+      case SLEEPING:
+        return "sleeping";
+      case OTHER:
+      default:
+        return state.toString();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/StreamEvents.java b/java/com/google/gerrit/sshd/commands/StreamEvents.java
new file mode 100644
index 0000000..0e634b4
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -0,0 +1,291 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventTypes;
+import com.google.gerrit.server.events.ProjectNameKeySerializer;
+import com.google.gerrit.server.events.SupplierSerializer;
+import com.google.gerrit.server.events.UserScopedEventListener;
+import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
+import com.google.gerrit.sshd.BaseCommand;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.StreamCommandExecutor;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@RequiresCapability(GlobalCapability.STREAM_EVENTS)
+@CommandMetaData(name = "stream-events", description = "Monitor events occurring in real time")
+final class StreamEvents extends BaseCommand {
+  private static final Logger log = LoggerFactory.getLogger(StreamEvents.class);
+
+  /** Maximum number of events that may be queued up for each connection. */
+  private static final int MAX_EVENTS = 128;
+
+  /** Number of events to write before yielding off the thread. */
+  private static final int BATCH_SIZE = 32;
+
+  @Option(
+    name = "--subscribe",
+    aliases = {"-s"},
+    metaVar = "SUBSCRIBE",
+    usage = "subscribe to specific stream-events"
+  )
+  private List<String> subscribedToEvents = new ArrayList<>();
+
+  @Inject private IdentifiedUser currentUser;
+
+  @Inject private DynamicSet<UserScopedEventListener> eventListeners;
+
+  @Inject @StreamCommandExecutor private ScheduledThreadPoolExecutor pool;
+
+  /** Queue of events to stream to the connected user. */
+  private final LinkedBlockingQueue<Event> queue = new LinkedBlockingQueue<>(MAX_EVENTS);
+
+  private Gson gson;
+
+  private RegistrationHandle eventListenerRegistration;
+
+  /** Special event to notify clients they missed other events. */
+  private static final class DroppedOutputEvent extends Event {
+    private static final String TYPE = "dropped-output";
+
+    DroppedOutputEvent() {
+      super(TYPE);
+    }
+  }
+
+  static {
+    EventTypes.register(DroppedOutputEvent.TYPE, DroppedOutputEvent.class);
+  }
+
+  private final CancelableRunnable writer =
+      new CancelableRunnable() {
+        @Override
+        public void run() {
+          writeEvents();
+        }
+
+        @Override
+        public void cancel() {
+          onExit(0);
+        }
+
+        @Override
+        public String toString() {
+          return "Stream Events (" + currentUser.getAccount().getUserName() + ")";
+        }
+      };
+
+  /** True if {@link DroppedOutputEvent} needs to be sent. */
+  private volatile boolean dropped;
+
+  /** Lock to protect {@link #queue}, {@link #task}, {@link #done}. */
+  private final Object taskLock = new Object();
+
+  /** True if no more messages should be sent to the output. */
+  private boolean done;
+
+  /**
+   * Currently scheduled task to spin out {@link #queue}.
+   *
+   * <p>This field is usually {@code null}, unless there is at least one object present inside of
+   * {@link #queue} ready for delivery. Tasks are only started when there are events to be sent.
+   */
+  private Future<?> task;
+
+  private PrintWriter stdout;
+
+  @Override
+  public void start(Environment env) throws IOException {
+    try {
+      parseCommandLine();
+    } catch (UnloggedFailure e) {
+      String msg = e.getMessage();
+      if (!msg.endsWith("\n")) {
+        msg += "\n";
+      }
+      err.write(msg.getBytes(UTF_8));
+      err.flush();
+      onExit(1);
+      return;
+    }
+
+    stdout = toPrintWriter(out);
+    eventListenerRegistration =
+        eventListeners.add(
+            new UserScopedEventListener() {
+              @Override
+              public void onEvent(Event event) {
+                if (subscribedToEvents.isEmpty() || subscribedToEvents.contains(event.getType())) {
+                  offer(event);
+                }
+              }
+
+              @Override
+              public CurrentUser getUser() {
+                return currentUser;
+              }
+            });
+
+    gson =
+        new GsonBuilder()
+            .registerTypeAdapter(Supplier.class, new SupplierSerializer())
+            .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeySerializer())
+            .create();
+  }
+
+  private void removeEventListenerRegistration() {
+    if (eventListenerRegistration != null) {
+      eventListenerRegistration.remove();
+    }
+  }
+
+  @Override
+  protected void onExit(int rc) {
+    removeEventListenerRegistration();
+
+    synchronized (taskLock) {
+      done = true;
+    }
+
+    super.onExit(rc);
+  }
+
+  @Override
+  public void destroy() {
+    removeEventListenerRegistration();
+
+    final boolean exit;
+    synchronized (taskLock) {
+      if (task != null) {
+        task.cancel(true);
+        exit = false; // onExit will be invoked by the task cancellation.
+      } else {
+        exit = !done;
+      }
+      done = true;
+    }
+    if (exit) {
+      onExit(0);
+    }
+  }
+
+  private void offer(Event event) {
+    synchronized (taskLock) {
+      if (!queue.offer(event)) {
+        dropped = true;
+      }
+
+      if (task == null && !done) {
+        task = pool.submit(writer);
+      }
+    }
+  }
+
+  private Event poll() {
+    synchronized (taskLock) {
+      Event event = queue.poll();
+      if (event == null) {
+        task = null;
+      }
+      return event;
+    }
+  }
+
+  private void writeEvents() {
+    int processed = 0;
+
+    while (processed < BATCH_SIZE) {
+      if (Thread.interrupted() || stdout.checkError()) {
+        // The other side either requested a shutdown by calling our
+        // destroy() above, or it closed the stream and is no longer
+        // accepting output. Either way terminate this instance.
+        //
+        removeEventListenerRegistration();
+        flush();
+        onExit(0);
+        return;
+      }
+
+      if (dropped) {
+        write(new DroppedOutputEvent());
+        dropped = false;
+      }
+
+      final Event event = poll();
+      if (event == null) {
+        break;
+      }
+
+      write(event);
+      processed++;
+    }
+
+    flush();
+
+    if (BATCH_SIZE <= processed) {
+      // We processed the limit, but more might remain in the queue.
+      // Schedule the write task again so we will come back here and
+      // can process more events.
+      //
+      synchronized (taskLock) {
+        task = pool.submit(writer);
+      }
+    }
+  }
+
+  private void write(Object message) {
+    String msg = null;
+    try {
+      msg = gson.toJson(message) + "\n";
+    } catch (Exception e) {
+      log.warn("Could not deserialize the msg: ", e);
+    }
+    if (msg != null) {
+      synchronized (stdout) {
+        stdout.print(msg);
+      }
+    }
+  }
+
+  private void flush() {
+    synchronized (stdout) {
+      stdout.flush();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/TestSubmitRuleCommand.java b/java/com/google/gerrit/sshd/commands/TestSubmitRuleCommand.java
new file mode 100644
index 0000000..ce43c3d
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/TestSubmitRuleCommand.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.gerrit.extensions.common.TestSubmitRuleInput;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.restapi.change.TestSubmitRule;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.inject.Inject;
+
+/** Command that allows testing of prolog submit-rules in a live instance. */
+@CommandMetaData(name = "rule", description = "Test prolog submit rules")
+final class TestSubmitRuleCommand extends BaseTestPrologCommand {
+  @Inject private TestSubmitRule view;
+
+  @Override
+  protected RestModifyView<RevisionResource, TestSubmitRuleInput> createView() {
+    return view;
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/TestSubmitTypeCommand.java b/java/com/google/gerrit/sshd/commands/TestSubmitTypeCommand.java
new file mode 100644
index 0000000..90d54d5
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/TestSubmitTypeCommand.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.gerrit.extensions.common.TestSubmitRuleInput;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.restapi.change.TestSubmitType;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.inject.Inject;
+
+@CommandMetaData(name = "type", description = "Test prolog submit type")
+final class TestSubmitTypeCommand extends BaseTestPrologCommand {
+  @Inject private TestSubmitType view;
+
+  @Override
+  protected RestModifyView<RevisionResource, TestSubmitRuleInput> createView() {
+    return view;
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/Upload.java b/java/com/google/gerrit/sshd/commands/Upload.java
new file mode 100644
index 0000000..0d78279
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/Upload.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.UploadPackInitializer;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.git.validators.UploadValidationException;
+import com.google.gerrit.server.git.validators.UploadValidators;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.sshd.AbstractGitCommand;
+import com.google.gerrit.sshd.SshSession;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.transport.PostUploadHook;
+import org.eclipse.jgit.transport.PostUploadHookChain;
+import org.eclipse.jgit.transport.PreUploadHook;
+import org.eclipse.jgit.transport.PreUploadHookChain;
+import org.eclipse.jgit.transport.UploadPack;
+
+/** Publishes Git repositories over SSH using the Git upload-pack protocol. */
+final class Upload extends AbstractGitCommand {
+  @Inject private TransferConfig config;
+  @Inject private VisibleRefFilter.Factory refFilterFactory;
+  @Inject private DynamicSet<PreUploadHook> preUploadHooks;
+  @Inject private DynamicSet<PostUploadHook> postUploadHooks;
+  @Inject private DynamicSet<UploadPackInitializer> uploadPackInitializers;
+  @Inject private UploadValidators.Factory uploadValidatorsFactory;
+  @Inject private SshSession session;
+  @Inject private PermissionBackend permissionBackend;
+
+  @Override
+  protected void runImpl() throws IOException, Failure {
+    try {
+      permissionBackend
+          .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");
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "fatal: unable to check permissions " + e);
+    }
+
+    final UploadPack up = new UploadPack(repo);
+    up.setAdvertiseRefsHook(refFilterFactory.create(projectState, repo));
+    up.setPackConfig(config.getPackConfig());
+    up.setTimeout(config.getTimeout());
+    up.setPostUploadHook(PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
+
+    List<PreUploadHook> allPreUploadHooks = Lists.newArrayList(preUploadHooks);
+    allPreUploadHooks.add(
+        uploadValidatorsFactory.create(project, repo, session.getRemoteAddressAsString()));
+    up.setPreUploadHook(PreUploadHookChain.newChain(allPreUploadHooks));
+    for (UploadPackInitializer initializer : uploadPackInitializers) {
+      initializer.init(projectState.getNameKey(), up);
+    }
+    try {
+      up.upload(in, out, err);
+      session.setPeerAgent(up.getPeerUserAgent());
+    } catch (UploadValidationException e) {
+      // UploadValidationException is used by the UploadValidators to
+      // stop the uploadPack. We do not want this exception to go beyond this
+      // point otherwise it would print a stacktrace in the logs and return an
+      // internal server error to the client.
+      if (!e.isOutput()) {
+        up.sendMessage(e.getMessage());
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/UploadArchive.java b/java/com/google/gerrit/sshd/commands/UploadArchive.java
new file mode 100644
index 0000000..7518cbb
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -0,0 +1,255 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.change.ArchiveFormat;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.restapi.change.AllowedFormats;
+import com.google.gerrit.server.restapi.project.CommitsCollection;
+import com.google.gerrit.sshd.AbstractGitCommand;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.api.ArchiveCommand;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PacketLineIn;
+import org.eclipse.jgit.transport.PacketLineOut;
+import org.eclipse.jgit.transport.SideBandOutputStream;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.Option;
+
+/** Allows getting archives for Git repositories over SSH using the Git upload-archive protocol. */
+public class UploadArchive extends AbstractGitCommand {
+  /**
+   * Options for parsing Git commands.
+   *
+   * <p>These options are not passed on command line, but received through input stream in pkt-line
+   * format.
+   */
+  static class Options {
+    @Option(
+      name = "-f",
+      aliases = {"--format"},
+      usage =
+          "Format of the"
+              + " resulting archive: tar or zip... If this option is not given, and"
+              + " the output file is specified, the format is inferred from the"
+              + " filename if possible (e.g. writing to \"foo.zip\" makes the output"
+              + " to be in the zip format). Otherwise the output format is tar."
+    )
+    private String format = "tar";
+
+    @Option(name = "--prefix", usage = "Prepend <prefix>/ to each filename in the archive.")
+    private String prefix;
+
+    @Option(name = "-0", usage = "Store the files instead of deflating them.")
+    private boolean level0;
+
+    @Option(name = "-1")
+    private boolean level1;
+
+    @Option(name = "-2")
+    private boolean level2;
+
+    @Option(name = "-3")
+    private boolean level3;
+
+    @Option(name = "-4")
+    private boolean level4;
+
+    @Option(name = "-5")
+    private boolean level5;
+
+    @Option(name = "-6")
+    private boolean level6;
+
+    @Option(name = "-7")
+    private boolean level7;
+
+    @Option(name = "-8")
+    private boolean level8;
+
+    @Option(
+      name = "-9",
+      usage =
+          "Highest and slowest compression level. You "
+              + "can specify any number from 1 to 9 to adjust compression speed and "
+              + "ratio."
+    )
+    private boolean level9;
+
+    @Argument(index = 0, required = true, usage = "The tree or commit to produce an archive for.")
+    private String treeIsh = "master";
+
+    @Argument(
+      index = 1,
+      multiValued = true,
+      usage =
+          "Without an optional path parameter, all files and subdirectories of "
+              + "the current working directory are included in the archive. If one "
+              + "or more paths are specified, only these are included."
+    )
+    private List<String> path;
+  }
+
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private CommitsCollection commits;
+  @Inject private AllowedFormats allowedFormats;
+  private Options options = new Options();
+
+  /**
+   * Read and parse arguments from input stream. This method gets the arguments from input stream,
+   * in Pkt-line format, then parses them to fill the options object.
+   */
+  protected void readArguments() throws IOException, Failure {
+    String argCmd = "argument ";
+    List<String> args = new ArrayList<>();
+
+    // Read arguments in Pkt-Line format
+    PacketLineIn packetIn = new PacketLineIn(in);
+    for (; ; ) {
+      String s = packetIn.readString();
+      if (s == PacketLineIn.END) {
+        break;
+      }
+      if (!s.startsWith(argCmd)) {
+        throw new Failure(1, "fatal: 'argument' token or flush expected");
+      }
+      String[] parts = s.substring(argCmd.length()).split("=", 2);
+      for (String p : parts) {
+        args.add(p);
+      }
+    }
+
+    try {
+      // Parse them into the 'options' field
+      CmdLineParser parser = new CmdLineParser(options);
+      parser.parseArgument(args);
+      if (options.path == null || Arrays.asList(".").equals(options.path)) {
+        options.path = Collections.emptyList();
+      }
+    } catch (CmdLineException e) {
+      throw new Failure(2, "fatal: unable to parse arguments, " + e);
+    }
+  }
+
+  @Override
+  protected void runImpl() throws IOException, PermissionBackendException, Failure {
+    PacketLineOut packetOut = new PacketLineOut(out);
+    packetOut.setFlushOnEnd(true);
+    packetOut.writeString("ACK");
+    packetOut.end();
+
+    try {
+      // Parse Git arguments
+      readArguments();
+
+      ArchiveFormat f = allowedFormats.getExtensions().get("." + options.format);
+      if (f == null) {
+        throw new Failure(3, "fatal: upload-archive not permitted");
+      }
+
+      // Find out the object to get from the specified reference and paths
+      ObjectId treeId = repo.resolve(options.treeIsh);
+      if (treeId == null) {
+        throw new Failure(4, "fatal: reference not found");
+      }
+
+      // Verify the user has permissions to read the specified tree.
+      if (!canRead(treeId)) {
+        throw new Failure(5, "fatal: cannot perform upload-archive operation");
+      }
+
+      // The archive is sent in DATA sideband channel
+      try (SideBandOutputStream sidebandOut =
+          new SideBandOutputStream(
+              SideBandOutputStream.CH_DATA, SideBandOutputStream.MAX_BUF, out)) {
+        new ArchiveCommand(repo)
+            .setFormat(f.name())
+            .setFormatOptions(getFormatOptions(f))
+            .setTree(treeId)
+            .setPaths(options.path.toArray(new String[0]))
+            .setPrefix(options.prefix)
+            .setOutputStream(sidebandOut)
+            .call();
+        sidebandOut.flush();
+      } catch (GitAPIException e) {
+        throw new Failure(7, "fatal: git api exception, " + e);
+      }
+    } catch (Failure f) {
+      // Report the error in ERROR sideband channel
+      try (SideBandOutputStream sidebandError =
+          new SideBandOutputStream(
+              SideBandOutputStream.CH_ERROR, SideBandOutputStream.MAX_BUF, out)) {
+        sidebandError.write(f.getMessage().getBytes(UTF_8));
+        sidebandError.flush();
+      }
+      throw f;
+    } finally {
+      // In any case, cleanly close the packetOut channel
+      packetOut.end();
+    }
+  }
+
+  private Map<String, Object> getFormatOptions(ArchiveFormat f) {
+    if (f == ArchiveFormat.ZIP) {
+      int value =
+          Arrays.asList(
+                  options.level0,
+                  options.level1,
+                  options.level2,
+                  options.level3,
+                  options.level4,
+                  options.level5,
+                  options.level6,
+                  options.level7,
+                  options.level8,
+                  options.level9)
+              .indexOf(true);
+      if (value >= 0) {
+        return ImmutableMap.<String, Object>of("level", Integer.valueOf(value));
+      }
+    }
+    return Collections.emptyMap();
+  }
+
+  private boolean canRead(ObjectId revId) throws IOException, PermissionBackendException {
+    try {
+      permissionBackend.user(user).project(projectName).check(ProjectPermission.READ);
+      return true;
+    } catch (AuthException e) {
+      // Check reachability of the specific revision.
+      try (RevWalk rw = new RevWalk(repo)) {
+        RevCommit commit = rw.parseCommit(revId);
+        return commits.canRead(projectState, repo, commit);
+      }
+    }
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/VersionCommand.java b/java/com/google/gerrit/sshd/commands/VersionCommand.java
similarity index 100%
rename from gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/VersionCommand.java
rename to java/com/google/gerrit/sshd/commands/VersionCommand.java
diff --git a/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java b/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
new file mode 100644
index 0000000..1858f40
--- /dev/null
+++ b/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.plugin;
+
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.sshd.CommandModule;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Argument;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class LfsPluginAuthCommand extends SshCommand {
+  private static final Logger log = LoggerFactory.getLogger(LfsPluginAuthCommand.class);
+  private static final String CONFIGURATION_ERROR =
+      "Server configuration error: LFS auth over SSH is not properly configured.";
+
+  public interface LfsSshPluginAuth {
+    String authenticate(CurrentUser user, List<String> args) throws UnloggedFailure, Failure;
+  }
+
+  public static class Module extends CommandModule {
+    private final boolean pluginProvided;
+
+    @Inject
+    Module(@GerritServerConfig Config cfg) {
+      pluginProvided = cfg.getString("lfs", null, "plugin") != null;
+    }
+
+    @Override
+    protected void configure() {
+      if (pluginProvided) {
+        command("git-lfs-authenticate").to(LfsPluginAuthCommand.class);
+        DynamicItem.itemOf(binder(), LfsSshPluginAuth.class);
+      }
+    }
+  }
+
+  private final DynamicItem<LfsSshPluginAuth> auth;
+
+  @Argument(index = 0, multiValued = true, metaVar = "PARAMS")
+  private List<String> args = new ArrayList<>();
+
+  @Inject
+  LfsPluginAuthCommand(DynamicItem<LfsSshPluginAuth> auth) {
+    this.auth = auth;
+  }
+
+  @Override
+  protected void run() throws UnloggedFailure, Exception {
+    LfsSshPluginAuth pluginAuth = auth.get();
+    if (pluginAuth == null) {
+      log.warn(CONFIGURATION_ERROR);
+      throw new UnloggedFailure(1, CONFIGURATION_ERROR);
+    }
+
+    stdout.print(pluginAuth.authenticate(user, args));
+  }
+}
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
new file mode 100644
index 0000000..59102a9
--- /dev/null
+++ b/java/com/google/gerrit/testing/BUILD
@@ -0,0 +1,40 @@
+java_library(
+    name = "gerrit-test-util",
+    testonly = 1,
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    exports = [
+        "//lib/easymock",
+        "//lib/powermock:powermock-api-easymock",
+        "//lib/powermock:powermock-api-support",
+        "//lib/powermock:powermock-core",
+        "//lib/powermock:powermock-module-junit4",
+        "//lib/powermock:powermock-module-junit4-common",
+    ],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/gpg",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/pgm/init",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server:module",
+        "//java/com/google/gerrit/server/api",
+        "//java/com/google/gerrit/server/cache/h2",
+        "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/schema",
+        "//lib:gwtorm",
+        "//lib:h2",
+        "//lib:truth",
+        "//lib/auto:auto-value",
+        "//lib/guice",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/log:api",
+    ],
+)
diff --git a/java/com/google/gerrit/testing/ConfigSuite.java b/java/com/google/gerrit/testing/ConfigSuite.java
new file mode 100644
index 0000000..4d06671
--- /dev/null
+++ b/java/com/google/gerrit/testing/ConfigSuite.java
@@ -0,0 +1,309 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import java.lang.annotation.Annotation;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.List;
+import java.util.Map;
+import org.junit.runner.Runner;
+import org.junit.runners.BlockJUnit4ClassRunner;
+import org.junit.runners.Suite;
+import org.junit.runners.model.FrameworkMethod;
+import org.junit.runners.model.InitializationError;
+
+/**
+ * Suite to run tests with different {@code gerrit.config} values.
+ *
+ * <p>For each {@link Config} method in the class and base classes, a new group of tests is created
+ * with the {@link Parameter} field set to the config.
+ *
+ * <pre>
+ * {@literal @}RunWith(ConfigSuite.class)
+ * public abstract class MyAbstractTest {
+ *   {@literal @}ConfigSuite.Parameter
+ *   protected Config cfg;
+ *
+ *   {@literal @}ConfigSuite.Config
+ *   public static Config firstConfig() {
+ *     Config cfg = new Config();
+ *     cfg.setString("gerrit", null, "testValue", "a");
+ *   }
+ * }
+ *
+ * public class MyTest extends MyAbstractTest {
+ *   {@literal @}ConfigSuite.Config
+ *   public static Config secondConfig() {
+ *     Config cfg = new Config();
+ *     cfg.setString("gerrit", null, "testValue", "b");
+ *   }
+ *
+ *   {@literal @}Test
+ *   public void myTest() {
+ *     // Test using cfg.
+ *   }
+ * }
+ * </pre>
+ *
+ * This creates a suite of tests with three groups:
+ *
+ * <ul>
+ *   <li><strong>default</strong>: {@code MyTest.myTest}
+ *   <li><strong>firstConfig</strong>: {@code MyTest.myTest[firstConfig]}
+ *   <li><strong>secondConfig</strong>: {@code MyTest.myTest[secondConfig]}
+ * </ul>
+ *
+ * Additionally, config values used by <strong>default</strong> can be set in a method annotated
+ * with {@code @ConfigSuite.Default}.
+ *
+ * <p>In addition groups of tests for different configurations can be defined by annotating a method
+ * that returns a Map&lt;String, Config&gt; with {@link Configs}. The map keys define the test suite
+ * names, while the values define the configurations for the test suites.
+ *
+ * <pre>
+ * {@literal @}ConfigSuite.Configs
+ * public static Map&lt;String, Config&gt; configs() {
+ *   Config cfgA = new Config();
+ *   cfgA.setString("gerrit", null, "testValue", "a");
+ *   Config cfgB = new Config();
+ *   cfgB.setString("gerrit", null, "testValue", "b");
+ *   return ImmutableMap.of("testWithValueA", cfgA, "testWithValueB", cfgB);
+ * }
+ * </pre>
+ *
+ * <p>The name of the config method corresponding to the currently-running test can be stored in a
+ * field annotated with {@code @ConfigSuite.Name}.
+ */
+public class ConfigSuite extends Suite {
+  public static final String DEFAULT = "default";
+
+  @Target({METHOD})
+  @Retention(RUNTIME)
+  public static @interface Default {}
+
+  @Target({METHOD})
+  @Retention(RUNTIME)
+  public static @interface Config {}
+
+  @Target({METHOD})
+  @Retention(RUNTIME)
+  public static @interface Configs {}
+
+  @Target({FIELD})
+  @Retention(RUNTIME)
+  public static @interface Parameter {}
+
+  @Target({FIELD})
+  @Retention(RUNTIME)
+  public static @interface Name {}
+
+  private static class ConfigRunner extends BlockJUnit4ClassRunner {
+    private final org.eclipse.jgit.lib.Config cfg;
+    private final Field parameterField;
+    private final Field nameField;
+    private final String name;
+
+    private ConfigRunner(
+        Class<?> clazz,
+        Field parameterField,
+        Field nameField,
+        String name,
+        org.eclipse.jgit.lib.Config cfg)
+        throws InitializationError {
+      super(clazz);
+      this.parameterField = parameterField;
+      this.nameField = nameField;
+      this.name = name;
+      this.cfg = cfg;
+    }
+
+    @Override
+    public Object createTest() throws Exception {
+      Object test = getTestClass().getJavaClass().newInstance();
+      parameterField.set(test, new org.eclipse.jgit.lib.Config(cfg));
+      if (nameField != null) {
+        nameField.set(test, name);
+      }
+      return test;
+    }
+
+    @Override
+    protected String getName() {
+      return MoreObjects.firstNonNull(name, DEFAULT);
+    }
+
+    @Override
+    protected String testName(FrameworkMethod method) {
+      String n = method.getName();
+      return name == null ? n : n + "[" + name + "]";
+    }
+  }
+
+  private static List<Runner> runnersFor(Class<?> clazz) {
+    Method defaultConfig = getDefaultConfig(clazz);
+    List<Method> configs = getConfigs(clazz);
+    Map<String, org.eclipse.jgit.lib.Config> configMap =
+        callConfigMapMethod(getConfigMap(clazz), configs);
+
+    Field parameterField = getOnlyField(clazz, Parameter.class);
+    checkArgument(parameterField != null, "No @ConfigSuite.Parameter found");
+    Field nameField = getOnlyField(clazz, Name.class);
+    List<Runner> result = Lists.newArrayListWithCapacity(configs.size() + 1);
+    try {
+      result.add(
+          new ConfigRunner(
+              clazz, parameterField, nameField, null, callConfigMethod(defaultConfig)));
+      for (Method m : configs) {
+        result.add(
+            new ConfigRunner(clazz, parameterField, nameField, m.getName(), callConfigMethod(m)));
+      }
+      for (Map.Entry<String, org.eclipse.jgit.lib.Config> e : configMap.entrySet()) {
+        result.add(new ConfigRunner(clazz, parameterField, nameField, e.getKey(), e.getValue()));
+      }
+      return result;
+    } catch (InitializationError e) {
+      System.err.println("Errors initializing runners:");
+      for (Throwable t : e.getCauses()) {
+        t.printStackTrace();
+      }
+      throw new RuntimeException(e);
+    }
+  }
+
+  private static Method getDefaultConfig(Class<?> clazz) {
+    return getAnnotatedMethod(clazz, Default.class);
+  }
+
+  private static Method getConfigMap(Class<?> clazz) {
+    return getAnnotatedMethod(clazz, Configs.class);
+  }
+
+  private static <T extends Annotation> Method getAnnotatedMethod(
+      Class<?> clazz, Class<T> annotationClass) {
+    Method result = null;
+    for (Method m : clazz.getMethods()) {
+      T ann = m.getAnnotation(annotationClass);
+      if (ann != null) {
+        checkArgument(result == null, "Multiple methods annotated with %s: %s, %s", ann, result, m);
+        result = m;
+      }
+    }
+    return result;
+  }
+
+  private static List<Method> getConfigs(Class<?> clazz) {
+    List<Method> result = Lists.newArrayListWithExpectedSize(3);
+    for (Method m : clazz.getMethods()) {
+      Config ann = m.getAnnotation(Config.class);
+      if (ann != null) {
+        checkArgument(!m.getName().equals(DEFAULT), "%s cannot be named %s", ann, DEFAULT);
+        result.add(m);
+      }
+    }
+    return result;
+  }
+
+  private static org.eclipse.jgit.lib.Config callConfigMethod(Method m) {
+    if (m == null) {
+      return new org.eclipse.jgit.lib.Config();
+    }
+    checkArgument(
+        org.eclipse.jgit.lib.Config.class.isAssignableFrom(m.getReturnType()),
+        "%s must return Config",
+        m);
+    checkArgument((m.getModifiers() & Modifier.STATIC) != 0, "%s must be static", m);
+    checkArgument(m.getParameterTypes().length == 0, "%s must take no parameters", m);
+    try {
+      return (org.eclipse.jgit.lib.Config) m.invoke(null);
+    } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
+      throw new IllegalArgumentException(e);
+    }
+  }
+
+  private static Map<String, org.eclipse.jgit.lib.Config> callConfigMapMethod(
+      Method m, List<Method> configs) {
+    if (m == null) {
+      return ImmutableMap.of();
+    }
+    checkArgument(Map.class.isAssignableFrom(m.getReturnType()), "%s must return Map", m);
+    Type[] types = ((ParameterizedType) m.getGenericReturnType()).getActualTypeArguments();
+    checkArgument(
+        String.class.isAssignableFrom((Class<?>) types[0]),
+        "The map returned by %s must have String as key",
+        m);
+    checkArgument(
+        org.eclipse.jgit.lib.Config.class.isAssignableFrom((Class<?>) types[1]),
+        "The map returned by %s must have Config as value",
+        m);
+    checkArgument((m.getModifiers() & Modifier.STATIC) != 0, "%s must be static", m);
+    checkArgument(m.getParameterTypes().length == 0, "%s must take no parameters", m);
+    try {
+      @SuppressWarnings("unchecked")
+      Map<String, org.eclipse.jgit.lib.Config> configMap =
+          (Map<String, org.eclipse.jgit.lib.Config>) m.invoke(null);
+      checkArgument(
+          !configMap.containsKey(DEFAULT),
+          "The map returned by %s cannot contain key %s (duplicate test suite name)",
+          m,
+          DEFAULT);
+      for (String name : configs.stream().map(cm -> cm.getName()).collect(toSet())) {
+        checkArgument(
+            !configMap.containsKey(name),
+            "The map returned by %s cannot contain key %s (duplicate test suite name)",
+            m,
+            name);
+      }
+      return configMap;
+    } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
+      throw new IllegalArgumentException(e);
+    }
+  }
+
+  private static Field getOnlyField(Class<?> clazz, Class<? extends Annotation> ann) {
+    List<Field> fields = Lists.newArrayListWithExpectedSize(1);
+    for (Field f : clazz.getFields()) {
+      if (f.getAnnotation(ann) != null) {
+        fields.add(f);
+      }
+    }
+    checkArgument(
+        fields.size() <= 1,
+        "expected 1 @ConfigSuite.%s field, found: %s",
+        ann.getSimpleName(),
+        fields);
+    return Iterables.getFirst(fields, null);
+  }
+
+  public ConfigSuite(Class<?> clazz) throws InitializationError {
+    super(clazz, runnersFor(clazz));
+  }
+}
diff --git a/java/com/google/gerrit/testing/DisabledReviewDb.java b/java/com/google/gerrit/testing/DisabledReviewDb.java
new file mode 100644
index 0000000..998b893
--- /dev/null
+++ b/java/com/google/gerrit/testing/DisabledReviewDb.java
@@ -0,0 +1,153 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import com.google.gerrit.reviewdb.server.AccountGroupAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupByIdAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupByIdAudAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupMemberAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupMemberAuditAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupNameAccess;
+import com.google.gerrit.reviewdb.server.ChangeAccess;
+import com.google.gerrit.reviewdb.server.ChangeMessageAccess;
+import com.google.gerrit.reviewdb.server.PatchLineCommentAccess;
+import com.google.gerrit.reviewdb.server.PatchSetAccess;
+import com.google.gerrit.reviewdb.server.PatchSetApprovalAccess;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.SchemaVersionAccess;
+import com.google.gerrit.reviewdb.server.SystemConfigAccess;
+import com.google.gwtorm.server.Access;
+import com.google.gwtorm.server.StatementExecutor;
+
+/** ReviewDb that is disabled for testing. */
+public class DisabledReviewDb implements ReviewDb {
+  public static class Disabled extends RuntimeException {
+    private static final long serialVersionUID = 1L;
+
+    private Disabled() {
+      super("ReviewDb is disabled for this test");
+    }
+  }
+
+  @Override
+  public void close() {
+    // Do nothing.
+  }
+
+  @Override
+  public void commit() {
+    throw new Disabled();
+  }
+
+  @Override
+  public void rollback() {
+    throw new Disabled();
+  }
+
+  @Override
+  public void updateSchema(StatementExecutor e) {
+    throw new Disabled();
+  }
+
+  @Override
+  public void pruneSchema(StatementExecutor e) {
+    throw new Disabled();
+  }
+
+  @Override
+  public Access<?, ?>[] allRelations() {
+    throw new Disabled();
+  }
+
+  @Override
+  public SchemaVersionAccess schemaVersion() {
+    throw new Disabled();
+  }
+
+  @Override
+  public SystemConfigAccess systemConfig() {
+    throw new Disabled();
+  }
+
+  @Override
+  public AccountGroupAccess accountGroups() {
+    throw new Disabled();
+  }
+
+  @Override
+  public AccountGroupNameAccess accountGroupNames() {
+    throw new Disabled();
+  }
+
+  @Override
+  public AccountGroupMemberAccess accountGroupMembers() {
+    throw new Disabled();
+  }
+
+  @Override
+  public AccountGroupMemberAuditAccess accountGroupMembersAudit() {
+    throw new Disabled();
+  }
+
+  @Override
+  public ChangeAccess changes() {
+    throw new Disabled();
+  }
+
+  @Override
+  public PatchSetApprovalAccess patchSetApprovals() {
+    throw new Disabled();
+  }
+
+  @Override
+  public ChangeMessageAccess changeMessages() {
+    throw new Disabled();
+  }
+
+  @Override
+  public PatchSetAccess patchSets() {
+    throw new Disabled();
+  }
+
+  @Override
+  public PatchLineCommentAccess patchComments() {
+    throw new Disabled();
+  }
+
+  @Override
+  public AccountGroupByIdAccess accountGroupById() {
+    throw new Disabled();
+  }
+
+  @Override
+  public AccountGroupByIdAudAccess accountGroupByIdAud() {
+    throw new Disabled();
+  }
+
+  @Override
+  public int nextAccountId() {
+    throw new Disabled();
+  }
+
+  @Override
+  public int nextAccountGroupId() {
+    throw new Disabled();
+  }
+
+  @Override
+  public int nextChangeId() {
+    throw new Disabled();
+  }
+}
diff --git a/java/com/google/gerrit/testing/FakeAccountCache.java b/java/com/google/gerrit/testing/FakeAccountCache.java
new file mode 100644
index 0000000..7668912
--- /dev/null
+++ b/java/com/google/gerrit/testing/FakeAccountCache.java
@@ -0,0 +1,86 @@
+// 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.testing;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Fake implementation of {@link AccountCache} for testing. */
+public class FakeAccountCache implements AccountCache {
+  private final Map<Account.Id, AccountState> byId;
+  private final Map<String, AccountState> byUsername;
+
+  public FakeAccountCache() {
+    byId = new HashMap<>();
+    byUsername = new HashMap<>();
+  }
+
+  @Override
+  public synchronized AccountState get(Account.Id accountId) {
+    AccountState state = byId.get(accountId);
+    if (state != null) {
+      return state;
+    }
+    return newState(new Account(accountId, TimeUtil.nowTs()));
+  }
+
+  @Override
+  @Nullable
+  public synchronized AccountState getOrNull(Account.Id accountId) {
+    return byId.get(accountId);
+  }
+
+  @Override
+  public synchronized AccountState getByUsername(String username) {
+    return byUsername.get(username);
+  }
+
+  @Override
+  public synchronized void evict(Account.Id accountId) {
+    byId.remove(accountId);
+  }
+
+  @Override
+  public synchronized void evictAllNoReindex() {
+    byId.clear();
+    byUsername.clear();
+  }
+
+  public synchronized void put(Account account) {
+    AccountState state = newState(account);
+    byId.put(account.getId(), state);
+    if (account.getUserName() != null) {
+      byUsername.put(account.getUserName(), state);
+    }
+  }
+
+  private static AccountState newState(Account account) {
+    return new AccountState(
+        new AllUsersName(AllUsersNameProvider.DEFAULT),
+        account,
+        ImmutableSet.of(),
+        new HashMap<>(),
+        GeneralPreferencesInfo.defaults());
+  }
+}
diff --git a/java/com/google/gerrit/testing/FakeEmailSender.java b/java/com/google/gerrit/testing/FakeEmailSender.java
new file mode 100644
index 0000000..7aed684
--- /dev/null
+++ b/java/com/google/gerrit/testing/FakeEmailSender.java
@@ -0,0 +1,173 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.server.mail.send.EmailSender;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Email sender implementation that records messages in memory.
+ *
+ * <p>This class is mostly threadsafe. The only exception is that not all {@link EmailHeader}
+ * subclasses are immutable. In particular, if a caller holds a reference to an {@code AddressList}
+ * and mutates it after sending, the message returned by {@link #getMessages()} may or may not
+ * reflect mutations.
+ */
+@Singleton
+public class FakeEmailSender implements EmailSender {
+  private static final Logger log = LoggerFactory.getLogger(FakeEmailSender.class);
+
+  public static class Module extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(EmailSender.class).to(FakeEmailSender.class);
+    }
+  }
+
+  @AutoValue
+  public abstract static class Message {
+    private static Message create(
+        Address from,
+        Collection<Address> rcpt,
+        Map<String, EmailHeader> headers,
+        String body,
+        String htmlBody) {
+      return new AutoValue_FakeEmailSender_Message(
+          from, ImmutableList.copyOf(rcpt), ImmutableMap.copyOf(headers), body, htmlBody);
+    }
+
+    public abstract Address from();
+
+    public abstract ImmutableList<Address> rcpt();
+
+    public abstract ImmutableMap<String, EmailHeader> headers();
+
+    public abstract String body();
+
+    @Nullable
+    public abstract String htmlBody();
+  }
+
+  private final WorkQueue workQueue;
+  private final List<Message> messages;
+  private int messagesRead;
+
+  @Inject
+  FakeEmailSender(WorkQueue workQueue) {
+    this.workQueue = workQueue;
+    messages = Collections.synchronizedList(new ArrayList<Message>());
+    messagesRead = 0;
+  }
+
+  @Override
+  public boolean isEnabled() {
+    return true;
+  }
+
+  @Override
+  public boolean canEmail(String address) {
+    return true;
+  }
+
+  @Override
+  public void send(
+      Address from, Collection<Address> rcpt, Map<String, EmailHeader> headers, String body)
+      throws EmailException {
+    send(from, rcpt, headers, body, null);
+  }
+
+  @Override
+  public void send(
+      Address from,
+      Collection<Address> rcpt,
+      Map<String, EmailHeader> headers,
+      String body,
+      String htmlBody)
+      throws EmailException {
+    messages.add(Message.create(from, rcpt, headers, body, htmlBody));
+  }
+
+  public void clear() {
+    waitForEmails();
+    synchronized (messages) {
+      messages.clear();
+      messagesRead = 0;
+    }
+  }
+
+  public synchronized @Nullable Message peekMessage() {
+    if (messagesRead >= messages.size()) {
+      return null;
+    }
+    return messages.get(messagesRead);
+  }
+
+  public synchronized @Nullable Message nextMessage() {
+    Message msg = peekMessage();
+    messagesRead++;
+    return msg;
+  }
+
+  public ImmutableList<Message> getMessages() {
+    waitForEmails();
+    synchronized (messages) {
+      return ImmutableList.copyOf(messages);
+    }
+  }
+
+  public List<Message> getMessages(String changeId, String type) {
+    final String idFooter = "\nGerrit-Change-Id: " + changeId + "\n";
+    final String typeFooter = "\nGerrit-MessageType: " + type + "\n";
+    return getMessages()
+        .stream()
+        .filter(in -> in.body().contains(idFooter) && in.body().contains(typeFooter))
+        .collect(toList());
+  }
+
+  private void waitForEmails() {
+    // TODO(dborowitz): This is brittle; consider forcing emails to use
+    // a single thread in tests (tricky because most callers just use the
+    // default executor).
+    for (WorkQueue.Task<?> task : workQueue.getTasks()) {
+      if (task.toString().contains("send-email")) {
+        try {
+          task.get();
+        } catch (ExecutionException | InterruptedException e) {
+          log.warn("error finishing email task", e);
+        }
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/testing/GerritBaseTests.java b/java/com/google/gerrit/testing/GerritBaseTests.java
new file mode 100644
index 0000000..01fb85d
--- /dev/null
+++ b/java/com/google/gerrit/testing/GerritBaseTests.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import com.google.common.base.CharMatcher;
+import com.google.gwtorm.client.KeyUtil;
+import com.google.gwtorm.server.StandardKeyEncoder;
+import org.junit.Ignore;
+import org.junit.Rule;
+import org.junit.rules.ExpectedException;
+import org.junit.rules.TestName;
+
+@Ignore
+public abstract class GerritBaseTests {
+  static {
+    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
+  }
+
+  @Rule public ExpectedException exception = ExpectedException.none();
+  @Rule public final TestName testName = new TestName();
+
+  protected String getSanitizedMethodName() {
+    String name = testName.getMethodName().toLowerCase();
+    name =
+        CharMatcher.inRange('a', 'z')
+            .or(CharMatcher.inRange('A', 'Z'))
+            .or(CharMatcher.inRange('0', '9'))
+            .negate()
+            .replaceFrom(name, '_');
+    name = CharMatcher.is('_').trimTrailingFrom(name);
+    return name;
+  }
+}
diff --git a/java/com/google/gerrit/testing/GerritServerTests.java b/java/com/google/gerrit/testing/GerritServerTests.java
new file mode 100644
index 0000000..69806e1
--- /dev/null
+++ b/java/com/google/gerrit/testing/GerritServerTests.java
@@ -0,0 +1,59 @@
+// 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.testing;
+
+import com.google.gerrit.server.notedb.MutableNotesMigration;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Rule;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runner.RunWith;
+import org.junit.runners.model.Statement;
+
+@RunWith(ConfigSuite.class)
+public class GerritServerTests extends GerritBaseTests {
+  @ConfigSuite.Parameter public Config config;
+
+  @ConfigSuite.Name private String configName;
+
+  protected MutableNotesMigration notesMigration;
+
+  @Rule
+  public TestRule testRunner =
+      new TestRule() {
+        @Override
+        public Statement apply(Statement base, Description description) {
+          return new Statement() {
+            @Override
+            public void evaluate() throws Throwable {
+              beforeTest();
+              try {
+                base.evaluate();
+              } finally {
+                afterTest();
+              }
+            }
+          };
+        }
+      };
+
+  public void beforeTest() throws Exception {
+    notesMigration = NoteDbMode.newNotesMigrationFromEnv();
+  }
+
+  public void afterTest() {
+    NoteDbMode.resetFromEnv(notesMigration);
+  }
+}
diff --git a/java/com/google/gerrit/testing/GroupNoteDbMode.java b/java/com/google/gerrit/testing/GroupNoteDbMode.java
new file mode 100644
index 0000000..86e92b8
--- /dev/null
+++ b/java/com/google/gerrit/testing/GroupNoteDbMode.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Enums;
+import com.google.common.base.Strings;
+import com.google.gerrit.server.notedb.GroupsMigration;
+
+public enum GroupNoteDbMode {
+  /** NoteDb is disabled, groups are only in ReviewDb */
+  OFF(new GroupsMigration(false, false, false)),
+
+  /** Writing new groups to NoteDb is enabled. */
+  WRITE(new GroupsMigration(true, false, false)),
+
+  /**
+   * Reading/writing groups from/to NoteDb is enabled. Trying to read groups from ReviewDb throws an
+   * exception.
+   */
+  READ_WRITE(new GroupsMigration(true, true, false)),
+
+  /**
+   * All group tables in ReviewDb are entirely disabled. Trying to read groups from ReviewDb throws
+   * an exception. Reading groups through an unwrapped ReviewDb instance writing groups to ReviewDb
+   * is a No-Op.
+   */
+  ON(new GroupsMigration(true, true, true));
+
+  private static final String ENV_VAR = "GERRIT_NOTEDB_GROUPS";
+  private static final String SYS_PROP = "gerrit.notedb.groups";
+
+  public static GroupNoteDbMode get() {
+    String value = System.getenv(ENV_VAR);
+    if (Strings.isNullOrEmpty(value)) {
+      value = System.getProperty(SYS_PROP);
+    }
+    if (Strings.isNullOrEmpty(value)) {
+      return OFF;
+    }
+    value = value.toUpperCase().replace("-", "_");
+    GroupNoteDbMode mode = Enums.getIfPresent(GroupNoteDbMode.class, value).orNull();
+    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
+      checkArgument(
+          mode != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
+    } else {
+      checkArgument(
+          mode != null,
+          "Invalid value for system property %s: %s",
+          SYS_PROP,
+          System.getProperty(SYS_PROP));
+    }
+    return mode;
+  }
+
+  private final GroupsMigration groupsMigration;
+
+  private GroupNoteDbMode(GroupsMigration groupsMigration) {
+    this.groupsMigration = groupsMigration;
+  }
+
+  public GroupsMigration getGroupsMigration() {
+    return groupsMigration;
+  }
+}
diff --git a/java/com/google/gerrit/testing/InMemoryDatabase.java b/java/com/google/gerrit/testing/InMemoryDatabase.java
new file mode 100644
index 0000000..ec98e16
--- /dev/null
+++ b/java/com/google/gerrit/testing/InMemoryDatabase.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.pgm.init.index.elasticsearch.ElasticIndexModuleOnInit;
+import com.google.gerrit.pgm.init.index.lucene.LuceneIndexModuleOnInit;
+import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
+import com.google.gerrit.reviewdb.client.SystemConfig;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.index.IndexModule;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.schema.SchemaVersion;
+import com.google.gwtorm.jdbc.Database;
+import com.google.gwtorm.jdbc.SimpleDataSource;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.Properties;
+import javax.sql.DataSource;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * An in-memory test instance of {@link ReviewDb} database.
+ *
+ * <p>Test classes should create one instance of this class for each unique test database they want
+ * to use. When the tests needing this instance are complete, ensure that {@link
+ * #drop(InMemoryDatabase)} is called to free the resources so the JVM running the unit tests
+ * doesn't run out of heap space.
+ */
+public class InMemoryDatabase implements SchemaFactory<ReviewDb> {
+  public static InMemoryDatabase newDatabase(LifecycleManager lifecycle) {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    lifecycle.add(injector);
+    return injector.getInstance(InMemoryDatabase.class);
+  }
+
+  private static int dbCnt;
+
+  private static synchronized DataSource newDataSource() throws SQLException {
+    final Properties p = new Properties();
+    p.setProperty("driver", org.h2.Driver.class.getName());
+    p.setProperty("url", "jdbc:h2:mem:Test_" + (++dbCnt));
+    return new SimpleDataSource(p);
+  }
+
+  /** Drop the database from memory; does nothing if the instance was null. */
+  public static void drop(InMemoryDatabase db) {
+    if (db != null) {
+      db.drop();
+    }
+  }
+
+  private final SchemaCreator schemaCreator;
+
+  private Connection openHandle;
+  private Database<ReviewDb> database;
+  private boolean created;
+
+  @Inject
+  InMemoryDatabase(Injector injector) throws OrmException {
+    Injector childInjector =
+        injector.createChildInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                switch (IndexModule.getIndexType(injector)) {
+                  case LUCENE:
+                    install(new LuceneIndexModuleOnInit());
+                    break;
+                  case ELASTICSEARCH:
+                    install(new ElasticIndexModuleOnInit());
+                    break;
+                  default:
+                    throw new IllegalStateException("unsupported index.type");
+                }
+              }
+            });
+    this.schemaCreator = childInjector.getInstance(SchemaCreator.class);
+    initDatabase();
+  }
+
+  InMemoryDatabase(SchemaCreator schemaCreator) throws OrmException {
+    this.schemaCreator = schemaCreator;
+    initDatabase();
+  }
+
+  private void initDatabase() throws OrmException {
+    try {
+      DataSource dataSource = newDataSource();
+
+      // Open one connection. This will peg the database into memory
+      // until someone calls drop on us, allowing subsequent connections
+      // opened against the same URL to go to the same set of tables.
+      //
+      openHandle = dataSource.getConnection();
+
+      // Build the access layer around the connection factory.
+      //
+      database = new Database<>(dataSource, ReviewDb.class);
+
+    } catch (SQLException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  public Database<ReviewDb> getDatabase() {
+    return database;
+  }
+
+  @Override
+  public ReviewDb open() throws OrmException {
+    return getDatabase().open();
+  }
+
+  /** Ensure the database schema has been created and initialized. */
+  public InMemoryDatabase create() throws OrmException {
+    if (!created) {
+      created = true;
+      try (ReviewDb c = open()) {
+        schemaCreator.create(c);
+      } catch (IOException | ConfigInvalidException e) {
+        throw new OrmException("Cannot create in-memory database", e);
+      }
+    }
+    return this;
+  }
+
+  /** Drop this database from memory so it no longer exists. */
+  public void drop() {
+    if (openHandle != null) {
+      try {
+        openHandle.close();
+      } catch (SQLException e) {
+        System.err.println("WARNING: Cannot close database connection");
+        e.printStackTrace(System.err);
+      }
+      openHandle = null;
+      database = null;
+    }
+  }
+
+  public SystemConfig getSystemConfig() throws OrmException {
+    try (ReviewDb c = open()) {
+      return c.systemConfig().get(new SystemConfig.Key());
+    }
+  }
+
+  public CurrentSchemaVersion getSchemaVersion() throws OrmException {
+    try (ReviewDb c = open()) {
+      return c.schemaVersion().get(new CurrentSchemaVersion.Key());
+    }
+  }
+
+  public void assertSchemaVersion() throws OrmException {
+    assertThat(getSchemaVersion().versionNbr).isEqualTo(SchemaVersion.getBinaryVersion());
+  }
+}
diff --git a/java/com/google/gerrit/testing/InMemoryH2Type.java b/java/com/google/gerrit/testing/InMemoryH2Type.java
new file mode 100644
index 0000000..ae3bf36
--- /dev/null
+++ b/java/com/google/gerrit/testing/InMemoryH2Type.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import com.google.gerrit.server.schema.BaseDataSourceType;
+
+public class InMemoryH2Type extends BaseDataSourceType {
+
+  protected InMemoryH2Type() {
+    super(null);
+  }
+
+  @Override
+  public String getUrl() {
+    // not used
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
new file mode 100644
index 0000000..c714d741
--- /dev/null
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -0,0 +1,321 @@
+// 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.testing;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.inject.Scopes.SINGLETON;
+
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.systemstatus.ServerInformation;
+import com.google.gerrit.gpg.GpgModule;
+import com.google.gerrit.index.SchemaDefinitions;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.api.GerritApiModule;
+import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.config.CanonicalWebUrlModule;
+import com.google.gerrit.server.config.CanonicalWebUrlProvider;
+import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritOptions;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.config.TrackingFooters;
+import com.google.gerrit.server.config.TrackingFootersProvider;
+import com.google.gerrit.server.git.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;
+import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.index.account.AllAccountsIndexer;
+import com.google.gerrit.server.index.change.AllChangesIndexer;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.group.AllGroupsIndexer;
+import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.server.index.project.ProjectSchemaDefinitions;
+import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
+import com.google.gerrit.server.notedb.MutableNotesMigration;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.patch.DiffExecutor;
+import com.google.gerrit.server.plugins.PluginRestApiModule;
+import com.google.gerrit.server.plugins.ServerInformationImpl;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
+import com.google.gerrit.server.project.DefaultProjectNameLockManager;
+import com.google.gerrit.server.schema.DataSourceType;
+import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
+import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
+import com.google.gerrit.server.schema.ReviewDbFactory;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.securestore.DefaultSecureStore;
+import com.google.gerrit.server.securestore.SecureStore;
+import com.google.gerrit.server.ssh.NoSshKeyCache;
+import com.google.gerrit.server.update.ChangeUpdateExecutor;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.Module;
+import com.google.inject.Provider;
+import com.google.inject.Provides;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.servlet.RequestScoped;
+import com.google.inject.util.Providers;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+
+public class InMemoryModule extends FactoryModule {
+  public static Config newDefaultConfig() {
+    Config cfg = new Config();
+    setDefaults(cfg);
+    return cfg;
+  }
+
+  public static void setDefaults(Config cfg) {
+    cfg.setEnum("auth", null, "type", AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT);
+    cfg.setString("gerrit", null, "allProjects", "Test-Projects");
+    cfg.setString("gerrit", null, "basePath", "git");
+    cfg.setString("gerrit", null, "canonicalWebUrl", "http://test/");
+    cfg.setString("user", null, "name", "Gerrit Code Review");
+    cfg.setString("user", null, "email", "gerrit@localhost");
+    cfg.unset("cache", null, "directory");
+    cfg.setString("index", null, "type", "lucene");
+    cfg.setBoolean("index", "lucene", "testInmemory", true);
+    cfg.setInt("sendemail", null, "threadPoolSize", 0);
+    cfg.setBoolean("receive", null, "enableSignedPush", false);
+    cfg.setString("receive", null, "certNonceSeed", "sekret");
+  }
+
+  private final Config cfg;
+  private final MutableNotesMigration notesMigration;
+
+  public InMemoryModule() {
+    this(newDefaultConfig(), NoteDbMode.newNotesMigrationFromEnv());
+  }
+
+  public InMemoryModule(Config cfg, MutableNotesMigration notesMigration) {
+    this.cfg = cfg;
+    this.notesMigration = notesMigration;
+  }
+
+  public void inject(Object instance) {
+    Guice.createInjector(this).injectMembers(instance);
+  }
+
+  @Override
+  protected void configure() {
+    // Do NOT bind @RemotePeer, as it is bound in a child injector of
+    // ChangeMergeQueue (bound via GerritGlobalModule below), so there cannot be
+    // a binding in the parent injector. If you need @RemotePeer, you must bind
+    // it in a child injector of the one containing InMemoryModule. But unless
+    // you really need to test something request-scoped, you likely don't
+    // actually need it.
+
+    // For simplicity, don't create child injectors, just use this one to get a
+    // few required modules.
+    Injector cfgInjector =
+        Guice.createInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
+              }
+            });
+    bind(MetricMaker.class).to(DisabledMetricMaker.class);
+    install(cfgInjector.getInstance(GerritGlobalModule.class));
+    install(new GerritApiModule());
+    install(new PluginApiModule());
+    install(new DefaultPermissionBackendModule());
+    install(new SearchingChangeCacheImpl.Module());
+    factory(GarbageCollection.Factory.class);
+
+    bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
+
+    // TODO(dborowitz): Use Jimfs. The biggest blocker is that JGit does not support Path-based
+    // Configs, only FileBasedConfig.
+    bind(Path.class).annotatedWith(SitePath.class).toInstance(Paths.get("."));
+    bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
+    bind(GerritOptions.class).toInstance(new GerritOptions(cfg, false, false, false));
+    bind(PersonIdent.class)
+        .annotatedWith(GerritPersonIdent.class)
+        .toProvider(GerritPersonIdentProvider.class);
+    bind(String.class)
+        .annotatedWith(AnonymousCowardName.class)
+        .toProvider(AnonymousCowardNameProvider.class);
+    bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
+    bind(AllProjectsName.class).toProvider(AllProjectsNameProvider.class);
+    bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
+    bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
+    bind(InMemoryRepositoryManager.class).in(SINGLETON);
+    bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class).in(SINGLETON);
+    bind(MutableNotesMigration.class).toInstance(notesMigration);
+    bind(NotesMigration.class).to(MutableNotesMigration.class);
+    bind(ListeningExecutorService.class)
+        .annotatedWith(ChangeUpdateExecutor.class)
+        .toInstance(MoreExecutors.newDirectExecutorService());
+    bind(DataSourceType.class).to(InMemoryH2Type.class);
+    bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
+    bind(SecureStore.class).to(DefaultSecureStore.class);
+
+    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
+        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
+    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
+    bind(Key.get(schemaFactory, ReviewDbFactory.class)).to(InMemoryDatabase.class);
+
+    install(NoSshKeyCache.module());
+    install(
+        new CanonicalWebUrlModule() {
+          @Override
+          protected Class<? extends Provider<String>> provider() {
+            return CanonicalWebUrlProvider.class;
+          }
+        });
+    // Replacement of DiffExecutorModule to not use thread pool in the tests
+    install(
+        new AbstractModule() {
+          @Override
+          protected void configure() {}
+
+          @Provides
+          @Singleton
+          @DiffExecutor
+          public ExecutorService createDiffExecutor() {
+            return MoreExecutors.newDirectExecutorService();
+          }
+        });
+    install(new DefaultCacheFactory.Module());
+    install(new FakeEmailSender.Module());
+    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));
+    bind(AllGroupsIndexer.class).toProvider(Providers.of(null));
+
+    IndexType indexType = null;
+    try {
+      indexType = cfg.getEnum("index", null, "type", IndexType.LUCENE);
+    } catch (IllegalArgumentException e) {
+      // Custom index type, caller must provide their own module.
+    }
+    if (indexType != null) {
+      switch (indexType) {
+        case LUCENE:
+          install(luceneIndexModule());
+          break;
+        case ELASTICSEARCH:
+          install(elasticIndexModule());
+          break;
+        default:
+          throw new ProvisionException("index type unsupported in tests: " + indexType);
+      }
+    }
+    bind(ServerInformationImpl.class);
+    bind(ServerInformation.class).to(ServerInformationImpl.class);
+    install(new PluginRestApiModule());
+    install(new DefaultProjectNameLockManager.Module());
+  }
+
+  @Provides
+  @Singleton
+  @SendEmailExecutor
+  public ExecutorService createSendEmailExecutor() {
+    return MoreExecutors.newDirectExecutorService();
+  }
+
+  @Provides
+  @Singleton
+  InMemoryDatabase getInMemoryDatabase(SchemaCreator schemaCreator) throws OrmException {
+    return new InMemoryDatabase(schemaCreator);
+  }
+
+  private Module luceneIndexModule() {
+    return indexModule("com.google.gerrit.lucene.LuceneIndexModule");
+  }
+
+  private Module elasticIndexModule() {
+    return indexModule("com.google.gerrit.elasticsearch.ElasticIndexModule");
+  }
+
+  private Module indexModule(String moduleClassName) {
+    try {
+      Class<?> clazz = Class.forName(moduleClassName);
+      Method m = clazz.getMethod("singleVersionWithExplicitVersions", Map.class, int.class);
+      return (Module) m.invoke(null, getSingleSchemaVersions(), 0);
+    } catch (ClassNotFoundException
+        | SecurityException
+        | NoSuchMethodException
+        | IllegalArgumentException
+        | IllegalAccessException
+        | InvocationTargetException e) {
+      e.printStackTrace();
+      ProvisionException pe = new ProvisionException(e.getMessage());
+      pe.initCause(e);
+      throw pe;
+    }
+  }
+
+  private Map<String, Integer> getSingleSchemaVersions() {
+    Map<String, Integer> singleVersions = new HashMap<>();
+    putSchemaVersion(singleVersions, AccountSchemaDefinitions.INSTANCE);
+    putSchemaVersion(singleVersions, ChangeSchemaDefinitions.INSTANCE);
+    putSchemaVersion(singleVersions, GroupSchemaDefinitions.INSTANCE);
+    putSchemaVersion(singleVersions, ProjectSchemaDefinitions.INSTANCE);
+    return singleVersions;
+  }
+
+  private void putSchemaVersion(
+      Map<String, Integer> singleVersions, SchemaDefinitions<?> schemaDef) {
+    String schemaName = schemaDef.getName();
+    int version = cfg.getInt("index", "lucene", schemaName + "TestVersion", -1);
+    if (version > 0) {
+      checkState(
+          !singleVersions.containsKey(schemaName),
+          "version for schema %s was alreay set",
+          schemaName);
+      singleVersions.put(schemaName, version);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
new file mode 100644
index 0000000..e44d8d38
--- /dev/null
+++ b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
@@ -0,0 +1,127 @@
+// 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.testing;
+
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.SortedSet;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepository;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+
+/** Repository manager that uses in-memory repositories. */
+public class InMemoryRepositoryManager implements GitRepositoryManager {
+  public static InMemoryRepository newRepository(Project.NameKey name) {
+    return new Repo(name);
+  }
+
+  public static class Description extends DfsRepositoryDescription {
+    private final Project.NameKey name;
+
+    private Description(Project.NameKey name) {
+      super(name.get());
+      this.name = name;
+    }
+
+    public Project.NameKey getProject() {
+      return name;
+    }
+  }
+
+  public static class Repo extends InMemoryRepository {
+    private String description;
+
+    private Repo(Project.NameKey name) {
+      super(new Description(name));
+      setPerformsAtomicTransactions(true);
+    }
+
+    @Override
+    public Description getDescription() {
+      return (Description) super.getDescription();
+    }
+
+    @Override
+    public String getGitwebDescription() {
+      return description;
+    }
+
+    @Override
+    public void setGitwebDescription(String d) {
+      description = d;
+    }
+  }
+
+  private final Map<String, Repo> repos;
+
+  @Inject
+  public InMemoryRepositoryManager() {
+    this.repos = new HashMap<>();
+  }
+
+  @Override
+  public synchronized Repo openRepository(Project.NameKey name) throws RepositoryNotFoundException {
+    return get(name);
+  }
+
+  @Override
+  public synchronized Repo createRepository(Project.NameKey name)
+      throws RepositoryCaseMismatchException, RepositoryNotFoundException {
+    Repo repo;
+    try {
+      repo = get(name);
+      if (!repo.getDescription().getRepositoryName().equals(name.get())) {
+        throw new RepositoryCaseMismatchException(name);
+      }
+    } catch (RepositoryNotFoundException e) {
+      repo = new Repo(name);
+      repos.put(normalize(name), repo);
+    }
+    return repo;
+  }
+
+  @Override
+  public synchronized SortedSet<Project.NameKey> list() {
+    SortedSet<Project.NameKey> names = Sets.newTreeSet();
+    for (DfsRepository repo : repos.values()) {
+      names.add(new Project.NameKey(repo.getDescription().getRepositoryName()));
+    }
+    return ImmutableSortedSet.copyOf(names);
+  }
+
+  public synchronized void deleteRepository(Project.NameKey name) {
+    repos.remove(normalize(name));
+  }
+
+  private synchronized Repo get(Project.NameKey name) throws RepositoryNotFoundException {
+    Repo repo = repos.get(normalize(name));
+    if (repo != null) {
+      repo.incrementOpen();
+      return repo;
+    }
+    throw new RepositoryNotFoundException(name.get());
+  }
+
+  private static String normalize(Project.NameKey name) {
+    return name.get().toLowerCase();
+  }
+}
diff --git a/java/com/google/gerrit/testing/IndexVersions.java b/java/com/google/gerrit/testing/IndexVersions.java
new file mode 100644
index 0000000..fde93b2
--- /dev/null
+++ b/java/com/google/gerrit/testing/IndexVersions.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.stream.Collectors.toMap;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaDefinitions;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import org.eclipse.jgit.lib.Config;
+
+public class IndexVersions {
+  static final String ALL = "all";
+  static final String CURRENT = "current";
+  static final String PREVIOUS = "previous";
+
+  /**
+   * Returns the index versions from {@link IndexVersions#get(SchemaDefinitions)} without the latest
+   * schema version.
+   *
+   * @param schemaDef the schema definition
+   * @return the index versions from {@link IndexVersions#get(SchemaDefinitions)} without the latest
+   *     schema version
+   */
+  public static <V> ImmutableList<Integer> getWithoutLatest(SchemaDefinitions<V> schemaDef) {
+    List<Integer> schemaVersions = new ArrayList<>(get(schemaDef));
+    schemaVersions.remove(Integer.valueOf(schemaDef.getLatest().getVersion()));
+    return ImmutableList.copyOf(schemaVersions);
+  }
+
+  /**
+   * Returns the schema versions against which the query tests should be executed.
+   *
+   * <p>The schema versions are read from the '<schema-name>_INDEX_VERSIONS' env var if it is set,
+   * e.g. 'ACCOUNTS_INDEX_VERSIONS', 'CHANGES_INDEX_VERSIONS', 'GROUPS_INDEX_VERSIONS'.
+   *
+   * <p>If schema versions were not specified by an env var, they are read from the
+   * 'gerrit.index.<schema-name>.versions' system property, e.g. 'gerrit.index.accounts.version',
+   * 'gerrit.index.changes.version', 'gerrit.index.groups.version'.
+   *
+   * <p>As value a comma-separated list of schema versions is expected. {@code current} can be used
+   * for the latest schema version and {@code previous} is resolved to the second last schema
+   * version. Alternatively the value can also be {@code all} for all schema versions.
+   *
+   * <p>If schema versions were neither specified by an env var nor by a system property, the
+   * current and the second last schema versions are returned. If there is no other schema version
+   * than the current schema version, only the current schema version is returned.
+   *
+   * @param schemaDef the schema definition
+   * @return the schema versions against which the query tests should be executed
+   * @throws IllegalArgumentException if the value of the env var or system property is invalid or
+   *     if any of the specified schema versions doesn't exist
+   */
+  public static <V> ImmutableList<Integer> get(SchemaDefinitions<V> schemaDef) {
+    String envVar = schemaDef.getName().toUpperCase() + "_INDEX_VERSIONS";
+    String value = System.getenv(envVar);
+    if (!Strings.isNullOrEmpty(value)) {
+      return get(schemaDef, "env variable " + envVar, value);
+    }
+
+    String systemProperty = "gerrit.index." + schemaDef.getName().toLowerCase() + ".versions";
+    value = System.getProperty(systemProperty);
+    return get(schemaDef, "system property " + systemProperty, value);
+  }
+
+  @VisibleForTesting
+  static <V> ImmutableList<Integer> get(SchemaDefinitions<V> schemaDef, String name, String value) {
+    if (value != null) {
+      value = value.trim();
+    }
+
+    SortedMap<Integer, Schema<V>> schemas = schemaDef.getSchemas();
+    if (!Strings.isNullOrEmpty(value)) {
+      if (ALL.equals(value)) {
+        return ImmutableList.copyOf(schemas.keySet());
+      }
+
+      List<Integer> versions = new ArrayList<>();
+      for (String s : Splitter.on(',').trimResults().split(value)) {
+        if (CURRENT.equals(s)) {
+          versions.add(schemaDef.getLatest().getVersion());
+        } else if (PREVIOUS.equals(s)) {
+          checkArgument(schemaDef.getPrevious() != null, "previous version does not exist");
+          versions.add(schemaDef.getPrevious().getVersion());
+        } else {
+          Integer version = Ints.tryParse(s);
+          checkArgument(version != null, "Invalid value for %s: %s", name, s);
+          checkArgument(
+              schemas.containsKey(version),
+              "Index version %s that was specified by %s not found." + " Possible versions are: %s",
+              version,
+              name,
+              schemas.keySet());
+          versions.add(version);
+        }
+      }
+      return ImmutableList.copyOf(versions);
+    }
+
+    List<Integer> schemaVersions = new ArrayList<>(2);
+    if (schemaDef.getPrevious() != null) {
+      schemaVersions.add(schemaDef.getPrevious().getVersion());
+    }
+    schemaVersions.add(schemaDef.getLatest().getVersion());
+    return ImmutableList.copyOf(schemaVersions);
+  }
+
+  public static <V> Map<String, Config> asConfigMap(
+      SchemaDefinitions<V> schemaDef,
+      List<Integer> schemaVersions,
+      String testSuiteNamePrefix,
+      Config baseConfig) {
+    return schemaVersions
+        .stream()
+        .collect(
+            toMap(
+                i -> testSuiteNamePrefix + i,
+                i -> {
+                  Config cfg = baseConfig;
+                  cfg.setInt(
+                      "index", "lucene", schemaDef.getName().toLowerCase() + "TestVersion", i);
+                  return cfg;
+                }));
+  }
+}
diff --git a/java/com/google/gerrit/testing/NoteDbChecker.java b/java/com/google/gerrit/testing/NoteDbChecker.java
new file mode 100644
index 0000000..77d581b
--- /dev/null
+++ b/java/com/google/gerrit/testing/NoteDbChecker.java
@@ -0,0 +1,205 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Joiner;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.MutableNotesMigration;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
+import com.google.gwtorm.client.IntKey;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.runner.Description;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class NoteDbChecker {
+  static final Logger log = LoggerFactory.getLogger(NoteDbChecker.class);
+
+  private final Provider<ReviewDb> dbProvider;
+  private final GitRepositoryManager repoManager;
+  private final MutableNotesMigration notesMigration;
+  private final ChangeBundleReader bundleReader;
+  private final ChangeNotes.Factory notesFactory;
+  private final ChangeRebuilder changeRebuilder;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  NoteDbChecker(
+      Provider<ReviewDb> dbProvider,
+      GitRepositoryManager repoManager,
+      MutableNotesMigration notesMigration,
+      ChangeBundleReader bundleReader,
+      ChangeNotes.Factory notesFactory,
+      ChangeRebuilder changeRebuilder,
+      CommentsUtil commentsUtil) {
+    this.dbProvider = dbProvider;
+    this.repoManager = repoManager;
+    this.bundleReader = bundleReader;
+    this.notesMigration = notesMigration;
+    this.notesFactory = notesFactory;
+    this.changeRebuilder = changeRebuilder;
+    this.commentsUtil = commentsUtil;
+  }
+
+  public void rebuildAndCheckAllChanges() throws Exception {
+    rebuildAndCheckChanges(getUnwrappedDb().changes().all().toList().stream().map(Change::getId));
+  }
+
+  public void rebuildAndCheckChanges(Change.Id... changeIds) throws Exception {
+    rebuildAndCheckChanges(Arrays.stream(changeIds));
+  }
+
+  private void rebuildAndCheckChanges(Stream<Change.Id> changeIds) throws Exception {
+    ReviewDb db = getUnwrappedDb();
+
+    List<ChangeBundle> allExpected = readExpected(changeIds);
+
+    boolean oldWrite = notesMigration.rawWriteChangesSetting();
+    boolean oldRead = notesMigration.readChanges();
+    try {
+      notesMigration.setWriteChanges(true);
+      notesMigration.setReadChanges(true);
+      List<String> msgs = new ArrayList<>();
+      for (ChangeBundle expected : allExpected) {
+        Change c = expected.getChange();
+        try {
+          changeRebuilder.rebuild(db, c.getId());
+        } catch (RepositoryNotFoundException e) {
+          msgs.add("Repository not found for change, cannot convert: " + c);
+        }
+      }
+
+      checkActual(allExpected, msgs);
+    } finally {
+      notesMigration.setReadChanges(oldRead);
+      notesMigration.setWriteChanges(oldWrite);
+    }
+  }
+
+  public void checkChanges(Change.Id... changeIds) throws Exception {
+    checkActual(readExpected(Arrays.stream(changeIds)), new ArrayList<>());
+  }
+
+  public void assertNoChangeRef(Project.NameKey project, Change.Id changeId) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef(RefNames.changeMetaRef(changeId))).isNull();
+    }
+  }
+
+  public void assertNoReviewDbChanges(Description desc) throws Exception {
+    ReviewDb db = getUnwrappedDb();
+    assertThat(db.changes().all().toList()).named("Changes in " + desc.getTestClass()).isEmpty();
+    assertThat(db.changeMessages().all().toList())
+        .named("ChangeMessages in " + desc.getTestClass())
+        .isEmpty();
+    assertThat(db.patchSets().all().toList())
+        .named("PatchSets in " + desc.getTestClass())
+        .isEmpty();
+    assertThat(db.patchSetApprovals().all().toList())
+        .named("PatchSetApprovals in " + desc.getTestClass())
+        .isEmpty();
+    assertThat(db.patchComments().all().toList())
+        .named("PatchLineComments in " + desc.getTestClass())
+        .isEmpty();
+  }
+
+  private List<ChangeBundle> readExpected(Stream<Change.Id> changeIds) throws Exception {
+    boolean old = notesMigration.readChanges();
+    try {
+      notesMigration.setReadChanges(false);
+      return changeIds
+          .sorted(comparing(IntKey::get))
+          .map(this::readBundleUnchecked)
+          .collect(toList());
+    } finally {
+      notesMigration.setReadChanges(old);
+    }
+  }
+
+  private ChangeBundle readBundleUnchecked(Change.Id id) {
+    try {
+      return bundleReader.fromReviewDb(getUnwrappedDb(), id);
+    } catch (OrmException e) {
+      throw new OrmRuntimeException(e);
+    }
+  }
+
+  private void checkActual(List<ChangeBundle> allExpected, List<String> msgs) throws Exception {
+    ReviewDb db = getUnwrappedDb();
+    boolean oldRead = notesMigration.readChanges();
+    boolean oldWrite = notesMigration.rawWriteChangesSetting();
+    try {
+      notesMigration.setWriteChanges(true);
+      notesMigration.setReadChanges(true);
+      for (ChangeBundle expected : allExpected) {
+        Change c = expected.getChange();
+        ChangeBundle actual;
+        try {
+          actual =
+              ChangeBundle.fromNotes(
+                  commentsUtil, notesFactory.create(db, c.getProject(), c.getId()));
+        } catch (Throwable t) {
+          String msg = "Error converting change: " + c;
+          msgs.add(msg);
+          log.error(msg, t);
+          continue;
+        }
+        List<String> diff = expected.differencesFrom(actual);
+        if (!diff.isEmpty()) {
+          msgs.add("Differences between ReviewDb and NoteDb for " + c + ":");
+          msgs.addAll(diff);
+          msgs.add("");
+        } else {
+          System.err.println("NoteDb conversion of change " + c.getId() + " successful");
+        }
+      }
+    } finally {
+      notesMigration.setReadChanges(oldRead);
+      notesMigration.setWriteChanges(oldWrite);
+    }
+    if (!msgs.isEmpty()) {
+      throw new AssertionError(Joiner.on('\n').join(msgs));
+    }
+  }
+
+  private ReviewDb getUnwrappedDb() {
+    ReviewDb db = dbProvider.get();
+    return ReviewDbUtil.unwrapDb(db);
+  }
+}
diff --git a/java/com/google/gerrit/testing/NoteDbMode.java b/java/com/google/gerrit/testing/NoteDbMode.java
new file mode 100644
index 0000000..d4a7c7e
--- /dev/null
+++ b/java/com/google/gerrit/testing/NoteDbMode.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Enums;
+import com.google.common.base.Strings;
+import com.google.gerrit.server.notedb.MutableNotesMigration;
+import com.google.gerrit.server.notedb.NotesMigrationState;
+
+public enum NoteDbMode {
+  /** NoteDb is disabled. */
+  OFF(NotesMigrationState.REVIEW_DB),
+
+  /** Writing data to NoteDb is enabled. */
+  WRITE(NotesMigrationState.WRITE),
+
+  /** Reading and writing all data to NoteDb is enabled. */
+  READ_WRITE(NotesMigrationState.READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY),
+
+  /** Changes are created with their primary storage as NoteDb. */
+  PRIMARY(NotesMigrationState.READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY),
+
+  /** All change tables are entirely disabled, and code/meta ref updates are fused. */
+  ON(NotesMigrationState.NOTE_DB),
+
+  /**
+   * Run tests with NoteDb disabled, then convert ReviewDb to NoteDb and check that the results
+   * match.
+   */
+  CHECK(NotesMigrationState.REVIEW_DB);
+
+  private static final String ENV_VAR = "GERRIT_NOTEDB";
+  private static final String SYS_PROP = "gerrit.notedb";
+
+  public static NoteDbMode get() {
+    String value = System.getenv(ENV_VAR);
+    if (Strings.isNullOrEmpty(value)) {
+      value = System.getProperty(SYS_PROP);
+    }
+    if (Strings.isNullOrEmpty(value)) {
+      return OFF;
+    }
+    value = value.toUpperCase().replace("-", "_");
+    NoteDbMode mode = Enums.getIfPresent(NoteDbMode.class, value).orNull();
+    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
+      checkArgument(
+          mode != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
+    } else {
+      checkArgument(
+          mode != null,
+          "Invalid value for system property %s: %s",
+          SYS_PROP,
+          System.getProperty(SYS_PROP));
+    }
+    return mode;
+  }
+
+  public static MutableNotesMigration newNotesMigrationFromEnv() {
+    MutableNotesMigration m = MutableNotesMigration.newDisabled();
+    resetFromEnv(m);
+    return m;
+  }
+
+  public static void resetFromEnv(MutableNotesMigration migration) {
+    migration.setFrom(get().state);
+  }
+
+  private final NotesMigrationState state;
+
+  private NoteDbMode(NotesMigrationState state) {
+    this.state = state;
+  }
+}
diff --git a/java/com/google/gerrit/testing/SchemaUpgradeTestEnvironment.java b/java/com/google/gerrit/testing/SchemaUpgradeTestEnvironment.java
new file mode 100644
index 0000000..23f66bf
--- /dev/null
+++ b/java/com/google/gerrit/testing/SchemaUpgradeTestEnvironment.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import org.eclipse.jgit.lib.Config;
+import org.junit.rules.MethodRule;
+import org.junit.runners.model.FrameworkMethod;
+import org.junit.runners.model.Statement;
+
+public final class SchemaUpgradeTestEnvironment implements MethodRule {
+  private final Provider<Config> configProvider;
+
+  @Inject private AccountManager accountManager;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+  @Inject private SchemaFactory<ReviewDb> schemaFactory;
+  @Inject private SchemaCreator schemaCreator;
+  @Inject private ThreadLocalRequestContext requestContext;
+  // Only for use in setting up/tearing down injector.
+  @Inject private InMemoryDatabase inMemoryDatabase;
+
+  private ReviewDb db;
+  private LifecycleManager lifecycle;
+
+  /** Create a test environment using an empty base config. */
+  public SchemaUpgradeTestEnvironment() {
+    this(Config::new);
+  }
+
+  /**
+   * Create a test environment using the specified base config.
+   *
+   * <p>The config is passed as a provider so it can be lazily initialized after this rule is
+   * instantiated, for example using {@link ConfigSuite}.
+   *
+   * @param configProvider possibly-lazy provider for the base config.
+   */
+  public SchemaUpgradeTestEnvironment(Provider<Config> configProvider) {
+    this.configProvider = configProvider;
+  }
+
+  @Override
+  public Statement apply(Statement base, FrameworkMethod method, Object target) {
+    return new Statement() {
+      @Override
+      public void evaluate() throws Throwable {
+        try {
+          setUp(target);
+          base.evaluate();
+        } finally {
+          tearDown();
+        }
+      }
+    };
+  }
+
+  public void setApiUser(Account.Id id) {
+    IdentifiedUser user = userFactory.create(id);
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return user;
+          }
+
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
+  }
+
+  private void setUp(Object target) throws Exception {
+    Config cfg = configProvider.get();
+    InMemoryModule.setDefaults(cfg);
+
+    Injector injector =
+        Guice.createInjector(new InMemoryModule(cfg, NoteDbMode.newNotesMigrationFromEnv()));
+    injector.injectMembers(this);
+    lifecycle = new LifecycleManager();
+    lifecycle.add(injector);
+    lifecycle.start();
+
+    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
+      schemaCreator.create(underlyingDb);
+    }
+    db = schemaFactory.open();
+    setApiUser(accountManager.authenticate(AuthRequest.forUser("user")).getAccountId());
+
+    // Inject target members after setting API user, so it can @Inject a ReviewDb if it wants.
+    injector.injectMembers(target);
+  }
+
+  private void tearDown() {
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    if (requestContext != null) {
+      requestContext.setContext(null);
+    }
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(inMemoryDatabase);
+  }
+}
diff --git a/java/com/google/gerrit/testing/SshMode.java b/java/com/google/gerrit/testing/SshMode.java
new file mode 100644
index 0000000..41633bd
--- /dev/null
+++ b/java/com/google/gerrit/testing/SshMode.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Enums;
+import com.google.common.base.Strings;
+
+/**
+ * Whether to enable/disable tests using SSH by inspecting the global environment.
+ *
+ * <p>Acceptance tests should generally not inspect this directly, since SSH may also be disabled on
+ * a per-class or per-method basis. Inject {@code @SshEnabled boolean} instead.
+ */
+public enum SshMode {
+  /** Tests annotated with UseSsh will be disabled. */
+  NO,
+
+  /** Tests annotated with UseSsh will be enabled. */
+  YES;
+
+  private static final String ENV_VAR = "GERRIT_USE_SSH";
+  private static final String SYS_PROP = "gerrit.use.ssh";
+
+  public static SshMode get() {
+    String value = System.getenv(ENV_VAR);
+    if (Strings.isNullOrEmpty(value)) {
+      value = System.getProperty(SYS_PROP);
+    }
+    if (Strings.isNullOrEmpty(value)) {
+      return YES;
+    }
+    value = value.toUpperCase();
+    SshMode mode = Enums.getIfPresent(SshMode.class, value).orNull();
+    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
+      checkArgument(
+          mode != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
+    } else {
+      checkArgument(
+          mode != null,
+          "Invalid value for system property %s: %s",
+          SYS_PROP,
+          System.getProperty(SYS_PROP));
+    }
+    return mode;
+  }
+
+  public static boolean useSsh() {
+    return get() == YES;
+  }
+}
diff --git a/java/com/google/gerrit/testing/TempFileUtil.java b/java/com/google/gerrit/testing/TempFileUtil.java
new file mode 100644
index 0000000..c42bd74
--- /dev/null
+++ b/java/com/google/gerrit/testing/TempFileUtil.java
@@ -0,0 +1,66 @@
+// 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.testing;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+public class TempFileUtil {
+  private static List<File> allDirsCreated = new ArrayList<>();
+
+  public static synchronized File createTempDirectory() throws IOException {
+    File tmp = File.createTempFile("gerrit_test_", "").getCanonicalFile();
+    if (!tmp.delete() || !tmp.mkdir()) {
+      throw new IOException("Cannot create " + tmp.getPath());
+    }
+    allDirsCreated.add(tmp);
+    return tmp;
+  }
+
+  public static synchronized void cleanup() throws IOException {
+    for (File dir : allDirsCreated) {
+      recursivelyDelete(dir);
+    }
+    allDirsCreated.clear();
+  }
+
+  public static void recursivelyDelete(File dir) throws IOException {
+    if (!dir.getPath().equals(dir.getCanonicalPath())) {
+      // Directory symlink reaching outside of temporary space.
+      return;
+    }
+    File[] contents = dir.listFiles();
+    if (contents != null) {
+      for (File d : contents) {
+        if (d.isDirectory()) {
+          recursivelyDelete(d);
+        } else {
+          deleteNowOrOnExit(d);
+        }
+      }
+    }
+    deleteNowOrOnExit(dir);
+  }
+
+  private static void deleteNowOrOnExit(File dir) {
+    if (!dir.delete()) {
+      dir.deleteOnExit();
+    }
+  }
+
+  private TempFileUtil() {}
+}
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
new file mode 100644
index 0000000..31ef805
--- /dev/null
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+
+import com.google.common.collect.Ordering;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.AbstractChangeNotes;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.inject.Injector;
+import java.util.TimeZone;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Utility functions to create and manipulate Change, ChangeUpdate, and ChangeControl objects for
+ * testing.
+ */
+public class TestChanges {
+  private static final AtomicInteger nextChangeId = new AtomicInteger(1);
+
+  public static Change newChange(Project.NameKey project, Account.Id userId) {
+    return newChange(project, userId, nextChangeId.getAndIncrement());
+  }
+
+  public static Change newChange(Project.NameKey project, Account.Id userId, int id) {
+    Change.Id changeId = new Change.Id(id);
+    Change c =
+        new Change(
+            new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
+            changeId,
+            userId,
+            new Branch.NameKey(project, "master"),
+            TimeUtil.nowTs());
+    incrementPatchSet(c);
+    return c;
+  }
+
+  public static PatchSet newPatchSet(PatchSet.Id id, ObjectId revision, Account.Id userId) {
+    return newPatchSet(id, revision.name(), userId);
+  }
+
+  public static PatchSet newPatchSet(PatchSet.Id id, String revision, Account.Id userId) {
+    PatchSet ps = new PatchSet(id);
+    ps.setRevision(new RevId(revision));
+    ps.setUploader(userId);
+    ps.setCreatedOn(TimeUtil.nowTs());
+    return ps;
+  }
+
+  public static ChangeUpdate newUpdate(Injector injector, Change c, CurrentUser user)
+      throws Exception {
+    injector =
+        injector.createChildInjector(
+            new FactoryModule() {
+              @Override
+              public void configure() {
+                bind(CurrentUser.class).toInstance(user);
+              }
+            });
+    ChangeUpdate update =
+        injector
+            .getInstance(ChangeUpdate.Factory.class)
+            .create(
+                new ChangeNotes(injector.getInstance(AbstractChangeNotes.Args.class), c).load(),
+                user,
+                TimeUtil.nowTs(),
+                Ordering.<String>natural());
+
+    ChangeNotes notes = update.getNotes();
+    boolean hasPatchSets = notes.getPatchSets() != null && !notes.getPatchSets().isEmpty();
+    NotesMigration migration = injector.getInstance(NotesMigration.class);
+    if (hasPatchSets || !migration.readChanges()) {
+      return update;
+    }
+
+    // Change doesn't exist yet. NoteDb requires that there be a commit for the
+    // first patch set, so create one.
+    GitRepositoryManager repoManager = injector.getInstance(GitRepositoryManager.class);
+    try (Repository repo = repoManager.openRepository(c.getProject())) {
+      TestRepository<Repository> tr = new TestRepository<>(repo);
+      PersonIdent ident =
+          user.asIdentifiedUser().newCommitterIdent(update.getWhen(), TimeZone.getDefault());
+      TestRepository<Repository>.CommitBuilder cb =
+          tr.commit()
+              .author(ident)
+              .committer(ident)
+              .message(firstNonNull(c.getSubject(), "Test change"));
+      Ref parent = repo.exactRef(c.getDest().get());
+      if (parent != null) {
+        cb.parent(tr.getRevWalk().parseCommit(parent.getObjectId()));
+      }
+      update.setBranch(c.getDest().get());
+      update.setChangeId(c.getKey().get());
+      update.setCommit(tr.getRevWalk(), cb.create());
+      return update;
+    }
+  }
+
+  public static void incrementPatchSet(Change change) {
+    PatchSet.Id curr = change.currentPatchSetId();
+    PatchSetInfo ps =
+        new PatchSetInfo(new PatchSet.Id(change.getId(), curr != null ? curr.get() + 1 : 1));
+    ps.setSubject("Change subject");
+    change.setCurrentPatchSet(ps);
+  }
+}
diff --git a/java/com/google/gerrit/testing/TestTimeUtil.java b/java/com/google/gerrit/testing/TestTimeUtil.java
new file mode 100644
index 0000000..1233810
--- /dev/null
+++ b/java/com/google/gerrit/testing/TestTimeUtil.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+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;
+
+/** Static utility methods for dealing with dates and times in tests. */
+public class TestTimeUtil {
+  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;
+
+  /**
+   * Reset the clock to a known start point, then set the clock step.
+   *
+   * <p>The clock is initially set to 2009/09/30 17:00:00 -0400.
+   *
+   * @param clockStep amount to increment clock by on each lookup.
+   * @param clockStepUnit time unit for {@code clockStep}.
+   */
+  public static synchronized void resetWithClockStep(long clockStep, TimeUnit clockStepUnit) {
+    // Set an arbitrary start point so tests are more repeatable.
+    clockMs = new AtomicLong(START.toEpochMilli());
+    setClockStep(clockStep, clockStepUnit);
+  }
+
+  /**
+   * Set the clock step used by {@link com.google.gerrit.common.TimeUtil}.
+   *
+   * @param clockStep amount to increment clock by on each lookup.
+   * @param clockStepUnit time unit for {@code clockStep}.
+   */
+  public static synchronized void setClockStep(long clockStep, TimeUnit clockStepUnit) {
+    checkState(clockMs != null, "call resetWithClockStep first");
+    clockStepMs = MILLISECONDS.convert(clockStep, clockStepUnit);
+    TimeUtil.setCurrentMillisSupplier(() -> clockMs.getAndAdd(clockStepMs));
+  }
+
+  /** {@link AutoCloseable} handle returned by {@link #withClockStep(long, TimeUnit)}. */
+  public static class TempClockStep implements AutoCloseable {
+    private final long oldClockStepMs;
+
+    private TempClockStep(long clockStep, TimeUnit clockStepUnit) {
+      oldClockStepMs = clockStepMs;
+      setClockStep(clockStep, clockStepUnit);
+    }
+
+    @Override
+    public void close() {
+      setClockStep(oldClockStepMs, TimeUnit.MILLISECONDS);
+    }
+  }
+
+  /**
+   * Set a clock step only for the scope of a single try-with-resources block.
+   *
+   * @param clockStep amount to increment clock by on each lookup.
+   * @param clockStepUnit time unit for {@code clockStep}.
+   * @return {@link AutoCloseable} handle which resets the clock step to its old value on close.
+   */
+  public static TempClockStep withClockStep(long clockStep, TimeUnit clockStepUnit) {
+    return new TempClockStep(clockStep, clockStepUnit);
+  }
+
+  /**
+   * Freeze the clock to stop moving only for the scope of a single try-with-resources block.
+   *
+   * @return {@link AutoCloseable} handle which resets the clock step to its old value on close.
+   */
+  public static TempClockStep freezeClock() {
+    return withClockStep(0, TimeUnit.SECONDS);
+  }
+
+  /**
+   * Set the clock to a specific timestamp.
+   *
+   * @param ts time to set
+   */
+  public static synchronized void setClock(Timestamp ts) {
+    checkState(clockMs != null, "call resetWithClockStep first");
+    clockMs.set(ts.getTime());
+  }
+
+  /**
+   * Increment the clock once by a given amount.
+   *
+   * @param clockStep amount to increment clock by.
+   * @param clockStepUnit time unit for {@code clockStep}.
+   */
+  public static synchronized void incrementClock(long clockStep, TimeUnit clockStepUnit) {
+    checkState(clockMs != null, "call resetWithClockStep first");
+    clockMs.addAndGet(clockStepUnit.toMillis(clockStep));
+  }
+
+  /** Reset the clock to use the actual system clock. */
+  public static synchronized void useSystemTime() {
+    clockMs = null;
+    TimeUtil.resetCurrentMillisSupplier();
+  }
+
+  private TestTimeUtil() {}
+}
diff --git a/java/com/google/gerrit/testing/TestUpdateUI.java b/java/com/google/gerrit/testing/TestUpdateUI.java
new file mode 100644
index 0000000..f36fc7e
--- /dev/null
+++ b/java/com/google/gerrit/testing/TestUpdateUI.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import com.google.gerrit.server.schema.UpdateUI;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.StatementExecutor;
+import java.util.List;
+import java.util.Set;
+
+public class TestUpdateUI implements UpdateUI {
+  @Override
+  public void message(String message) {}
+
+  @Override
+  public boolean yesno(boolean defaultValue, String message) {
+    return defaultValue;
+  }
+
+  @Override
+  public void waitForUser() {}
+
+  @Override
+  public String readString(String defaultValue, Set<String> allowedValues, String message) {
+    return defaultValue;
+  }
+
+  @Override
+  public boolean isBatch() {
+    return true;
+  }
+
+  @Override
+  public void pruneSchema(StatementExecutor e, List<String> pruneList) throws OrmException {
+    for (String sql : pruneList) {
+      e.execute(sql);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/truth/BUILD b/java/com/google/gerrit/truth/BUILD
new file mode 100644
index 0000000..a0e2ee9
--- /dev/null
+++ b/java/com/google/gerrit/truth/BUILD
@@ -0,0 +1,9 @@
+java_library(
+    name = "truth",
+    testonly = 1,
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:truth",
+    ],
+)
diff --git a/java/com/google/gerrit/truth/ListSubject.java b/java/com/google/gerrit/truth/ListSubject.java
new file mode 100644
index 0000000..bcd8dcf
--- /dev/null
+++ b/java/com/google/gerrit/truth/ListSubject.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.truth;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.Subject;
+import java.util.List;
+import java.util.function.Function;
+
+public class ListSubject<S extends Subject<S, E>, E> extends IterableSubject {
+
+  private final Function<E, S> elementAssertThatFunction;
+
+  @SuppressWarnings("unchecked")
+  public static <S extends Subject<S, E>, E> ListSubject<S, E> assertThat(
+      List<E> list, Function<E, S> elementAssertThatFunction) {
+    // The ListSubjectFactory always returns ListSubjects.
+    // -> Casting is appropriate.
+    return (ListSubject<S, E>)
+        assertAbout(new ListSubjectFactory<>(elementAssertThatFunction)).that(list);
+  }
+
+  private ListSubject(
+      FailureMetadata failureMetadata, List<E> list, Function<E, S> elementAssertThatFunction) {
+    super(failureMetadata, list);
+    this.elementAssertThatFunction = elementAssertThatFunction;
+  }
+
+  public S element(int index) {
+    checkArgument(index >= 0, "index(%s) must be >= 0", index);
+    // The constructor only accepts lists.
+    // -> Casting is appropriate.
+    @SuppressWarnings("unchecked")
+    List<E> list = (List<E>) actual();
+    isNotNull();
+    if (index >= list.size()) {
+      fail("has an element at index " + index);
+    }
+    return elementAssertThatFunction.apply(list.get(index));
+  }
+
+  public S onlyElement() {
+    isNotNull();
+    hasSize(1);
+    return element(0);
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public ListSubject<S, E> named(String s, Object... objects) {
+    // This object is returned which is of type ListSubject.
+    // -> Casting is appropriate.
+    return (ListSubject<S, E>) super.named(s, objects);
+  }
+
+  private static class ListSubjectFactory<S extends Subject<S, T>, T>
+      implements Subject.Factory<IterableSubject, Iterable<?>> {
+
+    private Function<T, S> elementAssertThatFunction;
+
+    ListSubjectFactory(Function<T, S> elementAssertThatFunction) {
+      this.elementAssertThatFunction = elementAssertThatFunction;
+    }
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public ListSubject<S, T> createSubject(FailureMetadata failureMetadata, Iterable<?> objects) {
+      // The constructor of ListSubject only accepts lists.
+      // -> Casting is appropriate.
+      return new ListSubject<>(failureMetadata, (List<T>) objects, elementAssertThatFunction);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/truth/OptionalSubject.java b/java/com/google/gerrit/truth/OptionalSubject.java
new file mode 100644
index 0000000..f24b5da
--- /dev/null
+++ b/java/com/google/gerrit/truth/OptionalSubject.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.truth;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.DefaultSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import java.util.Optional;
+import java.util.function.Function;
+
+public class OptionalSubject<S extends Subject<S, ? super T>, T>
+    extends Subject<OptionalSubject<S, T>, Optional<T>> {
+
+  private final Function<? super T, ? extends S> valueAssertThatFunction;
+
+  public static <S extends Subject<S, ? super T>, T> OptionalSubject<S, T> assertThat(
+      Optional<T> optional, Function<? super T, ? extends S> elementAssertThatFunction) {
+    OptionalSubjectFactory<S, T> optionalSubjectFactory =
+        new OptionalSubjectFactory<>(elementAssertThatFunction);
+    return assertAbout(optionalSubjectFactory).that(optional);
+  }
+
+  public static OptionalSubject<DefaultSubject, ?> assertThat(Optional<?> optional) {
+    // Unfortunately, we need to cast to DefaultSubject as Truth.assertThat()
+    // only returns Subject<DefaultSubject, Object>. There shouldn't be a way
+    // for that method not to return a DefaultSubject because the generic type
+    // definitions of a Subject are quite strict.
+    Function<Object, DefaultSubject> valueAssertThatFunction =
+        value -> (DefaultSubject) Truth.assertThat(value);
+    return assertThat(optional, valueAssertThatFunction);
+  }
+
+  private OptionalSubject(
+      FailureMetadata failureMetadata,
+      Optional<T> optional,
+      Function<? super T, ? extends S> valueAssertThatFunction) {
+    super(failureMetadata, optional);
+    this.valueAssertThatFunction = valueAssertThatFunction;
+  }
+
+  public void isPresent() {
+    isNotNull();
+    Optional<T> optional = actual();
+    if (!optional.isPresent()) {
+      fail("has a value");
+    }
+  }
+
+  public void isAbsent() {
+    isNotNull();
+    Optional<T> optional = actual();
+    if (optional.isPresent()) {
+      fail("does not have a value");
+    }
+  }
+
+  public void isEmpty() {
+    isAbsent();
+  }
+
+  public S value() {
+    isNotNull();
+    isPresent();
+    Optional<T> optional = actual();
+    return valueAssertThatFunction.apply(optional.get());
+  }
+
+  private static class OptionalSubjectFactory<S extends Subject<S, ? super T>, T>
+      implements Subject.Factory<OptionalSubject<S, T>, Optional<T>> {
+
+    private Function<? super T, ? extends S> valueAssertThatFunction;
+
+    OptionalSubjectFactory(Function<? super T, ? extends S> valueAssertThatFunction) {
+      this.valueAssertThatFunction = valueAssertThatFunction;
+    }
+
+    @Override
+    public OptionalSubject<S, T> createSubject(
+        FailureMetadata failureMetadata, Optional<T> optional) {
+      return new OptionalSubject<>(failureMetadata, optional, valueAssertThatFunction);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/util/cli/BUILD b/java/com/google/gerrit/util/cli/BUILD
new file mode 100644
index 0000000..91c14f7
--- /dev/null
+++ b/java/com/google/gerrit/util/cli/BUILD
@@ -0,0 +1,13 @@
+java_library(
+    name = "cli",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//lib:args4j",
+        "//lib:guava",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+    ],
+)
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
similarity index 100%
rename from gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
rename to java/com/google/gerrit/util/cli/CmdLineParser.java
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/EndOfOptionsHandler.java b/java/com/google/gerrit/util/cli/EndOfOptionsHandler.java
similarity index 100%
rename from gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/EndOfOptionsHandler.java
rename to java/com/google/gerrit/util/cli/EndOfOptionsHandler.java
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerFactory.java b/java/com/google/gerrit/util/cli/OptionHandlerFactory.java
similarity index 100%
rename from gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerFactory.java
rename to java/com/google/gerrit/util/cli/OptionHandlerFactory.java
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerUtil.java b/java/com/google/gerrit/util/cli/OptionHandlerUtil.java
similarity index 100%
rename from gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerUtil.java
rename to java/com/google/gerrit/util/cli/OptionHandlerUtil.java
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlers.java b/java/com/google/gerrit/util/cli/OptionHandlers.java
similarity index 100%
rename from gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlers.java
rename to java/com/google/gerrit/util/cli/OptionHandlers.java
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/Options.java b/java/com/google/gerrit/util/cli/Options.java
similarity index 100%
rename from gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/Options.java
rename to java/com/google/gerrit/util/cli/Options.java
diff --git a/java/com/google/gerrit/util/http/BUILD b/java/com/google/gerrit/util/http/BUILD
new file mode 100644
index 0000000..30d3adc
--- /dev/null
+++ b/java/com/google/gerrit/util/http/BUILD
@@ -0,0 +1,6 @@
+java_library(
+    name = "http",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = ["//lib:servlet-api-3_1"],
+)
diff --git a/gerrit-util-http/src/main/java/com/google/gerrit/util/http/RequestUtil.java b/java/com/google/gerrit/util/http/RequestUtil.java
similarity index 100%
rename from gerrit-util-http/src/main/java/com/google/gerrit/util/http/RequestUtil.java
rename to java/com/google/gerrit/util/http/RequestUtil.java
diff --git a/java/com/google/gerrit/util/ssl/BUILD b/java/com/google/gerrit/util/ssl/BUILD
new file mode 100644
index 0000000..4f65b61
--- /dev/null
+++ b/java/com/google/gerrit/util/ssl/BUILD
@@ -0,0 +1,5 @@
+java_library(
+    name = "ssl",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+)
diff --git a/gerrit-util-ssl/src/main/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java b/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
similarity index 100%
rename from gerrit-util-ssl/src/main/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
rename to java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
diff --git a/java/com/google/gwtexpui/clippy/BUILD b/java/com/google/gwtexpui/clippy/BUILD
new file mode 100644
index 0000000..80b6767
--- /dev/null
+++ b/java/com/google/gwtexpui/clippy/BUILD
@@ -0,0 +1,23 @@
+load("//tools/bzl:gwt.bzl", "gwt_module")
+
+gwt_module(
+    name = "clippy",
+    srcs = glob(["client/*.java"]),
+    data = [
+        "//lib:LICENSE-clippy",
+        "//lib:LICENSE-silk_icons",
+    ],
+    gwt_xml = "Clippy.gwt.xml",
+    resources = [
+        "client/CopyableLabelText.properties",
+        "client/clippy.css",
+        "client/clippy.swf",
+        "client/page_white_copy.png",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gwtexpui/safehtml",
+        "//java/com/google/gwtexpui/user:agent",
+        "//lib/gwt:user-neverlink",
+    ],
+)
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/Clippy.gwt.xml b/java/com/google/gwtexpui/clippy/Clippy.gwt.xml
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/Clippy.gwt.xml
rename to java/com/google/gwtexpui/clippy/Clippy.gwt.xml
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyCss.java b/java/com/google/gwtexpui/clippy/client/ClippyCss.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyCss.java
rename to java/com/google/gwtexpui/clippy/client/ClippyCss.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyResources.java b/java/com/google/gwtexpui/clippy/client/ClippyResources.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyResources.java
rename to java/com/google/gwtexpui/clippy/client/ClippyResources.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabel.java b/java/com/google/gwtexpui/clippy/client/CopyableLabel.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabel.java
rename to java/com/google/gwtexpui/clippy/client/CopyableLabel.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabelText.java b/java/com/google/gwtexpui/clippy/client/CopyableLabelText.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabelText.java
rename to java/com/google/gwtexpui/clippy/client/CopyableLabelText.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabelText.properties b/java/com/google/gwtexpui/clippy/client/CopyableLabelText.properties
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabelText.properties
rename to java/com/google/gwtexpui/clippy/client/CopyableLabelText.properties
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/clippy.css b/java/com/google/gwtexpui/clippy/client/clippy.css
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/clippy.css
rename to java/com/google/gwtexpui/clippy/client/clippy.css
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/clippy.swf b/java/com/google/gwtexpui/clippy/client/clippy.swf
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/clippy.swf
rename to java/com/google/gwtexpui/clippy/client/clippy.swf
Binary files differ
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/page_white_copy.png b/java/com/google/gwtexpui/clippy/client/page_white_copy.png
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/page_white_copy.png
rename to java/com/google/gwtexpui/clippy/client/page_white_copy.png
Binary files differ
diff --git a/java/com/google/gwtexpui/css/BUILD b/java/com/google/gwtexpui/css/BUILD
new file mode 100644
index 0000000..6c2fc71
--- /dev/null
+++ b/java/com/google/gwtexpui/css/BUILD
@@ -0,0 +1,9 @@
+load("//tools/bzl:gwt.bzl", "gwt_module")
+
+java_library(
+    name = "css",
+    srcs = glob(["rebind/*.java"]),
+    resources = ["CSS.gwt.xml"],
+    visibility = ["//visibility:public"],
+    deps = ["//lib/gwt:dev"],
+)
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/css/CSS.gwt.xml b/java/com/google/gwtexpui/css/CSS.gwt.xml
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/css/CSS.gwt.xml
rename to java/com/google/gwtexpui/css/CSS.gwt.xml
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/css/rebind/CssLinker.java b/java/com/google/gwtexpui/css/rebind/CssLinker.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/css/rebind/CssLinker.java
rename to java/com/google/gwtexpui/css/rebind/CssLinker.java
diff --git a/java/com/google/gwtexpui/globalkey/BUILD b/java/com/google/gwtexpui/globalkey/BUILD
new file mode 100644
index 0000000..c637194
--- /dev/null
+++ b/java/com/google/gwtexpui/globalkey/BUILD
@@ -0,0 +1,17 @@
+load("//tools/bzl:gwt.bzl", "gwt_module")
+
+gwt_module(
+    name = "globalkey",
+    srcs = glob(["client/*.java"]),
+    gwt_xml = "GlobalKey.gwt.xml",
+    resources = [
+        "client/KeyConstants.properties",
+        "client/key.css",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gwtexpui/safehtml",
+        "//java/com/google/gwtexpui/user:agent",
+        "//lib/gwt:user",
+    ],
+)
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/GlobalKey.gwt.xml b/java/com/google/gwtexpui/globalkey/GlobalKey.gwt.xml
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/GlobalKey.gwt.xml
rename to java/com/google/gwtexpui/globalkey/GlobalKey.gwt.xml
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/CompoundKeyCommand.java b/java/com/google/gwtexpui/globalkey/client/CompoundKeyCommand.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/CompoundKeyCommand.java
rename to java/com/google/gwtexpui/globalkey/client/CompoundKeyCommand.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/DocWidget.java b/java/com/google/gwtexpui/globalkey/client/DocWidget.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/DocWidget.java
rename to java/com/google/gwtexpui/globalkey/client/DocWidget.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/GlobalKey.java b/java/com/google/gwtexpui/globalkey/client/GlobalKey.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/GlobalKey.java
rename to java/com/google/gwtexpui/globalkey/client/GlobalKey.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/HidePopupPanelCommand.java b/java/com/google/gwtexpui/globalkey/client/HidePopupPanelCommand.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/HidePopupPanelCommand.java
rename to java/com/google/gwtexpui/globalkey/client/HidePopupPanelCommand.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommand.java b/java/com/google/gwtexpui/globalkey/client/KeyCommand.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommand.java
rename to java/com/google/gwtexpui/globalkey/client/KeyCommand.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java b/java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java
rename to java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java b/java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java
rename to java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyConstants.java b/java/com/google/gwtexpui/globalkey/client/KeyConstants.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyConstants.java
rename to java/com/google/gwtexpui/globalkey/client/KeyConstants.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyConstants.properties b/java/com/google/gwtexpui/globalkey/client/KeyConstants.properties
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyConstants.properties
rename to java/com/google/gwtexpui/globalkey/client/KeyConstants.properties
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCss.java b/java/com/google/gwtexpui/globalkey/client/KeyCss.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCss.java
rename to java/com/google/gwtexpui/globalkey/client/KeyCss.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java b/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
rename to java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyResources.java b/java/com/google/gwtexpui/globalkey/client/KeyResources.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyResources.java
rename to java/com/google/gwtexpui/globalkey/client/KeyResources.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/NpTextArea.java b/java/com/google/gwtexpui/globalkey/client/NpTextArea.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/NpTextArea.java
rename to java/com/google/gwtexpui/globalkey/client/NpTextArea.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/NpTextBox.java b/java/com/google/gwtexpui/globalkey/client/NpTextBox.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/NpTextBox.java
rename to java/com/google/gwtexpui/globalkey/client/NpTextBox.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java b/java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java
rename to java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/key.css b/java/com/google/gwtexpui/globalkey/client/key.css
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/key.css
rename to java/com/google/gwtexpui/globalkey/client/key.css
diff --git a/java/com/google/gwtexpui/linker/BUILD b/java/com/google/gwtexpui/linker/BUILD
new file mode 100644
index 0000000..5c5c600
--- /dev/null
+++ b/java/com/google/gwtexpui/linker/BUILD
@@ -0,0 +1,6 @@
+java_library(
+    name = "server",
+    srcs = glob(["server/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = ["//lib:servlet-api-3_1"],
+)
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java b/java/com/google/gwtexpui/linker/server/UserAgentRule.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java
rename to java/com/google/gwtexpui/linker/server/UserAgentRule.java
diff --git a/java/com/google/gwtexpui/progress/BUILD b/java/com/google/gwtexpui/progress/BUILD
new file mode 100644
index 0000000..74caa57
--- /dev/null
+++ b/java/com/google/gwtexpui/progress/BUILD
@@ -0,0 +1,10 @@
+load("//tools/bzl:gwt.bzl", "gwt_module")
+
+gwt_module(
+    name = "progress",
+    srcs = glob(["client/*.java"]),
+    gwt_xml = "Progress.gwt.xml",
+    resources = ["client/progress.css"],
+    visibility = ["//visibility:public"],
+    deps = ["//lib/gwt:user"],
+)
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/Progress.gwt.xml b/java/com/google/gwtexpui/progress/Progress.gwt.xml
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/Progress.gwt.xml
rename to java/com/google/gwtexpui/progress/Progress.gwt.xml
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressBar.java b/java/com/google/gwtexpui/progress/client/ProgressBar.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressBar.java
rename to java/com/google/gwtexpui/progress/client/ProgressBar.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressCss.java b/java/com/google/gwtexpui/progress/client/ProgressCss.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressCss.java
rename to java/com/google/gwtexpui/progress/client/ProgressCss.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressResources.java b/java/com/google/gwtexpui/progress/client/ProgressResources.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressResources.java
rename to java/com/google/gwtexpui/progress/client/ProgressResources.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/progress.css b/java/com/google/gwtexpui/progress/client/progress.css
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/progress.css
rename to java/com/google/gwtexpui/progress/client/progress.css
diff --git a/java/com/google/gwtexpui/safehtml/BUILD b/java/com/google/gwtexpui/safehtml/BUILD
new file mode 100644
index 0000000..af85c33
--- /dev/null
+++ b/java/com/google/gwtexpui/safehtml/BUILD
@@ -0,0 +1,10 @@
+load("//tools/bzl:gwt.bzl", "gwt_module")
+
+gwt_module(
+    name = "safehtml",
+    srcs = glob(["client/*.java"]),
+    gwt_xml = "SafeHtml.gwt.xml",
+    resources = ["client/safehtml.css"],
+    visibility = ["//visibility:public"],
+    deps = ["//lib/gwt:user"],
+)
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/SafeHtml.gwt.xml b/java/com/google/gwtexpui/safehtml/SafeHtml.gwt.xml
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/SafeHtml.gwt.xml
rename to java/com/google/gwtexpui/safehtml/SafeHtml.gwt.xml
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/AttMap.java b/java/com/google/gwtexpui/safehtml/client/AttMap.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/AttMap.java
rename to java/com/google/gwtexpui/safehtml/client/AttMap.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/Buffer.java b/java/com/google/gwtexpui/safehtml/client/Buffer.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/Buffer.java
rename to java/com/google/gwtexpui/safehtml/client/Buffer.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/BufferDirect.java b/java/com/google/gwtexpui/safehtml/client/BufferDirect.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/BufferDirect.java
rename to java/com/google/gwtexpui/safehtml/client/BufferDirect.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/BufferSealElement.java b/java/com/google/gwtexpui/safehtml/client/BufferSealElement.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/BufferSealElement.java
rename to java/com/google/gwtexpui/safehtml/client/BufferSealElement.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/FindReplace.java b/java/com/google/gwtexpui/safehtml/client/FindReplace.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/FindReplace.java
rename to java/com/google/gwtexpui/safehtml/client/FindReplace.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java b/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
rename to java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/LinkFindReplace.java b/java/com/google/gwtexpui/safehtml/client/LinkFindReplace.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/LinkFindReplace.java
rename to java/com/google/gwtexpui/safehtml/client/LinkFindReplace.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/RawFindReplace.java b/java/com/google/gwtexpui/safehtml/client/RawFindReplace.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/RawFindReplace.java
rename to java/com/google/gwtexpui/safehtml/client/RawFindReplace.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtml.java b/java/com/google/gwtexpui/safehtml/client/SafeHtml.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtml.java
rename to java/com/google/gwtexpui/safehtml/client/SafeHtml.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java b/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java
rename to java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlCss.java b/java/com/google/gwtexpui/safehtml/client/SafeHtmlCss.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlCss.java
rename to java/com/google/gwtexpui/safehtml/client/SafeHtmlCss.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlResources.java b/java/com/google/gwtexpui/safehtml/client/SafeHtmlResources.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlResources.java
rename to java/com/google/gwtexpui/safehtml/client/SafeHtmlResources.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java b/java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java
rename to java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/safehtml.css b/java/com/google/gwtexpui/safehtml/client/safehtml.css
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/safehtml.css
rename to java/com/google/gwtexpui/safehtml/client/safehtml.css
diff --git a/java/com/google/gwtexpui/server/BUILD b/java/com/google/gwtexpui/server/BUILD
new file mode 100644
index 0000000..9b81564
--- /dev/null
+++ b/java/com/google/gwtexpui/server/BUILD
@@ -0,0 +1,6 @@
+java_library(
+    name = "server",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = ["//lib:servlet-api-3_1"],
+)
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheControlFilter.java b/java/com/google/gwtexpui/server/CacheControlFilter.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheControlFilter.java
rename to java/com/google/gwtexpui/server/CacheControlFilter.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheHeaders.java b/java/com/google/gwtexpui/server/CacheHeaders.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheHeaders.java
rename to java/com/google/gwtexpui/server/CacheHeaders.java
diff --git a/java/com/google/gwtexpui/user/BUILD b/java/com/google/gwtexpui/user/BUILD
new file mode 100644
index 0000000..813f433
--- /dev/null
+++ b/java/com/google/gwtexpui/user/BUILD
@@ -0,0 +1,10 @@
+load("//tools/bzl:gwt.bzl", "gwt_module")
+
+gwt_module(
+    name = "agent",
+    srcs = glob(["client/*.java"]),
+    gwt_xml = "User.gwt.xml",
+    resources = ["client/tooltip.css"],
+    visibility = ["//visibility:public"],
+    deps = ["//lib/gwt:user"],
+)
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/User.gwt.xml b/java/com/google/gwtexpui/user/User.gwt.xml
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/User.gwt.xml
rename to java/com/google/gwtexpui/user/User.gwt.xml
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java b/java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java
rename to java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/Tooltip.java b/java/com/google/gwtexpui/user/client/Tooltip.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/Tooltip.java
rename to java/com/google/gwtexpui/user/client/Tooltip.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/UserAgent.java b/java/com/google/gwtexpui/user/client/UserAgent.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/UserAgent.java
rename to java/com/google/gwtexpui/user/client/UserAgent.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/View.java b/java/com/google/gwtexpui/user/client/View.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/View.java
rename to java/com/google/gwtexpui/user/client/View.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/ViewSite.java b/java/com/google/gwtexpui/user/client/ViewSite.java
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/ViewSite.java
rename to java/com/google/gwtexpui/user/client/ViewSite.java
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/tooltip.css b/java/com/google/gwtexpui/user/client/tooltip.css
similarity index 100%
rename from gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/tooltip.css
rename to java/com/google/gwtexpui/user/client/tooltip.css
diff --git a/gerrit-server/src/main/java/gerrit/AbstractCommitUserIdentityPredicate.java b/java/gerrit/AbstractCommitUserIdentityPredicate.java
similarity index 100%
rename from gerrit-server/src/main/java/gerrit/AbstractCommitUserIdentityPredicate.java
rename to java/gerrit/AbstractCommitUserIdentityPredicate.java
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD
new file mode 100644
index 0000000..980ad23
--- /dev/null
+++ b/java/gerrit/BUILD
@@ -0,0 +1,15 @@
+java_library(
+    name = "prolog-predicates",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:gwtorm",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//lib/prolog:runtime",
+    ],
+)
diff --git a/java/gerrit/PRED__load_commit_labels_1.java b/java/gerrit/PRED__load_commit_labels_1.java
new file mode 100644
index 0000000..1d0ba8a
--- /dev/null
+++ b/java/gerrit/PRED__load_commit_labels_1.java
@@ -0,0 +1,67 @@
+// Copyright 2011 Google Inc. All Rights Reserved.
+
+package gerrit;
+
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.StoredValues;
+import com.google.gwtorm.server.OrmException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.ListTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+
+/** Exports list of {@code commit_label( label('Code-Review', 2), user(12345789) )}. */
+class PRED__load_commit_labels_1 extends Predicate.P1 {
+  private static final SymbolTerm sym_commit_label = SymbolTerm.intern("commit_label", 2);
+  private static final SymbolTerm sym_label = SymbolTerm.intern("label", 2);
+  private static final SymbolTerm sym_user = SymbolTerm.intern("user", 1);
+
+  PRED__load_commit_labels_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+
+    Term listHead = Prolog.Nil;
+    try {
+      ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
+      LabelTypes types = cd.getLabelTypes();
+
+      for (PatchSetApproval a : cd.currentApprovals()) {
+        LabelType t = types.byLabel(a.getLabelId());
+        if (t == null) {
+          continue;
+        }
+
+        StructureTerm labelTerm =
+            new StructureTerm(
+                sym_label, SymbolTerm.intern(t.getName()), new IntegerTerm(a.getValue()));
+
+        StructureTerm userTerm =
+            new StructureTerm(sym_user, new IntegerTerm(a.getAccountId().get()));
+
+        listHead = new ListTerm(new StructureTerm(sym_commit_label, labelTerm, userTerm), listHead);
+      }
+    } catch (OrmException err) {
+      throw new JavaException(this, 1, err);
+    }
+
+    if (!a1.unify(listHead, engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+}
diff --git a/java/gerrit/PRED_change_branch_1.java b/java/gerrit/PRED_change_branch_1.java
new file mode 100644
index 0000000..0a7bb74
--- /dev/null
+++ b/java/gerrit/PRED_change_branch_1.java
@@ -0,0 +1,44 @@
+// 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 gerrit;
+
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+
+public class PRED_change_branch_1 extends Predicate.P1 {
+  public PRED_change_branch_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+
+    Branch.NameKey name = StoredValues.getChange(engine).getDest();
+
+    if (!a1.unify(SymbolTerm.create(name.get()), engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+}
diff --git a/java/gerrit/PRED_change_owner_1.java b/java/gerrit/PRED_change_owner_1.java
new file mode 100644
index 0000000..937b761
--- /dev/null
+++ b/java/gerrit/PRED_change_owner_1.java
@@ -0,0 +1,48 @@
+// 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 gerrit;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+
+public class PRED_change_owner_1 extends Predicate.P1 {
+  private static final SymbolTerm user = SymbolTerm.intern("user", 1);
+
+  public PRED_change_owner_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+
+    Account.Id ownerId = StoredValues.getChange(engine).getOwner();
+
+    if (!a1.unify(new StructureTerm(user, new IntegerTerm(ownerId.get())), engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+}
diff --git a/java/gerrit/PRED_change_project_1.java b/java/gerrit/PRED_change_project_1.java
new file mode 100644
index 0000000..28e637a
--- /dev/null
+++ b/java/gerrit/PRED_change_project_1.java
@@ -0,0 +1,44 @@
+// 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 gerrit;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+
+public class PRED_change_project_1 extends Predicate.P1 {
+  public PRED_change_project_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+
+    Project.NameKey name = StoredValues.getChange(engine).getProject();
+
+    if (!a1.unify(SymbolTerm.create(name.get()), engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+}
diff --git a/java/gerrit/PRED_change_topic_1.java b/java/gerrit/PRED_change_topic_1.java
new file mode 100644
index 0000000..564878f
--- /dev/null
+++ b/java/gerrit/PRED_change_topic_1.java
@@ -0,0 +1,49 @@
+// 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 gerrit;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+
+public class PRED_change_topic_1 extends Predicate.P1 {
+  public PRED_change_topic_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+
+    Term topicTerm = Prolog.Nil;
+    Change change = StoredValues.getChange(engine);
+    String topic = change.getTopic();
+    if (topic != null) {
+      topicTerm = SymbolTerm.create(topic);
+    }
+
+    if (!a1.unify(topicTerm, engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+}
diff --git a/java/gerrit/PRED_commit_author_3.java b/java/gerrit/PRED_commit_author_3.java
new file mode 100644
index 0000000..a876b5e
--- /dev/null
+++ b/java/gerrit/PRED_commit_author_3.java
@@ -0,0 +1,36 @@
+// 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 gerrit;
+
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.client.UserIdentity;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.Term;
+
+public class PRED_commit_author_3 extends AbstractCommitUserIdentityPredicate {
+  public PRED_commit_author_3(Term a1, Term a2, Term a3, Operation n) {
+    super(a1, a2, a3, n);
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    PatchSetInfo psInfo = StoredValues.PATCH_SET_INFO.get(engine);
+    UserIdentity author = psInfo.getAuthor();
+    return exec(engine, author);
+  }
+}
diff --git a/java/gerrit/PRED_commit_committer_3.java b/java/gerrit/PRED_commit_committer_3.java
new file mode 100644
index 0000000..b24b004
--- /dev/null
+++ b/java/gerrit/PRED_commit_committer_3.java
@@ -0,0 +1,36 @@
+// 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 gerrit;
+
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.client.UserIdentity;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.Term;
+
+public class PRED_commit_committer_3 extends AbstractCommitUserIdentityPredicate {
+  public PRED_commit_committer_3(Term a1, Term a2, Term a3, Operation n) {
+    super(a1, a2, a3, n);
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    PatchSetInfo psInfo = StoredValues.PATCH_SET_INFO.get(engine);
+    UserIdentity committer = psInfo.getCommitter();
+    return exec(engine, committer);
+  }
+}
diff --git a/java/gerrit/PRED_commit_delta_4.java b/java/gerrit/PRED_commit_delta_4.java
new file mode 100644
index 0000000..7c26632
--- /dev/null
+++ b/java/gerrit/PRED_commit_delta_4.java
@@ -0,0 +1,172 @@
+// 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 gerrit;
+
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
+import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import com.googlecode.prolog_cafe.lang.VariableTerm;
+import java.util.Iterator;
+import java.util.regex.Pattern;
+
+/**
+ * Given a regular expression, checks it against the file list in the most recent patchset of a
+ * change. For all files that match the regex, returns the (new) path of the file, the change type,
+ * and the old path of the file if applicable (if the file was copied or renamed).
+ *
+ * <pre>
+ *   'commit_delta'(+Regex, -ChangeType, -NewPath, -OldPath)
+ * </pre>
+ */
+public class PRED_commit_delta_4 extends Predicate.P4 {
+  private static final SymbolTerm add = SymbolTerm.intern("add");
+  private static final SymbolTerm modify = SymbolTerm.intern("modify");
+  private static final SymbolTerm delete = SymbolTerm.intern("delete");
+  private static final SymbolTerm rename = SymbolTerm.intern("rename");
+  private static final SymbolTerm copy = SymbolTerm.intern("copy");
+  static final Operation commit_delta_check = new PRED_commit_delta_check();
+  static final Operation commit_delta_next = new PRED_commit_delta_next();
+  static final Operation commit_delta_empty = new PRED_commit_delta_empty();
+
+  public PRED_commit_delta_4(Term a1, Term a2, Term a3, Term a4, Operation n) {
+    arg1 = a1;
+    arg2 = a2;
+    arg3 = a3;
+    arg4 = a4;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.cont = cont;
+    engine.setB0();
+
+    Term a1 = arg1.dereference();
+    if (a1 instanceof VariableTerm) {
+      throw new PInstantiationException(this, 1);
+    }
+    if (!(a1 instanceof SymbolTerm)) {
+      throw new IllegalTypeException(this, 1, "symbol", a1);
+    }
+    Pattern regex = Pattern.compile(a1.name());
+    engine.r1 = new JavaObjectTerm(regex);
+    engine.r2 = arg2;
+    engine.r3 = arg3;
+    engine.r4 = arg4;
+
+    PatchList pl = StoredValues.PATCH_LIST.get(engine);
+    Iterator<PatchListEntry> iter = pl.getPatches().iterator();
+
+    engine.r5 = new JavaObjectTerm(iter);
+
+    return engine.jtry5(commit_delta_check, commit_delta_next);
+  }
+
+  private static final class PRED_commit_delta_check extends Operation {
+    @Override
+    public Operation exec(Prolog engine) {
+      Term a1 = engine.r1;
+      Term a2 = engine.r2;
+      Term a3 = engine.r3;
+      Term a4 = engine.r4;
+      Term a5 = engine.r5;
+
+      Pattern regex = (Pattern) ((JavaObjectTerm) a1).object();
+      @SuppressWarnings("unchecked")
+      Iterator<PatchListEntry> iter = (Iterator<PatchListEntry>) ((JavaObjectTerm) a5).object();
+      while (iter.hasNext()) {
+        PatchListEntry patch = iter.next();
+        String newName = patch.getNewName();
+        String oldName = patch.getOldName();
+        Patch.ChangeType changeType = patch.getChangeType();
+
+        if (newName.equals("/COMMIT_MSG")) {
+          continue;
+        }
+
+        if (regex.matcher(newName).find() || (oldName != null && regex.matcher(oldName).find())) {
+          SymbolTerm changeSym = getTypeSymbol(changeType);
+          SymbolTerm newSym = SymbolTerm.create(newName);
+          SymbolTerm oldSym = Prolog.Nil;
+          if (oldName != null) {
+            oldSym = SymbolTerm.create(oldName);
+          }
+
+          if (!a2.unify(changeSym, engine.trail)) {
+            continue;
+          }
+          if (!a3.unify(newSym, engine.trail)) {
+            continue;
+          }
+          if (!a4.unify(oldSym, engine.trail)) {
+            continue;
+          }
+          return engine.cont;
+        }
+      }
+      return engine.fail();
+    }
+  }
+
+  private static final class PRED_commit_delta_next extends Operation {
+    @Override
+    public Operation exec(Prolog engine) {
+      return engine.trust(commit_delta_empty);
+    }
+  }
+
+  private static final class PRED_commit_delta_empty extends Operation {
+    @Override
+    public Operation exec(Prolog engine) {
+      Term a5 = engine.r5;
+
+      @SuppressWarnings("unchecked")
+      Iterator<PatchListEntry> iter = (Iterator<PatchListEntry>) ((JavaObjectTerm) a5).object();
+      if (!iter.hasNext()) {
+        return engine.fail();
+      }
+
+      return engine.jtry5(commit_delta_check, commit_delta_next);
+    }
+  }
+
+  private static SymbolTerm getTypeSymbol(Patch.ChangeType type) {
+    switch (type) {
+      case ADDED:
+        return add;
+      case MODIFIED:
+        return modify;
+      case DELETED:
+        return delete;
+      case RENAMED:
+        return rename;
+      case COPIED:
+        return copy;
+      case REWRITE:
+        break;
+    }
+    throw new IllegalArgumentException("ChangeType not recognized");
+  }
+}
diff --git a/java/gerrit/PRED_commit_edits_2.java b/java/gerrit/PRED_commit_edits_2.java
new file mode 100644
index 0000000..c196026
--- /dev/null
+++ b/java/gerrit/PRED_commit_edits_2.java
@@ -0,0 +1,162 @@
+// 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 gerrit;
+
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
+import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import com.googlecode.prolog_cafe.lang.VariableTerm;
+import java.io.IOException;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.errors.CorruptObjectException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * Returns true if any of the files that match FileNameRegex have edited lines that match EditRegex
+ *
+ * <pre>
+ *   'commit_edits'(+FileNameRegex, +EditRegex)
+ * </pre>
+ */
+public class PRED_commit_edits_2 extends Predicate.P2 {
+  public PRED_commit_edits_2(Term a1, Term a2, Operation n) {
+    arg1 = a1;
+    arg2 = a2;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+
+    Term a1 = arg1.dereference();
+    Term a2 = arg2.dereference();
+
+    Pattern fileRegex = getRegexParameter(a1);
+    Pattern editRegex = getRegexParameter(a2);
+
+    PatchList pl = StoredValues.PATCH_LIST.get(engine);
+    Repository repo = StoredValues.REPOSITORY.get(engine);
+
+    try (ObjectReader reader = repo.newObjectReader();
+        RevWalk rw = new RevWalk(reader)) {
+      final RevTree aTree;
+      final RevTree bTree;
+      final RevCommit bCommit = rw.parseCommit(pl.getNewId());
+
+      if (pl.getOldId() != null) {
+        aTree = rw.parseTree(pl.getOldId());
+      } else {
+        // Octopus merge with unknown automatic merge result, since the
+        // web UI returns no files to match against, just fail.
+        return engine.fail();
+      }
+      bTree = bCommit.getTree();
+
+      for (PatchListEntry entry : pl.getPatches()) {
+        String newName = entry.getNewName();
+        String oldName = entry.getOldName();
+
+        if (newName.equals("/COMMIT_MSG")) {
+          continue;
+        }
+
+        if (fileRegex.matcher(newName).find()
+            || (oldName != null && fileRegex.matcher(oldName).find())) {
+          // This cast still seems to be needed on JDK 8 as workaround for:
+          // https://bugs.openjdk.java.net/browse/JDK-8039214
+          @SuppressWarnings("cast")
+          List<Edit> edits = (List<Edit>) entry.getEdits();
+
+          if (edits.isEmpty()) {
+            continue;
+          }
+          Text tA;
+          if (oldName != null) {
+            tA = load(aTree, oldName, reader);
+          } else {
+            tA = load(aTree, newName, reader);
+          }
+          Text tB = load(bTree, newName, reader);
+          for (Edit edit : edits) {
+            if (tA != Text.EMPTY) {
+              String aDiff = tA.getString(edit.getBeginA(), edit.getEndA(), true);
+              if (editRegex.matcher(aDiff).find()) {
+                return cont;
+              }
+            }
+            if (tB != Text.EMPTY) {
+              String bDiff = tB.getString(edit.getBeginB(), edit.getEndB(), true);
+              if (editRegex.matcher(bDiff).find()) {
+                return cont;
+              }
+            }
+          }
+        }
+      }
+    } catch (IOException err) {
+      throw new JavaException(this, 1, err);
+    }
+
+    return engine.fail();
+  }
+
+  private Pattern getRegexParameter(Term term) {
+    if (term instanceof VariableTerm) {
+      throw new PInstantiationException(this, 1);
+    }
+    if (!(term instanceof SymbolTerm)) {
+      throw new IllegalTypeException(this, 1, "symbol", term);
+    }
+    return Pattern.compile(term.name(), Pattern.MULTILINE);
+  }
+
+  private Text load(ObjectId tree, String path, ObjectReader reader)
+      throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
+          IOException {
+    if (path == null) {
+      return Text.EMPTY;
+    }
+    final TreeWalk tw = TreeWalk.forPath(reader, path, tree);
+    if (tw == null) {
+      return Text.EMPTY;
+    }
+    if (tw.getFileMode(0).getObjectType() != Constants.OBJ_BLOB) {
+      return Text.EMPTY;
+    }
+    return new Text(reader.open(tw.getObjectId(0), Constants.OBJ_BLOB));
+  }
+}
diff --git a/java/gerrit/PRED_commit_message_1.java b/java/gerrit/PRED_commit_message_1.java
new file mode 100644
index 0000000..c5aa367
--- /dev/null
+++ b/java/gerrit/PRED_commit_message_1.java
@@ -0,0 +1,52 @@
+// 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 gerrit;
+
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+
+/**
+ * Returns the commit message as a symbol
+ *
+ * <pre>
+ *   'commit_message'(-Msg)
+ * </pre>
+ */
+public class PRED_commit_message_1 extends Predicate.P1 {
+  public PRED_commit_message_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+
+    PatchSetInfo psInfo = StoredValues.PATCH_SET_INFO.get(engine);
+
+    SymbolTerm msg = SymbolTerm.create(psInfo.getMessage());
+    if (!a1.unify(msg, engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+}
diff --git a/java/gerrit/PRED_commit_stats_3.java b/java/gerrit/PRED_commit_stats_3.java
new file mode 100644
index 0000000..c1666d8
--- /dev/null
+++ b/java/gerrit/PRED_commit_stats_3.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package gerrit;
+
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.Term;
+import java.util.List;
+
+/**
+ * Exports basic commit statistics.
+ *
+ * <pre>
+ *   'commit_stats'(-Files, -Insertions, -Deletions)
+ * </pre>
+ */
+public class PRED_commit_stats_3 extends Predicate.P3 {
+  public PRED_commit_stats_3(Term a1, Term a2, Term a3, Operation n) {
+    arg1 = a1;
+    arg2 = a2;
+    arg3 = a3;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+
+    Term a1 = arg1.dereference();
+    Term a2 = arg2.dereference();
+    Term a3 = arg3.dereference();
+
+    PatchList pl = StoredValues.PATCH_LIST.get(engine);
+    // Account for magic files
+    if (!a1.unify(
+        new IntegerTerm(pl.getPatches().size() - countMagicFiles(pl.getPatches())), engine.trail)) {
+      return engine.fail();
+    }
+    if (!a2.unify(new IntegerTerm(pl.getInsertions()), engine.trail)) {
+      return engine.fail();
+    }
+    if (!a3.unify(new IntegerTerm(pl.getDeletions()), engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+
+  private int countMagicFiles(List<PatchListEntry> entries) {
+    int count = 0;
+    for (PatchListEntry e : entries) {
+      if (Patch.isMagic(e.getNewName())) {
+        count++;
+      }
+    }
+    return count;
+  }
+}
diff --git a/java/gerrit/PRED_current_user_1.java b/java/gerrit/PRED_current_user_1.java
new file mode 100644
index 0000000..c7d381d
--- /dev/null
+++ b/java/gerrit/PRED_current_user_1.java
@@ -0,0 +1,69 @@
+// 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 gerrit;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PeerDaemonUser;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.EvaluationException;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+
+public class PRED_current_user_1 extends Predicate.P1 {
+  private static final SymbolTerm user = SymbolTerm.intern("user", 1);
+  private static final SymbolTerm anonymous = SymbolTerm.intern("anonymous");
+  private static final SymbolTerm peerDaemon = SymbolTerm.intern("peer_daemon");
+
+  public PRED_current_user_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+
+    CurrentUser curUser = StoredValues.CURRENT_USER.getOrNull(engine);
+    if (curUser == null) {
+      throw new EvaluationException("Current user not available in this rule type");
+    }
+    Term resultTerm;
+
+    if (curUser.isIdentifiedUser()) {
+      Account.Id id = curUser.getAccountId();
+      resultTerm = new IntegerTerm(id.get());
+    } else if (curUser instanceof AnonymousUser) {
+      resultTerm = anonymous;
+    } else if (curUser instanceof PeerDaemonUser) {
+      resultTerm = peerDaemon;
+    } else {
+      throw new EvaluationException("Unknown user type");
+    }
+
+    if (!a1.unify(new StructureTerm(user, resultTerm), engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+}
diff --git a/java/gerrit/PRED_current_user_2.java b/java/gerrit/PRED_current_user_2.java
new file mode 100644
index 0000000..4815b9f
--- /dev/null
+++ b/java/gerrit/PRED_current_user_2.java
@@ -0,0 +1,108 @@
+// 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 gerrit;
+
+import static com.googlecode.prolog_cafe.lang.SymbolTerm.intern;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.rules.PrologEnvironment;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
+import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import com.googlecode.prolog_cafe.lang.VariableTerm;
+import java.util.Map;
+
+/**
+ * Loads a CurrentUser object for a user identity.
+ *
+ * <p>Values are cached in the hash {@code current_user}, avoiding recreation during a single
+ * evaluation.
+ *
+ * <pre>
+ *   current_user(user(+AccountId), -CurrentUser).
+ * </pre>
+ */
+class PRED_current_user_2 extends Predicate.P2 {
+  private static final SymbolTerm user = intern("user", 1);
+  private static final SymbolTerm anonymous = intern("anonymous");
+
+  PRED_current_user_2(Term a1, Term a2, Operation n) {
+    arg1 = a1;
+    arg2 = a2;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+    Term a2 = arg2.dereference();
+
+    if (a1 instanceof VariableTerm) {
+      throw new PInstantiationException(this, 1);
+    }
+
+    if (!a2.unify(createUser(engine, a1), engine.trail)) {
+      return engine.fail();
+    }
+
+    return cont;
+  }
+
+  public Term createUser(Prolog engine, Term key) {
+    if (!(key instanceof StructureTerm)
+        || key.arity() != 1
+        || !((StructureTerm) key).functor().equals(user)) {
+      throw new IllegalTypeException(this, 1, "user(int)", key);
+    }
+
+    Term idTerm = key.arg(0);
+    CurrentUser user;
+    if (idTerm instanceof IntegerTerm) {
+      Map<Account.Id, IdentifiedUser> cache = StoredValues.USERS.get(engine);
+      Account.Id accountId = new Account.Id(((IntegerTerm) idTerm).intValue());
+      user = cache.get(accountId);
+      if (user == null) {
+        IdentifiedUser.GenericFactory userFactory = userFactory(engine);
+        IdentifiedUser who = userFactory.create(accountId);
+        cache.put(accountId, who);
+        user = who;
+      }
+
+    } else if (idTerm.equals(anonymous)) {
+      user = StoredValues.ANONYMOUS_USER.get(engine);
+
+    } else {
+      throw new IllegalTypeException(this, 1, "user(int)", key);
+    }
+
+    return new JavaObjectTerm(user);
+  }
+
+  private static IdentifiedUser.GenericFactory userFactory(Prolog engine) {
+    return ((PrologEnvironment) engine.control).getArgs().getUserFactory();
+  }
+}
diff --git a/java/gerrit/PRED_get_legacy_label_types_1.java b/java/gerrit/PRED_get_legacy_label_types_1.java
new file mode 100644
index 0000000..ef79e05
--- /dev/null
+++ b/java/gerrit/PRED_get_legacy_label_types_1.java
@@ -0,0 +1,85 @@
+// 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 gerrit;
+
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.server.rules.StoredValues;
+import com.google.gwtorm.server.OrmException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.ListTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import java.util.List;
+
+/**
+ * Obtain a list of label types from the server configuration.
+ *
+ * <p>Unifies to a Prolog list of: {@code label_type(Label, Fun, Min, Max)} where:
+ *
+ * <ul>
+ *   <li>{@code Label} - the newer style label name
+ *   <li>{@code Fun} - legacy function name
+ *   <li>{@code Min, Max} - the smallest and largest configured values.
+ * </ul>
+ */
+class PRED_get_legacy_label_types_1 extends Predicate.P1 {
+  private static final SymbolTerm NONE = SymbolTerm.intern("none");
+
+  PRED_get_legacy_label_types_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+    List<LabelType> list;
+    try {
+      list = StoredValues.CHANGE_DATA.get(engine).getLabelTypes().getLabelTypes();
+    } catch (OrmException err) {
+      throw new JavaException(this, 1, err);
+    }
+    Term head = Prolog.Nil;
+    for (int idx = list.size() - 1; 0 <= idx; idx--) {
+      head = new ListTerm(export(list.get(idx)), head);
+    }
+
+    if (!a1.unify(head, engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+
+  static final SymbolTerm symLabelType = SymbolTerm.intern("label_type", 4);
+
+  static Term export(LabelType type) {
+    LabelValue min = type.getMin();
+    LabelValue max = type.getMax();
+    return new StructureTerm(
+        symLabelType,
+        SymbolTerm.intern(type.getName()),
+        SymbolTerm.intern(type.getFunction().getFunctionName()),
+        min != null ? new IntegerTerm(min.getValue()) : NONE,
+        max != null ? new IntegerTerm(max.getValue()) : NONE);
+  }
+}
diff --git a/java/gerrit/PRED_project_default_submit_type_1.java b/java/gerrit/PRED_project_default_submit_type_1.java
new file mode 100644
index 0000000..d70a9e4
--- /dev/null
+++ b/java/gerrit/PRED_project_default_submit_type_1.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package gerrit;
+
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+
+public class PRED_project_default_submit_type_1 extends Predicate.P1 {
+
+  private static final SymbolTerm[] term;
+
+  static {
+    SubmitType[] val = SubmitType.values();
+    term = new SymbolTerm[val.length];
+    for (int i = 0; i < val.length; i++) {
+      term[i] = SymbolTerm.create(val[i].name());
+    }
+  }
+
+  public PRED_project_default_submit_type_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+
+    ProjectState projectState = StoredValues.PROJECT_STATE.get(engine);
+    SubmitType submitType = projectState.getSubmitType();
+    if (!a1.unify(term[submitType.ordinal()], engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+}
diff --git a/java/gerrit/PRED_pure_revert_1.java b/java/gerrit/PRED_pure_revert_1.java
new file mode 100644
index 0000000..95a0729
--- /dev/null
+++ b/java/gerrit/PRED_pure_revert_1.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package gerrit;
+
+import com.google.gerrit.server.rules.StoredValues;
+import com.google.gwtorm.server.OrmException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.Term;
+
+/** Checks if change is a pure revert of the change it references in 'revertOf'. */
+public class PRED_pure_revert_1 extends Predicate.P1 {
+  public PRED_pure_revert_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+
+    Boolean isPureRevert;
+    try {
+      isPureRevert = StoredValues.CHANGE_DATA.get(engine).isPureRevert();
+    } catch (OrmException e) {
+      throw new JavaException(this, 1, e);
+    }
+    if (!a1.unify(new IntegerTerm(Boolean.TRUE.equals(isPureRevert) ? 1 : 0), engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+}
diff --git a/java/gerrit/PRED_unresolved_comments_count_1.java b/java/gerrit/PRED_unresolved_comments_count_1.java
new file mode 100644
index 0000000..5ed1525
--- /dev/null
+++ b/java/gerrit/PRED_unresolved_comments_count_1.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 gerrit;
+
+import com.google.gerrit.server.rules.StoredValues;
+import com.google.gwtorm.server.OrmException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.Term;
+
+public class PRED_unresolved_comments_count_1 extends Predicate.P1 {
+  public PRED_unresolved_comments_count_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+
+    try {
+      Integer count = StoredValues.CHANGE_DATA.get(engine).unresolvedCommentCount();
+      if (!a1.unify(new IntegerTerm(count != null ? count : 0), engine.trail)) {
+        return engine.fail();
+      }
+    } catch (OrmException err) {
+      throw new JavaException(this, 1, err);
+    }
+    return cont;
+  }
+}
diff --git a/java/gerrit/PRED_uploader_1.java b/java/gerrit/PRED_uploader_1.java
new file mode 100644
index 0000000..bf1bf27
--- /dev/null
+++ b/java/gerrit/PRED_uploader_1.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package gerrit;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PRED_uploader_1 extends Predicate.P1 {
+  private static final Logger log = LoggerFactory.getLogger(PRED_uploader_1.class);
+
+  private static final SymbolTerm user = SymbolTerm.intern("user", 1);
+
+  public PRED_uploader_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+
+    PatchSet patchSet = StoredValues.getPatchSet(engine);
+    if (patchSet == null) {
+      log.error(
+          "Failed to load current patch set of change "
+              + StoredValues.getChange(engine).getChangeId());
+      return engine.fail();
+    }
+
+    Account.Id uploaderId = patchSet.getUploader();
+
+    if (!a1.unify(new StructureTerm(user, new IntegerTerm(uploaderId.get())), engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+}
diff --git a/java/org/apache/commons/net/BUILD b/java/org/apache/commons/net/BUILD
new file mode 100644
index 0000000..0074a03
--- /dev/null
+++ b/java/org/apache/commons/net/BUILD
@@ -0,0 +1,11 @@
+java_library(
+    name = "net",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/util/ssl",
+        "//lib/commons:codec",
+        "//lib/commons:net",
+        "//lib/log:api",
+    ],
+)
diff --git a/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java b/java/org/apache/commons/net/smtp/AuthSMTPClient.java
similarity index 100%
rename from gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java
rename to java/org/apache/commons/net/smtp/AuthSMTPClient.java
diff --git a/java/org/eclipse/jgit/BUILD b/java/org/eclipse/jgit/BUILD
new file mode 100644
index 0000000..65bc118
--- /dev/null
+++ b/java/org/eclipse/jgit/BUILD
@@ -0,0 +1,50 @@
+load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//tools/bzl:gwt.bzl", "gwt_module")
+
+gwt_module(
+    name = "client",
+    srcs = [
+        "diff/Edit_JsonSerializer.java",
+        "diff/ReplaceEdit.java",
+    ],
+    gwt_xml = "JGit.gwt.xml",
+    visibility = ["//visibility:public"],
+    deps = [
+        ":Edit",
+        "//lib:gwtjsonrpc",
+        "//lib/gwt:user",
+    ],
+)
+
+gwt_module(
+    name = "Edit",
+    srcs = [":jgit_edit_src"],
+    visibility = ["//visibility:public"],
+)
+
+genrule2(
+    name = "jgit_edit_src",
+    outs = ["edit.srcjar"],
+    cmd = " && ".join([
+        "unzip -qd $$TMP $(location //lib/jgit/org.eclipse.jgit:jgit-source) " +
+        "org/eclipse/jgit/diff/Edit.java",
+        "cd $$TMP",
+        "zip -Dq $$ROOT/$@ org/eclipse/jgit/diff/Edit.java",
+    ]),
+    tools = ["//lib/jgit/org.eclipse.jgit:jgit-source"],
+)
+
+java_library(
+    name = "server",
+    srcs = [
+        "diff/EditDeserializer.java",
+        "diff/ReplaceEdit.java",
+        "internal/storage/file/WindowCacheStatAccessor.java",
+        "lib/ObjectIdSerialization.java",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:gson",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/JGit.gwt.xml b/java/org/eclipse/jgit/JGit.gwt.xml
similarity index 100%
rename from gerrit-patch-jgit/src/main/java/org/eclipse/jgit/JGit.gwt.xml
rename to java/org/eclipse/jgit/JGit.gwt.xml
diff --git a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/EditDeserializer.java b/java/org/eclipse/jgit/diff/EditDeserializer.java
similarity index 100%
rename from gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/EditDeserializer.java
rename to java/org/eclipse/jgit/diff/EditDeserializer.java
diff --git a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/Edit_JsonSerializer.java b/java/org/eclipse/jgit/diff/Edit_JsonSerializer.java
similarity index 100%
rename from gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/Edit_JsonSerializer.java
rename to java/org/eclipse/jgit/diff/Edit_JsonSerializer.java
diff --git a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/ReplaceEdit.java b/java/org/eclipse/jgit/diff/ReplaceEdit.java
similarity index 100%
rename from gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/ReplaceEdit.java
rename to java/org/eclipse/jgit/diff/ReplaceEdit.java
diff --git a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/internal/storage/file/WindowCacheStatAccessor.java b/java/org/eclipse/jgit/internal/storage/file/WindowCacheStatAccessor.java
similarity index 100%
rename from gerrit-patch-jgit/src/main/java/org/eclipse/jgit/internal/storage/file/WindowCacheStatAccessor.java
rename to java/org/eclipse/jgit/internal/storage/file/WindowCacheStatAccessor.java
diff --git a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/lib/ObjectIdSerialization.java b/java/org/eclipse/jgit/lib/ObjectIdSerialization.java
similarity index 100%
rename from gerrit-patch-jgit/src/main/java/org/eclipse/jgit/lib/ObjectIdSerialization.java
rename to java/org/eclipse/jgit/lib/ObjectIdSerialization.java
diff --git a/javatests/com/google/gerrit/acceptance/BUILD b/javatests/com/google/gerrit/acceptance/BUILD
new file mode 100644
index 0000000..234e4be
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/BUILD
@@ -0,0 +1,12 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "acceptance_framework_tests",
+    srcs = glob(["**/*.java"]),
+    deps = [
+        "//java/com/google/gerrit/acceptance:lib",
+        "//lib:guava",
+        "//lib:truth",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java b/javatests/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java
similarity index 100%
rename from gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java
rename to javatests/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
new file mode 100644
index 0000000..6c3a4b0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -0,0 +1,442 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+import org.easymock.EasyMock;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ProjectResetterTest extends GerritBaseTests {
+  private InMemoryRepositoryManager repoManager;
+  private Project.NameKey project;
+  private Repository repo;
+
+  @Before
+  public void setUp() throws Exception {
+    repoManager = new InMemoryRepositoryManager();
+    project = new Project.NameKey("foo");
+    repo = repoManager.createRepository(project);
+  }
+
+  @Before
+  public void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void resetAllRefs() throws Exception {
+    Ref matchingRef = createRef("refs/any/test");
+
+    try (ProjectResetter resetProject = builder().reset(project).build()) {
+      updateRef(matchingRef);
+    }
+
+    // The matching refs are reset to the old state.
+    assertRef(matchingRef);
+  }
+
+  @Test
+  public void onlyResetMatchingRefs() throws Exception {
+    Ref matchingRef = createRef("refs/match/test");
+    Ref anotherMatchingRef = createRef("refs/another-match/test");
+    Ref nonMatchingRef = createRef("refs/no-match/test");
+
+    Ref updatedNonMatchingRef;
+    try (ProjectResetter resetProject =
+        builder().reset(project, "refs/match/*", "refs/another-match/*").build()) {
+      updateRef(matchingRef);
+      updateRef(anotherMatchingRef);
+      updatedNonMatchingRef = updateRef(nonMatchingRef);
+    }
+
+    // The matching refs are reset to the old state.
+    assertRef(matchingRef);
+    assertRef(anotherMatchingRef);
+
+    // The non-matching ref is not reset, hence it still has the updated state.
+    assertRef(updatedNonMatchingRef);
+  }
+
+  @Test
+  public void onlyDeleteNewlyCreatedMatchingRefs() throws Exception {
+    Ref matchingRef;
+    Ref anotherMatchingRef;
+    Ref nonMatchingRef;
+    try (ProjectResetter resetProject =
+        builder().reset(project, "refs/match/*", "refs/another-match/*").build()) {
+      matchingRef = createRef("refs/match/test");
+      anotherMatchingRef = createRef("refs/another-match/test");
+      nonMatchingRef = createRef("refs/no-match/test");
+    }
+
+    // The matching refs are deleted since they didn't exist before.
+    assertDeletedRef(matchingRef);
+    assertDeletedRef(anotherMatchingRef);
+
+    // The non-matching ref is not deleted.
+    assertRef(nonMatchingRef);
+  }
+
+  @Test
+  public void onlyResetMatchingRefsMultipleProjects() throws Exception {
+    Project.NameKey project2 = new Project.NameKey("bar");
+    Repository repo2 = repoManager.createRepository(project2);
+
+    Ref matchingRefProject1 = createRef("refs/foo/test");
+    Ref nonMatchingRefProject1 = createRef("refs/bar/test");
+
+    Ref matchingRefProject2 = createRef(repo2, "refs/bar/test");
+    Ref nonMatchingRefProject2 = createRef(repo2, "refs/foo/test");
+
+    Ref updatedNonMatchingRefProject1;
+    Ref updatedNonMatchingRefProject2;
+    try (ProjectResetter resetProject =
+        builder().reset(project, "refs/foo/*").reset(project2, "refs/bar/*").build()) {
+      updateRef(matchingRefProject1);
+      updatedNonMatchingRefProject1 = updateRef(nonMatchingRefProject1);
+
+      updateRef(repo2, matchingRefProject2);
+      updatedNonMatchingRefProject2 = updateRef(repo2, nonMatchingRefProject2);
+    }
+
+    // The matching refs are reset to the old state.
+    assertRef(matchingRefProject1);
+    assertRef(repo2, matchingRefProject2);
+
+    // The non-matching refs are not reset, hence they still has the updated states.
+    assertRef(updatedNonMatchingRefProject1);
+    assertRef(repo2, updatedNonMatchingRefProject2);
+  }
+
+  @Test
+  public void onlyDeleteNewlyCreatedMatchingRefsMultipleProjects() throws Exception {
+    Project.NameKey project2 = new Project.NameKey("bar");
+    Repository repo2 = repoManager.createRepository(project2);
+
+    Ref matchingRefProject1;
+    Ref nonMatchingRefProject1;
+    Ref matchingRefProject2;
+    Ref nonMatchingRefProject2;
+    try (ProjectResetter resetProject =
+        builder().reset(project, "refs/foo/*").reset(project2, "refs/bar/*").build()) {
+      matchingRefProject1 = createRef("refs/foo/test");
+      nonMatchingRefProject1 = createRef("refs/bar/test");
+
+      matchingRefProject2 = createRef(repo2, "refs/bar/test");
+      nonMatchingRefProject2 = createRef(repo2, "refs/foo/test");
+    }
+
+    // The matching refs are deleted since they didn't exist before.
+    assertDeletedRef(matchingRefProject1);
+    assertDeletedRef(repo2, matchingRefProject2);
+
+    // The non-matching ref is not deleted.
+    assertRef(nonMatchingRefProject1);
+    assertRef(repo2, nonMatchingRefProject2);
+  }
+
+  @Test
+  public void onlyDeleteNewlyCreatedWithOverlappingRefPatterns() throws Exception {
+    Ref matchingRef;
+    try (ProjectResetter resetProject =
+        builder().reset(project, "refs/match/*", "refs/match/test").build()) {
+      // This ref matches 2 ref pattern, ProjectResetter should try to delete it only once.
+      matchingRef = createRef("refs/match/test");
+    }
+
+    // The matching ref is deleted since it didn't exist before.
+    assertDeletedRef(matchingRef);
+  }
+
+  @Test
+  public void projectEvictionIfRefsMetaConfigIsReset() throws Exception {
+    Project.NameKey project2 = new Project.NameKey("bar");
+    Repository repo2 = repoManager.createRepository(project2);
+    Ref metaConfig = createRef(repo2, RefNames.REFS_CONFIG);
+
+    ProjectCache projectCache = EasyMock.createNiceMock(ProjectCache.class);
+    projectCache.evict(project2);
+    EasyMock.expectLastCall();
+    EasyMock.replay(projectCache);
+
+    Ref nonMetaConfig = createRef("refs/heads/master");
+
+    try (ProjectResetter resetProject =
+        builder(null, null, projectCache).reset(project).reset(project2).build()) {
+      updateRef(nonMetaConfig);
+      updateRef(repo2, metaConfig);
+    }
+
+    EasyMock.verify(projectCache);
+  }
+
+  @Test
+  public void projectEvictionIfRefsMetaConfigIsDeleted() throws Exception {
+    Project.NameKey project2 = new Project.NameKey("bar");
+    Repository repo2 = repoManager.createRepository(project2);
+
+    ProjectCache projectCache = EasyMock.createNiceMock(ProjectCache.class);
+    projectCache.evict(project2);
+    EasyMock.expectLastCall();
+    EasyMock.replay(projectCache);
+
+    try (ProjectResetter resetProject =
+        builder(null, null, projectCache).reset(project).reset(project2).build()) {
+      createRef("refs/heads/master");
+      createRef(repo2, RefNames.REFS_CONFIG);
+    }
+
+    EasyMock.verify(projectCache);
+  }
+
+  @Test
+  public void accountEvictionIfUserBranchIsReset() throws Exception {
+    Account.Id accountId = new Account.Id(1);
+    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Repository allUsersRepo = repoManager.createRepository(allUsers);
+    Ref userBranch = createRef(allUsersRepo, RefNames.refsUsers(accountId));
+
+    AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
+    accountCache.evict(accountId);
+    EasyMock.expectLastCall();
+    EasyMock.replay(accountCache);
+
+    // Non-user branch because it's not in All-Users.
+    Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(2)));
+
+    try (ProjectResetter resetProject =
+        builder(null, accountCache, null).reset(project).reset(allUsers).build()) {
+      updateRef(nonUserBranch);
+      updateRef(allUsersRepo, userBranch);
+    }
+
+    EasyMock.verify(accountCache);
+  }
+
+  @Test
+  public void accountEvictionIfUserBranchIsDeleted() throws Exception {
+    Account.Id accountId = new Account.Id(1);
+    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Repository allUsersRepo = repoManager.createRepository(allUsers);
+
+    AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
+    accountCache.evict(accountId);
+    EasyMock.expectLastCall();
+    EasyMock.replay(accountCache);
+
+    try (ProjectResetter resetProject =
+        builder(null, accountCache, null).reset(project).reset(allUsers).build()) {
+      // Non-user branch because it's not in All-Users.
+      createRef(RefNames.refsUsers(new Account.Id(2)));
+
+      createRef(allUsersRepo, RefNames.refsUsers(accountId));
+    }
+
+    EasyMock.verify(accountCache);
+  }
+
+  @Test
+  public void accountEvictionIfExternalIdsBranchIsReset() throws Exception {
+    Account.Id accountId = new Account.Id(1);
+    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Repository allUsersRepo = repoManager.createRepository(allUsers);
+    Ref externalIds = createRef(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    createRef(allUsersRepo, RefNames.refsUsers(accountId));
+
+    Account.Id accountId2 = new Account.Id(2);
+
+    AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
+    accountCache.evict(accountId);
+    EasyMock.expectLastCall();
+    accountCache.evict(accountId2);
+    EasyMock.expectLastCall();
+    EasyMock.replay(accountCache);
+
+    // Non-user branch because it's not in All-Users.
+    Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(3)));
+
+    try (ProjectResetter resetProject =
+        builder(null, accountCache, null).reset(project).reset(allUsers).build()) {
+      updateRef(nonUserBranch);
+      updateRef(allUsersRepo, externalIds);
+      createRef(allUsersRepo, RefNames.refsUsers(accountId2));
+    }
+
+    EasyMock.verify(accountCache);
+  }
+
+  @Test
+  public void accountEvictionIfExternalIdsBranchIsDeleted() throws Exception {
+    Account.Id accountId = new Account.Id(1);
+    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Repository allUsersRepo = repoManager.createRepository(allUsers);
+    createRef(allUsersRepo, RefNames.refsUsers(accountId));
+
+    Account.Id accountId2 = new Account.Id(2);
+
+    AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
+    accountCache.evict(accountId);
+    EasyMock.expectLastCall();
+    accountCache.evict(accountId2);
+    EasyMock.expectLastCall();
+    EasyMock.replay(accountCache);
+
+    // Non-user branch because it's not in All-Users.
+    Ref nonUserBranch = createRef(RefNames.refsUsers(new Account.Id(3)));
+
+    try (ProjectResetter resetProject =
+        builder(null, accountCache, null).reset(project).reset(allUsers).build()) {
+      updateRef(nonUserBranch);
+      createRef(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+      createRef(allUsersRepo, RefNames.refsUsers(accountId2));
+    }
+
+    EasyMock.verify(accountCache);
+  }
+
+  @Test
+  public void accountEvictionFromAccountCreatorIfUserBranchIsDeleted() throws Exception {
+    Account.Id accountId = new Account.Id(1);
+    Project.NameKey allUsers = new Project.NameKey(AllUsersNameProvider.DEFAULT);
+    Repository allUsersRepo = repoManager.createRepository(allUsers);
+
+    AccountCreator accountCreator = EasyMock.createNiceMock(AccountCreator.class);
+    accountCreator.evict(ImmutableSet.of(accountId));
+    EasyMock.expectLastCall();
+    EasyMock.replay(accountCreator);
+
+    try (ProjectResetter resetProject =
+        builder(accountCreator, null, null).reset(project).reset(allUsers).build()) {
+      createRef(allUsersRepo, RefNames.refsUsers(accountId));
+    }
+
+    EasyMock.verify(accountCreator);
+  }
+
+  private Ref createRef(String ref) throws IOException {
+    return createRef(repo, ref);
+  }
+
+  private Ref createRef(Repository repo, String ref) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter();
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId emptyCommit = createCommit(repo);
+      RefUpdate updateRef = repo.updateRef(ref);
+      updateRef.setExpectedOldObjectId(ObjectId.zeroId());
+      updateRef.setNewObjectId(emptyCommit);
+      assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.NEW);
+      return repo.exactRef(ref);
+    }
+  }
+
+  private Ref updateRef(Ref ref) throws IOException {
+    return updateRef(repo, ref);
+  }
+
+  private Ref updateRef(Repository repo, Ref ref) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter();
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId emptyCommit = createCommit(repo);
+      RefUpdate updateRef = repo.updateRef(ref.getName());
+      updateRef.setExpectedOldObjectId(ref.getObjectId());
+      updateRef.setNewObjectId(emptyCommit);
+      updateRef.setForceUpdate(true);
+      assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.FORCED);
+      Ref updatedRef = repo.exactRef(ref.getName());
+      assertThat(updatedRef.getObjectId()).isNotEqualTo(ref.getObjectId());
+      return updatedRef;
+    }
+  }
+
+  private void assertRef(Ref ref) throws IOException {
+    assertRef(repo, ref);
+  }
+
+  private void assertRef(Repository repo, Ref ref) throws IOException {
+    assertThat(repo.exactRef(ref.getName()).getObjectId()).isEqualTo(ref.getObjectId());
+  }
+
+  private void assertDeletedRef(Ref ref) throws IOException {
+    assertDeletedRef(repo, ref);
+  }
+
+  private void assertDeletedRef(Repository repo, Ref ref) throws IOException {
+    assertThat(repo.exactRef(ref.getName())).isNull();
+  }
+
+  private ObjectId createCommit(Repository repo) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      PersonIdent ident =
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.nowTs());
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
+      cb.setCommitter(ident);
+      cb.setAuthor(ident);
+      cb.setMessage("Test commit");
+
+      ObjectId commit = oi.insert(cb);
+      oi.flush();
+      return commit;
+    }
+  }
+
+  private ProjectResetter.Builder builder() {
+    return builder(null, null, null);
+  }
+
+  private ProjectResetter.Builder builder(
+      @Nullable AccountCreator accountCreator,
+      @Nullable AccountCache accountCache,
+      @Nullable ProjectCache projectCache) {
+    return new ProjectResetter.Builder(
+        repoManager,
+        new AllUsersName(AllUsersNameProvider.DEFAULT),
+        accountCreator,
+        accountCache,
+        projectCache);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/annotation/BUILD b/javatests/com/google/gerrit/acceptance/annotation/BUILD
new file mode 100644
index 0000000..5476bb6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/annotation/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*.java"]),
+    group = "annotation",
+    labels = ["annotation"],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/SandboxTest.java b/javatests/com/google/gerrit/acceptance/annotation/SandboxTest.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/SandboxTest.java
rename to javatests/com/google/gerrit/acceptance/annotation/SandboxTest.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/UseGerritConfigAnnotationTest.java b/javatests/com/google/gerrit/acceptance/annotation/UseGerritConfigAnnotationTest.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/UseGerritConfigAnnotationTest.java
rename to javatests/com/google/gerrit/acceptance/annotation/UseGerritConfigAnnotationTest.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/UseGlobalPluginConfigAnnotationTest.java b/javatests/com/google/gerrit/acceptance/annotation/UseGlobalPluginConfigAnnotationTest.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/UseGlobalPluginConfigAnnotationTest.java
rename to javatests/com/google/gerrit/acceptance/annotation/UseGlobalPluginConfigAnnotationTest.java
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
new file mode 100644
index 0000000..4b1b5d0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -0,0 +1,2442 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.gpg.PublicKeyStore.REFS_GPG_KEYS;
+import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
+import static com.google.gerrit.gpg.testing.TestKeys.allValidKeys;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithExpiration;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithSecondUserId;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
+import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
+import static com.google.gerrit.server.StarredChangesUtil.IGNORE_LABEL;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.github.rholder.retry.StopStrategies;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.io.BaseEncoding;
+import com.google.common.util.concurrent.AtomicLongMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AccountCreator;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.StarsInput;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountsInput;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.EmailInfo;
+import com.google.gerrit.extensions.common.GpgKeyInfo;
+import com.google.gerrit.extensions.common.SshKeyInfo;
+import com.google.gerrit.extensions.events.AccountIndexedListener;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.gpg.Fingerprint;
+import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.testing.TestKey;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.account.WatchConfig;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.index.account.AccountIndexer;
+import com.google.gerrit.server.index.account.StalenessChecker;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
+import com.google.gerrit.server.project.RefPattern;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.name.Named;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.bouncycastle.bcpg.ArmoredOutputStream;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.eclipse.jgit.api.errors.TransportException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushCertificateIdent;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class AccountIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config enableSignedPushConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("receive", null, "enableSignedPush", true);
+
+    // Disable the staleness checker so that tests that verify the number of expected index events
+    // are stable.
+    cfg.setBoolean("index", null, "autoReindexIfStale", false);
+
+    return cfg;
+  }
+
+  @Inject private Provider<PublicKeyStore> publicKeyStoreProvider;
+
+  @Inject private AccountsUpdate.Server accountsUpdate;
+
+  @Inject private ExternalIds externalIds;
+
+  @Inject private DynamicSet<AccountIndexedListener> accountIndexedListeners;
+
+  @Inject private DynamicSet<GitReferenceUpdatedListener> refUpdateListeners;
+
+  @Inject private Sequences seq;
+
+  @Inject private Provider<InternalAccountQuery> accountQueryProvider;
+
+  @Inject protected Emails emails;
+
+  @Inject private StalenessChecker stalenessChecker;
+
+  @Inject private AccountIndexer accountIndexer;
+
+  @Inject private GitReferenceUpdated gitReferenceUpdated;
+
+  @Inject private RetryHelper.Metrics retryMetrics;
+
+  @Inject private Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+
+  @Inject private ExternalIdNotes.Factory extIdNotesFactory;
+
+  @Inject
+  @Named("accounts")
+  private LoadingCache<Account.Id, Optional<AccountState>> accountsCache;
+
+  private AccountIndexedCounter accountIndexedCounter;
+  private RegistrationHandle accountIndexEventCounterHandle;
+  private RefUpdateCounter refUpdateCounter;
+  private RegistrationHandle refUpdateCounterHandle;
+
+  @Before
+  public void addAccountIndexEventCounter() {
+    accountIndexedCounter = new AccountIndexedCounter();
+    accountIndexEventCounterHandle = accountIndexedListeners.add(accountIndexedCounter);
+  }
+
+  @After
+  public void removeAccountIndexEventCounter() {
+    if (accountIndexEventCounterHandle != null) {
+      accountIndexEventCounterHandle.remove();
+    }
+  }
+
+  @Before
+  public void addRefUpdateCounter() {
+    refUpdateCounter = new RefUpdateCounter();
+    refUpdateCounterHandle = refUpdateListeners.add(refUpdateCounter);
+  }
+
+  @After
+  public void removeRefUpdateCounter() {
+    if (refUpdateCounterHandle != null) {
+      refUpdateCounterHandle.remove();
+    }
+  }
+
+  @After
+  public void clearPublicKeyStore() throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      Ref ref = repo.exactRef(REFS_GPG_KEYS);
+      if (ref != null) {
+        RefUpdate ru = repo.updateRef(REFS_GPG_KEYS);
+        ru.setForceUpdate(true);
+        assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+      }
+    }
+  }
+
+  @After
+  public void deleteGpgKeys() throws Exception {
+    String ref = REFS_GPG_KEYS;
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      if (repo.getRefDatabase().exactRef(ref) != null) {
+        RefUpdate ru = repo.updateRef(ref);
+        ru.setForceUpdate(true);
+        assertWithMessage("Failed to delete " + ref)
+            .that(ru.delete())
+            .isEqualTo(RefUpdate.Result.FORCED);
+      }
+    }
+  }
+
+  @Test
+  public void createByAccountCreator() throws Exception {
+    Account.Id accountId = createByAccountCreator(2); // account creation + external ID creation
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
+        RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
+        RefUpdateCounter.projectRef(allUsers, RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS));
+  }
+
+  @Test
+  @UseSsh
+  public void createWithSshKeysByAccountCreator() throws Exception {
+    Account.Id accountId =
+        createByAccountCreator(3); // account creation + external ID creation + adding SSH keys
+    refUpdateCounter.assertRefUpdateFor(
+        ImmutableMap.of(
+            RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
+            2,
+            RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
+            1,
+            RefUpdateCounter.projectRef(
+                allUsers, RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS),
+            1));
+  }
+
+  private Account.Id createByAccountCreator(int expectedAccountReindexCalls) throws Exception {
+    String name = "foo";
+    TestAccount foo = accountCreator.create(name);
+    AccountInfo info = gApi.accounts().id(foo.id.get()).get();
+    assertThat(info.username).isEqualTo(name);
+    assertThat(info.name).isEqualTo(name);
+    accountIndexedCounter.assertReindexOf(foo, expectedAccountReindexCalls);
+    assertUserBranch(foo.getId(), name, null);
+    return foo.getId();
+  }
+
+  @Test
+  public void createAnonymousCowardByAccountCreator() throws Exception {
+    TestAccount anonymousCoward = accountCreator.create();
+    accountIndexedCounter.assertReindexOf(anonymousCoward);
+    assertUserBranchWithoutAccountConfig(anonymousCoward.getId());
+  }
+
+  @Test
+  public void create() throws Exception {
+    AccountInput input = new AccountInput();
+    input.username = "foo";
+    input.name = "Foo";
+    input.email = "foo@example.com";
+    AccountInfo accountInfo = gApi.accounts().create(input).get();
+    assertThat(accountInfo._accountId).isNotNull();
+    assertThat(accountInfo.username).isEqualTo(input.username);
+    assertThat(accountInfo.name).isEqualTo(input.name);
+    assertThat(accountInfo.email).isEqualTo(input.email);
+    assertThat(accountInfo.status).isNull();
+
+    Account.Id accountId = new Account.Id(accountInfo._accountId);
+    accountIndexedCounter.assertReindexOf(accountId, 2); // account creation + external ID creation
+    assertThat(externalIds.byAccount(accountId))
+        .containsExactly(
+            ExternalId.createUsername(input.username, accountId, null),
+            ExternalId.createEmail(accountId, input.email));
+  }
+
+  @Test
+  public void createAccountUsernameAlreadyTaken() throws Exception {
+    AccountInput input = new AccountInput();
+    input.username = admin.username;
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("username '" + admin.username + "' already exists");
+    gApi.accounts().create(input);
+  }
+
+  @Test
+  public void createAccountEmailAlreadyTaken() throws Exception {
+    AccountInput input = new AccountInput();
+    input.username = "foo";
+    input.email = admin.email;
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("email '" + admin.email + "' already exists");
+    gApi.accounts().create(input);
+  }
+
+  @Test
+  public void commitMessageOnAccountUpdates() throws Exception {
+    AccountsUpdate au = accountsUpdate.create();
+    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    au.insert("Create Test Account", accountId, u -> {});
+    assertLastCommitMessageOfUserBranch(accountId, "Create Test Account");
+
+    au.update("Set Status", accountId, u -> u.setStatus("Foo"));
+    assertLastCommitMessageOfUserBranch(accountId, "Set Status");
+  }
+
+  private void assertLastCommitMessageOfUserBranch(Account.Id accountId, String expectedMessage)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      Ref exactRef = repo.exactRef(RefNames.refsUsers(accountId));
+      assertThat(rw.parseCommit(exactRef.getObjectId()).getShortMessage())
+          .isEqualTo(expectedMessage);
+    }
+  }
+
+  @Test
+  public void createAtomically() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    try {
+      Account.Id accountId = new Account.Id(seq.nextAccountId());
+      String fullName = "Foo";
+      ExternalId extId = ExternalId.createEmail(accountId, "foo@example.com");
+      Account account =
+          accountsUpdate
+              .create()
+              .insert(
+                  "Create Account Atomically",
+                  accountId,
+                  u -> u.setFullName(fullName).addExternalId(extId));
+      assertThat(account.getFullName()).isEqualTo(fullName);
+
+      AccountInfo info = gApi.accounts().id(accountId.get()).get();
+      assertThat(info.name).isEqualTo(fullName);
+
+      List<EmailInfo> emails = gApi.accounts().id(accountId.get()).getEmails();
+      assertThat(emails.stream().map(e -> e.email).collect(toSet())).containsExactly(extId.email());
+
+      RevCommit commitUserBranch = getRemoteHead(allUsers, RefNames.refsUsers(accountId));
+      RevCommit commitRefsMetaExternalIds = getRemoteHead(allUsers, RefNames.REFS_EXTERNAL_IDS);
+      assertThat(commitUserBranch.getCommitTime())
+          .isEqualTo(commitRefsMetaExternalIds.getCommitTime());
+    } finally {
+      TestTimeUtil.useSystemTime();
+    }
+  }
+
+  @Test
+  public void updateNonExistingAccount() throws Exception {
+    Account.Id nonExistingAccountId = new Account.Id(999999);
+    AtomicBoolean consumerCalled = new AtomicBoolean();
+    Account account =
+        accountsUpdate
+            .create()
+            .update(
+                "Update Non-Existing Account", nonExistingAccountId, a -> consumerCalled.set(true));
+    assertThat(account).isNull();
+    assertThat(consumerCalled.get()).isFalse();
+  }
+
+  @Test
+  public void updateAccountWithoutAccountConfigNoteDb() throws Exception {
+    TestAccount anonymousCoward = accountCreator.create();
+    assertUserBranchWithoutAccountConfig(anonymousCoward.getId());
+
+    String status = "OOO";
+    Account account =
+        accountsUpdate
+            .create()
+            .update("Set status", anonymousCoward.getId(), u -> u.setStatus(status));
+    assertThat(account).isNotNull();
+    assertThat(account.getFullName()).isNull();
+    assertThat(account.getStatus()).isEqualTo(status);
+    assertUserBranch(anonymousCoward.getId(), null, status);
+  }
+
+  private void assertUserBranchWithoutAccountConfig(Account.Id accountId) throws Exception {
+    assertUserBranch(accountId, null, null);
+  }
+
+  private void assertUserBranch(
+      Account.Id accountId, @Nullable String name, @Nullable String status) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo);
+        ObjectReader or = repo.newObjectReader()) {
+      Ref ref = repo.exactRef(RefNames.refsUsers(accountId));
+      assertThat(ref).isNotNull();
+      RevCommit c = rw.parseCommit(ref.getObjectId());
+      long timestampDiffMs =
+          Math.abs(
+              c.getCommitTime() * 1000L
+                  - accountCache.get(accountId).getAccount().getRegisteredOn().getTime());
+      assertThat(timestampDiffMs).isAtMost(ChangeRebuilderImpl.MAX_WINDOW_MS);
+
+      // Check the 'account.config' file.
+      try (TreeWalk tw = TreeWalk.forPath(or, AccountConfig.ACCOUNT_CONFIG, c.getTree())) {
+        if (name != null || status != null) {
+          assertThat(tw).isNotNull();
+          Config cfg = new Config();
+          cfg.fromText(new String(or.open(tw.getObjectId(0), OBJ_BLOB).getBytes(), UTF_8));
+          assertThat(cfg.getString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_FULL_NAME))
+              .isEqualTo(name);
+          assertThat(cfg.getString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_STATUS))
+              .isEqualTo(status);
+        } else {
+          // No account properties were set, hence an 'account.config' file was not created.
+          assertThat(tw).isNull();
+        }
+      }
+    }
+  }
+
+  @Test
+  public void get() throws Exception {
+    AccountInfo info = gApi.accounts().id("admin").get();
+    assertThat(info.name).isEqualTo("Administrator");
+    assertThat(info.email).isEqualTo("admin@example.com");
+    assertThat(info.username).isEqualTo("admin");
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void getByIntId() throws Exception {
+    AccountInfo info = gApi.accounts().id("admin").get();
+    AccountInfo infoByIntId = gApi.accounts().id(info._accountId).get();
+    assertThat(info.name).isEqualTo(infoByIntId.name);
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void self() throws Exception {
+    AccountInfo info = gApi.accounts().self().get();
+    assertUser(info, admin);
+
+    info = gApi.accounts().id("self").get();
+    assertUser(info, admin);
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void active() throws Exception {
+    assertThat(gApi.accounts().id("user").getActive()).isTrue();
+    gApi.accounts().id("user").setActive(false);
+    assertThat(gApi.accounts().id("user").getActive()).isFalse();
+    accountIndexedCounter.assertReindexOf(user);
+
+    gApi.accounts().id("user").setActive(true);
+    assertThat(gApi.accounts().id("user").getActive()).isTrue();
+    accountIndexedCounter.assertReindexOf(user);
+  }
+
+  @Test
+  public void deactivateSelf() throws Exception {
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("cannot deactivate own account");
+    gApi.accounts().self().setActive(false);
+  }
+
+  @Test
+  public void deactivateNotActive() throws Exception {
+    assertThat(gApi.accounts().id("user").getActive()).isTrue();
+    gApi.accounts().id("user").setActive(false);
+    assertThat(gApi.accounts().id("user").getActive()).isFalse();
+    try {
+      gApi.accounts().id("user").setActive(false);
+      fail("Expected exception");
+    } catch (ResourceConflictException e) {
+      assertThat(e.getMessage()).isEqualTo("account not active");
+    }
+    gApi.accounts().id("user").setActive(true);
+  }
+
+  @Test
+  public void starUnstarChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    refUpdateCounter.clear();
+
+    gApi.accounts().self().starChange(triplet);
+    ChangeInfo change = info(triplet);
+    assertThat(change.starred).isTrue();
+    assertThat(change.stars).contains(DEFAULT_LABEL);
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(
+            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
+
+    gApi.accounts().self().unstarChange(triplet);
+    change = info(triplet);
+    assertThat(change.starred).isNull();
+    assertThat(change.stars).isNull();
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(
+            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
+
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void starUnstarChangeWithLabels() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    refUpdateCounter.clear();
+
+    assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
+    assertThat(gApi.accounts().self().getStarredChanges()).isEmpty();
+
+    gApi.accounts()
+        .self()
+        .setStars(triplet, new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "red", "blue")));
+    ChangeInfo change = info(triplet);
+    assertThat(change.starred).isTrue();
+    assertThat(change.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
+    assertThat(gApi.accounts().self().getStars(triplet))
+        .containsExactly("blue", "red", DEFAULT_LABEL)
+        .inOrder();
+    List<ChangeInfo> starredChanges = gApi.accounts().self().getStarredChanges();
+    assertThat(starredChanges).hasSize(1);
+    ChangeInfo starredChange = starredChanges.get(0);
+    assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
+    assertThat(starredChange.starred).isTrue();
+    assertThat(starredChange.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(
+            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
+
+    gApi.accounts()
+        .self()
+        .setStars(
+            triplet,
+            new StarsInput(ImmutableSet.of("yellow"), ImmutableSet.of(DEFAULT_LABEL, "blue")));
+    change = info(triplet);
+    assertThat(change.starred).isNull();
+    assertThat(change.stars).containsExactly("red", "yellow").inOrder();
+    assertThat(gApi.accounts().self().getStars(triplet)).containsExactly("red", "yellow").inOrder();
+    starredChanges = gApi.accounts().self().getStarredChanges();
+    assertThat(starredChanges).hasSize(1);
+    starredChange = starredChanges.get(0);
+    assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
+    assertThat(starredChange.starred).isNull();
+    assertThat(starredChange.stars).containsExactly("red", "yellow").inOrder();
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(
+            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
+
+    accountIndexedCounter.assertNoReindex();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to get stars of another account");
+    gApi.accounts().id(Integer.toString((admin.id.get()))).getStars(triplet);
+  }
+
+  @Test
+  public void starWithInvalidLabels() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid labels: another invalid label, invalid label");
+    gApi.accounts()
+        .self()
+        .setStars(
+            triplet,
+            new StarsInput(
+                ImmutableSet.of(DEFAULT_LABEL, "invalid label", "blue", "another invalid label")));
+  }
+
+  @Test
+  public void starWithDefaultAndIgnoreLabel() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "The labels "
+            + DEFAULT_LABEL
+            + " and "
+            + IGNORE_LABEL
+            + " are mutually exclusive."
+            + " Only one of them can be set.");
+    gApi.accounts()
+        .self()
+        .setStars(triplet, new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "blue", IGNORE_LABEL)));
+  }
+
+  @Test
+  public void ignoreChangeBySetStars() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    accountIndexedCounter.clear();
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    in = new AddReviewerInput();
+    in.reviewer = user2.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    setApiUser(user);
+    gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
+
+    sender.clear();
+    setApiUser(admin);
+    gApi.changes().id(r.getChangeId()).abandon();
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    assertThat(messages.get(0).rcpt()).containsExactly(user2.emailAddress);
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void addReviewerToIgnoredChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    setApiUser(user);
+    gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
+
+    sender.clear();
+    setApiUser(admin);
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message message = messages.get(0);
+    assertThat(message.rcpt()).containsExactly(user.emailAddress);
+    assertMailReplyTo(message, admin.email);
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void suggestAccounts() throws Exception {
+    String adminUsername = "admin";
+    List<AccountInfo> result = gApi.accounts().suggestAccounts().withQuery(adminUsername).get();
+    assertThat(result).hasSize(1);
+    assertThat(result.get(0).username).isEqualTo(adminUsername);
+
+    List<AccountInfo> resultShortcutApi = gApi.accounts().suggestAccounts(adminUsername).get();
+    assertThat(resultShortcutApi).hasSize(result.size());
+
+    List<AccountInfo> emptyResult = gApi.accounts().suggestAccounts("unknown").get();
+    assertThat(emptyResult).isEmpty();
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void getOwnEmails() throws Exception {
+    String email = "preferred@example.com";
+    TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
+
+    setApiUser(foo);
+    assertThat(getEmails()).containsExactly(email);
+
+    setApiUser(admin);
+    String secondaryEmail = "secondary@example.com";
+    EmailInput input = newEmailInput(secondaryEmail);
+    gApi.accounts().id(foo.id.hashCode()).addEmail(input);
+
+    setApiUser(foo);
+    assertThat(getEmails()).containsExactly(email, secondaryEmail);
+  }
+
+  @Test
+  public void cannotGetEmailsOfOtherAccountWithoutModifyAccount() throws Exception {
+    String email = "preferred2@example.com";
+    TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("modify account not permitted");
+    gApi.accounts().id(foo.id.get()).getEmails();
+  }
+
+  @Test
+  public void getEmailsOfOtherAccount() throws Exception {
+    String email = "preferred3@example.com";
+    String secondaryEmail = "secondary3@example.com";
+    TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
+    EmailInput input = newEmailInput(secondaryEmail);
+    gApi.accounts().id(foo.id.hashCode()).addEmail(input);
+
+    assertThat(
+            gApi.accounts()
+                .id(foo.id.get())
+                .getEmails()
+                .stream()
+                .map(e -> e.email)
+                .collect(toSet()))
+        .containsExactly(email, secondaryEmail);
+  }
+
+  @Test
+  public void addEmail() throws Exception {
+    List<String> emails = ImmutableList.of("new.email@example.com", "new.email@example.systems");
+    Set<String> currentEmails = getEmails();
+    for (String email : emails) {
+      assertThat(currentEmails).doesNotContain(email);
+      EmailInput input = newEmailInput(email);
+      gApi.accounts().self().addEmail(input);
+      accountIndexedCounter.assertReindexOf(admin);
+    }
+
+    resetCurrentApiUser();
+    assertThat(getEmails()).containsAllIn(emails);
+  }
+
+  @Test
+  public void addInvalidEmail() throws Exception {
+    List<String> emails =
+        ImmutableList.of(
+            // Missing domain part
+            "new.email",
+
+            // Missing domain part
+            "new.email@",
+
+            // Missing user part
+            "@example.com",
+
+            // Non-supported TLD  (see tlds-alpha-by-domain.txt)
+            "new.email@example.africa");
+    for (String email : emails) {
+      EmailInput input = newEmailInput(email);
+      try {
+        gApi.accounts().self().addEmail(input);
+        fail("Expected BadRequestException for invalid email address: " + email);
+      } catch (BadRequestException e) {
+        assertThat(e).hasMessageThat().isEqualTo("invalid email address");
+      }
+    }
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void cannotAddNonConfirmedEmailWithoutModifyAccountPermission() throws Exception {
+    TestAccount account = accountCreator.create(name("user"));
+    EmailInput input = newEmailInput("test@test.com");
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.accounts().id(account.username).addEmail(input);
+  }
+
+  @Test
+  public void cannotAddEmailAddressUsedByAnotherAccount() throws Exception {
+    String email = "new.email@example.com";
+    EmailInput input = newEmailInput(email);
+    gApi.accounts().self().addEmail(input);
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Identity 'mailto:" + email + "' in use by another account");
+    gApi.accounts().id(user.username).addEmail(input);
+  }
+
+  @Test
+  @GerritConfig(
+    name = "auth.registerEmailPrivateKey",
+    value = "HsOc6l+2lhS9G7sE/RsnS7Z6GJjdRDX14co="
+  )
+  public void addEmailSendsConfirmationEmail() throws Exception {
+    String email = "new.email@example.com";
+    EmailInput input = newEmailInput(email, false);
+    gApi.accounts().self().addEmail(input);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(new Address(email));
+  }
+
+  @Test
+  @GerritConfig(
+    name = "auth.registerEmailPrivateKey",
+    value = "HsOc6l+2lhS9G7sE/RsnS7Z6GJjdRDX14co="
+  )
+  public void addEmailToBeConfirmedToOwnAccount() throws Exception {
+    TestAccount user = accountCreator.create();
+    setApiUser(user);
+
+    String email = "self@example.com";
+    EmailInput input = newEmailInput(email, false);
+    gApi.accounts().self().addEmail(input);
+  }
+
+  @Test
+  public void cannotAddEmailToBeConfirmedToOtherAccountWithoutModifyAccountPermission()
+      throws Exception {
+    TestAccount user = accountCreator.create();
+    setApiUser(user);
+
+    exception.expect(AuthException.class);
+    exception.expectMessage("modify account not permitted");
+    gApi.accounts().id(admin.id.get()).addEmail(newEmailInput("foo@example.com", false));
+  }
+
+  @Test
+  @GerritConfig(
+    name = "auth.registerEmailPrivateKey",
+    value = "HsOc6l+2lhS9G7sE/RsnS7Z6GJjdRDX14co="
+  )
+  public void addEmailToBeConfirmedToOtherAccount() throws Exception {
+    TestAccount user = accountCreator.create();
+    String email = "me@example.com";
+    gApi.accounts().id(user.id.get()).addEmail(newEmailInput(email, false));
+  }
+
+  @Test
+  public void deleteEmail() throws Exception {
+    String email = "foo.bar@example.com";
+    EmailInput input = newEmailInput(email);
+    gApi.accounts().self().addEmail(input);
+
+    resetCurrentApiUser();
+    assertThat(getEmails()).contains(email);
+
+    accountIndexedCounter.clear();
+    gApi.accounts().self().deleteEmail(input.email);
+    accountIndexedCounter.assertReindexOf(admin);
+
+    resetCurrentApiUser();
+    assertThat(getEmails()).doesNotContain(email);
+  }
+
+  @Test
+  public void deleteEmailFromCustomExternalIdSchemes() throws Exception {
+    String email = "foo.bar@example.com";
+    String extId1 = "foo:bar";
+    String extId2 = "foo:baz";
+    accountsUpdate
+        .create()
+        .update(
+            "Add External IDs",
+            admin.id,
+            u ->
+                u.addExternalId(
+                        ExternalId.createWithEmail(ExternalId.Key.parse(extId1), admin.id, email))
+                    .addExternalId(
+                        ExternalId.createWithEmail(ExternalId.Key.parse(extId2), admin.id, email)));
+    accountIndexedCounter.assertReindexOf(admin);
+    assertThat(
+            gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
+        .containsAllOf(extId1, extId2);
+
+    resetCurrentApiUser();
+    assertThat(getEmails()).contains(email);
+
+    gApi.accounts().self().deleteEmail(email);
+    accountIndexedCounter.assertReindexOf(admin);
+
+    resetCurrentApiUser();
+    assertThat(getEmails()).doesNotContain(email);
+    assertThat(
+            gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
+        .containsNoneOf(extId1, extId2);
+  }
+
+  @Test
+  public void deleteEmailOfOtherUser() throws Exception {
+    String email = "foo.bar@example.com";
+    EmailInput input = new EmailInput();
+    input.email = email;
+    input.noConfirmation = true;
+    gApi.accounts().id(user.id.get()).addEmail(input);
+    accountIndexedCounter.assertReindexOf(user);
+
+    setApiUser(user);
+    assertThat(getEmails()).contains(email);
+
+    // admin can delete email of user
+    setApiUser(admin);
+    gApi.accounts().id(user.id.get()).deleteEmail(email);
+    accountIndexedCounter.assertReindexOf(user);
+
+    setApiUser(user);
+    assertThat(getEmails()).doesNotContain(email);
+
+    // user cannot delete email of admin
+    exception.expect(AuthException.class);
+    exception.expectMessage("modify account not permitted");
+    gApi.accounts().id(admin.id.get()).deleteEmail(admin.email);
+  }
+
+  @Test
+  public void lookUpByEmail() throws Exception {
+    // exact match with scheme "mailto:"
+    assertEmail(emails.getAccountFor(admin.email), admin);
+
+    // exact match with other scheme
+    String email = "foo.bar@example.com";
+    accountsUpdate
+        .create()
+        .update(
+            "Add Email",
+            admin.id,
+            u ->
+                u.addExternalId(
+                    ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id, email)));
+    assertEmail(emails.getAccountFor(email), admin);
+
+    // wrong case doesn't match
+    assertThat(emails.getAccountFor(admin.email.toUpperCase(Locale.US))).isEmpty();
+
+    // prefix doesn't match
+    assertThat(emails.getAccountFor(admin.email.substring(0, admin.email.indexOf('@')))).isEmpty();
+
+    // non-existing doesn't match
+    assertThat(emails.getAccountFor("non-existing@example.com")).isEmpty();
+
+    // lookup several accounts by email at once
+    ImmutableSetMultimap<String, Account.Id> byEmails =
+        emails.getAccountsFor(admin.email, user.email);
+    assertEmail(byEmails.get(admin.email), admin);
+    assertEmail(byEmails.get(user.email), user);
+  }
+
+  @Test
+  public void lookUpByPreferredEmail() throws Exception {
+    // create an inconsistent account that has a preferred email without external ID
+    String prefix = "foo.preferred";
+    String prefEmail = prefix + "@example.com";
+    TestAccount foo = accountCreator.create(name("foo"));
+    accountsUpdate
+        .create()
+        .update("Set Preferred Email", foo.id, u -> u.setPreferredEmail(prefEmail));
+
+    // verify that the account is still found when using the preferred email to lookup the account
+    ImmutableSet<Account.Id> accountsByPrefEmail = emails.getAccountFor(prefEmail);
+    assertThat(accountsByPrefEmail).hasSize(1);
+    assertThat(Iterables.getOnlyElement(accountsByPrefEmail)).isEqualTo(foo.id);
+
+    // look up by email prefix doesn't find the account
+    accountsByPrefEmail = emails.getAccountFor(prefix);
+    assertThat(accountsByPrefEmail).isEmpty();
+
+    // look up by other case doesn't find the account
+    accountsByPrefEmail = emails.getAccountFor(prefEmail.toUpperCase(Locale.US));
+    assertThat(accountsByPrefEmail).isEmpty();
+  }
+
+  @Test
+  public void putStatus() throws Exception {
+    List<String> statuses = ImmutableList.of("OOO", "Busy");
+    AccountInfo info;
+    for (String status : statuses) {
+      gApi.accounts().self().setStatus(status);
+      info = gApi.accounts().self().get();
+      assertUser(info, admin, status);
+      accountIndexedCounter.assertReindexOf(admin);
+    }
+
+    gApi.accounts().self().setStatus(null);
+    info = gApi.accounts().self().get();
+    assertUser(info, admin);
+    accountIndexedCounter.assertReindexOf(admin);
+  }
+
+  @Test
+  public void fetchUserBranch() throws Exception {
+    setApiUser(user);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
+    String userRefName = RefNames.refsUsers(user.id);
+
+    // remove default READ permissions
+    ProjectConfig cfg = projectCache.checkedGet(allUsers).getConfig();
+    cfg.getAccessSection(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true)
+        .remove(new Permission(Permission.READ));
+    saveProjectConfig(allUsers, cfg);
+
+    // deny READ permission that is inherited from All-Projects
+    deny(allUsers, RefNames.REFS + "*", Permission.READ, ANONYMOUS_USERS);
+
+    // fetching user branch without READ permission fails
+    try {
+      fetch(allUsersRepo, userRefName + ":userRef");
+      fail("user branch is visible although no READ permission is granted");
+    } catch (TransportException e) {
+      // expected because no READ granted on user branch
+    }
+
+    // allow each user to read its own user branch
+    grant(
+        allUsers,
+        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+        Permission.READ,
+        false,
+        REGISTERED_USERS);
+
+    // fetch user branch using refs/users/YY/XXXXXXX
+    fetch(allUsersRepo, userRefName + ":userRef");
+    Ref userRef = allUsersRepo.getRepository().exactRef("userRef");
+    assertThat(userRef).isNotNull();
+
+    // fetch user branch using refs/users/self
+    fetch(allUsersRepo, RefNames.REFS_USERS_SELF + ":userSelfRef");
+    Ref userSelfRef = allUsersRepo.getRepository().getRefDatabase().exactRef("userSelfRef");
+    assertThat(userSelfRef).isNotNull();
+    assertThat(userSelfRef.getObjectId()).isEqualTo(userRef.getObjectId());
+
+    accountIndexedCounter.assertNoReindex();
+
+    // fetching user branch of another user fails
+    String otherUserRefName = RefNames.refsUsers(admin.id);
+    exception.expect(TransportException.class);
+    exception.expectMessage("Remote does not have " + otherUserRefName + " available for fetch.");
+    fetch(allUsersRepo, otherUserRefName + ":otherUserRef");
+  }
+
+  @Test
+  public void pushToUserBranch() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    allUsersRepo.reset("userRef");
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
+    push.to(RefNames.refsUsers(admin.id)).assertOkStatus();
+    accountIndexedCounter.assertReindexOf(admin);
+
+    push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
+    push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+    accountIndexedCounter.assertReindexOf(admin);
+  }
+
+  @Test
+  public void pushToUserBranchForReview() throws Exception {
+    String userRefName = RefNames.refsUsers(admin.id);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, userRefName + ":userRef");
+    allUsersRepo.reset("userRef");
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
+    PushOneCommit.Result r = push.to(MagicBranch.NEW_CHANGE + userRefName);
+    r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    accountIndexedCounter.assertReindexOf(admin);
+
+    push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
+    r = push.to(MagicBranch.NEW_CHANGE + RefNames.REFS_USERS_SELF);
+    r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    accountIndexedCounter.assertReindexOf(admin);
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewAndSubmit() throws Exception {
+    String userRef = RefNames.refsUsers(admin.id);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_STATUS, "out-of-office");
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                ac.toText())
+            .to(MagicBranch.NEW_CHANGE + userRef);
+    r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    accountIndexedCounter.assertReindexOf(admin);
+
+    AccountInfo info = gApi.accounts().self().get();
+    assertThat(info.email).isEqualTo(admin.email);
+    assertThat(info.name).isEqualTo(admin.fullName);
+    assertThat(info.status).isEqualTo("out-of-office");
+  }
+
+  @Test
+  public void pushAccountConfigWithPrefEmailThatDoesNotExistAsExtIdToUserBranchForReviewAndSubmit()
+      throws Exception {
+    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+    String userRef = RefNames.refsUsers(foo.id);
+    accountIndexedCounter.clear();
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    String email = "some.email@example.com";
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_PREFERRED_EMAIL, email);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                foo.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                ac.toText())
+            .to(MagicBranch.NEW_CHANGE + userRef);
+    r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+
+    setApiUser(foo);
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    accountIndexedCounter.assertReindexOf(foo);
+
+    AccountInfo info = gApi.accounts().self().get();
+    assertThat(info.email).isEqualTo(email);
+    assertThat(info.name).isEqualTo(foo.fullName);
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfConfigIsInvalid()
+      throws Exception {
+    String userRef = RefNames.refsUsers(admin.id);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                "invalid config")
+            .to(MagicBranch.NEW_CHANGE + userRef);
+    r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        String.format(
+            "invalid account configuration: commit '%s' has an invalid '%s' file for account '%s':"
+                + " Invalid config file %s in commit %s",
+            r.getCommit().name(),
+            AccountConfig.ACCOUNT_CONFIG,
+            admin.id,
+            AccountConfig.ACCOUNT_CONFIG,
+            r.getCommit().name()));
+    gApi.changes().id(r.getChangeId()).current().submit();
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfPreferredEmailIsInvalid()
+      throws Exception {
+    String userRef = RefNames.refsUsers(admin.id);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    String noEmail = "no.email";
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_PREFERRED_EMAIL, noEmail);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                ac.toText())
+            .to(MagicBranch.NEW_CHANGE + userRef);
+    r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        String.format(
+            "invalid account configuration: invalid preferred email '%s' for account '%s'",
+            noEmail, admin.id));
+    gApi.changes().id(r.getChangeId()).current().submit();
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfOwnAccountIsDeactivated()
+      throws Exception {
+    String userRef = RefNames.refsUsers(admin.id);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setBoolean(AccountConfig.ACCOUNT, null, AccountConfig.KEY_ACTIVE, false);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                ac.toText())
+            .to(MagicBranch.NEW_CHANGE + userRef);
+    r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("invalid account configuration: cannot deactivate own account");
+    gApi.changes().id(r.getChangeId()).current().submit();
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewDeactivateOtherAccount() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestAccount foo = accountCreator.create(name("foo"));
+    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isTrue();
+    String userRef = RefNames.refsUsers(foo.id);
+    accountIndexedCounter.clear();
+
+    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
+    grantLabel("Code-Review", -2, 2, allUsers, userRef, false, adminGroupUuid(), false);
+    grant(allUsers, userRef, Permission.SUBMIT, false, adminGroupUuid());
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setBoolean(AccountConfig.ACCOUNT, null, AccountConfig.KEY_ACTIVE, false);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                ac.toText())
+            .to(MagicBranch.NEW_CHANGE + userRef);
+    r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    accountIndexedCounter.assertReindexOf(foo);
+
+    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isFalse();
+  }
+
+  @Test
+  public void pushWatchConfigToUserBranch() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config wc = new Config();
+    wc.setString(
+        WatchConfig.PROJECT,
+        project.get(),
+        WatchConfig.KEY_NOTIFY,
+        WatchConfig.NotifyValue.create(null, EnumSet.of(NotifyType.ALL_COMMENTS)).toString());
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            allUsersRepo,
+            "Add project watch",
+            WatchConfig.WATCH_CONFIG,
+            wc.toText());
+    push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+    accountIndexedCounter.assertReindexOf(admin);
+
+    String invalidNotifyValue = "]invalid[";
+    wc.setString(WatchConfig.PROJECT, project.get(), WatchConfig.KEY_NOTIFY, invalidNotifyValue);
+    push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            allUsersRepo,
+            "Add invalid project watch",
+            WatchConfig.WATCH_CONFIG,
+            wc.toText());
+    PushOneCommit.Result r = push.to(RefNames.REFS_USERS_SELF);
+    r.assertErrorStatus("invalid account configuration");
+    r.assertMessage(
+        String.format(
+            "%s: Invalid project watch of account %d for project %s: %s",
+            WatchConfig.WATCH_CONFIG, admin.getId().get(), project.get(), invalidNotifyValue));
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranch() throws Exception {
+    TestAccount oooUser = accountCreator.create("away", "away@mail.invalid", "Ambrose Way");
+    setApiUser(oooUser);
+
+    // Must clone as oooUser to ensure the push is allowed.
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, oooUser);
+    fetch(allUsersRepo, RefNames.refsUsers(oooUser.id) + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_STATUS, "out-of-office");
+
+    accountIndexedCounter.clear();
+    pushFactory
+        .create(
+            db,
+            oooUser.getIdent(),
+            allUsersRepo,
+            "Update account config",
+            AccountConfig.ACCOUNT_CONFIG,
+            ac.toText())
+        .to(RefNames.refsUsers(oooUser.id))
+        .assertOkStatus();
+
+    accountIndexedCounter.assertReindexOf(oooUser);
+
+    AccountInfo info = gApi.accounts().self().get();
+    assertThat(info.email).isEqualTo(oooUser.email);
+    assertThat(info.name).isEqualTo(oooUser.fullName);
+    assertThat(info.status).isEqualTo("out-of-office");
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIsRejectedIfConfigIsInvalid() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                "invalid config")
+            .to(RefNames.REFS_USERS_SELF);
+    r.assertErrorStatus("invalid account configuration");
+    r.assertMessage(
+        String.format(
+            "commit '%s' has an invalid '%s' file for account '%s':"
+                + " Invalid config file %s in commit %s",
+            r.getCommit().name(),
+            AccountConfig.ACCOUNT_CONFIG,
+            admin.id,
+            AccountConfig.ACCOUNT_CONFIG,
+            r.getCommit().name()));
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIsRejectedIfPreferredEmailIsInvalid() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    String noEmail = "no.email";
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_PREFERRED_EMAIL, noEmail);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                ac.toText())
+            .to(RefNames.REFS_USERS_SELF);
+    r.assertErrorStatus("invalid account configuration");
+    r.assertMessage(
+        String.format("invalid preferred email '%s' for account '%s'", noEmail, admin.id));
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchInvalidPreferredEmailButNotChanged() throws Exception {
+    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+    String userRef = RefNames.refsUsers(foo.id);
+
+    String noEmail = "no.email";
+    accountsUpdate
+        .create()
+        .update("Set Preferred Email", foo.id, u -> u.setPreferredEmail(noEmail));
+    accountIndexedCounter.clear();
+
+    grant(allUsers, userRef, Permission.PUSH, false, REGISTERED_USERS);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    String status = "in vacation";
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_STATUS, status);
+
+    pushFactory
+        .create(
+            db,
+            foo.getIdent(),
+            allUsersRepo,
+            "Update account config",
+            AccountConfig.ACCOUNT_CONFIG,
+            ac.toText())
+        .to(userRef)
+        .assertOkStatus();
+    accountIndexedCounter.assertReindexOf(foo);
+
+    AccountInfo info = gApi.accounts().id(foo.id.get()).get();
+    assertThat(info.email).isEqualTo(noEmail);
+    assertThat(info.name).isEqualTo(foo.fullName);
+    assertThat(info.status).isEqualTo(status);
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIfPreferredEmailDoesNotExistAsExtId() throws Exception {
+    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+    String userRef = RefNames.refsUsers(foo.id);
+    accountIndexedCounter.clear();
+
+    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    String email = "some.email@example.com";
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_PREFERRED_EMAIL, email);
+
+    pushFactory
+        .create(
+            db,
+            foo.getIdent(),
+            allUsersRepo,
+            "Update account config",
+            AccountConfig.ACCOUNT_CONFIG,
+            ac.toText())
+        .to(userRef)
+        .assertOkStatus();
+    accountIndexedCounter.assertReindexOf(foo);
+
+    AccountInfo info = gApi.accounts().id(foo.id.get()).get();
+    assertThat(info.email).isEqualTo(email);
+    assertThat(info.name).isEqualTo(foo.fullName);
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIsRejectedIfOwnAccountIsDeactivated() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setBoolean(AccountConfig.ACCOUNT, null, AccountConfig.KEY_ACTIVE, false);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                ac.toText())
+            .to(RefNames.REFS_USERS_SELF);
+    r.assertErrorStatus("invalid account configuration");
+    r.assertMessage("cannot deactivate own account");
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchDeactivateOtherAccount() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestAccount foo = accountCreator.create(name("foo"));
+    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isTrue();
+    String userRef = RefNames.refsUsers(foo.id);
+    accountIndexedCounter.clear();
+
+    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setBoolean(AccountConfig.ACCOUNT, null, AccountConfig.KEY_ACTIVE, false);
+
+    pushFactory
+        .create(
+            db,
+            admin.getIdent(),
+            allUsersRepo,
+            "Update account config",
+            AccountConfig.ACCOUNT_CONFIG,
+            ac.toText())
+        .to(userRef)
+        .assertOkStatus();
+    accountIndexedCounter.assertReindexOf(foo);
+
+    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isFalse();
+  }
+
+  @Test
+  public void cannotCreateUserBranch() throws Exception {
+    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
+    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
+
+    String userRef = RefNames.refsUsers(new Account.Id(seq.nextAccountId()));
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    PushOneCommit.Result r = pushFactory.create(db, admin.getIdent(), allUsersRepo).to(userRef);
+    r.assertErrorStatus();
+    assertThat(r.getMessage()).contains("Not allowed to create user branch.");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNull();
+    }
+  }
+
+  @Test
+  public void createUserBranchWithAccessDatabaseCapability() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
+    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
+
+    String userRef = RefNames.refsUsers(new Account.Id(seq.nextAccountId()));
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    pushFactory.create(db, admin.getIdent(), allUsersRepo).to(userRef).assertOkStatus();
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNotNull();
+    }
+  }
+
+  @Test
+  public void cannotCreateNonUserBranchUnderRefsUsersWithAccessDatabaseCapability()
+      throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
+    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
+
+    String userRef = RefNames.REFS_USERS + "foo";
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    PushOneCommit.Result r = pushFactory.create(db, admin.getIdent(), allUsersRepo).to(userRef);
+    r.assertErrorStatus();
+    assertThat(r.getMessage()).contains("Not allowed to create non-user branch under refs/users/.");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNull();
+    }
+  }
+
+  @Test
+  public void createDefaultUserBranch() throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(RefNames.REFS_USERS_DEFAULT)).isNull();
+    }
+
+    grant(allUsers, RefNames.REFS_USERS_DEFAULT, Permission.CREATE);
+    grant(allUsers, RefNames.REFS_USERS_DEFAULT, Permission.PUSH);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    pushFactory
+        .create(db, admin.getIdent(), allUsersRepo)
+        .to(RefNames.REFS_USERS_DEFAULT)
+        .assertOkStatus();
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(RefNames.REFS_USERS_DEFAULT)).isNotNull();
+    }
+  }
+
+  @Test
+  public void cannotDeleteUserBranch() throws Exception {
+    grant(
+        allUsers,
+        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+        Permission.DELETE,
+        true,
+        REGISTERED_USERS);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    String userRef = RefNames.refsUsers(admin.id);
+    PushResult r = deleteRef(allUsersRepo, userRef);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(userRef);
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(refUpdate.getMessage()).contains("Not allowed to delete user branch.");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNotNull();
+    }
+  }
+
+  @Test
+  public void deleteUserBranchWithAccessDatabaseCapability() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    grant(
+        allUsers,
+        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+        Permission.DELETE,
+        true,
+        REGISTERED_USERS);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    String userRef = RefNames.refsUsers(admin.id);
+    PushResult r = deleteRef(allUsersRepo, userRef);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(userRef);
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNull();
+    }
+
+    assertThat(accountCache.getOrNull(admin.id)).isNull();
+    assertThat(accountQueryProvider.get().byDefault(admin.id.toString())).isEmpty();
+  }
+
+  @Test
+  public void addGpgKey() throws Exception {
+    TestKey key = validKeyWithoutExpiration();
+    String id = key.getKeyIdString();
+    addExternalIdEmail(admin, "test1@example.com");
+
+    assertKeyMapContains(key, addGpgKey(key.getPublicKeyArmored()));
+    assertKeys(key);
+
+    setApiUser(user);
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage(id);
+    gApi.accounts().self().gpgKey(id).get();
+  }
+
+  @Test
+  public void reAddExistingGpgKey() throws Exception {
+    addExternalIdEmail(admin, "test5@example.com");
+    TestKey key = validKeyWithSecondUserId();
+    String id = key.getKeyIdString();
+    PGPPublicKey pk = key.getPublicKey();
+
+    GpgKeyInfo info = addGpgKey(armor(pk)).get(id);
+    assertThat(info.userIds).hasSize(2);
+    assertIteratorSize(2, getOnlyKeyFromStore(key).getUserIDs());
+
+    pk = PGPPublicKey.removeCertification(pk, "foo:myId");
+    info = addGpgKeyNoReindex(armor(pk)).get(id);
+    assertThat(info.userIds).hasSize(1);
+    assertIteratorSize(1, getOnlyKeyFromStore(key).getUserIDs());
+  }
+
+  @Test
+  public void addOtherUsersGpgKey_Conflict() throws Exception {
+    // Both users have a matching external ID for this key.
+    addExternalIdEmail(admin, "test5@example.com");
+    accountsUpdate
+        .create()
+        .update(
+            "Add External ID",
+            user.getId(),
+            u -> u.addExternalId(ExternalId.create("foo", "myId", user.getId())));
+    accountIndexedCounter.assertReindexOf(user);
+
+    TestKey key = validKeyWithSecondUserId();
+    addGpgKey(key.getPublicKeyArmored());
+    setApiUser(user);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("GPG key already associated with another account");
+    addGpgKey(key.getPublicKeyArmored());
+  }
+
+  @Test
+  public void listGpgKeys() throws Exception {
+    List<TestKey> keys = allValidKeys();
+    List<String> toAdd = new ArrayList<>(keys.size());
+    for (TestKey key : keys) {
+      addExternalIdEmail(admin, PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress());
+      toAdd.add(key.getPublicKeyArmored());
+    }
+    gApi.accounts().self().putGpgKeys(toAdd, ImmutableList.<String>of());
+    assertKeys(keys);
+    accountIndexedCounter.assertReindexOf(admin);
+  }
+
+  @Test
+  public void deleteGpgKey() throws Exception {
+    TestKey key = validKeyWithoutExpiration();
+    String id = key.getKeyIdString();
+    addExternalIdEmail(admin, "test1@example.com");
+    addGpgKey(key.getPublicKeyArmored());
+    assertKeys(key);
+
+    gApi.accounts().self().gpgKey(id).delete();
+    accountIndexedCounter.assertReindexOf(admin);
+    assertKeys();
+
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage(id);
+    gApi.accounts().self().gpgKey(id).get();
+  }
+
+  @Test
+  public void addAndRemoveGpgKeys() throws Exception {
+    for (TestKey key : allValidKeys()) {
+      addExternalIdEmail(admin, PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress());
+    }
+    TestKey key1 = validKeyWithoutExpiration();
+    TestKey key2 = validKeyWithExpiration();
+    TestKey key5 = validKeyWithSecondUserId();
+
+    Map<String, GpgKeyInfo> infos =
+        gApi.accounts()
+            .self()
+            .putGpgKeys(
+                ImmutableList.of(key1.getPublicKeyArmored(), key2.getPublicKeyArmored()),
+                ImmutableList.of(key5.getKeyIdString()));
+    assertThat(infos.keySet()).containsExactly(key1.getKeyIdString(), key2.getKeyIdString());
+    assertKeys(key1, key2);
+    accountIndexedCounter.assertReindexOf(admin);
+
+    infos =
+        gApi.accounts()
+            .self()
+            .putGpgKeys(
+                ImmutableList.of(key5.getPublicKeyArmored()),
+                ImmutableList.of(key1.getKeyIdString()));
+    assertThat(infos.keySet()).containsExactly(key1.getKeyIdString(), key5.getKeyIdString());
+    assertKeyMapContains(key5, infos);
+    assertThat(infos.get(key1.getKeyIdString()).key).isNull();
+    assertKeys(key2, key5);
+    accountIndexedCounter.assertReindexOf(admin);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Cannot both add and delete key: " + keyToString(key2.getPublicKey()));
+    infos =
+        gApi.accounts()
+            .self()
+            .putGpgKeys(
+                ImmutableList.of(key2.getPublicKeyArmored()),
+                ImmutableList.of(key2.getKeyIdString()));
+  }
+
+  @Test
+  public void addMalformedGpgKey() throws Exception {
+    String key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\ntest\n-----END PGP PUBLIC KEY BLOCK-----";
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Failed to parse GPG keys");
+    addGpgKey(key);
+  }
+
+  @Test
+  @UseSsh
+  public void sshKeys() throws Exception {
+    //
+    // The test account should initially have exactly one ssh key
+    List<SshKeyInfo> info = gApi.accounts().self().listSshKeys();
+    assertThat(info).hasSize(1);
+    assertSequenceNumbers(info);
+    SshKeyInfo key = info.get(0);
+    String inital = AccountCreator.publicKey(admin.sshKey, admin.email);
+    assertThat(key.sshPublicKey).isEqualTo(inital);
+    accountIndexedCounter.assertNoReindex();
+
+    // Add a new key
+    String newKey = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
+    gApi.accounts().self().addSshKey(newKey);
+    info = gApi.accounts().self().listSshKeys();
+    assertThat(info).hasSize(2);
+    assertSequenceNumbers(info);
+    accountIndexedCounter.assertReindexOf(admin);
+
+    // Add an existing key (the request succeeds, but the key isn't added again)
+    gApi.accounts().self().addSshKey(inital);
+    info = gApi.accounts().self().listSshKeys();
+    assertThat(info).hasSize(2);
+    assertSequenceNumbers(info);
+    accountIndexedCounter.assertNoReindex();
+
+    // Add another new key
+    String newKey2 = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
+    gApi.accounts().self().addSshKey(newKey2);
+    info = gApi.accounts().self().listSshKeys();
+    assertThat(info).hasSize(3);
+    assertSequenceNumbers(info);
+    accountIndexedCounter.assertReindexOf(admin);
+
+    // Delete second key
+    gApi.accounts().self().deleteSshKey(2);
+    info = gApi.accounts().self().listSshKeys();
+    assertThat(info).hasSize(2);
+    assertThat(info.get(0).seq).isEqualTo(1);
+    assertThat(info.get(1).seq).isEqualTo(3);
+    accountIndexedCounter.assertReindexOf(admin);
+  }
+
+  // reindex is tested by {@link AbstractQueryAccountsTest#reindex}
+  @Test
+  public void reindexPermissions() throws Exception {
+    // admin can reindex any account
+    setApiUser(admin);
+    gApi.accounts().id(user.username).index();
+    accountIndexedCounter.assertReindexOf(user);
+
+    // user can reindex own account
+    setApiUser(user);
+    gApi.accounts().self().index();
+    accountIndexedCounter.assertReindexOf(user);
+
+    // user cannot reindex any account
+    exception.expect(AuthException.class);
+    exception.expectMessage("modify account not permitted");
+    gApi.accounts().id(admin.username).index();
+  }
+
+  @Test
+  public void checkConsistency() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    resetCurrentApiUser();
+
+    // Create an account with a preferred email.
+    String username = name("foo");
+    String email = username + "@example.com";
+    TestAccount account = accountCreator.create(username, email, "Foo Bar");
+
+    ConsistencyCheckInput input = new ConsistencyCheckInput();
+    input.checkAccounts = new CheckAccountsInput();
+    ConsistencyCheckInfo checkInfo = gApi.config().server().checkConsistency(input);
+    assertThat(checkInfo.checkAccountsResult.problems).isEmpty();
+
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+
+    // Delete the external ID for the preferred email. This makes the account inconsistent since it
+    // now doesn't have an external ID for its preferred email.
+    accountsUpdate
+        .create()
+        .update(
+            "Delete External ID",
+            account.getId(),
+            u -> u.deleteExternalId(ExternalId.createEmail(account.getId(), email)));
+    expectedProblems.add(
+        new ConsistencyProblemInfo(
+            ConsistencyProblemInfo.Status.ERROR,
+            "Account '"
+                + account.getId().get()
+                + "' has no external ID for its preferred email '"
+                + email
+                + "'"));
+
+    checkInfo = gApi.config().server().checkConsistency(input);
+    assertThat(checkInfo.checkAccountsResult.problems).hasSize(expectedProblems.size());
+    assertThat(checkInfo.checkAccountsResult.problems).containsExactlyElementsIn(expectedProblems);
+  }
+
+  @Test
+  public void internalQueryFindActiveAndInactiveAccounts() throws Exception {
+    String name = name("foo");
+    assertThat(accountQueryProvider.get().byDefault(name)).isEmpty();
+
+    TestAccount foo1 = accountCreator.create(name + "-1");
+    assertThat(gApi.accounts().id(foo1.username).getActive()).isTrue();
+
+    TestAccount foo2 = accountCreator.create(name + "-2");
+    gApi.accounts().id(foo2.username).setActive(false);
+    assertThat(gApi.accounts().id(foo2.username).getActive()).isFalse();
+
+    assertThat(accountQueryProvider.get().byDefault(name)).hasSize(2);
+  }
+
+  @Test
+  public void checkMetaId() throws Exception {
+    // metaId is set when account is loaded
+    assertThat(accounts.get(admin.getId()).getAccount().getMetaId())
+        .isEqualTo(getMetaId(admin.getId()));
+
+    // metaId is set when account is created
+    AccountsUpdate au = accountsUpdate.create();
+    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account account = au.insert("Create Test Account", accountId, u -> {});
+    assertThat(account.getMetaId()).isEqualTo(getMetaId(accountId));
+
+    // metaId is set when account is updated
+    Account updatedAccount = au.update("Set Full Name", accountId, u -> u.setFullName("foo"));
+    assertThat(account.getMetaId()).isNotEqualTo(updatedAccount.getMetaId());
+    assertThat(updatedAccount.getMetaId()).isEqualTo(getMetaId(accountId));
+  }
+
+  private EmailInput newEmailInput(String email, boolean noConfirmation) {
+    EmailInput input = new EmailInput();
+    input.email = email;
+    input.noConfirmation = noConfirmation;
+    return input;
+  }
+
+  private EmailInput newEmailInput(String email) {
+    return newEmailInput(email, true);
+  }
+
+  private String getMetaId(Account.Id accountId) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo);
+        ObjectReader or = repo.newObjectReader()) {
+      Ref ref = repo.exactRef(RefNames.refsUsers(accountId));
+      return ref != null ? ref.getObjectId().name() : null;
+    }
+  }
+
+  @Test
+  public void groups() throws Exception {
+    assertGroups(
+        admin.username, ImmutableList.of("Anonymous Users", "Registered Users", "Administrators"));
+
+    // TODO: update when test user is fixed to be included in "Anonymous Users" and
+    //      "Registered Users" groups
+    assertGroups(user.username, ImmutableList.of());
+
+    String group = createGroup("group");
+    String newUser = createAccount("user1", group);
+    assertGroups(newUser, ImmutableList.of(group));
+  }
+
+  @Test
+  public void defaultPermissionsOnUserBranches() throws Exception {
+    String userRef = RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}";
+    assertPermissions(
+        allUsers,
+        groupRef(REGISTERED_USERS),
+        userRef,
+        true,
+        Permission.READ,
+        Permission.PUSH,
+        Permission.SUBMIT);
+
+    assertLabelPermission(
+        allUsers, groupRef(REGISTERED_USERS), userRef, true, "Code-Review", -2, 2);
+
+    assertPermissions(
+        allUsers,
+        adminGroupRef(),
+        RefNames.REFS_USERS_DEFAULT,
+        true,
+        Permission.READ,
+        Permission.PUSH,
+        Permission.CREATE);
+  }
+
+  @Test
+  public void retryOnLockFailure() throws Exception {
+    String status = "happy";
+    String fullName = "Foo";
+    AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
+    PersonIdent ident = serverIdent.get();
+    AccountsUpdate update =
+        new AccountsUpdate(
+            repoManager,
+            gitReferenceUpdated,
+            null,
+            allUsers,
+            metaDataUpdateInternalFactory,
+            new RetryHelper(
+                cfg,
+                retryMetrics,
+                null,
+                null,
+                null,
+                r -> r.withBlockStrategy(noSleepBlockStrategy)),
+            extIdNotesFactory,
+            ident,
+            ident,
+            () -> {
+              if (!doneBgUpdate.getAndSet(true)) {
+                try {
+                  accountsUpdate.create().update("Set Status", admin.id, u -> u.setStatus(status));
+                } catch (IOException | ConfigInvalidException | OrmException e) {
+                  // Ignore, the successful update of the account is asserted later
+                }
+              }
+            });
+    assertThat(doneBgUpdate.get()).isFalse();
+    AccountInfo accountInfo = gApi.accounts().id(admin.id.get()).get();
+    assertThat(accountInfo.status).isNull();
+    assertThat(accountInfo.name).isNotEqualTo(fullName);
+
+    Account updatedAccount = update.update("Set Full Name", admin.id, u -> u.setFullName(fullName));
+    assertThat(doneBgUpdate.get()).isTrue();
+
+    assertThat(updatedAccount.getStatus()).isEqualTo(status);
+    assertThat(updatedAccount.getFullName()).isEqualTo(fullName);
+
+    accountInfo = gApi.accounts().id(admin.id.get()).get();
+    assertThat(accountInfo.status).isEqualTo(status);
+    assertThat(accountInfo.name).isEqualTo(fullName);
+  }
+
+  @Test
+  public void failAfterRetryerGivesUp() throws Exception {
+    List<String> status = ImmutableList.of("foo", "bar", "baz");
+    String fullName = "Foo";
+    AtomicInteger bgCounter = new AtomicInteger(0);
+    PersonIdent ident = serverIdent.get();
+    AccountsUpdate update =
+        new AccountsUpdate(
+            repoManager,
+            gitReferenceUpdated,
+            null,
+            allUsers,
+            metaDataUpdateInternalFactory,
+            new RetryHelper(
+                cfg,
+                retryMetrics,
+                null,
+                null,
+                null,
+                r ->
+                    r.withStopStrategy(StopStrategies.stopAfterAttempt(status.size()))
+                        .withBlockStrategy(noSleepBlockStrategy)),
+            extIdNotesFactory,
+            ident,
+            ident,
+            () -> {
+              try {
+                accountsUpdate
+                    .create()
+                    .update(
+                        "Set Status",
+                        admin.id,
+                        u -> u.setStatus(status.get(bgCounter.getAndAdd(1))));
+              } catch (IOException | ConfigInvalidException | OrmException e) {
+                // Ignore, the expected exception is asserted later
+              }
+            });
+    assertThat(bgCounter.get()).isEqualTo(0);
+    AccountInfo accountInfo = gApi.accounts().id(admin.id.get()).get();
+    assertThat(accountInfo.status).isNull();
+    assertThat(accountInfo.name).isNotEqualTo(fullName);
+
+    try {
+      update.update("Set Full Name", admin.id, u -> u.setFullName(fullName));
+      fail("expected LockFailureException");
+    } catch (LockFailureException e) {
+      // Ignore, expected
+    }
+    assertThat(bgCounter.get()).isEqualTo(status.size());
+
+    Account updatedAccount = accounts.get(admin.id).getAccount();
+    assertThat(updatedAccount.getStatus()).isEqualTo(Iterables.getLast(status));
+    assertThat(updatedAccount.getFullName()).isEqualTo(admin.fullName);
+
+    accountInfo = gApi.accounts().id(admin.id.get()).get();
+    assertThat(accountInfo.status).isEqualTo(Iterables.getLast(status));
+    assertThat(accountInfo.name).isEqualTo(admin.fullName);
+  }
+
+  @Test
+  public void stalenessChecker() throws Exception {
+    // Newly created account is not stale.
+    AccountInfo accountInfo = gApi.accounts().create(name("foo")).get();
+    Account.Id accountId = new Account.Id(accountInfo._accountId);
+    assertThat(stalenessChecker.isStale(accountId)).isFalse();
+
+    // Manually updating the user ref makes the index document stale.
+    String userRef = RefNames.refsUsers(accountId);
+    try (Repository repo = repoManager.openRepository(allUsers);
+        ObjectInserter oi = repo.newObjectInserter();
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
+
+      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.nowTs());
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(commit.getTree());
+      cb.setCommitter(ident);
+      cb.setAuthor(ident);
+      cb.setMessage(commit.getFullMessage());
+      ObjectId emptyCommit = oi.insert(cb);
+      oi.flush();
+
+      RefUpdate updateRef = repo.updateRef(userRef);
+      updateRef.setExpectedOldObjectId(commit.toObjectId());
+      updateRef.setNewObjectId(emptyCommit);
+      assertThat(updateRef.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+    assertStaleAccountAndReindex(accountId);
+
+    // Manually inserting/updating/deleting an external ID of the user makes the index document
+    // stale.
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+
+      ExternalId.Key key = ExternalId.Key.create("foo", "foo");
+      extIdNotes.insert(ExternalId.create(key, accountId));
+      try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+        extIdNotes.commit(update);
+      }
+      assertStaleAccountAndReindex(accountId);
+
+      extIdNotes.upsert(ExternalId.createWithEmail(key, accountId, "foo@example.com"));
+      try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+        extIdNotes.commit(update);
+      }
+      assertStaleAccountAndReindex(accountId);
+
+      extIdNotes.delete(accountId, key);
+      try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+        extIdNotes.commit(update);
+      }
+      assertStaleAccountAndReindex(accountId);
+    }
+
+    // Manually delete account
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
+      RefUpdate updateRef = repo.updateRef(userRef);
+      updateRef.setExpectedOldObjectId(commit.toObjectId());
+      updateRef.setNewObjectId(ObjectId.zeroId());
+      updateRef.setForceUpdate(true);
+      assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+    assertStaleAccountAndReindex(accountId);
+  }
+
+  private void assertStaleAccountAndReindex(Account.Id accountId) throws IOException {
+    // Evict account from cache to be sure that we use the index state for staleness checks. This
+    // has to happen directly on the accounts cache because AccountCacheImpl triggers a reindex for
+    // the account.
+    accountsCache.invalidate(accountId);
+    assertThat(stalenessChecker.isStale(accountId)).isTrue();
+
+    // Reindex fixes staleness
+    accountIndexer.index(accountId);
+    assertThat(stalenessChecker.isStale(accountId)).isFalse();
+  }
+
+  private void assertGroups(String user, List<String> expected) throws Exception {
+    List<String> actual =
+        gApi.accounts().id(user).getGroups().stream().map(g -> g.name).collect(toList());
+    assertThat(actual).containsExactlyElementsIn(expected);
+  }
+
+  private void assertSequenceNumbers(List<SshKeyInfo> sshKeys) {
+    int seq = 1;
+    for (SshKeyInfo key : sshKeys) {
+      assertThat(key.seq).isEqualTo(seq++);
+    }
+  }
+
+  private PGPPublicKey getOnlyKeyFromStore(TestKey key) throws Exception {
+    try (PublicKeyStore store = publicKeyStoreProvider.get()) {
+      Iterable<PGPPublicKeyRing> keys = store.get(key.getKeyId());
+      assertThat(keys).hasSize(1);
+      return keys.iterator().next().getPublicKey();
+    }
+  }
+
+  private static String armor(PGPPublicKey key) throws Exception {
+    ByteArrayOutputStream out = new ByteArrayOutputStream(4096);
+    try (ArmoredOutputStream aout = new ArmoredOutputStream(out)) {
+      key.encode(aout);
+    }
+    return new String(out.toByteArray(), UTF_8);
+  }
+
+  private static void assertIteratorSize(int size, Iterator<?> it) {
+    List<?> lst = ImmutableList.copyOf(it);
+    assertThat(lst).hasSize(size);
+  }
+
+  private static void assertKeyMapContains(TestKey expected, Map<String, GpgKeyInfo> actualMap) {
+    GpgKeyInfo actual = actualMap.get(expected.getKeyIdString());
+    assertThat(actual).isNotNull();
+    assertThat(actual.id).isNull();
+    actual.id = expected.getKeyIdString();
+    assertKeyEquals(expected, actual);
+  }
+
+  private void assertKeys(TestKey... expectedKeys) throws Exception {
+    assertKeys(Arrays.asList(expectedKeys));
+  }
+
+  private void assertKeys(Iterable<TestKey> expectedKeys) throws Exception {
+    // Check via API.
+    FluentIterable<TestKey> expected = FluentIterable.from(expectedKeys);
+    Map<String, GpgKeyInfo> keyMap = gApi.accounts().self().listGpgKeys();
+    assertThat(keyMap.keySet())
+        .named("keys returned by listGpgKeys()")
+        .containsExactlyElementsIn(expected.transform(TestKey::getKeyIdString));
+
+    for (TestKey key : expected) {
+      assertKeyEquals(key, gApi.accounts().self().gpgKey(key.getKeyIdString()).get());
+      assertKeyEquals(
+          key,
+          gApi.accounts()
+              .self()
+              .gpgKey(Fingerprint.toString(key.getPublicKey().getFingerprint()))
+              .get());
+      assertKeyMapContains(key, keyMap);
+    }
+
+    // Check raw external IDs.
+    Account.Id currAccountId = atrScope.get().getUser().getAccountId();
+    Iterable<String> expectedFps =
+        expected.transform(k -> BaseEncoding.base16().encode(k.getPublicKey().getFingerprint()));
+    Iterable<String> actualFps =
+        externalIds
+            .byAccount(currAccountId, SCHEME_GPGKEY)
+            .stream()
+            .map(e -> e.key().id())
+            .collect(toSet());
+    assertThat(actualFps).named("external IDs in database").containsExactlyElementsIn(expectedFps);
+
+    // Check raw stored keys.
+    for (TestKey key : expected) {
+      getOnlyKeyFromStore(key);
+    }
+  }
+
+  private static void assertKeyEquals(TestKey expected, GpgKeyInfo actual) {
+    String id = expected.getKeyIdString();
+    assertThat(actual.id).named(id).isEqualTo(id);
+    assertThat(actual.fingerprint)
+        .named(id)
+        .isEqualTo(Fingerprint.toString(expected.getPublicKey().getFingerprint()));
+    List<String> userIds = ImmutableList.copyOf(expected.getPublicKey().getUserIDs());
+    assertThat(actual.userIds).named(id).containsExactlyElementsIn(userIds);
+    assertThat(actual.key).named(id).startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n");
+    assertThat(actual.status).isEqualTo(GpgKeyInfo.Status.TRUSTED);
+    assertThat(actual.problems).isEmpty();
+  }
+
+  private void addExternalIdEmail(TestAccount account, String email) throws Exception {
+    checkNotNull(email);
+    accountsUpdate
+        .create()
+        .update(
+            "Add Email",
+            account.getId(),
+            u ->
+                u.addExternalId(
+                    ExternalId.createWithEmail(name("test"), email, account.getId(), email)));
+    accountIndexedCounter.assertReindexOf(account);
+    setApiUser(account);
+  }
+
+  private Map<String, GpgKeyInfo> addGpgKey(String armored) throws Exception {
+    Map<String, GpgKeyInfo> gpgKeys =
+        gApi.accounts().self().putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+    accountIndexedCounter.assertReindexOf(gApi.accounts().self().get());
+    return gpgKeys;
+  }
+
+  private Map<String, GpgKeyInfo> addGpgKeyNoReindex(String armored) throws Exception {
+    return gApi.accounts().self().putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+  }
+
+  private void assertUser(AccountInfo info, TestAccount account) throws Exception {
+    assertUser(info, account, null);
+  }
+
+  private void assertUser(AccountInfo info, TestAccount account, @Nullable String expectedStatus)
+      throws Exception {
+    assertThat(info.name).isEqualTo(account.fullName);
+    assertThat(info.email).isEqualTo(account.email);
+    assertThat(info.username).isEqualTo(account.username);
+    assertThat(info.status).isEqualTo(expectedStatus);
+  }
+
+  private Set<String> getEmails() throws RestApiException {
+    return gApi.accounts().self().getEmails().stream().map(e -> e.email).collect(toSet());
+  }
+
+  private void assertEmail(Set<Account.Id> accounts, TestAccount expectedAccount) {
+    assertThat(accounts).hasSize(1);
+    assertThat(Iterables.getOnlyElement(accounts)).isEqualTo(expectedAccount.getId());
+  }
+
+  private Config getAccountConfig(TestRepository<?> allUsersRepo) throws Exception {
+    Config ac = new Config();
+    try (TreeWalk tw =
+        TreeWalk.forPath(
+            allUsersRepo.getRepository(),
+            AccountConfig.ACCOUNT_CONFIG,
+            getHead(allUsersRepo.getRepository()).getTree())) {
+      assertThat(tw).isNotNull();
+      ac.fromText(
+          new String(
+              allUsersRepo
+                  .getRevWalk()
+                  .getObjectReader()
+                  .open(tw.getObjectId(0), OBJ_BLOB)
+                  .getBytes(),
+              UTF_8));
+    }
+    return ac;
+  }
+
+  /** Checks if an account is indexed the correct number of times. */
+  private static class AccountIndexedCounter implements AccountIndexedListener {
+    private final AtomicLongMap<Integer> countsByAccount = AtomicLongMap.create();
+
+    @Override
+    public void onAccountIndexed(int id) {
+      countsByAccount.incrementAndGet(id);
+    }
+
+    void clear() {
+      countsByAccount.clear();
+    }
+
+    long getCount(Account.Id accountId) {
+      return countsByAccount.get(accountId.get());
+    }
+
+    void assertReindexOf(TestAccount testAccount) {
+      assertReindexOf(testAccount, 1);
+    }
+
+    void assertReindexOf(AccountInfo accountInfo) {
+      assertReindexOf(new Account.Id(accountInfo._accountId), 1);
+    }
+
+    void assertReindexOf(TestAccount testAccount, int expectedCount) {
+      assertThat(getCount(testAccount.id)).isEqualTo(expectedCount);
+      assertThat(countsByAccount).hasSize(1);
+      clear();
+    }
+
+    void assertReindexOf(Account.Id accountId, int expectedCount) {
+      assertThat(getCount(accountId)).isEqualTo(expectedCount);
+      countsByAccount.remove(accountId.get());
+    }
+
+    void assertNoReindex() {
+      assertThat(countsByAccount).isEmpty();
+    }
+  }
+
+  private static class RefUpdateCounter implements GitReferenceUpdatedListener {
+    private final AtomicLongMap<String> countsByProjectRefs = AtomicLongMap.create();
+
+    static String projectRef(Project.NameKey project, String ref) {
+      return projectRef(project.get(), ref);
+    }
+
+    static String projectRef(String project, String ref) {
+      return project + ":" + ref;
+    }
+
+    @Override
+    public void onGitReferenceUpdated(Event event) {
+      countsByProjectRefs.incrementAndGet(projectRef(event.getProjectName(), event.getRefName()));
+    }
+
+    void clear() {
+      countsByProjectRefs.clear();
+    }
+
+    long getCount(String projectRef) {
+      return countsByProjectRefs.get(projectRef);
+    }
+
+    void assertRefUpdateFor(String... projectRefs) {
+      Map<String, Integer> expectedRefUpdateCounts = new HashMap<>();
+      for (String projectRef : projectRefs) {
+        expectedRefUpdateCounts.put(projectRef, 1);
+      }
+      assertRefUpdateFor(expectedRefUpdateCounts);
+    }
+
+    void assertRefUpdateFor(Map<String, Integer> expectedProjectRefUpdateCounts) {
+      for (Map.Entry<String, Integer> e : expectedProjectRefUpdateCounts.entrySet()) {
+        assertThat(getCount(e.getKey())).isEqualTo(e.getValue());
+      }
+      assertThat(countsByProjectRefs).hasSize(expectedProjectRefUpdateCounts.size());
+      clear();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
new file mode 100644
index 0000000..a98d103
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -0,0 +1,244 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.common.AgreementInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class AgreementsIT extends AbstractDaemonTest {
+  private ContributorAgreement caAutoVerify;
+  private ContributorAgreement caNoAutoVerify;
+
+  @ConfigSuite.Config
+  public static Config enableAgreementsConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("auth", null, "contributorAgreements", true);
+    return cfg;
+  }
+
+  @BeforeClass
+  public static void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @AfterClass
+  public static void restoreTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    caAutoVerify = configureContributorAgreement(true);
+    caNoAutoVerify = configureContributorAgreement(false);
+    setApiUser(user);
+  }
+
+  @Test
+  public void getAvailableAgreements() throws Exception {
+    ServerInfo info = gApi.config().server().getInfo();
+    if (isContributorAgreementsEnabled()) {
+      assertThat(info.auth.useContributorAgreements).isTrue();
+      assertThat(info.auth.contributorAgreements).hasSize(2);
+      assertAgreement(info.auth.contributorAgreements.get(0), caAutoVerify);
+      assertAgreement(info.auth.contributorAgreements.get(1), caNoAutoVerify);
+    } else {
+      assertThat(info.auth.useContributorAgreements).isNull();
+      assertThat(info.auth.contributorAgreements).isNull();
+    }
+  }
+
+  @Test
+  public void signNonExistingAgreement() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("contributor agreement not found");
+    gApi.accounts().self().signAgreement("does-not-exist");
+  }
+
+  @Test
+  public void signAgreementNoAutoVerify() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("cannot enter a non-autoVerify agreement");
+    gApi.accounts().self().signAgreement(caNoAutoVerify.getName());
+  }
+
+  @Test
+  public void signAgreement() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+
+    // List of agreements is initially empty
+    List<AgreementInfo> result = gApi.accounts().self().listAgreements();
+    assertThat(result).isEmpty();
+
+    // Sign the agreement
+    gApi.accounts().self().signAgreement(caAutoVerify.getName());
+
+    // Explicitly reset the user to force a new request context
+    setApiUser(user);
+
+    // Verify that the agreement was signed
+    result = gApi.accounts().self().listAgreements();
+    assertThat(result).hasSize(1);
+    AgreementInfo info = result.get(0);
+    assertAgreement(info, caAutoVerify);
+
+    // Signing the same agreement again has no effect
+    gApi.accounts().self().signAgreement(caAutoVerify.getName());
+    result = gApi.accounts().self().listAgreements();
+    assertThat(result).hasSize(1);
+  }
+
+  @Test
+  public void agreementsDisabledSign() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isFalse();
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("contributor agreements disabled");
+    gApi.accounts().self().signAgreement(caAutoVerify.getName());
+  }
+
+  @Test
+  public void agreementsDisabledList() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isFalse();
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("contributor agreements disabled");
+    gApi.accounts().self().listAgreements();
+  }
+
+  @Test
+  public void revertChangeWithoutCLA() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+
+    // Create a change succeeds when agreement is not required
+    setUseContributorAgreements(InheritableBoolean.FALSE);
+    ChangeInfo change = gApi.changes().create(newChangeInput()).get();
+
+    // Approve and submit it
+    setApiUser(admin);
+    gApi.changes().id(change.changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(change.changeId).current().submit(new SubmitInput());
+
+    // Revert is not allowed when CLA is required but not signed
+    setApiUser(user);
+    setUseContributorAgreements(InheritableBoolean.TRUE);
+    exception.expect(AuthException.class);
+    exception.expectMessage("A Contributor Agreement must be completed");
+    gApi.changes().id(change.changeId).revert();
+  }
+
+  @Test
+  public void cherrypickChangeWithoutCLA() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+
+    // Create a new branch
+    setApiUser(admin);
+    BranchInfo dest =
+        gApi.projects()
+            .name(project.get())
+            .branch("cherry-pick-to")
+            .create(new BranchInput())
+            .get();
+
+    // Create a change succeeds when agreement is not required
+    setUseContributorAgreements(InheritableBoolean.FALSE);
+    ChangeInfo change = gApi.changes().create(newChangeInput()).get();
+
+    // Approve and submit it
+    gApi.changes().id(change.changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(change.changeId).current().submit(new SubmitInput());
+
+    // Cherry-pick is not allowed when CLA is required but not signed
+    setApiUser(user);
+    setUseContributorAgreements(InheritableBoolean.TRUE);
+    CherryPickInput in = new CherryPickInput();
+    in.destination = dest.ref;
+    in.message = change.subject;
+    exception.expect(AuthException.class);
+    exception.expectMessage("A Contributor Agreement must be completed");
+    gApi.changes().id(change.changeId).current().cherryPick(in);
+  }
+
+  @Test
+  public void createChangeRespectsCLA() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+
+    // Create a change succeeds when agreement is not required
+    setUseContributorAgreements(InheritableBoolean.FALSE);
+    gApi.changes().create(newChangeInput());
+
+    // Create a change is not allowed when CLA is required but not signed
+    setUseContributorAgreements(InheritableBoolean.TRUE);
+    try {
+      gApi.changes().create(newChangeInput());
+      fail("Expected AuthException");
+    } catch (AuthException e) {
+      assertThat(e.getMessage()).contains("A Contributor Agreement must be completed");
+    }
+
+    // Sign the agreement
+    gApi.accounts().self().signAgreement(caAutoVerify.getName());
+
+    // Explicitly reset the user to force a new request context
+    setApiUser(user);
+
+    // Create a change succeeds after signing the agreement
+    gApi.changes().create(newChangeInput());
+  }
+
+  private void assertAgreement(AgreementInfo info, ContributorAgreement ca) {
+    assertThat(info.name).isEqualTo(ca.getName());
+    assertThat(info.description).isEqualTo(ca.getDescription());
+    assertThat(info.url).isEqualTo(ca.getAgreementUrl());
+    if (ca.getAutoVerify() != null) {
+      assertThat(info.autoVerifyGroup.name).isEqualTo(ca.getAutoVerify().getName());
+    } else {
+      assertThat(info.autoVerifyGroup).isNull();
+    }
+  }
+
+  private ChangeInput newChangeInput() {
+    ChangeInput in = new ChangeInput();
+    in.branch = "master";
+    in.subject = "test";
+    in.project = project.get();
+    return in;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
new file mode 100644
index 0000000..7f7a5a6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
@@ -0,0 +1,10 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "api_account",
+    labels = [
+        "api",
+        "noci",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
new file mode 100644
index 0000000..ba340eb
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
@@ -0,0 +1,135 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.client.Theme;
+import org.junit.Test;
+
+@NoHttpd
+public class DiffPreferencesIT extends AbstractDaemonTest {
+  @Test
+  public void getDiffPreferences() throws Exception {
+    DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
+    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
+    assertPrefs(o, d);
+  }
+
+  @Test
+  public void setDiffPreferences() throws Exception {
+    DiffPreferencesInfo i = DiffPreferencesInfo.defaults();
+
+    // change all default values
+    i.context *= -1;
+    i.tabSize *= -1;
+    i.fontSize *= -1;
+    i.lineLength *= -1;
+    i.cursorBlinkRate = 500;
+    i.theme = Theme.MIDNIGHT;
+    i.ignoreWhitespace = Whitespace.IGNORE_ALL;
+    i.expandAllComments ^= true;
+    i.intralineDifference ^= true;
+    i.manualReview ^= true;
+    i.retainHeader ^= true;
+    i.showLineEndings ^= true;
+    i.showTabs ^= true;
+    i.showWhitespaceErrors ^= true;
+    i.skipDeleted ^= true;
+    i.skipUnchanged ^= true;
+    i.skipUncommented ^= true;
+    i.syntaxHighlighting ^= true;
+    i.hideTopMenu ^= true;
+    i.autoHideDiffTableHeader ^= true;
+    i.hideLineNumbers ^= true;
+    i.renderEntireFile ^= true;
+    i.hideEmptyPane ^= true;
+    i.matchBrackets ^= true;
+    i.lineWrapping ^= true;
+
+    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
+    assertPrefs(o, i);
+
+    // Partially fill input record
+    i = new DiffPreferencesInfo();
+    i.tabSize = 42;
+    DiffPreferencesInfo a = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
+    assertPrefs(a, o, "tabSize");
+    assertThat(a.tabSize).isEqualTo(42);
+  }
+
+  @Test
+  public void getDiffPreferencesWithConfiguredDefaults() throws Exception {
+    DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
+    int newLineLength = d.lineLength + 10;
+    int newTabSize = d.tabSize * 2;
+    int newFontSize = d.fontSize - 2;
+    DiffPreferencesInfo update = new DiffPreferencesInfo();
+    update.lineLength = newLineLength;
+    update.tabSize = newTabSize;
+    update.fontSize = newFontSize;
+    gApi.config().server().setDefaultDiffPreferences(update);
+
+    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
+
+    // assert configured defaults
+    assertThat(o.lineLength).isEqualTo(newLineLength);
+    assertThat(o.tabSize).isEqualTo(newTabSize);
+    assertThat(o.fontSize).isEqualTo(newFontSize);
+
+    // assert hard-coded defaults
+    assertPrefs(o, d, "lineLength", "tabSize", "fontSize");
+  }
+
+  @Test
+  public void overwriteConfiguredDefaults() throws Exception {
+    DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
+    int configuredDefaultLineLength = d.lineLength + 10;
+    DiffPreferencesInfo update = new DiffPreferencesInfo();
+    update.lineLength = configuredDefaultLineLength;
+    gApi.config().server().setDefaultDiffPreferences(update);
+
+    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
+    assertThat(o.lineLength).isEqualTo(configuredDefaultLineLength);
+    assertPrefs(o, d, "lineLength");
+
+    int newLineLength = configuredDefaultLineLength + 10;
+    DiffPreferencesInfo i = new DiffPreferencesInfo();
+    i.lineLength = newLineLength;
+    DiffPreferencesInfo a = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
+    assertThat(a.lineLength).isEqualTo(newLineLength);
+    assertPrefs(a, d, "lineLength");
+
+    a = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
+    assertThat(a.lineLength).isEqualTo(newLineLength);
+    assertPrefs(a, d, "lineLength");
+
+    // overwrite the configured default with original hard-coded default
+    i = new DiffPreferencesInfo();
+    i.lineLength = d.lineLength;
+    a = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
+    assertThat(a.lineLength).isEqualTo(d.lineLength);
+    assertPrefs(a, d, "lineLength");
+
+    a = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
+    assertThat(a.lineLength).isEqualTo(d.lineLength);
+    assertPrefs(a, d, "lineLength");
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
rename to javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
new file mode 100644
index 0000000..946e15c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -0,0 +1,180 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DateFormat;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DefaultBase;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
+import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class GeneralPreferencesIT extends AbstractDaemonTest {
+  private TestAccount user42;
+
+  @Before
+  public void setUp() throws Exception {
+    String name = name("user42");
+    user42 = accountCreator.create(name, name + "@example.com", "User 42");
+  }
+
+  @Test
+  public void getAndSetPreferences() throws Exception {
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.id.toString()).getPreferences();
+    assertPrefs(o, GeneralPreferencesInfo.defaults(), "my", "changeTable");
+    assertThat(o.my)
+        .containsExactly(
+            new MenuItem("Changes", "#/dashboard/self", null),
+            new MenuItem("Draft Comments", "#/q/has:draft", null),
+            new MenuItem("Edits", "#/q/has:edit", null),
+            new MenuItem("Watched Changes", "#/q/is:watched+is:open", null),
+            new MenuItem("Starred Changes", "#/q/is:starred", null),
+            new MenuItem("Groups", "#/groups/self", null));
+    assertThat(o.changeTable).isEmpty();
+
+    GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
+
+    // change all default values
+    i.changesPerPage *= -1;
+    i.showSiteHeader ^= true;
+    i.useFlashClipboard ^= true;
+    i.downloadCommand = DownloadCommand.REPO_DOWNLOAD;
+    i.dateFormat = DateFormat.US;
+    i.timeFormat = TimeFormat.HHMM_24;
+    i.emailStrategy = EmailStrategy.DISABLED;
+    i.emailFormat = EmailFormat.PLAINTEXT;
+    i.defaultBaseForMerges = DefaultBase.AUTO_MERGE;
+    i.expandInlineDiffs ^= true;
+    i.highlightAssigneeInChangeTable ^= true;
+    i.relativeDateInChangeTable ^= true;
+    i.sizeBarInChangeTable ^= true;
+    i.legacycidInChangeTable ^= true;
+    i.muteCommonPathPrefixes ^= true;
+    i.signedOffBy ^= true;
+    i.reviewCategoryStrategy = ReviewCategoryStrategy.ABBREV;
+    i.diffView = DiffView.UNIFIED_DIFF;
+    i.my = new ArrayList<>();
+    i.my.add(new MenuItem("name", "url"));
+    i.changeTable = new ArrayList<>();
+    i.changeTable.add("Status");
+    i.urlAliases = new HashMap<>();
+    i.urlAliases.put("foo", "bar");
+
+    o = gApi.accounts().id(user42.getId().toString()).setPreferences(i);
+    assertPrefs(o, i, "my");
+    assertThat(o.my).containsExactlyElementsIn(i.my);
+    assertThat(o.changeTable).containsExactlyElementsIn(i.changeTable);
+  }
+
+  @Test
+  public void getPreferencesWithConfiguredDefaults() throws Exception {
+    GeneralPreferencesInfo d = GeneralPreferencesInfo.defaults();
+    int newChangesPerPage = d.changesPerPage * 2;
+    GeneralPreferencesInfo update = new GeneralPreferencesInfo();
+    update.changesPerPage = newChangesPerPage;
+    gApi.config().server().setDefaultPreferences(update);
+
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.getId().toString()).getPreferences();
+
+    // assert configured defaults
+    assertThat(o.changesPerPage).isEqualTo(newChangesPerPage);
+
+    // assert hard-coded defaults
+    assertPrefs(o, d, "my", "changeTable", "changesPerPage");
+  }
+
+  @Test
+  public void overwriteConfiguredDefaults() throws Exception {
+    GeneralPreferencesInfo d = GeneralPreferencesInfo.defaults();
+    int configuredChangesPerPage = d.changesPerPage * 2;
+    GeneralPreferencesInfo update = new GeneralPreferencesInfo();
+    update.changesPerPage = configuredChangesPerPage;
+    gApi.config().server().setDefaultPreferences(update);
+
+    GeneralPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getPreferences();
+    assertThat(o.changesPerPage).isEqualTo(configuredChangesPerPage);
+    assertPrefs(o, d, "my", "changeTable", "changesPerPage");
+
+    int newChangesPerPage = configuredChangesPerPage * 2;
+    GeneralPreferencesInfo i = new GeneralPreferencesInfo();
+    i.changesPerPage = newChangesPerPage;
+    GeneralPreferencesInfo a = gApi.accounts().id(admin.getId().toString()).setPreferences(i);
+    assertThat(a.changesPerPage).isEqualTo(newChangesPerPage);
+    assertPrefs(a, d, "my", "changeTable", "changesPerPage");
+
+    a = gApi.accounts().id(admin.getId().toString()).getPreferences();
+    assertThat(a.changesPerPage).isEqualTo(newChangesPerPage);
+    assertPrefs(a, d, "my", "changeTable", "changesPerPage");
+
+    // overwrite the configured default with original hard-coded default
+    i = new GeneralPreferencesInfo();
+    i.changesPerPage = d.changesPerPage;
+    a = gApi.accounts().id(admin.getId().toString()).setPreferences(i);
+    assertThat(a.changesPerPage).isEqualTo(d.changesPerPage);
+    assertPrefs(a, d, "my", "changeTable", "changesPerPage");
+
+    a = gApi.accounts().id(admin.getId().toString()).getPreferences();
+    assertThat(a.changesPerPage).isEqualTo(d.changesPerPage);
+    assertPrefs(a, d, "my", "changeTable", "changesPerPage");
+  }
+
+  @Test
+  public void rejectMyMenuWithoutName() throws Exception {
+    GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
+    i.my = new ArrayList<>();
+    i.my.add(new MenuItem(null, "url"));
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("name for menu item is required");
+    gApi.accounts().id(user42.getId().toString()).setPreferences(i);
+  }
+
+  @Test
+  public void rejectMyMenuWithoutUrl() throws Exception {
+    GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
+    i.my = new ArrayList<>();
+    i.my.add(new MenuItem("name", null));
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("URL for menu item is required");
+    gApi.accounts().id(user42.getId().toString()).setPreferences(i);
+  }
+
+  @Test
+  public void trimMyMenuInput() throws Exception {
+    GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
+    i.my = new ArrayList<>();
+    i.my.add(new MenuItem(" name\t", " url\t", " _blank\t", " id\t"));
+
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.getId().toString()).setPreferences(i);
+    assertThat(o.my).containsExactly(new MenuItem("name", "url", "_blank", "id"));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
new file mode 100644
index 0000000..05eca2a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -0,0 +1,183 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.AbandonUtil;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.Inject;
+import java.util.List;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+public class AbandonIT extends AbstractDaemonTest {
+  @Inject private AbandonUtil abandonUtil;
+
+  @Test
+  public void abandon() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes().id(changeId).abandon();
+    ChangeInfo info = get(changeId, MESSAGES);
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("change is abandoned");
+    gApi.changes().id(changeId).abandon();
+  }
+
+  @Test
+  public void batchAbandon() throws Exception {
+    CurrentUser user = atrScope.get().getUser();
+    PushOneCommit.Result a = createChange();
+    PushOneCommit.Result b = createChange();
+    List<ChangeData> list = ImmutableList.of(a.getChange(), b.getChange());
+    batchAbandon.batchAbandon(batchUpdateFactory, a.getChange().project(), user, list, "deadbeef");
+
+    ChangeInfo info = get(a.getChangeId(), MESSAGES);
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
+
+    info = get(b.getChangeId(), MESSAGES);
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
+  }
+
+  @Test
+  public void batchAbandonChangeProject() throws Exception {
+    String project1Name = name("Project1");
+    String project2Name = name("Project2");
+    gApi.projects().create(project1Name);
+    gApi.projects().create(project2Name);
+    TestRepository<InMemoryRepository> project1 = cloneProject(new Project.NameKey(project1Name));
+    TestRepository<InMemoryRepository> project2 = cloneProject(new Project.NameKey(project2Name));
+
+    CurrentUser user = atrScope.get().getUser();
+    PushOneCommit.Result a = createChange(project1, "master", "x", "x", "x", "");
+    PushOneCommit.Result b = createChange(project2, "master", "x", "x", "x", "");
+    List<ChangeData> list = ImmutableList.of(a.getChange(), b.getChange());
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        String.format("Project name \"%s\" doesn't match \"%s\"", project2Name, project1Name));
+    batchAbandon.batchAbandon(batchUpdateFactory, new Project.NameKey(project1Name), user, list);
+  }
+
+  @Test
+  @GerritConfig(name = "changeCleanup.abandonAfter", value = "1w")
+  public void abandonInactiveOpenChanges() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+
+    // create 2 changes which will be abandoned ...
+    int id1 = createChange().getChange().getId().get();
+    int id2 = createChange().getChange().getId().get();
+
+    // ... because they are older than 1 week
+    TestTimeUtil.incrementClock(7 * 24, HOURS);
+
+    // create 1 new change that will not be abandoned
+    ChangeData cd = createChange().getChange();
+    int id3 = cd.getId().get();
+
+    assertThat(toChangeNumbers(query("is:open"))).containsExactly(id1, id2, id3);
+    assertThat(query("is:abandoned")).isEmpty();
+
+    abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+    assertThat(toChangeNumbers(query("is:open"))).containsExactly(id3);
+    assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id1, id2);
+  }
+
+  @Test
+  public void abandonNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("abandon not permitted");
+    gApi.changes().id(changeId).abandon();
+  }
+
+  @Test
+  public void abandonAndRestoreAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    grant(project, "refs/heads/master", Permission.ABANDON, false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(changeId).abandon();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
+    gApi.changes().id(changeId).restore();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void restore() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes().id(changeId).abandon();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
+
+    gApi.changes().id(changeId).restore();
+    ChangeInfo info = get(changeId, MESSAGES);
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("restored");
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("change is new");
+    gApi.changes().id(changeId).restore();
+  }
+
+  @Test
+  public void restoreNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes().id(changeId).abandon();
+    setApiUser(user);
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
+    exception.expect(AuthException.class);
+    exception.expectMessage("restore not permitted");
+    gApi.changes().id(changeId).restore();
+  }
+
+  private List<Integer> toChangeNumbers(List<ChangeInfo> changes) {
+    return changes.stream().map(i -> i._number).collect(toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/BUILD b/javatests/com/google/gerrit/acceptance/api/change/BUILD
new file mode 100644
index 0000000..fd6c6d1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/BUILD
@@ -0,0 +1,10 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "api_change",
+    labels = [
+        "api",
+        "noci",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
new file mode 100644
index 0000000..fa683cf
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -0,0 +1,3676 @@
+// 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.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
+import static com.google.gerrit.extensions.client.ListChangesOption.COMMIT_FOOTERS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
+import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
+import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
+import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.Util.category;
+import static com.google.gerrit.server.project.testing.Util.value;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.util.concurrent.AtomicLongMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.StarsInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.Comment.Range;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.TrackingIdInfo;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+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.server.ChangeMessagesUtil;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.change.ChangeResource;
+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.testing.Util;
+import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Stream;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class ChangeIT extends AbstractDaemonTest {
+  private String systemTimeZone;
+
+  @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+
+  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
+
+  private ChangeIndexedCounter changeIndexedCounter;
+  private RegistrationHandle changeIndexedCounterHandle;
+
+  @Before
+  public void setTimeForTesting() {
+    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZone);
+  }
+
+  @Before
+  public void addChangeIndexedCounter() {
+    changeIndexedCounter = new ChangeIndexedCounter();
+    changeIndexedCounterHandle = changeIndexedListeners.add(changeIndexedCounter);
+  }
+
+  @After
+  public void removeChangeIndexedCounter() {
+    if (changeIndexedCounterHandle != null) {
+      changeIndexedCounterHandle.remove();
+    }
+  }
+
+  @Test
+  public void reflog() throws Exception {
+    // Tests are using DfsRepository which does not implement getReflogReader,
+    // so this will always fail.
+    // TODO: change this if/when DfsRepository#getReflogReader is implemented.
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("reflog not supported");
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+
+  @Test
+  public void get() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    ChangeInfo c = info(triplet);
+    assertThat(c.id).isEqualTo(triplet);
+    assertThat(c.project).isEqualTo(project.get());
+    assertThat(c.branch).isEqualTo("master");
+    assertThat(c.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(c.subject).isEqualTo("test commit");
+    assertThat(c.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+    assertThat(c.mergeable).isTrue();
+    assertThat(c.changeId).isEqualTo(r.getChangeId());
+    assertThat(c.created).isEqualTo(c.updated);
+    assertThat(c._number).isEqualTo(r.getChange().getId().get());
+
+    assertThat(c.owner._accountId).isEqualTo(admin.getId().get());
+    assertThat(c.owner.name).isNull();
+    assertThat(c.owner.email).isNull();
+    assertThat(c.owner.username).isNull();
+    assertThat(c.owner.avatars).isNull();
+  }
+
+  @Test
+  public void setPrivateByOwner() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    setApiUser(user);
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+
+    gApi.changes().id(changeId).setPrivate(true, null);
+    ChangeInfo info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set private");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_PRIVATE);
+
+    gApi.changes().id(changeId).setPrivate(false, null);
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isNull();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Unset private");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_UNSET_PRIVATE);
+
+    String msg = "This is a security fix that must not be public.";
+    gApi.changes().id(changeId).setPrivate(true, msg);
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set private\n\n" + msg);
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_PRIVATE);
+
+    msg = "After this security fix has been released we can make it public now.";
+    gApi.changes().id(changeId).setPrivate(false, msg);
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isNull();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Unset private\n\n" + msg);
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_UNSET_PRIVATE);
+  }
+
+  @Test
+  public void administratorCanSetUserChangePrivate() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+
+    gApi.changes().id(changeId).setPrivate(true, null);
+    setApiUser(user);
+    ChangeInfo info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isTrue();
+  }
+
+  @Test
+  public void cannotSetOtherUsersChangePrivate() throws Exception {
+    PushOneCommit.Result result = createChange();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to mark private");
+    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
+  }
+
+  @Test
+  public void accessPrivate() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    setApiUser(user);
+    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
+    // Owner can always access its private changes.
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+
+    // Add admin as a reviewer.
+    gApi.changes().id(result.getChangeId()).addReviewer(admin.getId().toString());
+
+    // This change should be visible for admin as a reviewer.
+    setApiUser(admin);
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+
+    // Remove admin from reviewers.
+    gApi.changes().id(result.getChangeId()).reviewer(admin.getId().toString()).remove();
+
+    // This change should not be visible for admin anymore.
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: " + result.getChangeId());
+    gApi.changes().id(result.getChangeId());
+  }
+
+  @Test
+  public void privateChangeOfOtherUserCanBeAccessedWithPermission() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
+
+    allow("refs/*", Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS);
+    setApiUser(user);
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+  }
+
+  @Test
+  public void administratorCanUnmarkPrivateAfterMerging() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).setPrivate(true, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
+    merge(result);
+    gApi.changes().id(changeId).setPrivate(false, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+  }
+
+  @Test
+  public void administratorCanMarkPrivateAfterMerging() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+    merge(result);
+    gApi.changes().id(changeId).setPrivate(true, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
+  }
+
+  @Test
+  public void ownerCannotMarkPrivateAfterMerging() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+
+    merge(result);
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to mark private");
+    gApi.changes().id(changeId).setPrivate(true, null);
+  }
+
+  @Test
+  public void ownerCanUnmarkPrivateAfterMerging() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+    gApi.changes().id(changeId).addReviewer(admin.getId().toString());
+    gApi.changes().id(changeId).setPrivate(true, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
+
+    merge(result);
+
+    setApiUser(user);
+    gApi.changes().id(changeId).setPrivate(false, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+  }
+
+  @Test
+  public void setWorkInProgressNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result rwip = createChange();
+    String changeId = rwip.getChangeId();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to set work in progress");
+    gApi.changes().id(changeId).setWorkInProgress();
+  }
+
+  @Test
+  public void setReadyForReviewNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result rready = createChange();
+    String changeId = rready.getChangeId();
+    gApi.changes().id(changeId).setWorkInProgress();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to set ready for review");
+    gApi.changes().id(changeId).setReadyForReview();
+  }
+
+  @Test
+  public void hasReviewStarted() throws Exception {
+    PushOneCommit.Result r = createWorkInProgressChange();
+    String changeId = r.getChangeId();
+    ChangeInfo info = gApi.changes().id(changeId).get();
+    assertThat(info.hasReviewStarted).isFalse();
+
+    gApi.changes().id(changeId).setReadyForReview();
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.hasReviewStarted).isTrue();
+  }
+
+  @Test
+  public void pendingReviewersInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    PushOneCommit.Result r = createWorkInProgressChange();
+    String changeId = r.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().pendingReviewers).isEmpty();
+
+    // Add some pending reviewers.
+    TestAccount user1 =
+        accountCreator.create(name("user1"), name("user1") + "@example.com", "User 1");
+    TestAccount user2 =
+        accountCreator.create(name("user2"), name("user2") + "@example.com", "User 2");
+    TestAccount user3 =
+        accountCreator.create(name("user3"), name("user3") + "@example.com", "User 3");
+    TestAccount user4 =
+        accountCreator.create(name("user4"), name("user4") + "@example.com", "User 4");
+    ReviewInput in =
+        ReviewInput.noScore()
+            .reviewer(user1.email)
+            .reviewer(user2.email)
+            .reviewer(user3.email, CC, false)
+            .reviewer(user4.email, CC, false)
+            .reviewer("byemail1@example.com")
+            .reviewer("byemail2@example.com")
+            .reviewer("byemail3@example.com", CC, false)
+            .reviewer("byemail4@example.com", CC, false);
+    ReviewResult result = gApi.changes().id(changeId).revision("current").review(in);
+    assertThat(result.reviewers).isNotEmpty();
+    ChangeInfo info = gApi.changes().id(changeId).get();
+    Function<Collection<AccountInfo>, Collection<String>> toEmails =
+        ais -> ais.stream().map(ai -> ai.email).collect(toSet());
+    assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
+        .containsExactly(
+            admin.email, user1.email, user2.email, "byemail1@example.com", "byemail2@example.com");
+    assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
+        .containsExactly(user3.email, user4.email, "byemail3@example.com", "byemail4@example.com");
+    assertThat(info.pendingReviewers.get(REMOVED)).isNull();
+
+    // Stage some pending reviewer removals.
+    gApi.changes().id(changeId).reviewer(user1.email).remove();
+    gApi.changes().id(changeId).reviewer(user3.email).remove();
+    gApi.changes().id(changeId).reviewer("byemail1@example.com").remove();
+    gApi.changes().id(changeId).reviewer("byemail3@example.com").remove();
+    info = gApi.changes().id(changeId).get();
+    assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
+        .containsExactly(admin.email, user2.email, "byemail2@example.com");
+    assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
+        .containsExactly(user4.email, "byemail4@example.com");
+    assertThat(toEmails.apply(info.pendingReviewers.get(REMOVED)))
+        .containsExactly(user1.email, user3.email, "byemail1@example.com", "byemail3@example.com");
+
+    // "Undo" a removal.
+    in = ReviewInput.noScore().reviewer(user1.email);
+    gApi.changes().id(changeId).revision("current").review(in);
+    info = gApi.changes().id(changeId).get();
+    assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
+        .containsExactly(admin.email, user1.email, user2.email, "byemail2@example.com");
+    assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
+        .containsExactly(user4.email, "byemail4@example.com");
+    assertThat(toEmails.apply(info.pendingReviewers.get(REMOVED)))
+        .containsExactly(user3.email, "byemail1@example.com", "byemail3@example.com");
+
+    // "Commit" by moving out of WIP.
+    gApi.changes().id(changeId).setReadyForReview();
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.pendingReviewers).isEmpty();
+    assertThat(toEmails.apply(info.reviewers.get(REVIEWER)))
+        .containsExactly(admin.email, user1.email, user2.email, "byemail2@example.com");
+    assertThat(toEmails.apply(info.reviewers.get(CC)))
+        .containsExactly(user4.email, "byemail4@example.com");
+    assertThat(info.reviewers.get(REMOVED)).isNull();
+  }
+
+  @Test
+  public void toggleWorkInProgressState() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // With message
+    gApi.changes().id(changeId).setWorkInProgress("Needs some refactoring");
+
+    ChangeInfo info = gApi.changes().id(changeId).get();
+
+    assertThat(info.workInProgress).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).contains("Needs some refactoring");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_WIP);
+
+    gApi.changes().id(changeId).setReadyForReview("PTAL");
+
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.workInProgress).isNull();
+    assertThat(Iterables.getLast(info.messages).message).contains("PTAL");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_READY);
+
+    // No message
+    gApi.changes().id(changeId).setWorkInProgress();
+
+    info = gApi.changes().id(changeId).get();
+
+    assertThat(info.workInProgress).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set Work In Progress");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_WIP);
+
+    gApi.changes().id(changeId).setReadyForReview();
+
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.workInProgress).isNull();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set Ready For Review");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_READY);
+  }
+
+  @Test
+  public void reviewAndStartReview() throws Exception {
+    PushOneCommit.Result r = createWorkInProgressChange();
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+
+    ReviewInput in = ReviewInput.noScore().setWorkInProgress(false);
+    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    assertThat(result.ready).isTrue();
+
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.workInProgress).isNull();
+  }
+
+  @Test
+  public void reviewAndMoveToWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+
+    ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
+    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    assertThat(result.ready).isNull();
+
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.workInProgress).isTrue();
+  }
+
+  @Test
+  public void reviewAndSetWorkInProgressAndAddReviewerAndVote() throws Exception {
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+
+    ReviewInput in =
+        ReviewInput.approve().reviewer(user.email).label("Code-Review", 1).setWorkInProgress(true);
+    gApi.changes().id(r.getChangeId()).revision("current").review(in);
+
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.workInProgress).isTrue();
+    assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
+        .containsExactly(admin.id.get(), user.id.get());
+    assertThat(info.labels.get("Code-Review").recommended._accountId).isEqualTo(admin.id.get());
+  }
+
+  @Test
+  public void reviewWithWorkInProgressAndReadyReturnsError() throws Exception {
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    ReviewInput in = ReviewInput.noScore();
+    in.ready = true;
+    in.workInProgress = true;
+    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    assertThat(result.error).isEqualTo(PostReview.ERROR_WIP_READY_MUTUALLY_EXCLUSIVE);
+  }
+
+  @Test
+  public void reviewWithWorkInProgressByNonOwnerReturnsError() throws Exception {
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
+    setApiUser(user);
+    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    assertThat(result.error).isEqualTo(PostReview.ERROR_ONLY_OWNER_CAN_MODIFY_WORK_IN_PROGRESS);
+  }
+
+  @Test
+  public void getAmbiguous() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    String changeId = r1.getChangeId();
+    gApi.changes().id(changeId).get();
+
+    BranchInput b = new BranchInput();
+    b.revision = repo().exactRef("HEAD").getObjectId().name();
+    gApi.projects().name(project.get()).branch("other").create(b);
+
+    PushOneCommit push2 =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            PushOneCommit.FILE_CONTENT,
+            changeId);
+    PushOneCommit.Result r2 = push2.to("refs/for/other");
+    assertThat(r2.getChangeId()).isEqualTo(changeId);
+
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Multiple changes found for " + changeId);
+    gApi.changes().id(changeId).get();
+  }
+
+  @Test
+  public void revert() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert().get();
+
+    // expected messages on source change:
+    // 1. Uploaded patch set 1.
+    // 2. Patch Set 1: Code-Review+2
+    // 3. Change has been successfully merged by Administrator
+    // 4. Patch Set 1: Reverted
+    List<ChangeMessageInfo> sourceMessages =
+        new ArrayList<>(gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(sourceMessages).hasSize(4);
+    String expectedMessage =
+        String.format("Created a revert of this change as %s", revertChange.changeId);
+    assertThat(sourceMessages.get(3).message).isEqualTo(expectedMessage);
+
+    assertThat(revertChange.messages).hasSize(1);
+    assertThat(revertChange.messages.iterator().next().message).isEqualTo("Uploaded patch set 1.");
+    assertThat(revertChange.revertOf).isEqualTo(gApi.changes().id(r.getChangeId()).get()._number);
+  }
+
+  @Test
+  public void revertPreservesReviewersAndCcs() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput in = ReviewInput.approve();
+    in.reviewer(user.email);
+    in.reviewer(accountCreator.user2().email, ReviewerState.CC, true);
+    // Add user as reviewer that will create the revert
+    in.reviewer(accountCreator.admin2().email);
+
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    // expect both the original reviewers and CCs to be preserved
+    // original owner should be added as reviewer, user requesting the revert (new owner) removed
+    setApiUser(accountCreator.admin2());
+    Map<ReviewerState, Collection<AccountInfo>> result =
+        gApi.changes().id(r.getChangeId()).revert().get().reviewers;
+    assertThat(result).containsKey(ReviewerState.REVIEWER);
+
+    List<Integer> reviewers =
+        result.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
+    if (notesMigration.readChanges()) {
+      assertThat(result).containsKey(ReviewerState.CC);
+      List<Integer> ccs =
+          result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
+      assertThat(ccs).containsExactly(accountCreator.user2().id.get());
+      assertThat(reviewers).containsExactly(user.id.get(), admin.id.get());
+    } else {
+      assertThat(reviewers)
+          .containsExactly(user.id.get(), admin.id.get(), accountCreator.user2().id.get());
+    }
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void revertInitialCommit() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Cannot revert initial commit");
+    gApi.changes().id(r.getChangeId()).revert();
+  }
+
+  @FunctionalInterface
+  private interface Rebase {
+    void call(String id) throws RestApiException;
+  }
+
+  @Test
+  public void rebaseViaRevisionApi() throws Exception {
+    testRebase(id -> gApi.changes().id(id).current().rebase());
+  }
+
+  @Test
+  public void rebaseViaChangeApi() throws Exception {
+    testRebase(id -> gApi.changes().id(id).rebase());
+  }
+
+  private void testRebase(Rebase rebase) throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    // Add an approval whose score should be copied on trivial rebase
+    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+
+    String changeId = r2.getChangeId();
+    // Rebase the second change
+    rebase.call(changeId);
+
+    // Second change should have 2 patch sets and an approval
+    ChangeInfo c2 = gApi.changes().id(changeId).get(CURRENT_REVISION, DETAILED_LABELS);
+    assertThat(c2.revisions.get(c2.currentRevision)._number).isEqualTo(2);
+
+    // ...and the committer and description should be correct
+    ChangeInfo info = gApi.changes().id(changeId).get(CURRENT_REVISION, CURRENT_COMMIT);
+    GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
+    assertThat(committer.name).isEqualTo(admin.fullName);
+    assertThat(committer.email).isEqualTo(admin.email);
+    String description = info.revisions.get(info.currentRevision).description;
+    assertThat(description).isEqualTo("Rebase");
+
+    // ...and the approval was copied
+    LabelInfo cr = c2.labels.get("Code-Review");
+    assertThat(cr).isNotNull();
+    assertThat(cr.all).hasSize(1);
+    assertThat(cr.all.get(0).value).isEqualTo(1);
+
+    if (notesMigration.changePrimaryStorage() == PrimaryStorage.REVIEW_DB) {
+      // Ensure record was actually copied under ReviewDb
+      List<PatchSetApproval> psas =
+          unwrapDb(db)
+              .patchSetApprovals()
+              .byPatchSet(new PatchSet.Id(new Change.Id(c2._number), 2))
+              .toList();
+      assertThat(psas).hasSize(1);
+      assertThat(psas.get(0).getValue()).isEqualTo((short) 1);
+    }
+
+    // Rebasing the second change again should fail
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Change is already up to date");
+    gApi.changes().id(changeId).current().rebase();
+  }
+
+  @Test
+  public void rebaseNotAllowedWithoutPermission() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    // Rebase the second
+    String changeId = r2.getChangeId();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("rebase not permitted");
+    gApi.changes().id(changeId).rebase();
+  }
+
+  @Test
+  public void rebaseAllowedWithPermission() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    grant(project, "refs/heads/master", Permission.REBASE, false, REGISTERED_USERS);
+
+    // Rebase the second
+    String changeId = r2.getChangeId();
+    setApiUser(user);
+    gApi.changes().id(changeId).rebase();
+  }
+
+  @Test
+  public void rebaseNotAllowedWithoutPushPermission() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    grant(project, "refs/heads/master", Permission.REBASE, false, REGISTERED_USERS);
+    block("refs/for/*", Permission.PUSH, REGISTERED_USERS);
+
+    // Rebase the second
+    String changeId = r2.getChangeId();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("rebase not permitted");
+    gApi.changes().id(changeId).rebase();
+  }
+
+  @Test
+  public void rebaseNotAllowedForOwnerWithoutPushPermission() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    block("refs/for/*", Permission.PUSH, REGISTERED_USERS);
+
+    // Rebase the second
+    String changeId = r2.getChangeId();
+    exception.expect(AuthException.class);
+    exception.expectMessage("rebase not permitted");
+    gApi.changes().id(changeId).rebase();
+  }
+
+  @Test
+  public void deleteNewChangeAsAdmin() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    String changeId = changeResult.getChangeId();
+
+    gApi.changes().id(changeId).delete();
+
+    assertThat(query(changeId)).isEmpty();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteNewChangeAsNormalUser() throws Exception {
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    String changeId = changeResult.getChangeId();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("delete not permitted");
+    gApi.changes().id(changeId).delete();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteChangeAsUserWithDeleteOwnChangesPermission() throws Exception {
+    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
+
+    try {
+      PushOneCommit.Result changeResult =
+          pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+      String changeId = changeResult.getChangeId();
+      int id = changeResult.getChange().getId().id;
+      RevCommit commit = changeResult.getCommit();
+
+      setApiUser(user);
+      gApi.changes().id(changeId).delete();
+
+      assertThat(query(changeId)).isEmpty();
+
+      String ref = new Change.Id(id).toRefPrefix() + "1";
+      eventRecorder.assertRefUpdatedEvents(project.get(), ref, null, commit, commit, null);
+    } finally {
+      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
+    }
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteNewChangeOfAnotherUserAsAdmin() throws Exception {
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    changeResult.assertOkStatus();
+    String changeId = changeResult.getChangeId();
+
+    setApiUser(admin);
+    gApi.changes().id(changeId).delete();
+
+    assertThat(query(changeId)).isEmpty();
+  }
+
+  @Test
+  public void deleteNewChangeOfAnotherUserWithDeleteOwnChangesPermission() throws Exception {
+    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
+
+    try {
+      PushOneCommit.Result changeResult = createChange();
+      String changeId = changeResult.getChangeId();
+
+      setApiUser(user);
+      exception.expect(AuthException.class);
+      exception.expectMessage("delete not permitted");
+      gApi.changes().id(changeId).delete();
+    } finally {
+      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
+    }
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void deleteNewChangeForBranchWithoutCommits() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    String changeId = changeResult.getChangeId();
+
+    gApi.changes().id(changeId).delete();
+
+    assertThat(query(changeId)).isEmpty();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteAbandonedChangeAsNormalUser() throws Exception {
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    String changeId = changeResult.getChangeId();
+
+    setApiUser(user);
+    gApi.changes().id(changeId).abandon();
+
+    exception.expect(AuthException.class);
+    exception.expectMessage("delete not permitted");
+    gApi.changes().id(changeId).delete();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteAbandonedChangeOfAnotherUserAsAdmin() throws Exception {
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    String changeId = changeResult.getChangeId();
+
+    gApi.changes().id(changeId).abandon();
+
+    gApi.changes().id(changeId).delete();
+
+    assertThat(query(changeId)).isEmpty();
+  }
+
+  @Test
+  public void deleteMergedChange() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    String changeId = changeResult.getChangeId();
+
+    merge(changeResult);
+
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("delete not permitted");
+    gApi.changes().id(changeId).delete();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteMergedChangeWithDeleteOwnChangesPermission() throws Exception {
+    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
+
+    try {
+      PushOneCommit.Result changeResult =
+          pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+      String changeId = changeResult.getChangeId();
+
+      merge(changeResult);
+
+      setApiUser(user);
+      exception.expect(MethodNotAllowedException.class);
+      exception.expectMessage("delete not permitted");
+      gApi.changes().id(changeId).delete();
+    } finally {
+      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
+    }
+  }
+
+  @Test
+  public void deleteNewChangeWithMergedPatchSet() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    String changeId = changeResult.getChangeId();
+    Change.Id id = changeResult.getChange().getId();
+
+    merge(changeResult);
+    setChangeStatus(id, Change.Status.NEW);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        String.format("Cannot delete change %s: patch set 1 is already merged", id));
+    gApi.changes().id(changeId).delete();
+  }
+
+  @Test
+  public void rebaseUpToDateChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Change is already up to date");
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase();
+  }
+
+  @Test
+  public void rebaseConflict() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            "other content",
+            "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
+    r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    exception.expect(ResourceConflictException.class);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase();
+  }
+
+  @Test
+  public void rebaseChangeBase() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    PushOneCommit.Result r3 = createChange();
+    RebaseInput ri = new RebaseInput();
+
+    // rebase r3 directly onto master (break dep. towards r2)
+    ri.base = "";
+    gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).rebase(ri);
+    PatchSet ps3 = r3.getPatchSet();
+    assertThat(ps3.getId().get()).isEqualTo(2);
+
+    // rebase r2 onto r3 (referenced by ref)
+    ri.base = ps3.getId().toRefName();
+    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
+    PatchSet ps2 = r2.getPatchSet();
+    assertThat(ps2.getId().get()).isEqualTo(2);
+
+    // rebase r1 onto r2 (referenced by commit)
+    ri.base = ps2.getRevision().get();
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
+    PatchSet ps1 = r1.getPatchSet();
+    assertThat(ps1.getId().get()).isEqualTo(2);
+
+    // rebase r1 onto r3 (referenced by change number)
+    ri.base = String.valueOf(r3.getChange().getId().get());
+    gApi.changes().id(r1.getChangeId()).revision(ps1.getRevision().get()).rebase(ri);
+    assertThat(r1.getPatchSetId().get()).isEqualTo(3);
+  }
+
+  @Test
+  public void rebaseChangeBaseRecursion() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+
+    RebaseInput ri = new RebaseInput();
+    ri.base = r2.getCommit().name();
+    String expectedMessage =
+        "base change "
+            + r2.getChangeId()
+            + " is a descendant of the current change - recursion not allowed";
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(expectedMessage);
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
+  }
+
+  @Test
+  public void rebaseAbandonedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes().id(changeId).abandon();
+    ChangeInfo info = info(changeId);
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("change is abandoned");
+    gApi.changes().id(changeId).revision(r.getCommit().name()).rebase();
+  }
+
+  @Test
+  public void rebaseOntoAbandonedChange() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Abandon the first change
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes().id(changeId).abandon();
+    ChangeInfo info = info(changeId);
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+    RebaseInput ri = new RebaseInput();
+    ri.base = r.getCommit().name();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("base change is abandoned: " + changeId);
+    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
+  }
+
+  @Test
+  public void rebaseOntoSelf() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String commit = r.getCommit().name();
+    RebaseInput ri = new RebaseInput();
+    ri.base = commit;
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("cannot rebase change onto itself");
+    gApi.changes().id(changeId).revision(commit).rebase(ri);
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void changeNoParentToOneParent() throws Exception {
+    // create initial commit with no parent and push it as change, so that patch
+    // set 1 has no parent
+    RevCommit c = testRepo.commit().message("Initial commit").insertChangeId().create();
+    String id = GitUtil.getChangeId(testRepo, c).get();
+    testRepo.reset(c);
+
+    PushResult pr = pushHead(testRepo, "refs/for/master", false);
+    assertPushOk(pr, "refs/for/master");
+
+    ChangeInfo change = gApi.changes().id(id).get();
+    assertThat(change.revisions.get(change.currentRevision).commit.parents).isEmpty();
+
+    // create another initial commit with no parent and push it directly into
+    // the remote repository
+    c = testRepo.amend(c.getId()).message("Initial Empty Commit").create();
+    testRepo.reset(c);
+    pr = pushHead(testRepo, "refs/heads/master", false);
+    assertPushOk(pr, "refs/heads/master");
+
+    // create a successor commit and push it as second patch set to the change,
+    // so that patch set 2 has 1 parent
+    RevCommit c2 =
+        testRepo
+            .commit()
+            .message("Initial commit")
+            .parent(c)
+            .insertChangeId(id.substring(1))
+            .create();
+    testRepo.reset(c2);
+
+    pr = pushHead(testRepo, "refs/for/master", false);
+    assertPushOk(pr, "refs/for/master");
+
+    change = gApi.changes().id(id).get();
+    RevisionInfo rev = change.revisions.get(change.currentRevision);
+    assertThat(rev.commit.parents).hasSize(1);
+    assertThat(rev.commit.parents.get(0).commit).isEqualTo(c.name());
+
+    // check that change kind is correctly detected as REWORK
+    assertThat(rev.kind).isEqualTo(ChangeKind.REWORK);
+  }
+
+  @Test
+  public void pushCommitOfOtherUser() throws Exception {
+    // admin pushes commit of user
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), testRepo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+    CommitInfo commit = change.revisions.get(change.currentRevision).commit;
+    assertThat(commit.author.email).isEqualTo(user.email);
+    assertThat(commit.committer.email).isEqualTo(user.email);
+
+    // check that the author/committer was added as reviewer
+    Collection<AccountInfo> reviewers = change.reviewers.get(REVIEWER);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
+    assertThat(change.reviewers.get(CC)).isNull();
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains(admin.fullName + " has uploaded this change for review");
+    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertMailReplyTo(m, admin.email);
+  }
+
+  @Test
+  public void pushCommitOfOtherUserThatCannotSeeChange() throws Exception {
+    // create hidden project that is only visible to administrators
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.allow(cfg, Permission.READ, adminGroupUuid(), "refs/*");
+    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    // admin pushes commit of user
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+    CommitInfo commit = change.revisions.get(change.currentRevision).commit;
+    assertThat(commit.author.email).isEqualTo(user.email);
+    assertThat(commit.committer.email).isEqualTo(user.email);
+
+    // check the user cannot see the change
+    setApiUser(user);
+    try {
+      gApi.changes().id(result.getChangeId()).get();
+      fail("Expected ResourceNotFoundException");
+    } catch (ResourceNotFoundException e) {
+      // Expected.
+    }
+
+    // check that the author/committer was NOT added as reviewer (he can't see
+    // the change)
+    assertThat(change.reviewers.get(REVIEWER)).isNull();
+    assertThat(change.reviewers.get(CC)).isNull();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void pushCommitWithFooterOfOtherUser() throws Exception {
+    // admin pushes commit that references 'user' in a footer
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT
+                + "\n\n"
+                + FooterConstants.REVIEWED_BY.getName()
+                + ": "
+                + user.getIdent().toExternalString(),
+            PushOneCommit.FILE_NAME,
+            PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    // check that 'user' was added as reviewer
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    Collection<AccountInfo> reviewers = change.reviewers.get(REVIEWER);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
+    assertThat(change.reviewers.get(CC)).isNull();
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
+    assertThat(m.body()).contains("I'd like you to do a code review.");
+    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertMailReplyTo(m, admin.email);
+  }
+
+  @Test
+  public void pushCommitWithFooterOfOtherUserThatCannotSeeChange() throws Exception {
+    // create hidden project that is only visible to administrators
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.allow(cfg, Permission.READ, adminGroupUuid(), "refs/*");
+    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    // admin pushes commit that references 'user' in a footer
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            repo,
+            PushOneCommit.SUBJECT
+                + "\n\n"
+                + FooterConstants.REVIEWED_BY.getName()
+                + ": "
+                + user.getIdent().toExternalString(),
+            PushOneCommit.FILE_NAME,
+            PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    // check that 'user' cannot see the change
+    setApiUser(user);
+    try {
+      gApi.changes().id(result.getChangeId()).get();
+      fail("Expected ResourceNotFoundException");
+    } catch (ResourceNotFoundException e) {
+      // Expected.
+    }
+
+    // check that 'user' was NOT added as cc ('user' can't see the change)
+    setApiUser(admin);
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.reviewers.get(REVIEWER)).isNull();
+    assertThat(change.reviewers.get(CC)).isNull();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void addReviewerThatCannotSeeChange() throws Exception {
+    // create hidden project that is only visible to administrators
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.allow(cfg, Permission.READ, adminGroupUuid(), "refs/*");
+    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    // create change
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    // check the user cannot see the change
+    setApiUser(user);
+    try {
+      gApi.changes().id(result.getChangeId()).get();
+      fail("Expected ResourceNotFoundException");
+    } catch (ResourceNotFoundException e) {
+      // Expected.
+    }
+
+    // try to add user as reviewer
+    setApiUser(admin);
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(user.email);
+    assertThat(r.error).contains("does not have permission to see this change");
+    assertThat(r.reviewers).isNull();
+  }
+
+  @Test
+  public void addReviewerThatIsInactive() throws Exception {
+    PushOneCommit.Result result = createChange();
+
+    String username = name("new-user");
+    gApi.accounts().create(username).setActive(false);
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = username;
+    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(username);
+    assertThat(r.error).contains("identifies an inactive account");
+    assertThat(r.reviewers).isNull();
+  }
+
+  @Test
+  public void addReviewerThatIsInactiveEmailFallback() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    PushOneCommit.Result result = createChange();
+
+    String username = "user@domain.com";
+    gApi.accounts().create(username).setActive(false);
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = username;
+    in.state = ReviewerState.CC;
+    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(username);
+    assertThat(r.error).isNull();
+    // When adding by email, the reviewers field is also empty because we can't
+    // render a ReviewerInfo object for a non-account.
+    assertThat(r.reviewers).isNull();
+  }
+
+  @Test
+  public void addReviewer() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    PushOneCommit.Result r = createChange();
+    ChangeResource rsrc = parseResource(r);
+    String oldETag = rsrc.getETag();
+    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
+    assertThat(m.body()).contains("I'd like you to do a code review.");
+    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertMailReplyTo(m, admin.email);
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+
+    // When NoteDb is enabled adding a reviewer records that user as reviewer
+    // in NoteDb. When NoteDb is disabled adding a reviewer results in a dummy 0
+    // approval on the change which is treated as CC when the ChangeInfo is
+    // created.
+    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
+
+    // Ensure ETag and lastUpdatedOn are updated.
+    rsrc = parseResource(r);
+    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
+    assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
+
+    // Change status of reviewer and ensure ETag is updated.
+    oldETag = rsrc.getETag();
+    gApi.accounts().id(user.id.get()).setStatus("new status");
+    rsrc = parseResource(r);
+    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
+  }
+
+  @Test
+  public void notificationsForAddedWorkInProgressReviewers() throws Exception {
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    ReviewInput batchIn = new ReviewInput();
+    batchIn.reviewers = ImmutableList.of(in);
+
+    // Added reviewers not notified by default.
+    PushOneCommit.Result r = createWorkInProgressChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    assertThat(sender.getMessages()).hasSize(0);
+
+    // Default notification handling can be overridden.
+    r = createWorkInProgressChange();
+    in.notify = NotifyHandling.OWNER_REVIEWERS;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    assertThat(sender.getMessages()).hasSize(1);
+    sender.clear();
+
+    // Reviewers added via PostReview also not notified by default.
+    // In this case, the child ReviewerInput has a notify=OWNER_REVIEWERS
+    // that should be ignored.
+    r = createWorkInProgressChange();
+    gApi.changes().id(r.getChangeId()).revision("current").review(batchIn);
+    assertThat(sender.getMessages()).hasSize(0);
+
+    // Top-level notify property can force notifications when adding reviewer
+    // via PostReview.
+    r = createWorkInProgressChange();
+    batchIn.notify = NotifyHandling.OWNER_REVIEWERS;
+    gApi.changes().id(r.getChangeId()).revision("current").review(batchIn);
+    assertThat(sender.getMessages()).hasSize(1);
+  }
+
+  @Test
+  public void addReviewerWithNoteDbWhenDummyApprovalInReviewDbExists() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    assume().that(notesMigration.changePrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+
+    PushOneCommit.Result r = createChange();
+
+    // insert dummy approval in ReviewDb
+    PatchSetApproval psa =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(r.getPatchSetId(), user.id, new LabelId("Code-Review")),
+            (short) 0,
+            TimeUtil.nowTs());
+    db.patchSetApprovals().insert(Collections.singleton(psa));
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+  }
+
+  @Test
+  public void addSelfAsReviewer() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    PushOneCommit.Result r = createChange();
+    ChangeResource rsrc = parseResource(r);
+    String oldETag = rsrc.getETag();
+    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    // There should be no email notification when adding self
+    assertThat(sender.getMessages()).isEmpty();
+
+    // When NoteDb is enabled adding a reviewer records that user as reviewer
+    // in NoteDb. When NoteDb is disabled adding a reviewer results in a dummy 0
+    // approval on the change which is treated as CC when the ChangeInfo is
+    // created.
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
+
+    // Ensure ETag and lastUpdatedOn are updated.
+    rsrc = parseResource(r);
+    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
+    assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
+  }
+
+  @Test
+  public void implicitlyCcOnNonVotingReviewPgStyle() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    assertThat(getReviewerState(r.getChangeId(), user.id)).isEmpty();
+
+    // Exact request format made by PG UI at ddc6b7160fe416fed9e7e3180489d44c82fd64f8.
+    ReviewInput in = new ReviewInput();
+    in.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    in.labels = ImmutableMap.of();
+    in.message = "comment";
+    in.reviewers = ImmutableList.of();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+
+    // If we're not reading from NoteDb, then the CCed user will be returned in the REVIEWER state.
+    assertThat(getReviewerState(r.getChangeId(), user.id))
+        .hasValue(notesMigration.readChanges() ? CC : REVIEWER);
+  }
+
+  @Test
+  public void implicitlyCcOnNonVotingReviewGwtStyle() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    assertThat(getReviewerState(r.getChangeId(), user.id)).isEmpty();
+
+    // Exact request format made by GWT UI at ddc6b7160fe416fed9e7e3180489d44c82fd64f8.
+    ReviewInput in = new ReviewInput();
+    in.labels = ImmutableMap.of("Code-Review", (short) 0);
+    in.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    in.message = "comment";
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+
+    // If we're not reading from NoteDb, then the CCed user will be returned in the REVIEWER state.
+    assertThat(getReviewerState(r.getChangeId(), user.id))
+        .hasValue(notesMigration.readChanges() ? CC : REVIEWER);
+  }
+
+  @Test
+  public void implicitlyAddReviewerOnVotingReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.recommend().message("LGTM"));
+
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertThat(c.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
+        .containsExactly(user.id.get());
+
+    // Further test: remove the vote, then comment again. The user should be
+    // implicitly re-added to the ReviewerSet, as a CC if we're using NoteDb.
+    setApiUser(admin);
+    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).remove();
+    c = gApi.changes().id(r.getChangeId()).get();
+    assertThat(c.reviewers.values()).isEmpty();
+
+    setApiUser(user);
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(new ReviewInput().message("hi"));
+    c = gApi.changes().id(r.getChangeId()).get();
+    ReviewerState state = notesMigration.readChanges() ? CC : REVIEWER;
+    assertThat(c.reviewers.get(state).stream().map(ai -> ai._accountId).collect(toList()))
+        .containsExactly(user.id.get());
+  }
+
+  @Test
+  public void addReviewerToClosedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(admin.getId().get());
+    assertThat(c.reviewers).doesNotContainKey(CC);
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    c = gApi.changes().id(r.getChangeId()).get();
+    reviewers = c.reviewers.get(REVIEWER);
+    assertThat(reviewers).hasSize(2);
+    Iterator<AccountInfo> reviewerIt = reviewers.iterator();
+    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.getId().get());
+    assertThat(reviewerIt.next()._accountId).isEqualTo(user.getId().get());
+    assertThat(c.reviewers).doesNotContainKey(CC);
+  }
+
+  @Test
+  public void eTagChangesWhenOwnerUpdatesAccountStatus() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeResource rsrc = parseResource(r);
+    String oldETag = rsrc.getETag();
+
+    gApi.accounts().id(admin.id.get()).setStatus("new status");
+    rsrc = parseResource(r);
+    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
+  }
+
+  @Test
+  public void emailNotificationForFileLevelComment() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(changeId).addReviewer(in);
+    sender.clear();
+
+    ReviewInput review = new ReviewInput();
+    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
+    comment.path = PushOneCommit.FILE_NAME;
+    comment.side = Side.REVISION;
+    comment.message = "comment 1";
+    review.comments = new HashMap<>();
+    review.comments.put(comment.path, Lists.newArrayList(comment));
+    gApi.changes().id(changeId).current().review(review);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+  }
+
+  @Test
+  public void invalidRange() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    ReviewInput review = new ReviewInput();
+    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
+
+    comment.range = new Range();
+    comment.range.startLine = 1;
+    comment.range.endLine = 1;
+    comment.range.startCharacter = -1;
+    comment.range.endCharacter = 0;
+
+    comment.path = PushOneCommit.FILE_NAME;
+    comment.side = Side.REVISION;
+    comment.message = "comment 1";
+    review.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
+
+    exception.expect(BadRequestException.class);
+    gApi.changes().id(changeId).current().review(review);
+  }
+
+  @Test
+  public void listVotes() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    Map<String, Short> m =
+        gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).votes();
+
+    assertThat(m).hasSize(1);
+    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 2));
+
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.dislike());
+
+    m = gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).votes();
+
+    assertThat(m).hasSize(1);
+    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) -1));
+  }
+
+  @Test
+  public void removeReviewerNoVotes() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+
+    LabelType verified =
+        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    cfg.getLabelSections().put(verified.getName(), verified);
+
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(cfg, Permission.forLabel(Util.verified().getName()), -1, 1, registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.getId().toString());
+
+    // ReviewerState will vary between ReviewDb and NoteDb; we just care that it
+    // shows up somewhere.
+    Iterable<AccountInfo> reviewers =
+        Iterables.concat(gApi.changes().id(changeId).get().reviewers.values());
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
+
+    sender.clear();
+    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove();
+    assertThat(gApi.changes().id(changeId).get().reviewers).isEmpty();
+
+    assertThat(sender.getMessages()).hasSize(1);
+    Message message = sender.getMessages().get(0);
+    assertThat(message.body()).contains("Removed reviewer " + user.fullName + ".");
+    assertThat(message.body()).doesNotContain("with the following votes");
+
+    // Make sure the reviewer can still be added again.
+    gApi.changes().id(changeId).addReviewer(user.getId().toString());
+    reviewers = Iterables.concat(gApi.changes().id(changeId).get().reviewers.values());
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
+
+    // Remove again, and then try to remove once more to verify 404 is
+    // returned.
+    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove();
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove();
+  }
+
+  @Test
+  public void removeReviewer() throws Exception {
+    testRemoveReviewer(true);
+  }
+
+  @Test
+  public void removeNoNotify() throws Exception {
+    testRemoveReviewer(false);
+  }
+
+  private void testRemoveReviewer(boolean notify) throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    setApiUser(user);
+    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.recommend());
+
+    Collection<AccountInfo> reviewers = gApi.changes().id(changeId).get().reviewers.get(REVIEWER);
+
+    assertThat(reviewers).hasSize(2);
+    Iterator<AccountInfo> reviewerIt = reviewers.iterator();
+    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.getId().get());
+    assertThat(reviewerIt.next()._accountId).isEqualTo(user.getId().get());
+
+    sender.clear();
+    setApiUser(admin);
+    DeleteReviewerInput input = new DeleteReviewerInput();
+    if (!notify) {
+      input.notify = NotifyHandling.NONE;
+    }
+    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove(input);
+
+    if (notify) {
+      assertThat(sender.getMessages()).hasSize(1);
+      Message message = sender.getMessages().get(0);
+      assertThat(message.body())
+          .contains("Removed reviewer " + user.fullName + " with the following votes");
+      assertThat(message.body()).contains("* Code-Review+1 by " + user.fullName);
+    } else {
+      assertThat(sender.getMessages()).isEmpty();
+    }
+
+    reviewers = gApi.changes().id(changeId).get().reviewers.get(REVIEWER);
+    assertThat(reviewers).hasSize(1);
+    reviewerIt = reviewers.iterator();
+    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.getId().get());
+
+    eventRecorder.assertReviewerDeletedEvents(changeId, user.email);
+  }
+
+  @Test
+  public void removeReviewerNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("remove reviewer not permitted");
+    gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).remove();
+  }
+
+  @Test
+  public void deleteVote() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    setApiUser(user);
+    recommend(r.getChangeId());
+
+    setApiUser(admin);
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote("Code-Review");
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message msg = messages.get(0);
+    assertThat(msg.rcpt()).containsExactly(user.emailAddress);
+    assertThat(msg.body()).contains(admin.fullName + " has removed a vote on this change.\n");
+    assertThat(msg.body())
+        .contains("Removed Code-Review+1 by " + user.fullName + " <" + user.email + ">\n");
+
+    Map<String, Short> m =
+        gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).votes();
+
+    // Dummy 0 approval on the change to block vote copying to this patch set.
+    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
+
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+
+    ChangeMessageInfo message = Iterables.getLast(c.messages);
+    assertThat(message.author._accountId).isEqualTo(admin.getId().get());
+    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
+  }
+
+  @Test
+  public void deleteVoteNotifyNone() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    setApiUser(user);
+    recommend(r.getChangeId());
+
+    setApiUser(admin);
+    sender.clear();
+    DeleteVoteInput in = new DeleteVoteInput();
+    in.label = "Code-Review";
+    in.notify = NotifyHandling.NONE;
+    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void deleteVoteNotifyAccount() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    DeleteVoteInput in = new DeleteVoteInput();
+    in.label = "Code-Review";
+    in.notify = NotifyHandling.NONE;
+
+    // notify unrelated account as TO
+    TestAccount user2 = accountCreator.user2();
+    setApiUser(user);
+    recommend(r.getChangeId());
+    setApiUser(admin);
+    sender.clear();
+    in.notifyDetails = new HashMap<>();
+    in.notifyDetails.put(RecipientType.TO, new NotifyInfo(ImmutableList.of(user2.email)));
+    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
+    assertNotifyTo(user2);
+
+    // notify unrelated account as CC
+    setApiUser(user);
+    recommend(r.getChangeId());
+    setApiUser(admin);
+    sender.clear();
+    in.notifyDetails = new HashMap<>();
+    in.notifyDetails.put(RecipientType.CC, new NotifyInfo(ImmutableList.of(user2.email)));
+    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
+    assertNotifyCc(user2);
+
+    // notify unrelated account as BCC
+    setApiUser(user);
+    recommend(r.getChangeId());
+    setApiUser(admin);
+    sender.clear();
+    in.notifyDetails = new HashMap<>();
+    in.notifyDetails.put(RecipientType.BCC, new NotifyInfo(ImmutableList.of(user2.email)));
+    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
+    assertNotifyBcc(user2);
+  }
+
+  @Test
+  public void deleteVoteNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("delete vote not permitted");
+    gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).deleteVote("Code-Review");
+  }
+
+  @Test
+  public void nonVotingReviewerStaysAfterSubmit() throws Exception {
+    LabelType verified =
+        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().put(verified.getName(), verified);
+    String heads = "refs/heads/*";
+    AccountGroup.UUID owners = systemGroupBackend.getGroup(CHANGE_OWNER).getUUID();
+    AccountGroup.UUID registered = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    Util.allow(cfg, Permission.forLabel(verified.getName()), -1, 1, owners, heads);
+    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, registered, heads);
+    saveProjectConfig(project, cfg);
+
+    // Set Code-Review+2 and Verified+1 as admin (change owner)
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String commit = r.getCommit().name();
+    ReviewInput input = ReviewInput.approve();
+    input.label(verified.getName(), 1);
+    gApi.changes().id(changeId).revision(commit).review(input);
+
+    // Reviewers should only be "admin"
+    ChangeInfo c = gApi.changes().id(changeId).get();
+    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+        .containsExactlyElementsIn(ImmutableSet.of(admin.getId()));
+    assertThat(c.reviewers.get(CC)).isNull();
+
+    // Add the user as reviewer
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(changeId).addReviewer(in);
+    c = gApi.changes().id(changeId).get();
+    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
+
+    // Approve the change as user, then remove the approval
+    // (only to confirm that the user does have Code-Review+2 permission)
+    setApiUser(user);
+    gApi.changes().id(changeId).revision(commit).review(ReviewInput.approve());
+    gApi.changes().id(changeId).revision(commit).review(ReviewInput.noScore());
+
+    // Submit the change
+    setApiUser(admin);
+    gApi.changes().id(changeId).revision(commit).submit();
+
+    // User should still be on the change
+    c = gApi.changes().id(changeId).get();
+    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
+  }
+
+  @Test
+  public void createEmptyChange() throws Exception {
+    ChangeInput in = new ChangeInput();
+    in.branch = Constants.MASTER;
+    in.subject = "Create a change from the API";
+    in.project = project.get();
+    ChangeInfo info = gApi.changes().create(in).get();
+    assertThat(info.project).isEqualTo(in.project);
+    assertThat(info.branch).isEqualTo(in.branch);
+    assertThat(info.subject).isEqualTo(in.subject);
+    assertThat(Iterables.getOnlyElement(info.messages).message).isEqualTo("Uploaded patch set 1.");
+  }
+
+  @Test
+  public void queryChangesNoQuery() throws Exception {
+    PushOneCommit.Result r = createChange();
+    List<ChangeInfo> results = gApi.changes().query().get();
+    assertThat(results.size()).isAtLeast(1);
+    List<Integer> ids = new ArrayList<>(results.size());
+    for (int i = 0; i < results.size(); i++) {
+      ChangeInfo info = results.get(i);
+      if (i == 0) {
+        assertThat(info._number).isEqualTo(r.getChange().getId().get());
+      }
+      assertThat(Change.Status.forChangeStatus(info.status).isOpen()).isTrue();
+      ids.add(info._number);
+    }
+    assertThat(ids).contains(r.getChange().getId().get());
+  }
+
+  @Test
+  public void queryChangesNoResults() throws Exception {
+    createChange();
+    assertThat(query("message:test")).isNotEmpty();
+    assertThat(query("message:{" + getClass().getName() + "fhqwhgads}")).isEmpty();
+  }
+
+  @Test
+  public void queryChanges() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    createChange();
+    List<ChangeInfo> results = query("project:{" + project.get() + "} " + r1.getChangeId());
+    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r1.getChangeId());
+  }
+
+  @Test
+  public void queryChangesLimit() throws Exception {
+    createChange();
+    PushOneCommit.Result r2 = createChange();
+    List<ChangeInfo> results = gApi.changes().query().withLimit(1).get();
+    assertThat(results).hasSize(1);
+    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r2.getChangeId());
+  }
+
+  @Test
+  public void queryChangesStart() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    createChange();
+    List<ChangeInfo> results =
+        gApi.changes().query("project:{" + project.get() + "}").withStart(1).get();
+    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r1.getChangeId());
+  }
+
+  @Test
+  public void queryChangesNoOptions() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeInfo result = Iterables.getOnlyElement(query(r.getChangeId()));
+    assertThat(result.labels).isNull();
+    assertThat(result.messages).isNull();
+    assertThat(result.revisions).isNull();
+    assertThat(result.actions).isNull();
+  }
+
+  @Test
+  public void queryChangesOptions() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    ChangeInfo result = Iterables.getOnlyElement(gApi.changes().query(r.getChangeId()).get());
+    assertThat(result.labels).isNull();
+    assertThat(result.messages).isNull();
+    assertThat(result.actions).isNull();
+    assertThat(result.revisions).isNull();
+
+    result =
+        Iterables.getOnlyElement(
+            gApi.changes()
+                .query(r.getChangeId())
+                .withOptions(
+                    ALL_REVISIONS, CHANGE_ACTIONS, CURRENT_ACTIONS, DETAILED_LABELS, MESSAGES)
+                .get());
+    assertThat(Iterables.getOnlyElement(result.labels.keySet())).isEqualTo("Code-Review");
+    assertThat(result.messages).hasSize(1);
+    assertThat(result.actions).isNotEmpty();
+
+    RevisionInfo rev = Iterables.getOnlyElement(result.revisions.values());
+    assertThat(rev._number).isEqualTo(r.getPatchSetId().get());
+    assertThat(rev.created).isNotNull();
+    assertThat(rev.uploader._accountId).isEqualTo(admin.getId().get());
+    assertThat(rev.ref).isEqualTo(r.getPatchSetId().toRefName());
+    assertThat(rev.actions).isNotEmpty();
+  }
+
+  @Test
+  public void queryChangesOwnerWithDifferentUsers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(
+            Iterables.getOnlyElement(query("project:{" + project.get() + "} owner:self")).changeId)
+        .isEqualTo(r.getChangeId());
+    setApiUser(user);
+    assertThat(query("owner:self project:{" + project.get() + "}")).isEmpty();
+  }
+
+  @Test
+  public void checkReviewedFlagBeforeAndAfterReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    setApiUser(user);
+    assertThat(get(r.getChangeId(), REVIEWED).reviewed).isNull();
+
+    revision(r).review(ReviewInput.recommend());
+    assertThat(get(r.getChangeId(), REVIEWED).reviewed).isTrue();
+  }
+
+  @Test
+  public void topic() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
+    gApi.changes().id(r.getChangeId()).topic("mytopic");
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("mytopic");
+    gApi.changes().id(r.getChangeId()).topic("");
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
+  }
+
+  @Test
+  public void editTopicWithoutPermissionNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("edit topic name not permitted");
+    gApi.changes().id(r.getChangeId()).topic("mytopic");
+  }
+
+  @Test
+  public void editTopicWithPermissionAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
+    grant(project, "refs/heads/master", Permission.EDIT_TOPIC_NAME, false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).topic("mytopic");
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("mytopic");
+  }
+
+  @Test
+  public void submitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String id = r.getChangeId();
+
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).info();
+    assertThat(c.submitted).isNull();
+    assertThat(c.submitter).isNull();
+
+    gApi.changes().id(id).current().review(ReviewInput.approve());
+    gApi.changes().id(id).current().submit();
+
+    c = gApi.changes().id(r.getChangeId()).info();
+    assertThat(c.submitted).isNotNull();
+    assertThat(c.submitter).isNotNull();
+    assertThat(c.submitter._accountId).isEqualTo(atrScope.get().getUser().getAccountId().get());
+  }
+
+  @Test
+  public void submitStaleChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    disableChangeIndexWrites();
+    try {
+      r = amendChange(r.getChangeId());
+    } finally {
+      enableChangeIndexWrites();
+    }
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void submitNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("submit not permitted");
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+  }
+
+  @Test
+  public void submitAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    grant(project, "refs/heads/master", Permission.SUBMIT, false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void check() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(gApi.changes().id(r.getChangeId()).get().problems).isNull();
+    assertThat(gApi.changes().id(r.getChangeId()).get(CHECK).problems).isEmpty();
+  }
+
+  @Test
+  public void commitFooters() throws Exception {
+    LabelType verified =
+        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    LabelType custom1 =
+        category("Custom1", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+    LabelType custom2 =
+        category("Custom2", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().put(verified.getName(), verified);
+    cfg.getLabelSections().put(custom1.getName(), custom1);
+    cfg.getLabelSections().put(custom2.getName(), custom2);
+    String heads = "refs/heads/*";
+    AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    Util.allow(cfg, Permission.forLabel("Verified"), -1, 1, anon, heads);
+    Util.allow(cfg, Permission.forLabel("Custom1"), -1, 1, anon, heads);
+    Util.allow(cfg, Permission.forLabel("Custom2"), -1, 1, anon, heads);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r1 = createChange();
+    r1.assertOkStatus();
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
+            .to("refs/for/master");
+    r2.assertOkStatus();
+
+    ReviewInput in = new ReviewInput();
+    in.label("Code-Review", 1);
+    in.label("Verified", 1);
+    in.label("Custom1", -1);
+    in.label("Custom2", 1);
+    gApi.changes().id(r2.getChangeId()).current().review(in);
+
+    ChangeInfo actual = gApi.changes().id(r2.getChangeId()).get(ALL_REVISIONS, COMMIT_FOOTERS);
+    assertThat(actual.revisions).hasSize(2);
+
+    // No footers except on latest patch set.
+    assertThat(actual.revisions.get(r1.getCommit().getName()).commitWithFooters).isNull();
+
+    List<String> footers =
+        new ArrayList<>(
+            Arrays.asList(
+                actual.revisions.get(r2.getCommit().getName()).commitWithFooters.split("\\n")));
+    // remove subject + blank line
+    footers.remove(0);
+    footers.remove(0);
+
+    List<String> expectedFooters =
+        Arrays.asList(
+            "Change-Id: " + r2.getChangeId(),
+            "Reviewed-on: " + canonicalWebUrl.get() + r2.getChange().getId(),
+            "Reviewed-by: Administrator <admin@example.com>",
+            "Custom2: Administrator <admin@example.com>",
+            "Tested-by: Administrator <admin@example.com>");
+
+    assertThat(footers).containsExactlyElementsIn(expectedFooters);
+  }
+
+  @Test
+  public void customCommitFooters() throws Exception {
+    PushOneCommit.Result change = createChange();
+    RegistrationHandle handle =
+        changeMessageModifiers.add(
+            new ChangeMessageModifier() {
+              @Override
+              public String onSubmit(
+                  String newCommitMessage,
+                  RevCommit original,
+                  RevCommit mergeTip,
+                  Branch.NameKey destination) {
+                assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
+                return newCommitMessage + "Custom: " + destination.get();
+              }
+            });
+    ChangeInfo actual;
+    try {
+      actual = gApi.changes().id(change.getChangeId()).get(ALL_REVISIONS, COMMIT_FOOTERS);
+    } finally {
+      handle.remove();
+    }
+    List<String> footers =
+        new ArrayList<>(
+            Arrays.asList(
+                actual.revisions.get(change.getCommit().getName()).commitWithFooters.split("\\n")));
+    // remove subject + blank line
+    footers.remove(0);
+    footers.remove(0);
+
+    List<String> expectedFooters =
+        Arrays.asList(
+            "Change-Id: " + change.getChangeId(),
+            "Reviewed-on: " + canonicalWebUrl.get() + change.getChange().getId(),
+            "Custom: refs/heads/master");
+    assertThat(footers).containsExactlyElementsIn(expectedFooters);
+  }
+
+  @Test
+  public void defaultSearchDoesNotTouchDatabase() throws Exception {
+    setApiUser(admin);
+    PushOneCommit.Result r1 = createChange();
+    gApi.changes()
+        .id(r1.getChangeId())
+        .revision(r1.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+    createChange();
+
+    setApiUser(user);
+    AcceptanceTestRequestScope.Context ctx = disableDb();
+    try {
+      assertThat(
+              gApi.changes()
+                  .query()
+                  .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+                  // Options should match defaults in AccountDashboardScreen.
+                  .withOption(LABELS)
+                  .withOption(DETAILED_ACCOUNTS)
+                  .withOption(REVIEWED)
+                  .get())
+          .hasSize(2);
+    } finally {
+      enableDb(ctx);
+    }
+  }
+
+  @Test
+  public void votable() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    gApi.changes().id(triplet).addReviewer(user.username);
+    ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
+    LabelInfo codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    ApprovalInfo approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.value).isEqualTo(0);
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.blockLabel(cfg, "Code-Review", REGISTERED_USERS, "refs/heads/*");
+    saveProjectConfig(project, cfg);
+    c = gApi.changes().id(triplet).get(DETAILED_LABELS);
+    codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.value).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.editGpgKeys", value = "true")
+  @GerritConfig(name = "receive.enableSignedPush", value = "true")
+  public void pushCertificates() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId());
+
+    ChangeInfo info = gApi.changes().id(r1.getChangeId()).get(ALL_REVISIONS, PUSH_CERTIFICATES);
+
+    RevisionInfo rev1 = info.revisions.get(r1.getCommit().name());
+    assertThat(rev1).isNotNull();
+    assertThat(rev1.pushCertificate).isNotNull();
+    assertThat(rev1.pushCertificate.certificate).isNull();
+    assertThat(rev1.pushCertificate.key).isNull();
+
+    RevisionInfo rev2 = info.revisions.get(r2.getCommit().name());
+    assertThat(rev2).isNotNull();
+    assertThat(rev2.pushCertificate).isNotNull();
+    assertThat(rev2.pushCertificate.certificate).isNull();
+    assertThat(rev2.pushCertificate.key).isNull();
+  }
+
+  @Test
+  public void anonymousRestApi() throws Exception {
+    setApiUserAnonymous();
+    PushOneCommit.Result r = createChange();
+
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.changeId).isEqualTo(r.getChangeId());
+
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    info = gApi.changes().id(triplet).get();
+    assertThat(info.changeId).isEqualTo(r.getChangeId());
+
+    info = gApi.changes().id(info._number).get();
+    assertThat(info.changeId).isEqualTo(r.getChangeId());
+
+    exception.expect(AuthException.class);
+    gApi.changes().id(triplet).current().review(ReviewInput.approve());
+  }
+
+  @Test
+  public void noteDbCommitsOnPatchSetCreation() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    pushFactory
+        .create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "4711", r.getChangeId())
+        .to("refs/for/master")
+        .assertOkStatus();
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commitPatchSetCreation =
+          rw.parseCommit(repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
+
+      assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
+      PersonIdent expectedAuthor =
+          changeNoteUtil.newIdent(
+              accountCache.get(admin.id).getAccount(), c.updated, serverIdent.get());
+      assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
+      assertThat(commitPatchSetCreation.getCommitterIdent())
+          .isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
+      assertThat(commitPatchSetCreation.getParentCount()).isEqualTo(1);
+
+      RevCommit commitChangeCreation = rw.parseCommit(commitPatchSetCreation.getParent(0));
+      assertThat(commitChangeCreation.getShortMessage()).isEqualTo("Create change");
+      expectedAuthor =
+          changeNoteUtil.newIdent(
+              accountCache.get(admin.id).getAccount(), c.created, serverIdent.get());
+      assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
+      assertThat(commitChangeCreation.getCommitterIdent())
+          .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
+      assertThat(commitChangeCreation.getParentCount()).isEqualTo(0);
+    }
+  }
+
+  @Test
+  public void createEmptyChangeOnNonExistingBranch() throws Exception {
+    ChangeInput in = new ChangeInput();
+    in.branch = "foo";
+    in.subject = "Create a change on new branch from the API";
+    in.project = project.get();
+    in.newBranch = true;
+    ChangeInfo info = gApi.changes().create(in).get();
+    assertThat(info.project).isEqualTo(in.project);
+    assertThat(info.branch).isEqualTo(in.branch);
+    assertThat(info.subject).isEqualTo(in.subject);
+    assertThat(Iterables.getOnlyElement(info.messages).message).isEqualTo("Uploaded patch set 1.");
+  }
+
+  @Test
+  public void createEmptyChangeOnExistingBranchWithNewBranch() throws Exception {
+    ChangeInput in = new ChangeInput();
+    in.branch = Constants.MASTER;
+    in.subject = "Create a change on new branch from the API";
+    in.project = project.get();
+    in.newBranch = true;
+
+    exception.expect(ResourceConflictException.class);
+    gApi.changes().create(in).get();
+  }
+
+  @Test
+  public void createNewPatchSetWithoutPermission() throws Exception {
+    // Create new project with clean permissions
+    Project.NameKey p = createProject("addPatchSet1");
+
+    // Clone separate repositories of the same project as admin and as user
+    TestRepository<InMemoryRepository> adminTestRepo = cloneProject(p, admin);
+    TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
+
+    // Block default permission
+    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+
+    // Create change as admin
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
+    PushOneCommit.Result r1 = push.to("refs/for/master");
+    r1.assertOkStatus();
+
+    // Fetch change
+    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    userTestRepo.reset("ps");
+
+    // Amend change as user
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId(), "refs/for/master", user, userTestRepo);
+    r2.assertErrorStatus("cannot add patch set to " + r1.getChange().getId().id + ".");
+  }
+
+  @Test
+  public void createNewSetPatchWithPermission() throws Exception {
+    // Clone separate repositories of the same project as admin and as user
+    TestRepository<?> adminTestRepo = cloneProject(project, admin);
+    TestRepository<?> userTestRepo = cloneProject(project, user);
+
+    // Create change as admin
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
+    PushOneCommit.Result r1 = push.to("refs/for/master");
+    r1.assertOkStatus();
+
+    // Fetch change
+    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    userTestRepo.reset("ps");
+
+    // Amend change as user
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId(), "refs/for/master", user, userTestRepo);
+    r2.assertOkStatus();
+  }
+
+  @Test
+  public void createNewPatchSetAsOwnerWithoutPermission() throws Exception {
+    // Create new project with clean permissions
+    Project.NameKey p = createProject("addPatchSet2");
+    // Clone separate repositories of the same project as admin and as user
+    TestRepository<?> adminTestRepo = cloneProject(project, admin);
+
+    // Block default permission
+    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+
+    // Create change as admin
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
+    PushOneCommit.Result r1 = push.to("refs/for/master");
+    r1.assertOkStatus();
+
+    // Fetch change
+    GitUtil.fetch(adminTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    adminTestRepo.reset("ps");
+
+    // Amend change as admin
+    PushOneCommit.Result r2 =
+        amendChange(r1.getChangeId(), "refs/for/master", admin, adminTestRepo);
+    r2.assertOkStatus();
+  }
+
+  @Test
+  public void createMergePatchSet() throws Exception {
+    PushOneCommit.Result start = pushTo("refs/heads/master");
+    start.assertOkStatus();
+    // create a change for master
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    String changeId = r.getChangeId();
+
+    testRepo.reset(start.getCommit());
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    createBranch("dev");
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(db, user.getIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+    gApi.changes().id(changeId).createMergePatchSet(in);
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions.size()).isEqualTo(2);
+    assertThat(changeInfo.subject).isEqualTo(in.subject);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+  }
+
+  @Test
+  public void createMergePatchSetInheritParent() throws Exception {
+    PushOneCommit.Result start = pushTo("refs/heads/master");
+    start.assertOkStatus();
+    // create a change for master
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    String changeId = r.getChangeId();
+    String parent = r.getCommit().getParent(0).getName();
+
+    // advance master branch
+    testRepo.reset(start.getCommit());
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    createBranch("dev");
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(db, user.getIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2 inherit parent of ps1";
+    in.inheritParent = true;
+    gApi.changes().id(changeId).createMergePatchSet(in);
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+
+    assertThat(changeInfo.revisions.size()).isEqualTo(2);
+    assertThat(changeInfo.subject).isEqualTo(in.subject);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isNotEqualTo(currentMaster.getCommit().getName());
+  }
+
+  @Test
+  public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    createBranch("foo");
+    createBranch("bar");
+
+    // Create a merged commit on 'foo' branch.
+    merge(createChange("refs/for/foo"));
+
+    // Create the base change on 'bar' branch.
+    testRepo.reset(initialHead);
+    String baseChange = createChange("refs/for/bar").getChangeId();
+    gApi.changes().id(baseChange).setPrivate(true, "set private");
+
+    // Create the destination change on 'master' branch.
+    setApiUser(user);
+    testRepo.reset(initialHead);
+    String changeId = createChange().getChangeId();
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Read not permitted for " + baseChange);
+    gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
+  }
+
+  @Test
+  public void createMergePatchSetBaseOnChange() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    createBranch("foo");
+    createBranch("bar");
+
+    // Create a merged commit on 'foo' branch.
+    merge(createChange("refs/for/foo"));
+
+    // Create the base change on 'bar' branch.
+    testRepo.reset(initialHead);
+    PushOneCommit.Result result = createChange("refs/for/bar");
+    String baseChange = result.getChangeId();
+    String expectedParent = result.getCommit().getName();
+
+    // Create the destination change on 'master' branch.
+    testRepo.reset(initialHead);
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
+
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions.size()).isEqualTo(2);
+    assertThat(changeInfo.subject).isEqualTo("create ps2");
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(expectedParent);
+  }
+
+  private MergePatchSetInput createMergePatchSetInput(String baseChange) {
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "foo";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "create ps2";
+    in.inheritParent = false;
+    in.baseChange = baseChange;
+    return in;
+  }
+
+  @Test
+  public void checkLabelsForUnsubmittedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+
+    // add new label and assert that it's returned for existing changes
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType verified = Util.verified();
+    cfg.getLabelSections().put(verified.getName(), verified);
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(cfg, Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review", "Verified");
+    assertPermitted(change, "Code-Review", -2, -1, 0, 1, 2);
+    assertPermitted(change, "Verified", -1, 0, 1);
+
+    // add an approval on the new label
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
+
+    // remove label and assert that it's no longer returned for existing
+    // changes, even if there is an approval for it
+    cfg.getLabelSections().remove(verified.getName());
+    Util.remove(cfg, Permission.forLabel(verified.getName()), registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+
+    // abandon the change and see that the returned labels stay the same
+    // while all permitted labels disappear.
+    gApi.changes().id(r.getChangeId()).abandon();
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels).isEmpty();
+  }
+
+  @Test
+  public void checkLabelsForMergedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 2);
+
+    // add new label and assert that it's returned for existing changes
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType verified = Util.verified();
+    cfg.getLabelSections().put(verified.getName(), verified);
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(cfg, Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review", "Verified");
+    assertPermitted(change, "Code-Review", 2);
+    assertPermitted(change, "Verified", 0, 1);
+
+    // ignore the new label by Prolog submit rule and assert that the label is
+    // no longer returned
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    PushOneCommit push2 =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Ignore Verified",
+            "rules.pl",
+            "submit_rule(submit(CR)) :-\n  gerrit:max_with_block(-2, 2, 'Code-Review', CR).");
+    push2.to(RefNames.REFS_CONFIG);
+
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertPermitted(change, "Code-Review", 2);
+    assertPermitted(change, "Verified");
+
+    // add an approval on the new label and assert that the label is now
+    // returned although it is ignored by the Prolog submit rule and hence not
+    // included in the submit records
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
+
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
+    assertPermitted(change, "Code-Review", 2);
+    assertPermitted(change, "Verified");
+
+    // remove label and assert that it's no longer returned for existing
+    // changes, even if there is an approval for it
+    cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().remove(verified.getName());
+    Util.remove(cfg, Permission.forLabel(verified.getName()), registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 2);
+  }
+
+  @Test
+  public void checkLabelsForMergedChangeWithNonAuthorCodeReview() throws Exception {
+    // Configure Non-Author-Code-Review
+    RevCommit oldHead = getRemoteHead();
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    PushOneCommit push2 =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Configure Non-Author-Code-Review",
+            "rules.pl",
+            "submit_rule(S) :-\n"
+                + "  gerrit:default_submit(X),\n"
+                + "  X =.. [submit | Ls],\n"
+                + "  add_non_author_approval(Ls, R),\n"
+                + "  S =.. [submit | R].\n"
+                + "\n"
+                + "add_non_author_approval(S1, S2) :-\n"
+                + "  gerrit:commit_author(A),\n"
+                + "  gerrit:commit_label(label('Code-Review', 2), R),\n"
+                + "  R \\= A, !,\n"
+                + "  S2 = [label('Non-Author-Code-Review', ok(R)) | S1].\n"
+                + "add_non_author_approval(S1,"
+                + " [label('Non-Author-Code-Review', need(_)) | S1]).");
+    push2.to(RefNames.REFS_CONFIG);
+    testRepo.reset(oldHead);
+
+    // Allow user to approve
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(
+        cfg, Permission.forLabel(Util.codeReview().getName()), -2, 2, registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    setApiUser(admin);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Non-Author-Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 0, 1, 2);
+  }
+
+  @Test
+  public void checkLabelsForAutoClosedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result result = push.to("refs/heads/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 0, 1, 2);
+  }
+
+  @Test
+  public void maxPermittedValueAllowed() throws Exception {
+    final int minPermittedValue = -2;
+    final int maxPermittedValue = +2;
+    String heads = "refs/heads/*";
+
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+
+    gApi.changes().id(triplet).addReviewer(user.username);
+
+    ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
+    LabelInfo codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    ApprovalInfo approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.permittedVotingRange).isNotNull();
+    // default values
+    assertThat(approval.permittedVotingRange.min).isEqualTo(-1);
+    assertThat(approval.permittedVotingRange.max).isEqualTo(1);
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.allow(
+        cfg,
+        Permission.forLabel("Code-Review"),
+        minPermittedValue,
+        maxPermittedValue,
+        REGISTERED_USERS,
+        heads);
+    saveProjectConfig(project, cfg);
+
+    c = gApi.changes().id(triplet).get(DETAILED_LABELS);
+    codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.permittedVotingRange).isNotNull();
+    assertThat(approval.permittedVotingRange.min).isEqualTo(minPermittedValue);
+    assertThat(approval.permittedVotingRange.max).isEqualTo(maxPermittedValue);
+  }
+
+  @Test
+  public void maxPermittedValueBlocked() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.blockLabel(cfg, "Code-Review", REGISTERED_USERS, "refs/heads/*");
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+
+    gApi.changes().id(triplet).addReviewer(user.username);
+
+    ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
+    LabelInfo codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    ApprovalInfo approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.permittedVotingRange).isNull();
+  }
+
+  @Test
+  public void unresolvedCommentsBlocked() throws Exception {
+    modifySubmitRules(
+        "submit_rule(submit(R)) :- \n"
+            + "gerrit:unresolved_comments_count(0), \n"
+            + "!,"
+            + "gerrit:uploader(U), \n"
+            + "R = label('All-Comments-Resolved', ok(U)).\n"
+            + "submit_rule(submit(R)) :- \n"
+            + "gerrit:unresolved_comments_count(U), \n"
+            + "U > 0,"
+            + "R = label('All-Comments-Resolved', need(_)). \n\n");
+
+    String oldHead = getRemoteHead().name();
+    PushOneCommit.Result result1 =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    testRepo.reset(oldHead);
+    PushOneCommit.Result result2 =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+
+    addComment(result1, "comment 1", true, false, null);
+    addComment(result2, "comment 2", true, true, null);
+
+    gApi.changes().id(result1.getChangeId()).current().submit();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Failed to submit 1 change due to the following problems");
+    exception.expectMessage("needs All-Comments-Resolved");
+    gApi.changes().id(result2.getChangeId()).current().submit();
+  }
+
+  @Test
+  public void pureRevertFactBlocksSubmissionOfNonReverts() throws Exception {
+    addPureRevertSubmitRule();
+
+    // Create a change that is not a revert of another change
+    PushOneCommit.Result r1 =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    approve(r1.getChangeId());
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Failed to submit 1 change due to the following problems");
+    exception.expectMessage("needs Is-Pure-Revert");
+    gApi.changes().id(r1.getChangeId()).current().submit();
+  }
+
+  @Test
+  public void pureRevertFactBlocksSubmissionOfNonPureReverts() throws Exception {
+    PushOneCommit.Result r1 =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    merge(r1);
+
+    addPureRevertSubmitRule();
+
+    // Create a revert and push a content change
+    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
+    amendChange(revertId);
+    approve(revertId);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Failed to submit 1 change due to the following problems");
+    exception.expectMessage("needs Is-Pure-Revert");
+    gApi.changes().id(revertId).current().submit();
+  }
+
+  @Test
+  public void pureRevertFactAllowsSubmissionOfPureReverts() throws Exception {
+    // Create a change that we can later revert
+    PushOneCommit.Result r1 =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    merge(r1);
+
+    addPureRevertSubmitRule();
+
+    // Create a revert and submit it
+    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
+    approve(revertId);
+    gApi.changes().id(revertId).current().submit();
+  }
+
+  @Test
+  public void changeCommitMessage() throws Exception {
+    // Tests mutating the commit message as both the owner of the change and a regular user with
+    // addPatchSet permission. Asserts that both cases succeed.
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+
+    for (TestAccount acc : ImmutableList.of(admin, user)) {
+      setApiUser(acc);
+      String newMessage =
+          "modified commit by " + acc.username + "\n\nChange-Id: " + r.getChangeId() + "\n";
+      gApi.changes().id(r.getChangeId()).setMessage(newMessage);
+      RevisionApi rApi = gApi.changes().id(r.getChangeId()).current();
+      assertThat(rApi.files().keySet()).containsExactly("/COMMIT_MSG", "a.txt");
+      assertThat(getCommitMessage(r.getChangeId())).isEqualTo(newMessage);
+      assertThat(rApi.description()).isEqualTo("Edit commit message");
+    }
+
+    // Verify tags, which should differ according to whether the change was WIP
+    // at the time the commit message was edited. First, look at the last edit
+    // we created above, when the change was not WIP.
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(Iterables.getLast(info.messages).tag)
+        .isEqualTo(ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+
+    // Move the change to WIP and edit the commit message again, to observe a
+    // different tag. Must switch to change owner to move into WIP.
+    setApiUser(admin);
+    gApi.changes().id(r.getChangeId()).setWorkInProgress();
+    String newMessage = "modified commit in WIP change\n\nChange-Id: " + r.getChangeId() + "\n";
+    gApi.changes().id(r.getChangeId()).setMessage(newMessage);
+    info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(Iterables.getLast(info.messages).tag)
+        .isEqualTo(ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
+  }
+
+  @Test
+  public void changeCommitMessageWithNoChangeIdSucceedsIfChangeIdNotRequired() throws Exception {
+    ConfigInput configInput = new ConfigInput();
+    configInput.requireChangeId = InheritableBoolean.FALSE;
+    gApi.projects().name(project.get()).config(configInput);
+
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+
+    String newMessage = "modified commit\n";
+    gApi.changes().id(r.getChangeId()).setMessage(newMessage);
+    RevisionApi rApi = gApi.changes().id(r.getChangeId()).current();
+    assertThat(rApi.files().keySet()).containsExactly("/COMMIT_MSG", "a.txt");
+    assertThat(getCommitMessage(r.getChangeId())).isEqualTo(newMessage);
+  }
+
+  @Test
+  public void changeCommitMessageWithNoChangeIdFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("missing Change-Id footer");
+    gApi.changes().id(r.getChangeId()).setMessage("modified commit\n");
+  }
+
+  @Test
+  public void changeCommitMessageWithWrongChangeIdFails() throws Exception {
+    PushOneCommit.Result otherChange = createChange();
+    PushOneCommit.Result r = createChange();
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("wrong Change-Id footer");
+    gApi.changes()
+        .id(r.getChangeId())
+        .setMessage("modified commit\n\nChange-Id: " + otherChange.getChangeId() + "\n");
+  }
+
+  @Test
+  public void changeCommitMessageWithoutPermissionFails() throws Exception {
+    // Create new project with clean permissions
+    Project.NameKey p = createProject("addPatchSetEdit");
+    TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
+    // Block default permission
+    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+    // Create change as user
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), userTestRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    // Try to change the commit message
+    exception.expect(AuthException.class);
+    exception.expectMessage("modifying commit message not permitted");
+    gApi.changes().id(r.getChangeId()).setMessage("foo");
+  }
+
+  @Test
+  public void changeCommitMessageWithSameMessageFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("new and existing commit message are the same");
+    gApi.changes().id(r.getChangeId()).setMessage(getCommitMessage(r.getChangeId()));
+  }
+
+  @Test
+  public void fourByteEmoji() throws Exception {
+    // U+1F601 GRINNING FACE WITH SMILING EYES
+    String smile = new String(Character.toChars(0x1f601));
+    assertThat(smile).isEqualTo("😁");
+    assertThat(smile).hasLength(2); // Thanks, Java.
+    assertThat(smile.getBytes(UTF_8)).hasLength(4);
+
+    String subject = "A happy change " + smile;
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, subject, FILE_NAME, FILE_CONTENT)
+            .to("refs/for/master");
+    r.assertOkStatus();
+    String id = r.getChangeId();
+
+    ReviewInput ri = ReviewInput.approve();
+    ri.message = "I like it " + smile;
+    ReviewInput.CommentInput ci = new ReviewInput.CommentInput();
+    ci.path = FILE_NAME;
+    ci.side = Side.REVISION;
+    ci.message = "Good " + smile;
+    ri.comments = ImmutableMap.of(FILE_NAME, ImmutableList.of(ci));
+    gApi.changes().id(id).current().review(ri);
+
+    ChangeInfo info = gApi.changes().id(id).get(MESSAGES, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(info.subject).isEqualTo(subject);
+    assertThat(Iterables.getLast(info.messages).message).endsWith(ri.message);
+    assertThat(Iterables.getOnlyElement(info.revisions.values()).commit.message)
+        .startsWith(subject);
+
+    List<CommentInfo> comments =
+        Iterables.getOnlyElement(gApi.changes().id(id).comments().values());
+    assertThat(Iterables.getOnlyElement(comments).message).isEqualTo(ci.message);
+  }
+
+  @Test
+  public void pureRevertReturnsTrueForPureRevert() throws Exception {
+    PushOneCommit.Result r = createChange();
+    merge(r);
+    String revertId = gApi.changes().id(r.getChangeId()).revert().get().id;
+    // Without query parameter
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+    // With query parameter
+    assertThat(
+            gApi.changes()
+                .id(revertId)
+                .pureRevert(getRemoteHead().toObjectId().name())
+                .isPureRevert)
+        .isTrue();
+  }
+
+  @Test
+  public void pureRevertReturnsFalseOnContentChange() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    merge(r1);
+    // Create a revert and expect pureRevert to be true
+    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+
+    // Create a new PS and expect pureRevert to be false
+    PushOneCommit.Result result = amendChange(revertId);
+    result.assertOkStatus();
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isFalse();
+  }
+
+  @Test
+  public void pureRevertParameterTakesPrecedence() throws Exception {
+    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
+    merge(r1);
+    String oldHead = getRemoteHead().toObjectId().name();
+
+    PushOneCommit.Result r2 = createChange("commit message", "a.txt", "content2");
+    merge(r2);
+
+    String revertId = gApi.changes().id(r2.getChangeId()).revert().get().changeId;
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+    assertThat(gApi.changes().id(revertId).pureRevert(oldHead).isPureRevert).isFalse();
+  }
+
+  @Test
+  public void pureRevertReturnsFalseOnInvalidInput() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    merge(r1);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid object ID");
+    gApi.changes().id(createChange().getChangeId()).pureRevert("invalid id");
+  }
+
+  @Test
+  public void pureRevertReturnsTrueWithCleanRebase() throws Exception {
+    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
+    merge(r1);
+
+    PushOneCommit.Result r2 = createChange("commit message", "b.txt", "content2");
+    merge(r2);
+
+    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
+    // Rebase revert onto HEAD
+    gApi.changes().id(revertId).rebase();
+    // Check that pureRevert is true which implies that the commit can be rebased onto the original
+    // commit.
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+  }
+
+  @Test
+  public void pureRevertReturnsFalseWithRebaseConflict() throws Exception {
+    // Create an initial commit to serve as claimed original
+    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
+    merge(r1);
+    String claimedOriginal = getRemoteHead().toObjectId().name();
+
+    // Change contents of the file to provoke a conflict
+    merge(createChange("commit message", "a.txt", "content2"));
+
+    // Create a commit that we can revert
+    PushOneCommit.Result r2 = createChange("commit message", "a.txt", "content3");
+    merge(r2);
+
+    // Create a revert of r2
+    String revertR3Id = gApi.changes().id(r2.getChangeId()).revert().id();
+    // Assert that the change is a pure revert of it's 'revertOf'
+    assertThat(gApi.changes().id(revertR3Id).pureRevert().isPureRevert).isTrue();
+    // Assert that the change is not a pure revert of claimedOriginal because pureRevert is trying
+    // to rebase this on claimed original, which fails.
+    PureRevertInfo pureRevert = gApi.changes().id(revertR3Id).pureRevert(claimedOriginal);
+    assertThat(pureRevert.isPureRevert).isFalse();
+  }
+
+  @Test
+  public void pureRevertThrowsExceptionWhenChangeIsNotARevertAndNoIdProvided() throws Exception {
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("no ID was provided and change isn't a revert");
+    gApi.changes().id(createChange().getChangeId()).pureRevert();
+  }
+
+  @Test
+  public void putTopicExceedLimitFails() throws Exception {
+    String changeId = createChange().getChangeId();
+    String topic = Stream.generate(() -> "t").limit(2049).collect(joining());
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("topic length exceeds the limit");
+    gApi.changes().id(changeId).topic(topic);
+  }
+
+  @Test
+  public void submittableAfterLosingPermissions_MaxWithBlock() throws Exception {
+    configLabel("Label", LabelFunction.MAX_WITH_BLOCK);
+    submittableAfterLosingPermissions("Label");
+  }
+
+  @Test
+  public void submittableAfterLosingPermissions_AnyWithBlock() throws Exception {
+    configLabel("Label", LabelFunction.ANY_WITH_BLOCK);
+    submittableAfterLosingPermissions("Label");
+  }
+
+  public void submittableAfterLosingPermissions(String label) throws Exception {
+    String codeReviewLabel = "Code-Review";
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
+    Util.allow(cfg, Permission.forLabel(label), -1, +1, registered, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel(codeReviewLabel), -2, +2, registered, "refs/heads/*");
+    saveProjectConfig(cfg);
+
+    setApiUser(user);
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Verify user's permitted range.
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertPermitted(change, label, -1, 0, 1);
+    assertPermitted(change, codeReviewLabel, -2, -1, 0, 1, 2);
+
+    ReviewInput input = new ReviewInput();
+    input.label(codeReviewLabel, 2);
+    input.label(label, 1);
+    gApi.changes().id(changeId).current().review(input);
+
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email).votes().keySet())
+        .containsExactly(codeReviewLabel, label);
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email).votes().values())
+        .containsExactly((short) 2, (short) 1);
+    assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
+
+    setApiUser(admin);
+    // Remove user's permission for 'Label'.
+    Util.remove(cfg, Permission.forLabel(label), registered, "refs/heads/*");
+    // Update user's permitted range for 'Code-Review' to be -1...+1.
+    Util.remove(cfg, Permission.forLabel(codeReviewLabel), registered, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel(codeReviewLabel), -1, +1, registered, "refs/heads/*");
+    saveProjectConfig(cfg);
+
+    // Verify user's new permitted range.
+    setApiUser(user);
+    change = gApi.changes().id(changeId).get();
+    assertPermitted(change, label);
+    assertPermitted(change, codeReviewLabel, -1, 0, 1);
+
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email).votes().values())
+        .containsExactly((short) 2, (short) 1);
+    assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
+
+    setApiUser(admin);
+    gApi.changes().id(changeId).current().submit();
+  }
+
+  private String getCommitMessage(String changeId) throws RestApiException, IOException {
+    return gApi.changes().id(changeId).current().file("/COMMIT_MSG").content().asString();
+  }
+
+  private void addComment(
+      PushOneCommit.Result r,
+      String message,
+      boolean omitDuplicateComments,
+      Boolean unresolved,
+      String inReplyTo)
+      throws Exception {
+    ReviewInput.CommentInput c = new ReviewInput.CommentInput();
+    c.line = 1;
+    c.message = message;
+    c.path = FILE_NAME;
+    c.unresolved = unresolved;
+    c.inReplyTo = inReplyTo;
+    ReviewInput in = new ReviewInput();
+    in.comments = new HashMap<>();
+    in.comments.put(c.path, Lists.newArrayList(c));
+    in.omitDuplicateComments = omitDuplicateComments;
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+  }
+
+  private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
+    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+  }
+
+  private ChangeResource parseResource(PushOneCommit.Result r) throws Exception {
+    return parseChangeResource(r.getChangeId());
+  }
+
+  private Optional<ReviewerState> getReviewerState(String changeId, Account.Id accountId)
+      throws Exception {
+    ChangeInfo c = gApi.changes().id(changeId).get(DETAILED_LABELS);
+    Set<ReviewerState> states =
+        c.reviewers
+            .entrySet()
+            .stream()
+            .filter(e -> e.getValue().stream().anyMatch(a -> a._accountId == accountId.get()))
+            .map(e -> e.getKey())
+            .collect(toSet());
+    assertThat(states.size()).named(states.toString()).isAtMost(1);
+    return states.stream().findFirst();
+  }
+
+  private void setChangeStatus(Change.Id id, Change.Status newStatus) throws Exception {
+    try (BatchUpdate batchUpdate =
+        batchUpdateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+      batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
+      batchUpdate.execute();
+    }
+
+    ChangeStatus changeStatus = gApi.changes().id(id.get()).get().status;
+    assertThat(changeStatus).isEqualTo(newStatus.asChangeStatus());
+  }
+
+  private static class ChangeStatusUpdateOp implements BatchUpdateOp {
+    private final Change.Status newStatus;
+
+    ChangeStatusUpdateOp(Change.Status newStatus) {
+      this.newStatus = newStatus;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      Change change = ctx.getChange();
+
+      // Change status in database.
+      change.setStatus(newStatus);
+
+      // Change status in NoteDb.
+      PatchSet.Id currentPatchSetId = change.currentPatchSetId();
+      ctx.getUpdate(currentPatchSetId).setStatus(newStatus);
+
+      return true;
+    }
+  }
+
+  private void addPureRevertSubmitRule() throws Exception {
+    modifySubmitRules(
+        "submit_rule(submit(R)) :- \n"
+            + "gerrit:pure_revert(1), \n"
+            + "!,"
+            + "gerrit:uploader(U), \n"
+            + "R = label('Is-Pure-Revert', ok(U)).\n"
+            + "submit_rule(submit(R)) :- \n"
+            + "gerrit:pure_revert(U), \n"
+            + "U \\= 1,"
+            + "R = label('Is-Pure-Revert', need(_)). \n\n");
+  }
+
+  private void modifySubmitRules(String newContent) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> testRepo = new TestRepository<>((InMemoryRepository) repo);
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .author(admin.getIdent())
+          .committer(admin.getIdent())
+          .add("rules.pl", newContent)
+          .message("Modify rules.pl")
+          .create();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "trackingid.jira-bug.footer", value = "Bug:")
+  @GerritConfig(name = "trackingid.jira-bug.match", value = "JRA\\d{2,8}")
+  @GerritConfig(name = "trackingid.jira-bug.system", value = "JIRA")
+  public void trackingIds() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT + "\n\n" + "Bug:JRA001",
+            PushOneCommit.FILE_NAME,
+            PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get(TRACKING_IDS);
+    Collection<TrackingIdInfo> trackingIds = change.trackingIds;
+    assertThat(trackingIds).isNotNull();
+    assertThat(trackingIds).hasSize(1);
+    assertThat(trackingIds.iterator().next().system).isEqualTo("JIRA");
+    assertThat(trackingIds.iterator().next().id).isEqualTo("JRA001");
+  }
+
+  @Test
+  public void starUnstar() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    changeIndexedCounter.clear();
+
+    gApi.accounts().self().starChange(triplet);
+    ChangeInfo change = info(triplet);
+    assertThat(change.starred).isTrue();
+    assertThat(change.stars).contains(DEFAULT_LABEL);
+    changeIndexedCounter.assertReindexOf(change);
+
+    gApi.accounts().self().unstarChange(triplet);
+    change = info(triplet);
+    assertThat(change.starred).isNull();
+    assertThat(change.stars).isNull();
+    changeIndexedCounter.assertReindexOf(change);
+  }
+
+  @Test
+  public void ignore() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    in = new AddReviewerInput();
+    in.reviewer = user2.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).ignore(true);
+    assertThat(gApi.changes().id(r.getChangeId()).ignored()).isTrue();
+
+    sender.clear();
+    setApiUser(admin);
+    gApi.changes().id(r.getChangeId()).abandon();
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    assertThat(messages.get(0).rcpt()).containsExactly(user2.emailAddress);
+
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).ignore(false);
+    assertThat(gApi.changes().id(r.getChangeId()).ignored()).isFalse();
+  }
+
+  @Test
+  public void cannotIgnoreOwnChange() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("cannot ignore own change");
+    gApi.changes().id(changeId).ignore(true);
+  }
+
+  @Test
+  public void cannotIgnoreStarredChange() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    setApiUser(user);
+    gApi.accounts().self().starChange(changeId);
+    assertThat(gApi.changes().id(changeId).get().starred).isTrue();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        "The labels "
+            + StarredChangesUtil.DEFAULT_LABEL
+            + " and "
+            + StarredChangesUtil.IGNORE_LABEL
+            + " are mutually exclusive. Only one of them can be set.");
+    gApi.changes().id(changeId).ignore(true);
+  }
+
+  @Test
+  public void cannotStarIgnoredChange() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    setApiUser(user);
+    gApi.changes().id(changeId).ignore(true);
+    assertThat(gApi.changes().id(changeId).ignored()).isTrue();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        "The labels "
+            + StarredChangesUtil.DEFAULT_LABEL
+            + " and "
+            + StarredChangesUtil.IGNORE_LABEL
+            + " are mutually exclusive. Only one of them can be set.");
+    gApi.accounts().self().starChange(changeId);
+  }
+
+  @Test
+  public void markAsReviewed() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    setApiUser(user);
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isNull();
+    gApi.changes().id(r.getChangeId()).markAsReviewed(true);
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isTrue();
+
+    setApiUser(user2);
+    sender.clear();
+    amendChange(r.getChangeId());
+
+    setApiUser(user);
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isNull();
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    assertThat(messages.get(0).rcpt()).containsExactly(user.emailAddress);
+  }
+
+  @Test
+  public void cannotSetUnreviewedLabelForPatchSetThatAlreadyHasReviewedLabel() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    setApiUser(user);
+    gApi.changes().id(changeId).markAsReviewed(true);
+    assertThat(gApi.changes().id(changeId).get().reviewed).isTrue();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "The labels "
+            + StarredChangesUtil.REVIEWED_LABEL
+            + "/"
+            + 1
+            + " and "
+            + StarredChangesUtil.UNREVIEWED_LABEL
+            + "/"
+            + 1
+            + " are mutually exclusive. Only one of them can be set.");
+    gApi.accounts()
+        .self()
+        .setStars(
+            changeId, new StarsInput(ImmutableSet.of(StarredChangesUtil.UNREVIEWED_LABEL + "/1")));
+  }
+
+  @Test
+  public void cannotSetReviewedLabelForPatchSetThatAlreadyHasUnreviewedLabel() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    setApiUser(user);
+    gApi.changes().id(changeId).markAsReviewed(false);
+    assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "The labels "
+            + StarredChangesUtil.REVIEWED_LABEL
+            + "/"
+            + 1
+            + " and "
+            + StarredChangesUtil.UNREVIEWED_LABEL
+            + "/"
+            + 1
+            + " are mutually exclusive. Only one of them can be set.");
+    gApi.accounts()
+        .self()
+        .setStars(
+            changeId, new StarsInput(ImmutableSet.of(StarredChangesUtil.REVIEWED_LABEL + "/1")));
+  }
+
+  @Test
+  public void setReviewedAndUnreviewedLabelsForDifferentPatchSets() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    setApiUser(user);
+    gApi.changes().id(changeId).markAsReviewed(true);
+    assertThat(gApi.changes().id(changeId).get().reviewed).isTrue();
+
+    amendChange(changeId);
+    assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
+
+    gApi.changes().id(changeId).markAsReviewed(false);
+    assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
+
+    assertThat(gApi.accounts().self().getStars(changeId))
+        .containsExactly(
+            StarredChangesUtil.REVIEWED_LABEL + "/" + 1,
+            StarredChangesUtil.UNREVIEWED_LABEL + "/" + 2);
+  }
+
+  @Test
+  public void cannotSetInvalidLabel() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    // label cannot contain whitespace
+    String invalidLabel = "invalid label";
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid labels: " + invalidLabel);
+    gApi.accounts().self().setStars(changeId, new StarsInput(ImmutableSet.of(invalidLabel)));
+  }
+
+  @Test
+  public void changeDetailsDoesNotRequireIndex() throws Exception {
+    PushOneCommit.Result change = createChange();
+    int number = gApi.changes().id(change.getChangeId()).get()._number;
+
+    try (AutoCloseable ctx = disableChangeIndex()) {
+      assertThat(gApi.changes().id(project.get(), number).get(ImmutableSet.of()).changeId)
+          .isEqualTo(change.getChangeId());
+    }
+  }
+
+  private static class ChangeIndexedCounter implements ChangeIndexedListener {
+    private final AtomicLongMap<Integer> countsByChange = AtomicLongMap.create();
+
+    @Override
+    public void onChangeIndexed(int id) {
+      countsByChange.incrementAndGet(id);
+    }
+
+    @Override
+    public void onChangeDeleted(int id) {
+      countsByChange.incrementAndGet(id);
+    }
+
+    void clear() {
+      countsByChange.clear();
+    }
+
+    long getCount(ChangeInfo info) {
+      return countsByChange.get(info._number);
+    }
+
+    void assertReindexOf(ChangeInfo info) {
+      assertReindexOf(info, 1);
+    }
+
+    void assertReindexOf(ChangeInfo info, int expectedCount) {
+      assertThat(getCount(info)).isEqualTo(expectedCount);
+      assertThat(countsByChange).hasSize(1);
+      clear();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
new file mode 100644
index 0000000..0b7f340
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
@@ -0,0 +1,147 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.DeprecatedIdentifierException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Project;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class ChangeIdIT extends AbstractDaemonTest {
+  private ChangeInfo changeInfo;
+
+  @Before
+  public void setup() throws Exception {
+    changeInfo = gApi.changes().create(new ChangeInput(project.get(), "master", "msg")).get();
+  }
+
+  @Test
+  public void projectChangeNumberReturnsChange() throws Exception {
+    ChangeApi cApi = gApi.changes().id(project.get(), changeInfo._number);
+    assertThat(cApi.get().changeId).isEqualTo(changeInfo.changeId);
+  }
+
+  @Test
+  public void projectChangeNumberReturnsChangeWhenProjectContainsSlashes() throws Exception {
+    Project.NameKey p = createProject("foo/bar");
+    ChangeInfo ci = gApi.changes().create(new ChangeInput(p.get(), "master", "msg")).get();
+    ChangeApi cApi = gApi.changes().id(p.get(), ci._number);
+    assertThat(cApi.get().changeId).isEqualTo(ci.changeId);
+  }
+
+  @Test
+  public void wrongProjectInProjectChangeNumberReturnsNotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: unknown~" + changeInfo._number);
+    gApi.changes().id("unknown", changeInfo._number);
+  }
+
+  @Test
+  public void wrongIdInProjectChangeNumberReturnsNotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: " + project.get() + "~" + Integer.MAX_VALUE);
+    gApi.changes().id(project.get(), Integer.MAX_VALUE);
+  }
+
+  @Test
+  public void changeNumberReturnsChange() throws Exception {
+    ChangeApi cApi = gApi.changes().id(changeInfo._number);
+    assertThat(cApi.get().changeId).isEqualTo(changeInfo.changeId);
+  }
+
+  @Test
+  public void wrongChangeNumberReturnsNotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes().id(Integer.MAX_VALUE);
+  }
+
+  @Test
+  public void tripletChangeIdReturnsChange() throws Exception {
+    ChangeApi cApi = gApi.changes().id(project.get(), changeInfo.branch, changeInfo.changeId);
+    assertThat(cApi.get().changeId).isEqualTo(changeInfo.changeId);
+  }
+
+  @Test
+  public void wrongProjectInTripletChangeIdReturnsNotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: unknown~" + changeInfo.branch + "~" + changeInfo.changeId);
+    gApi.changes().id("unknown", changeInfo.branch, changeInfo.changeId);
+  }
+
+  @Test
+  public void wrongBranchInTripletChangeIdReturnsNotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: " + project.get() + "~unknown~" + changeInfo.changeId);
+    gApi.changes().id(project.get(), "unknown", changeInfo.changeId);
+  }
+
+  @Test
+  public void wrongIdInTripletChangeIdReturnsNotFound() throws Exception {
+    String unknownId = "I1234567890";
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage(
+        "Not found: " + project.get() + "~" + changeInfo.branch + "~" + unknownId);
+    gApi.changes().id(project.get(), changeInfo.branch, unknownId);
+  }
+
+  @Test
+  public void changeIdReturnsChange() throws Exception {
+    // ChangeId is not unique and this method needs a unique changeId to work.
+    // Hence we generate a new change with a different content.
+    ChangeInfo ci =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "different message")).get();
+    ChangeApi cApi = gApi.changes().id(ci.changeId);
+    assertThat(cApi.get()._number).isEqualTo(ci._number);
+  }
+
+  @Test
+  public void wrongChangeIdReturnsNotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes().id("I1234567890");
+  }
+
+  @Test
+  @GerritConfig(
+    name = "change.api.allowedIdentifier",
+    values = {"PROJECT_NUMERIC_ID", "NUMERIC_ID"}
+  )
+  public void deprecatedChangeIdReturnsBadRequest() throws Exception {
+    // project~changeNumber still works
+    ChangeApi cApi1 = gApi.changes().id(project.get(), changeInfo._number);
+    assertThat(cApi1.get().changeId).isEqualTo(changeInfo.changeId);
+    // Change number still works
+    ChangeApi cApi2 = gApi.changes().id(changeInfo._number);
+    assertThat(cApi2.get().changeId).isEqualTo(changeInfo.changeId);
+    // IHash throws
+    ChangeInfo ci =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "different message")).get();
+    exception.expect(DeprecatedIdentifierException.class);
+    exception.expectMessage(
+        "The provided change identifier "
+            + ci.changeId
+            + " is deprecated. Use 'project~changeNumber' instead.");
+    gApi.changes().id(ci.changeId);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
new file mode 100644
index 0000000..287434d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
@@ -0,0 +1,118 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class DisablePrivateChangesIT extends AbstractDaemonTest {
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void createPrivateChangeWithDisablePrivateChangesTrue() throws Exception {
+    ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
+    input.isPrivate = true;
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("private changes are disabled");
+    gApi.changes().create(input);
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void createNonPrivateChangeWithDisablePrivateChangesTrue() throws Exception {
+    ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
+    assertThat(gApi.changes().create(input).get().isPrivate).isNull();
+  }
+
+  @Test
+  public void createPrivateChangeWithDisablePrivateChangesFalse() throws Exception {
+    ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
+    input.isPrivate = true;
+    assertThat(gApi.changes().create(input).get().isPrivate).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void pushPrivatesWithDisablePrivateChangesTrue() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%private");
+    result.assertErrorStatus();
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void pushDraftsWithDisablePrivateChangesTrue() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%draft");
+    result.assertErrorStatus();
+
+    testRepo.reset(initialHead);
+    result = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/drafts/master");
+    result.assertErrorStatus();
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void pushWithDisablePrivateChangesTrue() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master");
+    result.assertOkStatus();
+    assertThat(result.getChange().change().isPrivate()).isFalse();
+  }
+
+  @Test
+  public void pushPrivatesWithDisablePrivateChangesFalse() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%private");
+    assertThat(result.getChange().change().isPrivate()).isTrue();
+  }
+
+  @Test
+  public void pushDraftsWithDisablePrivateChangesFalse() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%draft");
+    assertThat(result.getChange().change().isPrivate()).isTrue();
+
+    testRepo.reset(initialHead);
+    result = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/drafts/master");
+    assertThat(result.getChange().change().isPrivate()).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void setPrivateWithDisablePrivateChangesTrue() throws Exception {
+    PushOneCommit.Result result = createChange();
+
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("private changes are disabled");
+    gApi.changes().id(result.getChangeId()).setPrivate(true, "set private");
+  }
+
+  @Test
+  public void setPrivateWithDisablePrivateChangesFalse() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).setPrivate(true, "set private");
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/MergeListIT.java b/javatests/com/google/gerrit/acceptance/api/change/MergeListIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/MergeListIT.java
rename to javatests/com/google/gerrit/acceptance/api/change/MergeListIT.java
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
new file mode 100644
index 0000000..c3df089
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -0,0 +1,581 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.ChangeKind.MERGE_FIRST_PARENT_UPDATE;
+import static com.google.gerrit.extensions.client.ChangeKind.NO_CHANGE;
+import static com.google.gerrit.extensions.client.ChangeKind.NO_CODE_CHANGE;
+import static com.google.gerrit.extensions.client.ChangeKind.REWORK;
+import static com.google.gerrit.extensions.client.ChangeKind.TRIVIAL_REBASE;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.Util.category;
+import static com.google.gerrit.server.project.testing.Util.value;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.testing.Util;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class StickyApprovalsIT extends AbstractDaemonTest {
+  @Before
+  public void setup() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+
+    // Overwrite "Code-Review" label that is inherited from All-Projects.
+    // This way changes to the "Code Review" label don't affect other tests.
+    LabelType codeReview =
+        category(
+            "Code-Review",
+            value(2, "Looks good to me, approved"),
+            value(1, "Looks good to me, but someone else must approve"),
+            value(0, "No score"),
+            value(-1, "I would prefer that you didn't submit this"),
+            value(-2, "Do not submit"));
+    codeReview.setCopyAllScoresIfNoChange(false);
+    cfg.getLabelSections().put(codeReview.getName(), codeReview);
+
+    LabelType verified =
+        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    verified.setCopyAllScoresIfNoChange(false);
+    cfg.getLabelSections().put(verified.getName(), verified);
+
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(
+        cfg, Permission.forLabel(Util.codeReview().getName()), -2, 2, registeredUsers, heads);
+    Util.allow(cfg, Permission.forLabel(Util.verified().getName()), -1, 1, registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+  }
+
+  @Test
+  public void notSticky() throws Exception {
+    assertNotSticky(
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE));
+  }
+
+  @Test
+  public void stickyOnMinScore() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review").setCopyMinScore(true);
+    saveProjectConfig(project, cfg);
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(getRemoteHead());
+
+      String changeId = createChange(changeKind);
+      vote(admin, changeId, -1, 1);
+      vote(user, changeId, -2, -1);
+
+      updateChange(changeId, changeKind);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 0, 0, changeKind);
+      assertVotes(c, user, -2, 0, changeKind);
+    }
+  }
+
+  @Test
+  public void stickyOnMaxScore() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review").setCopyMaxScore(true);
+    saveProjectConfig(project, cfg);
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(getRemoteHead());
+
+      String changeId = createChange(changeKind);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, -1);
+
+      updateChange(changeId, changeKind);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 0, changeKind);
+      assertVotes(c, user, 0, 0, changeKind);
+    }
+  }
+
+  @Test
+  public void stickyOnTrivialRebase() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
+    saveProjectConfig(project, cfg);
+
+    String changeId = createChange(TRIVIAL_REBASE);
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    updateChange(changeId, NO_CHANGE);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, NO_CHANGE);
+    assertVotes(c, user, -2, 0, NO_CHANGE);
+
+    updateChange(changeId, TRIVIAL_REBASE);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, TRIVIAL_REBASE);
+    assertVotes(c, user, -2, 0, TRIVIAL_REBASE);
+
+    assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE));
+
+    // check that votes are sticky when trivial rebase is done by cherry-pick
+    testRepo.reset(getRemoteHead());
+    changeId = createChange().getChangeId();
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    String cherryPickChangeId = cherryPick(changeId, TRIVIAL_REBASE);
+    c = detailedChange(cherryPickChangeId);
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+
+    // check that votes are not sticky when rework is done by cherry-pick
+    testRepo.reset(getRemoteHead());
+    changeId = createChange().getChangeId();
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    cherryPickChangeId = cherryPick(changeId, REWORK);
+    c = detailedChange(cherryPickChangeId);
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void stickyOnNoCodeChange() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+    saveProjectConfig(project, cfg);
+
+    String changeId = createChange(NO_CODE_CHANGE);
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    updateChange(changeId, NO_CHANGE);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 1, NO_CHANGE);
+    assertVotes(c, user, 0, -1, NO_CHANGE);
+
+    updateChange(changeId, NO_CODE_CHANGE);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 1, NO_CODE_CHANGE);
+    assertVotes(c, user, 0, -1, NO_CODE_CHANGE);
+
+    assertNotSticky(EnumSet.of(REWORK, TRIVIAL_REBASE, MERGE_FIRST_PARENT_UPDATE));
+  }
+
+  @Test
+  public void stickyOnMergeFirstParentUpdate() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review").setCopyAllScoresOnMergeFirstParentUpdate(true);
+    saveProjectConfig(project, cfg);
+
+    String changeId = createChange(MERGE_FIRST_PARENT_UPDATE);
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    updateChange(changeId, NO_CHANGE);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, NO_CHANGE);
+    assertVotes(c, user, -2, 0, NO_CHANGE);
+
+    updateChange(changeId, MERGE_FIRST_PARENT_UPDATE);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, MERGE_FIRST_PARENT_UPDATE);
+    assertVotes(c, user, -2, 0, MERGE_FIRST_PARENT_UPDATE);
+
+    assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, TRIVIAL_REBASE));
+  }
+
+  @Test
+  public void removedVotesNotSticky() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
+    cfg.getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+    saveProjectConfig(project, cfg);
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(getRemoteHead());
+
+      String changeId = createChange(changeKind);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, -2, -1);
+
+      // Remove votes by re-voting with 0
+      vote(admin, changeId, 0, 0);
+      vote(user, changeId, 0, 0);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 0, 0, null);
+      assertVotes(c, user, 0, 0, null);
+
+      updateChange(changeId, changeKind);
+      c = detailedChange(changeId);
+      assertVotes(c, admin, 0, 0, changeKind);
+      assertVotes(c, user, 0, 0, changeKind);
+    }
+  }
+
+  @Test
+  public void stickyAcrossMultiplePatchSets() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review").setCopyMaxScore(true);
+    cfg.getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+    saveProjectConfig(project, cfg);
+
+    String changeId = createChange(REWORK);
+    vote(admin, changeId, 2, 1);
+
+    for (int i = 0; i < 5; i++) {
+      updateChange(changeId, NO_CODE_CHANGE);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 1, NO_CODE_CHANGE);
+    }
+
+    updateChange(changeId, REWORK);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+  }
+
+  @Test
+  public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review").setCopyMaxScore(true);
+    cfg.getLabelSections().get("Code-Review").setCopyMinScore(true);
+    saveProjectConfig(project, cfg);
+
+    // Vote max score on PS1
+    String changeId = createChange(REWORK);
+    vote(admin, changeId, 2, 1);
+
+    // Have someone else vote min score on PS2
+    updateChange(changeId, REWORK);
+    vote(user, changeId, -2, 0);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+    assertVotes(c, user, -2, 0, REWORK);
+
+    // No vote changes on PS3
+    updateChange(changeId, REWORK);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+    assertVotes(c, user, -2, 0, REWORK);
+
+    // Both users revote on PS4
+    updateChange(changeId, REWORK);
+    vote(admin, changeId, 1, 1);
+    vote(user, changeId, 1, 1);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 1, 1, REWORK);
+    assertVotes(c, user, 1, 1, REWORK);
+
+    // New approvals shouldn't carry through to PS5
+    updateChange(changeId, REWORK);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 0, REWORK);
+    assertVotes(c, user, 0, 0, REWORK);
+  }
+
+  @Test
+  public void deleteStickyVote() throws Exception {
+    String label = "Code-Review";
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get(label).setCopyMaxScore(true);
+    saveProjectConfig(project, cfg);
+
+    // Vote max score on PS1
+    String changeId = createChange(REWORK);
+    vote(admin, changeId, label, 2);
+    assertVotes(detailedChange(changeId), admin, label, 2, null);
+    updateChange(changeId, REWORK);
+    assertVotes(detailedChange(changeId), admin, label, 2, REWORK);
+
+    // Delete vote that was copied via sticky approval
+    deleteVote(admin, changeId, "Code-Review");
+    assertVotes(detailedChange(changeId), admin, label, 0, REWORK);
+  }
+
+  private ChangeInfo detailedChange(String changeId) throws Exception {
+    return gApi.changes().id(changeId).get(DETAILED_LABELS, CURRENT_REVISION, CURRENT_COMMIT);
+  }
+
+  private void assertNotSticky(Set<ChangeKind> changeKinds) throws Exception {
+    for (ChangeKind changeKind : changeKinds) {
+      testRepo.reset(getRemoteHead());
+
+      String changeId = createChange(changeKind);
+      vote(admin, changeId, +2, 1);
+      vote(user, changeId, -2, -1);
+
+      updateChange(changeId, changeKind);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 0, 0, changeKind);
+      assertVotes(c, user, 0, 0, changeKind);
+    }
+  }
+
+  private String createChange(ChangeKind kind) throws Exception {
+    switch (kind) {
+      case NO_CODE_CHANGE:
+      case REWORK:
+      case TRIVIAL_REBASE:
+      case NO_CHANGE:
+        return createChange().getChangeId();
+      case MERGE_FIRST_PARENT_UPDATE:
+        return createChangeForMergeCommit();
+      default:
+        throw new IllegalStateException("unexpected change kind: " + kind);
+    }
+  }
+
+  private void updateChange(String changeId, ChangeKind changeKind) throws Exception {
+    switch (changeKind) {
+      case NO_CODE_CHANGE:
+        noCodeChange(changeId);
+        return;
+      case REWORK:
+        rework(changeId);
+        return;
+      case TRIVIAL_REBASE:
+        trivialRebase(changeId);
+        return;
+      case MERGE_FIRST_PARENT_UPDATE:
+        updateFirstParent(changeId);
+        return;
+      case NO_CHANGE:
+        noChange(changeId);
+        return;
+      default:
+        fail("unexpected change kind: " + changeKind);
+    }
+  }
+
+  private void noCodeChange(String changeId) throws Exception {
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
+    commitBuilder
+        .message("New subject " + System.nanoTime())
+        .author(admin.getIdent())
+        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
+    commitBuilder.create();
+    GitUtil.pushHead(testRepo, "refs/for/master", false);
+    assertThat(getChangeKind(changeId)).isEqualTo(NO_CODE_CHANGE);
+  }
+
+  private void noChange(String changeId) throws Exception {
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    String commitMessage = change.revisions.get(change.currentRevision).commit.message;
+
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
+    commitBuilder
+        .message(commitMessage)
+        .author(admin.getIdent())
+        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
+    commitBuilder.create();
+    GitUtil.pushHead(testRepo, "refs/for/master", false);
+    assertThat(getChangeKind(changeId)).isEqualTo(NO_CHANGE);
+  }
+
+  private void rework(String changeId) throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            "new content " + System.nanoTime(),
+            changeId);
+    push.to("refs/for/master").assertOkStatus();
+    assertThat(getChangeKind(changeId)).isEqualTo(REWORK);
+  }
+
+  private void trivialRebase(String changeId) throws Exception {
+    setApiUser(admin);
+    testRepo.reset(getRemoteHead());
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Other Change",
+            "a" + System.nanoTime() + ".txt",
+            PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    ReviewInput in = new ReviewInput().label("Code-Review", 2).label("Verified", 1);
+    revision.review(in);
+    revision.submit();
+
+    gApi.changes().id(changeId).current().rebase();
+    assertThat(getChangeKind(changeId)).isEqualTo(TRIVIAL_REBASE);
+  }
+
+  private String createChangeForMergeCommit() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    PushOneCommit.Result parent1 = createChange("parent 1", "p1.txt", "content 1");
+
+    testRepo.reset(initial);
+    PushOneCommit.Result parent2 = createChange("parent 2", "p2.txt", "content 2");
+
+    testRepo.reset(parent1.getCommit());
+
+    PushOneCommit merge = pushFactory.create(db, admin.getIdent(), testRepo);
+    merge.setParents(ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+    return result.getChangeId();
+  }
+
+  private void updateFirstParent(String changeId) throws Exception {
+    ChangeInfo c = detailedChange(changeId);
+    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
+    String parent1 = parents.get(0).commit;
+    String parent2 = parents.get(1).commit;
+    RevCommit commitParent2 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent2));
+
+    testRepo.reset(parent1);
+    PushOneCommit.Result newParent1 = createChange("new parent 1", "p1-1.txt", "content 1-1");
+
+    PushOneCommit merge = pushFactory.create(db, admin.getIdent(), testRepo, changeId);
+    merge.setParents(ImmutableList.of(newParent1.getCommit(), commitParent2));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+
+    assertThat(getChangeKind(changeId)).isEqualTo(MERGE_FIRST_PARENT_UPDATE);
+  }
+
+  private String cherryPick(String changeId, ChangeKind changeKind) throws Exception {
+    switch (changeKind) {
+      case REWORK:
+      case TRIVIAL_REBASE:
+        break;
+      case NO_CODE_CHANGE:
+      case NO_CHANGE:
+      case MERGE_FIRST_PARENT_UPDATE:
+      default:
+        fail("unexpected change kind: " + changeKind);
+    }
+
+    testRepo.reset(getRemoteHead());
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                "other.txt",
+                "new content " + System.nanoTime())
+            .to("refs/for/master");
+    r.assertOkStatus();
+    vote(admin, r.getChangeId(), 2, 1);
+    merge(r);
+
+    String subject =
+        TRIVIAL_REBASE.equals(changeKind)
+            ? PushOneCommit.SUBJECT
+            : "Reworked change " + System.nanoTime();
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "master";
+    in.message = String.format("%s\n\nChange-Id: %s", subject, changeId);
+    ChangeInfo c = gApi.changes().id(changeId).revision("current").cherryPick(in).get();
+    return c.changeId;
+  }
+
+  private ChangeKind getChangeKind(String changeId) throws Exception {
+    ChangeInfo c = gApi.changes().id(changeId).get(CURRENT_REVISION);
+    return c.revisions.get(c.currentRevision).kind;
+  }
+
+  private void vote(TestAccount user, String changeId, String label, int vote) throws Exception {
+    setApiUser(user);
+    gApi.changes().id(changeId).current().review(new ReviewInput().label(label, vote));
+  }
+
+  private void vote(TestAccount user, String changeId, int codeReviewVote, int verifiedVote)
+      throws Exception {
+    setApiUser(user);
+    ReviewInput in =
+        new ReviewInput().label("Code-Review", codeReviewVote).label("Verified", verifiedVote);
+    gApi.changes().id(changeId).current().review(in);
+  }
+
+  private void deleteVote(TestAccount user, String changeId, String label) throws Exception {
+    setApiUser(user);
+    gApi.changes().id(changeId).reviewer(user.getId().toString()).deleteVote(label);
+  }
+
+  private void assertVotes(ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote) {
+    assertVotes(c, user, codeReviewVote, verifiedVote, null);
+  }
+
+  private void assertVotes(
+      ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote, ChangeKind changeKind) {
+    assertVotes(c, user, "Code-Review", codeReviewVote, changeKind);
+    assertVotes(c, user, "Verified", verifiedVote, changeKind);
+  }
+
+  private void assertVotes(
+      ChangeInfo c, TestAccount user, String label, int expectedVote, ChangeKind changeKind) {
+    Integer vote = 0;
+    if (c.labels.get(label) != null && c.labels.get(label).all != null) {
+      for (ApprovalInfo approval : c.labels.get(label).all) {
+        if (approval._accountId == user.id.get()) {
+          vote = approval.value;
+          break;
+        }
+      }
+    }
+
+    String name = "label = " + label;
+    if (changeKind != null) {
+      name += "; changeKind = " + changeKind.name();
+    }
+    assertThat(vote).named(name).isEqualTo(expectedVote);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
new file mode 100644
index 0000000..fe2af77
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
@@ -0,0 +1,270 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.SubmitType.CHERRY_PICK;
+import static com.google.gerrit.extensions.client.SubmitType.FAST_FORWARD_ONLY;
+import static com.google.gerrit.extensions.client.SubmitType.MERGE_ALWAYS;
+import static com.google.gerrit.extensions.client.SubmitType.MERGE_IF_NECESSARY;
+import static com.google.gerrit.extensions.client.SubmitType.REBASE_ALWAYS;
+import static com.google.gerrit.extensions.client.SubmitType.REBASE_IF_NECESSARY;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.TestSubmitRuleInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.gerrit.testing.ConfigSuite;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class SubmitTypeRuleIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config submitWholeTopicEnabled() {
+    return submitWholeTopicEnabledConfig();
+  }
+
+  private class RulesPl extends VersionedMetaData {
+    private static final String FILENAME = "rules.pl";
+
+    private String rule;
+
+    @Override
+    protected String getRefName() {
+      return RefNames.REFS_CONFIG;
+    }
+
+    @Override
+    protected void onLoad() throws IOException, ConfigInvalidException {
+      rule = readUTF8(FILENAME);
+    }
+
+    @Override
+    protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+      TestSubmitRuleInput in = new TestSubmitRuleInput();
+      in.rule = rule;
+      try {
+        gApi.changes().id(testChangeId.get()).current().testSubmitType(in);
+      } catch (RestApiException e) {
+        throw new ConfigInvalidException("Invalid submit type rule", e);
+      }
+
+      saveUTF8(FILENAME, rule);
+      return true;
+    }
+  }
+
+  private AtomicInteger fileCounter;
+  private Change.Id testChangeId;
+
+  @Before
+  public void setUp() throws Exception {
+    fileCounter = new AtomicInteger();
+    gApi.projects().name(project.get()).branch("test").create(new BranchInput());
+    testChangeId = createChange("test", "test change").getChange().getId();
+  }
+
+  private void setRulesPl(String rule) throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      RulesPl r = new RulesPl();
+      r.load(md);
+      r.rule = rule;
+      r.commit(md);
+    }
+  }
+
+  private static final String SUBMIT_TYPE_FROM_SUBJECT =
+      "submit_type(fast_forward_only) :-"
+          + "gerrit:commit_message(M),"
+          + "regex_matches('.*FAST_FORWARD_ONLY.*', M),"
+          + "!.\n"
+          + "submit_type(merge_if_necessary) :-"
+          + "gerrit:commit_message(M),"
+          + "regex_matches('.*MERGE_IF_NECESSARY.*', M),"
+          + "!.\n"
+          + "submit_type(rebase_if_necessary) :-"
+          + "gerrit:commit_message(M),"
+          + "regex_matches('.*REBASE_IF_NECESSARY.*', M),"
+          + "!.\n"
+          + "submit_type(rebase_always) :-"
+          + "gerrit:commit_message(M),"
+          + "regex_matches('.*REBASE_ALWAYS.*', M),"
+          + "!.\n"
+          + "submit_type(merge_always) :-"
+          + "gerrit:commit_message(M),"
+          + "regex_matches('.*MERGE_ALWAYS.*', M),"
+          + "!.\n"
+          + "submit_type(cherry_pick) :-"
+          + "gerrit:commit_message(M),"
+          + "regex_matches('.*CHERRY_PICK.*', M),"
+          + "!.\n"
+          + "submit_type(T) :- gerrit:project_default_submit_type(T).";
+
+  private PushOneCommit.Result createChange(String dest, String subject) throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            subject,
+            "file" + fileCounter.incrementAndGet(),
+            PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result r = push.to("refs/for/" + dest);
+    r.assertOkStatus();
+    return r;
+  }
+
+  @Test
+  public void unconditionalCherryPick() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertSubmitType(MERGE_IF_NECESSARY, r.getChangeId());
+    setRulesPl("submit_type(cherry_pick).");
+    assertSubmitType(CHERRY_PICK, r.getChangeId());
+  }
+
+  @Test
+  public void submitTypeFromSubject() throws Exception {
+    PushOneCommit.Result r1 = createChange("master", "Default 1");
+    PushOneCommit.Result r2 = createChange("master", "FAST_FORWARD_ONLY 2");
+    PushOneCommit.Result r3 = createChange("master", "MERGE_IF_NECESSARY 3");
+    PushOneCommit.Result r4 = createChange("master", "REBASE_IF_NECESSARY 4");
+    PushOneCommit.Result r5 = createChange("master", "REBASE_ALWAYS 5");
+    PushOneCommit.Result r6 = createChange("master", "MERGE_ALWAYS 6");
+    PushOneCommit.Result r7 = createChange("master", "CHERRY_PICK 7");
+
+    assertSubmitType(MERGE_IF_NECESSARY, r1.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r2.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r3.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r4.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r5.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r6.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r7.getChangeId());
+
+    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
+
+    assertSubmitType(MERGE_IF_NECESSARY, r1.getChangeId());
+    assertSubmitType(FAST_FORWARD_ONLY, r2.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r3.getChangeId());
+    assertSubmitType(REBASE_IF_NECESSARY, r4.getChangeId());
+    assertSubmitType(REBASE_ALWAYS, r5.getChangeId());
+    assertSubmitType(MERGE_ALWAYS, r6.getChangeId());
+    assertSubmitType(CHERRY_PICK, r7.getChangeId());
+  }
+
+  @Test
+  public void submitTypeIsUsedForSubmit() throws Exception {
+    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
+
+    PushOneCommit.Result r = createChange("master", "CHERRY_PICK 1");
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    List<RevCommit> log = log("master", 1);
+    assertThat(log.get(0).getShortMessage()).isEqualTo("CHERRY_PICK 1");
+    assertThat(log.get(0).name()).isNotEqualTo(r.getCommit().name());
+    assertThat(log.get(0).getFullMessage()).contains("Change-Id: " + r.getChangeId());
+    assertThat(log.get(0).getFullMessage()).contains("Reviewed-on: ");
+  }
+
+  @Test
+  public void mixingSubmitTypesAcrossBranchesSucceeds() throws Exception {
+    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
+
+    PushOneCommit.Result r1 = createChange("master", "MERGE_IF_NECESSARY 1");
+
+    RevCommit initialCommit = r1.getCommit().getParent(0);
+    BranchInput bin = new BranchInput();
+    bin.revision = initialCommit.name();
+    gApi.projects().name(project.get()).branch("branch").create(bin);
+
+    testRepo.reset(initialCommit);
+    PushOneCommit.Result r2 = createChange("branch", "MERGE_ALWAYS 1");
+
+    gApi.changes().id(r1.getChangeId()).topic(name("topic"));
+    gApi.changes().id(r1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r2.getChangeId()).topic(name("topic"));
+    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r2.getChangeId()).current().submit();
+
+    assertThat(log("master", 1).get(0).name()).isEqualTo(r1.getCommit().name());
+
+    List<RevCommit> branchLog = log("branch", 1);
+    assertThat(branchLog.get(0).getParents()).hasLength(2);
+    assertThat(branchLog.get(0).getParent(1).name()).isEqualTo(r2.getCommit().name());
+  }
+
+  @Test
+  public void mixingSubmitTypesOnOneBranchFails() throws Exception {
+    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
+
+    PushOneCommit.Result r1 = createChange("master", "CHERRY_PICK 1");
+    PushOneCommit.Result r2 = createChange("master", "MERGE_IF_NECESSARY 2");
+
+    gApi.changes().id(r1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.approve());
+
+    try {
+      gApi.changes().id(r2.getChangeId()).current().submit();
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(
+              "Failed to submit 2 changes due to the following problems:\n"
+                  + "Change "
+                  + r1.getChange().getId()
+                  + ": Change has submit type "
+                  + "CHERRY_PICK, but previously chose submit type MERGE_IF_NECESSARY "
+                  + "from change "
+                  + r2.getChange().getId()
+                  + " in the same batch");
+    }
+  }
+
+  private List<RevCommit> log(String commitish, int n) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        Git git = new Git(repo)) {
+      ObjectId id = repo.resolve(commitish);
+      assertThat(id).isNotNull();
+      return ImmutableList.copyOf(git.log().add(id).setMaxCount(n).call());
+    }
+  }
+
+  private void assertSubmitType(SubmitType expected, String id) throws Exception {
+    assertThat(gApi.changes().id(id).current().submitType()).isEqualTo(expected);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/config/BUILD b/javatests/com/google/gerrit/acceptance/api/config/BUILD
new file mode 100644
index 0000000..4d6217a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/config/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "api_config",
+    labels = ["api"],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
rename to javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
diff --git a/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
new file mode 100644
index 0000000..c606982
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import org.junit.Test;
+
+@NoHttpd
+public class GeneralPreferencesIT extends AbstractDaemonTest {
+  @Test
+  public void getGeneralPreferences() throws Exception {
+    GeneralPreferencesInfo result = gApi.config().server().getDefaultPreferences();
+    assertPrefs(result, GeneralPreferencesInfo.defaults(), "changeTable", "my");
+  }
+
+  @Test
+  public void setGeneralPreferences() throws Exception {
+    boolean newSignedOffBy = !GeneralPreferencesInfo.defaults().signedOffBy;
+    GeneralPreferencesInfo update = new GeneralPreferencesInfo();
+    update.signedOffBy = newSignedOffBy;
+    GeneralPreferencesInfo result = gApi.config().server().setDefaultPreferences(update);
+    assertThat(result.signedOffBy).named("signedOffBy").isEqualTo(newSignedOffBy);
+
+    result = gApi.config().server().getDefaultPreferences();
+    GeneralPreferencesInfo expected = GeneralPreferencesInfo.defaults();
+    expected.signedOffBy = newSignedOffBy;
+    assertPrefs(result, expected, "changeTable", "my");
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/ServerIT.java b/javatests/com/google/gerrit/acceptance/api/config/ServerIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/ServerIT.java
rename to javatests/com/google/gerrit/acceptance/api/config/ServerIT.java
diff --git a/javatests/com/google/gerrit/acceptance/api/group/BUILD b/javatests/com/google/gerrit/acceptance/api/group/BUILD
new file mode 100644
index 0000000..781122b
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/group/BUILD
@@ -0,0 +1,24 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "api_group",
+    labels = ["api"],
+    deps = [
+        ":util",
+        "//java/com/google/gerrit/server/group/db/testing",
+        "//javatests/com/google/gerrit/acceptance/rest/account:util",
+    ],
+)
+
+java_library(
+    name = "util",
+    srcs = ["GroupAssert.java"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:gwtorm",
+        "//lib:truth",
+    ],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java b/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java
rename to javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupRebuilderIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupRebuilderIT.java
new file mode 100644
index 0000000..6b8eb14
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupRebuilderIT.java
@@ -0,0 +1,254 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.group;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.group.ServerInitiated;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.group.db.GroupBundle;
+import com.google.gerrit.server.group.db.GroupRebuilder;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gerrit.testing.TestTimeUtil.TempClockStep;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class GroupRebuilderIT extends AbstractDaemonTest {
+  @Inject @GerritServerId private String serverId;
+  @Inject @ServerInitiated private Provider<GroupsUpdate> groupsUpdate;
+  @Inject private GroupBundle.Factory bundleFactory;
+  @Inject private GroupRebuilder rebuilder;
+  @Inject private GroupsMigration migration;
+
+  @Before
+  public void setUp() {
+    // This test is explicitly testing the migration from ReviewDb to NoteDb, and handles reading
+    // from NoteDb manually. It should work regardless of the value of noteDb.groups.write, however.
+    assume().that(migration.readFromNoteDb()).isFalse();
+  }
+
+  @Before
+  public void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void basicGroupProperties() throws Exception {
+    GroupInfo createdGroup = gApi.groups().create(name("group")).get();
+    try (BlockReviewDbUpdatesForGroups ctx = new BlockReviewDbUpdatesForGroups()) {
+      GroupBundle reviewDbBundle =
+          bundleFactory.fromReviewDb(db, new AccountGroup.Id(createdGroup.groupId));
+      deleteGroupRefs(reviewDbBundle);
+
+      assertMigratedCleanly(rebuild(reviewDbBundle), reviewDbBundle);
+    }
+  }
+
+  @Test
+  public void logFormat() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    GroupInfo group1 = gApi.groups().create(name("group1")).get();
+    GroupInfo group2 = gApi.groups().create(name("group2")).get();
+
+    try (TempClockStep step = TestTimeUtil.freezeClock()) {
+      gApi.groups().id(group1.id).addMembers(user.id.toString(), user2.id.toString());
+    }
+    TimeUtil.nowTs();
+
+    try (TempClockStep step = TestTimeUtil.freezeClock()) {
+      gApi.groups().id(group1.id).addGroups(group2.id, SystemGroupBackend.REGISTERED_USERS.get());
+    }
+
+    try (BlockReviewDbUpdatesForGroups ctx = new BlockReviewDbUpdatesForGroups()) {
+      GroupBundle reviewDbBundle =
+          bundleFactory.fromReviewDb(db, new AccountGroup.Id(group1.groupId));
+      deleteGroupRefs(reviewDbBundle);
+
+      GroupBundle noteDbBundle = rebuild(reviewDbBundle);
+      assertMigratedCleanly(noteDbBundle, reviewDbBundle);
+
+      ImmutableList<CommitInfo> log = log(group1);
+      assertThat(log).hasSize(4);
+
+      assertThat(log.get(0)).message().isEqualTo("Create group");
+      assertThat(log.get(0)).author().name().isEqualTo(serverIdent.get().getName());
+      assertThat(log.get(0)).author().email().isEqualTo(serverIdent.get().getEmailAddress());
+      assertThat(log.get(0)).author().date().isEqualTo(noteDbBundle.group().getCreatedOn());
+      assertThat(log.get(0)).author().tz().isEqualTo(serverIdent.get().getTimeZoneOffset());
+      assertThat(log.get(0)).committer().isEqualTo(log.get(0).author);
+
+      assertThat(log.get(1))
+          .message()
+          .isEqualTo("Update group\n\nAdd: Administrator <" + admin.id + "@" + serverId + ">");
+      assertThat(log.get(1)).author().name().isEqualTo(admin.fullName);
+      assertThat(log.get(1)).author().email().isEqualTo(admin.id + "@" + serverId);
+      assertThat(log.get(1)).committer().hasSameDateAs(log.get(1).author);
+
+      assertThat(log.get(2))
+          .message()
+          .isEqualTo(
+              "Update group\n"
+                  + "\n"
+                  + ("Add: User <" + user.id + "@" + serverId + ">\n")
+                  + ("Add: User2 <" + user2.id + "@" + serverId + ">"));
+      assertThat(log.get(2)).author().name().isEqualTo(admin.fullName);
+      assertThat(log.get(2)).author().email().isEqualTo(admin.id + "@" + serverId);
+      assertThat(log.get(2)).committer().hasSameDateAs(log.get(2).author);
+
+      assertThat(log.get(3))
+          .message()
+          .isEqualTo(
+              "Update group\n"
+                  + "\n"
+                  + ("Add-group: " + group2.name + " <" + group2.id + ">\n")
+                  + ("Add-group: Registered Users <global:Registered-Users>"));
+      assertThat(log.get(3)).author().name().isEqualTo(admin.fullName);
+      assertThat(log.get(3)).author().email().isEqualTo(admin.id + "@" + serverId);
+      assertThat(log.get(3)).committer().hasSameDateAs(log.get(3).author);
+    }
+  }
+
+  @Test
+  public void unknownGroupUuid() throws Exception {
+    GroupInfo group = gApi.groups().create(name("group")).get();
+
+    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("mybackend:foo");
+
+    AccountGroupById byId =
+        new AccountGroupById(
+            new AccountGroupById.Key(new AccountGroup.Id(group.groupId), subgroupUuid));
+    assertThat(groupBackend.handles(byId.getIncludeUUID())).isFalse();
+    db.accountGroupById().insert(Collections.singleton(byId));
+
+    AccountGroupByIdAud audit = new AccountGroupByIdAud(byId, admin.id, TimeUtil.nowTs());
+    db.accountGroupByIdAud().insert(Collections.singleton(audit));
+
+    GroupBundle reviewDbBundle = bundleFactory.fromReviewDb(db, new AccountGroup.Id(group.groupId));
+    deleteGroupRefs(reviewDbBundle);
+
+    GroupBundle noteDbBundle = rebuild(reviewDbBundle);
+    assertMigratedCleanly(noteDbBundle, reviewDbBundle);
+
+    ImmutableList<CommitInfo> log = log(group);
+    assertThat(log).hasSize(3);
+
+    assertThat(log.get(0)).message().isEqualTo("Create group");
+    assertThat(log.get(1))
+        .message()
+        .isEqualTo("Update group\n\nAdd: Administrator <" + admin.id + "@" + serverId + ">");
+    assertThat(log.get(2))
+        .message()
+        .isEqualTo("Update group\n\nAdd-group: mybackend:foo <mybackend:foo>");
+  }
+
+  private void deleteGroupRefs(GroupBundle bundle) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      String refName = RefNames.refsGroups(bundle.uuid());
+      RefUpdate ru = repo.updateRef(refName);
+      ru.setForceUpdate(true);
+      Ref oldRef = repo.exactRef(refName);
+      if (oldRef == null) {
+        return;
+      }
+      ru.setExpectedOldObjectId(oldRef.getObjectId());
+      ru.setNewObjectId(ObjectId.zeroId());
+      assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+  }
+
+  private GroupBundle rebuild(GroupBundle reviewDbBundle) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      rebuilder.rebuild(repo, reviewDbBundle, null);
+      return bundleFactory.fromNoteDb(repo, reviewDbBundle.uuid());
+    }
+  }
+
+  private void assertMigratedCleanly(GroupBundle noteDbBundle, GroupBundle expectedReviewDbBundle) {
+    assertThat(GroupBundle.compareWithAudits(expectedReviewDbBundle, noteDbBundle)).isEmpty();
+  }
+
+  private ImmutableList<CommitInfo> log(GroupInfo g) throws Exception {
+    ImmutableList.Builder<CommitInfo> result = ImmutableList.builder();
+    List<Date> commitDates = new ArrayList<>();
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(RefNames.refsGroups(new AccountGroup.UUID(g.id)));
+      if (ref != null) {
+        rw.sort(RevSort.REVERSE);
+        rw.setRetainBody(true);
+        rw.markStart(rw.parseCommit(ref.getObjectId()));
+        for (RevCommit c : rw) {
+          result.add(CommitUtil.toCommitInfo(c));
+          commitDates.add(c.getCommitterIdent().getWhen());
+        }
+      }
+    }
+    assertThat(commitDates).named("commit timestamps for %s", result).isOrdered();
+    return result.build();
+  }
+
+  private class BlockReviewDbUpdatesForGroups implements AutoCloseable {
+    BlockReviewDbUpdatesForGroups() {
+      blockReviewDbUpdates(true);
+    }
+
+    @Override
+    public void close() throws Exception {
+      blockReviewDbUpdates(false);
+    }
+
+    private void blockReviewDbUpdates(boolean block) {
+      cfg.setBoolean("user", null, "blockReviewDbGroupUpdates", block);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
new file mode 100644
index 0000000..4e9c37b
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
@@ -0,0 +1,280 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.group;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.notedb.NoteDbTable.GROUPS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.GroupNameNotes;
+import com.google.gerrit.server.group.db.testing.GroupTestUtil;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.testing.ConfigSuite;
+import java.util.List;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.RefRename;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Checks that invalid group configurations are flagged. Since the inconsistencies are global to the
+ * test server configuration, and leak from one test method into the next one, there is no way for
+ * this test to not be sandboxed.
+ */
+@Sandboxed
+@NoHttpd
+public class GroupsConsistencyIT extends AbstractDaemonTest {
+
+  @ConfigSuite.Config
+  public static Config noteDbConfig() {
+    Config config = new Config();
+    config.setBoolean(NotesMigration.SECTION_NOTE_DB, GROUPS.key(), NotesMigration.WRITE, true);
+    config.setBoolean(NotesMigration.SECTION_NOTE_DB, GROUPS.key(), NotesMigration.READ, true);
+    return config;
+  }
+
+  @Inject private GroupsMigration groupsMigration;
+
+  private GroupInfo gAdmin;
+  private GroupInfo g1;
+  private GroupInfo g2;
+
+  private static final String BOGUS_UUID = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+
+  @Before
+  public void basicSetup() throws Exception {
+    assume().that(groupsInNoteDb()).isTrue();
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    String name1 = createGroup("g1");
+    String name2 = createGroup("g2");
+
+    gApi.groups().id(name1).addMembers(user.fullName);
+    gApi.groups().id(name2).addMembers(admin.fullName);
+    gApi.groups().id(name1).addGroups(name2);
+
+    this.g1 = gApi.groups().id(name1).detail();
+    this.g2 = gApi.groups().id(name2).detail();
+    this.gAdmin = gApi.groups().id("Administrators").detail();
+  }
+
+  private boolean groupsInNoteDb() {
+    return groupsMigration.writeToNoteDb();
+  }
+
+  @Test
+  public void allGood() throws Exception {
+    assertThat(check()).isEmpty();
+  }
+
+  @Test
+  public void missingGroupNameRef() throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      RefUpdate ru = repo.updateRef(RefNames.REFS_GROUPNAMES);
+      ru.setForceUpdate(true);
+      RefUpdate.Result result = ru.delete();
+      assertThat(result).isEqualTo(Result.FORCED);
+    }
+
+    assertError("refs/meta/group-names does not exist");
+  }
+
+  @Test
+  public void missingGroupRef() throws Exception {
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      RefUpdate ru = repo.updateRef(RefNames.refsGroups(new AccountGroup.UUID(g1.id)));
+      ru.setForceUpdate(true);
+      RefUpdate.Result result = ru.delete();
+      assertThat(result).isEqualTo(Result.FORCED);
+    }
+
+    assertError("missing as group ref");
+  }
+
+  @Test
+  public void parseGroupRef() throws Exception {
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      RefRename ru =
+          repo.renameRef(
+              RefNames.refsGroups(new AccountGroup.UUID(g1.id)), RefNames.REFS_GROUPS + BOGUS_UUID);
+      RefUpdate.Result result = ru.rename();
+      assertThat(result).isEqualTo(Result.RENAMED);
+    }
+
+    assertError("null UUID from");
+  }
+
+  @Test
+  public void missingNameEntry() throws Exception {
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      RefRename ru =
+          repo.renameRef(
+              RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
+              RefNames.refsGroups(new AccountGroup.UUID(BOGUS_UUID)));
+      RefUpdate.Result result = ru.rename();
+      assertThat(result).isEqualTo(Result.RENAMED);
+    }
+
+    assertError("group " + BOGUS_UUID + " has no entry in name map");
+  }
+
+  @Test
+  public void groupRefDoesNotParse() throws Exception {
+    updateGroupFile(
+        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
+        GroupConfig.GROUP_CONFIG_FILE,
+        "[this is not valid\n");
+    assertError("does not parse");
+  }
+
+  @Test
+  public void nameRefDoesNotParse() throws Exception {
+    updateGroupFile(
+        RefNames.REFS_GROUPNAMES,
+        GroupNameNotes.getNoteKey(new AccountGroup.NameKey(g1.name)).getName(),
+        "[this is not valid\n");
+    assertError("does not parse");
+  }
+
+  @Test
+  public void inconsistentName() throws Exception {
+    Config cfg = new Config();
+    cfg.setString("group", null, "name", "not really");
+    cfg.setString("group", null, "id", "42");
+    cfg.setString("group", null, "ownerGroupUuid", gAdmin.id);
+
+    updateGroupFile(
+        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
+        GroupConfig.GROUP_CONFIG_FILE,
+        cfg.toText());
+    assertError("inconsistent name");
+  }
+
+  @Test
+  public void sharedGroupID() throws Exception {
+    Config cfg = new Config();
+    cfg.setString("group", null, "name", g1.name);
+    cfg.setInt("group", null, "id", g2.groupId);
+    cfg.setString("group", null, "ownerGroupUuid", gAdmin.id);
+
+    updateGroupFile(
+        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
+        GroupConfig.GROUP_CONFIG_FILE,
+        cfg.toText());
+    assertError("shared group id");
+  }
+
+  @Test
+  public void unknownOwnerGroup() throws Exception {
+    Config cfg = new Config();
+    cfg.setString("group", null, "name", g1.name);
+    cfg.setInt("group", null, "id", g1.groupId);
+    cfg.setString("group", null, "ownerGroupUuid", BOGUS_UUID);
+
+    updateGroupFile(
+        RefNames.refsGroups(new AccountGroup.UUID(g1.id)),
+        GroupConfig.GROUP_CONFIG_FILE,
+        cfg.toText());
+    assertError("nonexistent owner group");
+  }
+
+  @Test
+  public void nameWithoutGroupRef() throws Exception {
+    String bogusName = "bogus name";
+    Config config = new Config();
+    config.setString("group", null, "uuid", BOGUS_UUID);
+    config.setString("group", null, "name", bogusName);
+
+    updateGroupFile(
+        RefNames.REFS_GROUPNAMES,
+        GroupNameNotes.getNoteKey(new AccountGroup.NameKey(bogusName)).getName(),
+        config.toText());
+    assertError("entry missing as group ref");
+  }
+
+  @Test
+  public void nonexistentMember() throws Exception {
+    updateGroupFile(RefNames.refsGroups(new AccountGroup.UUID(g1.id)), "members", "314159265\n");
+    assertError("nonexistent member 314159265");
+  }
+
+  @Test
+  public void nonexistentSubgroup() throws Exception {
+    updateGroupFile(
+        RefNames.refsGroups(new AccountGroup.UUID(g1.id)), "subgroups", BOGUS_UUID + "\n");
+    assertError("has nonexistent subgroup");
+  }
+
+  @Test
+  public void cyclicSubgroup() throws Exception {
+    updateGroupFile(RefNames.refsGroups(new AccountGroup.UUID(g1.id)), "subgroups", g1.id + "\n");
+    assertWarning("cyclic");
+  }
+
+  private void assertError(String msg) throws Exception {
+    assertConsistency(msg, ConsistencyProblemInfo.Status.ERROR);
+  }
+
+  private void assertWarning(String msg) throws Exception {
+    assertConsistency(msg, ConsistencyProblemInfo.Status.WARNING);
+  }
+
+  private List<ConsistencyProblemInfo> check() throws Exception {
+    ConsistencyCheckInput in = new ConsistencyCheckInput();
+    in.checkGroups = new ConsistencyCheckInput.CheckGroupsInput();
+    ConsistencyCheckInfo info = gApi.config().server().checkConsistency(in);
+    return info.checkGroupsResult.problems;
+  }
+
+  private void assertConsistency(String msg, ConsistencyProblemInfo.Status want) throws Exception {
+    List<ConsistencyProblemInfo> problems = check();
+
+    for (ConsistencyProblemInfo i : problems) {
+      if (!i.status.equals(want)) {
+        continue;
+      }
+      if (i.message.contains(msg)) {
+        return;
+      }
+    }
+
+    fail(String.format("could not find %s substring '%s' in %s", want, msg, problems));
+  }
+
+  private void updateGroupFile(String refName, String fileName, String content) throws Exception {
+    GroupTestUtil.updateGroupFile(
+        repoManager, allUsers, serverIdent.get(), refName, fileName, content);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
new file mode 100644
index 0000000..863d7e0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -0,0 +1,1398 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.group;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.acceptance.api.group.GroupAssert.assertGroupInfo;
+import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfos;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.notedb.NoteDbTable.GROUPS;
+import static com.google.gerrit.server.notedb.NotesMigration.DISABLE_REVIEW_DB;
+import static com.google.gerrit.server.notedb.NotesMigration.READ;
+import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
+import static com.google.gerrit.server.notedb.NotesMigration.WRITE;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Throwables;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.ProjectResetter;
+import com.google.gerrit.acceptance.ProjectResetter.Builder;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.groups.GroupApi;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.api.groups.Groups.ListRequest;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.GroupAuditEventInfo;
+import com.google.gerrit.extensions.common.GroupAuditEventInfo.GroupMemberAuditEventInfo;
+import com.google.gerrit.extensions.common.GroupAuditEventInfo.Type;
+import com.google.gerrit.extensions.common.GroupAuditEventInfo.UserMemberAuditEventInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.common.GroupOptionsInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.GroupIncludeCache;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.group.db.GroupsConsistencyChecker;
+import com.google.gerrit.server.index.group.GroupIndexer;
+import com.google.gerrit.server.index.group.StalenessChecker;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class GroupsIT extends AbstractDaemonTest {
+  @ConfigSuite.Config
+  public static Config noteDbConfig() {
+    Config config = new Config();
+    config.setBoolean(SECTION_NOTE_DB, GROUPS.key(), WRITE, true);
+    config.setBoolean(SECTION_NOTE_DB, GROUPS.key(), READ, true);
+    return config;
+  }
+
+  @ConfigSuite.Config
+  public static Config disableReviewDb() {
+    Config config = noteDbConfig();
+    config.setBoolean(SECTION_NOTE_DB, GROUPS.key(), DISABLE_REVIEW_DB, true);
+    return config;
+  }
+
+  @Inject private Groups groups;
+  @Inject private GroupIncludeCache groupIncludeCache;
+  @Inject private StalenessChecker stalenessChecker;
+  @Inject private GroupIndexer groupIndexer;
+  @Inject private GroupsConsistencyChecker consistencyChecker;
+
+  @Inject
+  @Named("groups_byuuid")
+  private LoadingCache<String, Optional<InternalGroup>> groupsByUUIDCache;
+
+  @Before
+  public void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @After
+  public void consistencyCheck() throws Exception {
+    if (description.getAnnotation(IgnoreGroupInconsistencies.class) == null) {
+      assertThat(consistencyChecker.check()).isEmpty();
+    }
+  }
+
+  @Override
+  protected ProjectResetter resetProjects(Builder resetter) throws IOException {
+    // Don't reset All-Users since deleting users makes groups inconsistent (e.g. groups would
+    // contain members that no longer exist) and as result of this the group consistency checker
+    // that is executed after each test would fail.
+    return resetter.reset(allProjects, RefNames.REFS_CONFIG).build();
+  }
+
+  @Test
+  public void systemGroupCanBeRetrievedFromIndex() throws Exception {
+    List<GroupInfo> groupInfos = gApi.groups().query("name:Administrators").get();
+    assertThat(groupInfos).isNotEmpty();
+  }
+
+  @Test
+  public void addToNonExistingGroup_NotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.groups().id("non-existing").addMembers("admin");
+  }
+
+  @Test
+  public void removeFromNonExistingGroup_NotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.groups().id("non-existing").removeMembers("admin");
+  }
+
+  @Test
+  public void addRemoveMember() throws Exception {
+    String g = createGroup("users");
+    gApi.groups().id(g).addMembers("user");
+    assertMembers(g, user);
+
+    gApi.groups().id(g).removeMembers("user");
+    assertNoMembers(g);
+  }
+
+  @Test
+  public void cachedGroupsForMemberAreUpdatedOnMemberAdditionAndRemoval() throws Exception {
+    TestAccount account = createUniqueAccount("user", "User");
+
+    // Fill the cache for the observed account.
+    groupIncludeCache.getGroupsWithMember(account.getId());
+    String groupName = createGroup("users");
+    AccountGroup.UUID groupUuid = new AccountGroup.UUID(gApi.groups().id(groupName).get().id);
+
+    gApi.groups().id(groupName).addMembers(account.username);
+
+    Collection<AccountGroup.UUID> groupsWithMemberAfterAddition =
+        groupIncludeCache.getGroupsWithMember(account.getId());
+    assertThat(groupsWithMemberAfterAddition).contains(groupUuid);
+
+    gApi.groups().id(groupName).removeMembers(account.username);
+
+    Collection<AccountGroup.UUID> groupsWithMemberAfterRemoval =
+        groupIncludeCache.getGroupsWithMember(account.getId());
+    assertThat(groupsWithMemberAfterRemoval).doesNotContain(groupUuid);
+  }
+
+  @Test
+  public void addExistingMember_OK() throws Exception {
+    String g = "Administrators";
+    assertMembers(g, admin);
+    gApi.groups().id("Administrators").addMembers("admin");
+    assertMembers(g, admin);
+  }
+
+  @Test
+  public void addNonExistingMember_UnprocessableEntity() throws Exception {
+    exception.expect(UnprocessableEntityException.class);
+    gApi.groups().id("Administrators").addMembers("non-existing");
+  }
+
+  @Test
+  public void addMultipleMembers() throws Exception {
+    String g = createGroup("users");
+    TestAccount u1 = createUniqueAccount("u1", "Full Name 1");
+    TestAccount u2 = createUniqueAccount("u2", "Full Name 2");
+    gApi.groups().id(g).addMembers(u1.username, u2.username);
+    assertMembers(g, u1, u2);
+  }
+
+  @Test
+  public void addMembersWithAtSign() throws Exception {
+    String g = createGroup("users");
+    TestAccount u1 = createUniqueAccount("u1", "Full Name 1");
+    TestAccount u2_at = createUniqueAccount("u2@something", "Full Name 2 With At");
+    TestAccount u2 = createUniqueAccount("u2", "Full Name 2 Without At");
+    gApi.groups().id(g).addMembers(u1.username, u2_at.username, u2.username);
+    assertMembers(g, u1, u2_at, u2);
+  }
+
+  @Test
+  public void includeRemoveGroup() throws Exception {
+    String p = createGroup("parent");
+    String g = createGroup("newGroup");
+    gApi.groups().id(p).addGroups(g);
+    assertIncludes(p, g);
+
+    gApi.groups().id(p).removeGroups(g);
+    assertNoIncludes(p);
+  }
+
+  @Test
+  public void includeExternalGroup() throws Exception {
+    String g = createGroup("group");
+    String subgroupUuid = SystemGroupBackend.REGISTERED_USERS.get();
+    gApi.groups().id(g).addGroups(subgroupUuid);
+
+    List<GroupInfo> subgroups = gApi.groups().id(g).includedGroups();
+    assertThat(subgroups).hasSize(1);
+    assertThat(subgroups.get(0).id).isEqualTo(subgroupUuid.replace(":", "%3A"));
+    assertThat(subgroups.get(0).name).isEqualTo("Registered Users");
+    assertThat(subgroups.get(0).groupId).isNull();
+
+    List<? extends GroupAuditEventInfo> auditEvents = gApi.groups().id(g).auditLog();
+    assertThat(auditEvents).hasSize(1);
+    assertAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id, "Registered Users");
+  }
+
+  @Test
+  public void includeExistingGroup_OK() throws Exception {
+    String p = createGroup("parent");
+    String g = createGroup("newGroup");
+    gApi.groups().id(p).addGroups(g);
+    assertIncludes(p, g);
+    gApi.groups().id(p).addGroups(g);
+    assertIncludes(p, g);
+  }
+
+  @Test
+  public void addMultipleIncludes() throws Exception {
+    String p = createGroup("parent");
+    String g1 = createGroup("newGroup1");
+    String g2 = createGroup("newGroup2");
+    List<String> groups = new ArrayList<>();
+    groups.add(g1);
+    groups.add(g2);
+    gApi.groups().id(p).addGroups(g1, g2);
+    assertIncludes(p, g1, g2);
+  }
+
+  @Test
+  public void createGroup() throws Exception {
+    String newGroupName = name("newGroup");
+    GroupInfo g = gApi.groups().create(newGroupName).get();
+    assertGroupInfo(group(newGroupName), g);
+  }
+
+  @Test
+  public void createDuplicateInternalGroupCaseSensitiveName_Conflict() throws Exception {
+    String dupGroupName = name("dupGroup");
+    gApi.groups().create(dupGroupName);
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("group '" + dupGroupName + "' already exists");
+    gApi.groups().create(dupGroupName);
+  }
+
+  @Test
+  public void createDuplicateInternalGroupCaseInsensitiveName() throws Exception {
+    String dupGroupName = name("dupGroupA");
+    String dupGroupNameLowerCase = name("dupGroupA").toLowerCase();
+    gApi.groups().create(dupGroupName);
+    gApi.groups().create(dupGroupNameLowerCase);
+    assertThat(gApi.groups().list().getAsMap().keySet()).contains(dupGroupName);
+    assertThat(gApi.groups().list().getAsMap().keySet()).contains(dupGroupNameLowerCase);
+  }
+
+  @Test
+  public void createDuplicateSystemGroupCaseSensitiveName_Conflict() throws Exception {
+    String newGroupName = "Registered Users";
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("group 'Registered Users' already exists");
+    gApi.groups().create(newGroupName);
+  }
+
+  @Test
+  public void createDuplicateSystemGroupCaseInsensitiveName_Conflict() throws Exception {
+    String newGroupName = "registered users";
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("group 'Registered Users' already exists");
+    gApi.groups().create(newGroupName);
+  }
+
+  @Test
+  @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
+  public void createGroupWithConfiguredNameOfSystemGroup_Conflict() throws Exception {
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("group 'All Users' already exists");
+    gApi.groups().create("all users");
+  }
+
+  @Test
+  @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
+  public void createGroupWithDefaultNameOfSystemGroup_Conflict() throws Exception {
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("group name 'Anonymous Users' is reserved");
+    gApi.groups().create("anonymous users");
+  }
+
+  @Test
+  public void createGroupWithProperties() throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = name("newGroup");
+    in.description = "Test description";
+    in.visibleToAll = true;
+    in.ownerId = adminGroupUuid().get();
+    GroupInfo g = gApi.groups().create(in).detail();
+    assertThat(g.description).isEqualTo(in.description);
+    assertThat(g.options.visibleToAll).isEqualTo(in.visibleToAll);
+    assertThat(g.ownerId).isEqualTo(in.ownerId);
+  }
+
+  @Test
+  public void createGroupWithoutCapability_Forbidden() throws Exception {
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.groups().create(name("newGroup"));
+  }
+
+  @Test
+  public void createdOnFieldIsPopulatedForNewGroup() throws Exception {
+    // NoteDb allows only second precision.
+    Timestamp testStartTime = TimeUtil.truncateToSecond(TimeUtil.nowTs());
+    String newGroupName = name("newGroup");
+    GroupInfo group = gApi.groups().create(newGroupName).get();
+
+    assertThat(group.createdOn).isAtLeast(testStartTime);
+  }
+
+  @Test
+  public void cachedGroupsForMemberAreUpdatedOnGroupCreation() throws Exception {
+    TestAccount account = createUniqueAccount("user", "User");
+
+    // Fill the cache for the observed account.
+    groupIncludeCache.getGroupsWithMember(account.id);
+
+    GroupInput groupInput = new GroupInput();
+    groupInput.name = name("Users");
+    groupInput.members = ImmutableList.of(account.username);
+    GroupInfo group = gApi.groups().create(groupInput).get();
+
+    Collection<AccountGroup.UUID> groups = groupIncludeCache.getGroupsWithMember(account.id);
+    assertThat(groups).containsExactly(new AccountGroup.UUID(group.id));
+  }
+
+  @Test
+  public void getGroup() throws Exception {
+    InternalGroup adminGroup = adminGroup();
+    testGetGroup(adminGroup.getGroupUUID().get(), adminGroup);
+    testGetGroup(adminGroup.getName(), adminGroup);
+    testGetGroup(adminGroup.getId().get(), adminGroup);
+  }
+
+  private void testGetGroup(Object id, InternalGroup expectedGroup) throws Exception {
+    GroupInfo group = gApi.groups().id(id.toString()).get();
+    assertGroupInfo(expectedGroup, group);
+  }
+
+  @Test
+  @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
+  public void getSystemGroupByConfiguredName() throws Exception {
+    GroupReference anonymousUsersGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
+    assertThat(anonymousUsersGroup.getName()).isEqualTo("All Users");
+
+    GroupInfo group = gApi.groups().id(anonymousUsersGroup.getUUID().get()).get();
+    assertThat(group.name).isEqualTo(anonymousUsersGroup.getName());
+
+    group = gApi.groups().id(anonymousUsersGroup.getName()).get();
+    assertThat(group.id).isEqualTo(Url.encode((anonymousUsersGroup.getUUID().get())));
+  }
+
+  @Test
+  public void getSystemGroupByDefaultName() throws Exception {
+    GroupReference anonymousUsersGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
+    GroupInfo group = gApi.groups().id("Anonymous Users").get();
+    assertThat(group.name).isEqualTo(anonymousUsersGroup.getName());
+    assertThat(group.id).isEqualTo(Url.encode((anonymousUsersGroup.getUUID().get())));
+  }
+
+  @Test
+  @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
+  public void getSystemGroupByDefaultName_NotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.groups().id("Anonymous-Users").get();
+  }
+
+  @Test
+  public void groupIsCreatedForSpecifiedName() throws Exception {
+    String name = name("Users");
+    gApi.groups().create(name);
+
+    assertThat(gApi.groups().id(name).name()).isEqualTo(name);
+  }
+
+  @Test
+  public void groupCannotBeCreatedWithNameOfAnotherGroup() throws Exception {
+    String name = name("Users");
+    gApi.groups().create(name).get();
+
+    exception.expect(ResourceConflictException.class);
+    gApi.groups().create(name);
+  }
+
+  @Test
+  public void groupCanBeRenamed() throws Exception {
+    String name = name("Name1");
+    GroupInfo group = gApi.groups().create(name).get();
+
+    String newName = name("Name2");
+    gApi.groups().id(name).name(newName);
+    assertThat(gApi.groups().id(group.id).name()).isEqualTo(newName);
+  }
+
+  @Test
+  public void groupCanBeRenamedToItsCurrentName() throws Exception {
+    String name = name("Users");
+    GroupInfo group = gApi.groups().create(name).get();
+
+    gApi.groups().id(group.id).name(name);
+    assertThat(gApi.groups().id(group.id).name()).isEqualTo(name);
+  }
+
+  @Test
+  public void groupCannotBeRenamedToNameOfAnotherGroup() throws Exception {
+    String name1 = name("Name1");
+    GroupInfo group1 = gApi.groups().create(name1).get();
+
+    String name2 = name("Name2");
+    gApi.groups().create(name2);
+
+    exception.expect(ResourceConflictException.class);
+    gApi.groups().id(group1.id).name(name2);
+  }
+
+  @Test
+  public void renamedGroupCanBeLookedUpByNewName() throws Exception {
+    String name = name("Name1");
+    GroupInfo group = gApi.groups().create(name).get();
+
+    String newName = name("Name2");
+    gApi.groups().id(group.id).name(newName);
+
+    GroupInfo foundGroup = gApi.groups().id(newName).get();
+    assertThat(foundGroup.id).isEqualTo(group.id);
+  }
+
+  @Test
+  public void oldNameOfRenamedGroupIsNotAccessibleAnymore() throws Exception {
+    String name = name("Name1");
+    GroupInfo group = gApi.groups().create(name).get();
+
+    String newName = name("Name2");
+    gApi.groups().id(group.id).name(newName);
+
+    assertGroupDoesNotExist(name);
+    exception.expect(ResourceNotFoundException.class);
+    gApi.groups().id(name).get();
+  }
+
+  @Test
+  public void oldNameOfRenamedGroupIsFreeForUseAgain() throws Exception {
+    String name = name("Name1");
+    GroupInfo group1 = gApi.groups().create(name).get();
+
+    String newName = name("Name2");
+    gApi.groups().id(group1.id).name(newName);
+
+    GroupInfo group2 = gApi.groups().create(name).get();
+    assertThat(group2.id).isNotEqualTo(group1.id);
+  }
+
+  @Test
+  public void groupDescription() throws Exception {
+    String name = name("group");
+    gApi.groups().create(name);
+
+    // get description
+    assertThat(gApi.groups().id(name).description()).isEmpty();
+
+    // set description
+    String desc = "New description for the group.";
+    gApi.groups().id(name).description(desc);
+    assertThat(gApi.groups().id(name).description()).isEqualTo(desc);
+
+    // set description to null
+    gApi.groups().id(name).description(null);
+    assertThat(gApi.groups().id(name).description()).isEmpty();
+
+    // set description to empty string
+    gApi.groups().id(name).description("");
+    assertThat(gApi.groups().id(name).description()).isEmpty();
+  }
+
+  @Test
+  public void groupOptions() throws Exception {
+    String name = name("group");
+    gApi.groups().create(name);
+
+    // get options
+    assertThat(gApi.groups().id(name).options().visibleToAll).isNull();
+
+    // set options
+    GroupOptionsInfo options = new GroupOptionsInfo();
+    options.visibleToAll = true;
+    gApi.groups().id(name).options(options);
+    assertThat(gApi.groups().id(name).options().visibleToAll).isTrue();
+  }
+
+  @Test
+  public void groupOwner() throws Exception {
+    String name = name("group");
+    GroupInfo info = gApi.groups().create(name).get();
+    String adminUUID = adminGroupUuid().get();
+    String registeredUUID = SystemGroupBackend.REGISTERED_USERS.get();
+
+    // get owner
+    assertThat(Url.decode(gApi.groups().id(name).owner().id)).isEqualTo(info.id);
+
+    // set owner by name
+    gApi.groups().id(name).owner("Registered Users");
+    assertThat(Url.decode(gApi.groups().id(name).owner().id)).isEqualTo(registeredUUID);
+
+    // set owner by UUID
+    gApi.groups().id(name).owner(adminUUID);
+    assertThat(Url.decode(gApi.groups().id(name).owner().id)).isEqualTo(adminUUID);
+
+    // set non existing owner
+    exception.expect(UnprocessableEntityException.class);
+    gApi.groups().id(name).owner("Non-Existing Group");
+  }
+
+  @Test
+  public void listNonExistingGroupIncludes_NotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.groups().id("non-existing").includedGroups();
+  }
+
+  @Test
+  public void listEmptyGroupIncludes() throws Exception {
+    String gx = createGroup("gx");
+    assertThat(gApi.groups().id(gx).includedGroups()).isEmpty();
+  }
+
+  @Test
+  public void includeNonExistingGroup() throws Exception {
+    String gx = createGroup("gx");
+    exception.expect(UnprocessableEntityException.class);
+    gApi.groups().id(gx).addGroups("non-existing");
+  }
+
+  @Test
+  public void listNonEmptyGroupIncludes() throws Exception {
+    String gx = createGroup("gx");
+    String gy = createGroup("gy");
+    String gz = createGroup("gz");
+    gApi.groups().id(gx).addGroups(gy);
+    gApi.groups().id(gx).addGroups(gz);
+    assertIncludes(gApi.groups().id(gx).includedGroups(), gy, gz);
+  }
+
+  @Test
+  public void listOneIncludeMember() throws Exception {
+    String gx = createGroup("gx");
+    String gy = createGroup("gy");
+    gApi.groups().id(gx).addGroups(gy);
+    assertIncludes(gApi.groups().id(gx).includedGroups(), gy);
+  }
+
+  @Test
+  public void listNonExistingGroupMembers_NotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.groups().id("non-existing").members();
+  }
+
+  @Test
+  public void listEmptyGroupMembers() throws Exception {
+    String group = createGroup("empty");
+    assertThat(gApi.groups().id(group).members()).isEmpty();
+  }
+
+  @Test
+  public void listNonEmptyGroupMembers() throws Exception {
+    String group = createGroup("group");
+    String user1 = createAccount("user1", group);
+    String user2 = createAccount("user2", group);
+    assertMembers(gApi.groups().id(group).members(), user1, user2);
+  }
+
+  @Test
+  public void listOneGroupMember() throws Exception {
+    String group = createGroup("group");
+    String user = createAccount("user1", group);
+    assertMembers(gApi.groups().id(group).members(), user);
+  }
+
+  @Test
+  public void listGroupMembersRecursively() throws Exception {
+    String gx = createGroup("gx");
+    String ux = createAccount("ux", gx);
+
+    String gy = createGroup("gy");
+    String uy = createAccount("uy", gy);
+
+    String gz = createGroup("gz");
+    String uz = createAccount("uz", gz);
+
+    gApi.groups().id(gx).addGroups(gy);
+    gApi.groups().id(gy).addGroups(gz);
+    assertMembers(gApi.groups().id(gx).members(), ux);
+    assertMembers(gApi.groups().id(gx).members(true), ux, uy, uz);
+  }
+
+  @Test
+  public void usersSeeTheirDirectMembershipWhenListingMembersRecursively() throws Exception {
+    String group = createGroup("group");
+    gApi.groups().id(group).addMembers(user.username);
+
+    setApiUser(user);
+    assertMembers(gApi.groups().id(group).members(true), user.fullName);
+  }
+
+  @Test
+  public void usersDoNotSeeTheirIndirectMembershipWhenListingMembersRecursively() throws Exception {
+    String group1 = createGroup("group1");
+    String group2 = createGroup("group2");
+    gApi.groups().id(group1).addGroups(group2);
+    gApi.groups().id(group2).addMembers(user.username);
+
+    setApiUser(user);
+    List<AccountInfo> listedMembers = gApi.groups().id(group1).members(true);
+
+    assertMembers(listedMembers);
+  }
+
+  @Test
+  public void adminsSeeTheirIndirectMembershipWhenListingMembersRecursively() throws Exception {
+    String ownerGroup = createGroup("ownerGroup", null);
+    String group1 = createGroup("group1", ownerGroup);
+    String group2 = createGroup("group2", ownerGroup);
+    gApi.groups().id(group1).addGroups(group2);
+    gApi.groups().id(group2).addMembers(admin.username);
+
+    List<AccountInfo> listedMembers = gApi.groups().id(group1).members(true);
+
+    assertMembers(listedMembers, admin.fullName);
+  }
+
+  @Test
+  public void ownersSeeTheirIndirectMembershipWhenListingMembersRecursively() throws Exception {
+    String ownerGroup = createGroup("ownerGroup", null);
+    String group1 = createGroup("group1", ownerGroup);
+    String group2 = createGroup("group2", ownerGroup);
+    gApi.groups().id(group1).addGroups(group2);
+    gApi.groups().id(ownerGroup).addMembers(user.username);
+    gApi.groups().id(group2).addMembers(user.username);
+
+    setApiUser(user);
+    List<AccountInfo> listedMembers = gApi.groups().id(group1).members(true);
+
+    assertMembers(listedMembers, user.fullName);
+  }
+
+  @Test
+  public void defaultGroupsCreated() throws Exception {
+    Iterable<String> names = gApi.groups().list().getAsMap().keySet();
+    assertThat(names).containsAllOf("Administrators", "Non-Interactive Users").inOrder();
+  }
+
+  @Test
+  public void listAllGroups() throws Exception {
+    List<String> expectedGroups =
+        groups.getAllGroupReferences(db).map(GroupReference::getName).sorted().collect(toList());
+    assertThat(expectedGroups.size()).isAtLeast(2);
+    assertThat(gApi.groups().list().getAsMap().keySet())
+        .containsExactlyElementsIn(expectedGroups)
+        .inOrder();
+  }
+
+  @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(groupUuid(parent).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();
+    in.name = newGroupName;
+    in.description = "a hidden group";
+    in.visibleToAll = false;
+    in.ownerId = adminGroupUuid().get();
+    gApi.groups().create(in);
+
+    setApiUser(user);
+    assertThat(gApi.groups().list().getAsMap()).doesNotContainKey(newGroupName);
+
+    setApiUser(admin);
+    gApi.groups().id(newGroupName).addMembers(user.username);
+
+    setApiUser(user);
+    assertThat(gApi.groups().list().getAsMap()).containsKey(newGroupName);
+  }
+
+  @Test
+  public void suggestGroup() throws Exception {
+    Map<String, GroupInfo> groups = gApi.groups().list().withSuggest("adm").getAsMap();
+    assertThat(groups).containsKey("Administrators");
+    assertThat(groups).hasSize(1);
+    assertBadRequest(gApi.groups().list().withSuggest("adm").withSubstring("foo"));
+    assertBadRequest(gApi.groups().list().withSuggest("adm").withRegex("foo.*"));
+    assertBadRequest(gApi.groups().list().withSuggest("adm").withUser("user"));
+    assertBadRequest(gApi.groups().list().withSuggest("adm").withOwned(true));
+    assertBadRequest(gApi.groups().list().withSuggest("adm").withVisibleToAll(true));
+    assertBadRequest(gApi.groups().list().withSuggest("adm").withStart(1));
+  }
+
+  @Test
+  public void withSubstring() throws Exception {
+    String group = name("Abcdefghijklmnop");
+    gApi.groups().create(group);
+
+    // Choose a substring which isn't part of any group or test method within this class.
+    String substring = "efghijk";
+    Map<String, GroupInfo> groups = gApi.groups().list().withSubstring(substring).getAsMap();
+    assertThat(groups).containsKey(group);
+    assertThat(groups).hasSize(1);
+
+    groups = gApi.groups().list().withSubstring("abcdefghi").getAsMap();
+    assertThat(groups).containsKey(group);
+    assertThat(groups).hasSize(1);
+
+    String otherGroup = name("Abcdefghijklmnop2");
+    gApi.groups().create(otherGroup);
+    groups = gApi.groups().list().withSubstring(substring).getAsMap();
+    assertThat(groups).hasSize(2);
+    assertThat(groups).containsKey(group);
+    assertThat(groups).containsKey(otherGroup);
+
+    groups = gApi.groups().list().withSubstring("non-existing-substring").getAsMap();
+    assertThat(groups).isEmpty();
+  }
+
+  @Test
+  public void withRegex() throws Exception {
+    Map<String, GroupInfo> groups = gApi.groups().list().withRegex("Admin.*").getAsMap();
+    assertThat(groups).containsKey("Administrators");
+    assertThat(groups).hasSize(1);
+
+    groups = gApi.groups().list().withRegex("admin.*").getAsMap();
+    assertThat(groups).isEmpty();
+
+    groups = gApi.groups().list().withRegex(".*istrators").getAsMap();
+    assertThat(groups).containsKey("Administrators");
+    assertThat(groups).hasSize(1);
+
+    assertBadRequest(gApi.groups().list().withRegex(".*istrators").withSubstring("s"));
+  }
+
+  @Test
+  public void allGroupInfoFieldsSetCorrectly() throws Exception {
+    InternalGroup adminGroup = adminGroup();
+    Map<String, GroupInfo> groups = gApi.groups().list().addGroup(adminGroup.getName()).getAsMap();
+    assertThat(groups).hasSize(1);
+    assertThat(groups).containsKey("Administrators");
+    assertGroupInfo(adminGroup, Iterables.getOnlyElement(groups.values()));
+  }
+
+  @Test
+  public void getAuditLog() throws Exception {
+    GroupApi g = gApi.groups().create(name("group"));
+    List<? extends GroupAuditEventInfo> auditEvents = g.auditLog();
+    assertThat(auditEvents).hasSize(1);
+    assertAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id, admin.id);
+
+    g.addMembers(user.username);
+    auditEvents = g.auditLog();
+    assertThat(auditEvents).hasSize(2);
+    assertAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id, user.id);
+
+    g.removeMembers(user.username);
+    auditEvents = g.auditLog();
+    assertThat(auditEvents).hasSize(3);
+    assertAuditEvent(auditEvents.get(0), Type.REMOVE_USER, admin.id, user.id);
+
+    String otherGroup = name("otherGroup");
+    gApi.groups().create(otherGroup);
+    g.addGroups(otherGroup);
+    auditEvents = g.auditLog();
+    assertThat(auditEvents).hasSize(4);
+    assertAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id, otherGroup);
+
+    g.removeGroups(otherGroup);
+    auditEvents = g.auditLog();
+    assertThat(auditEvents).hasSize(5);
+    assertAuditEvent(auditEvents.get(0), Type.REMOVE_GROUP, admin.id, otherGroup);
+
+    // Add a removed member back again.
+    g.addMembers(user.username);
+    auditEvents = g.auditLog();
+    assertThat(auditEvents).hasSize(6);
+    assertAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id, user.id);
+
+    // Add a removed group back again.
+    g.addGroups(otherGroup);
+    auditEvents = g.auditLog();
+    assertThat(auditEvents).hasSize(7);
+    assertAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id, otherGroup);
+
+    Timestamp lastDate = null;
+    for (GroupAuditEventInfo auditEvent : auditEvents) {
+      if (lastDate != null) {
+        assertThat(lastDate).isAtLeast(auditEvent.date);
+      }
+      lastDate = auditEvent.date;
+    }
+  }
+
+  // reindex is tested by {@link AbstractQueryGroupsTest#reindex}
+  @Test
+  public void reindexPermissions() throws Exception {
+    TestAccount groupOwner = accountCreator.user2();
+    GroupInput in = new GroupInput();
+    in.name = name("group");
+    in.members =
+        Collections.singleton(groupOwner).stream().map(u -> u.id.toString()).collect(toList());
+    in.visibleToAll = true;
+    GroupInfo group = gApi.groups().create(in).get();
+
+    // admin can reindex any group
+    setApiUser(admin);
+    gApi.groups().id(group.id).index();
+
+    // group owner can reindex own group (group is owned by itself)
+    setApiUser(groupOwner);
+    gApi.groups().id(group.id).index();
+
+    // user cannot reindex any group
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to index group");
+    gApi.groups().id(group.id).index();
+  }
+
+  @Test
+  public void pushToGroupBranchIsRejectedForAllUsersRepo() throws Exception {
+    assume().that(groupsInNoteDb()).isTrue(); // branch only exists when groups are in NoteDb
+    assertPushToGroupBranch(
+        allUsers, RefNames.refsGroups(adminGroupUuid()), "group update not allowed");
+  }
+
+  @Test
+  public void pushToDeletedGroupBranchIsRejectedForAllUsersRepo() throws Exception {
+    String groupRef =
+        RefNames.refsDeletedGroups(
+            new AccountGroup.UUID(gApi.groups().create(name("foo")).get().id));
+    createBranch(allUsers, groupRef);
+    assertPushToGroupBranch(allUsers, groupRef, "group update not allowed");
+  }
+
+  @Test
+  public void pushToGroupNamesBranchIsRejectedForAllUsersRepo() throws Exception {
+    assume().that(groupsInNoteDb()).isTrue(); // branch only exists when groups are in NoteDb
+    // refs/meta/group-names isn't usually available for fetch, so grant ACCESS_DATABASE
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    assertPushToGroupBranch(allUsers, RefNames.REFS_GROUPNAMES, "group update not allowed");
+  }
+
+  @Test
+  public void pushToGroupsBranchForNonAllUsersRepo() throws Exception {
+    assertCreateGroupBranch(project, null);
+    String groupRef =
+        RefNames.refsGroups(new AccountGroup.UUID(gApi.groups().create(name("foo")).get().id));
+    createBranch(project, groupRef);
+    assertPushToGroupBranch(project, groupRef, null);
+  }
+
+  @Test
+  public void pushToDeletedGroupsBranchForNonAllUsersRepo() throws Exception {
+    assertCreateGroupBranch(project, null);
+    String groupRef =
+        RefNames.refsDeletedGroups(
+            new AccountGroup.UUID(gApi.groups().create(name("foo")).get().id));
+    createBranch(project, groupRef);
+    assertPushToGroupBranch(project, groupRef, null);
+  }
+
+  @Test
+  public void pushToGroupNamesBranchForNonAllUsersRepo() throws Exception {
+    createBranch(project, RefNames.REFS_GROUPNAMES);
+    assertPushToGroupBranch(project, RefNames.REFS_GROUPNAMES, null);
+  }
+
+  private void assertPushToGroupBranch(
+      Project.NameKey project, String groupRefName, String expectedErrorOnUpdate) throws Exception {
+    grant(project, RefNames.REFS_GROUPS + "*", Permission.CREATE, false, REGISTERED_USERS);
+    grant(project, RefNames.REFS_GROUPS + "*", Permission.PUSH, false, REGISTERED_USERS);
+    grant(project, RefNames.REFS_DELETED_GROUPS + "*", Permission.CREATE, false, REGISTERED_USERS);
+    grant(project, RefNames.REFS_DELETED_GROUPS + "*", Permission.PUSH, false, REGISTERED_USERS);
+    grant(project, RefNames.REFS_GROUPNAMES, Permission.PUSH, false, REGISTERED_USERS);
+
+    TestRepository<InMemoryRepository> repo = cloneProject(project);
+
+    // update existing branch
+    fetch(repo, groupRefName + ":groupRef");
+    repo.reset("groupRef");
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                repo,
+                "Update group config",
+                GroupConfig.GROUP_CONFIG_FILE,
+                "some content")
+            .to(groupRefName);
+    if (expectedErrorOnUpdate != null) {
+      r.assertErrorStatus(expectedErrorOnUpdate);
+    } else {
+      r.assertOkStatus();
+    }
+  }
+
+  private void assertCreateGroupBranch(Project.NameKey project, String expectedErrorOnCreate)
+      throws Exception {
+    grant(project, RefNames.REFS_GROUPS + "*", Permission.CREATE, false, REGISTERED_USERS);
+    grant(project, RefNames.REFS_GROUPS + "*", Permission.PUSH, false, REGISTERED_USERS);
+    TestRepository<InMemoryRepository> repo = cloneProject(project);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                repo,
+                "Update group config",
+                GroupConfig.GROUP_CONFIG_FILE,
+                "some content")
+            .setParents(ImmutableList.of())
+            .to(RefNames.REFS_GROUPS + name("bar"));
+    if (expectedErrorOnCreate != null) {
+      r.assertErrorStatus(expectedErrorOnCreate);
+    } else {
+      r.assertOkStatus();
+    }
+  }
+
+  @Test
+  public void pushToGroupBranchForReviewForAllUsersRepoIsRejectedOnSubmit() throws Exception {
+    pushToGroupBranchForReviewAndSubmit(
+        allUsers, RefNames.refsGroups(adminGroupUuid()), "group update not allowed");
+  }
+
+  @Test
+  public void pushToGroupBranchForReviewForNonAllUsersRepoAndSubmit() throws Exception {
+    String groupRef = RefNames.refsGroups(adminGroupUuid());
+    createBranch(project, groupRef);
+    pushToGroupBranchForReviewAndSubmit(project, groupRef, null);
+  }
+
+  @Test
+  public void pushCustomInheritanceForAllUsersFails() throws Exception {
+    TestRepository<InMemoryRepository> repo = cloneProject(allUsers, RefNames.REFS_CONFIG);
+
+    String config =
+        gApi.projects()
+            .name(allUsers.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file("project.config")
+            .asString();
+
+    Config cfg = new Config();
+    cfg.fromText(config);
+    cfg.setString("access", null, "inheritFrom", project.get());
+    config = cfg.toText();
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), repo, "Subject", "project.config", config)
+            .to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus("invalid project configuration");
+    r.assertMessage("All-Users must inherit from All-Projects");
+  }
+
+  @Test
+  public void cannotCreateGroupBranch() throws Exception {
+    testCannotCreateGroupBranch(
+        RefNames.REFS_GROUPS + "*", RefNames.refsGroups(new AccountGroup.UUID(name("foo"))));
+  }
+
+  @Test
+  public void cannotCreateDeletedGroupBranch() throws Exception {
+    testCannotCreateGroupBranch(
+        RefNames.REFS_DELETED_GROUPS + "*",
+        RefNames.refsDeletedGroups(new AccountGroup.UUID(name("foo"))));
+  }
+
+  @Test
+  @IgnoreGroupInconsistencies
+  public void cannotCreateGroupNamesBranch() throws Exception {
+    assume().that(groupsInNoteDb()).isTrue();
+
+    // Use ProjectResetter to restore the group names ref
+    try (ProjectResetter resetter =
+        projectResetter.builder().reset(allUsers, RefNames.REFS_GROUPNAMES).build()) {
+      // Manually delete group names ref
+      try (Repository repo = repoManager.openRepository(allUsers);
+          RevWalk rw = new RevWalk(repo)) {
+        RevCommit commit = rw.parseCommit(repo.exactRef(RefNames.REFS_GROUPNAMES).getObjectId());
+        RefUpdate updateRef = repo.updateRef(RefNames.REFS_GROUPNAMES);
+        updateRef.setExpectedOldObjectId(commit.toObjectId());
+        updateRef.setNewObjectId(ObjectId.zeroId());
+        updateRef.setForceUpdate(true);
+        assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
+      }
+
+      // refs/meta/group-names is only visible with ACCESS_DATABASE
+      allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+      testCannotCreateGroupBranch(RefNames.REFS_GROUPNAMES, RefNames.REFS_GROUPNAMES);
+    }
+  }
+
+  private void testCannotCreateGroupBranch(String refPattern, String groupRef) throws Exception {
+    grant(allUsers, refPattern, Permission.CREATE);
+    grant(allUsers, refPattern, Permission.PUSH);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    PushOneCommit.Result r = pushFactory.create(db, admin.getIdent(), allUsersRepo).to(groupRef);
+    r.assertErrorStatus();
+    assertThat(r.getMessage()).contains("Not allowed to create group branch.");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(groupRef)).isNull();
+    }
+  }
+
+  @Test
+  public void cannotDeleteGroupBranch() throws Exception {
+    assume().that(groupsInNoteDb()).isTrue();
+    testCannotDeleteGroupBranch(RefNames.REFS_GROUPS + "*", RefNames.refsGroups(adminGroupUuid()));
+  }
+
+  @Test
+  public void cannotDeleteDeletedGroupBranch() throws Exception {
+    String groupRef = RefNames.refsDeletedGroups(new AccountGroup.UUID(name("foo")));
+    createBranch(allUsers, groupRef);
+    testCannotDeleteGroupBranch(RefNames.REFS_DELETED_GROUPS + "*", groupRef);
+  }
+
+  @Test
+  public void cannotDeleteGroupNamesBranch() throws Exception {
+    assume().that(groupsInNoteDb()).isTrue();
+
+    // refs/meta/group-names is only visible with ACCESS_DATABASE
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    testCannotDeleteGroupBranch(RefNames.REFS_GROUPNAMES, RefNames.REFS_GROUPNAMES);
+  }
+
+  private void testCannotDeleteGroupBranch(String refPattern, String groupRef) throws Exception {
+    grant(allUsers, refPattern, Permission.DELETE, true, REGISTERED_USERS);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    PushResult r = deleteRef(allUsersRepo, groupRef);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(groupRef);
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(refUpdate.getMessage()).contains("Not allowed to delete group branch.");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(groupRef)).isNotNull();
+    }
+  }
+
+  @Test
+  public void defaultPermissionsOnGroupBranches() throws Exception {
+    assertPermissions(
+        allUsers, groupRef(REGISTERED_USERS), RefNames.REFS_GROUPS + "*", true, Permission.READ);
+  }
+
+  @Test
+  @Sandboxed
+  public void blockReviewDbUpdatesOnGroupCreation() throws Exception {
+    assume().that(groupsInNoteDb()).isFalse();
+    try (AutoCloseable ctx = createBlockReviewDbGroupUpdatesContext()) {
+      gApi.groups().create(name("foo"));
+      fail("Expected RestApiException: Updates to groups in ReviewDb are blocked");
+    } catch (RestApiException e) {
+      assertWriteGroupToReviewDbBlockedException(e);
+    }
+  }
+
+  @Test
+  @Sandboxed
+  public void blockReviewDbUpdatesOnGroupUpdate() throws Exception {
+    assume().that(groupsInNoteDb()).isFalse();
+    String group1 = gApi.groups().create(name("foo")).get().id;
+    String group2 = gApi.groups().create(name("bar")).get().id;
+    try (AutoCloseable ctx = createBlockReviewDbGroupUpdatesContext()) {
+      gApi.groups().id(group1).addGroups(group2);
+      fail("Expected RestApiException: Updates to groups in ReviewDb are blocked");
+    } catch (RestApiException e) {
+      assertWriteGroupToReviewDbBlockedException(e);
+    }
+  }
+
+  private void assertWriteGroupToReviewDbBlockedException(Exception e) throws Exception {
+    Throwable t = Throwables.getRootCause(e);
+    assertThat(t).isInstanceOf(OrmException.class);
+    assertThat(t.getMessage()).isEqualTo("Updates to groups in ReviewDb are blocked");
+  }
+
+  @Test
+  @IgnoreGroupInconsistencies
+  public void stalenessChecker() throws Exception {
+    assume().that(readGroupsFromNoteDb()).isTrue();
+
+    // Newly created group is not stale
+    GroupInfo groupInfo = gApi.groups().create(name("foo")).get();
+    AccountGroup.UUID groupUuid = new AccountGroup.UUID(groupInfo.id);
+    assertThat(stalenessChecker.isStale(groupUuid)).isFalse();
+
+    // Manual update makes index document stale
+    String groupRef = RefNames.refsGroups(groupUuid);
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(repo.exactRef(groupRef).getObjectId());
+      ObjectId emptyCommit = createCommit(repo, commit.getFullMessage(), commit.getTree());
+      RefUpdate updateRef = repo.updateRef(groupRef);
+      updateRef.setExpectedOldObjectId(commit.toObjectId());
+      updateRef.setNewObjectId(emptyCommit);
+      assertThat(updateRef.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+    assertStaleGroupAndReindex(groupUuid);
+
+    // Manually delete group
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(repo.exactRef(groupRef).getObjectId());
+      RefUpdate updateRef = repo.updateRef(groupRef);
+      updateRef.setExpectedOldObjectId(commit.toObjectId());
+      updateRef.setNewObjectId(ObjectId.zeroId());
+      updateRef.setForceUpdate(true);
+      assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+    assertStaleGroupAndReindex(groupUuid);
+  }
+
+  @Test
+  public void groupNamesWithLeadingAndTrailingWhitespace() throws Exception {
+    for (String leading : ImmutableList.of("", " ", "  ")) {
+      for (String trailing : ImmutableList.of("", " ", "  ")) {
+        String name = leading + name("group") + trailing;
+        GroupInfo g = gApi.groups().create(name).get();
+        assertThat(g.name).isEqualTo(name);
+      }
+    }
+  }
+
+  private void assertStaleGroupAndReindex(AccountGroup.UUID groupUuid) throws IOException {
+    // Evict group from cache to be sure that we use the index state for staleness checks. This has
+    // to happen directly on the groupsByUUID cache because GroupsCacheImpl triggers a reindex for
+    // the group.
+    groupsByUUIDCache.invalidate(groupUuid.get());
+    assertThat(stalenessChecker.isStale(groupUuid)).isTrue();
+
+    // Reindex fixes staleness
+    groupIndexer.index(groupUuid);
+    assertThat(stalenessChecker.isStale(groupUuid)).isFalse();
+  }
+
+  private void pushToGroupBranchForReviewAndSubmit(
+      Project.NameKey project, String groupRef, String expectedError) throws Exception {
+    assume().that(groupsInNoteDb()).isTrue(); // branch only exists when groups are in NoteDb
+
+    grantLabel(
+        "Code-Review", -2, 2, project, RefNames.REFS_GROUPS + "*", false, REGISTERED_USERS, false);
+    grant(project, RefNames.REFS_GROUPS + "*", Permission.SUBMIT, false, REGISTERED_USERS);
+
+    TestRepository<InMemoryRepository> repo = cloneProject(project);
+    fetch(repo, groupRef + ":groupRef");
+    repo.reset("groupRef");
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db, admin.getIdent(), repo, "Update group config", "group.config", "some content")
+            .to(MagicBranch.NEW_CHANGE + groupRef);
+    r.assertOkStatus();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(groupRef);
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+
+    if (expectedError != null) {
+      exception.expect(ResourceConflictException.class);
+      exception.expectMessage("group update not allowed");
+    }
+    gApi.changes().id(r.getChangeId()).current().submit();
+  }
+
+  private void createBranch(Project.NameKey project, String ref) throws IOException {
+    try (Repository r = repoManager.openRepository(project);
+        ObjectInserter oi = r.newObjectInserter();
+        RevWalk rw = new RevWalk(r)) {
+      ObjectId emptyCommit = createCommit(r, "Test change");
+      RefUpdate updateRef = r.updateRef(ref);
+      updateRef.setExpectedOldObjectId(ObjectId.zeroId());
+      updateRef.setNewObjectId(emptyCommit);
+      assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.NEW);
+    }
+  }
+
+  private ObjectId createCommit(Repository repo, String commitMessage) throws IOException {
+    return createCommit(repo, commitMessage, null);
+  }
+
+  private ObjectId createCommit(Repository repo, String commitMessage, @Nullable ObjectId treeId)
+      throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      if (treeId == null) {
+        treeId = oi.insert(Constants.OBJ_TREE, new byte[] {});
+      }
+
+      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.nowTs());
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(treeId);
+      cb.setCommitter(ident);
+      cb.setAuthor(ident);
+      cb.setMessage(commitMessage);
+
+      ObjectId commit = oi.insert(cb);
+      oi.flush();
+      return commit;
+    }
+  }
+
+  private void assertAuditEvent(
+      GroupAuditEventInfo info,
+      Type expectedType,
+      Account.Id expectedUser,
+      Account.Id expectedMember) {
+    assertThat(info.user._accountId).isEqualTo(expectedUser.get());
+    assertThat(info.type).isEqualTo(expectedType);
+    assertThat(info).isInstanceOf(UserMemberAuditEventInfo.class);
+    assertThat(((UserMemberAuditEventInfo) info).member._accountId).isEqualTo(expectedMember.get());
+  }
+
+  private void assertAuditEvent(
+      GroupAuditEventInfo info,
+      Type expectedType,
+      Account.Id expectedUser,
+      String expectedMemberGroupName) {
+    assertThat(info.user._accountId).isEqualTo(expectedUser.get());
+    assertThat(info.type).isEqualTo(expectedType);
+    assertThat(info).isInstanceOf(GroupMemberAuditEventInfo.class);
+    assertThat(((GroupMemberAuditEventInfo) info).member.name).isEqualTo(expectedMemberGroupName);
+  }
+
+  private void assertMembers(String group, TestAccount... expectedMembers) throws Exception {
+    assertMembers(
+        gApi.groups().id(group).members(),
+        TestAccount.names(expectedMembers).stream().toArray(String[]::new));
+    assertAccountInfos(Arrays.asList(expectedMembers), gApi.groups().id(group).members());
+  }
+
+  private void assertMembers(Iterable<AccountInfo> members, String... expectedNames) {
+    assertThat(Iterables.transform(members, i -> i.name))
+        .containsExactlyElementsIn(Arrays.asList(expectedNames))
+        .inOrder();
+  }
+
+  private void assertNoMembers(String group) throws Exception {
+    assertThat(gApi.groups().id(group).members()).isEmpty();
+  }
+
+  private void assertIncludes(String group, String... expectedNames) throws Exception {
+    assertIncludes(gApi.groups().id(group).includedGroups(), expectedNames);
+  }
+
+  private static void assertIncludes(Iterable<GroupInfo> includes, String... expectedNames) {
+    assertThat(Iterables.transform(includes, i -> i.name))
+        .containsExactlyElementsIn(Arrays.asList(expectedNames))
+        .inOrder();
+  }
+
+  private void assertNoIncludes(String group) throws Exception {
+    assertThat(gApi.groups().id(group).includedGroups()).isEmpty();
+  }
+
+  private void assertBadRequest(ListRequest req) throws Exception {
+    try {
+      req.get();
+      fail("Expected BadRequestException");
+    } catch (BadRequestException e) {
+      // Expected
+    }
+  }
+
+  private boolean groupsInNoteDb() {
+    return cfg.getBoolean(SECTION_NOTE_DB, GROUPS.key(), WRITE, false);
+  }
+
+  private boolean readGroupsFromNoteDb() {
+    return groupsInNoteDb() && cfg.getBoolean(SECTION_NOTE_DB, GROUPS.key(), READ, false);
+  }
+
+  private AutoCloseable createBlockReviewDbGroupUpdatesContext() {
+    cfg.setBoolean("user", null, "blockReviewDbGroupUpdates", true);
+    return new AutoCloseable() {
+      @Override
+      public void close() {
+        cfg.setBoolean("user", null, "blockReviewDbGroupUpdates", false);
+      }
+    };
+  }
+
+  @Target({METHOD})
+  @Retention(RUNTIME)
+  private @interface IgnoreGroupInconsistencies {}
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/BUILD b/javatests/com/google/gerrit/acceptance/api/plugin/BUILD
new file mode 100644
index 0000000..3239f23
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "api_plugin",
+    labels = ["api"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
new file mode 100644
index 0000000..82d395f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -0,0 +1,164 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.plugin;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.plugins.PluginApi;
+import com.google.gerrit.extensions.api.plugins.Plugins.ListRequest;
+import com.google.gerrit.extensions.common.InstallPluginInput;
+import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RawInput;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.List;
+import org.junit.Test;
+
+@NoHttpd
+public class PluginIT extends AbstractDaemonTest {
+  private static final String JS_PLUGIN = "Gerrit.install(function(self){});\n";
+  private static final String HTML_PLUGIN =
+      String.format("<dom-module id=\"test\"><script>%s</script></dom-module>", JS_PLUGIN);
+  private static final RawInput JS_PLUGIN_CONTENT = RawInputUtil.create(JS_PLUGIN.getBytes(UTF_8));
+  private static final RawInput HTML_PLUGIN_CONTENT =
+      RawInputUtil.create(HTML_PLUGIN.getBytes(UTF_8));
+
+  private static final ImmutableList<String> PLUGINS =
+      ImmutableList.of(
+          "plugin-a.js", "plugin-b.html", "plugin-c.js", "plugin-d.html", "plugin_e.js");
+
+  @Test
+  @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
+  public void pluginManagement() throws Exception {
+    // No plugins are loaded
+    assertThat(list().get()).isEmpty();
+    assertThat(list().all().get()).isEmpty();
+
+    PluginApi api;
+    // Install all the plugins
+    InstallPluginInput input = new InstallPluginInput();
+    for (String plugin : PLUGINS) {
+      input.raw = plugin.endsWith(".js") ? JS_PLUGIN_CONTENT : HTML_PLUGIN_CONTENT;
+      api = gApi.plugins().install(plugin, input);
+      assertThat(api).isNotNull();
+      PluginInfo info = api.get();
+      String name = pluginName(plugin);
+      assertThat(info.id).isEqualTo(name);
+      assertThat(info.version).isEqualTo(pluginVersion(plugin));
+      assertThat(info.indexUrl).isEqualTo(String.format("plugins/%s/", name));
+      assertThat(info.filename).isEqualTo(plugin);
+      assertThat(info.disabled).isNull();
+    }
+    assertPlugins(list().get(), PLUGINS);
+
+    // With pagination
+    assertPlugins(list().start(1).limit(2).get(), PLUGINS.subList(1, 3));
+
+    // With prefix
+    assertPlugins(list().prefix("plugin-b").get(), ImmutableList.of("plugin-b.html"));
+    assertPlugins(list().prefix("PLUGIN-").get(), ImmutableList.of());
+
+    // With substring
+    assertPlugins(list().substring("lugin-").get(), PLUGINS.subList(0, PLUGINS.size() - 1));
+    assertPlugins(list().substring("lugin-").start(1).limit(2).get(), PLUGINS.subList(1, 3));
+
+    // With regex
+    assertPlugins(list().regex(".*in-b").get(), ImmutableList.of("plugin-b.html"));
+    assertPlugins(list().regex("plugin-.*").get(), PLUGINS.subList(0, PLUGINS.size() - 1));
+    assertPlugins(list().regex("plugin-.*").start(1).limit(2).get(), PLUGINS.subList(1, 3));
+
+    // Invalid match combinations
+    assertBadRequest(list().regex(".*in-b").substring("a"));
+    assertBadRequest(list().regex(".*in-b").prefix("a"));
+    assertBadRequest(list().substring(".*in-b").prefix("a"));
+
+    // Disable
+    api = gApi.plugins().name("plugin-a");
+    api.disable();
+    api = gApi.plugins().name("plugin-a");
+    assertThat(api.get().disabled).isTrue();
+    assertPlugins(list().get(), PLUGINS.subList(1, PLUGINS.size()));
+    assertPlugins(list().all().get(), PLUGINS);
+
+    // Enable
+    api.enable();
+    api = gApi.plugins().name("plugin-a");
+    assertThat(api.get().disabled).isNull();
+    assertPlugins(list().get(), PLUGINS);
+
+    // 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 plugin administration is disabled");
+    gApi.plugins().install("test.js", new InstallPluginInput());
+  }
+
+  @Test
+  public void getNonExistingThrowsNotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.plugins().name("does-not-exist");
+  }
+
+  private ListRequest list() throws RestApiException {
+    return gApi.plugins().list();
+  }
+
+  private void assertPlugins(List<PluginInfo> actual, List<String> expected) {
+    List<String> _actual = actual.stream().map(p -> p.id).collect(toList());
+    List<String> _expected = expected.stream().map(p -> pluginName(p)).collect(toList());
+    assertThat(_actual).containsExactlyElementsIn(_expected);
+  }
+
+  private String pluginName(String plugin) {
+    int dot = plugin.indexOf(".");
+    assertThat(dot).isGreaterThan(0);
+    return plugin.substring(0, dot);
+  }
+
+  private String pluginVersion(String plugin) {
+    String name = pluginName(plugin);
+    int dash = name.lastIndexOf("-");
+    return dash > 0 ? name.substring(dash + 1) : "";
+  }
+
+  private void assertBadRequest(ListRequest req) throws Exception {
+    try {
+      req.get();
+      fail("Expected BadRequestException");
+    } catch (BadRequestException e) {
+      // Expected
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/BUILD b/javatests/com/google/gerrit/acceptance/api/project/BUILD
new file mode 100644
index 0000000..768c20b
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "api_project",
+    labels = ["api"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
new file mode 100644
index 0000000..384dd7d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.config.AccessCheckInfo;
+import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CheckAccessIT extends AbstractDaemonTest {
+
+  private Project.NameKey normalProject;
+  private Project.NameKey secretProject;
+  private Project.NameKey secretRefProject;
+  private TestAccount privilegedUser;
+  private InternalGroup privilegedGroup;
+
+  @Before
+  public void setUp() throws Exception {
+    normalProject = createProject("normal");
+    secretProject = createProject("secret");
+    secretRefProject = createProject("secretRef");
+    privilegedGroup = group(createGroup("privilegedGroup"));
+
+    privilegedUser = accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden");
+    gApi.groups().id(privilegedGroup.getGroupUUID().get()).addMembers(privilegedUser.username);
+
+    assertThat(gApi.groups().id(privilegedGroup.getGroupUUID().get()).members().get(0).email)
+        .contains("snowden");
+
+    grant(secretProject, "refs/*", Permission.READ, false, privilegedGroup.getGroupUUID());
+    block(secretProject, "refs/*", Permission.READ, SystemGroupBackend.REGISTERED_USERS);
+
+    // deny/grant/block arg ordering is screwy.
+    deny(secretRefProject, "refs/*", Permission.READ, SystemGroupBackend.ANONYMOUS_USERS);
+    grant(
+        secretRefProject,
+        "refs/heads/secret/*",
+        Permission.READ,
+        false,
+        privilegedGroup.getGroupUUID());
+    block(
+        secretRefProject,
+        "refs/heads/secret/*",
+        Permission.READ,
+        SystemGroupBackend.REGISTERED_USERS);
+    grant(
+        secretRefProject,
+        "refs/heads/*",
+        Permission.READ,
+        false,
+        SystemGroupBackend.REGISTERED_USERS);
+  }
+
+  @Test
+  public void emptyInput() throws Exception {
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("input requires 'account'");
+    gApi.projects().name(normalProject.get()).checkAccess(new AccessCheckInput());
+  }
+
+  @Test
+  public void nonexistentEmail() throws Exception {
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("cannot find account doesnotexist@invalid.com");
+    gApi.projects()
+        .name(normalProject.get())
+        .checkAccess(new AccessCheckInput("doesnotexist@invalid.com", null));
+  }
+
+  private static class TestCase {
+    AccessCheckInput input;
+    String project;
+    int want;
+
+    TestCase(String mail, String project, String ref, int want) {
+      this.input = new AccessCheckInput(mail, ref);
+      this.project = project;
+      this.want = want;
+    }
+  }
+
+  @Test
+  public void accessible() throws Exception {
+    List<TestCase> inputs =
+        ImmutableList.of(
+            new TestCase(user.email, normalProject.get(), null, 200),
+            new TestCase(user.email, secretProject.get(), null, 403),
+            new TestCase(user.email, secretRefProject.get(), "refs/heads/secret/master", 403),
+            new TestCase(
+                privilegedUser.email, secretRefProject.get(), "refs/heads/secret/master", 200),
+            new TestCase(privilegedUser.email, normalProject.get(), null, 200),
+            new TestCase(privilegedUser.email, secretProject.get(), null, 200));
+
+    for (TestCase tc : inputs) {
+      String in = newGson().toJson(tc.input);
+      AccessCheckInfo info = null;
+
+      try {
+        info = gApi.projects().name(tc.project).checkAccess(tc.input);
+      } catch (RestApiException e) {
+        fail(String.format("check.access(%s, %s): exception %s", tc.project, in, e));
+      }
+
+      int want = tc.want;
+      if (want != info.status) {
+        fail(
+            String.format("check.access(%s, %s) = %d, want %d", tc.project, in, info.status, want));
+      }
+
+      switch (want) {
+        case 403:
+          assertThat(info.message).contains("cannot see");
+          break;
+        case 404:
+          assertThat(info.message).contains("does not exist");
+          break;
+        case 200:
+          assertThat(info.message).isNull();
+          break;
+        default:
+          fail(String.format("unknown code %d", want));
+      }
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
new file mode 100644
index 0000000..37a86d2
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -0,0 +1,220 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import 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;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.restapi.project.DashboardsCollection;
+import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class DashboardIT extends AbstractDaemonTest {
+  @Before
+  public void setup() throws Exception {
+    allow("refs/meta/dashboards/*", Permission.CREATE, REGISTERED_USERS);
+  }
+
+  @Test
+  public void defaultDashboardDoesNotExist() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    project().defaultDashboard().get();
+  }
+
+  @Test
+  public void dashboardDoesNotExist() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    project().dashboard("my:dashboard").get();
+  }
+
+  @Test
+  public void getDashboard() throws Exception {
+    DashboardInfo info = createTestDashboard();
+    DashboardInfo result = project().dashboard(info.id).get();
+    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 = createTestDashboard();
+    assertThat(info.isDefault).isNull();
+    project().dashboard(info.id).setDefault();
+    assertThat(project().dashboard(info.id).get().isDefault).isTrue();
+    assertThat(project().defaultDashboard().get().id).isEqualTo(info.id);
+  }
+
+  @Test
+  public void setDefaultDashboardByProject() throws Exception {
+    DashboardInfo info = createTestDashboard();
+    assertThat(info.isDefault).isNull();
+    project().defaultDashboard(info.id);
+    assertThat(project().dashboard(info.id).get().isDefault).isTrue();
+    assertThat(project().defaultDashboard().get().id).isEqualTo(info.id);
+
+    project().removeDefaultDashboard();
+    assertThat(project().dashboard(info.id).get().isDefault).isNull();
+
+    exception.expect(ResourceNotFoundException.class);
+    project().defaultDashboard().get();
+  }
+
+  @Test
+  public void replaceDefaultDashboard() throws Exception {
+    DashboardInfo d1 = 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();
+    assertThat(project().dashboard(d1.id).get().isDefault).isTrue();
+    assertThat(project().dashboard(d2.id).get().isDefault).isNull();
+    assertThat(project().defaultDashboard().get().id).isEqualTo(d1.id);
+    project().dashboard(d2.id).setDefault();
+    assertThat(project().defaultDashboard().get().id).isEqualTo(d2.id);
+    assertThat(project().dashboard(d1.id).get().isDefault).isNull();
+    assertThat(project().dashboard(d2.id).get().isDefault).isTrue();
+  }
+
+  @Test
+  public void cannotGetDashboardWithInheritedForNonDefault() throws Exception {
+    DashboardInfo info = 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();
+  }
+
+  private ProjectApi project() throws RestApiException {
+    return gApi.projects().name(project.get());
+  }
+
+  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());
+    } catch (ResourceConflictException e) {
+      // The branch already exists if this method has already been called once.
+      if (!e.getMessage().contains("already exists")) {
+        throw e;
+      }
+    }
+    try (Repository r = repoManager.openRepository(project)) {
+      TestRepository<Repository>.CommitBuilder cb =
+          new TestRepository<>(r).branch(canonicalRef).commit();
+      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());
+    }
+    return info;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
new file mode 100644
index 0000000..453a89f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -0,0 +1,381 @@
+// 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 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;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.DescriptionInput;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.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(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(gApi.projects().create(name + ".git").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[] {});
+  }
+
+  @Test
+  public void createProjectWithInitialCommit() throws Exception {
+    String name = name("foo");
+    ProjectInput input = new ProjectInput();
+    input.name = name;
+    input.createEmptyCommit = true;
+    assertThat(gApi.projects().create(input).get().name).isEqualTo(name);
+
+    RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
+    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
+
+    head = getRemoteHead(name, "refs/heads/master");
+    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master", null, head);
+  }
+
+  @Test
+  public void createProjectWithMismatchedInput() throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.name = name("foo");
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("name must match input.name");
+    gApi.projects().name("bar").create(in);
+  }
+
+  @Test
+  public void createProjectNoNameInInput() throws Exception {
+    ProjectInput in = new ProjectInput();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("input.name is required");
+    gApi.projects().create(in);
+  }
+
+  @Test
+  public void createProjectDuplicate() throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.name = name("baz");
+    gApi.projects().create(in);
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Project already exists");
+    gApi.projects().create(in);
+  }
+
+  @Test
+  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
+  public void descriptionChangeCausesRefUpdate() throws Exception {
+    RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    assertThat(gApi.projects().name(project.get()).description()).isEmpty();
+    DescriptionInput in = new DescriptionInput();
+    in.description = "new project description";
+    gApi.projects().name(project.get()).description(in);
+    assertThat(gApi.projects().name(project.get()).description()).isEqualTo(in.description);
+
+    RevCommit updatedHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    eventRecorder.assertRefUpdatedEvents(
+        project.get(), RefNames.REFS_CONFIG, initialHead, updatedHead);
+  }
+
+  @Test
+  public void descriptionIsDeletedWhenNotSpecified() throws Exception {
+    assertThat(gApi.projects().name(project.get()).description()).isEmpty();
+    DescriptionInput in = new DescriptionInput();
+    in.description = "new project description";
+    gApi.projects().name(project.get()).description(in);
+    assertThat(gApi.projects().name(project.get()).description()).isEqualTo(in.description);
+    in.description = null;
+    gApi.projects().name(project.get()).description(in);
+    assertThat(gApi.projects().name(project.get()).description()).isEmpty();
+  }
+
+  @Test
+  public void configChangeCausesRefUpdate() throws Exception {
+    RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+
+    ConfigInfo info = gApi.projects().name(project.get()).config();
+    assertThat(info.defaultSubmitType.value).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+    ConfigInput input = new ConfigInput();
+    input.submitType = SubmitType.CHERRY_PICK;
+    info = gApi.projects().name(project.get()).config(input);
+    assertThat(info.defaultSubmitType.value).isEqualTo(SubmitType.CHERRY_PICK);
+    info = gApi.projects().name(project.get()).config();
+    assertThat(info.defaultSubmitType.value).isEqualTo(SubmitType.CHERRY_PICK);
+
+    RevCommit updatedHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    eventRecorder.assertRefUpdatedEvents(
+        project.get(), RefNames.REFS_CONFIG, initialHead, updatedHead);
+  }
+
+  @Test
+  @SuppressWarnings("deprecation")
+  public void setConfig() throws Exception {
+    ConfigInput input = createTestConfigInput();
+    ConfigInfo info = gApi.projects().name(project.get()).config(input);
+    assertThat(info.description).isEqualTo(input.description);
+    assertThat(info.useContributorAgreements.configuredValue)
+        .isEqualTo(input.useContributorAgreements);
+    assertThat(info.useContentMerge.configuredValue).isEqualTo(input.useContentMerge);
+    assertThat(info.useSignedOffBy.configuredValue).isEqualTo(input.useSignedOffBy);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.requireChangeId.configuredValue).isEqualTo(input.requireChangeId);
+    assertThat(info.rejectImplicitMerges.configuredValue).isEqualTo(input.rejectImplicitMerges);
+    assertThat(info.enableReviewerByEmail.configuredValue).isEqualTo(input.enableReviewerByEmail);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo(input.maxObjectSizeLimit);
+    assertThat(info.submitType).isEqualTo(input.submitType);
+    assertThat(info.defaultSubmitType.value).isEqualTo(input.submitType);
+    assertThat(info.defaultSubmitType.inheritedValue).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+    assertThat(info.defaultSubmitType.configuredValue).isEqualTo(input.submitType);
+    assertThat(info.state).isEqualTo(input.state);
+  }
+
+  @SuppressWarnings("deprecation")
+  @Test
+  public void setPartialConfig() throws Exception {
+    ConfigInput input = createTestConfigInput();
+    ConfigInfo info = gApi.projects().name(project.get()).config(input);
+
+    ConfigInput partialInput = new ConfigInput();
+    partialInput.useContributorAgreements = InheritableBoolean.FALSE;
+    info = gApi.projects().name(project.get()).config(partialInput);
+
+    assertThat(info.description).isNull();
+    assertThat(info.useContributorAgreements.configuredValue)
+        .isEqualTo(partialInput.useContributorAgreements);
+    assertThat(info.useContentMerge.configuredValue).isEqualTo(input.useContentMerge);
+    assertThat(info.useSignedOffBy.configuredValue).isEqualTo(input.useSignedOffBy);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.requireChangeId.configuredValue).isEqualTo(input.requireChangeId);
+    assertThat(info.rejectImplicitMerges.configuredValue).isEqualTo(input.rejectImplicitMerges);
+    assertThat(info.enableReviewerByEmail.configuredValue).isEqualTo(input.enableReviewerByEmail);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo(input.maxObjectSizeLimit);
+    assertThat(info.submitType).isEqualTo(input.submitType);
+    assertThat(info.defaultSubmitType.value).isEqualTo(input.submitType);
+    assertThat(info.defaultSubmitType.inheritedValue).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+    assertThat(info.defaultSubmitType.configuredValue).isEqualTo(input.submitType);
+    assertThat(info.state).isEqualTo(input.state);
+  }
+
+  @Test
+  public void nonOwnerCannotSetConfig() throws Exception {
+    ConfigInput input = createTestConfigInput();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("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";
+    input.useContributorAgreements = InheritableBoolean.TRUE;
+    input.useContentMerge = InheritableBoolean.TRUE;
+    input.useSignedOffBy = InheritableBoolean.TRUE;
+    input.createNewChangeForAllNotInTarget = InheritableBoolean.TRUE;
+    input.requireChangeId = InheritableBoolean.TRUE;
+    input.rejectImplicitMerges = InheritableBoolean.TRUE;
+    input.enableReviewerByEmail = InheritableBoolean.TRUE;
+    input.createNewChangeForAllNotInTarget = InheritableBoolean.TRUE;
+    input.maxObjectSizeLimit = "5m";
+    input.submitType = SubmitType.CHERRY_PICK;
+    input.state = ProjectState.HIDDEN;
+    return input;
+  }
+
+  private 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/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
new file mode 100644
index 0000000..c1bc83e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.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.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.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");
+  }
+
+  @Test
+  public void setParentForAllUsersMustBeAllProjects() throws Exception {
+    gApi.projects().name(allUsers.get()).parent(allProjects.get());
+
+    String parent = createProject("parent", null, true).get();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("All-Users must inherit from All-Projects");
+    gApi.projects().name(allUsers.get()).parent(parent);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/BUILD b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
new file mode 100644
index 0000000..06e45c5
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "api_revision",
+    labels = ["api"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
new file mode 100644
index 0000000..060cef5
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -0,0 +1,1348 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
+import static com.google.gerrit.extensions.common.testing.FileInfoSubject.assertThat;
+import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toMap;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.changes.FileApi;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.testing.ConfigSuite;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.IntStream;
+import javax.imageio.ImageIO;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class RevisionDiffIT extends AbstractDaemonTest {
+  // @RunWith(Parameterized.class) can't be used as AbstractDaemonTest is annotated with another
+  // runner. Using different configs is a workaround to achieve the same.
+  private static final String TEST_PARAMETER_MARKER = "test_only_parameter";
+  private static final String CURRENT = "current";
+  private static final String FILE_NAME = "some_file.txt";
+  private static final String FILE_NAME2 = "another_file.txt";
+  private static final String FILE_CONTENT =
+      IntStream.rangeClosed(1, 100)
+          .mapToObj(number -> String.format("Line %d\n", number))
+          .collect(joining());
+  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
+
+  private boolean intraline;
+  private ObjectId commit1;
+  private String changeId;
+  private String initialPatchSetId;
+
+  @ConfigSuite.Config
+  public static Config intralineConfig() {
+    Config config = new Config();
+    config.setBoolean(TEST_PARAMETER_MARKER, null, "intraline", true);
+    return config;
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    intraline = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "intraline", false);
+
+    ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
+    commit1 =
+        addCommit(headCommit, ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
+
+    Result result = createEmptyChange();
+    changeId = result.getChangeId();
+    initialPatchSetId = result.getPatchSetId().getId();
+  }
+
+  @Test
+  public void diff() throws Exception {
+    // The assertions assume that intraline is false.
+    assume().that(intraline).isFalse();
+
+    String fileName = "a_new_file.txt";
+    String fileContent = "First line\nSecond line\n";
+    PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
+    assertDiffForNewFile(result, fileName, fileContent);
+    assertDiffForNewFile(result, COMMIT_MSG, result.getCommit().getFullMessage());
+  }
+
+  @Test
+  public void diffDeletedFile() throws Exception {
+    gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
+
+    DiffInfo diff = getDiffRequest(changeId, CURRENT, FILE_NAME).get();
+    assertThat(diff.metaA.lines).isEqualTo(100);
+    assertThat(diff.metaB).isNull();
+  }
+
+  @Test
+  public void addedFileIsIncludedInDiff() throws Exception {
+    String newFilePath = "a_new_file.txt";
+    String newFileContent = "arbitrary content";
+    gApi.changes().id(changeId).edit().modifyFile(newFilePath, RawInputUtil.create(newFileContent));
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath);
+  }
+
+  @Test
+  public void renamedFileIsIncludedInDiff() throws Exception {
+    String newFilePath = "a_new_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath);
+  }
+
+  @Test
+  public void copiedFileTreatedAsAddedFileInDiff() throws Exception {
+    String copyFilePath = "copy_of_some_file.txt";
+    gApi.changes().id(changeId).edit().modifyFile(copyFilePath, RawInputUtil.create(FILE_CONTENT));
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, copyFilePath);
+    // If this ever changes, please add tests which cover copied files.
+    assertThat(changedFiles.get(copyFilePath)).status().isEqualTo('A');
+    assertThat(changedFiles.get(copyFilePath)).linesInserted().isEqualTo(100);
+    assertThat(changedFiles.get(copyFilePath)).linesDeleted().isNull();
+  }
+
+  @Test
+  public void addedBinaryFileIsIncludedInDiff() throws Exception {
+    String imageFileName = "an_image.png";
+    byte[] imageBytes = createRgbImage(255, 0, 0);
+    gApi.changes().id(changeId).edit().modifyFile(imageFileName, RawInputUtil.create(imageBytes));
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, imageFileName);
+  }
+
+  @Test
+  public void modifiedBinaryFileIsIncludedInDiff() throws Exception {
+    String imageFileName = "an_image.png";
+    byte[] imageBytes1 = createRgbImage(255, 100, 0);
+    ObjectId commit2 = addCommit(commit1, imageFileName, imageBytes1);
+
+    rebaseChangeOn(changeId, commit2);
+    byte[] imageBytes2 = createRgbImage(0, 100, 255);
+    gApi.changes().id(changeId).edit().modifyFile(imageFileName, RawInputUtil.create(imageBytes2));
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, imageFileName);
+  }
+
+  @Test
+  public void diffOnMergeCommitChange() throws Exception {
+    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+
+    DiffInfo diff;
+
+    // automerge
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").get();
+    assertThat(diff.metaA.lines).isEqualTo(5);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").get();
+    assertThat(diff.metaA.lines).isEqualTo(5);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+
+    // parent 1
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(1).get();
+    assertThat(diff.metaA.lines).isEqualTo(1);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+
+    // parent 2
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(2).get();
+    assertThat(diff.metaA.lines).isEqualTo(1);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+  }
+
+  @Test
+  public void addedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
+    ObjectId commit2 = addCommit(commit1, "file_added_in_another_commit.txt", "Some file content");
+
+    rebaseChangeOn(changeId, commit2);
+    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
+  }
+
+  @Test
+  public void removedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
+    ObjectId commit2 = addCommitRemovingFiles(commit1, FILE_NAME2);
+
+    rebaseChangeOn(changeId, commit2);
+    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
+  }
+
+  @Test
+  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
+    ObjectId commit2 = addCommitRenamingFile(commit1, FILE_NAME2, "a_new_file_name.txt");
+
+    rebaseChangeOn(changeId, commit2);
+    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
+  }
+
+  @Test
+  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase_WhenEquallyModifiedInBoth()
+      throws Exception {
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("1st line\n", "First line\n");
+    addModifiedPatchSet(changeId, FILE_NAME2, contentModification);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Revert the modification to be able to rebase.
+    addModifiedPatchSet(
+        changeId, FILE_NAME2, fileContent -> fileContent.replace("First line\n", "1st line\n"));
+
+    String renamedFileName = "renamed_file.txt";
+    ObjectId commit2 = addCommitRenamingFile(commit1, FILE_NAME2, renamedFileName);
+    rebaseChangeOn(changeId, commit2);
+    addModifiedPatchSet(changeId, renamedFileName, contentModification);
+    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
+  }
+
+  @Test
+  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase_WhenModifiedDuringRebase()
+      throws Exception {
+    String renamedFilePath = "renamed_some_file.txt";
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
+    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, renamedFilePath);
+
+    rebaseChangeOn(changeId, commit3);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
+  }
+
+  @Test
+  public void fileRenamedDuringRebaseSameAsInPatchSetIsIgnored() throws Exception {
+    String renamedFileName = "renamed_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Revert the renaming to be able to rebase.
+    gApi.changes().id(changeId).edit().renameFile(renamedFileName, FILE_NAME);
+    gApi.changes().id(changeId).edit().publish();
+
+    ObjectId commit2 = addCommitRenamingFile(commit1, FILE_NAME, renamedFileName);
+    rebaseChangeOn(changeId, commit2);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
+  }
+
+  @Test
+  public void fileWithRebaseHunksRenamedDuringRebaseSameAsInPatchSetIsIgnored() throws Exception {
+    String renamedFileName = "renamed_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Revert the renaming to be able to rebase.
+    gApi.changes().id(changeId).edit().renameFile(renamedFileName, FILE_NAME);
+    gApi.changes().id(changeId).edit().publish();
+
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 10\n", "Line ten\n"));
+    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, renamedFileName);
+    rebaseChangeOn(changeId, commit3);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
+  }
+
+  @Test
+  public void filesNotTouchedByPatchSetsAndContainingOnlyRebaseHunksAreIgnored() throws Exception {
+    String newFileContent = FILE_CONTENT.replace("Line 10\n", "Line ten\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME2, "a_new_file_name.txt");
+
+    rebaseChangeOn(changeId, commit3);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
+  }
+
+  @Test
+  public void filesTouchedByPatchSetsAndContainingOnlyRebaseHunksAreIgnored() throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 50\n", "Line fifty\n"));
+    addModifiedPatchSet(
+        changeId, FILE_NAME2, fileContent -> fileContent.replace("1st line\n", "First line\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    // Revert the modification to allow rebasing.
+    addModifiedPatchSet(
+        changeId, FILE_NAME2, fileContent -> fileContent.replace("First line\n", "1st line\n"));
+
+    String newFileContent = FILE_CONTENT.replace("Line 10\n", "Line ten\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+    String newFilePath = "a_new_file_name.txt";
+    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME2, newFilePath);
+
+    rebaseChangeOn(changeId, commit3);
+    // Apply the modification again to bring the file into the same state as for the previous
+    // patch set.
+    addModifiedPatchSet(
+        changeId, newFilePath, fileContent -> fileContent.replace("1st line\n", "First line\n"));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
+  }
+
+  @Test
+  public void rebaseHunksAtStartOfFileAreIdentified() throws Exception {
+    String newFileContent =
+        FILE_CONTENT.replace("Line 1\n", "Line one\n").replace("Line 5\n", "Line five\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
+    assertThat(diffInfo).content().element(0).isDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 5");
+    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line five");
+    assertThat(diffInfo).content().element(2).isDueToRebase();
+    assertThat(diffInfo).content().element(3).commonLines().hasSize(44);
+    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 50");
+    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line fifty");
+    assertThat(diffInfo).content().element(4).isNotDueToRebase();
+    assertThat(diffInfo).content().element(5).commonLines().hasSize(50);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunksAtEndOfFileAreIdentified() throws Exception {
+    String newFileContent =
+        FILE_CONTENT
+            .replace("Line 60\n", "Line sixty\n")
+            .replace("Line 100\n", "Line one hundred\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(49);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 50");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line fifty");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 60");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line sixty");
+    assertThat(diffInfo).content().element(3).isDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(39);
+    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 100");
+    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line one hundred");
+    assertThat(diffInfo).content().element(5).isDueToRebase();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunksInBetweenRegularHunksAreIdentified() throws Exception {
+    String newFileContent =
+        FILE_CONTENT.replace("Line 40\n", "Line forty\n").replace("Line 45\n", "Line forty five\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent ->
+            fileContent
+                .replace("Line 1\n", "Line one\n")
+                .replace("Line 100\n", "Line one hundred\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
+    assertThat(diffInfo).content().element(0).isNotDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(38);
+    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 40");
+    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line forty");
+    assertThat(diffInfo).content().element(2).isDueToRebase();
+    assertThat(diffInfo).content().element(3).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 45");
+    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty five");
+    assertThat(diffInfo).content().element(4).isDueToRebase();
+    assertThat(diffInfo).content().element(5).commonLines().hasSize(54);
+    assertThat(diffInfo).content().element(6).linesOfA().containsExactly("Line 100");
+    assertThat(diffInfo).content().element(6).linesOfB().containsExactly("Line one hundred");
+    assertThat(diffInfo).content().element(6).isNotDueToRebase();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
+  }
+
+  @Test
+  public void rebaseHunkIsIdentifiedWhenMovedDownInPreviousPatchSet() throws Exception {
+    // Move the code down by introducing additional lines (pure insert + enlarging replacement) in
+    // the previous patch set.
+    Function<String, String> contentModification1 =
+        fileContent ->
+            "Line zero\n" + fileContent.replace("Line 10\n", "Line ten\nLine ten and a half\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification1);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification2 =
+        fileContent -> fileContent.replace("Line 100\n", "Line one hundred\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification2);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(41);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line forty");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 100");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line one hundred");
+    assertThat(diffInfo).content().element(3).isNotDueToRebase();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunkIsIdentifiedWhenMovedDownInLatestPatchSet() throws Exception {
+    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    // Move the code down by introducing additional lines (pure insert + enlarging replacement) in
+    // the latest patch set.
+    Function<String, String> contentModification =
+        fileContent ->
+            "Line zero\n" + fileContent.replace("Line 10\n", "Line ten\nLine ten and a half\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().isNull();
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line zero");
+    assertThat(diffInfo).content().element(0).isNotDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 10");
+    assertThat(diffInfo)
+        .content()
+        .element(2)
+        .linesOfB()
+        .containsExactly("Line ten", "Line ten and a half");
+    assertThat(diffInfo).content().element(2).isNotDueToRebase();
+    assertThat(diffInfo).content().element(3).commonLines().hasSize(29);
+    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 40");
+    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty");
+    assertThat(diffInfo).content().element(4).isDueToRebase();
+    assertThat(diffInfo).content().element(5).commonLines().hasSize(60);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunkIsIdentifiedWhenMovedUpInPreviousPatchSet() throws Exception {
+    // Move the code up by removing lines (pure deletion + shrinking replacement) in the previous
+    // patch set.
+    Function<String, String> contentModification1 =
+        fileContent ->
+            fileContent.replace("Line 1\n", "").replace("Line 10\nLine 11\n", "Line ten\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification1);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification2 =
+        fileContent -> fileContent.replace("Line 100\n", "Line one hundred\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification2);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(37);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line forty");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 100");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line one hundred");
+    assertThat(diffInfo).content().element(3).isNotDueToRebase();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunkIsIdentifiedWhenMovedUpInLatestPatchSet() throws Exception {
+    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    // Move the code up by removing lines (pure deletion + shrinking replacement) in the latest
+    // patch set.
+    Function<String, String> contentModification =
+        fileContent ->
+            fileContent.replace("Line 1\n", "").replace("Line 10\nLine 11\n", "Line ten\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
+    assertThat(diffInfo).content().element(0).linesOfB().isNull();
+    assertThat(diffInfo).content().element(0).isNotDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(8);
+    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 10", "Line 11");
+    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line ten");
+    assertThat(diffInfo).content().element(2).isNotDueToRebase();
+    assertThat(diffInfo).content().element(3).commonLines().hasSize(28);
+    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 40");
+    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty");
+    assertThat(diffInfo).content().element(4).isDueToRebase();
+    assertThat(diffInfo).content().element(5).commonLines().hasSize(60);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(3);
+  }
+
+  @Test
+  public void modifiedRebaseHunkWithSameRegionConsideredAsRegularHunk() throws Exception {
+    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line forty\n", "Line modified after rebase\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(39);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line modified after rebase");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(60);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunkOverlappingAtBeginningConsideredAsRegularHunk() throws Exception {
+    String newFileContent =
+        FILE_CONTENT.replace("Line 40\nLine 41\n", "Line forty\nLine forty one\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent ->
+            fileContent
+                .replace("Line 39\n", "Line thirty nine\n")
+                .replace("Line forty one\n", "Line 41\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(38);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 39", "Line 40");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line thirty nine", "Line forty");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(60);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
+  }
+
+  @Test
+  public void rebaseHunkOverlappingAtEndConsideredAsRegularHunk() throws Exception {
+    String newFileContent =
+        FILE_CONTENT.replace("Line 40\nLine 41\n", "Line forty\nLine forty one\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent ->
+            fileContent
+                .replace("Line forty\n", "Line 40\n")
+                .replace("Line 42\n", "Line forty two\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 41", "Line 42");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line forty one", "Line forty two");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(58);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
+  }
+
+  @Test
+  public void rebaseHunkModifiedInsideConsideredAsRegularHunk() throws Exception {
+    String newFileContent =
+        FILE_CONTENT.replace(
+            "Line 39\nLine 40\nLine 41\n", "Line thirty nine\nLine forty\nLine forty one\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line forty\n", "A different line forty\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(38);
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfA()
+        .containsExactly("Line 39", "Line 40", "Line 41");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line thirty nine", "A different line forty", "Line forty one");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(3);
+  }
+
+  @Test
+  public void rebaseHunkAfterLineNumberChangingOverlappingHunksIsIdentified() throws Exception {
+    String newFileContent =
+        FILE_CONTENT
+            .replace("Line 40\nLine 41\n", "Line forty\nLine forty one\n")
+            .replace("Line 60\n", "Line sixty\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent ->
+            fileContent
+                .replace("Line forty\n", "Line 40\n")
+                .replace("Line 42\n", "Line forty two\nLine forty two and a half\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 41", "Line 42");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line forty one", "Line forty two", "Line forty two and a half");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(17);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 60");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line sixty");
+    assertThat(diffInfo).content().element(3).isDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(40);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
+  }
+
+  @Test
+  public void rebaseHunksOneLineApartFromRegularHunkAreIdentified() throws Exception {
+    String newFileContent =
+        FILE_CONTENT.replace("Line 1\n", "Line one\n").replace("Line 5\n", "Line five\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 3\n", "Line three\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
+    assertThat(diffInfo).content().element(0).isDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 3");
+    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line three");
+    assertThat(diffInfo).content().element(2).isNotDueToRebase();
+    assertThat(diffInfo).content().element(3).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 5");
+    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line five");
+    assertThat(diffInfo).content().element(4).isDueToRebase();
+    assertThat(diffInfo).content().element(5).commonLines().hasSize(95);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void multipleRebaseEditsMixedWithRegularEditsCanBeIdentified() throws Exception {
+    addModifiedPatchSet(
+        changeId,
+        FILE_NAME,
+        fileContent -> fileContent.replace("Line 7\n", "Line seven\n").replace("Line 24\n", ""));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    ObjectId commit2 =
+        addCommit(
+            commit1,
+            FILE_NAME,
+            FILE_CONTENT
+                .replace("Line 2\n", "Line two\n")
+                .replace("Line 18\nLine 19\n", "Line eighteen\nLine nineteen\n")
+                .replace("Line 50\n", "Line fifty\n"));
+
+    rebaseChangeOn(changeId, commit2);
+    addModifiedPatchSet(
+        changeId,
+        FILE_NAME,
+        fileContent ->
+            fileContent
+                .replace("Line seven\n", "Line 7\n")
+                .replace("Line 9\n", "Line nine\n")
+                .replace("Line 60\n", "Line sixty\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 2");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line two");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line seven");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 7");
+    assertThat(diffInfo).content().element(3).isNotDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 9");
+    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line nine");
+    assertThat(diffInfo).content().element(5).isNotDueToRebase();
+    assertThat(diffInfo).content().element(6).commonLines().hasSize(8);
+    assertThat(diffInfo).content().element(7).linesOfA().containsExactly("Line 18", "Line 19");
+    assertThat(diffInfo)
+        .content()
+        .element(7)
+        .linesOfB()
+        .containsExactly("Line eighteen", "Line nineteen");
+    assertThat(diffInfo).content().element(7).isDueToRebase();
+    assertThat(diffInfo).content().element(8).commonLines().hasSize(29);
+    assertThat(diffInfo).content().element(9).linesOfA().containsExactly("Line 50");
+    assertThat(diffInfo).content().element(9).linesOfB().containsExactly("Line fifty");
+    assertThat(diffInfo).content().element(9).isDueToRebase();
+    assertThat(diffInfo).content().element(10).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(11).linesOfA().containsExactly("Line 60");
+    assertThat(diffInfo).content().element(11).linesOfB().containsExactly("Line sixty");
+    assertThat(diffInfo).content().element(11).isNotDueToRebase();
+    assertThat(diffInfo).content().element(12).commonLines().hasSize(40);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(3);
+  }
+
+  @Test
+  public void deletedFileDuringRebaseConsideredAsRegularHunkWhenModifiedInDiff() throws Exception {
+    // Modify the file and revert the modifications to allow rebasing.
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 50\n", "Line fifty\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line fifty\n", "Line 50\n"));
+
+    ObjectId commit2 = addCommitRemovingFiles(commit1, FILE_NAME);
+
+    rebaseChangeOn(changeId, commit2);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).changeType().isEqualTo(ChangeType.DELETED);
+    assertThat(diffInfo).content().element(0).linesOfA().hasSize(100);
+    assertThat(diffInfo).content().element(0).linesOfB().isNull();
+    assertThat(diffInfo).content().element(0).isNotDueToRebase();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isNull();
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(100);
+  }
+
+  @Test
+  public void addedFileDuringRebaseConsideredAsRegularHunkWhenModifiedInDiff() throws Exception {
+    String newFilePath = "a_new_file.txt";
+    ObjectId commit2 = addCommit(commit1, newFilePath, "1st line\n2nd line\n3rd line\n");
+
+    rebaseChangeOn(changeId, commit2);
+    addModifiedPatchSet(
+        changeId, newFilePath, fileContent -> fileContent.replace("1st line\n", "First line\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, newFilePath).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).changeType().isEqualTo(ChangeType.ADDED);
+    assertThat(diffInfo).content().element(0).linesOfA().isNull();
+    assertThat(diffInfo).content().element(0).linesOfB().hasSize(3);
+    assertThat(diffInfo).content().element(0).isNotDueToRebase();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(newFilePath)).linesInserted().isEqualTo(3);
+    assertThat(changedFiles.get(newFilePath)).linesDeleted().isNull();
+  }
+
+  @Test
+  public void rebaseHunkInRenamedFileIsIdentified_WhenFileIsRenamedDuringRebase() throws Exception {
+    String renamedFilePath = "renamed_some_file.txt";
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 1\n", "Line one\n"));
+    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, renamedFilePath);
+
+    rebaseChangeOn(changeId, commit3);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
+    addModifiedPatchSet(changeId, renamedFilePath, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, renamedFilePath).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
+    assertThat(diffInfo).content().element(0).isDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(48);
+    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 50");
+    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line fifty");
+    assertThat(diffInfo).content().element(2).isNotDueToRebase();
+    assertThat(diffInfo).content().element(3).commonLines().hasSize(50);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(renamedFilePath)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(renamedFilePath)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunkInRenamedFileIsIdentified_WhenFileIsRenamedInPatchSets() throws Exception {
+    String renamedFilePath = "renamed_some_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFilePath);
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Revert the renaming to be able to rebase.
+    gApi.changes().id(changeId).edit().renameFile(renamedFilePath, FILE_NAME);
+    gApi.changes().id(changeId).edit().publish();
+
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
+
+    rebaseChangeOn(changeId, commit2);
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFilePath);
+    gApi.changes().id(changeId).edit().publish();
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
+    addModifiedPatchSet(changeId, renamedFilePath, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, renamedFilePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(44);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 50");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line fifty");
+    assertThat(diffInfo).content().element(3).isNotDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(50);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(renamedFilePath)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(renamedFilePath)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void renamedFileWithOnlyRebaseHunksIsIdentified_WhenRenamedBetweenPatchSets()
+      throws Exception {
+    String newFilePath1 = "renamed_some_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath1);
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Revert the renaming to be able to rebase.
+    gApi.changes().id(changeId).edit().renameFile(newFilePath1, FILE_NAME);
+    gApi.changes().id(changeId).edit().publish();
+
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
+
+    rebaseChangeOn(changeId, commit2);
+    String newFilePath2 = "renamed_some_file_to_something_else.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath2);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath2);
+    assertThat(changedFiles.get(newFilePath2)).linesInserted().isNull();
+    assertThat(changedFiles.get(newFilePath2)).linesDeleted().isNull();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, newFilePath2).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(95);
+  }
+
+  @Test
+  public void renamedFileWithOnlyRebaseHunksIsIdentified_WhenRenamedForRebaseAndForPatchSets()
+      throws Exception {
+    String newFilePath1 = "renamed_some_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath1);
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Revert the renaming to be able to rebase.
+    gApi.changes().id(changeId).edit().renameFile(newFilePath1, FILE_NAME);
+    gApi.changes().id(changeId).edit().publish();
+
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
+    String newFilePath2 = "renamed_some_file_during_rebase.txt";
+    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, newFilePath2);
+
+    rebaseChangeOn(changeId, commit3);
+    String newFilePath3 = "renamed_some_file_to_something_else.txt";
+    gApi.changes().id(changeId).edit().renameFile(newFilePath2, newFilePath3);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath3);
+    assertThat(changedFiles.get(newFilePath3)).linesInserted().isNull();
+    assertThat(changedFiles.get(newFilePath3)).linesDeleted().isNull();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, newFilePath3).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(95);
+  }
+
+  @Test
+  public void copiedAndRenamedFilesWithOnlyRebaseHunksAreIdentified() throws Exception {
+    String newFileContent = FILE_CONTENT.replace("Line 5\n", "Line five\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    // Copies are only identified by JGit when paired with renaming.
+    String copyFileName = "copy_of_some_file.txt";
+    String renamedFileName = "renamed_some_file.txt";
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyFile(copyFileName, RawInputUtil.create(newFileContent));
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, copyFileName, renamedFileName);
+
+    DiffInfo renamedFileDiffInfo =
+        getDiffRequest(changeId, CURRENT, renamedFileName).withBase(initialPatchSetId).get();
+    assertThat(renamedFileDiffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(renamedFileDiffInfo).content().element(1).linesOfA().containsExactly("Line 5");
+    assertThat(renamedFileDiffInfo).content().element(1).linesOfB().containsExactly("Line five");
+    assertThat(renamedFileDiffInfo).content().element(1).isDueToRebase();
+    assertThat(renamedFileDiffInfo).content().element(2).commonLines().hasSize(95);
+
+    DiffInfo copiedFileDiffInfo =
+        getDiffRequest(changeId, CURRENT, copyFileName).withBase(initialPatchSetId).get();
+    assertThat(copiedFileDiffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(copiedFileDiffInfo).content().element(1).linesOfA().containsExactly("Line 5");
+    assertThat(copiedFileDiffInfo).content().element(1).linesOfB().containsExactly("Line five");
+    assertThat(copiedFileDiffInfo).content().element(1).isDueToRebase();
+    assertThat(copiedFileDiffInfo).content().element(2).commonLines().hasSize(95);
+  }
+
+  /*
+   *                change PS B
+   *                   |
+   * change PS A    commit4
+   *    |              |
+   * commit2        commit3
+   *    |             /
+   * commit1 --------
+   */
+  @Test
+  public void rebaseHunksWhenRebasingOnAnotherChangeOrPatchSetAreIdentified() throws Exception {
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
+    rebaseChangeOn(changeId, commit2);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    String commit3FileContent = FILE_CONTENT.replace("Line 35\n", "Line thirty five\n");
+    ObjectId commit3 = addCommit(commit1, FILE_NAME, commit3FileContent);
+    ObjectId commit4 =
+        addCommit(commit3, FILE_NAME, commit3FileContent.replace("Line 60\n", "Line sixty\n"));
+
+    rebaseChangeOn(changeId, commit4);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 20\n", "Line twenty\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line five");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 5");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 20");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line twenty");
+    assertThat(diffInfo).content().element(3).isNotDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 35");
+    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line thirty five");
+    assertThat(diffInfo).content().element(5).isDueToRebase();
+    assertThat(diffInfo).content().element(6).commonLines().hasSize(24);
+    assertThat(diffInfo).content().element(7).linesOfA().containsExactly("Line 60");
+    assertThat(diffInfo).content().element(7).linesOfB().containsExactly("Line sixty");
+    assertThat(diffInfo).content().element(7).isDueToRebase();
+    assertThat(diffInfo).content().element(8).commonLines().hasSize(40);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  /*
+   *                change PS B
+   *                   |
+   * change PS A    commit4
+   *    |              |
+   * commit2        commit3
+   *    |             /
+   * commit1 --------
+   */
+  @Test
+  public void unrelatedFileWhenRebasingOnAnotherChangeOrPatchSetIsIgnored() throws Exception {
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
+    rebaseChangeOn(changeId, commit2);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    ObjectId commit3 =
+        addCommit(commit1, FILE_NAME2, FILE_CONTENT2.replace("2nd line\n", "Second line\n"));
+    ObjectId commit4 =
+        addCommit(commit3, FILE_NAME, FILE_CONTENT.replace("Line 60\n", "Line sixty\n"));
+
+    rebaseChangeOn(changeId, commit4);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 20\n", "Line twenty\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
+  }
+
+  @Test
+  public void rebaseHunksWhenReversingPatchSetOrderAreIdentified() throws Exception {
+    ObjectId commit2 =
+        addCommit(
+            commit1,
+            FILE_NAME,
+            FILE_CONTENT.replace("Line 5\n", "Line five\n").replace("Line 35\n", ""));
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 20\n", "Line twenty\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    String currentPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, initialPatchSetId, FILE_NAME).withBase(currentPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line five");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 5");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line twenty");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 20");
+    assertThat(diffInfo).content().element(3).isNotDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(5).linesOfA().isNull();
+    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line 35");
+    assertThat(diffInfo).content().element(5).isDueToRebase();
+    assertThat(diffInfo).content().element(6).commonLines().hasSize(65);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).revision(initialPatchSetId).files(currentPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  private void assertDiffForNewFile(
+      PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
+    DiffInfo diff =
+        gApi.changes()
+            .id(pushResult.getChangeId())
+            .revision(pushResult.getCommit().name())
+            .file(path)
+            .diff();
+
+    List<String> headers = new ArrayList<>();
+    if (path.equals(COMMIT_MSG)) {
+      RevCommit c = pushResult.getCommit();
+
+      RevCommit parentCommit = c.getParents()[0];
+      String parentCommitId =
+          testRepo.getRevWalk().getObjectReader().abbreviate(parentCommit.getId(), 8).name();
+      headers.add("Parent:     " + parentCommitId + " (" + parentCommit.getShortMessage() + ")");
+
+      SimpleDateFormat dtfmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US);
+      PersonIdent author = c.getAuthorIdent();
+      dtfmt.setTimeZone(author.getTimeZone());
+      headers.add("Author:     " + author.getName() + " <" + author.getEmailAddress() + ">");
+      headers.add("AuthorDate: " + dtfmt.format(author.getWhen().getTime()));
+
+      PersonIdent committer = c.getCommitterIdent();
+      dtfmt.setTimeZone(committer.getTimeZone());
+      headers.add("Commit:     " + committer.getName() + " <" + committer.getEmailAddress() + ">");
+      headers.add("CommitDate: " + dtfmt.format(committer.getWhen().getTime()));
+      headers.add("");
+    }
+
+    if (!headers.isEmpty()) {
+      String header = Joiner.on("\n").join(headers);
+      expectedContentSideB = header + "\n" + expectedContentSideB;
+    }
+
+    assertDiffForNewFile(diff, pushResult.getCommit(), path, expectedContentSideB);
+  }
+
+  private void rebaseChangeOn(String changeId, ObjectId newParent) throws Exception {
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.base = newParent.getName();
+    gApi.changes().id(changeId).current().rebase(rebaseInput);
+  }
+
+  private ObjectId addCommit(ObjectId parentCommit, String filePath, String fileContent)
+      throws Exception {
+    ImmutableMap<String, String> files = ImmutableMap.of(filePath, fileContent);
+    return addCommit(parentCommit, files);
+  }
+
+  private ObjectId addCommit(ObjectId parentCommit, ImmutableMap<String, String> files)
+      throws Exception {
+    testRepo.reset(parentCommit);
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, "Adjust files of repo", files);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    return result.getCommit();
+  }
+
+  private ObjectId addCommit(ObjectId parentCommit, String filePath, byte[] fileContent)
+      throws Exception {
+    testRepo.reset(parentCommit);
+    PushOneCommit.Result result = createEmptyChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
+    GitUtil.fetch(testRepo, "refs/*:refs/*");
+    return ObjectId.fromString(currentRevision);
+  }
+
+  private ObjectId addCommitRemovingFiles(ObjectId parentCommit, String... removedFilePaths)
+      throws Exception {
+    testRepo.reset(parentCommit);
+    Map<String, String> files =
+        Arrays.stream(removedFilePaths)
+            .collect(toMap(Function.identity(), path -> "Irrelevant content"));
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, "Remove files from repo", files);
+    PushOneCommit.Result result = push.rm("refs/for/master");
+    return result.getCommit();
+  }
+
+  private ObjectId addCommitRenamingFile(
+      ObjectId parentCommit, String oldFilePath, String newFilePath) throws Exception {
+    testRepo.reset(parentCommit);
+    PushOneCommit.Result result = createEmptyChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).edit().renameFile(oldFilePath, newFilePath);
+    gApi.changes().id(changeId).edit().publish();
+    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
+    GitUtil.fetch(testRepo, "refs/*:refs/*");
+    return ObjectId.fromString(currentRevision);
+  }
+
+  private Result createEmptyChange() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, "Test change", ImmutableMap.of());
+    return push.to("refs/for/master");
+  }
+
+  private void addModifiedPatchSet(
+      String changeId, String filePath, Function<String, String> contentModification)
+      throws Exception {
+    try (BinaryResult content = gApi.changes().id(changeId).current().file(filePath).content()) {
+      String newContent = contentModification.apply(content.asString());
+      gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(newContent));
+    }
+    gApi.changes().id(changeId).edit().publish();
+  }
+
+  private static byte[] createRgbImage(int red, int green, int blue) throws IOException {
+    BufferedImage bufferedImage = new BufferedImage(10, 20, BufferedImage.TYPE_INT_RGB);
+    for (int x = 0; x < bufferedImage.getWidth(); x++) {
+      for (int y = 0; y < bufferedImage.getHeight(); y++) {
+        int rgb = (red << 16) + (green << 8) + blue;
+        bufferedImage.setRGB(x, y, rgb);
+      }
+    }
+
+    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+    ImageIO.write(bufferedImage, "png", byteArrayOutputStream);
+    return byteArrayOutputStream.toByteArray();
+  }
+
+  private FileApi.DiffRequest getDiffRequest(String changeId, String revisionId, String fileName)
+      throws Exception {
+    return gApi.changes()
+        .id(changeId)
+        .revision(revisionId)
+        .file(fileName)
+        .diffRequest()
+        .withIntraline(intraline);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
new file mode 100644
index 0000000..e3d0699
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -0,0 +1,1358 @@
+// 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.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static com.google.gerrit.acceptance.PushOneCommit.PATCH;
+import static com.google.gerrit.acceptance.PushOneCommit.PATCH_FILE_ONLY;
+import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
+import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Iterators;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.DraftApi;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.MergeableInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ETagView;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.webui.PatchSetWebLink;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.restapi.change.GetRevisionActions;
+import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.sql.Timestamp;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.RefSpec;
+import org.junit.Test;
+
+public class RevisionIT extends AbstractDaemonTest {
+
+  @Inject private GetRevisionActions getRevisionActions;
+  @Inject private DynamicSet<PatchSetWebLink> patchSetLinks;
+
+  @Test
+  public void reviewTriplet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(project.get() + "~master~" + r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+  }
+
+  @Test
+  public void reviewCurrent() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+  }
+
+  @Test
+  public void reviewNumber() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(1).review(ReviewInput.approve());
+
+    r = updateChange(r, "new content");
+    gApi.changes().id(r.getChangeId()).revision(2).review(ReviewInput.approve());
+  }
+
+  @Test
+  public void submit() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes().id(changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(changeId).current().submit();
+    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void postSubmitApproval() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes().id(changeId).current().review(ReviewInput.recommend());
+
+    String label = "Code-Review";
+    ApprovalInfo approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(1);
+    assertThat(approval.postSubmit).isNull();
+
+    // Submit by direct push.
+    git().push().setRefSpecs(new RefSpec(r.getCommit().name() + ":refs/heads/master")).call();
+    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
+
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(1);
+    assertThat(approval.postSubmit).isNull();
+    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), "Code-Review", 1, 2);
+
+    // Repeating the current label is allowed. Does not flip the postSubmit bit
+    // due to deduplication codepath.
+    gApi.changes().id(changeId).current().review(ReviewInput.recommend());
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(1);
+    assertThat(approval.postSubmit).isNull();
+
+    // Reducing vote is not allowed.
+    try {
+      gApi.changes().id(changeId).current().review(ReviewInput.dislike());
+      fail("expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
+    }
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(1);
+    assertThat(approval.postSubmit).isNull();
+
+    // Increasing vote is allowed.
+    gApi.changes().id(changeId).current().review(ReviewInput.approve());
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(2);
+    assertThat(approval.postSubmit).isTrue();
+    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), "Code-Review", 2);
+
+    // Decreasing to previous post-submit vote is still not allowed.
+    try {
+      gApi.changes().id(changeId).current().review(ReviewInput.dislike());
+      fail("expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
+    }
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(2);
+    assertThat(approval.postSubmit).isTrue();
+  }
+
+  @Test
+  public void postSubmitApprovalAfterVoteRemoved() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+
+    setApiUser(admin);
+    revision(r).review(ReviewInput.approve());
+
+    setApiUser(user);
+    revision(r).review(ReviewInput.recommend());
+
+    setApiUser(admin);
+    gApi.changes().id(changeId).reviewer(user.username).deleteVote("Code-Review");
+    Optional<ApprovalInfo> crUser =
+        get(changeId, DETAILED_LABELS)
+            .labels
+            .get("Code-Review")
+            .all
+            .stream()
+            .filter(a -> a._accountId == user.id.get())
+            .findFirst();
+    assertThat(crUser).isPresent();
+    assertThat(crUser.get().value).isEqualTo(0);
+
+    revision(r).submit();
+
+    setApiUser(user);
+    ReviewInput in = new ReviewInput();
+    in.label("Code-Review", 1);
+    in.message = "Still LGTM";
+    revision(r).review(in);
+
+    ApprovalInfo cr =
+        gApi.changes()
+            .id(changeId)
+            .get(DETAILED_LABELS)
+            .labels
+            .get("Code-Review")
+            .all
+            .stream()
+            .filter(a -> a._accountId == user.getId().get())
+            .findFirst()
+            .get();
+    assertThat(cr.postSubmit).isTrue();
+  }
+
+  @Test
+  public void postSubmitDeleteApprovalNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    revision(r).review(ReviewInput.approve());
+    revision(r).submit();
+
+    ReviewInput in = new ReviewInput();
+    in.label("Code-Review", 0);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Cannot reduce vote on labels for closed change: Code-Review");
+    revision(r).review(in);
+  }
+
+  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
+  @Test
+  public void approvalCopiedDuringSubmitIsNotPostSubmit() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id.get()).current().submit();
+
+    ChangeData cd = r.getChange();
+    assertThat(cd.patchSets()).hasSize(2);
+    PatchSetApproval psa =
+        Iterators.getOnlyElement(
+            cd.currentApprovals().stream().filter(a -> !a.isLegacySubmit()).iterator());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(2);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getValue()).isEqualTo(2);
+    assertThat(psa.isPostSubmit()).isFalse();
+  }
+
+  @Test
+  public void voteOnAbandonedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).abandon();
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("change is closed");
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+  }
+
+  @Test
+  public void voteNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("is restricted");
+    gApi.changes().id(r.getChange().getId().get()).current().review(ReviewInput.approve());
+  }
+
+  @Test
+  public void cherryPick() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master%topic=someTopic");
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = "it goes to stable branch";
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
+
+    assertThat(orig.get().messages).hasSize(1);
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+
+    Collection<ChangeMessageInfo> messages =
+        gApi.changes().id(project.get() + "~master~" + r.getChangeId()).get().messages;
+    assertThat(messages).hasSize(2);
+
+    String cherryPickedRevision = cherry.get().currentRevision;
+    String expectedMessage =
+        String.format(
+            "Patch Set 1: Cherry Picked\n\n"
+                + "This patchset was cherry picked to branch %s as commit %s",
+            in.destination, cherryPickedRevision);
+
+    Iterator<ChangeMessageInfo> origIt = messages.iterator();
+    origIt.next();
+    assertThat(origIt.next().message).isEqualTo(expectedMessage);
+
+    assertThat(cherry.get().messages).hasSize(1);
+    Iterator<ChangeMessageInfo> cherryIt = cherry.get().messages.iterator();
+    expectedMessage = "Patch Set 1: Cherry Picked from branch master.";
+    assertThat(cherryIt.next().message).isEqualTo(expectedMessage);
+
+    assertThat(cherry.get().subject).contains(in.message);
+    assertThat(cherry.get().topic).isEqualTo("someTopic-foo");
+    cherry.current().review(ReviewInput.approve());
+    cherry.current().submit();
+  }
+
+  @Test
+  public void cherryPickSetChangeId() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    String id = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbe3f";
+    in.message = "it goes to foo branch\n\nChange-Id: " + id;
+
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
+
+    assertThat(orig.get().messages).hasSize(1);
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+
+    ChangeInfo changeInfo = cherry.get();
+
+    // The cherry-pick honors the ChangeId specified in the input message:
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).endsWith(id + "\n");
+  }
+
+  @Test
+  public void cherryPickwithNoTopic() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = "it goes to stable branch";
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
+
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+    assertThat(cherry.get().topic).isNull();
+    cherry.current().review(ReviewInput.approve());
+    cherry.current().submit();
+  }
+
+  @Test
+  public void cherryPickToSameBranch() throws Exception {
+    PushOneCommit.Result r = createChange();
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "master";
+    in.message = "it generates a new patch set\n\nChange-Id: " + r.getChangeId();
+    ChangeInfo cherryInfo =
+        gApi.changes()
+            .id(project.get() + "~master~" + r.getChangeId())
+            .revision(r.getCommit().name())
+            .cherryPick(in)
+            .get();
+    assertThat(cherryInfo.messages).hasSize(2);
+    Iterator<ChangeMessageInfo> cherryIt = cherryInfo.messages.iterator();
+    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
+    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 2.");
+  }
+
+  @Test
+  public void cherryPickToSameBranchWithRebase() throws Exception {
+    // Push a new change, then merge it
+    PushOneCommit.Result baseChange = createChange();
+    String triplet = project.get() + "~master~" + baseChange.getChangeId();
+    RevisionApi baseRevision = gApi.changes().id(triplet).current();
+    baseRevision.review(ReviewInput.approve());
+    baseRevision.submit();
+
+    // Push a new change (change 1)
+    PushOneCommit.Result r1 = createChange();
+
+    // Push another new change (change 2)
+    String subject = "Test change\n\nChange-Id: Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, subject, "another_file.txt", "another content");
+    PushOneCommit.Result r2 = push.to("refs/for/master");
+
+    // Change 2's parent should be change 1
+    assertThat(r2.getCommit().getParents()[0].name()).isEqualTo(r1.getCommit().name());
+
+    // Cherry pick change 2 onto the same branch
+    triplet = project.get() + "~master~" + r2.getChangeId();
+    ChangeApi orig = gApi.changes().id(triplet);
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "master";
+    in.message = subject;
+    ChangeApi cherry = orig.revision(r2.getCommit().name()).cherryPick(in);
+    ChangeInfo cherryInfo = cherry.get();
+    assertThat(cherryInfo.messages).hasSize(2);
+    Iterator<ChangeMessageInfo> cherryIt = cherryInfo.messages.iterator();
+    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
+    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 2.");
+
+    // Parent of change 2 should now be the change that was merged, i.e.
+    // change 2 is rebased onto the head of the master branch.
+    String newParent =
+        cherryInfo.revisions.get(cherryInfo.currentRevision).commit.parents.get(0).commit;
+    assertThat(newParent).isEqualTo(baseChange.getCommit().name());
+  }
+
+  @Test
+  public void cherryPickIdenticalTree() throws Exception {
+    PushOneCommit.Result r = createChange();
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = "it goes to stable branch";
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
+
+    assertThat(orig.get().messages).hasSize(1);
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+
+    Collection<ChangeMessageInfo> messages =
+        gApi.changes().id(project.get() + "~master~" + r.getChangeId()).get().messages;
+    assertThat(messages).hasSize(2);
+
+    assertThat(cherry.get().subject).contains(in.message);
+    cherry.current().review(ReviewInput.approve());
+    cherry.current().submit();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Cherry pick failed: identical tree");
+    orig.revision(r.getCommit().name()).cherryPick(in);
+  }
+
+  @Test
+  public void cherryPickConflict() throws Exception {
+    PushOneCommit.Result r = createChange();
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = "it goes to stable branch";
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            "another content");
+    push.to("refs/heads/foo");
+
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    ChangeApi orig = gApi.changes().id(triplet);
+    assertThat(orig.get().messages).hasSize(1);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Cherry pick failed: merge conflict");
+    orig.revision(r.getCommit().name()).cherryPick(in);
+  }
+
+  @Test
+  public void cherryPickToExistingChange() throws Exception {
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "a")
+            .to("refs/for/master");
+    String t1 = project.get() + "~master~" + r1.getChangeId();
+
+    BranchInput bin = new BranchInput();
+    bin.revision = r1.getCommit().getParent(0).name();
+    gApi.projects().name(project.get()).branch("foo").create(bin);
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "b", r1.getChangeId())
+            .to("refs/for/foo");
+    String t2 = project.get() + "~foo~" + r2.getChangeId();
+    gApi.changes().id(t2).abandon();
+
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = r1.getCommit().getFullMessage();
+    try {
+      gApi.changes().id(t1).current().cherryPick(in);
+      fail();
+    } catch (ResourceConflictException e) {
+      assertThat(e.getMessage())
+          .isEqualTo(
+              "Cannot create new patch set of change "
+                  + info(t2)._number
+                  + " because it is abandoned");
+    }
+
+    gApi.changes().id(t2).restore();
+    gApi.changes().id(t1).current().cherryPick(in);
+    assertThat(get(t2, ALL_REVISIONS).revisions).hasSize(2);
+    assertThat(gApi.changes().id(t2).current().file(FILE_NAME).content().asString()).isEqualTo("a");
+  }
+
+  @Test
+  public void cherryPickMergeRelativeToDefaultParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+
+    ChangeInfo cherryPickedChangeInfo =
+        gApi.changes()
+            .id(mergeChangeResult.getChangeId())
+            .current()
+            .cherryPick(cherryPickInput)
+            .get();
+
+    Map<String, FileInfo> cherryPickedFilesByName =
+        cherryPickedChangeInfo.revisions.get(cherryPickedChangeInfo.currentRevision).files;
+    assertThat(cherryPickedFilesByName).containsKey(parent2FileName);
+    assertThat(cherryPickedFilesByName).doesNotContainKey(parent1FileName);
+  }
+
+  @Test
+  public void cherryPickMergeRelativeToSpecificParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+    cherryPickInput.parent = 2;
+
+    ChangeInfo cherryPickedChangeInfo =
+        gApi.changes()
+            .id(mergeChangeResult.getChangeId())
+            .current()
+            .cherryPick(cherryPickInput)
+            .get();
+
+    Map<String, FileInfo> cherryPickedFilesByName =
+        cherryPickedChangeInfo.revisions.get(cherryPickedChangeInfo.currentRevision).files;
+    assertThat(cherryPickedFilesByName).containsKey(parent1FileName);
+    assertThat(cherryPickedFilesByName).doesNotContainKey(parent2FileName);
+  }
+
+  @Test
+  public void cherryPickMergeUsingInvalidParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+    cherryPickInput.parent = 0;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "Cherry Pick: Parent 0 does not exist. Please specify a parent in range [1, 2].");
+    gApi.changes().id(mergeChangeResult.getChangeId()).current().cherryPick(cherryPickInput);
+  }
+
+  @Test
+  public void cherryPickMergeUsingNonExistentParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+    cherryPickInput.parent = 3;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "Cherry Pick: Parent 3 does not exist. Please specify a parent in range [1, 2].");
+    gApi.changes().id(mergeChangeResult.getChangeId()).current().cherryPick(cherryPickInput);
+  }
+
+  @Test
+  public void cherryPickNotify() throws Exception {
+    createBranch(new Branch.NameKey(project, "branch-1"));
+    createBranch(new Branch.NameKey(project, "branch-2"));
+    createBranch(new Branch.NameKey(project, "branch-3"));
+
+    // Creates a change for 'admin'.
+    PushOneCommit.Result result = createChange();
+    String changeId = project.get() + "~master~" + result.getChangeId();
+
+    // 'user' cherry-picks the change to a new branch, the source change's author/committer('admin')
+    // will be added as a reviewer of the newly created change.
+    setApiUser(user);
+    CherryPickInput input = new CherryPickInput();
+    input.message = "it goes to a new branch";
+
+    // Enable the notification. 'admin' as a reviewer should be notified.
+    input.destination = "branch-1";
+    input.notify = NotifyHandling.ALL;
+    sender.clear();
+    gApi.changes().id(changeId).current().cherryPick(input);
+    assertNotifyCc(admin);
+
+    // Disable the notification. 'admin' as a reviewer should not be notified any more.
+    input.destination = "branch-2";
+    input.notify = NotifyHandling.NONE;
+    sender.clear();
+    gApi.changes().id(changeId).current().cherryPick(input);
+    assertThat(sender.getMessages()).hasSize(0);
+
+    // Disable the notification. The user provided in the 'notifyDetails' should still be notified.
+    TestAccount userToNotify = accountCreator.user2();
+    input.destination = "branch-3";
+    input.notify = NotifyHandling.NONE;
+    input.notifyDetails =
+        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
+    sender.clear();
+    gApi.changes().id(changeId).current().cherryPick(input);
+    assertNotifyTo(userToNotify);
+  }
+
+  @Test
+  public void cherryPickKeepReviewers() throws Exception {
+    createBranch(new Branch.NameKey(project, "stable"));
+
+    // Change is created by 'admin'.
+    PushOneCommit.Result r = createChange();
+    // Change is approved by 'admin2'. Change is CC'd to 'user'.
+    setApiUser(accountCreator.admin2());
+    ReviewInput in = ReviewInput.approve();
+    in.reviewer(user.email, ReviewerState.CC, true);
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    // Change is cherrypicked by 'user2'.
+    setApiUser(accountCreator.user2());
+    CherryPickInput cin = new CherryPickInput();
+    cin.message = "this need to go to stable";
+    cin.destination = "stable";
+    cin.keepReviewers = true;
+    Map<ReviewerState, Collection<AccountInfo>> result =
+        gApi.changes().id(r.getChangeId()).current().cherryPick(cin).get().reviewers;
+
+    // 'admin' should be a reviewer as the old owner.
+    // 'admin2' should be a reviewer as the old reviewer.
+    // 'user' should be on CC.
+    assertThat(result).containsKey(ReviewerState.REVIEWER);
+    List<Integer> reviewers =
+        result.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
+    if (notesMigration.readChanges()) {
+      assertThat(result).containsKey(ReviewerState.CC);
+      List<Integer> ccs =
+          result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
+      assertThat(ccs).containsExactly(user.id.get());
+      assertThat(reviewers).containsExactly(admin.id.get(), accountCreator.admin2().id.get());
+    } else {
+      assertThat(reviewers)
+          .containsExactly(user.id.get(), admin.id.get(), accountCreator.admin2().id.get());
+    }
+  }
+
+  @Test
+  public void cherryPickToMergedChangeRevision() throws Exception {
+    createBranch(new Branch.NameKey(project, "foo"));
+
+    PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
+    dstChange.assertOkStatus();
+
+    merge(dstChange);
+
+    PushOneCommit.Result result = createChange(testRepo, "foo", SUBJECT, "b.txt", "c", "t");
+    result.assertOkStatus();
+    merge(result);
+
+    PushOneCommit.Result srcChange = createChange();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    input.base = dstChange.getCommit().name();
+    input.message = srcChange.getCommit().getFullMessage();
+    ChangeInfo changeInfo =
+        gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
+    assertCherryPickResult(changeInfo, input, srcChange.getChangeId());
+  }
+
+  @Test
+  public void cherryPickToOpenChangeRevision() throws Exception {
+    createBranch(new Branch.NameKey(project, "foo"));
+
+    PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
+    dstChange.assertOkStatus();
+
+    PushOneCommit.Result srcChange = createChange();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    input.base = dstChange.getCommit().name();
+    input.message = srcChange.getCommit().getFullMessage();
+    ChangeInfo changeInfo =
+        gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
+    assertCherryPickResult(changeInfo, input, srcChange.getChangeId());
+  }
+
+  @Test
+  public void cherryPickToNonVisibleChangeFails() throws Exception {
+    createBranch(new Branch.NameKey(project, "foo"));
+
+    PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
+    dstChange.assertOkStatus();
+
+    gApi.changes().id(dstChange.getChangeId()).setPrivate(true, null);
+
+    PushOneCommit.Result srcChange = createChange();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    input.base = dstChange.getCommit().name();
+    input.message = srcChange.getCommit().getFullMessage();
+
+    setApiUser(user);
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage(
+        String.format("Commit %s does not exist on branch refs/heads/foo", input.base));
+    gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
+  }
+
+  @Test
+  public void cherryPickToAbandonedChangeFails() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+    gApi.changes().id(change2.getChangeId()).abandon();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "master";
+    input.base = change2.getCommit().name();
+    input.message = change1.getCommit().getFullMessage();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        String.format(
+            "Change %s with commit %s is %s",
+            change2.getChange().getId().get(), input.base, ChangeStatus.ABANDONED));
+    gApi.changes().id(change1.getChangeId()).current().cherryPick(input);
+  }
+
+  @Test
+  public void cherryPickWithInvalidBaseFails() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "master";
+    input.base = "invalid-sha1";
+    input.message = change1.getCommit().getFullMessage();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(String.format("Base %s doesn't represent a valid SHA-1", input.base));
+    gApi.changes().id(change1.getChangeId()).current().cherryPick(input);
+  }
+
+  @Test
+  public void cherryPickToCommitWithoutChangeId() throws Exception {
+    RevCommit commit1 = createNewCommitWithoutChangeId("refs/heads/foo", "a.txt", "content 1");
+
+    createNewCommitWithoutChangeId("refs/heads/foo", "a.txt", "content 2");
+
+    PushOneCommit.Result srcChange = createChange("subject", "b.txt", "b");
+    srcChange.assertOkStatus();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    input.base = commit1.name();
+    input.message = srcChange.getCommit().getFullMessage();
+    ChangeInfo changeInfo =
+        gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
+    assertCherryPickResult(changeInfo, input, srcChange.getChangeId());
+  }
+
+  @Test
+  public void canRebase() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r1 = push.to("refs/for/master");
+    merge(r1);
+
+    push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r2 = push.to("refs/for/master");
+    boolean canRebase =
+        gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).canRebase();
+    assertThat(canRebase).isFalse();
+    merge(r2);
+
+    testRepo.reset(r1.getCommit());
+    push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r3 = push.to("refs/for/master");
+
+    canRebase = gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).canRebase();
+    assertThat(canRebase).isTrue();
+  }
+
+  @Test
+  public void setUnsetReviewedFlag() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+
+    gApi.changes().id(r.getChangeId()).current().setReviewed(PushOneCommit.FILE_NAME, true);
+
+    assertThat(Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).current().reviewed()))
+        .isEqualTo(PushOneCommit.FILE_NAME);
+
+    gApi.changes().id(r.getChangeId()).current().setReviewed(PushOneCommit.FILE_NAME, false);
+
+    assertThat(gApi.changes().id(r.getChangeId()).current().reviewed()).isEmpty();
+  }
+
+  @Test
+  public void mergeable() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    PushOneCommit push1 =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            "push 1 content");
+
+    PushOneCommit.Result r1 = push1.to("refs/for/master");
+    assertMergeable(r1.getChangeId(), true);
+    merge(r1);
+
+    // Reset HEAD to initial so the new change is a merge conflict.
+    RefUpdate ru = repo().updateRef(HEAD);
+    ru.setNewObjectId(initial);
+    assertThat(ru.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+
+    PushOneCommit push2 =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            "push 2 content");
+    PushOneCommit.Result r2 = push2.to("refs/for/master");
+    assertMergeable(r2.getChangeId(), false);
+    // TODO(dborowitz): Test for other-branches.
+  }
+
+  @Test
+  public void files() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Map<String, FileInfo> files =
+        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files();
+    assertThat(files).hasSize(2);
+    assertThat(Iterables.all(files.keySet(), f -> f.matches(FILE_NAME + '|' + COMMIT_MSG)))
+        .isTrue();
+  }
+
+  @Test
+  public void filesOnMergeCommitChange() throws Exception {
+    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+
+    // list files against auto-merge
+    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files().keySet())
+        .containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "bar");
+
+    // list files against parent 1
+    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(1).keySet())
+        .containsExactly(COMMIT_MSG, MERGE_LIST, "bar");
+
+    // list files against parent 2
+    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(2).keySet())
+        .containsExactly(COMMIT_MSG, MERGE_LIST, "foo");
+  }
+
+  @Test
+  public void listFilesOnDifferentBases() throws Exception {
+    PushOneCommit.Result result1 = createChange();
+    String changeId = result1.getChangeId();
+    PushOneCommit.Result result2 = amendChange(changeId, SUBJECT, "b.txt", "b");
+    PushOneCommit.Result result3 = amendChange(changeId, SUBJECT, "c.txt", "c");
+
+    String revId1 = result1.getCommit().name();
+    String revId2 = result2.getCommit().name();
+    String revId3 = result3.getCommit().name();
+
+    assertThat(gApi.changes().id(changeId).revision(revId1).files(null).keySet())
+        .containsExactly(COMMIT_MSG, "a.txt");
+    assertThat(gApi.changes().id(changeId).revision(revId2).files(null).keySet())
+        .containsExactly(COMMIT_MSG, "a.txt", "b.txt");
+    assertThat(gApi.changes().id(changeId).revision(revId3).files(null).keySet())
+        .containsExactly(COMMIT_MSG, "a.txt", "b.txt", "c.txt");
+
+    assertThat(gApi.changes().id(changeId).revision(revId2).files(revId1).keySet())
+        .containsExactly(COMMIT_MSG, "b.txt");
+    assertThat(gApi.changes().id(changeId).revision(revId3).files(revId1).keySet())
+        .containsExactly(COMMIT_MSG, "b.txt", "c.txt");
+    assertThat(gApi.changes().id(changeId).revision(revId3).files(revId2).keySet())
+        .containsExactly(COMMIT_MSG, "c.txt");
+  }
+
+  @Test
+  public void queryRevisionFiles() throws Exception {
+    Map<String, String> files = ImmutableMap.of("file1.txt", "content 1", "file2.txt", "content 2");
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo, SUBJECT, files).to("refs/for/master");
+    result.assertOkStatus();
+    String changeId = result.getChangeId();
+
+    assertThat(gApi.changes().id(changeId).current().queryFiles("file1.txt"))
+        .containsExactly("file1.txt");
+    assertThat(gApi.changes().id(changeId).current().queryFiles("file2.txt"))
+        .containsExactly("file2.txt");
+    assertThat(gApi.changes().id(changeId).current().queryFiles("file1"))
+        .containsExactly("file1.txt");
+    assertThat(gApi.changes().id(changeId).current().queryFiles("file2"))
+        .containsExactly("file2.txt");
+    assertThat(gApi.changes().id(changeId).current().queryFiles("file"))
+        .containsExactly("file1.txt", "file2.txt");
+    assertThat(gApi.changes().id(changeId).current().queryFiles(""))
+        .containsExactly("file1.txt", "file2.txt");
+  }
+
+  @Test
+  public void description() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertDescription(r, "");
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
+    assertDescription(r, "test");
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("");
+    assertDescription(r, "");
+  }
+
+  @Test
+  public void setDescriptionNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertDescription(r, "");
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("edit description not permitted");
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
+  }
+
+  @Test
+  public void setDescriptionAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertDescription(r, "");
+    grant(project, "refs/heads/master", Permission.OWNER, false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
+    assertDescription(r, "test");
+  }
+
+  private void assertDescription(PushOneCommit.Result r, String expected) throws Exception {
+    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
+        .isEqualTo(expected);
+  }
+
+  @Test
+  public void content() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertContent(r, FILE_NAME, FILE_CONTENT);
+    assertContent(r, COMMIT_MSG, r.getCommit().getFullMessage());
+  }
+
+  @Test
+  public void contentType() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    String endPoint =
+        "/changes/"
+            + r.getChangeId()
+            + "/revisions/"
+            + r.getCommit().name()
+            + "/files/"
+            + FILE_NAME
+            + "/content";
+    RestResponse response = adminRestSession.head(endPoint);
+    response.assertOK();
+    assertThat(response.getContentType()).startsWith("text/plain");
+    assertThat(response.hasContent()).isFalse();
+  }
+
+  @Test
+  public void commit() throws Exception {
+    WebLinkInfo expectedWebLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
+    patchSetLinks.add(
+        new PatchSetWebLink() {
+          @Override
+          public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+            return expectedWebLinkInfo;
+          }
+        });
+
+    PushOneCommit.Result r = createChange();
+    RevCommit c = r.getCommit();
+
+    CommitInfo commitInfo = gApi.changes().id(r.getChangeId()).current().commit(false);
+    assertThat(commitInfo.commit).isEqualTo(c.name());
+    assertPersonIdent(commitInfo.author, c.getAuthorIdent());
+    assertPersonIdent(commitInfo.committer, c.getCommitterIdent());
+    assertThat(commitInfo.message).isEqualTo(c.getFullMessage());
+    assertThat(commitInfo.subject).isEqualTo(c.getShortMessage());
+    assertThat(commitInfo.parents).hasSize(1);
+    assertThat(Iterables.getOnlyElement(commitInfo.parents).commit)
+        .isEqualTo(c.getParent(0).name());
+    assertThat(commitInfo.webLinks).isNull();
+
+    commitInfo = gApi.changes().id(r.getChangeId()).current().commit(true);
+    assertThat(commitInfo.webLinks).hasSize(1);
+    WebLinkInfo webLinkInfo = Iterables.getOnlyElement(commitInfo.webLinks);
+    assertThat(webLinkInfo.name).isEqualTo(expectedWebLinkInfo.name);
+    assertThat(webLinkInfo.imageUrl).isEqualTo(expectedWebLinkInfo.imageUrl);
+    assertThat(webLinkInfo.url).isEqualTo(expectedWebLinkInfo.url);
+    assertThat(webLinkInfo.target).isEqualTo(expectedWebLinkInfo.target);
+  }
+
+  private void assertPersonIdent(GitPerson gitPerson, PersonIdent expectedIdent) {
+    assertThat(gitPerson.name).isEqualTo(expectedIdent.getName());
+    assertThat(gitPerson.email).isEqualTo(expectedIdent.getEmailAddress());
+    assertThat(gitPerson.date).isEqualTo(new Timestamp(expectedIdent.getWhen().getTime()));
+    assertThat(gitPerson.tz).isEqualTo(expectedIdent.getTimeZoneOffset());
+  }
+
+  private void assertMergeable(String id, boolean expected) throws Exception {
+    MergeableInfo m = gApi.changes().id(id).current().mergeable();
+    assertThat(m.mergeable).isEqualTo(expected);
+    assertThat(m.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+    assertThat(m.mergeableInto).isNull();
+    ChangeInfo c = gApi.changes().id(id).info();
+    assertThat(c.mergeable).isEqualTo(expected);
+  }
+
+  @Test
+  public void drafts() throws Exception {
+    PushOneCommit.Result r = createChange();
+    DraftInput in = new DraftInput();
+    in.line = 1;
+    in.message = "nit: trailing whitespace";
+    in.path = FILE_NAME;
+
+    DraftApi draftApi =
+        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).createDraft(in);
+    assertThat(draftApi.get().message).isEqualTo(in.message);
+    assertThat(
+            gApi.changes()
+                .id(r.getChangeId())
+                .revision(r.getCommit().name())
+                .draft(draftApi.get().id)
+                .get()
+                .message)
+        .isEqualTo(in.message);
+    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).drafts())
+        .hasSize(1);
+
+    in.message = "good catch!";
+    assertThat(
+            gApi.changes()
+                .id(r.getChangeId())
+                .revision(r.getCommit().name())
+                .draft(draftApi.get().id)
+                .update(in)
+                .message)
+        .isEqualTo(in.message);
+
+    assertThat(
+            gApi.changes()
+                .id(r.getChangeId())
+                .revision(r.getCommit().name())
+                .draft(draftApi.get().id)
+                .get()
+                .author
+                .email)
+        .isEqualTo(admin.email);
+
+    draftApi.delete();
+    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).drafts())
+        .isEmpty();
+  }
+
+  @Test
+  public void comments() throws Exception {
+    PushOneCommit.Result r = createChange();
+    CommentInput in = new CommentInput();
+    in.line = 1;
+    in.message = "nit: trailing whitespace";
+    in.path = FILE_NAME;
+    ReviewInput reviewInput = new ReviewInput();
+    Map<String, List<CommentInput>> comments = new HashMap<>();
+    comments.put(FILE_NAME, Collections.singletonList(in));
+    reviewInput.comments = comments;
+    reviewInput.message = "comment test";
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+
+    Map<String, List<CommentInfo>> out =
+        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).comments();
+    assertThat(out).hasSize(1);
+    CommentInfo comment = Iterables.getOnlyElement(out.get(FILE_NAME));
+    assertThat(comment.message).isEqualTo(in.message);
+    assertThat(comment.author.email).isEqualTo(admin.email);
+    assertThat(comment.path).isNull();
+
+    List<CommentInfo> list =
+        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).commentsAsList();
+    assertThat(list).hasSize(1);
+
+    CommentInfo comment2 = list.get(0);
+    assertThat(comment2.path).isEqualTo(FILE_NAME);
+    assertThat(comment2.line).isEqualTo(comment.line);
+    assertThat(comment2.message).isEqualTo(comment.message);
+    assertThat(comment2.author.email).isEqualTo(comment.author.email);
+
+    assertThat(
+            gApi.changes()
+                .id(r.getChangeId())
+                .revision(r.getCommit().name())
+                .comment(comment.id)
+                .get()
+                .message)
+        .isEqualTo(in.message);
+  }
+
+  @Test
+  public void patch() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeApi changeApi = gApi.changes().id(r.getChangeId());
+    BinaryResult bin = changeApi.revision(r.getCommit().name()).patch();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String res = new String(os.toByteArray(), UTF_8);
+    ChangeInfo change = changeApi.get();
+    RevisionInfo rev = change.revisions.get(change.currentRevision);
+    DateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
+    String date = df.format(rev.commit.author.date);
+    assertThat(res).isEqualTo(String.format(PATCH, r.getCommit().name(), date, r.getChangeId()));
+  }
+
+  @Test
+  public void patchWithPath() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeApi changeApi = gApi.changes().id(r.getChangeId());
+    BinaryResult bin = changeApi.revision(r.getCommit().name()).patch(FILE_NAME);
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String res = new String(os.toByteArray(), UTF_8);
+    assertThat(res).isEqualTo(PATCH_FILE_ONLY);
+
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("File not found: nonexistent-file.");
+    changeApi.revision(r.getCommit().name()).patch("nonexistent-file");
+  }
+
+  @Test
+  public void actions() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(current(r).actions().keySet())
+        .containsExactly("cherrypick", "description", "rebase");
+
+    current(r).review(ReviewInput.approve());
+    assertThat(current(r).actions().keySet())
+        .containsExactly("submit", "cherrypick", "description", "rebase");
+
+    current(r).submit();
+    assertThat(current(r).actions().keySet()).containsExactly("cherrypick");
+  }
+
+  @Test
+  public void actionsETag() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+
+    String oldETag = checkETag(getRevisionActions, r2, null);
+    current(r2).review(ReviewInput.approve());
+    oldETag = checkETag(getRevisionActions, r2, oldETag);
+
+    // Dependent change is included in ETag.
+    current(r1).review(ReviewInput.approve());
+    oldETag = checkETag(getRevisionActions, r2, oldETag);
+
+    current(r2).submit();
+    oldETag = checkETag(getRevisionActions, r2, oldETag);
+  }
+
+  @Test
+  public void deleteVoteOnNonCurrentPatchSet() throws Exception {
+    PushOneCommit.Result r = createChange(); // patch set 1
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    // patch set 2
+    amendChange(r.getChangeId());
+
+    // code-review
+    setApiUser(user);
+    recommend(r.getChangeId());
+
+    // check if it's blocked to delete a vote on a non-current patch set.
+    setApiUser(admin);
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("Cannot access on non-current patch set");
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().getName())
+        .reviewer(user.getId().toString())
+        .deleteVote("Code-Review");
+  }
+
+  @Test
+  public void deleteVoteOnCurrentPatchSet() throws Exception {
+    PushOneCommit.Result r = createChange(); // patch set 1
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    // patch set 2
+    amendChange(r.getChangeId());
+
+    // code-review
+    setApiUser(user);
+    recommend(r.getChangeId());
+
+    setApiUser(admin);
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .reviewer(user.getId().toString())
+        .deleteVote("Code-Review");
+
+    Map<String, Short> m =
+        gApi.changes().id(r.getChangeId()).current().reviewer(user.getId().toString()).votes();
+
+    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
+
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    ChangeMessageInfo message = Iterables.getLast(c.messages);
+    assertThat(message.author._accountId).isEqualTo(admin.getId().get());
+    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(getReviewers(c.reviewers.get(ReviewerState.REVIEWER)))
+        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
+  }
+
+  private static void assertCherryPickResult(
+      ChangeInfo changeInfo, CherryPickInput input, String srcChangeId) throws Exception {
+    assertThat(changeInfo.changeId).isEqualTo(srcChangeId);
+    assertThat(changeInfo.revisions.keySet()).containsExactly(changeInfo.currentRevision);
+    RevisionInfo revisionInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revisionInfo.commit.message).isEqualTo(input.message);
+    assertThat(revisionInfo.commit.parents).hasSize(1);
+    assertThat(revisionInfo.commit.parents.get(0).commit).isEqualTo(input.base);
+  }
+
+  private PushOneCommit.Result updateChange(PushOneCommit.Result r, String content)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, "test commit", "a.txt", content, r.getChangeId());
+    return push.to("refs/for/master");
+  }
+
+  private RevisionApi current(PushOneCommit.Result r) throws Exception {
+    return gApi.changes().id(r.getChangeId()).current();
+  }
+
+  private String checkETag(ETagView<RevisionResource> view, PushOneCommit.Result r, String oldETag)
+      throws Exception {
+    String eTag = view.getETag(parseRevisionResource(r));
+    assertThat(eTag).isNotEqualTo(oldETag);
+    return eTag;
+  }
+
+  private PushOneCommit.Result createCherryPickableMerge(
+      String parent1FileName, String parent2FileName) throws Exception {
+    RevCommit initialCommit = getHead(repo());
+
+    String branchAName = "branchA";
+    createBranch(new Branch.NameKey(project, branchAName));
+    String branchBName = "branchB";
+    createBranch(new Branch.NameKey(project, branchBName));
+
+    PushOneCommit.Result changeAResult =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "change a", parent1FileName, "Content of a")
+            .to("refs/for/" + branchAName);
+
+    testRepo.reset(initialCommit);
+    PushOneCommit.Result changeBResult =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "change b", parent2FileName, "Content of b")
+            .to("refs/for/" + branchBName);
+
+    PushOneCommit pushableMergeCommit =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "merge",
+            ImmutableMap.of(parent1FileName, "Content of a", parent2FileName, "Content of b"));
+    pushableMergeCommit.setParents(
+        ImmutableList.of(changeAResult.getCommit(), changeBResult.getCommit()));
+    PushOneCommit.Result mergeChangeResult = pushableMergeCommit.to("refs/for/" + branchAName);
+    mergeChangeResult.assertOkStatus();
+    return mergeChangeResult;
+  }
+
+  private ApprovalInfo getApproval(String changeId, String label) throws Exception {
+    ChangeInfo info = gApi.changes().id(changeId).get(DETAILED_LABELS);
+    LabelInfo li = info.labels.get(label);
+    assertThat(li).isNotNull();
+    int accountId = atrScope.get().getUser().getAccountId().get();
+    return li.all.stream().filter(a -> a._accountId == accountId).findFirst().get();
+  }
+
+  private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
+    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
new file mode 100644
index 0000000..cd20765
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -0,0 +1,1151 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
+import static com.google.gerrit.extensions.common.testing.RobotCommentInfoSubject.assertThatList;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.testing.BinaryResultSubject;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+
+public class RobotCommentsIT extends AbstractDaemonTest {
+  private static final String FILE_NAME = "file_to_fix.txt";
+  private static final String FILE_NAME2 = "another_file_to_fix.txt";
+  private static final String FILE_CONTENT =
+      "First line\nSecond line\nThird line\nFourth line\nFifth line\nSixth line"
+          + "\nSeventh line\nEighth line\nNinth line\nTenth line\n";
+  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
+
+  private String changeId;
+  private FixReplacementInfo fixReplacementInfo;
+  private FixSuggestionInfo fixSuggestionInfo;
+  private RobotCommentInput withFixRobotCommentInput;
+
+  @Before
+  public void setUp() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Provide files which can be used for fixes",
+            ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
+    PushOneCommit.Result changeResult = push.to("refs/for/master");
+    changeId = changeResult.getChangeId();
+
+    fixReplacementInfo = createFixReplacementInfo();
+    fixSuggestionInfo = createFixSuggestionInfo(fixReplacementInfo);
+    withFixRobotCommentInput = createRobotCommentInput(fixSuggestionInfo);
+  }
+
+  @Test
+  public void retrievingRobotCommentsBeforeAddingAnyDoesNotRaiseAnException() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    Map<String, List<RobotCommentInfo>> robotComments =
+        gApi.changes().id(changeId).current().robotComments();
+
+    assertThat(robotComments).isNotNull();
+    assertThat(robotComments).isEmpty();
+  }
+
+  @Test
+  public void addedRobotCommentsCanBeRetrieved() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    RobotCommentInput in = createRobotCommentInput();
+    addRobotComment(changeId, in);
+
+    Map<String, List<RobotCommentInfo>> out = gApi.changes().id(changeId).current().robotComments();
+
+    assertThat(out).hasSize(1);
+    RobotCommentInfo comment = Iterables.getOnlyElement(out.get(in.path));
+    assertRobotComment(comment, in, false);
+  }
+
+  @Test
+  public void addedRobotCommentsCanBeRetrievedByChange() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    RobotCommentInput in = createRobotCommentInput();
+    addRobotComment(changeId, in);
+
+    pushFactory.create(db, admin.getIdent(), testRepo, changeId).to("refs/for/master");
+
+    RobotCommentInput in2 = createRobotCommentInput();
+    addRobotComment(changeId, in2);
+
+    Map<String, List<RobotCommentInfo>> out = gApi.changes().id(changeId).robotComments();
+
+    assertThat(out).hasSize(1);
+    assertThat(out.get(in.path)).hasSize(2);
+
+    RobotCommentInfo comment1 = out.get(in.path).get(0);
+    assertRobotComment(comment1, in, false);
+    RobotCommentInfo comment2 = out.get(in.path).get(1);
+    assertRobotComment(comment2, in2, false);
+  }
+
+  @Test
+  public void robotCommentsCanBeRetrievedAsList() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    RobotCommentInput robotCommentInput = createRobotCommentInput();
+    addRobotComment(changeId, robotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos =
+        gApi.changes().id(changeId).current().robotCommentsAsList();
+
+    assertThat(robotCommentInfos).hasSize(1);
+    RobotCommentInfo robotCommentInfo = Iterables.getOnlyElement(robotCommentInfos);
+    assertRobotComment(robotCommentInfo, robotCommentInput);
+  }
+
+  @Test
+  public void specificRobotCommentCanBeRetrieved() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    RobotCommentInput robotCommentInput = createRobotCommentInput();
+    addRobotComment(changeId, robotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    RobotCommentInfo robotCommentInfo = Iterables.getOnlyElement(robotCommentInfos);
+
+    RobotCommentInfo specificRobotCommentInfo =
+        gApi.changes().id(changeId).current().robotComment(robotCommentInfo.id).get();
+    assertRobotComment(specificRobotCommentInfo, robotCommentInput);
+  }
+
+  @Test
+  public void robotCommentWithoutOptionalFieldsCanBeAdded() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
+    addRobotComment(changeId, in);
+
+    Map<String, List<RobotCommentInfo>> out = gApi.changes().id(changeId).current().robotComments();
+    assertThat(out).hasSize(1);
+    RobotCommentInfo comment = Iterables.getOnlyElement(out.get(in.path));
+    assertRobotComment(comment, in, false);
+  }
+
+  @Test
+  public void hugeRobotCommentIsRejected() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    int sizeOfRest = 451;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest + 1);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("limit");
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void reasonablyLargeRobotCommentIsAccepted() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    int sizeOfRest = 451;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThat(robotCommentInfos).hasSize(1);
+  }
+
+  @Test
+  @GerritConfig(name = "change.robotCommentSizeLimit", value = "10k")
+  public void maximumAllowedSizeOfRobotCommentCanBeAdjusted() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int sizeLimit = 10 * 1024;
+    fixReplacementInfo.replacement = getStringFor(sizeLimit);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("limit");
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  @GerritConfig(name = "change.robotCommentSizeLimit", value = "0")
+  public void zeroForMaximumAllowedSizeOfRobotCommentRemovesRestriction() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThat(robotCommentInfos).hasSize(1);
+  }
+
+  @Test
+  @GerritConfig(name = "change.robotCommentSizeLimit", value = "-1")
+  public void negativeValueForMaximumAllowedSizeOfRobotCommentRemovesRestriction()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThat(robotCommentInfos).hasSize(1);
+  }
+
+  @Test
+  public void addedFixSuggestionCanBeRetrieved() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    assertThatList(robotCommentInfos).onlyElement().onlyFixSuggestion().isNotNull();
+  }
+
+  @Test
+  public void fixIdIsGeneratedForFixSuggestion() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    assertThatList(robotCommentInfos).onlyElement().onlyFixSuggestion().fixId().isNotEmpty();
+    assertThatList(robotCommentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .fixId()
+        .isNotEqualTo(fixSuggestionInfo.fixId);
+  }
+
+  @Test
+  public void descriptionOfFixSuggestionIsAcceptedAsIs() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    assertThatList(robotCommentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .description()
+        .isEqualTo(fixSuggestionInfo.description);
+  }
+
+  @Test
+  public void descriptionOfFixSuggestionIsMandatory() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixSuggestionInfo.description = null;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        String.format(
+            "A description is required for the suggested fix of the robot comment on %s",
+            withFixRobotCommentInput.path));
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void addedFixReplacementCanBeRetrieved() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    assertThatList(robotCommentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .onlyReplacement()
+        .isNotNull();
+  }
+
+  @Test
+  public void fixReplacementsAreMandatory() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixSuggestionInfo.replacements = Collections.emptyList();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        String.format(
+            "At least one replacement is required"
+                + " for the suggested fix of the robot comment on %s",
+            withFixRobotCommentInput.path));
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void pathOfFixReplacementIsAcceptedAsIs() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    assertThatList(robotCommentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .onlyReplacement()
+        .path()
+        .isEqualTo(fixReplacementInfo.path);
+  }
+
+  @Test
+  public void pathOfFixReplacementIsMandatory() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = null;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        String.format(
+            "A file path must be given for the replacement of the robot comment on %s",
+            withFixRobotCommentInput.path));
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void rangeOfFixReplacementIsAcceptedAsIs() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    assertThatList(robotCommentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .onlyReplacement()
+        .range()
+        .isEqualTo(fixReplacementInfo.range);
+  }
+
+  @Test
+  public void rangeOfFixReplacementIsMandatory() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.range = null;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        String.format(
+            "A range must be given for the replacement of the robot comment on %s",
+            withFixRobotCommentInput.path));
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void rangeOfFixReplacementNeedsToBeValid() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.range = createRange(13, 9, 5, 10);
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Range (13:9 - 5:10)");
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void rangesOfFixReplacementsOfSameFixSuggestionForSameFileMayNotOverlap()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("overlap");
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void rangesOfFixReplacementsOfSameFixSuggestionForDifferentFileMayOverlap()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThatList(robotCommentInfos).onlyElement().fixSuggestions().hasSize(1);
+  }
+
+  @Test
+  public void rangesOfFixReplacementsOfDifferentFixSuggestionsForSameFileMayOverlap()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    withFixRobotCommentInput.fixSuggestions =
+        ImmutableList.of(fixSuggestionInfo1, fixSuggestionInfo2);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThatList(robotCommentInfos).onlyElement().fixSuggestions().hasSize(2);
+  }
+
+  @Test
+  public void fixReplacementsDoNotNeedToBeOrderedAccordingToRange() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixReplacementInfo fixReplacementInfo3 = new FixReplacementInfo();
+    fixReplacementInfo3.path = FILE_NAME;
+    fixReplacementInfo3.range = createRange(4, 0, 5, 0);
+    fixReplacementInfo3.replacement = "Third modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo2, fixReplacementInfo1, fixReplacementInfo3);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThatList(robotCommentInfos).onlyElement().onlyFixSuggestion().replacements().hasSize(3);
+  }
+
+  @Test
+  public void replacementStringOfFixReplacementIsAcceptedAsIs() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    assertThatList(robotCommentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .onlyReplacement()
+        .replacement()
+        .isEqualTo(fixReplacementInfo.replacement);
+  }
+
+  @Test
+  public void replacementStringOfFixReplacementIsMandatory() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.replacement = null;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        String.format(
+            "A content for replacement must be "
+                + "indicated for the replacement of the robot comment on %s",
+            withFixRobotCommentInput.path));
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void fixWithinALineCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void fixSpanningMultipleLinesCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content\n5";
+    fixReplacementInfo.range = createRange(3, 2, 5, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nThModified content\n5th line\nSixth line\nSeventh line\n"
+                + "Eighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void fixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nSome other modified content\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoFixesOnSameFileCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    RobotCommentInput robotCommentInput1 = createRobotCommentInput(fixSuggestionInfo1);
+    RobotCommentInput robotCommentInput2 = createRobotCommentInput(fixSuggestionInfo2);
+    addRobotComment(changeId, robotCommentInput1);
+    addRobotComment(changeId, robotCommentInput2);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoConflictingFixesOnSameFileCannotBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    RobotCommentInput robotCommentInput1 = createRobotCommentInput(fixSuggestionInfo1);
+    RobotCommentInput robotCommentInput2 = createRobotCommentInput(fixSuggestionInfo2);
+    addRobotComment(changeId, robotCommentInput1);
+    addRobotComment(changeId, robotCommentInput2);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("merge");
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+  }
+
+  @Test
+  public void twoFixesOfSameRobotCommentCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    withFixRobotCommentInput.fixSuggestions =
+        ImmutableList.of(fixSuggestionInfo1, fixSuggestionInfo2);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void fixReferringToDifferentFileThanRobotCommentCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME2;
+    fixReplacementInfo.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo.replacement = "Modified content\n";
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo("1st line\nModified content\n3rd line\n");
+  }
+
+  @Test
+  public void fixInvolvingTwoFilesCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo2.replacement = "Different file modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nEighth line\nNinth line\nTenth line\n");
+    Optional<BinaryResult> file2 = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file2)
+        .value()
+        .asString()
+        .isEqualTo("Different file modification\n2nd line\n3rd line\n");
+  }
+
+  @Test
+  public void fixReferringToNonExistentFileCannotBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = "a_non_existent_file.txt";
+    fixReplacementInfo.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo.replacement = "Modified content\n";
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes().id(changeId).current().applyFix(fixId);
+  }
+
+  @Test
+  public void fixOnPreviousPatchSetWithoutChangeEditCannotBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    // Remember patch set and add another one.
+    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
+    amendChange(changeId);
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("current");
+    gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId);
+  }
+
+  @Test
+  public void fixOnPreviousPatchSetWithExistingChangeEditCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    // Remember patch set and add another one.
+    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
+    amendChange(changeId);
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+    assertThat(editInfo).baseRevision().isEqualTo(previousRevision);
+  }
+
+  @Test
+  public void fixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+
+    // Add another patch set.
+    amendChange(changeId);
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("based");
+    gApi.changes().id(changeId).current().applyFix(fixId);
+  }
+
+  @Test
+  public void fixDoesNotModifyCommitMessageOfChangeEdit() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    String changeEditCommitMessage = "This is the commit message of the change edit.\n";
+    gApi.changes().id(changeId).edit().modifyCommitMessage(changeEditCommitMessage);
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo(changeEditCommitMessage);
+  }
+
+  @Test
+  public void applyingFixTwiceIsIdempotent() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+    String expectedEditCommit =
+        gApi.changes().id(changeId).edit().get().map(edit -> edit.commit.commit).orElse("");
+
+    // Apply the fix again.
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> editInfo = gApi.changes().id(changeId).edit().get();
+    assertThat(editInfo).value().commit().commit().isEqualTo(expectedEditCommit);
+  }
+
+  @Test
+  public void nonExistentFixCannotBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+    String nonExistentFixId = fixId + "_non-existent";
+
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes().id(changeId).current().applyFix(nonExistentFixId);
+  }
+
+  @Test
+  public void applyingFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
+    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
+    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
+    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
+    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
+  }
+
+  @Test
+  public void applyingFixOnTopOfChangeEditReturnsEditInfoForUpdatedChangeEdit() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    gApi.changes().id(changeId).edit().create();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
+    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
+    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
+    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
+    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
+  }
+
+  @Test
+  public void createdChangeEditIsBasedOnCurrentPatchSet() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
+
+    assertThat(editInfo).baseRevision().isEqualTo(currentRevision);
+  }
+
+  @Test
+  public void robotCommentsNotSupportedWithoutNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+
+    RobotCommentInput in = createRobotCommentInput();
+    ReviewInput reviewInput = new ReviewInput();
+    Map<String, List<RobotCommentInput>> robotComments = new HashMap<>();
+    robotComments.put(in.path, ImmutableList.of(in));
+    reviewInput.robotComments = robotComments;
+    reviewInput.message = "comment test";
+
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("robot comments not supported");
+    gApi.changes().id(changeId).current().review(reviewInput);
+  }
+
+  @Test
+  public void queryChangesWithUnresolvedCommentCount() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
+            .to("refs/for/master");
+
+    addRobotComment(r2.getChangeId(), createRobotCommentInputWithMandatoryFields());
+
+    AcceptanceTestRequestScope.Context ctx = disableDb();
+    try {
+      ChangeInfo result = Iterables.getOnlyElement(query(r2.getChangeId()));
+      // currently, we create all robot comments as 'resolved' by default.
+      // if we allow users to resolve a robot comment, then this test should
+      // be modified.
+      assertThat(result.unresolvedCommentCount).isEqualTo(0);
+    } finally {
+      enableDb(ctx);
+    }
+  }
+
+  private static RobotCommentInput createRobotCommentInputWithMandatoryFields() {
+    RobotCommentInput in = new RobotCommentInput();
+    in.robotId = "happyRobot";
+    in.robotRunId = "1";
+    in.line = 1;
+    in.message = "nit: trailing whitespace";
+    in.path = FILE_NAME;
+    return in;
+  }
+
+  private static RobotCommentInput createRobotCommentInput(
+      FixSuggestionInfo... fixSuggestionInfos) {
+    RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
+    in.url = "http://www.happy-robot.com";
+    in.properties = new HashMap<>();
+    in.properties.put("key1", "value1");
+    in.properties.put("key2", "value2");
+    in.fixSuggestions = Arrays.asList(fixSuggestionInfos);
+    return in;
+  }
+
+  private static FixSuggestionInfo createFixSuggestionInfo(
+      FixReplacementInfo... fixReplacementInfos) {
+    FixSuggestionInfo newFixSuggestionInfo = new FixSuggestionInfo();
+    newFixSuggestionInfo.fixId = "An ID which must be overwritten.";
+    newFixSuggestionInfo.description = "A description for a suggested fix.";
+    newFixSuggestionInfo.replacements = Arrays.asList(fixReplacementInfos);
+    return newFixSuggestionInfo;
+  }
+
+  private static FixReplacementInfo createFixReplacementInfo() {
+    FixReplacementInfo newFixReplacementInfo = new FixReplacementInfo();
+    newFixReplacementInfo.path = FILE_NAME;
+    newFixReplacementInfo.replacement = "some replacement code";
+    newFixReplacementInfo.range = createRange(3, 9, 8, 4);
+    return newFixReplacementInfo;
+  }
+
+  private static Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+
+  private void addRobotComment(String targetChangeId, RobotCommentInput robotCommentInput)
+      throws Exception {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.robotComments =
+        Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
+    reviewInput.message = "robot comment test";
+    gApi.changes().id(targetChangeId).current().review(reviewInput);
+  }
+
+  private List<RobotCommentInfo> getRobotComments() throws RestApiException {
+    return gApi.changes().id(changeId).current().robotCommentsAsList();
+  }
+
+  private void assertRobotComment(RobotCommentInfo c, RobotCommentInput expected) {
+    assertRobotComment(c, expected, true);
+  }
+
+  private void assertRobotComment(
+      RobotCommentInfo c, RobotCommentInput expected, boolean expectPath) {
+    assertThat(c.robotId).isEqualTo(expected.robotId);
+    assertThat(c.robotRunId).isEqualTo(expected.robotRunId);
+    assertThat(c.url).isEqualTo(expected.url);
+    assertThat(c.properties).isEqualTo(expected.properties);
+    assertThat(c.line).isEqualTo(expected.line);
+    assertThat(c.message).isEqualTo(expected.message);
+
+    assertThat(c.author.email).isEqualTo(admin.email);
+
+    if (expectPath) {
+      assertThat(c.path).isEqualTo(expected.path);
+    } else {
+      assertThat(c.path).isNull();
+    }
+  }
+
+  private static String getStringFor(int numberOfBytes) {
+    char[] chars = new char[numberOfBytes];
+    // 'a' will require one byte even when mapped to a JSON string
+    Arrays.fill(chars, 'a');
+    return new String(chars);
+  }
+
+  private static List<String> getFixIds(List<RobotCommentInfo> robotComments) {
+    assertThatList(robotComments).isNotNull();
+    return robotComments
+        .stream()
+        .map(robotCommentInfo -> robotCommentInfo.fixSuggestions)
+        .filter(Objects::nonNull)
+        .flatMap(List::stream)
+        .map(fixSuggestionInfo -> fixSuggestionInfo.fixId)
+        .collect(toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/edit/BUILD b/javatests/com/google/gerrit/acceptance/edit/BUILD
new file mode 100644
index 0000000..25fc4f6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/edit/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = ["ChangeEditIT.java"],
+    group = "edit",
+    labels = ["edit"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
new file mode 100644
index 0000000..28c1641
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -0,0 +1,853 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.edit;
+
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
+import static com.google.gerrit.extensions.restapi.testing.BinaryResultSubject.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.restapi.change.ChangeEdits.EditMessage;
+import com.google.gerrit.server.restapi.change.ChangeEdits.Post;
+import com.google.gerrit.server.restapi.change.ChangeEdits.Put;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class ChangeEditIT extends AbstractDaemonTest {
+
+  private static final String FILE_NAME = "foo";
+  private static final String FILE_NAME2 = "foo2";
+  private static final String FILE_NAME3 = "foo3";
+  private static final byte[] CONTENT_OLD = "bar".getBytes(UTF_8);
+  private static final byte[] CONTENT_NEW = "baz".getBytes(UTF_8);
+  private static final String CONTENT_NEW2_STR = "quxÄÜÖßµ";
+  private static final byte[] CONTENT_NEW2 = CONTENT_NEW2_STR.getBytes(UTF_8);
+
+  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
+
+  private String changeId;
+  private String changeId2;
+  private PatchSet ps;
+
+  @BeforeClass
+  public static void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @AfterClass
+  public static void restoreTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    db = reviewDbProvider.open();
+    changeId = newChange(admin.getIdent());
+    ps = getCurrentPatchSet(changeId);
+    assertThat(ps).isNotNull();
+    amendChange(admin.getIdent(), changeId);
+    changeId2 = newChange2(admin.getIdent());
+  }
+
+  @After
+  public void cleanup() {
+    db.close();
+  }
+
+  @Test
+  public void parseEditRevision() throws Exception {
+    createArbitraryEditFor(changeId);
+
+    // check that '0' is parsed as edit revision
+    gApi.changes().id(changeId).revision(0).comments();
+
+    // check that 'edit' is parsed as edit revision
+    gApi.changes().id(changeId).revision("edit").comments();
+  }
+
+  @Test
+  public void deleteEditOfCurrentPatchSet() throws Exception {
+    createArbitraryEditFor(changeId);
+    gApi.changes().id(changeId).edit().delete();
+    assertThat(getEdit(changeId)).isAbsent();
+  }
+
+  @Test
+  public void deleteEditOfOlderPatchSet() throws Exception {
+    createArbitraryEditFor(changeId2);
+    amendChange(admin.getIdent(), changeId2);
+
+    gApi.changes().id(changeId2).edit().delete();
+    assertThat(getEdit(changeId2)).isAbsent();
+  }
+
+  @Test
+  public void publishEdit() throws Exception {
+    createArbitraryEditFor(changeId);
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    publishInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(changeId).edit().publish(publishInput);
+
+    assertThat(getEdit(changeId)).isAbsent();
+    assertChangeMessages(
+        changeId,
+        ImmutableList.of(
+            "Uploaded patch set 1.",
+            "Uploaded patch set 2.",
+            "Patch Set 3: Published edit on patch set 2."));
+
+    // The tag for the publish edit change message should vary according
+    // to whether the change was WIP at the time of publishing.
+    ChangeInfo info = get(changeId, MESSAGES);
+    assertThat(info.messages).isNotEmpty();
+    assertThat(Iterables.getLast(info.messages).tag)
+        .isEqualTo(ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+
+    // Move the change to WIP, repeat, and verify.
+    gApi.changes().id(changeId).setWorkInProgress();
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW2));
+    gApi.changes().id(changeId).edit().publish();
+    info = get(changeId, MESSAGES);
+    assertThat(info.messages).isNotEmpty();
+    assertThat(Iterables.getLast(info.messages).tag)
+        .isEqualTo(ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
+  }
+
+  @Test
+  public void publishEditRest() throws Exception {
+    PatchSet oldCurrentPatchSet = getCurrentPatchSet(changeId);
+    createArbitraryEditFor(changeId);
+
+    adminRestSession.post(urlPublish(changeId)).assertNoContent();
+    assertThat(getEdit(changeId)).isAbsent();
+    PatchSet newCurrentPatchSet = getCurrentPatchSet(changeId);
+    assertThat(newCurrentPatchSet.getId()).isNotEqualTo(oldCurrentPatchSet.getId());
+    assertChangeMessages(
+        changeId,
+        ImmutableList.of(
+            "Uploaded patch set 1.",
+            "Uploaded patch set 2.",
+            "Patch Set 3: Published edit on patch set 2."));
+  }
+
+  @Test
+  public void publishEditNotifyRest() throws Exception {
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(changeId).addReviewer(in);
+
+    createArbitraryEditFor(changeId);
+
+    sender.clear();
+    PublishChangeEditInput input = new PublishChangeEditInput();
+    input.notify = NotifyHandling.NONE;
+    adminRestSession.post(urlPublish(changeId), input).assertNoContent();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void publishEditWithDefaultNotify() throws Exception {
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(changeId).addReviewer(in);
+
+    createArbitraryEditFor(changeId);
+
+    sender.clear();
+    gApi.changes().id(changeId).edit().publish();
+    assertThat(sender.getMessages()).isNotEmpty();
+  }
+
+  @Test
+  public void deleteEditRest() throws Exception {
+    createArbitraryEditFor(changeId);
+    adminRestSession.delete(urlEdit(changeId)).assertNoContent();
+    assertThat(getEdit(changeId)).isAbsent();
+  }
+
+  @Test
+  public void publishEditRestWithoutCLA() throws Exception {
+    createArbitraryEditFor(changeId);
+    setUseContributorAgreements(InheritableBoolean.TRUE);
+    adminRestSession.post(urlPublish(changeId)).assertForbidden();
+    setUseContributorAgreements(InheritableBoolean.FALSE);
+    adminRestSession.post(urlPublish(changeId)).assertNoContent();
+  }
+
+  @Test
+  public void rebaseEdit() throws Exception {
+    PatchSet previousPatchSet = getCurrentPatchSet(changeId2);
+    createEmptyEditFor(changeId2);
+    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    amendChange(admin.getIdent(), changeId2);
+    PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
+
+    Optional<EditInfo> originalEdit = getEdit(changeId2);
+    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.getRevision().get());
+    Timestamp beforeRebase = originalEdit.get().commit.committer.date;
+    gApi.changes().id(changeId2).edit().rebase();
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME2), CONTENT_NEW2);
+    Optional<EditInfo> rebasedEdit = getEdit(changeId2);
+    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
+    assertThat(rebasedEdit).value().commit().committer().date().isNotEqualTo(beforeRebase);
+  }
+
+  @Test
+  public void rebaseEditRest() throws Exception {
+    PatchSet previousPatchSet = getCurrentPatchSet(changeId2);
+    createEmptyEditFor(changeId2);
+    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    amendChange(admin.getIdent(), changeId2);
+    PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
+
+    Optional<EditInfo> originalEdit = getEdit(changeId2);
+    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.getRevision().get());
+    Timestamp beforeRebase = originalEdit.get().commit.committer.date;
+    adminRestSession.post(urlRebase(changeId2)).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME2), CONTENT_NEW2);
+    Optional<EditInfo> rebasedEdit = getEdit(changeId2);
+    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
+    assertThat(rebasedEdit).value().commit().committer().date().isNotEqualTo(beforeRebase);
+  }
+
+  @Test
+  public void rebaseEditWithConflictsRest_Conflict() throws Exception {
+    PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
+    createEmptyEditFor(changeId2);
+    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    Optional<EditInfo> edit = getEdit(changeId2);
+    assertThat(edit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            FILE_NAME,
+            new String(CONTENT_NEW2, UTF_8),
+            changeId2);
+    push.to("refs/for/master").assertOkStatus();
+    adminRestSession.post(urlRebase(changeId2)).assertConflict();
+  }
+
+  @Test
+  public void updateExistingFile() throws Exception {
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    assertThat(getEdit(changeId)).isPresent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void updateRootCommitMessage() throws Exception {
+    // Re-clone empty repo; TestRepository doesn't let us reset to unborn head.
+    testRepo = cloneProject(project);
+    changeId = newChange(admin.getIdent());
+
+    createEmptyEditFor(changeId);
+    Optional<EditInfo> edit = getEdit(changeId);
+    assertThat(edit).value().commit().parents().isEmpty();
+
+    String msg = String.format("New commit message\n\nChange-Id: %s\n", changeId);
+    gApi.changes().id(changeId).edit().modifyCommitMessage(msg);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo(msg);
+  }
+
+  @Test
+  public void updateMessageNoChange() throws Exception {
+    createEmptyEditFor(changeId);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("New commit message cannot be same as existing commit message");
+    gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage);
+  }
+
+  @Test
+  public void updateMessageOnlyAddTrailingNewLines() throws Exception {
+    createEmptyEditFor(changeId);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("New commit message cannot be same as existing commit message");
+    gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage + "\n\n");
+  }
+
+  @Test
+  public void updateMessage() throws Exception {
+    createEmptyEditFor(changeId);
+    String msg = String.format("New commit message\n\nChange-Id: %s\n", changeId);
+    gApi.changes().id(changeId).edit().modifyCommitMessage(msg);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo(msg);
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    publishInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(changeId).edit().publish(publishInput);
+    assertThat(getEdit(changeId)).isAbsent();
+
+    ChangeInfo info =
+        get(changeId, ListChangesOption.CURRENT_COMMIT, ListChangesOption.CURRENT_REVISION);
+    assertThat(info.revisions.get(info.currentRevision).commit.message).isEqualTo(msg);
+    assertThat(info.revisions.get(info.currentRevision).description)
+        .isEqualTo("Edit commit message");
+
+    assertChangeMessages(
+        changeId,
+        ImmutableList.of(
+            "Uploaded patch set 1.",
+            "Uploaded patch set 2.",
+            "Patch Set 3: Commit message was updated."));
+  }
+
+  @Test
+  public void updateMessageRest() throws Exception {
+    adminRestSession.get(urlEditMessage(changeId, false)).assertNotFound();
+    EditMessage.Input in = new EditMessage.Input();
+    in.message =
+        String.format(
+            "New commit message\n\n" + CONTENT_NEW2_STR + "\n\nChange-Id: %s\n", changeId);
+    adminRestSession.put(urlEditMessage(changeId, false), in).assertNoContent();
+    RestResponse r = adminRestSession.getJsonAccept(urlEditMessage(changeId, false));
+    r.assertOK();
+    assertThat(readContentFromJson(r)).isEqualTo(in.message);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo(in.message);
+    in.message = String.format("New commit message2\n\nChange-Id: %s\n", changeId);
+    adminRestSession.put(urlEditMessage(changeId, false), in).assertNoContent();
+    String updatedCommitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(updatedCommitMessage).isEqualTo(in.message);
+
+    r = adminRestSession.getJsonAccept(urlEditMessage(changeId, true));
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+      assertThat(readContentFromJson(r)).isEqualTo(commit.getFullMessage());
+    }
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    publishInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(changeId).edit().publish(publishInput);
+    assertChangeMessages(
+        changeId,
+        ImmutableList.of(
+            "Uploaded patch set 1.",
+            "Uploaded patch set 2.",
+            "Patch Set 3: Commit message was updated."));
+  }
+
+  @Test
+  public void retrieveEdit() throws Exception {
+    adminRestSession.get(urlEdit(changeId)).assertNoContent();
+    createArbitraryEditFor(changeId);
+    EditInfo editInfo = getEditInfo(changeId, false);
+    ChangeInfo changeInfo = get(changeId, CURRENT_REVISION, CURRENT_COMMIT);
+    assertThat(editInfo.commit.commit).isNotEqualTo(changeInfo.currentRevision);
+    assertThat(editInfo).commit().parents().hasSize(1);
+    assertThat(editInfo).baseRevision().isEqualTo(changeInfo.currentRevision);
+
+    gApi.changes().id(changeId).edit().delete();
+
+    adminRestSession.get(urlEdit(changeId)).assertNoContent();
+  }
+
+  @Test
+  public void retrieveFilesInEdit() throws Exception {
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+
+    EditInfo info = getEditInfo(changeId, true);
+    assertThat(info.files).isNotNull();
+    assertThat(info.files.keySet()).containsExactly(Patch.COMMIT_MSG, FILE_NAME, FILE_NAME2);
+  }
+
+  @Test
+  public void deleteExistingFile() throws Exception {
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
+    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
+  }
+
+  @Test
+  public void renameExistingFile() throws Exception {
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, FILE_NAME3);
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_OLD);
+    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
+  }
+
+  @Test
+  public void createEditByDeletingExistingFileRest() throws Exception {
+    adminRestSession.delete(urlEditFile(changeId, FILE_NAME)).assertNoContent();
+    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
+  }
+
+  @Test
+  public void deletingNonExistingEditRest() throws Exception {
+    adminRestSession.delete(urlEdit(changeId)).assertNotFound();
+  }
+
+  @Test
+  public void deleteExistingFileRest() throws Exception {
+    createEmptyEditFor(changeId);
+    adminRestSession.delete(urlEditFile(changeId, FILE_NAME)).assertNoContent();
+    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
+  }
+
+  @Test
+  public void restoreDeletedFileInPatchSet() throws Exception {
+    createEmptyEditFor(changeId2);
+    gApi.changes().id(changeId2).edit().restoreFile(FILE_NAME);
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_OLD);
+  }
+
+  @Test
+  public void revertChanges() throws Exception {
+    createEmptyEditFor(changeId2);
+    gApi.changes().id(changeId2).edit().restoreFile(FILE_NAME);
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_OLD);
+    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
+    gApi.changes().id(changeId2).edit().restoreFile(FILE_NAME);
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_OLD);
+  }
+
+  @Test
+  public void renameFileRest() throws Exception {
+    createEmptyEditFor(changeId);
+    Post.Input in = new Post.Input();
+    in.oldPath = FILE_NAME;
+    in.newPath = FILE_NAME3;
+    adminRestSession.post(urlEdit(changeId), in).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_OLD);
+    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
+  }
+
+  @Test
+  public void restoreDeletedFileInPatchSetRest() throws Exception {
+    Post.Input in = new Post.Input();
+    in.restorePath = FILE_NAME;
+    adminRestSession.post(urlEdit(changeId2), in).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_OLD);
+  }
+
+  @Test
+  public void amendExistingFile() throws Exception {
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW2));
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW2);
+  }
+
+  @Test
+  public void createAndChangeEditInOneRequestRest() throws Exception {
+    Put.Input in = new Put.Input();
+    in.content = RawInputUtil.create(CONTENT_NEW);
+    adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
+    in.content = RawInputUtil.create(CONTENT_NEW2);
+    adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW2);
+  }
+
+  @Test
+  public void changeEditRest() throws Exception {
+    createEmptyEditFor(changeId);
+    Put.Input in = new Put.Input();
+    in.content = RawInputUtil.create(CONTENT_NEW);
+    adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
+  }
+
+  @Test
+  public void emptyPutRequest() throws Exception {
+    createEmptyEditFor(changeId);
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME)).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), "".getBytes(UTF_8));
+  }
+
+  @Test
+  public void createEmptyEditRest() throws Exception {
+    adminRestSession.post(urlEdit(changeId)).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_OLD);
+  }
+
+  @Test
+  public void getFileContentRest() throws Exception {
+    Put.Input in = new Put.Input();
+    in.content = RawInputUtil.create(CONTENT_NEW);
+    adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW2));
+    RestResponse r = adminRestSession.getJsonAccept(urlEditFile(changeId, FILE_NAME));
+    r.assertOK();
+    assertThat(readContentFromJson(r)).isEqualTo(new String(CONTENT_NEW2, UTF_8));
+
+    r = adminRestSession.getJsonAccept(urlEditFile(changeId, FILE_NAME, true));
+    r.assertOK();
+    assertThat(readContentFromJson(r)).isEqualTo(new String(CONTENT_OLD, UTF_8));
+  }
+
+  @Test
+  public void getFileNotFoundRest() throws Exception {
+    createEmptyEditFor(changeId);
+    adminRestSession.delete(urlEditFile(changeId, FILE_NAME)).assertNoContent();
+    adminRestSession.get(urlEditFile(changeId, FILE_NAME)).assertNoContent();
+    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
+  }
+
+  @Test
+  public void addNewFile() throws Exception {
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW);
+  }
+
+  @Test
+  public void addNewFileAndAmend() throws Exception {
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW2));
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW2);
+  }
+
+  @Test
+  public void writeNoChanges() throws Exception {
+    createEmptyEditFor(changeId);
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("no changes were made");
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_OLD));
+  }
+
+  @Test
+  public void editCommitMessageCopiesLabelScores() throws Exception {
+    String cr = "Code-Review";
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType codeReview = Util.codeReview();
+    codeReview.setCopyAllScoresIfNoCodeChange(true);
+    cfg.getLabelSections().put(cr, codeReview);
+    saveProjectConfig(project, cfg);
+
+    ReviewInput r = new ReviewInput();
+    r.labels = ImmutableMap.of(cr, (short) 1);
+    gApi.changes().id(changeId).current().review(r);
+
+    createEmptyEditFor(changeId);
+    String newSubj = "New commit message";
+    String newMsg = newSubj + "\n\nChange-Id: " + changeId + "\n";
+    gApi.changes().id(changeId).edit().modifyCommitMessage(newMsg);
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    publishInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(changeId).edit().publish(publishInput);
+
+    ChangeInfo info = get(changeId, DETAILED_LABELS);
+    assertThat(info.subject).isEqualTo(newSubj);
+    List<ApprovalInfo> approvals = info.labels.get(cr).all;
+    assertThat(approvals).hasSize(1);
+    assertThat(approvals.get(0).value).isEqualTo(1);
+  }
+
+  @Test
+  public void hasEditPredicate() throws Exception {
+    createEmptyEditFor(changeId);
+    assertThat(queryEdits()).hasSize(1);
+
+    createEmptyEditFor(changeId2);
+    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    assertThat(queryEdits()).hasSize(2);
+
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    gApi.changes().id(changeId).edit().delete();
+    assertThat(queryEdits()).hasSize(1);
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    publishInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(changeId2).edit().publish(publishInput);
+    assertThat(queryEdits()).isEmpty();
+
+    setApiUser(user);
+    createEmptyEditFor(changeId);
+    assertThat(queryEdits()).hasSize(1);
+
+    setApiUser(admin);
+    assertThat(queryEdits()).isEmpty();
+  }
+
+  @Test
+  public void files() throws Exception {
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    Optional<EditInfo> edit = getEdit(changeId);
+    assertThat(edit).isPresent();
+    String editCommitId = edit.get().commit.commit;
+
+    RestResponse r = adminRestSession.getJsonAccept(urlRevisionFiles(changeId, editCommitId));
+    Map<String, FileInfo> files = readContentFromJson(r, new TypeToken<Map<String, FileInfo>>() {});
+    assertThat(files).containsKey(FILE_NAME);
+
+    r = adminRestSession.getJsonAccept(urlRevisionFiles(changeId));
+    files = readContentFromJson(r, new TypeToken<Map<String, FileInfo>>() {});
+    assertThat(files).containsKey(FILE_NAME);
+  }
+
+  @Test
+  public void diff() throws Exception {
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    Optional<EditInfo> edit = getEdit(changeId);
+    assertThat(edit).isPresent();
+    String editCommitId = edit.get().commit.commit;
+
+    RestResponse r = adminRestSession.getJsonAccept(urlDiff(changeId, editCommitId, FILE_NAME));
+    DiffInfo diff = readContentFromJson(r, DiffInfo.class);
+    assertThat(diff.diffHeader.get(0)).contains(FILE_NAME);
+
+    r = adminRestSession.getJsonAccept(urlDiff(changeId, FILE_NAME));
+    diff = readContentFromJson(r, DiffInfo.class);
+    assertThat(diff.diffHeader.get(0)).contains(FILE_NAME);
+  }
+
+  @Test
+  public void createEditWithoutPushPatchSetPermission() throws Exception {
+    // Create new project with clean permissions
+    Project.NameKey p = createProject("addPatchSetEdit");
+    // Clone repository as user
+    TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
+
+    // Block default permission
+    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+
+    // Create change as user
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), userTestRepo);
+    PushOneCommit.Result r1 = push.to("refs/for/master");
+    r1.assertOkStatus();
+
+    // Try to create edit as admin
+    exception.expect(AuthException.class);
+    createEmptyEditFor(r1.getChangeId());
+  }
+
+  private void createArbitraryEditFor(String changeId) throws Exception {
+    createEmptyEditFor(changeId);
+    arbitrarilyModifyEditOf(changeId);
+  }
+
+  private void createEmptyEditFor(String changeId) throws Exception {
+    gApi.changes().id(changeId).edit().create();
+  }
+
+  private void arbitrarilyModifyEditOf(String changeId) throws Exception {
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+  }
+
+  private Optional<BinaryResult> getFileContentOfEdit(String changeId, String filePath)
+      throws Exception {
+    return gApi.changes().id(changeId).edit().getFile(filePath);
+  }
+
+  private List<ChangeInfo> queryEdits() throws Exception {
+    return query("project:{" + project.get() + "} has:edit");
+  }
+
+  private String newChange(PersonIdent ident) throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            db, ident, testRepo, PushOneCommit.SUBJECT, FILE_NAME, new String(CONTENT_OLD, UTF_8));
+    return push.to("refs/for/master").getChangeId();
+  }
+
+  private String amendChange(PersonIdent ident, String changeId) throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            ident,
+            testRepo,
+            PushOneCommit.SUBJECT,
+            FILE_NAME2,
+            new String(CONTENT_NEW2, UTF_8),
+            changeId);
+    return push.to("refs/for/master").getChangeId();
+  }
+
+  private String newChange2(PersonIdent ident) throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            db, ident, testRepo, PushOneCommit.SUBJECT, FILE_NAME, new String(CONTENT_OLD, UTF_8));
+    return push.rm("refs/for/master").getChangeId();
+  }
+
+  private PatchSet getCurrentPatchSet(String changeId) throws Exception {
+    return getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).currentPatchSet();
+  }
+
+  private void ensureSameBytes(Optional<BinaryResult> fileContent, byte[] expectedFileBytes)
+      throws IOException {
+    assertThat(fileContent).value().bytes().isEqualTo(expectedFileBytes);
+  }
+
+  private String urlEdit(String changeId) {
+    return "/changes/" + changeId + "/edit";
+  }
+
+  private String urlEditMessage(String changeId, boolean base) {
+    return "/changes/" + changeId + "/edit:message" + (base ? "?base" : "");
+  }
+
+  private String urlEditFile(String changeId, String fileName) {
+    return urlEditFile(changeId, fileName, false);
+  }
+
+  private String urlEditFile(String changeId, String fileName, boolean base) {
+    return urlEdit(changeId) + "/" + fileName + (base ? "?base" : "");
+  }
+
+  private String urlGetFiles(String changeId) {
+    return urlEdit(changeId) + "?list";
+  }
+
+  private String urlRevisionFiles(String changeId, String revisionId) {
+    return "/changes/" + changeId + "/revisions/" + revisionId + "/files";
+  }
+
+  private String urlRevisionFiles(String changeId) {
+    return "/changes/" + changeId + "/revisions/0/files";
+  }
+
+  private String urlPublish(String changeId) {
+    return "/changes/" + changeId + "/edit:publish";
+  }
+
+  private String urlRebase(String changeId) {
+    return "/changes/" + changeId + "/edit:rebase";
+  }
+
+  private String urlDiff(String changeId, String fileName) {
+    return "/changes/"
+        + changeId
+        + "/revisions/0/files/"
+        + fileName
+        + "/diff?context=ALL&intraline";
+  }
+
+  private String urlDiff(String changeId, String revisionId, String fileName) {
+    return "/changes/"
+        + changeId
+        + "/revisions/"
+        + revisionId
+        + "/files/"
+        + fileName
+        + "/diff?context=ALL&intraline";
+  }
+
+  private EditInfo getEditInfo(String changeId, boolean files) throws Exception {
+    RestResponse r = adminRestSession.get(files ? urlGetFiles(changeId) : urlEdit(changeId));
+    return readContentFromJson(r, EditInfo.class);
+  }
+
+  private <T> T readContentFromJson(RestResponse r, Class<T> clazz) throws Exception {
+    r.assertOK();
+    JsonReader jsonReader = new JsonReader(r.getReader());
+    jsonReader.setLenient(true);
+    return newGson().fromJson(jsonReader, clazz);
+  }
+
+  private <T> T readContentFromJson(RestResponse r, TypeToken<T> typeToken) throws Exception {
+    r.assertOK();
+    JsonReader jsonReader = new JsonReader(r.getReader());
+    jsonReader.setLenient(true);
+    return newGson().fromJson(jsonReader, typeToken.getType());
+  }
+
+  private String readContentFromJson(RestResponse r) throws Exception {
+    return readContentFromJson(r, String.class);
+  }
+
+  private void assertChangeMessages(String changeId, List<String> expectedMessages)
+      throws Exception {
+    ChangeInfo ci = get(changeId, MESSAGES);
+    assertThat(ci.messages).isNotNull();
+    assertThat(ci.messages).hasSize(expectedMessages.size());
+    List<String> actualMessages =
+        ci.messages.stream().map(message -> message.message).collect(toList());
+    assertThat(actualMessages).containsExactlyElementsIn(expectedMessages).inOrder();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
new file mode 100644
index 0000000..38a9489
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -0,0 +1,2162 @@
+// 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.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
+import static com.google.gerrit.server.git.receive.ReceiveConstants.PUSH_OPTION_SKIP_VALIDATION;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.project.testing.Util.category;
+import static com.google.gerrit.server.project.testing.Util.value;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.testing.EditInfoSubject;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.receive.ReceiveConstants;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public abstract class AbstractPushForReview extends AbstractDaemonTest {
+  protected enum Protocol {
+    // TODO(dborowitz): TEST.
+    SSH,
+    HTTP
+  }
+
+  private LabelType patchSetLock;
+
+  @BeforeClass
+  public static void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @AfterClass
+  public static void restoreTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    patchSetLock = Util.patchSetLock();
+    cfg.getLabelSections().put(patchSetLock.getName(), patchSetLock);
+    AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    Util.allow(
+        cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, anonymousUsers, "refs/heads/*");
+    saveProjectConfig(cfg);
+    grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    setApiUser(admin);
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    prefs.publishCommentsOnPush = false;
+    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
+  }
+
+  protected void selectProtocol(Protocol p) throws Exception {
+    String url;
+    switch (p) {
+      case SSH:
+        url = adminSshSession.getUrl();
+        break;
+      case HTTP:
+        url = admin.getHttpUrl(server);
+        break;
+      default:
+        throw new IllegalArgumentException("unexpected protocol: " + p);
+    }
+    testRepo = GitUtil.cloneProject(project, url + "/" + project.get());
+  }
+
+  @Test
+  public void pushForMaster() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, null);
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void pushInitialCommitForMasterBranch() throws Exception {
+    RevCommit c = testRepo.commit().message("Initial commit").insertChangeId().create();
+    String id = GitUtil.getChangeId(testRepo, c).get();
+    testRepo.reset(c);
+
+    String r = "refs/for/master";
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    ChangeInfo change = gApi.changes().id(id).info();
+    assertThat(change.branch).isEqualTo("master");
+    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.resolve("master")).isNull();
+    }
+
+    gApi.changes().id(change.id).current().review(ReviewInput.approve());
+    gApi.changes().id(change.id).current().submit();
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.resolve("master")).isEqualTo(c);
+    }
+  }
+
+  @Test
+  public void pushInitialCommitForRefsMetaConfigBranch() throws Exception {
+    // delete refs/meta/config
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
+      u.setForceUpdate(true);
+      u.setExpectedOldObjectId(repo.resolve(RefNames.REFS_CONFIG));
+      assertThat(u.delete(rw)).isEqualTo(Result.FORCED);
+    }
+
+    RevCommit c =
+        testRepo
+            .commit()
+            .message("Initial commit")
+            .author(admin.getIdent())
+            .committer(admin.getIdent())
+            .insertChangeId()
+            .create();
+    String id = GitUtil.getChangeId(testRepo, c).get();
+    testRepo.reset(c);
+
+    String r = "refs/for/" + RefNames.REFS_CONFIG;
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    ChangeInfo change = gApi.changes().id(id).info();
+    assertThat(change.branch).isEqualTo(RefNames.REFS_CONFIG);
+    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.resolve(RefNames.REFS_CONFIG)).isNull();
+    }
+
+    gApi.changes().id(change.id).current().review(ReviewInput.approve());
+    gApi.changes().id(change.id).current().submit();
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.resolve(RefNames.REFS_CONFIG)).isEqualTo(c);
+    }
+  }
+
+  @Test
+  public void pushInitialCommitForNormalNonExistingBranchFails() throws Exception {
+    RevCommit c =
+        testRepo
+            .commit()
+            .message("Initial commit")
+            .author(admin.getIdent())
+            .committer(admin.getIdent())
+            .insertChangeId()
+            .create();
+    testRepo.reset(c);
+
+    String r = "refs/for/foo";
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushRejected(pr, r, "branch foo not found");
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.resolve("foo")).isNull();
+    }
+  }
+
+  @Test
+  public void output() throws Exception {
+    String url = canonicalWebUrl.get() + "#/c/" + project.get() + "/+/";
+    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
+    PushOneCommit.Result r1 = pushTo("refs/for/master");
+    Change.Id id1 = r1.getChange().getId();
+    r1.assertOkStatus();
+    r1.assertChange(Change.Status.NEW, null);
+    r1.assertMessage(
+        "New changes:\n  " + url + id1 + " " + r1.getCommit().getShortMessage() + "\n");
+
+    testRepo.reset(initialHead);
+    String newMsg = r1.getCommit().getShortMessage() + " v2";
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .message(newMsg)
+        .insertChangeId(r1.getChangeId().substring(1))
+        .create();
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "another commit", "b.txt", "bbb")
+            .to("refs/for/master");
+    Change.Id id2 = r2.getChange().getId();
+    r2.assertOkStatus();
+    r2.assertChange(Change.Status.NEW, null);
+    r2.assertMessage(
+        "New changes:\n"
+            + "  "
+            + url
+            + id2
+            + " another commit\n"
+            + "\n"
+            + "\n"
+            + "Updated changes:\n"
+            + "  "
+            + url
+            + id1
+            + " "
+            + newMsg
+            + "\n");
+  }
+
+  @Test
+  public void autocloseByCommit() throws Exception {
+    // Create a change
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+
+    // Force push it, closing it
+    String master = "refs/heads/master";
+    assertPushOk(pushHead(testRepo, master, false), master);
+
+    // Attempt to push amended commit to same change
+    String url = canonicalWebUrl.get() + "#/c/" + project.get() + "/+/" + r.getChange().getId();
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertErrorStatus("change " + url + " closed");
+
+    // Check change message that was added on auto-close
+    ChangeInfo change = gApi.changes().id(r.getChange().getId().get()).get();
+    assertThat(Iterables.getLast(change.messages).message)
+        .isEqualTo("Change has been successfully pushed.");
+  }
+
+  @Test
+  public void autocloseByChangeId() throws Exception {
+    // Create a change
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+
+    // Amend the commit locally
+    RevCommit c = testRepo.amend(r.getCommit()).create();
+    assertThat(c).isNotEqualTo(r.getCommit());
+    testRepo.reset(c);
+
+    // Force push it, closing it
+    String master = "refs/heads/master";
+    assertPushOk(pushHead(testRepo, master, false), master);
+
+    // Attempt to push amended commit to same change
+    String url = canonicalWebUrl.get() + "#/c/" + project.get() + "/+/" + r.getChange().getId();
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertErrorStatus("change " + url + " closed");
+
+    // Check that new commit was added as patch set
+    ChangeInfo change = gApi.changes().id(r.getChange().getId().get()).get();
+    assertThat(change.revisions).hasSize(2);
+    assertThat(change.currentRevision).isEqualTo(c.name());
+  }
+
+  @Test
+  public void pushForMasterWithTopic() throws Exception {
+    // specify topic in ref
+    String topic = "my/topic";
+    PushOneCommit.Result r = pushTo("refs/for/master/" + topic);
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, topic);
+
+    // specify topic as option
+    r = pushTo("refs/for/master%topic=" + topic);
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, topic);
+  }
+
+  @Test
+  public void pushForMasterWithTopicOption() throws Exception {
+    String topicOption = "topic=myTopic";
+    List<String> pushOptions = new ArrayList<>();
+    pushOptions.add(topicOption);
+
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(pushOptions);
+    PushOneCommit.Result r = push.to("refs/for/master");
+
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, "myTopic");
+    r.assertPushOptions(pushOptions);
+  }
+
+  @Test
+  public void pushForMasterWithTopicInRefExceedLimitFails() throws Exception {
+    String topic = Stream.generate(() -> "t").limit(2049).collect(joining());
+    PushOneCommit.Result r = pushTo("refs/for/master/" + topic);
+    r.assertErrorStatus("topic length exceeds the limit (2048)");
+  }
+
+  @Test
+  public void pushForMasterWithTopicAsOptionExceedLimitFails() throws Exception {
+    String topic = Stream.generate(() -> "t").limit(2049).collect(joining());
+    PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
+    r.assertErrorStatus("topic length exceeds the limit (2048)");
+  }
+
+  @Test
+  public void pushForMasterWithNotify() throws Exception {
+    // create a user that watches the project
+    TestAccount user3 = accountCreator.create("user3", "user3@example.com", "User3");
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = project.get();
+    pwi.filter = "*";
+    pwi.notifyNewChanges = true;
+    projectsToWatch.add(pwi);
+    setApiUser(user3);
+    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+
+    TestAccount user2 = accountCreator.user2();
+    String pushSpec = "refs/for/master%reviewer=" + user.email + ",cc=" + user2.email;
+
+    sender.clear();
+    PushOneCommit.Result r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE);
+    r.assertOkStatus();
+    assertThat(sender.getMessages()).isEmpty();
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.OWNER);
+    r.assertOkStatus();
+    // no email notification about own changes
+    assertThat(sender.getMessages()).isEmpty();
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.OWNER_REVIEWERS);
+    r.assertOkStatus();
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.ALL);
+    r.assertOkStatus();
+    assertThat(sender.getMessages()).hasSize(1);
+    m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress, user2.emailAddress, user3.emailAddress);
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-to=" + user3.email);
+    r.assertOkStatus();
+    assertNotifyTo(user3);
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-cc=" + user3.email);
+    r.assertOkStatus();
+    assertNotifyCc(user3);
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-bcc=" + user3.email);
+    r.assertOkStatus();
+    assertNotifyBcc(user3);
+
+    // request that sender gets notified as TO, CC and BCC, email should be sent
+    // even if the sender is the only recipient
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-to=" + admin.email);
+    assertNotifyTo(admin);
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-cc=" + admin.email);
+    r.assertOkStatus();
+    assertNotifyCc(admin);
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-bcc=" + admin.email);
+    r.assertOkStatus();
+    assertNotifyBcc(admin);
+  }
+
+  @Test
+  public void pushForMasterWithCc() throws Exception {
+    // cc one user
+    String topic = "my/topic";
+    PushOneCommit.Result r = pushTo("refs/for/master/" + topic + "%cc=" + user.email);
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, topic, ImmutableList.of(), ImmutableList.of(user));
+
+    // cc several users
+    r =
+        pushTo(
+            "refs/for/master/"
+                + topic
+                + "%cc="
+                + admin.email
+                + ",cc="
+                + user.email
+                + ",cc="
+                + accountCreator.user2().email);
+    r.assertOkStatus();
+    // Check that admin isn't CC'd as they own the change
+    r.assertChange(
+        Change.Status.NEW,
+        topic,
+        ImmutableList.of(),
+        ImmutableList.of(user, accountCreator.user2()));
+
+    // cc non-existing user
+    String nonExistingEmail = "non.existing@example.com";
+    r =
+        pushTo(
+            "refs/for/master/"
+                + topic
+                + "%cc="
+                + admin.email
+                + ",cc="
+                + nonExistingEmail
+                + ",cc="
+                + user.email);
+    r.assertErrorStatus("user \"" + nonExistingEmail + "\" not found");
+  }
+
+  @Test
+  public void pushForMasterWithReviewer() throws Exception {
+    // add one reviewer
+    String topic = "my/topic";
+    PushOneCommit.Result r = pushTo("refs/for/master/" + topic + "%r=" + user.email);
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, topic, user);
+
+    // add several reviewers
+    TestAccount user2 =
+        accountCreator.create("another-user", "another.user@example.com", "Another User");
+    r =
+        pushTo(
+            "refs/for/master/"
+                + topic
+                + "%r="
+                + admin.email
+                + ",r="
+                + user.email
+                + ",r="
+                + user2.email);
+    r.assertOkStatus();
+    // admin is the owner of the change and should not appear as reviewer
+    r.assertChange(Change.Status.NEW, topic, user, user2);
+
+    // add non-existing user as reviewer
+    String nonExistingEmail = "non.existing@example.com";
+    r =
+        pushTo(
+            "refs/for/master/"
+                + topic
+                + "%r="
+                + admin.email
+                + ",r="
+                + nonExistingEmail
+                + ",r="
+                + user.email);
+    r.assertErrorStatus("user \"" + nonExistingEmail + "\" not found");
+  }
+
+  @Test
+  public void pushPrivateChange() throws Exception {
+    // Push a private change.
+    PushOneCommit.Result r = pushTo("refs/for/master%private");
+    r.assertOkStatus();
+    r.assertMessage(" [PRIVATE]");
+    assertThat(r.getChange().change().isPrivate()).isTrue();
+
+    // Pushing a new patch set without --private doesn't remove the privacy flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    r.assertMessage(" [PRIVATE]");
+    assertThat(r.getChange().change().isPrivate()).isTrue();
+
+    // Remove the privacy flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master%remove-private");
+    r.assertOkStatus();
+    r.assertNotMessage(" [PRIVATE]");
+    assertThat(r.getChange().change().isPrivate()).isFalse();
+
+    // Normal push: privacy flag is not added back.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    r.assertNotMessage(" [PRIVATE]");
+    assertThat(r.getChange().change().isPrivate()).isFalse();
+
+    // Make the change private again.
+    r = pushTo("refs/for/master%private");
+    r.assertOkStatus();
+    r.assertMessage(" [PRIVATE]");
+    assertThat(r.getChange().change().isPrivate()).isTrue();
+
+    // Can't use --private and --remove-private together.
+    r = pushTo("refs/for/master%private,remove-private");
+    r.assertErrorStatus();
+  }
+
+  @Test
+  public void pushWorkInProgressChange() throws Exception {
+    // Push a work-in-progress change.
+    PushOneCommit.Result r = pushTo("refs/for/master%wip");
+    r.assertOkStatus();
+    r.assertMessage(" [WIP]");
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
+
+    // Pushing a new patch set without --wip doesn't remove the wip flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    r.assertMessage(" [WIP]");
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
+
+    // Remove the wip flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master%ready");
+    r.assertOkStatus();
+    r.assertNotMessage(" [WIP]");
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+
+    // Normal push: wip flag is not added back.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    r.assertNotMessage(" [WIP]");
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+
+    // Make the change work-in-progress again.
+    r = amendChange(r.getChangeId(), "refs/for/master%wip");
+    r.assertOkStatus();
+    r.assertMessage(" [WIP]");
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
+
+    // Can't use --wip and --ready together.
+    r = amendChange(r.getChangeId(), "refs/for/master%wip,ready");
+    r.assertErrorStatus();
+  }
+
+  private void assertUploadTag(ChangeData cd, String expectedTag) throws Exception {
+    List<ChangeMessage> msgs = cd.messages();
+    assertThat(msgs).isNotEmpty();
+    assertThat(Iterables.getLast(msgs).getTag()).isEqualTo(expectedTag);
+  }
+
+  @Test
+  public void pushWorkInProgressChangeWhenNotOwner() throws Exception {
+    TestRepository<?> userRepo = cloneProject(project, user);
+    PushOneCommit.Result r =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master%wip");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().getOwner()).isEqualTo(user.id);
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+
+    // Other user trying to move from WIP to ready should fail.
+    GitUtil.fetch(testRepo, r.getPatchSet().getRefName() + ":ps");
+    testRepo.reset("ps");
+    r = amendChange(r.getChangeId(), "refs/for/master%ready", admin, testRepo);
+    r.assertErrorStatus(ReceiveConstants.ONLY_OWNER_CAN_MODIFY_WIP);
+
+    // Other user trying to move from WIP to WIP should succeed.
+    r = amendChange(r.getChangeId(), "refs/for/master%wip", admin, testRepo);
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+
+    // Push as change owner to move change from WIP to ready.
+    r = pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master%ready");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+
+    // Other user trying to move from ready to WIP should fail.
+    GitUtil.fetch(testRepo, r.getPatchSet().getRefName() + ":ps");
+    testRepo.reset("ps");
+    r = amendChange(r.getChangeId(), "refs/for/master%wip", admin, testRepo);
+    r.assertErrorStatus(ReceiveConstants.ONLY_OWNER_CAN_MODIFY_WIP);
+
+    // Other user trying to move from ready to ready should succeed.
+    r = amendChange(r.getChangeId(), "refs/for/master%ready", admin, testRepo);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void pushForMasterAsEdit() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    Optional<EditInfo> edit = getEdit(r.getChangeId());
+    assertThat(edit).isAbsent();
+    assertThat(query("has:edit")).isEmpty();
+
+    // specify edit as option
+    r = amendChange(r.getChangeId(), "refs/for/master%edit");
+    r.assertOkStatus();
+    edit = getEdit(r.getChangeId());
+    assertThat(edit).isPresent();
+    EditInfo editInfo = edit.get();
+    r.assertMessage(
+        "Updated Changes:\n  "
+            + canonicalWebUrl.get()
+            + "#/c/"
+            + project.get()
+            + "/+/"
+            + r.getChange().getId()
+            + " "
+            + editInfo.commit.subject
+            + " [EDIT]\n");
+
+    // verify that the re-indexing was triggered for the change
+    assertThat(query("has:edit")).hasSize(1);
+  }
+
+  @Test
+  public void pushForMasterWithMessage() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master/%m=my_test_message");
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, null);
+    ChangeInfo ci = get(r.getChangeId(), MESSAGES, ALL_REVISIONS);
+    Collection<ChangeMessageInfo> changeMessages = ci.messages;
+    assertThat(changeMessages).hasSize(1);
+    for (ChangeMessageInfo cm : changeMessages) {
+      assertThat(cm.message).isEqualTo("Uploaded patch set 1.\nmy test message");
+    }
+    Collection<RevisionInfo> revisions = ci.revisions.values();
+    assertThat(revisions).hasSize(1);
+    for (RevisionInfo ri : revisions) {
+      assertThat(ri.description).isEqualTo("my test message");
+    }
+  }
+
+  @Test
+  public void pushForMasterWithMessageTwiceWithDifferentMessages() throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+    // %2C is comma; the value below tests that percent decoding happens after splitting.
+    // All three ways of representing space ("%20", "+", and "_" are also exercised.
+    PushOneCommit.Result r = push.to("refs/for/master/%m=my_test%20+_message%2Cm=");
+    r.assertOkStatus();
+
+    push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
+    r = push.to("refs/for/master/%m=new_test_message");
+    r.assertOkStatus();
+
+    ChangeInfo ci = get(r.getChangeId(), ALL_REVISIONS);
+    Collection<RevisionInfo> revisions = ci.revisions.values();
+    assertThat(revisions).hasSize(2);
+    for (RevisionInfo ri : revisions) {
+      if (ri.isCurrent) {
+        assertThat(ri.description).isEqualTo("new test message");
+      } else {
+        assertThat(ri.description).isEqualTo("my test   message,m=");
+      }
+    }
+  }
+
+  @Test
+  public void pushForMasterWithPercentEncodedMessage() throws Exception {
+    // Exercise percent-encoding of UTF-8, underscores, and patterns reserved by git-rev-parse.
+    PushOneCommit.Result r =
+        pushTo(
+            "refs/for/master/%m="
+                + "Punctu%2E%2e%2Eation%7E%2D%40%7Bu%7D%20%7C%20%28%E2%95%AF%C2%B0%E2%96%A1%C2%B0"
+                + "%EF%BC%89%E2%95%AF%EF%B8%B5%20%E2%94%BB%E2%94%81%E2%94%BB%20%5E%5F%5E");
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, null);
+    ChangeInfo ci = get(r.getChangeId(), MESSAGES, ALL_REVISIONS);
+    Collection<ChangeMessageInfo> changeMessages = ci.messages;
+    assertThat(changeMessages).hasSize(1);
+    for (ChangeMessageInfo cm : changeMessages) {
+      assertThat(cm.message)
+          .isEqualTo("Uploaded patch set 1.\nPunctu...ation~-@{u} | (╯°□°)╯︵ ┻━┻ ^_^");
+    }
+    Collection<RevisionInfo> revisions = ci.revisions.values();
+    assertThat(revisions).hasSize(1);
+    for (RevisionInfo ri : revisions) {
+      assertThat(ri.description).isEqualTo("Punctu...ation~-@{u} | (╯°□°)╯︵ ┻━┻ ^_^");
+    }
+  }
+
+  @Test
+  public void pushForMasterWithInvalidPercentEncodedMessage() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master/%m=not_percent_decodable_%%oops%20");
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, null);
+    ChangeInfo ci = get(r.getChangeId(), MESSAGES, ALL_REVISIONS);
+    Collection<ChangeMessageInfo> changeMessages = ci.messages;
+    assertThat(changeMessages).hasSize(1);
+    for (ChangeMessageInfo cm : changeMessages) {
+      assertThat(cm.message).isEqualTo("Uploaded patch set 1.\nnot percent decodable %%oops%20");
+    }
+    Collection<RevisionInfo> revisions = ci.revisions.values();
+    assertThat(revisions).hasSize(1);
+    for (RevisionInfo ri : revisions) {
+      assertThat(ri.description).isEqualTo("not percent decodable %%oops%20");
+    }
+  }
+
+  @Test
+  public void pushForMasterWithApprovals() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master/%l=Code-Review");
+    r.assertOkStatus();
+    ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
+    LabelInfo cr = ci.labels.get("Code-Review");
+    assertThat(cr.all).hasSize(1);
+    assertThat(cr.all.get(0).name).isEqualTo("Administrator");
+    assertThat(cr.all.get(0).value).isEqualTo(1);
+    assertThat(Iterables.getLast(ci.messages).message)
+        .isEqualTo("Uploaded patch set 1: Code-Review+1.");
+
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
+    r = push.to("refs/for/master/%l=Code-Review+2");
+
+    ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
+    cr = ci.labels.get("Code-Review");
+    assertThat(Iterables.getLast(ci.messages).message)
+        .isEqualTo("Uploaded patch set 2: Code-Review+2.");
+    // Check that the user who pushed the change was added as a reviewer since they added a vote
+    assertThatUserIsOnlyReviewer(ci, admin);
+
+    assertThat(cr.all).hasSize(1);
+    assertThat(cr.all.get(0).name).isEqualTo("Administrator");
+    assertThat(cr.all.get(0).value).isEqualTo(2);
+
+    push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "c.txt",
+            "moreContent",
+            r.getChangeId());
+    r = push.to("refs/for/master/%l=Code-Review+2");
+    ci = get(r.getChangeId(), MESSAGES);
+    assertThat(Iterables.getLast(ci.messages).message).isEqualTo("Uploaded patch set 3.");
+  }
+
+  @Test
+  public void pushNewPatchSetForMasterWithApprovals() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
+    r = push.to("refs/for/master/%l=Code-Review+2");
+
+    ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
+    LabelInfo cr = ci.labels.get("Code-Review");
+    assertThat(Iterables.getLast(ci.messages).message)
+        .isEqualTo("Uploaded patch set 2: Code-Review+2.");
+
+    // Check that the user who pushed the new patch set was added as a reviewer since they added
+    // a vote
+    assertThatUserIsOnlyReviewer(ci, admin);
+
+    assertThat(cr.all).hasSize(1);
+    assertThat(cr.all.get(0).name).isEqualTo("Administrator");
+    assertThat(cr.all.get(0).value).isEqualTo(2);
+  }
+
+  /**
+   * There was a bug that allowed a user with Forge Committer Identity access right to upload a
+   * commit and put *votes on behalf of another user* on it. This test checks that this is not
+   * possible, but that the votes that are specified on push are applied only on behalf of the
+   * uploader.
+   *
+   * <p>This particular bug only occurred when there was more than one label defined. However to
+   * test that the votes that are specified on push are applied on behalf of the uploader a single
+   * label is sufficient.
+   */
+  @Test
+  public void pushForMasterWithApprovalsForgeCommitterButNoForgeVote() throws Exception {
+    // Create a commit with "User" as author and committer
+    RevCommit c =
+        commitBuilder()
+            .author(user.getIdent())
+            .committer(user.getIdent())
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
+
+    // Push this commit as "Administrator" (requires Forge Committer Identity)
+    pushHead(testRepo, "refs/for/master/%l=Code-Review+1", false);
+
+    // Expected Code-Review votes:
+    // 1. 0 from User (committer):
+    //    When the committer is forged, the committer is automatically added as
+    //    reviewer, hence we expect a dummy 0 vote for the committer.
+    // 2. +1 from Administrator (uploader):
+    //    On push Code-Review+1 was specified, hence we expect a +1 vote from
+    //    the uploader.
+    ChangeInfo ci =
+        get(GitUtil.getChangeId(testRepo, c).get(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
+    LabelInfo cr = ci.labels.get("Code-Review");
+    assertThat(cr.all).hasSize(2);
+    int indexAdmin = admin.fullName.equals(cr.all.get(0).name) ? 0 : 1;
+    int indexUser = indexAdmin == 0 ? 1 : 0;
+    assertThat(cr.all.get(indexAdmin).name).isEqualTo(admin.fullName);
+    assertThat(cr.all.get(indexAdmin).value.intValue()).isEqualTo(1);
+    assertThat(cr.all.get(indexUser).name).isEqualTo(user.fullName);
+    assertThat(cr.all.get(indexUser).value.intValue()).isEqualTo(0);
+    assertThat(Iterables.getLast(ci.messages).message)
+        .isEqualTo("Uploaded patch set 1: Code-Review+1.");
+    // Check that the user who pushed the change was added as a reviewer since they added a vote
+    assertThatUserIsOnlyReviewer(ci, admin);
+  }
+
+  @Test
+  public void pushWithMultipleApprovals() throws Exception {
+    LabelType Q =
+        category("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    String heads = "refs/heads/*";
+    Util.allow(config, Permission.forLabel("Custom-Label"), -1, 1, anon, heads);
+    config.getLabelSections().put(Q.getName(), Q);
+    saveProjectConfig(project, config);
+
+    RevCommit c =
+        commitBuilder()
+            .author(admin.getIdent())
+            .committer(admin.getIdent())
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
+
+    pushHead(testRepo, "refs/for/master/%l=Code-Review+1,l=Custom-Label-1", false);
+
+    ChangeInfo ci = get(GitUtil.getChangeId(testRepo, c).get(), DETAILED_LABELS, DETAILED_ACCOUNTS);
+    LabelInfo cr = ci.labels.get("Code-Review");
+    assertThat(cr.all).hasSize(1);
+    cr = ci.labels.get("Custom-Label");
+    assertThat(cr.all).hasSize(1);
+    // Check that the user who pushed the change was added as a reviewer since they added a vote
+    assertThatUserIsOnlyReviewer(ci, admin);
+  }
+
+  @Test
+  public void pushNewPatchsetToRefsChanges() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
+    r = push.to("refs/changes/" + r.getChange().change().getId().get());
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void pushNewPatchsetToPatchSetLockedChange() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
+    revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
+    r = push.to("refs/for/master");
+    r.assertErrorStatus("cannot add patch set to " + r.getChange().change().getChangeId() + ".");
+  }
+
+  @Test
+  public void pushForMasterWithApprovals_MissingLabel() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master/%l=Verify");
+    r.assertErrorStatus("label \"Verify\" is not a configured label");
+  }
+
+  @Test
+  public void pushForMasterWithApprovals_ValueOutOfRange() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master/%l=Code-Review-3");
+    r.assertErrorStatus("label \"Code-Review\": -3 is not a valid value");
+  }
+
+  @Test
+  public void pushForNonExistingBranch() throws Exception {
+    String branchName = "non-existing";
+    PushOneCommit.Result r = pushTo("refs/for/" + branchName);
+    r.assertErrorStatus("branch " + branchName + " not found");
+  }
+
+  @Test
+  public void pushForMasterWithHashtags() throws Exception {
+    // Hashtags only work when reading from NoteDB is enabled
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    // specify a single hashtag as option
+    String hashtag1 = "tag1";
+    Set<String> expected = ImmutableSet.of(hashtag1);
+    PushOneCommit.Result r = pushTo("refs/for/master%hashtag=#" + hashtag1);
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, null);
+
+    Set<String> hashtags = gApi.changes().id(r.getChangeId()).getHashtags();
+    assertThat(hashtags).containsExactlyElementsIn(expected);
+
+    // specify a single hashtag as option in new patch set
+    String hashtag2 = "tag2";
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
+    r = push.to("refs/for/master/%hashtag=" + hashtag2);
+    r.assertOkStatus();
+    expected = ImmutableSet.of(hashtag1, hashtag2);
+    hashtags = gApi.changes().id(r.getChangeId()).getHashtags();
+    assertThat(hashtags).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void pushForMasterWithMultipleHashtags() throws Exception {
+    // Hashtags only work when reading from NoteDB is enabled
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    // specify multiple hashtags as options
+    String hashtag1 = "tag1";
+    String hashtag2 = "tag2";
+    Set<String> expected = ImmutableSet.of(hashtag1, hashtag2);
+    PushOneCommit.Result r =
+        pushTo("refs/for/master%hashtag=#" + hashtag1 + ",hashtag=##" + hashtag2);
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, null);
+
+    Set<String> hashtags = gApi.changes().id(r.getChangeId()).getHashtags();
+    assertThat(hashtags).containsExactlyElementsIn(expected);
+
+    // specify multiple hashtags as options in new patch set
+    String hashtag3 = "tag3";
+    String hashtag4 = "tag4";
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
+    r = push.to("refs/for/master%hashtag=" + hashtag3 + ",hashtag=" + hashtag4);
+    r.assertOkStatus();
+    expected = ImmutableSet.of(hashtag1, hashtag2, hashtag3, hashtag4);
+    hashtags = gApi.changes().id(r.getChangeId()).getHashtags();
+    assertThat(hashtags).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void pushForMasterWithHashtagsNoteDbDisabled() throws Exception {
+    // Push with hashtags should fail when reading from NoteDb is disabled.
+    assume().that(notesMigration.readChanges()).isFalse();
+    PushOneCommit.Result r = pushTo("refs/for/master%hashtag=tag1");
+    r.assertErrorStatus("cannot add hashtags; noteDb is disabled");
+  }
+
+  @Test
+  public void pushCommitUsingSignedOffBy() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    setUseSignedOffBy(InheritableBoolean.TRUE);
+    blockForgeCommitter(project, "refs/heads/master");
+
+    push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT
+                + String.format("\n\nSigned-off-by: %s <%s>", admin.fullName, admin.email),
+            "b.txt",
+            "anotherContent");
+    r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+    r = push.to("refs/for/master");
+    r.assertErrorStatus("not Signed-off-by author/committer/uploader in commit message footer");
+  }
+
+  @Test
+  public void createNewChangeForAllNotInTarget() throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+    r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    gApi.projects().name(project.get()).branch("otherBranch").create(new BranchInput());
+
+    PushOneCommit.Result r2 = push.to("refs/for/otherBranch");
+    r2.assertOkStatus();
+    assertTwoChangesWithSameRevision(r);
+  }
+
+  @Test
+  public void pushChangeBasedOnChangeOfOtherUserWithCreateNewChangeForAllNotInTarget()
+      throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+
+    // create a change as admin
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    RevCommit commitChange1 = r.getCommit();
+
+    // create a second change as user (depends on the change from admin)
+    TestRepository<?> userRepo = cloneProject(project, user);
+    GitUtil.fetch(userRepo, r.getPatchSet().getRefName() + ":change");
+    userRepo.reset("change");
+    push =
+        pushFactory.create(
+            db, user.getIdent(), userRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+    r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert that no new change was created for the commit of the predecessor change
+    assertThat(query(commitChange1.name())).hasSize(1);
+  }
+
+  @Test
+  public void pushSameCommitTwiceUsingMagicBranchBaseOption() throws Exception {
+    grant(project, "refs/heads/master", Permission.PUSH);
+    PushOneCommit.Result rBase = pushTo("refs/heads/master");
+    rBase.assertOkStatus();
+
+    gApi.projects().name(project.get()).branch("foo").create(new BranchInput());
+
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    PushResult pr =
+        GitUtil.pushHead(testRepo, "refs/for/foo%base=" + rBase.getCommit().name(), false, false);
+
+    // BatchUpdate implementations differ in how they hook into progress monitors. We mostly just
+    // care that there is a new change.
+    assertThat(pr.getMessages()).containsMatch("changes: new: 1,( refs: 1)? done");
+    assertTwoChangesWithSameRevision(r);
+  }
+
+  @Test
+  public void pushSameCommitTwice() throws Exception {
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config
+        .getProject()
+        .setBooleanConfig(
+            BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET, InheritableBoolean.TRUE);
+    saveProjectConfig(project, config);
+
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+    r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    assertPushRejected(
+        pushHead(testRepo, "refs/for/master", false),
+        "refs/for/master",
+        "commit(s) already exists (as current patchset)");
+  }
+
+  @Test
+  public void pushSameCommitTwiceWhenIndexFailed() throws Exception {
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config
+        .getProject()
+        .setBooleanConfig(
+            BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET, InheritableBoolean.TRUE);
+    saveProjectConfig(project, config);
+
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+    r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    indexer.delete(r.getChange().getId());
+
+    assertPushRejected(
+        pushHead(testRepo, "refs/for/master", false),
+        "refs/for/master",
+        "commit(s) already exists (as current patchset)");
+  }
+
+  private void assertTwoChangesWithSameRevision(PushOneCommit.Result result) throws Exception {
+    List<ChangeInfo> changes = query(result.getCommit().name());
+    assertThat(changes).hasSize(2);
+    ChangeInfo c1 = get(changes.get(0).id, CURRENT_REVISION);
+    ChangeInfo c2 = get(changes.get(1).id, CURRENT_REVISION);
+    assertThat(c1.project).isEqualTo(c2.project);
+    assertThat(c1.branch).isNotEqualTo(c2.branch);
+    assertThat(c1.changeId).isEqualTo(c2.changeId);
+    assertThat(c1.currentRevision).isEqualTo(c2.currentRevision);
+  }
+
+  @Test
+  public void pushAFewChanges() throws Exception {
+    testPushAFewChanges();
+  }
+
+  @Test
+  public void pushAFewChangesWithCreateNewChangeForAllNotInTarget() throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+    testPushAFewChanges();
+  }
+
+  private void testPushAFewChanges() throws Exception {
+    int n = 10;
+    String r = "refs/for/master";
+    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
+    List<RevCommit> commits = createChanges(n, r);
+
+    // Check that a change was created for each.
+    for (RevCommit c : commits) {
+      assertThat(byCommit(c).change().getSubject())
+          .named("change for " + c.name())
+          .isEqualTo(c.getShortMessage());
+    }
+
+    List<RevCommit> commits2 = amendChanges(initialHead, commits, r);
+
+    // Check that there are correct patch sets.
+    for (int i = 0; i < n; i++) {
+      RevCommit c = commits.get(i);
+      RevCommit c2 = commits2.get(i);
+      String name = "change for " + c2.name();
+      ChangeData cd = byCommit(c);
+      assertThat(cd.change().getSubject()).named(name).isEqualTo(c2.getShortMessage());
+      assertThat(getPatchSetRevisions(cd))
+          .named(name)
+          .containsExactlyEntriesIn(ImmutableMap.of(1, c.name(), 2, c2.name()));
+    }
+
+    // Pushing again results in "no new changes".
+    assertPushRejected(pushHead(testRepo, r, false), r, "no new changes");
+  }
+
+  @Test
+  public void pushWithoutChangeId() throws Exception {
+    testPushWithoutChangeId();
+  }
+
+  @Test
+  public void pushWithoutChangeIdWithCreateNewChangeForAllNotInTarget() throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+    testPushWithoutChangeId();
+  }
+
+  private void testPushWithoutChangeId() throws Exception {
+    RevCommit c = createCommit(testRepo, "Message without Change-Id");
+    assertThat(GitUtil.getChangeId(testRepo, c)).isEmpty();
+    pushForReviewRejected(testRepo, "missing Change-Id in commit message footer");
+
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config
+        .getProject()
+        .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
+    saveProjectConfig(project, config);
+    pushForReviewOk(testRepo);
+  }
+
+  @Test
+  public void pushWithMultipleChangeIds() throws Exception {
+    testPushWithMultipleChangeIds();
+  }
+
+  @Test
+  public void pushWithMultipleChangeIdsWithCreateNewChangeForAllNotInTarget() throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+    testPushWithMultipleChangeIds();
+  }
+
+  private void testPushWithMultipleChangeIds() throws Exception {
+    createCommit(
+        testRepo,
+        "Message with multiple Change-Id\n"
+            + "\n"
+            + "Change-Id: I10f98c2ef76e52e23aa23be5afeb71e40b350e86\n"
+            + "Change-Id: Ie9a132e107def33bdd513b7854b50de911edba0a\n");
+    pushForReviewRejected(testRepo, "multiple Change-Id lines in commit message footer");
+
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config
+        .getProject()
+        .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
+    saveProjectConfig(project, config);
+    pushForReviewRejected(testRepo, "multiple Change-Id lines in commit message footer");
+  }
+
+  @Test
+  public void pushWithInvalidChangeId() throws Exception {
+    testpushWithInvalidChangeId();
+  }
+
+  @Test
+  public void pushWithInvalidChangeIdWithCreateNewChangeForAllNotInTarget() throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+    testpushWithInvalidChangeId();
+  }
+
+  private void testpushWithInvalidChangeId() throws Exception {
+    createCommit(testRepo, "Message with invalid Change-Id\n\nChange-Id: X\n");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config
+        .getProject()
+        .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
+    saveProjectConfig(project, config);
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+  }
+
+  @Test
+  public void pushWithInvalidChangeIdFromEgit() throws Exception {
+    testPushWithInvalidChangeIdFromEgit();
+  }
+
+  @Test
+  public void pushWithInvalidChangeIdFromEgitWithCreateNewChangeForAllNotInTarget()
+      throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+    testPushWithInvalidChangeIdFromEgit();
+  }
+
+  private void testPushWithInvalidChangeIdFromEgit() throws Exception {
+    createCommit(
+        testRepo,
+        "Message with invalid Change-Id\n"
+            + "\n"
+            + "Change-Id: I0000000000000000000000000000000000000000\n");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config
+        .getProject()
+        .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
+
+    saveProjectConfig(project, config);
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+  }
+
+  @Test
+  public void pushCommitWithSameChangeIdAsPredecessorChange() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    RevCommit commitChange1 = r.getCommit();
+
+    createCommit(testRepo, commitChange1.getFullMessage());
+
+    pushForReviewRejected(
+        testRepo,
+        "same Change-Id in multiple changes.\n"
+            + "Squash the commits with the same Change-Id or ensure Change-Ids are unique for each"
+            + " commit");
+
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config
+        .getProject()
+        .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
+    saveProjectConfig(project, config);
+
+    pushForReviewRejected(
+        testRepo,
+        "same Change-Id in multiple changes.\n"
+            + "Squash the commits with the same Change-Id or ensure Change-Ids are unique for each"
+            + " commit");
+  }
+
+  @Test
+  public void pushTwoCommitWithSameChangeId() throws Exception {
+    RevCommit commitChange1 = createCommitWithChangeId(testRepo, "some change");
+
+    createCommit(testRepo, commitChange1.getFullMessage());
+
+    pushForReviewRejected(
+        testRepo,
+        "same Change-Id in multiple changes.\n"
+            + "Squash the commits with the same Change-Id or ensure Change-Ids are unique for each"
+            + " commit");
+
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config
+        .getProject()
+        .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
+    saveProjectConfig(project, config);
+
+    pushForReviewRejected(
+        testRepo,
+        "same Change-Id in multiple changes.\n"
+            + "Squash the commits with the same Change-Id or ensure Change-Ids are unique for each"
+            + " commit");
+  }
+
+  private static RevCommit createCommit(TestRepository<?> testRepo, String message)
+      throws Exception {
+    return testRepo.branch("HEAD").commit().message(message).add("a.txt", "content").create();
+  }
+
+  private static RevCommit createCommitWithChangeId(TestRepository<?> testRepo, String message)
+      throws Exception {
+    RevCommit c =
+        testRepo
+            .branch("HEAD")
+            .commit()
+            .message(message)
+            .insertChangeId()
+            .add("a.txt", "content")
+            .create();
+    return testRepo.getRevWalk().parseCommit(c);
+  }
+
+  @Test
+  public void cantAutoCloseChangeAlreadyMergedToBranch() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    Change.Id id1 = r1.getChange().getId();
+    PushOneCommit.Result r2 = createChange();
+    Change.Id id2 = r2.getChange().getId();
+
+    // Merge change 1 behind Gerrit's back.
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> tr = new TestRepository<>(repo);
+      tr.branch("refs/heads/master").update(r1.getCommit());
+    }
+
+    assertThat(gApi.changes().id(id1.get()).info().status).isEqualTo(ChangeStatus.NEW);
+    assertThat(gApi.changes().id(id2.get()).info().status).isEqualTo(ChangeStatus.NEW);
+    r2 = amendChange(r2.getChangeId());
+    r2.assertOkStatus();
+
+    // Change 1 is still new despite being merged into the branch, because
+    // ReceiveCommits only considers commits between the branch tip (which is
+    // now the merged change 1) and the push tip (new patch set of change 2).
+    assertThat(gApi.changes().id(id1.get()).info().status).isEqualTo(ChangeStatus.NEW);
+    assertThat(gApi.changes().id(id2.get()).info().status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void accidentallyPushNewPatchSetDirectlyToBranchAndRecoverByPushingToRefsChanges()
+      throws Exception {
+    Change.Id id = accidentallyPushNewPatchSetDirectlyToBranch();
+    ChangeData cd = byChangeId(id);
+    String ps1Rev = Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
+
+    String r = "refs/changes/" + id;
+    assertPushOk(pushHead(testRepo, r, false), r);
+
+    // Added a new patch set and auto-closed the change.
+    cd = byChangeId(id);
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(getPatchSetRevisions(cd))
+        .containsExactlyEntriesIn(
+            ImmutableMap.of(1, ps1Rev, 2, testRepo.getRepository().resolve("HEAD").name()));
+  }
+
+  @Test
+  public void accidentallyPushNewPatchSetDirectlyToBranchAndCantRecoverByPushingToRefsFor()
+      throws Exception {
+    Change.Id id = accidentallyPushNewPatchSetDirectlyToBranch();
+    ChangeData cd = byChangeId(id);
+    String ps1Rev = Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
+
+    String r = "refs/for/master";
+    assertPushRejected(pushHead(testRepo, r, false), r, "no new changes");
+
+    // Change not updated.
+    cd = byChangeId(id);
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.NEW);
+    assertThat(getPatchSetRevisions(cd)).containsExactlyEntriesIn(ImmutableMap.of(1, ps1Rev));
+  }
+
+  @Test
+  public void forcePushAbandonedChange() throws Exception {
+    grant(project, "refs/*", Permission.PUSH, true);
+    PushOneCommit push1 =
+        pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
+    PushOneCommit.Result r = push1.to("refs/for/master");
+    r.assertOkStatus();
+
+    // abandon the change
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes().id(changeId).abandon();
+    ChangeInfo info = get(changeId);
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+    push1.setForce(true);
+    PushOneCommit.Result r1 = push1.to("refs/heads/master");
+    r1.assertOkStatus();
+    ChangeInfo result = Iterables.getOnlyElement(gApi.changes().query(r.getChangeId()).get());
+    assertThat(result.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  private Change.Id accidentallyPushNewPatchSetDirectlyToBranch() throws Exception {
+    PushOneCommit.Result r = createChange();
+    RevCommit ps1Commit = r.getCommit();
+    Change c = r.getChange().change();
+
+    RevCommit ps2Commit;
+    try (Repository repo = repoManager.openRepository(project)) {
+      // Create a new patch set of the change directly in Gerrit's repository,
+      // without pushing it. In reality it's more likely that the client would
+      // create and push this behind Gerrit's back (e.g. an admin accidentally
+      // using direct ssh access to the repo), but that's harder to do in tests.
+      TestRepository<?> tr = new TestRepository<>(repo);
+      ps2Commit =
+          tr.branch("refs/heads/master")
+              .commit()
+              .message(ps1Commit.getShortMessage() + " v2")
+              .insertChangeId(r.getChangeId().substring(1))
+              .create();
+    }
+
+    testRepo.git().fetch().setRefSpecs(new RefSpec("refs/heads/master")).call();
+    testRepo.reset(ps2Commit);
+
+    ChangeData cd = byCommit(ps1Commit);
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.NEW);
+    assertThat(getPatchSetRevisions(cd))
+        .containsExactlyEntriesIn(ImmutableMap.of(1, ps1Commit.name()));
+    return c.getId();
+  }
+
+  @Test
+  public void pushWithEmailInFooter() throws Exception {
+    pushWithReviewerInFooter(user.emailAddress.toString(), user);
+  }
+
+  @Test
+  public void pushWithNameInFooter() throws Exception {
+    pushWithReviewerInFooter(user.fullName, user);
+  }
+
+  @Test
+  public void pushWithEmailInFooterNotFound() throws Exception {
+    pushWithReviewerInFooter(new Address("No Body", "notarealuser@example.com").toString(), null);
+  }
+
+  @Test
+  public void pushWithNameInFooterNotFound() throws Exception {
+    pushWithReviewerInFooter("Notauser", null);
+  }
+
+  @Test
+  public void pushNewPatchsetOverridingStickyLabel() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType codeReview = Util.codeReview();
+    codeReview.setCopyMaxScore(true);
+    cfg.getLabelSections().put(codeReview.getName(), codeReview);
+    saveProjectConfig(cfg);
+
+    PushOneCommit.Result r = pushTo("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
+    r = push.to("refs/for/master%l=Code-Review+1");
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void createChangeForMergedCommit() throws Exception {
+    String master = "refs/heads/master";
+    grant(project, master, Permission.PUSH, true);
+
+    // Update master with a direct push.
+    RevCommit c1 = testRepo.commit().message("Non-change 1").create();
+    RevCommit c2 =
+        testRepo.parseBody(
+            testRepo.commit().parent(c1).message("Non-change 2").insertChangeId().create());
+    String changeId = Iterables.getOnlyElement(c2.getFooterLines(CHANGE_ID));
+
+    testRepo.reset(c2);
+    assertPushOk(pushHead(testRepo, master, false, true), master);
+
+    String q = "commit:" + c1.name() + " OR commit:" + c2.name() + " OR change:" + changeId;
+    assertThat(gApi.changes().query(q).get()).isEmpty();
+
+    // Push c2 as a merged change.
+    String r = "refs/for/master%merged";
+    assertPushOk(pushHead(testRepo, r, false), r);
+
+    EnumSet<ListChangesOption> opts = EnumSet.of(ListChangesOption.CURRENT_REVISION);
+    ChangeInfo info = gApi.changes().id(changeId).get(opts);
+    assertThat(info.currentRevision).isEqualTo(c2.name());
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+
+    // Only c2 was created as a change.
+    String q1 = "commit: " + c1.name();
+    assertThat(gApi.changes().query(q1).get()).isEmpty();
+
+    // Push c1 as a merged change.
+    testRepo.reset(c1);
+    assertPushOk(pushHead(testRepo, r, false), r);
+    List<ChangeInfo> infos = gApi.changes().query(q1).withOptions(opts).get();
+    assertThat(infos).hasSize(1);
+    info = infos.get(0);
+    assertThat(info.currentRevision).isEqualTo(c1.name());
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void mergedOptionFailsWhenCommitIsNotMerged() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master%merged");
+    r.assertErrorStatus("not merged into branch");
+  }
+
+  @Test
+  public void mergedOptionFailsWhenCommitIsMergedOnOtherBranch() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> tr = new TestRepository<>(repo);
+      tr.branch("refs/heads/branch").commit().message("Initial commit on branch").create();
+    }
+
+    pushTo("refs/for/master%merged").assertErrorStatus("not merged into branch");
+  }
+
+  @Test
+  public void mergedOptionFailsWhenChangeExists() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    testRepo.reset(r.getCommit());
+    String ref = "refs/for/master%merged";
+    PushResult pr = pushHead(testRepo, ref, false);
+    RemoteRefUpdate rru = pr.getRemoteUpdate(ref);
+    assertThat(rru.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(rru.getMessage()).contains("no new changes");
+  }
+
+  @Test
+  public void mergedOptionWithNewCommitWithSameChangeIdFails() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    RevCommit c2 =
+        testRepo
+            .amend(r.getCommit())
+            .message("New subject")
+            .insertChangeId(r.getChangeId().substring(1))
+            .create();
+    testRepo.reset(c2);
+
+    String ref = "refs/for/master%merged";
+    PushResult pr = pushHead(testRepo, ref, false);
+    RemoteRefUpdate rru = pr.getRemoteUpdate(ref);
+    assertThat(rru.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(rru.getMessage()).contains("not merged into branch");
+  }
+
+  @Test
+  public void mergedOptionWithExistingChangeInsertsPatchSet() throws Exception {
+    String master = "refs/heads/master";
+    grant(project, master, Permission.PUSH, true);
+
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    ObjectId c1 = r.getCommit().copy();
+
+    // Create a PS2 commit directly on master in the server's repo. This
+    // simulates the client amending locally and pushing directly to the branch,
+    // expecting the change to be auto-closed, but the change metadata update
+    // fails.
+    ObjectId c2;
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> tr = new TestRepository<>(repo);
+      RevCommit commit2 =
+          tr.amend(c1).message("New subject").insertChangeId(r.getChangeId().substring(1)).create();
+      c2 = commit2.copy();
+      tr.update(master, c2);
+    }
+
+    testRepo.git().fetch().setRefSpecs(new RefSpec("refs/heads/master")).call();
+    testRepo.reset(c2);
+
+    String ref = "refs/for/master%merged";
+    assertPushOk(pushHead(testRepo, ref, false), ref);
+
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get(ALL_REVISIONS);
+    assertThat(info.currentRevision).isEqualTo(c2.name());
+    assertThat(info.revisions.keySet()).containsExactly(c1.name(), c2.name());
+    // TODO(dborowitz): Fix ReceiveCommits to also auto-close the change.
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void publishCommentsOnPushPublishesDraftsOnAllRevisions() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String rev1 = r.getCommit().name();
+    CommentInfo c1 = addDraft(r.getChangeId(), rev1, newDraft(FILE_NAME, 1, "comment1"));
+    CommentInfo c2 = addDraft(r.getChangeId(), rev1, newDraft(FILE_NAME, 1, "comment2"));
+
+    r = amendChange(r.getChangeId());
+    String rev2 = r.getCommit().name();
+    CommentInfo c3 = addDraft(r.getChangeId(), rev2, newDraft(FILE_NAME, 1, "comment3"));
+
+    assertThat(getPublishedComments(r.getChangeId())).isEmpty();
+
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
+    sender.clear();
+    amendChange(r.getChangeId(), "refs/for/master%publish-comments");
+
+    Collection<CommentInfo> comments = getPublishedComments(r.getChangeId());
+    assertThat(comments.stream().map(c -> c.id)).containsExactly(c1.id, c2.id, c3.id);
+    assertThat(comments.stream().map(c -> c.message))
+        .containsExactly("comment1", "comment2", "comment3");
+    assertThat(getLastMessage(r.getChangeId())).isEqualTo("Uploaded patch set 3.\n\n(3 comments)");
+
+    List<String> messages =
+        sender
+            .getMessages()
+            .stream()
+            .map(m -> m.body())
+            .sorted(Comparator.comparingInt(m -> m.contains("reexamine") ? 0 : 1))
+            .collect(toList());
+    assertThat(messages).hasSize(2);
+
+    assertThat(messages.get(0)).contains("Gerrit-MessageType: newpatchset");
+    assertThat(messages.get(0)).contains("I'd like you to reexamine a change");
+    assertThat(messages.get(0)).doesNotContain("Uploaded patch set 3");
+
+    assertThat(messages.get(1)).contains("Gerrit-MessageType: comment");
+    assertThat(messages.get(1))
+        .containsMatch(
+            Pattern.compile(
+                // A little weird that the comment email contains this text, but it's actually
+                // what's in the ChangeMessage. Really we should fuse the emails into one, but until
+                // then, this test documents the current behavior.
+                "Uploaded patch set 3\\.\n"
+                    + "\n"
+                    + "\\(3 comments\\)\\n.*"
+                    + "PS1, Line 1:.*"
+                    + "comment1\\n.*"
+                    + "PS1, Line 1:.*"
+                    + "comment2\\n.*"
+                    + "PS2, Line 1:.*"
+                    + "comment3\\n",
+                Pattern.DOTALL));
+  }
+
+  @Test
+  public void publishCommentsOnPushWithMessage() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String rev = r.getCommit().name();
+    addDraft(r.getChangeId(), rev, newDraft(FILE_NAME, 1, "comment1"));
+
+    r = amendChange(r.getChangeId(), "refs/for/master%publish-comments,m=The_message");
+
+    Collection<CommentInfo> comments = getPublishedComments(r.getChangeId());
+    assertThat(comments.stream().map(c -> c.message)).containsExactly("comment1");
+    assertThat(getLastMessage(r.getChangeId()))
+        .isEqualTo("Uploaded patch set 2.\n\n(1 comment)\n\nThe message");
+  }
+
+  @Test
+  public void publishCommentsOnPushPublishesDraftsOnMultipleChanges() throws Exception {
+    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
+    List<RevCommit> commits = createChanges(2, "refs/for/master");
+    String id1 = byCommit(commits.get(0)).change().getKey().get();
+    String id2 = byCommit(commits.get(1)).change().getKey().get();
+    CommentInfo c1 = addDraft(id1, commits.get(0).name(), newDraft(FILE_NAME, 1, "comment1"));
+    CommentInfo c2 = addDraft(id2, commits.get(1).name(), newDraft(FILE_NAME, 1, "comment2"));
+
+    assertThat(getPublishedComments(id1)).isEmpty();
+    assertThat(getPublishedComments(id2)).isEmpty();
+
+    amendChanges(initialHead, commits, "refs/for/master%publish-comments");
+
+    Collection<CommentInfo> cs1 = getPublishedComments(id1);
+    assertThat(cs1.stream().map(c -> c.message)).containsExactly("comment1");
+    assertThat(cs1.stream().map(c -> c.id)).containsExactly(c1.id);
+    assertThat(getLastMessage(id1))
+        .isEqualTo("Uploaded patch set 2: Commit message was updated.\n\n(1 comment)");
+
+    Collection<CommentInfo> cs2 = getPublishedComments(id2);
+    assertThat(cs2.stream().map(c -> c.message)).containsExactly("comment2");
+    assertThat(cs2.stream().map(c -> c.id)).containsExactly(c2.id);
+    assertThat(getLastMessage(id2))
+        .isEqualTo("Uploaded patch set 2: Commit message was updated.\n\n(1 comment)");
+  }
+
+  @Test
+  public void publishCommentsOnPushOnlyPublishesDraftsOnUpdatedChanges() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    String id1 = r1.getChangeId();
+    String id2 = r2.getChangeId();
+    addDraft(id1, r1.getCommit().name(), newDraft(FILE_NAME, 1, "comment1"));
+    CommentInfo c2 = addDraft(id2, r2.getCommit().name(), newDraft(FILE_NAME, 1, "comment2"));
+
+    assertThat(getPublishedComments(id1)).isEmpty();
+    assertThat(getPublishedComments(id2)).isEmpty();
+
+    r2 = amendChange(id2, "refs/for/master%publish-comments");
+
+    assertThat(getPublishedComments(id1)).isEmpty();
+    assertThat(gApi.changes().id(id1).drafts()).hasSize(1);
+
+    Collection<CommentInfo> cs2 = getPublishedComments(id2);
+    assertThat(cs2.stream().map(c -> c.message)).containsExactly("comment2");
+    assertThat(cs2.stream().map(c -> c.id)).containsExactly(c2.id);
+
+    assertThat(getLastMessage(id1)).doesNotMatch("[Cc]omment");
+    assertThat(getLastMessage(id2)).isEqualTo("Uploaded patch set 2.\n\n(1 comment)");
+  }
+
+  @Test
+  public void publishCommentsOnPushWithPreference() throws Exception {
+    PushOneCommit.Result r = createChange();
+    addDraft(r.getChangeId(), r.getCommit().name(), newDraft(FILE_NAME, 1, "comment1"));
+    r = amendChange(r.getChangeId());
+
+    assertThat(getPublishedComments(r.getChangeId())).isEmpty();
+
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    prefs.publishCommentsOnPush = true;
+    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
+
+    r = amendChange(r.getChangeId());
+    assertThat(getPublishedComments(r.getChangeId()).stream().map(c -> c.message))
+        .containsExactly("comment1");
+  }
+
+  @Test
+  public void publishCommentsOnPushOverridingPreference() throws Exception {
+    PushOneCommit.Result r = createChange();
+    addDraft(r.getChangeId(), r.getCommit().name(), newDraft(FILE_NAME, 1, "comment1"));
+
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    prefs.publishCommentsOnPush = true;
+    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
+
+    r = amendChange(r.getChangeId(), "refs/for/master%no-publish-comments");
+
+    assertThat(getPublishedComments(r.getChangeId())).isEmpty();
+  }
+
+  @Test
+  public void pushDraftGetsPrivateChange() throws Exception {
+    String changeId1 = createChange("refs/drafts/master").getChangeId();
+    String changeId2 = createChange("refs/for/master%draft").getChangeId();
+
+    ChangeInfo info1 = gApi.changes().id(changeId1).get();
+    ChangeInfo info2 = gApi.changes().id(changeId2).get();
+
+    assertThat(info1.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(info2.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(info1.isPrivate).isTrue();
+    assertThat(info2.isPrivate).isTrue();
+    assertThat(info1.revisions).hasSize(1);
+    assertThat(info2.revisions).hasSize(1);
+  }
+
+  @Sandboxed
+  @Test
+  public void pushWithDraftOptionToExistingNewChangeGetsChangeEdit() throws Exception {
+    String changeId = createChange().getChangeId();
+    EditInfoSubject.assertThat(getEdit(changeId)).isAbsent();
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    ChangeStatus originalChangeStatus = changeInfo.status;
+
+    PushOneCommit.Result result = amendChange(changeId, "refs/drafts/master");
+    result.assertOkStatus();
+
+    changeInfo = gApi.changes().id(changeId).get();
+    assertThat(changeInfo.status).isEqualTo(originalChangeStatus);
+    assertThat(changeInfo.isPrivate).isNull();
+    assertThat(changeInfo.revisions).hasSize(1);
+
+    EditInfoSubject.assertThat(getEdit(changeId)).isPresent();
+  }
+
+  @GerritConfig(name = "receive.maxBatchCommits", value = "2")
+  @Test
+  public void maxBatchCommits() throws Exception {
+    List<RevCommit> commits = new ArrayList<>();
+    commits.addAll(initChanges(2));
+    String master = "refs/heads/master";
+    assertPushOk(pushHead(testRepo, master), master);
+
+    commits.addAll(initChanges(3));
+    assertPushRejected(pushHead(testRepo, master), master, "too many commits");
+
+    grantSkipValidation(project, master, SystemGroupBackend.REGISTERED_USERS);
+    PushResult r =
+        pushHead(testRepo, master, false, false, ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+    assertPushOk(r, master);
+
+    // No open changes; branch was advanced.
+    String q = commits.stream().map(ObjectId::name).collect(joining(" OR commit:", "commit:", ""));
+    assertThat(gApi.changes().query(q).get()).isEmpty();
+    assertThat(gApi.projects().name(project.get()).branch(master).get().revision)
+        .isEqualTo(Iterables.getLast(commits).name());
+  }
+
+  @Test
+  public void pushToPublishMagicBranchIsAllowed() throws Exception {
+    // Push to "refs/publish/*" will be a synonym of "refs/for/*".
+    createChange("refs/publish/master");
+    PushOneCommit.Result result = pushTo("refs/publish/master");
+    result.assertOkStatus();
+    assertThat(result.getMessage())
+        .endsWith("Pushing to refs/publish/* is deprecated, use refs/for/* instead.\n");
+  }
+
+  private DraftInput newDraft(String path, int line, String message) {
+    DraftInput d = new DraftInput();
+    d.path = path;
+    d.side = Side.REVISION;
+    d.line = line;
+    d.message = message;
+    d.unresolved = true;
+    return d;
+  }
+
+  private CommentInfo addDraft(String changeId, String revId, DraftInput in) throws Exception {
+    return gApi.changes().id(changeId).revision(revId).createDraft(in).get();
+  }
+
+  private Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
+    return gApi.changes()
+        .id(changeId)
+        .comments()
+        .values()
+        .stream()
+        .flatMap(cs -> cs.stream())
+        .collect(toList());
+  }
+
+  private String getLastMessage(String changeId) throws Exception {
+    return Streams.findLast(
+            gApi.changes().id(changeId).get(MESSAGES).messages.stream().map(m -> m.message))
+        .get();
+  }
+
+  private void assertThatUserIsOnlyReviewer(ChangeInfo ci, TestAccount reviewer) {
+    assertThat(ci.reviewers).isNotNull();
+    assertThat(ci.reviewers.keySet()).containsExactly(ReviewerState.REVIEWER);
+    assertThat(ci.reviewers.get(ReviewerState.REVIEWER).iterator().next().email)
+        .isEqualTo(reviewer.email);
+  }
+
+  private void pushWithReviewerInFooter(String nameEmail, TestAccount expectedReviewer)
+      throws Exception {
+    int n = 5;
+    String r = "refs/for/master";
+    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
+    List<RevCommit> commits = createChanges(n, r, ImmutableList.of("Acked-By: " + nameEmail));
+    for (int i = 0; i < n; i++) {
+      RevCommit c = commits.get(i);
+      ChangeData cd = byCommit(c);
+      String name = "reviewers for " + (i + 1);
+      if (expectedReviewer != null) {
+        assertThat(cd.reviewers().all()).named(name).containsExactly(expectedReviewer.getId());
+        gApi.changes().id(cd.getId().get()).reviewer(expectedReviewer.getId().toString()).remove();
+      }
+      assertThat(byCommit(c).reviewers().all()).named(name).isEmpty();
+    }
+
+    List<RevCommit> commits2 = amendChanges(initialHead, commits, r);
+    for (int i = 0; i < n; i++) {
+      RevCommit c = commits2.get(i);
+      ChangeData cd = byCommit(c);
+      String name = "reviewers for " + (i + 1);
+      if (expectedReviewer != null) {
+        assertThat(cd.reviewers().all()).named(name).containsExactly(expectedReviewer.getId());
+      } else {
+        assertThat(byCommit(c).reviewers().all()).named(name).isEmpty();
+      }
+    }
+  }
+
+  private List<RevCommit> createChanges(int n, String refsFor) throws Exception {
+    return createChanges(n, refsFor, ImmutableList.of());
+  }
+
+  private List<RevCommit> createChanges(int n, String refsFor, List<String> footerLines)
+      throws Exception {
+    List<RevCommit> commits = initChanges(n, footerLines);
+    assertPushOk(pushHead(testRepo, refsFor, false), refsFor);
+    return commits;
+  }
+
+  private List<RevCommit> initChanges(int n) throws Exception {
+    return initChanges(n, ImmutableList.of());
+  }
+
+  private List<RevCommit> initChanges(int n, List<String> footerLines) throws Exception {
+    List<RevCommit> commits = new ArrayList<>(n);
+    for (int i = 1; i <= n; i++) {
+      String msg = "Change " + i;
+      if (!footerLines.isEmpty()) {
+        StringBuilder sb = new StringBuilder(msg).append("\n\n");
+        for (String line : footerLines) {
+          sb.append(line).append('\n');
+        }
+        msg = sb.toString();
+      }
+      TestRepository<?>.CommitBuilder cb =
+          testRepo.branch("HEAD").commit().message(msg).insertChangeId();
+      if (!commits.isEmpty()) {
+        cb.parent(commits.get(commits.size() - 1));
+      }
+      RevCommit c = cb.create();
+      testRepo.getRevWalk().parseBody(c);
+      commits.add(c);
+    }
+    return commits;
+  }
+
+  private List<RevCommit> amendChanges(
+      ObjectId initialHead, List<RevCommit> origCommits, String refsFor) throws Exception {
+    testRepo.reset(initialHead);
+    List<RevCommit> newCommits = new ArrayList<>(origCommits.size());
+    for (RevCommit c : origCommits) {
+      String msg = c.getShortMessage() + "v2";
+      if (!c.getShortMessage().equals(c.getFullMessage())) {
+        msg = msg + c.getFullMessage().substring(c.getShortMessage().length());
+      }
+      TestRepository<?>.CommitBuilder cb = testRepo.branch("HEAD").commit().message(msg);
+      if (!newCommits.isEmpty()) {
+        cb.parent(origCommits.get(newCommits.size() - 1));
+      }
+      RevCommit c2 = cb.create();
+      testRepo.getRevWalk().parseBody(c2);
+      newCommits.add(c2);
+    }
+    assertPushOk(pushHead(testRepo, refsFor, false), refsFor);
+    return newCommits;
+  }
+
+  private static Map<Integer, String> getPatchSetRevisions(ChangeData cd) throws Exception {
+    Map<Integer, String> revisions = new HashMap<>();
+    for (PatchSet ps : cd.patchSets()) {
+      revisions.put(ps.getPatchSetId(), ps.getRevision().get());
+    }
+    return revisions;
+  }
+
+  private ChangeData byCommit(ObjectId id) throws Exception {
+    List<ChangeData> cds = queryProvider.get().byCommit(id);
+    assertThat(cds).named("change for " + id.name()).hasSize(1);
+    return cds.get(0);
+  }
+
+  private ChangeData byChangeId(Change.Id id) throws Exception {
+    List<ChangeData> cds = queryProvider.get().byLegacyChangeId(id);
+    assertThat(cds).named("change " + id).hasSize(1);
+    return cds.get(0);
+  }
+
+  private static void pushForReviewOk(TestRepository<?> testRepo) throws GitAPIException {
+    pushForReview(testRepo, RemoteRefUpdate.Status.OK, null);
+  }
+
+  private static void pushForReviewRejected(TestRepository<?> testRepo, String expectedMessage)
+      throws GitAPIException {
+    pushForReview(testRepo, RemoteRefUpdate.Status.REJECTED_OTHER_REASON, expectedMessage);
+  }
+
+  private static void pushForReview(
+      TestRepository<?> testRepo, RemoteRefUpdate.Status expectedStatus, String expectedMessage)
+      throws GitAPIException {
+    String ref = "refs/for/master";
+    PushResult r = pushHead(testRepo, ref);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(ref);
+    assertThat(refUpdate.getStatus()).isEqualTo(expectedStatus);
+    if (expectedMessage != null) {
+      assertThat(refUpdate.getMessage()).contains(expectedMessage);
+    }
+  }
+
+  private void grantSkipValidation(Project.NameKey project, String ref, AccountGroup.UUID groupUuid)
+      throws Exception {
+    // See SKIP_VALIDATION implementation in default permission backend.
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    Util.allow(config, Permission.FORGE_AUTHOR, groupUuid, ref);
+    Util.allow(config, Permission.FORGE_COMMITTER, groupUuid, ref);
+    Util.allow(config, Permission.FORGE_SERVER, groupUuid, ref);
+    Util.allow(config, Permission.PUSH_MERGE, groupUuid, "refs/for/" + ref);
+    saveProjectConfig(project, config);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
rename to javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
diff --git a/javatests/com/google/gerrit/acceptance/git/BUILD b/javatests/com/google/gerrit/acceptance/git/BUILD
new file mode 100644
index 0000000..7a6a3c4
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/BUILD
@@ -0,0 +1,27 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "git",
+    labels = ["git"],
+    deps = [
+        ":push_for_review",
+        ":submodule_util",
+    ],
+)
+
+java_library(
+    name = "push_for_review",
+    testonly = 1,
+    srcs = ["AbstractPushForReview.java"],
+    deps = [
+        "//java/com/google/gerrit/acceptance:lib",
+    ],
+)
+
+java_library(
+    name = "submodule_util",
+    testonly = 1,
+    srcs = ["AbstractSubmoduleSubscription.java"],
+    deps = ["//java/com/google/gerrit/acceptance:lib"],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ForcePushIT.java b/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ForcePushIT.java
rename to javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java b/javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
rename to javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
new file mode 100644
index 0000000..cc4347b
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.server.git.ProjectConfig;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ImplicitMergeCheckIT extends AbstractDaemonTest {
+
+  @Test
+  public void implicitMergeViaFastForward() throws Exception {
+    setRejectImplicitMerges();
+
+    pushHead(testRepo, "refs/heads/stable", false);
+    PushOneCommit.Result m = push("refs/heads/master", "0", "file", "0");
+    PushOneCommit.Result c = push("refs/for/stable", "1", "file", "1");
+
+    c.assertMessage(implicitMergeOf(m.getCommit()));
+    c.assertErrorStatus();
+  }
+
+  @Test
+  public void implicitMergeViaRealMerge() throws Exception {
+    setRejectImplicitMerges();
+
+    ObjectId base = repo().exactRef("HEAD").getObjectId();
+    push("refs/heads/stable", "0", "f", "0");
+    testRepo.reset(base);
+    PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
+    PushOneCommit.Result c = push("refs/for/stable", "2", "f", "2");
+
+    c.assertMessage(implicitMergeOf(m.getCommit()));
+    c.assertErrorStatus();
+  }
+
+  @Test
+  public void implicitMergeCheckOff() throws Exception {
+    ObjectId base = repo().exactRef("HEAD").getObjectId();
+    push("refs/heads/stable", "0", "f", "0");
+    testRepo.reset(base);
+    PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
+    PushOneCommit.Result c = push("refs/for/stable", "2", "f", "2");
+
+    assertThat(c.getMessage().toLowerCase()).doesNotContain(implicitMergeOf(m.getCommit()));
+  }
+
+  @Test
+  public void notImplicitMerge_noWarning() throws Exception {
+    setRejectImplicitMerges();
+
+    ObjectId base = repo().exactRef("HEAD").getObjectId();
+    push("refs/heads/stable", "0", "f", "0");
+    testRepo.reset(base);
+    PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
+    PushOneCommit.Result c = push("refs/for/master", "2", "f", "2");
+
+    assertThat(c.getMessage().toLowerCase()).doesNotContain(implicitMergeOf(m.getCommit()));
+  }
+
+  private static String implicitMergeOf(ObjectId commit) {
+    return "implicit merge of " + commit.abbreviate(7).name();
+  }
+
+  private void setRejectImplicitMerges() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getProject()
+        .setBooleanConfig(BooleanProjectConfig.REJECT_IMPLICIT_MERGES, InheritableBoolean.TRUE);
+    saveProjectConfig(project, cfg);
+  }
+
+  private PushOneCommit.Result push(String ref, String subject, String fileName, String content)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
+    return push.to(ref);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
new file mode 100644
index 0000000..cc5b5d3
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -0,0 +1,787 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Predicates;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.ProjectResetter;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHook;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testing.NoteDbMode;
+import com.google.gerrit.testing.TestChanges;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Predicate;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class RefAdvertisementIT extends AbstractDaemonTest {
+  @Inject private VisibleRefFilter.Factory refFilterFactory;
+  @Inject private ChangeNoteUtil noteUtil;
+  @Inject @AnonymousCowardName private String anonymousCowardName;
+  @Inject private AllUsersName allUsersName;
+
+  private AccountGroup.UUID admins;
+  private AccountGroup.UUID nonInteractiveUsers;
+
+  private ChangeData c1;
+  private ChangeData c2;
+  private ChangeData c3;
+  private ChangeData c4;
+  private String r1;
+  private String r2;
+  private String r3;
+  private String r4;
+
+  @Before
+  public void setUp() throws Exception {
+    admins = adminGroupUuid();
+    nonInteractiveUsers = groupUuid("Non-Interactive Users");
+    setUpPermissions();
+    setUpChanges();
+  }
+
+  private void setUpPermissions() throws Exception {
+    // Remove read permissions for all users besides admin. This method is idempotent, so is safe
+    // to call on every test setup.
+    ProjectConfig pc = projectCache.checkedGet(allProjects).getConfig();
+    for (AccessSection sec : pc.getAccessSections()) {
+      sec.removePermission(Permission.READ);
+    }
+    Util.allow(pc, Permission.READ, admins, "refs/*");
+    saveProjectConfig(allProjects, pc);
+
+    // Remove all read permissions on All-Users. This method is idempotent, so is safe to call on
+    // every test setup.
+    pc = projectCache.checkedGet(allUsers).getConfig();
+    for (AccessSection sec : pc.getAccessSections()) {
+      sec.removePermission(Permission.READ);
+    }
+    saveProjectConfig(allUsers, pc);
+  }
+
+  private static String changeRefPrefix(Change.Id id) {
+    String ps = new PatchSet.Id(id, 1).toRefName();
+    return ps.substring(0, ps.length() - 1);
+  }
+
+  private void setUpChanges() throws Exception {
+    gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
+
+    // First 2 changes are merged, which means the tags pointing to them are
+    // visible.
+    allow("refs/for/refs/heads/*", Permission.SUBMIT, admins);
+    PushOneCommit.Result mr =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%submit");
+    mr.assertOkStatus();
+    c1 = mr.getChange();
+    r1 = changeRefPrefix(c1.getId());
+    PushOneCommit.Result br =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/branch%submit");
+    br.assertOkStatus();
+    c2 = br.getChange();
+    r2 = changeRefPrefix(c2.getId());
+
+    // Second 2 changes are unmerged.
+    mr = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master");
+    mr.assertOkStatus();
+    c3 = mr.getChange();
+    r3 = changeRefPrefix(c3.getId());
+    br = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/branch");
+    br.assertOkStatus();
+    c4 = br.getChange();
+    r4 = changeRefPrefix(c4.getId());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // master-tag -> master
+      RefUpdate mtu = repo.updateRef("refs/tags/master-tag");
+      mtu.setExpectedOldObjectId(ObjectId.zeroId());
+      mtu.setNewObjectId(repo.exactRef("refs/heads/master").getObjectId());
+      assertThat(mtu.update()).isEqualTo(RefUpdate.Result.NEW);
+
+      // branch-tag -> branch
+      RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
+      btu.setExpectedOldObjectId(ObjectId.zeroId());
+      btu.setNewObjectId(repo.exactRef("refs/heads/branch").getObjectId());
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+    }
+  }
+
+  @Test
+  public void uploadPackAllRefsVisibleNoRefsMetaConfig() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.allow(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
+    Util.allow(cfg, Permission.READ, admins, RefNames.REFS_CONFIG);
+    Util.doNotInherit(cfg, Permission.READ, RefNames.REFS_CONFIG);
+    saveProjectConfig(project, cfg);
+
+    setApiUser(user);
+    assertUploadPackRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r2 + "1",
+        r2 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        r4 + "1",
+        r4 + "meta",
+        "refs/heads/branch",
+        "refs/heads/master",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+  }
+
+  @Test
+  public void uploadPackAllRefsVisibleWithRefsMetaConfig() throws Exception {
+    allow("refs/*", Permission.READ, REGISTERED_USERS);
+    allow(RefNames.REFS_CONFIG, Permission.READ, REGISTERED_USERS);
+
+    assertUploadPackRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r2 + "1",
+        r2 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        r4 + "1",
+        r4 + "meta",
+        "refs/heads/branch",
+        "refs/heads/master",
+        RefNames.REFS_CONFIG,
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+  }
+
+  @Test
+  public void uploadPackSubsetOfBranchesVisibleIncludingHead() throws Exception {
+    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
+    deny("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+
+    setApiUser(user);
+    assertUploadPackRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        "refs/heads/master",
+        "refs/tags/master-tag");
+  }
+
+  @Test
+  public void uploadPackSubsetOfBranchesVisibleNotIncludingHead() throws Exception {
+    deny("refs/heads/master", Permission.READ, REGISTERED_USERS);
+    allow("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+
+    setApiUser(user);
+    assertUploadPackRefs(
+        r2 + "1",
+        r2 + "meta",
+        r4 + "1",
+        r4 + "meta",
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        // master branch is not visible but master-tag is reachable from branch
+        // (since PushOneCommit always bases changes on each other).
+        "refs/tags/master-tag");
+  }
+
+  @Test
+  public void uploadPackSubsetOfBranchesVisibleWithEdit() throws Exception {
+    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
+
+    Change c = notesFactory.createChecked(db, project, c1.getId()).getChange();
+    String changeId = c.getKey().get();
+
+    // Admin's edit is not visible.
+    setApiUser(admin);
+    gApi.changes().id(changeId).edit().create();
+
+    // User's edit is visible.
+    setApiUser(user);
+    gApi.changes().id(changeId).edit().create();
+
+    assertUploadPackRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        "refs/heads/master",
+        "refs/tags/master-tag",
+        "refs/users/01/1000001/edit-" + c1.getId() + "/1");
+  }
+
+  @Test
+  public void uploadPackSubsetOfBranchesAndEditsVisibleWithViewPrivateChanges() throws Exception {
+    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
+    allow("refs/*", Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS);
+
+    Change change1 = notesFactory.createChecked(db, project, c1.getId()).getChange();
+    String changeId1 = change1.getKey().get();
+    Change change2 = notesFactory.createChecked(db, project, c2.getId()).getChange();
+    String changeId2 = change2.getKey().get();
+
+    // Admin's edit on change1 is visible.
+    setApiUser(admin);
+    gApi.changes().id(changeId1).edit().create();
+
+    // Admin's edit on change2 is not visible since user cannot see the change.
+    gApi.changes().id(changeId2).edit().create();
+
+    // User's edit is visible.
+    setApiUser(user);
+    gApi.changes().id(changeId1).edit().create();
+
+    assertUploadPackRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        "refs/heads/master",
+        "refs/tags/master-tag",
+        "refs/users/00/1000000/edit-" + c1.getId() + "/1",
+        "refs/users/01/1000001/edit-" + c1.getId() + "/1");
+  }
+
+  @Test
+  public void uploadPackSubsetOfRefsVisibleWithAccessDatabase() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    deny("refs/heads/master", Permission.READ, REGISTERED_USERS);
+    allow("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+
+    String changeId = c1.change().getKey().get();
+    setApiUser(admin);
+    gApi.changes().id(changeId).edit().create();
+    setApiUser(user);
+
+    assertUploadPackRefs(
+        // Change 1 is visible due to accessDatabase capability, even though
+        // refs/heads/master is not.
+        r1 + "1",
+        r1 + "meta",
+        r2 + "1",
+        r2 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        r4 + "1",
+        r4 + "meta",
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        // See comment in subsetOfBranchesVisibleNotIncludingHead.
+        "refs/tags/master-tag",
+        // All edits are visible due to accessDatabase capability.
+        "refs/users/00/1000000/edit-" + c1.getId() + "/1");
+  }
+
+  @Test
+  public void uploadPackNoSearchingChangeCacheImpl() throws Exception {
+    allow("refs/heads/*", Permission.READ, REGISTERED_USERS);
+
+    setApiUser(user);
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertRefs(
+          repo,
+          refFilterFactory.create(projectCache.get(project), repo),
+          // Can't use stored values from the index so DB must be enabled.
+          false,
+          "HEAD",
+          r1 + "1",
+          r1 + "meta",
+          r2 + "1",
+          r2 + "meta",
+          r3 + "1",
+          r3 + "meta",
+          r4 + "1",
+          r4 + "meta",
+          "refs/heads/branch",
+          "refs/heads/master",
+          "refs/tags/branch-tag",
+          "refs/tags/master-tag");
+    }
+  }
+
+  @Test
+  public void uploadPackSequencesWithAccessDatabase() throws Exception {
+    assume().that(notesMigration.readChangeSequence()).isTrue();
+    try (Repository repo = repoManager.openRepository(allProjects)) {
+      setApiUser(user);
+      assertRefs(repo, newFilter(repo, allProjects), true);
+
+      allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+      setApiUser(user);
+      assertRefs(repo, newFilter(repo, allProjects), true, "refs/sequences/changes");
+    }
+  }
+
+  @Test
+  public void receivePackListsOpenChangesAsAdditionalHaves() throws Exception {
+    ReceiveCommitsAdvertiseRefsHook.Result r = getReceivePackRefs();
+    assertThat(r.allRefs().keySet())
+        .containsExactly(
+            // meta refs are excluded even when NoteDb is enabled.
+            "HEAD",
+            "refs/heads/branch",
+            "refs/heads/master",
+            "refs/meta/config",
+            "refs/tags/branch-tag",
+            "refs/tags/master-tag");
+    assertThat(r.additionalHaves()).containsExactly(obj(c3, 1), obj(c4, 1));
+  }
+
+  @Test
+  public void receivePackRespectsVisibilityOfOpenChanges() throws Exception {
+    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
+    deny("refs/heads/branch", Permission.READ, REGISTERED_USERS);
+    setApiUser(user);
+
+    assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c3, 1));
+  }
+
+  @Test
+  public void receivePackListsOnlyLatestPatchSet() throws Exception {
+    testRepo.reset(obj(c3, 1));
+    PushOneCommit.Result r = amendChange(c3.change().getKey().get());
+    r.assertOkStatus();
+    c3 = r.getChange();
+    assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c3, 2), obj(c4, 1));
+  }
+
+  @Test
+  public void receivePackOmitsMissingObject() throws Exception {
+    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> tr = new TestRepository<>(repo);
+      String subject = "Subject for missing commit";
+      Change c = new Change(c3.change());
+      PatchSet.Id psId = new PatchSet.Id(c3.getId(), 2);
+      c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
+
+      if (notesMigration.changePrimaryStorage() == PrimaryStorage.REVIEW_DB) {
+        PatchSet ps = TestChanges.newPatchSet(psId, rev, admin.getId());
+        db.patchSets().insert(Collections.singleton(ps));
+        db.changes().update(Collections.singleton(c));
+      }
+
+      if (notesMigration.commitChangeWrites()) {
+        PersonIdent committer = serverIdent.get();
+        PersonIdent author =
+            noteUtil.newIdent(
+                accountCache.get(admin.getId()).getAccount(), committer.getWhen(), committer);
+        tr.branch(RefNames.changeMetaRef(c3.getId()))
+            .commit()
+            .author(author)
+            .committer(committer)
+            .message(
+                "Update patch set "
+                    + psId.get()
+                    + "\n"
+                    + "\n"
+                    + "Patch-set: "
+                    + psId.get()
+                    + "\n"
+                    + "Commit: "
+                    + rev
+                    + "\n"
+                    + "Subject: "
+                    + subject
+                    + "\n")
+            .create();
+      }
+      indexer.index(db, c.getProject(), c.getId());
+    }
+
+    assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c4, 1));
+  }
+
+  @Test
+  public void advertisedReferencesDontShowUserBranchWithoutRead() throws Exception {
+    TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getUserRefs(git)).isEmpty();
+    }
+  }
+
+  @Test
+  public void advertisedReferencesOmitUserBranchesOfOtherUsers() throws Exception {
+    allow(allUsersName, RefNames.REFS_USERS + "*", Permission.READ, REGISTERED_USERS);
+    TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getUserRefs(git))
+          .containsExactly(RefNames.REFS_USERS_SELF, RefNames.refsUsers(user.id));
+    }
+  }
+
+  @Test
+  public void advertisedReferencesIncludeAllUserBranchesWithAccessDatabase() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getUserRefs(git))
+          .containsExactly(
+              RefNames.REFS_USERS_SELF, RefNames.refsUsers(user.id), RefNames.refsUsers(admin.id));
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void advertisedReferencesDontShowGroupBranchToOwnerWithoutRead() throws Exception {
+    try (ProjectResetter resetter = resetGroups()) {
+      createSelfOwnedGroup("Foos", user);
+      TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+      try (Git git = userTestRepository.git()) {
+        assertThat(getGroupRefs(git)).isEmpty();
+      }
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void advertisedReferencesOmitGroupBranchesOfNonOwnedGroups() throws Exception {
+    try (ProjectResetter resetter = resetGroups()) {
+      allow(allUsersName, RefNames.REFS_GROUPS + "*", Permission.READ, REGISTERED_USERS);
+      AccountGroup.UUID users = createGroup("Users", admins, user);
+      AccountGroup.UUID foos = createGroup("Foos", users);
+      AccountGroup.UUID bars = createSelfOwnedGroup("Bars", user);
+      TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+      try (Git git = userTestRepository.git()) {
+        assertThat(getGroupRefs(git))
+            .containsExactly(RefNames.refsGroups(foos), RefNames.refsGroups(bars));
+      }
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void advertisedReferencesIncludeAllGroupBranchesWithAccessDatabase() throws Exception {
+    try (ProjectResetter resetter = resetGroups()) {
+      allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+      AccountGroup.UUID users = createGroup("Users", admins);
+      TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+      try (Git git = userTestRepository.git()) {
+        assertThat(getGroupRefs(git))
+            .containsExactly(
+                RefNames.refsGroups(admins),
+                RefNames.refsGroups(nonInteractiveUsers),
+                RefNames.refsGroups(users));
+      }
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void advertisedReferencesIncludeAllGroupBranchesForAdmins() throws Exception {
+    allow(allUsersName, RefNames.REFS_GROUPS + "*", Permission.READ, REGISTERED_USERS);
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ADMINISTRATE_SERVER);
+    AccountGroup.UUID users = createGroup("Users", admins);
+    TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getGroupRefs(git))
+          .containsExactly(
+              RefNames.refsGroups(admins),
+              RefNames.refsGroups(nonInteractiveUsers),
+              RefNames.refsGroups(users));
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void advertisedReferencesOmitNoteDbNotesBranches() throws Exception {
+    allow(allUsersName, RefNames.REFS + "*", Permission.READ, REGISTERED_USERS);
+    TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getRefs(git)).containsNoneOf(RefNames.REFS_EXTERNAL_IDS, RefNames.REFS_GROUPNAMES);
+    }
+  }
+
+  @Test
+  public void advertisedReferencesOmitPrivateChangesOfOtherUsers() throws Exception {
+    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
+
+    TestRepository<?> userTestRepository = cloneProject(project, user);
+    try (Git git = userTestRepository.git()) {
+      String change3RefName = c3.currentPatchSet().getRefName();
+      assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
+
+      gApi.changes().id(c3.getId().get()).setPrivate(true, null);
+      assertThat(getRefs(git)).doesNotContain(change3RefName);
+    }
+  }
+
+  @Test
+  public void advertisedReferencesIncludePrivateChangesWhenAllRefsMayBeRead() throws Exception {
+    allow("refs/*", Permission.READ, REGISTERED_USERS);
+
+    TestRepository<?> userTestRepository = cloneProject(project, user);
+    try (Git git = userTestRepository.git()) {
+      String change3RefName = c3.currentPatchSet().getRefName();
+      assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
+
+      gApi.changes().id(c3.getId().get()).setPrivate(true, null);
+      assertThat(getRefs(git)).contains(change3RefName);
+    }
+  }
+
+  @Test
+  public void advertisedReferencesOmitDraftCommentRefsOfOtherUsers() throws Exception {
+    assume().that(notesMigration.commitChangeWrites()).isTrue();
+
+    allow(project, "refs/*", Permission.READ, REGISTERED_USERS);
+    allow(allUsersName, "refs/*", Permission.READ, REGISTERED_USERS);
+
+    setApiUser(user);
+    DraftInput draftInput = new DraftInput();
+    draftInput.line = 1;
+    draftInput.message = "nit: trailing whitespace";
+    draftInput.path = Patch.COMMIT_MSG;
+    gApi.changes().id(c3.getId().get()).current().createDraft(draftInput);
+    String draftCommentRef = RefNames.refsDraftComments(c3.getId(), user.id);
+
+    // user can see the draft comment ref of the own draft comment
+    assertThat(lsRemote(allUsersName, user)).contains(draftCommentRef);
+
+    // user2 can't see the draft comment ref of user's draft comment
+    assertThat(lsRemote(allUsersName, accountCreator.user2())).doesNotContain(draftCommentRef);
+  }
+
+  @Test
+  public void advertisedReferencesOmitStarredChangesRefsOfOtherUsers() throws Exception {
+    assume().that(notesMigration.commitChangeWrites()).isTrue();
+
+    allow(project, "refs/*", Permission.READ, REGISTERED_USERS);
+    allow(allUsersName, "refs/*", Permission.READ, REGISTERED_USERS);
+
+    setApiUser(user);
+    gApi.accounts().self().starChange(c3.getId().toString());
+    String starredChangesRef = RefNames.refsStarredChanges(c3.getId(), user.id);
+
+    // user can see the starred changes ref of the own star
+    assertThat(lsRemote(allUsersName, user)).contains(starredChangesRef);
+
+    // user2 can't see the starred changes ref of admin's star
+    assertThat(lsRemote(allUsersName, accountCreator.user2())).doesNotContain(starredChangesRef);
+  }
+
+  @Test
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void hideMetadata() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    // create change
+    TestRepository<?> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_USERS_SELF + ":userRef");
+    allUsersRepo.reset("userRef");
+    PushOneCommit.Result mr =
+        pushFactory
+            .create(db, admin.getIdent(), allUsersRepo)
+            .to("refs/for/" + RefNames.REFS_USERS_SELF);
+    mr.assertOkStatus();
+
+    List<String> expectedNonMetaRefs =
+        ImmutableList.of(
+            RefNames.REFS_USERS_SELF,
+            RefNames.refsUsers(admin.id),
+            RefNames.refsUsers(user.id),
+            RefNames.REFS_EXTERNAL_IDS,
+            RefNames.REFS_GROUPNAMES,
+            RefNames.refsGroups(admins),
+            RefNames.refsGroups(nonInteractiveUsers),
+            RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS,
+            RefNames.REFS_SEQUENCES + Sequences.NAME_GROUPS,
+            RefNames.REFS_CONFIG,
+            Constants.HEAD);
+
+    List<String> expectedMetaRefs =
+        new ArrayList<>(ImmutableList.of(mr.getPatchSetId().toRefName()));
+    if (NoteDbMode.get() != NoteDbMode.OFF) {
+      expectedMetaRefs.add(changeRefPrefix(mr.getChange().getId()) + "meta");
+    }
+
+    List<String> expectedAllRefs = new ArrayList<>(expectedNonMetaRefs);
+    expectedAllRefs.addAll(expectedMetaRefs);
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      Map<String, Ref> all = repo.getAllRefs();
+
+      VisibleRefFilter filter = refFilterFactory.create(projectCache.get(allUsers), repo);
+      assertThat(filter.filter(all, false).keySet()).containsExactlyElementsIn(expectedAllRefs);
+
+      assertThat(filter.setShowMetadata(false).filter(all, false).keySet())
+          .containsExactlyElementsIn(expectedNonMetaRefs);
+    }
+  }
+
+  private List<String> lsRemote(Project.NameKey p, TestAccount a) throws Exception {
+    TestRepository<?> testRepository = cloneProject(p, a);
+    try (Git git = testRepository.git()) {
+      return git.lsRemote().call().stream().map(Ref::getName).collect(toList());
+    }
+  }
+
+  private List<String> getRefs(Git git) throws Exception {
+    return getRefs(git, Predicates.alwaysTrue());
+  }
+
+  private List<String> getUserRefs(Git git) throws Exception {
+    return getRefs(git, RefNames::isRefsUsers);
+  }
+
+  private List<String> getGroupRefs(Git git) throws Exception {
+    return getRefs(git, RefNames::isRefsGroups);
+  }
+
+  private List<String> getRefs(Git git, Predicate<String> predicate) throws Exception {
+    return git.lsRemote().call().stream().map(Ref::getName).filter(predicate).collect(toList());
+  }
+
+  /**
+   * Assert that refs seen by a non-admin user match expected.
+   *
+   * @param expectedWithMeta expected refs, in order. If NoteDb is disabled by the configuration,
+   *     any NoteDb refs (i.e. ending in "/meta") are removed from the expected list before
+   *     comparing to the actual results.
+   * @throws Exception
+   */
+  private void assertUploadPackRefs(String... expectedWithMeta) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertRefs(
+          repo, refFilterFactory.create(projectCache.get(project), repo), true, expectedWithMeta);
+    }
+  }
+
+  private void assertRefs(
+      Repository repo, VisibleRefFilter filter, boolean disableDb, String... expectedWithMeta)
+      throws Exception {
+    List<String> expected = new ArrayList<>(expectedWithMeta.length);
+    for (String r : expectedWithMeta) {
+      if (notesMigration.commitChangeWrites() || !r.endsWith(RefNames.META_SUFFIX)) {
+        expected.add(r);
+      }
+    }
+
+    AcceptanceTestRequestScope.Context ctx = null;
+    if (disableDb) {
+      ctx = disableDb();
+    }
+    try {
+      Map<String, Ref> all = repo.getAllRefs();
+      assertThat(filter.filter(all, false).keySet()).containsExactlyElementsIn(expected);
+    } finally {
+      if (disableDb) {
+        enableDb(ctx);
+      }
+    }
+  }
+
+  private ReceiveCommitsAdvertiseRefsHook.Result getReceivePackRefs() throws Exception {
+    ReceiveCommitsAdvertiseRefsHook hook =
+        new ReceiveCommitsAdvertiseRefsHook(queryProvider, project);
+    try (Repository repo = repoManager.openRepository(project)) {
+      return hook.advertiseRefs(repo.getAllRefs());
+    }
+  }
+
+  private VisibleRefFilter newFilter(Repository repo, Project.NameKey project) {
+    return refFilterFactory.create(projectCache.get(project), repo);
+  }
+
+  private static ObjectId obj(ChangeData cd, int psNum) throws Exception {
+    PatchSet.Id psId = new PatchSet.Id(cd.getId(), psNum);
+    PatchSet ps = cd.patchSet(psId);
+    assertWithMessage("%s not found in %s", psId, cd.patchSets()).that(ps).isNotNull();
+    return ObjectId.fromString(ps.getRevision().get());
+  }
+
+  private AccountGroup.UUID createSelfOwnedGroup(String name, TestAccount... members)
+      throws RestApiException {
+    return createGroup(name, null, members);
+  }
+
+  private AccountGroup.UUID createGroup(
+      String name, @Nullable AccountGroup.UUID ownerGroup, TestAccount... members)
+      throws RestApiException {
+    GroupInput groupInput = new GroupInput();
+    groupInput.name = name(name);
+    groupInput.ownerId = ownerGroup != null ? ownerGroup.get() : null;
+    groupInput.members =
+        Arrays.stream(members).map(m -> String.valueOf(m.id.get())).collect(toList());
+    return new AccountGroup.UUID(gApi.groups().create(groupInput).get().id);
+  }
+
+  /**
+   * Create a resetter to reset the group branches in All-Users. This makes the group data between
+   * ReviewDb and NoteDb inconsistent, but in the context of this test class we only care about refs
+   * and hence this is not an issue. Once groups are no longer in ReviewDb and {@link
+   * AbstractDaemonTest#resetProjects} takes care to reset group branches we no longer need this
+   * method.
+   */
+  private ProjectResetter resetGroups() throws IOException {
+    return projectResetter
+        .builder()
+        .reset(allUsers, RefNames.REFS_GROUPS + "*", RefNames.REFS_GROUPNAMES)
+        .build();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SshPushForReviewIT.java b/javatests/com/google/gerrit/acceptance/git/SshPushForReviewIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SshPushForReviewIT.java
rename to javatests/com/google/gerrit/acceptance/git/SshPushForReviewIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
rename to javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java
rename to javatests/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
new file mode 100644
index 0000000..81ee3a0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -0,0 +1,545 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.junit.Test;
+
+@NoHttpd
+public class SubmoduleSubscriptionsIT extends AbstractSubmoduleSubscription {
+  @ConfigSuite.Config
+  public static Config submitWholeTopicEnabled() {
+    return submitWholeTopicEnabledConfig();
+  }
+
+  @Test
+  @GerritConfig(name = "submodule.enableSuperProjectSubscriptions", value = "false")
+  public void testSubscriptionWithoutGlobalServerSetting() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void subscriptionWithoutSpecificSubscription() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void subscriptionToEmptyRepo() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    pushChangeTo(subRepo, "master");
+    ObjectId subHEAD = pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isTrue();
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
+  }
+
+  @Test
+  public void subscriptionToExistingRepo() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    ObjectId subHEAD = pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isTrue();
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
+  }
+
+  @Test
+  public void subscriptionWildcardACLForSingleBranch() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    // master is allowed to be subscribed to master branch only:
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", null);
+    // create 'branch':
+    pushChangeTo(superRepo, "branch");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "master");
+
+    ObjectId subHEAD = pushChangeTo(subRepo, "master");
+
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
+    assertThat(hasSubmodule(superRepo, "branch", "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void subscriptionWildcardACLForMissingProject() throws Exception {
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/*", "not-existing-super-project", "refs/heads/*");
+    pushChangeTo(subRepo, "master");
+  }
+
+  @Test
+  public void subscriptionWildcardACLForMissingBranch() throws Exception {
+    createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/*", "super-project", "refs/heads/*");
+    pushChangeTo(subRepo, "foo");
+  }
+
+  @Test
+  public void subscriptionWildcardACLForMissingGitmodules() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/*", "super-project", "refs/heads/*");
+    pushChangeTo(superRepo, "master");
+    pushChangeTo(subRepo, "master");
+  }
+
+  @Test
+  public void subscriptionWildcardACLOneOnOneMapping() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    // any branch is allowed to be subscribed to the same superprojects branch:
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/*", "super-project", "refs/heads/*");
+
+    // create 'branch' in both repos:
+    pushChangeTo(superRepo, "branch");
+    pushChangeTo(subRepo, "branch");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "branch");
+
+    ObjectId subHEAD1 = pushChangeTo(subRepo, "master");
+    ObjectId subHEAD2 = pushChangeTo(subRepo, "branch");
+
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD1);
+    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD2);
+
+    // Now test that cross subscriptions do not work:
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "branch");
+    ObjectId subHEAD3 = pushChangeTo(subRepo, "branch");
+
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD1);
+    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD3);
+  }
+
+  @Test
+  public void subscriptionWildcardACLForManyBranches() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
+    // Any branch is allowed to be subscribed to any superproject branch:
+    allowSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/*", "super-project", null, false);
+    pushChangeTo(superRepo, "branch");
+    pushChangeTo(subRepo, "another-branch");
+    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "another-branch");
+    ObjectId subHEAD = pushChangeTo(subRepo, "another-branch");
+    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD);
+  }
+
+  @Test
+  public void subscriptionWildcardACLOneToManyBranches() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
+    // Any branch is allowed to be subscribed to any superproject branch:
+    allowSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/*", false);
+    pushChangeTo(superRepo, "branch");
+    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "master");
+    ObjectId subHEAD = pushChangeTo(subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD);
+
+    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "branch");
+    pushChangeTo(subRepo, "branch");
+
+    // no change expected, as only master is subscribed:
+    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD);
+  }
+
+  @Test
+  @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "false")
+  public void testSubmoduleShortCommitMessage() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+
+    // The first update doesn't include any commit messages
+    ObjectId subRepoId = pushChangeTo(subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
+    expectToHaveCommitMessage(superRepo, "master", "Update git submodules\n\n");
+
+    // Any following update also has a short message
+    subRepoId = pushChangeTo(subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
+    expectToHaveCommitMessage(superRepo, "master", "Update git submodules\n\n");
+  }
+
+  @Test
+  @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "SUBJECT_ONLY")
+  public void testSubmoduleSubjectCommitMessage() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    ObjectId subHEAD = pushChangeTo(subRepo, "master");
+
+    // The first update doesn't include the rev log
+    RevWalk rw = subRepo.getRevWalk();
+    expectToHaveCommitMessage(
+        superRepo,
+        "master",
+        "Update git submodules\n\n"
+            + "* Update "
+            + name("subscribed-to-project")
+            + " from branch 'master'\n  to "
+            + subHEAD.getName());
+
+    // The next commit should generate only its commit message,
+    // omitting previous commit logs
+    subHEAD = pushChangeTo(subRepo, "master");
+    RevCommit subCommitMsg = rw.parseCommit(subHEAD);
+    expectToHaveCommitMessage(
+        superRepo,
+        "master",
+        "Update git submodules\n\n"
+            + "* Update "
+            + name("subscribed-to-project")
+            + " from branch 'master'\n  to "
+            + subHEAD.getName()
+            + "\n  - "
+            + subCommitMsg.getShortMessage());
+  }
+
+  @Test
+  public void submoduleCommitMessage() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    ObjectId subHEAD = pushChangeTo(subRepo, "master");
+
+    // The first update doesn't include the rev log
+    RevWalk rw = subRepo.getRevWalk();
+    expectToHaveCommitMessage(
+        superRepo,
+        "master",
+        "Update git submodules\n\n"
+            + "* Update "
+            + name("subscribed-to-project")
+            + " from branch 'master'\n  to "
+            + subHEAD.getName());
+
+    // The next commit should generate only its commit message,
+    // omitting previous commit logs
+    subHEAD = pushChangeTo(subRepo, "master");
+    RevCommit subCommitMsg = rw.parseCommit(subHEAD);
+    expectToHaveCommitMessage(
+        superRepo,
+        "master",
+        "Update git submodules\n\n"
+            + "* Update "
+            + name("subscribed-to-project")
+            + " from branch 'master'\n  to "
+            + subHEAD.getName()
+            + "\n  - "
+            + subCommitMsg.getFullMessage().replace("\n", "\n    "));
+  }
+
+  @Test
+  public void subscriptionUnsubscribe() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+
+    pushChangeTo(subRepo, "master");
+    ObjectId subHEADbeforeUnsubscribing = pushChangeTo(subRepo, "master");
+
+    deleteAllSubscriptions(superRepo, "master");
+    expectToHaveSubmoduleState(
+        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
+
+    pushChangeTo(superRepo, "refs/heads/master", "commit after unsubscribe", "");
+    pushChangeTo(subRepo, "refs/heads/master", "commit after unsubscribe", "");
+    expectToHaveSubmoduleState(
+        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
+  }
+
+  @Test
+  public void subscriptionUnsubscribeByDeletingGitModules() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+
+    pushChangeTo(subRepo, "master");
+    ObjectId subHEADbeforeUnsubscribing = pushChangeTo(subRepo, "master");
+
+    deleteGitModulesFile(superRepo, "master");
+    expectToHaveSubmoduleState(
+        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
+
+    pushChangeTo(superRepo, "refs/heads/master", "commit after unsubscribe", "");
+    pushChangeTo(subRepo, "refs/heads/master", "commit after unsubscribe", "");
+    expectToHaveSubmoduleState(
+        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
+  }
+
+  @Test
+  public void subscriptionToDifferentBranches() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/foo", "super-project", "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "foo");
+    ObjectId subFoo = pushChangeTo(subRepo, "foo");
+    pushChangeTo(subRepo, "master");
+
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subFoo);
+  }
+
+  @Test
+  public void branchCircularSubscription() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "super-project", "refs/heads/master", "subscribed-to-project", "refs/heads/master");
+
+    pushChangeTo(subRepo, "master");
+    pushChangeTo(superRepo, "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(subRepo, "master", "super-project", "master");
+
+    pushChangeTo(subRepo, "master");
+    pushChangeTo(superRepo, "master");
+
+    assertThat(hasSubmodule(subRepo, "master", "super-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void projectCircularSubscription() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "super-project", "refs/heads/dev", "subscribed-to-project", "refs/heads/dev");
+
+    pushChangeTo(subRepo, "master");
+    pushChangeTo(superRepo, "master");
+    pushChangeTo(subRepo, "dev");
+    pushChangeTo(superRepo, "dev");
+
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(subRepo, "dev", "super-project", "dev");
+
+    ObjectId subMasterHead = pushChangeTo(subRepo, "master");
+    ObjectId superDevHead = pushChangeTo(superRepo, "dev");
+
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isTrue();
+    assertThat(hasSubmodule(subRepo, "dev", "super-project")).isTrue();
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subMasterHead);
+    expectToHaveSubmoduleState(subRepo, "dev", "super-project", superDevHead);
+  }
+
+  @Test
+  public void subscriptionFailOnMissingACL() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void subscriptionFailOnWrongProjectACL() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "wrong-super-project", "refs/heads/master");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void subscriptionFailOnWrongBranchACL() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/wrong-branch");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void subscriptionInheritACL() throws Exception {
+    createProjectWithPush("config-repo");
+    createProjectWithPush("config-repo2", new Project.NameKey(name("config-repo")));
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo =
+        createProjectWithPush("subscribed-to-project", new Project.NameKey(name("config-repo2")));
+    allowMatchingSubmoduleSubscription(
+        "config-repo", "refs/heads/*", "super-project", "refs/heads/*");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    ObjectId subHEAD = pushChangeTo(subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
+  }
+
+  @Test
+  public void allowedButNotSubscribed() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+
+    pushChangeTo(subRepo, "master");
+    subRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
+        .message("some change")
+        .add("b.txt", "b contents for testing")
+        .create();
+    String refspec = "HEAD:refs/heads/master";
+    PushResult r =
+        Iterables.getOnlyElement(
+            subRepo.git().push().setRemote("origin").setRefSpecs(new RefSpec(refspec)).call());
+    assertThat(r.getMessages()).doesNotContain("error");
+    assertThat(r.getRemoteUpdate("refs/heads/master").getStatus())
+        .isEqualTo(RemoteRefUpdate.Status.OK);
+
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void subscriptionDeepRelative() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("nested/subscribed-to-project");
+    // master is allowed to be subscribed to any superprojects branch:
+    allowMatchingSubmoduleSubscription(
+        "nested/subscribed-to-project", "refs/heads/master", "super-project", null);
+
+    pushChangeTo(subRepo, "master");
+    createRelativeSubmoduleSubscription(
+        superRepo, "master", "../", "nested/subscribed-to-project", "master");
+
+    ObjectId subHEAD = pushChangeTo(subRepo, "master");
+
+    expectToHaveSubmoduleState(superRepo, "master", "nested/subscribed-to-project", subHEAD);
+  }
+
+  @Test
+  @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "SUBJECT_ONLY")
+  @GerritConfig(name = "submodule.maxCommitMessages", value = "1")
+  public void submoduleSubjectCommitMessageCountLimit() throws Exception {
+    testSubmoduleSubjectCommitMessageAndExpectTruncation();
+  }
+
+  @Test
+  @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "SUBJECT_ONLY")
+  @GerritConfig(name = "submodule.maxCombinedCommitMessageSize", value = "220")
+  public void submoduleSubjectCommitMessageSizeLimit() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isFalse();
+    testSubmoduleSubjectCommitMessageAndExpectTruncation();
+  }
+
+  private void testSubmoduleSubjectCommitMessageAndExpectTruncation() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    // The first update doesn't include the rev log, so we ignore it
+    pushChangeTo(subRepo, "master");
+
+    // Next, we push two commits at once. Since maxCommitMessages=1, we expect to see only the first
+    // message plus ellipsis to mark truncation.
+    ObjectId subHEAD = pushChangesTo(subRepo, "master", 2);
+    RevCommit subCommitMsg = subRepo.getRevWalk().parseCommit(subHEAD);
+    expectToHaveCommitMessage(
+        superRepo,
+        "master",
+        String.format(
+            "Update git submodules\n\n* Update %s from branch 'master'\n  to %s\n  - %s\n\n[...]",
+            name("subscribed-to-project"), subHEAD.getName(), subCommitMsg.getShortMessage()));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
new file mode 100644
index 0000000..95b6a2a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -0,0 +1,813 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.getChangeId;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.TestSubmitInput;
+import com.google.gerrit.testing.ConfigSuite;
+import java.util.ArrayDeque;
+import java.util.Map;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.RefSpec;
+import org.junit.Test;
+
+@NoHttpd
+public class SubmoduleSubscriptionsWholeTopicMergeIT extends AbstractSubmoduleSubscription {
+
+  @ConfigSuite.Default
+  public static Config mergeIfNecessary() {
+    return submitByMergeIfNecessary();
+  }
+
+  @ConfigSuite.Config
+  public static Config mergeAlways() {
+    return submitByMergeAlways();
+  }
+
+  @ConfigSuite.Config
+  public static Config cherryPick() {
+    return submitByCherryPickConfig();
+  }
+
+  @ConfigSuite.Config
+  public static Config rebaseAlways() {
+    return submitByRebaseAlwaysConfig();
+  }
+
+  @ConfigSuite.Config
+  public static Config rebaseIfNecessary() {
+    return submitByRebaseIfNecessaryConfig();
+  }
+
+  @Test
+  public void subscriptionUpdateOfManyChanges() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+
+    ObjectId subHEAD =
+        subRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .message("some change")
+            .add("a.txt", "a contents ")
+            .create();
+    subRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
+
+    RevCommit c = subRepo.getRevWalk().parseCommit(subHEAD);
+
+    RevCommit c1 =
+        subRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .message("first change")
+            .add("asdf", "asdf\n")
+            .create();
+    subRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+        .call();
+
+    subRepo.reset(c.getId());
+    RevCommit c2 =
+        subRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .message("qwerty")
+            .add("qwerty", "qwerty")
+            .create();
+
+    RevCommit c3 =
+        subRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .message("qwerty followup")
+            .add("qwerty", "qwerty\nqwerty\n")
+            .create();
+    subRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+        .call();
+
+    String id1 = getChangeId(subRepo, c1).get();
+    String id2 = getChangeId(subRepo, c2).get();
+    String id3 = getChangeId(subRepo, c3).get();
+    gApi.changes().id(id1).current().review(ReviewInput.approve());
+    gApi.changes().id(id2).current().review(ReviewInput.approve());
+    gApi.changes().id(id3).current().review(ReviewInput.approve());
+
+    Map<Branch.NameKey, ObjectId> preview = fetchFromSubmitPreview(id1);
+    gApi.changes().id(id1).current().submit();
+    ObjectId subRepoId =
+        subRepo
+            .git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/master")
+            .getObjectId();
+
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
+
+    // As the submodules have changed commits, the superproject tree will be
+    // different, so we cannot directly compare the trees here, so make
+    // assumptions only about the changed branches:
+    Project.NameKey p1 = new Project.NameKey(name("super-project"));
+    Project.NameKey p2 = new Project.NameKey(name("subscribed-to-project"));
+    assertThat(preview).containsKey(new Branch.NameKey(p1, "refs/heads/master"));
+    assertThat(preview).containsKey(new Branch.NameKey(p2, "refs/heads/master"));
+
+    if ((getSubmitType() == SubmitType.CHERRY_PICK)
+        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
+      // each change is updated and the respective target branch is updated:
+      assertThat(preview).hasSize(5);
+    } else if ((getSubmitType() == SubmitType.REBASE_IF_NECESSARY)) {
+      // Either the first is used first as is, then the second and third need
+      // rebasing, or those two stay as is and the first is rebased.
+      // add in 2 master branches, expect 3 or 4:
+      assertThat(preview.size()).isAnyOf(3, 4);
+    } else {
+      assertThat(preview).hasSize(2);
+    }
+  }
+
+  @Test
+  public void subscriptionUpdateIncludingChangeInSuperproject() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+
+    ObjectId subHEAD =
+        subRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .message("some change")
+            .add("a.txt", "a contents ")
+            .create();
+    subRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
+
+    RevCommit c = subRepo.getRevWalk().parseCommit(subHEAD);
+
+    RevCommit c1 =
+        subRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .message("first change")
+            .add("asdf", "asdf\n")
+            .create();
+    subRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+        .call();
+
+    subRepo.reset(c.getId());
+    RevCommit c2 =
+        subRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .message("qwerty")
+            .add("qwerty", "qwerty")
+            .create();
+
+    RevCommit c3 =
+        subRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .message("qwerty followup")
+            .add("qwerty", "qwerty\nqwerty\n")
+            .create();
+    subRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+        .call();
+
+    RevCommit c4 =
+        superRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .message("new change on superproject")
+            .add("foo", "bar")
+            .create();
+    superRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+        .call();
+
+    String id1 = getChangeId(subRepo, c1).get();
+    String id2 = getChangeId(subRepo, c2).get();
+    String id3 = getChangeId(subRepo, c3).get();
+    String id4 = getChangeId(superRepo, c4).get();
+    gApi.changes().id(id1).current().review(ReviewInput.approve());
+    gApi.changes().id(id2).current().review(ReviewInput.approve());
+    gApi.changes().id(id3).current().review(ReviewInput.approve());
+    gApi.changes().id(id4).current().review(ReviewInput.approve());
+
+    gApi.changes().id(id1).current().submit();
+    ObjectId subRepoId =
+        subRepo
+            .git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/master")
+            .getObjectId();
+
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
+  }
+
+  @Test
+  public void updateManySubmodules() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> sub1 = createProjectWithPush("sub1");
+    TestRepository<?> sub2 = createProjectWithPush("sub2");
+    TestRepository<?> sub3 = createProjectWithPush("sub3");
+
+    allowMatchingSubmoduleSubscription(
+        "sub1", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub2", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub3", "refs/heads/master", "super-project", "refs/heads/master");
+
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "sub1", "master");
+    prepareSubmoduleConfigEntry(config, "sub2", "master");
+    prepareSubmoduleConfigEntry(config, "sub3", "master");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
+
+    ObjectId sub1Id = pushChangeTo(sub1, "refs/for/master", "some message", "same-topic");
+    ObjectId sub2Id = pushChangeTo(sub2, "refs/for/master", "some message", "same-topic");
+    ObjectId sub3Id = pushChangeTo(sub3, "refs/for/master", "some message", "same-topic");
+
+    approve(getChangeId(sub1, sub1Id).get());
+    approve(getChangeId(sub2, sub2Id).get());
+    approve(getChangeId(sub3, sub3Id).get());
+
+    gApi.changes().id(getChangeId(sub1, sub1Id).get()).current().submit();
+
+    expectToHaveSubmoduleState(superRepo, "master", "sub1", sub1, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "sub2", sub2, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "sub3", sub3, "master");
+
+    superRepo
+        .git()
+        .fetch()
+        .setRemote("origin")
+        .call()
+        .getAdvertisedRef("refs/heads/master")
+        .getObjectId();
+
+    assertWithMessage("submodule subscription update should have made one commit")
+        .that(superRepo.getRepository().resolve("origin/master^"))
+        .isEqualTo(superPreviousId);
+  }
+
+  @Test
+  public void doNotUseFastForward() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project", false);
+    TestRepository<?> sub = createProjectWithPush("sub", false);
+
+    allowMatchingSubmoduleSubscription(
+        "sub", "refs/heads/master", "super-project", "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master", "sub", "master");
+
+    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
+
+    ObjectId superId = pushChangeTo(superRepo, "refs/for/master", "some message", "same-topic");
+
+    String subChangeId = getChangeId(sub, subId).get();
+    approve(subChangeId);
+    approve(getChangeId(superRepo, superId).get());
+
+    gApi.changes().id(subChangeId).current().submit();
+
+    expectToHaveSubmoduleState(superRepo, "master", "sub", sub, "master");
+    RevCommit superHead = getRemoteHead(name("super-project"), "master");
+    assertThat(superHead.getShortMessage()).contains("some message");
+    assertThat(superHead.getId()).isNotEqualTo(superId);
+  }
+
+  @Test
+  public void useFastForwardWhenNoSubmodule() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project", false);
+    TestRepository<?> sub = createProjectWithPush("sub", false);
+
+    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
+
+    ObjectId superId = pushChangeTo(superRepo, "refs/for/master", "some message", "same-topic");
+
+    String subChangeId = getChangeId(sub, subId).get();
+    approve(subChangeId);
+    approve(getChangeId(superRepo, superId).get());
+
+    gApi.changes().id(subChangeId).current().submit();
+
+    RevCommit superHead = getRemoteHead(name("super-project"), "master");
+    assertThat(superHead.getShortMessage()).isEqualTo("some message");
+    assertThat(superHead.getId()).isEqualTo(superId);
+  }
+
+  @Test
+  public void sameProjectSameBranchDifferentPaths() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> sub = createProjectWithPush("sub");
+
+    allowMatchingSubmoduleSubscription(
+        "sub", "refs/heads/master", "super-project", "refs/heads/master");
+
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "sub", "master");
+    prepareSubmoduleConfigEntry(config, "sub", "sub-copy", "master");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
+
+    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "");
+
+    approve(getChangeId(sub, subId).get());
+
+    gApi.changes().id(getChangeId(sub, subId).get()).current().submit();
+
+    expectToHaveSubmoduleState(superRepo, "master", "sub", sub, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "sub-copy", sub, "master");
+
+    superRepo
+        .git()
+        .fetch()
+        .setRemote("origin")
+        .call()
+        .getAdvertisedRef("refs/heads/master")
+        .getObjectId();
+
+    assertWithMessage("submodule subscription update should have made one commit")
+        .that(superRepo.getRepository().resolve("origin/master^"))
+        .isEqualTo(superPreviousId);
+  }
+
+  @Test
+  public void sameProjectDifferentBranchDifferentPaths() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> sub = createProjectWithPush("sub");
+
+    allowMatchingSubmoduleSubscription(
+        "sub", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub", "refs/heads/dev", "super-project", "refs/heads/master");
+
+    ObjectId devHead = pushChangeTo(sub, "dev");
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "sub", "sub-master", "master");
+    prepareSubmoduleConfigEntry(config, "sub", "sub-dev", "dev");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    ObjectId subMasterId =
+        pushChangeTo(sub, "refs/for/master", "some message", "b.txt", "content b", "same-topic");
+
+    sub.reset(devHead);
+    ObjectId subDevId =
+        pushChangeTo(
+            sub, "refs/for/dev", "some message in dev", "b.txt", "content b", "same-topic");
+
+    approve(getChangeId(sub, subMasterId).get());
+    approve(getChangeId(sub, subDevId).get());
+
+    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
+
+    gApi.changes().id(getChangeId(sub, subMasterId).get()).current().submit();
+
+    expectToHaveSubmoduleState(superRepo, "master", "sub-master", sub, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "sub-dev", sub, "dev");
+
+    superRepo
+        .git()
+        .fetch()
+        .setRemote("origin")
+        .call()
+        .getAdvertisedRef("refs/heads/master")
+        .getObjectId();
+
+    assertWithMessage("submodule subscription update should have made one commit")
+        .that(superRepo.getRepository().resolve("origin/master^"))
+        .isEqualTo(superPreviousId);
+  }
+
+  @Test
+  public void nonSubmoduleInSameTopic() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> sub = createProjectWithPush("sub");
+    TestRepository<?> standAlone = createProjectWithPush("standalone");
+
+    allowMatchingSubmoduleSubscription(
+        "sub", "refs/heads/master", "super-project", "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master", "sub", "master");
+
+    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
+
+    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
+    ObjectId standAloneId =
+        pushChangeTo(standAlone, "refs/for/master", "some message", "same-topic");
+
+    String subChangeId = getChangeId(sub, subId).get();
+    String standAloneChangeId = getChangeId(standAlone, standAloneId).get();
+    approve(subChangeId);
+    approve(standAloneChangeId);
+
+    gApi.changes().id(subChangeId).current().submit();
+
+    expectToHaveSubmoduleState(superRepo, "master", "sub", sub, "master");
+
+    ChangeStatus status = gApi.changes().id(standAloneChangeId).info().status;
+    assertThat(status).isEqualTo(ChangeStatus.MERGED);
+
+    superRepo
+        .git()
+        .fetch()
+        .setRemote("origin")
+        .call()
+        .getAdvertisedRef("refs/heads/master")
+        .getObjectId();
+
+    assertWithMessage("submodule subscription update should have made one commit")
+        .that(superRepo.getRepository().resolve("origin/master^"))
+        .isEqualTo(superPreviousId);
+  }
+
+  @Test
+  public void recursiveSubmodules() throws Exception {
+    TestRepository<?> topRepo = createProjectWithPush("top-project");
+    TestRepository<?> midRepo = createProjectWithPush("mid-project");
+    TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
+
+    allowMatchingSubmoduleSubscription(
+        "mid-project", "refs/heads/master", "top-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "bottom-project", "refs/heads/master", "mid-project", "refs/heads/master");
+
+    createSubmoduleSubscription(topRepo, "master", "mid-project", "master");
+    createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
+
+    ObjectId bottomHead = pushChangeTo(bottomRepo, "refs/for/master", "some message", "same-topic");
+    ObjectId topHead = pushChangeTo(topRepo, "refs/for/master", "some message", "same-topic");
+
+    String id1 = getChangeId(bottomRepo, bottomHead).get();
+    String id2 = getChangeId(topRepo, topHead).get();
+
+    gApi.changes().id(id1).current().review(ReviewInput.approve());
+    gApi.changes().id(id2).current().review(ReviewInput.approve());
+
+    gApi.changes().id(id1).current().submit();
+
+    expectToHaveSubmoduleState(midRepo, "master", "bottom-project", bottomRepo, "master");
+    expectToHaveSubmoduleState(topRepo, "master", "mid-project", midRepo, "master");
+  }
+
+  @Test
+  public void triangleSubmodules() throws Exception {
+    TestRepository<?> topRepo = createProjectWithPush("top-project");
+    TestRepository<?> midRepo = createProjectWithPush("mid-project");
+    TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
+
+    allowMatchingSubmoduleSubscription(
+        "mid-project", "refs/heads/master", "top-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "bottom-project", "refs/heads/master", "mid-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "bottom-project", "refs/heads/master", "top-project", "refs/heads/master");
+
+    createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "bottom-project", "master");
+    prepareSubmoduleConfigEntry(config, "mid-project", "master");
+    pushSubmoduleConfig(topRepo, "master", config);
+
+    ObjectId bottomHead = pushChangeTo(bottomRepo, "refs/for/master", "some message", "same-topic");
+    ObjectId topHead = pushChangeTo(topRepo, "refs/for/master", "some message", "same-topic");
+
+    String id1 = getChangeId(bottomRepo, bottomHead).get();
+    String id2 = getChangeId(topRepo, topHead).get();
+
+    gApi.changes().id(id1).current().review(ReviewInput.approve());
+    gApi.changes().id(id2).current().review(ReviewInput.approve());
+
+    gApi.changes().id(id1).current().submit();
+
+    expectToHaveSubmoduleState(midRepo, "master", "bottom-project", bottomRepo, "master");
+    expectToHaveSubmoduleState(topRepo, "master", "mid-project", midRepo, "master");
+    expectToHaveSubmoduleState(topRepo, "master", "bottom-project", bottomRepo, "master");
+  }
+
+  private String prepareBranchCircularSubscription() throws Exception {
+    TestRepository<?> topRepo = createProjectWithPush("top-project");
+    TestRepository<?> midRepo = createProjectWithPush("mid-project");
+    TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
+
+    createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
+    createSubmoduleSubscription(topRepo, "master", "mid-project", "master");
+    createSubmoduleSubscription(bottomRepo, "master", "top-project", "master");
+
+    allowMatchingSubmoduleSubscription(
+        "bottom-project", "refs/heads/master", "mid-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "mid-project", "refs/heads/master", "top-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "top-project", "refs/heads/master", "bottom-project", "refs/heads/master");
+
+    ObjectId bottomMasterHead = pushChangeTo(bottomRepo, "refs/for/master", "some message", "");
+    String changeId = getChangeId(bottomRepo, bottomMasterHead).get();
+
+    approve(changeId);
+    exception.expectMessage("Branch level circular subscriptions detected");
+    exception.expectMessage("top-project,refs/heads/master");
+    exception.expectMessage("mid-project,refs/heads/master");
+    exception.expectMessage("bottom-project,refs/heads/master");
+    return changeId;
+  }
+
+  @Test
+  public void branchCircularSubscription() throws Exception {
+    String changeId = prepareBranchCircularSubscription();
+    gApi.changes().id(changeId).current().submit();
+  }
+
+  @Test
+  public void branchCircularSubscriptionPreview() throws Exception {
+    String changeId = prepareBranchCircularSubscription();
+    gApi.changes().id(changeId).current().submitPreview();
+  }
+
+  @Test
+  public void projectCircularSubscriptionWholeTopic() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "super-project", "refs/heads/dev", "subscribed-to-project", "refs/heads/dev");
+
+    pushChangeTo(subRepo, "dev");
+    pushChangeTo(superRepo, "dev");
+
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(subRepo, "dev", "super-project", "dev");
+
+    ObjectId subMasterHead =
+        pushChangeTo(
+            subRepo, "refs/for/master", "b.txt", "content b", "some message", "same-topic");
+    ObjectId superDevHead = pushChangeTo(superRepo, "refs/for/dev", "some message", "same-topic");
+
+    approve(getChangeId(subRepo, subMasterHead).get());
+    approve(getChangeId(superRepo, superDevHead).get());
+
+    exception.expectMessage("Project level circular subscriptions detected");
+    exception.expectMessage("subscribed-to-project");
+    exception.expectMessage("super-project");
+    gApi.changes().id(getChangeId(subRepo, subMasterHead).get()).current().submit();
+  }
+
+  @Test
+  public void projectNoSubscriptionWholeTopic() throws Exception {
+    TestRepository<?> repoA = createProjectWithPush("project-a");
+    TestRepository<?> repoB = createProjectWithPush("project-b");
+    // bootstrap the dev branch
+    ObjectId a0 = pushChangeTo(repoA, "dev");
+
+    // bootstrap the dev branch
+    ObjectId b0 = pushChangeTo(repoB, "dev");
+
+    // create a change for master branch in repo a
+    ObjectId aHead =
+        pushChangeTo(
+            repoA,
+            "refs/for/master",
+            "master.txt",
+            "content master A",
+            "some message in a master.txt",
+            "same-topic");
+
+    // create a change for master branch in repo b
+    ObjectId bHead =
+        pushChangeTo(
+            repoB,
+            "refs/for/master",
+            "master.txt",
+            "content master B",
+            "some message in b master.txt",
+            "same-topic");
+
+    // create a change for dev branch in repo a
+    repoA.reset(a0);
+    ObjectId aDevHead =
+        pushChangeTo(
+            repoA,
+            "refs/for/dev",
+            "dev.txt",
+            "content dev A",
+            "some message in a dev.txt",
+            "same-topic");
+
+    // create a change for dev branch in repo b
+    repoB.reset(b0);
+    ObjectId bDevHead =
+        pushChangeTo(
+            repoB,
+            "refs/for/dev",
+            "dev.txt",
+            "content dev B",
+            "some message in b dev.txt",
+            "same-topic");
+
+    approve(getChangeId(repoA, aHead).get());
+    approve(getChangeId(repoB, bHead).get());
+    approve(getChangeId(repoA, aDevHead).get());
+    approve(getChangeId(repoB, bDevHead).get());
+
+    gApi.changes().id(getChangeId(repoA, aDevHead).get()).current().submit();
+    assertThat(getRemoteHead(name("project-a"), "refs/heads/master").getShortMessage())
+        .contains("some message in a master.txt");
+    assertThat(getRemoteHead(name("project-a"), "refs/heads/dev").getShortMessage())
+        .contains("some message in a dev.txt");
+    assertThat(getRemoteHead(name("project-b"), "refs/heads/master").getShortMessage())
+        .contains("some message in b master.txt");
+    assertThat(getRemoteHead(name("project-b"), "refs/heads/dev").getShortMessage())
+        .contains("some message in b dev.txt");
+  }
+
+  @Test
+  public void twoProjectsMultipleBranchesWholeTopic() throws Exception {
+    TestRepository<?> repoA = createProjectWithPush("project-a");
+    TestRepository<?> repoB = createProjectWithPush("project-b");
+    // bootstrap the dev branch
+    pushChangeTo(repoA, "dev");
+
+    // bootstrap the dev branch
+    ObjectId b0 = pushChangeTo(repoB, "dev");
+
+    allowMatchingSubmoduleSubscription(
+        "project-b", "refs/heads/master", "project-a", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "project-b", "refs/heads/dev", "project-a", "refs/heads/dev");
+
+    createSubmoduleSubscription(repoA, "master", "project-b", "master");
+    createSubmoduleSubscription(repoA, "dev", "project-b", "dev");
+
+    // create a change for master branch in repo b
+    ObjectId bHead =
+        pushChangeTo(
+            repoB,
+            "refs/for/master",
+            "master.txt",
+            "content master B",
+            "some message in b master.txt",
+            "same-topic");
+
+    // create a change for dev branch in repo b
+    repoB.reset(b0);
+    ObjectId bDevHead =
+        pushChangeTo(
+            repoB,
+            "refs/for/dev",
+            "dev.txt",
+            "content dev B",
+            "some message in b dev.txt",
+            "same-topic");
+
+    approve(getChangeId(repoB, bHead).get());
+    approve(getChangeId(repoB, bDevHead).get());
+    gApi.changes().id(getChangeId(repoB, bHead).get()).current().submit();
+
+    expectToHaveSubmoduleState(repoA, "master", "project-b", repoB, "master");
+    expectToHaveSubmoduleState(repoA, "dev", "project-b", repoB, "dev");
+  }
+
+  @Test
+  public void retrySubmitAfterTornTopicOnLockFailure() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> sub1 = createProjectWithPush("sub1");
+    TestRepository<?> sub2 = createProjectWithPush("sub2");
+
+    allowMatchingSubmoduleSubscription(
+        "sub1", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub2", "refs/heads/master", "super-project", "refs/heads/master");
+
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "sub1", "master");
+    prepareSubmoduleConfigEntry(config, "sub2", "master");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
+
+    String topic = "same-topic";
+    ObjectId sub1Id = pushChangeTo(sub1, "refs/for/master", "some message", topic);
+    ObjectId sub2Id = pushChangeTo(sub2, "refs/for/master", "some message", topic);
+
+    String changeId1 = getChangeId(sub1, sub1Id).get();
+    String changeId2 = getChangeId(sub2, sub2Id).get();
+    approve(changeId1);
+    approve(changeId2);
+
+    TestSubmitInput input = new TestSubmitInput();
+    input.generateLockFailures =
+        new ArrayDeque<>(
+            ImmutableList.of(
+                false, // Change 1, attempt 1: success
+                true, // Change 2, attempt 1: lock failure
+                false, // Change 1, attempt 2: success
+                false, // Change 2, attempt 2: success
+                false)); // Leftover value to check total number of calls.
+    gApi.changes().id(changeId1).current().submit(input);
+
+    assertThat(info(changeId1).status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info(changeId2).status).isEqualTo(ChangeStatus.MERGED);
+
+    sub1.git().fetch().call();
+    RevWalk rw1 = sub1.getRevWalk();
+    RevCommit master1 = rw1.parseCommit(getRemoteHead(name("sub1"), "master"));
+    RevCommit change1Ps = parseCurrentRevision(rw1, changeId1);
+    assertThat(rw1.isMergedInto(change1Ps, master1)).isTrue();
+
+    sub2.git().fetch().call();
+    RevWalk rw2 = sub2.getRevWalk();
+    RevCommit master2 = rw2.parseCommit(getRemoteHead(name("sub2"), "master"));
+    RevCommit change2Ps = parseCurrentRevision(rw2, changeId2);
+    assertThat(rw2.isMergedInto(change2Ps, master2)).isTrue();
+
+    assertThat(input.generateLockFailures).containsExactly(false);
+
+    expectToHaveSubmoduleState(superRepo, "master", "sub1", sub1, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "sub2", sub2, "master");
+
+    assertWithMessage("submodule subscription update should have made one commit")
+        .that(superRepo.getRepository().resolve("origin/master^"))
+        .isEqualTo(superPreviousId);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
new file mode 100644
index 0000000..3550a99
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -0,0 +1,19 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "pgm",
+    labels = ["pgm"],
+    vm_args = ["-Xmx512m"],
+    deps = [
+        ":util",
+        "//java/com/google/gerrit/server/schema",
+    ],
+)
+
+java_library(
+    name = "util",
+    testonly = 1,
+    srcs = ["IndexUpgradeController.java"],
+    deps = ["//java/com/google/gerrit/acceptance:lib"],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java b/javatests/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java
rename to javatests/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ReindexIT.java
rename to javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
diff --git a/javatests/com/google/gerrit/acceptance/pgm/StandaloneNoteDbMigrationIT.java b/javatests/com/google/gerrit/acceptance/pgm/StandaloneNoteDbMigrationIT.java
new file mode 100644
index 0000000..35fcc94
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/StandaloneNoteDbMigrationIT.java
@@ -0,0 +1,330 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.GerritIndexStatus;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
+import com.google.gerrit.server.notedb.NotesMigrationState;
+import com.google.gerrit.server.schema.ReviewDbFactory;
+import com.google.gerrit.testing.NoteDbMode;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Key;
+import com.google.inject.TypeLiteral;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests for NoteDb migrations where the entry point is through a program, {@code
+ * migrate-to-note-db} or {@code daemon}.
+ *
+ * <p><strong>Note:</strong> These tests are very slow due to the repeated daemon startup. Prefer
+ * adding tests to {@link com.google.gerrit.acceptance.server.notedb.OnlineNoteDbMigrationIT} if
+ * possible.
+ */
+@NoHttpd
+public class StandaloneNoteDbMigrationIT extends StandaloneSiteTest {
+  private StoredConfig gerritConfig;
+  private StoredConfig noteDbConfig;
+
+  private Project.NameKey project;
+  private Change.Id changeId;
+
+  @Before
+  public void setUp() throws Exception {
+    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.OFF);
+    gerritConfig = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.detect());
+    // Unlike in the running server, for tests, we don't stack notedb.config on gerrit.config.
+    noteDbConfig = new FileBasedConfig(sitePaths.notedb_config.toFile(), FS.detect());
+  }
+
+  @Test
+  public void rebuildOneChangeTrialMode() throws Exception {
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertNoAutoMigrateConfig(noteDbConfig);
+    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
+    setUpOneChange();
+
+    migrate("--trial");
+    assertNotesMigrationState(NotesMigrationState.READ_WRITE_NO_SEQUENCE);
+
+    try (ServerContext ctx = startServer()) {
+      GitRepositoryManager repoManager = ctx.getInjector().getInstance(GitRepositoryManager.class);
+      ObjectId metaId;
+      try (Repository repo = repoManager.openRepository(project)) {
+        Ref ref = repo.exactRef(RefNames.changeMetaRef(changeId));
+        assertThat(ref).isNotNull();
+        metaId = ref.getObjectId();
+      }
+
+      try (ReviewDb db = openUnderlyingReviewDb(ctx)) {
+        Change c = db.changes().get(changeId);
+        assertThat(c).isNotNull();
+        NoteDbChangeState state = NoteDbChangeState.parse(c);
+        assertThat(state).isNotNull();
+        assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+        assertThat(state.getRefState()).hasValue(RefState.create(metaId, ImmutableMap.of()));
+      }
+    }
+  }
+
+  @Test
+  public void migrateOneChange() throws Exception {
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertNoAutoMigrateConfig(noteDbConfig);
+    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
+    setUpOneChange();
+
+    migrate();
+    assertNotesMigrationState(NotesMigrationState.NOTE_DB);
+
+    try (ServerContext ctx = startServer()) {
+      GitRepositoryManager repoManager = ctx.getInjector().getInstance(GitRepositoryManager.class);
+      try (Repository repo = repoManager.openRepository(project)) {
+        assertThat(repo.exactRef(RefNames.changeMetaRef(changeId))).isNotNull();
+      }
+
+      try (ReviewDb db = openUnderlyingReviewDb(ctx)) {
+        Change c = db.changes().get(changeId);
+        assertThat(c).isNotNull();
+        NoteDbChangeState state = NoteDbChangeState.parse(c);
+        assertThat(state).isNotNull();
+        assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.NOTE_DB);
+        assertThat(state.getRefState()).isEmpty();
+
+        ChangeInput in = new ChangeInput(project.get(), "master", "NoteDb-only change");
+        in.newBranch = true;
+        GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+        Change.Id id2 = new Change.Id(gApi.changes().create(in).info()._number);
+        assertThat(db.changes().get(id2)).isNull();
+      }
+    }
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertAutoMigrateConfig(noteDbConfig, false);
+  }
+
+  @Test
+  public void migrationWithReindex() throws Exception {
+    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
+    setUpOneChange();
+
+    int version = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
+    GerritIndexStatus status = new GerritIndexStatus(sitePaths);
+    assertThat(status.getReady(ChangeSchemaDefinitions.NAME, version)).isTrue();
+    status.setReady(ChangeSchemaDefinitions.NAME, version, false);
+    status.save();
+    assertServerStartupFails();
+
+    migrate();
+    assertNotesMigrationState(NotesMigrationState.NOTE_DB);
+
+    status = new GerritIndexStatus(sitePaths);
+    assertThat(status.getReady(ChangeSchemaDefinitions.NAME, version)).isTrue();
+  }
+
+  @Test
+  public void onlineMigrationViaDaemon() throws Exception {
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertNoAutoMigrateConfig(noteDbConfig);
+
+    testOnlineMigration(u -> startServer(u.module(), "--migrate-to-note-db", "true"));
+
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertAutoMigrateConfig(noteDbConfig, false);
+  }
+
+  @Test
+  public void onlineMigrationViaConfig() throws Exception {
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertNoAutoMigrateConfig(noteDbConfig);
+
+    testOnlineMigration(
+        u -> {
+          gerritConfig.setBoolean("noteDb", "changes", "autoMigrate", true);
+          gerritConfig.save();
+          return startServer(u.module());
+        });
+
+    // Auto-migration is turned off in notedb.config, which takes precedence, but is still on in
+    // gerrit.config. This means Puppet can continue overwriting gerrit.config without turning
+    // auto-migration back on.
+    assertAutoMigrateConfig(gerritConfig, true);
+    assertAutoMigrateConfig(noteDbConfig, false);
+  }
+
+  @Test
+  public void onlineMigrationTrialModeViaFlag() throws Exception {
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertNoTrialConfig(gerritConfig);
+
+    assertNoAutoMigrateConfig(noteDbConfig);
+    assertNoTrialConfig(noteDbConfig);
+
+    testOnlineMigration(
+        u -> startServer(u.module(), "--migrate-to-note-db", "--trial"),
+        NotesMigrationState.READ_WRITE_NO_SEQUENCE);
+
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertNoTrialConfig(gerritConfig);
+
+    assertAutoMigrateConfig(noteDbConfig, true);
+    assertTrialConfig(noteDbConfig, true);
+  }
+
+  @Test
+  public void onlineMigrationTrialModeViaConfig() throws Exception {
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertNoTrialConfig(gerritConfig);
+
+    assertNoAutoMigrateConfig(noteDbConfig);
+    assertNoTrialConfig(noteDbConfig);
+
+    testOnlineMigration(
+        u -> {
+          gerritConfig.setBoolean("noteDb", "changes", "autoMigrate", true);
+          gerritConfig.setBoolean("noteDb", "changes", "trial", true);
+          gerritConfig.save();
+          return startServer(u.module());
+        },
+        NotesMigrationState.READ_WRITE_NO_SEQUENCE);
+
+    assertAutoMigrateConfig(gerritConfig, true);
+    assertTrialConfig(gerritConfig, true);
+
+    assertAutoMigrateConfig(noteDbConfig, true);
+    assertTrialConfig(noteDbConfig, true);
+  }
+
+  @FunctionalInterface
+  private interface StartServerWithMigration {
+    ServerContext start(IndexUpgradeController u) throws Exception;
+  }
+
+  private void testOnlineMigration(StartServerWithMigration start) throws Exception {
+    testOnlineMigration(start, NotesMigrationState.NOTE_DB);
+  }
+
+  private void testOnlineMigration(
+      StartServerWithMigration start, NotesMigrationState expectedEndState) throws Exception {
+    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
+    int prevVersion = ChangeSchemaDefinitions.INSTANCE.getPrevious().getVersion();
+    int currVersion = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
+
+    // Before storing any changes, switch back to the previous version.
+    GerritIndexStatus status = new GerritIndexStatus(sitePaths);
+    status.setReady(ChangeSchemaDefinitions.NAME, currVersion, false);
+    status.setReady(ChangeSchemaDefinitions.NAME, prevVersion, true);
+    status.save();
+
+    setOnlineUpgradeConfig(false);
+    setUpOneChange();
+    setOnlineUpgradeConfig(true);
+
+    IndexUpgradeController u = new IndexUpgradeController(1);
+    try (ServerContext ctx = start.start(u)) {
+      ChangeIndexCollection indexes = ctx.getInjector().getInstance(ChangeIndexCollection.class);
+      assertThat(indexes.getSearchIndex().getSchema().getVersion()).isEqualTo(prevVersion);
+
+      // Index schema upgrades happen after NoteDb migration, so waiting for those to complete
+      // should be sufficient.
+      u.runUpgrades();
+
+      assertThat(indexes.getSearchIndex().getSchema().getVersion()).isEqualTo(currVersion);
+      assertNotesMigrationState(expectedEndState);
+    }
+  }
+
+  private void setUpOneChange() throws Exception {
+    project = new Project.NameKey("project");
+    try (ServerContext ctx = startServer()) {
+      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+      gApi.projects().create("project");
+
+      ChangeInput in = new ChangeInput(project.get(), "master", "Test change");
+      in.newBranch = true;
+      changeId = new Change.Id(gApi.changes().create(in).info()._number);
+    }
+  }
+
+  private void migrate(String... additionalArgs) throws Exception {
+    runGerrit(
+        ImmutableList.of(
+            "migrate-to-note-db", "-d", sitePaths.site_path.toString(), "--show-stack-trace"),
+        ImmutableList.copyOf(additionalArgs));
+  }
+
+  private void assertNotesMigrationState(NotesMigrationState expected) throws Exception {
+    noteDbConfig.load();
+    assertThat(NotesMigrationState.forConfig(noteDbConfig)).hasValue(expected);
+  }
+
+  private ReviewDb openUnderlyingReviewDb(ServerContext ctx) throws Exception {
+    return ctx.getInjector()
+        .getInstance(Key.get(new TypeLiteral<SchemaFactory<ReviewDb>>() {}, ReviewDbFactory.class))
+        .open();
+  }
+
+  private static void assertNoAutoMigrateConfig(StoredConfig cfg) throws Exception {
+    cfg.load();
+    assertThat(cfg.getString("noteDb", "changes", "autoMigrate")).isNull();
+  }
+
+  private static void assertAutoMigrateConfig(StoredConfig cfg, boolean expected) throws Exception {
+    cfg.load();
+    assertThat(cfg.getString("noteDb", "changes", "autoMigrate")).isNotNull();
+    assertThat(cfg.getBoolean("noteDb", "changes", "autoMigrate", false)).isEqualTo(expected);
+  }
+
+  private static void assertNoTrialConfig(StoredConfig cfg) throws Exception {
+    cfg.load();
+    assertThat(cfg.getString("noteDb", "changes", "trial")).isNull();
+  }
+
+  private static void assertTrialConfig(StoredConfig cfg, boolean expected) throws Exception {
+    cfg.load();
+    assertThat(cfg.getString("noteDb", "changes", "trial")).isNotNull();
+    assertThat(cfg.getBoolean("noteDb", "changes", "trial", false)).isEqualTo(expected);
+  }
+
+  private void setOnlineUpgradeConfig(boolean enable) throws Exception {
+    gerritConfig.load();
+    gerritConfig.setBoolean("index", null, "onlineUpgrade", enable);
+    gerritConfig.save();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/StartStopDaemonIT.java b/javatests/com/google/gerrit/acceptance/pgm/StartStopDaemonIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/StartStopDaemonIT.java
rename to javatests/com/google/gerrit/acceptance/pgm/StartStopDaemonIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/AccountAssert.java b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/AccountAssert.java
rename to javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/BUILD b/javatests/com/google/gerrit/acceptance/rest/account/BUILD
new file mode 100644
index 0000000..217d716
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/account/BUILD
@@ -0,0 +1,24 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "rest_account",
+    labels = ["rest"],
+    deps = [":util"],
+)
+
+java_library(
+    name = "util",
+    testonly = 1,
+    srcs = [
+        "AccountAssert.java",
+        "CapabilityInfo.java",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/acceptance:lib",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//lib:gwtorm",
+        "//lib:junit",
+    ],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
rename to javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
rename to javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/EmailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/EmailIT.java
rename to javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
new file mode 100644
index 0000000..2aa8d58
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -0,0 +1,866 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountExternalIdsInput;
+import com.google.gerrit.extensions.common.AccountExternalIdInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gson.reflect.TypeToken;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import org.eclipse.jgit.api.errors.TransportException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
+import org.eclipse.jgit.util.MutableInteger;
+import org.junit.Test;
+
+public class ExternalIdIT extends AbstractDaemonTest {
+  @Inject private AccountsUpdate.Server accountsUpdate;
+  @Inject private ExternalIds externalIds;
+  @Inject private ExternalIdReader externalIdReader;
+  @Inject private ExternalIdNotes.Factory externalIdNotesFactory;
+
+  @Test
+  public void getExternalIds() throws Exception {
+    Collection<ExternalId> expectedIds = accountCache.get(user.getId()).getExternalIds();
+    List<AccountExternalIdInfo> expectedIdInfos = toExternalIdInfos(expectedIds);
+
+    RestResponse response = userRestSession.get("/accounts/self/external.ids");
+    response.assertOK();
+
+    List<AccountExternalIdInfo> results =
+        newGson()
+            .fromJson(
+                response.getReader(), new TypeToken<List<AccountExternalIdInfo>>() {}.getType());
+
+    Collections.sort(expectedIdInfos);
+    Collections.sort(results);
+    assertThat(results).containsExactlyElementsIn(expectedIdInfos);
+  }
+
+  @Test
+  public void getExternalIdsOfOtherUserNotAllowed() throws Exception {
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("access database not permitted");
+    gApi.accounts().id(admin.id.get()).getExternalIds();
+  }
+
+  @Test
+  public void getExternalIdsOfOtherUserWithAccessDatabase() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    Collection<ExternalId> expectedIds = accountCache.get(admin.getId()).getExternalIds();
+    List<AccountExternalIdInfo> expectedIdInfos = toExternalIdInfos(expectedIds);
+
+    RestResponse response = userRestSession.get("/accounts/" + admin.id + "/external.ids");
+    response.assertOK();
+
+    List<AccountExternalIdInfo> results =
+        newGson()
+            .fromJson(
+                response.getReader(), new TypeToken<List<AccountExternalIdInfo>>() {}.getType());
+
+    Collections.sort(expectedIdInfos);
+    Collections.sort(results);
+    assertThat(results).containsExactlyElementsIn(expectedIdInfos);
+  }
+
+  @Test
+  public void deleteExternalIds() throws Exception {
+    setApiUser(user);
+    List<AccountExternalIdInfo> externalIds = gApi.accounts().self().getExternalIds();
+
+    List<String> toDelete = new ArrayList<>();
+    List<AccountExternalIdInfo> expectedIds = new ArrayList<>();
+    for (AccountExternalIdInfo id : externalIds) {
+      if (id.canDelete != null && id.canDelete) {
+        toDelete.add(id.identity);
+        continue;
+      }
+      expectedIds.add(id);
+    }
+
+    assertThat(toDelete).hasSize(1);
+
+    RestResponse response = userRestSession.post("/accounts/self/external.ids:delete", toDelete);
+    response.assertNoContent();
+    List<AccountExternalIdInfo> results = gApi.accounts().self().getExternalIds();
+    // The external ID in WebSession will not be set for tests, resulting that
+    // "mailto:user@example.com" can be deleted while "username:user" can't.
+    assertThat(results).hasSize(1);
+    assertThat(results).containsExactlyElementsIn(expectedIds);
+  }
+
+  @Test
+  public void deleteExternalIdsOfOtherUserNotAllowed() throws Exception {
+    List<AccountExternalIdInfo> extIds = gApi.accounts().self().getExternalIds();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("access database not permitted");
+    gApi.accounts()
+        .id(admin.id.get())
+        .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList()));
+  }
+
+  @Test
+  public void deleteExternalIdOfOtherUserUnderOwnAccount_UnprocessableEntity() throws Exception {
+    List<AccountExternalIdInfo> extIds = gApi.accounts().self().getExternalIds();
+    setApiUser(user);
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage(String.format("External id %s does not exist", extIds.get(0).identity));
+    gApi.accounts()
+        .self()
+        .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList()));
+  }
+
+  @Test
+  public void deleteExternalIdsOfOtherUserWithAccessDatabase() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    List<AccountExternalIdInfo> externalIds = gApi.accounts().self().getExternalIds();
+
+    List<String> toDelete = new ArrayList<>();
+    List<AccountExternalIdInfo> expectedIds = new ArrayList<>();
+    for (AccountExternalIdInfo id : externalIds) {
+      if (id.canDelete != null && id.canDelete) {
+        toDelete.add(id.identity);
+        continue;
+      }
+      expectedIds.add(id);
+    }
+
+    assertThat(toDelete).hasSize(1);
+
+    setApiUser(user);
+    RestResponse response =
+        userRestSession.post("/accounts/" + admin.id + "/external.ids:delete", toDelete);
+    response.assertNoContent();
+    List<AccountExternalIdInfo> results = gApi.accounts().id(admin.id.get()).getExternalIds();
+    // The external ID in WebSession will not be set for tests, resulting that
+    // "mailto:user@example.com" can be deleted while "username:user" can't.
+    assertThat(results).hasSize(1);
+    assertThat(results).containsExactlyElementsIn(expectedIds);
+  }
+
+  @Test
+  public void deleteExternalIdOfPreferredEmail() throws Exception {
+    String preferredEmail = gApi.accounts().self().get().email;
+    assertThat(preferredEmail).isNotNull();
+
+    gApi.accounts()
+        .self()
+        .deleteExternalIds(
+            ImmutableList.of(ExternalId.Key.create(SCHEME_MAILTO, preferredEmail).get()));
+    assertThat(gApi.accounts().self().get().email).isNull();
+  }
+
+  @Test
+  public void deleteExternalIds_Conflict() throws Exception {
+    List<String> toDelete = new ArrayList<>();
+    String externalIdStr = "username:" + user.username;
+    toDelete.add(externalIdStr);
+    RestResponse response = userRestSession.post("/accounts/self/external.ids:delete", toDelete);
+    response.assertConflict();
+    assertThat(response.getEntityContent())
+        .isEqualTo(String.format("External id %s cannot be deleted", externalIdStr));
+  }
+
+  @Test
+  public void deleteExternalIds_UnprocessableEntity() throws Exception {
+    List<String> toDelete = new ArrayList<>();
+    String externalIdStr = "mailto:user@domain.com";
+    toDelete.add(externalIdStr);
+    RestResponse response = userRestSession.post("/accounts/self/external.ids:delete", toDelete);
+    response.assertUnprocessableEntity();
+    assertThat(response.getEntityContent())
+        .isEqualTo(String.format("External id %s does not exist", externalIdStr));
+  }
+
+  @Test
+  public void fetchExternalIdsBranch() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
+
+    // refs/meta/external-ids is only visible to users with the 'Access Database' capability
+    try {
+      fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+      fail("expected TransportException");
+    } catch (TransportException e) {
+      assertThat(e.getMessage())
+          .isEqualTo(
+              "Remote does not have " + RefNames.REFS_EXTERNAL_IDS + " available for fetch.");
+    }
+
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    // re-clone to get new request context, otherwise the old global capabilities are still cached
+    // in the IdentifiedUser object
+    allUsersRepo = cloneProject(allUsers, user);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+  }
+
+  @Test
+  public void pushToExternalIdsBranch() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    // different case email is allowed
+    ExternalId newExtId = createExternalIdWithOtherCaseEmail("foo:bar");
+    addExtId(allUsersRepo, newExtId);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    List<AccountExternalIdInfo> extIdsBefore = gApi.accounts().self().getExternalIds();
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertThat(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS).getStatus()).isEqualTo(Status.OK);
+
+    List<AccountExternalIdInfo> extIdsAfter = gApi.accounts().self().getExternalIds();
+    assertThat(extIdsAfter)
+        .containsExactlyElementsIn(
+            Iterables.concat(extIdsBefore, ImmutableSet.of(toExternalIdInfo(newExtId))));
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithoutAccountId() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    insertExternalIdWithoutAccountId(
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithKeyThatDoesntMatchTheNoteId()
+      throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    insertExternalIdWithKeyThatDoesntMatchNoteId(
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithInvalidConfig() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    insertExternalIdWithInvalidConfig(
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithEmptyNote() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    insertExternalIdWithEmptyNote(
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdForNonExistingAccount() throws Exception {
+    testPushToExternalIdsBranchRejectsInvalidExternalId(
+        createExternalIdForNonExistingAccount("foo:bar"));
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithInvalidEmail() throws Exception {
+    testPushToExternalIdsBranchRejectsInvalidExternalId(
+        createExternalIdWithInvalidEmail("foo:bar"));
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsDuplicateEmails() throws Exception {
+    testPushToExternalIdsBranchRejectsInvalidExternalId(
+        createExternalIdWithDuplicateEmail("foo:bar"));
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsBadPassword() throws Exception {
+    testPushToExternalIdsBranchRejectsInvalidExternalId(createExternalIdWithBadPassword("foo"));
+  }
+
+  private void testPushToExternalIdsBranchRejectsInvalidExternalId(ExternalId invalidExtId)
+      throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    addExtId(allUsersRepo, invalidExtId);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void readExternalIdsWhenInvalidExternalIdsExist() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    resetCurrentApiUser();
+
+    insertValidExternalIds();
+    insertInvalidButParsableExternalIds();
+
+    Set<ExternalId> parseableExtIds = externalIds.all();
+
+    insertNonParsableExternalIds();
+
+    Set<ExternalId> extIds = externalIds.all();
+    assertThat(extIds).containsExactlyElementsIn(parseableExtIds);
+
+    for (ExternalId parseableExtId : parseableExtIds) {
+      ExternalId extId = externalIds.get(parseableExtId.key());
+      assertThat(extId).isEqualTo(parseableExtId);
+    }
+  }
+
+  @Test
+  public void checkConsistency() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    resetCurrentApiUser();
+
+    insertValidExternalIds();
+
+    ConsistencyCheckInput input = new ConsistencyCheckInput();
+    input.checkAccountExternalIds = new CheckAccountExternalIdsInput();
+    ConsistencyCheckInfo checkInfo = gApi.config().server().checkConsistency(input);
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems).isEmpty();
+
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+    expectedProblems.addAll(insertInvalidButParsableExternalIds());
+    expectedProblems.addAll(insertNonParsableExternalIds());
+
+    checkInfo = gApi.config().server().checkConsistency(input);
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems).hasSize(expectedProblems.size());
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems)
+        .containsExactlyElementsIn(expectedProblems);
+  }
+
+  @Test
+  public void checkConsistencyNotAllowed() throws Exception {
+    exception.expect(AuthException.class);
+    exception.expectMessage("access database not permitted");
+    gApi.config().server().checkConsistency(new ConsistencyCheckInput());
+  }
+
+  private ConsistencyProblemInfo consistencyError(String message) {
+    return new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, message);
+  }
+
+  private void insertValidExternalIds() throws Exception {
+    MutableInteger i = new MutableInteger();
+    String scheme = "valid";
+
+    // create valid external IDs
+    insertExtId(
+        ExternalId.createWithPassword(
+            ExternalId.Key.parse(nextId(scheme, i)),
+            admin.id,
+            "admin.other@example.com",
+            "secret-password"));
+    insertExtId(createExternalIdWithOtherCaseEmail(nextId(scheme, i)));
+  }
+
+  private Set<ConsistencyProblemInfo> insertInvalidButParsableExternalIds() throws Exception {
+    MutableInteger i = new MutableInteger();
+    String scheme = "invalid";
+
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+    ExternalId extIdForNonExistingAccount =
+        createExternalIdForNonExistingAccount(nextId(scheme, i));
+    insertExtIdForNonExistingAccount(extIdForNonExistingAccount);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdForNonExistingAccount.key().get()
+                + "' belongs to account that doesn't exist: "
+                + extIdForNonExistingAccount.accountId().get()));
+
+    ExternalId extIdWithInvalidEmail = createExternalIdWithInvalidEmail(nextId(scheme, i));
+    insertExtId(extIdWithInvalidEmail);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdWithInvalidEmail.key().get()
+                + "' has an invalid email: "
+                + extIdWithInvalidEmail.email()));
+
+    ExternalId extIdWithDuplicateEmail = createExternalIdWithDuplicateEmail(nextId(scheme, i));
+    insertExtId(extIdWithDuplicateEmail);
+    expectedProblems.add(
+        consistencyError(
+            "Email '"
+                + extIdWithDuplicateEmail.email()
+                + "' is not unique, it's used by the following external IDs: '"
+                + extIdWithDuplicateEmail.key().get()
+                + "', 'mailto:"
+                + extIdWithDuplicateEmail.email()
+                + "'"));
+
+    ExternalId extIdWithBadPassword = createExternalIdWithBadPassword("admin-username");
+    insertExtId(extIdWithBadPassword);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdWithBadPassword.key().get()
+                + "' has an invalid password: unrecognized algorithm"));
+
+    return expectedProblems;
+  }
+
+  private Set<ConsistencyProblemInfo> insertNonParsableExternalIds() throws IOException {
+    MutableInteger i = new MutableInteger();
+    String scheme = "corrupt";
+
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      String externalId = nextId(scheme, i);
+      String noteId = insertExternalIdWithoutAccountId(repo, rw, externalId);
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': Value for 'externalId."
+                  + externalId
+                  + ".accountId' is missing, expected account ID"));
+
+      externalId = nextId(scheme, i);
+      noteId = insertExternalIdWithKeyThatDoesntMatchNoteId(repo, rw, externalId);
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': SHA1 of external ID '"
+                  + externalId
+                  + "' does not match note ID '"
+                  + noteId
+                  + "'"));
+
+      noteId = insertExternalIdWithInvalidConfig(repo, rw, nextId(scheme, i));
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '" + noteId + "': Invalid line in config file"));
+
+      noteId = insertExternalIdWithEmptyNote(repo, rw, nextId(scheme, i));
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': Expected exactly 1 'externalId' section, found 0"));
+    }
+
+    return expectedProblems;
+  }
+
+  private ExternalId createExternalIdWithOtherCaseEmail(String externalId) {
+    return ExternalId.createWithPassword(
+        ExternalId.Key.parse(externalId), admin.id, admin.email.toUpperCase(Locale.US), "password");
+  }
+
+  private String insertExternalIdWithoutAccountId(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    return insertExternalId(
+        repo,
+        rw,
+        (ins, noteMap) -> {
+          ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
+          ObjectId noteId = extId.key().sha1();
+          Config c = new Config();
+          extId.writeToConfig(c);
+          c.unset("externalId", extId.key().get(), "accountId");
+          byte[] raw = c.toText().getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
+  }
+
+  private String insertExternalIdWithKeyThatDoesntMatchNoteId(
+      Repository repo, RevWalk rw, String externalId) throws IOException {
+    return insertExternalId(
+        repo,
+        rw,
+        (ins, noteMap) -> {
+          ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
+          ObjectId noteId = ExternalId.Key.parse(externalId + "x").sha1();
+          Config c = new Config();
+          extId.writeToConfig(c);
+          byte[] raw = c.toText().getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
+  }
+
+  private String insertExternalIdWithInvalidConfig(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    return insertExternalId(
+        repo,
+        rw,
+        (ins, noteMap) -> {
+          ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+          byte[] raw = "bad-config".getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
+  }
+
+  private String insertExternalIdWithEmptyNote(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    return insertExternalId(
+        repo,
+        rw,
+        (ins, noteMap) -> {
+          ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+          byte[] raw = "".getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
+  }
+
+  private String insertExternalId(Repository repo, RevWalk rw, ExternalIdInserter extIdInserter)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = extIdInserter.addNote(ins, noteMap);
+
+      CommitBuilder cb = new CommitBuilder();
+      cb.setMessage("Update external IDs");
+      cb.setTreeId(noteMap.writeTree(ins));
+      cb.setAuthor(admin.getIdent());
+      cb.setCommitter(admin.getIdent());
+      if (!rev.equals(ObjectId.zeroId())) {
+        cb.setParentId(rev);
+      } else {
+        cb.setParentIds(); // Ref is currently nonexistent, commit has no parents.
+      }
+      if (cb.getTreeId() == null) {
+        if (rev.equals(ObjectId.zeroId())) {
+          cb.setTreeId(ins.insert(OBJ_TREE, new byte[] {})); // No parent, assume empty tree.
+        } else {
+          RevCommit p = rw.parseCommit(rev);
+          cb.setTreeId(p.getTree()); // Copy tree from parent.
+        }
+      }
+      ObjectId commitId = ins.insert(cb);
+      ins.flush();
+
+      RefUpdate u = repo.updateRef(RefNames.REFS_EXTERNAL_IDS);
+      u.setExpectedOldObjectId(rev);
+      u.setNewObjectId(commitId);
+      RefUpdate.Result res = u.update();
+      switch (res) {
+        case NEW:
+        case FAST_FORWARD:
+        case NO_CHANGE:
+        case RENAMED:
+        case FORCED:
+          break;
+        case LOCK_FAILURE:
+        case IO_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
+          throw new IOException("Updating external IDs failed with " + res);
+      }
+      return noteId.getName();
+    }
+  }
+
+  private ExternalId createExternalIdForNonExistingAccount(String externalId) {
+    return ExternalId.create(ExternalId.Key.parse(externalId), new Account.Id(1));
+  }
+
+  private ExternalId createExternalIdWithInvalidEmail(String externalId) {
+    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id, "invalid-email");
+  }
+
+  private ExternalId createExternalIdWithDuplicateEmail(String externalId) {
+    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id, admin.email);
+  }
+
+  private ExternalId createExternalIdWithBadPassword(String username) {
+    return ExternalId.create(
+        ExternalId.Key.create(SCHEME_USERNAME, username),
+        admin.id,
+        null,
+        "non-hashed-password-is-not-allowed");
+  }
+
+  private static String nextId(String scheme, MutableInteger i) {
+    return scheme + ":foo" + ++i.value;
+  }
+
+  @Test
+  public void readExternalIdWithAccountIdThatCanBeExpressedInKiB() throws Exception {
+    ExternalId.Key extIdKey = ExternalId.Key.parse("foo:bar");
+    Account.Id accountId = new Account.Id(1024 * 100);
+    accountsUpdate
+        .create()
+        .insert(
+            "Create Account with Bad External ID",
+            accountId,
+            u -> u.addExternalId(ExternalId.create(extIdKey, accountId)));
+    ExternalId extId = externalIds.get(extIdKey);
+    assertThat(extId.accountId()).isEqualTo(accountId);
+  }
+
+  @Test
+  public void checkNoReloadAfterUpdate() throws Exception {
+    Set<ExternalId> expectedExtIds = new HashSet<>(externalIds.byAccount(admin.id));
+    try (AutoCloseable ctx = createFailOnLoadContext()) {
+      // insert external ID
+      ExternalId extId = ExternalId.create("foo", "bar", admin.id);
+      insertExtId(extId);
+      expectedExtIds.add(extId);
+      assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
+
+      // update external ID
+      expectedExtIds.remove(extId);
+      ExternalId extId2 = ExternalId.createWithEmail("foo", "bar", admin.id, "foo.bar@example.com");
+      accountsUpdate
+          .create()
+          .update("Update External ID", admin.id, u -> u.updateExternalId(extId2));
+      expectedExtIds.add(extId2);
+      assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
+
+      // delete external ID
+      accountsUpdate
+          .create()
+          .update("Delete External ID", admin.id, u -> u.deleteExternalId(extId));
+      expectedExtIds.remove(extId2);
+      assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
+    }
+  }
+
+  @Test
+  public void byAccountFailIfReadingExternalIdsFails() throws Exception {
+    try (AutoCloseable ctx = createFailOnLoadContext()) {
+      // update external ID branch so that external IDs need to be reloaded
+      insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id));
+
+      exception.expect(IOException.class);
+      externalIds.byAccount(admin.id);
+    }
+  }
+
+  @Test
+  public void byEmailFailIfReadingExternalIdsFails() throws Exception {
+    try (AutoCloseable ctx = createFailOnLoadContext()) {
+      // update external ID branch so that external IDs need to be reloaded
+      insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id));
+
+      exception.expect(IOException.class);
+      externalIds.byEmail(admin.email);
+    }
+  }
+
+  @Test
+  public void byAccountUpdateExternalIdsBehindGerritsBack() throws Exception {
+    Set<ExternalId> expectedExternalIds = new HashSet<>(externalIds.byAccount(admin.id));
+    ExternalId newExtId = ExternalId.create("foo", "bar", admin.id);
+    insertExtIdBehindGerritsBack(newExtId);
+    expectedExternalIds.add(newExtId);
+    assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExternalIds);
+  }
+
+  private void insertExtId(ExternalId extId) throws Exception {
+    accountsUpdate
+        .create()
+        .update("Add External ID", extId.accountId(), u -> u.addExternalId(extId));
+  }
+
+  private void insertExtIdForNonExistingAccount(ExternalId extId) throws Exception {
+    // Cannot use AccountsUpdate to insert an external ID for a non-existing account.
+    try (Repository repo = repoManager.openRepository(allUsers);
+        MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(repo);
+      extIdNotes.insert(extId);
+      extIdNotes.commit(update);
+      extIdNotes.updateCaches();
+    }
+  }
+
+  private void insertExtIdBehindGerritsBack(ExternalId extId) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      // Inserting an external ID "behind Gerrit's back" means that the caches are not updated.
+      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+      extIdNotes.insert(extId);
+      try (MetaDataUpdate metaDataUpdate =
+          new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, repo)) {
+        metaDataUpdate.getCommitBuilder().setAuthor(admin.getIdent());
+        metaDataUpdate.getCommitBuilder().setCommitter(admin.getIdent());
+        extIdNotes.commit(metaDataUpdate);
+      }
+    }
+  }
+
+  private void addExtId(TestRepository<?> testRepo, ExternalId... extIds)
+      throws IOException, OrmDuplicateKeyException, ConfigInvalidException {
+    ExternalIdNotes extIdNotes = externalIdNotesFactory.load(testRepo.getRepository());
+    extIdNotes.insert(Arrays.asList(extIds));
+    try (MetaDataUpdate metaDataUpdate =
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, testRepo.getRepository())) {
+      metaDataUpdate.getCommitBuilder().setAuthor(admin.getIdent());
+      metaDataUpdate.getCommitBuilder().setCommitter(admin.getIdent());
+      extIdNotes.commit(metaDataUpdate);
+      extIdNotes.updateCaches();
+    }
+  }
+
+  private List<AccountExternalIdInfo> toExternalIdInfos(Collection<ExternalId> extIds) {
+    return extIds.stream().map(this::toExternalIdInfo).collect(toList());
+  }
+
+  private AccountExternalIdInfo toExternalIdInfo(ExternalId extId) {
+    AccountExternalIdInfo info = new AccountExternalIdInfo();
+    info.identity = extId.key().get();
+    info.emailAddress = extId.email();
+    info.canDelete = !extId.isScheme(SCHEME_USERNAME) ? true : null;
+    info.trusted =
+        extId.isScheme(SCHEME_MAILTO)
+                || extId.isScheme(SCHEME_UUID)
+                || extId.isScheme(SCHEME_USERNAME)
+            ? true
+            : null;
+    return info;
+  }
+
+  private void allowPushOfExternalIds() throws IOException, ConfigInvalidException {
+    grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.READ);
+    grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.PUSH);
+  }
+
+  private void assertRefUpdateFailure(RemoteRefUpdate update, String msg) {
+    assertThat(update.getStatus()).isEqualTo(Status.REJECTED_OTHER_REASON);
+    assertThat(update.getMessage()).isEqualTo(msg);
+  }
+
+  private AutoCloseable createFailOnLoadContext() {
+    externalIdReader.setFailOnLoad(true);
+    return new AutoCloseable() {
+      @Override
+      public void close() {
+        externalIdReader.setFailOnLoad(false);
+      }
+    };
+  }
+
+  @FunctionalInterface
+  private interface ExternalIdInserter {
+    public ObjectId addNote(ObjectInserter ins, NoteMap noteMap) throws IOException;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
new file mode 100644
index 0000000..08322d8
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfo;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.restapi.account.GetDetail.AccountDetailInfo;
+import org.junit.Test;
+
+public class GetAccountDetailIT extends AbstractDaemonTest {
+  @Test
+  public void getDetail() throws Exception {
+    RestResponse r = adminRestSession.get("/accounts/" + admin.username + "/detail/");
+    AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
+    assertAccountInfo(admin, info);
+    Account account = accountCache.get(admin.getId()).getAccount();
+    assertThat(info.registeredOn).isEqualTo(account.getRegisteredOn());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
rename to javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
new file mode 100644
index 0000000..8cc165e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -0,0 +1,581 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.RestSession;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+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.Comment;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import org.apache.http.Header;
+import org.apache.http.message.BasicHeader;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ImpersonationIT extends AbstractDaemonTest {
+  @Inject private AccountControl.Factory accountControlFactory;
+
+  @Inject private ApprovalsUtil approvalsUtil;
+
+  @Inject private ChangeMessagesUtil cmUtil;
+
+  @Inject private CommentsUtil commentsUtil;
+
+  private RestSession anonRestSession;
+  private TestAccount admin2;
+  private GroupInfo newGroup;
+
+  @Before
+  public void setUp() throws Exception {
+    anonRestSession = new RestSession(server, null);
+    admin2 = accountCreator.admin2();
+    GroupInput gi = new GroupInput();
+    gi.name = name("New-Group");
+    gi.members = ImmutableList.of(user.id.toString());
+    newGroup = gApi.groups().create(gi).get();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    removeRunAs();
+  }
+
+  @Test
+  public void voteOnBehalfOf() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    ReviewInput in = ReviewInput.recommend();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+    revision.review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getAccountId()).isEqualTo(user.id);
+    assertThat(psa.getValue()).isEqualTo(1);
+    assertThat(psa.getRealAccountId()).isEqualTo(admin.id);
+
+    ChangeData cd = r.getChange();
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(db, cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(user.id);
+    assertThat(m.getRealAuthor()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void voteOnBehalfOfRequiresLabel() 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.message = "Message on behalf of";
+
+    exception.expect(AuthException.class);
+    exception.expectMessage("label required to post review on behalf of \"" + in.onBehalfOf + '"');
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfInvalidLabel() 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.label("Not-A-Label", 5);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("label \"Not-A-Label\" is not a configured label");
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfLabelNotPermitted() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType verified = Util.verified();
+    cfg.getLabelSections().put(verified.getName(), verified);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Verified", 1);
+
+    exception.expect(AuthException.class);
+    exception.expectMessage(
+        "not permitted to modify label \"Verified\" on behalf of \"" + in.onBehalfOf + '"');
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfWithComment() throws Exception {
+    testVoteOnBehalfOfWithComment();
+  }
+
+  @GerritConfig(name = "notedb.writeJson", value = "true")
+  @Test
+  public void voteOnBehalfOfWithCommentWritingJson() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    testVoteOnBehalfOfWithComment();
+  }
+
+  private void testVoteOnBehalfOfWithComment() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+    CommentInput ci = new CommentInput();
+    ci.path = Patch.COMMIT_MSG;
+    ci.side = Side.REVISION;
+    ci.line = 1;
+    ci.message = "message";
+    in.comments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getAccountId()).isEqualTo(user.id);
+    assertThat(psa.getValue()).isEqualTo(1);
+    assertThat(psa.getRealAccountId()).isEqualTo(admin.id);
+
+    ChangeData cd = r.getChange();
+    Comment c = Iterables.getOnlyElement(commentsUtil.publishedByChange(db, cd.notes()));
+    assertThat(c.message).isEqualTo(ci.message);
+    assertThat(c.author.getId()).isEqualTo(user.id);
+    assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id);
+  }
+
+  @GerritConfig(name = "notedb.writeJson", value = "true")
+  @Test
+  public void voteOnBehalfOfWithRobotComment() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+    RobotCommentInput ci = new RobotCommentInput();
+    ci.robotId = "my-robot";
+    ci.robotRunId = "abcd1234";
+    ci.path = Patch.COMMIT_MSG;
+    ci.side = Side.REVISION;
+    ci.line = 1;
+    ci.message = "message";
+    in.robotComments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    ChangeData cd = r.getChange();
+    RobotComment c = Iterables.getOnlyElement(commentsUtil.robotCommentsByChange(cd.notes()));
+    assertThat(c.message).isEqualTo(ci.message);
+    assertThat(c.robotId).isEqualTo(ci.robotId);
+    assertThat(c.robotRunId).isEqualTo(ci.robotRunId);
+    assertThat(c.author.getId()).isEqualTo(user.id);
+    assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void voteOnBehalfOfCannotModifyDrafts() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+
+    setApiUser(user);
+    DraftInput di = new DraftInput();
+    di.path = Patch.COMMIT_MSG;
+    di.side = Side.REVISION;
+    di.line = 1;
+    di.message = "message";
+    gApi.changes().id(r.getChangeId()).current().createDraft(di);
+
+    setApiUser(admin);
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+    in.drafts = DraftHandling.PUBLISH;
+
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to modify other user's drafts");
+    gApi.changes().id(r.getChangeId()).current().review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfMissingUser() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = "doesnotexist";
+    in.label("Code-Review", 1);
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: doesnotexist");
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfFailsWhenUserCannotSeeDestinationRef() throws Exception {
+    blockRead(newGroup);
+
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("on_behalf_of account " + user.id + " cannot see change");
+    revision.review(in);
+  }
+
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  @Test
+  public void voteOnBehalfOfInvisibleUserNotAllowed() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    setApiUser(accountCreator.user2());
+    assertThat(accountControlFactory.get().canSee(user.id)).isFalse();
+
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: " + in.onBehalfOf);
+    revision.review(in);
+  }
+
+  @Test
+  public void submitOnBehalfOf() throws Exception {
+    allowSubmitOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes().id(changeId).current().review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = admin2.email;
+    gApi.changes().id(changeId).current().submit(in);
+
+    ChangeData cd = r.getChange();
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    PatchSetApproval submitter =
+        approvalsUtil.getSubmitter(db, cd.notes(), cd.change().currentPatchSetId());
+    assertThat(submitter.getAccountId()).isEqualTo(admin2.id);
+    assertThat(submitter.getRealAccountId()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void submitOnBehalfOfInvalidUser() throws Exception {
+    allowSubmitOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes().id(changeId).current().review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = "doesnotexist";
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: doesnotexist");
+    gApi.changes().id(changeId).current().submit(in);
+  }
+
+  @Test
+  public void submitOnBehalfOfNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(project.get() + "~master~" + r.getChangeId())
+        .current()
+        .review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = admin2.email;
+    exception.expect(AuthException.class);
+    exception.expectMessage("submit as not permitted");
+    gApi.changes().id(project.get() + "~master~" + r.getChangeId()).current().submit(in);
+  }
+
+  @Test
+  public void submitOnBehalfOfFailsWhenUserCannotSeeDestinationRef() throws Exception {
+    blockRead(newGroup);
+
+    allowSubmitOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes().id(changeId).current().review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = user.email;
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("on_behalf_of account " + user.id + " cannot see change");
+    gApi.changes().id(changeId).current().submit(in);
+  }
+
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  @Test
+  public void submitOnBehalfOfInvisibleUserNotAllowed() throws Exception {
+    allowSubmitOnBehalfOf();
+    setApiUser(accountCreator.user2());
+    assertThat(accountControlFactory.get().canSee(user.id)).isFalse();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes().id(changeId).current().review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = user.email;
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: " + in.onBehalfOf);
+    gApi.changes().id(changeId).current().submit(in);
+  }
+
+  @Test
+  public void runAsValidUser() throws Exception {
+    allowRunAs();
+    RestResponse res = adminRestSession.getWithHeader("/accounts/self", runAsHeader(user.id));
+    res.assertOK();
+    AccountInfo account = newGson().fromJson(res.getEntityContent(), AccountInfo.class);
+    assertThat(account._accountId).isEqualTo(user.id.get());
+  }
+
+  @GerritConfig(name = "auth.enableRunAs", value = "false")
+  @Test
+  public void runAsDisabledByConfig() throws Exception {
+    allowRunAs();
+    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent())
+        .isEqualTo("X-Gerrit-RunAs disabled by auth.enableRunAs = false");
+  }
+
+  @Test
+  public void runAsNotPermitted() throws Exception {
+    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent()).isEqualTo("not permitted to use X-Gerrit-RunAs");
+  }
+
+  @Test
+  public void runAsNeverPermittedForAnonymousUsers() throws Exception {
+    allowRunAs();
+    RestResponse res = anonRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent()).isEqualTo("not permitted to use X-Gerrit-RunAs");
+  }
+
+  @Test
+  public void runAsInvalidUser() throws Exception {
+    allowRunAs();
+    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader("doesnotexist"));
+    res.assertForbidden();
+    assertThat(res.getEntityContent()).isEqualTo("no account matches X-Gerrit-RunAs");
+  }
+
+  @Test
+  public void voteUsingRunAsAvoidsRestrictionsOfOnBehalfOf() throws Exception {
+    allowRunAs();
+    PushOneCommit.Result r = createChange();
+
+    setApiUser(user);
+    DraftInput di = new DraftInput();
+    di.path = Patch.COMMIT_MSG;
+    di.side = Side.REVISION;
+    di.line = 1;
+    di.message = "inline comment";
+    gApi.changes().id(r.getChangeId()).current().createDraft(di);
+    setApiUser(admin);
+
+    // Things that aren't allowed with on_behalf_of:
+    //  - no labels.
+    //  - publish other user's drafts.
+    ReviewInput in = new ReviewInput();
+    in.message = "message";
+    in.drafts = DraftHandling.PUBLISH;
+    RestResponse res =
+        adminRestSession.postWithHeader(
+            "/changes/" + r.getChangeId() + "/revisions/current/review", in, runAsHeader(user.id));
+    res.assertOK();
+
+    ChangeMessageInfo m = Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(m.message).endsWith(in.message);
+    assertThat(m.author._accountId).isEqualTo(user.id.get());
+
+    CommentInfo c =
+        Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).comments().get(di.path));
+    assertThat(c.author._accountId).isEqualTo(user.id.get());
+    assertThat(c.message).isEqualTo(di.message);
+
+    setApiUser(user);
+    assertThat(gApi.changes().id(r.getChangeId()).drafts()).isEmpty();
+  }
+
+  @Test
+  public void runAsWithOnBehalfOf() throws Exception {
+    // - Has the same restrictions as on_behalf_of (e.g. requires labels).
+    // - Takes the effective user from on_behalf_of (user).
+    // - Takes the real user from the real caller, not the intermediate
+    //   X-Gerrit-RunAs user (user2).
+    allowRunAs();
+    allowCodeReviewOnBehalfOf();
+    TestAccount user2 = accountCreator.user2();
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+
+    String endpoint = "/changes/" + r.getChangeId() + "/revisions/current/review";
+    RestResponse res = adminRestSession.postWithHeader(endpoint, in, runAsHeader(user2.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent())
+        .isEqualTo("label required to post review on behalf of \"" + in.onBehalfOf + '"');
+
+    in.label("Code-Review", 1);
+    adminRestSession.postWithHeader(endpoint, in, runAsHeader(user2.id)).assertOK();
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getAccountId()).isEqualTo(user.id);
+    assertThat(psa.getValue()).isEqualTo(1);
+    assertThat(psa.getRealAccountId()).isEqualTo(admin.id); // not user2
+
+    ChangeData cd = r.getChange();
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(db, cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(user.id);
+    assertThat(m.getRealAuthor()).isEqualTo(admin.id); // not user2
+  }
+
+  @Test
+  public void changeMessageCreatedOnBehalfOfHasRealUser() throws Exception {
+    allowCodeReviewOnBehalfOf();
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+    in.label("Code-Review", 1);
+
+    setApiUser(accountCreator.user2());
+    gApi.changes().id(r.getChangeId()).revision(r.getPatchSetId().getId()).review(in);
+
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get(MESSAGES);
+    assertThat(info.messages).hasSize(2);
+
+    ChangeMessageInfo changeMessageInfo = Iterables.getLast(info.messages);
+    assertThat(changeMessageInfo.realAuthor).isNotNull();
+    assertThat(changeMessageInfo.realAuthor._accountId).isEqualTo(accountCreator.user2().id.get());
+  }
+
+  private void allowCodeReviewOnBehalfOf() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType codeReviewType = Util.codeReview();
+    String forCodeReviewAs = Permission.forLabelAs(codeReviewType.getName());
+    String heads = "refs/heads/*";
+    AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    Util.allow(cfg, forCodeReviewAs, -1, 1, uuid, heads);
+    saveProjectConfig(project, cfg);
+  }
+
+  private void allowSubmitOnBehalfOf() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    String heads = "refs/heads/*";
+    AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    Util.allow(cfg, Permission.SUBMIT_AS, uuid, heads);
+    Util.allow(cfg, Permission.SUBMIT, uuid, heads);
+    LabelType codeReviewType = Util.codeReview();
+    Util.allow(cfg, Permission.forLabel(codeReviewType.getName()), -2, 2, uuid, heads);
+    saveProjectConfig(project, cfg);
+  }
+
+  private void blockRead(GroupInfo group) throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.block(cfg, Permission.READ, new AccountGroup.UUID(group.id), "refs/heads/master");
+    saveProjectConfig(project, cfg);
+  }
+
+  private void allowRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.allow(
+        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  private void removeRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.remove(
+        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  private static Header runAsHeader(Object user) {
+    return new BasicHeader("X-Gerrit-RunAs", user.toString());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java b/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
new file mode 100644
index 0000000..ea71281
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.accounts.UsernameInput;
+import org.junit.Test;
+
+public class PutUsernameIT extends AbstractDaemonTest {
+  @Test
+  public void set() throws Exception {
+    UsernameInput in = new UsernameInput();
+    in.username = "myUsername";
+    RestResponse r =
+        adminRestSession.put("/accounts/" + accountCreator.create().id.get() + "/username", in);
+    r.assertOK();
+    assertThat(newGson().fromJson(r.getReader(), String.class)).isEqualTo(in.username);
+  }
+
+  @Test
+  public void setExisting_Conflict() throws Exception {
+    UsernameInput in = new UsernameInput();
+    in.username = admin.username;
+    adminRestSession
+        .put("/accounts/" + accountCreator.create().id.get() + "/username", in)
+        .assertConflict();
+  }
+
+  @Test
+  public void setNew_MethodNotAllowed() throws Exception {
+    UsernameInput in = new UsernameInput();
+    in.username = "newUsername";
+    adminRestSession.put("/accounts/" + admin.username + "/username", in).assertMethodNotAllowed();
+  }
+
+  @Test
+  public void delete_MethodNotAllowed() throws Exception {
+    adminRestSession.put("/accounts/" + admin.username + "/username").assertMethodNotAllowed();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
rename to javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
new file mode 100644
index 0000000..e45f271
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -0,0 +1,1307 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
+import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.LabelInfo;
+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.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.change.TestSubmitInput;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.restapi.change.Submit;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.RefSpec;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public abstract class AbstractSubmit extends AbstractDaemonTest {
+  @ConfigSuite.Config
+  public static Config submitWholeTopicEnabled() {
+    return submitWholeTopicEnabledConfig();
+  }
+
+  @Inject private ApprovalsUtil approvalsUtil;
+
+  @Inject private Submit submitHandler;
+
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+
+  @Inject private DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
+  private RegistrationHandle onSubmitValidatorHandle;
+
+  private String systemTimeZone;
+
+  @Before
+  public void setTimeForTesting() {
+    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZone);
+  }
+
+  @After
+  public void cleanup() {
+    db.close();
+  }
+
+  @After
+  public void removeOnSubmitValidator() {
+    if (onSubmitValidatorHandle != null) {
+      onSubmitValidatorHandle.remove();
+    }
+  }
+
+  protected abstract SubmitType getSubmitType();
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void submitToEmptyRepo() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange();
+    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    RevCommit headAfterSubmitPreview = getRemoteHead();
+    assertThat(headAfterSubmitPreview).isEqualTo(initialHead);
+    assertThat(actual).hasSize(1);
+
+    submit(change.getChangeId());
+    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertTrees(project, actual);
+  }
+
+  @Test
+  public void submitSingleChange() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange();
+    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    RevCommit headAfterSubmit = getRemoteHead();
+    assertThat(headAfterSubmit).isEqualTo(initialHead);
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+
+    if ((getSubmitType() == SubmitType.CHERRY_PICK)
+        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
+      // The change is updated as well:
+      assertThat(actual).hasSize(2);
+    } else {
+      assertThat(actual).hasSize(1);
+    }
+
+    submit(change.getChangeId());
+    assertTrees(project, actual);
+  }
+
+  @Test
+  public void submitMultipleChangesOtherMergeConflictPreview() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
+    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
+    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
+    // change 2 is not approved, but we ignore labels
+    approve(change3.getChangeId());
+
+    try (BinaryResult request = submitPreview(change4.getChangeId())) {
+      assertThat(getSubmitType()).isEqualTo(SubmitType.CHERRY_PICK);
+      submit(change4.getChangeId());
+    } catch (RestApiException e) {
+      switch (getSubmitType()) {
+        case FAST_FORWARD_ONLY:
+          assertThat(e.getMessage())
+              .isEqualTo(
+                  "Failed to submit 3 changes due to the following problems:\n"
+                      + "Change "
+                      + change2.getChange().getId()
+                      + ": internal error: "
+                      + "change not processed by merge strategy\n"
+                      + "Change "
+                      + change3.getChange().getId()
+                      + ": internal error: "
+                      + "change not processed by merge strategy\n"
+                      + "Change "
+                      + change4.getChange().getId()
+                      + ": Project policy "
+                      + "requires all submissions to be a fast-forward. Please "
+                      + "rebase the change locally and upload again for review.");
+          break;
+        case REBASE_IF_NECESSARY:
+        case REBASE_ALWAYS:
+          String change2hash = change2.getChange().currentPatchSet().getRevision().get();
+          assertThat(e.getMessage())
+              .isEqualTo(
+                  "Cannot rebase "
+                      + change2hash
+                      + ": The change could "
+                      + "not be rebased due to a conflict during merge.");
+          break;
+        case MERGE_ALWAYS:
+        case MERGE_IF_NECESSARY:
+        case INHERIT:
+          assertThat(e.getMessage())
+              .isEqualTo(
+                  "Failed to submit 3 changes due to the following problems:\n"
+                      + "Change "
+                      + change2.getChange().getId()
+                      + ": Change could not be "
+                      + "merged due to a path conflict. Please rebase the change "
+                      + "locally and upload the rebased commit for review.\n"
+                      + "Change "
+                      + change3.getChange().getId()
+                      + ": Change could not be "
+                      + "merged due to a path conflict. Please rebase the change "
+                      + "locally and upload the rebased commit for review.\n"
+                      + "Change "
+                      + change4.getChange().getId()
+                      + ": Change could not be "
+                      + "merged due to a path conflict. Please rebase the change "
+                      + "locally and upload the rebased commit for review.");
+          break;
+        case CHERRY_PICK:
+        default:
+          fail("Should not reach here.");
+          break;
+      }
+
+      RevCommit headAfterSubmit = getRemoteHead();
+      assertThat(headAfterSubmit).isEqualTo(headAfterFirstSubmit);
+      assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+      assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
+    }
+  }
+
+  @Test
+  public void submitMultipleChangesPreview() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
+    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
+    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
+    // change 2 is not approved, but we ignore labels
+    approve(change3.getChangeId());
+    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change4.getChangeId());
+    Map<String, Map<String, Integer>> expected = new HashMap<>();
+    expected.put(project.get(), new HashMap<>());
+    expected.get(project.get()).put("refs/heads/master", 3);
+
+    assertThat(actual).containsKey(new Branch.NameKey(project, "refs/heads/master"));
+    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+      // CherryPick ignores dependencies, thus only change and destination
+      // branch refs are modified.
+      assertThat(actual).hasSize(2);
+    } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      // RebaseAlways takes care of dependencies, therefore Change{2,3,4} and
+      // destination branch will be modified.
+      assertThat(actual).hasSize(4);
+    } else {
+      assertThat(actual).hasSize(1);
+    }
+
+    // check that the submit preview did not actually submit
+    RevCommit headAfterSubmit = getRemoteHead();
+    assertThat(headAfterSubmit).isEqualTo(initialHead);
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+
+    // now check we actually have the same content:
+    approve(change2.getChangeId());
+    submit(change4.getChangeId());
+    assertTrees(project, actual);
+  }
+
+  @Test
+  public void submitNoPermission() throws Exception {
+    // create project where submit is blocked
+    Project.NameKey p = createProject("p");
+    block(p, "refs/*", Permission.SUBMIT, REGISTERED_USERS);
+
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    submit(result.getChangeId(), new SubmitInput(), AuthException.class, "submit not permitted");
+  }
+
+  @Test
+  public void noSelfSubmit() throws Exception {
+    // create project where submit is blocked for the change owner
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.block(cfg, Permission.SUBMIT, CHANGE_OWNER, "refs/*");
+    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+
+    submit(result.getChangeId(), new SubmitInput(), AuthException.class, "submit not permitted");
+
+    setApiUser(user);
+    submit(result.getChangeId());
+  }
+
+  @Test
+  public void onlySelfSubmit() throws Exception {
+    // create project where only the change owner can submit
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.block(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/*");
+    Util.allow(cfg, Permission.SUBMIT, CHANGE_OWNER, "refs/*");
+    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+
+    setApiUser(user);
+    submit(result.getChangeId(), new SubmitInput(), AuthException.class, "submit not permitted");
+
+    setApiUser(admin);
+    submit(result.getChangeId());
+  }
+
+  @Test
+  public void submitWholeTopicMultipleProjects() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    String topic = "test-topic";
+
+    // Create test projects
+    TestRepository<?> repoA = createProjectWithPush("project-a", null, getSubmitType());
+    TestRepository<?> repoB = createProjectWithPush("project-b", null, getSubmitType());
+
+    // Create changes on project-a
+    PushOneCommit.Result change1 =
+        createChange(repoA, "master", "Change 1", "a.txt", "content", topic);
+    PushOneCommit.Result change2 =
+        createChange(repoA, "master", "Change 2", "b.txt", "content", topic);
+
+    // Create changes on project-b
+    PushOneCommit.Result change3 =
+        createChange(repoB, "master", "Change 3", "a.txt", "content", topic);
+    PushOneCommit.Result change4 =
+        createChange(repoB, "master", "Change 4", "b.txt", "content", topic);
+
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+    approve(change3.getChangeId());
+    approve(change4.getChangeId());
+    submit(change4.getChangeId());
+
+    String expectedTopic = name(topic);
+    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change3.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change4.assertChange(Change.Status.MERGED, expectedTopic, admin);
+  }
+
+  @Test
+  public void submitWholeTopicMultipleBranchesOnSameProject() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    String topic = "test-topic";
+
+    // Create test project
+    String projectName = "project-a";
+    TestRepository<?> repoA = createProjectWithPush(projectName, null, getSubmitType());
+
+    RevCommit initialHead = getRemoteHead(new Project.NameKey(name(projectName)), "master");
+
+    // Create the dev branch on the test project
+    BranchInput in = new BranchInput();
+    in.revision = initialHead.name();
+    gApi.projects().name(name(projectName)).branch("dev").create(in);
+
+    // Create changes on master
+    PushOneCommit.Result change1 =
+        createChange(repoA, "master", "Change 1", "a.txt", "content", topic);
+    PushOneCommit.Result change2 =
+        createChange(repoA, "master", "Change 2", "b.txt", "content", topic);
+
+    // Create  changes on dev
+    repoA.reset(initialHead);
+    PushOneCommit.Result change3 =
+        createChange(repoA, "dev", "Change 3", "a.txt", "content", topic);
+    PushOneCommit.Result change4 =
+        createChange(repoA, "dev", "Change 4", "b.txt", "content", topic);
+
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+    approve(change3.getChangeId());
+    approve(change4.getChangeId());
+    submit(change4.getChangeId());
+
+    String expectedTopic = name(topic);
+    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change3.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change4.assertChange(Change.Status.MERGED, expectedTopic, admin);
+  }
+
+  @Test
+  public void submitWholeTopic() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    String topic = "test-topic";
+    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content", topic);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "content", topic);
+    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "content", topic);
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+    approve(change3.getChangeId());
+    submit(change3.getChangeId());
+    String expectedTopic = name(topic);
+    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change3.assertChange(Change.Status.MERGED, expectedTopic, admin);
+
+    // Check for the exact change to have the correct submitter.
+    assertSubmitter(change3);
+    // Also check submitters for changes submitted via the topic relationship.
+    assertSubmitter(change1);
+    assertSubmitter(change2);
+
+    // Check that the repo has the expected commits
+    List<RevCommit> log = getRemoteLog();
+    List<String> commitsInRepo = log.stream().map(c -> c.getShortMessage()).collect(toList());
+    int expectedCommitCount =
+        getSubmitType() == SubmitType.MERGE_ALWAYS
+            ? 5 // initial commit + 3 commits + merge commit
+            : 4; // initial commit + 3 commits
+    assertThat(log).hasSize(expectedCommitCount);
+
+    assertThat(commitsInRepo)
+        .containsAllOf("Initial empty repository", "Change 1", "Change 2", "Change 3");
+    if (getSubmitType() == SubmitType.MERGE_ALWAYS) {
+      assertThat(commitsInRepo).contains("Merge changes from topic \"" + expectedTopic + "\"");
+    }
+  }
+
+  @Test
+  public void submitReusingOldTopic() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    String topic = "test-topic";
+    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content", topic);
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "content", topic);
+    String id1 = change1.getChangeId();
+    String id2 = change2.getChangeId();
+    approve(id1);
+    approve(id2);
+    assertSubmittedTogether(id1, ImmutableList.of(id1, id2));
+    assertSubmittedTogether(id2, ImmutableList.of(id1, id2));
+    submit(id2);
+
+    String expectedTopic = name(topic);
+    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    assertSubmittedTogether(id1, ImmutableList.of(id1, id2));
+    assertSubmittedTogether(id2, ImmutableList.of(id1, id2));
+
+    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "content", topic);
+    String id3 = change3.getChangeId();
+    approve(id3);
+    assertSubmittedTogether(id3, ImmutableList.of());
+    submit(id3);
+
+    change3.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    assertSubmittedTogether(id3, ImmutableList.of());
+  }
+
+  private void assertSubmittedTogether(String changeId, Iterable<String> expected)
+      throws Exception {
+    assertThat(gApi.changes().id(changeId).submittedTogether().stream().map(i -> i.changeId))
+        .containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void submitWorkInProgressChange() throws Exception {
+    PushOneCommit.Result change = createWorkInProgressChange();
+    Change.Id num = change.getChange().getId();
+    submitWithConflict(
+        change.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + num
+            + ": Change "
+            + num
+            + " is work in progress");
+  }
+
+  @Test
+  public void submitWithHiddenBranchInSameTopic() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    PushOneCommit.Result visible = createChange("refs/for/master/" + name("topic"));
+    Change.Id num = visible.getChange().getId();
+
+    createBranch(new Branch.NameKey(project, "hidden"));
+    PushOneCommit.Result hidden = createChange("refs/for/hidden/" + name("topic"));
+    approve(hidden.getChangeId());
+    blockRead("refs/heads/hidden");
+
+    submit(
+        visible.getChangeId(),
+        new SubmitInput(),
+        AuthException.class,
+        "A change to be submitted with " + num + " is not visible");
+  }
+
+  @Test
+  public void submitChangeWhenParentOfOtherBranchTip() throws Exception {
+    // Chain of two commits
+    // Push both to topic-branch
+    // Push the first commit for review and submit
+    //
+    // C2 -- tip of topic branch
+    //  |
+    // C1 -- pushed for review
+    //  |
+    // C0 -- Master
+    //
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config
+        .getProject()
+        .setBooleanConfig(
+            BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET, InheritableBoolean.TRUE);
+    saveProjectConfig(project, config);
+
+    PushOneCommit push1 =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+    PushOneCommit.Result c1 = push1.to("refs/heads/topic");
+    c1.assertOkStatus();
+    PushOneCommit push2 =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+    PushOneCommit.Result c2 = push2.to("refs/heads/topic");
+    c2.assertOkStatus();
+
+    PushOneCommit.Result change1 = push1.to("refs/for/master");
+    change1.assertOkStatus();
+
+    approve(change1.getChangeId());
+    submit(change1.getChangeId());
+  }
+
+  @Test
+  public void submitMergeOfNonChangeBranchTip() throws Exception {
+    // Merge a branch with commits that have not been submitted as
+    // changes.
+    //
+    // M  -- mergeCommit (pushed for review and submitted)
+    // | \
+    // |  S -- stable (pushed directly to refs/heads/stable)
+    // | /
+    // I   -- master
+    //
+    RevCommit master = getRemoteHead(project, "master");
+    PushOneCommit stableTip =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, "Tip of branch stable", "stable.txt", "");
+    PushOneCommit.Result stable = stableTip.to("refs/heads/stable");
+    PushOneCommit mergeCommit =
+        pushFactory.create(db, admin.getIdent(), testRepo, "The merge commit", "merge.txt", "");
+    mergeCommit.setParents(ImmutableList.of(master, stable.getCommit()));
+    PushOneCommit.Result mergeReview = mergeCommit.to("refs/for/master");
+    approve(mergeReview.getChangeId());
+    submit(mergeReview.getChangeId());
+
+    List<RevCommit> log = getRemoteLog();
+    assertThat(log).contains(stable.getCommit());
+    assertThat(log).contains(mergeReview.getCommit());
+  }
+
+  @Test
+  public void submitMergeOfNonChangeBranchNonTip() throws Exception {
+    // Merge a branch with commits that have not been submitted as
+    // changes.
+    //
+    // MC  -- merge commit (pushed for review and submitted)
+    // |\   S2 -- new stable tip (pushed directly to refs/heads/stable)
+    // M \ /
+    // |  S1 -- stable (pushed directly to refs/heads/stable)
+    // | /
+    // I -- master
+    //
+    RevCommit initial = getRemoteHead(project, "master");
+    // push directly to stable to S1
+    PushOneCommit.Result s1 =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "new commit into stable", "stable1.txt", "")
+            .to("refs/heads/stable");
+    // move the stable tip ahead to S2
+    pushFactory
+        .create(db, admin.getIdent(), testRepo, "Tip of branch stable", "stable2.txt", "")
+        .to("refs/heads/stable");
+
+    testRepo.reset(initial);
+
+    // move the master ahead
+    PushOneCommit.Result m =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "Move master ahead", "master.txt", "")
+            .to("refs/heads/master");
+
+    // create merge change
+    PushOneCommit mc =
+        pushFactory.create(db, admin.getIdent(), testRepo, "The merge commit", "merge.txt", "");
+    mc.setParents(ImmutableList.of(m.getCommit(), s1.getCommit()));
+    PushOneCommit.Result mergeReview = mc.to("refs/for/master");
+    approve(mergeReview.getChangeId());
+    submit(mergeReview.getChangeId());
+
+    List<RevCommit> log = getRemoteLog();
+    assertThat(log).contains(s1.getCommit());
+    assertThat(log).contains(mergeReview.getCommit());
+  }
+
+  @Test
+  public void submitChangeWithCommitThatWasAlreadyMerged() throws Exception {
+    // create and submit a change
+    PushOneCommit.Result change = createChange();
+    submit(change.getChangeId());
+    RevCommit headAfterSubmit = getRemoteHead();
+
+    // set the status of the change back to NEW to simulate a failed submit that
+    // merged the commit but failed to update the change status
+    setChangeStatusToNew(change);
+
+    // submitting the change again should detect that the commit was already
+    // merged and just fix the change status to be MERGED
+    submit(change.getChangeId());
+    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+  }
+
+  @Test
+  public void submitChangesWithCommitsThatWereAlreadyMerged() throws Exception {
+    // create and submit 2 changes
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+    approve(change1.getChangeId());
+    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+      submit(change1.getChangeId());
+    }
+    submit(change2.getChangeId());
+    assertMerged(change1.getChangeId());
+    RevCommit headAfterSubmit = getRemoteHead();
+
+    // set the status of the changes back to NEW to simulate a failed submit that
+    // merged the commits but failed to update the change status
+    setChangeStatusToNew(change1, change2);
+
+    // submitting the changes again should detect that the commits were already
+    // merged and just fix the change status to be MERGED
+    submit(change1.getChangeId());
+    submit(change2.getChangeId());
+    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+  }
+
+  @Test
+  public void submitTopicWithCommitsThatWereAlreadyMerged() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    // create and submit 2 changes with the same topic
+    String topic = name("topic");
+    PushOneCommit.Result change1 = createChange("refs/for/master/" + topic);
+    PushOneCommit.Result change2 = createChange("refs/for/master/" + topic);
+    approve(change1.getChangeId());
+    submit(change2.getChangeId());
+    assertMerged(change1.getChangeId());
+    RevCommit headAfterSubmit = getRemoteHead();
+
+    // set the status of the second change back to NEW to simulate a failed
+    // submit that merged the commits but failed to update the change status of
+    // some changes in the topic
+    setChangeStatusToNew(change2);
+
+    // submitting the topic again should detect that the commits were already
+    // merged and just fix the change status to be MERGED
+    submit(change2.getChangeId());
+    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+  }
+
+  @Test
+  public void submitWithValidation() throws Exception {
+    AtomicBoolean called = new AtomicBoolean(false);
+    this.addOnSubmitValidationListener(
+        new OnSubmitValidationListener() {
+          @Override
+          public void preBranchUpdate(Arguments args) throws ValidationException {
+            called.set(true);
+            HashSet<String> refs = Sets.newHashSet(args.getCommands().keySet());
+            assertThat(refs).contains("refs/heads/master");
+            refs.remove("refs/heads/master");
+            if (!refs.isEmpty()) {
+              // Some submit strategies need to insert new patchset.
+              assertThat(refs).hasSize(1);
+              assertThat(refs.iterator().next()).startsWith(RefNames.REFS_CHANGES);
+            }
+          }
+        });
+
+    PushOneCommit.Result change = createChange();
+    approve(change.getChangeId());
+    submit(change.getChangeId());
+    assertThat(called.get()).isTrue();
+  }
+
+  @Test
+  public void submitWithValidationMultiRepo() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    String topic = "test-topic";
+
+    // Create test projects
+    TestRepository<?> repoA = createProjectWithPush("project-a", null, getSubmitType());
+    TestRepository<?> repoB = createProjectWithPush("project-b", null, getSubmitType());
+
+    // Create changes on project-a
+    PushOneCommit.Result change1 =
+        createChange(repoA, "master", "Change 1", "a.txt", "content", topic);
+    PushOneCommit.Result change2 =
+        createChange(repoA, "master", "Change 2", "b.txt", "content", topic);
+
+    // Create changes on project-b
+    PushOneCommit.Result change3 =
+        createChange(repoB, "master", "Change 3", "a.txt", "content", topic);
+    PushOneCommit.Result change4 =
+        createChange(repoB, "master", "Change 4", "b.txt", "content", topic);
+
+    List<PushOneCommit.Result> changes = Lists.newArrayList(change1, change2, change3, change4);
+    for (PushOneCommit.Result change : changes) {
+      approve(change.getChangeId());
+    }
+
+    // Construct validator which will throw on a second call.
+    // Since there are 2 repos, first submit attempt will fail, the second will
+    // succeed.
+    List<String> projectsCalled = new ArrayList<>(4);
+    this.addOnSubmitValidationListener(
+        new OnSubmitValidationListener() {
+          @Override
+          public void preBranchUpdate(Arguments args) throws ValidationException {
+            String master = "refs/heads/master";
+            assertThat(args.getCommands()).containsKey(master);
+            ReceiveCommand cmd = args.getCommands().get(master);
+            ObjectId newMasterId = cmd.getNewId();
+            try (Repository repo = repoManager.openRepository(args.getProject())) {
+              assertThat(repo.exactRef(master).getObjectId()).isEqualTo(cmd.getOldId());
+              assertThat(args.getRef(master)).hasValue(newMasterId);
+              args.getRevWalk().parseBody(args.getRevWalk().parseCommit(newMasterId));
+            } catch (IOException e) {
+              throw new AssertionError("failed checking new ref value", e);
+            }
+            projectsCalled.add(args.getProject().get());
+            if (projectsCalled.size() == 2) {
+              throw new ValidationException("time to fail");
+            }
+          }
+        });
+    submitWithConflict(change4.getChangeId(), "time to fail");
+    assertThat(projectsCalled).containsExactly(name("project-a"), name("project-b"));
+    for (PushOneCommit.Result change : changes) {
+      change.assertChange(Change.Status.NEW, name(topic), admin);
+    }
+
+    submit(change4.getChangeId());
+    assertThat(projectsCalled)
+        .containsExactly(
+            name("project-a"), name("project-b"), name("project-a"), name("project-b"));
+    for (PushOneCommit.Result change : changes) {
+      change.assertChange(Change.Status.MERGED, name(topic), admin);
+    }
+  }
+
+  @Test
+  public void submitWithCommitAndItsMergeCommitTogether() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    RevCommit initialHead = getRemoteHead();
+
+    // Create a stable branch and bootstrap it.
+    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
+    PushOneCommit push =
+        pushFactory.create(db, user.getIdent(), testRepo, "initial commit", "a.txt", "a");
+    PushOneCommit.Result change = push.to("refs/heads/stable");
+
+    RevCommit stable = getRemoteHead(project, "stable");
+    RevCommit master = getRemoteHead(project, "master");
+
+    assertThat(master).isEqualTo(initialHead);
+    assertThat(stable).isEqualTo(change.getCommit());
+
+    testRepo.git().fetch().call();
+    testRepo.git().branchCreate().setName("stable").setStartPoint(stable).call();
+    testRepo.git().branchCreate().setName("master").setStartPoint(master).call();
+
+    // Create a fix in stable branch.
+    testRepo.reset(stable);
+    RevCommit fix =
+        testRepo
+            .commit()
+            .parent(stable)
+            .message("small fix")
+            .add("b.txt", "b")
+            .insertChangeId()
+            .create();
+    testRepo.branch("refs/heads/stable").update(fix);
+    testRepo
+        .git()
+        .push()
+        .setRefSpecs(new RefSpec("refs/heads/stable:refs/for/stable/" + name("topic")))
+        .call();
+
+    // Merge the fix into master.
+    testRepo.reset(master);
+    RevCommit merge =
+        testRepo
+            .commit()
+            .parent(master)
+            .parent(fix)
+            .message("Merge stable into master")
+            .insertChangeId()
+            .create();
+    testRepo.branch("refs/heads/master").update(merge);
+    testRepo
+        .git()
+        .push()
+        .setRefSpecs(new RefSpec("refs/heads/master:refs/for/master/" + name("topic")))
+        .call();
+
+    // Submit together.
+    String fixId = GitUtil.getChangeId(testRepo, fix).get();
+    String mergeId = GitUtil.getChangeId(testRepo, merge).get();
+    approve(fixId);
+    approve(mergeId);
+    submit(mergeId);
+    assertMerged(fixId);
+    assertMerged(mergeId);
+    testRepo.git().fetch().call();
+    RevWalk rw = testRepo.getRevWalk();
+    master = rw.parseCommit(getRemoteHead(project, "master"));
+    assertThat(rw.isMergedInto(merge, master)).isTrue();
+    assertThat(rw.isMergedInto(fix, master)).isTrue();
+  }
+
+  @Test
+  public void retrySubmitSingleChangeOnLockFailure() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+
+    PushOneCommit.Result change = createChange();
+    String id = change.getChangeId();
+    approve(id);
+
+    TestSubmitInput input = new TestSubmitInput();
+    input.generateLockFailures =
+        new ArrayDeque<>(
+            ImmutableList.of(
+                true, // Attempt 1: lock failure
+                false, // Attempt 2: success
+                false)); // Leftover value to check total number of calls.
+    submit(id, input);
+    assertMerged(id);
+
+    testRepo.git().fetch().call();
+    RevWalk rw = testRepo.getRevWalk();
+    RevCommit master = rw.parseCommit(getRemoteHead(project, "master"));
+    RevCommit patchSet = parseCurrentRevision(rw, change);
+    assertThat(rw.isMergedInto(patchSet, master)).isTrue();
+
+    assertThat(input.generateLockFailures).containsExactly(false);
+  }
+
+  @Test
+  public void retrySubmitAfterTornTopicOnLockFailure() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    String topic = "test-topic";
+
+    TestRepository<?> repoA = createProjectWithPush("project-a", null, getSubmitType());
+    TestRepository<?> repoB = createProjectWithPush("project-b", null, getSubmitType());
+
+    PushOneCommit.Result change1 =
+        createChange(repoA, "master", "Change 1", "a.txt", "content", topic);
+    PushOneCommit.Result change2 =
+        createChange(repoB, "master", "Change 2", "b.txt", "content", topic);
+
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+
+    TestSubmitInput input = new TestSubmitInput();
+    input.generateLockFailures =
+        new ArrayDeque<>(
+            ImmutableList.of(
+                false, // Change 1, attempt 1: success
+                true, // Change 2, attempt 1: lock failure
+                false, // Change 1, attempt 2: success
+                false, // Change 2, attempt 2: success
+                false)); // Leftover value to check total number of calls.
+    submit(change2.getChangeId(), input);
+
+    String expectedTopic = name(topic);
+    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
+
+    repoA.git().fetch().call();
+    RevWalk rwA = repoA.getRevWalk();
+    RevCommit masterA = rwA.parseCommit(getRemoteHead(name("project-a"), "master"));
+    RevCommit change1Ps = parseCurrentRevision(rwA, change1);
+    assertThat(rwA.isMergedInto(change1Ps, masterA)).isTrue();
+
+    repoB.git().fetch().call();
+    RevWalk rwB = repoB.getRevWalk();
+    RevCommit masterB = rwB.parseCommit(getRemoteHead(name("project-b"), "master"));
+    RevCommit change2Ps = parseCurrentRevision(rwB, change2);
+    assertThat(rwB.isMergedInto(change2Ps, masterB)).isTrue();
+
+    assertThat(input.generateLockFailures).containsExactly(false);
+  }
+
+  @Test
+  public void authorAndCommitDateAreEqual() throws Exception {
+    assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
+
+    ConfigInput ci = new ConfigInput();
+    ci.matchAuthorToCommitterDate = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(ci);
+
+    RevCommit initialHead = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
+
+    if (getSubmitType() == SubmitType.MERGE_IF_NECESSARY
+        || getSubmitType() == SubmitType.REBASE_IF_NECESSARY) {
+      // Merge another change so that change2 is not a fast-forward
+      submit(change.getChangeId());
+    }
+
+    submit(change2.getChangeId());
+    assertAuthorAndCommitDateEquals(getRemoteHead());
+  }
+
+  @Test
+  @TestProjectInput(rejectEmptyCommit = InheritableBoolean.FALSE)
+  public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitAllowed() throws Exception {
+    assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
+
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    ChangeApi revert1 = gApi.changes().id(change.getChangeId()).revert();
+    approve(revert1.id());
+    revert1.current().submit();
+
+    ChangeApi revert2 = gApi.changes().id(change.getChangeId()).revert();
+    approve(revert2.id());
+    revert2.current().submit();
+  }
+
+  @Test
+  @TestProjectInput(rejectEmptyCommit = InheritableBoolean.TRUE)
+  public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitNotAllowed() throws Exception {
+    assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
+
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    ChangeApi revert1 = gApi.changes().id(change.getChangeId()).revert();
+    approve(revert1.id());
+    revert1.current().submit();
+
+    ChangeApi revert2 = gApi.changes().id(change.getChangeId()).revert();
+    approve(revert2.id());
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        "Change "
+            + revert2.get()._number
+            + ": Change could not be merged because the commit is empty. "
+            + "Project policy requires all commits to contain modifications to at least one file.");
+    revert2.current().submit();
+  }
+
+  @Test
+  @TestProjectInput(rejectEmptyCommit = InheritableBoolean.FALSE)
+  public void submitEmptyCommitPatchSetCanFastForward_emptyCommitAllowed() throws Exception {
+    ChangeInput ci = new ChangeInput();
+    ci.subject = "Empty change";
+    ci.project = project.get();
+    ci.branch = "master";
+    ChangeApi change = gApi.changes().create(ci);
+    approve(change.id());
+    change.current().submit();
+  }
+
+  @Test
+  @TestProjectInput(rejectEmptyCommit = InheritableBoolean.TRUE)
+  public void submitEmptyCommitPatchSetCanFastForward_emptyCommitNotAllowed() throws Exception {
+    ChangeInput ci = new ChangeInput();
+    ci.subject = "Empty change";
+    ci.project = project.get();
+    ci.branch = "master";
+    ChangeApi change = gApi.changes().create(ci);
+    approve(change.id());
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        "Change "
+            + change.get()._number
+            + ": Change could not be merged because the commit is empty. "
+            + "Project policy requires all commits to contain modifications to at least one file.");
+    change.current().submit();
+  }
+
+  private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Exception {
+    for (PushOneCommit.Result change : changes) {
+      try (BatchUpdate bu =
+          batchUpdateFactory.create(db, project, userFactory.create(admin.id), TimeUtil.nowTs())) {
+        bu.addOp(
+            change.getChange().getId(),
+            new BatchUpdateOp() {
+              @Override
+              public boolean updateChange(ChangeContext ctx) throws OrmException {
+                ctx.getChange().setStatus(Change.Status.NEW);
+                ctx.getUpdate(ctx.getChange().currentPatchSetId()).setStatus(Change.Status.NEW);
+                return true;
+              }
+            });
+        bu.execute();
+      }
+    }
+  }
+
+  private void assertSubmitter(PushOneCommit.Result change) throws Exception {
+    ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
+    assertThat(info.messages).isNotNull();
+    Iterable<String> messages = Iterables.transform(info.messages, i -> i.message);
+    assertThat(messages).hasSize(3);
+    String last = Iterables.getLast(messages);
+    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+      assertThat(last).startsWith("Change has been successfully cherry-picked as ");
+    } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      assertThat(last).startsWith("Change has been successfully rebased and submitted as");
+    } else {
+      assertThat(last).isEqualTo("Change has been successfully merged by Administrator");
+    }
+  }
+
+  @Override
+  protected void updateProjectInput(ProjectInput in) {
+    in.submitType = getSubmitType();
+    if (in.useContentMerge == InheritableBoolean.INHERIT) {
+      in.useContentMerge = InheritableBoolean.FALSE;
+    }
+  }
+
+  protected void submit(String changeId) throws Exception {
+    submit(changeId, new SubmitInput(), null, null);
+  }
+
+  protected void submit(String changeId, SubmitInput input) throws Exception {
+    submit(changeId, input, null, null);
+  }
+
+  protected void submitWithConflict(String changeId, String expectedError) throws Exception {
+    submit(changeId, new SubmitInput(), ResourceConflictException.class, expectedError);
+  }
+
+  protected void submit(
+      String changeId,
+      SubmitInput input,
+      Class<? extends RestApiException> expectedExceptionType,
+      String expectedExceptionMsg)
+      throws Exception {
+    approve(changeId);
+    if (expectedExceptionType == null) {
+      assertSubmittable(changeId);
+    }
+    try {
+      gApi.changes().id(changeId).current().submit(input);
+      if (expectedExceptionType != null) {
+        fail("Expected exception of type " + expectedExceptionType.getSimpleName());
+      }
+    } catch (RestApiException e) {
+      if (expectedExceptionType == null) {
+        throw e;
+      }
+      // More verbose than using assertThat and/or ExpectedException, but gives
+      // us the stack trace.
+      if (!expectedExceptionType.isAssignableFrom(e.getClass())
+          || !e.getMessage().equals(expectedExceptionMsg)) {
+        throw new AssertionError(
+            "Expected exception of type "
+                + expectedExceptionType.getSimpleName()
+                + " with message: \""
+                + expectedExceptionMsg
+                + "\" but got exception of type "
+                + e.getClass().getSimpleName()
+                + " with message \""
+                + e.getMessage()
+                + "\"",
+            e);
+      }
+      return;
+    }
+    ChangeInfo change = gApi.changes().id(changeId).info();
+    assertMerged(change.changeId);
+  }
+
+  protected void assertSubmittable(String changeId) throws Exception {
+    assertThat(get(changeId, SUBMITTABLE).submittable).named("submit bit on ChangeInfo").isTrue();
+    RevisionResource rsrc = parseCurrentRevisionResource(changeId);
+    UiAction.Description desc = submitHandler.getDescription(rsrc);
+    assertThat(desc.isVisible()).named("visible bit on submit action").isTrue();
+    assertThat(desc.isEnabled()).named("enabled bit on submit action").isTrue();
+  }
+
+  protected void assertChangeMergedEvents(String... expected) throws Exception {
+    eventRecorder.assertChangeMergedEvents(project.get(), "refs/heads/master", expected);
+  }
+
+  protected void assertRefUpdatedEvents(RevCommit... expected) throws Exception {
+    eventRecorder.assertRefUpdatedEvents(project.get(), "refs/heads/master", expected);
+  }
+
+  protected void assertCurrentRevision(String changeId, int expectedNum, ObjectId expectedId)
+      throws Exception {
+    ChangeInfo c = get(changeId, CURRENT_REVISION);
+    assertThat(c.currentRevision).isEqualTo(expectedId.name());
+    assertThat(c.revisions.get(expectedId.name())._number).isEqualTo(expectedNum);
+    try (Repository repo = repoManager.openRepository(new Project.NameKey(c.project))) {
+      String refName = new PatchSet.Id(new Change.Id(c._number), expectedNum).toRefName();
+      Ref ref = repo.exactRef(refName);
+      assertThat(ref).named(refName).isNotNull();
+      assertThat(ref.getObjectId()).isEqualTo(expectedId);
+    }
+  }
+
+  protected void assertNew(String changeId) throws Exception {
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  protected void assertApproved(String changeId) throws Exception {
+    assertApproved(changeId, admin);
+  }
+
+  protected void assertApproved(String changeId, TestAccount user) throws Exception {
+    ChangeInfo c = get(changeId, DETAILED_LABELS);
+    LabelInfo cr = c.labels.get("Code-Review");
+    assertThat(cr.all).hasSize(1);
+    assertThat(cr.all.get(0).value).isEqualTo(2);
+    assertThat(new Account.Id(cr.all.get(0)._accountId)).isEqualTo(user.getId());
+  }
+
+  protected void assertMerged(String changeId) throws RestApiException {
+    ChangeStatus status = gApi.changes().id(changeId).info().status;
+    assertThat(status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  protected void assertPersonEquals(PersonIdent expected, PersonIdent actual) {
+    assertThat(actual.getEmailAddress()).isEqualTo(expected.getEmailAddress());
+    assertThat(actual.getName()).isEqualTo(expected.getName());
+    assertThat(actual.getTimeZone()).isEqualTo(expected.getTimeZone());
+  }
+
+  protected void assertAuthorAndCommitDateEquals(RevCommit commit) {
+    assertThat(commit.getAuthorIdent().getWhen()).isEqualTo(commit.getCommitterIdent().getWhen());
+    assertThat(commit.getAuthorIdent().getTimeZone())
+        .isEqualTo(commit.getCommitterIdent().getTimeZone());
+  }
+
+  protected void assertSubmitter(String changeId, int psId) throws Exception {
+    assertSubmitter(changeId, psId, admin);
+  }
+
+  protected void assertSubmitter(String changeId, int psId, TestAccount user) throws Exception {
+    Change c = getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
+    ChangeNotes cn = notesFactory.createChecked(db, c);
+    PatchSetApproval submitter =
+        approvalsUtil.getSubmitter(db, cn, new PatchSet.Id(cn.getChangeId(), psId));
+    assertThat(submitter).isNotNull();
+    assertThat(submitter.isLegacySubmit()).isTrue();
+    assertThat(submitter.getAccountId()).isEqualTo(user.getId());
+  }
+
+  protected void assertNoSubmitter(String changeId, int psId) throws Exception {
+    Change c = getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
+    ChangeNotes cn = notesFactory.createChecked(db, c);
+    PatchSetApproval submitter =
+        approvalsUtil.getSubmitter(db, cn, new PatchSet.Id(cn.getChangeId(), psId));
+    assertThat(submitter).isNull();
+  }
+
+  protected void assertCherryPick(TestRepository<?> testRepo, boolean contentMerge)
+      throws Exception {
+    assertRebase(testRepo, contentMerge);
+    RevCommit remoteHead = getRemoteHead();
+    assertThat(remoteHead.getFooterLines("Reviewed-On")).isNotEmpty();
+    assertThat(remoteHead.getFooterLines("Reviewed-By")).isNotEmpty();
+  }
+
+  protected void assertRebase(TestRepository<?> testRepo, boolean contentMerge) throws Exception {
+    Repository repo = testRepo.getRepository();
+    RevCommit localHead = getHead(repo);
+    RevCommit remoteHead = getRemoteHead();
+    assertThat(localHead.getId()).isNotEqualTo(remoteHead.getId());
+    assertThat(remoteHead.getParentCount()).isEqualTo(1);
+    if (!contentMerge) {
+      assertThat(getLatestRemoteDiff()).isEqualTo(getLatestDiff(repo));
+    }
+    assertThat(remoteHead.getShortMessage()).isEqualTo(localHead.getShortMessage());
+  }
+
+  protected List<RevCommit> getRemoteLog(Project.NameKey project, String branch) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      rw.markStart(rw.parseCommit(repo.exactRef("refs/heads/" + branch).getObjectId()));
+      return Lists.newArrayList(rw);
+    }
+  }
+
+  protected List<RevCommit> getRemoteLog() throws Exception {
+    return getRemoteLog(project, "master");
+  }
+
+  protected void addOnSubmitValidationListener(OnSubmitValidationListener listener) {
+    assertThat(onSubmitValidatorHandle).isNull();
+    onSubmitValidatorHandle = onSubmitValidationListeners.add(listener);
+  }
+
+  private String getLatestDiff(Repository repo) throws Exception {
+    ObjectId oldTreeId = repo.resolve("HEAD~1^{tree}");
+    ObjectId newTreeId = repo.resolve("HEAD^{tree}");
+    return getLatestDiff(repo, oldTreeId, newTreeId);
+  }
+
+  private String getLatestRemoteDiff() throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId oldTreeId = repo.resolve("refs/heads/master~1^{tree}");
+      ObjectId newTreeId = repo.resolve("refs/heads/master^{tree}");
+      return getLatestDiff(repo, oldTreeId, newTreeId);
+    }
+  }
+
+  private String getLatestDiff(Repository repo, ObjectId oldTreeId, ObjectId newTreeId)
+      throws Exception {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try (DiffFormatter fmt = new DiffFormatter(out)) {
+      fmt.setRepository(repo);
+      fmt.format(oldTreeId, newTreeId);
+      fmt.flush();
+      return out.toString();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
new file mode 100644
index 0000000..29a81ca
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -0,0 +1,181 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.change.TestSubmitInput;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Test;
+
+public abstract class AbstractSubmitByMerge extends AbstractSubmit {
+
+  @Test
+  public void submitWithMerge() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit oldHead = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
+    submit(change2.getChangeId());
+    RevCommit head = getRemoteHead();
+    assertThat(head.getParentCount()).isEqualTo(2);
+    assertThat(head.getParent(0)).isEqualTo(oldHead);
+    assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithContentMerge() throws Exception {
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
+    submit(change.getChangeId());
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
+    submit(change2.getChangeId());
+
+    RevCommit oldHead = getRemoteHead();
+    testRepo.reset(change.getCommit());
+    PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
+    submit(change3.getChangeId());
+    RevCommit head = getRemoteHead();
+    assertThat(head.getParentCount()).isEqualTo(2);
+    assertThat(head.getParent(0)).isEqualTo(oldHead);
+    assertThat(head.getParent(1)).isEqualTo(change3.getCommit());
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithContentMerge_Conflict() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit oldHead = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
+    submitWithConflict(
+        change2.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change2.getChange().getId()
+            + ": "
+            + "Change could not be merged due to a path conflict. "
+            + "Please rebase the change locally "
+            + "and upload the rebased commit for review.");
+    assertThat(getRemoteHead()).isEqualTo(oldHead);
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void submitMultipleCommitsToEmptyRepoAsFastForward() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+    approve(change1.getChangeId());
+    submit(change2.getChangeId());
+    assertThat(getRemoteHead().getId()).isEqualTo(change2.getCommit());
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void submitMultipleCommitsToEmptyRepoWithOneMerge() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    PushOneCommit.Result change1 =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "Change 1", "a", "a")
+            .to("refs/for/master/" + name("topic"));
+
+    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo, "Change 2", "b", "b");
+    push2.noParents();
+    PushOneCommit.Result change2 = push2.to("refs/for/master/" + name("topic"));
+    change2.assertOkStatus();
+
+    approve(change1.getChangeId());
+    submit(change2.getChangeId());
+
+    RevCommit head = getRemoteHead();
+    assertThat(head.getParents()).hasLength(2);
+    assertThat(head.getParent(0)).isEqualTo(change1.getCommit());
+    assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
+  }
+
+  @Test
+  public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+    RevCommit afterChange1Head = getRemoteHead();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
+    Change.Id id2 = change2.getChange().getId();
+    TestSubmitInput failInput = new TestSubmitInput();
+    failInput.failAfterRefUpdates = true;
+    submit(
+        change2.getChangeId(),
+        failInput,
+        ResourceConflictException.class,
+        "Failing after ref updates");
+
+    // Bad: ref advanced but change wasn't updated.
+    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
+    ChangeInfo info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
+
+    RevCommit tip;
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
+      assertThat(rev1).isNotNull();
+
+      tip = rw.parseCommit(repo.exactRef("refs/heads/master").getObjectId());
+      assertThat(tip.getParentCount()).isEqualTo(2);
+      assertThat(tip.getParent(0)).isEqualTo(afterChange1Head);
+      assertThat(tip.getParent(1)).isEqualTo(change2.getCommit());
+    }
+
+    submit(change2.getChangeId(), new SubmitInput(), null, null);
+
+    // Change status and patch set entities were updated, and branch tip stayed
+    // the same.
+    info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
+    assertThat(Iterables.getLast(info.messages).message)
+        .isEqualTo("Change has been successfully merged by Administrator");
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(tip);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
new file mode 100644
index 0000000..d551595
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -0,0 +1,458 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.getChangeId;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.TestSubmitInput;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.testing.Util;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Test;
+
+public abstract class AbstractSubmitByRebase extends AbstractSubmit {
+
+  @Override
+  protected abstract SubmitType getSubmitType();
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithRebase() throws Exception {
+    submitWithRebase(admin);
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithRebaseWithoutAddPatchSetPermission() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.block(cfg, Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/*");
+    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
+    Util.allow(
+        cfg,
+        Permission.forLabel(Util.codeReview().getName()),
+        -2,
+        2,
+        REGISTERED_USERS,
+        "refs/heads/*");
+    saveProjectConfig(project, cfg);
+
+    submitWithRebase(user);
+  }
+
+  private void submitWithRebase(TestAccount submitter) throws Exception {
+    setApiUser(submitter);
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
+    submit(change2.getChangeId());
+    assertRebase(testRepo, false);
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit.getParent(0)).isEqualTo(headAfterFirstSubmit);
+    assertApproved(change2.getChangeId(), submitter);
+    assertCurrentRevision(change2.getChangeId(), 2, headAfterSecondSubmit);
+    assertSubmitter(change2.getChangeId(), 1, submitter);
+    assertSubmitter(change2.getChangeId(), 2, submitter);
+    assertPersonEquals(admin.getIdent(), headAfterSecondSubmit.getAuthorIdent());
+    assertPersonEquals(submitter.getIdent(), headAfterSecondSubmit.getCommitterIdent());
+
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterSecondSubmit.name());
+  }
+
+  @Test
+  public void submitWithRebaseMultipleChanges() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content");
+    submit(change1.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      assertCurrentRevision(change1.getChangeId(), 2, headAfterFirstSubmit);
+    } else {
+      assertThat(headAfterFirstSubmit.name()).isEqualTo(change1.getCommit().name());
+    }
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
+    assertThat(change2.getCommit().getParent(0)).isNotEqualTo(change1.getCommit());
+    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "third content");
+    PushOneCommit.Result change4 = createChange("Change 4", "d.txt", "fourth content");
+    approve(change2.getChangeId());
+    approve(change3.getChangeId());
+    submit(change4.getChangeId());
+
+    assertRebase(testRepo, false);
+    assertApproved(change2.getChangeId());
+    assertApproved(change3.getChangeId());
+    assertApproved(change4.getChangeId());
+
+    RevCommit headAfterSecondSubmit = parse(getRemoteHead());
+    assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo("Change 4");
+    assertThat(headAfterSecondSubmit).isNotEqualTo(change4.getCommit());
+    assertCurrentRevision(change4.getChangeId(), 2, headAfterSecondSubmit);
+
+    RevCommit parent = parse(headAfterSecondSubmit.getParent(0));
+    assertThat(parent.getShortMessage()).isEqualTo("Change 3");
+    assertThat(parent).isNotEqualTo(change3.getCommit());
+    assertCurrentRevision(change3.getChangeId(), 2, parent);
+
+    RevCommit grandparent = parse(parent.getParent(0));
+    assertThat(grandparent).isNotEqualTo(change2.getCommit());
+    assertCurrentRevision(change2.getChangeId(), 2, grandparent);
+
+    RevCommit greatgrandparent = parse(grandparent.getParent(0));
+    assertThat(greatgrandparent).isEqualTo(headAfterFirstSubmit);
+    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      assertCurrentRevision(change1.getChangeId(), 2, greatgrandparent);
+    } else {
+      assertCurrentRevision(change1.getChangeId(), 1, greatgrandparent);
+    }
+
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change1.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterSecondSubmit.name(),
+        change3.getChangeId(),
+        headAfterSecondSubmit.name(),
+        change4.getChangeId(),
+        headAfterSecondSubmit.name());
+  }
+
+  @Test
+  public void submitWithRebaseMergeCommit() throws Exception {
+    /*
+       *  (HEAD, origin/master, origin/HEAD) Merge changes X,Y
+       |\
+       | *   Merge branch 'master' into origin/master
+       | |\
+       | | * SHA Added a
+       | |/
+       * | Before
+       |/
+       * Initial empty repository
+    */
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
+
+    PushOneCommit change2Push =
+        pushFactory.create(db, admin.getIdent(), testRepo, "Merge to master", "m.txt", "");
+    change2Push.setParents(ImmutableList.of(initialHead, change1.getCommit()));
+    PushOneCommit.Result change2 = change2Push.to("refs/for/master");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change3 = createChange("Before", "b.txt", "");
+
+    approve(change3.getChangeId());
+    submit(change3.getChangeId());
+
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+    submit(change2.getChangeId());
+
+    RevCommit newHead = getRemoteHead();
+    assertThat(newHead.getParentCount()).isEqualTo(2);
+
+    RevCommit headParent1 = parse(newHead.getParent(0).getId());
+    RevCommit headParent2 = parse(newHead.getParent(1).getId());
+
+    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      assertCurrentRevision(change3.getChangeId(), 2, headParent1.getId());
+    } else {
+      assertThat(change3.getCommit().getId()).isEqualTo(headParent1.getId());
+    }
+    assertThat(headParent1.getParentCount()).isEqualTo(1);
+    assertThat(headParent1.getParent(0)).isEqualTo(initialHead);
+
+    assertThat(headParent2.getId()).isEqualTo(change2.getCommit().getId());
+    assertThat(headParent2.getParentCount()).isEqualTo(2);
+
+    RevCommit headGrandparent1 = parse(headParent2.getParent(0).getId());
+    RevCommit headGrandparent2 = parse(headParent2.getParent(1).getId());
+
+    assertThat(headGrandparent1.getId()).isEqualTo(initialHead.getId());
+    assertThat(headGrandparent2.getId()).isEqualTo(change1.getCommit().getId());
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithContentMerge_Conflict() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
+    submitWithConflict(
+        change2.getChangeId(),
+        "Cannot rebase "
+            + change2.getCommit().name()
+            + ": The change could not be rebased due to a conflict during merge.");
+    RevCommit head = getRemoteHead();
+    assertThat(head).isEqualTo(headAfterFirstSubmit);
+    assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
+    assertNoSubmitter(change2.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
+  }
+
+  @Test
+  public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
+    Change.Id id2 = change2.getChange().getId();
+    TestSubmitInput failInput = new TestSubmitInput();
+    failInput.failAfterRefUpdates = true;
+    submit(
+        change2.getChangeId(),
+        failInput,
+        ResourceConflictException.class,
+        "Failing after ref updates");
+    RevCommit headAfterFailedSubmit = getRemoteHead();
+
+    // Bad: ref advanced but change wasn't updated.
+    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
+    PatchSet.Id psId2 = new PatchSet.Id(id2, 2);
+    ChangeInfo info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
+    assertThat(getPatchSet(psId2)).isNull();
+
+    ObjectId rev2;
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
+      assertThat(rev1).isNotNull();
+
+      rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
+      assertThat(rev2).isNotNull();
+      assertThat(rev2).isNotEqualTo(rev1);
+      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(headAfterFirstSubmit);
+
+      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
+    }
+
+    submit(change2.getChangeId());
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit).isEqualTo(headAfterFailedSubmit);
+
+    // Change status and patch set entities were updated, and branch tip stayed
+    // the same.
+    info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(2);
+    PatchSet ps2 = getPatchSet(psId2);
+    assertThat(ps2).isNotNull();
+    assertThat(ps2.getRevision().get()).isEqualTo(rev2.name());
+    assertThat(Iterables.getLast(info.messages).message)
+        .isEqualTo(
+            "Change has been successfully rebased and submitted as "
+                + rev2.name()
+                + " by Administrator");
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
+    }
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(
+        change.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterSecondSubmit.name());
+  }
+
+  protected RevCommit parse(ObjectId id) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = rw.parseCommit(id);
+      rw.parseBody(c);
+      return c;
+    }
+  }
+
+  @Test
+  public void submitAfterReorderOfCommits() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    // Create two commits and push.
+    RevCommit c1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    pushHead(testRepo, "refs/for/master", false);
+
+    String id1 = getChangeId(testRepo, c1).get();
+    String id2 = getChangeId(testRepo, c2).get();
+
+    // Swap the order of commits and push again.
+    testRepo.reset("HEAD~2");
+    testRepo.cherryPick(c2);
+    testRepo.cherryPick(c1);
+    pushHead(testRepo, "refs/for/master", false);
+
+    approve(id1);
+    approve(id2);
+    submit(id1);
+    RevCommit headAfterSubmit = getRemoteHead();
+
+    assertRefUpdatedEvents(initialHead, headAfterSubmit);
+    assertChangeMergedEvents(id2, headAfterSubmit.name(), id1, headAfterSubmit.name());
+  }
+
+  @Test
+  public void submitChangesAfterBranchOnSecond() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    PushOneCommit.Result change = createChange();
+    approve(change.getChangeId());
+
+    PushOneCommit.Result change2 = createChange();
+    approve(change2.getChangeId());
+    Project.NameKey project = change2.getChange().change().getProject();
+    Branch.NameKey branch = new Branch.NameKey(project, "branch");
+    createBranchWithRevision(branch, change2.getCommit().getName());
+    gApi.changes().id(change2.getChangeId()).current().submit();
+    assertMerged(change2.getChangeId());
+    assertMerged(change.getChangeId());
+
+    RevCommit newHead = getRemoteHead();
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(
+        change.getChangeId(), newHead.name(), change2.getChangeId(), newHead.name());
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitFastForwardIdenticalTree() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "a");
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "a");
+
+    assertThat(change1.getCommit().getTree()).isEqualTo(change2.getCommit().getTree());
+
+    // for rebase if necessary, otherwise, the manual rebase of change2 will
+    // fail since change1 would be merged as fast forward
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change0 = createChange("Change 0", "b.txt", "b");
+    submit(change0.getChangeId());
+    RevCommit headAfterChange0 = getRemoteHead();
+    assertThat(headAfterChange0.getShortMessage()).isEqualTo("Change 0");
+
+    submit(change1.getChangeId());
+    RevCommit headAfterChange1 = getRemoteHead();
+    assertThat(headAfterChange1.getShortMessage()).isEqualTo("Change 1");
+    assertThat(headAfterChange0).isEqualTo(headAfterChange1.getParent(0));
+
+    // Do manual rebase first.
+    gApi.changes().id(change2.getChangeId()).current().rebase();
+    submit(change2.getChangeId());
+    RevCommit headAfterChange2 = getRemoteHead();
+    assertThat(headAfterChange2.getShortMessage()).isEqualTo("Change 2");
+    assertThat(headAfterChange1).isEqualTo(headAfterChange2.getParent(0));
+
+    ChangeInfo info2 = info(change2.getChangeId());
+    assertThat(info2.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitChainOneByOne() throws Exception {
+    PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
+    PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
+    submit(change1.getChangeId());
+    submit(change2.getChangeId());
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitChainFailsOnRework() throws Exception {
+    PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
+    RevCommit headAfterChange1 = change1.getCommit();
+    PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
+    testRepo.reset(headAfterChange1);
+    change1 =
+        amendChange(change1.getChangeId(), "subject 1 amend", "fileName 2", "rework content 2");
+    submit(change1.getChangeId());
+    headAfterChange1 = getRemoteHead();
+
+    submitWithConflict(
+        change2.getChangeId(),
+        "Cannot rebase "
+            + change2.getCommit().getName()
+            + ": "
+            + "The change could not be rebased due to a conflict during merge.");
+    assertThat(getRemoteHead()).isEqualTo(headAfterChange1);
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitChainOneByOneManualRebase() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
+    PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
+
+    // for rebase if necessary, otherwise, the manual rebase of change2 will
+    // fail since change1 would be merged as fast forward
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change = createChange();
+    submit(change.getChangeId());
+
+    submit(change1.getChangeId());
+    // Do manual rebase first.
+    gApi.changes().id(change2.getChangeId()).current().rebase();
+    submit(change2.getChangeId());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
new file mode 100644
index 0000000..066af79
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -0,0 +1,455 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.extensions.api.changes.ActionVisitor;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ActionsIT extends AbstractDaemonTest {
+  @ConfigSuite.Config
+  public static Config submitWholeTopicEnabled() {
+    return submitWholeTopicEnabledConfig();
+  }
+
+  @Inject private ChangeJson.Factory changeJsonFactory;
+
+  @Inject private DynamicSet<ActionVisitor> actionVisitors;
+
+  private RegistrationHandle visitorHandle;
+
+  @Before
+  public void setUp() {
+    visitorHandle = null;
+  }
+
+  @After
+  public void tearDown() {
+    if (visitorHandle != null) {
+      visitorHandle.remove();
+    }
+  }
+
+  @Test
+  public void revisionActionsOneChangePerTopicUnapproved() throws Exception {
+    String changeId = createChangeWithTopic().getChangeId();
+    Map<String, ActionInfo> actions = getActions(changeId);
+    assertThat(actions).hasSize(3);
+    assertThat(actions).containsKey("cherrypick");
+    assertThat(actions).containsKey("rebase");
+    assertThat(actions).containsKey("description");
+  }
+
+  @Test
+  public void revisionActionsOneChangePerTopic() throws Exception {
+    String changeId = createChangeWithTopic().getChangeId();
+    approve(changeId);
+    Map<String, ActionInfo> actions = getActions(changeId);
+    commonActionsAssertions(actions);
+    // We want to treat a single change in a topic not as a whole topic,
+    // so regardless of how submitWholeTopic is configured:
+    noSubmitWholeTopicAssertions(actions, 1);
+  }
+
+  @Test
+  public void revisionActionsTwoChangesInTopic() throws Exception {
+    String changeId = createChangeWithTopic().getChangeId();
+    approve(changeId);
+    String changeId2 = createChangeWithTopic().getChangeId();
+    Map<String, ActionInfo> actions = getActions(changeId);
+    commonActionsAssertions(actions);
+    if (isSubmitWholeTopicEnabled()) {
+      ActionInfo info = actions.get("submit");
+      assertThat(info.enabled).isNull();
+      assertThat(info.label).isEqualTo("Submit whole topic");
+      assertThat(info.method).isEqualTo("POST");
+      assertThat(info.title).isEqualTo("This change depends on other changes which are not ready");
+    } else {
+      noSubmitWholeTopicAssertions(actions, 1);
+
+      assertThat(getActions(changeId2).get("submit")).isNull();
+      approve(changeId2);
+      noSubmitWholeTopicAssertions(getActions(changeId2), 2);
+    }
+  }
+
+  @Test
+  public void revisionActionsETag() throws Exception {
+    String parent = createChange().getChangeId();
+    String change = createChangeWithTopic().getChangeId();
+    approve(change);
+    String etag1 = getETag(change);
+
+    approve(parent);
+    String etag2 = getETag(change);
+
+    String changeWithSameTopic = createChangeWithTopic().getChangeId();
+    String etag3 = getETag(change);
+
+    approve(changeWithSameTopic);
+    String etag4 = getETag(change);
+
+    if (isSubmitWholeTopicEnabled()) {
+      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
+    } else {
+      assertThat(etag2).isNotEqualTo(etag1);
+      assertThat(etag3).isEqualTo(etag2);
+      assertThat(etag4).isEqualTo(etag2);
+    }
+  }
+
+  @Test
+  public void revisionActionsAnonymousETag() throws Exception {
+    String parent = createChange().getChangeId();
+    String change = createChangeWithTopic().getChangeId();
+    approve(change);
+
+    setApiUserAnonymous();
+    String etag1 = getETag(change);
+
+    setApiUser(admin);
+    approve(parent);
+
+    setApiUserAnonymous();
+    String etag2 = getETag(change);
+
+    setApiUser(admin);
+    String changeWithSameTopic = createChangeWithTopic().getChangeId();
+
+    setApiUserAnonymous();
+    String etag3 = getETag(change);
+
+    setApiUser(admin);
+    approve(changeWithSameTopic);
+
+    setApiUserAnonymous();
+    String etag4 = getETag(change);
+
+    if (isSubmitWholeTopicEnabled()) {
+      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
+    } else {
+      assertThat(etag2).isNotEqualTo(etag1);
+      assertThat(etag3).isEqualTo(etag2);
+      assertThat(etag4).isEqualTo(etag2);
+    }
+  }
+
+  @Test
+  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
+  public void revisionActionsAnonymousETagCherryPickStrategy() throws Exception {
+    String parent = createChange().getChangeId();
+    String change = createChange().getChangeId();
+    approve(change);
+
+    setApiUserAnonymous();
+    String etag1 = getETag(change);
+
+    setApiUser(admin);
+    approve(parent);
+
+    setApiUserAnonymous();
+    String etag2 = getETag(change);
+    assertThat(etag2).isEqualTo(etag1);
+  }
+
+  @Test
+  public void revisionActionsTwoChangesInTopic_conflicting() throws Exception {
+    String changeId = createChangeWithTopic().getChangeId();
+    approve(changeId);
+
+    // create another change with the same topic
+    String changeId2 =
+        createChangeWithTopic(testRepo, "topic", "touching b", "b.txt", "real content")
+            .getChangeId();
+    int changeNum2 = gApi.changes().id(changeId2).info()._number;
+    approve(changeId2);
+
+    // collide with the other change in the same topic
+    testRepo.reset("HEAD~2");
+    String collidingChange =
+        createChangeWithTopic(
+                testRepo, "off_topic", "rewriting file b", "b.txt", "garbage\ngarbage\ngarbage")
+            .getChangeId();
+    gApi.changes().id(collidingChange).current().review(ReviewInput.approve());
+    gApi.changes().id(collidingChange).current().submit();
+
+    Map<String, ActionInfo> actions = getActions(changeId);
+    commonActionsAssertions(actions);
+    if (isSubmitWholeTopicEnabled()) {
+      ActionInfo info = actions.get("submit");
+      assertThat(info.enabled).isNull();
+      assertThat(info.label).isEqualTo("Submit whole topic");
+      assertThat(info.method).isEqualTo("POST");
+      assertThat(info.title).isEqualTo("Problems with change(s): " + changeNum2);
+    } else {
+      noSubmitWholeTopicAssertions(actions, 1);
+    }
+  }
+
+  @Test
+  public void revisionActionsTwoChangesInTopicWithAncestorReady() throws Exception {
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+    approve(changeId);
+    String changeId1 = createChangeWithTopic().getChangeId();
+    approve(changeId1);
+    // create another change with the same topic
+    String changeId2 = createChangeWithTopic().getChangeId();
+    approve(changeId2);
+    Map<String, ActionInfo> actions = getActions(changeId1);
+    commonActionsAssertions(actions);
+    if (isSubmitWholeTopicEnabled()) {
+      ActionInfo info = actions.get("submit");
+      assertThat(info.enabled).isTrue();
+      assertThat(info.label).isEqualTo("Submit whole topic");
+      assertThat(info.method).isEqualTo("POST");
+      assertThat(info.title)
+          .isEqualTo(
+              "Submit all 2 changes of the same "
+                  + "topic (3 changes including ancestors "
+                  + "and other changes related by topic)");
+    } else {
+      noSubmitWholeTopicAssertions(actions, 2);
+    }
+  }
+
+  @Test
+  public void revisionActionsReadyWithAncestors() throws Exception {
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+    approve(changeId);
+    String changeId1 = createChange().getChangeId();
+    approve(changeId1);
+    String changeId2 = createChangeWithTopic().getChangeId();
+    approve(changeId2);
+    Map<String, ActionInfo> actions = getActions(changeId2);
+    commonActionsAssertions(actions);
+    // The topic contains only one change, so standard text applies
+    noSubmitWholeTopicAssertions(actions, 3);
+  }
+
+  private void noSubmitWholeTopicAssertions(Map<String, ActionInfo> actions, int nrChanges) {
+    ActionInfo info = actions.get("submit");
+    assertThat(info.enabled).isTrue();
+    if (nrChanges == 1) {
+      assertThat(info.label).isEqualTo("Submit");
+    } else {
+      assertThat(info.label).isEqualTo("Submit including parents");
+    }
+    assertThat(info.method).isEqualTo("POST");
+    if (nrChanges == 1) {
+      assertThat(info.title).isEqualTo("Submit patch set 1 into master");
+    } else {
+      assertThat(info.title)
+          .isEqualTo(
+              String.format(
+                  "Submit patch set 1 and ancestors (%d changes altogether) into master",
+                  nrChanges));
+    }
+  }
+
+  @Test
+  public void changeActionVisitor() throws Exception {
+    String id = createChange().getChangeId();
+    ChangeInfo origChange = gApi.changes().id(id).get(CHANGE_ACTIONS);
+
+    class Visitor implements ActionVisitor {
+      @Override
+      public boolean visit(String name, ActionInfo actionInfo, ChangeInfo changeInfo) {
+        assertThat(changeInfo).isNotNull();
+        assertThat(changeInfo._number).isEqualTo(origChange._number);
+        if (name.equals("followup")) {
+          return false;
+        }
+        if (name.equals("abandon")) {
+          actionInfo.label = "Abandon All Hope";
+        }
+        return true;
+      }
+
+      @Override
+      public boolean visit(
+          String name, ActionInfo actionInfo, ChangeInfo changeInfo, RevisionInfo revisionInfo) {
+        throw new UnsupportedOperationException();
+      }
+    }
+
+    Map<String, ActionInfo> origActions = origChange.actions;
+    assertThat(origActions.keySet()).containsAllOf("followup", "abandon");
+    assertThat(origActions.get("abandon").label).isEqualTo("Abandon");
+
+    Visitor v = new Visitor();
+    visitorHandle = actionVisitors.add(v);
+
+    Map<String, ActionInfo> newActions =
+        gApi.changes().id(id).get(EnumSet.of(ListChangesOption.CHANGE_ACTIONS)).actions;
+
+    Set<String> expectedNames = new TreeSet<>(origActions.keySet());
+    expectedNames.remove("followup");
+    assertThat(newActions.keySet()).isEqualTo(expectedNames);
+
+    ActionInfo abandon = newActions.get("abandon");
+    assertThat(abandon).isNotNull();
+    assertThat(abandon.label).isEqualTo("Abandon All Hope");
+  }
+
+  @Test
+  public void currentRevisionActionVisitor() throws Exception {
+    String id = createChange().getChangeId();
+    amendChange(id);
+    ChangeInfo origChange = gApi.changes().id(id).get(CHANGE_ACTIONS);
+    Change.Id changeId = new Change.Id(origChange._number);
+
+    class Visitor implements ActionVisitor {
+      @Override
+      public boolean visit(String name, ActionInfo actionInfo, ChangeInfo changeInfo) {
+        return true; // Do nothing; implicitly called for CURRENT_ACTIONS.
+      }
+
+      @Override
+      public boolean visit(
+          String name, ActionInfo actionInfo, ChangeInfo changeInfo, RevisionInfo revisionInfo) {
+        assertThat(changeInfo).isNotNull();
+        assertThat(changeInfo._number).isEqualTo(origChange._number);
+        assertThat(revisionInfo).isNotNull();
+        assertThat(revisionInfo._number).isEqualTo(2);
+        if (name.equals("cherrypick")) {
+          return false;
+        }
+        if (name.equals("rebase")) {
+          actionInfo.label = "All Your Base";
+        }
+        return true;
+      }
+    }
+
+    Map<String, ActionInfo> origActions = gApi.changes().id(id).current().actions();
+    assertThat(origActions.keySet()).containsAllOf("cherrypick", "rebase");
+    assertThat(origActions.get("rebase").label).isEqualTo("Rebase");
+
+    Visitor v = new Visitor();
+    visitorHandle = actionVisitors.add(v);
+
+    // Test different codepaths within ActionJson...
+    // ...via revision API.
+    visitedCurrentRevisionActionsAssertions(origActions, gApi.changes().id(id).current().actions());
+
+    // ...via change API with option.
+    EnumSet<ListChangesOption> opts = EnumSet.of(CURRENT_ACTIONS, CURRENT_REVISION);
+    ChangeInfo changeInfo = gApi.changes().id(id).get(opts);
+    RevisionInfo revisionInfo = Iterables.getOnlyElement(changeInfo.revisions.values());
+    visitedCurrentRevisionActionsAssertions(origActions, revisionInfo.actions);
+
+    // ...via ChangeJson directly.
+    ChangeData cd = changeDataFactory.create(db, project, changeId);
+    revisionInfo =
+        changeJsonFactory
+            .create(opts)
+            .getRevisionInfo(cd, cd.patchSet(new PatchSet.Id(changeId, 1)));
+  }
+
+  private void visitedCurrentRevisionActionsAssertions(
+      Map<String, ActionInfo> origActions, Map<String, ActionInfo> newActions) {
+    assertThat(newActions).isNotNull();
+    Set<String> expectedNames = new TreeSet<>(origActions.keySet());
+    expectedNames.remove("cherrypick");
+    assertThat(newActions.keySet()).isEqualTo(expectedNames);
+
+    ActionInfo rebase = newActions.get("rebase");
+    assertThat(rebase).isNotNull();
+    assertThat(rebase.label).isEqualTo("All Your Base");
+  }
+
+  @Test
+  public void oldRevisionActionVisitor() throws Exception {
+    String id = createChange().getChangeId();
+    amendChange(id);
+    ChangeInfo origChange = gApi.changes().id(id).get(CHANGE_ACTIONS);
+
+    class Visitor implements ActionVisitor {
+      @Override
+      public boolean visit(String name, ActionInfo actionInfo, ChangeInfo changeInfo) {
+        return true; // Do nothing; implicitly called for CURRENT_ACTIONS.
+      }
+
+      @Override
+      public boolean visit(
+          String name, ActionInfo actionInfo, ChangeInfo changeInfo, RevisionInfo revisionInfo) {
+        assertThat(changeInfo).isNotNull();
+        assertThat(changeInfo._number).isEqualTo(origChange._number);
+        assertThat(revisionInfo).isNotNull();
+        assertThat(revisionInfo._number).isEqualTo(1);
+        if (name.equals("description")) {
+          actionInfo.label = "Describify";
+        }
+        return true;
+      }
+    }
+
+    Map<String, ActionInfo> origActions = gApi.changes().id(id).revision(1).actions();
+    assertThat(origActions.keySet()).containsExactly("description");
+    assertThat(origActions.get("description").label).isEqualTo("Edit Description");
+
+    Visitor v = new Visitor();
+    visitorHandle = actionVisitors.add(v);
+
+    // Unlike for the current revision, actions for old revisions are only available via the
+    // revision API.
+    Map<String, ActionInfo> newActions = gApi.changes().id(id).revision(1).actions();
+    assertThat(newActions).isNotNull();
+    assertThat(newActions.keySet()).isEqualTo(origActions.keySet());
+
+    ActionInfo description = newActions.get("description");
+    assertThat(description).isNotNull();
+    assertThat(description.label).isEqualTo("Describify");
+  }
+
+  private void commonActionsAssertions(Map<String, ActionInfo> actions) {
+    assertThat(actions).hasSize(4);
+    assertThat(actions).containsKey("cherrypick");
+    assertThat(actions).containsKey("submit");
+    assertThat(actions).containsKey("description");
+    assertThat(actions).containsKey("rebase");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
new file mode 100644
index 0000000..69035f2
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -0,0 +1,192 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.util.Iterator;
+import java.util.List;
+import org.eclipse.jgit.transport.RefSpec;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+@NoHttpd
+public class AssigneeIT extends AbstractDaemonTest {
+
+  @BeforeClass
+  public static void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @AfterClass
+  public static void restoreTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void getNoAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(getAssignee(r)).isNull();
+  }
+
+  @Test
+  public void addGetAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+    assertThat(getAssignee(r)._accountId).isEqualTo(user.getId().get());
+
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+  }
+
+  @Test
+  public void setNewAssigneeWhenExists() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setAssignee(r, user.email);
+    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+  }
+
+  @Test
+  public void getPastAssignees() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+    setAssignee(r, user.email);
+    setAssignee(r, admin.email);
+    List<AccountInfo> assignees = getPastAssignees(r);
+    assertThat(assignees).hasSize(2);
+    Iterator<AccountInfo> itr = assignees.iterator();
+    assertThat(itr.next()._accountId).isEqualTo(user.getId().get());
+    assertThat(itr.next()._accountId).isEqualTo(admin.getId().get());
+  }
+
+  @Test
+  public void assigneeAddedAsReviewer() throws Exception {
+    ReviewerState state;
+    // Assignee is added as CC, if back-end is reviewDb (that does not support
+    // CC) CC is stored as REVIEWER
+    if (notesMigration.readChanges()) {
+      state = ReviewerState.CC;
+    } else {
+      state = ReviewerState.REVIEWER;
+    }
+    PushOneCommit.Result r = createChange();
+    Iterable<AccountInfo> reviewers = getReviewers(r, state);
+    assertThat(reviewers).isNull();
+    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+    reviewers = getReviewers(r, state);
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getFirst(reviewers, null);
+    assertThat(reviewer._accountId).isEqualTo(user.getId().get());
+  }
+
+  @Test
+  public void setAlreadyExistingAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setAssignee(r, user.email);
+    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+  }
+
+  @Test
+  public void deleteAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+    assertThat(deleteAssignee(r)._accountId).isEqualTo(user.getId().get());
+    assertThat(getAssignee(r)).isNull();
+  }
+
+  @Test
+  public void deleteAssigneeWhenNoAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(deleteAssignee(r)).isNull();
+  }
+
+  @Test
+  public void setAssigneeToInactiveUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.accounts().id(user.getId().get()).setActive(false);
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("is not active");
+    setAssignee(r, user.email);
+  }
+
+  @Test
+  public void setAssigneeForNonVisibleChange() throws Exception {
+    git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
+    testRepo.reset(RefNames.REFS_CONFIG);
+    PushOneCommit.Result r = createChange("refs/for/refs/meta/config");
+    exception.expect(AuthException.class);
+    exception.expectMessage("read not permitted");
+    setAssignee(r, user.email);
+  }
+
+  @Test
+  public void setAssigneeNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not permitted");
+    setAssignee(r, user.email);
+  }
+
+  @Test
+  public void setAssigneeAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    grant(project, "refs/heads/master", Permission.EDIT_ASSIGNEE, false, REGISTERED_USERS);
+    setApiUser(user);
+    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+  }
+
+  private AccountInfo getAssignee(PushOneCommit.Result r) throws Exception {
+    return gApi.changes().id(r.getChange().getId().get()).getAssignee();
+  }
+
+  private List<AccountInfo> getPastAssignees(PushOneCommit.Result r) throws Exception {
+    return gApi.changes().id(r.getChange().getId().get()).getPastAssignees();
+  }
+
+  private Iterable<AccountInfo> getReviewers(PushOneCommit.Result r, ReviewerState state)
+      throws Exception {
+    return get(r.getChangeId(), DETAILED_LABELS).reviewers.get(state);
+  }
+
+  private AccountInfo setAssignee(PushOneCommit.Result r, String identifieer) throws Exception {
+    AssigneeInput input = new AssigneeInput();
+    input.assignee = identifieer;
+    return gApi.changes().id(r.getChange().getId().get()).setAssignee(input);
+  }
+
+  private AccountInfo deleteAssignee(PushOneCommit.Result r) throws Exception {
+    return gApi.changes().id(r.getChange().getId().get()).deleteAssignee();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/BUILD b/javatests/com/google/gerrit/acceptance/rest/change/BUILD
new file mode 100644
index 0000000..6a4b4a7
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/BUILD
@@ -0,0 +1,38 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+SUBMIT_UTIL_SRCS = glob(["AbstractSubmit*.java"])
+
+SUBMIT_TESTS = glob(["Submit*IT.java"])
+
+OTHER_TESTS = glob(
+    ["*IT.java"],
+    exclude = SUBMIT_TESTS,
+)
+
+acceptance_tests(
+    srcs = OTHER_TESTS,
+    group = "rest_change_other",
+    labels = ["rest"],
+    deps = [
+        ":submit_util",
+    ],
+)
+
+acceptance_tests(
+    srcs = SUBMIT_TESTS,
+    group = "rest_change_submit",
+    labels = ["rest"],
+    deps = [
+        ":submit_util",
+    ],
+)
+
+java_library(
+    name = "submit_util",
+    testonly = 1,
+    srcs = SUBMIT_UTIL_SRCS,
+    deps = [
+        "//java/com/google/gerrit/acceptance:lib",
+        "//java/com/google/gerrit/server/restapi",
+    ],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
rename to javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
rename to javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
new file mode 100644
index 0000000..c55af25
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.util.Iterator;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(ConfigSuite.class)
+public class ChangeMessagesIT extends AbstractDaemonTest {
+  private String systemTimeZone;
+
+  @Before
+  public void setTimeForTesting() {
+    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZone);
+  }
+
+  @Test
+  public void messagesNotReturnedByDefault() throws Exception {
+    String changeId = createChange().getChangeId();
+    postMessage(changeId, "Some nits need to be fixed.");
+    ChangeInfo c = info(changeId);
+    assertThat(c.messages).isNull();
+  }
+
+  @Test
+  public void defaultMessage() throws Exception {
+    String changeId = createChange().getChangeId();
+    ChangeInfo c = get(changeId, MESSAGES);
+    assertThat(c.messages).isNotNull();
+    assertThat(c.messages).hasSize(1);
+    assertThat(c.messages.iterator().next().message).isEqualTo("Uploaded patch set 1.");
+  }
+
+  @Test
+  public void messagesReturnedInChronologicalOrder() throws Exception {
+    String changeId = createChange().getChangeId();
+    String firstMessage = "Some nits need to be fixed.";
+    postMessage(changeId, firstMessage);
+    String secondMessage = "I like this feature.";
+    postMessage(changeId, secondMessage);
+    ChangeInfo c = get(changeId, MESSAGES);
+    assertThat(c.messages).isNotNull();
+    assertThat(c.messages).hasSize(3);
+    Iterator<ChangeMessageInfo> it = c.messages.iterator();
+    assertThat(it.next().message).isEqualTo("Uploaded patch set 1.");
+    assertMessage(firstMessage, it.next().message);
+    assertMessage(secondMessage, it.next().message);
+  }
+
+  @Test
+  public void postMessageWithTag() throws Exception {
+    String changeId = createChange().getChangeId();
+    String tag = "jenkins";
+    String msg = "Message with tag.";
+    postMessage(changeId, msg, tag);
+    ChangeInfo c = get(changeId, MESSAGES);
+    assertThat(c.messages).isNotNull();
+    assertThat(c.messages).hasSize(2);
+    Iterator<ChangeMessageInfo> it = c.messages.iterator();
+    assertThat(it.next().message).isEqualTo("Uploaded patch set 1.");
+    ChangeMessageInfo actual = it.next();
+    assertMessage(msg, actual.message);
+    assertThat(actual.tag).isEqualTo(tag);
+  }
+
+  private void assertMessage(String expected, String actual) {
+    assertThat(actual).isEqualTo("Patch Set 1:\n\n" + expected);
+  }
+
+  private void postMessage(String changeId, String msg) throws Exception {
+    postMessage(changeId, msg, null);
+  }
+
+  private void postMessage(String changeId, String msg, String tag) throws Exception {
+    ReviewInput in = new ReviewInput();
+    in.message = msg;
+    in.tag = tag;
+    gApi.changes().id(changeId).current().review(in);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
rename to javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
new file mode 100644
index 0000000..84c9c03
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -0,0 +1,325 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class ChangeReviewersByEmailIT extends AbstractDaemonTest {
+
+  @Before
+  public void setUp() throws Exception {
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+  }
+
+  @Test
+  public void addByEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
+      assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
+      // All reviewers added by email should be removable
+      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(acc));
+    }
+  }
+
+  @Test
+  public void addByEmailAndById() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo byEmail = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+    AccountInfo byId = new AccountInfo(user.id.get());
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput inputByEmail = new AddReviewerInput();
+      inputByEmail.reviewer = toRfcAddressString(byEmail);
+      inputByEmail.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(inputByEmail);
+
+      AddReviewerInput inputById = new AddReviewerInput();
+      inputById.reviewer = user.email;
+      inputById.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(inputById);
+
+      ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
+      assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(byId, byEmail)));
+      // All reviewers (both by id and by email) should be removable
+      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(byId, byEmail));
+    }
+  }
+
+  @Test
+  public void removeByEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput addInput = new AddReviewerInput();
+      addInput.reviewer = toRfcAddressString(acc);
+      addInput.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+      gApi.changes().id(r.getChangeId()).reviewer(acc.email).remove();
+
+      ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
+      assertThat(info.reviewers).isEmpty();
+    }
+  }
+
+  @Test
+  public void convertFromCCToReviewer() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput addInput = new AddReviewerInput();
+    addInput.reviewer = toRfcAddressString(acc);
+    addInput.state = ReviewerState.CC;
+    gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+    AddReviewerInput modifyInput = new AddReviewerInput();
+    modifyInput.reviewer = addInput.reviewer;
+    modifyInput.state = ReviewerState.REVIEWER;
+    gApi.changes().id(r.getChangeId()).addReviewer(modifyInput);
+
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
+    assertThat(info.reviewers)
+        .isEqualTo(ImmutableMap.of(ReviewerState.REVIEWER, ImmutableList.of(acc)));
+  }
+
+  @Test
+  public void addedReviewersGetNotified() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      List<Message> messages = sender.getMessages();
+      assertThat(messages).hasSize(1);
+      assertThat(messages.get(0).rcpt()).containsExactly(Address.parse(input.reviewer));
+      sender.clear();
+    }
+  }
+
+  @Test
+  public void removingReviewerTriggersNotification() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput addInput = new AddReviewerInput();
+      addInput.reviewer = toRfcAddressString(acc);
+      addInput.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+      // Review change as user
+      ReviewInput reviewInput = new ReviewInput();
+      reviewInput.message = "I have a comment";
+      setApiUser(user);
+      revision(r).review(reviewInput);
+      setApiUser(admin);
+
+      sender.clear();
+
+      // Delete as admin
+      gApi.changes().id(r.getChangeId()).reviewer(addInput.reviewer).remove();
+
+      List<Message> messages = sender.getMessages();
+      assertThat(messages).hasSize(1);
+      assertThat(messages.get(0).rcpt())
+          .containsExactly(Address.parse(addInput.reviewer), user.emailAddress);
+      sender.clear();
+    }
+  }
+
+  @Test
+  public void reviewerAndCCReceiveRegularNotification() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+      sender.clear();
+
+      gApi.changes()
+          .id(r.getChangeId())
+          .revision(r.getCommit().name())
+          .review(ReviewInput.approve());
+
+      assertNotifyCc(Address.parse(input.reviewer));
+    }
+  }
+
+  @Test
+  public void reviewerAndCCReceiveSameEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      for (int i = 0; i < 10; i++) {
+        AddReviewerInput input = new AddReviewerInput();
+        input.reviewer = String.format("%s-%s@gerritcodereview.com", state, i);
+        input.state = state;
+        gApi.changes().id(r.getChangeId()).addReviewer(input);
+      }
+    }
+
+    // Also add user as a regular reviewer
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = user.email;
+    input.state = ReviewerState.REVIEWER;
+    gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    // Assert that only one email was sent out to everyone
+    assertThat(sender.getMessages()).hasSize(1);
+  }
+
+  @Test
+  public void addingMultipleReviewersAndCCsAtOnceSendsOnlyOneEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = new ReviewInput();
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      for (int i = 0; i < 10; i++) {
+        reviewInput.reviewer(String.format("%s-%s@gerritcodereview.com", state, i), state, true);
+      }
+    }
+    assertThat(reviewInput.reviewers).hasSize(20);
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(reviewInput);
+    assertThat(sender.getMessages()).hasSize(1);
+  }
+
+  @Test
+  public void rejectMissingEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("");
+    assertThat(result.error).isEqualTo(" is not a valid user identifier");
+    assertThat(result.reviewers).isNull();
+  }
+
+  @Test
+  public void rejectMalformedEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@");
+    assertThat(result.error).isEqualTo("Foo Bar <foo.bar@ is not a valid user identifier");
+    assertThat(result.reviewers).isNull();
+  }
+
+  @Test
+  public void rejectWhenFeatureIsDisabled() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.FALSE;
+    gApi.projects().name(project.get()).config(conf);
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerResult result =
+        gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@gerritcodereview.com>");
+    assertThat(result.error)
+        .isEqualTo(
+            "Foo Bar <foo.bar@gerritcodereview.com> does not identify a registered user or group");
+    assertThat(result.reviewers).isNull();
+  }
+
+  @Test
+  public void reviewersByEmailAreServedFromIndex() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      notesMigration.setFailOnLoadForTest(true);
+      try {
+        ChangeInfo info =
+            Iterables.getOnlyElement(
+                gApi.changes().query(r.getChangeId()).withOption(DETAILED_LABELS).get());
+        assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
+      } finally {
+        notesMigration.setFailOnLoadForTest(false);
+      }
+    }
+  }
+
+  private static String toRfcAddressString(AccountInfo info) {
+    return (new Address(info.name, info.email)).toString();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
new file mode 100644
index 0000000..f1bba8a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -0,0 +1,879 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.restapi.change.PostReviewers;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gson.stream.JsonReader;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import org.junit.Test;
+
+public class ChangeReviewersIT extends AbstractDaemonTest {
+  @Test
+  public void addGroupAsReviewer() throws Exception {
+    // Set up two groups, one that is too large too add as reviewer, and one
+    // that is too large to add without confirmation.
+    String largeGroup = createGroup("largeGroup");
+    String mediumGroup = createGroup("mediumGroup");
+
+    int largeGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS + 1;
+    int mediumGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
+    List<TestAccount> users = createAccounts(largeGroupSize, "addGroupAsReviewer");
+    List<String> largeGroupUsernames = new ArrayList<>(mediumGroupSize);
+    for (TestAccount u : users) {
+      largeGroupUsernames.add(u.username);
+    }
+    List<String> mediumGroupUsernames = largeGroupUsernames.subList(0, mediumGroupSize);
+    gApi.groups()
+        .id(largeGroup)
+        .addMembers(largeGroupUsernames.toArray(new String[largeGroupSize]));
+    gApi.groups()
+        .id(mediumGroup)
+        .addMembers(mediumGroupUsernames.toArray(new String[mediumGroupSize]));
+
+    // Attempt to add overly large group as reviewers.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    AddReviewerResult result = addReviewer(changeId, largeGroup);
+    assertThat(result.input).isEqualTo(largeGroup);
+    assertThat(result.confirm).isNull();
+    assertThat(result.error).contains("has too many members to add them all as reviewers");
+    assertThat(result.reviewers).isNull();
+
+    // Attempt to add medium group without confirmation.
+    result = addReviewer(changeId, mediumGroup);
+    assertThat(result.input).isEqualTo(mediumGroup);
+    assertThat(result.confirm).isTrue();
+    assertThat(result.error)
+        .contains("has " + mediumGroupSize + " members. Do you want to add them all as reviewers?");
+    assertThat(result.reviewers).isNull();
+
+    // Add medium group with confirmation.
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = mediumGroup;
+    in.confirmed = true;
+    result = addReviewer(changeId, in);
+    assertThat(result.input).isEqualTo(mediumGroup);
+    assertThat(result.confirm).isNull();
+    assertThat(result.error).isNull();
+    assertThat(result.reviewers).hasSize(mediumGroupSize);
+
+    // Verify that group members were added as reviewers.
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, REVIEWER, users.subList(0, mediumGroupSize));
+  }
+
+  @Test
+  public void addCcAccount() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    in.state = CC;
+    AddReviewerResult result = addReviewer(changeId, in);
+
+    assertThat(result.input).isEqualTo(user.email);
+    assertThat(result.confirm).isNull();
+    assertThat(result.error).isNull();
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    if (notesMigration.readChanges()) {
+      assertThat(result.reviewers).isNull();
+      assertThat(result.ccs).hasSize(1);
+      AccountInfo ai = result.ccs.get(0);
+      assertThat(ai._accountId).isEqualTo(user.id.get());
+      assertReviewers(c, CC, user);
+    } else {
+      assertThat(result.ccs).isNull();
+      assertThat(result.reviewers).hasSize(1);
+      AccountInfo ai = result.reviewers.get(0);
+      assertThat(ai._accountId).isEqualTo(user.id.get());
+      assertReviewers(c, REVIEWER, user);
+    }
+
+    // Verify email was sent to CCed account.
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    if (notesMigration.readChanges()) {
+      assertThat(m.body()).contains(admin.fullName + " has uploaded this change for review.");
+    } else {
+      assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
+      assertThat(m.body()).contains("I'd like you to do a code review.");
+    }
+  }
+
+  @Test
+  public void addCcGroup() throws Exception {
+    List<TestAccount> users = createAccounts(6, "addCcGroup");
+    List<String> usernames = new ArrayList<>(6);
+    for (TestAccount u : users) {
+      usernames.add(u.username);
+    }
+
+    List<TestAccount> firstUsers = users.subList(0, 3);
+    List<String> firstUsernames = usernames.subList(0, 3);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = createGroup("cc1");
+    in.state = CC;
+    gApi.groups()
+        .id(in.reviewer)
+        .addMembers(firstUsernames.toArray(new String[firstUsernames.size()]));
+    AddReviewerResult result = addReviewer(changeId, in);
+
+    assertThat(result.input).isEqualTo(in.reviewer);
+    assertThat(result.confirm).isNull();
+    assertThat(result.error).isNull();
+    if (notesMigration.readChanges()) {
+      assertThat(result.reviewers).isNull();
+    } else {
+      assertThat(result.ccs).isNull();
+    }
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, CC, firstUsers);
+    } else {
+      assertReviewers(c, REVIEWER, firstUsers);
+      assertReviewers(c, CC);
+    }
+
+    // Verify emails were sent to each of the group's accounts.
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    List<Address> expectedAddresses = new ArrayList<>(firstUsers.size());
+    for (TestAccount u : firstUsers) {
+      expectedAddresses.add(u.emailAddress);
+    }
+    assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
+
+    // CC a group that overlaps with some existing reviewers and CCed accounts.
+    TestAccount reviewer =
+        accountCreator.create(name("reviewer"), "addCcGroup-reviewer@example.com", "Reviewer");
+    result = addReviewer(changeId, reviewer.username);
+    assertThat(result.error).isNull();
+    sender.clear();
+    in.reviewer = createGroup("cc2");
+    gApi.groups().id(in.reviewer).addMembers(usernames.toArray(new String[usernames.size()]));
+    gApi.groups().id(in.reviewer).addMembers(reviewer.username);
+    result = addReviewer(changeId, in);
+    assertThat(result.input).isEqualTo(in.reviewer);
+    assertThat(result.confirm).isNull();
+    assertThat(result.error).isNull();
+    c = gApi.changes().id(r.getChangeId()).get();
+    if (notesMigration.readChanges()) {
+      assertThat(result.ccs).hasSize(3);
+      assertThat(result.reviewers).isNull();
+      assertReviewers(c, REVIEWER, reviewer);
+      assertReviewers(c, CC, users);
+    } else {
+      assertThat(result.ccs).isNull();
+      assertThat(result.reviewers).hasSize(3);
+      List<TestAccount> expectedUsers = new ArrayList<>(users.size() + 2);
+      expectedUsers.addAll(users);
+      expectedUsers.add(reviewer);
+      assertReviewers(c, REVIEWER, expectedUsers);
+    }
+
+    messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    m = messages.get(0);
+    expectedAddresses = new ArrayList<>(4);
+    for (int i = 0; i < 3; i++) {
+      expectedAddresses.add(users.get(users.size() - i - 1).emailAddress);
+    }
+    if (!notesMigration.readChanges()) {
+      for (int i = 0; i < 3; i++) {
+        expectedAddresses.add(users.get(i).emailAddress);
+      }
+    }
+    expectedAddresses.add(reviewer.emailAddress);
+    assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
+  }
+
+  @Test
+  public void transitionCcToReviewer() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    in.state = CC;
+    addReviewer(changeId, in);
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, REVIEWER);
+      assertReviewers(c, CC, user);
+    } else {
+      assertReviewers(c, REVIEWER, user);
+      assertReviewers(c, CC);
+    }
+
+    in.state = REVIEWER;
+    addReviewer(changeId, in);
+    c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, REVIEWER, user);
+    assertReviewers(c, CC);
+  }
+
+  @Test
+  public void driveByComment() throws Exception {
+    // Create change owned by admin.
+    PushOneCommit.Result r = createChange();
+
+    // Post drive-by message as user.
+    ReviewInput input = new ReviewInput().message("hello");
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + r.getChangeId() + "/revisions/" + r.getCommit().getName() + "/review",
+            input);
+    ReviewResult result = readContentFromJson(resp, 200, ReviewResult.class);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNull();
+
+    // Verify user is added to CC list.
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, REVIEWER);
+      assertReviewers(c, CC, user);
+    } else {
+      // If we aren't reading from NoteDb, the user will appear as a
+      // reviewer.
+      assertReviewers(c, REVIEWER, user);
+      assertReviewers(c, CC);
+    }
+  }
+
+  @Test
+  public void addSelfAsReviewer() throws Exception {
+    // Create change owned by admin.
+    PushOneCommit.Result r = createChange();
+
+    // user adds self as REVIEWER.
+    ReviewInput input = new ReviewInput().reviewer(user.username);
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + r.getChangeId() + "/revisions/" + r.getCommit().getName() + "/review",
+            input);
+    ReviewResult result = readContentFromJson(resp, 200, ReviewResult.class);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(1);
+
+    // Verify reviewer state.
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, REVIEWER, user);
+    assertReviewers(c, CC);
+    LabelInfo label = c.labels.get("Code-Review");
+    assertThat(label).isNotNull();
+    assertThat(label.all).isNotNull();
+    assertThat(label.all).hasSize(1);
+    ApprovalInfo approval = label.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.getId().get());
+  }
+
+  @Test
+  public void addSelfAsCc() throws Exception {
+    // Create change owned by admin.
+    PushOneCommit.Result r = createChange();
+
+    // user adds self as CC.
+    ReviewInput input = new ReviewInput().reviewer(user.username, CC, false);
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + r.getChangeId() + "/revisions/" + r.getCommit().getName() + "/review",
+            input);
+    ReviewResult result = readContentFromJson(resp, 200, ReviewResult.class);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(1);
+
+    // Verify reviewer state.
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, REVIEWER);
+      assertReviewers(c, CC, user);
+      // Verify no approvals were added.
+      assertThat(c.labels).isNotNull();
+      LabelInfo label = c.labels.get("Code-Review");
+      assertThat(label).isNotNull();
+      assertThat(label.all).isNull();
+    } else {
+      // When approvals are stored in ReviewDb, we still create a label for
+      // the reviewing user, and force them into the REVIEWER state.
+      assertReviewers(c, REVIEWER, user);
+      assertReviewers(c, CC);
+      LabelInfo label = c.labels.get("Code-Review");
+      assertThat(label).isNotNull();
+      assertThat(label.all).isNotNull();
+      assertThat(label.all).hasSize(1);
+      ApprovalInfo approval = label.all.get(0);
+      assertThat(approval._accountId).isEqualTo(user.getId().get());
+    }
+  }
+
+  @Test
+  public void reviewerReplyWithoutVote() throws Exception {
+    // Create change owned by admin.
+    PushOneCommit.Result r = createChange();
+
+    // Verify reviewer state.
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, REVIEWER);
+    assertReviewers(c, CC);
+    LabelInfo label = c.labels.get("Code-Review");
+    assertThat(label).isNotNull();
+    assertThat(label.all).isNull();
+
+    // Add user as REVIEWER.
+    ReviewInput input = new ReviewInput().reviewer(user.username);
+    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(1);
+
+    // Verify reviewer state. Both admin and user should be REVIEWERs now,
+    // because admin gets forced into REVIEWER state by virtue of being owner.
+    c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, REVIEWER, admin, user);
+    assertReviewers(c, CC);
+    label = c.labels.get("Code-Review");
+    assertThat(label).isNotNull();
+    assertThat(label.all).isNotNull();
+    assertThat(label.all).hasSize(2);
+    Map<Integer, Integer> approvals = new HashMap<>();
+    for (ApprovalInfo approval : label.all) {
+      approvals.put(approval._accountId, approval.value);
+    }
+    assertThat(approvals).containsEntry(admin.getId().get(), 0);
+    assertThat(approvals).containsEntry(user.getId().get(), 0);
+
+    // Comment as user without voting. This should delete the approval and
+    // then replace it with the default value.
+    input = new ReviewInput().message("hello");
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + r.getChangeId() + "/revisions/" + r.getCommit().getName() + "/review",
+            input);
+    result = readContentFromJson(resp, 200, ReviewResult.class);
+    assertThat(result.labels).isNull();
+
+    // Verify reviewer state.
+    c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, REVIEWER, admin, user);
+    assertReviewers(c, CC);
+    label = c.labels.get("Code-Review");
+    assertThat(label).isNotNull();
+    assertThat(label.all).isNotNull();
+    assertThat(label.all).hasSize(2);
+    approvals.clear();
+    for (ApprovalInfo approval : label.all) {
+      approvals.put(approval._accountId, approval.value);
+    }
+    assertThat(approvals).containsEntry(admin.getId().get(), 0);
+    assertThat(approvals).containsEntry(user.getId().get(), 0);
+  }
+
+  @Test
+  public void reviewAndAddReviewers() throws Exception {
+    TestAccount observer = accountCreator.user2();
+    PushOneCommit.Result r = createChange();
+    ReviewInput input =
+        ReviewInput.approve().reviewer(user.email).reviewer(observer.email, CC, false);
+
+    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.labels).isNotNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(2);
+
+    // Verify reviewer and CC were added. If not in NoteDb read mode, both
+    // parties will be returned as CCed.
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, REVIEWER, admin, user);
+      assertReviewers(c, CC, observer);
+    } else {
+      // In legacy mode, everyone should be a reviewer.
+      assertReviewers(c, REVIEWER, admin, user, observer);
+      assertReviewers(c, CC);
+    }
+
+    // Verify emails were sent to added reviewers.
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(2);
+
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress, observer.emailAddress);
+    assertThat(m.body()).contains(admin.fullName + " has posted comments on this change.");
+    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertThat(m.body()).contains("Patch Set 1: Code-Review+2");
+
+    m = messages.get(1);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress, observer.emailAddress);
+    assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
+    assertThat(m.body()).contains("I'd like you to do a code review.");
+  }
+
+  @Test
+  public void reviewAndAddGroupReviewers() throws Exception {
+    int largeGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS + 1;
+    int mediumGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
+    List<TestAccount> users = createAccounts(largeGroupSize, "reviewAndAddGroupReviewers");
+    List<String> usernames = new ArrayList<>(largeGroupSize);
+    for (TestAccount u : users) {
+      usernames.add(u.username);
+    }
+
+    String largeGroup = createGroup("largeGroup");
+    String mediumGroup = createGroup("mediumGroup");
+    gApi.groups().id(largeGroup).addMembers(usernames.toArray(new String[largeGroupSize]));
+    gApi.groups()
+        .id(mediumGroup)
+        .addMembers(usernames.subList(0, mediumGroupSize).toArray(new String[mediumGroupSize]));
+
+    TestAccount observer = accountCreator.user2();
+    PushOneCommit.Result r = createChange();
+
+    // Attempt to add overly large group as reviewers.
+    ReviewInput input =
+        ReviewInput.approve()
+            .reviewer(user.email)
+            .reviewer(observer.email, CC, false)
+            .reviewer(largeGroup);
+    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input, SC_BAD_REQUEST);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(3);
+    AddReviewerResult reviewerResult = result.reviewers.get(largeGroup);
+    assertThat(reviewerResult).isNotNull();
+    assertThat(reviewerResult.confirm).isNull();
+    assertThat(reviewerResult.error).isNotNull();
+    assertThat(reviewerResult.error).contains("has too many members to add them all as reviewers");
+
+    // No labels should have changed, and no reviewers/CCs should have been added.
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertThat(c.messages).hasSize(1);
+    assertThat(c.reviewers.get(REVIEWER)).isNull();
+    assertThat(c.reviewers.get(CC)).isNull();
+
+    // Attempt to add group large enough to require confirmation, without
+    // confirmation, as reviewers.
+    input =
+        ReviewInput.approve()
+            .reviewer(user.email)
+            .reviewer(observer.email, CC, false)
+            .reviewer(mediumGroup);
+    result = review(r.getChangeId(), r.getCommit().name(), input, SC_BAD_REQUEST);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(3);
+    reviewerResult = result.reviewers.get(mediumGroup);
+    assertThat(reviewerResult).isNotNull();
+    assertThat(reviewerResult.confirm).isTrue();
+    assertThat(reviewerResult.error)
+        .contains("has " + mediumGroupSize + " members. Do you want to add them all as reviewers?");
+
+    // No labels should have changed, and no reviewers/CCs should have been added.
+    c = gApi.changes().id(r.getChangeId()).get();
+    assertThat(c.messages).hasSize(1);
+    assertThat(c.reviewers.get(REVIEWER)).isNull();
+    assertThat(c.reviewers.get(CC)).isNull();
+
+    // Retrying with confirmation should successfully approve and add reviewers/CCs.
+    input = ReviewInput.approve().reviewer(user.email).reviewer(mediumGroup, CC, true);
+    result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.labels).isNotNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(2);
+
+    c = gApi.changes().id(r.getChangeId()).get();
+    assertThat(c.messages).hasSize(2);
+
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, REVIEWER, admin, user);
+      assertReviewers(c, CC, users.subList(0, mediumGroupSize));
+    } else {
+      // If not in NoteDb mode, then everyone is a REVIEWER.
+      List<TestAccount> expected = users.subList(0, mediumGroupSize);
+      expected.add(admin);
+      expected.add(user);
+      assertReviewers(c, REVIEWER, expected);
+      assertReviewers(c, CC);
+    }
+  }
+
+  @Test
+  public void noteDbAddReviewerToReviewerChangeInfo() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    in.state = CC;
+    addReviewer(changeId, in);
+
+    in.state = REVIEWER;
+    addReviewer(changeId, in);
+
+    gApi.changes().id(changeId).current().review(ReviewInput.dislike());
+
+    setApiUser(user);
+    // NoteDb adds reviewer to a change on every review.
+    gApi.changes().id(changeId).current().review(ReviewInput.dislike());
+
+    deleteReviewer(changeId, user).assertNoContent();
+
+    ChangeInfo c = gApi.changes().id(changeId).get();
+    assertThat(c.reviewerUpdates).isNotNull();
+    assertThat(c.reviewerUpdates).hasSize(3);
+
+    Iterator<ReviewerUpdateInfo> it = c.reviewerUpdates.iterator();
+    ReviewerUpdateInfo reviewerChange = it.next();
+    assertThat(reviewerChange.state).isEqualTo(CC);
+    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.getId().get());
+    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.getId().get());
+
+    reviewerChange = it.next();
+    assertThat(reviewerChange.state).isEqualTo(REVIEWER);
+    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.getId().get());
+    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.getId().get());
+
+    reviewerChange = it.next();
+    assertThat(reviewerChange.state).isEqualTo(REMOVED);
+    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.getId().get());
+    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.getId().get());
+  }
+
+  @Test
+  public void addDuplicateReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput input = ReviewInput.approve().reviewer(user.email).reviewer(user.email);
+    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(1);
+    AddReviewerResult reviewerResult = result.reviewers.get(user.email);
+    assertThat(reviewerResult).isNotNull();
+    assertThat(reviewerResult.confirm).isNull();
+    assertThat(reviewerResult.error).isNull();
+  }
+
+  @Test
+  public void addOverlappingGroups() throws Exception {
+    String emailPrefix = "addOverlappingGroups-";
+    TestAccount user1 =
+        accountCreator.create(name("user1"), emailPrefix + "user1@example.com", "User1");
+    TestAccount user2 =
+        accountCreator.create(name("user2"), emailPrefix + "user2@example.com", "User2");
+    TestAccount user3 =
+        accountCreator.create(name("user3"), emailPrefix + "user3@example.com", "User3");
+    String group1 = createGroup("group1");
+    String group2 = createGroup("group2");
+    gApi.groups().id(group1).addMembers(user1.username, user2.username);
+    gApi.groups().id(group2).addMembers(user2.username, user3.username);
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput input = ReviewInput.approve().reviewer(group1).reviewer(group2);
+    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(2);
+    AddReviewerResult reviewerResult = result.reviewers.get(group1);
+    assertThat(reviewerResult.error).isNull();
+    assertThat(reviewerResult.reviewers).hasSize(2);
+    reviewerResult = result.reviewers.get(group2);
+    assertThat(reviewerResult.error).isNull();
+    assertThat(reviewerResult.reviewers).hasSize(1);
+
+    // Repeat the above for CCs
+    if (!notesMigration.readChanges()) {
+      return;
+    }
+    r = createChange();
+    input = ReviewInput.approve().reviewer(group1, CC, false).reviewer(group2, CC, false);
+    result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(2);
+    reviewerResult = result.reviewers.get(group1);
+    assertThat(reviewerResult.error).isNull();
+    assertThat(reviewerResult.ccs).hasSize(2);
+    reviewerResult = result.reviewers.get(group2);
+    assertThat(reviewerResult.error).isNull();
+    assertThat(reviewerResult.ccs).hasSize(1);
+
+    // Repeat again with one group REVIEWER, the other CC. The overlapping
+    // member should end up as a REVIEWER.
+    r = createChange();
+    input = ReviewInput.approve().reviewer(group1, REVIEWER, false).reviewer(group2, CC, false);
+    result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(2);
+    reviewerResult = result.reviewers.get(group1);
+    assertThat(reviewerResult.error).isNull();
+    assertThat(reviewerResult.reviewers).hasSize(2);
+    reviewerResult = result.reviewers.get(group2);
+    assertThat(reviewerResult.error).isNull();
+    assertThat(reviewerResult.reviewers).isNull();
+    assertThat(reviewerResult.ccs).hasSize(1);
+  }
+
+  @Test
+  public void removingReviewerRemovesTheirVote() throws Exception {
+    String crLabel = "Code-Review";
+    PushOneCommit.Result r = createChange();
+    ReviewInput input = ReviewInput.approve().reviewer(admin.email);
+    ReviewResult addResult = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(addResult.reviewers).isNotNull();
+    assertThat(addResult.reviewers).hasSize(1);
+
+    Map<String, LabelInfo> changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).hasSize(1);
+
+    RestResponse deleteResult = deleteReviewer(r.getChangeId(), admin);
+    deleteResult.assertNoContent();
+
+    changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).isNull();
+
+    // Check that the vote is gone even after the reviewer is added back
+    addReviewer(r.getChangeId(), admin.email);
+    changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).isNull();
+  }
+
+  @Test
+  public void notifyDetailsWorkOnPostReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount userToNotify = createAccounts(1, "notify-details-post-review").get(0);
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.reviewer(user.email, ReviewerState.REVIEWER, true);
+    reviewInput.notify = NotifyHandling.NONE;
+    reviewInput.notifyDetails =
+        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.emailAddress);
+  }
+
+  @Test
+  public void notifyDetailsWorkOnPostReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount userToNotify = createAccounts(1, "notify-details-post-reviewers").get(0);
+
+    AddReviewerInput addReviewer = new AddReviewerInput();
+    addReviewer.reviewer = user.email;
+    addReviewer.notify = NotifyHandling.NONE;
+    addReviewer.notifyDetails =
+        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).addReviewer(addReviewer);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.emailAddress);
+  }
+
+  @Test
+  public void removeReviewerWithVoteWithoutPermissionFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Code-Review", 1));
+    setApiUser(newUser);
+    exception.expect(AuthException.class);
+    exception.expectMessage("remove reviewer not permitted");
+    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
+  }
+
+  @Test
+  @Sandboxed
+  public void removeReviewerWithoutVoteWithPermissionSucceeds() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // This test creates a new user so that it can explicitly check the REMOVE_REVIEWER permission
+    // rather than bypassing the check because of project or ref ownership.
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+    grant(project, RefNames.REFS + "*", Permission.REMOVE_REVIEWER, false, REGISTERED_USERS);
+
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
+    assertThatUserIsOnlyReviewer(r.getChangeId());
+    setApiUser(newUser);
+    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
+  }
+
+  @Test
+  public void removeReviewerWithoutVoteWithoutPermissionFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
+    setApiUser(newUser);
+    exception.expect(AuthException.class);
+    exception.expectMessage("remove reviewer not permitted");
+    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
+  }
+
+  @Test
+  public void removeCCWithoutPermissionFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = user.email;
+    input.state = ReviewerState.CC;
+    gApi.changes().id(r.getChangeId()).addReviewer(input);
+    setApiUser(newUser);
+    exception.expect(AuthException.class);
+    exception.expectMessage("remove reviewer not permitted");
+    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
+  }
+
+  private void assertThatUserIsOnlyReviewer(String changeId) throws Exception {
+    AccountInfo userInfo = new AccountInfo(user.fullName, user.emailAddress.getEmail());
+    userInfo._accountId = user.id.get();
+    userInfo.username = user.username;
+    assertThat(gApi.changes().id(changeId).get().reviewers)
+        .containsExactly(ReviewerState.REVIEWER, ImmutableList.of(userInfo));
+  }
+
+  private AddReviewerResult addReviewer(String changeId, String reviewer) throws Exception {
+    return addReviewer(changeId, reviewer, SC_OK);
+  }
+
+  private AddReviewerResult addReviewer(String changeId, String reviewer, int expectedStatus)
+      throws Exception {
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = reviewer;
+    return addReviewer(changeId, in, expectedStatus);
+  }
+
+  private AddReviewerResult addReviewer(String changeId, AddReviewerInput in) throws Exception {
+    return addReviewer(changeId, in, SC_OK);
+  }
+
+  private AddReviewerResult addReviewer(String changeId, AddReviewerInput in, int expectedStatus)
+      throws Exception {
+    RestResponse resp = adminRestSession.post("/changes/" + changeId + "/reviewers", in);
+    return readContentFromJson(resp, expectedStatus, AddReviewerResult.class);
+  }
+
+  private RestResponse deleteReviewer(String changeId, TestAccount account) throws Exception {
+    return adminRestSession.delete("/changes/" + changeId + "/reviewers/" + account.getId().get());
+  }
+
+  private ReviewResult review(String changeId, String revisionId, ReviewInput in) throws Exception {
+    return review(changeId, revisionId, in, SC_OK);
+  }
+
+  private ReviewResult review(
+      String changeId, String revisionId, ReviewInput in, int expectedStatus) throws Exception {
+    RestResponse resp =
+        adminRestSession.post("/changes/" + changeId + "/revisions/" + revisionId + "/review", in);
+    return readContentFromJson(resp, expectedStatus, ReviewResult.class);
+  }
+
+  private static <T> T readContentFromJson(RestResponse r, int expectedStatus, Class<T> clazz)
+      throws Exception {
+    r.assertStatus(expectedStatus);
+    JsonReader jsonReader = new JsonReader(r.getReader());
+    jsonReader.setLenient(true);
+    return newGson().fromJson(jsonReader, clazz);
+  }
+
+  private static void assertReviewers(
+      ChangeInfo c, ReviewerState reviewerState, TestAccount... accounts) throws Exception {
+    List<TestAccount> accountList = new ArrayList<>(accounts.length);
+    for (TestAccount a : accounts) {
+      accountList.add(a);
+    }
+    assertReviewers(c, reviewerState, accountList);
+  }
+
+  private static void assertReviewers(
+      ChangeInfo c, ReviewerState reviewerState, Iterable<TestAccount> accounts) throws Exception {
+    Collection<AccountInfo> actualAccounts = c.reviewers.get(reviewerState);
+    if (actualAccounts == null) {
+      assertThat(accounts.iterator().hasNext()).isFalse();
+      return;
+    }
+    assertThat(actualAccounts).isNotNull();
+    List<Integer> actualAccountIds = new ArrayList<>(actualAccounts.size());
+    for (AccountInfo account : actualAccounts) {
+      actualAccountIds.add(account._accountId);
+    }
+    List<Integer> expectedAccountIds = new ArrayList<>();
+    for (TestAccount account : accounts) {
+      expectedAccountIds.add(account.getId().get());
+    }
+    assertThat(actualAccountIds).containsExactlyElementsIn(expectedAccountIds);
+  }
+
+  private List<TestAccount> createAccounts(int n, String emailPrefix) throws Exception {
+    List<TestAccount> result = new ArrayList<>(n);
+    for (int i = 0; i < n; i++) {
+      result.add(
+          accountCreator.create(
+              name("u" + i), emailPrefix + "-" + i + "@example.com", "Full Name " + i));
+    }
+    return result;
+  }
+
+  private Map<String, LabelInfo> getChangeLabels(String changeId) throws Exception {
+    return gApi.changes().id(changeId).get(DETAILED_LABELS).labels;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
new file mode 100644
index 0000000..fd3208e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -0,0 +1,203 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.testing.Util;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.RefSpec;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ConfigChangeIT extends AbstractDaemonTest {
+  @Before
+  public void setUp() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.allow(cfg, Permission.OWNER, REGISTERED_USERS, "refs/*");
+    Util.allow(cfg, Permission.PUSH, REGISTERED_USERS, "refs/for/refs/meta/config");
+    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, RefNames.REFS_CONFIG);
+    saveProjectConfig(project, cfg);
+
+    setApiUser(user);
+    fetchRefsMetaConfig();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void updateProjectConfig() throws Exception {
+    String id = testUpdateProjectConfig();
+    assertThat(gApi.changes().id(id).get().revisions).hasSize(1);
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user", submitType = SubmitType.CHERRY_PICK)
+  public void updateProjectConfigWithCherryPick() throws Exception {
+    String id = testUpdateProjectConfig();
+    assertThat(gApi.changes().id(id).get().revisions).hasSize(2);
+  }
+
+  private String testUpdateProjectConfig() throws Exception {
+    Config cfg = readProjectConfig();
+    assertThat(cfg.getString("project", null, "description")).isNull();
+    String desc = "new project description";
+    cfg.setString("project", null, "description", desc);
+
+    PushOneCommit.Result r = createConfigChange(cfg);
+    String id = r.getChangeId();
+
+    gApi.changes().id(id).current().review(ReviewInput.approve());
+    gApi.changes().id(id).current().submit();
+
+    assertThat(gApi.changes().id(id).info().status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.projects().name(project.get()).get().description).isEqualTo(desc);
+    fetchRefsMetaConfig();
+    assertThat(readProjectConfig().getString("project", null, "description")).isEqualTo(desc);
+    String changeRev = gApi.changes().id(id).get().currentRevision;
+    String branchRev =
+        gApi.projects().name(project.get()).branch(RefNames.REFS_CONFIG).get().revision;
+    assertThat(changeRev).isEqualTo(branchRev);
+    return id;
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void onlyAdminMayUpdateProjectParent() throws Exception {
+    setApiUser(admin);
+    ProjectInput parent = new ProjectInput();
+    parent.name = name("parent");
+    parent.permissionsOnly = true;
+    gApi.projects().create(parent);
+
+    setApiUser(user);
+    Config cfg = readProjectConfig();
+    assertThat(cfg.getString("access", null, "inheritFrom")).isAnyOf(null, allProjects.get());
+    cfg.setString("access", null, "inheritFrom", parent.name);
+
+    PushOneCommit.Result r = createConfigChange(cfg);
+    String id = r.getChangeId();
+
+    gApi.changes().id(id).current().review(ReviewInput.approve());
+    try {
+      gApi.changes().id(id).current().submit();
+      fail("expected submit to fail");
+    } catch (ResourceConflictException e) {
+      int n = gApi.changes().id(id).info()._number;
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(
+              "Failed to submit 1 change due to the following problems:\n"
+                  + "Change "
+                  + n
+                  + ": Change contains a project configuration that"
+                  + " changes the parent project.\n"
+                  + "The change must be submitted by a Gerrit administrator.");
+    }
+
+    assertThat(gApi.projects().name(project.get()).get().parent).isEqualTo(allProjects.get());
+    fetchRefsMetaConfig();
+    assertThat(readProjectConfig().getString("access", null, "inheritFrom"))
+        .isAnyOf(null, allProjects.get());
+
+    setApiUser(admin);
+    gApi.changes().id(id).current().submit();
+    assertThat(gApi.changes().id(id).info().status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.projects().name(project.get()).get().parent).isEqualTo(parent.name);
+    fetchRefsMetaConfig();
+    assertThat(readProjectConfig().getString("access", null, "inheritFrom")).isEqualTo(parent.name);
+  }
+
+  @Test
+  public void rejectDoubleInheritance() throws Exception {
+    setApiUser(admin);
+    // Create separate projects to test the config
+    Project.NameKey parent = createProject("projectToInheritFrom");
+    Project.NameKey child = createProject("projectWithMalformedConfig");
+
+    String config =
+        gApi.projects()
+            .name(child.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file("project.config")
+            .asString();
+
+    // Append and push malformed project config
+    String pattern = "[access]\n\tinheritFrom = " + allProjects.get() + "\n";
+    String doubleInherit = pattern + "\tinheritFrom = " + parent.get() + "\n";
+    config = config.replace(pattern, doubleInherit);
+
+    TestRepository<InMemoryRepository> childRepo = cloneProject(child, admin);
+    // Fetch meta ref
+    GitUtil.fetch(childRepo, RefNames.REFS_CONFIG + ":cfg");
+    childRepo.reset("cfg");
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), childRepo, "Subject", "project.config", config);
+    PushOneCommit.Result res = push.to(RefNames.REFS_CONFIG);
+    res.assertErrorStatus();
+    res.assertMessage("cannot inherit from multiple projects");
+  }
+
+  private void fetchRefsMetaConfig() throws Exception {
+    git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
+    testRepo.reset(RefNames.REFS_CONFIG);
+  }
+
+  private Config readProjectConfig() throws Exception {
+    RevWalk rw = testRepo.getRevWalk();
+    RevTree tree = rw.parseTree(testRepo.getRepository().resolve("HEAD"));
+    RevObject obj = rw.parseAny(testRepo.get(tree, "project.config"));
+    ObjectLoader loader = rw.getObjectReader().open(obj);
+    String text = new String(loader.getCachedBytes(), UTF_8);
+    Config cfg = new Config();
+    cfg.fromText(text);
+    return cfg;
+  }
+
+  private PushOneCommit.Result createConfigChange(Config cfg) throws Exception {
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                user.getIdent(),
+                testRepo,
+                "Update project config",
+                "project.config",
+                cfg.toText())
+            .to("refs/for/refs/meta/config");
+    r.assertOkStatus();
+    return r;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
new file mode 100644
index 0000000..865c7e0a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
@@ -0,0 +1,291 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_MAX_AGE;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
+import static com.google.common.net.HttpHeaders.AUTHORIZATION;
+import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
+import static com.google.common.net.HttpHeaders.ORIGIN;
+import static com.google.common.net.HttpHeaders.VARY;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.StringSubject;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.UrlEncoded;
+import com.google.gerrit.testing.ConfigSuite;
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+import java.util.stream.Stream;
+import org.apache.http.Header;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.fluent.Executor;
+import org.apache.http.client.fluent.Request;
+import org.apache.http.cookie.Cookie;
+import org.apache.http.impl.client.BasicCookieStore;
+import org.apache.http.message.BasicHeader;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class CorsIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config allowExampleDotCom() {
+    Config cfg = new Config();
+    cfg.setString("auth", null, "type", "DEVELOPMENT_BECOME_ANY_ACCOUNT");
+    cfg.setStringList(
+        "site",
+        null,
+        "allowOriginRegex",
+        ImmutableList.of("https?://(.+[.])?example[.]com", "http://friend[.]ly"));
+    return cfg;
+  }
+
+  @Test
+  public void missingOriginIsAllowedWithNoCorsResponseHeaders() throws Exception {
+    Result change = createChange();
+    String url = "/changes/" + change.getChangeId() + "/detail";
+    RestResponse r = adminRestSession.get(url);
+    r.assertOK();
+
+    String allowOrigin = r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
+    String allowCred = r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
+    String maxAge = r.getHeader(ACCESS_CONTROL_MAX_AGE);
+    String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
+    String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
+
+    assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNull();
+    assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNull();
+    assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isNull();
+    assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNull();
+    assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNull();
+  }
+
+  @Test
+  public void origins() throws Exception {
+    Result change = createChange();
+    String url = "/changes/" + change.getChangeId() + "/detail";
+
+    check(url, true, "http://example.com");
+    check(url, true, "https://sub.example.com");
+    check(url, true, "http://friend.ly");
+
+    check(url, false, "http://evil.attacker");
+    check(url, false, "http://friendsly");
+  }
+
+  @Test
+  public void putWithServerOriginAcceptedWithNoCorsResponseHeaders() throws Exception {
+    Result change = createChange();
+    String origin = adminRestSession.url();
+    RestResponse r =
+        adminRestSession.putWithHeader(
+            "/changes/" + change.getChangeId() + "/topic", new BasicHeader(ORIGIN, origin), "A");
+    r.assertOK();
+    checkCors(r, false, origin);
+    checkTopic(change, "A");
+  }
+
+  @Test
+  public void putWithOtherOriginAccepted() throws Exception {
+    Result change = createChange();
+    String origin = "http://example.com";
+    RestResponse r =
+        adminRestSession.putWithHeader(
+            "/changes/" + change.getChangeId() + "/topic", new BasicHeader(ORIGIN, origin), "A");
+    r.assertOK();
+    checkCors(r, true, origin);
+  }
+
+  @Test
+  public void preflightOk() throws Exception {
+    Result change = createChange();
+
+    String origin = "http://example.com";
+    Request req =
+        Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
+    req.addHeader(ORIGIN, origin);
+    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
+    req.addHeader(ACCESS_CONTROL_REQUEST_HEADERS, "X-Requested-With");
+
+    RestResponse res = adminRestSession.execute(req);
+    res.assertOK();
+
+    String vary = res.getHeader(VARY);
+    assertThat(vary).named(VARY).isNotNull();
+    assertThat(Splitter.on(", ").splitToList(vary))
+        .containsExactly(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS);
+    checkCors(res, true, origin);
+  }
+
+  @Test
+  public void preflightBadOrigin() throws Exception {
+    Result change = createChange();
+    Request req =
+        Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
+    req.addHeader(ORIGIN, "http://evil.attacker");
+    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
+    adminRestSession.execute(req).assertBadRequest();
+  }
+
+  @Test
+  public void preflightBadMethod() throws Exception {
+    Result change = createChange();
+    Request req =
+        Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
+    req.addHeader(ORIGIN, "http://example.com");
+    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "CALL");
+    adminRestSession.execute(req).assertBadRequest();
+  }
+
+  @Test
+  public void preflightBadHeader() throws Exception {
+    Result change = createChange();
+    Request req =
+        Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
+    req.addHeader(ORIGIN, "http://example.com");
+    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
+    req.addHeader(ACCESS_CONTROL_REQUEST_HEADERS, "X-Secret-Auth-Token");
+    adminRestSession.execute(req).assertBadRequest();
+  }
+
+  @Test
+  public void crossDomainPutTopic() throws Exception {
+    Result change = createChange();
+    BasicCookieStore cookies = new BasicCookieStore();
+    Executor http = Executor.newInstance().cookieStore(cookies);
+
+    Request req = Request.Get(canonicalWebUrl.get() + "/login/?account_id=" + admin.id.get());
+    HttpResponse r = http.execute(req).returnResponse();
+    String auth = null;
+    for (Cookie c : cookies.getCookies()) {
+      if ("GerritAccount".equals(c.getName())) {
+        auth = c.getValue();
+      }
+    }
+    assertThat(auth).named("GerritAccount cookie").isNotNull();
+    cookies.clear();
+
+    UrlEncoded url =
+        new UrlEncoded(canonicalWebUrl.get() + "/changes/" + change.getChangeId() + "/topic");
+    url.put("$m", "PUT");
+    url.put("$ct", "application/json; charset=US-ASCII");
+    url.put("access_token", auth);
+
+    String origin = "http://example.com";
+    req = Request.Post(url.toString());
+    req.setHeader(CONTENT_TYPE, "text/plain");
+    req.setHeader(ORIGIN, origin);
+    req.bodyByteArray("{\"topic\":\"test-xd\"}".getBytes(StandardCharsets.US_ASCII));
+
+    r = http.execute(req).returnResponse();
+    assertThat(r.getStatusLine().getStatusCode()).isEqualTo(200);
+
+    Header vary = r.getFirstHeader(VARY);
+    assertThat(vary).named(VARY).isNotNull();
+    assertThat(Splitter.on(", ").splitToList(vary.getValue())).named(VARY).contains(ORIGIN);
+
+    Header allowOrigin = r.getFirstHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
+    assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNotNull();
+    assertThat(allowOrigin.getValue()).named(ACCESS_CONTROL_ALLOW_ORIGIN).isEqualTo(origin);
+
+    Header allowAuth = r.getFirstHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
+    assertThat(allowAuth).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNotNull();
+    assertThat(allowAuth.getValue()).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isEqualTo("true");
+
+    checkTopic(change, "test-xd");
+  }
+
+  @Test
+  public void crossDomainRejectsBadOrigin() throws Exception {
+    Result change = createChange();
+    UrlEncoded url =
+        new UrlEncoded(canonicalWebUrl.get() + "/changes/" + change.getChangeId() + "/topic");
+    url.put("$m", "PUT");
+    url.put("$ct", "application/json; charset=US-ASCII");
+
+    Request req = Request.Post(url.toString());
+    req.setHeader(CONTENT_TYPE, "text/plain");
+    req.setHeader(ORIGIN, "http://evil.attacker");
+    req.bodyByteArray("{\"topic\":\"test-xd\"}".getBytes(StandardCharsets.US_ASCII));
+    adminRestSession.execute(req).assertBadRequest();
+    checkTopic(change, null);
+  }
+
+  private void checkTopic(Result change, @Nullable String topic) throws RestApiException {
+    ChangeInfo info = gApi.changes().id(change.getChangeId()).get();
+    StringSubject t = assertThat(info.topic).named("topic");
+    if (topic != null) {
+      t.isEqualTo(topic);
+    } else {
+      t.isNull();
+    }
+  }
+
+  private void check(String url, boolean accept, String origin) throws Exception {
+    Header hdr = new BasicHeader(ORIGIN, origin);
+    RestResponse r = adminRestSession.getWithHeader(url, hdr);
+    r.assertOK();
+    checkCors(r, accept, origin);
+  }
+
+  private void checkCors(RestResponse r, boolean accept, String origin) {
+    String vary = r.getHeader(VARY);
+    assertThat(vary).named(VARY).isNotNull();
+    assertThat(Splitter.on(", ").splitToList(vary)).named(VARY).contains(ORIGIN);
+
+    String allowOrigin = r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
+    String allowCred = r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
+    String maxAge = r.getHeader(ACCESS_CONTROL_MAX_AGE);
+    String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
+    String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
+    if (accept) {
+      assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isEqualTo(origin);
+      assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isEqualTo("true");
+      assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isEqualTo("600");
+
+      assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNotNull();
+      assertThat(Splitter.on(", ").splitToList(allowMethods))
+          .named(ACCESS_CONTROL_ALLOW_METHODS)
+          .containsExactly("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS");
+
+      assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNotNull();
+      assertThat(Splitter.on(", ").splitToList(allowHeaders))
+          .named(ACCESS_CONTROL_ALLOW_HEADERS)
+          .containsExactlyElementsIn(
+              Stream.of(AUTHORIZATION, CONTENT_TYPE, "X-Gerrit-Auth", "X-Requested-With")
+                  .map(s -> s.toLowerCase(Locale.US))
+                  .collect(ImmutableSet.toImmutableSet()));
+    } else {
+      assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNull();
+      assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNull();
+      assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isNull();
+      assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNull();
+      assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNull();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
new file mode 100644
index 0000000..fad78c6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -0,0 +1,459 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.git.ChangeAlreadyMergedException;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.RefSpec;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class CreateChangeIT extends AbstractDaemonTest {
+  @BeforeClass
+  public static void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @AfterClass
+  public static void restoreTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void createEmptyChange_MissingBranch() throws Exception {
+    ChangeInput ci = new ChangeInput();
+    ci.project = project.get();
+    assertCreateFails(ci, BadRequestException.class, "branch must be non-empty");
+  }
+
+  @Test
+  public void createEmptyChange_MissingMessage() throws Exception {
+    ChangeInput ci = new ChangeInput();
+    ci.project = project.get();
+    ci.branch = "master";
+    assertCreateFails(ci, BadRequestException.class, "commit message must be non-empty");
+  }
+
+  @Test
+  public void createEmptyChange_InvalidStatus() throws Exception {
+    ChangeInput ci = newChangeInput(ChangeStatus.MERGED);
+    assertCreateFails(ci, BadRequestException.class, "unsupported change status");
+  }
+
+  @Test
+  public void createNewChange() throws Exception {
+    assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
+  }
+
+  @Test
+  public void notificationsOnChangeCreation() throws Exception {
+    setApiUser(user);
+    watch(project.get());
+
+    // check that watcher is notified
+    setApiUser(admin);
+    assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains(admin.fullName + " has uploaded this change for review.");
+
+    // check that watcher is not notified if notify=NONE
+    sender.clear();
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.notify = NotifyHandling.NONE;
+    assertCreateSucceeds(input);
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void createNewChangeSignedOffByFooter() throws Exception {
+    setSignedOffByFooter();
+
+    ChangeInfo info = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
+    String message = info.revisions.get(info.currentRevision).commit.message;
+    assertThat(message)
+        .contains(
+            String.format(
+                "%sAdministrator <%s>", SIGNED_OFF_BY_TAG, admin.getIdent().getEmailAddress()));
+  }
+
+  @Test
+  public void createNewPrivateChange() throws Exception {
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.isPrivate = true;
+    assertCreateSucceeds(input);
+  }
+
+  @Test
+  public void createNewWorkInProgressChange() throws Exception {
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.workInProgress = true;
+    assertCreateSucceeds(input);
+  }
+
+  @Test
+  public void createChangeWithoutAccessToParentCommitFails() throws Exception {
+    Map<String, PushOneCommit.Result> results =
+        changeInTwoBranches("invisible-branch", "a.txt", "visible-branch", "b.txt");
+    block(project, "refs/heads/invisible-branch", READ, REGISTERED_USERS);
+
+    ChangeInput in = newChangeInput(ChangeStatus.NEW);
+    in.branch = "visible-branch";
+    in.baseChange = results.get("invisible-branch").getChangeId();
+    assertCreateFails(
+        in, UnprocessableEntityException.class, "Base change not found: " + in.baseChange);
+  }
+
+  @Test
+  public void createChangeOnInvisibleBranchFails() throws Exception {
+    changeInTwoBranches("invisible-branch", "a.txt", "branchB", "b.txt");
+    block(project, "refs/heads/invisible-branch", READ, REGISTERED_USERS);
+
+    ChangeInput in = newChangeInput(ChangeStatus.NEW);
+    in.branch = "invisible-branch";
+    assertCreateFails(in, ResourceNotFoundException.class, "");
+  }
+
+  @Test
+  public void noteDbCommit() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    ChangeInfo c = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit =
+          rw.parseCommit(repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
+
+      assertThat(commit.getShortMessage()).isEqualTo("Create change");
+
+      PersonIdent expectedAuthor =
+          changeNoteUtil.newIdent(
+              accountCache.get(admin.id).getAccount(), c.created, serverIdent.get());
+      assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
+
+      assertThat(commit.getCommitterIdent())
+          .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
+      assertThat(commit.getParentCount()).isEqualTo(0);
+    }
+  }
+
+  @Test
+  public void createMergeChange() throws Exception {
+    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+    assertCreateSucceeds(in);
+  }
+
+  @Test
+  public void createMergeChange_Conflicts() throws Exception {
+    changeInTwoBranches("branchA", "shared.txt", "branchB", "shared.txt");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+    assertCreateFails(in, RestApiException.class, "merge conflict");
+  }
+
+  @Test
+  public void createMergeChange_Conflicts_Ours() throws Exception {
+    changeInTwoBranches("branchA", "shared.txt", "branchB", "shared.txt");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "ours");
+    assertCreateSucceeds(in);
+  }
+
+  @Test
+  public void invalidSource() throws Exception {
+    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+    ChangeInput in = newMergeChangeInput("branchA", "invalid", "");
+    assertCreateFails(in, BadRequestException.class, "Cannot resolve 'invalid' to a commit");
+  }
+
+  @Test
+  public void invalidStrategy() throws Exception {
+    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "octopus");
+    assertCreateFails(in, BadRequestException.class, "invalid merge strategy: octopus");
+  }
+
+  @Test
+  public void alreadyMerged() throws Exception {
+    ObjectId c0 =
+        testRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .message("first commit")
+            .add("a.txt", "a contents ")
+            .create();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
+
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
+        .message("second commit")
+        .add("b.txt", "b contents ")
+        .create();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
+
+    ChangeInput in = newMergeChangeInput("master", c0.getName(), "");
+    assertCreateFails(
+        in, ChangeAlreadyMergedException.class, "'" + c0.getName() + "' has already been merged");
+  }
+
+  @Test
+  public void onlyContentMerged() throws Exception {
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
+        .message("first commit")
+        .add("a.txt", "a contents ")
+        .create();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
+
+    // create a change, and cherrypick into master
+    PushOneCommit.Result cId = createChange();
+    RevCommit commitId = cId.getCommit();
+    CherryPickInput cpi = new CherryPickInput();
+    cpi.destination = "master";
+    cpi.message = "cherry pick the commit";
+    ChangeApi orig = gApi.changes().id(cId.getChangeId());
+    ChangeApi cherry = orig.current().cherryPick(cpi);
+    cherry.current().review(ReviewInput.approve());
+    cherry.current().submit();
+
+    ObjectId remoteId = getRemoteHead();
+    assertThat(remoteId).isNotEqualTo(commitId);
+
+    ChangeInput in = newMergeChangeInput("master", commitId.getName(), "");
+    assertCreateSucceeds(in);
+  }
+
+  @Test
+  public void cherryPickCommitWithoutChangeId() throws Exception {
+    // This test is a little superfluous, since the current cherry-pick code ignores
+    // the commit message of the to-be-cherry-picked change, using the one in
+    // CherryPickInput instead.
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    input.message = "it goes to foo branch";
+    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+
+    RevCommit revCommit = createNewCommitWithoutChangeId("refs/heads/master", "a.txt", "content");
+    ChangeInfo changeInfo =
+        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
+
+    assertThat(changeInfo.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
+    String expectedMessage =
+        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
+    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
+
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    CommitInfo commitInfo = revInfo.commit;
+    assertThat(commitInfo.message)
+        .isEqualTo(input.message + "\n\nChange-Id: " + changeInfo.changeId + "\n");
+  }
+
+  @Test
+  public void cherryPickCommitWithChangeId() throws Exception {
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+
+    RevCommit revCommit = createChange().getCommit();
+    List<String> footers = revCommit.getFooterLines("Change-Id");
+    assertThat(footers).hasSize(1);
+    String changeId = footers.get(0);
+
+    input.message = "it goes to foo branch\n\nChange-Id: " + changeId;
+    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+
+    ChangeInfo changeInfo =
+        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
+
+    assertThat(changeInfo.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
+    String expectedMessage =
+        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
+    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
+
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(input.message + "\n");
+  }
+
+  private ChangeInput newChangeInput(ChangeStatus status) {
+    ChangeInput in = new ChangeInput();
+    in.project = project.get();
+    in.branch = "master";
+    in.subject = "Empty change";
+    in.topic = "support-gerrit-workflow-in-browser";
+    in.status = status;
+    return in;
+  }
+
+  private ChangeInfo assertCreateSucceeds(ChangeInput in) throws Exception {
+    ChangeInfo out = gApi.changes().create(in).get();
+    assertThat(out.project).isEqualTo(in.project);
+    assertThat(out.branch).isEqualTo(in.branch);
+    assertThat(out.subject).isEqualTo(in.subject);
+    assertThat(out.topic).isEqualTo(in.topic);
+    assertThat(out.status).isEqualTo(in.status);
+    assertThat(out.isPrivate).isEqualTo(in.isPrivate);
+    assertThat(out.workInProgress).isEqualTo(in.workInProgress);
+    assertThat(out.revisions).hasSize(1);
+    assertThat(out.submitted).isNull();
+    assertThat(in.status).isEqualTo(ChangeStatus.NEW);
+    return out;
+  }
+
+  private void assertCreateFails(
+      ChangeInput in, Class<? extends RestApiException> errType, String errSubstring)
+      throws Exception {
+    exception.expect(errType);
+    exception.expectMessage(errSubstring);
+    gApi.changes().create(in);
+  }
+
+  // TODO(davido): Expose setting of account preferences in the API
+  private void setSignedOffByFooter() throws Exception {
+    RestResponse r = adminRestSession.get("/accounts/" + admin.email + "/preferences");
+    r.assertOK();
+    GeneralPreferencesInfo i = newGson().fromJson(r.getReader(), GeneralPreferencesInfo.class);
+    i.signedOffBy = true;
+
+    r = adminRestSession.put("/accounts/" + admin.email + "/preferences", i);
+    r.assertOK();
+    GeneralPreferencesInfo o = newGson().fromJson(r.getReader(), GeneralPreferencesInfo.class);
+
+    assertThat(o.signedOffBy).isTrue();
+  }
+
+  private ChangeInput newMergeChangeInput(String targetBranch, String sourceRef, String strategy) {
+    // create a merge change from branchA to master in gerrit
+    ChangeInput in = new ChangeInput();
+    in.project = project.get();
+    in.branch = targetBranch;
+    in.subject = "merge " + sourceRef + " to " + targetBranch;
+    in.status = ChangeStatus.NEW;
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = sourceRef;
+    in.merge = mergeInput;
+    if (!Strings.isNullOrEmpty(strategy)) {
+      in.merge.strategy = strategy;
+    }
+    return in;
+  }
+
+  /**
+   * Create an empty commit in master, two new branches with one commit each.
+   *
+   * @param branchA name of first branch to create
+   * @param fileA name of file to commit to branchA
+   * @param branchB name of second branch to create
+   * @param fileB name of file to commit to branchB
+   * @return A {@code Map} of branchName => commit result.
+   * @throws Exception
+   */
+  private Map<String, Result> changeInTwoBranches(
+      String branchA, String fileA, String branchB, String fileB) throws Exception {
+    // create a initial commit in master
+    Result initialCommit =
+        pushFactory
+            .create(db, user.getIdent(), testRepo, "initial commit", "readme.txt", "initial commit")
+            .to("refs/heads/master");
+    initialCommit.assertOkStatus();
+
+    // create two new branches
+    createBranch(new Branch.NameKey(project, branchA));
+    createBranch(new Branch.NameKey(project, branchB));
+
+    // create a commit in branchA
+    Result changeA =
+        pushFactory
+            .create(db, user.getIdent(), testRepo, "change A", fileA, "A content")
+            .to("refs/heads/" + branchA);
+    changeA.assertOkStatus();
+
+    // create a commit in branchB
+    PushOneCommit commitB =
+        pushFactory.create(db, user.getIdent(), testRepo, "change B", fileB, "B content");
+    commitB.setParent(initialCommit.getCommit());
+    Result changeB = commitB.to("refs/heads/" + branchB);
+    changeB.assertOkStatus();
+
+    return ImmutableMap.of("master", initialCommit, branchA, changeA, branchB, changeB);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
new file mode 100644
index 0000000..7e5ebdb
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.testing.FakeEmailSender;
+import com.google.gson.reflect.TypeToken;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import org.junit.Test;
+
+public class DeleteVoteIT extends AbstractDaemonTest {
+  @Test
+  public void deleteVoteOnChange() throws Exception {
+    deleteVote(false);
+  }
+
+  @Test
+  public void deleteVoteOnRevision() throws Exception {
+    deleteVote(true);
+  }
+
+  private void deleteVote(boolean onRevisionLevel) throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    PushOneCommit.Result r2 = amendChange(r.getChangeId());
+
+    setApiUser(user);
+    recommend(r.getChangeId());
+
+    sender.clear();
+    String endPoint =
+        "/changes/"
+            + r.getChangeId()
+            + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
+            + "/reviewers/"
+            + user.getId().toString()
+            + "/votes/Code-Review";
+
+    RestResponse response = adminRestSession.delete(endPoint);
+    response.assertNoContent();
+
+    List<FakeEmailSender.Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    FakeEmailSender.Message msg = messages.get(0);
+    assertThat(msg.rcpt()).containsExactly(user.emailAddress);
+    assertThat(msg.body()).contains(admin.fullName + " has removed a vote on this change.\n");
+    assertThat(msg.body())
+        .contains("Removed Code-Review+1 by " + user.fullName + " <" + user.email + ">\n");
+
+    endPoint =
+        "/changes/"
+            + r.getChangeId()
+            + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
+            + "/reviewers/"
+            + user.getId().toString()
+            + "/votes";
+
+    response = adminRestSession.get(endPoint);
+    response.assertOK();
+
+    Map<String, Short> m =
+        newGson().fromJson(response.getReader(), new TypeToken<Map<String, Short>>() {}.getType());
+
+    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
+
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+
+    ChangeMessageInfo message = Iterables.getLast(c.messages);
+    assertThat(message.author._accountId).isEqualTo(admin.getId().get());
+    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
+  }
+
+  private Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
+    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
new file mode 100644
index 0000000..48a1a1e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -0,0 +1,313 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.common.truth.IterableSubject;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.testing.TestTimeUtil;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+@NoHttpd
+public class HashtagsIT extends AbstractDaemonTest {
+  @Before
+  public void before() {
+    assume().that(notesMigration.readChanges()).isTrue();
+  }
+
+  @BeforeClass
+  public static void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @AfterClass
+  public static void restoreTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void getNoHashtags() throws Exception {
+    // Get on a change with no hashtags returns an empty list.
+    PushOneCommit.Result r = createChange();
+    assertThatGet(r).isEmpty();
+  }
+
+  @Test
+  public void addSingleHashtag() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Adding a single hashtag returns a single hashtag.
+    addHashtags(r, "tag2");
+    assertThatGet(r).containsExactly("tag2");
+    assertMessage(r, "Hashtag added: tag2");
+
+    // Adding another single hashtag to change that already has one hashtag
+    // returns a sorted list of hashtags with existing and new.
+    addHashtags(r, "tag1");
+    assertThatGet(r).containsExactly("tag1", "tag2").inOrder();
+    assertMessage(r, "Hashtag added: tag1");
+  }
+
+  @Test
+  public void addInvalidHashtag() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("hashtags may not contain commas");
+    addHashtags(r, "invalid,hashtag");
+  }
+
+  @Test
+  public void addMultipleHashtags() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Adding multiple hashtags returns a sorted list of hashtags.
+    addHashtags(r, "tag3", "tag1");
+    assertThatGet(r).containsExactly("tag1", "tag3").inOrder();
+    assertMessage(r, "Hashtags added: tag1, tag3");
+
+    // Adding multiple hashtags to change that already has hashtags returns a
+    // sorted list of hashtags with existing and new.
+    addHashtags(r, "tag2", "tag4");
+    assertThatGet(r).containsExactly("tag1", "tag2", "tag3", "tag4").inOrder();
+    assertMessage(r, "Hashtags added: tag2, tag4");
+  }
+
+  @Test
+  public void addAlreadyExistingHashtag() throws Exception {
+    // Adding a hashtag that already exists on the change returns a sorted list
+    // of hashtags without duplicates.
+    PushOneCommit.Result r = createChange();
+    addHashtags(r, "tag2");
+    assertThatGet(r).containsExactly("tag2");
+    assertMessage(r, "Hashtag added: tag2");
+    ChangeMessageInfo last = getLastMessage(r);
+
+    addHashtags(r, "tag2");
+    assertThatGet(r).containsExactly("tag2");
+    assertNoNewMessageSince(r, last);
+
+    addHashtags(r, "tag1", "tag2");
+    assertThatGet(r).containsExactly("tag1", "tag2").inOrder();
+    assertMessage(r, "Hashtag added: tag1");
+  }
+
+  @Test
+  public void hashtagsWithPrefix() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Leading # is stripped from added tag.
+    addHashtags(r, "#tag1");
+    assertThatGet(r).containsExactly("tag1");
+    assertMessage(r, "Hashtag added: tag1");
+
+    // Leading # is stripped from multiple added tags.
+    addHashtags(r, "#tag2", "#tag3");
+    assertThatGet(r).containsExactly("tag1", "tag2", "tag3").inOrder();
+    assertMessage(r, "Hashtags added: tag2, tag3");
+
+    // Leading # is stripped from removed tag.
+    removeHashtags(r, "#tag2");
+    assertThatGet(r).containsExactly("tag1", "tag3").inOrder();
+    assertMessage(r, "Hashtag removed: tag2");
+
+    // Leading # is stripped from multiple removed tags.
+    removeHashtags(r, "#tag1", "#tag3");
+    assertThatGet(r).isEmpty();
+    assertMessage(r, "Hashtags removed: tag1, tag3");
+
+    // Leading # and space are stripped from added tag.
+    addHashtags(r, "# tag1");
+    assertThatGet(r).containsExactly("tag1");
+    assertMessage(r, "Hashtag added: tag1");
+
+    // Multiple leading # are stripped from added tag.
+    addHashtags(r, "##tag2");
+    assertThatGet(r).containsExactly("tag1", "tag2").inOrder();
+    assertMessage(r, "Hashtag added: tag2");
+
+    // Multiple leading spaces and # are stripped from added tag.
+    addHashtags(r, "# # tag3");
+    assertThatGet(r).containsExactly("tag1", "tag2", "tag3").inOrder();
+    assertMessage(r, "Hashtag added: tag3");
+  }
+
+  @Test
+  public void removeSingleHashtag() throws Exception {
+    // Removing a single tag from a change that only has that tag returns an
+    // empty list.
+    PushOneCommit.Result r = createChange();
+    addHashtags(r, "tag1");
+    assertThatGet(r).containsExactly("tag1");
+    removeHashtags(r, "tag1");
+    assertThatGet(r).isEmpty();
+    assertMessage(r, "Hashtag removed: tag1");
+
+    // Removing a single tag from a change that has multiple tags returns a
+    // sorted list of remaining tags.
+    addHashtags(r, "tag1", "tag2", "tag3");
+    removeHashtags(r, "tag2");
+    assertThatGet(r).containsExactly("tag1", "tag3").inOrder();
+    assertMessage(r, "Hashtag removed: tag2");
+  }
+
+  @Test
+  public void removeMultipleHashtags() throws Exception {
+    // Removing multiple tags from a change that only has those tags returns an
+    // empty list.
+    PushOneCommit.Result r = createChange();
+    addHashtags(r, "tag1", "tag2");
+    assertThatGet(r).containsExactly("tag1", "tag2").inOrder();
+    removeHashtags(r, "tag1", "tag2");
+    assertThatGet(r).isEmpty();
+    assertMessage(r, "Hashtags removed: tag1, tag2");
+
+    // Removing multiple tags from a change that has multiple tags returns a
+    // sorted list of remaining tags.
+    addHashtags(r, "tag1", "tag2", "tag3", "tag4");
+    assertThatGet(r).containsExactly("tag1", "tag2", "tag3", "tag4").inOrder();
+    removeHashtags(r, "tag2", "tag4");
+    assertThatGet(r).containsExactly("tag1", "tag3").inOrder();
+    assertMessage(r, "Hashtags removed: tag2, tag4");
+  }
+
+  @Test
+  public void removeNotExistingHashtag() throws Exception {
+    // Removing a single hashtag from change that has no hashtags returns an
+    // empty list.
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+    removeHashtags(r, "tag1");
+    assertThatGet(r).isEmpty();
+    assertNoNewMessageSince(r, last);
+
+    // Removing a single non-existing tag from a change that only has one other
+    // tag returns a list of only one tag.
+    addHashtags(r, "tag1");
+    last = getLastMessage(r);
+    removeHashtags(r, "tag4");
+    assertThatGet(r).containsExactly("tag1");
+    assertNoNewMessageSince(r, last);
+
+    // Removing a single non-existing tag from a change that has multiple tags
+    // returns a sorted list of tags without any deleted.
+    addHashtags(r, "tag1", "tag2", "tag3");
+    last = getLastMessage(r);
+    removeHashtags(r, "tag4");
+    assertThatGet(r).containsExactly("tag1", "tag2", "tag3").inOrder();
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void addAndRemove() throws Exception {
+    // Adding and remove hashtags in a single request performs correctly.
+    PushOneCommit.Result r = createChange();
+    addHashtags(r, "tag1", "tag2");
+    HashtagsInput input = new HashtagsInput();
+    input.add = Sets.newHashSet("tag3", "tag4");
+    input.remove = Sets.newHashSet("tag1");
+    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
+    assertThatGet(r).containsExactly("tag2", "tag3", "tag4");
+    assertMessage(r, "Hashtags added: tag3, tag4\nHashtag removed: tag1");
+
+    // Adding and removing the same hashtag actually removes it.
+    addHashtags(r, "tag1", "tag2");
+    input = new HashtagsInput();
+    input.add = Sets.newHashSet("tag3", "tag4");
+    input.remove = Sets.newHashSet("tag3");
+    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
+    assertThatGet(r).containsExactly("tag1", "tag2", "tag4");
+    assertMessage(r, "Hashtag removed: tag3");
+  }
+
+  @Test
+  public void hashtagWithMixedCase() throws Exception {
+    PushOneCommit.Result r = createChange();
+    addHashtags(r, "MyHashtag");
+    assertThatGet(r).containsExactly("MyHashtag");
+    assertMessage(r, "Hashtag added: MyHashtag");
+  }
+
+  @Test
+  public void addHashtagWithoutPermissionNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("edit hashtags not permitted");
+    addHashtags(r, "MyHashtag");
+  }
+
+  @Test
+  public void addHashtagWithPermissionAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    grant(project, "refs/heads/master", Permission.EDIT_HASHTAGS, false, REGISTERED_USERS);
+    setApiUser(user);
+    addHashtags(r, "MyHashtag");
+    assertThatGet(r).containsExactly("MyHashtag");
+    assertMessage(r, "Hashtag added: MyHashtag");
+  }
+
+  private IterableSubject assertThatGet(PushOneCommit.Result r) throws Exception {
+    return assertThat(gApi.changes().id(r.getChange().getId().get()).getHashtags());
+  }
+
+  private void addHashtags(PushOneCommit.Result r, String... toAdd) throws Exception {
+    HashtagsInput input = new HashtagsInput();
+    input.add = Sets.newHashSet(toAdd);
+    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
+  }
+
+  private void removeHashtags(PushOneCommit.Result r, String... toRemove) throws Exception {
+    HashtagsInput input = new HashtagsInput();
+    input.remove = Sets.newHashSet(toRemove);
+    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
+  }
+
+  private void assertMessage(PushOneCommit.Result r, String expectedMessage) throws Exception {
+    assertThat(getLastMessage(r).message).isEqualTo(expectedMessage);
+  }
+
+  private void assertNoNewMessageSince(PushOneCommit.Result r, ChangeMessageInfo expected)
+      throws Exception {
+    checkNotNull(expected);
+    ChangeMessageInfo last = getLastMessage(r);
+    assertThat(last.message).isEqualTo(expected.message);
+    assertThat(last.id).isEqualTo(expected.id);
+  }
+
+  private ChangeMessageInfo getLastMessage(PushOneCommit.Result r) throws Exception {
+    ChangeMessageInfo lastMessage =
+        Iterables.getLast(gApi.changes().id(r.getChange().getId().get()).get().messages, null);
+    assertThat(lastMessage).named(lastMessage.message).isNotNull();
+    return lastMessage;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
rename to javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java
rename to javatests/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
new file mode 100644
index 0000000..d0ad427
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -0,0 +1,306 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.MoveInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.testing.Util;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+@NoHttpd
+public class MoveChangeIT extends AbstractDaemonTest {
+  @Test
+  public void moveChangeWithShortRef() throws Exception {
+    // Move change to a different branch using short ref name
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+    move(r.getChangeId(), newBranch.getShortName());
+    assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
+  }
+
+  @Test
+  public void moveChangeWithFullRef() throws Exception {
+    // Move change to a different branch using full ref name
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+    move(r.getChangeId(), newBranch.get());
+    assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
+  }
+
+  @Test
+  public void moveChangeWithMessage() throws Exception {
+    // Provide a message using --message flag
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+    String moveMessage = "Moving for the move test";
+    move(r.getChangeId(), newBranch.get(), moveMessage);
+    assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
+    StringBuilder expectedMessage = new StringBuilder();
+    expectedMessage.append("Change destination moved from master to moveTest");
+    expectedMessage.append("\n\n");
+    expectedMessage.append(moveMessage);
+    assertThat(r.getChange().messages().get(1).getMessage()).isEqualTo(expectedMessage.toString());
+  }
+
+  @Test
+  public void moveChangeToSameRefAsCurrent() throws Exception {
+    // Move change to the branch same as change's destination
+    PushOneCommit.Result r = createChange();
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Change is already destined for the specified branch");
+    move(r.getChangeId(), r.getChange().change().getDest().get());
+  }
+
+  @Test
+  public void moveChangeToSameChangeId() throws Exception {
+    // Move change to a branch with existing change with same change ID
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+    int changeNum = r.getChange().change().getChangeId();
+    createChange(newBranch.get(), r.getChangeId());
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        "Destination "
+            + newBranch.getShortName()
+            + " has a different change with same change key "
+            + r.getChangeId());
+    move(changeNum, newBranch.get());
+  }
+
+  @Test
+  public void moveChangeToNonExistentRef() throws Exception {
+    // Move change to a non-existing branch
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch =
+        new Branch.NameKey(r.getChange().change().getProject(), "does_not_exist");
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Destination " + newBranch.get() + " not found in the project");
+    move(r.getChangeId(), newBranch.get());
+  }
+
+  @Test
+  public void moveClosedChange() throws Exception {
+    // Move a change which is not open
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+    merge(r);
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Change is merged");
+    move(r.getChangeId(), newBranch.get());
+  }
+
+  @Test
+  public void moveMergeCommitChange() throws Exception {
+    // Move a change which has a merge commit as the current PS
+    // Create a merge commit and push for review
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.branch("HEAD").commit().insertChangeId();
+    commitBuilder
+        .parent(r1.getCommit())
+        .parent(r2.getCommit())
+        .message("Move change Merge Commit")
+        .author(admin.getIdent())
+        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
+    RevCommit c = commitBuilder.create();
+    pushHead(testRepo, "refs/for/master", false, false);
+
+    // Try to move the merge commit to another branch
+    Branch.NameKey newBranch = new Branch.NameKey(r1.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Merge commit cannot be moved");
+    move(GitUtil.getChangeId(testRepo, c).get(), newBranch.get());
+  }
+
+  @Test
+  public void moveChangeToBranchWithoutUploadPerms() throws Exception {
+    // Move change to a destination where user doesn't have upload permissions
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch =
+        new Branch.NameKey(r.getChange().change().getProject(), "blocked_branch");
+    createBranch(newBranch);
+    block(
+        "refs/for/" + newBranch.get(),
+        Permission.PUSH,
+        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+    exception.expect(AuthException.class);
+    exception.expectMessage("move not permitted");
+    move(r.getChangeId(), newBranch.get());
+  }
+
+  @Test
+  public void moveChangeFromBranchWithoutAbandonPerms() throws Exception {
+    // Move change for which user does not have abandon permissions
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+    block(
+        r.getChange().change().getDest().get(),
+        Permission.ABANDON,
+        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("move not permitted");
+    move(r.getChangeId(), newBranch.get());
+  }
+
+  @Test
+  public void moveChangeToBranchThatContainsCurrentCommit() throws Exception {
+    // Move change to a branch for which current PS revision is reachable from
+    // tip
+
+    // Create a change
+    PushOneCommit.Result r = createChange();
+    int changeNum = r.getChange().change().getChangeId();
+
+    // Create a branch with that same commit
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchInput bi = new BranchInput();
+    bi.revision = r.getCommit().name();
+    gApi.projects().name(newBranch.getParentKey().get()).branch(newBranch.get()).create(bi);
+
+    // Try to move the change to the branch with the same commit
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        "Current patchset revision is reachable from tip of " + newBranch.get());
+    move(changeNum, newBranch.get());
+  }
+
+  @Test
+  public void moveChangeWithCurrentPatchSetLocked() throws Exception {
+    // Move change that is locked
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType patchSetLock = Util.patchSetLock();
+    cfg.getLabelSections().put(patchSetLock.getName(), patchSetLock);
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    Util.allow(
+        cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, registeredUsers, "refs/heads/*");
+    saveProjectConfig(cfg);
+    grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
+    revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
+
+    exception.expect(AuthException.class);
+    exception.expectMessage("move not permitted");
+    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);
+  }
+
+  private void move(String changeId, String destination) throws RestApiException {
+    gApi.changes().id(changeId).move(destination);
+  }
+
+  private void move(String changeId, String destination, String message) throws RestApiException {
+    MoveInput in = new MoveInput();
+    in.destinationBranch = destination;
+    in.message = message;
+    gApi.changes().id(changeId).move(in);
+  }
+
+  private PushOneCommit.Result createChange(String branch, String changeId) throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo, changeId);
+    PushOneCommit.Result result = push.to("refs/for/" + branch);
+    result.assertOkStatus();
+    return result;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
new file mode 100644
index 0000000..0ece00a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
@@ -0,0 +1,159 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.reviewdb.client.Project;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PrivateByDefaultIT extends AbstractDaemonTest {
+  private Project.NameKey project1;
+  private Project.NameKey project2;
+
+  @Before
+  public void setUp() throws Exception {
+    project1 = createProject("project-1");
+    project2 = createProject("project-2", project1);
+    setPrivateByDefault(project1, InheritableBoolean.FALSE);
+  }
+
+  @Test
+  public void createChangeWithPrivateByDefaultEnabled() throws Exception {
+    setPrivateByDefault(project2, InheritableBoolean.TRUE);
+    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    assertThat(gApi.changes().create(input).get().isPrivate).isTrue();
+  }
+
+  @Test
+  public void createChangeBypassPrivateByDefaultEnabled() throws Exception {
+    setPrivateByDefault(project2, InheritableBoolean.TRUE);
+    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    input.isPrivate = false;
+    assertThat(gApi.changes().create(input).get().isPrivate).isNull();
+  }
+
+  @Test
+  public void createChangeWithPrivateByDefaultDisabled() throws Exception {
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project2.get(), "master", "empty change")).get();
+    assertThat(info.isPrivate).isNull();
+  }
+
+  @Test
+  public void createChangeWithPrivateByDefaultInherited() throws Exception {
+    setPrivateByDefault(project1, InheritableBoolean.TRUE);
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project2.get(), "master", "empty change")).get();
+    assertThat(info.isPrivate).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void createChangeWithPrivateByDefaultAndDisablePrivateChangesTrue() throws Exception {
+    setPrivateByDefault(project2, InheritableBoolean.TRUE);
+
+    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("private changes are disabled");
+    gApi.changes().create(input);
+  }
+
+  @Test
+  public void pushWithPrivateByDefaultEnabled() throws Exception {
+    setPrivateByDefault(project2, InheritableBoolean.TRUE);
+    assertThat(createChange(project2).getChange().change().isPrivate()).isTrue();
+  }
+
+  @Test
+  public void pushBypassPrivateByDefaultEnabled() throws Exception {
+    setPrivateByDefault(project2, InheritableBoolean.TRUE);
+    assertThat(
+            createChange(project2, "refs/for/master%remove-private")
+                .getChange()
+                .change()
+                .isPrivate())
+        .isFalse();
+  }
+
+  @Test
+  public void pushWithPrivateByDefaultDisabled() throws Exception {
+    assertThat(createChange(project2).getChange().change().isPrivate()).isFalse();
+  }
+
+  @Test
+  public void pushBypassPrivateByDefaultInherited() throws Exception {
+    setPrivateByDefault(project1, InheritableBoolean.TRUE);
+    assertThat(createChange(project2).getChange().change().isPrivate()).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void pushPrivatesWithPrivateByDefaultAndDisablePrivateChangesTrue() throws Exception {
+    setPrivateByDefault(project2, InheritableBoolean.TRUE);
+
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project2);
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%private");
+    result.assertErrorStatus();
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void pushDraftsWithPrivateByDefaultAndDisablePrivateChangesTrue() throws Exception {
+    setPrivateByDefault(project2, InheritableBoolean.TRUE);
+
+    RevCommit initialHead = getRemoteHead();
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project2);
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%draft");
+    result.assertErrorStatus();
+
+    testRepo.reset(initialHead);
+    result = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/drafts/master");
+    result.assertErrorStatus();
+  }
+
+  private void setPrivateByDefault(Project.NameKey proj, InheritableBoolean value)
+      throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.privateByDefault = value;
+    gApi.projects().name(proj.get()).config(input);
+  }
+
+  private PushOneCommit.Result createChange(Project.NameKey proj) throws Exception {
+    return createChange(proj, "refs/for/master");
+  }
+
+  private PushOneCommit.Result createChange(Project.NameKey proj, String ref) throws Exception {
+    TestRepository<InMemoryRepository> testRepo = cloneProject(proj);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result result = push.to(ref);
+    result.assertOkStatus();
+    return result;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
new file mode 100644
index 0000000..e508664
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -0,0 +1,463 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.change.TestSubmitInput;
+import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.git.strategy.CommitMergeStatus;
+import com.google.inject.Inject;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Test;
+
+public class SubmitByCherryPickIT extends AbstractSubmit {
+  @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+
+  @Override
+  protected SubmitType getSubmitType() {
+    return SubmitType.CHERRY_PICK;
+  }
+
+  @Test
+  public void submitWithCherryPickIfFastForwardPossible() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange();
+    submit(change.getChangeId());
+    assertCherryPick(testRepo, false);
+    RevCommit newHead = getRemoteHead();
+    assertThat(newHead.getParent(0)).isEqualTo(change.getCommit().getParent(0));
+
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change.getChangeId(), newHead.name());
+  }
+
+  @Test
+  public void submitWithCherryPick() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
+    submit(change2.getChangeId());
+    assertCherryPick(testRepo, false);
+    RevCommit newHead = getRemoteHead();
+    assertThat(newHead.getParentCount()).isEqualTo(1);
+    assertThat(newHead.getParent(0)).isEqualTo(headAfterFirstSubmit);
+    assertCurrentRevision(change2.getChangeId(), 2, newHead);
+    assertSubmitter(change2.getChangeId(), 1);
+    assertSubmitter(change2.getChangeId(), 2);
+    assertPersonEquals(admin.getIdent(), newHead.getAuthorIdent());
+    assertPersonEquals(admin.getIdent(), newHead.getCommitterIdent());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit, headAfterFirstSubmit, newHead);
+    assertChangeMergedEvents(
+        change.getChangeId(), headAfterFirstSubmit.name(), change2.getChangeId(), newHead.name());
+  }
+
+  @Test
+  public void changeMessageOnSubmit() throws Exception {
+    PushOneCommit.Result change = createChange();
+    RegistrationHandle handle =
+        changeMessageModifiers.add(
+            new ChangeMessageModifier() {
+              @Override
+              public String onSubmit(
+                  String newCommitMessage,
+                  RevCommit original,
+                  RevCommit mergeTip,
+                  Branch.NameKey destination) {
+                return newCommitMessage + "Custom: " + destination.get();
+              }
+            });
+    try {
+      submit(change.getChangeId());
+    } finally {
+      handle.remove();
+    }
+    testRepo.git().fetch().setRemote("origin").call();
+    ChangeInfo info = get(change.getChangeId(), CURRENT_REVISION);
+    RevCommit c = testRepo.getRevWalk().parseCommit(ObjectId.fromString(info.currentRevision));
+    testRepo.getRevWalk().parseBody(c);
+    assertThat(c.getFooterLines("Custom")).containsExactly("refs/heads/master");
+    assertThat(c.getFooterLines(FooterConstants.REVIEWED_ON)).hasSize(1);
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithContentMerge() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
+    submit(change.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
+    submit(change2.getChangeId());
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+
+    testRepo.reset(change.getCommit());
+    PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
+    submit(change3.getChangeId());
+    assertCherryPick(testRepo, true);
+    RevCommit headAfterThirdSubmit = getRemoteHead();
+    assertThat(headAfterThirdSubmit.getParent(0)).isEqualTo(headAfterSecondSubmit);
+    assertApproved(change3.getChangeId());
+    assertCurrentRevision(change3.getChangeId(), 2, headAfterThirdSubmit);
+    assertSubmitter(change2.getChangeId(), 1);
+    assertSubmitter(change2.getChangeId(), 2);
+
+    assertRefUpdatedEvents(
+        initialHead,
+        headAfterFirstSubmit,
+        headAfterFirstSubmit,
+        headAfterSecondSubmit,
+        headAfterSecondSubmit,
+        headAfterThirdSubmit);
+    assertChangeMergedEvents(
+        change.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterSecondSubmit.name(),
+        change3.getChangeId(),
+        headAfterThirdSubmit.name());
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithContentMerge_Conflict() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit newHead = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
+    submitWithConflict(
+        change2.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change2.getChange().getId()
+            + ": Change could not be "
+            + "merged due to a path conflict. Please rebase the change locally and "
+            + "upload the rebased commit for review.");
+
+    assertThat(getRemoteHead()).isEqualTo(newHead);
+    assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
+    assertNoSubmitter(change2.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change.getChangeId(), newHead.name());
+  }
+
+  @Test
+  public void submitOutOfOrder() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    createChange("Change 2", "b.txt", "other content");
+    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "different content");
+    submit(change3.getChangeId());
+    assertCherryPick(testRepo, false);
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit.getParent(0)).isEqualTo(headAfterFirstSubmit);
+    assertApproved(change3.getChangeId());
+    assertCurrentRevision(change3.getChangeId(), 2, headAfterSecondSubmit);
+    assertSubmitter(change3.getChangeId(), 1);
+    assertSubmitter(change3.getChangeId(), 2);
+
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change3.getChangeId(),
+        headAfterSecondSubmit.name());
+  }
+
+  @Test
+  public void submitOutOfOrder_Conflict() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit newHead = getRemoteHead();
+    testRepo.reset(initialHead);
+    createChange("Change 2", "b.txt", "other content");
+    PushOneCommit.Result change3 = createChange("Change 3", "b.txt", "different content");
+    submitWithConflict(
+        change3.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change3.getChange().getId()
+            + ": Change could not be "
+            + "merged due to a path conflict. Please rebase the change locally and "
+            + "upload the rebased commit for review.");
+
+    assertThat(getRemoteHead()).isEqualTo(newHead);
+    assertCurrentRevision(change3.getChangeId(), 1, change3.getCommit());
+    assertNoSubmitter(change3.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change.getChangeId(), newHead.name());
+  }
+
+  @Test
+  public void submitMultipleChanges() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
+
+    approve(change.getChangeId());
+    approve(change2.getChangeId());
+    submit(change3.getChangeId());
+
+    List<RevCommit> log = getRemoteLog();
+    assertThat(log.get(0).getShortMessage()).isEqualTo(change3.getCommit().getShortMessage());
+    assertThat(log.get(1).getId()).isEqualTo(initialHead.getId());
+
+    assertNew(change.getChangeId());
+    assertNew(change2.getChangeId());
+
+    assertRefUpdatedEvents(initialHead, log.get(0));
+    assertChangeMergedEvents(change3.getChangeId(), log.get(0).name());
+  }
+
+  @Test
+  public void submitDependentNonConflictingChangesOutOfOrder() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
+    assertThat(change2.getCommit().getParent(0)).isEqualTo(change.getCommit());
+
+    // Submit succeeds; change2 is successfully cherry-picked onto head.
+    submit(change2.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    // Submit succeeds; change is successfully cherry-picked onto head
+    // (which was change2's cherry-pick).
+    submit(change.getChangeId());
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+
+    // change is the new tip.
+    List<RevCommit> log = getRemoteLog();
+    assertThat(log.get(0).getShortMessage()).isEqualTo(change.getCommit().getShortMessage());
+    assertThat(log.get(0).getParent(0)).isEqualTo(log.get(1));
+
+    assertThat(log.get(1).getShortMessage()).isEqualTo(change2.getCommit().getShortMessage());
+    assertThat(log.get(1).getParent(0)).isEqualTo(log.get(2));
+
+    assertThat(log.get(2).getId()).isEqualTo(initialHead.getId());
+
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change2.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change.getChangeId(),
+        headAfterSecondSubmit.name());
+  }
+
+  @Test
+  public void submitDependentConflictingChangesOutOfOrder() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change = createChange("Change 1", "b", "b1");
+    PushOneCommit.Result change2 = createChange("Change 2", "b", "b2");
+    assertThat(change2.getCommit().getParent(0)).isEqualTo(change.getCommit());
+
+    // Submit fails; change2 contains the delta "b1" -> "b2", which cannot be
+    // applied against tip.
+    submitWithConflict(
+        change2.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change2.getChange().getId()
+            + ": Change could not be "
+            + "merged due to a path conflict. Please rebase the change locally and "
+            + "upload the rebased commit for review.");
+
+    ChangeInfo info3 = get(change2.getChangeId(), ListChangesOption.MESSAGES);
+    assertThat(info3.status).isEqualTo(ChangeStatus.NEW);
+
+    // Tip has not changed.
+    List<RevCommit> log = getRemoteLog();
+    assertThat(log.get(0)).isEqualTo(initialHead.getId());
+    assertNoSubmitter(change2.getChangeId(), 1);
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
+
+  @Test
+  public void submitSubsetOfDependentChanges() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
+    PushOneCommit.Result change3 = createChange("Change 3", "e", "e");
+
+    // Out of the above, only submit change 3. Changes 1 and 2 are not
+    // related to change 3 by topic or ancestor (due to cherrypicking!)
+    approve(change2.getChangeId());
+    submit(change3.getChangeId());
+    RevCommit newHead = getRemoteHead();
+
+    assertNew(change.getChangeId());
+    assertNew(change2.getChangeId());
+
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change3.getChangeId(), newHead.name());
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitIdenticalTree() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "a");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "a");
+
+    submit(change1.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    assertThat(headAfterFirstSubmit.getShortMessage()).isEqualTo("Change 1");
+
+    submit(change2.getChangeId(), new SubmitInput(), null, null);
+
+    assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
+
+    ChangeInfo info2 = get(change2.getChangeId(), MESSAGES);
+    assertThat(info2.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(Iterables.getLast(info2.messages).message)
+        .isEqualTo(CommitMergeStatus.SKIPPED_IDENTICAL_TREE.getMessage());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(
+        change1.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterFirstSubmit.name());
+  }
+
+  @Test
+  public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
+    Change.Id id2 = change2.getChange().getId();
+    TestSubmitInput failInput = new TestSubmitInput();
+    failInput.failAfterRefUpdates = true;
+    submit(
+        change2.getChangeId(),
+        failInput,
+        ResourceConflictException.class,
+        "Failing after ref updates");
+    RevCommit headAfterFailedSubmit = getRemoteHead();
+
+    // Bad: ref advanced but change wasn't updated.
+    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
+    PatchSet.Id psId2 = new PatchSet.Id(id2, 2);
+    ChangeInfo info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
+    assertThat(getPatchSet(psId2)).isNull();
+
+    ObjectId rev2;
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
+      assertThat(rev1).isNotNull();
+
+      rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
+      assertThat(rev2).isNotNull();
+      assertThat(rev2).isNotEqualTo(rev1);
+      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(headAfterFirstSubmit);
+
+      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
+    }
+
+    submit(change2.getChangeId());
+
+    // Change status and patch set entities were updated, and branch tip stayed
+    // the same.
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit).isEqualTo(headAfterFailedSubmit);
+    info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(2);
+    PatchSet ps2 = getPatchSet(psId2);
+    assertThat(ps2).isNotNull();
+    assertThat(ps2.getRevision().get()).isEqualTo(rev2.name());
+    assertThat(Iterables.getLast(info.messages).message)
+        .isEqualTo(
+            "Change has been successfully cherry-picked as " + rev2.name() + " by Administrator");
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
+    }
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(
+        change.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterSecondSubmit.name());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
new file mode 100644
index 0000000..2ebf3ca
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -0,0 +1,218 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.change.TestSubmitInput;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
+import org.junit.Test;
+
+public class SubmitByFastForwardIT extends AbstractSubmit {
+
+  @Override
+  protected SubmitType getSubmitType() {
+    return SubmitType.FAST_FORWARD_ONLY;
+  }
+
+  @Test
+  public void submitWithFastForward() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange();
+    submit(change.getChangeId());
+    RevCommit updatedHead = getRemoteHead();
+    assertThat(updatedHead.getId()).isEqualTo(change.getCommit());
+    assertThat(updatedHead.getParent(0)).isEqualTo(initialHead);
+    assertSubmitter(change.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, updatedHead);
+    assertChangeMergedEvents(change.getChangeId(), updatedHead.name());
+  }
+
+  @Test
+  public void submitMultipleChangesWithFastForward() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    PushOneCommit.Result change = createChange();
+    PushOneCommit.Result change2 = createChange();
+    PushOneCommit.Result change3 = createChange();
+
+    String id1 = change.getChangeId();
+    String id2 = change2.getChangeId();
+    String id3 = change3.getChangeId();
+    approve(id1);
+    approve(id2);
+    submit(id3);
+
+    RevCommit updatedHead = getRemoteHead();
+    assertThat(updatedHead.getId()).isEqualTo(change3.getCommit());
+    assertThat(updatedHead.getParent(0).getId()).isEqualTo(change2.getCommit());
+    assertSubmitter(change.getChangeId(), 1);
+    assertSubmitter(change2.getChangeId(), 1);
+    assertSubmitter(change3.getChangeId(), 1);
+    assertPersonEquals(admin.getIdent(), updatedHead.getAuthorIdent());
+    assertPersonEquals(admin.getIdent(), updatedHead.getCommitterIdent());
+    assertSubmittedTogether(id1, id3, id2, id1);
+    assertSubmittedTogether(id2, id3, id2, id1);
+    assertSubmittedTogether(id3, id3, id2, id1);
+
+    assertRefUpdatedEvents(initialHead, updatedHead);
+    assertChangeMergedEvents(
+        id1, updatedHead.name(), id2, updatedHead.name(), id3, updatedHead.name());
+  }
+
+  @Test
+  public void submitTwoChangesWithFastForward_missingDependency() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+
+    Change.Id id1 = change1.getPatchSetId().getParentKey();
+    submitWithConflict(
+        change2.getChangeId(),
+        "Failed to submit 2 changes due to the following problems:\n"
+            + "Change "
+            + id1
+            + ": needs Code-Review");
+
+    RevCommit updatedHead = getRemoteHead();
+    assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
+
+  @Test
+  public void submitFastForwardNotPossible_Conflict() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
+
+    approve(change2.getChangeId());
+    Map<String, ActionInfo> actions = getActions(change2.getChangeId());
+
+    assertThat(actions).containsKey("submit");
+    ActionInfo info = actions.get("submit");
+    assertThat(info.enabled).isNull();
+
+    submitWithConflict(
+        change2.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change2.getChange().getId()
+            + ": Project policy requires "
+            + "all submissions to be a fast-forward. Please rebase the change "
+            + "locally and upload again for review.");
+    assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
+    assertSubmitter(change.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
+  }
+
+  @Test
+  public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    Change.Id id = change.getChange().getId();
+    TestSubmitInput failInput = new TestSubmitInput();
+    failInput.failAfterRefUpdates = true;
+    submit(
+        change.getChangeId(),
+        failInput,
+        ResourceConflictException.class,
+        "Failing after ref updates");
+
+    // Bad: ref advanced but change wasn't updated.
+    PatchSet.Id psId = new PatchSet.Id(id, 1);
+    ChangeInfo info = gApi.changes().id(id.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
+
+    ObjectId rev;
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      rev = repo.exactRef(psId.toRefName()).getObjectId();
+      assertThat(rev).isNotNull();
+      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev);
+    }
+
+    submit(change.getChangeId());
+
+    // Change status was updated, and branch tip stayed the same.
+    info = gApi.changes().id(id.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
+    assertThat(Iterables.getLast(info.messages).message)
+        .isEqualTo("Change has been successfully merged by Administrator");
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev);
+    }
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents(change.getChangeId(), getRemoteHead().name());
+  }
+
+  @Test
+  public void submitSameCommitsAsInExperimentalBranch() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    grant(project, "refs/heads/*", Permission.CREATE);
+    grant(project, "refs/heads/experimental", Permission.PUSH);
+
+    RevCommit c1 = commitBuilder().add("b.txt", "1").message("commit at tip").create();
+    String id1 = GitUtil.getChangeId(testRepo, c1).get();
+
+    PushResult r1 = pushHead(testRepo, "refs/for/master", false);
+    assertThat(r1.getRemoteUpdate("refs/for/master").getNewObjectId()).isEqualTo(c1.getId());
+
+    PushResult r2 = pushHead(testRepo, "refs/heads/experimental", false);
+    assertThat(r2.getRemoteUpdate("refs/heads/experimental").getNewObjectId())
+        .isEqualTo(c1.getId());
+
+    submit(id1);
+    RevCommit headAfterSubmit = getRemoteHead();
+
+    assertThat(getRemoteHead().getId()).isEqualTo(c1.getId());
+    assertSubmitter(id1, 1);
+
+    assertRefUpdatedEvents(initialHead, headAfterSubmit);
+    assertChangeMergedEvents(id1, headAfterSubmit.name());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
rename to javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
new file mode 100644
index 0000000..19ca430
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -0,0 +1,538 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import java.io.File;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.GZIPInputStream;
+import org.apache.commons.compress.archivers.ArchiveStreamFactory;
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.RefSpec;
+import org.junit.Test;
+
+public class SubmitByMergeIfNecessaryIT extends AbstractSubmitByMerge {
+
+  @Override
+  protected SubmitType getSubmitType() {
+    return SubmitType.MERGE_IF_NECESSARY;
+  }
+
+  @Test
+  public void submitWithFastForward() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange();
+    submit(change.getChangeId());
+    RevCommit updatedHead = getRemoteHead();
+    assertThat(updatedHead.getId()).isEqualTo(change.getCommit());
+    assertThat(updatedHead.getParent(0)).isEqualTo(initialHead);
+    assertSubmitter(change.getChangeId(), 1);
+    assertPersonEquals(admin.getIdent(), updatedHead.getAuthorIdent());
+    assertPersonEquals(admin.getIdent(), updatedHead.getCommitterIdent());
+
+    assertRefUpdatedEvents(initialHead, updatedHead);
+    assertChangeMergedEvents(change.getChangeId(), updatedHead.name());
+  }
+
+  @Test
+  public void submitMultipleChanges() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
+    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
+    PushOneCommit.Result change5 = createChange("Change 5", "f", "f");
+
+    // Change 2 is a fast-forward, no need to merge.
+    submit(change2.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteLog().get(0);
+    assertThat(headAfterFirstSubmit.getShortMessage())
+        .isEqualTo(change2.getCommit().getShortMessage());
+    assertThat(headAfterFirstSubmit.getParent(0).getId()).isEqualTo(initialHead.getId());
+    assertPersonEquals(admin.getIdent(), headAfterFirstSubmit.getAuthorIdent());
+    assertPersonEquals(admin.getIdent(), headAfterFirstSubmit.getCommitterIdent());
+
+    // We need to merge changes 3, 4 and 5.
+    approve(change3.getChangeId());
+    approve(change4.getChangeId());
+    submit(change5.getChangeId());
+
+    RevCommit headAfterSecondSubmit = getRemoteLog().get(0);
+    assertThat(headAfterSecondSubmit.getParent(1).getShortMessage())
+        .isEqualTo(change5.getCommit().getShortMessage());
+    assertThat(headAfterSecondSubmit.getParent(0).getShortMessage())
+        .isEqualTo(change2.getCommit().getShortMessage());
+
+    assertPersonEquals(admin.getIdent(), headAfterSecondSubmit.getAuthorIdent());
+    assertPersonEquals(serverIdent.get(), headAfterSecondSubmit.getCommitterIdent());
+
+    // First change stays untouched.
+    assertNew(change.getChangeId());
+
+    // The two submit operations should have resulted in two ref-update events
+    // and three change-merged events.
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change2.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change3.getChangeId(),
+        headAfterSecondSubmit.name(),
+        change4.getChangeId(),
+        headAfterSecondSubmit.name(),
+        change5.getChangeId(),
+        headAfterSecondSubmit.name());
+  }
+
+  @Test
+  public void submitChangesAcrossRepos() throws Exception {
+    Project.NameKey p1 = createProject("project-where-we-submit");
+    Project.NameKey p2 = createProject("project-impacted-via-topic");
+    Project.NameKey p3 = createProject("project-impacted-indirectly-via-topic");
+
+    RevCommit initialHead2 = getRemoteHead(p2, "master");
+    RevCommit initialHead3 = getRemoteHead(p3, "master");
+
+    TestRepository<?> repo1 = cloneProject(p1);
+    TestRepository<?> repo2 = cloneProject(p2);
+    TestRepository<?> repo3 = cloneProject(p3);
+
+    PushOneCommit.Result change1a =
+        createChange(
+            repo1,
+            "master",
+            "An ancestor of the change we want to submit",
+            "a.txt",
+            "1",
+            "dependent-topic");
+    PushOneCommit.Result change1b =
+        createChange(
+            repo1,
+            "master",
+            "We're interested in submitting this change",
+            "a.txt",
+            "2",
+            "topic-to-submit");
+
+    PushOneCommit.Result change2a =
+        createChange(repo2, "master", "indirection level 1", "a.txt", "1", "topic-indirect");
+    PushOneCommit.Result change2b =
+        createChange(
+            repo2, "master", "should go in with first change", "a.txt", "2", "dependent-topic");
+
+    PushOneCommit.Result change3 =
+        createChange(repo3, "master", "indirection level 2", "a.txt", "1", "topic-indirect");
+
+    approve(change1a.getChangeId());
+    approve(change2a.getChangeId());
+    approve(change2b.getChangeId());
+    approve(change3.getChangeId());
+
+    // get a preview before submitting:
+    Map<Branch.NameKey, ObjectId> preview = fetchFromSubmitPreview(change1b.getChangeId());
+    submit(change1b.getChangeId());
+
+    RevCommit tip1 = getRemoteLog(p1, "master").get(0);
+    RevCommit tip2 = getRemoteLog(p2, "master").get(0);
+    RevCommit tip3 = getRemoteLog(p3, "master").get(0);
+
+    assertThat(tip1.getShortMessage()).isEqualTo(change1b.getCommit().getShortMessage());
+
+    if (isSubmitWholeTopicEnabled()) {
+      assertThat(tip2.getShortMessage()).isEqualTo(change2b.getCommit().getShortMessage());
+      assertThat(tip3.getShortMessage()).isEqualTo(change3.getCommit().getShortMessage());
+
+      // check that the preview matched what happened:
+      assertThat(preview).hasSize(3);
+
+      assertThat(preview).containsKey(new Branch.NameKey(p1, "refs/heads/master"));
+      assertTrees(p1, preview);
+
+      assertThat(preview).containsKey(new Branch.NameKey(p2, "refs/heads/master"));
+      assertTrees(p2, preview);
+
+      assertThat(preview).containsKey(new Branch.NameKey(p3, "refs/heads/master"));
+      assertTrees(p3, preview);
+    } else {
+      assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
+      assertThat(tip3.getShortMessage()).isEqualTo(initialHead3.getShortMessage());
+      assertThat(preview).hasSize(1);
+      assertThat(preview.get(new Branch.NameKey(p1, "refs/heads/master"))).isNotNull();
+    }
+  }
+
+  @Test
+  public void submitChangesAcrossReposBlocked() throws Exception {
+    Project.NameKey p1 = createProject("project-where-we-submit");
+    Project.NameKey p2 = createProject("project-impacted-via-topic");
+    Project.NameKey p3 = createProject("project-impacted-indirectly-via-topic");
+
+    TestRepository<?> repo1 = cloneProject(p1);
+    TestRepository<?> repo2 = cloneProject(p2);
+    TestRepository<?> repo3 = cloneProject(p3);
+
+    RevCommit initialHead1 = getRemoteHead(p1, "master");
+    RevCommit initialHead2 = getRemoteHead(p2, "master");
+    RevCommit initialHead3 = getRemoteHead(p3, "master");
+
+    PushOneCommit.Result change1a =
+        createChange(
+            repo1,
+            "master",
+            "An ancestor of the change we want to submit",
+            "a.txt",
+            "1",
+            "dependent-topic");
+    PushOneCommit.Result change1b =
+        createChange(
+            repo1,
+            "master",
+            "we're interested to submit this change",
+            "a.txt",
+            "2",
+            "topic-to-submit");
+
+    PushOneCommit.Result change2a =
+        createChange(repo2, "master", "indirection level 2a", "a.txt", "1", "topic-indirect");
+    PushOneCommit.Result change2b =
+        createChange(
+            repo2, "master", "should go in with first change", "a.txt", "2", "dependent-topic");
+
+    PushOneCommit.Result change3 =
+        createChange(repo3, "master", "indirection level 2b", "a.txt", "1", "topic-indirect");
+
+    // Create a merge conflict for change3 which is only indirectly related
+    // via topics.
+    repo3.reset(initialHead3);
+    PushOneCommit.Result change3Conflict =
+        createChange(repo3, "master", "conflicting change", "a.txt", "2\n2", "conflicting-topic");
+    submit(change3Conflict.getChangeId());
+    RevCommit tipConflict = getRemoteLog(p3, "master").get(0);
+    assertThat(tipConflict.getShortMessage())
+        .isEqualTo(change3Conflict.getCommit().getShortMessage());
+
+    approve(change1a.getChangeId());
+    approve(change2a.getChangeId());
+    approve(change2b.getChangeId());
+    approve(change3.getChangeId());
+
+    if (isSubmitWholeTopicEnabled()) {
+      String msg =
+          "Failed to submit 5 changes due to the following problems:\n"
+              + "Change "
+              + change3.getChange().getId()
+              + ": Change could not be "
+              + "merged due to a path conflict. Please rebase the change locally "
+              + "and upload the rebased commit for review.";
+
+      // Get a preview before submitting:
+      try (BinaryResult r = submitPreview(change1b.getChangeId())) {
+        // We cannot just use the ExpectedException infrastructure as provided
+        // by AbstractDaemonTest, as then we'd stop early and not test the
+        // actual submit.
+
+        fail("expected failure");
+      } catch (RestApiException e) {
+        assertThat(e.getMessage()).isEqualTo(msg);
+      }
+      submitWithConflict(change1b.getChangeId(), msg);
+    } else {
+      submit(change1b.getChangeId());
+    }
+
+    RevCommit tip1 = getRemoteLog(p1, "master").get(0);
+    RevCommit tip2 = getRemoteLog(p2, "master").get(0);
+    RevCommit tip3 = getRemoteLog(p3, "master").get(0);
+    if (isSubmitWholeTopicEnabled()) {
+      assertThat(tip1.getShortMessage()).isEqualTo(initialHead1.getShortMessage());
+      assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
+      assertThat(tip3.getShortMessage()).isEqualTo(change3Conflict.getCommit().getShortMessage());
+      assertNoSubmitter(change1a.getChangeId(), 1);
+      assertNoSubmitter(change2a.getChangeId(), 1);
+      assertNoSubmitter(change2b.getChangeId(), 1);
+      assertNoSubmitter(change3.getChangeId(), 1);
+    } else {
+      assertThat(tip1.getShortMessage()).isEqualTo(change1b.getCommit().getShortMessage());
+      assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
+      assertThat(tip3.getShortMessage()).isEqualTo(change3Conflict.getCommit().getShortMessage());
+      assertNoSubmitter(change2a.getChangeId(), 1);
+      assertNoSubmitter(change2b.getChangeId(), 1);
+      assertNoSubmitter(change3.getChangeId(), 1);
+    }
+  }
+
+  @Test
+  public void submitWithMergedAncestorsOnOtherBranch() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    PushOneCommit.Result change1 =
+        createChange(testRepo, "master", "base commit", "a.txt", "1", "");
+    submit(change1.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+
+    gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
+
+    PushOneCommit.Result change2 =
+        createChange(
+            testRepo, "master", "We want to commit this to master first", "a.txt", "2", "");
+
+    submit(change2.getChangeId());
+
+    RevCommit headAfterSecondSubmit = getRemoteLog(project, "master").get(0);
+    assertThat(headAfterSecondSubmit.getShortMessage())
+        .isEqualTo(change2.getCommit().getShortMessage());
+
+    RevCommit tip2 = getRemoteLog(project, "branch").get(0);
+    assertThat(tip2.getShortMessage()).isEqualTo(change1.getCommit().getShortMessage());
+
+    PushOneCommit.Result change3 =
+        createChange(
+            testRepo,
+            "branch",
+            "This commit is based on master, which includes change2, "
+                + "but is targeted at branch, which doesn't include it.",
+            "a.txt",
+            "3",
+            "");
+
+    submit(change3.getChangeId());
+
+    List<RevCommit> log3 = getRemoteLog(project, "branch");
+    assertThat(log3.get(0).getShortMessage()).isEqualTo(change3.getCommit().getShortMessage());
+    assertThat(log3.get(1).getShortMessage()).isEqualTo(change2.getCommit().getShortMessage());
+
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change1.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterSecondSubmit.name());
+  }
+
+  @Test
+  public void submitWithOpenAncestorsOnOtherBranch() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change1 =
+        createChange(testRepo, "master", "base commit", "a.txt", "1", "");
+    submit(change1.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+
+    gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
+
+    PushOneCommit.Result change2 =
+        createChange(
+            testRepo, "master", "We want to commit this to master first", "a.txt", "2", "");
+
+    approve(change2.getChangeId());
+
+    RevCommit tip1 = getRemoteLog(project, "master").get(0);
+    assertThat(tip1.getShortMessage()).isEqualTo(change1.getCommit().getShortMessage());
+
+    RevCommit tip2 = getRemoteLog(project, "branch").get(0);
+    assertThat(tip2.getShortMessage()).isEqualTo(change1.getCommit().getShortMessage());
+
+    PushOneCommit.Result change3a =
+        createChange(
+            testRepo,
+            "branch",
+            "This commit is based on change2 pending for master, "
+                + "but is targeted itself at branch, which doesn't include it.",
+            "a.txt",
+            "3",
+            "a-topic-here");
+
+    Project.NameKey p3 = createProject("project-related-to-change3");
+    TestRepository<?> repo3 = cloneProject(p3);
+    RevCommit repo3Head = getRemoteHead(p3, "master");
+    PushOneCommit.Result change3b =
+        createChange(
+            repo3,
+            "master",
+            "some accompanying changes for change3a in another repo tied together via topic",
+            "a.txt",
+            "1",
+            "a-topic-here");
+    approve(change3b.getChangeId());
+
+    String cnt = isSubmitWholeTopicEnabled() ? "2 changes" : "1 change";
+    submitWithConflict(
+        change3a.getChangeId(),
+        "Failed to submit "
+            + cnt
+            + " due to the following problems:\n"
+            + "Change "
+            + change3a.getChange().getId()
+            + ": depends on change that"
+            + " was not submitted");
+
+    RevCommit tipbranch = getRemoteLog(project, "branch").get(0);
+    assertThat(tipbranch.getShortMessage()).isEqualTo(change1.getCommit().getShortMessage());
+
+    RevCommit tipmaster = getRemoteLog(p3, "master").get(0);
+    assertThat(tipmaster.getShortMessage()).isEqualTo(repo3Head.getShortMessage());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name());
+  }
+
+  @Test
+  public void gerritWorkflow() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    // We'll setup a master and a stable branch.
+    // Then we create a change to be applied to master, which is
+    // then cherry picked back to stable. The stable branch will
+    // be merged up into master again.
+    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
+
+    // Push a change to master
+    PushOneCommit push =
+        pushFactory.create(db, user.getIdent(), testRepo, "small fix", "a.txt", "2");
+    PushOneCommit.Result change = push.to("refs/for/master");
+    submit(change.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteLog(project, "master").get(0);
+    assertThat(headAfterFirstSubmit.getShortMessage())
+        .isEqualTo(change.getCommit().getShortMessage());
+
+    // Now cherry pick to stable
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "stable";
+    in.message = "This goes to stable as well\n" + headAfterFirstSubmit.getFullMessage();
+    ChangeApi orig = gApi.changes().id(change.getChangeId());
+    String cherryId = orig.current().cherryPick(in).id();
+    gApi.changes().id(cherryId).current().review(ReviewInput.approve());
+    gApi.changes().id(cherryId).current().submit();
+
+    // Create the merge locally
+    RevCommit stable = getRemoteHead(project, "stable");
+    RevCommit master = getRemoteHead(project, "master");
+    testRepo.git().fetch().call();
+    testRepo.git().branchCreate().setName("stable").setStartPoint(stable).call();
+    testRepo.git().branchCreate().setName("master").setStartPoint(master).call();
+
+    RevCommit merge =
+        testRepo
+            .commit()
+            .parent(master)
+            .parent(stable)
+            .message("Merge stable into master")
+            .insertChangeId()
+            .create();
+
+    testRepo.branch("refs/heads/master").update(merge);
+    testRepo.git().push().setRefSpecs(new RefSpec("refs/heads/master:refs/for/master")).call();
+
+    String changeId = GitUtil.getChangeId(testRepo, merge).get();
+    approve(changeId);
+    submit(changeId);
+    RevCommit headAfterSecondSubmit = getRemoteLog(project, "master").get(0);
+    assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo(merge.getShortMessage());
+
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change.getChangeId(), headAfterFirstSubmit.name(), changeId, headAfterSecondSubmit.name());
+  }
+
+  @Test
+  public void openChangeForTargetBranchPreventsMerge() throws Exception {
+    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
+
+    // Propose a change for master, but leave it open for master!
+    PushOneCommit change =
+        pushFactory.create(db, user.getIdent(), testRepo, "small fix", "a.txt", "2");
+    PushOneCommit.Result change2result = change.to("refs/for/master");
+
+    // Now cherry pick to stable
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "stable";
+    in.message = "it goes to stable branch";
+    ChangeApi orig = gApi.changes().id(change2result.getChangeId());
+    ChangeApi cherry = orig.current().cherryPick(in);
+    cherry.current().review(ReviewInput.approve());
+    cherry.current().submit();
+
+    // Create a commit locally
+    testRepo.git().fetch().setRefSpecs(new RefSpec("refs/heads/stable")).call();
+
+    PushOneCommit.Result change3 = createChange(testRepo, "stable", "test", "a.txt", "3", "");
+    submitWithConflict(
+        change3.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change3.getPatchSetId().getParentKey().get()
+            + ": depends on change that was not submitted");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
+
+  @Test
+  public void testPreviewSubmitTgz() throws Exception {
+    Project.NameKey p1 = createProject("project-name");
+
+    TestRepository<?> repo1 = cloneProject(p1);
+    PushOneCommit.Result change1 = createChange(repo1, "master", "test", "a.txt", "1", "topic");
+    approve(change1.getChangeId());
+
+    // get a preview before submitting:
+    File tempfile;
+    try (BinaryResult request = submitPreview(change1.getChangeId(), "tgz")) {
+      assertThat(request.getContentType()).isEqualTo("application/x-gzip");
+      tempfile = File.createTempFile("test", null);
+      request.writeTo(Files.newOutputStream(tempfile.toPath()));
+    }
+
+    InputStream is = new GZIPInputStream(Files.newInputStream(tempfile.toPath()));
+
+    List<String> untarredFiles = new ArrayList<>();
+    try (TarArchiveInputStream tarInputStream =
+        (TarArchiveInputStream) new ArchiveStreamFactory().createArchiveInputStream("tar", is)) {
+      TarArchiveEntry entry = null;
+      while ((entry = (TarArchiveEntry) tarInputStream.getNextEntry()) != null) {
+        untarredFiles.add(entry.getName());
+      }
+    }
+    assertThat(untarredFiles).containsExactly(name("project-name") + ".git");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
new file mode 100644
index 0000000..e8b8fe8
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -0,0 +1,135 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.inject.Inject;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class SubmitByRebaseAlwaysIT extends AbstractSubmitByRebase {
+  @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+
+  @Override
+  protected SubmitType getSubmitType() {
+    return SubmitType.REBASE_ALWAYS;
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithPossibleFastForward() throws Exception {
+    RevCommit oldHead = getRemoteHead();
+    PushOneCommit.Result change = createChange();
+    submit(change.getChangeId());
+
+    RevCommit head = getRemoteHead();
+    assertThat(head.getId()).isNotEqualTo(change.getCommit());
+    assertThat(head.getParent(0)).isEqualTo(oldHead);
+    assertApproved(change.getChangeId());
+    assertCurrentRevision(change.getChangeId(), 2, head);
+    assertSubmitter(change.getChangeId(), 1);
+    assertSubmitter(change.getChangeId(), 2);
+    assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
+    assertPersonEquals(admin.getIdent(), head.getCommitterIdent());
+    assertRefUpdatedEvents(oldHead, head);
+    assertChangeMergedEvents(change.getChangeId(), head.name());
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void alwaysAddFooters() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+
+    assertThat(getCurrentCommit(change1).getFooterLines(FooterConstants.REVIEWED_BY)).isEmpty();
+    assertThat(getCurrentCommit(change2).getFooterLines(FooterConstants.REVIEWED_BY)).isEmpty();
+
+    // change1 is a fast-forward, but should be rebased in cherry pick style
+    // anyway, making change2 not a fast-forward, requiring a rebase.
+    approve(change1.getChangeId());
+    submit(change2.getChangeId());
+    // ... but both changes should get reviewed-by footers.
+    assertLatestRevisionHasFooters(change1);
+    assertLatestRevisionHasFooters(change2);
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void changeMessageOnSubmit() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+
+    RegistrationHandle handle =
+        changeMessageModifiers.add(
+            new ChangeMessageModifier() {
+              @Override
+              public String onSubmit(
+                  String newCommitMessage,
+                  RevCommit original,
+                  RevCommit mergeTip,
+                  Branch.NameKey destination) {
+                List<String> custom = mergeTip.getFooterLines("Custom");
+                if (!custom.isEmpty()) {
+                  newCommitMessage += "Custom-Parent: " + custom.get(0) + "\n";
+                }
+                return newCommitMessage + "Custom: " + destination.get();
+              }
+            });
+    try {
+      // change1 is a fast-forward, but should be rebased in cherry pick style
+      // anyway, making change2 not a fast-forward, requiring a rebase.
+      approve(change1.getChangeId());
+      submit(change2.getChangeId());
+    } finally {
+      handle.remove();
+    }
+    // ... but both changes should get custom footers.
+    assertThat(getCurrentCommit(change1).getFooterLines("Custom"))
+        .containsExactly("refs/heads/master");
+    assertThat(getCurrentCommit(change2).getFooterLines("Custom"))
+        .containsExactly("refs/heads/master");
+    assertThat(getCurrentCommit(change2).getFooterLines("Custom-Parent"))
+        .containsExactly("refs/heads/master");
+  }
+
+  private void assertLatestRevisionHasFooters(PushOneCommit.Result change) throws Exception {
+    RevCommit c = getCurrentCommit(change);
+    assertThat(c.getFooterLines(FooterConstants.CHANGE_ID)).isNotEmpty();
+    assertThat(c.getFooterLines(FooterConstants.REVIEWED_BY)).isNotEmpty();
+    assertThat(c.getFooterLines(FooterConstants.REVIEWED_ON)).isNotEmpty();
+  }
+
+  private RevCommit getCurrentCommit(PushOneCommit.Result change) throws Exception {
+    testRepo.git().fetch().setRemote("origin").call();
+    ChangeInfo info = get(change.getChangeId(), CURRENT_REVISION);
+    RevCommit c = testRepo.getRevWalk().parseCommit(ObjectId.fromString(info.currentRevision));
+    testRepo.getRevWalk().parseBody(c);
+    return c;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
rename to javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
new file mode 100644
index 0000000..8e3618d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -0,0 +1,382 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.ChangeSet;
+import com.google.gerrit.server.git.MergeSuperSet;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.restapi.change.Submit;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+@NoHttpd
+public class SubmitResolvingMergeCommitIT extends AbstractDaemonTest {
+  @Inject private Provider<MergeSuperSet> mergeSuperSet;
+
+  @Inject private Submit submit;
+
+  @ConfigSuite.Default
+  public static Config submitWholeTopicEnabled() {
+    return submitWholeTopicEnabledConfig();
+  }
+
+  @Test
+  public void resolvingMergeCommitAtEndOfChain() throws Exception {
+    /*
+      A <- B <- C <------- D
+      ^                    ^
+      |                    |
+      E <- F <- G <- H <-- M*
+
+      G has a conflict with C and is resolved in M which is a merge
+      commit of H and D.
+    */
+
+    PushOneCommit.Result a = createChange("A");
+    PushOneCommit.Result b =
+        createChange("B", "new.txt", "No conflict line", ImmutableList.of(a.getCommit()));
+    PushOneCommit.Result c = createChange("C", ImmutableList.of(b.getCommit()));
+    PushOneCommit.Result d = createChange("D", ImmutableList.of(c.getCommit()));
+
+    PushOneCommit.Result e = createChange("E", ImmutableList.of(a.getCommit()));
+    PushOneCommit.Result f = createChange("F", ImmutableList.of(e.getCommit()));
+    PushOneCommit.Result g =
+        createChange("G", "new.txt", "Conflicting line", ImmutableList.of(f.getCommit()));
+    PushOneCommit.Result h = createChange("H", ImmutableList.of(g.getCommit()));
+
+    approve(a.getChangeId());
+    approve(b.getChangeId());
+    approve(c.getChangeId());
+    approve(d.getChangeId());
+    submit(d.getChangeId());
+
+    approve(e.getChangeId());
+    approve(f.getChangeId());
+    approve(g.getChangeId());
+    approve(h.getChangeId());
+
+    assertMergeable(e.getChange());
+    assertMergeable(f.getChange());
+    assertNotMergeable(g.getChange());
+    assertNotMergeable(h.getChange());
+
+    PushOneCommit.Result m =
+        createChange(
+            "M", "new.txt", "Resolved conflict", ImmutableList.of(d.getCommit(), h.getCommit()));
+    approve(m.getChangeId());
+
+    assertChangeSetMergeable(m.getChange(), true);
+
+    assertMergeable(m.getChange());
+    submit(m.getChangeId());
+
+    assertMerged(e.getChangeId());
+    assertMerged(f.getChangeId());
+    assertMerged(g.getChangeId());
+    assertMerged(h.getChangeId());
+    assertMerged(m.getChangeId());
+  }
+
+  @Test
+  public void resolvingMergeCommitComingBeforeConflict() throws Exception {
+    /*
+      A <- B <- C <- D
+      ^    ^
+      |    |
+      E <- F* <- G
+
+      F is a merge commit of E and B and resolves any conflict.
+      However G is conflicting with C.
+    */
+
+    PushOneCommit.Result a = createChange("A");
+    PushOneCommit.Result b =
+        createChange("B", "new.txt", "No conflict line", ImmutableList.of(a.getCommit()));
+    PushOneCommit.Result c =
+        createChange("C", "new.txt", "No conflict line #2", ImmutableList.of(b.getCommit()));
+    PushOneCommit.Result d = createChange("D", ImmutableList.of(c.getCommit()));
+    PushOneCommit.Result e =
+        createChange("E", "new.txt", "Conflicting line", ImmutableList.of(a.getCommit()));
+    PushOneCommit.Result f =
+        createChange(
+            "F", "new.txt", "Resolved conflict", ImmutableList.of(b.getCommit(), e.getCommit()));
+    PushOneCommit.Result g =
+        createChange("G", "new.txt", "Conflicting line #2", ImmutableList.of(f.getCommit()));
+
+    assertMergeable(e.getChange());
+
+    approve(a.getChangeId());
+    approve(b.getChangeId());
+    submit(b.getChangeId());
+
+    assertNotMergeable(e.getChange());
+    assertMergeable(f.getChange());
+    assertMergeable(g.getChange());
+
+    approve(c.getChangeId());
+    approve(d.getChangeId());
+    submit(d.getChangeId());
+
+    approve(e.getChangeId());
+    approve(f.getChangeId());
+    approve(g.getChangeId());
+
+    assertNotMergeable(g.getChange());
+    assertChangeSetMergeable(g.getChange(), false);
+  }
+
+  @Test
+  public void resolvingMergeCommitWithTopics() throws Exception {
+    /*
+      Project1:
+        A <- B <-- C <---
+        ^    ^          |
+        |    |          |
+        E <- F* <- G <- L*
+
+      G clashes with C, and F resolves the clashes between E and B.
+      Later, L resolves the clashes between C and G.
+
+      Project2:
+        H <- I
+        ^    ^
+        |    |
+        J <- K*
+
+      J clashes with I, and K resolves all problems.
+      G, K and L are in the same topic.
+    */
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    String project1Name = name("Project1");
+    String project2Name = name("Project2");
+    gApi.projects().create(project1Name);
+    gApi.projects().create(project2Name);
+    TestRepository<InMemoryRepository> project1 = cloneProject(new Project.NameKey(project1Name));
+    TestRepository<InMemoryRepository> project2 = cloneProject(new Project.NameKey(project2Name));
+
+    PushOneCommit.Result a = createChange(project1, "A");
+    PushOneCommit.Result b =
+        createChange(project1, "B", "new.txt", "No conflict line", ImmutableList.of(a.getCommit()));
+    PushOneCommit.Result c =
+        createChange(
+            project1, "C", "new.txt", "No conflict line #2", ImmutableList.of(b.getCommit()));
+
+    approve(a.getChangeId());
+    approve(b.getChangeId());
+    approve(c.getChangeId());
+    submit(c.getChangeId());
+
+    PushOneCommit.Result e =
+        createChange(project1, "E", "new.txt", "Conflicting line", ImmutableList.of(a.getCommit()));
+    PushOneCommit.Result f =
+        createChange(
+            project1,
+            "F",
+            "new.txt",
+            "Resolved conflict",
+            ImmutableList.of(b.getCommit(), e.getCommit()));
+    PushOneCommit.Result g =
+        createChange(
+            project1,
+            "G",
+            "new.txt",
+            "Conflicting line #2",
+            ImmutableList.of(f.getCommit()),
+            "refs/for/master/" + name("topic1"));
+
+    PushOneCommit.Result h = createChange(project2, "H");
+    PushOneCommit.Result i =
+        createChange(project2, "I", "new.txt", "No conflict line", ImmutableList.of(h.getCommit()));
+    PushOneCommit.Result j =
+        createChange(project2, "J", "new.txt", "Conflicting line", ImmutableList.of(h.getCommit()));
+    PushOneCommit.Result k =
+        createChange(
+            project2,
+            "K",
+            "new.txt",
+            "Sadly conflicting topic-wise",
+            ImmutableList.of(i.getCommit(), j.getCommit()),
+            "refs/for/master/" + name("topic1"));
+
+    approve(h.getChangeId());
+    approve(i.getChangeId());
+    submit(i.getChangeId());
+
+    approve(e.getChangeId());
+    approve(f.getChangeId());
+    approve(g.getChangeId());
+    approve(j.getChangeId());
+    approve(k.getChangeId());
+
+    assertChangeSetMergeable(g.getChange(), false);
+    assertChangeSetMergeable(k.getChange(), false);
+
+    PushOneCommit.Result l =
+        createChange(
+            project1,
+            "L",
+            "new.txt",
+            "Resolving conflicts again",
+            ImmutableList.of(c.getCommit(), g.getCommit()),
+            "refs/for/master/" + name("topic1"));
+
+    approve(l.getChangeId());
+    assertChangeSetMergeable(l.getChange(), true);
+
+    submit(l.getChangeId());
+    assertMerged(c.getChangeId());
+    assertMerged(g.getChangeId());
+    assertMerged(k.getChangeId());
+  }
+
+  @Test
+  public void resolvingMergeCommitAtEndOfChainAndNotUpToDate() throws Exception {
+    /*
+        A <-- B
+         \
+          C  <- D
+           \   /
+             E
+
+        B is the target branch, and D should be merged with B, but one
+        of C conflicts with B
+    */
+
+    PushOneCommit.Result a = createChange("A");
+    PushOneCommit.Result b =
+        createChange("B", "new.txt", "No conflict line", ImmutableList.of(a.getCommit()));
+
+    approve(a.getChangeId());
+    approve(b.getChangeId());
+    submit(b.getChangeId());
+
+    PushOneCommit.Result c =
+        createChange("C", "new.txt", "Create conflicts", ImmutableList.of(a.getCommit()));
+    PushOneCommit.Result e = createChange("E", ImmutableList.of(c.getCommit()));
+    PushOneCommit.Result d =
+        createChange(
+            "D", "new.txt", "Resolves conflicts", ImmutableList.of(c.getCommit(), e.getCommit()));
+
+    approve(c.getChangeId());
+    approve(e.getChangeId());
+    approve(d.getChangeId());
+    assertNotMergeable(d.getChange());
+    assertChangeSetMergeable(d.getChange(), false);
+  }
+
+  private void submit(String changeId) throws Exception {
+    gApi.changes().id(changeId).current().submit();
+  }
+
+  private void assertChangeSetMergeable(ChangeData change, boolean expected)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException, OrmException,
+          PermissionBackendException {
+    ChangeSet cs = mergeSuperSet.get().completeChangeSet(db, change.change(), user(admin));
+    assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
+  }
+
+  private void assertMergeable(ChangeData change) throws Exception {
+    change.setMergeable(null);
+    assertThat(change.isMergeable()).isTrue();
+  }
+
+  private void assertNotMergeable(ChangeData change) throws Exception {
+    change.setMergeable(null);
+    assertThat(change.isMergeable()).isFalse();
+  }
+
+  private void assertMerged(String changeId) throws Exception {
+    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  private PushOneCommit.Result createChange(
+      TestRepository<?> repo,
+      String subject,
+      String fileName,
+      String content,
+      List<RevCommit> parents,
+      String ref)
+      throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo, subject, fileName, content);
+
+    if (!parents.isEmpty()) {
+      push.setParents(parents);
+    }
+
+    PushOneCommit.Result result;
+    if (fileName.isEmpty()) {
+      result = push.execute(ref);
+    } else {
+      result = push.to(ref);
+    }
+    result.assertOkStatus();
+    return result;
+  }
+
+  private PushOneCommit.Result createChange(TestRepository<?> repo, String subject)
+      throws Exception {
+    return createChange(repo, subject, "x", "x", new ArrayList<RevCommit>(), "refs/for/master");
+  }
+
+  private PushOneCommit.Result createChange(
+      TestRepository<?> repo,
+      String subject,
+      String fileName,
+      String content,
+      List<RevCommit> parents)
+      throws Exception {
+    return createChange(repo, subject, fileName, content, parents, "refs/for/master");
+  }
+
+  @Override
+  protected PushOneCommit.Result createChange(String subject) throws Exception {
+    return createChange(
+        testRepo, subject, "", "", Collections.<RevCommit>emptyList(), "refs/for/master");
+  }
+
+  private PushOneCommit.Result createChange(String subject, List<RevCommit> parents)
+      throws Exception {
+    return createChange(testRepo, subject, "", "", parents, "refs/for/master");
+  }
+
+  private PushOneCommit.Result createChange(
+      String subject, String fileName, String content, List<RevCommit> parents) throws Exception {
+    return createChange(testRepo, subject, fileName, content, parents, "refs/for/master");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
new file mode 100644
index 0000000..c188d63
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -0,0 +1,535 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.restapi.group.CreateGroup;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SuggestReviewersIT extends AbstractDaemonTest {
+  @Inject private CreateGroup.Factory createGroupFactory;
+
+  private InternalGroup group1;
+  private InternalGroup group2;
+  private InternalGroup group3;
+
+  private TestAccount user1;
+  private TestAccount user2;
+  private TestAccount user3;
+  private TestAccount user4;
+
+  @Before
+  public void setUp() throws Exception {
+    group1 = newGroup("users1");
+    group2 = newGroup("users2");
+    group3 = newGroup("users3");
+
+    user1 = user("user1", "First1 Last1", group1);
+    user2 = user("user2", "First2 Last2", group2);
+    user3 = user("user3", "First3 Last3", group1, group2);
+    user4 = user("jdoe", "John Doe", "JDOE");
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void suggestReviewersNoResult1() throws Exception {
+    String changeId = createChange().getChangeId();
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+    assertThat(reviewers).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "suggest.from", value = "1")
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void suggestReviewersNoResult2() throws Exception {
+    String changeId = createChange().getChangeId();
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+    assertThat(reviewers).isEmpty();
+  }
+
+  @Test
+  public void suggestReviewersChange() throws Exception {
+    String changeId = createChange().getChangeId();
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+    assertReviewers(
+        reviewers, ImmutableList.of(user1, user2, user3), ImmutableList.of(group1, group2, group3));
+
+    reviewers = suggestReviewers(changeId, name("u"), 5);
+    assertReviewers(
+        reviewers, ImmutableList.of(user1, user2, user3), ImmutableList.of(group1, group2));
+
+    reviewers = suggestReviewers(changeId, group3.getName(), 10);
+    assertReviewers(reviewers, ImmutableList.of(), ImmutableList.of(group3));
+
+    // Suggested accounts are ordered by activity. All users have no activity,
+    // hence we don't know which of the matching accounts we get when the query
+    // is limited to 1.
+    reviewers = suggestReviewers(changeId, name("u"), 1);
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.get(0).account).isNotNull();
+    assertThat(ImmutableList.of(reviewers.get(0).account._accountId))
+        .containsAnyIn(
+            ImmutableList.of(user1, user2, user3).stream().map(u -> u.id.get()).collect(toList()));
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void suggestReviewersSameGroupVisibility() throws Exception {
+    String changeId = createChange().getChangeId();
+    List<SuggestedReviewerInfo> reviewers;
+
+    reviewers = suggestReviewers(changeId, user2.username, 2);
+    assertThat(reviewers).hasSize(1);
+    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
+
+    setApiUser(user1);
+    reviewers = suggestReviewers(changeId, user2.fullName, 2);
+    assertThat(reviewers).isEmpty();
+
+    setApiUser(user2);
+    reviewers = suggestReviewers(changeId, user2.username, 2);
+    assertThat(reviewers).hasSize(1);
+    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
+
+    setApiUser(user3);
+    reviewers = suggestReviewers(changeId, user2.username, 2);
+    assertThat(reviewers).hasSize(1);
+    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
+  }
+
+  @Test
+  public void suggestReviewsPrivateProjectVisibility() throws Exception {
+    String changeId = createChange().getChangeId();
+    List<SuggestedReviewerInfo> reviewers;
+
+    setApiUser(user3);
+    block("refs/*", "read", ANONYMOUS_USERS);
+    allow("refs/*", "read", group1.getGroupUUID());
+    reviewers = suggestReviewers(changeId, user2.username, 2);
+    assertThat(reviewers).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void suggestReviewersViewAllAccounts() throws Exception {
+    String changeId = createChange().getChangeId();
+    List<SuggestedReviewerInfo> reviewers;
+
+    setApiUser(user1);
+    reviewers = suggestReviewers(changeId, user2.username, 2);
+    assertThat(reviewers).isEmpty();
+
+    setApiUser(user1); // Clear cached group info.
+    allowGlobalCapabilities(group1.getGroupUUID(), GlobalCapability.VIEW_ALL_ACCOUNTS);
+    reviewers = suggestReviewers(changeId, user2.username, 2);
+    assertThat(reviewers).hasSize(1);
+    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
+  }
+
+  @Test
+  @GerritConfig(name = "suggest.maxSuggestedReviewers", value = "2")
+  public void suggestReviewersMaxNbrSuggestions() throws Exception {
+    String changeId = createChange().getChangeId();
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("user"), 5);
+    assertThat(reviewers).hasSize(2);
+  }
+
+  @Test
+  public void suggestReviewersFullTextSearch() throws Exception {
+    String changeId = createChange().getChangeId();
+    List<SuggestedReviewerInfo> reviewers;
+
+    reviewers = suggestReviewers(changeId, "first");
+    assertThat(reviewers).hasSize(3);
+
+    reviewers = suggestReviewers(changeId, "first1");
+    assertThat(reviewers).hasSize(1);
+
+    reviewers = suggestReviewers(changeId, "last");
+    assertThat(reviewers).hasSize(3);
+
+    reviewers = suggestReviewers(changeId, "last1");
+    assertThat(reviewers).hasSize(1);
+
+    reviewers = suggestReviewers(changeId, "fi la");
+    assertThat(reviewers).hasSize(3);
+
+    reviewers = suggestReviewers(changeId, "la fi");
+    assertThat(reviewers).hasSize(3);
+
+    reviewers = suggestReviewers(changeId, "first1 la");
+    assertThat(reviewers).hasSize(1);
+
+    reviewers = suggestReviewers(changeId, "fi last1");
+    assertThat(reviewers).hasSize(1);
+
+    reviewers = suggestReviewers(changeId, "first1 last2");
+    assertThat(reviewers).isEmpty();
+
+    reviewers = suggestReviewers(changeId, name("user"));
+    assertThat(reviewers).hasSize(6);
+
+    reviewers = suggestReviewers(changeId, user1.username);
+    assertThat(reviewers).hasSize(1);
+
+    reviewers = suggestReviewers(changeId, "example.com");
+    assertThat(reviewers).hasSize(5);
+
+    reviewers = suggestReviewers(changeId, user1.email);
+    assertThat(reviewers).hasSize(1);
+
+    reviewers = suggestReviewers(changeId, user1.username + " example");
+    assertThat(reviewers).hasSize(1);
+
+    reviewers = suggestReviewers(changeId, user4.email.toLowerCase());
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.get(0).account.email).isEqualTo(user4.email);
+  }
+
+  @Test
+  public void suggestReviewersWithoutLimitOptionSpecified() throws Exception {
+    String changeId = createChange().getChangeId();
+    String query = user3.username;
+    List<SuggestedReviewerInfo> suggestedReviewerInfos =
+        gApi.changes().id(changeId).suggestReviewers(query).get();
+    assertThat(suggestedReviewerInfos).hasSize(1);
+  }
+
+  @Test
+  @GerritConfig(name = "addreviewer.maxAllowed", value = "2")
+  @GerritConfig(name = "addreviewer.maxWithoutConfirmation", value = "1")
+  public void suggestReviewersGroupSizeConsiderations() throws Exception {
+    InternalGroup largeGroup = newGroup("large");
+    InternalGroup mediumGroup = newGroup("medium");
+
+    // Both groups have Administrator as a member. Add two users to large
+    // group to push it past maxAllowed, and one to medium group to push it
+    // past maxWithoutConfirmation.
+    user("individual 0", "Test0 Last0", largeGroup, mediumGroup);
+    user("individual 1", "Test1 Last1", largeGroup);
+
+    String changeId = createChange().getChangeId();
+    List<SuggestedReviewerInfo> reviewers;
+    SuggestedReviewerInfo reviewer;
+
+    // Individual account suggestions have count of 1 and no confirm.
+    reviewers = suggestReviewers(changeId, "test", 10);
+    assertThat(reviewers).hasSize(2);
+    reviewer = reviewers.get(0);
+    assertThat(reviewer.count).isEqualTo(1);
+    assertThat(reviewer.confirm).isNull();
+
+    // Large group should never be suggested.
+    reviewers = suggestReviewers(changeId, largeGroup.getName(), 10);
+    assertThat(reviewers).isEmpty();
+
+    // Medium group should be suggested with appropriate count and confirm.
+    reviewers = suggestReviewers(changeId, mediumGroup.getName(), 10);
+    assertThat(reviewers).hasSize(1);
+    reviewer = reviewers.get(0);
+    assertThat(reviewer.group.name).isEqualTo(mediumGroup.getName());
+    assertThat(reviewer.count).isEqualTo(2);
+    assertThat(reviewer.confirm).isTrue();
+  }
+
+  @Test
+  public void defaultReviewerSuggestion() throws Exception {
+    TestAccount user1 = user("customuser1", "User1");
+    TestAccount reviewer1 = user("customuser2", "User2");
+    TestAccount reviewer2 = user("customuser3", "User3");
+
+    setApiUser(user1);
+    String changeId1 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId1);
+
+    setApiUser(user1);
+    String changeId2 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId2);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId2);
+
+    setApiUser(user1);
+    String changeId3 = createChangeFromApi();
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId3, null, 4);
+    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
+        .containsExactly(reviewer1.id.get(), reviewer2.id.get())
+        .inOrder();
+
+    // check that existing reviewers are filtered out
+    gApi.changes().id(changeId3).addReviewer(reviewer1.email);
+    reviewers = suggestReviewers(changeId3, null, 4);
+    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
+        .containsExactly(reviewer2.id.get())
+        .inOrder();
+  }
+
+  @Test
+  public void defaultReviewerSuggestionOnFirstChange() throws Exception {
+    TestAccount user1 = user("customuser1", "User1");
+    setApiUser(user1);
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChange().getChangeId(), "", 4);
+    assertThat(reviewers).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "suggest.maxSuggestedReviewers", value = "10")
+  public void reviewerRanking() throws Exception {
+    // Assert that user are ranked by the number of times they have applied a
+    // a label to a change (highest), added comments (medium) or owned a
+    // change (low).
+    String fullName = "Primum Finalis";
+    TestAccount userWhoOwns = user("customuser1", fullName);
+    TestAccount reviewer1 = user("customuser2", fullName);
+    TestAccount reviewer2 = user("customuser3", fullName);
+    TestAccount userWhoComments = user("customuser4", fullName);
+    TestAccount userWhoLooksForSuggestions = user("customuser5", fullName);
+
+    // Create a change as userWhoOwns and add some reviews
+    setApiUser(userWhoOwns);
+    String changeId1 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId1);
+
+    setApiUser(user1);
+    String changeId2 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId2);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId2);
+
+    // Create a comment as a different user
+    setApiUser(userWhoComments);
+    ReviewInput ri = new ReviewInput();
+    ri.message = "Test";
+    gApi.changes().id(changeId1).revision(1).review(ri);
+
+    // Create a change as a new user to assert that we receive the correct
+    // ranking
+
+    setApiUser(userWhoLooksForSuggestions);
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "Pri", 4);
+    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
+        .containsExactly(
+            reviewer1.id.get(), reviewer2.id.get(), userWhoOwns.id.get(), userWhoComments.id.get())
+        .inOrder();
+  }
+
+  @Test
+  public void reviewerRankingProjectIsolation() throws Exception {
+    // Create new project
+    Project.NameKey newProject = createProject("test");
+
+    // Create users who review changes in both the default and the new project
+    String fullName = "Primum Finalis";
+    TestAccount userWhoOwns = user("customuser1", fullName);
+    TestAccount reviewer1 = user("customuser2", fullName);
+    TestAccount reviewer2 = user("customuser3", fullName);
+
+    setApiUser(userWhoOwns);
+    String changeId1 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId1);
+
+    setApiUser(userWhoOwns);
+    String changeId2 = createChangeFromApi(newProject);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId2);
+
+    setApiUser(userWhoOwns);
+    String changeId3 = createChangeFromApi(newProject);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId3);
+
+    setApiUser(userWhoOwns);
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "Prim", 4);
+
+    // Assert that reviewer1 is on top, even though reviewer2 has more reviews
+    // in other projects
+    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
+        .containsExactly(reviewer1.id.get(), reviewer2.id.get())
+        .inOrder();
+  }
+
+  @Test
+  public void suggestNoInactiveAccounts() throws Exception {
+    String name = name("foo");
+    TestAccount foo1 = accountCreator.create(name + "-1");
+    assertThat(gApi.accounts().id(foo1.username).getActive()).isTrue();
+
+    TestAccount foo2 = accountCreator.create(name + "-2");
+    assertThat(gApi.accounts().id(foo2.username).getActive()).isTrue();
+
+    String changeId = createChange().getChangeId();
+    assertReviewers(
+        suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
+
+    gApi.accounts().id(foo2.username).setActive(false);
+    assertThat(gApi.accounts().id(foo2.username).getActive()).isFalse();
+    assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(foo1), ImmutableList.of());
+  }
+
+  @Test
+  public void suggestBySecondaryEmailWithModifyAccount() throws Exception {
+    String secondaryEmail = "foo.secondary@example.com";
+    TestAccount foo = createAccountWithSecondaryEmail("foo", secondaryEmail);
+
+    List<SuggestedReviewerInfo> reviewers =
+        suggestReviewers(createChange().getChangeId(), secondaryEmail, 4);
+    assertReviewers(reviewers, ImmutableList.of(foo), ImmutableList.of());
+
+    reviewers = suggestReviewers(createChange().getChangeId(), "secondary", 4);
+    assertReviewers(reviewers, ImmutableList.of(foo), ImmutableList.of());
+  }
+
+  @Test
+  public void cannotSuggestBySecondaryEmailWithoutModifyAccount() throws Exception {
+    String secondaryEmail = "foo.secondary@example.com";
+    createAccountWithSecondaryEmail("foo", secondaryEmail);
+
+    setApiUser(user);
+    List<SuggestedReviewerInfo> reviewers =
+        suggestReviewers(createChange().getChangeId(), secondaryEmail, 4);
+    assertThat(reviewers).isEmpty();
+
+    reviewers = suggestReviewers(createChange().getChangeId(), "secondary2", 4);
+    assertThat(reviewers).isEmpty();
+  }
+
+  @Test
+  public void secondaryEmailsInSuggestions() throws Exception {
+    String secondaryEmail = "foo.secondary@example.com";
+    TestAccount foo = createAccountWithSecondaryEmail("foo", secondaryEmail);
+
+    List<SuggestedReviewerInfo> reviewers =
+        suggestReviewers(createChange().getChangeId(), "foo", 4);
+    assertReviewers(reviewers, ImmutableList.of(foo), ImmutableList.of());
+    assertThat(Iterables.getOnlyElement(reviewers).account.secondaryEmails)
+        .containsExactly(secondaryEmail);
+
+    setApiUser(user);
+    reviewers = suggestReviewers(createChange().getChangeId(), "foo", 4);
+    assertReviewers(reviewers, ImmutableList.of(foo), ImmutableList.of());
+    assertThat(Iterables.getOnlyElement(reviewers).account.secondaryEmails).isNull();
+  }
+
+  private TestAccount createAccountWithSecondaryEmail(String name, String secondaryEmail)
+      throws Exception {
+    TestAccount foo = accountCreator.create(name(name), "foo.primary@example.com", "Foo");
+    EmailInput input = new EmailInput();
+    input.email = secondaryEmail;
+    input.noConfirmation = true;
+    gApi.accounts().id(foo.id.get()).addEmail(input);
+    return foo;
+  }
+
+  private List<SuggestedReviewerInfo> suggestReviewers(String changeId, String query)
+      throws Exception {
+    return gApi.changes().id(changeId).suggestReviewers(query).get();
+  }
+
+  private List<SuggestedReviewerInfo> suggestReviewers(String changeId, String query, int n)
+      throws Exception {
+    return gApi.changes().id(changeId).suggestReviewers(query).withLimit(n).get();
+  }
+
+  private InternalGroup newGroup(String name) throws Exception {
+    GroupInfo group = createGroupFactory.create(name(name)).apply(TopLevelResource.INSTANCE, null);
+    return group(new AccountGroup.UUID(group.id));
+  }
+
+  private TestAccount user(String name, String fullName, String emailName, InternalGroup... groups)
+      throws Exception {
+    String[] groupNames = Arrays.stream(groups).map(InternalGroup::getName).toArray(String[]::new);
+    return accountCreator.create(
+        name(name), name(emailName) + "@example.com", fullName, groupNames);
+  }
+
+  private TestAccount user(String name, String fullName, InternalGroup... groups) throws Exception {
+    return user(name, fullName, name, groups);
+  }
+
+  private void reviewChange(String changeId) throws RestApiException {
+    ReviewInput ri = new ReviewInput();
+    ri.label("Code-Review", 1);
+    gApi.changes().id(changeId).current().review(ri);
+  }
+
+  private String createChangeFromApi() throws RestApiException {
+    return createChangeFromApi(project);
+  }
+
+  private String createChangeFromApi(Project.NameKey project) throws RestApiException {
+    ChangeInput ci = new ChangeInput();
+    ci.project = project.get();
+    ci.subject = "Test change at" + System.nanoTime();
+    ci.branch = "master";
+    return gApi.changes().create(ci).get().changeId;
+  }
+
+  private void assertReviewers(
+      List<SuggestedReviewerInfo> actual,
+      List<TestAccount> expectedUsers,
+      List<InternalGroup> expectedGroups) {
+    List<Integer> actualAccountIds =
+        actual
+            .stream()
+            .filter(i -> i.account != null)
+            .map(i -> i.account._accountId)
+            .collect(toList());
+    assertThat(actualAccountIds)
+        .containsExactlyElementsIn(expectedUsers.stream().map(u -> u.id.get()).collect(toList()));
+
+    List<String> actualGroupIds =
+        actual.stream().filter(i -> i.group != null).map(i -> i.group.id).collect(toList());
+    assertThat(actualGroupIds)
+        .containsExactlyElementsIn(
+            expectedGroups.stream().map(g -> g.getGroupUUID().get()).collect(toList()))
+        .inOrder();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/TopicIT.java b/javatests/com/google/gerrit/acceptance/rest/change/TopicIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/TopicIT.java
rename to javatests/com/google/gerrit/acceptance/rest/change/TopicIT.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/BUILD b/javatests/com/google/gerrit/acceptance/rest/config/BUILD
new file mode 100644
index 0000000..8550423
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/config/BUILD
@@ -0,0 +1,10 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "rest_config",
+    labels = ["rest"],
+    deps = [
+        "//java/com/google/gerrit/server/restapi",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
new file mode 100644
index 0000000..65ed7e4
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
@@ -0,0 +1,134 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.restapi.config.PostCaches.Operation.FLUSH;
+import static com.google.gerrit.server.restapi.config.PostCaches.Operation.FLUSH_ALL;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
+import com.google.gerrit.server.restapi.config.PostCaches;
+import java.util.Arrays;
+import org.junit.Test;
+
+public class CacheOperationsIT extends AbstractDaemonTest {
+
+  @Test
+  public void flushAll() throws Exception {
+    RestResponse r = adminRestSession.getOK("/config/server/caches/project_list");
+    CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
+
+    r = adminRestSession.postOK("/config/server/caches/", new PostCaches.Input(FLUSH_ALL));
+    r.consume();
+
+    r = adminRestSession.getOK("/config/server/caches/project_list");
+    cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertThat(cacheInfo.entries.mem).isNull();
+  }
+
+  @Test
+  public void flushAll_Forbidden() throws Exception {
+    userRestSession
+        .post("/config/server/caches/", new PostCaches.Input(FLUSH_ALL))
+        .assertForbidden();
+  }
+
+  @Test
+  public void flushAll_BadRequest() throws Exception {
+    adminRestSession
+        .post("/config/server/caches/", new PostCaches.Input(FLUSH_ALL, Arrays.asList("projects")))
+        .assertBadRequest();
+  }
+
+  @Test
+  public void flush() throws Exception {
+    RestResponse r = adminRestSession.getOK("/config/server/caches/project_list");
+    CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
+
+    r = adminRestSession.getOK("/config/server/caches/projects");
+    cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 1);
+
+    r =
+        adminRestSession.postOK(
+            "/config/server/caches/",
+            new PostCaches.Input(FLUSH, Arrays.asList("accounts", "project_list")));
+    r.consume();
+
+    r = adminRestSession.getOK("/config/server/caches/project_list");
+    cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertThat(cacheInfo.entries.mem).isNull();
+
+    r = adminRestSession.getOK("/config/server/caches/projects");
+    cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 1);
+  }
+
+  @Test
+  public void flush_Forbidden() throws Exception {
+    userRestSession
+        .post("/config/server/caches/", new PostCaches.Input(FLUSH, Arrays.asList("projects")))
+        .assertForbidden();
+  }
+
+  @Test
+  public void flush_BadRequest() throws Exception {
+    adminRestSession.post("/config/server/caches/", new PostCaches.Input(FLUSH)).assertBadRequest();
+  }
+
+  @Test
+  public void flush_UnprocessableEntity() throws Exception {
+    RestResponse r = adminRestSession.getOK("/config/server/caches/projects");
+    CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
+
+    r =
+        adminRestSession.post(
+            "/config/server/caches/",
+            new PostCaches.Input(FLUSH, Arrays.asList("projects", "unprocessable")));
+    r.assertUnprocessableEntity();
+    r.consume();
+
+    r = adminRestSession.getOK("/config/server/caches/projects");
+    cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
+  }
+
+  @Test
+  public void flushWebSessions_Forbidden() throws Exception {
+    allowGlobalCapabilities(
+        REGISTERED_USERS, GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
+    try {
+      RestResponse r =
+          userRestSession.postOK(
+              "/config/server/caches/", new PostCaches.Input(FLUSH, Arrays.asList("projects")));
+      r.consume();
+
+      userRestSession
+          .post(
+              "/config/server/caches/", new PostCaches.Input(FLUSH, Arrays.asList("web_sessions")))
+          .assertForbidden();
+    } finally {
+      removeGlobalCapabilities(
+          REGISTERED_USERS, GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
new file mode 100644
index 0000000..7133580
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.config;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.server.mail.EmailTokenVerifier;
+import com.google.gerrit.server.restapi.config.ConfirmEmail;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gwtjsonrpc.server.SignedToken;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class ConfirmEmailIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setString("auth", null, "registerEmailPrivateKey", SignedToken.generateRandomKey());
+    return cfg;
+  }
+
+  @Inject private EmailTokenVerifier emailTokenVerifier;
+
+  @Test
+  public void confirm() throws Exception {
+    ConfirmEmail.Input in = new ConfirmEmail.Input();
+    in.token = emailTokenVerifier.encode(admin.getId(), "new.mail@example.com");
+    adminRestSession.put("/config/server/email.confirm", in).assertNoContent();
+  }
+
+  @Test
+  public void confirmForOtherUser_UnprocessableEntity() throws Exception {
+    ConfirmEmail.Input in = new ConfirmEmail.Input();
+    in.token = emailTokenVerifier.encode(user.getId(), "new.mail@example.com");
+    adminRestSession.put("/config/server/email.confirm", in).assertUnprocessableEntity();
+  }
+
+  @Test
+  public void confirmInvalidToken_UnprocessableEntity() throws Exception {
+    ConfirmEmail.Input in = new ConfirmEmail.Input();
+    in.token = "invalidToken";
+    adminRestSession.put("/config/server/email.confirm", in).assertUnprocessableEntity();
+  }
+
+  @Test
+  public void confirmAlreadyInUse_UnprocessableEntity() throws Exception {
+    ConfirmEmail.Input in = new ConfirmEmail.Input();
+    in.token = emailTokenVerifier.encode(admin.getId(), user.email);
+    adminRestSession.put("/config/server/email.confirm", in).assertUnprocessableEntity();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
new file mode 100644
index 0000000..caecefa
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
+import org.junit.Test;
+
+public class FlushCacheIT extends AbstractDaemonTest {
+
+  @Test
+  public void flushCache() throws Exception {
+    // access the admin group once so that it is loaded into the group cache
+    adminGroup();
+
+    RestResponse r = adminRestSession.get("/config/server/caches/groups_byname");
+    CacheInfo result = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertThat(result.entries.mem).isGreaterThan((long) 0);
+
+    r = adminRestSession.post("/config/server/caches/groups_byname/flush");
+    r.assertOK();
+    r.consume();
+
+    r = adminRestSession.get("/config/server/caches/groups_byname");
+    result = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertThat(result.entries.mem).isNull();
+  }
+
+  @Test
+  public void flushCache_Forbidden() throws Exception {
+    userRestSession.post("/config/server/caches/accounts/flush").assertForbidden();
+  }
+
+  @Test
+  public void flushCache_NotFound() throws Exception {
+    adminRestSession.post("/config/server/caches/nonExisting/flush").assertNotFound();
+  }
+
+  @Test
+  public void flushCacheWithGerritPrefix() throws Exception {
+    adminRestSession.post("/config/server/caches/gerrit-accounts/flush").assertOK();
+  }
+
+  @Test
+  public void flushWebSessionsCache() throws Exception {
+    adminRestSession.post("/config/server/caches/web_sessions/flush").assertOK();
+  }
+
+  @Test
+  public void flushWebSessionsCache_Forbidden() throws Exception {
+    allowGlobalCapabilities(
+        REGISTERED_USERS, GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
+    try {
+      RestResponse r = userRestSession.post("/config/server/caches/accounts/flush");
+      r.assertOK();
+      r.consume();
+
+      userRestSession.post("/config/server/caches/web_sessions/flush").assertForbidden();
+    } finally {
+      removeGlobalCapabilities(
+          REGISTERED_USERS, GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/GetCacheIT.java b/javatests/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
new file mode 100644
index 0000000..247d63b
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheType;
+import org.junit.Test;
+
+public class GetCacheIT extends AbstractDaemonTest {
+
+  @Test
+  public void getCache() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/caches/accounts");
+    r.assertOK();
+    CacheInfo result = newGson().fromJson(r.getReader(), CacheInfo.class);
+
+    assertThat(result.name).isEqualTo("accounts");
+    assertThat(result.type).isEqualTo(CacheType.MEM);
+    assertThat(result.entries.mem).isAtLeast(1L);
+    assertThat(result.averageGet).isNotNull();
+    assertThat(result.averageGet).endsWith("s");
+    assertThat(result.entries.disk).isNull();
+    assertThat(result.entries.space).isNull();
+    assertThat(result.hitRatio.mem).isAtLeast(0);
+    assertThat(result.hitRatio.mem).isAtMost(100);
+    assertThat(result.hitRatio.disk).isNull();
+
+    userRestSession.get("/config/server/version").consume();
+    r = adminRestSession.get("/config/server/caches/accounts");
+    r.assertOK();
+    result = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertThat(result.entries.mem).isEqualTo(2);
+  }
+
+  @Test
+  public void getCache_Forbidden() throws Exception {
+    userRestSession.get("/config/server/caches/accounts").assertForbidden();
+  }
+
+  @Test
+  public void getCache_NotFound() throws Exception {
+    adminRestSession.get("/config/server/caches/nonExisting").assertNotFound();
+  }
+
+  @Test
+  public void getCacheWithGerritPrefix() throws Exception {
+    adminRestSession.get("/config/server/caches/gerrit-accounts").assertOK();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
new file mode 100644
index 0000000..6d2c6dfa
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
+import com.google.gson.reflect.TypeToken;
+import java.util.List;
+import org.junit.Test;
+
+public class GetTaskIT extends AbstractDaemonTest {
+
+  @Test
+  public void getTask() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/tasks/" + getLogFileCompressorTaskId());
+    r.assertOK();
+    TaskInfo info = newGson().fromJson(r.getReader(), new TypeToken<TaskInfo>() {}.getType());
+    assertThat(info.id).isNotNull();
+    Long.parseLong(info.id, 16);
+    assertThat(info.command).isEqualTo("Log File Compressor");
+    assertThat(info.startTime).isNotNull();
+  }
+
+  @Test
+  public void getTask_NotFound() throws Exception {
+    userRestSession.get("/config/server/tasks/" + getLogFileCompressorTaskId()).assertNotFound();
+  }
+
+  private String getLogFileCompressorTaskId() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/tasks/");
+    List<TaskInfo> result =
+        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
+    r.consume();
+    for (TaskInfo info : result) {
+      if ("Log File Compressor".equals(info.command)) {
+        return info.id;
+      }
+    }
+    return null;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java b/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
new file mode 100644
index 0000000..c19f5d0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
+import com.google.gson.reflect.TypeToken;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.junit.Test;
+
+public class KillTaskIT extends AbstractDaemonTest {
+
+  private void killTask() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/tasks/");
+    List<TaskInfo> result =
+        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
+    r.consume();
+
+    Optional<String> id =
+        result
+            .stream()
+            .filter(t -> "Log File Compressor".equals(t.command))
+            .map(t -> t.id)
+            .findFirst();
+    assertThat(id).isPresent();
+
+    r = adminRestSession.delete("/config/server/tasks/" + id.get());
+    r.assertNoContent();
+    r.consume();
+
+    r = adminRestSession.get("/config/server/tasks/");
+    result = newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
+    r.consume();
+    Set<String> ids = result.stream().map(t -> t.id).collect(toSet());
+    assertThat(ids).doesNotContain(id.get());
+  }
+
+  private void killTask_NotFound() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/tasks/");
+    List<TaskInfo> result =
+        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
+    r.consume();
+    assertThat(result.size()).isGreaterThan(0);
+
+    userRestSession.delete("/config/server/tasks/" + result.get(0).id).assertNotFound();
+  }
+
+  @Test
+  public void killTaskTests_inOrder() throws Exception {
+    // As killTask() changes the state of the server, we want to test it last
+    killTask_NotFound();
+    killTask();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
new file mode 100644
index 0000000..ae17be0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.Ordering;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheType;
+import com.google.gson.reflect.TypeToken;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.util.Base64;
+import org.junit.Test;
+
+public class ListCachesIT extends AbstractDaemonTest {
+
+  @Test
+  public void listCaches() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/caches/");
+    r.assertOK();
+    Map<String, CacheInfo> result =
+        newGson().fromJson(r.getReader(), new TypeToken<Map<String, CacheInfo>>() {}.getType());
+
+    assertThat(result).containsKey("accounts");
+    CacheInfo accountsCacheInfo = result.get("accounts");
+    assertThat(accountsCacheInfo.type).isEqualTo(CacheType.MEM);
+    assertThat(accountsCacheInfo.entries.mem).isAtLeast(1L);
+    assertThat(accountsCacheInfo.averageGet).isNotNull();
+    assertThat(accountsCacheInfo.averageGet).endsWith("s");
+    assertThat(accountsCacheInfo.entries.disk).isNull();
+    assertThat(accountsCacheInfo.entries.space).isNull();
+    assertThat(accountsCacheInfo.hitRatio.mem).isAtLeast(0);
+    assertThat(accountsCacheInfo.hitRatio.mem).isAtMost(100);
+    assertThat(accountsCacheInfo.hitRatio.disk).isNull();
+
+    userRestSession.get("/config/server/version").consume();
+    r = adminRestSession.get("/config/server/caches/");
+    r.assertOK();
+    result =
+        newGson().fromJson(r.getReader(), new TypeToken<Map<String, CacheInfo>>() {}.getType());
+    assertThat(result.get("accounts").entries.mem).isEqualTo(2);
+  }
+
+  @Test
+  public void listCaches_Forbidden() throws Exception {
+    userRestSession.get("/config/server/caches/").assertForbidden();
+  }
+
+  @Test
+  public void listCacheNames() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/caches/?format=LIST");
+    r.assertOK();
+    List<String> result =
+        newGson().fromJson(r.getReader(), new TypeToken<List<String>>() {}.getType());
+    assertThat(result).contains("accounts");
+    assertThat(result).contains("projects");
+    assertThat(Ordering.natural().isOrdered(result)).isTrue();
+  }
+
+  @Test
+  public void listCacheNamesTextList() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/caches/?format=TEXT_LIST");
+    r.assertOK();
+    String result = new String(Base64.decode(r.getEntityContent()), UTF_8.name());
+    List<String> list = Arrays.asList(result.split("\n"));
+    assertThat(list).contains("accounts");
+    assertThat(list).contains("projects");
+    assertThat(Ordering.natural().isOrdered(list)).isTrue();
+  }
+
+  @Test
+  public void listCaches_BadRequest() throws Exception {
+    adminRestSession.get("/config/server/caches/?format=NONSENSE").assertBadRequest();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
new file mode 100644
index 0000000..674ca79
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
+import com.google.gson.reflect.TypeToken;
+import java.util.List;
+import org.junit.Test;
+
+public class ListTasksIT extends AbstractDaemonTest {
+
+  @Test
+  public void listTasks() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/tasks/");
+    r.assertOK();
+    List<TaskInfo> result =
+        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
+    assertThat(result).isNotEmpty();
+    boolean foundLogFileCompressorTask = false;
+    for (TaskInfo info : result) {
+      if ("Log File Compressor".equals(info.command)) {
+        foundLogFileCompressorTask = true;
+      }
+      assertThat(info.id).isNotNull();
+      Long.parseLong(info.id, 16);
+      assertThat(info.command).isNotNull();
+      assertThat(info.startTime).isNotNull();
+    }
+    assertThat(foundLogFileCompressorTask).isTrue();
+  }
+
+  @Test
+  public void listTasksWithoutViewQueueCapability() throws Exception {
+    RestResponse r = userRestSession.get("/config/server/tasks/");
+    r.assertOK();
+    List<TaskInfo> result =
+        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
+
+    assertThat(result).isEmpty();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
new file mode 100644
index 0000000..8fc5312
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -0,0 +1,216 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.common.AccountVisibility;
+import com.google.gerrit.extensions.common.InstallPluginInput;
+import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class ServerInfoIT extends AbstractDaemonTest {
+  private static final byte[] JS_PLUGIN_CONTENT =
+      "Gerrit.install(function(self){});\n".getBytes(UTF_8);
+
+  @Test
+  // accounts
+  @GerritConfig(name = "accounts.visibility", value = "VISIBLE_GROUP")
+
+  // auth
+  @GerritConfig(name = "auth.type", value = "HTTP")
+  @GerritConfig(name = "auth.contributorAgreements", value = "true")
+  @GerritConfig(name = "auth.loginUrl", value = "https://example.com/login")
+  @GerritConfig(name = "auth.loginText", value = "LOGIN")
+  @GerritConfig(name = "auth.switchAccountUrl", value = "https://example.com/switch")
+
+  // auth fields ignored when auth == HTTP
+  @GerritConfig(name = "auth.registerUrl", value = "https://example.com/register")
+  @GerritConfig(name = "auth.registerText", value = "REGISTER")
+  @GerritConfig(name = "auth.editFullNameUrl", value = "https://example.com/editname")
+  @GerritConfig(name = "auth.httpPasswordUrl", value = "https://example.com/password")
+
+  // change
+  @GerritConfig(name = "change.allowDrafts", value = "false")
+  @GerritConfig(name = "change.largeChange", value = "300")
+  @GerritConfig(name = "change.replyTooltip", value = "Publish votes and draft comments")
+  @GerritConfig(name = "change.replyLabel", value = "Vote")
+  @GerritConfig(name = "change.updateDelay", value = "50s")
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+
+  // download
+  @GerritConfig(
+    name = "download.archive",
+    values = {"tar", "tbz2", "tgz", "txz"}
+  )
+
+  // gerrit
+  @GerritConfig(name = "gerrit.allProjects", value = "Root")
+  @GerritConfig(name = "gerrit.allUsers", value = "Users")
+  @GerritConfig(name = "gerrit.enableGwtUi", value = "true")
+  @GerritConfig(name = "gerrit.reportBugText", value = "REPORT BUG")
+  @GerritConfig(name = "gerrit.reportBugUrl", value = "https://example.com/report")
+
+  // suggest
+  @GerritConfig(name = "suggest.from", value = "3")
+
+  // user
+  @GerritConfig(name = "user.anonymousCoward", value = "Unnamed User")
+  public void serverConfig() throws Exception {
+    ServerInfo i = gApi.config().server().getInfo();
+
+    // accounts
+    assertThat(i.accounts.visibility).isEqualTo(AccountVisibility.VISIBLE_GROUP);
+
+    // auth
+    assertThat(i.auth.authType).isEqualTo(AuthType.HTTP);
+    assertThat(i.auth.editableAccountFields)
+        .containsExactly(AccountFieldName.REGISTER_NEW_EMAIL, AccountFieldName.FULL_NAME);
+    assertThat(i.auth.useContributorAgreements).isTrue();
+    assertThat(i.auth.loginUrl).isEqualTo("https://example.com/login");
+    assertThat(i.auth.loginText).isEqualTo("LOGIN");
+    assertThat(i.auth.switchAccountUrl).isEqualTo("https://example.com/switch");
+    assertThat(i.auth.registerUrl).isNull();
+    assertThat(i.auth.registerText).isNull();
+    assertThat(i.auth.editFullNameUrl).isNull();
+    assertThat(i.auth.httpPasswordUrl).isNull();
+
+    // change
+    assertThat(i.change.allowDrafts).isNull();
+    assertThat(i.change.largeChange).isEqualTo(300);
+    assertThat(i.change.replyTooltip).startsWith("Publish votes and draft comments");
+    assertThat(i.change.replyLabel).isEqualTo("Vote\u2026");
+    assertThat(i.change.updateDelay).isEqualTo(50);
+    assertThat(i.change.disablePrivateChanges).isTrue();
+
+    // download
+    assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
+    assertThat(i.download.schemes).isEmpty();
+
+    // gerrit
+    assertThat(i.gerrit.allProjects).isEqualTo("Root");
+    assertThat(i.gerrit.allUsers).isEqualTo("Users");
+    assertThat(i.gerrit.reportBugUrl).isEqualTo("https://example.com/report");
+    assertThat(i.gerrit.reportBugText).isEqualTo("REPORT BUG");
+
+    // Acceptance tests force --headless even when UIs are specified in config.
+    assertThat(i.gerrit.webUis).isEmpty();
+
+    // plugin
+    assertThat(i.plugin.jsResourcePaths).isEmpty();
+
+    // sshd
+    assertThat(i.sshd).isNotNull();
+
+    // suggest
+    assertThat(i.suggest.from).isEqualTo(3);
+
+    // user
+    assertThat(i.user.anonymousCowardName).isEqualTo("Unnamed User");
+
+    // notedb
+    notesMigration.setReadChanges(true);
+    assertThat(gApi.config().server().getInfo().noteDbEnabled).isTrue();
+    notesMigration.setReadChanges(false);
+    assertThat(gApi.config().server().getInfo().noteDbEnabled).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
+  public void serverConfigWithPlugin() throws Exception {
+    ServerInfo i = gApi.config().server().getInfo();
+    assertThat(i.plugin.jsResourcePaths).isEmpty();
+
+    InstallPluginInput input = new InstallPluginInput();
+    input.raw = RawInputUtil.create(JS_PLUGIN_CONTENT);
+    gApi.plugins().install("js-plugin-1.js", input);
+
+    i = gApi.config().server().getInfo();
+    assertThat(i.plugin.jsResourcePaths).hasSize(1);
+  }
+
+  @Test
+  public void serverConfigWithDefaults() throws Exception {
+    ServerInfo i = gApi.config().server().getInfo();
+
+    // auth
+    assertThat(i.auth.authType).isEqualTo(AuthType.OPENID);
+    assertThat(i.auth.editableAccountFields)
+        .containsExactly(
+            AccountFieldName.REGISTER_NEW_EMAIL,
+            AccountFieldName.FULL_NAME,
+            AccountFieldName.USER_NAME);
+    assertThat(i.auth.useContributorAgreements).isNull();
+    assertThat(i.auth.loginUrl).isNull();
+    assertThat(i.auth.loginText).isNull();
+    assertThat(i.auth.switchAccountUrl).isNull();
+    assertThat(i.auth.registerUrl).isNull();
+    assertThat(i.auth.registerText).isNull();
+    assertThat(i.auth.editFullNameUrl).isNull();
+    assertThat(i.auth.httpPasswordUrl).isNull();
+
+    // change
+    assertThat(i.change.allowDrafts).isTrue();
+    assertThat(i.change.largeChange).isEqualTo(500);
+    assertThat(i.change.replyTooltip).startsWith("Reply and score");
+    assertThat(i.change.replyLabel).isEqualTo("Reply\u2026");
+    assertThat(i.change.updateDelay).isEqualTo(300);
+    assertThat(i.change.disablePrivateChanges).isNull();
+
+    // download
+    assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
+    assertThat(i.download.schemes).isEmpty();
+
+    // gerrit
+    assertThat(i.gerrit.allProjects).isEqualTo(AllProjectsNameProvider.DEFAULT);
+    assertThat(i.gerrit.allUsers).isEqualTo(AllUsersNameProvider.DEFAULT);
+    assertThat(i.gerrit.reportBugUrl).isNull();
+    assertThat(i.gerrit.reportBugText).isNull();
+
+    // plugin
+    assertThat(i.plugin.jsResourcePaths).isEmpty();
+
+    // sshd
+    assertThat(i.sshd).isNotNull();
+
+    // suggest
+    assertThat(i.suggest.from).isEqualTo(0);
+
+    // user
+    assertThat(i.user.anonymousCowardName).isEqualTo(AnonymousCowardNameProvider.DEFAULT);
+  }
+
+  @Test
+  @GerritConfig(name = "auth.contributorAgreements", value = "true")
+  public void anonymousAccess() throws Exception {
+    configureContributorAgreement(true);
+
+    setApiUserAnonymous();
+    gApi.config().server().getInfo();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/AddMemberIT.java b/javatests/com/google/gerrit/acceptance/rest/group/AddMemberIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/AddMemberIT.java
rename to javatests/com/google/gerrit/acceptance/rest/group/AddMemberIT.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/BUILD b/javatests/com/google/gerrit/acceptance/rest/group/BUILD
new file mode 100644
index 0000000..8925368
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/group/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "rest_group",
+    labels = ["rest"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/GroupsIT.java
new file mode 100644
index 0000000..a52dd6d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/group/GroupsIT.java
@@ -0,0 +1,180 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License
+
+package com.google.gerrit.acceptance.rest.group;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.group.db.GroupBundle;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gerrit.server.restapi.group.Rebuild;
+import com.google.inject.Inject;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class GroupsIT extends AbstractDaemonTest {
+  @Inject private GroupsMigration groupsMigration;
+  @Inject private GroupBundle.Factory bundleFactory;
+
+  @Test
+  public void invalidQueryOptions() throws Exception {
+    RestResponse r = adminRestSession.put("/groups/?query=foo&query2=bar");
+    r.assertBadRequest();
+    assertThat(r.getEntityContent())
+        .isEqualTo("\"query\" and \"query2\" options are mutually exclusive");
+  }
+
+  @Test
+  public void rebuild() throws Exception {
+    assume().that(groupsMigration.writeToNoteDb()).isTrue();
+    assume().that(groupsMigration.readFromNoteDb()).isFalse();
+
+    GroupInfo g = gApi.groups().create(name("group")).get();
+    AccountGroup.UUID uuid = new AccountGroup.UUID(g.id);
+    String refName = RefNames.refsGroups(uuid);
+    ObjectId oldId;
+    GroupBundle oldBundle;
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      oldId = repo.exactRef(refName).getObjectId();
+      oldBundle = bundleFactory.fromNoteDb(repo, uuid);
+      new TestRepository<>(repo).delete(refName);
+    }
+
+    assertThat(
+            adminRestSession.postOK("/groups/" + uuid + "/rebuild", input(null)).getEntityContent())
+        .isEqualTo("No differences between ReviewDb and NoteDb");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      Ref ref = repo.exactRef(refName);
+      assertThat(ref).isNotNull();
+
+      // An artifact of the migration process makes the SHA-1 different, but it's actually ok
+      // because the bundles are equal.
+      assertThat(ref.getObjectId()).isNotEqualTo(oldId);
+
+      assertNoDifferences(oldBundle, bundleFactory.fromNoteDb(repo, uuid));
+    }
+  }
+
+  @Test
+  public void rebuildFailsWithWritesDisabled() throws Exception {
+    assume().that(groupsMigration.writeToNoteDb()).isFalse();
+
+    GroupInfo g = gApi.groups().create(name("group")).get();
+    AccountGroup.UUID uuid = new AccountGroup.UUID(g.id);
+
+    RestResponse res = adminRestSession.post("/groups/" + uuid + "/rebuild", input(null));
+    assertThat(res.getStatusCode()).isEqualTo(405);
+    assertThat(res.getEntityContent()).isEqualTo("NoteDb writes must be enabled");
+  }
+
+  @Test
+  public void rebuildForceRequiresReadsDisabled() throws Exception {
+    assume().that(groupsMigration.writeToNoteDb()).isTrue();
+    assume().that(groupsMigration.readFromNoteDb()).isTrue();
+
+    GroupInfo g = gApi.groups().create(name("group")).get();
+    AccountGroup.UUID uuid = new AccountGroup.UUID(g.id);
+
+    RestResponse res = adminRestSession.post("/groups/" + uuid + "/rebuild", input(true));
+    assertThat(res.getStatusCode()).isEqualTo(405);
+    assertThat(res.getEntityContent())
+        .isEqualTo("NoteDb reads must not be enabled when force=true");
+  }
+
+  @Test
+  public void rebuildWithoutForceFailsIfRefExists() throws Exception {
+    assume().that(groupsMigration.writeToNoteDb()).isTrue();
+    assume().that(groupsMigration.readFromNoteDb()).isFalse();
+
+    GroupInfo g = gApi.groups().create(name("group")).get();
+    AccountGroup.UUID uuid = new AccountGroup.UUID(g.id);
+    String refName = RefNames.refsGroups(uuid);
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      new TestRepository<>(repo)
+          .branch(refName)
+          .commit()
+          .add("somefile", "contents")
+          .create()
+          .copy();
+    }
+
+    RestResponse res = adminRestSession.post("/groups/" + uuid + "/rebuild", input(null));
+    assertThat(res.getStatusCode()).isEqualTo(409);
+    assertThat(res.getEntityContent()).isEqualTo("Group already exists in NoteDb");
+  }
+
+  @Test
+  public void rebuildForce() throws Exception {
+    assume().that(groupsMigration.writeToNoteDb()).isTrue();
+    assume().that(groupsMigration.readFromNoteDb()).isFalse();
+
+    GroupInfo g = gApi.groups().create(name("group")).get();
+    AccountGroup.UUID uuid = new AccountGroup.UUID(g.id);
+    String refName = RefNames.refsGroups(uuid);
+
+    ObjectId oldId;
+    GroupBundle oldBundle;
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      oldBundle = bundleFactory.fromNoteDb(repo, uuid);
+
+      oldId =
+          new TestRepository<>(repo)
+              .branch(refName)
+              .commit()
+              .add("somefile", "contents")
+              .create()
+              .copy();
+    }
+
+    assertThat(
+            adminRestSession.postOK("/groups/" + uuid + "/rebuild", input(true)).getEntityContent())
+        .isEqualTo("No differences between ReviewDb and NoteDb");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      Ref ref = repo.exactRef(refName);
+      assertThat(ref).isNotNull();
+
+      // oldId contains some garbage, so rebuilt value should definitely be different.
+      assertThat(ref.getObjectId()).isNotEqualTo(oldId);
+
+      assertNoDifferences(oldBundle, bundleFactory.fromNoteDb(repo, uuid));
+    }
+  }
+
+  private static void assertNoDifferences(GroupBundle expected, GroupBundle actual) {
+    // Comparing NoteDb to NoteDb, so compare fields instead of using static compare method.
+    assertThat(actual.group()).isEqualTo(expected.group());
+    assertThat(actual.members()).isEqualTo(expected.members());
+    assertThat(actual.memberAudit()).isEqualTo(expected.memberAudit());
+    assertThat(actual.byId()).isEqualTo(expected.byId());
+    assertThat(actual.byIdAudit()).isEqualTo(expected.byIdAudit());
+  }
+
+  private static Rebuild.Input input(Boolean force) {
+    Rebuild.Input input = new Rebuild.Input();
+    input.force = force;
+    return input;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
rename to javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
new file mode 100644
index 0000000..d9a2d92
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -0,0 +1,629 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class AccessIT extends AbstractDaemonTest {
+
+  private static final String PROJECT_NAME = "newProject";
+
+  private static final String REFS_ALL = Constants.R_REFS + "*";
+  private static final String REFS_HEADS = Constants.R_HEADS + "*";
+
+  private static final String LABEL_CODE_REVIEW = "Code-Review";
+
+  private String newProjectName;
+  private ProjectApi pApi;
+
+  @Before
+  public void setUp() throws Exception {
+    newProjectName = createProject(PROJECT_NAME).get();
+    pApi = gApi.projects().name(newProjectName);
+  }
+
+  @Test
+  public void getDefaultInheritance() throws Exception {
+    String inheritedName = pApi.access().inheritsFrom.name;
+    assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
+  }
+
+  @Test
+  public void addAccessSection() throws Exception {
+    Project.NameKey p = new Project.NameKey(newProjectName);
+    RevCommit initialHead = getRemoteHead(p, RefNames.REFS_CONFIG);
+
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi.access(accessInput);
+
+    assertThat(pApi.access().local).isEqualTo(accessInput.add);
+
+    RevCommit updatedHead = getRemoteHead(p, RefNames.REFS_CONFIG);
+    eventRecorder.assertRefUpdatedEvents(
+        p.get(), RefNames.REFS_CONFIG, null, initialHead, initialHead, updatedHead);
+  }
+
+  @Test
+  public void createAccessChange() throws Exception {
+    // User can see the branch
+    setApiUser(user);
+    gApi.projects().name(newProjectName).branch("refs/heads/master").get();
+
+    ProjectAccessInput accessInput = newProjectAccessInput();
+
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    // Deny read to registered users.
+    PermissionInfo read = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    read.exclusive = true;
+    accessSection.permissions.put(Permission.READ, read);
+    accessInput.add.put(REFS_HEADS, accessSection);
+
+    setApiUser(user);
+    ChangeInfo out = pApi.accessChange(accessInput);
+
+    assertThat(out.project).isEqualTo(newProjectName);
+    assertThat(out.branch).isEqualTo(RefNames.REFS_CONFIG);
+    assertThat(out.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(out.submitted).isNull();
+
+    setApiUser(admin);
+
+    ChangeInfo c = gApi.changes().id(out._number).get(MESSAGES);
+    assertThat(c.messages.stream().map(m -> m.message)).containsExactly("Uploaded patch set 1");
+
+    ReviewInput reviewIn = new ReviewInput();
+    reviewIn.label("Code-Review", (short) 2);
+    gApi.changes().id(out._number).current().review(reviewIn);
+    gApi.changes().id(out._number).current().submit();
+
+    // check that the change took effect.
+    setApiUser(user);
+    try {
+      BranchInfo info = gApi.projects().name(newProjectName).branch("refs/heads/master").get();
+      fail("wanted failure, got " + newGson().toJson(info));
+    } catch (ResourceNotFoundException e) {
+      // OK.
+    }
+
+    // Restore.
+    accessInput.add.clear();
+    accessInput.remove.put(REFS_HEADS, accessSection);
+    setApiUser(user);
+
+    pApi.accessChange(accessInput);
+
+    setApiUser(admin);
+    out = pApi.accessChange(accessInput);
+
+    gApi.changes().id(out._number).current().review(reviewIn);
+    gApi.changes().id(out._number).current().submit();
+
+    // Now it works again.
+    setApiUser(user);
+    gApi.projects().name(newProjectName).branch("refs/heads/master").get();
+  }
+
+  @Test
+  public void removePermission() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi.access(accessInput);
+
+    // Remove specific permission
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    accessSectionToRemove.permissions.put(
+        Permission.LABEL + LABEL_CODE_REVIEW, newPermissionInfo());
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi.access(removal);
+
+    // Remove locally
+    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LABEL_CODE_REVIEW);
+
+    // Check
+    assertThat(pApi.access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void removePermissionRule() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi.access(accessInput);
+
+    // Remove specific permission rule
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LABEL_CODE_REVIEW;
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionToRemove.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi.access(removal);
+
+    // Remove locally
+    accessInput
+        .add
+        .get(REFS_HEADS)
+        .permissions
+        .get(Permission.LABEL + LABEL_CODE_REVIEW)
+        .rules
+        .remove(SystemGroupBackend.REGISTERED_USERS.get());
+
+    // Check
+    assertThat(pApi.access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void removePermissionRulesAndCleanupEmptyEntries() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi.access(accessInput);
+
+    // Remove specific permission rules
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LABEL_CODE_REVIEW;
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSectionToRemove.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi.access(removal);
+
+    // Remove locally
+    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LABEL_CODE_REVIEW);
+
+    // Check
+    assertThat(pApi.access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void getPermissionsWithDisallowedUser() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
+
+    // Disallow READ
+    accessInput.add.put(REFS_ALL, accessSectionInfo);
+    pApi.access(accessInput);
+
+    setApiUser(user);
+    exception.expect(ResourceNotFoundException.class);
+    gApi.projects().name(newProjectName).access();
+  }
+
+  @Test
+  public void setPermissionsWithDisallowedUser() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
+
+    // Disallow READ
+    accessInput.add.put(REFS_ALL, accessSectionInfo);
+    pApi.access(accessInput);
+
+    // Create a change to apply
+    ProjectAccessInput accessInfoToApply = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfoToApply = createDefaultAccessSectionInfo();
+    accessInfoToApply.add.put(REFS_HEADS, accessSectionInfoToApply);
+
+    setApiUser(user);
+    exception.expect(ResourceNotFoundException.class);
+    gApi.projects().name(newProjectName).access();
+  }
+
+  @Test
+  public void permissionsGroupMap() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSection.permissions.put(Permission.PUSH, push);
+
+    PermissionInfo read = newPermissionInfo();
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
+    accessSection.permissions.put(Permission.READ, read);
+
+    accessInput.add.put(REFS_ALL, accessSection);
+    ProjectAccessInfo result = pApi.access(accessInput);
+    assertThat(result.groups.keySet())
+        .containsExactly(
+            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
+
+    // Check the name, which is what the UI cares about; exhaustive
+    // coverage of GroupInfo should be in groups REST API tests.
+    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).name)
+        .isEqualTo("Project Owners");
+    // Strip the ID, since it is in the key.
+    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).id).isNull();
+
+    // Get call returns groups too.
+    ProjectAccessInfo loggedInResult = pApi.access();
+    assertThat(loggedInResult.groups.keySet())
+        .containsExactly(
+            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
+    assertThat(loggedInResult.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).name)
+        .isEqualTo("Project Owners");
+    assertThat(loggedInResult.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).id).isNull();
+
+    // PROJECT_OWNERS is invisible to anonymous user, so we strip it.
+    setApiUserAnonymous();
+    ProjectAccessInfo anonResult = pApi.access();
+    assertThat(anonResult.groups.keySet())
+        .containsExactly(SystemGroupBackend.ANONYMOUS_USERS.get());
+  }
+
+  @Test
+  public void updateParentAsUser() throws Exception {
+    // Create child
+    String newParentProjectName = createProject(PROJECT_NAME + "PA").get();
+
+    // Set new parent
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = newParentProjectName;
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("administrate server not permitted");
+    gApi.projects().name(newProjectName).access(accessInput);
+  }
+
+  @Test
+  public void updateParentAsAdministrator() throws Exception {
+    // Create parent
+    String newParentProjectName = createProject(PROJECT_NAME + "PA").get();
+
+    // Set new parent
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = newParentProjectName;
+
+    gApi.projects().name(newProjectName).access(accessInput);
+
+    assertThat(pApi.access().inheritsFrom.name).isEqualTo(newParentProjectName);
+  }
+
+  @Test
+  public void addGlobalCapabilityAsUser() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+  }
+
+  @Test
+  public void addGlobalCapabilityAsAdmin() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    ProjectAccessInfo updatedAccessSectionInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThat(
+            updatedAccessSectionInfo
+                .local
+                .get(AccessSection.GLOBAL_CAPABILITIES)
+                .permissions
+                .keySet())
+        .containsAllIn(accessSectionInfo.permissions.keySet());
+  }
+
+  @Test
+  public void addGlobalCapabilityForNonRootProject() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    exception.expect(BadRequestException.class);
+    pApi.access(accessInput);
+  }
+
+  @Test
+  public void addNonGlobalCapabilityToGlobalCapabilities() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    permissionInfo.rules.put(adminGroupUuid().get(), null);
+    accessSectionInfo.permissions.put(Permission.PUSH, permissionInfo);
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    exception.expect(BadRequestException.class);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+  }
+
+  @Test
+  public void removeGlobalCapabilityAsUser() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+  }
+
+  @Test
+  public void removeGlobalCapabilityAsAdmin() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    permissionInfo.rules.put(adminGroupUuid().get(), null);
+    accessSectionInfo.permissions.put(GlobalCapability.ACCESS_DATABASE, permissionInfo);
+
+    // Add and validate first as removing existing privileges such as
+    // administrateServer would break upcoming tests
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    ProjectAccessInfo updatedProjectAccessInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThat(
+            updatedProjectAccessInfo
+                .local
+                .get(AccessSection.GLOBAL_CAPABILITIES)
+                .permissions
+                .keySet())
+        .containsAllIn(accessSectionInfo.permissions.keySet());
+
+    // Remove
+    accessInput.add.clear();
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    updatedProjectAccessInfo = gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThat(
+            updatedProjectAccessInfo
+                .local
+                .get(AccessSection.GLOBAL_CAPABILITIES)
+                .permissions
+                .keySet())
+        .containsNoneIn(accessSectionInfo.permissions.keySet());
+  }
+
+  @Test
+  public void unknownPermissionRemainsUnchanged() throws Exception {
+    String access = "access";
+    String unknownPermission = "unknownPermission";
+    String registeredUsers = "group Registered Users";
+    String refsFor = "refs/for/*";
+    // Clone repository to forcefully add permission
+    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
+
+    // Fetch permission ref
+    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
+    allProjectsRepo.reset("cfg");
+
+    // Load current permissions
+    String config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+
+    // Append and push unknown permission
+    Config cfg = new Config();
+    cfg.fromText(config);
+    cfg.setString(access, refsFor, unknownPermission, registeredUsers);
+    config = cfg.toText();
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
+    push.to(RefNames.REFS_CONFIG).assertOkStatus();
+
+    // Verify that unknownPermission is present
+    config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+    cfg.fromText(config);
+    assertThat(cfg.getString(access, refsFor, unknownPermission)).isEqualTo(registeredUsers);
+
+    // Make permission change through API
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+    accessInput.add.put(refsFor, accessSectionInfo);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+    accessInput.add.clear();
+    accessInput.remove.put(refsFor, accessSectionInfo);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Verify that unknownPermission is still present
+    config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+    cfg.fromText(config);
+    assertThat(cfg.getString(access, refsFor, unknownPermission)).isEqualTo(registeredUsers);
+  }
+
+  @Test
+  public void allUsersCanOnlyInheritFromAllProjects() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = project.get();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(allUsers.get() + " must inherit from " + allProjects.get());
+    gApi.projects().name(allUsers.get()).access(accessInput);
+  }
+
+  @Test
+  public void syncCreateGroupPermission() throws Exception {
+    // Grant CREATE_GROUP to Registered Users
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+    PermissionInfo createGroup = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
+    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
+    assertThat(local).isNotNull();
+    assertThat(local).containsKey(RefNames.REFS_GROUPS + "*");
+    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
+    assertThat(permissions).hasSize(2);
+    // READ is the default permission and should be preserved by the syncer
+    assertThat(permissions.keySet()).containsExactly(Permission.READ, Permission.CREATE);
+    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
+    assertThat(rules.values()).containsExactly(pri);
+
+    // Revoke the permission
+    accessInput.add.clear();
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
+    Map<String, AccessSectionInfo> local2 = gApi.projects().name("All-Users").access().local;
+    assertThat(local2).isNotNull();
+    assertThat(local2).containsKey(RefNames.REFS_GROUPS + "*");
+    Map<String, PermissionInfo> permissions2 = local2.get(RefNames.REFS_GROUPS + "*").permissions;
+    assertThat(permissions2).hasSize(1);
+    // READ is the default permission and should be preserved by the syncer
+    assertThat(permissions2.keySet()).containsExactly(Permission.READ);
+  }
+
+  private ProjectAccessInput newProjectAccessInput() {
+    ProjectAccessInput p = new ProjectAccessInput();
+    p.add = new HashMap<>();
+    p.remove = new HashMap<>();
+    return p;
+  }
+
+  private PermissionInfo newPermissionInfo() {
+    PermissionInfo p = new PermissionInfo(null, null);
+    p.rules = new HashMap<>();
+    return p;
+  }
+
+  private AccessSectionInfo newAccessSectionInfo() {
+    AccessSectionInfo a = new AccessSectionInfo();
+    a.permissions = new HashMap<>();
+    return a;
+  }
+
+  private AccessSectionInfo createDefaultAccessSectionInfo() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(Permission.PUSH, push);
+
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LABEL_CODE_REVIEW;
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    pri.max = 1;
+    pri.min = -1;
+    codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSection.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
+
+    return accessSection;
+  }
+
+  private AccessSectionInfo createDefaultGlobalCapabilitiesAccessSectionInfo() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo email = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    email.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.EMAIL_REVIEWERS, email);
+
+    return accessSection;
+  }
+
+  private AccessSectionInfo createAccessSectionInfoDenyAll() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo read = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
+    accessSection.permissions.put(Permission.READ, read);
+
+    return accessSection;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
new file mode 100644
index 0000000..0720fb3
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -0,0 +1,48 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "rest_project",
+    labels = ["rest"],
+    deps = [
+        ":project",
+        ":push_tag_util",
+        ":refassert",
+    ],
+)
+
+java_library(
+    name = "refassert",
+    srcs = [
+        "RefAssert.java",
+    ],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//lib:truth",
+    ],
+)
+
+java_library(
+    name = "project",
+    srcs = [
+        "ProjectAssert.java",
+    ],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:gwtorm",
+        "//lib:truth",
+    ],
+)
+
+java_library(
+    name = "push_tag_util",
+    testonly = 1,
+    srcs = [
+        "AbstractPushTag.java",
+    ],
+    deps = [
+        "//java/com/google/gerrit/acceptance:lib",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BanCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
new file mode 100644
index 0000000..19f6295
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.projects.BanCommitInput;
+import com.google.gerrit.server.restapi.project.BanCommit.BanResultInfo;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.junit.Test;
+
+public class BanCommitIT extends AbstractDaemonTest {
+
+  @Test
+  public void banCommit() throws Exception {
+    RevCommit c = commitBuilder().add("a.txt", "some content").create();
+
+    RestResponse r =
+        adminRestSession.put(
+            "/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());
+    assertThat(info.alreadyBanned).isNull();
+    assertThat(info.ignored).isNull();
+
+    RemoteRefUpdate u =
+        pushHead(testRepo, "refs/heads/master", false).getRemoteUpdate("refs/heads/master");
+    assertThat(u).isNotNull();
+    assertThat(u.getStatus()).isEqualTo(REJECTED_OTHER_REASON);
+    assertThat(u.getMessage()).startsWith("contains banned commit");
+  }
+
+  @Test
+  public void banAlreadyBannedCommit() throws Exception {
+    RestResponse r =
+        adminRestSession.put(
+            "/projects/" + project.get() + "/ban/",
+            BanCommitInput.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
+    r.consume();
+
+    r =
+        adminRestSession.put(
+            "/projects/" + project.get() + "/ban/",
+            BanCommitInput.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
+    r.assertOK();
+    BanResultInfo info = newGson().fromJson(r.getReader(), BanResultInfo.class);
+    assertThat(Iterables.getOnlyElement(info.alreadyBanned))
+        .isEqualTo("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96");
+    assertThat(info.newlyBanned).isNull();
+    assertThat(info.ignored).isNull();
+  }
+
+  @Test
+  public void banCommit_Forbidden() throws Exception {
+    userRestSession
+        .put(
+            "/projects/" + project.get() + "/ban/",
+            BanCommitInput.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"))
+        .assertForbidden();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
rename to javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java
rename to javatests/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
new file mode 100644
index 0000000..48dc994
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -0,0 +1,158 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.projects.BranchApi;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.RefNames;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class CreateBranchIT extends AbstractDaemonTest {
+  private Branch.NameKey testBranch;
+
+  @Before
+  public void setUp() throws Exception {
+    testBranch = new Branch.NameKey(project, "test");
+  }
+
+  @Test
+  public void createBranch_Forbidden() throws Exception {
+    setApiUser(user);
+    assertCreateFails(testBranch, AuthException.class, "create not permitted for refs/heads/test");
+  }
+
+  @Test
+  public void createBranchByAdmin() throws Exception {
+    assertCreateSucceeds(testBranch);
+  }
+
+  @Test
+  public void branchAlreadyExists_Conflict() throws Exception {
+    assertCreateSucceeds(testBranch);
+    assertCreateFails(testBranch, ResourceConflictException.class);
+  }
+
+  @Test
+  public void createBranchByProjectOwner() throws Exception {
+    grantOwner();
+    setApiUser(user);
+    assertCreateSucceeds(testBranch);
+  }
+
+  @Test
+  public void createBranchByAdminCreateReferenceBlocked_Forbidden() throws Exception {
+    blockCreateReference();
+    assertCreateFails(testBranch, AuthException.class, "create not permitted for refs/heads/test");
+  }
+
+  @Test
+  public void createBranchByProjectOwnerCreateReferenceBlocked_Forbidden() throws Exception {
+    grantOwner();
+    blockCreateReference();
+    setApiUser(user);
+    assertCreateFails(testBranch, AuthException.class, "create not permitted for refs/heads/test");
+  }
+
+  @Test
+  public void createMetaBranch() throws Exception {
+    String metaRef = RefNames.REFS_META + "foo";
+    allow(metaRef, Permission.CREATE, REGISTERED_USERS);
+    allow(metaRef, Permission.PUSH, REGISTERED_USERS);
+    assertCreateSucceeds(new Branch.NameKey(project, metaRef));
+  }
+
+  @Test
+  public void createUserBranch_Conflict() throws Exception {
+    allow(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE, REGISTERED_USERS);
+    allow(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH, REGISTERED_USERS);
+    assertCreateFails(
+        new Branch.NameKey(allUsers, RefNames.refsUsers(new Account.Id(1))),
+        RefNames.refsUsers(admin.getId()),
+        ResourceConflictException.class,
+        "Not allowed to create user branch.");
+  }
+
+  @Test
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void createGroupBranch_Conflict() throws Exception {
+    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.CREATE, REGISTERED_USERS);
+    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.PUSH, REGISTERED_USERS);
+    assertCreateFails(
+        new Branch.NameKey(allUsers, RefNames.refsGroups(new AccountGroup.UUID("foo"))),
+        RefNames.refsGroups(adminGroupUuid()),
+        ResourceConflictException.class,
+        "Not allowed to create group branch.");
+  }
+
+  private void blockCreateReference() throws Exception {
+    block("refs/*", Permission.CREATE, ANONYMOUS_USERS);
+  }
+
+  private void grantOwner() throws Exception {
+    allow("refs/*", Permission.OWNER, REGISTERED_USERS);
+  }
+
+  private BranchApi branch(Branch.NameKey branch) throws Exception {
+    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
+  }
+
+  private void assertCreateSucceeds(Branch.NameKey branch) throws Exception {
+    BranchInfo created = branch(branch).create(new BranchInput()).get();
+    assertThat(created.ref).isEqualTo(branch.get());
+  }
+
+  private void assertCreateFails(
+      Branch.NameKey branch, Class<? extends RestApiException> errType, String errMsg)
+      throws Exception {
+    assertCreateFails(branch, null, errType, errMsg);
+  }
+
+  private void assertCreateFails(
+      Branch.NameKey branch,
+      String revision,
+      Class<? extends RestApiException> errType,
+      String errMsg)
+      throws Exception {
+    exception.expect(errType);
+    if (errMsg != null) {
+      exception.expectMessage(errMsg);
+    }
+    BranchInput in = new BranchInput();
+    in.revision = revision;
+    branch(branch).create(in);
+  }
+
+  private void assertCreateFails(Branch.NameKey branch, Class<? extends RestApiException> errType)
+      throws Exception {
+    assertCreateFails(branch, errType, null);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
new file mode 100644
index 0000000..8cbe1e7
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -0,0 +1,441 @@
+// 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 static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectInfo;
+import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectOwners;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.common.net.HttpHeaders;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.group.SystemGroupBackend;
+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;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.Test;
+
+public class CreateProjectIT extends AbstractDaemonTest {
+  @Test
+  public void createProjectHttp() throws Exception {
+    String newProjectName = name("newProject");
+    RestResponse r = adminRestSession.put("/projects/" + newProjectName);
+    r.assertCreated();
+    ProjectInfo p = newGson().fromJson(r.getReader(), ProjectInfo.class);
+    assertThat(p.name).isEqualTo(newProjectName);
+
+    // Check that we populate the label data in the HTTP path. See GetProjectIT#getProject
+    // for more extensive coverage of the LabelTypeInfo.
+    assertThat(p.labels).hasSize(1);
+
+    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    assertThat(projectState).isNotNull();
+    assertProjectInfo(projectState.getProject(), p);
+    assertHead(newProjectName, "refs/heads/master");
+  }
+
+  @Test
+  public void createProjectHttpWhenProjectAlreadyExists_Conflict() throws Exception {
+    adminRestSession.put("/projects/" + allProjects.get()).assertConflict();
+  }
+
+  @Test
+  public void createProjectHttpWhenProjectAlreadyExists_PreconditionFailed() throws Exception {
+    adminRestSession
+        .putWithHeader(
+            "/projects/" + allProjects.get(), new BasicHeader(HttpHeaders.IF_NONE_MATCH, "*"))
+        .assertPreconditionFailed();
+  }
+
+  @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 =
+        ImmutableList.of(
+            "/../", "/./", "//", ".git/", "?", "%", "*", ":", "<", ">", "|", "$", "/+", "~");
+    for (String s : forbiddenStrings) {
+      String projectName = name("invalid" + s + "name");
+      assertWithMessage("Expected status code for " + projectName + " to be 400.")
+          .that(adminRestSession.put("/projects/" + Url.encode(projectName)).getStatusCode())
+          .isEqualTo(HttpStatus.SC_BAD_REQUEST);
+    }
+  }
+
+  @Test
+  public void createProjectHttpWithNameMismatch_BadRequest() throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.name = name("otherName");
+    adminRestSession.put("/projects/" + name("someName"), in).assertBadRequest();
+  }
+
+  @Test
+  public void createProjectHttpWithInvalidRefName_BadRequest() throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.branches = Collections.singletonList(name("invalid ref name"));
+    adminRestSession.put("/projects/" + name("newProject"), in).assertBadRequest();
+  }
+
+  @Test
+  public void createProject() throws Exception {
+    String newProjectName = name("newProject");
+    ProjectInfo p = gApi.projects().create(newProjectName).get();
+    assertThat(p.name).isEqualTo(newProjectName);
+    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    assertThat(projectState).isNotNull();
+    assertProjectInfo(projectState.getProject(), p);
+    assertHead(newProjectName, "refs/heads/master");
+  }
+
+  @Test
+  public void createProjectWithGitSuffix() throws Exception {
+    String newProjectName = name("newProject");
+    ProjectInfo p = gApi.projects().create(newProjectName + ".git").get();
+    assertThat(p.name).isEqualTo(newProjectName);
+    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    assertThat(projectState).isNotNull();
+    assertProjectInfo(projectState.getProject(), p);
+    assertHead(newProjectName, "refs/heads/master");
+  }
+
+  @Test
+  public void createProjectWithProperties() throws Exception {
+    String newProjectName = name("newProject");
+    ProjectInput in = new ProjectInput();
+    in.name = newProjectName;
+    in.description = "Test description";
+    in.submitType = SubmitType.CHERRY_PICK;
+    in.useContributorAgreements = InheritableBoolean.TRUE;
+    in.useSignedOffBy = InheritableBoolean.TRUE;
+    in.useContentMerge = InheritableBoolean.TRUE;
+    in.requireChangeId = InheritableBoolean.TRUE;
+    ProjectInfo p = gApi.projects().create(in).get();
+    assertThat(p.name).isEqualTo(newProjectName);
+    Project project = projectCache.get(new Project.NameKey(newProjectName)).getProject();
+    assertProjectInfo(project, p);
+    assertThat(project.getDescription()).isEqualTo(in.description);
+    assertThat(project.getConfiguredSubmitType()).isEqualTo(in.submitType);
+    assertThat(project.getBooleanConfig(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS))
+        .isEqualTo(in.useContributorAgreements);
+    assertThat(project.getBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY))
+        .isEqualTo(in.useSignedOffBy);
+    assertThat(project.getBooleanConfig(BooleanProjectConfig.USE_CONTENT_MERGE))
+        .isEqualTo(in.useContentMerge);
+    assertThat(project.getBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID))
+        .isEqualTo(in.requireChangeId);
+  }
+
+  @Test
+  public void createChildProject() throws Exception {
+    String parentName = name("parent");
+    ProjectInput in = new ProjectInput();
+    in.name = parentName;
+    gApi.projects().create(in);
+
+    String childName = name("child");
+    in = new ProjectInput();
+    in.name = childName;
+    in.parent = parentName;
+    gApi.projects().create(in);
+    Project project = projectCache.get(new Project.NameKey(childName)).getProject();
+    assertThat(project.getParentName()).isEqualTo(in.parent);
+  }
+
+  @Test
+  public void createChildProjectUnderNonExistingParent_UnprocessableEntity() throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.name = name("newProjectName");
+    in.parent = "non-existing-project";
+    assertCreateFails(in, UnprocessableEntityException.class);
+  }
+
+  @Test
+  public void createProjectWithOwner() throws Exception {
+    String newProjectName = name("newProject");
+    ProjectInput in = new ProjectInput();
+    in.name = newProjectName;
+    in.owners = Lists.newArrayListWithCapacity(3);
+    in.owners.add("Anonymous Users"); // by name
+    in.owners.add(SystemGroupBackend.REGISTERED_USERS.get()); // by UUID
+    in.owners.add(
+        Integer.toString(
+            groupCache
+                .get(new AccountGroup.NameKey("Administrators"))
+                .orElse(null)
+                .getId()
+                .get())); // by ID
+    gApi.projects().create(in);
+    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
+    Set<AccountGroup.UUID> expectedOwnerIds = Sets.newHashSetWithExpectedSize(3);
+    expectedOwnerIds.add(SystemGroupBackend.ANONYMOUS_USERS);
+    expectedOwnerIds.add(SystemGroupBackend.REGISTERED_USERS);
+    expectedOwnerIds.add(groupUuid("Administrators"));
+    assertProjectOwners(expectedOwnerIds, projectState);
+  }
+
+  @Test
+  public void createProjectWithNonExistingOwner_UnprocessableEntity() throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.name = name("newProjectName");
+    in.owners = Collections.singletonList("non-existing-group");
+    assertCreateFails(in, UnprocessableEntityException.class);
+  }
+
+  @Test
+  public void createPermissionOnlyProject() throws Exception {
+    String newProjectName = name("newProject");
+    ProjectInput in = new ProjectInput();
+    in.name = newProjectName;
+    in.permissionsOnly = true;
+    gApi.projects().create(in);
+    assertHead(newProjectName, RefNames.REFS_CONFIG);
+  }
+
+  @Test
+  public void createProjectWithEmptyCommit() throws Exception {
+    String newProjectName = name("newProject");
+    ProjectInput in = new ProjectInput();
+    in.name = newProjectName;
+    in.createEmptyCommit = true;
+    gApi.projects().create(in);
+    assertEmptyCommit(newProjectName, "refs/heads/master");
+  }
+
+  @Test
+  public void createProjectWithBranches() throws Exception {
+    String newProjectName = name("newProject");
+    ProjectInput in = new ProjectInput();
+    in.name = newProjectName;
+    in.createEmptyCommit = true;
+    in.branches = Lists.newArrayListWithCapacity(3);
+    in.branches.add("refs/heads/test");
+    in.branches.add("refs/heads/master");
+    in.branches.add("release"); // without 'refs/heads' prefix
+    gApi.projects().create(in);
+    assertHead(newProjectName, "refs/heads/test");
+    assertEmptyCommit(newProjectName, "refs/heads/test", "refs/heads/master", "refs/heads/release");
+  }
+
+  @Test
+  public void createProjectWithCapability() throws Exception {
+    allowGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+    try {
+      setApiUser(user);
+      ProjectInput in = new ProjectInput();
+      in.name = name("newProject");
+      ProjectInfo p = gApi.projects().create(in).get();
+      assertThat(p.name).isEqualTo(in.name);
+    } finally {
+      removeGlobalCapabilities(
+          SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+    }
+  }
+
+  @Test
+  public void createProjectWithoutCapability_Forbidden() throws Exception {
+    setApiUser(user);
+    ProjectInput in = new ProjectInput();
+    in.name = name("newProject");
+    assertCreateFails(in, AuthException.class);
+  }
+
+  @Test
+  public void createProjectWhenProjectAlreadyExists_Conflict() throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.name = allProjects.get();
+    assertCreateFails(in, ResourceConflictException.class);
+  }
+
+  @Test
+  public void createProjectWithCreateProjectCapabilityAndParentNotVisible() throws Exception {
+    Project parent = projectCache.get(allProjects).getProject();
+    parent.setState(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
+    allowGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+    try {
+      setApiUser(user);
+      ProjectInput in = new ProjectInput();
+      in.name = name("newProject");
+      ProjectInfo p = gApi.projects().create(in).get();
+      assertThat(p.name).isEqualTo(in.name);
+    } finally {
+      parent.setState(com.google.gerrit.extensions.client.ProjectState.ACTIVE);
+      removeGlobalCapabilities(
+          SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
+    }
+  }
+
+  @SuppressWarnings("deprecation")
+  @Test
+  public void createProjectWithDefaultInheritedSubmitType() throws Exception {
+    String parent = name("parent");
+    ProjectInput pin = new ProjectInput();
+    pin.name = parent;
+    ConfigInfo cfg = gApi.projects().create(pin).config();
+    assertThat(cfg.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+    assertThat(cfg.defaultSubmitType.value).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+    assertThat(cfg.defaultSubmitType.configuredValue).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+    assertThat(cfg.defaultSubmitType.inheritedValue).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+
+    ConfigInput cin = new ConfigInput();
+    cin.submitType = SubmitType.CHERRY_PICK;
+    gApi.projects().name(parent).config(cin);
+    cfg = gApi.projects().name(parent).config();
+    assertThat(cfg.submitType).isEqualTo(SubmitType.CHERRY_PICK);
+    assertThat(cfg.defaultSubmitType.value).isEqualTo(SubmitType.CHERRY_PICK);
+    assertThat(cfg.defaultSubmitType.configuredValue).isEqualTo(SubmitType.CHERRY_PICK);
+    assertThat(cfg.defaultSubmitType.inheritedValue).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+
+    String child = name("child");
+    pin = new ProjectInput();
+    pin.submitType = SubmitType.INHERIT;
+    pin.parent = parent;
+    pin.name = child;
+    cfg = gApi.projects().create(pin).config();
+    assertThat(cfg.submitType).isEqualTo(SubmitType.CHERRY_PICK);
+    assertThat(cfg.defaultSubmitType.value).isEqualTo(SubmitType.CHERRY_PICK);
+    assertThat(cfg.defaultSubmitType.configuredValue).isEqualTo(SubmitType.INHERIT);
+    assertThat(cfg.defaultSubmitType.inheritedValue).isEqualTo(SubmitType.CHERRY_PICK);
+
+    cin = new ConfigInput();
+    cin.submitType = SubmitType.REBASE_IF_NECESSARY;
+    gApi.projects().name(parent).config(cin);
+    cfg = gApi.projects().name(parent).config();
+    assertThat(cfg.submitType).isEqualTo(SubmitType.REBASE_IF_NECESSARY);
+    assertThat(cfg.defaultSubmitType.value).isEqualTo(SubmitType.REBASE_IF_NECESSARY);
+    assertThat(cfg.defaultSubmitType.configuredValue).isEqualTo(SubmitType.REBASE_IF_NECESSARY);
+    assertThat(cfg.defaultSubmitType.inheritedValue).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+
+    cfg = gApi.projects().name(child).config();
+    assertThat(cfg.submitType).isEqualTo(SubmitType.REBASE_IF_NECESSARY);
+    assertThat(cfg.defaultSubmitType.value).isEqualTo(SubmitType.REBASE_IF_NECESSARY);
+    assertThat(cfg.defaultSubmitType.configuredValue).isEqualTo(SubmitType.INHERIT);
+    assertThat(cfg.defaultSubmitType.inheritedValue).isEqualTo(SubmitType.REBASE_IF_NECESSARY);
+  }
+
+  @SuppressWarnings("deprecation")
+  @Test
+  @GerritConfig(
+    name = "repository.testinheritedsubmittype/*.defaultSubmitType",
+    value = "CHERRY_PICK"
+  )
+  public void repositoryConfigTakesPrecedenceOverInheritedSubmitType() throws Exception {
+    // Can't use name() since we need to specify this project name in gerrit.config prior to
+    // startup. Pick something reasonably unique instead.
+    String parent = "testinheritedsubmittype";
+    ProjectInput pin = new ProjectInput();
+    pin.name = parent;
+    pin.submitType = SubmitType.MERGE_ALWAYS;
+    ConfigInfo cfg = gApi.projects().create(pin).config();
+    assertThat(cfg.submitType).isEqualTo(SubmitType.MERGE_ALWAYS);
+    assertThat(cfg.defaultSubmitType.value).isEqualTo(SubmitType.MERGE_ALWAYS);
+    assertThat(cfg.defaultSubmitType.configuredValue).isEqualTo(SubmitType.MERGE_ALWAYS);
+    assertThat(cfg.defaultSubmitType.inheritedValue).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+
+    String child = parent + "/child";
+    pin = new ProjectInput();
+    pin.parent = parent;
+    pin.name = child;
+    cfg = gApi.projects().create(pin).config();
+    assertThat(cfg.submitType).isEqualTo(SubmitType.CHERRY_PICK);
+    assertThat(cfg.defaultSubmitType.value).isEqualTo(SubmitType.CHERRY_PICK);
+    assertThat(cfg.defaultSubmitType.configuredValue).isEqualTo(SubmitType.CHERRY_PICK);
+    assertThat(cfg.defaultSubmitType.inheritedValue).isEqualTo(SubmitType.MERGE_ALWAYS);
+  }
+
+  private void assertHead(String projectName, String expectedRef) throws Exception {
+    try (Repository repo = repoManager.openRepository(new Project.NameKey(projectName))) {
+      assertThat(repo.exactRef(Constants.HEAD).getTarget().getName()).isEqualTo(expectedRef);
+    }
+  }
+
+  private void assertEmptyCommit(String projectName, String... refs) throws Exception {
+    Project.NameKey projectKey = new Project.NameKey(projectName);
+    try (Repository repo = repoManager.openRepository(projectKey);
+        RevWalk rw = new RevWalk(repo);
+        TreeWalk tw = new TreeWalk(rw.getObjectReader())) {
+      for (String ref : refs) {
+        RevCommit commit = rw.lookupCommit(repo.exactRef(ref).getObjectId());
+        rw.parseBody(commit);
+        tw.addTree(commit.getTree());
+        assertThat(tw.next()).isFalse();
+        tw.reset();
+      }
+    }
+  }
+
+  private void assertCreateFails(ProjectInput in, Class<? extends RestApiException> errType)
+      throws Exception {
+    exception.expect(errType);
+    gApi.projects().create(in);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
new file mode 100644
index 0000000..5e1b0bf
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -0,0 +1,199 @@
+// 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 static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.projects.BranchApi;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.RefNames;
+import org.junit.Before;
+import org.junit.Test;
+
+public class DeleteBranchIT extends AbstractDaemonTest {
+
+  private Branch.NameKey testBranch;
+
+  @Before
+  public void setUp() throws Exception {
+    project = createProject(name("p"));
+    testBranch = new Branch.NameKey(project, "test");
+    branch(testBranch).create(new BranchInput());
+  }
+
+  @Test
+  public void deleteBranch_Forbidden() throws Exception {
+    setApiUser(user);
+    assertDeleteForbidden(testBranch);
+  }
+
+  @Test
+  public void deleteBranchByAdmin() throws Exception {
+    assertDeleteSucceeds(testBranch);
+  }
+
+  @Test
+  public void deleteBranchByProjectOwner() throws Exception {
+    grantOwner();
+    setApiUser(user);
+    assertDeleteSucceeds(testBranch);
+  }
+
+  @Test
+  public void deleteBranchByAdminForcePushBlocked() throws Exception {
+    blockForcePush();
+    assertDeleteSucceeds(testBranch);
+  }
+
+  @Test
+  public void deleteBranchByProjectOwnerForcePushBlocked_Forbidden() throws Exception {
+    grantOwner();
+    blockForcePush();
+    setApiUser(user);
+    assertDeleteForbidden(testBranch);
+  }
+
+  @Test
+  public void deleteBranchByUserWithForcePushPermission() throws Exception {
+    grantForcePush();
+    setApiUser(user);
+    assertDeleteSucceeds(testBranch);
+  }
+
+  @Test
+  public void deleteBranchByUserWithDeletePermission() throws Exception {
+    grantDelete();
+    setApiUser(user);
+    assertDeleteSucceeds(testBranch);
+  }
+
+  @Test
+  public void deleteBranchByRestWithoutRefsHeadsPrefix() throws Exception {
+    grantDelete();
+    String ref = testBranch.getShortName();
+    assertThat(ref).doesNotMatch(R_HEADS);
+    assertDeleteByRestSucceeds(testBranch, ref);
+  }
+
+  @Test
+  public void deleteBranchByRestWithFullName() throws Exception {
+    grantDelete();
+    assertDeleteByRestSucceeds(testBranch, testBranch.get());
+  }
+
+  @Test
+  public void deleteBranchByRestFailsWithUnencodedFullName() throws Exception {
+    grantDelete();
+    RestResponse r =
+        userRestSession.delete("/projects/" + project.get() + "/branches/" + testBranch.get());
+    r.assertNotFound();
+    branch(testBranch).get();
+  }
+
+  @Test
+  public void deleteMetaBranch() throws Exception {
+    String metaRef = RefNames.REFS_META + "foo";
+    allow(metaRef, Permission.CREATE, REGISTERED_USERS);
+    allow(metaRef, Permission.PUSH, REGISTERED_USERS);
+
+    Branch.NameKey metaBranch = new Branch.NameKey(project, metaRef);
+    branch(metaBranch).create(new BranchInput());
+
+    grantDelete();
+    assertDeleteByRestSucceeds(metaBranch, metaRef);
+  }
+
+  @Test
+  public void deleteUserBranch_Conflict() throws Exception {
+    allow(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE, REGISTERED_USERS);
+    allow(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH, REGISTERED_USERS);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Not allowed to delete user branch.");
+    branch(new Branch.NameKey(allUsers, RefNames.refsUsers(admin.id))).delete();
+  }
+
+  @Test
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void deleteGroupBranch_Conflict() throws Exception {
+    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.CREATE, REGISTERED_USERS);
+    allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.PUSH, REGISTERED_USERS);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Not allowed to delete group branch.");
+    branch(new Branch.NameKey(allUsers, RefNames.refsGroups(adminGroupUuid()))).delete();
+  }
+
+  private void blockForcePush() throws Exception {
+    block("refs/heads/*", Permission.PUSH, ANONYMOUS_USERS).setForce(true);
+  }
+
+  private void grantForcePush() throws Exception {
+    grant(project, "refs/heads/*", Permission.PUSH, true, ANONYMOUS_USERS);
+  }
+
+  private void grantDelete() throws Exception {
+    allow("refs/*", Permission.DELETE, ANONYMOUS_USERS);
+  }
+
+  private void grantOwner() throws Exception {
+    allow("refs/*", Permission.OWNER, REGISTERED_USERS);
+  }
+
+  private BranchApi branch(Branch.NameKey branch) throws Exception {
+    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
+  }
+
+  private void assertDeleteByRestSucceeds(Branch.NameKey branch, String ref) throws Exception {
+    RestResponse r =
+        userRestSession.delete(
+            "/projects/"
+                + IdString.fromDecoded(project.get()).encoded()
+                + "/branches/"
+                + IdString.fromDecoded(ref).encoded());
+    r.assertNoContent();
+    exception.expect(ResourceNotFoundException.class);
+    branch(branch).get();
+  }
+
+  private void assertDeleteSucceeds(Branch.NameKey branch) throws Exception {
+    assertThat(branch(branch).get().canDelete).isTrue();
+    String branchRev = branch(branch).get().revision;
+    branch(branch).delete();
+    eventRecorder.assertRefUpdatedEvents(
+        project.get(), branch.get(), null, branchRev, branchRev, null);
+    exception.expect(ResourceNotFoundException.class);
+    branch(branch).get();
+  }
+
+  private void assertDeleteForbidden(Branch.NameKey branch) throws Exception {
+    assertThat(branch(branch).get().canDelete).isNull();
+    exception.expect(AuthException.class);
+    exception.expectMessage("delete not permitted");
+    branch(branch).delete();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
new file mode 100644
index 0000000..099e90d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -0,0 +1,195 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+import static org.eclipse.jgit.lib.Constants.R_REFS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.RefNames;
+import java.util.HashMap;
+import java.util.List;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class DeleteBranchesIT extends AbstractDaemonTest {
+  private static final ImmutableList<String> BRANCHES =
+      ImmutableList.of("refs/heads/test-1", "refs/heads/test-2", "test-3", "refs/meta/foo");
+
+  @Before
+  public void setUp() throws Exception {
+    allow("refs/*", Permission.CREATE, REGISTERED_USERS);
+    allow("refs/*", Permission.PUSH, REGISTERED_USERS);
+    for (String name : BRANCHES) {
+      project().branch(name).create(new BranchInput());
+    }
+    assertBranches(BRANCHES);
+  }
+
+  @Test
+  public void deleteBranches() throws Exception {
+    HashMap<String, RevCommit> initialRevisions = initialRevisions(BRANCHES);
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = BRANCHES;
+    project().deleteBranches(input);
+    assertBranchesDeleted(BRANCHES);
+    assertRefUpdatedEvents(initialRevisions);
+  }
+
+  @Test
+  public void deleteBranchesForbidden() throws Exception {
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = BRANCHES;
+    setApiUser(user);
+    try {
+      project().deleteBranches(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e).hasMessageThat().isEqualTo(errorMessageForBranches(BRANCHES));
+    }
+    setApiUser(admin);
+    assertBranches(BRANCHES);
+  }
+
+  @Test
+  public void deleteBranchesNotFound() throws Exception {
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    List<String> branches = Lists.newArrayList(BRANCHES);
+    branches.add("refs/heads/does-not-exist");
+    input.branches = branches;
+    try {
+      project().deleteBranches(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
+    }
+    assertBranchesDeleted(BRANCHES);
+  }
+
+  @Test
+  public void deleteBranchesNotFoundContinue() throws Exception {
+    // If it fails on the first branch in the input, it should still
+    // continue to process the remaining branches.
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    List<String> branches = Lists.newArrayList("refs/heads/does-not-exist");
+    branches.addAll(BRANCHES);
+    input.branches = branches;
+    try {
+      project().deleteBranches(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
+    }
+    assertBranchesDeleted(BRANCHES);
+  }
+
+  @Test
+  public void missingInput() throws Exception {
+    DeleteBranchesInput input = null;
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branches must be specified");
+    project().deleteBranches(input);
+  }
+
+  @Test
+  public void missingBranchList() throws Exception {
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branches must be specified");
+    project().deleteBranches(input);
+  }
+
+  @Test
+  public void emptyBranchList() throws Exception {
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = Lists.newArrayList();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branches must be specified");
+    project().deleteBranches(input);
+  }
+
+  private String errorMessageForBranches(List<String> branches) {
+    StringBuilder message = new StringBuilder();
+    for (String branch : branches) {
+      message
+          .append("Cannot delete ")
+          .append(prefixRef(branch))
+          .append(": it doesn't exist or you do not have permission ")
+          .append("to delete it\n");
+    }
+    return message.toString();
+  }
+
+  private HashMap<String, RevCommit> initialRevisions(List<String> branches) throws Exception {
+    HashMap<String, RevCommit> result = new HashMap<>();
+    for (String branch : branches) {
+      result.put(branch, getRemoteHead(project, branch));
+    }
+    return result;
+  }
+
+  private void assertRefUpdatedEvents(HashMap<String, RevCommit> revisions) throws Exception {
+    for (String branch : revisions.keySet()) {
+      RevCommit revision = revisions.get(branch);
+      eventRecorder.assertRefUpdatedEvents(
+          project.get(), prefixRef(branch), null, revision, revision, null);
+    }
+  }
+
+  private String prefixRef(String ref) {
+    return ref.startsWith(R_REFS) ? ref : R_HEADS + ref;
+  }
+
+  private ProjectApi project() throws Exception {
+    return gApi.projects().name(project.get());
+  }
+
+  private void assertBranches(List<String> branches) throws Exception {
+    List<String> expected = Lists.newArrayList("HEAD", RefNames.REFS_CONFIG, "refs/heads/master");
+    expected.addAll(branches.stream().map(b -> prefixRef(b)).collect(toList()));
+    try (Repository repo = repoManager.openRepository(project)) {
+      for (String branch : expected) {
+        assertThat(repo.exactRef(branch)).isNotNull();
+      }
+    }
+  }
+
+  private void assertBranchesDeleted(List<String> branches) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      for (String branch : branches) {
+        assertThat(repo.exactRef(branch)).isNull();
+      }
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
rename to javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
new file mode 100644
index 0000000..746a8ee
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
@@ -0,0 +1,159 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import java.util.HashMap;
+import java.util.List;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class DeleteTagsIT extends AbstractDaemonTest {
+  private static final ImmutableList<String> TAGS =
+      ImmutableList.of("refs/tags/test-1", "refs/tags/test-2", "refs/tags/test-3", "test-4");
+
+  @Before
+  public void setUp() throws Exception {
+    for (String name : TAGS) {
+      project().tag(name).create(new TagInput());
+    }
+    assertTags(TAGS);
+  }
+
+  @Test
+  public void deleteTags() throws Exception {
+    HashMap<String, RevCommit> initialRevisions = initialRevisions(TAGS);
+    DeleteTagsInput input = new DeleteTagsInput();
+    input.tags = TAGS;
+    project().deleteTags(input);
+    assertTagsDeleted();
+    assertRefUpdatedEvents(initialRevisions);
+  }
+
+  @Test
+  public void deleteTagsForbidden() throws Exception {
+    DeleteTagsInput input = new DeleteTagsInput();
+    input.tags = TAGS;
+    setApiUser(user);
+    try {
+      project().deleteTags(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e).hasMessageThat().isEqualTo(errorMessageForTags(TAGS));
+    }
+    setApiUser(admin);
+    assertTags(TAGS);
+  }
+
+  @Test
+  public void deleteTagsNotFound() throws Exception {
+    DeleteTagsInput input = new DeleteTagsInput();
+    List<String> tags = Lists.newArrayList(TAGS);
+    tags.add("refs/tags/does-not-exist");
+    input.tags = tags;
+    try {
+      project().deleteTags(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(errorMessageForTags(ImmutableList.of("refs/tags/does-not-exist")));
+    }
+    assertTagsDeleted();
+  }
+
+  @Test
+  public void deleteTagsNotFoundContinue() throws Exception {
+    // If it fails on the first tag in the input, it should still
+    // continue to process the remaining tags.
+    DeleteTagsInput input = new DeleteTagsInput();
+    List<String> tags = Lists.newArrayList("refs/tags/does-not-exist");
+    tags.addAll(TAGS);
+    input.tags = tags;
+    try {
+      project().deleteTags(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(errorMessageForTags(ImmutableList.of("refs/tags/does-not-exist")));
+    }
+    assertTagsDeleted();
+  }
+
+  private String errorMessageForTags(List<String> tags) {
+    StringBuilder message = new StringBuilder();
+    for (String tag : tags) {
+      message
+          .append("Cannot delete ")
+          .append(prefixRef(tag))
+          .append(": it doesn't exist or you do not have permission ")
+          .append("to delete it\n");
+    }
+    return message.toString();
+  }
+
+  private HashMap<String, RevCommit> initialRevisions(List<String> tags) throws Exception {
+    HashMap<String, RevCommit> result = new HashMap<>();
+    for (String tag : tags) {
+      String ref = prefixRef(tag);
+      result.put(ref, getRemoteHead(project, ref));
+    }
+    return result;
+  }
+
+  private void assertRefUpdatedEvents(HashMap<String, RevCommit> revisions) throws Exception {
+    for (String tag : revisions.keySet()) {
+      RevCommit revision = revisions.get(prefixRef(tag));
+      eventRecorder.assertRefUpdatedEvents(
+          project.get(), prefixRef(tag), null, revision, revision, null);
+    }
+  }
+
+  private String prefixRef(String ref) {
+    return ref.startsWith(R_TAGS) ? ref : R_TAGS + ref;
+  }
+
+  private ProjectApi project() throws Exception {
+    return gApi.projects().name(project.get());
+  }
+
+  private void assertTags(List<String> expected) throws Exception {
+    List<TagInfo> actualTags = project().tags().get();
+    Iterable<String> actualNames = Iterables.transform(actualTags, b -> b.ref);
+    assertThat(actualNames)
+        .containsExactlyElementsIn(expected.stream().map(t -> prefixRef(t)).collect(toList()))
+        .inOrder();
+  }
+
+  private void assertTagsDeleted() throws Exception {
+    assertTags(ImmutableList.<String>of());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/FileBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
rename to javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
rename to javatests/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
rename to javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
rename to javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
rename to javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
new file mode 100644
index 0000000..bc029ae
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -0,0 +1,184 @@
+// 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.gerrit.acceptance.rest.project.RefAssert.assertRefNames;
+import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefs;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.RefNames;
+import org.junit.Test;
+
+@NoHttpd
+public class ListBranchesIT extends AbstractDaemonTest {
+  @Test
+  public void listBranchesOfNonExistingProject_NotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.projects().name("non-existing").branches().get();
+  }
+
+  @Test
+  public void listBranchesOfNonVisibleProject_NotFound() throws Exception {
+    blockRead("refs/*");
+    setApiUser(user);
+    exception.expect(ResourceNotFoundException.class);
+    gApi.projects().name(project.get()).branches().get();
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void listBranchesOfEmptyProject() throws Exception {
+    assertRefs(
+        ImmutableList.of(branch("HEAD", null, false), branch(RefNames.REFS_CONFIG, null, false)),
+        list().get());
+  }
+
+  @Test
+  public void listBranches() throws Exception {
+    String master = pushTo("refs/heads/master").getCommit().name();
+    String dev = pushTo("refs/heads/dev").getCommit().name();
+    assertRefs(
+        ImmutableList.of(
+            branch("HEAD", "master", false),
+            branch(RefNames.REFS_CONFIG, null, false),
+            branch("refs/heads/dev", dev, true),
+            branch("refs/heads/master", master, false)),
+        list().get());
+  }
+
+  @Test
+  public void listBranchesSomeHidden() throws Exception {
+    blockRead("refs/heads/dev");
+    String master = pushTo("refs/heads/master").getCommit().name();
+    pushTo("refs/heads/dev");
+    setApiUser(user);
+    // refs/meta/config is hidden since user is no project owner
+    assertRefs(
+        ImmutableList.of(
+            branch("HEAD", "master", false), branch("refs/heads/master", master, false)),
+        list().get());
+  }
+
+  @Test
+  public void listBranchesHeadHidden() throws Exception {
+    blockRead("refs/heads/master");
+    pushTo("refs/heads/master");
+    String dev = pushTo("refs/heads/dev").getCommit().name();
+    setApiUser(user);
+    // refs/meta/config is hidden since user is no project owner
+    assertRefs(ImmutableList.of(branch("refs/heads/dev", dev, false)), list().get());
+  }
+
+  @Test
+  public void listBranchesUsingPagination() throws Exception {
+    pushTo("refs/heads/master");
+    pushTo("refs/heads/someBranch1");
+    pushTo("refs/heads/someBranch2");
+    pushTo("refs/heads/someBranch3");
+
+    // Using only limit.
+    assertRefNames(
+        ImmutableList.of(
+            "HEAD", RefNames.REFS_CONFIG, "refs/heads/master", "refs/heads/someBranch1"),
+        list().withLimit(4).get());
+
+    // Limit higher than total number of branches.
+    assertRefNames(
+        ImmutableList.of(
+            "HEAD",
+            RefNames.REFS_CONFIG,
+            "refs/heads/master",
+            "refs/heads/someBranch1",
+            "refs/heads/someBranch2",
+            "refs/heads/someBranch3"),
+        list().withLimit(25).get());
+
+    // Using start only.
+    assertRefNames(
+        ImmutableList.of(
+            "refs/heads/master",
+            "refs/heads/someBranch1",
+            "refs/heads/someBranch2",
+            "refs/heads/someBranch3"),
+        list().withStart(2).get());
+
+    // Skip more branches than the number of available branches.
+    assertRefNames(ImmutableList.<String>of(), list().withStart(7).get());
+
+    // Ssing start and limit.
+    assertRefNames(
+        ImmutableList.of("refs/heads/master", "refs/heads/someBranch1"),
+        list().withStart(2).withLimit(2).get());
+  }
+
+  @Test
+  public void listBranchesUsingFilter() throws Exception {
+    pushTo("refs/heads/master");
+    pushTo("refs/heads/someBranch1");
+    pushTo("refs/heads/someBranch2");
+    pushTo("refs/heads/someBranch3");
+
+    // Using substring.
+    assertRefNames(
+        ImmutableList.of(
+            "refs/heads/someBranch1", "refs/heads/someBranch2", "refs/heads/someBranch3"),
+        list().withSubstring("some").get());
+
+    assertRefNames(
+        ImmutableList.of(
+            "refs/heads/someBranch1", "refs/heads/someBranch2", "refs/heads/someBranch3"),
+        list().withSubstring("Branch").get());
+
+    assertRefNames(
+        ImmutableList.of(
+            "refs/heads/someBranch1", "refs/heads/someBranch2", "refs/heads/someBranch3"),
+        list().withSubstring("somebranch").get());
+
+    // Using regex.
+    assertRefNames(ImmutableList.of("refs/heads/master"), list().withRegex(".*ast.*r").get());
+    assertRefNames(ImmutableList.of(), list().withRegex(".*AST.*R").get());
+
+    // Conflicting options
+    assertBadRequest(list().withSubstring("somebranch").withRegex(".*ast.*r"));
+  }
+
+  private ListRefsRequest<BranchInfo> list() throws Exception {
+    return gApi.projects().name(project.get()).branches();
+  }
+
+  private static BranchInfo branch(String ref, String revision, boolean canDelete) {
+    BranchInfo info = new BranchInfo();
+    info.ref = ref;
+    info.revision = revision;
+    info.canDelete = canDelete ? true : null;
+    return info;
+  }
+
+  private void assertBadRequest(ListRefsRequest<BranchInfo> req) throws Exception {
+    try {
+      req.get();
+      fail("Expected BadRequestException");
+    } catch (BadRequestException e) {
+      // Expected
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
rename to javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
new file mode 100644
index 0000000..4af329a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -0,0 +1,265 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.Projects.ListRequest;
+import com.google.gerrit.extensions.api.projects.Projects.ListRequest.FilterType;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.testing.Util;
+import java.util.List;
+import java.util.Map;
+import org.junit.Test;
+
+@NoHttpd
+@Sandboxed
+public class ListProjectsIT extends AbstractDaemonTest {
+
+  @Test
+  public void listProjects() throws Exception {
+    Project.NameKey someProject = createProject("some-project");
+    assertThatNameList(filter(gApi.projects().list().get()))
+        .containsExactly(allProjects, allUsers, project, someProject)
+        .inOrder();
+  }
+
+  @Test
+  public void listProjectsFiltersInvisibleProjects() throws Exception {
+    setApiUser(user);
+    assertThatNameList(gApi.projects().list().get()).contains(project);
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(project, cfg);
+
+    assertThatNameList(filter(gApi.projects().list().get())).doesNotContain(project);
+  }
+
+  @Test
+  public void listProjectsWithBranch() throws Exception {
+    Map<String, ProjectInfo> result = gApi.projects().list().addShowBranch("master").getAsMap();
+    assertThat(result).containsKey(project.get());
+    ProjectInfo info = result.get(project.get());
+    assertThat(info.branches).isNotNull();
+    assertThat(info.branches).hasSize(1);
+    assertThat(info.branches.get("master")).isNotNull();
+  }
+
+  @Test
+  @TestProjectInput(description = "Description of some-project")
+  public void listProjectWithDescription() throws Exception {
+    // description not be included in the results by default.
+    Map<String, ProjectInfo> result = gApi.projects().list().getAsMap();
+    assertThat(result).containsKey(project.get());
+    assertThat(result.get(project.get()).description).isNull();
+
+    result = gApi.projects().list().withDescription(true).getAsMap();
+    assertThat(result).containsKey(project.get());
+    assertThat(result.get(project.get()).description).isEqualTo("Description of some-project");
+  }
+
+  @Test
+  public void listProjectsWithLimit() throws Exception {
+    for (int i = 0; i < 5; i++) {
+      createProject("someProject" + i);
+    }
+
+    String p = name("");
+    // 5, plus p which was automatically created.
+    int n = 6;
+    for (int i = 1; i <= n + 2; i++) {
+      assertThatNameList(gApi.projects().list().withPrefix(p).withLimit(i).get())
+          .hasSize(Math.min(i, n));
+    }
+  }
+
+  @Test
+  public void listProjectsWithPrefix() throws Exception {
+    Project.NameKey someProject = createProject("some-project");
+    Project.NameKey someOtherProject = createProject("some-other-project");
+    createProject("project-awesome");
+
+    String p = name("some");
+    assertBadRequest(gApi.projects().list().withPrefix(p).withRegex(".*"));
+    assertBadRequest(gApi.projects().list().withPrefix(p).withSubstring(p));
+    assertThatNameList(filter(gApi.projects().list().withPrefix(p).get()))
+        .containsExactly(someOtherProject, someProject)
+        .inOrder();
+    p = name("SOME");
+    assertThatNameList(filter(gApi.projects().list().withPrefix(p).get())).isEmpty();
+  }
+
+  @Test
+  public void listProjectsWithRegex() throws Exception {
+    Project.NameKey someProject = createProject("some-project");
+    Project.NameKey someOtherProject = createProject("some-other-project");
+    Project.NameKey projectAwesome = createProject("project-awesome");
+
+    assertBadRequest(gApi.projects().list().withRegex("[.*"));
+    assertBadRequest(gApi.projects().list().withRegex(".*").withPrefix("p"));
+    assertBadRequest(gApi.projects().list().withRegex(".*").withSubstring("p"));
+
+    assertThatNameList(filter(gApi.projects().list().withRegex(".*some").get()))
+        .containsExactly(projectAwesome);
+    String r = name("some-project$").replace(".", "\\.");
+    assertThatNameList(filter(gApi.projects().list().withRegex(r).get()))
+        .containsExactly(someProject);
+    assertThatNameList(filter(gApi.projects().list().withRegex(".*").get()))
+        .containsExactly(
+            allProjects, allUsers, project, projectAwesome, someOtherProject, someProject)
+        .inOrder();
+  }
+
+  @Test
+  public void listProjectsWithStart() throws Exception {
+    for (int i = 0; i < 5; i++) {
+      createProject(new Project.NameKey("someProject" + i).get());
+    }
+
+    String p = name("");
+    List<ProjectInfo> all = gApi.projects().list().withPrefix(p).get();
+    // 5, plus p which was automatically created.
+    int n = 6;
+    assertThat(all).hasSize(n);
+    assertThatNameList(gApi.projects().list().withPrefix(p).withStart(n - 1).get())
+        .containsExactly(new Project.NameKey(Iterables.getLast(all).name));
+  }
+
+  @Test
+  public void listProjectsWithSubstring() throws Exception {
+    Project.NameKey someProject = createProject("some-project");
+    Project.NameKey someOtherProject = createProject("some-other-project");
+    Project.NameKey projectAwesome = createProject("project-awesome");
+
+    assertBadRequest(gApi.projects().list().withSubstring("some").withRegex(".*"));
+    assertBadRequest(gApi.projects().list().withSubstring("some").withPrefix("some"));
+    assertThatNameList(filter(gApi.projects().list().withSubstring("some").get()))
+        .containsExactly(projectAwesome, someOtherProject, someProject)
+        .inOrder();
+    assertThatNameList(filter(gApi.projects().list().withSubstring("SOME").get()))
+        .containsExactly(projectAwesome, someOtherProject, someProject)
+        .inOrder();
+  }
+
+  @Test
+  public void listProjectsWithTree() throws Exception {
+    Project.NameKey someParentProject = createProject("some-parent-project");
+    Project.NameKey someChildProject = createProject("some-child-project", someParentProject);
+
+    Map<String, ProjectInfo> result = gApi.projects().list().withTree(true).getAsMap();
+    assertThat(result).containsKey(someChildProject.get());
+    assertThat(result.get(someChildProject.get()).parent).isEqualTo(someParentProject.get());
+  }
+
+  @Test
+  public void listProjectWithType() throws Exception {
+    Map<String, ProjectInfo> result =
+        gApi.projects().list().withType(FilterType.PERMISSIONS).getAsMap();
+    assertThat(result.keySet()).containsExactly(allProjects.get(), allUsers.get());
+
+    assertThatNameList(filter(gApi.projects().list().withType(FilterType.ALL).get()))
+        .containsExactly(allProjects, allUsers, project)
+        .inOrder();
+  }
+
+  @Test
+  public void listWithHiddenAndReadonlyProjects() throws Exception {
+    Project.NameKey hidden = createProject("project-to-hide");
+    Project.NameKey readonly = createProject("project-to-read");
+
+    // Set project read-only
+    ConfigInput input = new ConfigInput();
+    input.state = ProjectState.READ_ONLY;
+    ConfigInfo info = gApi.projects().name(readonly.get()).config(input);
+    assertThat(info.state).isEqualTo(input.state);
+
+    // The hidden project is included because it was not hidden yet.
+    // The read-only project is included.
+    assertThatNameList(gApi.projects().list().get())
+        .containsExactly(allProjects, allUsers, project, hidden, readonly)
+        .inOrder();
+
+    // Hide the project
+    input.state = ProjectState.HIDDEN;
+    info = gApi.projects().name(hidden.get()).config(input);
+    assertThat(info.state).isEqualTo(input.state);
+
+    // Project is still accessible directly
+    gApi.projects().name(hidden.get()).get();
+
+    // Hidden project is not included in the list
+    assertThatNameList(gApi.projects().list().get())
+        .containsExactly(allProjects, allUsers, project, readonly)
+        .inOrder();
+
+    // ALL filter applies to type, and doesn't include hidden state
+    assertThatNameList(gApi.projects().list().withType(FilterType.ALL).get())
+        .containsExactly(allProjects, allUsers, project, readonly)
+        .inOrder();
+
+    // "All" boolean option causes hidden projects to be included
+    assertThatNameList(gApi.projects().list().withAll(true).get())
+        .containsExactly(allProjects, allUsers, project, hidden, readonly)
+        .inOrder();
+
+    // "State" option causes only the projects in that state to be included
+    assertThatNameList(gApi.projects().list().withState(ProjectState.HIDDEN).get())
+        .containsExactly(hidden);
+    assertThatNameList(gApi.projects().list().withState(ProjectState.READ_ONLY).get())
+        .containsExactly(readonly);
+    assertThatNameList(gApi.projects().list().withState(ProjectState.ACTIVE).get())
+        .containsExactly(allProjects, allUsers, project)
+        .inOrder();
+
+    // Cannot use "all" and "state" together
+    assertBadRequest(gApi.projects().list().withAll(true).withState(ProjectState.ACTIVE));
+  }
+
+  private void assertBadRequest(ListRequest req) throws Exception {
+    try {
+      req.get();
+      fail("Expected BadRequestException");
+    } catch (BadRequestException expected) {
+      // Expected.
+    }
+  }
+
+  private Iterable<ProjectInfo> filter(Iterable<ProjectInfo> infos) {
+    String prefix = name("");
+    return Iterables.filter(
+        infos,
+        p -> {
+          return p.name != null
+              && (p.name.equals(allProjects.get())
+                  || p.name.equals(allUsers.get())
+                  || p.name.startsWith(prefix));
+        });
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
rename to javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
rename to javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushAnnotatedTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PushAnnotatedTagIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushAnnotatedTagIT.java
rename to javatests/com/google/gerrit/acceptance/rest/project/PushAnnotatedTagIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushLightweightTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PushLightweightTagIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushLightweightTagIT.java
rename to javatests/com/google/gerrit/acceptance/rest/project/PushLightweightTagIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/RefAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/RefAssert.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/RefAssert.java
rename to javatests/com/google/gerrit/acceptance/rest/project/RefAssert.java
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
new file mode 100644
index 0000000..c9ba851
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -0,0 +1,373 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
+import com.google.gerrit.extensions.api.projects.TagApi;
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import java.sql.Timestamp;
+import java.util.List;
+import org.junit.Test;
+
+@NoHttpd
+public class TagsIT extends AbstractDaemonTest {
+  private static final List<String> testTags =
+      ImmutableList.of("tag-A", "tag-B", "tag-C", "tag-D", "tag-E", "tag-F", "tag-G", "tag-H");
+
+  private static final String SIGNED_ANNOTATION =
+      "annotation\n"
+          + "-----BEGIN PGP SIGNATURE-----\n"
+          + "Version: GnuPG v1\n"
+          + "\n"
+          + "iQEcBAABAgAGBQJVeGg5AAoJEPfTicJkUdPkUggH/RKAeI9/i/LduuiqrL/SSdIa\n"
+          + "9tYaSqJKLbXz63M/AW4Sp+4u+dVCQvnAt/a35CVEnpZz6hN4Kn/tiswOWVJf4CO7\n"
+          + "htNubGs5ZMwvD6sLYqKAnrM3WxV/2TbbjzjZW6Jkidz3jz/WRT4SmjGYiEO7aA+V\n"
+          + "4ZdIS9f7sW5VsHHYlNThCA7vH8Uu48bUovFXyQlPTX0pToSgrWV3JnTxDNxfn3iG\n"
+          + "IL0zTY/qwVCdXgFownLcs6J050xrrBWIKqfcWr3u4D2aCLyR0v+S/KArr7ulZygY\n"
+          + "+SOklImn8TAZiNxhWtA6ens66IiammUkZYFv7SSzoPLFZT4dC84SmGPWgf94NoQ=\n"
+          + "=XFeC\n"
+          + "-----END PGP SIGNATURE-----";
+
+  @Test
+  public void listTagsOfNonExistingProject() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.projects().name("does-not-exist").tags().get();
+  }
+
+  @Test
+  public void getTagOfNonExistingProject() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.projects().name("does-not-exist").tag("tag").get();
+  }
+
+  @Test
+  public void listTagsOfNonVisibleProject() throws Exception {
+    blockRead("refs/*");
+    setApiUser(user);
+    exception.expect(ResourceNotFoundException.class);
+    gApi.projects().name(project.get()).tags().get();
+  }
+
+  @Test
+  public void getTagOfNonVisibleProject() throws Exception {
+    blockRead("refs/*");
+    exception.expect(ResourceNotFoundException.class);
+    gApi.projects().name(project.get()).tag("tag").get();
+  }
+
+  @Test
+  public void listTags() throws Exception {
+    createTags();
+
+    // No options
+    List<TagInfo> result = getTags().get();
+    assertTagList(FluentIterable.from(testTags), result);
+
+    // With start option
+    result = getTags().withStart(1).get();
+    assertTagList(FluentIterable.from(testTags).skip(1), result);
+
+    // With limit option
+    int limit = testTags.size() - 1;
+    result = getTags().withLimit(limit).get();
+    assertTagList(FluentIterable.from(testTags).limit(limit), result);
+
+    // With both start and limit
+    limit = testTags.size() - 3;
+    result = getTags().withStart(1).withLimit(limit).get();
+    assertTagList(FluentIterable.from(testTags).skip(1).limit(limit), result);
+
+    // With regular expression filter
+    result = getTags().withRegex("^tag-[C|D]$").get();
+    assertTagList(FluentIterable.from(ImmutableList.of("tag-C", "tag-D")), result);
+
+    result = getTags().withRegex("^tag-[c|d]$").get();
+    assertTagList(FluentIterable.from(ImmutableList.of()), result);
+
+    // With substring filter
+    result = getTags().withSubstring("tag-").get();
+    assertTagList(FluentIterable.from(testTags), result);
+    result = getTags().withSubstring("ag-B").get();
+    assertTagList(FluentIterable.from(ImmutableList.of("tag-B")), result);
+
+    // With conflicting options
+    assertBadRequest(getTags().withSubstring("ag-B").withRegex("^tag-[c|d]$"));
+  }
+
+  @Test
+  public void listTagsOfNonVisibleBranch() throws Exception {
+    grantTagPermissions();
+
+    PushOneCommit push1 = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r1 = push1.to("refs/heads/master");
+    r1.assertOkStatus();
+    TagInput tag1 = new TagInput();
+    tag1.ref = "v1.0";
+    tag1.revision = r1.getCommit().getName();
+    TagInfo result = tag(tag1.ref).create(tag1).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + tag1.ref);
+    assertThat(result.revision).isEqualTo(tag1.revision);
+
+    pushTo("refs/heads/hidden");
+    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r2 = push2.to("refs/heads/hidden");
+    r2.assertOkStatus();
+
+    TagInput tag2 = new TagInput();
+    tag2.ref = "v2.0";
+    tag2.revision = r2.getCommit().getName();
+    result = tag(tag2.ref).create(tag2).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + tag2.ref);
+    assertThat(result.revision).isEqualTo(tag2.revision);
+
+    List<TagInfo> tags = getTags().get();
+    assertThat(tags).hasSize(2);
+    assertThat(tags.get(0).ref).isEqualTo(R_TAGS + tag1.ref);
+    assertThat(tags.get(0).revision).isEqualTo(tag1.revision);
+    assertThat(tags.get(1).ref).isEqualTo(R_TAGS + tag2.ref);
+    assertThat(tags.get(1).revision).isEqualTo(tag2.revision);
+
+    blockRead("refs/heads/hidden");
+    tags = getTags().get();
+    assertThat(tags).hasSize(1);
+    assertThat(tags.get(0).ref).isEqualTo(R_TAGS + tag1.ref);
+    assertThat(tags.get(0).revision).isEqualTo(tag1.revision);
+  }
+
+  @Test
+  public void lightweightTag() throws Exception {
+    grantTagPermissions();
+
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+
+    TagInput input = new TagInput();
+    input.ref = "v1.0";
+    input.revision = r.getCommit().getName();
+
+    TagInfo result = tag(input.ref).create(input).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + input.ref);
+    assertThat(result.revision).isEqualTo(input.revision);
+    assertThat(result.canDelete).isTrue();
+    assertThat(result.created).isEqualTo(timestamp(r));
+
+    input.ref = "refs/tags/v2.0";
+    result = tag(input.ref).create(input).get();
+    assertThat(result.ref).isEqualTo(input.ref);
+    assertThat(result.revision).isEqualTo(input.revision);
+    assertThat(result.canDelete).isTrue();
+    assertThat(result.created).isEqualTo(timestamp(r));
+
+    setApiUser(user);
+    result = tag(input.ref).get();
+    assertThat(result.canDelete).isNull();
+
+    eventRecorder.assertRefUpdatedEvents(project.get(), result.ref, null, result.revision);
+  }
+
+  @Test
+  public void annotatedTag() throws Exception {
+    grantTagPermissions();
+
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+
+    TagInput input = new TagInput();
+    input.ref = "v1.0";
+    input.revision = r.getCommit().getName();
+    input.message = "annotation message";
+
+    TagInfo result = tag(input.ref).create(input).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + input.ref);
+    assertThat(result.object).isEqualTo(input.revision);
+    assertThat(result.message).isEqualTo(input.message);
+    assertThat(result.tagger.name).isEqualTo(admin.fullName);
+    assertThat(result.tagger.email).isEqualTo(admin.email);
+    assertThat(result.created).isEqualTo(result.tagger.date);
+
+    eventRecorder.assertRefUpdatedEvents(project.get(), result.ref, null, result.revision);
+
+    // A second tag pushed on the same ref should have the same ref
+    TagInput input2 = new TagInput();
+    input2.ref = "refs/tags/v2.0";
+    input2.revision = input.revision;
+    input2.message = "second annotation message";
+    TagInfo result2 = tag(input2.ref).create(input2).get();
+    assertThat(result2.ref).isEqualTo(input2.ref);
+    assertThat(result2.object).isEqualTo(input2.revision);
+    assertThat(result2.message).isEqualTo(input2.message);
+    assertThat(result2.tagger.name).isEqualTo(admin.fullName);
+    assertThat(result2.tagger.email).isEqualTo(admin.email);
+    assertThat(result2.created).isEqualTo(result2.tagger.date);
+
+    eventRecorder.assertRefUpdatedEvents(project.get(), result2.ref, null, result2.revision);
+  }
+
+  @Test
+  public void createExistingTag() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "test";
+    TagInfo result = tag(input.ref).create(input).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + "test");
+
+    input.ref = "refs/tags/test";
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("tag \"" + R_TAGS + "test\" already exists");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void createTagNotAllowed() throws Exception {
+    block(R_TAGS + "*", Permission.CREATE, REGISTERED_USERS);
+    TagInput input = new TagInput();
+    input.ref = "test";
+    exception.expect(AuthException.class);
+    exception.expectMessage("create not permitted");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void createAnnotatedTagNotAllowed() throws Exception {
+    block(R_TAGS + "*", Permission.CREATE_TAG, REGISTERED_USERS);
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.message = "annotation";
+    exception.expect(AuthException.class);
+    exception.expectMessage("Cannot create annotated tag \"" + R_TAGS + "test\"");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void createSignedTagNotSupported() throws Exception {
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.message = SIGNED_ANNOTATION;
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("Cannot create signed tag \"" + R_TAGS + "test\"");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void mismatchedInput() throws Exception {
+    TagInput input = new TagInput();
+    input.ref = "test";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("ref must match URL");
+    tag("TEST").create(input);
+  }
+
+  @Test
+  public void invalidTagName() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "refs/heads/test";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid tag name \"" + input.ref + "\"");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void invalidTagNameOnlySlashes() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "//";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid tag name \"refs/tags/\"");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void invalidBaseRevision() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.revision = "abcdefg";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Invalid base revision");
+    tag(input.ref).create(input);
+  }
+
+  private void assertTagList(FluentIterable<String> expected, List<TagInfo> actual)
+      throws Exception {
+    assertThat(actual).hasSize(expected.size());
+    for (int i = 0; i < expected.size(); i++) {
+      TagInfo info = actual.get(i);
+      assertThat(info.created).isNotNull();
+      assertThat(info.ref).isEqualTo(R_TAGS + expected.get(i));
+    }
+  }
+
+  private void createTags() throws Exception {
+    grantTagPermissions();
+
+    String revision = pushTo("refs/heads/master").getCommit().name();
+    TagInput input = new TagInput();
+    input.revision = revision;
+
+    for (String tagname : testTags) {
+      TagInfo result = tag(tagname).create(input).get();
+      assertThat(result.revision).isEqualTo(input.revision);
+      assertThat(result.ref).isEqualTo(R_TAGS + tagname);
+    }
+  }
+
+  private ListRefsRequest<TagInfo> getTags() throws Exception {
+    return gApi.projects().name(project.get()).tags();
+  }
+
+  private TagApi tag(String tagname) throws Exception {
+    return gApi.projects().name(project.get()).tag(tagname);
+  }
+
+  private Timestamp timestamp(PushOneCommit.Result r) {
+    return new Timestamp(r.getCommit().getCommitterIdent().getWhen().getTime());
+  }
+
+  private void assertBadRequest(ListRefsRequest<TagInfo> req) throws Exception {
+    try {
+      req.get();
+      fail("Expected BadRequestException");
+    } catch (BadRequestException e) {
+      // Expected
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/revision/BUILD b/javatests/com/google/gerrit/acceptance/rest/revision/BUILD
new file mode 100644
index 0000000..10839f2
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/revision/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "rest_revision",
+    labels = ["rest"],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/rest/revision/RevisionIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/RevisionIT.java
rename to javatests/com/google/gerrit/acceptance/rest/revision/RevisionIT.java
diff --git a/javatests/com/google/gerrit/acceptance/server/change/BUILD b/javatests/com/google/gerrit/acceptance/server/change/BUILD
new file mode 100644
index 0000000..6c79618
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_change",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
new file mode 100644
index 0000000..343eacc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -0,0 +1,1204 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static java.util.stream.Collectors.groupingBy;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.notedb.DeleteCommentRewriter;
+import com.google.gerrit.server.restapi.change.ChangesCollection;
+import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.testing.FakeEmailSender;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.function.Supplier;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class CommentsIT extends AbstractDaemonTest {
+
+  @Inject private Provider<ChangesCollection> changes;
+
+  @Inject private Provider<PostReview> postReview;
+
+  @Inject private FakeEmailSender email;
+
+  @Inject private ChangeNoteUtil noteUtil;
+
+  private final Integer[] lines = {0, 1};
+
+  @Before
+  public void setUp() {
+    setApiUser(user);
+  }
+
+  @Test
+  public void getNonExistingComment() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    exception.expect(ResourceNotFoundException.class);
+    getPublishedComment(changeId, revId, "non-existing");
+  }
+
+  @Test
+  public void createDraft() throws Exception {
+    for (Integer line : lines) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      String path = "file1";
+      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
+      addDraft(changeId, revId, comment);
+      Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
+      assertThat(result).hasSize(1);
+      CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
+      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+    }
+  }
+
+  @Test
+  public void createDraftOnMergeCommitChange() throws Exception {
+    for (Integer line : lines) {
+      PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      String path = "file1";
+      DraftInput c1 = newDraft(path, Side.REVISION, line, "ps-1");
+      DraftInput c2 = newDraft(path, Side.PARENT, line, "auto-merge of ps-1");
+      DraftInput c3 = newDraftOnParent(path, 1, line, "parent-1 of ps-1");
+      DraftInput c4 = newDraftOnParent(path, 2, line, "parent-2 of ps-1");
+      addDraft(changeId, revId, c1);
+      addDraft(changeId, revId, c2);
+      addDraft(changeId, revId, c3);
+      addDraft(changeId, revId, c4);
+      Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
+      assertThat(result).hasSize(1);
+      assertThat(Lists.transform(result.get(path), infoToDraft(path)))
+          .containsExactly(c1, c2, c3, c4);
+    }
+  }
+
+  @Test
+  public void postComment() throws Exception {
+    for (Integer line : lines) {
+      String file = "file";
+      String contents = "contents " + line;
+      PushOneCommit push =
+          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
+      PushOneCommit.Result r = push.to("refs/for/master");
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      ReviewInput input = new ReviewInput();
+      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
+      input.comments = new HashMap<>();
+      input.comments.put(comment.path, Lists.newArrayList(comment));
+      revision(r).review(input);
+      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+      assertThat(result).isNotEmpty();
+      CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
+      assertThat(comment).isEqualTo(infoToInput(file).apply(actual));
+      assertThat(comment)
+          .isEqualTo(infoToInput(file).apply(getPublishedComment(changeId, revId, actual.id)));
+    }
+  }
+
+  @Test
+  public void postCommentWithReply() throws Exception {
+    for (Integer line : lines) {
+      String file = "file";
+      String contents = "contents " + line;
+      PushOneCommit push =
+          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
+      PushOneCommit.Result r = push.to("refs/for/master");
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      ReviewInput input = new ReviewInput();
+      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
+      input.comments = new HashMap<>();
+      input.comments.put(comment.path, Lists.newArrayList(comment));
+      revision(r).review(input);
+      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+      CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
+
+      input = new ReviewInput();
+      comment = newComment(file, Side.REVISION, line, "comment 1 reply", false);
+      comment.inReplyTo = actual.id;
+      input.comments = new HashMap<>();
+      input.comments.put(comment.path, Lists.newArrayList(comment));
+      revision(r).review(input);
+      result = getPublishedComments(changeId, revId);
+      actual = result.get(comment.path).get(1);
+      assertThat(comment).isEqualTo(infoToInput(file).apply(actual));
+      assertThat(comment)
+          .isEqualTo(infoToInput(file).apply(getPublishedComment(changeId, revId, actual.id)));
+    }
+  }
+
+  @Test
+  public void postCommentWithUnresolved() throws Exception {
+    for (Integer line : lines) {
+      String file = "file";
+      String contents = "contents " + line;
+      PushOneCommit push =
+          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
+      PushOneCommit.Result r = push.to("refs/for/master");
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      ReviewInput input = new ReviewInput();
+      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", true);
+      input.comments = new HashMap<>();
+      input.comments.put(comment.path, Lists.newArrayList(comment));
+      revision(r).review(input);
+      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+      assertThat(result).isNotEmpty();
+      CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
+      assertThat(comment).isEqualTo(infoToInput(file).apply(actual));
+      assertThat(comment)
+          .isEqualTo(infoToInput(file).apply(getPublishedComment(changeId, revId, actual.id)));
+    }
+  }
+
+  @Test
+  public void postCommentOnMergeCommitChange() throws Exception {
+    for (Integer line : lines) {
+      String file = "foo";
+      PushOneCommit.Result r = createMergeCommitChange("refs/for/master", file);
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      ReviewInput input = new ReviewInput();
+      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1", false);
+      CommentInput c2 = newComment(file, Side.PARENT, line, "auto-merge of ps-1", false);
+      CommentInput c3 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
+      CommentInput c4 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
+      input.comments = new HashMap<>();
+      input.comments.put(file, ImmutableList.of(c1, c2, c3, c4));
+      revision(r).review(input);
+      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+      assertThat(result).isNotEmpty();
+      assertThat(Lists.transform(result.get(file), infoToInput(file)))
+          .containsExactly(c1, c2, c3, c4);
+    }
+
+    // for the commit message comments on the auto-merge are not possible
+    for (Integer line : lines) {
+      String file = Patch.COMMIT_MSG;
+      PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      ReviewInput input = new ReviewInput();
+      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1", false);
+      CommentInput c2 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
+      CommentInput c3 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
+      input.comments = new HashMap<>();
+      input.comments.put(file, ImmutableList.of(c1, c2, c3));
+      revision(r).review(input);
+      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+      assertThat(result).isNotEmpty();
+      assertThat(Lists.transform(result.get(file), infoToInput(file))).containsExactly(c1, c2, c3);
+    }
+  }
+
+  @Test
+  public void postCommentOnCommitMessageOnAutoMerge() throws Exception {
+    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+    ReviewInput input = new ReviewInput();
+    CommentInput c = newComment(Patch.COMMIT_MSG, Side.PARENT, 0, "comment on auto-merge", false);
+    input.comments = new HashMap<>();
+    input.comments.put(Patch.COMMIT_MSG, ImmutableList.of(c));
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("cannot comment on " + Patch.COMMIT_MSG + " on auto-merge");
+    revision(r).review(input);
+  }
+
+  @Test
+  public void listComments() throws Exception {
+    String file = "file";
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, "contents");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    assertThat(getPublishedComments(changeId, revId)).isEmpty();
+
+    List<CommentInput> expectedComments = new ArrayList<>();
+    for (Integer line : lines) {
+      ReviewInput input = new ReviewInput();
+      CommentInput comment = newComment(file, Side.REVISION, line, "comment " + line, false);
+      expectedComments.add(comment);
+      input.comments = new HashMap<>();
+      input.comments.put(comment.path, Lists.newArrayList(comment));
+      revision(r).review(input);
+    }
+
+    Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+    assertThat(result).isNotEmpty();
+    List<CommentInfo> actualComments = result.get(file);
+    assertThat(Lists.transform(actualComments, infoToInput(file)))
+        .containsExactlyElementsIn(expectedComments);
+  }
+
+  @Test
+  public void putDraft() throws Exception {
+    for (Integer line : lines) {
+      PushOneCommit.Result r = createChange();
+      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      String path = "file1";
+      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
+      addDraft(changeId, revId, comment);
+      Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
+      CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
+      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+      String uuid = actual.id;
+      comment.message = "updated comment 1";
+      updateDraft(changeId, revId, comment, uuid);
+      result = getDraftComments(changeId, revId);
+      actual = Iterables.getOnlyElement(result.get(comment.path));
+      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+
+      // Posting a draft comment doesn't cause lastUpdatedOn to change.
+      assertThat(r.getChange().change().getLastUpdatedOn()).isEqualTo(origLastUpdated);
+    }
+  }
+
+  @Test
+  public void listDrafts() throws Exception {
+    String file = "file";
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    assertThat(getDraftComments(changeId, revId)).isEmpty();
+
+    List<DraftInput> expectedDrafts = new ArrayList<>();
+    for (Integer line : lines) {
+      DraftInput comment = newDraft(file, Side.REVISION, line, "comment " + line);
+      expectedDrafts.add(comment);
+      addDraft(changeId, revId, comment);
+    }
+
+    Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
+    assertThat(result).isNotEmpty();
+    List<CommentInfo> actualComments = result.get(file);
+    assertThat(Lists.transform(actualComments, infoToDraft(file)))
+        .containsExactlyElementsIn(expectedDrafts);
+  }
+
+  @Test
+  public void getDraft() throws Exception {
+    for (Integer line : lines) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      String path = "file1";
+      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
+      CommentInfo returned = addDraft(changeId, revId, comment);
+      CommentInfo actual = getDraftComment(changeId, revId, returned.id);
+      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+    }
+  }
+
+  @Test
+  public void deleteDraft() throws Exception {
+    for (Integer line : lines) {
+      PushOneCommit.Result r = createChange();
+      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      DraftInput draft = newDraft("file1", Side.REVISION, line, "comment 1");
+      CommentInfo returned = addDraft(changeId, revId, draft);
+      deleteDraft(changeId, revId, returned.id);
+      Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
+      assertThat(drafts).isEmpty();
+
+      // Deleting a draft comment doesn't cause lastUpdatedOn to change.
+      assertThat(r.getChange().change().getLastUpdatedOn()).isEqualTo(origLastUpdated);
+    }
+  }
+
+  @Test
+  public void insertCommentsWithHistoricTimestamp() throws Exception {
+    Timestamp timestamp = new Timestamp(0);
+    for (Integer line : lines) {
+      String file = "file";
+      String contents = "contents " + line;
+      PushOneCommit push =
+          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
+      PushOneCommit.Result r = push.to("refs/for/master");
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
+
+      ReviewInput input = new ReviewInput();
+      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
+      comment.updated = timestamp;
+      input.comments = new HashMap<>();
+      input.comments.put(comment.path, Lists.newArrayList(comment));
+      ChangeResource changeRsrc =
+          changes.get().parse(TopLevelResource.INSTANCE, IdString.fromDecoded(changeId));
+      RevisionResource revRsrc = revisions.parse(changeRsrc, IdString.fromDecoded(revId));
+      postReview.get().apply(batchUpdateFactory, revRsrc, input, timestamp);
+      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+      assertThat(result).isNotEmpty();
+      CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
+      CommentInput ci = infoToInput(file).apply(actual);
+      ci.updated = comment.updated;
+      assertThat(comment).isEqualTo(ci);
+      assertThat(actual.updated).isEqualTo(gApi.changes().id(r.getChangeId()).info().created);
+
+      // Updating historic comments doesn't cause lastUpdatedOn to regress.
+      assertThat(r.getChange().change().getLastUpdatedOn()).isEqualTo(origLastUpdated);
+    }
+  }
+
+  @Test
+  public void addDuplicateComments() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    String changeId = r1.getChangeId();
+    String revId = r1.getCommit().getName();
+    addComment(r1, "nit: trailing whitespace");
+    addComment(r1, "nit: trailing whitespace");
+    Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+    assertThat(result.get(FILE_NAME)).hasSize(2);
+    addComment(r1, "nit: trailing whitespace", true, false, null);
+    result = getPublishedComments(changeId, revId);
+    assertThat(result.get(FILE_NAME)).hasSize(2);
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "content")
+            .to("refs/for/master");
+    changeId = r2.getChangeId();
+    revId = r2.getCommit().getName();
+    addComment(r2, "nit: trailing whitespace", true, false, null);
+    result = getPublishedComments(changeId, revId);
+    assertThat(result.get(FILE_NAME)).hasSize(1);
+  }
+
+  @Test
+  public void listChangeDrafts() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
+            .to("refs/for/master");
+
+    setApiUser(admin);
+    addDraft(
+        r1.getChangeId(),
+        r1.getCommit().getName(),
+        newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
+    addDraft(
+        r2.getChangeId(),
+        r2.getCommit().getName(),
+        newDraft(FILE_NAME, Side.REVISION, 1, "typo: content"));
+
+    setApiUser(user);
+    addDraft(
+        r2.getChangeId(),
+        r2.getCommit().getName(),
+        newDraft(FILE_NAME, Side.REVISION, 1, "+1, please fix"));
+
+    setApiUser(admin);
+    Map<String, List<CommentInfo>> actual = gApi.changes().id(r1.getChangeId()).drafts();
+    assertThat(actual.keySet()).containsExactly(FILE_NAME);
+    List<CommentInfo> comments = actual.get(FILE_NAME);
+    assertThat(comments).hasSize(2);
+
+    CommentInfo c1 = comments.get(0);
+    assertThat(c1.author).isNull();
+    assertThat(c1.patchSet).isEqualTo(1);
+    assertThat(c1.message).isEqualTo("nit: trailing whitespace");
+    assertThat(c1.side).isNull();
+    assertThat(c1.line).isEqualTo(1);
+
+    CommentInfo c2 = comments.get(1);
+    assertThat(c2.author).isNull();
+    assertThat(c2.patchSet).isEqualTo(2);
+    assertThat(c2.message).isEqualTo("typo: content");
+    assertThat(c2.side).isNull();
+    assertThat(c2.line).isEqualTo(1);
+  }
+
+  @Test
+  public void listChangeComments() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new cntent", r1.getChangeId())
+            .to("refs/for/master");
+
+    addComment(r1, "nit: trailing whitespace");
+    addComment(r2, "typo: content");
+
+    Map<String, List<CommentInfo>> actual = gApi.changes().id(r2.getChangeId()).comments();
+    assertThat(actual.keySet()).containsExactly(FILE_NAME);
+
+    List<CommentInfo> comments = actual.get(FILE_NAME);
+    assertThat(comments).hasSize(2);
+
+    CommentInfo c1 = comments.get(0);
+    assertThat(c1.author._accountId).isEqualTo(user.getId().get());
+    assertThat(c1.patchSet).isEqualTo(1);
+    assertThat(c1.message).isEqualTo("nit: trailing whitespace");
+    assertThat(c1.side).isNull();
+    assertThat(c1.line).isEqualTo(1);
+
+    CommentInfo c2 = comments.get(1);
+    assertThat(c2.author._accountId).isEqualTo(user.getId().get());
+    assertThat(c2.patchSet).isEqualTo(2);
+    assertThat(c2.message).isEqualTo("typo: content");
+    assertThat(c2.side).isNull();
+    assertThat(c2.line).isEqualTo(1);
+  }
+
+  @Test
+  public void listChangeWithDrafts() throws Exception {
+    for (Integer line : lines) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      DraftInput comment = newDraft("file1", Side.REVISION, line, "comment 1");
+      addDraft(changeId, revId, comment);
+      assertThat(gApi.changes().query("change:" + changeId + " has:draft").get()).hasSize(1);
+    }
+  }
+
+  @Test
+  public void publishCommentsAllRevisions() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                testRepo,
+                SUBJECT,
+                FILE_NAME,
+                "new\ncntent\n",
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    addDraft(
+        r1.getChangeId(),
+        r1.getCommit().getName(),
+        newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
+    addDraft(
+        r1.getChangeId(),
+        r1.getCommit().getName(),
+        newDraft(FILE_NAME, Side.PARENT, 2, "what happened to this?"));
+    addDraft(
+        r2.getChangeId(),
+        r2.getCommit().getName(),
+        newDraft(FILE_NAME, Side.REVISION, 1, "join lines"));
+    addDraft(
+        r2.getChangeId(),
+        r2.getCommit().getName(),
+        newDraft(FILE_NAME, Side.REVISION, 2, "typo: content"));
+    addDraft(
+        r2.getChangeId(),
+        r2.getCommit().getName(),
+        newDraft(FILE_NAME, Side.PARENT, 1, "comment 1 on base"));
+    addDraft(
+        r2.getChangeId(),
+        r2.getCommit().getName(),
+        newDraft(FILE_NAME, Side.PARENT, 2, "comment 2 on base"));
+
+    PushOneCommit.Result other = createChange();
+    // Drafts on other changes aren't returned.
+    addDraft(
+        other.getChangeId(),
+        other.getCommit().getName(),
+        newDraft(FILE_NAME, Side.REVISION, 1, "unrelated comment"));
+
+    setApiUser(admin);
+    // Drafts by other users aren't returned.
+    addDraft(
+        r2.getChangeId(), r2.getCommit().getName(), newDraft(FILE_NAME, Side.REVISION, 2, "oops"));
+    setApiUser(user);
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    reviewInput.message = "comments";
+    gApi.changes().id(r2.getChangeId()).current().review(reviewInput);
+
+    assertThat(gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).drafts())
+        .isEmpty();
+    Map<String, List<CommentInfo>> ps1Map =
+        gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).comments();
+    assertThat(ps1Map.keySet()).containsExactly(FILE_NAME);
+    List<CommentInfo> ps1List = ps1Map.get(FILE_NAME);
+    assertThat(ps1List).hasSize(2);
+    assertThat(ps1List.get(0).message).isEqualTo("what happened to this?");
+    assertThat(ps1List.get(0).side).isEqualTo(Side.PARENT);
+    assertThat(ps1List.get(1).message).isEqualTo("nit: trailing whitespace");
+    assertThat(ps1List.get(1).side).isNull();
+
+    assertThat(gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).drafts())
+        .isEmpty();
+    Map<String, List<CommentInfo>> ps2Map =
+        gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).comments();
+    assertThat(ps2Map.keySet()).containsExactly(FILE_NAME);
+    List<CommentInfo> ps2List = ps2Map.get(FILE_NAME);
+    assertThat(ps2List).hasSize(4);
+    assertThat(ps2List.get(0).message).isEqualTo("comment 1 on base");
+    assertThat(ps2List.get(1).message).isEqualTo("comment 2 on base");
+    assertThat(ps2List.get(2).message).isEqualTo("join lines");
+    assertThat(ps2List.get(3).message).isEqualTo("typo: content");
+
+    List<Message> messages = email.getMessages(r2.getChangeId(), "comment");
+    assertThat(messages).hasSize(1);
+    String url = canonicalWebUrl.get();
+    int c = r1.getChange().getId().get();
+    assertThat(extractComments(messages.get(0).body()))
+        .isEqualTo(
+            "Patch Set 2:\n"
+                + "\n"
+                + "(6 comments)\n"
+                + "\n"
+                + "comments\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/1/a.txt\n"
+                + "File a.txt:\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/1/a.txt@a2\n"
+                + "PS1, Line 2: \n"
+                + "what happened to this?\n"
+                + "\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/1/a.txt@1\n"
+                + "PS1, Line 1: ew\n"
+                + "nit: trailing whitespace\n"
+                + "\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/2/a.txt\n"
+                + "File a.txt:\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/2/a.txt@a1\n"
+                + "PS2, Line 1: \n"
+                + "comment 1 on base\n"
+                + "\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/2/a.txt@a2\n"
+                + "PS2, Line 2: \n"
+                + "comment 2 on base\n"
+                + "\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/2/a.txt@1\n"
+                + "PS2, Line 1: ew\n"
+                + "join lines\n"
+                + "\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/2/a.txt@2\n"
+                + "PS2, Line 2: nten\n"
+                + "typo: content\n"
+                + "\n"
+                + "\n");
+  }
+
+  @Test
+  public void commentTags() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    CommentInput pub = new CommentInput();
+    pub.line = 1;
+    pub.message = "published comment";
+    pub.path = FILE_NAME;
+    ReviewInput rin = newInput(pub);
+    rin.tag = "tag1";
+    gApi.changes().id(r.getChangeId()).current().review(rin);
+
+    List<CommentInfo> comments = gApi.changes().id(r.getChangeId()).current().commentsAsList();
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).tag).isEqualTo("tag1");
+
+    DraftInput draft = new DraftInput();
+    draft.line = 2;
+    draft.message = "draft comment";
+    draft.path = FILE_NAME;
+    draft.tag = "tag2";
+    addDraft(r.getChangeId(), r.getCommit().name(), draft);
+
+    List<CommentInfo> drafts = gApi.changes().id(r.getChangeId()).current().draftsAsList();
+    assertThat(drafts).hasSize(1);
+    assertThat(drafts.get(0).tag).isEqualTo("tag2");
+  }
+
+  @Test
+  public void queryChangesWithUnresolvedCommentCount() throws Exception {
+    // PS1 has three comments in three different threads, PS2 has one comment in one thread.
+    PushOneCommit.Result result = createChange("change 1", FILE_NAME, "content 1");
+    String changeId1 = result.getChangeId();
+    addComment(result, "comment 1", false, true, null);
+    addComment(result, "comment 2", false, null, null);
+    addComment(result, "comment 3", false, false, null);
+    PushOneCommit.Result result2 = amendChange(changeId1);
+    addComment(result2, "comment4", false, true, null);
+
+    // Change2 has two comments in one thread, the first is unresolved and the second is resolved.
+    result = createChange("change 2", FILE_NAME, "content 2");
+    String changeId2 = result.getChangeId();
+    addComment(result, "comment 1", false, true, null);
+    Map<String, List<CommentInfo>> comments =
+        getPublishedComments(changeId2, result.getCommit().name());
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(FILE_NAME)).hasSize(1);
+    addComment(result, "comment 2", false, false, comments.get(FILE_NAME).get(0).id);
+
+    // Change3 has two comments in one thread, the first is resolved, the second is unresolved.
+    result = createChange("change 3", FILE_NAME, "content 3");
+    String changeId3 = result.getChangeId();
+    addComment(result, "comment 1", false, false, null);
+    comments = getPublishedComments(result.getChangeId(), result.getCommit().name());
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(FILE_NAME)).hasSize(1);
+    addComment(result, "comment 2", false, true, comments.get(FILE_NAME).get(0).id);
+
+    AcceptanceTestRequestScope.Context ctx = disableDb();
+    try {
+      ChangeInfo changeInfo1 = Iterables.getOnlyElement(query(changeId1));
+      ChangeInfo changeInfo2 = Iterables.getOnlyElement(query(changeId2));
+      ChangeInfo changeInfo3 = Iterables.getOnlyElement(query(changeId3));
+      assertThat(changeInfo1.unresolvedCommentCount).isEqualTo(2);
+      assertThat(changeInfo2.unresolvedCommentCount).isEqualTo(0);
+      assertThat(changeInfo3.unresolvedCommentCount).isEqualTo(1);
+    } finally {
+      enableDb(ctx);
+    }
+  }
+
+  @Test
+  public void deleteCommentCannotBeAppliedByUser() throws Exception {
+    PushOneCommit.Result result = createChange();
+    CommentInput targetComment = addComment(result.getChangeId(), "My password: abc123");
+
+    Map<String, List<CommentInfo>> commentsMap =
+        getPublishedComments(result.getChangeId(), result.getCommit().name());
+
+    assertThat(commentsMap.size()).isEqualTo(1);
+    assertThat(commentsMap.get(FILE_NAME)).hasSize(1);
+
+    String uuid = commentsMap.get(targetComment.path).get(0).id;
+    DeleteCommentInput input = new DeleteCommentInput("contains confidential information");
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.changes().id(result.getChangeId()).current().comment(uuid).delete(input);
+  }
+
+  @Test
+  public void deleteCommentByRewritingCommitHistory() throws Exception {
+    // Creates the following commit history on the meta branch of the test change. Then tries to
+    // delete the comments one by one, which will rewrite most of the commits on the 'meta' branch.
+    // Commits will be rewritten N times for N added comments. After each deletion, the meta branch
+    // should keep its previous state except that the target comment's message should be updated.
+
+    // 1st commit: Create PS1.
+    PushOneCommit.Result result1 = createChange(SUBJECT, "a.txt", "a");
+    Change.Id id = result1.getChange().getId();
+    String changeId = result1.getChangeId();
+    String ps1 = result1.getCommit().name();
+
+    // 2nd commit: Add (c1) to PS1.
+    CommentInput c1 = newComment("a.txt", "comment 1");
+    addComments(changeId, ps1, c1);
+
+    // 3rd commit: Add (c2, c3) to PS1.
+    CommentInput c2 = newComment("a.txt", "comment 2");
+    CommentInput c3 = newComment("a.txt", "comment 3");
+    addComments(changeId, ps1, c2, c3);
+
+    // 4th commit: Add (c4) to PS1.
+    CommentInput c4 = newComment("a.txt", "comment 4");
+    addComments(changeId, ps1, c4);
+
+    // 5th commit: Create PS2.
+    PushOneCommit.Result result2 = amendChange(changeId, "refs/for/master", "b.txt", "b");
+    String ps2 = result2.getCommit().name();
+
+    // 6th commit: Add (c5) to PS1.
+    CommentInput c5 = newComment("a.txt", "comment 5");
+    addComments(changeId, ps1, c5);
+
+    // 7th commit: Add (c6) to PS2.
+    CommentInput c6 = newComment("b.txt", "comment 6");
+    addComments(changeId, ps2, c6);
+
+    // 8th commit: Create PS3.
+    PushOneCommit.Result result3 = amendChange(changeId);
+    String ps3 = result3.getCommit().name();
+
+    // 9th commit: Create PS4.
+    PushOneCommit.Result result4 = amendChange(changeId, "refs/for/master", "c.txt", "c");
+    String ps4 = result4.getCommit().name();
+
+    // 10th commit: Add (c7, c8) to PS4.
+    CommentInput c7 = newComment("c.txt", "comment 7");
+    CommentInput c8 = newComment("b.txt", "comment 8");
+    addComments(changeId, ps4, c7, c8);
+
+    // 11th commit: Add (c9) to PS2.
+    CommentInput c9 = newComment("b.txt", "comment 9");
+    addComments(changeId, ps2, c9);
+
+    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(changeId);
+    assertThat(commentsBeforeDelete).hasSize(9);
+    // PS1 has comments [c1, c2, c3, c4, c5].
+    assertThat(getRevisionComments(changeId, ps1)).hasSize(5);
+    // PS2 has comments [c6, c9].
+    assertThat(getRevisionComments(changeId, ps2)).hasSize(2);
+    // PS3 has no comment.
+    assertThat(getRevisionComments(changeId, ps3)).hasSize(0);
+    // PS4 has comments [c7, c8].
+    assertThat(getRevisionComments(changeId, ps4)).hasSize(2);
+
+    setApiUser(admin);
+    for (int i = 0; i < commentsBeforeDelete.size(); i++) {
+      List<RevCommit> commitsBeforeDelete = new ArrayList<>();
+      if (notesMigration.commitChangeWrites()) {
+        commitsBeforeDelete = getCommits(id);
+      }
+
+      CommentInfo comment = commentsBeforeDelete.get(i);
+      String uuid = comment.id;
+      int patchSet = comment.patchSet;
+      // 'oldComment' has some fields unset compared with 'comment'.
+      CommentInfo oldComment = gApi.changes().id(changeId).revision(patchSet).comment(uuid).get();
+
+      DeleteCommentInput input = new DeleteCommentInput("delete comment " + uuid);
+      CommentInfo updatedComment =
+          gApi.changes().id(changeId).revision(patchSet).comment(uuid).delete(input);
+
+      String expectedMsg =
+          String.format("Comment removed by: %s; Reason: %s", admin.fullName, input.reason);
+      assertThat(updatedComment.message).isEqualTo(expectedMsg);
+      oldComment.message = expectedMsg;
+      assertThat(updatedComment).isEqualTo(oldComment);
+
+      // Check the NoteDb state after the deletion.
+      if (notesMigration.commitChangeWrites()) {
+        assertMetaBranchCommitsAfterRewriting(commitsBeforeDelete, id, uuid, expectedMsg);
+      }
+
+      comment.message = expectedMsg;
+      commentsBeforeDelete.set(i, comment);
+      List<CommentInfo> commentsAfterDelete = getChangeSortedComments(changeId);
+      assertThat(commentsAfterDelete).isEqualTo(commentsBeforeDelete);
+    }
+
+    // Make sure that comments can still be added correctly.
+    CommentInput c10 = newComment("a.txt", "comment 10");
+    CommentInput c11 = newComment("b.txt", "comment 11");
+    CommentInput c12 = newComment("a.txt", "comment 12");
+    CommentInput c13 = newComment("c.txt", "comment 13");
+    addComments(changeId, ps1, c10);
+    addComments(changeId, ps2, c11);
+    addComments(changeId, ps3, c12);
+    addComments(changeId, ps4, c13);
+
+    assertThat(getChangeSortedComments(changeId)).hasSize(13);
+    assertThat(getRevisionComments(changeId, ps1)).hasSize(6);
+    assertThat(getRevisionComments(changeId, ps2)).hasSize(3);
+    assertThat(getRevisionComments(changeId, ps3)).hasSize(1);
+    assertThat(getRevisionComments(changeId, ps4)).hasSize(3);
+  }
+
+  @Test
+  public void deleteOneCommentMultipleTimes() throws Exception {
+    PushOneCommit.Result result = createChange();
+    Change.Id id = result.getChange().getId();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput c1 = newComment(FILE_NAME, "comment 1");
+    CommentInput c2 = newComment(FILE_NAME, "comment 2");
+    CommentInput c3 = newComment(FILE_NAME, "comment 3");
+    addComments(changeId, ps1, c1);
+    addComments(changeId, ps1, c2);
+    addComments(changeId, ps1, c3);
+
+    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(changeId);
+    assertThat(commentsBeforeDelete).hasSize(3);
+    Optional<CommentInfo> targetComment =
+        commentsBeforeDelete.stream().filter(c -> c.message.equals("comment 2")).findFirst();
+    assertThat(targetComment).isPresent();
+    String uuid = targetComment.get().id;
+    CommentInfo oldComment = gApi.changes().id(changeId).revision(ps1).comment(uuid).get();
+
+    List<RevCommit> commitsBeforeDelete = new ArrayList<>();
+    if (notesMigration.commitChangeWrites()) {
+      commitsBeforeDelete = getCommits(id);
+    }
+
+    setApiUser(admin);
+    for (int i = 0; i < 3; i++) {
+      DeleteCommentInput input = new DeleteCommentInput("delete comment 2, iteration: " + i);
+      gApi.changes().id(changeId).revision(ps1).comment(uuid).delete(input);
+    }
+
+    CommentInfo updatedComment = gApi.changes().id(changeId).revision(ps1).comment(uuid).get();
+    String expectedMsg =
+        String.format(
+            "Comment removed by: %s; Reason: %s", admin.fullName, "delete comment 2, iteration: 2");
+    assertThat(updatedComment.message).isEqualTo(expectedMsg);
+    oldComment.message = expectedMsg;
+    assertThat(updatedComment).isEqualTo(oldComment);
+
+    if (notesMigration.commitChangeWrites()) {
+      assertMetaBranchCommitsAfterRewriting(commitsBeforeDelete, id, uuid, expectedMsg);
+    }
+    assertThat(getChangeSortedComments(changeId)).hasSize(3);
+  }
+
+  @Test
+  public void jsonCommentHasLegacyFormatFalse() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    assertThat(noteUtil.getWriteJson()).isTrue();
+
+    PushOneCommit.Result result = createChange();
+    Change.Id changeId = result.getChange().getId();
+    addComment(result.getChangeId(), "comment");
+
+    Collection<com.google.gerrit.reviewdb.client.Comment> comments =
+        notesFactory.createChecked(db, project, changeId).getComments().values();
+    assertThat(comments).hasSize(1);
+    com.google.gerrit.reviewdb.client.Comment comment = comments.iterator().next();
+    assertThat(comment.message).isEqualTo("comment");
+    assertThat(comment.legacyFormat).isFalse();
+  }
+
+  private List<CommentInfo> getChangeSortedComments(String changeId) throws Exception {
+    List<CommentInfo> comments = new ArrayList<>();
+    Map<String, List<CommentInfo>> commentsMap = getPublishedComments(changeId);
+    for (Entry<String, List<CommentInfo>> e : commentsMap.entrySet()) {
+      for (CommentInfo c : e.getValue()) {
+        c.path = e.getKey(); // Set the comment's path field.
+        comments.add(c);
+      }
+    }
+    comments.sort(Comparator.comparing(c -> c.id));
+    return comments;
+  }
+
+  private List<CommentInfo> getRevisionComments(String changeId, String revId) throws Exception {
+    return getPublishedComments(changeId, revId)
+        .values()
+        .stream()
+        .flatMap(List::stream)
+        .collect(toList());
+  }
+
+  private CommentInput addComment(String changeId, String message) throws Exception {
+    ReviewInput input = new ReviewInput();
+    CommentInput comment = newComment(FILE_NAME, Side.REVISION, 0, message, false);
+    input.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
+    gApi.changes().id(changeId).current().review(input);
+    return comment;
+  }
+
+  private void addComments(String changeId, String revision, CommentInput... commentInputs)
+      throws Exception {
+    ReviewInput input = new ReviewInput();
+    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
+    gApi.changes().id(changeId).revision(revision).review(input);
+  }
+
+  private List<RevCommit> getCommits(Change.Id changeId) throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      Ref metaRef = repo.exactRef(RefNames.changeMetaRef(changeId));
+      revWalk.markStart(revWalk.parseCommit(metaRef.getObjectId()));
+      return Lists.newArrayList(revWalk);
+    }
+  }
+
+  /**
+   * All the commits, which contain the target comment before, should still contain the comment with
+   * the updated message. All the other metas of the commits should be exactly the same.
+   */
+  private void assertMetaBranchCommitsAfterRewriting(
+      List<RevCommit> beforeDelete,
+      Change.Id changeId,
+      String targetCommentUuid,
+      String expectedMessage)
+      throws Exception {
+    List<RevCommit> afterDelete = getCommits(changeId);
+    assertThat(afterDelete).hasSize(beforeDelete.size());
+
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectReader reader = repo.newObjectReader()) {
+      for (int i = 0; i < beforeDelete.size(); i++) {
+        RevCommit commitBefore = beforeDelete.get(i);
+        RevCommit commitAfter = afterDelete.get(i);
+
+        Map<String, com.google.gerrit.reviewdb.client.Comment> commentMapBefore =
+            DeleteCommentRewriter.getPublishedComments(
+                noteUtil, changeId, reader, NoteMap.read(reader, commitBefore));
+        Map<String, com.google.gerrit.reviewdb.client.Comment> commentMapAfter =
+            DeleteCommentRewriter.getPublishedComments(
+                noteUtil, changeId, reader, NoteMap.read(reader, commitAfter));
+
+        if (commentMapBefore.containsKey(targetCommentUuid)) {
+          assertThat(commentMapAfter).containsKey(targetCommentUuid);
+          com.google.gerrit.reviewdb.client.Comment comment =
+              commentMapAfter.get(targetCommentUuid);
+          assertThat(comment.message).isEqualTo(expectedMessage);
+          comment.message = commentMapBefore.get(targetCommentUuid).message;
+          commentMapAfter.put(targetCommentUuid, comment);
+          assertThat(commentMapAfter).isEqualTo(commentMapBefore);
+        } else {
+          assertThat(commentMapAfter).doesNotContainKey(targetCommentUuid);
+        }
+
+        // Other metas should be exactly the same.
+        assertThat(commitAfter.getFullMessage()).isEqualTo(commitBefore.getFullMessage());
+        assertThat(commitAfter.getCommitterIdent()).isEqualTo(commitBefore.getCommitterIdent());
+        assertThat(commitAfter.getAuthorIdent()).isEqualTo(commitBefore.getAuthorIdent());
+        assertThat(commitAfter.getEncoding()).isEqualTo(commitBefore.getEncoding());
+        assertThat(commitAfter.getEncodingName()).isEqualTo(commitBefore.getEncodingName());
+      }
+    }
+  }
+
+  private static String extractComments(String msg) {
+    // Extract lines between start "....." and end "-- ".
+    Pattern p = Pattern.compile(".*[.]{5}\n+(.*)\\n+-- \n.*", Pattern.DOTALL);
+    Matcher m = p.matcher(msg);
+    return m.matches() ? m.group(1) : msg;
+  }
+
+  private ReviewInput newInput(CommentInput c) {
+    ReviewInput in = new ReviewInput();
+    in.comments = new HashMap<>();
+    in.comments.put(c.path, Lists.newArrayList(c));
+    return in;
+  }
+
+  private void addComment(PushOneCommit.Result r, String message) throws Exception {
+    addComment(r, message, false, false, null);
+  }
+
+  private void addComment(
+      PushOneCommit.Result r,
+      String message,
+      boolean omitDuplicateComments,
+      Boolean unresolved,
+      String inReplyTo)
+      throws Exception {
+    CommentInput c = new CommentInput();
+    c.line = 1;
+    c.message = message;
+    c.path = FILE_NAME;
+    c.unresolved = unresolved;
+    c.inReplyTo = inReplyTo;
+    ReviewInput in = newInput(c);
+    in.omitDuplicateComments = omitDuplicateComments;
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+  }
+
+  private CommentInfo addDraft(String changeId, String revId, DraftInput in) throws Exception {
+    return gApi.changes().id(changeId).revision(revId).createDraft(in).get();
+  }
+
+  private void updateDraft(String changeId, String revId, DraftInput in, String uuid)
+      throws Exception {
+    gApi.changes().id(changeId).revision(revId).draft(uuid).update(in);
+  }
+
+  private void deleteDraft(String changeId, String revId, String uuid) throws Exception {
+    gApi.changes().id(changeId).revision(revId).draft(uuid).delete();
+  }
+
+  private CommentInfo getPublishedComment(String changeId, String revId, String uuid)
+      throws Exception {
+    return gApi.changes().id(changeId).revision(revId).comment(uuid).get();
+  }
+
+  private Map<String, List<CommentInfo>> getPublishedComments(String changeId, String revId)
+      throws Exception {
+    return gApi.changes().id(changeId).revision(revId).comments();
+  }
+
+  private Map<String, List<CommentInfo>> getDraftComments(String changeId, String revId)
+      throws Exception {
+    return gApi.changes().id(changeId).revision(revId).drafts();
+  }
+
+  private Map<String, List<CommentInfo>> getPublishedComments(String changeId) throws Exception {
+    return gApi.changes().id(changeId).comments();
+  }
+
+  private CommentInfo getDraftComment(String changeId, String revId, String uuid) throws Exception {
+    return gApi.changes().id(changeId).revision(revId).draft(uuid).get();
+  }
+
+  private static CommentInput newComment(String file, String message) {
+    return newComment(file, Side.REVISION, 0, message, false);
+  }
+
+  private static CommentInput newComment(
+      String path, Side side, int line, String message, Boolean unresolved) {
+    CommentInput c = new CommentInput();
+    return populate(c, path, side, null, line, message, unresolved);
+  }
+
+  private static CommentInput newCommentOnParent(
+      String path, int parent, int line, String message) {
+    CommentInput c = new CommentInput();
+    return populate(c, path, Side.PARENT, Integer.valueOf(parent), line, message, false);
+  }
+
+  private DraftInput newDraft(String path, Side side, int line, String message) {
+    DraftInput d = new DraftInput();
+    return populate(d, path, side, null, line, message, false);
+  }
+
+  private DraftInput newDraftOnParent(String path, int parent, int line, String message) {
+    DraftInput d = new DraftInput();
+    return populate(d, path, Side.PARENT, Integer.valueOf(parent), line, message, false);
+  }
+
+  private static <C extends Comment> C populate(
+      C c, String path, Side side, Integer parent, int line, String message, Boolean unresolved) {
+    c.path = path;
+    c.side = side;
+    c.parent = parent;
+    c.line = line != 0 ? line : null;
+    c.message = message;
+    c.unresolved = unresolved;
+    if (line != 0) {
+      Comment.Range range = new Comment.Range();
+      range.startLine = line;
+      range.startCharacter = 1;
+      range.endLine = line;
+      range.endCharacter = 5;
+      c.range = range;
+    }
+    return c;
+  }
+
+  private static Function<CommentInfo, CommentInput> infoToInput(String path) {
+    return infoToInput(path, CommentInput::new);
+  }
+
+  private static Function<CommentInfo, DraftInput> infoToDraft(String path) {
+    return infoToInput(path, DraftInput::new);
+  }
+
+  private static <I extends Comment> Function<CommentInfo, I> infoToInput(
+      String path, Supplier<I> supplier) {
+    return info -> {
+      I i = supplier.get();
+      i.path = path;
+      copy(info, i);
+      return i;
+    };
+  }
+
+  private static void copy(Comment from, Comment to) {
+    to.side = from.side == null ? Side.REVISION : from.side;
+    to.parent = from.parent;
+    to.line = from.line;
+    to.message = from.message;
+    to.range = from.range;
+    to.unresolved = from.unresolved;
+    to.inReplyTo = from.inReplyTo;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
new file mode 100644
index 0000000..f8b7652
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -0,0 +1,980 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIXED;
+import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIX_FAILED;
+import static com.google.gerrit.testing.TestChanges.newPatchSet;
+import static java.util.Collections.singleton;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.FixInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ProblemInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.ConsistencyChecker;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gerrit.testing.NoteDbMode;
+import com.google.gerrit.testing.TestChanges;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class ConsistencyCheckerIT extends AbstractDaemonTest {
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+
+  @Inject private Provider<ConsistencyChecker> checkerProvider;
+
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+
+  @Inject private ChangeInserter.Factory changeInserterFactory;
+
+  @Inject private PatchSetInserter.Factory patchSetInserterFactory;
+
+  @Inject private ChangeNoteUtil noteUtil;
+
+  @Inject @AnonymousCowardName private String anonymousCowardName;
+
+  @Inject private Sequences sequences;
+
+  private RevCommit tip;
+  private Account.Id adminId;
+  private ConsistencyChecker checker;
+
+  private void assumeNoteDbDisabled() {
+    assume().that(notesMigration.readChanges()).isFalse();
+    assume().that(NoteDbMode.get()).isNotEqualTo(NoteDbMode.CHECK);
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    // Ignore client clone of project; repurpose as server-side TestRepository.
+    testRepo = new TestRepository<>((InMemoryRepository) repoManager.openRepository(project));
+    tip =
+        testRepo.getRevWalk().parseCommit(testRepo.getRepository().exactRef("HEAD").getObjectId());
+    adminId = admin.getId();
+    checker = checkerProvider.get();
+  }
+
+  @Test
+  public void validNewChange() throws Exception {
+    assertNoProblems(insertChange(), null);
+  }
+
+  @Test
+  public void validMergedChange() throws Exception {
+    ChangeNotes notes = mergeChange(incrementPatchSet(insertChange()));
+    assertNoProblems(notes, null);
+  }
+
+  @Test
+  public void missingOwner() throws Exception {
+    TestAccount owner = accountCreator.create("missing");
+    ChangeNotes notes = insertChange(owner);
+    deleteUserBranch(owner.getId());
+
+    assertProblems(notes, null, problem("Missing change owner: " + owner.getId()));
+  }
+
+  @Test
+  public void missingRepo() throws Exception {
+    // NoteDb can't have a change without a repo.
+    assumeNoteDbDisabled();
+
+    ChangeNotes notes = insertChange();
+    Project.NameKey name = notes.getProjectName();
+    ((InMemoryRepositoryManager) repoManager).deleteRepository(name);
+    assertThat(checker.check(notes, null).problems())
+        .containsExactly(problem("Destination repository not found: " + name));
+  }
+
+  @Test
+  public void invalidRevision() throws Exception {
+    // NoteDb always parses the revision when inserting a patch set, so we can't
+    // create an invalid patch set.
+    assumeNoteDbDisabled();
+
+    ChangeNotes notes = insertChange();
+    PatchSet ps =
+        newPatchSet(
+            notes.getChange().currentPatchSetId(),
+            "fooooooooooooooooooooooooooooooooooooooo",
+            adminId);
+    db.patchSets().update(singleton(ps));
+
+    assertProblems(
+        notes,
+        null,
+        problem("Invalid revision on patch set 1: fooooooooooooooooooooooooooooooooooooooo"));
+  }
+
+  // No test for ref existing but object missing; InMemoryRepository won't let
+  // us do such a thing.
+
+  @Test
+  public void patchSetObjectAndRefMissing() throws Exception {
+    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    ChangeNotes notes = insertChange();
+    PatchSet ps = insertMissingPatchSet(notes, rev);
+    notes = reload(notes);
+    assertProblems(
+        notes,
+        null,
+        problem("Ref missing: " + ps.getId().toRefName()),
+        problem("Object missing: patch set 2: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+  }
+
+  @Test
+  public void patchSetObjectAndRefMissingWithFix() throws Exception {
+    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    ChangeNotes notes = insertChange();
+    PatchSet ps = insertMissingPatchSet(notes, rev);
+    notes = reload(notes);
+
+    String refName = ps.getId().toRefName();
+    assertProblems(
+        notes,
+        new FixInput(),
+        problem("Ref missing: " + refName),
+        problem("Object missing: patch set 2: " + rev));
+  }
+
+  @Test
+  public void patchSetRefMissing() throws Exception {
+    ChangeNotes notes = insertChange();
+    testRepo.update(
+        "refs/other/foo", ObjectId.fromString(psUtil.current(db, notes).getRevision().get()));
+    String refName = notes.getChange().currentPatchSetId().toRefName();
+    deleteRef(refName);
+
+    assertProblems(notes, null, problem("Ref missing: " + refName));
+  }
+
+  @Test
+  public void patchSetRefMissingWithFix() throws Exception {
+    ChangeNotes notes = insertChange();
+    String rev = psUtil.current(db, notes).getRevision().get();
+    testRepo.update("refs/other/foo", ObjectId.fromString(rev));
+    String refName = notes.getChange().currentPatchSetId().toRefName();
+    deleteRef(refName);
+
+    assertProblems(
+        notes, new FixInput(), problem("Ref missing: " + refName, FIXED, "Repaired patch set ref"));
+    assertThat(testRepo.getRepository().exactRef(refName).getObjectId().name()).isEqualTo(rev);
+  }
+
+  @Test
+  public void patchSetObjectAndRefMissingWithDeletingPatchSet() throws Exception {
+    ChangeNotes notes = insertChange();
+    PatchSet ps1 = psUtil.current(db, notes);
+
+    String rev2 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    PatchSet ps2 = insertMissingPatchSet(notes, rev2);
+    notes = reload(notes);
+
+    FixInput fix = new FixInput();
+    fix.deletePatchSetIfCommitMissing = true;
+    assertProblems(
+        notes,
+        fix,
+        problem("Ref missing: " + ps2.getId().toRefName()),
+        problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"));
+
+    notes = reload(notes);
+    assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(1);
+    assertThat(psUtil.get(db, notes, ps1.getId())).isNotNull();
+    assertThat(psUtil.get(db, notes, ps2.getId())).isNull();
+  }
+
+  @Test
+  public void patchSetMultipleObjectsMissingWithDeletingPatchSets() throws Exception {
+    ChangeNotes notes = insertChange();
+    PatchSet ps1 = psUtil.current(db, notes);
+
+    String rev2 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    PatchSet ps2 = insertMissingPatchSet(notes, rev2);
+
+    notes = incrementPatchSet(reload(notes));
+    PatchSet ps3 = psUtil.current(db, notes);
+
+    String rev4 = "c0ffeeeec0ffeeeec0ffeeeec0ffeeeec0ffeeee";
+    PatchSet ps4 = insertMissingPatchSet(notes, rev4);
+    notes = reload(notes);
+
+    FixInput fix = new FixInput();
+    fix.deletePatchSetIfCommitMissing = true;
+    assertProblems(
+        notes,
+        fix,
+        problem("Ref missing: " + ps2.getId().toRefName()),
+        problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"),
+        problem("Ref missing: " + ps4.getId().toRefName()),
+        problem("Object missing: patch set 4: " + rev4, FIXED, "Deleted patch set"));
+
+    notes = reload(notes);
+    assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(3);
+    assertThat(psUtil.get(db, notes, ps1.getId())).isNotNull();
+    assertThat(psUtil.get(db, notes, ps2.getId())).isNull();
+    assertThat(psUtil.get(db, notes, ps3.getId())).isNotNull();
+    assertThat(psUtil.get(db, notes, ps4.getId())).isNull();
+  }
+
+  @Test
+  public void onlyPatchSetObjectMissingWithFix() throws Exception {
+    Change c = TestChanges.newChange(project, admin.getId(), sequences.nextChangeId());
+
+    // Set review started, mimicking Schema_153, so tests pass with NoteDbMode.CHECK.
+    c.setReviewStarted(true);
+
+    PatchSet.Id psId = c.currentPatchSetId();
+    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    PatchSet ps = newPatchSet(psId, rev, adminId);
+
+    if (notesMigration.changePrimaryStorage() == PrimaryStorage.REVIEW_DB) {
+      db.changes().insert(singleton(c));
+      db.patchSets().insert(singleton(ps));
+    }
+    addNoteDbCommit(
+        c.getId(),
+        "Create change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: "
+            + c.getDest().get()
+            + "\n"
+            + "Change-id: "
+            + c.getKey().get()
+            + "\n"
+            + "Subject: Bogus subject\n"
+            + "Commit: "
+            + rev
+            + "\n"
+            + "Groups: "
+            + rev
+            + "\n");
+    indexer.index(db, c.getProject(), c.getId());
+    ChangeNotes notes = changeNotesFactory.create(db, c.getProject(), c.getId());
+
+    FixInput fix = new FixInput();
+    fix.deletePatchSetIfCommitMissing = true;
+    assertProblems(
+        notes,
+        fix,
+        problem("Ref missing: " + ps.getId().toRefName()),
+        problem(
+            "Object missing: patch set 1: " + rev,
+            FIX_FAILED,
+            "Cannot delete patch set; no patch sets would remain"));
+
+    notes = reload(notes);
+    assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(1);
+    assertThat(psUtil.current(db, notes)).isNotNull();
+  }
+
+  @Test
+  public void currentPatchSetMissing() throws Exception {
+    // NoteDb can't create a change without a patch set.
+    assumeNoteDbDisabled();
+
+    ChangeNotes notes = insertChange();
+    db.patchSets().deleteKeys(singleton(notes.getChange().currentPatchSetId()));
+    assertProblems(notes, null, problem("Current patch set 1 not found"));
+  }
+
+  @Test
+  public void duplicatePatchSetRevisions() throws Exception {
+    ChangeNotes notes = insertChange();
+    PatchSet ps1 = psUtil.current(db, notes);
+    String rev = ps1.getRevision().get();
+
+    notes = incrementPatchSet(notes, testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+
+    assertProblems(notes, null, problem("Multiple patch sets pointing to " + rev + ": [1, 2]"));
+  }
+
+  @Test
+  public void missingDestRef() throws Exception {
+    ChangeNotes notes = insertChange();
+
+    String ref = "refs/heads/master";
+    // Detach head so we're allowed to delete ref.
+    testRepo.reset(testRepo.getRepository().exactRef(ref).getObjectId());
+    RefUpdate ru = testRepo.getRepository().updateRef(ref);
+    ru.setForceUpdate(true);
+    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+
+    assertProblems(notes, null, problem("Destination ref not found (may be new branch): " + ref));
+  }
+
+  @Test
+  public void mergedChangeIsNotMerged() throws Exception {
+    ChangeNotes notes = insertChange();
+
+    try (BatchUpdate bu = newUpdate(adminId)) {
+      bu.addOp(
+          notes.getChangeId(),
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws OrmException {
+              ctx.getChange().setStatus(Change.Status.MERGED);
+              ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
+              return true;
+            }
+          });
+      bu.execute();
+    }
+    notes = reload(notes);
+
+    String rev = psUtil.current(db, notes).getRevision().get();
+    ObjectId tip = getDestRef(notes);
+    assertProblems(
+        notes,
+        null,
+        problem(
+            "Patch set 1 ("
+                + rev
+                + ") is not merged into destination ref"
+                + " refs/heads/master ("
+                + tip.name()
+                + "), but change status is MERGED"));
+  }
+
+  @Test
+  public void newChangeIsMerged() throws Exception {
+    ChangeNotes notes = insertChange();
+    String rev = psUtil.current(db, notes).getRevision().get();
+    testRepo
+        .branch(notes.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+
+    assertProblems(
+        notes,
+        null,
+        problem(
+            "Patch set 1 ("
+                + rev
+                + ") is merged into destination ref"
+                + " refs/heads/master ("
+                + rev
+                + "), but change status is NEW"));
+  }
+
+  @Test
+  public void newChangeIsMergedWithFix() throws Exception {
+    ChangeNotes notes = insertChange();
+    String rev = psUtil.current(db, notes).getRevision().get();
+    testRepo
+        .branch(notes.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+
+    assertProblems(
+        notes,
+        new FixInput(),
+        problem(
+            "Patch set 1 ("
+                + rev
+                + ") is merged into destination ref"
+                + " refs/heads/master ("
+                + rev
+                + "), but change status is NEW",
+            FIXED,
+            "Marked change as merged"));
+
+    notes = reload(notes);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertNoProblems(notes, null);
+  }
+
+  @Test
+  public void extensionApiReturnsUpdatedValueAfterFix() throws Exception {
+    ChangeNotes notes = insertChange();
+    String rev = psUtil.current(db, notes).getRevision().get();
+    testRepo
+        .branch(notes.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+
+    ChangeInfo info = gApi.changes().id(notes.getChangeId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    info = gApi.changes().id(notes.getChangeId().get()).check(new FixInput());
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void expectedMergedCommitIsLatestPatchSet() throws Exception {
+    ChangeNotes notes = insertChange();
+    String rev = psUtil.current(db, notes).getRevision().get();
+    testRepo
+        .branch(notes.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+
+    FixInput fix = new FixInput();
+    fix.expectMergedAs = rev;
+    assertProblems(
+        notes,
+        fix,
+        problem(
+            "Patch set 1 ("
+                + rev
+                + ") is merged into destination ref"
+                + " refs/heads/master ("
+                + rev
+                + "), but change status is NEW",
+            FIXED,
+            "Marked change as merged"));
+
+    notes = reload(notes);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertNoProblems(notes, null);
+  }
+
+  @Test
+  public void expectedMergedCommitNotMergedIntoDestination() throws Exception {
+    ChangeNotes notes = insertChange();
+    String rev = psUtil.current(db, notes).getRevision().get();
+    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    testRepo.branch(notes.getChange().getDest().get()).update(commit);
+
+    FixInput fix = new FixInput();
+    RevCommit other = testRepo.commit().message(commit.getFullMessage()).create();
+    fix.expectMergedAs = other.name();
+    assertProblems(
+        notes,
+        fix,
+        problem(
+            "Expected merged commit "
+                + other.name()
+                + " is not merged into destination ref refs/heads/master"
+                + " ("
+                + commit.name()
+                + ")"));
+  }
+
+  @Test
+  public void createNewPatchSetForExpectedMergeCommitWithNoChangeId() throws Exception {
+    ChangeNotes notes = insertChange();
+    String dest = notes.getChange().getDest().get();
+    String rev = psUtil.current(db, notes).getRevision().get();
+    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+
+    RevCommit mergedAs =
+        testRepo.commit().parent(commit.getParent(0)).message(commit.getShortMessage()).create();
+    testRepo.getRevWalk().parseBody(mergedAs);
+    assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID)).isEmpty();
+    testRepo.update(dest, mergedAs);
+
+    assertNoProblems(notes, null);
+
+    FixInput fix = new FixInput();
+    fix.expectMergedAs = mergedAs.name();
+    assertProblems(
+        notes,
+        fix,
+        problem(
+            "No patch set found for merged commit " + mergedAs.name(),
+            FIXED,
+            "Marked change as merged"),
+        problem(
+            "Expected merged commit " + mergedAs.name() + " has no associated patch set",
+            FIXED,
+            "Inserted as patch set 2"));
+
+    notes = reload(notes);
+    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
+    assertThat(psUtil.get(db, notes, psId2).getRevision().get()).isEqualTo(mergedAs.name());
+
+    assertNoProblems(notes, null);
+  }
+
+  @Test
+  public void createNewPatchSetForExpectedMergeCommitWithChangeId() throws Exception {
+    ChangeNotes notes = insertChange();
+    String dest = notes.getChange().getDest().get();
+    String rev = psUtil.current(db, notes).getRevision().get();
+    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+
+    RevCommit mergedAs =
+        testRepo
+            .commit()
+            .parent(commit.getParent(0))
+            .message(
+                commit.getShortMessage()
+                    + "\n"
+                    + "\n"
+                    + "Change-Id: "
+                    + notes.getChange().getKey().get()
+                    + "\n")
+            .create();
+    testRepo.getRevWalk().parseBody(mergedAs);
+    assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID))
+        .containsExactly(notes.getChange().getKey().get());
+    testRepo.update(dest, mergedAs);
+
+    assertNoProblems(notes, null);
+
+    FixInput fix = new FixInput();
+    fix.expectMergedAs = mergedAs.name();
+    assertProblems(
+        notes,
+        fix,
+        problem(
+            "No patch set found for merged commit " + mergedAs.name(),
+            FIXED,
+            "Marked change as merged"),
+        problem(
+            "Expected merged commit " + mergedAs.name() + " has no associated patch set",
+            FIXED,
+            "Inserted as patch set 2"));
+
+    notes = reload(notes);
+    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
+    assertThat(psUtil.get(db, notes, psId2).getRevision().get()).isEqualTo(mergedAs.name());
+
+    assertNoProblems(notes, null);
+  }
+
+  @Test
+  public void expectedMergedCommitIsOldPatchSetOfSameChange() throws Exception {
+    ChangeNotes notes = insertChange();
+    PatchSet ps1 = psUtil.current(db, notes);
+    String rev1 = ps1.getRevision().get();
+    notes = incrementPatchSet(notes);
+    PatchSet ps2 = psUtil.current(db, notes);
+    testRepo
+        .branch(notes.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev1)));
+
+    FixInput fix = new FixInput();
+    fix.expectMergedAs = rev1;
+    assertProblems(
+        notes,
+        fix,
+        problem("No patch set found for merged commit " + rev1, FIXED, "Marked change as merged"),
+        problem(
+            "Expected merge commit "
+                + rev1
+                + " corresponds to patch set 1,"
+                + " not the current patch set 2",
+            FIXED,
+            "Deleted patch set"),
+        problem(
+            "Expected merge commit "
+                + rev1
+                + " corresponds to patch set 1,"
+                + " not the current patch set 2",
+            FIXED,
+            "Inserted as patch set 3"));
+
+    notes = reload(notes);
+    PatchSet.Id psId3 = new PatchSet.Id(notes.getChangeId(), 3);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId3);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(ps2.getId(), psId3);
+    assertThat(psUtil.get(db, notes, psId3).getRevision().get()).isEqualTo(rev1);
+  }
+
+  @Test
+  public void expectedMergedCommitIsDanglingPatchSetOlderThanCurrent() throws Exception {
+    ChangeNotes notes = insertChange();
+    PatchSet ps1 = psUtil.current(db, notes);
+
+    // Create dangling ref so next ID in the database becomes 3.
+    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    RevCommit commit2 = patchSetCommit(psId2);
+    String rev2 = commit2.name();
+    testRepo.branch(psId2.toRefName()).update(commit2);
+
+    notes = incrementPatchSet(notes);
+    PatchSet ps3 = psUtil.current(db, notes);
+    assertThat(ps3.getId().get()).isEqualTo(3);
+
+    testRepo
+        .branch(notes.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
+
+    FixInput fix = new FixInput();
+    fix.expectMergedAs = rev2;
+    assertProblems(
+        notes,
+        fix,
+        problem("No patch set found for merged commit " + rev2, FIXED, "Marked change as merged"),
+        problem(
+            "Expected merge commit "
+                + rev2
+                + " corresponds to patch set 2,"
+                + " not the current patch set 3",
+            FIXED,
+            "Deleted patch set"),
+        problem(
+            "Expected merge commit "
+                + rev2
+                + " corresponds to patch set 2,"
+                + " not the current patch set 3",
+            FIXED,
+            "Inserted as patch set 4"));
+
+    notes = reload(notes);
+    PatchSet.Id psId4 = new PatchSet.Id(notes.getChangeId(), 4);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId4);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(psUtil.byChangeAsMap(db, notes).keySet())
+        .containsExactly(ps1.getId(), ps3.getId(), psId4);
+    assertThat(psUtil.get(db, notes, psId4).getRevision().get()).isEqualTo(rev2);
+  }
+
+  @Test
+  public void expectedMergedCommitIsDanglingPatchSetNewerThanCurrent() throws Exception {
+    ChangeNotes notes = insertChange();
+    PatchSet ps1 = psUtil.current(db, notes);
+
+    // Create dangling ref with no patch set.
+    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    RevCommit commit2 = patchSetCommit(psId2);
+    String rev2 = commit2.name();
+    testRepo.branch(psId2.toRefName()).update(commit2);
+
+    testRepo
+        .branch(notes.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
+
+    FixInput fix = new FixInput();
+    fix.expectMergedAs = rev2;
+    assertProblems(
+        notes,
+        fix,
+        problem("No patch set found for merged commit " + rev2, FIXED, "Marked change as merged"),
+        problem(
+            "Expected merge commit "
+                + rev2
+                + " corresponds to patch set 2,"
+                + " not the current patch set 1",
+            FIXED,
+            "Inserted as patch set 2"));
+
+    notes = reload(notes);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(ps1.getId(), psId2);
+    assertThat(psUtil.get(db, notes, psId2).getRevision().get()).isEqualTo(rev2);
+  }
+
+  @Test
+  public void expectedMergedCommitWithMismatchedChangeId() throws Exception {
+    ChangeNotes notes = insertChange();
+    String dest = notes.getChange().getDest().get();
+    RevCommit parent = testRepo.branch(dest).commit().message("parent").create();
+    String rev = psUtil.current(db, notes).getRevision().get();
+    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    testRepo.branch(dest).update(commit);
+
+    String badId = "I0000000000000000000000000000000000000000";
+    RevCommit mergedAs =
+        testRepo
+            .commit()
+            .parent(parent)
+            .message(commit.getShortMessage() + "\n\nChange-Id: " + badId + "\n")
+            .create();
+    testRepo.getRevWalk().parseBody(mergedAs);
+    assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID)).containsExactly(badId);
+    testRepo.update(dest, mergedAs);
+
+    assertNoProblems(notes, null);
+
+    FixInput fix = new FixInput();
+    fix.expectMergedAs = mergedAs.name();
+    assertProblems(
+        notes,
+        fix,
+        problem(
+            "Expected merged commit "
+                + mergedAs.name()
+                + " has Change-Id: "
+                + badId
+                + ", but expected "
+                + notes.getChange().getKey().get()));
+  }
+
+  @Test
+  public void expectedMergedCommitMatchesMultiplePatchSets() throws Exception {
+    ChangeNotes notes1 = insertChange();
+    PatchSet.Id psId1 = psUtil.current(db, notes1).getId();
+    String dest = notes1.getChange().getDest().get();
+    String rev = psUtil.current(db, notes1).getRevision().get();
+    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    testRepo.branch(dest).update(commit);
+
+    ChangeNotes notes2 = insertChange();
+    notes2 = incrementPatchSet(notes2, commit);
+    PatchSet.Id psId2 = psUtil.current(db, notes2).getId();
+
+    ChangeNotes notes3 = insertChange();
+    notes3 = incrementPatchSet(notes3, commit);
+    PatchSet.Id psId3 = psUtil.current(db, notes3).getId();
+
+    FixInput fix = new FixInput();
+    fix.expectMergedAs = commit.name();
+    assertProblems(
+        notes1,
+        fix,
+        problem(
+            "Multiple patch sets for expected merged commit "
+                + commit.name()
+                + ": ["
+                + psId1
+                + ", "
+                + psId2
+                + ", "
+                + psId3
+                + "]"));
+  }
+
+  private BatchUpdate newUpdate(Account.Id owner) {
+    return batchUpdateFactory.create(db, project, userFactory.create(owner), TimeUtil.nowTs());
+  }
+
+  private ChangeNotes insertChange() throws Exception {
+    return insertChange(admin);
+  }
+
+  private ChangeNotes insertChange(TestAccount owner) throws Exception {
+    return insertChange(owner, "refs/heads/master");
+  }
+
+  private ChangeNotes insertChange(TestAccount owner, String dest) throws Exception {
+    Change.Id id = new Change.Id(sequences.nextChangeId());
+    ChangeInserter ins;
+    try (BatchUpdate bu = newUpdate(owner.getId())) {
+      RevCommit commit = patchSetCommit(new PatchSet.Id(id, 1));
+      ins =
+          changeInserterFactory
+              .create(id, commit, dest)
+              .setValidate(false)
+              .setNotify(NotifyHandling.NONE)
+              .setFireRevisionCreated(false)
+              .setSendMail(false);
+      bu.insertChange(ins).execute();
+    }
+    return changeNotesFactory.create(db, project, ins.getChange().getId());
+  }
+
+  private PatchSet.Id nextPatchSetId(ChangeNotes notes) throws Exception {
+    return ChangeUtil.nextPatchSetId(
+        testRepo.getRepository(), notes.getChange().currentPatchSetId());
+  }
+
+  private ChangeNotes incrementPatchSet(ChangeNotes notes) throws Exception {
+    return incrementPatchSet(notes, patchSetCommit(nextPatchSetId(notes)));
+  }
+
+  private ChangeNotes incrementPatchSet(ChangeNotes notes, RevCommit commit) throws Exception {
+    PatchSetInserter ins;
+    try (BatchUpdate bu = newUpdate(notes.getChange().getOwner())) {
+      ins =
+          patchSetInserterFactory
+              .create(notes, nextPatchSetId(notes), commit)
+              .setValidate(false)
+              .setFireRevisionCreated(false)
+              .setNotify(NotifyHandling.NONE);
+      bu.addOp(notes.getChangeId(), ins).execute();
+    }
+    return reload(notes);
+  }
+
+  private ChangeNotes reload(ChangeNotes notes) throws Exception {
+    return changeNotesFactory.create(db, notes.getChange().getProject(), notes.getChangeId());
+  }
+
+  private RevCommit patchSetCommit(PatchSet.Id psId) throws Exception {
+    RevCommit c = testRepo.commit().parent(tip).message("Change " + psId).create();
+    return testRepo.parseBody(c);
+  }
+
+  private PatchSet insertMissingPatchSet(ChangeNotes notes, String rev) throws Exception {
+    // Don't use BatchUpdate since we're manually updating the meta ref rather
+    // than using ChangeUpdate.
+    String subject = "Subject for missing commit";
+    Change c = new Change(notes.getChange());
+    PatchSet.Id psId = nextPatchSetId(notes);
+    c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
+    PatchSet ps = newPatchSet(psId, rev, adminId);
+
+    if (PrimaryStorage.of(c) == PrimaryStorage.REVIEW_DB) {
+      db.patchSets().insert(singleton(ps));
+      db.changes().update(singleton(c));
+    }
+
+    addNoteDbCommit(
+        c.getId(),
+        "Update patch set "
+            + psId.get()
+            + "\n"
+            + "\n"
+            + "Patch-set: "
+            + psId.get()
+            + "\n"
+            + "Commit: "
+            + rev
+            + "\n"
+            + "Subject: "
+            + subject
+            + "\n");
+    indexer.index(db, c.getProject(), c.getId());
+
+    return ps;
+  }
+
+  private void deleteRef(String refName) throws Exception {
+    RefUpdate ru = testRepo.getRepository().updateRef(refName, true);
+    ru.setForceUpdate(true);
+    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+  }
+
+  private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
+    if (!notesMigration.commitChangeWrites()) {
+      return;
+    }
+    PersonIdent committer = serverIdent.get();
+    PersonIdent author =
+        noteUtil.newIdent(
+            accountCache.get(admin.getId()).getAccount(), committer.getWhen(), committer);
+    testRepo
+        .branch(RefNames.changeMetaRef(id))
+        .commit()
+        .author(author)
+        .committer(committer)
+        .message(commitMessage)
+        .create();
+  }
+
+  private ObjectId getDestRef(ChangeNotes notes) throws Exception {
+    return testRepo.getRepository().exactRef(notes.getChange().getDest().get()).getObjectId();
+  }
+
+  private ChangeNotes mergeChange(ChangeNotes notes) throws Exception {
+    final ObjectId oldId = getDestRef(notes);
+    final ObjectId newId = ObjectId.fromString(psUtil.current(db, notes).getRevision().get());
+    final String dest = notes.getChange().getDest().get();
+
+    try (BatchUpdate bu = newUpdate(adminId)) {
+      bu.addOp(
+          notes.getChangeId(),
+          new BatchUpdateOp() {
+            @Override
+            public void updateRepo(RepoContext ctx) throws IOException {
+              ctx.addRefUpdate(oldId, newId, dest);
+            }
+
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws OrmException {
+              ctx.getChange().setStatus(Change.Status.MERGED);
+              ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
+              return true;
+            }
+          });
+      bu.execute();
+    }
+    return reload(notes);
+  }
+
+  private static ProblemInfo problem(String message) {
+    ProblemInfo p = new ProblemInfo();
+    p.message = message;
+    return p;
+  }
+
+  private static ProblemInfo problem(String message, ProblemInfo.Status status, String outcome) {
+    ProblemInfo p = problem(message);
+    p.status = checkNotNull(status);
+    p.outcome = checkNotNull(outcome);
+    return p;
+  }
+
+  private void assertProblems(
+      ChangeNotes notes, @Nullable FixInput fix, ProblemInfo first, ProblemInfo... rest)
+      throws Exception {
+    List<ProblemInfo> expected = new ArrayList<>(1 + rest.length);
+    expected.add(first);
+    expected.addAll(Arrays.asList(rest));
+    assertThat(checker.check(notes, fix).problems()).containsExactlyElementsIn(expected).inOrder();
+  }
+
+  private void assertNoProblems(ChangeNotes notes, @Nullable FixInput fix) throws Exception {
+    assertThat(checker.check(notes, fix).problems()).isEmpty();
+  }
+
+  private void deleteUserBranch(Account.Id accountId) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      String refName = RefNames.refsUsers(accountId);
+      Ref ref = repo.exactRef(refName);
+      if (ref == null) {
+        return;
+      }
+
+      RefUpdate ru = repo.updateRef(refName);
+      ru.setExpectedOldObjectId(ref.getObjectId());
+      ru.setNewObjectId(ObjectId.zeroId());
+      ru.setForceUpdate(true);
+      Result result = ru.delete();
+      if (result != Result.FORCED) {
+        throw new IOException(String.format("Failed to delete ref %s: %s", refName, result.name()));
+      }
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
new file mode 100644
index 0000000..3877239
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -0,0 +1,651 @@
+// 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.server.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.restapi.change.ChangesCollection;
+import com.google.gerrit.server.restapi.change.GetRelated;
+import com.google.gerrit.server.restapi.change.GetRelated.ChangeAndCommit;
+import com.google.gerrit.server.restapi.change.GetRelated.RelatedInfo;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class GetRelatedIT extends AbstractDaemonTest {
+  private static final int MAX_TERMS = 10;
+
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setInt("index", null, "maxTerms", MAX_TERMS);
+    return cfg;
+  }
+
+  private String systemTimeZone;
+
+  @Before
+  public void setTimeForTesting() {
+    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZone);
+  }
+
+  @Inject private IndexConfig indexConfig;
+  @Inject private ChangesCollection changes;
+
+  @Test
+  public void getRelatedNoResult() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    assertRelated(push.to("refs/for/master").getPatchSetId());
+  }
+
+  @Test
+  public void getRelatedLinear() throws Exception {
+    // 1,1---2,1
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
+    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
+
+    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
+      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
+    }
+  }
+
+  @Test
+  public void getRelatedLinearSeparatePushes() throws Exception {
+    // 1,1---2,1
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+
+    testRepo.reset(c1_1);
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
+    String oldETag = changes.parse(ps1_1.getParentKey()).getETag();
+
+    testRepo.reset(c2_1);
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
+
+    // Push of change 2 should not affect groups (or anything else) of change 1.
+    assertThat(changes.parse(ps1_1.getParentKey()).getETag()).isEqualTo(oldETag);
+
+    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
+      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
+    }
+  }
+
+  @Test
+  public void getRelatedReorder() throws Exception {
+    // 1,1---2,1
+    //
+    // 2,2---1,2
+
+    // Create two commits and push.
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
+    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
+
+    // Swap the order of commits and push again.
+    testRepo.reset("HEAD~2");
+    RevCommit c2_2 = testRepo.cherryPick(c2_1);
+    RevCommit c1_2 = testRepo.cherryPick(c1_1);
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_2 = getPatchSetId(c1_1);
+    PatchSet.Id ps2_2 = getPatchSetId(c2_1);
+
+    for (PatchSet.Id ps : ImmutableList.of(ps2_2, ps1_2)) {
+      assertRelated(ps, changeAndCommit(ps1_2, c1_2, 2), changeAndCommit(ps2_2, c2_2, 2));
+    }
+
+    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
+      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 2), changeAndCommit(ps1_1, c1_1, 2));
+    }
+  }
+
+  @Test
+  public void getRelatedAmendParentChange() throws Exception {
+    // 1,1---2,1
+    //
+    // 1,2
+
+    // Create two commits and push.
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
+    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
+
+    // Amend parent change and push.
+    testRepo.reset("HEAD~1");
+    RevCommit c1_2 = amendBuilder().add("c.txt", "2").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_2 = getPatchSetId(c1_2);
+
+    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
+      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 2));
+    }
+
+    assertRelated(ps1_2, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_2, c1_2, 2));
+  }
+
+  @Test
+  public void getRelatedReorderAndExtend() throws Exception {
+    // 1,1---2,1
+    //
+    // 2,2---1,2---3,1
+
+    // Create two commits and push.
+    ObjectId initial = repo().exactRef("HEAD").getObjectId();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
+    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
+
+    // Swap the order of commits, create a new commit on top, and push again.
+    testRepo.reset(initial);
+    RevCommit c2_2 = testRepo.cherryPick(c2_1);
+    RevCommit c1_2 = testRepo.cherryPick(c1_1);
+    RevCommit c3_1 = commitBuilder().add("c.txt", "3").message("subject: 3").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_2 = getPatchSetId(c1_1);
+    PatchSet.Id ps2_2 = getPatchSetId(c2_1);
+    PatchSet.Id ps3_1 = getPatchSetId(c3_1);
+
+    for (PatchSet.Id ps : ImmutableList.of(ps3_1, ps2_2, ps1_2)) {
+      assertRelated(
+          ps,
+          changeAndCommit(ps3_1, c3_1, 1),
+          changeAndCommit(ps1_2, c1_2, 2),
+          changeAndCommit(ps2_2, c2_2, 2));
+    }
+
+    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
+      assertRelated(
+          ps,
+          changeAndCommit(ps3_1, c3_1, 1),
+          changeAndCommit(ps2_1, c2_1, 2),
+          changeAndCommit(ps1_1, c1_1, 2));
+    }
+  }
+
+  @Test
+  public void getRelatedReworkSeries() throws Exception {
+    // 1,1---2,1---3,1
+    //
+    // 1,2---2,2---3,2
+
+    // Create three commits and push.
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "1").message("subject: 2").create();
+    RevCommit c3_1 = commitBuilder().add("b.txt", "1").message("subject: 3").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
+    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
+    PatchSet.Id ps3_1 = getPatchSetId(c3_1);
+
+    // Amend all changes change and push.
+    testRepo.reset(c1_1);
+    RevCommit c1_2 = amendBuilder().add("a.txt", "2").create();
+    RevCommit c2_2 =
+        commitBuilder().add("b.txt", "2").message(parseBody(c2_1).getFullMessage()).create();
+    RevCommit c3_2 =
+        commitBuilder().add("b.txt", "3").message(parseBody(c3_1).getFullMessage()).create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_2 = getPatchSetId(c1_2);
+    PatchSet.Id ps2_2 = getPatchSetId(c2_2);
+    PatchSet.Id ps3_2 = getPatchSetId(c3_2);
+
+    for (PatchSet.Id ps : ImmutableList.of(ps1_1, ps2_1, ps3_1)) {
+      assertRelated(
+          ps,
+          changeAndCommit(ps3_1, c3_1, 2),
+          changeAndCommit(ps2_1, c2_1, 2),
+          changeAndCommit(ps1_1, c1_1, 2));
+    }
+
+    for (PatchSet.Id ps : ImmutableList.of(ps1_2, ps2_2, ps3_2)) {
+      assertRelated(
+          ps,
+          changeAndCommit(ps3_2, c3_2, 2),
+          changeAndCommit(ps2_2, c2_2, 2),
+          changeAndCommit(ps1_2, c1_2, 2));
+    }
+  }
+
+  @Test
+  public void getRelatedReworkThenExtendInTheMiddleOfSeries() throws Exception {
+    // 1,1---2,1---3,1
+    //
+    // 1,2---2,2---3,2
+    //   \---4,1
+
+    // Create three commits and push.
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "1").message("subject: 2").create();
+    RevCommit c3_1 = commitBuilder().add("b.txt", "1").message("subject: 3").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
+    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
+    PatchSet.Id ps3_1 = getPatchSetId(c3_1);
+
+    // Amend all changes change and push.
+    testRepo.reset(c1_1);
+    RevCommit c1_2 = amendBuilder().add("a.txt", "2").create();
+    RevCommit c2_2 =
+        commitBuilder().add("b.txt", "2").message(parseBody(c2_1).getFullMessage()).create();
+    RevCommit c3_2 =
+        commitBuilder().add("b.txt", "3").message(parseBody(c3_1).getFullMessage()).create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_2 = getPatchSetId(c1_2);
+    PatchSet.Id ps2_2 = getPatchSetId(c2_2);
+    PatchSet.Id ps3_2 = getPatchSetId(c3_2);
+
+    // Add one more commit 4,1 based on 1,2.
+    testRepo.reset(c1_2);
+    RevCommit c4_1 = commitBuilder().add("d.txt", "4").message("subject: 4").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps4_1 = getPatchSetId(c4_1);
+
+    // 1,1 is related indirectly to 4,1.
+    assertRelated(
+        ps1_1,
+        changeAndCommit(ps4_1, c4_1, 1),
+        changeAndCommit(ps3_1, c3_1, 2),
+        changeAndCommit(ps2_1, c2_1, 2),
+        changeAndCommit(ps1_1, c1_1, 2));
+
+    // 2,1 and 3,1 don't include 4,1 since we don't walk forward after walking
+    // backward.
+    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps3_1)) {
+      assertRelated(
+          ps,
+          changeAndCommit(ps3_1, c3_1, 2),
+          changeAndCommit(ps2_1, c2_1, 2),
+          changeAndCommit(ps1_1, c1_1, 2));
+    }
+
+    // 1,2 is related directly to 4,1, and the 2-3 parallel branch stays intact.
+    assertRelated(
+        ps1_2,
+        changeAndCommit(ps4_1, c4_1, 1),
+        changeAndCommit(ps3_2, c3_2, 2),
+        changeAndCommit(ps2_2, c2_2, 2),
+        changeAndCommit(ps1_2, c1_2, 2));
+
+    // 4,1 is only related to 1,2, since we don't walk forward after walking
+    // backward.
+    assertRelated(ps4_1, changeAndCommit(ps4_1, c4_1, 1), changeAndCommit(ps1_2, c1_2, 2));
+
+    // 2,2 and 3,2 don't include 4,1 since we don't walk forward after walking
+    // backward.
+    for (PatchSet.Id ps : ImmutableList.of(ps2_2, ps3_2)) {
+      assertRelated(
+          ps,
+          changeAndCommit(ps3_2, c3_2, 2),
+          changeAndCommit(ps2_2, c2_2, 2),
+          changeAndCommit(ps1_2, c1_2, 2));
+    }
+  }
+
+  @Test
+  public void getRelatedCrissCrossDependency() throws Exception {
+    // 1,1---2,1---3,2
+    //
+    // 1,2---2,2---3,1
+
+    // Create two commits and push.
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
+    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
+
+    // Amend both changes change and push.
+    testRepo.reset(c1_1);
+    RevCommit c1_2 = amendBuilder().add("a.txt", "2").create();
+    RevCommit c2_2 =
+        commitBuilder().add("b.txt", "2").message(parseBody(c2_1).getFullMessage()).create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_2 = getPatchSetId(c1_2);
+    PatchSet.Id ps2_2 = getPatchSetId(c2_2);
+
+    // PS 3,1 depends on 2,2.
+    RevCommit c3_1 = commitBuilder().add("c.txt", "1").message("subject: 3").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps3_1 = getPatchSetId(c3_1);
+
+    // PS 3,2 depends on 2,1.
+    testRepo.reset(c2_1);
+    RevCommit c3_2 =
+        commitBuilder().add("c.txt", "2").message(parseBody(c3_1).getFullMessage()).create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps3_2 = getPatchSetId(c3_2);
+
+    for (PatchSet.Id ps : ImmutableList.of(ps1_1, ps2_1, ps3_2)) {
+      assertRelated(
+          ps,
+          changeAndCommit(ps3_2, c3_2, 2),
+          changeAndCommit(ps2_1, c2_1, 2),
+          changeAndCommit(ps1_1, c1_1, 2));
+    }
+
+    for (PatchSet.Id ps : ImmutableList.of(ps1_2, ps2_2, ps3_1)) {
+      assertRelated(
+          ps,
+          changeAndCommit(ps3_1, c3_1, 2),
+          changeAndCommit(ps2_2, c2_2, 2),
+          changeAndCommit(ps1_2, c1_2, 2));
+    }
+  }
+
+  @Test
+  public void getRelatedParallelDescendentBranches() throws Exception {
+    // 1,1---2,1---3,1
+    //   \---4,1---5,1
+    //    \--6,1---7,1
+
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    RevCommit c3_1 = commitBuilder().add("c.txt", "3").message("subject: 3").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
+    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
+    PatchSet.Id ps3_1 = getPatchSetId(c3_1);
+
+    testRepo.reset(c1_1);
+    RevCommit c4_1 = commitBuilder().add("d.txt", "4").message("subject: 4").create();
+    RevCommit c5_1 = commitBuilder().add("e.txt", "5").message("subject: 5").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps4_1 = getPatchSetId(c4_1);
+    PatchSet.Id ps5_1 = getPatchSetId(c5_1);
+
+    testRepo.reset(c1_1);
+    RevCommit c6_1 = commitBuilder().add("f.txt", "6").message("subject: 6").create();
+    RevCommit c7_1 = commitBuilder().add("g.txt", "7").message("subject: 7").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps6_1 = getPatchSetId(c6_1);
+    PatchSet.Id ps7_1 = getPatchSetId(c7_1);
+
+    // All changes are related to 1,1, keeping each of the parallel branches
+    // intact.
+    assertRelated(
+        ps1_1,
+        changeAndCommit(ps7_1, c7_1, 1),
+        changeAndCommit(ps6_1, c6_1, 1),
+        changeAndCommit(ps5_1, c5_1, 1),
+        changeAndCommit(ps4_1, c4_1, 1),
+        changeAndCommit(ps3_1, c3_1, 1),
+        changeAndCommit(ps2_1, c2_1, 1),
+        changeAndCommit(ps1_1, c1_1, 1));
+
+    // The 2-3 branch is only related back to 1, not the other branches.
+    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps3_1)) {
+      assertRelated(
+          ps,
+          changeAndCommit(ps3_1, c3_1, 1),
+          changeAndCommit(ps2_1, c2_1, 1),
+          changeAndCommit(ps1_1, c1_1, 1));
+    }
+
+    // The 4-5 branch is only related back to 1, not the other branches.
+    for (PatchSet.Id ps : ImmutableList.of(ps4_1, ps5_1)) {
+      assertRelated(
+          ps,
+          changeAndCommit(ps5_1, c5_1, 1),
+          changeAndCommit(ps4_1, c4_1, 1),
+          changeAndCommit(ps1_1, c1_1, 1));
+    }
+
+    // The 6-7 branch is only related back to 1, not the other branches.
+    for (PatchSet.Id ps : ImmutableList.of(ps6_1, ps7_1)) {
+      assertRelated(
+          ps,
+          changeAndCommit(ps7_1, c7_1, 1),
+          changeAndCommit(ps6_1, c6_1, 1),
+          changeAndCommit(ps1_1, c1_1, 1));
+    }
+  }
+
+  @Test
+  public void getRelatedEdit() throws Exception {
+    // 1,1---2,1---3,1
+    //   \---2,E---/
+
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    RevCommit c3_1 = commitBuilder().add("c.txt", "3").message("subject: 3").create();
+    pushHead(testRepo, "refs/for/master", false);
+
+    Change ch2 = getChange(c2_1).change();
+    String changeId2 = ch2.getKey().get();
+    gApi.changes().id(changeId2).edit().create();
+    gApi.changes().id(changeId2).edit().modifyFile("a.txt", RawInputUtil.create(new byte[] {'a'}));
+    Optional<EditInfo> edit = getEdit(changeId2);
+    assertThat(edit).isPresent();
+    ObjectId editRev = ObjectId.fromString(edit.get().commit.commit);
+
+    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
+    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
+    PatchSet.Id ps2_edit = new PatchSet.Id(ch2.getId(), 0);
+    PatchSet.Id ps3_1 = getPatchSetId(c3_1);
+
+    for (PatchSet.Id ps : ImmutableList.of(ps1_1, ps2_1, ps3_1)) {
+      assertRelated(
+          ps,
+          changeAndCommit(ps3_1, c3_1, 1),
+          changeAndCommit(ps2_1, c2_1, 1),
+          changeAndCommit(ps1_1, c1_1, 1));
+    }
+
+    assertRelated(
+        ps2_edit,
+        changeAndCommit(ps3_1, c3_1, 1),
+        changeAndCommit(new PatchSet.Id(ch2.getId(), 0), editRev, 1),
+        changeAndCommit(ps1_1, c1_1, 1));
+  }
+
+  @Test
+  public void pushNewPatchSetWhenParentHasNullGroup() throws Exception {
+    // 1,1---2,1
+    //   \---2,2
+
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id psId1_1 = getPatchSetId(c1_1);
+    PatchSet.Id psId2_1 = getPatchSetId(c2_1);
+
+    for (PatchSet.Id psId : ImmutableList.of(psId1_1, psId2_1)) {
+      assertRelated(psId, changeAndCommit(psId2_1, c2_1, 1), changeAndCommit(psId1_1, c1_1, 1));
+    }
+
+    // Pretend PS1,1 was pushed before the groups field was added.
+    clearGroups(psId1_1);
+    indexer.index(changeDataFactory.create(db, project, psId1_1.getParentKey()));
+
+    // PS1,1 has no groups, so disappeared from related changes.
+    assertRelated(psId2_1);
+
+    RevCommit c2_2 = testRepo.amend(c2_1).add("c.txt", "2").create();
+    testRepo.reset(c2_2);
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id psId2_2 = getPatchSetId(c2_2);
+
+    // Push updated the group for PS1,1, so it shows up in related changes even
+    // though a new patch set was not pushed.
+    assertRelated(psId2_2, changeAndCommit(psId2_2, c2_2, 2), changeAndCommit(psId1_1, c1_1, 1));
+  }
+
+  @Test
+  @GerritConfig(name = "index.autoReindexIfStale", value = "false")
+  public void getRelatedForStaleChange() throws Exception {
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+
+    RevCommit c2_1 = commitBuilder().add("b.txt", "1").message("subject: 1").create();
+    pushHead(testRepo, "refs/for/master", false);
+
+    RevCommit c2_2 = testRepo.amend(c2_1).add("b.txt", "2").create();
+    testRepo.reset(c2_2);
+
+    disableChangeIndexWrites();
+    try {
+      pushHead(testRepo, "refs/for/master", false);
+    } finally {
+      enableChangeIndexWrites();
+    }
+
+    PatchSet.Id psId1_1 = getPatchSetId(c1_1);
+    PatchSet.Id psId2_1 = getPatchSetId(c2_1);
+    PatchSet.Id psId2_2 = new PatchSet.Id(psId2_1.changeId, psId2_1.get() + 1);
+
+    assertRelated(psId2_2, changeAndCommit(psId2_2, c2_2, 2), changeAndCommit(psId1_1, c1_1, 1));
+  }
+
+  @Test
+  public void getRelatedManyGroups() throws Exception {
+    List<RevCommit> commits = new ArrayList<>();
+    RevCommit last = null;
+    int n = 2 * MAX_TERMS;
+    assertThat(n).isGreaterThan(indexConfig.maxTerms());
+    for (int i = 1; i <= n; i++) {
+      TestRepository<?>.CommitBuilder cb = last != null ? amendBuilder() : commitBuilder();
+      last = cb.add("a.txt", Integer.toString(i)).message("subject: " + i).create();
+      testRepo.reset(last);
+      assertPushOk(pushHead(testRepo, "refs/for/master", false), "refs/for/master");
+      commits.add(last);
+    }
+
+    ChangeData cd = getChange(last);
+    assertThat(cd.patchSets().size()).isEqualTo(n);
+    assertThat(GetRelated.getAllGroups(cd.notes(), db, psUtil).size()).isEqualTo(n);
+
+    assertRelated(cd.change().currentPatchSetId());
+  }
+
+  private List<ChangeAndCommit> getRelated(PatchSet.Id ps) throws Exception {
+    return getRelated(ps.getParentKey(), ps.get());
+  }
+
+  private List<ChangeAndCommit> getRelated(Change.Id changeId, int ps) throws Exception {
+    String url = String.format("/changes/%d/revisions/%d/related", changeId.get(), ps);
+    RestResponse r = adminRestSession.get(url);
+    r.assertOK();
+    return newGson().fromJson(r.getReader(), RelatedInfo.class).changes;
+  }
+
+  private RevCommit parseBody(RevCommit c) throws Exception {
+    testRepo.getRevWalk().parseBody(c);
+    return c;
+  }
+
+  private PatchSet.Id getPatchSetId(ObjectId c) throws Exception {
+    return getChange(c).change().currentPatchSetId();
+  }
+
+  private ChangeData getChange(ObjectId c) throws Exception {
+    return Iterables.getOnlyElement(queryProvider.get().byCommit(c));
+  }
+
+  private ChangeAndCommit changeAndCommit(
+      PatchSet.Id psId, ObjectId commitId, int currentRevisionNum) {
+    ChangeAndCommit result = new ChangeAndCommit();
+    result.project = project.get();
+    result._changeNumber = psId.getParentKey().get();
+    result.commit = new CommitInfo();
+    result.commit.commit = commitId.name();
+    result._revisionNumber = psId.get();
+    result._currentRevisionNumber = currentRevisionNum;
+    result.status = "NEW";
+    return result;
+  }
+
+  private void clearGroups(PatchSet.Id psId) throws Exception {
+    try (BatchUpdate bu = batchUpdateFactory.create(db, project, user(user), TimeUtil.nowTs())) {
+      bu.addOp(
+          psId.getParentKey(),
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws OrmException {
+              PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+              psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, ImmutableList.<String>of());
+              ctx.dontBumpLastUpdatedOn();
+              return true;
+            }
+          });
+      bu.execute();
+    }
+  }
+
+  private void assertRelated(PatchSet.Id psId, ChangeAndCommit... expected) throws Exception {
+    List<ChangeAndCommit> actual = getRelated(psId);
+    assertThat(actual).named("related to " + psId).hasSize(expected.length);
+    for (int i = 0; i < actual.size(); i++) {
+      String name = "index " + i + " related to " + psId;
+      ChangeAndCommit a = actual.get(i);
+      ChangeAndCommit e = expected[i];
+      assertThat(a.project).named("project of " + name).isEqualTo(e.project);
+      assertThat(a._changeNumber).named("change ID of " + name).isEqualTo(e._changeNumber);
+      // Don't bother checking changeId; assume _changeNumber is sufficient.
+      assertThat(a._revisionNumber).named("revision of " + name).isEqualTo(e._revisionNumber);
+      assertThat(a.commit.commit).named("commit of " + name).isEqualTo(e.commit.commit);
+      assertThat(a._currentRevisionNumber)
+          .named("current revision of " + name)
+          .isEqualTo(e._currentRevisionNumber);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java
new file mode 100644
index 0000000..a3a0339
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+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.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import java.util.Collection;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class LegacyCommentsIT extends AbstractDaemonTest {
+  @Inject private ChangeNoteUtil noteUtil;
+
+  @ConfigSuite.Default
+  public static Config writeJsonFalseConfig() {
+    Config c = new Config();
+    c.setBoolean("noteDb", null, "writeJson", false);
+    return c;
+  }
+
+  @Before
+  public void setUp() {
+    setApiUser(user);
+  }
+
+  @Test
+  public void legacyCommentHasLegacyFormatTrue() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    assertThat(noteUtil.getWriteJson()).isFalse();
+
+    PushOneCommit.Result result = createChange();
+    Change.Id changeId = result.getChange().getId();
+
+    CommentInput cin = new CommentInput();
+    cin.message = "comment";
+    cin.path = PushOneCommit.FILE_NAME;
+
+    ReviewInput rin = new ReviewInput();
+    rin.comments = ImmutableMap.of(cin.path, ImmutableList.of(cin));
+    gApi.changes().id(changeId.get()).current().review(rin);
+
+    Collection<Comment> comments =
+        notesFactory.createChecked(db, project, changeId).getComments().values();
+    assertThat(comments).hasSize(1);
+    Comment comment = comments.iterator().next();
+    assertThat(comment.message).isEqualTo("comment");
+    assertThat(comment.legacyFormat).isTrue();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java b/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
rename to javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
new file mode 100644
index 0000000..304a1e4
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -0,0 +1,307 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.testing.ConfigSuite;
+import java.util.EnumSet;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class SubmittedTogetherIT extends AbstractDaemonTest {
+  @ConfigSuite.Config
+  public static Config submitWholeTopicEnabled() {
+    return submitWholeTopicEnabledConfig();
+  }
+
+  @Test
+  public void doesNotIncludeCurrentFiles() throws Exception {
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/master", false);
+
+    SubmittedTogetherInfo info =
+        gApi.changes().id(id2).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+    assertThat(info.changes).hasSize(2);
+    assertThat(info.changes.get(0).currentRevision).isEqualTo(c2_1.name());
+    assertThat(info.changes.get(1).currentRevision).isEqualTo(c1_1.name());
+
+    RevisionInfo rev = info.changes.get(0).revisions.get(c2_1.name());
+    assertThat(rev.files).isNull();
+  }
+
+  @Test
+  public void returnsCurrentFilesIfOptionRequested() throws Exception {
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/master", false);
+
+    SubmittedTogetherInfo info =
+        gApi.changes()
+            .id(id2)
+            .submittedTogether(
+                EnumSet.of(ListChangesOption.CURRENT_FILES), EnumSet.of(NON_VISIBLE_CHANGES));
+    assertThat(info.changes).hasSize(2);
+    assertThat(info.changes.get(0).currentRevision).isEqualTo(c2_1.name());
+    assertThat(info.changes.get(1).currentRevision).isEqualTo(c1_1.name());
+
+    RevisionInfo rev = info.changes.get(0).revisions.get(c2_1.name());
+    assertThat(rev).isNotNull();
+    FileInfo file = rev.files.get("b.txt");
+    assertThat(file).isNotNull();
+    assertThat(file.status).isEqualTo('A');
+  }
+
+  @Test
+  public void returnsAncestors() throws Exception {
+    // Create two commits and push.
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    String id1 = getChangeId(c1_1);
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/master", false);
+
+    assertSubmittedTogether(id1);
+    assertSubmittedTogether(id2, id2, id1);
+  }
+
+  @Test
+  public void anonymousAncestors() throws Exception {
+    RevCommit a = commitBuilder().add("a", "1").message("change 1").create();
+    RevCommit b = commitBuilder().add("b", "1").message("change 2").create();
+    pushHead(testRepo, "refs/for/master", false);
+
+    setApiUserAnonymous();
+    assertSubmittedTogether(getChangeId(a));
+    assertSubmittedTogether(getChangeId(b), getChangeId(b), getChangeId(a));
+  }
+
+  @Test
+  public void respectWholeTopic() 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);
+
+    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("connectingTopic"), false);
+
+    if (isSubmitWholeTopicEnabled()) {
+      assertSubmittedTogether(id1, id2, id1);
+      assertSubmittedTogether(id2, id2, id1);
+    } else {
+      assertSubmittedTogether(id1);
+      assertSubmittedTogether(id2);
+    }
+  }
+
+  @Test
+  public void anonymousWholeTopic() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    RevCommit a = commitBuilder().add("a", "1").message("change 1").create();
+    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    String id1 = getChangeId(a);
+
+    testRepo.reset(initialHead);
+    RevCommit b = commitBuilder().add("b", "1").message("change 2").create();
+    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    String id2 = getChangeId(b);
+
+    setApiUserAnonymous();
+    if (isSubmitWholeTopicEnabled()) {
+      assertSubmittedTogether(id1, id2, id1);
+      assertSubmittedTogether(id2, id2, id1);
+    } else {
+      assertSubmittedTogether(id1);
+      assertSubmittedTogether(id2);
+    }
+  }
+
+  @Test
+  public void topicChaining() 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("connectingTopic"), false);
+
+    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);
+
+    if (isSubmitWholeTopicEnabled()) {
+      assertSubmittedTogether(id1, id2, id1);
+      assertSubmittedTogether(id2, id2, id1);
+      assertSubmittedTogether(id3, id3, id2, id1);
+    } else {
+      assertSubmittedTogether(id1);
+      assertSubmittedTogether(id2);
+      assertSubmittedTogether(id3, id3, id2);
+    }
+  }
+
+  @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);
+
+    RevCommit c1 =
+        repo1
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .add("a.txt", "1")
+            .message("subject: 1")
+            .create();
+    String id1 = GitUtil.getChangeId(repo1, c1).get();
+    pushHead(repo1, "refs/for/master", false);
+
+    RevCommit c2 =
+        repo1
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .add("b.txt", "2")
+            .message("subject: 2")
+            .create();
+    String id2 = GitUtil.getChangeId(repo1, c2).get();
+    pushHead(repo1, "refs/for/master", false);
+    assertSubmittedTogether(id1);
+    assertSubmittedTogether(id2, id2, id1);
+  }
+
+  @Test
+  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
+  public void testCherryPickWithoutAncestors() throws Exception {
+    // Create two commits and push.
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    String id1 = getChangeId(c1_1);
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/master", false);
+
+    assertSubmittedTogether(id1);
+    assertSubmittedTogether(id2);
+  }
+
+  @Test
+  public void submissionIdSavedOnMergeInOneProject() throws Exception {
+    // Create two commits and push.
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    String id1 = getChangeId(c1_1);
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/master", false);
+
+    assertSubmittedTogether(id1);
+    assertSubmittedTogether(id2, id2, id1);
+
+    approve(id1);
+    approve(id2);
+    submit(id2);
+    assertMerged(id1);
+    assertMerged(id2);
+
+    // Prior to submission this was empty, but the post-merge value is what was
+    // actually submitted.
+    assertSubmittedTogether(id1, id2, id1);
+
+    assertSubmittedTogether(id2, id2, id1);
+  }
+
+  private String getChangeId(RevCommit c) throws Exception {
+    return GitUtil.getChangeId(testRepo, c).get();
+  }
+
+  private void submit(String changeId) throws Exception {
+    gApi.changes().id(changeId).current().submit();
+  }
+
+  private void assertMerged(String changeId) throws Exception {
+    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/event/BUILD b/javatests/com/google/gerrit/acceptance/server/event/BUILD
new file mode 100644
index 0000000..0db2cd8
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/event/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_event",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
new file mode 100644
index 0000000..86a9d18
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -0,0 +1,260 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.event;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.project.testing.Util.category;
+import static com.google.gerrit.server.project.testing.Util.value;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.testing.Util;
+import com.google.inject.Inject;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class CommentAddedEventIT extends AbstractDaemonTest {
+
+  @Inject private DynamicSet<CommentAddedListener> source;
+
+  private final LabelType label =
+      category("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+
+  private final LabelType pLabel =
+      category("CustomLabel2", value(1, "Positive"), value(0, "No score"));
+
+  private RegistrationHandle eventListenerRegistration;
+  private CommentAddedListener.Event lastCommentAddedEvent;
+
+  @Before
+  public void setUp() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    Util.allow(cfg, Permission.forLabel(label.getName()), -1, 1, anonymousUsers, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel(pLabel.getName()), 0, 1, anonymousUsers, "refs/heads/*");
+    saveProjectConfig(project, cfg);
+
+    eventListenerRegistration =
+        source.add(
+            new CommentAddedListener() {
+              @Override
+              public void onCommentAdded(Event event) {
+                lastCommentAddedEvent = event;
+              }
+            });
+  }
+
+  @After
+  public void cleanup() {
+    eventListenerRegistration.remove();
+  }
+
+  private void saveLabelConfig() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().put(label.getName(), label);
+    cfg.getLabelSections().put(pLabel.getName(), pLabel);
+    saveProjectConfig(project, cfg);
+  }
+
+  /* Need to lookup info for the label under test since there can be multiple
+   * labels defined.  By default Gerrit already has a Code-Review label.
+   */
+  private ApprovalValues getApprovalValues(LabelType label) {
+    ApprovalValues res = new ApprovalValues();
+    ApprovalInfo info = lastCommentAddedEvent.getApprovals().get(label.getName());
+    if (info != null) {
+      res.value = info.value;
+    }
+    info = lastCommentAddedEvent.getOldApprovals().get(label.getName());
+    if (info != null) {
+      res.oldValue = info.value;
+    }
+    return res;
+  }
+
+  @Test
+  public void newChangeWithVote() throws Exception {
+    saveLabelConfig();
+
+    // push a new change with -1 vote
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = new ReviewInput().label(label.getName(), (short) -1);
+    revision(r).review(reviewInput);
+    ApprovalValues attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(0);
+    assertThat(attr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
+  }
+
+  @Test
+  public void newPatchSetWithVote() throws Exception {
+    saveLabelConfig();
+
+    // push a new change
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = new ReviewInput().message(label.getName());
+    revision(r).review(reviewInput);
+
+    // push a new revision with +1 vote
+    ChangeInfo c = info(r.getChangeId());
+    r = amendChange(c.changeId);
+    reviewInput = new ReviewInput().label(label.getName(), (short) 1);
+    revision(r).review(reviewInput);
+    ApprovalValues attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(0);
+    assertThat(attr.value).isEqualTo(1);
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 2: %s+1", label.getName()));
+  }
+
+  @Test
+  public void reviewChange() throws Exception {
+    saveLabelConfig();
+
+    // push a change
+    PushOneCommit.Result r = createChange();
+
+    // review with message only, do not apply votes
+    ReviewInput reviewInput = new ReviewInput().message(label.getName());
+    revision(r).review(reviewInput);
+    // reply message only so vote is shown as 0
+    ApprovalValues attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isNull();
+    assertThat(attr.value).isEqualTo(0);
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1:\n\n%s", label.getName()));
+
+    // transition from un-voted to -1 vote
+    reviewInput = new ReviewInput().label(label.getName(), -1);
+    revision(r).review(reviewInput);
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(0);
+    assertThat(attr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
+
+    // transition vote from -1 to 0
+    reviewInput = new ReviewInput().label(label.getName(), 0);
+    revision(r).review(reviewInput);
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(-1);
+    assertThat(attr.value).isEqualTo(0);
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1: -%s", label.getName()));
+
+    // transition vote from 0 to 1
+    reviewInput = new ReviewInput().label(label.getName(), 1);
+    revision(r).review(reviewInput);
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(0);
+    assertThat(attr.value).isEqualTo(1);
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1: %s+1", label.getName()));
+
+    // transition vote from 1 to -1
+    reviewInput = new ReviewInput().label(label.getName(), -1);
+    revision(r).review(reviewInput);
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(1);
+    assertThat(attr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
+
+    // review with message only, do not apply votes
+    reviewInput = new ReviewInput().message(label.getName());
+    revision(r).review(reviewInput);
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isNull(); // no vote change so not included
+    assertThat(attr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1:\n\n%s", label.getName()));
+  }
+
+  @Test
+  public void reviewChange_MultipleVotes() throws Exception {
+    saveLabelConfig();
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = new ReviewInput().label(label.getName(), -1);
+    reviewInput.message = label.getName();
+    revision(r).review(reviewInput);
+
+    ChangeInfo c = get(r.getChangeId(), DETAILED_LABELS);
+    LabelInfo q = c.labels.get(label.getName());
+    assertThat(q.all).hasSize(1);
+    ApprovalValues labelAttr = getApprovalValues(label);
+    assertThat(labelAttr.oldValue).isEqualTo(0);
+    assertThat(labelAttr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1: %s-1\n\n%s", label.getName(), label.getName()));
+
+    // there should be 3 approval labels (label, pLabel, and CRVV)
+    assertThat(lastCommentAddedEvent.getApprovals()).hasSize(3);
+
+    // check the approvals that were not voted on
+    ApprovalValues pLabelAttr = getApprovalValues(pLabel);
+    assertThat(pLabelAttr.oldValue).isNull();
+    assertThat(pLabelAttr.value).isEqualTo(0);
+
+    LabelType crLabel = LabelType.withDefaultValues("Code-Review");
+    ApprovalValues crlAttr = getApprovalValues(crLabel);
+    assertThat(crlAttr.oldValue).isNull();
+    assertThat(crlAttr.value).isEqualTo(0);
+
+    // update pLabel approval
+    reviewInput = new ReviewInput().label(pLabel.getName(), 1);
+    reviewInput.message = pLabel.getName();
+    revision(r).review(reviewInput);
+
+    c = get(r.getChangeId(), DETAILED_LABELS);
+    q = c.labels.get(label.getName());
+    assertThat(q.all).hasSize(1);
+    pLabelAttr = getApprovalValues(pLabel);
+    assertThat(pLabelAttr.oldValue).isEqualTo(0);
+    assertThat(pLabelAttr.value).isEqualTo(1);
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1: %s+1\n\n%s", pLabel.getName(), pLabel.getName()));
+
+    // check the approvals that were not voted on
+    labelAttr = getApprovalValues(label);
+    assertThat(labelAttr.oldValue).isNull();
+    assertThat(labelAttr.value).isEqualTo(-1);
+
+    crlAttr = getApprovalValues(crLabel);
+    assertThat(crlAttr.oldValue).isNull();
+    assertThat(crlAttr.value).isEqualTo(0);
+  }
+
+  private static class ApprovalValues {
+    Integer value;
+    Integer oldValue;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java b/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
new file mode 100644
index 0000000..32f1ce5
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
@@ -0,0 +1,147 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.mail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+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.junit.Ignore;
+
+@Ignore
+public class AbstractMailIT extends AbstractDaemonTest {
+
+  protected MailMessage.Builder messageBuilderWithDefaultFields() {
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("some id");
+    b.from(user.emailAddress);
+    b.addTo(user.emailAddress); // Not evaluated
+    b.subject("");
+    b.dateReceived(Instant.now());
+    return b;
+  }
+
+  protected String createChangeWithReview() throws Exception {
+    return createChangeWithReview(admin);
+  }
+
+  protected String createChangeWithReview(TestAccount reviewer) throws Exception {
+    // Create change
+    String file = "gerrit-server/test.txt";
+    String contents = "contents \nlorem \nipsum \nlorem";
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    String changeId = r.getChangeId();
+
+    // Review it
+    setApiUser(reviewer);
+    ReviewInput input = new ReviewInput();
+    input.message = "I have two comments";
+    input.comments = new HashMap<>();
+    CommentInput c1 = newComment(file, Side.REVISION, 0, "comment on file");
+    CommentInput c2 = newComment(file, Side.REVISION, 2, "inline comment");
+    input.comments.put(c1.path, ImmutableList.of(c1, c2));
+    revision(r).review(input);
+    return changeId;
+  }
+
+  protected static CommentInput newComment(String path, Side side, int line, String message) {
+    CommentInput c = new CommentInput();
+    c.path = path;
+    c.side = side;
+    c.line = line != 0 ? line : null;
+    c.message = message;
+    if (line != 0) {
+      Comment.Range range = new Comment.Range();
+      range.startLine = line;
+      range.startCharacter = 1;
+      range.endLine = line;
+      range.endCharacter = 5;
+      c.range = range;
+    }
+    return c;
+  }
+
+  /**
+   * Create a plaintext message body with the specified comments.
+   *
+   * @param changeMessage
+   * @param c1 Comment in reply to first inline comment.
+   * @param f1 Comment on file one.
+   * @param fc1 Comment in reply to a comment of file 1.
+   * @return A string with all inline comments and the original quoted email.
+   */
+  protected static String newPlaintextBody(
+      String changeURL, String changeMessage, String c1, String f1, String fc1) {
+    return (changeMessage == null ? "" : changeMessage + "\n")
+        + "> Foo Bar has posted comments on this change. (  \n"
+        + "> "
+        + changeURL
+        + " )\n"
+        + "> \n"
+        + "> Change subject: Test change\n"
+        + "> ...............................................................\n"
+        + "> \n"
+        + "> \n"
+        + "> Patch Set 1: Code-Review+1\n"
+        + "> \n"
+        + "> (3 comments)\n"
+        + "> \n"
+        + "> "
+        + changeURL
+        + "/gerrit-server/test.txt\n"
+        + "> File  \n"
+        + "> gerrit-server/test.txt:\n"
+        + (f1 == null ? "" : f1 + "\n")
+        + "> \n"
+        + "> Patch Set #4:\n"
+        + "> "
+        + changeURL
+        + "/gerrit-server/test.txt\n"
+        + "> \n"
+        + "> Some comment"
+        + "> \n"
+        + (fc1 == null ? "" : fc1 + "\n")
+        + "> "
+        + changeURL
+        + "/gerrit-server/test.txt@2\n"
+        + "> PS1, Line 2: throw new Exception(\"Object has unsupported: \" +\n"
+        + ">               :             entry.getValue() +\n"
+        + ">               :             \" must be java.util.Date\");\n"
+        + "> Should entry.getKey() be included in this message?\n"
+        + "> \n"
+        + (c1 == null ? "" : c1 + "\n")
+        + "> \n";
+  }
+
+  protected static String textFooterForChange(int changeNumber, String timestamp) {
+    return "Gerrit-Change-Number: "
+        + changeNumber
+        + "\n"
+        + "Gerrit-PatchSet: 1\n"
+        + "Gerrit-MessageType: comment\n"
+        + "Gerrit-Comment-Date: "
+        + timestamp
+        + "\n";
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/BUILD b/javatests/com/google/gerrit/acceptance/server/mail/BUILD
new file mode 100644
index 0000000..1ef3850
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/mail/BUILD
@@ -0,0 +1,23 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+DEPS = [
+    "//lib/greenmail",
+    "//lib/mail",
+]
+
+acceptance_tests(
+    srcs = glob(
+        ["*IT.java"],
+        exclude = ["AbstractMailIT.java"],
+    ),
+    group = "server_mail",
+    labels = ["server"],
+    deps = DEPS + [":util"],
+)
+
+java_library(
+    name = "util",
+    testonly = 1,
+    srcs = ["AbstractMailIT.java"],
+    deps = DEPS + ["//java/com/google/gerrit/acceptance:lib"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
new file mode 100644
index 0000000..11edb50
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -0,0 +1,2577 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.mail;
+
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.api.changes.NotifyHandling.ALL;
+import static com.google.gerrit.extensions.api.changes.NotifyHandling.NONE;
+import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER;
+import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER_REVIEWERS;
+import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
+import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.ENABLED;
+import static com.google.gerrit.server.account.WatchConfig.NotifyType.ABANDONED_CHANGES;
+import static com.google.gerrit.server.account.WatchConfig.NotifyType.ALL_COMMENTS;
+import static com.google.gerrit.server.account.WatchConfig.NotifyType.NEW_CHANGES;
+import static com.google.gerrit.server.account.WatchConfig.NotifyType.NEW_PATCHSETS;
+import static com.google.gerrit.server.account.WatchConfig.NotifyType.SUBMITTED_CHANGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.Truth;
+import com.google.gerrit.acceptance.AbstractNotificationTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.AbandonInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.CommitMessageInput;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.restapi.change.PostReview;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ChangeNotificationsIT extends AbstractNotificationTest {
+  /*
+   * Set up for extra standard test accounts and permissions.
+   */
+  private TestAccount other;
+  private TestAccount extraReviewer;
+  private TestAccount extraCcer;
+
+  @Before
+  public void createExtraAccounts() throws Exception {
+    extraReviewer =
+        accountCreator.create("extraReviewer", "extraReviewer@example.com", "extraReviewer");
+    extraCcer = accountCreator.create("extraCcer", "extraCcer@example.com", "extraCcer");
+    other = accountCreator.create("other", "other@example.com", "other");
+  }
+
+  @Before
+  public void grantPermissions() throws Exception {
+    grant(project, "refs/*", Permission.FORGE_COMMITTER, false, REGISTERED_USERS);
+    grant(project, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
+    grant(project, "refs/heads/master", Permission.ABANDON, false, REGISTERED_USERS);
+    ProjectConfig cfg = projectCache.get(project).getConfig();
+    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
+  }
+
+  /*
+   * AbandonedSender tests.
+   */
+
+  @Test
+  public void abandonReviewableChangeByOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    abandon(sc.changeId, sc.owner);
+    assertThat(sender)
+        .sent("abandon", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ABANDONED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void abandonReviewableChangeByOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    abandon(sc.changeId, sc.owner, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("abandon", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ABANDONED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void abandonReviewableChangeByOther() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    abandon(sc.changeId, other);
+    assertThat(sender)
+        .sent("abandon", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ABANDONED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void abandonReviewableChangeByOtherCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    abandon(sc.changeId, other, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("abandon", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, other)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ABANDONED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void abandonReviewableChangeNotifyOwnersReviewers() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    abandon(sc.changeId, sc.owner, OWNER_REVIEWERS);
+    assertThat(sender)
+        .sent("abandon", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void abandonReviewableChangeNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    abandon(sc.changeId, sc.owner, OWNER);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void abandonReviewableChangeNotifyOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    abandon(sc.changeId, sc.owner, CC_ON_OWN_COMMENTS, OWNER);
+    // Self-CC applies *after* need for sending notification is determined.
+    // Since there are no recipients before including the user taking action,
+    // there should no notification sent.
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void abandonReviewableChangeByOtherCcingSelfNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    abandon(sc.changeId, other, CC_ON_OWN_COMMENTS, OWNER);
+    assertThat(sender).sent("abandon", sc).to(sc.owner).cc(other).noOneElse();
+  }
+
+  @Test
+  public void abandonReviewableChangeNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    abandon(sc.changeId, sc.owner, NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void abandonReviewableChangeNotifyNoneCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    abandon(sc.changeId, sc.owner, CC_ON_OWN_COMMENTS, NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void abandonReviewableWipChange() throws Exception {
+    StagedChange sc = stageReviewableWipChange();
+    abandon(sc.changeId, sc.owner);
+    assertThat(sender)
+        .sent("abandon", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ABANDONED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void abandonWipChange() throws Exception {
+    StagedChange sc = stageWipChange();
+    abandon(sc.changeId, sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void abandonWipChangeNotifyAll() throws Exception {
+    StagedChange sc = stageWipChange();
+    abandon(sc.changeId, sc.owner, ALL);
+    assertThat(sender)
+        .sent("abandon", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ABANDONED_CHANGES)
+        .noOneElse();
+  }
+
+  private void abandon(String changeId, TestAccount by) throws Exception {
+    abandon(changeId, by, ENABLED);
+  }
+
+  private void abandon(String changeId, TestAccount by, EmailStrategy emailStrategy)
+      throws Exception {
+    abandon(changeId, by, emailStrategy, null);
+  }
+
+  private void abandon(String changeId, TestAccount by, @Nullable NotifyHandling notify)
+      throws Exception {
+    abandon(changeId, by, ENABLED, notify);
+  }
+
+  private void abandon(
+      String changeId, TestAccount by, EmailStrategy emailStrategy, @Nullable NotifyHandling notify)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    setApiUser(by);
+    AbandonInput in = new AbandonInput();
+    if (notify != null) {
+      in.notify = notify;
+    }
+    gApi.changes().id(changeId).abandon(in);
+  }
+
+  /*
+   * AddReviewerSender tests.
+   */
+
+  private void addReviewerToReviewableChangeInReviewDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInReviewDbSingly() throws Exception {
+    addReviewerToReviewableChangeInReviewDb(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInReviewDbBatch() throws Exception {
+    addReviewerToReviewableChangeInReviewDb(batch());
+  }
+
+  private void addReviewerToReviewableChangeInNoteDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.reviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbSingly() throws Exception {
+    addReviewerToReviewableChangeInNoteDb(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbBatch() throws Exception {
+    addReviewerToReviewableChangeInNoteDb(batch());
+  }
+
+  private void addReviewerToReviewableChangeByOwnerCcingSelfInNoteDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, CC_ON_OWN_COMMENTS, null);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.owner, sc.reviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeByOwnerCcingSelfInNoteDbSingly() throws Exception {
+    addReviewerToReviewableChangeByOwnerCcingSelfInNoteDb(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeByOwnerCcingSelfInNoteDbBatch() throws Exception {
+    addReviewerToReviewableChangeByOwnerCcingSelfInNoteDb(batch());
+  }
+
+  private void addReviewerToReviewableChangeByOtherInNoteDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, other, reviewer.email);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.owner, sc.reviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeByOtherInNoteDbSingly() throws Exception {
+    addReviewerToReviewableChangeByOtherInNoteDb(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeByOtherInNoteDbBatch() throws Exception {
+    addReviewerToReviewableChangeByOtherInNoteDb(batch());
+  }
+
+  private void addReviewerToReviewableChangeByOtherCcingSelfInNoteDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, other, reviewer.email, CC_ON_OWN_COMMENTS, null);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.owner, sc.reviewer, other)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeByOtherCcingSelfInNoteDbSingly() throws Exception {
+    addReviewerToReviewableChangeByOtherCcingSelfInNoteDb(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeByOtherCcingSelfInNoteDbBatch() throws Exception {
+    addReviewerToReviewableChangeByOtherCcingSelfInNoteDb(batch());
+  }
+
+  private void addReviewerByEmailToReviewableChangeInReviewDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    String email = "addedbyemail@example.com";
+    StagedChange sc = stageReviewableChange();
+    addReviewer(adder, sc.changeId, sc.owner, email);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void addReviewerByEmailToReviewableChangeInReviewDbSingly() throws Exception {
+    addReviewerByEmailToReviewableChangeInReviewDb(singly());
+  }
+
+  @Test
+  public void addReviewerByEmailToReviewableChangeInReviewDbBatch() throws Exception {
+    addReviewerByEmailToReviewableChangeInReviewDb(batch());
+  }
+
+  private void addReviewerByEmailToReviewableChangeInNoteDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    String email = "addedbyemail@example.com";
+    StagedChange sc = stageReviewableChange();
+    addReviewer(adder, sc.changeId, sc.owner, email);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(email)
+        .cc(sc.reviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerByEmailToReviewableChangeInNoteDbSingly() throws Exception {
+    addReviewerByEmailToReviewableChangeInNoteDb(singly());
+  }
+
+  @Test
+  public void addReviewerByEmailToReviewableChangeInNoteDbBatch() throws Exception {
+    addReviewerByEmailToReviewableChangeInNoteDb(batch());
+  }
+
+  private void addReviewerToWipChange(Adder adder) throws Exception {
+    StagedChange sc = stageWipChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void addReviewerToWipChangeSingly() throws Exception {
+    addReviewerToWipChange(singly());
+  }
+
+  @Test
+  public void addReviewerToWipChangeBatch() throws Exception {
+    addReviewerToWipChange(batch());
+  }
+
+  private void addReviewerToReviewableWipChange(Adder adder) throws Exception {
+    StagedChange sc = stageReviewableWipChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void addReviewerToReviewableWipChangeSingly() throws Exception {
+    addReviewerToReviewableWipChange(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableWipChangeBatch() throws Exception {
+    addReviewerToReviewableWipChange(batch());
+  }
+
+  private void addReviewerToWipChangeInNoteDbNotifyAll(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageWipChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, NotifyHandling.ALL);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.reviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToWipChangeInNoteDbNotifyAllSingly() throws Exception {
+    addReviewerToWipChangeInNoteDbNotifyAll(singly());
+  }
+
+  @Test
+  public void addReviewerToWipChangeInNoteDbNotifyAllBatch() throws Exception {
+    addReviewerToWipChangeInNoteDbNotifyAll(batch());
+  }
+
+  private void addReviewerToWipChangeInReviewDbNotifyAll(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageWipChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, NotifyHandling.ALL);
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToWipChangeInReviewDbNotifyAllSingly() throws Exception {
+    addReviewerToWipChangeInReviewDbNotifyAll(singly());
+  }
+
+  @Test
+  public void addReviewerToWipChangeInReviewDbNotifyAllBatch() throws Exception {
+    addReviewerToWipChangeInReviewDbNotifyAll(batch());
+  }
+
+  private void addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewers(Adder adder)
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, OWNER_REVIEWERS);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.reviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewersSingly() throws Exception {
+    addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewers(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewersBatch() throws Exception {
+    addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewers(batch());
+  }
+
+  private void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwner(Adder adder)
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, CC_ON_OWN_COMMENTS, OWNER);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwnerSingly()
+      throws Exception {
+    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwner(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwnerBatch()
+      throws Exception {
+    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwner(batch());
+  }
+
+  private void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNone(Adder adder)
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, CC_ON_OWN_COMMENTS, NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNoneSingly()
+      throws Exception {
+    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNone(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNoneBatch()
+      throws Exception {
+    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNone(batch());
+  }
+
+  private interface Adder {
+    void addReviewer(String changeId, String reviewer, @Nullable NotifyHandling notify)
+        throws Exception;
+  }
+
+  private Adder singly() {
+    return (String changeId, String reviewer, @Nullable NotifyHandling notify) -> {
+      AddReviewerInput in = new AddReviewerInput();
+      in.reviewer = reviewer;
+      if (notify != null) {
+        in.notify = notify;
+      }
+      gApi.changes().id(changeId).addReviewer(in);
+    };
+  }
+
+  private Adder batch() {
+    return (String changeId, String reviewer, @Nullable NotifyHandling notify) -> {
+      ReviewInput in = ReviewInput.noScore();
+      in.reviewer(reviewer);
+      if (notify != null) {
+        in.notify = notify;
+      }
+      gApi.changes().id(changeId).revision("current").review(in);
+    };
+  }
+
+  private void addReviewer(Adder adder, String changeId, TestAccount by, String reviewer)
+      throws Exception {
+    addReviewer(adder, changeId, by, reviewer, ENABLED, null);
+  }
+
+  private void addReviewer(
+      Adder adder, String changeId, TestAccount by, String reviewer, NotifyHandling notify)
+      throws Exception {
+    addReviewer(adder, changeId, by, reviewer, ENABLED, notify);
+  }
+
+  private void addReviewer(
+      Adder adder,
+      String changeId,
+      TestAccount by,
+      String reviewer,
+      EmailStrategy emailStrategy,
+      @Nullable NotifyHandling notify)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    setApiUser(by);
+    adder.addReviewer(changeId, reviewer, notify);
+  }
+
+  /*
+   * CommentSender tests.
+   */
+
+  @Test
+  public void commentOnReviewableChangeByOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    review(sc.owner, sc.changeId, ENABLED);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByReviewer() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    review(sc.reviewer, sc.changeId, ENABLED);
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    review(sc.owner, sc.changeId, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByReviewerCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    review(sc.reviewer, sc.changeId, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOther() throws Exception {
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    StagedChange sc = stageReviewableChange();
+    review(other, sc.changeId, ENABLED);
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOtherCcingSelf() throws Exception {
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    StagedChange sc = stageReviewableChange();
+    review(other, sc.changeId, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, other)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOwnerNotifyOwnerReviewers() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    review(sc.owner, sc.changeId, ENABLED, OWNER_REVIEWERS);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOwnerNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    review(sc.owner, sc.changeId, ENABLED, OWNER);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOwnerCcingSelfNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
+    review(sc.owner, sc.changeId, ENABLED, OWNER);
+    assertThat(sender).notSent(); // TODO(logan): Why not send to owner?
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOwnerNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    review(sc.owner, sc.changeId, ENABLED, NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOwnerCcingSelfNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
+    review(sc.owner, sc.changeId, ENABLED, NONE);
+    assertThat(sender).notSent(); // TODO(logan): Why not send to owner?
+  }
+
+  @Test
+  public void commentOnReviewableChangeByBot() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    TestAccount bot = sc.testAccount("bot");
+    review(bot, sc.changeId, ENABLED, null, "autogenerated:bot");
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnWipChangeByOwner() throws Exception {
+    StagedChange sc = stageWipChange();
+    review(sc.owner, sc.changeId, ENABLED);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void commentOnWipChangeByOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageWipChange();
+    review(sc.owner, sc.changeId, CC_ON_OWN_COMMENTS);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void commentOnWipChangeByOwnerNotifyAll() throws Exception {
+    StagedChange sc = stageWipChange();
+    review(sc.owner, sc.changeId, ENABLED, ALL);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnWipChangeByBot() throws Exception {
+    StagedChange sc = stageWipChange();
+    TestAccount bot = sc.testAccount("bot");
+    review(bot, sc.changeId, ENABLED, null, "autogenerated:tag");
+    assertThat(sender).sent("comment", sc).to(sc.owner).noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableWipChangeByBot() throws Exception {
+    StagedChange sc = stageReviewableWipChange();
+    TestAccount bot = sc.testAccount("bot");
+    review(bot, sc.changeId, ENABLED, null, "autogenerated:tag");
+    assertThat(sender).sent("comment", sc).to(sc.owner).noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableWipChangeByBotNotifyAll() throws Exception {
+    StagedChange sc = stageWipChange();
+    TestAccount bot = sc.testAccount("bot");
+    review(bot, sc.changeId, ENABLED, ALL, "tag");
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableWipChangeByOwner() throws Exception {
+    StagedChange sc = stageReviewableWipChange();
+    review(sc.owner, sc.changeId, ENABLED);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void noCommentAndSetWorkInProgress() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
+    gApi.changes().id(sc.changeId).revision("current").review(in);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void commentAndSetWorkInProgress() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    ReviewInput in = ReviewInput.noScore().message("ok").setWorkInProgress(true);
+    gApi.changes().id(sc.changeId).revision("current").review(in);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void commentOnWipChangeAndStartReview() throws Exception {
+    StagedChange sc = stageWipChange();
+    ReviewInput in = ReviewInput.noScore().message("ok").setWorkInProgress(false);
+    gApi.changes().id(sc.changeId).revision("current").review(in);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void addReviewerOnWipChangeAndStartReview() throws Exception {
+    StagedChange sc = stageWipChange();
+    ReviewInput in = ReviewInput.noScore().reviewer(other.email).setWorkInProgress(false);
+    gApi.changes().id(sc.changeId).revision("current").review(in);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer, other)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void startReviewMessageNotRepeated() throws Exception {
+    // TODO(logan): Remove this test check once PolyGerrit workaround is rolled back.
+    StagedChange sc = stageWipChange();
+    ReviewInput in =
+        ReviewInput.noScore().message(PostReview.START_REVIEW_MESSAGE).setWorkInProgress(false);
+    gApi.changes().id(sc.changeId).revision("current").review(in);
+    Truth.assertThat(sender.getMessages()).isNotEmpty();
+    String body = sender.getMessages().get(0).body();
+    int idx = body.indexOf(PostReview.START_REVIEW_MESSAGE);
+    Truth.assertThat(idx).isAtLeast(0);
+    Truth.assertThat(body.indexOf(PostReview.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
+  }
+
+  private void review(TestAccount account, String changeId, EmailStrategy strategy)
+      throws Exception {
+    review(account, changeId, strategy, null);
+  }
+
+  private void review(
+      TestAccount account, String changeId, EmailStrategy strategy, @Nullable NotifyHandling notify)
+      throws Exception {
+    review(account, changeId, strategy, notify, null);
+  }
+
+  private void review(
+      TestAccount account,
+      String changeId,
+      EmailStrategy strategy,
+      @Nullable NotifyHandling notify,
+      @Nullable String tag)
+      throws Exception {
+    setEmailStrategy(account, strategy);
+    ReviewInput in = ReviewInput.recommend();
+    in.notify = notify;
+    in.tag = tag;
+    gApi.changes().id(changeId).revision("current").review(in);
+  }
+
+  /*
+   * CreateChangeSender tests.
+   */
+
+  @Test
+  public void createReviewableChange() throws Exception {
+    StagedPreChange spc = stagePreChange("refs/for/master");
+    assertThat(sender)
+        .sent("newchange", spc)
+        .to(spc.watchingProjectOwner)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void createWipChange() throws Exception {
+    stagePreChange("refs/for/master%wip");
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void createReviewableChangeWithNotifyOwnerReviewers() throws Exception {
+    stagePreChange("refs/for/master%notify=OWNER_REVIEWERS");
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void createReviewableChangeWithNotifyOwner() throws Exception {
+    stagePreChange("refs/for/master%notify=OWNER");
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void createReviewableChangeWithNotifyNone() throws Exception {
+    stagePreChange("refs/for/master%notify=OWNER");
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void createWipChangeWithNotifyAll() throws Exception {
+    StagedPreChange spc = stagePreChange("refs/for/master%wip,notify=ALL");
+    assertThat(sender)
+        .sent("newchange", spc)
+        .to(spc.watchingProjectOwner)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void createReviewableChangeWithReviewersAndCcs() throws Exception {
+    // TODO(logan): Support reviewers/CCs-by-email via push option.
+    StagedPreChange spc =
+        stagePreChange(
+            "refs/for/master",
+            users -> ImmutableList.of("r=" + users.reviewer.username, "cc=" + users.ccer.username));
+    assertThat(sender)
+        .sent("newchange", spc)
+        .to(spc.reviewer, spc.watchingProjectOwner)
+        .cc(spc.ccer)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  /*
+   * DeleteReviewerSender tests.
+   */
+
+  @Test
+  public void deleteReviewerFromReviewableChange() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setApiUser(sc.owner);
+    removeReviewer(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteReviewer", sc)
+        .to(extraReviewer)
+        .cc(extraCcer, sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeByOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setEmailStrategy(sc.owner, EmailStrategy.CC_ON_OWN_COMMENTS);
+    removeReviewer(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteReviewer", sc)
+        .to(sc.owner, extraReviewer)
+        .cc(extraCcer, sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeByAdmin() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setApiUser(admin);
+    removeReviewer(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteReviewer", sc)
+        .to(sc.owner, extraReviewer)
+        .cc(extraCcer, sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeByAdminCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setEmailStrategy(admin, EmailStrategy.CC_ON_OWN_COMMENTS);
+    setApiUser(admin);
+    removeReviewer(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteReviewer", sc)
+        .to(sc.owner, extraReviewer)
+        .cc(admin, extraCcer, sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteCcerFromReviewableChange() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setApiUser(sc.owner);
+    removeReviewer(sc, extraCcer);
+    assertThat(sender)
+        .sent("deleteReviewer", sc)
+        .to(extraCcer)
+        .cc(sc.reviewer, sc.ccer, extraReviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeNotifyOwnerReviewers() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setApiUser(sc.owner);
+    removeReviewer(sc, extraReviewer, NotifyHandling.OWNER_REVIEWERS);
+    assertThat(sender)
+        .sent("deleteReviewer", sc)
+        .to(extraReviewer)
+        .cc(extraCcer, sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    removeReviewer(sc, extraReviewer, NotifyHandling.OWNER);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeByOwnerCcingSelfNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setEmailStrategy(sc.owner, EmailStrategy.CC_ON_OWN_COMMENTS);
+    removeReviewer(sc, extraReviewer, NotifyHandling.OWNER);
+    assertThat(sender).sent("deleteReviewer", sc).to(sc.owner, extraReviewer).noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    removeReviewer(sc, extraReviewer, NotifyHandling.NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeByOwnerCcingSelfNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setEmailStrategy(sc.owner, EmailStrategy.CC_ON_OWN_COMMENTS);
+    removeReviewer(sc, extraReviewer, NotifyHandling.NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableWipChange() throws Exception {
+    StagedChange sc = stageReviewableWipChangeWithExtraReviewer();
+    removeReviewer(sc, extraReviewer);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteReviewerFromWipChange() throws Exception {
+    StagedChange sc = stageWipChangeWithExtraReviewer();
+    removeReviewer(sc, extraReviewer);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteReviewerFromWipChangeNotifyAll() throws Exception {
+    StagedChange sc = stageWipChangeWithExtraReviewer();
+    setApiUser(sc.owner);
+    removeReviewer(sc, extraReviewer, NotifyHandling.ALL);
+    assertThat(sender)
+        .sent("deleteReviewer", sc)
+        .to(extraReviewer)
+        .cc(extraCcer, sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerWithApprovalFromWipChange() throws Exception {
+    StagedChange sc = stageWipChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(sc.owner);
+    removeReviewer(sc, extraReviewer);
+    assertThat(sender).sent("deleteReviewer", sc).to(extraReviewer).noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerWithApprovalFromWipChangeNotifyOwner() throws Exception {
+    StagedChange sc = stageWipChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    removeReviewer(sc, extraReviewer, NotifyHandling.OWNER);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteReviewerByEmailFromWipChangeInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageWipChangeWithExtraReviewer();
+    gApi.changes().id(sc.changeId).reviewer(sc.reviewerByEmail).remove();
+    assertThat(sender).notSent();
+  }
+
+  private void recommend(StagedChange sc, TestAccount by) throws Exception {
+    setApiUser(by);
+    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.recommend());
+  }
+
+  private interface Stager {
+    StagedChange stage() throws Exception;
+  }
+
+  private StagedChange stageChangeWithExtraReviewer(Stager stager) throws Exception {
+    StagedChange sc = stager.stage();
+    ReviewInput in =
+        ReviewInput.noScore()
+            .reviewer(extraReviewer.email)
+            .reviewer(extraCcer.email, ReviewerState.CC, false);
+    setApiUser(extraReviewer);
+    gApi.changes().id(sc.changeId).revision("current").review(in);
+    return sc;
+  }
+
+  private StagedChange stageReviewableChangeWithExtraReviewer() throws Exception {
+    return stageChangeWithExtraReviewer(this::stageReviewableChange);
+  }
+
+  private StagedChange stageReviewableWipChangeWithExtraReviewer() throws Exception {
+    return stageChangeWithExtraReviewer(this::stageReviewableWipChange);
+  }
+
+  private StagedChange stageWipChangeWithExtraReviewer() throws Exception {
+    return stageChangeWithExtraReviewer(this::stageWipChange);
+  }
+
+  private void removeReviewer(StagedChange sc, TestAccount account) throws Exception {
+    sender.clear();
+    gApi.changes().id(sc.changeId).reviewer(account.email).remove();
+  }
+
+  private void removeReviewer(StagedChange sc, TestAccount account, NotifyHandling notify)
+      throws Exception {
+    sender.clear();
+    DeleteReviewerInput in = new DeleteReviewerInput();
+    in.notify = notify;
+    gApi.changes().id(sc.changeId).reviewer(account.email).remove(in);
+  }
+
+  /*
+   * DeleteVoteSender tests.
+   */
+
+  @Test
+  public void deleteVoteFromReviewableChange() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeWithSelfCc() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeByAdmin() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(admin);
+    deleteVote(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeByAdminCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setEmailStrategy(admin, EmailStrategy.CC_ON_OWN_COMMENTS);
+    setApiUser(admin);
+    deleteVote(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, admin, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeNotifyOwnerReviewers() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer, NotifyHandling.OWNER_REVIEWERS);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeNotifyOwnerReviewersWithSelfCc() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer, NotifyHandling.OWNER_REVIEWERS);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(admin);
+    deleteVote(sc, extraReviewer, NotifyHandling.OWNER);
+    assertThat(sender).sent("deleteVote", sc).to(sc.owner).noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer, NotifyHandling.NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeNotifyNoneWithSelfCc() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer, NotifyHandling.NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableWipChange() throws Exception {
+    StagedChange sc = stageReviewableWipChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromWipChange() throws Exception {
+    StagedChange sc = stageWipChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  private void deleteVote(StagedChange sc, TestAccount account) throws Exception {
+    sender.clear();
+    gApi.changes().id(sc.changeId).reviewer(account.email).deleteVote("Code-Review");
+  }
+
+  private void deleteVote(StagedChange sc, TestAccount account, NotifyHandling notify)
+      throws Exception {
+    sender.clear();
+    DeleteVoteInput in = new DeleteVoteInput();
+    in.label = "Code-Review";
+    in.notify = notify;
+    gApi.changes().id(sc.changeId).reviewer(account.email).deleteVote(in);
+  }
+
+  /*
+   * MergedSender tests.
+   */
+
+  @Test
+  public void mergeByOwner() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, sc.owner);
+    assertThat(sender)
+        .sent("merged", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void mergeByOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, sc.owner, EmailStrategy.CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void mergeByReviewer() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, sc.reviewer);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void mergeByReviewerCcingSelf() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, sc.reviewer, EmailStrategy.CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void mergeByOtherNotifyOwnerReviewers() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, other, OWNER_REVIEWERS);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void mergeByOtherNotifyOwner() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, other, OWNER);
+    assertThat(sender).sent("merged", sc).to(sc.owner).noOneElse();
+  }
+
+  @Test
+  public void mergeByOtherCcingSelfNotifyOwner() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    setEmailStrategy(other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    merge(sc.changeId, other, OWNER);
+    assertThat(sender).sent("merged", sc).to(sc.owner).noOneElse();
+  }
+
+  @Test
+  public void mergeByOtherNotifyNone() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, other, NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void mergeByOtherCcingSelfNotifyNone() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    setEmailStrategy(other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    merge(sc.changeId, other, NONE);
+    assertThat(sender).notSent();
+  }
+
+  private void merge(String changeId, TestAccount by) throws Exception {
+    merge(changeId, by, ENABLED);
+  }
+
+  private void merge(String changeId, TestAccount by, EmailStrategy emailStrategy)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    setApiUser(by);
+    gApi.changes().id(changeId).revision("current").submit();
+  }
+
+  private void merge(String changeId, TestAccount by, NotifyHandling notify) throws Exception {
+    merge(changeId, by, ENABLED, notify);
+  }
+
+  private void merge(
+      String changeId, TestAccount by, EmailStrategy emailStrategy, NotifyHandling notify)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    setApiUser(by);
+    SubmitInput in = new SubmitInput();
+    in.notify = notify;
+    gApi.changes().id(changeId).revision("current").submit(in);
+  }
+
+  private StagedChange stageChangeReadyForMerge() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    setApiUser(sc.reviewer);
+    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.approve());
+    sender.clear();
+    return sc;
+  }
+
+  /*
+   * ReplacePatchSetSender tests.
+   */
+
+  @Test
+  public void newPatchSetByOwnerOnReviewableChangeInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOwnerOnReviewableChangeInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master", other);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
+        .to(sc.reviewer, other)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master", other);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .notTo(sc.owner) // TODO(logan): This email shouldn't come from the owner.
+        .to(sc.reviewer, sc.ccer, other)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master", other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
+        .to(sc.reviewer, other)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master", other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
+        .to(sc.reviewer, sc.ccer, other)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeNotifyOwnerReviewersInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeNotifyOwnerReviewersInReviewDb()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
+        .to(sc.reviewer, sc.ccer)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInNoteDb()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInReviewDb()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer)
+        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=OWNER", other);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=OWNER", other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    // TODO(logan): This email shouldn't come from the owner, and that's why
+    // no email is currently sent (owner isn't CCing self).
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=NONE", other);
+    // TODO(logan): This email shouldn't come from the owner, and that's why
+    // no email is currently sent (owner isn't CCing self).
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=NONE", other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetByOwnerOnReviewableChangeToWip() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%wip", sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnWipChange() throws Exception {
+    StagedChange sc = stageWipChange();
+    pushTo(sc, "refs/for/master%wip", sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeNotifyAllInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageWipChange();
+    pushTo(sc, "refs/for/master%wip,notify=ALL", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeNotifyAllInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageWipChange();
+    pushTo(sc, "refs/for/master%wip,notify=ALL", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeToReadyInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageWipChange();
+    pushTo(sc, "refs/for/master%ready", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeToReadyInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageWipChange();
+    pushTo(sc, "refs/for/master%ready", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetOnReviewableWipChange() throws Exception {
+    StagedChange sc = stageReviewableWipChange();
+    pushTo(sc, "refs/for/master%wip", sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnReviewableChangeAddingReviewerInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    TestAccount newReviewer = sc.testAccount("newReviewer");
+    pushTo(sc, "refs/for/master%r=" + newReviewer.username, sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, newReviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnReviewableChangeAddingReviewerInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    TestAccount newReviewer = sc.testAccount("newReviewer");
+    pushTo(sc, "refs/for/master%r=" + newReviewer.username, sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer, newReviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeAddingReviewer() throws Exception {
+    StagedChange sc = stageWipChange();
+    TestAccount newReviewer = sc.testAccount("newReviewer");
+    pushTo(sc, "refs/for/master%r=" + newReviewer.username, sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeAddingReviewerNotifyAllInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageWipChange();
+    TestAccount newReviewer = sc.testAccount("newReviewer");
+    pushTo(sc, "refs/for/master%notify=ALL,r=" + newReviewer.username, sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, newReviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeAddingReviewerNotifyAllInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageWipChange();
+    TestAccount newReviewer = sc.testAccount("newReviewer");
+    pushTo(sc, "refs/for/master%notify=ALL,r=" + newReviewer.username, sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer, newReviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeSettingReadyInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageWipChange();
+    pushTo(sc, "refs/for/master%ready", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeSettingReadyInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageWipChange();
+    pushTo(sc, "refs/for/master%ready", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  private void pushTo(StagedChange sc, String ref, TestAccount by) throws Exception {
+    pushTo(sc, ref, by, ENABLED);
+  }
+
+  private void pushTo(StagedChange sc, String ref, TestAccount by, EmailStrategy emailStrategy)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    pushFactory.create(db, by.getIdent(), sc.repo, sc.changeId).to(ref).assertOkStatus();
+  }
+
+  @Test
+  public void editCommitMessageEditByOwnerOnReviewableChangeInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageEditByOwnerOnReviewableChangeInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageEditByOtherOnReviewableChangeInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.owner, sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageEditByOtherOnReviewableChangeInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.owner, sc.reviewer, sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.owner, sc.reviewer, other)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.owner, sc.reviewer, sc.ccer, other)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeNotifyOwnerReviewersInNoteDb()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, OWNER_REVIEWERS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.owner, sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeNotifyOwnerReviewersInReviewDb()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, OWNER_REVIEWERS);
+    assertThat(sender).sent("newpatchset", sc).to(sc.owner, sc.reviewer, sc.ccer).noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInNoteDb()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, OWNER_REVIEWERS, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.owner, sc.reviewer)
+        .cc(sc.ccer, other)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInReviewDb()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, OWNER_REVIEWERS, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.owner, sc.reviewer, sc.ccer)
+        .cc(other)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, OWNER);
+    assertThat(sender).sent("newpatchset", sc).to(sc.owner).noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, OWNER, CC_ON_OWN_COMMENTS);
+    assertThat(sender).sent("newpatchset", sc).to(sc.owner).cc(other).noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, NONE, CC_ON_OWN_COMMENTS);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void editCommitMessageOnWipChange() throws Exception {
+    StagedChange sc = stageWipChange();
+    editCommitMessage(sc, sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnWipChange() throws Exception {
+    StagedChange sc = stageWipChange();
+    editCommitMessage(sc, other);
+    assertThat(sender).sent("newpatchset", sc).to(sc.owner).noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnWipChangeSelfCc() throws Exception {
+    StagedChange sc = stageWipChange();
+    editCommitMessage(sc, other, CC_ON_OWN_COMMENTS);
+    assertThat(sender).sent("newpatchset", sc).to(sc.owner).cc(other).noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageOnWipChangeNotifyAllInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageWipChange();
+    editCommitMessage(sc, sc.owner, ALL);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageOnWipChangeNotifyAllInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageWipChange();
+    editCommitMessage(sc, sc.owner, ALL);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  private void editCommitMessage(StagedChange sc, TestAccount by) throws Exception {
+    editCommitMessage(sc, by, null, ENABLED);
+  }
+
+  private void editCommitMessage(StagedChange sc, TestAccount by, @Nullable NotifyHandling notify)
+      throws Exception {
+    editCommitMessage(sc, by, notify, ENABLED);
+  }
+
+  private void editCommitMessage(StagedChange sc, TestAccount by, EmailStrategy emailStrategy)
+      throws Exception {
+    editCommitMessage(sc, by, null, emailStrategy);
+  }
+
+  private void editCommitMessage(
+      StagedChange sc, TestAccount by, @Nullable NotifyHandling notify, EmailStrategy emailStrategy)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    CommitInfo commit = gApi.changes().id(sc.changeId).revision("current").commit(false);
+    CommitMessageInput in = new CommitMessageInput();
+    in.message = "update\n" + commit.message;
+    in.notify = notify;
+    gApi.changes().id(sc.changeId).setMessage(in);
+  }
+
+  /*
+   * RestoredSender tests.
+   */
+
+  @Test
+  public void restoreReviewableChange() throws Exception {
+    StagedChange sc = stageAbandonedReviewableChange();
+    restore(sc.changeId, sc.owner);
+    assertThat(sender)
+        .sent("restore", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void restoreReviewableWipChange() throws Exception {
+    StagedChange sc = stageAbandonedReviewableWipChange();
+    restore(sc.changeId, sc.owner);
+    assertThat(sender)
+        .sent("restore", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void restoreWipChange() throws Exception {
+    StagedChange sc = stageAbandonedWipChange();
+    restore(sc.changeId, sc.owner);
+    assertThat(sender)
+        .sent("restore", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void restoreReviewableChangeByAdmin() throws Exception {
+    StagedChange sc = stageAbandonedReviewableChange();
+    restore(sc.changeId, admin);
+    assertThat(sender)
+        .sent("restore", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void restoreReviewableChangeByOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageAbandonedReviewableChange();
+    restore(sc.changeId, sc.owner, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("restore", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void restoreReviewableChangeByAdminCcingSelf() throws Exception {
+    StagedChange sc = stageAbandonedReviewableChange();
+    restore(sc.changeId, admin, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("restore", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  private void restore(String changeId, TestAccount by) throws Exception {
+    restore(changeId, by, ENABLED);
+  }
+
+  private void restore(String changeId, TestAccount by, EmailStrategy emailStrategy)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    setApiUser(by);
+    gApi.changes().id(changeId).restore();
+  }
+
+  /*
+   * RevertedSender tests.
+   */
+
+  @Test
+  public void revertChangeByOwnerInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageChange();
+    revert(sc, sc.owner);
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+
+    assertThat(sender)
+        .sent("revert", sc)
+        .cc(sc.reviewer, sc.ccer, admin)
+        .bcc(ALL_COMMENTS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+  }
+
+  @Test
+  public void revertChangeByOwnerInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageChange();
+    revert(sc, sc.owner);
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.reviewer, sc.watchingProjectOwner, admin)
+        .cc(sc.ccer)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+
+    assertThat(sender)
+        .sent("revert", sc)
+        .cc(sc.reviewer, sc.ccer, admin)
+        .bcc(ALL_COMMENTS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+  }
+
+  @Test
+  public void revertChangeByOwnerCcingSelfInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageChange();
+    revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
+        .cc(sc.owner)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+
+    assertThat(sender)
+        .sent("revert", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, admin)
+        .bcc(ALL_COMMENTS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+  }
+
+  @Test
+  public void revertChangeByOwnerCcingSelfInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageChange();
+    revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.reviewer, sc.watchingProjectOwner, admin)
+        .cc(sc.owner, sc.ccer)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+
+    assertThat(sender)
+        .sent("revert", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, admin)
+        .bcc(ALL_COMMENTS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+  }
+
+  @Test
+  public void revertChangeByOtherInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageChange();
+    revert(sc, other);
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.owner, sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+
+    assertThat(sender)
+        .sent("revert", sc)
+        .cc(sc.owner, sc.reviewer, sc.ccer, admin)
+        .bcc(ALL_COMMENTS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+  }
+
+  @Test
+  public void revertChangeByOtherInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageChange();
+    revert(sc, other);
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.owner, sc.reviewer, sc.watchingProjectOwner, admin)
+        .cc(sc.ccer)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+
+    assertThat(sender)
+        .sent("revert", sc)
+        .cc(sc.owner, sc.reviewer, sc.ccer, admin)
+        .bcc(ALL_COMMENTS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+  }
+
+  @Test
+  public void revertChangeByOtherCcingSelfInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageChange();
+    revert(sc, other, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.owner, sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
+        .cc(other)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+
+    assertThat(sender)
+        .sent("revert", sc)
+        .to(other)
+        .cc(sc.owner, sc.reviewer, sc.ccer, admin)
+        .bcc(ALL_COMMENTS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+  }
+
+  @Test
+  public void revertChangeByOtherCcingSelfInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageChange();
+    revert(sc, other, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.owner, sc.reviewer, sc.watchingProjectOwner, admin)
+        .cc(sc.ccer, other)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+
+    assertThat(sender)
+        .sent("revert", sc)
+        .to(other)
+        .cc(sc.owner, sc.reviewer, sc.ccer, admin)
+        .bcc(ALL_COMMENTS)
+        .noOneElse(); // TODO(logan): Why not starrer/reviewers-by-email?
+  }
+
+  private StagedChange stageChange() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    setApiUser(admin);
+    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.approve());
+    gApi.changes().id(sc.changeId).revision("current").submit();
+    sender.clear();
+    return sc;
+  }
+
+  private void revert(StagedChange sc, TestAccount by) throws Exception {
+    revert(sc, by, ENABLED);
+  }
+
+  private void revert(StagedChange sc, TestAccount by, EmailStrategy emailStrategy)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    setApiUser(by);
+    gApi.changes().id(sc.changeId).revert();
+  }
+
+  /*
+   * SetAssigneeSender tests.
+   */
+
+  @Test
+  public void setAssigneeOnReviewableChange() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    assign(sc, sc.owner, sc.assignee);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .to(sc.assignee)
+        .noOneElse();
+  }
+
+  @Test
+  public void setAssigneeOnReviewableChangeByOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    assign(sc, sc.owner, sc.assignee, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.owner)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .to(sc.assignee)
+        .noOneElse();
+  }
+
+  @Test
+  public void setAssigneeOnReviewableChangeByAdmin() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    assign(sc, admin, sc.assignee);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .to(sc.assignee)
+        .noOneElse();
+  }
+
+  @Test
+  public void setAssigneeOnReviewableChangeByAdminCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    assign(sc, admin, sc.assignee, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(admin)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .to(sc.assignee)
+        .noOneElse();
+  }
+
+  @Test
+  public void setAssigneeToSelfOnReviewableChangeInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    assign(sc, sc.owner, sc.owner);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .noOneElse();
+  }
+
+  @Test
+  public void setAssigneeToSelfOnReviewableChangeInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    assign(sc, sc.owner, sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void changeAssigneeOnReviewableChange() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    assign(sc, sc.owner, other);
+    sender.clear();
+    assign(sc, sc.owner, sc.assignee);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .to(sc.assignee)
+        .noOneElse();
+  }
+
+  @Test
+  public void changeAssigneeToSelfOnReviewableChangeInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    assign(sc, sc.owner, sc.assignee);
+    sender.clear();
+    assign(sc, sc.owner, sc.owner);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .noOneElse();
+  }
+
+  @Test
+  public void changeAssigneeToSelfOnReviewableChangeInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    assign(sc, sc.owner, sc.assignee);
+    sender.clear();
+    assign(sc, sc.owner, sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void setAssigneeOnReviewableWipChange() throws Exception {
+    StagedChange sc = stageReviewableWipChange();
+    assign(sc, sc.owner, sc.assignee);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .to(sc.assignee)
+        .noOneElse();
+  }
+
+  @Test
+  public void setAssigneeOnWipChange() throws Exception {
+    StagedChange sc = stageWipChange();
+    assign(sc, sc.owner, sc.assignee);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .to(sc.assignee)
+        .noOneElse();
+  }
+
+  private void assign(StagedChange sc, TestAccount by, TestAccount to) throws Exception {
+    assign(sc, by, to, ENABLED);
+  }
+
+  private void assign(StagedChange sc, TestAccount by, TestAccount to, EmailStrategy emailStrategy)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    setApiUser(by);
+    AssigneeInput in = new AssigneeInput();
+    in.assignee = to.email;
+    gApi.changes().id(sc.changeId).setAssignee(in);
+  }
+
+  /*
+   * Start review and WIP tests.
+   */
+
+  @Test
+  public void startReviewOnWipChange() throws Exception {
+    StagedChange sc = stageWipChange();
+    startReview(sc);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void startReviewOnWipChangeCcingSelf() throws Exception {
+    StagedChange sc = stageWipChange();
+    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
+    startReview(sc);
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void setWorkInProgress() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    gApi.changes().id(sc.changeId).setWorkInProgress();
+    assertThat(sender).notSent();
+  }
+
+  private void startReview(StagedChange sc) throws Exception {
+    setApiUser(sc.owner);
+    gApi.changes().id(sc.changeId).setReadyForReview();
+    // PolyGerrit current immediately follows up with a review.
+    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.noScore());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
rename to javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
rename to javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailIT.java
new file mode 100644
index 0000000..0d31a96
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailIT.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.server.mail.receive.MailReceiver;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import com.icegreen.greenmail.junit.GreenMailRule;
+import com.icegreen.greenmail.user.GreenMailUser;
+import com.icegreen.greenmail.util.GreenMail;
+import com.icegreen.greenmail.util.GreenMailUtil;
+import com.icegreen.greenmail.util.ServerSetupTest;
+import javax.mail.internet.MimeMessage;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@NoHttpd
+@RunWith(ConfigSuite.class)
+public class MailIT extends AbstractDaemonTest {
+  private static final String RECEIVEEMAIL = "receiveemail";
+  private static final String HOST = "localhost";
+  private static final String USERNAME = "user@domain.com";
+  private static final String PASSWORD = "password";
+
+  @Inject private MailReceiver mailReceiver;
+
+  @Inject private GreenMail greenMail;
+
+  @Rule
+  public final GreenMailRule mockPop3Server = new GreenMailRule(ServerSetupTest.SMTP_POP3_IMAP);
+
+  @ConfigSuite.Default
+  public static Config pop3Config() {
+    Config cfg = new Config();
+    cfg.setString(RECEIVEEMAIL, null, "host", HOST);
+    cfg.setString(RECEIVEEMAIL, null, "port", "3110");
+    cfg.setString(RECEIVEEMAIL, null, "username", USERNAME);
+    cfg.setString(RECEIVEEMAIL, null, "password", PASSWORD);
+    cfg.setString(RECEIVEEMAIL, null, "protocol", "POP3");
+    cfg.setString(RECEIVEEMAIL, null, "fetchInterval", Integer.toString(Integer.MAX_VALUE));
+    return cfg;
+  }
+
+  @ConfigSuite.Config
+  public static Config imapConfig() {
+    Config cfg = new Config();
+    cfg.setString(RECEIVEEMAIL, null, "host", HOST);
+    cfg.setString(RECEIVEEMAIL, null, "port", "3143");
+    cfg.setString(RECEIVEEMAIL, null, "username", USERNAME);
+    cfg.setString(RECEIVEEMAIL, null, "password", PASSWORD);
+    cfg.setString(RECEIVEEMAIL, null, "protocol", "IMAP");
+    cfg.setString(RECEIVEEMAIL, null, "fetchInterval", Integer.toString(Integer.MAX_VALUE));
+    return cfg;
+  }
+
+  @Test
+  public void doesNotDeleteMessageNotMarkedForDeletion() throws Exception {
+    GreenMailUser user = mockPop3Server.setUser(USERNAME, USERNAME, PASSWORD);
+    user.deliver(createSimpleMessage());
+    assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(1);
+    // Let Gerrit handle emails
+    mailReceiver.handleEmails(false);
+    // Check that the message is still present
+    assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(1);
+  }
+
+  @Test
+  public void deletesMessageMarkedForDeletion() throws Exception {
+    GreenMailUser user = mockPop3Server.setUser(USERNAME, USERNAME, PASSWORD);
+    user.deliver(createSimpleMessage());
+    assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(1);
+    // Mark the message for deletion
+    mailReceiver.requestDeletion(mockPop3Server.getReceivedMessages()[0].getMessageID());
+    // Let Gerrit handle emails
+    mailReceiver.handleEmails(false);
+    // Check that the message was deleted
+    assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(0);
+  }
+
+  private MimeMessage createSimpleMessage() {
+    return GreenMailUtil.createTextEmail(
+        USERNAME, "from@localhost.com", "subject", "body", greenMail.getImap().getServerSetup());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
new file mode 100644
index 0000000..3315a33
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -0,0 +1,162 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.server.mail.MailUtil;
+import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.testing.FakeEmailSender;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.sql.Timestamp;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests the presence of required metadata in email headers, text and html. */
+public class MailMetadataIT extends AbstractDaemonTest {
+  private String systemTimeZone;
+
+  @Before
+  public void setTimeForTesting() {
+    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZone);
+  }
+
+  @Test
+  public void metadataOnNewChange() throws Exception {
+    PushOneCommit.Result newChange = createChange();
+    gApi.changes().id(newChange.getChangeId()).addReviewer(user.getId().toString());
+
+    List<FakeEmailSender.Message> emails = sender.getMessages();
+    assertThat(emails).hasSize(1);
+    FakeEmailSender.Message message = emails.get(0);
+
+    String changeURL = "<" + canonicalWebUrl.get() + newChange.getChange().getId().get() + ">";
+
+    Map<String, Object> expectedHeaders = new HashMap<>();
+    expectedHeaders.put("Gerrit-PatchSet", "1");
+    expectedHeaders.put(
+        "Gerrit-Change-Number", String.valueOf(newChange.getChange().getId().get()));
+    expectedHeaders.put("Gerrit-MessageType", "newchange");
+    expectedHeaders.put("Gerrit-Commit", newChange.getCommit().getId().name());
+    expectedHeaders.put("Gerrit-ChangeURL", changeURL);
+
+    assertHeaders(message.headers(), expectedHeaders);
+
+    // Remove metadata that is not present in email
+    expectedHeaders.remove("Gerrit-ChangeURL");
+    expectedHeaders.remove("Gerrit-Commit");
+    assertTextFooter(message.body(), expectedHeaders);
+  }
+
+  @Test
+  public void metadataOnNewComment() throws Exception {
+    PushOneCommit.Result newChange = createChange();
+    gApi.changes().id(newChange.getChangeId()).addReviewer(user.getId().toString());
+    sender.clear();
+
+    // Review change
+    ReviewInput input = new ReviewInput();
+    input.message = "Test";
+    revision(newChange).review(input);
+    setApiUser(user);
+    Collection<ChangeMessageInfo> result =
+        gApi.changes().id(newChange.getChangeId()).get().messages;
+    assertThat(result).isNotEmpty();
+
+    List<FakeEmailSender.Message> emails = sender.getMessages();
+    assertThat(emails).hasSize(1);
+    FakeEmailSender.Message message = emails.get(0);
+
+    String changeURL = "<" + canonicalWebUrl.get() + newChange.getChange().getId().get() + ">";
+    Map<String, Object> expectedHeaders = new HashMap<>();
+    expectedHeaders.put("Gerrit-PatchSet", "1");
+    expectedHeaders.put(
+        "Gerrit-Change-Number", String.valueOf(newChange.getChange().getId().get()));
+    expectedHeaders.put("Gerrit-MessageType", "comment");
+    expectedHeaders.put("Gerrit-Commit", newChange.getCommit().getId().name());
+    expectedHeaders.put("Gerrit-ChangeURL", changeURL);
+    expectedHeaders.put("Gerrit-Comment-Date", Iterables.getLast(result).date);
+
+    assertHeaders(message.headers(), expectedHeaders);
+
+    // Remove metadata that is not present in email
+    expectedHeaders.remove("Gerrit-ChangeURL");
+    expectedHeaders.remove("Gerrit-Commit");
+    assertTextFooter(message.body(), expectedHeaders);
+  }
+
+  private static void assertHeaders(Map<String, EmailHeader> have, Map<String, Object> want)
+      throws Exception {
+    for (Map.Entry<String, Object> entry : want.entrySet()) {
+      if (entry.getValue() instanceof String) {
+        assertThat(have)
+            .containsEntry(
+                "X-" + entry.getKey(), new EmailHeader.String((String) entry.getValue()));
+      } else if (entry.getValue() instanceof Date) {
+        assertThat(have)
+            .containsEntry("X-" + entry.getKey(), new EmailHeader.Date((Date) entry.getValue()));
+      } else {
+        throw new Exception(
+            "Object has unsupported type: "
+                + entry.getValue().getClass().getName()
+                + " must be java.util.Date or java.lang.String for key "
+                + entry.getKey());
+      }
+    }
+  }
+
+  private static void assertTextFooter(String body, Map<String, Object> want) throws Exception {
+    for (Map.Entry<String, Object> entry : want.entrySet()) {
+      if (entry.getValue() instanceof String) {
+        assertThat(body).contains(entry.getKey() + ": " + entry.getValue());
+      } else if (entry.getValue() instanceof Timestamp) {
+        assertThat(body)
+            .contains(
+                entry.getKey()
+                    + ": "
+                    + MailUtil.rfcDateformatter.format(
+                        ZonedDateTime.ofInstant(
+                            ((Timestamp) entry.getValue()).toInstant(), ZoneId.of("UTC"))));
+      } else {
+        throw new Exception(
+            "Object has unsupported type: "
+                + entry.getValue().getClass().getName()
+                + " must be java.util.Date or java.lang.String for key "
+                + entry.getKey());
+      }
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
rename to javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
new file mode 100644
index 0000000..4f51e1f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+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;
+
+public class MailSenderIT extends AbstractMailIT {
+
+  @Test
+  @GerritConfig(name = "sendemail.replyToAddress", value = "custom@gerritcodereview.com")
+  @GerritConfig(name = "receiveemail.protocol", value = "POP3")
+  public void outgoingMailHasCustomReplyToHeader() throws Exception {
+    createChangeWithReview(user);
+    // 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(headerString(headers, "Reply-To")).isEqualTo("custom@gerritcodereview.com");
+  }
+
+  @Test
+  public void outgoingMailHasUserEmailInReplyToHeader() throws Exception {
+    createChangeWithReview(user);
+    // 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(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/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java b/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
new file mode 100644
index 0000000..c8292ba
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
+import com.google.gerrit.testing.FakeEmailSender;
+import org.junit.Test;
+
+public class NotificationMailFormatIT extends AbstractDaemonTest {
+
+  @Test
+  public void userReceivesPlaintextEmail() throws Exception {
+    // Set user preference to receive only plaintext content
+    GeneralPreferencesInfo i = new GeneralPreferencesInfo();
+    i.emailFormat = EmailFormat.PLAINTEXT;
+    gApi.accounts().id(admin.getId().toString()).setPreferences(i);
+
+    // Create change as admin and review as user
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
+
+    // Check that admin has received only plaintext content
+    assertThat(sender.getMessages()).hasSize(1);
+    FakeEmailSender.Message m = sender.getMessages().get(0);
+    assertThat(m.body()).isNotNull();
+    assertThat(m.htmlBody()).isNull();
+    assertMailReplyTo(m, admin.email);
+    assertMailReplyTo(m, user.email);
+
+    // Reset user preference
+    setApiUser(admin);
+    i.emailFormat = EmailFormat.HTML_PLAINTEXT;
+    gApi.accounts().id(admin.getId().toString()).setPreferences(i);
+  }
+
+  @Test
+  public void userReceivesHtmlAndPlaintextEmail() throws Exception {
+    // Create change as admin and review as user
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
+
+    // Check that admin has received both HTML and plaintext content
+    assertThat(sender.getMessages()).hasSize(1);
+    FakeEmailSender.Message m = sender.getMessages().get(0);
+    assertThat(m.body()).isNotNull();
+    assertThat(m.htmlBody()).isNotNull();
+    assertMailReplyTo(m, admin.email);
+    assertMailReplyTo(m, user.email);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/BUILD b/javatests/com/google/gerrit/acceptance/server/notedb/BUILD
new file mode 100644
index 0000000..20c256f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/BUILD
@@ -0,0 +1,11 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_notedb",
+    labels = [
+        "notedb",
+        "server",
+    ],
+    deps = ["//java/com/google/gerrit/server/schema"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
new file mode 100644
index 0000000..c0dcc9c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
@@ -0,0 +1,1474 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.TimeUnit.DAYS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.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;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.RepoRefCache;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.restapi.change.Rebuild;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.NoteDbChecker;
+import com.google.gerrit.testing.NoteDbMode;
+import com.google.gerrit.testing.TestChanges;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.sql.Timestamp;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.apache.http.Header;
+import org.apache.http.message.BasicHeader;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ChangeRebuilderIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("noteDb", null, "testRebuilderWrapper", true);
+
+    // Disable async reindex-if-stale check after index update. This avoids
+    // unintentional auto-rebuilding of the change in NoteDb during the read
+    // path of the reindex-if-stale check. For the purposes of this test, we
+    // want precise control over when auto-rebuilding happens.
+    cfg.setBoolean("index", null, "autoReindexIfStale", false);
+
+    // setNotesMigration tries to keep IDs in sync between ReviewDb and NoteDb, which is behavior
+    // unique to this test. This gets prohibitively slow if we use the default sequence gap.
+    cfg.setInt("noteDb", "changes", "initialSequenceGap", 0);
+
+    return cfg;
+  }
+
+  @Inject private NoteDbChecker checker;
+
+  @Inject private Rebuild rebuildHandler;
+
+  @Inject private Provider<ReviewDb> dbProvider;
+
+  @Inject private CommentsUtil commentsUtil;
+
+  @Inject private Provider<PostReview> postReview;
+
+  @Inject private TestChangeRebuilderWrapper rebuilderWrapper;
+
+  @Inject private Sequences seq;
+
+  @Inject private ChangeBundleReader bundleReader;
+
+  @Inject private PatchSetInfoFactory patchSetInfoFactory;
+
+  @Before
+  public void setUp() throws Exception {
+    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.OFF);
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    setNotesMigration(false, false);
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @SuppressWarnings("deprecation")
+  private void setNotesMigration(boolean writeChanges, boolean readChanges) throws Exception {
+    notesMigration.setWriteChanges(writeChanges);
+    notesMigration.setReadChanges(readChanges);
+    db = atrScope.reopenDb().getReviewDbProvider().get();
+
+    if (notesMigration.readChangeSequence()) {
+      // Copy next ReviewDb ID to NoteDb.
+      seq.getChangeIdRepoSequence().set(db.nextChangeId());
+    } else {
+      // Copy next NoteDb ID to ReviewDb.
+      while (db.nextChangeId() < seq.getChangeIdRepoSequence().next()) {}
+    }
+  }
+
+  @Test
+  public void changeFields() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void patchSets() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    r = amendChange(r.getChangeId());
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void publishedComment() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putComment(user, id, 1, "comment", null);
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void publishedCommentAndReply() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putComment(user, id, 1, "comment", null);
+    Map<String, List<CommentInfo>> comments = getPublishedComments(id);
+    String parentUuid = comments.get("a.txt").get(0).id;
+    putComment(user, id, 1, "comment", parentUuid);
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void patchSetWithNullGroups() throws Exception {
+    Timestamp ts = TimeUtil.nowTs();
+    Change c = TestChanges.newChange(project, user.getId(), seq.nextChangeId());
+    c.setCreatedOn(ts);
+    c.setLastUpdatedOn(ts);
+    c.setReviewStarted(true);
+    PatchSet ps =
+        TestChanges.newPatchSet(
+            c.currentPatchSetId(), "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", user.getId());
+    ps.setCreatedOn(ts);
+    db.changes().insert(Collections.singleton(c));
+    db.patchSets().insert(Collections.singleton(ps));
+
+    assertThat(ps.getGroups()).isEmpty();
+    checker.rebuildAndCheckChanges(c.getId());
+  }
+
+  @Test
+  public void draftComment() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment", null);
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void draftAndPublishedComment() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "draft comment", null);
+    putComment(user, id, 1, "published comment", null);
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void publishDraftComment() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "draft comment", null);
+    publishDrafts(user, id);
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void nullAccountId() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId = r.getPatchSetId();
+    Change.Id id = psId.getParentKey();
+
+    // Events need to be otherwise identical for the account ID to be compared.
+    ChangeMessage msg1 = insertMessage(id, psId, user.getId(), TimeUtil.nowTs(), "message 1");
+    insertMessage(id, psId, null, msg1.getWrittenOn(), "message 2");
+
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void nullPatchSetId() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId1 = r.getPatchSetId();
+    Change.Id id = psId1.getParentKey();
+
+    // Events need to be otherwise identical for the PatchSet.ID to be compared.
+    ChangeMessage msg1 = insertMessage(id, null, user.getId(), TimeUtil.nowTs(), "message 1");
+    insertMessage(id, null, user.getId(), msg1.getWrittenOn(), "message 2");
+
+    PatchSet.Id psId2 = amendChange(r.getChangeId()).getPatchSetId();
+
+    ChangeMessage msg3 = insertMessage(id, null, user.getId(), TimeUtil.nowTs(), "message 3");
+    insertMessage(id, null, user.getId(), msg3.getWrittenOn(), "message 4");
+
+    checker.rebuildAndCheckChanges(id);
+
+    setNotesMigration(true, true);
+
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    Map<String, PatchSet.Id> psIds = new HashMap<>();
+    for (ChangeMessage msg : notes.getChangeMessages()) {
+      PatchSet.Id psId = msg.getPatchSetId();
+      assertThat(psId).named("patchset for " + msg).isNotNull();
+      psIds.put(msg.getMessage(), psId);
+    }
+    // Patch set IDs were replaced during conversion process.
+    assertThat(psIds).containsEntry("message 1", psId1);
+    assertThat(psIds).containsEntry("message 2", psId1);
+    assertThat(psIds).containsEntry("message 3", psId2);
+    assertThat(psIds).containsEntry("message 4", psId2);
+  }
+
+  @Test
+  public void noWriteToNewRef() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    checker.assertNoChangeRef(project, id);
+
+    setNotesMigration(true, false);
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+
+    // First write doesn't create the ref, but rebuilding works.
+    checker.assertNoChangeRef(project, id);
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isNull();
+    checker.rebuildAndCheckChanges(id);
+
+    // Now that there is a ref, writes are "turned on" for this change, and
+    // NoteDb stays up to date without explicit rebuilding.
+    gApi.changes().id(id.get()).topic(name("new-topic"));
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isNotNull();
+    checker.checkChanges(id);
+  }
+
+  @Test
+  public void restApiNotFoundWhenNoteDbDisabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    exception.expect(ResourceNotFoundException.class);
+    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Input());
+  }
+
+  @Test
+  public void rebuildViaRestApi() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    setNotesMigration(true, false);
+
+    checker.assertNoChangeRef(project, id);
+    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Input());
+    checker.checkChanges(id);
+  }
+
+  @Test
+  public void writeToNewRefForNewChange() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    Change.Id id1 = r1.getPatchSetId().getParentKey();
+
+    setNotesMigration(true, false);
+    gApi.changes().id(id1.get()).topic(name("a-topic"));
+    PushOneCommit.Result r2 = createChange();
+    Change.Id id2 = r2.getPatchSetId().getParentKey();
+
+    // Second change was created after NoteDb writes were turned on, so it was
+    // allowed to write to a new ref.
+    checker.checkChanges(id2);
+
+    // First change was created before NoteDb writes were turned on, so its meta
+    // ref doesn't exist until a manual rebuild.
+    checker.assertNoChangeRef(project, id1);
+    checker.rebuildAndCheckChanges(id1);
+  }
+
+  @Test
+  public void noteDbChangeState() throws Exception {
+    setNotesMigration(true, true);
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+
+    ObjectId changeMetaId = getMetaRef(project, changeMetaRef(id));
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(changeMetaId.name());
+
+    putDraft(user, id, 1, "comment by user", null);
+    ObjectId userDraftsId = getMetaRef(allUsers, refsDraftComments(id, user.getId()));
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState())
+        .isEqualTo(changeMetaId.name() + "," + user.getId() + "=" + userDraftsId.name());
+
+    putDraft(admin, id, 2, "comment by admin", null);
+    ObjectId adminDraftsId = getMetaRef(allUsers, refsDraftComments(id, admin.getId()));
+    assertThat(admin.getId().get()).isLessThan(user.getId().get());
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState())
+        .isEqualTo(
+            changeMetaId.name()
+                + ","
+                + admin.getId()
+                + "="
+                + adminDraftsId.name()
+                + ","
+                + user.getId()
+                + "="
+                + userDraftsId.name());
+
+    putDraft(admin, id, 2, "revised comment by admin", null);
+    adminDraftsId = getMetaRef(allUsers, refsDraftComments(id, admin.getId()));
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState())
+        .isEqualTo(
+            changeMetaId.name()
+                + ","
+                + admin.getId()
+                + "="
+                + adminDraftsId.name()
+                + ","
+                + user.getId()
+                + "="
+                + userDraftsId.name());
+  }
+
+  @Test
+  public void rebuildAutomaticallyWhenChangeOutOfDate() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+
+    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
+    setNotesMigration(false, false);
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+    setInvalidNoteDbState(id);
+    assertChangeUpToDate(false, id);
+
+    // On next NoteDb read, the change is transparently rebuilt.
+    setNotesMigration(true, true);
+    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
+    assertChangeUpToDate(true, id);
+
+    // Check that the bundles are equal.
+    ChangeBundle actual =
+        ChangeBundle.fromNotes(commentsUtil, notesFactory.create(dbProvider.get(), project, id));
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+  }
+
+  @Test
+  public void rebuildAutomaticallyWithinBatchUpdate() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    final Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+
+    // Update ReviewDb and NoteDb, then revert the corresponding NoteDb change
+    // to simulate it failing.
+    NoteDbChangeState oldState = NoteDbChangeState.parse(getUnwrappedDb().changes().get(id));
+    String topic = name("a-topic");
+    gApi.changes().id(id.get()).topic(topic);
+    try (Repository repo = repoManager.openRepository(project)) {
+      new TestRepository<>(repo).update(RefNames.changeMetaRef(id), oldState.getChangeMetaId());
+    }
+    assertChangeUpToDate(false, id);
+
+    // Next NoteDb read comes inside the transaction started by BatchUpdate. In
+    // reality this could be caused by a failed update happening between when
+    // the change is parsed by ChangesCollection and when the BatchUpdate
+    // executes. We simulate it here by using BatchUpdate directly and not going
+    // through an API handler.
+    final String msg = "message from BatchUpdate";
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(
+            db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs())) {
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws OrmException {
+              PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+              ChangeMessage cm =
+                  new ChangeMessage(
+                      new ChangeMessage.Key(id, ChangeUtil.messageUuid()),
+                      ctx.getAccountId(),
+                      ctx.getWhen(),
+                      psId);
+              cm.setMessage(msg);
+              ctx.getDb().changeMessages().insert(Collections.singleton(cm));
+              ctx.getUpdate(psId).setChangeMessage(msg);
+              return true;
+            }
+          });
+      try {
+        bu.execute();
+        fail("expected update to fail");
+      } catch (UpdateException e) {
+        assertThat(e.getMessage()).contains("cannot copy ChangeNotesState");
+      }
+    }
+
+    // TODO(dborowitz): Re-enable these assertions once we fix auto-rebuilding
+    // in the BatchUpdate path.
+    // As an implementation detail, change wasn't actually rebuilt inside the
+    // BatchUpdate transaction, but it was rebuilt during read for the
+    // subsequent reindex. Thus it's impossible to actually observe an
+    // out-of-date state in the caller.
+    // assertChangeUpToDate(true, id);
+
+    // Check that the bundles are equal.
+    // ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+    // ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
+    // ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
+    // assertThat(actual.differencesFrom(expected)).isEmpty();
+    // assertThat(
+    //        Iterables.transform(
+    //            notes.getChangeMessages(),
+    //            ChangeMessage::getMessage))
+    //    .contains(msg);
+    // assertThat(actual.getChange().getTopic()).isEqualTo(topic);
+  }
+
+  @Test
+  public void rebuildIgnoresErrorIfChangeIsUpToDateAfter() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+
+    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
+    setNotesMigration(false, false);
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+    setInvalidNoteDbState(id);
+    assertChangeUpToDate(false, id);
+
+    // Force the next rebuild attempt to fail but also rebuild the change in the
+    // background.
+    rebuilderWrapper.stealNextUpdate();
+    setNotesMigration(true, true);
+    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
+    assertChangeUpToDate(true, id);
+
+    // Check that the bundles are equal.
+    ChangeBundle actual =
+        ChangeBundle.fromNotes(commentsUtil, notesFactory.create(dbProvider.get(), project, id));
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+  }
+
+  @Test
+  public void rebuildReturnsCorrectResultEvenIfSavingToNoteDbFailed() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+    ObjectId oldMetaId = getMetaRef(project, changeMetaRef(id));
+
+    // Make a ReviewDb change behind NoteDb's back.
+    setNotesMigration(false, false);
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+    setInvalidNoteDbState(id);
+    assertChangeUpToDate(false, id);
+    assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId);
+
+    // Force the next rebuild attempt to fail.
+    rebuilderWrapper.failNextUpdate();
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+
+    // Not up to date, but the actual returned state matches anyway.
+    assertChangeUpToDate(false, id);
+    assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId);
+    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+    assertChangeUpToDate(false, id);
+
+    // Another rebuild attempt succeeds
+    notesFactory.create(dbProvider.get(), project, id);
+    assertThat(getMetaRef(project, changeMetaRef(id))).isNotEqualTo(oldMetaId);
+    assertChangeUpToDate(true, id);
+  }
+
+  @Test
+  public void rebuildReturnsDraftResultWhenRebuildingInChangeNotesFails() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment by user", null);
+    assertChangeUpToDate(true, id);
+
+    ObjectId oldMetaId = getMetaRef(allUsers, refsDraftComments(id, user.getId()));
+
+    // Add a draft behind NoteDb's back.
+    setNotesMigration(false, false);
+    putDraft(user, id, 1, "second comment by user", null);
+    setInvalidNoteDbState(id);
+    assertDraftsUpToDate(false, id, user);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
+
+    // Force the next rebuild attempt to fail (in ChangeNotes).
+    rebuilderWrapper.failNextUpdate();
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+    notes.getDraftComments(user.getId());
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
+
+    // Not up to date, but the actual returned state matches anyway.
+    assertDraftsUpToDate(false, id, user);
+    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+
+    // Another rebuild attempt succeeds
+    notesFactory.create(dbProvider.get(), project, id);
+    assertChangeUpToDate(true, id);
+    assertDraftsUpToDate(true, id, user);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isNotEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void rebuildReturnsDraftResultWhenRebuildingInDraftCommentNotesFails() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment by user", null);
+    assertChangeUpToDate(true, id);
+
+    ObjectId oldMetaId = getMetaRef(allUsers, refsDraftComments(id, user.getId()));
+
+    // Add a draft behind NoteDb's back.
+    setNotesMigration(false, false);
+    putDraft(user, id, 1, "second comment by user", null);
+
+    ReviewDb db = getUnwrappedDb();
+    Change c = db.changes().get(id);
+    // Leave change meta ID alone so DraftCommentNotes does the rebuild.
+    ObjectId badSha = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    NoteDbChangeState bogusState =
+        new NoteDbChangeState(
+            id,
+            PrimaryStorage.REVIEW_DB,
+            Optional.of(
+                NoteDbChangeState.RefState.create(
+                    NoteDbChangeState.parse(c).getChangeMetaId(),
+                    ImmutableMap.of(user.getId(), badSha))),
+            Optional.empty());
+    c.setNoteDbState(bogusState.toString());
+    db.changes().update(Collections.singleton(c));
+
+    assertDraftsUpToDate(false, id, user);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
+
+    // Force the next rebuild attempt to fail (in DraftCommentNotes).
+    rebuilderWrapper.failNextUpdate();
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+    notes.getDraftComments(user.getId());
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
+
+    // Not up to date, but the actual returned state matches anyway.
+    assertChangeUpToDate(true, id);
+    assertDraftsUpToDate(false, id, user);
+    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+
+    // Another rebuild attempt succeeds
+    notesFactory.create(dbProvider.get(), project, id).getDraftComments(user.getId());
+    assertChangeUpToDate(true, id);
+    assertDraftsUpToDate(true, id, user);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isNotEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void rebuildAutomaticallyWhenDraftsOutOfDate() throws Exception {
+    setNotesMigration(true, true);
+    setApiUser(user);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment", null);
+    assertDraftsUpToDate(true, id, user);
+
+    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
+    setNotesMigration(false, false);
+    putDraft(user, id, 1, "comment", null);
+    setInvalidNoteDbState(id);
+    assertDraftsUpToDate(false, id, user);
+
+    // On next NoteDb read, the drafts are transparently rebuilt.
+    setNotesMigration(true, true);
+    assertThat(gApi.changes().id(id.get()).current().drafts()).containsKey(PushOneCommit.FILE_NAME);
+    assertDraftsUpToDate(true, id, user);
+  }
+
+  @Test
+  public void pushCert() throws Exception {
+    // We don't have the code in our test harness to do signed pushes, so just
+    // use a hard-coded cert. This cert was actually generated by C git 2.2.0
+    // (albeit not for sending to Gerrit).
+    String cert =
+        "certificate version 0.1\n"
+            + "pusher Dave Borowitz <dborowitz@google.com> 1433954361 -0700\n"
+            + "pushee git://localhost/repo.git\n"
+            + "nonce 1433954361-bde756572d665bba81d8\n"
+            + "\n"
+            + "0000000000000000000000000000000000000000"
+            + "b981a177396fb47345b7df3e4d3f854c6bea7"
+            + "s/heads/master\n"
+            + "-----BEGIN PGP SIGNATURE-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "iQEcBAABAgAGBQJVeGg5AAoJEPfTicJkUdPkUggH/RKAeI9/i/LduuiqrL/SSdIa\n"
+            + "9tYaSqJKLbXz63M/AW4Sp+4u+dVCQvnAt/a35CVEnpZz6hN4Kn/tiswOWVJf4CO7\n"
+            + "htNubGs5ZMwvD6sLYqKAnrM3WxV/2TbbjzjZW6Jkidz3jz/WRT4SmjGYiEO7aA+V\n"
+            + "4ZdIS9f7sW5VsHHYlNThCA7vH8Uu48bUovFXyQlPTX0pToSgrWV3JnTxDNxfn3iG\n"
+            + "IL0zTY/qwVCdXgFownLcs6J050xrrBWIKqfcWr3u4D2aCLyR0v+S/KArr7ulZygY\n"
+            + "+SOklImn8TAZiNxhWtA6ens66IiammUkZYFv7SSzoPLFZT4dC84SmGPWgf94NoQ=\n"
+            + "=XFeC\n"
+            + "-----END PGP SIGNATURE-----\n";
+
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId = r.getPatchSetId();
+    Change.Id id = psId.getParentKey();
+
+    PatchSet ps = db.patchSets().get(psId);
+    ps.setPushCertificate(cert);
+    db.patchSets().update(Collections.singleton(ps));
+    indexer.index(db, project, id);
+
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void emptyTopic() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    Change c = db.changes().get(id);
+    assertThat(c.getTopic()).isNull();
+    c.setTopic("");
+    db.changes().update(Collections.singleton(c));
+
+    checker.rebuildAndCheckChanges(id);
+
+    setNotesMigration(true, true);
+
+    // Rebuild and check was successful, but NoteDb doesn't support storing an
+    // empty topic, so it comes out as null.
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(notes.getChange().getTopic()).isNull();
+  }
+
+  @Test
+  public void commentBeforeFirstPatchSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId = r.getPatchSetId();
+    Change.Id id = psId.getParentKey();
+
+    Change c = db.changes().get(id);
+    c.setCreatedOn(new Timestamp(c.getCreatedOn().getTime() - 5000));
+    db.changes().update(Collections.singleton(c));
+    indexer.index(db, project, id);
+
+    ReviewInput rin = new ReviewInput();
+    rin.message = "comment";
+
+    Timestamp ts = new Timestamp(c.getCreatedOn().getTime() + 2000);
+    assertThat(ts).isGreaterThan(c.getCreatedOn());
+    assertThat(ts).isLessThan(db.patchSets().get(psId).getCreatedOn());
+    RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId());
+    postReview.get().apply(batchUpdateFactory, revRsrc, rin, ts);
+
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void commentPredatingChangeBySomeoneOtherThanOwner() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId = r.getPatchSetId();
+    Change.Id id = psId.getParentKey();
+    Change c = db.changes().get(id);
+
+    ReviewInput rin = new ReviewInput();
+    rin.message = "comment";
+
+    Timestamp ts = new Timestamp(c.getCreatedOn().getTime() - 10000);
+    RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId());
+    setApiUser(user);
+    postReview.get().apply(batchUpdateFactory, revRsrc, rin, ts);
+
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void noteDbUsesOriginalSubjectFromPatchSetAndIgnoresChangeField() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String orig = r.getChange().change().getSubject();
+    r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                testRepo,
+                orig + " v2",
+                PushOneCommit.FILE_NAME,
+                "new contents",
+                r.getChangeId())
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    PatchSet.Id psId = r.getPatchSetId();
+    Change.Id id = psId.getParentKey();
+    Change c = db.changes().get(id);
+
+    c.setCurrentPatchSet(psId, c.getSubject(), "Bogus original subject");
+    db.changes().update(Collections.singleton(c));
+
+    checker.rebuildAndCheckChanges(id);
+
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    Change nc = notes.getChange();
+    assertThat(nc.getSubject()).isEqualTo(c.getSubject());
+    assertThat(nc.getSubject()).isEqualTo(orig + " v2");
+    assertThat(nc.getOriginalSubject()).isNotEqualTo(c.getOriginalSubject());
+    assertThat(nc.getOriginalSubject()).isEqualTo(orig);
+  }
+
+  @Test
+  public void ignorePatchLineCommentsOnPatchSet0() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change change = r.getChange().change();
+    Change.Id id = change.getId();
+
+    PatchLineComment comment =
+        new PatchLineComment(
+            new PatchLineComment.Key(
+                new Patch.Key(new PatchSet.Id(id, 0), PushOneCommit.FILE_NAME), "uuid"),
+            0,
+            user.getId(),
+            null,
+            TimeUtil.nowTs());
+    comment.setSide((short) 1);
+    comment.setMessage("message");
+    comment.setStatus(PatchLineComment.Status.PUBLISHED);
+    db.patchComments().insert(Collections.singleton(comment));
+    indexer.index(db, change.getProject(), id);
+
+    checker.rebuildAndCheckChanges(id);
+
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(notes.getComments()).isEmpty();
+  }
+
+  @Test
+  public void leadingSpacesInSubject() throws Exception {
+    String subj = "   " + PushOneCommit.SUBJECT;
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            subj,
+            PushOneCommit.FILE_NAME,
+            PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    Change change = r.getChange().change();
+    assertThat(change.getSubject()).isEqualTo(subj);
+    Change.Id id = r.getPatchSetId().getParentKey();
+
+    checker.rebuildAndCheckChanges(id);
+
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(notes.getChange().getSubject()).isNotEqualTo(subj);
+    assertThat(notes.getChange().getSubject()).isEqualTo(PushOneCommit.SUBJECT);
+  }
+
+  @Test
+  public void allTimestampsExceptUpdatedAreEqualDueToBadMigration() throws Exception {
+    // https://bugs.chromium.org/p/gerrit/issues/detail?id=7397
+    PushOneCommit.Result r = createChange();
+    Change c = r.getChange().change();
+    Change.Id id = c.getId();
+    Timestamp ts = TimeUtil.nowTs();
+    Timestamp origUpdated = c.getLastUpdatedOn();
+
+    c.setCreatedOn(ts);
+    assertThat(c.getCreatedOn()).isGreaterThan(c.getLastUpdatedOn());
+    db.changes().update(Collections.singleton(c));
+
+    List<ChangeMessage> cm = db.changeMessages().byChange(id).toList();
+    cm.forEach(m -> m.setWrittenOn(ts));
+    db.changeMessages().update(cm);
+
+    List<PatchSet> ps = db.patchSets().byChange(id).toList();
+    ps.forEach(p -> p.setCreatedOn(ts));
+    db.patchSets().update(ps);
+
+    List<PatchSetApproval> psa = db.patchSetApprovals().byChange(id).toList();
+    psa.forEach(p -> p.setGranted(ts));
+    db.patchSetApprovals().update(psa);
+
+    List<PatchLineComment> plc = db.patchComments().byChange(id).toList();
+    plc.forEach(p -> p.setWrittenOn(ts));
+    db.patchComments().update(plc);
+
+    checker.rebuildAndCheckChanges(id);
+
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(notes.getChange().getCreatedOn()).isEqualTo(origUpdated);
+    assertThat(notes.getChange().getLastUpdatedOn()).isAtLeast(origUpdated);
+    assertThat(notes.getPatchSets().get(new PatchSet.Id(id, 1)).getCreatedOn())
+        .isEqualTo(origUpdated);
+  }
+
+  @Test
+  public void createWithAutoRebuildingDisabled() throws Exception {
+    ReviewDb oldDb = db;
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    ChangeNotes oldNotes = notesFactory.create(db, project, id);
+
+    // Make a ReviewDb change behind NoteDb's back.
+    Change c = oldDb.changes().get(id);
+    assertThat(c.getTopic()).isNull();
+    String topic = name("a-topic");
+    c.setTopic(topic);
+    oldDb.changes().update(Collections.singleton(c));
+
+    c = oldDb.changes().get(c.getId());
+    ChangeNotes newNotes = notesFactory.createWithAutoRebuildingDisabled(c, null);
+    assertThat(newNotes.getChange().getTopic()).isNotEqualTo(topic);
+    assertThat(newNotes.getChange().getTopic()).isEqualTo(oldNotes.getChange().getTopic());
+  }
+
+  @Test
+  public void rebuildDeletesOldDraftRefs() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment", null);
+
+    Account.Id otherAccountId = new Account.Id(user.getId().get() + 1234);
+    String otherDraftRef = refsDraftComments(id, otherAccountId);
+
+    try (Repository repo = repoManager.openRepository(allUsers);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId sha = ins.insert(OBJ_BLOB, "garbage data".getBytes(UTF_8));
+      ins.flush();
+      RefUpdate ru = repo.updateRef(otherDraftRef);
+      ru.setExpectedOldObjectId(ObjectId.zeroId());
+      ru.setNewObjectId(sha);
+      assertThat(ru.update()).isEqualTo(RefUpdate.Result.NEW);
+    }
+
+    checker.rebuildAndCheckChanges(id);
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(otherDraftRef)).isNull();
+    }
+  }
+
+  @Test
+  public void failWhenWritesDisabled() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+    assertThat(gApi.changes().id(id.get()).info().topic).isNull();
+
+    // Turning off writes causes failure.
+    setNotesMigration(false, true);
+    try {
+      gApi.changes().id(id.get()).topic(name("a-topic"));
+      fail("Expected write to fail");
+    } catch (RestApiException e) {
+      assertChangesReadOnly(e);
+    }
+
+    // Update was not written.
+    assertThat(gApi.changes().id(id.get()).info().topic).isNull();
+    assertChangeUpToDate(true, id);
+  }
+
+  @Test
+  public void rebuildWhenWritesDisabledWorksButDoesNotWrite() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+
+    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
+    setNotesMigration(false, false);
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+    setInvalidNoteDbState(id);
+    assertChangeUpToDate(false, id);
+
+    // On next NoteDb read, change is rebuilt in-memory but not stored.
+    setNotesMigration(false, true);
+    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
+    assertChangeUpToDate(false, id);
+
+    // Attempting to write directly causes failure.
+    try {
+      gApi.changes().id(id.get()).topic(name("other-topic"));
+      fail("Expected write to fail");
+    } catch (RestApiException e) {
+      assertChangesReadOnly(e);
+    }
+
+    // Update was not written.
+    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
+    assertChangeUpToDate(false, id);
+  }
+
+  @Test
+  public void rebuildChangeWithNoPatchSets() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    db.changes().beginTransaction(id);
+    try {
+      db.patchSets().delete(db.patchSets().byChange(id));
+      db.commit();
+    } finally {
+      db.rollback();
+    }
+
+    exception.expect(NoPatchSetsException.class);
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void rebuildEntitiesCreatedByImpersonation() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    PatchSet.Id psId = new PatchSet.Id(id, 1);
+    String prefix = "/changes/" + id + "/revisions/current/";
+
+    // For each of the entities that have a real user field, create one entity
+    // without impersonation and one with.
+    CommentInput ci = new CommentInput();
+    ci.path = Patch.COMMIT_MSG;
+    ci.side = Side.REVISION;
+    ci.line = 1;
+    ci.message = "comment without impersonation";
+    ReviewInput ri = new ReviewInput();
+    ri.label("Code-Review", -1);
+    ri.message = "message without impersonation";
+    ri.drafts = DraftHandling.KEEP;
+    ri.comments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
+    userRestSession.post(prefix + "review", ri).assertOK();
+
+    DraftInput di = new DraftInput();
+    di.path = Patch.COMMIT_MSG;
+    di.side = Side.REVISION;
+    di.line = 1;
+    di.message = "draft without impersonation";
+    userRestSession.put(prefix + "drafts", di).assertCreated();
+
+    allowRunAs();
+    try {
+      Header runAs = new BasicHeader("X-Gerrit-RunAs", user.id.toString());
+      ci.message = "comment with impersonation";
+      ri.message = "message with impersonation";
+      ri.label("Code-Review", 1);
+      adminRestSession.postWithHeader(prefix + "review", ri, runAs).assertOK();
+
+      di.message = "draft with impersonation";
+      adminRestSession.putWithHeader(prefix + "drafts", runAs, di).assertCreated();
+    } finally {
+      removeRunAs();
+    }
+
+    List<ChangeMessage> msgs =
+        Ordering.natural()
+            .onResultOf(ChangeMessage::getWrittenOn)
+            .sortedCopy(db.changeMessages().byChange(id));
+    assertThat(msgs).hasSize(3);
+    assertThat(msgs.get(1).getMessage()).endsWith("message without impersonation");
+    assertThat(msgs.get(1).getAuthor()).isEqualTo(user.id);
+    assertThat(msgs.get(1).getRealAuthor()).isEqualTo(user.id);
+    assertThat(msgs.get(2).getMessage()).endsWith("message with impersonation");
+    assertThat(msgs.get(2).getAuthor()).isEqualTo(user.id);
+    assertThat(msgs.get(2).getRealAuthor()).isEqualTo(admin.id);
+
+    List<PatchSetApproval> psas = db.patchSetApprovals().byChange(id).toList();
+    assertThat(psas).hasSize(1);
+    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).getValue()).isEqualTo(1);
+    assertThat(psas.get(0).getAccountId()).isEqualTo(user.id);
+    assertThat(psas.get(0).getRealAccountId()).isEqualTo(admin.id);
+
+    Ordering<PatchLineComment> commentOrder =
+        Ordering.natural().onResultOf(PatchLineComment::getWrittenOn);
+    List<PatchLineComment> drafts =
+        commentOrder.sortedCopy(db.patchComments().draftByPatchSetAuthor(psId, user.id));
+    assertThat(drafts).hasSize(2);
+    assertThat(drafts.get(0).getMessage()).isEqualTo("draft without impersonation");
+    assertThat(drafts.get(0).getAuthor()).isEqualTo(user.id);
+    assertThat(drafts.get(0).getRealAuthor()).isEqualTo(user.id);
+    assertThat(drafts.get(1).getMessage()).isEqualTo("draft with impersonation");
+    assertThat(drafts.get(1).getAuthor()).isEqualTo(user.id);
+    assertThat(drafts.get(1).getRealAuthor()).isEqualTo(admin.id);
+
+    List<PatchLineComment> pub =
+        commentOrder.sortedCopy(db.patchComments().publishedByPatchSet(psId));
+    assertThat(pub).hasSize(2);
+    assertThat(pub.get(0).getMessage()).isEqualTo("comment without impersonation");
+    assertThat(pub.get(0).getAuthor()).isEqualTo(user.id);
+    assertThat(pub.get(0).getRealAuthor()).isEqualTo(user.id);
+    assertThat(pub.get(1).getMessage()).isEqualTo("comment with impersonation");
+    assertThat(pub.get(1).getAuthor()).isEqualTo(user.id);
+    assertThat(pub.get(1).getRealAuthor()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void laterEventsDependingOnEarlierPatchSetDontIntefereWithOtherPatchSets()
+      throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    ChangeData cd = r1.getChange();
+    Change.Id id = cd.getId();
+    amendChange(cd.change().getKey().get());
+    TestTimeUtil.incrementClock(90, TimeUnit.DAYS);
+
+    ReviewInput rin = ReviewInput.approve();
+    rin.message = "Some very late message on PS1";
+    gApi.changes().id(id.get()).revision(1).review(rin);
+
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void ignoreChangeMessageBeyondCurrentPatchSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId1 = r.getPatchSetId();
+    Change.Id id = psId1.getParentKey();
+    gApi.changes().id(id.get()).current().review(ReviewInput.recommend());
+
+    r = amendChange(r.getChangeId());
+    PatchSet.Id psId2 = r.getPatchSetId();
+
+    assertThat(db.patchSets().byChange(id)).hasSize(2);
+    assertThat(db.changeMessages().byPatchSet(psId2)).hasSize(1);
+    db.patchSets().deleteKeys(Collections.singleton(psId2));
+
+    checker.rebuildAndCheckChanges(psId2.getParentKey());
+    setNotesMigration(true, true);
+
+    ChangeData cd = changeDataFactory.create(db, project, id);
+    assertThat(cd.change().currentPatchSetId()).isEqualTo(psId1);
+    assertThat(cd.patchSets().stream().map(ps -> ps.getId()).collect(toList()))
+        .containsExactly(psId1);
+    PatchSet ps = cd.currentPatchSet();
+    assertThat(ps).isNotNull();
+    assertThat(ps.getId()).isEqualTo(psId1);
+  }
+
+  @Test
+  public void highestNumberedPatchSetIsNotCurrent() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PatchSet.Id psId1 = r1.getPatchSetId();
+    Change.Id id = psId1.getParentKey();
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId());
+    PatchSet.Id psId2 = r2.getPatchSetId();
+
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(
+            db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs())) {
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx)
+                throws PatchSetInfoNotAvailableException {
+              ctx.getChange()
+                  .setCurrentPatchSet(patchSetInfoFactory.get(ctx.getDb(), ctx.getNotes(), psId1));
+              return true;
+            }
+          });
+      bu.execute();
+    }
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(psId1, psId2);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId1);
+
+    assertThat(db.changes().get(id).currentPatchSetId()).isEqualTo(psId1);
+
+    checker.rebuildAndCheckChanges(id);
+    setNotesMigration(true, true);
+
+    notes = notesFactory.create(db, project, id);
+    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(psId1, psId2);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId1);
+  }
+
+  @Test
+  public void resolveCommentsInheritsValueFromParentWhenUnspecified() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment", true);
+    putDraft(user, id, 1, "newComment", null);
+
+    Map<String, List<CommentInfo>> comments = gApi.changes().id(id.get()).current().drafts();
+    for (List<CommentInfo> cList : comments.values()) {
+      for (CommentInfo ci : cList) {
+        assertThat(ci.unresolved).isTrue();
+      }
+    }
+  }
+
+  @Test
+  public void rebuilderRespectsReadOnlyInNoteDbChangeState() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId1 = r.getPatchSetId();
+    Change.Id id = psId1.getParentKey();
+
+    checker.rebuildAndCheckChanges(id);
+    setNotesMigration(true, true);
+
+    ReviewDb db = getUnwrappedDb();
+    Change c = db.changes().get(id);
+    NoteDbChangeState state = NoteDbChangeState.parse(c);
+    Timestamp until = new Timestamp(TimeUtil.nowMs() + MILLISECONDS.convert(1, DAYS));
+    state = state.withReadOnlyUntil(until);
+    c.setNoteDbState(state.toString());
+    db.changes().update(Collections.singleton(c));
+
+    try {
+      rebuilderWrapper.rebuild(db, id);
+      fail("expected rebuild to fail");
+    } catch (OrmRuntimeException e) {
+      assertThat(e.getMessage()).contains("read-only until");
+    }
+
+    TestTimeUtil.setClock(new Timestamp(until.getTime() + MILLISECONDS.convert(1, SECONDS)));
+    rebuilderWrapper.rebuild(db, id);
+  }
+
+  @Test
+  public void commitWithCrLineEndings() throws Exception {
+    PushOneCommit.Result r =
+        createChange("Subject\r\rBody\r", PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT);
+    Change c = r.getChange().change();
+
+    // This assertion demonstrates an arguable bug in JGit's commit subject
+    // parsing, and shows how this kind of data might have gotten into
+    // ReviewDb. If that bug ever gets fixed upstream, this assert may start
+    // failing. If that happens, this test can be rewritten to directly set the
+    // subject field in ReviewDb.
+    assertThat(c.getSubject()).isEqualTo("Subject\r\rBody");
+
+    checker.rebuildAndCheckChanges(c.getId());
+  }
+
+  @Test
+  public void patchSetsOutOfOrder() throws Exception {
+    String id = createChange().getChangeId();
+    amendChange(id);
+    PushOneCommit.Result r = amendChange(id);
+
+    ChangeData cd = r.getChange();
+    PatchSet.Id psId3 = cd.change().currentPatchSetId();
+    assertThat(psId3.get()).isEqualTo(3);
+
+    PatchSet ps1 = db.patchSets().get(new PatchSet.Id(cd.getId(), 1));
+    PatchSet ps3 = db.patchSets().get(psId3);
+    assertThat(ps1.getCreatedOn()).isLessThan(ps3.getCreatedOn());
+
+    // Simulate an old Gerrit bug by setting the created timestamp of the latest
+    // patch set ID to the timestamp of PS1.
+    ps3.setCreatedOn(ps1.getCreatedOn());
+    db.patchSets().update(Collections.singleton(ps3));
+
+    checker.rebuildAndCheckChanges(cd.getId());
+
+    setNotesMigration(true, true);
+    cd = changeDataFactory.create(db, project, cd.getId());
+    assertThat(cd.change().currentPatchSetId()).isEqualTo(psId3);
+
+    List<PatchSet> patchSets = ImmutableList.copyOf(cd.patchSets());
+    assertThat(patchSets).hasSize(3);
+
+    PatchSet newPs1 = patchSets.get(0);
+    assertThat(newPs1.getId()).isEqualTo(ps1.getId());
+    assertThat(newPs1.getCreatedOn()).isEqualTo(ps1.getCreatedOn());
+
+    PatchSet newPs2 = patchSets.get(1);
+    assertThat(newPs2.getCreatedOn()).isGreaterThan(newPs1.getCreatedOn());
+
+    PatchSet newPs3 = patchSets.get(2);
+    assertThat(newPs3.getId()).isEqualTo(ps3.getId());
+    // Migrated with a newer timestamp than the original, to preserve ordering.
+    assertThat(newPs3.getCreatedOn()).isAtLeast(newPs2.getCreatedOn());
+    assertThat(newPs3.getCreatedOn()).isGreaterThan(ps1.getCreatedOn());
+  }
+
+  @Test
+  public void ignoreNoteDbStateWithNoCorrespondingRefWhenWritesAndReadsDisabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    ReviewDb db = getUnwrappedDb();
+    Change c = db.changes().get(id);
+    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    db.changes().update(Collections.singleton(c));
+    c = db.changes().get(id);
+
+    String refName = RefNames.changeMetaRef(id);
+    assertThat(getMetaRef(project, refName)).isNull();
+
+    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+    assertThat(notes.getChange().getRowVersion()).isEqualTo(c.getRowVersion());
+
+    notes = notesFactory.createChecked(dbProvider.get(), project, id);
+    assertThat(notes.getChange().getRowVersion()).isEqualTo(c.getRowVersion());
+
+    assertThat(getMetaRef(project, refName)).isNull();
+  }
+
+  @Test
+  public void autoRebuildMissingRefWriteOnly() throws Exception {
+    setNotesMigration(true, false);
+    testAutoRebuildMissingRef();
+  }
+
+  @Test
+  public void autoRebuildMissingRefReadWrite() throws Exception {
+    setNotesMigration(true, true);
+    testAutoRebuildMissingRef();
+  }
+
+  private void testAutoRebuildMissingRef() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    assertChangeUpToDate(true, id);
+    notesFactory.createChecked(db, project, id);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      RefUpdate ru = repo.updateRef(RefNames.changeMetaRef(id));
+      ru.setForceUpdate(true);
+      assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+    assertChangeUpToDate(false, id);
+
+    notesFactory.createChecked(db, project, id);
+    assertChangeUpToDate(true, id);
+  }
+
+  private void assertChangesReadOnly(RestApiException e) throws Exception {
+    Throwable cause = e.getCause();
+    assertThat(cause).isInstanceOf(UpdateException.class);
+    assertThat(cause.getCause()).isInstanceOf(OrmException.class);
+    assertThat(cause.getCause()).hasMessageThat().isEqualTo(NoteDbUpdateManager.CHANGES_READ_ONLY);
+  }
+
+  private void setInvalidNoteDbState(Change.Id id) throws Exception {
+    ReviewDb db = getUnwrappedDb();
+    Change c = db.changes().get(id);
+    // In reality we would have NoteDb writes enabled, which would write a real
+    // state into this field. For tests however, we turn NoteDb writes off, so
+    // just use a dummy state to force ChangeNotes to view the notes as
+    // out-of-date.
+    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    db.changes().update(Collections.singleton(c));
+  }
+
+  private void assertChangeUpToDate(boolean expected, Change.Id id) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      Change c = getUnwrappedDb().changes().get(id);
+      assertThat(c).isNotNull();
+      assertThat(c.getNoteDbState()).isNotNull();
+      NoteDbChangeState state = NoteDbChangeState.parse(c);
+      assertThat(state).isNotNull();
+      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+      assertThat(state.isChangeUpToDate(new RepoRefCache(repo))).isEqualTo(expected);
+    }
+  }
+
+  private void assertDraftsUpToDate(boolean expected, Change.Id changeId, TestAccount account)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      Change c = getUnwrappedDb().changes().get(changeId);
+      assertThat(c).isNotNull();
+      assertThat(c.getNoteDbState()).isNotNull();
+      NoteDbChangeState state = NoteDbChangeState.parse(c);
+      assertThat(state.areDraftsUpToDate(new RepoRefCache(repo), account.getId()))
+          .isEqualTo(expected);
+    }
+  }
+
+  private ObjectId getMetaRef(Project.NameKey p, String name) throws Exception {
+    try (Repository repo = repoManager.openRepository(p)) {
+      Ref ref = repo.exactRef(name);
+      return ref != null ? ref.getObjectId() : null;
+    }
+  }
+
+  private void putDraft(TestAccount account, Change.Id id, int line, String msg, Boolean unresolved)
+      throws Exception {
+    DraftInput in = new DraftInput();
+    in.line = line;
+    in.message = msg;
+    in.path = PushOneCommit.FILE_NAME;
+    in.unresolved = unresolved;
+    AcceptanceTestRequestScope.Context old = setApiUser(account);
+    try {
+      gApi.changes().id(id.get()).current().createDraft(in);
+    } finally {
+      atrScope.set(old);
+    }
+  }
+
+  private void putComment(TestAccount account, Change.Id id, int line, String msg, String inReplyTo)
+      throws Exception {
+    CommentInput in = new CommentInput();
+    in.line = line;
+    in.message = msg;
+    in.inReplyTo = inReplyTo;
+    ReviewInput rin = new ReviewInput();
+    rin.comments = new HashMap<>();
+    rin.comments.put(PushOneCommit.FILE_NAME, ImmutableList.of(in));
+    rin.drafts = ReviewInput.DraftHandling.KEEP;
+    AcceptanceTestRequestScope.Context old = setApiUser(account);
+    try {
+      gApi.changes().id(id.get()).current().review(rin);
+    } finally {
+      atrScope.set(old);
+    }
+  }
+
+  private void publishDrafts(TestAccount account, Change.Id id) throws Exception {
+    ReviewInput rin = new ReviewInput();
+    rin.drafts = ReviewInput.DraftHandling.PUBLISH_ALL_REVISIONS;
+    AcceptanceTestRequestScope.Context old = setApiUser(account);
+    try {
+      gApi.changes().id(id.get()).current().review(rin);
+    } finally {
+      atrScope.set(old);
+    }
+  }
+
+  private ChangeMessage insertMessage(
+      Change.Id id, PatchSet.Id psId, Account.Id author, Timestamp ts, String message)
+      throws Exception {
+    ChangeMessage msg =
+        new ChangeMessage(new ChangeMessage.Key(id, ChangeUtil.messageUuid()), author, ts, psId);
+    msg.setMessage(message);
+    db.changeMessages().insert(Collections.singleton(msg));
+
+    Change c = db.changes().get(id);
+    if (ts.compareTo(c.getLastUpdatedOn()) > 0) {
+      c.setLastUpdatedOn(ts);
+      db.changes().update(Collections.singleton(c));
+    }
+
+    return msg;
+  }
+
+  private ReviewDb getUnwrappedDb() {
+    ReviewDb db = dbProvider.get();
+    return ReviewDbUtil.unwrapDb(db);
+  }
+
+  private void allowRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.allow(
+        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  private void removeRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.remove(
+        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  private Map<String, List<CommentInfo>> getPublishedComments(Change.Id id) throws Exception {
+    return gApi.changes().id(id.get()).current().comments();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
new file mode 100644
index 0000000..1b6d8c8
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -0,0 +1,326 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateListener;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+public class NoteDbOnlyIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    // Avoid spurious timeouts during intentional retries due to overloaded test machines.
+    cfg.setString("noteDb", null, "retryTimeout", Integer.MAX_VALUE + "s");
+    return cfg;
+  }
+
+  @Inject private RetryHelper retryHelper;
+
+  @Before
+  public void setUp() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+  }
+
+  @Test
+  public void updateChangeFailureRollsBackRefUpdate() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    String master = "refs/heads/master";
+    String backup = "refs/backup/master";
+    ObjectId master1 = getRef(master).get();
+    assertThat(getRef(backup)).isEmpty();
+
+    // Toy op that copies the value of refs/heads/master to refs/backup/master.
+    BatchUpdateOp backupMasterOp =
+        new BatchUpdateOp() {
+          ObjectId newId;
+
+          @Override
+          public void updateRepo(RepoContext ctx) throws IOException {
+            ObjectId oldId = ctx.getRepoView().getRef(backup).orElse(ObjectId.zeroId());
+            newId = ctx.getRepoView().getRef(master).get();
+            ctx.addRefUpdate(oldId, newId, backup);
+          }
+
+          @Override
+          public boolean updateChange(ChangeContext ctx) {
+            ctx.getUpdate(ctx.getChange().currentPatchSetId())
+                .setChangeMessage("Backed up master branch to " + newId.name());
+            return true;
+          }
+        };
+
+    try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+      bu.addOp(id, backupMasterOp);
+      bu.execute();
+    }
+
+    // Ensure backupMasterOp worked.
+    assertThat(getRef(backup)).hasValue(master1);
+    assertThat(getMessages(id)).contains("Backed up master branch to " + master1.name());
+
+    // Advance master by submitting the change.
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id.get()).current().submit();
+    ObjectId master2 = getRef(master).get();
+    assertThat(master2).isNotEqualTo(master1);
+    int msgCount = getMessages(id).size();
+
+    try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+      // This time, we attempt to back up master, but we fail during updateChange.
+      bu.addOp(id, backupMasterOp);
+      String msg = "Change is bad";
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws ResourceConflictException {
+              throw new ResourceConflictException(msg);
+            }
+          });
+      try {
+        bu.execute();
+        fail("expected ResourceConflictException");
+      } catch (ResourceConflictException e) {
+        assertThat(e).hasMessageThat().isEqualTo(msg);
+      }
+    }
+
+    // If updateChange hadn't failed, backup would have been updated to master2.
+    assertThat(getRef(backup)).hasValue(master1);
+    assertThat(getMessages(id)).hasSize(msgCount);
+  }
+
+  @Test
+  public void retryOnLockFailureWithAtomicUpdates() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    String master = "refs/heads/master";
+    ObjectId initial;
+    try (Repository repo = repoManager.openRepository(project)) {
+      ensureAtomicTransactions(repo);
+      initial = repo.exactRef(master).getObjectId();
+    }
+
+    AtomicInteger updateRepoCalledCount = new AtomicInteger();
+    AtomicInteger updateChangeCalledCount = new AtomicInteger();
+    AtomicInteger afterUpdateReposCalledCount = new AtomicInteger();
+
+    String result =
+        retryHelper.execute(
+            batchUpdateFactory -> {
+              try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+                bu.addOp(
+                    id,
+                    new UpdateRefAndAddMessageOp(updateRepoCalledCount, updateChangeCalledCount));
+                bu.execute(new ConcurrentWritingListener(afterUpdateReposCalledCount));
+              }
+              return "Done";
+            });
+
+    assertThat(result).isEqualTo("Done");
+    assertThat(updateRepoCalledCount.get()).isEqualTo(2);
+    assertThat(afterUpdateReposCalledCount.get()).isEqualTo(2);
+    assertThat(updateChangeCalledCount.get()).isEqualTo(2);
+
+    List<String> messages = getMessages(id);
+    assertThat(Iterables.getLast(messages)).isEqualTo(UpdateRefAndAddMessageOp.CHANGE_MESSAGE);
+    assertThat(Collections.frequency(messages, UpdateRefAndAddMessageOp.CHANGE_MESSAGE))
+        .isEqualTo(1);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // Op lost the race, so the other writer's commit happened first. Then op retried and wrote
+      // its commit with the other writer's commit as parent.
+      assertThat(commitMessages(repo, initial, repo.exactRef(master).getObjectId()))
+          .containsExactly(
+              ConcurrentWritingListener.MSG_PREFIX + "1", UpdateRefAndAddMessageOp.COMMIT_MESSAGE)
+          .inOrder();
+    }
+  }
+
+  @Test
+  public void missingChange() throws Exception {
+    Change.Id changeId = new Change.Id(1234567);
+    assertNoSuchChangeException(() -> notesFactory.create(db, project, changeId));
+    assertNoSuchChangeException(() -> notesFactory.createChecked(db, project, changeId));
+  }
+
+  private void assertNoSuchChangeException(Callable<?> callable) throws Exception {
+    try {
+      callable.call();
+      fail("expected NoSuchChangeException");
+    } catch (NoSuchChangeException e) {
+      // Expected.
+    }
+  }
+
+  private class ConcurrentWritingListener implements BatchUpdateListener {
+    static final String MSG_PREFIX = "Other writer ";
+
+    private final AtomicInteger calledCount;
+
+    private ConcurrentWritingListener(AtomicInteger calledCount) {
+      this.calledCount = calledCount;
+    }
+
+    @Override
+    public void afterUpdateRepos() throws Exception {
+      // Reopen repo and update ref, to simulate a concurrent write in another
+      // thread. Only do this the first time the listener is called.
+      if (calledCount.getAndIncrement() > 0) {
+        return;
+      }
+      try (Repository repo = repoManager.openRepository(project);
+          RevWalk rw = new RevWalk(repo);
+          ObjectInserter ins = repo.newObjectInserter()) {
+        String master = "refs/heads/master";
+        ObjectId oldId = repo.exactRef(master).getObjectId();
+        ObjectId newId = newCommit(rw, ins, oldId, MSG_PREFIX + calledCount.get());
+        ins.flush();
+        RefUpdate ru = repo.updateRef(master);
+        ru.setExpectedOldObjectId(oldId);
+        ru.setNewObjectId(newId);
+        assertThat(ru.update(rw)).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+      }
+    }
+  }
+
+  private class UpdateRefAndAddMessageOp implements BatchUpdateOp {
+    static final String COMMIT_MESSAGE = "A commit";
+    static final String CHANGE_MESSAGE = "A change message";
+
+    private final AtomicInteger updateRepoCalledCount;
+    private final AtomicInteger updateChangeCalledCount;
+
+    private UpdateRefAndAddMessageOp(
+        AtomicInteger updateRepoCalledCount, AtomicInteger updateChangeCalledCount) {
+      this.updateRepoCalledCount = updateRepoCalledCount;
+      this.updateChangeCalledCount = updateChangeCalledCount;
+    }
+
+    @Override
+    public void updateRepo(RepoContext ctx) throws Exception {
+      String master = "refs/heads/master";
+      ObjectId oldId = ctx.getRepoView().getRef(master).get();
+      ObjectId newId = newCommit(ctx.getRevWalk(), ctx.getInserter(), oldId, COMMIT_MESSAGE);
+      ctx.addRefUpdate(oldId, newId, master);
+      updateRepoCalledCount.incrementAndGet();
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      ctx.getUpdate(ctx.getChange().currentPatchSetId()).setChangeMessage(CHANGE_MESSAGE);
+      updateChangeCalledCount.incrementAndGet();
+      return true;
+    }
+  }
+
+  private ObjectId newCommit(RevWalk rw, ObjectInserter ins, ObjectId parent, String msg)
+      throws IOException {
+    PersonIdent ident = serverIdent.get();
+    CommitBuilder cb = new CommitBuilder();
+    cb.setParentId(parent);
+    cb.setTreeId(rw.parseCommit(parent).getTree());
+    cb.setMessage(msg);
+    cb.setAuthor(ident);
+    cb.setCommitter(ident);
+    return ins.insert(Constants.OBJ_COMMIT, cb.build());
+  }
+
+  private BatchUpdate newBatchUpdate(BatchUpdate.Factory buf) {
+    return buf.create(db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs());
+  }
+
+  private Optional<ObjectId> getRef(String name) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return Optional.ofNullable(repo.exactRef(name)).map(Ref::getObjectId);
+    }
+  }
+
+  private List<String> getMessages(Change.Id id) throws Exception {
+    return gApi.changes()
+        .id(id.get())
+        .get(MESSAGES)
+        .messages
+        .stream()
+        .map(m -> m.message)
+        .collect(toList());
+  }
+
+  private static List<String> commitMessages(
+      Repository repo, ObjectId fromExclusive, ObjectId toInclusive) throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      rw.markStart(rw.parseCommit(toInclusive));
+      rw.markUninteresting(rw.parseCommit(fromExclusive));
+      rw.sort(RevSort.REVERSE);
+      rw.setRetainBody(true);
+      return Streams.stream(rw).map(c -> c.getShortMessage()).collect(toList());
+    }
+  }
+
+  private void ensureAtomicTransactions(Repository repo) throws Exception {
+    if (repo instanceof InMemoryRepository) {
+      ((InMemoryRepository) repo).setPerformsAtomicTransactions(true);
+    } else {
+      assertThat(repo.getRefDatabase().performsAtomicTransactions())
+          .named("performsAtomicTransactions on %s", repo)
+          .isTrue();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
new file mode 100644
index 0000000..90f20ef
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
@@ -0,0 +1,524 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.formatTime;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
+import static java.util.concurrent.TimeUnit.DAYS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.git.RepoRefCache;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.PrimaryStorageMigrator;
+import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.NoteDbMode;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.inject.Inject;
+import com.google.inject.util.Providers;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class NoteDbPrimaryIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setString("notedb", null, "concurrentWriterTimeout", "0s");
+    cfg.setString("notedb", null, "primaryStorageMigrationTimeout", "1d");
+    cfg.setBoolean("noteDb", null, "testRebuilderWrapper", true);
+    return cfg;
+  }
+
+  @Inject private ChangeBundleReader bundleReader;
+  @Inject private CommentsUtil commentsUtil;
+  @Inject private TestChangeRebuilderWrapper rebuilderWrapper;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private ChangeUpdate.Factory updateFactory;
+  @Inject private InternalUser.Factory internalUserFactory;
+  @Inject private RetryHelper retryHelper;
+
+  private PrimaryStorageMigrator migrator;
+
+  @Before
+  public void setUp() throws Exception {
+    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.READ_WRITE);
+    db = ReviewDbUtil.unwrapDb(db);
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    migrator = newMigrator(null);
+  }
+
+  private PrimaryStorageMigrator newMigrator(
+      @Nullable Retryer<NoteDbChangeState> ensureRebuiltRetryer) {
+    return new PrimaryStorageMigrator(
+        cfg,
+        Providers.of(db),
+        repoManager,
+        allUsers,
+        rebuilderWrapper,
+        ensureRebuiltRetryer,
+        changeNotesFactory,
+        queryProvider,
+        updateFactory,
+        internalUserFactory,
+        retryHelper);
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void updateChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    setNoteDbPrimary(id);
+
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id.get()).current().submit();
+
+    ChangeInfo info = gApi.changes().id(id.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+    ApprovalInfo approval = Iterables.getOnlyElement(info.labels.get("Code-Review").all);
+    assertThat(approval._accountId).isEqualTo(admin.id.get());
+    assertThat(approval.value).isEqualTo(2);
+    assertThat(info.messages).hasSize(3);
+    assertThat(Iterables.getLast(info.messages).message)
+        .isEqualTo("Change has been successfully merged by " + admin.fullName);
+
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(notes.getChange().getNoteDbState())
+        .isEqualTo(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+
+    // Writes weren't reflected in ReviewDb.
+    assertThat(db.changes().get(id).getStatus()).isEqualTo(Change.Status.NEW);
+    assertThat(db.patchSetApprovals().byChange(id)).isEmpty();
+    assertThat(db.changeMessages().byChange(id)).hasSize(1);
+  }
+
+  @Test
+  public void deleteDraftComment() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    setNoteDbPrimary(id);
+
+    DraftInput din = new DraftInput();
+    din.path = PushOneCommit.FILE_NAME;
+    din.line = 1;
+    din.message = "A comment";
+    gApi.changes().id(id.get()).current().createDraft(din);
+
+    CommentInfo di =
+        Iterables.getOnlyElement(
+            gApi.changes().id(id.get()).current().drafts().get(PushOneCommit.FILE_NAME));
+    assertThat(di.message).isEqualTo(din.message);
+
+    assertThat(db.patchComments().draftByChangeFileAuthor(id, din.path, admin.id)).isEmpty();
+
+    gApi.changes().id(id.get()).current().draft(di.id).delete();
+    assertThat(gApi.changes().id(id.get()).current().drafts()).isEmpty();
+  }
+
+  @Test
+  public void deleteVote() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    setNoteDbPrimary(id);
+
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    List<ApprovalInfo> approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
+    assertThat(approvals).hasSize(1);
+    assertThat(approvals.get(0).value).isEqualTo(2);
+
+    gApi.changes().id(id.get()).reviewer(admin.id.toString()).deleteVote("Code-Review");
+
+    approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
+    assertThat(approvals).hasSize(1);
+    assertThat(approvals.get(0).value).isEqualTo(0);
+  }
+
+  @Test
+  public void deleteVoteViaReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    setNoteDbPrimary(id);
+
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    List<ApprovalInfo> approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
+    assertThat(approvals).hasSize(1);
+    assertThat(approvals.get(0).value).isEqualTo(2);
+
+    gApi.changes().id(id.get()).current().review(ReviewInput.noScore());
+
+    approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
+    assertThat(approvals).hasSize(1);
+    assertThat(approvals.get(0).value).isEqualTo(0);
+  }
+
+  @Test
+  public void deleteReviewer() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    setNoteDbPrimary(id);
+
+    gApi.changes().id(id.get()).addReviewer(user.id.toString());
+    assertThat(getReviewers(id)).containsExactly(user.id);
+    gApi.changes().id(id.get()).reviewer(user.id.toString()).remove();
+    assertThat(getReviewers(id)).isEmpty();
+  }
+
+  @Test
+  public void readOnlyReviewDb() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    testReadOnly(id);
+  }
+
+  @Test
+  public void readOnlyNoteDb() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    setNoteDbPrimary(id);
+    testReadOnly(id);
+  }
+
+  private void testReadOnly(Change.Id id) throws Exception {
+    Timestamp before = TimeUtil.nowTs();
+    Timestamp until = new Timestamp(before.getTime() + 1000 * 3600);
+
+    // Set read-only.
+    Change c = db.changes().get(id);
+    assertThat(c).named("change " + id).isNotNull();
+    NoteDbChangeState state = NoteDbChangeState.parse(c);
+    state = state.withReadOnlyUntil(until);
+    c.setNoteDbState(state.toString());
+    db.changes().update(Collections.singleton(c));
+
+    assertThat(gApi.changes().id(id.get()).get().subject).isEqualTo(PushOneCommit.SUBJECT);
+    assertThat(gApi.changes().id(id.get()).get().topic).isNull();
+    try {
+      gApi.changes().id(id.get()).topic("a-topic");
+      fail("expected read-only exception");
+    } catch (RestApiException e) {
+      Optional<Throwable> oe =
+          Throwables.getCausalChain(e)
+              .stream()
+              .filter(x -> x instanceof OrmRuntimeException)
+              .findFirst();
+      assertThat(oe).named("OrmRuntimeException in causal chain of " + e).isPresent();
+      assertThat(oe.get().getMessage()).contains("read-only");
+    }
+    assertThat(gApi.changes().id(id.get()).get().topic).isNull();
+
+    TestTimeUtil.setClock(new Timestamp(until.getTime() + 1000));
+    assertThat(gApi.changes().id(id.get()).get().subject).isEqualTo(PushOneCommit.SUBJECT);
+    gApi.changes().id(id.get()).topic("a-topic");
+    assertThat(gApi.changes().id(id.get()).get().topic).isEqualTo("a-topic");
+  }
+
+  @Test
+  public void migrateToNoteDb() throws Exception {
+    testMigrateToNoteDb(createChange().getChange().getId());
+  }
+
+  @Test
+  public void migrateToNoteDbWithRebuildingFirst() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    Change c = db.changes().get(id);
+    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    db.changes().update(Collections.singleton(c));
+    testMigrateToNoteDb(id);
+  }
+
+  private void testMigrateToNoteDb(Change.Id id) throws Exception {
+    assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.REVIEW_DB);
+    migrator.migrateToNoteDbPrimary(id);
+    assertNoteDbPrimary(id);
+
+    gApi.changes().id(id.get()).topic("a-topic");
+    assertThat(gApi.changes().id(id.get()).get().topic).isEqualTo("a-topic");
+    assertThat(db.changes().get(id).getTopic()).isNull();
+  }
+
+  @Test
+  public void migrateToNoteDbFailsRebuildingOnceAndRetries() throws Exception {
+    Change.Id id = createChange().getChange().getId();
+
+    Change c = db.changes().get(id);
+    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    db.changes().update(Collections.singleton(c));
+    rebuilderWrapper.failNextUpdate();
+
+    migrator =
+        newMigrator(
+            RetryerBuilder.<NoteDbChangeState>newBuilder()
+                .retryIfException()
+                .withStopStrategy(StopStrategies.neverStop())
+                .build());
+    migrator.migrateToNoteDbPrimary(id);
+    assertNoteDbPrimary(id);
+  }
+
+  @Test
+  public void migrateToNoteDbFailsRebuildingAndStops() throws Exception {
+    Change.Id id = createChange().getChange().getId();
+
+    Change c = db.changes().get(id);
+    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    db.changes().update(Collections.singleton(c));
+    rebuilderWrapper.failNextUpdate();
+
+    migrator =
+        newMigrator(
+            RetryerBuilder.<NoteDbChangeState>newBuilder()
+                .retryIfException()
+                .withStopStrategy(StopStrategies.stopAfterAttempt(1))
+                .build());
+    exception.expect(OrmException.class);
+    exception.expectMessage("Retrying failed");
+    migrator.migrateToNoteDbPrimary(id);
+  }
+
+  @Test
+  public void migrateToNoteDbMissingOldState() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    Change c = db.changes().get(id);
+    c.setNoteDbState(null);
+    db.changes().update(Collections.singleton(c));
+
+    exception.expect(OrmRuntimeException.class);
+    exception.expectMessage("no note_db_state");
+    migrator.migrateToNoteDbPrimary(id);
+  }
+
+  @Test
+  public void migrateToNoteDbLeaseExpires() throws Exception {
+    TestTimeUtil.resetWithClockStep(2, DAYS);
+    exception.expect(OrmRuntimeException.class);
+    exception.expectMessage("read-only lease");
+    migrator.migrateToNoteDbPrimary(createChange().getChange().getId());
+  }
+
+  @Test
+  public void migrateToNoteDbAlreadyReadOnly() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    Change c = db.changes().get(id);
+    NoteDbChangeState state = NoteDbChangeState.parse(c);
+    Timestamp until = new Timestamp(TimeUtil.nowMs() + MILLISECONDS.convert(1, DAYS));
+    state = state.withReadOnlyUntil(until);
+    c.setNoteDbState(state.toString());
+    db.changes().update(Collections.singleton(c));
+
+    exception.expect(OrmRuntimeException.class);
+    exception.expectMessage("read-only until " + until);
+    migrator.migrateToNoteDbPrimary(id);
+  }
+
+  @Test
+  public void migrateToNoteDbAlreadyMigrated() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.REVIEW_DB);
+    migrator.migrateToNoteDbPrimary(id);
+    assertNoteDbPrimary(id);
+
+    migrator.migrateToNoteDbPrimary(id);
+    assertNoteDbPrimary(id);
+  }
+
+  @Test
+  public void rebuildReviewDb() throws Exception {
+    Change c = createChange().getChange().change();
+    Change.Id id = c.getId();
+
+    CommentInput cin = new CommentInput();
+    cin.line = 1;
+    cin.message = "Published comment";
+    ReviewInput rin = ReviewInput.approve();
+    rin.comments = ImmutableMap.of(PushOneCommit.FILE_NAME, ImmutableList.of(cin));
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+
+    DraftInput din = new DraftInput();
+    din.path = PushOneCommit.FILE_NAME;
+    din.line = 1;
+    din.message = "Draft comment";
+    gApi.changes().id(id.get()).current().createDraft(din);
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id.get()).current().createDraft(din);
+
+    assertThat(db.changeMessages().byChange(id)).isNotEmpty();
+    assertThat(db.patchSets().byChange(id)).isNotEmpty();
+    assertThat(db.patchSetApprovals().byChange(id)).isNotEmpty();
+    assertThat(db.patchComments().byChange(id)).isNotEmpty();
+
+    ChangeBundle noteDbBundle =
+        ChangeBundle.fromNotes(commentsUtil, notesFactory.create(db, project, id));
+
+    setNoteDbPrimary(id);
+
+    db.changeMessages().delete(db.changeMessages().byChange(id));
+    db.patchSets().delete(db.patchSets().byChange(id));
+    db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
+    db.patchComments().delete(db.patchComments().byChange(id));
+    ChangeMessage bogusMessage =
+        ChangeMessagesUtil.newMessage(
+            c.currentPatchSetId(),
+            identifiedUserFactory.create(admin.getId()),
+            TimeUtil.nowTs(),
+            "some message",
+            null);
+    db.changeMessages().insert(Collections.singleton(bogusMessage));
+
+    rebuilderWrapper.rebuildReviewDb(db, project, id);
+
+    assertThat(db.changeMessages().byChange(id)).isNotEmpty();
+    assertThat(db.patchSets().byChange(id)).isNotEmpty();
+    assertThat(db.patchSetApprovals().byChange(id)).isNotEmpty();
+    assertThat(db.patchComments().byChange(id)).isNotEmpty();
+
+    ChangeBundle reviewDbBundle = bundleReader.fromReviewDb(ReviewDbUtil.unwrapDb(db), id);
+    assertThat(reviewDbBundle.differencesFrom(noteDbBundle)).isEmpty();
+  }
+
+  @Test
+  public void migrateBackToReviewDbPrimary() throws Exception {
+    Change c = createChange().getChange().change();
+    Change.Id id = c.getId();
+
+    migrator.migrateToNoteDbPrimary(id);
+    assertNoteDbPrimary(id);
+
+    gApi.changes().id(id.get()).topic("new-topic");
+    assertThat(gApi.changes().id(id.get()).topic()).isEqualTo("new-topic");
+    assertThat(db.changes().get(id).getTopic()).isNotEqualTo("new-topic");
+
+    migrator.migrateToReviewDbPrimary(id, null);
+    ObjectId metaId;
+    try (Repository repo = repoManager.openRepository(c.getProject());
+        RevWalk rw = new RevWalk(repo)) {
+      metaId = repo.exactRef(RefNames.changeMetaRef(id)).getObjectId();
+      RevCommit commit = rw.parseCommit(metaId);
+      rw.parseBody(commit);
+      assertThat(commit.getFullMessage())
+          .contains("Read-only-until: " + formatTime(serverIdent.get(), new Timestamp(0)));
+    }
+    NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
+    assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+    assertThat(state.getChangeMetaId()).isEqualTo(metaId);
+    assertThat(gApi.changes().id(id.get()).topic()).isEqualTo("new-topic");
+    assertThat(db.changes().get(id).getTopic()).isEqualTo("new-topic");
+
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(notes.getRevision()).isEqualTo(metaId); // No rebuilding, change was up to date.
+    assertThat(notes.getReadOnlyUntil()).isNotNull();
+
+    gApi.changes().id(id.get()).topic("reviewdb-topic");
+    assertThat(db.changes().get(id).getTopic()).isEqualTo("reviewdb-topic");
+  }
+
+  private void setNoteDbPrimary(Change.Id id) throws Exception {
+    Change c = db.changes().get(id);
+    assertThat(c).named("change " + id).isNotNull();
+    NoteDbChangeState state = NoteDbChangeState.parse(c);
+    assertThat(state.getPrimaryStorage()).named("storage of " + id).isEqualTo(REVIEW_DB);
+
+    try (Repository changeRepo = repoManager.openRepository(c.getProject());
+        Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      assertThat(state.isUpToDate(new RepoRefCache(changeRepo), new RepoRefCache(allUsersRepo)))
+          .named("change " + id + " up to date")
+          .isTrue();
+    }
+
+    c.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+    db.changes().update(Collections.singleton(c));
+  }
+
+  private void assertNoteDbPrimary(Change.Id id) throws Exception {
+    assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.NOTE_DB);
+  }
+
+  private List<Account.Id> getReviewers(Change.Id id) throws Exception {
+    return gApi.changes()
+        .id(id.get())
+        .get()
+        .reviewers
+        .values()
+        .stream()
+        .flatMap(Collection::stream)
+        .map(a -> new Account.Id(a._accountId))
+        .collect(toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
new file mode 100644
index 0000000..d4990af
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
@@ -0,0 +1,558 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.notedb;
+
+import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.notedb.NotesMigrationState.NOTE_DB;
+import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_NO_SEQUENCE;
+import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY;
+import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY;
+import static com.google.gerrit.server.notedb.NotesMigrationState.REVIEW_DB;
+import static com.google.gerrit.server.notedb.NotesMigrationState.WRITE;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.naturalOrder;
+import static org.easymock.EasyMock.createStrictMock;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
+import com.google.gerrit.server.notedb.NotesMigrationState;
+import com.google.gerrit.server.notedb.rebuild.MigrationException;
+import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
+import com.google.gerrit.server.notedb.rebuild.NotesMigrationStateListener;
+import com.google.gerrit.server.schema.ReviewDbFactory;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.NoteDbMode;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@Sandboxed
+@UseLocalDisk
+@NoHttpd
+public class OnlineNoteDbMigrationIT extends AbstractDaemonTest {
+  private static final String INVALID_STATE = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setInt("noteDb", "changes", "sequenceBatchSize", 10);
+    cfg.setInt("noteDb", "changes", "initialSequenceGap", 500);
+    return cfg;
+  }
+
+  // Tests in this class are generally interested in the actual ReviewDb contents, but the shifting
+  // migration state may result in various kinds of wrappers showing up unexpectedly.
+  @Inject @ReviewDbFactory private SchemaFactory<ReviewDb> schemaFactory;
+
+  @Inject private SitePaths sitePaths;
+  @Inject private Provider<NoteDbMigrator.Builder> migratorBuilderProvider;
+  @Inject private Sequences sequences;
+  @Inject private DynamicSet<NotesMigrationStateListener> listeners;
+
+  private FileBasedConfig noteDbConfig;
+  private List<RegistrationHandle> addedListeners;
+
+  @Before
+  public void setUp() throws Exception {
+    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.OFF);
+    // Unlike in the running server, for tests, we don't stack notedb.config on gerrit.config.
+    noteDbConfig = new FileBasedConfig(sitePaths.notedb_config.toFile(), FS.detect());
+    assertNotesMigrationState(REVIEW_DB, false, false);
+    addedListeners = new ArrayList<>();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (addedListeners != null) {
+      addedListeners.forEach(RegistrationHandle::remove);
+      addedListeners = null;
+    }
+  }
+
+  @Test
+  public void preconditionsFail() throws Exception {
+    List<Change.Id> cs = ImmutableList.of(new Change.Id(1));
+    List<Project.NameKey> ps = ImmutableList.of(new Project.NameKey("p"));
+    assertMigrationException(
+        "Cannot rebuild without noteDb.changes.write=true", b -> b, NoteDbMigrator::rebuild);
+    assertMigrationException(
+        "Cannot set both changes and projects", b -> b.setChanges(cs).setProjects(ps), m -> {});
+    assertMigrationException(
+        "Cannot set changes or projects during full migration",
+        b -> b.setChanges(cs),
+        NoteDbMigrator::migrate);
+    assertMigrationException(
+        "Cannot set changes or projects during full migration",
+        b -> b.setProjects(ps),
+        NoteDbMigrator::migrate);
+
+    setNotesMigrationState(READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY);
+    assertMigrationException(
+        "Migration has already progressed past the endpoint of the \"trial mode\" state",
+        b -> b.setTrialMode(true),
+        NoteDbMigrator::migrate);
+
+    setNotesMigrationState(READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY);
+    assertMigrationException(
+        "Cannot force rebuild changes; NoteDb is already the primary storage for some changes",
+        b -> b.setForceRebuild(true),
+        NoteDbMigrator::migrate);
+  }
+
+  @Test
+  @GerritConfig(name = "noteDb.changes.initialSequenceGap", value = "-7")
+  public void initialSequenceGapMustBeNonNegative() throws Exception {
+    setNotesMigrationState(READ_WRITE_NO_SEQUENCE);
+    assertMigrationException("Sequence gap must be non-negative: -7", b -> b, m -> {});
+  }
+
+  @Test
+  public void rebuildOneChangeTrialModeAndForceRebuild() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    migrate(b -> b.setTrialMode(true));
+    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, false, true);
+
+    ObjectId oldMetaId;
+    try (Repository repo = repoManager.openRepository(project);
+        ReviewDb db = schemaFactory.open()) {
+      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
+      assertThat(ref).isNotNull();
+      oldMetaId = ref.getObjectId();
+
+      Change c = db.changes().get(id);
+      assertThat(c).isNotNull();
+      NoteDbChangeState state = NoteDbChangeState.parse(c);
+      assertThat(state).isNotNull();
+      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+      assertThat(state.getRefState()).hasValue(RefState.create(oldMetaId, ImmutableMap.of()));
+
+      // Force change to be out of date, and change topic so it will get rebuilt as something other
+      // than oldMetaId.
+      c.setNoteDbState(INVALID_STATE);
+      c.setTopic(name("a-new-topic"));
+      db.changes().update(ImmutableList.of(c));
+    }
+
+    migrate(b -> b.setTrialMode(true));
+    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, false, true);
+
+    try (Repository repo = repoManager.openRepository(project);
+        ReviewDb db = schemaFactory.open()) {
+      // Change is out of date, but was not rebuilt without forceRebuild.
+      assertThat(repo.exactRef(RefNames.changeMetaRef(id)).getObjectId()).isEqualTo(oldMetaId);
+      Change c = db.changes().get(id);
+      assertThat(c.getNoteDbState()).isEqualTo(INVALID_STATE);
+    }
+
+    migrate(b -> b.setTrialMode(true).setForceRebuild(true));
+    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, false, true);
+
+    try (Repository repo = repoManager.openRepository(project);
+        ReviewDb db = schemaFactory.open()) {
+      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
+      assertThat(ref).isNotNull();
+      ObjectId newMetaId = ref.getObjectId();
+      assertThat(newMetaId).isNotEqualTo(oldMetaId);
+
+      NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
+      assertThat(state).isNotNull();
+      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+      assertThat(state.getRefState()).hasValue(RefState.create(newMetaId, ImmutableMap.of()));
+    }
+  }
+
+  @Test
+  public void autoMigrateTrialMode() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    migrate(b -> b.setAutoMigrate(true).setTrialMode(true).setStopAtStateForTesting(WRITE));
+    assertNotesMigrationState(WRITE, true, true);
+
+    migrate(b -> b);
+    // autoMigrate is still enabled so that we can continue the migration by only unsetting trial.
+    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, true, true);
+
+    ObjectId metaId;
+    try (Repository repo = repoManager.openRepository(project);
+        ReviewDb db = schemaFactory.open()) {
+      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
+      assertThat(ref).isNotNull();
+      metaId = ref.getObjectId();
+      NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
+      assertThat(state).isNotNull();
+      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+      assertThat(state.getRefState()).hasValue(RefState.create(metaId, ImmutableMap.of()));
+    }
+
+    // Unset trial mode and the next migration runs to completion.
+    noteDbConfig.load();
+    NoteDbMigrator.setTrialMode(noteDbConfig, false);
+    noteDbConfig.save();
+
+    migrate(b -> b);
+    assertNotesMigrationState(NOTE_DB, false, false);
+
+    try (Repository repo = repoManager.openRepository(project);
+        ReviewDb db = schemaFactory.open()) {
+      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
+      assertThat(ref).isNotNull();
+      assertThat(ref.getObjectId()).isEqualTo(metaId);
+      NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
+      assertThat(state).isNotNull();
+      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.NOTE_DB);
+    }
+  }
+
+  @Test
+  public void rebuildSubsetOfChanges() throws Exception {
+    setNotesMigrationState(WRITE);
+
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    Change.Id id1 = r1.getChange().getId();
+    Change.Id id2 = r2.getChange().getId();
+
+    try (ReviewDb db = schemaFactory.open()) {
+      Change c1 = db.changes().get(id1);
+      c1.setNoteDbState(INVALID_STATE);
+      Change c2 = db.changes().get(id2);
+      c2.setNoteDbState(INVALID_STATE);
+      db.changes().update(ImmutableList.of(c1, c2));
+    }
+
+    migrate(b -> b.setChanges(ImmutableList.of(id2)), NoteDbMigrator::rebuild);
+
+    try (ReviewDb db = schemaFactory.open()) {
+      NoteDbChangeState s1 = NoteDbChangeState.parse(db.changes().get(id1));
+      assertThat(s1.getChangeMetaId().name()).isEqualTo(INVALID_STATE);
+
+      NoteDbChangeState s2 = NoteDbChangeState.parse(db.changes().get(id2));
+      assertThat(s2.getChangeMetaId().name()).isNotEqualTo(INVALID_STATE);
+    }
+  }
+
+  @Test
+  public void rebuildSubsetOfProjects() throws Exception {
+    setNotesMigrationState(WRITE);
+
+    Project.NameKey p2 = createProject("project2");
+    TestRepository<?> tr2 = cloneProject(p2, admin);
+
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = pushFactory.create(db, admin.getIdent(), tr2).to("refs/for/master");
+    Change.Id id1 = r1.getChange().getId();
+    Change.Id id2 = r2.getChange().getId();
+
+    String invalidState = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    try (ReviewDb db = schemaFactory.open()) {
+      Change c1 = db.changes().get(id1);
+      c1.setNoteDbState(invalidState);
+      Change c2 = db.changes().get(id2);
+      c2.setNoteDbState(invalidState);
+      db.changes().update(ImmutableList.of(c1, c2));
+    }
+
+    migrate(b -> b.setProjects(ImmutableList.of(p2)), NoteDbMigrator::rebuild);
+
+    try (ReviewDb db = schemaFactory.open()) {
+      NoteDbChangeState s1 = NoteDbChangeState.parse(db.changes().get(id1));
+      assertThat(s1.getChangeMetaId().name()).isEqualTo(invalidState);
+
+      NoteDbChangeState s2 = NoteDbChangeState.parse(db.changes().get(id2));
+      assertThat(s2.getChangeMetaId().name()).isNotEqualTo(invalidState);
+    }
+  }
+
+  @Test
+  public void enableSequencesNoGap() throws Exception {
+    testEnableSequences(0, 2, "12");
+  }
+
+  @Test
+  public void enableSequencesWithGap() throws Exception {
+    testEnableSequences(-1, 502, "512");
+  }
+
+  private void testEnableSequences(int builderOption, int expectedFirstId, String expectedRefValue)
+      throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    assertThat(id.get()).isEqualTo(1);
+
+    migrate(
+        b ->
+            b.setSequenceGap(builderOption)
+                .setStopAtStateForTesting(READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY));
+
+    assertThat(sequences.nextChangeId()).isEqualTo(expectedFirstId);
+    assertThat(sequences.nextChangeId()).isEqualTo(expectedFirstId + 1);
+
+    try (Repository repo = repoManager.openRepository(allProjects);
+        ObjectReader reader = repo.newObjectReader()) {
+      Ref ref = repo.exactRef("refs/sequences/changes");
+      assertThat(ref).isNotNull();
+      ObjectLoader loader = reader.open(ref.getObjectId());
+      assertThat(loader.getType()).isEqualTo(Constants.OBJ_BLOB);
+      // Acquired a block of 10 to serve the first nextChangeId call after migration.
+      assertThat(new String(loader.getCachedBytes(), UTF_8)).isEqualTo(expectedRefValue);
+    }
+
+    try (ReviewDb db = schemaFactory.open()) {
+      // Underlying, unused ReviewDb is still on its own sequence.
+      @SuppressWarnings("deprecation")
+      int nextFromReviewDb = db.nextChangeId();
+      assertThat(nextFromReviewDb).isEqualTo(3);
+    }
+  }
+
+  @Test
+  public void fullMigrationSameThread() throws Exception {
+    testFullMigration(1);
+  }
+
+  @Test
+  public void fullMigrationMultipleThreads() throws Exception {
+    testFullMigration(2);
+  }
+
+  private void testFullMigration(int threads) throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    Change.Id id1 = r1.getChange().getId();
+    Change.Id id2 = r2.getChange().getId();
+
+    Set<String> objectFiles = getObjectFiles(project);
+    assertThat(objectFiles).isNotEmpty();
+
+    migrate(b -> b.setThreads(threads));
+
+    assertNotesMigrationState(NOTE_DB, false, false);
+    assertThat(sequences.nextChangeId()).isEqualTo(503);
+    assertThat(getObjectFiles(project)).containsExactlyElementsIn(objectFiles);
+
+    ObjectId oldMetaId = null;
+    int rowVersion = 0;
+    try (ReviewDb db = schemaFactory.open();
+        Repository repo = repoManager.openRepository(project)) {
+      for (Change.Id id : ImmutableList.of(id1, id2)) {
+        String refName = RefNames.changeMetaRef(id);
+        Ref ref = repo.exactRef(refName);
+        assertThat(ref).named(refName).isNotNull();
+
+        Change c = db.changes().get(id);
+        assertThat(c.getTopic()).named("topic of change %s", id).isNull();
+        NoteDbChangeState s = NoteDbChangeState.parse(c);
+        assertThat(s.getPrimaryStorage())
+            .named("primary storage of change %s", id)
+            .isEqualTo(PrimaryStorage.NOTE_DB);
+        assertThat(s.getRefState()).named("ref state of change %s").isEmpty();
+
+        if (id.equals(id1)) {
+          oldMetaId = ref.getObjectId();
+          rowVersion = c.getRowVersion();
+        }
+      }
+    }
+
+    // Do not open a new context, to simulate races with other threads that opened a context earlier
+    // in the migration process; this needs to work.
+    gApi.changes().id(id1.get()).topic(name("a-topic"));
+
+    // Of course, it should also work with a new context.
+    resetCurrentApiUser();
+    gApi.changes().id(id1.get()).topic(name("another-topic"));
+
+    try (ReviewDb db = schemaFactory.open();
+        Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef(RefNames.changeMetaRef(id1)).getObjectId()).isNotEqualTo(oldMetaId);
+
+      Change c = db.changes().get(id1);
+      assertThat(c.getTopic()).isNull();
+      assertThat(c.getRowVersion()).isEqualTo(rowVersion);
+    }
+  }
+
+  @Test
+  public void autoMigrationConfig() throws Exception {
+    createChange();
+
+    migrate(b -> b.setStopAtStateForTesting(WRITE));
+    assertNotesMigrationState(WRITE, false, false);
+
+    migrate(b -> b.setAutoMigrate(true).setStopAtStateForTesting(READ_WRITE_NO_SEQUENCE));
+    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, true, false);
+
+    migrate(b -> b);
+    assertNotesMigrationState(NOTE_DB, false, false);
+  }
+
+  @Test
+  public void notesMigrationStateListener() throws Exception {
+    NotesMigrationStateListener listener = createStrictMock(NotesMigrationStateListener.class);
+    listener.preStateChange(REVIEW_DB, WRITE);
+    expectLastCall();
+    listener.preStateChange(WRITE, READ_WRITE_NO_SEQUENCE);
+    expectLastCall();
+    listener.preStateChange(READ_WRITE_NO_SEQUENCE, READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY);
+    expectLastCall();
+    listener.preStateChange(
+        READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY, READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY);
+    listener.preStateChange(READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY, NOTE_DB);
+    expectLastCall();
+    replay(listener);
+    addListener(listener);
+
+    createChange();
+    migrate(b -> b);
+    assertNotesMigrationState(NOTE_DB, false, false);
+    verify(listener);
+  }
+
+  @Test
+  public void notesMigrationStateListenerFails() throws Exception {
+    NotesMigrationStateListener listener = createStrictMock(NotesMigrationStateListener.class);
+    listener.preStateChange(REVIEW_DB, WRITE);
+    expectLastCall();
+    listener.preStateChange(WRITE, READ_WRITE_NO_SEQUENCE);
+    IOException listenerException = new IOException("Listener failed");
+    expectLastCall().andThrow(listenerException);
+    replay(listener);
+    addListener(listener);
+
+    createChange();
+    try {
+      migrate(b -> b);
+      fail("expected IOException");
+    } catch (IOException e) {
+      assertThat(e).isSameAs(listenerException);
+    }
+    assertNotesMigrationState(WRITE, false, false);
+    verify(listener);
+  }
+
+  private void assertNotesMigrationState(
+      NotesMigrationState expected, boolean autoMigrate, boolean trialMode) throws Exception {
+    assertThat(NotesMigrationState.forNotesMigration(notesMigration)).hasValue(expected);
+    noteDbConfig.load();
+    assertThat(NotesMigrationState.forConfig(noteDbConfig)).hasValue(expected);
+    assertThat(NoteDbMigrator.getAutoMigrate(noteDbConfig))
+        .named("noteDb.changes.autoMigrate")
+        .isEqualTo(autoMigrate);
+    assertThat(NoteDbMigrator.getTrialMode(noteDbConfig))
+        .named("noteDb.changes.trial")
+        .isEqualTo(trialMode);
+  }
+
+  private void setNotesMigrationState(NotesMigrationState state) throws Exception {
+    noteDbConfig.load();
+    state.setConfigValues(noteDbConfig);
+    noteDbConfig.save();
+    notesMigration.setFrom(state);
+  }
+
+  @FunctionalInterface
+  interface PrepareBuilder {
+    NoteDbMigrator.Builder prepare(NoteDbMigrator.Builder b) throws Exception;
+  }
+
+  @FunctionalInterface
+  interface RunMigration {
+    void run(NoteDbMigrator m) throws Exception;
+  }
+
+  private void migrate(PrepareBuilder b) throws Exception {
+    migrate(b, NoteDbMigrator::migrate);
+  }
+
+  private void migrate(PrepareBuilder b, RunMigration m) throws Exception {
+    try (NoteDbMigrator migrator = b.prepare(migratorBuilderProvider.get()).build()) {
+      m.run(migrator);
+    }
+  }
+
+  private void assertMigrationException(
+      String expectMessageContains, PrepareBuilder b, RunMigration m) throws Exception {
+    try {
+      migrate(b, m);
+      fail("expected MigrationException");
+    } catch (MigrationException e) {
+      assertThat(e).hasMessageThat().contains(expectMessageContains);
+    }
+  }
+
+  private void addListener(NotesMigrationStateListener listener) {
+    addedListeners.add(listeners.add(listener));
+  }
+
+  private ImmutableSortedSet<String> getObjectFiles(Project.NameKey project) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        Stream<Path> paths =
+            Files.walk(((FileRepository) repo).getObjectDatabase().getDirectory().toPath())) {
+      return paths
+          .filter(path -> !Files.isDirectory(path))
+          .map(Path::toString)
+          .filter(name -> !name.endsWith(".pack") && !name.endsWith(".idx"))
+          .collect(toImmutableSortedSet(naturalOrder()));
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
new file mode 100644
index 0000000..834dbfa
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.reviewdb.client.Change;
+import java.io.File;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+@UseLocalDisk
+public class ReflogIT extends AbstractDaemonTest {
+  @Before
+  public void setUp() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+  }
+
+  @Test
+  public void guessRestApiInReflog() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    try (Repository repo = repoManager.openRepository(r.getChange().project())) {
+      File log = new File(repo.getDirectory(), "logs/" + changeMetaRef(id));
+      if (!log.exists()) {
+        log.getParentFile().mkdirs();
+        assertThat(log.createNewFile()).isTrue();
+      }
+
+      gApi.changes().id(id.get()).topic("foo");
+      ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
+      assertThat(last).named("last RefLogEntry").isNotNull();
+      assertThat(last.getComment()).isEqualTo("restapi.change.PutTopic");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/BUILD b/javatests/com/google/gerrit/acceptance/server/project/BUILD
new file mode 100644
index 0000000..efa1cdb
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_project",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
new file mode 100644
index 0000000..e7d7fc7
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -0,0 +1,335 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import 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.MAX_WITH_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.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.project.testing.Util.category;
+import static com.google.gerrit.server.project.testing.Util.value;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.testing.Util;
+import com.google.inject.Inject;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class CustomLabelIT extends AbstractDaemonTest {
+
+  @Inject private DynamicSet<CommentAddedListener> source;
+
+  private final LabelType label =
+      category("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+
+  private final LabelType P = category("CustomLabel2", value(1, "Positive"), value(0, "No score"));
+
+  private RegistrationHandle eventListenerRegistration;
+  private CommentAddedListener.Event lastCommentAddedEvent;
+
+  @Before
+  public void setUp() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    Util.allow(cfg, Permission.forLabel(label.getName()), -1, 1, anonymousUsers, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel(P.getName()), 0, 1, anonymousUsers, "refs/heads/*");
+    saveProjectConfig(project, cfg);
+
+    eventListenerRegistration =
+        source.add(
+            new CommentAddedListener() {
+              @Override
+              public void onCommentAdded(Event event) {
+                lastCommentAddedEvent = event;
+              }
+            });
+  }
+
+  @After
+  public void cleanup() {
+    eventListenerRegistration.remove();
+    db.close();
+  }
+
+  @Test
+  public void customLabelNoOp_NegativeVoteNotBlock() throws Exception {
+    label.setFunction(NO_OP);
+    saveLabelConfig();
+    PushOneCommit.Result r = createChange();
+    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    ChangeInfo c = getWithLabels(r);
+    LabelInfo q = c.labels.get(label.getName());
+    assertThat(q.all).hasSize(1);
+    assertThat(q.approved).isNull();
+    assertThat(q.recommended).isNull();
+    assertThat(q.disliked).isNull();
+    assertThat(q.rejected).isNotNull();
+    assertThat(q.blocking).isNull();
+  }
+
+  @Test
+  public void customLabelNoBlock_NegativeVoteNotBlock() throws Exception {
+    label.setFunction(NO_BLOCK);
+    saveLabelConfig();
+    PushOneCommit.Result r = createChange();
+    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    ChangeInfo c = getWithLabels(r);
+    LabelInfo q = c.labels.get(label.getName());
+    assertThat(q.all).hasSize(1);
+    assertThat(q.approved).isNull();
+    assertThat(q.recommended).isNull();
+    assertThat(q.disliked).isNull();
+    assertThat(q.rejected).isNotNull();
+    assertThat(q.blocking).isNull();
+  }
+
+  @Test
+  public void customLabelMaxNoBlock_NegativeVoteNotBlock() throws Exception {
+    label.setFunction(MAX_NO_BLOCK);
+    saveLabelConfig();
+    PushOneCommit.Result r = createChange();
+    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    ChangeInfo c = getWithLabels(r);
+    LabelInfo q = c.labels.get(label.getName());
+    assertThat(q.all).hasSize(1);
+    assertThat(q.approved).isNull();
+    assertThat(q.recommended).isNull();
+    assertThat(q.disliked).isNull();
+    assertThat(q.rejected).isNotNull();
+    assertThat(q.blocking).isNull();
+  }
+
+  @Test
+  public void customLabelMaxNoBlock_MaxVoteSubmittable() throws Exception {
+    label.setFunction(MAX_NO_BLOCK);
+    P.setFunction(NO_OP);
+    saveLabelConfig();
+    PushOneCommit.Result r = createChange();
+    assertThat(info(r.getChangeId()).submittable).isNull();
+    revision(r).review(ReviewInput.approve().label(label.getName(), 1));
+
+    ChangeInfo c = getWithLabels(r);
+    assertThat(c.submittable).isTrue();
+    LabelInfo q = c.labels.get(label.getName());
+    assertThat(q.all).hasSize(1);
+    assertThat(q.approved).isNotNull();
+    assertThat(q.recommended).isNull();
+    assertThat(q.disliked).isNull();
+    assertThat(q.rejected).isNull();
+    assertThat(q.blocking).isNull();
+  }
+
+  @Test
+  public void customLabelAnyWithBlock_NegativeVoteBlock() throws Exception {
+    label.setFunction(ANY_WITH_BLOCK);
+    saveLabelConfig();
+    PushOneCommit.Result r = createChange();
+    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    ChangeInfo c = getWithLabels(r);
+    LabelInfo q = c.labels.get(label.getName());
+    assertThat(q.all).hasSize(1);
+    assertThat(q.approved).isNull();
+    assertThat(q.recommended).isNull();
+    assertThat(q.disliked).isNull();
+    assertThat(q.rejected).isNotNull();
+    assertThat(q.blocking).isTrue();
+  }
+
+  @Test
+  public void customLabelAnyWithBlock_Addreviewer_ZeroVote() throws Exception {
+    P.setFunction(ANY_WITH_BLOCK);
+    saveLabelConfig();
+    PushOneCommit.Result r = createChange();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    ReviewInput input = new ReviewInput().label(P.getName(), 0);
+    input.message = "foo";
+
+    revision(r).review(input);
+    ChangeInfo c = getWithLabels(r);
+    LabelInfo q = c.labels.get(P.getName());
+    assertThat(q.all).hasSize(2);
+    assertThat(q.approved).isNull();
+    assertThat(q.recommended).isNull();
+    assertThat(q.disliked).isNull();
+    assertThat(q.rejected).isNull();
+    assertThat(q.blocking).isNull();
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo("Patch Set 1:\n\n" + input.message);
+  }
+
+  @Test
+  public void customLabelMaxWithBlock_NegativeVoteBlock() throws Exception {
+    label.setFunction(MAX_WITH_BLOCK);
+    saveLabelConfig();
+    PushOneCommit.Result r = createChange();
+    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    ChangeInfo c = getWithLabels(r);
+    LabelInfo q = c.labels.get(label.getName());
+    assertThat(q.all).hasSize(1);
+    assertThat(q.approved).isNull();
+    assertThat(q.recommended).isNull();
+    assertThat(q.disliked).isNull();
+    assertThat(q.rejected).isNotNull();
+    assertThat(q.blocking).isTrue();
+  }
+
+  @Test
+  public void customLabelMaxWithBlock_MaxVoteSubmittable() throws Exception {
+    label.setFunction(MAX_WITH_BLOCK);
+    P.setFunction(NO_OP);
+    saveLabelConfig();
+    PushOneCommit.Result r = createChange();
+    assertThat(info(r.getChangeId()).submittable).isNull();
+    revision(r).review(ReviewInput.approve().label(label.getName(), 1));
+
+    ChangeInfo c = getWithLabels(r);
+    assertThat(c.submittable).isTrue();
+    LabelInfo q = c.labels.get(label.getName());
+    assertThat(q.all).hasSize(1);
+    assertThat(q.approved).isNotNull();
+    assertThat(q.recommended).isNull();
+    assertThat(q.disliked).isNull();
+    assertThat(q.rejected).isNull();
+    assertThat(q.blocking).isNull();
+  }
+
+  @Test
+  public void customLabel_DisallowPostSubmit() throws Exception {
+    label.setFunction(NO_OP);
+    label.setAllowPostSubmit(false);
+    P.setFunction(NO_OP);
+    saveLabelConfig();
+
+    PushOneCommit.Result r = createChange();
+    revision(r).review(ReviewInput.approve());
+    revision(r).submit();
+
+    ChangeInfo info = getWithLabels(r);
+    assertPermitted(info, "Code-Review", 2);
+    assertPermitted(info, P.getName(), 0, 1);
+    assertPermitted(info, label.getName());
+
+    ReviewInput in = new ReviewInput();
+    in.label(P.getName(), P.getMax().getValue());
+    revision(r).review(in);
+
+    in = new ReviewInput();
+    in.label(label.getName(), label.getMax().getValue());
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Voting on labels disallowed after submit: " + label.getName());
+    revision(r).review(in);
+  }
+
+  @Test
+  public void customLabelWithUserPermissionChange() throws Exception {
+    String testLabel = "Test-Label";
+    configLabel(
+        project,
+        testLabel,
+        LabelFunction.MAX_WITH_BLOCK,
+        value(2, "Looks good to me, approved"),
+        value(1, "Looks good to me, but someone else must approve"),
+        value(0, "No score"),
+        value(-1, "I would prefer this is not merged as is"),
+        value(-2, "This shall not be merged"));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
+    Util.allow(cfg, Permission.forLabel(testLabel), -2, +2, registered, "refs/heads/*");
+    saveProjectConfig(cfg);
+
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+
+    // admin votes 'Test-Label +2' and 'Code-Review +2'.
+    ReviewInput input = new ReviewInput();
+    input.label(testLabel, 2);
+    input.label("Code-Review", 2);
+    revision(result).review(input);
+
+    // Verify the value of 'Test-Label' is +2.
+    assertLabelStatus(changeId, testLabel);
+
+    // The change is submittable.
+    assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
+
+    // Update admin's permitted range for 'Test-Label' to be -1...+1.
+    Util.remove(cfg, Permission.forLabel(testLabel), registered, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel(testLabel), -1, +1, registered, "refs/heads/*");
+    saveProjectConfig(cfg);
+
+    // Verify admin doesn't have +2 permission any more.
+    assertPermitted(gApi.changes().id(changeId).get(), testLabel, -1, 0, 1);
+
+    // Verify the value of 'Test-Label' is still +2.
+    assertLabelStatus(changeId, testLabel);
+
+    // Verify the change is still submittable.
+    assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
+    gApi.changes().id(changeId).current().submit();
+  }
+
+  private void assertLabelStatus(String changeId, String testLabel) throws Exception {
+    ChangeInfo changeInfo = getWithLabels(changeId);
+    LabelInfo labelInfo = changeInfo.labels.get(testLabel);
+    assertThat(labelInfo.all).hasSize(1);
+    assertThat(labelInfo.approved).isNotNull();
+    assertThat(labelInfo.recommended).isNull();
+    assertThat(labelInfo.disliked).isNull();
+    assertThat(labelInfo.rejected).isNull();
+    assertThat(labelInfo.blocking).isNull();
+  }
+
+  private void saveLabelConfig() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().put(label.getName(), label);
+    cfg.getLabelSections().put(P.getName(), P);
+    saveProjectConfig(project, cfg);
+  }
+
+  private ChangeInfo getWithLabels(PushOneCommit.Result r) throws Exception {
+    return getWithLabels(r.getChangeId());
+  }
+
+  private ChangeInfo getWithLabels(String changeId) throws Exception {
+    return get(changeId, LABELS, DETAILED_LABELS, SUBMITTABLE);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
new file mode 100644
index 0000000..1236826
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -0,0 +1,552 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.StarredChangesUtil.IGNORE_LABEL;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.StarsInput;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.git.NotifyConfig;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import java.util.EnumSet;
+import java.util.List;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+@NoHttpd
+public class ProjectWatchIT extends AbstractDaemonTest {
+  @Test
+  public void newPatchSetsNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("new-patch-set");
+    nc.setHeader(NotifyConfig.Header.CC);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+    nc.setFilter("message:sekret");
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("watch", nc);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "original subject", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    r =
+        pushFactory
+            .create(
+                db, admin.getIdent(), testRepo, "super sekret subject", "a", "a2", r.getChangeId())
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "back to original subject", "a", "a3")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(addr);
+    assertThat(m.body()).contains("Change subject: super sekret subject\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 2\n");
+  }
+
+  @Test
+  public void noNotificationForPrivateChangesForWatchersInNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    sender.clear();
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "private change", "a", "a1")
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+
+    setApiUser(admin);
+    ReviewInput in = new ReviewInput();
+    in.message = "comment";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForChangeThatIsTurnedPrivateForWatchersInNotifyConfig()
+      throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    sender.clear();
+
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForWipChangesForWatchersInNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    sender.clear();
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "wip change", "a", "a1")
+            .to("refs/for/master%wip");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+
+    setApiUser(admin);
+    ReviewInput in = new ReviewInput();
+    in.message = "comment";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForChangeThatIsTurnedWipForWatchersInNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    sender.clear();
+
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
+            .to("refs/for/master%wip");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchProject() throws Exception {
+    // watch project
+    String watchedProject = createProject("watchedProject").get();
+    setApiUser(user);
+    watch(watchedProject);
+
+    // push a change to watched project -> should trigger email notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // push a change to non-watched project -> should not trigger email
+    // notification
+    String notWatchedProject = createProject("otherProject").get();
+    TestRepository<InMemoryRepository> notWatchedRepo =
+        cloneProject(new Project.NameKey(notWatchedProject), admin);
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), notWatchedRepo, "DONT_TRIGGER", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
+
+  @Test
+  public void watchFile() throws Exception {
+    String watchedProject = createProject("watchedProject").get();
+    String otherWatchedProject = createProject("otherWatchedProject").get();
+    setApiUser(user);
+
+    // watch file in project as user
+    watch(watchedProject, "file:a.txt");
+
+    // watch other project as user
+    watch(otherWatchedProject);
+
+    // push a change to watched file -> should trigger email notification for
+    // user
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // watch project as user2
+    TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2");
+    setApiUser(user2);
+    watch(watchedProject);
+
+    // push a change to non-watched file -> should not trigger email
+    // notification for user, only for user2
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "TRIGGER_USER2", "b.txt", "b1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user2.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER_USER2\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
+
+  @Test
+  public void watchKeyword() throws Exception {
+    String watchedProject = createProject("watchedProject").get();
+    setApiUser(user);
+
+    // watch keyword in project as user
+    watch(watchedProject, "multimaster");
+
+    // push a change with keyword -> should trigger email notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "Document multimaster setup", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Change subject: Document multimaster setup\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // push a change without keyword -> should not trigger email notification
+    r =
+        pushFactory
+            .create(
+                db, admin.getIdent(), watchedRepo, "Cleanup cache implementation", "b.txt", "b1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchAllProjects() throws Exception {
+    String anyProject = createProject("anyProject").get();
+    setApiUser(user);
+
+    // watch the All-Projects project to watch all projects
+    watch(allProjects.get());
+
+    // push a change to any project -> should trigger email notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> anyRepo =
+        cloneProject(new Project.NameKey(anyProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), anyRepo, "TRIGGER", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
+
+  @Test
+  public void watchFileAllProjects() throws Exception {
+    String anyProject = createProject("anyProject").get();
+    setApiUser(user);
+
+    // watch file in All-Projects project as user to watch the file in all
+    // projects
+    watch(allProjects.get(), "file:a.txt");
+
+    // push a change to watched file in any project -> should trigger email
+    // notification for user
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> anyRepo =
+        cloneProject(new Project.NameKey(anyProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), anyRepo, "TRIGGER", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // watch project as user2
+    TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2");
+    setApiUser(user2);
+    watch(anyProject);
+
+    // push a change to non-watched file in any project -> should not trigger
+    // email notification for user, only for user2
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), anyRepo, "TRIGGER_USER2", "b.txt", "b1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user2.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER_USER2\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
+
+  @Test
+  public void watchKeywordAllProjects() throws Exception {
+    String anyProject = createProject("anyProject").get();
+    setApiUser(user);
+
+    // watch keyword in project as user
+    watch(allProjects.get(), "multimaster");
+
+    // push a change with keyword to any project -> should trigger email
+    // notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> anyRepo =
+        cloneProject(new Project.NameKey(anyProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), anyRepo, "Document multimaster setup", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Change subject: Document multimaster setup\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // push a change without keyword to any project -> should not trigger email
+    // notification
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), anyRepo, "Cleanup cache implementation", "b.txt", "b1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchProjectNoNotificationForIgnoredChange() throws Exception {
+    // watch project
+    String watchedProject = createProject("watchedProject").get();
+    setApiUser(user);
+    watch(watchedProject);
+
+    // push a change to watched project
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "ignored change", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // ignore the change
+    setApiUser(user);
+    gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
+
+    sender.clear();
+
+    // post a comment -> should not trigger email notification since user ignored the change
+    setApiUser(admin);
+    ReviewInput in = new ReviewInput();
+    in.message = "comment";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    // assert email notification
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchProjectNoNotificationForPrivateChange() throws Exception {
+    // watch project
+    String watchedProject = createProject("watchedProject").get();
+    setApiUser(user);
+    watch(watchedProject);
+
+    // push a private change to watched project -> should not trigger email notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "private change", "a", "a1")
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    // assert email notification
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchProjectNotifyOnPrivateChange() throws Exception {
+    String watchedProject = createProject("watchedProject").get();
+
+    // create group that can view all private changes
+    GroupInfo groupThatCanViewPrivateChanges =
+        gApi.groups().create("groupThatCanViewPrivateChanges").get();
+    grant(
+        new Project.NameKey(watchedProject),
+        "refs/*",
+        Permission.VIEW_PRIVATE_CHANGES,
+        false,
+        new AccountGroup.UUID(groupThatCanViewPrivateChanges.id));
+
+    // watch project as user that can't view private changes
+    setApiUser(user);
+    watch(watchedProject);
+
+    // watch project as user that can view all private change
+    TestAccount userThatCanViewPrivateChanges =
+        accountCreator.create(
+            "user2", "user2@test.com", "User2", groupThatCanViewPrivateChanges.name);
+    setApiUser(userThatCanViewPrivateChanges);
+    watch(watchedProject);
+
+    // push a private change to watched project -> should trigger email notification for
+    // userThatCanViewPrivateChanges, but not for user
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a", "a1")
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    // assert email notification
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(userThatCanViewPrivateChanges.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java b/javatests/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
new file mode 100644
index 0000000..f0b937c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class AbandonRestoreIT extends AbstractDaemonTest {
+
+  @Test
+  public void withMessage() throws Exception {
+    Result result = createChange();
+    String commit = result.getCommit().name();
+    executeCmd(commit, "abandon", "'abandon it'");
+    executeCmd(commit, "restore", "'restore it'");
+    assertChangeMessages(
+        result.getChangeId(),
+        ImmutableList.of(
+            "Uploaded patch set 1.", "Abandoned\n\nabandon it", "Restored\n\nrestore it"));
+  }
+
+  @Test
+  public void withoutMessage() throws Exception {
+    Result result = createChange();
+    String commit = result.getCommit().name();
+    executeCmd(commit, "abandon", null);
+    executeCmd(commit, "restore", null);
+    assertChangeMessages(
+        result.getChangeId(), ImmutableList.of("Uploaded patch set 1.", "Abandoned", "Restored"));
+  }
+
+  private void executeCmd(String commit, String op, String message) throws Exception {
+    StringBuilder command =
+        new StringBuilder("gerrit review ").append(commit).append(" --").append(op);
+    if (message != null) {
+      command.append(" --message ").append(message);
+    }
+    String response = adminSshSession.exec(command.toString());
+    assertWithMessage(adminSshSession.getError()).that(adminSshSession.hasError()).isFalse();
+    assertThat(response.toLowerCase(Locale.US)).doesNotContain("error");
+  }
+
+  private void assertChangeMessages(String changeId, List<String> expected) throws Exception {
+    ChangeInfo c = get(changeId, MESSAGES);
+    Iterable<ChangeMessageInfo> messages = c.messages;
+    assertThat(messages).isNotNull();
+    assertThat(messages).hasSize(expected.size());
+    List<String> actual = new ArrayList<>();
+    for (ChangeMessageInfo info : messages) {
+      actual.add(info.message);
+    }
+    assertThat(actual).containsExactlyElementsIn(expected);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/BUILD b/javatests/com/google/gerrit/acceptance/ssh/BUILD
new file mode 100644
index 0000000..ec386a5
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/BUILD
@@ -0,0 +1,8 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "ssh",
+    labels = ["ssh"],
+    deps = ["//lib/commons:compress"],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BanCommitIT.java b/javatests/com/google/gerrit/acceptance/ssh/BanCommitIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BanCommitIT.java
rename to javatests/com/google/gerrit/acceptance/ssh/BanCommitIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
rename to javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java b/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
rename to javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/QueryIT.java b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/QueryIT.java
rename to javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/SetReviewersIT.java b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
rename to javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
rename to javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
diff --git a/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java b/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
new file mode 100644
index 0000000..a64818d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
@@ -0,0 +1,142 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+import com.google.common.base.Splitter;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.testing.NoteDbMode;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.util.Set;
+import java.util.TreeSet;
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream;
+import org.eclipse.jgit.transport.PacketLineIn;
+import org.eclipse.jgit.transport.PacketLineOut;
+import org.eclipse.jgit.util.IO;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class UploadArchiveIT extends AbstractDaemonTest {
+
+  @Before
+  public void setUp() {
+    // There is some Guice request scoping problem preventing this test from
+    // passing in CHECK mode.
+    assume().that(NoteDbMode.get()).isNotEqualTo(NoteDbMode.CHECK);
+  }
+
+  @Test
+  @GerritConfig(name = "download.archive", value = "off")
+  public void archiveFeatureOff() throws Exception {
+    archiveNotPermitted();
+  }
+
+  @Test
+  @GerritConfig(
+    name = "download.archive",
+    values = {"tar", "tbz2", "tgz", "txz"}
+  )
+  public void zipFormatDisabled() throws Exception {
+    archiveNotPermitted();
+  }
+
+  @Test
+  public void zipFormat() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String abbreviated = r.getCommit().abbreviate(8).name();
+    String c = command(r, abbreviated);
+
+    InputStream out =
+        adminSshSession.exec2("git-upload-archive " + project.get(), argumentsToInputStream(c));
+
+    // Wrap with PacketLineIn to read ACK bytes from output stream
+    PacketLineIn in = new PacketLineIn(out);
+    String tmp = in.readString();
+    assertThat(tmp).isEqualTo("ACK");
+    tmp = in.readString();
+
+    // Skip length (4 bytes) + 1 byte
+    // to position the output stream to the raw zip stream
+    byte[] buffer = new byte[5];
+    IO.readFully(out, buffer, 0, 5);
+    Set<String> entryNames = new TreeSet<>();
+    try (ZipArchiveInputStream zip = new ZipArchiveInputStream(out)) {
+      ZipArchiveEntry zipEntry = zip.getNextZipEntry();
+      while (zipEntry != null) {
+        String name = zipEntry.getName();
+        entryNames.add(name);
+        zipEntry = zip.getNextZipEntry();
+      }
+    }
+
+    assertThat(entryNames)
+        .containsExactly(
+            String.format("%s/", abbreviated),
+            String.format("%s/%s", abbreviated, PushOneCommit.FILE_NAME))
+        .inOrder();
+  }
+
+  private String command(PushOneCommit.Result r, String abbreviated) {
+    String c =
+        "-f=zip "
+            + "-9 "
+            + "--prefix="
+            + abbreviated
+            + "/ "
+            + r.getCommit().name()
+            + " "
+            + PushOneCommit.FILE_NAME;
+    return c;
+  }
+
+  private void archiveNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String abbreviated = r.getCommit().abbreviate(8).name();
+    String c = command(r, abbreviated);
+
+    InputStream out =
+        adminSshSession.exec2("git-upload-archive " + project.get(), argumentsToInputStream(c));
+
+    // Wrap with PacketLineIn to read ACK bytes from output stream
+    PacketLineIn in = new PacketLineIn(out);
+    String tmp = in.readString();
+    assertThat(tmp).isEqualTo("ACK");
+    tmp = in.readString();
+    tmp = in.readString();
+    tmp = tmp.substring(1);
+    assertThat(tmp).isEqualTo("fatal: upload-archive not permitted");
+  }
+
+  private InputStream argumentsToInputStream(String c) throws Exception {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    PacketLineOut pctOut = new PacketLineOut(out);
+    for (String arg : Splitter.on(' ').split(c)) {
+      pctOut.writeString("argument " + arg);
+    }
+    pctOut.end();
+    return new ByteArrayInputStream(out.toByteArray());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/tests.bzl b/javatests/com/google/gerrit/acceptance/tests.bzl
new file mode 100644
index 0000000..4b3b802d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/tests.bzl
@@ -0,0 +1,21 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+def acceptance_tests(
+    group,
+    deps = [],
+    labels = [],
+    vm_args = ['-Xmx256m'],
+    **kwargs):
+  junit_tests(
+    name = group,
+    deps = deps + [
+      '//java/com/google/gerrit/acceptance:lib',
+    ],
+    tags = labels + [
+      'acceptance',
+      'slow',
+    ],
+    size = "large",
+    jvm_flags = vm_args,
+    **kwargs
+  )
diff --git a/gerrit-common/src/test/java/com/google/gerrit/common/AutoValueTest.java b/javatests/com/google/gerrit/common/AutoValueTest.java
similarity index 100%
rename from gerrit-common/src/test/java/com/google/gerrit/common/AutoValueTest.java
rename to javatests/com/google/gerrit/common/AutoValueTest.java
diff --git a/javatests/com/google/gerrit/common/BUILD b/javatests/com/google/gerrit/common/BUILD
new file mode 100644
index 0000000..50889ba
--- /dev/null
+++ b/javatests/com/google/gerrit/common/BUILD
@@ -0,0 +1,33 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+SERVER_TEST_SRCS = [
+    "AutoValueTest.java",
+    "VersionTest.java",
+]
+
+junit_tests(
+    name = "client_tests",
+    srcs = glob(
+        ["**/*.java"],
+        exclude = SERVER_TEST_SRCS,
+    ),
+    deps = [
+        "//java/com/google/gerrit/common:client",
+        "//lib:guava",
+        "//lib:junit",
+        "//lib:truth",
+    ],
+)
+
+junit_tests(
+    name = "server_tests",
+    srcs = SERVER_TEST_SRCS,
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/common:version",
+        "//java/com/google/gerrit/launcher",
+        "//lib:guava",
+        "//lib:truth",
+        "//lib/auto:auto-value",
+    ],
+)
diff --git a/javatests/com/google/gerrit/common/VersionTest.java b/javatests/com/google/gerrit/common/VersionTest.java
new file mode 100644
index 0000000..bceb203
--- /dev/null
+++ b/javatests/com/google/gerrit/common/VersionTest.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.launcher.GerritLauncher;
+import java.util.regex.Pattern;
+import org.junit.Test;
+
+public final class VersionTest {
+  private static final Pattern DEV_PATTERN =
+      Pattern.compile("^" + Pattern.quote(Version.DEV) + "$");
+
+  private static final Pattern GIT_DESCRIBE_PATTERN =
+      Pattern.compile(
+          "^[1-9]+\\.[0-9]+(\\.[0-9]+)*(-rc[0-9]+)?(-[0-9]+" + "-g[0-9a-f]{7,})?(-dirty)?$");
+
+  @Test
+  public void version() {
+    Pattern expected =
+        GerritLauncher.isRunningInEclipse()
+            ? DEV_PATTERN // Different source line so it shows up in coverage.
+            : GIT_DESCRIBE_PATTERN;
+    assertThat(Version.getVersion()).matches(expected);
+    // Try again in case of caching issues.
+    assertThat(Version.getVersion()).matches(expected);
+  }
+
+  @Test
+  public void gitDescribePattern() {
+    for (String suffix : ImmutableList.of("", "-dirty")) {
+      assertThat("2.15-rc0" + suffix).matches(GIT_DESCRIBE_PATTERN);
+      assertThat("2.15-rc0" + suffix).matches(GIT_DESCRIBE_PATTERN);
+      assertThat("2.15-rc1" + suffix).matches(GIT_DESCRIBE_PATTERN);
+      assertThat("2.15" + suffix).matches(GIT_DESCRIBE_PATTERN);
+      assertThat("2.15.1" + suffix).matches(GIT_DESCRIBE_PATTERN);
+      assertThat("2.15.1.2" + suffix).matches(GIT_DESCRIBE_PATTERN);
+      assertThat("2.15.1.2.3" + suffix).matches(GIT_DESCRIBE_PATTERN);
+      assertThat("2.15.1-rc1" + suffix).matches(GIT_DESCRIBE_PATTERN);
+      assertThat("2.15-rc2-123-gabcd123" + suffix).matches(GIT_DESCRIBE_PATTERN);
+      assertThat("2.15-123-gabcd123" + suffix).matches(GIT_DESCRIBE_PATTERN);
+    }
+
+    assertThat("2.15-ugly").doesNotMatch(GIT_DESCRIBE_PATTERN);
+    assertThat("(dev)").doesNotMatch(GIT_DESCRIBE_PATTERN);
+    assertThat("1").doesNotMatch(GIT_DESCRIBE_PATTERN);
+    assertThat("v2.15").doesNotMatch(GIT_DESCRIBE_PATTERN);
+  }
+}
diff --git a/gerrit-common/src/test/java/com/google/gerrit/common/data/EncodePathSeparatorTest.java b/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
similarity index 100%
rename from gerrit-common/src/test/java/com/google/gerrit/common/data/EncodePathSeparatorTest.java
rename to javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
diff --git a/gerrit-common/src/test/java/com/google/gerrit/common/data/FilenameComparatorTest.java b/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
similarity index 100%
rename from gerrit-common/src/test/java/com/google/gerrit/common/data/FilenameComparatorTest.java
rename to javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
diff --git a/gerrit-common/src/test/java/com/google/gerrit/common/data/ParameterizedStringTest.java b/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
similarity index 100%
rename from gerrit-common/src/test/java/com/google/gerrit/common/data/ParameterizedStringTest.java
rename to javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
new file mode 100644
index 0000000..82cecfa
--- /dev/null
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -0,0 +1,49 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+java_library(
+    name = "elasticsearch_test_utils",
+    testonly = 1,
+    srcs = ["ElasticTestUtils.java"],
+    deps = [
+        "//java/com/google/gerrit/elasticsearch",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:junit",
+        "//lib:truth",
+        "//lib/elasticsearch",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+)
+
+ELASTICSEARCH_TESTS = {i: "ElasticQuery" + i.capitalize() + "sTest.java" for i in [
+    "account",
+    "change",
+    "group",
+    "project",
+]}
+
+[junit_tests(
+    name = "elasticsearch_%ss_test" % name,
+    size = "large",
+    srcs = [src],
+    tags = [
+        "elastic",
+    ],
+    deps = [
+        ":elasticsearch_test_utils",
+        "//java/com/google/gerrit/elasticsearch",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//javatests/com/google/gerrit/server/query:index-config",
+        "//javatests/com/google/gerrit/server/query/%s:abstract_query_tests" % name,
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+) for name, src in ELASTICSEARCH_TESTS.items()]
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
new file mode 100644
index 0000000..794956f
--- /dev/null
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.IndexConfig;
+import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.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 ElasticQueryAccountsTest extends AbstractQueryAccountsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForElasticsearch();
+  }
+
+  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/javatests/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
new file mode 100644
index 0000000..bc6c853
--- /dev/null
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.IndexConfig;
+import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class ElasticQueryChangesTest extends AbstractQueryChangesTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForElasticsearch();
+  }
+
+  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);
+  }
+
+  @After
+  public void cleanupIndex() {
+    if (nodeInfo != null) {
+      ElasticTestUtils.deleteAllIndexes(nodeInfo);
+      ElasticTestUtils.createAllIndexes(nodeInfo);
+    }
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (nodeInfo != null) {
+      nodeInfo.node.close();
+      nodeInfo.elasticDir.delete();
+      nodeInfo = null;
+    }
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+
+  @Test
+  public void byOwnerInvalidQuery() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    insert(repo, newChange(repo), userId);
+    String nameEmail = user.asIdentifiedUser().getNameEmail();
+    assertQuery("owner: \"" + nameEmail + "\"\\");
+  }
+}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
new file mode 100644
index 0000000..9659c9e
--- /dev/null
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.IndexConfig;
+import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.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 ElasticQueryGroupsTest extends AbstractQueryGroupsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForElasticsearch();
+  }
+
+  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/javatests/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java
new file mode 100644
index 0000000..66a6aab
--- /dev/null
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.IndexConfig;
+import com.google.gerrit.server.query.project.AbstractQueryProjectsTest;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.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 {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForElasticsearch();
+  }
+
+  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/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
new file mode 100644
index 0000000..d1d6611
--- /dev/null
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -0,0 +1,203 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.elasticsearch.ElasticAccountIndex.ACCOUNTS_PREFIX;
+import static com.google.gerrit.elasticsearch.ElasticChangeIndex.CHANGES_PREFIX;
+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;
+import com.google.gerrit.server.index.IndexModule.IndexType;
+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;
+import com.google.gson.GsonBuilder;
+import java.io.File;
+import java.nio.file.Path;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.action.admin.cluster.node.info.NodesInfoRequest;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.node.Node;
+import org.elasticsearch.node.NodeBuilder;
+
+final class ElasticTestUtils {
+  static final Gson gson =
+      new GsonBuilder()
+          .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+          .create();
+
+  static class ElasticNodeInfo {
+    final Node node;
+    final String port;
+    final File elasticDir;
+
+    private ElasticNodeInfo(Node node, File rootDir, String port) {
+      this.node = node;
+      this.port = port;
+      this.elasticDir = rootDir;
+    }
+  }
+
+  static void configure(Config config, String port) {
+    config.setEnum("index", null, "type", IndexType.ELASTICSEARCH);
+    config.setString("elasticsearch", "test", "protocol", "http");
+    config.setString("elasticsearch", "test", "hostname", "localhost");
+    config.setString("elasticsearch", "test", "port", port);
+  }
+
+  static ElasticNodeInfo startElasticsearchNode() throws InterruptedException, ExecutionException {
+    File elasticDir = Files.createTempDir();
+    Path elasticDirPath = elasticDir.toPath();
+    Settings settings =
+        Settings.settingsBuilder()
+            .put("cluster.name", "gerrit")
+            .put("node.name", "Gerrit Elasticsearch Test Node")
+            .put("node.local", true)
+            .put("discovery.zen.ping.multicast.enabled", false)
+            .put("index.store.fs.memory.enabled", true)
+            .put("index.gateway.type", "none")
+            .put("index.max_result_window", Integer.MAX_VALUE)
+            .put("gateway.type", "default")
+            .put("http.port", 0)
+            .put("discovery.zen.ping.unicast.hosts", "[\"localhost\"]")
+            .put("path.home", elasticDirPath.toAbsolutePath())
+            .put("path.data", elasticDirPath.resolve("data").toAbsolutePath())
+            .put("path.work", elasticDirPath.resolve("work").toAbsolutePath())
+            .put("path.logs", elasticDirPath.resolve("logs").toAbsolutePath())
+            .put("transport.tcp.connect_timeout", "60s")
+            .build();
+
+    // Start the node
+    Node node = NodeBuilder.nodeBuilder().settings(settings).node();
+
+    // Wait for it to be ready
+    node.client().admin().cluster().prepareHealth().setWaitForYellowStatus().execute().actionGet();
+
+    assertThat(node.isClosed()).isFalse();
+    return new ElasticNodeInfo(node, elasticDir, getHttpPort(node));
+  }
+
+  static void deleteAllIndexes(ElasticNodeInfo nodeInfo) {
+    nodeInfo.node.client().admin().indices().prepareDelete("_all").execute().actionGet();
+  }
+
+  static class NodeInfo {
+    String httpAddress;
+  }
+
+  static class Info {
+    Map<String, NodeInfo> nodes;
+  }
+
+  static void createAllIndexes(ElasticNodeInfo nodeInfo) {
+    Schema<ChangeData> changeSchema = ChangeSchemaDefinitions.INSTANCE.getLatest();
+    ChangeMapping openChangesMapping = new ChangeMapping(changeSchema);
+    ChangeMapping closedChangesMapping = new ChangeMapping(changeSchema);
+    openChangesMapping.closedChanges = null;
+    closedChangesMapping.openChanges = null;
+    nodeInfo
+        .node
+        .client()
+        .admin()
+        .indices()
+        .prepareCreate(String.format("%s%04d", CHANGES_PREFIX, changeSchema.getVersion()))
+        .addMapping(OPEN_CHANGES, gson.toJson(openChangesMapping))
+        .addMapping(CLOSED_CHANGES, gson.toJson(closedChangesMapping))
+        .execute()
+        .actionGet();
+
+    Schema<AccountState> accountSchema = AccountSchemaDefinitions.INSTANCE.getLatest();
+    AccountMapping accountMapping = new AccountMapping(accountSchema);
+    nodeInfo
+        .node
+        .client()
+        .admin()
+        .indices()
+        .prepareCreate(String.format("%s%04d", ACCOUNTS_PREFIX, accountSchema.getVersion()))
+        .addMapping(ElasticAccountIndex.ACCOUNTS, gson.toJson(accountMapping))
+        .execute()
+        .actionGet();
+
+    Schema<InternalGroup> groupSchema = GroupSchemaDefinitions.INSTANCE.getLatest();
+    GroupMapping groupMapping = new GroupMapping(groupSchema);
+    nodeInfo
+        .node
+        .client()
+        .admin()
+        .indices()
+        .prepareCreate(String.format("%s%04d", GROUPS_PREFIX, groupSchema.getVersion()))
+        .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 {
+    String nodes =
+        node.client().admin().cluster().nodesInfo(new NodesInfoRequest("*")).get().toString();
+    Gson gson =
+        new GsonBuilder()
+            .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+            .create();
+    Info info = gson.fromJson(nodes, Info.class);
+    if (info.nodes == null || info.nodes.size() != 1) {
+      throw new RuntimeException("Cannot extract local Elasticsearch http port");
+    }
+    Iterator<NodeInfo> values = info.nodes.values().iterator();
+    String httpAddress = values.next().httpAddress;
+    if (Strings.isNullOrEmpty(httpAddress)) {
+      throw new RuntimeException("Cannot extract local Elasticsearch http port");
+    }
+    if (httpAddress.indexOf(':') < 0) {
+      throw new RuntimeException("Seems that port is not included in Elasticsearch http_address");
+    }
+    return httpAddress.substring(httpAddress.indexOf(':') + 1, httpAddress.length());
+  }
+
+  private ElasticTestUtils() {
+    // hide default constructor
+  }
+}
diff --git a/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java b/javatests/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java
similarity index 100%
rename from gerrit-extension-api/src/test/java/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java
rename to javatests/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java
diff --git a/javatests/com/google/gerrit/extensions/client/RangeTest.java b/javatests/com/google/gerrit/extensions/client/RangeTest.java
new file mode 100644
index 0000000..b8938aa
--- /dev/null
+++ b/javatests/com/google/gerrit/extensions/client/RangeTest.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.client;
+
+import static com.google.gerrit.extensions.common.testing.RangeSubject.assertThat;
+
+import org.junit.Test;
+
+public class RangeTest {
+
+  @Test
+  public void rangeOverMultipleLinesWithSmallerEndCharacterIsValid() {
+    Comment.Range range = createRange(13, 31, 19, 10);
+    assertThat(range).isValid();
+  }
+
+  @Test
+  public void rangeInOneLineIsValid() {
+    Comment.Range range = createRange(13, 2, 13, 10);
+    assertThat(range).isValid();
+  }
+
+  @Test
+  public void startPositionEqualToEndPositionIsValidRange() {
+    Comment.Range range = createRange(13, 11, 13, 11);
+    assertThat(range).isValid();
+  }
+
+  @Test
+  public void negativeStartLineResultsInInvalidRange() {
+    Comment.Range range = createRange(-1, 2, 19, 10);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void negativeEndLineResultsInInvalidRange() {
+    Comment.Range range = createRange(13, 2, -1, 10);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void negativeStartCharacterResultsInInvalidRange() {
+    Comment.Range range = createRange(13, -1, 19, 10);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void negativeEndCharacterResultsInInvalidRange() {
+    Comment.Range range = createRange(13, 2, 19, -1);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void zeroStartLineResultsInInvalidRange() {
+    Comment.Range range = createRange(0, 2, 19, 10);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void zeroEndLineResultsInInvalidRange() {
+    Comment.Range range = createRange(13, 2, 0, 10);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void zeroStartCharacterResultsInValidRange() {
+    Comment.Range range = createRange(13, 0, 19, 10);
+    assertThat(range).isValid();
+  }
+
+  @Test
+  public void zeroEndCharacterResultsInValidRange() {
+    Comment.Range range = createRange(13, 31, 19, 0);
+    assertThat(range).isValid();
+  }
+
+  @Test
+  public void startLineGreaterThanEndLineResultsInInvalidRange() {
+    Comment.Range range = createRange(20, 2, 19, 10);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void startCharGreaterThanEndCharForSameLineResultsInInvalidRange() {
+    Comment.Range range = createRange(13, 11, 13, 10);
+    assertThat(range).isInvalid();
+  }
+
+  private Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+}
diff --git a/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/registration/DynamicSetTest.java b/javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
similarity index 100%
rename from gerrit-extension-api/src/test/java/com/google/gerrit/extensions/registration/DynamicSetTest.java
rename to javatests/com/google/gerrit/extensions/registration/DynamicSetTest.java
diff --git a/javatests/com/google/gerrit/gpg/BUILD b/javatests/com/google/gerrit/gpg/BUILD
new file mode 100644
index 0000000..9beb0ff
--- /dev/null
+++ b/javatests/com/google/gerrit/gpg/BUILD
@@ -0,0 +1,33 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "gpg_tests",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/gpg",
+        "//java/com/google/gerrit/gpg/testing:gpg-test-util",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/lucene",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/h2",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:truth",
+        "//lib/bouncycastle:bcpg",
+        "//lib/bouncycastle:bcpg-neverlink",
+        "//lib/bouncycastle:bcprov",
+        "//lib/bouncycastle:bcprov-neverlink",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/log:api",
+    ],
+)
diff --git a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
new file mode 100644
index 0000000..d130c20
--- /dev/null
+++ b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
@@ -0,0 +1,440 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.gpg;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.gpg.GerritPublicKeyChecker.toExtIdKey;
+import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithSecondUserId;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyA;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyB;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyC;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyD;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyE;
+import static org.eclipse.jgit.lib.RefUpdate.Result.FAST_FORWARD;
+import static org.eclipse.jgit.lib.RefUpdate.Result.FORCED;
+import static org.eclipse.jgit.lib.RefUpdate.Result.NEW;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterators;
+import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
+import com.google.gerrit.gpg.testing.TestKey;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testing.InMemoryDatabase;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.NoteDbMode;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.PushCertificateIdent;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Unit tests for {@link GerritPublicKeyChecker}. */
+public class GerritPublicKeyCheckerTest {
+  @Inject private AccountsUpdate.Server accountsUpdate;
+
+  @Inject private AccountManager accountManager;
+
+  @Inject private GerritPublicKeyChecker.Factory checkerFactory;
+
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+
+  @Inject private InMemoryDatabase schemaFactory;
+
+  @Inject private SchemaCreator schemaCreator;
+
+  @Inject private ThreadLocalRequestContext requestContext;
+
+  @Inject private ExternalIds externalIds;
+
+  private LifecycleManager lifecycle;
+  private ReviewDb db;
+  private Account.Id userId;
+  private IdentifiedUser user;
+  private Repository storeRepo;
+  private PublicKeyStore store;
+
+  @Before
+  public void setUpInjector() throws Exception {
+    Config cfg = InMemoryModule.newDefaultConfig();
+    cfg.setInt("receive", null, "maxTrustDepth", 2);
+    cfg.setStringList(
+        "receive",
+        null,
+        "trustedKey",
+        ImmutableList.of(
+            Fingerprint.toString(keyB().getPublicKey().getFingerprint()),
+            Fingerprint.toString(keyD().getPublicKey().getFingerprint())));
+    Injector injector =
+        Guice.createInjector(new InMemoryModule(cfg, NoteDbMode.newNotesMigrationFromEnv()));
+
+    lifecycle = new LifecycleManager();
+    lifecycle.add(injector);
+    injector.injectMembers(this);
+    lifecycle.start();
+
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+    userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
+    // Note: does not match any key in TestKeys.
+    accountsUpdate
+        .create()
+        .update("Set Preferred Email", userId, u -> u.setPreferredEmail("user@example.com"));
+    user = reloadUser();
+
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return user;
+          }
+
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
+
+    storeRepo = new InMemoryRepository(new DfsRepositoryDescription("repo"));
+    store = new PublicKeyStore(storeRepo);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    store.close();
+    storeRepo.close();
+  }
+
+  private IdentifiedUser addUser(String name) throws Exception {
+    AuthRequest req = AuthRequest.forUser(name);
+    Account.Id id = accountManager.authenticate(req).getAccountId();
+    return userFactory.create(id);
+  }
+
+  private IdentifiedUser reloadUser() {
+    user = userFactory.create(userId);
+    return user;
+  }
+
+  @After
+  public void tearDownInjector() {
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
+  }
+
+  @Test
+  public void defaultGpgCertificationMatchesEmail() throws Exception {
+    TestKey key = validKeyWithSecondUserId();
+    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
+    assertProblems(
+        checker.check(key.getPublicKey()),
+        Status.BAD,
+        "Key must contain a valid certification for one of the following "
+            + "identities:\n"
+            + "  gerrit:user\n"
+            + "  username:user");
+
+    addExternalId("test", "test", "test5@example.com");
+    checker = checkerFactory.create(user, store).disableTrust();
+    assertNoProblems(checker.check(key.getPublicKey()));
+  }
+
+  @Test
+  public void defaultGpgCertificationDoesNotMatchEmail() throws Exception {
+    addExternalId("test", "test", "nobody@example.com");
+    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
+    assertProblems(
+        checker.check(validKeyWithSecondUserId().getPublicKey()),
+        Status.BAD,
+        "Key must contain a valid certification for one of the following "
+            + "identities:\n"
+            + "  gerrit:user\n"
+            + "  nobody@example.com\n"
+            + "  test:test\n"
+            + "  username:user");
+  }
+
+  @Test
+  public void manualCertificationMatchesExternalId() throws Exception {
+    addExternalId("foo", "myId", null);
+    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
+    assertNoProblems(checker.check(validKeyWithSecondUserId().getPublicKey()));
+  }
+
+  @Test
+  public void manualCertificationDoesNotMatchExternalId() throws Exception {
+    addExternalId("foo", "otherId", null);
+    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
+    assertProblems(
+        checker.check(validKeyWithSecondUserId().getPublicKey()),
+        Status.BAD,
+        "Key must contain a valid certification for one of the following "
+            + "identities:\n"
+            + "  foo:otherId\n"
+            + "  gerrit:user\n"
+            + "  username:user");
+  }
+
+  @Test
+  public void noExternalIds() throws Exception {
+    Set<ExternalId> extIds = externalIds.byAccount(user.getAccountId());
+    accountsUpdate
+        .create()
+        .update("Delete External IDs", user.getAccountId(), u -> u.deleteExternalIds(extIds));
+    reloadUser();
+
+    TestKey key = validKeyWithSecondUserId();
+    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
+    assertProblems(
+        checker.check(key.getPublicKey()),
+        Status.BAD,
+        "No identities found for user; check http://test/#/settings/web-identities");
+
+    checker = checkerFactory.create().setStore(store).disableTrust();
+    assertProblems(
+        checker.check(key.getPublicKey()), Status.BAD, "Key is not associated with any users");
+    insertExtId(ExternalId.create(toExtIdKey(key.getPublicKey()), user.getAccountId()));
+    assertProblems(checker.check(key.getPublicKey()), Status.BAD, "No identities found for user");
+  }
+
+  @Test
+  public void checkValidTrustChainAndCorrectExternalIds() throws Exception {
+    // A---Bx
+    //  \
+    //   \---C---D
+    //        \
+    //         \---Ex
+    //
+    // The server ultimately trusts B and D.
+    // D and E trust C to be a valid introducer of depth 2.
+    IdentifiedUser userB = addUser("userB");
+    TestKey keyA = add(keyA(), user);
+    TestKey keyB = add(keyB(), userB);
+    add(keyC(), addUser("userC"));
+    add(keyD(), addUser("userD"));
+    add(keyE(), addUser("userE"));
+
+    // Checker for A, checking A.
+    PublicKeyChecker checkerA = checkerFactory.create(user, store);
+    assertNoProblems(checkerA.check(keyA.getPublicKey()));
+
+    // Checker for B, checking B. Trust chain and IDs are correct, so the only
+    // problem is with the key itself.
+    PublicKeyChecker checkerB = checkerFactory.create(userB, store);
+    assertProblems(checkerB.check(keyB.getPublicKey()), Status.BAD, "Key is expired");
+  }
+
+  @Test
+  public void checkWithValidKeyButWrongExpectedUserInChecker() throws Exception {
+    // A---Bx
+    //  \
+    //   \---C---D
+    //        \
+    //         \---Ex
+    //
+    // The server ultimately trusts B and D.
+    // D and E trust C to be a valid introducer of depth 2.
+    IdentifiedUser userB = addUser("userB");
+    TestKey keyA = add(keyA(), user);
+    TestKey keyB = add(keyB(), userB);
+    add(keyC(), addUser("userC"));
+    add(keyD(), addUser("userD"));
+    add(keyE(), addUser("userE"));
+
+    // Checker for A, checking B.
+    PublicKeyChecker checkerA = checkerFactory.create(user, store);
+    assertProblems(
+        checkerA.check(keyB.getPublicKey()),
+        Status.BAD,
+        "Key is expired",
+        "Key must contain a valid certification for one of the following"
+            + " identities:\n"
+            + "  gerrit:user\n"
+            + "  mailto:testa@example.com\n"
+            + "  testa@example.com\n"
+            + "  username:user");
+
+    // Checker for B, checking A.
+    PublicKeyChecker checkerB = checkerFactory.create(userB, store);
+    assertProblems(
+        checkerB.check(keyA.getPublicKey()),
+        Status.BAD,
+        "Key must contain a valid certification for one of the following"
+            + " identities:\n"
+            + "  gerrit:userB\n"
+            + "  mailto:testb@example.com\n"
+            + "  testb@example.com\n"
+            + "  username:userB");
+  }
+
+  @Test
+  public void checkTrustChainWithExpiredKey() throws Exception {
+    // A---Bx
+    //
+    // The server ultimately trusts B.
+    TestKey keyA = add(keyA(), user);
+    TestKey keyB = add(keyB(), addUser("userB"));
+
+    PublicKeyChecker checker = checkerFactory.create(user, store);
+    assertProblems(
+        checker.check(keyA.getPublicKey()),
+        Status.OK,
+        "No path to a trusted key",
+        "Certification by "
+            + keyToString(keyB.getPublicKey())
+            + " is valid, but key is not trusted",
+        "Key D24FE467 used for certification is not in store");
+  }
+
+  @Test
+  public void checkTrustChainUsingCheckerWithoutExpectedKey() throws Exception {
+    // A---Bx
+    //  \
+    //   \---C---D
+    //        \
+    //         \---Ex
+    //
+    // The server ultimately trusts B and D.
+    // D and E trust C to be a valid introducer of depth 2.
+    TestKey keyA = add(keyA(), user);
+    TestKey keyB = add(keyB(), addUser("userB"));
+    TestKey keyC = add(keyC(), addUser("userC"));
+    TestKey keyD = add(keyD(), addUser("userD"));
+    TestKey keyE = add(keyE(), addUser("userE"));
+
+    // This checker can check any key, so the only problems come from issues
+    // with the keys themselves, not having invalid user IDs.
+    PublicKeyChecker checker = checkerFactory.create().setStore(store);
+    assertNoProblems(checker.check(keyA.getPublicKey()));
+    assertProblems(checker.check(keyB.getPublicKey()), Status.BAD, "Key is expired");
+    assertNoProblems(checker.check(keyC.getPublicKey()));
+    assertNoProblems(checker.check(keyD.getPublicKey()));
+    assertProblems(
+        checker.check(keyE.getPublicKey()),
+        Status.BAD,
+        "Key is expired",
+        "No path to a trusted key");
+  }
+
+  @Test
+  public void keyLaterInTrustChainMissingUserId() throws Exception {
+    // A---Bx
+    //  \
+    //   \---C
+    //
+    // The server ultimately trusts B.
+    // C signed A's key but is not in the store.
+    TestKey keyA = add(keyA(), user);
+
+    PGPPublicKeyRing keyRingB = keyB().getPublicKeyRing();
+    PGPPublicKey keyB = keyRingB.getPublicKey();
+    keyB = PGPPublicKey.removeCertification(keyB, keyB.getUserIDs().next());
+    keyRingB = PGPPublicKeyRing.insertPublicKey(keyRingB, keyB);
+    add(keyRingB, addUser("userB"));
+
+    PublicKeyChecker checkerA = checkerFactory.create(user, store);
+    assertProblems(
+        checkerA.check(keyA.getPublicKey()),
+        Status.OK,
+        "No path to a trusted key",
+        "Certification by " + keyToString(keyB) + " is valid, but key is not trusted",
+        "Key D24FE467 used for certification is not in store");
+  }
+
+  private void add(PGPPublicKeyRing kr, IdentifiedUser user) throws Exception {
+    Account.Id id = user.getAccountId();
+    List<ExternalId> newExtIds = new ArrayList<>(2);
+    newExtIds.add(ExternalId.create(toExtIdKey(kr.getPublicKey()), id));
+
+    String userId = Iterators.getOnlyElement(kr.getPublicKey().getUserIDs(), null);
+    if (userId != null) {
+      String email = PushCertificateIdent.parse(userId).getEmailAddress();
+      assertThat(email).contains("@");
+      newExtIds.add(ExternalId.createEmail(id, email));
+    }
+
+    store.add(kr);
+    PersonIdent ident = new PersonIdent("A U Thor", "author@example.com");
+    CommitBuilder cb = new CommitBuilder();
+    cb.setAuthor(ident);
+    cb.setCommitter(ident);
+    assertThat(store.save(cb)).isAnyOf(NEW, FAST_FORWARD, FORCED);
+
+    accountsUpdate.create().update("Add External IDs", id, u -> u.addExternalIds(newExtIds));
+  }
+
+  private TestKey add(TestKey k, IdentifiedUser user) throws Exception {
+    add(k.getPublicKeyRing(), user);
+    return k;
+  }
+
+  private void assertProblems(
+      CheckResult result, Status expectedStatus, String first, String... rest) throws Exception {
+    List<String> expectedProblems = new ArrayList<>();
+    expectedProblems.add(first);
+    expectedProblems.addAll(Arrays.asList(rest));
+    assertThat(result.getStatus()).isEqualTo(expectedStatus);
+    assertThat(result.getProblems()).containsExactlyElementsIn(expectedProblems).inOrder();
+  }
+
+  private void assertNoProblems(CheckResult result) {
+    assertThat(result.getStatus()).isEqualTo(Status.TRUSTED);
+    assertThat(result.getProblems()).isEmpty();
+  }
+
+  private void addExternalId(String scheme, String id, String email) throws Exception {
+    insertExtId(ExternalId.createWithEmail(scheme, id, user.getAccountId(), email));
+  }
+
+  private void insertExtId(ExternalId extId) throws Exception {
+    accountsUpdate
+        .create()
+        .update("Add External ID", extId.accountId(), u -> u.addExternalId(extId));
+    reloadUser();
+  }
+}
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
new file mode 100644
index 0000000..48d5266
--- /dev/null
+++ b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
@@ -0,0 +1,377 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.gpg;
+
+import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
+import static com.google.gerrit.gpg.testing.TestKeys.expiredKey;
+import static com.google.gerrit.gpg.testing.TestKeys.keyRevokedByExpiredKeyAfterExpiration;
+import static com.google.gerrit.gpg.testing.TestKeys.keyRevokedByExpiredKeyBeforeExpiration;
+import static com.google.gerrit.gpg.testing.TestKeys.revokedCompromisedKey;
+import static com.google.gerrit.gpg.testing.TestKeys.revokedNoLongerUsedKey;
+import static com.google.gerrit.gpg.testing.TestKeys.selfRevokedKey;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithExpiration;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyA;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyB;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyC;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyD;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyE;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyF;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyG;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyH;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyI;
+import static com.google.gerrit.gpg.testing.TestTrustKeys.keyJ;
+import static org.bouncycastle.bcpg.SignatureSubpacketTags.REVOCATION_KEY;
+import static org.bouncycastle.openpgp.PGPSignature.DIRECT_KEY;
+import static org.junit.Assert.assertEquals;
+
+import com.google.gerrit.gpg.testing.TestKey;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.bouncycastle.openpgp.PGPSignature;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class PublicKeyCheckerTest {
+  @Rule public ExpectedException thrown = ExpectedException.none();
+
+  private InMemoryRepository repo;
+  private PublicKeyStore store;
+
+  @Before
+  public void setUp() {
+    repo = new InMemoryRepository(new DfsRepositoryDescription("repo"));
+    store = new PublicKeyStore(repo);
+  }
+
+  @After
+  public void tearDown() {
+    if (store != null) {
+      store.close();
+      store = null;
+    }
+    if (repo != null) {
+      repo.close();
+      repo = null;
+    }
+  }
+
+  @Test
+  public void validKey() throws Exception {
+    assertNoProblems(validKeyWithoutExpiration());
+  }
+
+  @Test
+  public void keyExpiringInFuture() throws Exception {
+    TestKey k = validKeyWithExpiration();
+
+    PublicKeyChecker checker = new PublicKeyChecker().setStore(store);
+    assertNoProblems(checker, k);
+
+    checker.setEffectiveTime(parseDate("2015-07-10 12:00:00 -0400"));
+    assertNoProblems(checker, k);
+
+    checker.setEffectiveTime(parseDate("2075-07-10 12:00:00 -0400"));
+    assertProblems(checker, k, "Key is expired");
+  }
+
+  @Test
+  public void expiredKeyIsExpired() throws Exception {
+    assertProblems(expiredKey(), "Key is expired");
+  }
+
+  @Test
+  public void selfRevokedKeyIsRevoked() throws Exception {
+    assertProblems(selfRevokedKey(), "Key is revoked (key material has been compromised)");
+  }
+
+  // Test keys specific to this test are at the bottom of this class. Each test
+  // has a diagram of the trust network, where:
+  //  - The notation M---N indicates N trusts M.
+  //  - An 'x' indicates the key is expired.
+
+  @Test
+  public void trustValidPathLength2() throws Exception {
+    // A---Bx
+    //  \
+    //   \---C---D
+    //        \
+    //         \---Ex
+    //
+    // D and E trust C to be a valid introducer of depth 2.
+    TestKey ka = add(keyA());
+    TestKey kb = add(keyB());
+    TestKey kc = add(keyC());
+    TestKey kd = add(keyD());
+    TestKey ke = add(keyE());
+    save();
+
+    PublicKeyChecker checker = newChecker(2, kb, kd);
+    assertNoProblems(checker, ka);
+    assertProblems(checker, kb, "Key is expired");
+    assertNoProblems(checker, kc);
+    assertNoProblems(checker, kd);
+    assertProblems(checker, ke, "Key is expired", "No path to a trusted key");
+  }
+
+  @Test
+  public void trustValidPathLength1() throws Exception {
+    // A---Bx
+    //  \
+    //   \---C---D
+    //        \
+    //         \---Ex
+    //
+    // D and E trust C to be a valid introducer of depth 2.
+    TestKey ka = add(keyA());
+    TestKey kb = add(keyB());
+    TestKey kc = add(keyC());
+    TestKey kd = add(keyD());
+    add(keyE());
+    save();
+
+    PublicKeyChecker checker = newChecker(1, kd);
+    assertProblems(checker, ka, "No path to a trusted key", notTrusted(kb), notTrusted(kc));
+  }
+
+  @Test
+  public void trustCycle() throws Exception {
+    // F---G---F, in a cycle.
+    TestKey kf = add(keyF());
+    TestKey kg = add(keyG());
+    save();
+
+    PublicKeyChecker checker = newChecker(10, keyA());
+    assertProblems(checker, kf, "No path to a trusted key", notTrusted(kg));
+    assertProblems(checker, kg, "No path to a trusted key", notTrusted(kf));
+  }
+
+  @Test
+  public void trustInsufficientDepthInSignature() throws Exception {
+    // H---I---J, but J is only trusted to length 1.
+    TestKey kh = add(keyH());
+    TestKey ki = add(keyI());
+    add(keyJ());
+    save();
+
+    PublicKeyChecker checker = newChecker(10, keyJ());
+
+    // J trusts I to a depth of 1, so I itself is valid, but I's certification
+    // of K is not valid.
+    assertNoProblems(checker, ki);
+    assertProblems(checker, kh, "No path to a trusted key", notTrusted(ki));
+  }
+
+  @Test
+  public void revokedKeyDueToCompromise() throws Exception {
+    TestKey k = add(revokedCompromisedKey());
+    add(validKeyWithoutExpiration());
+    save();
+
+    assertProblems(k, "Key is revoked (key material has been compromised): test6 compromised");
+
+    PGPPublicKeyRing kr = removeRevokers(k.getPublicKeyRing());
+    store.add(kr);
+    save();
+
+    // Key no longer specified as revoker.
+    assertNoProblems(kr.getPublicKey());
+  }
+
+  @Test
+  public void revokedKeyDueToCompromiseRevokesKeyRetroactively() throws Exception {
+    TestKey k = add(revokedCompromisedKey());
+    add(validKeyWithoutExpiration());
+    save();
+
+    String problem = "Key is revoked (key material has been compromised): test6 compromised";
+    assertProblems(k, problem);
+
+    SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+    PublicKeyChecker checker =
+        new PublicKeyChecker().setStore(store).setEffectiveTime(df.parse("2010-01-01 12:00:00"));
+    assertProblems(checker, k, problem);
+  }
+
+  @Test
+  public void revokedByKeyNotPresentInStore() throws Exception {
+    TestKey k = add(revokedCompromisedKey());
+    save();
+
+    assertProblems(k, "Key is revoked (key material has been compromised): test6 compromised");
+  }
+
+  @Test
+  public void revokedKeyDueToNoLongerBeingUsed() throws Exception {
+    TestKey k = add(revokedNoLongerUsedKey());
+    add(validKeyWithoutExpiration());
+    save();
+
+    assertProblems(k, "Key is revoked (retired and no longer valid): test7 not used");
+  }
+
+  @Test
+  public void revokedKeyDueToNoLongerBeingUsedDoesNotRevokeKeyRetroactively() throws Exception {
+    TestKey k = add(revokedNoLongerUsedKey());
+    add(validKeyWithoutExpiration());
+    save();
+
+    assertProblems(k, "Key is revoked (retired and no longer valid): test7 not used");
+
+    PublicKeyChecker checker =
+        new PublicKeyChecker()
+            .setStore(store)
+            .setEffectiveTime(parseDate("2010-01-01 12:00:00 -0400"));
+    assertNoProblems(checker, k);
+  }
+
+  @Test
+  public void keyRevokedByExpiredKeyAfterExpirationIsNotRevoked() throws Exception {
+    TestKey k = add(keyRevokedByExpiredKeyAfterExpiration());
+    add(expiredKey());
+    save();
+
+    PublicKeyChecker checker = new PublicKeyChecker().setStore(store);
+    assertNoProblems(checker, k);
+  }
+
+  @Test
+  public void keyRevokedByExpiredKeyBeforeExpirationIsRevoked() throws Exception {
+    TestKey k = add(keyRevokedByExpiredKeyBeforeExpiration());
+    add(expiredKey());
+    save();
+
+    PublicKeyChecker checker = new PublicKeyChecker().setStore(store);
+    assertProblems(checker, k, "Key is revoked (retired and no longer valid): test9 not used");
+
+    // Set time between key creation and revocation.
+    checker.setEffectiveTime(parseDate("2005-08-01 13:00:00 -0400"));
+    assertNoProblems(checker, k);
+  }
+
+  private PGPPublicKeyRing removeRevokers(PGPPublicKeyRing kr) {
+    PGPPublicKey k = kr.getPublicKey();
+    @SuppressWarnings("unchecked")
+    Iterator<PGPSignature> sigs = k.getSignaturesOfType(DIRECT_KEY);
+    while (sigs.hasNext()) {
+      PGPSignature sig = sigs.next();
+      if (sig.getHashedSubPackets().hasSubpacket(REVOCATION_KEY)) {
+        k = PGPPublicKey.removeCertification(k, sig);
+      }
+    }
+    return PGPPublicKeyRing.insertPublicKey(kr, k);
+  }
+
+  private PublicKeyChecker newChecker(int maxTrustDepth, TestKey... trusted) {
+    Map<Long, Fingerprint> fps = new HashMap<>();
+    for (TestKey k : trusted) {
+      Fingerprint fp = new Fingerprint(k.getPublicKey().getFingerprint());
+      fps.put(fp.getId(), fp);
+    }
+    return new PublicKeyChecker().enableTrust(maxTrustDepth, fps).setStore(store);
+  }
+
+  private TestKey add(TestKey k) {
+    store.add(k.getPublicKeyRing());
+    return k;
+  }
+
+  private void save() throws Exception {
+    PersonIdent ident = new PersonIdent("A U Thor", "author@example.com");
+    CommitBuilder cb = new CommitBuilder();
+    cb.setAuthor(ident);
+    cb.setCommitter(ident);
+    RefUpdate.Result result = store.save(cb);
+    switch (result) {
+      case NEW:
+      case FAST_FORWARD:
+      case FORCED:
+        break;
+      case IO_FAILURE:
+      case LOCK_FAILURE:
+      case NOT_ATTEMPTED:
+      case NO_CHANGE:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case RENAMED:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+      default:
+        throw new AssertionError(result);
+    }
+  }
+
+  private void assertProblems(PublicKeyChecker checker, TestKey k, String first, String... rest) {
+    CheckResult result = checker.setStore(store).check(k.getPublicKey());
+    assertEquals(list(first, rest), result.getProblems());
+  }
+
+  private void assertNoProblems(PublicKeyChecker checker, TestKey k) {
+    CheckResult result = checker.setStore(store).check(k.getPublicKey());
+    assertEquals(Collections.emptyList(), result.getProblems());
+  }
+
+  private void assertProblems(TestKey tk, String first, String... rest) {
+    assertProblems(tk.getPublicKey(), first, rest);
+  }
+
+  private void assertNoProblems(TestKey tk) {
+    assertNoProblems(tk.getPublicKey());
+  }
+
+  private void assertProblems(PGPPublicKey k, String first, String... rest) {
+    CheckResult result = new PublicKeyChecker().setStore(store).check(k);
+    assertEquals(list(first, rest), result.getProblems());
+  }
+
+  private void assertNoProblems(PGPPublicKey k) {
+    CheckResult result = new PublicKeyChecker().setStore(store).check(k);
+    assertEquals(Collections.emptyList(), result.getProblems());
+  }
+
+  private static String notTrusted(TestKey k) {
+    return "Certification by "
+        + keyToString(k.getPublicKey())
+        + " is valid, but key is not trusted";
+  }
+
+  private static Date parseDate(String str) throws Exception {
+    return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").parse(str);
+  }
+
+  private static List<String> list(String first, String[] rest) {
+    List<String> all = new ArrayList<>();
+    all.add(first);
+    all.addAll(Arrays.asList(rest));
+    return all;
+  }
+}
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java b/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
new file mode 100644
index 0000000..b5b942d
--- /dev/null
+++ b/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
@@ -0,0 +1,250 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.gpg;
+
+import static com.google.gerrit.gpg.PublicKeyStore.REFS_GPG_KEYS;
+import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
+import static com.google.gerrit.gpg.PublicKeyStore.keyObjectId;
+import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithExpiration;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithSecondUserId;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.gpg.testing.TestKey;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.bouncycastle.openpgp.PGPPublicKeyRingCollection;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PublicKeyStoreTest {
+  private TestRepository<?> tr;
+  private PublicKeyStore store;
+
+  @Before
+  public void setUp() throws Exception {
+    tr = new TestRepository<>(new InMemoryRepository(new DfsRepositoryDescription("pubkeys")));
+    store = new PublicKeyStore(tr.getRepository());
+  }
+
+  @Test
+  public void testKeyIdToString() throws Exception {
+    PGPPublicKey key = validKeyWithoutExpiration().getPublicKey();
+    assertEquals("46328A8C", keyIdToString(key.getKeyID()));
+  }
+
+  @Test
+  public void testKeyToString() throws Exception {
+    PGPPublicKey key = validKeyWithoutExpiration().getPublicKey();
+    assertEquals(
+        "46328A8C Testuser One <test1@example.com>"
+            + " (04AE A7ED 2F82 1133 E5B1  28D1 ED06 25DC 4632 8A8C)",
+        keyToString(key));
+  }
+
+  @Test
+  public void testKeyObjectId() throws Exception {
+    PGPPublicKey key = validKeyWithoutExpiration().getPublicKey();
+    String objId = keyObjectId(key.getKeyID()).name();
+    assertEquals("ed0625dc46328a8c000000000000000000000000", objId);
+    assertEquals(keyIdToString(key.getKeyID()).toLowerCase(), objId.substring(8, 16));
+  }
+
+  @Test
+  public void get() throws Exception {
+    TestKey key1 = validKeyWithoutExpiration();
+    tr.branch(REFS_GPG_KEYS)
+        .commit()
+        .add(keyObjectId(key1.getKeyId()).name(), key1.getPublicKeyArmored())
+        .create();
+    TestKey key2 = validKeyWithExpiration();
+    tr.branch(REFS_GPG_KEYS)
+        .commit()
+        .add(keyObjectId(key2.getKeyId()).name(), key2.getPublicKeyArmored())
+        .create();
+
+    assertKeys(key1.getKeyId(), key1);
+    assertKeys(key2.getKeyId(), key2);
+  }
+
+  @Test
+  public void getMultiple() throws Exception {
+    TestKey key1 = validKeyWithoutExpiration();
+    TestKey key2 = validKeyWithExpiration();
+    tr.branch(REFS_GPG_KEYS)
+        .commit()
+        .add(
+            keyObjectId(key1.getKeyId()).name(),
+            key1.getPublicKeyArmored()
+                // Mismatched for this key ID, but we can still read it out.
+                + key2.getPublicKeyArmored())
+        .create();
+    assertKeys(key1.getKeyId(), key1, key2);
+  }
+
+  @Test
+  public void save() throws Exception {
+    TestKey key1 = validKeyWithoutExpiration();
+    TestKey key2 = validKeyWithExpiration();
+    store.add(key1.getPublicKeyRing());
+    store.add(key2.getPublicKeyRing());
+
+    assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
+
+    assertKeys(key1.getKeyId(), key1);
+    assertKeys(key2.getKeyId(), key2);
+  }
+
+  @Test
+  public void saveAppendsToExistingList() throws Exception {
+    TestKey key1 = validKeyWithoutExpiration();
+    TestKey key2 = validKeyWithExpiration();
+    tr.branch(REFS_GPG_KEYS)
+        .commit()
+        // Mismatched for this key ID, but we can still read it out.
+        .add(keyObjectId(key1.getKeyId()).name(), key2.getPublicKeyArmored())
+        .create();
+
+    store.add(key1.getPublicKeyRing());
+    assertEquals(RefUpdate.Result.FAST_FORWARD, store.save(newCommitBuilder()));
+
+    assertKeys(key1.getKeyId(), key1, key2);
+
+    try (ObjectReader reader = tr.getRepository().newObjectReader();
+        RevWalk rw = new RevWalk(reader)) {
+      NoteMap notes =
+          NoteMap.read(
+              reader,
+              tr.getRevWalk()
+                  .parseCommit(tr.getRepository().exactRef(REFS_GPG_KEYS).getObjectId()));
+      String contents =
+          new String(reader.open(notes.get(keyObjectId(key1.getKeyId()))).getBytes(), UTF_8);
+      String header = "-----BEGIN PGP PUBLIC KEY BLOCK-----";
+      int i1 = contents.indexOf(header);
+      assertTrue(i1 >= 0);
+      int i2 = contents.indexOf(header, i1 + header.length());
+      assertTrue(i2 >= 0);
+    }
+  }
+
+  @Test
+  public void updateExisting() throws Exception {
+    TestKey key5 = validKeyWithSecondUserId();
+    PGPPublicKeyRing keyRing = key5.getPublicKeyRing();
+    PGPPublicKey key = keyRing.getPublicKey();
+    store.add(keyRing);
+    assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
+
+    assertUserIds(
+        store.get(key5.getKeyId()).iterator().next(),
+        "Testuser Five <test5@example.com>",
+        "foo:myId");
+
+    keyRing = PGPPublicKeyRing.removePublicKey(keyRing, key);
+    key = PGPPublicKey.removeCertification(key, "foo:myId");
+    keyRing = PGPPublicKeyRing.insertPublicKey(keyRing, key);
+    store.add(keyRing);
+    assertEquals(RefUpdate.Result.FAST_FORWARD, store.save(newCommitBuilder()));
+
+    Iterator<PGPPublicKeyRing> keyRings = store.get(key.getKeyID()).iterator();
+    keyRing = keyRings.next();
+    assertFalse(keyRings.hasNext());
+    assertUserIds(keyRing, "Testuser Five <test5@example.com>");
+  }
+
+  @Test
+  public void remove() throws Exception {
+    TestKey key1 = validKeyWithoutExpiration();
+    store.add(key1.getPublicKeyRing());
+    assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
+    assertKeys(key1.getKeyId(), key1);
+
+    store.remove(key1.getPublicKey().getFingerprint());
+    assertEquals(RefUpdate.Result.FAST_FORWARD, store.save(newCommitBuilder()));
+    assertKeys(key1.getKeyId());
+  }
+
+  @Test
+  public void removeNonexisting() throws Exception {
+    TestKey key1 = validKeyWithoutExpiration();
+    store.add(key1.getPublicKeyRing());
+    assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
+
+    TestKey key2 = validKeyWithExpiration();
+    store.remove(key2.getPublicKey().getFingerprint());
+    assertEquals(RefUpdate.Result.NO_CHANGE, store.save(newCommitBuilder()));
+    assertKeys(key1.getKeyId(), key1);
+  }
+
+  @Test
+  public void addThenRemove() throws Exception {
+    TestKey key1 = validKeyWithoutExpiration();
+    store.add(key1.getPublicKeyRing());
+    store.remove(key1.getPublicKey().getFingerprint());
+    assertEquals(RefUpdate.Result.NO_CHANGE, store.save(newCommitBuilder()));
+    assertKeys(key1.getKeyId());
+  }
+
+  private void assertKeys(long keyId, TestKey... expected) throws Exception {
+    Set<String> expectedStrings = new TreeSet<>();
+    for (TestKey k : expected) {
+      expectedStrings.add(keyToString(k.getPublicKey()));
+    }
+    PGPPublicKeyRingCollection actual = store.get(keyId);
+    Set<String> actualStrings = new TreeSet<>();
+    for (PGPPublicKeyRing k : actual) {
+      actualStrings.add(keyToString(k.getPublicKey()));
+    }
+    assertEquals(expectedStrings, actualStrings);
+  }
+
+  private void assertUserIds(PGPPublicKeyRing keyRing, String... expected) throws Exception {
+    List<String> actual = new ArrayList<>();
+    Iterator<String> userIds =
+        store.get(keyRing.getPublicKey().getKeyID()).iterator().next().getPublicKey().getUserIDs();
+    while (userIds.hasNext()) {
+      actual.add(userIds.next());
+    }
+
+    assertEquals(Arrays.asList(expected), actual);
+  }
+
+  private CommitBuilder newCommitBuilder() {
+    CommitBuilder cb = new CommitBuilder();
+    PersonIdent ident = new PersonIdent("A U Thor", "author@example.com");
+    cb.setAuthor(ident);
+    cb.setCommitter(ident);
+    return cb;
+  }
+}
diff --git a/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java b/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java
new file mode 100644
index 0000000..ad8f4311
--- /dev/null
+++ b/javatests/com/google/gerrit/gpg/PushCertificateCheckerTest.java
@@ -0,0 +1,204 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.gpg;
+
+import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
+import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
+import static com.google.gerrit.gpg.testing.TestKeys.expiredKey;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithExpiration;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertEquals;
+
+import com.google.gerrit.gpg.testing.TestKey;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import org.bouncycastle.bcpg.ArmoredOutputStream;
+import org.bouncycastle.bcpg.BCPGOutputStream;
+import org.bouncycastle.openpgp.PGPSignature;
+import org.bouncycastle.openpgp.PGPSignatureGenerator;
+import org.bouncycastle.openpgp.PGPSignatureSubpacketGenerator;
+import org.bouncycastle.openpgp.PGPUtil;
+import org.bouncycastle.openpgp.operator.bc.BcPGPContentSignerBuilder;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.PushCertificate;
+import org.eclipse.jgit.transport.PushCertificateIdent;
+import org.eclipse.jgit.transport.PushCertificateParser;
+import org.eclipse.jgit.transport.SignedPushConfig;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PushCertificateCheckerTest {
+  private InMemoryRepository repo;
+  private PublicKeyStore store;
+  private SignedPushConfig signedPushConfig;
+  private PushCertificateChecker checker;
+
+  @Before
+  public void setUp() throws Exception {
+    TestKey key1 = validKeyWithoutExpiration();
+    TestKey key3 = expiredKey();
+    repo = new InMemoryRepository(new DfsRepositoryDescription("repo"));
+    store = new PublicKeyStore(repo);
+    store.add(key1.getPublicKeyRing());
+    store.add(key3.getPublicKeyRing());
+
+    PersonIdent ident = new PersonIdent("A U Thor", "author@example.com");
+    CommitBuilder cb = new CommitBuilder();
+    cb.setAuthor(ident);
+    cb.setCommitter(ident);
+    assertEquals(RefUpdate.Result.NEW, store.save(cb));
+
+    signedPushConfig = new SignedPushConfig();
+    signedPushConfig.setCertNonceSeed("sekret");
+    signedPushConfig.setCertNonceSlopLimit(60 * 24);
+    checker = newChecker(true);
+  }
+
+  private PushCertificateChecker newChecker(boolean checkNonce) {
+    PublicKeyChecker keyChecker = new PublicKeyChecker().setStore(store);
+    return new PushCertificateChecker(keyChecker) {
+      @Override
+      protected Repository getRepository() {
+        return repo;
+      }
+
+      @Override
+      protected boolean shouldClose(Repository repo) {
+        return false;
+      }
+    }.setCheckNonce(checkNonce);
+  }
+
+  @Test
+  public void validCert() throws Exception {
+    PushCertificate cert = newSignedCert(validNonce(), validKeyWithoutExpiration());
+    assertNoProblems(cert);
+  }
+
+  @Test
+  public void invalidNonce() throws Exception {
+    PushCertificate cert = newSignedCert("invalid-nonce", validKeyWithoutExpiration());
+    assertProblems(cert, "Invalid nonce");
+  }
+
+  @Test
+  public void invalidNonceNotChecked() throws Exception {
+    checker = newChecker(false);
+    PushCertificate cert = newSignedCert("invalid-nonce", validKeyWithoutExpiration());
+    assertNoProblems(cert);
+  }
+
+  @Test
+  public void missingKey() throws Exception {
+    TestKey key2 = validKeyWithExpiration();
+    PushCertificate cert = newSignedCert(validNonce(), key2);
+    assertProblems(cert, "No public keys found for key ID " + keyIdToString(key2.getKeyId()));
+  }
+
+  @Test
+  public void invalidKey() throws Exception {
+    TestKey key3 = expiredKey();
+    PushCertificate cert = newSignedCert(validNonce(), key3);
+    assertProblems(
+        cert, "Invalid public key " + keyToString(key3.getPublicKey()) + ":\n  Key is expired");
+  }
+
+  @Test
+  public void signatureByExpiredKeyBeforeExpiration() throws Exception {
+    TestKey key3 = expiredKey();
+    Date now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").parse("2005-07-10 12:00:00 -0400");
+    PushCertificate cert = newSignedCert(validNonce(), key3, now);
+    assertNoProblems(cert);
+  }
+
+  private String validNonce() {
+    return signedPushConfig
+        .getNonceGenerator()
+        .createNonce(repo, System.currentTimeMillis() / 1000);
+  }
+
+  private PushCertificate newSignedCert(String nonce, TestKey signingKey) throws Exception {
+    return newSignedCert(nonce, signingKey, null);
+  }
+
+  private PushCertificate newSignedCert(String nonce, TestKey signingKey, Date now)
+      throws Exception {
+    PushCertificateIdent ident =
+        new PushCertificateIdent(signingKey.getFirstUserId(), System.currentTimeMillis(), -7 * 60);
+    String payload =
+        "certificate version 0.1\n"
+            + "pusher "
+            + ident.getRaw()
+            + "\n"
+            + "pushee test://localhost/repo.git\n"
+            + "nonce "
+            + nonce
+            + "\n"
+            + "\n"
+            + "0000000000000000000000000000000000000000"
+            + " deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
+            + " refs/heads/master\n";
+    PGPSignatureGenerator gen =
+        new PGPSignatureGenerator(
+            new BcPGPContentSignerBuilder(signingKey.getPublicKey().getAlgorithm(), PGPUtil.SHA1));
+
+    if (now != null) {
+      PGPSignatureSubpacketGenerator subGen = new PGPSignatureSubpacketGenerator();
+      subGen.setSignatureCreationTime(false, now);
+      gen.setHashedSubpackets(subGen.generate());
+    }
+
+    gen.init(PGPSignature.BINARY_DOCUMENT, signingKey.getPrivateKey());
+    gen.update(payload.getBytes(UTF_8));
+    PGPSignature sig = gen.generate();
+
+    ByteArrayOutputStream bout = new ByteArrayOutputStream();
+    try (BCPGOutputStream out = new BCPGOutputStream(new ArmoredOutputStream(bout))) {
+      sig.encode(out);
+    }
+
+    String cert = payload + new String(bout.toByteArray(), UTF_8);
+    Reader reader = new InputStreamReader(new ByteArrayInputStream(cert.getBytes(UTF_8)));
+    PushCertificateParser parser = new PushCertificateParser(repo, signedPushConfig);
+    return parser.parse(reader);
+  }
+
+  private void assertProblems(PushCertificate cert, String first, String... rest) throws Exception {
+    List<String> expected = new ArrayList<>();
+    expected.add(first);
+    expected.addAll(Arrays.asList(rest));
+    CheckResult result = checker.check(cert).getCheckResult();
+    assertEquals(expected, result.getProblems());
+  }
+
+  private void assertNoProblems(PushCertificate cert) {
+    CheckResult result = checker.check(cert).getCheckResult();
+    assertEquals(Collections.emptyList(), result.getProblems());
+  }
+}
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java b/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
similarity index 100%
rename from gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
rename to javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
diff --git a/javatests/com/google/gerrit/httpd/BUILD b/javatests/com/google/gerrit/httpd/BUILD
new file mode 100644
index 0000000..e2f2a45
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/BUILD
@@ -0,0 +1,29 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "httpd_tests",
+    srcs = glob(["**/*.java"]),
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/httpd",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/util/http",
+        "//javatests/com/google/gerrit/util/http/testutil",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:jimfs",
+        "//lib:junit",
+        "//lib:servlet-api-3_1-without-neverlink",
+        "//lib:soy",
+        "//lib:truth",
+        "//lib/easymock",
+        "//lib/guice",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+)
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/RemoteUserUtilTest.java b/javatests/com/google/gerrit/httpd/RemoteUserUtilTest.java
similarity index 100%
rename from gerrit-httpd/src/test/java/com/google/gerrit/httpd/RemoteUserUtilTest.java
rename to javatests/com/google/gerrit/httpd/RemoteUserUtilTest.java
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/ContextMapperTest.java b/javatests/com/google/gerrit/httpd/plugins/ContextMapperTest.java
similarity index 100%
rename from gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/ContextMapperTest.java
rename to javatests/com/google/gerrit/httpd/plugins/ContextMapperTest.java
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
new file mode 100644
index 0000000..edafeb3
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.template.soy.data.SoyMapData;
+import java.net.URISyntaxException;
+import org.junit.Test;
+
+public class IndexServletTest {
+  class TestIndexServlet extends IndexServlet {
+    private static final long serialVersionUID = 1L;
+
+    TestIndexServlet(String canonicalURL, String cdnPath, String faviconPath)
+        throws URISyntaxException {
+      super(canonicalURL, cdnPath, faviconPath);
+    }
+
+    String getIndexSource() {
+      return new String(indexSource);
+    }
+  }
+
+  @Test
+  public void noPathAndNoCDN() throws URISyntaxException {
+    SoyMapData data = IndexServlet.getTemplateData("http://example.com/", null, null);
+    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("");
+    assertThat(data.getSingle("staticResourcePath").stringValue()).isEqualTo("");
+  }
+
+  @Test
+  public void pathAndNoCDN() throws URISyntaxException {
+    SoyMapData data = IndexServlet.getTemplateData("http://example.com/gerrit/", null, null);
+    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("/gerrit");
+    assertThat(data.getSingle("staticResourcePath").stringValue()).isEqualTo("/gerrit");
+  }
+
+  @Test
+  public void noPathAndCDN() throws URISyntaxException {
+    SoyMapData data =
+        IndexServlet.getTemplateData("http://example.com/", "http://my-cdn.com/foo/bar/", null);
+    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("");
+    assertThat(data.getSingle("staticResourcePath").stringValue())
+        .isEqualTo("http://my-cdn.com/foo/bar/");
+  }
+
+  @Test
+  public void pathAndCDN() throws URISyntaxException {
+    SoyMapData data =
+        IndexServlet.getTemplateData(
+            "http://example.com/gerrit", "http://my-cdn.com/foo/bar/", null);
+    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("/gerrit");
+    assertThat(data.getSingle("staticResourcePath").stringValue())
+        .isEqualTo("http://my-cdn.com/foo/bar/");
+  }
+
+  @Test
+  public void renderTemplate() throws URISyntaxException {
+    String testCanonicalUrl = "foo-url";
+    String testCdnPath = "bar-cdn";
+    String testFaviconURL = "zaz-url";
+    TestIndexServlet servlet = new TestIndexServlet(testCanonicalUrl, testCdnPath, testFaviconURL);
+    String output = servlet.getIndexSource();
+    assertThat(output).contains("<!DOCTYPE html>");
+    assertThat(output).contains("window.CANONICAL_PATH = '" + testCanonicalUrl);
+    assertThat(output).contains("<link rel=\"preload\" href=\"" + testCdnPath);
+    assertThat(output)
+        .contains(
+            "<link rel=\"icon\" type=\"image/x-icon\" href=\""
+                + testCanonicalUrl
+                + "/"
+                + testFaviconURL);
+  }
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
new file mode 100644
index 0000000..6dd15bc
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -0,0 +1,377 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Strings;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.collect.Lists;
+import com.google.common.io.ByteStreams;
+import com.google.common.jimfs.Configuration;
+import com.google.common.jimfs.Jimfs;
+import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
+import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.file.FileSystem;
+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.junit.Before;
+import org.junit.Test;
+
+public class ResourceServletTest {
+  private static Cache<Path, Resource> newCache(int size) {
+    return CacheBuilder.newBuilder().maximumSize(size).recordStats().build();
+  }
+
+  private static class Servlet extends ResourceServlet {
+    private static final long serialVersionUID = 1L;
+
+    private final FileSystem fs;
+
+    private Servlet(FileSystem fs, Cache<Path, Resource> cache, boolean refresh) {
+      super(cache, refresh);
+      this.fs = fs;
+    }
+
+    private Servlet(
+        FileSystem fs, Cache<Path, Resource> cache, boolean refresh, boolean cacheOnClient) {
+      super(cache, refresh, cacheOnClient);
+      this.fs = fs;
+    }
+
+    private Servlet(
+        FileSystem fs, Cache<Path, Resource> cache, boolean refresh, int cacheFileSizeLimitBytes) {
+      super(cache, refresh, true, cacheFileSizeLimitBytes);
+      this.fs = fs;
+    }
+
+    private Servlet(
+        FileSystem fs,
+        Cache<Path, Resource> cache,
+        boolean refresh,
+        boolean cacheOnClient,
+        int cacheFileSizeLimitBytes) {
+      super(cache, refresh, cacheOnClient, cacheFileSizeLimitBytes);
+      this.fs = fs;
+    }
+
+    @Override
+    protected Path getResourcePath(String pathInfo) {
+      return fs.getPath("/" + CharMatcher.is('/').trimLeadingFrom(pathInfo));
+    }
+  }
+
+  private FileSystem fs;
+  private AtomicLong ts;
+
+  @Before
+  public void setUp() {
+    fs = Jimfs.newFileSystem(Configuration.unix());
+    ts =
+        new AtomicLong(
+            LocalDateTime.of(2010, Month.JANUARY, 30, 12, 0, 0)
+                .atOffset(ZoneOffset.ofHours(-8))
+                .toInstant()
+                .toEpochMilli());
+  }
+
+  @Test
+  public void notFoundWithoutRefresh() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, false);
+
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(request("/notfound"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
+    assertNotCacheable(res);
+    assertCacheHits(cache, 0, 1);
+
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/notfound"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
+    assertNotCacheable(res);
+    assertCacheHits(cache, 1, 1);
+  }
+
+  @Test
+  public void notFoundWithRefresh() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, true);
+
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(request("/notfound"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
+    assertNotCacheable(res);
+    assertCacheHits(cache, 0, 1);
+
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/notfound"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
+    assertNotCacheable(res);
+    assertCacheHits(cache, 1, 1);
+  }
+
+  @Test
+  public void smallFileWithRefresh() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, true);
+
+    writeFile("/foo", "foo1");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertCacheable(res, true);
+    assertHasETag(res);
+    // Miss on getIfPresent, miss on get.
+    assertCacheHits(cache, 0, 2);
+
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertCacheable(res, true);
+    assertHasETag(res);
+    assertCacheHits(cache, 1, 2);
+
+    writeFile("/foo", "foo2");
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo2");
+    assertCacheable(res, true);
+    assertHasETag(res);
+    // Hit, invalidate, miss.
+    assertCacheHits(cache, 2, 3);
+  }
+
+  @Test
+  public void smallFileWithoutClientCache() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, false, false);
+
+    writeFile("/foo", "foo1");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertNotCacheable(res);
+
+    // Miss on getIfPresent, miss on get.
+    assertCacheHits(cache, 0, 2);
+
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertNotCacheable(res);
+    assertCacheHits(cache, 1, 2);
+
+    writeFile("/foo", "foo2");
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertNotCacheable(res);
+    assertCacheHits(cache, 2, 2);
+  }
+
+  @Test
+  public void smallFileWithoutRefresh() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, false);
+
+    writeFile("/foo", "foo1");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertCacheable(res, false);
+    assertHasETag(res);
+    // Miss on getIfPresent, miss on get.
+    assertCacheHits(cache, 0, 2);
+
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertCacheable(res, false);
+    assertHasETag(res);
+    assertCacheHits(cache, 1, 2);
+
+    writeFile("/foo", "foo2");
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertCacheable(res, false);
+    assertHasETag(res);
+    assertCacheHits(cache, 2, 2);
+  }
+
+  @Test
+  public void verySmallFileDoesntBotherWithGzip() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, true);
+    writeFile("/foo", "foo1");
+
+    FakeHttpServletRequest req = request("/foo").addHeader("Accept-Encoding", "gzip");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getHeader("Content-Encoding")).isNull();
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertHasETag(res);
+    assertCacheable(res, true);
+  }
+
+  @Test
+  public void smallFileWithGzip() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, true);
+    String content = Strings.repeat("a", 100);
+    writeFile("/foo", content);
+
+    FakeHttpServletRequest req = request("/foo").addHeader("Accept-Encoding", "gzip");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getHeader("Content-Encoding")).isEqualTo("gzip");
+    assertThat(gunzip(res.getActualBody())).isEqualTo(content);
+    assertHasETag(res);
+    assertCacheable(res, true);
+  }
+
+  @Test
+  public void largeFileBypassesCacheRegardlessOfRefreshParamter() throws Exception {
+    for (boolean refresh : Lists.newArrayList(true, false)) {
+      Cache<Path, Resource> cache = newCache(1);
+      Servlet servlet = new Servlet(fs, cache, refresh, 3);
+
+      writeFile("/foo", "foo1");
+      FakeHttpServletResponse res = new FakeHttpServletResponse();
+      servlet.doGet(request("/foo"), res);
+      assertThat(res.getStatus()).isEqualTo(SC_OK);
+      assertThat(res.getActualBodyString()).isEqualTo("foo1");
+      assertThat(res.getHeader("Last-Modified")).isNotNull();
+      assertCacheable(res, refresh);
+      assertHasLastModified(res);
+      assertCacheHits(cache, 0, 1);
+
+      writeFile("/foo", "foo1");
+      res = new FakeHttpServletResponse();
+      servlet.doGet(request("/foo"), res);
+      assertThat(res.getStatus()).isEqualTo(SC_OK);
+      assertThat(res.getActualBodyString()).isEqualTo("foo1");
+      assertThat(res.getHeader("Last-Modified")).isNotNull();
+      assertCacheable(res, refresh);
+      assertHasLastModified(res);
+      assertCacheHits(cache, 0, 2);
+
+      writeFile("/foo", "foo2");
+      res = new FakeHttpServletResponse();
+      servlet.doGet(request("/foo"), res);
+      assertThat(res.getStatus()).isEqualTo(SC_OK);
+      assertThat(res.getActualBodyString()).isEqualTo("foo2");
+      assertThat(res.getHeader("Last-Modified")).isNotNull();
+      assertCacheable(res, refresh);
+      assertHasLastModified(res);
+      assertCacheHits(cache, 0, 3);
+    }
+  }
+
+  @Test
+  public void largeFileWithGzip() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, true, 3);
+    String content = Strings.repeat("a", 100);
+    writeFile("/foo", content);
+
+    FakeHttpServletRequest req = request("/foo").addHeader("Accept-Encoding", "gzip");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getHeader("Content-Encoding")).isEqualTo("gzip");
+    assertThat(gunzip(res.getActualBody())).isEqualTo(content);
+    assertHasLastModified(res);
+    assertCacheable(res, true);
+  }
+
+  // TODO(dborowitz): Check MIME type.
+  // TODO(dborowitz): Test that JS is not gzipped.
+  // TODO(dborowitz): Test ?e parameter.
+  // TODO(dborowitz): Test If-None-Match behavior.
+  // TODO(dborowitz): Test If-Modified-Since behavior.
+
+  private void writeFile(String path, String content) throws Exception {
+    Files.write(fs.getPath(path), content.getBytes(UTF_8));
+    Files.setLastModifiedTime(fs.getPath(path), FileTime.fromMillis(ts.getAndIncrement()));
+  }
+
+  private static void assertCacheHits(Cache<?, ?> cache, int hits, int misses) {
+    assertThat(cache.stats().hitCount()).named("hits").isEqualTo(hits);
+    assertThat(cache.stats().missCount()).named("misses").isEqualTo(misses);
+  }
+
+  private static void assertCacheable(FakeHttpServletResponse res, boolean revalidate) {
+    String header = res.getHeader("Cache-Control").toLowerCase();
+    assertThat(header).contains("public");
+    if (revalidate) {
+      assertThat(header).contains("must-revalidate");
+    } else {
+      assertThat(header).doesNotContain("must-revalidate");
+    }
+  }
+
+  private static void assertHasLastModified(FakeHttpServletResponse res) {
+    assertThat(res.getHeader("Last-Modified")).isNotNull();
+    assertThat(res.getHeader("ETag")).isNull();
+  }
+
+  private static void assertHasETag(FakeHttpServletResponse res) {
+    assertThat(res.getHeader("ETag")).isNotNull();
+    assertThat(res.getHeader("Last-Modified")).isNull();
+  }
+
+  private static void assertNotCacheable(FakeHttpServletResponse res) {
+    assertThat(res.getHeader("Cache-Control")).contains("no-cache");
+    assertThat(res.getHeader("ETag")).isNull();
+    assertThat(res.getHeader("Last-Modified")).isNull();
+  }
+
+  private static FakeHttpServletRequest request(String path) {
+    return new FakeHttpServletRequest().setPathInfo(path);
+  }
+
+  private static String gunzip(byte[] data) throws Exception {
+    try (InputStream in = new GZIPInputStream(new ByteArrayInputStream(data))) {
+      return new String(ByteStreams.toByteArray(in), UTF_8);
+    }
+  }
+}
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/restapi/ParameterParserTest.java b/javatests/com/google/gerrit/httpd/restapi/ParameterParserTest.java
similarity index 100%
rename from gerrit-httpd/src/test/java/com/google/gerrit/httpd/restapi/ParameterParserTest.java
rename to javatests/com/google/gerrit/httpd/restapi/ParameterParserTest.java
diff --git a/javatests/com/google/gerrit/index/BUILD b/javatests/com/google/gerrit/index/BUILD
new file mode 100644
index 0000000..bd79860
--- /dev/null
+++ b/javatests/com/google/gerrit/index/BUILD
@@ -0,0 +1,17 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "tests",
+    size = "small",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/index:query_parser",
+        "//lib:junit",
+        "//lib:truth",
+        "//lib/antlr:java_runtime",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/gerrit-index/src/test/java/com/google/gerrit/index/SchemaUtilTest.java b/javatests/com/google/gerrit/index/SchemaUtilTest.java
similarity index 100%
rename from gerrit-index/src/test/java/com/google/gerrit/index/SchemaUtilTest.java
rename to javatests/com/google/gerrit/index/SchemaUtilTest.java
diff --git a/gerrit-index/src/test/java/com/google/gerrit/index/query/AndPredicateTest.java b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
similarity index 100%
rename from gerrit-index/src/test/java/com/google/gerrit/index/query/AndPredicateTest.java
rename to javatests/com/google/gerrit/index/query/AndPredicateTest.java
diff --git a/gerrit-index/src/test/java/com/google/gerrit/index/query/FieldPredicateTest.java b/javatests/com/google/gerrit/index/query/FieldPredicateTest.java
similarity index 100%
rename from gerrit-index/src/test/java/com/google/gerrit/index/query/FieldPredicateTest.java
rename to javatests/com/google/gerrit/index/query/FieldPredicateTest.java
diff --git a/gerrit-index/src/test/java/com/google/gerrit/index/query/NotPredicateTest.java b/javatests/com/google/gerrit/index/query/NotPredicateTest.java
similarity index 100%
rename from gerrit-index/src/test/java/com/google/gerrit/index/query/NotPredicateTest.java
rename to javatests/com/google/gerrit/index/query/NotPredicateTest.java
diff --git a/gerrit-index/src/test/java/com/google/gerrit/index/query/OrPredicateTest.java b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
similarity index 100%
rename from gerrit-index/src/test/java/com/google/gerrit/index/query/OrPredicateTest.java
rename to javatests/com/google/gerrit/index/query/OrPredicateTest.java
diff --git a/gerrit-index/src/test/java/com/google/gerrit/index/query/PredicateTest.java b/javatests/com/google/gerrit/index/query/PredicateTest.java
similarity index 100%
rename from gerrit-index/src/test/java/com/google/gerrit/index/query/PredicateTest.java
rename to javatests/com/google/gerrit/index/query/PredicateTest.java
diff --git a/gerrit-index/src/test/java/com/google/gerrit/index/query/QueryParserTest.java b/javatests/com/google/gerrit/index/query/QueryParserTest.java
similarity index 100%
rename from gerrit-index/src/test/java/com/google/gerrit/index/query/QueryParserTest.java
rename to javatests/com/google/gerrit/index/query/QueryParserTest.java
diff --git a/javatests/com/google/gerrit/metrics/proc/BUILD b/javatests/com/google/gerrit/metrics/proc/BUILD
new file mode 100644
index 0000000..8e50cf6
--- /dev/null
+++ b/javatests/com/google/gerrit/metrics/proc/BUILD
@@ -0,0 +1,16 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "proc_tests",
+    size = "small",
+    srcs = glob(["**/*.java"]),
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/metrics/dropwizard",
+        "//lib:truth",
+        "//lib/dropwizard:dropwizard-core",
+        "//lib/guice",
+    ],
+)
diff --git a/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java b/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
rename to javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
diff --git a/javatests/com/google/gerrit/pgm/BUILD b/javatests/com/google/gerrit/pgm/BUILD
new file mode 100644
index 0000000..af0bea6
--- /dev/null
+++ b/javatests/com/google/gerrit/pgm/BUILD
@@ -0,0 +1,27 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+load("//tools/bzl:license.bzl", "license_test")
+
+junit_tests(
+    name = "pgm_tests",
+    srcs = glob(["**/*.java"]),
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/pgm",
+        "//java/com/google/gerrit/pgm/http",
+        "//java/com/google/gerrit/pgm/init",
+        "//java/com/google/gerrit/pgm/init/api",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib:junit",
+        "//lib:truth",
+        "//lib/easymock",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+)
+
+license_test(
+    name = "pgm_license_test",
+    target = "//java/com/google/gerrit/pgm",
+)
diff --git a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/http/jetty/HttpLogRedactTest.java b/javatests/com/google/gerrit/pgm/http/jetty/HttpLogRedactTest.java
similarity index 100%
rename from gerrit-pgm/src/test/java/com/google/gerrit/pgm/http/jetty/HttpLogRedactTest.java
rename to javatests/com/google/gerrit/pgm/http/jetty/HttpLogRedactTest.java
diff --git a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/InitTestCase.java b/javatests/com/google/gerrit/pgm/init/InitTestCase.java
similarity index 100%
rename from gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/InitTestCase.java
rename to javatests/com/google/gerrit/pgm/init/InitTestCase.java
diff --git a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/LibrariesTest.java b/javatests/com/google/gerrit/pgm/init/LibrariesTest.java
similarity index 100%
rename from gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/LibrariesTest.java
rename to javatests/com/google/gerrit/pgm/init/LibrariesTest.java
diff --git a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java b/javatests/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java
similarity index 100%
rename from gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java
rename to javatests/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java
diff --git a/javatests/com/google/gerrit/reviewdb/BUILD b/javatests/com/google/gerrit/reviewdb/BUILD
new file mode 100644
index 0000000..a7b9b51
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/BUILD
@@ -0,0 +1,13 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "client_tests",
+    srcs = glob(["client/**/*.java"]),
+    deps = [
+        "//java/com/google/gerrit/reviewdb:client",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:gwtorm",
+        "//lib:truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/reviewdb/client/AccountGroupTest.java b/javatests/com/google/gerrit/reviewdb/client/AccountGroupTest.java
new file mode 100644
index 0000000..18a55bf
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/client/AccountGroupTest.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.client;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.reviewdb.client.AccountGroup.UUID.fromRef;
+import static com.google.gerrit.reviewdb.client.AccountGroup.UUID.fromRefPart;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Test;
+
+public class AccountGroupTest {
+  private static final String TEST_UUID = "ccab3195282a8ce4f5014efa391e82d10f884c64";
+  private static final String TEST_SHARDED_UUID = TEST_UUID.substring(0, 2) + "/" + TEST_UUID;
+
+  @Test
+  public void auditCreationInstant() {
+    Instant instant = LocalDateTime.of(2009, Month.JUNE, 8, 19, 31).toInstant(ZoneOffset.UTC);
+    assertThat(AccountGroup.auditCreationInstantTs()).isEqualTo(Timestamp.from(instant));
+  }
+
+  @Test
+  public void parseRefName() {
+    assertThat(fromRef("refs/groups/" + TEST_SHARDED_UUID)).isEqualTo(uuid(TEST_UUID));
+    assertThat(fromRef("refs/groups/" + TEST_SHARDED_UUID + "-2"))
+        .isEqualTo(uuid(TEST_UUID + "-2"));
+    assertThat(fromRef("refs/groups/7e/7ec4775d")).isEqualTo(uuid("7ec4775d"));
+    assertThat(fromRef("refs/groups/fo/foo")).isEqualTo(uuid("foo"));
+
+    assertThat(fromRef(null)).isNull();
+    assertThat(fromRef("")).isNull();
+
+    // Missing prefix.
+    assertThat(fromRef(TEST_SHARDED_UUID)).isNull();
+
+    // Invalid shards.
+    assertThat(fromRef("refs/groups/c/" + TEST_UUID)).isNull();
+    assertThat(fromRef("refs/groups/cca/" + TEST_UUID)).isNull();
+
+    // Mismatched shard.
+    assertThat(fromRef("refs/groups/ca/" + TEST_UUID)).isNull();
+    assertThat(fromRef("refs/groups/64/" + TEST_UUID)).isNull();
+
+    // Wrong number of segments.
+    assertThat(fromRef("refs/groups/cc")).isNull();
+    assertThat(fromRef("refs/groups/" + TEST_SHARDED_UUID + "/1")).isNull();
+  }
+
+  @Test
+  public void parseRefNameParts() {
+    assertThat(fromRefPart(TEST_SHARDED_UUID)).isEqualTo(uuid(TEST_UUID));
+
+    // Mismatched shard.
+    assertThat(fromRefPart("ab/" + TEST_UUID)).isNull();
+  }
+
+  private AccountGroup.UUID uuid(String uuid) {
+    return new AccountGroup.UUID(uuid);
+  }
+}
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java b/javatests/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java
similarity index 100%
rename from gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java
rename to javatests/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountTest.java b/javatests/com/google/gerrit/reviewdb/client/AccountTest.java
similarity index 100%
rename from gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountTest.java
rename to javatests/com/google/gerrit/reviewdb/client/AccountTest.java
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/ChangeTest.java b/javatests/com/google/gerrit/reviewdb/client/ChangeTest.java
similarity index 100%
rename from gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/ChangeTest.java
rename to javatests/com/google/gerrit/reviewdb/client/ChangeTest.java
diff --git a/javatests/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java b/javatests/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
new file mode 100644
index 0000000..5e42ce0
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.client;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.testing.GerritBaseTests;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.Test;
+
+public class PatchSetApprovalTest extends GerritBaseTests {
+  @Test
+  public void keyEquality() {
+    PatchSetApproval.Key k1 =
+        new PatchSetApproval.Key(
+            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("My-Label"));
+    PatchSetApproval.Key k2 =
+        new PatchSetApproval.Key(
+            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("My-Label"));
+    PatchSetApproval.Key k3 =
+        new PatchSetApproval.Key(
+            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("Other-Label"));
+
+    assertThat(k2).isEqualTo(k1);
+    assertThat(k3).isNotEqualTo(k1);
+    assertThat(k2.hashCode()).isEqualTo(k1.hashCode());
+    assertThat(k3.hashCode()).isNotEqualTo(k1.hashCode());
+
+    Map<PatchSetApproval.Key, String> map = new HashMap<>();
+    map.put(k1, "k1");
+    map.put(k2, "k2");
+    map.put(k3, "k3");
+    assertThat(map).containsKey(k1);
+    assertThat(map).containsKey(k2);
+    assertThat(map).containsKey(k3);
+    assertThat(map).containsEntry(k1, "k2");
+    assertThat(map).containsEntry(k2, "k2");
+    assertThat(map).containsEntry(k3, "k3");
+  }
+}
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetTest.java b/javatests/com/google/gerrit/reviewdb/client/PatchSetTest.java
similarity index 100%
rename from gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetTest.java
rename to javatests/com/google/gerrit/reviewdb/client/PatchSetTest.java
diff --git a/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java b/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java
new file mode 100644
index 0000000..fa6a722
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java
@@ -0,0 +1,300 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.client;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.reviewdb.client.RefNames.parseAfterShardedRefPart;
+import static com.google.gerrit.reviewdb.client.RefNames.parseRefSuffix;
+import static com.google.gerrit.reviewdb.client.RefNames.parseShardedRefPart;
+import static com.google.gerrit.reviewdb.client.RefNames.parseShardedUuidFromRefPart;
+import static com.google.gerrit.reviewdb.client.RefNames.skipShardedRefPart;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class RefNamesTest {
+  private static final String TEST_GROUP_UUID = "ccab3195282a8ce4f5014efa391e82d10f884c64";
+  private static final String TEST_SHARDED_GROUP_UUID =
+      TEST_GROUP_UUID.substring(0, 2) + "/" + TEST_GROUP_UUID;
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  private final Account.Id accountId = new Account.Id(1011123);
+  private final Change.Id changeId = new Change.Id(67473);
+  private final PatchSet.Id psId = new PatchSet.Id(changeId, 42);
+
+  @Test
+  public void fullName() throws Exception {
+    assertThat(RefNames.fullName(RefNames.REFS_CONFIG)).isEqualTo(RefNames.REFS_CONFIG);
+    assertThat(RefNames.fullName("refs/heads/master")).isEqualTo("refs/heads/master");
+    assertThat(RefNames.fullName("master")).isEqualTo("refs/heads/master");
+    assertThat(RefNames.fullName("refs/tags/v1.0")).isEqualTo("refs/tags/v1.0");
+    assertThat(RefNames.fullName("HEAD")).isEqualTo("HEAD");
+  }
+
+  @Test
+  public void changeRefs() throws Exception {
+    String changeMetaRef = RefNames.changeMetaRef(changeId);
+    assertThat(changeMetaRef).isEqualTo("refs/changes/73/67473/meta");
+    assertThat(RefNames.isNoteDbMetaRef(changeMetaRef)).isTrue();
+
+    String robotCommentsRef = RefNames.robotCommentsRef(changeId);
+    assertThat(robotCommentsRef).isEqualTo("refs/changes/73/67473/robot-comments");
+    assertThat(RefNames.isNoteDbMetaRef(robotCommentsRef)).isTrue();
+  }
+
+  @Test
+  public void refForGroupIsSharded() throws Exception {
+    AccountGroup.UUID groupUuid = new AccountGroup.UUID("ABCDEFG");
+    String groupRef = RefNames.refsGroups(groupUuid);
+    assertThat(groupRef).isEqualTo("refs/groups/AB/ABCDEFG");
+  }
+
+  @Test
+  public void refForGroupWithUuidLessThanTwoCharsIsRejected() throws Exception {
+    AccountGroup.UUID groupUuid = new AccountGroup.UUID("A");
+    expectedException.expect(IllegalArgumentException.class);
+    RefNames.refsGroups(groupUuid);
+  }
+
+  @Test
+  public void refForDeletedGroupIsSharded() throws Exception {
+    AccountGroup.UUID groupUuid = new AccountGroup.UUID("ABCDEFG");
+    String groupRef = RefNames.refsDeletedGroups(groupUuid);
+    assertThat(groupRef).isEqualTo("refs/deleted-groups/AB/ABCDEFG");
+  }
+
+  @Test
+  public void refForDeletedGroupWithUuidLessThanTwoCharsIsRejected() throws Exception {
+    AccountGroup.UUID groupUuid = new AccountGroup.UUID("A");
+    expectedException.expect(IllegalArgumentException.class);
+    RefNames.refsDeletedGroups(groupUuid);
+  }
+
+  @Test
+  public void refsUsers() throws Exception {
+    assertThat(RefNames.refsUsers(accountId)).isEqualTo("refs/users/23/1011123");
+  }
+
+  @Test
+  public void refsDraftComments() throws Exception {
+    assertThat(RefNames.refsDraftComments(changeId, accountId))
+        .isEqualTo("refs/draft-comments/73/67473/1011123");
+  }
+
+  @Test
+  public void refsDraftCommentsPrefix() throws Exception {
+    assertThat(RefNames.refsDraftCommentsPrefix(changeId))
+        .isEqualTo("refs/draft-comments/73/67473/");
+  }
+
+  @Test
+  public void refsStarredChanges() throws Exception {
+    assertThat(RefNames.refsStarredChanges(changeId, accountId))
+        .isEqualTo("refs/starred-changes/73/67473/1011123");
+  }
+
+  @Test
+  public void refsStarredChangesPrefix() throws Exception {
+    assertThat(RefNames.refsStarredChangesPrefix(changeId))
+        .isEqualTo("refs/starred-changes/73/67473/");
+  }
+
+  @Test
+  public void refsEdit() throws Exception {
+    assertThat(RefNames.refsEdit(accountId, changeId, psId))
+        .isEqualTo("refs/users/23/1011123/edit-67473/42");
+  }
+
+  @Test
+  public void isRefsEdit() throws Exception {
+    assertThat(RefNames.isRefsEdit("refs/users/23/1011123/edit-67473/42")).isTrue();
+
+    // user ref, but no edit ref
+    assertThat(RefNames.isRefsEdit("refs/users/23/1011123")).isFalse();
+
+    // other ref
+    assertThat(RefNames.isRefsEdit("refs/heads/master")).isFalse();
+  }
+
+  @Test
+  public void isRefsUsers() throws Exception {
+    assertThat(RefNames.isRefsUsers("refs/users/23/1011123")).isTrue();
+    assertThat(RefNames.isRefsUsers("refs/users/default")).isTrue();
+    assertThat(RefNames.isRefsUsers("refs/users/23/1011123/edit-67473/42")).isTrue();
+
+    assertThat(RefNames.isRefsUsers("refs/heads/master")).isFalse();
+    assertThat(RefNames.isRefsUsers("refs/groups/" + TEST_SHARDED_GROUP_UUID)).isFalse();
+  }
+
+  @Test
+  public void isRefsGroups() throws Exception {
+    assertThat(RefNames.isRefsGroups("refs/groups/" + TEST_SHARDED_GROUP_UUID)).isTrue();
+
+    assertThat(RefNames.isRefsGroups("refs/heads/master")).isFalse();
+    assertThat(RefNames.isRefsGroups("refs/users/23/1011123")).isFalse();
+    assertThat(RefNames.isRefsGroups(RefNames.REFS_GROUPNAMES)).isFalse();
+    assertThat(RefNames.isRefsGroups("refs/deleted-groups/" + TEST_SHARDED_GROUP_UUID)).isFalse();
+  }
+
+  @Test
+  public void isRefsDeletedGroups() throws Exception {
+    assertThat(RefNames.isRefsDeletedGroups("refs/deleted-groups/" + TEST_SHARDED_GROUP_UUID))
+        .isTrue();
+
+    assertThat(RefNames.isRefsDeletedGroups("refs/heads/master")).isFalse();
+    assertThat(RefNames.isRefsDeletedGroups("refs/users/23/1011123")).isFalse();
+    assertThat(RefNames.isRefsDeletedGroups(RefNames.REFS_GROUPNAMES)).isFalse();
+    assertThat(RefNames.isRefsDeletedGroups("refs/groups/" + TEST_SHARDED_GROUP_UUID)).isFalse();
+  }
+
+  @Test
+  public void isGroupRef() throws Exception {
+    assertThat(RefNames.isGroupRef("refs/groups/" + TEST_SHARDED_GROUP_UUID)).isTrue();
+    assertThat(RefNames.isGroupRef("refs/deleted-groups/" + TEST_SHARDED_GROUP_UUID)).isTrue();
+    assertThat(RefNames.isGroupRef(RefNames.REFS_GROUPNAMES)).isTrue();
+
+    assertThat(RefNames.isGroupRef("refs/heads/master")).isFalse();
+    assertThat(RefNames.isGroupRef("refs/users/23/1011123")).isFalse();
+  }
+
+  @Test
+  public void parseShardedRefsPart() throws Exception {
+    assertThat(parseShardedRefPart("01/1")).isEqualTo(1);
+    assertThat(parseShardedRefPart("01/1-drafts")).isEqualTo(1);
+    assertThat(parseShardedRefPart("01/1-drafts/2")).isEqualTo(1);
+
+    assertThat(parseShardedRefPart(null)).isNull();
+    assertThat(parseShardedRefPart("")).isNull();
+
+    // Prefix not stripped.
+    assertThat(parseShardedRefPart("refs/users/01/1")).isNull();
+
+    // Invalid characters.
+    assertThat(parseShardedRefPart("01a/1")).isNull();
+    assertThat(parseShardedRefPart("01/a1")).isNull();
+
+    // Mismatched shard.
+    assertThat(parseShardedRefPart("01/23")).isNull();
+
+    // Shard too short.
+    assertThat(parseShardedRefPart("1/1")).isNull();
+  }
+
+  @Test
+  public void parseShardedUuidFromRefsPart() throws Exception {
+    assertThat(parseShardedUuidFromRefPart(TEST_SHARDED_GROUP_UUID)).isEqualTo(TEST_GROUP_UUID);
+    assertThat(parseShardedUuidFromRefPart(TEST_SHARDED_GROUP_UUID + "-2"))
+        .isEqualTo(TEST_GROUP_UUID + "-2");
+    assertThat(parseShardedUuidFromRefPart("7e/7ec4775d")).isEqualTo("7ec4775d");
+    assertThat(parseShardedUuidFromRefPart("fo/foo")).isEqualTo("foo");
+
+    assertThat(parseShardedUuidFromRefPart(null)).isNull();
+    assertThat(parseShardedUuidFromRefPart("")).isNull();
+
+    // Prefix not stripped.
+    assertThat(parseShardedUuidFromRefPart("refs/groups/" + TEST_SHARDED_GROUP_UUID)).isNull();
+
+    // Invalid shards.
+    assertThat(parseShardedUuidFromRefPart("c/" + TEST_GROUP_UUID)).isNull();
+    assertThat(parseShardedUuidFromRefPart("cca/" + TEST_GROUP_UUID)).isNull();
+
+    // Mismatched shard.
+    assertThat(parseShardedUuidFromRefPart("ca/" + TEST_GROUP_UUID)).isNull();
+    assertThat(parseShardedUuidFromRefPart("64/" + TEST_GROUP_UUID)).isNull();
+
+    // Wrong number of segments.
+    assertThat(parseShardedUuidFromRefPart("cc")).isNull();
+    assertThat(parseShardedUuidFromRefPart(TEST_SHARDED_GROUP_UUID + "/1")).isNull();
+  }
+
+  @Test
+  public void skipShardedRefsPart() throws Exception {
+    assertThat(skipShardedRefPart("01/1")).isEqualTo("");
+    assertThat(skipShardedRefPart("01/1/")).isEqualTo("/");
+    assertThat(skipShardedRefPart("01/1/2")).isEqualTo("/2");
+    assertThat(skipShardedRefPart("01/1-edit")).isEqualTo("-edit");
+
+    assertThat(skipShardedRefPart(null)).isNull();
+    assertThat(skipShardedRefPart("")).isNull();
+
+    // Prefix not stripped.
+    assertThat(skipShardedRefPart("refs/draft-comments/01/1/2")).isNull();
+
+    // Invalid characters.
+    assertThat(skipShardedRefPart("01a/1/2")).isNull();
+    assertThat(skipShardedRefPart("01a/a1/2")).isNull();
+
+    // Mismatched shard.
+    assertThat(skipShardedRefPart("01/23/2")).isNull();
+
+    // Shard too short.
+    assertThat(skipShardedRefPart("1/1")).isNull();
+  }
+
+  @Test
+  public void parseAfterShardedRefsPart() throws Exception {
+    assertThat(parseAfterShardedRefPart("01/1/2")).isEqualTo(2);
+    assertThat(parseAfterShardedRefPart("01/1/2/4")).isEqualTo(2);
+    assertThat(parseAfterShardedRefPart("01/1/2-edit")).isEqualTo(2);
+
+    assertThat(parseAfterShardedRefPart(null)).isNull();
+    assertThat(parseAfterShardedRefPart("")).isNull();
+
+    // No ID after sharded ref part
+    assertThat(parseAfterShardedRefPart("01/1")).isNull();
+    assertThat(parseAfterShardedRefPart("01/1/")).isNull();
+    assertThat(parseAfterShardedRefPart("01/1/a")).isNull();
+
+    // Prefix not stripped.
+    assertThat(parseAfterShardedRefPart("refs/draft-comments/01/1/2")).isNull();
+
+    // Invalid characters.
+    assertThat(parseAfterShardedRefPart("01a/1/2")).isNull();
+    assertThat(parseAfterShardedRefPart("01a/a1/2")).isNull();
+
+    // Mismatched shard.
+    assertThat(parseAfterShardedRefPart("01/23/2")).isNull();
+
+    // Shard too short.
+    assertThat(parseAfterShardedRefPart("1/1")).isNull();
+  }
+
+  @Test
+  public void testParseRefSuffix() throws Exception {
+    assertThat(parseRefSuffix("1/2/34")).isEqualTo(34);
+    assertThat(parseRefSuffix("/34")).isEqualTo(34);
+
+    assertThat(parseRefSuffix(null)).isNull();
+    assertThat(parseRefSuffix("")).isNull();
+    assertThat(parseRefSuffix("34")).isNull();
+    assertThat(parseRefSuffix("12/ab")).isNull();
+    assertThat(parseRefSuffix("12/a4")).isNull();
+    assertThat(parseRefSuffix("12/4a")).isNull();
+    assertThat(parseRefSuffix("a4")).isNull();
+    assertThat(parseRefSuffix("4a")).isNull();
+  }
+
+  @Test
+  public void shard() throws Exception {
+    assertThat(RefNames.shard(1011123)).isEqualTo("23/1011123");
+    assertThat(RefNames.shard(537)).isEqualTo("37/537");
+    assertThat(RefNames.shard(12)).isEqualTo("12/12");
+    assertThat(RefNames.shard(0)).isEqualTo("00/0");
+    assertThat(RefNames.shard(1)).isEqualTo("01/1");
+    assertThat(RefNames.shard(-1)).isNull();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
new file mode 100644
index 0000000..a228ed6
--- /dev/null
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -0,0 +1,59 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+CUSTOM_TRUTH_SUBJECTS = glob([
+    "**/*Subject.java",
+])
+
+java_library(
+    name = "custom-truth-subjects",
+    testonly = 1,
+    srcs = CUSTOM_TRUTH_SUBJECTS,
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/truth",
+        "//lib:truth",
+    ],
+)
+
+junit_tests(
+    name = "server_tests",
+    size = "large",
+    srcs = glob(
+        ["**/*.java"],
+        exclude = CUSTOM_TRUTH_SUBJECTS,
+    ),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/server"],
+    visibility = ["//visibility:public"],
+    runtime_deps = [
+        "//lib/bouncycastle:bcprov",
+    ],
+    deps = [
+        ":custom-truth-subjects",
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/extensions/common/testing:common-test-util",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/org/eclipse/jgit:server",
+        "//lib:grappa",
+        "//lib:gson",
+        "//lib:guava-retrying",
+        "//lib:gwtorm",
+        "//lib:truth-java8-extension",
+        "//lib/commons:codec",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+)
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/ChangeUtilTest.java b/javatests/com/google/gerrit/server/ChangeUtilTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/ChangeUtilTest.java
rename to javatests/com/google/gerrit/server/ChangeUtilTest.java
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
new file mode 100644
index 0000000..1a76dac
--- /dev/null
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -0,0 +1,126 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.inject.Scopes.SINGLETON;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.FakeRealm;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.DisableReverseDnsLookup;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.FakeAccountCache;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(ConfigSuite.class)
+public class IdentifiedUserTest {
+  @ConfigSuite.Parameter public Config config;
+
+  private IdentifiedUser identifiedUser;
+
+  @Inject private IdentifiedUser.GenericFactory identifiedUserFactory;
+
+  private static final String[] TEST_CASES = {
+    "",
+    "FirstName.LastName@Corporation.com",
+    "!#$%&'+-/=.?^`{|}~@[IPv6:0123:4567:89AB:CDEF:0123:4567:89AB:CDEF]",
+  };
+
+  @Before
+  public void setUp() throws Exception {
+    final FakeAccountCache accountCache = new FakeAccountCache();
+    final Realm mockRealm =
+        new FakeRealm() {
+          HashSet<String> emails = new HashSet<>(Arrays.asList(TEST_CASES));
+
+          @Override
+          public boolean hasEmailAddress(IdentifiedUser who, String email) {
+            return emails.contains(email);
+          }
+
+          @Override
+          public Set<String> getEmailAddresses(IdentifiedUser who) {
+            return emails;
+          }
+        };
+
+    AbstractModule mod =
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(Boolean.class)
+                .annotatedWith(DisableReverseDnsLookup.class)
+                .toInstance(Boolean.FALSE);
+            bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(config);
+            bind(String.class)
+                .annotatedWith(AnonymousCowardName.class)
+                .toProvider(AnonymousCowardNameProvider.class);
+            bind(String.class)
+                .annotatedWith(CanonicalWebUrl.class)
+                .toInstance("http://localhost:8080/");
+            bind(AccountCache.class).toInstance(accountCache);
+            bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
+            bind(Realm.class).toInstance(mockRealm);
+          }
+        };
+
+    Injector injector = Guice.createInjector(mod);
+    injector.injectMembers(this);
+
+    Account account = new Account(new Account.Id(1), TimeUtil.nowTs());
+    Account.Id ownerId = account.getId();
+
+    identifiedUser = identifiedUserFactory.create(ownerId);
+
+    /* Trigger identifiedUser to load the email addresses from mockRealm */
+    identifiedUser.getEmailAddresses();
+  }
+
+  @Test
+  public void emailsExistence() {
+    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[0])).isTrue();
+    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1].toLowerCase())).isTrue();
+    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1])).isTrue();
+    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1].toUpperCase())).isTrue();
+    /* assert again to test cached email address by IdentifiedUser.validEmails */
+    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1])).isTrue();
+
+    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[2])).isTrue();
+    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[2].toLowerCase())).isTrue();
+
+    assertThat(identifiedUser.hasEmailAddress("non-exist@email.com")).isFalse();
+    /* assert again to test cached email address by IdentifiedUser.invalidEmails */
+    assertThat(identifiedUser.hasEmailAddress("non-exist@email.com")).isFalse();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/account/AuthorizedKeysTest.java b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/account/AuthorizedKeysTest.java
rename to javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
diff --git a/javatests/com/google/gerrit/server/account/GroupUUIDTest.java b/javatests/com/google/gerrit/server/account/GroupUUIDTest.java
new file mode 100644
index 0000000..3b72b08
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/GroupUUIDTest.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.junit.Test;
+
+public class GroupUUIDTest {
+  @Test
+  public void createdUuidsForSameInputShouldBeDifferent() {
+    String groupName = "Users";
+    PersonIdent personIdent = new PersonIdent("John", "john@example.com");
+    AccountGroup.UUID uuid1 = GroupUUID.make(groupName, personIdent);
+    AccountGroup.UUID uuid2 = GroupUUID.make(groupName, personIdent);
+    assertThat(uuid2).isNotEqualTo(uuid1);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/account/HashedPasswordTest.java b/javatests/com/google/gerrit/server/account/HashedPasswordTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/account/HashedPasswordTest.java
rename to javatests/com/google/gerrit/server/account/HashedPasswordTest.java
diff --git a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
new file mode 100644
index 0000000..91cc2b7
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
@@ -0,0 +1,138 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static org.easymock.EasyMock.anyObject;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.createNiceMock;
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.getCurrentArguments;
+import static org.easymock.EasyMock.not;
+import static org.easymock.EasyMock.replay;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.testing.GerritBaseTests;
+import java.util.Set;
+import org.easymock.IAnswer;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+public class UniversalGroupBackendTest extends GerritBaseTests {
+  private static final AccountGroup.UUID OTHER_UUID = new AccountGroup.UUID("other");
+
+  private UniversalGroupBackend backend;
+  private IdentifiedUser user;
+
+  private DynamicSet<GroupBackend> backends;
+
+  @Before
+  public void setup() {
+    user = createNiceMock(IdentifiedUser.class);
+    replay(user);
+    backends = new DynamicSet<>();
+    backends.add(new SystemGroupBackend(new Config()));
+    backend = new UniversalGroupBackend(backends);
+  }
+
+  @Test
+  public void handles() {
+    assertTrue(backend.handles(ANONYMOUS_USERS));
+    assertTrue(backend.handles(PROJECT_OWNERS));
+    assertFalse(backend.handles(OTHER_UUID));
+  }
+
+  @Test
+  public void get() {
+    assertEquals("Registered Users", backend.get(REGISTERED_USERS).getName());
+    assertEquals("Project Owners", backend.get(PROJECT_OWNERS).getName());
+    assertNull(backend.get(OTHER_UUID));
+  }
+
+  @Test
+  public void suggest() {
+    assertTrue(backend.suggest("X", null).isEmpty());
+    assertEquals(1, backend.suggest("project", null).size());
+    assertEquals(1, backend.suggest("reg", null).size());
+  }
+
+  @Test
+  public void sytemGroupMemberships() {
+    GroupMembership checker = backend.membershipsOf(user);
+    assertTrue(checker.contains(REGISTERED_USERS));
+    assertFalse(checker.contains(OTHER_UUID));
+    assertFalse(checker.contains(PROJECT_OWNERS));
+  }
+
+  @Test
+  public void knownGroups() {
+    GroupMembership checker = backend.membershipsOf(user);
+    Set<UUID> knownGroups = checker.getKnownGroups();
+    assertEquals(2, knownGroups.size());
+    assertTrue(knownGroups.contains(REGISTERED_USERS));
+    assertTrue(knownGroups.contains(ANONYMOUS_USERS));
+  }
+
+  @Test
+  public void otherMemberships() {
+    final AccountGroup.UUID handled = new AccountGroup.UUID("handled");
+    final AccountGroup.UUID notHandled = new AccountGroup.UUID("not handled");
+    final IdentifiedUser member = createNiceMock(IdentifiedUser.class);
+    final IdentifiedUser notMember = createNiceMock(IdentifiedUser.class);
+
+    GroupBackend backend = createMock(GroupBackend.class);
+    expect(backend.handles(handled)).andStubReturn(true);
+    expect(backend.handles(not(eq(handled)))).andStubReturn(false);
+    expect(backend.membershipsOf(anyObject(IdentifiedUser.class)))
+        .andStubAnswer(
+            new IAnswer<GroupMembership>() {
+              @Override
+              public GroupMembership answer() throws Throwable {
+                Object[] args = getCurrentArguments();
+                GroupMembership membership = createMock(GroupMembership.class);
+                expect(membership.contains(eq(handled))).andStubReturn(args[0] == member);
+                expect(membership.contains(not(eq(notHandled)))).andStubReturn(false);
+                replay(membership);
+                return membership;
+              }
+            });
+    replay(member, notMember, backend);
+
+    backends = new DynamicSet<>();
+    backends.add(backend);
+    backend = new UniversalGroupBackend(backends);
+
+    GroupMembership checker = backend.membershipsOf(member);
+    assertFalse(checker.contains(REGISTERED_USERS));
+    assertFalse(checker.contains(OTHER_UUID));
+    assertTrue(checker.contains(handled));
+    assertFalse(checker.contains(notHandled));
+    checker = backend.membershipsOf(notMember);
+    assertFalse(checker.contains(handled));
+    assertFalse(checker.contains(notHandled));
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/account/WatchConfigTest.java b/javatests/com/google/gerrit/server/account/WatchConfigTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/account/WatchConfigTest.java
rename to javatests/com/google/gerrit/server/account/WatchConfigTest.java
diff --git a/javatests/com/google/gerrit/server/cache/h2/BUILD b/javatests/com/google/gerrit/server/cache/h2/BUILD
new file mode 100644
index 0000000..0ac4aef
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/h2/BUILD
@@ -0,0 +1,14 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "tests",
+    srcs = glob(["**/*.java"]),
+    deps = [
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/h2",
+        "//lib:guava",
+        "//lib:h2",
+        "//lib:junit",
+        "//lib/guice",
+    ],
+)
diff --git a/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
similarity index 100%
rename from gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java
rename to javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/change/HashtagsTest.java b/javatests/com/google/gerrit/server/change/HashtagsTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/change/HashtagsTest.java
rename to javatests/com/google/gerrit/server/change/HashtagsTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/change/IncludedInResolverTest.java b/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/change/IncludedInResolverTest.java
rename to javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
diff --git a/javatests/com/google/gerrit/server/change/WalkSorterTest.java b/javatests/com/google/gerrit/server/change/WalkSorterTest.java
new file mode 100644
index 0000000..189dfbc
--- /dev/null
+++ b/javatests/com/google/gerrit/server/change/WalkSorterTest.java
@@ -0,0 +1,374 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.collect.Collections2.permutations;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.change.WalkSorter.PatchSetData;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testing.TestChanges;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+public class WalkSorterTest extends GerritBaseTests {
+  private Account.Id userId;
+  private InMemoryRepositoryManager repoManager;
+
+  @Before
+  public void setUp() {
+    userId = new Account.Id(1);
+    repoManager = new InMemoryRepositoryManager();
+  }
+
+  @Test
+  public void seriesOfChanges() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit c1_1 = p.commit().create();
+    RevCommit c2_1 = p.commit().parent(c1_1).create();
+    RevCommit c3_1 = p.commit().parent(c2_1).create();
+
+    ChangeData cd1 = newChange(p, c1_1);
+    ChangeData cd2 = newChange(p, c2_1);
+    ChangeData cd3 = newChange(p, c3_1);
+
+    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3);
+    WalkSorter sorter = new WalkSorter(repoManager);
+
+    assertSorted(
+        sorter,
+        changes,
+        ImmutableList.of(
+            patchSetData(cd3, c3_1), patchSetData(cd2, c2_1), patchSetData(cd1, c1_1)));
+
+    // Add new patch sets whose commits are in reverse order, so output is in
+    // reverse order.
+    RevCommit c3_2 = p.commit().create();
+    RevCommit c2_2 = p.commit().parent(c3_2).create();
+    RevCommit c1_2 = p.commit().parent(c2_2).create();
+
+    addPatchSet(cd1, c1_2);
+    addPatchSet(cd2, c2_2);
+    addPatchSet(cd3, c3_2);
+
+    assertSorted(
+        sorter,
+        changes,
+        ImmutableList.of(
+            patchSetData(cd1, c1_2), patchSetData(cd2, c2_2), patchSetData(cd3, c3_2)));
+  }
+
+  @Test
+  public void subsetOfSeriesOfChanges() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit c1_1 = p.commit().create();
+    RevCommit c2_1 = p.commit().parent(c1_1).create();
+    RevCommit c3_1 = p.commit().parent(c2_1).create();
+
+    ChangeData cd1 = newChange(p, c1_1);
+    ChangeData cd3 = newChange(p, c3_1);
+
+    List<ChangeData> changes = ImmutableList.of(cd1, cd3);
+    WalkSorter sorter = new WalkSorter(repoManager);
+
+    assertSorted(
+        sorter, changes, ImmutableList.of(patchSetData(cd3, c3_1), patchSetData(cd1, c1_1)));
+  }
+
+  @Test
+  public void seriesOfChangesAtSameTimestamp() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit c0 = p.commit().tick(0).create();
+    RevCommit c1 = p.commit().tick(0).parent(c0).create();
+    RevCommit c2 = p.commit().tick(0).parent(c1).create();
+    RevCommit c3 = p.commit().tick(0).parent(c2).create();
+    RevCommit c4 = p.commit().tick(0).parent(c3).create();
+
+    RevWalk rw = p.getRevWalk();
+    rw.parseCommit(c1);
+    assertThat(rw.parseCommit(c2).getCommitTime()).isEqualTo(c1.getCommitTime());
+    assertThat(rw.parseCommit(c3).getCommitTime()).isEqualTo(c1.getCommitTime());
+    assertThat(rw.parseCommit(c4).getCommitTime()).isEqualTo(c1.getCommitTime());
+
+    ChangeData cd1 = newChange(p, c1);
+    ChangeData cd2 = newChange(p, c2);
+    ChangeData cd3 = newChange(p, c3);
+    ChangeData cd4 = newChange(p, c4);
+
+    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
+    WalkSorter sorter = new WalkSorter(repoManager);
+
+    assertSorted(
+        sorter,
+        changes,
+        ImmutableList.of(
+            patchSetData(cd4, c4),
+            patchSetData(cd3, c3),
+            patchSetData(cd2, c2),
+            patchSetData(cd1, c1)));
+  }
+
+  @Test
+  public void seriesOfChangesWithReverseTimestamps() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit c0 = p.commit().tick(-1).create();
+    RevCommit c1 = p.commit().tick(-1).parent(c0).create();
+    RevCommit c2 = p.commit().tick(-1).parent(c1).create();
+    RevCommit c3 = p.commit().tick(-1).parent(c2).create();
+    RevCommit c4 = p.commit().tick(-1).parent(c3).create();
+
+    RevWalk rw = p.getRevWalk();
+    rw.parseCommit(c1);
+    assertThat(rw.parseCommit(c2).getCommitTime()).isLessThan(c1.getCommitTime());
+    assertThat(rw.parseCommit(c3).getCommitTime()).isLessThan(c2.getCommitTime());
+    assertThat(rw.parseCommit(c4).getCommitTime()).isLessThan(c3.getCommitTime());
+
+    ChangeData cd1 = newChange(p, c1);
+    ChangeData cd2 = newChange(p, c2);
+    ChangeData cd3 = newChange(p, c3);
+    ChangeData cd4 = newChange(p, c4);
+
+    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
+    WalkSorter sorter = new WalkSorter(repoManager);
+
+    assertSorted(
+        sorter,
+        changes,
+        ImmutableList.of(
+            patchSetData(cd4, c4),
+            patchSetData(cd3, c3),
+            patchSetData(cd2, c2),
+            patchSetData(cd1, c1)));
+  }
+
+  @Test
+  public void subsetOfSeriesOfChangesWithReverseTimestamps() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit c0 = p.commit().tick(-1).create();
+    RevCommit c1 = p.commit().tick(-1).parent(c0).create();
+    RevCommit c2 = p.commit().tick(-1).parent(c1).create();
+    RevCommit c3 = p.commit().tick(-1).parent(c2).create();
+    RevCommit c4 = p.commit().tick(-1).parent(c3).create();
+
+    RevWalk rw = p.getRevWalk();
+    rw.parseCommit(c1);
+    assertThat(rw.parseCommit(c2).getCommitTime()).isLessThan(c1.getCommitTime());
+    assertThat(rw.parseCommit(c3).getCommitTime()).isLessThan(c2.getCommitTime());
+    assertThat(rw.parseCommit(c4).getCommitTime()).isLessThan(c3.getCommitTime());
+
+    ChangeData cd1 = newChange(p, c1);
+    ChangeData cd2 = newChange(p, c2);
+    ChangeData cd4 = newChange(p, c4);
+
+    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd4);
+    WalkSorter sorter = new WalkSorter(repoManager);
+    List<PatchSetData> expected =
+        ImmutableList.of(patchSetData(cd4, c4), patchSetData(cd2, c2), patchSetData(cd1, c1));
+
+    for (List<ChangeData> list : permutations(changes)) {
+      // Not inOrder(); since child of c2 is missing, partial topo sort isn't
+      // guaranteed to work.
+      assertThat(sorter.sort(list)).containsExactlyElementsIn(expected);
+    }
+  }
+
+  @Test
+  public void seriesOfChangesAtSameTimestampWithRootCommit() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit c1 = p.commit().tick(0).create();
+    RevCommit c2 = p.commit().tick(0).parent(c1).create();
+    RevCommit c3 = p.commit().tick(0).parent(c2).create();
+    RevCommit c4 = p.commit().tick(0).parent(c3).create();
+
+    RevWalk rw = p.getRevWalk();
+    rw.parseCommit(c1);
+    assertThat(rw.parseCommit(c2).getCommitTime()).isEqualTo(c1.getCommitTime());
+    assertThat(rw.parseCommit(c3).getCommitTime()).isEqualTo(c1.getCommitTime());
+    assertThat(rw.parseCommit(c4).getCommitTime()).isEqualTo(c1.getCommitTime());
+
+    ChangeData cd1 = newChange(p, c1);
+    ChangeData cd2 = newChange(p, c2);
+    ChangeData cd3 = newChange(p, c3);
+    ChangeData cd4 = newChange(p, c4);
+
+    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
+    WalkSorter sorter = new WalkSorter(repoManager);
+
+    assertSorted(
+        sorter,
+        changes,
+        ImmutableList.of(
+            patchSetData(cd4, c4),
+            patchSetData(cd3, c3),
+            patchSetData(cd2, c2),
+            patchSetData(cd1, c1)));
+  }
+
+  @Test
+  public void projectsSortedByName() throws Exception {
+    TestRepository<Repo> pa = newRepo("a");
+    TestRepository<Repo> pb = newRepo("b");
+    RevCommit c1 = pa.commit().create();
+    RevCommit c2 = pb.commit().create();
+    RevCommit c3 = pa.commit().parent(c1).create();
+    RevCommit c4 = pb.commit().parent(c2).create();
+
+    ChangeData cd1 = newChange(pa, c1);
+    ChangeData cd2 = newChange(pb, c2);
+    ChangeData cd3 = newChange(pa, c3);
+    ChangeData cd4 = newChange(pb, c4);
+
+    assertSorted(
+        new WalkSorter(repoManager),
+        ImmutableList.of(cd1, cd2, cd3, cd4),
+        ImmutableList.of(
+            patchSetData(cd3, c3),
+            patchSetData(cd1, c1),
+            patchSetData(cd4, c4),
+            patchSetData(cd2, c2)));
+  }
+
+  @Test
+  public void restrictToPatchSets() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit c1_1 = p.commit().create();
+    RevCommit c2_1 = p.commit().parent(c1_1).create();
+
+    ChangeData cd1 = newChange(p, c1_1);
+    ChangeData cd2 = newChange(p, c2_1);
+
+    // Add new patch sets whose commits are in reverse order.
+    RevCommit c2_2 = p.commit().create();
+    RevCommit c1_2 = p.commit().parent(c2_2).create();
+
+    addPatchSet(cd1, c1_2);
+    addPatchSet(cd2, c2_2);
+
+    List<ChangeData> changes = ImmutableList.of(cd1, cd2);
+    WalkSorter sorter = new WalkSorter(repoManager);
+
+    assertSorted(
+        sorter, changes, ImmutableList.of(patchSetData(cd1, c1_2), patchSetData(cd2, c2_2)));
+
+    // If we restrict to PS1 of each change, the sorter uses that commit.
+    sorter.includePatchSets(
+        ImmutableSet.of(new PatchSet.Id(cd1.getId(), 1), new PatchSet.Id(cd2.getId(), 1)));
+    assertSorted(
+        sorter, changes, ImmutableList.of(patchSetData(cd2, 1, c2_1), patchSetData(cd1, 1, c1_1)));
+  }
+
+  @Test
+  public void restrictToPatchSetsOmittingWholeProject() throws Exception {
+    TestRepository<Repo> pa = newRepo("a");
+    TestRepository<Repo> pb = newRepo("b");
+    RevCommit c1 = pa.commit().create();
+    RevCommit c2 = pa.commit().create();
+
+    ChangeData cd1 = newChange(pa, c1);
+    ChangeData cd2 = newChange(pb, c2);
+
+    List<ChangeData> changes = ImmutableList.of(cd1, cd2);
+    WalkSorter sorter =
+        new WalkSorter(repoManager)
+            .includePatchSets(ImmutableSet.of(cd1.currentPatchSet().getId()));
+
+    assertSorted(sorter, changes, ImmutableList.of(patchSetData(cd1, c1)));
+  }
+
+  @Test
+  public void retainBody() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit c = p.commit().message("message").create();
+    ChangeData cd = newChange(p, c);
+
+    List<ChangeData> changes = ImmutableList.of(cd);
+    RevCommit actual =
+        new WalkSorter(repoManager).setRetainBody(true).sort(changes).iterator().next().commit();
+    assertThat(actual.getRawBuffer()).isNotNull();
+    assertThat(actual.getShortMessage()).isEqualTo("message");
+
+    actual =
+        new WalkSorter(repoManager).setRetainBody(false).sort(changes).iterator().next().commit();
+    assertThat(actual.getRawBuffer()).isNull();
+  }
+
+  @Test
+  public void oneChange() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit c = p.commit().create();
+    ChangeData cd = newChange(p, c);
+
+    List<ChangeData> changes = ImmutableList.of(cd);
+    WalkSorter sorter = new WalkSorter(repoManager);
+
+    assertSorted(sorter, changes, ImmutableList.of(patchSetData(cd, c)));
+  }
+
+  private ChangeData newChange(TestRepository<Repo> tr, ObjectId id) throws Exception {
+    Project.NameKey project = tr.getRepository().getDescription().getProject();
+    Change c = TestChanges.newChange(project, userId);
+    ChangeData cd = ChangeData.createForTest(project, c.getId(), 1);
+    cd.setChange(c);
+    cd.currentPatchSet().setRevision(new RevId(id.name()));
+    cd.setPatchSets(ImmutableList.of(cd.currentPatchSet()));
+    return cd;
+  }
+
+  private PatchSet addPatchSet(ChangeData cd, ObjectId id) throws Exception {
+    TestChanges.incrementPatchSet(cd.change());
+    PatchSet ps = new PatchSet(cd.change().currentPatchSetId());
+    ps.setRevision(new RevId(id.name()));
+    List<PatchSet> patchSets = new ArrayList<>(cd.patchSets());
+    patchSets.add(ps);
+    cd.setPatchSets(patchSets);
+    return ps;
+  }
+
+  private TestRepository<Repo> newRepo(String name) throws Exception {
+    return new TestRepository<>(repoManager.createRepository(new Project.NameKey(name)));
+  }
+
+  private static PatchSetData patchSetData(ChangeData cd, RevCommit commit) throws Exception {
+    return PatchSetData.create(cd, cd.currentPatchSet(), commit);
+  }
+
+  private static PatchSetData patchSetData(ChangeData cd, int psId, RevCommit commit)
+      throws Exception {
+    return PatchSetData.create(cd, cd.patchSet(new PatchSet.Id(cd.getId(), psId)), commit);
+  }
+
+  private static void assertSorted(
+      WalkSorter sorter, List<ChangeData> changes, List<PatchSetData> expected) throws Exception {
+    for (List<ChangeData> list : permutations(changes)) {
+      assertThat(sorter.sort(list)).containsExactlyElementsIn(expected).inOrder();
+    }
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/ConfigUtilTest.java b/javatests/com/google/gerrit/server/config/ConfigUtilTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/config/ConfigUtilTest.java
rename to javatests/com/google/gerrit/server/config/ConfigUtilTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/GitwebConfigTest.java b/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/config/GitwebConfigTest.java
rename to javatests/com/google/gerrit/server/config/GitwebConfigTest.java
diff --git a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
new file mode 100644
index 0000000..bcba665
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.server.restapi.config.ListCapabilities;
+import com.google.gerrit.server.restapi.config.ListCapabilities.CapabilityInfo;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ListCapabilitiesTest {
+  private Injector injector;
+
+  @Before
+  public void setUp() throws Exception {
+    AbstractModule mod =
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            DynamicMap.mapOf(binder(), CapabilityDefinition.class);
+            bind(CapabilityDefinition.class)
+                .annotatedWith(Exports.named("printHello"))
+                .toInstance(
+                    new CapabilityDefinition() {
+                      @Override
+                      public String getDescription() {
+                        return "Print Hello";
+                      }
+                    });
+          }
+        };
+    injector = Guice.createInjector(mod);
+  }
+
+  @Test
+  public void list() throws Exception {
+    Map<String, CapabilityInfo> m =
+        injector.getInstance(ListCapabilities.class).apply(new ConfigResource());
+    for (String id : GlobalCapability.getAllNames()) {
+      assertTrue("contains " + id, m.containsKey(id));
+      assertEquals(id, m.get(id).id);
+      assertNotNull(id + " has name", m.get(id).name);
+    }
+
+    String pluginCapability = "gerrit-printHello";
+    assertTrue("contains " + pluginCapability, m.containsKey(pluginCapability));
+    assertEquals(pluginCapability, m.get(pluginCapability).id);
+    assertEquals("Print Hello", m.get(pluginCapability).name);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
rename to javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
diff --git a/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java b/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
new file mode 100644
index 0000000..0423a53
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static java.util.concurrent.TimeUnit.DAYS;
+import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+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.junit.Test;
+
+public class ScheduleConfigTest {
+
+  // Friday June 13, 2014 10:00 UTC
+  private static final ZonedDateTime NOW =
+      LocalDateTime.of(2014, Month.JUNE, 13, 10, 0, 0).atOffset(ZoneOffset.UTC).toZonedDateTime();
+
+  @Test
+  public void initialDelay() throws Exception {
+    assertEquals(ms(1, HOURS), initialDelay("11:00", "1h"));
+    assertEquals(ms(30, MINUTES), initialDelay("05:30", "1h"));
+    assertEquals(ms(30, MINUTES), initialDelay("09:30", "1h"));
+    assertEquals(ms(30, MINUTES), initialDelay("13:30", "1h"));
+    assertEquals(ms(59, MINUTES), initialDelay("13:59", "1h"));
+
+    assertEquals(ms(1, HOURS), initialDelay("11:00", "1d"));
+    assertEquals(ms(19, HOURS) + ms(30, MINUTES), initialDelay("05:30", "1d"));
+
+    assertEquals(ms(1, HOURS), initialDelay("11:00", "1w"));
+    assertEquals(ms(7, DAYS) - ms(4, HOURS) - ms(30, MINUTES), initialDelay("05:30", "1w"));
+
+    assertEquals(ms(3, DAYS) + ms(1, HOURS), initialDelay("Mon 11:00", "1w"));
+    assertEquals(ms(1, HOURS), initialDelay("Fri 11:00", "1w"));
+
+    assertEquals(ms(1, HOURS), initialDelay("Mon 11:00", "1d"));
+    assertEquals(ms(23, HOURS), initialDelay("Mon 09:00", "1d"));
+    assertEquals(ms(1, DAYS), initialDelay("Mon 10:00", "1d"));
+    assertEquals(ms(1, DAYS), initialDelay("Mon 10:00", "1d"));
+  }
+
+  @Test
+  public void customKeys() {
+    Config rc = new Config();
+    rc.setString("a", "b", "i", "1h");
+    rc.setString("a", "b", "s", "01:00");
+
+    ScheduleConfig s = new ScheduleConfig(rc, "a", "b", "i", "s", NOW);
+    assertEquals(ms(1, HOURS), s.getInterval());
+    assertEquals(ms(1, HOURS), s.getInitialDelay());
+
+    s = new ScheduleConfig(rc, "a", "b", "myInterval", "myStart", NOW);
+    assertEquals(s.getInterval(), ScheduleConfig.MISSING_CONFIG);
+    assertEquals(s.getInitialDelay(), ScheduleConfig.MISSING_CONFIG);
+  }
+
+  private static long initialDelay(String startTime, String interval) {
+    return new ScheduleConfig(config(startTime, interval), "section", "subsection", NOW)
+        .getInitialDelay();
+  }
+
+  private static Config config(String startTime, String interval) {
+    Config rc = new Config();
+    rc.setString("section", "subsection", "startTime", startTime);
+    rc.setString("section", "subsection", "interval", interval);
+    return rc;
+  }
+
+  private static long ms(int cnt, TimeUnit unit) {
+    return MILLISECONDS.convert(cnt, unit);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/SitePathsTest.java b/javatests/com/google/gerrit/server/config/SitePathsTest.java
new file mode 100644
index 0000000..058a497
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/SitePathsTest.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.extensions.common.testing.PathSubject;
+import com.google.gerrit.server.util.HostPlatform;
+import com.google.gerrit.testing.GerritBaseTests;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.NotDirectoryException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.Test;
+
+public class SitePathsTest extends GerritBaseTests {
+  @Test
+  public void create_NotExisting() throws IOException {
+    final Path root = random();
+    final SitePaths site = new SitePaths(root);
+    assertThat(site.isNew).isTrue();
+    PathSubject.assertThat(site.site_path).isEqualTo(root);
+    PathSubject.assertThat(site.etc_dir).isEqualTo(root.resolve("etc"));
+  }
+
+  @Test
+  public void create_Empty() throws IOException {
+    final Path root = random();
+    try {
+      Files.createDirectory(root);
+
+      final SitePaths site = new SitePaths(root);
+      assertThat(site.isNew).isTrue();
+      PathSubject.assertThat(site.site_path).isEqualTo(root);
+    } finally {
+      Files.delete(root);
+    }
+  }
+
+  @Test
+  public void create_NonEmpty() throws IOException {
+    final Path root = random();
+    final Path txt = root.resolve("test.txt");
+    try {
+      Files.createDirectory(root);
+      Files.createFile(txt);
+
+      final SitePaths site = new SitePaths(root);
+      assertThat(site.isNew).isFalse();
+      PathSubject.assertThat(site.site_path).isEqualTo(root);
+    } finally {
+      Files.delete(txt);
+      Files.delete(root);
+    }
+  }
+
+  @Test
+  public void create_NotDirectory() throws IOException {
+    final Path root = random();
+    try {
+      Files.createFile(root);
+      exception.expect(NotDirectoryException.class);
+      new SitePaths(root);
+    } finally {
+      Files.delete(root);
+    }
+  }
+
+  @Test
+  public void resolve() throws IOException {
+    final Path root = random();
+    final SitePaths site = new SitePaths(root);
+
+    PathSubject.assertThat(site.resolve(null)).isNull();
+    PathSubject.assertThat(site.resolve("")).isNull();
+
+    PathSubject.assertThat(site.resolve("a")).isNotNull();
+    PathSubject.assertThat(site.resolve("a"))
+        .isEqualTo(root.resolve("a").toAbsolutePath().normalize());
+
+    final String pfx = HostPlatform.isWin32() ? "C:/" : "/";
+    PathSubject.assertThat(site.resolve(pfx + "a")).isNotNull();
+    PathSubject.assertThat(site.resolve(pfx + "a")).isEqualTo(Paths.get(pfx + "a"));
+  }
+
+  private static Path random() throws IOException {
+    Path tmp = Files.createTempFile("gerrit_test_", "_site");
+    Files.deleteIfExists(tmp);
+    return tmp;
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/edit/ChangeEditTest.java b/javatests/com/google/gerrit/server/edit/ChangeEditTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/edit/ChangeEditTest.java
rename to javatests/com/google/gerrit/server/edit/ChangeEditTest.java
diff --git a/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java b/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
new file mode 100644
index 0000000..574c795
--- /dev/null
+++ b/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.edit.tree;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.io.CharStreams;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.restapi.RawInput;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+public class ChangeFileContentModificationSubject
+    extends Subject<ChangeFileContentModificationSubject, ChangeFileContentModification> {
+
+  public static ChangeFileContentModificationSubject assertThat(
+      ChangeFileContentModification modification) {
+    return assertAbout(ChangeFileContentModificationSubject::new).that(modification);
+  }
+
+  private ChangeFileContentModificationSubject(
+      FailureMetadata failureMetadata, ChangeFileContentModification modification) {
+    super(failureMetadata, modification);
+  }
+
+  public StringSubject filePath() {
+    isNotNull();
+    return Truth.assertThat(actual().getFilePath()).named("filePath");
+  }
+
+  public StringSubject newContent() throws IOException {
+    isNotNull();
+    RawInput newContent = actual().getNewContent();
+    Truth.assertThat(newContent).named("newContent").isNotNull();
+    String contentString =
+        CharStreams.toString(
+            new InputStreamReader(newContent.getInputStream(), StandardCharsets.UTF_8));
+    return Truth.assertThat(contentString).named("newContent");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java b/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
new file mode 100644
index 0000000..59ee2b7
--- /dev/null
+++ b/javatests/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.edit.tree;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.gerrit.truth.ListSubject;
+import java.util.List;
+
+public class TreeModificationSubject extends Subject<TreeModificationSubject, TreeModification> {
+
+  public static TreeModificationSubject assertThat(TreeModification treeModification) {
+    return assertAbout(TreeModificationSubject::new).that(treeModification);
+  }
+
+  public static ListSubject<TreeModificationSubject, TreeModification> assertThatList(
+      List<TreeModification> treeModifications) {
+    return ListSubject.assertThat(treeModifications, TreeModificationSubject::assertThat)
+        .named("treeModifications");
+  }
+
+  private TreeModificationSubject(
+      FailureMetadata failureMetadata, TreeModification treeModification) {
+    super(failureMetadata, treeModification);
+  }
+
+  public ChangeFileContentModificationSubject asChangeFileContentModification() {
+    isInstanceOf(ChangeFileContentModification.class);
+    return ChangeFileContentModificationSubject.assertThat(
+        (ChangeFileContentModification) actual());
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/events/EventDeserializerTest.java b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/events/EventDeserializerTest.java
rename to javatests/com/google/gerrit/server/events/EventDeserializerTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/events/EventTypesTest.java b/javatests/com/google/gerrit/server/events/EventTypesTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/events/EventTypesTest.java
rename to javatests/com/google/gerrit/server/events/EventTypesTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java b/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
rename to javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/fixes/LineIdentifierTest.java b/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/fixes/LineIdentifierTest.java
rename to javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/fixes/StringModifierTest.java b/javatests/com/google/gerrit/server/fixes/StringModifierTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/fixes/StringModifierTest.java
rename to javatests/com/google/gerrit/server/fixes/StringModifierTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java b/javatests/com/google/gerrit/server/git/DestinationListTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java
rename to javatests/com/google/gerrit/server/git/DestinationListTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupCollectorTest.java b/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/git/GroupCollectorTest.java
rename to javatests/com/google/gerrit/server/git/GroupCollectorTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupListTest.java b/javatests/com/google/gerrit/server/git/GroupListTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/git/GroupListTest.java
rename to javatests/com/google/gerrit/server/git/GroupListTest.java
diff --git a/javatests/com/google/gerrit/server/git/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/git/LabelNormalizerTest.java
new file mode 100644
index 0000000..7bbb84b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/LabelNormalizerTest.java
@@ -0,0 +1,227 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.gerrit.common.data.Permission.forLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.Util.allow;
+import static com.google.gerrit.server.project.testing.Util.category;
+import static com.google.gerrit.server.project.testing.Util.value;
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.LabelNormalizer.Result;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testing.InMemoryDatabase;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import java.util.List;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Unit tests for {@link LabelNormalizer}. */
+public class LabelNormalizerTest {
+  @Inject private AccountManager accountManager;
+  @Inject private AllProjectsName allProjects;
+  @Inject private GitRepositoryManager repoManager;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+  @Inject private InMemoryDatabase schemaFactory;
+  @Inject private LabelNormalizer norm;
+  @Inject private MetaDataUpdate.User metaDataUpdateFactory;
+  @Inject private ProjectCache projectCache;
+  @Inject private SchemaCreator schemaCreator;
+  @Inject protected ThreadLocalRequestContext requestContext;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+
+  private LifecycleManager lifecycle;
+  private ReviewDb db;
+  private Account.Id userId;
+  private IdentifiedUser user;
+  private Change change;
+  private ChangeNotes notes;
+
+  @Before
+  public void setUpInjector() throws Exception {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+    lifecycle = new LifecycleManager();
+    lifecycle.add(injector);
+    lifecycle.start();
+
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+    userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
+    user = userFactory.create(userId);
+
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return user;
+          }
+
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
+
+    configureProject();
+    setUpChange();
+  }
+
+  private void configureProject() throws Exception {
+    ProjectConfig pc = loadAllProjects();
+    for (AccessSection sec : pc.getAccessSections()) {
+      for (String label : pc.getLabelSections().keySet()) {
+        sec.removePermission(forLabel(label));
+      }
+    }
+    LabelType lt =
+        category("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
+    pc.getLabelSections().put(lt.getName(), lt);
+    save(pc);
+  }
+
+  private void setUpChange() throws Exception {
+    change =
+        new Change(
+            new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
+            new Change.Id(1),
+            userId,
+            new Branch.NameKey(allProjects, "refs/heads/master"),
+            TimeUtil.nowTs());
+    PatchSetInfo ps = new PatchSetInfo(new PatchSet.Id(change.getId(), 1));
+    ps.setSubject("Test change");
+    change.setCurrentPatchSet(ps);
+    db.changes().insert(ImmutableList.of(change));
+    notes = changeNotesFactory.createChecked(db, change);
+  }
+
+  @After
+  public void tearDown() {
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
+  }
+
+  @Test
+  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/*");
+    save(pc);
+
+    PatchSetApproval cr = psa(userId, "Code-Review", 2);
+    PatchSetApproval v = psa(userId, "Verified", 1);
+    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
+  }
+
+  @Test
+  public void normalizeByType() throws Exception {
+    ProjectConfig pc = loadAllProjects();
+    allow(pc, forLabel("Code-Review"), -5, 5, REGISTERED_USERS, "refs/heads/*");
+    allow(pc, forLabel("Verified"), -5, 5, REGISTERED_USERS, "refs/heads/*");
+    save(pc);
+
+    PatchSetApproval cr = psa(userId, "Code-Review", 5);
+    PatchSetApproval v = psa(userId, "Verified", 5);
+    assertEquals(
+        Result.create(list(), list(copy(cr, 2), copy(v, 1)), list()),
+        norm.normalize(notes, list(cr, v)));
+  }
+
+  @Test
+  public void emptyPermissionRangeKeepsResult() throws Exception {
+    PatchSetApproval cr = psa(userId, "Code-Review", 1);
+    PatchSetApproval v = psa(userId, "Verified", 1);
+    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
+  }
+
+  @Test
+  public void explicitZeroVoteOnNonEmptyRangeIsPresent() throws Exception {
+    ProjectConfig pc = loadAllProjects();
+    allow(pc, forLabel("Code-Review"), -1, 1, REGISTERED_USERS, "refs/heads/*");
+    save(pc);
+
+    PatchSetApproval cr = psa(userId, "Code-Review", 0);
+    PatchSetApproval v = psa(userId, "Verified", 0);
+    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
+  }
+
+  private ProjectConfig loadAllProjects() throws Exception {
+    try (Repository repo = repoManager.openRepository(allProjects)) {
+      ProjectConfig pc = new ProjectConfig(allProjects);
+      pc.load(repo);
+      return pc;
+    }
+  }
+
+  private void save(ProjectConfig pc) throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(pc.getProject().getNameKey(), user)) {
+      pc.commit(md);
+      projectCache.evict(pc.getProject().getNameKey());
+    }
+  }
+
+  private PatchSetApproval psa(Account.Id accountId, String label, int value) {
+    return new PatchSetApproval(
+        new PatchSetApproval.Key(change.currentPatchSetId(), accountId, new LabelId(label)),
+        (short) value,
+        TimeUtil.nowTs());
+  }
+
+  private PatchSetApproval copy(PatchSetApproval src, int newValue) {
+    PatchSetApproval result = new PatchSetApproval(src.getKey().getParentKey(), src);
+    result.setValue((short) newValue);
+    return result;
+  }
+
+  private static List<PatchSetApproval> list(PatchSetApproval... psas) {
+    return ImmutableList.<PatchSetApproval>copyOf(psas);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
new file mode 100644
index 0000000..aaad2a6
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
@@ -0,0 +1,251 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.HostPlatform;
+import com.google.gerrit.testing.TempFileUtil;
+import com.google.gwtorm.client.KeyUtil;
+import com.google.gwtorm.server.StandardKeyEncoder;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.easymock.EasyMockSupport;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.util.FS;
+import org.junit.Before;
+import org.junit.Test;
+
+public class LocalDiskRepositoryManagerTest extends EasyMockSupport {
+
+  static {
+    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
+  }
+
+  private Config cfg;
+  private SitePaths site;
+  private LocalDiskRepositoryManager repoManager;
+
+  @Before
+  public void setUp() throws Exception {
+    site = new SitePaths(TempFileUtil.createTempDirectory().toPath());
+    site.resolve("git").toFile().mkdir();
+    cfg = new Config();
+    cfg.setString("gerrit", null, "basePath", "git");
+    repoManager = new LocalDiskRepositoryManager(site, cfg);
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testThatNullBasePathThrowsAnException() {
+    new LocalDiskRepositoryManager(site, new Config());
+  }
+
+  @Test
+  public void projectCreation() throws Exception {
+    Project.NameKey projectA = new Project.NameKey("projectA");
+    try (Repository repo = repoManager.createRepository(projectA)) {
+      assertThat(repo).isNotNull();
+    }
+    try (Repository repo = repoManager.openRepository(projectA)) {
+      assertThat(repo).isNotNull();
+    }
+    assertThat(repoManager.list()).containsExactly(projectA);
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationWithEmptyName() throws Exception {
+    repoManager.createRepository(new Project.NameKey(""));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationWithTrailingSlash() throws Exception {
+    repoManager.createRepository(new Project.NameKey("projectA/"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationWithBackSlash() throws Exception {
+    repoManager.createRepository(new Project.NameKey("a\\projectA"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationAbsolutePath() throws Exception {
+    repoManager.createRepository(new Project.NameKey("/projectA"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationStartingWithDotDot() throws Exception {
+    repoManager.createRepository(new Project.NameKey("../projectA"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationContainsDotDot() throws Exception {
+    repoManager.createRepository(new Project.NameKey("a/../projectA"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationDotPathSegment() throws Exception {
+    repoManager.createRepository(new Project.NameKey("a/./projectA"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationWithTwoSlashes() throws Exception {
+    repoManager.createRepository(new Project.NameKey("a//projectA"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationWithPathSegmentEndingByDotGit() throws Exception {
+    repoManager.createRepository(new Project.NameKey("a/b.git/projectA"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationWithQuestionMark() throws Exception {
+    repoManager.createRepository(new Project.NameKey("project?A"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationWithPercentageSign() throws Exception {
+    repoManager.createRepository(new Project.NameKey("project%A"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationWithWidlcard() throws Exception {
+    repoManager.createRepository(new Project.NameKey("project*A"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationWithColon() throws Exception {
+    repoManager.createRepository(new Project.NameKey("project:A"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationWithLessThatSign() throws Exception {
+    repoManager.createRepository(new Project.NameKey("project<A"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationWithGreaterThatSign() throws Exception {
+    repoManager.createRepository(new Project.NameKey("project>A"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationWithPipe() throws Exception {
+    repoManager.createRepository(new Project.NameKey("project|A"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationWithDollarSign() throws Exception {
+    repoManager.createRepository(new Project.NameKey("project$A"));
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testProjectCreationWithCarriageReturn() throws Exception {
+    repoManager.createRepository(new Project.NameKey("project\\rA"));
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testProjectRecreation() throws Exception {
+    repoManager.createRepository(new Project.NameKey("a"));
+    repoManager.createRepository(new Project.NameKey("a"));
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testProjectRecreationAfterRestart() throws Exception {
+    repoManager.createRepository(new Project.NameKey("a"));
+    LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
+    newRepoManager.createRepository(new Project.NameKey("a"));
+  }
+
+  @Test
+  public void openRepositoryCreatedDirectlyOnDisk() throws Exception {
+    Project.NameKey projectA = new Project.NameKey("projectA");
+    createRepository(repoManager.getBasePath(projectA), projectA.get());
+    try (Repository repo = repoManager.openRepository(projectA)) {
+      assertThat(repo).isNotNull();
+    }
+    assertThat(repoManager.list()).containsExactly(projectA);
+  }
+
+  @Test(expected = RepositoryCaseMismatchException.class)
+  public void testNameCaseMismatch() throws Exception {
+    assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
+    repoManager.createRepository(new Project.NameKey("a"));
+    repoManager.createRepository(new Project.NameKey("A"));
+  }
+
+  @Test(expected = RepositoryCaseMismatchException.class)
+  public void testNameCaseMismatchWithSymlink() throws Exception {
+    assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
+    Project.NameKey name = new Project.NameKey("a");
+    repoManager.createRepository(name);
+    createSymLink(name, "b.git");
+    repoManager.createRepository(new Project.NameKey("B"));
+  }
+
+  @Test(expected = RepositoryCaseMismatchException.class)
+  public void testNameCaseMismatchAfterRestart() throws Exception {
+    assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
+    Project.NameKey name = new Project.NameKey("a");
+    repoManager.createRepository(name);
+
+    LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
+    newRepoManager.createRepository(new Project.NameKey("A"));
+  }
+
+  private void createSymLink(Project.NameKey project, String link) throws IOException {
+    Path base = repoManager.getBasePath(project);
+    Path projectDir = base.resolve(project.get() + ".git");
+    Path symlink = base.resolve(link);
+    Files.createSymbolicLink(symlink, projectDir);
+  }
+
+  @Test(expected = RepositoryNotFoundException.class)
+  public void testOpenRepositoryInvalidName() throws Exception {
+    repoManager.openRepository(new Project.NameKey("project%?|<>A"));
+  }
+
+  @Test
+  public void list() throws Exception {
+    Project.NameKey projectA = new Project.NameKey("projectA");
+    createRepository(repoManager.getBasePath(projectA), projectA.get());
+
+    Project.NameKey projectB = new Project.NameKey("path/projectB");
+    createRepository(repoManager.getBasePath(projectB), projectB.get());
+
+    Project.NameKey projectC = new Project.NameKey("anotherPath/path/projectC");
+    createRepository(repoManager.getBasePath(projectC), projectC.get());
+    // create an invalid git repo named only .git
+    repoManager.getBasePath(null).resolve(".git").toFile().mkdir();
+    // create an invalid repo name
+    createRepository(repoManager.getBasePath(null), "project?A");
+    assertThat(repoManager.list()).containsExactly(projectA, projectB, projectC);
+  }
+
+  private void createRepository(Path directory, String projectName) throws IOException {
+    String n = projectName + Constants.DOT_GIT_EXT;
+    FileKey loc = FileKey.exact(directory.resolve(n).toFile(), FS.DETECTED);
+    try (Repository db = RepositoryCache.open(loc, false)) {
+      db.create(true /* bare */);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
new file mode 100644
index 0000000..aee2aa9
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
@@ -0,0 +1,164 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.createNiceMock;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.reset;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.RepositoryConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.TempFileUtil;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.SortedSet;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.util.FS;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class MultiBaseLocalDiskRepositoryManagerTest extends GerritBaseTests {
+  private Config cfg;
+  private SitePaths site;
+  private MultiBaseLocalDiskRepositoryManager repoManager;
+  private RepositoryConfig configMock;
+
+  @Before
+  public void setUp() throws IOException {
+    site = new SitePaths(TempFileUtil.createTempDirectory().toPath());
+    site.resolve("git").toFile().mkdir();
+    cfg = new Config();
+    cfg.setString("gerrit", null, "basePath", "git");
+    configMock = createNiceMock(RepositoryConfig.class);
+    expect(configMock.getAllBasePaths()).andReturn(new ArrayList<Path>()).anyTimes();
+    replay(configMock);
+    repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
+  }
+
+  @After
+  public void tearDown() throws IOException {
+    TempFileUtil.cleanup();
+  }
+
+  @Test
+  public void defaultRepositoryLocation()
+      throws RepositoryCaseMismatchException, RepositoryNotFoundException, IOException {
+    Project.NameKey someProjectKey = new Project.NameKey("someProject");
+    Repository repo = repoManager.createRepository(someProjectKey);
+    assertThat(repo.getDirectory()).isNotNull();
+    assertThat(repo.getDirectory().exists()).isTrue();
+    assertThat(repo.getDirectory().getParent())
+        .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
+
+    repo = repoManager.openRepository(someProjectKey);
+    assertThat(repo.getDirectory()).isNotNull();
+    assertThat(repo.getDirectory().exists()).isTrue();
+    assertThat(repo.getDirectory().getParent())
+        .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
+
+    assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
+        .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
+
+    SortedSet<Project.NameKey> repoList = repoManager.list();
+    assertThat(repoList.size()).isEqualTo(1);
+    assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
+        .isEqualTo(new Project.NameKey[] {someProjectKey});
+  }
+
+  @Test
+  public void alternateRepositoryLocation() throws IOException {
+    Path alternateBasePath = TempFileUtil.createTempDirectory().toPath();
+    Project.NameKey someProjectKey = new Project.NameKey("someProject");
+    reset(configMock);
+    expect(configMock.getBasePath(someProjectKey)).andReturn(alternateBasePath).anyTimes();
+    expect(configMock.getAllBasePaths()).andReturn(Arrays.asList(alternateBasePath)).anyTimes();
+    replay(configMock);
+
+    Repository repo = repoManager.createRepository(someProjectKey);
+    assertThat(repo.getDirectory()).isNotNull();
+    assertThat(repo.getDirectory().exists()).isTrue();
+    assertThat(repo.getDirectory().getParent()).isEqualTo(alternateBasePath.toString());
+
+    repo = repoManager.openRepository(someProjectKey);
+    assertThat(repo.getDirectory()).isNotNull();
+    assertThat(repo.getDirectory().exists()).isTrue();
+    assertThat(repo.getDirectory().getParent()).isEqualTo(alternateBasePath.toString());
+
+    assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
+        .isEqualTo(alternateBasePath.toString());
+
+    SortedSet<Project.NameKey> repoList = repoManager.list();
+    assertThat(repoList.size()).isEqualTo(1);
+    assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
+        .isEqualTo(new Project.NameKey[] {someProjectKey});
+  }
+
+  @Test
+  public void listReturnRepoFromProperLocation() throws IOException {
+    Project.NameKey basePathProject = new Project.NameKey("basePathProject");
+    Project.NameKey altPathProject = new Project.NameKey("altPathProject");
+    Project.NameKey misplacedProject1 = new Project.NameKey("misplacedProject1");
+    Project.NameKey misplacedProject2 = new Project.NameKey("misplacedProject2");
+
+    Path alternateBasePath = TempFileUtil.createTempDirectory().toPath();
+
+    reset(configMock);
+    expect(configMock.getBasePath(altPathProject)).andReturn(alternateBasePath).anyTimes();
+    expect(configMock.getBasePath(misplacedProject2)).andReturn(alternateBasePath).anyTimes();
+    expect(configMock.getAllBasePaths()).andReturn(Arrays.asList(alternateBasePath)).anyTimes();
+    replay(configMock);
+
+    repoManager.createRepository(basePathProject);
+    repoManager.createRepository(altPathProject);
+    // create the misplaced ones without the repomanager otherwise they would
+    // end up at the proper place.
+    createRepository(repoManager.getBasePath(basePathProject), misplacedProject2);
+    createRepository(alternateBasePath, misplacedProject1);
+
+    SortedSet<Project.NameKey> repoList = repoManager.list();
+    assertThat(repoList.size()).isEqualTo(2);
+    assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
+        .isEqualTo(new Project.NameKey[] {altPathProject, basePathProject});
+  }
+
+  private void createRepository(Path directory, Project.NameKey projectName) throws IOException {
+    String n = projectName.get() + Constants.DOT_GIT_EXT;
+    FileKey loc = FileKey.exact(directory.resolve(n).toFile(), FS.DETECTED);
+    try (Repository db = RepositoryCache.open(loc, false)) {
+      db.create(true /* bare */);
+    }
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testRelativeAlternateLocation() {
+    configMock = createNiceMock(RepositoryConfig.class);
+    expect(configMock.getAllBasePaths()).andReturn(Arrays.asList(Paths.get("repos"))).anyTimes();
+    replay(configMock);
+    repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/ProjectConfigTest.java b/javatests/com/google/gerrit/server/git/ProjectConfigTest.java
new file mode 100644
index 0000000..627fc27
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/ProjectConfigTest.java
@@ -0,0 +1,503 @@
+// 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.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.common.collect.Iterables;
+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.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.testing.GerritBaseTests;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ProjectConfigTest extends GerritBaseTests {
+  private static final String LABEL_SCORES_CONFIG =
+      "  copyMinScore = "
+          + !LabelType.DEF_COPY_MIN_SCORE
+          + "\n"
+          + "  copyMaxScore = "
+          + !LabelType.DEF_COPY_MAX_SCORE
+          + "\n"
+          + "  copyAllScoresOnMergeFirstParentUpdate = "
+          + !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE
+          + "\n"
+          + "  copyAllScoresOnTrivialRebase = "
+          + !LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE
+          + "\n"
+          + "  copyAllScoresIfNoCodeChange = "
+          + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE
+          + "\n"
+          + "  copyAllScoresIfNoChange = "
+          + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE
+          + "\n";
+
+  private final GroupReference developers =
+      new GroupReference(new AccountGroup.UUID("X"), "Developers");
+  private final GroupReference staff = new GroupReference(new AccountGroup.UUID("Y"), "Staff");
+
+  private Repository db;
+  private TestRepository<?> tr;
+
+  @Before
+  public void setUp() throws Exception {
+    db = new InMemoryRepository(new DfsRepositoryDescription("repo"));
+    tr = new TestRepository<>(db);
+  }
+
+  @Test
+  public void readConfig() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[access \"refs/heads/*\"]\n"
+                    + "  exclusiveGroupPermissions = read submit create\n"
+                    + "  submit = group Developers\n"
+                    + "  push = group Developers\n"
+                    + "  read = group Developers\n"
+                    + "[accounts]\n"
+                    + "  sameGroupVisibility = deny group Developers\n"
+                    + "  sameGroupVisibility = block group Staff\n"
+                    + "[contributor-agreement \"Individual\"]\n"
+                    + "  description = A simple description\n"
+                    + "  accepted = group Developers\n"
+                    + "  accepted = group Staff\n"
+                    + "  autoVerify = group Developers\n"
+                    + "  agreementUrl = http://www.example.com/agree\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    assertThat(cfg.getAccountsSection().getSameGroupVisibility()).hasSize(2);
+    ContributorAgreement ca = cfg.getContributorAgreement("Individual");
+    assertThat(ca.getName()).isEqualTo("Individual");
+    assertThat(ca.getDescription()).isEqualTo("A simple description");
+    assertThat(ca.getAgreementUrl()).isEqualTo("http://www.example.com/agree");
+    assertThat(ca.getAccepted()).hasSize(2);
+    assertThat(ca.getAccepted().get(0).getGroup()).isEqualTo(developers);
+    assertThat(ca.getAccepted().get(1).getGroup().getName()).isEqualTo("Staff");
+    assertThat(ca.getAutoVerify().getName()).isEqualTo("Developers");
+
+    AccessSection section = cfg.getAccessSection("refs/heads/*");
+    assertThat(section).isNotNull();
+    assertThat(cfg.getAccessSection("refs/*")).isNull();
+
+    Permission create = section.getPermission(Permission.CREATE);
+    Permission submit = section.getPermission(Permission.SUBMIT);
+    Permission read = section.getPermission(Permission.READ);
+    Permission push = section.getPermission(Permission.PUSH);
+
+    assertThat(create.getExclusiveGroup()).isTrue();
+    assertThat(submit.getExclusiveGroup()).isTrue();
+    assertThat(read.getExclusiveGroup()).isTrue();
+    assertThat(push.getExclusiveGroup()).isFalse();
+  }
+
+  @Test
+  public void readConfigLabelDefaultValue() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[label \"CustomLabel\"]\n"
+                    + "  value = -1 Negative\n"
+                    // No leading space before 0.
+                    + "  value = 0 No Score\n"
+                    + "  value =  1 Positive\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, LabelType> labels = cfg.getLabelSections();
+    Short dv = labels.entrySet().iterator().next().getValue().getDefaultValue();
+    assertThat((int) dv).isEqualTo(0);
+  }
+
+  @Test
+  public void readConfigLabelOldStyleWithLeadingSpace() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[label \"CustomLabel\"]\n"
+                    + "  value = -1 Negative\n"
+                    // Leading space before 0.
+                    + "  value =  0 No Score\n"
+                    + "  value =  1 Positive\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, LabelType> labels = cfg.getLabelSections();
+    Short dv = labels.entrySet().iterator().next().getValue().getDefaultValue();
+    assertThat((int) dv).isEqualTo(0);
+  }
+
+  @Test
+  public void readConfigLabelDefaultValueInRange() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[label \"CustomLabel\"]\n"
+                    + "  value = -1 Negative\n"
+                    + "  value = 0 No Score\n"
+                    + "  value =  1 Positive\n"
+                    + "  defaultValue = -1\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, LabelType> labels = cfg.getLabelSections();
+    Short dv = labels.entrySet().iterator().next().getValue().getDefaultValue();
+    assertThat((int) dv).isEqualTo(-1);
+  }
+
+  @Test
+  public void readConfigLabelDefaultValueNotInRange() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[label \"CustomLabel\"]\n"
+                    + "  value = -1 Negative\n"
+                    + "  value = 0 No Score\n"
+                    + "  value =  1 Positive\n"
+                    + "  defaultValue = -2\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    assertThat(cfg.getValidationErrors()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
+        .isEqualTo("project.config: Invalid defaultValue \"-2\" for label \"CustomLabel\"");
+  }
+
+  @Test
+  public void readConfigLabelScores() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add("project.config", "[label \"CustomLabel\"]\n" + LABEL_SCORES_CONFIG)
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, LabelType> labels = cfg.getLabelSections();
+    LabelType type = labels.entrySet().iterator().next().getValue();
+    assertThat(type.isCopyMinScore()).isNotEqualTo(LabelType.DEF_COPY_MIN_SCORE);
+    assertThat(type.isCopyMaxScore()).isNotEqualTo(LabelType.DEF_COPY_MAX_SCORE);
+    assertThat(type.isCopyAllScoresOnMergeFirstParentUpdate())
+        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    assertThat(type.isCopyAllScoresOnTrivialRebase())
+        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    assertThat(type.isCopyAllScoresIfNoCodeChange())
+        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+    assertThat(type.isCopyAllScoresIfNoChange())
+        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
+  }
+
+  @Test
+  public void editConfig() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[access \"refs/heads/*\"]\n"
+                    + "  exclusiveGroupPermissions = read submit\n"
+                    + "  submit = group Developers\n"
+                    + "  upload = group Developers\n"
+                    + "  read = group Developers\n"
+                    + "[accounts]\n"
+                    + "  sameGroupVisibility = deny group Developers\n"
+                    + "  sameGroupVisibility = block group Staff\n"
+                    + "[contributor-agreement \"Individual\"]\n"
+                    + "  description = A simple description\n"
+                    + "  accepted = group Developers\n"
+                    + "  autoVerify = group Developers\n"
+                    + "  agreementUrl = http://www.example.com/agree\n"
+                    + "[label \"CustomLabel\"]\n"
+                    + LABEL_SCORES_CONFIG)
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    AccessSection section = cfg.getAccessSection("refs/heads/*");
+    cfg.getAccountsSection()
+        .setSameGroupVisibility(Collections.singletonList(new PermissionRule(cfg.resolve(staff))));
+    Permission submit = section.getPermission(Permission.SUBMIT);
+    submit.add(new PermissionRule(cfg.resolve(staff)));
+    ContributorAgreement ca = cfg.getContributorAgreement("Individual");
+    ca.setAccepted(Collections.singletonList(new PermissionRule(cfg.resolve(staff))));
+    ca.setAutoVerify(null);
+    ca.setDescription("A new description");
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo(
+            "[access \"refs/heads/*\"]\n"
+                + "  exclusiveGroupPermissions = read submit\n"
+                + "  submit = group Developers\n"
+                + "\tsubmit = group Staff\n"
+                + "  upload = group Developers\n"
+                + "  read = group Developers\n"
+                + "[accounts]\n"
+                + "  sameGroupVisibility = group Staff\n"
+                + "[contributor-agreement \"Individual\"]\n"
+                + "  description = A new description\n"
+                + "  accepted = group Staff\n"
+                + "  agreementUrl = http://www.example.com/agree\n"
+                + "[label \"CustomLabel\"]\n"
+                + LABEL_SCORES_CONFIG
+                + "\tfunction = MaxWithBlock\n" // label gets this function when it is created
+                + "\tdefaultValue = 0\n"); //  label gets this value when it is created
+  }
+
+  @Test
+  public void editConfigLabelValues() throws Exception {
+    RevCommit rev = tr.commit().create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    cfg.getLabelSections()
+        .put(
+            "My-Label",
+            Util.category(
+                "My-Label",
+                Util.value(-1, "Negative"),
+                Util.value(0, "No score"),
+                Util.value(1, "Positive")));
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo(
+            "[label \"My-Label\"]\n"
+                + "\tfunction = MaxWithBlock\n"
+                + "\tdefaultValue = 0\n"
+                + "\tvalue = -1 Negative\n"
+                + "\tvalue = 0 No score\n"
+                + "\tvalue = +1 Positive\n");
+  }
+
+  @Test
+  public void editConfigMissingGroupTableEntry() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[access \"refs/heads/*\"]\n"
+                    + "  exclusiveGroupPermissions = read submit\n"
+                    + "  submit = group People Who Can Submit\n"
+                    + "  upload = group Developers\n"
+                    + "  read = group Developers\n")
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    AccessSection section = cfg.getAccessSection("refs/heads/*");
+    Permission submit = section.getPermission(Permission.SUBMIT);
+    submit.add(new PermissionRule(cfg.resolve(staff)));
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo(
+            "[access \"refs/heads/*\"]\n"
+                + "  exclusiveGroupPermissions = read submit\n"
+                + "  submit = group People Who Can Submit\n"
+                + "\tsubmit = group Staff\n"
+                + "  upload = group Developers\n"
+                + "  read = group Developers\n");
+  }
+
+  @Test
+  public void readExistingPluginConfig() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[plugin \"somePlugin\"]\n"
+                    + "  key1 = value1\n"
+                    + "  key2 = value2a\n"
+                    + "  key2 = value2b\n")
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
+    assertThat(pluginCfg.getNames().size()).isEqualTo(2);
+    assertThat(pluginCfg.getString("key1")).isEqualTo("value1");
+    assertThat(pluginCfg.getStringList(("key2"))).isEqualTo(new String[] {"value2a", "value2b"});
+  }
+
+  @Test
+  public void readUnexistingPluginConfig() throws Exception {
+    ProjectConfig cfg = new ProjectConfig(new Project.NameKey("test"));
+    cfg.load(db);
+    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
+    assertThat(pluginCfg.getNames()).isEmpty();
+  }
+
+  @Test
+  public void editPluginConfig() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[plugin \"somePlugin\"]\n"
+                    + "  key1 = value1\n"
+                    + "  key2 = value2a\n"
+                    + "  key2 = value2b\n")
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
+    pluginCfg.setString("key1", "updatedValue1");
+    pluginCfg.setStringList("key2", Arrays.asList("updatedValue2a", "updatedValue2b"));
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo(
+            "[plugin \"somePlugin\"]\n"
+                + "\tkey1 = updatedValue1\n"
+                + "\tkey2 = updatedValue2a\n"
+                + "\tkey2 = updatedValue2b\n");
+  }
+
+  @Test
+  public void readPluginConfigGroupReference() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add("project.config", "[plugin \"somePlugin\"]\nkey1 = " + developers.toConfigValue())
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
+    assertThat(pluginCfg.getNames().size()).isEqualTo(1);
+    assertThat(pluginCfg.getGroupReference("key1")).isEqualTo(developers);
+  }
+
+  @Test
+  public void readPluginConfigGroupReferenceNotInGroupsFile() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add("project.config", "[plugin \"somePlugin\"]\nkey1 = " + staff.toConfigValue())
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    assertThat(cfg.getValidationErrors()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
+        .isEqualTo(
+            "project.config: group \"" + staff.getName() + "\" not in " + GroupList.FILE_NAME);
+  }
+
+  @Test
+  public void editPluginConfigGroupReference() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add("project.config", "[plugin \"somePlugin\"]\nkey1 = " + developers.toConfigValue())
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
+    assertThat(pluginCfg.getNames().size()).isEqualTo(1);
+    assertThat(pluginCfg.getGroupReference("key1")).isEqualTo(developers);
+
+    pluginCfg.setGroupReference("key1", staff);
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo("[plugin \"somePlugin\"]\n\tkey1 = " + staff.toConfigValue() + "\n");
+    assertThat(text(rev, "groups"))
+        .isEqualTo(
+            "# UUID\tGroup Name\n"
+                + "#\n"
+                + staff.getUUID().get()
+                + "     \t"
+                + staff.getName()
+                + "\n");
+  }
+
+  private ProjectConfig read(RevCommit rev) throws IOException, ConfigInvalidException {
+    ProjectConfig cfg = new ProjectConfig(new Project.NameKey("test"));
+    cfg.load(db, rev);
+    return cfg;
+  }
+
+  private RevCommit commit(ProjectConfig cfg)
+      throws IOException, MissingObjectException, IncorrectObjectTypeException {
+    try (MetaDataUpdate md =
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, cfg.getProject().getNameKey(), db)) {
+      tr.tick(5);
+      tr.setAuthorAndCommitter(md.getCommitBuilder());
+      md.setMessage("Edit\n");
+      cfg.commit(md);
+
+      Ref ref = db.exactRef(RefNames.REFS_CONFIG);
+      return tr.getRevWalk().parseCommit(ref.getObjectId());
+    }
+  }
+
+  private void update(RevCommit rev) throws Exception {
+    RefUpdate u = db.updateRef(RefNames.REFS_CONFIG);
+    u.disableRefLog();
+    u.setNewObjectId(rev);
+    Result result = u.forceUpdate();
+    assertWithMessage("Cannot update ref for test: " + result)
+        .that(result)
+        .isAnyOf(Result.FAST_FORWARD, Result.FORCED, Result.NEW, Result.NO_CHANGE);
+  }
+
+  private String text(RevCommit rev, String path) throws Exception {
+    RevObject blob = tr.get(rev.getTree(), path);
+    byte[] data = db.open(blob).getCachedBytes(Integer.MAX_VALUE);
+    return RawParseUtils.decode(data);
+  }
+
+  private static String group(GroupReference g) {
+    return g.getUUID().get() + "\t" + g.getName() + "\n";
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/QueryListTest.java b/javatests/com/google/gerrit/server/git/QueryListTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/git/QueryListTest.java
rename to javatests/com/google/gerrit/server/git/QueryListTest.java
diff --git a/javatests/com/google/gerrit/server/git/VersionedMetaDataTest.java b/javatests/com/google/gerrit/server/git/VersionedMetaDataTest.java
new file mode 100644
index 0000000..d765c5e
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/VersionedMetaDataTest.java
@@ -0,0 +1,304 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.VersionedMetaData.BatchMetaDataUpdate;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.TimeZone;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class VersionedMetaDataTest {
+  // If you're considering fleshing out this test and making it more comprehensive, please consider
+  // instead coming up with a replacement interface for
+  // VersionedMetaData/BatchMetaDataUpdate/MetaDataUpdate that is easier to use correctly.
+
+  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  private static final String DEFAULT_REF = "refs/meta/config";
+
+  private Project.NameKey project;
+  private Repository repo;
+
+  @Before
+  public void setUp() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+    project = new Project.NameKey("repo");
+    repo = new InMemoryRepository(new DfsRepositoryDescription(project.get()));
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void singleUpdate() throws Exception {
+    MyMetaData d = load(0);
+    d.setIncrement(3);
+    d.commit(newMetaDataUpdate());
+    assertMyMetaData(3, "Increment conf.value by 3");
+  }
+
+  @Test
+  public void noOpNoSetter() throws Exception {
+    MyMetaData d = load(0);
+    d.commit(newMetaDataUpdate());
+    assertMyMetaData(0);
+  }
+
+  @Test
+  public void noOpWithSetter() throws Exception {
+    MyMetaData d = load(0);
+    d.setIncrement(0);
+    d.commit(newMetaDataUpdate());
+    // First commit is actually not a no-op because it creates an empty config file.
+    assertMyMetaData(0, "Increment conf.value by 0");
+
+    d = load(0);
+    d.setIncrement(0);
+    d.commit(newMetaDataUpdate());
+    assertMyMetaData(0, "Increment conf.value by 0");
+  }
+
+  @Test
+  public void multipleSeparateUpdatesWithSameObject() throws Exception {
+    MyMetaData d = load(0);
+    d.setIncrement(1);
+    d.commit(newMetaDataUpdate());
+    assertMyMetaData(1, "Increment conf.value by 1");
+    d.setIncrement(2);
+    d.commit(newMetaDataUpdate());
+    assertMyMetaData(3, "Increment conf.value by 1", "Increment conf.value by 2");
+  }
+
+  @Test
+  public void multipleSeparateUpdatesWithDifferentObject() throws Exception {
+    MyMetaData d = load(0);
+    d.setIncrement(1);
+    d.commit(newMetaDataUpdate());
+    assertMyMetaData(1, "Increment conf.value by 1");
+
+    d = load(1);
+    d.setIncrement(2);
+    d.commit(newMetaDataUpdate());
+    assertMyMetaData(3, "Increment conf.value by 1", "Increment conf.value by 2");
+  }
+
+  @Test
+  public void multipleUpdatesInBatchWithSameObject() throws Exception {
+    MyMetaData d = load(0);
+    d.setIncrement(1);
+    try (BatchMetaDataUpdate batch = d.openUpdate(newMetaDataUpdate())) {
+      batch.write(d, newCommitBuilder());
+      assertMyMetaData(0); // Batch not yet committed.
+
+      d.setIncrement(2);
+      batch.write(d, newCommitBuilder());
+      batch.commit();
+    }
+
+    assertMyMetaData(3, "Increment conf.value by 1", "Increment conf.value by 2");
+  }
+
+  @Test
+  public void multipleUpdatesSomeNoOps() throws Exception {
+    MyMetaData d = load(0);
+    d.setIncrement(1);
+    try (BatchMetaDataUpdate batch = d.openUpdate(newMetaDataUpdate())) {
+      batch.write(d, newCommitBuilder());
+      assertMyMetaData(0); // Batch not yet committed.
+
+      d.setIncrement(0);
+      batch.write(d, newCommitBuilder());
+      assertMyMetaData(0); // Batch not yet committed.
+
+      d.setIncrement(3);
+      batch.write(d, newCommitBuilder());
+      batch.commit();
+    }
+
+    assertMyMetaData(4, "Increment conf.value by 1", "Increment conf.value by 3");
+  }
+
+  @Test
+  public void sharedBatchRefUpdate() throws Exception {
+    MyMetaData d1 = load("refs/meta/1", 0);
+    MyMetaData d2 = load("refs/meta/2", 0);
+
+    BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+    try (BatchMetaDataUpdate batch1 = d1.openUpdate(newMetaDataUpdate(bru));
+        BatchMetaDataUpdate batch2 = d2.openUpdate(newMetaDataUpdate(bru))) {
+      d1.setIncrement(1);
+      batch1.write(d1, newCommitBuilder());
+
+      d2.setIncrement(2000);
+      batch2.write(d2, newCommitBuilder());
+
+      d1.setIncrement(3);
+      batch1.write(d1, newCommitBuilder());
+
+      d2.setIncrement(4000);
+      batch2.write(d2, newCommitBuilder());
+
+      batch1.commit();
+      batch2.commit();
+    }
+
+    assertMyMetaData(d1.getRefName(), 0);
+    assertMyMetaData(d2.getRefName(), 0);
+    assertThat(bru.getCommands().stream().map(ReceiveCommand::getRefName))
+        .containsExactly("refs/meta/1", "refs/meta/2");
+    RefUpdateUtil.executeChecked(bru, repo);
+
+    assertMyMetaData(d1.getRefName(), 4, "Increment conf.value by 1", "Increment conf.value by 3");
+    assertMyMetaData(
+        d2.getRefName(), 6000, "Increment conf.value by 2000", "Increment conf.value by 4000");
+  }
+
+  private MyMetaData load(int expectedValue) throws Exception {
+    return load(DEFAULT_REF, expectedValue);
+  }
+
+  private MyMetaData load(String ref, int expectedValue) throws Exception {
+    MyMetaData d = new MyMetaData(ref);
+    d.load(repo);
+    assertThat(d.getValue()).isEqualTo(expectedValue);
+    return d;
+  }
+
+  private MetaDataUpdate newMetaDataUpdate() {
+    return newMetaDataUpdate(null);
+  }
+
+  private MetaDataUpdate newMetaDataUpdate(@Nullable BatchRefUpdate bru) {
+    MetaDataUpdate u = new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, repo, bru);
+    CommitBuilder cb = newCommitBuilder();
+    u.getCommitBuilder().setAuthor(cb.getAuthor());
+    u.getCommitBuilder().setCommitter(cb.getCommitter());
+    return u;
+  }
+
+  private CommitBuilder newCommitBuilder() {
+    CommitBuilder cb = new CommitBuilder();
+    PersonIdent author = new PersonIdent("J. Author", "author@example.com", TimeUtil.nowTs(), TZ);
+    cb.setAuthor(author);
+    cb.setCommitter(
+        new PersonIdent(
+            "M. Committer", "committer@example.com", author.getWhen(), author.getTimeZone()));
+    return cb;
+  }
+
+  private void assertMyMetaData(String ref, int expectedValue, String... expectedLog)
+      throws Exception {
+    MyMetaData d = load(ref, expectedValue);
+    assertThat(log(d)).containsExactlyElementsIn(Arrays.asList(expectedLog)).inOrder();
+  }
+
+  private void assertMyMetaData(int expectedValue, String... expectedLog) throws Exception {
+    assertMyMetaData(DEFAULT_REF, expectedValue, expectedLog);
+  }
+
+  private ImmutableList<String> log(MyMetaData d) throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(d.getRefName());
+      if (ref == null) {
+        return ImmutableList.of();
+      }
+      rw.sort(RevSort.REVERSE);
+      rw.setRetainBody(true);
+      rw.markStart(rw.parseCommit(ref.getObjectId()));
+      return Streams.stream(rw).map(RevCommit::getFullMessage).collect(toImmutableList());
+    }
+  }
+
+  private static class MyMetaData extends VersionedMetaData {
+    private static final String CONFIG_FILE = "my.config";
+    private static final String SECTION = "conf";
+    private static final String NAME = "value";
+
+    private final String ref;
+
+    MyMetaData(String ref) {
+      this.ref = ref;
+    }
+
+    @Override
+    protected String getRefName() {
+      return ref;
+    }
+
+    private int curr;
+    private Optional<Integer> increment = Optional.empty();
+
+    @Override
+    protected void onLoad() throws IOException, ConfigInvalidException {
+      Config cfg = readConfig(CONFIG_FILE);
+      curr = cfg.getInt(SECTION, null, NAME, 0);
+    }
+
+    int getValue() {
+      return curr;
+    }
+
+    void setIncrement(int increment) {
+      checkArgument(increment >= 0, "increment must be positive: %s", increment);
+      this.increment = Optional.of(increment);
+    }
+
+    @Override
+    protected boolean onSave(CommitBuilder cb) throws IOException, ConfigInvalidException {
+      // Two ways to produce a no-op: don't call setIncrement, and call setIncrement(0);
+      if (!increment.isPresent()) {
+        return false;
+      }
+      Config cfg = readConfig(CONFIG_FILE);
+      cfg.setInt(SECTION, null, NAME, cfg.getInt(SECTION, null, NAME, 0) + increment.get());
+      cb.setMessage(String.format("Increment %s.%s by %d", SECTION, NAME, increment.get()));
+      saveConfig(CONFIG_FILE, cfg);
+      increment = Optional.empty();
+      return true;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
new file mode 100644
index 0000000..70d5bf6
--- /dev/null
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -0,0 +1,143 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.sql.Timestamp;
+import java.util.TimeZone;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+
+@Ignore
+public class AbstractGroupTest extends GerritBaseTests {
+  protected static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  protected static final String SERVER_ID = "server-id";
+  protected static final String SERVER_NAME = "Gerrit Server";
+  protected static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
+  protected static final int SERVER_ACCOUNT_NUMBER = 100000;
+  protected static final int USER_ACCOUNT_NUMBER = 100001;
+
+  protected AllUsersName allUsersName;
+  protected InMemoryRepositoryManager repoManager;
+  protected Repository allUsersRepo;
+  protected Account.Id serverAccountId;
+  protected PersonIdent serverIdent;
+  protected Account.Id userId;
+  protected PersonIdent userIdent;
+
+  @Before
+  public void abstractGroupTestSetUp() throws Exception {
+    allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
+    repoManager = new InMemoryRepositoryManager();
+    allUsersRepo = repoManager.createRepository(allUsersName);
+    serverAccountId = new Account.Id(SERVER_ACCOUNT_NUMBER);
+    serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+    userId = new Account.Id(USER_ACCOUNT_NUMBER);
+    userIdent = newPersonIdent(userId, serverIdent);
+  }
+
+  @After
+  public void abstractGroupTestTearDown() throws Exception {
+    allUsersRepo.close();
+  }
+
+  protected Timestamp getTipTimestamp(AccountGroup.UUID uuid) throws Exception {
+    try (RevWalk rw = new RevWalk(allUsersRepo)) {
+      Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(uuid));
+      return ref == null
+          ? null
+          : new Timestamp(rw.parseCommit(ref.getObjectId()).getAuthorIdent().getWhen().getTime());
+    }
+  }
+
+  protected void assertTipCommit(AccountGroup.UUID uuid, String expectedMessage) throws Exception {
+    try (RevWalk rw = new RevWalk(allUsersRepo)) {
+      Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(uuid));
+      assertCommit(
+          CommitUtil.toCommitInfo(rw.parseCommit(ref.getObjectId()), rw),
+          expectedMessage,
+          getAccountName(userId),
+          getAccountEmail(userId));
+    }
+  }
+
+  protected static void assertServerCommit(CommitInfo commitInfo, String expectedMessage) {
+    assertCommit(commitInfo, expectedMessage, SERVER_NAME, SERVER_EMAIL);
+  }
+
+  protected static void assertCommit(
+      CommitInfo commitInfo, String expectedMessage, String expectedName, String expectedEmail) {
+    assertThat(commitInfo).message().isEqualTo(expectedMessage);
+    assertThat(commitInfo).author().name().isEqualTo(expectedName);
+    assertThat(commitInfo).author().email().isEqualTo(expectedEmail);
+
+    // Committer should always be the server, regardless of author.
+    assertThat(commitInfo).committer().name().isEqualTo(SERVER_NAME);
+    assertThat(commitInfo).committer().email().isEqualTo(SERVER_EMAIL);
+    assertThat(commitInfo).committer().date().isEqualTo(commitInfo.author.date);
+    assertThat(commitInfo).committer().tz().isEqualTo(commitInfo.author.tz);
+  }
+
+  protected MetaDataUpdate createMetaDataUpdate(PersonIdent authorIdent) {
+    MetaDataUpdate md =
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo);
+    md.getCommitBuilder().setAuthor(authorIdent);
+    md.getCommitBuilder().setCommitter(serverIdent); // Committer is always the server identity.
+    return md;
+  }
+
+  protected static PersonIdent newPersonIdent() {
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+  }
+
+  protected static PersonIdent newPersonIdent(Account.Id id, PersonIdent ident) {
+    return new PersonIdent(
+        getAccountName(id), getAccountEmail(id), ident.getWhen(), ident.getTimeZone());
+  }
+
+  protected static String getAccountNameEmail(Account.Id id) {
+    return String.format("%s <%s>", getAccountName(id), getAccountEmail(id));
+  }
+
+  protected static String getGroupName(AccountGroup.UUID uuid) {
+    return String.format("Group <%s>", uuid);
+  }
+
+  protected static String getAccountName(Account.Id id) {
+    return "Account " + id;
+  }
+
+  protected static String getAccountEmail(Account.Id id) {
+    return String.format("%s@%s", id, SERVER_ID);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
new file mode 100644
index 0000000..0d1fcf9
--- /dev/null
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -0,0 +1,380 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.server.account.GroupUUID;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.group.InternalGroup;
+import java.sql.Timestamp;
+import java.util.Set;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Unit tests for {@link AuditLogReader}. */
+public final class AuditLogReaderTest extends AbstractGroupTest {
+
+  private AuditLogReader auditLogReader;
+
+  @Before
+  public void setUp() throws Exception {
+    auditLogReader = new AuditLogReader(SERVER_ID);
+  }
+
+  @Test
+  public void createGroupAsUserIdent() throws Exception {
+    InternalGroup group = createGroupAsUser(1, "test-group");
+    AccountGroup.UUID uuid = group.getGroupUUID();
+
+    AccountGroupMemberAudit expAudit =
+        createExpMemberAudit(group.getId(), userId, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expAudit);
+  }
+
+  @Test
+  public void createGroupAsServerIdent() throws Exception {
+    InternalGroup group = createGroup(1, "test-group", serverIdent, null);
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, group.getGroupUUID())).hasSize(0);
+  }
+
+  @Test
+  public void addAndRemoveMember() throws Exception {
+    InternalGroup group = createGroupAsUser(1, "test-group");
+    AccountGroup.UUID uuid = group.getGroupUUID();
+
+    AccountGroupMemberAudit expAudit1 =
+        createExpMemberAudit(group.getId(), userId, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expAudit1);
+
+    // User adds account 100002 to the group.
+    Account.Id id = new Account.Id(100002);
+    addMembers(uuid, ImmutableSet.of(id));
+
+    AccountGroupMemberAudit expAudit2 =
+        createExpMemberAudit(group.getId(), id, userId, getTipTimestamp(uuid));
+    assertTipCommit(uuid, "Update group\n\nAdd: Account 100002 <100002@server-id>");
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
+        .containsExactly(expAudit1, expAudit2)
+        .inOrder();
+
+    // User removes account 100002 from the group.
+    removeMembers(uuid, ImmutableSet.of(id));
+    assertTipCommit(uuid, "Update group\n\nRemove: Account 100002 <100002@server-id>");
+
+    expAudit2.removed(userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
+        .containsExactly(expAudit1, expAudit2)
+        .inOrder();
+  }
+
+  @Test
+  public void addMultiMembers() throws Exception {
+    InternalGroup group = createGroupAsUser(1, "test-group");
+    AccountGroup.Id groupId = group.getId();
+    AccountGroup.UUID uuid = group.getGroupUUID();
+
+    AccountGroupMemberAudit expAudit1 =
+        createExpMemberAudit(groupId, userId, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expAudit1);
+
+    Account.Id id1 = new Account.Id(100002);
+    Account.Id id2 = new Account.Id(100003);
+    addMembers(uuid, ImmutableSet.of(id1, id2));
+
+    AccountGroupMemberAudit expAudit2 =
+        createExpMemberAudit(groupId, id1, userId, getTipTimestamp(uuid));
+    AccountGroupMemberAudit expAudit3 =
+        createExpMemberAudit(groupId, id2, userId, getTipTimestamp(uuid));
+
+    assertTipCommit(
+        uuid,
+        "Update group\n"
+            + "\n"
+            + "Add: Account 100002 <100002@server-id>\n"
+            + "Add: Account 100003 <100003@server-id>");
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
+        .containsExactly(expAudit1, expAudit2, expAudit3)
+        .inOrder();
+  }
+
+  @Test
+  public void addAndRemoveSubgroups() throws Exception {
+    InternalGroup group = createGroupAsUser(1, "test-group");
+    AccountGroup.UUID uuid = group.getGroupUUID();
+
+    InternalGroup subgroup = createGroupAsUser(2, "test-group-2");
+    AccountGroup.UUID subgroupUuid = subgroup.getGroupUUID();
+
+    addSubgroups(uuid, ImmutableSet.of(subgroupUuid));
+    assertTipCommit(uuid, String.format("Update group\n\nAdd-group: Group <%s>", subgroupUuid));
+
+    AccountGroupByIdAud expAudit =
+        createExpGroupAudit(group.getId(), subgroupUuid, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid)).containsExactly(expAudit);
+
+    removeSubgroups(uuid, ImmutableSet.of(subgroupUuid));
+    assertTipCommit(uuid, String.format("Update group\n\nRemove-group: Group <%s>", subgroupUuid));
+
+    expAudit.removed(userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid)).containsExactly(expAudit);
+  }
+
+  @Test
+  public void addMultiSubgroups() throws Exception {
+    InternalGroup group = createGroupAsUser(1, "test-group");
+    AccountGroup.UUID uuid = group.getGroupUUID();
+
+    InternalGroup subgroup1 = createGroupAsUser(2, "test-group-2");
+    InternalGroup subgroup2 = createGroupAsUser(3, "test-group-3");
+    AccountGroup.UUID subgroupUuid1 = subgroup1.getGroupUUID();
+    AccountGroup.UUID subgroupUuid2 = subgroup2.getGroupUUID();
+
+    addSubgroups(uuid, ImmutableSet.of(subgroupUuid1, subgroupUuid2));
+
+    assertTipCommit(
+        uuid,
+        "Update group\n"
+            + "\n"
+            + String.format("Add-group: Group <%s>\n", subgroupUuid1)
+            + String.format("Add-group: Group <%s>", subgroupUuid2));
+
+    AccountGroupByIdAud expAudit1 =
+        createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
+    AccountGroupByIdAud expAudit2 =
+        createExpGroupAudit(group.getId(), subgroupUuid2, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
+        .containsExactly(expAudit1, expAudit2)
+        .inOrder();
+  }
+
+  @Test
+  public void addAndRemoveMembersAndSubgroups() throws Exception {
+    InternalGroup group = createGroupAsUser(1, "test-group");
+    AccountGroup.Id groupId = group.getId();
+    AccountGroup.UUID uuid = group.getGroupUUID();
+    AccountGroupMemberAudit expMemberAudit =
+        createExpMemberAudit(groupId, userId, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expMemberAudit);
+
+    Account.Id id1 = new Account.Id(100002);
+    Account.Id id2 = new Account.Id(100003);
+    Account.Id id3 = new Account.Id(100004);
+    InternalGroup subgroup1 = createGroupAsUser(2, "test-group-2");
+    InternalGroup subgroup2 = createGroupAsUser(3, "test-group-3");
+    InternalGroup subgroup3 = createGroupAsUser(4, "test-group-4");
+    AccountGroup.UUID subgroupUuid1 = subgroup1.getGroupUUID();
+    AccountGroup.UUID subgroupUuid2 = subgroup2.getGroupUUID();
+    AccountGroup.UUID subgroupUuid3 = subgroup3.getGroupUUID();
+
+    // Add two accounts.
+    addMembers(uuid, ImmutableSet.of(id1, id2));
+    assertTipCommit(
+        uuid,
+        "Update group\n"
+            + "\n"
+            + String.format("Add: Account %s <%s@server-id>\n", id1, id1)
+            + String.format("Add: Account %s <%s@server-id>", id2, id2));
+    AccountGroupMemberAudit expMemberAudit1 =
+        createExpMemberAudit(groupId, id1, userId, getTipTimestamp(uuid));
+    AccountGroupMemberAudit expMemberAudit2 =
+        createExpMemberAudit(groupId, id2, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
+        .containsExactly(expMemberAudit, expMemberAudit1, expMemberAudit2)
+        .inOrder();
+
+    // Add one subgroup.
+    addSubgroups(uuid, ImmutableSet.of(subgroupUuid1));
+    assertTipCommit(uuid, String.format("Update group\n\nAdd-group: Group <%s>", subgroupUuid1));
+    AccountGroupByIdAud expGroupAudit1 =
+        createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
+        .containsExactly(expGroupAudit1);
+
+    // Remove one account.
+    removeMembers(uuid, ImmutableSet.of(id2));
+    assertTipCommit(
+        uuid, String.format("Update group\n\nRemove: Account %s <%s@server-id>", id2, id2));
+    expMemberAudit2.removed(userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
+        .containsExactly(expMemberAudit, expMemberAudit1, expMemberAudit2)
+        .inOrder();
+
+    // Add two subgroups.
+    addSubgroups(uuid, ImmutableSet.of(subgroupUuid2, subgroupUuid3));
+    assertTipCommit(
+        uuid,
+        "Update group\n"
+            + "\n"
+            + String.format("Add-group: Group <%s>\n", subgroupUuid2)
+            + String.format("Add-group: Group <%s>", subgroupUuid3));
+    AccountGroupByIdAud expGroupAudit2 =
+        createExpGroupAudit(group.getId(), subgroupUuid2, userId, getTipTimestamp(uuid));
+    AccountGroupByIdAud expGroupAudit3 =
+        createExpGroupAudit(group.getId(), subgroupUuid3, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
+        .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3)
+        .inOrder();
+
+    // Add two account, including a removed account.
+    addMembers(uuid, ImmutableSet.of(id2, id3));
+    assertTipCommit(
+        uuid,
+        "Update group\n"
+            + "\n"
+            + String.format("Add: Account %s <%s@server-id>\n", id2, id2)
+            + String.format("Add: Account %s <%s@server-id>", id3, id3));
+    AccountGroupMemberAudit expMemberAudit4 =
+        createExpMemberAudit(groupId, id2, userId, getTipTimestamp(uuid));
+    AccountGroupMemberAudit expMemberAudit3 =
+        createExpMemberAudit(groupId, id3, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
+        .containsExactly(
+            expMemberAudit, expMemberAudit1, expMemberAudit2, expMemberAudit4, expMemberAudit3)
+        .inOrder();
+
+    // Remove two subgroups.
+    removeSubgroups(uuid, ImmutableSet.of(subgroupUuid1, subgroupUuid3));
+    assertTipCommit(
+        uuid,
+        "Update group\n"
+            + "\n"
+            + String.format("Remove-group: Group <%s>\n", subgroupUuid1)
+            + String.format("Remove-group: Group <%s>", subgroupUuid3));
+    expGroupAudit1.removed(userId, getTipTimestamp(uuid));
+    expGroupAudit3.removed(userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
+        .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3)
+        .inOrder();
+
+    // Add back one removed subgroup.
+    addSubgroups(uuid, ImmutableSet.of(subgroupUuid1));
+    AccountGroupByIdAud expGroupAudit4 =
+        createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
+        .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3, expGroupAudit4)
+        .inOrder();
+  }
+
+  private InternalGroup createGroupAsUser(int next, String groupName) throws Exception {
+    return createGroup(next, groupName, userIdent, userId);
+  }
+
+  private InternalGroup createGroup(
+      int next, String groupName, PersonIdent authorIdent, Account.Id authorId) throws Exception {
+    InternalGroupCreation groupCreation =
+        InternalGroupCreation.builder()
+            .setGroupUUID(GroupUUID.make(groupName, serverIdent))
+            .setNameKey(new AccountGroup.NameKey(groupName))
+            .setId(new AccountGroup.Id(next))
+            .build();
+    InternalGroupUpdate groupUpdate =
+        authorIdent.equals(serverIdent)
+            ? InternalGroupUpdate.builder().setDescription("Groups").build()
+            : InternalGroupUpdate.builder()
+                .setDescription("Groups")
+                .setMemberModification(members -> ImmutableSet.of(authorId))
+                .build();
+
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
+    groupConfig.setGroupUpdate(
+        groupUpdate, AbstractGroupTest::getAccountNameEmail, AbstractGroupTest::getGroupName);
+
+    RevCommit commit = groupConfig.commit(createMetaDataUpdate(authorIdent));
+    assertCreateGroup(authorIdent, commit);
+    return groupConfig
+        .getLoadedGroup()
+        .orElseThrow(() -> new IllegalStateException("create group failed"));
+  }
+
+  private void assertCreateGroup(PersonIdent authorIdent, RevCommit commit) throws Exception {
+    if (authorIdent.equals(serverIdent)) {
+      assertServerCommit(CommitUtil.toCommitInfo(commit), "Create group");
+    } else {
+      assertCommit(
+          CommitUtil.toCommitInfo(commit),
+          String.format("Create group\n\nAdd: Account %s <%s@%s>", userId, userId, SERVER_ID),
+          getAccountName(userId),
+          getAccountEmail(userId));
+    }
+  }
+
+  private InternalGroup updateGroup(AccountGroup.UUID uuid, InternalGroupUpdate groupUpdate)
+      throws Exception {
+    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepo, uuid);
+    groupConfig.setGroupUpdate(
+        groupUpdate, AbstractGroupTest::getAccountNameEmail, AbstractGroupTest::getGroupName);
+
+    groupConfig.commit(createMetaDataUpdate(userIdent));
+    return groupConfig
+        .getLoadedGroup()
+        .orElseThrow(() -> new IllegalStateException("updated group failed"));
+  }
+
+  private InternalGroup addMembers(AccountGroup.UUID groupUuid, Set<Account.Id> ids)
+      throws Exception {
+    InternalGroupUpdate update =
+        InternalGroupUpdate.builder()
+            .setMemberModification(memberIds -> Sets.union(memberIds, ids))
+            .build();
+    return updateGroup(groupUuid, update);
+  }
+
+  private InternalGroup removeMembers(AccountGroup.UUID groupUuid, Set<Account.Id> ids)
+      throws Exception {
+    InternalGroupUpdate update =
+        InternalGroupUpdate.builder()
+            .setMemberModification(memberIds -> Sets.difference(memberIds, ids))
+            .build();
+    return updateGroup(groupUuid, update);
+  }
+
+  private InternalGroup addSubgroups(AccountGroup.UUID groupUuid, Set<AccountGroup.UUID> uuids)
+      throws Exception {
+    InternalGroupUpdate update =
+        InternalGroupUpdate.builder()
+            .setSubgroupModification(memberIds -> Sets.union(memberIds, uuids))
+            .build();
+    return updateGroup(groupUuid, update);
+  }
+
+  private InternalGroup removeSubgroups(AccountGroup.UUID groupUuid, Set<AccountGroup.UUID> uuids)
+      throws Exception {
+    InternalGroupUpdate update =
+        InternalGroupUpdate.builder()
+            .setSubgroupModification(memberIds -> Sets.difference(memberIds, uuids))
+            .build();
+    return updateGroup(groupUuid, update);
+  }
+
+  private AccountGroupMemberAudit createExpMemberAudit(
+      AccountGroup.Id groupId, Account.Id id, Account.Id addedBy, Timestamp addedOn) {
+    return new AccountGroupMemberAudit(
+        new AccountGroupMemberAudit.Key(id, groupId, addedOn), addedBy);
+  }
+
+  private AccountGroupByIdAud createExpGroupAudit(
+      AccountGroup.Id groupId, AccountGroup.UUID uuid, Account.Id addedBy, Timestamp addedOn) {
+    return new AccountGroupByIdAud(new AccountGroupByIdAud.Key(groupId, uuid, addedOn), addedBy);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/group/db/BUILD b/javatests/com/google/gerrit/server/group/db/BUILD
new file mode 100644
index 0000000..1ba0ce9
--- /dev/null
+++ b/javatests/com/google/gerrit/server/group/db/BUILD
@@ -0,0 +1,20 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "db_tests",
+    size = "small",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/extensions/common/testing:common-test-util",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/group/db/testing",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:gwtorm",
+        "//lib:truth",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/group/db/GroupBundleTest.java b/javatests/com/google/gerrit/server/group/db/GroupBundleTest.java
new file mode 100644
index 0000000..b449090
--- /dev/null
+++ b/javatests/com/google/gerrit/server/group/db/GroupBundleTest.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.server.group.db.GroupBundle.Source;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.sql.Timestamp;
+import java.util.TimeZone;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class GroupBundleTest extends GerritBaseTests {
+  // This class just contains sanity checks that GroupBundle#compare correctly compares all parts of
+  // the bundle. Most other test coverage should come via the slightly more realistic
+  // GroupRebuilderTest.
+
+  private static final String TIMEZONE_ID = "US/Eastern";
+
+  private String systemTimeZoneProperty;
+  private TimeZone systemTimeZone;
+  private Timestamp ts;
+
+  @Before
+  public void setUp() {
+    systemTimeZoneProperty = System.setProperty("user.timezone", TIMEZONE_ID);
+    systemTimeZone = TimeZone.getDefault();
+    TimeZone.setDefault(TimeZone.getTimeZone(TIMEZONE_ID));
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+    ts = TimeUtil.nowTs();
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZoneProperty);
+    TimeZone.setDefault(systemTimeZone);
+  }
+
+  @Test
+  public void compareNonEqual() throws Exception {
+    GroupBundle reviewDbBundle = newBundle().source(Source.REVIEW_DB).build();
+    AccountGroup g2 = new AccountGroup(reviewDbBundle.group());
+    g2.setDescription("Hello!");
+    GroupBundle noteDbBundle = GroupBundle.builder().source(Source.NOTE_DB).group(g2).build();
+    assertThat(GroupBundle.compareWithAudits(reviewDbBundle, noteDbBundle))
+        .containsExactly(
+            "AccountGroups differ\n"
+                + ("ReviewDb: AccountGroup{name=group, groupId=1, description=null,"
+                    + " visibleToAll=false, groupUUID=group-1, ownerGroupUUID=group-1,"
+                    + " createdOn=2009-09-30 17:00:00.0}\n")
+                + ("NoteDb  : AccountGroup{name=group, groupId=1, description=Hello!,"
+                    + " visibleToAll=false, groupUUID=group-1, ownerGroupUUID=group-1,"
+                    + " createdOn=2009-09-30 17:00:00.0}"),
+            "AccountGroupMembers differ\n"
+                + "ReviewDb: [AccountGroupMember{key=1000,1}]\n"
+                + "NoteDb  : []",
+            "AccountGroupMemberAudits differ\n"
+                + ("ReviewDb: [AccountGroupMemberAudit{key=Key{groupId=1, accountId=1000,"
+                    + " addedOn=2009-09-30 17:00:00.0}, addedBy=2000, removedBy=null,"
+                    + " removedOn=null}]\n")
+                + "NoteDb  : []",
+            "AccountGroupByIds differ\n"
+                + "ReviewDb: [AccountGroupById{key=1,subgroup}]\n"
+                + "NoteDb  : []",
+            "AccountGroupByIdAudits differ\n"
+                + ("ReviewDb: [AccountGroupByIdAud{key=Key{groupId=1, includeUUID=subgroup,"
+                    + " addedOn=2009-09-30 17:00:00.0}, addedBy=3000, removedBy=null,"
+                    + " removedOn=null}]\n")
+                + "NoteDb  : []");
+  }
+
+  @Test
+  public void compareIgnoreAudits() throws Exception {
+    GroupBundle reviewDbBundle = newBundle().source(Source.REVIEW_DB).build();
+    AccountGroup group = new AccountGroup(reviewDbBundle.group());
+
+    AccountGroupMember member =
+        new AccountGroupMember(new AccountGroupMember.Key(new Account.Id(1), group.getId()));
+    AccountGroupMemberAudit memberAudit =
+        new AccountGroupMemberAudit(member, new Account.Id(2), ts);
+    AccountGroupById byId =
+        new AccountGroupById(
+            new AccountGroupById.Key(group.getId(), new AccountGroup.UUID("subgroup-2")));
+    AccountGroupByIdAud byIdAudit = new AccountGroupByIdAud(byId, new Account.Id(3), ts);
+
+    GroupBundle noteDbBundle =
+        newBundle().source(Source.NOTE_DB).memberAudit(memberAudit).byIdAudit(byIdAudit).build();
+
+    assertThat(GroupBundle.compareWithAudits(reviewDbBundle, noteDbBundle)).isNotEmpty();
+    assertThat(GroupBundle.compareWithoutAudits(reviewDbBundle, noteDbBundle)).isEmpty();
+  }
+
+  @Test
+  public void compareEqual() throws Exception {
+    GroupBundle reviewDbBundle = newBundle().source(Source.REVIEW_DB).build();
+    GroupBundle noteDbBundle = newBundle().source(Source.NOTE_DB).build();
+    assertThat(GroupBundle.compareWithAudits(reviewDbBundle, noteDbBundle)).isEmpty();
+  }
+
+  private GroupBundle.Builder newBundle() {
+    AccountGroup group =
+        new AccountGroup(
+            new AccountGroup.NameKey("group"),
+            new AccountGroup.Id(1),
+            new AccountGroup.UUID("group-1"),
+            ts);
+    AccountGroupMember member =
+        new AccountGroupMember(new AccountGroupMember.Key(new Account.Id(1000), group.getId()));
+    AccountGroupMemberAudit memberAudit =
+        new AccountGroupMemberAudit(member, new Account.Id(2000), ts);
+    AccountGroupById byId =
+        new AccountGroupById(
+            new AccountGroupById.Key(group.getId(), new AccountGroup.UUID("subgroup")));
+    AccountGroupByIdAud byIdAudit = new AccountGroupByIdAud(byId, new Account.Id(3000), ts);
+    return GroupBundle.builder()
+        .group(group)
+        .members(member)
+        .memberAudit(memberAudit)
+        .byId(byId)
+        .byIdAudit(byIdAudit);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
new file mode 100644
index 0000000..6240f9c
--- /dev/null
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -0,0 +1,263 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.hamcrest.CoreMatchers.instanceOf;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import java.util.TimeZone;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class GroupConfigTest {
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  private Repository repository;
+  private TestRepository<?> testRepository;
+  private final AccountGroup.UUID groupUuid = new AccountGroup.UUID("users-XYZ");
+  private final AccountGroup.NameKey groupName = new AccountGroup.NameKey("users");
+  private final AccountGroup.Id groupId = new AccountGroup.Id(123);
+
+  @Before
+  public void setUp() throws Exception {
+    repository = new InMemoryRepository(new DfsRepositoryDescription("Test Repository"));
+    testRepository = new TestRepository<>(repository);
+  }
+
+  @Test
+  public void nameOfNewGroupMustNotBeNull() throws Exception {
+    InternalGroupCreation groupCreation =
+        getPrefilledGroupCreationBuilder().setNameKey(new AccountGroup.NameKey(null)).build();
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
+      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
+      expectedException.expectMessage("Name of the group users-XYZ");
+      groupConfig.commit(metaDataUpdate);
+    }
+  }
+
+  @Test
+  public void nameOfNewGroupMustNotBeEmpty() throws Exception {
+    InternalGroupCreation groupCreation =
+        getPrefilledGroupCreationBuilder().setNameKey(new AccountGroup.NameKey("")).build();
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
+      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
+      expectedException.expectMessage("Name of the group users-XYZ");
+      groupConfig.commit(metaDataUpdate);
+    }
+  }
+
+  @Test
+  public void idOfNewGroupMustNotBeNegative() throws Exception {
+    InternalGroupCreation groupCreation =
+        getPrefilledGroupCreationBuilder().setId(new AccountGroup.Id(-2)).build();
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
+      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
+      expectedException.expectMessage("ID of the group users-XYZ");
+      groupConfig.commit(metaDataUpdate);
+    }
+  }
+
+  @Test
+  public void ownerUuidOfNewGroupMustNotBeNull() throws Exception {
+    InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID(null)).build();
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    groupConfig.setGroupUpdate(groupUpdate, Account.Id::toString, AccountGroup.UUID::get);
+
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
+      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
+      expectedException.expectMessage("Owner UUID of the group users-XYZ");
+      groupConfig.commit(metaDataUpdate);
+    }
+  }
+
+  @Test
+  public void ownerUuidOfNewGroupMustNotBeEmpty() throws Exception {
+    InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID("")).build();
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    groupConfig.setGroupUpdate(groupUpdate, Account.Id::toString, AccountGroup.UUID::get);
+
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
+      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
+      expectedException.expectMessage("Owner UUID of the group users-XYZ");
+      groupConfig.commit(metaDataUpdate);
+    }
+  }
+
+  @Test
+  public void nameInConfigMayBeUndefined() throws Exception {
+    populateGroupConfig(groupUuid, "[group]\n\tid = 42\n\townerGroupUuid = owners\n");
+
+    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    assertThat(groupConfig.getLoadedGroup().get().getName()).isEmpty();
+  }
+
+  @Test
+  public void nameInConfigMayBeEmpty() throws Exception {
+    populateGroupConfig(groupUuid, "[group]\n\tname=\n\tid = 42\n\townerGroupUuid = owners\n");
+
+    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    assertThat(groupConfig.getLoadedGroup().get().getName()).isEmpty();
+  }
+
+  @Test
+  public void idInConfigMustBeDefined() throws Exception {
+    populateGroupConfig(groupUuid, "[group]\n\tname = users\n\townerGroupUuid = owners\n");
+
+    expectedException.expect(ConfigInvalidException.class);
+    expectedException.expectMessage("ID of the group users-XYZ");
+    GroupConfig.loadForGroup(repository, groupUuid);
+  }
+
+  @Test
+  public void idInConfigMustNotBeNegative() throws Exception {
+    populateGroupConfig(
+        groupUuid, "[group]\n\tname = users\n\tid = -5\n\townerGroupUuid = owners\n");
+
+    expectedException.expect(ConfigInvalidException.class);
+    expectedException.expectMessage("ID of the group users-XYZ");
+    GroupConfig.loadForGroup(repository, groupUuid);
+  }
+
+  @Test
+  public void ownerUuidInConfigMustBeDefined() throws Exception {
+    populateGroupConfig(groupUuid, "[group]\n\tname = users\n\tid = 42\n");
+
+    expectedException.expect(ConfigInvalidException.class);
+    expectedException.expectMessage("Owner UUID of the group users-XYZ");
+    GroupConfig.loadForGroup(repository, groupUuid);
+  }
+
+  @Test
+  public void nameCannotBeUpdatedToNull() throws Exception {
+    populateGroupConfig(
+        groupUuid, "[group]\n\tname = users\n\tid = 42\n\townerGroupUuid = owners\n");
+
+    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey(null)).build();
+    groupConfig.setGroupUpdate(groupUpdate, Account.Id::toString, AccountGroup.UUID::get);
+
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
+      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
+      expectedException.expectMessage("Name of the group users-XYZ");
+      groupConfig.commit(metaDataUpdate);
+    }
+  }
+
+  @Test
+  public void nameCannotBeUpdatedToEmptyString() throws Exception {
+    populateGroupConfig(
+        groupUuid, "[group]\n\tname = users\n\tid = 42\n\townerGroupUuid = owners\n");
+
+    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder().setName(new AccountGroup.NameKey("")).build();
+    groupConfig.setGroupUpdate(groupUpdate, Account.Id::toString, AccountGroup.UUID::get);
+
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
+      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
+      expectedException.expectMessage("Name of the group users-XYZ");
+      groupConfig.commit(metaDataUpdate);
+    }
+  }
+
+  @Test
+  public void ownerUuidCannotBeUpdatedToNull() throws Exception {
+    populateGroupConfig(
+        groupUuid, "[group]\n\tname = users\n\tid = 42\n\townerGroupUuid = owners\n");
+
+    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID(null)).build();
+    groupConfig.setGroupUpdate(groupUpdate, Account.Id::toString, AccountGroup.UUID::get);
+
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
+      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
+      expectedException.expectMessage("Owner UUID of the group users-XYZ");
+      groupConfig.commit(metaDataUpdate);
+    }
+  }
+
+  @Test
+  public void ownerUuidCannotBeUpdatedToEmptyString() throws Exception {
+    populateGroupConfig(
+        groupUuid, "[group]\n\tname = users\n\tid = 42\n\townerGroupUuid = owners\n");
+
+    GroupConfig groupConfig = GroupConfig.loadForGroup(repository, groupUuid);
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder().setOwnerGroupUUID(new AccountGroup.UUID("")).build();
+    groupConfig.setGroupUpdate(groupUpdate, Account.Id::toString, AccountGroup.UUID::get);
+
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
+      expectedException.expectCause(instanceOf(ConfigInvalidException.class));
+      expectedException.expectMessage("Owner UUID of the group users-XYZ");
+      groupConfig.commit(metaDataUpdate);
+    }
+  }
+
+  private InternalGroupCreation.Builder getPrefilledGroupCreationBuilder() {
+    return InternalGroupCreation.builder()
+        .setGroupUUID(groupUuid)
+        .setNameKey(groupName)
+        .setId(groupId);
+  }
+
+  private void populateGroupConfig(AccountGroup.UUID uuid, String fileContent) throws Exception {
+    testRepository
+        .branch(RefNames.refsGroups(uuid))
+        .commit()
+        .message("Prepopulate group.config")
+        .add(GroupConfig.GROUP_CONFIG_FILE, fileContent)
+        .create();
+  }
+
+  private MetaDataUpdate createMetaDataUpdate() {
+    TimeZone tz = TimeZone.getTimeZone("America/Los_Angeles");
+    PersonIdent serverIdent =
+        new PersonIdent("Gerrit Server", "noreply@gerritcodereview.com", TimeUtil.nowTs(), tz);
+
+    MetaDataUpdate metaDataUpdate =
+        new MetaDataUpdate(
+            GitReferenceUpdated.DISABLED, new Project.NameKey("Test Repository"), repository);
+    metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
+    metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
+    return metaDataUpdate;
+  }
+}
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
new file mode 100644
index 0000000..d4ddbcf
--- /dev/null
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -0,0 +1,228 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_GROUPNAMES;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.group.db.testing.GroupTestUtil;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.util.Arrays;
+import java.util.TimeZone;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class GroupNameNotesTest extends GerritBaseTests {
+  private static final String SERVER_NAME = "Gerrit Server";
+  private static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
+  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+
+  private AtomicInteger idCounter;
+  private Repository repo;
+
+  @Before
+  public void setUp() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+    idCounter = new AtomicInteger();
+    repo = new InMemoryRepository(new DfsRepositoryDescription(AllUsersNameProvider.DEFAULT));
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void updateGroupNames() throws Exception {
+    GroupReference g1 = newGroup("a");
+    GroupReference g2 = newGroup("b");
+
+    PersonIdent ident = newPersonIdent();
+    updateGroupNames(ident, g1, g2);
+
+    ImmutableList<CommitInfo> log = log();
+    assertThat(log).hasSize(1);
+    assertThat(log.get(0)).parents().isEmpty();
+    assertThat(log.get(0)).message().isEqualTo("Store 2 group names");
+    assertThat(log.get(0)).author().matches(ident);
+    assertThat(log.get(0)).committer().matches(ident);
+
+    assertThat(GroupTestUtil.readNameToUuidMap(repo)).containsExactly("a", "a-1", "b", "b-2");
+
+    // Updating the same set of names is a no-op.
+    String commit = log.get(0).commit;
+    updateGroupNames(newPersonIdent(), g1, g2);
+    log = log();
+    assertThat(log).hasSize(1);
+    assertThat(log.get(0)).commit().isEqualTo(commit);
+  }
+
+  @Test
+  public void updateGroupNamesOverwritesExistingNotes() throws Exception {
+    GroupReference g1 = newGroup("a");
+    GroupReference g2 = newGroup("b");
+
+    TestRepository<?> tr = new TestRepository<>(repo);
+    ObjectId k1 = getNoteKey(g1);
+    ObjectId k2 = getNoteKey(g2);
+    ObjectId k3 = GroupNameNotes.getNoteKey(new AccountGroup.NameKey("c"));
+    PersonIdent ident = newPersonIdent();
+    ObjectId origCommitId =
+        tr.branch(REFS_GROUPNAMES)
+            .commit()
+            .message("Prepopulate group name")
+            .author(ident)
+            .committer(ident)
+            .add(k1.name(), "[group]\n\tuuid = a-1\n\tname = a\nanotherKey = foo\n")
+            .add(k2.name(), "[group]\n\tuuid = a-1\n\tname = b\n")
+            .add(k3.name(), "[group]\n\tuuid = c-3\n\tname = c\n")
+            .create()
+            .copy();
+
+    ident = newPersonIdent();
+    updateGroupNames(ident, g1, g2);
+
+    assertThat(GroupTestUtil.readNameToUuidMap(repo)).containsExactly("a", "a-1", "b", "b-2");
+
+    ImmutableList<CommitInfo> log = log();
+    assertThat(log).hasSize(2);
+    assertThat(log.get(0)).commit().isEqualTo(origCommitId.name());
+
+    assertThat(log.get(1)).message().isEqualTo("Store 2 group names");
+    assertThat(log.get(1)).author().matches(ident);
+    assertThat(log.get(1)).committer().matches(ident);
+
+    // Old note content was overwritten.
+    assertThat(readNameNote(g1)).isEqualTo("[group]\n\tuuid = a-1\n\tname = a\n");
+  }
+
+  @Test
+  public void updateGroupNamesWithEmptyCollectionClearsAllNotes() throws Exception {
+    GroupReference g1 = newGroup("a");
+    GroupReference g2 = newGroup("b");
+
+    PersonIdent ident = newPersonIdent();
+    updateGroupNames(ident, g1, g2);
+
+    assertThat(GroupTestUtil.readNameToUuidMap(repo)).containsExactly("a", "a-1", "b", "b-2");
+
+    updateGroupNames(ident);
+
+    assertThat(GroupTestUtil.readNameToUuidMap(repo)).isEmpty();
+
+    ImmutableList<CommitInfo> log = log();
+    assertThat(log).hasSize(2);
+    assertThat(log.get(1)).message().isEqualTo("Store 0 group names");
+  }
+
+  @Test
+  public void updateGroupNamesRejectsNonOneToOneGroupReferences() throws Exception {
+    assertIllegalArgument(
+        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
+        new GroupReference(new AccountGroup.UUID("uuid1"), "name2"));
+    assertIllegalArgument(
+        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
+        new GroupReference(new AccountGroup.UUID("uuid2"), "name1"));
+    assertIllegalArgument(
+        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
+        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"));
+  }
+
+  @Test
+  public void emptyGroupName() throws Exception {
+    GroupReference g = newGroup("");
+    updateGroupNames(newPersonIdent(), g);
+
+    assertThat(GroupTestUtil.readNameToUuidMap(repo)).containsExactly("", "-1");
+    assertThat(readNameNote(g)).isEqualTo("[group]\n\tuuid = -1\n\tname = \n");
+  }
+
+  private GroupReference newGroup(String name) {
+    int id = idCounter.incrementAndGet();
+    return new GroupReference(new AccountGroup.UUID(name + "-" + id), name);
+  }
+
+  private static PersonIdent newPersonIdent() {
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+  }
+
+  private static ObjectId getNoteKey(GroupReference g) {
+    return GroupNameNotes.getNoteKey(new AccountGroup.NameKey(g.getName()));
+  }
+
+  private void updateGroupNames(PersonIdent ident, GroupReference... groupRefs) throws Exception {
+    try (ObjectInserter inserter = repo.newObjectInserter()) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      GroupNameNotes.updateGroupNames(repo, inserter, bru, Arrays.asList(groupRefs), ident);
+      inserter.flush();
+      RefUpdateUtil.executeChecked(bru, repo);
+    }
+  }
+
+  private void assertIllegalArgument(GroupReference... groupRefs) throws Exception {
+    try (ObjectInserter inserter = repo.newObjectInserter()) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      PersonIdent ident = newPersonIdent();
+      try {
+        GroupNameNotes.updateGroupNames(repo, inserter, bru, Arrays.asList(groupRefs), ident);
+        assert_().fail("Expected IllegalArgumentException");
+      } catch (IllegalArgumentException e) {
+        assertThat(e).hasMessageThat().isEqualTo(GroupNameNotes.UNIQUE_REF_ERROR);
+      }
+    }
+  }
+
+  private ImmutableList<CommitInfo> log() throws Exception {
+    return GroupTestUtil.log(repo, REFS_GROUPNAMES);
+  }
+
+  private String readNameNote(GroupReference g) throws Exception {
+    ObjectId k = getNoteKey(g);
+    try (RevWalk rw = new RevWalk(repo)) {
+      ObjectReader reader = rw.getObjectReader();
+      Ref ref = repo.exactRef(RefNames.REFS_GROUPNAMES);
+      NoteMap noteMap = NoteMap.read(reader, rw.parseCommit(ref.getObjectId()));
+      return new String(reader.open(noteMap.get(k), OBJ_BLOB).getCachedBytes(), UTF_8);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/group/db/GroupRebuilderTest.java b/javatests/com/google/gerrit/server/group/db/GroupRebuilderTest.java
new file mode 100644
index 0000000..51cf987
--- /dev/null
+++ b/javatests/com/google/gerrit/server/group/db/GroupRebuilderTest.java
@@ -0,0 +1,668 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group.db;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_GROUPNAMES;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.group.db.testing.GroupTestUtil;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import java.sql.Timestamp;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class GroupRebuilderTest extends AbstractGroupTest {
+  private AtomicInteger idCounter;
+  private Repository repo;
+  private GroupRebuilder rebuilder;
+  private GroupBundle.Factory bundleFactory;
+
+  @Before
+  public void setUp() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+    idCounter = new AtomicInteger();
+    repo = repoManager.createRepository(allUsersName);
+    rebuilder =
+        new GroupRebuilder(
+            GroupRebuilderTest::newPersonIdent,
+            allUsersName,
+            (project, repo, batch) ->
+                new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, repo, batch),
+            // Note that the expected name/email values in tests are not necessarily realistic,
+            // since they use these trivial name/email functions. GroupRebuilderIT checks the actual
+            // values.
+            AbstractGroupTest::newPersonIdent,
+            AbstractGroupTest::getAccountNameEmail,
+            AbstractGroupTest::getGroupName);
+    bundleFactory = new GroupBundle.Factory(new AuditLogReader(SERVER_ID));
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void minimalGroupFields() throws Exception {
+    AccountGroup g = newGroup("a");
+    GroupBundle b = builder().group(g).build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertMigratedCleanly(reload(g), b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(1);
+    assertCommit(log.get(0), "Create group", SERVER_NAME, SERVER_EMAIL);
+    assertThat(logGroupNames()).isEmpty();
+  }
+
+  @Test
+  public void allGroupFields() throws Exception {
+    AccountGroup g = newGroup("a");
+    g.setDescription("Description");
+    g.setOwnerGroupUUID(new AccountGroup.UUID("owner"));
+    g.setVisibleToAll(true);
+    GroupBundle b = builder().group(g).build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertMigratedCleanly(reload(g), b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(1);
+    assertServerCommit(log.get(0), "Create group");
+  }
+
+  @Test
+  public void emptyGroupName() throws Exception {
+    AccountGroup g = newGroup("");
+    GroupBundle b = builder().group(g).build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    GroupBundle noteDbBundle = reload(g);
+    assertMigratedCleanly(noteDbBundle, b);
+    assertThat(noteDbBundle.group().getName()).isEmpty();
+  }
+
+  @Test
+  public void nullGroupDescription() throws Exception {
+    AccountGroup g = newGroup("a");
+    g.setDescription(null);
+    assertThat(g.getDescription()).isNull();
+    GroupBundle b = builder().group(g).build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    GroupBundle noteDbBundle = reload(g);
+    assertMigratedCleanly(noteDbBundle, b);
+    assertThat(noteDbBundle.group().getDescription()).isNull();
+  }
+
+  @Test
+  public void emptyGroupDescription() throws Exception {
+    AccountGroup g = newGroup("a");
+    g.setDescription("");
+    assertThat(g.getDescription()).isEmpty();
+    GroupBundle b = builder().group(g).build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    GroupBundle noteDbBundle = reload(g);
+    assertMigratedCleanly(noteDbBundle, b);
+    assertThat(noteDbBundle.group().getDescription()).isNull();
+  }
+
+  @Test
+  public void membersAndSubgroups() throws Exception {
+    AccountGroup g = newGroup("a");
+    GroupBundle b =
+        builder()
+            .group(g)
+            .members(member(g, 1), member(g, 2))
+            .byId(byId(g, "x"), byId(g, "y"))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertMigratedCleanly(reload(g), b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(2);
+    assertServerCommit(log.get(0), "Create group");
+    assertServerCommit(
+        log.get(1),
+        "Update group\n"
+            + "\n"
+            + "Add: Account 1 <1@server-id>\n"
+            + "Add: Account 2 <2@server-id>\n"
+            + "Add-group: Group <x>\n"
+            + "Add-group: Group <y>");
+  }
+
+  @Test
+  public void memberAudit() throws Exception {
+    AccountGroup g = newGroup("a");
+    Timestamp t1 = TimeUtil.nowTs();
+    Timestamp t2 = TimeUtil.nowTs();
+    Timestamp t3 = TimeUtil.nowTs();
+    GroupBundle b =
+        builder()
+            .group(g)
+            .members(member(g, 1))
+            .memberAudit(addMember(g, 1, 8, t2), addAndRemoveMember(g, 2, 8, t1, 9, t3))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertMigratedCleanly(reload(g), b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(4);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(
+        log.get(1), "Update group\n\nAdd: Account 2 <2@server-id>", "Account 8", "8@server-id");
+    assertCommit(
+        log.get(2), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 8", "8@server-id");
+    assertCommit(
+        log.get(3), "Update group\n\nRemove: Account 2 <2@server-id>", "Account 9", "9@server-id");
+  }
+
+  @Test
+  public void memberAuditLegacyRemoved() throws Exception {
+    AccountGroup g = newGroup("a");
+    GroupBundle b =
+        builder()
+            .group(g)
+            .members(member(g, 2))
+            .memberAudit(
+                addAndLegacyRemoveMember(g, 1, 8, TimeUtil.nowTs()),
+                addMember(g, 2, 8, TimeUtil.nowTs()))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertMigratedCleanly(reload(g), b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(4);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(
+        log.get(1), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 8", "8@server-id");
+    assertCommit(
+        log.get(2), "Update group\n\nRemove: Account 1 <1@server-id>", "Account 8", "8@server-id");
+    assertCommit(
+        log.get(3), "Update group\n\nAdd: Account 2 <2@server-id>", "Account 8", "8@server-id");
+  }
+
+  @Test
+  public void unauditedMembershipsAddedAtEnd() throws Exception {
+    AccountGroup g = newGroup("a");
+    GroupBundle b =
+        builder()
+            .group(g)
+            .members(member(g, 1), member(g, 2), member(g, 3))
+            .memberAudit(addMember(g, 1, 8, TimeUtil.nowTs()))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertMigratedCleanly(reload(g), b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(3);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(
+        log.get(1), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 8", "8@server-id");
+    assertServerCommit(
+        log.get(2), "Update group\n\nAdd: Account 2 <2@server-id>\nAdd: Account 3 <3@server-id>");
+  }
+
+  @Test
+  public void byIdAudit() throws Exception {
+    AccountGroup g = newGroup("a");
+    Timestamp t1 = TimeUtil.nowTs();
+    Timestamp t2 = TimeUtil.nowTs();
+    Timestamp t3 = TimeUtil.nowTs();
+    GroupBundle b =
+        builder()
+            .group(g)
+            .byId(byId(g, "x"))
+            .byIdAudit(addById(g, "x", 8, t2), addAndRemoveById(g, "y", 8, t1, 9, t3))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertMigratedCleanly(reload(g), b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(4);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(log.get(1), "Update group\n\nAdd-group: Group <y>", "Account 8", "8@server-id");
+    assertCommit(log.get(2), "Update group\n\nAdd-group: Group <x>", "Account 8", "8@server-id");
+    assertCommit(log.get(3), "Update group\n\nRemove-group: Group <y>", "Account 9", "9@server-id");
+  }
+
+  @Test
+  public void unauditedByIdAddedAtEnd() throws Exception {
+    AccountGroup g = newGroup("a");
+    GroupBundle b =
+        builder()
+            .group(g)
+            .byId(byId(g, "x"), byId(g, "y"), byId(g, "z"))
+            .byIdAudit(addById(g, "x", 8, TimeUtil.nowTs()))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertMigratedCleanly(reload(g), b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(3);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(log.get(1), "Update group\n\nAdd-group: Group <x>", "Account 8", "8@server-id");
+    assertServerCommit(log.get(2), "Update group\n\nAdd-group: Group <y>\nAdd-group: Group <z>");
+  }
+
+  @Test
+  public void auditsAtSameTimestampBrokenDownByType() throws Exception {
+    AccountGroup g = newGroup("a");
+    Timestamp ts = TimeUtil.nowTs();
+    int user = 8;
+    GroupBundle b =
+        builder()
+            .group(g)
+            .members(member(g, 1), member(g, 2))
+            .memberAudit(
+                addMember(g, 1, user, ts),
+                addMember(g, 2, user, ts),
+                addAndRemoveMember(g, 3, user, ts, user, ts))
+            .byId(byId(g, "x"), byId(g, "y"))
+            .byIdAudit(
+                addById(g, "x", user, ts),
+                addById(g, "y", user, ts),
+                addAndRemoveById(g, "z", user, ts, user, ts))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertMigratedCleanly(reload(g), b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(5);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(
+        log.get(1),
+        "Update group\n"
+            + "\n"
+            + "Add: Account 1 <1@server-id>\n"
+            + "Add: Account 2 <2@server-id>\n"
+            + "Add: Account 3 <3@server-id>",
+        "Account 8",
+        "8@server-id");
+    assertCommit(
+        log.get(2), "Update group\n\nRemove: Account 3 <3@server-id>", "Account 8", "8@server-id");
+    assertCommit(
+        log.get(3),
+        "Update group\n"
+            + "\n"
+            + "Add-group: Group <x>\n"
+            + "Add-group: Group <y>\n"
+            + "Add-group: Group <z>",
+        "Account 8",
+        "8@server-id");
+    assertCommit(log.get(4), "Update group\n\nRemove-group: Group <z>", "Account 8", "8@server-id");
+  }
+
+  @Test
+  public void auditsAtSameTimestampBrokenDownByUserAndType() throws Exception {
+    AccountGroup g = newGroup("a");
+    Timestamp ts = TimeUtil.nowTs();
+    int user1 = 8;
+    int user2 = 9;
+
+    GroupBundle b =
+        builder()
+            .group(g)
+            .members(member(g, 1), member(g, 2), member(g, 3))
+            .memberAudit(
+                addMember(g, 1, user1, ts), addMember(g, 2, user2, ts), addMember(g, 3, user1, ts))
+            .byId(byId(g, "x"), byId(g, "y"), byId(g, "z"))
+            .byIdAudit(
+                addById(g, "x", user1, ts), addById(g, "y", user2, ts), addById(g, "z", user1, ts))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertMigratedCleanly(reload(g), b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(5);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(
+        log.get(1),
+        "Update group\n" + "\n" + "Add: Account 1 <1@server-id>\n" + "Add: Account 3 <3@server-id>",
+        "Account 8",
+        "8@server-id");
+    assertCommit(
+        log.get(2),
+        "Update group\n\nAdd-group: Group <x>\nAdd-group: Group <z>",
+        "Account 8",
+        "8@server-id");
+    assertCommit(
+        log.get(3), "Update group\n\nAdd: Account 2 <2@server-id>", "Account 9", "9@server-id");
+    assertCommit(log.get(4), "Update group\n\nAdd-group: Group <y>", "Account 9", "9@server-id");
+  }
+
+  @Test
+  public void fixupCommitPostDatesAllAuditEventsEvenIfAuditEventsAreInTheFuture() throws Exception {
+    AccountGroup g = newGroup("a");
+    IntStream.range(0, 20).forEach(i -> TimeUtil.nowTs());
+    Timestamp future = TimeUtil.nowTs();
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+
+    GroupBundle b =
+        builder()
+            .group(g)
+            .byId(byId(g, "x"), byId(g, "y"), byId(g, "z"))
+            .byIdAudit(addById(g, "x", 8, future))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertMigratedCleanly(reload(g), b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(3);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(log.get(1), "Update group\n\nAdd-group: Group <x>", "Account 8", "8@server-id");
+    assertServerCommit(log.get(2), "Update group\n\nAdd-group: Group <y>\nAdd-group: Group <z>");
+
+    assertThat(log.stream().map(c -> c.committer.date).collect(toImmutableList()))
+        .named("%s", log)
+        .isOrdered();
+    assertThat(TimeUtil.nowTs()).isLessThan(future);
+  }
+
+  @Test
+  public void redundantMemberAuditsAreIgnored() throws Exception {
+    AccountGroup g = newGroup("a");
+    Timestamp t1 = TimeUtil.nowTs();
+    Timestamp t2 = TimeUtil.nowTs();
+    Timestamp t3 = TimeUtil.nowTs();
+    Timestamp t4 = TimeUtil.nowTs();
+    Timestamp t5 = TimeUtil.nowTs();
+    GroupBundle b =
+        builder()
+            .group(g)
+            .members(member(g, 2))
+            .memberAudit(
+                addMember(g, 1, 8, t1),
+                addMember(g, 1, 8, t1),
+                addMember(g, 1, 8, t3),
+                addMember(g, 1, 9, t4),
+                addAndRemoveMember(g, 1, 8, t2, 9, t5),
+                addAndLegacyRemoveMember(g, 2, 9, t3),
+                addMember(g, 2, 8, t1),
+                addMember(g, 2, 9, t4),
+                addMember(g, 1, 8, t5))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertMigratedCleanly(reload(g), b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(5);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(
+        log.get(1),
+        "Update group\n\nAdd: Account 1 <1@server-id>\nAdd: Account 2 <2@server-id>",
+        "Account 8",
+        "8@server-id");
+    assertCommit(
+        log.get(2), "Update group\n\nRemove: Account 2 <2@server-id>", "Account 9", "9@server-id");
+    assertCommit(
+        log.get(3), "Update group\n\nAdd: Account 2 <2@server-id>", "Account 9", "9@server-id");
+    assertCommit(
+        log.get(4), "Update group\n\nRemove: Account 1 <1@server-id>", "Account 9", "9@server-id");
+  }
+
+  @Test
+  public void additionsAndRemovalsWithinSameSecondCanBeMigrated() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.MILLISECONDS);
+    AccountGroup g = newGroup("a");
+    Timestamp t1 = TimeUtil.nowTs();
+    Timestamp t2 = TimeUtil.nowTs();
+    Timestamp t3 = TimeUtil.nowTs();
+    Timestamp t4 = TimeUtil.nowTs();
+    Timestamp t5 = TimeUtil.nowTs();
+    GroupBundle b =
+        builder()
+            .group(g)
+            .members(member(g, 1))
+            .memberAudit(
+                addAndLegacyRemoveMember(g, 1, 8, t1),
+                addMember(g, 1, 10, t2),
+                addAndRemoveMember(g, 1, 8, t3, 9, t4),
+                addMember(g, 1, 8, t5))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertMigratedCleanly(reload(g), b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(6);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(
+        log.get(1), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 8", "8@server-id");
+    assertCommit(
+        log.get(2), "Update group\n\nRemove: Account 1 <1@server-id>", "Account 8", "8@server-id");
+    assertCommit(
+        log.get(3), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 10", "10@server-id");
+    assertCommit(
+        log.get(4), "Update group\n\nRemove: Account 1 <1@server-id>", "Account 9", "9@server-id");
+    assertCommit(
+        log.get(5), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 8", "8@server-id");
+  }
+
+  @Test
+  public void redundantByIdAuditsAreIgnored() throws Exception {
+    AccountGroup g = newGroup("a");
+    Timestamp t1 = TimeUtil.nowTs();
+    Timestamp t2 = TimeUtil.nowTs();
+    Timestamp t3 = TimeUtil.nowTs();
+    Timestamp t4 = TimeUtil.nowTs();
+    Timestamp t5 = TimeUtil.nowTs();
+    GroupBundle b =
+        builder()
+            .group(g)
+            .byId()
+            .byIdAudit(
+                addById(g, "x", 8, t1),
+                addById(g, "x", 8, t3),
+                addById(g, "x", 9, t4),
+                addAndRemoveById(g, "x", 8, t2, 9, t5))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertMigratedCleanly(reload(g), b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(3);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(log.get(1), "Update group\n\nAdd-group: Group <x>", "Account 8", "8@server-id");
+    assertCommit(log.get(2), "Update group\n\nRemove-group: Group <x>", "Account 9", "9@server-id");
+  }
+
+  @Test
+  public void combineWithBatchGroupNameNotes() throws Exception {
+    AccountGroup g1 = newGroup("a");
+    AccountGroup g2 = newGroup("b");
+
+    GroupBundle b1 = builder().group(g1).build();
+    GroupBundle b2 = builder().group(g2).build();
+
+    BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+
+    rebuilder.rebuild(repo, b1, bru);
+    rebuilder.rebuild(repo, b2, bru);
+    try (ObjectInserter inserter = repo.newObjectInserter()) {
+      ImmutableList<GroupReference> refs =
+          ImmutableList.of(GroupReference.forGroup(g1), GroupReference.forGroup(g2));
+      GroupNameNotes.updateGroupNames(repo, inserter, bru, refs, newPersonIdent());
+      inserter.flush();
+    }
+
+    assertThat(log(g1)).isEmpty();
+    assertThat(log(g2)).isEmpty();
+    assertThat(logGroupNames()).isEmpty();
+
+    RefUpdateUtil.executeChecked(bru, repo);
+
+    assertThat(log(g1)).hasSize(1);
+    assertThat(log(g2)).hasSize(1);
+    assertThat(logGroupNames()).hasSize(1);
+    assertMigratedCleanly(reload(g1), b1);
+    assertMigratedCleanly(reload(g2), b2);
+
+    assertThat(GroupTestUtil.readNameToUuidMap(repo)).containsExactly("a", "a-1", "b", "b-2");
+  }
+
+  @Test
+  public void groupNamesWithLeadingAndTrailingWhitespace() throws Exception {
+    for (String leading : ImmutableList.of("", " ", "  ")) {
+      for (String trailing : ImmutableList.of("", " ", "  ")) {
+        AccountGroup g = newGroup(leading + "a" + trailing);
+        GroupBundle b = builder().group(g).build();
+        rebuilder.rebuild(repo, b, null);
+        assertMigratedCleanly(reload(g), b);
+      }
+    }
+  }
+
+  @Test
+  public void disallowExisting() throws Exception {
+    AccountGroup g = newGroup("a");
+    GroupBundle b = builder().group(g).build();
+
+    rebuilder.rebuild(repo, b, null);
+    assertMigratedCleanly(reload(g), b);
+    String refName = RefNames.refsGroups(g.getGroupUUID());
+    ObjectId oldId = repo.exactRef(refName).getObjectId();
+
+    try {
+      rebuilder.rebuild(repo, b, null);
+      assert_().fail("expected OrmDuplicateKeyException");
+    } catch (OrmDuplicateKeyException e) {
+      // Expected.
+    }
+
+    assertThat(repo.exactRef(refName).getObjectId()).isEqualTo(oldId);
+  }
+
+  private GroupBundle reload(AccountGroup g) throws Exception {
+    return bundleFactory.fromNoteDb(repo, g.getGroupUUID());
+  }
+
+  private void assertMigratedCleanly(GroupBundle noteDbBundle, GroupBundle expectedReviewDbBundle) {
+    assertThat(GroupBundle.compareWithAudits(expectedReviewDbBundle, noteDbBundle)).isEmpty();
+  }
+
+  private AccountGroup newGroup(String name) {
+    int id = idCounter.incrementAndGet();
+    return new AccountGroup(
+        new AccountGroup.NameKey(name),
+        new AccountGroup.Id(id),
+        new AccountGroup.UUID(name.trim() + "-" + id),
+        TimeUtil.nowTs());
+  }
+
+  private AccountGroupMember member(AccountGroup g, int accountId) {
+    return new AccountGroupMember(new AccountGroupMember.Key(new Account.Id(accountId), g.getId()));
+  }
+
+  private AccountGroupMemberAudit addMember(
+      AccountGroup g, int accountId, int adder, Timestamp addedOn) {
+    return new AccountGroupMemberAudit(member(g, accountId), new Account.Id(adder), addedOn);
+  }
+
+  private AccountGroupMemberAudit addAndLegacyRemoveMember(
+      AccountGroup g, int accountId, int adder, Timestamp addedOn) {
+    AccountGroupMemberAudit a = addMember(g, accountId, adder, addedOn);
+    a.removedLegacy();
+    return a;
+  }
+
+  private AccountGroupMemberAudit addAndRemoveMember(
+      AccountGroup g,
+      int accountId,
+      int adder,
+      Timestamp addedOn,
+      int removedBy,
+      Timestamp removedOn) {
+    AccountGroupMemberAudit a = addMember(g, accountId, adder, addedOn);
+    a.removed(new Account.Id(removedBy), removedOn);
+    return a;
+  }
+
+  private AccountGroupByIdAud addById(
+      AccountGroup g, String subgroupUuid, int adder, Timestamp addedOn) {
+    return new AccountGroupByIdAud(byId(g, subgroupUuid), new Account.Id(adder), addedOn);
+  }
+
+  private AccountGroupByIdAud addAndRemoveById(
+      AccountGroup g,
+      String subgroupUuid,
+      int adder,
+      Timestamp addedOn,
+      int removedBy,
+      Timestamp removedOn) {
+    AccountGroupByIdAud a = addById(g, subgroupUuid, adder, addedOn);
+    a.removed(new Account.Id(removedBy), removedOn);
+    return a;
+  }
+
+  private AccountGroupById byId(AccountGroup g, String subgroupUuid) {
+    return new AccountGroupById(
+        new AccountGroupById.Key(g.getId(), new AccountGroup.UUID(subgroupUuid)));
+  }
+
+  private ImmutableList<CommitInfo> log(AccountGroup g) throws Exception {
+    return GroupTestUtil.log(repo, RefNames.refsGroups(g.getGroupUUID()));
+  }
+
+  private ImmutableList<CommitInfo> logGroupNames() throws Exception {
+    return GroupTestUtil.log(repo, REFS_GROUPNAMES);
+  }
+
+  private static GroupBundle.Builder builder() {
+    return GroupBundle.builder().source(GroupBundle.Source.REVIEW_DB);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
new file mode 100644
index 0000000..eff755f
--- /dev/null
+++ b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.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.group.db;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo.warning;
+
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.group.db.testing.GroupTestUtil;
+import java.util.List;
+import org.junit.Test;
+
+public class GroupsNoteDbConsistencyCheckerTest extends AbstractGroupTest {
+
+  @Test
+  public void groupNamesRefIsMissing() throws Exception {
+    List<ConsistencyProblemInfo> problems =
+        GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
+            allUsersRepo, "g-1", new AccountGroup.UUID("uuid-1"));
+    assertThat(problems)
+        .containsExactly(warning("Group with name 'g-1' doesn't exist in the list of all names"));
+  }
+
+  @Test
+  public void groupNameNoteIsMissing() throws Exception {
+    updateGroupNamesRef("g-2", "[group]\n\tuuid = uuid-2\n\tname = g-2\n");
+    List<ConsistencyProblemInfo> problems =
+        GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
+            allUsersRepo, "g-1", new AccountGroup.UUID("uuid-1"));
+    assertThat(problems)
+        .containsExactly(warning("Group with name 'g-1' doesn't exist in the list of all names"));
+  }
+
+  @Test
+  public void groupNameNoteIsConsistent() throws Exception {
+    updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-1\n\tname = g-1\n");
+    List<ConsistencyProblemInfo> problems =
+        GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
+            allUsersRepo, "g-1", new AccountGroup.UUID("uuid-1"));
+    assertThat(problems).isEmpty();
+  }
+
+  @Test
+  public void groupNameNoteHasDifferentUUID() throws Exception {
+    updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-2\n\tname = g-1\n");
+    List<ConsistencyProblemInfo> problems =
+        GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
+            allUsersRepo, "g-1", new AccountGroup.UUID("uuid-1"));
+    assertThat(problems)
+        .containsExactly(
+            warning(
+                "group with name 'g-1' has UUID 'uuid-1' in 'group.config' but 'uuid-2' in group "
+                    + "name notes"));
+  }
+
+  @Test
+  public void groupNameNoteHasDifferentName() throws Exception {
+    updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-1\n\tname = g-2\n");
+    List<ConsistencyProblemInfo> problems =
+        GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
+            allUsersRepo, "g-1", new AccountGroup.UUID("uuid-1"));
+    assertThat(problems)
+        .containsExactly(warning("group note of name 'g-1' claims to represent name of 'g-2'"));
+  }
+
+  @Test
+  public void groupNameNoteHasDifferentNameAndUUID() throws Exception {
+    updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-2\n\tname = g-2\n");
+    List<ConsistencyProblemInfo> problems =
+        GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
+            allUsersRepo, "g-1", new AccountGroup.UUID("uuid-1"));
+    assertThat(problems)
+        .containsExactly(
+            warning(
+                "group with name 'g-1' has UUID 'uuid-1' in 'group.config' but 'uuid-2' in group "
+                    + "name notes"),
+            warning("group note of name 'g-1' claims to represent name of 'g-2'"))
+        .inOrder();
+  }
+
+  @Test
+  public void groupNameNoteFailToParse() throws Exception {
+    updateGroupNamesRef("g-1", "[invalid");
+    List<ConsistencyProblemInfo> problems =
+        GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
+            allUsersRepo, "g-1", new AccountGroup.UUID("uuid-1"));
+    assertThat(problems)
+        .containsExactly(
+            warning(
+                "fail to check consistency with group name notes: Unexpected end of config file"));
+  }
+
+  private void updateGroupNamesRef(String groupName, String content) throws Exception {
+    String nameKey = GroupNameNotes.getNoteKey(new AccountGroup.NameKey(groupName)).getName();
+    GroupTestUtil.updateGroupFile(
+        allUsersRepo, serverIdent, RefNames.REFS_GROUPNAMES, nameKey, content);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
new file mode 100644
index 0000000..1542fe5
--- /dev/null
+++ b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.testing.GerritBaseTests;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class AccountFieldTest extends GerritBaseTests {
+  @Test
+  public void refStateFieldValues() throws Exception {
+    AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
+    Account account = new Account(new Account.Id(1), TimeUtil.nowTs());
+    String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
+    account.setMetaId(metaId);
+    List<String> values =
+        toStrings(
+            AccountField.REF_STATE.get(
+                new AccountState(
+                    allUsersName,
+                    account,
+                    ImmutableSet.of(),
+                    ImmutableMap.of(),
+                    GeneralPreferencesInfo.defaults())));
+    assertThat(values).hasSize(1);
+    String expectedValue =
+        allUsersName.get() + ":" + RefNames.refsUsers(account.getId()) + ":" + metaId;
+    assertThat(Iterables.getOnlyElement(values)).isEqualTo(expectedValue);
+  }
+
+  @Test
+  public void externalIdStateFieldValues() throws Exception {
+    Account.Id id = new Account.Id(1);
+    Account account = new Account(id, TimeUtil.nowTs());
+    ExternalId extId1 =
+        ExternalId.create(
+            ExternalId.Key.create(ExternalId.SCHEME_MAILTO, "foo.bar@example.com"),
+            id,
+            "foo.bar@example.com",
+            null,
+            ObjectId.fromString("1b9a0cf038ea38a0ab08617c39aa8e28413a27ca"));
+    ExternalId extId2 =
+        ExternalId.create(
+            ExternalId.Key.create(ExternalId.SCHEME_USERNAME, "foo"),
+            id,
+            null,
+            "secret",
+            ObjectId.fromString("5b3a73dc9a668a5b89b5f049225261e3e3291d1a"));
+    List<String> values =
+        toStrings(
+            AccountField.EXTERNAL_ID_STATE.get(
+                new AccountState(
+                    null,
+                    account,
+                    ImmutableSet.of(extId1, extId2),
+                    ImmutableMap.of(),
+                    GeneralPreferencesInfo.defaults())));
+    String expectedValue1 = extId1.key().sha1().name() + ":" + extId1.blobId().name();
+    String expectedValue2 = extId2.key().sha1().name() + ":" + extId2.blobId().name();
+    assertThat(values).containsExactly(expectedValue1, expectedValue2);
+  }
+
+  private List<String> toStrings(Iterable<byte[]> values) {
+    return Streams.stream(values).map(v -> new String(v, UTF_8)).collect(toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
new file mode 100644
index 0000000..5ecafd0
--- /dev/null
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Table;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ChangeFieldTest extends GerritBaseTests {
+  @Before
+  public void setUp() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void reviewerFieldValues() {
+    Table<ReviewerStateInternal, Account.Id, Timestamp> t = HashBasedTable.create();
+    Timestamp t1 = TimeUtil.nowTs();
+    t.put(ReviewerStateInternal.REVIEWER, new Account.Id(1), t1);
+    Timestamp t2 = TimeUtil.nowTs();
+    t.put(ReviewerStateInternal.CC, new Account.Id(2), t2);
+    ReviewerSet reviewers = ReviewerSet.fromTable(t);
+
+    List<String> values = ChangeField.getReviewerFieldValues(reviewers);
+    assertThat(values)
+        .containsExactly(
+            "REVIEWER,1", "REVIEWER,1," + t1.getTime(), "CC,2", "CC,2," + t2.getTime());
+
+    assertThat(ChangeField.parseReviewerFieldValues(values)).isEqualTo(reviewers);
+  }
+
+  @Test
+  public void formatSubmitRecordValues() {
+    assertThat(
+            ChangeField.formatSubmitRecordValues(
+                ImmutableList.of(
+                    record(
+                        SubmitRecord.Status.OK,
+                        label(SubmitRecord.Label.Status.MAY, "Label-1", null),
+                        label(SubmitRecord.Label.Status.OK, "Label-2", 1))),
+                new Account.Id(1)))
+        .containsExactly("OK", "MAY,label-1", "OK,label-2", "OK,label-2,0", "OK,label-2,1");
+  }
+
+  @Test
+  public void storedSubmitRecords() {
+    assertStoredRecordRoundTrip(record(SubmitRecord.Status.CLOSED));
+    assertStoredRecordRoundTrip(
+        record(
+            SubmitRecord.Status.OK,
+            label(SubmitRecord.Label.Status.MAY, "Label-1", null),
+            label(SubmitRecord.Label.Status.OK, "Label-2", 1)));
+  }
+
+  private static SubmitRecord record(SubmitRecord.Status status, SubmitRecord.Label... labels) {
+    SubmitRecord r = new SubmitRecord();
+    r.status = status;
+    if (labels.length > 0) {
+      r.labels = ImmutableList.copyOf(labels);
+    }
+    return r;
+  }
+
+  private static SubmitRecord.Label label(
+      SubmitRecord.Label.Status status, String label, Integer appliedBy) {
+    SubmitRecord.Label l = new SubmitRecord.Label();
+    l.status = status;
+    l.label = label;
+    if (appliedBy != null) {
+      l.appliedBy = new Account.Id(appliedBy);
+    }
+    return l;
+  }
+
+  private static void assertStoredRecordRoundTrip(SubmitRecord... records) {
+    List<SubmitRecord> recordList = ImmutableList.copyOf(records);
+    List<String> stored =
+        ChangeField.storedSubmitRecords(recordList)
+            .stream()
+            .map(s -> new String(s, UTF_8))
+            .collect(toList());
+    assertThat(ChangeField.parseSubmitRecords(stored))
+        .named("JSON %s" + stored)
+        .isEqualTo(recordList);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
new file mode 100644
index 0000000..9cf013b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -0,0 +1,262 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
+import static com.google.gerrit.index.query.Predicate.and;
+import static com.google.gerrit.index.query.Predicate.or;
+import static com.google.gerrit.reviewdb.client.Change.Status.MERGED;
+import static com.google.gerrit.reviewdb.client.Change.Status.NEW;
+import static com.google.gerrit.server.index.change.IndexedChangeQuery.convertOptions;
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.query.change.AndChangeSource;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gerrit.server.query.change.OrSource;
+import com.google.gerrit.testing.GerritBaseTests;
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.Set;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ChangeIndexRewriterTest extends GerritBaseTests {
+  private static final IndexConfig CONFIG = IndexConfig.createDefault();
+
+  private FakeChangeIndex index;
+  private ChangeIndexCollection indexes;
+  private ChangeQueryBuilder queryBuilder;
+  private ChangeIndexRewriter rewrite;
+
+  @Before
+  public void setUp() throws Exception {
+    index = new FakeChangeIndex(FakeChangeIndex.V2);
+    indexes = new ChangeIndexCollection();
+    indexes.setSearchIndex(index);
+    queryBuilder = new FakeQueryBuilder(indexes);
+    rewrite = new ChangeIndexRewriter(indexes, IndexConfig.builder().maxTerms(3).build());
+  }
+
+  @Test
+  public void indexPredicate() throws Exception {
+    Predicate<ChangeData> in = parse("file:a");
+    assertThat(rewrite(in)).isEqualTo(query(in));
+  }
+
+  @Test
+  public void nonIndexPredicate() throws Exception {
+    Predicate<ChangeData> in = parse("foo:a");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(AndChangeSource.class).isSameAs(out.getClass());
+    assertThat(out.getChildren())
+        .containsExactly(query(ChangeStatusPredicate.open()), in)
+        .inOrder();
+  }
+
+  @Test
+  public void indexPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("file:a file:b");
+    assertThat(rewrite(in)).isEqualTo(query(in));
+  }
+
+  @Test
+  public void nonIndexPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("foo:a OR foo:b");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(AndChangeSource.class).isSameAs(out.getClass());
+    assertThat(out.getChildren())
+        .containsExactly(query(ChangeStatusPredicate.open()), in)
+        .inOrder();
+  }
+
+  @Test
+  public void oneIndexPredicate() throws Exception {
+    Predicate<ChangeData> in = parse("foo:a file:b");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(AndChangeSource.class).isSameAs(out.getClass());
+    assertThat(out.getChildren()).containsExactly(query(in.getChild(1)), in.getChild(0)).inOrder();
+  }
+
+  @Test
+  public void threeLevelTreeWithAllIndexPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("-status:abandoned (file:a OR file:b)");
+    assertThat(rewrite.rewrite(in, options(0, DEFAULT_MAX_QUERY_LIMIT))).isEqualTo(query(in));
+  }
+
+  @Test
+  public void threeLevelTreeWithSomeIndexPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("-foo:a (file:b OR file:c)");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isSameAs(AndChangeSource.class);
+    assertThat(out.getChildren()).containsExactly(query(in.getChild(1)), in.getChild(0)).inOrder();
+  }
+
+  @Test
+  public void multipleIndexPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("file:a OR foo:b OR file:c OR foo:d");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isSameAs(OrSource.class);
+    assertThat(out.getChildren())
+        .containsExactly(query(or(in.getChild(0), in.getChild(2))), in.getChild(1), in.getChild(3))
+        .inOrder();
+  }
+
+  @Test
+  public void indexAndNonIndexPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("status:new bar:p file:a");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(AndChangeSource.class).isSameAs(out.getClass());
+    assertThat(out.getChildren())
+        .containsExactly(query(and(in.getChild(0), in.getChild(2))), in.getChild(1))
+        .inOrder();
+  }
+
+  @Test
+  public void duplicateCompoundNonIndexOnlyPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("status:new bar:p file:a");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
+    assertThat(out.getChildren())
+        .containsExactly(query(and(in.getChild(0), in.getChild(2))), in.getChild(1))
+        .inOrder();
+  }
+
+  @Test
+  public void duplicateCompoundIndexOnlyPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("(status:new OR file:a) bar:p file:b");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
+    assertThat(out.getChildren())
+        .containsExactly(query(and(in.getChild(0), in.getChild(2))), in.getChild(1))
+        .inOrder();
+  }
+
+  @Test
+  public void optionsArgumentOverridesAllLimitPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("limit:1 file:a limit:3");
+    Predicate<ChangeData> out = rewrite(in, options(0, 5));
+    assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
+    assertThat(out.getChildren())
+        .containsExactly(query(in.getChild(1), 5), parse("limit:5"), parse("limit:5"))
+        .inOrder();
+  }
+
+  @Test
+  public void startIncreasesLimitInQueryButNotPredicate() throws Exception {
+    int n = 3;
+    Predicate<ChangeData> f = parse("file:a");
+    Predicate<ChangeData> l = parse("limit:" + n);
+    Predicate<ChangeData> in = andSource(f, l);
+    assertThat(rewrite.rewrite(in, options(0, n))).isEqualTo(andSource(query(f, 3), l));
+    assertThat(rewrite.rewrite(in, options(1, n))).isEqualTo(andSource(query(f, 4), l));
+    assertThat(rewrite.rewrite(in, options(2, n))).isEqualTo(andSource(query(f, 5), l));
+  }
+
+  @Test
+  public void getPossibleStatus() throws Exception {
+    Set<Change.Status> all = EnumSet.allOf(Change.Status.class);
+    assertThat(status("file:a")).isEqualTo(all);
+    assertThat(status("is:new")).containsExactly(NEW);
+    assertThat(status("is:new OR is:merged")).containsExactly(NEW, MERGED);
+    assertThat(status("is:new OR is:x")).isEqualTo(all);
+
+    assertThat(status("is:new is:merged")).isEmpty();
+    assertThat(status("(is:new) (is:merged)")).isEmpty();
+    assertThat(status("(is:new) (is:merged)")).isEmpty();
+    assertThat(status("is:new is:x")).containsExactly(NEW);
+  }
+
+  @Test
+  public void unsupportedIndexOperator() throws Exception {
+    Predicate<ChangeData> in = parse("status:merged file:a");
+    assertThat(rewrite(in)).isEqualTo(query(in));
+
+    indexes.setSearchIndex(new FakeChangeIndex(FakeChangeIndex.V1));
+
+    exception.expect(QueryParseException.class);
+    exception.expectMessage("Unsupported index predicate: file:a");
+    rewrite(in);
+  }
+
+  @Test
+  public void tooManyTerms() throws Exception {
+    String q = "file:a OR file:b OR file:c";
+    Predicate<ChangeData> in = parse(q);
+    assertEquals(query(in), rewrite(in));
+
+    exception.expect(QueryParseException.class);
+    exception.expectMessage("too many terms in query");
+    rewrite(parse(q + " OR file:d"));
+  }
+
+  @Test
+  public void testConvertOptions() throws Exception {
+    assertEquals(options(0, 3), convertOptions(options(0, 3)));
+    assertEquals(options(0, 4), convertOptions(options(1, 3)));
+    assertEquals(options(0, 5), convertOptions(options(2, 3)));
+  }
+
+  @Test
+  public void addingStartToLimitDoesNotExceedBackendLimit() throws Exception {
+    int max = CONFIG.maxLimit();
+    assertEquals(options(0, max), convertOptions(options(0, max)));
+    assertEquals(options(0, max), convertOptions(options(1, max)));
+    assertEquals(options(0, max), convertOptions(options(1, max - 1)));
+    assertEquals(options(0, max), convertOptions(options(2, max - 1)));
+  }
+
+  private Predicate<ChangeData> parse(String query) throws QueryParseException {
+    return queryBuilder.parse(query);
+  }
+
+  @SafeVarargs
+  private static AndChangeSource andSource(Predicate<ChangeData>... preds) {
+    return new AndChangeSource(Arrays.asList(preds));
+  }
+
+  private Predicate<ChangeData> rewrite(Predicate<ChangeData> in) throws QueryParseException {
+    return rewrite.rewrite(in, options(0, DEFAULT_MAX_QUERY_LIMIT));
+  }
+
+  private Predicate<ChangeData> rewrite(Predicate<ChangeData> in, QueryOptions opts)
+      throws QueryParseException {
+    return rewrite.rewrite(in, opts);
+  }
+
+  private IndexedChangeQuery query(Predicate<ChangeData> p) throws QueryParseException {
+    return query(p, DEFAULT_MAX_QUERY_LIMIT);
+  }
+
+  private IndexedChangeQuery query(Predicate<ChangeData> p, int limit) throws QueryParseException {
+    return new IndexedChangeQuery(index, p, options(0, limit));
+  }
+
+  private static QueryOptions options(int start, int limit) {
+    return IndexedChangeQuery.createOptions(CONFIG, start, limit, ImmutableSet.<String>of());
+  }
+
+  private Set<Change.Status> status(String query) throws QueryParseException {
+    return ChangeIndexRewriter.getPossibleStatus(parse(query));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
new file mode 100644
index 0000000..d4ecb6d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import org.junit.Ignore;
+
+@Ignore
+public class FakeChangeIndex implements ChangeIndex {
+  static final Schema<ChangeData> V1 =
+      new Schema<>(1, ImmutableList.<FieldDef<ChangeData, ?>>of(ChangeField.STATUS));
+
+  static final Schema<ChangeData> V2 =
+      new Schema<>(2, ImmutableList.of(ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED));
+
+  private static class Source implements ChangeDataSource {
+    private final Predicate<ChangeData> p;
+
+    Source(Predicate<ChangeData> p) {
+      this.p = p;
+    }
+
+    @Override
+    public int getCardinality() {
+      return 1;
+    }
+
+    @Override
+    public boolean hasChange() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public ResultSet<ChangeData> read() throws OrmException {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public ResultSet<FieldBundle> readRaw() throws OrmException {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public String toString() {
+      return p.toString();
+    }
+  }
+
+  private final Schema<ChangeData> schema;
+
+  FakeChangeIndex(Schema<ChangeData> schema) {
+    this.schema = schema;
+  }
+
+  @Override
+  public void replace(ChangeData cd) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void delete(Change.Id id) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void deleteAll() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
+      throws QueryParseException {
+    return new FakeChangeIndex.Source(p);
+  }
+
+  @Override
+  public Schema<ChangeData> getSchema() {
+    return schema;
+  }
+
+  @Override
+  public void close() {}
+
+  @Override
+  public void markReady(boolean ready) {
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
new file mode 100644
index 0000000..b525504
--- /dev/null
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import com.google.gerrit.index.query.OperatorPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import org.junit.Ignore;
+
+@Ignore
+public class FakeQueryBuilder extends ChangeQueryBuilder {
+  FakeQueryBuilder(ChangeIndexCollection indexes) {
+    super(
+        new FakeQueryBuilder.Definition<>(FakeQueryBuilder.class),
+        new ChangeQueryBuilder.Arguments(
+            null, null, null, null, null, null, null, null, null, null, null, null, null, null,
+            null, null, null, null, null, indexes, null, null, null, null, null, null, null, null));
+  }
+
+  @Operator
+  public Predicate<ChangeData> foo(String value) {
+    return predicate("foo", value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> bar(String value) {
+    return predicate("bar", value);
+  }
+
+  private Predicate<ChangeData> predicate(String name, String value) {
+    return new OperatorPredicate<ChangeData>(name, value) {};
+  }
+}
diff --git a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
new file mode 100644
index 0000000..acb33e9
--- /dev/null
+++ b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -0,0 +1,344 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.server.index.change.StalenessChecker.refsAreStale;
+import static com.google.gerrit.testing.TestChanges.newChange;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.ListMultimap;
+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.git.GitRepositoryManager;
+import com.google.gerrit.server.index.RefState;
+import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gwtorm.protobuf.CodecFactory;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import java.util.stream.Stream;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+public class StalenessCheckerTest extends GerritBaseTests {
+  private static final String SHA1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+  private static final String SHA2 = "badc0feebadc0feebadc0feebadc0feebadc0fee";
+
+  private static final Project.NameKey P1 = new Project.NameKey("project1");
+  private static final Project.NameKey P2 = new Project.NameKey("project2");
+
+  private static final Change.Id C = new Change.Id(1234);
+
+  private static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
+
+  private GitRepositoryManager repoManager;
+  private Repository r1;
+  private Repository r2;
+  private TestRepository<Repository> tr1;
+  private TestRepository<Repository> tr2;
+
+  @Before
+  public void setUp() throws Exception {
+    repoManager = new InMemoryRepositoryManager();
+    r1 = repoManager.createRepository(P1);
+    tr1 = new TestRepository<>(r1);
+    r2 = repoManager.createRepository(P2);
+    tr2 = new TestRepository<>(r2);
+  }
+
+  @Test
+  public void parseStates() {
+    assertInvalidState(null);
+    assertInvalidState("");
+    assertInvalidState("project1:refs/heads/foo");
+    assertInvalidState("project1:refs/heads/foo:notasha");
+    assertInvalidState("project1:refs/heads/foo:");
+
+    assertThat(
+            RefState.parseStates(
+                byteArrays(
+                    P1 + ":refs/heads/foo:" + SHA1,
+                    P1 + ":refs/heads/bar:" + SHA2,
+                    P2 + ":refs/heads/baz:" + SHA1)))
+        .isEqualTo(
+            ImmutableSetMultimap.of(
+                P1, RefState.create("refs/heads/foo", SHA1),
+                P1, RefState.create("refs/heads/bar", SHA2),
+                P2, RefState.create("refs/heads/baz", SHA1)));
+  }
+
+  private static void assertInvalidState(String state) {
+    try {
+      RefState.parseStates(byteArrays(state));
+      assert_().fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void refStateToByteArray() {
+    assertThat(
+            new String(
+                RefState.create("refs/heads/foo", ObjectId.fromString(SHA1)).toByteArray(P1),
+                UTF_8))
+        .isEqualTo(P1 + ":refs/heads/foo:" + SHA1);
+    assertThat(
+            new String(RefState.create("refs/heads/foo", (ObjectId) null).toByteArray(P1), UTF_8))
+        .isEqualTo(P1 + ":refs/heads/foo:" + ObjectId.zeroId().name());
+  }
+
+  @Test
+  public void parsePatterns() {
+    assertInvalidPattern(null);
+    assertInvalidPattern("");
+    assertInvalidPattern("project:");
+    assertInvalidPattern("project:refs/heads/foo");
+    assertInvalidPattern("project:refs/he*ds/bar");
+    assertInvalidPattern("project:refs/(he)*ds/bar");
+    assertInvalidPattern("project:invalidrefname");
+
+    ListMultimap<Project.NameKey, RefStatePattern> r =
+        StalenessChecker.parsePatterns(
+            byteArrays(
+                P1 + ":refs/heads/*",
+                P2 + ":refs/heads/foo/*/bar",
+                P2 + ":refs/heads/foo/*-baz/*/quux"));
+
+    assertThat(r.keySet()).containsExactly(P1, P2);
+    RefStatePattern p = r.get(P1).get(0);
+    assertThat(p.pattern()).isEqualTo("refs/heads/*");
+    assertThat(p.prefix()).isEqualTo("refs/heads/");
+    assertThat(p.regex().pattern()).isEqualTo("^\\Qrefs/heads/\\E.*\\Q\\E$");
+    assertThat(p.match("refs/heads/foo")).isTrue();
+    assertThat(p.match("xrefs/heads/foo")).isFalse();
+    assertThat(p.match("refs/tags/foo")).isFalse();
+
+    p = r.get(P2).get(0);
+    assertThat(p.pattern()).isEqualTo("refs/heads/foo/*/bar");
+    assertThat(p.prefix()).isEqualTo("refs/heads/foo/");
+    assertThat(p.regex().pattern()).isEqualTo("^\\Qrefs/heads/foo/\\E.*\\Q/bar\\E$");
+    assertThat(p.match("refs/heads/foo//bar")).isTrue();
+    assertThat(p.match("refs/heads/foo/x/bar")).isTrue();
+    assertThat(p.match("refs/heads/foo/x/y/bar")).isTrue();
+    assertThat(p.match("refs/heads/foo/x/baz")).isFalse();
+
+    p = r.get(P2).get(1);
+    assertThat(p.pattern()).isEqualTo("refs/heads/foo/*-baz/*/quux");
+    assertThat(p.prefix()).isEqualTo("refs/heads/foo/");
+    assertThat(p.regex().pattern()).isEqualTo("^\\Qrefs/heads/foo/\\E.*\\Q-baz/\\E.*\\Q/quux\\E$");
+    assertThat(p.match("refs/heads/foo/-baz//quux")).isTrue();
+    assertThat(p.match("refs/heads/foo/x-baz/x/quux")).isTrue();
+    assertThat(p.match("refs/heads/foo/x/y-baz/x/y/quux")).isTrue();
+    assertThat(p.match("refs/heads/foo/x-baz/x/y")).isFalse();
+  }
+
+  @Test
+  public void refStatePatternToByteArray() {
+    assertThat(new String(RefStatePattern.create("refs/*").toByteArray(P1), UTF_8))
+        .isEqualTo(P1 + ":refs/*");
+  }
+
+  private static void assertInvalidPattern(String state) {
+    try {
+      StalenessChecker.parsePatterns(byteArrays(state));
+      assert_().fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void isStaleRefStatesOnly() throws Exception {
+    String ref1 = "refs/heads/foo";
+    ObjectId id1 = tr1.update(ref1, tr1.commit().message("commit 1"));
+    String ref2 = "refs/heads/bar";
+    ObjectId id2 = tr2.update(ref2, tr2.commit().message("commit 2"));
+
+    // Not stale.
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P2, RefState.create(ref2, id2.name())),
+                ImmutableListMultimap.of()))
+        .isFalse();
+
+    // Wrong ref value.
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, SHA1),
+                    P2, RefState.create(ref2, id2.name())),
+                ImmutableListMultimap.of()))
+        .isTrue();
+
+    // Swapped repos.
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id2.name()),
+                    P2, RefState.create(ref2, id1.name())),
+                ImmutableListMultimap.of()))
+        .isTrue();
+
+    // Two refs in same repo, not stale.
+    String ref3 = "refs/heads/baz";
+    ObjectId id3 = tr1.update(ref3, tr1.commit().message("commit 3"));
+    tr1.update(ref3, id3);
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P1, RefState.create(ref3, id3.name())),
+                ImmutableListMultimap.of()))
+        .isFalse();
+
+    // Ignore ref not mentioned.
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                ImmutableListMultimap.of()))
+        .isFalse();
+
+    // One ref wrong.
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P1, RefState.create(ref3, SHA1)),
+                ImmutableListMultimap.of()))
+        .isTrue();
+  }
+
+  @Test
+  public void isStaleWithRefStatePatterns() throws Exception {
+    String ref1 = "refs/heads/foo";
+    ObjectId id1 = tr1.update(ref1, tr1.commit().message("commit 1"));
+
+    // ref1 is only ref matching pattern.
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
+        .isFalse();
+
+    // Now ref2 matches pattern, so stale unless ref2 is present in state map.
+    String ref2 = "refs/heads/bar";
+    ObjectId id2 = tr1.update(ref2, tr1.commit().message("commit 2"));
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
+        .isTrue();
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P1, RefState.create(ref2, id2.name())),
+                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
+        .isFalse();
+  }
+
+  @Test
+  public void isStaleWithNonPrefixPattern() throws Exception {
+    String ref1 = "refs/heads/foo";
+    ObjectId id1 = tr1.update(ref1, tr1.commit().message("commit 1"));
+    tr1.update("refs/heads/bar", tr1.commit().message("commit 2"));
+
+    // ref1 is only ref matching pattern.
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
+        .isFalse();
+
+    // Now ref2 matches pattern, so stale unless ref2 is present in state map.
+    String ref3 = "refs/other/foo";
+    ObjectId id3 = tr1.update(ref3, tr1.commit().message("commit 3"));
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
+        .isTrue();
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P1, RefState.create(ref3, id3.name())),
+                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
+        .isFalse();
+  }
+
+  @Test
+  public void reviewDbChangeIsStale() throws Exception {
+    Change indexChange = newChange(P1, new Account.Id(1));
+    indexChange.setNoteDbState(SHA1);
+
+    // Change is missing from ReviewDb but present in index.
+    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, null)).isTrue();
+
+    // Change differs only in primary storage.
+    Change noteDbPrimary = clone(indexChange);
+    noteDbPrimary.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, noteDbPrimary)).isTrue();
+
+    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, clone(indexChange))).isFalse();
+
+    // Can't easily change row version to check true case.
+  }
+
+  private static Iterable<byte[]> byteArrays(String... strs) {
+    return Stream.of(strs).map(s -> s != null ? s.getBytes(UTF_8) : null).collect(toList());
+  }
+
+  private static Change clone(Change change) {
+    return CHANGE_CODEC.decode(CHANGE_CODEC.encodeToByteArray(change));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/ioutil/BUILD b/javatests/com/google/gerrit/server/ioutil/BUILD
new file mode 100644
index 0000000..721c6f9
--- /dev/null
+++ b/javatests/com/google/gerrit/server/ioutil/BUILD
@@ -0,0 +1,14 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "ioutil_tests",
+    size = "small",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    resource_strip_prefix = "resources",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/server/ioutil",
+    ],
+)
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/BasicSerializationTest.java b/javatests/com/google/gerrit/server/ioutil/BasicSerializationTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/ioutil/BasicSerializationTest.java
rename to javatests/com/google/gerrit/server/ioutil/BasicSerializationTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/ColumnFormatterTest.java b/javatests/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
rename to javatests/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
diff --git a/javatests/com/google/gerrit/server/ioutil/StringUtilTest.java b/javatests/com/google/gerrit/server/ioutil/StringUtilTest.java
new file mode 100644
index 0000000..04f806d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/ioutil/StringUtilTest.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.ioutil;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+public class StringUtilTest {
+  /** Test the boundary condition that the first character of a string should be escaped. */
+  @Test
+  public void escapeFirstChar() {
+    assertEquals(StringUtil.escapeString("\tLeading tab"), "\\tLeading tab");
+  }
+
+  /** Test the boundary condition that the last character of a string should be escaped. */
+  @Test
+  public void escapeLastChar() {
+    assertEquals(StringUtil.escapeString("Trailing tab\t"), "Trailing tab\\t");
+  }
+
+  /** Test that various forms of input strings are escaped (or left as-is) in the expected way. */
+  @Test
+  public void escapeString() {
+    final String[] testPairs = {
+      "", "",
+      "plain string", "plain string",
+      "string with \"quotes\"", "string with \"quotes\"",
+      "string with 'quotes'", "string with 'quotes'",
+      "string with 'quotes'", "string with 'quotes'",
+      "C:\\Program Files\\MyProgram", "C:\\\\Program Files\\\\MyProgram",
+      "string\nwith\nnewlines", "string\\nwith\\nnewlines",
+      "string\twith\ttabs", "string\\twith\\ttabs",
+    };
+    for (int i = 0; i < testPairs.length; i += 2) {
+      assertEquals(StringUtil.escapeString(testPairs[i]), testPairs[i + 1]);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/mail/AddressTest.java b/javatests/com/google/gerrit/server/mail/AddressTest.java
new file mode 100644
index 0000000..7dbd563
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/AddressTest.java
@@ -0,0 +1,157 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import com.google.gerrit.testing.GerritBaseTests;
+import org.junit.Test;
+
+public class AddressTest extends GerritBaseTests {
+  @Test
+  public void parse_NameEmail1() {
+    final Address a = Address.parse("A U Thor <author@example.com>");
+    assertThat(a.name).isEqualTo("A U Thor");
+    assertThat(a.email).isEqualTo("author@example.com");
+  }
+
+  @Test
+  public void parse_NameEmail2() {
+    final Address a = Address.parse("A <a@b>");
+    assertThat(a.name).isEqualTo("A");
+    assertThat(a.email).isEqualTo("a@b");
+  }
+
+  @Test
+  public void parse_NameEmail3() {
+    final Address a = Address.parse("<a@b>");
+    assertThat(a.name).isNull();
+    assertThat(a.email).isEqualTo("a@b");
+  }
+
+  @Test
+  public void parse_NameEmail4() {
+    final Address a = Address.parse("A U Thor<author@example.com>");
+    assertThat(a.name).isEqualTo("A U Thor");
+    assertThat(a.email).isEqualTo("author@example.com");
+  }
+
+  @Test
+  public void parse_NameEmail5() {
+    final Address a = Address.parse("A U Thor  <author@example.com>");
+    assertThat(a.name).isEqualTo("A U Thor");
+    assertThat(a.email).isEqualTo("author@example.com");
+  }
+
+  @Test
+  public void parse_Email1() {
+    final Address a = Address.parse("author@example.com");
+    assertThat(a.name).isNull();
+    assertThat(a.email).isEqualTo("author@example.com");
+  }
+
+  @Test
+  public void parse_Email2() {
+    final Address a = Address.parse("a@b");
+    assertThat(a.name).isNull();
+    assertThat(a.email).isEqualTo("a@b");
+  }
+
+  @Test
+  public void parse_NewTLD() {
+    Address a = Address.parse("A U Thor <author@example.systems>");
+    assertThat(a.name).isEqualTo("A U Thor");
+    assertThat(a.email).isEqualTo("author@example.systems");
+  }
+
+  @Test
+  public void parseInvalid() {
+    assertInvalid("");
+    assertInvalid("a");
+    assertInvalid("a<");
+    assertInvalid("<a");
+    assertInvalid("<a>");
+    assertInvalid("a<a>");
+    assertInvalid("a <a>");
+
+    assertInvalid("a");
+    assertInvalid("a<@");
+    assertInvalid("<a@");
+    assertInvalid("<a@>");
+    assertInvalid("a<a@>");
+    assertInvalid("a <a@>");
+    assertInvalid("a <@a>");
+  }
+
+  private void assertInvalid(String in) {
+    try {
+      Address.parse(in);
+      fail("Expected IllegalArgumentException for " + in);
+    } catch (IllegalArgumentException e) {
+      assertThat(e.getMessage()).isEqualTo("Invalid email address: " + in);
+    }
+  }
+
+  @Test
+  public void toHeaderString_NameEmail1() {
+    assertThat(format("A", "a@a")).isEqualTo("A <a@a>");
+  }
+
+  @Test
+  public void toHeaderString_NameEmail2() {
+    assertThat(format("A B", "a@a")).isEqualTo("A B <a@a>");
+  }
+
+  @Test
+  public void toHeaderString_NameEmail3() {
+    assertThat(format("A B. C", "a@a")).isEqualTo("\"A B. C\" <a@a>");
+  }
+
+  @Test
+  public void toHeaderString_NameEmail4() {
+    assertThat(format("A B, C", "a@a")).isEqualTo("\"A B, C\" <a@a>");
+  }
+
+  @Test
+  public void toHeaderString_NameEmail5() {
+    assertThat(format("A \" C", "a@a")).isEqualTo("\"A \\\" C\" <a@a>");
+  }
+
+  @Test
+  public void toHeaderString_NameEmail6() {
+    assertThat(format("A \u20ac B", "a@a")).isEqualTo("=?UTF-8?Q?A_=E2=82=AC_B?= <a@a>");
+  }
+
+  @Test
+  public void toHeaderString_NameEmail7() {
+    assertThat(format("A \u20ac B (Code Review)", "a@a"))
+        .isEqualTo("=?UTF-8?Q?A_=E2=82=AC_B_=28Code_Review=29?= <a@a>");
+  }
+
+  @Test
+  public void toHeaderString_Email1() {
+    assertThat(format(null, "a@a")).isEqualTo("a@a");
+  }
+
+  @Test
+  public void toHeaderString_Email2() {
+    assertThat(format(null, "a,b@a")).isEqualTo("<a,b@a>");
+  }
+
+  private static String format(String name, String email) {
+    return new Address(name, email).toHeaderString();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/mail/receive/AbstractParserTest.java b/javatests/com/google/gerrit/server/mail/receive/AbstractParserTest.java
new file mode 100644
index 0000000..0e894a6
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/receive/AbstractParserTest.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Account;
+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.junit.Ignore;
+
+@Ignore
+public class AbstractParserTest {
+  protected static final String CHANGE_URL = "https://gerrit-review.googlesource.com/#/changes/123";
+
+  protected static void assertChangeMessage(String message, MailComment comment) {
+    assertThat(comment.fileName).isNull();
+    assertThat(comment.message).isEqualTo(message);
+    assertThat(comment.inReplyTo).isNull();
+    assertThat(comment.type).isEqualTo(MailComment.CommentType.CHANGE_MESSAGE);
+  }
+
+  protected static void assertInlineComment(
+      String message, MailComment comment, Comment inReplyTo) {
+    assertThat(comment.fileName).isNull();
+    assertThat(comment.message).isEqualTo(message);
+    assertThat(comment.inReplyTo).isEqualTo(inReplyTo);
+    assertThat(comment.type).isEqualTo(MailComment.CommentType.INLINE_COMMENT);
+  }
+
+  protected static void assertFileComment(String message, MailComment comment, String file) {
+    assertThat(comment.fileName).isEqualTo(file);
+    assertThat(comment.message).isEqualTo(message);
+    assertThat(comment.inReplyTo).isNull();
+    assertThat(comment.type).isEqualTo(MailComment.CommentType.FILE_COMMENT);
+  }
+
+  protected static Comment newComment(String uuid, String file, String message, int line) {
+    Comment c =
+        new Comment(
+            new Comment.Key(uuid, file, 1),
+            new Account.Id(0),
+            new Timestamp(0L),
+            (short) 0,
+            message,
+            "",
+            false);
+    c.lineNbr = line;
+    return c;
+  }
+
+  protected static Comment newRangeComment(String uuid, String file, String message, int line) {
+    Comment c =
+        new Comment(
+            new Comment.Key(uuid, file, 1),
+            new Account.Id(0),
+            new Timestamp(0L),
+            (short) 0,
+            message,
+            "",
+            false);
+    c.range = new Comment.Range(line, 1, line + 1, 1);
+    c.lineNbr = line + 1;
+    return c;
+  }
+
+  /** Returns a MailMessage.Builder with all required fields populated. */
+  protected static MailMessage.Builder newMailMessageBuilder() {
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("id");
+    b.from(new Address("Foo Bar", "foo@bar.com"));
+    b.dateReceived(Instant.now());
+    b.subject("");
+    return b;
+  }
+
+  /** Returns a List of default comments for testing. */
+  protected static List<Comment> defaultComments() {
+    List<Comment> comments = new ArrayList<>();
+    comments.add(newComment("c1", "gerrit-server/test.txt", "comment", 0));
+    comments.add(newComment("c2", "gerrit-server/test.txt", "comment", 2));
+    comments.add(newComment("c3", "gerrit-server/test.txt", "comment", 3));
+    comments.add(newComment("c3", "gerrit-server/test.txt", "comment", 115));
+    comments.add(newRangeComment("c5", "gerrit-server/readme.txt", "comment", 3));
+    return comments;
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java b/javatests/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java
rename to javatests/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java
diff --git a/javatests/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java b/javatests/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java
new file mode 100644
index 0000000..df71629
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java
@@ -0,0 +1,119 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+public class GmailHtmlParserTest extends HtmlParserTest {
+  @Override
+  protected String newHtmlBody(
+      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1) {
+    String email =
+        ""
+            + "<div class=\"gmail_default\" dir=\"ltr\">"
+            + (changeMessage != null ? changeMessage : "")
+            + "<div class=\"gmail_extra\"><br><div class=\"gmail_quote\">"
+            + "On Fri, Nov 18, 2016 at 11:15 AM, foobar (Gerrit) noreply@gerrit.com"
+            + "<span dir=\"ltr\">&lt;<a href=\"mailto:noreply@gerrit.com\" "
+            + "target=\"_blank\">noreply@gerrit.com</a>&gt;</span> wrote:<br>"
+            + "</div></div><blockquote class=\"gmail_quote\" "
+            + "<p>foobar <strong>posted comments</strong> on this change.</p>"
+            + "<p><a href=\""
+            + CHANGE_URL
+            + "/1\" "
+            + "target=\"_blank\">View Change</a></p><div>Patch Set 2: CR-1\n"
+            + "\n"
+            + "(3 comments)</div><ul><li>"
+            + "<p>"
+            + // File #1: test.txt
+            "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt\">"
+            + "File gerrit-server/<wbr>test.txt:</a></p>"
+            + commentBlock(f1)
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt\">"
+            + "Patch Set #2:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some comment on file 1</p>"
+            + "</li>"
+            + commentBlock(fc1)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@2\">"
+            + "Patch Set #2, Line 31:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some text from original comment</p>"
+            + "</li>"
+            + commentBlock(c1)
+            + ""
+            + // Inline comment #2
+            "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@3\">"
+            + "Patch Set #2, Line 47:</a> </p>"
+            + "<blockquote><pre>Some comment posted on Gerrit</pre>"
+            + "</blockquote><p>Some more comments from Gerrit</p>"
+            + "</li>"
+            + commentBlock(c2)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@115\">"
+            + "Patch Set #2, Line 115:</a> <code>some code</code></p>"
+            + "<p>some comment</p></li></ul></li>"
+            + ""
+            + "<li><p>"
+            + // File #2: test.txt
+            "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/readme.txt\">"
+            + "File gerrit-server/<wbr>readme.txt:</a></p>"
+            + commentBlock(f2)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/readme.txt@3\">"
+            + "Patch Set #2, Line 31:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some text from original comment</p>"
+            + "</li>"
+            + commentBlock(c3)
+            + ""
+            + // Inline comment #2
+            "</ul></li></ul>"
+            + ""
+            + // Footer
+            "<p>To view, visit <a href=\""
+            + CHANGE_URL
+            + "/1\">this change</a>. "
+            + "To unsubscribe, visit <a href=\"https://someurl\">settings</a>."
+            + "</p><p>Gerrit-MessageType: comment<br>"
+            + "Footer omitted</p>"
+            + "<div><div></div></div>"
+            + "<p>Gerrit-HasComments: Yes</p></blockquote></div>";
+    return email;
+  }
+
+  private static String commentBlock(String comment) {
+    if (comment == null) {
+      return "";
+    }
+    return "</ul></li></ul></blockquote><div>"
+        + comment
+        + "</div><blockquote class=\"gmail_quote\"><ul><li><ul>";
+  }
+}
diff --git a/javatests/com/google/gerrit/server/mail/receive/HtmlParserTest.java b/javatests/com/google/gerrit/server/mail/receive/HtmlParserTest.java
new file mode 100644
index 0000000..b9548bd
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/receive/HtmlParserTest.java
@@ -0,0 +1,164 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.List;
+import org.junit.Ignore;
+import org.junit.Test;
+
+/**
+ * Abstract parser test for HTML messages. Payload will be added through concrete implementations.
+ */
+@Ignore
+public abstract class HtmlParserTest extends AbstractParserTest {
+  @Test
+  public void simpleChangeMessage() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(newHtmlBody("Looks good to me", null, null, null, null, null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
+
+    assertThat(parsedComments).hasSize(1);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+  }
+
+  @Test
+  public void changeMessageWithLink() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(
+        newHtmlBody(
+            "Did you consider this: "
+                + "<a href=\"http://gerritcodereview.com\">http://gerritcodereview.com</a>",
+            null,
+            null,
+            null,
+            null,
+            null,
+            null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
+
+    assertThat(parsedComments).hasSize(1);
+    assertChangeMessage(
+        "Did you consider this: http://gerritcodereview.com", parsedComments.get(0));
+  }
+
+  @Test
+  public void simpleInlineComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(
+        newHtmlBody(
+            "Looks good to me",
+            "I have a comment on this.&nbsp;",
+            null,
+            "Also have a comment here.",
+            null,
+            null,
+            null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertInlineComment("I have a comment on this.", parsedComments.get(1), comments.get(1));
+    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(4));
+  }
+
+  @Test
+  public void simpleFileComment() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(
+        newHtmlBody(
+            "Looks good to me",
+            null,
+            null,
+            "Also have a comment here.",
+            "This is a nice file",
+            null,
+            null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertFileComment("This is a nice file", parsedComments.get(1), comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(4));
+  }
+
+  @Test
+  public void noComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(newHtmlBody(null, null, null, null, null, null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).isEmpty();
+  }
+
+  @Test
+  public void noChangeMessage() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(
+        newHtmlBody(
+            null, null, null, "Also have a comment here.", "This is a nice file", null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(2);
+    assertFileComment("This is a nice file", parsedComments.get(0), comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(1), comments.get(4));
+  }
+
+  @Test
+  public void commentsSpanningMultipleBlocks() {
+    String htmlMessage =
+        "This is a very long test comment. <div><br></div><div>Now this is a new paragraph yay.</div>";
+    String txtMessage = "This is a very long test comment.\n\nNow this is a new paragraph yay.";
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(newHtmlBody(htmlMessage, null, null, htmlMessage, htmlMessage, null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage(txtMessage, parsedComments.get(0));
+    assertFileComment(txtMessage, parsedComments.get(1), comments.get(1).key.filename);
+    assertInlineComment(txtMessage, parsedComments.get(2), comments.get(4));
+  }
+
+  /**
+   * Create an html message body with the specified comments.
+   *
+   * @param changeMessage
+   * @param c1 Comment in reply to first comment.
+   * @param c2 Comment in reply to second comment.
+   * @param c3 Comment in reply to third comment.
+   * @param f1 Comment on file one.
+   * @param f2 Comment on file two.
+   * @param fc1 Comment in reply to a comment on file 1.
+   * @return A string with all inline comments and the original quoted email.
+   */
+  protected abstract String newHtmlBody(
+      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1);
+}
diff --git a/javatests/com/google/gerrit/server/mail/receive/MetadataParserTest.java b/javatests/com/google/gerrit/server/mail/receive/MetadataParserTest.java
new file mode 100644
index 0000000..dc25939
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/receive/MetadataParserTest.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.mail.MetadataName.toFooterWithDelimiter;
+import static com.google.gerrit.server.mail.MetadataName.toHeaderWithDelimiter;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.MetadataName;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Test;
+
+public class MetadataParserTest {
+  @Test
+  public void parseMetadataFromHeader() {
+    // This tests if the metadata parser is able to parse metadata from the
+    // email headers of the message.
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("");
+    b.dateReceived(Instant.now());
+    b.subject("");
+
+    b.addAdditionalHeader(toHeaderWithDelimiter(MetadataName.CHANGE_NUMBER) + "123");
+    b.addAdditionalHeader(toHeaderWithDelimiter(MetadataName.PATCH_SET) + "1");
+    b.addAdditionalHeader(toHeaderWithDelimiter(MetadataName.MESSAGE_TYPE) + "comment");
+    b.addAdditionalHeader(
+        toHeaderWithDelimiter(MetadataName.TIMESTAMP) + "Tue, 25 Oct 2016 02:11:35 -0700");
+
+    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    b.from(author);
+
+    MailMetadata meta = MetadataParser.parse(b.build());
+    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.changeNumber).isEqualTo(123);
+    assertThat(meta.patchSet).isEqualTo(1);
+    assertThat(meta.messageType).isEqualTo("comment");
+    assertThat(meta.timestamp.toInstant())
+        .isEqualTo(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
+  }
+
+  @Test
+  public void parseMetadataFromText() {
+    // This tests if the metadata parser is able to parse metadata from the
+    // the text body of the message.
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("");
+    b.dateReceived(Instant.now());
+    b.subject("");
+
+    StringBuilder stringBuilder = new StringBuilder();
+    stringBuilder.append(toFooterWithDelimiter(MetadataName.CHANGE_NUMBER) + "123\r\n");
+    stringBuilder.append("> " + toFooterWithDelimiter(MetadataName.PATCH_SET) + "1\n");
+    stringBuilder.append(toFooterWithDelimiter(MetadataName.MESSAGE_TYPE) + "comment\n");
+    stringBuilder.append(
+        toFooterWithDelimiter(MetadataName.TIMESTAMP) + "Tue, 25 Oct 2016 02:11:35 -0700\r\n");
+    b.textContent(stringBuilder.toString());
+
+    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    b.from(author);
+
+    MailMetadata meta = MetadataParser.parse(b.build());
+    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.changeNumber).isEqualTo(123);
+    assertThat(meta.patchSet).isEqualTo(1);
+    assertThat(meta.messageType).isEqualTo("comment");
+    assertThat(meta.timestamp.toInstant())
+        .isEqualTo(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
+  }
+
+  @Test
+  public void parseMetadataFromHTML() {
+    // This tests if the metadata parser is able to parse metadata from the
+    // the HTML body of the message.
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("");
+    b.dateReceived(Instant.now());
+    b.subject("");
+
+    StringBuilder stringBuilder = new StringBuilder();
+    stringBuilder.append(
+        "<div id\"someid\">" + toFooterWithDelimiter(MetadataName.CHANGE_NUMBER) + "123</div>");
+    stringBuilder.append("<div>" + toFooterWithDelimiter(MetadataName.PATCH_SET) + "1</div>");
+    stringBuilder.append(
+        "<div>" + toFooterWithDelimiter(MetadataName.MESSAGE_TYPE) + "comment</div>");
+    stringBuilder.append(
+        "<div>"
+            + toFooterWithDelimiter(MetadataName.TIMESTAMP)
+            + "Tue, 25 Oct 2016 02:11:35 -0700"
+            + "</div>");
+    b.htmlContent(stringBuilder.toString());
+
+    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    b.from(author);
+
+    MailMetadata meta = MetadataParser.parse(b.build());
+    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.changeNumber).isEqualTo(123);
+    assertThat(meta.patchSet).isEqualTo(1);
+    assertThat(meta.messageType).isEqualTo("comment");
+    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/ParserUtilTest.java b/javatests/com/google/gerrit/server/mail/receive/ParserUtilTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/ParserUtilTest.java
rename to javatests/com/google/gerrit/server/mail/receive/ParserUtilTest.java
diff --git a/javatests/com/google/gerrit/server/mail/receive/RawMailParserTest.java b/javatests/com/google/gerrit/server/mail/receive/RawMailParserTest.java
new file mode 100644
index 0000000..fb52947
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/receive/RawMailParserTest.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.mail.receive.data.AttachmentMessage;
+import com.google.gerrit.server.mail.receive.data.Base64HeaderMessage;
+import com.google.gerrit.server.mail.receive.data.HtmlMimeMessage;
+import com.google.gerrit.server.mail.receive.data.NonUTF8Message;
+import com.google.gerrit.server.mail.receive.data.QuotedPrintableHeaderMessage;
+import com.google.gerrit.server.mail.receive.data.RawMailMessage;
+import com.google.gerrit.server.mail.receive.data.SimpleTextMessage;
+import com.google.gerrit.testing.GerritBaseTests;
+import org.junit.Test;
+
+public class RawMailParserTest extends GerritBaseTests {
+  @Test
+  public void parseEmail() throws Exception {
+    RawMailMessage[] messages =
+        new RawMailMessage[] {
+          new SimpleTextMessage(),
+          new Base64HeaderMessage(),
+          new QuotedPrintableHeaderMessage(),
+          new HtmlMimeMessage(),
+          new AttachmentMessage(),
+          new NonUTF8Message(),
+        };
+    for (RawMailMessage rawMailMessage : messages) {
+      if (rawMailMessage.rawChars() != null) {
+        // Assert Character to Mail Parser
+        MailMessage parsedMailMessage = RawMailParser.parse(rawMailMessage.rawChars());
+        assertMail(parsedMailMessage, rawMailMessage.expectedMailMessage());
+      }
+      if (rawMailMessage.raw() != null) {
+        // Assert String to Mail Parser
+        MailMessage parsedMailMessage = RawMailParser.parse(rawMailMessage.raw());
+        assertMail(parsedMailMessage, rawMailMessage.expectedMailMessage());
+      }
+    }
+  }
+
+  /**
+   * This method makes it easier to debug failing tests by checking each property individual instead
+   * of calling equals as it will immediately reveal the property that diverges between the two
+   * objects.
+   *
+   * @param have MailMessage retrieved from the parser
+   * @param want MailMessage that would be expected
+   */
+  private void assertMail(MailMessage have, MailMessage want) {
+    assertThat(have.id()).isEqualTo(want.id());
+    assertThat(have.to()).isEqualTo(want.to());
+    assertThat(have.from()).isEqualTo(want.from());
+    assertThat(have.cc()).isEqualTo(want.cc());
+    assertThat(have.dateReceived()).isEqualTo(want.dateReceived());
+    assertThat(have.additionalHeaders()).isEqualTo(want.additionalHeaders());
+    assertThat(have.subject()).isEqualTo(want.subject());
+    assertThat(have.textContent()).isEqualTo(want.textContent());
+    assertThat(have.htmlContent()).isEqualTo(want.htmlContent());
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/TextParserTest.java b/javatests/com/google/gerrit/server/mail/receive/TextParserTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/TextParserTest.java
rename to javatests/com/google/gerrit/server/mail/receive/TextParserTest.java
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java b/javatests/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java
new file mode 100644
index 0000000..eb4d180
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive.data;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Ignore;
+
+/**
+ * Provides a raw message payload and a parsed {@code MailMessage} to check that mime parts that are
+ * neither text/plain, nor * text/html are dropped.
+ */
+@Ignore
+public class AttachmentMessage extends RawMailMessage {
+  private static String raw =
+      "MIME-Version: 1.0\n"
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <CAM7sg=3meaAVUxW3KXeJEVs8sv_ADw1BnvpcHHiYVR2TQQi__w"
+          + "@mail.gmail.com>\n"
+          + "Subject: Test Subject\n"
+          + "From: Patrick Hiesel <hiesel@google.com>\n"
+          + "To: Patrick Hiesel <hiesel@google.com>\n"
+          + "Content-Type: multipart/mixed; boundary=001a114e019a56962d054062708f\n"
+          + "\n"
+          + "--001a114e019a56962d054062708f\n"
+          + "Content-Type: multipart/alternative; boundary=001a114e019a5696250540"
+          + "62708d\n"
+          + "\n"
+          + "--001a114e019a569625054062708d\n"
+          + "Content-Type: text/plain; charset=UTF-8\n"
+          + "\n"
+          + "Contains unwanted attachment"
+          + "\n"
+          + "--001a114e019a569625054062708d\n"
+          + "Content-Type: text/html; charset=UTF-8\n"
+          + "\n"
+          + "<div dir=\"ltr\">Contains unwanted attachment</div>"
+          + "\n"
+          + "--001a114e019a569625054062708d--\n"
+          + "--001a114e019a56962d054062708f\n"
+          + "Content-Type: text/plain; charset=US-ASCII; name=\"test.txt\"\n"
+          + "Content-Disposition: attachment; filename=\"test.txt\"\n"
+          + "Content-Transfer-Encoding: base64\n"
+          + "X-Attachment-Id: f_iv264bt50\n"
+          + "\n"
+          + "VEVTVAo=\n"
+          + "--001a114e019a56962d054062708f--";
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    System.out.println("\uD83D\uDE1B test");
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<CAM7sg=3meaAVUxW3KXeJEVs8sv_ADw1BnvpcHHiYVR2TQQi__w@mail.gmail.com>")
+        .from(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .addTo(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .textContent("Contains unwanted attachment")
+        .htmlContent("<div dir=\"ltr\">Contains unwanted attachment</div>")
+        .subject("Test Subject")
+        .addAdditionalHeader("MIME-Version: 1.0")
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
+    return expect.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java b/javatests/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
new file mode 100644
index 0000000..91dc6f1
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive.data;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Ignore;
+
+/** Tests parsing a Base64 encoded subject. */
+@Ignore
+public class Base64HeaderMessage extends RawMailMessage {
+  private static String textContent = "Some Text";
+  private static String raw =
+      ""
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
+          + "Subject: =?UTF-8?B?8J+YmyB0ZXN0?=\n"
+          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
+          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
+          + "To: ekempin <ekempin@google.com>\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent;
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
+        .from(
+            new Address(
+                "Jonathan Nieder (Gerrit)",
+                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
+        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .textContent(textContent)
+        .subject("\uD83D\uDE1B test")
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
+    return expect.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java b/javatests/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
new file mode 100644
index 0000000..756581f
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive.data;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.receive.MailMessage;
+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. */
+@Ignore
+public class HtmlMimeMessage extends RawMailMessage {
+  private static String textContent = "Simple test";
+
+  // htmlContent is encoded in quoted-printable
+  private static String htmlContent =
+      "<div dir=3D\"ltr\">Test <span style"
+          + "=3D\"background-color:rgb(255,255,0)\">Messa=\n"
+          + "ge</span> in <u>HTML=C2=A0</u><a href=3D\"https://en.wikipedia.org/"
+          + "wiki/%C3%=\n9Cmlaut_(band)\" class=3D\"gmail-mw-redirect\" title=3D\""
+          + "=C3=9Cmlaut (band)\" st=\nyle=3D\"text-decoration:none;color:rgb(11,"
+          + "0,128);background-image:none;backg=\nround-position:initial;background"
+          + "-size:initial;background-repeat:initial;ba=\nckground-origin:initial;"
+          + "background-clip:initial;font-family:sans-serif;font=\n"
+          + "-size:14px\">=C3=9C</a></div>";
+
+  private static String unencodedHtmlContent =
+      ""
+          + "<div dir=\"ltr\">Test <span style=\"background-color:rgb(255,255,0)\">"
+          + "Message</span> in <u>HTML </u><a href=\"https://en.wikipedia.org/wiki/"
+          + "%C3%9Cmlaut_(band)\" class=\"gmail-mw-redirect\" title=\"Ümlaut "
+          + "(band)\" style=\"text-decoration:none;color:rgb(11,0,128);"
+          + "background-image:none;background-position:initial;background-size:"
+          + "initial;background-repeat:initial;background-origin:initial;background"
+          + "-clip:initial;font-family:sans-serif;font-size:14px\">Ü</a></div>";
+
+  private static String raw =
+      ""
+          + "MIME-Version: 1.0\n"
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <001a114cd8be55b4ab053face5cd@google.com>\n"
+          + "Subject: Change in gerrit[master]: Implement receiver class structure "
+          + "and bindings\n"
+          + "From: \"ekempin (Gerrit)\" <noreply-gerritcodereview-qUgXfQecoDLHwp0Ml"
+          + "dAzig@google.com>\n"
+          + "To: Patrick Hiesel <hiesel@google.com>\n"
+          + "Cc: ekempin <ekempin@google.com>\n"
+          + "Content-Type: multipart/alternative; boundary=001a114cd8b"
+          + "e55b486053face5ca\n"
+          + "\n"
+          + "--001a114cd8be55b486053face5ca\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent
+          + "\n"
+          + "--001a114cd8be55b486053face5ca\n"
+          + "Content-Type: text/html; charset=UTF-8\n"
+          + "Content-Transfer-Encoding: quoted-printable\n"
+          + "\n"
+          + htmlContent
+          + "\n"
+          + "--001a114cd8be55b486053face5ca--";
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114cd8be55b4ab053face5cd@google.com>")
+        .from(
+            new Address(
+                "ekempin (Gerrit)", "noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com"))
+        .addCc(new Address("ekempin", "ekempin@google.com"))
+        .addTo(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .textContent(textContent)
+        .htmlContent(unencodedHtmlContent)
+        .subject("Change in gerrit[master]: Implement receiver class structure and bindings")
+        .addAdditionalHeader("MIME-Version: 1.0")
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
+    return expect.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java b/javatests/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
new file mode 100644
index 0000000..3fafd4b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.mail.receive.data;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Ignore;
+
+/** Tests that non-UTF8 encodings are handled correctly. */
+@Ignore
+public class NonUTF8Message extends RawMailMessage {
+  private static String textContent = "Some Text";
+  private static String raw =
+      ""
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
+          + "Subject: =?UTF-8?B?8J+YmyB0ZXN0?=\n"
+          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
+          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
+          + "To: ekempin <ekempin@google.com>\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent;
+
+  @Override
+  public String raw() {
+    return null;
+  }
+
+  @Override
+  public int[] rawChars() {
+    int[] arr = new int[raw.length()];
+    int i = 0;
+    for (char c : raw.toCharArray()) {
+      arr[i++] = c;
+    }
+    return arr;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
+        .from(
+            new Address(
+                "Jonathan Nieder (Gerrit)",
+                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
+        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .textContent(textContent)
+        .subject("\uD83D\uDE1B test")
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
+    return expect.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java b/javatests/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
new file mode 100644
index 0000000..2dc48b5
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive.data;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Ignore;
+
+/** Tests parsing a quoted printable encoded subject */
+@Ignore
+public class QuotedPrintableHeaderMessage extends RawMailMessage {
+  private static String textContent = "Some Text";
+  private static String raw =
+      ""
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
+          + "Subject: =?UTF-8?Q?=C3=A2me vulgaire?=\n"
+          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
+          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
+          + "To: ekempin <ekempin@google.com>\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent;
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    System.out.println("\uD83D\uDE1B test");
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
+        .from(
+            new Address(
+                "Jonathan Nieder (Gerrit)",
+                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
+        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .textContent(textContent)
+        .subject("âme vulgaire")
+        .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/RawMailMessage.java b/javatests/com/google/gerrit/server/mail/receive/data/RawMailMessage.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/RawMailMessage.java
rename to javatests/com/google/gerrit/server/mail/receive/data/RawMailMessage.java
diff --git a/javatests/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java b/javatests/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
new file mode 100644
index 0000000..aa5b78a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
@@ -0,0 +1,142 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive.data;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.receive.MailMessage;
+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. */
+@Ignore
+public class SimpleTextMessage extends RawMailMessage {
+  private static String textContent =
+      ""
+          + "Jonathan Nieder has posted comments on this change. (  \n"
+          + "https://gerrit-review.googlesource.com/90018 )\n"
+          + "\n"
+          + "Change subject: (Re)enable voting buttons for merged changes\n"
+          + "...........................................................\n"
+          + "\n"
+          + "\n"
+          + "Patch Set 2:\n"
+          + "\n"
+          + "This is producing NPEs server-side and 500s for the client.   \n"
+          + "when I try to load this change:\n"
+          + "\n"
+          + "  Error in GET /changes/90018/detail?O=10004\n"
+          + "  com.google.gwtorm.OrmException: java.lang.NullPointerException\n"
+          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:303)\n"
+          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:285)\n"
+          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:263)\n"
+          + "\tat com.google.gerrit.change.GetChange.apply(GetChange.java:50)\n"
+          + "\tat com.google.gerrit.change.GetDetail.apply(GetDetail.java:51)\n"
+          + "\tat com.google.gerrit.change.GetDetail.apply(GetDetail.java:26)\n"
+          + "\tat  \n"
+          + "com.google.gerrit.RestApiServlet.service(RestApiServlet.java:367)\n"
+          + "\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:717)\n"
+          + "[...]\n"
+          + "  Caused by: java.lang.NullPointerException\n"
+          + "\tat  \n"
+          + "com.google.gerrit.ChangeJson.setLabelScores(ChangeJson.java:670)\n"
+          + "\tat  \n"
+          + "com.google.gerrit.ChangeJson.labelsFor(ChangeJson.java:845)\n"
+          + "\tat  \n"
+          + "com.google.gerrit.change.ChangeJson.labelsFor(ChangeJson.java:598)\n"
+          + "\tat  \n"
+          + "com.google.gerrit.change.ChangeJson.toChange(ChangeJson.java:499)\n"
+          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:294)\n"
+          + "\t... 105 more\n"
+          + "-- \n"
+          + "To view, visit https://gerrit-review.googlesource.com/90018\n"
+          + "To unsubscribe, visit https://gerrit-review.googlesource.com\n"
+          + "\n"
+          + "Gerrit-MessageType: comment\n"
+          + "Gerrit-Change-Id: Iba501e00bee77be3bd0ced72f88fd04ba0accaed\n"
+          + "Gerrit-PatchSet: 2\n"
+          + "Gerrit-Project: gerrit\n"
+          + "Gerrit-Branch: master\n"
+          + "Gerrit-Owner: ekempin <ekempin@google.com>\n"
+          + "Gerrit-Reviewer: Dave Borowitz <dborowitz@google.com>\n"
+          + "Gerrit-Reviewer: Edwin Kempin <ekempin@google.com>\n"
+          + "Gerrit-Reviewer: GerritForge CI <gerritforge@gmail.com>\n"
+          + "Gerrit-Reviewer: Jonathan Nieder <jrn@google.com>\n"
+          + "Gerrit-Reviewer: Patrick Hiesel <hiesel@google.com>\n"
+          + "Gerrit-Reviewer: ekempin <ekempin@google.com>\n"
+          + "Gerrit-HasComments: No";
+
+  private static String raw =
+      ""
+          + "Authentication-Results: mx.google.com; dkim=pass header.i="
+          + "@google.com;\n"
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "In-Reply-To: <gerrit.1477487889000.Iba501e00bee77be3bd0ced"
+          + "72f88fd04ba0accaed@gerrit-review.googlesource.com>\n"
+          + "References: <gerrit.1477487889000.Iba501e00bee77be3bd0ced72f8"
+          + "8fd04ba0accaed@gerrit-review.googlesource.com>\n"
+          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
+          + "Subject: Change in gerrit[master]: (Re)enable voting buttons for "
+          + "merged changes\n"
+          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-CtTy0"
+          + "igsBrnvL7dKoWEIEg@google.com>\n"
+          + "To: ekempin <ekempin@google.com>\n"
+          + "Cc: Dave Borowitz <dborowitz@google.com>, Jonathan Nieder "
+          + "<jrn@google.com>, Patrick Hiesel <hiesel@google.com>\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent;
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
+        .from(
+            new Address(
+                "Jonathan Nieder (Gerrit)",
+                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
+        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .addCc(new Address("Dave Borowitz", "dborowitz@google.com"))
+        .addCc(new Address("Jonathan Nieder", "jrn@google.com"))
+        .addCc(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .textContent(textContent)
+        .subject("Change in gerrit[master]: (Re)enable voting buttons for merged changes")
+        .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(
+            "In-Reply-To: <gerrit.1477487889000.Iba501e00bee"
+                + "77be3bd0ced72f88fd04ba0accaed@gerrit-review.googlesource.com>")
+        .addAdditionalHeader(
+            "References: <gerrit.1477487889000.Iba501e00bee"
+                + "77be3bd0ced72f88fd04ba0accaed@gerrit-review.googlesource.com>");
+    return expect.build();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentFormatterTest.java b/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentFormatterTest.java
rename to javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentSenderTest.java b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentSenderTest.java
rename to javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
new file mode 100644
index 0000000..01e8225
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -0,0 +1,395 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.createStrictMock;
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.mail.Address;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.junit.Before;
+import org.junit.Test;
+
+public class FromAddressGeneratorProviderTest {
+  private Config config;
+  private PersonIdent ident;
+  private AccountCache accountCache;
+
+  @Before
+  public void setUp() throws Exception {
+    config = new Config();
+    ident = new PersonIdent("NAME", "e@email", 0, 0);
+    accountCache = createStrictMock(AccountCache.class);
+  }
+
+  private FromAddressGenerator create() {
+    return new FromAddressGeneratorProvider(config, "Anonymous Coward", ident, accountCache).get();
+  }
+
+  private void setFrom(String newFrom) {
+    config.setString("sendemail", null, "from", newFrom);
+  }
+
+  private void setDomains(List<String> domains) {
+    config.setStringList("sendemail", null, "allowedDomain", domains);
+  }
+
+  @Test
+  public void defaultIsMIXED() {
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
+  }
+
+  @Test
+  public void selectUSER() {
+    setFrom("USER");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.UserGen.class);
+
+    setFrom("user");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.UserGen.class);
+
+    setFrom("uSeR");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.UserGen.class);
+  }
+
+  @Test
+  public void USER_FullyConfiguredUser() {
+    setFrom("USER");
+
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name);
+    assertThat(r.getEmail()).isEqualTo(email);
+    verify(accountCache);
+  }
+
+  @Test
+  public void USER_NoFullNameUser() {
+    setFrom("USER");
+
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(null, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isNull();
+    assertThat(r.getEmail()).isEqualTo(email);
+    verify(accountCache);
+  }
+
+  @Test
+  public void USER_NoPreferredEmailUser() {
+    setFrom("USER");
+
+    final String name = "A U. Thor";
+    final Account.Id user = user(name, null);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void USER_NullUser() {
+    setFrom("USER");
+    replay(accountCache);
+    final Address r = create().from(null);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void USERAllowDomain() {
+    setFrom("USER");
+    setDomains(Arrays.asList("*.example.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name);
+    assertThat(r.getEmail()).isEqualTo(email);
+    verify(accountCache);
+  }
+
+  @Test
+  public void USERNoAllowDomain() {
+    setFrom("USER");
+    setDomains(Arrays.asList("example.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void USERAllowDomainTwice() {
+    setFrom("USER");
+    setDomains(Arrays.asList("example.com"));
+    setDomains(Arrays.asList("test.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name);
+    assertThat(r.getEmail()).isEqualTo(email);
+    verify(accountCache);
+  }
+
+  @Test
+  public void USERAllowDomainTwiceReverse() {
+    setFrom("USER");
+    setDomains(Arrays.asList("test.com"));
+    setDomains(Arrays.asList("example.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void USERAllowTwoDomains() {
+    setFrom("USER");
+    setDomains(Arrays.asList("example.com", "test.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name);
+    assertThat(r.getEmail()).isEqualTo(email);
+    verify(accountCache);
+  }
+
+  @Test
+  public void selectSERVER() {
+    setFrom("SERVER");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.ServerGen.class);
+
+    setFrom("server");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.ServerGen.class);
+
+    setFrom("sErVeR");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.ServerGen.class);
+  }
+
+  @Test
+  public void SERVER_FullyConfiguredUser() {
+    setFrom("SERVER");
+
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = userNoLookup(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void SERVER_NullUser() {
+    setFrom("SERVER");
+    replay(accountCache);
+    final Address r = create().from(null);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void selectMIXED() {
+    setFrom("MIXED");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
+
+    setFrom("mixed");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
+
+    setFrom("mIxEd");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
+  }
+
+  @Test
+  public void MIXED_FullyConfiguredUser() {
+    setFrom("MIXED");
+
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void MIXED_NoFullNameUser() {
+    setFrom("MIXED");
+
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(null, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo("Anonymous Coward (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void MIXED_NoPreferredEmailUser() {
+    setFrom("MIXED");
+
+    final String name = "A U. Thor";
+    final Account.Id user = user(name, null);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void MIXED_NullUser() {
+    setFrom("MIXED");
+    replay(accountCache);
+    final Address r = create().from(null);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void CUSTOM_FullyConfiguredUser() {
+    setFrom("A ${user} B <my.server@email.address>");
+
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo("A " + name + " B");
+    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
+    verify(accountCache);
+  }
+
+  @Test
+  public void CUSTOM_NoFullNameUser() {
+    setFrom("A ${user} B <my.server@email.address>");
+
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(null, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo("A Anonymous Coward B");
+    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
+    verify(accountCache);
+  }
+
+  @Test
+  public void CUSTOM_NullUser() {
+    setFrom("A ${user} B <my.server@email.address>");
+
+    replay(accountCache);
+    final Address r = create().from(null);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
+    verify(accountCache);
+  }
+
+  private Account.Id user(String name, String email) {
+    final AccountState s = makeUser(name, email);
+    expect(accountCache.get(eq(s.getAccount().getId()))).andReturn(s);
+    return s.getAccount().getId();
+  }
+
+  private Account.Id userNoLookup(String name, String email) {
+    final AccountState s = makeUser(name, email);
+    return s.getAccount().getId();
+  }
+
+  private AccountState makeUser(String name, String email) {
+    final Account.Id userId = new Account.Id(42);
+    final Account account = new Account(userId, TimeUtil.nowTs());
+    account.setFullName(name);
+    account.setPreferredEmail(email);
+    return new AccountState(
+        new AllUsersName(AllUsersNameProvider.DEFAULT),
+        account,
+        Collections.emptySet(),
+        new HashMap<>(),
+        GeneralPreferencesInfo.defaults());
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
new file mode 100644
index 0000000..08ff40b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -0,0 +1,299 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.inject.Scopes.SINGLETON;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.CommentRange;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.FakeRealm;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.DisableReverseDnsLookup;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitModule;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.FakeAccountCache;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gerrit.testing.TestChanges;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.TypeLiteral;
+import com.google.inject.util.Providers;
+import java.sql.Timestamp;
+import java.util.TimeZone;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.runner.RunWith;
+
+@Ignore
+@RunWith(ConfigSuite.class)
+public abstract class AbstractChangeNotesTest extends GerritBaseTests {
+  @ConfigSuite.Default
+  public static Config changeNotesLegacy() {
+    Config cfg = new Config();
+    cfg.setBoolean("notedb", null, "writeJson", false);
+    return cfg;
+  }
+
+  @ConfigSuite.Config
+  public static Config changeNotesJson() {
+    Config cfg = new Config();
+    cfg.setBoolean("notedb", null, "writeJson", true);
+    return cfg;
+  }
+
+  @ConfigSuite.Parameter public Config testConfig;
+
+  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+
+  protected Account.Id otherUserId;
+  protected FakeAccountCache accountCache;
+  protected IdentifiedUser changeOwner;
+  protected IdentifiedUser otherUser;
+  protected InMemoryRepository repo;
+  protected InMemoryRepositoryManager repoManager;
+  protected PersonIdent serverIdent;
+  protected InternalUser internalUser;
+  protected Project.NameKey project;
+  protected RevWalk rw;
+  protected TestRepository<InMemoryRepository> tr;
+
+  @Inject protected IdentifiedUser.GenericFactory userFactory;
+
+  @Inject protected NoteDbUpdateManager.Factory updateManagerFactory;
+
+  @Inject protected AllUsersName allUsers;
+
+  @Inject protected AbstractChangeNotes.Args args;
+
+  @Inject @GerritServerId private String serverId;
+
+  protected Injector injector;
+  private String systemTimeZone;
+
+  @Before
+  public void setUp() throws Exception {
+    setTimeForTesting();
+
+    serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.nowTs(), TZ);
+    project = new Project.NameKey("test-project");
+    repoManager = new InMemoryRepositoryManager();
+    repo = repoManager.createRepository(project);
+    tr = new TestRepository<>(repo);
+    rw = tr.getRevWalk();
+    accountCache = new FakeAccountCache();
+    Account co = new Account(new Account.Id(1), TimeUtil.nowTs());
+    co.setFullName("Change Owner");
+    co.setPreferredEmail("change@owner.com");
+    accountCache.put(co);
+    Account ou = new Account(new Account.Id(2), TimeUtil.nowTs());
+    ou.setFullName("Other Account");
+    ou.setPreferredEmail("other@account.com");
+    accountCache.put(ou);
+
+    injector =
+        Guice.createInjector(
+            new FactoryModule() {
+              @Override
+              public void configure() {
+                install(new GitModule());
+                install(NoteDbModule.forTest(testConfig));
+                bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
+                bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
+                bind(GitRepositoryManager.class).toInstance(repoManager);
+                bind(ProjectCache.class).toProvider(Providers.<ProjectCache>of(null));
+                bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
+                bind(String.class)
+                    .annotatedWith(AnonymousCowardName.class)
+                    .toProvider(AnonymousCowardNameProvider.class);
+                bind(String.class)
+                    .annotatedWith(CanonicalWebUrl.class)
+                    .toInstance("http://localhost:8080/");
+                bind(Boolean.class)
+                    .annotatedWith(DisableReverseDnsLookup.class)
+                    .toInstance(Boolean.FALSE);
+                bind(Realm.class).to(FakeRealm.class);
+                bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
+                bind(AccountCache.class).toInstance(accountCache);
+                bind(PersonIdent.class)
+                    .annotatedWith(GerritPersonIdent.class)
+                    .toInstance(serverIdent);
+                bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+                bind(MetricMaker.class).to(DisabledMetricMaker.class);
+                bind(ReviewDb.class).toProvider(Providers.<ReviewDb>of(null));
+
+                MutableNotesMigration migration = MutableNotesMigration.newDisabled();
+                migration.setFrom(NotesMigrationState.FINAL);
+                bind(MutableNotesMigration.class).toInstance(migration);
+                bind(NotesMigration.class).to(MutableNotesMigration.class);
+
+                // Tests don't support ReviewDb at all, but bindings are required via NoteDbModule.
+                bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {})
+                    .toInstance(
+                        () -> {
+                          throw new UnsupportedOperationException();
+                        });
+                bind(ChangeBundleReader.class)
+                    .toInstance(
+                        (db, id) -> {
+                          throw new UnsupportedOperationException();
+                        });
+              }
+            });
+
+    injector.injectMembers(this);
+    repoManager.createRepository(allUsers);
+    changeOwner = userFactory.create(co.getId());
+    otherUser = userFactory.create(ou.getId());
+    otherUserId = otherUser.getAccountId();
+    internalUser = new InternalUser();
+  }
+
+  private void setTimeForTesting() {
+    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZone);
+  }
+
+  protected Change newChange(boolean workInProgress) throws Exception {
+    Change c = TestChanges.newChange(project, changeOwner.getAccountId());
+    ChangeUpdate u = newUpdate(c, changeOwner);
+    u.setChangeId(c.getKey().get());
+    u.setBranch(c.getDest().get());
+    u.setWorkInProgress(workInProgress);
+    u.commit();
+    return c;
+  }
+
+  protected Change newWorkInProgressChange() throws Exception {
+    return newChange(true);
+  }
+
+  protected Change newChange() throws Exception {
+    return newChange(false);
+  }
+
+  protected ChangeUpdate newUpdate(Change c, CurrentUser user) throws Exception {
+    ChangeUpdate update = TestChanges.newUpdate(injector, c, user);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.setAllowWriteToNewRef(true);
+    return update;
+  }
+
+  protected ChangeNotes newNotes(Change c) throws OrmException {
+    return new ChangeNotes(args, c).load();
+  }
+
+  protected static SubmitRecord submitRecord(
+      String status, String errorMessage, SubmitRecord.Label... labels) {
+    SubmitRecord rec = new SubmitRecord();
+    rec.status = SubmitRecord.Status.valueOf(status);
+    rec.errorMessage = errorMessage;
+    if (labels.length > 0) {
+      rec.labels = ImmutableList.copyOf(labels);
+    }
+    return rec;
+  }
+
+  protected static SubmitRecord.Label submitLabel(
+      String name, String status, Account.Id appliedBy) {
+    SubmitRecord.Label label = new SubmitRecord.Label();
+    label.label = name;
+    label.status = SubmitRecord.Label.Status.valueOf(status);
+    label.appliedBy = appliedBy;
+    return label;
+  }
+
+  protected Comment newComment(
+      PatchSet.Id psId,
+      String filename,
+      String UUID,
+      CommentRange range,
+      int line,
+      IdentifiedUser commenter,
+      String parentUUID,
+      Timestamp t,
+      String message,
+      short side,
+      String commitSHA1,
+      boolean unresolved) {
+    Comment c =
+        new Comment(
+            new Comment.Key(UUID, filename, psId.get()),
+            commenter.getAccountId(),
+            t,
+            side,
+            message,
+            serverId,
+            unresolved);
+    c.lineNbr = line;
+    c.parentUuid = parentUUID;
+    c.revId = commitSHA1;
+    c.setRange(range);
+    return c;
+  }
+
+  protected static Timestamp truncate(Timestamp ts) {
+    return new Timestamp((ts.getTime() / 1000) * 1000);
+  }
+
+  protected static Timestamp after(Change c, long millis) {
+    return new Timestamp(c.getCreatedOn().getTime() + millis);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java b/javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java
new file mode 100644
index 0000000..722dd08
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java
@@ -0,0 +1,1976 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.common.TimeUtil.truncateToSecond;
+import static com.google.gerrit.server.notedb.ChangeBundle.Source.NOTE_DB;
+import static com.google.gerrit.server.notedb.ChangeBundle.Source.REVIEW_DB;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Table;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.TestChanges;
+import com.google.gerrit.testing.TestTimeUtil;
+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.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ChangeBundleTest extends GerritBaseTests {
+  private static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
+  private static final ProtobufCodec<ChangeMessage> CHANGE_MESSAGE_CODEC =
+      CodecFactory.encoder(ChangeMessage.class);
+  private static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
+      CodecFactory.encoder(PatchSet.class);
+  private static final ProtobufCodec<PatchSetApproval> PATCH_SET_APPROVAL_CODEC =
+      CodecFactory.encoder(PatchSetApproval.class);
+  private static final ProtobufCodec<PatchLineComment> PATCH_LINE_COMMENT_CODEC =
+      CodecFactory.encoder(PatchLineComment.class);
+  private static final String TIMEZONE_ID = "US/Eastern";
+
+  private String systemTimeZoneProperty;
+  private TimeZone systemTimeZone;
+
+  private Project.NameKey project;
+  private Account.Id accountId;
+
+  @Before
+  public void setUp() {
+    systemTimeZoneProperty = System.setProperty("user.timezone", TIMEZONE_ID);
+    systemTimeZone = TimeZone.getDefault();
+    TimeZone.setDefault(TimeZone.getTimeZone(TIMEZONE_ID));
+    long maxMs = ChangeRebuilderImpl.MAX_WINDOW_MS;
+    assertThat(maxMs).isGreaterThan(1000L);
+    TestTimeUtil.resetWithClockStep(maxMs * 2, MILLISECONDS);
+    project = new Project.NameKey("project");
+    accountId = new Account.Id(100);
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZoneProperty);
+    TimeZone.setDefault(systemTimeZone);
+  }
+
+  private void superWindowResolution() {
+    TestTimeUtil.setClockStep(ChangeRebuilderImpl.MAX_WINDOW_MS * 2, MILLISECONDS);
+    TimeUtil.nowTs();
+  }
+
+  private void subWindowResolution() {
+    TestTimeUtil.setClockStep(1, SECONDS);
+    TimeUtil.nowTs();
+  }
+
+  @Test
+  public void diffChangesDifferentIds() throws Exception {
+    Change c1 = TestChanges.newChange(project, accountId);
+    int id1 = c1.getId().get();
+    Change c2 = TestChanges.newChange(project, accountId);
+    int id2 = c2.getId().get();
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+
+    assertDiffs(
+        b1,
+        b2,
+        "changeId differs for Changes: {" + id1 + "} != {" + id2 + "}",
+        "createdOn differs for Changes: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}",
+        "effective last updated time differs for Changes:"
+            + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}");
+  }
+
+  @Test
+  public void diffChangesSameId() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    Change c2 = clone(c1);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+
+    assertNoDiffs(b1, b2);
+
+    c2.setTopic("topic");
+    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {null} != {topic}");
+  }
+
+  @Test
+  public void diffChangesMixedSourcesAllowsSlop() throws Exception {
+    subWindowResolution();
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    Change c2 = clone(c1);
+    c2.setCreatedOn(TimeUtil.nowTs());
+    c2.setLastUpdatedOn(TimeUtil.nowTs());
+
+    // Both are ReviewDb, exact timestamp match is required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "createdOn differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:02.0}",
+        "effective last updated time differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:03.0}");
+
+    // One NoteDb, slop is allowed.
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+
+    // But not too much slop.
+    superWindowResolution();
+    Change c3 = clone(c1);
+    c3.setLastUpdatedOn(TimeUtil.nowTs());
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    ChangeBundle b3 =
+        new ChangeBundle(
+            c3, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    String msg =
+        "effective last updated time differs for Change.Id "
+            + c1.getId()
+            + " in NoteDb vs. ReviewDb:"
+            + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:10.0}";
+    assertDiffs(b1, b3, msg);
+    assertDiffs(b3, b1, msg);
+  }
+
+  @Test
+  public void diffChangesIgnoresOriginalSubjectInReviewDb() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    c1.setCurrentPatchSet(c1.currentPatchSetId(), "Subject", "Original A");
+    Change c2 = clone(c1);
+    c2.setCurrentPatchSet(c2.currentPatchSetId(), c1.getSubject(), "Original B");
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "originalSubject differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {Original A} != {Original B}");
+
+    // Both NoteDb, exact match required.
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "originalSubject differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {Original A} != {Original B}");
+
+    // One ReviewDb, one NoteDb, original subject is ignored.
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffChangesSanitizesSubjectsBeforeComparison() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    c1.setCurrentPatchSet(c1.currentPatchSetId(), "Subject\r\rbody", "Original");
+    Change c2 = clone(c1);
+    c2.setCurrentPatchSet(c2.currentPatchSetId(), "Subject  body", "Original");
+
+    // Both ReviewDb, exact match required
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "subject differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {Subject\r\rbody} != {Subject  body}");
+
+    // Both NoteDb, exact match required (although it should be impossible to
+    // create a NoteDb change with '\r' in the subject).
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "subject differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {Subject\r\rbody} != {Subject  body}");
+
+    // One ReviewDb, one NoteDb, '\r' is normalized to ' '.
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffChangesConsidersEmptyReviewDbTopicEquivalentToNullInNoteDb() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    c1.setTopic("");
+    Change c2 = clone(c1);
+    c2.setTopic(null);
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {} != {null}");
+
+    // Topic ignored if ReviewDb is empty and NoteDb is null.
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+
+    // Exact match still required if NoteDb has empty value (not realistic).
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {} != {null}");
+
+    // Null is not equal to a non-empty string.
+    Change c3 = clone(c1);
+    c3.setTopic("topic");
+    b1 =
+        new ChangeBundle(
+            c3, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {topic} != {null}");
+
+    // Null is equal to a string that is all whitespace.
+    Change c4 = clone(c1);
+    c4.setTopic("  ");
+    b1 =
+        new ChangeBundle(
+            c4, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffChangesIgnoresLeadingAndTrailingWhitespaceInReviewDbTopics() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    c1.setTopic(" abc ");
+    Change c2 = clone(c1);
+    c2.setTopic("abc");
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": { abc } != {abc}");
+
+    // Leading whitespace in ReviewDb topic is ignored.
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+
+    // Must match except for the leading/trailing whitespace.
+    Change c3 = clone(c1);
+    c3.setTopic("cba");
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c3, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": { abc } != {cba}");
+  }
+
+  @Test
+  public void diffChangesTakesMaxEntityTimestampFromReviewDb() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    PatchSet ps = new PatchSet(c1.currentPatchSetId());
+    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps.setUploader(accountId);
+    ps.setCreatedOn(TimeUtil.nowTs());
+    PatchSetApproval a =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c1.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            TimeUtil.nowTs());
+
+    Change c2 = clone(c1);
+    c2.setLastUpdatedOn(a.getGranted());
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(ps), approvals(a), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(ps), approvals(a), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "effective last updated time differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:12.0}");
+
+    // NoteDb allows latest timestamp from all entities in bundle.
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(ps), approvals(a), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+  }
+
+  @Test
+  public void diffChangesIgnoresChangeTimestampIfAnyOtherEntitiesExist() {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    PatchSet ps = new PatchSet(c1.currentPatchSetId());
+    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps.setUploader(accountId);
+    ps.setCreatedOn(TimeUtil.nowTs());
+    PatchSetApproval a =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c1.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            TimeUtil.nowTs());
+    c1.setLastUpdatedOn(a.getGranted());
+
+    Change c2 = clone(c1);
+    c2.setLastUpdatedOn(TimeUtil.nowTs());
+
+    // ReviewDb has later lastUpdatedOn timestamp than NoteDb, allowed since
+    // NoteDb matches the latest timestamp of a non-Change entity.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c2, messages(), patchSets(ps), approvals(a), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c1, messages(), patchSets(ps), approvals(a), comments(), reviewers(), NOTE_DB);
+    assertThat(b1.getChange().getLastUpdatedOn()).isGreaterThan(b2.getChange().getLastUpdatedOn());
+    assertNoDiffs(b1, b2);
+
+    // Timestamps must actually match if Change is the only entity.
+    b1 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "effective last updated time differs for Change.Id "
+            + c1.getId()
+            + " in NoteDb vs. ReviewDb:"
+            + " {2009-09-30 17:00:12.0} != {2009-09-30 17:00:18.0}");
+  }
+
+  @Test
+  public void diffChangesAllowsReviewDbSubjectToBePrefixOfNoteDbSubject() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    Change c2 = clone(c1);
+    c2.setCurrentPatchSet(
+        c1.currentPatchSetId(), c1.getSubject().substring(0, 10), c1.getOriginalSubject());
+    assertThat(c2.getSubject()).isNotEqualTo(c1.getSubject());
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "subject differs for Change.Id " + c1.getId() + ": {Change subject} != {Change sub}");
+
+    // ReviewDb has shorter subject, allowed.
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+
+    // NoteDb has shorter subject, not allowed.
+    b1 =
+        new ChangeBundle(
+            c1, messages(), latest(c1), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(c2, messages(), latest(c2), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "subject differs for Change.Id " + c1.getId() + ": {Change subject} != {Change sub}");
+  }
+
+  @Test
+  public void diffChangesTrimsLeadingSpacesFromReviewDbComparingToNoteDb() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    Change c2 = clone(c1);
+    c2.setCurrentPatchSet(c1.currentPatchSetId(), "   " + c1.getSubject(), c1.getOriginalSubject());
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "subject differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {Change subject} != {   Change subject}");
+
+    // ReviewDb is missing leading spaces, allowed.
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffChangesDoesntTrimLeadingNonSpaceWhitespaceFromSubject() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    Change c2 = clone(c1);
+    c2.setCurrentPatchSet(c1.currentPatchSetId(), "\t" + c1.getSubject(), c1.getOriginalSubject());
+
+    // Both ReviewDb.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "subject differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {Change subject} != {\tChange subject}");
+
+    // One NoteDb.
+    b1 =
+        new ChangeBundle(c1, messages(), latest(c1), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), latest(c2), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "subject differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {Change subject} != {\tChange subject}");
+    assertDiffs(
+        b2,
+        b1,
+        "subject differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {\tChange subject} != {Change subject}");
+  }
+
+  @Test
+  public void diffChangesHandlesBuggyJGitSubjectExtraction() throws Exception {
+    Change c1 = TestChanges.newChange(project, accountId);
+    String buggySubject = "Subject\r \r Rest of message.";
+    c1.setCurrentPatchSet(c1.currentPatchSetId(), buggySubject, buggySubject);
+    Change c2 = clone(c1);
+    c2.setCurrentPatchSet(c2.currentPatchSetId(), "Subject", "Subject");
+
+    // Both ReviewDb.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "originalSubject differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {Subject\r \r Rest of message.} != {Subject}",
+        "subject differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {Subject\r \r Rest of message.} != {Subject}");
+
+    // NoteDb has correct subject without "\r ".
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffChangesIgnoresInvalidCurrentPatchSetIdInReviewDb() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    Change c2 = clone(c1);
+    c2.setCurrentPatchSet(
+        new PatchSet.Id(c2.getId(), 0), "Unrelated subject", c2.getOriginalSubject());
+
+    // Both ReviewDb.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "currentPatchSetId differs for Change.Id " + c1.getId() + ": {1} != {0}",
+        "subject differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {Change subject} != {Unrelated subject}");
+
+    // One NoteDb.
+    //
+    // This is based on a real corrupt change where all patch sets were deleted
+    // but the Change entity stuck around, resulting in a currentPatchSetId of 0
+    // after converting to NoteDb.
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffChangesAllowsCreatedToMatchLastUpdated() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    c1.setCreatedOn(TimeUtil.nowTs());
+    assertThat(c1.getCreatedOn()).isGreaterThan(c1.getLastUpdatedOn());
+    Change c2 = clone(c1);
+    c2.setCreatedOn(c2.getLastUpdatedOn());
+
+    // Both ReviewDb.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "createdOn differs for Change.Id "
+            + c1.getId()
+            + ": {2009-09-30 17:00:06.0} != {2009-09-30 17:00:00.0}");
+
+    // One NoteDb.
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffChangeMessageKeySets() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    int id = c.getId().get();
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid1"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+    ChangeMessage cm2 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid2"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+
+    assertDiffs(
+        b1,
+        b2,
+        "ChangeMessage.Key sets differ:"
+            + " ["
+            + id
+            + ",uuid1] only in A; ["
+            + id
+            + ",uuid2] only in B");
+  }
+
+  @Test
+  public void diffChangeMessages() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+    cm1.setMessage("message 1");
+    ChangeMessage cm2 = clone(cm1);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+
+    assertNoDiffs(b1, b2);
+
+    cm2.setMessage("message 2");
+    assertDiffs(
+        b1,
+        b2,
+        "message differs for ChangeMessage.Key "
+            + c.getId()
+            + ",uuid:"
+            + " {message 1} != {message 2}");
+  }
+
+  @Test
+  public void diffChangeMessagesIgnoresUuids() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    int id = c.getId().get();
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid1"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+    cm1.setMessage("message 1");
+    ChangeMessage cm2 = clone(cm1);
+    cm2.getKey().set("uuid2");
+
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    // Both are ReviewDb, exact UUID match is required.
+    assertDiffs(
+        b1,
+        b2,
+        "ChangeMessage.Key sets differ:"
+            + " ["
+            + id
+            + ",uuid1] only in A; ["
+            + id
+            + ",uuid2] only in B");
+
+    // One NoteDb, UUIDs are ignored.
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+  }
+
+  @Test
+  public void diffChangeMessagesWithDifferentCounts() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    int id = c.getId().get();
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid1"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+    cm1.setMessage("message 1");
+    ChangeMessage cm2 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid2"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+    cm1.setMessage("message 2");
+
+    // Both ReviewDb: Uses same keySet diff as other types.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1, cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1, b2, "ChangeMessage.Key sets differ: [" + id + ",uuid2] only in A; [] only in B");
+
+    // One NoteDb: UUIDs in keys can't be used for comparison, just diff counts.
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1, cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(b1, b2, "ChangeMessages differ for Change.Id " + id + "\nOnly in A:\n  " + cm2);
+    assertDiffs(b2, b1, "ChangeMessages differ for Change.Id " + id + "\nOnly in B:\n  " + cm2);
+  }
+
+  @Test
+  public void diffChangeMessagesMixedSourcesWithDifferences() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    int id = c.getId().get();
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid1"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+    cm1.setMessage("message 1");
+    ChangeMessage cm2 = clone(cm1);
+    cm2.setMessage("message 2");
+    ChangeMessage cm3 = clone(cm1);
+    cm3.getKey().set("uuid2"); // Differs only in UUID.
+
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1, cm3), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(cm2, cm3), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
+    // Implementation happens to pair up cm1 in b1 with cm3 in b2 because it
+    // depends on iteration order and doesn't care about UUIDs. The important
+    // thing is that there's some diff.
+    assertDiffs(
+        b1,
+        b2,
+        "ChangeMessages differ for Change.Id "
+            + id
+            + "\n"
+            + "Only in A:\n  "
+            + cm3
+            + "\n"
+            + "Only in B:\n  "
+            + cm2);
+    assertDiffs(
+        b2,
+        b1,
+        "ChangeMessages differ for Change.Id "
+            + id
+            + "\n"
+            + "Only in A:\n  "
+            + cm2
+            + "\n"
+            + "Only in B:\n  "
+            + cm3);
+  }
+
+  @Test
+  public void diffChangeMessagesMixedSourcesAllowsSlop() throws Exception {
+    subWindowResolution();
+    Change c = TestChanges.newChange(project, accountId);
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid1"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+    ChangeMessage cm2 = clone(cm1);
+    cm2.setWrittenOn(TimeUtil.nowTs());
+
+    // Both are ReviewDb, exact timestamp match is required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "writtenOn differs for ChangeMessage.Key "
+            + c.getId()
+            + ",uuid1:"
+            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
+
+    // One NoteDb, slop is allowed.
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+
+    // But not too much slop.
+    superWindowResolution();
+    ChangeMessage cm3 = clone(cm1);
+    cm3.setWrittenOn(TimeUtil.nowTs());
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
+    ChangeBundle b3 =
+        new ChangeBundle(
+            c, messages(cm3), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    int id = c.getId().get();
+    assertDiffs(
+        b1,
+        b3,
+        "ChangeMessages differ for Change.Id "
+            + id
+            + "\n"
+            + "Only in A:\n  "
+            + cm1
+            + "\n"
+            + "Only in B:\n  "
+            + cm3);
+    assertDiffs(
+        b3,
+        b1,
+        "ChangeMessages differ for Change.Id "
+            + id
+            + "\n"
+            + "Only in A:\n  "
+            + cm3
+            + "\n"
+            + "Only in B:\n  "
+            + cm1);
+  }
+
+  @Test
+  public void diffChangeMessagesAllowsNullPatchSetIdFromReviewDb() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    int id = c.getId().get();
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+    cm1.setMessage("message 1");
+    ChangeMessage cm2 = clone(cm1);
+    cm2.setPatchSetId(null);
+
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+
+    // Both are ReviewDb, exact patch set ID match is required.
+    assertDiffs(
+        b1,
+        b2,
+        "patchset differs for ChangeMessage.Key "
+            + c.getId()
+            + ",uuid:"
+            + " {"
+            + id
+            + ",1} != {null}");
+
+    // Null patch set ID on ReviewDb is ignored.
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+
+    // Null patch set ID on NoteDb is not ignored (but is not realistic).
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "ChangeMessages differ for Change.Id "
+            + id
+            + "\n"
+            + "Only in A:\n  "
+            + cm1
+            + "\n"
+            + "Only in B:\n  "
+            + cm2);
+    assertDiffs(
+        b2,
+        b1,
+        "ChangeMessages differ for Change.Id "
+            + id
+            + "\n"
+            + "Only in A:\n  "
+            + cm2
+            + "\n"
+            + "Only in B:\n  "
+            + cm1);
+  }
+
+  @Test
+  public void diffPatchSetIdSets() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    TestChanges.incrementPatchSet(c);
+
+    PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
+    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps1.setUploader(accountId);
+    ps1.setCreatedOn(TimeUtil.nowTs());
+    PatchSet ps2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
+    ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
+    ps2.setUploader(accountId);
+    ps2.setCreatedOn(TimeUtil.nowTs());
+
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1, ps2), approvals(), comments(), reviewers(), REVIEW_DB);
+
+    assertDiffs(b1, b2, "PatchSet.Id sets differ: [] only in A; [" + c.getId() + ",1] only in B");
+  }
+
+  @Test
+  public void diffPatchSets() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    PatchSet ps1 = new PatchSet(c.currentPatchSetId());
+    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps1.setUploader(accountId);
+    ps1.setCreatedOn(TimeUtil.nowTs());
+    PatchSet ps2 = clone(ps1);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
+
+    assertNoDiffs(b1, b2);
+
+    ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
+    assertDiffs(
+        b1,
+        b2,
+        "revision differs for PatchSet.Id "
+            + c.getId()
+            + ",1:"
+            + " {RevId{deadbeefdeadbeefdeadbeefdeadbeefdeadbeef}}"
+            + " != {RevId{badc0feebadc0feebadc0feebadc0feebadc0fee}}");
+  }
+
+  @Test
+  public void diffPatchSetsMixedSourcesAllowsSlop() throws Exception {
+    subWindowResolution();
+    Change c = TestChanges.newChange(project, accountId);
+    PatchSet ps1 = new PatchSet(c.currentPatchSetId());
+    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps1.setUploader(accountId);
+    ps1.setCreatedOn(truncateToSecond(TimeUtil.nowTs()));
+    PatchSet ps2 = clone(ps1);
+    ps2.setCreatedOn(TimeUtil.nowTs());
+
+    // Both are ReviewDb, exact timestamp match is required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "createdOn differs for PatchSet.Id "
+            + c.getId()
+            + ",1:"
+            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
+
+    // One NoteDb, slop is allowed.
+    b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+
+    // But not too much slop.
+    superWindowResolution();
+    PatchSet ps3 = clone(ps1);
+    ps3.setCreatedOn(TimeUtil.nowTs());
+    b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB);
+    ChangeBundle b3 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps3), approvals(), comments(), reviewers(), REVIEW_DB);
+    String msg =
+        "createdOn differs for PatchSet.Id "
+            + c.getId()
+            + ",1 in NoteDb vs. ReviewDb:"
+            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:10.0}";
+    assertDiffs(b1, b3, msg);
+    assertDiffs(b3, b1, msg);
+  }
+
+  @Test
+  public void diffPatchSetsIgnoresTrailingNewlinesInPushCertificate() throws Exception {
+    subWindowResolution();
+    Change c = TestChanges.newChange(project, accountId);
+    PatchSet ps1 = new PatchSet(c.currentPatchSetId());
+    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps1.setUploader(accountId);
+    ps1.setCreatedOn(truncateToSecond(TimeUtil.nowTs()));
+    ps1.setPushCertificate("some cert");
+    PatchSet ps2 = clone(ps1);
+    ps2.setPushCertificate(ps2.getPushCertificate() + "\n\n");
+
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+
+    b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffPatchSetsGreaterThanCurrent() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+
+    PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
+    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps1.setUploader(accountId);
+    ps1.setCreatedOn(TimeUtil.nowTs());
+    PatchSet ps2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
+    ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
+    ps2.setUploader(accountId);
+    ps2.setCreatedOn(TimeUtil.nowTs());
+    assertThat(ps2.getId().get()).isGreaterThan(c.currentPatchSetId().get());
+
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid1"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+    ChangeMessage cm2 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid2"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+
+    PatchSetApproval a1 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(ps1.getId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            TimeUtil.nowTs());
+    PatchSetApproval a2 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(ps2.getId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            TimeUtil.nowTs());
+
+    // Both ReviewDb.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c,
+            messages(cm1, cm2),
+            patchSets(ps1, ps2),
+            approvals(a1, a2),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "ChangeMessage.Key sets differ: [] only in A; [" + cm2.getKey() + "] only in B",
+        "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B",
+        "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B");
+
+    // One NoteDb.
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c,
+            messages(cm1, cm2),
+            patchSets(ps1, ps2),
+            approvals(a1, a2),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "ChangeMessages differ for Change.Id " + c.getId() + "\nOnly in B:\n  " + cm2,
+        "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B",
+        "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B");
+
+    // Both NoteDb.
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c,
+            messages(cm1, cm2),
+            patchSets(ps1, ps2),
+            approvals(a1, a2),
+            comments(),
+            reviewers(),
+            NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "ChangeMessages differ for Change.Id " + c.getId() + "\nOnly in B:\n  " + cm2,
+        "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B",
+        "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B");
+  }
+
+  @Test
+  public void diffPatchSetsIgnoresLeadingAndTrailingWhitespaceInReviewDbDescriptions()
+      throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+
+    PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
+    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps1.setUploader(accountId);
+    ps1.setCreatedOn(TimeUtil.nowTs());
+    ps1.setDescription(" abc ");
+    PatchSet ps2 = clone(ps1);
+    ps2.setDescription("abc");
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1, b2, "description differs for PatchSet.Id " + ps1.getId() + ": { abc } != {abc}");
+
+    // Whitespace in ReviewDb description is ignored.
+    b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+
+    // Must match except for the leading/trailing whitespace.
+    PatchSet ps3 = clone(ps1);
+    ps3.setDescription("cba");
+    b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps3), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(
+        b1, b2, "description differs for PatchSet.Id " + ps1.getId() + ": { abc } != {cba}");
+  }
+
+  @Test
+  public void diffPatchSetsIgnoresCreatedOnWhenReviewDbIsNonMonotonic() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+
+    Timestamp beforePs1 = TimeUtil.nowTs();
+
+    PatchSet goodPs1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
+    goodPs1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    goodPs1.setUploader(accountId);
+    goodPs1.setCreatedOn(TimeUtil.nowTs());
+    PatchSet goodPs2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
+    goodPs2.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    goodPs2.setUploader(accountId);
+    goodPs2.setCreatedOn(TimeUtil.nowTs());
+    assertThat(goodPs2.getCreatedOn()).isGreaterThan(goodPs1.getCreatedOn());
+
+    PatchSet badPs2 = clone(goodPs2);
+    badPs2.setCreatedOn(beforePs1);
+    assertThat(badPs2.getCreatedOn()).isLessThan(goodPs1.getCreatedOn());
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(goodPs1, goodPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(goodPs1, badPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "createdOn differs for PatchSet.Id "
+            + badPs2.getId()
+            + ":"
+            + " {2009-09-30 17:00:18.0} != {2009-09-30 17:00:06.0}");
+
+    // Non-monotonic in ReviewDb but monotonic in NoteDb, timestamps are
+    // ignored, including for ps1.
+    PatchSet badPs1 = clone(goodPs1);
+    badPs1.setCreatedOn(TimeUtil.nowTs());
+    b1 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(badPs1, badPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(goodPs1, goodPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+
+    // Non-monotonic in NoteDb but monotonic in ReviewDb, timestamps are not
+    // ignored.
+    b1 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(goodPs1, goodPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(badPs1, badPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "createdOn differs for PatchSet.Id "
+            + badPs1.getId()
+            + " in NoteDb vs. ReviewDb:"
+            + " {2009-09-30 17:00:24.0} != {2009-09-30 17:00:12.0}",
+        "createdOn differs for PatchSet.Id "
+            + badPs2.getId()
+            + " in NoteDb vs. ReviewDb:"
+            + " {2009-09-30 17:00:06.0} != {2009-09-30 17:00:18.0}");
+  }
+
+  @Test
+  public void diffPatchSetsAllowsFirstPatchSetCreatedOnToMatchChangeCreatedOn() {
+    Change c = TestChanges.newChange(project, accountId);
+    c.setLastUpdatedOn(TimeUtil.nowTs());
+
+    PatchSet goodPs1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
+    goodPs1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    goodPs1.setUploader(accountId);
+    goodPs1.setCreatedOn(TimeUtil.nowTs());
+    assertThat(goodPs1.getCreatedOn()).isGreaterThan(c.getCreatedOn());
+
+    PatchSet ps1AtCreatedOn = clone(goodPs1);
+    ps1AtCreatedOn.setCreatedOn(c.getCreatedOn());
+
+    PatchSet goodPs2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
+    goodPs2.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    goodPs2.setUploader(accountId);
+    goodPs2.setCreatedOn(TimeUtil.nowTs());
+
+    PatchSet ps2AtCreatedOn = clone(goodPs2);
+    ps2AtCreatedOn.setCreatedOn(c.getCreatedOn());
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(goodPs1, goodPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(ps1AtCreatedOn, ps2AtCreatedOn),
+            approvals(),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "createdOn differs for PatchSet.Id "
+            + c.getId()
+            + ",1: {2009-09-30 17:00:12.0} != {2009-09-30 17:00:00.0}",
+        "createdOn differs for PatchSet.Id "
+            + c.getId()
+            + ",2: {2009-09-30 17:00:18.0} != {2009-09-30 17:00:00.0}");
+
+    // One ReviewDb, PS1 is allowed to match change createdOn, but PS2 isn't.
+    b1 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(goodPs1, goodPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(ps1AtCreatedOn, ps2AtCreatedOn),
+            approvals(),
+            comments(),
+            reviewers(),
+            NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "createdOn differs for PatchSet.Id "
+            + c.getId()
+            + ",2 in NoteDb vs. ReviewDb: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:18.0}");
+    assertDiffs(
+        b2,
+        b1,
+        "createdOn differs for PatchSet.Id "
+            + c.getId()
+            + ",2 in NoteDb vs. ReviewDb: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:18.0}");
+  }
+
+  @Test
+  public void diffPatchSetApprovalKeySets() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    int id = c.getId().get();
+    PatchSetApproval a1 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            TimeUtil.nowTs());
+    PatchSetApproval a2 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Verified")),
+            (short) 1,
+            TimeUtil.nowTs());
+
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
+
+    assertDiffs(
+        b1,
+        b2,
+        "PatchSetApproval.Key sets differ:"
+            + " ["
+            + id
+            + "%2C1,100,Code-Review] only in A;"
+            + " ["
+            + id
+            + "%2C1,100,Verified] only in B");
+  }
+
+  @Test
+  public void diffPatchSetApprovals() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    PatchSetApproval a1 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            TimeUtil.nowTs());
+    PatchSetApproval a2 = clone(a1);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
+
+    assertNoDiffs(b1, b2);
+
+    a2.setValue((short) -1);
+    assertDiffs(
+        b1,
+        b2,
+        "value differs for PatchSetApproval.Key "
+            + c.getId()
+            + "%2C1,100,Code-Review: {1} != {-1}");
+  }
+
+  @Test
+  public void diffPatchSetApprovalsMixedSourcesAllowsSlop() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    subWindowResolution();
+    PatchSetApproval a1 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            truncateToSecond(TimeUtil.nowTs()));
+    PatchSetApproval a2 = clone(a1);
+    a2.setGranted(TimeUtil.nowTs());
+
+    // Both are ReviewDb, exact timestamp match is required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "granted differs for PatchSetApproval.Key "
+            + c.getId()
+            + "%2C1,100,Code-Review:"
+            + " {2009-09-30 17:00:07.0} != {2009-09-30 17:00:08.0}");
+
+    // One NoteDb, slop is allowed.
+    b1 =
+        new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+
+    // But not too much slop.
+    superWindowResolution();
+    PatchSetApproval a3 = clone(a1);
+    a3.setGranted(TimeUtil.nowTs());
+    b1 =
+        new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB);
+    ChangeBundle b3 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a3), comments(), reviewers(), REVIEW_DB);
+    String msg =
+        "granted differs for PatchSetApproval.Key "
+            + c.getId()
+            + "%2C1,100,Code-Review in NoteDb vs. ReviewDb:"
+            + " {2009-09-30 17:00:07.0} != {2009-09-30 17:00:15.0}";
+    assertDiffs(b1, b3, msg);
+    assertDiffs(b3, b1, msg);
+  }
+
+  @Test
+  public void diffPatchSetApprovalsAllowsTruncatedTimestampInNoteDb() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    PatchSetApproval a1 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            c.getCreatedOn());
+    PatchSetApproval a2 = clone(a1);
+    a2.setGranted(
+        new Timestamp(
+            LocalDate.of(1900, Month.JANUARY, 1)
+                .atStartOfDay()
+                .atZone(ZoneId.of(TIMEZONE_ID))
+                .toInstant()
+                .toEpochMilli()));
+
+    // Both are ReviewDb, exact match is required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "granted differs for PatchSetApproval.Key "
+            + c.getId()
+            + "%2C1,100,Code-Review:"
+            + " {2009-09-30 17:00:00.0} != {1900-01-01 00:00:00.0}");
+
+    // Truncating NoteDb timestamp is allowed.
+    b1 =
+        new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffPatchSetApprovalsIgnoresPostSubmitBitOnZeroVote() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    c.setStatus(Change.Status.MERGED);
+    PatchSetApproval a1 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+            (short) 0,
+            TimeUtil.nowTs());
+    a1.setPostSubmit(false);
+    PatchSetApproval a2 = clone(a1);
+    a2.setPostSubmit(true);
+
+    // Both are ReviewDb, exact match is required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "postSubmit differs for PatchSetApproval.Key "
+            + c.getId()
+            + "%2C1,100,Code-Review:"
+            + " {false} != {true}");
+
+    // One NoteDb, postSubmit is ignored.
+    b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(c, messages(), latest(c), approvals(a2), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+
+    // postSubmit is not ignored if vote isn't 0.
+    a1.setValue((short) 1);
+    a2.setValue((short) 1);
+    assertDiffs(
+        b1,
+        b2,
+        "postSubmit differs for PatchSetApproval.Key "
+            + c.getId()
+            + "%2C1,100,Code-Review:"
+            + " {false} != {true}");
+    assertDiffs(
+        b2,
+        b1,
+        "postSubmit differs for PatchSetApproval.Key "
+            + c.getId()
+            + "%2C1,100,Code-Review:"
+            + " {true} != {false}");
+  }
+
+  @Test
+  public void diffReviewers() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    Timestamp now = TimeUtil.nowTs();
+    ReviewerSet r1 = reviewers(REVIEWER, new Account.Id(1), now);
+    ReviewerSet r2 = reviewers(REVIEWER, new Account.Id(2), now);
+
+    ChangeBundle b1 =
+        new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r1, REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r2, REVIEW_DB);
+    assertNoDiffs(b1, b1);
+    assertNoDiffs(b2, b2);
+    assertDiffs(b1, b2, "reviewer sets differ: [1] only in A; [2] only in B");
+  }
+
+  @Test
+  public void diffReviewersIgnoresStateAndTimestamp() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    ReviewerSet r1 = reviewers(REVIEWER, new Account.Id(1), TimeUtil.nowTs());
+    ReviewerSet r2 = reviewers(CC, new Account.Id(1), TimeUtil.nowTs());
+
+    ChangeBundle b1 =
+        new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r1, REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r2, REVIEW_DB);
+    assertNoDiffs(b1, b1);
+    assertNoDiffs(b2, b2);
+  }
+
+  @Test
+  public void diffPatchLineCommentKeySets() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    int id = c.getId().get();
+    PatchLineComment c1 =
+        new PatchLineComment(
+            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename1"), "uuid1"),
+            5,
+            accountId,
+            null,
+            TimeUtil.nowTs());
+    PatchLineComment c2 =
+        new PatchLineComment(
+            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename2"), "uuid2"),
+            5,
+            accountId,
+            null,
+            TimeUtil.nowTs());
+
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
+
+    assertDiffs(
+        b1,
+        b2,
+        "PatchLineComment.Key sets differ:"
+            + " ["
+            + id
+            + ",1,filename1,uuid1] only in A;"
+            + " ["
+            + id
+            + ",1,filename2,uuid2] only in B");
+  }
+
+  @Test
+  public void diffPatchLineComments() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    PatchLineComment c1 =
+        new PatchLineComment(
+            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename"), "uuid"),
+            5,
+            accountId,
+            null,
+            TimeUtil.nowTs());
+    PatchLineComment c2 = clone(c1);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
+
+    assertNoDiffs(b1, b2);
+
+    c2.setStatus(PatchLineComment.Status.PUBLISHED);
+    assertDiffs(
+        b1,
+        b2,
+        "status differs for PatchLineComment.Key " + c.getId() + ",1,filename,uuid: {d} != {P}");
+  }
+
+  @Test
+  public void diffPatchLineCommentsMixedSourcesAllowsSlop() throws Exception {
+    subWindowResolution();
+    Change c = TestChanges.newChange(project, accountId);
+    PatchLineComment c1 =
+        new PatchLineComment(
+            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename"), "uuid"),
+            5,
+            accountId,
+            null,
+            truncateToSecond(TimeUtil.nowTs()));
+    PatchLineComment c2 = clone(c1);
+    c2.setWrittenOn(TimeUtil.nowTs());
+
+    // Both are ReviewDb, exact timestamp match is required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "writtenOn differs for PatchLineComment.Key "
+            + c.getId()
+            + ",1,filename,uuid:"
+            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
+
+    // One NoteDb, slop is allowed.
+    b1 =
+        new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+
+    // But not too much slop.
+    superWindowResolution();
+    PatchLineComment c3 = clone(c1);
+    c3.setWrittenOn(TimeUtil.nowTs());
+    b1 =
+        new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1), reviewers(), NOTE_DB);
+    ChangeBundle b3 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c3), reviewers(), REVIEW_DB);
+    String msg =
+        "writtenOn differs for PatchLineComment.Key "
+            + c.getId()
+            + ",1,filename,uuid in NoteDb vs. ReviewDb:"
+            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:10.0}";
+    assertDiffs(b1, b3, msg);
+    assertDiffs(b3, b1, msg);
+  }
+
+  @Test
+  public void diffPatchLineCommentsIgnoresCommentsOnInvalidPatchSet() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    PatchLineComment c1 =
+        new PatchLineComment(
+            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename1"), "uuid1"),
+            5,
+            accountId,
+            null,
+            TimeUtil.nowTs());
+    PatchLineComment c2 =
+        new PatchLineComment(
+            new PatchLineComment.Key(
+                new Patch.Key(new PatchSet.Id(c.getId(), 0), "filename2"), "uuid2"),
+            5,
+            accountId,
+            null,
+            TimeUtil.nowTs());
+
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c1, c2), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+  }
+
+  private static void assertNoDiffs(ChangeBundle a, ChangeBundle b) {
+    assertThat(a.differencesFrom(b)).isEmpty();
+    assertThat(b.differencesFrom(a)).isEmpty();
+  }
+
+  private static void assertDiffs(ChangeBundle a, ChangeBundle b, String first, String... rest) {
+    List<String> actual = a.differencesFrom(b);
+    if (actual.size() == 1 && rest.length == 0) {
+      // This error message is much easier to read.
+      assertThat(actual.get(0)).isEqualTo(first);
+    } else {
+      List<String> expected = new ArrayList<>(1 + rest.length);
+      expected.add(first);
+      Collections.addAll(expected, rest);
+      assertThat(actual).containsExactlyElementsIn(expected).inOrder();
+    }
+    assertThat(a).isNotEqualTo(b);
+  }
+
+  private static List<ChangeMessage> messages(ChangeMessage... ents) {
+    return Arrays.asList(ents);
+  }
+
+  private static List<PatchSet> patchSets(PatchSet... ents) {
+    return Arrays.asList(ents);
+  }
+
+  private static List<PatchSet> latest(Change c) {
+    PatchSet ps = new PatchSet(c.currentPatchSetId());
+    ps.setCreatedOn(c.getLastUpdatedOn());
+    return ImmutableList.of(ps);
+  }
+
+  private static List<PatchSetApproval> approvals(PatchSetApproval... ents) {
+    return Arrays.asList(ents);
+  }
+
+  private static ReviewerSet reviewers(Object... ents) {
+    checkArgument(ents.length % 3 == 0);
+    Table<ReviewerStateInternal, Account.Id, Timestamp> t = HashBasedTable.create();
+    for (int i = 0; i < ents.length; i += 3) {
+      t.put((ReviewerStateInternal) ents[i], (Account.Id) ents[i + 1], (Timestamp) ents[i + 2]);
+    }
+    return ReviewerSet.fromTable(t);
+  }
+
+  private static List<PatchLineComment> comments(PatchLineComment... ents) {
+    return Arrays.asList(ents);
+  }
+
+  private static Change clone(Change ent) {
+    return clone(CHANGE_CODEC, ent);
+  }
+
+  private static ChangeMessage clone(ChangeMessage ent) {
+    return clone(CHANGE_MESSAGE_CODEC, ent);
+  }
+
+  private static PatchSet clone(PatchSet ent) {
+    return clone(PATCH_SET_CODEC, ent);
+  }
+
+  private static PatchSetApproval clone(PatchSetApproval ent) {
+    return clone(PATCH_SET_APPROVAL_CODEC, ent);
+  }
+
+  private static PatchLineComment clone(PatchLineComment ent) {
+    return clone(PATCH_LINE_COMMENT_CODEC, ent);
+  }
+
+  private static <T> T clone(ProtobufCodec<T> codec, T obj) {
+    return codec.decode(codec.encodeToByteArray(obj));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
new file mode 100644
index 0000000..9b7aad2
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -0,0 +1,561 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ChangeNotesParserTest extends AbstractChangeNotesTest {
+  private TestRepository<InMemoryRepository> testRepo;
+  private ChangeNotesRevWalk walk;
+
+  @Before
+  public void setUpTestRepo() throws Exception {
+    testRepo = new TestRepository<>(repo);
+    walk = ChangeNotesCommit.newRevWalk(repo);
+  }
+
+  @After
+  public void tearDownTestRepo() throws Exception {
+    walk.close();
+  }
+
+  @Test
+  public void parseAuthor() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n");
+    assertParseFails(
+        writeCommit(
+            "Update change\n\nPatch-set: 1\n",
+            new PersonIdent(
+                "Change Owner",
+                "owner@example.com",
+                serverIdent.getWhen(),
+                serverIdent.getTimeZone())));
+    assertParseFails(
+        writeCommit(
+            "Update change\n\nPatch-set: 1\n",
+            new PersonIdent(
+                "Change Owner", "x@gerrit", serverIdent.getWhen(), serverIdent.getTimeZone())));
+    assertParseFails(
+        writeCommit(
+            "Update change\n\nPatch-set: 1\n",
+            new PersonIdent(
+                "Change\n\u1234<Owner>",
+                "\n\nx<@>\u0002gerrit",
+                serverIdent.getWhen(),
+                serverIdent.getTimeZone())));
+  }
+
+  @Test
+  public void parseStatus() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Status: NEW\n"
+            + "Subject: This is a test change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Status: new\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nStatus: OOPS\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nStatus: NEW\nStatus: NEW\n");
+  }
+
+  @Test
+  public void parsePatchSetId() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nPatch-set: 1\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\nPatch-set: x\n");
+  }
+
+  @Test
+  public void parseApproval() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Label: Label1=+1\n"
+            + "Label: Label2=1\n"
+            + "Label: Label3=0\n"
+            + "Label: Label4=-1\n"
+            + "Subject: This is a test change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Label: -Label1\n"
+            + "Label: -Label1 Other Account <2@gerrit>\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1=X\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1 = 1\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: X+Y\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1 Other Account <2@gerrit>\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: -Label!1\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: -Label!1 Other Account <2@gerrit>\n");
+  }
+
+  @Test
+  public void parseSubmitRecords() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n"
+            + "Submitted-with: NOT_READY\n"
+            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+            + "Submitted-with: NEED: Code-Review\n"
+            + "Submitted-with: NOT_READY\n"
+            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+            + "Submitted-with: NEED: Alternative-Code-Review\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nSubmitted-with: OOPS\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nSubmitted-with: NEED: X+Y\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Submitted-with: OK: X+Y: Change Owner <1@gerrit>\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Submitted-with: OK: Code-Review: 1@gerrit\n");
+  }
+
+  @Test
+  public void parseSubmissionId() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n"
+            + "Submission-id: 1-1453387607626-96fabc25");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Submission-id: 1-1453387607626-96fabc25\n"
+            + "Submission-id: 1-1453387901516-5d1e2450");
+  }
+
+  @Test
+  public void parseReviewer() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Reviewer: Change Owner <1@gerrit>\n"
+            + "CC: Other Account <2@gerrit>\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nReviewer: 1@gerrit\n");
+  }
+
+  @Test
+  public void parseTopic() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Topic: Some Topic\n"
+            + "Subject: This is a test change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Topic:\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nTopic: Some Topic\nTopic: Other Topic");
+  }
+
+  @Test
+  public void parseBranch() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Branch: refs/heads/stable");
+  }
+
+  @Test
+  public void parseChangeId() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Change-id: I159532ef4844d7c18f7f3fd37a0b275590d41b1b");
+  }
+
+  @Test
+  public void parseSubject() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Some subject of a change\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Subject: Some subject of a change\n"
+            + "Subject: Some other subject\n");
+  }
+
+  @Test
+  public void parseCommit() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 2\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Some subject of a change\n"
+            + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 2\n"
+            + "Branch: refs/heads/master\n"
+            + "Subject: Some subject of a change\n"
+            + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+            + "Commit: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    assertParseFails(
+        "Update patch set 1\n"
+            + "Uploaded patch set 1.\n"
+            + "Patch-set: 2\n"
+            + "Branch: refs/heads/master\n"
+            + "Subject: Some subject of a change\n"
+            + "Commit: beef");
+  }
+
+  @Test
+  public void parsePatchSetState() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1 (PUBLISHED)\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Some subject of a change\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1 (DRAFT)\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Some subject of a change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1 (DELETED)\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Some subject of a change\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1 (NOT A STATUS)\n"
+            + "Branch: refs/heads/master\n"
+            + "Subject: Some subject of a change\n");
+  }
+
+  @Test
+  public void parsePatchSetGroups() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 2\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+            + "Subject: Change subject\n"
+            + "Groups: a,b,c\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 2\n"
+            + "Branch: refs/heads/master\n"
+            + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+            + "Subject: Change subject\n"
+            + "Groups: a,b,c\n"
+            + "Groups: d,e,f\n");
+  }
+
+  @Test
+  public void parseServerIdent() throws Exception {
+    String msg =
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Change subject\n";
+    assertParseSucceeds(msg);
+    assertParseSucceeds(writeCommit(msg, serverIdent));
+
+    msg =
+        "Update change\n"
+            + "\n"
+            + "With a message."
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Change subject\n";
+    assertParseSucceeds(msg);
+    assertParseSucceeds(writeCommit(msg, serverIdent));
+
+    msg =
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Change subject\n"
+            + "Label: Label1=+1\n";
+    assertParseSucceeds(msg);
+    assertParseFails(writeCommit(msg, serverIdent));
+  }
+
+  @Test
+  public void parseTag() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Change subject\n"
+            + "Tag:\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Change subject\n"
+            + "Tag: jenkins\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Change subject\n"
+            + "Tag: ci\n"
+            + "Tag: jenkins\n");
+  }
+
+  @Test
+  public void parseWorkInProgress() throws Exception {
+    // Change created in WIP remains in WIP.
+    RevCommit commit = writeCommit("Update WIP change\n" + "\n" + "Patch-set: 1\n", true);
+    ChangeNotesState state = newParser(commit).parseAll();
+    assertThat(state.hasReviewStarted()).isFalse();
+
+    // Moving change out of WIP starts review.
+    commit =
+        writeCommit("New ready change\n" + "\n" + "Patch-set: 1\n" + "Work-in-progress: false\n");
+    state = newParser(commit).parseAll();
+    assertThat(state.hasReviewStarted()).isTrue();
+
+    // Change created not in WIP has always been in review started state.
+    state = assertParseSucceeds("New change that doesn't declare WIP\n" + "\n" + "Patch-set: 1\n");
+    assertThat(state.hasReviewStarted()).isTrue();
+  }
+
+  @Test
+  public void pendingReviewers() throws Exception {
+    // Change created in WIP.
+    RevCommit commit = writeCommit("Update WIP change\n" + "\n" + "Patch-set: 1\n", true);
+    ChangeNotesState state = newParser(commit).parseAll();
+    assertThat(state.pendingReviewers().all()).isEmpty();
+    assertThat(state.pendingReviewersByEmail().all()).isEmpty();
+
+    // Reviewers added while in WIP.
+    commit =
+        writeCommit(
+            "Add reviewers\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Reviewer: Change Owner "
+                + "<1@gerrit>\n",
+            true);
+    state = newParser(commit).parseAll();
+    assertThat(state.pendingReviewers().byState(ReviewerStateInternal.REVIEWER)).isNotEmpty();
+  }
+
+  @Test
+  public void caseInsensitiveFooters() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "BRaNch: refs/heads/master\n"
+            + "Change-ID: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "patcH-set: 1\n"
+            + "subject: This is a test change\n");
+  }
+
+  @Test
+  public void currentPatchSet() throws Exception {
+    assertParseSucceeds("Update change\n\nPatch-set: 1\nCurrent: true");
+    assertParseSucceeds("Update change\n\nPatch-set: 1\nCurrent: tRUe");
+    assertParseFails("Update change\n\nPatch-set: 1\nCurrent: false");
+    assertParseFails("Update change\n\nPatch-set: 1\nCurrent: blah");
+  }
+
+  private RevCommit writeCommit(String body) throws Exception {
+    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
+    return writeCommit(
+        body, noteUtil.newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent), false);
+  }
+
+  private RevCommit writeCommit(String body, PersonIdent author) throws Exception {
+    return writeCommit(body, author, false);
+  }
+
+  private RevCommit writeCommit(String body, boolean initWorkInProgress) throws Exception {
+    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
+    return writeCommit(
+        body,
+        noteUtil.newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent),
+        initWorkInProgress);
+  }
+
+  private RevCommit writeCommit(String body, PersonIdent author, boolean initWorkInProgress)
+      throws Exception {
+    Change change = newChange(initWorkInProgress);
+    ChangeNotes notes = newNotes(change).load();
+    try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
+      CommitBuilder cb = new CommitBuilder();
+      cb.setParentId(notes.getRevision());
+      cb.setAuthor(author);
+      cb.setCommitter(new PersonIdent(serverIdent, author.getWhen()));
+      cb.setTreeId(testRepo.tree());
+      cb.setMessage(body);
+      ObjectId id = ins.insert(cb);
+      ins.flush();
+      RevCommit commit = walk.parseCommit(id);
+      walk.parseBody(commit);
+      return commit;
+    }
+  }
+
+  private ChangeNotesState assertParseSucceeds(String body) throws Exception {
+    return assertParseSucceeds(writeCommit(body));
+  }
+
+  private ChangeNotesState assertParseSucceeds(RevCommit commit) throws Exception {
+    return newParser(commit).parseAll();
+  }
+
+  private void assertParseFails(String body) throws Exception {
+    assertParseFails(writeCommit(body));
+  }
+
+  private void assertParseFails(RevCommit commit) throws Exception {
+    try {
+      newParser(commit).parseAll();
+      fail("Expected parse to fail:\n" + commit.getFullMessage());
+    } catch (ConfigInvalidException e) {
+      // Expected
+    }
+  }
+
+  private ChangeNotesParser newParser(ObjectId tip) throws Exception {
+    walk.reset();
+    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
+    return new ChangeNotesParser(newChange().getId(), tip, walk, noteUtil, args.metrics);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
new file mode 100644
index 0000000..e5a34aa
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -0,0 +1,3579 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REMOVED;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.CommentRange;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gerrit.testing.TestChanges;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Test;
+
+public class ChangeNotesTest extends AbstractChangeNotesTest {
+  @Inject private DraftCommentNotes.Factory draftNotesFactory;
+
+  @Inject private ChangeNoteUtil noteUtil;
+
+  @Inject private @GerritServerId String serverId;
+
+  @Test
+  public void tagChangeMessage() throws Exception {
+    String tag = "jenkins";
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("verification from jenkins");
+    update.setTag(tag);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    assertThat(notes.getChangeMessages()).hasSize(1);
+    assertThat(notes.getChangeMessages().get(0).getTag()).isEqualTo(tag);
+  }
+
+  @Test
+  public void patchSetDescription() throws Exception {
+    String description = "descriptive";
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPsDescription(description);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getCurrentPatchSet().getDescription()).isEqualTo(description);
+
+    description = "new, now more descriptive!";
+    update = newUpdate(c, changeOwner);
+    update.setPsDescription(description);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getCurrentPatchSet().getDescription()).isEqualTo(description);
+  }
+
+  @Test
+  public void tagInlineComments() throws Exception {
+    String tag = "jenkins";
+    Change c = newChange();
+    RevCommit commit = tr.commit().message("PS2").create();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putComment(
+        Status.PUBLISHED,
+        newComment(
+            c.currentPatchSetId(),
+            "a.txt",
+            "uuid1",
+            new CommentRange(1, 2, 3, 4),
+            1,
+            changeOwner,
+            null,
+            TimeUtil.nowTs(),
+            "Comment",
+            (short) 1,
+            commit.name(),
+            false));
+    update.setTag(tag);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    ImmutableListMultimap<RevId, Comment> comments = notes.getComments();
+    assertThat(comments).hasSize(1);
+    assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(tag);
+  }
+
+  @Test
+  public void tagApprovals() throws Exception {
+    String tag1 = "jenkins";
+    String tag2 = "ip";
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Verified", (short) -1);
+    update.setTag(tag1);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval("Verified", (short) 1);
+    update.setTag(tag2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
+    assertThat(approvals).hasSize(1);
+    assertThat(approvals.entries().asList().get(0).getValue().getTag()).isEqualTo(tag2);
+  }
+
+  @Test
+  public void multipleTags() throws Exception {
+    String ipTag = "ip";
+    String coverageTag = "coverage";
+    String integrationTag = "integration";
+    Change c = newChange();
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Verified", (short) -1);
+    update.setChangeMessage("integration verification");
+    update.setTag(integrationTag);
+    update.commit();
+
+    RevCommit commit = tr.commit().message("PS2").create();
+    update = newUpdate(c, changeOwner);
+    update.putComment(
+        Status.PUBLISHED,
+        newComment(
+            c.currentPatchSetId(),
+            "a.txt",
+            "uuid1",
+            new CommentRange(1, 2, 3, 4),
+            1,
+            changeOwner,
+            null,
+            TimeUtil.nowTs(),
+            "Comment",
+            (short) 1,
+            commit.name(),
+            false));
+    update.setChangeMessage("coverage verification");
+    update.setTag(coverageTag);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setChangeMessage("ip clear");
+    update.setTag(ipTag);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
+    assertThat(approvals).hasSize(1);
+    PatchSetApproval approval = approvals.entries().asList().get(0).getValue();
+    assertThat(approval.getTag()).isEqualTo(integrationTag);
+    assertThat(approval.getValue()).isEqualTo(-1);
+
+    ImmutableListMultimap<RevId, Comment> comments = notes.getComments();
+    assertThat(comments).hasSize(1);
+    assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(coverageTag);
+
+    ImmutableList<ChangeMessage> messages = notes.getChangeMessages();
+    assertThat(messages).hasSize(3);
+    assertThat(messages.get(0).getTag()).isEqualTo(integrationTag);
+    assertThat(messages.get(1).getTag()).isEqualTo(coverageTag);
+    assertThat(messages.get(2).getTag()).isEqualTo(ipTag);
+  }
+
+  @Test
+  public void approvalsOnePatchSet() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Verified", (short) 1);
+    update.putApproval("Code-Review", (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(psas).hasSize(2);
+
+    assertThat(psas.get(0).getPatchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(0).getAccountId().get()).isEqualTo(1);
+    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).getValue()).isEqualTo((short) -1);
+    assertThat(psas.get(0).getGranted()).isEqualTo(truncate(after(c, 2000)));
+
+    assertThat(psas.get(1).getPatchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(1).getAccountId().get()).isEqualTo(1);
+    assertThat(psas.get(1).getLabel()).isEqualTo("Verified");
+    assertThat(psas.get(1).getValue()).isEqualTo((short) 1);
+    assertThat(psas.get(1).getGranted()).isEqualTo(psas.get(0).getGranted());
+  }
+
+  @Test
+  public void approvalsMultiplePatchSets() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) -1);
+    update.commit();
+    PatchSet.Id ps1 = c.currentPatchSetId();
+
+    incrementPatchSet(c);
+    update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.commit();
+    PatchSet.Id ps2 = c.currentPatchSetId();
+
+    ChangeNotes notes = newNotes(c);
+    ListMultimap<PatchSet.Id, PatchSetApproval> psas = notes.getApprovals();
+    assertThat(psas).hasSize(2);
+
+    PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
+    assertThat(psa1.getPatchSetId()).isEqualTo(ps1);
+    assertThat(psa1.getAccountId().get()).isEqualTo(1);
+    assertThat(psa1.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa1.getValue()).isEqualTo((short) -1);
+    assertThat(psa1.getGranted()).isEqualTo(truncate(after(c, 2000)));
+
+    PatchSetApproval psa2 = Iterables.getOnlyElement(psas.get(ps2));
+    assertThat(psa2.getPatchSetId()).isEqualTo(ps2);
+    assertThat(psa2.getAccountId().get()).isEqualTo(1);
+    assertThat(psa2.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa2.getValue()).isEqualTo((short) +1);
+    assertThat(psa2.getGranted()).isEqualTo(truncate(after(c, 4000)));
+  }
+
+  @Test
+  public void approvalsMultipleApprovals() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval psa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getValue()).isEqualTo((short) -1);
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.commit();
+
+    notes = newNotes(c);
+    psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getValue()).isEqualTo((short) 1);
+  }
+
+  @Test
+  public void approvalsMultipleUsers() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) -1);
+    update.commit();
+
+    update = newUpdate(c, otherUser);
+    update.putApproval("Code-Review", (short) 1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(psas).hasSize(2);
+
+    assertThat(psas.get(0).getPatchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(0).getAccountId().get()).isEqualTo(1);
+    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).getValue()).isEqualTo((short) -1);
+    assertThat(psas.get(0).getGranted()).isEqualTo(truncate(after(c, 2000)));
+
+    assertThat(psas.get(1).getPatchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(psas.get(1).getAccountId().get()).isEqualTo(2);
+    assertThat(psas.get(1).getLabel()).isEqualTo("Code-Review");
+    assertThat(psas.get(1).getValue()).isEqualTo((short) 1);
+    assertThat(psas.get(1).getGranted()).isEqualTo(truncate(after(c, 3000)));
+  }
+
+  @Test
+  public void approvalsTombstone() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Not-For-Long", (short) 1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval psa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(psa.getAccountId().get()).isEqualTo(1);
+    assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
+    assertThat(psa.getValue()).isEqualTo((short) 1);
+
+    update = newUpdate(c, changeOwner);
+    update.removeApproval("Not-For-Long");
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovals())
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                psa.getPatchSetId(),
+                new PatchSetApproval(psa.getKey(), (short) 0, update.getWhen())));
+  }
+
+  @Test
+  public void removeOtherUsersApprovals() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.putApproval("Not-For-Long", (short) 1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval psa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(psa.getAccountId()).isEqualTo(otherUserId);
+    assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
+    assertThat(psa.getValue()).isEqualTo((short) 1);
+
+    update = newUpdate(c, changeOwner);
+    update.removeApprovalFor(otherUserId, "Not-For-Long");
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovals())
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                psa.getPatchSetId(),
+                new PatchSetApproval(psa.getKey(), (short) 0, update.getWhen())));
+
+    // Add back approval on same label.
+    update = newUpdate(c, otherUser);
+    update.putApproval("Not-For-Long", (short) 2);
+    update.commit();
+
+    notes = newNotes(c);
+    psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(psa.getAccountId()).isEqualTo(otherUserId);
+    assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
+    assertThat(psa.getValue()).isEqualTo((short) 2);
+  }
+
+  @Test
+  public void putOtherUsersApprovals() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.putApprovalFor(otherUser.getAccountId(), "Code-Review", (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    List<PatchSetApproval> approvals =
+        ReviewDbUtil.intKeyOrdering()
+            .onResultOf(PatchSetApproval::getAccountId)
+            .sortedCopy(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(approvals).hasSize(2);
+
+    assertThat(approvals.get(0).getAccountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(approvals.get(0).getLabel()).isEqualTo("Code-Review");
+    assertThat(approvals.get(0).getValue()).isEqualTo((short) 1);
+
+    assertThat(approvals.get(1).getAccountId()).isEqualTo(otherUser.getAccountId());
+    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).getValue()).isEqualTo((short) -1);
+  }
+
+  @Test
+  public void approvalsPostSubmit() throws Exception {
+    Change c = newChange();
+    RequestId submissionId = RequestId.forChange(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.putApproval("Verified", (short) 1);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Code-Review", "NEED", null))));
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
+    assertThat(approvals).hasSize(2);
+    assertThat(approvals.get(0).getLabel()).isEqualTo("Verified");
+    assertThat(approvals.get(0).getValue()).isEqualTo((short) 1);
+    assertThat(approvals.get(0).isPostSubmit()).isFalse();
+    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).getValue()).isEqualTo((short) 2);
+    assertThat(approvals.get(1).isPostSubmit()).isTrue();
+  }
+
+  @Test
+  public void approvalsDuringSubmit() throws Exception {
+    Change c = newChange();
+    RequestId submissionId = RequestId.forChange(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.putApproval("Verified", (short) 1);
+    update.commit();
+
+    Account.Id ownerId = changeOwner.getAccountId();
+    Account.Id otherId = otherUser.getAccountId();
+    update = newUpdate(c, otherUser);
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", ownerId),
+                submitLabel("Code-Review", "NEED", null))));
+    update.putApproval("Other-Label", (short) 1);
+    update.putApprovalFor(ownerId, "Code-Review", (short) 2);
+    update.commit();
+
+    update = newUpdate(c, otherUser);
+    update.putApproval("Other-Label", (short) 2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
+    assertThat(approvals).hasSize(3);
+    assertThat(approvals.get(0).getAccountId()).isEqualTo(ownerId);
+    assertThat(approvals.get(0).getLabel()).isEqualTo("Verified");
+    assertThat(approvals.get(0).getValue()).isEqualTo(1);
+    assertThat(approvals.get(0).isPostSubmit()).isFalse();
+    assertThat(approvals.get(1).getAccountId()).isEqualTo(ownerId);
+    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).getValue()).isEqualTo(2);
+    assertThat(approvals.get(1).isPostSubmit()).isFalse(); // During submit.
+    assertThat(approvals.get(2).getAccountId()).isEqualTo(otherId);
+    assertThat(approvals.get(2).getLabel()).isEqualTo("Other-Label");
+    assertThat(approvals.get(2).getValue()).isEqualTo(2);
+    assertThat(approvals.get(2).isPostSubmit()).isTrue();
+  }
+
+  @Test
+  public void multipleReviewers() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(otherUser.getAccount().getId(), REVIEWER);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    assertThat(notes.getReviewers())
+        .isEqualTo(
+            ReviewerSet.fromTable(
+                ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
+                    .put(REVIEWER, new Account.Id(1), ts)
+                    .put(REVIEWER, new Account.Id(2), ts)
+                    .build()));
+  }
+
+  @Test
+  public void reviewerTypes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(otherUser.getAccount().getId(), CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    assertThat(notes.getReviewers())
+        .isEqualTo(
+            ReviewerSet.fromTable(
+                ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
+                    .put(REVIEWER, new Account.Id(1), ts)
+                    .put(CC, new Account.Id(2), ts)
+                    .build()));
+  }
+
+  @Test
+  public void oneReviewerMultipleTypes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(otherUser.getAccount().getId(), REVIEWER);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    assertThat(notes.getReviewers())
+        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(REVIEWER, new Account.Id(2), ts)));
+
+    update = newUpdate(c, otherUser);
+    update.putReviewer(otherUser.getAccount().getId(), CC);
+    update.commit();
+
+    notes = newNotes(c);
+    ts = new Timestamp(update.getWhen().getTime());
+    assertThat(notes.getReviewers())
+        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(CC, new Account.Id(2), ts)));
+  }
+
+  @Test
+  public void removeReviewer() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(otherUser.getAccount().getId(), REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.commit();
+
+    update = newUpdate(c, otherUser);
+    update.putApproval("Code-Review", (short) 1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(psas).hasSize(2);
+    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(psas.get(1).getAccountId()).isEqualTo(otherUser.getAccount().getId());
+
+    update = newUpdate(c, changeOwner);
+    update.removeReviewer(otherUser.getAccount().getId());
+    update.commit();
+
+    notes = newNotes(c);
+    psas = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(psas).hasSize(1);
+    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
+  }
+
+  @Test
+  public void submitRecords() throws Exception {
+    Change c = newChange();
+    RequestId submissionId = RequestId.forChange(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setSubjectForCommit("Submit patch set 1");
+
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Code-Review", "NEED", null)),
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Alternative-Code-Review", "NEED", null))));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    List<SubmitRecord> recs = notes.getSubmitRecords();
+    assertThat(recs).hasSize(2);
+    assertThat(recs.get(0))
+        .isEqualTo(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Code-Review", "NEED", null)));
+    assertThat(recs.get(1))
+        .isEqualTo(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Alternative-Code-Review", "NEED", null)));
+    assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toStringForStorage());
+  }
+
+  @Test
+  public void latestSubmitRecordsOnly() throws Exception {
+    Change c = newChange();
+    RequestId submissionId = RequestId.forChange(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setSubjectForCommit("Submit patch set 1");
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord("OK", null, submitLabel("Code-Review", "OK", otherUser.getAccountId()))));
+    update.commit();
+
+    incrementPatchSet(c);
+    update = newUpdate(c, changeOwner);
+    update.setSubjectForCommit("Submit patch set 2");
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "OK", null, submitLabel("Code-Review", "OK", changeOwner.getAccountId()))));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getSubmitRecords())
+        .containsExactly(
+            submitRecord("OK", null, submitLabel("Code-Review", "OK", changeOwner.getAccountId())));
+    assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toStringForStorage());
+  }
+
+  @Test
+  public void emptyChangeUpdate() throws Exception {
+    Change c = newChange();
+    Ref initial = repo.exactRef(changeMetaRef(c.getId()));
+    assertThat(initial).isNotNull();
+
+    // Empty update doesn't create a new commit.
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.commit();
+    assertThat(update.getResult()).isNull();
+
+    Ref updated = repo.exactRef(changeMetaRef(c.getId()));
+    assertThat(updated.getObjectId()).isEqualTo(initial.getObjectId());
+  }
+
+  @Test
+  public void assigneeCommit() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setAssignee(otherUserId);
+    ObjectId result = update.commit();
+    assertThat(result).isNotNull();
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(update.getResult());
+      rw.parseBody(commit);
+      String strIdent = otherUser.getName() + " <" + otherUserId + "@" + serverId + ">";
+      assertThat(commit.getFullMessage()).contains("Assignee: " + strIdent);
+    }
+  }
+
+  @Test
+  public void assigneeChangeNotes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setAssignee(otherUserId);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getChange().getAssignee()).isEqualTo(otherUserId);
+
+    update = newUpdate(c, changeOwner);
+    update.setAssignee(changeOwner.getAccountId());
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getChange().getAssignee()).isEqualTo(changeOwner.getAccountId());
+  }
+
+  @Test
+  public void pastAssigneesChangeNotes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setAssignee(otherUserId);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    update = newUpdate(c, changeOwner);
+    update.setAssignee(changeOwner.getAccountId());
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setAssignee(otherUserId);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.removeAssignee();
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getPastAssignees()).hasSize(2);
+  }
+
+  @Test
+  public void hashtagCommit() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    LinkedHashSet<String> hashtags = new LinkedHashSet<>();
+    hashtags.add("tag1");
+    hashtags.add("tag2");
+    update.setHashtags(hashtags);
+    update.commit();
+    try (RevWalk walk = new RevWalk(repo)) {
+      RevCommit commit = walk.parseCommit(update.getResult());
+      walk.parseBody(commit);
+      assertThat(commit.getFullMessage()).contains("Hashtags: tag1,tag2\n");
+    }
+  }
+
+  @Test
+  public void hashtagChangeNotes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    LinkedHashSet<String> hashtags = new LinkedHashSet<>();
+    hashtags.add("tag1");
+    hashtags.add("tag2");
+    update.setHashtags(hashtags);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getHashtags()).isEqualTo(hashtags);
+  }
+
+  @Test
+  public void topicChangeNotes() throws Exception {
+    Change c = newChange();
+
+    // initially topic is not set
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isNull();
+
+    // set topic
+    String topic = "myTopic";
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setTopic(topic);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isEqualTo(topic);
+
+    // clear topic by setting empty string
+    update = newUpdate(c, changeOwner);
+    update.setTopic("");
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isNull();
+
+    // set other topic
+    topic = "otherTopic";
+    update = newUpdate(c, changeOwner);
+    update.setTopic(topic);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isEqualTo(topic);
+
+    // clear topic by setting null
+    update = newUpdate(c, changeOwner);
+    update.setTopic(null);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isNull();
+  }
+
+  @Test
+  public void changeIdChangeNotes() throws Exception {
+    Change c = newChange();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getChange().getKey()).isEqualTo(c.getKey());
+
+    // An update doesn't affect the Change-Id
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setTopic("topic"); // Change something to get a new commit.
+    update.commit();
+    assertThat(notes.getChange().getKey()).isEqualTo(c.getKey());
+
+    // Trying to set another Change-Id fails
+    String otherChangeId = "I577fb248e474018276351785930358ec0450e9f7";
+    update = newUpdate(c, changeOwner);
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage(
+        "The Change-Id was already set to "
+            + c.getKey()
+            + ", so we cannot set this Change-Id: "
+            + otherChangeId);
+    update.setChangeId(otherChangeId);
+  }
+
+  @Test
+  public void branchChangeNotes() throws Exception {
+    Change c = newChange();
+
+    ChangeNotes notes = newNotes(c);
+    Branch.NameKey expectedBranch = new Branch.NameKey(project, "refs/heads/master");
+    assertThat(notes.getChange().getDest()).isEqualTo(expectedBranch);
+
+    // An update doesn't affect the branch
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setTopic("topic"); // Change something to get a new commit.
+    update.commit();
+    assertThat(newNotes(c).getChange().getDest()).isEqualTo(expectedBranch);
+
+    // Set another branch
+    String otherBranch = "refs/heads/stable";
+    update = newUpdate(c, changeOwner);
+    update.setBranch(otherBranch);
+    update.commit();
+    assertThat(newNotes(c).getChange().getDest())
+        .isEqualTo(new Branch.NameKey(project, otherBranch));
+  }
+
+  @Test
+  public void ownerChangeNotes() throws Exception {
+    Change c = newChange();
+
+    assertThat(newNotes(c).getChange().getOwner()).isEqualTo(changeOwner.getAccountId());
+
+    // An update doesn't affect the owner
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setTopic("topic"); // Change something to get a new commit.
+    update.commit();
+    assertThat(newNotes(c).getChange().getOwner()).isEqualTo(changeOwner.getAccountId());
+  }
+
+  @Test
+  public void createdOnChangeNotes() throws Exception {
+    Change c = newChange();
+
+    Timestamp createdOn = newNotes(c).getChange().getCreatedOn();
+    assertThat(createdOn).isNotNull();
+
+    // An update doesn't affect the createdOn timestamp.
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setTopic("topic"); // Change something to get a new commit.
+    update.commit();
+    assertThat(newNotes(c).getChange().getCreatedOn()).isEqualTo(createdOn);
+  }
+
+  @Test
+  public void lastUpdatedOnChangeNotes() throws Exception {
+    Change c = newChange();
+
+    ChangeNotes notes = newNotes(c);
+    Timestamp ts1 = notes.getChange().getLastUpdatedOn();
+    assertThat(ts1).isEqualTo(notes.getChange().getCreatedOn());
+
+    // Various kinds of updates that update the timestamp.
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setTopic("topic"); // Change something to get a new commit.
+    update.commit();
+    Timestamp ts2 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts2).isGreaterThan(ts1);
+
+    update = newUpdate(c, changeOwner);
+    update.setChangeMessage("Some message");
+    update.commit();
+    Timestamp ts3 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts3).isGreaterThan(ts2);
+
+    update = newUpdate(c, changeOwner);
+    update.setHashtags(ImmutableSet.of("foo"));
+    update.commit();
+    Timestamp ts4 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts4).isGreaterThan(ts3);
+
+    incrementPatchSet(c);
+    Timestamp ts5 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts5).isGreaterThan(ts4);
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.commit();
+    Timestamp ts6 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts6).isGreaterThan(ts5);
+
+    update = newUpdate(c, changeOwner);
+    update.setStatus(Change.Status.ABANDONED);
+    update.commit();
+    Timestamp ts7 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts7).isGreaterThan(ts6);
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewer(otherUser.getAccountId(), ReviewerStateInternal.REVIEWER);
+    update.commit();
+    Timestamp ts8 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts8).isGreaterThan(ts7);
+
+    update = newUpdate(c, changeOwner);
+    update.setGroups(ImmutableList.of("a", "b"));
+    update.commit();
+    Timestamp ts9 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts9).isGreaterThan(ts8);
+
+    // Finish off by merging the change.
+    update = newUpdate(c, changeOwner);
+    update.merge(
+        RequestId.forChange(c),
+        ImmutableList.of(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Alternative-Code-Review", "NEED", null))));
+    update.commit();
+    Timestamp ts10 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts10).isGreaterThan(ts9);
+  }
+
+  @Test
+  public void subjectLeadingWhitespaceChangeNotes() throws Exception {
+    Change c = TestChanges.newChange(project, changeOwner.getAccountId());
+    String trimmedSubj = c.getSubject();
+    c.setCurrentPatchSet(c.currentPatchSetId(), "  " + trimmedSubj, c.getOriginalSubject());
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeId(c.getKey().get());
+    update.setBranch(c.getDest().get());
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getChange().getSubject()).isEqualTo(trimmedSubj);
+
+    String tabSubj = "\t\t" + trimmedSubj;
+
+    c = TestChanges.newChange(project, changeOwner.getAccountId());
+    c.setCurrentPatchSet(c.currentPatchSetId(), tabSubj, c.getOriginalSubject());
+    update = newUpdate(c, changeOwner);
+    update.setChangeId(c.getKey().get());
+    update.setBranch(c.getDest().get());
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getChange().getSubject()).isEqualTo(tabSubj);
+  }
+
+  @Test
+  public void commitChangeNotesUnique() throws Exception {
+    // PatchSetId -> RevId must be a one to one mapping
+    Change c = newChange();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSet ps = notes.getCurrentPatchSet();
+    assertThat(ps).isNotNull();
+
+    // new revId for the same patch set, ps1
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    RevCommit commit = tr.commit().message("PS1 again").create();
+    update.setCommit(rw, commit);
+    update.commit();
+
+    try {
+      notes = newNotes(c);
+      fail("Expected IOException");
+    } catch (OrmException e) {
+      assertCause(
+          e,
+          ConfigInvalidException.class,
+          "Multiple revisions parsed for patch set 1:"
+              + " RevId{"
+              + commit.name()
+              + "} and "
+              + ps.getRevision().get());
+    }
+  }
+
+  @Test
+  public void patchSetChangeNotes() throws Exception {
+    Change c = newChange();
+
+    // ps1 created by newChange()
+    ChangeNotes notes = newNotes(c);
+    PatchSet ps1 = notes.getCurrentPatchSet();
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps1.getId());
+    assertThat(notes.getChange().getSubject()).isEqualTo("Change subject");
+    assertThat(notes.getChange().getOriginalSubject()).isEqualTo("Change subject");
+    assertThat(ps1.getId()).isEqualTo(new PatchSet.Id(c.getId(), 1));
+    assertThat(ps1.getUploader()).isEqualTo(changeOwner.getAccountId());
+
+    // ps2 by other user
+    RevCommit commit = incrementPatchSet(c, otherUser);
+    notes = newNotes(c);
+    PatchSet ps2 = notes.getCurrentPatchSet();
+    assertThat(ps2.getId()).isEqualTo(new PatchSet.Id(c.getId(), 2));
+    assertThat(notes.getChange().getSubject()).isEqualTo("PS2");
+    assertThat(notes.getChange().getOriginalSubject()).isEqualTo("Change subject");
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.getId());
+    assertThat(ps2.getRevision().get()).isNotEqualTo(ps1.getRevision());
+    assertThat(ps2.getRevision().get()).isEqualTo(commit.name());
+    assertThat(ps2.getUploader()).isEqualTo(otherUser.getAccountId());
+    assertThat(ps2.getCreatedOn()).isEqualTo(notes.getChange().getLastUpdatedOn());
+
+    // comment on ps1, current patch set is still ps2
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPatchSetId(ps1.getId());
+    update.setChangeMessage("Comment on old patch set.");
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.getId());
+  }
+
+  @Test
+  public void patchSetStates() throws Exception {
+    Change c = newChange();
+    PatchSet.Id psId1 = c.currentPatchSetId();
+
+    incrementCurrentPatchSetFieldOnly(c);
+    PatchSet.Id psId2 = c.currentPatchSetId();
+    RevCommit commit = tr.commit().message("PS2").create();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setCommit(rw, commit);
+    update.putApproval("Code-Review", (short) 1);
+    update.setChangeMessage("This is a message");
+    update.putComment(
+        Status.PUBLISHED,
+        newComment(
+            c.currentPatchSetId(),
+            "a.txt",
+            "uuid1",
+            new CommentRange(1, 2, 3, 4),
+            1,
+            changeOwner,
+            null,
+            TimeUtil.nowTs(),
+            "Comment",
+            (short) 1,
+            commit.name(),
+            false));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getPatchSets().keySet()).containsExactly(psId1, psId2);
+    assertThat(notes.getApprovals()).isNotEmpty();
+    assertThat(notes.getChangeMessagesByPatchSet()).isNotEmpty();
+    assertThat(notes.getChangeMessages()).isNotEmpty();
+    assertThat(notes.getComments()).isNotEmpty();
+
+    // publish ps2
+    update = newUpdate(c, changeOwner);
+    update.setPatchSetState(PatchSetState.PUBLISHED);
+    update.commit();
+
+    // delete ps2
+    update = newUpdate(c, changeOwner);
+    update.setPatchSetState(PatchSetState.DELETED);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getPatchSets().keySet()).containsExactly(psId1);
+    assertThat(notes.getApprovals()).isEmpty();
+    assertThat(notes.getChangeMessagesByPatchSet()).isEmpty();
+    assertThat(notes.getChangeMessages()).isEmpty();
+    assertThat(notes.getComments()).isEmpty();
+  }
+
+  @Test
+  public void patchSetGroups() throws Exception {
+    Change c = newChange();
+    PatchSet.Id psId1 = c.currentPatchSetId();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getPatchSets().get(psId1).getGroups()).isEmpty();
+
+    // ps1
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setGroups(ImmutableList.of("a", "b"));
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getPatchSets().get(psId1).getGroups()).containsExactly("a", "b").inOrder();
+
+    incrementCurrentPatchSetFieldOnly(c);
+    PatchSet.Id psId2 = c.currentPatchSetId();
+    update = newUpdate(c, changeOwner);
+    update.setCommit(rw, tr.commit().message("PS2").create());
+    update.setGroups(ImmutableList.of("d"));
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getPatchSets().get(psId2).getGroups()).containsExactly("d");
+    assertThat(notes.getPatchSets().get(psId1).getGroups()).containsExactly("a", "b").inOrder();
+  }
+
+  @Test
+  public void pushCertificate() throws Exception {
+    String pushCert =
+        "certificate version 0.1\n"
+            + "pusher This is not a real push cert\n"
+            + "-----BEGIN PGP SIGNATURE-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "Nor is this a real signature.\n"
+            + "-----END PGP SIGNATURE-----\n";
+
+    // ps2 with push cert
+    Change c = newChange();
+    PatchSet.Id psId1 = c.currentPatchSetId();
+    incrementCurrentPatchSetFieldOnly(c);
+    PatchSet.Id psId2 = c.currentPatchSetId();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPatchSetId(psId2);
+    RevCommit commit = tr.commit().message("PS2").create();
+    update.setCommit(rw, commit, pushCert);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    String note = readNote(notes, commit);
+    if (!testJson()) {
+      assertThat(note).isEqualTo(pushCert);
+    }
+    Map<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
+    assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
+    assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
+    assertThat(notes.getComments()).isEmpty();
+
+    // comment on ps2
+    update = newUpdate(c, changeOwner);
+    update.setPatchSetId(psId2);
+    Timestamp ts = TimeUtil.nowTs();
+    update.putComment(
+        Status.PUBLISHED,
+        newComment(
+            psId2,
+            "a.txt",
+            "uuid1",
+            new CommentRange(1, 2, 3, 4),
+            1,
+            changeOwner,
+            null,
+            ts,
+            "Comment",
+            (short) 1,
+            commit.name(),
+            false));
+    update.commit();
+
+    notes = newNotes(c);
+
+    patchSets = notes.getPatchSets();
+    assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
+    assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
+    assertThat(notes.getComments()).isNotEmpty();
+
+    if (!testJson()) {
+      assertThat(readNote(notes, commit))
+          .isEqualTo(
+              pushCert
+                  + "Revision: "
+                  + commit.name()
+                  + "\n"
+                  + "Patch-set: 2\n"
+                  + "File: a.txt\n"
+                  + "\n"
+                  + "1:2-3:4\n"
+                  + ChangeNoteUtil.formatTime(serverIdent, ts)
+                  + "\n"
+                  + "Author: Change Owner <1@gerrit>\n"
+                  + "Unresolved: false\n"
+                  + "UUID: uuid1\n"
+                  + "Bytes: 7\n"
+                  + "Comment\n"
+                  + "\n");
+    }
+  }
+
+  @Test
+  public void emptyExceptSubject() throws Exception {
+    ChangeUpdate update = newUpdate(newChange(), changeOwner);
+    update.setSubjectForCommit("Create change");
+    assertThat(update.commit()).isNotNull();
+  }
+
+  @Test
+  public void multipleUpdatesInManager() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update1 = newUpdate(c, changeOwner);
+    update1.putApproval("Verified", (short) 1);
+
+    ChangeUpdate update2 = newUpdate(c, otherUser);
+    update2.putApproval("Code-Review", (short) 2);
+
+    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
+      updateManager.add(update1);
+      updateManager.add(update2);
+      updateManager.execute();
+    }
+
+    ChangeNotes notes = newNotes(c);
+    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(psas).hasSize(2);
+
+    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(psas.get(0).getLabel()).isEqualTo("Verified");
+    assertThat(psas.get(0).getValue()).isEqualTo((short) 1);
+
+    assertThat(psas.get(1).getAccountId()).isEqualTo(otherUser.getAccount().getId());
+    assertThat(psas.get(1).getLabel()).isEqualTo("Code-Review");
+    assertThat(psas.get(1).getValue()).isEqualTo((short) 2);
+  }
+
+  @Test
+  public void multipleUpdatesIncludingComments() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update1 = newUpdate(c, otherUser);
+    String uuid1 = "uuid1";
+    String message1 = "comment 1";
+    CommentRange range1 = new CommentRange(1, 1, 2, 1);
+    Timestamp time1 = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+    RevCommit tipCommit;
+    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
+      Comment comment1 =
+          newComment(
+              psId,
+              "file1",
+              uuid1,
+              range1,
+              range1.getEndLine(),
+              otherUser,
+              null,
+              time1,
+              message1,
+              (short) 0,
+              "abcd1234abcd1234abcd1234abcd1234abcd1234",
+              false);
+      update1.setPatchSetId(psId);
+      update1.putComment(Status.PUBLISHED, comment1);
+      updateManager.add(update1);
+
+      ChangeUpdate update2 = newUpdate(c, otherUser);
+      update2.putApproval("Code-Review", (short) 2);
+      updateManager.add(update2);
+
+      updateManager.execute();
+    }
+
+    ChangeNotes notes = newNotes(c);
+    ObjectId tip = notes.getRevision();
+    tipCommit = rw.parseCommit(tip);
+
+    RevCommit commitWithApprovals = tipCommit;
+    assertThat(commitWithApprovals).isNotNull();
+    RevCommit commitWithComments = commitWithApprovals.getParent(0);
+    assertThat(commitWithComments).isNotNull();
+
+    try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
+      ChangeNotesParser notesWithComments =
+          new ChangeNotesParser(c.getId(), commitWithComments.copy(), rw, noteUtil, args.metrics);
+      ChangeNotesState state = notesWithComments.parseAll();
+      assertThat(state.approvals()).isEmpty();
+      assertThat(state.publishedComments()).hasSize(1);
+    }
+
+    try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
+      ChangeNotesParser notesWithApprovals =
+          new ChangeNotesParser(c.getId(), commitWithApprovals.copy(), rw, noteUtil, args.metrics);
+      ChangeNotesState state = notesWithApprovals.parseAll();
+      assertThat(state.approvals()).hasSize(1);
+      assertThat(state.publishedComments()).hasSize(1);
+    }
+  }
+
+  @Test
+  public void multipleUpdatesAcrossRefs() throws Exception {
+    Change c1 = newChange();
+    ChangeUpdate update1 = newUpdate(c1, changeOwner);
+    update1.putApproval("Verified", (short) 1);
+
+    Change c2 = newChange();
+    ChangeUpdate update2 = newUpdate(c2, otherUser);
+    update2.putApproval("Code-Review", (short) 2);
+
+    Ref initial1 = repo.exactRef(update1.getRefName());
+    assertThat(initial1).isNotNull();
+    Ref initial2 = repo.exactRef(update2.getRefName());
+    assertThat(initial2).isNotNull();
+
+    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
+      updateManager.add(update1);
+      updateManager.add(update2);
+      updateManager.execute();
+    }
+
+    Ref ref1 = repo.exactRef(update1.getRefName());
+    assertThat(ref1.getObjectId()).isEqualTo(update1.getResult());
+    assertThat(ref1.getObjectId()).isNotEqualTo(initial1.getObjectId());
+    Ref ref2 = repo.exactRef(update2.getRefName());
+    assertThat(ref2.getObjectId()).isEqualTo(update2.getResult());
+    assertThat(ref2.getObjectId()).isNotEqualTo(initial2.getObjectId());
+
+    PatchSetApproval approval1 =
+        newNotes(c1).getApprovals().get(c1.currentPatchSetId()).iterator().next();
+    assertThat(approval1.getLabel()).isEqualTo("Verified");
+
+    PatchSetApproval approval2 =
+        newNotes(c2).getApprovals().get(c2.currentPatchSetId()).iterator().next();
+    assertThat(approval2.getLabel()).isEqualTo("Code-Review");
+  }
+
+  @Test
+  public void changeMessageOnePatchSet() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.setChangeMessage("Just a little code change.\n");
+    update.commit();
+    PatchSet.Id ps1 = c.currentPatchSetId();
+
+    ChangeNotes notes = newNotes(c);
+    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
+    assertThat(changeMessages.keySet()).containsExactly(ps1);
+
+    ChangeMessage cm = Iterables.getOnlyElement(changeMessages.get(ps1));
+    assertThat(cm.getMessage()).isEqualTo("Just a little code change.\n");
+    assertThat(cm.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm.getPatchSetId()).isEqualTo(ps1);
+  }
+
+  @Test
+  public void noChangeMessage() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getChangeMessages()).isEmpty();
+  }
+
+  @Test
+  public void changeMessageWithTrailingDoubleNewline() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("Testing trailing double newline\n\n");
+    update.commit();
+    PatchSet.Id ps1 = c.currentPatchSetId();
+
+    ChangeNotes notes = newNotes(c);
+    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
+    assertThat(changeMessages).hasSize(1);
+
+    ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
+    assertThat(cm1.getMessage()).isEqualTo("Testing trailing double newline\n\n");
+    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+  }
+
+  @Test
+  public void changeMessageWithMultipleParagraphs() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("Testing paragraph 1\n\nTesting paragraph 2\n\nTesting paragraph 3");
+    update.commit();
+    PatchSet.Id ps1 = c.currentPatchSetId();
+
+    ChangeNotes notes = newNotes(c);
+    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
+    assertThat(changeMessages).hasSize(1);
+
+    ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
+    assertThat(cm1.getMessage())
+        .isEqualTo(
+            "Testing paragraph 1\n"
+                + "\n"
+                + "Testing paragraph 2\n"
+                + "\n"
+                + "Testing paragraph 3");
+    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+  }
+
+  @Test
+  public void changeMessagesMultiplePatchSets() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.setChangeMessage("This is the change message for the first PS.");
+    update.commit();
+    PatchSet.Id ps1 = c.currentPatchSetId();
+
+    incrementPatchSet(c);
+    update = newUpdate(c, changeOwner);
+
+    update.setChangeMessage("This is the change message for the second PS.");
+    update.commit();
+    PatchSet.Id ps2 = c.currentPatchSetId();
+
+    ChangeNotes notes = newNotes(c);
+    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
+    assertThat(changeMessages).hasSize(2);
+
+    ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
+    assertThat(cm1.getMessage()).isEqualTo("This is the change message for the first PS.");
+    assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+
+    ChangeMessage cm2 = Iterables.getOnlyElement(changeMessages.get(ps2));
+    assertThat(cm1.getPatchSetId()).isEqualTo(ps1);
+    assertThat(cm2.getMessage()).isEqualTo("This is the change message for the second PS.");
+    assertThat(cm2.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
+  }
+
+  @Test
+  public void changeMessageMultipleInOnePatchSet() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.setChangeMessage("First change message.\n");
+    update.commit();
+
+    PatchSet.Id ps1 = c.currentPatchSetId();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.setChangeMessage("Second change message.\n");
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
+    assertThat(changeMessages.keySet()).hasSize(1);
+
+    List<ChangeMessage> cm = changeMessages.get(ps1);
+    assertThat(cm).hasSize(2);
+    assertThat(cm.get(0).getMessage()).isEqualTo("First change message.\n");
+    assertThat(cm.get(0).getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm.get(0).getPatchSetId()).isEqualTo(ps1);
+    assertThat(cm.get(1).getMessage()).isEqualTo("Second change message.\n");
+    assertThat(cm.get(1).getAuthor()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm.get(1).getPatchSetId()).isEqualTo(ps1);
+  }
+
+  @Test
+  public void patchLineCommentsFileComment() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    PatchSet.Id psId = c.currentPatchSetId();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+
+    Comment comment =
+        newComment(
+            psId,
+            "file1",
+            "uuid",
+            null,
+            0,
+            otherUser,
+            null,
+            TimeUtil.nowTs(),
+            "message",
+            (short) 1,
+            revId.get(),
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+  }
+
+  @Test
+  public void patchLineCommentsZeroColumns() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    PatchSet.Id psId = c.currentPatchSetId();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    CommentRange range = new CommentRange(1, 0, 2, 0);
+
+    Comment comment =
+        newComment(
+            psId,
+            "file1",
+            "uuid",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            TimeUtil.nowTs(),
+            "message",
+            (short) 1,
+            revId.get(),
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+  }
+
+  @Test
+  public void patchLineCommentZeroRange() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    PatchSet.Id psId = c.currentPatchSetId();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    CommentRange range = new CommentRange(0, 0, 0, 0);
+
+    Comment comment =
+        newComment(
+            psId,
+            "file",
+            "uuid",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            TimeUtil.nowTs(),
+            "message",
+            (short) 1,
+            revId.get(),
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+  }
+
+  @Test
+  public void patchLineCommentEmptyFilename() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    PatchSet.Id psId = c.currentPatchSetId();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    CommentRange range = new CommentRange(1, 2, 3, 4);
+
+    Comment comment =
+        newComment(
+            psId,
+            "",
+            "uuid",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            TimeUtil.nowTs(),
+            "message",
+            (short) 1,
+            revId.get(),
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+  }
+
+  @Test
+  public void patchLineCommentNotesFormatSide1() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    String uuid1 = "uuid1";
+    String uuid2 = "uuid2";
+    String uuid3 = "uuid3";
+    String message1 = "comment 1";
+    String message2 = "comment 2";
+    String message3 = "comment 3";
+    CommentRange range1 = new CommentRange(1, 1, 2, 1);
+    Timestamp time1 = TimeUtil.nowTs();
+    Timestamp time2 = TimeUtil.nowTs();
+    Timestamp time3 = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+
+    Comment comment1 =
+        newComment(
+            psId,
+            "file1",
+            uuid1,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            null,
+            time1,
+            message1,
+            (short) 1,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment1);
+    update.commit();
+
+    update = newUpdate(c, otherUser);
+    CommentRange range2 = new CommentRange(2, 1, 3, 1);
+    Comment comment2 =
+        newComment(
+            psId,
+            "file1",
+            uuid2,
+            range2,
+            range2.getEndLine(),
+            otherUser,
+            null,
+            time2,
+            message2,
+            (short) 1,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment2);
+    update.commit();
+
+    update = newUpdate(c, otherUser);
+    CommentRange range3 = new CommentRange(3, 0, 4, 1);
+    Comment comment3 =
+        newComment(
+            psId,
+            "file2",
+            uuid3,
+            range3,
+            range3.getEndLine(),
+            otherUser,
+            null,
+            time3,
+            message3,
+            (short) 1,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment3);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    try (RevWalk walk = new RevWalk(repo)) {
+      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      Note note = Iterables.getOnlyElement(notesInTree);
+
+      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
+      String noteString = new String(bytes, UTF_8);
+
+      if (!testJson()) {
+        assertThat(noteString)
+            .isEqualTo(
+                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                    + "Patch-set: 1\n"
+                    + "File: file1\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time1)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid1\n"
+                    + "Bytes: 9\n"
+                    + "comment 1\n"
+                    + "\n"
+                    + "2:1-3:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time2)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid2\n"
+                    + "Bytes: 9\n"
+                    + "comment 2\n"
+                    + "\n"
+                    + "File: file2\n"
+                    + "\n"
+                    + "3:0-4:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time3)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid3\n"
+                    + "Bytes: 9\n"
+                    + "comment 3\n"
+                    + "\n");
+      }
+    }
+  }
+
+  @Test
+  public void patchLineCommentNotesFormatSide0() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    String uuid1 = "uuid1";
+    String uuid2 = "uuid2";
+    String message1 = "comment 1";
+    String message2 = "comment 2";
+    CommentRange range1 = new CommentRange(1, 1, 2, 1);
+    Timestamp time1 = TimeUtil.nowTs();
+    Timestamp time2 = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+
+    Comment comment1 =
+        newComment(
+            psId,
+            "file1",
+            uuid1,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            null,
+            time1,
+            message1,
+            (short) 0,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment1);
+    update.commit();
+
+    update = newUpdate(c, otherUser);
+    CommentRange range2 = new CommentRange(2, 1, 3, 1);
+    Comment comment2 =
+        newComment(
+            psId,
+            "file1",
+            uuid2,
+            range2,
+            range2.getEndLine(),
+            otherUser,
+            null,
+            time2,
+            message2,
+            (short) 0,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    try (RevWalk walk = new RevWalk(repo)) {
+      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      Note note = Iterables.getOnlyElement(notesInTree);
+
+      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
+      String noteString = new String(bytes, UTF_8);
+
+      if (!testJson()) {
+        assertThat(noteString)
+            .isEqualTo(
+                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                    + "Base-for-patch-set: 1\n"
+                    + "File: file1\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time1)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid1\n"
+                    + "Bytes: 9\n"
+                    + "comment 1\n"
+                    + "\n"
+                    + "2:1-3:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time2)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid2\n"
+                    + "Bytes: 9\n"
+                    + "comment 2\n"
+                    + "\n");
+      }
+    }
+  }
+
+  @Test
+  public void patchLineCommentNotesResolvedChangesValue() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    String uuid1 = "uuid1";
+    String uuid2 = "uuid2";
+    String message1 = "comment 1";
+    String message2 = "comment 2";
+    CommentRange range1 = new CommentRange(1, 1, 2, 1);
+    Timestamp time1 = TimeUtil.nowTs();
+    Timestamp time2 = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+
+    Comment comment1 =
+        newComment(
+            psId,
+            "file1",
+            uuid1,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            null,
+            time1,
+            message1,
+            (short) 0,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment1);
+    update.commit();
+
+    update = newUpdate(c, otherUser);
+    Comment comment2 =
+        newComment(
+            psId,
+            "file1",
+            uuid2,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            uuid1,
+            time2,
+            message2,
+            (short) 0,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            true);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    try (RevWalk walk = new RevWalk(repo)) {
+      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      Note note = Iterables.getOnlyElement(notesInTree);
+
+      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
+      String noteString = new String(bytes, UTF_8);
+
+      if (!testJson()) {
+        assertThat(noteString)
+            .isEqualTo(
+                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                    + "Base-for-patch-set: 1\n"
+                    + "File: file1\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time1)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid1\n"
+                    + "Bytes: 9\n"
+                    + "comment 1\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time2)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Parent: uuid1\n"
+                    + "Unresolved: true\n"
+                    + "UUID: uuid2\n"
+                    + "Bytes: 9\n"
+                    + "comment 2\n"
+                    + "\n");
+      }
+    }
+  }
+
+  @Test
+  public void patchLineCommentNotesFormatMultiplePatchSetsSameRevId() throws Exception {
+    Change c = newChange();
+    PatchSet.Id psId1 = c.currentPatchSetId();
+    incrementPatchSet(c);
+    PatchSet.Id psId2 = c.currentPatchSetId();
+    String uuid1 = "uuid1";
+    String uuid2 = "uuid2";
+    String uuid3 = "uuid3";
+    String message1 = "comment 1";
+    String message2 = "comment 2";
+    String message3 = "comment 3";
+    CommentRange range1 = new CommentRange(1, 1, 2, 1);
+    CommentRange range2 = new CommentRange(2, 1, 3, 1);
+    Timestamp time = TimeUtil.nowTs();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+
+    Comment comment1 =
+        newComment(
+            psId1,
+            "file1",
+            uuid1,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            null,
+            time,
+            message1,
+            (short) 0,
+            revId.get(),
+            false);
+    Comment comment2 =
+        newComment(
+            psId1,
+            "file1",
+            uuid2,
+            range2,
+            range2.getEndLine(),
+            otherUser,
+            null,
+            time,
+            message2,
+            (short) 0,
+            revId.get(),
+            false);
+    Comment comment3 =
+        newComment(
+            psId2,
+            "file1",
+            uuid3,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            null,
+            time,
+            message3,
+            (short) 0,
+            revId.get(),
+            false);
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(psId2);
+    update.putComment(Status.PUBLISHED, comment3);
+    update.putComment(Status.PUBLISHED, comment2);
+    update.putComment(Status.PUBLISHED, comment1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    try (RevWalk walk = new RevWalk(repo)) {
+      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      Note note = Iterables.getOnlyElement(notesInTree);
+
+      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
+      String noteString = new String(bytes, UTF_8);
+      String timeStr = ChangeNoteUtil.formatTime(serverIdent, time);
+
+      if (!testJson()) {
+        assertThat(noteString)
+            .isEqualTo(
+                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                    + "Base-for-patch-set: 1\n"
+                    + "File: file1\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + timeStr
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid1\n"
+                    + "Bytes: 9\n"
+                    + "comment 1\n"
+                    + "\n"
+                    + "2:1-3:1\n"
+                    + timeStr
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid2\n"
+                    + "Bytes: 9\n"
+                    + "comment 2\n"
+                    + "\n"
+                    + "Base-for-patch-set: 2\n"
+                    + "File: file1\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + timeStr
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid3\n"
+                    + "Bytes: 9\n"
+                    + "comment 3\n"
+                    + "\n");
+      }
+    }
+    assertThat(notes.getComments())
+        .isEqualTo(
+            ImmutableListMultimap.of(
+                revId, comment1,
+                revId, comment2,
+                revId, comment3));
+  }
+
+  @Test
+  public void patchLineCommentNotesFormatRealAuthor() throws Exception {
+    Change c = newChange();
+    CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
+    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
+    String uuid = "uuid";
+    String message = "comment";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    Timestamp time = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+
+    Comment comment =
+        newComment(
+            psId,
+            "file",
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            time,
+            message,
+            (short) 1,
+            revId.get(),
+            false);
+    comment.setRealAuthor(changeOwner.getAccountId());
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    try (RevWalk walk = new RevWalk(repo)) {
+      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      Note note = Iterables.getOnlyElement(notesInTree);
+
+      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
+      String noteString = new String(bytes, UTF_8);
+
+      if (!testJson()) {
+        assertThat(noteString)
+            .isEqualTo(
+                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                    + "Patch-set: 1\n"
+                    + "File: file\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Real-author: Change Owner <1@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid\n"
+                    + "Bytes: 7\n"
+                    + "comment\n"
+                    + "\n");
+      }
+    }
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
+  }
+
+  @Test
+  public void patchLineCommentNotesFormatWeirdUser() throws Exception {
+    Account account = new Account(new Account.Id(3), TimeUtil.nowTs());
+    account.setFullName("Weird\n\u0002<User>\n");
+    account.setPreferredEmail(" we\r\nird@ex>ample<.com");
+    accountCache.put(account);
+    IdentifiedUser user = userFactory.create(account.getId());
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, user);
+    String uuid = "uuid";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    Timestamp time = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+
+    Comment comment =
+        newComment(
+            psId,
+            "file1",
+            uuid,
+            range,
+            range.getEndLine(),
+            user,
+            null,
+            time,
+            "comment",
+            (short) 1,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    try (RevWalk walk = new RevWalk(repo)) {
+      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      Note note = Iterables.getOnlyElement(notesInTree);
+
+      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
+      String noteString = new String(bytes, UTF_8);
+      String timeStr = ChangeNoteUtil.formatTime(serverIdent, time);
+
+      if (!testJson()) {
+        assertThat(noteString)
+            .isEqualTo(
+                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                    + "Patch-set: 1\n"
+                    + "File: file1\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + timeStr
+                    + "\n"
+                    + "Author: Weird\u0002User <3@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid\n"
+                    + "Bytes: 7\n"
+                    + "comment\n"
+                    + "\n");
+      }
+    }
+    assertThat(notes.getComments())
+        .isEqualTo(ImmutableListMultimap.of(new RevId(comment.revId), comment));
+  }
+
+  @Test
+  public void patchLineCommentMultipleOnePatchsetOneFileBothSides() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    String uuid1 = "uuid1";
+    String uuid2 = "uuid2";
+    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    String messageForBase = "comment for base";
+    String messageForPS = "comment for ps";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    Timestamp now = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+
+    Comment commentForBase =
+        newComment(
+            psId,
+            "filename",
+            uuid1,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            messageForBase,
+            (short) 0,
+            rev1,
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, commentForBase);
+    update.commit();
+
+    update = newUpdate(c, otherUser);
+    Comment commentForPS =
+        newComment(
+            psId,
+            "filename",
+            uuid2,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            messageForPS,
+            (short) 1,
+            rev2,
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, commentForPS);
+    update.commit();
+
+    assertThat(newNotes(c).getComments())
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                new RevId(rev1), commentForBase,
+                new RevId(rev2), commentForPS));
+  }
+
+  @Test
+  public void patchLineCommentMultipleOnePatchsetOneFile() throws Exception {
+    Change c = newChange();
+    String uuid1 = "uuid1";
+    String uuid2 = "uuid2";
+    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id psId = c.currentPatchSetId();
+    String filename = "filename";
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    Timestamp timeForComment1 = TimeUtil.nowTs();
+    Timestamp timeForComment2 = TimeUtil.nowTs();
+    Comment comment1 =
+        newComment(
+            psId,
+            filename,
+            uuid1,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            timeForComment1,
+            "comment 1",
+            side,
+            rev,
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment1);
+    update.commit();
+
+    update = newUpdate(c, otherUser);
+    Comment comment2 =
+        newComment(
+            psId,
+            filename,
+            uuid2,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            timeForComment2,
+            "comment 2",
+            side,
+            rev,
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment2);
+    update.commit();
+
+    assertThat(newNotes(c).getComments())
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                new RevId(rev), comment1,
+                new RevId(rev), comment2))
+        .inOrder();
+  }
+
+  @Test
+  public void patchLineCommentMultipleOnePatchsetMultipleFiles() throws Exception {
+    Change c = newChange();
+    String uuid = "uuid";
+    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id psId = c.currentPatchSetId();
+    String filename1 = "filename1";
+    String filename2 = "filename2";
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    Timestamp now = TimeUtil.nowTs();
+    Comment comment1 =
+        newComment(
+            psId,
+            filename1,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment 1",
+            side,
+            rev,
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment1);
+    update.commit();
+
+    update = newUpdate(c, otherUser);
+    Comment comment2 =
+        newComment(
+            psId,
+            filename2,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment 2",
+            side,
+            rev,
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment2);
+    update.commit();
+
+    assertThat(newNotes(c).getComments())
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                new RevId(rev), comment1,
+                new RevId(rev), comment2))
+        .inOrder();
+  }
+
+  @Test
+  public void patchLineCommentMultiplePatchsets() throws Exception {
+    Change c = newChange();
+    String uuid = "uuid";
+    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id ps1 = c.currentPatchSetId();
+    String filename = "filename1";
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    Timestamp now = TimeUtil.nowTs();
+    Comment comment1 =
+        newComment(
+            ps1,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev1,
+            false);
+    update.setPatchSetId(ps1);
+    update.putComment(Status.PUBLISHED, comment1);
+    update.commit();
+
+    incrementPatchSet(c);
+    PatchSet.Id ps2 = c.currentPatchSetId();
+
+    update = newUpdate(c, otherUser);
+    now = TimeUtil.nowTs();
+    Comment comment2 =
+        newComment(
+            ps2,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps2",
+            side,
+            rev2,
+            false);
+    update.setPatchSetId(ps2);
+    update.putComment(Status.PUBLISHED, comment2);
+    update.commit();
+
+    assertThat(newNotes(c).getComments())
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                new RevId(rev1), comment1,
+                new RevId(rev2), comment2));
+  }
+
+  @Test
+  public void patchLineCommentSingleDraftToPublished() throws Exception {
+    Change c = newChange();
+    String uuid = "uuid";
+    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id ps1 = c.currentPatchSetId();
+    String filename = "filename1";
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    Timestamp now = TimeUtil.nowTs();
+    Comment comment1 =
+        newComment(
+            ps1,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev,
+            false);
+    update.setPatchSetId(ps1);
+    update.putComment(Status.DRAFT, comment1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId))
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
+    assertThat(notes.getComments()).isEmpty();
+
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(ps1);
+    update.putComment(Status.PUBLISHED, comment1);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId)).isEmpty();
+    assertThat(notes.getComments())
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
+  }
+
+  @Test
+  public void patchLineCommentMultipleDraftsSameSidePublishOne() throws Exception {
+    Change c = newChange();
+    String uuid1 = "uuid1";
+    String uuid2 = "uuid2";
+    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    CommentRange range1 = new CommentRange(1, 1, 2, 2);
+    CommentRange range2 = new CommentRange(2, 2, 3, 3);
+    String filename = "filename1";
+    short side = (short) 1;
+    Timestamp now = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+
+    // Write two drafts on the same side of one patch set.
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(psId);
+    Comment comment1 =
+        newComment(
+            psId,
+            filename,
+            uuid1,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev,
+            false);
+    Comment comment2 =
+        newComment(
+            psId,
+            filename,
+            uuid2,
+            range2,
+            range2.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "other on ps1",
+            side,
+            rev,
+            false);
+    update.putComment(Status.DRAFT, comment1);
+    update.putComment(Status.DRAFT, comment2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId))
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                new RevId(rev), comment1,
+                new RevId(rev), comment2))
+        .inOrder();
+    assertThat(notes.getComments()).isEmpty();
+
+    // Publish first draft.
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment1);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId))
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment2));
+    assertThat(notes.getComments())
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
+  }
+
+  @Test
+  public void patchLineCommentsMultipleDraftsBothSidesPublishAll() throws Exception {
+    Change c = newChange();
+    String uuid1 = "uuid1";
+    String uuid2 = "uuid2";
+    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    CommentRange range1 = new CommentRange(1, 1, 2, 2);
+    CommentRange range2 = new CommentRange(2, 2, 3, 3);
+    String filename = "filename1";
+    Timestamp now = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+
+    // Write two drafts, one on each side of the patchset.
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(psId);
+    Comment baseComment =
+        newComment(
+            psId,
+            filename,
+            uuid1,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on base",
+            (short) 0,
+            rev1,
+            false);
+    Comment psComment =
+        newComment(
+            psId,
+            filename,
+            uuid2,
+            range2,
+            range2.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps",
+            (short) 1,
+            rev2,
+            false);
+
+    update.putComment(Status.DRAFT, baseComment);
+    update.putComment(Status.DRAFT, psComment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId))
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                new RevId(rev1), baseComment,
+                new RevId(rev2), psComment));
+    assertThat(notes.getComments()).isEmpty();
+
+    // Publish both comments.
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(psId);
+
+    update.putComment(Status.PUBLISHED, baseComment);
+    update.putComment(Status.PUBLISHED, psComment);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId)).isEmpty();
+    assertThat(notes.getComments())
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                new RevId(rev1), baseComment,
+                new RevId(rev2), psComment));
+  }
+
+  @Test
+  public void patchLineCommentsDeleteAllDrafts() throws Exception {
+    Change c = newChange();
+    String uuid = "uuid";
+    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    ObjectId objId = ObjectId.fromString(rev);
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id psId = c.currentPatchSetId();
+    String filename = "filename";
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    Timestamp now = TimeUtil.nowTs();
+    Comment comment =
+        newComment(
+            psId,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev,
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.DRAFT, comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId)).hasSize(1);
+    assertThat(notes.getDraftCommentNotes().getNoteMap().contains(objId)).isTrue();
+
+    update = newUpdate(c, otherUser);
+    now = TimeUtil.nowTs();
+    update.setPatchSetId(psId);
+    update.deleteComment(comment);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId)).isEmpty();
+    assertThat(notes.getDraftCommentNotes().getNoteMap()).isNull();
+  }
+
+  @Test
+  public void patchLineCommentsDeleteAllDraftsForOneRevision() throws Exception {
+    Change c = newChange();
+    String uuid = "uuid";
+    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    ObjectId objId1 = ObjectId.fromString(rev1);
+    ObjectId objId2 = ObjectId.fromString(rev2);
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id ps1 = c.currentPatchSetId();
+    String filename = "filename1";
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    Timestamp now = TimeUtil.nowTs();
+    Comment comment1 =
+        newComment(
+            ps1,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev1,
+            false);
+    update.setPatchSetId(ps1);
+    update.putComment(Status.DRAFT, comment1);
+    update.commit();
+
+    incrementPatchSet(c);
+    PatchSet.Id ps2 = c.currentPatchSetId();
+
+    update = newUpdate(c, otherUser);
+    now = TimeUtil.nowTs();
+    Comment comment2 =
+        newComment(
+            ps2,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps2",
+            side,
+            rev2,
+            false);
+    update.setPatchSetId(ps2);
+    update.putComment(Status.DRAFT, comment2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId)).hasSize(2);
+
+    update = newUpdate(c, otherUser);
+    now = TimeUtil.nowTs();
+    update.setPatchSetId(ps2);
+    update.deleteComment(comment2);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId)).hasSize(1);
+    NoteMap noteMap = notes.getDraftCommentNotes().getNoteMap();
+    assertThat(noteMap.contains(objId1)).isTrue();
+    assertThat(noteMap.contains(objId2)).isFalse();
+  }
+
+  @Test
+  public void addingPublishedCommentDoesNotCreateNoOpCommitOnEmptyDraftRef() throws Exception {
+    Change c = newChange();
+    String uuid = "uuid";
+    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id ps1 = c.currentPatchSetId();
+    String filename = "filename1";
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    Timestamp now = TimeUtil.nowTs();
+    Comment comment =
+        newComment(
+            ps1,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev,
+            false);
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    assertThat(repo.exactRef(changeMetaRef(c.getId()))).isNotNull();
+    String draftRef = refsDraftComments(c.getId(), otherUser.getAccountId());
+    assertThat(exactRefAllUsers(draftRef)).isNull();
+  }
+
+  @Test
+  public void addingPublishedCommentDoesNotCreateNoOpCommitOnNonEmptyDraftRef() throws Exception {
+    Change c = newChange();
+    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id ps1 = c.currentPatchSetId();
+    String filename = "filename1";
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    Timestamp now = TimeUtil.nowTs();
+    Comment draft =
+        newComment(
+            ps1,
+            filename,
+            "uuid1",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "draft comment on ps1",
+            side,
+            rev,
+            false);
+    update.putComment(Status.DRAFT, draft);
+    update.commit();
+
+    String draftRef = refsDraftComments(c.getId(), otherUser.getAccountId());
+    ObjectId old = exactRefAllUsers(draftRef);
+    assertThat(old).isNotNull();
+
+    update = newUpdate(c, otherUser);
+    Comment pub =
+        newComment(
+            ps1,
+            filename,
+            "uuid2",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev,
+            false);
+    update.putComment(Status.PUBLISHED, pub);
+    update.commit();
+
+    assertThat(exactRefAllUsers(draftRef)).isEqualTo(old);
+  }
+
+  @Test
+  public void fileComment() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    String uuid = "uuid";
+    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    String messageForBase = "comment for base";
+    Timestamp now = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+
+    Comment comment =
+        newComment(
+            psId,
+            "filename",
+            uuid,
+            null,
+            0,
+            otherUser,
+            null,
+            now,
+            messageForBase,
+            (short) 0,
+            rev,
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    assertThat(newNotes(c).getComments())
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment));
+  }
+
+  @Test
+  public void patchLineCommentNoRange() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    String uuid = "uuid";
+    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    String messageForBase = "comment for base";
+    Timestamp now = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+
+    Comment comment =
+        newComment(
+            psId,
+            "filename",
+            uuid,
+            null,
+            1,
+            otherUser,
+            null,
+            now,
+            messageForBase,
+            (short) 0,
+            rev,
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    assertThat(newNotes(c).getComments())
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment));
+  }
+
+  @Test
+  public void putCommentsForMultipleRevisions() throws Exception {
+    Change c = newChange();
+    String uuid = "uuid";
+    String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+    String rev2 = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id ps1 = c.currentPatchSetId();
+    String filename = "filename1";
+    short side = (short) 1;
+
+    incrementPatchSet(c);
+    PatchSet.Id ps2 = c.currentPatchSetId();
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(ps2);
+    Timestamp now = TimeUtil.nowTs();
+    Comment comment1 =
+        newComment(
+            ps1,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev1,
+            false);
+    Comment comment2 =
+        newComment(
+            ps2,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps2",
+            side,
+            rev2,
+            false);
+    update.putComment(Status.DRAFT, comment1);
+    update.putComment(Status.DRAFT, comment2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId)).hasSize(2);
+    assertThat(notes.getComments()).isEmpty();
+
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(ps2);
+    update.putComment(Status.PUBLISHED, comment1);
+    update.putComment(Status.PUBLISHED, comment2);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId)).isEmpty();
+    assertThat(notes.getComments()).hasSize(2);
+  }
+
+  @Test
+  public void publishSubsetOfCommentsOnRevision() throws Exception {
+    Change c = newChange();
+    RevId rev1 = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id ps1 = c.currentPatchSetId();
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(ps1);
+    Timestamp now = TimeUtil.nowTs();
+    Comment comment1 =
+        newComment(
+            ps1,
+            "file1",
+            "uuid1",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment1",
+            side,
+            rev1.get(),
+            false);
+    Comment comment2 =
+        newComment(
+            ps1,
+            "file2",
+            "uuid2",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment2",
+            side,
+            rev1.get(),
+            false);
+    update.putComment(Status.DRAFT, comment1);
+    update.putComment(Status.DRAFT, comment2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1, comment2);
+    assertThat(notes.getComments()).isEmpty();
+
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(ps1);
+    update.putComment(Status.PUBLISHED, comment2);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1);
+    assertThat(notes.getComments().get(rev1)).containsExactly(comment2);
+  }
+
+  @Test
+  public void updateWithServerIdent() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, internalUser);
+    update.setChangeMessage("A message.");
+    update.commit();
+
+    ChangeMessage msg = Iterables.getLast(newNotes(c).getChangeMessages());
+    assertThat(msg.getMessage()).isEqualTo("A message.");
+    assertThat(msg.getAuthor()).isNull();
+
+    update = newUpdate(c, internalUser);
+    exception.expect(IllegalStateException.class);
+    update.putApproval("Code-Review", (short) 1);
+  }
+
+  @Test
+  public void filterOutAndFixUpZombieDraftComments() throws Exception {
+    Change c = newChange();
+    RevId rev1 = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id ps1 = c.currentPatchSetId();
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    Timestamp now = TimeUtil.nowTs();
+    Comment comment1 =
+        newComment(
+            ps1,
+            "file1",
+            "uuid1",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev1.get(),
+            false);
+    Comment comment2 =
+        newComment(
+            ps1,
+            "file2",
+            "uuid2",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "another comment",
+            side,
+            rev1.get(),
+            false);
+    update.putComment(Status.DRAFT, comment1);
+    update.putComment(Status.DRAFT, comment2);
+    update.commit();
+
+    String refName = refsDraftComments(c.getId(), otherUserId);
+    ObjectId oldDraftId = exactRefAllUsers(refName);
+
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(ps1);
+    update.putComment(Status.PUBLISHED, comment2);
+    update.commit();
+    assertThat(exactRefAllUsers(refName)).isNotNull();
+    assertThat(exactRefAllUsers(refName)).isNotEqualTo(oldDraftId);
+
+    // Re-add draft version of comment2 back to draft ref without updating
+    // change ref. Simulates the case where deleting the draft failed
+    // non-atomically after adding the published comment succeeded.
+    ChangeDraftUpdate draftUpdate = newUpdate(c, otherUser).createDraftUpdateIfNull();
+    draftUpdate.putComment(comment2);
+    try (NoteDbUpdateManager manager = updateManagerFactory.create(c.getProject())) {
+      manager.add(draftUpdate);
+      manager.execute();
+    }
+
+    // Looking at drafts directly shows the zombie comment.
+    DraftCommentNotes draftNotes = draftNotesFactory.create(c, otherUserId);
+    assertThat(draftNotes.load().getComments().get(rev1)).containsExactly(comment1, comment2);
+
+    // Zombie comment is filtered out of drafts via ChangeNotes.
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1);
+    assertThat(notes.getComments().get(rev1)).containsExactly(comment2);
+
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(ps1);
+    update.putComment(Status.PUBLISHED, comment1);
+    update.commit();
+
+    // Updating an unrelated comment causes the zombie comment to get fixed up.
+    assertThat(exactRefAllUsers(refName)).isNull();
+  }
+
+  @Test
+  public void updateCommentsInSequentialUpdates() throws Exception {
+    Change c = newChange();
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+
+    ChangeUpdate update1 = newUpdate(c, otherUser);
+    Comment comment1 =
+        newComment(
+            c.currentPatchSetId(),
+            "filename",
+            "uuid1",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            new Timestamp(update1.getWhen().getTime()),
+            "comment 1",
+            (short) 1,
+            rev,
+            false);
+    update1.putComment(Status.PUBLISHED, comment1);
+
+    ChangeUpdate update2 = newUpdate(c, otherUser);
+    Comment comment2 =
+        newComment(
+            c.currentPatchSetId(),
+            "filename",
+            "uuid2",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            new Timestamp(update2.getWhen().getTime()),
+            "comment 2",
+            (short) 1,
+            rev,
+            false);
+    update2.putComment(Status.PUBLISHED, comment2);
+
+    try (NoteDbUpdateManager manager = updateManagerFactory.create(project)) {
+      manager.add(update1);
+      manager.add(update2);
+      manager.execute();
+    }
+
+    ChangeNotes notes = newNotes(c);
+    List<Comment> comments = notes.getComments().get(new RevId(rev));
+    assertThat(comments).hasSize(2);
+    assertThat(comments.get(0).message).isEqualTo("comment 1");
+    assertThat(comments.get(1).message).isEqualTo("comment 2");
+  }
+
+  @Test
+  public void realUser() throws Exception {
+    Change c = newChange();
+    CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
+    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
+    update.setChangeMessage("Message on behalf of other user");
+    update.commit();
+
+    ChangeMessage msg = Iterables.getLast(newNotes(c).getChangeMessages());
+    assertThat(msg.getMessage()).isEqualTo("Message on behalf of other user");
+    assertThat(msg.getAuthor()).isEqualTo(otherUserId);
+    assertThat(msg.getRealAuthor()).isEqualTo(changeOwner.getAccountId());
+  }
+
+  @Test
+  public void ignoreEntitiesBeyondCurrentPatchSet() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    int numMessages = notes.getChangeMessages().size();
+    int numPatchSets = notes.getPatchSets().size();
+    int numApprovals = notes.getApprovals().size();
+    int numComments = notes.getComments().size();
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPatchSetId(new PatchSet.Id(c.getId(), c.currentPatchSetId().get() + 1));
+    update.setChangeMessage("Should be ignored");
+    update.putApproval("Code-Review", (short) 2);
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    Comment comment =
+        newComment(
+            update.getPatchSetId(),
+            "filename",
+            "uuid",
+            range,
+            range.getEndLine(),
+            changeOwner,
+            null,
+            new Timestamp(update.getWhen().getTime()),
+            "comment",
+            (short) 1,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getChangeMessages()).hasSize(numMessages);
+    assertThat(notes.getPatchSets()).hasSize(numPatchSets);
+    assertThat(notes.getApprovals()).hasSize(numApprovals);
+    assertThat(notes.getComments()).hasSize(numComments);
+  }
+
+  @Test
+  public void currentPatchSet() throws Exception {
+    Change c = newChange();
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(1);
+
+    incrementPatchSet(c);
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPatchSetId(new PatchSet.Id(c.getId(), 1));
+    update.setCurrentPatchSet();
+    update.commit();
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(1);
+
+    incrementPatchSet(c);
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(3);
+
+    // Delete PS3, PS1 becomes current, as the most recent event explicitly set
+    // it to current.
+    update = newUpdate(c, changeOwner);
+    update.setPatchSetState(PatchSetState.DELETED);
+    update.commit();
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(1);
+
+    // Delete PS1, PS2 becomes current.
+    update = newUpdate(c, changeOwner);
+    update.setPatchSetId(new PatchSet.Id(c.getId(), 1));
+    update.setPatchSetState(PatchSetState.DELETED);
+    update.commit();
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
+  }
+
+  @Test
+  public void readOnlyUntilExpires() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    Timestamp until = new Timestamp(TimeUtil.nowMs() + 10000);
+    update.setReadOnlyUntil(until);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setTopic("failing-topic");
+    try {
+      update.commit();
+      assert_().fail("expected OrmException");
+    } catch (OrmException e) {
+      assertThat(e.getMessage()).contains("read-only until");
+    }
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isNotEqualTo("failing-topic");
+    assertThat(notes.getReadOnlyUntil()).isEqualTo(until);
+
+    TestTimeUtil.incrementClock(30, TimeUnit.SECONDS);
+    update = newUpdate(c, changeOwner);
+    update.setTopic("succeeding-topic");
+    update.commit();
+
+    // Write succeeded; lease still exists, even though it's expired.
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isEqualTo("succeeding-topic");
+    assertThat(notes.getReadOnlyUntil()).isEqualTo(until);
+
+    // New lease takes precedence.
+    update = newUpdate(c, changeOwner);
+    until = new Timestamp(TimeUtil.nowMs() + 10000);
+    update.setReadOnlyUntil(until);
+    update.commit();
+    assertThat(newNotes(c).getReadOnlyUntil()).isEqualTo(until);
+  }
+
+  @Test
+  public void readOnlyUntilCleared() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    Timestamp until = new Timestamp(TimeUtil.nowMs() + TimeUnit.DAYS.toMillis(30));
+    update.setReadOnlyUntil(until);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setTopic("failing-topic");
+    try {
+      update.commit();
+      assert_().fail("expected OrmException");
+    } catch (OrmException e) {
+      assertThat(e.getMessage()).contains("read-only until");
+    }
+
+    // Sentinel timestamp of 0 can be written to clear lease.
+    update = newUpdate(c, changeOwner);
+    update.setReadOnlyUntil(new Timestamp(0));
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setTopic("succeeding-topic");
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isEqualTo("succeeding-topic");
+    assertThat(notes.getReadOnlyUntil()).isEqualTo(new Timestamp(0));
+  }
+
+  @Test
+  public void privateDefault() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isFalse();
+  }
+
+  @Test
+  public void privateSetPrivate() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPrivate(true);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isTrue();
+  }
+
+  @Test
+  public void privateSetPrivateMultipleTimes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPrivate(true);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setPrivate(false);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isFalse();
+  }
+
+  @Test
+  public void defaultReviewersByEmailIsEmpty() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).isEmpty();
+  }
+
+  @Test
+  public void putReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
+  @Test
+  public void putAndRemoveReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.removeReviewerByEmail(adr);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).isEmpty();
+  }
+
+  @Test
+  public void putRemoveAndAddBackReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.removeReviewerByEmail(adr);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
+  @Test
+  public void putReviewerByEmailAndCcByEmail() throws Exception {
+    Address adrReviewer = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+    Address adrCc = new Address("Foo Bor", "foo.bar.2@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adrReviewer, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adrCc, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.REVIEWER))
+        .containsExactly(adrReviewer);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.CC))
+        .containsExactly(adrCc);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adrReviewer, adrCc);
+  }
+
+  @Test
+  public void putReviewerByEmailAndChangeToCc() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.REVIEWER)).isEmpty();
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.CC)).containsExactly(adr);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
+  @Test
+  public void hasReviewStarted() throws Exception {
+    ChangeNotes notes = newNotes(newChange());
+    assertThat(notes.hasReviewStarted()).isTrue();
+
+    notes = newNotes(newWorkInProgressChange());
+    assertThat(notes.hasReviewStarted()).isFalse();
+
+    Change c = newWorkInProgressChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.hasReviewStarted()).isFalse();
+
+    update = newUpdate(c, changeOwner);
+    update.setWorkInProgress(true);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.hasReviewStarted()).isFalse();
+
+    update = newUpdate(c, changeOwner);
+    update.setWorkInProgress(false);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.hasReviewStarted()).isTrue();
+
+    // Once review is started, setting WIP should have no impact.
+    c = newChange();
+    notes = newNotes(c);
+    assertThat(notes.hasReviewStarted()).isTrue();
+    update = newUpdate(c, changeOwner);
+    update.setWorkInProgress(true);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.hasReviewStarted()).isTrue();
+  }
+
+  @Test
+  public void pendingReviewers() throws Exception {
+    Address adr1 = new Address("Foo Bar1", "foo.bar1@gerritcodereview.com");
+    Address adr2 = new Address("Foo Bar2", "foo.bar2@gerritcodereview.com");
+    Account.Id ownerId = changeOwner.getAccount().getId();
+    Account.Id otherUserId = otherUser.getAccount().getId();
+
+    ChangeNotes notes = newNotes(newChange());
+    assertThat(notes.getPendingReviewers().asTable()).isEmpty();
+    assertThat(notes.getPendingReviewersByEmail().asTable()).isEmpty();
+
+    Change c = newWorkInProgressChange();
+    notes = newNotes(c);
+    assertThat(notes.getPendingReviewers().asTable()).isEmpty();
+    assertThat(notes.getPendingReviewersByEmail().asTable()).isEmpty();
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(ownerId, REVIEWER);
+    update.putReviewer(otherUserId, CC);
+    update.putReviewerByEmail(adr1, REVIEWER);
+    update.putReviewerByEmail(adr2, CC);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getPendingReviewers().byState(REVIEWER)).containsExactly(ownerId);
+    assertThat(notes.getPendingReviewers().byState(CC)).containsExactly(otherUserId);
+    assertThat(notes.getPendingReviewers().byState(REMOVED)).isEmpty();
+    assertThat(notes.getPendingReviewersByEmail().byState(REVIEWER)).containsExactly(adr1);
+    assertThat(notes.getPendingReviewersByEmail().byState(CC)).containsExactly(adr2);
+    assertThat(notes.getPendingReviewersByEmail().byState(REMOVED)).isEmpty();
+
+    update = newUpdate(c, changeOwner);
+    update.removeReviewer(ownerId);
+    update.removeReviewerByEmail(adr1);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getPendingReviewers().byState(REVIEWER)).isEmpty();
+    assertThat(notes.getPendingReviewers().byState(CC)).containsExactly(otherUserId);
+    assertThat(notes.getPendingReviewers().byState(REMOVED)).containsExactly(ownerId);
+    assertThat(notes.getPendingReviewersByEmail().byState(REVIEWER)).isEmpty();
+    assertThat(notes.getPendingReviewersByEmail().byState(CC)).containsExactly(adr2);
+    assertThat(notes.getPendingReviewersByEmail().byState(REMOVED)).containsExactly(adr1);
+
+    update = newUpdate(c, changeOwner);
+    update.setWorkInProgress(false);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getPendingReviewers().asTable()).isEmpty();
+    assertThat(notes.getPendingReviewersByEmail().asTable()).isEmpty();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewer(ownerId, REVIEWER);
+    update.putReviewerByEmail(adr1, REVIEWER);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getPendingReviewers().asTable()).isEmpty();
+    assertThat(notes.getPendingReviewersByEmail().asTable()).isEmpty();
+  }
+
+  @Test
+  public void revertOfIsNullByDefault() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getRevertOf()).isNull();
+  }
+
+  @Test
+  public void setRevertOfPersistsValue() throws Exception {
+    Change changeToRevert = newChange();
+    Change c = TestChanges.newChange(project, changeOwner.getAccountId());
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeId(c.getKey().get());
+    update.setRevertOf(changeToRevert.getId().get());
+    update.commit();
+    assertThat(newNotes(c).getRevertOf()).isEqualTo(changeToRevert.getId());
+  }
+
+  @Test
+  public void setRevertOfToCurrentChangeFails() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage("A change cannot revert itself");
+    update.setRevertOf(c.getId().get());
+  }
+
+  @Test
+  public void setRevertOfOnChildCommitFails() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    exception.expect(OrmException.class);
+    exception.expectMessage("Given ChangeUpdate is only allowed on initial commit");
+    update.setRevertOf(newChange().getId().get());
+    update.commit();
+  }
+
+  private boolean testJson() {
+    return noteUtil.getWriteJson();
+  }
+
+  private String readNote(ChangeNotes notes, ObjectId noteId) throws Exception {
+    ObjectId dataId = notes.revisionNoteMap.noteMap.getNote(noteId).getData();
+    return new String(rw.getObjectReader().open(dataId, OBJ_BLOB).getCachedBytes(), UTF_8);
+  }
+
+  private ObjectId exactRefAllUsers(String refName) throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      Ref ref = allUsersRepo.exactRef(refName);
+      return ref != null ? ref.getObjectId() : null;
+    }
+  }
+
+  private void assertCause(
+      Throwable e, Class<? extends Throwable> expectedClass, String expectedMsg) {
+    Throwable cause = null;
+    for (Throwable t : Throwables.getCausalChain(e)) {
+      if (expectedClass.isAssignableFrom(t.getClass())) {
+        cause = t;
+        break;
+      }
+    }
+    assertThat(cause)
+        .named(
+            expectedClass.getSimpleName()
+                + " in causal chain of:\n"
+                + Throwables.getStackTraceAsString(e))
+        .isNotNull();
+    assertThat(cause.getMessage()).isEqualTo(expectedMsg);
+  }
+
+  private void incrementCurrentPatchSetFieldOnly(Change c) {
+    TestChanges.incrementPatchSet(c);
+  }
+
+  private RevCommit incrementPatchSet(Change c) throws Exception {
+    return incrementPatchSet(c, userFactory.create(c.getOwner()));
+  }
+
+  private RevCommit incrementPatchSet(Change c, IdentifiedUser user) throws Exception {
+    incrementCurrentPatchSetFieldOnly(c);
+    RevCommit commit = tr.commit().message("PS" + c.currentPatchSetId().get()).create();
+    ChangeUpdate update = newUpdate(c, user);
+    update.setCommit(rw, commit);
+    update.commit();
+    return tr.parseBody(commit);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
rename to javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
new file mode 100644
index 0000000..f826fec
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -0,0 +1,427 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.TestChanges;
+import java.util.Date;
+import java.util.TimeZone;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(ConfigSuite.class)
+public class CommitMessageOutputTest extends AbstractChangeNotesTest {
+  @Test
+  public void approvalsCommitFormatSimple() throws Exception {
+    Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Verified", (short) 1);
+    update.putApproval("Code-Review", (short) -1);
+    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.putReviewer(otherUser.getAccount().getId(), CC);
+    update.commit();
+    assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
+
+    RevCommit commit = parseCommit(update.getResult());
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Change-id: "
+            + c.getKey().get()
+            + "\n"
+            + "Subject: Change subject\n"
+            + "Branch: refs/heads/master\n"
+            + "Commit: "
+            + update.getCommit().name()
+            + "\n"
+            + "Reviewer: Change Owner <1@gerrit>\n"
+            + "CC: Other Account <2@gerrit>\n"
+            + "Label: Code-Review=-1\n"
+            + "Label: Verified=+1\n",
+        commit);
+
+    PersonIdent author = commit.getAuthorIdent();
+    assertThat(author.getName()).isEqualTo("Change Owner");
+    assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
+    assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 1000));
+    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
+
+    PersonIdent committer = commit.getCommitterIdent();
+    assertThat(committer.getName()).isEqualTo("Gerrit Server");
+    assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
+    assertThat(committer.getWhen()).isEqualTo(author.getWhen());
+    assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
+  }
+
+  @Test
+  public void changeMessageCommitFormatSimple() throws Exception {
+    Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("Just a little code change.\nHow about a new line");
+    update.commit();
+    assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
+
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Just a little code change.\n"
+            + "How about a new line\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Change-id: "
+            + c.getKey().get()
+            + "\n"
+            + "Subject: Change subject\n"
+            + "Branch: refs/heads/master\n"
+            + "Commit: "
+            + update.getCommit().name()
+            + "\n",
+        update.getResult());
+  }
+
+  @Test
+  public void changeWithRevision() throws Exception {
+    Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("Foo");
+    RevCommit commit = tr.commit().message("Subject").create();
+    update.setCommit(rw, commit);
+    update.commit();
+    assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
+
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Foo\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Change-id: "
+            + c.getKey().get()
+            + "\n"
+            + "Subject: Subject\n"
+            + "Branch: refs/heads/master\n"
+            + "Commit: "
+            + commit.name()
+            + "\n",
+        update.getResult());
+  }
+
+  @Test
+  public void approvalTombstoneCommitFormat() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.removeApproval("Code-Review");
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n\nPatch-set: 1\nLabel: -Code-Review\n", update.getResult());
+  }
+
+  @Test
+  public void submitCommitFormat() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setSubjectForCommit("Submit patch set 1");
+
+    RequestId submissionId = RequestId.forChange(c);
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Code-Review", "NEED", null)),
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Alternative-Code-Review", "NEED", null))));
+    update.commit();
+
+    RevCommit commit = parseCommit(update.getResult());
+    assertBodyEquals(
+        "Submit patch set 1\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Status: merged\n"
+            + "Submission-id: "
+            + submissionId.toStringForStorage()
+            + "\n"
+            + "Submitted-with: NOT_READY\n"
+            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+            + "Submitted-with: NEED: Code-Review\n"
+            + "Submitted-with: NOT_READY\n"
+            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+            + "Submitted-with: NEED: Alternative-Code-Review\n",
+        commit);
+
+    PersonIdent author = commit.getAuthorIdent();
+    assertThat(author.getName()).isEqualTo("Change Owner");
+    assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
+    assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 2000));
+    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
+
+    PersonIdent committer = commit.getCommitterIdent();
+    assertThat(committer.getName()).isEqualTo("Gerrit Server");
+    assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
+    assertThat(committer.getWhen()).isEqualTo(author.getWhen());
+    assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
+  }
+
+  @Test
+  public void anonymousUser() throws Exception {
+    Account anon = new Account(new Account.Id(3), TimeUtil.nowTs());
+    accountCache.put(anon);
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, userFactory.create(anon.getId()));
+    update.setChangeMessage("Comment on the change.");
+    update.commit();
+
+    RevCommit commit = parseCommit(update.getResult());
+    assertBodyEquals("Update patch set 1\n\nComment on the change.\n\nPatch-set: 1\n", commit);
+
+    PersonIdent author = commit.getAuthorIdent();
+    assertThat(author.getName()).isEqualTo("GerritAccount #3");
+    assertThat(author.getEmailAddress()).isEqualTo("3@gerrit");
+  }
+
+  @Test
+  public void submitWithErrorMessage() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setSubjectForCommit("Submit patch set 1");
+
+    RequestId submissionId = RequestId.forChange(c);
+    update.merge(
+        submissionId, ImmutableList.of(submitRecord("RULE_ERROR", "Problem with patch set:\n1")));
+    update.commit();
+
+    assertBodyEquals(
+        "Submit patch set 1\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Status: merged\n"
+            + "Submission-id: "
+            + submissionId.toStringForStorage()
+            + "\n"
+            + "Submitted-with: RULE_ERROR Problem with patch set: 1\n",
+        update.getResult());
+  }
+
+  @Test
+  public void noChangeMessage() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n\nPatch-set: 1\nReviewer: Change Owner <1@gerrit>\n",
+        update.getResult());
+  }
+
+  @Test
+  public void changeMessageWithTrailingDoubleNewline() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("Testing trailing double newline\n\n");
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Testing trailing double newline\n"
+            + "\n"
+            + "\n"
+            + "\n"
+            + "Patch-set: 1\n",
+        update.getResult());
+  }
+
+  @Test
+  public void changeMessageWithMultipleParagraphs() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("Testing paragraph 1\n\nTesting paragraph 2\n\nTesting paragraph 3");
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Testing paragraph 1\n"
+            + "\n"
+            + "Testing paragraph 2\n"
+            + "\n"
+            + "Testing paragraph 3\n"
+            + "\n"
+            + "Patch-set: 1\n",
+        update.getResult());
+  }
+
+  @Test
+  public void changeMessageWithTag() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("Change message with tag");
+    update.setTag("jenkins");
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Change message with tag\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Tag: jenkins\n",
+        update.getResult());
+  }
+
+  @Test
+  public void leadingWhitespace() throws Exception {
+    Change c = TestChanges.newChange(project, changeOwner.getAccountId());
+    c.setCurrentPatchSet(c.currentPatchSetId(), "  " + c.getSubject(), c.getOriginalSubject());
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeId(c.getKey().get());
+    update.setBranch(c.getDest().get());
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Change-id: "
+            + c.getKey().get()
+            + "\n"
+            + "Subject:   Change subject\n"
+            + "Branch: refs/heads/master\n"
+            + "Commit: "
+            + update.getCommit().name()
+            + "\n",
+        update.getResult());
+
+    c = TestChanges.newChange(project, changeOwner.getAccountId());
+    c.setCurrentPatchSet(c.currentPatchSetId(), "\t\t" + c.getSubject(), c.getOriginalSubject());
+    update = newUpdate(c, changeOwner);
+    update.setChangeId(c.getKey().get());
+    update.setBranch(c.getDest().get());
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Change-id: "
+            + c.getKey().get()
+            + "\n"
+            + "Subject: \t\tChange subject\n"
+            + "Branch: refs/heads/master\n"
+            + "Commit: "
+            + update.getCommit().name()
+            + "\n",
+        update.getResult());
+  }
+
+  @Test
+  public void realUser() throws Exception {
+    Change c = newChange();
+    CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
+    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
+    update.setChangeMessage("Message on behalf of other user");
+    update.commit();
+
+    RevCommit commit = parseCommit(update.getResult());
+    PersonIdent author = commit.getAuthorIdent();
+    assertThat(author.getName()).isEqualTo("Other Account");
+    assertThat(author.getEmailAddress()).isEqualTo("2@gerrit");
+
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Message on behalf of other user\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Real-user: Change Owner <1@gerrit>\n",
+        commit);
+  }
+
+  @Test
+  public void currentPatchSet() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setCurrentPatchSet();
+    update.commit();
+
+    assertBodyEquals("Update patch set 1\n\nPatch-set: 1\nCurrent: true\n", update.getResult());
+  }
+
+  @Test
+  public void reviewerByEmail() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(
+        new Address("John Doe", "j.doe@gerritcodereview.com"), ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n\nPatch-set: 1\n"
+            + "Reviewer-email: John Doe <j.doe@gerritcodereview.com>\n",
+        update.getResult());
+  }
+
+  @Test
+  public void ccByEmail() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(new Address("j.doe@gerritcodereview.com"), ReviewerStateInternal.CC);
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n\nPatch-set: 1\nCC-email: j.doe@gerritcodereview.com\n",
+        update.getResult());
+  }
+
+  private RevCommit parseCommit(ObjectId id) throws Exception {
+    if (id instanceof RevCommit) {
+      return (RevCommit) id;
+    }
+    try (RevWalk walk = new RevWalk(repo)) {
+      RevCommit commit = walk.parseCommit(id);
+      walk.parseBody(commit);
+      return commit;
+    }
+  }
+
+  private void assertBodyEquals(String expected, ObjectId commitId) throws Exception {
+    RevCommit commit = parseCommit(commitId);
+    assertThat(commit.getFullMessage()).isEqualTo(expected);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java b/javatests/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
new file mode 100644
index 0000000..fe27cac
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
@@ -0,0 +1,241 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.common.TimeUtil.nowTs;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.NOTE_DB;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.applyDelta;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.parse;
+import static org.eclipse.jgit.lib.ObjectId.zeroId;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.notedb.NoteDbChangeState.Delta;
+import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.TestChanges;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.sql.Timestamp;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Unit tests for {@link NoteDbChangeState}. */
+public class NoteDbChangeStateTest extends GerritBaseTests {
+  ObjectId SHA1 = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+  ObjectId SHA2 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+  ObjectId SHA3 = ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee");
+
+  @Before
+  public void setUp() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void parseReviewDbWithoutDrafts() {
+    NoteDbChangeState state = parse(new Change.Id(1), SHA1.name());
+    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
+    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
+    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
+    assertThat(state.getDraftIds()).isEmpty();
+    assertThat(state.getReadOnlyUntil()).isEmpty();
+    assertThat(state.toString()).isEqualTo(SHA1.name());
+
+    state = parse(new Change.Id(1), "R," + SHA1.name());
+    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
+    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
+    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
+    assertThat(state.getDraftIds()).isEmpty();
+    assertThat(state.getReadOnlyUntil()).isEmpty();
+    assertThat(state.toString()).isEqualTo(SHA1.name());
+  }
+
+  @Test
+  public void parseReviewDbWithDrafts() {
+    String str = SHA1.name() + ",2003=" + SHA2.name() + ",1001=" + SHA3.name();
+    String expected = SHA1.name() + ",1001=" + SHA3.name() + ",2003=" + SHA2.name();
+    NoteDbChangeState state = parse(new Change.Id(1), str);
+    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
+    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
+    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
+    assertThat(state.getDraftIds())
+        .containsExactly(
+            new Account.Id(1001), SHA3,
+            new Account.Id(2003), SHA2);
+    assertThat(state.getReadOnlyUntil()).isEmpty();
+    assertThat(state.toString()).isEqualTo(expected);
+
+    state = parse(new Change.Id(1), "R," + str);
+    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
+    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
+    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
+    assertThat(state.getDraftIds())
+        .containsExactly(
+            new Account.Id(1001), SHA3,
+            new Account.Id(2003), SHA2);
+    assertThat(state.getReadOnlyUntil()).isEmpty();
+    assertThat(state.toString()).isEqualTo(expected);
+  }
+
+  @Test
+  public void parseReadOnlyUntil() {
+    Timestamp ts = new Timestamp(12345);
+    String str = "R=12345," + SHA1.name();
+    NoteDbChangeState state = parse(new Change.Id(1), str);
+    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
+    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
+    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
+    assertThat(state.getReadOnlyUntil().get()).isEqualTo(ts);
+    assertThat(state.toString()).isEqualTo(str);
+
+    str = "N=12345";
+    state = parse(new Change.Id(1), str);
+    assertThat(state.getPrimaryStorage()).isEqualTo(NOTE_DB);
+    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
+    assertThat(state.getRefState()).isEmpty();
+    assertThat(state.getReadOnlyUntil().get()).isEqualTo(ts);
+    assertThat(state.toString()).isEqualTo(str);
+  }
+
+  @Test
+  public void applyDeltaToNullWithNoNewMetaId() throws Exception {
+    Change c = newChange();
+    assertThat(c.getNoteDbState()).isNull();
+    applyDelta(c, Delta.create(c.getId(), noMetaId(), noDrafts()));
+    assertThat(c.getNoteDbState()).isNull();
+
+    applyDelta(c, Delta.create(c.getId(), noMetaId(), drafts(new Account.Id(1001), zeroId())));
+    assertThat(c.getNoteDbState()).isNull();
+  }
+
+  @Test
+  public void applyDeltaToMetaId() throws Exception {
+    Change c = newChange();
+    applyDelta(c, Delta.create(c.getId(), metaId(SHA1), noDrafts()));
+    assertThat(c.getNoteDbState()).isEqualTo(SHA1.name());
+
+    applyDelta(c, Delta.create(c.getId(), metaId(SHA2), noDrafts()));
+    assertThat(c.getNoteDbState()).isEqualTo(SHA2.name());
+
+    // No-op delta.
+    applyDelta(c, Delta.create(c.getId(), noMetaId(), noDrafts()));
+    assertThat(c.getNoteDbState()).isEqualTo(SHA2.name());
+
+    // Set to zero clears the field.
+    applyDelta(c, Delta.create(c.getId(), metaId(zeroId()), noDrafts()));
+    assertThat(c.getNoteDbState()).isNull();
+  }
+
+  @Test
+  public void applyDeltaToDrafts() throws Exception {
+    Change c = newChange();
+    applyDelta(c, Delta.create(c.getId(), metaId(SHA1), drafts(new Account.Id(1001), SHA2)));
+    assertThat(c.getNoteDbState()).isEqualTo(SHA1.name() + ",1001=" + SHA2.name());
+
+    applyDelta(c, Delta.create(c.getId(), noMetaId(), drafts(new Account.Id(2003), SHA3)));
+    assertThat(c.getNoteDbState())
+        .isEqualTo(SHA1.name() + ",1001=" + SHA2.name() + ",2003=" + SHA3.name());
+
+    applyDelta(c, Delta.create(c.getId(), noMetaId(), drafts(new Account.Id(2003), zeroId())));
+    assertThat(c.getNoteDbState()).isEqualTo(SHA1.name() + ",1001=" + SHA2.name());
+
+    applyDelta(c, Delta.create(c.getId(), metaId(SHA3), noDrafts()));
+    assertThat(c.getNoteDbState()).isEqualTo(SHA3.name() + ",1001=" + SHA2.name());
+  }
+
+  @Test
+  public void applyDeltaToReadOnly() throws Exception {
+    Timestamp ts = nowTs();
+    Change c = newChange();
+    NoteDbChangeState state =
+        new NoteDbChangeState(
+            c.getId(),
+            REVIEW_DB,
+            Optional.of(RefState.create(SHA1, ImmutableMap.of())),
+            Optional.of(new Timestamp(ts.getTime() + 10000)));
+    c.setNoteDbState(state.toString());
+    Delta delta = Delta.create(c.getId(), metaId(SHA2), noDrafts());
+    applyDelta(c, delta);
+    assertThat(NoteDbChangeState.parse(c))
+        .isEqualTo(
+            new NoteDbChangeState(
+                state.getChangeId(),
+                state.getPrimaryStorage(),
+                Optional.of(RefState.create(SHA2, ImmutableMap.of())),
+                state.getReadOnlyUntil()));
+  }
+
+  @Test
+  public void parseNoteDbPrimary() {
+    NoteDbChangeState state = parse(new Change.Id(1), "N");
+    assertThat(state.getPrimaryStorage()).isEqualTo(NOTE_DB);
+    assertThat(state.getRefState()).isEmpty();
+    assertThat(state.getReadOnlyUntil()).isEmpty();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void parseInvalidPrimaryStorage() {
+    parse(new Change.Id(1), "X");
+  }
+
+  @Test
+  public void applyDeltaToNoteDbPrimaryIsNoOp() throws Exception {
+    Change c = newChange();
+    c.setNoteDbState("N");
+    applyDelta(c, Delta.create(c.getId(), metaId(SHA1), drafts(new Account.Id(1001), SHA2)));
+    assertThat(c.getNoteDbState()).isEqualTo("N");
+  }
+
+  private static Change newChange() {
+    return TestChanges.newChange(new Project.NameKey("project"), new Account.Id(12345));
+  }
+
+  // Static factory methods to avoid type arguments when using as method args.
+
+  private static Optional<ObjectId> noMetaId() {
+    return Optional.empty();
+  }
+
+  private static Optional<ObjectId> metaId(ObjectId id) {
+    return Optional.of(id);
+  }
+
+  private static ImmutableMap<Account.Id, ObjectId> noDrafts() {
+    return ImmutableMap.of();
+  }
+
+  private static ImmutableMap<Account.Id, ObjectId> drafts(Object... args) {
+    checkArgument(args.length % 2 == 0);
+    ImmutableMap.Builder<Account.Id, ObjectId> b = ImmutableMap.builder();
+    for (int i = 0; i < args.length / 2; i++) {
+      b.put((Account.Id) args[2 * i], (ObjectId) args[2 * i + 1]);
+    }
+    return b.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
new file mode 100644
index 0000000..a21f5ba
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -0,0 +1,394 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.junit.Assert.fail;
+
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.google.common.util.concurrent.Runnables;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class RepoSequenceTest {
+  // Don't sleep in tests.
+  private static final Retryer<RefUpdate.Result> RETRYER =
+      RepoSequence.retryerBuilder().withBlockStrategy(t -> {}).build();
+
+  @Rule public ExpectedException exception = ExpectedException.none();
+
+  private InMemoryRepositoryManager repoManager;
+  private Project.NameKey project;
+
+  @Before
+  public void setUp() throws Exception {
+    repoManager = new InMemoryRepositoryManager();
+    project = new Project.NameKey("project");
+    repoManager.createRepository(project);
+  }
+
+  @Test
+  public void oneCaller() throws Exception {
+    int max = 20;
+    for (int batchSize = 1; batchSize <= 10; batchSize++) {
+      String name = "batch-size-" + batchSize;
+      RepoSequence s = newSequence(name, 1, batchSize);
+      for (int i = 1; i <= max; i++) {
+        try {
+          assertThat(s.next()).named("i=" + i + " for " + name).isEqualTo(i);
+        } catch (OrmException e) {
+          throw new AssertionError("failed batchSize=" + batchSize + ", i=" + i, e);
+        }
+      }
+      assertThat(s.acquireCount)
+          .named("acquireCount for " + name)
+          .isEqualTo(divCeil(max, batchSize));
+    }
+  }
+
+  @Test
+  public void oneCallerNoLoop() throws Exception {
+    RepoSequence s = newSequence("id", 1, 3);
+    assertThat(s.acquireCount).isEqualTo(0);
+
+    assertThat(s.next()).isEqualTo(1);
+    assertThat(s.acquireCount).isEqualTo(1);
+    assertThat(s.next()).isEqualTo(2);
+    assertThat(s.acquireCount).isEqualTo(1);
+    assertThat(s.next()).isEqualTo(3);
+    assertThat(s.acquireCount).isEqualTo(1);
+
+    assertThat(s.next()).isEqualTo(4);
+    assertThat(s.acquireCount).isEqualTo(2);
+    assertThat(s.next()).isEqualTo(5);
+    assertThat(s.acquireCount).isEqualTo(2);
+    assertThat(s.next()).isEqualTo(6);
+    assertThat(s.acquireCount).isEqualTo(2);
+
+    assertThat(s.next()).isEqualTo(7);
+    assertThat(s.acquireCount).isEqualTo(3);
+    assertThat(s.next()).isEqualTo(8);
+    assertThat(s.acquireCount).isEqualTo(3);
+    assertThat(s.next()).isEqualTo(9);
+    assertThat(s.acquireCount).isEqualTo(3);
+
+    assertThat(s.next()).isEqualTo(10);
+    assertThat(s.acquireCount).isEqualTo(4);
+  }
+
+  @Test
+  public void twoCallers() throws Exception {
+    RepoSequence s1 = newSequence("id", 1, 3);
+    RepoSequence s2 = newSequence("id", 1, 3);
+
+    // s1 acquires 1-3; s2 acquires 4-6.
+    assertThat(s1.next()).isEqualTo(1);
+    assertThat(s2.next()).isEqualTo(4);
+    assertThat(s1.next()).isEqualTo(2);
+    assertThat(s2.next()).isEqualTo(5);
+    assertThat(s1.next()).isEqualTo(3);
+    assertThat(s2.next()).isEqualTo(6);
+
+    // s2 acquires 7-9; s1 acquires 10-12.
+    assertThat(s2.next()).isEqualTo(7);
+    assertThat(s1.next()).isEqualTo(10);
+    assertThat(s2.next()).isEqualTo(8);
+    assertThat(s1.next()).isEqualTo(11);
+    assertThat(s2.next()).isEqualTo(9);
+    assertThat(s1.next()).isEqualTo(12);
+  }
+
+  @Test
+  public void populateEmptyRefWithStartValue() throws Exception {
+    RepoSequence s = newSequence("id", 1234, 10);
+    assertThat(s.next()).isEqualTo(1234);
+    assertThat(readBlob("id")).isEqualTo("1244");
+  }
+
+  @Test
+  public void startIsIgnoredIfRefIsPresent() throws Exception {
+    writeBlob("id", "1234");
+    RepoSequence s = newSequence("id", 3456, 10);
+    assertThat(s.next()).isEqualTo(1234);
+    assertThat(readBlob("id")).isEqualTo("1244");
+  }
+
+  @Test
+  public void retryOnLockFailure() throws Exception {
+    // Seed existing ref value.
+    writeBlob("id", "1");
+
+    AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
+    Runnable bgUpdate =
+        () -> {
+          if (!doneBgUpdate.getAndSet(true)) {
+            writeBlob("id", "1234");
+          }
+        };
+
+    RepoSequence s = newSequence("id", 1, 10, bgUpdate, RETRYER);
+    assertThat(doneBgUpdate.get()).isFalse();
+    assertThat(s.next()).isEqualTo(1234);
+    // Single acquire call that results in 2 ref reads.
+    assertThat(s.acquireCount).isEqualTo(1);
+    assertThat(doneBgUpdate.get()).isTrue();
+  }
+
+  @Test
+  public void failOnInvalidValue() throws Exception {
+    ObjectId id = writeBlob("id", "not a number");
+    exception.expect(OrmException.class);
+    exception.expectMessage("invalid value in refs/sequences/id blob at " + id.name());
+    newSequence("id", 1, 3).next();
+  }
+
+  @Test
+  public void failOnWrongType() throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<Repository> tr = new TestRepository<>(repo);
+      tr.branch(RefNames.REFS_SEQUENCES + "id").commit().create();
+      try {
+        newSequence("id", 1, 3).next();
+        fail();
+      } catch (OrmException e) {
+        assertThat(e.getCause()).isInstanceOf(ExecutionException.class);
+        assertThat(e.getCause().getCause()).isInstanceOf(IncorrectObjectTypeException.class);
+      }
+    }
+  }
+
+  @Test
+  public void failAfterRetryerGivesUp() throws Exception {
+    AtomicInteger bgCounter = new AtomicInteger(1234);
+    RepoSequence s =
+        newSequence(
+            "id",
+            1,
+            10,
+            () -> writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000))),
+            RetryerBuilder.<RefUpdate.Result>newBuilder()
+                .withStopStrategy(StopStrategies.stopAfterAttempt(3))
+                .build());
+    exception.expect(OrmException.class);
+    exception.expectMessage("failed to update refs/sequences/id: LOCK_FAILURE");
+    s.next();
+  }
+
+  @Test
+  public void nextWithCountOneCaller() throws Exception {
+    RepoSequence s = newSequence("id", 1, 3);
+    assertThat(s.next(2)).containsExactly(1, 2).inOrder();
+    assertThat(s.acquireCount).isEqualTo(1);
+    assertThat(s.next(2)).containsExactly(3, 4).inOrder();
+    assertThat(s.acquireCount).isEqualTo(2);
+    assertThat(s.next(2)).containsExactly(5, 6).inOrder();
+    assertThat(s.acquireCount).isEqualTo(2);
+
+    assertThat(s.next(3)).containsExactly(7, 8, 9).inOrder();
+    assertThat(s.acquireCount).isEqualTo(3);
+    assertThat(s.next(3)).containsExactly(10, 11, 12).inOrder();
+    assertThat(s.acquireCount).isEqualTo(4);
+    assertThat(s.next(3)).containsExactly(13, 14, 15).inOrder();
+    assertThat(s.acquireCount).isEqualTo(5);
+
+    assertThat(s.next(7)).containsExactly(16, 17, 18, 19, 20, 21, 22).inOrder();
+    assertThat(s.acquireCount).isEqualTo(6);
+    assertThat(s.next(7)).containsExactly(23, 24, 25, 26, 27, 28, 29).inOrder();
+    assertThat(s.acquireCount).isEqualTo(7);
+    assertThat(s.next(7)).containsExactly(30, 31, 32, 33, 34, 35, 36).inOrder();
+    assertThat(s.acquireCount).isEqualTo(8);
+  }
+
+  @Test
+  public void nextWithCountMultipleCallers() throws Exception {
+    RepoSequence s1 = newSequence("id", 1, 3);
+    RepoSequence s2 = newSequence("id", 1, 4);
+
+    assertThat(s1.next(2)).containsExactly(1, 2).inOrder();
+    assertThat(s1.acquireCount).isEqualTo(1);
+
+    // s1 hasn't exhausted its last batch.
+    assertThat(s2.next(2)).containsExactly(4, 5).inOrder();
+    assertThat(s2.acquireCount).isEqualTo(1);
+
+    // s1 acquires again to cover this request, plus a whole new batch.
+    assertThat(s1.next(3)).containsExactly(3, 8, 9);
+    assertThat(s1.acquireCount).isEqualTo(2);
+
+    // s2 hasn't exhausted its last batch, do so now.
+    assertThat(s2.next(2)).containsExactly(6, 7);
+    assertThat(s2.acquireCount).isEqualTo(1);
+  }
+
+  @Test
+  public void increaseTo() throws Exception {
+    // Seed existing ref value.
+    writeBlob("id", "1");
+
+    RepoSequence s = newSequence("id", 1, 10);
+
+    s.increaseTo(2);
+    assertThat(s.next()).isEqualTo(2);
+  }
+
+  @Test
+  public void increaseToLowerValueIsIgnored() throws Exception {
+    // Seed existing ref value.
+    writeBlob("id", "2");
+
+    RepoSequence s = newSequence("id", 1, 10);
+
+    s.increaseTo(1);
+    assertThat(s.next()).isEqualTo(2);
+  }
+
+  @Test
+  public void increaseToRetryOnLockFailureV1() throws Exception {
+    // Seed existing ref value.
+    writeBlob("id", "1");
+
+    AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
+    Runnable bgUpdate =
+        () -> {
+          if (!doneBgUpdate.getAndSet(true)) {
+            writeBlob("id", "2");
+          }
+        };
+
+    RepoSequence s = newSequence("id", 1, 10, bgUpdate, RETRYER);
+    assertThat(doneBgUpdate.get()).isFalse();
+
+    // Increase the value to 3. The background thread increases the value to 2, which makes the
+    // increase to value 3 fail once with LockFailure. The increase to 3 is then retried and is
+    // expected to succeed.
+    s.increaseTo(3);
+    assertThat(s.next()).isEqualTo(3);
+
+    assertThat(doneBgUpdate.get()).isTrue();
+  }
+
+  @Test
+  public void increaseToRetryOnLockFailureV2() throws Exception {
+    // Seed existing ref value.
+    writeBlob("id", "1");
+
+    AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
+    Runnable bgUpdate =
+        () -> {
+          if (!doneBgUpdate.getAndSet(true)) {
+            writeBlob("id", "3");
+          }
+        };
+
+    RepoSequence s = newSequence("id", 1, 10, bgUpdate, RETRYER);
+    assertThat(doneBgUpdate.get()).isFalse();
+
+    // Increase the value to 2. The background thread increases the value to 3, which makes the
+    // increase to value 2 fail with LockFailure. The increase to 2 is then not retried because the
+    // current value is already higher and it should be preserved.
+    s.increaseTo(2);
+    assertThat(s.next()).isEqualTo(3);
+
+    assertThat(doneBgUpdate.get()).isTrue();
+  }
+
+  @Test
+  public void increaseToFailAfterRetryerGivesUp() throws Exception {
+    AtomicInteger bgCounter = new AtomicInteger(1234);
+    RepoSequence s =
+        newSequence(
+            "id",
+            1,
+            10,
+            () -> writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000))),
+            RetryerBuilder.<RefUpdate.Result>newBuilder()
+                .withStopStrategy(StopStrategies.stopAfterAttempt(3))
+                .build());
+    exception.expect(OrmException.class);
+    exception.expectMessage("failed to update refs/sequences/id: LOCK_FAILURE");
+    s.increaseTo(2);
+  }
+
+  private RepoSequence newSequence(String name, int start, int batchSize) {
+    return newSequence(name, start, batchSize, Runnables.doNothing(), RETRYER);
+  }
+
+  private RepoSequence newSequence(
+      String name,
+      final int start,
+      int batchSize,
+      Runnable afterReadRef,
+      Retryer<RefUpdate.Result> retryer) {
+    return new RepoSequence(
+        repoManager,
+        GitReferenceUpdated.DISABLED,
+        project,
+        name,
+        () -> start,
+        batchSize,
+        afterReadRef,
+        retryer);
+  }
+
+  private ObjectId writeBlob(String sequenceName, String value) {
+    String refName = RefNames.REFS_SEQUENCES + sequenceName;
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId newId = ins.insert(OBJ_BLOB, value.getBytes(UTF_8));
+      ins.flush();
+      RefUpdate ru = repo.updateRef(refName);
+      ru.setNewObjectId(newId);
+      assertThat(ru.forceUpdate()).isAnyOf(RefUpdate.Result.NEW, RefUpdate.Result.FORCED);
+      return newId;
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private String readBlob(String sequenceName) throws Exception {
+    String refName = RefNames.REFS_SEQUENCES + sequenceName;
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId id = repo.exactRef(refName).getObjectId();
+      return new String(rw.getObjectReader().open(id).getCachedBytes(), UTF_8);
+    }
+  }
+
+  private static long divCeil(float a, float b) {
+    return Math.round(Math.ceil(a / b));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java b/javatests/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java
new file mode 100644
index 0000000..5a1ec2b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java
@@ -0,0 +1,231 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Collections2;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+import org.junit.Before;
+import org.junit.Test;
+
+public class EventSorterTest {
+  private class TestEvent extends Event {
+    protected TestEvent(Timestamp when) {
+      super(
+          new PatchSet.Id(new Change.Id(1), 1),
+          new Account.Id(1000),
+          new Account.Id(1000),
+          when,
+          changeCreatedOn,
+          null);
+    }
+
+    @Override
+    boolean uniquePerUpdate() {
+      return false;
+    }
+
+    @Override
+    void apply(ChangeUpdate update) {
+      throw new UnsupportedOperationException();
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public String toString() {
+      return "E{" + when.getSeconds() + '}';
+    }
+  }
+
+  private Timestamp changeCreatedOn;
+
+  @Before
+  public void setUp() {
+    TestTimeUtil.resetWithClockStep(10, TimeUnit.SECONDS);
+    changeCreatedOn = TimeUtil.nowTs();
+  }
+
+  @Test
+  public void naturalSort() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+
+    for (List<Event> events : Collections2.permutations(events(e1, e2, e3))) {
+      assertSorted(events, events(e1, e2, e3));
+    }
+  }
+
+  @Test
+  public void topoSortOneDep() {
+    List<Event> es;
+
+    // Input list is 0,1,2
+
+    // 0 depends on 1 => 1,0,2
+    es = threeEventsOneDep(0, 1);
+    assertSorted(es, events(es, 1, 0, 2));
+
+    // 1 depends on 0 => 0,1,2
+    es = threeEventsOneDep(1, 0);
+    assertSorted(es, events(es, 0, 1, 2));
+
+    // 0 depends on 2 => 1,2,0
+    es = threeEventsOneDep(0, 2);
+    assertSorted(es, events(es, 1, 2, 0));
+
+    // 2 depends on 0 => 0,1,2
+    es = threeEventsOneDep(2, 0);
+    assertSorted(es, events(es, 0, 1, 2));
+
+    // 1 depends on 2 => 0,2,1
+    es = threeEventsOneDep(1, 2);
+    assertSorted(es, events(es, 0, 2, 1));
+
+    // 2 depends on 1 => 0,1,2
+    es = threeEventsOneDep(2, 1);
+    assertSorted(es, events(es, 0, 1, 2));
+  }
+
+  private List<Event> threeEventsOneDep(int depFromIdx, int depOnIdx) {
+    List<Event> events =
+        Lists.newArrayList(
+            new TestEvent(TimeUtil.nowTs()),
+            new TestEvent(TimeUtil.nowTs()),
+            new TestEvent(TimeUtil.nowTs()));
+    events.get(depFromIdx).addDep(events.get(depOnIdx));
+    return events;
+  }
+
+  @Test
+  public void lastEventDependsOnFirstEvent() {
+    List<Event> events = new ArrayList<>();
+    for (int i = 0; i < 20; i++) {
+      events.add(new TestEvent(TimeUtil.nowTs()));
+    }
+    events.get(events.size() - 1).addDep(events.get(0));
+    assertSorted(events, events);
+  }
+
+  @Test
+  public void firstEventDependsOnLastEvent() {
+    List<Event> events = new ArrayList<>();
+    for (int i = 0; i < 20; i++) {
+      events.add(new TestEvent(TimeUtil.nowTs()));
+    }
+    events.get(0).addDep(events.get(events.size() - 1));
+
+    List<Event> expected = new ArrayList<>();
+    expected.addAll(events.subList(1, events.size()));
+    expected.add(events.get(0));
+    assertSorted(events, expected);
+  }
+
+  @Test
+  public void topoSortChainOfDeps() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+    Event e4 = new TestEvent(TimeUtil.nowTs());
+    e1.addDep(e2);
+    e2.addDep(e3);
+    e3.addDep(e4);
+
+    assertSorted(events(e1, e2, e3, e4), events(e4, e3, e2, e1));
+  }
+
+  @Test
+  public void topoSortMultipleDeps() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+    Event e4 = new TestEvent(TimeUtil.nowTs());
+    e1.addDep(e2);
+    e1.addDep(e4);
+    e2.addDep(e3);
+
+    // Processing 3 pops 2, processing 4 pops 1.
+    assertSorted(events(e2, e3, e1, e4), events(e3, e2, e4, e1));
+  }
+
+  @Test
+  public void topoSortMultipleDepsPreservesNaturalOrder() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+    Event e4 = new TestEvent(TimeUtil.nowTs());
+    e1.addDep(e4);
+    e2.addDep(e4);
+    e3.addDep(e4);
+
+    // Processing 4 pops 1, 2, 3 in natural order.
+    assertSorted(events(e4, e3, e2, e1), events(e4, e1, e2, e3));
+  }
+
+  @Test
+  public void topoSortCycle() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+
+    // Implementation is not really defined, but infinite looping would be bad.
+    // According to current implementation details, 2 pops 1, 1 pops 2 which was
+    // already seen.
+    assertSorted(events(e2, e1), events(e1, e2));
+  }
+
+  @Test
+  public void topoSortDepNotInInputList() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+    e1.addDep(e3);
+
+    List<Event> events = events(e2, e1);
+    try {
+      new EventSorter(events).sort();
+      fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  private static List<Event> events(Event... es) {
+    return Lists.newArrayList(es);
+  }
+
+  private static List<Event> events(List<Event> in, Integer... indexes) {
+    return Stream.of(indexes).map(in::get).collect(toList());
+  }
+
+  private static void assertSorted(List<Event> unsorted, List<Event> expected) {
+    List<Event> actual = new ArrayList<>(unsorted);
+    new EventSorter(actual).sort();
+    assertThat(actual).named("sorted" + unsorted).isEqualTo(expected);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/patch/IntraLineLoaderTest.java b/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/patch/IntraLineLoaderTest.java
rename to javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListEntryTest.java b/javatests/com/google/gerrit/server/patch/PatchListEntryTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListEntryTest.java
rename to javatests/com/google/gerrit/server/patch/PatchListEntryTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListTest.java b/javatests/com/google/gerrit/server/patch/PatchListTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListTest.java
rename to javatests/com/google/gerrit/server/patch/PatchListTest.java
diff --git a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
new file mode 100644
index 0000000..267c622
--- /dev/null
+++ b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
@@ -0,0 +1,268 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.restapi.project.CommitsCollection;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testing.InMemoryDatabase;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Unit tests for {@link CommitsCollection}. */
+public class CommitsCollectionTest {
+  @Inject private AccountManager accountManager;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+  @Inject private InMemoryDatabase schemaFactory;
+  @Inject private InMemoryRepositoryManager repoManager;
+  @Inject private SchemaCreator schemaCreator;
+  @Inject private ThreadLocalRequestContext requestContext;
+  @Inject protected ProjectCache projectCache;
+  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject protected AllProjectsName allProjects;
+  @Inject protected GroupCache groupCache;
+  @Inject private CommitsCollection commits;
+
+  private LifecycleManager lifecycle;
+  private ReviewDb db;
+  private TestRepository<InMemoryRepository> repo;
+  private ProjectConfig project;
+  private IdentifiedUser user;
+  private AccountGroup.UUID admins;
+
+  @Before
+  public void setUp() throws Exception {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+    lifecycle = new LifecycleManager();
+    lifecycle.add(injector);
+    lifecycle.start();
+
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+    // Need to create at least one user to be admin before creating a "normal"
+    // registered user.
+    // See AccountManager#create().
+    accountManager.authenticate(AuthRequest.forUser("admin")).getAccountId();
+    admins = groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID();
+    setUpPermissions();
+
+    Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
+    user = userFactory.create(userId);
+
+    Project.NameKey name = new Project.NameKey("project");
+    InMemoryRepository inMemoryRepo = repoManager.createRepository(name);
+    project = new ProjectConfig(name);
+    project.load(inMemoryRepo);
+    repo = new TestRepository<>(inMemoryRepo);
+
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return user;
+          }
+
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
+  }
+
+  @After
+  public void tearDown() {
+    if (repo != null) {
+      repo.getRepository().close();
+    }
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
+  }
+
+  @Test
+  public void canReadCommitWhenAllRefsVisible() throws Exception {
+    allow(project, READ, REGISTERED_USERS, "refs/*");
+    ObjectId id = repo.branch("master").commit().create();
+    ProjectState state = readProjectState();
+    RevWalk rw = repo.getRevWalk();
+    Repository r = repo.getRepository();
+
+    assertTrue(commits.canRead(state, r, rw.parseCommit(id)));
+  }
+
+  @Test
+  public void canReadCommitIfTwoRefsVisible() throws Exception {
+    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
+    allow(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+
+    ObjectId id1 = repo.branch("branch1").commit().create();
+    ObjectId id2 = repo.branch("branch2").commit().create();
+
+    ProjectState state = readProjectState();
+    RevWalk rw = repo.getRevWalk();
+    Repository r = repo.getRepository();
+
+    assertTrue(commits.canRead(state, r, rw.parseCommit(id1)));
+    assertTrue(commits.canRead(state, r, rw.parseCommit(id2)));
+  }
+
+  @Test
+  public void canReadCommitIfRefVisible() throws Exception {
+    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
+    deny(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+
+    ObjectId id1 = repo.branch("branch1").commit().create();
+    ObjectId id2 = repo.branch("branch2").commit().create();
+
+    ProjectState state = readProjectState();
+    RevWalk rw = repo.getRevWalk();
+    Repository r = repo.getRepository();
+
+    assertTrue(commits.canRead(state, r, rw.parseCommit(id1)));
+    assertFalse(commits.canRead(state, r, rw.parseCommit(id2)));
+  }
+
+  @Test
+  public void canReadCommitIfReachableFromVisibleRef() throws Exception {
+    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
+    deny(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+
+    RevCommit parent1 = repo.commit().create();
+    repo.branch("branch1").commit().parent(parent1).create();
+
+    RevCommit parent2 = repo.commit().create();
+    repo.branch("branch2").commit().parent(parent2).create();
+
+    ProjectState state = readProjectState();
+    RevWalk rw = repo.getRevWalk();
+    Repository r = repo.getRepository();
+    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
+    assertFalse(commits.canRead(state, r, rw.parseCommit(parent2)));
+  }
+
+  @Test
+  public void cannotReadAfterRollbackWithRestrictedRead() throws Exception {
+    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
+
+    RevCommit parent1 = repo.commit().create();
+    ObjectId id1 = repo.branch("branch1").commit().parent(parent1).create();
+
+    ProjectState state = readProjectState();
+    RevWalk rw = repo.getRevWalk();
+    Repository r = repo.getRepository();
+
+    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
+    assertTrue(commits.canRead(state, r, rw.parseCommit(id1)));
+
+    repo.branch("branch1").update(parent1);
+    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
+    assertFalse(commits.canRead(state, r, rw.parseCommit(id1)));
+  }
+
+  @Test
+  public void canReadAfterRollbackWithAllRefsVisible() throws Exception {
+    allow(project, READ, REGISTERED_USERS, "refs/*");
+
+    RevCommit parent1 = repo.commit().create();
+    ObjectId id1 = repo.branch("branch1").commit().parent(parent1).create();
+
+    ProjectState state = readProjectState();
+    RevWalk rw = repo.getRevWalk();
+    Repository r = repo.getRepository();
+
+    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
+    assertTrue(commits.canRead(state, r, rw.parseCommit(id1)));
+
+    repo.branch("branch1").update(parent1);
+    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
+    assertFalse(commits.canRead(state, r, rw.parseCommit(id1)));
+  }
+
+  private ProjectState readProjectState() throws Exception {
+    return projectCache.get(project.getName());
+  }
+
+  protected void allow(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
+      throws Exception {
+    Util.allow(project, permission, id, ref);
+    saveProjectConfig(project);
+  }
+
+  protected void deny(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
+      throws Exception {
+    Util.deny(project, permission, id, ref);
+    saveProjectConfig(project);
+  }
+
+  protected void saveProjectConfig(ProjectConfig cfg) throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(cfg.getName())) {
+      cfg.commit(md);
+    }
+    projectCache.evict(cfg.getProject());
+  }
+
+  private void setUpPermissions() throws Exception {
+    // Remove read permissions for all users besides admin, because by default
+    // Anonymous user group has ALLOW READ permission in refs/*.
+    // This method is idempotent, so is safe to call on every test setup.
+    ProjectConfig pc = projectCache.checkedGet(allProjects).getConfig();
+    for (AccessSection sec : pc.getAccessSections()) {
+      sec.removePermission(Permission.READ);
+    }
+    allow(pc, Permission.READ, admins, "refs/*");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/project/RefControlTest.java b/javatests/com/google/gerrit/server/project/RefControlTest.java
new file mode 100644
index 0000000..f57a3d4
--- /dev/null
+++ b/javatests/com/google/gerrit/server/project/RefControlTest.java
@@ -0,0 +1,908 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.common.data.Permission.EDIT_TOPIC_NAME;
+import static com.google.gerrit.common.data.Permission.LABEL;
+import static com.google.gerrit.common.data.Permission.OWNER;
+import static com.google.gerrit.common.data.Permission.PUSH;
+import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.common.data.Permission.SUBMIT;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.Util.ADMIN;
+import static com.google.gerrit.server.project.testing.Util.DEVS;
+import static com.google.gerrit.server.project.testing.Util.allow;
+import static com.google.gerrit.server.project.testing.Util.block;
+import static com.google.gerrit.server.project.testing.Util.deny;
+import static com.google.gerrit.server.project.testing.Util.doNotInherit;
+import static com.google.gerrit.testing.InMemoryRepositoryManager.newRepository;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.CapabilityCollection;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.ListGroupMembership;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.rules.PrologEnvironment;
+import com.google.gerrit.server.rules.RulesCache;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testing.InMemoryDatabase;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class RefControlTest {
+  private void assertAdminsAreOwnersAndDevsAreNot() {
+    ProjectControl uBlah = user(local, DEVS);
+    ProjectControl uAdmin = user(local, DEVS, ADMIN);
+
+    assertThat(uBlah.isOwner()).named("not owner").isFalse();
+    assertThat(uAdmin.isOwner()).named("is owner").isTrue();
+  }
+
+  private void assertOwner(String ref, ProjectControl u) {
+    assertThat(u.controlForRef(ref).isOwner()).named("OWN " + ref).isTrue();
+  }
+
+  private void assertNotOwner(ProjectControl u) {
+    assertThat(u.isOwner()).named("not owner").isFalse();
+  }
+
+  private void assertNotOwner(String ref, ProjectControl u) {
+    assertThat(u.controlForRef(ref).isOwner()).named("NOT OWN " + ref).isFalse();
+  }
+
+  private void assertCanAccess(ProjectControl u) {
+    boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
+    assertThat(access).named("can access").isTrue();
+  }
+
+  private void assertAccessDenied(ProjectControl u) {
+    boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
+    assertThat(access).named("cannot access").isFalse();
+  }
+
+  private void assertCanRead(String ref, ProjectControl u) {
+    assertThat(u.controlForRef(ref).isVisible()).named("can read " + ref).isTrue();
+  }
+
+  private void assertCannotRead(String ref, ProjectControl u) {
+    assertThat(u.controlForRef(ref).isVisible()).named("cannot read " + ref).isFalse();
+  }
+
+  private void assertCanSubmit(String ref, ProjectControl u) {
+    assertThat(u.controlForRef(ref).canSubmit(false)).named("can submit " + ref).isTrue();
+  }
+
+  private void assertCannotSubmit(String ref, ProjectControl u) {
+    assertThat(u.controlForRef(ref).canSubmit(false)).named("can submit " + ref).isFalse();
+  }
+
+  private void assertCanUpload(ProjectControl u) {
+    assertThat(u.canPushToAtLeastOneRef()).named("can upload").isTrue();
+  }
+
+  private void assertCreateChange(String ref, ProjectControl u) {
+    boolean create = u.asForProject().ref(ref).testOrFalse(RefPermission.CREATE_CHANGE);
+    assertThat(create).named("can create change " + ref).isTrue();
+  }
+
+  private void assertCannotUpload(ProjectControl u) {
+    assertThat(u.canPushToAtLeastOneRef()).named("cannot upload").isFalse();
+  }
+
+  private void assertCannotCreateChange(String ref, ProjectControl u) {
+    boolean create = u.asForProject().ref(ref).testOrFalse(RefPermission.CREATE_CHANGE);
+    assertThat(create).named("cannot create change " + ref).isFalse();
+  }
+
+  private void assertBlocked(String p, String ref, ProjectControl u) {
+    assertThat(u.controlForRef(ref).isBlocked(p)).named(p + " is blocked for " + ref).isTrue();
+  }
+
+  private void assertNotBlocked(String p, String ref, ProjectControl u) {
+    assertThat(u.controlForRef(ref).isBlocked(p)).named(p + " is blocked for " + ref).isFalse();
+  }
+
+  private void assertCanUpdate(String ref, ProjectControl u) {
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
+    assertThat(update).named("can update " + ref).isTrue();
+  }
+
+  private void assertCannotUpdate(String ref, ProjectControl u) {
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
+    assertThat(update).named("cannot update " + ref).isFalse();
+  }
+
+  private void assertCanForceUpdate(String ref, ProjectControl u) {
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
+    assertThat(update).named("can force push " + ref).isTrue();
+  }
+
+  private void assertCannotForceUpdate(String ref, ProjectControl u) {
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
+    assertThat(update).named("cannot force push " + ref).isFalse();
+  }
+
+  private void assertCanVote(int score, PermissionRange range) {
+    assertThat(range.contains(score)).named("can vote " + score).isTrue();
+  }
+
+  private void assertCannotVote(int score, PermissionRange range) {
+    assertThat(range.contains(score)).named("cannot vote " + score).isFalse();
+  }
+
+  private final AllProjectsName allProjectsName =
+      new AllProjectsName(AllProjectsNameProvider.DEFAULT);
+  private final AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
+  private final AccountGroup.UUID fixers = new AccountGroup.UUID("test.fixers");
+  private final Map<Project.NameKey, ProjectState> all = new HashMap<>();
+  private Project.NameKey localKey = new Project.NameKey("local");
+  private ProjectConfig local;
+  private Project.NameKey parentKey = new Project.NameKey("parent");
+  private ProjectConfig parent;
+  private InMemoryRepositoryManager repoManager;
+  private ProjectCache projectCache;
+  private PermissionCollection.Factory sectionSorter;
+  private ChangeControl.Factory changeControlFactory;
+  private ReviewDb db;
+
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private CapabilityCollection.Factory capabilityCollectionFactory;
+  @Inject private SchemaCreator schemaCreator;
+  @Inject private SingleVersionListener singleVersionListener;
+  @Inject private InMemoryDatabase schemaFactory;
+  @Inject private ThreadLocalRequestContext requestContext;
+  @Inject private ProjectControl.Factory projectControlFactory;
+
+  @Before
+  public void setUp() throws Exception {
+    repoManager = new InMemoryRepositoryManager();
+    projectCache =
+        new ProjectCache() {
+          @Override
+          public ProjectState getAllProjects() {
+            return get(allProjectsName);
+          }
+
+          @Override
+          public ProjectState getAllUsers() {
+            return null;
+          }
+
+          @Override
+          public ProjectState get(Project.NameKey projectName) {
+            return all.get(projectName);
+          }
+
+          @Override
+          public void evict(Project p) {}
+
+          @Override
+          public void remove(Project p) {}
+
+          @Override
+          public ImmutableSortedSet<Project.NameKey> all() {
+            return ImmutableSortedSet.of();
+          }
+
+          @Override
+          public ImmutableSortedSet<Project.NameKey> byName(String prefix) {
+            return ImmutableSortedSet.of();
+          }
+
+          @Override
+          public void onCreateProject(Project.NameKey newProjectName) {}
+
+          @Override
+          public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
+            return Collections.emptySet();
+          }
+
+          @Override
+          public ProjectState checkedGet(Project.NameKey projectName) throws IOException {
+            return all.get(projectName);
+          }
+
+          @Override
+          public void evict(Project.NameKey p) {}
+        };
+
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+
+    try {
+      Repository repo = repoManager.createRepository(allProjectsName);
+      ProjectConfig allProjects = new ProjectConfig(new Project.NameKey(allProjectsName.get()));
+      allProjects.load(repo);
+      LabelType cr = Util.codeReview();
+      allProjects.getLabelSections().put(cr.getName(), cr);
+      add(allProjects);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new RuntimeException(e);
+    }
+
+    db = schemaFactory.open();
+    singleVersionListener.start();
+    try {
+      schemaCreator.create(db);
+    } finally {
+      singleVersionListener.stop();
+    }
+
+    Cache<SectionSortCache.EntryKey, SectionSortCache.EntryVal> c =
+        CacheBuilder.newBuilder().build();
+    sectionSorter = new PermissionCollection.Factory(new SectionSortCache(c));
+
+    parent = new ProjectConfig(parentKey);
+    parent.load(newRepository(parentKey));
+    add(parent);
+
+    local = new ProjectConfig(localKey);
+    local.load(newRepository(localKey));
+    add(local);
+    local.getProject().setParentName(parentKey);
+
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return null;
+          }
+
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
+
+    changeControlFactory = injector.getInstance(ChangeControl.Factory.class);
+  }
+
+  @After
+  public void tearDown() {
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
+  }
+
+  @Test
+  public void ownerProject() {
+    allow(local, OWNER, ADMIN, "refs/*");
+
+    assertAdminsAreOwnersAndDevsAreNot();
+  }
+
+  @Test
+  public void denyOwnerProject() {
+    allow(local, OWNER, ADMIN, "refs/*");
+    deny(local, OWNER, DEVS, "refs/*");
+
+    assertAdminsAreOwnersAndDevsAreNot();
+  }
+
+  @Test
+  public void blockOwnerProject() {
+    allow(local, OWNER, ADMIN, "refs/*");
+    block(local, OWNER, DEVS, "refs/*");
+
+    assertAdminsAreOwnersAndDevsAreNot();
+  }
+
+  @Test
+  public void branchDelegation1() {
+    allow(local, OWNER, ADMIN, "refs/*");
+    allow(local, OWNER, DEVS, "refs/heads/x/*");
+
+    ProjectControl uDev = user(local, DEVS);
+    assertNotOwner(uDev);
+
+    assertOwner("refs/heads/x/*", uDev);
+    assertOwner("refs/heads/x/y", uDev);
+    assertOwner("refs/heads/x/y/*", uDev);
+
+    assertNotOwner("refs/*", uDev);
+    assertNotOwner("refs/heads/master", uDev);
+  }
+
+  @Test
+  public void branchDelegation2() {
+    allow(local, OWNER, ADMIN, "refs/*");
+    allow(local, OWNER, DEVS, "refs/heads/x/*");
+    allow(local, OWNER, fixers, "refs/heads/x/y/*");
+    doNotInherit(local, OWNER, "refs/heads/x/y/*");
+
+    ProjectControl uDev = user(local, DEVS);
+    assertNotOwner(uDev);
+
+    assertOwner("refs/heads/x/*", uDev);
+    assertOwner("refs/heads/x/y", uDev);
+    assertOwner("refs/heads/x/y/*", uDev);
+    assertNotOwner("refs/*", uDev);
+    assertNotOwner("refs/heads/master", uDev);
+
+    ProjectControl uFix = user(local, fixers);
+    assertNotOwner(uFix);
+
+    assertOwner("refs/heads/x/y/*", uFix);
+    assertOwner("refs/heads/x/y/bar", uFix);
+    assertNotOwner("refs/heads/x/*", uFix);
+    assertNotOwner("refs/heads/x/y", uFix);
+    assertNotOwner("refs/*", uFix);
+    assertNotOwner("refs/heads/master", uFix);
+  }
+
+  @Test
+  public void inheritRead_SingleBranchDeniesUpload() {
+    allow(parent, READ, REGISTERED_USERS, "refs/*");
+    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
+    allow(local, READ, REGISTERED_USERS, "refs/heads/foobar");
+    doNotInherit(local, READ, "refs/heads/foobar");
+    doNotInherit(local, PUSH, "refs/for/refs/heads/foobar");
+
+    ProjectControl u = user(local);
+    assertCanUpload(u);
+    assertCreateChange("refs/heads/master", u);
+    assertCannotCreateChange("refs/heads/foobar", u);
+  }
+
+  @Test
+  public void blockPushDrafts() {
+    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
+    block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
+
+    ProjectControl u = user(local);
+    assertCreateChange("refs/heads/master", u);
+    assertBlocked(PUSH, "refs/drafts/refs/heads/master", u);
+  }
+
+  @Test
+  public void blockPushDraftsUnblockAdmin() {
+    block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
+    allow(parent, PUSH, ADMIN, "refs/drafts/*");
+
+    ProjectControl u = user(local);
+    ProjectControl a = user(local, "a", ADMIN);
+    assertBlocked(PUSH, "refs/drafts/refs/heads/master", u);
+    assertNotBlocked(PUSH, "refs/drafts/refs/heads/master", a);
+  }
+
+  @Test
+  public void inheritRead_SingleBranchDoesNotOverrideInherited() {
+    allow(parent, READ, REGISTERED_USERS, "refs/*");
+    allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
+    allow(local, READ, REGISTERED_USERS, "refs/heads/foobar");
+
+    ProjectControl u = user(local);
+    assertCanUpload(u);
+    assertCreateChange("refs/heads/master", u);
+    assertCreateChange("refs/heads/foobar", u);
+  }
+
+  @Test
+  public void inheritDuplicateSections() throws Exception {
+    allow(parent, READ, ADMIN, "refs/*");
+    allow(local, READ, DEVS, "refs/heads/*");
+    assertCanAccess(user(local, "a", ADMIN));
+
+    local = new ProjectConfig(localKey);
+    local.load(newRepository(localKey));
+    local.getProject().setParentName(parentKey);
+    allow(local, READ, DEVS, "refs/*");
+    assertCanAccess(user(local, "d", DEVS));
+  }
+
+  @Test
+  public void inheritRead_OverrideWithDeny() {
+    allow(parent, READ, REGISTERED_USERS, "refs/*");
+    deny(local, READ, REGISTERED_USERS, "refs/*");
+
+    assertAccessDenied(user(local));
+  }
+
+  @Test
+  public void inheritRead_AppendWithDenyOfRef() {
+    allow(parent, READ, REGISTERED_USERS, "refs/*");
+    deny(local, READ, REGISTERED_USERS, "refs/heads/*");
+
+    ProjectControl u = user(local);
+    assertCanAccess(u);
+    assertCanRead("refs/master", u);
+    assertCanRead("refs/tags/foobar", u);
+    assertCanRead("refs/heads/master", u);
+  }
+
+  @Test
+  public void inheritRead_OverridesAndDeniesOfRef() {
+    allow(parent, READ, REGISTERED_USERS, "refs/*");
+    deny(local, READ, REGISTERED_USERS, "refs/*");
+    allow(local, READ, REGISTERED_USERS, "refs/heads/*");
+
+    ProjectControl u = user(local);
+    assertCanAccess(u);
+    assertCannotRead("refs/foobar", u);
+    assertCannotRead("refs/tags/foobar", u);
+    assertCanRead("refs/heads/foobar", u);
+  }
+
+  @Test
+  public void inheritSubmit_OverridesAndDeniesOfRef() {
+    allow(parent, SUBMIT, REGISTERED_USERS, "refs/*");
+    deny(local, SUBMIT, REGISTERED_USERS, "refs/*");
+    allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
+
+    ProjectControl u = user(local);
+    assertCannotSubmit("refs/foobar", u);
+    assertCannotSubmit("refs/tags/foobar", u);
+    assertCanSubmit("refs/heads/foobar", u);
+  }
+
+  @Test
+  public void cannotUploadToAnyRef() {
+    allow(parent, READ, REGISTERED_USERS, "refs/*");
+    allow(local, READ, DEVS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/for/refs/heads/*");
+
+    ProjectControl u = user(local);
+    assertCannotUpload(u);
+    assertCannotCreateChange("refs/heads/master", u);
+  }
+
+  @Test
+  public void usernamePatternCanUploadToAnyRef() {
+    allow(local, PUSH, REGISTERED_USERS, "refs/heads/users/${username}/*");
+    ProjectControl u = user(local, "a-registered-user");
+    assertCanUpload(u);
+  }
+
+  @Test
+  public void usernamePatternNonRegex() {
+    allow(local, READ, DEVS, "refs/sb/${username}/heads/*");
+
+    ProjectControl u = user(local, "u", DEVS);
+    ProjectControl d = user(local, "d", DEVS);
+    assertCannotRead("refs/sb/d/heads/foobar", u);
+    assertCanRead("refs/sb/d/heads/foobar", d);
+  }
+
+  @Test
+  public void usernamePatternWithRegex() {
+    allow(local, READ, DEVS, "^refs/sb/${username}/heads/.*");
+
+    ProjectControl u = user(local, "d.v", DEVS);
+    ProjectControl d = user(local, "dev", DEVS);
+    assertCannotRead("refs/sb/dev/heads/foobar", u);
+    assertCanRead("refs/sb/dev/heads/foobar", d);
+  }
+
+  @Test
+  public void usernameEmailPatternWithRegex() {
+    allow(local, READ, DEVS, "^refs/sb/${username}/heads/.*");
+
+    ProjectControl u = user(local, "d.v@ger-rit.org", DEVS);
+    ProjectControl d = user(local, "dev@ger-rit.org", DEVS);
+    assertCannotRead("refs/sb/dev@ger-rit.org/heads/foobar", u);
+    assertCanRead("refs/sb/dev@ger-rit.org/heads/foobar", d);
+  }
+
+  @Test
+  public void sortWithRegex() {
+    allow(local, READ, DEVS, "^refs/heads/.*");
+    allow(parent, READ, ANONYMOUS_USERS, "^refs/heads/.*-QA-.*");
+
+    ProjectControl u = user(local, DEVS);
+    ProjectControl d = user(local, DEVS);
+    assertCanRead("refs/heads/foo-QA-bar", u);
+    assertCanRead("refs/heads/foo-QA-bar", d);
+  }
+
+  @Test
+  public void blockRule_ParentBlocksChild() {
+    allow(local, PUSH, DEVS, "refs/tags/*");
+    block(parent, PUSH, ANONYMOUS_USERS, "refs/tags/*");
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/tags/V10", u);
+  }
+
+  @Test
+  public void blockRule_ParentBlocksChildEvenIfAlreadyBlockedInChild() {
+    allow(local, PUSH, DEVS, "refs/tags/*");
+    block(local, PUSH, ANONYMOUS_USERS, "refs/tags/*");
+    block(parent, PUSH, ANONYMOUS_USERS, "refs/tags/*");
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/tags/V10", u);
+  }
+
+  @Test
+  public void blockLabelRange_ParentBlocksChild() {
+    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+    block(parent, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+
+    ProjectControl u = user(local, DEVS);
+
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    assertCanVote(-1, range);
+    assertCanVote(1, range);
+    assertCannotVote(-2, range);
+    assertCannotVote(2, range);
+  }
+
+  @Test
+  public void blockLabelRange_ParentBlocksChildEvenIfAlreadyBlockedInChild() {
+    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+    block(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+    block(parent, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+
+    ProjectControl u = user(local, DEVS);
+
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    assertCanVote(-1, range);
+    assertCanVote(1, range);
+    assertCannotVote(-2, range);
+    assertCannotVote(2, range);
+  }
+
+  @Test
+  public void inheritSubmit_AllowInChildDoesntAffectUnblockInParent() {
+    block(parent, SUBMIT, ANONYMOUS_USERS, "refs/heads/*");
+    allow(parent, SUBMIT, REGISTERED_USERS, "refs/heads/*");
+    allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
+
+    ProjectControl u = user(local);
+    assertNotBlocked(SUBMIT, "refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockNoForce() {
+    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/heads/*");
+
+    ProjectControl u = user(local, DEVS);
+    assertCanUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockForce() {
+    PermissionRule r = block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    r.setForce(true);
+    allow(local, PUSH, DEVS, "refs/heads/*").setForce(true);
+
+    ProjectControl u = user(local, DEVS);
+    assertCanForceUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockForceWithAllowNoForce_NotPossible() {
+    PermissionRule r = block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    r.setForce(true);
+    allow(local, PUSH, DEVS, "refs/heads/*");
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotForceUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockMoreSpecificRef_Fails() {
+    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/heads/master");
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockMoreSpecificRefInLocal_Fails() {
+    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/heads/master");
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockMoreSpecificRefWithExclusiveFlag() {
+    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/heads/master", true);
+
+    ProjectControl u = user(local, DEVS);
+    assertCanUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockMoreSpecificRefInLocalWithExclusiveFlag_Fails() {
+    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/heads/master", true);
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockOtherPermissionWithMoreSpecificRefAndExclusiveFlag_Fails() {
+    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/heads/master");
+    allow(local, SUBMIT, DEVS, "refs/heads/master", true);
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockLargerScope_Fails() {
+    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/master");
+    allow(local, PUSH, DEVS, "refs/heads/*");
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockInLocal_Fails() {
+    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, fixers, "refs/heads/*");
+
+    ProjectControl f = user(local, fixers);
+    assertCannotUpdate("refs/heads/master", f);
+  }
+
+  @Test
+  public void unblockInParentBlockInLocal() {
+    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(parent, PUSH, DEVS, "refs/heads/*");
+    block(local, PUSH, DEVS, "refs/heads/*");
+
+    ProjectControl d = user(local, DEVS);
+    assertCannotUpdate("refs/heads/master", d);
+  }
+
+  @Test
+  public void unblockForceEditTopicName() {
+    block(local, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
+
+    ProjectControl u = user(local, DEVS);
+    assertThat(u.controlForRef("refs/heads/master").canForceEditTopicName())
+        .named("u can edit topic name")
+        .isTrue();
+  }
+
+  @Test
+  public void unblockInLocalForceEditTopicName_Fails() {
+    block(parent, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
+
+    ProjectControl u = user(local, REGISTERED_USERS);
+    assertThat(u.controlForRef("refs/heads/master").canForceEditTopicName())
+        .named("u can't edit topic name")
+        .isFalse();
+  }
+
+  @Test
+  public void unblockRange() {
+    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+
+    ProjectControl u = user(local, DEVS);
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    assertCanVote(-2, range);
+    assertCanVote(2, range);
+  }
+
+  @Test
+  public void unblockRangeOnMoreSpecificRef_Fails() {
+    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/master");
+
+    ProjectControl u = user(local, DEVS);
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    assertCannotVote(-2, range);
+    assertCannotVote(2, range);
+  }
+
+  @Test
+  public void unblockRangeOnLargerScope_Fails() {
+    block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/master");
+    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+
+    ProjectControl u = user(local, DEVS);
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    assertCannotVote(-2, range);
+    assertCannotVote(2, range);
+  }
+
+  @Test
+  public void unblockInLocalRange_Fails() {
+    block(parent, LABEL + "Code-Review", -1, 1, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
+
+    ProjectControl u = user(local, DEVS);
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    assertCannotVote(-2, range);
+    assertCannotVote(2, range);
+  }
+
+  @Test
+  public void unblockRangeForChangeOwner() {
+    allow(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
+
+    ProjectControl u = user(local, DEVS);
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review", true);
+    assertCanVote(-2, range);
+    assertCanVote(2, range);
+  }
+
+  @Test
+  public void unblockRangeForNotChangeOwner() {
+    allow(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
+
+    ProjectControl u = user(local, DEVS);
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    assertCannotVote(-2, range);
+    assertCannotVote(2, range);
+  }
+
+  @Test
+  public void blockOwner() {
+    block(parent, OWNER, ANONYMOUS_USERS, "refs/*");
+    allow(local, OWNER, DEVS, "refs/*");
+
+    assertThat(user(local, DEVS).isOwner()).isFalse();
+  }
+
+  @Test
+  public void validateRefPatternsOK() throws Exception {
+    RefPattern.validate("refs/*");
+    RefPattern.validate("^refs/heads/*");
+    RefPattern.validate("^refs/tags/[0-9a-zA-Z-_.]+");
+    RefPattern.validate("refs/heads/review/${username}/*");
+  }
+
+  @Test(expected = InvalidNameException.class)
+  public void testValidateBadRefPatternDoubleCaret() throws Exception {
+    RefPattern.validate("^^refs/*");
+  }
+
+  @Test(expected = InvalidNameException.class)
+  public void testValidateBadRefPatternDanglingCharacter() throws Exception {
+    RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}*");
+  }
+
+  @Test
+  public void validateRefPatternNoDanglingCharacter() throws Exception {
+    RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}");
+  }
+
+  private InMemoryRepository add(ProjectConfig pc) {
+    PrologEnvironment.Factory envFactory = null;
+    RulesCache rulesCache = null;
+    SitePaths sitePaths = null;
+    List<CommentLinkInfo> commentLinks = null;
+
+    InMemoryRepository repo;
+    try {
+      repo = repoManager.createRepository(pc.getName());
+      if (pc.getProject() == null) {
+        pc.load(repo);
+      }
+    } catch (IOException | ConfigInvalidException e) {
+      throw new RuntimeException(e);
+    }
+    all.put(
+        pc.getName(),
+        new ProjectState(
+            sitePaths,
+            projectCache,
+            allProjectsName,
+            allUsersName,
+            projectControlFactory,
+            envFactory,
+            repoManager,
+            rulesCache,
+            commentLinks,
+            capabilityCollectionFactory,
+            pc));
+    return repo;
+  }
+
+  private ProjectControl user(ProjectConfig local, AccountGroup.UUID... memberOf) {
+    return user(local, null, memberOf);
+  }
+
+  private ProjectControl user(ProjectConfig local, String name, AccountGroup.UUID... memberOf) {
+    return new ProjectControl(
+        Collections.<AccountGroup.UUID>emptySet(),
+        Collections.<AccountGroup.UUID>emptySet(),
+        sectionSorter,
+        changeControlFactory,
+        permissionBackend,
+        new MockUser(name, memberOf),
+        newProjectState(local));
+  }
+
+  private ProjectState newProjectState(ProjectConfig local) {
+    add(local);
+    return all.get(local.getProject().getNameKey());
+  }
+
+  private class MockUser extends CurrentUser {
+    private final String username;
+    private final GroupMembership groups;
+
+    MockUser(String name, AccountGroup.UUID[] groupId) {
+      username = name;
+      ArrayList<AccountGroup.UUID> groupIds = Lists.newArrayList(groupId);
+      groupIds.add(REGISTERED_USERS);
+      groupIds.add(ANONYMOUS_USERS);
+      groups = new ListGroupMembership(groupIds);
+    }
+
+    @Override
+    public GroupMembership getEffectiveGroups() {
+      return groups;
+    }
+
+    @Override
+    public String getUserName() {
+      return username;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/BUILD b/javatests/com/google/gerrit/server/query/BUILD
new file mode 100644
index 0000000..96201d2
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/BUILD
@@ -0,0 +1,10 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+java_library(
+    name = "index-config",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/query/IndexConfig.java b/javatests/com/google/gerrit/server/query/IndexConfig.java
new file mode 100644
index 0000000..87452b5
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/IndexConfig.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query;
+
+import org.eclipse.jgit.lib.Config;
+
+public class IndexConfig {
+
+  public static Config createForLucene() {
+    return create();
+  }
+
+  public static Config createForElasticsearch() {
+    Config cfg = create();
+
+    // For some reason enabling the staleness checker increases the flakiness of the Elasticsearch
+    // tests. Hence disable the staleness checker.
+    cfg.setBoolean("index", null, "autoReindexIfStale", false);
+
+    return cfg;
+  }
+
+  public static Config create() {
+    Config cfg = new Config();
+    cfg.setInt("index", null, "maxPages", 10);
+    return cfg;
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
new file mode 100644
index 0000000..262701c
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -0,0 +1,793 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.accounts.Accounts.QueryRequest;
+import com.google.gerrit.extensions.client.ListAccountsOption;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.common.AccountExternalIdInfo;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.FieldBundle;
+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.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.InternalAccountUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.index.account.AccountField;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+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.testing.GerritServerTests;
+import com.google.gerrit.testing.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.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+
+@Ignore
+public abstract class AbstractQueryAccountsTest extends GerritServerTests {
+  @Inject protected Accounts accounts;
+
+  @Inject protected AccountsUpdate.Server accountsUpdate;
+
+  @Inject protected AccountCache accountCache;
+
+  @Inject protected AccountManager accountManager;
+
+  @Inject protected GerritApi gApi;
+
+  @Inject @GerritPersonIdent Provider<PersonIdent> serverIdent;
+
+  @Inject protected IdentifiedUser.GenericFactory userFactory;
+
+  @Inject private Provider<AnonymousUser> anonymousUser;
+
+  @Inject protected InMemoryDatabase schemaFactory;
+
+  @Inject protected SchemaCreator schemaCreator;
+
+  @Inject protected ThreadLocalRequestContext requestContext;
+
+  @Inject protected OneOffRequestContext oneOffRequestContext;
+
+  @Inject protected Provider<InternalAccountQuery> queryProvider;
+
+  @Inject protected AllProjectsName allProjects;
+
+  @Inject protected AllUsersName allUsers;
+
+  @Inject protected GitRepositoryManager repoManager;
+
+  @Inject protected AccountIndexCollection indexes;
+
+  @Inject protected ExternalIds externalIds;
+
+  protected LifecycleManager lifecycle;
+  protected Injector injector;
+  protected ReviewDb db;
+  protected AccountInfo currentUserInfo;
+  protected CurrentUser admin;
+
+  protected abstract Injector createInjector();
+
+  @Before
+  public void setUpInjector() throws Exception {
+    lifecycle = new LifecycleManager();
+    injector = createInjector();
+    lifecycle.add(injector);
+    injector.injectMembers(this);
+    lifecycle.start();
+    setUpDatabase();
+  }
+
+  @After
+  public void cleanUp() {
+    lifecycle.stop();
+    db.close();
+  }
+
+  protected void setUpDatabase() throws Exception {
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+
+    Account.Id adminId = createAccount("admin", "Administrator", "admin@example.com", true);
+    admin = userFactory.create(adminId);
+    requestContext.setContext(newRequestContext(adminId));
+    currentUserInfo = gApi.accounts().id(adminId.get()).get();
+  }
+
+  protected RequestContext newRequestContext(Account.Id requestUserId) {
+    final CurrentUser requestUser = userFactory.create(requestUserId);
+    return new RequestContext() {
+      @Override
+      public CurrentUser getUser() {
+        return requestUser;
+      }
+
+      @Override
+      public Provider<ReviewDb> getReviewDbProvider() {
+        return Providers.of(db);
+      }
+    };
+  }
+
+  protected void setAnonymous() {
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return anonymousUser.get();
+          }
+
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
+  }
+
+  @After
+  public void tearDownInjector() {
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
+  }
+
+  @Test
+  public void byId() throws Exception {
+    AccountInfo user = newAccount("user");
+
+    assertQuery("9999999");
+    assertQuery(currentUserInfo._accountId, currentUserInfo);
+    assertQuery(user._accountId, user);
+  }
+
+  @Test
+  public void bySelf() throws Exception {
+    assertQuery("self", currentUserInfo);
+  }
+
+  @Test
+  public void byEmail() throws Exception {
+    AccountInfo user1 = newAccountWithEmail("user1", name("user1@example.com"));
+
+    String domain = name("test.com");
+    AccountInfo user2 = newAccountWithEmail("user2", "user2@" + domain);
+    AccountInfo user3 = newAccountWithEmail("user3", "user3@" + domain);
+
+    String prefix = name("prefix");
+    AccountInfo user4 = newAccountWithEmail("user4", prefix + "user4@example.com");
+
+    AccountInfo user5 = newAccountWithEmail("user5", name("user5MixedCase@example.com"));
+
+    assertQuery("notexisting@test.com");
+
+    assertQuery(currentUserInfo.email, currentUserInfo);
+    assertQuery("email:" + currentUserInfo.email, currentUserInfo);
+
+    assertQuery(user1.email, user1);
+    assertQuery("email:" + user1.email, user1);
+
+    assertQuery(domain, user2, user3);
+
+    assertQuery("email:" + prefix, user4);
+
+    assertQuery(user5.email, user5);
+    assertQuery("email:" + user5.email, user5);
+    assertQuery("email:" + user5.email.toUpperCase(), user5);
+  }
+
+  @Test
+  public void bySecondaryEmail() throws Exception {
+    String prefix = name("secondary");
+    String domain = name("test.com");
+    String secondaryEmail = prefix + "@" + domain;
+    AccountInfo user1 = newAccountWithEmail("user1", name("user1@example.com"));
+    addEmails(user1, secondaryEmail);
+
+    AccountInfo user2 = newAccountWithEmail("user2", name("user2@example.com"));
+    addEmails(user2, name("other@" + domain));
+
+    assertQuery(secondaryEmail, user1);
+    assertQuery("email:" + secondaryEmail, user1);
+    assertQuery("email:" + prefix, user1);
+    assertQuery(domain, user1, user2);
+  }
+
+  @Test
+  public void byEmailWithoutModifyAccountCapability() throws Exception {
+    String preferredEmail = name("primary@test.com");
+    String secondaryEmail = name("secondary@test.com");
+    AccountInfo user1 = newAccountWithEmail("user1", preferredEmail);
+    addEmails(user1, secondaryEmail);
+
+    AccountInfo user2 = newAccount("user");
+    requestContext.setContext(newRequestContext(new Account.Id(user2._accountId)));
+
+    if (getSchemaVersion() < 5) {
+      assertMissingField(AccountField.PREFERRED_EMAIL);
+      assertFailingQuery("email:foo", "'email' operator is not supported by account index version");
+      return;
+    }
+
+    // This at least needs the PREFERRED_EMAIL field which is available from schema version 5.
+    if (getSchemaVersion() >= 5) {
+      assertQuery(preferredEmail, user1);
+    } else {
+      assertQuery(preferredEmail);
+    }
+
+    assertQuery(secondaryEmail);
+
+    assertQuery("email:" + preferredEmail, user1);
+    assertQuery("email:" + secondaryEmail);
+  }
+
+  @Test
+  public void byUsername() throws Exception {
+    AccountInfo user1 = newAccount("myuser");
+
+    assertQuery("notexisting");
+    assertQuery("Not Existing");
+
+    assertQuery(user1.username, user1);
+    assertQuery("username:" + user1.username, user1);
+    assertQuery("username:" + user1.username.toUpperCase(), user1);
+  }
+
+  @Test
+  public void isActive() throws Exception {
+    String domain = name("test.com");
+    AccountInfo user1 = newAccountWithEmail("user1", "user1@" + domain);
+    AccountInfo user2 = newAccountWithEmail("user2", "user2@" + domain);
+    AccountInfo user3 = newAccount("user3", "user3@" + domain, false);
+    AccountInfo user4 = newAccount("user4", "user4@" + domain, false);
+
+    // by default only active accounts are returned
+    assertQuery(domain, user1, user2);
+    assertQuery("name:" + domain, user1, user2);
+
+    assertQuery("is:active name:" + domain, user1, user2);
+
+    assertQuery("is:inactive name:" + domain, user3, user4);
+  }
+
+  @Test
+  public void byName() throws Exception {
+    AccountInfo user1 = newAccountWithFullName("jdoe", "John Doe");
+    AccountInfo user2 = newAccountWithFullName("jroe", "Jane Roe");
+    AccountInfo user3 = newAccountWithFullName("user3", "Mr Selfish");
+
+    assertQuery("notexisting");
+    assertQuery("Not Existing");
+
+    assertQuery(quote(user1.name), user1);
+    assertQuery("name:" + quote(user1.name), user1);
+    assertQuery("John", user1);
+    assertQuery("john", user1);
+    assertQuery("Doe", user1);
+    assertQuery("doe", user1);
+    assertQuery("DOE", user1);
+    assertQuery("Jo Do", user1);
+    assertQuery("jo do", user1);
+    assertQuery("self", currentUserInfo, user3);
+    assertQuery("me", currentUserInfo);
+    assertQuery("name:John", user1);
+    assertQuery("name:john", user1);
+    assertQuery("name:Doe", user1);
+    assertQuery("name:doe", user1);
+    assertQuery("name:DOE", user1);
+    assertQuery("name:self", user3);
+
+    assertQuery(quote(user2.name), user2);
+    assertQuery("name:" + quote(user2.name), user2);
+  }
+
+  @Test
+  public void byNameWithoutModifyAccountCapability() throws Exception {
+    AccountInfo user1 = newAccountWithFullName("jdoe", "John Doe");
+    AccountInfo user2 = newAccountWithFullName("jroe", "Jane Roe");
+
+    AccountInfo user3 = newAccount("user");
+    requestContext.setContext(newRequestContext(new Account.Id(user3._accountId)));
+
+    assertQuery("notexisting");
+    assertQuery("Not Existing");
+
+    // by full name works with any index version
+    assertQuery(quote(user1.name), user1);
+    assertQuery("name:" + quote(user1.name), user1);
+    assertQuery(quote(user2.name), user2);
+    assertQuery("name:" + quote(user2.name), user2);
+
+    // by self/me works with any index version
+    assertQuery("self", user3);
+    assertQuery("me", user3);
+
+    if (getSchemaVersion() < 8) {
+      assertMissingField(AccountField.NAME_PART_NO_SECONDARY_EMAIL);
+
+      // prefix queries only work if the NAME_PART_NO_SECONDARY_EMAIL field is available
+      assertQuery("john");
+      return;
+    }
+
+    assertQuery("John", user1);
+    assertQuery("john", user1);
+    assertQuery("Doe", user1);
+    assertQuery("doe", user1);
+    assertQuery("DOE", user1);
+    assertQuery("Jo Do", user1);
+    assertQuery("jo do", user1);
+    assertQuery("name:John", user1);
+    assertQuery("name:john", user1);
+    assertQuery("name:Doe", user1);
+    assertQuery("name:doe", user1);
+    assertQuery("name:DOE", user1);
+  }
+
+  @Test
+  public void byWatchedProject() throws Exception {
+    Project.NameKey p = createProject(name("p"));
+    Project.NameKey p2 = createProject(name("p2"));
+    AccountInfo user1 = newAccountWithFullName("jdoe", "John Doe");
+    AccountInfo user2 = newAccountWithFullName("jroe", "Jane Roe");
+    AccountInfo user3 = newAccountWithFullName("user3", "Mr Selfish");
+
+    assertThat(queryProvider.get().byWatchedProject(p)).isEmpty();
+
+    watch(user1, p, null);
+    assertAccounts(queryProvider.get().byWatchedProject(p), user1);
+
+    watch(user2, p, "keyword");
+    assertAccounts(queryProvider.get().byWatchedProject(p), user1, user2);
+
+    watch(user3, p2, "keyword");
+    watch(user3, allProjects, "keyword");
+    assertAccounts(queryProvider.get().byWatchedProject(p), user1, user2);
+    assertAccounts(queryProvider.get().byWatchedProject(p2), user3);
+    assertAccounts(queryProvider.get().byWatchedProject(allProjects), user3);
+  }
+
+  @Test
+  public void withLimit() throws Exception {
+    String domain = name("test.com");
+    AccountInfo user1 = newAccountWithEmail("user1", "user1@" + domain);
+    AccountInfo user2 = newAccountWithEmail("user2", "user2@" + domain);
+    AccountInfo user3 = newAccountWithEmail("user3", "user3@" + domain);
+
+    List<AccountInfo> result = assertQuery(domain, user1, user2, user3);
+    assertThat(Iterables.getLast(result)._moreAccounts).isNull();
+
+    result = assertQuery(newQuery(domain).withLimit(2), result.subList(0, 2));
+    assertThat(Iterables.getLast(result)._moreAccounts).isTrue();
+  }
+
+  @Test
+  public void withStart() throws Exception {
+    String domain = name("test.com");
+    AccountInfo user1 = newAccountWithEmail("user1", "user1@" + domain);
+    AccountInfo user2 = newAccountWithEmail("user2", "user2@" + domain);
+    AccountInfo user3 = newAccountWithEmail("user3", "user3@" + domain);
+
+    List<AccountInfo> result = assertQuery(domain, user1, user2, user3);
+    assertQuery(newQuery(domain).withStart(1), result.subList(1, 3));
+  }
+
+  @Test
+  public void withDetails() throws Exception {
+    AccountInfo user1 = newAccount("myuser", "My User", "my.user@example.com", true);
+
+    List<AccountInfo> result = assertQuery(user1.username, user1);
+    AccountInfo ai = result.get(0);
+    assertThat(ai._accountId).isEqualTo(user1._accountId);
+    assertThat(ai.name).isNull();
+    assertThat(ai.username).isNull();
+    assertThat(ai.email).isNull();
+    assertThat(ai.avatars).isNull();
+
+    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
+    ai = result.get(0);
+    assertThat(ai._accountId).isEqualTo(user1._accountId);
+    assertThat(ai.name).isEqualTo(user1.name);
+    assertThat(ai.username).isEqualTo(user1.username);
+    assertThat(ai.email).isEqualTo(user1.email);
+    assertThat(ai.avatars).isNull();
+  }
+
+  @Test
+  public void withSecondaryEmails() throws Exception {
+    AccountInfo user1 = newAccount("myuser", "My User", "my.user@example.com", true);
+    String[] secondaryEmails = new String[] {"bar@example.com", "foo@example.com"};
+    addEmails(user1, secondaryEmails);
+
+    List<AccountInfo> result = assertQuery(user1.username, user1);
+    assertThat(result.get(0).secondaryEmails).isNull();
+
+    result = assertQuery(newQuery(user1.username).withSuggest(true), user1);
+    assertThat(result.get(0).secondaryEmails)
+        .containsExactlyElementsIn(Arrays.asList(secondaryEmails))
+        .inOrder();
+
+    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
+    assertThat(result.get(0).secondaryEmails).isNull();
+
+    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.ALL_EMAILS), user1);
+    assertThat(result.get(0).secondaryEmails)
+        .containsExactlyElementsIn(Arrays.asList(secondaryEmails))
+        .inOrder();
+
+    result =
+        assertQuery(
+            newQuery(user1.username)
+                .withOptions(ListAccountsOption.DETAILS, ListAccountsOption.ALL_EMAILS),
+            user1);
+    assertThat(result.get(0).secondaryEmails)
+        .containsExactlyElementsIn(Arrays.asList(secondaryEmails))
+        .inOrder();
+  }
+
+  @Test
+  public void withSecondaryEmailsWithoutModifyAccountCapability() throws Exception {
+    AccountInfo user = newAccount("myuser", "My User", "abc@example.com", true);
+    String[] secondaryEmails = new String[] {"dfg@example.com", "hij@example.com"};
+    addEmails(user, secondaryEmails);
+
+    requestContext.setContext(newRequestContext(new Account.Id(user._accountId)));
+
+    List<AccountInfo> result = newQuery(user.username).withSuggest(true).get();
+    assertThat(result.get(0).secondaryEmails).isNull();
+
+    exception.expect(AuthException.class);
+    newQuery(user.username).withOption(ListAccountsOption.ALL_EMAILS).get();
+  }
+
+  @Test
+  public void asAnonymous() throws Exception {
+    AccountInfo user1 = newAccount("user1");
+
+    setAnonymous();
+    assertQuery("9999999");
+    assertQuery("self");
+    assertQuery("username:" + user1.username, user1);
+  }
+
+  // reindex permissions are tested by {@link AccountIT#reindexPermissions}
+  @Test
+  public void reindex() throws Exception {
+    AccountInfo user1 = newAccountWithFullName("tester", "Test Usre");
+
+    // update account without reindex so that account index is stale
+    Account.Id accountId = new Account.Id(user1._accountId);
+    String newName = "Test User";
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsers, repo);
+      PersonIdent ident = serverIdent.get();
+      md.getCommitBuilder().setAuthor(ident);
+      md.getCommitBuilder().setCommitter(ident);
+      new AccountConfig(accountId, repo)
+          .load()
+          .setAccountUpdate(InternalAccountUpdate.builder().setFullName(newName).build())
+          .commit(md);
+    }
+
+    assertQuery("name:" + quote(user1.name), user1);
+    assertQuery("name:" + quote(newName));
+
+    gApi.accounts().id(user1.username).index();
+    assertQuery("name:" + quote(user1.name));
+    assertQuery("name:" + quote(newName), user1);
+  }
+
+  @Test
+  public void rawDocument() throws Exception {
+    AccountInfo userInfo = gApi.accounts().id(admin.getAccountId().get()).get();
+
+    Optional<FieldBundle> rawFields =
+        indexes
+            .getSearchIndex()
+            .getRaw(
+                new Account.Id(userInfo._accountId),
+                QueryOptions.create(
+                    IndexConfig.createDefault(),
+                    0,
+                    1,
+                    indexes.getSearchIndex().getSchema().getStoredFields().keySet()));
+
+    assertThat(rawFields.isPresent()).isTrue();
+    assertThat(rawFields.get().getValue(AccountField.ID)).isEqualTo(userInfo._accountId);
+
+    // The field EXTERNAL_ID_STATE is only supported from schema version 6.
+    if (getSchemaVersion() < 6) {
+      return;
+    }
+
+    List<AccountExternalIdInfo> externalIdInfos = gApi.accounts().self().getExternalIds();
+    List<ByteArrayWrapper> blobs = new ArrayList<>();
+    for (AccountExternalIdInfo info : externalIdInfos) {
+      blobs.add(
+          new ByteArrayWrapper(externalIds.get(ExternalId.Key.parse(info.identity)).toByteArray()));
+    }
+    assertThat(rawFields.get().getValue(AccountField.EXTERNAL_ID_STATE)).hasSize(blobs.size());
+    assertThat(
+            Streams.stream(rawFields.get().getValue(AccountField.EXTERNAL_ID_STATE))
+                .map(b -> new ByteArrayWrapper(b))
+                .collect(toList()))
+        .containsExactlyElementsIn(blobs);
+  }
+
+  protected AccountInfo newAccount(String username) throws Exception {
+    return newAccountWithEmail(username, null);
+  }
+
+  protected AccountInfo newAccountWithEmail(String username, String email) throws Exception {
+    return newAccount(username, email, true);
+  }
+
+  protected AccountInfo newAccountWithFullName(String username, String fullName) throws Exception {
+    return newAccount(username, fullName, null, true);
+  }
+
+  protected AccountInfo newAccount(String username, String email, boolean active) throws Exception {
+    return newAccount(username, null, email, active);
+  }
+
+  protected AccountInfo newAccount(String username, String fullName, String email, boolean active)
+      throws Exception {
+    String uniqueName = name(username);
+
+    try {
+      gApi.accounts().id(uniqueName).get();
+      fail("user " + uniqueName + " already exists");
+    } catch (ResourceNotFoundException e) {
+      // expected: user does not exist yet
+    }
+
+    Account.Id id = createAccount(uniqueName, fullName, email, active);
+    return gApi.accounts().id(id.get()).get();
+  }
+
+  protected Project.NameKey createProject(String name) throws RestApiException {
+    gApi.projects().create(name);
+    return new Project.NameKey(name);
+  }
+
+  protected void watch(AccountInfo account, Project.NameKey project, String filter)
+      throws RestApiException {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = project.get();
+    pwi.filter = filter;
+    pwi.notifyAbandonedChanges = true;
+    pwi.notifyNewChanges = true;
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+    gApi.accounts().id(account._accountId).setWatchedProjects(projectsToWatch);
+  }
+
+  protected String quote(String s) {
+    return "\"" + s + "\"";
+  }
+
+  protected String name(String name) {
+    if (name == null) {
+      return null;
+    }
+
+    String suffix = getSanitizedMethodName();
+    if (name.contains("@")) {
+      return name + "." + suffix;
+    }
+    return name + "_" + suffix;
+  }
+
+  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(
+              "Update Test Account",
+              id,
+              u -> {
+                u.setFullName(fullName).setPreferredEmail(email).setActive(active);
+              });
+      return id;
+    }
+  }
+
+  private void addEmails(AccountInfo account, String... emails) throws Exception {
+    Account.Id id = new Account.Id(account._accountId);
+    for (String email : emails) {
+      accountManager.link(id, AuthRequest.forEmail(email));
+    }
+    accountCache.evict(id);
+  }
+
+  protected QueryRequest newQuery(Object query) throws RestApiException {
+    return gApi.accounts().query(query.toString());
+  }
+
+  protected List<AccountInfo> assertQuery(Object query, AccountInfo... accounts) throws Exception {
+    return assertQuery(newQuery(query), accounts);
+  }
+
+  protected List<AccountInfo> assertQuery(QueryRequest query, AccountInfo... accounts)
+      throws Exception {
+    return assertQuery(query, Arrays.asList(accounts));
+  }
+
+  protected List<AccountInfo> assertQuery(QueryRequest query, List<AccountInfo> accounts)
+      throws Exception {
+    List<AccountInfo> result = query.get();
+    Iterable<Integer> ids = ids(result);
+    assertThat(ids)
+        .named(format(query, result, accounts))
+        .containsExactlyElementsIn(ids(accounts))
+        .inOrder();
+    return result;
+  }
+
+  protected void assertAccounts(List<AccountState> accounts, AccountInfo... expectedAccounts) {
+    assertThat(accounts.stream().map(a -> a.getAccount().getId().get()).collect(toList()))
+        .containsExactlyElementsIn(
+            Arrays.asList(expectedAccounts).stream().map(a -> a._accountId).collect(toList()));
+  }
+
+  private String format(
+      QueryRequest query, List<AccountInfo> actualIds, List<AccountInfo> expectedAccounts) {
+    StringBuilder b = new StringBuilder();
+    b.append("query '").append(query.getQuery()).append("' with expected accounts ");
+    b.append(format(expectedAccounts));
+    b.append(" and result ");
+    b.append(format(actualIds));
+    return b.toString();
+  }
+
+  private String format(Iterable<AccountInfo> accounts) {
+    StringBuilder b = new StringBuilder();
+    b.append("[");
+    Iterator<AccountInfo> it = accounts.iterator();
+    while (it.hasNext()) {
+      AccountInfo a = it.next();
+      b.append("{")
+          .append(a._accountId)
+          .append(", ")
+          .append("name=")
+          .append(a.name)
+          .append(", ")
+          .append("email=")
+          .append(a.email)
+          .append(", ")
+          .append("username=")
+          .append(a.username)
+          .append("}");
+      if (it.hasNext()) {
+        b.append(", ");
+      }
+    }
+    b.append("]");
+    return b.toString();
+  }
+
+  protected static Iterable<Integer> ids(AccountInfo... accounts) {
+    return ids(Arrays.asList(accounts));
+  }
+
+  protected static Iterable<Integer> ids(List<AccountInfo> accounts) {
+    return accounts.stream().map(a -> a._accountId).collect(toList());
+  }
+
+  protected void assertMissingField(FieldDef<AccountState, ?> field) {
+    assertThat(getSchema().hasField(field))
+        .named("schema %s has field %s", getSchemaVersion(), field.getName())
+        .isFalse();
+  }
+
+  protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
+    try {
+      assertQuery(query);
+      fail("expected BadRequestException for query '" + query + "'");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+
+  protected int getSchemaVersion() {
+    return getSchema().getVersion();
+  }
+
+  protected Schema<AccountState> getSchema() {
+    return indexes.getSearchIndex().getSchema();
+  }
+
+  /** Boiler plate code to check two byte arrays for equality */
+  private static class ByteArrayWrapper {
+    private byte[] arr;
+
+    private ByteArrayWrapper(byte[] arr) {
+      this.arr = arr;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      if (!(other instanceof ByteArrayWrapper)) {
+        return false;
+      }
+      return Arrays.equals(arr, ((ByteArrayWrapper) other).arr);
+    }
+
+    @Override
+    public int hashCode() {
+      return Arrays.hashCode(arr);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
new file mode 100644
index 0000000..0127fa5
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -0,0 +1,40 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+ABSTRACT_QUERY_TEST = ["AbstractQueryAccountsTest.java"]
+
+java_library(
+    name = "abstract_query_tests",
+    testonly = 1,
+    srcs = ABSTRACT_QUERY_TEST,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
+
+junit_tests(
+    name = "lucene_query_test",
+    size = "large",
+    srcs = glob(
+        ["*.java"],
+        exclude = ABSTRACT_QUERY_TEST,
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":abstract_query_tests",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//javatests/com/google/gerrit/server/query:index-config",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
new file mode 100644
index 0000000..da4b0d5
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.account;
+
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.query.IndexConfig;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexVersions;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+public class LuceneQueryAccountsTest extends AbstractQueryAccountsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForLucene();
+  }
+
+  @ConfigSuite.Configs
+  public static Map<String, Config> againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    List<Integer> schemaVersions =
+        IndexVersions.getWithoutLatest(AccountSchemaDefinitions.INSTANCE);
+    return IndexVersions.asConfigMap(
+        AccountSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config luceneConfig = new Config(config);
+    InMemoryModule.setDefaults(luceneConfig);
+    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
new file mode 100644
index 0000000..0b7f94a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -0,0 +1,2765 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
+import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Streams;
+import com.google.common.truth.ThrowableSubject;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+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;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.api.changes.StarsInput;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.ChangeTriplet;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.index.change.IndexedChangeQuery;
+import com.google.gerrit.server.index.change.StalenessChecker;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testing.DisabledReviewDb;
+import com.google.gerrit.testing.GerritServerTests;
+import com.google.gerrit.testing.InMemoryDatabase;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.SystemReader;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+
+@Ignore
+public abstract class AbstractQueryChangesTest extends GerritServerTests {
+  @Inject protected Accounts accounts;
+  @Inject protected AccountCache accountCache;
+  @Inject protected AccountsUpdate.Server accountsUpdate;
+  @Inject protected AccountManager accountManager;
+  @Inject protected AllUsersName allUsersName;
+  @Inject protected BatchUpdate.Factory updateFactory;
+  @Inject protected ChangeInserter.Factory changeFactory;
+  @Inject protected ChangeQueryBuilder queryBuilder;
+  @Inject protected GerritApi gApi;
+  @Inject protected IdentifiedUser.GenericFactory userFactory;
+  @Inject protected ChangeIndexCollection indexes;
+  @Inject protected ChangeIndexer indexer;
+  @Inject protected IndexConfig indexConfig;
+  @Inject protected InMemoryRepositoryManager repoManager;
+  @Inject protected Provider<InternalChangeQuery> queryProvider;
+  @Inject protected ChangeNotes.Factory notesFactory;
+  @Inject protected OneOffRequestContext oneOffRequestContext;
+  @Inject protected PatchSetInserter.Factory patchSetFactory;
+  @Inject protected PatchSetUtil psUtil;
+  @Inject protected ChangeNotes.Factory changeNotesFactory;
+  @Inject protected Provider<ChangeQueryProcessor> queryProcessorProvider;
+  @Inject protected SchemaCreator schemaCreator;
+  @Inject protected SchemaFactory<ReviewDb> schemaFactory;
+  @Inject protected Sequences seq;
+  @Inject protected ThreadLocalRequestContext requestContext;
+  @Inject protected ProjectCache projectCache;
+  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
+
+  // Only for use in setting up/tearing down injector; other users should use schemaFactory.
+  @Inject private InMemoryDatabase inMemoryDatabase;
+
+  protected Injector injector;
+  protected LifecycleManager lifecycle;
+  protected ReviewDb db;
+  protected Account.Id userId;
+  protected CurrentUser user;
+
+  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 final String DASHBOARD_WORK_IN_PROGRESS_QUERY = "is:open owner:${user} is:wip";
+  protected static final String DASHBOARD_OUTGOING_QUERY =
+      "is:open owner:${user} -is:wip -is:ignored";
+  protected static final String DASHBOARD_INCOMING_QUERY =
+      "is:open -owner:${user} -is:wip -is:ignored (reviewer:${user} OR assignee:${user})";
+  protected static final 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
+  public void setUpInjector() throws Exception {
+    lifecycle = new LifecycleManager();
+    injector = createInjector();
+    lifecycle.add(injector);
+    injector.injectMembers(this);
+    lifecycle.start();
+    setUpDatabase();
+  }
+
+  @After
+  public void cleanUp() {
+    lifecycle.stop();
+    db.close();
+  }
+
+  protected void setUpDatabase() throws Exception {
+    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
+      schemaCreator.create(underlyingDb);
+    }
+    db = schemaFactory.open();
+
+    userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
+    String email = "user@example.com";
+    accountsUpdate
+        .create()
+        .update(
+            "Add Email",
+            userId,
+            u -> u.addExternalId(ExternalId.createEmail(userId, email)).setPreferredEmail(email));
+    user = userFactory.create(userId);
+    requestContext.setContext(newRequestContext(userId));
+  }
+
+  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);
+      }
+    };
+  }
+
+  @After
+  public void tearDownInjector() {
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(inMemoryDatabase);
+  }
+
+  @Before
+  public void setTimeForTesting() {
+    resetTimeWithClockStep(1, SECONDS);
+  }
+
+  private void resetTimeWithClockStep(long clockStep, TimeUnit clockStepUnit) {
+    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
+    // TODO(dborowitz): Figure out why tests fail when stubbing out
+    // SystemReader.
+    TestTimeUtil.resetWithClockStep(clockStep, clockStepUnit);
+    SystemReader.setInstance(null);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZone);
+  }
+
+  @Test
+  public void byId() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    assertQuery("12345");
+    assertQuery(change1.getId().get(), change1);
+    assertQuery(change2.getId().get(), change2);
+  }
+
+  @Test
+  public void byKey() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change = insert(repo, newChange(repo));
+    String key = change.getKey().get();
+
+    assertQuery("I0000000000000000000000000000000000000000");
+    for (int i = 0; i <= 36; i++) {
+      String q = key.substring(0, 41 - i);
+      assertQuery(q, change);
+    }
+  }
+
+  @Test
+  public void byTriplet() throws Exception {
+    TestRepository<Repo> repo = createProject("iabcde");
+    Change change = insert(repo, newChangeForBranch(repo, "branch"));
+    String k = change.getKey().get();
+
+    assertQuery("iabcde~branch~" + k, change);
+    assertQuery("change:iabcde~branch~" + k, change);
+    assertQuery("iabcde~refs/heads/branch~" + k, change);
+    assertQuery("change:iabcde~refs/heads/branch~" + k, change);
+    assertQuery("iabcde~branch~" + k.substring(0, 10), change);
+    assertQuery("change:iabcde~branch~" + k.substring(0, 10), change);
+
+    assertQuery("foo~bar");
+    assertThatQueryException("change:foo~bar").hasMessageThat().isEqualTo("Invalid change format");
+    assertQuery("otherrepo~branch~" + k);
+    assertQuery("change:otherrepo~branch~" + k);
+    assertQuery("iabcde~otherbranch~" + k);
+    assertQuery("change:iabcde~otherbranch~" + k);
+    assertQuery("iabcde~branch~I0000000000000000000000000000000000000000");
+    assertQuery("change:iabcde~branch~I0000000000000000000000000000000000000000");
+  }
+
+  @Test
+  public void byStatus() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+    Change change1 = insert(repo, ins1);
+    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
+    Change change2 = insert(repo, ins2);
+
+    assertQuery("status:new", change1);
+    assertQuery("status:NEW", change1);
+    assertQuery("is:new", change1);
+    assertQuery("status:merged", change2);
+    assertQuery("is:merged", change2);
+    assertQuery("status:draft");
+    assertQuery("is:draft");
+  }
+
+  @Test
+  public void byStatusOpen() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+    Change change1 = insert(repo, ins1);
+    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+
+    Change[] expected = new Change[] {change1};
+    assertQuery("status:open", expected);
+    assertQuery("status:OPEN", expected);
+    assertQuery("status:o", expected);
+    assertQuery("status:op", expected);
+    assertQuery("status:ope", expected);
+    assertQuery("status:pending", expected);
+    assertQuery("status:PENDING", expected);
+    assertQuery("status:p", expected);
+    assertQuery("status:pe", expected);
+    assertQuery("status:pen", expected);
+    assertQuery("is:open", expected);
+  }
+
+  @Test
+  public void byStatusClosed() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
+    Change change1 = insert(repo, ins1);
+    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.ABANDONED);
+    Change change2 = insert(repo, ins2);
+    insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+
+    Change[] expected = new Change[] {change2, change1};
+    assertQuery("status:closed", expected);
+    assertQuery("status:CLOSED", expected);
+    assertQuery("status:c", expected);
+    assertQuery("status:cl", expected);
+    assertQuery("status:clo", expected);
+    assertQuery("status:clos", expected);
+    assertQuery("status:close", expected);
+    assertQuery("status:closed", expected);
+    assertQuery("is:closed", expected);
+  }
+
+  @Test
+  public void byStatusPrefix() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+    Change change1 = insert(repo, ins1);
+    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+
+    assertQuery("status:n", change1);
+    assertQuery("status:ne", change1);
+    assertQuery("status:new", change1);
+    assertQuery("status:N", change1);
+    assertQuery("status:nE", change1);
+    assertQuery("status:neW", change1);
+    assertQuery("status:nx");
+    assertQuery("status:newx");
+  }
+
+  @Test
+  public void byPrivate() throws Exception {
+    if (getSchemaVersion() < 40) {
+      assertMissingField(ChangeField.PRIVATE);
+      assertFailingQuery(
+          "is:private", "'is:private' operator is not supported by change index version");
+      return;
+    }
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change2 = insert(repo, newChange(repo), user2);
+
+    // No private changes.
+    assertQuery("is:open", change2, change1);
+    assertQuery("is:private");
+
+    gApi.changes().id(change1.getChangeId()).setPrivate(true, null);
+
+    // Change1 is not private, but should be still visible to its owner.
+    assertQuery("is:open", change1, change2);
+    assertQuery("is:private", change1);
+
+    // Switch request context to user2.
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("is:open", change2);
+    assertQuery("is:private");
+  }
+
+  @Test
+  public void byWip() throws Exception {
+    if (getSchemaVersion() < 42) {
+      assertMissingField(ChangeField.WIP);
+      assertFailingQuery("is:wip", "'is:wip' operator is not supported by change index version");
+      return;
+    }
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+
+    assertQuery("is:open", change1);
+    assertQuery("is:wip");
+
+    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+
+    assertQuery("is:wip", change1);
+
+    gApi.changes().id(change1.getChangeId()).setReadyForReview();
+
+    assertQuery("is:wip");
+  }
+
+  @Test
+  public void excludeWipChangeFromReviewersDashboardsBeforeSchema42() throws Exception {
+    assume().that(getSchemaVersion()).isLessThan(42);
+
+    assertMissingField(ChangeField.WIP);
+    assertFailingQuery("is:wip", "'is:wip' operator is not supported by change index version");
+
+    Account.Id user1 = createAccount("user1");
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChangeWorkInProgress(repo), userId);
+    assertQuery("reviewer:" + user1, change1);
+    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+    assertQuery("reviewer:" + user1, change1);
+  }
+
+  @Test
+  public void excludeWipChangeFromReviewersDashboards() throws Exception {
+    assume().that(getSchemaVersion()).isAtLeast(42);
+
+    Account.Id user1 = createAccount("user1");
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChangeWorkInProgress(repo), userId);
+
+    assertQuery("is:wip", change1);
+    assertQuery("reviewer:" + user1);
+
+    gApi.changes().id(change1.getChangeId()).setReadyForReview();
+    assertQuery("is:wip");
+    assertQuery("reviewer:" + user1);
+
+    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+    assertQuery("is:wip", change1);
+    assertQuery("reviewer:" + user1);
+  }
+
+  @Test
+  public void byStartedBeforeSchema44() throws Exception {
+    assume().that(getSchemaVersion()).isLessThan(44);
+    assertMissingField(ChangeField.STARTED);
+    assertFailingQuery(
+        "is:started", "'is:started' operator is not supported by change index version");
+  }
+
+  @Test
+  public void byStarted() throws Exception {
+    assume().that(getSchemaVersion()).isAtLeast(44);
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChangeWorkInProgress(repo));
+
+    assertQuery("is:started");
+
+    gApi.changes().id(change1.getChangeId()).setReadyForReview();
+    assertQuery("is:started", change1);
+
+    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+    assertQuery("is:started", change1);
+  }
+
+  private void assertReviewers(Collection<AccountInfo> reviewers, Object... expected)
+      throws Exception {
+    if (expected.length == 0) {
+      assertThat(reviewers).isNull();
+      return;
+    }
+
+    // Convert AccountInfos to strings, either account ID or email.
+    List<String> reviewerIds =
+        reviewers
+            .stream()
+            .map(
+                ai -> {
+                  if (ai._accountId != null) {
+                    return ai._accountId.toString();
+                  }
+                  return ai.email;
+                })
+            .collect(toList());
+    assertThat(reviewerIds).containsExactly(expected);
+  }
+
+  @Test
+  public void restorePendingReviewers() throws Exception {
+    assume().that(getSchemaVersion()).isAtLeast(44);
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+    Change change1 = insert(repo, newChangeWorkInProgress(repo));
+    Account.Id user1 = createAccount("user1");
+    Account.Id user2 = createAccount("user2");
+    String email1 = "email1@example.com";
+    String email2 = "email2@example.com";
+
+    ReviewInput in =
+        ReviewInput.noScore()
+            .reviewer(user1.toString())
+            .reviewer(user2.toString(), ReviewerState.CC, false)
+            .reviewer(email1)
+            .reviewer(email2, ReviewerState.CC, false);
+    gApi.changes().id(change1.getId().get()).revision("current").review(in);
+
+    List<ChangeInfo> changeInfos =
+        assertQuery(newQuery("is:wip").withOption(DETAILED_LABELS), change1);
+    assertThat(changeInfos).isNotEmpty();
+
+    Map<ReviewerState, Collection<AccountInfo>> pendingReviewers =
+        changeInfos.get(0).pendingReviewers;
+    assertThat(pendingReviewers).isNotNull();
+
+    assertReviewers(
+        pendingReviewers.get(ReviewerState.REVIEWER), userId.toString(), user1.toString(), email1);
+    assertReviewers(pendingReviewers.get(ReviewerState.CC), user2.toString(), email2);
+    assertReviewers(pendingReviewers.get(ReviewerState.REMOVED));
+
+    // Pending reviewers may also be presented in the REMOVED state. Toggle the
+    // change to ready and then back to WIP and remove reviewers to produce.
+    assertThat(pendingReviewers.get(ReviewerState.REMOVED)).isNull();
+    gApi.changes().id(change1.getId().get()).setReadyForReview();
+    gApi.changes().id(change1.getId().get()).setWorkInProgress();
+    gApi.changes().id(change1.getId().get()).reviewer(user1.toString()).remove();
+    gApi.changes().id(change1.getId().get()).reviewer(user2.toString()).remove();
+    gApi.changes().id(change1.getId().get()).reviewer(email1).remove();
+    gApi.changes().id(change1.getId().get()).reviewer(email2).remove();
+
+    changeInfos = assertQuery(newQuery("is:wip").withOption(DETAILED_LABELS), change1);
+    assertThat(changeInfos).isNotEmpty();
+
+    pendingReviewers = changeInfos.get(0).pendingReviewers;
+    assertThat(pendingReviewers).isNotNull();
+    assertReviewers(pendingReviewers.get(ReviewerState.REVIEWER));
+    assertReviewers(pendingReviewers.get(ReviewerState.CC));
+    assertReviewers(
+        pendingReviewers.get(ReviewerState.REMOVED),
+        user1.toString(),
+        user2.toString(),
+        email1,
+        email2);
+  }
+
+  @Test
+  public void byCommit() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins = newChange(repo);
+    insert(repo, ins);
+    String sha = ins.getCommitId().name();
+
+    assertQuery("0000000000000000000000000000000000000000");
+    for (int i = 0; i <= 36; i++) {
+      String q = sha.substring(0, 40 - i);
+      assertQuery(q, ins.getChange());
+    }
+  }
+
+  @Test
+  public void byOwner() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change2 = insert(repo, newChange(repo), user2);
+
+    assertQuery("owner:" + userId.get(), change1);
+    assertQuery("owner:" + user2, change2);
+
+    String nameEmail = user.asIdentifiedUser().getNameEmail();
+    assertQuery("owner: \"" + nameEmail + "\"", change1);
+  }
+
+  @Test
+  public void byAuthorExact() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.EXACT_AUTHOR)).isTrue();
+    byAuthorOrCommitterExact("author:");
+  }
+
+  @Test
+  public void byAuthorFullText() throws Exception {
+    byAuthorOrCommitterFullText("author:");
+  }
+
+  @Test
+  public void byCommitterExact() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.EXACT_COMMITTER)).isTrue();
+    byAuthorOrCommitterExact("committer:");
+  }
+
+  @Test
+  public void byCommitterFullText() throws Exception {
+    byAuthorOrCommitterFullText("committer:");
+  }
+
+  private void byAuthorOrCommitterExact(String searchOperator) throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
+    PersonIdent john = new PersonIdent("John", "john@example.com");
+    PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
+    Change change1 = createChange(repo, johnDoe);
+    Change change2 = createChange(repo, john);
+    Change change3 = createChange(repo, doeSmith);
+
+    // Only email address.
+    assertQuery(searchOperator + "john.doe@example.com", change1);
+    assertQuery(searchOperator + "john@example.com", change2);
+    assertQuery(searchOperator + "Doe_SmIth@example.com", change3); // Case insensitive.
+
+    // Right combination of email address and name.
+    assertQuery(searchOperator + "\"John Doe <john.doe@example.com>\"", change1);
+    assertQuery(searchOperator + "\" John <john@example.com> \"", change2);
+    assertQuery(searchOperator + "\"doE SMITH <doe_smitH@example.com>\"", change3);
+
+    // Wrong combination of email address and name.
+    assertQuery(searchOperator + "\"John <john.doe@example.com>\"");
+    assertQuery(searchOperator + "\"Doe John <john@example.com>\"");
+    assertQuery(searchOperator + "\"Doe John <doe_smith@example.com>\"");
+  }
+
+  private void byAuthorOrCommitterFullText(String searchOperator) throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
+    PersonIdent john = new PersonIdent("John", "john@example.com");
+    PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
+    Change change1 = createChange(repo, johnDoe);
+    Change change2 = createChange(repo, john);
+    Change change3 = createChange(repo, doeSmith);
+
+    // By exact name.
+    assertQuery(searchOperator + "\"John Doe\"", change1);
+    assertQuery(searchOperator + "\"john\"", change2, change1);
+    assertQuery(searchOperator + "\"Doe smith\"", change3);
+
+    // By name part.
+    assertQuery(searchOperator + "Doe", change3, change1);
+    assertQuery(searchOperator + "smith", change3);
+
+    // By wrong combination.
+    assertQuery(searchOperator + "\"John Smith\"");
+
+    // By invalid query.
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid value");
+    // SchemaUtil.getNameParts will return an empty set for query only containing these characters.
+    assertQuery(searchOperator + "@.- /_");
+  }
+
+  private Change createChange(TestRepository<Repo> repo, PersonIdent person) throws Exception {
+    RevCommit commit =
+        repo.parseBody(repo.commit().message("message").author(person).committer(person).create());
+    return insert(repo, newChangeForCommit(repo, commit), null);
+  }
+
+  @Test
+  public void byOwnerIn() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change2 = insert(repo, newChange(repo), user2);
+
+    assertQuery("ownerin:Administrators", change1);
+    assertQuery("ownerin:\"Registered Users\"", change2, change1);
+  }
+
+  @Test
+  public void byProject() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    TestRepository<Repo> repo2 = createProject("repo2");
+    Change change1 = insert(repo1, newChange(repo1));
+    Change change2 = insert(repo2, newChange(repo2));
+
+    assertQuery("project:foo");
+    assertQuery("project:repo");
+    assertQuery("project:repo1", change1);
+    assertQuery("project:repo2", change2);
+  }
+
+  @Test
+  public void byProjectPrefix() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    TestRepository<Repo> repo2 = createProject("repo2");
+    Change change1 = insert(repo1, newChange(repo1));
+    Change change2 = insert(repo2, newChange(repo2));
+
+    assertQuery("projects:foo");
+    assertQuery("projects:repo1", change1);
+    assertQuery("projects:repo2", change2);
+    assertQuery("projects:repo", change2, change1);
+  }
+
+  @Test
+  public void byBranchAndRef() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChangeForBranch(repo, "master"));
+    Change change2 = insert(repo, newChangeForBranch(repo, "branch"));
+
+    assertQuery("branch:foo");
+    assertQuery("branch:master", change1);
+    assertQuery("branch:refs/heads/master", change1);
+    assertQuery("ref:master");
+    assertQuery("ref:refs/heads/master", change1);
+    assertQuery("branch:refs/heads/master", change1);
+    assertQuery("branch:branch", change2);
+    assertQuery("branch:refs/heads/branch", change2);
+    assertQuery("ref:branch");
+    assertQuery("ref:refs/heads/branch", change2);
+  }
+
+  @Test
+  public void byTopic() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
+    Change change1 = insert(repo, ins1);
+
+    ChangeInserter ins2 = newChangeWithTopic(repo, "feature2");
+    Change change2 = insert(repo, ins2);
+
+    ChangeInserter ins3 = newChangeWithTopic(repo, "Cherrypick-feature2");
+    Change change3 = insert(repo, ins3);
+
+    ChangeInserter ins4 = newChangeWithTopic(repo, "feature2-fixup");
+    Change change4 = insert(repo, ins4);
+
+    Change change5 = insert(repo, newChange(repo));
+
+    assertQuery("intopic:foo");
+    assertQuery("intopic:feature1", change1);
+    assertQuery("intopic:feature2", change4, change3, change2);
+    assertQuery("topic:feature2", change2);
+    assertQuery("intopic:feature2", change4, change3, change2);
+    assertQuery("intopic:fixup", change4);
+    assertQuery("topic:\"\"", change5);
+    assertQuery("intopic:\"\"", change5);
+  }
+
+  @Test
+  public void byTopicRegex() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+
+    ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
+    Change change1 = insert(repo, ins1);
+
+    ChangeInserter ins2 = newChangeWithTopic(repo, "Cherrypick-feature1");
+    Change change2 = insert(repo, ins2);
+
+    ChangeInserter ins3 = newChangeWithTopic(repo, "feature1-fixup");
+    Change change3 = insert(repo, ins3);
+
+    assertQuery("intopic:^feature1.*", change3, change1);
+    assertQuery("intopic:{^.*feature1$}", change2, change1);
+  }
+
+  @Test
+  public void byMessageExact() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("one").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("two").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+
+    assertQuery("message:foo");
+    assertQuery("message:one", change1);
+    assertQuery("message:two", change2);
+  }
+
+  @Test
+  public void fullTextWithNumbers() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("12345 67890").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("12346 67891").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+
+    assertQuery("message:1234");
+    assertQuery("message:12345", change1);
+    assertQuery("message:12346", change2);
+  }
+
+  @Test
+  public void byLabel() throws Exception {
+    accountManager.authenticate(AuthRequest.forUser("anotheruser"));
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins2 = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins3 = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins4 = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins5 = newChange(repo, null, null, null, null, false);
+
+    Change reviewMinus2Change = insert(repo, ins);
+    gApi.changes().id(reviewMinus2Change.getId().get()).current().review(ReviewInput.reject());
+
+    Change reviewMinus1Change = insert(repo, ins2);
+    gApi.changes().id(reviewMinus1Change.getId().get()).current().review(ReviewInput.dislike());
+
+    Change noLabelChange = insert(repo, ins3);
+
+    Change reviewPlus1Change = insert(repo, ins4);
+    gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+
+    Change reviewPlus2Change = insert(repo, ins5);
+    gApi.changes().id(reviewPlus2Change.getId().get()).current().review(ReviewInput.approve());
+
+    Map<String, Short> m =
+        gApi.changes()
+            .id(reviewPlus1Change.getId().get())
+            .reviewer(user.getAccountId().toString())
+            .votes();
+    assertThat(m).hasSize(1);
+    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 1));
+
+    Map<Integer, Change> changes = new LinkedHashMap<>(5);
+    changes.put(2, reviewPlus2Change);
+    changes.put(1, reviewPlus1Change);
+    changes.put(0, noLabelChange);
+    changes.put(-1, reviewMinus1Change);
+    changes.put(-2, reviewMinus2Change);
+
+    assertQuery("label:Code-Review=-2", reviewMinus2Change);
+    assertQuery("label:Code-Review-2", reviewMinus2Change);
+    assertQuery("label:Code-Review=-1", reviewMinus1Change);
+    assertQuery("label:Code-Review-1", reviewMinus1Change);
+    assertQuery("label:Code-Review=0", noLabelChange);
+    assertQuery("label:Code-Review=+1", reviewPlus1Change);
+    assertQuery("label:Code-Review=1", reviewPlus1Change);
+    assertQuery("label:Code-Review+1", reviewPlus1Change);
+    assertQuery("label:Code-Review=+2", reviewPlus2Change);
+    assertQuery("label:Code-Review=2", reviewPlus2Change);
+    assertQuery("label:Code-Review+2", reviewPlus2Change);
+
+    assertQuery("label:Code-Review>-3", codeReviewInRange(changes, -2, 2));
+    assertQuery("label:Code-Review>=-2", codeReviewInRange(changes, -2, 2));
+    assertQuery("label:Code-Review>-2", codeReviewInRange(changes, -1, 2));
+    assertQuery("label:Code-Review>=-1", codeReviewInRange(changes, -1, 2));
+    assertQuery("label:Code-Review>-1", codeReviewInRange(changes, 0, 2));
+    assertQuery("label:Code-Review>=0", codeReviewInRange(changes, 0, 2));
+    assertQuery("label:Code-Review>0", codeReviewInRange(changes, 1, 2));
+    assertQuery("label:Code-Review>=1", codeReviewInRange(changes, 1, 2));
+    assertQuery("label:Code-Review>1", reviewPlus2Change);
+    assertQuery("label:Code-Review>=2", reviewPlus2Change);
+    assertQuery("label:Code-Review>2");
+
+    assertQuery("label:Code-Review<=2", codeReviewInRange(changes, -2, 2));
+    assertQuery("label:Code-Review<2", codeReviewInRange(changes, -2, 1));
+    assertQuery("label:Code-Review<=1", codeReviewInRange(changes, -2, 1));
+    assertQuery("label:Code-Review<1", codeReviewInRange(changes, -2, 0));
+    assertQuery("label:Code-Review<=0", codeReviewInRange(changes, -2, 0));
+    assertQuery("label:Code-Review<0", codeReviewInRange(changes, -2, -1));
+    assertQuery("label:Code-Review<=-1", codeReviewInRange(changes, -2, -1));
+    assertQuery("label:Code-Review<-1", reviewMinus2Change);
+    assertQuery("label:Code-Review<=-2", reviewMinus2Change);
+    assertQuery("label:Code-Review<-2");
+
+    assertQuery("label:Code-Review=+1,anotheruser");
+    assertQuery("label:Code-Review=+1,user", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=user", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,Administrators", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,group=Administrators", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=owner", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,owner", reviewPlus1Change);
+    assertQuery("label:Code-Review=+2,owner", reviewPlus2Change);
+    assertQuery("label:Code-Review=-2,owner", reviewMinus2Change);
+  }
+
+  @Test
+  public void byLabelNotOwner() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins = newChange(repo, null, null, null, null, false);
+    Account.Id user1 = createAccount("user1");
+
+    Change reviewPlus1Change = insert(repo, ins);
+
+    // post a review with user1
+    requestContext.setContext(newRequestContext(user1));
+    gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+
+    assertQuery("label:Code-Review=+1,user=user1", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,owner");
+  }
+
+  private Change[] codeReviewInRange(Map<Integer, Change> changes, int start, int end) {
+    int size = 0;
+    Change[] range = new Change[end - start + 1];
+    for (int i : changes.keySet()) {
+      if (i >= start && i <= end) {
+        range[size] = changes.get(i);
+        size++;
+      }
+    }
+    return range;
+  }
+
+  private String createGroup(String name, String owner) throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = name;
+    in.ownerId = owner;
+    gApi.groups().create(in);
+    return name;
+  }
+
+  private Account.Id createAccount(String name) throws Exception {
+    return accountManager.authenticate(AuthRequest.forUser(name)).getAccountId();
+  }
+
+  @Test
+  public void byLabelGroup() throws Exception {
+    Account.Id user1 = createAccount("user1");
+    createAccount("user2");
+    TestRepository<Repo> repo = createProject("repo");
+
+    // create group and add users
+    String g1 = createGroup("group1", "Administrators");
+    String g2 = createGroup("group2", "Administrators");
+    gApi.groups().id(g1).addMembers("user1");
+    gApi.groups().id(g2).addMembers("user2");
+
+    // create a change
+    Change change1 = insert(repo, newChange(repo), user1);
+
+    // post a review with user1
+    requestContext.setContext(newRequestContext(user1));
+    gApi.changes()
+        .id(change1.getId().get())
+        .current()
+        .review(new ReviewInput().label("Code-Review", 1));
+
+    // verify that query with user1 will return results.
+    requestContext.setContext(newRequestContext(userId));
+    assertQuery("label:Code-Review=+1,group1", change1);
+    assertQuery("label:Code-Review=+1,group=group1", change1);
+    assertQuery("label:Code-Review=+1,user=user1", change1);
+    assertQuery("label:Code-Review=+1,user=user2");
+    assertQuery("label:Code-Review=+1,group=group2");
+  }
+
+  @Test
+  public void limit() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change last = null;
+    int n = 5;
+    for (int i = 0; i < n; i++) {
+      last = insert(repo, newChange(repo));
+    }
+
+    for (int i = 1; i <= n + 2; i++) {
+      int expectedSize;
+      Boolean expectedMoreChanges;
+      if (i < n) {
+        expectedSize = i;
+        expectedMoreChanges = true;
+      } else {
+        expectedSize = n;
+        expectedMoreChanges = null;
+      }
+      String q = "status:new limit:" + i;
+      List<ChangeInfo> results = newQuery(q).get();
+      assertThat(results).named(q).hasSize(expectedSize);
+      assertThat(results.get(results.size() - 1)._moreChanges)
+          .named(q)
+          .isEqualTo(expectedMoreChanges);
+      assertThat(results.get(0)._number).isEqualTo(last.getId().get());
+    }
+  }
+
+  @Test
+  public void start() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    List<Change> changes = new ArrayList<>();
+    for (int i = 0; i < 2; i++) {
+      changes.add(insert(repo, newChange(repo)));
+    }
+
+    assertQuery("status:new", changes.get(1), changes.get(0));
+    assertQuery(newQuery("status:new").withStart(1), changes.get(0));
+    assertQuery(newQuery("status:new").withStart(2));
+    assertQuery(newQuery("status:new").withStart(3));
+  }
+
+  @Test
+  public void startWithLimit() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    List<Change> changes = new ArrayList<>();
+    for (int i = 0; i < 3; i++) {
+      changes.add(insert(repo, newChange(repo)));
+    }
+
+    assertQuery("status:new limit:2", changes.get(2), changes.get(1));
+    assertQuery(newQuery("status:new limit:2").withStart(1), changes.get(1), changes.get(0));
+    assertQuery(newQuery("status:new limit:2").withStart(2), changes.get(0));
+    assertQuery(newQuery("status:new limit:2").withStart(3));
+  }
+
+  @Test
+  public void maxPages() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change = insert(repo, newChange(repo));
+
+    QueryRequest query = newQuery("status:new").withLimit(10);
+    assertQuery(query, change);
+    assertQuery(query.withStart(1));
+    assertQuery(query.withStart(99));
+    assertThatQueryException(query.withStart(100))
+        .hasMessageThat()
+        .isEqualTo("Cannot go beyond page 10 of results");
+    assertQuery(query.withLimit(100).withStart(100));
+  }
+
+  @Test
+  public void updateOrder() throws Exception {
+    resetTimeWithClockStep(2, MINUTES);
+    TestRepository<Repo> repo = createProject("repo");
+    List<ChangeInserter> inserters = new ArrayList<>();
+    List<Change> changes = new ArrayList<>();
+    for (int i = 0; i < 5; i++) {
+      inserters.add(newChange(repo));
+      changes.add(insert(repo, inserters.get(i)));
+    }
+
+    for (int i : ImmutableList.of(2, 0, 1, 4, 3)) {
+      gApi.changes()
+          .id(changes.get(i).getId().get())
+          .current()
+          .review(new ReviewInput().message("modifying " + i));
+    }
+
+    assertQuery(
+        "status:new",
+        changes.get(3),
+        changes.get(4),
+        changes.get(1),
+        changes.get(0),
+        changes.get(2));
+  }
+
+  @Test
+  public void updatedOrder() throws Exception {
+    resetTimeWithClockStep(1, SECONDS);
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins1 = newChange(repo);
+    Change change1 = insert(repo, ins1);
+    Change change2 = insert(repo, newChange(repo));
+
+    assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
+    assertQuery("status:new", change2, change1);
+
+    gApi.changes().id(change1.getId().get()).topic("new-topic");
+    change1 = notesFactory.create(db, change1.getProject(), change1.getId()).getChange();
+
+    assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
+    assertThat(lastUpdatedMs(change1) - lastUpdatedMs(change2))
+        .isAtLeast(MILLISECONDS.convert(1, SECONDS));
+
+    // change1 moved to the top.
+    assertQuery("status:new", change1, change2);
+  }
+
+  @Test
+  public void filterOutMoreThanOnePageOfResults() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change = insert(repo, newChange(repo), userId);
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    for (int i = 0; i < 5; i++) {
+      insert(repo, newChange(repo), user2);
+    }
+
+    assertQuery("status:new ownerin:Administrators", change);
+    assertQuery("status:new ownerin:Administrators limit:2", change);
+  }
+
+  @Test
+  public void filterOutAllResults() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    for (int i = 0; i < 5; i++) {
+      insert(repo, newChange(repo), user2);
+    }
+
+    assertQuery("status:new ownerin:Administrators");
+    assertQuery("status:new ownerin:Administrators limit:2");
+  }
+
+  @Test
+  public void byFileExact() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit =
+        repo.parseBody(
+            repo.commit()
+                .message("one")
+                .add("dir/file1", "contents1")
+                .add("dir/file2", "contents2")
+                .create());
+    Change change = insert(repo, newChangeForCommit(repo, commit));
+
+    assertQuery("file:file");
+    assertQuery("file:dir", change);
+    assertQuery("file:file1", change);
+    assertQuery("file:file2", change);
+    assertQuery("file:dir/file1", change);
+    assertQuery("file:dir/file2", change);
+  }
+
+  @Test
+  public void byFileRegex() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit =
+        repo.parseBody(
+            repo.commit()
+                .message("one")
+                .add("dir/file1", "contents1")
+                .add("dir/file2", "contents2")
+                .create());
+    Change change = insert(repo, newChangeForCommit(repo, commit));
+
+    assertQuery("file:.*file.*");
+    assertQuery("file:^file.*"); // Whole path only.
+    assertQuery("file:^dir.file.*", change);
+  }
+
+  @Test
+  public void byPathExact() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit =
+        repo.parseBody(
+            repo.commit()
+                .message("one")
+                .add("dir/file1", "contents1")
+                .add("dir/file2", "contents2")
+                .create());
+    Change change = insert(repo, newChangeForCommit(repo, commit));
+
+    assertQuery("path:file");
+    assertQuery("path:dir");
+    assertQuery("path:file1");
+    assertQuery("path:file2");
+    assertQuery("path:dir/file1", change);
+    assertQuery("path:dir/file2", change);
+  }
+
+  @Test
+  public void byPathRegex() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit =
+        repo.parseBody(
+            repo.commit()
+                .message("one")
+                .add("dir/file1", "contents1")
+                .add("dir/file2", "contents2")
+                .create());
+    Change change = insert(repo, newChangeForCommit(repo, commit));
+
+    assertQuery("path:.*file.*");
+    assertQuery("path:^dir.file.*", change);
+  }
+
+  @Test
+  public void byComment() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins = newChange(repo);
+    Change change = insert(repo, ins);
+
+    ReviewInput input = new ReviewInput();
+    input.message = "toplevel";
+    ReviewInput.CommentInput commentInput = new ReviewInput.CommentInput();
+    commentInput.line = 1;
+    commentInput.message = "inline";
+    input.comments =
+        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
+            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(commentInput));
+    gApi.changes().id(change.getId().get()).current().review(input);
+
+    Map<String, List<CommentInfo>> comments =
+        gApi.changes().id(change.getId().get()).current().comments();
+    assertThat(comments).hasSize(1);
+    CommentInfo comment = Iterables.getOnlyElement(comments.get(Patch.COMMIT_MSG));
+    assertThat(comment.message).isEqualTo(commentInput.message);
+    ChangeMessageInfo lastMsg =
+        Iterables.getLast(gApi.changes().id(change.getId().get()).get().messages, null);
+    assertThat(lastMsg.message).isEqualTo("Patch Set 1:\n\n(1 comment)\n\n" + input.message);
+
+    assertQuery("comment:foo");
+    assertQuery("comment:toplevel", change);
+    assertQuery("comment:inline", change);
+  }
+
+  @Test
+  public void byAge() throws Exception {
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+    resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
+    TestRepository<Repo> repo = createProject("repo");
+    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));
+
+    // Stop time so age queries use the same endpoint.
+    TestTimeUtil.setClockStep(0, MILLISECONDS);
+    TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
+    long nowMs = TimeUtil.nowMs();
+
+    assertThat(lastUpdatedMs(change2) - lastUpdatedMs(change1)).isEqualTo(thirtyHoursInMs);
+    assertThat(nowMs - lastUpdatedMs(change2)).isEqualTo(thirtyHoursInMs);
+    assertThat(TimeUtil.nowMs()).isEqualTo(nowMs);
+
+    assertQuery("-age:1d");
+    assertQuery("-age:" + (30 * 60 - 1) + "m");
+    assertQuery("-age:2d", change2);
+    assertQuery("-age:3d", change2, change1);
+    assertQuery("age:3d");
+    assertQuery("age:2d", change1);
+    assertQuery("age:1d", change2, change1);
+  }
+
+  @Test
+  public void byBefore() throws Exception {
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+    resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
+    TestRepository<Repo> repo = createProject("repo");
+    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);
+
+    assertQuery("before:2009-09-29");
+    assertQuery("before:2009-09-30");
+    assertQuery("before:\"2009-09-30 16:59:00 -0400\"");
+    assertQuery("before:\"2009-09-30 20:59:00 -0000\"");
+    assertQuery("before:\"2009-09-30 20:59:00\"");
+    assertQuery("before:\"2009-09-30 17:02:00 -0400\"", change1);
+    assertQuery("before:\"2009-10-01 21:02:00 -0000\"", change1);
+    assertQuery("before:\"2009-10-01 21:02:00\"", change1);
+    assertQuery("before:2009-10-01", change1);
+    assertQuery("before:2009-10-03", change2, change1);
+  }
+
+  @Test
+  public void byAfter() throws Exception {
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+    resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
+    TestRepository<Repo> repo = createProject("repo");
+    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);
+
+    assertQuery("after:2009-10-03");
+    assertQuery("after:\"2009-10-01 20:59:59 -0400\"", change2);
+    assertQuery("after:\"2009-10-01 20:59:59 -0000\"", change2);
+    assertQuery("after:2009-10-01", change2);
+    assertQuery("after:2009-09-30", change2, change1);
+  }
+
+  @Test
+  public void bySize() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+
+    // added = 3, deleted = 0, delta = 3
+    RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "foo\n\foo\nfoo").create());
+    // added = 0, deleted = 2, delta = 2
+    RevCommit commit2 = repo.parseBody(repo.commit().parent(commit1).add("file1", "foo").create());
+
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+
+    assertQuery("added:>4");
+    assertQuery("-added:<=4");
+
+    assertQuery("added:3", change1);
+    assertQuery("-(added:<3 OR added>3)", change1);
+
+    assertQuery("added:>2", change1);
+    assertQuery("-added:<=2", change1);
+
+    assertQuery("added:>=3", change1);
+    assertQuery("-added:<3", change1);
+
+    assertQuery("added:<1", change2);
+    assertQuery("-added:>=1", change2);
+
+    assertQuery("added:<=0", change2);
+    assertQuery("-added:>0", change2);
+
+    assertQuery("deleted:>3");
+    assertQuery("-deleted:<=3");
+
+    assertQuery("deleted:2", change2);
+    assertQuery("-(deleted:<2 OR deleted>2)", change2);
+
+    assertQuery("deleted:>1", change2);
+    assertQuery("-deleted:<=1", change2);
+
+    assertQuery("deleted:>=2", change2);
+    assertQuery("-deleted:<2", change2);
+
+    assertQuery("deleted:<1", change1);
+    assertQuery("-deleted:>=1", change1);
+
+    assertQuery("deleted:<=0", change1);
+
+    for (String str : Lists.newArrayList("delta", "size")) {
+      assertQuery(str + ":<2");
+      assertQuery(str + ":3", change1);
+      assertQuery(str + ":>2", change1);
+      assertQuery(str + ":>=3", change1);
+      assertQuery(str + ":<3", change2);
+      assertQuery(str + ":<=2", change2);
+    }
+  }
+
+  private List<Change> setUpHashtagChanges() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    HashtagsInput in = new HashtagsInput();
+    in.add = ImmutableSet.of("foo");
+    gApi.changes().id(change1.getId().get()).setHashtags(in);
+
+    in.add = ImmutableSet.of("foo", "bar", "a tag");
+    gApi.changes().id(change2.getId().get()).setHashtags(in);
+
+    return ImmutableList.of(change1, change2);
+  }
+
+  @Test
+  public void byHashtagWithNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    List<Change> changes = setUpHashtagChanges();
+    assertQuery("hashtag:foo", changes.get(1), changes.get(0));
+    assertQuery("hashtag:bar", changes.get(1));
+    assertQuery("hashtag:\"a tag\"", changes.get(1));
+    assertQuery("hashtag:\"a tag \"", changes.get(1));
+    assertQuery("hashtag:\" a tag \"", changes.get(1));
+    assertQuery("hashtag:\"#a tag\"", changes.get(1));
+    assertQuery("hashtag:\"# #a tag\"", changes.get(1));
+  }
+
+  @Test
+  public void byHashtagWithoutNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+
+    notesMigration.setWriteChanges(true);
+    notesMigration.setReadChanges(true);
+    db.close();
+    db = schemaFactory.open();
+    List<Change> changes;
+    try {
+      changes = setUpHashtagChanges();
+      notesMigration.setWriteChanges(false);
+      notesMigration.setReadChanges(false);
+    } finally {
+      db.close();
+    }
+    db = schemaFactory.open();
+    for (Change c : changes) {
+      indexer.index(db, c); // Reindex without hashtag field.
+    }
+    assertQuery("hashtag:foo");
+    assertQuery("hashtag:bar");
+    assertQuery("hashtag:\" bar \"");
+    assertQuery("hashtag:\"a tag\"");
+    assertQuery("hashtag:\" a tag \"");
+    assertQuery("hashtag:#foo");
+    assertQuery("hashtag:\"# #foo\"");
+  }
+
+  @Test
+  public void byDefault() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+
+    Change change1 = insert(repo, newChange(repo));
+
+    RevCommit commit2 = repo.parseBody(repo.commit().message("foosubject").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+
+    RevCommit commit3 = repo.parseBody(repo.commit().add("Foo.java", "foo contents").create());
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+
+    ChangeInserter ins4 = newChange(repo);
+    Change change4 = insert(repo, ins4);
+    ReviewInput ri4 = new ReviewInput();
+    ri4.message = "toplevel";
+    ri4.labels = ImmutableMap.<String, Short>of("Code-Review", (short) 1);
+    gApi.changes().id(change4.getId().get()).current().review(ri4);
+
+    ChangeInserter ins5 = newChangeWithTopic(repo, "feature5");
+    Change change5 = insert(repo, ins5);
+
+    Change change6 = insert(repo, newChangeForBranch(repo, "branch6"));
+
+    assertQuery(change1.getId().get(), change1);
+    assertQuery(ChangeTriplet.format(change1), change1);
+    assertQuery("foosubject", change2);
+    assertQuery("Foo.java", change3);
+    assertQuery("Code-Review+1", change4);
+    assertQuery("toplevel", change4);
+    assertQuery("feature5", change5);
+    assertQuery("branch6", change6);
+    assertQuery("refs/heads/branch6", change6);
+
+    Change[] expected = new Change[] {change6, change5, change4, change3, change2, change1};
+    assertQuery("user@example.com", expected);
+    assertQuery("repo", expected);
+  }
+
+  @Test
+  public void byDefaultWithCommitPrefix() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit = repo.parseBody(repo.commit().message("message").create());
+    Change change = insert(repo, newChangeForCommit(repo, commit));
+
+    assertQuery(commit.getId().getName().substring(0, 6), change);
+  }
+
+  @Test
+  public void byCommentBy() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    int user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId().get();
+
+    ReviewInput input = new ReviewInput();
+    input.message = "toplevel";
+    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
+    comment.line = 1;
+    comment.message = "inline";
+    input.comments =
+        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
+            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
+    gApi.changes().id(change1.getId().get()).current().review(input);
+
+    input = new ReviewInput();
+    input.message = "toplevel";
+    gApi.changes().id(change2.getId().get()).current().review(input);
+
+    assertQuery("commentby:" + userId.get(), change2, change1);
+    assertQuery("commentby:" + user2);
+  }
+
+  @Test
+  public void byDraftBy() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    DraftInput in = new DraftInput();
+    in.line = 1;
+    in.message = "nit: trailing whitespace";
+    in.path = Patch.COMMIT_MSG;
+    gApi.changes().id(change1.getId().get()).current().createDraft(in);
+
+    in = new DraftInput();
+    in.line = 2;
+    in.message = "nit: point in the end of the statement";
+    in.path = Patch.COMMIT_MSG;
+    gApi.changes().id(change2.getId().get()).current().createDraft(in);
+
+    int user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId().get();
+
+    assertQuery("draftby:" + userId.get(), change2, change1);
+    assertQuery("draftby:" + user2);
+  }
+
+  @Test
+  public void byDraftByExcludesZombieDrafts() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    Change change = insert(repo, newChange(repo));
+    Change.Id id = change.getId();
+
+    DraftInput in = new DraftInput();
+    in.line = 1;
+    in.message = "nit: trailing whitespace";
+    in.path = Patch.COMMIT_MSG;
+    gApi.changes().id(id.get()).current().createDraft(in);
+
+    assertQuery("draftby:" + userId, change);
+    assertQuery("commentby:" + userId);
+
+    TestRepository<Repo> allUsers = new TestRepository<>(repoManager.openRepository(allUsersName));
+
+    Ref draftsRef = allUsers.getRepository().exactRef(RefNames.refsDraftComments(id, userId));
+    assertThat(draftsRef).isNotNull();
+
+    ReviewInput rin = ReviewInput.dislike();
+    rin.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    gApi.changes().id(id.get()).current().review(rin);
+
+    assertQuery("draftby:" + userId);
+    assertQuery("commentby:" + userId, change);
+    assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNull();
+
+    // Re-add drafts ref and ensure it gets filtered out during indexing.
+    allUsers.update(draftsRef.getName(), draftsRef.getObjectId());
+    assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNotNull();
+
+    if (PrimaryStorage.of(change) == PrimaryStorage.REVIEW_DB
+        && !notesMigration.disableChangeReviewDb()) {
+      // Record draft ref in noteDbState as well.
+      ReviewDb db = ReviewDbUtil.unwrapDb(this.db);
+      change = db.changes().get(id);
+      NoteDbChangeState.applyDelta(
+          change,
+          NoteDbChangeState.Delta.create(
+              id, Optional.empty(), ImmutableMap.of(userId, draftsRef.getObjectId())));
+      db.changes().update(Collections.singleton(change));
+    }
+
+    indexer.index(db, project, id);
+    assertQuery("draftby:" + userId);
+  }
+
+  @Test
+  public void byStarredBy() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    gApi.accounts().self().starChange(change1.getId().toString());
+    gApi.accounts().self().starChange(change2.getId().toString());
+
+    int user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId().get();
+
+    assertQuery("starredby:self", change2, change1);
+    assertQuery("starredby:" + user2);
+  }
+
+  @Test
+  public void byStar() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change3 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change4 = insert(repo, newChange(repo));
+
+    gApi.accounts()
+        .self()
+        .setStars(
+            change1.getId().toString(),
+            new StarsInput(new HashSet<>(Arrays.asList("red", "blue"))));
+    gApi.accounts()
+        .self()
+        .setStars(
+            change2.getId().toString(),
+            new StarsInput(
+                new HashSet<>(Arrays.asList(StarredChangesUtil.DEFAULT_LABEL, "green", "blue"))));
+
+    gApi.accounts()
+        .self()
+        .setStars(
+            change4.getId().toString(), new StarsInput(new HashSet<>(Arrays.asList("ignore"))));
+
+    // check labeled stars
+    assertQuery("star:red", change1);
+    assertQuery("star:blue", change2, change1);
+    assertQuery("has:stars", change4, change2, change1);
+
+    // check default star
+    assertQuery("has:star", change2);
+    assertQuery("is:starred", change2);
+    assertQuery("starredby:self", change2);
+    assertQuery("star:" + StarredChangesUtil.DEFAULT_LABEL, change2);
+
+    // check ignored
+    assertQuery("is:ignored", change4);
+    assertQuery("-is:ignored", change3, change2, change1);
+  }
+
+  @Test
+  public void byIgnore() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change1 = insert(repo, newChange(repo), user2);
+    Change change2 = insert(repo, newChange(repo), user2);
+
+    gApi.changes().id(change1.getId().toString()).ignore(true);
+    assertQuery("is:ignored", change1);
+    assertQuery("-is:ignored", change2);
+
+    gApi.changes().id(change1.getId().toString()).ignore(false);
+    assertQuery("is:ignored");
+    assertQuery("-is:ignored", change2, change1);
+  }
+
+  @Test
+  public void byFrom() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change2 = insert(repo, newChange(repo), user2);
+
+    ReviewInput input = new ReviewInput();
+    input.message = "toplevel";
+    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
+    comment.line = 1;
+    comment.message = "inline";
+    input.comments =
+        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
+            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
+    gApi.changes().id(change2.getId().get()).current().review(input);
+
+    assertQuery("from:" + userId.get(), change2, change1);
+    assertQuery("from:" + user2, change2);
+  }
+
+  @Test
+  public void conflicts() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 =
+        repo.parseBody(
+            repo.commit()
+                .add("file1", "contents1")
+                .add("dir/file2", "contents2")
+                .add("dir/file3", "contents3")
+                .create());
+    RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents1").create());
+    RevCommit commit3 =
+        repo.parseBody(repo.commit().add("dir/file2", "contents2 different").create());
+    RevCommit commit4 = repo.parseBody(repo.commit().add("file4", "contents4").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+
+    assertQuery("conflicts:" + change1.getId().get(), change3);
+    assertQuery("conflicts:" + change2.getId().get());
+    assertQuery("conflicts:" + change3.getId().get(), change1);
+    assertQuery("conflicts:" + change4.getId().get());
+  }
+
+  @Test
+  public void reviewedBy() throws Exception {
+    resetTimeWithClockStep(2, MINUTES);
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
+
+    gApi.changes().id(change1.getId().get()).current().review(new ReviewInput().message("comment"));
+
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    requestContext.setContext(newRequestContext(user2));
+
+    gApi.changes().id(change2.getId().get()).current().review(new ReviewInput().message("comment"));
+
+    PatchSet.Id ps3_1 = change3.currentPatchSetId();
+    change3 = newPatchSet(repo, change3);
+    assertThat(change3.currentPatchSetId()).isNotEqualTo(ps3_1);
+    // Response to previous patch set still counts as reviewing.
+    gApi.changes()
+        .id(change3.getId().get())
+        .revision(ps3_1.get())
+        .review(new ReviewInput().message("comment"));
+
+    List<ChangeInfo> actual;
+    actual = assertQuery(newQuery("is:reviewed").withOption(REVIEWED), change3, change2);
+    assertThat(actual.get(0).reviewed).isTrue();
+    assertThat(actual.get(1).reviewed).isTrue();
+
+    actual = assertQuery(newQuery("-is:reviewed").withOption(REVIEWED), change1);
+    assertThat(actual.get(0).reviewed).isNull();
+
+    actual = assertQuery("reviewedby:" + userId.get());
+
+    actual =
+        assertQuery(newQuery("reviewedby:" + user2.get()).withOption(REVIEWED), change3, change2);
+    assertThat(actual.get(0).reviewed).isTrue();
+    assertThat(actual.get(1).reviewed).isTrue();
+  }
+
+  @Test
+  public void reviewerAndCc() throws Exception {
+    Account.Id user1 = createAccount("user1");
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = user1.toString();
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    rin = new AddReviewerInput();
+    rin.reviewer = user1.toString();
+    rin.state = ReviewerState.CC;
+    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+
+    if (notesMigration.readChanges()) {
+      assertQuery("reviewer:" + user1, change1);
+      assertQuery("cc:" + user1, change2);
+    } else {
+      assertQuery("reviewer:" + user1, change2, change1);
+      assertQuery("cc:" + user1);
+    }
+  }
+
+  @Test
+  public void reviewerAndCcByEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    String userByEmail = "un.registered@reviewer.com";
+    String userByEmailWithName = "John Doe <" + userByEmail + ">";
+
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = userByEmailWithName;
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    rin = new AddReviewerInput();
+    rin.reviewer = userByEmailWithName;
+    rin.state = ReviewerState.CC;
+    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+
+    if (getSchemaVersion() >= 41) {
+      assertQuery("reviewer:\"" + userByEmailWithName + "\"", change1);
+      assertQuery("cc:\"" + userByEmailWithName + "\"", change2);
+
+      // Omitting the name:
+      assertQuery("reviewer:\"" + userByEmail + "\"", change1);
+      assertQuery("cc:\"" + userByEmail + "\"", change2);
+    } else {
+      assertMissingField(ChangeField.REVIEWER_BY_EMAIL);
+
+      assertFailingQuery(
+          "reviewer:\"" + userByEmailWithName + "\"", "User " + userByEmailWithName + " not found");
+      assertFailingQuery(
+          "cc:\"" + userByEmailWithName + "\"", "User " + userByEmailWithName + " not found");
+
+      // Omitting the name:
+      assertFailingQuery("reviewer:\"" + userByEmail + "\"", "User " + userByEmail + " not found");
+      assertFailingQuery("cc:\"" + userByEmail + "\"", "User " + userByEmail + " not found");
+    }
+  }
+
+  @Test
+  public void reviewerAndCcByEmailWithQueryForDifferentUser() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    String userByEmail = "John Doe <un.registered@reviewer.com>";
+
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = userByEmail;
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    rin = new AddReviewerInput();
+    rin.reviewer = userByEmail;
+    rin.state = ReviewerState.CC;
+    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+
+    if (getSchemaVersion() >= 41) {
+      assertQuery("reviewer:\"someone@example.com\"");
+      assertQuery("cc:\"someone@example.com\"");
+    } else {
+      assertMissingField(ChangeField.REVIEWER_BY_EMAIL);
+
+      String someoneEmail = "someone@example.com";
+      assertFailingQuery(
+          "reviewer:\"" + someoneEmail + "\"", "User " + someoneEmail + " not found");
+      assertFailingQuery("cc:\"" + someoneEmail + "\"", "User " + someoneEmail + " not found");
+    }
+  }
+
+  @Test
+  public void submitRecords() throws Exception {
+    Account.Id user1 = createAccount("user1");
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    gApi.changes().id(change1.getId().get()).current().review(ReviewInput.approve());
+    requestContext.setContext(newRequestContext(user1));
+    gApi.changes().id(change2.getId().get()).current().review(ReviewInput.recommend());
+    requestContext.setContext(newRequestContext(user.getAccountId()));
+
+    assertQuery("is:submittable", change1);
+    assertQuery("-is:submittable", change2);
+    assertQuery("submittable:ok", change1);
+    assertQuery("submittable:not_ready", change2);
+
+    assertQuery("label:CodE-RevieW=ok", change1);
+    assertQuery("label:CodE-RevieW=ok,user=user", change1);
+    assertQuery("label:CodE-RevieW=ok,Administrators", change1);
+    assertQuery("label:CodE-RevieW=ok,group=Administrators", change1);
+    assertQuery("label:CodE-RevieW=ok,owner", change1);
+    assertQuery("label:CodE-RevieW=ok,user1");
+    assertQuery("label:CodE-RevieW=need", change2);
+    // NEED records don't have associated users.
+    assertQuery("label:CodE-RevieW=need,user1");
+    assertQuery("label:CodE-RevieW=need,user");
+  }
+
+  @Test
+  public void hasEdit() throws Exception {
+    Account.Id user1 = createAccount("user1");
+    Account.Id user2 = createAccount("user2");
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    String changeId1 = change1.getKey().get();
+    Change change2 = insert(repo, newChange(repo));
+    String changeId2 = change2.getKey().get();
+
+    requestContext.setContext(newRequestContext(user1));
+    assertQuery("has:edit");
+    gApi.changes().id(changeId1).edit().create();
+    gApi.changes().id(changeId2).edit().create();
+
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("has:edit");
+    gApi.changes().id(changeId2).edit().create();
+
+    requestContext.setContext(newRequestContext(user1));
+    assertQuery("has:edit", change2, change1);
+
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("has:edit", change2);
+  }
+
+  @Test
+  public void byUnresolved() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
+
+    // Change1 has one resolved comment (unresolvedcount = 0)
+    // Change2 has one unresolved comment (unresolvedcount = 1)
+    // Change3 has one resolved comment and one unresolved comment (unresolvedcount = 1)
+    addComment(change1.getChangeId(), "comment 1", false);
+    addComment(change2.getChangeId(), "comment 2", true);
+    addComment(change3.getChangeId(), "comment 3", false);
+    addComment(change3.getChangeId(), "comment 4", true);
+
+    assertQuery("has:unresolved", change3, change2);
+
+    assertQuery("unresolved:0", change1);
+    List<ChangeInfo> changeInfos = assertQuery("unresolved:>=0", change3, change2, change1);
+    assertThat(changeInfos.get(0).unresolvedCommentCount).isEqualTo(1); // Change3
+    assertThat(changeInfos.get(1).unresolvedCommentCount).isEqualTo(1); // Change2
+    assertThat(changeInfos.get(2).unresolvedCommentCount).isEqualTo(0); // Change1
+    assertQuery("unresolved:>0", change3, change2);
+
+    assertQuery("unresolved:<1", change1);
+    assertQuery("unresolved:<=1", change3, change2, change1);
+    assertQuery("unresolved:1", change3, change2);
+    assertQuery("unresolved:>1");
+    assertQuery("unresolved:>=1", change3, change2);
+  }
+
+  @Test
+  public void byCommitsOnBranchNotMerged() throws Exception {
+    TestRepository<Repo> tr = createProject("repo");
+    testByCommitsOnBranchNotMerged(tr, ImmutableSet.of());
+  }
+
+  @Test
+  public void byCommitsOnBranchNotMergedSkipsMissingChanges() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ObjectId missing =
+        repo.branch(new PatchSet.Id(new Change.Id(987654), 1).toRefName())
+            .commit()
+            .message("No change for this commit")
+            .insertChangeId()
+            .create()
+            .copy();
+    testByCommitsOnBranchNotMerged(repo, ImmutableSet.of(missing));
+  }
+
+  private void testByCommitsOnBranchNotMerged(TestRepository<Repo> repo, Collection<ObjectId> extra)
+      throws Exception {
+    int n = 10;
+    List<String> shas = new ArrayList<>(n + extra.size());
+    extra.forEach(i -> shas.add(i.name()));
+    List<Integer> expectedIds = new ArrayList<>(n);
+    Branch.NameKey dest = null;
+    for (int i = 0; i < n; i++) {
+      ChangeInserter ins = newChange(repo);
+      insert(repo, ins);
+      if (dest == null) {
+        dest = ins.getChange().getDest();
+      }
+      shas.add(ins.getCommitId().name());
+      expectedIds.add(ins.getChange().getId().get());
+    }
+
+    for (int i = 1; i <= 11; i++) {
+      Iterable<ChangeData> cds =
+          queryProvider.get().byCommitsOnBranchNotMerged(repo.getRepository(), db, dest, shas, i);
+      Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
+      String name = "limit " + i;
+      assertThat(ids).named(name).hasSize(n);
+      assertThat(ids).named(name).containsExactlyElementsIn(expectedIds);
+    }
+  }
+
+  @Test
+  public void prepopulatedFields() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    TestRepository<Repo> repo = createProject("repo");
+    Change change = insert(repo, newChange(repo));
+
+    db = new DisabledReviewDb();
+    requestContext.setContext(newRequestContext(userId));
+    // Use QueryProcessor directly instead of API so we get ChangeDatas back.
+    List<ChangeData> cds =
+        queryProcessorProvider
+            .get()
+            .query(queryBuilder.parse(change.getId().toString()))
+            .entities();
+    assertThat(cds).hasSize(1);
+
+    ChangeData cd = cds.get(0);
+    cd.change();
+    cd.patchSets();
+    cd.currentApprovals();
+    cd.changedLines();
+    cd.reviewedBy();
+    cd.reviewers();
+    cd.unresolvedCommentCount();
+
+    // TODO(dborowitz): Swap out GitRepositoryManager somehow? Will probably be
+    // necessary for NoteDb anyway.
+    cd.isMergeable();
+
+    exception.expect(DisabledReviewDb.Disabled.class);
+    cd.messages();
+  }
+
+  @Test
+  public void prepopulateOnlyRequestedFields() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    TestRepository<Repo> repo = createProject("repo");
+    Change change = insert(repo, newChange(repo));
+
+    db = new DisabledReviewDb();
+    requestContext.setContext(newRequestContext(userId));
+    // Use QueryProcessor directly instead of API so we get ChangeDatas back.
+    List<ChangeData> cds =
+        queryProcessorProvider
+            .get()
+            .setRequestedFields(
+                ImmutableSet.of(ChangeField.PATCH_SET.getName(), ChangeField.CHANGE.getName()))
+            .query(queryBuilder.parse(change.getId().toString()))
+            .entities();
+    assertThat(cds).hasSize(1);
+
+    ChangeData cd = cds.get(0);
+    cd.change();
+    cd.patchSets();
+
+    exception.expect(DisabledReviewDb.Disabled.class);
+    cd.currentApprovals();
+  }
+
+  @Test
+  public void reindexIfStale() throws Exception {
+    Account.Id user = createAccount("user");
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    Change change = insert(repo, newChange(repo));
+    String changeId = change.getKey().get();
+    ChangeNotes notes = notesFactory.create(db, change.getProject(), change.getId());
+    PatchSet ps = psUtil.get(db, notes, change.currentPatchSetId());
+
+    requestContext.setContext(newRequestContext(user));
+    gApi.changes().id(changeId).edit().create();
+    assertQuery("has:edit", change);
+    assertThat(indexer.reindexIfStale(project, change.getId()).get()).isFalse();
+
+    // Delete edit ref behind index's back.
+    RefUpdate ru =
+        repo.getRepository().updateRef(RefNames.refsEdit(user, change.getId(), ps.getId()));
+    ru.setForceUpdate(true);
+    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+
+    // Index is stale.
+    assertQuery("has:edit", change);
+    assertThat(indexer.reindexIfStale(project, change.getId()).get()).isTrue();
+    assertQuery("has:edit");
+  }
+
+  @Test
+  public void refStateFields() throws Exception {
+    // This test method manages primary storage manually.
+    assume().that(notesMigration.changePrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+    Account.Id user = createAccount("user");
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    String path = "file";
+    RevCommit commit = repo.parseBody(repo.commit().message("one").add(path, "contents").create());
+    Change change = insert(repo, newChangeForCommit(repo, commit));
+    Change.Id id = change.getId();
+    int c = id.get();
+    String changeId = change.getKey().get();
+    requestContext.setContext(newRequestContext(user));
+
+    // Ensure one of each type of supported ref is present for the change. If
+    // any more refs are added, update this test to reflect them.
+
+    // Edit
+    gApi.changes().id(changeId).edit().create();
+
+    // Star
+    gApi.accounts().self().starChange(change.getId().toString());
+
+    if (notesMigration.readChanges()) {
+      // Robot comment.
+      ReviewInput rin = new ReviewInput();
+      RobotCommentInput rcin = new RobotCommentInput();
+      rcin.robotId = "happyRobot";
+      rcin.robotRunId = "1";
+      rcin.line = 1;
+      rcin.message = "nit: trailing whitespace";
+      rcin.path = path;
+      rin.robotComments = ImmutableMap.of(path, ImmutableList.of(rcin));
+      gApi.changes().id(c).current().review(rin);
+    }
+
+    // Draft.
+    DraftInput din = new DraftInput();
+    din.path = path;
+    din.line = 1;
+    din.message = "draft";
+    gApi.changes().id(c).current().createDraft(din);
+
+    if (notesMigration.readChanges()) {
+      // Force NoteDb primary.
+      change = ReviewDbUtil.unwrapDb(db).changes().get(id);
+      change.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+      ReviewDbUtil.unwrapDb(db).changes().update(Collections.singleton(change));
+      indexer.index(db, change);
+    }
+
+    QueryOptions opts =
+        IndexedChangeQuery.createOptions(indexConfig, 0, 1, StalenessChecker.FIELDS);
+    ChangeData cd = indexes.getSearchIndex().get(id, opts).get();
+
+    String cs = RefNames.shard(c);
+    int u = user.get();
+    String us = RefNames.shard(u);
+
+    List<String> expectedStates =
+        Lists.newArrayList(
+            "repo:refs/users/" + us + "/edit-" + c + "/1",
+            "All-Users:refs/starred-changes/" + cs + "/" + u);
+    if (notesMigration.readChanges()) {
+      expectedStates.add("repo:refs/changes/" + cs + "/meta");
+      expectedStates.add("repo:refs/changes/" + cs + "/robot-comments");
+      expectedStates.add("All-Users:refs/draft-comments/" + cs + "/" + u);
+    }
+    assertThat(
+            cd.getRefStates()
+                .stream()
+                .map(String::new)
+                // Omit SHA-1, we're just concerned with the project/ref names.
+                .map(s -> s.substring(0, s.lastIndexOf(':')))
+                .collect(toList()))
+        .containsExactlyElementsIn(expectedStates);
+
+    List<String> expectedPatterns = Lists.newArrayList("repo:refs/users/*/edit-" + c + "/*");
+    expectedPatterns.add("All-Users:refs/starred-changes/" + cs + "/*");
+    if (notesMigration.readChanges()) {
+      expectedPatterns.add("All-Users:refs/draft-comments/" + cs + "/*");
+    }
+    assertThat(cd.getRefStatePatterns().stream().map(String::new).collect(toList()))
+        .containsExactlyElementsIn(expectedPatterns);
+  }
+
+  @Test
+  public void selfAndMe() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo), userId);
+    insert(repo, newChange(repo));
+    gApi.accounts().self().starChange(change1.getId().toString());
+    gApi.accounts().self().starChange(change2.getId().toString());
+
+    assertQuery("starredby:self", change2, change1);
+    assertQuery("starredby:me", change2, change1);
+  }
+
+  @Test
+  public void defaultFieldWithManyUsers() throws Exception {
+    for (int i = 0; i < ChangeQueryBuilder.MAX_ACCOUNTS_PER_DEFAULT_FIELD * 2; i++) {
+      createAccount("user" + i, "User " + i, "user" + i + "@example.com", true);
+    }
+    assertQuery("us");
+  }
+
+  @Test
+  public void revertOf() throws Exception {
+    if (getSchemaVersion() < 45) {
+      assertMissingField(ChangeField.REVERT_OF);
+      assertFailingQuery(
+          "revertof:1", "'revertof' operator is not supported by change index version");
+      return;
+    }
+
+    TestRepository<Repo> repo = createProject("repo");
+    // Create two commits and revert second commit (initial commit can't be reverted)
+    Change initial = insert(repo, newChange(repo));
+    gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(initial.getChangeId()).current().submit();
+
+    ChangeInfo changeToRevert =
+        gApi.changes().create(new ChangeInput("repo", "master", "commit to revert")).get();
+    gApi.changes().id(changeToRevert.id).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToRevert.id).current().submit();
+
+    ChangeInfo changeThatReverts = gApi.changes().id(changeToRevert.id).revert().get();
+    assertQueryByIds(
+        "revertof:" + changeToRevert._number, new Change.Id(changeThatReverts._number));
+  }
+
+  /** 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);
+  }
+
+  protected ChangeInserter newChangeForCommit(TestRepository<Repo> repo, RevCommit commit)
+      throws Exception {
+    return newChange(repo, commit, null, null, null, false);
+  }
+
+  protected ChangeInserter newChangeForBranch(TestRepository<Repo> repo, String branch)
+      throws Exception {
+    return newChange(repo, null, branch, null, null, false);
+  }
+
+  protected ChangeInserter newChangeWithStatus(TestRepository<Repo> repo, Change.Status status)
+      throws Exception {
+    return newChange(repo, null, null, status, null, false);
+  }
+
+  protected ChangeInserter newChangeWithTopic(TestRepository<Repo> repo, String topic)
+      throws Exception {
+    return newChange(repo, null, null, null, topic, false);
+  }
+
+  protected ChangeInserter newChangeWorkInProgress(TestRepository<Repo> repo) throws Exception {
+    return newChange(repo, null, null, null, null, true);
+  }
+
+  protected ChangeInserter newChange(
+      TestRepository<Repo> repo,
+      @Nullable RevCommit commit,
+      @Nullable String branch,
+      @Nullable Change.Status status,
+      @Nullable String topic,
+      boolean workInProgress)
+      throws Exception {
+    if (commit == null) {
+      commit = repo.parseBody(repo.commit().message("message").create());
+    }
+
+    branch = MoreObjects.firstNonNull(branch, "refs/heads/master");
+    if (!branch.startsWith("refs/heads/")) {
+      branch = "refs/heads/" + branch;
+    }
+
+    Change.Id id = new Change.Id(seq.nextChangeId());
+    ChangeInserter ins =
+        changeFactory
+            .create(id, commit, branch)
+            .setValidate(false)
+            .setStatus(status)
+            .setTopic(topic)
+            .setWorkInProgress(workInProgress);
+    return ins;
+  }
+
+  protected Change insert(TestRepository<Repo> repo, ChangeInserter ins) throws Exception {
+    return insert(repo, ins, null, TimeUtil.nowTs());
+  }
+
+  protected Change insert(TestRepository<Repo> repo, ChangeInserter ins, @Nullable Account.Id owner)
+      throws Exception {
+    return insert(repo, ins, owner, TimeUtil.nowTs());
+  }
+
+  protected Change insert(
+      TestRepository<Repo> repo,
+      ChangeInserter ins,
+      @Nullable Account.Id owner,
+      Timestamp createdOn)
+      throws Exception {
+    Project.NameKey project =
+        new Project.NameKey(repo.getRepository().getDescription().getRepositoryName());
+    Account.Id ownerId = owner != null ? owner : userId;
+    IdentifiedUser user = userFactory.create(ownerId);
+    try (BatchUpdate bu = updateFactory.create(db, project, user, createdOn)) {
+      bu.insertChange(ins);
+      bu.execute();
+      return ins.getChange();
+    }
+  }
+
+  protected Change newPatchSet(TestRepository<Repo> repo, Change c) throws Exception {
+    // Add a new file so the patch set is not a trivial rebase, to avoid default
+    // Code-Review label copying.
+    int n = c.currentPatchSetId().get() + 1;
+    RevCommit commit =
+        repo.parseBody(repo.commit().message("message").add("file" + n, "contents " + n).create());
+
+    PatchSetInserter inserter =
+        patchSetFactory
+            .create(changeNotesFactory.createChecked(db, c), new PatchSet.Id(c.getId(), n), commit)
+            .setNotify(NotifyHandling.NONE)
+            .setFireRevisionCreated(false)
+            .setValidate(false);
+    try (BatchUpdate bu = updateFactory.create(db, c.getProject(), user, TimeUtil.nowTs());
+        ObjectInserter oi = repo.getRepository().newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo.getRepository(), rw, oi);
+      bu.addOp(c.getId(), inserter);
+      bu.execute();
+    }
+
+    return inserter.getChange();
+  }
+
+  protected ThrowableSubject assertThatQueryException(Object query) throws Exception {
+    return assertThatQueryException(newQuery(query));
+  }
+
+  protected ThrowableSubject assertThatQueryException(QueryRequest query) throws Exception {
+    try {
+      query.get();
+      throw new AssertionError("expected BadRequestException for query: " + query);
+    } catch (BadRequestException e) {
+      return assertThat(e);
+    }
+  }
+
+  protected TestRepository<Repo> createProject(String name) throws Exception {
+    gApi.projects().create(name).get();
+    return new TestRepository<>(repoManager.openRepository(new Project.NameKey(name)));
+  }
+
+  protected QueryRequest newQuery(Object query) {
+    return gApi.changes().query(query.toString());
+  }
+
+  protected List<ChangeInfo> assertQuery(Object query, Change... changes) throws Exception {
+    return assertQuery(newQuery(query), changes);
+  }
+
+  protected List<ChangeInfo> assertQueryByIds(Object query, Change.Id... changes) throws Exception {
+    return assertQueryByIds(newQuery(query), changes);
+  }
+
+  protected List<ChangeInfo> assertQuery(QueryRequest query, Change... changes) throws Exception {
+    return assertQueryByIds(
+        query, Arrays.stream(changes).map(Change::getId).toArray(Change.Id[]::new));
+  }
+
+  protected List<ChangeInfo> assertQueryByIds(QueryRequest query, Change.Id... changes)
+      throws Exception {
+    List<ChangeInfo> result = query.get();
+    Iterable<Change.Id> ids = ids(result);
+    assertThat(ids)
+        .named(format(query, ids, changes))
+        .containsExactlyElementsIn(Arrays.asList(changes))
+        .inOrder();
+    return result;
+  }
+
+  private String format(
+      QueryRequest query, Iterable<Change.Id> actualIds, Change.Id... expectedChanges)
+      throws RestApiException {
+    StringBuilder b = new StringBuilder();
+    b.append("query '").append(query.getQuery()).append("' with expected changes ");
+    b.append(format(Arrays.asList(expectedChanges)));
+    b.append(" and result ");
+    b.append(format(actualIds));
+    return b.toString();
+  }
+
+  private String format(Iterable<Change.Id> changeIds) throws RestApiException {
+    return format(changeIds.iterator());
+  }
+
+  private String format(Iterator<Change.Id> changeIds) throws RestApiException {
+    StringBuilder b = new StringBuilder();
+    b.append("[");
+    while (changeIds.hasNext()) {
+      Change.Id id = changeIds.next();
+      ChangeInfo c = gApi.changes().id(id.get()).get();
+      b.append("{")
+          .append(id)
+          .append(" (")
+          .append(c.changeId)
+          .append("), ")
+          .append("dest=")
+          .append(new Branch.NameKey(new Project.NameKey(c.project), c.branch))
+          .append(", ")
+          .append("status=")
+          .append(c.status)
+          .append(", ")
+          .append("lastUpdated=")
+          .append(c.updated.getTime())
+          .append("}");
+      if (changeIds.hasNext()) {
+        b.append(", ");
+      }
+    }
+    b.append("]");
+    return b.toString();
+  }
+
+  protected static Iterable<Change.Id> ids(Change... changes) {
+    return Arrays.stream(changes).map(c -> c.getId()).collect(toList());
+  }
+
+  protected static Iterable<Change.Id> ids(Iterable<ChangeInfo> changes) {
+    return Streams.stream(changes).map(c -> new Change.Id(c._number)).collect(toList());
+  }
+
+  protected static long lastUpdatedMs(Change c) {
+    return c.getLastUpdatedOn().getTime();
+  }
+
+  private void addComment(int changeId, String message, Boolean unresolved) throws Exception {
+    ReviewInput input = new ReviewInput();
+    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
+    comment.line = 1;
+    comment.message = message;
+    comment.unresolved = unresolved;
+    input.comments =
+        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
+            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
+    gApi.changes().id(changeId).current().review(input);
+  }
+
+  private Account.Id createAccount(String username, String fullName, String email, boolean active)
+      throws Exception {
+    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
+      Account.Id id = accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
+      if (email != null) {
+        accountManager.link(id, AuthRequest.forEmail(email));
+      }
+      accountsUpdate
+          .create()
+          .update(
+              "Update Test Account",
+              id,
+              u -> {
+                u.setFullName(fullName).setPreferredEmail(email).setActive(active);
+              });
+      return id;
+    }
+  }
+
+  protected void assertMissingField(FieldDef<ChangeData, ?> field) {
+    assertThat(getSchema().hasField(field))
+        .named("schema %s has field %s", getSchemaVersion(), field.getName())
+        .isFalse();
+  }
+
+  protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
+    try {
+      assertQuery(query);
+      fail("expected BadRequestException for query '" + query + "'");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+
+  protected int getSchemaVersion() {
+    return getSchema().getVersion();
+  }
+
+  protected Schema<ChangeData> getSchema() {
+    return indexes.getSearchIndex().getSchema();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
new file mode 100644
index 0000000..5ade4ef
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -0,0 +1,50 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+ABSTRACT_QUERY_TEST = ["AbstractQueryChangesTest.java"]
+
+java_library(
+    name = "abstract_query_tests",
+    testonly = 1,
+    srcs = ABSTRACT_QUERY_TEST,
+    visibility = ["//visibility:public"],
+    runtime_deps = ["//prolog:gerrit-prolog-common"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:gwtorm",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+)
+
+junit_tests(
+    name = "lucene_query_test",
+    size = "large",
+    srcs = glob(
+        ["*.java"],
+        exclude = ABSTRACT_QUERY_TEST,
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":abstract_query_tests",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//javatests/com/google/gerrit/server/query:index-config",
+        "//lib:gwtorm",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
new file mode 100644
index 0000000..c8637cd
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.testing.TestChanges;
+import org.junit.Test;
+
+public class ChangeDataTest {
+  @Test
+  public void setPatchSetsClearsCurrentPatchSet() throws Exception {
+    Project.NameKey project = new Project.NameKey("project");
+    ChangeData cd = ChangeData.createForTest(project, new Change.Id(1), 1);
+    cd.setChange(TestChanges.newChange(project, new Account.Id(1000)));
+    PatchSet curr1 = cd.currentPatchSet();
+    int currId = curr1.getId().get();
+    PatchSet ps1 = new PatchSet(new PatchSet.Id(cd.getId(), currId + 1));
+    PatchSet ps2 = new PatchSet(new PatchSet.Id(cd.getId(), currId + 2));
+    cd.setPatchSets(ImmutableList.of(ps1, ps2));
+    PatchSet curr2 = cd.currentPatchSet();
+    assertThat(curr2).isNotSameAs(curr1);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
new file mode 100644
index 0000000..e0ddc4c
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.query.IndexConfig;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testing.IndexVersions;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class LuceneQueryChangesTest extends AbstractQueryChangesTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForLucene();
+  }
+
+  @ConfigSuite.Configs
+  public static Map<String, Config> againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    List<Integer> schemaVersions = IndexVersions.getWithoutLatest(ChangeSchemaDefinitions.INSTANCE);
+    return IndexVersions.asConfigMap(
+        ChangeSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config luceneConfig = new Config(config);
+    InMemoryModule.setDefaults(luceneConfig);
+    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
+  }
+
+  @Test
+  public void fullTextWithSpecialChars() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("foo_bar_foo").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("one.two.three").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+
+    assertQuery("message:foo_ba");
+    assertQuery("message:bar", change1);
+    assertQuery("message:foo_bar", change1);
+    assertQuery("message:foo bar", change1);
+    assertQuery("message:two", change2);
+    assertQuery("message:one.two", change2);
+    assertQuery("message:one two", change2);
+  }
+
+  @Test
+  public void byOwnerInvalidQuery() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    String nameEmail = user.asIdentifiedUser().getNameEmail();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Cannot create full-text query with value: \\");
+    assertQuery("owner: \"" + nameEmail + "\"\\", change1);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/RegexPathPredicateTest.java b/javatests/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
rename to javatests/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
new file mode 100644
index 0000000..7dfc08c
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -0,0 +1,573 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.group;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.CharMatcher;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.api.groups.Groups.QueryRequest;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.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.account.GroupCache;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.ServerInitiated;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.index.group.GroupField;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+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.testing.GerritServerTests;
+import com.google.gerrit.testing.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 java.util.Optional;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+
+@Ignore
+public abstract class AbstractQueryGroupsTest extends GerritServerTests {
+  @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 AllProjectsName allProjects;
+
+  @Inject protected GroupCache groupCache;
+
+  @Inject @ServerInitiated protected Provider<GroupsUpdate> groupsUpdateProvider;
+
+  @Inject protected GroupIndexCollection indexes;
+
+  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();
+  }
+
+  @After
+  public void cleanUp() {
+    lifecycle.stop();
+    db.close();
+  }
+
+  protected void setUpDatabase() throws Exception {
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+
+    Account.Id userId =
+        createAccountOutsideRequestContext("user", "User", "user@example.com", true);
+    user = userFactory.create(userId);
+    requestContext.setContext(newRequestContext(userId));
+    currentUserInfo = gApi.accounts().id(userId.get()).get();
+  }
+
+  protected 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();
+    }
+    if (requestContext != null) {
+      requestContext.setContext(null);
+    }
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
+  }
+
+  @Test
+  public void byUuid() throws Exception {
+    assertQuery("uuid:6d70856bc40ded50f2585c4c0f7e179f3544a272");
+    assertQuery("uuid:non-existing");
+
+    GroupInfo group = createGroup(name("group"));
+    assertQuery("uuid:" + group.id, group);
+
+    GroupInfo admins = gApi.groups().id("Administrators").get();
+    assertQuery("uuid:" + admins.id, admins);
+  }
+
+  @Test
+  public void byName() throws Exception {
+    assertQuery("name:non-existing");
+
+    GroupInfo group = createGroup(name("Group"));
+    assertQuery("name:" + group.name, group);
+    assertQuery("name:" + group.name.toLowerCase(Locale.US));
+
+    // only exact match
+    GroupInfo groupWithHyphen = createGroup(name("group-with-hyphen"));
+    createGroup(name("group-no-match-with-hyphen"));
+    assertQuery("name:" + groupWithHyphen.name, groupWithHyphen);
+  }
+
+  @Test
+  public void byInname() throws Exception {
+    String namePart = getSanitizedMethodName();
+    namePart = CharMatcher.is('_').removeFrom(namePart);
+
+    GroupInfo group1 = createGroup("group-" + namePart);
+    GroupInfo group2 = createGroup("group-" + namePart + "-2");
+    GroupInfo group3 = createGroup("group-" + namePart + "3");
+    assertQuery("inname:" + namePart, group1, group2, group3);
+    assertQuery("inname:" + namePart.toUpperCase(Locale.US), group1, group2, group3);
+    assertQuery("inname:" + namePart.toLowerCase(Locale.US), group1, group2, group3);
+  }
+
+  @Test
+  public void byDescription() throws Exception {
+    GroupInfo group1 = createGroupWithDescription(name("group1"), "This is a test group.");
+    GroupInfo group2 = createGroupWithDescription(name("group2"), "ANOTHER TEST GROUP.");
+    createGroupWithDescription(name("group3"), "Maintainers of project foo.");
+    assertQuery("description:test", group1, group2);
+
+    assertQuery("description:non-existing");
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("description operator requires a value");
+    assertQuery("description:\"\"");
+  }
+
+  @Test
+  public void byOwner() throws Exception {
+    GroupInfo ownerGroup = createGroup(name("owner-group"));
+    GroupInfo group = createGroupWithOwner(name("group"), ownerGroup);
+    createGroup(name("group2"));
+
+    assertQuery("owner:" + group.id);
+
+    // ownerGroup owns itself
+    assertQuery("owner:" + ownerGroup.id, group, ownerGroup);
+    assertQuery("owner:" + ownerGroup.name, group, ownerGroup);
+  }
+
+  @Test
+  public void byIsVisibleToAll() throws Exception {
+    assertQuery("is:visibletoall");
+
+    GroupInfo groupThatIsVisibleToAll =
+        createGroupThatIsVisibleToAll(name("group-that-is-visible-to-all"));
+    createGroup(name("group"));
+
+    assertQuery("is:visibletoall", groupThatIsVisibleToAll);
+  }
+
+  @Test
+  public void byMember() throws Exception {
+    if (getSchemaVersion() < 4) {
+      assertMissingField(GroupField.MEMBER);
+      assertFailingQuery(
+          "member:someName", "'member' operator is not supported by group index version");
+      return;
+    }
+
+    AccountInfo user1 = createAccount("user1", "User1", "user1@example.com");
+    AccountInfo user2 = createAccount("user2", "User2", "user2@example.com");
+
+    GroupInfo group1 = createGroup(name("group1"), user1);
+    GroupInfo group2 = createGroup(name("group2"), user2);
+    GroupInfo group3 = createGroup(name("group3"), user1);
+
+    assertQuery("member:" + user1.name, group1, group3);
+    assertQuery("member:" + user1.email, group1, group3);
+
+    gApi.groups().id(group3.id).removeMembers(user1.username);
+    gApi.groups().id(group2.id).addMembers(user1.username);
+
+    assertQuery("member:" + user1.name, group1, group2);
+  }
+
+  @Test
+  public void bySubgroups() throws Exception {
+    if (getSchemaVersion() < 4) {
+      assertMissingField(GroupField.SUBGROUP);
+      assertFailingQuery(
+          "subgroup:someGroupName", "'subgroup' operator is not supported by group index version");
+      return;
+    }
+
+    GroupInfo superParentGroup = createGroup(name("superParentGroup"));
+    GroupInfo parentGroup1 = createGroup(name("parentGroup1"));
+    GroupInfo parentGroup2 = createGroup(name("parentGroup2"));
+    GroupInfo subGroup = createGroup(name("subGroup"));
+
+    gApi.groups().id(superParentGroup.id).addGroups(parentGroup1.id, parentGroup2.id);
+    gApi.groups().id(parentGroup1.id).addGroups(subGroup.id);
+    gApi.groups().id(parentGroup2.id).addGroups(subGroup.id);
+
+    assertQuery("subgroup:" + subGroup.id, parentGroup1, parentGroup2);
+    assertQuery("subgroup:" + parentGroup1.id, superParentGroup);
+
+    gApi.groups().id(superParentGroup.id).addGroups(subGroup.id);
+    gApi.groups().id(parentGroup1.id).removeGroups(subGroup.id);
+
+    assertQuery("subgroup:" + subGroup.id, superParentGroup, parentGroup2);
+  }
+
+  @Test
+  public void byDefaultField() throws Exception {
+    GroupInfo group1 = createGroup(name("foo-group"));
+    GroupInfo group2 = createGroup(name("group2"));
+    GroupInfo group3 =
+        createGroupWithDescription(
+            name("group3"), "decription that contains foo and the UUID of group2: " + group2.id);
+
+    assertQuery("non-existing");
+    assertQuery("foo", group1, group3);
+    assertQuery(group2.id, group2, group3);
+  }
+
+  @Test
+  public void withLimit() throws Exception {
+    GroupInfo group1 = createGroup(name("group1"));
+    GroupInfo group2 = createGroup(name("group2"));
+    GroupInfo group3 = createGroup(name("group3"));
+
+    String query = "uuid:" + group1.id + " OR uuid:" + group2.id + " OR uuid:" + group3.id;
+    List<GroupInfo> result = assertQuery(query, group1, group2, group3);
+    assertThat(result.get(result.size() - 1)._moreGroups).isNull();
+
+    result = assertQuery(newQuery(query).withLimit(2), result.subList(0, 2));
+    assertThat(result.get(result.size() - 1)._moreGroups).isTrue();
+  }
+
+  @Test
+  public void withStart() throws Exception {
+    GroupInfo group1 = createGroup(name("group1"));
+    GroupInfo group2 = createGroup(name("group2"));
+    GroupInfo group3 = createGroup(name("group3"));
+
+    String query = "uuid:" + group1.id + " OR uuid:" + group2.id + " OR uuid:" + group3.id;
+    List<GroupInfo> result = assertQuery(query, group1, group2, group3);
+
+    assertQuery(newQuery(query).withStart(1), result.subList(1, 3));
+  }
+
+  @Test
+  public void asAnonymous() throws Exception {
+    GroupInfo group = createGroup(name("group"));
+
+    setAnonymous();
+    assertQuery("uuid:" + group.id);
+  }
+
+  // reindex permissions are tested by {@link GroupsIT#reindexPermissions}
+  @Test
+  public void reindex() throws Exception {
+    GroupInfo group1 = createGroupWithDescription(name("group"), "barX");
+
+    // update group in the database so that group index is stale
+    String newDescription = "barY";
+    AccountGroup.UUID groupUuid = new AccountGroup.UUID(group1.id);
+    InternalGroupUpdate groupUpdate =
+        InternalGroupUpdate.builder().setDescription(newDescription).build();
+    groupsUpdateProvider.get().updateGroupInDb(db, groupUuid, groupUpdate);
+
+    assertQuery("description:" + group1.description, group1);
+    assertQuery("description:" + newDescription);
+
+    gApi.groups().id(group1.id).index();
+    assertQuery("description:" + group1.description);
+    assertQuery("description:" + newDescription, group1);
+  }
+
+  @Test
+  public void rawDocument() throws Exception {
+    GroupInfo group1 = createGroup(name("group1"));
+    AccountGroup.UUID uuid = new AccountGroup.UUID(group1.id);
+
+    Optional<FieldBundle> rawFields =
+        indexes
+            .getSearchIndex()
+            .getRaw(
+                uuid,
+                QueryOptions.create(
+                    IndexConfig.createDefault(),
+                    0,
+                    10,
+                    indexes.getSearchIndex().getSchema().getStoredFields().keySet()));
+
+    assertThat(rawFields.isPresent()).isTrue();
+    assertThat(rawFields.get().getValue(GroupField.UUID)).isEqualTo(uuid.get());
+  }
+
+  private Account.Id createAccountOutsideRequestContext(
+      String username, String fullName, String email, boolean active) throws Exception {
+    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
+      Account.Id id = accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
+      if (email != null) {
+        accountManager.link(id, AuthRequest.forEmail(email));
+      }
+      accountsUpdate
+          .create()
+          .update(
+              "Update Test Account",
+              id,
+              u -> {
+                u.setFullName(fullName).setPreferredEmail(email).setActive(active);
+              });
+      return id;
+    }
+  }
+
+  protected AccountInfo createAccount(String username, String fullName, String email)
+      throws Exception {
+    String uniqueName = name(username);
+    AccountInput accountInput = new AccountInput();
+    accountInput.username = uniqueName;
+    accountInput.name = fullName;
+    accountInput.email = email;
+    return gApi.accounts().create(accountInput).get();
+  }
+
+  protected GroupInfo createGroup(String name, AccountInfo... members) throws Exception {
+    return createGroupWithDescription(name, null, members);
+  }
+
+  protected GroupInfo createGroupWithDescription(
+      String name, String description, AccountInfo... members) throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = name;
+    in.description = description;
+    in.members =
+        Arrays.asList(members).stream().map(a -> String.valueOf(a._accountId)).collect(toList());
+    return gApi.groups().create(in).get();
+  }
+
+  protected GroupInfo createGroupWithOwner(String name, GroupInfo ownerGroup) throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = name;
+    in.ownerId = ownerGroup.id;
+    return gApi.groups().create(in).get();
+  }
+
+  protected GroupInfo createGroupThatIsVisibleToAll(String name) throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = name;
+    in.visibleToAll = true;
+    return gApi.groups().create(in).get();
+  }
+
+  protected GroupInfo getGroup(AccountGroup.UUID uuid) throws Exception {
+    return gApi.groups().id(uuid.get()).get();
+  }
+
+  protected List<GroupInfo> assertQuery(Object query, GroupInfo... groups) throws Exception {
+    return assertQuery(newQuery(query), groups);
+  }
+
+  protected List<GroupInfo> assertQuery(QueryRequest query, GroupInfo... groups) throws Exception {
+    return assertQuery(query, Arrays.asList(groups));
+  }
+
+  protected List<GroupInfo> assertQuery(QueryRequest query, List<GroupInfo> groups)
+      throws Exception {
+    List<GroupInfo> result = query.get();
+    Iterable<String> uuids = uuids(result);
+    assertThat(uuids).named(format(query, result, groups)).containsExactlyElementsIn(uuids(groups));
+    return result;
+  }
+
+  protected QueryRequest newQuery(Object query) {
+    return gApi.groups().query(query.toString());
+  }
+
+  protected String format(
+      QueryRequest query, List<GroupInfo> actualGroups, List<GroupInfo> expectedGroups) {
+    StringBuilder b = new StringBuilder();
+    b.append("query '").append(query.getQuery()).append("' with expected groups ");
+    b.append(format(expectedGroups));
+    b.append(" and result ");
+    b.append(format(actualGroups));
+    return b.toString();
+  }
+
+  protected String format(Iterable<GroupInfo> groups) {
+    StringBuilder b = new StringBuilder();
+    b.append("[");
+    Iterator<GroupInfo> it = groups.iterator();
+    while (it.hasNext()) {
+      GroupInfo g = it.next();
+      b.append("{")
+          .append(g.id)
+          .append(", ")
+          .append("name=")
+          .append(g.name)
+          .append(", ")
+          .append("groupId=")
+          .append(g.groupId)
+          .append(", ")
+          .append("url=")
+          .append(g.url)
+          .append(", ")
+          .append("ownerId=")
+          .append(g.ownerId)
+          .append(", ")
+          .append("owner=")
+          .append(g.owner)
+          .append(", ")
+          .append("description=")
+          .append(g.description)
+          .append(", ")
+          .append("visibleToAll=")
+          .append(toBoolean(g.options.visibleToAll))
+          .append("}");
+      if (it.hasNext()) {
+        b.append(", ");
+      }
+    }
+    b.append("]");
+    return b.toString();
+  }
+
+  protected static boolean toBoolean(Boolean b) {
+    return b == null ? false : b;
+  }
+
+  protected static Iterable<String> ids(GroupInfo... groups) {
+    return uuids(Arrays.asList(groups));
+  }
+
+  protected static Iterable<String> uuids(List<GroupInfo> groups) {
+    return groups.stream().map(g -> g.id).collect(toList());
+  }
+
+  protected String name(String name) {
+    if (name == null) {
+      return null;
+    }
+
+    return name + "_" + getSanitizedMethodName();
+  }
+
+  protected void assertMissingField(FieldDef<InternalGroup, ?> field) {
+    assertThat(getSchema().hasField(field))
+        .named("schema %s has field %s", getSchemaVersion(), field.getName())
+        .isFalse();
+  }
+
+  protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
+    try {
+      assertQuery(query);
+      fail("expected BadRequestException for query '" + query + "'");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+
+  protected int getSchemaVersion() {
+    return getSchema().getVersion();
+  }
+
+  protected Schema<InternalGroup> getSchema() {
+    return indexes.getSearchIndex().getSchema();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
new file mode 100644
index 0000000..e9e206e
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -0,0 +1,40 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+ABSTRACT_QUERY_TEST = ["AbstractQueryGroupsTest.java"]
+
+java_library(
+    name = "abstract_query_tests",
+    testonly = 1,
+    srcs = ABSTRACT_QUERY_TEST,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
+
+junit_tests(
+    name = "lucene_query_test",
+    size = "large",
+    srcs = glob(
+        ["*.java"],
+        exclude = ABSTRACT_QUERY_TEST,
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":abstract_query_tests",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//javatests/com/google/gerrit/server/query:index-config",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
new file mode 100644
index 0000000..be231a3
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.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.group;
+
+import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.server.query.IndexConfig;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexVersions;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+public class LuceneQueryGroupsTest extends AbstractQueryGroupsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForLucene();
+  }
+
+  @ConfigSuite.Configs
+  public static Map<String, Config> againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    List<Integer> schemaVersions = IndexVersions.getWithoutLatest(GroupSchemaDefinitions.INSTANCE);
+    return IndexVersions.asConfigMap(
+        GroupSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config luceneConfig = new Config(config);
+    InMemoryModule.setDefaults(luceneConfig);
+    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
new file mode 100644
index 0000000..e450b42
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -0,0 +1,368 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.testing.GerritServerTests;
+import com.google.gerrit.testing.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.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+
+@Ignore
+public abstract class AbstractQueryProjectsTest extends GerritServerTests {
+  @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();
+  }
+
+  @After
+  public void cleanUp() {
+    lifecycle.stop();
+    db.close();
+  }
+
+  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(
+              "Update Test Account",
+              id,
+              u -> {
+                u.setFullName(fullName).setPreferredEmail(email).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/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
new file mode 100644
index 0000000..760aa36
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -0,0 +1,39 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+ABSTRACT_QUERY_TEST = ["AbstractQueryProjectsTest.java"]
+
+java_library(
+    name = "abstract_query_tests",
+    testonly = 1,
+    srcs = ABSTRACT_QUERY_TEST,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
+
+junit_tests(
+    name = "lucene_query_test",
+    size = "large",
+    srcs = glob(
+        ["*.java"],
+        exclude = ABSTRACT_QUERY_TEST,
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":abstract_query_tests",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//javatests/com/google/gerrit/server/query:index-config",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
new file mode 100644
index 0000000..a537dc9
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.project;
+
+import com.google.gerrit.server.index.project.ProjectSchemaDefinitions;
+import com.google.gerrit.server.query.IndexConfig;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.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.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForLucene();
+  }
+
+  @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/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
new file mode 100644
index 0000000..04a6485
--- /dev/null
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -0,0 +1,19 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "prolog_tests",
+    srcs = glob(["*.java"]),
+    resource_strip_prefix = "prologtests",
+    resources = ["//prologtests:gerrit_common_test"],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/prolog:runtime",
+        "//prolog:gerrit-prolog-common",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
new file mode 100644
index 0000000..152b057
--- /dev/null
+++ b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
@@ -0,0 +1,91 @@
+// 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.rules;
+
+import static org.easymock.EasyMock.expect;
+
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.server.project.testing.Util;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.AbstractModule;
+import com.googlecode.prolog_cafe.exceptions.CompileException;
+import com.googlecode.prolog_cafe.exceptions.ReductionLimitException;
+import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import java.io.PushbackReader;
+import java.io.StringReader;
+import java.util.Arrays;
+import org.easymock.EasyMock;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+public class GerritCommonTest extends PrologTestCase {
+  @Before
+  public void setUp() throws Exception {
+    load(
+        "gerrit",
+        "gerrit_common_test.pl",
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            Config cfg = new Config();
+            cfg.setInt("rules", null, "reductionLimit", 1300);
+            cfg.setInt("rules", null, "compileReductionLimit", (int) 1e6);
+            bind(PrologEnvironment.Args.class)
+                .toInstance(
+                    new PrologEnvironment.Args(null, null, null, null, null, null, null, cfg));
+          }
+        });
+  }
+
+  @Override
+  protected void setUpEnvironment(PrologEnvironment env) throws Exception {
+    LabelTypes labelTypes = new LabelTypes(Arrays.asList(Util.codeReview(), Util.verified()));
+    ChangeData cd = EasyMock.createMock(ChangeData.class);
+    expect(cd.getLabelTypes()).andStubReturn(labelTypes);
+    EasyMock.replay(cd);
+    env.set(StoredValues.CHANGE_DATA, cd);
+  }
+
+  @Test
+  public void gerritCommon() throws Exception {
+    runPrologBasedTests();
+  }
+
+  @Test
+  public void reductionLimit() throws Exception {
+    PrologEnvironment env = envFactory.create(machine);
+    setUpEnvironment(env);
+
+    String script = "loopy :- b(5).\nb(N) :- N > 0, !, S = N - 1, b(S).\nb(_) :- true.\n";
+
+    SymbolTerm nameTerm = SymbolTerm.create("testReductionLimit");
+    JavaObjectTerm inTerm =
+        new JavaObjectTerm(new PushbackReader(new StringReader(script), Prolog.PUSHBACK_SIZE));
+    if (!env.execute(Prolog.BUILTIN, "consult_stream", nameTerm, inTerm)) {
+      throw new CompileException("Cannot consult " + nameTerm);
+    }
+
+    exception.expect(ReductionLimitException.class);
+    exception.expectMessage("exceeded reduction limit of 1300");
+    env.once(
+        Prolog.BUILTIN,
+        "call",
+        new StructureTerm(":", SymbolTerm.create("user"), SymbolTerm.create("loopy")));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/rules/PrologTestCase.java b/javatests/com/google/gerrit/server/rules/PrologTestCase.java
new file mode 100644
index 0000000..e8eea2d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/rules/PrologTestCase.java
@@ -0,0 +1,189 @@
+// 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.rules;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.inject.Guice;
+import com.google.inject.Module;
+import com.googlecode.prolog_cafe.exceptions.CompileException;
+import com.googlecode.prolog_cafe.lang.BufferingPrologControl;
+import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.PrologClassLoader;
+import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import com.googlecode.prolog_cafe.lang.VariableTerm;
+import java.io.BufferedReader;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.PushbackReader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Ignore;
+
+/** Base class for any tests written in Prolog. */
+@Ignore
+public abstract class PrologTestCase extends GerritBaseTests {
+  private static final SymbolTerm test_1 = SymbolTerm.intern("test", 1);
+
+  private String pkg;
+  private boolean hasSetup;
+  private boolean hasTeardown;
+  private List<Term> tests;
+  protected PrologMachineCopy machine;
+  protected PrologEnvironment.Factory envFactory;
+
+  protected void load(String pkg, String prologResource, Module... modules)
+      throws CompileException, IOException {
+    ArrayList<Module> moduleList = new ArrayList<>();
+    moduleList.add(new PrologModule.EnvironmentModule());
+    moduleList.addAll(Arrays.asList(modules));
+
+    envFactory = Guice.createInjector(moduleList).getInstance(PrologEnvironment.Factory.class);
+    PrologEnvironment env = envFactory.create(newMachine());
+    consult(env, getClass(), prologResource);
+
+    this.pkg = pkg;
+    hasSetup = has(env, "setup");
+    hasTeardown = has(env, "teardown");
+
+    StructureTerm head =
+        new StructureTerm(
+            ":", SymbolTerm.intern(pkg), new StructureTerm(test_1, new VariableTerm()));
+
+    tests = new ArrayList<>();
+    for (Term[] pair : env.all(Prolog.BUILTIN, "clause", head, new VariableTerm())) {
+      tests.add(pair[0]);
+    }
+    assertThat(tests).isNotEmpty();
+    machine = PrologMachineCopy.save(env);
+  }
+
+  /**
+   * Set up the Prolog environment.
+   *
+   * @param env Prolog environment.
+   */
+  protected void setUpEnvironment(PrologEnvironment env) throws Exception {}
+
+  private PrologMachineCopy newMachine() {
+    BufferingPrologControl ctl = new BufferingPrologControl();
+    ctl.setMaxDatabaseSize(16 * 1024);
+    ctl.setPrologClassLoader(new PrologClassLoader(getClass().getClassLoader()));
+    return PrologMachineCopy.save(ctl);
+  }
+
+  protected void consult(BufferingPrologControl env, Class<?> clazz, String prologResource)
+      throws CompileException, IOException {
+    try (InputStream in = clazz.getResourceAsStream(prologResource)) {
+      if (in == null) {
+        throw new FileNotFoundException(prologResource);
+      }
+      SymbolTerm pathTerm = SymbolTerm.create(prologResource);
+      JavaObjectTerm inTerm =
+          new JavaObjectTerm(
+              new PushbackReader(
+                  new BufferedReader(new InputStreamReader(in, UTF_8)), Prolog.PUSHBACK_SIZE));
+      if (!env.execute(Prolog.BUILTIN, "consult_stream", pathTerm, inTerm)) {
+        throw new CompileException("Cannot consult " + prologResource);
+      }
+    }
+  }
+
+  private boolean has(BufferingPrologControl env, String name) {
+    StructureTerm head = SymbolTerm.create(pkg, name, 0);
+    return env.execute(Prolog.BUILTIN, "clause", head, new VariableTerm());
+  }
+
+  public void runPrologBasedTests() throws Exception {
+    int errors = 0;
+    long start = TimeUtil.nowMs();
+
+    for (Term test : tests) {
+      PrologEnvironment env = envFactory.create(machine);
+      setUpEnvironment(env);
+      env.setEnabled(Prolog.Feature.IO, true);
+
+      System.out.format("Prolog %-60s ...", removePackage(test));
+      System.out.flush();
+
+      if (hasSetup) {
+        call(env, "setup");
+      }
+
+      List<Term> all = env.all(Prolog.BUILTIN, "call", test);
+
+      if (hasTeardown) {
+        call(env, "teardown");
+      }
+
+      System.out.println(all.size() == 1 ? "OK" : "FAIL");
+
+      if (all.size() > 0 && !test.equals(all.get(0))) {
+        for (Term t : all) {
+          Term head = ((StructureTerm) removePackage(t)).args()[0];
+          Term[] args = ((StructureTerm) head).args();
+          System.out.print("  Result: ");
+          for (int i = 0; i < args.length; i++) {
+            if (0 < i) {
+              System.out.print(", ");
+            }
+            System.out.print(args[i]);
+          }
+          System.out.println();
+        }
+        System.out.println();
+      }
+
+      if (all.size() != 1) {
+        errors++;
+      }
+    }
+
+    long end = TimeUtil.nowMs();
+    System.out.println("-------------------------------");
+    System.out.format(
+        "Prolog tests: %d, Failures: %d, Time elapsed %.3f sec",
+        tests.size(), errors, (end - start) / 1000.0);
+    System.out.println();
+
+    assertThat(errors).isEqualTo(0);
+  }
+
+  private void call(BufferingPrologControl env, String name) {
+    StructureTerm head = SymbolTerm.create(pkg, name, 0);
+    assertWithMessage("Cannot invoke " + pkg + ":" + name)
+        .that(env.execute(Prolog.BUILTIN, "call", head))
+        .isTrue();
+  }
+
+  private Term removePackage(Term test) {
+    Term name = test;
+    if (name instanceof StructureTerm && ":".equals(name.name())) {
+      name = name.arg(1);
+    }
+    return name;
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/HANATest.java b/javatests/com/google/gerrit/server/schema/HANATest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/schema/HANATest.java
rename to javatests/com/google/gerrit/server/schema/HANATest.java
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java
new file mode 100644
index 0000000..4b5dc36
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorTest.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.common.truth.Truth.assertThat;
+
+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;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.testing.InMemoryDatabase;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SchemaCreatorTest {
+  @Inject private AllProjectsName allProjects;
+
+  @Inject private GitRepositoryManager repoManager;
+
+  @Inject private InMemoryDatabase db;
+
+  @Before
+  public void setUp() throws Exception {
+    new InMemoryModule().inject(this);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    InMemoryDatabase.drop(db);
+  }
+
+  @Test
+  public void getCauses_CreateSchema() throws OrmException, SQLException, IOException {
+    // Initially the schema should be empty.
+    String[] types = {"TABLE", "VIEW"};
+    try (JdbcSchema d = (JdbcSchema) db.open();
+        ResultSet rs = d.getConnection().getMetaData().getTables(null, null, null, types)) {
+      assertThat(rs.next()).isFalse();
+    }
+
+    // Create the schema using the current schema version.
+    //
+    db.create();
+    db.assertSchemaVersion();
+
+    // By default sitePath is set to the current working directory.
+    //
+    File sitePath = new File(".").getAbsoluteFile();
+    if (sitePath.getName().equals(".")) {
+      sitePath = sitePath.getParentFile();
+    }
+    assertThat(db.getSystemConfig().sitePath).isEqualTo(sitePath.getCanonicalPath());
+  }
+
+  private LabelTypes getLabelTypes() throws Exception {
+    db.create();
+    ProjectConfig c = new ProjectConfig(allProjects);
+    try (Repository repo = repoManager.openRepository(allProjects)) {
+      c.load(repo);
+      return new LabelTypes(ImmutableList.copyOf(c.getLabelSections().values()));
+    }
+  }
+
+  @Test
+  public void createSchema_LabelTypes() throws Exception {
+    List<String> labels = new ArrayList<>();
+    for (LabelType label : getLabelTypes().getLabelTypes()) {
+      labels.add(label.getName());
+    }
+    assertThat(labels).containsExactly("Code-Review");
+  }
+
+  @Test
+  public void createSchema_Label_CodeReview() throws Exception {
+    LabelType codeReview = getLabelTypes().byLabel("Code-Review");
+    assertThat(codeReview).isNotNull();
+    assertThat(codeReview.getName()).isEqualTo("Code-Review");
+    assertThat(codeReview.getDefaultValue()).isEqualTo(0);
+    assertThat(codeReview.getFunction()).isEqualTo(LabelFunction.MAX_WITH_BLOCK);
+    assertThat(codeReview.isCopyMinScore()).isTrue();
+    assertValueRange(codeReview, 2, 1, 0, -1, -2);
+  }
+
+  private void assertValueRange(LabelType label, Integer... range) {
+    assertThat(label.getValuesAsList()).containsExactlyElementsIn(Arrays.asList(range)).inOrder();
+    assertThat(label.getMax().getValue()).isEqualTo(range[0]);
+    assertThat(label.getMin().getValue()).isEqualTo(range[range.length - 1]);
+    for (LabelValue v : label.getValues()) {
+      assertThat(Strings.isNullOrEmpty(v.getText())).isFalse();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java
new file mode 100644
index 0000000..e4b0da5
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.client.SystemConfig;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.testing.InMemoryDatabase;
+import com.google.gerrit.testing.InMemoryH2Type;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gerrit.testing.TestUpdateUI;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Guice;
+import com.google.inject.Key;
+import com.google.inject.ProvisionException;
+import com.google.inject.TypeLiteral;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.UUID;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SchemaUpdaterTest {
+  private LifecycleManager lifecycle;
+  private InMemoryDatabase db;
+
+  @Before
+  public void setUp() throws Exception {
+    lifecycle = new LifecycleManager();
+    db = InMemoryDatabase.newDatabase(lifecycle);
+    lifecycle.start();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    InMemoryDatabase.drop(db);
+  }
+
+  @Test
+  public void update() throws OrmException, FileNotFoundException, IOException {
+    db.create();
+
+    final Path site = Paths.get(UUID.randomUUID().toString());
+    final SitePaths paths = new SitePaths(site);
+    SchemaUpdater u =
+        Guice.createInjector(
+                new FactoryModule() {
+                  @Override
+                  protected void configure() {
+                    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
+                        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
+                    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
+                    bind(Key.get(schemaFactory, ReviewDbFactory.class)).toInstance(db);
+                    bind(SitePaths.class).toInstance(paths);
+
+                    Config cfg = new Config();
+                    cfg.setString("user", null, "name", "Gerrit Code Review");
+                    cfg.setString("user", null, "email", "gerrit@localhost");
+
+                    bind(Config.class) //
+                        .annotatedWith(GerritServerConfig.class) //
+                        .toInstance(cfg);
+
+                    bind(PersonIdent.class) //
+                        .annotatedWith(GerritPersonIdent.class) //
+                        .toProvider(GerritPersonIdentProvider.class);
+
+                    bind(AllProjectsName.class).toInstance(new AllProjectsName("All-Projects"));
+                    bind(AllUsersName.class).toInstance(new AllUsersName("All-Users"));
+
+                    bind(GitRepositoryManager.class).toInstance(new InMemoryRepositoryManager());
+
+                    bind(String.class) //
+                        .annotatedWith(AnonymousCowardName.class) //
+                        .toProvider(AnonymousCowardNameProvider.class);
+
+                    bind(DataSourceType.class).to(InMemoryH2Type.class);
+
+                    bind(SystemGroupBackend.class);
+                    install(new NotesMigration.Module());
+                    install(new GroupsMigration.Module());
+                    bind(MetricMaker.class).to(DisabledMetricMaker.class);
+                  }
+                })
+            .getInstance(SchemaUpdater.class);
+
+    for (SchemaVersion s = u.getLatestSchemaVersion(); s.getVersionNbr() > 1; s = s.getPrior()) {
+      try {
+        assertThat(s.getPrior().getVersionNbr())
+            .named(
+                "schema %s has prior version %s. Not true that",
+                s.getVersionNbr(), s.getPrior().getVersionNbr())
+            .isEqualTo(s.getVersionNbr() - 1);
+      } catch (ProvisionException e) {
+        // Ignored
+        // The oldest supported schema version doesn't have a prior schema
+        // version.
+        break;
+      }
+    }
+
+    u.update(new TestUpdateUI());
+
+    db.assertSchemaVersion();
+    final SystemConfig sc = db.getSystemConfig();
+    assertThat(sc.sitePath).isEqualTo(paths.site_path.toAbsolutePath().toString());
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/Schema_150_to_151_Test.java b/javatests/com/google/gerrit/server/schema/Schema_150_to_151_Test.java
new file mode 100644
index 0000000..7dd265e
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/Schema_150_to_151_Test.java
@@ -0,0 +1,141 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+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.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.server.ReviewDb;
+import com.google.gerrit.server.restapi.group.CreateGroup;
+import com.google.gerrit.testing.SchemaUpgradeTestEnvironment;
+import com.google.gerrit.testing.TestUpdateUI;
+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;
+
+public class Schema_150_to_151_Test {
+
+  @Rule public SchemaUpgradeTestEnvironment testEnv = new SchemaUpgradeTestEnvironment();
+
+  @Inject private CreateGroup.Factory createGroupFactory;
+  @Inject private Schema_151 schema151;
+  @Inject private ReviewDb db;
+
+  private Connection connection;
+  private PreparedStatement createdOnRetrieval;
+  private PreparedStatement createdOnUpdate;
+  private PreparedStatement auditEntryDeletion;
+
+  @Before
+  public void setUp() throws Exception {
+    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
+  public void createdOnIsPopulatedForGroupsCreatedAfterAudit() throws Exception {
+    Timestamp testStartTime = TimeUtil.nowTs();
+    AccountGroup.Id groupId = createGroup("Group for schema migration");
+    setCreatedOnToVeryOldTimestamp(groupId);
+
+    schema151.migrateData(db, new TestUpdateUI());
+
+    Timestamp createdOn = getCreatedOn(groupId);
+    assertThat(createdOn).isAtLeast(testStartTime);
+  }
+
+  @Test
+  public void createdOnIsPopulatedForGroupsCreatedBeforeAudit() throws Exception {
+    AccountGroup.Id groupId = createGroup("Ancient group for schema migration");
+    setCreatedOnToVeryOldTimestamp(groupId);
+    removeAuditEntriesFor(groupId);
+
+    schema151.migrateData(db, new TestUpdateUI());
+
+    Timestamp createdOn = getCreatedOn(groupId);
+    assertThat(createdOn).isEqualTo(AccountGroup.auditCreationInstantTs());
+  }
+
+  private AccountGroup.Id createGroup(String name) throws Exception {
+    GroupInput groupInput = new GroupInput();
+    groupInput.name = name;
+    GroupInfo groupInfo =
+        createGroupFactory.create(name).apply(TopLevelResource.INSTANCE, groupInput);
+    return new Id(groupInfo.groupId);
+  }
+
+  private 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);
+    createdOnUpdate.setTimestamp(1, Timestamp.from(instant));
+    createdOnUpdate.setInt(2, groupId.get());
+    createdOnUpdate.executeUpdate();
+  }
+
+  private void removeAuditEntriesFor(AccountGroup.Id groupId) throws Exception {
+    auditEntryDeletion.setInt(1, groupId.get());
+    auditEntryDeletion.executeUpdate();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/Schema_159_to_160_Test.java b/javatests/com/google/gerrit/server/schema/Schema_159_to_160_Test.java
new file mode 100644
index 0000000..9b86c0e
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/Schema_159_to_160_Test.java
@@ -0,0 +1,135 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
+import static com.google.gerrit.server.git.UserConfigSections.MY;
+import static com.google.gerrit.server.schema.Schema_160.DEFAULT_DRAFT_ITEM;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.testing.SchemaUpgradeTestEnvironment;
+import com.google.gerrit.testing.TestUpdateUI;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class Schema_159_to_160_Test {
+  @Rule public SchemaUpgradeTestEnvironment testEnv = new SchemaUpgradeTestEnvironment();
+
+  @Inject private AccountCache accountCache;
+  @Inject private AllUsersName allUsersName;
+  @Inject private GerritApi gApi;
+  @Inject private GitRepositoryManager repoManager;
+  @Inject private Provider<IdentifiedUser> userProvider;
+  @Inject private ReviewDb db;
+  @Inject private Schema_160 schema160;
+
+  private Account.Id accountId;
+
+  @Before
+  public void setUp() throws Exception {
+    accountId = userProvider.get().getAccountId();
+  }
+
+  @Test
+  public void skipUnmodified() throws Exception {
+    ObjectId oldMetaId = metaRef(accountId);
+    assertThat(myMenusFromNoteDb(accountId).values()).doesNotContain(DEFAULT_DRAFT_ITEM);
+    assertThat(myMenusFromApi(accountId).values()).doesNotContain(DEFAULT_DRAFT_ITEM);
+
+    schema160.migrateData(db, new TestUpdateUI());
+
+    assertThat(metaRef(accountId)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void deleteItems() throws Exception {
+    ObjectId oldMetaId = metaRef(accountId);
+    List<String> defaultNames = ImmutableList.copyOf(myMenusFromApi(accountId).keySet());
+
+    GeneralPreferencesInfo prefs = gApi.accounts().id(accountId.get()).getPreferences();
+    prefs.my.add(0, new MenuItem("Something else", DEFAULT_DRAFT_ITEM + "+is:mergeable"));
+    prefs.my.add(new MenuItem("Drafts", DEFAULT_DRAFT_ITEM));
+    prefs.my.add(new MenuItem("Totally not drafts", DEFAULT_DRAFT_ITEM));
+    gApi.accounts().id(accountId.get()).setPreferences(prefs);
+
+    List<String> oldNames =
+        ImmutableList.<String>builder()
+            .add("Something else")
+            .addAll(defaultNames)
+            .add("Drafts")
+            .add("Totally not drafts")
+            .build();
+    assertThat(myMenusFromApi(accountId).keySet()).containsExactlyElementsIn(oldNames).inOrder();
+
+    schema160.migrateData(db, new TestUpdateUI());
+    accountCache.evict(accountId);
+    testEnv.setApiUser(accountId);
+
+    assertThat(metaRef(accountId)).isNotEqualTo(oldMetaId);
+
+    List<String> newNames =
+        ImmutableList.<String>builder().add("Something else").addAll(defaultNames).build();
+    assertThat(myMenusFromNoteDb(accountId).keySet()).containsExactlyElementsIn(newNames).inOrder();
+    assertThat(myMenusFromApi(accountId).keySet()).containsExactlyElementsIn(newNames).inOrder();
+  }
+
+  // Raw config values, bypassing the defaults set by PreferencesConfig.
+  private ImmutableMap<String, String> myMenusFromNoteDb(Account.Id id) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      VersionedAccountPreferences prefs = VersionedAccountPreferences.forUser(id);
+      prefs.load(repo);
+      Config cfg = prefs.getConfig();
+      return cfg.getSubsections(MY)
+          .stream()
+          .collect(toImmutableMap(i -> i, i -> cfg.getString(MY, i, KEY_URL)));
+    }
+  }
+
+  private ImmutableMap<String, String> myMenusFromApi(Account.Id id) throws Exception {
+    return gApi.accounts()
+        .id(id.get())
+        .getPreferences()
+        .my
+        .stream()
+        .collect(toImmutableMap(i -> i.name, i -> i.url));
+  }
+
+  private ObjectId metaRef(Account.Id id) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return repo.exactRef(RefNames.refsUsers(id)).getObjectId();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/Schema_161_to_162_Test.java b/javatests/com/google/gerrit/server/schema/Schema_161_to_162_Test.java
new file mode 100644
index 0000000..c428d26
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/Schema_161_to_162_Test.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.testing.SchemaUpgradeTestEnvironment;
+import com.google.gerrit.testing.TestUpdateUI;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class Schema_161_to_162_Test {
+  @Rule public SchemaUpgradeTestEnvironment testEnv = new SchemaUpgradeTestEnvironment();
+
+  @Inject private AllProjectsName allProjectsName;
+  @Inject private AllUsersName allUsersName;
+  @Inject private GerritApi gApi;
+  @Inject private GitRepositoryManager repoManager;
+  @Inject private Schema_162 schema162;
+  @Inject private ReviewDb db;
+  @Inject @GerritPersonIdent private PersonIdent serverUser;
+
+  @Test
+  public void skipCorrectInheritance() throws Exception {
+    assertThatAllUsersInheritsFrom(allProjectsName.get());
+    ObjectId oldHead;
+    try (Repository git = repoManager.openRepository(allUsersName)) {
+      oldHead = git.findRef(RefNames.REFS_CONFIG).getObjectId();
+    }
+
+    schema162.migrateData(db, new TestUpdateUI());
+
+    // Check that the parent remained unchanged and that no commit was made
+    assertThatAllUsersInheritsFrom(allProjectsName.get());
+    try (Repository git = repoManager.openRepository(allUsersName)) {
+      assertThat(oldHead).isEqualTo(git.findRef(RefNames.REFS_CONFIG).getObjectId());
+    }
+  }
+
+  @Test
+  public void fixIncorrectInheritance() throws Exception {
+    String testProject = gApi.projects().create("test").get().name;
+    assertThatAllUsersInheritsFrom(allProjectsName.get());
+
+    try (Repository git = repoManager.openRepository(allUsersName);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
+      ProjectConfig cfg = ProjectConfig.read(md);
+      cfg.getProject().setParentName(testProject);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.setMessage("Test");
+      cfg.commit(md);
+    } catch (ConfigInvalidException | IOException ex) {
+      throw new OrmException(ex);
+    }
+    assertThatAllUsersInheritsFrom(testProject);
+
+    schema162.migrateData(db, new TestUpdateUI());
+
+    assertThatAllUsersInheritsFrom(allProjectsName.get());
+  }
+
+  private void assertThatAllUsersInheritsFrom(String parent) throws Exception {
+    assertThat(gApi.projects().name(allUsersName.get()).access().inheritsFrom.name)
+        .isEqualTo(parent);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java b/javatests/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java
rename to javatests/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/HookTestCase.java b/javatests/com/google/gerrit/server/tools/hooks/HookTestCase.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/HookTestCase.java
rename to javatests/com/google/gerrit/server/tools/hooks/HookTestCase.java
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
new file mode 100644
index 0000000..0cd3234
--- /dev/null
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -0,0 +1,42 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+MEDIUM_TESTS = ["RefUpdateUtilRepoTest.java"]
+
+junit_tests(
+    name = "medium_tests",
+    size = "medium",
+    timeout = "short",
+    srcs = MEDIUM_TESTS,
+    deps = [
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib:junit",
+        "//lib:truth",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+)
+
+junit_tests(
+    name = "small_tests",
+    size = "small",
+    srcs = glob(
+        ["*.java"],
+        exclude = MEDIUM_TESTS,
+    ),
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/schema",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:truth",
+        "//lib:truth-java8-extension",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
new file mode 100644
index 0000000..2b235a1
--- /dev/null
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testing.InMemoryDatabase;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class BatchUpdateTest {
+  @Inject private AccountManager accountManager;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+  @Inject private SchemaFactory<ReviewDb> schemaFactory;
+  @Inject private InMemoryRepositoryManager repoManager;
+  @Inject private SchemaCreator schemaCreator;
+  @Inject private ThreadLocalRequestContext requestContext;
+  @Inject private BatchUpdate.Factory batchUpdateFactory;
+
+  // Only for use in setting up/tearing down injector; other users should use schemaFactory.
+  @Inject private InMemoryDatabase inMemoryDatabase;
+
+  private LifecycleManager lifecycle;
+  private ReviewDb db;
+  private TestRepository<InMemoryRepository> repo;
+  private Project.NameKey project;
+  private IdentifiedUser user;
+
+  @Before
+  public void setUp() throws Exception {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+    lifecycle = new LifecycleManager();
+    lifecycle.add(injector);
+    lifecycle.start();
+
+    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
+      schemaCreator.create(underlyingDb);
+    }
+    db = schemaFactory.open();
+    Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
+    user = userFactory.create(userId);
+
+    project = new Project.NameKey("test");
+
+    InMemoryRepository inMemoryRepo = repoManager.createRepository(project);
+    repo = new TestRepository<>(inMemoryRepo);
+
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return user;
+          }
+
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
+  }
+
+  @After
+  public void tearDown() {
+    if (repo != null) {
+      repo.getRepository().close();
+    }
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(inMemoryDatabase);
+  }
+
+  @Test
+  public void addRefUpdateFromFastForwardCommit() throws Exception {
+    final RevCommit masterCommit = repo.branch("master").commit().create();
+    final RevCommit branchCommit = repo.branch("branch").commit().parent(masterCommit).create();
+
+    try (BatchUpdate bu = batchUpdateFactory.create(db, project, user, TimeUtil.nowTs())) {
+      bu.addRepoOnlyOp(
+          new RepoOnlyOp() {
+            @Override
+            public void updateRepo(RepoContext ctx) throws Exception {
+              ctx.addRefUpdate(masterCommit.getId(), branchCommit.getId(), "refs/heads/master");
+            }
+          });
+      bu.execute();
+    }
+
+    assertEquals(
+        repo.getRepository().exactRef("refs/heads/master").getObjectId(), branchCommit.getId());
+  }
+}
diff --git a/javatests/com/google/gerrit/server/update/RefUpdateUtilRepoTest.java b/javatests/com/google/gerrit/server/update/RefUpdateUtilRepoTest.java
new file mode 100644
index 0000000..fe9d522
--- /dev/null
+++ b/javatests/com/google/gerrit/server/update/RefUpdateUtilRepoTest.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.MoreFiles;
+import com.google.common.io.RecursiveDeleteOption;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class RefUpdateUtilRepoTest {
+  public enum RepoSetup {
+    LOCAL_DISK {
+      @Override
+      Repository setUpRepo() throws Exception {
+        Path p = Files.createTempDirectory("gerrit_repo_");
+        try {
+          Repository repo = new FileRepository(p.toFile());
+          repo.create(true);
+          return repo;
+        } catch (Exception e) {
+          delete(p);
+          throw e;
+        }
+      }
+
+      @Override
+      void tearDownRepo(Repository repo) throws Exception {
+        delete(repo.getDirectory().toPath());
+      }
+
+      private void delete(Path p) throws Exception {
+        MoreFiles.deleteRecursively(p, RecursiveDeleteOption.ALLOW_INSECURE);
+      }
+    },
+
+    IN_MEMORY {
+      @Override
+      Repository setUpRepo() {
+        return new InMemoryRepository(new DfsRepositoryDescription("repo"));
+      }
+
+      @Override
+      void tearDownRepo(Repository repo) {}
+    };
+
+    abstract Repository setUpRepo() throws Exception;
+
+    abstract void tearDownRepo(Repository repo) throws Exception;
+  }
+
+  @Parameters(name = "{0}")
+  public static ImmutableList<RepoSetup[]> data() {
+    return ImmutableList.copyOf(new RepoSetup[][] {{RepoSetup.LOCAL_DISK}, {RepoSetup.IN_MEMORY}});
+  }
+
+  @Parameter public RepoSetup repoSetup;
+
+  private Repository repo;
+
+  @Before
+  public void setUp() throws Exception {
+    repo = repoSetup.setUpRepo();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (repo != null) {
+      repoSetup.tearDownRepo(repo);
+      repo = null;
+    }
+  }
+
+  @Test
+  public void deleteRefNoOp() throws Exception {
+    String ref = "refs/heads/foo";
+    assertThat(repo.exactRef(ref)).isNull();
+    RefUpdateUtil.deleteChecked(repo, "refs/heads/foo");
+    assertThat(repo.exactRef(ref)).isNull();
+  }
+
+  @Test
+  public void deleteRef() throws Exception {
+    String ref = "refs/heads/foo";
+    new TestRepository<>(repo).branch(ref).commit().create();
+    assertThat(repo.exactRef(ref)).isNotNull();
+    RefUpdateUtil.deleteChecked(repo, "refs/heads/foo");
+    assertThat(repo.exactRef(ref)).isNull();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/update/RefUpdateUtilTest.java b/javatests/com/google/gerrit/server/update/RefUpdateUtilTest.java
new file mode 100644
index 0000000..fc8696a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/update/RefUpdateUtilTest.java
@@ -0,0 +1,119 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.git.LockFailureException;
+import java.io.IOException;
+import java.util.function.Consumer;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class RefUpdateUtilTest {
+  private static final Consumer<ReceiveCommand> OK = c -> c.setResult(ReceiveCommand.Result.OK);
+  private static final Consumer<ReceiveCommand> LOCK_FAILURE =
+      c -> c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
+  private static final Consumer<ReceiveCommand> REJECTED =
+      c -> c.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON);
+  private static final Consumer<ReceiveCommand> ABORTED =
+      c -> {
+        c.setResult(ReceiveCommand.Result.NOT_ATTEMPTED);
+        ReceiveCommand.abort(ImmutableList.of(c));
+        checkState(
+            c.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED
+                && c.getResult() != ReceiveCommand.Result.LOCK_FAILURE
+                && c.getResult() != ReceiveCommand.Result.OK,
+            "unexpected state after abort: %s",
+            c);
+      };
+
+  @Test
+  public void checkBatchRefUpdateResults() throws Exception {
+    checkResults();
+    checkResults(OK);
+    checkResults(OK, OK);
+
+    assertIoException(REJECTED);
+    assertIoException(OK, REJECTED);
+    assertIoException(LOCK_FAILURE, REJECTED);
+    assertIoException(LOCK_FAILURE, OK);
+    assertIoException(LOCK_FAILURE, REJECTED, OK);
+    assertIoException(LOCK_FAILURE, LOCK_FAILURE, REJECTED);
+    assertIoException(LOCK_FAILURE, ABORTED, REJECTED);
+    assertIoException(LOCK_FAILURE, ABORTED, OK);
+
+    assertLockFailureException(LOCK_FAILURE);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED, ABORTED);
+    assertLockFailureException(ABORTED);
+    assertLockFailureException(ABORTED, ABORTED);
+  }
+
+  @SafeVarargs
+  private static void checkResults(Consumer<ReceiveCommand>... resultSetters) throws Exception {
+    RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
+  }
+
+  @SafeVarargs
+  private static void assertIoException(Consumer<ReceiveCommand>... resultSetters) {
+    try {
+      RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
+      assert_().fail("expected IOException");
+    } catch (IOException e) {
+      assertThat(e).isNotInstanceOf(LockFailureException.class);
+    }
+  }
+
+  @SafeVarargs
+  private static void assertLockFailureException(Consumer<ReceiveCommand>... resultSetters)
+      throws Exception {
+    try {
+      RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
+      assert_().fail("expected LockFailureException");
+    } catch (LockFailureException e) {
+      // Expected.
+    }
+  }
+
+  @SafeVarargs
+  private static BatchRefUpdate newBatchRefUpdate(Consumer<ReceiveCommand>... resultSetters) {
+    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      for (int i = 0; i < resultSetters.length; i++) {
+        ReceiveCommand cmd =
+            new ReceiveCommand(
+                ObjectId.fromString(String.format("%039x1", i)),
+                ObjectId.fromString(String.format("%039x2", i)),
+                "refs/heads/branch" + i);
+        bru.addCommand(cmd);
+        resultSetters[i].accept(cmd);
+      }
+      return bru;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/update/RepoViewTest.java b/javatests/com/google/gerrit/server/update/RepoViewTest.java
new file mode 100644
index 0000000..9f7deee
--- /dev/null
+++ b/javatests/com/google/gerrit/server/update/RepoViewTest.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class RepoViewTest {
+  private static final String MASTER = "refs/heads/master";
+  private static final String BRANCH = "refs/heads/branch";
+
+  private Repository repo;
+  private TestRepository<?> tr;
+  private RepoView view;
+
+  @Before
+  public void setUp() throws Exception {
+    InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+    Project.NameKey project = new Project.NameKey("project");
+    repo = repoManager.createRepository(project);
+    tr = new TestRepository<>(repo);
+    tr.branch(MASTER).commit().create();
+    view = new RepoView(repoManager, project);
+  }
+
+  @After
+  public void tearDown() {
+    view.close();
+    repo.close();
+  }
+
+  @Test
+  public void getConfigIsDefensiveCopy() throws Exception {
+    StoredConfig orig = repo.getConfig();
+    orig.setString("a", "config", "option", "yes");
+    orig.save();
+
+    Config copy = view.getConfig();
+    copy.setString("a", "config", "option", "no");
+
+    assertThat(orig.getString("a", "config", "option")).isEqualTo("yes");
+    assertThat(repo.getConfig().getString("a", "config", "option")).isEqualTo("yes");
+  }
+
+  @Test
+  public void getRef() throws Exception {
+    ObjectId oldMaster = repo.exactRef(MASTER).getObjectId();
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(oldMaster);
+    assertThat(repo.exactRef(BRANCH)).isNull();
+    assertThat(view.getRef(MASTER)).hasValue(oldMaster);
+    assertThat(view.getRef(BRANCH)).isEmpty();
+
+    tr.branch(MASTER).commit().create();
+    tr.branch(BRANCH).commit().create();
+    assertThat(repo.exactRef(MASTER).getObjectId()).isNotEqualTo(oldMaster);
+    assertThat(repo.exactRef(BRANCH)).isNotNull();
+    assertThat(view.getRef(MASTER)).hasValue(oldMaster);
+    assertThat(view.getRef(BRANCH)).isEmpty();
+  }
+
+  @Test
+  public void getRefsRescansWhenNotCaching() throws Exception {
+    ObjectId oldMaster = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", oldMaster);
+
+    ObjectId newBranch = tr.branch(BRANCH).commit().create();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", oldMaster, "branch", newBranch);
+  }
+
+  @Test
+  public void getRefsUsesCachedValueMatchingGetRef() throws Exception {
+    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
+    assertThat(view.getRef(MASTER)).hasValue(master1);
+
+    // Doesn't reflect new value for master.
+    ObjectId master2 = tr.branch(MASTER).commit().create();
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
+
+    // Branch wasn't previously cached, so does reflect new value.
+    ObjectId branch1 = tr.branch(BRANCH).commit().create();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1, "branch", branch1);
+
+    // Looking up branch causes it to be cached.
+    assertThat(view.getRef(BRANCH)).hasValue(branch1);
+    ObjectId branch2 = tr.branch(BRANCH).commit().create();
+    assertThat(repo.exactRef(BRANCH).getObjectId()).isEqualTo(branch2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1, "branch", branch1);
+  }
+
+  @Test
+  public void getRefsReflectsCommands() throws Exception {
+    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
+
+    ObjectId master2 = tr.commit().create();
+    view.getCommands().add(new ReceiveCommand(master1, master2, MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).hasValue(master2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master2);
+
+    view.getCommands().add(new ReceiveCommand(master2, ObjectId.zeroId(), MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).isEmpty();
+    assertThat(view.getRefs(R_HEADS)).isEmpty();
+  }
+
+  @Test
+  public void getRefsOverwritesCachedValueWithCommand() throws Exception {
+    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRef(MASTER)).hasValue(master1);
+
+    ObjectId master2 = tr.commit().create();
+    view.getCommands().add(new ReceiveCommand(master1, master2, MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).hasValue(master2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master2);
+
+    view.getCommands().add(new ReceiveCommand(master2, ObjectId.zeroId(), MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).isEmpty();
+    assertThat(view.getRefs(R_HEADS)).isEmpty();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/IdGeneratorTest.java b/javatests/com/google/gerrit/server/util/IdGeneratorTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/util/IdGeneratorTest.java
rename to javatests/com/google/gerrit/server/util/IdGeneratorTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/LabelVoteTest.java b/javatests/com/google/gerrit/server/util/LabelVoteTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/util/LabelVoteTest.java
rename to javatests/com/google/gerrit/server/util/LabelVoteTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/MostSpecificComparatorTest.java b/javatests/com/google/gerrit/server/util/MostSpecificComparatorTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/util/MostSpecificComparatorTest.java
rename to javatests/com/google/gerrit/server/util/MostSpecificComparatorTest.java
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/ParboiledTest.java b/javatests/com/google/gerrit/server/util/ParboiledTest.java
similarity index 100%
rename from gerrit-server/src/test/java/com/google/gerrit/server/util/ParboiledTest.java
rename to javatests/com/google/gerrit/server/util/ParboiledTest.java
diff --git a/javatests/com/google/gerrit/server/util/RegexListSearcherTest.java b/javatests/com/google/gerrit/server/util/RegexListSearcherTest.java
new file mode 100644
index 0000000..01964a8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/util/RegexListSearcherTest.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.util;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import org.junit.Test;
+
+public class RegexListSearcherTest {
+  private static final ImmutableList<String> EMPTY = ImmutableList.of();
+
+  @Test
+  public void emptyList() {
+    assertSearchReturns(EMPTY, "pat", EMPTY);
+  }
+
+  @Test
+  public void anchors() {
+    List<String> list = ImmutableList.of("foo");
+    assertSearchReturns(list, "^f.*", list);
+    assertSearchReturns(list, "^f.*o$", list);
+    assertSearchReturns(list, "f.*o$", list);
+    assertSearchReturns(list, "f.*o$", list);
+    assertSearchReturns(EMPTY, "^.*\\$", list);
+  }
+
+  @Test
+  public void noCommonPrefix() {
+    List<String> list = ImmutableList.of("bar", "foo", "quux");
+    assertSearchReturns(ImmutableList.of("foo"), "f.*", list);
+    assertSearchReturns(ImmutableList.of("foo"), ".*o.*", list);
+    assertSearchReturns(ImmutableList.of("bar", "foo", "quux"), ".*[aou].*", list);
+  }
+
+  @Test
+  public void commonPrefix() {
+    List<String> list = ImmutableList.of("bar", "baz", "foo1", "foo2", "foo3", "quux");
+    assertSearchReturns(ImmutableList.of("bar", "baz"), "b.*", list);
+    assertSearchReturns(ImmutableList.of("foo1", "foo2"), "foo[12]", list);
+    assertSearchReturns(ImmutableList.of("foo1", "foo2", "foo3"), "foo.*", list);
+    assertSearchReturns(ImmutableList.of("quux"), "q.*", list);
+  }
+
+  private void assertSearchReturns(List<?> expected, String re, List<String> inputs) {
+    assertThat(inputs).isOrdered();
+    assertThat(RegexListSearcher.ofStrings(re).search(inputs))
+        .containsExactlyElementsIn(expected)
+        .inOrder();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/util/SocketUtilTest.java b/javatests/com/google/gerrit/server/util/SocketUtilTest.java
new file mode 100644
index 0000000..018b8db
--- /dev/null
+++ b/javatests/com/google/gerrit/server/util/SocketUtilTest.java
@@ -0,0 +1,141 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.util;
+
+import static com.google.gerrit.server.util.SocketUtil.hostname;
+import static com.google.gerrit.server.util.SocketUtil.isIPv6;
+import static com.google.gerrit.server.util.SocketUtil.parse;
+import static com.google.gerrit.server.util.SocketUtil.resolve;
+import static java.net.InetAddress.getByName;
+import static java.net.InetSocketAddress.createUnresolved;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.testing.GerritBaseTests;
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.UnknownHostException;
+import org.junit.Test;
+
+public class SocketUtilTest extends GerritBaseTests {
+  @Test
+  public void testIsIPv6() throws UnknownHostException {
+    final InetAddress ipv6 = getByName("1:2:3:4:5:6:7:8");
+    assertTrue(ipv6 instanceof Inet6Address);
+    assertTrue(isIPv6(ipv6));
+
+    final InetAddress ipv4 = getByName("127.0.0.1");
+    assertTrue(ipv4 instanceof Inet4Address);
+    assertFalse(isIPv6(ipv4));
+  }
+
+  @Test
+  public void testHostname() {
+    assertEquals("*", hostname(new InetSocketAddress(80)));
+    assertEquals("localhost", hostname(new InetSocketAddress("localhost", 80)));
+    assertEquals("foo", hostname(createUnresolved("foo", 80)));
+  }
+
+  @Test
+  public void testFormat() throws UnknownHostException {
+    assertEquals("*:1234", SocketUtil.format(new InetSocketAddress(1234), 80));
+    assertEquals("*", SocketUtil.format(new InetSocketAddress(80), 80));
+
+    assertEquals("foo:1234", SocketUtil.format(createUnresolved("foo", 1234), 80));
+    assertEquals("foo", SocketUtil.format(createUnresolved("foo", 80), 80));
+
+    assertEquals(
+        "[1:2:3:4:5:6:7:8]:1234", //
+        SocketUtil.format(new InetSocketAddress(getByName("1:2:3:4:5:6:7:8"), 1234), 80));
+    assertEquals(
+        "[1:2:3:4:5:6:7:8]", //
+        SocketUtil.format(new InetSocketAddress(getByName("1:2:3:4:5:6:7:8"), 80), 80));
+
+    assertEquals(
+        "localhost:1234", //
+        SocketUtil.format(new InetSocketAddress("localhost", 1234), 80));
+    assertEquals(
+        "localhost", //
+        SocketUtil.format(new InetSocketAddress("localhost", 80), 80));
+  }
+
+  @Test
+  public void testParse() {
+    assertEquals(new InetSocketAddress(1234), parse("*:1234", 80));
+    assertEquals(new InetSocketAddress(80), parse("*", 80));
+    assertEquals(new InetSocketAddress(1234), parse(":1234", 80));
+    assertEquals(new InetSocketAddress(80), parse("", 80));
+
+    assertEquals(
+        createUnresolved("1:2:3:4:5:6:7:8", 1234), //
+        parse("[1:2:3:4:5:6:7:8]:1234", 80));
+    assertEquals(
+        createUnresolved("1:2:3:4:5:6:7:8", 80), //
+        parse("[1:2:3:4:5:6:7:8]", 80));
+
+    assertEquals(
+        createUnresolved("localhost", 1234), //
+        parse("[localhost]:1234", 80));
+    assertEquals(
+        createUnresolved("localhost", 80), //
+        parse("[localhost]", 80));
+
+    assertEquals(
+        createUnresolved("foo.bar.example.com", 1234), //
+        parse("[foo.bar.example.com]:1234", 80));
+    assertEquals(
+        createUnresolved("foo.bar.example.com", 80), //
+        parse("[foo.bar.example.com]", 80));
+  }
+
+  @Test
+  public void testParseInvalidIPv6() {
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage("invalid IPv6: [:3");
+    parse("[:3", 80);
+  }
+
+  @Test
+  public void testParseInvalidPort() {
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage("invalid port: localhost:A");
+    parse("localhost:A", 80);
+  }
+
+  @Test
+  public void testResolve() throws UnknownHostException {
+    assertEquals(new InetSocketAddress(1234), resolve("*:1234", 80));
+    assertEquals(new InetSocketAddress(80), resolve("*", 80));
+    assertEquals(new InetSocketAddress(1234), resolve(":1234", 80));
+    assertEquals(new InetSocketAddress(80), resolve("", 80));
+
+    assertEquals(
+        new InetSocketAddress(getByName("1:2:3:4:5:6:7:8"), 1234), //
+        resolve("[1:2:3:4:5:6:7:8]:1234", 80));
+    assertEquals(
+        new InetSocketAddress(getByName("1:2:3:4:5:6:7:8"), 80), //
+        resolve("[1:2:3:4:5:6:7:8]", 80));
+
+    assertEquals(
+        new InetSocketAddress(getByName("localhost"), 1234), //
+        resolve("[localhost]:1234", 80));
+    assertEquals(
+        new InetSocketAddress(getByName("localhost"), 80), //
+        resolve("[localhost]", 80));
+  }
+}
diff --git a/javatests/com/google/gerrit/sshd/BUILD b/javatests/com/google/gerrit/sshd/BUILD
new file mode 100644
index 0000000..c0eaedf
--- /dev/null
+++ b/javatests/com/google/gerrit/sshd/BUILD
@@ -0,0 +1,13 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "sshd_tests",
+    srcs = glob(["**/*.java"]),
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/sshd",
+        "//lib:truth",
+        "//lib/mina:sshd",
+    ],
+)
diff --git a/gerrit-sshd/src/test/java/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java b/javatests/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
similarity index 100%
rename from gerrit-sshd/src/test/java/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
rename to javatests/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
diff --git a/javatests/com/google/gerrit/testing/BUILD b/javatests/com/google/gerrit/testing/BUILD
new file mode 100644
index 0000000..191e98f
--- /dev/null
+++ b/javatests/com/google/gerrit/testing/BUILD
@@ -0,0 +1,12 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "testing_tests",
+    size = "small",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/testing/IndexVersionsTest.java b/javatests/com/google/gerrit/testing/IndexVersionsTest.java
new file mode 100644
index 0000000..36247f8
--- /dev/null
+++ b/javatests/com/google/gerrit/testing/IndexVersionsTest.java
@@ -0,0 +1,140 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testing.IndexVersions.ALL;
+import static com.google.gerrit.testing.IndexVersions.CURRENT;
+import static com.google.gerrit.testing.IndexVersions.PREVIOUS;
+
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+
+public class IndexVersionsTest extends GerritBaseTests {
+  private static final ChangeSchemaDefinitions SCHEMA_DEF = ChangeSchemaDefinitions.INSTANCE;
+
+  @Test
+  public void noValue() {
+    List<Integer> expected = new ArrayList<>();
+    if (SCHEMA_DEF.getPrevious() != null) {
+      expected.add(SCHEMA_DEF.getPrevious().getVersion());
+    }
+    expected.add(SCHEMA_DEF.getLatest().getVersion());
+
+    assertThat(get(null)).containsExactlyElementsIn(expected).inOrder();
+  }
+
+  @Test
+  public void emptyValue() {
+    List<Integer> expected = new ArrayList<>();
+    if (SCHEMA_DEF.getPrevious() != null) {
+      expected.add(SCHEMA_DEF.getPrevious().getVersion());
+    }
+    expected.add(SCHEMA_DEF.getLatest().getVersion());
+
+    assertThat(get("")).containsExactlyElementsIn(expected).inOrder();
+  }
+
+  @Test
+  public void all() {
+    assertThat(get(ALL)).containsExactlyElementsIn(SCHEMA_DEF.getSchemas().keySet()).inOrder();
+  }
+
+  @Test
+  public void current() {
+    assertThat(get(CURRENT)).containsExactly(SCHEMA_DEF.getLatest().getVersion());
+  }
+
+  @Test
+  public void previous() {
+    assume().that(SCHEMA_DEF.getPrevious()).isNotNull();
+
+    assertThat(get(PREVIOUS)).containsExactly(SCHEMA_DEF.getPrevious().getVersion());
+  }
+
+  @Test
+  public void versionNumber() {
+    assume().that(SCHEMA_DEF.getPrevious()).isNotNull();
+
+    assertThat(get(Integer.toString(SCHEMA_DEF.getPrevious().getVersion())))
+        .containsExactly(SCHEMA_DEF.getPrevious().getVersion());
+  }
+
+  @Test
+  public void invalid() {
+    assertIllegalArgument("foo", "Invalid value for test: foo");
+  }
+
+  @Test
+  public void currentAndPrevious() {
+    if (SCHEMA_DEF.getPrevious() == null) {
+      assertIllegalArgument(CURRENT + "," + PREVIOUS, "previous version does not exist");
+      return;
+    }
+
+    assertThat(get(CURRENT + "," + PREVIOUS))
+        .containsExactly(SCHEMA_DEF.getLatest().getVersion(), SCHEMA_DEF.getPrevious().getVersion())
+        .inOrder();
+    assertThat(get(PREVIOUS + "," + CURRENT))
+        .containsExactly(SCHEMA_DEF.getPrevious().getVersion(), SCHEMA_DEF.getLatest().getVersion())
+        .inOrder();
+  }
+
+  @Test
+  public void currentAndVersionNumber() {
+    assume().that(SCHEMA_DEF.getPrevious()).isNotNull();
+
+    assertThat(get(CURRENT + "," + SCHEMA_DEF.getPrevious().getVersion()))
+        .containsExactly(SCHEMA_DEF.getLatest().getVersion(), SCHEMA_DEF.getPrevious().getVersion())
+        .inOrder();
+    assertThat(get(SCHEMA_DEF.getPrevious().getVersion() + "," + CURRENT))
+        .containsExactly(SCHEMA_DEF.getPrevious().getVersion(), SCHEMA_DEF.getLatest().getVersion())
+        .inOrder();
+  }
+
+  @Test
+  public void currentAndAll() {
+    assertIllegalArgument(CURRENT + "," + ALL, "Invalid value for test: " + ALL);
+  }
+
+  @Test
+  public void currentAndInvalid() {
+    assertIllegalArgument(CURRENT + ",foo", "Invalid value for test: foo");
+  }
+
+  @Test
+  public void nonExistingVersion() {
+    int nonExistingVersion = SCHEMA_DEF.getLatest().getVersion() + 1;
+    assertIllegalArgument(
+        Integer.toString(nonExistingVersion),
+        "Index version "
+            + nonExistingVersion
+            + " that was specified by test not found. Possible versions are: "
+            + SCHEMA_DEF.getSchemas().keySet());
+  }
+
+  private static List<Integer> get(String value) {
+    return IndexVersions.get(ChangeSchemaDefinitions.INSTANCE, "test", value);
+  }
+
+  private void assertIllegalArgument(String value, String expectedMessage) {
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage(expectedMessage);
+    get(value);
+  }
+}
diff --git a/javatests/com/google/gerrit/util/http/BUILD b/javatests/com/google/gerrit/util/http/BUILD
new file mode 100644
index 0000000..5755ca8
--- /dev/null
+++ b/javatests/com/google/gerrit/util/http/BUILD
@@ -0,0 +1,14 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "http_tests",
+    srcs = glob(["**/*.java"]),
+    deps = [
+        "//java/com/google/gerrit/util/http",
+        "//javatests/com/google/gerrit/util/http/testutil",
+        "//lib:junit",
+        "//lib:servlet-api-3_1-without-neverlink",
+        "//lib:truth",
+        "//lib/easymock",
+    ],
+)
diff --git a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/RequestUtilTest.java b/javatests/com/google/gerrit/util/http/RequestUtilTest.java
similarity index 100%
rename from gerrit-util-http/src/test/java/com/google/gerrit/util/http/RequestUtilTest.java
rename to javatests/com/google/gerrit/util/http/RequestUtilTest.java
diff --git a/javatests/com/google/gerrit/util/http/testutil/BUILD b/javatests/com/google/gerrit/util/http/testutil/BUILD
new file mode 100644
index 0000000..b925188
--- /dev/null
+++ b/javatests/com/google/gerrit/util/http/testutil/BUILD
@@ -0,0 +1,13 @@
+java_library(
+    name = "testutil",
+    testonly = 1,
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//lib:guava",
+        "//lib:servlet-api-3_1",
+        "//lib/httpcomponents:httpclient",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
similarity index 100%
rename from gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
rename to javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
diff --git a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
similarity index 100%
rename from gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
rename to javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
diff --git a/javatests/com/google/gwtexpui/safehtml/BUILD b/javatests/com/google/gwtexpui/safehtml/BUILD
new file mode 100644
index 0000000..4f75bdb
--- /dev/null
+++ b/javatests/com/google/gwtexpui/safehtml/BUILD
@@ -0,0 +1,12 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "safehtml_tests",
+    srcs = glob(["client/**/*.java"]),
+    deps = [
+        "//java/com/google/gwtexpui/safehtml",
+        "//lib:truth",
+        "//lib/gwt:dev",
+        "//lib/gwt:user",
+    ],
+)
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/LinkFindReplaceTest.java b/javatests/com/google/gwtexpui/safehtml/client/LinkFindReplaceTest.java
similarity index 100%
rename from gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/LinkFindReplaceTest.java
rename to javatests/com/google/gwtexpui/safehtml/client/LinkFindReplaceTest.java
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/RawFindReplaceTest.java b/javatests/com/google/gwtexpui/safehtml/client/RawFindReplaceTest.java
similarity index 100%
rename from gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/RawFindReplaceTest.java
rename to javatests/com/google/gwtexpui/safehtml/client/RawFindReplaceTest.java
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java b/javatests/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java
similarity index 100%
rename from gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java
rename to javatests/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java b/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java
similarity index 100%
rename from gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java
rename to javatests/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_ReplaceTest.java b/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_ReplaceTest.java
similarity index 100%
rename from gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_ReplaceTest.java
rename to javatests/com/google/gwtexpui/safehtml/client/SafeHtml_ReplaceTest.java
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyListTest.java b/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyListTest.java
similarity index 100%
rename from gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyListTest.java
rename to javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyListTest.java
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyPreformatTest.java b/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyPreformatTest.java
similarity index 100%
rename from gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyPreformatTest.java
rename to javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyPreformatTest.java
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyQuoteTest.java b/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyQuoteTest.java
similarity index 100%
rename from gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyQuoteTest.java
rename to javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyQuoteTest.java
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java b/javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java
similarity index 100%
rename from gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java
rename to javatests/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java
diff --git a/javatests/org/eclipse/jgit/BUILD b/javatests/org/eclipse/jgit/BUILD
new file mode 100644
index 0000000..213c8c5
--- /dev/null
+++ b/javatests/org/eclipse/jgit/BUILD
@@ -0,0 +1,11 @@
+java_test(
+    name = "jgit_patch_tests",
+    srcs = glob(["**/*.java"]),
+    test_class = "org.eclipse.jgit.diff.EditDeserializerTest",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/org/eclipse/jgit:server",
+        "//lib:junit",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/gerrit-patch-jgit/src/test/java/org/eclipse/jgit/diff/EditDeserializerTest.java b/javatests/org/eclipse/jgit/diff/EditDeserializerTest.java
similarity index 100%
rename from gerrit-patch-jgit/src/test/java/org/eclipse/jgit/diff/EditDeserializerTest.java
rename to javatests/org/eclipse/jgit/diff/EditDeserializerTest.java
diff --git a/lib/BUILD b/lib/BUILD
index 91334cb..545c4a5 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -76,22 +76,18 @@
 )
 
 java_library(
+    name = "j2objc",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@j2objc//jar"],
+)
+
+java_library(
     name = "guava",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
     exports = ["@guava//jar"],
-)
-
-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",
-    ],
+    runtime_deps = [":j2objc"],
 )
 
 java_library(
diff --git a/lib/antlr/BUILD b/lib/antlr/BUILD
index 37a354e..08c320b 100644
--- a/lib/antlr/BUILD
+++ b/lib/antlr/BUILD
@@ -1,4 +1,4 @@
-package(default_visibility = ["//gerrit-index:__pkg__"])
+package(default_visibility = ["//java/com/google/gerrit/index:__pkg__"])
 
 [java_library(
     name = n,
@@ -22,7 +22,7 @@
     name = "antlr-tool",
     jvm_flags = ["-XX:-UsePerfData"],
     main_class = "org.antlr.Tool",
-    visibility = ["//gerrit-index:__pkg__"],
+    visibility = ["//antlr3:__pkg__"],
     runtime_deps = [":tool"],
 )
 
diff --git a/lib/asciidoctor/BUILD b/lib/asciidoctor/BUILD
index c7567d9..da05dd1 100644
--- a/lib/asciidoctor/BUILD
+++ b/lib/asciidoctor/BUILD
@@ -31,7 +31,7 @@
     visibility = ["//visibility:public"],
     deps = [
         ":asciidoc_lib",
-        "//gerrit-server:constants",
+        "//java/com/google/gerrit/server:constants",
         "//lib:args4j",
         "//lib:guava",
         "//lib/lucene:lucene-analyzers-common",
diff --git a/lib/asciidoctor/java/AsciiDoctor.java b/lib/asciidoctor/java/AsciiDoctor.java
index 219cc24..596fe66 100644
--- a/lib/asciidoctor/java/AsciiDoctor.java
+++ b/lib/asciidoctor/java/AsciiDoctor.java
@@ -12,10 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import com.google.common.io.ByteStreams;
 import java.io.BufferedReader;
 import java.io.File;
-import java.io.FileReader;
 import java.io.FilenameFilter;
 import java.io.IOException;
 import java.io.InputStream;
@@ -150,7 +151,7 @@
     }
 
     if (revnumberFile != null) {
-      try (BufferedReader reader = new BufferedReader(new FileReader(revnumberFile))) {
+      try (BufferedReader reader = Files.newBufferedReader(revnumberFile.toPath(), UTF_8)) {
         revnumber = reader.readLine();
       }
     }
diff --git a/lib/asciidoctor/java/DocIndexer.java b/lib/asciidoctor/java/DocIndexer.java
index fbb7f94..c90c439 100644
--- a/lib/asciidoctor/java/DocIndexer.java
+++ b/lib/asciidoctor/java/DocIndexer.java
@@ -19,9 +19,7 @@
 import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.FileNotFoundException;
-import java.io.FileReader;
 import java.io.IOException;
-import java.io.InputStreamReader;
 import java.io.UnsupportedEncodingException;
 import java.nio.file.Files;
 import java.nio.file.Paths;
@@ -105,8 +103,7 @@
         }
 
         String title;
-        try (BufferedReader titleReader =
-            new BufferedReader(new InputStreamReader(Files.newInputStream(file.toPath()), UTF_8))) {
+        try (BufferedReader titleReader = Files.newBufferedReader(file.toPath(), UTF_8)) {
           title = titleReader.readLine();
           if (title != null && title.startsWith("[[")) {
             // Generally the first line of the txt is the title. In a few cases the
@@ -120,7 +117,7 @@
         }
 
         String outputFile = AsciiDoctor.mapInFileToOutFile(inputFile, inExt, outExt);
-        try (FileReader reader = new FileReader(file)) {
+        try (BufferedReader reader = Files.newBufferedReader(file.toPath(), UTF_8)) {
           Document doc = new Document();
           doc.add(new TextField(Constants.DOC_FIELD, reader));
           doc.add(new StringField(Constants.URL_FIELD, prefix + outputFile, Field.Store.YES));
diff --git a/lib/commons/BUILD b/lib/commons/BUILD
index cc4de55..741c75d 100644
--- a/lib/commons/BUILD
+++ b/lib/commons/BUILD
@@ -8,13 +8,6 @@
 )
 
 java_library(
-    name = "collections",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@commons_collections//jar"],
-)
-
-java_library(
     name = "compress",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
@@ -57,13 +50,6 @@
 )
 
 java_library(
-    name = "oro",
-    data = ["//lib:LICENSE-Apache1.1"],
-    visibility = ["//visibility:public"],
-    exports = ["@commons_oro//jar"],
-)
-
-java_library(
     name = "validator",
     data = ["//lib:LICENSE-Apache2.0"],
     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..0aa3cf7a 100644
--- a/lib/guava.bzl
+++ b/lib/guava.bzl
@@ -1,5 +1,5 @@
-GUAVA_VERSION = "22.0"
+GUAVA_VERSION = "23.6-jre"
 
-GUAVA_BIN_SHA1 = "3564ef3803de51fb0530a8377ec6100b33b0d073"
+GUAVA_BIN_SHA1 = "c0b638df79e7b2e1ed98f8d68ac62538a715ab1d"
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index 9ca6f54..2fc3f70 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,12 +1,12 @@
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "MAVEN_CENTRAL", "maven_jar")
 
-_JGIT_VERS = "4.9.2.201712150930-r.4-g085d1f959"
+_JGIT_VERS = "4.10.0.201712302008-r"
 
-_DOC_VERS = "4.9.2.201712150930-r"  # Set to _JGIT_VERS unless using a snapshot
+_DOC_VERS = "_JGIT_VERS"  # Set to _JGIT_VERS unless using a snapshot
 
 JGIT_DOC_URL = "http://download.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs"
 
-_JGIT_REPO = GERRIT  # Leave here even if set to MAVEN_CENTRAL.
+_JGIT_REPO = MAVEN_CENTRAL  # Leave here even if set to MAVEN_CENTRAL.
 
 # set this to use a local version.
 # "/home/<user>/projects/jgit"
@@ -26,28 +26,28 @@
         name = "jgit_lib",
         artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "1b9090699d03f57b2f3e682dd4a87ddf6bf460ac",
-        src_sha1 = "46ed37a4a385e7dc3fb65ccf165e5f854698186f",
+        sha1 = "61bda423283956d35117a0c1f993e752d12f3132",
+        src_sha1 = "6695024ae412511a5563b68b0df5dde3f765af77",
         unsign = True,
     )
     maven_jar(
         name = "jgit_servlet",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "27c490cc62a6efd4d4bc10789e8e5945fd01458b",
+        sha1 = "dadd8545b42c47fa1aaa2a53797cb36fa6c91e59",
         unsign = True,
     )
     maven_jar(
         name = "jgit_archive",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "bff16e9ae593808fd8b77f8917fb44c73d2eb2f3",
+        sha1 = "8a666fd481124ae34c11dd51955fc0b58644ab4a",
     )
     maven_jar(
         name = "jgit_junit",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "87f5f48bf2efa11726e2751f316f49c8f2429ee8",
+        sha1 = "57f3e31ee3d8ac90d5cd46359111cbb3a4167ebc",
         unsign = True,
     )
 
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/lib/js/BUILD b/lib/js/BUILD
index 93b321f..0bb7b0c 100644
--- a/lib/js/BUILD
+++ b/lib/js/BUILD
@@ -35,3 +35,8 @@
     srcs = ["//lib/highlightjs:highlight.min.js"],
     data = ["//lib:LICENSE-highlightjs"],
 )
+
+bower_component(
+    name = "codemirror-minified",
+    license = "//lib:LICENSE-codemirror-minified",
+)
diff --git a/lib/js/bower_archives.bzl b/lib/js/bower_archives.bzl
index d3f7483..c035793 100644
--- a/lib/js/bower_archives.bzl
+++ b/lib/js/bower_archives.bzl
@@ -39,7 +39,7 @@
     sha1 = "f58358ee652c67e6e721364ba50fb77a2ece1465")
   bower_archive(
     name = "iron-behaviors",
-    package = "PolymerElements/iron-behaviors",
+    package = "polymerelements/iron-behaviors",
     version = "1.0.18",
     sha1 = "e231a1a02b090f5183db917639fdb96cdd0dca18")
   bower_archive(
@@ -99,17 +99,12 @@
     sha1 = "588d289f779d02b21ce5b676e257bbd6155649e8")
   bower_archive(
     name = "paper-behaviors",
-    package = "polymerelements/paper-behaviors",
+    package = "PolymerElements/paper-behaviors",
     version = "1.0.13",
     sha1 = "a81eab28a952e124c208430e17508d9a1aae4ee7")
   bower_archive(
-    name = "paper-material",
-    package = "polymerelements/paper-material",
-    version = "1.0.7",
-    sha1 = "159b7fb6b13b181c4276b25f9c6adbeaacb0d42b")
-  bower_archive(
     name = "paper-ripple",
-    package = "polymerelements/paper-ripple",
+    package = "PolymerElements/paper-ripple",
     version = "1.0.10",
     sha1 = "21199db50d02b842da54bd6f4f1d1b10b474e893")
   bower_archive(
diff --git a/lib/js/bower_components.bzl b/lib/js/bower_components.bzl
index 2dfe4d7..fb40855 100644
--- a/lib/js/bower_components.bzl
+++ b/lib/js/bower_components.bzl
@@ -223,8 +223,7 @@
     deps = [
       ":iron-flex-layout",
       ":paper-behaviors",
-      ":paper-material",
-      ":paper-ripple",
+      ":paper-styles",
       ":polymer",
     ],
     seed = True,
@@ -266,14 +265,6 @@
     seed = True,
   )
   bower_component(
-    name = "paper-material",
-    license = "//lib:LICENSE-polymer",
-    deps = [
-      ":paper-styles",
-      ":polymer",
-    ],
-  )
-  bower_component(
     name = "paper-ripple",
     license = "//lib:LICENSE-polymer",
     deps = [
@@ -291,6 +282,17 @@
     ],
   )
   bower_component(
+    name = "paper-toggle-button",
+    license = "//lib:LICENSE-polymer",
+    deps = [
+      ":iron-checked-element-behavior",
+      ":paper-behaviors",
+      ":paper-styles",
+      ":polymer",
+    ],
+    seed = True,
+  )
+  bower_component(
     name = "polymer-resin",
     license = "//lib:LICENSE-polymer",
     deps = [
diff --git a/plugins/BUILD b/plugins/BUILD
index 3253b98..7c3fdd8 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -16,3 +16,117 @@
           "zip -qr $$ROOT/$@ .",
     visibility = ["//visibility:public"],
 )
+
+PLUGIN_API = [
+    "//java/com/google/gerrit/server",
+    "//java/com/google/gerrit/pgm/init/api",
+    "//java/com/google/gerrit/httpd",
+    "//java/com/google/gerrit/sshd",
+]
+
+EXPORTS = [
+    "//java/com/google/gerrit/common:annotations",
+    "//java/com/google/gerrit/common:server",
+    "//java/com/google/gerrit/extensions:api",
+    "//java/com/google/gerrit/index",
+    "//java/com/google/gerrit/index:query_exception",
+    "//java/com/google/gerrit/index:query_parser",
+    "//java/com/google/gerrit/lifecycle",
+    "//java/com/google/gerrit/metrics",
+    "//java/com/google/gerrit/metrics/dropwizard",
+    "//java/com/google/gerrit/reviewdb:server",
+    "//java/com/google/gwtexpui/server",
+    "//lib/commons:dbcp",
+    "//lib/commons:lang",
+    "//lib/commons:lang3",
+    "//lib/dropwizard:dropwizard-core",
+    "//lib/guice:guice",
+    "//lib/guice:guice-assistedinject",
+    "//lib/guice:guice-servlet",
+    "//lib/guice:javax-inject",
+    "//lib/guice:multibindings",
+    "//lib/httpcomponents:httpclient",
+    "//lib/httpcomponents:httpcore",
+    "//lib/jackson:jackson-core",
+    "//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet",
+    "//lib/jgit/org.eclipse.jgit:jgit",
+    "//lib/log:api",
+    "//lib/log:log4j",
+    "//lib/mina:sshd",
+    "//lib/ow2:ow2-asm",
+    "//lib/ow2:ow2-asm-analysis",
+    "//lib/ow2:ow2-asm-commons",
+    "//lib/ow2:ow2-asm-util",
+    "//lib:args4j",
+    "//lib:blame-cache",
+    "//lib:guava",
+    "//lib:guava-retrying",
+    "//lib:gson",
+    "//lib:gwtorm",
+    "//lib:icu4j",
+    "//lib:jsch",
+    "//lib:mime-util",
+    "//lib:protobuf",
+    "//lib:servlet-api-3_1-without-neverlink",
+    "//lib:soy",
+    "//prolog:gerrit-prolog-common",
+]
+
+java_binary(
+    name = "plugin-api",
+    main_class = "Dummy",
+    visibility = ["//visibility:public"],
+    runtime_deps = [":plugin-lib"],
+)
+
+java_library(
+    name = "plugin-lib",
+    visibility = ["//visibility:public"],
+    exports = PLUGIN_API + EXPORTS,
+)
+
+java_library(
+    name = "plugin-lib-neverlink",
+    neverlink = 1,
+    visibility = ["//visibility:public"],
+    exports = PLUGIN_API + EXPORTS,
+)
+
+java_binary(
+    name = "plugin-api-sources",
+    main_class = "Dummy",
+    visibility = ["//visibility:public"],
+    runtime_deps = [
+        "//java/com/google/gerrit/common:libannotations-src.jar",
+        "//java/com/google/gerrit/common:libserver-src.jar",
+        "//java/com/google/gerrit/extensions:libapi-src.jar",
+        "//java/com/google/gerrit/httpd:libhttpd-src.jar",
+        "//java/com/google/gerrit/index:libindex-src.jar",
+        "//java/com/google/gerrit/index:libquery_exception-src.jar",
+        "//java/com/google/gerrit/index:libquery_parser-src.jar",
+        "//java/com/google/gerrit/pgm/init/api:libapi-src.jar",
+        "//java/com/google/gerrit/reviewdb:libserver-src.jar",
+        "//java/com/google/gerrit/server:libserver-src.jar",
+        "//java/com/google/gerrit/sshd:libsshd-src.jar",
+        "//java/com/google/gwtexpui/server:libserver-src.jar",
+    ],
+)
+
+load("//tools/bzl:javadoc.bzl", "java_doc")
+
+java_doc(
+    name = "plugin-api-javadoc",
+    libs = PLUGIN_API + [
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/index:query_parser",
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gwtexpui/server",
+        "//java/com/google/gerrit/reviewdb:server",
+    ],
+    pkgs = ["com.google.gerrit"],
+    title = "Gerrit Review Plugin API Documentation",
+    visibility = ["//visibility:public"],
+)
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
new file mode 160000
index 0000000..b33196a
--- /dev/null
+++ b/plugins/codemirror-editor
@@ -0,0 +1 @@
+Subproject commit b33196a3da70e75ad00b5ac787620b29d20fed65
diff --git a/plugins/download-commands b/plugins/download-commands
index 43656ce..ddbd127 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 43656ce1658a56834cfe62760eafbb9b17c7ad53
+Subproject commit ddbd12781fd4b3156625608ee608950ab370b9a3
diff --git a/plugins/replication b/plugins/replication
index 8bc97a1..32f7c91 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 8bc97a106a46a0f32350396e74769af11ec7b98e
+Subproject commit 32f7c91a3d905d891b7bec9d44bf274718351d5e
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index bd390d5..0c2cd5e 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -26,6 +26,7 @@
         "//lib/js:paper-input",
         "//lib/js:paper-item",
         "//lib/js:paper-listbox",
+        "//lib/js:paper-toggle-button",
         "//lib/js:polymer",
         "//lib/js:polymer-resin",
         "//lib/js:promise-polyfill",
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index e47d246..d444f79 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -1,25 +1,46 @@
 # PolyGerrit
 
-## Installing [Node.js](https://nodejs.org/en/download/)
+## Installing [Bazel](https://bazel.build/)
+
+Follow the instructions
+[here](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_installation)
+to get and install Bazel.
+
+## Installing [Node.js](https://nodejs.org/en/download/) and npm packages
 
 The minimum nodejs version supported is 6.x+
 
 ```sh
 # Debian experimental
 sudo apt-get install nodejs-legacy
+sudo apt-get install npm
 
 # OS X with Homebrew
 brew install node
+brew install npm
 ```
 
 All other platforms: [download from
 nodejs.org](https://nodejs.org/en/download/).
 
-## Installing [Bazel](https://bazel.build/)
+Various steps below require installing additional npm packages. The full list of
+dependencies can be installed with:
 
-Follow the instructions
-[here](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_installation)
-to get and install Bazel.
+```sh
+sudo npm install -g \
+  eslint \
+  eslint-config-google \
+  eslint-plugin-html \
+  typescript \
+  fried-twinkie \
+  polylint \
+  web-component-tester
+```
+
+It may complain about a missing `typescript@2.3.4` peer dependency, which is
+harmless.
+
+If you're interested in the details, keep reading.
 
 ## Local UI, Production Data
 
@@ -78,18 +99,7 @@
 
 ## Running Tests
 
-One-time setup:
-
-```sh
-# Debian/Ubuntu
-sudo apt-get install npm
-
-# OS X with Homebrew
-brew install npm
-
-# All platforms (including those above)
-sudo npm install -g web-component-tester
-```
+This step requires the `web-component-tester` npm module.
 
 Run all web tests:
 
@@ -123,11 +133,7 @@
 
 In addition, we encourage the use of [ESLint](http://eslint.org/).
 It is available as a command line utility, as well as a plugin for most editors
-and IDEs. It, along with a few dependencies, can also be installed through NPM:
-
-```sh
-sudo npm install -g eslint eslint-config-google eslint-plugin-html
-```
+and IDEs.
 
 `eslint-config-google` is a port of the Google JS Style Guide to an ESLint
 config module, and `eslint-plugin-html` allows ESLint to lint scripts inside
@@ -149,13 +155,9 @@
 * To run the linter on all of your local changes:
 `git diff --name-only master | xargs eslint --ext .html,.js`
 
-We also use the polylint tool to lint use of Polymer. To install polylint,
+We also use the `polylint` tool to lint use of Polymer. To install polylint,
 execute the following command.
 
-```sh
-npm install -g polylint
-```
-
 To run polylint, execute the following command.
 
 ```sh
@@ -168,10 +170,7 @@
 - Any functions with optional parameters will need closure annotations.
 - Any Polymer parameters that are nullable or can be multiple types (other than the one explicitly delared) will need type annotations.
 
-A few dependencies are necessary to run these tests:
-``` sh
-npm install -g typescript fried-twinkie
-```
+These tests require the `typescript` and `fried-twinkie` npm packages.
 
 To run on all files, execute the following command:
 
@@ -191,4 +190,4 @@
 
 ```sh
 bazel test //polygerrit-ui/app:template_test_change-list --test_arg=gr-change-list-view  --test_output errors
-```
\ No newline at end of file
+```
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 1f18bfc..ec7efb6 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -4,14 +4,7 @@
 
 load(":rules.bzl", "polygerrit_bundle")
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library", "closure_js_binary")
-load(
-    "//tools/bzl:js.bzl",
-    "bower_component_bundle",
-    "vulcanize",
-    "bower_component",
-    "js_component",
-)
+load("//tools/bzl:js.bzl", "bower_component_bundle")
 
 polygerrit_bundle(
     name = "polygerrit_ui",
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
index cda8c530..027d481 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
@@ -20,6 +20,8 @@
 
   window.Gerrit = window.Gerrit || {};
 
+  const PROJECT_DASHBOARD_PATTERN = /\/p\/(.+)\/\+\/dashboard\/(.*)/;
+
   /** @polymerBehavior Gerrit.BaseUrlBehavior */
   Gerrit.BaseUrlBehavior = {
     /** @return {string} */
@@ -29,7 +31,11 @@
 
     computeGwtUrl(path) {
       const base = this.getBaseUrl();
-      const clientPath = path.substring(base.length);
+      let clientPath = path.substring(base.length);
+      const match = clientPath.match(PROJECT_DASHBOARD_PATTERN);
+      if (match) {
+        clientPath = `/projects/${match[1]},dashboards/${match[2]}`;
+      }
       return base + '/?polygerrit=0#' + clientPath;
     },
   };
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
index b7c29dc..1fa8f34 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
@@ -72,5 +72,11 @@
           '/r/?polygerrit=0#/c/1/'
       );
     });
+
+    test('computeGwtUrl for project dashboard', () => {
+      assert.deepEqual(
+          element.computeGwtUrl('/r/p/gerrit/proj/+/dashboard/main:default'),
+          '/r/?polygerrit=0#/projects/gerrit/proj,dashboards/main:default');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
index 13c232e..9745f9e 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
@@ -104,11 +104,13 @@
     },
 
     /**
-     * Sort given revisions array according to the patch set number. The sort
-     * algorithm is change edit aware. Change edit has patch set number equals
-     * 0, but must appear after the patch set it was based on. Example: change
-     * edit is based on patch set 2, and another patch set was uploaded after
-     * change edit creation, the sorted order should be: 1, 2, (0|edit), 3.
+     * Sort given revisions array according to the patch set number, in
+     * descending order.
+     * The sort algorithm is change edit aware. Change edit has patch set number
+     * equals 'edit', but must appear after the patch set it was based on.
+     * Example: change edit is based on patch set 2, and another patch set was
+     * uploaded after change edit creation, the sorted order should be:
+     * 3, edit, 2, 1.
      *
      * @param {!Array<!Object>} revisions The revisions array
      * @return {!Array<!Object>} The sorted {revisions} array
@@ -122,7 +124,7 @@
       const num = r => r._number === Gerrit.PatchSetBehavior.EDIT_NAME ?
           2 * editParent :
           2 * (r._number - 1) + 1;
-      return revisions.sort((a, b) => num(a) - num(b));
+      return revisions.sort((a, b) => num(b) - num(a));
     },
 
     /**
@@ -143,8 +145,7 @@
     computeAllPatchSets(change) {
       if (!change) { return []; }
       let patchNums = [];
-      if (change.revisions &&
-          Object.keys(change.revisions).length) {
+      if (change.revisions && Object.keys(change.revisions).length) {
         patchNums =
           Gerrit.PatchSetBehavior.sortRevisions(Object.values(change.revisions))
               .map(e => {
@@ -195,29 +196,27 @@
     /** @return {number|undefined} */
     computeLatestPatchNum(allPatchSets) {
       if (!allPatchSets || !allPatchSets.length) { return undefined; }
-      if (allPatchSets[allPatchSets.length - 1].num ===
-          Gerrit.PatchSetBehavior.EDIT_NAME) {
-        return allPatchSets[allPatchSets.length - 2].num;
+      if (allPatchSets[0].num === Gerrit.PatchSetBehavior.EDIT_NAME) {
+        return allPatchSets[1].num;
       }
-      return allPatchSets[allPatchSets.length - 1].num;
+      return allPatchSets[0].num;
     },
 
     /** @return {Boolean} */
     hasEditBasedOnCurrentPatchSet(allPatchSets) {
-      if (!allPatchSets || !allPatchSets.length) { return false; }
-      return allPatchSets[allPatchSets.length - 1].num ===
-          Gerrit.PatchSetBehavior.EDIT_NAME;
+      if (!allPatchSets || allPatchSets.length < 2) { return false; }
+      return allPatchSets[0].num === Gerrit.PatchSetBehavior.EDIT_NAME;
     },
 
     /**
      * Check whether there is no newer patch than the latest patch that was
      * available when this change was loaded.
      *
-     * @return {Promise<boolean>} A promise that yields true if the latest patch
+     * @return {Promise<!Object>} A promise that yields true if the latest patch
      *     has been loaded, and false if a newer patch has been uploaded in the
      *     meantime. The promise is rejected on network error.
      */
-    fetchIsLatestKnown(change, restAPI) {
+    fetchChangeUpdates(change, restAPI) {
       const knownLatest = Gerrit.PatchSetBehavior.computeLatestPatchNum(
           Gerrit.PatchSetBehavior.computeAllPatchSets(change));
       return restAPI.getChangeDetail(change._number)
@@ -227,7 +226,11 @@
             }
             const actualLatest = Gerrit.PatchSetBehavior.computeLatestPatchNum(
                 Gerrit.PatchSetBehavior.computeAllPatchSets(detail));
-            return actualLatest <= knownLatest;
+            return {
+              isLatest: actualLatest <= knownLatest,
+              newStatus: change.status !== detail.status ? detail.status : null,
+              newMessages: change.messages.length < detail.messages.length,
+            };
           });
     },
 
@@ -242,6 +245,16 @@
       const findNum = rev => rev._number + '' === patchNum + '';
       return revisions.findIndex(findNum);
     },
+
+    /**
+     * Convert parent indexes from patch range expressions to numbers.
+     * For example, in a patch range expression `"-3"` becomes `3`.
+     * @param {number|string} rangeBase
+     * @return {number}
+     */
+    getParentIndex(rangeBase) {
+      return -parseInt(rangeBase + '', 10);
+    },
   };
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
index 54c1355..7116c5d 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
@@ -22,7 +22,7 @@
 <link rel="import" href="gr-patch-set-behavior.html">
 
 <script>
-  suite('gr-path-list-behavior tests', () => {
+  suite('gr-patch-set-behavior tests', () => {
     test('getRevisionByPatchNum', () => {
       const get = Gerrit.PatchSetBehavior.getRevisionByPatchNum;
       const revisions = [
@@ -35,31 +35,37 @@
       assert.equal(get(revisions, '3'), undefined);
     });
 
-    test('fetchIsLatestKnown on latest', done => {
+    test('fetchChangeUpdates on latest', done => {
       const knownChange = {
         revisions: {
           sha1: {description: 'patch 1', _number: 1},
           sha2: {description: 'patch 2', _number: 2},
         },
+        status: 'NEW',
+        messages: [],
       };
       const mockRestApi = {
         getChangeDetail() {
           return Promise.resolve(knownChange);
         },
       };
-      Gerrit.PatchSetBehavior.fetchIsLatestKnown(knownChange, mockRestApi)
-          .then(isLatest => {
-            assert.isTrue(isLatest);
+      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+          .then(result => {
+            assert.isTrue(result.isLatest);
+            assert.isNotOk(result.newStatus);
+            assert.isFalse(result.newMessages);
             done();
           });
     });
 
-    test('fetchIsLatestKnown not on latest', done => {
+    test('fetchChangeUpdates not on latest', done => {
       const knownChange = {
         revisions: {
           sha1: {description: 'patch 1', _number: 1},
           sha2: {description: 'patch 2', _number: 2},
         },
+        status: 'NEW',
+        messages: [],
       };
       const actualChange = {
         revisions: {
@@ -67,15 +73,81 @@
           sha2: {description: 'patch 2', _number: 2},
           sha3: {description: 'patch 3', _number: 3},
         },
+        status: 'NEW',
+        messages: [],
       };
       const mockRestApi = {
         getChangeDetail() {
           return Promise.resolve(actualChange);
         },
       };
-      Gerrit.PatchSetBehavior.fetchIsLatestKnown(knownChange, mockRestApi)
-          .then(isLatest => {
-            assert.isFalse(isLatest);
+      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+          .then(result => {
+            assert.isFalse(result.isLatest);
+            assert.isNotOk(result.newStatus);
+            assert.isFalse(result.newMessages);
+            done();
+          });
+    });
+
+    test('fetchChangeUpdates new status', done => {
+      const knownChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+        },
+        status: 'NEW',
+        messages: [],
+      };
+      const actualChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+        },
+        status: 'MERGED',
+        messages: [],
+      };
+      const mockRestApi = {
+        getChangeDetail() {
+          return Promise.resolve(actualChange);
+        },
+      };
+      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+          .then(result => {
+            assert.isTrue(result.isLatest);
+            assert.equal(result.newStatus, 'MERGED');
+            assert.isFalse(result.newMessages);
+            done();
+          });
+    });
+
+    test('fetchChangeUpdates new messages', done => {
+      const knownChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+        },
+        status: 'NEW',
+        messages: [],
+      };
+      const actualChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+        },
+        status: 'NEW',
+        messages: [{message: 'blah blah'}],
+      };
+      const mockRestApi = {
+        getChangeDetail() {
+          return Promise.resolve(actualChange);
+        },
+      };
+      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+          .then(result => {
+            assert.isTrue(result.isLatest);
+            assert.isNotOk(result.newStatus);
+            assert.isTrue(result.newMessages);
             done();
           });
     });
@@ -226,24 +298,29 @@
         {_number: 1},
       ];
       const sorted = [
-        {_number: 0},
-        {_number: 1},
         {_number: 2},
+        {_number: 1},
+        {_number: 0},
       ];
 
       assert.deepEqual(sort(revisions), sorted);
 
       // Edit patchset should follow directly after its basePatchNum.
       revisions.push({_number: 'edit', basePatchNum: 2});
-      sorted.push({_number: 'edit', basePatchNum: 2});
+      sorted.unshift({_number: 'edit', basePatchNum: 2});
       assert.deepEqual(sort(revisions), sorted);
 
-      revisions[3].basePatchNum = 0;
-      const edit = sorted.pop();
+      revisions[0].basePatchNum = 0;
+      const edit = sorted.shift();
       edit.basePatchNum = 0;
-      // Edit patchset should be at index 1.
-      sorted.splice(1, 0, edit);
+      // Edit patchset should be at index 2.
+      sorted.splice(2, 0, edit);
       assert.deepEqual(sort(revisions), sorted);
     });
+
+    test('getParentIndex', () => {
+      assert.equal(Gerrit.PatchSetBehavior.getParentIndex('-13'), 13);
+      assert.equal(Gerrit.PatchSetBehavior.getParentIndex(-4), 4);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
index 7da82b7..109f534 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
@@ -26,6 +26,11 @@
         type: Boolean,
         observer: '_setupTooltipListeners',
       },
+      positionBelow: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
 
       _isTouchDevice: {
         type: Boolean,
@@ -73,6 +78,7 @@
       const tooltip = document.createElement('gr-tooltip');
       tooltip.text = this._titleText;
       tooltip.maxWidth = this.getAttribute('max-width');
+      tooltip.positionBelow = this.getAttribute('position-below');
 
       // Set visibility to hidden before appending to the DOM so that
       // calculations can be made based on the element’s size.
@@ -125,9 +131,14 @@
         });
       }
       tooltip.style.left = Math.max(0, left) + 'px';
-      tooltip.style.top = Math.max(0, top) + 'px';
-      tooltip.style.transform = 'translateY(calc(-100% - ' + BOTTOM_OFFSET +
-          'px))';
+
+      if (!this.positionBelow) {
+        tooltip.style.top = Math.max(0, top) + 'px';
+        tooltip.style.transform = 'translateY(calc(-100% - ' + BOTTOM_OFFSET +
+            'px))';
+      } else {
+        tooltip.style.top = top + rect.height + BOTTOM_OFFSET + 'px';
+      }
     },
   };
 })(window);
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
index f442c43..a73c9ab 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
@@ -111,6 +111,24 @@
       assert.equal(tooltip.style.top, '100px');
     });
 
+    test('position to bottom', () => {
+      sandbox.stub(element, 'getBoundingClientRect', () => {
+        return {top: 100, left: 950, width: 50, height: 50};
+      });
+      const tooltip = makeTooltip(
+          {height: 30, width: 120},
+          {top: 0, left: 0, width: 1000});
+
+      element.positionBelow = true;
+      element._positionTooltip(tooltip);
+      assert.isTrue(tooltip.updateStyles.called);
+      const offset = tooltip.updateStyles
+          .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+      assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
+      assert.equal(tooltip.style.left, '915px');
+      assert.equal(tooltip.style.top, '157.2px');
+    });
+
     test('hides tooltip when detached', () => {
       sandbox.stub(element, '_handleHideTooltip');
       element.remove();
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
index a6106e4..a5db2d0 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
@@ -119,7 +119,13 @@
       return status === this.ChangeStatus.NEW;
     },
 
-    changeStatusString(change) {
+    /**
+     * @param {!Object} change
+     * @param {!Object=} opt_options
+     *
+     * @return {!Array}
+     */
+    changeStatuses(change, opt_options) {
       const states = [];
       if (change.status === this.ChangeStatus.MERGED) {
         states.push('Merged');
@@ -131,7 +137,27 @@
       }
       if (change.work_in_progress) { states.push('WIP'); }
       if (change.is_private) { states.push('Private'); }
-      return states.join(', ');
+
+      // If there are any pre-defined statuses, only return those. Otherwise,
+      // will determine the derived status.
+      if (states.length || !opt_options) { return states; }
+
+      // If no missing requirements, either active or ready to submit.
+      if (opt_options.readyToSubmit) {
+        states.push('Ready to submit');
+      } else {
+        // Otherwise it is active.
+        states.push('Active');
+      }
+      return states;
+    },
+
+    /**
+     * @param {!Object} change
+     * @return {String}
+     */
+    changeStatusString(change) {
+      return this.changeStatuses(change).join(', ');
     },
   },
     Gerrit.BaseUrlBehavior,
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
index 968b855..9024775 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
@@ -87,8 +87,20 @@
         labels: {},
         mergeable: true,
       };
-      const status = element.changeStatusString(change);
-      assert.equal(status, '');
+      let statuses = element.changeStatuses(change);
+      let statusString = element.changeStatusString(change);
+      assert.deepEqual(statuses, []);
+      assert.equal(statusString, '');
+
+      statuses = element.changeStatuses(change,
+          {readyToSubmit: false, includeDerived: true});
+      assert.deepEqual(statuses, ['Active']);
+
+      // With no missing labels
+      statuses = element.changeStatuses(change,
+          {readyToSubmit: true, includeDerived: true});
+      statusString = element.changeStatusString(change);
+      assert.deepEqual(statuses, ['Ready to submit']);
     });
 
     test('Merge conflict', () => {
@@ -102,8 +114,10 @@
         labels: {},
         mergeable: false,
       };
-      const status = element.changeStatusString(change);
-      assert.equal(status, 'Merge Conflict');
+      const statuses = element.changeStatuses(change);
+      const statusString = element.changeStatusString(change);
+      assert.deepEqual(statuses, ['Merge Conflict']);
+      assert.equal(statusString, 'Merge Conflict');
     });
 
     test('mergeable prop undefined', () => {
@@ -116,8 +130,10 @@
         status: 'NEW',
         labels: {},
       };
-      const status = element.changeStatusString(change);
-      assert.equal(status, '');
+      const statuses = element.changeStatuses(change);
+      const statusString = element.changeStatusString(change);
+      assert.deepEqual(statuses, []);
+      assert.equal(statusString, '');
     });
 
     test('Merged status', () => {
@@ -130,8 +146,10 @@
         status: 'MERGED',
         labels: {},
       };
-      const status = element.changeStatusString(change);
-      assert.equal(status, 'Merged');
+      const statuses = element.changeStatuses(change);
+      const statusString = element.changeStatusString(change);
+      assert.deepEqual(statuses, ['Merged']);
+      assert.equal(statusString, 'Merged');
     });
 
     test('Abandoned status', () => {
@@ -144,8 +162,10 @@
         status: 'ABANDONED',
         labels: {},
       };
-      const status = element.changeStatusString(change);
-      assert.equal(status, 'Abandoned');
+      const statuses = element.changeStatuses(change);
+      const statusString = element.changeStatusString(change);
+      assert.deepEqual(statuses, ['Abandoned']);
+      assert.equal(statusString, 'Abandoned');
     });
 
     test('Open status with private and wip', () => {
@@ -161,8 +181,10 @@
         labels: {},
         mergeable: true,
       };
-      const status = element.changeStatusString(change);
-      assert.equal(status, 'WIP, Private');
+      const statuses = element.changeStatuses(change);
+      const statusString = element.changeStatusString(change);
+      assert.deepEqual(statuses, ['WIP', 'Private']);
+      assert.equal(statusString, 'WIP, Private');
     });
 
     test('Merge conflict with private and wip', () => {
@@ -178,8 +200,10 @@
         labels: {},
         mergeable: false,
       };
-      const status = element.changeStatusString(change);
-      assert.equal(status, 'Merge Conflict, WIP, Private');
+      const statuses = element.changeStatuses(change);
+      const statusString = element.changeStatusString(change);
+      assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
+      assert.equal(statusString, 'Merge Conflict, WIP, Private');
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
index 529e14c..14662dd 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
@@ -61,12 +61,13 @@
       .editContainer {
         display: none;
       }
-      .deleted #deletedContainer,
+      /* TODO @beckysiegel add back when editing allowed */
+      /* .deleted #deletedContainer,
       #mainContainer,
       .editing #addPermission,
       .editing #updateBtns  {
         display: block;
-      }
+      } */
       .editingRef .editContainer {
         display: flex;
       }
@@ -125,7 +126,8 @@
               </template>
             </select>
             <gr-button id="addBtn" on-tap="_handleAddPermission">Add</gr-button>
-          </div><!-- end addPermission -->
+          </div>
+          <!-- end addPermission -->
         </div><!-- end sectionContent -->
       </div><!-- end mainContainer -->
       <div id="deletedContainer">
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
index efacb1e..2d84179 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
@@ -20,6 +20,7 @@
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-table-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
 <link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
@@ -69,10 +70,10 @@
           confirm-label="Create"
           on-confirm="_handleCreateGroup"
           on-cancel="_handleCloseCreate">
-        <div class="header">
+        <div class="header" slot="header">
           Create Group
         </div>
-        <div class="main">
+        <div class="main" slot="main">
           <gr-create-group-dialog
               has-new-group-name="{{_hasNewGroupName}}"
               params="[[params]]"
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
index 72439e2..e4f5647 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
@@ -93,7 +93,7 @@
     },
 
     _computeGroupUrl(id) {
-      return this.getUrl(this._path + '/', id);
+      return Gerrit.Nav.getUrlForGroup(id);
     },
 
     _getCreateGroupCapability() {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
index 951df45..e298d8d 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
@@ -21,19 +21,19 @@
 <link rel="import" href="../../../styles/gr-menu-page-styles.html">
 <link rel="import" href="../../../styles/gr-page-nav-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-page-nav/gr-page-nav.html">
-<link rel="import" href="../../shared/gr-placeholder/gr-placeholder.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-admin-group-list/gr-admin-group-list.html">
 <link rel="import" href="../gr-group/gr-group.html">
 <link rel="import" href="../gr-group-audit-log/gr-group-audit-log.html">
 <link rel="import" href="../gr-group-members/gr-group-members.html">
 <link rel="import" href="../gr-plugin-list/gr-plugin-list.html">
-<link rel="import" href="../gr-project/gr-project.html">
-<link rel="import" href="../gr-project-access/gr-project-access.html">
-<link rel="import" href="../gr-project-commands/gr-project-commands.html">
-<link rel="import" href="../gr-project-detail-list/gr-project-detail-list.html">
-<link rel="import" href="../gr-project-list/gr-project-list.html">
+<link rel="import" href="../gr-repo/gr-repo.html">
+<link rel="import" href="../gr-repo-access/gr-repo-access.html">
+<link rel="import" href="../gr-repo-commands/gr-repo-commands.html">
+<link rel="import" href="../gr-repo-detail-list/gr-repo-detail-list.html">
+<link rel="import" href="../gr-repo-list/gr-repo-list.html">
 
 <dom-module id="gr-admin-view">
   <template>
@@ -71,14 +71,14 @@
         </template>
       </ul>
     </gr-page-nav>
-    <template is="dom-if" if="[[_showProjectList]]" restamp="true">
+    <template is="dom-if" if="[[_showRepoList]]" restamp="true">
       <main class="table">
-        <gr-project-list class="table" params="[[params]]"></gr-project-list>
+        <gr-repo-list class="table" params="[[params]]"></gr-repo-list>
       </main>
     </template>
-    <template is="dom-if" if="[[_showProjectMain]]" restamp="true">
+    <template is="dom-if" if="[[_showRepoMain]]" restamp="true">
       <main>
-        <gr-project project="[[params.project]]"></gr-project>
+        <gr-repo repo="[[params.repo]]"></gr-repo>
       </main>
     </template>
     <template is="dom-if" if="[[_showGroup]]" restamp="true">
@@ -105,11 +105,11 @@
         <gr-plugin-list class="table" params="[[params]]"></gr-plugin-list>
       </main>
     </template>
-    <template is="dom-if" if="[[_showProjectDetailList]]" restamp="true">
+    <template is="dom-if" if="[[_showRepoDetailList]]" restamp="true">
       <main class="table">
-        <gr-project-detail-list
+        <gr-repo-detail-list
             params="[[params]]"
-            class="table"></gr-project-detail-list>
+            class="table"></gr-repo-detail-list>
       </main>
     </template>
     <template is="dom-if" if="[[_showGroupAuditLog]]" restamp="true">
@@ -119,22 +119,19 @@
             class="table"></gr-group-audit-log>
       </main>
     </template>
-    <template is="dom-if" if="[[_showProjectCommands]]" restamp="true">
+    <template is="dom-if" if="[[_showRepoCommands]]" restamp="true">
       <main>
-        <gr-project-commands
-            project="[[params.project]]"></gr-project-commands>
+        <gr-repo-commands
+            repo="[[params.repo]]"></gr-repo-commands>
       </main>
     </template>
-    <template is="dom-if" if="[[_showProjectAccess]]" restamp="true">
+    <template is="dom-if" if="[[_showRepoAccess]]" restamp="true">
       <main class="table">
-        <gr-project-access
+        <gr-repo-access
             path="[[path]]"
-            project="[[params.project]]"></gr-project-access>
+            repo="[[params.repo]]"></gr-repo-access>
       </main>
     </template>
-    <template is="dom-if" if="[[params.placeholder]]" restamp="true">
-      <gr-placeholder title="Admin" path="[[path]]"></gr-placeholder>
-    </template>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-admin-view.js"></script>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
index ae28a05..0ee4538 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
@@ -14,15 +14,19 @@
 (function() {
   'use strict';
 
+  // Note: noBaseUrl: true is set on entries where the URL is not yet supported
+  // by router abstraction.
   const ADMIN_LINKS = [{
-    name: 'Projects',
-    url: '/admin/projects',
-    view: 'gr-project-list',
+    name: 'Repositories',
+    noBaseUrl: true,
+    url: '/admin/repos',
+    view: 'gr-repo-list',
     viewableToAll: true,
     children: [],
   }, {
     name: 'Groups',
     section: 'Groups',
+    noBaseUrl: true,
     url: '/admin/groups',
     view: 'gr-admin-group-list',
     children: [],
@@ -30,6 +34,7 @@
     name: 'Plugins',
     capability: 'viewPlugins',
     section: 'Plugins',
+    noBaseUrl: true,
     url: '/admin/plugins',
     view: 'gr-plugin-list',
   }];
@@ -45,7 +50,7 @@
       path: String,
       adminView: String,
 
-      _projectName: String,
+      _repoName: String,
       _groupId: {
         type: Number,
         observer: '_computeGroupName',
@@ -68,12 +73,12 @@
       _showGroupAuditLog: Boolean,
       _showGroupList: Boolean,
       _showGroupMembers: Boolean,
-      _showProjectCommands: Boolean,
-      _showProjectMain: Boolean,
-      _showProjectList: Boolean,
-      _showProjectDetailList: Boolean,
+      _showRepoAccess: Boolean,
+      _showRepoCommands: Boolean,
+      _showRepoDetailList: Boolean,
+      _showRepoMain: Boolean,
+      _showRepoList: Boolean,
       _showPluginList: Boolean,
-      _showProjectAccess: Boolean,
     },
 
     behaviors: [
@@ -109,53 +114,48 @@
         const linkCopy = Object.assign({}, link);
         linkCopy.children = linkCopy.children ?
             linkCopy.children.filter(filterFn) : [];
-        if (linkCopy.name === 'Projects' && this._projectName) {
+        if (linkCopy.name === 'Repositories' && this._repoName) {
           linkCopy.subsection = {
-            name: this._projectName,
-            view: 'gr-project',
-            url: `/admin/projects/${this.encodeURL(this._projectName, true)}`,
+            name: this._repoName,
+            view: Gerrit.Nav.View.REPO,
+            url: Gerrit.Nav.getUrlForRepo(this._repoName),
             children: [{
               name: 'Access',
-              detailType: 'access',
-              view: 'gr-project-access',
-              url: `/admin/projects/` +
-                  `${this.encodeURL(this._projectName, true)},access`,
+              view: Gerrit.Nav.View.REPO,
+              detailType: Gerrit.Nav.RepoDetailView.ACCESS,
+              url: Gerrit.Nav.getUrlForRepoAccess(this._repoName),
             },
             {
               name: 'Commands',
-              detailType: 'commands',
-              view: 'gr-project-commands',
-              url: `/admin/projects/` +
-                  `${this.encodeURL(this._projectName, true)},commands`,
+              view: Gerrit.Nav.View.REPO,
+              detailType: Gerrit.Nav.RepoDetailView.COMMANDS,
+              url: Gerrit.Nav.getUrlForRepoCommands(this._repoName),
             },
             {
               name: 'Branches',
-              detailType: 'branches',
-              view: 'gr-project-detail-list',
-              url: `/admin/projects/` +
-                  `${this.encodeURL(this._projectName, true)},branches`,
+              view: Gerrit.Nav.View.REPO,
+              detailType: Gerrit.Nav.RepoDetailView.BRANCHES,
+              url: Gerrit.Nav.getUrlForRepoBranches(this._repoName),
             },
             {
               name: 'Tags',
-              detailType: 'tags',
-              view: 'gr-project-detail-list',
-              url: `/admin/projects/` +
-                  `${this.encodeURL(this._projectName, true)},tags`,
+              view: Gerrit.Nav.View.REPO,
+              detailType: Gerrit.Nav.RepoDetailView.TAGS,
+              url: Gerrit.Nav.getUrlForRepoTags(this._repoName),
             }],
           };
         }
         if (linkCopy.name === 'Groups' && this._groupId && this._groupName) {
           linkCopy.subsection = {
             name: this._groupName,
-            view: 'gr-group',
-            url: `/admin/groups/${this.encodeURL(this._groupId + '', true)}`,
+            view: Gerrit.Nav.View.GROUP,
+            url: Gerrit.Nav.getUrlForGroup(this._groupId),
             children: [
               {
                 name: 'Members',
-                detailType: 'members',
-                view: 'gr-group-members',
-                url: `/admin/groups/${this.encodeURL(this._groupId, true)}` +
-                    ',members',
+                detailType: Gerrit.Nav.GroupDetailView.MEMBERS,
+                view: Gerrit.Nav.View.GROUP,
+                url: Gerrit.Nav.getUrlForGroupMembers(this._groupId),
               },
             ],
           };
@@ -163,10 +163,9 @@
             linkCopy.subsection.children.push(
                 {
                   name: 'Audit Log',
-                  detailType: 'audit-log',
-                  view: 'gr-group-audit-log',
-                  url: '/admin/groups/' +
-                      `${this.encodeURL(this._groupId + '', true)},audit-log`,
+                  detailType: Gerrit.Nav.GroupDetailView.LOG,
+                  view: Gerrit.Nav.View.GROUP,
+                  url: Gerrit.Nav.getUrlForGroupLog(this._groupId),
                 }
             );
           }
@@ -187,21 +186,36 @@
     },
 
     _paramsChanged(params) {
-      this.set('_showGroup', params.adminView === 'gr-group');
-      this.set('_showGroupAuditLog', params.adminView === 'gr-group-audit-log');
-      this.set('_showGroupList', params.adminView === 'gr-admin-group-list');
-      this.set('_showGroupMembers', params.adminView === 'gr-group-members');
-      this.set('_showProjectCommands',
-          params.adminView === 'gr-project-commands');
-      this.set('_showProjectMain', params.adminView === 'gr-project');
-      this.set('_showProjectList',
-          params.adminView === 'gr-project-list');
-      this.set('_showProjectDetailList',
-          params.adminView === 'gr-project-detail-list');
-      this.set('_showPluginList', params.adminView === 'gr-plugin-list');
-      this.set('_showProjectAccess', params.adminView === 'gr-project-access');
-      if (params.project !== this._projectName) {
-        this._projectName = params.project || '';
+      const isGroupView = params.view === Gerrit.Nav.View.GROUP;
+      const isRepoView = params.view === Gerrit.Nav.View.REPO;
+      const isAdminView = params.view === Gerrit.Nav.View.ADMIN;
+
+      this.set('_showGroup', isGroupView && !params.detail);
+      this.set('_showGroupAuditLog', isGroupView &&
+          params.detail === Gerrit.Nav.GroupDetailView.LOG);
+      this.set('_showGroupMembers', isGroupView &&
+          params.detail === Gerrit.Nav.GroupDetailView.MEMBERS);
+
+      this.set('_showGroupList', isAdminView &&
+          params.adminView === 'gr-admin-group-list');
+
+      this.set('_showRepoAccess', isRepoView &&
+          params.detail === Gerrit.Nav.RepoDetailView.ACCESS);
+      this.set('_showRepoCommands', isRepoView &&
+          params.detail === Gerrit.Nav.RepoDetailView.COMMANDS);
+      this.set('_showRepoDetailList', isRepoView &&
+          (params.detail === Gerrit.Nav.RepoDetailView.BRANCHES ||
+           params.detail === Gerrit.Nav.RepoDetailView.TAGS));
+      this.set('_showRepoMain', isRepoView && !params.detail);
+
+      this.set('_showRepoList', isAdminView &&
+          params.adminView === 'gr-repo-list');
+
+      this.set('_showPluginList', isAdminView &&
+          params.adminView === 'gr-plugin-list');
+
+      if (params.repo !== this._repoName) {
+        this._repoName = params.repo || '';
         // Reloads the admin menu.
         this.reload();
       }
@@ -226,7 +240,7 @@
 
     _computeLinkURL(link) {
       if (!link || typeof link.url === 'undefined') { return ''; }
-      if (link.target) {
+      if (link.target || !link.noBaseUrl) {
         return link.url;
       }
       return this._computeRelativeURL(link.url);
@@ -238,6 +252,23 @@
      * @param {string=} opt_detailType
      */
     _computeSelectedClass(itemView, params, opt_detailType) {
+      // Group params are structured differently from admin params. Compute
+      // selected differently for groups.
+      // TODO(wyatta): Simplify this when all routes work like group params.
+      if (params.view === Gerrit.Nav.View.GROUP &&
+          itemView === Gerrit.Nav.View.GROUP) {
+        if (!params.detail && !opt_detailType) { return 'selected'; }
+        if (params.detail === opt_detailType) { return 'selected'; }
+        return '';
+      }
+
+      if (params.view === Gerrit.Nav.View.REPO &&
+          itemView === Gerrit.Nav.View.REPO) {
+        if (!params.detail && !opt_detailType) { return 'selected'; }
+        if (params.detail === opt_detailType) { return 'selected'; }
+        return '';
+      }
+
       if (params.detailType && params.detailType !== opt_detailType) {
         return '';
       }
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
index 117940a3..bd79872 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
@@ -59,8 +59,14 @@
 
     test('link URLs', () => {
       assert.equal(
-          element._computeLinkURL({url: '/test'}),
+          element._computeLinkURL({url: '/test', noBaseUrl: true}),
           '//' + window.location.host + '/test');
+
+      sandbox.stub(element, 'getBaseUrl').returns('/foo');
+      assert.equal(
+          element._computeLinkURL({url: '/test', noBaseUrl: true}),
+          '//' + window.location.host + '/foo/test');
+      assert.equal(element._computeLinkURL({url: '/test'}), '/test');
       assert.equal(
           element._computeLinkURL({url: '/test', target: '_blank'}),
           '/test');
@@ -68,21 +74,22 @@
 
     test('current page gets selected and is displayed', () => {
       element._filteredLinks = [{
-        name: 'Projects',
-        url: '/admin/projects',
-        view: 'gr-project-list',
+        name: 'Repositories',
+        url: '/admin/repos',
+        view: 'gr-repo-list',
         children: [],
       }];
 
       element.params = {
-        adminView: 'gr-project-list',
+        view: 'admin',
+        adminView: 'gr-repo-list',
       };
 
       flushAsynchronousOperations();
       assert.equal(Polymer.dom(element.root).querySelectorAll(
           '.selected').length, 1);
-      assert.ok(element.$$('gr-project-list'));
-      assert.isNotOk(element.$$('gr-admin-create-project'));
+      assert.ok(element.$$('gr-repo-list'));
+      assert.isNotOk(element.$$('gr-admin-create-repo'));
     });
 
     test('_filteredLinks admin', done => {
@@ -96,7 +103,7 @@
       element._loadAccountCapabilities().then(() => {
         assert.equal(element._filteredLinks.length, 3);
 
-        // Projects
+        // Repos
         assert.equal(element._filteredLinks[0].children.length, 0);
         assert.isNotOk(element._filteredLinks[0].subsection);
 
@@ -116,7 +123,7 @@
       element._loadAccountCapabilities().then(() => {
         assert.equal(element._filteredLinks.length, 2);
 
-        // Projects
+        // Repos
         assert.equal(element._filteredLinks[0].children.length, 0);
         assert.isNotOk(element._filteredLinks[0].subsection);
 
@@ -130,15 +137,15 @@
       element.reload().then(() => {
         assert.equal(element._filteredLinks.length, 1);
 
-        // Projects
+        // Repos
         assert.equal(element._filteredLinks[0].children.length, 0);
         assert.isNotOk(element._filteredLinks[0].subsection);
         done();
       });
     });
 
-    test('Project shows up in nav', done => {
-      element._projectName = 'Test Project';
+    test('Repo shows up in nav', done => {
+      element._repoName = 'Test Repo';
       sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
         return Promise.resolve({
           createGroup: true,
@@ -149,9 +156,9 @@
       element._loadAccountCapabilities().then(() => {
         assert.equal(element._filteredLinks.length, 3);
 
-        // Projects
+        // Repos
         assert.equal(element._filteredLinks[0].children.length, 0);
-        assert.equal(element._filteredLinks[0].subsection.name, 'Test Project');
+        assert.equal(element._filteredLinks[0].subsection.name, 'Test Repo');
 
         // Groups
         assert.equal(element._filteredLinks[1].children.length, 0);
@@ -162,7 +169,7 @@
       });
     });
 
-    test('Nav is reloaded when project changes', () => {
+    test('Nav is reloaded when repo changes', () => {
       sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
         return Promise.resolve({
           createGroup: true,
@@ -174,10 +181,10 @@
         return Promise.resolve({_id: 1});
       });
       sandbox.stub(element, 'reload');
-      element.params = {project: 'Test Project', adminView: 'gr-project'};
+      element.params = {repo: 'Test Repo', adminView: 'gr-repo'};
       assert.equal(element.reload.callCount, 1);
-      element.params = {project: 'Test Project 2',
-        adminView: 'gr-project'};
+      element.params = {repo: 'Test Repo 2',
+        adminView: 'gr-repo'};
       assert.equal(element.reload.callCount, 2);
     });
 
@@ -206,10 +213,147 @@
         assert.isTrue(element.reload.called);
         done();
       });
-      element.params = {group: 1, adminView: 'gr-group'};
+      element.params = {group: 1, view: Gerrit.Nav.View.GROUP};
       element._groupName = 'oldName';
       flushAsynchronousOperations();
       element.$$('gr-group').fire('name-changed', {name: newName});
     });
+
+    suite('_computeSelectedClass', () => {
+      setup(() => {
+        sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
+          return Promise.resolve({
+            createGroup: true,
+            createProject: true,
+            viewPlugins: true,
+          });
+        });
+        sandbox.stub(element.$.restAPI, 'getAccount', () => {
+          return Promise.resolve({_id: 1});
+        });
+
+        return element.reload();
+      });
+
+      suite('repos', () => {
+        setup(() => {
+          stub('gr-repo-access', {
+            _repoChanged: () => {},
+          });
+        });
+
+        test('repo list', done => {
+          element.params = {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-repo-list',
+            openCreateModal: false,
+          };
+          flush(() => {
+            const selected = element.$$('gr-page-nav .selected');
+            assert.isOk(selected);
+            assert.equal(selected.textContent.trim(), 'Repositories');
+            done();
+          });
+        });
+
+        test('repo', done => {
+          element.params = {
+            view: Gerrit.Nav.View.REPO,
+            repoName: 'foo',
+          };
+          element._repoName = 'foo';
+          element.reload().then(() => {
+            flush(() => {
+              const selected = element.$$('gr-page-nav .selected');
+              assert.isOk(selected);
+              assert.equal(selected.textContent.trim(), 'foo');
+              done();
+            });
+          });
+        });
+
+        test('repo access', done => {
+          element.params = {
+            view: Gerrit.Nav.View.REPO,
+            detail: Gerrit.Nav.RepoDetailView.ACCESS,
+            repoName: 'foo',
+          };
+          element._repoName = 'foo';
+          element.reload().then(() => {
+            flush(() => {
+              const selected = element.$$('gr-page-nav .selected');
+              assert.isOk(selected);
+              assert.equal(selected.textContent.trim(), 'Access');
+              done();
+            });
+          });
+        });
+      });
+
+      suite('groups', () => {
+        setup(() => {
+          stub('gr-group', {
+            _loadGroup: () => Promise.resolve({}),
+          });
+          stub('gr-group-members', {
+            _loadGroupDetails: () => {},
+          });
+
+          sandbox.stub(element.$.restAPI, 'getGroupConfig')
+              .returns(Promise.resolve({
+                name: 'foo',
+              }));
+          sandbox.stub(element.$.restAPI, 'getIsGroupOwner')
+              .returns(Promise.resolve(true));
+        });
+
+        test('group list', done => {
+          element.params = {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-admin-group-list',
+            openCreateModal: false,
+          };
+          flush(() => {
+            const selected = element.$$('gr-page-nav .selected');
+            assert.isOk(selected);
+            assert.equal(selected.textContent.trim(), 'Groups');
+            done();
+          });
+        });
+
+        test('group', done => {
+          element.params = {
+            view: Gerrit.Nav.View.GROUP,
+            groupId: 1234,
+          };
+          element._groupName = 'foo';
+          element.reload().then(() => {
+            flush(() => {
+              const selected = element.$$('gr-page-nav .selected');
+              assert.isOk(selected);
+              assert.equal(selected.textContent.trim(), 'foo');
+              done();
+            });
+          });
+        });
+
+        test('group members', done => {
+          element.params = {
+            view: Gerrit.Nav.View.GROUP,
+            detail: Gerrit.Nav.GroupDetailView.MEMBERS,
+            groupId: 1234,
+          };
+          element._groupName = 'foo';
+          element.reload().then(() => {
+            flush(() => {
+              const selected = element.$$('gr-page-nav .selected');
+              assert.isOk(selected);
+              assert.equal(selected.textContent.trim(), 'Members');
+              done();
+            });
+          });
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
index e132100..da20181 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
@@ -30,8 +30,8 @@
         confirm-label="Delete [[_computeItemName(itemType)]]"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header">[[_computeItemName(itemType)]] Deletion</div>
-      <div class="main">
+      <div class="header" slot="header">[[_computeItemName(itemType)]] Deletion</div>
+      <div class="main" slot="main">
         <label for="branchInput">
           Do you really want to delete the following [[_computeItemName(itemType)]]?
         </label>
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
index ddc98e9..fd30a0b 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
@@ -16,6 +16,7 @@
 
   const DETAIL_TYPES = {
     BRANCHES: 'branches',
+    ID: 'id',
     TAGS: 'tags',
   };
 
@@ -56,6 +57,8 @@
         return 'Branch';
       } else if (detailType === DETAIL_TYPES.TAGS) {
         return 'Tag';
+      } else if (detailType === DETAIL_TYPES.ID) {
+        return 'ID';
       }
     },
   });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
index 914f9e2..4792c30 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
@@ -69,6 +69,7 @@
           <input
               is="iron-input"
               id="tagNameInput"
+              maxlength="1024"
               bind-value="{{topic}}">
         </section>
         <section>
@@ -90,7 +91,7 @@
             <input
                 type="checkbox"
                 id="privateChangeCheckBox"
-                checked$="[[_projectConfig.private_by_default.inherited_value]]">
+                checked$="[[_repoConfig.private_by_default.inherited_value]]">
           </section>
           <section>
             <label for="wipChangeCheckBox">WIP Change</label>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
index 7a9387b..eace34a 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
@@ -21,16 +21,16 @@
     is: 'gr-create-change-dialog',
 
     properties: {
-      projectName: String,
+      repoName: String,
       branch: String,
       /** @type {?} */
-      _projectConfig: Object,
+      _repoConfig: Object,
       subject: String,
       topic: String,
       _query: {
         type: Function,
         value() {
-          return this._getProjectBranchesSuggestions.bind(this);
+          return this._getRepoBranchesSuggestions.bind(this);
         },
       },
       canCreate: {
@@ -46,8 +46,8 @@
     ],
 
     attached() {
-      this.$.restAPI.getProjectConfig(this.projectName).then(config => {
-        this._projectConfig = config;
+      this.$.restAPI.getProjectConfig(this.repoName).then(config => {
+        this._repoConfig = config;
       });
     },
 
@@ -62,7 +62,7 @@
     handleCreateChange() {
       const isPrivate = this.$.privateChangeCheckBox.checked;
       const isWip = this.$.wipChangeCheckBox.checked;
-      return this.$.restAPI.createChange(this.projectName, this.branch,
+      return this.$.restAPI.createChange(this.repoName, this.branch,
           this.subject, this.topic, isPrivate, isWip)
           .then(changeCreated => {
             if (!changeCreated) {
@@ -72,12 +72,12 @@
           });
     },
 
-    _getProjectBranchesSuggestions(input) {
+    _getRepoBranchesSuggestions(input) {
       if (input.startsWith(REF_PREFIX)) {
         input = input.substring(REF_PREFIX.length);
       }
-      return this.$.restAPI.getProjectBranches(
-          input, this.projectName, SUGGESTIONS_LIMIT).then(response => {
+      return this.$.restAPI.getRepoBranches(
+          input, this.repoName, SUGGESTIONS_LIMIT).then(response => {
             const branches = [];
             let branch;
             for (const key in response) {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
index 4f5dd46..afe3801 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
@@ -40,7 +40,7 @@
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getLoggedIn() { return Promise.resolve(true); },
-        getProjectBranches(input) {
+        getRepoBranches(input) {
           if (input.startsWith('test')) {
             return Promise.resolve([
               {
@@ -55,8 +55,8 @@
         },
       });
       element = fixture('basic');
-      element.projectName = 'test-project';
-      element._projectConfig = {
+      element.repoName = 'test-repo';
+      element._repoConfig = {
         private_by_default: {},
       };
     });
@@ -66,7 +66,7 @@
     });
 
     test('new change created with private', () => {
-      element._projectConfig = {
+      element._repoConfig = {
         private_by_default: {
           inherited_value: true,
         },
@@ -77,7 +77,6 @@
         topic: 'test-topic',
         subject: 'first change created with polygerrit ui',
         work_in_progress: false,
-        project: element.projectName,
       };
 
       const saveStub = sandbox.stub(element.$.restAPI,
@@ -85,7 +84,6 @@
             return Promise.resolve({});
           });
 
-      element.project = element.projectName;
       element.branch = 'test-branch';
       element.topic = 'test-topic';
       element.subject = 'first change created with polygerrit ui';
@@ -105,7 +103,6 @@
         branch: 'test-branch',
         topic: 'test-topic',
         subject: 'first change created with polygerrit ui',
-        project: element.projectName,
       };
 
       const saveStub = sandbox.stub(element.$.restAPI,
@@ -113,7 +110,6 @@
             return Promise.resolve({});
           });
 
-      element.project = element.projectName;
       element.branch = 'test-branch';
       element.topic = 'test-topic';
       element.subject = 'first change created with polygerrit ui';
@@ -129,15 +125,15 @@
       });
     });
 
-    test('_getProjectBranchesSuggestions empty', done => {
-      element._getProjectBranchesSuggestions('nonexistent').then(branches => {
+    test('_getRepoBranchesSuggestions empty', done => {
+      element._getRepoBranchesSuggestions('nonexistent').then(branches => {
         assert.equal(branches.length, 0);
         done();
       });
     });
 
-    test('_getProjectBranchesSuggestions non-empty', done => {
-      element._getProjectBranchesSuggestions('test-branch').then(branches => {
+    test('_getRepoBranchesSuggestions non-empty', done => {
+      element._getRepoBranchesSuggestions('test-branch').then(branches => {
         assert.equal(branches.length, 1);
         assert.equal(branches[0].name, 'test-branch');
         done();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
index 4d552f4..a74a7d9 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
@@ -24,7 +24,7 @@
 
     properties: {
       detailType: String,
-      projectName: String,
+      repoName: String,
       hasNewItemName: {
         type: Boolean,
         notify: true,
@@ -51,18 +51,18 @@
 
     _computeItemUrl(project) {
       if (this.itemDetail === DETAIL_TYPES.branches) {
-        return this.getBaseUrl() + '/admin/projects/' +
-            this.encodeURL(this.projectName, true) + ',branches';
+        return this.getBaseUrl() + '/admin/repos/' +
+            this.encodeURL(this.repoName, true) + ',branches';
       } else if (this.itemDetail === DETAIL_TYPES.tags) {
-        return this.getBaseUrl() + '/admin/projects/' +
-            this.encodeURL(this.projectName, true) + ',tags';
+        return this.getBaseUrl() + '/admin/repos/' +
+            this.encodeURL(this.repoName, true) + ',tags';
       }
     },
 
     handleCreateItem() {
       const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
       if (this.itemDetail === DETAIL_TYPES.branches) {
-        return this.$.restAPI.createProjectBranch(this.projectName,
+        return this.$.restAPI.createRepoBranch(this.repoName,
             this._itemName, {revision: USE_HEAD})
             .then(itemRegistered => {
               if (itemRegistered.status === 201) {
@@ -70,7 +70,7 @@
               }
             });
       } else if (this.itemDetail === DETAIL_TYPES.tags) {
-        return this.$.restAPI.createProjectTag(this.projectName,
+        return this.$.restAPI.createRepoTag(this.repoName,
             this._itemName,
             {revision: USE_HEAD, message: this._itemAnnotation || null})
             .then(itemRegistered => {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
index dd74574..c31b5b0 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
@@ -49,7 +49,7 @@
     });
 
     test('branch created', () => {
-      sandbox.stub(element.$.restAPI, 'createProjectBranch', () => {
+      sandbox.stub(element.$.restAPI, 'createRepoBranch', () => {
         return Promise.resolve({});
       });
 
@@ -69,7 +69,7 @@
     });
 
     test('tag created', () => {
-      sandbox.stub(element.$.restAPI, 'createProjectTag', () => {
+      sandbox.stub(element.$.restAPI, 'createRepoTag', () => {
         return Promise.resolve({});
       });
 
@@ -89,7 +89,7 @@
     });
 
     test('tag created with annotations', () => {
-      sandbox.stub(element.$.restAPI, 'createProjectTag', () => {
+      sandbox.stub(element.$.restAPI, 'createRepoTag', () => {
         return Promise.resolve({});
       });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog.html
deleted file mode 100644
index 5381b0e..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog.html
+++ /dev/null
@@ -1,104 +0,0 @@
-<!--
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-<dom-module id="gr-create-project-dialog">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-      :host {
-        display: inline-block;
-      }
-      input {
-        width: 20em;
-      }
-      gr-autocomplete {
-        border: none;
-        --gr-autocomplete: {
-          border: 1px solid #d1d2d3;
-          border-radius: 2px;
-          font-size: 1em;
-          height: 2em;
-          padding: 0 .15em;
-          width: 20em;
-        }
-      }
-    </style>
-
-    <div class="gr-form-styles">
-      <div id="form">
-        <section>
-          <span class="title">Project name</span>
-          <input is="iron-input"
-              id="projectNameInput"
-              autocomplete="on"
-              bind-value="{{_projectConfig.name}}">
-        </section>
-        <section>
-          <span class="title">Rights inherit from</span>
-          <span class="value">
-            <gr-autocomplete
-                id="rightsInheritFromInput"
-                text="{{_projectConfig.parent}}"
-                query="[[_query]]"
-                placeholder="Optional, defaults to 'All-Projects'">
-            </gr-autocomplete>
-          </span>
-        </section>
-        <section>
-          <span class="title">Create initial empty commit</span>
-          <span class="value">
-            <gr-select
-                id="initalCommit"
-                bind-value="{{_projectConfig.create_empty_commit}}">
-              <select>
-                <option value="false">False</option>
-                <option value="true">True</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <span class="title">Only serve as parent for other projects</span>
-          <span class="value">
-            <gr-select
-                id="parentProject"
-                is="gr-select"
-                bind-value="{{_projectConfig.permissions_only}}">
-              <select>
-                <option value="false">False</option>
-                <option value="true">True</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-      </div>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-create-project-dialog.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog.js
deleted file mode 100644
index 3831534..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog.js
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-(function() {
-  'use strict';
-
-  Polymer({
-    is: 'gr-create-project-dialog',
-
-    properties: {
-      params: Object,
-      hasNewProjectName: {
-        type: Boolean,
-        notify: true,
-        value: false,
-      },
-
-      /** @type {?} */
-      _projectConfig: {
-        type: Object,
-        value: () => {
-          // Set default values for dropdowns.
-          return {
-            create_empty_commit: false,
-            permissions_only: false,
-          };
-        },
-      },
-      _projectCreated: {
-        type: Boolean,
-        value: false,
-      },
-
-      _query: {
-        type: Function,
-        value() {
-          return this._getProjectSuggestions.bind(this);
-        },
-      },
-    },
-
-    observers: [
-      '_updateProjectName(_projectConfig.name)',
-    ],
-
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
-    _computeProjectUrl(projectName) {
-      return this.getBaseUrl() + '/admin/projects/' +
-          this.encodeURL(projectName, true);
-    },
-
-    _updateProjectName(name) {
-      this.hasNewProjectName = !!name;
-    },
-
-    handleCreateProject() {
-      return this.$.restAPI.createProject(this._projectConfig)
-          .then(projectRegistered => {
-            if (projectRegistered.status === 201) {
-              this._projectCreated = true;
-              page.show(this._computeProjectUrl(this._projectConfig.name));
-            }
-          });
-    },
-
-    _getProjectSuggestions(input) {
-      return this.$.restAPI.getSuggestedProjects(input)
-          .then(response => {
-            const projects = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              projects.push({
-                name: key,
-                value: response[key],
-              });
-            }
-            return projects;
-          });
-    },
-  });
-})();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog_test.html
deleted file mode 100644
index e9a644f..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog_test.html
+++ /dev/null
@@ -1,95 +0,0 @@
-<!DOCTYPE html>
-<!--
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-create-project-dialog</title>
-
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-create-project-dialog.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-create-project-dialog></gr-create-project-dialog>
-  </template>
-</test-fixture>
-
-<script>
-  suite('gr-create-project-dialog tests', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-      });
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('default values are populated', () => {
-      assert.isFalse(element.$.initalCommit.bindValue);
-      assert.isFalse(element.$.parentProject.bindValue);
-    });
-
-    test('project created', done => {
-      const configInputObj = {
-        name: 'test-project',
-        create_empty_commit: true,
-        parent: 'All-Project',
-        permissions_only: false,
-      };
-
-      const saveStub = sandbox.stub(element.$.restAPI,
-          'createProject', () => {
-            return Promise.resolve({});
-          });
-
-      assert.isFalse(element.hasNewProjectName);
-
-      element._projectConfig = {
-        name: 'test-project',
-        create_empty_commit: true,
-        parent: 'All-Project',
-        permissions_only: false,
-      };
-
-      element.$.projectNameInput.bindValue = configInputObj.name;
-      element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
-      element.$.initalCommit.bindValue =
-          configInputObj.create_empty_commit;
-      element.$.parentProject.bindValue =
-          configInputObj.permissions_only;
-
-      assert.isTrue(element.hasNewProjectName);
-
-      assert.deepEqual(element._projectConfig, configInputObj);
-
-      element.handleCreateProject().then(() => {
-        assert.isTrue(saveStub.lastCall.calledWithExactly(configInputObj));
-        done();
-      });
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
new file mode 100644
index 0000000..04bfd8e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
@@ -0,0 +1,104 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+
+<dom-module id="gr-create-repo-dialog">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
+      :host {
+        display: inline-block;
+      }
+      input {
+        width: 20em;
+      }
+      gr-autocomplete {
+        border: none;
+        --gr-autocomplete: {
+          border: 1px solid #d1d2d3;
+          border-radius: 2px;
+          font-size: 1em;
+          height: 2em;
+          padding: 0 .15em;
+          width: 20em;
+        }
+      }
+    </style>
+
+    <div class="gr-form-styles">
+      <div id="form">
+        <section>
+          <span class="title">Repositories name</span>
+          <input is="iron-input"
+              id="repoNameInput"
+              autocomplete="on"
+              bind-value="{{_repoConfig.name}}">
+        </section>
+        <section>
+          <span class="title">Rights inherit from</span>
+          <span class="value">
+            <gr-autocomplete
+                id="rightsInheritFromInput"
+                text="{{_repoConfig.parent}}"
+                query="[[_query]]"
+                placeholder="Optional, defaults to 'All-Projects'">
+            </gr-autocomplete>
+          </span>
+        </section>
+        <section>
+          <span class="title">Create initial empty commit</span>
+          <span class="value">
+            <gr-select
+                id="initalCommit"
+                bind-value="{{_repoConfig.create_empty_commit}}">
+              <select>
+                <option value="false">False</option>
+                <option value="true">True</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section>
+          <span class="title">Only serve as parent for other repositories</span>
+          <span class="value">
+            <gr-select
+                id="parentRepo"
+                is="gr-select"
+                bind-value="{{_repoConfig.permissions_only}}">
+              <select>
+                <option value="false">False</option>
+                <option value="true">True</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+      </div>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-create-repo-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
new file mode 100644
index 0000000..7652710
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
@@ -0,0 +1,95 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-create-repo-dialog',
+
+    properties: {
+      params: Object,
+      hasNewRepoName: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+
+      /** @type {?} */
+      _repoConfig: {
+        type: Object,
+        value: () => {
+          // Set default values for dropdowns.
+          return {
+            create_empty_commit: false,
+            permissions_only: false,
+          };
+        },
+      },
+      _repoCreated: {
+        type: Boolean,
+        value: false,
+      },
+
+      _query: {
+        type: Function,
+        value() {
+          return this._getRepoSuggestions.bind(this);
+        },
+      },
+    },
+
+    observers: [
+      '_updateRepoName(_repoConfig.name)',
+    ],
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    _computeRepoUrl(repoName) {
+      return this.getBaseUrl() + '/admin/repos/' +
+          this.encodeURL(repoName, true);
+    },
+
+    _updateRepoName(name) {
+      this.hasNewRepoName = !!name;
+    },
+
+    handleCreateRepo() {
+      return this.$.restAPI.createRepo(this._repoConfig)
+          .then(repoRegistered => {
+            if (repoRegistered.status === 201) {
+              this._repoCreated = true;
+              page.show(this._computeRepoUrl(this._repoConfig.name));
+            }
+          });
+    },
+
+    _getRepoSuggestions(input) {
+      return this.$.restAPI.getSuggestedProjects(input)
+          .then(response => {
+            const repos = [];
+            for (const key in response) {
+              if (!response.hasOwnProperty(key)) { continue; }
+              repos.push({
+                name: key,
+                value: response[key],
+              });
+            }
+            return repos;
+          });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
new file mode 100644
index 0000000..bdb5bcb
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
@@ -0,0 +1,95 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-create-repo-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-create-repo-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-create-repo-dialog></gr-create-repo-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-create-repo-dialog tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+      });
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('default values are populated', () => {
+      assert.isFalse(element.$.initalCommit.bindValue);
+      assert.isFalse(element.$.parentRepo.bindValue);
+    });
+
+    test('repo created', done => {
+      const configInputObj = {
+        name: 'test-repo',
+        create_empty_commit: true,
+        parent: 'All-Project',
+        permissions_only: false,
+      };
+
+      const saveStub = sandbox.stub(element.$.restAPI,
+          'createRepo', () => {
+            return Promise.resolve({});
+          });
+
+      assert.isFalse(element.hasNewRepoName);
+
+      element._repoConfig = {
+        name: 'test-repo',
+        create_empty_commit: true,
+        parent: 'All-Project',
+        permissions_only: false,
+      };
+
+      element.$.repoNameInput.bindValue = configInputObj.name;
+      element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
+      element.$.initalCommit.bindValue =
+          configInputObj.create_empty_commit;
+      element.$.parentRepo.bindValue =
+          configInputObj.permissions_only;
+
+      assert.isTrue(element.hasNewRepoName);
+
+      assert.deepEqual(element._repoConfig, configInputObj);
+
+      element.handleCreateRepo().then(() => {
+        assert.isTrue(saveStub.lastCall.calledWithExactly(configInputObj));
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
index 9e08381..e7e5f17 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
@@ -85,99 +85,92 @@
       <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
         <h1 id="Title">[[_groupName]]</h1>
         <div id="form">
+          <h3 id="members">Members</h3>
           <fieldset>
-            <h3 id="members">Members</h3>
-            <fieldset>
-              <span class="value">
-                <gr-autocomplete
-                    id="groupMemberSearchInput"
-                    text="{{_groupMemberSearch}}"
-                    query="[[_queryMembers]]"
-                    placeholder="Name Or Email">
-                </gr-autocomplete>
-              </span>
-              <gr-button
-                  id="saveGroupMember"
-                  on-tap="_handleSavingGroupMember"
-                  disabled="[[!_groupMemberSearch]]">
-                Add
-              </gr-button>
-              <div class="gr-form-styles">
-                <table id="groupMembers" class="gr-form-styles">
-                  <tr class="headerRow">
-                    <th class="nameHeader">Name</th>
-                    <th class="emailAddressHeader">Email Address</th>
-                    <th class="deleteHeader">Delete Member</th>
+            <span class="value">
+              <gr-autocomplete
+                  id="groupMemberSearchInput"
+                  text="{{_groupMemberSearch}}"
+                  query="[[_queryMembers]]"
+                  placeholder="Name Or Email">
+              </gr-autocomplete>
+            </span>
+            <gr-button
+                id="saveGroupMember"
+                on-tap="_handleSavingGroupMember"
+                disabled="[[!_groupMemberSearch]]">
+              Add
+            </gr-button>
+            <table id="groupMembers">
+              <tr class="headerRow">
+                <th class="nameHeader">Name</th>
+                <th class="emailAddressHeader">Email Address</th>
+                <th class="deleteHeader">Delete Member</th>
+              </tr>
+              <tbody>
+                <template is="dom-repeat" items="[[_groupMembers]]">
+                  <tr>
+                    <td class="nameColumn">
+                      <gr-account-link account="[[item]]"></gr-account-link>
+                    </td>
+                    <td>[[item.email]]</td>
+                    <td class="deleteColumn">
+                      <gr-button
+                          class="deleteButton"
+                          on-tap="_handleDeleteMember">
+                        Delete
+                      </gr-button>
+                    </td>
                   </tr>
-                  <tbody>
-                    <template is="dom-repeat" items="[[_groupMembers]]">
-                      <tr>
-                        <td class="nameColumn">
-                          <gr-account-link account="[[item]]"></gr-account-link>
-                        </td>
-                        <td>[[item.email]]</td>
-                        <td class="deleteColumn">
-                          <gr-button
-                              class="deleteButton"
-                              on-tap="_handleDeleteMember">
-                            Delete
-                          </gr-button>
-                        </td>
-                      </tr>
-                    </template>
-                  </tbody>
-                </table>
-              </div>
-            </fieldset>
+                </template>
+              </tbody>
+            </table>
           </fieldset>
+          <h3 id="includedGroups">Included Groups</h3>
           <fieldset>
-            <h3 id="includedGroups">Included Groups</h3>
-            <fieldset>
-              <span class="value">
-                <gr-autocomplete
-                    id="includedGroupSearchInput"
-                    text="{{_includedGroupSearch}}"
-                    query="[[_queryIncludedGroup]]"
-                    placeholder="Group Name">
-                </gr-autocomplete>
-              </span>
-              <gr-button
-                  id="saveIncludedGroups"
-                  on-tap="_handleSavingIncludedGroups"
-                  disabled="[[!_includedGroupSearch]]">
-                Add
-              </gr-button>
-              <div class="gr-form-styles">
-                <table id="includedGroups" class="gr-form-styles">
-                  <tr class="headerRow">
-                    <th class="groupNameHeader">Group Name</th>
-                    <th class="descriptionHeader">Description</th>
-                    <th class="deleteIncludedHeader">
-                      Delete Included Group
-                    </th>
+            <span class="value">
+              <gr-autocomplete
+                  id="includedGroupSearchInput"
+                  text="{{_includedGroupSearch}}"
+                  query="[[_queryIncludedGroup]]"
+                  placeholder="Group Name">
+              </gr-autocomplete>
+            </span>
+            <gr-button
+                id="saveIncludedGroups"
+                on-tap="_handleSavingIncludedGroups"
+                disabled="[[!_includedGroupSearch]]">
+              Add
+            </gr-button>
+            <table id="includedGroups">
+              <tr class="headerRow">
+                <th class="groupNameHeader">Group Name</th>
+                <th class="descriptionHeader">Description</th>
+                <th class="deleteIncludedHeader">
+                  Delete Group
+                </th>
+              </tr>
+              <tbody>
+                <template is="dom-repeat" items="[[_includedGroups]]">
+                  <tr>
+                    <td class="nameColumn">
+                      <a href$="[[_computeGroupUrl(item.url)]]"
+                          rel="noopener">
+                        [[item.name]]
+                      </a>
+                    </td>
+                    <td>[[item.description]]</td>
+                    <td class="deleteColumn">
+                      <gr-button
+                          class="deleteIncludedGroupButton"
+                          on-tap="_handleDeleteIncludedGroup">
+                        Delete
+                      </gr-button>
+                    </td>
                   </tr>
-                  <tbody>
-                    <template is="dom-repeat" items="[[_includedGroups]]">
-                      <tr>
-                        <td class="nameColumn">
-                          <a href$="[[_groupUrl(item.group_id)]]">
-                            [[item.name]]
-                          </a>
-                        </td>
-                        <td>[[item.description]]</td>
-                        <td class="deleteColumn">
-                          <gr-button
-                              class="deleteIncludedGroupButton"
-                              on-tap="_handleDeleteIncludedGroup">
-                            Delete
-                          </gr-button>
-                        </td>
-                      </tr>
-                    </template>
-                  </tbody>
-                </table>
-              </div>
-            </fieldset>
+                </template>
+              </tbody>
+            </table>
           </fieldset>
         </div>
       </div>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
index 81bb902..0105a0c 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
@@ -16,6 +16,8 @@
 
   const SUGGESTIONS_LIMIT = 15;
 
+  const URL_REGEX = '^(?:[a-z]+:)?//';
+
   Polymer({
     is: 'gr-group-members',
 
@@ -109,8 +111,17 @@
       return this._loading || this._loading === undefined;
     },
 
-    _groupUrl(item) {
-      return this.getBaseUrl() + '/admin/groups/' + this.encodeURL(item, true);
+    _computeGroupUrl(url) {
+      const r = new RegExp(URL_REGEX, 'i');
+      if (r.test(url)) {
+        return url;
+      }
+
+      // For GWT compatibility
+      if (url.startsWith('#')) {
+        return this.getBaseUrl() + url.slice(1);
+      }
+      return this.getBaseUrl() + url;
     },
 
     _handleSavingGroupMember() {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
index ef89e37..04b8351 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
@@ -37,6 +37,7 @@
     let sandbox;
     let groups;
     let groupMembers;
+    let includedGroups;
 
     setup(() => {
       sandbox = sinon.sandbox.create();
@@ -67,6 +68,26 @@
         },
       ];
 
+      includedGroups = [{
+        url: 'https://group/url',
+        options: {},
+        id: 'testId',
+        name: 'testName',
+      },
+      {
+        url: '/group/url',
+        options: {},
+        id: 'testId2',
+        name: 'testName2',
+      },
+      {
+        url: '#/group/url',
+        options: {},
+        id: 'testId3',
+        name: 'testName3',
+      },
+      ];
+
       stub('gr-rest-api-interface', {
         getSuggestedAccounts(input) {
           if (input.startsWith('test')) {
@@ -94,6 +115,9 @@
           }
         },
         getLoggedIn() { return Promise.resolve(true); },
+        getConfig() {
+          return Promise.resolve();
+        },
         getGroupConfig() {
           return Promise.resolve(groups);
         },
@@ -103,15 +127,35 @@
         getIsGroupOwner() {
           return Promise.resolve(true);
         },
+        getIncludedGroup() {
+          return Promise.resolve(includedGroups);
+        },
+        getAccountCapabilities() {
+          return Promise.resolve();
+        },
       });
-
       element = fixture('basic');
+      sandbox.stub(element, 'getBaseUrl').returns('https://test/site');
+      element.groupId = 1;
+      return element._loadGroupDetails();
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
+    test('_includedGroups', () => {
+      assert.equal(element._includedGroups.length, 3);
+      assert.equal(Polymer.dom(element.root)
+          .querySelectorAll('.nameColumn a')[0].href, includedGroups[0].url);
+      assert.equal(Polymer.dom(element.root)
+          .querySelectorAll('.nameColumn a')[1].href,
+          'https://test/site/group/url');
+      assert.equal(Polymer.dom(element.root)
+          .querySelectorAll('.nameColumn a')[2].href,
+          'https://test/site/group/url');
+    });
+
     test('save correctly', () => {
       element._groupOwner = true;
 
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
index 31171f7..4013b37 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
@@ -16,6 +16,7 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
+<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-menu-page-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -37,10 +38,6 @@
         justify-content: space-between;
         margin: .3em .7em;
       }
-      #deletedContainer {
-        border: 1px solid #d1d2d3;
-        padding: .7em;
-      }
       .rules {
         background: #fafafa;
         border: 1px solid #d1d2d3;
@@ -56,8 +53,13 @@
       #removeBtn {
         display: none;
       }
+      .right {
+        display: flex;
+        align-items: center;
+      }
       .editing #removeBtn {
         display: block;
+        margin-left: 1.5em;
       }
       .editing #addRule {
         display: block;
@@ -67,7 +69,13 @@
       .deleted #mainContainer {
         display: none;
       }
-      .deleted #deletedContainer,
+      .deleted #deletedContainer {
+        align-items: baseline;
+        border: 1px solid #d1d2d3;
+        display: flex;
+        justify-content: space-between;
+        padding: .7em;
+      }
       #mainContainer {
         display: block;
       }
@@ -80,9 +88,17 @@
       <div id="mainContainer">
         <div class="header">
           <span class="title">[[name]]</span>
-          <gr-button
-              id="removeBtn"
-              on-tap="_handleRemovePermission">Remove</gr-button>
+          <div class="right">
+            <paper-toggle-button
+                id="exclusiveToggle"
+                checked="{{permission.value.exclusive}}"
+                on-change="_handleValueChange"
+                disabled$="[[!editing]]"></paper-toggle-button>Exclusive
+            <gr-button
+                link
+                id="removeBtn"
+                on-tap="_handleRemovePermission">Remove</gr-button>
+          </div>
         </div><!-- end header -->
         <div class="rules">
           <template
@@ -105,12 +121,14 @@
                 placeholder="Add group"
                 on-commit="_handleAddRuleItem">
             </gr-autocomplete>
-          </div> <!-- end addRule -->
+          </div>
+          <!-- end addRule -->
         </div> <!-- end rules -->
       </div><!-- end mainContainer -->
       <div id="deletedContainer">
-        [[name]] was deleted
+        <span>[[name]] was deleted</span>
         <gr-button
+            link
             id="undoRemoveBtn"
             on-tap="_handleUndoRemove">Undo</gr-button>
       </div><!-- end deletedContainer -->
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
index 2ba1eed..f9c04e60 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
@@ -16,6 +16,12 @@
 
   const MAX_AUTOCOMPLETE_RESULTS = 20;
 
+  /**
+   * Fired when the permission has been modified or removed.
+   *
+   * @event access-modified
+   */
+
   Polymer({
     is: 'gr-permission',
 
@@ -33,6 +39,7 @@
       editing: {
         type: Boolean,
         value: false,
+        observer: '_handleEditingChanged',
       },
       _label: {
         type: Object,
@@ -51,6 +58,7 @@
         type: Boolean,
         value: false,
       },
+      _originalExclusiveValue: Boolean,
     },
 
     behaviors: [
@@ -61,6 +69,53 @@
       '_handleRulesChanged(_rules.splices)',
     ],
 
+    listeners: {
+      'access-saved': '_handleAccessSaved',
+    },
+
+    ready() {
+      this._setupValues();
+    },
+
+    _setupValues() {
+      if (!this.permission) { return; }
+      this._originalExclusiveValue = !!this.permission.value.exclusive;
+      Polymer.dom.flush();
+    },
+
+    _handleAccessSaved() {
+      // Set a new 'original' value to keep track of after the value has been
+      // saved.
+      this._setupValues();
+    },
+
+    _handleEditingChanged(editing, editingOld) {
+      // Ignore when editing gets set initially.
+      if (!editingOld) { return; }
+      // Restore original values if no longer editing.
+      if (!editing) {
+        this._deleted = false;
+        this._groupFilter = '';
+        this._rules = this._rules.filter(rule => !rule.value.added);
+
+        // Restore exclusive bit to original.
+        this.set(['permission', 'value', 'exclusive'],
+            this._originalExclusiveValue);
+      }
+    },
+
+    _handleValueChange() {
+      this.permission.value.modified = true;
+      // Allows overall access page to know a change has been made.
+      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+    },
+
+    _handleRemovePermission() {
+      this._deleted = true;
+      this.permission.value.deleted = true;
+      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
+    },
+
     _handleRulesChanged(changeRecord) {
       // Update the groups to exclude in the autocomplete.
       this._groupsWithRules = this._computeGroupsWithRules(this._rules);
@@ -70,11 +125,6 @@
       this._rules = this.toSortedArray(permission.value.rules);
     },
 
-    _handleRemovePermission() {
-      this._deleted = true;
-      this.set('permission.value.deleted', true);
-    },
-
     _computeSectionClass(editing, deleted) {
       const classList = [];
       if (editing) {
@@ -164,21 +214,26 @@
      * gr-rule-editor handles setting the default values.
      */
     _handleAddRuleItem(e) {
-      this.set(['permission', 'value', 'rules', e.detail.value.id], {});
+      // The group id is encoded, but have to decode in order for the access
+      // API to work as expected.
+      const groupId = decodeURIComponent(e.detail.value.id);
+      this.set(['permission', 'value', 'rules', groupId], {});
 
       // Purposely don't recompute sorted array so that the newly added rule
       // is the last item of the array.
       this.push('_rules', {
-        id: e.detail.value.id,
+        id: groupId,
       });
 
-      // Wait for new rule to get value populated via gr-rule editor, and then
+      // Wait for new rule to get value populated via gr-rule-editor, and then
       // add to permission values as well, so that the change gets propogated
       // back to the section. Since the rule is inside a dom-repeat, a flush
       // is needed.
       Polymer.dom.flush();
-      this.set(['permission', 'value', 'rules', e.detail.value.id],
-          this._rules[this._rules.length - 1].value);
+      const value = this._rules[this._rules.length - 1].value;
+      value.added = true;
+      this.set(['permission', 'value', 'rules', groupId], value);
+      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
     },
   });
 })();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
index 179d221..b67d705 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
@@ -288,6 +288,7 @@
             },
           },
         };
+        element._setupValues();
         flushAsynchronousOperations();
       });
 
@@ -309,7 +310,7 @@
         assert.equal(element._rules.length, 3);
         assert.equal(Object.keys(element._groupsWithRules).length, 3);
         assert.deepEqual(element.permission.value.rules['newUserGroupId'],
-            {action: 'ALLOW', min: -2, max: 2});
+            {action: 'ALLOW', min: -2, max: 2, added: true});
       });
 
       test('removing the permission', () => {
@@ -326,6 +327,32 @@
         assert.isFalse(element.$.permission.classList.contains('deleted'));
         assert.isFalse(element._deleted);
       });
+
+      test('modify a permission', () => {
+        element.editing = true;
+        element.name = 'Priority';
+        element.section = 'refs/*';
+
+        assert.isFalse(element._originalExclusiveValue);
+        assert.isNotOk(element.permission.value.modified);
+        MockInteractions.tap(element.$.exclusiveToggle);
+        flushAsynchronousOperations();
+        assert.isTrue(element.permission.value.exclusive);
+        assert.isTrue(element.permission.value.modified);
+        assert.isFalse(element._originalExclusiveValue);
+        element.editing = false;
+        assert.isFalse(element.permission.value.exclusive);
+      });
+
+      test('_handleValueChange', () => {
+        const modifiedHandler = sandbox.stub();
+        element.permission = {value: {rules: {}}};
+        element.addEventListener('access-modified', modifiedHandler);
+        assert.isNotOk(element.permission.value.modified);
+        element._handleValueChange();
+        assert.isTrue(element.permission.value.modified);
+        assert.isTrue(modifiedHandler.called);
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
index 7d12e96..57cc771 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
@@ -45,7 +45,12 @@
           <template is="dom-repeat" items="[[_shownPlugins]]">
             <tr class="table">
               <td class="name">
-                <a href$="[[_computePluginUrl(item.index_url)]]">[[item.id]]</a>
+                <template is="dom-if" if="[[item.index_url]]">
+                  <a href$="[[_computePluginUrl(item.index_url)]]">[[item.id]]</a>
+                </template>
+                <template is="dom-if" if="[[!item.index_url]]">
+                  [[item.id]]
+                </template>
               </td>
               <td class="version">[[item.version]]</td>
               <td class="status">[[_status(item)]]</td>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
index 52f1a22..fea8069 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
@@ -35,12 +35,16 @@
 <script>
   let counter;
   const pluginGenerator = () => {
-    return {
+    const plugin = {
       id: `test${++counter}`,
-      index_url: `plugins/test${counter}/`,
       version: '3.0-SNAPSHOT',
       disabled: false,
     };
+
+    if (counter !== 2) {
+      plugin.index_url = `plugins/test${counter}/`;
+    }
+    return plugin;
   };
 
   suite('gr-plugin-list tests', () => {
@@ -82,6 +86,17 @@
         });
       });
 
+      test('with and without urls', done => {
+        flush(() => {
+          const names = Polymer.dom(element.root).querySelectorAll('.name');
+          assert.isOk(names[1].querySelector('a'));
+          assert.equal(names[1].querySelector('a').innerText, 'test1');
+          assert.isNotOk(names[2].querySelector('a'));
+          assert.equal(names[2].innerText, 'test2');
+          done();
+        });
+      });
+
       test('_shownPlugins', () => {
         assert.equal(element._shownPlugins.length, 25);
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.html b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.html
deleted file mode 100644
index ca3abc4..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.html
+++ /dev/null
@@ -1,69 +0,0 @@
-<!--
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
-
-<link rel="import" href="../../../styles/gr-menu-page-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-access-section/gr-access-section.html">
-
-<script src="../../../scripts/util.js"></script>
-
-<dom-module id="gr-project-access">
-  <template>
-    <style include="shared-styles">
-      .gwtLink {
-        margin-bottom: 1em;
-      }
-      .gwtLink {
-        display: none;
-      }
-      .admin .gwtLink {
-        display: block;
-      }
-    </style>
-    <style include="gr-menu-page-styles"></style>
-    <main class$="[[_computeAdminClass(_isAdmin)]]">
-      <div class="gwtLink">This is currently in read only mode.  To modify content, go to the
-        <a href$="[[computeGwtUrl(path)]]" rel="external">Old UI</a>
-      </div>
-      <template is="dom-if" if="[[_inheritsFrom]]">
-        <h3 id="inheritsFrom">Rights Inherit From
-          <a href$="[[_computeParentHref(_inheritsFrom.name)]]" rel="noopener">
-              [[_inheritsFrom.name]]</a>
-        </h3>
-      </template>
-      <template
-          is="dom-repeat"
-          items="{{_sections}}"
-          as="section">
-        <gr-access-section
-            capabilities="[[_capabilities]]"
-            section="{{section}}"
-            labels="[[_labels]]"
-            editing="[[_editing]]"
-            groups="[[_groups]]"></gr-access-section>
-      </template>
-    </main>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-project-access.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.js b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.js
deleted file mode 100644
index d736dac..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.js
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-(function() {
-  'use strict';
-
-  Polymer({
-    is: 'gr-project-access',
-
-    properties: {
-      project: {
-        type: String,
-        observer: '_projectChanged',
-      },
-      // The current path
-      path: String,
-
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-      _capabilities: Object,
-      _groups: Object,
-      /** @type {?} */
-      _inheritsFrom: Object,
-      _labels: Object,
-      _local: Object,
-      _editing: {
-        type: Boolean,
-        value: false,
-      },
-      _sections: Array,
-    },
-
-    behaviors: [
-      Gerrit.AccessBehavior,
-      Gerrit.BaseUrlBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
-    /**
-     * @param {string} project
-     * @return {!Promise}
-     */
-    _projectChanged(project) {
-      if (!project) { return Promise.resolve(); }
-      const promises = [];
-      // Always reset sections when a project changes.
-      this._sections = [];
-      promises.push(this.$.restAPI.getProjectAccessRights(project).then(res => {
-        this._inheritsFrom = res.inherits_from;
-        this._local = res.local;
-        this._groups = res.groups;
-        return this.toSortedArray(this._local);
-      }));
-
-      promises.push(this.$.restAPI.getCapabilities().then(res => {
-        return res;
-      }));
-
-      promises.push(this.$.restAPI.getProject(project).then(res => {
-        return res.labels;
-      }));
-
-      promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
-        this._isAdmin = isAdmin;
-      }));
-
-      return Promise.all(promises).then(([sections, capabilities, labels]) => {
-        this._capabilities = capabilities;
-        this._labels = labels;
-        this._sections = sections;
-      });
-    },
-
-    _computeAdminClass(isAdmin) {
-      return isAdmin ? 'admin' : '';
-    },
-
-    _computeParentHref(projectName) {
-      return this.getBaseUrl() +
-          `/admin/projects/${this.encodeURL(projectName, true)},access`;
-    },
-  });
-})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access_test.html b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access_test.html
deleted file mode 100644
index 8ea14cf..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access_test.html
+++ /dev/null
@@ -1,198 +0,0 @@
-<!DOCTYPE html>
-<!--
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-project-access</title>
-
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-project-access.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-project-access></gr-project-access>
-  </template>
-</test-fixture>
-
-<script>
-  suite('gr-project-access tests', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_projectChanged called when project name changes', () => {
-      sandbox.stub(element, '_projectChanged');
-      element.project = 'New Project';
-      assert.isTrue(element._projectChanged.called);
-    });
-
-    test('_projectChanged', done => {
-      const capabilitiesRes = {
-        accessDatabase: {
-          id: 'accessDatabase',
-          name: 'Access Database',
-        },
-        createAccount: {
-          id: 'createAccount',
-          name: 'Create Account',
-        },
-      };
-      const accessRes = {
-        local: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  234: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      const accessRes2 = {
-        local: {
-          GLOBAL_CAPABILITIES: {
-            permissions: {
-              accessDatabase: {
-                rules: {
-                  group1: {
-                    action: 'ALLOW',
-                  },
-                },
-              },
-            },
-          },
-        },
-      };
-      const projectRes = {
-        labels: {
-          'Code-Review': {},
-        },
-      };
-      const accessStub = sandbox.stub(element.$.restAPI,
-          'getProjectAccessRights');
-
-
-      accessStub.withArgs('New Project').returns(Promise.resolve(accessRes));
-      accessStub.withArgs('Another New Project')
-          .returns(Promise.resolve(accessRes2));
-      const capabilitiesStub = sandbox.stub(element.$.restAPI,
-          'getCapabilities');
-      capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
-      const projectStub = sandbox.stub(element.$.restAPI, 'getProject').returns(
-          Promise.resolve(projectRes));
-      const adminStub = sandbox.stub(element.$.restAPI, 'getIsAdmin').returns(
-          Promise.resolve(true));
-
-      element._projectChanged('New Project').then(() => {
-        assert.isTrue(accessStub.called);
-        assert.isTrue(capabilitiesStub.called);
-        assert.isTrue(projectStub.called);
-        assert.isTrue(adminStub.called);
-        assert.isNotOk(element._inheritsFrom);
-        assert.deepEqual(element._local, accessRes.local);
-        assert.deepEqual(element._sections,
-            element.toSortedArray(accessRes.local));
-        assert.deepEqual(element._labels, projectRes.labels);
-        return element._projectChanged('Another New Project');
-      })
-          .then(() => {
-            assert.deepEqual(element._sections,
-                element.toSortedArray(accessRes2.local));
-            done();
-          });
-    });
-
-    test('_projectChanged when project changes to undefined returns', done => {
-      const capabilitiesRes = {
-        accessDatabase: {
-          id: 'accessDatabase',
-          name: 'Access Database',
-        },
-      };
-      const accessRes = {
-        local: {
-          GLOBAL_CAPABILITIES: {
-            permissions: {
-              accessDatabase: {
-                rules: {
-                  123: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      const projectRes = {
-        labels: {
-          'Code-Review': {},
-        },
-      };
-      const accessStub = sandbox.stub(element.$.restAPI,
-          'getProjectAccessRights').returns(Promise.resolve(accessRes));
-      const capabilitiesStub = sandbox.stub(element.$.restAPI,
-          'getCapabilities').returns(Promise.resolve(capabilitiesRes));
-      const projectStub = sandbox.stub(element.$.restAPI, 'getProject').returns(
-          Promise.resolve(projectRes));
-
-      element._projectChanged().then(() => {
-        assert.isFalse(accessStub.called);
-        assert.isFalse(capabilitiesStub.called);
-        assert.isFalse(projectStub.called);
-        done();
-      });
-    });
-
-    test('_computeParentHref', () => {
-      const projectName = 'test-project';
-      assert.equal(element._computeParentHref(projectName),
-          '/admin/projects/test-project,access');
-    });
-
-    test('_computeAdminClass', () => {
-      let isAdmin = true;
-      assert.equal(element._computeAdminClass(isAdmin), 'admin');
-      isAdmin = false;
-      assert.equal(element._computeAdminClass(isAdmin), '');
-    });
-
-    test('inherit section', () => {
-      sandbox.stub(element, '_computeParentHref');
-      assert.isNotOk(Polymer.dom(element.root).querySelector('#inheritsFrom'));
-      assert.isFalse(element._computeParentHref.called);
-      element._inheritsFrom = {
-        name: 'another-project',
-      };
-      flushAsynchronousOperations();
-      assert.isOk(Polymer.dom(element.root).querySelector('#inheritsFrom'));
-      assert.isTrue(element._computeParentHref.called);
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.html b/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.html
deleted file mode 100644
index 6c0908a..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.html
+++ /dev/null
@@ -1,92 +0,0 @@
-<!--
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-create-change-dialog/gr-create-change-dialog.html">
-
-<dom-module id="gr-project-commands">
-  <template>
-    <style include="shared-styles">
-      main {
-        margin: 2em 1em;
-      }
-      .loading {
-        display: none;
-      }
-      #loading.loading {
-        display: block;
-      }
-      #loading:not(.loading) {
-        display: none;
-      }
-    </style>
-    <style include="gr-form-styles"></style>
-    <main class="gr-form-styles read-only">
-      <h1 id="Title">Project Commands</h1>
-      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">Loading...</div>
-      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-        <h2 id="options">Command</h2>
-        <div id="form">
-          <fieldset>
-            <h3 id="createChange">Create Change</h3>
-            <fieldset>
-              <gr-button id="createNewChange" on-tap="_createNewChange">
-                Create Change
-              </gr-button>
-            </fieldset>
-            <h3 id="runGC" hidden$="[[!_projectConfig.actions.gc.enabled]]">
-                Run GC
-            </h3>
-            <fieldset>
-              <gr-button
-                  on-tap="_handleRunningGC"
-                  hidden$="[[!_projectConfig.actions.gc.enabled]]">
-                Run GC
-              </gr-button>
-            </fieldset>
-          </fieldset>
-        </div>
-      </div>
-    </main>
-    <gr-overlay id="createChangeOverlay" with-backdrop>
-      <gr-confirm-dialog
-          id="createChangeDialog"
-          confirm-label="Create"
-          disabled="[[!_canCreate]]"
-          on-confirm="_handleCreateChange"
-          on-cancel="_handleCloseCreateChange">
-        <div class="header">
-          Create Change
-        </div>
-        <div class="main">
-          <gr-create-change-dialog
-              id="createNewChangeModal"
-              can-create="{{_canCreate}}"
-              project-name="[[project]]"></gr-create-change-dialog>
-        </div>
-      </gr-confirm-dialog>
-    </gr-overlay>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-project-commands.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.js b/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.js
deleted file mode 100644
index 88cf058..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.js
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-(function() {
-  'use strict';
-
-  const GC_MESSAGE = 'Garbage collection completed successfully.';
-
-  Polymer({
-    is: 'gr-project-commands',
-
-    properties: {
-      params: Object,
-      project: String,
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      /** @type {?} */
-      _projectConfig: Object,
-      _canCreate: Boolean,
-    },
-
-    attached() {
-      this._loadProject();
-
-      this.fire('title-change', {title: 'Project Commands'});
-    },
-
-    _loadProject() {
-      if (!this.project) { return Promise.resolve(); }
-
-      return this.$.restAPI.getProjectConfig(this.project).then(
-          config => {
-            this._projectConfig = config;
-            this._loading = false;
-          });
-    },
-
-    _computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    },
-
-    _isLoading() {
-      return this._loading || this._loading === undefined;
-    },
-
-    _handleRunningGC() {
-      return this.$.restAPI.runProjectGC(this.project).then(response => {
-        if (response.status === 200) {
-          this.dispatchEvent(new CustomEvent('show-alert',
-              {detail: {message: GC_MESSAGE}, bubbles: true}));
-        }
-      });
-    },
-
-    _createNewChange() {
-      this.$.createChangeOverlay.open();
-    },
-
-    _handleCreateChange() {
-      this.$.createNewChangeModal.handleCreateChange();
-      this._handleCloseCreateChange();
-    },
-
-    _handleCloseCreateChange() {
-      this.$.createChangeOverlay.close();
-    },
-  });
-})();
diff --git a/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands_test.html b/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands_test.html
deleted file mode 100644
index 693f07e..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands_test.html
+++ /dev/null
@@ -1,69 +0,0 @@
-<!DOCTYPE html>
-<!--
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-project-commands</title>
-
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-project-commands.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-project-commands></gr-project-commands>
-  </template>
-</test-fixture>
-
-<script>
-  suite('gr-project-commands tests', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('create new change dialog', () => {
-      test('_createNewChange opens modal', () => {
-        const openStub = sandbox.stub(element.$.createChangeOverlay, 'open');
-        element._createNewChange();
-        assert.isTrue(openStub.called);
-      });
-
-      test('_handleCreateChange called when confirm fired', () => {
-        sandbox.stub(element, '_handleCreateChange');
-        element.$.createChangeDialog.fire('confirm');
-        assert.isTrue(element._handleCreateChange.called);
-      });
-
-      test('_handleCloseCreateChange called when cancel fired', () => {
-        sandbox.stub(element, '_handleCloseCreateChange');
-        element.$.createChangeDialog.fire('cancel');
-        assert.isTrue(element._handleCloseCreateChange.called);
-      });
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.html b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.html
deleted file mode 100644
index 2effc20..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.html
+++ /dev/null
@@ -1,173 +0,0 @@
-<!--
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-create-pointer-dialog/gr-create-pointer-dialog.html">
-<link rel="import" href="../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html">
-
-<dom-module id="gr-project-detail-list">
-  <template>
-    <style include="gr-form-styles"></style>
-    <style include="shared-styles">
-      .editing .editItem {
-        display: inherit;
-      }
-      .editItem,
-      .editing .editBtn,
-      .canEdit .revisionNoEditing,
-      .editing .revisionWithEditing,
-      .revisionEdit {
-        display: none;
-      }
-      .revisionEdit gr-button {
-        margin-left: .6em;
-      }
-      .editBtn {
-        margin-left: 1em;
-      }
-      .canEdit .revisionEdit{
-        align-items: center;
-        display: flex;
-        line-height: 1em;
-      }
-      .deleteButton:not(.show) {
-        display: none;
-      }
-    </style>
-    <style include="gr-table-styles"></style>
-    <gr-list-view
-        create-new="[[_loggedIn]]"
-        filter="[[_filter]]"
-        items-per-page="[[_itemsPerPage]]"
-        items="[[_items]]"
-        loading="[[_loading]]"
-        offset="[[_offset]]"
-        on-create-clicked="_handleCreateClicked"
-        path="[[_getPath(_project, detailType)]]">
-      <table id="list" class="genericList gr-form-styles">
-        <tr class="headerRow">
-          <th class="name topHeader">Name</th>
-          <th class="description topHeader">Revision</th>
-          <th class="repositoryBrowser topHeader">
-            Repository Browser</th>
-          <th class="delete topHeader"></th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-        <tbody class$="[[computeLoadingClass(_loading)]]">
-          <template is="dom-repeat" items="[[_shownItems]]">
-            <tr class="table">
-              <td class="name">[[_stripRefs(item.ref, detailType)]]</td>
-              <td class$="description [[_computeCanEditClass(item.ref, detailType, _isOwner)]]">
-                <span class="revisionNoEditing">
-                  [[item.revision]]
-                </span>
-                <span class$="revisionEdit [[_computeEditingClass(_isEditing)]]">
-                  <span class="revisionWithEditing">
-                    [[item.revision]]
-                  </span>
-                  <gr-button
-                      link
-                      on-tap="_handleEditRevision"
-                      class="editBtn">
-                    edit
-                  </gr-button>
-                  <input
-                      is=iron-input
-                      bind-value="{{_revisedRef}}"
-                      class="editItem">
-                  <gr-button
-                      link
-                      on-tap="_handleCancelRevision"
-                      class="cancelBtn editItem">
-                    Cancel
-                  </gr-button>
-                  <gr-button
-                      link
-                      on-tap="_handleSaveRevision"
-                      class="saveBtn editItem"
-                      disabled="[[!_revisedRef]]">
-                    Save
-                  </gr-button>
-                </span>
-              </td>
-              <td class="repositoryBrowser">
-                <template is="dom-repeat"
-                    items="[[_computeWeblink(item)]]" as="link">
-                  <a href$="[[link.url]]"
-                      class="webLink"
-                      rel="noopener"
-                      target="_blank">
-                    ([[link.name]])
-                  </a>
-                </template>
-              </td>
-              <td class="delete">
-                <gr-button
-                    link
-                    class$="deleteButton [[_computeHideDeleteClass(_isOwner, item.can_delete)]]"
-                    on-tap="_handleDeleteItem">
-                  Delete
-                </gr-button>
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-      <gr-overlay id="overlay" with-backdrop>
-        <gr-confirm-delete-item-dialog
-            class="confirmDialog"
-            on-confirm="_handleDeleteItemConfirm"
-            on-cancel="_handleConfirmDialogCancel"
-            item="[[_refName]]"
-            item-type="[[detailType]]"></gr-confirm-delete-item-dialog>
-      </gr-overlay>
-    </gr-list-view>
-    <gr-overlay id="createOverlay" with-backdrop>
-      <gr-confirm-dialog
-          id="createDialog"
-          disabled="[[!_hasNewItemName]]"
-          confirm-label="Create"
-          on-confirm="_handleCreateItem"
-          on-cancel="_handleCloseCreate">
-        <div class="header">
-          Create [[_computeItemName(detailType)]]
-        </div>
-        <div class="main">
-          <gr-create-pointer-dialog
-              id="createNewModal"
-              detail-type="[[_computeItemName(detailType)]]"
-              has-new-item-name="{{_hasNewItemName}}"
-              item-detail="[[detailType]]"
-              project-name="[[_project]]"></gr-create-pointer-dialog>
-        </div>
-      </gr-confirm-dialog>
-    </gr-overlay>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-project-detail-list.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.js b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.js
deleted file mode 100644
index eec277e..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.js
+++ /dev/null
@@ -1,251 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-(function() {
-  'use strict';
-
-  const DETAIL_TYPES = {
-    BRANCHES: 'branches',
-    TAGS: 'tags',
-  };
-
-  Polymer({
-    is: 'gr-project-detail-list',
-
-    properties: {
-      /**
-       * URL params passed from the router.
-       */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-      /**
-       * The kind of detail we are displaying, possibilities are determined by
-       * the const DETAIL_TYPES.
-       */
-      detailType: String,
-
-      _editing: {
-        type: Boolean,
-        value: false,
-      },
-      _isOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: Number,
-      _project: Object,
-      _items: Array,
-      /**
-       * Because  we request one more than the projectsPerPage, _shownProjects
-       * maybe one less than _projects.
-       * */
-      _shownItems: {
-        type: Array,
-        computed: 'computeShownItems(_items)',
-      },
-      _itemsPerPage: {
-        type: Number,
-        value: 25,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _filter: String,
-      _refName: String,
-      _hasNewItemName: Boolean,
-      _isEditing: Boolean,
-      _revisedRef: String,
-    },
-
-    behaviors: [
-      Gerrit.ListViewBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
-    _determineIfOwner(project) {
-      return this.$.restAPI.getProjectAccess(project)
-          .then(access =>
-                this._isOwner = access && access[project].is_owner);
-    },
-
-    _paramsChanged(params) {
-      if (!params || !params.project) { return; }
-
-      this._project = params.project;
-
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-        if (loggedIn) {
-          this._determineIfOwner(this._project);
-        }
-      });
-
-      this.detailType = params.detailType;
-
-      this._filter = this.getFilterValue(params);
-      this._offset = this.getOffsetValue(params);
-
-      return this._getItems(this._filter, this._project,
-          this._itemsPerPage, this._offset, this.detailType);
-    },
-
-    _getItems(filter, project, itemsPerPage, offset, detailType) {
-      this._loading = true;
-      this._items = [];
-      Polymer.dom.flush();
-      if (detailType === DETAIL_TYPES.BRANCHES) {
-        return this.$.restAPI.getProjectBranches(
-            filter, project, itemsPerPage, offset) .then(items => {
-              if (!items) { return; }
-              this._items = items;
-              this._loading = false;
-            });
-      } else if (detailType === DETAIL_TYPES.TAGS) {
-        return this.$.restAPI.getProjectTags(
-            filter, project, itemsPerPage, offset) .then(items => {
-              if (!items) { return; }
-              this._items = items;
-              this._loading = false;
-            });
-      }
-    },
-
-    _getPath(project) {
-      return `/admin/projects/${this.encodeURL(project, false)},` +
-          `${this.detailType}`;
-    },
-
-    _computeWeblink(project) {
-      if (!project.web_links) { return ''; }
-      const webLinks = project.web_links;
-      return webLinks.length ? webLinks : null;
-    },
-
-    _stripRefs(item, detailType) {
-      if (detailType === DETAIL_TYPES.BRANCHES) {
-        return item.replace('refs/heads/', '');
-      } else if (detailType === DETAIL_TYPES.TAGS) {
-        return item.replace('refs/tags/', '');
-      }
-    },
-
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    },
-
-    _computeEditingClass(isEditing) {
-      return isEditing ? 'editing' : '';
-    },
-
-    _computeCanEditClass(ref, detailType, isOwner) {
-      return isOwner && this._stripRefs(ref, detailType) === 'HEAD' ?
-          'canEdit' : '';
-    },
-
-    _handleEditRevision(e) {
-      this._revisedRef = e.model.get('item.revision');
-      this._isEditing = true;
-    },
-
-    _handleCancelRevision() {
-      this._isEditing = false;
-    },
-
-    _handleSaveRevision(e) {
-      this._setProjectHead(this._project, this._revisedRef, e);
-    },
-
-    _setProjectHead(project, ref, e) {
-      return this.$.restAPI.setProjectHead(project, ref).then(res => {
-        if (res.status < 400) {
-          this._isEditing = false;
-          e.model.set('item.revision', ref);
-        }
-      });
-    },
-
-    _computeItemName(detailType) {
-      if (detailType === DETAIL_TYPES.BRANCHES) {
-        return 'Branch';
-      } else if (detailType === DETAIL_TYPES.TAGS) {
-        return 'Tag';
-      }
-    },
-
-    _handleDeleteItemConfirm() {
-      this.$.overlay.close();
-      if (this.detailType === DETAIL_TYPES.BRANCHES) {
-        return this.$.restAPI.deleteProjectBranches(this._project,
-            this._refName)
-            .then(itemDeleted => {
-              if (itemDeleted.status === 204) {
-                this._getItems(
-                    this._filter, this._project, this._itemsPerPage,
-                    this._offset, this.detailType);
-              }
-            });
-      } else if (this.detailType === DETAIL_TYPES.TAGS) {
-        return this.$.restAPI.deleteProjectTags(this._project,
-            this._refName)
-            .then(itemDeleted => {
-              if (itemDeleted.status === 204) {
-                this._getItems(
-                    this._filter, this._project, this._itemsPerPage,
-                    this._offset, this.detailType);
-              }
-            });
-      }
-    },
-
-    _handleConfirmDialogCancel() {
-      this.$.overlay.close();
-    },
-
-    _handleDeleteItem(e) {
-      const name = this._stripRefs(e.model.get('item.ref'), this.detailType);
-      if (!name) { return; }
-      this._refName = name;
-      this.$.overlay.open();
-    },
-
-    _computeHideDeleteClass(owner, deleteRef) {
-      if (owner && !deleteRef || owner && deleteRef || deleteRef || owner) {
-        return 'show';
-      }
-      return '';
-    },
-
-    _handleCreateItem() {
-      this.$.createNewModal.handleCreateItem();
-      this._handleCloseCreate();
-    },
-
-    _handleCloseCreate() {
-      this.$.createOverlay.close();
-    },
-
-    _handleCreateClicked() {
-      this.$.createOverlay.open();
-    },
-  });
-})();
diff --git a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list_test.html b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list_test.html
deleted file mode 100644
index 62870df..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list_test.html
+++ /dev/null
@@ -1,439 +0,0 @@
-<!DOCTYPE html>
-<!--
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-project-detail-list</title>
-
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-project-detail-list.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-project-detail-list></gr-project-detail-list>
-  </template>
-</test-fixture>
-
-<script>
-  let counter;
-  const branchGenerator = () => {
-    return {
-      ref: `refs/heads/test${++counter}`,
-      revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
-      web_links: [
-        {
-          name: 'diffusion',
-          url: `https://git.example.org/branch/test;refs/heads/test${counter}`,
-        },
-      ],
-    };
-  };
-  const tagGenerator = () => {
-    return {
-      ref: `refs/tags/test${++counter}`,
-      revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
-      web_links: [
-        {
-          name: 'diffusion',
-          url: `https://git.example.org/tag/test;refs/tags/test${counter}`,
-        },
-      ],
-    };
-  };
-
-  suite('Branches', () => {
-    let element;
-    let branches;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.detailType = 'branches';
-      counter = 0;
-      sandbox.stub(page, 'show');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('list of project branches', () => {
-      setup(done => {
-        branches = [{
-          ref: 'HEAD',
-          revision: 'master',
-        }].concat(_.times(25, branchGenerator));
-
-        stub('gr-rest-api-interface', {
-          getProjectBranches(num, project, offset) {
-            return Promise.resolve(branches);
-          },
-        });
-
-        const params = {
-          project: 'test',
-          detailType: 'branches',
-        };
-
-        element._paramsChanged(params).then(() => { flush(done); });
-      });
-
-      test('test for branch in the list', done => {
-        flush(() => {
-          assert.equal(element._items[2].ref, 'refs/heads/test2');
-          done();
-        });
-      });
-
-      test('test for web links in the branches list', done => {
-        flush(() => {
-          assert.equal(element._items[2].web_links[0].url,
-              'https://git.example.org/branch/test;refs/heads/test2');
-          done();
-        });
-      });
-
-      test('test for refs/heads/ being striped from ref', done => {
-        flush(() => {
-          assert.equal(element._stripRefs(element._items[2].ref,
-              element.detailType), 'test2');
-          done();
-        });
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-
-      test('Edit HEAD button not admin', done => {
-        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sandbox.stub(element.$.restAPI, 'getProjectAccess').returns(
-            Promise.resolve({
-              test: {is_owner: false},
-            }));
-        element._determineIfOwner('test').then(() => {
-          assert.equal(element._isOwner, false);
-          assert.equal(getComputedStyle(Polymer.dom(element.root)
-              .querySelector('.revisionNoEditing')).display, 'inline');
-          assert.equal(getComputedStyle(Polymer.dom(element.root)
-              .querySelector('.revisionEdit')).display, 'none');
-          done();
-        });
-      });
-
-      test('Edit HEAD button admin', done => {
-        const saveBtn = Polymer.dom(element.root).querySelector('.saveBtn');
-        const cancelBtn = Polymer.dom(element.root).querySelector('.cancelBtn');
-        const editBtn = Polymer.dom(element.root).querySelector('.editBtn');
-        const revisionNoEditing = Polymer.dom(element.root)
-              .querySelector('.revisionNoEditing');
-        const revisionWithEditing = Polymer.dom(element.root)
-              .querySelector('.revisionWithEditing');
-
-        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sandbox.stub(element.$.restAPI, 'getProjectAccess').returns(
-            Promise.resolve({
-              test: {is_owner: true},
-            }));
-        sandbox.stub(element, '_handleSaveRevision');
-        element._determineIfOwner('test').then(() => {
-          assert.equal(element._isOwner, true);
-          // The revision container for non-editing enabled row is not visible.
-          assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
-
-          // The revision container for editing enabled row is visible.
-          assert.notEqual(getComputedStyle(Polymer.dom(element.root)
-              .querySelector('.revisionEdit')).display, 'none');
-
-          // The revision and edit button are visible.
-          assert.notEqual(getComputedStyle(revisionWithEditing).display,
-              'none');
-          assert.notEqual(getComputedStyle(editBtn).display, 'none');
-
-          // The input, cancel, and save buttons are not visible.
-          const hiddenElements = Polymer.dom(element.root)
-              .querySelectorAll('.canEdit .editItem');
-
-          for (const item of hiddenElements) {
-            assert.equal(getComputedStyle(item).display, 'none');
-          }
-
-          MockInteractions.tap(editBtn);
-          flushAsynchronousOperations();
-          // The revision and edit button are not visible.
-          assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
-          assert.equal(getComputedStyle(editBtn).display, 'none');
-
-          // The input, cancel, and save buttons are not visible.
-          for (item of hiddenElements) {
-            assert.notEqual(getComputedStyle(item).display, 'none');
-          }
-
-          // The revised ref was set correctly
-          assert.equal(element._revisedRef, 'master');
-
-          assert.isFalse(saveBtn.disabled);
-
-          // Delete the ref.
-          element._revisedRef = '';
-          assert.isTrue(saveBtn.disabled);
-
-          // Change the ref to something else
-          element._revisedRef = 'newRef';
-          element._project = 'test';
-          assert.isFalse(saveBtn.disabled);
-
-          // Save button calls handleSave. since this is stubbed, the edit
-          // section remains open.
-          MockInteractions.tap(saveBtn);
-          assert.isTrue(element._handleSaveRevision.called);
-
-          // When cancel is tapped, the edit secion closes.
-          MockInteractions.tap(cancelBtn);
-          flushAsynchronousOperations();
-
-          // The revision and edit button are visible.
-          assert.notEqual(getComputedStyle(revisionWithEditing).display,
-              'none');
-          assert.notEqual(getComputedStyle(editBtn).display, 'none');
-
-          // The input, cancel, and save buttons are not visible.
-          for (const item of hiddenElements) {
-            assert.equal(getComputedStyle(item).display, 'none');
-          }
-          done();
-        });
-      });
-
-      test('_handleSaveRevision with invalid rev', done => {
-        const event = {model: {set: sandbox.stub()}};
-        element._isEditing = true;
-        sandbox.stub(element.$.restAPI, 'setProjectHead').returns(
-            Promise.resolve({
-              status: 400,
-            })
-        );
-
-        element._setProjectHead('test', 'newRef', event).then(() => {
-          assert.isTrue(element._isEditing);
-          assert.isFalse(event.model.set.called);
-          done();
-        });
-      });
-
-      test('_handleSaveRevision with valid rev', done => {
-        const event = {model: {set: sandbox.stub()}};
-        element._isEditing = true;
-        sandbox.stub(element.$.restAPI, 'setProjectHead').returns(
-            Promise.resolve({
-              status: 200,
-            })
-        );
-
-        element._setProjectHead('test', 'newRef', event).then(() => {
-          assert.isFalse(element._isEditing);
-          assert.isTrue(event.model.set.called);
-          done();
-        });
-      });
-
-      test('test _computeItemName', () => {
-        assert.deepEqual(element._computeItemName('branches'), 'Branch');
-        assert.deepEqual(element._computeItemName('tags'), 'Tag');
-      });
-    });
-
-    suite('list with less then 25 branches', () => {
-      setup(done => {
-        branches = _.times(25, branchGenerator);
-
-        stub('gr-rest-api-interface', {
-          getProjectBranches(num, project, offset) {
-            return Promise.resolve(branches);
-          },
-        });
-
-        const params = {
-          project: 'test',
-          detailType: 'branches',
-        };
-
-        element._paramsChanged(params).then(() => { flush(done); });
-      });
-
-      test('_shownProjectsBranches', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      test('_paramsChanged', done => {
-        sandbox.stub(element.$.restAPI, 'getProjectBranches', () => {
-          return Promise.resolve(branches);
-        });
-        const params = {
-          detailType: 'branches',
-          project: 'test',
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(params).then(() => {
-          assert.isTrue(element.$.restAPI.getProjectBranches.lastCall
-              .calledWithExactly('test', 'test', 25, 25));
-          done();
-        });
-      });
-    });
-  });
-
-  suite('Tags', () => {
-    let element;
-    let tags;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.detailType = 'tags';
-      counter = 0;
-      sandbox.stub(page, 'show');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('list of project tags', () => {
-      setup(done => {
-        tags = _.times(26, tagGenerator);
-
-        stub('gr-rest-api-interface', {
-          getProjectTags(num, project, offset) {
-            return Promise.resolve(tags);
-          },
-        });
-
-        const params = {
-          project: 'test',
-          detailType: 'tags',
-        };
-
-        element._paramsChanged(params).then(() => { flush(done); });
-      });
-
-      test('test for tag in the list', done => {
-        flush(() => {
-          assert.equal(element._items[1].ref, 'refs/tags/test2');
-          done();
-        });
-      });
-
-      test('test for web links in the tags list', done => {
-        flush(() => {
-          assert.equal(element._items[1].web_links[0].url,
-              'https://git.example.org/tag/test;refs/tags/test2');
-          done();
-        });
-      });
-
-      test('test for refs/tags/ being striped from ref', done => {
-        flush(() => {
-          assert.equal(element._stripRefs(element._items[1].ref,
-              element.detailType), 'test2');
-          done();
-        });
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-    });
-
-    suite('list with less then 25 tags', () => {
-      setup(done => {
-        tags = _.times(25, tagGenerator);
-
-        stub('gr-rest-api-interface', {
-          getProjectTags(num, project, offset) {
-            return Promise.resolve(tags);
-          },
-        });
-
-        const params = {
-          project: 'test',
-          detailType: 'tags',
-        };
-
-        element._paramsChanged(params).then(() => { flush(done); });
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      test('_paramsChanged', done => {
-        sandbox.stub(element.$.restAPI, 'getProjectTags', () => {
-          return Promise.resolve(tags);
-        });
-        const params = {
-          project: 'test',
-          detailType: 'tags',
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(params).then(() => {
-          assert.isTrue(element.$.restAPI.getProjectTags.lastCall
-              .calledWithExactly('test', 'test', 25, 25));
-          done();
-        });
-      });
-    });
-
-    suite('create new', () => {
-      test('_handleCreateClicked called when create-click fired', () => {
-        sandbox.stub(element, '_handleCreateClicked');
-        element.$$('gr-list-view').fire('create-clicked');
-        assert.isTrue(element._handleCreateClicked.called);
-      });
-
-      test('_handleCreateClicked opens modal', () => {
-        const openStub = sandbox.stub(element.$.createOverlay, 'open');
-        element._handleCreateClicked();
-        assert.isTrue(openStub.called);
-      });
-
-      test('_handleCreateItem called when confirm fired', () => {
-        sandbox.stub(element, '_handleCreateItem');
-        element.$.createDialog.fire('confirm');
-        assert.isTrue(element._handleCreateItem.called);
-      });
-
-      test('_handleCloseCreate called when cancel fired', () => {
-        sandbox.stub(element, '_handleCloseCreate');
-        element.$.createDialog.fire('cancel');
-        assert.isTrue(element._handleCloseCreate.called);
-      });
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.html b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.html
deleted file mode 100644
index 6c45704..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.html
+++ /dev/null
@@ -1,98 +0,0 @@
-<!--
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
-<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-create-project-dialog/gr-create-project-dialog.html">
-
-
-<dom-module id="gr-project-list">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-table-styles"></style>
-    <gr-list-view
-        create-new=[[_createNewCapability]]
-        filter="[[_filter]]"
-        items-per-page="[[_projectsPerPage]]"
-        items="[[_projects]]"
-        loading="[[_loading]]"
-        offset="[[_offset]]"
-        on-create-clicked="_handleCreateClicked"
-        path="[[_path]]">
-      <table id="list" class="genericList">
-        <tr class="headerRow">
-          <th class="name topHeader">Project Name</th>
-          <th class="description topHeader">Project Description</th>
-          <th class="repositoryBrowser topHeader">Repository Browser</th>
-          <th class="readOnly topHeader">Read only</th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-        <tbody class$="[[computeLoadingClass(_loading)]]">
-          <template is="dom-repeat" items="[[_shownProjects]]">
-            <tr class="table">
-              <td class="name">
-                <a href$="[[_computeProjectUrl(item.name)]]">[[item.name]]</a>
-              </td>
-              <td class="description">[[item.description]]</td>
-              <td class="repositoryBrowser">
-                <template is="dom-repeat"
-                    items="[[_computeWeblink(item)]]" as="link">
-                  <a href$="[[link.url]]"
-                      class="webLink"
-                      rel="noopener"
-                      target="_blank">
-                    ([[link.name]])
-                  </a>
-                </template>
-              </td>
-              <td class="readOnly">[[_readOnly(item)]]</td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-    </gr-list-view>
-    <gr-overlay id="createOverlay" with-backdrop>
-      <gr-confirm-dialog
-          id="createDialog"
-          class="confirmDialog"
-          disabled="[[!_hasNewProjectName]]"
-          confirm-label="Create"
-          on-confirm="_handleCreateProject"
-          on-cancel="_handleCloseCreate">
-        <div class="header">
-          Create Project
-        </div>
-        <div class="main">
-          <gr-create-project-dialog
-              has-new-project-name="{{_hasNewProjectName}}"
-              params="[[params]]"
-              id="createNewModal"></gr-create-project-dialog>
-        </div>
-      </gr-confirm-dialog>
-    </gr-overlay>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-project-list.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.js b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.js
deleted file mode 100644
index 070cc2f..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.js
+++ /dev/null
@@ -1,152 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-(function() {
-  'use strict';
-
-  Polymer({
-    is: 'gr-project-list',
-
-    properties: {
-      /**
-       * URL params passed from the router.
-       */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: Number,
-      _path: {
-        type: String,
-        readOnly: true,
-        value: '/admin/projects',
-      },
-      _hasNewProjectName: Boolean,
-      _createNewCapability: {
-        type: Boolean,
-        value: false,
-      },
-      _projects: Array,
-
-      /**
-       * Because  we request one more than the projectsPerPage, _shownProjects
-       * maybe one less than _projects.
-       * */
-      _shownProjects: {
-        type: Array,
-        computed: 'computeShownItems(_projects)',
-      },
-
-      _projectsPerPage: {
-        type: Number,
-        value: 25,
-      },
-
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _filter: String,
-    },
-
-    behaviors: [
-      Gerrit.ListViewBehavior,
-    ],
-
-    attached() {
-      this._getCreateProjectCapability();
-      this.fire('title-change', {title: 'Projects'});
-      this._maybeOpenCreateOverlay(this.params);
-    },
-
-    _paramsChanged(params) {
-      this._loading = true;
-      this._filter = this.getFilterValue(params);
-      this._offset = this.getOffsetValue(params);
-
-      return this._getProjects(this._filter, this._projectsPerPage,
-          this._offset);
-    },
-
-    /**
-     * Opens the create overlay if the route has a hash 'create'
-     * @param {!Object} params
-     */
-    _maybeOpenCreateOverlay(params) {
-      if (params && params.openCreateModal) {
-        this.$.createOverlay.open();
-      }
-    },
-
-    _computeProjectUrl(name) {
-      return this.getUrl(this._path + '/', name);
-    },
-
-    _getCreateProjectCapability() {
-      return this.$.restAPI.getAccount().then(account => {
-        if (!account) { return; }
-        return this.$.restAPI.getAccountCapabilities(['createProject'])
-            .then(capabilities => {
-              if (capabilities.createProject) {
-                this._createNewCapability = true;
-              }
-            });
-      });
-    },
-
-    _getProjects(filter, projectsPerPage, offset) {
-      this._projects = [];
-      return this.$.restAPI.getProjects(filter, projectsPerPage, offset)
-          .then(projects => {
-            if (!projects) {
-              return;
-            }
-            this._projects = Object.keys(projects)
-             .map(key => {
-               const project = projects[key];
-               project.name = key;
-               return project;
-             });
-            this._loading = false;
-          });
-    },
-
-    _handleCreateProject() {
-      this.$.createNewModal.handleCreateProject();
-    },
-
-    _handleCloseCreate() {
-      this.$.createOverlay.close();
-    },
-
-    _handleCreateClicked() {
-      this.$.createOverlay.open();
-    },
-
-    _readOnly(item) {
-      return item.state === 'READ_ONLY' ? 'Y' : '';
-    },
-
-    _computeWeblink(project) {
-      if (!project.web_links) {
-        return '';
-      }
-      const webLinks = project.web_links;
-      return webLinks.length ? webLinks : null;
-    },
-  });
-})();
diff --git a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list_test.html b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list_test.html
deleted file mode 100644
index 87732b8..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list_test.html
+++ /dev/null
@@ -1,177 +0,0 @@
-<!DOCTYPE html>
-<!--
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-project-list</title>
-
-<script src="../../../bower_components/page/page.js"></script>
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-project-list.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-project-list></gr-project-list>
-  </template>
-</test-fixture>
-
-<script>
-  let counter;
-  const projectGenerator = () => {
-    return {
-      id: `test${++counter}`,
-      state: 'ACTIVE',
-      web_links: [
-        {
-          name: 'diffusion',
-          url: `https://phabricator.example.org/r/project/test${counter}`,
-        },
-      ],
-    };
-  };
-
-  suite('gr-project-list tests', () => {
-    let element;
-    let projects;
-    let sandbox;
-    let value;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      counter = 0;
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('list with projects', () => {
-      setup(done => {
-        projects = _.times(26, projectGenerator);
-        stub('gr-rest-api-interface', {
-          getProjects(num, offset) {
-            return Promise.resolve(projects);
-          },
-        });
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('test for test project in the list', done => {
-        flush(() => {
-          assert.equal(element._projects[1].id, 'test2');
-          done();
-        });
-      });
-
-      test('_shownProjects', () => {
-        assert.equal(element._shownProjects.length, 25);
-      });
-
-      test('_maybeOpenCreateOverlay', () => {
-        const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
-        element._maybeOpenCreateOverlay();
-        assert.isFalse(overlayOpen.called);
-        const params = {};
-        element._maybeOpenCreateOverlay(params);
-        assert.isFalse(overlayOpen.called);
-        params.openCreateModal = true;
-        element._maybeOpenCreateOverlay(params);
-        assert.isTrue(overlayOpen.called);
-      });
-    });
-
-    suite('list with less then 25 projects', () => {
-      setup(done => {
-        projects = _.times(25, projectGenerator);
-
-        stub('gr-rest-api-interface', {
-          getProjects(num, offset) {
-            return Promise.resolve(projects);
-          },
-        });
-
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('_shownProjects', () => {
-        assert.equal(element._shownProjects.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      test('_paramsChanged', done => {
-        sandbox.stub(element.$.restAPI, 'getProjects', () => {
-          return Promise.resolve(projects);
-        });
-        const value = {
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(value).then(() => {
-          assert.isTrue(element.$.restAPI.getProjects.lastCall
-              .calledWithExactly('test', 25, 25));
-          done();
-        });
-      });
-    });
-
-    suite('loading', () => {
-      test('correct contents are displayed', () => {
-        assert.isTrue(element._loading);
-        assert.equal(element.computeLoadingClass(element._loading), 'loading');
-        assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-        element._loading = false;
-        element._projects = _.times(25, projectGenerator);
-
-        flushAsynchronousOperations();
-        assert.equal(element.computeLoadingClass(element._loading), '');
-        assert.equal(getComputedStyle(element.$.loading).display, 'none');
-      });
-    });
-
-    suite('create new', () => {
-      test('_handleCreateClicked called when create-click fired', () => {
-        sandbox.stub(element, '_handleCreateClicked');
-        element.$$('gr-list-view').fire('create-clicked');
-        assert.isTrue(element._handleCreateClicked.called);
-      });
-
-      test('_handleCreateClicked opens modal', () => {
-        const openStub = sandbox.stub(element.$.createOverlay, 'open');
-        element._handleCreateClicked();
-        assert.isTrue(openStub.called);
-      });
-
-      test('_handleCreateProject called when confirm fired', () => {
-        sandbox.stub(element, '_handleCreateProject');
-        element.$.createDialog.fire('confirm');
-        assert.isTrue(element._handleCreateProject.called);
-      });
-
-      test('_handleCloseCreate called when cancel fired', () => {
-        sandbox.stub(element, '_handleCloseCreate');
-        element.$.createDialog.fire('cancel');
-        assert.isTrue(element._handleCloseCreate.called);
-      });
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-project/gr-project.html b/polygerrit-ui/app/elements/admin/gr-project/gr-project.html
deleted file mode 100644
index 8ba27d9..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project/gr-project.html
+++ /dev/null
@@ -1,314 +0,0 @@
-<!--
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-
-<link rel="import" href="../../shared/gr-download-commands/gr-download-commands.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-project">
-  <template>
-    <style include="shared-styles">
-      main {
-        margin: 2em 1em;
-      }
-      h2.edited:after {
-        color: #444;
-        content: ' *';
-      }
-      .loading,
-      .hideDownload {
-        display: none;
-      }
-      #loading.loading {
-        display: block;
-      }
-      #loading:not(.loading) {
-        display: none;
-      }
-      .projectSettings {
-        display: none;
-      }
-      .projectSettings.showConfig {
-        display: block;
-      }
-    </style>
-    <style include="gr-form-styles"></style>
-    <main class="gr-form-styles read-only">
-      <h1 id="Title">[[project]]</h1>
-      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">Loading...</div>
-      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-        <div id="downloadContent" class$="[[_computeDownloadClass(_schemes)]]">
-          <h2 id="download">Download</h2>
-          <fieldset>
-            <gr-download-commands
-                id="downloadCommands"
-                commands="[[_computeCommands(project, _schemesObj, _selectedScheme)]]"
-                schemes="[[_schemes]]"
-                selected-scheme="{{_selectedScheme}}"></gr-download-commands>
-          </fieldset>
-        </div>
-        <h2 id="configurations"
-            class$="[[_computeHeaderClass(_configChanged)]]">Configurations</h2>
-        <div id="form">
-          <fieldset>
-            <h3 id="Description">Description</h3>
-            <fieldset>
-              <iron-autogrow-textarea
-                  id="descriptionInput"
-                  class="description"
-                  autocomplete="on"
-                  placeholder="<Insert project description here>"
-                  bind-value="{{_projectConfig.description}}"
-                  disabled$="[[_readOnly]]"></iron-autogrow-textarea>
-            </fieldset>
-            <h3 id="Options">Project Options</h3>
-            <fieldset id="options">
-              <section>
-                <span class="title">State</span>
-                <span class="value">
-                  <gr-select
-                      id="stateSelect"
-                      bind-value="{{_projectConfig.state}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat" items=[[_states]]>
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">Submit type</span>
-                <span class="value">
-                  <gr-select
-                      id="submitTypeSelect"
-                      bind-value="{{_projectConfig.submit_type}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat" items="[[_submitTypes]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">Allow content merges</span>
-                <span class="value">
-                  <gr-select
-                      id="contentMergeSelect"
-                      bind-value="{{_projectConfig.use_content_merge.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_projectConfig.use_content_merge)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">
-                  Create a new change for every commit not in the target branch
-                </span>
-                <span class="value">
-                  <gr-select
-                      id="newChangeSelect"
-                      bind-value="{{_projectConfig.create_new_change_for_all_not_in_target.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_projectConfig.create_new_change_for_all_not_in_target)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">Require Change-Id in commit message</span>
-                <span class="value">
-                  <gr-select
-                      id="requireChangeIdSelect"
-                      bind-value="{{_projectConfig.require_change_id.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_projectConfig.require_change_id)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section
-                   id="enableSignedPushSettings"
-                   class$="projectSettings [[_computeProjectsClass(_projectConfig.enable_signed_push)]]">
-                <span class="title">Enable signed push</span>
-                <span class="value">
-                  <gr-select
-                      id="enableSignedPush"
-                      bind-value="{{_projectConfig.enable_signed_push.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_projectConfig.enable_signed_push)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section
-                   id="requireSignedPushSettings"
-                   class$="projectSettings [[_computeProjectsClass(_projectConfig.require_signed_push)]]">
-                <span class="title">Require signed push</span>
-                <span class="value">
-                  <gr-select
-                      id="requireSignedPush"
-                      bind-value="{{_projectConfig.require_signed_push.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_projectConfig.require_signed_push)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">
-                  Reject implicit merges when changes are pushed for review</span>
-                <span class="value">
-                  <gr-select
-                      id="rejectImplicitMergesSelect"
-                      bind-value="{{_projectConfig.reject_implicit_merges.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_projectConfig.reject_implicit_merges)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section id="noteDbSettings" class$="projectSettings [[_computeProjectsClass(_noteDbEnabled)]]">
-                <span class="title">
-                  Enable adding unregistered users as reviewers and CCs on changes</span>
-                <span class="value">
-                  <gr-select
-                      id="unRegisteredCcSelect"
-                      bind-value="{{_projectConfig.enable_reviewer_by_email.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_projectConfig.enable_reviewer_by_email)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                  </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">
-                  Set all new changes private by default</span>
-                <span class="value">
-                  <gr-select
-                      id="setAllnewChangesPrivateByDefaultSelect"
-                      bind-value="{{_projectConfig.private_by_default.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_projectConfig.private_by_default)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">Maximum Git object size limit</span>
-                <span class="value">
-                  <input
-                      id="maxGitObjSizeInput"
-                      bind-value="{{_projectConfig.max_object_size_limit.configured_value}}"
-                      is="iron-input"
-                      type="text"
-                      disabled$="[[_readOnly]]">
-                </span>
-              </section>
-              <section>
-                <span class="title">Match authored date with committer date upon submit</span>
-                <span class="value">
-                  <gr-select
-                      id="matchAuthoredDateWithCommitterDateSelect"
-                      bind-value="{{_projectConfig.match_author_to_committer_date.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_projectConfig.match_author_to_committer_date)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                  </select>
-                  </gr-select>
-                </span>
-              </section>
-            </fieldset>
-            <h3 id="Options">Contributor Agreements</h3>
-            <fieldset id="agreements">
-              <section>
-                <span class="title">
-                  Require a valid contributor agreement to upload</span>
-                <span class="value">
-                  <gr-select
-                      id="contributorAgreementSelect"
-                      bind-value="{{_projectConfig.use_contributor_agreements.configured_value}}">
-                  <select disabled$="[[_readOnly]]">
-                    <template is="dom-repeat"
-                        items="[[_formatBooleanSelect(_projectConfig.use_contributor_agreements)]]">
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">Require Signed-off-by in commit message</span>
-                <span class="value">
-                  <gr-select
-                        id="useSignedOffBySelect"
-                        bind-value="{{_projectConfig.use_signed_off_by.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_projectConfig.use_signed_off_by)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-            </fieldset>
-            <!-- TODO @beckysiegel add plugin config widgets -->
-            <gr-button
-                on-tap="_handleSaveProjectConfig"
-                disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]">Save changes</gr-button>
-          </fieldset>
-        </div>
-      </div>
-    </main>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-project.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-project/gr-project.js b/polygerrit-ui/app/elements/admin/gr-project/gr-project.js
deleted file mode 100644
index bc28df1..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project/gr-project.js
+++ /dev/null
@@ -1,262 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-(function() {
-  'use strict';
-
-  const STATES = {
-    active: {value: 'ACTIVE', label: 'Active'},
-    readOnly: {value: 'READ_ONLY', label: 'Read Only'},
-    hidden: {value: 'HIDDEN', label: 'Hidden'},
-  };
-
-  const SUBMIT_TYPES = {
-    mergeIfNecessary: {
-      value: 'MERGE_IF_NECESSARY',
-      label: 'Merge if necessary',
-    },
-    fastForwardOnly: {
-      value: 'FAST_FORWARD_ONLY',
-      label: 'Fast forward only',
-    },
-    rebaseAlways: {
-      value: 'REBASE_ALWAYS',
-      label: 'Rebase Always',
-    },
-    rebaseIfNecessary: {
-      value: 'REBASE_IF_NECESSARY',
-      label: 'Rebase if necessary',
-    },
-    mergeAlways: {
-      value: 'MERGE_ALWAYS',
-      label: 'Merge always',
-    },
-    cherryPick: {
-      value: 'CHERRY_PICK',
-      label: 'Cherry pick',
-    },
-  };
-
-  Polymer({
-    is: 'gr-project',
-
-    properties: {
-      params: Object,
-      project: String,
-
-      _configChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-        observer: '_loggedInChanged',
-      },
-      /** @type {?} */
-      _projectConfig: Object,
-      _readOnly: {
-        type: Boolean,
-        value: true,
-      },
-      _states: {
-        type: Array,
-        value() {
-          return Object.values(STATES);
-        },
-      },
-      _submitTypes: {
-        type: Array,
-        value() {
-          return Object.values(SUBMIT_TYPES);
-        },
-      },
-      _schemes: {
-        type: Array,
-        value() { return []; },
-        computed: '_computeSchemes(_schemesObj)',
-        observer: '_schemesChanged',
-      },
-      _selectedCommand: {
-        type: String,
-        value: 'Clone',
-      },
-      _selectedScheme: String,
-      _schemesObj: Object,
-      _noteDbEnabled: {
-        type: Boolean,
-        value: false,
-      },
-    },
-
-    observers: [
-      '_handleConfigChanged(_projectConfig.*)',
-    ],
-
-    attached() {
-      this._loadProject();
-
-      this.fire('title-change', {title: this.project});
-    },
-
-    _loadProject() {
-      if (!this.project) { return Promise.resolve(); }
-
-      const promises = [];
-      promises.push(this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-        if (loggedIn) {
-          this.$.restAPI.getProjectAccess(this.project).then(access => {
-            // If the user is not an owner, is_owner is not a property.
-            this._readOnly = !access[this.project].is_owner;
-          });
-        }
-      }));
-
-      promises.push(this.$.restAPI.getProjectConfig(this.project).then(
-          config => {
-            if (!config.state) {
-              config.state = STATES.active.value;
-            }
-            this._projectConfig = config;
-            this._loading = false;
-          }));
-
-      promises.push(this.$.restAPI.getConfig().then(config => {
-        this._schemesObj = config.download.schemes;
-        this._noteDbEnabled = !!config.note_db_enabled;
-      }));
-
-      return Promise.all(promises);
-    },
-
-    _computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    },
-
-    _computeDownloadClass(schemes) {
-      return !schemes || !schemes.length ? 'hideDownload' : '';
-    },
-
-    _loggedInChanged(_loggedIn) {
-      if (!_loggedIn) { return; }
-      this.$.restAPI.getPreferences().then(prefs => {
-        if (prefs.download_scheme) {
-          // Note (issue 5180): normalize the download scheme with lower-case.
-          this._selectedScheme = prefs.download_scheme.toLowerCase();
-        }
-      });
-    },
-
-    _formatBooleanSelect(item) {
-      if (!item) { return; }
-      let inheritLabel = 'Inherit';
-      if (item.inherited_value) {
-        inheritLabel = `Inherit (${item.inherited_value})`;
-      }
-      return [
-        {
-          label: inheritLabel,
-          value: 'INHERIT',
-        },
-        {
-          label: 'True',
-          value: 'TRUE',
-        }, {
-          label: 'False',
-          value: 'FALSE',
-        },
-      ];
-    },
-
-    _isLoading() {
-      return this._loading || this._loading === undefined;
-    },
-
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    },
-
-    _formatProjectConfigForSave(p) {
-      const configInputObj = {};
-      for (const key in p) {
-        if (p.hasOwnProperty(key)) {
-          if (typeof p[key] === 'object') {
-            configInputObj[key] = p[key].configured_value;
-          } else {
-            configInputObj[key] = p[key];
-          }
-        }
-      }
-      return configInputObj;
-    },
-
-    _handleSaveProjectConfig() {
-      return this.$.restAPI.saveProjectConfig(this.project,
-          this._formatProjectConfigForSave(this._projectConfig)).then(() => {
-            this._configChanged = false;
-          });
-    },
-
-    _handleConfigChanged() {
-      if (this._isLoading()) { return; }
-      this._configChanged = true;
-    },
-
-    _computeButtonDisabled(readOnly, configChanged) {
-      return readOnly || !configChanged;
-    },
-
-    _computeHeaderClass(configChanged) {
-      return configChanged ? 'edited' : '';
-    },
-
-    _computeSchemes(schemesObj) {
-      return Object.keys(schemesObj);
-    },
-
-    _schemesChanged(schemes) {
-      if (schemes.length === 0) { return; }
-      if (!schemes.includes(this._selectedScheme)) {
-        this._selectedScheme = schemes.sort()[0];
-      }
-    },
-
-    _computeCommands(project, schemesObj, _selectedScheme) {
-      const commands = [];
-      let commandObj;
-      if (schemesObj.hasOwnProperty(_selectedScheme)) {
-        commandObj = schemesObj[_selectedScheme].clone_commands;
-      }
-      for (const title in commandObj) {
-        if (!commandObj.hasOwnProperty(title)) { continue; }
-        commands.push({
-          title,
-          command: commandObj[title]
-              .replace('${project}', project)
-              .replace('${project-base-name}',
-              project.substring(project.lastIndexOf('/') + 1)),
-        });
-      }
-      return commands;
-    },
-
-    _computeProjectsClass(config) {
-      return config ? 'showConfig': '';
-    },
-  });
-})();
diff --git a/polygerrit-ui/app/elements/admin/gr-project/gr-project_test.html b/polygerrit-ui/app/elements/admin/gr-project/gr-project_test.html
deleted file mode 100644
index 8013852..0000000
--- a/polygerrit-ui/app/elements/admin/gr-project/gr-project_test.html
+++ /dev/null
@@ -1,326 +0,0 @@
-<!DOCTYPE html>
-<!--
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-project</title>
-
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-project.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-project></gr-project>
-  </template>
-</test-fixture>
-
-<script>
-  suite('gr-project tests', () => {
-    let element;
-    let sandbox;
-    const PROJECT = 'test-project';
-    const SCHEMES = {http: {}, repo: {}, ssh: {}};
-
-    function getFormFields() {
-      const selects = Polymer.dom(element.root).querySelectorAll('select');
-      const textareas =
-          Polymer.dom(element.root).querySelectorAll('iron-autogrow-textarea');
-      const inputs = Polymer.dom(element.root).querySelectorAll('input');
-      return inputs.concat(textareas).concat(selects);
-    }
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-        getProjectConfig() {
-          return Promise.resolve({
-            description: 'Access inherited by all other projects.',
-            use_contributor_agreements: {
-              value: false,
-              configured_value: 'FALSE',
-            },
-            use_content_merge: {
-              value: false,
-              configured_value: 'FALSE',
-            },
-            use_signed_off_by: {
-              value: false,
-              configured_value: 'FALSE',
-            },
-            create_new_change_for_all_not_in_target: {
-              value: false,
-              configured_value: 'FALSE',
-            },
-            require_change_id: {
-              value: false,
-              configured_value: 'FALSE',
-            },
-            enable_signed_push: {
-              value: false,
-              configured_value: 'FALSE',
-            },
-            require_signed_push: {
-              value: false,
-              configured_value: 'FALSE',
-            },
-            reject_implicit_merges: {
-              value: false,
-              configured_value: 'FALSE',
-            },
-            private_by_default: {
-              value: false,
-              configured_value: 'FALSE',
-            },
-            match_author_to_committer_date: {
-              value: false,
-              configured_value: 'FALSE',
-            },
-            enable_reviewer_by_email: {
-              value: false,
-              configured_value: 'FALSE',
-            },
-            max_object_size_limit: {},
-            submit_type: 'MERGE_IF_NECESSARY',
-          });
-        },
-        getConfig() {
-          return Promise.resolve({download: {}});
-        },
-      });
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('loading displays before project config is loaded', () => {
-      assert.isTrue(element.$.loading.classList.contains('loading'));
-      assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
-      assert.isTrue(element.$.loadedContent.classList.contains('loading'));
-      assert.isTrue(getComputedStyle(element.$.loadedContent)
-          .display === 'none');
-    });
-
-    test('download commands visibility', () => {
-      element._loading = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.downloadContent.classList
-          .contains('hideDownload'));
-      assert.isTrue(getComputedStyle(element.$.downloadContent)
-          .display == 'none');
-      element._schemesObj = SCHEMES;
-      flushAsynchronousOperations();
-      assert.isFalse(element.$.downloadContent.classList
-          .contains('hideDownload'));
-      assert.isFalse(getComputedStyle(element.$.downloadContent)
-          .display == 'none');
-    });
-
-    test('form defaults to read only', () => {
-      assert.isTrue(element._readOnly);
-    });
-
-    test('form defaults to read only when not logged in', done => {
-      element.project = PROJECT;
-      element._loadProject().then(() => {
-        assert.isTrue(element._readOnly);
-        done();
-      });
-    });
-
-    test('form defaults to read only when logged in and not admin', done => {
-      element.project = PROJECT;
-      sandbox.stub(element, '_getLoggedIn', () => {
-        return Promise.resolve(true);
-      });
-      sandbox.stub(element.$.restAPI, 'getProjectAccess', () => {
-        return Promise.resolve({'test-project': {}});
-      });
-      element._loadProject().then(() => {
-        assert.isTrue(element._readOnly);
-        done();
-      });
-    });
-
-    test('all form elements are disabled when not admin', done => {
-      element.project = PROJECT;
-      element._loadProject().then(() => {
-        flushAsynchronousOperations();
-        const formFields = getFormFields();
-        for (const field of formFields) {
-          assert.isTrue(field.hasAttribute('disabled'));
-        }
-        done();
-      });
-    });
-
-    test('_formatBooleanSelect', () => {
-      let item = {inherited_value: 'true'};
-      assert.deepEqual(element._formatBooleanSelect(item), [
-        {
-          label: 'Inherit (true)',
-          value: 'INHERIT',
-        },
-        {
-          label: 'True',
-          value: 'TRUE',
-        }, {
-          label: 'False',
-          value: 'FALSE',
-        },
-      ]);
-
-      // For items without inherited values
-      item = {};
-      assert.deepEqual(element._formatBooleanSelect(item), [
-        {
-          label: 'Inherit',
-          value: 'INHERIT',
-        },
-        {
-          label: 'True',
-          value: 'TRUE',
-        }, {
-          label: 'False',
-          value: 'FALSE',
-        },
-      ]);
-    });
-
-    suite('admin', () => {
-      setup(() => {
-        element.project = PROJECT;
-        sandbox.stub(element, '_getLoggedIn', () => {
-          return Promise.resolve(true);
-        });
-        sandbox.stub(element.$.restAPI, 'getProjectAccess', () => {
-          return Promise.resolve({'test-project': {is_owner: true}});
-        });
-      });
-
-      test('all form elements are enabled', done => {
-        element._loadProject().then(() => {
-          flushAsynchronousOperations();
-          const formFields = getFormFields();
-          for (const field of formFields) {
-            assert.isFalse(field.hasAttribute('disabled'));
-          }
-          assert.isFalse(element._loading);
-          done();
-        });
-      });
-
-      test('state gets set correctly', done => {
-        element._loadProject().then(() => {
-          assert.equal(element._projectConfig.state, 'ACTIVE');
-          assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
-          done();
-        });
-      });
-
-      test('fields update and save correctly', done => {
-        // test notedb
-        element._noteDbEnabled = false;
-
-        assert.equal(
-            element._computeProjectsClass(element._noteDbEnabled), '');
-
-        element._noteDbEnabled = true;
-
-        assert.equal(
-            element._computeProjectsClass(element._noteDbEnabled), 'showConfig');
-
-        const configInputObj = {
-          description: 'new description',
-          use_contributor_agreements: 'TRUE',
-          use_content_merge: 'TRUE',
-          use_signed_off_by: 'TRUE',
-          create_new_change_for_all_not_in_target: 'TRUE',
-          require_change_id: 'TRUE',
-          enable_signed_push: 'TRUE',
-          require_signed_push: 'TRUE',
-          reject_implicit_merges: 'TRUE',
-          private_by_default: 'TRUE',
-          match_author_to_committer_date: 'TRUE',
-          max_object_size_limit: 10,
-          submit_type: 'FAST_FORWARD_ONLY',
-          state: 'READ_ONLY',
-          enable_reviewer_by_email: 'TRUE',
-        };
-
-        const saveStub = sandbox.stub(element.$.restAPI, 'saveProjectConfig'
-            , () => {
-              return Promise.resolve({});
-            });
-
-        const button = Polymer.dom(element.root).querySelector('gr-button');
-
-        element._loadProject().then(() => {
-          assert.isTrue(button.hasAttribute('disabled'));
-          assert.isFalse(element.$.Title.classList.contains('edited'));
-          element.$.descriptionInput.bindValue = configInputObj.description;
-          element.$.stateSelect.bindValue = configInputObj.state;
-          element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
-          element.$.contentMergeSelect.bindValue =
-              configInputObj.use_content_merge;
-          element.$.newChangeSelect.bindValue =
-              configInputObj.create_new_change_for_all_not_in_target;
-          element.$.requireChangeIdSelect.bindValue =
-              configInputObj.require_change_id;
-          element.$.enableSignedPush.bindValue =
-              configInputObj.enable_signed_push;
-          element.$.requireSignedPush.bindValue =
-              configInputObj.require_signed_push;
-          element.$.rejectImplicitMergesSelect.bindValue =
-              configInputObj.reject_implicit_merges;
-          element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
-              configInputObj.private_by_default;
-          element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
-              configInputObj.match_author_to_committer_date;
-          element.$.maxGitObjSizeInput.bindValue =
-              configInputObj.max_object_size_limit;
-          element.$.contributorAgreementSelect.bindValue =
-              configInputObj.use_contributor_agreements;
-          element.$.useSignedOffBySelect.bindValue =
-              configInputObj.use_signed_off_by;
-          element.$.unRegisteredCcSelect.bindValue =
-              configInputObj.enable_reviewer_by_email;
-
-          assert.isFalse(button.hasAttribute('disabled'));
-          assert.isTrue(element.$.configurations.classList.contains('edited'));
-
-          const formattedObj =
-              element._formatProjectConfigForSave(element._projectConfig);
-          assert.deepEqual(formattedObj, configInputObj);
-
-          element._handleSaveProjectConfig().then(() => {
-            assert.isTrue(button.hasAttribute('disabled'));
-            assert.isFalse(element.$.Title.classList.contains('edited'));
-            assert.isTrue(saveStub.lastCall.calledWithExactly(PROJECT,
-                configInputObj));
-            done();
-          });
-        });
-      });
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
new file mode 100644
index 0000000..0494e2c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
@@ -0,0 +1,87 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+
+<link rel="import" href="../../../styles/gr-menu-page-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-access-section/gr-access-section.html">
+
+<script src="../../../scripts/util.js"></script>
+
+<dom-module id="gr-repo-access">
+  <template>
+    <style include="shared-styles">
+      .gwtLink {
+        margin-bottom: 1em;
+      }
+      .gwtLink {
+        display: none;
+      }
+      .admin .gwtLink {
+        display: block;
+      }
+      gr-button {
+        display: none;
+      }
+      .admin gr-button.visible {
+        display: inline-block;
+        margin: 1em 0;
+      }
+    </style>
+    <style include="gr-menu-page-styles"></style>
+    <main class$="[[_computeAdminClass(_isAdmin)]]">
+      <div class="gwtLink">Editing access in the new UI is a work in progress. Visit the
+        <a href$="[[computeGwtUrl(path)]]" rel="external">Old UI</a>
+        if you need a feature that is not yet supported.
+      </div>
+      <template is="dom-if" if="[[_inheritsFrom]]">
+        <h3 id="inheritsFrom">Rights Inherit From
+          <a href$="[[_computeParentHref(_inheritsFrom.name)]]" rel="noopener">
+              [[_inheritsFrom.name]]</a>
+        </h3>
+      </template>
+      <gr-button id="editBtn"
+          class$="[[_computeShowEditClass(_sections)]]"
+          on-tap="_handleEdit">[[_editOrCancel(_editing)]]</gr-button>
+      <gr-button id="saveBtn"
+          primary
+          class$="[[_computeShowSaveClass(_editing)]]"
+          on-tap="_handleSaveForReview"
+          disabled$="[[!_modified]]">
+            Save for review</gr-button>
+      <template
+          is="dom-repeat"
+          items="{{_sections}}"
+          as="section">
+        <gr-access-section
+            capabilities="[[_capabilities]]"
+            section="{{section}}"
+            labels="[[_labels]]"
+            editing="[[_editing]]"
+            groups="[[_groups]]"></gr-access-section>
+      </template>
+    </main>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-repo-access.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
new file mode 100644
index 0000000..d903404
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
@@ -0,0 +1,248 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  const Defs = {};
+
+  /**
+   * @typedef {{
+   *    value: !Object,
+   * }}
+   */
+  Defs.rule;
+
+  /**
+   * @typedef {{
+   *    rules: !Object<string, Defs.rule>
+   * }}
+   */
+  Defs.permission;
+
+  /**
+   * Can be an empty object or consist of permissions.
+   *
+   * @typedef {{
+   *    permissions: !Object<string, Defs.permission>
+   * }}
+   */
+  Defs.permissions;
+
+  /**
+   * Can be an empty object or consist of permissions.
+   *
+   * @typedef {Object<string, Defs.permissions>}
+   */
+  Defs.sections;
+
+  /**
+   * @typedef {{
+   *    remove: Defs.sections,
+   *    add: Defs.sections,
+   * }}
+   */
+  Defs.projectAccessInput;
+
+
+  Polymer({
+    is: 'gr-repo-access',
+
+    properties: {
+      repo: {
+        type: String,
+        observer: '_repoChanged',
+      },
+      // The current path
+      path: String,
+
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
+      _capabilities: Object,
+      _groups: Object,
+      /** @type {?} */
+      _inheritsFrom: Object,
+      _labels: Object,
+      _local: Object,
+      _editing: {
+        type: Boolean,
+        value: false,
+      },
+      _modified: {
+        type: Boolean,
+        value: false,
+      },
+      _sections: Array,
+    },
+
+    behaviors: [
+      Gerrit.AccessBehavior,
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    listeners: {
+      'access-modified': '_handleAccessModified',
+    },
+
+    _handleAccessModified() {
+      this._modified = true;
+    },
+
+    /**
+     * @param {string} repo
+     * @return {!Promise}
+     */
+    _repoChanged(repo) {
+      if (!repo) { return Promise.resolve(); }
+      const promises = [];
+      // Always reset sections when a project changes.
+      this._sections = [];
+      promises.push(this.$.restAPI.getRepoAccessRights(repo).then(res => {
+        this._inheritsFrom = res.inherits_from;
+        this._local = res.local;
+        this._groups = res.groups;
+        return this.toSortedArray(this._local);
+      }));
+
+      promises.push(this.$.restAPI.getCapabilities().then(res => {
+        return res;
+      }));
+
+      promises.push(this.$.restAPI.getRepo(repo).then(res => {
+        return res.labels;
+      }));
+
+      promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
+        this._isAdmin = isAdmin;
+      }));
+
+      return Promise.all(promises).then(([sections, capabilities, labels]) => {
+        this._capabilities = capabilities;
+        this._labels = labels;
+        this._sections = sections;
+      });
+    },
+
+    _handleEdit() {
+      this._editing = !this._editing;
+    },
+
+    _editOrCancel(editing) {
+      return editing ? 'Cancel' : 'Edit';
+    },
+
+    /**
+     * @param {!Defs.projectAccessInput} addRemoveObj
+     * @param {!Array} path
+     * @param {string} type add or remove
+     * @param {!Object=} opt_value value to add if the type is 'add'
+     * @return {!Defs.projectAccessInput}
+     */
+    _updateAddRemoveObj(addRemoveObj, path, type, opt_value) {
+      let curPos = addRemoveObj[type];
+      for (const item of path) {
+        if (!curPos[item]) {
+          if (item === path[path.length - 1] && type === 'remove') {
+            // TODO(beckysiegel) This if statement should be removed when
+            // https://gerrit-review.googlesource.com/c/gerrit/+/150851
+            // is live.
+            if (path[path.length - 2] === 'permissions') {
+              curPos[item] = {rules: {}};
+            } else {
+              curPos[item] = null;
+            }
+          } else if (item === path[path.length - 1] && type === 'add') {
+            curPos[item] = opt_value;
+          } else {
+            curPos[item] = {};
+          }
+        }
+        curPos = curPos[item];
+      }
+      return addRemoveObj;
+    },
+
+    _recursivelyUpdateAddRemoveObj(obj, addRemoveObj, path = []) {
+      for (const k in obj) {
+        if (!obj.hasOwnProperty(k)) { return; }
+        if (typeof obj[k] == 'object') {
+          if (obj[k].deleted) {
+            this._updateAddRemoveObj(addRemoveObj,
+                path.concat(k), 'remove');
+            continue;
+          } else if (obj[k].modified) {
+            this._updateAddRemoveObj(addRemoveObj,
+                path.concat(k), 'remove');
+            this._updateAddRemoveObj(addRemoveObj,
+                path.concat(k), 'add', obj[k]);
+          } else if (obj[k].added) {
+            this._updateAddRemoveObj(addRemoveObj,
+                path.concat(k), 'add', obj[k]);
+          }
+          this._recursivelyUpdateAddRemoveObj(obj[k], addRemoveObj,
+              path.concat(k));
+        }
+      }
+    },
+
+    /**
+     * Returns an object formatted for saving or submitting access changes for
+     * review
+     *
+     * @return {!Defs.projectAccessInput}
+     */
+    _computeAddAndRemove() {
+      const addRemoveObj = {
+        add: {},
+        remove: {},
+      };
+
+      this._recursivelyUpdateAddRemoveObj(this._local, addRemoveObj);
+      return addRemoveObj;
+    },
+
+    _handleSaveForReview() {
+      // Use saving rather than editing here because rules have to handle
+      // save prior to toggling editing.
+      const addRemoveObj = this._computeAddAndRemove();
+      return this.$.restAPI.setProjectAccessRightsForReview(this.repo, {
+        add: addRemoveObj.add,
+        remove: addRemoveObj.remove,
+      }).then(change => {
+        Gerrit.Nav.navigateToChange(change);
+      });
+    },
+
+    _computeShowEditClass(sections) {
+      if (!sections.length) { return ''; }
+      return 'visible';
+    },
+
+    _computeShowSaveClass(editing) {
+      if (!editing) { return ''; }
+      return 'visible';
+    },
+
+    _computeAdminClass(isAdmin) {
+      return isAdmin ? 'admin' : '';
+    },
+
+    _computeParentHref(repoName) {
+      return this.getBaseUrl() +
+          `/admin/repos/${this.encodeURL(repoName, true)},access`;
+    },
+  });
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
new file mode 100644
index 0000000..b26121c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
@@ -0,0 +1,444 @@
+<!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-repo-access</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-repo-access.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-repo-access></gr-repo-access>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-repo-access tests', () => {
+    let element;
+    let sandbox;
+
+    const accessRes = {
+      local: {
+        'refs/*': {
+          permissions: {
+            owner: {
+              rules: {
+                234: {action: 'ALLOW'},
+                123: {action: 'DENY'},
+              },
+            },
+            read: {
+              rules: {
+                234: {action: 'ALLOW'},
+              },
+            },
+          },
+        },
+      },
+    };
+    const accessRes2 = {
+      local: {
+        GLOBAL_CAPABILITIES: {
+          permissions: {
+            accessDatabase: {
+              rules: {
+                group1: {
+                  action: 'ALLOW',
+                },
+              },
+            },
+          },
+        },
+      },
+    };
+    const repoRes = {
+      labels: {
+        'Code-Review': {},
+      },
+    };
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_repoChanged called when repo name changes', () => {
+      sandbox.stub(element, '_repoChanged');
+      element.repo = 'New Repo';
+      assert.isTrue(element._repoChanged.called);
+    });
+
+    test('_repoChanged', done => {
+      const capabilitiesRes = {
+        accessDatabase: {
+          id: 'accessDatabase',
+          name: 'Access Database',
+        },
+        createAccount: {
+          id: 'createAccount',
+          name: 'Create Account',
+        },
+      };
+      const accessStub = sandbox.stub(element.$.restAPI,
+          'getRepoAccessRights');
+
+      accessStub.withArgs('New Repo').returns(
+          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+      accessStub.withArgs('Another New Repo')
+          .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
+      const capabilitiesStub = sandbox.stub(element.$.restAPI,
+          'getCapabilities');
+      capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
+      const repoStub = sandbox.stub(element.$.restAPI, 'getRepo').returns(
+          Promise.resolve(repoRes));
+      const adminStub = sandbox.stub(element.$.restAPI, 'getIsAdmin').returns(
+          Promise.resolve(true));
+
+      element._repoChanged('New Repo').then(() => {
+        assert.isTrue(accessStub.called);
+        assert.isTrue(capabilitiesStub.called);
+        assert.isTrue(repoStub.called);
+        assert.isTrue(adminStub.called);
+        assert.isNotOk(element._inheritsFrom);
+        assert.deepEqual(element._local, accessRes.local);
+        assert.deepEqual(element._sections,
+            element.toSortedArray(accessRes.local));
+        assert.deepEqual(element._labels, repoRes.labels);
+        return element._repoChanged('Another New Repo');
+      })
+          .then(() => {
+            assert.deepEqual(element._sections,
+                element.toSortedArray(accessRes2.local));
+            done();
+          });
+    });
+
+    test('_repoChanged when repo changes to undefined returns', done => {
+      const capabilitiesRes = {
+        accessDatabase: {
+          id: 'accessDatabase',
+          name: 'Access Database',
+        },
+      };
+      const repoRes = {
+        labels: {
+          'Code-Review': {},
+        },
+      };
+      const accessStub = sandbox.stub(element.$.restAPI, 'getRepoAccessRights')
+          .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
+      const capabilitiesStub = sandbox.stub(element.$.restAPI,
+          'getCapabilities').returns(Promise.resolve(capabilitiesRes));
+      const repoStub = sandbox.stub(element.$.restAPI, 'getRepo').returns(
+          Promise.resolve(repoRes));
+
+      element._repoChanged().then(() => {
+        assert.isFalse(accessStub.called);
+        assert.isFalse(capabilitiesStub.called);
+        assert.isFalse(repoStub.called);
+        done();
+      });
+    });
+
+    test('_computeParentHref', () => {
+      const repoName = 'test-repo';
+      assert.equal(element._computeParentHref(repoName),
+          '/admin/repos/test-repo,access');
+    });
+
+    test('_computeAdminClass', () => {
+      let isAdmin = true;
+      assert.equal(element._computeAdminClass(isAdmin), 'admin');
+      isAdmin = false;
+      assert.equal(element._computeAdminClass(isAdmin), '');
+    });
+
+    test('inherit section', () => {
+      sandbox.stub(element, '_computeParentHref');
+      assert.isNotOk(Polymer.dom(element.root).querySelector('#inheritsFrom'));
+      assert.isFalse(element._computeParentHref.called);
+      element._inheritsFrom = {
+        name: 'another-repo',
+      };
+      flushAsynchronousOperations();
+      assert.isOk(Polymer.dom(element.root).querySelector('#inheritsFrom'));
+      assert.isTrue(element._computeParentHref.called);
+    });
+
+    suite('with defined sections', () => {
+      setup(() => {
+        element._sections =
+            element.toSortedArray(JSON.parse(JSON.stringify(accessRes.local)));
+        flushAsynchronousOperations();
+      });
+
+      test('button visibility for non admin', () => {
+        assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
+        assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
+      });
+
+      test('button visibility for admin', () => {
+        element._isAdmin = true;
+
+        // Edit button is visible and Save button is hidden.
+        assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
+        assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
+        assert.equal(element.$.editBtn.innerText, 'EDIT');
+
+        MockInteractions.tap(element.$.editBtn);
+
+        // Edit button changes to Cancel button, and Save button is visible but
+        // disabled.
+        assert.equal(element.$.editBtn.innerText, 'CANCEL');
+        assert.notEqual(getComputedStyle(element.$.saveBtn).display, 'none');
+        assert.isTrue(element.$.saveBtn.disabled);
+
+        // Save button should be enabled after access is modified
+        element.fire('access-modified');
+        assert.isFalse(element.$.saveBtn.disabled);
+      });
+
+      test('_handleAccessModified called with event fired', () => {
+        sandbox.spy(element, '_handleAccessModified');
+        element.fire('access-modified');
+        assert.isTrue(element._handleAccessModified.called);
+      });
+
+      test('_computeAddAndRemove rules', () => {
+        element._local = JSON.parse(JSON.stringify(accessRes.local));
+        assert.deepEqual(element._computeAddAndRemove(), {add: {}, remove: {}});
+        element._local['refs/*'].permissions.owner.rules[123].deleted = true;
+        let expectedInput = {
+          add: {},
+          remove: {
+            'refs/*': {
+              permissions: {
+                owner: {
+                  rules: {
+                    123: null,
+                  },
+                },
+              },
+            },
+          },
+        };
+        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+        delete element._local['refs/*'].permissions.owner.rules[123].deleted;
+        element._local['refs/*'].permissions.owner.rules[123].modified = true;
+        expectedInput = {
+          add: {
+            'refs/*': {
+              permissions: {
+                owner: {
+                  rules: {
+                    123: {action: 'DENY', modified: true},
+                  },
+                },
+              },
+            },
+          },
+          remove: {
+            'refs/*': {
+              permissions: {
+                owner: {
+                  rules: {
+                    123: null,
+                  },
+                },
+              },
+            },
+          },
+        };
+        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      });
+
+      test('_computeAddAndRemove permissions', () => {
+        element._local = JSON.parse(JSON.stringify(accessRes.local));
+        assert.deepEqual(element._computeAddAndRemove(), {add: {}, remove: {}});
+        element._local['refs/*'].permissions.owner.deleted = true;
+        let expectedInput = {
+          add: {},
+          remove: {
+            'refs/*': {
+              permissions: {
+                owner: {rules: {}},
+              },
+            },
+          },
+        };
+        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+        delete element._local['refs/*'].permissions.owner.deleted;
+        element._local['refs/*'].permissions.owner.modified = true;
+        expectedInput = {
+          add: {
+            'refs/*': {
+              permissions: {
+                owner: {
+                  modified: true,
+                  rules: {
+                    234: {action: 'ALLOW'},
+                    123: {action: 'DENY'},
+                  },
+                },
+              },
+            },
+          },
+          remove: {
+            'refs/*': {
+              permissions: {
+                owner: {rules: {}},
+              },
+            },
+          },
+        };
+        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      });
+
+      test('_computeAddAndRemove combinations', () => {
+        // Modify rule and delete permission that it is inside of.
+        element._local = JSON.parse(JSON.stringify(accessRes.local));
+        element._local['refs/*'].permissions.owner.rules[123].modified = true;
+        element._local['refs/*'].permissions.owner.deleted = true;
+        let expectedInput = {
+          add: {},
+          remove: {
+            'refs/*': {
+              permissions: {
+                owner: {rules: {}},
+              },
+            },
+          },
+        };
+        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+        // Delete rule and delete permission that it is inside of.
+        element._local['refs/*'].permissions.owner.rules[123].modified = false;
+        element._local['refs/*'].permissions.owner.rules[123].deleted = true;
+        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+        // Also modify a different rule inside of another permission.
+        element._local['refs/*'].permissions.read.modified = true;
+        expectedInput = {
+          add: {
+            'refs/*': {
+              permissions: {
+                read: {
+                  modified: true,
+                  rules: {
+                    234: {action: 'ALLOW'},
+                  },
+                },
+              },
+            },
+          },
+          remove: {
+            'refs/*': {
+              permissions: {
+                owner: {rules: {}},
+                read: {rules: {}},
+              },
+            },
+          },
+        };
+        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+        // Modify both permissions with an exclusive bit. Owner is still
+        // deleted.
+        element._local['refs/*'].permissions.owner.exclusive = true;
+        element._local['refs/*'].permissions.owner.modified = true;
+        element._local['refs/*'].permissions.read.exclusive = true;
+        element._local['refs/*'].permissions.read.modified = true;
+        expectedInput = {
+          add: {
+            'refs/*': {
+              permissions: {
+                read: {
+                  exclusive: true,
+                  modified: true,
+                  rules: {
+                    234: {action: 'ALLOW'},
+                  },
+                },
+              },
+            },
+          },
+          remove: {
+            'refs/*': {
+              permissions: {
+                owner: {rules: {}},
+                read: {rules: {}},
+              },
+            },
+          },
+        };
+        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      });
+
+      test('_handleSaveForReview', done => {
+        const repoAccessInput = {
+          add: {
+            'refs/*': {
+              permissions: {
+                owner: {
+                  rules: {
+                    123: {action: 'DENY', modified: true},
+                  },
+                },
+              },
+            },
+          },
+          remove: {
+            'refs/*': {
+              permissions: {
+                owner: {
+                  rules: {
+                    123: null,
+                  },
+                },
+              },
+            },
+          },
+        };
+        sandbox.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+            Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+        sandbox.stub(element.$.restAPI, 'getRepo')
+            .returns(Promise.resolve({}));
+        sandbox.stub(Gerrit.Nav, 'navigateToChange');
+        const saveForReviewStub = sandbox.stub(element.$.restAPI,
+            'setProjectAccessRightsForReview')
+            .returns(Promise.resolve({_number: 1}));
+
+        element.repo = 'test-repo';
+        sandbox.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
+
+        element._handleSaveForReview().then(() => {
+          assert.isTrue(saveForReviewStub.called);
+          assert.isTrue(Gerrit.Nav.navigateToChange
+              .lastCall.calledWithExactly({_number: 1}));
+          done();
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html
new file mode 100644
index 0000000..0b065bc
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-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-repo-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-repo-command.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
new file mode 100644
index 0000000..ce9655c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-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-repo-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-repo-command/gr-repo-command_test.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
new file mode 100644
index 0000000..5ec2ef9
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-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-repo-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-repo-command.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-repo-command></gr-repo-command>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-repo-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-repo-commands/gr-repo-commands.html b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
new file mode 100644
index 0000000..002b76b
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
@@ -0,0 +1,94 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.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-repo-command/gr-repo-command.html">
+
+<dom-module id="gr-repo-commands">
+  <template>
+    <style include="shared-styles">
+      main {
+        margin: 2em 1em;
+      }
+      .loading {
+        display: none;
+      }
+      #loading.loading {
+        display: block;
+      }
+      #loading:not(.loading) {
+        display: none;
+      }
+    </style>
+    <style include="gr-form-styles"></style>
+    <main class="gr-form-styles read-only">
+      <h1 id="Title">Repository Commands</h1>
+      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">Loading...</div>
+      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+        <h2 id="options">Command</h2>
+        <div id="form">
+          <gr-repo-command
+              title="Create Change"
+              on-command-tap="_createNewChange">
+          </gr-repo-command>
+
+          <gr-repo-command
+              title="Run GC"
+              hidden$="[[!_repoConfig.actions.gc.enabled]]"
+              on-command-tap="_handleRunningGC">
+          </gr-repo-command>
+
+          <gr-endpoint-decorator name="repo-command">
+            <gr-endpoint-param name="config" value="[[_repoConfig]]">
+            </gr-endpoint-param>
+            <gr-endpoint-param name="repoName" value="[[repo]]">
+            </gr-endpoint-param>
+          </gr-endpoint-decorator>
+        </div>
+      </div>
+    </main>
+    <gr-overlay id="createChangeOverlay" with-backdrop>
+      <gr-confirm-dialog
+          id="createChangeDialog"
+          confirm-label="Create"
+          disabled="[[!_canCreate]]"
+          on-confirm="_handleCreateChange"
+          on-cancel="_handleCloseCreateChange">
+        <div class="header" slot="header">
+          Create Change
+        </div>
+        <div class="main" slot="main">
+          <gr-create-change-dialog
+              id="createNewChangeModal"
+              can-create="{{_canCreate}}"
+              repo-name="[[repo]]"></gr-create-change-dialog>
+        </div>
+      </gr-confirm-dialog>
+    </gr-overlay>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-repo-commands.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
new file mode 100644
index 0000000..be5910c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
@@ -0,0 +1,80 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  const GC_MESSAGE = 'Garbage collection completed successfully.';
+
+  Polymer({
+    is: 'gr-repo-commands',
+
+    properties: {
+      params: Object,
+      repo: String,
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      /** @type {?} */
+      _repoConfig: Object,
+      _canCreate: Boolean,
+    },
+
+    attached() {
+      this._loadRepo();
+
+      this.fire('title-change', {title: 'Repo Commands'});
+    },
+
+    _loadRepo() {
+      if (!this.repo) { return Promise.resolve(); }
+
+      return this.$.restAPI.getProjectConfig(this.repo).then(
+          config => {
+            this._repoConfig = config;
+            this._loading = false;
+          });
+    },
+
+    _computeLoadingClass(loading) {
+      return loading ? 'loading' : '';
+    },
+
+    _isLoading() {
+      return this._loading || this._loading === undefined;
+    },
+
+    _handleRunningGC() {
+      return this.$.restAPI.runRepoGC(this.repo).then(response => {
+        if (response.status === 200) {
+          this.dispatchEvent(new CustomEvent('show-alert',
+              {detail: {message: GC_MESSAGE}, bubbles: true}));
+        }
+      });
+    },
+
+    _createNewChange() {
+      this.$.createChangeOverlay.open();
+    },
+
+    _handleCreateChange() {
+      this.$.createNewChangeModal.handleCreateChange();
+      this._handleCloseCreateChange();
+    },
+
+    _handleCloseCreateChange() {
+      this.$.createChangeOverlay.close();
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
new file mode 100644
index 0000000..1c2f627
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-repo-commands</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-repo-commands.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-repo-commands></gr-repo-commands>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-repo-commands tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('create new change dialog', () => {
+      test('_createNewChange opens modal', () => {
+        const openStub = sandbox.stub(element.$.createChangeOverlay, 'open');
+        element._createNewChange();
+        assert.isTrue(openStub.called);
+      });
+
+      test('_handleCreateChange called when confirm fired', () => {
+        sandbox.stub(element, '_handleCreateChange');
+        element.$.createChangeDialog.fire('confirm');
+        assert.isTrue(element._handleCreateChange.called);
+      });
+
+      test('_handleCloseCreateChange called when cancel fired', () => {
+        sandbox.stub(element, '_handleCloseCreateChange');
+        element.$.createChangeDialog.fire('cancel');
+        assert.isTrue(element._handleCloseCreateChange.called);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
new file mode 100644
index 0000000..409c54b
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
@@ -0,0 +1,212 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/gr-table-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-create-pointer-dialog/gr-create-pointer-dialog.html">
+<link rel="import" href="../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html">
+
+<dom-module id="gr-repo-detail-list">
+  <template>
+    <style include="gr-form-styles"></style>
+    <style include="shared-styles">
+      .tags td.name {
+        min-width: 25em;
+      }
+      td.name,
+      td.revision,
+      td.message {
+        word-break: break-word;
+      }
+      td.revision.tags {
+        width: 27em;
+      }
+      td.message,
+      td.tagger {
+        max-width: 15em;
+      }
+      .editing .editItem {
+        display: inherit;
+      }
+      .editItem,
+      .editing .editBtn,
+      .canEdit .revisionNoEditing,
+      .editing .revisionWithEditing,
+      .revisionEdit,
+      .hideItem {
+        display: none;
+      }
+      .revisionEdit gr-button {
+        margin-left: .6em;
+      }
+      .editBtn {
+        margin-left: 1em;
+      }
+      .canEdit .revisionEdit{
+        align-items: center;
+        display: flex;
+        line-height: 1em;
+      }
+      .deleteButton:not(.show) {
+        display: none;
+      }
+      .tagger.hide {
+        display: none;
+      }
+    </style>
+    <style include="gr-table-styles"></style>
+    <gr-list-view
+        create-new="[[_loggedIn]]"
+        filter="[[_filter]]"
+        items-per-page="[[_itemsPerPage]]"
+        items="[[_items]]"
+        loading="[[_loading]]"
+        offset="[[_offset]]"
+        on-create-clicked="_handleCreateClicked"
+        path="[[_getPath(_repo, detailType)]]">
+      <table id="list" class="genericList gr-form-styles">
+        <tr class="headerRow">
+          <th class="name topHeader">Name</th>
+          <th class="revision topHeader">Revision</th>
+          <th class$="message topHeader [[_hideIfBranch(detailType)]]">
+            Message</th>
+          <th class$="tagger topHeader [[_hideIfBranch(detailType)]]">
+            Tagger</th>
+          <th class="repositoryBrowser topHeader">
+            Repository Browser</th>
+          <th class="delete topHeader"></th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+        <tbody class$="[[computeLoadingClass(_loading)]]">
+          <template is="dom-repeat" items="[[_shownItems]]">
+            <tr class="table">
+              <td class$="[[detailType]] name">[[_stripRefs(item.ref, detailType)]]</td>
+              <td class$="[[detailType]] revision [[_computeCanEditClass(item.ref, detailType, _isOwner)]]">
+                <span class="revisionNoEditing">
+                  [[item.revision]]
+                </span>
+                <span class$="revisionEdit [[_computeEditingClass(_isEditing)]]">
+                  <span class="revisionWithEditing">
+                    [[item.revision]]
+                  </span>
+                  <gr-button
+                      link
+                      on-tap="_handleEditRevision"
+                      class="editBtn">
+                    edit
+                  </gr-button>
+                  <input
+                      is=iron-input
+                      bind-value="{{_revisedRef}}"
+                      class="editItem">
+                  <gr-button
+                      link
+                      on-tap="_handleCancelRevision"
+                      class="cancelBtn editItem">
+                    Cancel
+                  </gr-button>
+                  <gr-button
+                      link
+                      on-tap="_handleSaveRevision"
+                      class="saveBtn editItem"
+                      disabled="[[!_revisedRef]]">
+                    Save
+                  </gr-button>
+                </span>
+              </td>
+              <td class$="message [[_hideIfBranch(detailType)]]">
+                [[item.message]]
+              </td>
+              <td class$="tagger [[_hideIfBranch(detailType)]]">
+                <div class$="tagger [[_computeHideTagger(item.tagger)]]">
+                  <gr-account-link
+                      account="[[item.tagger]]">
+                  </gr-account-link>
+                  (<gr-date-formatter
+                      has-tooltip
+                      date-str="[[item.tagger.date]]">
+                  </gr-date-formatter>)
+                </div>
+              </td>
+              <td class="repositoryBrowser">
+                <template is="dom-repeat"
+                    items="[[_computeWeblink(item)]]" as="link">
+                  <a href$="[[link.url]]"
+                      class="webLink"
+                      rel="noopener"
+                      target="_blank">
+                    ([[link.name]])
+                  </a>
+                </template>
+              </td>
+              <td class="delete">
+                <gr-button
+                    link
+                    class$="deleteButton [[_computeHideDeleteClass(_isOwner, item.can_delete)]]"
+                    on-tap="_handleDeleteItem">
+                  Delete
+                </gr-button>
+              </td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+      <gr-overlay id="overlay" with-backdrop>
+        <gr-confirm-delete-item-dialog
+            class="confirmDialog"
+            on-confirm="_handleDeleteItemConfirm"
+            on-cancel="_handleConfirmDialogCancel"
+            item="[[_refName]]"
+            item-type="[[detailType]]"></gr-confirm-delete-item-dialog>
+      </gr-overlay>
+    </gr-list-view>
+    <gr-overlay id="createOverlay" with-backdrop>
+      <gr-confirm-dialog
+          id="createDialog"
+          disabled="[[!_hasNewItemName]]"
+          confirm-label="Create"
+          on-confirm="_handleCreateItem"
+          on-cancel="_handleCloseCreate">
+        <div class="header" slot="header">
+          Create [[_computeItemName(detailType)]]
+        </div>
+        <div class="main" slot="main">
+          <gr-create-pointer-dialog
+              id="createNewModal"
+              detail-type="[[_computeItemName(detailType)]]"
+              has-new-item-name="{{_hasNewItemName}}"
+              item-detail="[[detailType]]"
+              repo-name="[[_repo]]"></gr-create-pointer-dialog>
+        </div>
+      </gr-confirm-dialog>
+    </gr-overlay>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-repo-detail-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
new file mode 100644
index 0000000..05d5d5c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
@@ -0,0 +1,263 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  const DETAIL_TYPES = {
+    BRANCHES: 'branches',
+    TAGS: 'tags',
+  };
+
+  Polymer({
+    is: 'gr-repo-detail-list',
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+      /**
+       * The kind of detail we are displaying, possibilities are determined by
+       * the const DETAIL_TYPES.
+       */
+      detailType: String,
+
+      _editing: {
+        type: Boolean,
+        value: false,
+      },
+      _isOwner: {
+        type: Boolean,
+        value: false,
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      /**
+       * Offset of currently visible query results.
+       */
+      _offset: Number,
+      _repo: Object,
+      _items: Array,
+      /**
+       * Because  we request one more than the projectsPerPage, _shownProjects
+       * maybe one less than _projects.
+       * */
+      _shownItems: {
+        type: Array,
+        computed: 'computeShownItems(_items)',
+      },
+      _itemsPerPage: {
+        type: Number,
+        value: 25,
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _filter: String,
+      _refName: String,
+      _hasNewItemName: Boolean,
+      _isEditing: Boolean,
+      _revisedRef: String,
+    },
+
+    behaviors: [
+      Gerrit.ListViewBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    _determineIfOwner(repo) {
+      return this.$.restAPI.getRepoAccess(repo)
+          .then(access =>
+                this._isOwner = access && access[repo].is_owner);
+    },
+
+    _paramsChanged(params) {
+      if (!params || !params.repo) { return; }
+
+      this._repo = params.repo;
+
+      this._getLoggedIn().then(loggedIn => {
+        this._loggedIn = loggedIn;
+        if (loggedIn) {
+          this._determineIfOwner(this._repo);
+        }
+      });
+
+      this.detailType = params.detail;
+
+      this._filter = this.getFilterValue(params);
+      this._offset = this.getOffsetValue(params);
+
+      return this._getItems(this._filter, this._repo,
+          this._itemsPerPage, this._offset, this.detailType);
+    },
+
+    _getItems(filter, repo, itemsPerPage, offset, detailType) {
+      this._loading = true;
+      this._items = [];
+      Polymer.dom.flush();
+      if (detailType === DETAIL_TYPES.BRANCHES) {
+        return this.$.restAPI.getRepoBranches(
+            filter, repo, itemsPerPage, offset) .then(items => {
+              if (!items) { return; }
+              this._items = items;
+              this._loading = false;
+            });
+      } else if (detailType === DETAIL_TYPES.TAGS) {
+        return this.$.restAPI.getRepoTags(
+            filter, repo, itemsPerPage, offset) .then(items => {
+              if (!items) { return; }
+              this._items = items;
+              this._loading = false;
+            });
+      }
+    },
+
+    _getPath(repo) {
+      return `/admin/repos/${this.encodeURL(repo, false)},` +
+          `${this.detailType}`;
+    },
+
+    _computeWeblink(repo) {
+      if (!repo.web_links) { return ''; }
+      const webLinks = repo.web_links;
+      return webLinks.length ? webLinks : null;
+    },
+
+    _stripRefs(item, detailType) {
+      if (detailType === DETAIL_TYPES.BRANCHES) {
+        return item.replace('refs/heads/', '');
+      } else if (detailType === DETAIL_TYPES.TAGS) {
+        return item.replace('refs/tags/', '');
+      }
+    },
+
+    _getLoggedIn() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _computeEditingClass(isEditing) {
+      return isEditing ? 'editing' : '';
+    },
+
+    _computeCanEditClass(ref, detailType, isOwner) {
+      return isOwner && this._stripRefs(ref, detailType) === 'HEAD' ?
+          'canEdit' : '';
+    },
+
+    _handleEditRevision(e) {
+      this._revisedRef = e.model.get('item.revision');
+      this._isEditing = true;
+    },
+
+    _handleCancelRevision() {
+      this._isEditing = false;
+    },
+
+    _handleSaveRevision(e) {
+      this._setRepoHead(this._repo, this._revisedRef, e);
+    },
+
+    _setRepoHead(repo, ref, e) {
+      return this.$.restAPI.setRepoHead(repo, ref).then(res => {
+        if (res.status < 400) {
+          this._isEditing = false;
+          e.model.set('item.revision', ref);
+        }
+      });
+    },
+
+    _computeItemName(detailType) {
+      if (detailType === DETAIL_TYPES.BRANCHES) {
+        return 'Branch';
+      } else if (detailType === DETAIL_TYPES.TAGS) {
+        return 'Tag';
+      }
+    },
+
+    _handleDeleteItemConfirm() {
+      this.$.overlay.close();
+      if (this.detailType === DETAIL_TYPES.BRANCHES) {
+        return this.$.restAPI.deleteRepoBranches(this._repo,
+            this._refName)
+            .then(itemDeleted => {
+              if (itemDeleted.status === 204) {
+                this._getItems(
+                    this._filter, this._repo, this._itemsPerPage,
+                    this._offset, this.detailType);
+              }
+            });
+      } else if (this.detailType === DETAIL_TYPES.TAGS) {
+        return this.$.restAPI.deleteRepoTags(this._repo,
+            this._refName)
+            .then(itemDeleted => {
+              if (itemDeleted.status === 204) {
+                this._getItems(
+                    this._filter, this._repo, this._itemsPerPage,
+                    this._offset, this.detailType);
+              }
+            });
+      }
+    },
+
+    _handleConfirmDialogCancel() {
+      this.$.overlay.close();
+    },
+
+    _handleDeleteItem(e) {
+      const name = this._stripRefs(e.model.get('item.ref'), this.detailType);
+      if (!name) { return; }
+      this._refName = name;
+      this.$.overlay.open();
+    },
+
+    _computeHideDeleteClass(owner, deleteRef) {
+      if (owner && !deleteRef || owner && deleteRef || deleteRef || owner) {
+        return 'show';
+      }
+      return '';
+    },
+
+    _handleCreateItem() {
+      this.$.createNewModal.handleCreateItem();
+      this._handleCloseCreate();
+    },
+
+    _handleCloseCreate() {
+      this.$.createOverlay.close();
+    },
+
+    _handleCreateClicked() {
+      this.$.createOverlay.open();
+    },
+
+    _hideIfBranch(type) {
+      if (type === DETAIL_TYPES.BRANCHES) {
+        return 'hideItem';
+      }
+
+      return '';
+    },
+
+    _computeHideTagger(tagger) {
+      return tagger ? '' : 'hide';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
new file mode 100644
index 0000000..695ecc7
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
@@ -0,0 +1,475 @@
+<!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-repo-detail-list</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-repo-detail-list.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-repo-detail-list></gr-repo-detail-list>
+  </template>
+</test-fixture>
+
+<script>
+  let counter;
+  const branchGenerator = () => {
+    return {
+      ref: `refs/heads/test${++counter}`,
+      revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
+      web_links: [
+        {
+          name: 'diffusion',
+          url: `https://git.example.org/branch/test;refs/heads/test${counter}`,
+        },
+      ],
+    };
+  };
+  const tagGenerator = () => {
+    return {
+      ref: `refs/tags/test${++counter}`,
+      revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
+      web_links: [
+        {
+          name: 'diffusion',
+          url: `https://git.example.org/tag/test;refs/tags/test${counter}`,
+        },
+      ],
+      message: 'Annotated tag',
+      tagger: {
+        name: 'Test User',
+        email: 'test.user@gmail.com',
+        date: '2017-09-19 14:54:00.000000000',
+        tz: 540,
+      },
+    };
+  };
+
+  suite('Branches', () => {
+    let element;
+    let branches;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.detailType = 'branches';
+      counter = 0;
+      sandbox.stub(page, 'show');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('list of repo branches', () => {
+      setup(done => {
+        branches = [{
+          ref: 'HEAD',
+          revision: 'master',
+        }].concat(_.times(25, branchGenerator));
+
+        stub('gr-rest-api-interface', {
+          getRepoBranches(num, project, offset) {
+            return Promise.resolve(branches);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'branches',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('test for branch in the list', done => {
+        flush(() => {
+          assert.equal(element._items[2].ref, 'refs/heads/test2');
+          done();
+        });
+      });
+
+      test('test for web links in the branches list', done => {
+        flush(() => {
+          assert.equal(element._items[2].web_links[0].url,
+              'https://git.example.org/branch/test;refs/heads/test2');
+          done();
+        });
+      });
+
+      test('test for refs/heads/ being striped from ref', done => {
+        flush(() => {
+          assert.equal(element._stripRefs(element._items[2].ref,
+              element.detailType), 'test2');
+          done();
+        });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+
+      test('Edit HEAD button not admin', done => {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+        sandbox.stub(element.$.restAPI, 'getRepoAccess').returns(
+            Promise.resolve({
+              test: {is_owner: false},
+            }));
+        element._determineIfOwner('test').then(() => {
+          assert.equal(element._isOwner, false);
+          assert.equal(getComputedStyle(Polymer.dom(element.root)
+              .querySelector('.revisionNoEditing')).display, 'inline');
+          assert.equal(getComputedStyle(Polymer.dom(element.root)
+              .querySelector('.revisionEdit')).display, 'none');
+          done();
+        });
+      });
+
+      test('Edit HEAD button admin', done => {
+        const saveBtn = Polymer.dom(element.root).querySelector('.saveBtn');
+        const cancelBtn = Polymer.dom(element.root).querySelector('.cancelBtn');
+        const editBtn = Polymer.dom(element.root).querySelector('.editBtn');
+        const revisionNoEditing = Polymer.dom(element.root)
+              .querySelector('.revisionNoEditing');
+        const revisionWithEditing = Polymer.dom(element.root)
+              .querySelector('.revisionWithEditing');
+
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+        sandbox.stub(element.$.restAPI, 'getRepoAccess').returns(
+            Promise.resolve({
+              test: {is_owner: true},
+            }));
+        sandbox.stub(element, '_handleSaveRevision');
+        element._determineIfOwner('test').then(() => {
+          assert.equal(element._isOwner, true);
+          // The revision container for non-editing enabled row is not visible.
+          assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
+
+          // The revision container for editing enabled row is visible.
+          assert.notEqual(getComputedStyle(Polymer.dom(element.root)
+              .querySelector('.revisionEdit')).display, 'none');
+
+          // The revision and edit button are visible.
+          assert.notEqual(getComputedStyle(revisionWithEditing).display,
+              'none');
+          assert.notEqual(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          const hiddenElements = Polymer.dom(element.root)
+              .querySelectorAll('.canEdit .editItem');
+
+          for (const item of hiddenElements) {
+            assert.equal(getComputedStyle(item).display, 'none');
+          }
+
+          MockInteractions.tap(editBtn);
+          flushAsynchronousOperations();
+          // The revision and edit button are not visible.
+          assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
+          assert.equal(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          for (item of hiddenElements) {
+            assert.notEqual(getComputedStyle(item).display, 'none');
+          }
+
+          // The revised ref was set correctly
+          assert.equal(element._revisedRef, 'master');
+
+          assert.isFalse(saveBtn.disabled);
+
+          // Delete the ref.
+          element._revisedRef = '';
+          assert.isTrue(saveBtn.disabled);
+
+          // Change the ref to something else
+          element._revisedRef = 'newRef';
+          element._repo = 'test';
+          assert.isFalse(saveBtn.disabled);
+
+          // Save button calls handleSave. since this is stubbed, the edit
+          // section remains open.
+          MockInteractions.tap(saveBtn);
+          assert.isTrue(element._handleSaveRevision.called);
+
+          // When cancel is tapped, the edit secion closes.
+          MockInteractions.tap(cancelBtn);
+          flushAsynchronousOperations();
+
+          // The revision and edit button are visible.
+          assert.notEqual(getComputedStyle(revisionWithEditing).display,
+              'none');
+          assert.notEqual(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          for (const item of hiddenElements) {
+            assert.equal(getComputedStyle(item).display, 'none');
+          }
+          done();
+        });
+      });
+
+      test('_handleSaveRevision with invalid rev', done => {
+        const event = {model: {set: sandbox.stub()}};
+        element._isEditing = true;
+        sandbox.stub(element.$.restAPI, 'setRepoHead').returns(
+            Promise.resolve({
+              status: 400,
+            })
+        );
+
+        element._setRepoHead('test', 'newRef', event).then(() => {
+          assert.isTrue(element._isEditing);
+          assert.isFalse(event.model.set.called);
+          done();
+        });
+      });
+
+      test('_handleSaveRevision with valid rev', done => {
+        const event = {model: {set: sandbox.stub()}};
+        element._isEditing = true;
+        sandbox.stub(element.$.restAPI, 'setRepoHead').returns(
+            Promise.resolve({
+              status: 200,
+            })
+        );
+
+        element._setRepoHead('test', 'newRef', event).then(() => {
+          assert.isFalse(element._isEditing);
+          assert.isTrue(event.model.set.called);
+          done();
+        });
+      });
+
+      test('test _computeItemName', () => {
+        assert.deepEqual(element._computeItemName('branches'), 'Branch');
+        assert.deepEqual(element._computeItemName('tags'), 'Tag');
+      });
+    });
+
+    suite('list with less then 25 branches', () => {
+      setup(done => {
+        branches = _.times(25, branchGenerator);
+
+        stub('gr-rest-api-interface', {
+          getRepoBranches(num, repo, offset) {
+            return Promise.resolve(branches);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'branches',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_paramsChanged', done => {
+        sandbox.stub(element.$.restAPI, 'getRepoBranches', () => {
+          return Promise.resolve(branches);
+        });
+        const params = {
+          detail: 'branches',
+          repo: 'test',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params).then(() => {
+          assert.isTrue(element.$.restAPI.getRepoBranches.lastCall
+              .calledWithExactly('test', 'test', 25, 25));
+          done();
+        });
+      });
+    });
+  });
+
+  suite('Tags', () => {
+    let element;
+    let tags;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.detailType = 'tags';
+      counter = 0;
+      sandbox.stub(page, 'show');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('list of repo tags', () => {
+      setup(done => {
+        tags = _.times(26, tagGenerator);
+
+        stub('gr-rest-api-interface', {
+          getRepoTags(num, repo, offset) {
+            return Promise.resolve(tags);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('test for tag in the list', done => {
+        flush(() => {
+          assert.equal(element._items[1].ref, 'refs/tags/test2');
+          done();
+        });
+      });
+
+      test('test for tag message in the list', done => {
+        flush(() => {
+          assert.equal(element._items[1].message, 'Annotated tag');
+          done();
+        });
+      });
+
+      test('test for tagger in the tag list', done => {
+        const tagger = {
+          name: 'Test User',
+          email: 'test.user@gmail.com',
+          date: '2017-09-19 14:54:00.000000000',
+          tz: 540,
+        };
+        flush(() => {
+          assert.deepEqual(element._items[1].tagger, tagger);
+          done();
+        });
+      });
+
+      test('test for web links in the tags list', done => {
+        flush(() => {
+          assert.equal(element._items[1].web_links[0].url,
+              'https://git.example.org/tag/test;refs/tags/test2');
+          done();
+        });
+      });
+
+      test('test for refs/tags/ being striped from ref', done => {
+        flush(() => {
+          assert.equal(element._stripRefs(element._items[1].ref,
+              element.detailType), 'test2');
+          done();
+        });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+
+      test('_computeHideTagger', () => {
+        const testObject1 = {
+          tagger: 'test',
+        };
+        assert.equal(element._computeHideTagger(testObject1), '');
+
+        assert.equal(element._computeHideTagger(undefined), 'hide');
+      });
+    });
+
+    suite('list with less then 25 tags', () => {
+      setup(done => {
+        tags = _.times(25, tagGenerator);
+
+        stub('gr-rest-api-interface', {
+          getRepoTags(num, project, offset) {
+            return Promise.resolve(tags);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_paramsChanged', done => {
+        sandbox.stub(element.$.restAPI, 'getRepoTags', () => {
+          return Promise.resolve(tags);
+        });
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params).then(() => {
+          assert.isTrue(element.$.restAPI.getRepoTags.lastCall
+              .calledWithExactly('test', 'test', 25, 25));
+          done();
+        });
+      });
+    });
+
+    suite('create new', () => {
+      test('_handleCreateClicked called when create-click fired', () => {
+        sandbox.stub(element, '_handleCreateClicked');
+        element.$$('gr-list-view').fire('create-clicked');
+        assert.isTrue(element._handleCreateClicked.called);
+      });
+
+      test('_handleCreateClicked opens modal', () => {
+        const openStub = sandbox.stub(element.$.createOverlay, 'open');
+        element._handleCreateClicked();
+        assert.isTrue(openStub.called);
+      });
+
+      test('_handleCreateItem called when confirm fired', () => {
+        sandbox.stub(element, '_handleCreateItem');
+        element.$.createDialog.fire('confirm');
+        assert.isTrue(element._handleCreateItem.called);
+      });
+
+      test('_handleCloseCreate called when cancel fired', () => {
+        sandbox.stub(element, '_handleCloseCreate');
+        element.$.createDialog.fire('cancel');
+        assert.isTrue(element._handleCloseCreate.called);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
new file mode 100644
index 0000000..a43667a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
@@ -0,0 +1,97 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../styles/gr-table-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-create-repo-dialog/gr-create-repo-dialog.html">
+
+<dom-module id="gr-repo-list">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-table-styles"></style>
+    <gr-list-view
+        create-new=[[_createNewCapability]]
+        filter="[[_filter]]"
+        items-per-page="[[_reposPerPage]]"
+        items="[[_repos]]"
+        loading="[[_loading]]"
+        offset="[[_offset]]"
+        on-create-clicked="_handleCreateClicked"
+        path="[[_path]]">
+      <table id="list" class="genericList">
+        <tr class="headerRow">
+          <th class="name topHeader">Repository Name</th>
+          <th class="description topHeader">Repository Description</th>
+          <th class="repositoryBrowser topHeader">Repository Browser</th>
+          <th class="readOnly topHeader">Read only</th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+        <tbody class$="[[computeLoadingClass(_loading)]]">
+          <template is="dom-repeat" items="[[_shownRepos]]">
+            <tr class="table">
+              <td class="name">
+                <a href$="[[_computeRepoUrl(item.name)]]">[[item.name]]</a>
+              </td>
+              <td class="description">[[item.description]]</td>
+              <td class="repositoryBrowser">
+                <template is="dom-repeat"
+                    items="[[_computeWeblink(item)]]" as="link">
+                  <a href$="[[link.url]]"
+                      class="webLink"
+                      rel="noopener"
+                      target="_blank">
+                    ([[link.name]])
+                  </a>
+                </template>
+              </td>
+              <td class="readOnly">[[_readOnly(item)]]</td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+    </gr-list-view>
+    <gr-overlay id="createOverlay" with-backdrop>
+      <gr-confirm-dialog
+          id="createDialog"
+          class="confirmDialog"
+          disabled="[[!_hasNewRepoName]]"
+          confirm-label="Create"
+          on-confirm="_handleCreateRepo"
+          on-cancel="_handleCloseCreate">
+        <div class="header" slot="header">
+          Create Repository
+        </div>
+        <div class="main" slot="main">
+          <gr-create-repo-dialog
+              has-new-repo-name="{{_hasNewRepoName}}"
+              params="[[params]]"
+              id="createNewModal"></gr-create-repo-dialog>
+        </div>
+      </gr-confirm-dialog>
+    </gr-overlay>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-repo-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
new file mode 100644
index 0000000..0686a67
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
@@ -0,0 +1,148 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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-repo-list',
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+
+      /**
+       * Offset of currently visible query results.
+       */
+      _offset: Number,
+      _path: {
+        type: String,
+        readOnly: true,
+        value: '/admin/repos',
+      },
+      _hasNewRepoName: Boolean,
+      _createNewCapability: {
+        type: Boolean,
+        value: false,
+      },
+      _repos: Array,
+
+      /**
+       * Because  we request one more than the projectsPerPage, _shownProjects
+       * maybe one less than _projects.
+       * */
+      _shownRepos: {
+        type: Array,
+        computed: 'computeShownItems(_repos)',
+      },
+
+      _reposPerPage: {
+        type: Number,
+        value: 25,
+      },
+
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _filter: String,
+    },
+
+    behaviors: [
+      Gerrit.ListViewBehavior,
+    ],
+
+    attached() {
+      this._getCreateRepoCapability();
+      this.fire('title-change', {title: 'Repos'});
+      this._maybeOpenCreateOverlay(this.params);
+    },
+
+    _paramsChanged(params) {
+      this._loading = true;
+      this._filter = this.getFilterValue(params);
+      this._offset = this.getOffsetValue(params);
+
+      return this._getRepos(this._filter, this._reposPerPage,
+          this._offset);
+    },
+
+    /**
+     * Opens the create overlay if the route has a hash 'create'
+     * @param {!Object} params
+     */
+    _maybeOpenCreateOverlay(params) {
+      if (params && params.openCreateModal) {
+        this.$.createOverlay.open();
+      }
+    },
+
+    _computeRepoUrl(name) {
+      return this.getUrl(this._path + '/', name);
+    },
+
+    _getCreateRepoCapability() {
+      return this.$.restAPI.getAccount().then(account => {
+        if (!account) { return; }
+        return this.$.restAPI.getAccountCapabilities(['createProject'])
+            .then(capabilities => {
+              if (capabilities.createProject) {
+                this._createNewCapability = true;
+              }
+            });
+      });
+    },
+
+    _getRepos(filter, reposPerPage, offset) {
+      this._repos = [];
+      return this.$.restAPI.getRepos(filter, reposPerPage, offset)
+          .then(repos => {
+            if (!repos) { return; }
+            this._repos = Object.keys(repos)
+             .map(key => {
+               const repo = repos[key];
+               repo.name = key;
+               return repo;
+             });
+            this._loading = false;
+          });
+    },
+
+    _handleCreateRepo() {
+      this.$.createNewModal.handleCreateRepo();
+    },
+
+    _handleCloseCreate() {
+      this.$.createOverlay.close();
+    },
+
+    _handleCreateClicked() {
+      this.$.createOverlay.open();
+    },
+
+    _readOnly(item) {
+      return item.state === 'READ_ONLY' ? 'Y' : '';
+    },
+
+    _computeWeblink(repo) {
+      if (!repo.web_links) { return ''; }
+      const webLinks = repo.web_links;
+      return webLinks.length ? webLinks : null;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
new file mode 100644
index 0000000..46c0951
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
@@ -0,0 +1,177 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-repo-list</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-repo-list.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-repo-list></gr-repo-list>
+  </template>
+</test-fixture>
+
+<script>
+  let counter;
+  const repoGenerator = () => {
+    return {
+      id: `test${++counter}`,
+      state: 'ACTIVE',
+      web_links: [
+        {
+          name: 'diffusion',
+          url: `https://phabricator.example.org/r/project/test${counter}`,
+        },
+      ],
+    };
+  };
+
+  suite('gr-repo-list tests', () => {
+    let element;
+    let repos;
+    let sandbox;
+    let value;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      counter = 0;
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('list with repos', () => {
+      setup(done => {
+        repos = _.times(26, repoGenerator);
+        stub('gr-rest-api-interface', {
+          getRepos(num, offset) {
+            return Promise.resolve(repos);
+          },
+        });
+        element._paramsChanged(value).then(() => { flush(done); });
+      });
+
+      test('test for test repo in the list', done => {
+        flush(() => {
+          assert.equal(element._repos[1].id, 'test2');
+          done();
+        });
+      });
+
+      test('_shownRepos', () => {
+        assert.equal(element._shownRepos.length, 25);
+      });
+
+      test('_maybeOpenCreateOverlay', () => {
+        const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
+        element._maybeOpenCreateOverlay();
+        assert.isFalse(overlayOpen.called);
+        const params = {};
+        element._maybeOpenCreateOverlay(params);
+        assert.isFalse(overlayOpen.called);
+        params.openCreateModal = true;
+        element._maybeOpenCreateOverlay(params);
+        assert.isTrue(overlayOpen.called);
+      });
+    });
+
+    suite('list with less then 25 repos', () => {
+      setup(done => {
+        repos = _.times(25, repoGenerator);
+
+        stub('gr-rest-api-interface', {
+          getRepos(num, offset) {
+            return Promise.resolve(repos);
+          },
+        });
+
+        element._paramsChanged(value).then(() => { flush(done); });
+      });
+
+      test('_shownRepos', () => {
+        assert.equal(element._shownRepos.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_paramsChanged', done => {
+        sandbox.stub(element.$.restAPI, 'getRepos', () => {
+          return Promise.resolve(repos);
+        });
+        const value = {
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(value).then(() => {
+          assert.isTrue(element.$.restAPI.getRepos.lastCall
+              .calledWithExactly('test', 25, 25));
+          done();
+        });
+      });
+    });
+
+    suite('loading', () => {
+      test('correct contents are displayed', () => {
+        assert.isTrue(element._loading);
+        assert.equal(element.computeLoadingClass(element._loading), 'loading');
+        assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+        element._loading = false;
+        element._repos = _.times(25, repoGenerator);
+
+        flushAsynchronousOperations();
+        assert.equal(element.computeLoadingClass(element._loading), '');
+        assert.equal(getComputedStyle(element.$.loading).display, 'none');
+      });
+    });
+
+    suite('create new', () => {
+      test('_handleCreateClicked called when create-click fired', () => {
+        sandbox.stub(element, '_handleCreateClicked');
+        element.$$('gr-list-view').fire('create-clicked');
+        assert.isTrue(element._handleCreateClicked.called);
+      });
+
+      test('_handleCreateClicked opens modal', () => {
+        const openStub = sandbox.stub(element.$.createOverlay, 'open');
+        element._handleCreateClicked();
+        assert.isTrue(openStub.called);
+      });
+
+      test('_handleCreateRepo called when confirm fired', () => {
+        sandbox.stub(element, '_handleCreateRepo');
+        element.$.createDialog.fire('confirm');
+        assert.isTrue(element._handleCreateRepo.called);
+      });
+
+      test('_handleCloseCreate called when cancel fired', () => {
+        sandbox.stub(element, '_handleCloseCreate');
+        element.$.createDialog.fire('cancel');
+        assert.isTrue(element._handleCloseCreate.called);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
new file mode 100644
index 0000000..b1964ff
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
@@ -0,0 +1,330 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+
+<link rel="import" href="../../shared/gr-download-commands/gr-download-commands.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-repo">
+  <template>
+    <style include="shared-styles">
+      main {
+        margin: 2em 1em;
+      }
+      h2.edited:after {
+        color: #444;
+        content: ' *';
+      }
+      .loading,
+      .hideDownload {
+        display: none;
+      }
+      #loading.loading {
+        display: block;
+      }
+      #loading:not(.loading) {
+        display: none;
+      }
+      .repositorySettings {
+        display: none;
+      }
+      .repositorySettings.showConfig {
+        display: block;
+      }
+    </style>
+    <style include="gr-form-styles"></style>
+    <main class="gr-form-styles read-only">
+      <h1 id="Title">[[repo]]</h1>
+      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">Loading...</div>
+      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+        <div id="downloadContent" class$="[[_computeDownloadClass(_schemes)]]">
+          <h2 id="download">Download</h2>
+          <fieldset>
+            <gr-download-commands
+                id="downloadCommands"
+                commands="[[_computeCommands(repo, _schemesObj, _selectedScheme)]]"
+                schemes="[[_schemes]]"
+                selected-scheme="{{_selectedScheme}}"></gr-download-commands>
+          </fieldset>
+        </div>
+        <h2 id="configurations"
+            class$="[[_computeHeaderClass(_configChanged)]]">Configurations</h2>
+        <div id="form">
+          <fieldset>
+            <h3 id="Description">Description</h3>
+            <fieldset>
+              <iron-autogrow-textarea
+                  id="descriptionInput"
+                  class="description"
+                  autocomplete="on"
+                  placeholder="<Insert repo description here>"
+                  bind-value="{{_repoConfig.description}}"
+                  disabled$="[[_readOnly]]"></iron-autogrow-textarea>
+            </fieldset>
+            <h3 id="Options">Repository Options</h3>
+            <fieldset id="options">
+              <section>
+                <span class="title">State</span>
+                <span class="value">
+                  <gr-select
+                      id="stateSelect"
+                      bind-value="{{_repoConfig.state}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat" items=[[_states]]>
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">Submit type</span>
+                <span class="value">
+                  <gr-select
+                      id="submitTypeSelect"
+                      bind-value="{{_repoConfig.submit_type}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatSubmitTypeSelect(_repoConfig)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">Allow content merges</span>
+                <span class="value">
+                  <gr-select
+                      id="contentMergeSelect"
+                      bind-value="{{_repoConfig.use_content_merge.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_repoConfig.use_content_merge)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">
+                  Create a new change for every commit not in the target branch
+                </span>
+                <span class="value">
+                  <gr-select
+                      id="newChangeSelect"
+                      bind-value="{{_repoConfig.create_new_change_for_all_not_in_target.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_repoConfig.create_new_change_for_all_not_in_target)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">Require Change-Id in commit message</span>
+                <span class="value">
+                  <gr-select
+                      id="requireChangeIdSelect"
+                      bind-value="{{_repoConfig.require_change_id.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_repoConfig.require_change_id)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section
+                   id="enableSignedPushSettings"
+                   class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.enable_signed_push)]]">
+                <span class="title">Enable signed push</span>
+                <span class="value">
+                  <gr-select
+                      id="enableSignedPush"
+                      bind-value="{{_repoConfig.enable_signed_push.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_repoConfig.enable_signed_push)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section
+                   id="requireSignedPushSettings"
+                   class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.require_signed_push)]]">
+                <span class="title">Require signed push</span>
+                <span class="value">
+                  <gr-select
+                      id="requireSignedPush"
+                      bind-value="{{_repoConfig.require_signed_push.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_repoConfig.require_signed_push)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">
+                  Reject implicit merges when changes are pushed for review</span>
+                <span class="value">
+                  <gr-select
+                      id="rejectImplicitMergesSelect"
+                      bind-value="{{_repoConfig.reject_implicit_merges.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_repoConfig.reject_implicit_merges)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section id="noteDbSettings" class$="repositorySettings [[_computeRepositoriesClass(_noteDbEnabled)]]">
+                <span class="title">
+                  Enable adding unregistered users as reviewers and CCs on changes</span>
+                <span class="value">
+                  <gr-select
+                      id="unRegisteredCcSelect"
+                      bind-value="{{_repoConfig.enable_reviewer_by_email.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_repoConfig.enable_reviewer_by_email)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                  </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">
+                  Set all new changes private by default</span>
+                <span class="value">
+                  <gr-select
+                      id="setAllnewChangesPrivateByDefaultSelect"
+                      bind-value="{{_repoConfig.private_by_default.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_repoConfig.private_by_default)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">Maximum Git object size limit</span>
+                <span class="value">
+                  <input
+                      id="maxGitObjSizeInput"
+                      bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
+                      is="iron-input"
+                      type="text"
+                      disabled$="[[_readOnly]]">
+                </span>
+              </section>
+              <section>
+                <span class="title">Match authored date with committer date upon submit</span>
+                <span class="value">
+                  <gr-select
+                      id="matchAuthoredDateWithCommitterDateSelect"
+                      bind-value="{{_repoConfig.match_author_to_committer_date.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_repoConfig.match_author_to_committer_date)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                  </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">Reject empty commit upon submit</span>
+                <span class="value">
+                  <gr-select
+                      id="rejectEmptyCommitSelect"
+                      bind-value="{{_repoConfig.reject_empty_commit.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                                items="[[_formatBooleanSelect(_repoConfig.reject_empty_commit)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                  </select>
+                  </gr-select>
+                </span>
+              </section>
+            </fieldset>
+            <h3 id="Options">Contributor Agreements</h3>
+            <fieldset id="agreements">
+              <section>
+                <span class="title">
+                  Require a valid contributor agreement to upload</span>
+                <span class="value">
+                  <gr-select
+                      id="contributorAgreementSelect"
+                      bind-value="{{_repoConfig.use_contributor_agreements.configured_value}}">
+                  <select disabled$="[[_readOnly]]">
+                    <template is="dom-repeat"
+                        items="[[_formatBooleanSelect(_repoConfig.use_contributor_agreements)]]">
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">Require Signed-off-by in commit message</span>
+                <span class="value">
+                  <gr-select
+                        id="useSignedOffBySelect"
+                        bind-value="{{_repoConfig.use_signed_off_by.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_repoConfig.use_signed_off_by)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+            </fieldset>
+            <!-- TODO @beckysiegel add plugin config widgets -->
+            <gr-button
+                on-tap="_handleSaveRepoConfig"
+                disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]">Save changes</gr-button>
+          </fieldset>
+        </div>
+      </div>
+    </main>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-repo.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
new file mode 100644
index 0000000..2febb25
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
@@ -0,0 +1,308 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  const STATES = {
+    active: {value: 'ACTIVE', label: 'Active'},
+    readOnly: {value: 'READ_ONLY', label: 'Read Only'},
+    hidden: {value: 'HIDDEN', label: 'Hidden'},
+  };
+
+  const SUBMIT_TYPES = {
+    // Exclude INHERIT, which is handled specially.
+    mergeIfNecessary: {
+      value: 'MERGE_IF_NECESSARY',
+      label: 'Merge if necessary',
+    },
+    fastForwardOnly: {
+      value: 'FAST_FORWARD_ONLY',
+      label: 'Fast forward only',
+    },
+    rebaseAlways: {
+      value: 'REBASE_ALWAYS',
+      label: 'Rebase Always',
+    },
+    rebaseIfNecessary: {
+      value: 'REBASE_IF_NECESSARY',
+      label: 'Rebase if necessary',
+    },
+    mergeAlways: {
+      value: 'MERGE_ALWAYS',
+      label: 'Merge always',
+    },
+    cherryPick: {
+      value: 'CHERRY_PICK',
+      label: 'Cherry pick',
+    },
+  };
+
+  Polymer({
+    is: 'gr-repo',
+
+    properties: {
+      params: Object,
+      repo: String,
+
+      _configChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+        observer: '_loggedInChanged',
+      },
+      /** @type {?} */
+      _repoConfig: Object,
+      _readOnly: {
+        type: Boolean,
+        value: true,
+      },
+      _states: {
+        type: Array,
+        value() {
+          return Object.values(STATES);
+        },
+      },
+      _submitTypes: {
+        type: Array,
+        value() {
+          return Object.values(SUBMIT_TYPES);
+        },
+      },
+      _schemes: {
+        type: Array,
+        value() { return []; },
+        computed: '_computeSchemes(_schemesObj)',
+        observer: '_schemesChanged',
+      },
+      _selectedCommand: {
+        type: String,
+        value: 'Clone',
+      },
+      _selectedScheme: String,
+      _schemesObj: Object,
+      _noteDbEnabled: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    observers: [
+      '_handleConfigChanged(_repoConfig.*)',
+    ],
+
+    attached() {
+      this._loadRepo();
+
+      this.fire('title-change', {title: this.repo});
+    },
+
+    _loadRepo() {
+      if (!this.repo) { return Promise.resolve(); }
+
+      const promises = [];
+      promises.push(this._getLoggedIn().then(loggedIn => {
+        this._loggedIn = loggedIn;
+        if (loggedIn) {
+          this.$.restAPI.getRepoAccess(this.repo).then(access => {
+            // If the user is not an owner, is_owner is not a property.
+            this._readOnly = !access[this.repo].is_owner;
+          });
+        }
+      }));
+
+      promises.push(this.$.restAPI.getProjectConfig(this.repo).then(
+          config => {
+            if (config.default_submit_type) {
+              // The gr-select is bound to submit_type, which needs to be the
+              // *configured* submit type. When default_submit_type is
+              // present, the server reports the *effective* submit type in
+              // submit_type, so we need to overwrite it before storing the
+              // config in this.
+              config.submit_type =
+                  config.default_submit_type.configured_value;
+            }
+            if (!config.state) {
+              config.state = STATES.active.value;
+            }
+            this._repoConfig = config;
+            this._loading = false;
+          }));
+
+      promises.push(this.$.restAPI.getConfig().then(config => {
+        this._schemesObj = config.download.schemes;
+        this._noteDbEnabled = !!config.note_db_enabled;
+      }));
+
+      return Promise.all(promises);
+    },
+
+    _computeLoadingClass(loading) {
+      return loading ? 'loading' : '';
+    },
+
+    _computeDownloadClass(schemes) {
+      return !schemes || !schemes.length ? 'hideDownload' : '';
+    },
+
+    _loggedInChanged(_loggedIn) {
+      if (!_loggedIn) { return; }
+      this.$.restAPI.getPreferences().then(prefs => {
+        if (prefs.download_scheme) {
+          // Note (issue 5180): normalize the download scheme with lower-case.
+          this._selectedScheme = prefs.download_scheme.toLowerCase();
+        }
+      });
+    },
+
+    _formatBooleanSelect(item) {
+      if (!item) { return; }
+      let inheritLabel = 'Inherit';
+      if (!(item.inherited_value === undefined)) {
+        inheritLabel = `Inherit (${item.inherited_value})`;
+      }
+      return [
+        {
+          label: inheritLabel,
+          value: 'INHERIT',
+        },
+        {
+          label: 'True',
+          value: 'TRUE',
+        }, {
+          label: 'False',
+          value: 'FALSE',
+        },
+      ];
+    },
+
+    _formatSubmitTypeSelect(projectConfig) {
+      if (!projectConfig) { return; }
+      const allValues = Object.values(SUBMIT_TYPES);
+      const type = projectConfig.default_submit_type;
+      if (!type) {
+        // Server is too old to report default_submit_type, so assume INHERIT
+        // is not a valid value.
+        return allValues;
+      }
+
+      let inheritLabel = 'Inherit';
+      if (type.inherited_value) {
+        let inherited = type.inherited_value;
+        for (const val of allValues) {
+          if (val.value === type.inherited_value) {
+            inherited = val.label;
+            break;
+          }
+        }
+        inheritLabel = `Inherit (${inherited})`;
+      }
+      return [
+        {
+          label: inheritLabel,
+          value: 'INHERIT',
+        },
+        ...allValues,
+      ];
+    },
+
+    _isLoading() {
+      return this._loading || this._loading === undefined;
+    },
+
+    _getLoggedIn() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _formatRepoConfigForSave(p) {
+      const configInputObj = {};
+      for (const key in p) {
+        if (p.hasOwnProperty(key)) {
+          if (key === 'default_submit_type') {
+            // default_submit_type is not in the input type, and the
+            // configured value was already copied to submit_type by
+            // _loadProject. Omit this property when saving.
+            continue;
+          }
+          if (typeof p[key] === 'object') {
+            configInputObj[key] = p[key].configured_value;
+          } else {
+            configInputObj[key] = p[key];
+          }
+        }
+      }
+      return configInputObj;
+    },
+
+    _handleSaveRepoConfig() {
+      return this.$.restAPI.saveRepoConfig(this.repo,
+          this._formatRepoConfigForSave(this._repoConfig)).then(() => {
+            this._configChanged = false;
+          });
+    },
+
+    _handleConfigChanged() {
+      if (this._isLoading()) { return; }
+      this._configChanged = true;
+    },
+
+    _computeButtonDisabled(readOnly, configChanged) {
+      return readOnly || !configChanged;
+    },
+
+    _computeHeaderClass(configChanged) {
+      return configChanged ? 'edited' : '';
+    },
+
+    _computeSchemes(schemesObj) {
+      return Object.keys(schemesObj);
+    },
+
+    _schemesChanged(schemes) {
+      if (schemes.length === 0) { return; }
+      if (!schemes.includes(this._selectedScheme)) {
+        this._selectedScheme = schemes.sort()[0];
+      }
+    },
+
+    _computeCommands(repo, schemesObj, _selectedScheme) {
+      const commands = [];
+      let commandObj;
+      if (schemesObj.hasOwnProperty(_selectedScheme)) {
+        commandObj = schemesObj[_selectedScheme].clone_commands;
+      }
+      for (const title in commandObj) {
+        if (!commandObj.hasOwnProperty(title)) { continue; }
+        commands.push({
+          title,
+          command: commandObj[title]
+              .replace('${project}', repo)
+              .replace('${project-base-name}',
+              repo.substring(repo.lastIndexOf('/') + 1)),
+        });
+      }
+      return commands;
+    },
+
+    _computeRepositoriesClass(config) {
+      return config ? 'showConfig': '';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
new file mode 100644
index 0000000..9ea0177
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
@@ -0,0 +1,361 @@
+<!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-repo</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-repo.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-repo></gr-repo>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-repo tests', () => {
+    let element;
+    let sandbox;
+    const REPO = 'test-repo';
+    const SCHEMES = {http: {}, repo: {}, ssh: {}};
+
+    function getFormFields() {
+      const selects = Polymer.dom(element.root).querySelectorAll('select');
+      const textareas =
+          Polymer.dom(element.root).querySelectorAll('iron-autogrow-textarea');
+      const inputs = Polymer.dom(element.root).querySelectorAll('input');
+      return inputs.concat(textareas).concat(selects);
+    }
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+        getProjectConfig() {
+          return Promise.resolve({
+            description: 'Access inherited by all other projects.',
+            use_contributor_agreements: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            use_content_merge: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            use_signed_off_by: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            create_new_change_for_all_not_in_target: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            require_change_id: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            enable_signed_push: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            require_signed_push: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            reject_implicit_merges: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            private_by_default: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            match_author_to_committer_date: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            reject_empty_commit: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            enable_reviewer_by_email: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            max_object_size_limit: {},
+            submit_type: 'MERGE_IF_NECESSARY',
+            default_submit_type: {
+              value: 'MERGE_IF_NECESSARY',
+              configured_value: 'INHERIT',
+              inherited_value: 'MERGE_IF_NECESSARY',
+            },
+          });
+        },
+        getConfig() {
+          return Promise.resolve({download: {}});
+        },
+      });
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('loading displays before repo config is loaded', () => {
+      assert.isTrue(element.$.loading.classList.contains('loading'));
+      assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
+      assert.isTrue(element.$.loadedContent.classList.contains('loading'));
+      assert.isTrue(getComputedStyle(element.$.loadedContent)
+          .display === 'none');
+    });
+
+    test('download commands visibility', () => {
+      element._loading = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.downloadContent.classList
+          .contains('hideDownload'));
+      assert.isTrue(getComputedStyle(element.$.downloadContent)
+          .display == 'none');
+      element._schemesObj = SCHEMES;
+      flushAsynchronousOperations();
+      assert.isFalse(element.$.downloadContent.classList
+          .contains('hideDownload'));
+      assert.isFalse(getComputedStyle(element.$.downloadContent)
+          .display == 'none');
+    });
+
+    test('form defaults to read only', () => {
+      assert.isTrue(element._readOnly);
+    });
+
+    test('form defaults to read only when not logged in', done => {
+      element.repo = REPO;
+      element._loadRepo().then(() => {
+        assert.isTrue(element._readOnly);
+        done();
+      });
+    });
+
+    test('form defaults to read only when logged in and not admin', done => {
+      element.repo = REPO;
+      sandbox.stub(element, '_getLoggedIn', () => {
+        return Promise.resolve(true);
+      });
+      sandbox.stub(element.$.restAPI, 'getRepoAccess', () => {
+        return Promise.resolve({'test-repo': {}});
+      });
+      element._loadRepo().then(() => {
+        assert.isTrue(element._readOnly);
+        done();
+      });
+    });
+
+    test('all form elements are disabled when not admin', done => {
+      element.repo = REPO;
+      element._loadRepo().then(() => {
+        flushAsynchronousOperations();
+        const formFields = getFormFields();
+        for (const field of formFields) {
+          assert.isTrue(field.hasAttribute('disabled'));
+        }
+        done();
+      });
+    });
+
+    test('_formatBooleanSelect', () => {
+      let item = {inherited_value: true};
+      assert.deepEqual(element._formatBooleanSelect(item), [
+        {
+          label: 'Inherit (true)',
+          value: 'INHERIT',
+        },
+        {
+          label: 'True',
+          value: 'TRUE',
+        }, {
+          label: 'False',
+          value: 'FALSE',
+        },
+      ]);
+
+      item = {inherited_value: false};
+      assert.deepEqual(element._formatBooleanSelect(item), [
+        {
+          label: 'Inherit (false)',
+          value: 'INHERIT',
+        },
+        {
+          label: 'True',
+          value: 'TRUE',
+        }, {
+          label: 'False',
+          value: 'FALSE',
+        },
+      ]);
+
+      // For items without inherited values
+      item = {};
+      assert.deepEqual(element._formatBooleanSelect(item), [
+        {
+          label: 'Inherit',
+          value: 'INHERIT',
+        },
+        {
+          label: 'True',
+          value: 'TRUE',
+        }, {
+          label: 'False',
+          value: 'FALSE',
+        },
+      ]);
+    });
+
+    suite('admin', () => {
+      setup(() => {
+        element.repo = REPO;
+        sandbox.stub(element, '_getLoggedIn', () => {
+          return Promise.resolve(true);
+        });
+        sandbox.stub(element.$.restAPI, 'getRepoAccess', () => {
+          return Promise.resolve({'test-repo': {is_owner: true}});
+        });
+      });
+
+      test('all form elements are enabled', done => {
+        element._loadRepo().then(() => {
+          flushAsynchronousOperations();
+          const formFields = getFormFields();
+          for (const field of formFields) {
+            assert.isFalse(field.hasAttribute('disabled'));
+          }
+          assert.isFalse(element._loading);
+          done();
+        });
+      });
+
+      test('state gets set correctly', done => {
+        element._loadRepo().then(() => {
+          assert.equal(element._repoConfig.state, 'ACTIVE');
+          assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
+          done();
+        });
+      });
+
+      test('inherited submit type value is calculated correctly', () => {
+        return element._loadRepo().then(() => {
+          const sel = element.$.submitTypeSelect;
+          assert.equal(sel.bindValue, 'INHERIT');
+          assert.equal(
+              sel.nativeSelect.options[0].text, 'Inherit (Merge if necessary)');
+        });
+      });
+
+      test('fields update and save correctly', () => {
+        // test notedb
+        element._noteDbEnabled = false;
+
+        assert.equal(
+            element._computeRepositoriesClass(element._noteDbEnabled), '');
+
+        element._noteDbEnabled = true;
+
+        assert.equal(element._computeRepositoriesClass(
+            element._noteDbEnabled), 'showConfig');
+
+        const configInputObj = {
+          description: 'new description',
+          use_contributor_agreements: 'TRUE',
+          use_content_merge: 'TRUE',
+          use_signed_off_by: 'TRUE',
+          create_new_change_for_all_not_in_target: 'TRUE',
+          require_change_id: 'TRUE',
+          enable_signed_push: 'TRUE',
+          require_signed_push: 'TRUE',
+          reject_implicit_merges: 'TRUE',
+          private_by_default: 'TRUE',
+          match_author_to_committer_date: 'TRUE',
+          reject_empty_commit: 'TRUE',
+          max_object_size_limit: 10,
+          submit_type: 'FAST_FORWARD_ONLY',
+          state: 'READ_ONLY',
+          enable_reviewer_by_email: 'TRUE',
+        };
+
+        const saveStub = sandbox.stub(element.$.restAPI, 'saveRepoConfig'
+            , () => {
+              return Promise.resolve({});
+            });
+
+        const button = Polymer.dom(element.root).querySelector('gr-button');
+
+        return element._loadRepo().then(() => {
+          assert.isTrue(button.hasAttribute('disabled'));
+          assert.isFalse(element.$.Title.classList.contains('edited'));
+          element.$.descriptionInput.bindValue = configInputObj.description;
+          element.$.stateSelect.bindValue = configInputObj.state;
+          element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
+          element.$.contentMergeSelect.bindValue =
+              configInputObj.use_content_merge;
+          element.$.newChangeSelect.bindValue =
+              configInputObj.create_new_change_for_all_not_in_target;
+          element.$.requireChangeIdSelect.bindValue =
+              configInputObj.require_change_id;
+          element.$.enableSignedPush.bindValue =
+              configInputObj.enable_signed_push;
+          element.$.requireSignedPush.bindValue =
+              configInputObj.require_signed_push;
+          element.$.rejectImplicitMergesSelect.bindValue =
+              configInputObj.reject_implicit_merges;
+          element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
+              configInputObj.private_by_default;
+          element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
+              configInputObj.match_author_to_committer_date;
+          element.$.maxGitObjSizeInput.bindValue =
+              configInputObj.max_object_size_limit;
+          element.$.contributorAgreementSelect.bindValue =
+              configInputObj.use_contributor_agreements;
+          element.$.useSignedOffBySelect.bindValue =
+              configInputObj.use_signed_off_by;
+          element.$.rejectEmptyCommitSelect.bindValue =
+              configInputObj.reject_empty_commit;
+          element.$.unRegisteredCcSelect.bindValue =
+              configInputObj.enable_reviewer_by_email;
+
+          assert.isFalse(button.hasAttribute('disabled'));
+          assert.isTrue(element.$.configurations.classList.contains('edited'));
+
+          const formattedObj =
+              element._formatRepoConfigForSave(element._repoConfig);
+          assert.deepEqual(formattedObj, configInputObj);
+
+          return element._handleSaveRepoConfig().then(() => {
+            assert.isTrue(button.hasAttribute('disabled'));
+            assert.isFalse(element.$.Title.classList.contains('edited'));
+            assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
+                configInputObj));
+          });
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
index 18612c8..06f567c 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
@@ -32,10 +32,10 @@
         padding: .7em;
         display: block;
       }
-      .buttons {
+      #removeBtn {
         display: none;
       }
-      .editing .buttons {
+      .editing #removeBtn  {
         display: flex;
       }
       #options {
@@ -51,9 +51,10 @@
         flex-wrap: nowrap;
         justify-content: space-between;
       }
-      .buttons gr-button {
-        float: left;
-        margin-left: .3em;
+      #deletedContainer.deleted {
+        align-items: baseline;
+        display: flex;
+        justify-content: space-between;
       }
       #undoBtn,
       #force,
@@ -62,8 +63,7 @@
         display: none;
       }
       #undoBtn.modified,
-      #force.force,
-      #deletedContainer.deleted {
+      #force.force {
         display: block;
       }
       .groupPath {
@@ -122,19 +122,17 @@
           </select>
         </gr-select>
       </div>
-      <div class="buttons">
-        <gr-button
-            id="undoBtn"
-            on-tap="_handleUndoChange"
-            class$="[[_computeModifiedClass(_modified)]]">Undo</gr-button>
-        <gr-button id="removeBtn" on-tap="_handleRemoveRule">Remove</gr-button>
-      </div>
+      <gr-button
+          link
+          id="removeBtn"
+          on-tap="_handleRemoveRule">Remove</gr-button>
     </div>
     <div
         id="deletedContainer"
         class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
       [[groupName]] was deleted
-      <gr-button id="undoRemoveBtn" on-tap="_handleUndoRemove">Undo</gr-button>
+      <gr-button link
+          id="undoRemoveBtn" on-tap="_handleUndoRemove">Undo</gr-button>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
index 7f1a245..a3b0272 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
@@ -14,6 +14,12 @@
 (function() {
   'use strict';
 
+  /**
+   * Fired when the rule has been modified or removed.
+   *
+   * @event access-modified
+   */
+
   const PRIORITY_OPTIONS = [
     'BATCH',
     'INTERACTIVE',
@@ -56,6 +62,7 @@
       editing: {
         type: Boolean,
         value: false,
+        observer: '_handleEditingChanged',
       },
       groupId: String,
       groupName: String,
@@ -66,15 +73,12 @@
         notify: true,
       },
       section: String,
-      _modified: {
-        type: Boolean,
-        value: false,
-      },
-      _originalRuleValues: Object,
+
       _deleted: {
         type: Boolean,
         value: false,
       },
+      _originalRuleValues: Object,
     },
 
     behaviors: [
@@ -87,6 +91,10 @@
       '_handleValueChange(rule.value.*)',
     ],
 
+    listeners: {
+      'access-saved': '_handleAccessSaved',
+    },
+
     ready() {
       // Called on ready rather than the observer because when new rules are
       // added, the observer is triggered prior to being ready.
@@ -114,6 +122,21 @@
       return `${this.getBaseUrl()}/admin/groups/${this.encodeURL(group, true)}`;
     },
 
+    _handleAccessSaved() {
+      // Set a new 'original' value to keep track of after the value has been
+      // saved.
+      this._setOriginalRuleValues(this.rule.value);
+    },
+
+    _handleEditingChanged(editing, editingOld) {
+      // Ignore when editing gets set initially.
+      if (!editingOld) { return; }
+      // Restore original values if no longer editing.
+      if (!editing) {
+        this._handleUndoChange();
+      }
+    },
+
     _computeSectionClass(editing, deleted) {
       const classList = [];
       if (editing) {
@@ -163,7 +186,8 @@
 
     _handleRemoveRule() {
       this._deleted = true;
-      this.set('rule.value.deleted', true);
+      this.rule.value.deleted = true;
+      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
     },
 
     _handleUndoRemove() {
@@ -172,21 +196,24 @@
     },
 
     _handleUndoChange() {
+      // gr-permission will take care of removing rules that were added but
+      // unsaved. We need to keep the added bit for the filter.
+      if (this.rule.value.added) { return; }
       this.set('rule.value', Object.assign({}, this._originalRuleValues));
-      this._modified = false;
+      this._deleted = false;
+      delete this.rule.value.deleted;
+      delete this.rule.value.modified;
     },
 
     _handleValueChange() {
       if (!this._originalRuleValues) { return; }
-      this._modified = true;
+      this.rule.value.modified = true;
+      // Allows overall access page to know a change has been made.
+      this.dispatchEvent(new CustomEvent('access-modified', {bubbles: true}));
     },
 
     _setOriginalRuleValues(value) {
       this._originalRuleValues = Object.assign({}, value);
     },
-
-    _computeModifiedClass(modified) {
-      return modified ? 'modified' : '';
-    },
   });
 })();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
index 3594e4b..7595eb1 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
@@ -152,11 +152,24 @@
       });
 
       test('_handleValueChange', () => {
+        const modifiedHandler = sandbox.stub();
+        element.rule = {value: {}};
+        element.addEventListener('access-modified', modifiedHandler);
         element._handleValueChange();
-        assert.isFalse(element._modified);
+        assert.isNotOk(element.rule.value.modified);
         element._originalRuleValues = {};
         element._handleValueChange();
-        assert.isTrue(element._modified);
+        assert.isTrue(element.rule.value.modified);
+        assert.isTrue(modifiedHandler.called);
+      });
+
+      test('_handleAccessSaved', () => {
+        const originalValue = {action: 'DENY'};
+        const newValue = {action: 'ALLOW'};
+        element._originalRuleValues = originalValue;
+        element.rule = {value: newValue};
+        element._handleAccessSaved();
+        assert.deepEqual(element._originalRuleValues, newValue);
       });
 
       test('_setOriginalRuleValues', () => {
@@ -199,22 +212,27 @@
         assert.isFalse(element.$.force.classList.contains('force'));
       });
 
-      test('modify and undo value', () => {
-        assert.isFalse(element._modified);
-        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+      test('modify and cancel restores original values', () => {
+        element.editing = true;
+        assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
+        assert.isNotOk(element.rule.value.modified);
+        element.$.action.bindValue = 'DENY';
+        assert.isTrue(element.rule.value.modified);
+        element.editing = false;
+        assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+        assert.equal(element.$.action.bindValue, 'ALLOW');
+        assert.isNotOk(element.rule.value.modified);
+      });
+
+      test('modify value', () => {
+        assert.isNotOk(element.rule.value.modified);
         element.$.action.bindValue = 'DENY';
         flushAsynchronousOperations();
-        assert.isTrue(element._modified);
-        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+        assert.isTrue(element.rule.value.modified);
 
         // The original value should now differ from the rule values.
         assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-
-        // After undoing the change, the original value should get reset.
-        MockInteractions.tap(element.$.undoBtn);
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
-        assert.equal(element.$.action.bindValue, 'ALLOW');
-        assert.isFalse(element._modified);
       });
 
       test('all selects are disabled when not in edit mode', () => {
@@ -235,12 +253,39 @@
             element.$.deletedContainer.classList.contains('deleted'));
         MockInteractions.tap(element.$.removeBtn);
         assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
+        assert.isTrue(element._deleted);
         assert.isTrue(element.rule.value.deleted);
 
         MockInteractions.tap(element.$.undoRemoveBtn);
+        assert.isFalse(element._deleted);
         assert.isNotOk(element.rule.value.deleted);
       });
 
+      test('remove rule and cancel', () => {
+        element.editing = true;
+        assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
+        assert.equal(getComputedStyle(element.$.deletedContainer).display,
+            'none');
+
+        element.rule = {id: 123, value: {action: 'ALLOW'}};
+        MockInteractions.tap(element.$.removeBtn);
+        assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
+        assert.notEqual(getComputedStyle(element.$.deletedContainer).display,
+            'none');
+        assert.isTrue(element._deleted);
+        assert.isTrue(element.rule.value.deleted);
+
+        element.editing = false;
+        assert.isFalse(element._deleted);
+        assert.isNotOk(element.rule.value.deleted);
+        assert.isNotOk(element.rule.value.modified);
+
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+        assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
+        assert.equal(getComputedStyle(element.$.deletedContainer).display,
+            'none');
+      });
+
       test('_computeGroupPath', () => {
         const group = '123';
         assert.equal(element._computeGroupPath(group),
@@ -263,7 +308,7 @@
       test('_ruleValues and _originalRuleValues are set correctly', () => {
         // Since the element does not already have default values, they should
         // be set. The original values should be set to those too.
-        assert.isFalse(element._modified);
+        assert.isNotOk(element.rule.value.modified);
         const expectedRuleValue = {
           action: 'ALLOW',
           force: false,
@@ -276,20 +321,14 @@
         });
       });
 
-      test('modify and undo value', () => {
-        assert.isFalse(element._modified);
-        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+      test('modify value', () => {
+        assert.isNotOk(element.rule.value.modified);
         element.$.force.bindValue = true;
         flushAsynchronousOperations();
-        assert.isTrue(element._modified);
-        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+        assert.isTrue(element.rule.value.modified);
 
         // The original value should now differ from the rule values.
         assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-
-        // After undoing the change, the original value should get reset.
-        MockInteractions.tap(element.$.undoBtn);
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
       });
     });
 
@@ -333,20 +372,14 @@
         assert.isFalse(element.$.force.classList.contains('force'));
       });
 
-      test('modify and undo value', () => {
-        assert.isFalse(element._modified);
-        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+      test('modify value', () => {
+        assert.isNotOk(element.rule.value.modified);
         Polymer.dom(element.root).querySelector('#labelMin').bindValue = 1;
         flushAsynchronousOperations();
-        assert.isTrue(element._modified);
-        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+        assert.isTrue(element.rule.value.modified);
 
         // The original value should now differ from the rule values.
         assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-
-        // After undoing the change, the original value should get reset.
-        MockInteractions.tap(element.$.undoBtn);
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
       });
     });
 
@@ -373,7 +406,7 @@
       test('_ruleValues and _originalRuleValues are set correctly', () => {
         // Since the element does not already have default values, they should
         // be set. The original values should be set to those too.
-        assert.isFalse(element._modified);
+        assert.isNotOk(element.rule.value.modified);
         assert.isTrue(element._setDefaultRuleValues.called);
 
         const expectedRuleValue = {
@@ -396,20 +429,14 @@
         });
       });
 
-      test('modify and undo value', () => {
-        assert.isFalse(element._modified);
-        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+      test('modify value', () => {
+        assert.isNotOk(element.rule.value.modified);
         Polymer.dom(element.root).querySelector('#labelMin').bindValue = 1;
         flushAsynchronousOperations();
-        assert.isTrue(element._modified);
-        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+        assert.isTrue(element.rule.value.modified);
 
         // The original value should now differ from the rule values.
         assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-
-        // After undoing the change, the original value should get reset.
-        MockInteractions.tap(element.$.undoBtn);
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
       });
     });
 
@@ -443,20 +470,14 @@
         assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMax'));
       });
 
-      test('modify and undo value', () => {
-        assert.isFalse(element._modified);
-        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+      test('modify value', () => {
+        assert.isNotOk(element.rule.value.modified);
         element.$.action.bindValue = false;
         flushAsynchronousOperations();
-        assert.isTrue(element._modified);
-        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+        assert.isTrue(element.rule.value.modified);
 
         // The original value should now differ from the rule values.
         assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-
-        // After undoing the change, the original value should get reset.
-        MockInteractions.tap(element.$.undoBtn);
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
       });
     });
 
@@ -475,7 +496,7 @@
       test('_ruleValues and _originalRuleValues are set correctly', () => {
         // Since the element does not already have default values, they should
         // be set. The original values should be set to those too.
-        assert.isFalse(element._modified);
+        assert.isNotOk(element.rule.value.modified);
         const expectedRuleValue = {
           action: 'ALLOW',
           force: false,
@@ -488,20 +509,14 @@
         });
       });
 
-      test('modify and undo value', () => {
-        assert.isFalse(element._modified);
-        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+      test('modify value', () => {
+        assert.isNotOk(element.rule.value.modified);
         element.$.force.bindValue = true;
         flushAsynchronousOperations();
-        assert.isTrue(element._modified);
-        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+        assert.isTrue(element.rule.value.modified);
 
         // The original value should now differ from the rule values.
         assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-
-        // After undoing the change, the original value should get reset.
-        MockInteractions.tap(element.$.undoBtn);
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
       });
     });
 
@@ -535,20 +550,14 @@
         assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMax'));
       });
 
-      test('modify and undo value', () => {
-        assert.isFalse(element._modified);
-        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+      test('modify value', () => {
+        assert.isNotOk(element.rule.value.modified);
         element.$.action.bindValue = false;
         flushAsynchronousOperations();
-        assert.isTrue(element._modified);
-        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+        assert.isTrue(element.rule.value.modified);
 
         // The original value should now differ from the rule values.
         assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-
-        // After undoing the change, the original value should get reset.
-        MockInteractions.tap(element.$.undoBtn);
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
       });
     });
 
@@ -567,7 +576,7 @@
       test('_ruleValues and _originalRuleValues are set correctly', () => {
         // Since the element does not already have default values, they should
         // be set. The original values should be set to those too.
-        assert.isFalse(element._modified);
+        assert.isNotOk(element.rule.value.modified);
         const expectedRuleValue = {
           action: 'ALLOW',
           force: false,
@@ -580,20 +589,14 @@
         });
       });
 
-      test('modify and undo value', () => {
-        assert.isFalse(element._modified);
-        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+      test('modify value', () => {
+        assert.isNotOk(element.rule.value.modified);
         element.$.force.bindValue = true;
         flushAsynchronousOperations();
-        assert.isTrue(element._modified);
-        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+        assert.isTrue(element.rule.value.modified);
 
         // The original value should now differ from the rule values.
         assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-
-        // After undoing the change, the original value should get reset.
-        MockInteractions.tap(element.$.undoBtn);
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
index 79f06fe..aa40a6e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
@@ -104,7 +104,7 @@
     },
 
     _computeProjectURL(project) {
-      return Gerrit.Nav.getUrlForProject(project, true);
+      return Gerrit.Nav.getUrlForProjectChanges(project, true);
     },
 
     _computeProjectBranchURL(change) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
index 152ef3d..27d3a42 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
@@ -22,6 +22,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-change-list-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-change-list-item/gr-change-list-item.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -101,11 +102,17 @@
               visible-change-table-columns="[[visibleChangeTableColumns]]"
               show-number="[[showNumber]]"
               show-star="[[showStar]]"
+              tabindex="0"
               label-names="[[labelNames]]"></gr-change-list-item>
         </template>
       </template>
     </table>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-cursor-manager
+        id="cursor"
+        index="{{selectedIndex}}"
+        scroll-behavior="keep-visible"
+        focus-on-move></gr-cursor-manager>
   </template>
   <script src="gr-change-list.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index 572695b..f66d8c8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -113,6 +113,10 @@
       keydown: '_scopedKeydownHandler',
     },
 
+    observers: [
+      '_sectionsChanged(sections.*)',
+    ],
+
     /**
      * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
      * events must be scoped to a component level (e.g. `enter`) in order to not
@@ -194,15 +198,15 @@
     },
 
     _sectionHref(query) {
-      return `${this.getBaseUrl()}/q/${this.encodeURL(query, true)}`;
+      return Gerrit.Nav.getUrlForSearchQuery(query);
     },
 
     /**
      * Maps an index local to a particular section to the absolute index
      * across all the changes on the page.
      *
-     * @param sectionIndex {number} index of section
-     * @param localIndex {number} index of row within section
+     * @param {number} sectionIndex index of section
+     * @param {number} localIndex index of row within section
      * @return {number} absolute index of row in the aggregate dashboard
      */
     _computeItemAbsoluteIndex(sectionIndex, localIndex) {
@@ -234,10 +238,7 @@
           this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      // Compute absolute index of item that would come after final item.
-      const len = this._computeItemAbsoluteIndex(this.sections.length, 0);
-      if (this.selectedIndex === len - 1) { return; }
-      this.selectedIndex += 1;
+      this.$.cursor.next();
     },
 
     _handleKKey(e) {
@@ -245,8 +246,7 @@
           this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      if (this.selectedIndex === 0) { return; }
-      this.selectedIndex -= 1;
+      this.$.cursor.previous();
     },
 
     _handleOKey(e) {
@@ -317,5 +317,12 @@
     _getListItems() {
       return Polymer.dom(this.root).querySelectorAll('gr-change-list-item');
     },
+
+    _sectionsChanged() {
+      // Flush DOM operations so that the list item elements will be loaded.
+      Polymer.dom.flush();
+      this.$.cursor.stops = this._getListItems();
+      this.$.cursor.moveToStart();
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
index bded5f6..7b9fadf 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
@@ -473,16 +473,6 @@
       }
     });
 
-    test('_sectionHref', () => {
-      assert.equal(
-          element._sectionHref('is:open owner:self'),
-          '/q/is:open+owner:self');
-      assert.equal(
-          element._sectionHref(
-              'is:open ((reviewer:self -is:ignored) OR assignee:self)'),
-          '/q/is:open+((reviewer:self+-is:ignored)+OR+assignee:self)');
-    });
-
     test('_computeItemAbsoluteIndex', () => {
       sandbox.stub(element, '_computeLabelNames');
       element.sections = [
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 f04b7c6..c8f606b 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-star
           show-reviewed-state
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..6acd9fa 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,42 @@
 (function() {
   'use strict';
 
+  const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
+  const USER_PLACEHOLDER_PATTERN = /\$\{user\}/g;
+
+  // 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 +70,8 @@
       },
       /** @type {{ selectedChangeIndex: number }} */
       viewState: Object,
+
+      /** @type {{ user: string }} */
       params: {
         type: Object,
       },
@@ -73,7 +92,7 @@
     },
 
     observers: [
-      '_userChanged(params.user)',
+      '_paramsChanged(params.*)',
     ],
 
     behaviors: [
@@ -88,53 +107,111 @@
       );
     },
 
+    _getProjectDashboard(project, dashboard) {
+      const errFn = response => {
+        this.fire('page-error', {response});
+      };
+      return this.$.restAPI.getDashboard(
+          project, dashboard, errFn).then(response => {
+            if (!response) {
+              return;
+            }
+            return {
+              title: response.title,
+              sections: response.sections.map(section => {
+                const suffix = response.foreach ? ' ' + response.foreach : '';
+                return {
+                  name: section.name,
+                  query:
+                      section.query.replace(
+                          PROJECT_PLACEHOLDER_PATTERN, project) + suffix,
+                };
+              }),
+            };
+          });
+    },
+
+    _getUserDashboard(user, sections, title) {
+      sections = sections
+        .filter(section => (user === 'self' || !section.selfOnly))
+        .map(section => {
+          const dashboardSection = {
+            name: section.name,
+            query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
+          };
+          if (section.suffixForDashboard) {
+            dashboardSection.suffixForDashboard = section.suffixForDashboard;
+          }
+          return dashboardSection;
+        });
+      return Promise.resolve({title, sections});
+    },
+
     _computeTitle(user) {
-      if (user === 'self') {
+      if (!user || user === 'self') {
         return 'My Reviews';
       }
       return 'Dashboard for ' + user;
     },
 
-    /**
-     * Allows a refresh if menu item is selected again.
-     */
-    _userChanged(user) {
-      if (!user) { return; }
+    _isViewActive(params) {
+      return params.view === Gerrit.Nav.View.DASHBOARD;
+    },
+
+    _paramsChanged(paramsChangeRecord) {
+      const params = paramsChangeRecord.base;
+
+      if (!this._isViewActive(params)) {
+        return Promise.resolve();
+      }
+
+      const user = params.user || 'self';
 
       // 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)}));
+      const title = params.title || this._computeTitle(user);
+      this.async(() => this.fire('title-change', {title}));
 
       this._loading = true;
-      const sections = this._sectionMetadata.filter(
-          section => (user === 'self' || !section.selfOnly));
-      const queries =
-          sections.map(
-              section => this._dashboardQueryForSection(section, user));
-      this.$.restAPI.getChanges(null, queries, null, this.options)
-          .then(results => {
-            this._results = sections.map((section, i) => {
-              return {
-                sectionName: section.name,
-                query: queries[i],
-                results: results[i],
-              };
-            });
-            this._loading = false;
-          }).catch(err => {
-            this._loading = false;
-            console.warn(err.message);
+
+      const dashboardPromise = params.project ?
+          this._getProjectDashboard(params.project, params.dashboard) :
+          this._getUserDashboard(
+              params.user || 'self',
+              params.sections || DEFAULT_SECTIONS,
+              params.title || this._computeTitle(params.user));
+
+      return dashboardPromise.then(dashboard => {
+        if (!dashboard) {
+          this._loading = false;
+          return;
+        }
+        const queries = dashboard.sections.map(section => {
+          if (section.suffixForDashboard) {
+            return section.query + ' ' + section.suffixForDashboard;
+          }
+          return section.query;
+        });
+        const req =
+            this.$.restAPI.getChanges(null, queries, null, this.options);
+        return req.then(response => {
+          this._loading = false;
+          this._results = response.map((results, i) => {
+            return {
+              sectionName: dashboard.sections[i].name,
+              query: dashboard.sections[i].query,
+              results,
+            };
           });
+        });
+      }).catch(err => {
+        this._loading = false;
+        console.warn(err);
+      });
     },
 
-    _dashboardQueryForSection(section, user) {
-      const query =
-          section.suffixForDashboard ?
-          section.query + ' ' + section.suffixForDashboard :
-          section.query;
-      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..f7d933a 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
@@ -35,66 +35,226 @@
   suite('gr-dashboard-view tests', () => {
     let element;
     let sandbox;
+    let paramsChangedPromise;
 
     setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
       getChangesStub = sandbox.stub(element.$.restAPI, 'getChanges',
-          () => Promise.resolve());
+          () => Promise.resolve([]));
+
+      let resolver;
+      paramsChangedPromise = new Promise(resolve => {
+        resolver = resolve;
+      });
+      const paramsChanged = element._paramsChanged.bind(element);
+      sandbox.stub(element, '_paramsChanged', params => {
+        paramsChanged(params).then(resolver());
+      });
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
-    test('nothing happens when user param is falsy', () => {
-      element.params = {};
-      flushAsynchronousOperations();
-      assert.equal(getChangesStub.callCount, 0);
-
-      element.params = {user: ''};
-      flushAsynchronousOperations();
-      assert.equal(getChangesStub.callCount, 0);
-    });
-
-    test('content is refreshed when user param is updated', () => {
-      element.params = {user: 'self'};
-      flushAsynchronousOperations();
-      assert.equal(getChangesStub.callCount, 1);
-    });
-
-    test('viewing another user\'s dashboard omits selfOnly sections', () => {
-      element._sectionMetadata = [
-        {query: '1'},
-        {query: '2', selfOnly: true},
-      ];
-
-      element.params = {user: 'self'};
-      flushAsynchronousOperations();
-      assert.isTrue(
-          getChangesStub.calledWith(null, ['1', '2'], null, element.options));
-
-      element.params = {user: 'user'};
-      flushAsynchronousOperations();
-      assert.isTrue(
-          getChangesStub.calledWith(null, ['1'], null, element.options));
-    });
-
-    test('_dashboardQueryForSection', () => {
-      const query = 'query for ${user}';
-      const suffixForDashboard = 'suffix for ${user}';
-      assert.equal(
-          element._dashboardQueryForSection({query}, 'user'),
-          'query for user');
-      assert.equal(
-          element._dashboardQueryForSection(
-              {query, suffixForDashboard}, 'user'),
-          'query for user suffix for user');
-    });
-
     test('_computeTitle', () => {
       assert.equal(element._computeTitle('self'), 'My Reviews');
       assert.equal(element._computeTitle('not self'), 'Dashboard for not self');
     });
+
+    suite('_isViewActive', () => {
+      test('nothing happens when user param is falsy', () => {
+        element.params = {};
+        flushAsynchronousOperations();
+        assert.equal(getChangesStub.callCount, 0);
+
+        element.params = {user: ''};
+        flushAsynchronousOperations();
+        assert.equal(getChangesStub.callCount, 0);
+      });
+
+      test('content is refreshed when user param is updated', () => {
+        element.params = {
+          view: Gerrit.Nav.View.DASHBOARD,
+          user: 'self',
+        };
+        return paramsChangedPromise.then(() => {
+          assert.equal(getChangesStub.callCount, 1);
+        });
+      });
+    });
+
+    suite('selfOnly sections', () => {
+      test('viewing self dashboard includes selfOnly sections', () => {
+        element.params = {
+          view: Gerrit.Nav.View.DASHBOARD,
+          sections: [
+            {query: '1'},
+            {query: '2', selfOnly: true},
+          ],
+          user: 'user',
+        };
+        return paramsChangedPromise.then(() => {
+          assert.isTrue(
+              getChangesStub.calledWith(
+                  null, ['1'], null, element.options));
+        });
+      });
+
+      test('viewing another user\'s dashboard omits selfOnly sections', () => {
+        element.params = {
+          view: Gerrit.Nav.View.DASHBOARD,
+          sections: [
+            {query: '1'},
+            {query: '2', selfOnly: true},
+          ],
+          user: 'self',
+        };
+        return paramsChangedPromise.then(() => {
+          assert.isTrue(
+              getChangesStub.calledWith(
+                  null, ['1', '2'], null, element.options));
+        });
+      });
+    });
+
+    test('suffixForDashboard is included in getChanges query', () => {
+      element.params = {
+        view: Gerrit.Nav.View.DASHBOARD,
+        sections: [
+          {query: '1'},
+          {query: '2', suffixForDashboard: 'suffix'},
+        ],
+      };
+      return paramsChangedPromise.then(() => {
+        assert.isTrue(getChangesStub.calledOnce);
+        assert.deepEqual(
+            getChangesStub.firstCall.args,
+            [null, ['1', '2 suffix'], null, element.options]);
+      });
+    });
+
+    suite('_getProjectDashboard', () => {
+      test('dashboard with foreach', () => {
+        sandbox.stub(element.$.restAPI, 'getDashboard', () => Promise.resolve({
+          title: 'title',
+          // Note: ${project} should not be resolved in foreach!
+          foreach: 'foreach for ${project}',
+          sections: [
+            {name: 'section 1', query: 'query 1'},
+            {name: 'section 2', query: '${project} query 2'},
+          ],
+        }));
+        return element._getProjectDashboard('project', '').then(dashboard => {
+          assert.deepEqual(
+              dashboard,
+              {
+                title: 'title',
+                sections: [
+                  {name: 'section 1', query: 'query 1 foreach for ${project}'},
+                  {
+                    name: 'section 2',
+                    query: 'project query 2 foreach for ${project}',
+                  },
+                ],
+              });
+        });
+      });
+
+      test('dashboard without foreach', () => {
+        sandbox.stub(element.$.restAPI, 'getDashboard', () => Promise.resolve({
+          title: 'title',
+          sections: [
+            {name: 'section 1', query: 'query 1'},
+            {name: 'section 2', query: '${project} query 2'},
+          ],
+        }));
+        return element._getProjectDashboard('project', '').then(dashboard => {
+          assert.deepEqual(
+              dashboard,
+              {
+                title: 'title',
+                sections: [
+                  {name: 'section 1', query: 'query 1'},
+                  {name: 'section 2', query: 'project query 2'},
+                ],
+              });
+        });
+      });
+    });
+
+    suite('_getUserDashboard', () => {
+      const sections = [
+        {name: 'section 1', query: 'query 1'},
+        {name: 'section 2', query: 'query 2 for ${user}'},
+        {name: 'section 3', query: 'self only query', selfOnly: true},
+        {name: 'section 4', query: 'query 4', suffixForDashboard: 'suffix'},
+      ];
+
+      test('dashboard for self', () => {
+        return element._getUserDashboard('self', sections, 'title')
+            .then(dashboard => {
+              assert.deepEqual(
+                  dashboard,
+                  {
+                    title: 'title',
+                    sections: [
+                      {name: 'section 1', query: 'query 1'},
+                      {name: 'section 2', query: 'query 2 for self'},
+                      {name: 'section 3', query: 'self only query'},
+                      {
+                        name: 'section 4',
+                        query: 'query 4',
+                        suffixForDashboard: 'suffix',
+                      },
+                    ],
+                  });
+            });
+      });
+
+      test('dashboard for other user', () => {
+        return element._getUserDashboard('user', sections, 'title')
+            .then(dashboard => {
+              assert.deepEqual(
+                  dashboard,
+                  {
+                    title: 'title',
+                    sections: [
+                      {name: 'section 1', query: 'query 1'},
+                      {name: 'section 2', query: 'query 2 for user'},
+                      {
+                        name: 'section 4',
+                        query: 'query 4',
+                        suffixForDashboard: 'suffix',
+                      },
+                    ],
+                  });
+            });
+      });
+    });
+
+    test('_computeUserHeaderClass', () => {
+      assert.equal(element._computeUserHeaderClass(undefined), 'hide');
+      assert.equal(element._computeUserHeaderClass(''), 'hide');
+      assert.equal(element._computeUserHeaderClass('self'), 'hide');
+      assert.equal(element._computeUserHeaderClass('user'), '');
+    });
+
+    test('404 page', done => {
+      const response = {status: 404};
+      sandbox.stub(
+          element.$.restAPI, 'getDashboard', (project, dashboard, errFn) => {
+            errFn(response);
+          });
+      element.addEventListener('page-error', e => {
+        assert.strictEqual(e.detail.response, response);
+        done();
+      });
+      element.params = {
+        view: Gerrit.Nav.View.DASHBOARD,
+        project: 'project',
+        dashboard: 'dashboard',
+      };
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
index 4931ff1..9805d8e 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
@@ -35,9 +35,11 @@
         placeholder="[[placeholder]]"
         threshold="[[suggestFrom]]"
         query="[[query]]"
+        allow-non-suggested-values="[[allowAnyInput]]"
         on-commit="_handleInputCommit"
         clear-on-commit
-        warn-uncommitted>
+        warn-uncommitted
+        text="{{_inputText}}">
     </gr-autocomplete>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
index 3626b86..79ac07b 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
@@ -22,10 +22,18 @@
      *
      * @event add
      */
+
+    /**
+     * When allowAnyInput is true, account-text-changed is fired when input text
+     * changed. This is needed so that the reply dialog's save button can be
+     * enabled for arbitrary cc's, which don't need a 'commit'.
+     *
+     * @event account-text-changed
+     */
     properties: {
+      allowAnyInput: Boolean,
       borderless: Boolean,
       change: Object,
-      _config: Object,
       filter: Function,
       placeholder: String,
       /**
@@ -51,6 +59,13 @@
           return this._getReviewerSuggestions.bind(this);
         },
       },
+
+      _config: Object,
+      /** The value of the autocomplete entry. */
+      _inputText: {
+        type: String,
+        observer: '_inputTextChanged',
+      },
     },
 
     behaviors: [
@@ -92,6 +107,13 @@
       return this.getUserName(this._config, reviewer, false);
     },
 
+    _inputTextChanged(text) {
+      if (text.length && this.allowAnyInput) {
+        this.dispatchEvent(new CustomEvent('account-text-changed',
+            {bubbles: true}));
+      }
+    },
+
     _makeSuggestion(reviewer) {
       let name;
       let value;
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
index a4dacc7..0d2d859 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
@@ -188,6 +188,30 @@
         });
       });
     });
+    test('account-text-changed fired when input text changed and allowAnyInput',
+        () => {
+          // Spy on query, as that is called when _updateSuggestions proceeds.
+          const changeStub = sandbox.stub();
+          element.allowAnyInput = true;
+          sandbox.stub(element.$.restAPI, 'getChangeSuggestedReviewers')
+              .returns(Promise.resolve([]));
+          element.addEventListener('account-text-changed', changeStub);
+          element.$.input.text = 'a';
+          assert.isTrue(changeStub.calledOnce);
+          element.$.input.text = 'ab';
+          assert.isTrue(changeStub.calledTwice);
+        });
+
+    test('account-text-changed not fired when input text changed without ' +
+        'allowAnyUser', () => {
+          // Spy on query, as that is called when _updateSuggestions proceeds.
+      const changeStub = sandbox.stub();
+      sandbox.stub(element.$.restAPI, 'getChangeSuggestedReviewers')
+          .returns(Promise.resolve([]));
+      element.addEventListener('account-text-changed', changeStub);
+      element.$.input.text = 'a';
+      assert.isFalse(changeStub.called);
+    });
 
     test('setText', () => {
       // Spy on query, as that is called when _updateSuggestions proceeds.
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
index 1c78774..379dfce 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
@@ -69,6 +69,7 @@
         placeholder="[[placeholder]]"
         on-add="_handleAdd"
         on-input-keydown="_handleInputKeydown"
+        allow-any-input="[[allowAnyInput]]"
         allow-any-user="[[allowAnyUser]]">
     </gr-account-entry>
   </template>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index eb170ae..481cd76 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -38,30 +38,36 @@
   <template>
     <style include="shared-styles">
       :host {
-        display: inline-block;
+        display: flex;
         font-family: var(--font-family);
       }
+      #actionLoadingMessage,
+      #mainContent,
       section {
-        display: inline-block;
+        display: flex;
       }
+      #actionLoadingMessage,
       gr-button,
       gr-dropdown {
-        margin-left: .5em;
+        /* px because don't have the same font size */
+        margin-left: 12px;
       }
       #actionLoadingMessage {
+        align-items: center;
         color: #777;
       }
       @media screen and (max-width: 50em) {
-        :host,
+        #mainContent,
         section,
         gr-button,
         gr-dropdown {
           display: block;
+          flex: 1;
         }
         gr-button,
         gr-dropdown {
-          margin-bottom: .5em;
-          margin-left: 0;
+          /* px because don't have the same font size */
+          margin: 0 0 6px 0;
         }
         .confirmDialog {
           width: 90vw;
@@ -81,6 +87,37 @@
           id="actionLoadingMessage"
           hidden$="[[!_actionLoadingMessage]]">
         [[_actionLoadingMessage]]</span>
+        <section id="primaryActions"
+            hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]">
+          <template
+              is="dom-repeat"
+              items="[[_topLevelPrimaryActions]]"
+              as="action">
+            <gr-button title$="[[action.title]]"
+                primary$="[[action.__primary]]"
+                data-action-key$="[[action.__key]]"
+                data-action-type$="[[action.__type]]"
+                data-label$="[[action.label]]"
+                disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
+                on-tap="_handleActionTap">[[action.label]]</gr-button>
+          </template>
+        </section>
+        <section id="secondaryActions"
+            hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]">
+          <template
+              is="dom-repeat"
+              items="[[_topLevelSecondaryActions]]"
+              as="action">
+            <gr-button title$="[[action.title]]"
+                primary$="[[action.__primary]]"
+                data-action-key$="[[action.__key]]"
+                data-action-type$="[[action.__type]]"
+                data-label$="[[action.label]]"
+                disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
+                on-tap="_handleActionTap">[[action.label]]</gr-button>
+          </template>
+        </section>
+      <gr-button hidden$="[[!_loading]]" disabled>Loading actions...</gr-button>
       <gr-dropdown
           id="moreActions"
           tabindex="0"
@@ -91,21 +128,6 @@
           hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
           disabled-ids="[[_disabledMenuActions]]"
           items="[[_menuActions]]">More</gr-dropdown>
-      <section hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]">
-        <template
-            is="dom-repeat"
-            items="[[_topLevelActions]]"
-            as="action">
-          <gr-button title$="[[action.title]]"
-              primary$="[[action.__primary]]"
-              data-action-key$="[[action.__key]]"
-              data-action-type$="[[action.__type]]"
-              data-label$="[[action.label]]"
-              disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-              on-tap="_handleActionTap">[[action.label]]</gr-button>
-        </template>
-      </section>
-      <gr-button hidden$="[[!_loading]]" disabled>Loading actions...</gr-button>
     </div>
     <gr-overlay id="overlay" with-backdrop>
       <gr-confirm-rebase-dialog id="confirmRebase"
@@ -147,10 +169,10 @@
           confirm-label="Delete"
           on-cancel="_handleConfirmDialogCancel"
           on-confirm="_handleDeleteConfirm">
-        <div class="header">
+        <div class="header" slot="header">
           Delete Change
         </div>
-        <div class="main">
+        <div class="main" slot="main">
           Do you really want to delete the change?
         </div>
       </gr-confirm-dialog>
@@ -160,10 +182,10 @@
           confirm-label="Delete"
           on-cancel="_handleConfirmDialogCancel"
           on-confirm="_handleDeleteEditConfirm">
-        <div class="header">
+        <div class="header" slot="header">
           Delete Change Edit
         </div>
-        <div class="main">
+        <div class="main" slot="main">
           Do you really want to delete the edit?
         </div>
       </gr-confirm-dialog>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 397cba6..b2a6f0d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -76,7 +76,7 @@
 
   const ActionLoadingLabels = {
     abandon: 'Abandoning...',
-    cherrypick: 'Cherry-Picking...',
+    cherrypick: 'Cherry-picking...',
     delete: 'Deleting...',
     move: 'Moving..',
     rebase: 'Rebasing...',
@@ -97,7 +97,7 @@
     __type: 'change',
     enabled: true,
     key: 'review',
-    label: 'Quick Approve',
+    label: 'Quick approve',
     method: 'POST',
   };
 
@@ -120,7 +120,7 @@
 
   const REBASE_EDIT = {
     enabled: true,
-    label: 'Rebase Edit',
+    label: 'Rebase edit',
     title: 'Rebase change edit',
     __key: 'rebaseEdit',
     __primary: false,
@@ -130,7 +130,7 @@
 
   const PUBLISH_EDIT = {
     enabled: true,
-    label: 'Publish Edit',
+    label: 'Publish edit',
     title: 'Publish change edit',
     __key: 'publishEdit',
     __primary: false,
@@ -140,7 +140,7 @@
 
   const DELETE_EDIT = {
     enabled: true,
-    label: 'Delete Edit',
+    label: 'Delete edit',
     title: 'Delete change edit',
     __key: 'deleteEdit',
     __primary: false,
@@ -227,7 +227,10 @@
         type: Array,
         computed: '_computeTopLevelActions(_allActionValues.*, ' +
             '_hiddenActions.*, _overflowActions.*)',
+        observer: '_filterPrimaryActions',
       },
+      _topLevelPrimaryActions: Array,
+      _topLevelSecondaryActions: Array,
       _menuActions: {
         type: Array,
         computed: '_computeMenuActions(_allActionValues.*, _hiddenActions.*, ' +
@@ -323,7 +326,7 @@
     observers: [
       '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*, ' +
           'editLoaded, editBasedOnCurrentPatchSet, change)',
-      '_changeOrPatchNumChanged(changeNum, patchNum)',
+      '_changeChanged(change)',
     ],
 
     listeners: {
@@ -354,7 +357,7 @@
       });
     },
 
-    _changeOrPatchNumChanged() {
+    _changeChanged() {
       this.reload();
     },
 
@@ -636,13 +639,8 @@
         } else if (!values.includes(a)) {
           return;
         }
-        if (actions[a].label === 'Delete') {
-          // This label is common within change and revision actions. Make it
-          // more explicit to the user.
-          if (type === ActionType.CHANGE) {
-            actions[a].label += ' Change';
-          }
-        }
+        actions[a].label = this._getActionLabel(actions[a], type);
+
         // Triggers a re-render by ensuring object inequality.
         result.push(Object.assign({}, actions[a]));
       });
@@ -667,6 +665,32 @@
           .then(url => action.__url = url);
     },
 
+    /**
+     * Given a change action, return a display label that uses the appropriate
+     * casing or includes explanatory details.
+     */
+    _getActionLabel(action, type) {
+      if (action.label === 'Delete' && type === ActionType.CHANGE) {
+        // This label is common within change and revision actions. Make it more
+        // explicit to the user.
+        return 'Delete change';
+      } else if (action.label === 'WIP' && type === ActionType.CHANGE) {
+        return 'Mark as work in progress';
+      }
+      // Otherwise, just map the anme to sentence case.
+      return this._toSentenceCase(action.label);
+    },
+
+    /**
+     * Capitalize the first letter and lowecase all others.
+     * @param {string} s
+     * @return {string}
+     */
+    _toSentenceCase(s) {
+      if (!s.length) { return ''; }
+      return s[0].toUpperCase() + s.slice(1).toLowerCase();
+    },
+
     _computeLoadingLabel(action) {
       return ActionLoadingLabels[action] || 'Working...';
     },
@@ -1025,9 +1049,9 @@
         this._handleResponseError(response);
       };
 
-      return this.fetchIsLatestKnown(this.change, this.$.restAPI)
-          .then(isLatest => {
-            if (!isLatest) {
+      return this.fetchChangeUpdates(this.change, this.$.restAPI)
+          .then(result => {
+            if (!result.isLatest) {
               this.fire('show-alert', {
                 message: 'Cannot set label: a newer patch has been ' +
                     'uploaded to this change.',
@@ -1170,6 +1194,13 @@
       });
     },
 
+    _filterPrimaryActions(_topLevelActions) {
+      this._topLevelPrimaryActions = _topLevelActions.filter(action =>
+          action.__primary);
+      this._topLevelSecondaryActions = _topLevelActions.filter(action =>
+          !action.__primary);
+    },
+
     _computeMenuActions(actionRecord, hiddenActionsRecord) {
       const hiddenActions = hiddenActionsRecord.base || [];
       return actionRecord.base.filter(a => {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index 85958e5..62b4626 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -102,6 +102,12 @@
       sandbox.restore();
     });
 
+    test('primary and secondary actions split properly', () => {
+      assert.equal(element._topLevelPrimaryActions.length, 1);
+      assert.equal(element._topLevelPrimaryActions[0].label, 'Submit');
+      assert.equal(element._topLevelSecondaryActions.length, 1);
+    });
+
     test('_shouldHideActions', () => {
       assert.isTrue(element._shouldHideActions(undefined, true));
       assert.isTrue(element._shouldHideActions({base: {}}, false));
@@ -201,9 +207,7 @@
         });
         assert.equal(deleteItems.length, 1);
         assert.notEqual(deleteItems[0].name);
-        assert.isTrue(
-            deleteItems[0].name === 'Delete Change'
-        );
+        assert.equal(deleteItems[0].name, 'Delete change');
         done();
       });
     });
@@ -237,8 +241,8 @@
     test('submit change', done => {
       sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
           .returns(Promise.resolve('test'));
-      sandbox.stub(element, 'fetchIsLatestKnown',
-          () => { return Promise.resolve(true); });
+      sandbox.stub(element, 'fetchChangeUpdates',
+          () => { return Promise.resolve({isLatest: true}); });
       element.change = {
         revisions: {
           rev1: {_number: 1},
@@ -371,7 +375,7 @@
       let fireActionStub;
       const deleteEditAction = {
         enabled: true,
-        label: 'Delete Edit',
+        label: 'Delete edit',
         title: 'Delete change edit',
         __key: 'deleteEdit',
         __primary: false,
@@ -380,7 +384,7 @@
       };
       const publishEditAction = {
         enabled: true,
-        label: 'Publish Edit',
+        label: 'Publish edit',
         title: 'Publish change edit',
         __key: 'publishEdit',
         __primary: false,
@@ -389,7 +393,7 @@
       };
       const rebaseEditAction = {
         enabled: true,
-        label: 'Rebase Edit',
+        label: 'Rebase edit',
         title: 'Rebase change edit',
         __key: 'rebaseEdit',
         __primary: false,
@@ -513,7 +517,7 @@
           __type: 'revision',
           __primary: false,
           enabled: true,
-          label: 'Cherry Pick',
+          label: 'Cherry pick',
           method: 'POST',
           title: 'Cherry pick change to a different branch',
         };
@@ -620,7 +624,7 @@
       const key = 'cherrypick';
       const type = 'revision';
       const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Cherry-Picking...');
+      assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
       assert.include(element._disabledMenuActions, 'cherrypick');
       assert.isFunction(cleanup);
 
@@ -1022,8 +1026,9 @@
         assert.isNotNull(approveButton);
       });
 
-      test('is first in list of actions', () => {
-        const approveButton = element.$$('gr-button');
+      test('is first in list of secondary actions', () => {
+        const approveButton = element.$.secondaryActions
+            .querySelector('gr-button');
         assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
       });
 
@@ -1171,6 +1176,22 @@
       assert.isTrue(handler.called);
     });
 
+    test('changing changeNum or patchNum does not reload', () => {
+      const reloadStub = sandbox.stub(element, 'reload');
+      element.changeNum = 123;
+      assert.isFalse(reloadStub.called);
+      element.patchNum = 456;
+      assert.isFalse(reloadStub.called);
+    });
+
+    test('_toSentenceCase', () => {
+      assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
+      assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
+      assert.equal(element._toSentenceCase('b'), 'B');
+      assert.equal(element._toSentenceCase(''), '');
+      assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
+    });
+
     suite('setActionOverflow', () => {
       test('move action from overflow', () => {
         assert.isNotOk(element.$$('[data-action-key="cherrypick"]'));
@@ -1243,8 +1264,8 @@
         let sendStub;
 
         setup(() => {
-          sandbox.stub(element, 'fetchIsLatestKnown')
-              .returns(Promise.resolve(true));
+          sandbox.stub(element, 'fetchChangeUpdates')
+              .returns(Promise.resolve({isLatest: true}));
           sendStub = sandbox.stub(element.$.restAPI, 'getChangeURLAndSend')
               .returns(Promise.resolve({}));
         });
@@ -1272,8 +1293,8 @@
 
       suite('failure modes', () => {
         test('non-latest', () => {
-          sandbox.stub(element, 'fetchIsLatestKnown')
-              .returns(Promise.resolve(false));
+          sandbox.stub(element, 'fetchChangeUpdates')
+              .returns(Promise.resolve({isLatest: false}));
           const sendStub = sandbox.stub(element.$.restAPI,
               'getChangeURLAndSend');
 
@@ -1286,8 +1307,8 @@
         });
 
         test('send fails', () => {
-          sandbox.stub(element, 'fetchIsLatestKnown')
-              .returns(Promise.resolve(true));
+          sandbox.stub(element, 'fetchChangeUpdates')
+              .returns(Promise.resolve({isLatest: true}));
           const sendStub = sandbox.stub(element.$.restAPI,
               'getChangeURLAndSend',
               (num, method, patchNum, endpoint, payload, onErr) => {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 5f88565..66ab290 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -14,22 +14,28 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../plugins/gr-external-style/gr-external-style.html">
+<link rel="import" href="../../../styles/gr-voting-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
+<link rel="import" href="../../plugins/gr-external-style/gr-external-style.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
 <link rel="import" href="../../shared/gr-label/gr-label.html">
 <link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
+<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-commit-info/gr-commit-info.html">
 <link rel="import" href="../gr-reviewer-list/gr-reviewer-list.html">
 
 <dom-module id="gr-change-metadata">
   <template>
+    <style include="gr-voting-styles"></style>
     <style include="shared-styles">
       .hideDisplay {
         display: none;
@@ -60,21 +66,31 @@
       .labelValueContainer:not(:first-of-type) {
         margin-top: .25em;
       }
-      .labelValueContainer .approved,
-      .labelValueContainer .notApproved {
+      .labelValueContainer span {
+        align-items: baseline;
         display: inline-flex;
-        padding: .1em .3em;
+      }
+      .labelValueContainer {
         border-radius: 3px;
+        padding: .1em .3em;
       }
-      .labelValue {
-        display: inline-block;
-        padding-right: .3em;
+      gr-label {
+        margin-right: .3em;
+        padding: .05em .85em;
+        text-align: center;
+        @apply --vote-chip-styles;
       }
-      .approved {
-        background-color: #d4ffd4;
+      .max {
+        background-color: var(--vote-color-max);
       }
-      .notApproved {
-        background-color: #ffd4d4;
+      .min {
+        background-color: var(--vote-color-min);
+      }
+      .positive {
+        background-color: var(--vote-color-positive);
+      }
+      .negative {
+        background-color: var(--vote-color-negative);
       }
       .labelStatus .value {
         max-width: 9em;
@@ -91,19 +107,19 @@
 
       /* CSS Mixins should be applied last. */
       section.assignee {
-        @apply(--change-metadata-assignee);
+        @apply --change-metadata-assignee;
       }
       section.labelStatus {
-        @apply(--change-metadata-label-status);
+        @apply --change-metadata-label-status;
       }
       section.strategy {
-        @apply(--change-metadata-strategy);
+        @apply --change-metadata-strategy;
       }
       section.topic {
-        @apply(--change-metadata-topic);
+        @apply --change-metadata-topic;
       }
-      gr-account-chip([disabled]),
-      gr-linked-chip([disabled]) {
+      gr-account-chip[disabled],
+      gr-linked-chip[disabled] {
         opacity: 0;
         pointer-events: none;
       }
@@ -113,6 +129,20 @@
       #externalStyle {
         display: block;
       }
+      .parentList.merge {
+        list-style-type: decimal;
+        padding-left: 1em;
+      }
+      .parentList gr-commit-info {
+        display: inline-block;
+      }
+      #parentNotCurrentMessage {
+        display: none;
+      }
+      .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
+        --arrow-color: #ffa62f;
+        display: inline-block;
+      }
       @media screen and (max-width: 50em), screen and (min-width: 75em) {
         :host {
           display: table;
@@ -211,10 +241,32 @@
           <a href$="[[_computeBranchURL(change.project, change.branch)]]">[[change.branch]]</a>
         </span>
       </section>
+      <section>
+        <span class="title">[[_computeParentsLabel(_currentParents)]]</span>
+        <span class="value">
+          <ol class$="[[_computeParentListClass(_currentParents, parentIsCurrent)]]">
+            <template is="dom-repeat" items="[[_currentParents]]" as="parent">
+              <li>
+                <gr-commit-info
+                    change="[[change]]"
+                    commit-info="[[parent]]"
+                    server-config="[[serverConfig]]"></gr-commit-info>
+                <gr-tooltip-content
+                    id="parentNotCurrentMessage"
+                    has-tooltip
+                    show-icon
+                    title$="[[_notCurrentMessage]]"></gr-tooltip-content>
+              </li>
+            </template>
+          </ol>
+        </span>
+      </section>
       <section class="topic">
         <span class="title">Topic</span>
         <span class="value">
-          <template is="dom-if" if="[[change.topic]]">
+          <template
+              is="dom-if"
+              if="[[_showTopicChip(change.*, _settingTopic)]]">
             <gr-linked-chip
                 text="[[change.topic]]"
                 limit="40"
@@ -222,11 +274,13 @@
                 removable="[[!_topicReadOnly]]"
                 on-remove="_handleTopicRemoved"></gr-linked-chip>
           </template>
-          <template is="dom-if" if="[[!change.topic]]">
+          <template
+              is="dom-if"
+              if="[[_showAddTopic(change.*, _settingTopic)]]">
             <gr-editable-label
-                uppercase
                 label-text="Add a topic"
                 value="[[change.topic]]"
+                max-length="1024"
                 placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
                 read-only="[[_topicReadOnly]]"
                 on-changed="_handleTopicChanged"></gr-editable-label>
@@ -269,11 +323,11 @@
                 items="[[_computeLabelValues(labelName, change.labels.*)]]"
                 as="label">
               <div class="labelValueContainer">
-                <span class$="[[label.className]]">
+                <span>
                   <gr-label
                       has-tooltip
                       title="[[_computeValueTooltip(change, label.value, labelName)]]"
-                      class="labelValue">
+                      class$="[[label.className]] voteChip">
                     [[label.value]]
                   </gr-label>
                   <gr-account-chip
@@ -296,17 +350,17 @@
             <div hidden$="[[!_isWip]]">
               Work in progress
             </div>
-            <div hidden$="[[!_showMissingLabels(change.labels)]]">
-              [[_computeMissingLabelsHeader(change.labels)]]
+            <div hidden$="[[!_showMissingLabels(missingLabels)]]">
+              [[_computeMissingLabelsHeader(missingLabels)]]
               <ul id="missingLabels">
                 <template
                     is="dom-repeat"
-                    items="[[_computeMissingLabels(change.labels)]]">
+                    items="[[missingLabels]]">
                   <li>[[item]]</li>
                 </template>
               </ul>
             </div>
-            <div hidden$="[[_showMissingRequirements(change.labels, _isWip)]]">
+            <div hidden$="[[_showMissingRequirements(missingLabels, _isWip)]]">
               Ready to submit
             </div>
           </span>
@@ -323,6 +377,10 @@
           </template>
         </span>
       </section>
+      <gr-endpoint-decorator name="change-metadata-item">
+        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+        <gr-endpoint-param name="revision" value="[[revision]]"></gr-endpoint-param>
+      </gr-endpoint-decorator>
     </gr-external-style>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index e95c494..ddee577 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -25,6 +25,8 @@
     CHERRY_PICK: 'Cherry Pick',
   };
 
+  const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
+
   Polymer({
     is: 'gr-change-metadata',
 
@@ -37,12 +39,21 @@
     properties: {
       /** @type {?} */
       change: Object,
+      /** @type {?} */
+      revision: Object,
       commitInfo: Object,
+      missingLabels: Array,
       mutable: Boolean,
       /**
        * @type {{ note_db_enabled: string }}
        */
       serverConfig: Object,
+      parentIsCurrent: Boolean,
+      _notCurrentMessage: {
+        type: String,
+        value: NOT_CURRENT_MESSAGE,
+        readOnly: true,
+      },
       _topicReadOnly: {
         type: Boolean,
         computed: '_computeTopicReadOnly(mutable, change)',
@@ -66,6 +77,16 @@
         computed: '_computeIsWip(change)',
       },
       _newHashtag: String,
+
+      _settingTopic: {
+        type: Boolean,
+        value: false,
+      },
+
+      _currentParents: {
+        type: Array,
+        computed: '_computeParents(change)',
+      },
     },
 
     behaviors: [
@@ -102,27 +123,18 @@
     },
 
     /**
-     * This is a whitelist of web link types that provide direct links to
-     * the commit in the url property.
-     */
-    _isCommitWebLink(link) {
-      return link.name === 'gitiles' || link.name === 'gitweb';
-    },
-
-    /**
      * @param {Object} commitInfo
      * @return {?Array} If array is empty, returns null instead so
      * an existential check can be used to hide or show the webLinks
      * section.
      */
     _computeWebLinks(commitInfo) {
-      if (!commitInfo || !commitInfo.web_links) { return null; }
-      // We are already displaying these types of links elsewhere,
-      // don't include in the metadata links section.
-      const webLinks = commitInfo.web_links.filter(
-          l => { return !this._isCommitWebLink(l); });
-
-      return webLinks.length ? webLinks : null;
+      if (!commitInfo) { return null; }
+      const weblinks = Gerrit.Nav.getChangeWeblinks(
+          this.change ? this.change.repo : '',
+          commitInfo.commit,
+          {weblinks: commitInfo.web_links});
+      return weblinks.length ? weblinks : null;
     },
 
     _computeStrategy(change) {
@@ -136,18 +148,39 @@
     _computeLabelValues(labelName, _labels) {
       const result = [];
       const labels = _labels.base;
-      const t = labels[labelName];
-      if (!t) { return result; }
-      const approvals = t.all || [];
+      const labelInfo = labels[labelName];
+      if (!labelInfo) { return result; }
+      if (!labelInfo.values) {
+        if (labelInfo.rejected || labelInfo.approved) {
+          const ok = labelInfo.approved || !labelInfo.rejected;
+          return [{
+            value: ok ? '👍️' : '👎️',
+            className: ok ? 'positive' : 'negative',
+            account: ok ? labelInfo.approved : labelInfo.rejected,
+          }];
+        }
+        return result;
+      }
+      const approvals = labelInfo.all || [];
+      const values = Object.keys(labelInfo.values);
       for (const label of approvals) {
         if (label.value && label.value != labels[labelName].default_value) {
           let labelClassName;
           let labelValPrefix = '';
           if (label.value > 0) {
             labelValPrefix = '+';
-            labelClassName = 'approved';
+            if (parseInt(label.value, 10) ===
+                parseInt(values[values.length - 1], 10)) {
+              labelClassName = 'max';
+            } else {
+              labelClassName = 'positive';
+            }
           } else if (label.value < 0) {
-            labelClassName = 'notApproved';
+            if (parseInt(label.value, 10) === parseInt(values[0], 10)) {
+              labelClassName = 'min';
+            } else {
+              labelClassName = 'negative';
+            }
           }
           result.push({
             value: labelValPrefix + label.value,
@@ -169,8 +202,10 @@
     _handleTopicChanged(e, topic) {
       const lastTopic = this.change.topic;
       if (!topic.length) { topic = null; }
+      this._settingTopic = true;
       this.$.restAPI.setChangeTopic(this.change._number, topic)
           .then(newTopic => {
+            this._settingTopic = false;
             this.set(['change', 'topic'], newTopic);
             if (newTopic !== lastTopic) {
               this.dispatchEvent(
@@ -179,17 +214,28 @@
           });
     },
 
+    _showAddTopic(changeRecord, settingTopic) {
+      const hasTopic = !!changeRecord && !!changeRecord.base.topic;
+      return !hasTopic && !settingTopic;
+    },
+
+    _showTopicChip(changeRecord, settingTopic) {
+      const hasTopic = !!changeRecord && !!changeRecord.base.topic;
+      return hasTopic && !settingTopic;
+    },
+
     _handleHashtagChanged(e) {
       const lastHashtag = this.change.hashtag;
       if (!this._newHashtag.length) { return; }
+      const newHashtag = this._newHashtag;
+      this._newHashtag = '';
       this.$.restAPI.setChangeHashtag(
-          this.change._number, {add: [this._newHashtag]}).then(newHashtag => {
+          this.change._number, {add: [newHashtag]}).then(newHashtag => {
             this.set(['change', 'hashtags'], newHashtag);
             if (newHashtag !== lastHashtag) {
               this.dispatchEvent(
                   new CustomEvent('hashtag-changed', {bubbles: true}));
             }
-            this._newHashtag = '';
           });
     },
 
@@ -210,7 +256,9 @@
     },
 
     _computeTopicPlaceholder(_topicReadOnly) {
-      return _topicReadOnly ? 'No Topic' : 'Add Topic';
+      // Action items in Material Design are uppercase -- placeholder label text
+      // is sentence case.
+      return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
     },
 
     _computeHashtagPlaceholder(_hashtagReadOnly) {
@@ -288,33 +336,21 @@
       return isNewChange && hasLabels;
     },
 
-    _computeMissingLabels(labels) {
-      const missingLabels = [];
-      for (const label in labels) {
-        if (!labels.hasOwnProperty(label)) { continue; }
-        const obj = labels[label];
-        if (!obj.optional && !obj.approved) {
-          missingLabels.push(label);
-        }
-      }
-      return missingLabels;
-    },
-
-    _computeMissingLabelsHeader(labels) {
+    _computeMissingLabelsHeader(missingLabels) {
       return 'Needs label' +
-          (this._computeMissingLabels(labels).length > 1 ? 's' : '') + ':';
+          (missingLabels.length > 1 ? 's' : '') + ':';
     },
 
-    _showMissingLabels(labels) {
-      return !!this._computeMissingLabels(labels).length;
+    _showMissingLabels(missingLabels) {
+      return !!missingLabels.length;
     },
 
-    _showMissingRequirements(labels, workInProgress) {
-      return workInProgress || this._showMissingLabels(labels);
+    _showMissingRequirements(missingLabels, workInProgress) {
+      return workInProgress || this._showMissingLabels(missingLabels);
     },
 
     _computeProjectURL(project) {
-      return Gerrit.Nav.getUrlForProject(project);
+      return Gerrit.Nav.getUrlForProjectChanges(project);
     },
 
     _computeBranchURL(project, branch) {
@@ -383,5 +419,26 @@
 
       return rev.uploader;
     },
+
+    _computeParents(change) {
+      if (!change.current_revision ||
+          !change.revisions[change.current_revision] ||
+          !change.revisions[change.current_revision].commit) {
+        return undefined;
+      }
+      return change.revisions[change.current_revision].commit.parents;
+    },
+
+    _computeParentsLabel(parents) {
+      return parents.length > 1 ? 'Parents' : 'Parent';
+    },
+
+    _computeParentListClass(parents, parentIsCurrent) {
+      return [
+        'parentList',
+        parents.length > 1 ? 'merge' : 'nonMerge',
+        parentIsCurrent ? 'current' : 'notCurrent',
+      ].join(' ');
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index 58188cd..9ee09ea 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -21,6 +21,7 @@
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="../../core/gr-router/gr-router.html">
 <link rel="import" href="gr-change-metadata.html">
 
 <script>void(0);</script>
@@ -38,6 +39,9 @@
 
     setup(() => {
       sandbox = sinon.sandbox.create();
+      stub('gr-endpoint-decorator', {
+        _import: sandbox.stub().returns(Promise.resolve()),
+      });
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({}); },
         getLoggedIn() { return Promise.resolve(false); },
@@ -99,21 +103,23 @@
     });
 
     test('show missing labels', () => {
-      let labels = {};
-      assert.isFalse(element._showMissingLabels(labels));
-      labels = {test: {}};
-      assert.isTrue(element._showMissingLabels(labels));
-      assert.deepEqual(element._computeMissingLabels(labels), ['test']);
-      labels.test.approved = true;
-      assert.isFalse(element._showMissingLabels(labels));
-      labels.test.approved = false;
-      labels.test.optional = true;
-      assert.isFalse(element._showMissingLabels(labels));
-      labels.test.optional = false;
-      labels.test2 = {};
-      assert.isTrue(element._showMissingLabels(labels));
-      assert.deepEqual(element._computeMissingLabels(labels),
-          ['test', 'test2']);
+      let missingLabels = [];
+      assert.isFalse(element._showMissingLabels(missingLabels));
+      missingLabels = ['test'];
+      assert.isTrue(element._showMissingLabels(missingLabels));
+      missingLabels.push('test2');
+      assert.isTrue(element._showMissingLabels(missingLabels));
+    });
+
+    test('weblinks use Gerrit.Nav interface', () => {
+      const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
+          .returns([{name: 'stubb', url: '#s'}]);
+      element.commitInfo = {};
+      flushAsynchronousOperations();
+      const webLinks = element.$.webLinks;
+      assert.isTrue(weblinksStub.called);
+      assert.isFalse(webLinks.hasAttribute('hidden'));
+      assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
     });
 
     test('weblinks hidden when no weblinks', () => {
@@ -132,6 +138,10 @@
     });
 
     test('weblinks are visible when other weblinks', () => {
+      const router = document.createElement('gr-router');
+      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
+          router._generateWeblinks.bind(router));
+
       element.commitInfo = {web_links: [{name: 'test', url: '#'}]};
       flushAsynchronousOperations();
       const webLinks = element.$.webLinks;
@@ -144,6 +154,10 @@
     });
 
     test('weblinks are visible when gitiles and other weblinks', () => {
+      const router = document.createElement('gr-router');
+      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
+          router._generateWeblinks.bind(router));
+
       element.commitInfo = {
         web_links: [{name: 'test', url: '#'}, {name: 'gitiles', url: '#'}]};
       flushAsynchronousOperations();
@@ -155,6 +169,7 @@
 
     test('determines whether to show "Ready to Submit" label', () => {
       const showMissingSpy = sandbox.spy(element, '_showMissingRequirements');
+      element.missingLabels = ['bojack'];
       element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {
         test: {
           all: [{_account_id: 1, name: 'bojack', value: 1}],
@@ -303,6 +318,52 @@
       assert.equal(actual, '');
     });
 
+    test('_computeParents', () => {
+      const parents = [{commit: '123', subject: 'abc'}];
+      assert.isUndefined(element._computeParents(
+          {revisions: {456: {commit: {parents}}}}));
+      assert.isUndefined(element._computeParents(
+          {current_revision: '789', revisions: {456: {commit: {parents}}}}));
+      assert.equal(element._computeParents(
+          {current_revision: '456', revisions: {456: {commit: {parents}}}}),
+          parents);
+    });
+
+    test('_computeParentsLabel', () => {
+      const parent = {commit: 'abc123', subject: 'My parent commit'};
+      assert.equal(element._computeParentsLabel([parent]), 'Parent');
+      assert.equal(element._computeParentsLabel([parent, parent]),
+          'Parents');
+    });
+
+    test('_computeParentListClass', () => {
+      const parent = {commit: 'abc123', subject: 'My parent commit'};
+      assert.equal(element._computeParentListClass([parent], true),
+          'parentList nonMerge current');
+      assert.equal(element._computeParentListClass([parent], false),
+          'parentList nonMerge notCurrent');
+      assert.equal(element._computeParentListClass([parent, parent], false),
+          'parentList merge notCurrent');
+      assert.equal(element._computeParentListClass([parent, parent], true),
+          'parentList merge current');
+    });
+
+    test('_showAddTopic', () => {
+      assert.isTrue(element._showAddTopic(null, false));
+      assert.isTrue(element._showAddTopic({base: {topic: null}}, false));
+      assert.isFalse(element._showAddTopic({base: {topic: null}}, true));
+      assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, true));
+      assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, false));
+    });
+
+    test('_showTopicChip', () => {
+      assert.isFalse(element._showTopicChip(null, false));
+      assert.isFalse(element._showTopicChip({base: {topic: null}}, false));
+      assert.isFalse(element._showTopicChip({base: {topic: null}}, true));
+      assert.isFalse(element._showTopicChip({base: {topic: 'foo'}}, true));
+      assert.isTrue(element._showTopicChip({base: {topic: 'foo'}}, false));
+    });
+
     suite('Topic removal', () => {
       let change;
       setup(() => {
@@ -599,5 +660,129 @@
         });
       });
     });
+
+    suite('plugin endpoints', () => {
+      test('endpoint params', done => {
+        element.change = {labels: {}};
+        element.revision = {};
+        let hookEl;
+        let plugin;
+        Gerrit.install(
+            p => {
+              plugin = p;
+              plugin.hook('change-metadata-item').getLastAttached().then(
+                  el => hookEl = el);
+            },
+            '0.1',
+            'http://some/plugins/url.html');
+        Gerrit._setPluginsCount(0);
+        flush(() => {
+          assert.strictEqual(hookEl.plugin, plugin);
+          assert.strictEqual(hookEl.change, element.change);
+          assert.strictEqual(hookEl.revision, element.revision);
+          done();
+        });
+      });
+    });
+
+    suite('label colors', () => {
+      test('valueless label rejected', () => {
+        element.change = {
+          labels: {
+            'Do-Not-Submit': {
+              rejected: {name: 'someone'},
+            },
+          },
+        };
+        flushAsynchronousOperations();
+        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
+        assert.isTrue(labels[0].classList.contains('negative'));
+      });
+
+      test('valueless label approved', () => {
+        element.change = {
+          labels: {
+            'To-The-Infinity': {
+              approved: {name: 'someone'},
+            },
+          },
+        };
+        flushAsynchronousOperations();
+        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
+        assert.isTrue(labels[0].classList.contains('positive'));
+      });
+
+      test('-2 to +2', () => {
+        element.change = {
+          labels: {
+            'Code-Review': {
+              all: [
+                {value: 2, name: 'user 2'},
+                {value: 1, name: 'user 1'},
+                {value: -1, name: 'user 3'},
+                {value: -2, name: 'user 4'},
+              ],
+              values: {
+                '-2': 'Awful',
+                '-1': 'Don\'t submit as-is',
+                ' 0': 'No score',
+                '+1': 'Looks good to me',
+                '+2': 'Ready to submit',
+              },
+            },
+          },
+        };
+        flushAsynchronousOperations();
+        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
+        assert.isTrue(labels[0].classList.contains('max'));
+        assert.isTrue(labels[1].classList.contains('positive'));
+        assert.isTrue(labels[2].classList.contains('negative'));
+        assert.isTrue(labels[3].classList.contains('min'));
+      });
+
+      test('-1 to +1', () => {
+        element.change = {
+          labels: {
+            CI: {
+              all: [
+                {value: 1, name: 'user 1'},
+                {value: -1, name: 'user 2'},
+              ],
+              values: {
+                '-1': 'Don\'t submit as-is',
+                ' 0': 'No score',
+                '+1': 'Looks good to me',
+              },
+            },
+          },
+        };
+        flushAsynchronousOperations();
+        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
+        assert.isTrue(labels[0].classList.contains('max'));
+        assert.isTrue(labels[1].classList.contains('min'));
+      });
+
+      test('0 to +2', () => {
+        element.change = {
+          labels: {
+            CI: {
+              all: [
+                {value: 1, name: 'user 2'},
+                {value: 2, name: 'user '},
+              ],
+              values: {
+                ' 0': 'Don\'t submit as-is',
+                '+1': 'No score',
+                '+2': 'Looks good to me',
+              },
+            },
+          },
+        };
+        flushAsynchronousOperations();
+        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
+        assert.isTrue(labels[0].classList.contains('positive'));
+        assert.isTrue(labels[1].classList.contains('max'));
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index 3ac3a57..7e661c7 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,32 +14,36 @@
 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-comment-api/gr-comment-api.html">
 <link rel="import" href="../../diff/gr-diff-preferences/gr-diff-preferences.html">
+<link rel="import" href="../../edit/gr-edit-constants.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
+<link rel="import" href="../../shared/gr-change-status/gr-change-status.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-content/gr-editable-content.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
 <link rel="import" href="../gr-change-actions/gr-change-actions.html">
 <link rel="import" href="../gr-change-metadata/gr-change-metadata.html">
 <link rel="import" href="../gr-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>
@@ -53,9 +57,10 @@
       }
       .header {
         align-items: center;
-        background-color: var(--view-background-color);
+        background-color: #fafafa;
+        border-bottom: 1px solid #ddd;
         display: flex;
-        padding: .65em var(--default-horizontal-margin);
+        padding: .55em var(--default-horizontal-margin);
         z-index: 99;  /* Less than gr-overlay's backdrop */
       }
       .header .download {
@@ -69,22 +74,28 @@
         transition: box-shadow 250ms linear;
         width: 100%;
       }
-      .header.wip {
-        background-color: #fcfad6;
-        border-bottom: 1px solid #ddd;
-        margin-bottom: .5em;
+      gr-change-status {
+        display: initial;
+        margin: .1em .5em .1em 0;
       }
       .header-title {
+        align-items: center;
+        display: flex;
         flex: 1;
         font-size: 1.2em;
+      }
+      .header-title .headerSubject {
         font-family: var(--font-family-bold);
       }
+      .replyContainer {
+        margin-bottom: 1em;
+      }
       gr-change-star {
         margin-right: .25em;
         vertical-align: -.425em;
       }
       gr-reply-dialog {
-        width: 50em;
+        width: 60em;
       }
       .changeStatus {
         text-transform: capitalize;
@@ -105,7 +116,9 @@
         padding-right: 1em;
       }
       .changeMetadata {
+        border-right: 1px solid #ddd;
         font-size: .95em;
+        padding: 1em 0;
       }
       /* Prevent plugin text from overflowing. */
       #change_plugins {
@@ -125,16 +138,18 @@
       }
       .editCommitMessage {
         margin-top: 1em;
+        --gr-button: {
+          padding-left: 0;
+          padding-right: 0;
+        }
       }
+      .changeStatuses,
       .commitActions {
-        border-bottom: 1px solid #ddd;
+        align-items: center;
         display: flex;
-        justify-content: space-between;
-        margin-bottom: .5em;
-        padding-bottom: .5em;
       }
-      .reply {
-        margin-right: .5em;
+      .changeStatuses {
+        flex-wrap: wrap;
       }
       .mainChangeInfo {
         display: flex;
@@ -151,6 +166,7 @@
       .relatedChanges {
         flex: 1 1 auto;
         overflow: hidden;
+        padding: 1em 0;
       }
       .mobile {
         display: none;
@@ -180,6 +196,7 @@
         display: flex;
         flex-direction: column;
         flex-shrink: 0;
+        margin: 1em 0;
       }
       .collapseToggleContainer {
         display: flex;
@@ -203,6 +220,12 @@
       .scrollable {
         overflow: auto;
       }
+      .text {
+        white-space: pre;
+      }
+      gr-commit-info {
+        display: inline-block;
+      }
       @media screen and (min-width: 80em) {
         .commitMessage {
           max-width: var(--commit-message-max-width, 100ch);
@@ -218,9 +241,11 @@
         #commitMessageEditor {
           min-width: 0;
         }
-      }
-      .patchInfo {
-        margin-top: 1em;
+        gr-reply-dialog {
+          height: 100vh;
+          min-width: initial;
+          width: 100vw;
+        }
       }
       /* NOTE: If you update this breakpoint, also update the
       BREAKPOINT_RELATED_SMALL in the JS */
@@ -231,25 +256,24 @@
         .header {
           align-items: flex-start;
           flex-direction: column;
+          flex: 1;
           padding: .5em var(--default-horizontal-margin);
         }
         gr-change-star {
           vertical-align: middle;
         }
         .header-title {
+          flex-wrap: wrap;
           font-size: 1.1em;
         }
-        gr-reply-dialog {
-          min-width: initial;
-          width: 100vw;
-        }
         .desktop {
           display: none;
         }
         .reply {
           display: block;
           margin-right: 0;
-          margin-bottom: .5em;
+          /* px because don't have the same font size */
+          margin-bottom: 6px;
         }
         .changeInfo-column:not(:last-of-type) {
           margin-right: 0;
@@ -271,7 +295,9 @@
           max-width: none;
         }
         .commitActions {
-          flex-direction: column;
+          display: block;
+          margin-top: 1em;
+          width: 100%;
         }
         .commitMessage {
           flex: initial;
@@ -289,40 +315,62 @@
         id="mainContent"
         class="container"
         hidden$="{{_loading}}">
-      <div class$="hideOnMobileOverlay [[_computeHeaderClass(_change)]]">
-        <span class="header-title">
+      <div class$="[[_computeHeaderClass(_change)]]">
+        <div class="header-title">
           <gr-change-star
               id="changeStar"
               change="{{_change}}" hidden$="[[!_loggedIn]]"></gr-change-star>
-          <a
-              aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
-              href$="[[_computeChangeUrl(_change)]]">[[_change._number]]</a><!--
-       --><template is="dom-if" if="[[_changeStatus]]"><!--
-         --> (<!--
-         --><span
-                aria-label$="Change status: [[_changeStatus]]"
-                tabindex="0">[[_changeStatus]]</span><!--
+          <div class="changeStatuses">
+            <template is="dom-repeat" items="[[_changeStatuses]]" as="status">
+              <gr-change-status
+                  max-width="100"
+                  status="[[status]]"></gr-change-status>
+            </template>
+          </div>
+          <div class="changeText">
+            <a aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
+                href$="[[_computeChangeUrl(_change)]]">[[_change._number]]</a><!--
          --><template
                 is="dom-if"
-                if="[[_computeShowCommitInfo(_changeStatus, _change.current_revision)]]">
-              as
-              <gr-commit-info
-                  change="[[_change]]"
-                  commit-info="[[_computeMergedCommitInfo(_change.current_revision, _change.revisions)]]"
-                  server-config="[[_serverConfig]]"></gr-commit-info><!--
+                if="[[_computeShowCommitInfo(_changeStatus, _change.current_revision)]]"><!--
+           --><span class="text"> ([[_changeStatus]] as </span><!--
+             --><gr-commit-info
+                    change="[[_change]]"
+                    commit-info="[[_computeMergedCommitInfo(_change.current_revision, _change.revisions)]]"
+                    server-config="[[_serverConfig]]"></gr-commit-info>)<!--
          --></template><!--
-         -->)<!--
-       --></template><!--
-       -->: [[_change.subject]]
-        </span>
-      </div>
+         --><span class="text">: </span><span class="headerSubject">[[_change.subject]]</span>
+          </div>
+        </div><!-- end header-title -->
+        <div class="commitActions" hidden$="[[!_loggedIn]]">
+          <gr-change-actions id="actions"
+              change="[[_change]]"
+              has-parent="[[hasParent]]"
+              actions="[[_change.actions]]"
+              revision-actions="[[_currentRevisionActions]]"
+              change-num="[[_changeNum]]"
+              change-status="[[_change.status]]"
+              commit-num="[[_commitInfo.commit]]"
+              patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
+              reply-disabled="[[_replyDisabled]]"
+              reply-button-label="[[_replyButtonLabel]]"
+              commit-message="[[_latestCommitMessage]]"
+              edit-loaded="[[_editLoaded]]"
+              edit-based-on-current-patch-set="[[hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
+              on-reload-change="_handleReloadChange"
+              on-download-tap="_handleOpenDownloadDialog"></gr-change-actions>
+        </div><!-- end commit actions -->
+      </div><!-- end header -->
       <section class="changeInfo">
         <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
           <gr-change-metadata
               change="{{_change}}"
+              revision="[[_currentRevision]]"
               commit-info="[[_commitInfo]]"
               server-config="[[_serverConfig]]"
+              missing-labels="[[_missingLabels]]"
               mutable="[[_loggedIn]]"
+              parent-is-current="[[!_rebaseOriginallyEnabled]]"
               on-show-reply-dialog="_handleShowReplyDialog">
           </gr-change-metadata>
           <!-- Plugins insert content into following container.
@@ -331,36 +379,25 @@
           <div id="change_plugins"></div>
         </div>
         <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
-          <div class="commitActions" hidden$="[[!_loggedIn]]">
-            <gr-button
-                class="reply"
-                secondary
-                disabled="[[_replyDisabled]]"
-                on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
-            <gr-change-actions id="actions"
-                change="[[_change]]"
-                has-parent="[[hasParent]]"
-                actions="[[_change.actions]]"
-                revision-actions="[[_currentRevisionActions]]"
-                change-num="[[_changeNum]]"
-                change-status="[[_change.status]]"
-                commit-num="[[_commitInfo.commit]]"
-                patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
-                commit-message="[[_latestCommitMessage]]"
-                edit-loaded="[[_editLoaded]]"
-                edit-based-on-current-patch-set="[[hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
-                on-reload-change="_handleReloadChange"
-                on-download-tap="_handleOpenDownloadDialog"></gr-change-actions>
-          </div>
           <hr class="mobile">
           <div id="commitAndRelated" class="hideOnMobileOverlay">
             <div class="commitContainer">
+              <div class="replyContainer">
+                  <gr-button
+                      id="replyBtn"
+                      class="reply"
+                      hidden$="[[!_loggedIn]]"
+                      secondary
+                      disabled="[[_replyDisabled]]"
+                      on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
+              </div>
               <div
                   id="commitMessage"
                   class$="commitMessage [[_computeCommitClass(_commitCollapsed, _latestCommitMessage)]]">
                 <gr-editable-content id="commitMessageEditor"
                     editing="[[_editingCommitMessage]]"
                     content="{{_latestCommitMessage}}"
+                    storage-key="[[_computeCommitMessageKey(_change._number, _change.current_revision)]]"
                     remove-zero-width-space>
                   <gr-linked-text pre
                       content="[[_latestCommitMessage]]"
@@ -396,16 +433,16 @@
             </div>
             <div class="relatedChanges">
               <gr-related-changes-list id="relatedChanges"
-                  class$="[[_computeRelatedChangesClass(_relatedChangesCollapsed, _relatedChangesLoading)]]"
+                  class$="[[_computeRelatedChangesClass(_relatedChangesCollapsed)]]"
                   change="[[_change]]"
                   has-parent="{{hasParent}}"
-                  loading="{{_relatedChangesLoading}}"
                   on-update="_updateRelatedChangeMaxHeight"
-                  patch-num="[[computeLatestPatchNum(_allPatchSets)]]">
+                  patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
+                  on-new-section-loaded="_computeShowRelatedToggle">
               </gr-related-changes-list>
               <div
                   id="relatedChangesToggle"
-                  class$="collapseToggleContainer [[_computeRelatedChangesToggleClass(_relatedChangesLoading)]]">
+                  class="collapseToggleContainer">
                 <gr-button
                     link
                     id="relatedChangesToggleButton"
@@ -425,7 +462,7 @@
             all-patch-sets="[[_allPatchSets]]"
             change="[[_change]]"
             change-num="[[_changeNum]]"
-            comments="[[_comments]]"
+            change-comments="[[_changeComments]]"
             commit-info="[[_commitInfo]]"
             change-url="[[_computeChangeUrl(_change)]]"
             edit-loaded="[[_editLoaded]]"
@@ -436,7 +473,7 @@
             diff-view-mode="{{viewState.diffMode}}"
             patch-num="{{_patchRange.patchNum}}"
             base-patch-num="{{_patchRange.basePatchNum}}"
-            revisions="[[_sortedRevisions]]"
+            files-expanded="[[_filesExpanded]]"
             on-open-diff-prefs="_handleOpenDiffPrefs"
             on-open-download-dialog="_handleOpenDownloadDialog"
             on-expand-diffs="_expandAllDiffs"
@@ -447,23 +484,32 @@
             change="[[_change]]"
             change-num="[[_changeNum]]"
             patch-range="{{_patchRange}}"
-            comments="[[_comments]]"
+            change-comments="[[_changeComments]]"
             drafts="[[_diffDrafts]]"
-            revisions="[[_sortedRevisions]]"
+            revisions="[[_change.revisions]]"
             project-config="[[_projectConfig]]"
             selected-index="{{viewState.selectedFileIndex}}"
             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>
+            on-files-shown-changed="_setShownFiles"
+            on-file-action-tap="_handleFileActionTap"
+            on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
       </section>
+      <gr-endpoint-decorator name="change-view-integration">
+        <gr-endpoint-param name="change" value="[[_change]]">
+        </gr-endpoint-param>
+        <gr-endpoint-param name="revision" value="[[_currentRevision]]">
+        </gr-endpoint-param>
+      </gr-endpoint-decorator>
       <gr-messages-list id="messageList"
           class="hideOnMobileOverlay"
           change-num="[[_changeNum]]"
           messages="[[_change.messages]]"
           reviewer-updates="[[_change.reviewer_updates]]"
-          comments="[[_comments]]"
+          change-comments="[[_changeComments]]"
           project-name="[[_change.project]]"
           show-reply-buttons="[[_loggedIn]]"
           on-reply="_handleMessageReply"></gr-messages-list>
@@ -480,7 +526,6 @@
         class="scrollable"
         no-cancel-on-outside-click
         no-cancel-on-esc-key
-        on-iron-overlay-opened="_handleReplyOverlayOpen"
         with-backdrop>
       <gr-reply-dialog id="replyDialog"
           change="{{_change}}"
@@ -498,6 +543,7 @@
     </gr-overlay>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-comment-api id="commentAPI"></gr-comment-api>
   </template>
   <script src="gr-change-view.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 676e3de..fd9a490 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
@@ -39,6 +39,16 @@
 
   const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
 
+  const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
+
+  const ReloadToastMessage = {
+    NEWER_REVISION: 'A newer patch set has been uploaded',
+    RESTORED: 'This change has been restored',
+    ABANDONED: 'This change has been abandoned',
+    MERGED: 'This change has been merged',
+    NEW_MESSAGE: 'There are new messages on this change',
+  };
+
   Polymer({
     is: 'gr-change-view',
 
@@ -96,6 +106,8 @@
         type: Object,
         value: {},
       },
+      /** @type {?} */
+      _changeComments: Object,
       _canStartReview: {
         type: Boolean,
         computed: '_computeCanStartReview(_loggedIn, _change, _account)',
@@ -148,11 +160,9 @@
       // 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,
-        value: true,
-      },
+      _currentRevision: Object,
       _currentRevisionActions: Object,
       _allPatchSets: {
         type: Array,
@@ -164,6 +174,11 @@
       },
       _loading: Boolean,
       /** @type {?} */
+      _missingLabels: {
+        type: Array,
+        computed: '_computeMissingLabels(_change.labels)',
+      },
+      /** @type {?} */
       _projectConfig: Object,
       _rebaseOnCurrent: Boolean,
       _replyButtonLabel: {
@@ -186,6 +201,10 @@
         type: String,
         computed: 'changeStatusString(_change)',
       },
+      _changeStatuses: {
+        type: String,
+        computed: '_computeChangeStatusChips(_change, _missingLabels)',
+      },
       _commitCollapsed: {
         type: Boolean,
         value: true,
@@ -196,11 +215,16 @@
       },
       /** @type {?number} */
       _updateCheckTimerHandle: Number,
-      _sortedRevisions: Array,
       _editLoaded: {
         type: Boolean,
         computed: '_computeEditLoaded(_patchRange.*)',
       },
+      _showRelatedToggle: {
+        type: Boolean,
+        value: false,
+        observer: '_updateToggleContainerClass',
+      },
+      _rebaseOriginallyEnabled: Boolean,
     },
 
     behaviors: [
@@ -222,7 +246,6 @@
     observers: [
       '_labelsChanged(_change.labels.*)',
       '_paramsAndChangeChanged(params, _change)',
-      '_updateSortedRevisions(_change.revisions.*)',
     ],
 
     keyBindings: {
@@ -252,7 +275,7 @@
       });
 
       this.addEventListener('comment-save', this._handleCommentSave.bind(this));
-      this.addEventListener('comment-refresh', this._getDiffDrafts.bind(this));
+      this.addEventListener('comment-refresh', this._reloadDrafts.bind(this));
       this.addEventListener('comment-discard',
           this._handleCommentDiscard.bind(this));
       this.addEventListener('editable-content-save',
@@ -289,18 +312,14 @@
       });
     },
 
-    _updateSortedRevisions(revisionsRecord) {
-      const revisions = revisionsRecord.base;
-      this._sortedRevisions = this.sortRevisions(Object.values(revisions));
-    },
-
     _handleEditCommitMessage(e) {
       this._editingCommitMessage = true;
       this.$.commitMessageEditor.focusTextarea();
     },
 
     _handleCommitMessageSave(e) {
-      const message = e.detail.content;
+      // Trim trailing whitespace from each line.
+      const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
 
       this.$.jsAPI.handleCommitMessage(this._change, message);
 
@@ -327,6 +346,30 @@
       this._editingCommitMessage = false;
     },
 
+    _computeMissingLabels(labels) {
+      const missingLabels = [];
+      for (const label in labels) {
+        if (!labels.hasOwnProperty(label)) { continue; }
+        const obj = labels[label];
+        if (!obj.optional && !obj.approved) {
+          missingLabels.push(label);
+        }
+      }
+      return missingLabels;
+    },
+
+    _readyToSubmit(missingLabels) {
+      return missingLabels.length === 0;
+    },
+
+    _computeChangeStatusChips(change, missingLabels) {
+      const options = {
+        readyToSubmit: this._readyToSubmit(missingLabels),
+        includeDerived: true,
+      };
+      return this.changeStatuses(change, options);
+    },
+
     _computeHideEditCommitMessage(loggedIn, editing, change) {
       if (!loggedIn || editing || change.status === this.ChangeStatus.MERGED) {
         return true;
@@ -403,7 +446,7 @@
 
     _handleReplyTap(e) {
       e.preventDefault();
-      this._openReplyDialog();
+      this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
     },
 
     _handleOpenDiffPrefs() {
@@ -426,20 +469,8 @@
       const msg = e.detail.message.message;
       const quoteStr = msg.split('\n').map(
           line => { return '> ' + line; }).join('\n') + '\n\n';
-
-      if (quoteStr !== this.$.replyDialog.quote) {
-        this.$.replyDialog.draft = quoteStr;
-      }
       this.$.replyDialog.quote = quoteStr;
-      this._openReplyDialog();
-    },
-
-    _handleReplyOverlayOpen(e) {
-      // This is needed so that focus is not set on the reply overlay
-      // when the suggestion overaly from gr-autogrow-textarea opens.
-      if (e.target === this.$.replyOverlay) {
-        this.$.replyDialog.focus();
-      }
+      this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
     },
 
     _handleHideBackgroundContent() {
@@ -621,7 +652,7 @@
         if (!loggedIn) { return; }
 
         if (this.viewState.showReplyDialog) {
-          this._openReplyDialog();
+          this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
           // TODO(kaspern@): Find a better signal for when to call center.
           this.async(() => { this.$.replyOverlay.center(); }, 100);
           this.async(() => { this.$.replyOverlay.center(); }, 1000);
@@ -652,6 +683,10 @@
           this._patchRange.patchNum ||
               this.computeLatestPatchNum(this._allPatchSets));
 
+      // Reset the related changes toggle in the event it was previously
+      // displayed on an earlier change.
+      this._showRelatedToggle = false;
+
       const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
       this.fire('title-change', {title});
     },
@@ -768,7 +803,7 @@
         }
 
         e.preventDefault();
-        this._openReplyDialog();
+        this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
       });
     },
 
@@ -884,12 +919,6 @@
       this.fire('page-error', {response});
     },
 
-    _getDiffDrafts() {
-      return this.$.restAPI.getDiffDrafts(this._changeNum).then(drafts => {
-        this._diffDrafts = drafts;
-      });
-    },
-
     _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
@@ -909,6 +938,7 @@
       if (revisionActions && revisionActions.rebase) {
         revisionActions.rebase.rebaseOnCurrent =
             !!revisionActions.rebase.enabled;
+        this._rebaseOriginallyEnabled = !!revisionActions.rebase.enabled;
         revisionActions.rebase.enabled = true;
       }
       return revisionActions;
@@ -982,6 +1012,7 @@
                 parseInt(lineHeight.slice(0, lineHeight.length - 2), 10);
 
             this._change = change;
+            this._currentRevision = currentRevision;
             if (!this._patchRange || !this._patchRange.patchNum ||
                 this.patchNumEquals(this._patchRange.patchNum,
                     currentRevision._number)) {
@@ -997,12 +1028,6 @@
           });
     },
 
-    _getComments() {
-      return this.$.restAPI.getDiffComments(this._changeNum).then(comments => {
-        this._comments = comments;
-      });
-    },
-
     _getEdit() {
       return this.$.restAPI.getChangeEdit(this._changeNum, true);
     },
@@ -1042,30 +1067,45 @@
           });
     },
 
-    _reloadDiffDrafts() {
-      this._diffDrafts = {};
-      this._getDiffDrafts().then(() => {
-        if (this.$.replyOverlay.opened) {
-          this.async(() => { this.$.replyOverlay.center(); }, 1);
-        }
+    _reloadDraftsWithCallback(e) {
+      return this._reloadDrafts().then(() => {
+        return e.detail.resolve();
       });
     },
 
+    /**
+     * Fetches a new changeComment object, and data for all types of comments
+     * (comments, robot comments, draft comments) is requested.
+     */
+    _reloadComments() {
+      return this.$.commentAPI.loadAll(this._changeNum)
+          .then(comments => {
+            this._changeComments = comments;
+            this._diffDrafts = Object.assign({}, this._changeComments.drafts);
+          });
+    },
+
+    /**
+     * Fetches a new changeComment object, but only updated data for drafts is
+     * requested.
+     */
+    _reloadDrafts() {
+      return this.$.commentAPI.reloadDrafts(this._changeNum)
+          .then(comments => {
+            this._changeComments = comments;
+            this._diffDrafts = Object.assign({}, this._changeComments.drafts);
+          });
+    },
+
     _reload() {
       this._loading = true;
       this._relatedChangesCollapsed = true;
 
-      this._getLoggedIn().then(loggedIn => {
-        if (!loggedIn) { return; }
-
-        this._reloadDiffDrafts();
-      });
-
       const detailCompletes = this._getChangeDetail().then(() => {
         this._loading = false;
         this._getProjectConfig();
       });
-      this._getComments();
+      this._reloadComments();
 
       if (this._patchRange.patchNum) {
         return Promise.all([
@@ -1112,12 +1152,7 @@
       return collapsed ? 'collapsed' : '';
     },
 
-    _computeRelatedChangesClass(collapsed, loading) {
-      // TODO(beckysiegel) figure out how to check for customstyle in Polymer2,
-      // since customStyle was removed.
-      if (!loading && !this.customStyle['--relation-chain-max-height']) {
-        this._updateRelatedChangeMaxHeight();
-      }
+    _computeRelatedChangesClass(collapsed) {
       return collapsed ? 'collapsed' : '';
     },
 
@@ -1220,14 +1255,33 @@
       this.updateStyles(stylesToUpdate);
     },
 
-    _computeRelatedChangesToggleClass() {
+    _computeShowRelatedToggle() {
+      // Make sure the max height has been applied, since there is now content
+      // to populate.
+      // TODO update to polymer 2.x syntax
+      if (!this.getComputedStyleValue('--relation-chain-max-height')) {
+        this._updateRelatedChangeMaxHeight();
+      }
       // Prevents showMore from showing when click on related change, since the
       // line height would be positive, but related changes height is 0.
-      if (!this._getScrollHeight(this.$.relatedChanges)) { return ''; }
+      if (!this._getScrollHeight(this.$.relatedChanges)) {
+        return this._showRelatedToggle = false;
+      }
 
-      return this._getScrollHeight(this.$.relatedChanges) >
+      if (this._getScrollHeight(this.$.relatedChanges) >
           (this._getOffsetHeight(this.$.relatedChanges) +
-          this._getLineHeight(this.$.relatedChanges)) ? 'showToggle' : '';
+          this._getLineHeight(this.$.relatedChanges))) {
+        return this._showRelatedToggle = true;
+      }
+      this._showRelatedToggle = false;
+    },
+
+    _updateToggleContainerClass(showRelatedToggle) {
+      if (showRelatedToggle) {
+        this.$.relatedChangesToggle.classList.add('showToggle');
+      } else {
+        this.$.relatedChangesToggle.classList.remove('showToggle');
+      }
     },
 
     _startUpdateCheckTimer() {
@@ -1239,24 +1293,37 @@
       }
 
       this._updateCheckTimerHandle = this.async(() => {
-        this.fetchIsLatestKnown(this._change, this.$.restAPI)
-            .then(latest => {
-              if (latest) {
-                this._startUpdateCheckTimer();
-              } else {
-                this._cancelUpdateCheckTimer();
-                this.fire('show-alert', {
-                  message: 'A newer patch set has been uploaded.',
-                  // Persist this alert.
-                  dismissOnNavigation: true,
-                  action: 'Reload',
-                  callback: function() {
-                    // Load the current change without any patch range.
-                    Gerrit.Nav.navigateToChange(this._change);
-                  }.bind(this),
-                });
-              }
-            });
+        this.fetchChangeUpdates(this._change, this.$.restAPI).then(result => {
+          let toastMessage = null;
+          if (!result.isLatest) {
+            toastMessage = ReloadToastMessage.NEWER_REVISION;
+          } else if (result.newStatus === this.ChangeStatus.MERGED) {
+            toastMessage = ReloadToastMessage.MERGED;
+          } else if (result.newStatus === this.ChangeStatus.ABANDONED) {
+            toastMessage = ReloadToastMessage.ABANDONED;
+          } else if (result.newStatus === this.ChangeStatus.NEW) {
+            toastMessage = ReloadToastMessage.RESTORED;
+          } else if (result.newMessages) {
+            toastMessage = ReloadToastMessage.NEW_MESSAGE;
+          }
+
+          if (!toastMessage) {
+            this._startUpdateCheckTimer();
+            return;
+          }
+
+          this._cancelUpdateCheckTimer();
+          this.fire('show-alert', {
+            message: toastMessage,
+            // Persist this alert.
+            dismissOnNavigation: true,
+            action: 'Reload',
+            callback: function() {
+              // Load the current change without any patch range.
+              Gerrit.Nav.navigateToChange(this._change);
+            }.bind(this),
+          });
+        });
       }, this._serverConfig.change.update_delay * 1000);
     },
 
@@ -1287,5 +1354,30 @@
       const patchRange = patchRangeRecord.base || {};
       return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
     },
+
+    _handleFileActionTap(e) {
+      e.preventDefault();
+      const controls = this.$.fileListHeader.$.editControls;
+      const path = e.detail.path;
+      switch (e.detail.action) {
+        case GrEditConstants.Actions.DELETE.id:
+          controls.openDeleteDialog(path);
+          break;
+        case GrEditConstants.Actions.EDIT.id:
+          Gerrit.Nav.navigateToRelativeUrl(
+              Gerrit.Nav.getEditUrlForDiff(this._change, path));
+          break;
+        case GrEditConstants.Actions.RENAME.id:
+          controls.openRenameDialog(path);
+          break;
+        case GrEditConstants.Actions.RESTORE.id:
+          controls.openRestoreDialog(path);
+          break;
+      }
+    },
+
+    _computeCommitMessageKey(number, revision) {
+      return `c${number}_rev${revision}`;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index 6b86756..d8367610 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -23,6 +23,7 @@
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../bower_components/page/page.js"></script>
 
+<link rel="import" href="../../edit/gr-edit-constants.html">
 <link rel="import" href="gr-change-view.html">
 
 <script>void(0);</script>
@@ -48,6 +49,11 @@
 
     setup(() => {
       sandbox = sinon.sandbox.create();
+      stub('gr-endpoint-decorator', {
+        _import: sandbox.stub().returns(Promise.resolve()),
+      });
+      // Since _endpoints are global, must reset state.
+      Gerrit._endpoints = new GrPluginEndpoints();
       navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({test: 'config'}); },
@@ -55,6 +61,8 @@
         _fetchSharedCacheURL() { return Promise.resolve({}); },
       });
       element = fixture('basic');
+      sandbox.stub(element.$.actions, 'reload').returns(Promise.resolve());
+      Gerrit._setPluginsCount(0);
     });
 
     teardown(done => {
@@ -64,11 +72,13 @@
       });
     });
 
-    suite('keyboard shortcuts', () => {
-      setup(() => {
-        sandbox.stub(element, '_updateSortedRevisions');
-      });
+    getCustomCssValue = cssParam => {
+      // TODO: Update to be compatible with 2.x when we upgrade from
+      // 1.x to 2.x.
+      return element.getComputedStyleValue(cssParam);
+    };
 
+    suite('keyboard shortcuts', () => {
       test('S should toggle the CL star', () => {
         const starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
         MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
@@ -116,14 +126,20 @@
 
       test('A toggles overlay when logged in', done => {
         sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sandbox.stub(element.$.replyDialog, 'fetchIsLatestKnown')
-            .returns(Promise.resolve(true));
+        sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates')
+            .returns(Promise.resolve({isLatest: true}));
         element._change = {labels: {}};
+        const openSpy = sandbox.spy(element, '_openReplyDialog');
+
         MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
         flush(() => {
           assert.isTrue(element.$.replyOverlay.opened);
           element.$.replyOverlay.close();
           assert.isFalse(element.$.replyOverlay.opened);
+          assert(openSpy.lastCall.calledWithExactly(
+              element.$.replyDialog.FocusTarget.ANY),
+              '_openReplyDialog should have been passed ANY');
+          assert.equal(openSpy.callCount, 1);
           done();
         });
       });
@@ -147,7 +163,7 @@
         element.$.replyDialog.fire('fullscreen-overlay-opened');
         assert.isTrue(element._handleHideBackgroundContent.called);
         assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
-        assert.equal(getComputedStyle(element.$.actions).display, 'block');
+        assert.equal(getComputedStyle(element.$.actions).display, 'flex');
       });
 
       test('fullscreen-overlay-closed shows content', () => {
@@ -210,7 +226,7 @@
               change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
               _number: 42,
               revisions: {
-                rev1: {_number: 1},
+                rev1: {_number: 1, commit: {parents: []}},
               },
               current_revision: 'rev1',
               status: 'NEW',
@@ -218,8 +234,6 @@
               actions: {},
             };
 
-            sandbox.stub(element.$.actions, 'reload');
-
             navigateToChangeStub.restore();
             navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange',
                 (change, patchNum, basePatchNum) => {
@@ -245,6 +259,50 @@
       });
     });
 
+    suite('reloading drafts', () => {
+      let reloadStub;
+      const drafts = {
+        'testfile.txt': [
+          {
+            patch_set: 5,
+            id: 'dd2982f5_c01c9e6a',
+            line: 1,
+            updated: '2017-11-08 18:47:45.000000000',
+            message: 'test',
+            unresolved: true,
+          },
+        ],
+      };
+      setup(() => {
+        reloadStub = sandbox.stub(element.$.commentAPI, 'reloadDrafts')
+          .returns(Promise.resolve({
+            drafts,
+          }
+        ));
+      });
+
+      test('drafts are reloaded when reload-drafts fired', done => {
+        element.$.fileList.fire('reload-drafts', {
+          resolve: () => {
+            assert.isTrue(reloadStub.called);
+            assert.deepEqual(element._diffDrafts, drafts);
+            done();
+          },
+        });
+      });
+
+      test('drafts are reloaded when comment-refresh fired', () => {
+        element.fire('comment-refresh');
+        assert.isTrue(reloadStub.called);
+      });
+    });
+
+    test('reply button is not visible when logged out', () => {
+      assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
+      element._loggedIn = true;
+      assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
+    });
+
     test('download tap calls _handleOpenDownloadDialog', () => {
       sandbox.stub(element, '_handleOpenDownloadDialog');
       element.$.actions.fire('download-tap');
@@ -258,6 +316,53 @@
       });
     });
 
+    test('_changeStatuses', () => {
+      sandbox.stub(element, 'changeStatuses').returns(
+          ['Merged', 'WIP']);
+      element._loading = false;
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2},
+          rev1: {_number: 1},
+          rev13: {_number: 13},
+          rev3: {_number: 3},
+        },
+        current_revision: 'rev3',
+        labels: {
+          test: {
+            all: [],
+            default_value: 0,
+            values: [],
+            approved: {},
+          },
+        },
+      };
+      expectedStatuses = ['Merged', 'WIP'];
+      assert.deepEqual(element._changeStatuses, expectedStatuses);
+      assert.equal(element._changeStatus, expectedStatuses.join(', '));
+      flushAsynchronousOperations();
+      const statusChips = Polymer.dom(element.root)
+          .querySelectorAll('gr-change-status');
+      assert.equal(statusChips.length, 2);
+    });
+
+    test('_computeMissingLabels', () => {
+      let labels = {};
+      assert.equal(element._computeMissingLabels(labels).length, 0);
+      labels = {test: {}};
+      assert.deepEqual(element._computeMissingLabels(labels), ['test']);
+      labels.test.approved = true;
+      assert.equal(element._computeMissingLabels(labels).length, 0);
+      labels.test.approved = false;
+      labels.test.optional = true;
+      assert.equal(element._computeMissingLabels(labels).length, 0);
+      labels.test.optional = false;
+      labels.test2 = {};
+      assert.deepEqual(element._computeMissingLabels(labels),
+          ['test', 'test2']);
+    });
+
     test('diff preferences open when open-diff-prefs is fired', () => {
       const overlayOpenStub = sandbox.stub(element.$.fileList,
           'openDiffPrefs');
@@ -325,10 +430,10 @@
       element._change = {
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
         revisions: {
-          rev2: {_number: 2},
-          rev1: {_number: 1},
-          rev13: {_number: 13},
-          rev3: {_number: 3},
+          rev2: {_number: 2, commit: {parents: []}},
+          rev1: {_number: 1, commit: {parents: []}},
+          rev13: {_number: 13, commit: {parents: []}},
+          rev3: {_number: 3, commit: {parents: []}},
         },
         current_revision: 'rev3',
         status: 'NEW',
@@ -416,7 +521,6 @@
     });
 
     test('change num change', () => {
-      sandbox.stub(element, '_updateSortedRevisions');
       element._changeNum = null;
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -612,6 +716,22 @@
           _change));
     });
 
+    test('_handleCommitMessageSave trims trailing whitespace', () => {
+      const putStub = sandbox.stub(element.$.restAPI, 'putChangeCommitMessage')
+          .returns(Promise.resolve({}));
+
+      const mockEvent = content => { return {detail: {content}}; };
+
+      element._handleCommitMessageSave(mockEvent('test \n  test '));
+      assert.equal(putStub.lastCall.args[1], 'test\n  test');
+
+      element._handleCommitMessageSave(mockEvent('  test\ntest'));
+      assert.equal(putStub.lastCall.args[1], '  test\ntest');
+
+      element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
+      assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
+    });
+
     test('_computeChangeIdCommitMessageError', () => {
       let commitMessage =
         'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
@@ -748,6 +868,27 @@
       });
     });
 
+    test('_openReplyDialog called with `ANY` when coming from tap event',
+        () => {
+          const openStub = sandbox.stub(element, '_openReplyDialog');
+          element._serverConfig = {};
+          MockInteractions.tap(element.$.replyBtn);
+          assert(openStub.lastCall.calledWithExactly(
+              element.$.replyDialog.FocusTarget.ANY),
+              '_openReplyDialog should have been passed ANY');
+          assert.equal(openStub.callCount, 1);
+        });
+
+    test('_openReplyDialog called with `BODY` when coming from message reply' +
+        'event', () => {
+      const openStub = sandbox.stub(element, '_openReplyDialog');
+      element.$.messageList.fire('reply', {message: {message: 'text'}});
+      assert(openStub.lastCall.calledWithExactly(
+          element.$.replyDialog.FocusTarget.BODY),
+          '_openReplyDialog should have been passed BODY');
+      assert.equal(openStub.callCount, 1);
+    });
+
     test('reply dialog focus can be controlled', () => {
       const FocusTarget = element.$.replyDialog.FocusTarget;
       const openStub = sandbox.stub(element, '_openReplyDialog');
@@ -756,11 +897,13 @@
       element._handleShowReplyDialog(e);
       assert(openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
           '_openReplyDialog should have been passed REVIEWERS');
+      assert.equal(openStub.callCount, 1);
 
       e.detail.value = {ccsOnly: true};
       element._handleShowReplyDialog(e);
       assert(openStub.lastCall.calledWithExactly(FocusTarget.CCS),
           '_openReplyDialog should have been passed CCS');
+      assert.equal(openStub.callCount, 2);
     });
 
     test('getUrlParameter functionality', () => {
@@ -793,8 +936,8 @@
       element._change = {
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
         revisions: {
-          rev1: {_number: 1},
-          rev2: {_number: 2},
+          rev1: {_number: 1, commit: {parents: []}},
+          rev2: {_number: 2, commit: {parents: []}},
         },
         current_revision: 'rev1',
         status: element.ChangeStatus.MERGED,
@@ -854,16 +997,14 @@
     suite('reply dialog tests', () => {
       setup(() => {
         sandbox.stub(element.$.replyDialog, '_draftChanged');
-        sandbox.stub(element, '_updateSortedRevisions');
-        sandbox.stub(element.$.replyDialog, 'fetchIsLatestKnown',
-            () => { return Promise.resolve(true); });
+        sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates',
+            () => { return Promise.resolve({isLatest: true}); });
         element._change = {labels: {}};
       });
 
       test('reply from comment adds quote text', () => {
         const e = {detail: {message: {message: 'quote text'}}};
         element._handleMessageReply(e);
-        assert.equal(element.$.replyDialog.draft, '> quote text\n\n');
         assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
       });
 
@@ -872,7 +1013,6 @@
         element.$.replyDialog.quote = '> old quote text\n\n';
         const e = {detail: {message: {message: 'quote text'}}};
         element._handleMessageReply(e);
-        assert.equal(element.$.replyDialog.draft, '> quote text\n\n');
         assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
       });
 
@@ -906,8 +1046,8 @@
 
     suite('commit message expand/collapse', () => {
       setup(() => {
-        sandbox.stub(element, 'fetchIsLatestKnown',
-            () => { return Promise.resolve(false); });
+        sandbox.stub(element, 'fetchChangeUpdates',
+            () => { return Promise.resolve({isLatest: false}); });
       });
 
       test('commitCollapseToggle hidden for short commit message', () => {
@@ -938,6 +1078,24 @@
         updateHeightSpy = sandbox.spy(element, '_updateRelatedChangeMaxHeight');
       });
 
+      test('_showRelatedToggle is reset when a new change is loaded', () => {
+        element._patchRange = {};
+        assert.isFalse(element._showRelatedToggle);
+        element._showRelatedToggle = true;
+        element._change = {
+          change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+          _number: 42,
+          revisions: {
+            rev1: {_number: 1, commit: {parents: []}},
+          },
+          current_revision: 'rev1',
+          status: 'NEW',
+          labels: {},
+          actions: {},
+        };
+        assert.isFalse(element._showRelatedToggle);
+      });
+
       test('relatedChangesToggle shown height greater than changeInfo height',
           () => {
             assert.isFalse(element.$.relatedChangesToggle.classList
@@ -946,7 +1104,8 @@
             sandbox.stub(element, '_getScrollHeight', () => 60);
             sandbox.stub(element, '_getLineHeight', () => 5);
             sandbox.stub(window, 'matchMedia', () => ({matches: true}));
-            element._relatedChangesLoading = false;
+            element.$.relatedChanges.dispatchEvent(
+                new CustomEvent('new-section-loaded'));
             assert.isTrue(element.$.relatedChangesToggle.classList
                 .contains('showToggle'));
             assert.equal(updateHeightSpy.callCount, 1);
@@ -960,7 +1119,8 @@
             sandbox.stub(element, '_getScrollHeight', () => 40);
             sandbox.stub(element, '_getLineHeight', () => 5);
             sandbox.stub(window, 'matchMedia', () => ({matches: true}));
-            element._relatedChangesLoading = false;
+            element.$.relatedChanges.dispatchEvent(
+                new CustomEvent('new-section-loaded'));
             assert.isFalse(element.$.relatedChangesToggle.classList
                 .contains('showToggle'));
             assert.equal(updateHeightSpy.callCount, 1);
@@ -989,10 +1149,10 @@
         // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
 
         element._updateRelatedChangeMaxHeight();
-        assert.equal(element.customStyle['--relation-chain-max-height'],
+        assert.equal(getCustomCssValue('--relation-chain-max-height'),
             '12px');
-        assert.equal(element.customStyle['--related-change-btn-top-padding'],
-            undefined);
+        assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
+            '');
       });
 
       test('_updateRelatedChangeMaxHeight with commit toggle', () => {
@@ -1005,9 +1165,9 @@
         // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
 
         element._updateRelatedChangeMaxHeight();
-        assert.equal(element.customStyle['--relation-chain-max-height'],
+        assert.equal(getCustomCssValue('--relation-chain-max-height'),
             '48px');
-        assert.equal(element.customStyle['--related-change-btn-top-padding'],
+        assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
             '2px');
       });
 
@@ -1022,7 +1182,7 @@
         // 400 (new height) % 12 (line height) = 4 (remainder).
         // 400 (new height) - 4 (remainder) = 396.
 
-        assert.equal(element.customStyle['--relation-chain-max-height'],
+        assert.equal(getCustomCssValue('--relation-chain-max-height'),
             '396px');
       });
 
@@ -1041,7 +1201,7 @@
         // 100 (new height) % 12 (line height) = 4 (remainder).
         // 100 (new height) - 4 (remainder) = 96.
         element._updateRelatedChangeMaxHeight();
-        assert.equal(element.customStyle['--relation-chain-max-height'],
+        assert.equal(getCustomCssValue('--relation-chain-max-height'),
             '96px');
       });
 
@@ -1057,29 +1217,58 @@
         });
 
         test('_startUpdateCheckTimer negative delay', () => {
-          sandbox.stub(element, 'fetchIsLatestKnown');
+          sandbox.stub(element, 'fetchChangeUpdates');
 
           element._serverConfig = {change: {update_delay: -1}};
 
           assert.isTrue(element._startUpdateCheckTimer.called);
-          assert.isFalse(element.fetchIsLatestKnown.called);
+          assert.isFalse(element.fetchChangeUpdates.called);
         });
 
         test('_startUpdateCheckTimer up-to-date', () => {
-          sandbox.stub(element, 'fetchIsLatestKnown',
-              () => { return Promise.resolve(true); });
+          sandbox.stub(element, 'fetchChangeUpdates',
+              () => { return Promise.resolve({isLatest: true}); });
 
           element._serverConfig = {change: {update_delay: 12345}};
 
           assert.isTrue(element._startUpdateCheckTimer.called);
-          assert.isTrue(element.fetchIsLatestKnown.called);
+          assert.isTrue(element.fetchChangeUpdates.called);
           assert.equal(element.async.lastCall.args[1], 12345 * 1000);
         });
 
         test('_startUpdateCheckTimer out-of-date shows an alert', done => {
-          sandbox.stub(element, 'fetchIsLatestKnown',
-              () => { return Promise.resolve(false); });
-          element.addEventListener('show-alert', () => {
+          sandbox.stub(element, 'fetchChangeUpdates',
+              () => { return Promise.resolve({isLatest: false}); });
+          element.addEventListener('show-alert', e => {
+            assert.equal(e.detail.message,
+                'A newer patch set has been uploaded');
+            done();
+          });
+          element._serverConfig = {change: {update_delay: 12345}};
+        });
+
+        test('_startUpdateCheckTimer new status shows an alert', done => {
+          sandbox.stub(element, 'fetchChangeUpdates')
+              .returns(Promise.resolve({
+                isLatest: true,
+                newStatus: element.ChangeStatus.MERGED,
+              }));
+          element.addEventListener('show-alert', e => {
+            assert.equal(e.detail.message, 'This change has been merged');
+            done();
+          });
+          element._serverConfig = {change: {update_delay: 12345}};
+        });
+
+        test('_startUpdateCheckTimer new messages shows an alert', done => {
+          sandbox.stub(element, 'fetchChangeUpdates')
+              .returns(Promise.resolve({
+                isLatest: true,
+                newMessages: true,
+              }));
+          element.addEventListener('show-alert', e => {
+            assert.equal(e.detail.message,
+                'There are new messages on this change');
             done();
           });
           element._serverConfig = {change: {update_delay: 12345}};
@@ -1186,5 +1375,72 @@
 
       assert.isFalse(element._editLoaded);
     });
+
+    test('file-action-tap handling', () => {
+      const fileList = element.$.fileList;
+      const Actions = GrEditConstants.Actions;
+      const controls = element.$.fileListHeader.$.editControls;
+      sandbox.stub(controls, 'openDeleteDialog');
+      sandbox.stub(controls, 'openRenameDialog');
+      sandbox.stub(controls, 'openRestoreDialog');
+      sandbox.stub(Gerrit.Nav, 'getEditUrlForDiff');
+      sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
+
+      // Delete
+      fileList.dispatchEvent(new CustomEvent('file-action-tap',
+          {detail: {action: Actions.DELETE.id, path: 'foo'}, bubbles: true}));
+      flushAsynchronousOperations();
+
+      assert.isTrue(controls.openDeleteDialog.called);
+      assert.equal(controls.openDeleteDialog.lastCall.args[0], 'foo');
+
+      // Restore
+      fileList.dispatchEvent(new CustomEvent('file-action-tap',
+          {detail: {action: Actions.RESTORE.id, path: 'foo'}, bubbles: true}));
+      flushAsynchronousOperations();
+
+      assert.isTrue(controls.openRestoreDialog.called);
+      assert.equal(controls.openRestoreDialog.lastCall.args[0], 'foo');
+
+      // Rename
+      fileList.dispatchEvent(new CustomEvent('file-action-tap',
+          {detail: {action: Actions.RENAME.id, path: 'foo'}, bubbles: true}));
+      flushAsynchronousOperations();
+
+      assert.isTrue(controls.openRenameDialog.called);
+      assert.equal(controls.openRenameDialog.lastCall.args[0], 'foo');
+
+      // Edit
+      fileList.dispatchEvent(new CustomEvent('file-action-tap',
+          {detail: {action: Actions.EDIT.id, path: 'foo'}, bubbles: true}));
+      flushAsynchronousOperations();
+
+      assert.isTrue(Gerrit.Nav.getEditUrlForDiff.called);
+      assert.equal(Gerrit.Nav.getEditUrlForDiff.lastCall.args[1], 'foo');
+      assert.isTrue(Gerrit.Nav.navigateToRelativeUrl.called);
+    });
+
+    suite('plugin endpoints', () => {
+      test('endpoint params', done => {
+        element._change = {labels: {}};
+        element._currentRevision = {};
+        let hookEl;
+        let plugin;
+        Gerrit.install(
+            p => {
+              plugin = p;
+              plugin.hook('change-view-integration').getLastAttached().then(
+                  el => hookEl = el);
+            },
+            '0.1',
+            'http://some/plugins/url.html');
+        flush(() => {
+          assert.strictEqual(hookEl.plugin, plugin);
+          assert.strictEqual(hookEl.change, element._change);
+          assert.strictEqual(hookEl.revision, element._currentRevision);
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
index e6f37cf..1b5d6f9 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
@@ -77,7 +77,7 @@
               class="message"
               no-trailing-margin
               content="[[comment.message]]"
-              config="[[commentLinks]]"></gr-formatted-text>
+              config="[[projectConfig.commentlinks]]"></gr-formatted-text>
         </div>
       </template>
     </template>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
index 7e7a0ec..7fa8c7a 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
@@ -26,7 +26,6 @@
       changeNum: Number,
       comments: Object,
       patchNum: Number,
-      commentLinks: Object,
       projectName: String,
       /** @type {?} */
       projectConfig: Object,
@@ -47,8 +46,11 @@
     },
 
     _computeDiffLineURL(file, changeNum, patchNum, comment) {
+      const basePatchNum = comment.hasOwnProperty('parent') ?
+          -comment.parent : null;
       return Gerrit.Nav.getUrlForDiffById(this.changeNum, this.projectName,
-          file, patchNum, null, comment.line, this._isOnParent(comment));
+          file, patchNum, basePatchNum, comment.line,
+          this._isOnParent(comment));
     },
 
     _computeCommentsForFile(comments, file) {
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
index 0e47e30..48bde1c 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
@@ -34,11 +34,15 @@
 <script>
   suite('gr-comment-list tests', () => {
     let element;
+    let sandbox;
 
     setup(() => {
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
     });
 
+    teardown(() => { sandbox.restore(); });
+
     test('_computeFilesFromComments w/ special file path sorting', () => {
       const comments = {
         'file_b.html': [],
@@ -72,5 +76,51 @@
       comment.side = 'PARENT';
       assert.equal(element._computePatchDisplayName(comment), 'Base, ');
     });
+
+    test('config commentlinks propagate to formatted text', () => {
+      element.comments = {
+        'test.h': [{
+          author: {name: 'foo'},
+          patch_set: 4,
+          line: 10,
+          updated: '2017-10-30 20:48:40.000000000',
+          message: 'Ideadbeefdeadbeef',
+          unresolved: true,
+        }],
+      };
+      element.projectConfig = {
+        commentlinks: {foo: {link: '#/q/$1', match: '(I[0-9a-f]{8,40})'}},
+      };
+      flushAsynchronousOperations();
+      const formattedText = Polymer.dom(element.root).querySelector(
+          'gr-formatted-text.message');
+      assert.isOk(formattedText.config);
+      assert.deepEqual(formattedText.config,
+          element.projectConfig.commentlinks);
+    });
+
+    test('_computeDiffLineURL', () => {
+      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
+      element.projectName = 'proj';
+      element.changeNum = 123;
+
+      const comment = {line: 456};
+      element._computeDiffLineURL('foo.cc', 123, 4, comment);
+      assert.isTrue(getUrlStub.calledOnce);
+      assert.deepEqual(getUrlStub.lastCall.args,
+          [123, 'proj', 'foo.cc', 4, null, 456, false]);
+
+      comment.side = 'PARENT';
+      element._computeDiffLineURL('foo.cc', 123, 4, comment);
+      assert.isTrue(getUrlStub.calledTwice);
+      assert.deepEqual(getUrlStub.lastCall.args,
+          [123, 'proj', 'foo.cc', 4, null, 456, true]);
+
+      comment.parent = 12;
+      element._computeDiffLineURL('foo.cc', 123, 4, comment);
+      assert.isTrue(getUrlStub.calledThrice);
+      assert.deepEqual(getUrlStub.lastCall.args,
+          [123, 'proj', 'foo.cc', 4, -12, 456, true]);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
index dec4e118..67b54d6 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
@@ -16,21 +16,35 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
 
 <dom-module id="gr-commit-info">
   <template>
     <style include="shared-styles">
-      :host {
-        display: inline-block;
+      .container {
+        align-items: center;
+        display: flex;
+      }
+      gr-copy-clipboard {
+        padding-left: .5em;
       }
     </style>
-    <template is="dom-if" if="[[_showWebLink]]">
-      <a target="_blank" rel="noopener"
-         href$="[[_webLink]]">[[_computeShortHash(commitInfo)]]</a>
-    </template>
-    <template is="dom-if" if="[[!_showWebLink]]">
-      [[_computeShortHash(commitInfo)]]
-    </template>
+    <div class="container">
+      <template is="dom-if" if="[[_showWebLink]]">
+        <a target="_blank" rel="noopener"
+            href$="[[_webLink]]">[[_computeShortHash(commitInfo)]]</a>
+      </template>
+      <template is="dom-if" if="[[!_showWebLink]]">
+        [[_computeShortHash(commitInfo)]]
+      </template>
+      <gr-copy-clipboard
+          has-tooltip
+          button-title="Copy full SHA to clipboard"
+          hide-input
+          hide-label
+          text="[[commitInfo.commit]]">
+      </gr-copy-clipboard>
+    </div>
   </template>
   <script src="gr-commit-info.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
index c55e8c7..dbf20f0 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
@@ -19,6 +19,7 @@
 
     properties: {
       change: Object,
+      /** @type {?} */
       commitInfo: Object,
       serverConfig: Object,
       _showWebLink: {
@@ -31,68 +32,30 @@
       },
     },
 
-    _isWebLink(link) {
-      // This is a whitelist of web link types that provide direct links to
-      // the commit in the url property.
-      return link.name === 'gitiles' || link.name === 'gitweb';
+    _getWeblink(change, commitInfo, config) {
+      return Gerrit.Nav.getPatchSetWeblink(
+          change.project,
+          commitInfo.commit,
+          {
+            weblinks: commitInfo.web_links,
+            config,
+          });
     },
 
     _computeShowWebLink(change, commitInfo, serverConfig) {
-      if (serverConfig.gitweb && serverConfig.gitweb.url &&
-          serverConfig.gitweb.type && serverConfig.gitweb.type.revision) {
-        return true;
-      }
-
-      if (!commitInfo.web_links) {
-        return false;
-      }
-
-      for (const link of commitInfo.web_links) {
-        if (this._isWebLink(link)) {
-          return true;
-        }
-      }
-
-      return false;
+      const weblink = this._getWeblink(change, commitInfo, serverConfig);
+      return !!weblink && !!weblink.url;
     },
 
     _computeWebLink(change, commitInfo, serverConfig) {
-      if (!this._computeShowWebLink(change, commitInfo, serverConfig)) {
-        return;
-      }
-
-      if (serverConfig.gitweb && serverConfig.gitweb.url &&
-          serverConfig.gitweb.type && serverConfig.gitweb.type.revision) {
-        return serverConfig.gitweb.url +
-            serverConfig.gitweb.type.revision
-                .replace('${project}', change.project)
-                .replace('${commit}', commitInfo.commit);
-      }
-
-      let webLink = null;
-      for (const link of commitInfo.web_links) {
-        if (this._isWebLink(link)) {
-          webLink = link.url;
-          break;
-        }
-      }
-
-      if (!webLink) {
-        return;
-      }
-
-      if (!/^https?\:\/\//.test(webLink)) {
-        webLink = '../../' + webLink;
-      }
-
-      return webLink;
+      const {url} = this._getWeblink(change, commitInfo, serverConfig) || {};
+      return url;
     },
 
     _computeShortHash(commitInfo) {
-      if (!commitInfo || !commitInfo.commit) {
-        return;
-      }
-      return commitInfo.commit.slice(0, 7);
+      const {name} =
+            this._getWeblink(this.change, commitInfo, this.serverConfig) || {};
+      return name;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
index bd6fdcb..7704255 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
@@ -21,6 +21,7 @@
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="../../core/gr-router/gr-router.html">
 <link rel="import" href="gr-commit-info.html">
 
 <script>void(0);</script>
@@ -34,22 +35,43 @@
 <script>
   suite('gr-commit-info tests', () => {
     let element;
+    let sandbox;
 
     setup(() => {
+      sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('weblinks use Gerrit.Nav interface', () => {
+      const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
+          .returns([{name: 'stubb', url: '#s'}]);
+      element.change = {};
+      element.commitInfo = {};
+      element.serverConfig = {};
+      assert.isTrue(weblinksStub.called);
+    });
+
     test('no web link when unavailable', () => {
       element.commitInfo = {};
       element.serverConfig = {};
-      element.change = {labels: []};
+      element.change = {labels: [], project: ''};
 
       assert.isNotOk(element._computeShowWebLink(element.change,
           element.commitInfo, element.serverConfig));
     });
 
     test('use web link when available', () => {
-      element.commitInfo = {web_links: [{name: 'gitweb', url: 'link-url'}]};
+      const router = document.createElement('gr-router');
+      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
+          router._generateWeblinks.bind(router));
+
+      element.change = {labels: [], project: ''};
+      element.commitInfo =
+          {commit: 'commitsha', web_links: [{name: 'gitweb', url: 'link-url'}]};
       element.serverConfig = {};
 
       assert.isOk(element._computeShowWebLink(element.change,
@@ -59,7 +81,13 @@
     });
 
     test('does not relativize web links that begin with scheme', () => {
+      const router = document.createElement('gr-router');
+      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
+          router._generateWeblinks.bind(router));
+
+      element.change = {labels: [], project: ''};
       element.commitInfo = {
+        commit: 'commitsha',
         web_links: [{name: 'gitweb', url: 'https://link-url'}],
       };
       element.serverConfig = {};
@@ -71,11 +99,15 @@
     });
 
     test('use gitweb when available', () => {
-      element.commitInfo = {commit: 'commit-sha'};
+      const router = document.createElement('gr-router');
       element.serverConfig = {gitweb: {
         url: 'url-base/',
         type: {revision: 'xx ${project} xx ${commit} xx'},
       }};
+      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
+          router._generateWeblinks.bind(router));
+
+      element.commitInfo = {commit: 'commit-sha'};
       element.change = {
         project: 'project-name',
         labels: [],
@@ -90,14 +122,18 @@
     });
 
     test('prefer gitweb when both are available', () => {
-      element.commitInfo = {
-        commit: 'commit-sha',
-        web_links: [{url: 'link-url'}],
-      };
+      const router = document.createElement('gr-router');
       element.serverConfig = {gitweb: {
         url: 'url-base/',
         type: {revision: 'xx ${project} xx ${commit} xx'},
       }};
+      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
+          router._generateWeblinks.bind(router));
+
+      element.commitInfo = {
+        commit: 'commit-sha',
+        web_links: [{url: 'link-url'}],
+      };
       element.change = {
         project: 'project-name',
         labels: [],
@@ -115,6 +151,11 @@
     });
 
     test('ignore web links that are neither gitweb nor gitiles', () => {
+      const router = document.createElement('gr-router');
+      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
+          router._generateWeblinks.bind(router));
+
+      element.change = {project: 'project-name'};
       element.commitInfo = {
         commit: 'commit-sha',
         web_links: [
@@ -128,7 +169,6 @@
           },
         ],
       };
-      element.serverConfig = {};
 
       assert.isOk(element._computeShowWebLink(element.change,
           element.commitInfo, element.serverConfig));
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
index d3bf159..a18e2a2 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
@@ -55,8 +55,8 @@
         confirm-label="Abandon"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header">Abandon Change</div>
-      <div class="main">
+      <div class="header" slot="header">Abandon Change</div>
+      <div class="main" slot="main">
         <label for="messageInput">Abandon Message</label>
         <iron-autogrow-textarea
             id="messageInput"
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
index 5151280..34688ad 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
@@ -62,8 +62,8 @@
         confirm-label="Cherry Pick"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header">Cherry Pick Change to Another Branch</div>
-      <div class="main">
+      <div class="header" slot="header">Cherry Pick Change to Another Branch</div>
+      <div class="main" slot="main">
         <label for="branchInput">
           Cherry Pick to branch
         </label>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
index fab728d..03493fe 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
@@ -77,7 +77,7 @@
       if (input.startsWith('refs/heads/')) {
         input = input.substring('refs/heads/'.length);
       }
-      return this.$.restAPI.getProjectBranches(
+      return this.$.restAPI.getRepoBranches(
           input, this.project, SUGGESTIONS_LIMIT).then(response => {
             const branches = [];
             let branch;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
index 0956f84..33b9a88 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
@@ -39,7 +39,7 @@
     setup(() => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getProjectBranches(input) {
+        getRepoBranches(input) {
           if (input.startsWith('test')) {
             return Promise.resolve([
               {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
index ec6bfb4..2e530d9 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
@@ -61,8 +61,8 @@
         confirm-label="Move Change"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header">Move Change to Another Branch</div>
-      <div class="main">
+      <div class="header" slot="header">Move Change to Another Branch</div>
+      <div class="main" slot="main">
         <p class="warning">
           Warning: moving a change will not change its parents.
         </p>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
index 6d35dbf..dee4a3a 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
@@ -57,7 +57,7 @@
       if (input.startsWith('refs/heads/')) {
         input = input.substring('refs/heads/'.length);
       }
-      return this.$.restAPI.getProjectBranches(
+      return this.$.restAPI.getRepoBranches(
           input, this.project, SUGGESTIONS_LIMIT).then(response => {
             const branches = [];
             let branch;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
index caf61ba..a873c29 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
@@ -37,7 +37,7 @@
 
     setup(() => {
       stub('gr-rest-api-interface', {
-        getProjectBranches(input) {
+        getRepoBranches(input) {
           if (input.startsWith('test')) {
             return Promise.resolve([
               {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
index 2772594..582a03c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
@@ -52,8 +52,8 @@
         confirm-label="Rebase"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header">Confirm rebase</div>
-      <div class="main">
+      <div class="header" slot="header">Confirm rebase</div>
+      <div class="main" slot="main">
         <div id="rebaseOnParent" class="rebaseOption"
             hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]">
           <input id="rebaseOnParentInput"
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
index 92e8de3..5b39547 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
@@ -50,8 +50,8 @@
         confirm-label="Revert"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header">Revert Merged Change</div>
-      <div class="main">
+      <div class="header" slot="header">Revert Merged Change</div>
+      <div class="main" slot="main">
         <label for="messageInput">
           Revert Commit Message
         </label>
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..c92919d 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,15 @@
 <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-linked-chip/gr-linked-chip.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="../../shared/revision-info/revision-info.html">
+<link rel="import" href="../gr-file-list-constants.html">
 
 <dom-module id="gr-file-list-header">
   <template>
@@ -41,24 +45,22 @@
         background-color: #fff9c4;
       }
       .patchInfo-header {
+        align-items: center;
         background-color: #fafafa;
-        border-bottom: 1px solid #ddd;
         border-top: 1px solid #ddd;
         display: flex;
-        min-height: 3.2em;
-        padding: .5em var(--default-horizontal-margin);
-      }
-      .patchInfo-header-wrapper {
-        align-items: center;
-        display: flex;
-        width: 100%;
+        padding: 6px var(--default-horizontal-margin);
       }
       .patchInfo-left {
+        align-items: baseline;
+        display: flex;
+      }
+      .patchInfoContent {
         align-items: center;
         display: flex;
         flex-wrap: wrap;
       }
-      .patchInfo-header-wrapper .container.latestPatchContainer {
+      .patchInfo-header .container.latestPatchContainer {
         display: none;
       }
       .patchInfoOldPatchSet .container.latestPatchContainer {
@@ -73,63 +75,65 @@
       .mobile {
         display: none;
       }
-      #diffPrefsContainer,
+      .patchInfo-header .container {
+        align-items: center;
+        display: flex;
+      }
+      .downloadContainer {
+        margin-right: 16px;
+      }
       .rightControls {
         align-self: flex-end;
         margin: auto 0 auto auto;
-      }
-      .showOnEdit {
-        display: none;
-      }
-      .editLoaded .hideOnEdit {
-        display: none;
-      }
-      .editLoaded .showOnEdit {
-        display: initial;
-      }
-      .patchInfo-header-wrapper .container {
-        align-items: center;
-        display: flex;
-      }
-      #modeSelect {
-        margin-left: .1em;
-      }
-      .fileList-header {
-        align-items: center;
-        display: flex;
-        font-weight: bold;
-        height: 2.25em;
-        margin: 0 calc(var(--default-horizontal-margin) / 2);
-        padding: 0 .25em;
-      }
-      .rightControls {
         align-items: center;
         display: flex;
         flex-wrap: wrap;
         font-weight: normal;
         justify-content: flex-end;
       }
-      .separator {
-        background-color: rgba(0, 0, 0, .3);
-        height: 1.5em;
-        margin: 0 .6em;
-        width: 1px;
+      #collapseBtn,
+      .expanded #expandBtn,
+      .fileViewActions{
+        display: none;
       }
-      .separator.transparent {
-        background-color: transparent;
+      .expanded #expandBtn {
+        display: none;
       }
-      .expandInline {
-        padding-right: .25em;
+      gr-button.selected iron-icon {
+        color: var(--color-link);
+      }
+      gr-linked-chip {
+        --linked-chip-text-color: black;
+      }
+      .expanded #collapseBtn,
+      .openFile .fileViewActions {
+        align-items: center;
+        display: flex;
+      }
+      .fileViewActions gr-button {
+        --gr-button: {
+          padding: 2px 4px;
+        }
+      }
+      .fileViewActions > *:not(:last-child) {
+        margin-right: 5px;
       }
       .editLoaded .hideOnEdit {
         display: none;
       }
+      .showOnEdit {
+        display: none;
+      }
       .editLoaded .showOnEdit {
         display: initial;
       }
+      .editLoaded .showOnEdit.flexContainer {
+        align-items: center;
+        display: flex;
+      }
       .label {
         font-family: var(--font-family-bold);
-        margin-right: 1em;
+        margin-right: 24px;
       }
       @media screen and (max-width: 50em) {
         .patchInfo-header .desktop {
@@ -138,17 +142,18 @@
       }
     </style>
     <div class$="patchInfo-header [[_computeEditLoadedClass(editLoaded)]] [[_computePatchInfoClass(patchNum, allPatchSets)]]">
-      <div class="patchInfo-header-wrapper">
-        <div class="patchInfo-left">
-          <h3 class="label">Files</h3>
+      <div class="patchInfo-left">
+        <h3 class="label">Files</h3>
+        <div class="patchInfoContent">
           <gr-patch-range-select
               id="rangeSelect"
-              comments="[[comments]]"
+              change-comments="[[changeComments]]"
               change-num="[[changeNum]]"
               patch-num="[[patchNum]]"
               base-patch-num="[[basePatchNum]]"
               available-patches="[[allPatchSets]]"
               revisions="[[change.revisions]]"
+              revision-info="[[_revisionInfo]]"
               on-patch-range-change="_handlePatchChange">
           </gr-patch-range-select>
           <span class="separator"></span>
@@ -160,47 +165,53 @@
             <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
-                id="descriptionLabel"
-                class="descriptionLabel"
-                label-text="Add patchset description"
-                value="[[_computePatchSetDescription(change, patchNum)]]"
-                placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
-                read-only="[[_descriptionReadOnly]]"
-                on-changed="_handleDescriptionChanged"></gr-editable-label>
+            <template
+                is="dom-if"
+                if="[[_patchsetDescription]]">
+              <gr-linked-chip
+                  id="descriptionChip"
+                  text="[[_patchsetDescription]]"
+                  removable="[[!_descriptionReadOnly]]"
+                  on-remove="_handleDescriptionRemoved"></gr-linked-chip>
+            </template>
+            <template
+                is="dom-if"
+                if="[[!_patchsetDescription]]">
+              <gr-editable-label
+                  id="descriptionLabel"
+                  uppercase
+                  class="descriptionLabel"
+                  label-text="Add patchset description"
+                  value="[[_patchsetDescription]]"
+                  placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
+                  read-only="[[_descriptionReadOnly]]"
+                  on-changed="_handleDescriptionChanged"></gr-editable-label>
+            </template>
           </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">
+      <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">Show diffs</gr-button>
-          <span class="separator"></span>
+              on-tap="_expandAllDiffs">Expand All</gr-button>
           <gr-button
               id="collapseBtn"
               link
-              on-tap="_collapseAllDiffs">Hide diffs</gr-button>
+              on-tap="_collapseAllDiffs">Collapse All</gr-button>
         </template>
         <template is="dom-if"
             if="[[!_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
@@ -208,15 +219,35 @@
             Bulk actions disabled because there are too many files.
           </div>
         </template>
-        <span class="separator"></span>
-        <gr-select
-            id="modeSelect"
-            bind-value="{{diffViewMode}}">
-          <select>
-            <option value="SIDE_BY_SIDE">Side By Side</option>
-            <option value="UNIFIED_DIFF">Unified</option>
-          </select>
-        </gr-select>
+        <div 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>
       </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..9db173e 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
@@ -27,7 +27,7 @@
       change: Object,
       changeNum: String,
       changeUrl: String,
-      comments: Object,
+      changeComments: Object,
       commitInfo: Object,
       editLoaded: Boolean,
       loggedIn: Boolean,
@@ -40,7 +40,7 @@
       },
       patchNum: String,
       basePatchNum: String,
-      revisions: Array,
+      filesExpanded: String,
       // Caps the number of files that can be shown and have the 'show diffs' /
       // 'hide diffs' buttons still be functional.
       _maxFilesForBulkActions: {
@@ -48,24 +48,71 @@
         readOnly: true,
         value: 225,
       },
+      _patchsetDescription: {
+        type: String,
+        value: '',
+      },
       _descriptionReadOnly: {
         type: Boolean,
         computed: '_computeDescriptionReadOnly(loggedIn, change, account)',
       },
+      /** @type {?} */
+      _VIEW_MODES: {
+        type: Object,
+        readOnly: true,
+        value: {
+          SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+          UNIFIED: 'UNIFIED_DIFF',
+        },
+      },
+      _revisionInfo: {
+        type: Object,
+        computed: '_getRevisionInfo(change)',
+      },
     },
 
     behaviors: [
       Gerrit.PatchSetBehavior,
     ],
 
+    observers: [
+      '_computePatchSetDescription(change, patchNum)',
+    ],
+
     _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';
     },
@@ -76,10 +123,14 @@
 
     _computePatchSetDescription(change, patchNum) {
       const rev = this.getRevisionByPatchNum(change.revisions, patchNum);
-      return (rev && rev.description) ?
+      this._patchsetDescription = (rev && rev.description) ?
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
     },
 
+    _handleDescriptionRemoved(e) {
+      return this._updateDescription('', e);
+    },
+
     /**
      * @param {!Object} revisions The revisions object keyed by revision hashes
      * @param {?Object} patchSet A revision already fetched from {revisions}
@@ -96,23 +147,34 @@
 
     _handleDescriptionChanged(e) {
       const desc = e.detail.trim();
+      this._updateDescription(desc, e);
+    },
+
+    /**
+     * Update the patchset description with the rest API.
+     * @param {string} desc
+     * @param {?(Event|Node)} e
+     * @return {!Promise}
+     */
+    _updateDescription(desc, e) {
+      const target = Polymer.dom(e).rootTarget;
+      if (target) { target.disabled = true; }
       const rev = this.getRevisionByPatchNum(this.change.revisions,
           this.patchNum);
       const sha = this._getPatchsetHash(this.change.revisions, rev);
-      this.$.restAPI.setDescription(this.changeNum,
-          this.patchNum, desc)
+      return this.$.restAPI.setDescription(this.changeNum, this.patchNum, desc)
           .then(res => {
             if (res.ok) {
+              if (target) { target.disabled = false; }
               this.set(['_change', 'revisions', sha, 'description'], desc);
+              this._patchsetDescription = desc;
             }
+          }).catch(err => {
+            if (target) { target.disabled = false; }
+            return;
           });
     },
 
-    _computeBasePatchDisabled(patchNum, currentPatchNum) {
-      return this.findSortedIndex(patchNum, this.revisions) >=
-          this.findSortedIndex(currentPatchNum, this.revisions);
-    },
-
     _computePrefsButtonHidden(prefs, loggedIn) {
       return !loggedIn || !prefs;
     },
@@ -122,20 +184,6 @@
       return shownFileCount <= maxFilesForBulkActions;
     },
 
-    /**
-     * Determines if a patch number should be disabled based on value of the
-     * basePatchNum from gr-file-list.
-     * @param {number} patchNum Patch number available in dropdown
-     * @param {number|string} basePatchNum Base patch number from file list
-     * @return {boolean}
-     */
-    _computePatchSetDisabled(patchNum, basePatchNum) {
-      if (basePatchNum === 'PARENT') { return false; }
-
-      return this.findSortedIndex(patchNum, this.revisions) <=
-          this.findSortedIndex(basePatchNum, this.revisions);
-    },
-
     _handlePatchChange(e) {
       const {basePatchNum, patchNum} = e.detail;
       if (this.patchNumEquals(basePatchNum, this.basePatchNum) &&
@@ -168,5 +216,9 @@
       }
       return 'patchInfoOldPatchSet';
     },
+
+    _getRevisionInfo(change) {
+      return new Gerrit.RevisionInfo(change);
+    },
   });
 })();
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..8dbb06f 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
@@ -96,35 +96,9 @@
           'Add patchset description');
     });
 
-    test('_computePatchSetDisabled', () => {
-      element.revisions = [
-        {_number: 1},
-        {_number: 2},
-        {_number: element.EDIT_NAME, basePatchNum: 2},
-        {_number: 3},
-      ];
-      let basePatchNum = 'PARENT';
-      let patchNum = 1;
-      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
-          false);
-      basePatchNum = 1;
-      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
-          true);
-      patchNum = 2;
-      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
-          false);
-      basePatchNum = element.EDIT_NAME;
-      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
-          true);
-      patchNum = '3';
-      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
-          false);
-    });
-
-    test('_handleDescriptionChanged', () => {
+    test('description editing', () => {
       const putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
           .returns(Promise.resolve({ok: true}));
-      sandbox.stub(element, '_computeDescriptionReadOnly');
 
       element.changeNum = '42';
       element.basePatchNum = 'PARENT';
@@ -145,14 +119,44 @@
       element.loggedIn = true;
 
       flushAsynchronousOperations();
-      const label = element.$.descriptionLabel;
-      assert.equal(label.value, 'test');
-      label.editing = true;
-      label._inputText = 'test2';
-      label._save();
-      flushAsynchronousOperations();
-      assert.isTrue(putDescStub.called);
-      assert.equal(putDescStub.args[0][2], 'test2');
+
+      // The element has a description, so the account chip should be visible
+      // and the description label should not exist.
+      const chip = Polymer.dom(element.root).querySelector('#descriptionChip');
+      let label = Polymer.dom(element.root).querySelector('#descriptionLabel');
+
+      assert.equal(chip.text, 'test');
+      assert.isNotOk(label);
+
+      // Simulate tapping the remove button, but call function directly so that
+      // can determine what happens after the promise is resolved.
+      return element._handleDescriptionRemoved().then(() => {
+        // The API stub should be called with an empty string for the new
+        // description.
+        assert.equal(putDescStub.lastCall.args[2], '');
+
+        flushAsynchronousOperations();
+        // The editable label should now be visible and the chip hidden.
+        label = Polymer.dom(element.root).querySelector('#descriptionLabel');
+        assert.isOk(label);
+        assert.equal(getComputedStyle(chip).display, 'none');
+        assert.notEqual(getComputedStyle(label).display, 'none');
+        assert.isFalse(label.readOnly);
+        // Edit the label to have a new value of test2, and save.
+        label.editing = true;
+        label._inputText = 'test2';
+        label._save();
+        flushAsynchronousOperations();
+        // The API stub should be called with an `test2` for the new
+        // description.
+        assert.equal(putDescStub.callCount, 2);
+        assert.equal(putDescStub.lastCall.args[2], 'test2');
+      }).then(() => {
+        flushAsynchronousOperations();
+        // The chip should be visible again, and the label hidden.
+        assert.equal(getComputedStyle(label).display, 'none');
+        assert.notEqual(getComputedStyle(chip).display, 'none');
+      });
     });
 
     test('expandAllDiffs called when expand button clicked', () => {
@@ -191,14 +195,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', () => {
@@ -224,7 +265,7 @@
     });
 
     test('class is applied to file list on old patch set', () => {
-      const allPatchSets = [{num: 1}, {num: 2}, {num: 4}];
+      const allPatchSets = [{num: 4}, {num: 2}, {num: 1}];
       assert.equal(element._computePatchInfoClass('1', allPatchSets),
           'patchInfoOldPatchSet');
       assert.equal(element._computePatchInfoClass('2', allPatchSets),
@@ -257,6 +298,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 7e71f48..8ce0e9d 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-count-string-formatter/gr-count-string-formatter.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;
@@ -61,6 +69,16 @@
         text-align: center;
         width: 1.5em;
       }
+      .file-row.expanded {
+        background-color: #fff;
+        border-bottom: 1px solid #ddd;
+        position: -webkit-sticky;
+        position: sticky;
+        top: 0;
+        /* Has to visible above the diff view, and by default has a lower
+         z-index. setting to 1 places it directly above. */
+        z-index: 1;
+      }
       .file-row:hover {
         background-color: #f5fafd;
       }
@@ -136,9 +154,7 @@
         min-width: 2em;
       }
       gr-diff {
-        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
         display: block;
-        margin: .25em 0 1em;
         overflow-x: auto;
       }
       .truncatedFileName {
@@ -181,6 +197,10 @@
         display: initial;
         opacity: 100;
       }
+      .editFileControls {
+        margin-left: 1em;
+        width: 10em;
+      }
       @media screen and (max-width: 50em) {
         .desktop {
           display: none;
@@ -189,7 +209,7 @@
           display: block;
         }
         .row.selected {
-          background-color: transparent;
+          background-color: #fff;
         }
         .stats {
           display: none;
@@ -198,6 +218,9 @@
         .status {
           justify-content: flex-start;
         }
+        .reviewed {
+          display: none;
+        }
         .comments {
           min-width: initial;
         }
@@ -220,100 +243,107 @@
           as="file"
           initial-count="[[fileListIncrement]]"
           target-framerate="1">
-        <div class="file-row row" data-path$="[[file.__path]]" tabindex="-1">
-          <div class="show-hide" hidden$="[[_userPrefs.expand_inline_diffs]]">
-            <label class="show-hide" data-path$="[[file.__path]]"
-                data-expand=true>
-              <input type="checkbox" class="show-hide"
-                  checked$="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
-                  data-path$="[[file.__path]]" data-expand=true>
-              [[_computeShowHideText(file.__path, _expandedFilePaths.*)]]
-            </label>
-          </div>
-          <div class$="[[_computeClass('status', file.__path)]]"
-              tabindex="0"
-              aria-label$="[[_computeFileStatusLabel(file.status)]]">
-            [[_computeFileStatus(file.status)]]
-          </div>
-          <span
-              data-url="[[_computeDiffURL(change, patchRange.patchNum, patchRange.basePatchNum, file.__path)]]"
-              class$="[[_computePathClass(file.__path, _expandedFilePaths.*)]]">
-            <a href$="[[_computeDiffURL(change, patchRange.patchNum, patchRange.basePatchNum, file.__path)]]">
-              <span title$="[[computeDisplayPath(file.__path)]]"
-                  class="fullFileName">
-                [[computeDisplayPath(file.__path)]]
-              </span>
-              <span title$="[[computeDisplayPath(file.__path)]]"
-                  class="truncatedFileName">
-                [[computeTruncatedPath(file.__path)]]
-              </span>
-            </a>
-            <div class="oldPath" hidden$="[[!file.old_path]]" hidden
-                title$="[[file.old_path]]">
-              [[file.old_path]]
+        <div class="stickyArea">
+          <div class$="file-row row [[_computePathClass(file.__path, _expandedFilePaths.*)]]"
+              data-path$="[[file.__path]]" tabindex="-1">
+            <div class="show-hide" hidden$="[[_userPrefs.expand_inline_diffs]]">
+              <label class="show-hide" data-path$="[[file.__path]]"
+                  data-expand=true>
+                <input type="checkbox" class="show-hide"
+                    checked$="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
+                    data-path$="[[file.__path]]" data-expand=true>
+                [[_computeShowHideText(file.__path, _expandedFilePaths.*)]]
+              </label>
             </div>
-          </span>
-          <div class="comments desktop">
-            <span class="drafts">
-              [[_computeDraftsString(drafts, patchRange.patchNum, file.__path)]]
+            <div class$="[[_computeClass('status', file.__path)]]"
+                tabindex="0"
+                aria-label$="[[_computeFileStatusLabel(file.status)]]">
+              [[_computeFileStatus(file.status)]]
+            </div>
+            <span
+                data-url="[[_computeDiffURL(change, patchRange.patchNum, patchRange.basePatchNum, file.__path)]]"
+                class="path">
+              <a href$="[[_computeDiffURL(change, patchRange.patchNum, patchRange.basePatchNum, file.__path)]]">
+                <span title$="[[computeDisplayPath(file.__path)]]"
+                    class="fullFileName">
+                  [[computeDisplayPath(file.__path)]]
+                </span>
+                <span title$="[[computeDisplayPath(file.__path)]]"
+                    class="truncatedFileName">
+                  [[computeTruncatedPath(file.__path)]]
+                </span>
+              </a>
+              <div class="oldPath" hidden$="[[!file.old_path]]" hidden
+                  title$="[[file.old_path]]">
+                [[file.old_path]]
+              </div>
             </span>
-            [[_computeCommentsString(comments, patchRange.patchNum, file.__path)]]
-            [[_computeUnresolvedString(comments, drafts, patchRange.patchNum, file.__path)]]
-          </div>
-          <div class="comments mobile">
-            <span class="drafts">
-              [[_computeDraftsStringMobile(drafts, patchRange.patchNum,
+            <div class="comments desktop">
+              <span class="drafts">
+                [[_computeDraftsString(changeComments, patchRange.patchNum, file.__path)]]
+              </span>
+              [[_computeCommentsString(changeComments, patchRange.patchNum, file.__path)]]
+            </div>
+            <div class="comments mobile">
+              <span class="drafts">
+                [[_computeDraftsStringMobile(changeComments, patchRange.patchNum,
+                    file.__path)]]
+              </span>
+              [[_computeCommentsStringMobile(changeComments, patchRange.patchNum,
                   file.__path)]]
-            </span>
-            [[_computeCommentsStringMobile(comments, patchRange.patchNum,
-                file.__path)]]
+            </div>
+            <div class$="[[_computeClass('stats', file.__path)]]">
+              <span
+                  class="added"
+                  tabindex="0"
+                  aria-label$="[[file.lines_inserted]] lines added"
+                  hidden$=[[file.binary]]>
+                +[[file.lines_inserted]]
+              </span>
+              <span
+                  class="removed"
+                  tabindex="0"
+                  aria-label$="[[file.lines_deleted]] lines removed"
+                  hidden$=[[file.binary]]>
+                -[[file.lines_deleted]]
+              </span>
+              <span class$="[[_computeBinaryClass(file.size_delta)]]"
+                  hidden$=[[!file.binary]]>
+                [[_formatBytes(file.size_delta)]]
+                [[_formatPercentage(file.size, file.size_delta)]]
+              </span>
+            </div>
+            <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]" hidden>
+              <span class$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]">Reviewed</span>
+              <label>
+                <input class="reviewed" type="checkbox" checked="[[file.isReviewed]]">
+                <span class="markReviewed" title="Mark as reviewed (shortcut: r)">[[_computeReviewedText(file.isReviewed)]]</span>
+              </label>
+            </div>
+            <div class="editFileControls showOnEdit">
+              <gr-edit-file-controls
+                  class$="[[_computeClass('', file.__path)]]"
+                  file-path="[[file.__path]]"></gr-edit-file-controls>
+            </div>
           </div>
-          <div class$="[[_computeClass('stats', file.__path)]]">
-            <span
-                class="added"
-                tabindex="0"
-                aria-label$="[[file.lines_inserted]] lines added"
-                hidden$=[[file.binary]]>
-              +[[file.lines_inserted]]
-            </span>
-            <span
-                class="removed"
-                tabindex="0"
-                aria-label$="[[file.lines_deleted]] lines removed"
-                hidden$=[[file.binary]]>
-              -[[file.lines_deleted]]
-            </span>
-            <span class$="[[_computeBinaryClass(file.size_delta)]]"
-                hidden$=[[!file.binary]]>
-              [[_formatBytes(file.size_delta)]]
-              [[_formatPercentage(file.size, file.size_delta)]]
-            </span>
-          </div>
-          <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]" hidden>
-            <span class$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]">Reviewed</span>
-            <label>
-              <input class="reviewed" type="checkbox" checked="[[file.isReviewed]]">
-              <span class="markReviewed" title="Mark as reviewed (shortcut: r)">[[_computeReviewedText(file.isReviewed)]]</span>
-            </label>
-          </div>
+          <template is="dom-if"
+              if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]">
+            <gr-diff
+                no-auto-render
+                display-line="[[_displayLine]]"
+                inline-index=[[index]]
+                hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
+                change-num="[[changeNum]]"
+                patch-range="[[patchRange]]"
+                path="[[file.__path]]"
+                prefs="[[diffPrefs]]"
+                project-name="[[change.project]]"
+                project-config="[[projectConfig]]"
+                on-line-selected="_onLineSelected"
+                no-render-on-prefs-change
+                view-mode="[[diffViewMode]]"></gr-diff>
+          </template>
         </div>
-        <template is="dom-if"
-            if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]">
-          <gr-diff
-              no-auto-render
-              display-line="[[_displayLine]]"
-              inline-index=[[index]]
-              hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
-              change-num="[[changeNum]]"
-              patch-range="[[patchRange]]"
-              path="[[file.__path]]"
-              prefs="[[diffPrefs]]"
-              project-name="[[change.project]]"
-              project-config="[[projectConfig]]"
-              on-line-selected="_onLineSelected"
-              no-render-on-prefs-change
-              view-mode="[[diffViewMode]]"></gr-diff>
-        </template>
       </template>
     </div>
     <div
@@ -335,6 +365,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"
@@ -384,7 +415,6 @@
         focus-on-move
         cursor-target-class="selected"></gr-cursor-manager>
     <gr-reporting id="reporting"></gr-reporting>
-    <gr-comment-api id="commentAPI"></gr-comment-api>
   </template>
   <script src="gr-file-list.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index d6d8993..44c7e2d 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
@@ -32,14 +32,20 @@
   Polymer({
     is: 'gr-file-list',
 
+    /**
+     * Fired when a draft refresh should get triggered
+     *
+     * @event reload-drafts
+     */
+
     properties: {
       /** @type {?} */
       patchRange: Object,
       patchNum: String,
       changeNum: String,
-      comments: Object,
+      /** @type {?} */
+      changeComments: Object,
       drafts: Object,
-      // Already sorted by the change-view.
       revisions: Array,
       projectConfig: Object,
       selectedIndex: {
@@ -61,6 +67,11 @@
         type: Boolean,
         observer: '_editLoadedChanged',
       },
+      filesExpanded: {
+        type: String,
+        value: GrFileListConstants.FilesExpandedState.NONE,
+        notify: true,
+      },
       _files: {
         type: Array,
         observer: '_filesChanged',
@@ -114,7 +125,6 @@
         type: Boolean,
         observer: '_loadingChanged',
       },
-      _sortedRevisions: Array,
     },
 
     behaviors: [
@@ -188,9 +198,6 @@
         });
       }));
 
-      // Load all comments for the change.
-      promises.push(this.$.commentAPI.loadAll(this.changeNum));
-
       this._localPrefs = this.$.storage.getPreferences();
       promises.push(this._getDiffPreferences().then(prefs => {
         this.diffPrefs = prefs;
@@ -267,11 +274,7 @@
       const timerName = 'Update ' + this._expandedFilePaths.length +
           ' diffs with new prefs';
       this._renderInOrder(this._expandedFilePaths, this.diffs,
-          this._expandedFilePaths.length)
-          .then(() => {
-            this.$.reporting.timeEnd(timerName);
-            this.$.diffCursor.handleDiffUpdate();
-          });
+          this._expandedFilePaths.length, timerName);
     },
 
     _forEachDiff(fn) {
@@ -301,92 +304,72 @@
     collapseAllDiffs() {
       this._showInlineDiffs = false;
       this._expandedFilePaths = [];
+      this.filesExpanded = this._computeExpandedFiles(
+          this._expandedFilePaths.length, this._files.length);
       this.$.diffCursor.handleDiffUpdate();
     },
 
-    _computeCommentsString(comments, patchNum, path) {
-      return this._computeCountString(comments, patchNum, path, 'comment');
-    },
-
-    _computeDraftsString(drafts, patchNum, path) {
-      return this._computeCountString(drafts, patchNum, path, 'draft');
-    },
-
-    _computeDraftsStringMobile(drafts, patchNum, path) {
-      const draftCount = this._computeCountString(drafts, patchNum, path);
-      return draftCount ? draftCount + 'd' : '';
-    },
-
-    _computeCommentsStringMobile(comments, patchNum, path) {
-      const commentCount = this._computeCountString(comments, patchNum, path);
-      return commentCount ? commentCount + 'c' : '';
-    },
-
-    getCommentsForPath(comments, patchNum, path) {
-      return (comments[path] || []).filter(c => {
-        return this.patchNumEquals(c.patch_set, patchNum);
-      });
-    },
-
     /**
-     * @param {!Array} comments
-     * @param {number} patchNum
-     * @param {string} path
-     * @param {string=} opt_noun
-     */
-    _computeCountString(comments, patchNum, path, opt_noun) {
-      if (!comments) { return ''; }
-
-      const patchComments = this.getCommentsForPath(comments, patchNum, path);
-      const num = patchComments.length;
-      if (num === 0) { return ''; }
-      if (!opt_noun) { return num; }
-      const output = num + ' ' + opt_noun + (num > 1 ? 's' : '');
-      return output;
-    },
-
-    /**
-     * Computes a string counting the number of unresolved comment threads in a
-     * given file and path.
+     * Computes a string with the number of comments and unresolved comments.
      *
-     * @param {!Object} comments
-     * @param {!Object} drafts
+     * @param {!Object} changeComments
      * @param {number} patchNum
      * @param {string} path
      * @return {string}
      */
-    _computeUnresolvedString(comments, drafts, patchNum, path) {
-      const unresolvedNum = this.computeUnresolvedNum(
-          comments, drafts, patchNum, path);
-      return unresolvedNum === 0 ? '' : '(' + unresolvedNum + ' unresolved)';
+    _computeCommentsString(changeComments, patchNum, path) {
+      const unresolvedCount = changeComments.computeUnresolvedNum(patchNum,
+          path);
+      const commentCount = changeComments.computeCommentCount(patchNum, path);
+      const commentString = GrCountStringFormatter.computePluralString(
+          commentCount, 'comment');
+      const unresolvedString = GrCountStringFormatter.computeString(
+          unresolvedCount, 'unresolved');
+
+      return commentString +
+          // Add a space if both comments and unresolved
+          (commentString && unresolvedString ? ' ' : '') +
+          // Add parentheses around unresolved if it exists.
+          (unresolvedString ? `(${unresolvedString})` : '');
     },
 
-    computeUnresolvedNum(comments, drafts, patchNum, path) {
-      comments = this.getCommentsForPath(comments, patchNum, path);
-      drafts = this.getCommentsForPath(drafts, patchNum, path);
-      comments = comments.concat(drafts);
+    /**
+     * Computes a string with the number of drafts.
+     *
+     * @param {!Object} changeComments
+     * @param {number} patchNum
+     * @param {string} path
+     * @return {string}
+     */
+    _computeDraftsString(changeComments, patchNum, path) {
+      const draftCount = changeComments.computeDraftCount(patchNum, path);
+      return GrCountStringFormatter.computePluralString(draftCount, 'draft');
+    },
 
-      // Create an object where every comment ID is the key of an unresolved
-      // comment.
+    /**
+     * Computes a shortened string with the number of drafts.
+     *
+     * @param {!Object} changeComments
+     * @param {number} patchNum
+     * @param {string} path
+     * @return {string}
+     */
+    _computeDraftsStringMobile(changeComments, patchNum, path) {
+      const draftCount = changeComments.computeDraftCount(patchNum, path);
+      return GrCountStringFormatter.computeShortString(draftCount, 'd');
+    },
 
-      const idMap = comments.reduce((acc, comment) => {
-        if (comment.unresolved) {
-          acc[comment.id] = true;
-        }
-        return acc;
-      }, {});
-
-      // Set false for the comments that are marked as parents.
-      for (const comment of comments) {
-        idMap[comment.in_reply_to] = false;
-      }
-
-      // The unresolved comments are the comments that still have true.
-      const unresolvedLeaves = Object.keys(idMap).filter(key => {
-        return idMap[key];
-      });
-
-      return unresolvedLeaves.length;
+    /**
+     * Computes a shortened string with the number of comments.
+     *
+     * @param {!Object} changeComments
+     * @param {number} patchNum
+     * @param {string} path
+     * @return {string}
+     */
+    _computeCommentsStringMobile(changeComments, patchNum, path) {
+      const commentCount = changeComments.computeCommentCount(patchNum, path);
+      return GrCountStringFormatter.computeShortString(commentCount, 'c');
     },
 
     _computeReviewed(file, _reviewed) {
@@ -510,11 +493,14 @@
         return;
       }
 
-      e.preventDefault();
       if (this._showInlineDiffs) {
+        e.preventDefault();
         this.$.diffCursor.moveDown();
         this._displayLine = true;
       } else {
+        // Down key
+        if (this.getKeyboardEvent(e).keyCode === 40) { return; }
+        e.preventDefault();
         this.$.fileCursor.next();
         this.selectedIndex = this.$.fileCursor.index;
       }
@@ -525,11 +511,14 @@
         return;
       }
 
-      e.preventDefault();
       if (this._showInlineDiffs) {
+        e.preventDefault();
         this.$.diffCursor.moveUp();
         this._displayLine = true;
       } else {
+        // Up key
+        if (this.getKeyboardEvent(e).keyCode === 38) { return; }
+        e.preventDefault();
         this.$.fileCursor.previous();
         this.selectedIndex = this.$.fileCursor.index;
       }
@@ -717,8 +706,7 @@
     },
 
     _computePathClass(path, expandedFilesRecord) {
-      return this._isFileExpanded(path, expandedFilesRecord) ? 'path expanded' :
-          'path';
+      return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
     },
 
     _computeShowHideText(path, expandedFilesRecord) {
@@ -814,6 +802,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
@@ -822,8 +819,17 @@
      * @param {!Array} record The splice record in the expanded paths list.
      */
     _expandedPathsChanged(record) {
+      // Clear content for any diffs that are not open so if they get re-opened
+      // the stale content does not flash before it is cleared and reloaded.
+      const collapsedDiffs = this.diffs.filter(diff =>
+          this._expandedFilePaths.indexOf(diff.path) === -1);
+      this._clearCollapsedDiffs(collapsedDiffs);
+
       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 => {
@@ -838,15 +844,17 @@
       // Required so that the newly created diff view is included in this.diffs.
       Polymer.dom.flush();
 
-      this._renderInOrder(newPaths, this.diffs, newPaths.length)
-          .then(() => {
-            this.$.reporting.timeEnd(timerName);
-            this.$.diffCursor.handleDiffUpdate();
-          });
+      this._renderInOrder(newPaths, this.diffs, newPaths.length, timerName);
       this._updateDiffCursor();
       this.$.diffCursor.handleDiffUpdate();
     },
 
+    _clearCollapsedDiffs(collapsedDiffs) {
+      for (const diff of collapsedDiffs) {
+        diff.clearDiffContent();
+      }
+    },
+
     /**
      * Given an array of paths and a NodeList of diff elements, render the diff
      * for each path in order, awaiting the previous render to complete before
@@ -855,30 +863,35 @@
      * @param  {!NodeList<!Object>} diffElements (GrDiffElement)
      * @param  {number} initialCount The total number of paths in the pass. This
      *   is used to generate log messages.
+     * @param {string} timerName the timer to stop after the render has
+     *   completed
      * @return {!Promise}
      */
-    _renderInOrder(paths, diffElements, initialCount) {
+    _renderInOrder(paths, diffElements, initialCount, timerName) {
       let iter = 0;
 
-      return this.$.commentAPI.loadAll(this.changeNum)
-          .then(() => {
-            return this.asyncForeach(paths, path => {
-              iter++;
-              console.log('Expanding diff', iter, 'of', initialCount, ':',
-                  path);
-              const diffElem = this._findDiffByPath(path, diffElements);
-              diffElem.comments = this.$.commentAPI.getCommentsForPath(path,
-                  this.patchRange, this.projectConfig);
-              const promises = [diffElem.reload()];
-              if (this._isLoggedIn) {
-                promises.push(this._reviewFile(path));
-              }
-              return Promise.all(promises);
-            });
-          })
-          .then(() => {
-            console.log('Finished expanding', initialCount, 'diff(s)');
-          });
+      return (new Promise(resolve => {
+        this.fire('reload-drafts', {resolve});
+      })).then(() => {
+        return this.asyncForeach(paths, path => {
+          iter++;
+          console.log('Expanding diff', iter, 'of', initialCount, ':',
+              path);
+          const diffElem = this._findDiffByPath(path, diffElements);
+          diffElem.comments = this.changeComments.getCommentsBySideForPath(
+              path, this.patchRange, this.projectConfig);
+          const promises = [diffElem.reload()];
+          if (this._isLoggedIn) {
+            promises.push(this._reviewFile(path));
+          }
+          return Promise.all(promises);
+        }).then(() => {
+          this._nextRenderParams = null;
+          console.log('Finished expanding', initialCount, 'diff(s)');
+          this.$.reporting.timeEnd(timerName);
+          this.$.diffCursor.handleDiffUpdate();
+        });
+      });
     },
 
     /**
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 6b102ba..06799ff3 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
@@ -22,6 +22,7 @@
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../bower_components/page/page.js"></script>
+<link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
 <script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
@@ -29,25 +30,39 @@
 
 <script>void(0);</script>
 
+<dom-module id="comment-api-mock">
+  <template>
+    <gr-file-list id="fileList"
+        change-comments="[[_changeComments]]"
+        on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
+    <gr-comment-api id="commentAPI"></gr-comment-api>
+  </template>
+  <script src="../../diff/gr-comment-api/gr-comment-api-mock.js"></script>
+</dom-module>
+
 <test-fixture id="basic">
   <template>
-    <gr-file-list></gr-file-list>
+    <comment-api-mock></comment-api-mock>
   </template>
 </test-fixture>
 
 <script>
   suite('gr-file-list tests', () => {
     let element;
+    let commentApiWrapper;
     let sandbox;
     let saveStub;
-    let loadCommentStub;
+    let loadCommentSpy;
 
-    setup(() => {
+    setup(done => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getLoggedIn() { return Promise.resolve(true); },
         getPreferences() { return Promise.resolve({}); },
         fetchJSON() { return Promise.resolve({}); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
       });
       stub('gr-date-formatter', {
         _loadTimeFormat() { return Promise.resolve(''); },
@@ -55,17 +70,25 @@
       stub('gr-diff', {
         reload() { return Promise.resolve(); },
       });
-      stub('gr-comment-api', {
-        getPaths() { return {}; },
-        getCommentsForPath() { return {meta: {}, left: [], right: []}; },
+
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = fixture('basic');
+      element = commentApiWrapper.$.fileList;
+      loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initalized.
+      commentApiWrapper.loadComments().then(() => {
+        sandbox.stub(element.changeComments, 'getPaths').returns({});
+        sandbox.stub(element.changeComments, 'getCommentsBySideForPath')
+            .returns({meta: {}, left: [], right: []});
+        done();
       });
 
-      element = fixture('basic');
       element.numFilesShown = 200;
       saveStub = sandbox.stub(element, '_saveReviewedState',
           () => { return Promise.resolve(); });
-      loadCommentStub = sandbox.stub(element.$.commentAPI, 'loadAll',
-          () => { return Promise.resolve(); });
     });
 
     teardown(() => {
@@ -325,6 +348,135 @@
       }
     });
 
+    test('comment filtering', () => {
+      element.changeComments._comments = {
+        '/COMMIT_MSG': [
+          {patch_set: 1, message: 'Done', updated: '2017-02-08 16:40:49'},
+          {patch_set: 1, message: 'oh hay', updated: '2017-02-09 16:40:49'},
+          {patch_set: 2, message: 'hello', updated: '2017-02-10 16:40:49'},
+        ],
+        'myfile.txt': [
+          {patch_set: 1, message: 'good news!', updated: '2017-02-08 16:40:49'},
+          {patch_set: 2, message: 'wat!?', updated: '2017-02-09 16:40:49'},
+          {patch_set: 2, message: 'hi', updated: '2017-02-10 16:40:49'},
+        ],
+        'unresolved.file': [
+          {
+            patch_set: 2,
+            message: 'wat!?',
+            updated: '2017-02-09 16:40:49',
+            id: '1',
+            unresolved: true,
+          },
+          {
+            patch_set: 2,
+            message: 'hi',
+            updated: '2017-02-10 16:40:49',
+            id: '2',
+            in_reply_to: '1',
+            unresolved: false,
+          },
+          {
+            patch_set: 2,
+            message: 'good news!',
+            updated: '2017-02-08 16:40:49',
+            id: '3',
+            unresolved: true,
+          },
+        ],
+      };
+      element.changeComments._drafts = {
+        '/COMMIT_MSG': [
+          {
+            patch_set: 1,
+            message: 'hi',
+            updated: '2017-02-15 16:40:49',
+            id: '5',
+            unresolved: true,
+          },
+          {
+            patch_set: 1,
+            message: 'fyi',
+            updated: '2017-02-15 16:40:49',
+            id: '6',
+            unresolved: false,
+          },
+        ],
+        'unresolved.file': [
+          {
+            patch_set: 1,
+            message: 'hi',
+            updated: '2017-02-11 16:40:49',
+            id: '4',
+            unresolved: false,
+          },
+        ],
+      };
+
+      assert.equal(
+          element._computeCommentsString(element.changeComments, '1',
+              '/COMMIT_MSG', 'comment'), '2 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, '1'
+          , '/COMMIT_MSG'), '2c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, '1',
+              'unresolved.file'), '1 draft');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, '1',
+              'unresolved.file'), '1d');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, '1',
+              'myfile.txt', 'comment'), '1 comment');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, '1',
+              'myfile.txt'), '1c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, '1',
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, '1',
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, '1',
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, '1',
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, '1',
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, '1',
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, '2',
+              '/COMMIT_MSG', 'comment'), '1 comment');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, '2',
+              '/COMMIT_MSG'), '1c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, '1',
+              '/COMMIT_MSG'), '2 drafts');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, '1',
+              '/COMMIT_MSG'), '2d');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, '2',
+              'myfile.txt', 'comment'), '2 comments');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, '2',
+              'myfile.txt'), '2c');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, '2',
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, '2',
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(element._computeCommentsString(element.changeComments, '2',
+          'unresolved.file', 'comment'), '3 comments (1 unresolved)');
+    });
+
     suite('keyboard shortcuts', () => {
       setup(() => {
         element._files = [
@@ -368,6 +520,10 @@
         // j with a modifier should not move the cursor.
         MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
         assert.equal(element.$.fileCursor.index, 0);
+        // down should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
+        assert.equal(element.$.fileCursor.index, 0);
+
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
         assert.equal(element.$.fileCursor.index, 1);
         assert.equal(element.selectedIndex, 1);
@@ -381,6 +537,10 @@
         MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
         assert.equal(element.$.fileCursor.index, 2);
 
+        // up should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'down');
+        assert.equal(element.$.fileCursor.index, 2);
+
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         assert.equal(element.$.fileCursor.index, 1);
         assert.equal(element.selectedIndex, 1);
@@ -503,119 +663,6 @@
       });
     });
 
-    test('comment filtering', () => {
-      const comments = {
-        '/COMMIT_MSG': [
-          {patch_set: 1, message: 'Done', updated: '2017-02-08 16:40:49'},
-          {patch_set: 1, message: 'oh hay', updated: '2017-02-09 16:40:49'},
-          {patch_set: 2, message: 'hello', updated: '2017-02-10 16:40:49'},
-        ],
-        'myfile.txt': [
-          {patch_set: 1, message: 'good news!', updated: '2017-02-08 16:40:49'},
-          {patch_set: 2, message: 'wat!?', updated: '2017-02-09 16:40:49'},
-          {patch_set: 2, message: 'hi', updated: '2017-02-10 16:40:49'},
-        ],
-        'unresolved.file': [
-          {
-            patch_set: 2,
-            message: 'wat!?',
-            updated: '2017-02-09 16:40:49',
-            id: '1',
-            unresolved: true,
-          },
-          {
-            patch_set: 2,
-            message: 'hi',
-            updated: '2017-02-10 16:40:49',
-            id: '2',
-            in_reply_to: '1',
-            unresolved: false,
-          },
-          {
-            patch_set: 2,
-            message: 'good news!',
-            updated: '2017-02-08 16:40:49',
-            id: '3',
-            unresolved: true,
-          },
-        ],
-      };
-      const drafts = {
-        'unresolved.file': [
-          {
-            patch_set: 2,
-            message: 'hi',
-            updated: '2017-02-11 16:40:49',
-            id: '4',
-            in_reply_to: '3',
-            unresolved: false,
-          },
-        ],
-      };
-      assert.equal(
-          element._computeCountString(comments, '1', '/COMMIT_MSG', 'comment'),
-          '2 comments');
-      assert.equal(
-          element._computeCommentsStringMobile(comments, '1', '/COMMIT_MSG'),
-          '2c');
-      assert.equal(
-          element._computeDraftsStringMobile(comments, '1', '/COMMIT_MSG'),
-          '2d');
-      assert.equal(
-          element._computeCountString(comments, '1', 'myfile.txt', 'comment'),
-          '1 comment');
-      assert.equal(
-          element._computeCommentsStringMobile(comments, '1', 'myfile.txt'),
-          '1c');
-      assert.equal(
-          element._computeDraftsStringMobile(comments, '1', 'myfile.txt'),
-          '1d');
-      assert.equal(
-          element._computeCountString(comments, '1',
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsStringMobile(comments, '1',
-              'file_added_in_rev2.txt'), '');
-      assert.equal(
-          element._computeDraftsStringMobile(comments, '1',
-              'file_added_in_rev2.txt'), '');
-      assert.equal(
-          element._computeCountString(comments, '2', '/COMMIT_MSG', 'comment'),
-          '1 comment');
-      assert.equal(
-          element._computeCommentsStringMobile(comments, '2', '/COMMIT_MSG'),
-          '1c');
-      assert.equal(
-          element._computeDraftsStringMobile(comments, '2', '/COMMIT_MSG'),
-          '1d');
-      assert.equal(
-          element._computeCountString(comments, '2', 'myfile.txt', 'comment'),
-          '2 comments');
-      assert.equal(
-          element._computeCommentsStringMobile(comments, '2', 'myfile.txt'),
-          '2c');
-      assert.equal(
-          element._computeDraftsStringMobile(comments, '2', 'myfile.txt'),
-          '2d');
-      assert.equal(
-          element._computeCountString(comments, '2',
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(element._computeCountString(comments, '2',
-          'unresolved.file', 'comment'), '3 comments');
-      assert.equal(
-          element._computeUnresolvedString(comments, [], 2, 'myfile.txt'), '');
-      assert.equal(
-          element.computeUnresolvedNum(comments, [], 2, 'myfile.txt'), 0);
-      assert.equal(
-          element._computeUnresolvedString(comments, [], 2, 'unresolved.file'),
-          '(1 unresolved)');
-      assert.equal(
-          element.computeUnresolvedNum(comments, [], 2, 'unresolved.file'), 1);
-      assert.equal(
-          element._computeUnresolvedString(comments, drafts, 2,
-              'unresolved.file'), '');
-    });
-
     test('computed properties', () => {
       assert.equal(element._computeFileStatus('A'), 'A');
       assert.equal(element._computeFileStatus(undefined), 'M');
@@ -674,10 +721,10 @@
 
     test('patch set from revisions', () => {
       const expected = [
-        {num: 1, desc: 'test'},
-        {num: 2, desc: 'test'},
-        {num: 3, desc: 'test'},
         {num: 4, desc: 'test'},
+        {num: 3, desc: 'test'},
+        {num: 2, desc: 'test'},
+        {num: 1, desc: 'test'},
       ];
       const patchNums = element.computeAllPatchSets({
         revisions: {
@@ -744,7 +791,6 @@
       assert.isTrue(element._updateDiffPreferences.called);
     });
 
-
     test('expanded attribute not set on path when not expanded', () => {
       element._files = [
         {__path: '/COMMIT_MSG'},
@@ -785,39 +831,44 @@
 
     test('_togglePathExpanded', () => {
       const path = 'path/to/my/file.txt';
-      element.files = [{__path: path}];
-      const renderStub = sandbox.stub(element, '_renderInOrder')
-          .returns(Promise.resolve());
+      element._files = [{__path: path}];
+      const renderSpy = sandbox.spy(element, '_renderInOrder');
+      const collapseStub = sandbox.stub(element, '_clearCollapsedDiffs');
 
       assert.equal(element._expandedFilePaths.length, 0);
       element._togglePathExpanded(path);
       flushAsynchronousOperations();
+      assert.equal(collapseStub.lastCall.args[0].length, 0);
 
-      assert.equal(renderStub.callCount, 1);
+      assert.equal(renderSpy.callCount, 1);
       assert.include(element._expandedFilePaths, path);
       element._togglePathExpanded(path);
       flushAsynchronousOperations();
 
-      assert.equal(renderStub.callCount, 2);
+      assert.equal(renderSpy.callCount, 2);
       assert.notInclude(element._expandedFilePaths, path);
+      assert.equal(collapseStub.lastCall.args[0].length, 1);
     });
 
-    test('collapseAllDiffs', () => {
-      sandbox.stub(element, '_renderInOrder')
-          .returns(Promise.resolve());
+    test('expandAllDiffs and collapseAllDiffs', () => {
+      const collapseStub = sandbox.stub(element, '_clearCollapsedDiffs');
       const cursorUpdateStub = sandbox.stub(element.$.diffCursor,
           'handleDiffUpdate');
 
       const path = 'path/to/my/file.txt';
-      element.files = [{__path: path}];
-      element._expandedFilePaths = [path];
-      element._showInlineDiffs = true;
+      element._files = [{__path: path}];
+      element.expandAllDiffs();
+      flushAsynchronousOperations();
+      assert.isTrue(element._showInlineDiffs);
+      assert.isTrue(cursorUpdateStub.calledOnce);
+      assert.equal(collapseStub.lastCall.args[0].length, 0);
 
       element.collapseAllDiffs();
       flushAsynchronousOperations();
       assert.equal(element._expandedFilePaths.length, 0);
       assert.isFalse(element._showInlineDiffs);
-      assert.isTrue(cursorUpdateStub.calledOnce);
+      assert.isTrue(cursorUpdateStub.calledTwice);
+      assert.equal(collapseStub.lastCall.args[0].length, 1);
     });
 
     test('_expandedPathsChanged', done => {
@@ -835,6 +886,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() {}};
@@ -895,7 +969,7 @@
       element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
           .then(() => {
             assert.isFalse(reviewStub.called);
-            assert.isTrue(loadCommentStub.called);
+            assert.isTrue(loadCommentSpy.called);
             done();
           });
     });
@@ -1018,11 +1092,14 @@
       return diffs;
     };
 
-    setup(() => {
+    setup(done => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getLoggedIn() { return Promise.resolve(true); },
         getPreferences() { return Promise.resolve({}); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
       });
       stub('gr-date-formatter', {
         _loadTimeFormat() { return Promise.resolve(''); },
@@ -1030,12 +1107,21 @@
       stub('gr-diff', {
         reload() { return Promise.resolve(); },
       });
-      stub('gr-comment-api', {
-        loadAll() { return Promise.resolve(); },
-        getPaths() { return {}; },
-        getCommentsForPath() { return {meta: {}, left: [], right: []}; },
+
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = fixture('basic');
+      element = commentApiWrapper.$.fileList;
+      loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initalized.
+      commentApiWrapper.loadComments().then(() => {
+        sandbox.stub(element.changeComments, 'getPaths').returns({});
+        sandbox.stub(element.changeComments, 'getCommentsBySideForPath')
+            .returns({meta: {}, left: [], right: []});
+        done();
       });
-      element = fixture('basic');
       element.numFilesShown = 75;
       element.selectedIndex = 0;
       element._files = [
@@ -1247,19 +1333,12 @@
     });
 
     suite('editLoaded behavior', () => {
-      const isVisible = el => {
-        assert.ok(el);
-        return getComputedStyle(el).getPropertyValue('display') !== 'none';
-      };
-
       test('reviewed checkbox', () => {
         const alertStub = sandbox.stub();
         const saveReviewStub = sandbox.stub(element, '_saveReviewedState');
 
         element.addEventListener('show-alert', alertStub);
         element.editLoaded = false;
-        // Reviewed checkbox should be shown.
-        assert.isTrue(isVisible(element.$$('.reviewed')));
         MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
         assert.isFalse(alertStub.called);
         assert.isTrue(saveReviewStub.calledOnce);
@@ -1267,7 +1346,6 @@
         element.editLoaded = true;
         flushAsynchronousOperations();
 
-        assert.isFalse(isVisible(element.$$('.reviewed')));
         MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
         assert.isTrue(alertStub.called);
         assert.isTrue(saveReviewStub.calledOnce);
@@ -1282,6 +1360,16 @@
         });
       });
     });
+
+    test('editing actions', () => {
+      element.editLoaded = true;
+      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'));
+    });
   });
   a11ySuite('basic');
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
index 6496091..8324ab2 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
@@ -17,24 +17,25 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-selector/iron-selector.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../../styles/gr-voting-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-label-score-row">
   <template>
+    <style include="gr-voting-styles"></style>
     <style include="shared-styles">
       .labelContainer {
+        align-items: center;
+        display: flex;
         margin-bottom: .5em;
       }
-      .labelContainer:last-child {
-        margin-bottom: 0;
-      }
       .labelName {
         display: inline-block;
+        flex: 0 0 auto;
         margin-right: .5em;
         min-width: 7em;
-        text-align: right;
-        white-space: nowrap;
-        width: 25%;
+        text-align: left;
+        width: 20%;
       }
       .labelMessage {
         color: #666;
@@ -43,77 +44,93 @@
         content: ' ';
       }
       .selectedValueText {
-        color: #666;
+        color: rgba(0, 0, 0, .54);
         font-style: italic;
-        margin-bottom: .5em;
-        margin-left: calc(25% + .5em);
+        margin: 0 .5em 0 .5em;
       }
       .selectedValueText.hidden {
         display: none;
       }
+      .buttonWrapper {
+        flex: none;
+      }
       gr-button {
         min-width: 40px;
         --gr-button: {
-          border: 1px solid #d1d2d3;
-          border-radius: 12px;
-          box-shadow: none;
           padding: .2em .85em;
+          @apply(--vote-chip-styles);
         }
-        --gr-button-background: #f5f5f5;
+        --gr-button-background: var(--button-background-color, #f5f5f5);
         --gr-button-color: black;
-        --gr-button-hover-color: black;
-
       }
-      iron-selector > gr-button.iron-selected {
-        --gr-button-background:#ddd;
-        --gr-button-color: black;
-        --gr-button-hover-background-color: #ddd;
-        --gr-button-hover-color: black;
+      iron-selector > gr-button.iron-selected.max {
+        --button-background-color: var(--vote-color-max);
+      }
+      iron-selector > gr-button.iron-selected.positive {
+        --button-background-color: var(--vote-color-positive);
+      }
+      iron-selector > gr-button.iron-selected.min {
+        --button-background-color: var(--vote-color-min);
+      }
+      iron-selector > gr-button.iron-selected.negative {
+        --button-background-color: var(--vote-color-negative);
+      }
+      iron-selector > gr-button.iron-selected.neutral {
+        --button-background-color: var(--vote-color-neutral);
       }
       .placeholder {
         display: inline-block;
         width: 40px;
       }
+      @media only screen and (max-width: 50em) {
+        .selectedValueText {
+          display: none;
+        }
+      }
       @media only screen and (max-width: 25em) {
         .labelName {
           margin: 0;
           text-align: center;
           width: 100%;
         }
-        .selectedValueText {
-          display: none;
+        .labelContainer {
+          display: block;
         }
       }
     </style>
     <div class="labelContainer">
       <span class="labelName">[[label.name]]</span>
-      <template is="dom-repeat"
-          items="[[_computeBlankItems(permittedLabels, label.name, 'start')]]"
-          as="value">
-        <span class="placeholder" data-label$="[[label.name]]"></span>
-      </template>
-      <iron-selector
-          attr-for-selected="value"
-          selected="[[_computeLabelValue(labels, permittedLabels, label)]]"
-          hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
-          on-selected-item-changed="_setSelectedValueText">
+      <div class="buttonWrapper">
         <template is="dom-repeat"
-            items="[[_computePermittedLabelValues(permittedLabels, label.name)]]"
+            items="[[_computeBlankItems(permittedLabels, label.name, 'start')]]"
             as="value">
-          <gr-button has-tooltip value$="[[value]]"
-            title$="[[_computeLabelValueTitle(labels, label.name, value)]]">
-          [[value]]</gr-button>
+          <span class="placeholder" data-label$="[[label.name]]"></span>
         </template>
-      </iron-selector>
-      <template is="dom-repeat"
-          items="[[_computeBlankItems(permittedLabels, label.name, 'end')]]"
-          as="value">
-        <span class="placeholder" data-label$="[[label.name]]"></span>
-      </template>
-      <span class="labelMessage"
-          hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
-        You don't have permission to edit this label.
-      </span>
+        <iron-selector
+            attr-for-selected="value"
+            selected="[[_computeLabelValue(labels, permittedLabels, label)]]"
+            hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
+            on-selected-item-changed="_setSelectedValueText">
+          <template is="dom-repeat"
+              items="[[_items]]"
+              as="value">
+            <gr-button
+                class$="[[_computeButtonClass(value, index, _items.length)]]"
+                has-tooltip value$="[[value]]"
+                title$="[[_computeLabelValueTitle(labels, label.name, value)]]">
+              [[value]]</gr-button>
+          </template>
+        </iron-selector>
+        <template is="dom-repeat"
+            items="[[_computeBlankItems(permittedLabels, label.name, 'end')]]"
+            as="value">
+          <span class="placeholder" data-label$="[[label.name]]"></span>
+        </template>
+        <span class="labelMessage"
+            hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
+          You don't have permission to edit this label.
+        </span>
+      </div>
       <div class$="selectedValueText [[_computeHiddenClass(permittedLabels, label.name)]]">
         <span id="selectedValueLabel">[[_selectedValueText]]</span>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
index dd0bccc..cf743a4 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
@@ -39,6 +39,10 @@
         type: String,
         value: 'No value selected',
       },
+      _items: {
+        type: Array,
+        computed: '_computePermittedLabelValues(permittedLabels, label.name)',
+      },
     },
 
     get selectedItem() {
@@ -62,7 +66,7 @@
     },
 
     _computeBlankItems(permittedLabels, label, side) {
-      if (!permittedLabels || !permittedLabels[label] ||
+      if (!permittedLabels || !permittedLabels[label] || !this.labelValues ||
           !Object.keys(this.labelValues).length) {
         return [];
       }
@@ -87,6 +91,19 @@
       }
     },
 
+    _computeButtonClass(value, index, totalItems) {
+      if (value < 0 && index === 0) {
+        return 'min';
+      } else if (value < 0) {
+        return 'negative';
+      } else if (value > 0 && index === totalItems - 1) {
+        return 'max';
+      } else if (value > 0) {
+        return 'positive';
+      }
+      return 'neutral';
+    },
+
     _computeLabelValue(labels, permittedLabels, label) {
       if (!labels[label.name]) { return null; }
       const labelValue = this._getLabelValue(labels, permittedLabels, label);
@@ -125,7 +142,9 @@
     },
 
     _computeLabelValueTitle(labels, label, value) {
-      return labels[label] && labels[label].values[value];
+      return labels[label] &&
+        labels[label].values &&
+        labels[label].values[value];
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
index 891afbc..1acf6ad 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
@@ -117,6 +117,39 @@
       assert.isTrue(labelsChangedHandler.called);
     });
 
+    test('_computeButtonClass', () => {
+      let value = 1;
+      let index = 0;
+      const totalItems = 5;
+      // positive and first position
+      assert.equal(element._computeButtonClass(value, index,
+          totalItems), 'positive');
+      // negative and first position
+      value = -1;
+      assert.equal(element._computeButtonClass(value, index,
+          totalItems), 'min');
+      // negative but not first position
+      index = 1;
+      assert.equal(element._computeButtonClass(value, index,
+          totalItems), 'negative');
+      // neutral
+      value = 0;
+      assert.equal(element._computeButtonClass(value, index,
+          totalItems), 'neutral');
+      // positive but not last position
+      value = 1;
+      assert.equal(element._computeButtonClass(value, index,
+          totalItems), 'positive');
+      // positive and last position
+      index = 4;
+      assert.equal(element._computeButtonClass(value, index,
+          totalItems), 'max');
+      // negative and last position
+      value = -1;
+      assert.equal(element._computeButtonClass(value, index,
+          totalItems), 'negative');
+    });
+
     test('correct item is selected', () => {
       // 1 should be the value of the selected item
       assert.strictEqual(element.$$('iron-selector').selected, '+1');
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index e04eeb7..526cc86 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -93,6 +93,12 @@
       gr-account-chip {
         display: inline;
       }
+      gr-button {
+        --gr-button: {
+          padding-left: 0;
+          padding-right: 0;
+        }
+      }
       .collapsed gr-comment-list,
       .collapsed .replyContainer,
       .collapsed .hideOnCollapsed,
@@ -159,7 +165,7 @@
                 no-trailing-margin
                 class="message hideOnCollapsed"
                 content="[[message.message]]"
-                config="[[_commentLinks]]"></gr-formatted-text>
+                config="[[_projectConfig.commentlinks]]"></gr-formatted-text>
             <div class="replyContainer" hidden$="[[!showReplyButton]]" hidden>
               <gr-button link small on-tap="_handleReplyTap">Reply</gr-button>
             </div>
@@ -168,7 +174,7 @@
                 change-num="[[changeNum]]"
                 patch-num="[[message._revision_number]]"
                 project-name="[[projectName]]"
-                comment-links="[[_commentLinks]]"></gr-comment-list>
+                project-config="[[_projectConfig]]"></gr-comment-list>
           </div>
         </template>
         <template is="dom-if" if="[[_computeIsReviewerUpdate(message)]]">
@@ -189,6 +195,7 @@
           <span class="date">
             <gr-date-formatter
                 has-tooltip
+                show-date-and-time
                 date-str="[[message.date]]"></gr-date-formatter>
           </span>
         </template>
@@ -196,6 +203,7 @@
           <a class="date" href$="[[_computeMessageHash(message)]]" on-tap="_handleLinkTap">
             <gr-date-formatter
                 has-tooltip
+                show-date-and-time
                 date-str="[[message.date]]"></gr-date-formatter>
           </a>
         </template>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index d907f3b..6b4499f 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -79,11 +79,10 @@
         type: String,
         observer: '_projectNameChanged',
       },
-      _commentLinks: Object,
       /**
        * @type {{ commentlinks: Array }}
        */
-      projectConfig: Object,
+      _projectConfig: Object,
       // Computed property needed to trigger Polymer value observing.
       _expanded: {
         type: Object,
@@ -239,7 +238,7 @@
 
     _projectNameChanged(name) {
       this.$.restAPI.getProjectConfig(name).then(config => {
-        this._commentLinks = config.commentlinks;
+        this._projectConfig = config;
       });
     },
   });
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index ab494b4..df9ce54 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -15,6 +15,7 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../gr-message/gr-message.html">
@@ -57,15 +58,6 @@
       #messageControlsContainer gr-button {
         padding: 0.4em 0;
       }
-      .separator {
-        background-color: rgba(0, 0, 0, .3);
-        height: 1.5em;
-        margin: 0 .6em;
-        width: 1px;
-      }
-      .separator.transparent {
-        background-color: transparent;
-      }
       .container {
         align-items: center;
         display: flex;
@@ -74,20 +66,21 @@
     <div class="header">
       <h3>Messages</h3>
       <div class="messageListControls container">
-        <gr-button id="collapse-messages" link
-            on-tap="_handleExpandCollapseTap">
-          [[_computeExpandCollapseMessage(_expanded)]]
-        </gr-button>
         <span
             id="automatedMessageToggleContainer"
             class="container"
             hidden$="[[!_hasAutomatedMessages(messages)]]">
+          <paper-toggle-button
+              id="automatedMessageToggle"
+              checked="{{_hideAutomated}}"></paper-toggle-button>Only comments
           <span class="transparent separator"></span>
-          <gr-button id="automatedMessageToggle" link
-              on-tap="_handleAutomatedMessageToggleTap">
-            [[_computeAutomatedToggleText(_hideAutomated)]]
-          </gr-button>
         </span>
+        <gr-button
+            id="collapse-messages"
+            link
+            on-tap="_handleExpandCollapseTap">
+          [[_computeExpandCollapseMessage(_expanded)]]
+        </gr-button>
       </div>
     </div>
     <span
@@ -113,7 +106,7 @@
       <gr-message
           change-num="[[changeNum]]"
           message="[[message]]"
-          comments="[[_computeCommentsForMessage(comments, message)]]"
+          comments="[[_computeCommentsForMessage(changeComments, message)]]"
           hide-automated="[[_hideAutomated]]"
           project-name="[[projectName]]"
           show-reply-button="[[showReplyButtons]]"
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index 58e56a4..9ccadf7 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -35,7 +35,7 @@
         type: Array,
         value() { return []; },
       },
-      comments: Object,
+      changeComments: Object,
       projectName: String,
       showReplyButtons: {
         type: Boolean,
@@ -175,12 +175,6 @@
       this.handleExpandCollapse(!this._expanded);
     },
 
-    _handleAutomatedMessageToggleTap(e) {
-      e.preventDefault();
-
-      this._hideAutomated = !this._hideAutomated;
-    },
-
     _handleScrollTo(e) {
       this.scrollToMessage(e.detail.message.id);
     },
@@ -199,19 +193,18 @@
       return expanded ? 'Collapse all' : 'Expand all';
     },
 
-    _computeAutomatedToggleText(hideAutomated) {
-      return hideAutomated ? 'Show all messages' : 'Show comments only';
-    },
-
     /**
      * Computes message author's file comments for change's message.
      * Method uses this.messages to find next message and relies on messages
      * to be sorted by date field descending.
-     * @param {!Object} comments Hash of arrays of comments, filename as key.
+     * @param {!Object} changeComments changeComment object, which includes
+     *     a method to get all published comments (including robot comments),
+     *     which returns a Hash of arrays of comments, filename as key.
      * @param {!Object} message
      * @return {!Object} Hash of arrays of comments, filename as key.
      */
-    _computeCommentsForMessage(comments, message) {
+    _computeCommentsForMessage(changeComments, message) {
+      const comments = changeComments.getAllPublishedComments();
       if (message._index === undefined || !comments || !this.messages) {
         return [];
       }
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
index 15964a7..453d97a 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -21,13 +21,27 @@
 <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="../../diff/gr-comment-api/gr-comment-api.html">
+
 <link rel="import" href="gr-messages-list.html">
 
 <script>void(0);</script>
 
+<dom-module id="comment-api-mock">
+  <template>
+    <gr-messages-list
+        id="messagesList"
+        change-comments="[[_changeComments]]"></gr-messages-list>
+    <gr-comment-api id="commentAPI"></gr-comment-api>
+  </template>
+  <script src="../../diff/gr-comment-api/gr-comment-api-mock.js"></script>
+</dom-module>
+
 <test-fixture id="basic">
   <template>
-    <gr-messages-list></gr-messages-list>
+    <comment-api-mock>
+      <gr-messages-list></gr-messages-list>
+    </comment-api-mock>
   </template>
 </test-fixture>
 
@@ -58,21 +72,84 @@
     let element;
     let messages;
     let sandbox;
+    let commentApiWrapper;
 
     const getMessages = function() {
       return Polymer.dom(element.root).querySelectorAll('gr-message');
     };
 
+    const author = {
+      _account_id: 42,
+      name: 'Marvin the Paranoid Android',
+      email: 'marvin@sirius.org',
+    };
+
+    const comments = {
+      file1: [
+        {
+          message: 'message text',
+          updated: '2016-09-27 00:18:03.000000000',
+          in_reply_to: '6505d749_f0bec0aa',
+          line: 62,
+          id: '6505d749_10ed44b2',
+          patch_set: 2,
+          author: {
+            email: 'some@email.com',
+            _account_id: 123,
+          },
+        },
+        {
+          message: 'message text',
+          updated: '2016-09-27 00:18:03.000000000',
+          in_reply_to: 'c5912363_6b820105',
+          line: 42,
+          id: '450a935e_0f1c05db',
+          patch_set: 2,
+          author,
+        },
+        {
+          message: 'message text',
+          updated: '2016-09-27 00:18:03.000000000',
+          in_reply_to: '6505d749_f0bec0aa',
+          line: 62,
+          id: '6505d749_10ed44b2',
+          patch_set: 2,
+          author,
+        },
+      ],
+      file2: [
+        {
+          message: 'message text',
+          updated: '2016-09-27 00:18:03.000000000',
+          in_reply_to: 'c5912363_4b7d450a',
+          line: 132,
+          id: '450a935e_4f260d25',
+          patch_set: 2,
+          author,
+        },
+      ],
+    };
+
     setup(() => {
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({}); },
         getLoggedIn() { return Promise.resolve(false); },
+        getDiffComments() { return Promise.resolve(comments); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
       });
       sandbox = sinon.sandbox.create();
-      element = fixture('basic');
       messages = _.times(3, randomMessage);
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = fixture('basic');
+      element = commentApiWrapper.$.messagesList;
+      loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
       element.messages = messages;
-      flushAsynchronousOperations();
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initalized.
+      return commentApiWrapper.loadComments();
     });
 
     teardown(() => {
@@ -285,56 +362,6 @@
     });
 
     test('messages', () => {
-      const author = {
-        _account_id: 42,
-        name: 'Marvin the Paranoid Android',
-        email: 'marvin@sirius.org',
-      };
-      const comments = {
-        file1: [
-          {
-            message: 'message text',
-            updated: '2016-09-27 00:18:03.000000000',
-            in_reply_to: '6505d749_f0bec0aa',
-            line: 62,
-            id: '6505d749_10ed44b2',
-            patch_set: 2,
-            author: {
-              email: 'some@email.com',
-              _account_id: 123,
-            },
-          },
-          {
-            message: 'message text',
-            updated: '2016-09-27 00:18:03.000000000',
-            in_reply_to: 'c5912363_6b820105',
-            line: 42,
-            id: '450a935e_0f1c05db',
-            patch_set: 2,
-            author,
-          },
-          {
-            message: 'message text',
-            updated: '2016-09-27 00:18:03.000000000',
-            in_reply_to: '6505d749_f0bec0aa',
-            line: 62,
-            id: '6505d749_10ed44b2',
-            patch_set: 2,
-            author,
-          },
-        ],
-        file2: [
-          {
-            message: 'message text',
-            updated: '2016-09-27 00:18:03.000000000',
-            in_reply_to: 'c5912363_4b7d450a',
-            line: 132,
-            id: '450a935e_4f260d25',
-            patch_set: 2,
-            author,
-          },
-        ],
-      };
       const messages = [].concat(
           randomMessage(),
           {
@@ -354,7 +381,6 @@
             id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5',
           }
       );
-      element.comments = comments;
       element.messages = messages;
       const isAuthor = function(author, message) {
         return message.author._account_id === author._account_id;
@@ -373,21 +399,6 @@
     });
 
     test('messages without author do not throw', () => {
-      const comments = {
-        file1: [
-          {
-            message: 'message text',
-            updated: '2016-09-27 00:18:03.000000000',
-            in_reply_to: '6505d749_f0bec0aa',
-            line: 62,
-            id: '6505d749_10ed44b2',
-            patch_set: 2,
-            author: {
-              email: 'some@email.com',
-              _account_id: 123,
-            },
-          },
-        ]};
       const messages = [{
         _index: 5,
         _revision_number: 4,
@@ -396,7 +407,6 @@
         id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
       }];
       element.messages = messages;
-      element.comments = comments;
       flushAsynchronousOperations();
       const messageEls = getMessages();
       assert.equal(messageEls.length, 1);
@@ -419,6 +429,8 @@
   suite('gr-messages-list automate tests', () => {
     let element;
     let messages;
+    let sandbox;
+    let commentApiWrapper;
 
     const getMessages = function() {
       return Polymer.dom(element.root).querySelectorAll('gr-message');
@@ -429,18 +441,36 @@
 
     const randomMessageReviewer = {
       reviewer: {},
+      date: '2016-01-13 20:30:33.038000',
     };
 
     setup(() => {
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({}); },
         getLoggedIn() { return Promise.resolve(false); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
       });
-      element = fixture('basic');
+
+      sandbox = sinon.sandbox.create();
       messages = _.times(2, randomAutomated);
       messages.push(randomMessageReviewer);
+
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = fixture('basic');
+      element = commentApiWrapper.$.messagesList;
+      loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
       element.messages = messages;
-      flushAsynchronousOperations();
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initalized.
+      return commentApiWrapper.loadComments();
+    });
+
+    teardown(() => {
+      sandbox.restore();
     });
 
     test('hide autogenerated button is not hidden', () => {
@@ -454,7 +484,7 @@
       assert.isFalse(!!allHiddenMessageEls.length);
     });
 
-    test('autogenerated messages hidden after hide button tap', () => {
+    test('autogenerated messages hidden after comments only toggle', () => {
       let allHiddenMessageEls = getHiddenMessages();
 
       element._hideAutomated = false;
@@ -467,16 +497,17 @@
       assert.equal(allHiddenMessageEls.length, allMessageEls.length);
     });
 
-    test('autogenerated messages not hidden after show button tap', () => {
-      let allHiddenMessageEls = getHiddenMessages();
+    test('autogenerated messages not hidden after comments only toggle',
+        () => {
+          let allHiddenMessageEls = getHiddenMessages();
 
-      element._hideAutomated = true;
-      MockInteractions.tap(element.$.automatedMessageToggle);
-      allHiddenMessageEls = getHiddenMessages();
+          element._hideAutomated = true;
+          MockInteractions.tap(element.$.automatedMessageToggle);
+          allHiddenMessageEls = getHiddenMessages();
 
-      // Autogenerated messages are now hidden.
-      assert.isFalse(!!allHiddenMessageEls.length);
-    });
+          // Autogenerated messages are now hidden.
+          assert.isFalse(!!allHiddenMessageEls.length);
+        });
 
     test('_getDelta', () => {
       let messages = [randomMessage()];
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
index 401aaf8..db36438 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -57,6 +57,7 @@
       h4:before,
       section div:before {
         content: ' ';
+        flex-shrink: 0;
         width: 1.2em
       }
       .relatedChanges a {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index 780dca2..7ad0317 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -17,6 +17,14 @@
   Polymer({
     is: 'gr-related-changes-list',
 
+    /**
+     * Fired when a new section is loaded so that the change view can determine
+     * a show more button is needed, sometimes before all the sections finish
+     * loading.
+     *
+     * @event new-section-loaded
+     */
+
     properties: {
       change: Object,
       hasParent: {
@@ -76,6 +84,12 @@
     clear() {
       this.loading = true;
       this.hidden = true;
+
+      this._relatedResponse = {changes: []};
+      this._submittedTogether = [];
+      this._conflicts = [];
+      this._cherryPicks = [];
+      this._sameTopic = [];
     },
 
     reload() {
@@ -86,15 +100,17 @@
       const promises = [
         this._getRelatedChanges().then(response => {
           this._relatedResponse = response;
-
+          this._fireReloadEvent();
           this.hasParent = this._calculateHasParent(this.change.change_id,
               response.changes);
         }),
         this._getSubmittedTogether().then(response => {
           this._submittedTogether = response;
+          this._fireReloadEvent();
         }),
         this._getCherryPicks().then(response => {
           this._cherryPicks = response;
+          this._fireReloadEvent();
         }),
       ];
 
@@ -104,6 +120,7 @@
           // Because the server doesn't always return a response and the
           // template expects an array, always return an array.
           this._conflicts = response ? response : [];
+          this._fireReloadEvent();
         }));
       }
 
@@ -123,6 +140,14 @@
       });
     },
 
+    _fireReloadEvent() {
+      // The listener on the change computes height of the related changes
+      // section, so they have to be rendered first, and inside a dom-repeat,
+      // that requires a flush.
+      Polymer.dom.flush();
+      this.dispatchEvent(new CustomEvent('new-section-loaded'));
+    },
+
     /**
      * Determines whether or not the given change has a parent change. If there
      * is a relation chain, and the change id is not the last item of the
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
index df4391e..bc5ba80 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -231,6 +231,29 @@
           change1, change2).indexOf('thisChange'), -1);
     });
 
+    test('event for section loaded fires for each section ', () => {
+      const loadedStub = sandbox.stub();
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
+        status: 'NEW',
+        mergeable: true,
+      };
+      element.addEventListener('new-section-loaded', loadedStub);
+      sandbox.stub(element, '_getRelatedChanges')
+          .returns(Promise.resolve({changes: []}));
+      sandbox.stub(element, '_getSubmittedTogether')
+          .returns(Promise.resolve());
+      sandbox.stub(element, '_getCherryPicks')
+          .returns(Promise.resolve());
+      sandbox.stub(element, '_getConflicts')
+          .returns(Promise.resolve());
+
+      return element.reload().then(() => {
+        assert.equal(loadedStub.callCount, 4);
+      });
+    });
+
     suite('_getConflicts resolves undefined', () => {
       let element;
 
@@ -337,12 +360,35 @@
           true);
     });
 
-    test('clear hides', () => {
-      element.loading = false;
+    test('clear and empties', () => {
+      const changes = [{
+        project: 'foo/bar',
+        change_id: 'Ideadbeef',
+        commit: {
+          commit: 'deadbeef',
+          parents: [{commit: 'abc123'}],
+          author: {},
+          subject: 'do that thing',
+        },
+        _change_number: 12345,
+        _revision_number: 1,
+        _current_revision_number: 1,
+        status: 'NEW',
+      }];
+      element._relatedResponse = {changes};
+      element._submittedTogether = changes;
+      element._conflicts = changes;
+      element._cherryPicks = changes;
+      element._sameTopic = changes;
+
       element.hidden = false;
       element.clear();
-      assert.isTrue(element.loading);
       assert.isTrue(element.hidden);
+      assert.equal(element._relatedResponse.changes.length, 0);
+      assert.equal(element._submittedTogether.length, 0);
+      assert.equal(element._conflicts.length, 0);
+      assert.equal(element._cherryPicks.length, 0);
+      assert.equal(element._sameTopic.length, 0);
     });
 
     test('update fires', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
index babd95c..22b53c8 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
@@ -86,7 +86,8 @@
         ],
       };
       element.serverConfig = {note_db_enabled: true};
-      sandbox.stub(element, 'fetchIsLatestKnown', () => Promise.resolve(true));
+      sandbox.stub(element, 'fetchChangeUpdates')
+          .returns(Promise.resolve({isLatest: true}));
     };
 
     setup(() => {
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 a4fbf98..46b803f 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
@@ -38,7 +38,7 @@
     <style include="shared-styles">
       :host {
         display: block;
-        max-height: 90vh;
+        max-height: 100%;
       }
       :host([disabled]) {
         pointer-events: none;
@@ -49,13 +49,24 @@
       .container {
         display: flex;
         flex-direction: column;
-        max-height: 90vh;
+        max-height: 100%;
       }
       section {
         border-top: 1px solid #cdcdcd;
+        flex-shrink: 0;
         padding: .5em 1.5em;
         width: 100%;
       }
+      .actions {
+        background-color: var(--view-background-color);
+        bottom: 0;
+        display: flex;
+        justify-content: space-between;
+        position: sticky;
+      }
+      .actions .right gr-button {
+        margin-left: 1em;
+      }
       .peopleContainer,
       .labelsContainer {
         flex-shrink: 0;
@@ -65,11 +76,11 @@
       }
       .peopleList {
         display: flex;
-        align-items: center;
         padding-top: .1em;
       }
       .peopleListLabel {
         color: #666;
+        margin-top: .2em;
         min-width: 7em;
         padding-right: .5em;
       }
@@ -78,10 +89,6 @@
         flex-wrap: wrap;
         flex: 1;
         min-height: 1.8em;
-        --account-list-style: {
-          max-height: 12em;
-          overflow-y: auto;
-        }
       }
       #reviewerConfirmationOverlay {
         padding: 1em;
@@ -97,7 +104,7 @@
         font-style: italic;
       }
       .textareaContainer {
-        min-height: 6em;
+        min-height: 12em;
         position: relative;
       }
       .textareaContainer,
@@ -108,14 +115,8 @@
       }
       .previewContainer gr-formatted-text {
         background: #f6f6f6;
-        max-height: 20vh;
-        overflow-y: scroll;
         padding: 1em;
       }
-      .draftsContainer {
-        flex: 1;
-        overflow-y: auto;
-      }
       .draftsContainer h3 {
         margin-top: .25em;
       }
@@ -137,27 +138,10 @@
       #savingLabel.saving {
         display: inline;
       }
-      #cancelButton {
-        float: right;
-      }
-      @media screen and (max-width: 50em) {
-        :host {
-          max-height: none;
-        }
-        .container {
-          max-height: none;
-        }
-      }
     </style>
     <div class="container" tabindex="-1">
       <section class="peopleContainer">
         <div class="peopleList">
-          <div class="peopleListLabel">Owner</div>
-          <gr-account-chip account="[[_owner]]"></gr-account-chip>
-        </div>
-      </section>
-      <section class="peopleContainer">
-        <div class="peopleList">
           <div class="peopleListLabel">Reviewers</div>
           <gr-account-list
               id="reviewers"
@@ -166,7 +150,8 @@
               change="[[change]]"
               filter="[[filterReviewerSuggestion]]"
               pending-confirmation="{{_reviewerPendingConfirmation}}"
-              placeholder="Add reviewer...">
+              placeholder="Add reviewer..."
+              on-account-text-changed="_handleAccountTextEntry">
           </gr-account-list>
         </div>
         <template is="dom-if" if="[[serverConfig.note_db_enabled]]">
@@ -179,7 +164,8 @@
                 filter="[[filterCCSuggestion]]"
                 pending-confirmation="{{_ccPendingConfirmation}}"
                 allow-any-input
-                placeholder="Add CC...">
+                placeholder="Add CC..."
+                on-account-text-changed="_handleAccountTextEntry">
             </gr-account-list>
           </div>
         </template>
@@ -218,7 +204,6 @@
               monospace="true"
               disabled="{{disabled}}"
               rows="4"
-              max-rows="15"
               text="{{draft}}"
               on-bind-value-changed="_handleHeightChanged">
           </gr-textarea>
@@ -261,33 +246,45 @@
           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">
+          <template is="dom-if" if="[[canBeStarted]]">
+            <gr-button
+                link
+                tertiary
+                disabled="[[_isState(knownLatestState, 'not-latest')]]"
+                class="action save"
+                has-tooltip
+                title="[[_saveTooltip]]"
+                on-tap="_saveTapHandler">Save</gr-button>
+          </template>
+          <span
+              id="checkingStatusLabel"
+              hidden$="[[!_isState(knownLatestState, 'checking')]]">
+            Checking whether patch [[patchNum]] is latest...
+          </span>
+          <span
+              id="notLatestLabel"
+              hidden$="[[!_isState(knownLatestState, 'not-latest')]]">
+            [[_computePatchSetWarning(patchNum, _labelsChanged)]]
+            <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(_sendButtonLabel, diffDrafts, draft, _reviewersMutated, _labelsChanged, _includeComments)]]"
+              class="action send"
+              has-tooltip
+              title$="[[_computeSendButtonTooltip(canBeStarted)]]"
+              on-tap="_sendTapHandler">[[_sendButtonLabel]]</gr-button>
+        </div>
       </section>
     </div>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index ef9bc8d..35bdfb0 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -39,6 +39,12 @@
     SEND: 'Send',
   };
 
+  const ButtonTooltips = {
+    SAVE: 'Save reply but do not send',
+    START_REVIEW: 'Mark as ready for review and send reply',
+    SEND: 'Send reply',
+  };
+
   // TODO(logan): Remove once the fix for issue 6841 is stable on
   // googlesource.com.
   const START_REVIEW_MESSAGE = 'This change is ready for review.';
@@ -188,6 +194,11 @@
         type: Boolean,
         value: false,
       },
+      _saveTooltip: {
+        type: String,
+        value: ButtonTooltips.SAVE,
+        readOnly: true,
+      },
     },
 
     FocusTarget,
@@ -226,14 +237,19 @@
 
     open(opt_focusTarget) {
       this.knownLatestState = LatestPatchState.CHECKING;
-      this.fetchIsLatestKnown(this.change, this.$.restAPI)
-          .then(isUpToDate => {
-            this.knownLatestState = isUpToDate ?
+      this.fetchChangeUpdates(this.change, this.$.restAPI)
+          .then(result => {
+            this.knownLatestState = result.isLatest ?
                 LatestPatchState.LATEST : LatestPatchState.NOT_LATEST;
           });
 
       this._focusOn(opt_focusTarget);
-      if (!this.draft || !this.draft.length) {
+      if (this.quote && this.quote.length) {
+        // If a reply quote has been provided, use it and clear the property.
+        this.draft = this.quote;
+        this.quote = '';
+      } else {
+        // Otherwise, check for an unsaved draft in localstorage.
         this.draft = this._loadStoredDraft();
       }
       if (this.$.restAPI.hasPendingDiffDrafts()) {
@@ -252,7 +268,7 @@
     getFocusStops() {
       return {
         start: this.$.reviewers.focusStart,
-        end: this.$.cancelButton,
+        end: this.$.sendButton,
       };
     },
 
@@ -390,12 +406,6 @@
     },
 
     send(includeComments, startReview) {
-      if (this.knownLatestState === 'not-latest') {
-        this.fire('show-alert',
-            {message: 'Cannot reply to non-latest patch.'});
-        return Promise.resolve({});
-      }
-
       const labels = this.$.labelScores.getLabelValues();
 
       const obj = {
@@ -499,7 +509,8 @@
     },
 
     _focusOn(section) {
-      if (section === FocusTarget.ANY) {
+      // Safeguard- always want to focus on something.
+      if (!section || section === FocusTarget.ANY) {
         section = this._chooseFocusTarget();
       }
       if (section === FocusTarget.BODY) {
@@ -763,6 +774,14 @@
       return draft ? draft.message : '';
     },
 
+    _handleAccountTextEntry() {
+      // When either of the account entries has input added to the autocomplete,
+      // it should trigger the save button to enable/
+      //
+      // Note: if the text is removed, the save button will not get disabled.
+      this._reviewersMutated = true;
+    },
+
     _draftChanged(newDraft, oldDraft) {
       this.debounce('store', () => {
         if (!newDraft.length && oldDraft) {
@@ -798,6 +817,10 @@
       return canBeStarted ? ButtonLabels.START_REVIEW : ButtonLabels.SEND;
     },
 
+    _computeSendButtonTooltip(canBeStarted) {
+      return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
+    },
+
     _computeCCsEnabled(serverConfig) {
       return serverConfig && serverConfig.note_db_enabled;
     },
@@ -806,16 +829,21 @@
       return savingComments ? 'saving' : '';
     },
 
-    _computeSendButtonDisabled(knownLatestState, buttonLabel, drafts, text,
-        reviewersMutated, labelsChanged, includeComments) {
-      if (this._isState(knownLatestState, LatestPatchState.NOT_LATEST)) {
-        return true;
-      }
+    _computeSendButtonDisabled(buttonLabel, drafts, text, reviewersMutated,
+        labelsChanged, includeComments) {
       if (buttonLabel === ButtonLabels.START_REVIEW) {
         return false;
       }
       const hasDrafts = includeComments && Object.keys(drafts).length;
       return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged;
     },
+
+    _computePatchSetWarning(patchNum, labelsChanged) {
+      let str = `Patch ${patchNum} is not latest.`;
+      if (labelsChanged) {
+        str += ' Voting on a non-latest patch will have no effect.';
+      }
+      return str;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index 278f2c6..8d91ef8 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -55,7 +55,7 @@
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({}); },
         getAccount() { return Promise.resolve({}); },
-        getChange() { return Promise.resolve({}); },
+        getChange() { return Promise.resolve([{}]); },
         getChangeSuggestedReviewers() { return Promise.resolve([]); },
       });
 
@@ -103,8 +103,8 @@
       eraseDraftCommentStub = sandbox.stub(element.$.storage,
           'eraseDraftComment');
 
-      sandbox.stub(element, 'fetchIsLatestKnown',
-          () => { return Promise.resolve(true); });
+      sandbox.stub(element, 'fetchChangeUpdates')
+          .returns(Promise.resolve({isLatest: true}));
 
       // Allow the elements created by dom-repeat to be stamped.
       flushAsynchronousOperations();
@@ -249,8 +249,8 @@
 
     test('getlabelValue when no score is selected', done => {
       flush(() => {
-        element.$$('gr-label-scores').$$(`gr-label-score-row[name="Code-Review"]`)
-            .setSelectedValue(-1);
+        element.$$('gr-label-scores')
+            .$$(`gr-label-score-row[name="Code-Review"]`).setSelectedValue(-1);
         assert.strictEqual(element.getLabelValue('Verified'), ' 0');
         done();
       });
@@ -407,6 +407,17 @@
       assert.equal(actual.path, '@change');
     });
 
+    test('_reviewersMutated when account-text-change is fired from ccs', () => {
+      element.serverConfig = {note_db_enabled: true};
+      flushAsynchronousOperations();
+      assert.isFalse(element._reviewersMutated);
+      assert.isTrue(element.$$('#ccs').allowAnyInput);
+      assert.isFalse(element.$$('#reviewers').allowAnyInput);
+      element.$$('#ccs').dispatchEvent(new CustomEvent('account-text-changed',
+          {bubbles: true}));
+      assert.isTrue(element._reviewersMutated);
+    });
+
     test('gets draft from storage on open', () => {
       const storedDraft = 'hello world';
       getDraftCommentStub.returns({message: storedDraft});
@@ -415,13 +426,34 @@
       assert.equal(element.draft, storedDraft);
     });
 
+    test('gets draft from storage even when text is already present', () => {
+      const storedDraft = 'hello world';
+      getDraftCommentStub.returns({message: storedDraft});
+      element.draft = 'foo bar';
+      element.open();
+      assert.isTrue(getDraftCommentStub.called);
+      assert.equal(element.draft, storedDraft);
+    });
+
     test('blank if no stored draft', () => {
       getDraftCommentStub.returns(null);
+      element.draft = 'foo bar';
       element.open();
       assert.isTrue(getDraftCommentStub.called);
       assert.equal(element.draft, '');
     });
 
+    test('does not check stored draft when quote is present', () => {
+      const storedDraft = 'hello world';
+      const quote = '> foo bar';
+      getDraftCommentStub.returns({message: storedDraft});
+      element.quote = quote;
+      element.open();
+      assert.isFalse(getDraftCommentStub.called);
+      assert.equal(element.draft, quote);
+      assert.isNotOk(element.quote);
+    });
+
     test('updates stored draft on edits', () => {
       const firstEdit = 'hello';
       const location = element._getStorageLocation();
@@ -505,6 +537,45 @@
       assert.isFalse(filter({group: cc2}));
     });
 
+    test('_focusOn', () => {
+      sandbox.spy(element, '_chooseFocusTarget');
+      element.serverConfig = {note_db_enabled: true};
+      flushAsynchronousOperations();
+      const textareaStub = sandbox.stub(element.$.textarea, 'async');
+      const reviewerEntryStub = sandbox.stub(element.$.reviewers.focusStart,
+          'async');
+      const ccStub = sandbox.stub(element.$$('#ccs').focusStart, 'async');
+      element._focusOn();
+      assert.equal(element._chooseFocusTarget.callCount, 1);
+      assert.deepEqual(textareaStub.callCount, 1);
+      assert.deepEqual(reviewerEntryStub.callCount, 0);
+      assert.deepEqual(ccStub.callCount, 0);
+
+      element._focusOn(element.FocusTarget.ANY);
+      assert.equal(element._chooseFocusTarget.callCount, 2);
+      assert.deepEqual(textareaStub.callCount, 2);
+      assert.deepEqual(reviewerEntryStub.callCount, 0);
+      assert.deepEqual(ccStub.callCount, 0);
+
+      element._focusOn(element.FocusTarget.BODY);
+      assert.equal(element._chooseFocusTarget.callCount, 2);
+      assert.deepEqual(textareaStub.callCount, 3);
+      assert.deepEqual(reviewerEntryStub.callCount, 0);
+      assert.deepEqual(ccStub.callCount, 0);
+
+      element._focusOn(element.FocusTarget.REVIEWERS);
+      assert.equal(element._chooseFocusTarget.callCount, 2);
+      assert.deepEqual(textareaStub.callCount, 3);
+      assert.deepEqual(reviewerEntryStub.callCount, 1);
+      assert.deepEqual(ccStub.callCount, 0);
+
+      element._focusOn(element.FocusTarget.CCS);
+      assert.equal(element._chooseFocusTarget.callCount, 2);
+      assert.deepEqual(textareaStub.callCount, 3);
+      assert.deepEqual(reviewerEntryStub.callCount, 1);
+      assert.deepEqual(ccStub.callCount, 1);
+    });
+
     test('_chooseFocusTarget', () => {
       element._account = null;
       assert.strictEqual(
@@ -1012,21 +1083,20 @@
 
     test('_computeSendButtonDisabled', () => {
       const fn = element._computeSendButtonDisabled.bind(element);
-      assert.isTrue(fn('not-latest'));
-      assert.isFalse(fn('latest', 'Start review'));
-      assert.isTrue(fn('latest', 'Send', {}, '', false, false, false));
+      assert.isFalse(fn('Start review'));
+      assert.isTrue(fn('Send', {}, '', false, false, false));
       // Mock nonempty comment draft array, with seding comments.
-      assert.isFalse(fn('latest', 'Send', {file: ['draft']}, '', false, false,
+      assert.isFalse(fn('Send', {file: ['draft']}, '', false, false,
           true));
       // Mock nonempty comment draft array, without seding comments.
-      assert.isTrue(fn('latest', 'Send', {file: ['draft']}, '', false, false,
+      assert.isTrue(fn('Send', {file: ['draft']}, '', false, false,
           false));
       // Mock nonempty change message.
-      assert.isFalse(fn('latest', 'Send', {}, 'test', false, false, false));
+      assert.isFalse(fn('Send', {}, 'test', false, false, false));
       // Mock reviewers mutated.
-      assert.isFalse(fn('latest', 'Send', {}, '', true, false, false));
+      assert.isFalse(fn('Send', {}, '', true, false, false));
       // Mock labels changed.
-      assert.isFalse(fn('latest', 'Send', {}, '', false, true, false));
+      assert.isFalse(fn('Send', {}, '', false, true, false));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
index 9a5b5ed..b77cc30 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
@@ -54,6 +54,12 @@
       .remove {
         font-size: .9em;
       }
+      gr-button {
+        --gr-button: {
+          padding-left: 0;
+          padding-right: 0;
+        }
+      }
       @media screen and (max-width: 50em), screen and (min-width: 75em) {
         gr-account-chip:first-of-type {
           margin-top: 0;
@@ -63,6 +69,7 @@
     <template is="dom-repeat" items="[[_displayedReviewers]]" as="reviewer">
       <gr-account-chip class="reviewer" account="[[reviewer]]"
           on-remove="_handleRemove"
+          additional-text="[[_computeReviewerTooltip(reviewer, change)]]"
           removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]">
       </gr-account-chip>
     </template>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index 59332f9..b1e6a5a 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -75,6 +75,81 @@
       '_reviewersChanged(change.reviewers.*, change.owner)',
     ],
 
+    /**
+     * Converts change.permitted_labels to an array of hashes of label keys to
+     * numeric scores.
+     * Example:
+     * [{
+     *   'Code-Review': ['-1', ' 0', '+1']
+     * }]
+     * will be converted to
+     * [{
+     *   label: 'Code-Review',
+     *   scores: [-1, 0, 1]
+     * }]
+     */
+    _permittedLabelsToNumericScores(labels) {
+      if (!labels) return [];
+      return Object.keys(labels).map(label => ({
+        label,
+        scores: labels[label].map(v => parseInt(v, 10)),
+      }));
+    },
+
+    /**
+     * Returns hash of labels to max permitted score.
+     * @param {!Object} change
+     * @returns {!Object} labels to max permitted scores hash
+     */
+    _getMaxPermittedScores(change) {
+      return this._permittedLabelsToNumericScores(change.permitted_labels)
+          .map(({label, scores}) => ({
+            [label]: scores
+                .map(v => parseInt(v, 10))
+                .reduce((a, b) => Math.max(a, b))}))
+          .reduce((acc, i) => Object.assign(acc, i), {});
+    },
+
+    /**
+     * Returns max permitted score for reviewer.
+     * @param {!Object} reviewer
+     * @param {!Object} change
+     * @param {string} label
+     * @return {number}
+     */
+    _getReviewerPermittedScore(reviewer, change, label) {
+      // Note (issue 7874): sometimes the "all" list is not included in change
+      // detail responses, even when DETAILED_LABELS is included in options.
+      if (!change.labels[label].all) { return NaN; }
+      const detailed = change.labels[label].all.filter(
+          ({_account_id}) => reviewer._account_id === _account_id).pop();
+      if (!detailed || !detailed.hasOwnProperty('permitted_voting_range')) {
+        return NaN;
+      }
+      return detailed.permitted_voting_range.max;
+    },
+
+    _computeReviewerTooltip(reviewer, change) {
+      if (!change || !change.permitted_labels) return '';
+      const maxScores = [];
+      const maxPermitted = this._getMaxPermittedScores(change);
+      for (const label of Object.keys(change.permitted_labels)) {
+        const maxScore =
+              this._getReviewerPermittedScore(reviewer, change, label);
+        if (isNaN(maxScore) || maxScore < 0) continue;
+        if (maxScore > 0 && maxScore === maxPermitted[label]) {
+          maxScores.push(`${label}: +${maxScore}`);
+        } else {
+          maxScores.push(`${label}`);
+        }
+      }
+      if (maxScores.length) {
+        return 'Votable: ' + maxScores.join(', ');
+      } else {
+        return '';
+      }
+    },
+
     _reviewersChanged(changeRecord, owner) {
       let result = [];
       const reviewers = changeRecord.base;
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index 40f1cbd..985e4bb 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -290,5 +290,57 @@
       assert.equal(element._reviewers.length, 100);
       assert.isTrue(element.$$('.hiddenReviewers').hidden);
     });
+
+    test('votable labels', () => {
+      const change = {
+        labels: {
+          Foo: {
+            all: [{_account_id: 7, permitted_voting_range: {max: 2}}],
+          },
+          Bar: {
+            all: [{_account_id: 1, permitted_voting_range: {max: 1}},
+                  {_account_id: 7, permitted_voting_range: {max: 1}}],
+          },
+          FooBar: {
+            all: [{_account_id: 7, permitted_voting_range: {max: 0}}],
+          },
+        },
+        permitted_labels: {
+          Foo: ['-1', ' 0', '+1', '+2'],
+          Bar: ['-1', ' 0', '+1', '+2'],
+          FooBar: ['-1', ' 0'],
+        },
+      };
+      assert.strictEqual(
+          element._computeReviewerTooltip({_account_id: 1}, change),
+          'Votable: Bar');
+      assert.strictEqual(
+          element._computeReviewerTooltip({_account_id: 7}, change),
+          'Votable: Foo: +2, Bar, FooBar');
+      assert.strictEqual(
+          element._computeReviewerTooltip({_account_id: 2}, change),
+          '');
+    });
+
+    test('fails gracefully when all is not included', () => {
+      const change = {
+        labels: {
+          Foo: {},
+          Bar: {},
+          FooBar: {},
+        },
+        permitted_labels: {
+          Foo: ['-1', ' 0', '+1', '+2'],
+          Bar: ['-1', ' 0', '+1', '+2'],
+          FooBar: ['-1', ' 0'],
+        },
+      };
+      assert.strictEqual(
+          element._computeReviewerTooltip({_account_id: 1}, change), '');
+      assert.strictEqual(
+          element._computeReviewerTooltip({_account_id: 7}, change), '');
+      assert.strictEqual(
+          element._computeReviewerTooltip({_account_id: 2}, change), '');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
index 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-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
index 03f0e53..202093a 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
@@ -23,6 +23,8 @@
     <style include="shared-styles">
       :host {
         display: block;
+        max-height: 100vh;
+        overflow-y: auto;
       }
       header{
         padding: 1em;
@@ -81,6 +83,27 @@
             <td><span class="key">?</span></td>
             <td>Show this dialog</td>
           </tr>
+          <tr>
+            <td>
+              <span class="key modifier">g</span>
+              <span class="key">o</span>
+            </td>
+            <td>Go to Opened Changes</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">g</span>
+              <span class="key">m</span>
+            </td>
+            <td>Go to Merged Changes</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">g</span>
+              <span class="key">a</span>
+            </td>
+            <td>Go to Abandoned Changes</td>
+          </tr>
         </tbody>
         <!-- Change View -->
         <tbody hidden$="[[!_computeInView(view, 'change')]]" hidden>
@@ -240,6 +263,23 @@
             <td>Collapse all messages</td>
           </tr>
           <tr>
+            <td></td><td class="header">Reply dialog</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Ctrl</span>
+              <span class="key">Enter</span><br/>
+            </td>
+            <td>Send reply</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Meta</span>
+              <span class="key">Enter</span>
+            </td>
+            <td>Send reply</td>
+          </tr>
+          <tr>
             <td></td><td class="header">File list</td>
           </tr>
           <tr>
@@ -421,8 +461,18 @@
             <td>
               <span class="key modifier">Ctrl</span>
               <span class="key">s</span><br/>
+            </td>
+            <td>Save comment</td>
+          </tr>
+          <tr>
+            <td>
               <span class="key modifier">Ctrl</span>
               <span class="key">Enter</span><br/>
+            </td>
+            <td>Save comment</td>
+          </tr>
+          <tr>
+            <td>
               <span class="key modifier">Meta</span>
               <span class="key">Enter</span>
             </td>
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 e56dc91..9493a61 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
@@ -58,6 +58,7 @@
       }
       ul {
         list-style: none;
+        padding-left: 1em;
       }
       .links > li {
         cursor: default;
@@ -89,11 +90,13 @@
         margin-left: .5em;
         max-width: 500px;
       }
-      gr-dropdown {
-        padding: 0.5em;
+      gr-dropdown,
+      .browse {
+        padding: .6em .5em;
       }
       .browse {
-        padding: 1em;
+        /* Same as gr-button */
+        margin: 5px 4px;
         text-decoration: none;
       }
       .accountContainer:not(.loggedIn):not(.loggedOut) .loginButton,
@@ -105,7 +108,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;
@@ -162,7 +165,7 @@
         <li>
           <a
               class="browse linksTitle"
-              href$="[[_computeRelativeURL('/admin/projects')]]">
+              href$="[[_computeRelativeURL('/admin/repos')]]">
             Browse</a>
         </li>
       </ul>
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 bd69007..7b17f22 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -26,6 +26,9 @@
     //    - `changeNum`, required, String: the numeric ID of the change.
     //
     // - Gerrit.Nav.View.SEARCH:
+    //    - `query`, optional, String: the literal search query. If provided,
+    //        the string will be used as the query, and all other params will be
+    //        ignored.
     //    - `owner`, optional, String: the owner name.
     //    - `project`, optional, String: the project name.
     //    - `branch`, optional, String: the branch name.
@@ -47,6 +50,17 @@
     //    - `leftSide`, optional, Boolean, if a `lineNum` is provided, a value
     //        of true selects the line from base of the patch range. False by
     //        default.
+    //
+    //  - Gerrit.Nav.View.GROUP:
+    //    - `groupId`, required, String, the ID of the group.
+    //    - `detail`, optional, String, the name of the group detail view.
+    //      Takes any value from Gerrit.Nav.GroupDetailView.
+    //
+    //  - Gerrit.Nav.View.REPO:
+    //    - `repoName`, required, String, the name of the repo
+    //    - `detail`, optional, String, the name of the repo detail view.
+    //      Takes any value from Gerrit.Nav.RepoDetailView.
+
 
     window.Gerrit = window.Gerrit || {};
 
@@ -57,27 +71,52 @@
       console.warn('Use of uninitialized routing');
     };
 
+    const EDIT_PATCHNUM = 'edit';
     const PARENT_PATCHNUM = 'PARENT';
 
     window.Gerrit.Nav = {
 
       View: {
         ADMIN: 'admin',
-        CHANGE: 'change',
         AGREEMENTS: 'agreements',
+        CHANGE: 'change',
         DASHBOARD: 'dashboard',
         DIFF: 'diff',
         EDIT: 'edit',
+        GROUP: 'group',
+        PLUGIN_SCREEN: 'plugin-screen',
+        REPO: 'repo',
         SEARCH: 'search',
         SETTINGS: 'settings',
       },
 
+      GroupDetailView: {
+        MEMBERS: 'members',
+        LOG: 'log',
+      },
+
+      RepoDetailView: {
+        ACCESS: 'access',
+        BRANCHES: 'branches',
+        COMMANDS: 'commands',
+        TAGS: 'tags',
+      },
+
+      WeblinkType: {
+        CHANGE: 'change',
+        FILE: 'file',
+        PATCHSET: 'patchset',
+      },
+
       /** @type {Function} */
       _navigate: uninitialized,
 
       /** @type {Function} */
       _generateUrl: uninitialized,
 
+      /** @type {Function} */
+      _generateWeblinks: uninitialized,
+
       /**
        * @param {number=} patchNum
        * @param {number|string=} basePatchNum
@@ -92,15 +131,18 @@
        * Setup router implementation.
        * @param {Function} navigate
        * @param {Function} generateUrl
+       * @param {Function} generateWeblinks
        */
-      setup(navigate, generateUrl) {
+      setup(navigate, generateUrl, generateWeblinks) {
         this._navigate = navigate;
         this._generateUrl = generateUrl;
+        this._generateWeblinks = generateWeblinks;
       },
 
       destroy() {
         this._navigate = uninitialized;
         this._generateUrl = uninitialized;
+        this._generateWeblinks = uninitialized;
       },
 
       /**
@@ -112,13 +154,20 @@
         return this._generateUrl(params);
       },
 
+      getUrlForSearchQuery(query) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.SEARCH,
+          query,
+        });
+      },
+
       /**
        * @param {!string} project The name of the project.
        * @param {boolean=} opt_openOnly When true, only search open changes in
        *     the project.
        * @return {string}
        */
-      getUrlForProject(project, opt_openOnly) {
+      getUrlForProjectChanges(project, opt_openOnly) {
         return this._getUrlFor({
           view: Gerrit.Nav.View.SEARCH,
           project,
@@ -166,6 +215,17 @@
       },
 
       /**
+       * Navigate to a search for changes with the given status.
+       * @param {string} status
+       */
+      navigateToStatusSearch(status) {
+        this._navigate(this._getUrlFor({
+          view: Gerrit.Nav.View.SEARCH,
+          statuses: [status],
+        }));
+      },
+
+      /**
        * @param {!Object} change The change object.
        * @param {number=} opt_patchNum
        * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
@@ -277,6 +337,7 @@
           changeNum,
           project,
           path,
+          patchNum: EDIT_PATCHNUM,
         });
       },
 
@@ -314,9 +375,157 @@
         this._navigate(relativeUrl);
       },
 
+      /**
+       * @param {string} repoName
+       * @return {string}
+       */
+      getUrlForRepo(repoName) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.REPO,
+          repoName,
+        });
+      },
+
+      /**
+       * @param {string} repoName
+       * @return {string}
+       */
+      getUrlForRepoTags(repoName) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.REPO,
+          repoName,
+          detail: Gerrit.Nav.RepoDetailView.TAGS,
+        });
+      },
+
+      /**
+       * @param {string} repoName
+       * @return {string}
+       */
+      getUrlForRepoBranches(repoName) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.REPO,
+          repoName,
+          detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+        });
+      },
+
+      /**
+       * @param {string} repoName
+       * @return {string}
+       */
+      getUrlForRepoAccess(repoName) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.REPO,
+          repoName,
+          detail: Gerrit.Nav.RepoDetailView.ACCESS,
+        });
+      },
+
+      /**
+       * @param {string} repoName
+       * @return {string}
+       */
+      getUrlForRepoCommands(repoName) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.REPO,
+          repoName,
+          detail: Gerrit.Nav.RepoDetailView.COMMANDS,
+        });
+      },
+
+      /**
+       * @param {string} groupId
+       * @return {string}
+       */
+      getUrlForGroup(groupId) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.GROUP,
+          groupId,
+        });
+      },
+
+      /**
+       * @param {string} groupId
+       * @return {string}
+       */
+      getUrlForGroupLog(groupId) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.GROUP,
+          groupId,
+          detail: Gerrit.Nav.GroupDetailView.LOG,
+        });
+      },
+
+      /**
+       * @param {string} groupId
+       * @return {string}
+       */
+      getUrlForGroupMembers(groupId) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.GROUP,
+          groupId,
+          detail: Gerrit.Nav.GroupDetailView.MEMBERS,
+        });
+      },
+
       getUrlForSettings() {
         return this._getUrlFor({view: Gerrit.Nav.View.SETTINGS});
       },
+
+      /**
+       * @param {string} repo
+       * @param {string} commit
+       * @param {string} file
+       * @param {Object=} opt_options
+       * @return {
+       *   Array<{label: string, url: string}>|
+       *   {label: string, url: string}
+       *  }
+       */
+      getFileWebLinks(repo, commit, file, opt_options) {
+        const params = {type: Gerrit.Nav.WeblinkType.FILE, repo, commit, file};
+        if (opt_options) {
+          params.options = opt_options;
+        }
+        return [].concat(this._generateWeblinks(params));
+      },
+
+      /**
+       * @param {string} repo
+       * @param {string} commit
+       * @param {Object=} opt_options
+       * @return {{label: string, url: string}}
+       */
+      getPatchSetWeblink(repo, commit, opt_options) {
+        const params = {type: Gerrit.Nav.WeblinkType.PATCHSET, repo, commit};
+        if (opt_options) {
+          params.options = opt_options;
+        }
+        const result = this._generateWeblinks(params);
+        if (Array.isArray(result)) {
+          return result.pop();
+        } else {
+          return result;
+        }
+      },
+
+      /**
+       * @param {string} repo
+       * @param {string} commit
+       * @param {Object=} opt_options
+       * @return {
+       *   Array<{label: string, url: string}>|
+       *   {label: string, url: string}
+       *  }
+       */
+      getChangeWeblinks(repo, commit, opt_options) {
+        const params = {type: Gerrit.Nav.WeblinkType.CHANGE, repo, commit};
+        if (opt_options) {
+          params.options = opt_options;
+        }
+        return [].concat(this._generateWeblinks(params));
+      },
     };
   })(window);
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index ca35bcc..dc264f2 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -16,9 +16,13 @@
 
   const RoutePattern = {
     ROOT: '/',
-    DASHBOARD: '/dashboard/(.*)',
-    ADMIN_PLACEHOLDER: '/admin/(.*)',
-    AGREEMENTS: /^\/settings\/(agreements|new-agreement)/,
+
+    DASHBOARD: /^\/dashboard\/(.+)$/,
+    CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
+    PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
+
+    AGREEMENTS: /^\/settings\/agreements\/?/,
+    NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
     REGISTER: /^\/register(\/.*)?$/,
 
     // Pattern for login and logout URLs intended to be passed-through. May
@@ -52,31 +56,33 @@
     // Matches /admin/create-project
     LEGACY_CREATE_GROUP: /^\/admin\/create-group\/?$/,
 
-    // Matches /admin/projects/<project>
-    PROJECT: /^\/admin\/projects\/([^,]+)$/,
+    PROJECT_OLD: /^\/admin\/(projects)\/?(.+)?$/,
 
-    // Matches /admin/projects/<project>,commands.
-    PROJECT_COMMANDS: /^\/admin\/projects\/(.+),commands$/,
+    // Matches /admin/repos/<repo>
+    REPO: /^\/admin\/repos\/([^,]+)$/,
 
-    // Matches /admin/projects/<project>,access.
-    PROJECT_ACCESS: /^\/admin\/projects\/(.+),access$/,
+    // Matches /admin/repos/<repo>,commands.
+    REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
 
-    // Matches /admin/projects[,<offset>][/].
-    PROJECT_LIST_OFFSET: /^\/admin\/projects(,(\d+))?(\/)?$/,
-    PROJECT_LIST_FILTER: '/admin/projects/q/filter::filter',
-    PROJECT_LIST_FILTER_OFFSET: '/admin/projects/q/filter::filter,:offset',
+    // Matches /admin/repos/<repos>,access.
+    REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,
 
-    // Matches /admin/projects/<project>,branches[,<offset>].
-    BRANCH_LIST_OFFSET: /^\/admin\/projects\/(.+),branches(,(.+))?$/,
-    BRANCH_LIST_FILTER: '/admin/projects/:project,branches/q/filter::filter',
+    // Matches /admin/repos[,<offset>][/].
+    REPO_LIST_OFFSET: /^\/admin\/repos(,(\d+))?(\/)?$/,
+    REPO_LIST_FILTER: '/admin/repos/q/filter::filter',
+    REPO_LIST_FILTER_OFFSET: '/admin/repos/q/filter::filter,:offset',
+
+    // Matches /admin/repos/<repo>,branches[,<offset>].
+    BRANCH_LIST_OFFSET: /^\/admin\/repos\/(.+),branches(,(.+))?$/,
+    BRANCH_LIST_FILTER: '/admin/repos/:repo,branches/q/filter::filter',
     BRANCH_LIST_FILTER_OFFSET:
-        '/admin/projects/:project,branches/q/filter::filter,:offset',
+        '/admin/repos/:repo,branches/q/filter::filter,:offset',
 
-    // Matches /admin/projects/<project>,tags[,<offset>].
-    TAG_LIST_OFFSET: /^\/admin\/projects\/(.+),tags(,(.+))?$/,
-    TAG_LIST_FILTER: '/admin/projects/:project,tags/q/filter::filter',
+    // Matches /admin/repos/<repo>,tags[,<offset>].
+    TAG_LIST_OFFSET: /^\/admin\/repos\/(.+),tags(,(.+))?$/,
+    TAG_LIST_FILTER: '/admin/repos/:repo,tags/q/filter::filter',
     TAG_LIST_FILTER_OFFSET:
-        '/admin/projects/:project,tags/q/filter::filter,:offset',
+        '/admin/repos/:repo,tags/q/filter::filter,:offset',
 
     PLUGINS: /^\/plugins\/(.+)$/,
 
@@ -87,8 +93,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+))?$/,
 
     /**
      * Support vestigial params from GWT UI.
@@ -128,6 +133,8 @@
     // Matches /c/<changeNum>/ /<URL tail>
     // Catches improperly encoded URLs (context: Issue 7100)
     IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/\ \/(.+)$/,
+
+    PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
   };
 
   /**
@@ -140,6 +147,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 = /^\?*/;
+
+  /**
    * GWT UI would use @\d+ at the end of a path to indicate linenum.
    */
   const LEGACY_LINENUM_PATTERN = /@([ab]?\d+)$/;
@@ -198,71 +215,28 @@
       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';
-        }
-      } else if (params.view === Gerrit.Nav.View.SETTINGS) {
+      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 if (params.view === Views.GROUP) {
+        url = this._generateGroupUrl(params);
+      } else if (params.view === Views.REPO) {
+        url = this._generateRepoUrl(params);
+      } else if (params.view === Views.SETTINGS) {
         url = this._generateSettingsUrl(params);
       } else {
         throw new Error('Can\'t generate');
@@ -271,6 +245,226 @@
       return base + url;
     },
 
+    _generateWeblinks(params) {
+      const type = params.type;
+      switch (type) {
+        case Gerrit.Nav.WeblinkType.FILE:
+          return this._getFileWebLinks(params);
+        case Gerrit.Nav.WeblinkType.CHANGE:
+          return this._getChangeWeblinks(params);
+        case Gerrit.Nav.WeblinkType.PATCHSET:
+          return this._getPatchSetWeblink(params);
+        default:
+          console.warn(`Unsupported weblink ${type}!`);
+      }
+    },
+
+    _getPatchSetWeblink(params) {
+      const {repo, commit, options} = params;
+      const {weblinks, config} = options || {};
+      const name = commit && commit.slice(0, 7);
+      const gitwebConfigUrl = this._configBasedCommitUrl(repo, commit, config);
+      if (gitwebConfigUrl) {
+        return {
+          name,
+          url: gitwebConfigUrl,
+        };
+      }
+      const url = this._getSupportedWeblinkUrl(weblinks);
+      if (!url) {
+        return {name};
+      } else {
+        return {name, url};
+      }
+    },
+
+    _configBasedCommitUrl(repo, commit, config) {
+      if (config && config.gitweb && config.gitweb.url &&
+          config.gitweb.type && config.gitweb.type.revision) {
+        return config.gitweb.url + config.gitweb.type.revision
+            .replace('${project}', repo)
+            .replace('${commit}', commit);
+      }
+    },
+
+    _isDirectCommit(link) {
+      // This is a whitelist of web link types that provide direct links to
+      // the commit in the url property.
+      return link.name === 'gitiles' || link.name === 'gitweb';
+    },
+
+    _getSupportedWeblinkUrl(weblinks) {
+      if (!weblinks) { return null; }
+      const weblink = weblinks.find(this._isDirectCommit);
+      if (!weblink) { return null; }
+      const url = weblink.url;
+      if (url.startsWith('https:') || url.startsWith('http:')) {
+        return url;
+      } else {
+        return `../../${url}`;
+      }
+    },
+
+    _getChangeWeblinks({repo, commit, options: {weblinks}}) {
+      if (!weblinks || !weblinks.length) return [];
+      return weblinks.filter(weblink => !this._isDirectCommit(weblink)).map(
+          ({name, url}) => {
+            if (url.startsWith('https:') || url.startsWith('http:')) {
+              return {name, url};
+            } else {
+              return {
+                name,
+                url: `../../${url}`,
+              };
+            }
+          });
+    },
+
+    _getFileWebLinks({repo, commit, file, options: {weblinks}}) {
+      return weblinks;
+    },
+
+    /**
+     * @param {!Object} params
+     * @return {string}
+     */
+    _generateSearchUrl(params) {
+      if (params.query) {
+        return '/q/' + this.encodeURL(params.query, true);
+      }
+
+      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 if (params.project) {
+        // Project dashboard.
+        return `/p/${params.project}/+/dashboard/${params.dashboard}`;
+      } 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}`;
+      }
+    },
+
+    /**
+     * @param {!Object} params
+     * @return {string}
+     */
+    _generateGroupUrl(params) {
+      let url = `/admin/groups/${this.encodeURL(params.groupId + '', true)}`;
+      if (params.detail === Gerrit.Nav.GroupDetailView.MEMBERS) {
+        url += ',members';
+      } else if (params.detail === Gerrit.Nav.GroupDetailView.LOG) {
+        url += ',audit-log';
+      }
+      return url;
+    },
+
+    /**
+     * @param {!Object} params
+     * @return {string}
+     */
+    _generateRepoUrl(params) {
+      let url = `/admin/repos/${this.encodeURL(params.repoName + '', true)}`;
+      if (params.detail === Gerrit.Nav.RepoDetailView.ACCESS) {
+        url += ',access';
+      } else if (params.detail === Gerrit.Nav.RepoDetailView.BRANCHES) {
+        url += ',branches';
+      } else if (params.detail === Gerrit.Nav.RepoDetailView.TAGS) {
+        url += ',tags';
+      } else if (params.detail === Gerrit.Nav.RepoDetailView.COMMANDS) {
+        url += ',commands';
+      }
+      return url;
+    },
+
     /**
      * @param {!Object} params
      * @return {string}
@@ -330,14 +524,8 @@
 
       // Diffing a patch against itself is invalid, so if the base and revision
       // patches are equal clear the base.
-      // NOTE: while selecting numbered parents of a merge is not yet
-      // implemented, normalize parent base patches to be un-selected parents in
-      // the same way.
-      // TODO(issue 4760): Remove the isMergeParent check when PG supports
-      // diffing against numbered parents of a merge.
       if (hasBasePatchNum &&
-          (this.patchNumEquals(params.basePatchNum, params.patchNum) ||
-              this.isMergeParent(params.basePatchNum))) {
+          this.patchNumEquals(params.basePatchNum, params.patchNum)) {
         needsRedirect = true;
         params.basePatchNum = null;
       } else if (hasBasePatchNum && !hasPatchNum) {
@@ -448,13 +636,24 @@
 
       const reporting = getReporting();
 
-      Gerrit.Nav.setup(url => { page.show(url); },
-          this._generateUrl.bind(this));
+      Gerrit.Nav.setup(
+          url => { page.show(url); },
+          this._generateUrl.bind(this),
+          params => this._generateWeblinks(params)
+      );
 
       // Middleware
       page((ctx, next) => {
         document.body.scrollTop = 0;
 
+        if (ctx.hash.match(RoutePattern.PLUGIN_SCREEN)) {
+          // Redirect all urls using hash #/x/plugin/screen to /x/plugin/screen
+          // This is needed to allow plugins to add basic #/x/ screen links to
+          // any location.
+          this._redirect(ctx.hash);
+          return;
+        }
+
         // Fire asynchronously so that the URL is changed by the time the event
         // is processed.
         this.async(() => {
@@ -471,6 +670,12 @@
 
       this._mapRoute(RoutePattern.DASHBOARD, '_handleDashboardRoute');
 
+      this._mapRoute(RoutePattern.CUSTOM_DASHBOARD,
+          '_handleCustomDashboardRoute');
+
+      this._mapRoute(RoutePattern.PROJECT_DASHBOARD,
+          '_handleProjectDashboardRoute');
+
       this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);
 
       this._mapRoute(RoutePattern.GROUP_AUDIT_LOG, '_handleGroupAuditLogRoute',
@@ -490,11 +695,14 @@
 
       this._mapRoute(RoutePattern.GROUP, '_handleGroupRoute', true);
 
-      this._mapRoute(RoutePattern.PROJECT_COMMANDS,
-          '_handleProjectCommandsRoute', true);
+      this._mapRoute(RoutePattern.PROJECT_OLD,
+          '_handleProjectsOldRoute');
 
-      this._mapRoute(RoutePattern.PROJECT_ACCESS,
-          '_handleProjectAccessRoute');
+      this._mapRoute(RoutePattern.REPO_COMMANDS,
+          '_handleRepoCommandsRoute', true);
+
+      this._mapRoute(RoutePattern.REPO_ACCESS,
+          '_handleRepoAccessRoute');
 
       this._mapRoute(RoutePattern.BRANCH_LIST_OFFSET,
           '_handleBranchListOffsetRoute');
@@ -520,16 +728,16 @@
       this._mapRoute(RoutePattern.LEGACY_CREATE_PROJECT,
           '_handleCreateProjectRoute', true);
 
-      this._mapRoute(RoutePattern.PROJECT_LIST_OFFSET,
-          '_handleProjectListOffsetRoute');
+      this._mapRoute(RoutePattern.REPO_LIST_OFFSET,
+          '_handleRepoListOffsetRoute');
 
-      this._mapRoute(RoutePattern.PROJECT_LIST_FILTER_OFFSET,
-          '_handleProjectListFilterOffsetRoute');
+      this._mapRoute(RoutePattern.REPO_LIST_FILTER_OFFSET,
+          '_handleRepoListFilterOffsetRoute');
 
-      this._mapRoute(RoutePattern.PROJECT_LIST_FILTER,
-          '_handleProjectListFilterRoute');
+      this._mapRoute(RoutePattern.REPO_LIST_FILTER,
+          '_handleRepoListFilterRoute');
 
-      this._mapRoute(RoutePattern.PROJECT, '_handleProjectRoute');
+      this._mapRoute(RoutePattern.REPO, '_handleRepoRoute');
 
       this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');
 
@@ -547,10 +755,6 @@
       this._mapRoute(RoutePattern.QUERY_LEGACY_SUFFIX,
           '_handleQueryLegacySuffixRoute');
 
-      this._mapRoute(RoutePattern.ADMIN_PLACEHOLDER,
-          '_handleAdminPlaceholderRoute', true);
-
-      this._mapRoute(RoutePattern.QUERY_OFFSET, '_handleQueryRoute');
       this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
 
       this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');
@@ -568,6 +772,9 @@
 
       this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);
 
+      this._mapRoute(RoutePattern.NEW_AGREEMENTS, '_handleNewAgreementsRoute',
+          true);
+
       this._mapRoute(RoutePattern.SETTINGS_LEGACY,
           '_handleSettingsLegacyRoute', true);
 
@@ -580,6 +787,8 @@
       this._mapRoute(RoutePattern.IMPROPERLY_ENCODED_PLUS,
           '_handleImproperlyEncodedPlusRoute');
 
+      this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');
+
       // Note: this route should appear last so it only catches URLs unmatched
       // by other patterns.
       this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
@@ -628,19 +837,63 @@
       });
     },
 
-    _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, or project dashboards.
+     *
+     * @param {!Object} data The parsed route data.
+     */
+    _handleDashboardRoute(data) {
+      // 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({
@@ -651,24 +904,79 @@
       });
     },
 
+    /**
+     * Handle custom dashboard routes.
+     *
+     * @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).
+     */
+    _handleCustomDashboardRoute(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: 'self',
+          sections,
+          title,
+        });
+        return Promise.resolve();
+      }
+
+      // Redirect /dashboard/ -> /dashboard/self.
+      this._redirect('/dashboard/self');
+      return Promise.resolve();
+    },
+
+    _handleProjectDashboardRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.DASHBOARD,
+        project: data.params[0],
+        dashboard: decodeURIComponent(data.params[1]),
+      });
+    },
+
     _handleGroupInfoRoute(data) {
       this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
     },
 
+    _handleGroupRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.GROUP,
+        groupId: data.params[0],
+      });
+    },
+
     _handleGroupAuditLogRoute(data) {
       this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-group-audit-log',
-        detailType: 'audit-log',
+        view: Gerrit.Nav.View.GROUP,
+        detail: Gerrit.Nav.GroupDetailView.LOG,
         groupId: data.params[0],
       });
     },
 
     _handleGroupMembersRoute(data) {
       this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-group-members',
-        detailType: 'members',
+        view: Gerrit.Nav.View.GROUP,
+        detail: Gerrit.Nav.GroupDetailView.MEMBERS,
         groupId: data.params[0],
       });
     },
@@ -700,38 +1008,35 @@
       });
     },
 
-    _handleGroupRoute(data) {
+    _handleProjectsOldRoute(data) {
+      if (data.params[1]) {
+        this._redirect('/admin/repos/' + encodeURIComponent(data.params[1]));
+      } else {
+        this._redirect('/admin/repos');
+      }
+    },
+
+    _handleRepoCommandsRoute(data) {
       this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-group',
-        groupId: data.params[0],
+        view: Gerrit.Nav.View.REPO,
+        detail: Gerrit.Nav.RepoDetailView.COMMANDS,
+        repo: data.params[0],
       });
     },
 
-    _handleProjectCommandsRoute(data) {
+    _handleRepoAccessRoute(data) {
       this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-project-commands',
-        detailType: 'commands',
-        project: data.params[0],
-      });
-    },
-
-    _handleProjectAccessRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-project-access',
-        detailType: 'access',
-        project: data.params[0],
+        view: Gerrit.Nav.View.REPO,
+        detail: Gerrit.Nav.RepoDetailView.ACCESS,
+        repo: data.params[0],
       });
     },
 
     _handleBranchListOffsetRoute(data) {
       this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-project-detail-list',
-        detailType: 'branches',
-        project: data.params[0],
+        view: Gerrit.Nav.View.REPO,
+        detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+        repo: data.params[0],
         offset: data.params[2] || 0,
         filter: null,
       });
@@ -739,10 +1044,9 @@
 
     _handleBranchListFilterOffsetRoute(data) {
       this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-project-detail-list',
-        detailType: 'branches',
-        project: data.params.project,
+        view: Gerrit.Nav.View.REPO,
+        detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+        repo: data.params.repo,
         offset: data.params.offset,
         filter: data.params.filter,
       });
@@ -750,20 +1054,18 @@
 
     _handleBranchListFilterRoute(data) {
       this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-project-detail-list',
-        detailType: 'branches',
-        project: data.params.project,
+        view: Gerrit.Nav.View.REPO,
+        detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+        repo: data.params.repo,
         filter: data.params.filter || null,
       });
     },
 
     _handleTagListOffsetRoute(data) {
       this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-project-detail-list',
-        detailType: 'tags',
-        project: data.params[0],
+        view: Gerrit.Nav.View.REPO,
+        detail: Gerrit.Nav.RepoDetailView.TAGS,
+        repo: data.params[0],
         offset: data.params[2] || 0,
         filter: null,
       });
@@ -771,10 +1073,9 @@
 
     _handleTagListFilterOffsetRoute(data) {
       this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-project-detail-list',
-        detailType: 'tags',
-        project: data.params.project,
+        view: Gerrit.Nav.View.REPO,
+        detail: Gerrit.Nav.RepoDetailView.TAGS,
+        repo: data.params.repo,
         offset: data.params.offset,
         filter: data.params.filter,
       });
@@ -782,37 +1083,36 @@
 
     _handleTagListFilterRoute(data) {
       this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-project-detail-list',
-        detailType: 'tags',
-        project: data.params.project,
+        view: Gerrit.Nav.View.REPO,
+        detail: Gerrit.Nav.RepoDetailView.TAGS,
+        repo: data.params.repo,
         filter: data.params.filter || null,
       });
     },
 
-    _handleProjectListOffsetRoute(data) {
+    _handleRepoListOffsetRoute(data) {
       this._setParams({
         view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-project-list',
+        adminView: 'gr-repo-list',
         offset: data.params[1] || 0,
         filter: null,
         openCreateModal: data.hash === 'create',
       });
     },
 
-    _handleProjectListFilterOffsetRoute(data) {
+    _handleRepoListFilterOffsetRoute(data) {
       this._setParams({
         view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-project-list',
+        adminView: 'gr-repo-list',
         offset: data.params.offset,
         filter: data.params.filter,
       });
     },
 
-    _handleProjectListFilterRoute(data) {
+    _handleRepoListFilterRoute(data) {
       this._setParams({
         view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-project-list',
+        adminView: 'gr-repo-list',
         filter: data.params.filter || null,
       });
     },
@@ -829,11 +1129,10 @@
       this._redirect('/admin/groups#create');
     },
 
-    _handleProjectRoute(data) {
+    _handleRepoRoute(data) {
       this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        project: data.params[0],
-        adminView: 'gr-project',
+        view: Gerrit.Nav.View.REPO,
+        repo: data.params[0],
       });
     },
 
@@ -870,15 +1169,12 @@
       });
     },
 
-    _handleAdminPlaceholderRoute(data) {
-      data.params.view = Gerrit.Nav.View.ADMIN;
-      data.params.placeholder = true;
-      this._setParams(data.params);
-    },
-
     _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],
+      });
     },
 
     _handleQueryLegacySuffixRoute(ctx) {
@@ -920,6 +1216,7 @@
         basePatchNum: ctx.params[3],
         patchNum: ctx.params[5],
         view: Gerrit.Nav.View.CHANGE,
+        querystring: ctx.querystring,
       };
 
       this._normalizeLegacyRouteParams(params);
@@ -973,7 +1270,13 @@
       }
     },
 
+    // TODO fix this so it properly redirects
+    // to /settings#Agreements (Scrolls down)
     _handleAgreementsRoute(data) {
+      this._redirect('/settings/#Agreements');
+    },
+
+    _handleNewAgreementsRoute(data) {
       data.params.view = Gerrit.Nav.View.AGREEMENTS;
       this._setParams(data.params);
     },
@@ -1019,6 +1322,13 @@
       this._redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
     },
 
+    _handlePluginScreen(ctx) {
+      const view = Gerrit.Nav.View.PLUGIN_SCREEN;
+      const plugin = ctx.params[0];
+      const screen = ctx.params[1];
+      this._setParams({view, plugin, screen});
+    },
+
     /**
      * Catchall route for when no other route is matched.
      */
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 59d010a..7011c65 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
@@ -123,7 +123,6 @@
       actualDoesNotRequireAuth.sort();
 
       const shouldRequireAutoAuth = [
-        '_handleAdminPlaceholderRoute',
         '_handleAgreementsRoute',
         '_handleCreateGroupRoute',
         '_handleCreateProjectRoute',
@@ -135,11 +134,12 @@
         '_handleGroupListOffsetRoute',
         '_handleGroupMembersRoute',
         '_handleGroupRoute',
+        '_handleNewAgreementsRoute',
         '_handlePluginListFilterOffsetRoute',
         '_handlePluginListFilterRoute',
         '_handlePluginListOffsetRoute',
         '_handlePluginListRoute',
-        '_handleProjectCommandsRoute',
+        '_handleRepoCommandsRoute',
         '_handleSettingsLegacyRoute',
         '_handleSettingsRoute',
       ];
@@ -157,23 +157,27 @@
         '_handleLegacyLinenum',
         '_handleImproperlyEncodedPlusRoute',
         '_handlePassThroughRoute',
-        '_handleProjectAccessRoute',
-        '_handleProjectListFilterOffsetRoute',
-        '_handleProjectListFilterRoute',
-        '_handleProjectListOffsetRoute',
-        '_handleProjectRoute',
+        '_handleProjectDashboardRoute',
+        '_handleProjectsOldRoute',
+        '_handleRepoAccessRoute',
+        '_handleRepoListFilterOffsetRoute',
+        '_handleRepoListFilterRoute',
+        '_handleRepoListOffsetRoute',
+        '_handleRepoRoute',
         '_handleQueryLegacySuffixRoute',
         '_handleQueryRoute',
         '_handleRegisterRoute',
         '_handleTagListFilterOffsetRoute',
         '_handleTagListFilterRoute',
         '_handleTagListOffsetRoute',
+        '_handlePluginScreen',
       ];
 
       // Handler names that check authentication themselves, and thus don't need
       // it performed for them.
       const selfAuthenticatingHandlers = [
         '_handleDashboardRoute',
+        '_handleCustomDashboardRoute',
         '_handleRootRoute',
       ];
 
@@ -225,6 +229,10 @@
             '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
             'topic:"g%2525h"+status:op%2525en');
 
+        // The presence of the query param overrides other params.
+        params.query = 'foo$bar';
+        assert.equal(element._generateUrl(params), '/q/foo%2524bar');
+
         params = {
           view: Gerrit.Nav.View.SEARCH,
           statuses: ['a', 'b', 'c'],
@@ -239,13 +247,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', () => {
@@ -282,6 +305,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);
@@ -299,6 +333,89 @@
         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');
+        });
+
+        test('project dashboard', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+            project: 'gerrit/project',
+            dashboard: 'default:main',
+          };
+          assert.equal(
+              element._generateUrl(params),
+              '/p/gerrit/project/+/dashboard/default:main');
+        });
+      });
+
+      suite('groups', () => {
+        test('group info', () => {
+          const params = {
+            view: Gerrit.Nav.View.GROUP,
+            groupId: 1234,
+          };
+          assert.equal(element._generateUrl(params), '/admin/groups/1234');
+        });
+
+        test('group members', () => {
+          const params = {
+            view: Gerrit.Nav.View.GROUP,
+            groupId: 1234,
+            detail: 'members',
+          };
+          assert.equal(element._generateUrl(params),
+              '/admin/groups/1234,members');
+        });
+
+        test('group audit log', () => {
+          const params = {
+            view: Gerrit.Nav.View.GROUP,
+            groupId: 1234,
+            detail: 'log',
+          };
+          assert.equal(element._generateUrl(params),
+              '/admin/groups/1234,audit-log');
+        });
+      });
     });
 
     suite('param normalization', () => {
@@ -395,16 +512,6 @@
           assert.isNotOk(params.basePatchNum);
           assert.equal(params.patchNum, 'edit');
         });
-
-        // TODO(issue 4760): Remove when PG supports diffing against numbered
-        // parents of a merge.
-        test('range -n..m normalizes to m', () => {
-          const params = {basePatchNum: -2, patchNum: 4};
-          const needsRedirect = element._normalizePatchRangeParams(params);
-          assert.isTrue(needsRedirect);
-          assert.isNotOk(params.basePatchNum);
-          assert.equal(params.patchNum, 4);
-        });
       });
     });
 
@@ -425,15 +532,15 @@
         setParamsStub = sandbox.stub(element, '_setParams');
       });
 
-      test('_handleAdminPlaceholderRoute', () => {
-        element._handleAdminPlaceholderRoute({params: {}});
-        assert.equal(setParamsStub.lastCall.args[0].view,
-            Gerrit.Nav.View.ADMIN);
-        assert.isTrue(setParamsStub.lastCall.args[0].placeholder);
+      test('_handleAgreementsRoute', () => {
+        const data = {params: {}};
+        element._handleAgreementsRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
       });
 
-      test('_handleAgreementsRoute', () => {
-        element._handleAgreementsRoute({params: {}});
+      test('_handleNewAgreementsRoute', () => {
+        element._handleNewAgreementsRoute({params: {}});
         assert.isTrue(setParamsStub.calledOnce);
         assert.equal(setParamsStub.lastCall.args[0].view,
             Gerrit.Nav.View.AGREEMENTS);
@@ -480,6 +587,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',
+        });
+      });
+
       test('_handleQueryLegacySuffixRoute', () => {
         element._handleQueryLegacySuffixRoute({path: '/q/foo+bar,n,z'});
         assert.isTrue(redirectStub.calledOnce);
@@ -522,7 +645,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 = {
@@ -632,36 +755,22 @@
           redirectToLoginStub = sandbox.stub(element, '_redirectToLogin');
         });
 
-        test('no user specified', () => {
-          const data = {canonicalPath: '/dashboard', params: {}};
-          const result = element._handleDashboardRoute(data);
-          assert.isNotOk(result);
-          assert.isFalse(setParamsStub.called);
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isTrue(redirectStub.called);
-          assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
-        });
-
-        test('own dahsboard but signed out redirects to login', () => {
+        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);
@@ -669,13 +778,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);
@@ -687,6 +794,60 @@
         });
       });
 
+      suite('_handleCustomDashboardRoute', () => {
+        let redirectToLoginStub;
+
+        setup(() => {
+          redirectToLoginStub = sandbox.stub(element, '_redirectToLogin');
+        });
+
+        test('no user specified', () => {
+          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+          return element._handleCustomDashboardRoute(data, '').then(() => {
+            assert.isFalse(setParamsStub.called);
+            assert.isTrue(redirectStub.called);
+            assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
+          });
+        });
+
+        test('custom dashboard without title', () => {
+          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+          return element._handleCustomDashboardRoute(data, '?a=b&c&d=e')
+              .then(() => {
+                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._handleCustomDashboardRoute(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', () => {
         test('_handleGroupInfoRoute', () => {
           const data = {params: {0: 1234}};
@@ -698,9 +859,8 @@
         test('_handleGroupAuditLogRoute', () => {
           const data = {params: {0: 1234}};
           assertDataToParams(data, '_handleGroupAuditLogRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-group-audit-log',
-            detailType: 'audit-log',
+            view: Gerrit.Nav.View.GROUP,
+            detail: 'log',
             groupId: 1234,
           });
         });
@@ -708,9 +868,8 @@
         test('_handleGroupMembersRoute', () => {
           const data = {params: {0: 1234}};
           assertDataToParams(data, '_handleGroupMembersRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-group-members',
-            detailType: 'members',
+            view: Gerrit.Nav.View.GROUP,
+            detail: 'members',
             groupId: 1234,
           });
         });
@@ -766,40 +925,36 @@
         test('_handleGroupRoute', () => {
           const data = {params: {0: 4321}};
           assertDataToParams(data, '_handleGroupRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-group',
+            view: Gerrit.Nav.View.GROUP,
             groupId: 4321,
           });
         });
       });
 
-      suite('project routes', () => {
-        test('_handleProjectRoute', () => {
+      suite('repo routes', () => {
+        test('_handleRepoRoute', () => {
           const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleProjectRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-project',
-            project: 4321,
+          assertDataToParams(data, '_handleRepoRoute', {
+            view: Gerrit.Nav.View.REPO,
+            repo: 4321,
           });
         });
 
-        test('_handleProjectCommandsRoute', () => {
+        test('_handleRepoCommandsRoute', () => {
           const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleProjectCommandsRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-project-commands',
-            detailType: 'commands',
-            project: 4321,
+          assertDataToParams(data, '_handleRepoCommandsRoute', {
+            view: Gerrit.Nav.View.REPO,
+            detail: Gerrit.Nav.RepoDetailView.COMMANDS,
+            repo: 4321,
           });
         });
 
-        test('_handleProjectAccessRoute', () => {
+        test('_handleRepoAccessRoute', () => {
           const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleProjectAccessRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-project-access',
-            detailType: 'access',
-            project: 4321,
+          assertDataToParams(data, '_handleRepoAccessRoute', {
+            view: Gerrit.Nav.View.REPO,
+            detail: Gerrit.Nav.RepoDetailView.ACCESS,
+            repo: 4321,
           });
         });
 
@@ -807,44 +962,40 @@
           test('_handleBranchListOffsetRoute', () => {
             const data = {params: {0: 4321}};
             assertDataToParams(data, '_handleBranchListOffsetRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-project-detail-list',
-              detailType: 'branches',
-              project: 4321,
+              view: Gerrit.Nav.View.REPO,
+              detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+              repo: 4321,
               offset: 0,
               filter: null,
             });
 
             data.params[2] = 42;
             assertDataToParams(data, '_handleBranchListOffsetRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-project-detail-list',
-              detailType: 'branches',
-              project: 4321,
+              view: Gerrit.Nav.View.REPO,
+              detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+              repo: 4321,
               offset: 42,
               filter: null,
             });
           });
 
           test('_handleBranchListFilterOffsetRoute', () => {
-            const data = {params: {project: 4321, filter: 'foo', offset: 42}};
+            const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
             assertDataToParams(data, '_handleBranchListFilterOffsetRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-project-detail-list',
-              detailType: 'branches',
-              project: 4321,
+              view: Gerrit.Nav.View.REPO,
+              detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+              repo: 4321,
               offset: 42,
               filter: 'foo',
             });
           });
 
           test('_handleBranchListFilterRoute', () => {
-            const data = {params: {project: 4321, filter: 'foo'}};
+            const data = {params: {repo: 4321, filter: 'foo'}};
             assertDataToParams(data, '_handleBranchListFilterRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-project-detail-list',
-              detailType: 'branches',
-              project: 4321,
+              view: Gerrit.Nav.View.REPO,
+              detail: Gerrit.Nav.RepoDetailView.BRANCHES,
+              repo: 4321,
               filter: 'foo',
             });
           });
@@ -854,100 +1005,96 @@
           test('_handleTagListOffsetRoute', () => {
             const data = {params: {0: 4321}};
             assertDataToParams(data, '_handleTagListOffsetRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-project-detail-list',
-              detailType: 'tags',
-              project: 4321,
+              view: Gerrit.Nav.View.REPO,
+              detail: Gerrit.Nav.RepoDetailView.TAGS,
+              repo: 4321,
               offset: 0,
               filter: null,
             });
           });
 
           test('_handleTagListFilterOffsetRoute', () => {
-            const data = {params: {project: 4321, filter: 'foo', offset: 42}};
+            const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
             assertDataToParams(data, '_handleTagListFilterOffsetRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-project-detail-list',
-              detailType: 'tags',
-              project: 4321,
+              view: Gerrit.Nav.View.REPO,
+              detail: Gerrit.Nav.RepoDetailView.TAGS,
+              repo: 4321,
               offset: 42,
               filter: 'foo',
             });
           });
 
           test('_handleTagListFilterRoute', () => {
-            const data = {params: {project: 4321}};
+            const data = {params: {repo: 4321}};
             assertDataToParams(data, '_handleTagListFilterRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-project-detail-list',
-              detailType: 'tags',
-              project: 4321,
+              view: Gerrit.Nav.View.REPO,
+              detail: Gerrit.Nav.RepoDetailView.TAGS,
+              repo: 4321,
               filter: null,
             });
 
             data.params.filter = 'foo';
             assertDataToParams(data, '_handleTagListFilterRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-project-detail-list',
-              detailType: 'tags',
-              project: 4321,
+              view: Gerrit.Nav.View.REPO,
+              detail: Gerrit.Nav.RepoDetailView.TAGS,
+              repo: 4321,
               filter: 'foo',
             });
           });
         });
 
-        suite('project list routes', () => {
-          test('_handleProjectListOffsetRoute', () => {
+        suite('repo list routes', () => {
+          test('_handleRepoListOffsetRoute', () => {
             const data = {params: {}};
-            assertDataToParams(data, '_handleProjectListOffsetRoute', {
+            assertDataToParams(data, '_handleRepoListOffsetRoute', {
               view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-project-list',
+              adminView: 'gr-repo-list',
               offset: 0,
               filter: null,
               openCreateModal: false,
             });
 
             data.params[1] = 42;
-            assertDataToParams(data, '_handleProjectListOffsetRoute', {
+            assertDataToParams(data, '_handleRepoListOffsetRoute', {
               view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-project-list',
+              adminView: 'gr-repo-list',
               offset: 42,
               filter: null,
               openCreateModal: false,
             });
 
             data.hash = 'create';
-            assertDataToParams(data, '_handleProjectListOffsetRoute', {
+            assertDataToParams(data, '_handleRepoListOffsetRoute', {
               view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-project-list',
+              adminView: 'gr-repo-list',
               offset: 42,
               filter: null,
               openCreateModal: true,
             });
           });
 
-          test('_handleProjectListFilterOffsetRoute', () => {
+          test('_handleRepoListFilterOffsetRoute', () => {
             const data = {params: {filter: 'foo', offset: 42}};
-            assertDataToParams(data, '_handleProjectListFilterOffsetRoute', {
+            assertDataToParams(data, '_handleRepoListFilterOffsetRoute', {
               view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-project-list',
+              adminView: 'gr-repo-list',
               offset: 42,
               filter: 'foo',
             });
           });
 
-          test('_handleProjectListFilterRoute', () => {
+          test('_handleRepoListFilterRoute', () => {
             const data = {params: {}};
-            assertDataToParams(data, '_handleProjectListFilterRoute', {
+            assertDataToParams(data, '_handleRepoListFilterRoute', {
               view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-project-list',
+              adminView: 'gr-repo-list',
               filter: null,
             });
 
             data.params.filter = 'foo';
-            assertDataToParams(data, '_handleProjectListFilterRoute', {
+            assertDataToParams(data, '_handleRepoListFilterRoute', {
               view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-project-list',
+              adminView: 'gr-repo-list',
               filter: 'foo',
             });
           });
@@ -1028,6 +1175,7 @@
               null, // 4 Unused
               9, // 5 Patch number
             ],
+            querystring: '',
           };
           element._handleChangeLegacyRoute(ctx);
           assert.isTrue(normalizeRouteStub.calledOnce);
@@ -1036,6 +1184,7 @@
             basePatchNum: 6,
             patchNum: 9,
             view: Gerrit.Nav.View.CHANGE,
+            querystring: '',
           });
         });
 
@@ -1182,6 +1331,42 @@
           assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
         });
       });
+
+      test('_handlePluginScreen', () => {
+        const ctx = {params: ['foo', 'bar']};
+        assertDataToParams(ctx, '_handlePluginScreen', {
+          view: Gerrit.Nav.View.PLUGIN_SCREEN,
+          plugin: 'foo',
+          screen: 'bar',
+        });
+        assert.isFalse(redirectStub.called);
+      });
+    });
+
+    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/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index 553eacf..a6f27489 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -191,14 +191,9 @@
           MAX_AUTOCOMPLETE_RESULTS)
           .then(accounts => {
             if (!accounts) { return []; }
-            return accounts.map(acct => {
-              if (acct.email) {
-                return predicate + ':"' + this._accountOrAnon(acct) +
-                    ' <' + acct.email + '>"';
-              } else {
-                return predicate + ':"' + this._accountOrAnon(acct) + '"';
-              }
-            });
+            return accounts.map(acct => acct.email ?
+              `${predicate}:${acct.email}` :
+              `${predicate}:"${this._accountOrAnon(acct)}"`);
           }).then(accounts => {
             // When the expression supplied is a beginning substring of 'self',
             // add it as an autocomplete option.
@@ -337,8 +332,9 @@
     },
 
     _handleForwardSlashKey(e) {
+      const keyboardEvent = this.getKeyboardEvent(e);
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+          (this.modifierPressed(e) && !keyboardEvent.shiftKey)) { return; }
 
       e.preventDefault();
       this.$.searchInput.focus();
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
index 8c889e9..9551c79 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
@@ -105,7 +105,7 @@
     });
 
     suite('_getSearchSuggestions', () => {
-      test('Autocompletes accounts', done => {
+      test('Autocompletes accounts', () => {
         sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
           Promise.resolve([
             {
@@ -114,9 +114,8 @@
             },
           ])
         );
-        element._getSearchSuggestions('owner:fr').then(s => {
-          assert.equal(s[0].value, 'owner:"fred <fred@goog.co>"');
-          done();
+        return element._getSearchSuggestions('owner:fr').then(s => {
+          assert.equal(s[0].value, 'owner:fred@goog.co');
         });
       });
 
@@ -257,7 +256,7 @@
           ])
         );
         element._getSearchSuggestions('owner:fr').then(s => {
-          assert.equal(s[0].value, 'owner:"Anonymous <fred@goog.co>"');
+          assert.equal(s[0].value, 'owner:fred@goog.co');
           done();
         });
       });
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
new file mode 100644
index 0000000..3e554c6
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
@@ -0,0 +1,47 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'comment-api-mock',
+
+    properties: {
+      _changeComments: Object,
+    },
+
+    loadComments() {
+      return this._reloadComments();
+    },
+
+    /**
+     * For the purposes of the mock, _reloadDrafts is not included because its
+     * response is the same type as reloadComments, just makes less API
+     * requests. Since this is for test purposes/mocked data anyway, keep this
+     * file simpler by just using _reloadComments here instead.
+     */
+    _reloadDraftsWithCallback(e) {
+      return this._reloadComments().then(() => {
+        return e.detail.resolve();
+      });
+    },
+
+    _reloadComments() {
+      return this.$.commentAPI.loadAll(this._changeNum)
+          .then(comments => {
+            this._changeComments = this.$.commentAPI._changeComments;
+          });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
index ef39e1f..209e8c9 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
@@ -16,18 +16,349 @@
 
   const PARENT = 'PARENT';
 
+  const Defs = {};
+
+  /**
+   * @typedef {{
+   *    basePatchNum: (string|number),
+   *    patchNum: (number),
+   * }}
+   */
+  Defs.patchRange;
+
+  /**
+   * @typedef {{
+   *    changeNum: number,
+   *    path: string,
+   *    patchRange: !Defs.patchRange,
+   *    projectConfig: (Object|undefined),
+   * }}
+   */
+  Defs.commentMeta;
+
+  /**
+   * @typedef {{
+   *    meta: !Defs.commentMeta,
+   *    left: !Array,
+   *    right: !Array,
+   * }}
+   */
+  Defs.commentsBySide;
+
+  /**
+   * Construct a change comments object, which can be data-bound to child
+   * elements of that which uses the gr-comment-api.
+   *
+   * @param {!Object} comments
+   * @param {!Object} robotComments
+   * @param {!Object} drafts
+   * @param {number} changeNum
+   * @constructor
+   */
+  function ChangeComments(comments, robotComments, drafts, changeNum) {
+    this._comments = comments;
+    this._robotComments = robotComments;
+    this._drafts = drafts;
+    this._changeNum = changeNum;
+  }
+
+  ChangeComments.prototype = {
+    get comments() {
+      return this._comments;
+    },
+    get drafts() {
+      return this._drafts;
+    },
+    get robotComments() {
+      return this._robotComments;
+    },
+  };
+
+  ChangeComments.prototype._patchNumEquals =
+      Gerrit.PatchSetBehavior.patchNumEquals;
+  ChangeComments.prototype._isMergeParent =
+      Gerrit.PatchSetBehavior.isMergeParent;
+  ChangeComments.prototype._getParentIndex =
+      Gerrit.PatchSetBehavior.getParentIndex;
+
+  /**
+   * Get an object mapping file paths to a boolean representing whether that
+   * path contains diff comments in the given patch set (including drafts and
+   * robot comments).
+   *
+   * Paths with comments are mapped to true, whereas paths without comments
+   * are not mapped.
+   *
+   * @param {Defs.patchRange=} opt_patchRange The patch-range object containing
+   *     patchNum and basePatchNum properties to represent the range.
+   * @return {!Object}
+   */
+  ChangeComments.prototype.getPaths = function(opt_patchRange) {
+    const responses = [this.comments, this.drafts, this.robotComments];
+    const commentMap = {};
+    for (const response of responses) {
+      for (const path in response) {
+        if (response.hasOwnProperty(path) &&
+            response[path].some(c => {
+              // If don't care about patch range, we know that the path exists.
+              if (!opt_patchRange) { return true; }
+              return this._isInPatchRange(c, opt_patchRange);
+            })) {
+          commentMap[path] = true;
+        }
+      }
+    }
+    return commentMap;
+  };
+
+  /**
+   * Gets all the comments and robot comments for the given change.
+   *
+   * @param {number=} opt_patchNum
+   * @return {!Object}
+   */
+  ChangeComments.prototype.getAllPublishedComments = function(opt_patchNum) {
+    const paths = this.getPaths();
+    const publishedComments = {};
+    for (const path of Object.keys(paths)) {
+      publishedComments[path] = this.getAllCommentsForPath(path, opt_patchNum);
+    }
+    return publishedComments;
+  };
+
+  /**
+   * Gets all the comments and robot comments for the given change.
+   *
+   * @param {number=} opt_patchNum
+   * @return {!Object}
+   */
+  ChangeComments.prototype.getAllDrafts = function(opt_patchNum) {
+    const paths = this.getPaths();
+    const drafts = {};
+    for (const path of Object.keys(paths)) {
+      drafts[path] = this.getAllDraftsForPath(path, opt_patchNum);
+    }
+    return drafts;
+  };
+
+  /**
+   * Get the comments (robot comments) for a path and optional patch num.
+   *
+   * @param {!string} path
+   * @param {number=} opt_patchNum
+   * @return {!Array}
+   */
+  ChangeComments.prototype.getAllCommentsForPath = function(path,
+      opt_patchNum) {
+    const comments = this._comments[path] || [];
+    const robotComments = this._robotComments[path] || [];
+    const allComments = comments.concat(robotComments);
+    if (!opt_patchNum) { return allComments; }
+    return (allComments || []).filter(c =>
+      this._patchNumEquals(c.patch_set, opt_patchNum)
+    );
+  };
+
+  /**
+   * Get the drafts for a path and optional patch num.
+   *
+   * @param {!string} path
+   * @param {number=} opt_patchNum
+   * @return {!Array}
+   */
+  ChangeComments.prototype.getAllDraftsForPath = function(path,
+      opt_patchNum) {
+    const comments = this._drafts[path] || [];
+    if (!opt_patchNum) { return comments; }
+    return (comments || []).filter(c =>
+      this._patchNumEquals(c.patch_set, opt_patchNum)
+    );
+  };
+
+  /**
+   * Get the comments (with drafts and robot comments) for a path and
+   * patch-range. Returns an object with left and right properties mapping to
+   * arrays of comments in on either side of the patch range for that path.
+   *
+   * @param {!string} path
+   * @param {!Defs.patchRange} patchRange The patch-range object containing patchNum
+   *     and basePatchNum properties to represent the range.
+   * @param {Object=} opt_projectConfig Optional project config object to
+   *     include in the meta sub-object.
+   * @return {!Defs.commentsBySide}
+   */
+  ChangeComments.prototype.getCommentsBySideForPath = function(path,
+      patchRange, opt_projectConfig) {
+    const comments = this.comments[path] || [];
+    const drafts = this.drafts[path] || [];
+    const robotComments = this.robotComments[path] || [];
+
+    drafts.forEach(d => { d.__draft = true; });
+
+    const all = comments.concat(drafts).concat(robotComments);
+
+    const baseComments = all.filter(c =>
+        this._isInBaseOfPatchRange(c, patchRange));
+    const revisionComments = all.filter(c =>
+        this._isInRevisionOfPatchRange(c, patchRange));
+
+    return {
+      meta: {
+        changeNum: this._changeNum,
+        path,
+        patchRange,
+        projectConfig: opt_projectConfig,
+      },
+      left: baseComments,
+      right: revisionComments,
+    };
+  };
+
+  ChangeComments.prototype._commentObjToArray = function(comments) {
+    let commentArr = [];
+    for (const file of Object.keys(comments)) {
+      commentArr = commentArr.concat(comments[file]);
+    }
+    return commentArr;
+  };
+
+  /**
+   * Computes a string counting the number of commens in a given file and path.
+   *
+   * @param {number} patchNum
+   * @param {string=} opt_path
+   * @return {number}
+   */
+  ChangeComments.prototype.computeCommentCount = function(patchNum, opt_path) {
+    if (opt_path) {
+      return this.getAllCommentsForPath(opt_path, patchNum).length;
+    }
+    const allComments = this.getAllPublishedComments(patchNum);
+    return this._commentObjToArray(allComments).length;
+  };
+
+  /**
+   * Computes a string counting the number of commens in a given file and path.
+   *
+   * @param {number} patchNum
+   * @param {string=} opt_path
+   * @return {number}
+   */
+  ChangeComments.prototype.computeDraftCount = function(patchNum, opt_path) {
+    if (opt_path) {
+      return this.getAllDraftsForPath(opt_path, patchNum).length;
+    }
+    const allComments = this.getAllDrafts(patchNum);
+    return this._commentObjToArray(allComments).length;
+  };
+
+  /**
+   * Computes a number of unresolved comment threads in a given file and path.
+   *
+   * @param {number} patchNum
+   * @param {string=} opt_path
+   * @return {number}
+   */
+  ChangeComments.prototype.computeUnresolvedNum = function(patchNum,
+      opt_path) {
+    let comments = [];
+    let drafts = [];
+
+    if (opt_path) {
+      comments = this.getAllCommentsForPath(opt_path, patchNum);
+      drafts = this.getAllDraftsForPath(opt_path, patchNum);
+    } else {
+      comments = this._commentObjToArray(
+          this.getAllPublishedComments(patchNum));
+    }
+
+    comments = comments.concat(drafts);
+
+    // Create an object where every comment ID is the key of an unresolved
+    // comment.
+    const idMap = comments.reduce((acc, comment) => {
+      if (comment.unresolved) {
+        acc[comment.id] = true;
+      }
+      return acc;
+    }, {});
+
+    // Set false for the comments that are marked as parents.
+    for (const comment of comments) {
+      idMap[comment.in_reply_to] = false;
+    }
+
+    // The unresolved comments are the comments that still have true.
+    const unresolvedLeaves = Object.keys(idMap).filter(key => {
+      return idMap[key];
+    });
+    return unresolvedLeaves.length;
+  };
+
+  /**
+  * Whether the given comment should be included in the base side of the
+  * given patch range.
+  * @param {!Object} comment
+  * @param {!Defs.patchRange} range
+  * @return {boolean}
+  */
+  ChangeComments.prototype._isInBaseOfPatchRange = function(comment, range) {
+    // If the base of the patch range is a parent of a merge, and the comment
+    // appears on a specific parent then only show the comment if the parent
+    // index of the comment matches that of the range.
+    if (comment.parent && comment.side === PARENT) {
+      return this._isMergeParent(range.basePatchNum) &&
+          comment.parent === this._getParentIndex(range.basePatchNum);
+    }
+
+    // If the base of the range is the parent of the patch:
+    if (range.basePatchNum === PARENT &&
+        comment.side === PARENT &&
+        this._patchNumEquals(comment.patch_set, range.patchNum)) {
+      return true;
+    }
+    // If the base of the range is not the parent of the patch:
+    if (range.basePatchNum !== PARENT &&
+        comment.side !== PARENT &&
+        this._patchNumEquals(comment.patch_set, range.basePatchNum)) {
+      return true;
+    }
+    return false;
+  };
+
+  /**
+   * Whether the given comment should be included in the revision side of the
+   * given patch range.
+   * @param {!Object} comment
+   * @param {!Defs.patchRange} range
+   * @return {boolean}
+   */
+  ChangeComments.prototype._isInRevisionOfPatchRange = function(comment,
+      range) {
+    return comment.side !== PARENT &&
+        this._patchNumEquals(comment.patch_set, range.patchNum);
+  };
+
+  /**
+   * Whether the given comment should be included in the given patch range.
+   * @param {!Object} comment
+   * @param {!Defs.patchRange} range
+   * @return {boolean|undefined}
+   */
+  ChangeComments.prototype._isInPatchRange = function(comment, range) {
+    return this._isInBaseOfPatchRange(comment, range) ||
+        this._isInRevisionOfPatchRange(comment, range);
+  };
+
   Polymer({
     is: 'gr-comment-api',
 
     properties: {
-      /** @type {number} */
-      _changeNum: Number,
-      /** @type {!Object|undefined} */
-      _comments: Object,
-      /** @type {!Object|undefined} */
-      _drafts: Object,
-      /** @type {!Object|undefined} */
-      _robotComments: Object,
+      _changeComments: Object,
+    },
+
+    listeners: {
+      'reload-drafts': 'reloadDrafts',
     },
 
     behaviors: [
@@ -40,135 +371,39 @@
      * does not yield the comment data.
      *
      * @param {number} changeNum
-     * @return {!Promise}
+     * @return {!Promise<!Object>}
      */
     loadAll(changeNum) {
-      this._changeNum = changeNum;
-
-      // Reset comment arrays.
-      this._comments = undefined;
-      this._drafts = undefined;
-      this._robotComments = undefined;
-
       const promises = [];
-      promises.push(this.$.restAPI.getDiffComments(changeNum)
-          .then(comments => { this._comments = comments; }));
-      promises.push(this.$.restAPI.getDiffRobotComments(changeNum)
-          .then(robotComments => { this._robotComments = robotComments; }));
-      promises.push(this.$.restAPI.getDiffDrafts(changeNum)
-          .then(drafts => { this._drafts = drafts; }));
+      promises.push(this.$.restAPI.getDiffComments(changeNum));
+      promises.push(this.$.restAPI.getDiffRobotComments(changeNum));
+      promises.push(this.$.restAPI.getDiffDrafts(changeNum));
 
-      return Promise.all(promises);
+      return Promise.all(promises).then(([comments, robotComments, drafts]) => {
+        this._changeComments = new ChangeComments(comments,
+          robotComments, drafts, changeNum);
+        return this._changeComments;
+      });
     },
 
+
     /**
-     * Get an object mapping file paths to a boolean representing whether that
-     * path contains diff comments in the given patch set (including drafts and
-     * robot comments).
+     * Re-initialize _changeComments with a new ChangeComments object, that
+     * uses the previous values for comments and robot comments, but fetches
+     * updated draft comments.
      *
-     * Paths with comments are mapped to true, whereas paths without comments
-     * are not mapped.
-     *
-     * @param {!Object} patchRange The patch-range object containing patchNum
-     *     and basePatchNum properties to represent the range.
-     * @return {Object}
+     * @param {number} changeNum
+     * @return {!Promise<!Object>}
      */
-    getPaths(patchRange) {
-      const responses = [this._comments, this._drafts, this._robotComments];
-      const commentMap = {};
-      for (const response of responses) {
-        for (const path in response) {
-          if (response.hasOwnProperty(path) &&
-              response[path].some(c => this._isInPatchRange(c, patchRange))) {
-            commentMap[path] = true;
-          }
-        }
+    reloadDrafts(changeNum) {
+      if (!this._changeComments) {
+        return this.loadAll(changeNum);
       }
-      return commentMap;
-    },
-
-    /**
-     * Get the comments (with drafts and robot comments) for a path and
-     * patch-range. Returns an object with left and right properties mapping to
-     * arrays of comments in on either side of the patch range for that path.
-     *
-     * @param {!string} path
-     * @param {!Object} patchRange The patch-range object containing patchNum
-     *     and basePatchNum properties to represent the range.
-     * @param {Object=} opt_projectConfig Optional project config object to
-     *     include in the meta sub-object.
-     * @return {Object}
-     */
-    getCommentsForPath(path, patchRange, opt_projectConfig) {
-      const comments = this._comments[path] || [];
-      const drafts = this._drafts[path] || [];
-      const robotComments = this._robotComments[path] || [];
-
-      drafts.forEach(d => { d.__draft = true; });
-
-      const all = comments.concat(drafts).concat(robotComments);
-
-      const baseComments = all.filter(c =>
-          this._isInBaseOfPatchRange(c, patchRange));
-      const revisionComments = all.filter(c =>
-          this._isInRevisionOfPatchRange(c, patchRange));
-
-      return {
-        meta: {
-          changeNum: this._changeNum,
-          path,
-          patchRange,
-          projectConfig: opt_projectConfig,
-        },
-        left: baseComments,
-        right: revisionComments,
-      };
-    },
-
-    /**
-     * Whether the given comment should be included in the base side of the
-     * given patch range.
-     * @param {!Object} comment
-     * @param {!Object} range
-     * @return {boolean}
-     */
-    _isInBaseOfPatchRange(comment, range) {
-      // If the base of the range is the parent of the patch:
-      if (range.basePatchNum === PARENT &&
-          comment.side === PARENT &&
-          this.patchNumEquals(comment.patch_set, range.patchNum)) {
-        return true;
-      }
-      // If the base of the range is not the parent of the patch:
-      if (range.basePatchNum !== PARENT &&
-          comment.side !== PARENT &&
-          this.patchNumEquals(comment.patch_set, range.basePatchNum)) {
-        return true;
-      }
-      return false;
-    },
-
-    /**
-     * Whether the given comment should be included in the revision side of the
-     * given patch range.
-     * @param {!Object} comment
-     * @param {!Object} range
-     * @return {boolean}
-     */
-    _isInRevisionOfPatchRange(comment, range) {
-      return comment.side !== PARENT &&
-          this.patchNumEquals(comment.patch_set, range.patchNum);
-    },
-
-    /**
-     * Whether the given comment should be included in the given patch range.
-     * @param {!Object} comment
-     * @param {!Object} range
-     * @return {boolean|undefined}
-     */
-    _isInPatchRange(comment, range) {
-      return this._isInBaseOfPatchRange(comment, range) ||
-          this._isInRevisionOfPatchRange(comment, range);
+      return this.$.restAPI.getDiffDrafts(changeNum).then(drafts => {
+        this._changeComments = new ChangeComments(this._changeComments.comments,
+            this._changeComments.robotComments, drafts, changeNum);
+        return this._changeComments;
+      });
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
index 09403a4..b6e488f 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
@@ -67,9 +67,9 @@
             changeNum));
         assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
             changeNum));
-        assert.isOk(element._comments);
-        assert.isOk(element._robotComments);
-        assert.deepEqual(element._drafts, {});
+        assert.isOk(element._changeComments._comments);
+        assert.isOk(element._changeComments._robotComments);
+        assert.deepEqual(element._changeComments._drafts, {});
       });
     });
 
@@ -94,102 +94,275 @@
             changeNum));
         assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
             changeNum));
-        assert.isOk(element._comments);
-        assert.isOk(element._robotComments);
-        assert.notDeepEqual(element._drafts, {});
+        assert.isOk(element._changeComments._comments);
+        assert.isOk(element._changeComments._robotComments);
+        assert.notDeepEqual(element._changeComments._drafts, {});
       });
     });
 
-    test('_isInBaseOfPatchRange', () => {
-      const comment = {patch_set: 1};
-      const patchRange = {basePatchNum: 1, patchNum: 2};
-      assert.isTrue(element._isInBaseOfPatchRange(comment, patchRange));
-
-      patchRange.basePatchNum = PARENT;
-      assert.isFalse(element._isInBaseOfPatchRange(comment, patchRange));
-
-      comment.side = PARENT;
-      assert.isFalse(element._isInBaseOfPatchRange(comment, patchRange));
-
-      comment.patch_set = 2;
-      assert.isTrue(element._isInBaseOfPatchRange(comment, patchRange));
-    });
-
-    test('_isInRevisionOfPatchRange', () => {
-      const comment = {patch_set: 123};
-      const patchRange = {basePatchNum: 122, patchNum: 124};
-      assert.isFalse(element._isInRevisionOfPatchRange(comment, patchRange));
-
-      patchRange.patchNum = 123;
-      assert.isTrue(element._isInRevisionOfPatchRange(comment, patchRange));
-
-      comment.side = PARENT;
-      assert.isFalse(element._isInRevisionOfPatchRange(comment, patchRange));
-    });
-
-    suite('comment ranges and paths', () => {
+    suite('reloadDrafts', () => {
+      let commentStub;
+      let robotCommentStub;
+      let draftStub;
       setup(() => {
-        element._changeNum = 1234;
-        element._drafts = {};
-        element._robotComments = {};
-        element._comments = {
-          'file/one': [
-            {patch_set: 2, side: PARENT},
-            {patch_set: 2},
-          ],
-          'file/two': [
-            {patch_set: 2},
-            {patch_set: 3},
-          ],
-          'file/three': [
-            {patch_set: 2, side: PARENT},
-            {patch_set: 3},
-          ],
-        };
+        commentStub = sandbox.stub(element.$.restAPI, 'getDiffComments')
+          .returns(Promise.resolve({}));
+        robotCommentStub = sandbox.stub(element.$.restAPI,
+            'getDiffRobotComments').returns(Promise.resolve({}));
+        draftStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts')
+            .returns(Promise.resolve({}));
       });
 
-      test('getPaths', () => {
-        const patchRange = {basePatchNum: 1, patchNum: 4};
-        let paths = element.getPaths(patchRange);
-        assert.equal(Object.keys(paths).length, 0);
-
-        patchRange.basePatchNum = PARENT;
-        patchRange.patchNum = 3;
-        paths = element.getPaths(patchRange);
-        assert.notProperty(paths, 'file/one');
-        assert.property(paths, 'file/two');
-        assert.property(paths, 'file/three');
-
-        patchRange.patchNum = 2;
-        paths = element.getPaths(patchRange);
-        assert.property(paths, 'file/one');
-        assert.property(paths, 'file/two');
-        assert.property(paths, 'file/three');
+      test('without loadAll first', done => {
+        assert.isNotOk(element._changeComments);
+        sandbox.spy(element, 'loadAll');
+        element.reloadDrafts().then(() => {
+          assert.isTrue(element.loadAll.called);
+          assert.isOk(element._changeComments);
+          assert.equal(commentStub.callCount, 1);
+          assert.equal(robotCommentStub.callCount, 1);
+          assert.equal(draftStub.callCount, 1);
+          done();
+        });
       });
 
-      test('getCommentsForPath', () => {
-        const patchRange = {basePatchNum: 1, patchNum: 3};
-        let path = 'file/one';
-        let comments = element.getCommentsForPath(path, patchRange);
-        assert.equal(comments.meta.changeNum, 1234);
-        assert.equal(comments.left.length, 0);
-        assert.equal(comments.right.length, 0);
+      test('with loadAll first', done => {
+        assert.isNotOk(element._changeComments);
+        element.loadAll().then(() => {
+          assert.isOk(element._changeComments);
+          assert.equal(commentStub.callCount, 1);
+          assert.equal(robotCommentStub.callCount, 1);
+          assert.equal(draftStub.callCount, 1);
+          return element.reloadDrafts();
+        }).then(() => {
+          assert.isOk(element._changeComments);
+          assert.equal(commentStub.callCount, 1);
+          assert.equal(robotCommentStub.callCount, 1);
+          assert.equal(draftStub.callCount, 2);
+          done();
+        });
+      });
+    });
 
-        path = 'file/two';
-        comments = element.getCommentsForPath(path, patchRange);
-        assert.equal(comments.left.length, 0);
-        assert.equal(comments.right.length, 1);
+    suite('_changeComment methods', () => {
+      setup(done => {
+        const changeNum = 1234;
+        stub('gr-rest-api-interface', {
+          getDiffComments() { return Promise.resolve({}); },
+          getDiffRobotComments() { return Promise.resolve({}); },
+          getDiffDrafts() { return Promise.resolve({}); },
+        });
+        element.loadAll(changeNum).then(() => {
+          done();
+        });
+      });
 
-        patchRange.basePatchNum = 2;
-        comments = element.getCommentsForPath(path, patchRange);
-        assert.equal(comments.left.length, 1);
-        assert.equal(comments.right.length, 1);
+      test('_isInBaseOfPatchRange', () => {
+        const comment = {patch_set: 1};
+        const patchRange = {basePatchNum: 1, patchNum: 2};
+        assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+            patchRange));
 
         patchRange.basePatchNum = PARENT;
-        path = 'file/three';
-        comments = element.getCommentsForPath(path, patchRange);
-        assert.equal(comments.left.length, 0);
-        assert.equal(comments.right.length, 1);
+        assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+            patchRange));
+
+        comment.side = PARENT;
+        assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+            patchRange));
+
+        comment.patch_set = 2;
+        assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+            patchRange));
+
+        patchRange.basePatchNum = -2;
+        comment.side = PARENT;
+        comment.parent = 1;
+        assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+            patchRange));
+
+        comment.parent = 2;
+        assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+            patchRange));
+      });
+
+      test('_isInRevisionOfPatchRange', () => {
+        const comment = {patch_set: 123};
+        const patchRange = {basePatchNum: 122, patchNum: 124};
+        assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
+            comment, patchRange));
+
+        patchRange.patchNum = 123;
+        assert.isTrue(element._changeComments._isInRevisionOfPatchRange(
+            comment, patchRange));
+
+        comment.side = PARENT;
+        assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
+            comment, patchRange));
+      });
+
+      test('_isInPatchRange', () => {
+        const patchRange1 = {basePatchNum: 122, patchNum: 124};
+        const patchRange2 = {basePatchNum: 123, patchNum: 125};
+        const patchRange3 = {basePatchNum: 124, patchNum: 125};
+
+        const isInBasePatchStub = sandbox.stub(element._changeComments,
+            '_isInBaseOfPatchRange');
+        const isInRevisionPatchStub = sandbox.stub(element._changeComments,
+            '_isInRevisionOfPatchRange');
+
+        isInBasePatchStub.withArgs({}, patchRange1).returns(true);
+        isInBasePatchStub.withArgs({}, patchRange2).returns(false);
+        isInBasePatchStub.withArgs({}, patchRange3).returns(false);
+
+        isInRevisionPatchStub.withArgs({}, patchRange1).returns(false);
+        isInRevisionPatchStub.withArgs({}, patchRange2).returns(true);
+        isInRevisionPatchStub.withArgs({}, patchRange3).returns(false);
+
+        assert.isTrue(element._changeComments._isInPatchRange({}, patchRange1));
+        assert.isTrue(element._changeComments._isInPatchRange({}, patchRange2));
+        assert.isFalse(element._changeComments._isInPatchRange({},
+            patchRange3));
+      });
+
+      suite('comment ranges and paths', () => {
+        setup(() => {
+          element._changeComments._drafts = {
+            'file/one': [
+              {id: 11, patch_set: 2, side: PARENT},
+              {id: 12, patch_set: 2},
+            ],
+          };
+          element._changeComments._robotComments = {
+            'file/one': [
+              {id: 1, patch_set: 2, side: PARENT},
+              {id: 2, patch_set: 2, unresolved: true},
+            ],
+          };
+          element._changeComments._comments = {
+            'file/one': [
+              {id: 3, patch_set: 2, side: PARENT},
+              {id: 4, patch_set: 2},
+            ],
+            'file/two': [
+              {id: 5, patch_set: 2},
+              {id: 6, patch_set: 3},
+            ],
+            'file/three': [
+              {id: 7, patch_set: 2, side: PARENT, unresolved: true},
+              {id: 8, patch_set: 3},
+            ],
+            'file/four': [
+              {id: 9, patch_set: 5, side: PARENT},
+              {i: 10, patch_set: 5},
+            ],
+          };
+        });
+
+        test('getPaths', () => {
+          const patchRange = {basePatchNum: 1, patchNum: 4};
+          let paths = element._changeComments.getPaths(patchRange);
+          assert.equal(Object.keys(paths).length, 0);
+
+          patchRange.basePatchNum = PARENT;
+          patchRange.patchNum = 3;
+          paths = element._changeComments.getPaths(patchRange);
+          assert.notProperty(paths, 'file/one');
+          assert.property(paths, 'file/two');
+          assert.property(paths, 'file/three');
+          assert.notProperty(paths, 'file/four');
+
+          patchRange.patchNum = 2;
+          paths = element._changeComments.getPaths(patchRange);
+          assert.property(paths, 'file/one');
+          assert.property(paths, 'file/two');
+          assert.property(paths, 'file/three');
+          assert.notProperty(paths, 'file/four');
+
+          paths = element._changeComments.getPaths();
+          assert.property(paths, 'file/one');
+          assert.property(paths, 'file/two');
+          assert.property(paths, 'file/three');
+          assert.property(paths, 'file/four');
+        });
+
+        test('getCommentsBySideForPath', () => {
+          const patchRange = {basePatchNum: 1, patchNum: 3};
+          let path = 'file/one';
+          let comments = element._changeComments.getCommentsBySideForPath(path,
+              patchRange);
+          assert.equal(comments.meta.changeNum, 1234);
+          assert.equal(comments.left.length, 0);
+          assert.equal(comments.right.length, 0);
+
+          path = 'file/two';
+          comments = element._changeComments.getCommentsBySideForPath(path,
+              patchRange);
+          assert.equal(comments.left.length, 0);
+          assert.equal(comments.right.length, 1);
+
+          patchRange.basePatchNum = 2;
+          comments = element._changeComments.getCommentsBySideForPath(path,
+              patchRange);
+          assert.equal(comments.left.length, 1);
+          assert.equal(comments.right.length, 1);
+
+          patchRange.basePatchNum = PARENT;
+          path = 'file/three';
+          comments = element._changeComments.getCommentsBySideForPath(path,
+              patchRange);
+          assert.equal(comments.left.length, 0);
+          assert.equal(comments.right.length, 1);
+        });
+
+        test('getAllCommentsForPath', () => {
+          let path = 'file/one';
+          let comments = element._changeComments.getAllCommentsForPath(path);
+          assert.deepEqual(comments.length, 4);
+          path = 'file/two';
+          comments = element._changeComments.getAllCommentsForPath(path, 2);
+          assert.deepEqual(comments.length, 1);
+        });
+
+        test('getAllDraftsForPath', () => {
+          const path = 'file/one';
+          const drafts = element._changeComments.getAllDraftsForPath(path);
+          assert.deepEqual(drafts.length, 2);
+        });
+
+        test('computeUnresolvedNum', () => {
+          assert.equal(element._changeComments
+              .computeUnresolvedNum(2, 'file/one'), 1);
+          assert.equal(element._changeComments
+              .computeUnresolvedNum(1, 'file/one'), 0);
+          assert.equal(element._changeComments
+              .computeUnresolvedNum(2, 'file/three'), 1);
+        });
+
+        test('computeCommentCount', () => {
+          assert.equal(element._changeComments
+              .computeCommentCount(2, 'file/one'), 4);
+          assert.equal(element._changeComments
+              .computeCommentCount(1, 'file/one'), 0);
+          assert.equal(element._changeComments
+              .computeCommentCount(2, 'file/three'), 1);
+        });
+
+        test('computeDraftCount', () => {
+          assert.equal(element._changeComments
+              .computeDraftCount(2, 'file/one'), 2);
+          assert.equal(element._changeComments
+              .computeDraftCount(1, 'file/one'), 0);
+          assert.equal(element._changeComments
+              .computeDraftCount(2, 'file/three'), 0);
+        });
+
+        test('getAllPublishedComments', () => {
+          const publishedComments = element._changeComments
+              .getAllPublishedComments();
+          assert.equal(Object.keys(publishedComments).length, 4);
+          assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
+        });
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
index 999c883..8cefabb 100644
--- a/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
+++ b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
@@ -55,8 +55,8 @@
         confirm-label="Delete"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header">Delete Comment</div>
-      <div class="main">
+      <div class="header" slot="header">Delete Comment</div>
+      <div class="main" slot="main">
         <label for="messageInput">Enter comment delete reason</label>
         <iron-autogrow-textarea
             id="messageInput"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
new file mode 100644
index 0000000..4f67142
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
@@ -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.
+(function(window, GrDiffBuilderSideBySide) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrDiffBuilderBinary) { return; }
+
+  function GrDiffBuilderBinary(diff, comments, prefs, projectName, outputEl) {
+    GrDiffBuilder.call(this, diff, comments, prefs, projectName, outputEl);
+    console.log('binary village');
+  }
+
+  GrDiffBuilderBinary.prototype = Object.create(GrDiffBuilder.prototype);
+  GrDiffBuilderBinary.prototype.constructor = GrDiffBuilderBinary;
+
+  // This method definition is a no-op to satisfy the parent type.
+  GrDiffBuilderBinary.prototype.addColumns = function(outputEl, fontSize) {};
+
+  GrDiffBuilderBinary.prototype.buildSectionElement = function() {
+    const section = this._createElement('tbody', 'binary-diff');
+    const row = this._createElement('tr');
+    const cell = this._createElement('td');
+    const label = this._createElement('label');
+    label.textContent = 'Difference in binary files';
+    cell.appendChild(label);
+    row.appendChild(cell);
+    section.appendChild(row);
+    return section;
+  };
+
+  window.GrDiffBuilderBinary = GrDiffBuilderBinary;
+})(window, GrDiffBuilderSideBySide);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index ddf3896..971d012 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -138,7 +138,7 @@
     if (image) {
       const type = image.type || image._expectedType;
       if (image._width && image._height) {
-        return image._width + '⨉' + image._height + ' ' + type;
+        return image._width + '×' + image._height + ' ' + type;
       } else {
         return type;
       }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index dd18b65..3995c0d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -24,7 +24,7 @@
 <dom-module id="gr-diff-builder">
   <template>
     <div class="contentWrapper">
-      <content></content>
+      <slot></slot>
     </div>
     <gr-ranged-comment-layer
         id="rangeLayer"
@@ -36,6 +36,7 @@
         id="processor"
         groups="{{_groups}}"></gr-diff-processor>
     <gr-reporting id="reporting"></gr-reporting>
+    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
   </template>
   <script src="../gr-diff/gr-diff-line.js"></script>
   <script src="../gr-diff/gr-diff-group.js"></script>
@@ -44,6 +45,7 @@
   <script src="gr-diff-builder-side-by-side.js"></script>
   <script src="gr-diff-builder-unified.js"></script>
   <script src="gr-diff-builder-image.js"></script>
+  <script src="gr-diff-builder-binary.js"></script>
   <script>
     (function() {
       'use strict';
@@ -89,12 +91,16 @@
 
         properties: {
           diff: Object,
+          diffPath: String,
+          changeNum: String,
+          patchNum: String,
           viewMode: String,
           comments: Object,
           isImageDiff: Boolean,
           baseImage: Object,
           revisionImage: Object,
           projectName: String,
+          parentIndex: Number,
           _builder: Object,
           _groups: Array,
           _layers: Array,
@@ -111,7 +117,7 @@
 
         attached() {
           // Setup annotation layers.
-          this._layers = [
+          const layers = [
             this._createTrailingWhitespaceLayer(),
             this.$.syntaxLayer,
             this._createIntralineLayer(),
@@ -119,6 +125,14 @@
             this.$.rangeLayer,
           ];
 
+          // Get layers from plugins (if any).
+          for (const pluginLayer of this.$.jsAPI.getDiffLayers(
+              this.diffPath, this.changeNum, this.patchNum)) {
+            layers.push(pluginLayer);
+          }
+
+          this._layers = layers;
+
           this.async(() => {
             this._preRenderThread();
           });
@@ -141,11 +155,12 @@
           this._builder.addColumns(this.diffElement, prefs.font_size);
 
           const reporting = this.$.reporting;
+          const isBinary = !!(this.isImageDiff || this.diff.binary);
 
           reporting.time(TimingLabel.TOTAL);
           reporting.time(TimingLabel.CONTENT);
           this.dispatchEvent(new CustomEvent('render-start', {bubbles: true}));
-          return this.$.processor.process(this.diff.content, this.isImageDiff)
+          return this.$.processor.process(this.diff.content, isBinary)
               .then(() => {
                 if (this.isImageDiff) {
                   this._builder.renderDiffImages();
@@ -250,19 +265,50 @@
           this.$.syntaxLayer.cancel();
         },
 
+        _handlePreferenceError(pref) {
+          const message = `The value of the '${pref}' user preference is ` +
+              `invalid. Fix in diff preferences`;
+          this.dispatchEvent(new CustomEvent('show-alert', {
+            detail: {
+              message,
+            }, bubbles: true}));
+          throw Error(`Invalid preference value: ${pref}`);
+        },
+
         _getDiffBuilder(diff, comments, prefs) {
+          if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
+            this._handlePreferenceError('tab size');
+            return;
+          }
+
+          if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
+            this._handlePreferenceError('diff width');
+            return;
+          }
+
+          let builder = null;
           if (this.isImageDiff) {
-            return new GrDiffBuilderImage(diff, comments, prefs,
+            builder = new GrDiffBuilderImage(diff, comments, prefs,
                 this.projectName, this.diffElement, this.baseImage,
                 this.revisionImage);
+          } else if (diff.binary) {
+            // If the diff is binary, but not an image.
+            return new GrDiffBuilderBinary(diff, comments, prefs,
+                this.projectName, this.diffElement);
           } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-            return new GrDiffBuilderSideBySide(diff, comments, prefs,
+            builder = new GrDiffBuilderSideBySide(diff, comments, prefs,
                 this.projectName, this.diffElement, this._layers);
           } else if (this.viewMode === DiffViewMode.UNIFIED) {
-            return new GrDiffBuilderUnified(diff, comments, prefs,
+            builder = new GrDiffBuilderUnified(diff, comments, prefs,
                 this.projectName, this.diffElement, this._layers);
           }
-          throw Error('Unsupported diff view mode: ' + this.viewMode);
+          if (!builder) {
+            throw Error('Unsupported diff view mode: ' + this.viewMode);
+          }
+          if (this.parentIndex) {
+            builder.setParentIndex(this.parentIndex);
+          }
+          return builder;
         },
 
         _clearDiffContent() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index e199a75..9f7c5c0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -14,21 +14,30 @@
 (function(window, GrDiffGroup, GrDiffLine) {
   'use strict';
 
-  const HTML_ENTITY_PATTERN = /[&<>"'`\/]/g;
-  const HTML_ENTITY_MAP = {
-    '&': '&amp;',
-    '<': '&lt;',
-    '>': '&gt;',
-    '"': '&quot;',
-    '\'': '&#39;',
-    '/': '&#x2F;',
-    '`': '&#96;',
-  };
-
   // Prevent redefinition.
   if (window.GrDiffBuilder) { return; }
 
-  const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+  /**
+   * In JS, unicode code points above 0xFFFF occupy two elements of a string.
+   * For example '𐀏'.length is 2. An occurence of such a code point is called a
+   * surrogate pair.
+   *
+   * This regex segments a string along tabs ('\t') and surrogate pairs, since
+   * these are two cases where '1 char' does not automatically imply '1 column'.
+   *
+   * TODO: For human languages whose orthographies use combining marks, this
+   * approach won't correctly identify the grapheme boundaries. In those cases,
+   * a grapheme consists of multiple code points that should count as only one
+   * character against the column limit. Getting that correct (if it's desired)
+   * is probably beyond the limits of a regex, but there are nonstandard APIs to
+   * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs.
+   *
+   * Further reading:
+   *   On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode
+   *   Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
+   *   A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
+   */
+  const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
   function GrDiffBuilder(diff, comments, prefs, projectName, outputEl, layers) {
     this._diff = diff;
@@ -38,9 +47,19 @@
     this._outputEl = outputEl;
     this.groups = [];
     this._blameInfo = null;
+    this._parentIndex = undefined;
 
     this.layers = layers || [];
 
+    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
+      throw Error('Invalid tab size from preferences.');
+    }
+
+    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
+      throw Error('Invalid line length from preferences.');
+    }
+
+
     for (const layer of this.layers) {
       if (layer.addListener) {
         layer.addListener(this._handleLayerUpdate.bind(this));
@@ -48,14 +67,6 @@
     }
   }
 
-  GrDiffBuilder.LESS_THAN_CODE = '<'.charCodeAt(0);
-  GrDiffBuilder.GREATER_THAN_CODE = '>'.charCodeAt(0);
-  GrDiffBuilder.AMPERSAND_CODE = '&'.charCodeAt(0);
-  GrDiffBuilder.SEMICOLON_CODE = ';'.charCodeAt(0);
-
-  GrDiffBuilder.LINE_FEED_HTML =
-      '<span class="style-scope gr-diff br"></span>';
-
   GrDiffBuilder.GroupType = {
     ADDED: 'b',
     BOTH: 'ab',
@@ -94,7 +105,7 @@
    * @param {Object} group
    */
   GrDiffBuilder.prototype.buildSectionElement = function() {
-    throw Error('Subclasses must implement buildGroupElement');
+    throw Error('Subclasses must implement buildSectionElement');
   };
 
   GrDiffBuilder.prototype.emitGroup = function(group, opt_beforeSection) {
@@ -203,6 +214,11 @@
     for (let i = 0; i < lines.length; i++) {
       line = lines[i];
       el = elements[i];
+      if (!el) {
+        // Cannot re-render an element if it does not exist. This can happen
+        // if lines are collapsed and not visible on the page yet.
+        continue;
+      }
       el.parentElement.replaceChild(this._createTextEl(line, side).firstChild,
           el);
     }
@@ -214,10 +230,6 @@
         group => { return group.element; });
   };
 
-  GrDiffBuilder.prototype._commentIsAtLineNum = function(side, lineNum) {
-    return this._commentLocations[side][lineNum] === true;
-  };
-
   // TODO(wyatta): Move this completely into the processor.
   GrDiffBuilder.prototype._insertContextGroups = function(groups, lines,
       hiddenRange) {
@@ -347,11 +359,12 @@
     threadGroupEl.isOnParent = isOnParent;
     threadGroupEl.projectName = this._projectName;
     threadGroupEl.range = range;
+    threadGroupEl.parentIndex = this._parentIndex;
     return threadGroupEl;
   };
 
-  GrDiffBuilder.prototype._commentThreadGroupForLine = function(line,
-      opt_side) {
+  GrDiffBuilder.prototype._commentThreadGroupForLine = function(
+      line, opt_side) {
     const comments =
         this._getCommentsForLine(this._comments, line, opt_side);
     if (!comments || comments.length === 0) {
@@ -361,17 +374,17 @@
     let patchNum = this._comments.meta.patchRange.patchNum;
     let isOnParent = comments[0].side === 'PARENT' || false;
     if (line.type === GrDiffLine.Type.REMOVE ||
-    opt_side === GrDiffBuilder.Side.LEFT) {
-      if (this._comments.meta.patchRange.basePatchNum === 'PARENT') {
+        opt_side === GrDiffBuilder.Side.LEFT) {
+      if (this._comments.meta.patchRange.basePatchNum === 'PARENT' ||
+          Gerrit.PatchSetBehavior.isMergeParent(
+              this._comments.meta.patchRange.basePatchNum)) {
         isOnParent = true;
       } else {
         patchNum = this._comments.meta.patchRange.basePatchNum;
       }
     }
     const threadGroupEl = this.createCommentThreadGroup(
-        this._comments.meta.changeNum,
-        patchNum,
-        this._comments.meta.path,
+        this._comments.meta.changeNum, patchNum, this._comments.meta.path,
         isOnParent);
     threadGroupEl.comments = comments;
     if (opt_side) {
@@ -380,8 +393,8 @@
     return threadGroupEl;
   };
 
-  GrDiffBuilder.prototype._createLineEl = function(line, number, type,
-      opt_class) {
+  GrDiffBuilder.prototype._createLineEl = function(
+      line, number, type, opt_class) {
     const td = this._createElement('td');
     if (opt_class) {
       td.classList.add(opt_class);
@@ -407,32 +420,20 @@
 
   GrDiffBuilder.prototype._createTextEl = function(line, opt_side) {
     const td = this._createElement('td');
-    const text = line.text;
     if (line.type !== GrDiffLine.Type.BLANK) {
       td.classList.add('content');
     }
     td.classList.add(line.type);
-    let html = this._escapeHTML(text);
-    html = this._addTabWrappers(html, this._prefs.tab_size);
-    if (!this._prefs.line_wrapping &&
-        this._textLength(text, this._prefs.tab_size) >
-        this._prefs.line_length) {
-      html = this._addNewlines(text, html);
-    }
 
-    const contentText = this._createElement('div', 'contentText');
+    const lineLimit =
+        !this._prefs.line_wrapping ? this._prefs.line_length : Infinity;
+
+    const contentText =
+        this._formatText(line.text, this._prefs.tab_size, lineLimit);
     if (opt_side) {
       contentText.setAttribute('data-side', opt_side);
     }
 
-    // If the html is equivalent to the text then it didn't get highlighted
-    // or escaped. Use textContent which is faster than innerHTML.
-    if (html === text) {
-      contentText.textContent = text;
-    } else {
-      contentText.innerHTML = html;
-    }
-
     for (const layer of this.layers) {
       layer.annotate(contentText, line);
     }
@@ -443,139 +444,85 @@
   };
 
   /**
-   * Returns the text length after normalizing unicode and tabs.
-   * @return {number} The normalized length of the text.
+   * Returns a 'div' element containing the supplied |text| as its innerText,
+   * with '\t' characters expanded to a width determined by |tabSize|, and the
+   * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
+   * desired.
+   *
+   * @param {string} text The text to be formatted.
+   * @param {number} tabSize The width of each tab stop.
+   * @param {number} lineLimit The column after which to wrap lines.
+   * @return {HTMLElement}
    */
-  GrDiffBuilder.prototype._textLength = function(text, tabSize) {
-    text = text.replace(REGEX_ASTRAL_SYMBOL, '_');
-    let numChars = 0;
-    for (let i = 0; i < text.length; i++) {
-      if (text[i] === '\t') {
-        numChars += tabSize - (numChars % tabSize);
-      } else {
-        numChars++;
-      }
-    }
-    return numChars;
-  };
+  GrDiffBuilder.prototype._formatText = function(text, tabSize, lineLimit) {
+    const contentText = this._createElement('div', 'contentText');
 
-  // Advance `index` by the appropriate number of characters that would
-  // represent one source code character and return that index. For
-  // example, for source code '<span>' the escaped html string is
-  // '&lt;span&gt;'. Advancing from index 0 on the prior html string would
-  // return 4, since &lt; maps to one source code character ('<').
-  GrDiffBuilder.prototype._advanceChar = function(html, index) {
-    // TODO(andybons): Unicode is all kinds of messed up in JS. Account for it.
-    // https://mathiasbynens.be/notes/javascript-unicode
-
-    // Tags don't count as characters
-    while (index < html.length &&
-           html.charCodeAt(index) === GrDiffBuilder.LESS_THAN_CODE) {
-      while (index < html.length &&
-             html.charCodeAt(index) !== GrDiffBuilder.GREATER_THAN_CODE) {
-        index++;
-      }
-      index++; // skip the ">" itself
-    }
-    // An HTML entity (e.g., &lt;) counts as one character.
-    if (index < html.length &&
-        html.charCodeAt(index) === GrDiffBuilder.AMPERSAND_CODE) {
-      while (index < html.length &&
-             html.charCodeAt(index) !== GrDiffBuilder.SEMICOLON_CODE) {
-        index++;
-      }
-    }
-    return index + 1;
-  };
-
-  GrDiffBuilder.prototype._advancePastTagClose = function(html, index) {
-    while (index < html.length &&
-           html.charCodeAt(index) !== GrDiffBuilder.GREATER_THAN_CODE) {
-      index++;
-    }
-    return index + 1;
-  };
-
-  GrDiffBuilder.prototype._addNewlines = function(text, html) {
-    let htmlIndex = 0;
-    const indices = [];
-    let numChars = 0;
-    let prevHtmlIndex = 0;
-    for (let i = 0; i < text.length; i++) {
-      if (numChars > 0 && numChars % this._prefs.line_length === 0) {
-        indices.push(htmlIndex);
-      }
-      htmlIndex = this._advanceChar(html, htmlIndex);
-      if (text[i] === '\t') {
-        // Advance past tab closing tag.
-        htmlIndex = this._advancePastTagClose(html, htmlIndex);
-        // ~~ is a faster Math.floor
-        if (~~(numChars / this._prefs.line_length) !==
-            ~~((numChars + this._prefs.tab_size) / this._prefs.line_length)) {
-          // Tab crosses line limit - push it to the next line.
-          indices.push(prevHtmlIndex);
+    let columnPos = 0;
+    let textOffset = 0;
+    for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
+      if (segment) {
+        // |segment| contains only normal characters. If |segment| doesn't fit
+        // entirely on the current line, append chunks of |segment| followed by
+        // line breaks.
+        let rowStart = 0;
+        let rowEnd = lineLimit - columnPos;
+        while (rowEnd < segment.length) {
+          contentText.appendChild(
+              document.createTextNode(segment.substring(rowStart, rowEnd)));
+          contentText.appendChild(this._createElement('span', 'br'));
+          columnPos = 0;
+          rowStart = rowEnd;
+          rowEnd += lineLimit;
         }
-        numChars += this._prefs.tab_size;
-      } else {
-        numChars++;
+        // Append the last part of |segment|, which fits on the current line.
+        contentText.appendChild(
+            document.createTextNode(segment.substring(rowStart)));
+        columnPos += (segment.length - rowStart);
+        textOffset += segment.length;
       }
-      prevHtmlIndex = htmlIndex;
+      if (textOffset < text.length) {
+        // Handle the special character at |textOffset|.
+        if (text.startsWith('\t', textOffset)) {
+          // Append a single '\t' character.
+          let effectiveTabSize = tabSize - (columnPos % tabSize);
+          if (columnPos + effectiveTabSize > lineLimit) {
+            contentText.appendChild(this._createElement('span', 'br'));
+            columnPos = 0;
+            effectiveTabSize = tabSize;
+          }
+          contentText.appendChild(this._getTabWrapper(effectiveTabSize));
+          columnPos += effectiveTabSize;
+          textOffset++;
+        } else {
+          // Append a single surrogate pair.
+          if (columnPos >= lineLimit) {
+            contentText.appendChild(this._createElement('span', 'br'));
+            columnPos = 0;
+          }
+          contentText.appendChild(document.createTextNode(
+              text.substring(textOffset, textOffset + 2)));
+          textOffset += 2;
+          columnPos += 1;
+        }
+      }
     }
-    let result = html;
-    // Since the result string is being altered in place, start from the end
-    // of the string so that the insertion indices are not affected as the
-    // result string changes.
-    for (let i = indices.length - 1; i >= 0; i--) {
-      result = result.slice(0, indices[i]) + GrDiffBuilder.LINE_FEED_HTML +
-          result.slice(indices[i]);
-    }
-    return result;
+    return contentText;
   };
 
   /**
-   * Takes a string of text (not HTML) and returns a string of HTML with tab
-   * elements in place of tab characters. In each case tab elements are given
-   * the width needed to reach the next tab-stop.
+   * Returns a <span> element holding a '\t' character, that will visually
+   * occupy |tabSize| many columns.
    *
-   * @param {string} A line of text potentially containing tab characters.
-   * @param {number} The width for tabs.
-   * @return {string} An HTML string potentially containing tab elements.
+   * @param {number} tabSize The effective size of this tab stop.
+   * @return {HTMLElement}
    */
-  GrDiffBuilder.prototype._addTabWrappers = function(line, tabSize) {
-    if (!line.length) { return ''; }
-
-    let result = '';
-    let offset = 0;
-    const split = line.split('\t');
-    let width;
-
-    for (let i = 0; i < split.length - 1; i++) {
-      offset += split[i].length;
-      width = tabSize - (offset % tabSize);
-      result += split[i] + this._getTabWrapper(width);
-      offset += width;
-    }
-    if (split.length) {
-      result += split[split.length - 1];
-    }
-
-    return result;
-  };
-
   GrDiffBuilder.prototype._getTabWrapper = function(tabSize) {
     // Force this to be a number to prevent arbitrary injection.
-    tabSize = +tabSize;
-    if (isNaN(tabSize)) {
-      throw Error('Invalid tab size from preferences.');
-    }
-
-    let str = '<span class="style-scope gr-diff tab ';
-    str += '" style="';
-    // TODO(andybons): CSS tab-size is not supported in IE.
-    str += 'tab-size:' + tabSize + ';';
-    str += '-moz-tab-size:' + tabSize + ';';
-    str += '">\t</span>';
-    return str;
+    const result = this._createElement('span', 'tab');
+    result.style['tab-size'] = tabSize;
+    result.style['-moz-tab-size'] = tabSize;
+    result.innerText = '\t';
+    return result;
   };
 
   GrDiffBuilder.prototype._createElement = function(tagName, className) {
@@ -620,14 +567,8 @@
         !(!group.adds.length && !group.removes.length);
   };
 
-  GrDiffBuilder.prototype._escapeHTML = function(str) {
-    return str.replace(HTML_ENTITY_PATTERN, s => {
-      return HTML_ENTITY_MAP[s];
-    });
-  };
-
   /**
-   * Set the blame information for the diff. For any already-rednered line,
+   * Set the blame information for the diff. For any already-rendered line,
    * re-render its blame cell content.
    * @param {Object} blame
    */
@@ -654,6 +595,10 @@
     }
   };
 
+  GrDiffBuilder.prototype.setParentIndex = function(index) {
+    this._parentIndex = index;
+  };
+
   /**
    * Find the blame cell for a given line number.
    * @param {number} lineNum
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index f9e465e..e6f4f345 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -59,9 +59,11 @@
     let element;
     let builder;
     let sandbox;
+    const LINE_FEED_HTML = '<span class="style-scope gr-diff br"></span>';
 
     setup(() => {
       sandbox = sinon.sandbox.create();
+      element = fixture('basic');
       stub('gr-rest-api-interface', {
         getLoggedIn() { return Promise.resolve(false); },
         getProjectConfig() { return Promise.resolve({}); },
@@ -109,104 +111,139 @@
 
     test('newlines 1', () => {
       let text = 'abcdef';
-      assert.equal(builder._addNewlines(text, text), text);
+
+      assert.equal(builder._formatText(text, 4, 10).innerHTML, text);
       text = 'a'.repeat(20);
-      assert.equal(builder._addNewlines(text, text),
+      assert.equal(builder._formatText(text, 4, 10).innerHTML,
           'a'.repeat(10) +
-          GrDiffBuilder.LINE_FEED_HTML +
+          LINE_FEED_HTML +
           'a'.repeat(10));
     });
 
     test('newlines 2', () => {
       const text = '<span class="thumbsup">👍</span>';
-      const html =
-          '&lt;span class=&quot;thumbsup&quot;&gt;👍&lt;&#x2F;span&gt;';
-      assert.equal(builder._addNewlines(text, html),
+      assert.equal(builder._formatText(text, 4, 10).innerHTML,
           '&lt;span clas' +
-          GrDiffBuilder.LINE_FEED_HTML +
-          's=&quot;thumbsu' +
-          GrDiffBuilder.LINE_FEED_HTML +
-          'p&quot;&gt;👍&lt;&#x2F;spa' +
-          GrDiffBuilder.LINE_FEED_HTML +
-          'n&gt;');
+          LINE_FEED_HTML +
+          's="thumbsu' +
+          LINE_FEED_HTML +
+          'p"&gt;👍&lt;/span' +
+          LINE_FEED_HTML +
+          '&gt;');
     });
 
     test('newlines 3', () => {
       const text = '01234\t56789';
-      const html = '01234<span>\t</span>56789';
-      assert.equal(builder._addNewlines(text, html),
-          '01234<span>\t</span>5' +
-          GrDiffBuilder.LINE_FEED_HTML +
-          '6789');
+      assert.equal(builder._formatText(text, 4, 10).innerHTML,
+          '01234' + builder._getTabWrapper(3).outerHTML + '56' +
+          LINE_FEED_HTML +
+          '789');
     });
 
-    test('_addNewlines not called if line_wrapping is true', done => {
+    test('newlines 4', () => {
+      const text = '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍';
+      assert.equal(builder._formatText(text, 4, 20).innerHTML,
+          '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
+          LINE_FEED_HTML +
+          '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
+          LINE_FEED_HTML +
+          '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍');
+    });
+
+
+    test('line_length ignored if line_wrapping is true', () => {
       builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
-      const text = (new Array(52)).join('a');
+      const text = 'a'.repeat(51);
 
       const line = {text, highlights: []};
-      const newLineStub = sandbox.stub(builder, '_addNewlines');
-      builder._createTextEl(line);
-      flush(() => {
-        assert.isFalse(newLineStub.called);
-        done();
-      });
+      const result = builder._createTextEl(line).firstChild.innerHTML;
+      assert.equal(result, text);
     });
 
-    test('_addNewlines called if line_wrapping is true and meets other ' +
-        'conditions', done => {
+    test('line_length applied if line_wrapping is false', () => {
       builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
-      const text = (new Array(52)).join('a');
+      const text = 'a'.repeat(51);
 
       const line = {text, highlights: []};
-      const newLineStub = sandbox.stub(builder, '_addNewlines');
-      builder._createTextEl(line);
-
-      flush(() => {
-        assert.isTrue(newLineStub.called);
-        done();
-      });
+      const expected = 'a'.repeat(50) + LINE_FEED_HTML + 'a';
+      const result = builder._createTextEl(line).firstChild.innerHTML;
+      assert.equal(result, expected);
     });
 
     test('_createTextEl linewrap with tabs', () => {
-      const text = _.times(7, _.constant('\t')).join('') + '!';
+      const text = '\t'.repeat(7) + '!';
       const line = {text, highlights: []};
       const el = builder._createTextEl(line);
-      const tabEl = el.querySelector('.contentText > .br');
-      assert.isOk(tabEl);
+      assert.equal(el.innerText, text);
+      // With line length 10 and tab size 2, there should be a line break
+      // after every two tabs.
+      const newlineEl = el.querySelector('.contentText > .br');
+      assert.isOk(newlineEl);
       assert.equal(
           el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
-          tabEl);
+          newlineEl);
     });
 
     test('text length with tabs and unicode', () => {
-      assert.equal(builder._textLength('12345', 4), 5);
-      assert.equal(builder._textLength('\t\t12', 4), 10);
-      assert.equal(builder._textLength('abc💢123', 4), 7);
+      function expectTextLength(text, tabSize, expected) {
+        // Formatting to |expected| columns should not introduce line breaks.
+        const result = builder._formatText(text, tabSize, expected);
+        assert.isNotOk(result.querySelector('.contentText > .br'),
+            `  Expected the result of: \n` +
+            `      _formatText(${text}', ${tabSize}, ${expected})\n` +
+            `  to not contain a br. But the actual result HTML was:\n` +
+            `      '${result.innerHTML}'\nwhereupon`);
 
-      assert.equal(builder._textLength('abc\t', 8), 8);
-      assert.equal(builder._textLength('abc\t\t', 10), 20);
-      assert.equal(builder._textLength('', 10), 0);
-      assert.equal(builder._textLength('', 10), 0);
-      assert.equal(builder._textLength('abc\tde', 10), 12);
-      assert.equal(builder._textLength('abc\tde\t', 10), 20);
-      assert.equal(builder._textLength('\t\t\t\t\t', 20), 100);
+        // Increasing the line limit should produce the same markup.
+        assert.equal(builder._formatText(text, tabSize, Infinity).innerHTML,
+            result.innerHTML);
+        assert.equal(builder._formatText(text, tabSize, expected + 1).innerHTML,
+            result.innerHTML);
+
+        // Decreasing the line limit should introduce line breaks.
+        if (expected > 0) {
+          const tooSmall = builder._formatText(text, tabSize, expected - 1);
+          assert.isOk(tooSmall.querySelector('.contentText > .br'),
+              `  Expected the result of: \n` +
+              `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
+              `  to contain a br. But the actual result HTML was:\n` +
+              `      '${tooSmall.innerHTML}'\nwhereupon`);
+        }
+      }
+      expectTextLength('12345', 4, 5);
+      expectTextLength('\t\t12', 4, 10);
+      expectTextLength('abc💢123', 4, 7);
+      expectTextLength('abc\t', 8, 8);
+      expectTextLength('abc\t\t', 10, 20);
+      expectTextLength('', 10, 0);
+      expectTextLength('', 10, 0);
+      // 17 Thai combining chars.
+      expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
+      expectTextLength('abc\tde', 10, 12);
+      expectTextLength('abc\tde\t', 10, 20);
+      expectTextLength('\t\t\t\t\t', 20, 100);
     });
 
     test('tab wrapper insertion', () => {
       const html = 'abc\tdef';
-      const wrapper = builder._getTabWrapper(
-          builder._prefs.tab_size - 3,
-          builder._prefs.show_tabs);
+      const tabSize = builder._prefs.tab_size;
+      const wrapper = builder._getTabWrapper(tabSize - 3);
       assert.ok(wrapper);
-      assert.isAbove(wrapper.length, 0);
-      assert.equal(builder._addTabWrappers(html, builder._prefs.tab_size),
-          'abc' + wrapper + 'def');
-      assert.throws(builder._getTabWrapper.bind(
-          builder,
-          // using \x3c instead of < in string so gjslint can parse
-          '">\x3cimg src="/" onerror="alert(1);">\x3cspan class="',
-          true));
+      assert.equal(wrapper.innerText, '\t');
+      assert.equal(
+          builder._formatText(html, tabSize, Infinity).innerHTML,
+          'abc' + wrapper.outerHTML + 'def');
+    });
+
+    test('tab wrapper style', () => {
+      const pattern = new RegExp('^<span class="style-scope gr-diff tab" '
+          + 'style="(?:-moz-)?tab-size: (\\d+);">\\t<\\/span>$');
+
+      for (const size of [1, 3, 8, 55]) {
+        const html = builder._getTabWrapper(size).outerHTML;
+        expect(html).to.match(pattern);
+        assert.equal(html.match(pattern)[1], size);
+      }
     });
 
     test('comments', () => {
@@ -316,6 +353,24 @@
       checkThreadGroupProps(threadGroupEl, '3', false, [l3, r5]);
     });
 
+
+    test('_handlePreferenceError called with invalid preference', () => {
+      sandbox.stub(element, '_handlePreferenceError');
+      const prefs = {tab_size: 0};
+      element._getDiffBuilder(element.diff, element.comments, prefs);
+      assert.isTrue(element._handlePreferenceError.lastCall
+          .calledWithExactly('tab size'));
+    });
+
+    test('_handlePreferenceError triggers alert and javascript error', () => {
+      const errorStub = sinon.stub();
+      element.addEventListener('show-alert', errorStub);
+      assert.throws(element._handlePreferenceError.bind(element, 'tab size'));
+      assert.equal(errorStub.lastCall.args[0].detail.message,
+          `The value of the 'tab size' user preference is invalid. ` +
+        `Fix in diff preferences`);
+    });
+
     suite('_isTotal', () => {
       test('is total for add', () => {
         const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
@@ -621,6 +676,33 @@
       });
     });
 
+    suite('layers from plugins', () => {
+      let element;
+      let initialLayersCount;
+
+      setup(() => {
+        element = fixture('basic');
+        element._showTrailingWhitespace = true;
+        initialLayersCount = element._layers.length;
+      });
+
+      test('no plugin layers', () => {
+        const getDiffLayersStub = sinon.stub(element.$.jsAPI, 'getDiffLayers')
+                                       .returns([]);
+        element.attached();
+        assert.isTrue(getDiffLayersStub.called);
+        assert.equal(element._layers.length, initialLayersCount);
+      });
+
+      test('with plugin layers', () => {
+        const getDiffLayersStub = sinon.stub(element.$.jsAPI, 'getDiffLayers')
+                                       .returns([{}, {}]);
+        element.attached();
+        assert.isTrue(getDiffLayersStub.called);
+        assert.equal(element._layers.length, initialLayersCount+2);
+      });
+    });
+
     suite('trailing whitespace', () => {
       let element;
       let layer;
@@ -716,6 +798,63 @@
       });
     });
 
+    suite('rendering text, images and binary files', () => {
+      let processStub;
+      let comments;
+      let prefs;
+      let content;
+
+      setup(() => {
+        element = fixture('basic');
+        element.viewMode = 'SIDE_BY_SIDE';
+        processStub = sandbox.stub(element.$.processor, 'process')
+            .returns(Promise.resolve());
+        sandbox.stub(element, '_anyLineTooLong').returns(true);
+        comments = {left: [], right: []};
+        prefs = {
+          line_length: 10,
+          show_tabs: true,
+          tab_size: 4,
+          context: -1,
+          syntax_highlighting: true,
+        };
+        content = [{
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        }, {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        }];
+      });
+
+      test('text', () => {
+        element.diff = {content};
+        return element.render(comments, prefs).then(() => {
+          assert.isTrue(processStub.calledOnce);
+          assert.isFalse(processStub.lastCall.args[1]);
+        });
+      });
+
+      test('image', () => {
+        element.diff = {content, binary: true};
+        element.isImageDiff = true;
+        return element.render(comments, prefs).then(() => {
+          assert.isTrue(processStub.calledOnce);
+          assert.isTrue(processStub.lastCall.args[1]);
+        });
+      });
+
+      test('binary', () => {
+        element.diff = {content, binary: true};
+        return element.render(comments, prefs).then(() => {
+          assert.isTrue(processStub.calledOnce);
+          assert.isTrue(processStub.lastCall.args[1]);
+        });
+      });
+    });
+
     suite('rendering', () => {
       let content;
       let outputEl;
@@ -922,6 +1061,29 @@
         });
       });
 
+      test('_renderContentByRange notexistent elements', () => {
+        const spy = sandbox.spy(builder, '_createTextEl');
+
+        sandbox.stub(builder, 'findLinesByRange',
+            (s, e, d, lines, elements) => {
+              // Add a line and a corresponding element.
+              lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
+              const parEl = document.createElement('div');
+              const el = document.createElement('div');
+              parEl.appendChild(el);
+              elements.push(el);
+
+              // Add 2 lines without corresponding elements.
+              lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
+              lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
+            });
+
+        builder._renderContentByRange(1, 10, 'left');
+        // Should be called only once because only one line had a corresponding
+        // element.
+        assert.equal(spy.callCount, 1);
+      });
+
       test('_getNextContentOnSide side-by-side left', () => {
         const startElem = builder.getContentByLine(5, 'left',
             element.$.diffTable);
@@ -986,20 +1148,15 @@
         });
       });
 
-      test('_escapeHTML', () => {
+      test('escaping HTML', () => {
         let input = '<script>alert("XSS");<' + '/script>';
-        let expected = '&lt;script&gt;alert(&quot;XSS&quot;);' +
-            '&lt;&#x2F;script&gt;';
-        let result = GrDiffBuilder.prototype._escapeHTML(input);
+        let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
+        let result = builder._formatText(input, 1, Infinity).innerHTML;
         assert.equal(result, expected);
 
         input = '& < > " \' / `';
-
-        // \u0026 is an ampersand. This is being used here instead of &
-        // because of the gjslinter.
-        expected = '\u0026amp; \u0026lt; \u0026gt; \u0026quot;' +
-          ' \u0026#39; \u0026#x2F; \u0026#96;';
-        result = GrDiffBuilder.prototype._escapeHTML(input);
+        expected = '&amp; &lt; &gt; " \' / `';
+        result = builder._formatText(input, 1, Infinity).innerHTML;
         assert.equal(result, expected);
       });
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
index fdb7b6a..bf94bb6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
@@ -34,6 +34,7 @@
           comments="[[thread.comments]]"
           comment-side="[[thread.commentSide]]"
           is-on-parent="[[isOnParent]]"
+          parent-index="[[parentIndex]]"
           change-num="[[changeNum]]"
           location-range="[[thread.locationRange]]"
           patch-num="[[thread.patchNum]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
index b6af0d8..1cd7c89 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
@@ -30,6 +30,10 @@
         type: Boolean,
         value: false,
       },
+      parentIndex: {
+        type: Number,
+        value: null,
+      },
       _threads: {
         type: Array,
         value() { return []; },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
index 7cc94af..8c9ee24 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
@@ -31,10 +31,7 @@
       }
       gr-button {
         margin-left: .5em;
-        --gr-button: {
-          color: #212121;
-        }
-        --gr-button-hover-color: rgba(33, 33, 33, .75);
+        --gr-button-color: #212121;
       }
       #actions {
         margin-left: auto;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
index e9ab3d6..392267c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
@@ -48,7 +48,10 @@
         type: Boolean,
         value: false,
       },
-
+      parentIndex: {
+        type: Number,
+        value: null,
+      },
       _showActions: Boolean,
       _lastComment: Object,
       _orderedComments: Array,
@@ -113,6 +116,10 @@
 
     _commentsChanged(changeRecord) {
       this._orderedComments = this._sortedComments(this.comments);
+      this.updateThreadProperties();
+    },
+
+    updateThreadProperties() {
       if (this._orderedComments.length) {
         this._lastComment = this._getLastComment();
         this._unresolved = this._lastComment.unresolved;
@@ -149,18 +156,21 @@
     },
 
     /**
-     * Sets the initial state of the comment thread to have the last
-     * {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
-     * thread is unresolved.
+     * Sets the initial state of the comment thread.
+     * Expands the thread if one of the following is true:
+     * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
+     * thread is unresolved,
+     * - it's a robot comment.
      */
     _setInitialExpandedState() {
-      let comment;
       if (this._orderedComments) {
         for (let i = 0; i < this._orderedComments.length; i++) {
-          comment = this._orderedComments[i];
-          comment.collapsed =
-              this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT ||
-              !this._unresolved;
+          const comment = this._orderedComments[i];
+          const isRobotComment = !!comment.robot_id;
+          // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
+          const resolvedThread = !this._unresolved ||
+                this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
+          comment.collapsed = !isRobotComment && resolvedThread;
         }
       }
     },
@@ -170,7 +180,7 @@
         const c1Date = c1.__date || util.parseDate(c1.updated);
         const c2Date = c2.__date || util.parseDate(c2.updated);
         const dateCompare = c1Date - c2Date;
-        if (!c1.id || !c1.id.localeCompare) { return 0; }
+        if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
         // If same date, fall back to sorting by id.
         return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
       });
@@ -301,6 +311,9 @@
           end_character: opt_range.endChar,
         };
       }
+      if (this.parentIndex) {
+        d.parent = this.parentIndex;
+      }
       return d;
     },
 
@@ -347,6 +360,11 @@
         return;
       }
       this.set(['comments', index], comment);
+      // Because of the way we pass these comment objects around by-ref, in
+      // combination with the fact that Polymer does dirty checking in
+      // observers, the this.set() call above will not cause a thread update in
+      // some situations.
+      this.updateThreadProperties();
     },
 
     _indexOf(comment, arr) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
index c96c031..e1ab0a0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
@@ -60,10 +60,9 @@
     test('comments are sorted correctly', () => {
       const comments = [
         {
-          id: 'jacks_reply',
           message: 'i like you, too',
           in_reply_to: 'sallys_confession',
-          updated: '2015-12-25 15:00:20.396000000',
+          __date: new Date('2015-12-25'),
         }, {
           id: 'sallys_confession',
           message: 'i like you, jack',
@@ -113,10 +112,9 @@
           message: 'i have to find santa',
           updated: '2015-12-24 15:00:20.396000000',
         }, {
-          id: 'jacks_reply',
           message: 'i like you, too',
           in_reply_to: 'sallys_confession',
-          updated: '2015-12-25 15:00:20.396000000',
+          __date: new Date('2015-12-25'),
         },
       ]);
     });
@@ -512,6 +510,13 @@
         for (let i = 0; i < element.comments.length; i++) {
           assert.isTrue(element.comments[i].collapsed);
         }
+        for (let i = 0; i < element.comments.length; i++) {
+          element.comments[i].robot_id = 123;
+        }
+        element._setInitialExpandedState();
+        for (let i = 0; i < element.comments.length; i++) {
+          assert.isFalse(element.comments[i].collapsed);
+        }
       });
     });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
index d58b6be..66f9feb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
@@ -17,7 +17,9 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../shared/gr-textarea/gr-textarea.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
@@ -25,9 +27,9 @@
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-storage/gr-storage.html">
+<link rel="import" href="../../shared/gr-textarea/gr-textarea.html">
 <link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
 <link rel="import" href="../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html">
-<link rel="import" href="../../../styles/shared-styles.html">
 
 <script src="../../../scripts/rootElement.js"></script>
 
@@ -46,13 +48,15 @@
       :host([disabled]) {
         pointer-events: none;
       }
-      :host([disabled]) .container {
+      :host([disabled]) .body,
+      :host([disabled]) .date {
         opacity: .5;
       }
       :host([is-robot-comment]) {
         background-color: #cfe8fc;
       }
       .header {
+        align-items: baseline;
         cursor: pointer;
         display: flex;
         font-family: 'Open Sans', sans-serif;
@@ -95,10 +99,7 @@
       }
       .action {
         margin-left: 1em;
-        --gr-button: {
-          color: #212121;
-        }
-        --gr-button-hover-color: rgba(33, 33, 33, .75);
+        --gr-button-color: #212121;
       }
       .rightActions {
         display: flex;
@@ -123,8 +124,7 @@
         display: inline;
       }
       .draft:not(.editing) .save,
-      .draft:not(.editing) .cancel,
-      .draft:not(.editing) .resolve {
+      .draft:not(.editing) .cancel {
         display: none;
       }
       .editing .message,
@@ -133,6 +133,7 @@
       .editing .ack,
       .editing .done,
       .editing .edit,
+      .editing .discard,
       .editing .unresolved {
         display: none;
       }
@@ -203,12 +204,21 @@
         width: 100%;
       }
       #deleteBtn {
-        color: #666;
         display: none;
+        --gr-button: {
+          color: #666;
+          padding: 0;
+        }
       }
       #deleteBtn.showDeleteButtons {
         display: block;
       }
+      #savingMessage {
+        display: none;
+      }
+      :host([disabled]) #savingMessage {
+        display: inline;
+      }
     </style>
     <div id="container"
         class="container"
@@ -223,6 +233,7 @@
               title="This draft is only visible to you. To publish drafts, click the red 'Reply' button at the top of the change or press the 'A' key."
               max-width="20em"
               show-icon></gr-tooltip-content>
+          <span id="savingMessage">[[_savingMessage]]</span>
         </div>
         <div class="headerMiddle">
           <span class="collapsedContent">[[comment.message]]</span>
@@ -248,64 +259,67 @@
           </label>
         </div>
       </div>
-      <template is="dom-if" if="[[comment.robot_id]]">
-        <div class="robotId" hidden$="[[collapsed]]">
-          [[comment.robot_id]]
+      <div class="body">
+        <template is="dom-if" if="[[comment.robot_id]]">
+          <div class="robotId" hidden$="[[collapsed]]">
+            [[comment.robot_id]]
+          </div>
+        </template>
+        <gr-textarea
+            id="editTextarea"
+            class="editMessage"
+            autocomplete="on"
+            monospace
+            disabled="{{disabled}}"
+            rows="4"
+            text="{{_messageText}}"></gr-textarea>
+        <gr-formatted-text class="message"
+            content="[[comment.message]]"
+            no-trailing-margin="[[!comment.__draft]]"
+            collapsed="[[collapsed]]"
+            config="[[projectConfig.commentlinks]]"></gr-formatted-text>
+        <div hidden$="[[!comment.robot_run_id]]">
+          <div class="runIdInformation" hidden$="[[collapsed]]">
+            Run ID:
+            <a class="robotRunLink" href$="[[comment.url]]">
+              <span class="robotRun">[[comment.robot_run_id]]</span>
+            </a>
+          </div>
         </div>
-      </template>
-      <gr-textarea
-          id="editTextarea"
-          class="editMessage"
-          autocomplete="on"
-          monospace
-          disabled="{{disabled}}"
-          rows="4"
-          text="{{_messageText}}"></gr-textarea>
-      <gr-formatted-text class="message"
-          content="[[comment.message]]"
-          no-trailing-margin="[[!comment.__draft]]"
-          collapsed="[[collapsed]]"
-          config="[[projectConfig.commentlinks]]"></gr-formatted-text>
-      <div hidden$="[[!comment.robot_run_id]]">
-        <div class="runIdInformation" hidden$="[[collapsed]]">
-          Run ID:
-          <a class="robotRunLink" href$="[[comment.url]]">
-            <span class="robotRun">[[comment.robot_run_id]]</span>
-          </a>
+        <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
+          <div class="action resolve hideOnPublished">
+            <label>
+              <input type="checkbox"
+                  checked$="[[resolved]]"
+                  on-change="_handleToggleResolved">
+              Resolved
+            </label>
+          </div>
+          <div class="rightActions">
+            <gr-button link class="action cancel hideOnPublished"
+                on-tap="_handleCancel">Cancel</gr-button>
+            <gr-button link class="action discard hideOnPublished"
+                on-tap="_handleDiscard">Discard</gr-button>
+            <gr-button link class="action edit hideOnPublished"
+                on-tap="_handleEdit">Edit</gr-button>
+            <gr-button link class="action save hideOnPublished"
+                on-tap="_handleSave"
+                disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]">Save
+            </gr-button>
+          </div>
         </div>
-      </div>
-      <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
-        <div class="action resolve hideOnPublished">
-          <label>
-            <input type="checkbox"
-                checked$="[[resolved]]"
-                on-change="_handleToggleResolved">
-            Resolved
-          </label>
-        </div>
-        <div class="action unresolved hideOnPublished" hidden$="[[resolved]]">
-          Unresolved
-        </div>
-        <div class="rightActions">
-          <gr-button link class="action cancel hideOnPublished"
-              on-tap="_handleCancel" hidden>Cancel</gr-button>
-          <gr-button link class="action discard hideOnPublished"
-              on-tap="_handleDiscard">Discard</gr-button>
-          <gr-button link class="action edit hideOnPublished"
-              on-tap="_handleEdit">Edit</gr-button>
-          <gr-button link class="action save hideOnPublished"
-              on-tap="_handleSave"
-              disabled$="[[_computeSaveDisabled(_messageText)]]">Save
+        <div class="actions robotActions" hidden$="[[!_showRobotActions]]">
+          <gr-endpoint-decorator name="robot-comments-controls">
+            <gr-endpoint-param name="comment" value="[[comment]]">
+            </gr-endpoint-param>
+          </gr-endpoint-decorator>
+          <gr-button link class="action fix"
+              on-tap="_handleFix"
+              disabled="[[robotButtonDisabled]]">
+            Please Fix
           </gr-button>
         </div>
       </div>
-      <div class="actions robotActions" hidden$="[[!_showRobotActions]]">
-        <gr-button link class="action fix"
-            on-tap="_handleFix"
-            disabled="[[robotButtonDisabled]]">
-          Please Fix
-        </gr-button>
-      </div>
     </div>
     <gr-overlay id="confirmDeleteOverlay" with-backdrop>
       <gr-confirm-delete-comment-dialog id="confirmDeleteComment"
@@ -315,14 +329,14 @@
     </gr-overlay>
     <gr-overlay id="confirmDiscardOverlay" with-backdrop>
       <gr-confirm-dialog
-          id="confirmDiscaDialog"
+          id="confirmDiscardDialog"
           confirm-label="Discard"
           on-confirm="_handleConfirmDiscard"
           on-cancel="_closeConfirmDiscardOverlay">
-        <div class="header">
+        <div class="header" slot="header">
           Discard comment
         </div>
-        <div class="main">
+        <div class="main" slot="main">
           Are you sure you want to discard this draft comment?
         </div>
       </gr-confirm-dialog>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index c02aec5..9ae8ff2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -21,6 +21,8 @@
   const DRAFT_SINGULAR = 'draft...';
   const DRAFT_PLURAL = 'drafts...';
   const SAVED_MESSAGE = 'All changes saved';
+  const SAVING_PROGRESS_MESSAGE = 'Saving draft...';
+  const DiSCARDING_PROGRESS_MESSAGE = 'Discarding draft...';
 
   Polymer({
     is: 'gr-diff-comment',
@@ -116,10 +118,12 @@
         observer: '_toggleResolved',
       },
 
-      _numPendingDiffRequests: {
+      _numPendingDraftRequests: {
         type: Object,
         value: {number: 0}, // Intentional to share the object across instances.
       },
+
+      _savingMessage: String,
     },
 
     observers: [
@@ -175,27 +179,37 @@
       return this.$.restAPI.getIsAdmin();
     },
 
-    save() {
-      this.comment.message = this._messageText;
+    /**
+     * @param {*=} opt_comment
+     */
+    save(opt_comment) {
+      let comment = opt_comment;
+      if (!comment) {
+        comment = this.comment;
+        this.comment.message = this._messageText;
+      }
 
       this.disabled = true;
 
-      this._eraseDraftComment();
+      if (!this._messageText) {
+        return this._discardDraft();
+      }
 
-      this._xhrPromise = this._saveDraft(this.comment).then(response => {
+      this._xhrPromise = this._saveDraft(comment).then(response => {
         this.disabled = false;
         if (!response.ok) { return response; }
 
+        this._eraseDraftComment();
         return this.$.restAPI.getResponseObject(response).then(obj => {
-          const comment = obj;
-          comment.__draft = true;
+          const resComment = obj;
+          resComment.__draft = true;
           // Maintain the ephemeral draft ID for identification by other
           // elements.
           if (this.comment.__draftID) {
-            comment.__draftID = this.comment.__draftID;
+            resComment.__draftID = this.comment.__draftID;
           }
-          comment.__commentSide = this.commentSide;
-          this.comment = comment;
+          resComment.__commentSide = this.commentSide;
+          this.comment = resComment;
           this.editing = false;
           this._fireSave();
           return obj;
@@ -204,6 +218,8 @@
         this.disabled = false;
         throw err;
       });
+
+      return this._xhrPromise;
     },
 
     _eraseDraftComment() {
@@ -279,12 +295,16 @@
       return isAdmin && !draft ? 'showDeleteButtons' : '';
     },
 
-    _computeSaveDisabled(draft) {
-      return draft == null || draft.trim() == '';
+    _computeSaveDisabled(draft, comment, resolved) {
+      // If resolved state has changed and a msg exists, save should be enabled.
+      if (comment.unresolved === resolved && draft) { return false; }
+      if (comment.message) { return draft === comment.message; }
+      return !draft || draft.trim() === '';
     },
 
     _handleSaveKey(e) {
-      if (this._messageText.length) {
+      if (!this._computeSaveDisabled(this._messageText, this.comment,
+          this.resolved)) {
         e.preventDefault();
         this._handleSave(e);
       }
@@ -316,12 +336,8 @@
     _messageTextChanged(newValue, oldValue) {
       if (!this.comment || (this.comment && this.comment.id)) { return; }
 
-      // Keep comment.message in sync so that gr-diff-comment-thread is aware
-      // of the current message in the case that another comment is deleted.
-      this.comment.message = this._messageText || '';
       this.debounce('store', () => {
         const message = this._messageText;
-
         const commentLocation = {
           changeNum: this.changeNum,
           patchNum: this._getPatchNum(),
@@ -337,7 +353,6 @@
         } else {
           this.$.storage.setDraftComment(commentLocation, message);
         }
-        this._fireUpdate();
       }, STORAGE_DEBOUNCE_INTERVAL);
     },
 
@@ -390,13 +405,20 @@
 
     _handleSave(e) {
       e.preventDefault();
+
+      // Ignore saves started while already saving.
+      if (this.disabled) { return; }
+
       this.set('comment.__editing', false);
       this.save();
     },
 
     _handleCancel(e) {
       e.preventDefault();
-      if (!this.comment.message || this.comment.message.trim().length === 0) {
+
+      if (!this.comment.message ||
+          this.comment.message.trim().length === 0 ||
+          !this.comment.id) {
         this._fireDiscard();
         return;
       }
@@ -411,7 +433,8 @@
 
     _handleDiscard(e) {
       e.preventDefault();
-      if (this._computeSaveDisabled(this._messageText)) {
+
+      if (!this._messageText) {
         this._discardDraft();
         return;
       }
@@ -428,6 +451,7 @@
       if (!this.comment.__draft) {
         throw Error('Cannot discard a non-draft comment.');
       }
+      this._savingMessage = DiSCARDING_PROGRESS_MESSAGE;
       this.editing = false;
       this.disabled = true;
       this._eraseDraftComment();
@@ -447,6 +471,8 @@
         this.disabled = false;
         throw err;
       });
+
+      return this._xhrPromise;
     },
 
     _closeConfirmDiscardOverlay() {
@@ -463,15 +489,23 @@
     },
 
     _showStartRequest() {
-      const numPending = ++this._numPendingDiffRequests.number;
+      const numPending = ++this._numPendingDraftRequests.number;
       this._updateRequestToast(numPending);
     },
 
     _showEndRequest() {
-      const numPending = --this._numPendingDiffRequests.number;
+      const numPending = --this._numPendingDraftRequests.number;
       this._updateRequestToast(numPending);
     },
 
+    _handleFailedDraftRequest() {
+      this._numPendingDraftRequests.number--;
+
+      // Cancel the debouncer so that error toasts from the error-manager will
+      // not be overridden.
+      this.cancelDebouncer('draft-toast');
+    },
+
     _updateRequestToast(numPending) {
       const message = this._getSavingMessage(numPending);
       this.debounce('draft-toast', () => {
@@ -484,10 +518,15 @@
     },
 
     _saveDraft(draft) {
+      this._savingMessage = SAVING_PROGRESS_MESSAGE;
       this._showStartRequest();
       return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
           .then(result => {
-            this._showEndRequest();
+            if (result.ok) {
+              this._showEndRequest();
+            } else {
+              this._handleFailedDraftRequest();
+            }
             return result;
           });
     },
@@ -496,7 +535,11 @@
       this._showStartRequest();
       return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
           draft).then(result => {
-            this._showEndRequest();
+            if (result.ok) {
+              this._showEndRequest();
+            } else {
+              this._handleFailedDraftRequest();
+            }
             return result;
           });
     },
@@ -541,9 +584,20 @@
       this.resolved = !this.resolved;
     },
 
-    _toggleResolved(resolved) {
-      this.comment.unresolved = !resolved;
-      this.fire('comment-update', this._getEventPayload());
+    _toggleResolved(resolved, previousValue) {
+      // Do not proceed if this call is for the initial definition of the
+      // resolved property.
+      if (previousValue === undefined) { return; }
+
+      // Modify payload instead of this.comment, as this.comment is passed from
+      // the parent by ref.
+      const payload = this._getEventPayload();
+      payload.comment.unresolved = !resolved;
+      this.fire('comment-update', payload);
+      if (!this.editing) {
+        // Save the resolved state immediately.
+        this.save(payload.comment);
+      }
     },
 
     _handleCommentDelete() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
index 5793b05..8a2886c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -195,6 +195,7 @@
       suite('when text is empty', () => {
         setup(() => {
           element._messageText = '';
+          element.comment = {};
         });
 
         test('esc closes comment when text is empty', () => {
@@ -344,16 +345,15 @@
       assert.isTrue(isVisible(element.$$('.discard')), 'discard is visible');
       assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
       assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
-      assert.isFalse(isVisible(element.$$('.resolve')),
-          'resolve is not visible');
+      assert.isTrue(isVisible(element.$$('.resolve')), 'resolve is visible');
       assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
       assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
 
       element.editing = true;
       assert.isFalse(isVisible(element.$$('.edit')), 'edit is not visible');
-      assert.isTrue(isVisible(element.$$('.discard')), 'discard is visible');
+      assert.isFalse(isVisible(element.$$('.discard')), 'discard not visible');
       assert.isTrue(isVisible(element.$$('.save')), 'save is visible');
-      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is visible');
+      assert.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
       assert.isTrue(isVisible(element.$$('.resolve')), 'resolve is visible');
       assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
       assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
@@ -448,7 +448,7 @@
           'header middle content is not visible');
     });
 
-    test('draft creation/cancelation', done => {
+    test('draft creation/cancellation', done => {
       assert.isFalse(element.editing);
       MockInteractions.tap(element.$$('.edit'));
       assert.isTrue(element.editing);
@@ -493,28 +493,63 @@
       element._handleConfirmDiscard({preventDefault: sinon.stub()});
     });
 
+    test('storage is cleared only after save success', () => {
+      element._messageText = 'test';
+      const eraseStub = sandbox.stub(element, '_eraseDraftComment');
+      sandbox.stub(element.$.restAPI, 'getResponseObject')
+          .returns(Promise.resolve({}));
+
+      sandbox.stub(element, '_saveDraft').returns(Promise.resolve({ok: false}));
+
+      const savePromise = element.save();
+      assert.isFalse(eraseStub.called);
+      return savePromise.then(() => {
+        assert.isFalse(eraseStub.called);
+
+        element._saveDraft.restore();
+        sandbox.stub(element, '_saveDraft')
+            .returns(Promise.resolve({ok: true}));
+        return element.save().then(() => {
+          assert.isTrue(eraseStub.called);
+        });
+      });
+    });
+
+    test('_computeSaveDisabled', () => {
+      const comment = {unresolved: true};
+      const msgComment = {message: 'test', unresolved: true};
+      assert.equal(element._computeSaveDisabled('', comment, false), true);
+      assert.equal(element._computeSaveDisabled('test', comment, false), false);
+      assert.equal(element._computeSaveDisabled('', msgComment, false), false);
+      assert.equal(
+          element._computeSaveDisabled('test', msgComment, false), true);
+      assert.equal(
+          element._computeSaveDisabled('test2', msgComment, false), false);
+      assert.equal(element._computeSaveDisabled('test', comment, true), false);
+      assert.equal(element._computeSaveDisabled('', comment, true), true);
+      assert.equal(element._computeSaveDisabled('', comment, false), true);
+    });
+
     suite('confirm discard', () => {
-      let saveDisabled;
       let discardStub;
       let overlayStub;
       let mockEvent;
 
       setup(() => {
-        sandbox.stub(element, '_computeSaveDisabled', () => saveDisabled);
         discardStub = sandbox.stub(element, '_discardDraft');
         overlayStub = sandbox.stub(element, '_openOverlay');
         mockEvent = {preventDefault: sinon.stub()};
       });
 
-      test('confirms discard of comments that can be saved', () => {
-        saveDisabled = false;
+      test('confirms discard of comments with message text', () => {
+        element._messageText = 'test';
         element._handleDiscard(mockEvent);
         assert.isTrue(overlayStub.calledWith(element.$.confirmDiscardOverlay));
         assert.isFalse(discardStub.called);
       });
 
-      test('no confirmation for comments that cannot be saved', () => {
-        saveDisabled = true;
+      test('no confirmation for comments without message text', () => {
+        element._messageText = '';
         element._handleDiscard(mockEvent);
         assert.isFalse(overlayStub.called);
         assert.isTrue(discardStub.calledOnce);
@@ -544,21 +579,14 @@
       element.flushDebouncer('store');
       assert(fireStub.calledWith('comment-update'),
           'comment-update should be sent');
-      assert.deepEqual(fireStub.lastCall.args, [
-        'comment-update', {
-          comment: {
-            __commentSide: 'right',
-            __draft: true,
-            __draftID: 'temp_draft_id',
-            __editing: true,
-            line: 5,
-            path: '/path/to/file',
-            message: 'good news, everyone!',
-            unresolved: false,
-          },
-          patchNum: 1,
-        },
-      ]);
+      assert.isTrue(fireStub.calledOnce);
+
+      element._messageText = 'good news, everyone!';
+      element.flushDebouncer('fire-update');
+      element.flushDebouncer('store');
+      assert.isTrue(fireStub.calledOnce,
+          'No events should fire for text editing');
+
       MockInteractions.tap(element.$$('.save'));
 
       assert.isTrue(element.disabled,
@@ -606,6 +634,23 @@
       });
     });
 
+    test('draft prevent save when disabled', () => {
+      const saveStub = sandbox.stub(element, 'save');
+      element.draft = true;
+      MockInteractions.tap(element.$$('.edit'));
+      element._messageText = 'good news, everyone!';
+      element.flushDebouncer('fire-update');
+      element.flushDebouncer('store');
+
+      element.disabled = true;
+      MockInteractions.tap(element.$$('.save'));
+      assert.isFalse(saveStub.called);
+
+      element.disabled = false;
+      MockInteractions.tap(element.$$('.save'));
+      assert.isTrue(saveStub.calledOnce);
+    });
+
     test('clicking on date link does not trigger nav', () => {
       const showStub = sinon.stub(page, 'show');
       const dateEl = element.$$('.date');
@@ -617,21 +662,35 @@
       showStub.restore();
     });
 
-    test('proper event fires on resolve', done => {
+    test('proper event fires on resolve, comment is not saved', done => {
+      const save = sandbox.stub(element, 'save');
       element.addEventListener('comment-update', e => {
         assert.isTrue(e.detail.comment.unresolved);
+        assert.isFalse(save.called);
         done();
       });
       MockInteractions.tap(element.$$('.resolve input'));
     });
 
     test('resolved comment state indicated by checkbox', () => {
+      sandbox.stub(element, 'save');
       element.comment = {unresolved: false};
       assert.isTrue(element.$$('.resolve input').checked);
       element.comment = {unresolved: true};
       assert.isFalse(element.$$('.resolve input').checked);
     });
 
+    test('resolved checkbox saves when !editing', () => {
+      element.editing = false;
+      const save = sandbox.stub(element, 'save');
+
+      element.comment = {unresolved: false};
+      assert.isTrue(element.$$('.resolve input').checked);
+      element.comment = {unresolved: true};
+      assert.isFalse(element.$$('.resolve input').checked);
+      assert.isTrue(save.called);
+    });
+
     suite('draft saving messages', () => {
       test('_getSavingMessage', () => {
         assert.equal(element._getSavingMessage(0), 'All changes saved');
@@ -642,23 +701,77 @@
 
       test('_show{Start,End}Request', () => {
         const updateStub = sandbox.stub(element, '_updateRequestToast');
-        element._numPendingDiffRequests.number = 1;
+        element._numPendingDraftRequests.number = 1;
 
         element._showStartRequest();
         assert.isTrue(updateStub.calledOnce);
         assert.equal(updateStub.lastCall.args[0], 2);
-        assert.equal(element._numPendingDiffRequests.number, 2);
+        assert.equal(element._numPendingDraftRequests.number, 2);
 
         element._showEndRequest();
         assert.isTrue(updateStub.calledTwice);
         assert.equal(updateStub.lastCall.args[0], 1);
-        assert.equal(element._numPendingDiffRequests.number, 1);
+        assert.equal(element._numPendingDraftRequests.number, 1);
 
         element._showEndRequest();
         assert.isTrue(updateStub.calledThrice);
         assert.equal(updateStub.lastCall.args[0], 0);
-        assert.equal(element._numPendingDiffRequests.number, 0);
+        assert.equal(element._numPendingDraftRequests.number, 0);
       });
     });
+
+    suite('saving progress indicators', () => {
+      setup(() => {
+        sandbox.stub(element, '_deleteDraft').returns(Promise.resolve());
+        element._savingMessage = '';
+      });
+
+      test('saving', () => {
+        element._saveDraft();
+        assert.equal(element._savingMessage, 'Saving draft...');
+      });
+
+      test('discarding', () => {
+        element._discardDraft();
+        assert.equal(element._savingMessage, 'Discarding draft...');
+      });
+    });
+
+    test('cancelling an unsaved draft discards, persists in storage', () => {
+      const discardSpy = sandbox.spy(element, '_fireDiscard');
+      const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
+      const eraseStub = sandbox.stub(element.$.storage, 'eraseDraftComment');
+      element._messageText = 'test text';
+      flushAsynchronousOperations();
+      element.flushDebouncer('store');
+
+      assert.isTrue(storeStub.called);
+      assert.equal(storeStub.lastCall.args[1], 'test text');
+      element._handleCancel({preventDefault: () => {}});
+      assert.isTrue(discardSpy.called);
+      assert.isFalse(eraseStub.called);
+    });
+
+    test('cancelling edit on a saved draft does not store', () => {
+      element.comment.id = 'foo';
+      const discardSpy = sandbox.spy(element, '_fireDiscard');
+      const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
+      element._messageText = 'test text';
+      flushAsynchronousOperations();
+      element.flushDebouncer('store');
+
+      assert.isFalse(storeStub.called);
+      element._handleCancel({preventDefault: () => {}});
+      assert.isTrue(discardSpy.called);
+    });
+
+    test('deleting text from saved draft and saving deletes the draft', () => {
+      element.comment = {id: 'foo', message: 'test'};
+      element._messageText = '';
+      const discardStub = sandbox.stub(element, '_discardDraft');
+
+      element.save();
+      assert.isTrue(discardStub.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
index 7b9954d..c60f843 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
@@ -41,7 +41,7 @@
       }
     </style>
     <div class="contentWrapper">
-      <content></content>
+      <slot></slot>
     </div>
   </template>
   <script src="gr-annotation.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index 2490509..03380e4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -251,6 +251,22 @@
       };
     },
 
+    /**
+     * The only line in which add a comment tooltip is cut off is the first
+     * line. Even if there is a collapsed section, The first visible line is
+     * in the position where the second line would have been, if not for the
+     * collapsed section, so don't need to worry about this case for
+     * positioning the tooltip.
+     */
+    _positionActionBox(actionBox, startLine, range) {
+      if (startLine > 1) {
+        actionBox.placeAbove(range);
+        return;
+      }
+      actionBox.positionBelow = true;
+      actionBox.placeBelow(range);
+    },
+
     _handleSelection() {
       const normalizedRange = this._getNormalizedRange();
       if (!normalizedRange) {
@@ -285,17 +301,18 @@
       };
       actionBox.side = start.side;
       if (start.line === end.line) {
-        actionBox.placeAbove(domRange);
+        this._positionActionBox(actionBox, start.line, domRange);
       } else if (start.node instanceof Text) {
         if (start.column) {
-          actionBox.placeAbove(start.node.splitText(start.column));
+          this._positionActionBox(actionBox, start.line,
+              start.node.splitText(start.column));
         }
         start.node.parentElement.normalize(); // Undo splitText from above.
       } else if (start.node.classList.contains('content') &&
-                 start.node.firstChild) {
-        actionBox.placeAbove(start.node.firstChild);
+          start.node.firstChild) {
+        this._positionActionBox(actionBox, start.line, start.node.firstChild);
       } else {
-        actionBox.placeAbove(start.node);
+        this._positionActionBox(actionBox, start.line, start.node);
       }
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index b63b9a4..4bbf12b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -38,6 +38,27 @@
       <table id="diffTable">
 
         <tbody class="section both">
+           <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="1"></td>
+            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+            <td class="right lineNum" data-value="1"></td>
+            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section delta">
+          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+            <td class="left lineNum" data-value="2"></td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">	</span>quod <hl>Epicurum</hl></div></td>
+            <td class="right lineNum" data-value="2"></td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">	</span> audiam,  sit, quod</div></td>
+          </tr>
+        </tbody>
+
+
+        <tbody class="section both">
           <tr class="diff-row side-by-side" left-type="both" right-type="both">
             <td class="left lineNum" data-value="138"></td>
             <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
@@ -253,6 +274,7 @@
         contentStubs = [];
         stub('gr-selection-action-box', {
           placeAbove: sandbox.stub(),
+          placeBelow: sandbox.stub(),
         });
         diff = element.querySelector('#diffTable');
         builder = {
@@ -270,9 +292,29 @@
         window.getSelection().removeAllRanges();
       });
 
+      test('single first line', () => {
+        const content = stubContent(1, 'right');
+        sandbox.spy(element, '_positionActionBox');
+        emulateSelection(content.firstChild, 5, content.firstChild, 12);
+        const actionBox = element.$$('gr-selection-action-box');
+        assert.isTrue(actionBox.positionBelow);
+      });
+
+      test('multiline starting on first line', () => {
+        const startContent = stubContent(1, 'right');
+        const endContent = stubContent(2, 'right');
+        sandbox.spy(element, '_positionActionBox');
+        emulateSelection(
+            startContent.firstChild, 10, endContent.lastChild, 7);
+        const actionBox = element.$$('gr-selection-action-box');
+        assert.isTrue(actionBox.positionBelow);
+      });
+
       test('single line', () => {
         const content = stubContent(138, 'left');
+        sandbox.spy(element, '_positionActionBox');
         emulateSelection(content.firstChild, 5, content.firstChild, 12);
+        const actionBox = element.$$('gr-selection-action-box');
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
           startLine: 138,
@@ -281,14 +323,18 @@
           endChar: 12,
         });
         assert.equal(getActionSide(), 'left');
+        assert.notOk(actionBox.positionBelow);
       });
 
       test('multiline', () => {
         const startContent = stubContent(119, 'right');
         const endContent = stubContent(120, 'right');
+        sandbox.spy(element, '_positionActionBox');
         emulateSelection(
             startContent.firstChild, 10, endContent.lastChild, 7);
         assert.isTrue(element.isRangeSelected());
+        const actionBox = element.$$('gr-selection-action-box');
+
         assert.deepEqual(getActionRange(), {
           startLine: 119,
           startChar: 10,
@@ -296,6 +342,7 @@
           endChar: 36,
         });
         assert.equal(getActionSide(), 'right');
+        assert.notOk(actionBox.positionBelow);
       });
 
       test('multiple ranges aka firefox implementation', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
index 7e6d54d..ab2077d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
@@ -148,7 +148,7 @@
         <gr-button id="saveButton" link primary on-tap="_handleSave">
             Save</gr-button>
       </div>
-    </overlay>
+    </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
   </template>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
index f5944c3..185b047 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
@@ -45,7 +45,7 @@
     getFocusStops() {
       return {
         start: this.$.contextSelect,
-        end: this.$.cancelButton,
+        end: this.$.saveButton,
       };
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
index bca6bea..230cea9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -104,12 +104,13 @@
      * @return {Promise} A promise that resolves when the diff is completely
      *     processed.
      */
-    process(content, isImageDiff) {
+    process(content, isBinary) {
       this.groups = [];
       this.push('groups', this._makeFileComments());
 
-      // If image diff, only render the file lines.
-      if (isImageDiff) { return Promise.resolve(); }
+      // If it's a binary diff, we won't be rendering hunks of text differences
+      // so finish processing.
+      if (isBinary) { return Promise.resolve(); }
 
       return new Promise(resolve => {
         const state = {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
index 6699979..bc47106 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
@@ -43,7 +43,7 @@
       }
     </style>
     <div class="contentWrapper">
-      <content></content>
+      <slot></slot>
     </div>
   </template>
   <script src="../gr-diff-highlight/gr-range-normalizer.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index f00557e..16c9017 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -179,7 +179,9 @@
           this.diffBuilder.getLineElByChild(range.startContainer);
       const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
       const startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
-      const endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
+      const endLineNum = endLineEl === null ?
+          undefined :
+          parseInt(endLineEl.getAttribute('data-value'), 10);
 
       return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum,
           range.endOffset, side);
@@ -190,7 +192,8 @@
      *
      * @param {number} startLineNum
      * @param {number} startOffset
-     * @param {number} endLineNum
+     * @param {number|undefined} endLineNum Use undefined to get the range
+     *     extending to the end of the file.
      * @param {number} endOffset
      * @param {!string} side The side that is currently selected.
      * @return {string} The selected diff text.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
index a14d155..79b66c1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
@@ -328,6 +328,27 @@
       assert.equal(element._getSelectedText('right'), ' other');
     });
 
+    test('copies to end of side (issue 7895)', () => {
+      element._cachedDiffBuilder.getLineElByChild = function(child) {
+        // Return null for the end container.
+        if (child.textContent === 'ga ga') { return null; }
+        while (!child.classList.contains('content') && child.parentElement) {
+          child = child.parentElement;
+        }
+        return child.previousElementSibling;
+      };
+      element.classList.add('selected-left');
+      element.classList.remove('selected-right');
+      const selection = window.getSelection();
+      selection.removeAllRanges();
+      const range = document.createRange();
+      range.setStart(element.querySelector('div.contentText').firstChild, 3);
+      range.setEnd(
+          element.querySelectorAll('div.contentText')[4].firstChild, 2);
+      selection.addRange(range);
+      assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
+    });
+
     suite('_getTextContentForRange', () => {
       let selection;
       let range;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index a080396..0952ebd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -20,17 +20,20 @@
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-count-string-formatter/gr-count-string-formatter.html">
+<link rel="import" href="../../shared/gr-dropdown-list/gr-dropdown-list.html">
 <link rel="import" href="../../shared/gr-fixed-panel/gr-fixed-panel.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
+<link rel="import" href="../../shared/revision-info/revision-info.html">
 <link rel="import" href="../gr-comment-api/gr-comment-api.html">
 <link rel="import" href="../gr-diff-cursor/gr-diff-cursor.html">
 <link rel="import" href="../gr-diff-preferences/gr-diff-preferences.html">
 <link rel="import" href="../gr-diff/gr-diff.html">
 <link rel="import" href="../gr-patch-range-select/gr-patch-range-select.html">
-<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-diff-view">
   <template>
@@ -43,6 +46,9 @@
       }
       gr-diff {
         border: none;
+        --diff-container-styles: {
+          border-bottom: 1px solid #eee;
+        }
       }
       gr-fixed-panel {
         background-color: #fff;
@@ -67,8 +73,13 @@
         color: #999;
       }
       .navLinks {
+        align-items: center;
+        display: flex;
         white-space: nowrap;
       }
+      .navLink {
+        padding: 0 .25em;
+      }
       .reviewed {
         display: inline-block;
         margin: 0 .25em;
@@ -80,45 +91,6 @@
       .mobile {
         display: none;
       }
-      .dropdown-trigger {
-        cursor: pointer;
-        padding: 0;
-      }
-      iron-dropdown {
-        position: absolute;
-      }
-      .dropdown-content {
-        background-color: #fff;
-        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
-        max-height: 70vh;
-      }
-      .dropdown-content a {
-        cursor: pointer;
-        display: block;
-        font-weight: normal;
-        padding: .3em .5em;
-      }
-      .dropdown-content a:before {
-        color: #ccc;
-        content: attr(data-key-nav);
-        display: inline-block;
-        margin-right: .5em;
-        width: .3em;
-      }
-      .dropdown-content a:hover {
-        background-color: var(--color-link);
-        color: #fff;
-      }
-      .dropdown-content a[selected] {
-        color: #000;
-        font-family: var(--font-family-bold);
-        pointer-events: none;
-        text-decoration: none;
-      }
-      .dropdown-content a[selected]:hover {
-        background-color: #fff;
-        color: #000;
-      }
       gr-button {
         padding: .3em 0;
         text-decoration: none;
@@ -140,29 +112,27 @@
       .prefsButton {
         text-align: right;
       }
-      .separator {
-        margin: 0 .25em;
-      }
       .noOverflow {
         display: block;
         overflow: auto;
       }
-      #trigger {
-        --gr-button: {
-          -moz-user-select: text;
-          -ms-user-select: text;
-          -webkit-user-select: text;
-          user-select: text;
-        }
-      }
       .editLoaded .hideOnEdit {
         display: none;
       }
       .blameLoader {
         display: none;
       }
-      .blameLoader.show {
-        display: inline;
+      .blameLoader.show,
+      .download,
+      .preferences,
+      .rightControls {
+        align-items: center;
+        display: flex;
+      }
+      gr-dropdown-list {
+        --trigger-style: {
+          text-transform: none;
+        }
       }
       @media screen and (max-width: 50em) {
         header {
@@ -192,13 +162,6 @@
         .reviewed {
           vertical-align: -.1em;
         }
-        .mobileJumpToFileContainer {
-          display: block;
-          width: 100%;
-        }
-        .mobileJumpToFileContainer select {
-          width: 100%;
-        }
         .mobileNavLink {
           color: #000;
           font-size: 1.5em;
@@ -208,6 +171,20 @@
         .mobileNavLink:not([href]) {
           color: #bbb;
         }
+        .jumpToFileContainer {
+          display: block;
+          width: 100%;
+        }
+        gr-dropdown-list {
+          width: 100%;
+          --gr-select-style: {
+            display: block;
+            width: 100%;
+          }
+          --native-select-style: {
+            width: 100%;
+          }
+        }
       }
     </style>
     <gr-fixed-panel
@@ -226,56 +203,25 @@
               type="checkbox"
               on-change="_handleReviewedChange"
               hidden$="[[!_loggedIn]]" hidden>
-          <div class="jumpToFileContainer desktop">
-            <gr-button
-                down-arrow
-                no-uppercase
-                link
-                class="dropdown-trigger"
-                id="trigger"
-                on-tap="_showDropdownTapHandler">
-              <span>[[computeDisplayPath(_path)]]</span>
-            </gr-button>
-            <!-- *-align="" to disable iron-dropdown's element positioning. -->
-            <iron-dropdown id="dropdown"
-                allow-outside-scroll
-                vertical-align=""
-                horizontal-align="">
-              <div class="dropdown-content" slot="dropdown-content">
-                <template
-                    is="dom-repeat"
-                    items="[[_fileList]]"
-                    as="path"
-                    initial-count="75">
-                  <a href$="[[_computeDiffURL(_change, _patchRange.*, path)]]"
-                    selected$="[[_computeFileSelected(path, _path)]]"
-                    data-key-nav$="[[_computeKeyNav(path, _path, _fileList)]]"
-                    on-tap="_handleFileTap">[[computeDisplayPath(path)]]</a>
-                </template>
-              </div>
-            </iron-dropdown>
-          </div>
-          <div class="mobileJumpToFileContainer mobile">
-            <select on-change="_handleMobileSelectChange">
-              <template is="dom-repeat" items="[[_fileList]]" as="path">
-                <option
-                    value$="[[path]]"
-                    selected$="[[_computeFileSelected(path, _path)]]">
-                  [[computeTruncatedPath(path)]]
-                </option>
-              </template>
-            </select>
+          <div class="jumpToFileContainer">
+            <gr-dropdown-list
+                id="dropdown"
+                value="[[_path]]"
+                on-value-change="_handleFileChange"
+                items="[[_formattedFiles]]"
+                initial-count="75">
+           </gr-dropdown-list>
           </div>
         </h3>
         <div class="navLinks desktop">
           <a class="navLink"
               href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]">
             Prev</a>
-          /
+          <span class="separator"></span>
           <a class="navLink"
               href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]">
             Up</a>
-          /
+          <span class="separator"></span>
           <a class="navLink"
               href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]">
             Next</a>
@@ -286,15 +232,17 @@
           <gr-patch-range-select
               id="rangeSelect"
               change-num="[[_changeNum]]"
+              change-comments="[[_changeComments]]"
               patch-num="[[_patchRange.patchNum]]"
               base-patch-num="[[_patchRange.basePatchNum]]"
               files-weblinks="[[_filesWeblinks]]"
               available-patches="[[_allPatchSets]]"
               revisions="[[_change.revisions]]"
+              revision-info="[[_revisionInfo]]"
               on-patch-range-change="_handlePatchChange">
           </gr-patch-range-select>
           <span class="download desktop">
-            <span class="separator">/</span>
+            <span class="separator"></span>
             <a
               class="downloadLink"
               download
@@ -303,7 +251,7 @@
             </a>
           </span>
         </div>
-        <div>
+        <div class="rightControls">
           <gr-select
               id="modeSelect"
               bind-value="{{changeViewState.diffMode}}"
@@ -316,15 +264,14 @@
           <span id="diffPrefsContainer"
               hidden$="[[_computePrefsButtonHidden(_prefs, _loggedIn)]]" hidden>
             <span class="preferences desktop">
-              <span
-                  hidden$="[[_computeModeSelectHidden(_isImageDiff)]]">/</span>
+              <span class="separator" hidden$="[[_computeModeSelectHidden(_isImageDiff)]]"></span>
               <gr-button link
                   class="prefsButton"
                   on-tap="_handlePrefsTap">Preferences</gr-button>
             </span>
           </span>
           <span class$="blameLoader [[_computeBlameLoaderClass(_isImageDiff, _isBlameSupported)]]">
-            <span class="separator">/</span>
+            <span class="separator"></span>
             <gr-button
                 link
                 disabled="[[_isBlameLoading]]"
@@ -352,6 +299,7 @@
         is-image-diff="{{_isImageDiff}}"
         files-weblinks="{{_filesWeblinks}}"
         change-num="[[_changeNum]]"
+        commit-range="[[_commitRange]]"
         patch-range="[[_patchRange]]"
         path="[[_path]]"
         prefs="[[_prefs]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index b97d974..de9905a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -63,6 +63,8 @@
       },
       /** @type {?} */
       _patchRange: Object,
+      /** @type {?} */
+      _commitRange: Object,
       /**
        * @type {{
        *  subject: string,
@@ -71,8 +73,18 @@
        * }}
        */
       _change: Object,
+      /** @type {?} */
+      _changeComments: Object,
       _changeNum: String,
       _diff: Object,
+      // An array specifically formatted to be used in a gr-dropdown-list
+      // element for selected a file to view.
+      _formattedFiles: {
+        type: Array,
+        computed: '_formatFilesForDropdown(_fileList, _patchRange.patchNum, ' +
+            '_changeComments)',
+      },
+      // An sorted array of files, as returned by the rest API.
       _fileList: {
         type: Array,
         value() { return []; },
@@ -137,6 +149,10 @@
         type: Array,
         computed: 'computeAllPatchSets(_change, _change.revisions.*)',
       },
+      _revisionInfo: {
+        type: Object,
+        computed: '_getRevisionInfo(_change)',
+      },
     },
 
     behaviors: [
@@ -194,6 +210,7 @@
     _getChangeDetail(changeNum) {
       return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
         this._change = change;
+        return change;
       });
     },
 
@@ -290,17 +307,30 @@
     },
 
     _moveToPreviousFileWithComment() {
-      if (this._commentSkips && this._commentSkips.previous) {
-        Gerrit.Nav.navigateToDiff(this._change, this._commentSkips.previous,
-            this._patchRange.patchNum, this._patchRange.basePatchNum);
+      if (!this._commentSkips) { return; }
+
+      // If there is no previous diff with comments, then return to the change
+      // view.
+      if (!this._commentSkips.previous) {
+        this._navToChangeView();
+        return;
       }
+
+      Gerrit.Nav.navigateToDiff(this._change, this._commentSkips.previous,
+          this._patchRange.patchNum, this._patchRange.basePatchNum);
     },
 
     _moveToNextFileWithComment() {
-      if (this._commentSkips && this._commentSkips.next) {
-        Gerrit.Nav.navigateToDiff(this._change, this._commentSkips.next,
-            this._patchRange.patchNum, this._patchRange.basePatchNum);
+      if (!this._commentSkips) { return; }
+
+      // If there is no next diff with comments, then return to the change view.
+      if (!this._commentSkips.next) {
+        this._navToChangeView();
+        return;
       }
+
+      Gerrit.Nav.navigateToDiff(this._change, this._commentSkips.next,
+          this._patchRange.patchNum, this._patchRange.basePatchNum);
     },
 
     _handleCKey(e) {
@@ -517,7 +547,20 @@
         this._userPrefs = prefs;
       }));
 
-      promises.push(this._getChangeDetail(this._changeNum));
+      promises.push(this._getChangeDetail(this._changeNum).then(change => {
+        let commit;
+        let baseCommit;
+        for (const k in change.revisions) {
+          if (!change.revisions.hasOwnProperty(k)) continue;
+          const patchNum = change.revisions[k]._number.toString();
+          if (patchNum === this._patchRange.patchNum) {
+            commit = k;
+          } else if (patchNum === this._patchRange.basePatchNum) {
+            baseCommit = k;
+          }
+        }
+        this._commitRange = {commit, baseCommit};
+      }));
 
       promises.push(this._loadComments());
 
@@ -584,10 +627,6 @@
           patchRange.basePatchNum);
     },
 
-    _computeDiffURL(change, patchRangeRecord, path) {
-      return this._getDiffUrl(change, patchRangeRecord.base, path);
-    },
-
     _patchRangeStr(patchRange) {
       let patchStr = patchRange.patchNum;
       if (patchRange.basePatchNum != null &&
@@ -636,23 +675,50 @@
       return this._getChangePath(change, patchRangeRecord.base, revisions);
     },
 
-    _computeFileSelected(path, currentPath) {
-      return path == currentPath;
+    _formatFilesForDropdown(fileList, patchNum, changeComments) {
+      if (!fileList) { return; }
+      const dropdownContent = [];
+      for (const path of fileList) {
+        dropdownContent.push({
+          text: this.computeDisplayPath(path),
+          mobileText: this.computeTruncatedPath(path),
+          value: path,
+          bottomText: this._computeCommentString(changeComments, patchNum,
+              path),
+        });
+      }
+      return dropdownContent;
+    },
+
+    _computeCommentString(changeComments, patchNum, path) {
+      const unresolvedCount = changeComments.computeUnresolvedNum(patchNum,
+          path);
+      const commentCount = changeComments.computeCommentCount(patchNum, path);
+      const commentString = GrCountStringFormatter.computePluralString(
+          commentCount, 'comment');
+      const unresolvedString = GrCountStringFormatter.computeString(
+          unresolvedCount, 'unresolved');
+
+      return commentString +
+          // Add a space if both comments and unresolved
+          (commentString && unresolvedString ? ', ' : '') +
+          // Add parentheses around unresolved if it exists.
+          (unresolvedString ? `${unresolvedString}` : '');
     },
 
     _computePrefsButtonHidden(prefs, loggedIn) {
       return !loggedIn || !prefs;
     },
 
-    _computeKeyNav(path, selectedPath, fileList) {
-      const selectedIndex = fileList.indexOf(selectedPath);
-      if (fileList.indexOf(path) == selectedIndex - 1) {
-        return '[';
+    _handleFileChange(e) {
+      // This is when it gets set initially.
+      const path = e.detail.value;
+      if (path === this._path) {
+        return;
       }
-      if (fileList.indexOf(path) == selectedIndex + 1) {
-        return ']';
-      }
-      return '';
+
+      Gerrit.Nav.navigateToDiff(this._change, path, this._patchRange.patchNum,
+          this._patchRange.basePatchNum);
     },
 
     _handleFileTap(e) {
@@ -663,16 +729,6 @@
       }, 1);
     },
 
-    _handleMobileSelectChange(e) {
-      const path = Polymer.dom(e).rootTarget.value;
-      Gerrit.Nav.navigateToDiff(this._change, path, this._patchRange.patchNum,
-          this._patchRange.basePatchNum);
-    },
-
-    _showDropdownTapHandler(e) {
-      this.$.dropdown.open();
-    },
-
     _handlePatchChange(e) {
       const {basePatchNum, patchNum} = e.detail;
       if (this.patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
@@ -749,14 +805,24 @@
     },
 
     _loadComments() {
-      return this.$.commentAPI.loadAll(this._changeNum).then(() => {
-        this._commentMap = this.$.commentAPI.getPaths(this._patchRange);
+      return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
+        this._changeComments = comments;
+        this._commentMap = this._getPaths(this._patchRange);
 
-        this._commentsForDiff = this.$.commentAPI.getCommentsForPath(this._path,
+        this._commentsForDiff = this._getCommentsForPath(this._path,
             this._patchRange, this._projectConfig);
       });
     },
 
+    _getPaths(patchRange) {
+      return this._changeComments.getPaths(patchRange);
+    },
+
+    _getCommentsForPath(path, patchRange, projectConfig) {
+      return this._changeComments.getCommentsBySideForPath(path, patchRange,
+          projectConfig);
+    },
+
     _getDiffDrafts() {
       return this.$.restAPI.getDiffDrafts(this._changeNum);
     },
@@ -836,5 +902,9 @@
     _computeBlameLoaderClass(isImageDiff, supported) {
       return !isImageDiff && supported ? 'show' : '';
     },
+
+    _getRevisionInfo(change) {
+      return new Gerrit.RevisionInfo(change);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index fa37eb9..979f253 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -47,19 +47,24 @@
 
     const PARENT = 'PARENT';
 
-    setup(() => {
+    setup(done => {
       sandbox = sinon.sandbox.create();
 
       stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({change: {}}); },
         getLoggedIn() { return Promise.resolve(false); },
         getProjectConfig() { return Promise.resolve({}); },
         getDiffChangeDetail() { return Promise.resolve(null); },
         getChangeFiles() { return Promise.resolve({}); },
         saveFileReviewed() { return Promise.resolve(); },
+        getDiffComments() { return Promise.resolve({}); },
         getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve(); },
+        getDiffDrafts() { return Promise.resolve({}); },
       });
       element = fixture('basic');
+      element._loadComments().then(() => {
+        done();
+      });
     });
 
     teardown(() => {
@@ -81,7 +86,7 @@
       element._change = {
         _number: 42,
         revisions: {
-          a: {_number: 10},
+          a: {_number: 10, commit: {parents: []}},
         },
       };
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
@@ -166,8 +171,8 @@
       element._change = {
         _number: 42,
         revisions: {
-          a: {_number: 10},
-          b: {_number: 5},
+          a: {_number: 10, commit: {parents: []}},
+          b: {_number: 5, commit: {parents: []}},
         },
       };
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
@@ -230,8 +235,8 @@
       element._change = {
         _number: 42,
         revisions: {
-          a: {_number: 1},
-          b: {_number: 2},
+          a: {_number: 1, commit: {parents: []}},
+          b: {_number: 2, commit: {parents: []}},
         },
       };
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
@@ -311,6 +316,34 @@
       assert.isTrue(overlayOpenStub.called);
     });
 
+    test('_computeCommentString', done => {
+      loadCommentSpy = sandbox.spy(element.$.commentAPI, 'loadAll');
+      const path = '/test';
+      element.$.commentAPI.loadAll().then(comments => {
+        const commentCountStub =
+            sandbox.stub(comments, 'computeCommentCount');
+        const unresolvedCountStub =
+            sandbox.stub(comments, 'computeUnresolvedNum');
+        commentCountStub.withArgs(1, path).returns(0);
+        commentCountStub.withArgs(2, path).returns(1);
+        commentCountStub.withArgs(3, path).returns(2);
+        commentCountStub.withArgs(4, path).returns(0);
+        unresolvedCountStub.withArgs(1, path).returns(1);
+        unresolvedCountStub.withArgs(2, path).returns(0);
+        unresolvedCountStub.withArgs(3, path).returns(2);
+        unresolvedCountStub.withArgs(4, path).returns(0);
+
+        assert.equal(element._computeCommentString(comments, 1, path),
+            '1 unresolved');
+        assert.equal(element._computeCommentString(comments, 2, path),
+            '1 comment');
+        assert.equal(element._computeCommentString(comments, 3, path),
+            '2 comments, 2 unresolved');
+        assert.equal(element._computeCommentString(comments, 4, path), '');
+        done();
+      });
+    });
+
     suite('url params', () => {
       setup(() => {
         sandbox.stub(Gerrit.Nav, 'getUrlForDiff', (c, p, pn, bpn) => {
@@ -321,54 +354,49 @@
         });
       });
 
-      test('jump to file dropdown', () => {
+      test('_formattedFiles', () => {
         element._changeNum = '42';
         element._patchRange = {
           basePatchNum: PARENT,
           patchNum: '10',
         };
         element._change = {_number: 42};
-        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md',
+          '/COMMIT_MSG', '/MERGE_LIST'];
         element._path = 'glados.txt';
-        flushAsynchronousOperations();
-        const linkEls =
-            Polymer.dom(element.root).querySelectorAll('.dropdown-content > a');
-        assert.equal(linkEls.length, 3);
-        assert.isFalse(linkEls[0].hasAttribute('selected'));
-        assert.isTrue(linkEls[1].hasAttribute('selected'));
-        assert.isFalse(linkEls[2].hasAttribute('selected'));
-        assert.equal(linkEls[0].getAttribute('data-key-nav'), '[');
-        assert.equal(linkEls[1].getAttribute('data-key-nav'), '');
-        assert.equal(linkEls[2].getAttribute('data-key-nav'), ']');
-        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-PARENT');
-        assert.equal(linkEls[1].getAttribute('href'),
-            '42-glados.txt-10-PARENT');
-        assert.equal(linkEls[2].getAttribute('href'),
-            '42-wheatley.md-10-PARENT');
-      });
+        const expectedFormattedFiles = [
+          {
+            text: 'chell.go',
+            mobileText: 'chell.go',
+            value: 'chell.go',
+            bottomText: '',
+          }, {
+            text: 'glados.txt',
+            mobileText: 'glados.txt',
+            value: 'glados.txt',
+            bottomText: '',
+          }, {
+            text: 'wheatley.md',
+            mobileText: 'wheatley.md',
+            value: 'wheatley.md',
+            bottomText: '',
+          },
+          {
+            text: 'Commit message',
+            mobileText: 'Commit message',
+            value: '/COMMIT_MSG',
+            bottomText: '',
+          },
+          {
+            text: 'Merge list',
+            mobileText: 'Merge list',
+            value: '/MERGE_LIST',
+            bottomText: '',
+          },
+        ];
 
-      test('jump to file dropdown with patch range', () => {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: '5',
-          patchNum: '10',
-        };
-        element._change = {_number: 42};
-        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
-        element._path = 'glados.txt';
-        flushAsynchronousOperations();
-        const linkEls =
-            Polymer.dom(element.root).querySelectorAll('.dropdown-content > a');
-        assert.equal(linkEls.length, 3);
-        assert.isFalse(linkEls[0].hasAttribute('selected'));
-        assert.isTrue(linkEls[1].hasAttribute('selected'));
-        assert.isFalse(linkEls[2].hasAttribute('selected'));
-        assert.equal(linkEls[0].getAttribute('data-key-nav'), '[');
-        assert.equal(linkEls[1].getAttribute('data-key-nav'), '');
-        assert.equal(linkEls[2].getAttribute('data-key-nav'), ']');
-        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-5');
-        assert.equal(linkEls[1].getAttribute('href'), '42-glados.txt-10-5');
-        assert.equal(linkEls[2].getAttribute('href'), '42-wheatley.md-10-5');
+        assert.deepEqual(element._formattedFiles, expectedFormattedFiles);
+        assert.equal(element._formattedFiles[1].value, element._path);
       });
 
       test('prev/up/next links', () => {
@@ -380,7 +408,7 @@
         element._change = {
           _number: 42,
           revisions: {
-            a: {_number: 10},
+            a: {_number: 10, commit: {parents: []}},
           },
         };
         element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
@@ -421,8 +449,8 @@
         element._change = {
           _number: 42,
           revisions: {
-            a: {_number: 5},
-            b: {_number: 10},
+            a: {_number: 5, commit: {parents: []}},
+            b: {_number: 10, commit: {parents: []}},
           },
         };
         element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
@@ -484,9 +512,6 @@
     });
 
     test('file review status', done => {
-      stub('gr-rest-api-interface', {
-        getDiffComments() { return Promise.resolve({}); },
-      });
       const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
           () => Promise.resolve());
       sandbox.stub(element.$.diff, 'reload');
@@ -529,9 +554,6 @@
     });
 
     test('hash is determined from params', done => {
-      stub('gr-rest-api-interface', {
-        getDiffComments() { return Promise.resolve({}); },
-      });
       sandbox.stub(element.$.diff, 'reload');
       sandbox.stub(element, '_initCursor');
 
@@ -679,11 +701,6 @@
 
     suite('_loadComments', () => {
       test('empty', done => {
-        stub('gr-comment-api', {
-          loadAll() { return Promise.resolve(); },
-          getPaths() { return {}; },
-          getCommentsForPath() { return {meta: {}}; },
-        });
         element._loadComments().then(() => {
           assert.equal(Object.keys(element._commentMap).length, 0);
           done();
@@ -691,16 +708,11 @@
       });
 
       test('has paths', done => {
-        stub('gr-comment-api', {
-          loadAll() { return Promise.resolve(); },
-          getPaths() {
-            return {
-              'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
-              'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
-            };
-          },
-          getCommentsForPath() { return {meta: {}}; },
+        sandbox.stub(element, '_getPaths').returns({
+          'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
+          'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
         });
+        sandbox.stub(element, '_getCommentsForPath').returns({meta: {}});
         element._changeNum = '42';
         element._patchRange = {
           basePatchNum: '3',
@@ -757,6 +769,88 @@
         assert.equal(result.previous, fileList[1]);
         assert.isNull(result.next);
       });
+
+      suite('skip next/previous', () => {
+        let navToChangeStub;
+        let navToDiffStub;
+
+        setup(() => {
+          navToChangeStub = sandbox.stub(element, '_navToChangeView');
+          navToDiffStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+          element._fileList = [
+            'path/one.jpg', 'path/two.m4v', 'path/three.wav',
+          ];
+          element._patchRange = {patchNum: '2', basePatchNum: '1'};
+        });
+
+        suite('_moveToPreviousFileWithComment', () => {
+          test('no skips', () => {
+            element._moveToPreviousFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('no previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = false;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = true;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToPreviousFileWithComment();
+            assert.isTrue(navToChangeStub.calledOnce);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('w/ previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = true;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = true;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToPreviousFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isTrue(navToDiffStub.calledOnce);
+          });
+        });
+
+        suite('_moveToNextFileWithComment', () => {
+          test('no skips', () => {
+            element._moveToNextFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('no previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = true;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = false;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToNextFileWithComment();
+            assert.isTrue(navToChangeStub.calledOnce);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('w/ previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = true;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = true;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToNextFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isTrue(navToDiffStub.calledOnce);
+          });
+        });
+      });
     });
 
     test('_computeEditLoaded', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index f32234a..532b928 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -15,8 +15,8 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-diff-builder/gr-diff-builder.html">
@@ -24,7 +24,6 @@
 <link rel="import" href="../gr-diff-highlight/gr-diff-highlight.html">
 <link rel="import" href="../gr-diff-selection/gr-diff-selection.html">
 <link rel="import" href="../gr-syntax-themes/gr-theme-default.html">
-<link rel="import" href="../../../styles/shared-styles.html">
 
 <script src="../../../scripts/hiddenscroll.js"></script>
 
@@ -48,10 +47,9 @@
         display: none;
       }
       .diffContainer {
-        border-bottom: 1px solid #eee;
-        border-top: 1px solid #eee;
         display: flex;
         font: 12px var(--monospace-font-family);
+        @apply --diff-container-styles;
       }
       .diffContainer.hiddenscroll {
         padding-bottom: .8em;
@@ -71,7 +69,8 @@
         max-width: 50em;
         outline: 1px solid #ccc;
       }
-      .image-diff label {
+      .image-diff label,
+      .binary-diff label {
         font-family: var(--font-family);
         font-style: italic;
       }
@@ -266,11 +265,14 @@
               project-name="[[projectName]]"
               diff="[[_diff]]"
               diff-path="[[path]]"
+              change-num="[[changeNum]]"
+              patch-num="[[patchRange.patchNum]]"
               view-mode="[[viewMode]]"
               line-wrapping="[[lineWrapping]]"
               is-image-diff="[[isImageDiff]]"
               base-image="[[_baseImage]]"
-              revision-image="[[_revisionImage]]">
+              revision-image="[[_revisionImage]]"
+              parent-index="[[_parentIndex]]">
             <table
                 id="diffTable"
                 class$="[[_diffTableClass]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index c3add28..75ed70c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -52,6 +52,7 @@
         type: Boolean,
         value: false,
       },
+      /** @type {?} */
       patchRange: Object,
       path: String,
       prefs: {
@@ -72,6 +73,7 @@
         computed: '_computeIsImageDiff(_diff)',
         notify: true,
       },
+      commitRange: Object,
       filesWeblinks: {
         type: Object,
         value() { return {}; },
@@ -137,6 +139,11 @@
         notify: true,
         computed: '_computeIsBlameLoaded(_blame)',
       },
+
+      _parentIndex: {
+        type: Number,
+        computed: '_computeParentIndex(patchRange.*)',
+      },
     },
 
     behaviors: [
@@ -169,7 +176,7 @@
       this.clearBlame();
       this._safetyBypass = null;
       this._showWarning = false;
-      this._clearDiffContent();
+      this.clearDiffContent();
 
       const promises = [];
 
@@ -415,12 +422,27 @@
       return threadEl;
     },
 
-    /** @return {number} */
+    /**
+     * The value to be used for the patch number of new comments created at the
+     * given line and content elements.
+     *
+     * In two cases of creating a comment on the left side, the patch number to
+     * be used should actually be right side of the patch range:
+     * - When the patch range is against the parent comment of a normal change.
+     *   Such comments declare themmselves to be on the left using side=PARENT.
+     * - If the patch range is against the indexed parent of a merge change.
+     *   Such comments declare themselves to be on the given parent by
+     *   specifying the parent index via parent=i.
+     *
+     * @return {number}
+     */
     _getPatchNumByLineAndContent(lineEl, contentEl) {
       let patchNum = this.patchRange.patchNum;
+
       if ((lineEl.classList.contains(DiffSide.LEFT) ||
           contentEl.classList.contains('remove')) &&
-          this.patchRange.basePatchNum !== 'PARENT') {
+          this.patchRange.basePatchNum !== 'PARENT' &&
+          !this.isMergeParent(this.patchRange.basePatchNum)) {
         patchNum = this.patchRange.basePatchNum;
       }
       return patchNum;
@@ -428,13 +450,13 @@
 
     /** @return {boolean} */
     _getIsParentCommentByLineAndContent(lineEl, contentEl) {
-      let isOnParent = false;
       if ((lineEl.classList.contains(DiffSide.LEFT) ||
           contentEl.classList.contains('remove')) &&
-          this.patchRange.basePatchNum === 'PARENT') {
-        isOnParent = true;
+          (this.patchRange.basePatchNum === 'PARENT' ||
+          this.isMergeParent(this.patchRange.basePatchNum))) {
+        return true;
       }
-      return isOnParent;
+      return false;
     },
 
     /** @return {string} */
@@ -593,7 +615,7 @@
       return this.prefs;
     },
 
-    _clearDiffContent() {
+    clearDiffContent() {
       this.$.diffTable.innerHTML = null;
     },
 
@@ -615,9 +637,17 @@
           this.patchRange.patchNum,
           this.path,
           this._handleGetDiffError.bind(this)).then(diff => {
+            if (!this.commitRange) {
+              this.filesWeblinks = {};
+              return diff;
+            }
             this.filesWeblinks = {
-              meta_a: diff && diff.meta_a && diff.meta_a.web_links,
-              meta_b: diff && diff.meta_b && diff.meta_b.web_links,
+              meta_a: Gerrit.Nav.getFileWebLinks(
+                  this.projectName, this.commitRange.commit, this.path,
+                  {weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
+              meta_b: Gerrit.Nav.getFileWebLinks(
+                  this.projectName, this.commitRange.baseCommit, this.path,
+                  {weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
             };
             return diff;
           });
@@ -717,5 +747,15 @@
     _computeWarningClass(showWarning) {
       return showWarning ? 'warn' : '';
     },
+
+    /**
+     * @return {number|null}
+     */
+    _computeParentIndex(patchRangeRecord) {
+      if (!this.isMergeParent(patchRangeRecord.base.basePatchNum)) {
+        return null;
+      }
+      return this.getParentIndex(patchRangeRecord.base.basePatchNum);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index e422354..8d1626b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -64,6 +64,92 @@
       assert.equal(element._diffLength(mock.diffResponse), 52);
     });
 
+
+    suite('_get{PatchNum|IsParentComment}ByLineAndContent', () => {
+      let lineEl;
+      let contentEl;
+
+      setup(() => {
+        element = fixture('basic');
+        lineEl = document.createElement('td');
+        contentEl = document.createElement('span');
+      });
+
+      suite('_getPatchNumByLineAndContent', () => {
+        test('right side', () => {
+          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+          lineEl.classList.add('right');
+          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+              4);
+        });
+
+        test('left side parent by linenum', () => {
+          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+          lineEl.classList.add('left');
+          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+              4);
+        });
+
+        test('left side parent by content', () => {
+          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+          contentEl.classList.add('remove');
+          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+              4);
+        });
+
+        test('left side merge parent', () => {
+          element.patchRange = {patchNum: 4, basePatchNum: -2};
+          contentEl.classList.add('remove');
+          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+              4);
+        });
+
+        test('left side non parent', () => {
+          element.patchRange = {patchNum: 4, basePatchNum: 3};
+          contentEl.classList.add('remove');
+          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+              3);
+        });
+      });
+
+      suite('_getIsParentCommentByLineAndContent', () => {
+        test('right side', () => {
+          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+          lineEl.classList.add('right');
+          assert.isFalse(
+              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
+        });
+
+        test('left side parent by linenum', () => {
+          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+          lineEl.classList.add('left');
+          assert.isTrue(
+              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
+        });
+
+        test('left side parent by content', () => {
+          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+          contentEl.classList.add('remove');
+          assert.isTrue(
+              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
+        });
+
+        test('left side merge parent', () => {
+          element.patchRange = {patchNum: 4, basePatchNum: -2};
+          contentEl.classList.add('remove');
+          assert.isTrue(
+              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
+        });
+
+        test('left side non parent', () => {
+          element.patchRange = {patchNum: 4, basePatchNum: 3};
+          contentEl.classList.add('remove');
+          assert.isFalse(
+              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
+        });
+      });
+    });
+
     suite('not logged in', () => {
       setup(() => {
         stub('gr-rest-api-interface', {
@@ -103,23 +189,18 @@
             element.$$('.diffContainer').classList.contains('displayLine'));
       });
 
-      test('loads files weblinks', done => {
-        sandbox.stub(element.$.restAPI, 'getDiff').returns(
-            Promise.resolve({
-              meta_a: {
-                web_links: 'foo',
-              },
-              meta_b: {
-                web_links: 'bar',
-              },
-            }));
+      test('loads files weblinks', () => {
+        const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
+            .returns([{name: 'stubb', url: '#s'}]);
+        sandbox.stub(element.$.restAPI, 'getDiff').returns(Promise.resolve({}));
+        element.commitRange = {};
         element.patchRange = {};
-        element._getDiff().then(() => {
+        return element._getDiff().then(() => {
+          assert.isTrue(weblinksStub.called);
           assert.deepEqual(element.filesWeblinks, {
-            meta_a: 'foo',
-            meta_b: 'bar',
+            meta_a: [{name: 'stubb', url: '#s'}],
+            meta_b: [{name: 'stubb', url: '#s'}],
           });
-          done();
         });
       });
 
@@ -396,7 +477,7 @@
               assert.isOk(leftImage);
               assert.equal(leftImage.getAttribute('src'),
                   'data:image/bmp;base64, ' + mockFile1.body);
-              assert.equal(leftLabelContent.textContent, '1⨉1 image/bmp');
+              assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
               leftLoaded = true;
               if (rightLoaded) {
                 element.removeEventListener('render', rendered);
@@ -408,7 +489,7 @@
               assert.isOk(rightImage);
               assert.equal(rightImage.getAttribute('src'),
                   'data:image/bmp;base64, ' + mockFile2.body);
-              assert.equal(rightLabelContent.textContent, '1⨉1 image/bmp');
+              assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
 
               rightLoaded = true;
               if (leftLoaded) {
@@ -478,7 +559,7 @@
               assert.isOk(leftImage);
               assert.equal(leftImage.getAttribute('src'),
                   'data:image/bmp;base64, ' + mockFile1.body);
-              assert.equal(leftLabelContent.textContent, '1⨉1 image/bmp');
+              assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
               leftLoaded = true;
               if (rightLoaded) {
                 element.removeEventListener('render', rendered);
@@ -490,7 +571,7 @@
               assert.isOk(rightImage);
               assert.equal(rightImage.getAttribute('src'),
                   'data:image/bmp;base64, ' + mockFile2.body);
-              assert.equal(rightLabelContent.textContent, '1⨉1 image/bmp');
+              assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
 
               rightLoaded = true;
               if (leftLoaded) {
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
index f532e3f..7fd97ad 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
@@ -18,7 +18,7 @@
 <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="../../shared/gr-dropdown-list/gr-dropdown-list.html">
-
+<link rel="import" href="../../shared/gr-count-string-formatter/gr-count-string-formatter.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
 
 <dom-module id="gr-patch-range-select">
@@ -47,6 +47,14 @@
         .filesWeblinks {
           display: none;
         }
+        gr-dropdown-list {
+          --native-select-style: {
+            max-width: 5.25em;
+          }
+          --dropdown-content-stype: {
+            max-width: 300px;
+          }
+        }
       }
     </style>
     <span class="patchRange">
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index a8314e7..40259c6 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -34,26 +34,21 @@
       _baseDropdownContent: {
         type: Object,
         computed: '_computeBaseDropdownContent(availablePatches, patchNum,' +
-            '_sortedRevisions, revisions, comments)',
+            '_sortedRevisions, changeComments, revisionInfo)',
       },
       _patchDropdownContent: {
         type: Object,
         computed: '_computePatchDropdownContent(availablePatches,' +
-            'basePatchNum, _sortedRevisions, revisions, comments)',
+            'basePatchNum, _sortedRevisions, changeComments)',
       },
       changeNum: String,
-      // In the case of a patch range select (like diff view) comments should
-      // be an empty array, so that the patch and base content computed values
-      // get triggered.
-      comments: {
-        type: Object,
-        value: () => { return {}; },
-      },
+      changeComments: Object,
       /** @type {{ meta_a: !Array, meta_b: !Array}} */
       filesWeblinks: Object,
       patchNum: String,
       basePatchNum: String,
       revisions: Object,
+      revisionInfo: Object,
       _sortedRevisions: Array,
     },
 
@@ -64,135 +59,150 @@
     behaviors: [Gerrit.PatchSetBehavior],
 
     _computeBaseDropdownContent(availablePatches, patchNum, _sortedRevisions,
-        revisions, comments) {
+        changeComments, revisionInfo) {
+      const parentCounts = revisionInfo.getParentCountMap();
+      const currentParentCount = parentCounts.hasOwnProperty(patchNum) ?
+          parentCounts[patchNum] : 1;
+      const maxParents = revisionInfo.getMaxParents();
+      const isMerge = currentParentCount > 1;
+
       const dropdownContent = [];
-      dropdownContent.push({
-        text: 'Base',
-        value: 'PARENT',
-      });
       for (const basePatch of availablePatches) {
         const basePatchNum = basePatch.num;
-        dropdownContent.push({
+        const entry = this._createDropdownEntry(basePatchNum, 'Patchset ',
+            _sortedRevisions, changeComments);
+        dropdownContent.push(Object.assign({}, entry, {
           disabled: this._computeLeftDisabled(
               basePatch.num, patchNum, _sortedRevisions),
-          triggerText: `Patchset ${basePatchNum}`,
-          text: `Patchset ${basePatchNum}` +
-              this._computePatchSetCommentsString(this.comments, basePatchNum),
-          mobileText: this._computeMobileText(basePatchNum, comments,
-              revisions),
-          bottomText: `${this._computePatchSetDescription(
-              revisions, basePatchNum)}`,
-          value: basePatch.num,
+        }));
+      }
+
+      dropdownContent.push({
+        text: isMerge ? 'Auto Merge' : 'Base',
+        value: 'PARENT',
+      });
+
+      for (let idx = 0; isMerge && idx < maxParents; idx++) {
+        dropdownContent.push({
+          disabled: idx >= currentParentCount,
+          triggerText: `Parent ${idx + 1}`,
+          text: `Parent ${idx + 1}`,
+          mobileText: `Parent ${idx + 1}`,
+          value: -(idx + 1),
         });
       }
+
       return dropdownContent;
     },
 
-    _computeMobileText(patchNum, comments, revisions) {
+    _computeMobileText(patchNum, changeComments, revisions) {
       return `${patchNum}` +
-          `${this._computePatchSetCommentsString(this.comments, patchNum)}` +
+          `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
           `${this._computePatchSetDescription(revisions, patchNum, true)}`;
     },
 
     _computePatchDropdownContent(availablePatches, basePatchNum,
-        _sortedRevisions, revisions, comments) {
+        _sortedRevisions, changeComments) {
       const dropdownContent = [];
       for (const patch of availablePatches) {
         const patchNum = patch.num;
-        dropdownContent.push({
-          disabled: this._computeRightDisabled(patchNum, basePatchNum,
+        const entry = this._createDropdownEntry(
+            patchNum, patchNum === 'edit' ? '' : 'Patchset ', _sortedRevisions,
+            changeComments);
+        dropdownContent.push(Object.assign({}, entry, {
+          disabled: this._computeRightDisabled(basePatchNum, patchNum,
               _sortedRevisions),
-          triggerText: `${patchNum === 'edit' ? '': 'Patchset '}` +
-              patchNum,
-          text: `${patchNum === 'edit' ? '': 'Patchset '}${patchNum}` +
-              `${this._computePatchSetCommentsString(
-                  this.comments, patchNum)}`,
-          mobileText: this._computeMobileText(patchNum, comments, revisions),
-          bottomText: `${this._computePatchSetDescription(
-              revisions, patchNum)}`,
-          value: patchNum,
-        });
+        }));
       }
       return dropdownContent;
     },
 
+    _createDropdownEntry(patchNum, prefix, sortedRevisions, changeComments) {
+      const entry = {
+        triggerText: `${prefix}${patchNum}`,
+        text: `${prefix}${patchNum}` +
+            `${this._computePatchSetCommentsString(
+                changeComments, patchNum)}`,
+        mobileText: this._computeMobileText(patchNum, changeComments,
+            sortedRevisions),
+        bottomText: `${this._computePatchSetDescription(
+            sortedRevisions, patchNum)}`,
+        value: patchNum,
+      };
+      const date = this._computePatchSetDate(sortedRevisions, patchNum);
+      if (date) {
+        entry['date'] = date;
+      }
+      return entry;
+    },
+
     _updateSortedRevisions(revisionsRecord) {
       const revisions = revisionsRecord.base;
       this._sortedRevisions = this.sortRevisions(Object.values(revisions));
     },
 
+    /**
+     * The basePatchNum should always be <= patchNum -- because sortedRevisions
+     * is sorted in reverse order (higher patchset nums first), invalid base
+     * patch nums have an index greater than the index of patchNum.
+     * @param {number|string} basePatchNum The possible base patch num.
+     * @param {number|string} patchNum The current selected patch num.
+     * @param {!Array} sortedRevisions
+     */
     _computeLeftDisabled(basePatchNum, patchNum, sortedRevisions) {
-      return this.findSortedIndex(basePatchNum, sortedRevisions) >=
+      return this.findSortedIndex(basePatchNum, sortedRevisions) <=
           this.findSortedIndex(patchNum, sortedRevisions);
     },
 
-    _computeRightDisabled(patchNum, basePatchNum, sortedRevisions) {
-      if (basePatchNum == 'PARENT') { return false; }
+    /**
+     * The basePatchNum should always be <= patchNum -- because sortedRevisions
+     * is sorted in reverse order (higher patchset nums first), invalid patch
+     * nums have an index greater than the index of basePatchNum.
+     *
+     * In addition, if the current basePatchNum is 'PARENT', all patchNums are
+     * valid.
+     *
+     * If the curent basePatchNum is a parent index, then only patches that have
+     * at least that many parents are valid.
+     *
+     * @param {number|string} basePatchNum The current selected base patch num.
+     * @param {number|string} patchNum The possible patch num.
+     * @param {!Array} sortedRevisions
+     * @return {boolean}
+     */
+    _computeRightDisabled(basePatchNum, patchNum, sortedRevisions) {
+      if (this.patchNumEquals(basePatchNum, 'PARENT')) { return false; }
 
-      return this.findSortedIndex(patchNum, sortedRevisions) <=
-          this.findSortedIndex(basePatchNum, sortedRevisions);
-    },
-
-    // Copied from gr-file-list
-    // @todo(beckysiegel) clean up.
-    _getCommentsForPath(comments, patchNum, path) {
-      return (comments[path] || []).filter(c => {
-        return this.patchNumEquals(c.patch_set, patchNum);
-      });
-    },
-
-    // Copied from gr-file-list
-    // @todo(beckysiegel) clean up.
-    _computeUnresolvedNum(comments, drafts, patchNum, path) {
-      comments = this._getCommentsForPath(comments, patchNum, path);
-      drafts = this._getCommentsForPath(drafts, patchNum, path);
-      comments = comments.concat(drafts);
-
-      // Create an object where every comment ID is the key of an unresolved
-      // comment.
-
-      const idMap = comments.reduce((acc, comment) => {
-        if (comment.unresolved) {
-          acc[comment.id] = true;
-        }
-        return acc;
-      }, {});
-
-      // Set false for the comments that are marked as parents.
-      for (const comment of comments) {
-        idMap[comment.in_reply_to] = false;
+      if (this.isMergeParent(basePatchNum)) {
+        // Note: parent indices use 1-offset.
+        return this.revisionInfo.getParentCount(patchNum) <
+            this.getParentIndex(basePatchNum);
       }
 
-      // The unresolved comments are the comments that still have true.
-      const unresolvedLeaves = Object.keys(idMap).filter(key => {
-        return idMap[key];
-      });
-
-      return unresolvedLeaves.length;
+      return this.findSortedIndex(basePatchNum, sortedRevisions) <=
+          this.findSortedIndex(patchNum, sortedRevisions);
     },
 
-    _computePatchSetCommentsString(allComments, patchNum) {
-      // todo (beckysiegel) get comment strings for diff view also.
-      if (!allComments) { return ''; }
-      let numComments = 0;
-      let numUnresolved = 0;
-      for (const file in allComments) {
-        if (allComments.hasOwnProperty(file)) {
-          numComments += this._getCommentsForPath(
-              allComments, patchNum, file).length;
-          numUnresolved += this._computeUnresolvedNum(
-              allComments, {}, patchNum, file);
-        }
+
+    _computePatchSetCommentsString(changeComments, patchNum) {
+      if (!changeComments) { return; }
+
+      const commentCount = changeComments.computeCommentCount(patchNum);
+      const commentString = GrCountStringFormatter.computePluralString(
+          commentCount, 'comment');
+
+      const unresolvedCount = changeComments.computeUnresolvedNum(patchNum);
+      const unresolvedString = GrCountStringFormatter.computeString(
+          unresolvedCount, 'unresolved');
+
+      if (!commentString.length && !unresolvedString.length) {
+        return '';
       }
-      let commentsStr = '';
-      if (numComments > 0) {
-        commentsStr = ' (' + numComments + ' comments';
-        if (numUnresolved > 0) {
-          commentsStr += ', ' + numUnresolved + ' unresolved';
-        }
-        commentsStr += ')';
-      }
-      return commentsStr;
+
+      return ` (${commentString}` +
+          // Add a comma + space if both comments and unresolved
+          (commentString && unresolvedString ? ', ' : '') +
+          `${unresolvedString})`;
     },
 
     /**
@@ -208,6 +218,15 @@
     },
 
     /**
+     * @param {!Array} revisions
+     * @param {number|string} patchNum
+     */
+    _computePatchSetDate(revisions, patchNum) {
+      const rev = this.getRevisionByPatchNum(revisions, patchNum);
+      return rev ? rev.created : undefined;
+    },
+
+    /**
      * Catches value-change events from the patchset dropdowns and determines
      * whether or not a patch change event should be fired.
      */
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
index ff89d30..ea0420b 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
@@ -23,13 +23,26 @@
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../bower_components/page/page.js"></script>
 
+<link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
+<link rel="import" href="../../shared/revision-info/revision-info.html">
+
 <link rel="import" href="gr-patch-range-select.html">
 
 <script>void(0);</script>
 
+<dom-module id="comment-api-mock">
+  <template>
+    <gr-patch-range-select id="patchRange" auto
+        change-comments="[[_changeComments]]"></gr-patch-range-select>
+    <gr-comment-api id="commentAPI"></gr-comment-api>
+  </template>
+  <script src="../../diff/gr-comment-api/gr-comment-api-mock.js"></script>
+</dom-module>
+
 <test-fixture id="basic">
   <template>
-    <gr-patch-range-select auto></gr-patch-range-select>
+    <comment-api-mock></comment-api-mock>
   </template>
 </test-fixture>
 
@@ -37,10 +50,33 @@
   suite('gr-patch-range-select tests', () => {
     let element;
     let sandbox;
+    let commentApiWrapper;
+
+    function getInfo(revisions) {
+      const revisionObj = {};
+      for (let i = 0; i < revisions.length; i++) {
+        revisionObj[i] = revisions[i];
+      }
+      return new Gerrit.RevisionInfo({revisions: revisionObj});
+    }
 
     setup(() => {
-      element = fixture('basic');
       sandbox = sinon.sandbox.create();
+
+      stub('gr-rest-api-interface', {
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+      });
+
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = fixture('basic');
+      element = commentApiWrapper.$.patchRange;
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initalized.
+      return commentApiWrapper.loadComments();
     });
 
     teardown(() => sandbox.restore());
@@ -51,17 +87,17 @@
         patchNum: '3',
       };
       const sortedRevisions = [
-        {_number: 1},
-        {_number: 2},
-        {_number: element.EDIT_NAME, basePatchNum: 2},
         {_number: 3},
+        {_number: element.EDIT_NAME, basePatchNum: 2},
+        {_number: 2},
+        {_number: 1},
       ];
       for (const patchNum of ['1', '2', '3']) {
-        assert.isFalse(element._computeRightDisabled(patchNum,
-            patchRange.basePatchNum, sortedRevisions));
+        assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum,
+            patchNum, sortedRevisions));
       }
-      for (const patchNum of ['PARENT', '1', '2']) {
-        assert.isFalse(element._computeLeftDisabled(patchNum,
+      for (const basePatchNum of ['1', '2']) {
+        assert.isFalse(element._computeLeftDisabled(basePatchNum,
             patchRange.patchNum, sortedRevisions));
       }
       assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum));
@@ -69,53 +105,58 @@
       patchRange.basePatchNum = element.EDIT_NAME;
       assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum,
           sortedRevisions));
-      assert.isTrue(element._computeRightDisabled('1', patchRange.basePatchNum,
+      assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '1',
           sortedRevisions));
-      assert.isTrue(element._computeRightDisabled('2', patchRange.basePatchNum,
+      assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '2',
           sortedRevisions));
-      assert.isFalse(element._computeRightDisabled('3', patchRange.basePatchNum,
+      assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum, '3',
           sortedRevisions));
-      assert.isTrue(element._computeRightDisabled(element.EDIT_NAME,
-          patchRange.basePatchNum, sortedRevisions));
+      assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum,
+          element.EDIT_NAME, sortedRevisions));
     });
 
     test('_computeBaseDropdownContent', () => {
-      const comments = {};
       const availablePatches = [
-        {num: 1},
-        {num: 2},
-        {num: 3},
         {num: 'edit'},
+        {num: 3},
+        {num: 2},
+        {num: 1},
       ];
       const revisions = [
         {
-          commit: {},
+          commit: {parents: []},
           _number: 2,
           description: 'description',
         },
-        {commit: {}},
-        {commit: {}},
-        {commit: {}},
+        {commit: {parents: []}},
+        {commit: {parents: []}},
+        {commit: {parents: []}},
       ];
+      element.revisionInfo = getInfo(revisions);
       const patchNum = 1;
       const sortedRevisions = [
-        {_number: 1},
-        {_number: 2},
+        {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
         {_number: element.EDIT_NAME, basePatchNum: 2},
-        {_number: 3},
+        {_number: 2, description: 'description'},
+        {_number: 1},
       ];
       const expectedResult = [
         {
-          text: 'Base',
-          value: 'PARENT',
+          disabled: true,
+          triggerText: 'Patchset edit',
+          text: 'Patchset edit',
+          mobileText: 'edit',
+          bottomText: '',
+          value: 'edit',
         },
         {
           disabled: true,
-          triggerText: 'Patchset 1',
-          text: 'Patchset 1',
-          mobileText: '1',
+          triggerText: 'Patchset 3',
+          text: 'Patchset 3',
+          mobileText: '3',
           bottomText: '',
-          value: 1,
+          value: 3,
+          date: 'Mon, 01 Jan 2001 00:00:00 GMT',
         },
         {
           disabled: true,
@@ -127,32 +168,31 @@
         },
         {
           disabled: true,
-          triggerText: 'Patchset 3',
-          text: 'Patchset 3',
-          mobileText: '3',
+          triggerText: 'Patchset 1',
+          text: 'Patchset 1',
+          mobileText: '1',
           bottomText: '',
-          value: 3,
+          value: 1,
         },
         {
-          disabled: true,
-          triggerText: 'Patchset edit',
-          text: 'Patchset edit',
-          mobileText: 'edit',
-          bottomText: '',
-          value: 'edit',
+          text: 'Base',
+          value: 'PARENT',
         },
       ];
       assert.deepEqual(element._computeBaseDropdownContent(availablePatches,
-          patchNum, sortedRevisions, revisions, comments), expectedResult);
+          patchNum, sortedRevisions, element.changeComments,
+          element.revisionInfo),
+          expectedResult);
     });
 
     test('_computeBaseDropdownContent called when patchNum updates', () => {
       element.revisions = [
-        {commit: {}},
-        {commit: {}},
-        {commit: {}},
-        {commit: {}},
+        {commit: {parents: []}},
+        {commit: {parents: []}},
+        {commit: {parents: []}},
+        {commit: {parents: []}},
       ];
+      element.revisionInfo = getInfo(element.revisions);
       element.availablePatches = [
         {num: 1},
         {num: 2},
@@ -170,42 +210,42 @@
       assert.equal(element._computeBaseDropdownContent.callCount, 1);
     });
 
-    test('_computeBaseDropdownContent called when comments update', () => {
-      element.revisions = [
-        {commit: {}},
-        {commit: {}},
-        {commit: {}},
-        {commit: {}},
-      ];
-      element.availablePatches = [
-        {num: 1},
-        {num: 2},
-        {num: 3},
-        {num: 'edit'},
-      ];
-      element.patchNum = 2;
-      element.basePatchNum = 'PARENT';
-      flushAsynchronousOperations();
+    test('_computeBaseDropdownContent called when changeComments update',
+        done => {
+          element.revisions = [
+            {commit: {parents: []}},
+            {commit: {parents: []}},
+            {commit: {parents: []}},
+            {commit: {parents: []}},
+          ];
+          element.revisionInfo = getInfo(element.revisions);
+          element.availablePatches = [
+            {num: 'edit'},
+            {num: 3},
+            {num: 2},
+            {num: 1},
+          ];
+          element.patchNum = 2;
+          element.basePatchNum = 'PARENT';
+          flushAsynchronousOperations();
 
-      // Should be recomputed for each available patch
-      sandbox.stub(element, '_computeBaseDropdownContent');
-      assert.equal(element._computeBaseDropdownContent.callCount, 0);
-      element.set('comments', {
-        file: [{
-          message: 'test',
-          patch_set: 2,
-        }],
-      });
-      assert.equal(element._computeBaseDropdownContent.callCount, 1);
-    });
+          // Should be recomputed for each available patch
+          sandbox.stub(element, '_computeBaseDropdownContent');
+          assert.equal(element._computeBaseDropdownContent.callCount, 0);
+          commentApiWrapper.loadComments().then().then(() => {
+            assert.equal(element._computeBaseDropdownContent.callCount, 1);
+            done();
+          });
+        });
 
     test('_computePatchDropdownContent called when basePatchNum updates', () => {
       element.revisions = [
-        {commit: {}},
-        {commit: {}},
-        {commit: {}},
-        {commit: {}},
+        {commit: {parents: []}},
+        {commit: {parents: []}},
+        {commit: {parents: []}},
+        {commit: {parents: []}},
       ];
+      element.revisionInfo = getInfo(element.revisions);
       element.availablePatches = [
         {num: 1},
         {num: 2},
@@ -222,13 +262,14 @@
       assert.equal(element._computePatchDropdownContent.callCount, 1);
     });
 
-    test('_computePatchDropdownContent called when comments update', () => {
+    test('_computePatchDropdownContent called when comments update', done => {
       element.revisions = [
-        {commit: {}},
-        {commit: {}},
-        {commit: {}},
-        {commit: {}},
+        {commit: {parents: []}},
+        {commit: {parents: []}},
+        {commit: {parents: []}},
+        {commit: {parents: []}},
       ];
+      element.revisionInfo = getInfo(element.revisions);
       element.availablePatches = [
         {num: 1},
         {num: 2},
@@ -242,49 +283,43 @@
       // Should be recomputed for each available patch
       sandbox.stub(element, '_computePatchDropdownContent');
       assert.equal(element._computePatchDropdownContent.callCount, 0);
-      element.set('comments', {
-        file: [{
-          message: 'test',
-          patch_set: 2,
-        }],
+      commentApiWrapper.loadComments().then().then(() => {
+        done();
       });
-      assert.equal(element._computePatchDropdownContent.callCount, 1);
     });
 
     test('_computePatchDropdownContent', () => {
-      const comments = {};
       const availablePatches = [
-        {num: 1},
-        {num: 2},
-        {num: 3},
         {num: 'edit'},
-      ];
-      const revisions = [
-        {
-          commit: {},
-          _number: 2,
-          description: 'description',
-        },
-        {commit: {}},
-        {commit: {}},
-        {commit: {}},
+        {num: 3},
+        {num: 2},
+        {num: 1},
       ];
       const basePatchNum = 1;
       const sortedRevisions = [
-        {_number: 1},
-        {_number: 2},
+        {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
         {_number: element.EDIT_NAME, basePatchNum: 2},
-        {_number: 3},
+        {_number: 2, description: 'description'},
+        {_number: 1},
       ];
 
       const expectedResult = [
         {
-          disabled: true,
-          triggerText: 'Patchset 1',
-          text: 'Patchset 1',
-          mobileText: '1',
+          disabled: false,
+          triggerText: 'edit',
+          text: 'edit',
+          mobileText: 'edit',
           bottomText: '',
-          value: 1,
+          value: 'edit',
+        },
+        {
+          disabled: false,
+          triggerText: 'Patchset 3',
+          text: 'Patchset 3',
+          mobileText: '3',
+          bottomText: '',
+          value: 3,
+          date: 'Mon, 01 Jan 2001 00:00:00 GMT',
         },
         {
           disabled: false,
@@ -295,25 +330,18 @@
           value: 2,
         },
         {
-          disabled: false,
-          triggerText: 'Patchset 3',
-          text: 'Patchset 3',
-          mobileText: '3',
+          disabled: true,
+          triggerText: 'Patchset 1',
+          text: 'Patchset 1',
+          mobileText: '1',
           bottomText: '',
-          value: 3,
-        },
-        {
-          disabled: false,
-          triggerText: 'edit',
-          text: 'edit',
-          mobileText: 'edit',
-          bottomText: '',
-          value: 'edit',
+          value: 1,
         },
       ];
 
       assert.deepEqual(element._computePatchDropdownContent(availablePatches,
-          basePatchNum, sortedRevisions, revisions, comments), expectedResult);
+          basePatchNum, sortedRevisions, element.changeComments),
+          expectedResult);
     });
 
     test('filesWeblinks', () => {
@@ -341,7 +369,7 @@
 
     test('_computePatchSetCommentsString', () => {
       // Test string with unresolved comments.
-      comments = {
+      element.changeComments._comments = {
         foo: [{
           id: '27dcee4d_f7b77cfa',
           message: 'test',
@@ -361,17 +389,18 @@
         abc: [],
       };
 
-      assert.equal(element._computePatchSetCommentsString(comments, 1),
-          ' (3 comments, 1 unresolved)');
+      assert.equal(element._computePatchSetCommentsString(
+          element.changeComments, 1), ' (3 comments, 1 unresolved)');
 
       // Test string with no unresolved comments.
-      delete comments['foo'];
-      assert.equal(element._computePatchSetCommentsString(comments, 1),
-          ' (2 comments)');
+      delete element.changeComments._comments['foo'];
+      assert.equal(element._computePatchSetCommentsString(
+          element.changeComments, 1), ' (2 comments)');
 
       // Test string with no comments.
-      delete comments['bar'];
-      assert.equal(element._computePatchSetCommentsString(comments, 1), '');
+      delete element.changeComments._comments['bar'];
+      assert.equal(element._computePatchSetCommentsString(
+          element.changeComments, 1), '');
     });
 
     test('patch-range-change fires', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
index 47db5f0..5f96fce 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
@@ -17,34 +17,25 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-tooltip/gr-tooltip.html">
 
 <dom-module id="gr-selection-action-box">
   <template>
     <style include="shared-styles">
       :host {
-        --gr-arrow-size: .65em;
-
-        background-color: rgba(22, 22, 22, .9);
-        border-radius: 3px;
-        color: #fff;
         cursor: pointer;
         font-family: var(--font-family);
-        padding: .5em .75em;
         position: absolute;
         white-space: nowrap;
       }
-      .arrow {
-        border: var(--gr-arrow-size) solid transparent;
-        border-top: var(--gr-arrow-size) solid rgba(22, 22, 22, 0.9);
-        height: 0;
-        left: calc(50% - var(--gr-arrow-size));
-        margin-top: .5em;
-        position: absolute;
-        width: 0;
+      #tooltip {
+        --tooltip-background-color: rgba(22, 22, 22, .9);
       }
     </style>
-    Press <strong>c</strong> to comment.
-    <div class="arrow"></div>
+    <gr-tooltip
+        id="tooltip"
+        text="Press c to comment"
+        position-below="[[positionBelow]]"></gr-tooltip>
   </template>
   <script src="gr-selection-action-box.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index c228235..61a6eb2 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -37,6 +37,7 @@
           endChar: NaN,
         },
       },
+      positionBelow: Boolean,
       side: {
         type: String,
         value: '',
@@ -58,7 +59,7 @@
     placeAbove(el) {
       Polymer.dom.flush();
       const rect = this._getTargetBoundingRect(el);
-      const boxRect = this.getBoundingClientRect();
+      const boxRect = this.$.tooltip.getBoundingClientRect();
       const parentRect = this.parentElement.getBoundingClientRect();
       this.style.top =
           rect.top - parentRect.top - boxRect.height - 6 + 'px';
@@ -66,6 +67,17 @@
           rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
     },
 
+    placeBelow(el) {
+      Polymer.dom.flush();
+      const rect = this._getTargetBoundingRect(el);
+      const boxRect = this.$.tooltip.getBoundingClientRect();
+      const parentRect = this.parentElement.getBoundingClientRect();
+      this.style.top =
+          rect.top - parentRect.top + boxRect.height - 6 + 'px';
+      this.style.left =
+          rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
+    },
+
     _getTargetBoundingRect(el) {
       let rect;
       if (el instanceof Text) {
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index 8c70772..7fc634c 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -38,15 +38,17 @@
   suite('gr-selection-action-box', () => {
     let container;
     let element;
+    let sandbox;
 
     setup(() => {
       container = fixture('basic');
       element = container.querySelector('gr-selection-action-box');
-      sinon.stub(element, 'fire');
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(element, 'fire');
     });
 
     teardown(() => {
-      element.fire.restore();
+      sandbox.restore();
     });
 
     test('ignores regular keys', () => {
@@ -65,10 +67,10 @@
       setup(() => {
         e = {
           button: 0,
-          preventDefault: sinon.stub(),
-          stopPropagation: sinon.stub(),
+          preventDefault: sandbox.stub(),
+          stopPropagation: sandbox.stub(),
         };
-        sinon.stub(element, '_fireCreateComment');
+        sandbox.stub(element, '_fireCreateComment');
       });
 
       test('event handled if main button', () => {
@@ -107,20 +109,14 @@
 
       setup(() => {
         target = container.querySelector('.target');
-        sinon.stub(container, 'getBoundingClientRect').returns(
+        sandbox.stub(container, 'getBoundingClientRect').returns(
             {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
-        sinon.stub(element, '_getTargetBoundingRect').returns(
+        sandbox.stub(element, '_getTargetBoundingRect').returns(
             {top: 42, bottom: 20, left: 30, right: 40, width: 100, height: 60});
-        sinon.stub(element, 'getBoundingClientRect').returns(
+        sandbox.stub(element.$.tooltip, 'getBoundingClientRect').returns(
             {width: 10, height: 10});
       });
 
-      teardown(() => {
-        element.getBoundingClientRect.restore();
-        container.getBoundingClientRect.restore();
-        element._getTargetBoundingRect.restore();
-      });
-
       test('placeAbove for Element argument', () => {
         element.placeAbove(target);
         assert.equal(element.style.top, '25px');
@@ -133,13 +129,24 @@
         assert.equal(element.style.left, '72px');
       });
 
+      test('placeBelow for Element argument', () => {
+        element.placeBelow(target);
+        assert.equal(element.style.top, '45px');
+        assert.equal(element.style.left, '72px');
+      });
+
+      test('placeBelow for Text Node argument', () => {
+        element.placeBelow(target.firstChild);
+        assert.equal(element.style.top, '45px');
+        assert.equal(element.style.left, '72px');
+      });
+
       test('uses document.createRange', () => {
-        sinon.spy(document, 'createRange');
+        sandbox.spy(document, 'createRange');
         element._getTargetBoundingRect.restore();
-        sinon.spy(element, '_getTargetBoundingRect');
+        sandbox.spy(element, '_getTargetBoundingRect');
         element.placeAbove(target.firstChild);
         assert.isTrue(document.createRange.called);
-        document.createRange.restore();
       });
     });
   });
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-default-editor/gr-default-editor.html b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.html
new file mode 100644
index 0000000..b5573ab
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-default-editor">
+  <template>
+    <style include="shared-styles">
+      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%;
+      }
+      textarea:focus {
+        outline: none;
+      }
+    </style>
+    <textarea
+        id="textarea"
+        value="[[fileContent]]"
+        on-input="_handleTextareaInput"></textarea>
+  </template>
+  <script src="gr-default-editor.js"></script>
+</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
new file mode 100644
index 0000000..f30f9fa
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
@@ -0,0 +1,35 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-default-editor',
+
+    /**
+     * Fired when the content of the editor changes.
+     *
+     * @event content-change
+     */
+
+    properties: {
+      fileContent: String,
+    },
+
+    _handleTextareaInput(e) {
+      this.dispatchEvent(new CustomEvent('content-change',
+          {detail: {value: e.target.value}, bubbles: true}));
+    },
+  });
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
new file mode 100644
index 0000000..43ec9e5
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
@@ -0,0 +1,55 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-default-editor</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-default-editor.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-default-editor></gr-default-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-default-editor tests', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+      element.fileContent = '';
+    });
+
+    test('fires content-change event', done => {
+      const contentChangedHandler = e => {
+        assert.equal(e.detail.value, 'test');
+        done();
+      };
+      const textarea = element.$.textarea;
+      element.addEventListener('content-change', contentChangedHandler);
+      textarea.value = 'test';
+      textarea.dispatchEvent(new CustomEvent('input',
+          {target: textarea, bubbles: true}));
+    });
+  });
+</script>
\ No newline at end of file
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..c769d25
--- /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" slot="header">Edit a file</div>
+        <div class="main" slot="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" slot="header">Delete a file</div>
+        <div class="main" slot="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" slot="header">Rename a file</div>
+        <div class="main" slot="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" slot="header">Restore this file?</div>
+        <div class="main" slot="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..f0d7f6f
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
@@ -0,0 +1,64 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<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="../gr-edit-constants.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 {
+        text-decoration: none;
+      }
+      #edit,
+      #more {
+        margin-right: 1em;
+      }
+      gr-dropdown {
+        --gr-dropdown-item: {
+          background-color: transparent;
+          border: none;
+          color: #2a66d9;
+          font-size: inherit;
+          text-transform: uppercase;
+        }
+      }
+    </style>
+    <gr-button
+        id="edit"
+        link
+        on-tap="_handleEditTap">Edit</gr-button>
+    <!-- TODO(kaspern): implement more menu. -->
+    <gr-dropdown
+        id="more"
+        items="[[_fileActions]]"
+        down-arrow
+        vertical-offset="20"
+        on-tap-item="_handleActionTap"
+        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..62a4785
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
@@ -0,0 +1,67 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-edit-file-controls',
+
+    /**
+     * Fired when an action in the overflow menu is tapped.
+     *
+     * @event file-action-tap
+     */
+
+    properties: {
+      filePath: String,
+      // Edit action not needed in the overflow.
+      _allFileActions: {
+        type: Array,
+        value: () => Object.values(GrEditConstants.Actions)
+            .filter(action => action !== GrEditConstants.Actions.EDIT),
+      },
+      _fileActions: {
+        type: Array,
+        computed: '_computeFileActions(_allFileActions)',
+      },
+    },
+
+    _handleEditTap(e) {
+      e.preventDefault();
+      e.stopPropagation();
+      this._dispatchFileAction(GrEditConstants.Actions.EDIT.id, this.filePath);
+    },
+
+    _handleActionTap(e) {
+      e.preventDefault();
+      e.stopPropagation();
+      this._dispatchFileAction(e.detail.id, this.filePath);
+    },
+
+    _dispatchFileAction(action, path) {
+      this.dispatchEvent(new CustomEvent('file-action-tap',
+          {detail: {action, path}, bubbles: true}));
+    },
+
+    _computeFileActions(actions) {
+      // TODO(kaspern): conditionally disable some actions based on file status.
+      return actions.map(action => {
+        return {
+          name: action.label,
+          id: action.id,
+        };
+      });
+    },
+  });
+})();
\ 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..42fb466
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
@@ -0,0 +1,101 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<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-constants.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;
+  let fileActionHandler;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    fileActionHandler = sandbox.stub();
+    element.addEventListener('file-action-tap', fileActionHandler);
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('edit tap emits event', () => {
+    element.filePath = 'foo';
+
+    MockInteractions.tap(element.$.edit);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
+        {action: GrEditConstants.Actions.EDIT.id, path: 'foo'});
+  });
+
+  test('delete tap emits event', () => {
+    const more = element.$.more;
+    element.filePath = 'foo';
+    more._open();
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(more.$$('li [data-id="delete"]'));
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
+        {action: GrEditConstants.Actions.DELETE.id, path: 'foo'});
+  });
+
+  test('restore tap emits event', () => {
+    const more = element.$.more;
+    element.filePath = 'foo';
+    more._open();
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(more.$$('li [data-id="restore"]'));
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
+        {action: GrEditConstants.Actions.RESTORE.id, path: 'foo'});
+  });
+
+  test('rename tap emits event', () => {
+    const more = element.$.more;
+    element.filePath = 'foo';
+    more._open();
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(more.$$('li [data-id="rename"]'));
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
+        {action: GrEditConstants.Actions.RENAME.id, path: 'foo'});
+  });
+
+  test('computed properties', () => {
+    assert.equal(element._allFileActions.length, 3);
+    assert.notOk(element._allFileActions
+        .find(action => action.id === GrEditConstants.Actions.EDIT.id));
+  });
+});
+</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..23f1824
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
@@ -0,0 +1,92 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../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="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.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="../gr-default-editor/gr-default-editor.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: 1;
+      }
+      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 .editButtons {
+        display: none;
+      }
+      .rightControls {
+        justify-content: flex-end
+      }
+    </style>
+    <gr-fixed-panel keep-on-scroll>
+      <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">
+      <gr-endpoint-decorator id="editorEndpoint" name="editor">
+        <gr-endpoint-param name="fileContent" value="[[_newContent]]"></gr-endpoint-param>
+        <gr-endpoint-param name="prefs" value="[[_prefs]]"></gr-endpoint-param>
+        <gr-endpoint-param name="fileType" value="[[_type]]"></gr-endpoint-param>
+        <gr-default-editor id="file" file-content="[[_newContent]]"></gr-default-editor>
+      </gr-endpoint-decorator>
+    </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..ce0cd21
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
@@ -0,0 +1,169 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  const SAVING_MESSAGE = 'Saving changes...';
+  const SAVED_MESSAGE = 'All changes saved';
+  const SAVE_FAILED_MSG = 'Failed to save changes';
+
+  Polymer({
+    is: 'gr-editor-view',
+
+    /**
+     * Fired when the title of the page should change.
+     *
+     * @event title-change
+     */
+
+    /**
+     * Fired to notify the user of
+     *
+     * @event show-alert
+     */
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+
+      _change: Object,
+      _changeEditDetail: Object,
+      _changeNum: String,
+      _path: String,
+      _type: String,
+      _content: String,
+      _newContent: String,
+      _saving: {
+        type: Boolean,
+        value: false,
+      },
+      _saveDisabled: {
+        type: Boolean,
+        value: true,
+        computed: '_computeSaveDisabled(_content, _newContent, _saving)',
+      },
+      _prefs: Object,
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+      Gerrit.PatchSetBehavior,
+      Gerrit.PathListBehavior,
+    ],
+
+    listeners: {
+      'content-change': '_handleContentChange',
+    },
+
+    attached() {
+      this._getEditPrefs().then(prefs => { this._prefs = prefs; });
+    },
+
+    _getLoggedIn() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _getEditPrefs() {
+      return this.$.restAPI.getEditPreferences();
+    },
+
+    _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._getFileData(this._changeNum, this._path));
+      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);
+    },
+
+    _getFileData(changeNum, path) {
+      return this.$.restAPI.getFileInChangeEdit(changeNum, path).then(res => {
+        if (!res.ok) { return; }
+
+        this._type = res.type || '';
+        this._newContent = res.content || '';
+        this._content = res.content || '';
+      });
+    },
+
+    _saveEdit() {
+      this._saving = true;
+      this._showAlert(SAVING_MESSAGE);
+      return this.$.restAPI.saveChangeEdit(this._changeNum, this._path,
+          this._newContent).then(res => {
+            this._saving = false;
+            this._showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
+            if (res.ok) { this._content = this._newContent; }
+          });
+    },
+
+    _showAlert(message) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {message},
+        bubbles: true,
+      }));
+    },
+
+    _computeSaveDisabled(content, newContent, saving) {
+      if (saving) { return true; }
+      return content === newContent;
+    },
+
+    _handleCancelTap() {
+      // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
+      this._viewEditInChangeView();
+    },
+
+    _handleContentChange(e) {
+      if (e.detail.value) { this.set('_newContent', e.detail.value); }
+    },
+  });
+})();
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..3cd5608
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
@@ -0,0 +1,287 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT 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); },
+      getEditPreferences() { return Promise.resolve({}); },
+    });
+    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, '_getFileData', () => {
+        element._content = 'text';
+        element._newContent = 'text';
+        element._type = 'application/octet-stream';
+      });
+
+      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');
+        assert.equal(element._type, 'application/octet-stream');
+      });
+    });
+  });
+
+  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();
+        });
+      });
+    });
+  });
+
+  test('reacts to content-change event', () => {
+    element._newContent = 'test';
+    element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
+      bubbles: true,
+      detail: {value: 'new content value'},
+    }));
+    flushAsynchronousOperations();
+
+    assert.equal(element._newContent, 'new content value');
+  });
+
+  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.fileContent, originalText);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+    });
+
+    test('file modification and save, !ok response', () => {
+      const saveSpy = sandbox.spy(element, '_saveEdit');
+      const alertStub = sandbox.stub(element, '_showAlert');
+      saveFileStub.returns(Promise.resolve({ok: false}));
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+      assert.isFalse(element._saving);
+
+      MockInteractions.tap(element.$.save);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isFalse(element._saving);
+        assert.equal(alertStub.lastCall.args[0], 'Failed to save changes');
+        assert.deepEqual(saveFileStub.lastCall.args,
+            [mockParams.changeNum, mockParams.path, newText]);
+        assert.isFalse(navigateStub.called);
+        assert.isFalse(element.$.save.hasAttribute('disabled'));
+        assert.notEqual(element._content, element._newContent);
+      });
+    });
+
+    test('file modification and save', () => {
+      const saveSpy = sandbox.spy(element, '_saveEdit');
+      const alertStub = sandbox.stub(element, '_showAlert');
+      saveFileStub.returns(Promise.resolve({ok: true}));
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.isFalse(element._saving);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.save);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isFalse(element._saving);
+        assert.equal(alertStub.lastCall.args[0], 'All changes saved');
+        assert.isFalse(navigateStub.called);
+        assert.isTrue(element.$.save.hasAttribute('disabled'));
+        assert.equal(element._content, element._newContent);
+      });
+    });
+
+    test('file modification and cancel', () => {
+      const cancelSpy = sandbox.spy(element, '_handleCancelTap');
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.cancel);
+      assert.isTrue(cancelSpy.called);
+      assert.isFalse(saveFileStub.called);
+      assert.isTrue(navigateStub.called);
+    });
+  });
+
+  suite('_getFileData', () => {
+    setup(() => {
+      element._newContent = 'initial';
+      element._content = 'initial';
+      element._type = 'initial';
+    });
+
+    test('res.ok', () => {
+      sandbox.stub(element.$.restAPI, 'getFileInChangeEdit')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+            content: 'new content',
+          }));
+
+      // Ensure no data is set with a bad response.
+      return element._getFileData('1', 'test/path').then(() => {
+        assert.equal(element._newContent, 'new content');
+        assert.equal(element._content, 'new content');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('!res.ok', () => {
+      sandbox.stub(element.$.restAPI, 'getFileInChangeEdit')
+          .returns(Promise.resolve({}));
+
+      // Ensure no data is set with a bad response.
+      return element._getFileData('1', 'test/path').then(() => {
+        assert.equal(element._newContent, 'initial');
+        assert.equal(element._content, 'initial');
+        assert.equal(element._type, 'initial');
+      });
+    });
+
+    test('content is undefined', () => {
+      sandbox.stub(element.$.restAPI, 'getFileInChangeEdit')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+          }));
+
+      return element._getFileData('1', 'test/path').then(() => {
+        assert.equal(element._newContent, '');
+        assert.equal(element._content, '');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('content and type is undefined', () => {
+      sandbox.stub(element.$.restAPI, 'getFileInChangeEdit')
+          .returns(Promise.resolve({
+            ok: true,
+          }));
+
+      return element._getFileData('1', 'test/path').then(() => {
+        assert.equal(element._newContent, '');
+        assert.equal(element._content, '');
+        assert.equal(element._type, '');
+      });
+    });
+  });
+
+  test('_showAlert', done => {
+    element.addEventListener('show-alert', e => {
+      assert.deepEqual(e.detail, {message: 'test message'});
+      assert.isTrue(e.bubbles);
+      done();
+    });
+
+    element._showAlert('test message');
+  });
+});
+</script>
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index 6f8a4a1..ef93794 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -46,12 +46,15 @@
 <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-endpoint-param/gr-endpoint-param.html">
 <link rel="import" href="./plugins/gr-external-style/gr-external-style.html">
 <link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
 <link rel="import" href="./settings/gr-cla-view/gr-cla-view.html">
 <link rel="import" href="./settings/gr-registration-dialog/gr-registration-dialog.html">
 <link rel="import" href="./settings/gr-settings-view/gr-settings-view.html">
+<link rel="import" href="./shared/gr-fixed-panel/gr-fixed-panel.html">
 
 <script src="../scripts/util.js"></script>
 
@@ -151,11 +154,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]]"
@@ -166,8 +173,13 @@
         <gr-admin-view path="[[_path]]"
             params=[[params]]></gr-admin-view>
       </template>
+      <template is="dom-if" if="[[_showPluginScreen]]" restamp="true">
+        <gr-endpoint-decorator name="[[_pluginScreenName]]">
+          <gr-endpoint-param name="token" value="[[params.screen]]"></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </template>
       <template is="dom-if" if="[[_showCLAView]]" restamp="true">
-        <gr-cla-view path="[[_path]]"></gr-cla-view>
+        <gr-cla-view></gr-cla-view>
       </template>
       <div id="errorView" class="errorView">
         <div class="errorEmoji">[[_lastError.emoji]]</div>
@@ -199,6 +211,7 @@
     </gr-overlay>
     <gr-overlay id="registration" with-backdrop>
       <gr-registration-dialog
+          settings-url="[[_settingsUrl]]"
           on-account-detail-update="_handleAccountDetailUpdate"
           on-close="_handleRegistrationDialogClose">
       </gr-registration-dialog>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 4a38b85..e98aaac 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -14,6 +14,10 @@
 (function() {
   'use strict';
 
+  // The maximum age of a keydown event to be used in a jump navigation. This is
+  // only for cases when the keyup event is lost.
+  const G_KEY_TIMEOUT_MS = 1000;
+
   // Eagerly render Polymer components when backgrounded. (Skips
   // requestAnimationFrame.)
   // @see https://github.com/Polymer/polymer/issues/3851
@@ -32,7 +36,7 @@
 
     properties: {
       /**
-       * @type {{ query: string, view: string }}
+       * @type {{ query: string, view: string, screen: string }}
        */
       params: Object,
       keyEventTarget: {
@@ -44,6 +48,17 @@
         type: Object,
         observer: '_accountChanged',
       },
+
+      /**
+       * The last time the g key was pressed in milliseconds (or a keydown event
+       * was handled if the key is held down).
+       * @type {number|null}
+       */
+      _lastGKeyPressTimestamp: {
+        type: Number,
+        value: null,
+      },
+
       /**
        * @type {{ plugin: Object }}
        */
@@ -56,6 +71,8 @@
       _showSettingsView: Boolean,
       _showAdminView: Boolean,
       _showCLAView: Boolean,
+      _showEditorView: Boolean,
+      _showPluginScreen: Boolean,
       /** @type {?} */
       _viewState: Object,
       /** @type {?} */
@@ -63,6 +80,11 @@
       _lastSearchPage: String,
       _path: String,
       _isShadowDom: Boolean,
+      _pluginScreenName: {
+        type: String,
+        computed: '_computePluginScreenName(params)',
+      },
+      _settingsUrl: String,
     },
 
     listeners: {
@@ -82,6 +104,9 @@
 
     keyBindings: {
       '?': '_showKeyboardShortcuts',
+      'g:keydown': '_gKeyDown',
+      'g:keyup': '_gKeyUp',
+      'a m o': '_jumpKeyPressed',
     },
 
     ready() {
@@ -98,6 +123,10 @@
         this._version = version;
       });
 
+      // Note: this is evaluated here to ensure that it only happens after the
+      // router has been initialized. @see Issue 7837
+      this._settingsUrl = Gerrit.Nav.getUrlForSettings();
+
       this.$.reporting.appStarted();
       this._viewState = {
         changeView: {
@@ -126,6 +155,7 @@
       // Preferences are cached when a user is logged in; warm them.
       this.$.restAPI.getPreferences();
       this.$.restAPI.getDiffPreferences();
+      this.$.restAPI.getEditPreferences();
       this.$.errorManager.knownAccountId =
           this._account && this._account._account_id || null;
     },
@@ -137,8 +167,18 @@
       this.set('_showChangeView', view === Gerrit.Nav.View.CHANGE);
       this.set('_showDiffView', view === Gerrit.Nav.View.DIFF);
       this.set('_showSettingsView', view === Gerrit.Nav.View.SETTINGS);
-      this.set('_showAdminView', view === Gerrit.Nav.View.ADMIN);
+      this.set('_showAdminView', view === Gerrit.Nav.View.ADMIN ||
+          view === Gerrit.Nav.View.GROUP || view === Gerrit.Nav.View.REPO);
       this.set('_showCLAView', view === Gerrit.Nav.View.AGREEMENTS);
+      this.set('_showEditorView', view === Gerrit.Nav.View.EDIT);
+      const isPluginScreen = view === Gerrit.Nav.View.PLUGIN_SCREEN;
+      this.set('_showPluginScreen', false);
+      // Navigation within plugin screens does not restamp gr-endpoint-decorator
+      // because _showPluginScreen value does not change. To force restamp,
+      // change _showPluginScreen value between true and false.
+      if (isPluginScreen) {
+        this.async(() => this.set('_showPluginScreen', true), 1);
+      }
       if (this.params.justRegistered) {
         this.$.registration.open();
       }
@@ -233,5 +273,37 @@
     _computeShadowClass(isShadowDom) {
       return isShadowDom ? 'shadow' : '';
     },
+
+    _gKeyDown(e) {
+      if (this.modifierPressed(e)) { return; }
+      this._lastGKeyPressTimestamp = Date.now();
+    },
+
+    _gKeyUp() {
+      this._lastGKeyPressTimestamp = null;
+    },
+
+    _jumpKeyPressed(e) {
+      if (!this._lastGKeyPressTimestamp ||
+          (Date.now() - this._lastGKeyPressTimestamp > G_KEY_TIMEOUT_MS) ||
+          this.shouldSuppressKeyboardShortcut(e)) { return; }
+      e.preventDefault();
+
+      let status = null;
+      if (e.detail.key === 'a') {
+        status = 'abandoned';
+      } else if (e.detail.key === 'm') {
+        status = 'merged';
+      } else if (e.detail.key === 'o') {
+        status = 'open';
+      }
+      if (status !== null) {
+        Gerrit.Nav.navigateToStatusSearch(status);
+      }
+    },
+
+    _computePluginScreenName({plugin, screen}) {
+      return Gerrit._getPluginScreenName(plugin, screen);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
index 3712ffa..320c9f1 100644
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -105,5 +105,55 @@
         assert.deepEqual(element.$.plugins.config, config);
       });
     });
+
+    suite('_jumpKeyPressed', () => {
+      let navStub;
+
+      setup(() => {
+        navStub = sandbox.stub(Gerrit.Nav, 'navigateToStatusSearch');
+        sandbox.stub(Date, 'now').returns(10000);
+      });
+
+      test('success', () => {
+        const e = {detail: {key: 'a'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        element._lastGKeyPressTimestamp = 9000;
+        element._jumpKeyPressed(e);
+        assert.isTrue(navStub.calledOnce);
+        assert.equal(navStub.lastCall.args[0], 'abandoned');
+      });
+
+      test('no g key', () => {
+        const e = {detail: {key: 'a'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        element._lastGKeyPressTimestamp = null;
+        element._jumpKeyPressed(e);
+        assert.isFalse(navStub.called);
+      });
+
+      test('g key too long ago', () => {
+        const e = {detail: {key: 'a'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        element._lastGKeyPressTimestamp = 3000;
+        element._jumpKeyPressed(e);
+        assert.isFalse(navStub.called);
+      });
+
+      test('should suppress', () => {
+        const e = {detail: {key: 'a'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
+        element._lastGKeyPressTimestamp = 9000;
+        element._jumpKeyPressed(e);
+        assert.isFalse(navStub.called);
+      });
+
+      test('unrecognized key', () => {
+        const e = {detail: {key: 'f'}, preventDefault: () => {}};
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        element._lastGKeyPressTimestamp = 9000;
+        element._jumpKeyPressed(e);
+        assert.isFalse(navStub.called);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
index 0928534..fc42ae8 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
@@ -19,7 +19,7 @@
 
 <dom-module id="gr-endpoint-decorator">
   <template strip-whitespace>
-    <content></content>
+    <slot></slot>
   </template>
   <script src="gr-endpoint-decorator.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
index a2a1c0b..5e558ec 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
@@ -14,6 +14,8 @@
 (function() {
   'use strict';
 
+  const INIT_PROPERTIES_TIMEOUT_MS = 10000;
+
   Polymer({
     is: 'gr-endpoint-decorator',
 
@@ -40,59 +42,76 @@
 
     _initDecoration(name, plugin) {
       const el = document.createElement(name);
-      this._initProperties(el, plugin, this.getContentChildren().find(
-          el => el.nodeName !== 'GR-ENDPOINT-PARAM'));
-      this._appendChild(el);
-      return el;
+      return this._initProperties(el, plugin,
+          this.getContentChildren().find(
+              el => el.nodeName !== 'GR-ENDPOINT-PARAM'))
+          .then(el => this._appendChild(el));
     },
 
     _initReplacement(name, plugin) {
-      this.getContentChildNodes().forEach(node => node.remove());
+      this.getContentChildNodes()
+          .filter(node => node.nodeName !== 'GR-ENDPOINT-PARAM')
+          .forEach(node => node.remove());
       const el = document.createElement(name);
-      this._initProperties(el, plugin);
-      this._appendChild(el);
-      return el;
+      return this._initProperties(el, plugin).then(
+          el => this._appendChild(el));
     },
 
     _getEndpointParams() {
-      return Polymer.dom(this).querySelectorAll('gr-endpoint-param').map(el => {
-        return {name: el.getAttribute('name'), value: el.value};
-      });
+      return Polymer.dom(this).querySelectorAll('gr-endpoint-param');
     },
 
     /**
      * @param {!Element} el
      * @param {!Object} plugin
      * @param {!Element=} opt_content
+     * @return {!Promise<Element>}
      */
     _initProperties(el, plugin, opt_content) {
       el.plugin = plugin;
       if (opt_content) {
         el.content = opt_content;
       }
-      for (const {name, value} of this._getEndpointParams()) {
-        el[name] = value;
-      }
+      const expectProperties = this._getEndpointParams().map(
+          paramEl => plugin.attributeHelper(paramEl).get('value')
+              .then(value => el[paramEl.getAttribute('name')] = value)
+      );
+      let timeoutId;
+      const timeout = new Promise(
+        resolve => timeoutId = setTimeout(() => {
+          console.warn(
+              'Timeout waiting for endpoint properties initialization: ' +
+              `plugin ${plugin.getPluginName()}, endpoint ${this.name}`);
+        }, INIT_PROPERTIES_TIMEOUT_MS));
+      return Promise.race([timeout, Promise.all(expectProperties)])
+          .then(() => {
+            clearTimeout(timeoutId);
+            return el;
+          });
     },
 
     _appendChild(el) {
-      Polymer.dom(this.root).appendChild(el);
+      return Polymer.dom(this.root).appendChild(el);
     },
 
     _initModule({moduleName, plugin, type, domHook}) {
-      let el;
+      let initPromise;
       switch (type) {
         case 'decorate':
-          el = this._initDecoration(moduleName, plugin);
+          initPromise = this._initDecoration(moduleName, plugin);
           break;
         case 'replace':
-          el = this._initReplacement(moduleName, plugin);
+          initPromise = this._initReplacement(moduleName, plugin);
           break;
       }
-      if (el) {
-        domHook.handleInstanceAttached(el);
+      if (!initPromise) {
+        console.warn('Unable to initialize module' +
+            `${moduleName} from ${plugin.getPluginName()}`);
       }
-      this._domHooks.set(el, domHook);
+      initPromise.then(el => {
+        domHook.handleInstanceAttached(el);
+        this._domHooks.set(el, domHook);
+      });
     },
 
     ready() {
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
index c7ab3d9..cfebc95 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
@@ -28,48 +28,45 @@
 
 <test-fixture id="basic">
   <template>
-    <gr-endpoint-decorator name="foo">
-      <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
-    </gr-endpoint-decorator>
+    <div>
+      <gr-endpoint-decorator name="first">
+        <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
+      </gr-endpoint-decorator>
+      <gr-endpoint-decorator name="second">
+        <gr-endpoint-param name="someparam" value="foofoo"></gr-endpoint-param>
+      </gr-endpoint-decorator>
+      <gr-endpoint-decorator name="banana">
+        <gr-endpoint-param name="someParam" value="yes"></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    </div>
   </template>
 </test-fixture>
 
 <script>
   suite('gr-endpoint-decorator', () => {
+    let container;
     let sandbox;
-    let element;
     let plugin;
-    let domHookStub;
+    let decorationHook;
+    let replacementHook;
 
     setup(done => {
-      Gerrit._endpoints = new GrPluginEndpoints();
-
       sandbox = sinon.sandbox.create();
-
-      domHookStub = {
-        handleInstanceAttached: sandbox.stub(),
-        handleInstanceDetached: sandbox.stub(),
-        getPublicAPI: () => domHookStub,
-      };
-      sandbox.stub(
-          GrDomHooksManager.prototype, 'getDomHook').returns(domHookStub);
-
-      // NB: Order is important.
-      Gerrit.install(p => {
-        plugin = p;
-        plugin.registerCustomComponent('foo', 'some-module');
-        plugin.registerCustomComponent('foo', 'other-module', {replace: true});
-        plugin.registerCustomComponent('bar', 'some-module');
-      }, '0.1', 'http://some/plugin/url.html');
-
-      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
-
-      element = fixture('basic');
-      sandbox.stub(element, '_initDecoration').returns({});
-      sandbox.stub(element, '_initReplacement').returns({});
-      sandbox.stub(element, 'importHref', (url, resolve) => resolve());
-
+      stub('gr-endpoint-decorator', {
+        _import: sandbox.stub().returns(Promise.resolve()),
+      });
+      // Since _endpoints are global, must reset state.
+      Gerrit._endpoints = new GrPluginEndpoints();
+      container = fixture('basic');
+      Gerrit.install(p => plugin = p, '0.1', 'http://some/plugin/url.html');
+      hooks = [];
+      // Decoration
+      decorationHook = plugin.registerCustomComponent('first', 'some-module');
+      // Replacement
+      replacementHook = plugin.registerCustomComponent(
+          'second', 'other-module', {replace: true});
+      // Mimic all plugins loaded.
+      Gerrit._setPluginsCount(0);
       flush(done);
     });
 
@@ -77,51 +74,79 @@
       sandbox.restore();
     });
 
-    test('imports plugin-provided module', () => {
-      assert.isTrue(
-          element.importHref.calledWith(new URL('http://some/plugin/url.html')));
+    test('imports plugin-provided modules into endpoints', () => {
+      const endpoints =
+          Array.from(container.querySelectorAll('gr-endpoint-decorator'));
+      assert.equal(endpoints.length, 3);
+      endpoints.forEach(element => {
+        assert.isTrue(
+            element._import.calledWith(new URL('http://some/plugin/url.html')));
+      });
     });
 
-    test('inits decoration dom hook', () => {
-      assert.strictEqual(
-          element._initDecoration.lastCall.args[0], 'some-module');
-      assert.strictEqual(
-          element._initDecoration.lastCall.args[1], plugin);
+    test('decoration', () => {
+      const element =
+          container.querySelector('gr-endpoint-decorator[name="first"]');
+      const module = Polymer.dom(element.root).children.find(
+          element => element.nodeName === 'SOME-MODULE');
+      assert.isOk(module);
+      assert.equal(module['someparam'], 'barbar');
+      return decorationHook.getLastAttached().then(element => {
+        assert.strictEqual(element, module);
+      }).then(() => {
+        element.remove();
+        assert.equal(decorationHook.getAllAttached().length, 0);
+      });
     });
 
-    test('inits replacement dom hook', () => {
-      assert.strictEqual(
-          element._initReplacement.lastCall.args[0], 'other-module');
-      assert.strictEqual(
-          element._initReplacement.lastCall.args[1], plugin);
+    test('replacement', () => {
+      const element =
+          container.querySelector('gr-endpoint-decorator[name="second"]');
+      const module = Polymer.dom(element.root).children.find(
+          element => element.nodeName === 'OTHER-MODULE');
+      assert.isOk(module);
+      assert.equal(module['someparam'], 'foofoo');
+      return replacementHook.getLastAttached().then(element => {
+        assert.strictEqual(element, module);
+      }).then(() => {
+        element.remove();
+        assert.equal(replacementHook.getAllAttached().length, 0);
+      });
     });
 
-    test('calls dom hook handleInstanceAttached', () => {
-      assert.equal(domHookStub.handleInstanceAttached.callCount, 2);
-    });
-
-    test('calls dom hook handleInstanceDetached', () => {
-      element.detached();
-      assert.equal(domHookStub.handleInstanceDetached.callCount, 2);
-    });
-
-    test('installs modules on late registration', done => {
-      domHookStub.handleInstanceAttached.reset();
-      plugin.registerCustomComponent('foo', 'noob-noob');
+    test('late registration', done => {
+      plugin.registerCustomComponent('banana', 'noob-noob');
       flush(() => {
-        assert.equal(domHookStub.handleInstanceAttached.callCount, 1);
-        assert.strictEqual(
-            element._initDecoration.lastCall.args[0], 'noob-noob');
-        assert.strictEqual(
-            element._initDecoration.lastCall.args[1], plugin);
+        const element =
+            container.querySelector('gr-endpoint-decorator[name="banana"]');
+        const module = Polymer.dom(element.root).children.find(
+            element => element.nodeName === 'NOOB-NOOB');
+        assert.isOk(module);
         done();
       });
     });
 
-    test('params', () => {
-      const instance = document.createElement('foo');
-      element._initProperties(instance, plugin);
-      assert.equal(instance.someparam, 'barbar');
+    test('late param setup', done => {
+      const element =
+          container.querySelector('gr-endpoint-decorator[name="banana"]');
+      const param = Polymer.dom(element).querySelector('gr-endpoint-param');
+      param['value'] = undefined;
+      plugin.registerCustomComponent('banana', 'noob-noob');
+      flush(() => {
+        let module = Polymer.dom(element.root).children.find(
+            element => element.nodeName === 'NOOB-NOOB');
+        // Module waits for param to be defined.
+        assert.isNotOk(module);
+        const value = {abc: 'def'};
+        param.value = value;
+        flush(() => {
+          module = Polymer.dom(element.root).children.find(
+              element => element.nodeName === 'NOOB-NOOB');
+          assert.isOk(module);
+          assert.strictEqual(module['someParam'], value);
+          done();
+        });
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
index 5a2ab59..2833654 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
@@ -18,7 +18,10 @@
     is: 'gr-endpoint-param',
     properties: {
       name: String,
-      value: Object,
+      value: {
+        type: Object,
+        notify: true,
+      },
     },
   });
 })();
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-external-style/gr-external-style.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
index 623d304..2f957af 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
@@ -19,7 +19,7 @@
 
 <dom-module id="gr-external-style">
   <template>
-    <content></content>
+    <slot></slot>
   </template>
   <script src="gr-external-style.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
index a1382c76..d3ad997 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
@@ -30,8 +30,9 @@
 
     _configChanged(config) {
       const plugins = config.plugin;
-      const jsPlugins = plugins.js_resource_paths || [];
       const htmlPlugins = plugins.html_resource_paths || [];
+      const jsPlugins = this._handleMigrations(plugins.js_resource_paths || [],
+          htmlPlugins);
       const defaultTheme = config.default_theme;
       if (defaultTheme) {
         // Make theme first to be first to load.
@@ -43,6 +44,17 @@
     },
 
     /**
+     * Omit .js plugins that have .html counterparts.
+     * For example, if plugin provides foo.js and foo.html, skip foo.js.
+     */
+    _handleMigrations(jsPlugins, htmlPlugins) {
+      return jsPlugins.filter(url => {
+        const counterpart = url.replace(/\.js$/, '.html');
+        return !htmlPlugins.includes(counterpart);
+      });
+    },
+
+    /**
      * @suppress {checkTypes}
      * States that it expects no more than 3 parameters, but that's not true.
      * @todo (beckysiegel) check Polymer annotations and submit change.
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
index 3ccb3fd..af9ed37 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
@@ -21,7 +21,7 @@
   <template>
     <style include="shared-styles"></style>
     <gr-overlay id="overlay" with-backdrop>
-      <content></content>
+      <slot></slot>
     </gr-overlay>
   </template>
   <script src="gr-plugin-popup.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.html
new file mode 100644
index 0000000..5645c36
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-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-repo-command/gr-repo-command.html">
+
+<dom-module id="gr-plugin-repo-command">
+  <template>
+    <gr-repo-command title="[[title]]">
+    </gr-repo-command>
+  </template>
+  <script>
+    Polymer({
+      is: 'gr-plugin-repo-command',
+      properties: {
+        title: String,
+        repoName: String,
+        config: Object,
+      },
+    });
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html
new file mode 100644
index 0000000..6062642
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-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-repo-command.html">
+
+<dom-module id="gr-repo-api">
+  <script src="gr-repo-api.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
new file mode 100644
index 0000000..8ce34a7
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-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.GrRepoApi) { return; }
+
+  function GrRepoApi(plugin) {
+    this._hook = null;
+    this.plugin = plugin;
+  }
+
+  GrRepoApi.prototype._createHook = function(title) {
+    this._hook = this.plugin.hook('repo-command').onAttached(element => {
+      const pluginCommand =
+            document.createElement('gr-plugin-repo-command');
+      pluginCommand.title = title;
+      element.appendChild(pluginCommand);
+    });
+  };
+
+  GrRepoApi.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.repoName, element.config) === false) {
+        element.hidden = true;
+      }
+    });
+    return this;
+  };
+
+  GrRepoApi.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.GrRepoApi = GrRepoApi;
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
new file mode 100644
index 0000000..5ef79a9
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-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-repo-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-repo-api.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-endpoint-decorator name="repo-command">
+    </gr-endpoint-decorator>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-repo-api tests', () => {
+    let sandbox;
+    let repoApi;
+
+    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);
+      repoApi = plugin.project();
+    });
+
+    teardown(() => {
+      repoApi = null;
+      sandbox.restore();
+    });
+
+    test('exists', () => {
+      assert.isOk(repoApi);
+    });
+
+    test('works', done => {
+      const attachedStub = sandbox.stub();
+      const tapStub = sandbox.stub();
+      repoApi
+          .createCommand('foo', attachedStub)
+          .onTap(tapStub);
+      const element = fixture('basic');
+      flush(() => {
+        assert.isTrue(attachedStub.called);
+        const pluginCommand = element.$$('gr-plugin-repo-command');
+        assert.isOk(pluginCommand);
+        const command = pluginCommand.$$('gr-repo-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/plugins/gr-settings-api/gr-settings-api.html b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html
new file mode 100644
index 0000000..4c57daf
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html
@@ -0,0 +1,26 @@
+<!--
+Copyright (C) 2017 The Android Open Source Settings
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../settings/gr-settings-view/gr-settings-item.html">
+<link rel="import" href="../../settings/gr-settings-view/gr-settings-menu-item.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+
+<dom-module id="gr-settings-api">
+  <script src="gr-settings-api.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
new file mode 100644
index 0000000..f33b683
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
@@ -0,0 +1,62 @@
+// Copyright (C) 2017 The Android Open Source Settings
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+
+  function GrSettingsApi(plugin) {
+    this._title = '(no title)';
+    // Generate default screen URL token, specific to plugin, and unique(ish).
+    this._token =
+      plugin.getPluginName() + Math.random().toString(36).substr(5);
+    this.plugin = plugin;
+  }
+
+  GrSettingsApi.prototype.title = function(title) {
+    this._title = title;
+    return this;
+  };
+
+  GrSettingsApi.prototype.token = function(token) {
+    this._token = token;
+    return this;
+  };
+
+  GrSettingsApi.prototype.module = function(moduleName) {
+    this._moduleName = moduleName;
+    return this;
+  };
+
+  GrSettingsApi.prototype.build = function() {
+    if (!this._moduleName) {
+      throw new Error('Settings screen custom element not defined!');
+    }
+    const token = `x/${this.plugin.getPluginName()}/${this._token}`;
+    this.plugin.hook('settings-menu-item').onAttached(el => {
+      const menuItem = document.createElement('gr-settings-menu-item');
+      menuItem.title = this._title;
+      menuItem.href = `#${token}`;
+      el.appendChild(menuItem);
+    });
+
+    return this.plugin.hook('settings-screen').onAttached(el => {
+      const item = document.createElement('gr-settings-item');
+      item.title = this._title;
+      item.anchor = token;
+      item.appendChild(document.createElement(this._moduleName));
+      el.appendChild(item);
+    });
+  };
+
+  window.GrSettingsApi = GrSettingsApi;
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
new file mode 100644
index 0000000..cbe71fe
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
@@ -0,0 +1,82 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Settings
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-settings-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-settings-api.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-endpoint-decorator name="settings-menu-item">
+    </gr-endpoint-decorator>
+    <gr-endpoint-decorator name="settings-screen">
+    </gr-endpoint-decorator>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-settings-api tests', () => {
+    let sandbox;
+    let settingsApi;
+
+    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);
+      settingsApi = plugin.settings();
+    });
+
+    teardown(() => {
+      settingsApi = null;
+      sandbox.restore();
+    });
+
+    test('exists', () => {
+      assert.isOk(settingsApi);
+    });
+
+    test('works', done => {
+      settingsApi
+          .title('foo')
+          .token('bar')
+          .module('some-settings-screen')
+          .build();
+      const element = fixture('basic');
+      flush(() => {
+        const [menuItemEl, itemEl] = element;
+        const menuItem = menuItemEl.$$('gr-settings-menu-item');
+        assert.isOk(menuItem);
+        assert.equal(menuItem.title, 'foo');
+        assert.equal(menuItem.href, '#x/testplugin/bar');
+        const item = itemEl.$$('gr-settings-item');
+        assert.isOk(item);
+        assert.equal(item.title, 'foo');
+        assert.equal(item.anchor, 'x/testplugin/bar');
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
index 55164e0..5563ab9 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
@@ -76,7 +76,7 @@
         </span>
       </section>
       <section>
-        <span class="title">Status</span>
+        <span class="title">Status (e.g. "Vacation")</span>
         <span class="value">
           <input
               is="iron-input"
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
index a698c71..3cec65a 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -41,18 +41,9 @@
             '_hasUsernameChange, _hasStatusChange)',
       },
 
-      _hasNameChange: {
-        type: Boolean,
-        value: false,
-      },
-      _hasUsernameChange: {
-        type: Boolean,
-        value: false,
-      },
-      _hasStatusChange: {
-        type: Boolean,
-        value: false,
-      },
+      _hasNameChange: Boolean,
+      _hasUsernameChange: Boolean,
+      _hasStatusChange: Boolean,
       _loading: {
         type: Boolean,
         value: false,
@@ -85,6 +76,9 @@
       }));
 
       promises.push(this.$.restAPI.getAccount().then(account => {
+        this._hasNameChange = false;
+        this._hasUsernameChange = false;
+        this._hasStatusChange = false;
         // Provide predefined value for username to trigger computation of
         // username mutability.
         account.username = account.username || '';
@@ -154,8 +148,9 @@
     },
 
     _usernameChanged() {
-      if (this._loading) { return; }
-      this._hasUsernameChange = true;
+      if (this._loading || !this._account) { return; }
+      this._hasUsernameChange =
+          (this._account.username || '') !== (this._username || '');
     },
 
     _nameChanged() {
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
index d27d153..82997a5 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
@@ -311,5 +311,23 @@
         });
       });
     });
+
+    test('_usernameChanged compares usernames with loose equality', () => {
+      element._account = {};
+      element._username = '';
+      element._hasUsernameChange = false;
+      element._loading = false;
+      // _usernameChanged is an observer, but call it here after setting
+      // _hasUsernameChange in the test to force recomputation.
+      element._usernameChanged();
+      flushAsynchronousOperations();
+
+      assert.isFalse(element._hasUsernameChange);
+
+      element.set('_username', 'test');
+      flushAsynchronousOperations();
+
+      assert.isTrue(element._hasUsernameChange);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
index c665df4..307a2b4 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
@@ -53,8 +53,7 @@
           </template>
         </tbody>
       </table>
-      <!-- TODO: Renable this when supported in polygerrit -->
-      <!-- <a href$="[[getUrl()]]">New Contributor Agreement</a> -->
+      <a href$="[[getUrl()]]">New Contributor Agreement</a>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
index b667d66..a1f5dc5 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
@@ -1,5 +1,5 @@
 <!--
-Copyright (C) 2017 The Android Open Source Project
+Copyright (C) 2018 The Android Open Source Project
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -14,12 +14,94 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-placeholder/gr-placeholder.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-cla-view">
   <template>
-    <gr-placeholder title="Agreements" path="[[path]]"></gr-placeholder>
+    <style include="shared-styles">
+      h1 {
+        margin-bottom: .6em;
+      }
+      h3 {
+        margin-bottom: .5em;
+      }
+      .agreementsUrl {
+        border: 0.1em solid #b0bdcc;
+        margin-bottom: 1.25em;
+        margin-left: 1.25em;
+        margin-right: 1.25em;
+        padding: 0.3em;
+      }
+      #claNewAgreementsLabel {
+        font-family: var(--font-family-bold);
+      }
+      #claNewAgreement {
+        display: none;
+      }
+      #claNewAgreement.show {
+        display: block;
+      }
+      .contributorAgreementButton {
+        font-family: var(--font-family-bold);
+      }
+      .contributorAgreementAlreadySubmitted {
+        color: red;
+        margin: 0 2em;
+        padding: .5em;
+      }
+      .agreementsSubmitted,
+      .hideAgreementsTextBox {
+        display: none;
+      }
+      main {
+        margin: 2em auto;
+        max-width: 50em;
+      }
+    </style>
+    <style include="gr-form-styles"></style>
+    <main>
+      <h1>New Contributor Agreement</h1>
+      <h3>Select an agreement type:</h3>
+      <template is="dom-repeat" items="[[_serverConfig.auth.contributor_agreements]]">
+        <span class="contributorAgreementButton">
+          <input id$="claNewAgreementsInput[[item.name]]"
+              name="claNewAgreementsRadio"
+              type="radio"
+              data-name$="[[item.name]]"
+              data-url$="[[item.url]]"
+              on-tap="_handleShowAgreement"
+              disabled$="[[_disableAggreements(item, _groups)]]">
+          <label id="claNewAgreementsLabel">[[item.name]]</label>
+        </span>
+        <div class$="contributorAgreementAlreadySubmitted [[_hideAggreements(item, _groups)]]">
+          Agreement already submitted.
+        </div>
+        <div class="agreementsUrl">
+          [[item.description]]
+        </div>
+      </template>
+      <div id="claNewAgreement" class$="[[_computeShowAgreementsClass(_showAgreements)]]">
+        <h3 class="smallHeading">Review the agreement:</h3>
+        <div id="agreementsUrl" class="agreementsUrl">
+          <a href$="[[_agreementsUrl]]" target="blank" rel="noopener">
+            Please review the agreement.</a>
+        </div>
+        <div class$="agreementsTextBox [[_computeHideAgreementClass(_agreementName, _serverConfig.auth.contributor_agreements)]]">
+          <h3 class="smallHeading">Complete the agreement:</h3>
+          <input id="input-agreements" is="iron-input" bind-value="{{_agreementsText}}" placeholder="Enter 'I agree' here" />
+          <gr-button on-tap="_handleSaveAgreements" disabled="[[_disableAgreementsText(_agreementsText)]]">
+            Submit
+          </gr-button>
+        </div>
+      </div>
+    </main>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-cla-view.js"></script>
-</dom-module>
+</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
index 71dc71b..39400c64 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2018 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -18,7 +18,121 @@
     is: 'gr-cla-view',
 
     properties: {
-      path: String,
+      _groups: Object,
+      /** @type {?} */
+      _serverConfig: Object,
+      _agreementsText: String,
+      _agreementName: String,
+      _showAgreements: {
+        type: Boolean,
+        value: false,
+      },
+      _agreementsUrl: String,
+    },
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+    ],
+
+    attached() {
+      this.loadData();
+
+      this.fire('title-change', {title: 'New Contributor Agreement'});
+    },
+
+    loadData() {
+      const promises = [];
+      promises.push(this.$.restAPI.getConfig(true).then(config => {
+        this._serverConfig = config;
+      }));
+
+      promises.push(this.$.restAPI.getAccountGroups().then(groups => {
+        this._groups = groups.sort((a, b) => {
+          return a.name.localeCompare(b.name);
+        });
+      }));
+
+      return Promise.all(promises);
+    },
+
+    _getAgreementsUrl(configUrl) {
+      let url;
+      if (!configUrl) { return ''; }
+      if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
+        url = configUrl;
+      } else {
+        url = this.getBaseUrl() + '/' + configUrl;
+      }
+
+      return url;
+    },
+
+    _handleShowAgreement(e) {
+      this._agreementName = e.target.getAttribute('data-name');
+      this._agreementsUrl =
+          this._getAgreementsUrl(e.target.getAttribute('data-url'));
+      this._showAgreements = true;
+    },
+
+    _handleSaveAgreements(e) {
+      this._createToast('Agreement saving...');
+
+      const name = this._agreementName;
+      return this.$.restAPI.saveAccountAgreement({name}).then(res => {
+        let message = 'Agreement failed to be submitted, please try again';
+        if (res.status === 200) {
+          message = 'Agreement has been successfully submited.';
+        }
+        this._createToast(message);
+        this.loadData();
+        this._agreementsText = '';
+        this._showAgreements = false;
+      });
+    },
+
+    _createToast(message) {
+      this.dispatchEvent(new CustomEvent('show-alert',
+          {detail: {message}, bubbles: true}));
+    },
+
+    _computeShowAgreementsClass(agreements) {
+      return agreements ? 'show' : '';
+    },
+
+    _disableAggreements(item, groups) {
+      for (const value of groups) {
+        if (item && item.auto_verify_group &&
+            item.auto_verify_group.name === value.name) {
+          return true;
+        }
+      }
+
+      return false;
+    },
+
+    _hideAggreements(item, groups) {
+      return this._disableAggreements(item, groups) ?
+          '' : 'agreementsSubmitted';
+    },
+
+    _disableAgreementsText(text) {
+      return text.toLowerCase() === 'i agree' ? false : true;
+    },
+
+    // This checks for auto_verify_group,
+    // if specified it returns 'hideAgreementsTextBox' which
+    // then hides the text box and submit button.
+    _computeHideAgreementClass(name, config) {
+      for (const key in config) {
+        if (!config.hasOwnProperty(key)) { return; }
+        for (const prop in config[key]) {
+          if (!config[key].hasOwnProperty(prop)) { return; }
+          if (name === config[key].name &&
+              !config[key].auto_verify_group) {
+            return 'hideAgreementsTextBox';
+          }
+        }
+      }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
new file mode 100644
index 0000000..985fbfa
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
@@ -0,0 +1,183 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-cla-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-cla-view.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-cla-view></gr-cla-view>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-cla-view tests', () => {
+    let element;
+    let agreements;
+    const auth = {
+      name: 'Individual',
+      description: 'test-description',
+      url: 'static/cla_individual.html',
+      auto_verify_group: {
+        url: '#/admin/groups/uuid-bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+        options: {
+          visible_to_all: true,
+        },
+        group_id: 20,
+        owner: 'CLA Accepted - Individual',
+        owner_id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+        created_on: '2017-07-31 15:11:04.000000000',
+        id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+        name: 'CLA Accepted - Individual',
+      },
+    };
+    const auth2 = {
+      name: 'Individual2',
+      description: 'test-description2',
+      url: 'static/cla_individual2.html',
+      auto_verify_group: {
+        url: '#/admin/groups/uuid-e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+        options: {},
+        group_id: 21,
+        owner: 'CLA Accepted - Individual2',
+        owner_id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+        created_on: '2017-07-31 15:25:42.000000000',
+        id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+        name: 'CLA Accepted - Individual2',
+      },
+    };
+    const config = {
+      auth: {
+        use_contributor_agreements: true,
+        contributor_agreements: [
+          {
+            name: 'Individual',
+            description: 'test-description',
+            url: 'static/cla_individual.html',
+          },
+        ],
+      },
+    };
+    const config2 = {
+      auth: {
+        use_contributor_agreements: true,
+        contributor_agreements: [
+          {
+            name: 'Individual2',
+            description: 'test-description2',
+            url: 'static/cla_individual2.html',
+          },
+        ],
+      },
+    };
+    const groups = [
+      {
+        url: 'some url',
+        options: {},
+        description: 'Group 1 description',
+        group_id: 1,
+        owner: 'Administrators',
+        owner_id: '123',
+        id: 'abc',
+        name: 'Individual',
+      },
+      {
+        options: {visible_to_all: true},
+        id: '456',
+        group_id: 2,
+        name: 'Individual 2',
+      },
+      {
+        options: {visible_to_all: true},
+        id: '457',
+        group_id: 3,
+        name: 'CLA Accepted - Individual',
+      },
+    ];
+
+    setup(done => {
+      agreements = [{
+        url: 'test-agreements.html',
+        description: 'Agreements 1 description',
+        name: 'Agreements 1',
+      }];
+
+      stub('gr-rest-api-interface', {
+        getAccountGroups() { return Promise.resolve(agreements); },
+      });
+
+      element = fixture('basic');
+
+      element.loadData().then(() => { flush(done); });
+    });
+
+    test('_disableAggreements equals true', () => {
+      assert.isTrue(element._disableAggreements(auth, groups));
+    });
+
+    test('_disableAggreements equals false', () => {
+      assert.isFalse(element._disableAggreements(auth2, groups));
+    });
+
+    test('_hideAggreements equals string', () => {
+      assert.equal(element._hideAggreements(auth, groups), '');
+    });
+
+    test('_hideAggreements equals agreementsSubmitted', () => {
+      assert.equal(element._hideAggreements(auth2, groups),
+          'agreementsSubmitted');
+    });
+
+    test('_disableAgreementsText equals true', () => {
+      assert.isFalse(element._disableAgreementsText('I AGREE'));
+    });
+
+    test('_disableAgreementsText equals true', () => {
+      assert.isTrue(element._disableAgreementsText('I DO NOT AGREE'));
+    });
+
+    test('_computeHideAgreementClass returns true', () => {
+      assert.equal(
+          element._computeHideAgreementClass(
+              auth.name, config.auth.contributor_agreements),
+          'hideAgreementsTextBox');
+    });
+
+    test('_computeHideAgreementClass returns undefined', () => {
+      assert.isUndefined(
+          element._computeHideAgreementClass(
+              auth.name, config2.auth.contributor_agreements));
+    });
+
+    test('_getAgreementsUrl has http', () => {
+      assert.equal(element._getAgreementsUrl(
+          'http://test.org/test.html'), 'http://test.org/test.html');
+    });
+
+    test('_getAgreementsUrl does not have http://', () => {
+      assert.equal(element._getAgreementsUrl(
+          'test_cla.html'), '/test_cla.html');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
new file mode 100644
index 0000000..bbd7396
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
@@ -0,0 +1,170 @@
+<!--
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+
+<dom-module id="gr-edit-preferences">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles"></style>
+    <div id="editPreferences" class="gr-form-styles">
+      <section>
+        <span class="title">Tab width</span>
+        <span class="value">
+          <input
+              is="iron-input"
+              type="number"
+              prevent-invalid-input
+              allowed-pattern="[0-9]"
+              bind-value="{{editPrefs.tab_size}}"
+              on-change="_handleEditPrefsChanged">
+        </span>
+      </section>
+      <section>
+        <span class="title">Columns</span>
+        <span class="value">
+          <input
+              is="iron-input"
+              type="number"
+              prevent-invalid-input
+              allowed-pattern="[0-9]"
+              bind-value="{{editPrefs.line_length}}"
+              on-change="_handleEditPrefsChanged">
+        </span>
+      </section>
+      <section>
+        <span class="title">Indent unit</span>
+        <span class="value">
+          <input
+              is="iron-input"
+              type="number"
+              prevent-invalid-input
+              allowed-pattern="[0-9]"
+              bind-value="{{editPrefs.indent_unit}}"
+              on-change="_handleEditPrefsChanged">
+        </span>
+      </section>
+      <section>
+        <span class="title">Cursor blink rate</span>
+        <span class="value">
+          <input
+              is="iron-input"
+              type="number"
+              prevent-invalid-input
+              allowed-pattern="[0-9]"
+              bind-value="{{editPrefs.cursor_blink_rate}}"
+              on-change="_handleEditPrefsChanged">
+        </span>
+      </section>
+      <section>
+        <span class="title">Top menu</span>
+        <span class="value">
+          <input
+              id="showTopMenu"
+              type="checkbox"
+              checked$="[[editPrefs.hide_top_menu]]"
+              on-change="_handleTopMenuChanged">
+        </span>
+      </section>
+      <section>
+        <span class="title">Syntax highlighting</span>
+        <span class="value">
+          <input
+              id="editSyntaxHighlighting"
+              type="checkbox"
+              checked$="[[editPrefs.syntax_highlighting]]"
+              on-change="_handleEditSyntaxHighlightingChanged">
+        </span>
+      </section>
+      <section>
+        <span class="title">Show tabs</span>
+        <span class="value">
+          <input
+              id="editShowTabs"
+              type="checkbox"
+              checked$="[[editPrefs.show_tabs]]"
+              on-change="_handleEditShowTabsChanged">
+        </span>
+      </section>
+      <section>
+        <span class="title">Whitespace errors</span>
+        <span class="value">
+          <input
+              id="whitespaceErrors"
+              type="checkbox"
+              checked$="[[editPrefs.show_whitespace_errors]]"
+              on-change="_handleWhitespaceErrorsChanged">
+        </span>
+      </section>
+      <section>
+        <span class="title">Line numbers</span>
+        <span class="value">
+          <input
+              id="showLineNumbers"
+              type="checkbox"
+              checked$="[[editPrefs.hide_line_numbers]]"
+              on-change="_handleLineNumbersChanged">
+        </span>
+      </section>
+      <section>
+        <span class="title">Match brackets</span>
+        <span class="value">
+          <input
+              id="showMatchBrackets"
+              type="checkbox"
+              checked$="[[editPrefs.match_brackets]]"
+              on-change="_handleMatchBracketsChanged">
+        </span>
+      </section>
+      <section>
+        <span class="title">Line wrapping</span>
+        <span class="value">
+          <input
+              id="editShowLineWrapping"
+              type="checkbox"
+              checked$="[[editPrefs.line_wrapping]]"
+              on-change="_handleEditLineWrappingChanged">
+        </span>
+      </section>
+      <section>
+        <span class="title">Indent with tabs</span>
+        <span class="value">
+          <input
+              id="showIndentWithTabs"
+              type="checkbox"
+              checked$="[[editPrefs.indent_with_tabs]]"
+              on-change="_handleIndentWithTabsChanged">
+        </span>
+      </section>
+      <section>
+        <span class="title">Auto close brackets</span>
+        <span class="value">
+          <input
+              id="showAutoCloseBrackets"
+              type="checkbox"
+              checked$="[[editPrefs.auto_close_brackets]]"
+              on-change="_handleAutoCloseBracketsChanged">
+        </span>
+      </section>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-edit-preferences.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
new file mode 100644
index 0000000..01b45f8
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
@@ -0,0 +1,105 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-edit-preferences',
+
+    properties: {
+      hasUnsavedChanges: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+
+      /** @type {?} */
+      editPrefs: Object,
+    },
+
+    loadData() {
+      return this.$.restAPI.getEditPreferences().then(prefs => {
+        this.editPrefs = prefs;
+      });
+    },
+
+    _handleEditPrefsChanged() {
+      this.hasUnsavedChanges = true;
+    },
+
+    _handleTopMenuChanged() {
+      this.set('editPrefs.hide_top_menu', this.$.showTopMenu.checked);
+      this._handleEditPrefsChanged();
+    },
+
+    _handleEditSyntaxHighlightingChanged() {
+      this.set('editPrefs.syntax_highlighting',
+          this.$.editSyntaxHighlighting.checked);
+      this._handleEditPrefsChanged();
+    },
+
+    _handleEditShowTabsChanged() {
+      this.set('editPrefs.show_tabs', this.$.editShowTabs.checked);
+      this._handleEditPrefsChanged();
+    },
+
+    _handleWhitespaceErrorsChanged() {
+      this.set('editPrefs.show_whitespace_errors',
+          this.$.whitespaceErrors.checked);
+      this._handleEditPrefsChanged();
+    },
+
+    _handleLineNumbersChanged() {
+      this.set('editPrefs.hide_line_numbers',
+          this.$.showLineNumbers.checked);
+      this._handleEditPrefsChanged();
+    },
+
+    _handleMatchBracketsChanged() {
+      this.set('editPrefs.match_brackets', this.$.showMatchBrackets.checked);
+      this._handleEditPrefsChanged();
+    },
+
+    _handleEditLineWrappingChanged() {
+      this.set('editPrefs.line_wrapping',
+          this.$.editShowLineWrapping.checked);
+      this._handleEditPrefsChanged();
+    },
+
+    _handleIndentWithTabsChanged() {
+      this.set('editPrefs.indent_with_tabs',
+          this.$.showIndentWithTabs.checked);
+      this._handleEditPrefsChanged();
+    },
+
+    _handleAutoCloseBracketsChanged() {
+      this.set('editPrefs.auto_close_brackets',
+          this.$.showAutoCloseBrackets.checked);
+      this._handleEditPrefsChanged();
+    },
+
+    _handleShowBaseVersionChanged() {
+      this.set('editPrefs.show_base',
+          this.$.showShowBaseVersion.checked);
+      this._handleEditPrefsChanged();
+    },
+
+    save() {
+      return this.$.restAPI.saveEditPreferences(this.editPrefs)
+          .then(() => {
+            this.hasUnsavedChanges = false;
+          });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
new file mode 100644
index 0000000..0305c84
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
@@ -0,0 +1,135 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-edit-preferences</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-preferences.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-edit-preferences></gr-edit-preferences>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-edit-preferences tests', () => {
+    let element;
+    let editPreferences;
+
+    function valueOf(title, fieldsetid) {
+      const sections = element.$[fieldsetid].querySelectorAll('section');
+      let titleEl;
+      for (let i = 0; i < sections.length; i++) {
+        titleEl = sections[i].querySelector('.title');
+        if (titleEl.textContent.trim() === title) {
+          return sections[i].querySelector('.value');
+        }
+      }
+    }
+
+    setup(done => {
+      editPreferences = {
+        auto_close_brackets: false,
+        cursor_blink_rate: 0,
+        hide_line_numbers: false,
+        hide_top_menu: false,
+        indent_unit: 2,
+        indent_with_tabs: false,
+        key_map_type: 'DEFAULT',
+        line_length: 100,
+        line_wrapping: false,
+        match_brackets: true,
+        show_base: false,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        tab_size: 8,
+        theme: 'DEFAULT',
+      };
+
+      stub('gr-rest-api-interface', {
+        getEditPreferences() {
+          return Promise.resolve(editPreferences);
+        },
+      });
+
+      element = fixture('basic');
+
+      element.loadData().then(done);
+    });
+
+    test('renders', () => {
+      // Rendered with the expected preferences selected.
+      assert.equal(valueOf('Tab width', 'editPreferences')
+          .firstElementChild.bindValue, editPreferences.tab_size);
+      assert.equal(valueOf('Columns', 'editPreferences')
+          .firstElementChild.bindValue, editPreferences.line_length);
+      assert.equal(valueOf('Indent unit', 'editPreferences')
+          .firstElementChild.bindValue, editPreferences.indent_unit);
+      assert.equal(valueOf('Cursor blink rate', 'editPreferences')
+          .firstElementChild.bindValue, editPreferences.cursor_blink_rate);
+      assert.equal(valueOf('Top menu', 'editPreferences')
+          .firstElementChild.checked, editPreferences.hide_top_menu);
+      assert.equal(valueOf('Syntax highlighting', 'editPreferences')
+          .firstElementChild.checked, editPreferences.syntax_highlighting);
+      assert.equal(valueOf('Show tabs', 'editPreferences')
+          .firstElementChild.checked, editPreferences.show_tabs);
+      assert.equal(valueOf('Whitespace errors', 'editPreferences')
+          .firstElementChild.checked, editPreferences.show_whitespace_errors);
+      assert.equal(valueOf('Line numbers', 'editPreferences')
+          .firstElementChild.checked, editPreferences.hide_line_numbers);
+      assert.equal(valueOf('Match brackets', 'editPreferences')
+          .firstElementChild.checked, editPreferences.match_brackets);
+      assert.equal(valueOf('Line wrapping', 'editPreferences')
+          .firstElementChild.checked, editPreferences.line_wrapping);
+      assert.equal(valueOf('Indent with tabs', 'editPreferences')
+          .firstElementChild.checked, editPreferences.indent_with_tabs);
+      assert.equal(valueOf('Auto close brackets', 'editPreferences')
+          .firstElementChild.checked, editPreferences.auto_close_brackets);
+
+      assert.isFalse(element.hasUnsavedChanges);
+    });
+
+    test('save changes', done => {
+      const showTabsCheckbox = valueOf('Show tabs', 'editPreferences')
+          .firstElementChild;
+      showTabsCheckbox.checked = false;
+      element._handleEditShowTabsChanged();
+
+      assert.isTrue(element.hasUnsavedChanges);
+
+      stub('gr-rest-api-interface', {
+        saveEditPreferences(prefs) {
+          assert.equal(prefs.show_tabs, false);
+          return Promise.resolve();
+        },
+      });
+
+      // Save the change.
+      element.save().then(() => {
+        assert.isFalse(element.hasUnsavedChanges);
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html
new file mode 100644
index 0000000..5816288
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html
@@ -0,0 +1,84 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-identities">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
+      td {
+        width: 5em;
+      }
+      .deleteButton {
+        float: right;
+      }
+      .deleteButton:not(.show) {
+        display: none;
+      }
+      .statusColumn {
+        white-space: nowrap;
+      }
+    </style>
+    <div class="gr-form-styles">
+      <table id="identities">
+        <thead>
+          <tr>
+            <th class="statusHeader">Status</th>
+            <th class="emailAddressHeader">Email Address</th>
+            <th class="identityHeader">Identity</th>
+            <th class="deleteHeader"></th>
+          </tr>
+        </thead>
+        <tbody>
+          <template is="dom-repeat" items="[[_identities]]" filter="filterIdentities">
+            <tr>
+              <td class$="statusColumn">
+                [[_computeIsTrusted(item.trusted)]]
+              </td>
+              <td>[[item.email_address]]</td>
+              <td>[[_computeIdentity(item.identity)]]</td>
+              <td>
+                <gr-button
+                    link
+                    class$="deleteButton [[_computeHideDeleteClass(item.can_delete)]]"
+                    on-tap="_handleDeleteItem">
+                  Delete
+                </gr-button>
+              </td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+    </div>
+    <gr-overlay id="overlay" with-backdrop>
+      <gr-confirm-delete-item-dialog
+          class="confirmDialog"
+          on-confirm="_handleDeleteItemConfirm"
+          on-cancel="_handleConfirmDialogCancel"
+          item="[[_idName]]"
+          item-type="id"></gr-confirm-delete-item-dialog>
+    </gr-overlay>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-identities.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
new file mode 100644
index 0000000..9c70edd
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
@@ -0,0 +1,64 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-identities',
+
+    properties: {
+      _identities: Object,
+      _idName: String,
+    },
+
+    loadData() {
+      return this.$.restAPI.getExternalIds().then(id => {
+        this._identities = id;
+      });
+    },
+
+    _computeIdentity(id) {
+      return id && id.startsWith('mailto:') ? '' : id;
+    },
+
+    _computeHideDeleteClass(canDelete) {
+      return canDelete ? 'show' : '';
+    },
+
+    _handleDeleteItemConfirm() {
+      this.$.overlay.close();
+      return this.$.restAPI.deleteAccountIdentity([this._idName])
+          .then(() => { this.loadData(); });
+    },
+
+    _handleConfirmDialogCancel() {
+      this.$.overlay.close();
+    },
+
+    _handleDeleteItem(e) {
+      const name = e.model.get('item.identity');
+      if (!name) { return; }
+      this._idName = name;
+      this.$.overlay.open();
+    },
+
+    _computeIsTrusted(item) {
+      return item ? '' : 'Untrusted';
+    },
+
+    filterIdentities(item) {
+      return !item.identity.startsWith('username:');
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
new file mode 100644
index 0000000..c6bf764
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
@@ -0,0 +1,125 @@
+<!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-identities</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-identities.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-identities></gr-identities>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-identities tests', () => {
+    let element;
+    let sandbox;
+    const ids = [
+      {
+        identity: 'username:john',
+        email_address: 'john.doe@example.com',
+        trusted: true,
+      }, {
+        identity: 'gerrit:gerrit',
+        email_address: 'gerrit@example.com',
+      }, {
+        identity: 'mailto:gerrit2@example.com',
+        email_address: 'gerrit2@example.com',
+        trusted: true,
+        can_delete: true,
+      },
+    ];
+
+    setup(done => {
+      sandbox = sinon.sandbox.create();
+
+      stub('gr-rest-api-interface', {
+        getExternalIds() { return Promise.resolve(ids); },
+      });
+
+      element = fixture('basic');
+
+      element.loadData().then(() => { flush(done); });
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('renders', () => {
+      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+
+      assert.equal(rows.length, 2);
+
+      const nameCells = rows.map(row =>
+        row.querySelectorAll('td')[2].textContent
+      );
+
+      assert.equal(nameCells[0], 'gerrit:gerrit');
+      assert.equal(nameCells[1], '');
+    });
+
+    test('renders email', () => {
+      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+
+      assert.equal(rows.length, 2);
+
+      const nameCells = rows.map(row =>
+        row.querySelectorAll('td')[1].textContent
+      );
+
+      assert.equal(nameCells[0], 'gerrit@example.com');
+      assert.equal(nameCells[1], 'gerrit2@example.com');
+    });
+
+    test('_computeIdentity', () => {
+      assert.equal(
+          element._computeIdentity(ids[0].identity), 'username:john');
+      assert.equal(element._computeIdentity(ids[2].identity), '');
+    });
+
+    test('filterIdentities', () => {
+      assert.isFalse(element.filterIdentities(ids[0]));
+
+      assert.isTrue(element.filterIdentities(ids[1]));
+    });
+
+    test('delete id', done => {
+      element._idName = 'mailto:gerrit2@example.com';
+      const loadDataStub = sandbox.stub(element, 'loadData');
+      element._handleDeleteItemConfirm().then(() => {
+        assert.isTrue(loadDataStub.called);
+        done();
+      });
+    });
+
+    test('_handleDeleteItem opens modal', () => {
+      const deleteBtn =
+          Polymer.dom(element.root).querySelector('.deleteButton');
+      const deleteItem = sandbox.stub(element, '_handleDeleteItem');
+      MockInteractions.tap(deleteBtn);
+      assert.isTrue(deleteItem.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
index 543c86d..26a2470 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
@@ -24,7 +24,7 @@
     },
 
     _handleMoveUpButton(e) {
-      const index = e.target.dataIndex;
+      const index = Polymer.dom(e).localTarget.dataIndex;
       if (index === 0) { return; }
       const row = this.menuItems[index];
       const prev = this.menuItems[index - 1];
@@ -32,7 +32,7 @@
     },
 
     _handleMoveDownButton(e) {
-      const index = e.target.dataIndex;
+      const index = Polymer.dom(e).localTarget.dataIndex;
       if (index === this.menuItems.length - 1) { return; }
       const row = this.menuItems[index];
       const next = this.menuItems[index + 1];
@@ -40,7 +40,7 @@
     },
 
     _handleDeleteButton(e) {
-      const index = e.target.dataIndex;
+      const index = Polymer.dom(e).localTarget.dataIndex;
       this.splice('menuItems', index, 1);
     },
 
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
index f16ba6c..c70ae88 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
@@ -47,9 +47,10 @@
     // Click the up/down button (according to direction) for the index'th row.
     // The index of the first row is 0, corresponding to the array.
     function move(element, index, direction) {
-      const selector =
-          'tr:nth-child(' + (index + 1) + ') .move' + direction + 'Button';
-      const button = element.$$('tbody').querySelector(selector);
+      const selector = 'tr:nth-child(' + (index + 1) + ') .move' +
+          direction + 'Button';
+      const button =
+          element.$$('tbody').querySelector(selector).$$('paper-button');
       MockInteractions.tap(button);
     }
 
@@ -141,15 +142,15 @@
           ['first name', 'second name', 'third name']);
 
       // Tap the delete button for the middle item.
-      MockInteractions.tap(
-          element.$$('tbody').querySelector('tr:nth-child(2) .remove-button'));
+      MockInteractions.tap(element.$$('tbody')
+          .querySelector('tr:nth-child(2) .remove-button').$$('paper-button'));
 
       assertMenuNamesEqual(element, ['first name', 'third name']);
 
       // Delete remaining items.
       for (let i = 0; i < 2; i++) {
-        MockInteractions.tap(
-            element.$$('tbody').querySelector('tr:first-child .remove-button'));
+        MockInteractions.tap(element.$$('tbody')
+            .querySelector('tr:first-child .remove-button').$$('paper-button'));
       }
       assertMenuNamesEqual(element, []);
 
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
index 0cbd1f6..1b3d9d4 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
@@ -94,7 +94,7 @@
         <hr>
         <p>
           More configuration options for Gerrit may be found in the
-          <a on-tap="close" href$="[[_computeSettingsUrl(_account)]]">settings</a>.
+          <a on-tap="close" href$="[[settingsUrl]]">settings</a>.
         </p>
       </main>
       <footer>
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
index 406d16c..dace2ca 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
@@ -30,6 +30,7 @@
      */
 
     properties: {
+      settingsUrl: String,
       /** @type {?} */
       _account: {
         type: Object,
@@ -89,9 +90,5 @@
     _computeSaveDisabled(name, username, email, saving) {
       return !name || !username || !email || saving;
     },
-
-    _computeSettingsUrl() {
-      return Gerrit.Nav.getUrlForSettings();
-    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.html
new file mode 100644
index 0000000..97fbf0a
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-settings-item">
+  <style>
+    :host {
+      display: block;
+      margin-bottom: 2em;
+    }
+  </style>
+  <template>
+    <h2 id="[[anchor]]">[[title]]</h2>
+    <slot></slot>
+  </template>
+  <script src="gr-settings-item.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
new file mode 100644
index 0000000..e4f5d24
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
@@ -0,0 +1,24 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-settings-item',
+    properties: {
+      anchor: String,
+      title: String,
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html
new file mode 100644
index 0000000..ff71d3f
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/gr-page-nav-styles.html">
+
+<dom-module id="gr-settings-menu-item">
+  <style include="shared-styles"></style>
+  <style include="gr-page-nav-styles"></style>
+  <template>
+    <div class="navStyles">
+      <li><a href$="[[href]]">[[title]]</a></li>
+    </div>
+  </template>
+  <script src="gr-settings-menu-item.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
new file mode 100644
index 0000000..797990d
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
@@ -0,0 +1,24 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-settings-menu-item',
+    properties: {
+      href: String,
+      title: String,
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
index 7764d3b..1195408 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
@@ -15,12 +15,12 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-
 <link rel="import" href="../../../behaviors/docs-url-behavior/docs-url-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-menu-page-styles.html">
 <link rel="import" href="../../../styles/gr-page-nav-styles.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="../../settings/gr-change-table-editor/gr-change-table-editor.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
@@ -29,9 +29,11 @@
 <link rel="import" href="../../shared/gr-select/gr-select.html">
 <link rel="import" href="../gr-account-info/gr-account-info.html">
 <link rel="import" href="../gr-agreements-list/gr-agreements-list.html">
+<link rel="import" href="../gr-edit-preferences/gr-edit-preferences.html">
 <link rel="import" href="../gr-email-editor/gr-email-editor.html">
 <link rel="import" href="../gr-group-list/gr-group-list.html">
 <link rel="import" href="../gr-http-password/gr-http-password.html">
+<link rel="import" href="../gr-identities/gr-identities.html">
 <link rel="import" href="../gr-menu-editor/gr-menu-editor.html">
 <link rel="import" href="../gr-ssh-editor/gr-ssh-editor.html">
 <link rel="import" href="../gr-watched-projects-editor/gr-watched-projects-editor.html">
@@ -62,6 +64,7 @@
           <li><a href="#Profile">Profile</a></li>
           <li><a href="#Preferences">Preferences</a></li>
           <li><a href="#DiffPreferences">Diff Preferences</a></li>
+          <li><a href="#EditPreferences">Edit Preferences</a></li>
           <li><a href="#Menu">Menu</a></li>
           <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
           <li><a href="#Notifications">Notifications</a></li>
@@ -71,12 +74,15 @@
             SSH Keys
           </a></li>
           <li><a href="#Groups">Groups</a></li>
+          <li><a href="#Identities">Identities</a></li>
           <template is="dom-if" if="[[_serverConfig.auth.use_contributor_agreements]]">
             <li>
               <a href="#Agreements">Agreements</a>
             </li>
           </template>
           <li><a href="#MailFilters">Mail Filters</a></li>
+          <gr-endpoint-decorator name="settings-menu-item">
+          </gr-endpoint-decorator>
         </ul>
       </gr-page-nav>
       <main class="gr-form-styles">
@@ -233,10 +239,10 @@
             <span class="title">Fit to screen</span>
             <span class="value">
               <input
-                  id="lineWrapping"
+                  id="diffLineWrapping"
                   type="checkbox"
                   checked$="[[_diffPrefs.line_wrapping]]"
-                  on-change="_handleLineWrappingChanged">
+                  on-change="_handleDiffLineWrappingChanged">
             </span>
           </section>
           <section id="columnsPref" hidden$="[[_diffPrefs.line_wrapping]]">
@@ -276,10 +282,10 @@
             <span class="title">Show tabs</span>
             <span class="value">
               <input
-                  id="showTabs"
+                  id="diffShowTabs"
                   type="checkbox"
                   checked$="[[_diffPrefs.show_tabs]]"
-                  on-change="_handleShowTabsChanged">
+                  on-change="_handleDiffShowTabsChanged">
             </span>
           </section>
           <section>
@@ -296,10 +302,10 @@
             <span class="title">Syntax highlighting</span>
             <span class="value">
               <input
-                  id="syntaxHighlighting"
+                  id="diffSyntaxHighlighting"
                   type="checkbox"
                   checked$="[[_diffPrefs.syntax_highlighting]]"
-                  on-change="_handleSyntaxHighlightingChanged">
+                  on-change="_handleDiffSyntaxHighlightingChanged">
             </span>
           </section>
           <gr-button
@@ -307,6 +313,20 @@
               on-tap="_handleSaveDiffPreferences"
               disabled$="[[!_diffPrefsChanged]]">Save changes</gr-button>
         </fieldset>
+        <h2
+            id="EditPreferences"
+            class$="[[_computeHeaderClass(_editPrefsChanged)]]">
+          Edit Preferences
+        </h2>
+        <fieldset id="editPreferences">
+          <gr-edit-preferences
+              id="editPrefs"
+              has-unsaved-changes="{{_editPrefsChanged}}"></gr-edit-preferences>
+          <gr-button
+              id="saveEditPrefs"
+              on-tap="_handleSaveEditPreferences"
+              disabled$="[[!_editPrefsChanged]]">Save changes</gr-button>
+        </fieldset>
         <h2 id="Menu" class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
         <fieldset id="menu">
           <gr-menu-editor menu-items="{{_localMenu}}"></gr-menu-editor>
@@ -353,7 +373,6 @@
               id="emailEditor"
               has-unsaved-changes="{{_emailsChanged}}"></gr-email-editor>
           <gr-button
-              link
               on-tap="_handleSaveEmails"
               disabled$="[[!_emailsChanged]]">Save changes</gr-button>
         </fieldset>
@@ -399,6 +418,10 @@
         <fieldset>
           <gr-group-list id="groupList"></gr-group-list>
         </fieldset>
+        <h2 id="Identities">Identities</h2>
+        <fieldset>
+          <gr-identities id="identities"></gr-identities>
+        </fieldset>
         <template is="dom-if" if="[[_serverConfig.auth.use_contributor_agreements]]">
           <h2 id="Agreements">Agreements</h2>
           <fieldset>
@@ -475,6 +498,8 @@
             </tbody>
           </table>
         </fieldset>
+        <gr-endpoint-decorator name="settings-screen">
+        </gr-endpoint-decorator>
       </main>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 25dd259..5836b0d 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -90,6 +90,8 @@
         type: Boolean,
         value: false,
       },
+      /** @type {?} */
+      _editPrefsChanged: Boolean,
       _menuChanged: {
         type: Boolean,
         value: false,
@@ -145,6 +147,8 @@
         this.$.watchedProjectsEditor.loadData(),
         this.$.groupList.loadData(),
         this.$.httpPass.loadData(),
+        this.$.identities.loadData(),
+        this.$.editPrefs.loadData(),
       ];
 
       promises.push(this.$.restAPI.getPreferences().then(prefs => {
@@ -286,12 +290,12 @@
       });
     },
 
-    _handleLineWrappingChanged() {
-      this.set('_diffPrefs.line_wrapping', this.$.lineWrapping.checked);
+    _handleDiffLineWrappingChanged() {
+      this.set('_diffPrefs.line_wrapping', this.$.diffLineWrapping.checked);
     },
 
-    _handleShowTabsChanged() {
-      this.set('_diffPrefs.show_tabs', this.$.showTabs.checked);
+    _handleDiffShowTabsChanged() {
+      this.set('_diffPrefs.show_tabs', this.$.diffShowTabs.checked);
     },
 
     _handleShowTrailingWhitespaceChanged() {
@@ -299,9 +303,9 @@
           this.$.showTrailingWhitespace.checked);
     },
 
-    _handleSyntaxHighlightingChanged() {
+    _handleDiffSyntaxHighlightingChanged() {
       this.set('_diffPrefs.syntax_highlighting',
-          this.$.syntaxHighlighting.checked);
+          this.$.diffSyntaxHighlighting.checked);
     },
 
     _handleSaveChangeTable() {
@@ -320,6 +324,10 @@
           });
     },
 
+    _handleSaveEditPreferences() {
+      this.$.editPrefs.save();
+    },
+
     _handleSaveMenu() {
       this.set('prefs.my', this._localMenu);
       this._cloneMenu();
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
index 868a9e3..9a20b6c 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -254,7 +254,7 @@
       const showTabsCheckbox = valueOf('Show tabs', 'diffPreferences')
           .firstElementChild;
       showTabsCheckbox.checked = false;
-      element._handleShowTabsChanged();
+      element._handleDiffShowTabsChanged();
 
       assert.isTrue(element._diffPrefsChanged);
 
@@ -275,10 +275,10 @@
     test('columns input is hidden with fit to scsreen is selected', () => {
       assert.isFalse(element.$.columnsPref.hidden);
 
-      MockInteractions.tap(element.$.lineWrapping);
+      MockInteractions.tap(element.$.diffLineWrapping);
       assert.isTrue(element.$.columnsPref.hidden);
 
-      MockInteractions.tap(element.$.lineWrapping);
+      MockInteractions.tap(element.$.diffLineWrapping);
       assert.isFalse(element.$.columnsPref.hidden);
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
index b658025..b7b581f 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
@@ -56,12 +56,6 @@
           padding: 0;
           text-decoration: none;
         }
-        --gr-button-hover-color: {
-          color: #333;
-        }
-        --gr-button-hover-background-color: {
-          color: #333;
-        }
       }
       :host:focus {
         border-color: transparent;
@@ -82,7 +76,9 @@
       }
     </style>
     <div class$="container [[_getBackgroundClass(transparentBackground)]]">
-      <gr-account-link account="[[account]]"></gr-account-link>
+      <gr-account-link account="[[account]]"
+          additional-text="[[additionalText]]">
+      </gr-account-link>
       <gr-button
           id="remove"
           link
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
index e029ab0..b2cca89 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -33,6 +33,7 @@
 
     properties: {
       account: Object,
+      additionalText: String,
       disabled: {
         type: Boolean,
         value: false,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
index 21f4c3e..2a79945 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
@@ -37,10 +37,17 @@
         vertical-align: -.25em;
       }
       .text {
-        @apply(--gr-account-label-text-style);
+        @apply --gr-account-label-text-style;
       }
       .text:hover {
-        @apply(--gr-account-label-text-hover-style);
+        @apply --gr-account-label-text-hover-style;
+      }
+      .email,
+      .showEmail .name {
+        display: none;
+      }
+      .showEmail .email {
+        display: inline-block;
       }
     </style>
     <span>
@@ -48,9 +55,10 @@
         <gr-avatar account="[[account]]"
             image-size="[[avatarImageSize]]"></gr-avatar>
       </template>
-      <span class="text">
-        <span>[[_computeName(account, _serverConfig)]]</span>
-        <span hidden$="[[!_computeShowEmail(showEmail, account)]]">
+      <span class$="text [[_computeShowEmailClass(account)]]">
+        <span class="name">
+          [[_computeName(account, _serverConfig)]]</span>
+        <span class="email">
           [[_computeEmailStr(account)]]
         </span>
         <template is="dom-if" if="[[account.status]]">
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
index 2dee2f6..c75be10 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
@@ -26,15 +26,12 @@
         type: Number,
         value: 32,
       },
-      showEmail: {
-        type: Boolean,
-        value: false,
-      },
       title: {
         type: String,
         reflectToAttribute: true,
-        computed: '_computeAccountTitle(account)',
+        computed: '_computeAccountTitle(account, additionalText)',
       },
+      additionalText: String,
       hasTooltip: {
         type: Boolean,
         reflectToAttribute: true,
@@ -56,6 +53,7 @@
     ],
 
     ready() {
+      if (!this.additionalText) { this.additionalText = ''; }
       this.$.restAPI.getConfig()
           .then(config => { this._serverConfig = config; });
     },
@@ -64,7 +62,7 @@
       return this.getUserName(config, account, false);
     },
 
-    _computeAccountTitle(account) {
+    _computeAccountTitle(account, tooltip) {
       if (!account) { return; }
       let result = '';
       if (this._computeName(account, this._serverConfig)) {
@@ -73,11 +71,15 @@
       if (account.email) {
         result += ' <' + account.email + '>';
       }
+      if (this.additionalText) {
+        return result + ' ' + this.additionalText;
+      }
       return result;
     },
 
-    _computeShowEmail(showEmail, account) {
-      return !!(showEmail && account && account.email);
+    _computeShowEmailClass(account) {
+      if (!account || account.name || !account.email) { return ''; }
+      return 'showEmail';
     },
 
     _computeEmailStr(account) {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
index 731c9b7..3087b0e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
@@ -78,23 +78,21 @@
           }),
           'Anonymous <andybons+gerrit@gmail.com>');
 
-      assert.equal(element._computeShowEmail(true,
+      assert.equal(element._computeShowEmailClass(
           {
             name: 'Andrew Bonventre',
             email: 'andybons+gerrit@gmail.com',
-          }), true);
+          }), '');
 
-      assert.equal(element._computeShowEmail(true,
-          {name: 'Andrew Bonventre'}), false);
+      assert.equal(element._computeShowEmailClass(
+          {
+            email: 'andybons+gerrit@gmail.com',
+          }), 'showEmail');
 
-      assert.equal(element._computeShowEmail(false,
-          {name: 'Andrew Bonventre'}), false);
+      assert.equal(element._computeShowEmailClass({name: 'Andrew Bonventre'}),
+          '');
 
-      assert.equal(element._computeShowEmail(
-          true, undefined), false);
-
-      assert.equal(element._computeShowEmail(
-          false, undefined), false);
+      assert.equal(element._computeShowEmailClass(undefined), '');
 
       assert.equal(
           element._computeEmailStr({name: 'test', email: 'test'}), '(test)');
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
index 79747ba..785d509 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
@@ -39,8 +39,8 @@
     <span>
       <a href$="[[_computeOwnerLink(account)]]" tabindex="-1">
         <gr-account-label account="[[account]]"
-            avatar-image-size="[[avatarImageSize]]"
-            show-email="[[_computeShowEmail(account)]]"></gr-account-label>
+            additional-text="[[additionalText]]"
+            avatar-image-size="[[avatarImageSize]]"></gr-account-label>
       </a>
     </span>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
index 7a120c0..061e32b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
@@ -18,6 +18,7 @@
     is: 'gr-account-link',
 
     properties: {
+      additionalText: String,
       account: Object,
       avatarImageSize: {
         type: Number,
@@ -35,9 +36,5 @@
           account.email || account.username || account.name ||
           account._account_id);
     },
-
-    _computeShowEmail(account) {
-      return !!(account && !account.name);
-    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
index 11b099b..0c9661f 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
@@ -34,17 +34,44 @@
 <script>
   suite('gr-account-link tests', () => {
     let element;
+    let sandbox;
 
     setup(() => {
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({}); },
       });
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(() => {
+      sandbox.restore();
     });
 
     test('computed fields', () => {
-      assert.equal(element._computeShowEmail({name: 'asd'}), false);
-      assert.equal(element._computeShowEmail({}), true);
+      const url = 'test/url';
+      const urlStub = sandbox.stub(Gerrit.Nav, 'getUrlForOwner').returns(url);
+      const account = {
+        email: 'email',
+        username: 'username',
+        name: 'name',
+        _account_id: '_account_id',
+      };
+      assert.isNotOk(element._computeOwnerLink());
+      assert.equal(element._computeOwnerLink(account), url);
+      assert.isTrue(urlStub.lastCall.calledWithExactly('email'));
+
+      delete account.email;
+      assert.equal(element._computeOwnerLink(account), url);
+      assert.isTrue(urlStub.lastCall.calledWithExactly('username'));
+
+      delete account.username;
+      assert.equal(element._computeOwnerLink(account), url);
+      assert.isTrue(urlStub.lastCall.calledWithExactly('name'));
+
+      delete account.name;
+      assert.equal(element._computeOwnerLink(account), url);
+      assert.isTrue(urlStub.lastCall.calledWithExactly('_account_id'));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
index 51fa616..cc35e08 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
@@ -56,6 +56,9 @@
         font-family: var(--font-family-bold);
         margin-left: 1em;
         text-decoration: none;
+        --gr-button: {
+          padding: 0;
+        }
       }
     </style>
     <span class="text">[[text]]</span>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
index b8dcb8d..79960d4 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
@@ -41,8 +41,7 @@
       assert.isNull(element.parentNode);
       element.show();
       assert.equal(element.parentNode, document.body);
-      element.customStyle['--gr-alert-transition-duration'] = '0ms';
-      element.updateStyles();
+      element.updateStyles({'--gr-alert-transition-duration': '0ms'});
       element.hide();
       assert.isNull(element.parentNode);
     });
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-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index a263583..361192f 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -173,6 +173,7 @@
 
     selectAll() {
       const nativeInputElement = this.$.input.inputElement;
+      if (!this.$.input.value) { return; }
       nativeInputElement.setSelectionRange(0, this.$.input.value.length);
     },
 
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
index 605eaf7..e3ea213 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -80,6 +80,18 @@
       });
     });
 
+    test('selectAll', () => {
+      const nativeInput = element.$.input.inputElement;
+      const selectionStub = sandbox.stub(nativeInput, 'setSelectionRange');
+
+      element.selectAll();
+      assert.isFalse(selectionStub.called);
+
+      element.$.input.value = 'test';
+      element.selectAll();
+      assert.isTrue(selectionStub.called);
+    });
+
     test('esc key behavior', done => {
       let promise;
       const queryStub = sandbox.spy(() => {
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..a065f7a 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -24,7 +24,10 @@
 <dom-module id="gr-button">
   <template strip-whitespace>
     <style include="shared-styles">
+      /* general styles for all buttons */
       :host {
+        --background-color: var(--gr-button-background, #fff);
+        --button-color: var(--gr-button-color, var(--color-link));
         display: inline-block;
         font-family: var(--font-family-bold);
         font-size: 12px;
@@ -33,69 +36,63 @@
       :host([hidden]) {
         display: none;
       }
-      :host([link]) {
-        background-color: transparent;
-        border: none;
-        color: var(--color-link);
-        font-size: inherit;
-        font-family: var(--font-family-bold);
-        text-transform: none;
-      }
-      :host([link]) paper-button {
-        margin: 0;
-        padding: 0;
-        @apply --gr-button;
-      }
-      paper-button[raised] {
-        background-color: var(--gr-button-background, #fff);
-        color: var(--gr-button-color, --color-link);
-      }
       :host([no-uppercase]) paper-button {
         text-transform: none;
       }
-      /* todo (beckysiegel) switch all secondary to primary as there is no color
-        distinction anymore. */
-      :host([primary]) paper-button[raised],
-      :host([secondary]) paper-button[raised] {
-        background-color: var(--color-link);
-        color: #fff;
-      }
-      :host([primary][disabled]) paper-button[raised],
-      :host([disabled]) paper-button {
-        opacity: .5;
-      }
-      :host([link]) paper-button:hover,
-      :host([link]) paper-button:focus,
-      paper-button[raised]:hover,
-      paper-button[raised]:focus  {
-        color: var(--gr-button-hover-color, --color-button-hover);
-      }
-      :host([primary]) paper-button[raised]:hover,
-      :host([primary]) paper-button[raised]:focus,
-      :host([secondary]) paper-button[raised]:hover,
-      :host([secondary]) paper-button[raised]:focus {
-        background-color: var(--gr-button-hover-background-color, --color-button-hover);
-        color: var(--gr-button-color, #fff);
-      }
-      paper-button,
-      paper-button[raised],
-      paper-button[link] {
-        display: flex;
+      paper-button {
+        /* paper-button sets this to anti-aliased, which appears different than
+        roboto-medium elsewhere. */
+        -webkit-font-smoothing: initial;
         align-items: center;
+        background-color: var(--background-color);
+        color: var(--button-color);
+        display: flex;
+        font-family: inherit;
         justify-content: center;
-        margin: 0;
-        min-width: 0;
-        padding: .4em .85em;
+        margin: var(--margin, 0);
+        min-width: var(--border, 0);
+        padding: var(--padding, 5px 10px);
         @apply --gr-button;
       }
-      :host([link]) paper-button {
-        --paper-button: {
-          padding: 0;
-        }
+      paper-button:hover {
+        background: linear-gradient(
+          rgba(0, 0, 0, .12),
+          rgba(0, 0, 0, .12)
+        ), var(--background-color);
       }
+
+      /* Styles for raised buttons specifically */
+      :host([primary][raised]),
+      :host([secondary][raised]) {
+        --background-color: var(--color-link);
+        --button-color: #fff;
+      }
+
+      /* Keep below color definition for primary/secondary so that this takes
+       precedence when disabled. */
+      :host([disabled]) {
+        --background-color: #eaeaea;
+        --button-color: #a8a8a8;
+        cursor: default;
+      }
+
+      /* Styles for link buttons specifically */
+      :host([link]) {
+        --background-color: transparent;
+        --margin: 0;
+        --padding: 5px 4px;
+      }
+      :host([disabled][link]) {
+        --background-color: transparent;
+      }
+      :host([link][tertiary]) {
+        --button-color: var(--color-link-tertiary);
+      }
+
+      /* Styles for the optional down arrow */
       :host:not([down-arrow]) .downArrow {display: none; }
       :host([down-arrow]) .downArrow {
-        border-top: .36em solid var(--gr-button-arrow-color, #ccc);
+        border-top: .36em solid #ccc;
         border-left: .36em solid transparent;
         border-right: .36em solid transparent;
         margin-bottom: .05em;
@@ -103,23 +100,16 @@
         transition: border-top-color 200ms;
       }
       :host([down-arrow]) paper-button:hover .downArrow {
-        border-top-color: var(--gr-button-arrow-hover-color, #666);
-      }
-      :host([loading]) paper-button,
-      :host([disabled]) paper-button {
-        color: #aaa;
-      }
-      :host([loading]) paper-button,
-      :host([loading][disabled]) paper-button {
-        cursor: wait;
-        background-color: #efefef;
-        color: #aaa;
+        border-top-color: #666;
       }
     </style>
-    <paper-button raised="[[!link]]" disabled="[[disabled]]">
-      <content></content>
+    <paper-button
+        raised="[[!link]]"
+        disabled="[[_computeDisabled(disabled, loading)]]"
+        tabindex="-1">
+      <slot></slot>
       <i class="downArrow"></i>
     </paper-button>
   </template>
   <script src="gr-button.js"></script>
-</dom-module>
\ No newline at end of file
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
index 879e019..7b62fb3 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,
@@ -27,6 +28,21 @@
         value: false,
         reflectToAttribute: true,
       },
+      raised: {
+        type: Boolean,
+        reflectToAttribute: true,
+        computed: '_isRaised(link)',
+      },
+      loading: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      tertiary: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
       disabled: {
         type: Boolean,
         observer: '_disabledChanged',
@@ -48,6 +64,10 @@
       keydown: '_handleKeydown',
     },
 
+    observers: [
+      '_computeDisabled(disabled, loading)',
+    ],
+
     behaviors: [
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.TooltipBehavior,
@@ -58,6 +78,10 @@
       tabindex: '0',
     },
 
+    _isRaised(isLink) {
+      return !isLink;
+    },
+
     _handleAction(e) {
       if (this.disabled) {
         e.preventDefault();
@@ -70,6 +94,11 @@
         this._enabledTabindex = this.getAttribute('tabindex');
       }
       this.setAttribute('tabindex', disabled ? '-1' : this._enabledTabindex);
+      this.updateStyles();
+    },
+
+    _computeDisabled(disabled, loading) {
+      return disabled || loading;
     },
 
     _handleKeydown(e) {
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
index d78427b..c0ceb33 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
@@ -51,6 +51,16 @@
       sandbox.restore();
     });
 
+    test('disabled is set by disabled or loading', () => {
+      assert.isFalse(element.$$('paper-button').disabled);
+      element.disabled = true;
+      assert.isTrue(element.$$('paper-button').disabled);
+      element.disabled = false;
+      assert.isFalse(element.$$('paper-button').disabled);
+      element.loading = true;
+      assert.isTrue(element.$$('paper-button').disabled);
+    });
+
     for (const eventName of ['tap', 'click']) {
       test('dispatches ' + eventName + ' event', () => {
         const spy = addSpyOn(eventName);
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
index 08bc6eb..644b557 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
@@ -22,8 +22,7 @@
   <template>
     <style include="shared-styles">
       :host {
-        display: inline-block;
-        overflow: hidden;
+        display: flex;
       }
       .starButton {
         background-color: transparent;
@@ -40,7 +39,7 @@
         height: 1em;
       }
       .starButton-active svg {
-        fill: #ffac33;
+        fill: var(--color-link);
       }
     </style>
     <button
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.html b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.html
new file mode 100644
index 0000000..5680a02
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.html
@@ -0,0 +1,70 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-change-status">
+  <template>
+    <style include="shared-styles">
+      .chip {
+        border-radius: 4px;
+        background-color: var(--chip-background-color);
+        color: #fff;
+        font-family: var(--font-family);
+        font-size: 13px;
+        padding: .1em .5em;
+        white-space: nowrap;
+      }
+      :host(.merged) .chip {
+        background-color: #5b9d52;
+      }
+      :host(.abandoned) .chip {
+        background-color: #afafaf;
+      }
+      :host(.wip) .chip {
+        background-color: #8f756c;
+      }
+      :host(.private) .chip {
+        background-color: #c17ccf;
+      }
+      :host(.merge-conflict) .chip {
+        background-color: #dc5c60;
+      }
+      :host(.active) .chip {
+        background-color: #29b6f6;
+      }
+      :host(.ready-to-submit) .chip {
+        background-color: #e10ca3;
+      }
+      :host(.custom) .chip {
+        background-color: #825cc2;
+      }
+    </style>
+    <gr-tooltip-content
+        has-tooltip
+        position-below
+        title="[[tooltipText]]"
+        max-width="40em">
+      <div class="chip" aria-label$="Label: [[status]]">
+          [[_computeStatusString(status)]]</div>
+    </gr-tooltip-content>
+  </template>
+  <script src="gr-change-status.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
new file mode 100644
index 0000000..18896fd
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
@@ -0,0 +1,79 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  const ChangeStates = {
+    MERGED: 'Merged',
+    ABANDONED: 'Abandoned',
+    MERGE_CONFLIGT: 'Merge Conflict',
+    WIP: 'WIP',
+    PRIVATE: 'Private',
+  };
+
+  const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
+      'It will not appear in dashboards, and email notifications will be ' +
+      'silenced until the review is started.';
+
+  const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
+      'current reviewers (or anyone with "View Private Changes" permission).';
+
+  Polymer({
+    is: 'gr-change-status',
+
+    properties: {
+      status: {
+        type: String,
+        observer: '_updateChipDetails',
+      },
+      tooltipText: String,
+      hasTooltip: {
+        type: Boolean,
+        reflectToAttribute: true,
+        computed: '_determineHasTooltip(title)',
+      },
+    },
+
+    _determineHasTooltip(title) {
+      return !!title;
+    },
+
+    _computeStatusString(status) {
+      if (status === ChangeStates.WIP) {
+        return 'Work in Progress';
+      }
+      return status;
+    },
+
+    _toClassName(str) {
+      return str.toLowerCase().replace(/\s/g, '-');
+    },
+
+    _updateChipDetails(status, previousStatus) {
+      if (previousStatus) {
+        this.classList.remove(this._toClassName(previousStatus));
+      }
+      this.classList.add(this._toClassName(status));
+
+      switch (status) {
+        case ChangeStates.WIP:
+          this.tooltipText = WIP_TOOLTIP;
+          break;
+        case ChangeStates.PRIVATE:
+          this.tooltipText = PRIVATE_TOOLTIP;
+          break;
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
new file mode 100644
index 0000000..1085455
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
@@ -0,0 +1,107 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-status</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-change-status.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-status></gr-change-status>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-status tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('WIP', () => {
+      element.status = 'WIP';
+      assert.equal(element.$$('.chip').innerText, 'Work in Progress');
+      assert.isDefined(element.tooltipText);
+      assert.isTrue(element.classList.contains('wip'));
+    });
+
+    test('merged', () => {
+      element.status = 'Merged';
+      assert.equal(element.$$('.chip').innerText, element.status);
+      assert.isUndefined(element.tooltipText);
+      assert.isTrue(element.classList.contains('merged'));
+    });
+
+    test('abandoned', () => {
+      element.status = 'Abandoned';
+      assert.equal(element.$$('.chip').innerText, element.status);
+      assert.isUndefined(element.tooltipText);
+      assert.isTrue(element.classList.contains('abandoned'));
+    });
+
+    test('merge conflict', () => {
+      element.status = 'Merge Conflict';
+      assert.equal(element.$$('.chip').innerText, element.status);
+      assert.isUndefined(element.tooltipText);
+      assert.isTrue(element.classList.contains('merge-conflict'));
+    });
+
+    test('private', () => {
+      element.status = 'Private';
+      assert.equal(element.$$('.chip').innerText, element.status);
+      assert.isDefined(element.tooltipText);
+      assert.isTrue(element.classList.contains('private'));
+    });
+
+    test('active', () => {
+      element.status = 'Active';
+      assert.equal(element.$$('.chip').innerText, element.status);
+      assert.isUndefined(element.tooltipText);
+      assert.isTrue(element.classList.contains('active'));
+    });
+
+    test('ready to submit', () => {
+      element.status = 'Ready to submit';
+      assert.equal(element.$$('.chip').innerText, element.status);
+      assert.isUndefined(element.tooltipText);
+      assert.isTrue(element.classList.contains('ready-to-submit'));
+    });
+
+    test('updating status removes the previous class', () => {
+      element.status = 'Private';
+      assert.isTrue(element.classList.contains('private'));
+      assert.isFalse(element.classList.contains('wip'));
+
+      element.status = 'WIP';
+      assert.isFalse(element.classList.contains('private'));
+      assert.isTrue(element.classList.contains('wip'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
index b06b962..6801dea 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>
+      <header><slot name="header"></slot></header>
+      <main><slot name="main"></slot></main>
       <footer>
-        <gr-button primary on-tap="_handleConfirmTap" disabled="[[disabled]]">
+        <gr-button link on-tap="_handleCancelTap">[[cancelLabel]]</gr-button>
+        <gr-button link primary on-tap="_handleConfirmTap" disabled="[[disabled]]">
           [[confirmLabel]]
         </gr-button>
-        <gr-button on-tap="_handleCancelTap">Cancel</gr-button>
       </footer>
     </div>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
index 3d5e781..f322e25 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
@@ -34,6 +34,10 @@
         type: String,
         value: 'Confirm',
       },
+      cancelLabel: {
+        type: String,
+        value: 'Cancel',
+      },
       disabled: {
         type: Boolean,
         value: false,
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
index 932db89..e52ad39 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
@@ -16,9 +16,10 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-copy-clipboard">
   <template>
@@ -27,8 +28,6 @@
         align-items: center;
         display: flex;
         flex-wrap: wrap;
-        margin-bottom: .5em;
-        width: 60em;
       }
       .text label {
         flex: 0 0 100%;
@@ -37,15 +36,20 @@
         flex-grow: 1;
         margin-right: .3em;
       }
-      .hideInput {
+      .hideInput,
+      .hideLabel label {
         display: none;
       }
       input {
         font-family: var(--monospace-font-family);
         font-size: inherit;
       }
+      #icon {
+        height: 1.2em;
+        width: 1.2em;
+      }
     </style>
-    <div class="text">
+    <div class$="text [[_computeLabelClass(hideLabel)]]">
         <label>[[title]]</label>
         <input id="input" is="iron-input"
             class$="copyText [[_computeInputClass(hideInput)]]"
@@ -55,9 +59,11 @@
             readonly>
         <gr-button id="button"
             link
+            has-tooltip="[[hasTooltip]]"
             class="copyToClipboard"
+            title="[[buttonTitle]]"
             on-tap="_copyToClipboard">
-          copy
+          <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
         </gr-button>
       </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
index a371374..6a46ae0 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
@@ -22,10 +22,19 @@
     properties: {
       text: String,
       title: String,
+      buttonTitle: String,
+      hasTooltip: {
+        type: Boolean,
+        value: false,
+      },
       hideInput: {
         type: Boolean,
         value: false,
       },
+      hideLabel: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     focusOnCopy() {
@@ -36,17 +45,29 @@
       return hideInput ? 'hideInput' : '';
     },
 
+    _computeLabelClass(hideLabel) {
+      return hideLabel ? 'hideLabel' : '';
+    },
+
     _handleInputTap(e) {
       e.preventDefault();
       Polymer.dom(e).rootTarget.select();
     },
 
-    _copyToClipboard(e) {
+    _copyToClipboard() {
+      if (this.hideInput) {
+        this.$.input.style.display = 'block';
+      }
+      this.$.input.focus();
       this.$.input.select();
       document.execCommand('copy');
-      window.getSelection().removeAllRanges();
-      e.target.textContent = 'done';
-      this.async(() => { e.target.textContent = 'copy'; }, COPY_TIMEOUT_MS);
+      if (this.hideInput) {
+        this.$.input.style.display = 'none';
+      }
+      this.$.icon.icon = 'gr-icons:check';
+      this.async(
+          () => this.$.icon.icon = 'gr-icons:content-copy',
+          COPY_TIMEOUT_MS);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
index 7310629..13d8b9d 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
@@ -78,5 +78,12 @@
       flushAsynchronousOperations();
       assert.equal(getComputedStyle(element.$.input).display, 'none');
     });
+
+    test('hideLabel', () => {
+      assert.notEqual(getComputedStyle(element.$$('label')).display, 'none');
+      element.hideLabel = true;
+      flushAsynchronousOperations();
+      assert.equal(getComputedStyle(element.$$('label')).display, 'none');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.html b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.html
new file mode 100644
index 0000000..832747e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.html
@@ -0,0 +1,57 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<script>
+  (function(window) {
+    'use strict';
+    const GrCountStringFormatter = window.GrCountStringFormatter || {};
+
+    /**
+     * Returns a count plus string that is pluralized when necessary.
+     *
+     * @param {number} count
+     * @param {string} noun
+     * @return {string}
+     */
+    GrCountStringFormatter.computePluralString = function(count, noun) {
+      return this.computeString(count, noun) + (count > 1 ? 's' : '');
+    };
+
+    /**
+     * Returns a count plus string that is not pluralized.
+     *
+     * @param {number} count
+     * @param {string} noun
+     * @return {string}
+     */
+    GrCountStringFormatter.computeString = function(count, noun) {
+      if (count === 0) { return ''; }
+      return count + ' ' + noun;
+    };
+
+    /**
+     * Returns a count plus arbitrary text.
+     *
+     * @param {number} count
+     * @param {string} text
+     * @return {string}
+     */
+    GrCountStringFormatter.computeShortString = function(count, text) {
+      if (count === 0) { return ''; }
+      return count + text;
+    };
+    window.GrCountStringFormatter = GrCountStringFormatter;
+  })(window);
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
new file mode 100644
index 0000000..f33db80
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
@@ -0,0 +1,54 @@
+<!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-count-string-formatter</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-count-string-formatter.html"/>
+
+<script>
+  suite('gr-count-string-formatter tests', () => {
+    test('computeString', () => {
+      const noun = 'unresolved';
+      assert.equal(GrCountStringFormatter.computeString(0, noun), '');
+      assert.equal(GrCountStringFormatter.computeString(1, noun),
+          '1 unresolved');
+      assert.equal(GrCountStringFormatter.computeString(2, noun),
+          '2 unresolved');
+    });
+
+    test('computeShortString', () => {
+      const noun = 'c';
+      assert.equal(GrCountStringFormatter.computeShortString(0, noun), '');
+      assert.equal(GrCountStringFormatter.computeShortString(1, noun), '1c');
+      assert.equal(GrCountStringFormatter.computeShortString(2, noun), '2c');
+    });
+
+    test('computePluralString', () => {
+      const noun = 'comment';
+      assert.equal(GrCountStringFormatter.computePluralString(0, noun), '');
+      assert.equal(GrCountStringFormatter.computePluralString(1, noun),
+          '1 comment');
+      assert.equal(GrCountStringFormatter.computePluralString(2, noun),
+          '2 comments');
+    });
+  });
+</script>
+
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
index 21552d9..fb41ca6 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
@@ -30,7 +30,7 @@
       }
     </style>
     <span>
-      [[_computeDateStr(dateStr, _timeFormat, _relative)]]
+      [[_computeDateStr(dateStr, _timeFormat, _relative, showDateAndTime)]]
     </span>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
index 65a4c68..3f7b8db 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
@@ -37,6 +37,10 @@
         value: null,
         notify: true,
       },
+      showDateAndTime: {
+        type: Boolean,
+        value: false,
+      },
 
       /**
        * When true, the detailed date appears in a GR-TOOLTIP rather than in the
@@ -131,7 +135,7 @@
           diff < 180 * Duration.DAY;
     },
 
-    _computeDateStr(dateStr, timeFormat, relative) {
+    _computeDateStr(dateStr, timeFormat, relative, showDateAndTime) {
       if (!dateStr) { return ''; }
       const date = moment(util.parseDate(dateStr));
       if (!date.isValid()) { return ''; }
@@ -147,8 +151,13 @@
       let format = TimeFormats.MONTH_DAY_YEAR;
       if (this._isWithinDay(now, date)) {
         format = timeFormat;
-      } else if (this._isWithinHalfYear(now, date)) {
-        format = TimeFormats.MONTH_DAY;
+      } else {
+        if (this._isWithinHalfYear(now, date)) {
+          format = TimeFormats.MONTH_DAY;
+        }
+        if (this.showDateAndTime) {
+          format = `${format} ${timeFormat}`;
+        }
       }
       return date.format(format);
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
index 2c15ef6..b418a42 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
@@ -55,7 +55,8 @@
       return d;
     }
 
-    function testDates(nowStr, dateStr, expected, expectedTooltip, done) {
+    function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
+        expectedTooltip, done) {
       // Normalize and convert the date to mimic server response.
       dateStr = normalizedDate(dateStr)
           .toJSON().replace('T', ' ').slice(0, -1);
@@ -65,6 +66,9 @@
         const span = element.$$('span');
         assert.equal(span.textContent.trim(), expected);
         assert.equal(element.title, expectedTooltip);
+        element.showDateAndTime = true;
+        flushAsynchronousOperations();
+        assert.equal(span.textContent.trim(), expectedWithDateAndTime);
         done();
       });
     }
@@ -98,25 +102,33 @@
       test('Within 24 hours on same day', done => {
         testDates('2015-07-29 20:34:14.985000000',
             '2015-07-29 15:34:14.985000000',
-            '15:34', 'Jul 29, 2015, 15:34:14', done);
+            '15:34',
+            '15:34',
+            'Jul 29, 2015, 15:34:14', done);
       });
 
       test('Within 24 hours on different days', done => {
         testDates('2015-07-29 03:34:14.985000000',
             '2015-07-28 20:25:14.985000000',
-            'Jul 28', 'Jul 28, 2015, 20:25:14', done);
+            'Jul 28',
+            'Jul 28 20:25',
+            'Jul 28, 2015, 20:25:14', done);
       });
 
       test('More than 24 hours but less than six months', done => {
         testDates('2015-07-29 20:34:14.985000000',
             '2015-06-15 03:25:14.985000000',
-            'Jun 15', 'Jun 15, 2015, 03:25:14', done);
+            'Jun 15',
+            'Jun 15 03:25',
+            'Jun 15, 2015, 03:25:14', done);
       });
 
       test('More than six months', done => {
         testDates('2015-09-15 20:34:00.000000000',
             '2015-01-15 03:25:00.000000000',
-            'Jan 15, 2015', 'Jan 15, 2015, 03:25:00', done);
+            'Jan 15, 2015',
+            'Jan 15, 2015 03:25',
+            'Jan 15, 2015, 03:25:00', done);
       });
     });
 
@@ -135,7 +147,9 @@
       test('Within 24 hours on same day', done => {
         testDates('2015-07-29 20:34:14.985000000',
             '2015-07-29 15:34:14.985000000',
-            '3:34 PM', 'Jul 29, 2015, 3:34:14 PM', done);
+            '3:34 PM',
+            '3:34 PM',
+            'Jul 29, 2015, 3:34:14 PM', done);
       });
     });
 
@@ -153,13 +167,17 @@
       test('Within 24 hours on same day', done => {
         testDates('2015-07-29 20:34:14.985000000',
             '2015-07-29 15:34:14.985000000',
-            '5 hours ago', 'Jul 29, 2015, 3:34:14 PM', done);
+            '5 hours ago',
+            '5 hours ago',
+            'Jul 29, 2015, 3:34:14 PM', done);
       });
 
       test('More than six months', done => {
         testDates('2015-09-15 20:34:00.000000000',
             '2015-01-15 03:25:00.000000000',
-            '8 months ago', 'Jan 15, 2015, 3:25:00 AM', done);
+            '8 months ago',
+            '8 months ago',
+            'Jan 15, 2015, 3:25:00 AM', done);
       });
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
index 68c3848..0f3a469 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
@@ -52,10 +52,16 @@
         justify-content: space-between;
       }
       .commands {
+        display: flex;
+        flex-direction: column;
         border-bottom: 1px solid #ddd;
         border-top: 1px solid #ddd;
         padding: .5em;
       }
+      gr-copy-clipboard {
+        width: 60em;
+        margin-bottom: .5em;
+      }
     </style>
     <div class="schemes">
       <ul hidden$="[[!schemes.length]]" hidden>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
index 0916a89..e2d1cbd 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
@@ -31,7 +31,7 @@
       :host {
         display: inline-block;
       }
-      #trigger {
+      #triggerText {
         -moz-user-select: text;
         -ms-user-select: text;
         -webkit-user-select: text;
@@ -45,9 +45,9 @@
         background-color: #fff;
         box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
         max-height: 70vh;
-        margin-top: 1.5em;
+        margin-top: 2em;
         min-width: 266px;
-        max-width: 300px;
+        @apply --dropdown-content-style
       }
       paper-listbox {
         --paper-listbox: {
@@ -75,9 +75,6 @@
       paper-item:not(:last-of-type) {
         border-bottom: 1px solid #ddd;
       }
-      #trigger {
-        padding: .3em 0;
-      }
       .bottomContent {
         color: rgba(0,0,0,.54);
         font-size: .9em;
@@ -95,11 +92,11 @@
         --gr-button: {
           @apply --trigger-style;
         }
-        --gr-button-hover-color: var(--trigger-hover-color);
       }
       gr-date-formatter {
         color: rgba(0,0,0,.54);
         margin-left: 2em;
+        white-space: nowrap;
       }
       gr-select {
         display: none;
@@ -109,6 +106,7 @@
        dropdown content as if it is tapping whatever content is underneath it.
        The next two styles allow this to happen. */
       iron-dropdown {
+        max-width: none;
         pointer-events: none;
       }
       paper-listbox {
@@ -117,13 +115,14 @@
       @media only screen and (max-width: 50em) {
         gr-select {
           display: inline;
+          @apply --gr-select-style;
         }
         gr-button,
         iron-dropdown {
           display: none;
         }
         select {
-          max-width: 5.25em;
+          @apply --native-select-style;
         }
       }
     </style>
@@ -134,7 +133,7 @@
         class="dropdown-trigger"
         on-tap="_showDropdownTapHandler"
         slot="dropdown-trigger">
-      <span>[[text]]</span>
+      <span id="triggerText">[[text]]</span>
     </gr-button>
     <iron-dropdown
         id="dropdown"
@@ -147,24 +146,26 @@
           attr-for-selected="value"
           selected="{{value}}"
           on-tap="_handleDropdownTap">
-        <template is="dom-repeat" items="[[items]]">
-            <paper-item
-                disabled="[[item.disabled]]"
-                value="[[item.value]]">
-              <div class="topContent">
-                <div>[[item.text]]</div>
-                <template is="dom-if" if="[[item.date]]">
-                    <gr-date-formatter
-                        date-str="[[item.date]]"></gr-date-formatter>
-                </template>
-              </div>
-              <template is="dom-if" if="[[item.bottomText]]">
-                <div class="bottomContent">
-                  <div>[[item.bottomText]]</div>
-                </div>
+        <template is="dom-repeat"
+            items="[[items]]"
+            initial-count="[[initialCount]]">
+          <paper-item
+              disabled="[[item.disabled]]"
+              value="[[item.value]]">
+            <div class="topContent">
+              <div>[[item.text]]</div>
+              <template is="dom-if" if="[[item.date]]">
+                  <gr-date-formatter
+                      date-str="[[item.date]]"></gr-date-formatter>
               </template>
+            </div>
+            <template is="dom-if" if="[[item.bottomText]]">
+              <div class="bottomContent">
+                <div>[[item.bottomText]]</div>
+              </div>
+            </template>
           </paper-item>
-          </template>
+        </template>
       </paper-listbox>
     </iron-dropdown>
     <gr-select bind-value="{{value}}">
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
index 8f6a763..d4225d9 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
@@ -54,6 +54,7 @@
      */
 
     properties: {
+      initialCount: Number,
       /** @type {!Array<!Defs.item>} */
       items: Object,
       text: String,
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-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index 49e5a5e..90de1ea 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -162,6 +162,8 @@
      * @param {!Event} e
      */
     _showDropdownTapHandler(e) {
+      e.preventDefault();
+      e.stopPropagation();
       this._open();
     },
 
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
index e8c8037..cd7fa1c 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
@@ -17,6 +17,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../gr-storage/gr-storage.html">
 
 <dom-module id="gr-editable-content">
   <template>
@@ -42,7 +43,7 @@
       }
     </style>
     <div hidden$="[[editing]]">
-      <content></content>
+      <slot></slot>
     </div>
     <div class="editor" hidden$="[[!editing]]">
       <iron-autogrow-textarea
@@ -58,6 +59,7 @@
             disabled="[[disabled]]">Cancel</gr-button>
       </div>
     </div>
+    <gr-storage id="storage"></gr-storage>
   </template>
   <script src="gr-editable-content.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
index e6ea72d..846a272 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
@@ -14,6 +14,9 @@
 (function() {
   'use strict';
 
+  const RESTORED_MESSAGE = 'Content restored from a previous edit.';
+  const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+
   Polymer({
     is: 'gr-editable-content',
 
@@ -29,6 +32,12 @@
      * @event editable-content-cancel
      */
 
+    /**
+     * Fired when content is restored from storage.
+     *
+     * @event show-alert
+     */
+
     properties: {
       content: {
         notify: true,
@@ -45,24 +54,56 @@
         value: false,
       },
       removeZeroWidthSpace: Boolean,
+      // If no storage key is provided, content is not stored.
+      storageKey: String,
       _saveDisabled: {
         computed: '_computeSaveDisabled(disabled, content, _newContent)',
         type: Boolean,
         value: true,
       },
-      _newContent: String,
+      _newContent: {
+        type: String,
+        observer: '_newContentChanged',
+      },
     },
 
     focusTextarea() {
       this.$$('iron-autogrow-textarea').textarea.focus();
     },
 
+    _newContentChanged(newContent, oldContent) {
+      if (!this.storageKey) { return; }
+
+      this.debounce('store', () => {
+        if (newContent.length) {
+          this.$.storage.setEditableContentItem(this.storageKey, newContent);
+        } else {
+          this.$.storage.eraseEditableContentItem(this.storageKey);
+        }
+      }, STORAGE_DEBOUNCE_INTERVAL_MS);
+    },
+
     _editingChanged(editing) {
       if (!editing) { return; }
 
+      let content;
+      if (this.storageKey) {
+        const storedContent =
+            this.$.storage.getEditableContentItem(this.storageKey);
+        if (storedContent && storedContent.message) {
+          content = storedContent.message;
+          this.dispatchEvent(new CustomEvent('show-alert',
+              {detail: {message: RESTORED_MESSAGE}, bubbles: true}));
+        }
+      }
+      if (!content) {
+        content = this.content || '';
+      }
+
       // TODO(wyatta) switch linkify sequence, see issue 5526.
       this._newContent = this.removeZeroWidthSpace ?
-          this.content.replace(/^R=\u200B/gm, 'R=') : this.content;
+          content.replace(/^R=\u200B/gm, 'R=') :
+          content;
     },
 
     _computeSaveDisabled(disabled, content, newContent) {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
index d8e5b21..b306703 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
@@ -36,11 +36,15 @@
 <script>
   suite('gr-editable-content tests', () => {
     let element;
+    let sandbox;
 
     setup(() => {
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
     });
 
+    teardown(() => { sandbox.restore(); });
+
     test('save event', done => {
       element._newContent = 'foo';
       element.addEventListener('editable-content-save', e => {
@@ -94,5 +98,57 @@
         assert.isFalse(element.$$('gr-button[primary]').disabled);
       });
     });
+
+    suite('storageKey and related behavior', () => {
+      let dispatchSpy;
+      setup(() => {
+        element.content = 'current content';
+        element.storageKey = 'test';
+        dispatchSpy = sandbox.spy(element, 'dispatchEvent');
+      });
+
+      test('editing toggled to true, has stored data', () => {
+        sandbox.stub(element.$.storage, 'getEditableContentItem')
+            .returns({message: 'stored content'});
+        element.editing = true;
+
+        assert.equal(element._newContent, 'stored content');
+        assert.isTrue(dispatchSpy.called);
+        assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
+      });
+
+      test('editing toggled to true, has no stored data', () => {
+        sandbox.stub(element.$.storage, 'getEditableContentItem')
+            .returns({});
+        element.editing = true;
+
+        assert.equal(element._newContent, 'current content');
+        assert.isFalse(dispatchSpy.called);
+      });
+
+      test('edits are cached', () => {
+        const storeStub =
+            sandbox.stub(element.$.storage, 'setEditableContentItem');
+        const eraseStub =
+            sandbox.stub(element.$.storage, 'eraseEditableContentItem');
+        element.editing = true;
+
+        element._newContent = 'new content';
+        flushAsynchronousOperations();
+        element.flushDebouncer('store');
+
+        assert.isTrue(storeStub.called);
+        assert.deepEqual(
+            [element.storageKey, element._newContent],
+            storeStub.lastCall.args);
+
+        element._newContent = '';
+        flushAsynchronousOperations();
+        element.flushDebouncer('store');
+
+        assert.isTrue(eraseStub.called);
+        assert.deepEqual([element.storageKey], eraseStub.lastCall.args);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
index 6eb7c8d..07aa537 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
@@ -90,10 +90,11 @@
             <paper-input
                 id="input"
                 label="[[labelText]]"
+                maxlength="[[maxLength]]"
                 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-editable-label/gr-editable-label.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
index af06291..94a6b64 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
@@ -51,6 +51,7 @@
         reflectToAttribute: true,
         value: false,
       },
+      maxLength: Number,
       _inputText: String,
       // This is used to push the iron-input element up on the page, so
       // the input is placed in approximately the same position as the
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
index a1c80ae..967ff02 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
@@ -43,7 +43,7 @@
       }
     </style>
     <header id="header" class$="[[_computeHeaderClass(_headerFloating, _topLast)]]">
-      <content></content>
+      <slot></slot>
     </header>
   </template>
   <script src="gr-fixed-panel.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
index 4e3e045..b43e994 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
@@ -27,6 +27,10 @@
       <g id="side-by-side"><path d="M17.1578947,10.8888889 L2.84210526,10.8888889 C2.37894737,10.8888889 2,11.2888889 2,11.7777778 L2,17.1111111 C2,17.6 2.37894737,18 2.84210526,18 L17.1578947,18 C17.6210526,18 18,17.6 18,17.1111111 L18,11.7777778 C18,11.2888889 17.6210526,10.8888889 17.1578947,10.8888889 Z M17.1578947,2 L2.84210526,2 C2.37894737,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.37894737,9.11111111 2.84210526,9.11111111 L17.1578947,9.11111111 C17.6210526,9.11111111 18,8.71111111 18,8.22222222 L18,2.88888889 C18,2.4 17.6210526,2 17.1578947,2 Z M16.1973628,2 L2.78874238,2 C2.35493407,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.35493407,9.11111111 2.78874238,9.11111111 L16.1973628,9.11111111 C16.6311711,9.11111111 16.9861052,8.71111111 16.9861052,8.22222222 L16.9861052,2.88888889 C16.9861052,2.4 16.6311711,2 16.1973628,2 Z" id="Shape" transform="scale(1.2) translate(10.000000, 10.000000) rotate(-90.000000) translate(-10.000000, -10.000000)"/></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="unified"><path d="M4,2 L17,2 C18.1045695,2 19,2.8954305 19,4 L19,16 C19,17.1045695 18.1045695,18 17,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 L2,4 C2,2.8954305 2.8954305,2 4,2 L4,2 Z M4,7 L4,9 L17,9 L17,7 L4,7 Z M4,11 L4,13 L17,13 L17,11 L4,11 Z" id="Combined-Shape" transform="scale(1.2)"/></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></g>
     </defs>
   </svg>
-</iron-iconset-svg>
\ No newline at end of file
+</iron-iconset-svg>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js
new file mode 100644
index 0000000..7810d7d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js
@@ -0,0 +1,50 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+
+  /**
+   * Used to create a context for GrAnnotationActionsInterface.
+   * @param {HTMLElement} el The DIV.contentText element to apply the
+   *     annotation to using annotateRange.
+   * @param {GrDiffLine} line The line object.
+   * @param {String} path The file path (eg: /COMMIT_MSG').
+   * @param {String} changeNum The Gerrit change number.
+   * @param {String} patchNum The Gerrit patch number.
+   */
+  function GrAnnotationActionsContext(el, line, path, changeNum, patchNum) {
+    this._el = el;
+
+    this.line = line;
+    this.path = path;
+    this.changeNum = parseInt(changeNum);
+    this.patchNum = parseInt(patchNum);
+  }
+
+  /**
+   * Method to add annotations to a line.
+   * @param {Number} start The line number where the update starts.
+   * @param {Number} end The line number where the update ends.
+   * @param {String} cssClass The name of a CSS class created using Gerrit.css.
+   * @param {String} side The side of the update. ('left' or 'right')
+   */
+  GrAnnotationActionsContext.prototype.annotateRange = function(
+      start, end, cssClass, side) {
+    if (this._el.getAttribute('data-side') == side) {
+      GrAnnotation.annotateElement(this._el, start, end, cssClass);
+    }
+  };
+
+  window.GrAnnotationActionsContext = GrAnnotationActionsContext;
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
new file mode 100644
index 0000000..55ca90f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
@@ -0,0 +1,77 @@
+<!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-annotation-actions-context</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../diff/gr-diff-highlight/gr-annotation.js"></script>
+
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-js-api-interface.html"/>
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-annotation-actions-context tests', () => {
+    let instance;
+    let sandbox;
+    let el;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      const str = 'lorem ipsum blah blah';
+      const line = {text: str};
+      el = document.createElement('div');
+      el.textContent = str;
+      el.setAttribute('data-side', 'right');
+      instance = new GrAnnotationActionsContext(
+          el, line, 'dummy/path', '123', '1');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('test annotateRange', () => {
+      annotateElementSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+      const start = 0;
+      const end = 100;
+      const cssClass = Gerrit.css('background-color: #000000');
+
+      // Assert annotateElement is not called when side is different.
+      instance.annotateRange(start, end, cssClass, 'left');
+      assert.equal(annotateElementSpy.callCount, 0);
+
+      // Assert annotateElement is called once when side is the same.
+      instance.annotateRange(start, end, cssClass, 'right');
+      assert.equal(annotateElementSpy.callCount, 1);
+      const args = annotateElementSpy.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], start);
+      assert.equal(args[2], end);
+      assert.equal(args[3], cssClass);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
new file mode 100644
index 0000000..94bae45
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
@@ -0,0 +1,143 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+
+  function GrAnnotationActionsInterface(plugin) {
+    this.plugin = plugin;
+    // Return this instance when there is an annotatediff event.
+    plugin.on('annotatediff', this);
+
+    // Collect all annotation layers instantiated by getLayer. Will be used when
+    // notifying their listeners in the notify function.
+    this._annotationLayers = [];
+
+    // Default impl is a no-op.
+    this._addLayerFunc = annotationActionsContext => {};
+  }
+
+  /**
+   * Register a function to call to apply annotations. Plugins should use
+   * GrAnnotationActionsContext.annotateRange to apply a CSS class to a range
+   * within a line.
+   * @param {Function<GrAnnotationActionsContext>} addLayerFunc The function
+   *     that will be called when the AnnotationLayer is ready to annotate.
+   */
+  GrAnnotationActionsInterface.prototype.addLayer = function(addLayerFunc) {
+    this._addLayerFunc = addLayerFunc;
+    return this;
+  };
+
+  /**
+   * The specified function will be called with a notify function for the plugin
+   * to call when it has all required data for annotation. Optional.
+   * @param {Function<Function<String, Number, Number, String>>} notifyFunc See
+   *     doc of the notify function below to see what it does.
+   */
+  GrAnnotationActionsInterface.prototype.addNotifier = function(notifyFunc) {
+    // Register the notify function with the plugin's function.
+    notifyFunc(this.notify.bind(this));
+    return this;
+  };
+
+  /**
+   * The notify function will call the listeners of all required annotation
+   * layers. Intended to be called by the plugin when all required data for
+   * annotation is available.
+   * @param {String} path The file path whose listeners should be notified.
+   * @param {Number} start The line where the update starts.
+   * @param {Number} end The line where the update ends.
+   * @param {String} side The side of the update ('left' or 'right').
+   */
+  GrAnnotationActionsInterface.prototype.notify = function(
+      path, startRange, endRange, side) {
+    for (const annotationLayer of this._annotationLayers) {
+      // Notify only the annotation layer that is associated with the specified
+      // path.
+      if (annotationLayer._path === path) {
+        annotationLayer.notifyListeners(startRange, endRange, side);
+        break;
+      }
+    }
+  };
+
+  /**
+   * Should be called to register annotation layers by the framework. Not
+   * intended to be called by plugins.
+   * @param {String} path The file path (eg: /COMMIT_MSG').
+   * @param {String} changeNum The Gerrit change number.
+   * @param {String} patchNum The Gerrit patch number.
+   */
+  GrAnnotationActionsInterface.prototype.getLayer = function(
+      path, changeNum, patchNum) {
+    const annotationLayer = new AnnotationLayer(path, changeNum, patchNum,
+                                                this._addLayerFunc);
+    this._annotationLayers.push(annotationLayer);
+    return annotationLayer;
+  };
+
+  /**
+   * Used to create an instance of the Annotation Layer interface.
+   * @param {String} path The file path (eg: /COMMIT_MSG').
+   * @param {String} changeNum The Gerrit change number.
+   * @param {String} patchNum The Gerrit patch number.
+   * @param {Function<GrAnnotationActionsContext>} addLayerFunc The function
+   *     that will be called when the AnnotationLayer is ready to annotate.
+   */
+  function AnnotationLayer(path, changeNum, patchNum, addLayerFunc) {
+    this._path = path;
+    this._changeNum = changeNum;
+    this._patchNum = patchNum;
+    this._addLayerFunc = addLayerFunc;
+
+    this._listeners = [];
+  }
+
+  /**
+   * Register a listener for layer updates.
+   * @param {Function<Number, Number, String>} fn The update handler function.
+   *     Should accept as arguments the line numbers for the start and end of
+   *     the update and the side as a string.
+   */
+  AnnotationLayer.prototype.addListener = function(fn) {
+    this._listeners.push(fn);
+  };
+
+  /**
+   * Layer method to add annotations to a line.
+   * @param {HTMLElement} el The DIV.contentText element to apply the
+   *     annotation to.
+   * @param {GrDiffLine} line The line object.
+   */
+  AnnotationLayer.prototype.annotate = function(el, line) {
+    const annotationActionsContext = new GrAnnotationActionsContext(
+        el, line, this._path, this._changeNum, this._patchNum);
+    this._addLayerFunc(annotationActionsContext);
+  };
+
+  /**
+   * Notify Layer listeners of changes to annotations.
+   * @param {Number} start The line where the update starts.
+   * @param {Number} end The line where the update ends.
+   * @param {String} side The side of the update. ('left' or 'right')
+   */
+  AnnotationLayer.prototype.notifyListeners = function(
+      startRange, endRange, side) {
+    for (const listener of this._listeners) {
+      listener(startRange, endRange, side);
+    }
+  };
+
+  window.GrAnnotationActionsInterface = GrAnnotationActionsInterface;
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
new file mode 100644
index 0000000..39623ed
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
@@ -0,0 +1,133 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-annotation-actions-js-api-js-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="../../change/gr-change-actions/gr-change-actions.html">
+
+<script>
+  suite('gr-annotation-actions-js-api tests', () => {
+    let annotationActions;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      let plugin;
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      annotationActions = plugin.annotationApi();
+    });
+
+    teardown(() => {
+      annotationActions = null;
+      sandbox.restore();
+    });
+
+    test('add/get layer', () => {
+      const str = 'lorem ipsum blah blah';
+      const line = {text: str};
+      el = document.createElement('div');
+      el.textContent = str;
+      const changeNum = 1234;
+      const patchNum = 2;
+      let testLayerFuncCalled = false;
+
+      const testLayerFunc = context => {
+        testLayerFuncCalled = true;
+        assert.equal(context.line, line);
+        assert.equal(context.changeNum, changeNum);
+        assert.equal(context.patchNum, 2);
+      };
+      annotationActions.addLayer(testLayerFunc);
+
+      const annotationLayer = annotationActions.getLayer(
+          '/dummy/path', changeNum, patchNum);
+
+      annotationLayer.annotate(el, line);
+      assert.isTrue(testLayerFuncCalled);
+    });
+
+    test('add notifier', () => {
+      const path1 = '/dummy/path1';
+      const path2 = '/dummy/path2';
+      const annotationLayer1 = annotationActions.getLayer(path1, 1, 2);
+      const annotationLayer2 = annotationActions.getLayer(path2, 1, 2);
+      const layer1Spy = sandbox.spy(annotationLayer1, 'notifyListeners');
+      const layer2Spy = sandbox.spy(annotationLayer2, 'notifyListeners');
+
+      let notify;
+      const notifyFunc = n => {
+        notifyFuncCalled = true;
+        notify = n;
+      };
+      annotationActions.addNotifier(notifyFunc);
+      assert.isTrue(notifyFuncCalled);
+
+      // Assert that no layers are invoked with a different path.
+      notify('/dummy/path3', 0, 10, 'right');
+      assert.isFalse(layer1Spy.called);
+      assert.isFalse(layer2Spy.called);
+
+      // Assert that only the 1st layer is invoked with path1.
+      notify(path1, 0, 10, 'right');
+      assert.isTrue(layer1Spy.called);
+      assert.isFalse(layer2Spy.called);
+
+      // Reset spies.
+      layer1Spy.reset();
+      layer2Spy.reset();
+
+      // Assert that only the 2nd layer is invoked with path2.
+      notify(path2, 0, 20, 'left');
+      assert.isFalse(layer1Spy.called);
+      assert.isTrue(layer2Spy.called);
+    });
+
+    test('layer notify listeners', () => {
+      const annotationLayer = annotationActions.getLayer(
+          '/dummy/path', 1, 2);
+      let listenerCalledTimes = 0;
+      const startRange = 10;
+      const endRange = 20;
+      const side = 'right';
+      const listener = (st, end, s) => {
+        listenerCalledTimes++;
+        assert.equal(st, startRange);
+        assert.equal(end, endRange);
+        assert.equal(s, side);
+      };
+
+      // Notify with 0 listeners added.
+      annotationLayer.notifyListeners(startRange, endRange, side);
+      assert.equal(listenerCalledTimes, 0);
+
+      // Add 1 listener.
+      annotationLayer.addListener(listener);
+      annotationLayer.notifyListeners(startRange, endRange, side);
+      assert.equal(listenerCalledTimes, 1);
+
+      // Add 1 more listener. Total 2 listeners.
+      annotationLayer.addListener(listener);
+      annotationLayer.notifyListeners(startRange, endRange, side);
+      assert.equal(listenerCalledTimes, 3);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
index 6853a41..fe74906 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
@@ -14,21 +14,49 @@
 (function(window) {
   'use strict';
 
+  /**
+   * Ensure GrChangeActionsInterface instance has access to gr-change-actions
+   * element and retrieve if the interface was created before element.
+   * @param {!GrChangeActionsInterface} api
+   */
+  function ensureEl(api) {
+    if (!api._el) {
+      const sharedApiElement = document.createElement('gr-js-api-interface');
+      setEl(api, sharedApiElement.getElement(
+          sharedApiElement.Element.CHANGE_ACTIONS));
+    }
+  }
+
+  /**
+   * Set gr-change-actions element to a GrChangeActionsInterface instance.
+   * @param {!GrChangeActionsInterface} api
+   * @param {!Element} el gr-change-actions
+   */
+  function setEl(api, el) {
+    if (!el) {
+      console.warn('changeActions() is not ready');
+      return;
+    }
+    api._el = el;
+    api.RevisionActions = el.RevisionActions;
+    api.ChangeActions = el.ChangeActions;
+    api.ActionType = el.ActionType;
+  }
+
   function GrChangeActionsInterface(plugin, el) {
     this.plugin = plugin;
-    this._el = el;
-    this.RevisionActions = el.RevisionActions;
-    this.ChangeActions = el.ChangeActions;
-    this.ActionType = el.ActionType;
+    setEl(this, el);
   }
 
   GrChangeActionsInterface.prototype.addPrimaryActionKey = function(key) {
+    ensureEl(this);
     if (this._el.primaryActionKeys.includes(key)) { return; }
 
     this._el.push('primaryActionKeys', key);
   };
 
   GrChangeActionsInterface.prototype.removePrimaryActionKey = function(key) {
+    ensureEl(this);
     this._el.primaryActionKeys = this._el.primaryActionKeys.filter(k => {
       return k !== key;
     });
@@ -36,45 +64,55 @@
 
   GrChangeActionsInterface.prototype.setActionOverflow = function(type, key,
       overflow) {
+    ensureEl(this);
     return this._el.setActionOverflow(type, key, overflow);
   };
 
   GrChangeActionsInterface.prototype.setActionPriority = function(type, key,
       priority) {
+    ensureEl(this);
     return this._el.setActionPriority(type, key, priority);
   };
 
   GrChangeActionsInterface.prototype.setActionHidden = function(type, key,
       hidden) {
+    ensureEl(this);
     return this._el.setActionHidden(type, key, hidden);
   };
 
   GrChangeActionsInterface.prototype.add = function(type, label) {
+    ensureEl(this);
     return this._el.addActionButton(type, label);
   };
 
   GrChangeActionsInterface.prototype.remove = function(key) {
+    ensureEl(this);
     return this._el.removeActionButton(key);
   };
 
   GrChangeActionsInterface.prototype.addTapListener = function(key, handler) {
+    ensureEl(this);
     this._el.addEventListener(key + '-tap', handler);
   };
 
   GrChangeActionsInterface.prototype.removeTapListener = function(key,
       handler) {
+    ensureEl(this);
     this._el.removeEventListener(key + '-tap', handler);
   };
 
   GrChangeActionsInterface.prototype.setLabel = function(key, text) {
+    ensureEl(this);
     this._el.setActionButtonProp(key, 'label', text);
   };
 
   GrChangeActionsInterface.prototype.setEnabled = function(key, enabled) {
+    ensureEl(this);
     this._el.setActionButtonProp(key, 'enabled', enabled);
   };
 
   GrChangeActionsInterface.prototype.getActionDetails = function(action) {
+    ensureEl(this);
     return this._el.getActionDetails(action) ||
       this._el.getActionDetails(this.plugin.getPluginName() + '~' + action);
   };
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
index 53d7345..21f7629 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
@@ -39,6 +39,7 @@
   suite('gr-js-api-interface tests', () => {
     let element;
     let changeActions;
+    let plugin;
 
     // Because deepEqual doesn’t behave in Safari.
     function assertArraysEqual(actual, expected) {
@@ -48,132 +49,152 @@
       }
     }
 
-    setup(() => {
-      element = fixture('basic');
-      element.change = {};
-      element._hasKnownChainState = false;
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      changeActions = plugin.changeActions();
-    });
+    suite('early init', () => {
+      setup(() => {
+        Gerrit.install(p => { plugin = p; }, '0.1',
+            'http://test.com/plugins/testplugin/static/test.js');
+        changeActions = plugin.changeActions();
+        element = fixture('basic');
+      });
 
-    teardown(() => {
-      changeActions = null;
-    });
+      teardown(() => {
+        changeActions = null;
+      });
 
-    test('property existence', () => {
-      const properties = [
-        'ActionType',
-        'ChangeActions',
-        'RevisionActions',
-      ];
-      for (const p of properties) {
-        assertArraysEqual(changeActions[p], element[p]);
-      }
-    });
-
-    test('add/remove primary action keys', () => {
-      element.primaryActionKeys = [];
-      changeActions.addPrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['foo']);
-      changeActions.addPrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['foo']);
-      changeActions.addPrimaryActionKey('bar');
-      assertArraysEqual(element.primaryActionKeys, ['foo', 'bar']);
-      changeActions.removePrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['bar']);
-      changeActions.removePrimaryActionKey('baz');
-      assertArraysEqual(element.primaryActionKeys, ['bar']);
-      changeActions.removePrimaryActionKey('bar');
-      assertArraysEqual(element.primaryActionKeys, []);
-    });
-
-    test('action buttons', done => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      const handler = sinon.spy();
-      changeActions.addTapListener(key, handler);
-      flush(() => {
-        MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
-        assert(handler.calledOnce);
-        changeActions.removeTapListener(key, handler);
-        MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
-        assert(handler.calledOnce);
-        changeActions.remove(key);
-        flush(() => {
-          assert.isNull(element.$$('[data-action-key="' + key + '"]'));
-          done();
+      test('does not throw', ()=> {
+        assert.doesNotThrow(() => {
+          changeActions.add('change', 'foo');
         });
       });
     });
 
-    test('action button properties', done => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush(() => {
-        const button = element.$$('[data-action-key="' + key + '"]');
-        assert.isOk(button);
-        assert.equal(button.getAttribute('data-label'), 'Bork!');
-        assert.isNotOk(button.disabled);
-        changeActions.setLabel(key, 'Yo');
-        changeActions.setEnabled(key, false);
+    suite('normal init', () => {
+      setup(() => {
+        element = fixture('basic');
+        element.change = {};
+        element._hasKnownChainState = false;
+        Gerrit.install(p => { plugin = p; }, '0.1',
+            'http://test.com/plugins/testplugin/static/test.js');
+        changeActions = plugin.changeActions();
+      });
+
+      teardown(() => {
+        changeActions = null;
+      });
+
+      test('property existence', () => {
+        const properties = [
+          'ActionType',
+          'ChangeActions',
+          'RevisionActions',
+        ];
+        for (const p of properties) {
+          assertArraysEqual(changeActions[p], element[p]);
+        }
+      });
+
+      test('add/remove primary action keys', () => {
+        element.primaryActionKeys = [];
+        changeActions.addPrimaryActionKey('foo');
+        assertArraysEqual(element.primaryActionKeys, ['foo']);
+        changeActions.addPrimaryActionKey('foo');
+        assertArraysEqual(element.primaryActionKeys, ['foo']);
+        changeActions.addPrimaryActionKey('bar');
+        assertArraysEqual(element.primaryActionKeys, ['foo', 'bar']);
+        changeActions.removePrimaryActionKey('foo');
+        assertArraysEqual(element.primaryActionKeys, ['bar']);
+        changeActions.removePrimaryActionKey('baz');
+        assertArraysEqual(element.primaryActionKeys, ['bar']);
+        changeActions.removePrimaryActionKey('bar');
+        assertArraysEqual(element.primaryActionKeys, []);
+      });
+
+      test('action buttons', done => {
+        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+        const handler = sinon.spy();
+        changeActions.addTapListener(key, handler);
         flush(() => {
-          assert.equal(button.getAttribute('data-label'), 'Yo');
-          assert.isTrue(button.disabled);
-          done();
+          MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
+          assert(handler.calledOnce);
+          changeActions.removeTapListener(key, handler);
+          MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
+          assert(handler.calledOnce);
+          changeActions.remove(key);
+          flush(() => {
+            assert.isNull(element.$$('[data-action-key="' + key + '"]'));
+            done();
+          });
         });
       });
-    });
 
-    test('hide action buttons', done => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush(() => {
-        const button = element.$$('[data-action-key="' + key + '"]');
-        assert.isOk(button);
-        assert.isFalse(button.hasAttribute('hidden'));
-        changeActions.setActionHidden(
-            changeActions.ActionType.REVISION, key, true);
+      test('action button properties', done => {
+        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
         flush(() => {
           const button = element.$$('[data-action-key="' + key + '"]');
-          assert.isNotOk(button);
-          done();
+          assert.isOk(button);
+          assert.equal(button.getAttribute('data-label'), 'Bork!');
+          assert.isNotOk(button.disabled);
+          changeActions.setLabel(key, 'Yo');
+          changeActions.setEnabled(key, false);
+          flush(() => {
+            assert.equal(button.getAttribute('data-label'), 'Yo');
+            assert.isTrue(button.disabled);
+            done();
+          });
         });
       });
-    });
 
-    test('move action button to overflow', done => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush(() => {
-        assert.isTrue(element.$.moreActions.hidden);
-        assert.isOk(element.$$('[data-action-key="' + key + '"]'));
-        changeActions.setActionOverflow(
-            changeActions.ActionType.REVISION, key, true);
+      test('hide action buttons', done => {
+        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
         flush(() => {
-          assert.isNotOk(element.$$('[data-action-key="' + key + '"]'));
-          assert.isFalse(element.$.moreActions.hidden);
-          assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
-          done();
+          const button = element.$$('[data-action-key="' + key + '"]');
+          assert.isOk(button);
+          assert.isFalse(button.hasAttribute('hidden'));
+          changeActions.setActionHidden(
+              changeActions.ActionType.REVISION, key, true);
+          flush(() => {
+            const button = element.$$('[data-action-key="' + key + '"]');
+            assert.isNotOk(button);
+            done();
+          });
         });
       });
-    });
 
-    test('change actions priority', done => {
-      const key1 =
+      test('move action button to overflow', done => {
+        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+        flush(() => {
+          assert.isTrue(element.$.moreActions.hidden);
+          assert.isOk(element.$$('[data-action-key="' + key + '"]'));
+          changeActions.setActionOverflow(
+              changeActions.ActionType.REVISION, key, true);
+          flush(() => {
+            assert.isNotOk(element.$$('[data-action-key="' + key + '"]'));
+            assert.isFalse(element.$.moreActions.hidden);
+            assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
+            done();
+          });
+        });
+      });
+
+      test('change actions priority', done => {
+        const key1 =
           changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      const key2 =
+        const key2 =
           changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
-      flush(() => {
-        let buttons =
-            Polymer.dom(element.root).querySelectorAll('[data-action-key]');
-        assert.equal(buttons[0].getAttribute('data-action-key'), key1);
-        assert.equal(buttons[1].getAttribute('data-action-key'), key2);
-        changeActions.setActionPriority(
-            changeActions.ActionType.REVISION, key1, 10);
         flush(() => {
-          buttons =
+          let buttons =
+            Polymer.dom(element.root).querySelectorAll('[data-action-key]');
+          assert.equal(buttons[0].getAttribute('data-action-key'), key1);
+          assert.equal(buttons[1].getAttribute('data-action-key'), key2);
+          changeActions.setActionPriority(
+              changeActions.ActionType.REVISION, key1, 10);
+          flush(() => {
+            buttons =
               Polymer.dom(element.root).querySelectorAll('[data-action-key]');
-          assert.equal(buttons[0].getAttribute('data-action-key'), key2);
-          assert.equal(buttons[1].getAttribute('data-action-key'), key1);
-          done();
+            assert.equal(buttons[0].getAttribute('data-action-key'), key2);
+            assert.equal(buttons[1].getAttribute('data-action-key'), key1);
+            done();
+          });
         });
       });
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
index 65aa364..adaf622 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
@@ -15,6 +15,19 @@
   'use strict';
 
   /**
+   * Ensure GrChangeReplyInterface instance has access to gr-reply-dialog
+   * element and retrieve if the interface was created before element.
+   * @param {!GrChangeReplyInterfaceOld} api
+   */
+  function ensureEl(api) {
+    if (!api._el) {
+      const sharedApiElement = document.createElement('gr-js-api-interface');
+      api._el = sharedApiElement.getElement(
+          sharedApiElement.Element.REPLY_DIALOG);
+    }
+  }
+
+  /**
    * @deprecated
    */
   function GrChangeReplyInterfaceOld(el) {
@@ -22,14 +35,17 @@
   }
 
   GrChangeReplyInterfaceOld.prototype.getLabelValue = function(label) {
+    ensureEl(this);
     return this._el.getLabelValue(label);
   };
 
   GrChangeReplyInterfaceOld.prototype.setLabelValue = function(label, value) {
+    ensureEl(this);
     this._el.setLabelValue(label, value);
   };
 
   GrChangeReplyInterfaceOld.prototype.send = function(opt_includeComments) {
+    ensureEl(this);
     return this._el.send(opt_includeComments);
   };
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
index 2f67035..e453fd2 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
@@ -40,37 +40,72 @@
     let element;
     let sandbox;
     let changeReply;
+    let plugin;
 
     setup(() => {
+      sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({}); },
         getAccount() { return Promise.resolve(null); },
       });
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      changeReply = plugin.changeReply();
     });
 
     teardown(() => {
-      changeReply = null;
       sandbox.restore();
     });
 
-    test('calls', () => {
-      sandbox.stub(element, 'getLabelValue').returns('+123');
-      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+    suite('early init', () => {
+      setup(() => {
+        Gerrit.install(p => { plugin = p; }, '0.1',
+            'http://test.com/plugins/testplugin/static/test.js');
+        changeReply = plugin.changeReply();
+        element = fixture('basic');
+      });
 
-      sandbox.stub(element, 'setLabelValue');
-      changeReply.setLabelValue('My-Label', '+1337');
-      assert.isTrue(
-          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
+      teardown(() => {
+        changeReply = null;
+      });
 
-      sandbox.stub(element, 'send');
-      changeReply.send(false);
-      assert.isTrue(element.send.calledWithExactly(false));
+      test('works', () => {
+        sandbox.stub(element, 'getLabelValue').returns('+123');
+        assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+
+        sandbox.stub(element, 'setLabelValue');
+        changeReply.setLabelValue('My-Label', '+1337');
+        assert.isTrue(
+            element.setLabelValue.calledWithExactly('My-Label', '+1337'));
+
+        sandbox.stub(element, 'send');
+        changeReply.send(false);
+        assert.isTrue(element.send.calledWithExactly(false));
+      });
+    });
+
+    suite('normal init', () => {
+      setup(() => {
+        element = fixture('basic');
+        Gerrit.install(p => { plugin = p; }, '0.1',
+            'http://test.com/plugins/testplugin/static/test.js');
+        changeReply = plugin.changeReply();
+      });
+
+      teardown(() => {
+        changeReply = null;
+      });
+
+      test('works', () => {
+        sandbox.stub(element, 'getLabelValue').returns('+123');
+        assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+
+        sandbox.stub(element, 'setLabelValue');
+        changeReply.setLabelValue('My-Label', '+1337');
+        assert.isTrue(
+            element.setLabelValue.calledWithExactly('My-Label', '+1337'));
+
+        sandbox.stub(element, 'send');
+        changeReply.send(false);
+        assert.isTrue(element.send.calledWithExactly(false));
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
index fda085a..a489e2c 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,11 +21,14 @@
 <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-repo-api/gr-repo-api.html">
+<link rel="import" href="../../plugins/gr-settings-api/gr-settings-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">
 
 <dom-module id="gr-js-api-interface">
+  <script src="gr-annotation-actions-context.js"></script>
+  <script src="gr-annotation-actions-js-api.js"></script>
   <script src="gr-change-actions-js-api.js"></script>
   <script src="gr-change-reply-js-api.js"></script>
   <script src="gr-js-api-interface.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
index 420d4af..38262ec 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
@@ -23,6 +23,7 @@
     COMMENT: 'comment',
     REVERT: 'revert',
     POST_REVERT: 'postrevert',
+    ANNOTATE_DIFF: 'annotatediff',
   };
 
   const Element = {
@@ -178,6 +179,20 @@
       return revertMsg;
     },
 
+    getDiffLayers(path, changeNum, patchNum) {
+      const layers = [];
+      for (const annotationApi of
+           this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
+        try {
+          const layer = annotationApi.getLayer(path, changeNum, patchNum);
+          layers.push(layer);
+        } catch (err) {
+          console.error(err);
+        }
+      }
+      return layers;
+    },
+
     getLabelValuesPostRevert(change) {
       let labels = {};
       for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index 20dbe45..62c4a91 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -70,6 +70,13 @@
       plugin = null;
     });
 
+    test('reuse plugin for install calls', () => {
+      let otherPlugin;
+      Gerrit.install(p => { otherPlugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      assert.strictEqual(plugin, otherPlugin);
+    });
+
     test('url', () => {
       assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
       assert.equal(plugin.url('/static/test.js'),
@@ -339,19 +346,15 @@
 
     test('installGwt calls _pluginInstalled', () => {
       sandbox.stub(Gerrit, '_pluginInstalled');
-      Gerrit.installGwt();
+      Gerrit.installGwt('http://test.com/plugins/testplugin/static/test.js');
       assert.isTrue(Gerrit._pluginInstalled.calledOnce);
     });
 
-    test('installGwt returns a stub object', () => {
-      const plugin = Gerrit.installGwt();
-      sandbox.stub(console, 'warn');
-      assert.isAbove(Object.keys(plugin).length, 0);
-      for (const name of Object.keys(plugin)) {
-        console.warn.reset();
-        plugin[name]();
-        assert.isTrue(console.warn.calledOnce);
-      }
+    test('installGwt returns a plugin', () => {
+      const plugin = Gerrit.installGwt(
+          'http://test.com/plugins/testplugin/static/test.js');
+      assert.isOk(plugin);
+      assert.isOk(plugin._loadedGwt);
     });
 
     test('attributeHelper', () => {
@@ -384,9 +387,8 @@
 
     suite('popup', () => {
       test('popup(element) is deprecated', () => {
-        assert.throws(() => {
-          plugin.popup(document.createElement('div'));
-        });
+        plugin.popup(document.createElement('div'));
+        assert.isTrue(console.error.calledOnce);
       });
 
       test('popup(moduleName) creates popup with component', () => {
@@ -449,5 +451,111 @@
         assert.isFalse(stub.called);
       });
     });
+
+    suite('screen', () => {
+      test('screenUrl()', () => {
+        sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/base');
+        assert.equal(plugin.screenUrl(), 'http://test.com/base/x/testplugin');
+        assert.equal(
+            plugin.screenUrl('foo'), 'http://test.com/base/x/testplugin/foo');
+      });
+
+      test('deprecated works', () => {
+        const stub = sandbox.stub();
+        const hookStub = {onAttached: sandbox.stub()};
+        sandbox.stub(plugin, 'hook').returns(hookStub);
+        plugin.deprecated.screen('foo', stub);
+        assert.isTrue(plugin.hook.calledWith('testplugin-screen-foo'));
+        const fakeEl = {style: {display: ''}};
+        hookStub.onAttached.callArgWith(0, fakeEl);
+        assert.isTrue(stub.called);
+        assert.equal(fakeEl.style.display, 'none');
+      });
+
+      test('works', () => {
+        sandbox.stub(plugin, 'registerCustomComponent');
+        plugin.screen('foo', 'some-module');
+        assert.isTrue(plugin.registerCustomComponent.calledWith(
+            'testplugin-screen-foo', 'some-module'));
+      });
+    });
+
+    suite('panel', () => {
+      let fakeEl;
+      let emulateAttached;
+
+      setup(()=> {
+        fakeEl = {change: {}, revision: {}};
+        const hookStub = {onAttached: sandbox.stub()};
+        sandbox.stub(plugin, 'hook').returns(hookStub);
+        emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
+      });
+
+      test('plugin.panel is deprecated', () => {
+        plugin.panel('rubbish');
+        assert.isTrue(console.error.called);
+      });
+
+      [
+        ['CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK', 'change-view-integration'],
+        ['CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK', 'change-metadata-item'],
+      ].forEach(([panelName, endpointName]) => {
+        test(`deprecated.panel works for ${panelName}`, () => {
+          const callback = sandbox.stub();
+          plugin.deprecated.panel(panelName, callback);
+          assert.isTrue(plugin.hook.calledWith(endpointName));
+          emulateAttached();
+          assert.isTrue(callback.called);
+          const args = callback.args[0][0];
+          assert.strictEqual(args.body, fakeEl);
+          assert.strictEqual(args.p.CHANGE_INFO, fakeEl.change);
+          assert.strictEqual(args.p.REVISION_INFO, fakeEl.revision);
+        });
+      });
+    });
+
+    suite('settingsScreen', () => {
+      test('plugin.settingsScreen is deprecated', () => {
+        plugin.settingsScreen('rubbish');
+        assert.isTrue(console.error.called);
+      });
+
+      test('plugin.settings() returns GrSettingsApi', () => {
+        assert.isOk(plugin.settings());
+        assert.isTrue(plugin.settings() instanceof GrSettingsApi);
+      });
+
+      test('plugin.deprecated.settingsScreen() works', () => {
+        const hookStub = {onAttached: sandbox.stub()};
+        sandbox.stub(plugin, 'hook').returns(hookStub);
+        const fakeSettings = {};
+        fakeSettings.title = sandbox.stub().returns(fakeSettings);
+        fakeSettings.token = sandbox.stub().returns(fakeSettings);
+        fakeSettings.module = sandbox.stub().returns(fakeSettings);
+        fakeSettings.build = sandbox.stub().returns(hookStub);
+        sandbox.stub(plugin, 'settings').returns(fakeSettings);
+        const callback = sandbox.stub();
+
+        plugin.deprecated.settingsScreen('path', 'menu', callback);
+        assert.isTrue(fakeSettings.title.calledWith('menu'));
+        assert.isTrue(fakeSettings.token.calledWith('path'));
+        assert.isTrue(fakeSettings.module.calledWith('div'));
+        assert.equal(fakeSettings.build.callCount, 1);
+
+        const fakeBody = {};
+        const fakeEl = {
+          style: {
+            display: '',
+          },
+          querySelector: sandbox.stub().returns(fakeBody),
+        };
+        // Emulate settings screen attached
+        hookStub.onAttached.callArgWith(0, fakeEl);
+        assert.isTrue(callback.called);
+        const args = callback.args[0][0];
+        assert.strictEqual(args.body, fakeBody);
+        assert.equal(fakeEl.style.display, 'none');
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
index 1ee9eec..9374ccf 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
@@ -26,19 +26,35 @@
     this._callbacks[endpoint].push(callback);
   };
 
+  GrPluginEndpoints.prototype._getOrCreateModuleInfo = function(plugin,
+      endpoint, type, moduleName, domHook) {
+    const existingModule = this._endpoints[endpoint].find(info =>
+        info.plugin === plugin &&
+        info.moduleName === moduleName &&
+        info.domHook === domHook
+    );
+    if (existingModule) {
+      return existingModule;
+    } else {
+      const newModule = {
+        moduleName,
+        plugin,
+        pluginUrl: plugin._url,
+        type,
+        domHook,
+      };
+      this._endpoints[endpoint].push(newModule);
+      return newModule;
+    }
+  };
+
   GrPluginEndpoints.prototype.registerModule = function(plugin, endpoint, type,
       moduleName, domHook) {
     if (!this._endpoints[endpoint]) {
       this._endpoints[endpoint] = [];
     }
-    const moduleInfo = {
-      moduleName,
-      plugin,
-      pluginUrl: plugin._url,
-      type,
-      domHook,
-    };
-    this._endpoints[endpoint].push(moduleInfo);
+    const moduleInfo = this._getOrCreateModuleInfo(plugin, endpoint, type,
+        moduleName, domHook);
     if (Gerrit._arePluginsLoaded() && this._callbacks[endpoint]) {
       this._callbacks[endpoint].forEach(callback => callback(moduleInfo));
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
index a61cdc8..cb8b964 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
@@ -23,6 +23,8 @@
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-js-api-interface.html"/>
 
+<script>void(0);</script>
+
 <script>
   suite('gr-plugin-endpoints tests', () => {
     let sandbox;
@@ -102,7 +104,7 @@
 
     test('getPlugins', () => {
       assert.deepEqual(
-          instance.getPlugins('a-place'), [pluginFoo._url, pluginBar._url]);
+          instance.getPlugins('a-place'), [pluginFoo._url]);
     });
 
     test('onNewEndpoint', () => {
@@ -118,5 +120,26 @@
         domHook,
       });
     });
+
+    test('reuse dom hooks', () => {
+      instance.registerModule(
+          pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
+      assert.deepEqual(instance.getDetails('a-place'), [
+        {
+          moduleName: 'foo-module',
+          plugin: pluginFoo,
+          pluginUrl: pluginFoo._url,
+          type: 'decorate',
+          domHook,
+        },
+        {
+          moduleName: 'bar-module',
+          plugin: pluginBar,
+          pluginUrl: pluginBar._url,
+          type: 'style',
+          domHook,
+        },
+      ]);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index 70fcf62..cbf896a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -14,15 +14,15 @@
 (function(window) {
   'use strict';
 
-  const warnNotSupported = function(opt_name) {
-    console.warn('Plugin API method ' + (opt_name || '') + ' is not supported');
-  };
+  /**
+   * Hash of loaded and installed plugins, name to Plugin object.
+   */
+  const plugins = {};
 
-  const stubbedMethods = ['_loadedGwt', 'screen', 'settingsScreen', 'panel'];
-  const GWT_PLUGIN_STUB = {};
-  for (const name of stubbedMethods) {
-    GWT_PLUGIN_STUB[name] = warnNotSupported.bind(null, name);
-  }
+  const PANEL_ENDPOINTS_MAPPING = {
+    CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK: 'change-view-integration',
+    CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK: 'change-metadata-item',
+  };
 
   let _restAPI;
   const getRestAPI = () => {
@@ -76,6 +76,24 @@
   // http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsJSNI.html
   window.$wnd = window;
 
+  function getPluginNameFromUrl(url) {
+    const base = Gerrit.BaseUrlBehavior.getBaseUrl();
+    const pathname = url.pathname.replace(base, '');
+    // Site theme is server from predefined path.
+    if (pathname === '/static/gerrit-theme.html') {
+      return 'gerrit-theme';
+    } else if (!pathname.startsWith('/plugins')) {
+      console.warn('Plugin not being loaded from /plugins base path:',
+          url.href, '— Unable to determine name.');
+      return;
+    }
+    // Pathname should normally look like this:
+    // /plugins/PLUGINNAME/static/SCRIPTNAME.html
+    // Or, for app/samples:
+    // /plugins/PLUGINNAME.html
+    return pathname.split('/')[2].split('.')[0];
+  }
+
   function Plugin(opt_url) {
     this._domHooks = new GrDomHooksManager(this);
 
@@ -84,26 +102,18 @@
           'Unable to determine name.');
       return;
     }
-
-    const base = Gerrit.BaseUrlBehavior.getBaseUrl();
+    this.deprecated = {
+      _loadedGwt: deprecatedAPI._loadedGwt.bind(this),
+      install: deprecatedAPI.install.bind(this),
+      onAction: deprecatedAPI.onAction.bind(this),
+      panel: deprecatedAPI.panel.bind(this),
+      popup: deprecatedAPI.popup.bind(this),
+      screen: deprecatedAPI.screen.bind(this),
+      settingsScreen: deprecatedAPI.settingsScreen.bind(this),
+    };
 
     this._url = new URL(opt_url);
-    const pathname = this._url.pathname.replace(base, '');
-    // Site theme is server from predefined path.
-    if (pathname === '/static/gerrit-theme.html') {
-      this._name = 'gerrit-theme';
-    } else if (!pathname.startsWith('/plugins')) {
-      console.warn('Plugin not being loaded from /plugins base path:',
-          this._url.href, '— Unable to determine name.');
-      return;
-    }
-    this._name = pathname.split('/')[2];
-
-    this.deprecated = {
-      install: deprecatedAPI.install.bind(this),
-      popup: deprecatedAPI.popup.bind(this),
-      onAction: deprecatedAPI.onAction.bind(this),
-    };
+    this._name = getPluginNameFromUrl(this._url);
   }
 
   Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
@@ -152,6 +162,13 @@
         this._name + (opt_path || '/');
   };
 
+  Plugin.prototype.screenUrl = function(opt_screenName) {
+    const origin = this._url.origin;
+    const base = Gerrit.BaseUrlBehavior.getBaseUrl();
+    const tokenPart = opt_screenName ? '/' + opt_screenName : '';
+    return `${origin}${base}/x/${this.getPluginName()}${tokenPart}`;
+  };
+
   Plugin.prototype._send = function(method, url, opt_callback, opt_payload) {
     return send(method, this.url(url), opt_callback, opt_payload);
   };
@@ -175,6 +192,10 @@
     return Gerrit.delete(this.url(url), opt_callback);
   };
 
+  Plugin.prototype.annotationApi = function() {
+    return new GrAnnotationActionsInterface(this);
+  };
+
   Plugin.prototype.changeActions = function() {
     return new GrChangeActionsInterface(this,
       Plugin._sharedAPIElement.getElement(
@@ -196,7 +217,11 @@
   };
 
   Plugin.prototype.project = function() {
-    return new GrProjectApi(this);
+    return new GrRepoApi(this);
+  };
+
+  Plugin.prototype.settings = function() {
+    return new GrSettingsApi(this);
   };
 
   /**
@@ -220,13 +245,37 @@
 
   Plugin.prototype.popup = function(moduleName) {
     if (typeof moduleName !== 'string') {
-      throw new Error('deprecated, use deprecated.popup');
+      console.error('.popup(element) deprecated, use .popup(moduleName)!');
+      return;
     }
     const api = new GrPopupInterface(this, moduleName);
     return api.open();
   };
 
+  Plugin.prototype.panel = function() {
+    console.error('.panel() is deprecated! ' +
+        'Use registerCustomComponent() instead.');
+  };
+
+  Plugin.prototype.settingsScreen = function() {
+    console.error('.settingsScreen() is deprecated! ' +
+        'Use .settings() instead.');
+  };
+
+  Plugin.prototype.screen = function(screenName, opt_moduleName) {
+    if (opt_moduleName && typeof opt_moduleName !== 'string') {
+      console.error('.screen(pattern, callback) deprecated, use ' +
+          '.screen(screenName, opt_moduleName)!');
+      return;
+    }
+    return this.registerCustomComponent(
+        Gerrit._getPluginScreenName(this.getPluginName(), screenName),
+        opt_moduleName);
+  };
+
   const deprecatedAPI = {
+    _loadedGwt: ()=> {},
+
     install() {
       console.log('Installing deprecated APIs is deprecated!');
       for (const method in this.deprecated) {
@@ -255,16 +304,95 @@
       }
       this.on('showchange', (change, revision) => {
         const details = this.changeActions().getActionDetails(action);
+        if (!details) {
+          console.warn(
+              `${this.getPluginName()} onAction error: ${action} not found!`);
+          return;
+        }
         this.changeActions().addTapListener(details.__key, () => {
           callback(new GrPluginActionContext(this, details, change, revision));
         });
       });
     },
 
+    screen(pattern, callback) {
+      console.warn('plugin.deprecated.screen is deprecated,' +
+          ' use plugin.screen instead!');
+      if (pattern instanceof RegExp) {
+        console.error('deprecated.screen() does not support RegExp. ' +
+            'Please use strings for patterns.');
+        return;
+      }
+      this.hook(Gerrit._getPluginScreenName(this.getPluginName(), pattern))
+          .onAttached(el => {
+            el.style.display = 'none';
+            callback({
+              body: el,
+              token: el.token,
+              onUnload: () => {},
+              setTitle: () => {},
+              setWindowTitle: () => {},
+              show: () => {
+                el.style.display = 'initial';
+              },
+            });
+          });
+    },
+
+    settingsScreen(path, menu, callback) {
+      console.warn('.settingsScreen() is deprecated! Use .settings() instead.');
+      const hook = this.settings()
+          .title(menu)
+          .token(path)
+          .module('div')
+          .build();
+      hook.onAttached(el => {
+        el.style.display = 'none';
+        const body = el.querySelector('div');
+        callback({
+          body,
+          onUnload: () => {},
+          setTitle: () => {},
+          setWindowTitle: () => {},
+          show: () => {
+            el.style.display = 'initial';
+          },
+        });
+      });
+    },
+
+    panel(extensionpoint, callback) {
+      console.warn('.panel() is deprecated! ' +
+          'Use registerCustomComponent() instead.');
+      const endpoint = PANEL_ENDPOINTS_MAPPING[extensionpoint];
+      if (!endpoint) {
+        console.warn(`.panel ${extensionpoint} not supported!`);
+        return;
+      }
+      this.hook(endpoint).onAttached(el => callback({
+        body: el,
+        p: {
+          CHANGE_INFO: el.change,
+          REVISION_INFO: el.revision,
+        },
+        onUnload: () => {},
+      }));
+    },
   };
 
   const Gerrit = window.Gerrit || {};
 
+  // Provide reset plugins function to clear installed plugins between tests.
+  const app = document.querySelector('#app');
+  if (!app) {
+    // No gr-app found (running tests)
+    Gerrit._resetPlugins = () => {
+      for (const k of Object.keys(plugins)) {
+        delete plugins[k];
+      }
+    };
+  }
+
   // Number of plugins to initialize, -1 means 'not yet known'.
   Gerrit._pluginsPending = -1;
 
@@ -296,15 +424,15 @@
       return;
     }
 
-    // TODO(andybons): Polyfill currentScript for IE10/11 (edge supports it).
     const src = opt_src || (document.currentScript &&
          (document.currentScript.src || document.currentScript.baseURI));
-    const plugin = new Plugin(src);
+    const name = getPluginNameFromUrl(new URL(src));
+    const plugin = plugins[name] || new Plugin(src);
     try {
       callback(plugin);
+      plugins[name] = plugin;
     } catch (e) {
-      console.warn(plugin.getPluginName() + ' install failed: ' +
-          e.name + ': ' + e.message);
+      console.warn(`${name} install failed: ${e.name}: ${e.message}`);
     }
     Gerrit._pluginInstalled();
   };
@@ -350,13 +478,20 @@
   };
 
   /**
-   * Polyfill GWT API dependencies to avoid runtime exceptions when loading
-   * GWT-compiled plugins.
-   * @deprecated Not supported in PolyGerrit.
+   * Install "stepping stones" API for GWT-compiled plugins by default.
+   * @deprecated best effort support, will be removed with GWT UI.
    */
-  Gerrit.installGwt = function() {
+  Gerrit.installGwt = function(url) {
     Gerrit._pluginInstalled();
-    return GWT_PLUGIN_STUB;
+    const name = getPluginNameFromUrl(new URL(url));
+    let plugin;
+    try {
+      plugin = plugins[name] || new Plugin(url);
+      plugin.deprecated.install();
+    } catch (e) {
+      console.warn(`${name} install failed: ${e.name}: ${e.message}`);
+    }
+    return plugin;
   };
 
   Gerrit._allPluginsPromise = null;
@@ -393,5 +528,9 @@
     return Gerrit._pluginsPending === 0;
   };
 
+  Gerrit._getPluginScreenName = function(pluginName, screenName) {
+    return `${pluginName}-screen-${screenName}`;
+  };
+
   window.Gerrit = Gerrit;
 })(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.html b/polygerrit-ui/app/elements/shared/gr-label/gr-label.html
index 04b12e7..c448880 100644
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.html
@@ -17,7 +17,7 @@
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <dom-module id="gr-label">
   <template strip-whitespace>
-    <content></content>
+    <slot></slot>
   </template>
   <script src="gr-label.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
index eabe061..0e13563 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
@@ -39,10 +39,18 @@
         type: Boolean,
         value: false,
       },
+
+      /**
+       * The maximum number of characters to display in the tooltop.
+       */
+      tooltipLimit: {
+        type: Number,
+        value: 1024,
+      },
     },
 
     observers: [
-      '_updateTitle(text, limit)',
+      '_updateTitle(text, limit, tooltipLimit)',
     ],
 
     behaviors: [
@@ -53,10 +61,10 @@
      * The text or limit have changed. Recompute whether a tooltip needs to be
      * enabled.
      */
-    _updateTitle(text, limit) {
+    _updateTitle(text, limit, tooltipLimit) {
       this.hasTooltip = !!limit && !!text && text.length > limit;
       if (this.hasTooltip) {
-        this.setAttribute('title', text);
+        this.setAttribute('title', text.substr(0, tooltipLimit));
       } else {
         this.removeAttribute('title');
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
index 9e00331..d0d5a33 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
@@ -66,15 +66,20 @@
       assert.equal(element.getAttribute('title'), 'abc 123');
       assert.isTrue(element.hasTooltip);
 
+      element.tooltipLimit = 3;
+      flushAsynchronousOperations();
+      assert.equal(element.getAttribute('title'), 'abc');
+
+      element.tooltipLimit = 1024;
       element.limit = 100;
       flushAsynchronousOperations();
-      assert.equal(updateSpy.callCount, 4);
+      assert.equal(updateSpy.callCount, 6);
       assert.isNotOk(element.getAttribute('title'));
       assert.isFalse(element.hasTooltip);
 
       element.limit = null;
       flushAsynchronousOperations();
-      assert.equal(updateSpy.callCount, 5);
+      assert.equal(updateSpy.callCount, 7);
       assert.isNotOk(element.getAttribute('title'));
       assert.isFalse(element.hasTooltip);
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
index d30bad2..cf0c243 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
@@ -53,12 +53,6 @@
           padding: 0;
           text-decoration: none;
         }
-        --gr-button-hover-color: {
-          color: #333;
-        }
-        --gr-button-hover-background-color: {
-          color: #333;
-        }
       }
       .transparentBackground,
       gr-button.transparentBackground {
@@ -68,10 +62,15 @@
         opacity: .6;
         pointer-events: none;
       }
+      a {
+       color: var(--linked-chip-text-color);
+      }
     </style>
     <div class$="container [[_getBackgroundClass(transparentBackground)]]">
       <a href$="[[href]]">
-        <gr-limited-text limit="[[limit]]" text="[[text]]"></gr-limited-text>
+        <gr-limited-text
+            limit="[[limit]]"
+            text="[[text]]"></gr-limited-text>
       </a>
       <gr-button
           id="remove"
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
index 016932f..f9373f2 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
@@ -68,7 +68,7 @@
         </gr-button>
       </div>
     </div>
-    <content></content>
+    <slot></slot>
     <nav>
       <a id="prevArrow"
           href$="[[_computeNavLink(offset, -1, itemsPerPage, filter, path)]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
index 1b59d35..9067114 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
@@ -36,7 +36,7 @@
         }
       }
     </style>
-    <content></content>
+    <slot></slot>
   </template>
   <script src="gr-overlay.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
index f98a62c..cef02c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
@@ -39,7 +39,7 @@
       }
     </style>
     <nav id="nav">
-      <content></content>
+      <slot></slot>
     </nav>
   </template>
   <script src="gr-page-nav.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.html b/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.html
deleted file mode 100644
index 15f44cf..0000000
--- a/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.html
+++ /dev/null
@@ -1,55 +0,0 @@
-<!--
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-placeholder">
-  <template>
-    <style include="shared-styles">
-      main {
-        margin: 2em auto;
-        max-width: 46em;
-      }
-      h1 {
-        margin-bottom: .1em;
-      }
-      @media only screen and (max-width: 67em) {
-        main {
-          margin: 2em 0 2em 15em;
-        }
-      }
-      @media only screen and (max-width: 53em) {
-        .loading {
-          padding: 0 var(--default-horizontal-margin);
-        }
-        main {
-          margin: 2em 1em;
-        }
-      }
-    </style>
-    <main>
-      <h1>[[title]]</h1>
-      <section>
-        This page is not yet implemented in PolyGerrit. View it in the
-        <a id="gwtLink" href$="[[computeGwtUrl(path)]]" rel="external">
-        Old UI</a>
-      </section>
-    </main>
-  </template>
-  <script src="gr-placeholder.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.js b/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.js
deleted file mode 100644
index 9b60061..0000000
--- a/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.js
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-(function() {
-  'use strict';
-
-  Polymer({
-    is: 'gr-placeholder',
-
-    properties: {
-      path: String,
-      title: String,
-    },
-
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-    ],
-  });
-})();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
index 5afed95..0ba6f97 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
@@ -16,6 +16,7 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="gr-etag-decorator.html">
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 763e622..b5bb35a 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -14,6 +14,16 @@
 (function() {
   'use strict';
 
+  const Defs = {};
+
+  /**
+   * @typedef {{
+   *    basePatchNum: (string|number),
+   *    patchNum: (number),
+   * }}
+   */
+  Defs.patchRange;
+
   const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
@@ -40,6 +50,7 @@
 
     behaviors: [
       Gerrit.PathListBehavior,
+      Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
@@ -219,36 +230,48 @@
       return JSON.parse(source.substring(JSON_PREFIX.length));
     },
 
-    getConfig() {
-      return this._fetchSharedCacheURL('/config/server/info');
+    getConfig(noCache) {
+      if (!noCache) {
+        return this._fetchSharedCacheURL('/config/server/info');
+      }
+
+      return this.fetchJSON('/config/server/info');
     },
 
-    getProject(project) {
+    getRepo(repo) {
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
       return this._fetchSharedCacheURL(
-          '/projects/' + encodeURIComponent(project));
+          '/projects/' + encodeURIComponent(repo));
     },
 
-    getProjectConfig(project) {
+    getProjectConfig(repo) {
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
       return this._fetchSharedCacheURL(
-          '/projects/' + encodeURIComponent(project) + '/config');
+          '/projects/' + encodeURIComponent(repo) + '/config');
     },
 
-    getProjectAccess(project) {
+    getRepoAccess(repo) {
+      // TODO: Rename rest api from project to repo
+      // supports it.
       return this._fetchSharedCacheURL(
-          '/access/?project=' + encodeURIComponent(project));
+          '/access/?project=' + encodeURIComponent(repo));
     },
 
-    saveProjectConfig(project, config, opt_errFn, opt_ctx) {
-      const encodeName = encodeURIComponent(project);
+    saveRepoConfig(repo, config, opt_errFn, opt_ctx) {
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
+      const encodeName = encodeURIComponent(repo);
       return this.send('PUT', `/projects/${encodeName}/config`, config,
           opt_errFn, opt_ctx);
     },
 
-    runProjectGC(project, opt_errFn, opt_ctx) {
-      if (!project) {
-        return '';
-      }
-      const encodeName = encodeURIComponent(project);
+    runRepoGC(repo, opt_errFn, opt_ctx) {
+      if (!repo) { return ''; }
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
+      const encodeName = encodeURIComponent(repo);
       return this.send('POST', `/projects/${encodeName}/gc`, '',
           opt_errFn, opt_ctx);
     },
@@ -258,8 +281,10 @@
      * @param {function(?Response, string=)=} opt_errFn
      * @param {?=} opt_ctx
      */
-    createProject(config, opt_errFn, opt_ctx) {
+    createRepo(config, opt_errFn, opt_ctx) {
       if (!config.name) { return ''; }
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
       const encodeName = encodeURIComponent(config.name);
       return this.send('PUT', `/projects/${encodeName}`, config, opt_errFn,
           opt_ctx);
@@ -283,16 +308,16 @@
     },
 
     /**
-     * @param {string} project
+     * @param {string} repo
      * @param {string} ref
      * @param {function(?Response, string=)=} opt_errFn
      * @param {?=} opt_ctx
      */
-    deleteProjectBranches(project, ref, opt_errFn, opt_ctx) {
-      if (!project || !ref) {
-        return '';
-      }
-      const encodeName = encodeURIComponent(project);
+    deleteRepoBranches(repo, ref, opt_errFn, opt_ctx) {
+      if (!repo || !ref) { return ''; }
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
+      const encodeName = encodeURIComponent(repo);
       const encodeRef = encodeURIComponent(ref);
       return this.send('DELETE',
           `/projects/${encodeName}/branches/${encodeRef}`, '',
@@ -300,16 +325,16 @@
     },
 
     /**
-     * @param {string} project
+     * @param {string} repo
      * @param {string} ref
      * @param {function(?Response, string=)=} opt_errFn
      * @param {?=} opt_ctx
      */
-    deleteProjectTags(project, ref, opt_errFn, opt_ctx) {
-      if (!project || !ref) {
-        return '';
-      }
-      const encodeName = encodeURIComponent(project);
+    deleteRepoTags(repo, ref, opt_errFn, opt_ctx) {
+      if (!repo || !ref) { return ''; }
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
+      const encodeName = encodeURIComponent(repo);
       const encodeRef = encodeURIComponent(ref);
       return this.send('DELETE',
           `/projects/${encodeName}/tags/${encodeRef}`, '',
@@ -323,8 +348,10 @@
      * @param {function(?Response, string=)=} opt_errFn
      * @param {?=} opt_ctx
      */
-    createProjectBranch(name, branch, revision, opt_errFn, opt_ctx) {
+    createRepoBranch(name, branch, revision, opt_errFn, opt_ctx) {
       if (!name || !branch || !revision) { return ''; }
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
       const encodeName = encodeURIComponent(name);
       const encodeBranch = encodeURIComponent(branch);
       return this.send('PUT',
@@ -339,8 +366,10 @@
      * @param {function(?Response, string=)=} opt_errFn
      * @param {?=} opt_ctx
      */
-    createProjectTag(name, tag, revision, opt_errFn, opt_ctx) {
+    createRepoTag(name, tag, revision, opt_errFn, opt_ctx) {
       if (!name || !tag || !revision) { return ''; }
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
       const encodeName = encodeURIComponent(name);
       const encodeTag = encodeURIComponent(tag);
       return this.send('PUT', `/projects/${encodeName}/tags/${encodeTag}`,
@@ -433,7 +462,7 @@
           return this._fetchSharedCacheURL('/accounts/self/preferences.diff');
         }
         // These defaults should match the defaults in
-        // gerrit-extension-api/src/main/jcg/gerrit/extensions/client/DiffPreferencesInfo.java
+        // java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
         // NOTE: There are some settings that don't apply to PolyGerrit
         // (Render mode being at least one of them).
         return Promise.resolve({
@@ -455,6 +484,34 @@
       });
     },
 
+    getEditPreferences() {
+      return this.getLoggedIn().then(loggedIn => {
+        if (loggedIn) {
+          return this._fetchSharedCacheURL('/accounts/self/preferences.edit');
+        }
+        // These defaults should match the defaults in
+        // java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+        return Promise.resolve({
+          auto_close_brackets: false,
+          cursor_blink_rate: 0,
+          hide_line_numbers: false,
+          hide_top_menu: false,
+          indent_unit: 2,
+          indent_with_tabs: false,
+          key_map_type: 'DEFAULT',
+          line_length: 100,
+          line_wrapping: false,
+          match_brackets: true,
+          show_base: false,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          tab_size: 8,
+          theme: 'DEFAULT',
+        });
+      });
+    },
+
     /**
      * @param {?Object} prefs
      * @param {function(?Response, string=)=} opt_errFn
@@ -483,14 +540,35 @@
           opt_errFn, opt_ctx);
     },
 
+    /**
+     * @param {?Object} prefs
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    saveEditPreferences(prefs, opt_errFn, opt_ctx) {
+      // Invalidate the cache.
+      this._cache['/accounts/self/preferences.edit'] = undefined;
+      return this.send('PUT', '/accounts/self/preferences.edit', prefs,
+          opt_errFn, opt_ctx);
+    },
+
     getAccount() {
       return this._fetchSharedCacheURL('/accounts/self/detail', resp => {
-        if (resp.status === 403) {
+        if (!resp || resp.status === 403) {
           this._cache['/accounts/self/detail'] = null;
         }
       });
     },
 
+    getExternalIds() {
+      return this.fetchJSON('/accounts/self/external.ids');
+    },
+
+    deleteAccountIdentity(id) {
+      return this.send('POST', '/accounts/self/external.ids:delete', id)
+          .then(response => this.getResponseObject(response));
+    },
+
     /**
      * @param {string} userId the ID of the user usch as an email address.
      * @return {!Promise<!Object>}
@@ -601,13 +679,17 @@
     },
 
     getAccountGroups() {
-      return this._fetchSharedCacheURL('/accounts/self/groups');
+      return this.fetchJSON('/accounts/self/groups');
     },
 
     getAccountAgreements() {
       return this._fetchSharedCacheURL('/accounts/self/agreements');
     },
 
+    saveAccountAgreement(name) {
+      return this.send('PUT', '/accounts/self/agreements', name);
+    },
+
     /**
      * @param {string=} opt_params
      */
@@ -774,6 +856,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);
           }
@@ -819,7 +906,9 @@
           this.ListChangesOption.ALL_REVISIONS,
           this.ListChangesOption.CHANGE_ACTIONS,
           this.ListChangesOption.CURRENT_ACTIONS,
+          this.ListChangesOption.DETAILED_LABELS,
           this.ListChangesOption.DOWNLOAD_COMMANDS,
+          this.ListChangesOption.MESSAGES,
           this.ListChangesOption.SUBMITTABLE,
           this.ListChangesOption.WEB_LINKS
       );
@@ -835,6 +924,7 @@
      */
     getDiffChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
       const params = this.listChangesOptionsToHex(
+          this.ListChangesOption.ALL_COMMITS,
           this.ListChangesOption.ALL_REVISIONS
       );
       return this._getChangeDetail(changeNum, params, opt_errFn,
@@ -877,9 +967,8 @@
 
               return payloadPromise.then(payload => {
                 if (!payload) { return null; }
-
                 this._etags.collect(urlWithParams, response, payload.raw);
-                this._maybeInsertInLookup(payload);
+                this._maybeInsertInLookup(payload.parsed);
 
                 return payload.parsed;
               });
@@ -897,15 +986,18 @@
 
     /**
      * @param {number|string} changeNum
-     * @param {!Promise<?Object>} patchRange
+     * @param {Defs.patchRange} patchRange
+     * @param {number=} opt_parentIndex
      */
-    getChangeFiles(changeNum, patchRange) {
-      let endpoint = '/files';
-      if (patchRange.basePatchNum !== 'PARENT') {
-        endpoint += '?base=' + encodeURIComponent(patchRange.basePatchNum);
+    getChangeFiles(changeNum, patchRange, opt_parentIndex) {
+      let params = undefined;
+      if (this.isMergeParent(patchRange.basePatchNum)) {
+        params = {parent: this.getParentIndex(patchRange.basePatchNum)};
+      } else if (!this.patchNumEquals(patchRange.basePatchNum, 'PARENT')) {
+        params = {base: patchRange.basePatchNum};
       }
-      return this._getChangeURLAndFetch(changeNum, endpoint,
-          patchRange.patchNum);
+      return this._getChangeURLAndFetch(changeNum, '/files',
+          patchRange.patchNum, undefined, undefined, params);
     },
 
     /**
@@ -915,12 +1007,27 @@
     getChangeEditFiles(changeNum, patchRange) {
       let endpoint = '/edit?list';
       if (patchRange.basePatchNum !== 'PARENT') {
-        endpoint += '?base=' + encodeURIComponent(patchRange.basePatchNum);
+        endpoint += '&base=' + encodeURIComponent(patchRange.basePatchNum);
       }
-      return this._getChangeURLAndFetch(changeNum, endpoint,
-          patchRange.patchNum);
+      return this._getChangeURLAndFetch(changeNum, endpoint);
     },
 
+    /**
+     * @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);
+    },
+
+    /**
+     * @param {number|string} changeNum
+     * @param {Defs.patchRange} patchRange
+     * @return {!Promise<!Array<!Object>>}
+     */
     getChangeFilesAsSpeciallySortedArray(changeNum, patchRange) {
       return this.getChangeFiles(changeNum, patchRange).then(
           this._normalizeChangeFilesResponse.bind(this));
@@ -1014,54 +1121,62 @@
 
     /**
      * @param {string} filter
-     * @param {number} projectsPerPage
+     * @param {number} reposPerPage
      * @param {number=} opt_offset
      * @return {!Promise<?Object>}
      */
-    getProjects(filter, projectsPerPage, opt_offset) {
+    getRepos(filter, reposPerPage, opt_offset) {
       const offset = opt_offset || 0;
 
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
       return this._fetchSharedCacheURL(
-          `/projects/?d&n=${projectsPerPage + 1}&S=${offset}` +
+          `/projects/?d&n=${reposPerPage + 1}&S=${offset}` +
           this._computeFilter(filter)
       );
     },
 
-    setProjectHead(project, ref) {
+    setRepoHead(repo, ref) {
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
       return this.send(
-          'PUT', `/projects/${encodeURIComponent(project)}/HEAD`, {ref});
+          'PUT', `/projects/${encodeURIComponent(repo)}/HEAD`, {ref});
     },
 
     /**
      * @param {string} filter
-     * @param {string} project
-     * @param {number} projectsBranchesPerPage
+     * @param {string} repo
+     * @param {number} reposBranchesPerPage
      * @param {number=} opt_offset
      * @return {!Promise<?Object>}
      */
-    getProjectBranches(filter, project, projectsBranchesPerPage, opt_offset) {
+    getRepoBranches(filter, repo, reposBranchesPerPage, opt_offset) {
       const offset = opt_offset || 0;
 
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
       return this.fetchJSON(
-          `/projects/${encodeURIComponent(project)}/branches` +
-          `?n=${projectsBranchesPerPage + 1}&S=${offset}` +
+          `/projects/${encodeURIComponent(repo)}/branches` +
+          `?n=${reposBranchesPerPage + 1}&S=${offset}` +
           this._computeFilter(filter)
       );
     },
 
     /**
      * @param {string} filter
-     * @param {string} project
-     * @param {number} projectsTagsPerPage
+     * @param {string} repo
+     * @param {number} reposTagsPerPage
      * @param {number=} opt_offset
      * @return {!Promise<?Object>}
      */
-    getProjectTags(filter, project, projectsTagsPerPage, opt_offset) {
+    getRepoTags(filter, repo, reposTagsPerPage, opt_offset) {
       const offset = opt_offset || 0;
 
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
       return this.fetchJSON(
-          `/projects/${encodeURIComponent(project)}/tags` +
-          `?n=${projectsTagsPerPage + 1}&S=${offset}` +
+          `/projects/${encodeURIComponent(repo)}/tags` +
+          `?n=${reposTagsPerPage + 1}&S=${offset}` +
           this._computeFilter(filter)
       );
     },
@@ -1081,15 +1196,25 @@
       );
     },
 
-    getProjectAccessRights(projectName) {
+    getRepoAccessRights(repoName) {
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
       return this._fetchSharedCacheURL(
-          `/projects/${encodeURIComponent(projectName)}/access`);
+          `/projects/${encodeURIComponent(repoName)}/access`);
     },
 
-    setProjectAccessRights(projectName, projectInfo) {
+    setRepoAccessRights(repoName, repoInfo) {
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
       return this.send(
-          'POST', `/projects/${encodeURIComponent(projectName)}/access`,
-          projectInfo);
+          'POST', `/projects/${encodeURIComponent(repoName)}/access`,
+          repoInfo);
+    },
+
+    setProjectAccessRightsForReview(projectName, projectInfo) {
+      return this.send(
+          'PUT', `/projects/${encodeURIComponent(projectName)}/access:review`,
+          projectInfo).then(response => this.getResponseObject(response));
     },
 
     /**
@@ -1276,9 +1401,25 @@
           .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; }
+
+            // The file type (used for syntax highlighting) is identified in the
+            // X-FYI-Content-Type header of the response.
+            const type = res.headers.get('X-FYI-Content-Type');
+            return this.getResponseObject(res).then(content => {
+              return {content, type, ok: true};
+            });
+          });
     },
 
     rebaseChangeEdit(changeNum) {
@@ -1306,7 +1447,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.
@@ -1342,8 +1484,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();
@@ -1354,6 +1498,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]);
+        }
+      }
       if (!url.startsWith('http')) {
         url = this.getBaseUrl() + url;
       }
@@ -1377,7 +1528,8 @@
 
     /**
      * @param {number|string} changeNum
-     * @param {number|string} basePatchNum
+     * @param {number|string} basePatchNum Negative values specify merge parent
+     *     index.
      * @param {number|string} patchNum
      * @param {string} path
      * @param {function(?Response, string=)=} opt_errFn
@@ -1390,7 +1542,9 @@
         intraline: null,
         whitespace: 'IGNORE_NONE',
       };
-      if (basePatchNum != PARENT_PATCH_NUM) {
+      if (this.isMergeParent(basePatchNum)) {
+        params.parent = this.getParentIndex(basePatchNum);
+      } else if (!this.patchNumEquals(basePatchNum, PARENT_PATCH_NUM)) {
         params.base = basePatchNum;
       }
       const endpoint = `/files/${encodeURIComponent(path)}/diff`;
@@ -1404,15 +1558,23 @@
      * @param {number|string=} opt_basePatchNum
      * @param {number|string=} opt_patchNum
      * @param {string=} opt_path
+     * @return {!Promise<!Object>}
      */
     getDiffComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
       return this._getDiffComments(changeNum, '/comments', opt_basePatchNum,
           opt_patchNum, opt_path);
     },
 
-    getDiffRobotComments(changeNum, basePatchNum, patchNum, opt_path) {
-      return this._getDiffComments(changeNum, '/robotcomments', basePatchNum,
-          patchNum, opt_path);
+    /**
+     * @param {number|string} changeNum
+     * @param {number|string=} opt_basePatchNum
+     * @param {number|string=} opt_patchNum
+     * @param {string=} opt_path
+     * @return {!Promise<!Object>}
+     */
+    getDiffRobotComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
+      return this._getDiffComments(changeNum, '/robotcomments',
+          opt_basePatchNum, opt_patchNum, opt_path);
     },
 
     /**
@@ -1424,7 +1586,7 @@
      * @param {number|string=} opt_basePatchNum
      * @param {number|string=} opt_patchNum
      * @param {string=} opt_path
-     * @return {!Promise<?Object>}
+     * @return {!Promise<!Object>}
      */
     getDiffDrafts(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
       return this.getLoggedIn().then(loggedIn => {
@@ -1463,6 +1625,7 @@
      * @param {number|string=} opt_basePatchNum
      * @param {number|string=} opt_patchNum
      * @param {string=} opt_path
+     * @return {!Promise<!Object>}
      */
     _getDiffComments(changeNum, endpoint, opt_basePatchNum,
         opt_patchNum, opt_path) {
@@ -1471,7 +1634,7 @@
        * Helper function to make promises more legible.
        *
        * @param {string|number=} opt_patchNum
-       * @return {!Object} Diff comments response.
+       * @return {!Promise<!Object>} Diff comments response.
        */
       const fetchComments = opt_patchNum => {
         return this._getChangeURLAndFetch(changeNum, endpoint, opt_patchNum);
@@ -1830,7 +1993,7 @@
      */
     getChange(changeNum, opt_errFn) {
       // Cannot use _changeBaseURL, as this function is used by _projectLookup.
-      return this.fetchJSON(`/changes/${changeNum}`, opt_errFn);
+      return this.fetchJSON(`/changes/?q=${changeNum}`, opt_errFn);
     },
 
     /**
@@ -1863,7 +2026,8 @@
         this.fire('page-error', {response});
       };
 
-      return this.getChange(changeNum, onError).then(change => {
+      return this.getChange(changeNum, onError).then(res => {
+        const change = res[0];
         if (!change || !change.project) { return; }
         this.setInProjectLookup(changeNum, change.project);
         return change.project;
@@ -1879,16 +2043,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);
       });
     },
 
@@ -1956,5 +2121,20 @@
         return result;
       });
     },
+
+    /**
+     * Fetch a project dashboard definition.
+     * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard
+     * @param {string} project
+     * @param {string} dashboard
+     * @param {function(?Response, string=)=} opt_errFn
+     *    passed as null sometimes.
+     * @return {!Promise<!Object>}
+     */
+    getDashboard(project, dashboard, opt_errFn) {
+      const url = '/projects/' + encodeURIComponent(project) + '/dashboards/' +
+          encodeURIComponent(dashboard);
+      return this._fetchSharedCacheURL(url, opt_errFn);
+    },
   });
 })();
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 ef2e14b..a8c09d1 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
@@ -498,6 +498,51 @@
       assert.notOk(element._cache[cacheKey]);
     });
 
+    test('getAccount when resp is null does not add anything to the cache',
+        done => {
+          const cacheKey = '/accounts/self/detail';
+          const stub = sandbox.stub(element, '_fetchSharedCacheURL',
+              () => Promise.resolve());
+
+          element.getAccount().then(() => {
+            assert.isTrue(element._fetchSharedCacheURL.called);
+            assert.isNull(element._cache[cacheKey]);
+            done();
+          });
+
+          element._cache[cacheKey] = 'fake cache';
+          stub.callArg(1);
+        });
+
+    test('getAccount does not add to the cache when resp.status is 403',
+        done => {
+          const cacheKey = '/accounts/self/detail';
+          const stub = sandbox.stub(element, '_fetchSharedCacheURL',
+              () => Promise.resolve());
+
+          element.getAccount().then(() => {
+            assert.isTrue(element._fetchSharedCacheURL.called);
+            assert.isNull(element._cache[cacheKey]);
+            done();
+          });
+          element._cache[cacheKey] = 'fake cache';
+          stub.callArgWith(1, {status: 403});
+        });
+
+    test('getAccount when resp is successful', done => {
+      const cacheKey = '/accounts/self/detail';
+      const stub = sandbox.stub(element, '_fetchSharedCacheURL',
+          () => Promise.resolve());
+
+      element.getAccount().then(response => {
+        assert.isTrue(element._fetchSharedCacheURL.called);
+        assert.equal(element._cache[cacheKey], 'fake cache');
+        done();
+      });
+      element._cache[cacheKey] = 'fake cache';
+      stub.callArg(1, {});
+    });
+
     const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
       sandbox.stub(element, 'getLoggedIn', () => {
         return Promise.resolve(loggedIn);
@@ -576,6 +621,68 @@
       assert.equal(element.send.lastCall.args[2].download_scheme, 'http');
     });
 
+    test('getDiffPreferences returns correct defaults', done => {
+      sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(false));
+
+      element.getDiffPreferences().then(obj => {
+        assert.equal(obj.auto_hide_diff_table_header, true);
+        assert.equal(obj.context, 10);
+        assert.equal(obj.cursor_blink_rate, 0);
+        assert.equal(obj.font_size, 12);
+        assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
+        assert.equal(obj.intraline_difference, true);
+        assert.equal(obj.line_length, 100);
+        assert.equal(obj.line_wrapping, false);
+        assert.equal(obj.show_line_endings, true);
+        assert.equal(obj.show_tabs, true);
+        assert.equal(obj.show_whitespace_errors, true);
+        assert.equal(obj.syntax_highlighting, true);
+        assert.equal(obj.tab_size, 8);
+        assert.equal(obj.theme, 'DEFAULT');
+        done();
+      });
+    });
+
+    test('saveDiffPreferences set show_tabs to false', () => {
+      sandbox.stub(element, 'send');
+      element.saveDiffPreferences({show_tabs: false});
+      assert.isTrue(element.send.called);
+      assert.equal(element.send.lastCall.args[2].show_tabs, false);
+    });
+
+    test('getEditPreferences returns correct defaults', done => {
+      sandbox.stub(element, 'getLoggedIn', () => {
+        return Promise.resolve(false);
+      });
+
+      element.getEditPreferences().then(obj => {
+        assert.equal(obj.auto_close_brackets, false);
+        assert.equal(obj.cursor_blink_rate, 0);
+        assert.equal(obj.hide_line_numbers, false);
+        assert.equal(obj.hide_top_menu, false);
+        assert.equal(obj.indent_unit, 2);
+        assert.equal(obj.indent_with_tabs, false);
+        assert.equal(obj.key_map_type, 'DEFAULT');
+        assert.equal(obj.line_length, 100);
+        assert.equal(obj.line_wrapping, false);
+        assert.equal(obj.match_brackets, true);
+        assert.equal(obj.show_base, false);
+        assert.equal(obj.show_tabs, true);
+        assert.equal(obj.show_whitespace_errors, true);
+        assert.equal(obj.syntax_highlighting, true);
+        assert.equal(obj.tab_size, 8);
+        assert.equal(obj.theme, 'DEFAULT');
+        done();
+      });
+    });
+
+    test('saveEditPreferences set show_tabs to false', () => {
+      sandbox.stub(element, 'send');
+      element.saveEditPreferences({show_tabs: false});
+      assert.isTrue(element.send.called);
+      assert.equal(element.send.lastCall.args[2].show_tabs, false);
+    });
+
     test('confirmEmail', () => {
       sandbox.spy(element, 'send');
       element.confirmEmail('foo');
@@ -754,37 +861,46 @@
           {reason: 'removal reason'}));
     });
 
-    test('createProject encodes name', () => {
+    test('createRepo encodes name', () => {
       const sendStub = sandbox.stub(element, 'send');
-      element.createProject({name: 'x/y'});
+      element.createRepo({name: 'x/y'});
       assert.equal(sendStub.lastCall.args[1], '/projects/x%2Fy');
     });
 
-    test('getProjects', () => {
+    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('getRepos', () => {
       sandbox.stub(element, '_fetchSharedCacheURL');
-      element.getProjects('test', 25);
+      element.getRepos('test', 25);
       assert.isTrue(element._fetchSharedCacheURL.lastCall
           .calledWithExactly('/projects/?d&n=26&S=0&m=test'));
 
-      element.getProjects(null, 25);
+      element.getRepos(null, 25);
       assert.isTrue(element._fetchSharedCacheURL.lastCall
           .calledWithExactly('/projects/?d&n=26&S=0'));
 
-      element.getProjects('test', 25, 25);
+      element.getRepos('test', 25, 25);
       assert.isTrue(element._fetchSharedCacheURL.lastCall
           .calledWithExactly('/projects/?d&n=26&S=25&m=test'));
     });
 
-    test('getProjects filter', () => {
+    test('getRepos filter', () => {
       sandbox.stub(element, '_fetchSharedCacheURL');
-      element.getProjects('test/test/test', 25);
+      element.getRepos('test/test/test', 25);
       assert.isTrue(element._fetchSharedCacheURL.lastCall
           .calledWithExactly('/projects/?d&n=26&S=0&m=test%2Ftest%2Ftest'));
     });
 
-    test('getProjects filter regex', () => {
+    test('getRepos filter regex', () => {
       sandbox.stub(element, '_fetchSharedCacheURL');
-      element.getProjects('^test.*', 25);
+      element.getRepos('^test.*', 25);
       assert.isTrue(element._fetchSharedCacheURL.lastCall
           .calledWithExactly('/projects/?d&n=26&S=0&r=%5Etest.*'));
     });
@@ -838,6 +954,8 @@
 
       test('_getChangeDetail calls errFn on 500', () => {
         const errFn = sinon.stub();
+        sandbox.stub(element, 'getChangeActionURL')
+            .returns(Promise.resolve(''));
         sandbox.stub(element, '_fetchRawJSON')
             .returns(Promise.resolve({ok: false, status: 500}));
         return element._getChangeDetail(123, {}, errFn).then(() => {
@@ -846,6 +964,8 @@
       });
 
       test('_getChangeDetail populates _projectLookup', () => {
+        sandbox.stub(element, 'getChangeActionURL')
+            .returns(Promise.resolve(''));
         sandbox.stub(element, '_fetchRawJSON')
             .returns(Promise.resolve({ok: true}));
 
@@ -916,7 +1036,7 @@
     suite('getFromProjectLookup', () => {
       test('getChange fails', () => {
         sandbox.stub(element, 'getChange')
-            .returns(Promise.resolve());
+            .returns(Promise.resolve([]));
         return element.getFromProjectLookup().then(val => {
           assert.strictEqual(val, undefined);
           assert.deepEqual(element._projectLookup, {});
@@ -924,7 +1044,7 @@
       });
 
       test('getChange succeeds, no project', () => {
-        sandbox.stub(element, 'getChange').returns(Promise.resolve());
+        sandbox.stub(element, 'getChange').returns(Promise.resolve([]));
         return element.getFromProjectLookup().then(val => {
           assert.strictEqual(val, undefined);
           assert.deepEqual(element._projectLookup, {});
@@ -933,7 +1053,7 @@
 
       test('getChange succeeds with project', () => {
         sandbox.stub(element, 'getChange')
-            .returns(Promise.resolve({project: 'project'}));
+            .returns(Promise.resolve([{project: 'project'}]));
         return element.getFromProjectLookup('test').then(val => {
           assert.equal(val, 'project');
           assert.deepEqual(element._projectLookup, {test: 'project'});
@@ -1041,5 +1161,113 @@
         assert.deepEqual(sendSpy.lastCall.args[2], {generate: true});
       });
     });
+
+    suite('getChangeFiles', () => {
+      test('patch only', () => {
+        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+            .returns(Promise.resolve());
+        const range = {basePatchNum: 'PARENT', patchNum: 2};
+        return element.getChangeFiles(123, range).then(() => {
+          assert.isTrue(fetchStub.calledOnce);
+          assert.equal(fetchStub.lastCall.args[2], 2);
+          assert.isNotOk(fetchStub.lastCall.args[5]);
+        });
+      });
+
+      test('simple range', () => {
+        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+            .returns(Promise.resolve());
+        const range = {basePatchNum: 4, patchNum: 5};
+        return element.getChangeFiles(123, range).then(() => {
+          assert.isTrue(fetchStub.calledOnce);
+          assert.equal(fetchStub.lastCall.args[2], 5);
+          assert.isOk(fetchStub.lastCall.args[5]);
+          assert.equal(fetchStub.lastCall.args[5].base, 4);
+          assert.isNotOk(fetchStub.lastCall.args[5].parent);
+        });
+      });
+
+      test('parent index', () => {
+        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+            .returns(Promise.resolve());
+        const range = {basePatchNum: -3, patchNum: 5};
+        return element.getChangeFiles(123, range).then(() => {
+          assert.isTrue(fetchStub.calledOnce);
+          assert.equal(fetchStub.lastCall.args[2], 5);
+          assert.isOk(fetchStub.lastCall.args[5]);
+          assert.isNotOk(fetchStub.lastCall.args[5].base);
+          assert.equal(fetchStub.lastCall.args[5].parent, 3);
+        });
+      });
+    });
+
+    suite('getDiff', () => {
+      test('patchOnly', () => {
+        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+            .returns(Promise.resolve());
+        return element.getDiff(123, 'PARENT', 2, 'foo/bar.baz').then(() => {
+          assert.isTrue(fetchStub.calledOnce);
+          assert.equal(fetchStub.lastCall.args[2], 2);
+          assert.isOk(fetchStub.lastCall.args[5]);
+          assert.isNotOk(fetchStub.lastCall.args[5].parent);
+          assert.isNotOk(fetchStub.lastCall.args[5].base);
+        });
+      });
+
+      test('simple range', () => {
+        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+            .returns(Promise.resolve());
+        return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
+          assert.isTrue(fetchStub.calledOnce);
+          assert.equal(fetchStub.lastCall.args[2], 5);
+          assert.isOk(fetchStub.lastCall.args[5]);
+          assert.isNotOk(fetchStub.lastCall.args[5].parent);
+          assert.equal(fetchStub.lastCall.args[5].base, 4);
+        });
+      });
+
+      test('parent index', () => {
+        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+            .returns(Promise.resolve());
+        return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
+          assert.isTrue(fetchStub.calledOnce);
+          assert.equal(fetchStub.lastCall.args[2], 5);
+          assert.isOk(fetchStub.lastCall.args[5]);
+          assert.isNotOk(fetchStub.lastCall.args[5].base);
+          assert.equal(fetchStub.lastCall.args[5].parent, 3);
+        });
+      });
+    });
+
+    test('getDashboard', () => {
+      const fetchStub = sandbox.stub(element, '_fetchSharedCacheURL');
+      element.getDashboard('gerrit/project', 'default:main');
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+          fetchStub.lastCall.args[0],
+          '/projects/gerrit%2Fproject/dashboards/default%3Amain');
+    });
+
+    test('getFileInChangeEdit', () => {
+      sandbox.stub(element, 'getChangeURLAndSend')
+          .returns(Promise.resolve({
+            ok: 'true',
+            headers: {
+              get(header) {
+                if (header === 'X-FYI-Content-Type') {
+                  return 'text/java';
+                }
+              },
+            },
+          }));
+
+      sandbox.stub(element, 'getResponseObject')
+          .returns(Promise.resolve('new content'));
+
+      return element.getFileInChangeEdit('1', 'test/path').then(res => {
+        assert.deepEqual(res,
+            {content: 'new content', type: 'text/java', ok: true});
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
index b0b6ea9..ba945c4 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
@@ -16,6 +16,6 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <dom-module id="gr-select">
-  <content></content>
+  <slot></slot>
   <script src="gr-select.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
index 21e5e1f..5e0cf30 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
@@ -34,7 +34,8 @@
     },
 
     _updateValue() {
-      if (this.bindValue) {
+      // It's possible to have a value of 0.
+      if (this.bindValue !== undefined) {
         // Set for chrome/safari so it happens instantly
         this.nativeSelect.value = this.bindValue;
         // Async needed for firefox to populate value. It was trying to do it
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
index e7a4965..2bca29e 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
@@ -45,6 +45,11 @@
       element = fixture('basic');
     });
 
+    test('value of 0 should still trigger value updates', () => {
+      element.bindValue = 0;
+      assert.equal(element.nativeSelect.value, 0);
+    });
+
     test('bidirectional binding property-to-attribute', () => {
       const changeStub = sinon.stub();
       element.addEventListener('bind-value-changed', changeStub);
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
index 7f61edd..dfe86c51 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -15,11 +15,16 @@
   'use strict';
 
   // Date cutoff is one day:
-  const DRAFT_MAX_AGE = 24 * 60 * 60 * 1000;
+  const CLEANUP_MAX_AGE = 24 * 60 * 60 * 1000;
 
   // Clean up old entries no more frequently than one day.
   const CLEANUP_THROTTLE_INTERVAL = 24 * 60 * 60 * 1000;
 
+  const CLEANUP_PREFIXES = [
+    'draft:',
+    'editablecontent:',
+  ];
+
   Polymer({
     is: 'gr-storage',
 
@@ -39,7 +44,7 @@
     },
 
     getDraftComment(location) {
-      this._cleanupDrafts();
+      this._cleanupItems();
       return this._getObject(this._getDraftKey(location));
     },
 
@@ -53,6 +58,20 @@
       this._storage.removeItem(key);
     },
 
+    getEditableContentItem(key) {
+      this._cleanupItems();
+      return this._getObject(this._getEditableContentKey(key));
+    },
+
+    setEditableContentItem(key, message) {
+      this._setObject(this._getEditableContentKey(key),
+          {message, updated: Date.now()});
+    },
+
+    eraseEditableContentItem(key) {
+      this._storage.removeItem(key);
+    },
+
     getPreferences() {
       return this._getObject('localPrefs');
     },
@@ -74,7 +93,11 @@
       return key;
     },
 
-    _cleanupDrafts() {
+    _getEditableContentKey(key) {
+      return `editablecontent:${key}`;
+    },
+
+    _cleanupItems() {
       // Throttle cleanup to the throttle interval.
       if (this._lastCleanup &&
           Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL) {
@@ -82,12 +105,16 @@
       }
       this._lastCleanup = Date.now();
 
-      let draft;
+      let item;
       for (const key in this._storage) {
-        if (key.startsWith('draft:')) {
-          draft = this._getObject(key);
-          if (Date.now() - draft.updated > DRAFT_MAX_AGE) {
-            this._storage.removeItem(key);
+        if (!this._storage.hasOwnProperty(key)) { continue; }
+        for (const prefix of CLEANUP_PREFIXES) {
+          if (key.startsWith(prefix)) {
+            item = this._getObject(key);
+            if (Date.now() - item.updated > CLEANUP_MAX_AGE) {
+              this._storage.removeItem(key);
+            }
+            break;
           }
         }
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
index ce8ec20..68d92cb 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
@@ -33,6 +33,7 @@
 <script>
   suite('gr-storage tests', () => {
     let element;
+    let sandbox;
 
     function mockStorage(opt_quotaExceeded) {
       return {
@@ -48,9 +49,12 @@
 
     setup(() => {
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
       element._storage = mockStorage();
     });
 
+    teardown(() => sandbox.restore());
+
     test('storing, retrieving and erasing drafts', () => {
       const changeNum = 1234;
       const patchNum = 5;
@@ -100,7 +104,7 @@
       // Make sure that the call to cleanup doesn't get throttled.
       element._lastCleanup = 0;
 
-      const cleanupSpy = sinon.spy(element, '_cleanupDrafts');
+      const cleanupSpy = sandbox.spy(element, '_cleanupItems');
 
       // Create a message with a timestamp that is a second behind the max age.
       element._storage.setItem(key, JSON.stringify({
@@ -114,8 +118,6 @@
       assert.isTrue(cleanupSpy.called);
       assert.isNotOk(draft);
       assert.isNotOk(element._storage.getItem(key));
-
-      cleanupSpy.restore();
     });
 
     test('_getDraftKey', () => {
@@ -160,5 +162,32 @@
       assert.isTrue(element._exceededQuota);
       assert.isNotOk(element._storage.getItem(key));
     });
+
+    test('editable content items', () => {
+      const cleanupStub = sandbox.stub(element, '_cleanupItems');
+      const key = 'testKey';
+      const computedKey = element._getEditableContentKey(key);
+      // Key correctly computed.
+      assert.equal(computedKey, 'editablecontent:testKey');
+
+      element.setEditableContentItem(key, 'my content');
+
+      // Setting the draft stores it under the expected key.
+      let item = element._storage.getItem(computedKey);
+      assert.isOk(item);
+      assert.equal(JSON.parse(item).message, 'my content');
+      assert.isOk(JSON.parse(item).updated);
+
+      // getEditableContentItem performs as expected.
+      item = element.getEditableContentItem(key);
+      assert.isOk(item);
+      assert.equal(item.message, 'my content');
+      assert.isOk(item.updated);
+      assert.isTrue(cleanupStub.called);
+
+      // eraseEditableContentItem performs as expected.
+      element.eraseEditableContentItem(key);
+      assert.isNotOk(element._storage.getItem(key));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
index 58f8e39..68db696 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
@@ -19,7 +19,12 @@
 
 <dom-module id="gr-tooltip-content">
   <template>
-    <content></content><!--
+    <style>
+      .arrow {
+        color: var(--arrow-color);
+      }
+    </style>
+    <slot></slot><!--
  --><span class="arrow" hidden$="[[!showIcon]]">&#9432;</span>
   </template>
   <script src="gr-tooltip-content.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
index 26e1e2c..52f3ebd 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
@@ -26,6 +26,11 @@
         type: String,
         reflectToAttribute: true,
       },
+      positionBelow: {
+        type: Boolean,
+        valye: false,
+        reflectToAttribute: true,
+      },
       showIcon: {
         type: Boolean,
         value: false,
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
index 2fa02a3..9b23a31 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
@@ -43,6 +43,12 @@
           .querySelector('.arrow').hidden, true);
     });
 
+    test('position-below attribute is reflected', () => {
+      assert.isFalse(element.hasAttribute('position-below'));
+      element.positionBelow = true;
+      assert.isTrue(element.hasAttribute('position-below'));
+    });
+
     test('icon is visible with showIcon property', () => {
       element.showIcon = true;
       assert.equal(Polymer.dom(element.root)
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
index e79fb19..1744d28 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
@@ -24,7 +24,7 @@
         --gr-tooltip-arrow-size: .5em;
         --gr-tooltip-arrow-center-offset: 0;
 
-        background-color: #333;
+        background-color: var(--tooltip-background-color, #333);
         box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
         color: #fff;
         font-size: .75rem;
@@ -35,21 +35,35 @@
       :host .tooltip {
         padding: .5em .85em;
       }
+      :host .arrowPositionBelow,
+      :host([position-below]) .arrowPositionAbove  {
+        display: none;
+      }
+      :host([position-below]) .arrowPositionBelow {
+        display: initial;
+      }
       .arrow {
         border-left: var(--gr-tooltip-arrow-size) solid transparent;
         border-right: var(--gr-tooltip-arrow-size) solid transparent;
-        border-top: var(--gr-tooltip-arrow-size) solid #333;
-        bottom: -var(--gr-tooltip-arrow-size);
         height: 0;
         position: absolute;
         left: calc(50% - var(--gr-tooltip-arrow-size));
         margin-left: var(--gr-tooltip-arrow-center-offset);
         width: 0;
       }
+      .arrowPositionAbove {
+        border-top: var(--gr-tooltip-arrow-size) solid var(--tooltip-background-color, #333);
+        bottom: -var(--gr-tooltip-arrow-size);
+      }
+      .arrowPositionBelow {
+        border-bottom: var(--gr-tooltip-arrow-size) solid var(--tooltip-background-color, #333);
+        top: -var(--gr-tooltip-arrow-size);
+      }
     </style>
     <div class="tooltip">
+      <i class="arrowPositionBelow arrow"></i>
       [[text]]
-      <i class="arrow"></i>
+      <i class="arrowPositionAbove arrow"></i>
     </div>
   </template>
   <script src="gr-tooltip.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
index e30afa7..62e70b5 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
@@ -23,6 +23,10 @@
         type: String,
         observer: '_updateWidth',
       },
+      positionBelow: {
+        type: Boolean,
+        reflectToAttribute: true,
+      },
     },
 
     _updateWidth(maxWidth) {
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
index e1e6449..824fad5 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
@@ -44,5 +44,17 @@
       element.maxWidth = '50px';
       assert.equal(getComputedStyle(element).width, '50px');
     });
+
+    test('the correct arrow is displayed', () => {
+      assert.equal(getComputedStyle(element.$$('.arrowPositionBelow')).display,
+          'none');
+      assert.notEqual(getComputedStyle(element.$$('.arrowPositionAbove'))
+          .display, 'none');
+      element.positionBelow = true;
+      assert.notEqual(getComputedStyle(element.$$('.arrowPositionBelow'))
+          .display, 'none');
+      assert.equal(getComputedStyle(element.$$('.arrowPositionAbove'))
+          .display, 'none');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.html b/polygerrit-ui/app/elements/shared/revision-info/revision-info.html
new file mode 100644
index 0000000..5669143
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.html
@@ -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.
+-->
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<script>
+  (function() {
+    'use strict';
+
+    /**
+     * @param {Object} change A change object resulting from a change detail
+     *     call that includes revision information.
+     */
+    function RevisionInfo(change) {
+      this._change = change;
+    }
+
+    /**
+     * Get the largest number of parents of the commit in any revision. For
+     * example, with normal changes this will always return 1. For merge changes
+     * wherein the revisions are merge commits this will return 2 or potentially
+     * more.
+     * @return {Number}
+     */
+    RevisionInfo.prototype.getMaxParents = function() {
+      return Object.values(this._change.revisions)
+          .reduce((acc, rev) => Math.max(rev.commit.parents.length, acc), 0);
+    };
+
+    /**
+     * Get an object that maps revision numbers to the number of parents of the
+     * commit of that revision.
+     * @return {!Object}
+     */
+    RevisionInfo.prototype.getParentCountMap = function() {
+      const result = {};
+      Object.values(this._change.revisions)
+          .forEach(rev => { result[rev._number] = rev.commit.parents.length; });
+      return result;
+    };
+
+    /**
+     * @param {number|string} patchNum
+     * @return {number}
+     */
+    RevisionInfo.prototype.getParentCount = function(patchNum) {
+      return this.getParentCountMap()[patchNum];
+    };
+
+    /**
+     * Get the commit ID of the (0-offset) indexed parent in the given revision
+     * number.
+     * @param {number|string} patchNum
+     * @param {number} parentIndex (0-offset)
+     * @return {string}
+     */
+    RevisionInfo.prototype.getParentId = function(patchNum, parentIndex) {
+      const rev = Object.values(this._change.revisions).find(rev =>
+          Gerrit.PatchSetBehavior.patchNumEquals(rev._number, patchNum));
+      return rev.commit.parents[parentIndex].commit;
+    };
+
+    if (!window.Gerrit) {
+      window.Gerrit = {};
+    }
+    window.Gerrit.RevisionInfo = RevisionInfo;
+  })();
+</script>
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
new file mode 100644
index 0000000..27233eb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
@@ -0,0 +1,85 @@
+<!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>revision-info</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="revision-info.html">
+
+<script>
+  suite('revision-info tests', () => {
+    let mockChange;
+
+    setup(() => {
+      mockChange = {
+        revisions: {
+          r1: {_number: 1, commit: {parents: [
+            {commit: 'p1'},
+            {commit: 'p2'},
+            {commit: 'p3'},
+          ]}},
+          r2: {_number: 2, commit: {parents: [
+            {commit: 'p1'},
+            {commit: 'p4'},
+          ]}},
+          r3: {_number: 3, commit: {parents: [{commit: 'p5'}]}},
+          r4: {_number: 4, commit: {parents: [
+            {commit: 'p2'},
+            {commit: 'p3'},
+          ]}},
+          r5: {_number: 5, commit: {parents: [
+            {commit: 'p5'},
+            {commit: 'p2'},
+            {commit: 'p3'},
+          ]}},
+        },
+      };
+    });
+
+    test('getMaxParents', () => {
+      const ri = new window.Gerrit.RevisionInfo(mockChange);
+      assert.equal(ri.getMaxParents(), 3);
+    });
+
+    test('getParentCountMap', () => {
+      const ri = new window.Gerrit.RevisionInfo(mockChange);
+      assert.deepEqual(ri.getParentCountMap(), {1: 3, 2: 2, 3: 1, 4: 2, 5: 3});
+    });
+
+    test('getParentCount', () => {
+      const ri = new window.Gerrit.RevisionInfo(mockChange);
+      assert.deepEqual(ri.getParentCount(1), 3);
+      assert.deepEqual(ri.getParentCount(3), 1);
+    });
+
+    test('getParentCount', () => {
+      const ri = new window.Gerrit.RevisionInfo(mockChange);
+      assert.deepEqual(ri.getParentCount(1), 3);
+      assert.deepEqual(ri.getParentCount(3), 1);
+    });
+
+    test('getParentId', () => {
+      const ri = new window.Gerrit.RevisionInfo(mockChange);
+      assert.deepEqual(ri.getParentId(1, 2), 'p3');
+      assert.deepEqual(ri.getParentId(2, 1), 'p4');
+      assert.deepEqual(ri.getParentId(3, 0), 'p5');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index a77ad50..c125efc 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -2,7 +2,6 @@
 load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library", "closure_js_binary")
 load(
     "//tools/bzl:js.bzl",
-    "bower_component_bundle",
     "vulcanize",
     "bower_component",
     "js_component",
diff --git a/polygerrit-ui/app/samples/coverage-plugin.html b/polygerrit-ui/app/samples/coverage-plugin.html
new file mode 100644
index 0000000..6f76dc4
--- /dev/null
+++ b/polygerrit-ui/app/samples/coverage-plugin.html
@@ -0,0 +1,55 @@
+<dom-module id="coverage-plugin">
+  <script>
+
+    function populateWithDummyData(coverageData) {
+      coverageData['NewFile'] = {
+        linesMissingCoverage: [1, 2, 3],
+        totalLines: 5,
+        changeNum: 94,
+        patchNum: 2,
+      };
+      coverageData['/COMMIT_MSG'] = {
+        linesMissingCoverage: [3, 4, 7, 14],
+        totalLines: 14,
+        changeNum: 94,
+        patchNum: 2,
+      };
+      coverageData['DEPS'] = {
+        linesMissingCoverage: [3, 4, 7, 14],
+        totalLines: 16,
+        changeNum: 77001,
+        patchNum: 1,
+      };
+    }
+
+    Gerrit.install(plugin => {
+      const coverageData = {};
+      plugin.annotationApi().addNotifier(notifyFunc => {
+        new Promise(resolve => setTimeout(resolve, 3000)).then(
+            () => {
+              populateWithDummyData(coverageData);
+              Object.keys(coverageData).forEach(file => {
+                notifyFunc(file, 0, coverageData[file].totalLines, 'right');
+              });
+            });
+      }).addLayer(context => {
+        if (Object.keys(coverageData).length === 0) {
+          // Coverage data is not ready yet.
+          return;
+        }
+        const path = context.path;
+        const line = context.line;
+        // Highlight lines missing coverage with this background color.
+        const cssClass = Gerrit.css('background-color: #EF9B9B');
+        if (coverageData[path] &&
+            coverageData[path].changeNum === context.changeNum &&
+            coverageData[path].patchNum === context.patchNum) {
+          const linesMissingCoverage = coverageData[path].linesMissingCoverage;
+          if (linesMissingCoverage.includes(line.afterNumber)) {
+            context.annotateRange(0, line.text.length, cssClass, 'right');
+          }
+        }
+      });
+    });
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/samples/repo-command.html b/polygerrit-ui/app/samples/repo-command.html
new file mode 100644
index 0000000..67e528a
--- /dev/null
+++ b/polygerrit-ui/app/samples/repo-command.html
@@ -0,0 +1,42 @@
+<dom-module id="sample-repo-command">
+  <script>
+    Gerrit.install(plugin => {
+      // High-level API
+      plugin.project()
+          .createCommand('Bork', (repoName, projectConfig) => {
+            if (repoName !== 'All-Projects') {
+              return false;
+            }
+          }).onTap(() => {
+            alert('Bork, bork!');
+          });
+
+      // Low-level API
+      plugin.registerCustomComponent(
+          'repo-command', 'repo-command-low');
+    });
+  </script>
+</dom-module>
+
+<!-- Low-level custom component for repo command. -->
+<dom-module id="repo-command-low">
+  <template>
+    <gr-repo-command
+        title="Low-level bork"
+        on-command-tap="_handleCommandTap">
+    </gr-repo-command>
+  </template>
+  <script>
+    Polymer({
+      is: 'repo-command-low',
+      attached() {
+        console.log(this.repoName);
+        console.log(this.config);
+        this.hidden = this.repoName !== 'All-Projects';
+      },
+      _handleCommandTap() {
+        alert('(softly) bork, bork.');
+      },
+    });
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/samples/some-screen.html b/polygerrit-ui/app/samples/some-screen.html
new file mode 100644
index 0000000..de29315
--- /dev/null
+++ b/polygerrit-ui/app/samples/some-screen.html
@@ -0,0 +1,49 @@
+<dom-module id="some-screen">
+  <script>
+    Gerrit.install(plugin => {
+      // Recommended approach for screen() API.
+      plugin.screen('main', 'some-screen-main');
+
+      const mainUrl = plugin.screenUrl('main');
+
+      // Support for deprecated screen API.
+      plugin.deprecated.screen('foo', ({token, body, show}) => {
+        body.innerHTML = `This is a plugin screen at ${token}<br/>` +
+            `<a href="${mainUrl}">Go to main plugin screen</a>`;
+        show();
+      });
+
+      // Quick and dirty way to get something on screen.
+      plugin.screen('bar').onAttached(el => {
+        el.innerHTML = `This is a plugin screen at ${el.token}<br/>` +
+            `<a href="${mainUrl}">Go to main plugin screen</a>`;
+      });
+
+      // Add a "Plugin screen" link to the change view screen.
+      plugin.hook('change-metadata-item').onAttached(el => {
+        el.innerHTML = `<a href="${mainUrl}">Plugin screen</a>`;
+      });
+    });
+  </script>
+</dom-module>
+
+<dom-module id="some-screen-main">
+  <template>
+    This is the <b>main</b> plugin screen at [[token]]
+    <ul>
+      <li><a href$="[[rootUrl]]/foo">via deprecated</a></li>
+      <li><a href$="[[rootUrl]]/bar">without component</a></li>
+    </ul>
+  </template>
+  <script>
+    Polymer({
+      is: 'some-screen-main',
+      properties: {
+        rootUrl: String,
+      },
+      attached() {
+        this.rootUrl = `${this.plugin.screenUrl()}`;
+      },
+    });
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/styles/app-theme.html b/polygerrit-ui/app/styles/app-theme.html
index 6a81158..2d89272 100644
--- a/polygerrit-ui/app/styles/app-theme.html
+++ b/polygerrit-ui/app/styles/app-theme.html
@@ -18,7 +18,7 @@
   /* Following vars have LTS for plugin API. */
   --primary-text-color: #000;
   --header-background-color: #eee;
-  --header-title-content: 'PolyGerrit';
+  --header-title-content: 'Gerrit';
   --header-icon: none;
   --header-icon-size: 0em;
   --footer-background-color: var(--header-background-color);
@@ -38,6 +38,7 @@
 
   /* Follow are a part of the design refresh */
   --color-link: #2a66d9;
+  --color-link-tertiary: #000;
   /* 12% darker */
   --color-button-hover: #0B47BA;
 }
diff --git a/polygerrit-ui/app/styles/gr-form-styles.html b/polygerrit-ui/app/styles/gr-form-styles.html
index 603f610..823fee6 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.html
+++ b/polygerrit-ui/app/styles/gr-form-styles.html
@@ -16,9 +16,7 @@
 <dom-module id="gr-form-styles">
   <template>
     <style>
-      .gr-form-styles h1 {
-        margin-bottom: .1em;
-      }
+      .gr-form-styles h1,
       .gr-form-styles h2 {
         margin-bottom: .3em;
       }
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.html b/polygerrit-ui/app/styles/gr-voting-styles.html
new file mode 100644
index 0000000..d6ff0eb
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-voting-styles.html
@@ -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.
+-->
+
+<dom-module id="gr-voting-styles">
+  <template>
+    <style>
+      :host {
+        --vote-color-max: #9fcc6b;
+        --vote-color-positive: #c9dfaf;
+        --vote-color-min: #f7a1ad;
+        --vote-color-negative: #f7c4cb;
+        --vote-color-neutral: #ebf5fb;
+
+        --vote-chip-styles: {
+          border: 1px solid rgba(0,0,0,.12);
+          border-radius: 12px;
+          box-shadow: none;
+          min-width: 40px;
+        }
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/styles/shared-styles.html b/polygerrit-ui/app/styles/shared-styles.html
index 31b1c6e..a3cf247 100644
--- a/polygerrit-ui/app/styles/shared-styles.html
+++ b/polygerrit-ui/app/styles/shared-styles.html
@@ -87,6 +87,19 @@
       [hidden] {
         display: none !important;
       }
+      .separator {
+        background-color: rgba(0, 0, 0, .3);
+        height: 20px;
+        margin: 0 8px;
+        width: 1px;
+      }
+      .separator.transparent {
+        background-color: transparent;
+      }
+      paper-toggle-button {
+        --paper-toggle-button-checked-bar-color: var(--color-link);
+        --paper-toggle-button-checked-button-color: var(--color-link);
+      }
     </style>
   </template>
 </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..7c64db3 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',
@@ -31,6 +33,7 @@
   'GrRangeNormalizer',
   'GrReporting',
   'GrReviewerUpdatesParser',
+  'GrCountStringFormatter',
   'GrThemeApi',
   'moment',
   'page',
diff --git a/polygerrit-ui/app/test/common-test-setup.html b/polygerrit-ui/app/test/common-test-setup.html
index 57df8a6..d901bec 100644
--- a/polygerrit-ui/app/test/common-test-setup.html
+++ b/polygerrit-ui/app/test/common-test-setup.html
@@ -44,6 +44,21 @@
     return promise;
   };
 </script>
+<script>
+  (function() {
+    setup(() => {
+      if (!window.Gerrit) { return; }
+      Gerrit._pluginsPending = -1;
+      Gerrit._allPluginsPromise = undefined;
+      if (Gerrit._resetPlugins) {
+        Gerrit._resetPlugins();
+      }
+      if (Gerrit._endpoints) {
+        Gerrit._endpoints = new GrPluginEndpoints();
+      }
+    });
+  })();
+</script>
 <link rel="import"
     href="../bower_components/iron-test-helpers/iron-test-helpers.html" />
 <link rel="import" href="test-router.html" />
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index a06b470..44a9296 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.
@@ -37,17 +38,18 @@
     'admin/gr-create-change-dialog/gr-create-change-dialog_test.html',
     'admin/gr-create-group-dialog/gr-create-group-dialog_test.html',
     'admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html',
-    'admin/gr-create-project-dialog/gr-create-project-dialog_test.html',
+    'admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html',
     'admin/gr-group-audit-log/gr-group-audit-log_test.html',
     'admin/gr-group-members/gr-group-members_test.html',
     'admin/gr-group/gr-group_test.html',
     'admin/gr-permission/gr-permission_test.html',
     'admin/gr-plugin-list/gr-plugin-list_test.html',
-    'admin/gr-project-access/gr-project-access_test.html',
-    'admin/gr-project-commands/gr-project-commands_test.html',
-    'admin/gr-project-detail-list/gr-project-detail-list_test.html',
-    'admin/gr-project-list/gr-project-list_test.html',
-    'admin/gr-project/gr-project_test.html',
+    'admin/gr-repo-access/gr-repo-access_test.html',
+    'admin/gr-repo-command/gr-repo-command_test.html',
+    'admin/gr-repo-commands/gr-repo-commands_test.html',
+    'admin/gr-repo-detail-list/gr-repo-detail-list_test.html',
+    'admin/gr-repo-list/gr-repo-list_test.html',
+    'admin/gr-repo/gr-repo_test.html',
     'admin/gr-rule-editor/gr-rule-editor_test.html',
     'change-list/gr-change-list-item/gr-change-list-item_test.html',
     'change-list/gr-change-list-view/gr-change-list-view_test.html',
@@ -102,6 +104,10 @@
     '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-default-editor/gr-default-editor_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',
@@ -109,11 +115,16 @@
     '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',
+    'plugins/gr-repo-api/gr-repo-api_test.html',
+    'plugins/gr-settings-api/gr-settings-api_test.html',
     'settings/gr-account-info/gr-account-info_test.html',
     'settings/gr-change-table-editor/gr-change-table-editor_test.html',
+    'settings/gr-cla-view/gr-cla-view_test.html',
+    'settings/gr-edit-preferences/gr-edit-preferences_test.html',
     'settings/gr-email-editor/gr-email-editor_test.html',
     'settings/gr-group-list/gr-group-list_test.html',
     'settings/gr-http-password/gr-http-password_test.html',
+    'settings/gr-identities/gr-identities_test.html',
     'settings/gr-menu-editor/gr-menu-editor_test.html',
     'settings/gr-registration-dialog/gr-registration-dialog_test.html',
     'settings/gr-settings-view/gr-settings-view_test.html',
@@ -127,6 +138,7 @@
     'shared/gr-avatar/gr-avatar_test.html',
     'shared/gr-button/gr-button_test.html',
     'shared/gr-change-star/gr-change-star_test.html',
+    'shared/gr-change-status/gr-change-status_test.html',
     'shared/gr-confirm-dialog/gr-confirm-dialog_test.html',
     'shared/gr-copy-clipboard/gr-copy-clipboard_test.html',
     'shared/gr-cursor-manager/gr-cursor-manager_test.html',
@@ -139,6 +151,7 @@
     'shared/gr-js-api-interface/gr-change-actions-js-api_test.html',
     'shared/gr-js-api-interface/gr-change-reply-js-api_test.html',
     'shared/gr-js-api-interface/gr-js-api-interface_test.html',
+    'shared/gr-js-api-interface/gr-plugin-endpoints_test.html',
     'shared/gr-js-api-interface/gr-plugin-rest-api_test.html',
     'shared/gr-limited-text/gr-limited-text_test.html',
     'shared/gr-linked-chip/gr-linked-chip_test.html',
@@ -153,7 +166,9 @@
     'shared/gr-textarea/gr-textarea_test.html',
     'shared/gr-tooltip-content/gr-tooltip-content_test.html',
     'shared/gr-tooltip/gr-tooltip_test.html',
+    'shared/revision-info/revision-info_test.html',
   ];
+  /* eslint-enable max-len */
   for (let file of elements) {
     file = elementsPath + file;
     testFiles.push(file);
@@ -161,6 +176,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',
@@ -174,6 +190,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/polygerrit-ui/app/test/test-router.html b/polygerrit-ui/app/test/test-router.html
index 37a20c4..d96040f 100644
--- a/polygerrit-ui/app/test/test-router.html
+++ b/polygerrit-ui/app/test/test-router.html
@@ -17,5 +17,5 @@
 
 <link rel="import" href="../elements/core/gr-navigation/gr-navigation.html">
 <script>
-  Gerrit.Nav.setup(url => { /* noop */ }, params => '');
+  Gerrit.Nav.setup(url => { /* noop */ }, params => '', () => []);
 </script>
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 79cf4bf..ece071c 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -200,7 +200,7 @@
 
 // Any path prefixes that should resolve to index.html.
 var (
-	fePaths    = []string{"/q/", "/c/", "/dashboard/", "/admin/"}
+	fePaths    = []string{"/q/", "/c/", "/p/", "/x/", "/dashboard/", "/admin/"}
 	issueNumRE = regexp.MustCompile(`^\/\d+\/?$`)
 )
 
diff --git a/prolog/BUILD b/prolog/BUILD
new file mode 100644
index 0000000..5de443d
--- /dev/null
+++ b/prolog/BUILD
@@ -0,0 +1,8 @@
+load("//lib/prolog:prolog.bzl", "prolog_cafe_library")
+
+prolog_cafe_library(
+    name = "gerrit-prolog-common",
+    srcs = ["gerrit_common.pl"],
+    visibility = ["//visibility:public"],
+    deps = ["//java/gerrit:prolog-predicates"],
+)
diff --git a/prolog/gerrit_common.pl b/prolog/gerrit_common.pl
new file mode 100644
index 0000000..e2857d0
--- /dev/null
+++ b/prolog/gerrit_common.pl
@@ -0,0 +1,431 @@
+%% 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 gerrit.
+'$init' :- init.
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% init:
+%%
+%%   Initialize the module's private state. These typically take the form of global
+%%   aliased hashes carrying "constant" data about the current change for any
+%%   predicate that needs to obtain it.
+%%
+init :-
+  define_hash(commit_labels).
+
+define_hash(A) :- hash_exists(A), !, hash_clear(A).
+define_hash(A) :- atom(A), !, new_hash(_, [alias(A)]).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% commit_label/2:
+%%
+%% During rule evaluation of a change, this predicate is defined to
+%% be a table of labels that pertain to the commit of interest.
+%%
+%%   commit_label( label('Code-Review', 2), user(12345789) ).
+%%   commit_label( label('Verified', -1), user(8181) ).
+%%
+:- public commit_label/2.
+%%
+commit_label(L, User) :- L = label(H, _),
+  atom(H),
+  !,
+  hash_get(commit_labels, H, Cached),
+  ( [] == Cached ->
+    get_commit_labels(_),
+    hash_get(commit_labels, H, Rs), !
+    ;
+    Rs = Cached
+  ),
+  scan_commit_labels(Rs, L, User)
+  .
+commit_label(Label, User) :-
+  get_commit_labels(Rs),
+  scan_commit_labels(Rs, Label, User).
+
+scan_commit_labels([R | Rs], L, U) :- R = commit_label(L, U).
+scan_commit_labels([_ | Rs], L, U) :- scan_commit_labels(Rs, L, U).
+scan_commit_labels([], _, _) :- fail.
+
+get_commit_labels(Rs) :-
+  hash_contains_key(commit_labels, '$all'),
+  !,
+  hash_get(commit_labels, '$all', Rs)
+  .
+get_commit_labels(Rs) :-
+  '_load_commit_labels'(Rs),
+  set_commit_labels(Rs).
+
+set_commit_labels(Rs) :-
+  define_hash(commit_labels),
+  hash_put(commit_labels, '$all', Rs),
+  index_commit_labels(Rs).
+
+index_commit_labels([]).
+index_commit_labels([R | Rs]) :-
+  R = commit_label(label(H, _), _),
+  atom(H),
+  !,
+  hash_get(commit_labels, H, Tmp),
+  hash_put(commit_labels, H, [R | Tmp]),
+  index_commit_labels(Rs)
+  .
+index_commit_labels([_ | Rs]) :-
+  index_commit_labels(Rs).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% not_same/2:
+%%
+:- public not_same/2.
+%%
+not_same(ok(A), ok(B)) :- !, A \= B.
+not_same(label(_, ok(A)), label(_, ok(B))) :- !, A \= B.
+not_same(_, _).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% can_submit/2:
+%%
+%%   Executes the SubmitRule for each solution until one where all of the
+%%   states has the format label(_, ok(_)) is found, then cut away any
+%%   remaining choice points leaving this as the last solution.
+%%
+:- public can_submit/2.
+%%
+can_submit(SubmitRule, S) :-
+  call_rule(SubmitRule, Tmp),
+  Tmp =.. [submit | Ls],
+  ( is_all_ok(Ls) -> S = ok(Tmp), ! ; S = not_ready(Tmp) ).
+
+call_rule(P:X, Arg) :- !, F =.. [X, Arg], P:F.
+call_rule(X, Arg) :- !, F =.. [X, Arg], F.
+
+is_all_ok([]).
+is_all_ok([label(_, ok(__)) | Ls]) :- is_all_ok(Ls).
+is_all_ok([label(_, may(__)) | Ls]) :- is_all_ok(Ls).
+is_all_ok(_) :- fail.
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% locate_helper
+%%
+%%   Returns user:Func if it exists otherwise returns gerrit:Default
+
+locate_helper(Func, Default, Arity, user:Func) :-
+    '$compiled_predicate'(user, Func, Arity), !.
+locate_helper(Func, Default, Arity, user:Func) :-
+    listN(Arity, P), C =.. [Func | P], clause(user:C, _), !.
+locate_helper(Func, Default, _, gerrit:Default).
+
+listN(0, []).
+listN(N, [_|T]) :- N > 0, N1 is N - 1, listN(N1, T).
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% locate_submit_rule/1:
+%%
+%%   Finds a submit_rule depending on what rules are available.
+%%   If none are available, use default_submit/1.
+%%
+:- public locate_submit_rule/1.
+%%
+
+locate_submit_rule(RuleName) :-
+  locate_helper(submit_rule, default_submit, 1, RuleName).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% get_submit_type/2:
+%%
+%%   Executes the SubmitTypeRule and return the first solution
+%%
+:- public get_submit_type/2.
+%%
+get_submit_type(SubmitTypeRule, A) :-
+  call_rule(SubmitTypeRule, A), !.
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% locate_submit_type/1:
+%%
+%%   Finds a submit_type_rule depending on what rules are available.
+%%   If none are available, use project_default_submit_type/1.
+%%
+:- public locate_submit_type/1.
+%%
+locate_submit_type(RuleName) :-
+  locate_helper(submit_type, project_default_submit_type, 1, RuleName).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% default_submit/1:
+%%
+:- public default_submit/1.
+%%
+default_submit(P) :-
+  get_legacy_label_types(LabelTypes),
+  default_submit(LabelTypes, P).
+
+% Apply the old "all approval categories must be satisfied"
+% loop by scanning over all of the label types to build up the
+% submit record.
+%
+default_submit(LabelTypes, P) :-
+  default_submit(LabelTypes, [], Tmp),
+  reverse(Tmp, Ls),
+  P =.. [ submit | Ls].
+
+default_submit([], Out, Out).
+default_submit([Type | Types], Tmp, Out) :-
+  label_type(Label, Fun, Min, Max) = Type,
+  legacy_submit_rule(Fun, Label, Min, Max, Status),
+  R = label(Label, Status),
+  default_submit(Types, [R | Tmp], Out).
+
+
+%% legacy_submit_rule:
+%%
+%% Apply the old -2..+2 style logic.
+%%
+legacy_submit_rule('MaxWithBlock', Label, Min, Max, T) :- !, max_with_block(Label, Min, Max, T).
+legacy_submit_rule('AnyWithBlock', Label, Min, Max, T) :- !, any_with_block(Label, Min, T).
+legacy_submit_rule('MaxNoBlock', Label, Min, Max, T) :- !, max_no_block(Label, Max, T).
+legacy_submit_rule('NoBlock', Label, Min, Max, T) :- !, T = may(_).
+legacy_submit_rule('NoOp', Label, Min, Max, T) :- !, T = may(_).
+legacy_submit_rule('PatchSetLock', Label, Min, Max, T) :- !, T = may(_).
+legacy_submit_rule(Fun, Label, Min, Max, T) :- T = impossible(unsupported(Fun)).
+
+%% max_with_block:
+%%
+%% - The minimum is never used.
+%% - At least one maximum is used.
+%%
+:- public max_with_block/4.
+%%
+max_with_block(Min, Max, Label, label(Label, S)) :-
+  number(Min), number(Max), atom(Label),
+  !,
+  max_with_block(Label, Min, Max, S).
+max_with_block(Label, Min, Max, reject(Who)) :-
+  commit_label(label(Label, Min), Who),
+  !
+  .
+max_with_block(Label, Min, Max, ok(Who)) :-
+  \+ commit_label(label(Label, Min), _),
+  commit_label(label(Label, Max), Who),
+  !
+  .
+max_with_block(Label, Min, Max, need(Max)) :-
+  true
+  .
+
+%% any_with_block:
+%%
+%% - The maximum is never used.
+%%
+any_with_block(Label, Min, reject(Who)) :-
+  Min < 0,
+  commit_label(label(Label, Min), Who),
+  !
+  .
+any_with_block(Label, Min, may(_)).
+
+
+%% max_no_block:
+%%
+%% - At least one maximum is used.
+%%
+max_no_block(Max, Label, label(Label, S)) :-
+  number(Max), atom(Label),
+  !,
+  max_no_block(Label, Max, S).
+max_no_block(Label, Max, ok(Who)) :-
+  commit_label(label(Label, Max), Who),
+  !
+  .
+max_no_block(Label, Max, need(Max)) :-
+  true
+  .
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% filter_submit_results/3:
+%%
+%%   Executes the submit_filter against the given list of results,
+%%   returns a list of filtered results.
+%%
+:- public filter_submit_results/3.
+%%
+filter_submit_results(Filter, In, Out) :-
+    filter_submit_results(Filter, In, [], Tmp),
+    reverse(Tmp, Out).
+filter_submit_results(Filter, [I | In], Tmp, Out) :-
+    arg(1, I, R),
+    call_submit_filter(Filter, R, S),
+    !,
+    S =.. [submit | Ls],
+    ( is_all_ok(Ls) -> T = ok(S) ; T = not_ready(S) ),
+    filter_submit_results(Filter, In, [T | Tmp], Out).
+filter_submit_results(Filter, [_ | In], Tmp, Out) :-
+   filter_submit_results(Filter, In, Tmp, Out),
+   !
+   .
+filter_submit_results(Filter, [], Out, Out).
+
+call_submit_filter(P:X, R, S) :- !, F =.. [X, R, S], P:F.
+call_submit_filter(X, R, S) :- F =.. [X, R, S], F.
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% filter_submit_type_results/3:
+%%
+%%   Executes the submit_type_filter against the result,
+%%   returns the filtered result.
+%%
+:- public filter_submit_type_results/3.
+%%
+filter_submit_type_results(Filter, In, Out) :- call_submit_filter(Filter, In, Out).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% locate_submit_filter/1:
+%%
+%%   Finds a submit_filter if available.
+%%
+:- public locate_submit_filter/1.
+%%
+locate_submit_filter(FilterName) :-
+  locate_helper(submit_filter, noop_filter, 2, FilterName).
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% noop_filter/2:
+%%
+:- public noop_filter/2.
+%%
+noop_filter(In, In).
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% locate_submit_type_filter/1:
+%%
+%%   Finds a submit_type_filter if available.
+%%
+:- public locate_submit_type_filter/1.
+%%
+locate_submit_type_filter(FilterName) :-
+  locate_helper(submit_type_filter, noop_filter, 2, FilterName).
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% find_label/3:
+%%
+%%   Finds labels successively and fails when there are no more results.
+%%
+:- public find_label/3.
+%%
+find_label([], _, _) :- !, fail.
+find_label(List, Name, Label) :-
+  List = [_ | _],
+  !,
+  find_label2(List, Name, Label).
+find_label(S, Name, Label) :-
+  S =.. [submit | Ls],
+  find_label2(Ls, Name, Label).
+
+find_label2([L | _ ], Name, L) :- L = label(Name, _).
+find_label2([_ | Ls], Name, L) :- find_label2(Ls, Name, L).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% remove_label/3:
+%%
+%%   Removes all occurances of label(Name, Status).
+%%
+:- public remove_label/3.
+%%
+remove_label([], _, []) :- !.
+remove_label(List, Label, Out) :-
+  List = [_ | _],
+  !,
+  subtract1(List, Label, Out).
+remove_label(S, Label, Out) :-
+  S =.. [submit | Ls],
+  subtract1(Ls, Label, Tmp),
+  Out =.. [submit | Tmp].
+
+subtract1([], _, []) :- !.
+subtract1([E | L], E, R) :- !, subtract1(L, E, R).
+subtract1([H | L], E, [H | R]) :- subtract1(L, E, R).
+
+
+%% commit_author/1:
+%%
+:- public commit_author/1.
+%%
+commit_author(Author) :-
+  commit_author(Author, _, _).
+
+
+%% commit_committer/1:
+%%
+:- public commit_committer/1.
+%%
+commit_committer(Committer) :-
+  commit_committer(Committer, _, _).
+
+
+%% commit_delta/1:
+%%
+:- public commit_delta/1.
+%%
+commit_delta(Regex) :-
+  once(commit_delta(Regex, _, _, _)).
+
+
+%% commit_delta/3:
+%%
+:- public commit_delta/3.
+%%
+commit_delta(Regex, Type, Path) :-
+  commit_delta(Regex, TmpType, NewPath, OldPath),
+  split_commit_delta(TmpType, NewPath, OldPath, Type, Path).
+
+split_commit_delta(rename, NewPath, OldPath, delete, OldPath).
+split_commit_delta(rename, NewPath, OldPath, add, NewPath) :- !.
+split_commit_delta(copy, NewPath, OldPath, add, NewPath) :- !.
+split_commit_delta(Type, Path, _, Type, Path).
+
+
+%% commit_message_matches/1:
+%%
+:- public commit_message_matches/1.
+%%
+commit_message_matches(Pattern) :-
+  commit_message(Msg),
+  regex_matches(Pattern, Msg).
diff --git a/prologtests/BUILD b/prologtests/BUILD
new file mode 100644
index 0000000..279dbb7
--- /dev/null
+++ b/prologtests/BUILD
@@ -0,0 +1,5 @@
+filegroup(
+    name = "gerrit_common_test",
+    srcs = ["com/google/gerrit/server/rules/gerrit_common_test.pl"],
+    visibility = ["//visibility:public"],
+)
diff --git a/prologtests/com/google/gerrit/server/rules/gerrit_common_test.pl b/prologtests/com/google/gerrit/server/rules/gerrit_common_test.pl
new file mode 100644
index 0000000..a7df2b9
--- /dev/null
+++ b/prologtests/com/google/gerrit/server/rules/gerrit_common_test.pl
@@ -0,0 +1,180 @@
+%% 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 gerrit.
+
+
+%% not_same
+%%
+test(not_same_success) :-
+  not_same(ok(a), ok(b)),
+  not_same(label(e, ok(a)), label(e, ok(b))).
+
+
+%% get_legacy_label_types
+%%
+test(get_legacy_label_types) :-
+  get_legacy_label_types(T),
+  T = [C, V],
+  C = label_type('Code-Review', 'MaxWithBlock', -2, 2),
+  V = label_type('Verified', 'MaxWithBlock', -1, 1).
+
+
+%% commit_label
+%%
+test(commit_label_all) :-
+  findall(commit_label(L, U), commit_label(L, U), Out),
+  all_commit_labels(Ls),
+  Ls = Out.
+
+test(commit_label_CodeReview) :-
+  L = label('Code-Review', _),
+  findall(p(L, U), commit_label(L, U), Out),
+  [ p(label('Code-Review', 2), test_user(bob)),
+    p(label('Code-Review', 2), test_user(alice)) ] == Out.
+
+
+%% max_with_block
+%%
+test(max_with_block_success_accept_max_score) :-
+  max_with_block('Code-Review', -2, 2, ok(test_user(alice))).
+
+test(max_with_block_success_reject_min_score) :-
+  max_with_block('You-Fail', -1, 1, reject(test_user(failer))).
+
+test(max_with_block_success_need_suggest) :-
+  max_with_block('Verified', -1, 1, need(1)).
+
+skip_test(max_with_block_success_impossible) :-
+  max_with_block('Code-Style', 0, 1, impossible(no_access)).
+
+
+%% default_submit
+%%
+test(default_submit_fails) :-
+  findall(P, default_submit(P), All),
+  All = [submit(C, V)],
+  C = label('Code-Review', ok(_)),
+  V = label('Verified', need(1)).
+
+
+%% can_submit
+%%
+test(can_submit_ok) :-
+  set_commit_labels([
+    commit_label( label('Code-Review', 2), test_user(alice) ),
+    commit_label( label('Verified', 1), test_user(builder) )
+  ]),
+  can_submit(gerrit:default_submit, S),
+  S = ok(submit(C, V)),
+  C = label('Code-Review', ok(test_user(alice))),
+  V = label('Verified', ok(test_user(builder))).
+
+test(can_submit_not_ready) :-
+  can_submit(gerrit:default_submit, S),
+  S = not_ready(submit(C, V)),
+  C = label('Code-Review', ok(_)),
+  V = label('Verified', need(1)).
+
+test(can_submit_only_verified_not_ready) :-
+  can_submit(submit_only_verified, S),
+  S = not_ready(submit(V)),
+  V = label('Verified', need(1)).
+
+
+%% filter_submit_results
+%%
+test(filter_submit_remove_verified) :-
+  can_submit(gerrit:default_submit, R),
+  filter_submit_results(filter_out_v, [R], S),
+  S = [ok(submit(C))],
+  C = label('Code-Review', ok(_)).
+
+test(filter_submit_add_code_review) :-
+  set_commit_labels([
+    commit_label( label('Code-Review', 2), test_user(alice) ),
+    commit_label( label('Verified', 1), test_user(builder) )
+  ]),
+  can_submit(submit_only_verified, R),
+  filter_submit_results(filter_in_cr, [R], S),
+  S = [ok(submit(C, V))],
+  C = label('Code-Review', ok(test_user(alice))),
+  V = label('Verified', ok(test_user(builder))).
+
+
+%% find_label
+%%
+test(find_default_code_review) :-
+  can_submit(gerrit:default_submit, R),
+  arg(1, R, S),
+  find_label(S, 'Code-Review', L),
+  L = label('Code-Review', ok(_)).
+
+test(find_default_verified) :-
+  can_submit(gerrit:default_submit, R),
+  arg(1, R, S),
+  find_label(S, 'Verified', L),
+  L = label('Verified', need(1)).
+
+
+%% remove_label
+%%
+test(remove_default_code_review) :-
+  can_submit(gerrit:default_submit, R),
+  arg(1, R, S),
+  C = label('Code-Review', ok(_)),
+  remove_label(S, C, Out),
+  Out = submit(V),
+  V = label('Verified', need(1)).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Supporting Data
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+setup :-
+  init,
+  all_commit_labels(Ls),
+  set_commit_labels(Ls).
+
+all_commit_labels(Ls) :-
+  Ls = [
+    commit_label( label('Code-Review', 2), test_user(alice) ),
+    commit_label( label('Code-Review', 2), test_user(bob) ),
+    commit_label( label('You-Fail', -1), test_user(failer) ),
+    commit_label( label('You-Fail', -1), test_user(alice) )
+  ].
+
+submit_only_verified(P) :-
+  max_with_block('Verified', -1, 1, Status),
+  P = submit(label('Verified', Status)).
+
+filter_out_v(R, S) :-
+  find_label(R, 'Verified', Verified), !,
+  remove_label(R, Verified, S).
+filter_out_v(R, S).
+
+filter_in_cr(R, S) :-
+  R =.. [submit | Labels],
+  max_with_block('Code-Review', -2, 2, Status),
+  CR = label('Code-Review', Status),
+  S =.. [submit , CR | Labels].
+
+:- package user.
+test_grant('Code-Review', test_user(alice), range(-2, 2)).
+test_grant('Verified', test_user(builder), range(-1, 1)).
+test_grant('You-Fail', test_user(alice), range(-1, 1)).
+test_grant('You-Fail', test_user(failer), range(-1, 1)).
diff --git a/resources/BUILD b/resources/BUILD
new file mode 100644
index 0000000..18d8df6
--- /dev/null
+++ b/resources/BUILD
@@ -0,0 +1,14 @@
+load("//tools/bzl:genrule2.bzl", "genrule2")
+
+java_import(
+    name = "log4j-config",
+    jars = [":log4j-config__jar"],
+    visibility = ["//visibility:public"],
+)
+
+genrule2(
+    name = "log4j-config__jar",
+    srcs = ["log4j.properties"],
+    outs = ["log4j-config.jar"],
+    cmd = "cd resources && zip -9Dqr $$ROOT/$@ .",
+)
diff --git a/resources/com/google/gerrit/acceptance/BUILD b/resources/com/google/gerrit/acceptance/BUILD
new file mode 100644
index 0000000..38da575
--- /dev/null
+++ b/resources/com/google/gerrit/acceptance/BUILD
@@ -0,0 +1,8 @@
+filegroup(
+    name = "acceptance",
+    srcs = glob(
+        ["**/*"],
+        exclude = ["BUILD"],
+    ),
+    visibility = ["//visibility:public"],
+)
diff --git a/gerrit-acceptance-tests/src/test/resources/com/google/gerrit/acceptance/server/mail/tlds-alpha-by-domain.txt b/resources/com/google/gerrit/acceptance/server/mail/tlds-alpha-by-domain.txt
similarity index 100%
rename from gerrit-acceptance-tests/src/test/resources/com/google/gerrit/acceptance/server/mail/tlds-alpha-by-domain.txt
rename to resources/com/google/gerrit/acceptance/server/mail/tlds-alpha-by-domain.txt
diff --git a/resources/com/google/gerrit/httpd/BUILD b/resources/com/google/gerrit/httpd/BUILD
new file mode 100644
index 0000000..8ac21da
--- /dev/null
+++ b/resources/com/google/gerrit/httpd/BUILD
@@ -0,0 +1,8 @@
+filegroup(
+    name = "httpd",
+    srcs = glob(
+        ["**/*"],
+        exclude = ["BUILD"],
+    ),
+    visibility = ["//visibility:public"],
+)
diff --git a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/become/BecomeAnyAccount.html b/resources/com/google/gerrit/httpd/auth/become/BecomeAnyAccount.html
similarity index 100%
rename from gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/become/BecomeAnyAccount.html
rename to resources/com/google/gerrit/httpd/auth/become/BecomeAnyAccount.html
diff --git a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/container/ConfigurationError.html b/resources/com/google/gerrit/httpd/auth/container/ConfigurationError.html
similarity index 100%
rename from gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/container/ConfigurationError.html
rename to resources/com/google/gerrit/httpd/auth/container/ConfigurationError.html
diff --git a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/container/LoginRedirect.html b/resources/com/google/gerrit/httpd/auth/container/LoginRedirect.html
similarity index 100%
rename from gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/container/LoginRedirect.html
rename to resources/com/google/gerrit/httpd/auth/container/LoginRedirect.html
diff --git a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html b/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
similarity index 100%
rename from gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
rename to resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
diff --git a/resources/com/google/gerrit/httpd/auth/oauth/BUILD b/resources/com/google/gerrit/httpd/auth/oauth/BUILD
new file mode 100644
index 0000000..0721712
--- /dev/null
+++ b/resources/com/google/gerrit/httpd/auth/oauth/BUILD
@@ -0,0 +1,8 @@
+filegroup(
+    name = "oauth",
+    srcs = glob(
+        ["**/*"],
+        exclude = ["BUILD"],
+    ),
+    visibility = ["//visibility:public"],
+)
diff --git a/gerrit-oauth/src/main/resources/com/google/gerrit/httpd/auth/oauth/LoginForm.html b/resources/com/google/gerrit/httpd/auth/oauth/LoginForm.html
similarity index 100%
rename from gerrit-oauth/src/main/resources/com/google/gerrit/httpd/auth/oauth/LoginForm.html
rename to resources/com/google/gerrit/httpd/auth/oauth/LoginForm.html
diff --git a/resources/com/google/gerrit/httpd/auth/openid/BUILD b/resources/com/google/gerrit/httpd/auth/openid/BUILD
new file mode 100644
index 0000000..d8670be
--- /dev/null
+++ b/resources/com/google/gerrit/httpd/auth/openid/BUILD
@@ -0,0 +1,8 @@
+filegroup(
+    name = "openid",
+    srcs = glob(
+        ["**/*"],
+        exclude = ["BUILD"],
+    ),
+    visibility = ["//visibility:public"],
+)
diff --git a/gerrit-openid/src/main/resources/com/google/gerrit/httpd/auth/openid/LoginForm.html b/resources/com/google/gerrit/httpd/auth/openid/LoginForm.html
similarity index 100%
rename from gerrit-openid/src/main/resources/com/google/gerrit/httpd/auth/openid/LoginForm.html
rename to resources/com/google/gerrit/httpd/auth/openid/LoginForm.html
diff --git a/gerrit-openid/src/main/resources/com/google/gerrit/httpd/auth/openid/RedirectForm.html b/resources/com/google/gerrit/httpd/auth/openid/RedirectForm.html
similarity index 100%
rename from gerrit-openid/src/main/resources/com/google/gerrit/httpd/auth/openid/RedirectForm.html
rename to resources/com/google/gerrit/httpd/auth/openid/RedirectForm.html
diff --git a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/HostPage.html b/resources/com/google/gerrit/httpd/raw/HostPage.html
similarity index 100%
rename from gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/HostPage.html
rename to resources/com/google/gerrit/httpd/raw/HostPage.html
diff --git a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/LegacyGerrit.html b/resources/com/google/gerrit/httpd/raw/LegacyGerrit.html
similarity index 100%
rename from gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/LegacyGerrit.html
rename to resources/com/google/gerrit/httpd/raw/LegacyGerrit.html
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
new file mode 100644
index 0000000..a013140
--- /dev/null
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -0,0 +1,67 @@
+/**
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.httpd.raw}
+
+/**
+ * @param canonicalPath
+ * @param staticResourcePath
+ * @param? faviconPath
+ * @param? versionInfo
+ */
+{template .Index}
+  <!DOCTYPE html>{\n}
+  <html lang="en">{\n}
+  <meta charset="utf-8">{\n}
+  <meta name="description" content="Gerrit Code Review">{\n}
+  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">{\n}
+
+  {if $canonicalPath != '' or $versionInfo}
+    <script>
+      {if $canonicalPath != ''}window.CANONICAL_PATH = '{$canonicalPath}';{/if}
+      {if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
+    </script>{\n}
+  {/if}
+
+  {if $faviconPath}
+    <link rel="icon" type="image/x-icon" href="{$canonicalPath}/{$faviconPath}">{\n}
+  {else}
+    <link rel="icon" type="image/x-icon" href="{$canonicalPath}/favicon.ico">{\n}
+  {/if}
+
+  // RobotoMono fonts are used in styles/fonts.css
+  // @see https://github.com/w3c/preload/issues/32 regarding crossorigin
+  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin>{\n}
+  <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
+  <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
+  <script src="{$staticResourcePath}/bower_components/webcomponentsjs/webcomponents-lite.js"></script>{\n}
+  // Content between webcomponents-lite and the load of the main app element
+  // run before polymer-resin is installed so may have security consequences.
+  // Contact your local security engineer if you have any questions, and
+  // CC them on any changes that load content before gr-app.html.
+  //
+  // github.com/Polymer/polymer-resin/blob/master/getting-started.md#integrating
+  <link rel="preload" href="{$staticResourcePath}/elements/gr-app.js" as="script" crossorigin="anonymous">{\n}
+  <link rel="import" href="{$staticResourcePath}/elements/gr-app.html">{\n}
+
+  <body unresolved>{\n}
+  <gr-app id="app"></gr-app>{\n}
+{/template}
diff --git a/resources/com/google/gerrit/pgm/BUILD b/resources/com/google/gerrit/pgm/BUILD
new file mode 100644
index 0000000..6401c07
--- /dev/null
+++ b/resources/com/google/gerrit/pgm/BUILD
@@ -0,0 +1,8 @@
+filegroup(
+    name = "pgm",
+    srcs = glob(
+        ["**/*"],
+        exclude = ["BUILD"],
+    ),
+    visibility = ["//visibility:public"],
+)
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/ProtoGenHeader.txt b/resources/com/google/gerrit/pgm/ProtoGenHeader.txt
similarity index 100%
rename from gerrit-pgm/src/main/resources/com/google/gerrit/pgm/ProtoGenHeader.txt
rename to resources/com/google/gerrit/pgm/ProtoGenHeader.txt
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/Startup.py b/resources/com/google/gerrit/pgm/Startup.py
similarity index 100%
rename from gerrit-pgm/src/main/resources/com/google/gerrit/pgm/Startup.py
rename to resources/com/google/gerrit/pgm/Startup.py
diff --git a/resources/com/google/gerrit/pgm/init/BUILD b/resources/com/google/gerrit/pgm/init/BUILD
new file mode 100644
index 0000000..4a0d173
--- /dev/null
+++ b/resources/com/google/gerrit/pgm/init/BUILD
@@ -0,0 +1,8 @@
+filegroup(
+    name = "init",
+    srcs = glob(
+        ["**/*"],
+        exclude = ["BUILD"],
+    ),
+    visibility = ["//visibility:public"],
+)
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.service b/resources/com/google/gerrit/pgm/init/gerrit.service
similarity index 100%
rename from gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.service
rename to resources/com/google/gerrit/pgm/init/gerrit.service
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
similarity index 100%
rename from gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh
rename to resources/com/google/gerrit/pgm/init/gerrit.sh
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.socket b/resources/com/google/gerrit/pgm/init/gerrit.socket
similarity index 100%
rename from gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.socket
rename to resources/com/google/gerrit/pgm/init/gerrit.socket
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config b/resources/com/google/gerrit/pgm/init/libraries.config
similarity index 100%
rename from gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
rename to resources/com/google/gerrit/pgm/init/libraries.config
diff --git a/resources/com/google/gerrit/prettify/BUILD b/resources/com/google/gerrit/prettify/BUILD
new file mode 100644
index 0000000..3bae8ad
--- /dev/null
+++ b/resources/com/google/gerrit/prettify/BUILD
@@ -0,0 +1,4 @@
+exports_files([
+    "client/prettify.css",
+    "client/prettify.js",
+])
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/prettify.css b/resources/com/google/gerrit/prettify/client/prettify.css
similarity index 100%
rename from gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/prettify.css
rename to resources/com/google/gerrit/prettify/client/prettify.css
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/prettify.js b/resources/com/google/gerrit/prettify/client/prettify.js
similarity index 100%
rename from gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/prettify.js
rename to resources/com/google/gerrit/prettify/client/prettify.js
diff --git a/resources/com/google/gerrit/reviewdb/BUILD b/resources/com/google/gerrit/reviewdb/BUILD
new file mode 100644
index 0000000..8a1b457
--- /dev/null
+++ b/resources/com/google/gerrit/reviewdb/BUILD
@@ -0,0 +1,8 @@
+filegroup(
+    name = "reviewdb",
+    srcs = glob(
+        ["**/*"],
+        exclude = ["BUILD"],
+    ),
+    visibility = ["//visibility:public"],
+)
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql b/resources/com/google/gerrit/reviewdb/server/index_generic.sql
similarity index 100%
rename from gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
rename to resources/com/google/gerrit/reviewdb/server/index_generic.sql
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql b/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
similarity index 100%
rename from gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
rename to resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql b/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
similarity index 100%
rename from gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
rename to resources/com/google/gerrit/reviewdb/server/index_postgres.sql
diff --git a/resources/com/google/gerrit/server/BUILD b/resources/com/google/gerrit/server/BUILD
new file mode 100644
index 0000000..688474e
--- /dev/null
+++ b/resources/com/google/gerrit/server/BUILD
@@ -0,0 +1,8 @@
+filegroup(
+    name = "server",
+    srcs = glob(
+        ["**/*"],
+        exclude = ["BUILD"],
+    ),
+    visibility = ["//visibility:public"],
+)
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties b/resources/com/google/gerrit/server/change/ChangeMessages.properties
similarity index 100%
rename from gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
rename to resources/com/google/gerrit/server/change/ChangeMessages.properties
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/config/CapabilityConstants.properties b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
similarity index 100%
rename from gerrit-server/src/main/resources/com/google/gerrit/server/config/CapabilityConstants.properties
rename to resources/com/google/gerrit/server/config/CapabilityConstants.properties
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/documentation/pegdown.css b/resources/com/google/gerrit/server/documentation/pegdown.css
similarity index 100%
rename from gerrit-server/src/main/resources/com/google/gerrit/server/documentation/pegdown.css
rename to resources/com/google/gerrit/server/documentation/pegdown.css
diff --git a/resources/com/google/gerrit/server/mail/Abandoned.soy b/resources/com/google/gerrit/server/mail/Abandoned.soy
new file mode 100644
index 0000000..623cfe26
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/Abandoned.soy
@@ -0,0 +1,39 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * .Abandoned template will determine the contents of the email related to a
+ * change being abandoned.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .Abandoned kind="text"}
+  {$fromName} has abandoned this change.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/AbandonedHtml.soy b/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
new file mode 100644
index 0000000..75d940f
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
@@ -0,0 +1,38 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .AbandonedHtml}
+  <p>
+    {$fromName} <strong>abandoned</strong> this change.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $coverLetter}
+    <div style="white-space:pre-wrap">{$coverLetter}</div>
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/AddKey.soy b/resources/com/google/gerrit/server/mail/AddKey.soy
new file mode 100644
index 0000000..af99569
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AddKey.soy
@@ -0,0 +1,71 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .AddKey template will determine the contents of the email related to
+ * adding a new SSH or GPG key to an account.
+ * @param email
+ */
+{template .AddKey kind="text"}
+  One or more new {$email.keyType} keys have been added to Gerrit Code Review at
+  {sp}{$email.gerritHost}:
+
+  {\n}
+  {\n}
+
+  {if $email.sshKey}
+    {$email.sshKey}
+  {elseif $email.gpgKeys}
+    {$email.gpgKeys}
+  {/if}
+
+  {\n}
+  {\n}
+
+  If this is not expected, please contact your Gerrit Administrators
+  immediately.
+
+  {\n}
+  {\n}
+
+  You can also manage your {$email.keyType} keys by visiting
+  {\n}
+  {if $email.sshKey}
+    {$email.gerritUrl}#/settings/ssh-keys
+  {elseif $email.gpgKeys}
+    {$email.gerritUrl}#/settings/gpg-keys
+  {/if}
+  {\n}
+  {if $email.userNameEmail}
+    (while signed in as {$email.userNameEmail})
+  {else}
+    (while signed in as {$email.email})
+  {/if}
+
+  {\n}
+  {\n}
+
+  If clicking the link above does not work, copy and paste the URL in a new
+  browser window instead.
+
+  {\n}
+  {\n}
+
+  This is a send-only email address.  Replies to this message will not be read
+  or answered.
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
new file mode 100644
index 0000000..712abc7
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
@@ -0,0 +1,66 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param email
+ */
+{template .AddKeyHtml}
+  <p>
+    One or more new {$email.keyType} keys have been added to Gerrit Code Review
+    at {$email.gerritHost}:
+  </p>
+
+  {let $keyStyle kind="css"}
+    background: #f0f0f0;
+    border: 1px solid #ccc;
+    color: #555;
+    padding: 12px;
+    width: 400px;
+  {/let}
+
+  {if $email.sshKey}
+    <pre style="{$keyStyle}">{$email.sshKey}</pre>
+  {elseif $email.gpgKeys}
+    <pre style="{$keyStyle}">{$email.gpgKeys}</pre>
+  {/if}
+
+  <p>
+    If this is not expected, please contact your Gerrit Administrators
+    immediately.
+  </p>
+
+  <p>
+    You can also manage your {$email.keyType} keys by following{sp}
+    {if $email.sshKey}
+      <a href="{$email.gerritUrl}#/settings/ssh-keys">this link</a>
+    {elseif $email.gpgKeys}
+      <a href="{$email.gerritUrl}#/settings/gpg-keys">this link</a>
+    {/if}
+    {sp}
+    {if $email.userNameEmail}
+      (while signed in as {$email.userNameEmail})
+    {else}
+      (while signed in as {$email.email})
+    {/if}.
+  </p>
+
+  <p>
+    This is a send-only email address.  Replies to this message will not be read
+    or answered.
+  </p>
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/ChangeFooter.soy b/resources/com/google/gerrit/server/mail/ChangeFooter.soy
new file mode 100644
index 0000000..f1d201b
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/ChangeFooter.soy
@@ -0,0 +1,40 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .ChangeFooter template will determine the contents of the footer text
+ * that will be appended to ALL emails related to changes.
+ * @param email
+ */
+{template .ChangeFooter kind="text"}
+  --{sp}
+  {\n}
+
+  {if $email.changeUrl}
+    To view, visit {$email.changeUrl}{\n}
+  {/if}
+
+  {if $email.settingsUrl}
+    To unsubscribe, or for help writing mail filters,{sp}
+    visit {$email.settingsUrl}{\n}
+  {/if}
+
+  {if $email.changeUrl or $email.settingsUrl}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy b/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
new file mode 100644
index 0000000..99263e8
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
@@ -0,0 +1,46 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param change
+ * @param email
+ */
+{template .ChangeFooterHtml}
+  {if $email.changeUrl or $email.settingsUrl}
+    <p>
+      {if $email.changeUrl}
+        To view, visit{sp}
+        <a href="{$email.changeUrl}">change {$change.changeNumber}</a>.
+      {/if}
+      {if $email.changeUrl and $email.settingsUrl}{sp}{/if}
+      {if $email.settingsUrl}
+        To unsubscribe, or for help writing mail filters,{sp}
+        visit <a href="{$email.settingsUrl}">settings</a>.
+      {/if}
+    </p>
+  {/if}
+
+  {if $email.changeUrl}
+    <div itemscope itemtype="http://schema.org/EmailMessage">
+      <div itemscope itemprop="action" itemtype="http://schema.org/ViewAction">
+        <link itemprop="url" href="{$email.changeUrl}"/>
+        <meta itemprop="name" content="View Change"/>
+      </div>
+    </div>
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/ChangeSubject.soy b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
new file mode 100644
index 0000000..d8cffc4
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
@@ -0,0 +1,28 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .ChangeSubject template will determine the contents of the email subject
+ * line for ALL emails related to changes.
+ * @param branch
+ * @param change
+ * @param shortProjectName
+ */
+{template .ChangeSubject kind="text"}
+  Change in {$shortProjectName}[{$branch.shortName}]: {$change.shortSubject}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
new file mode 100644
index 0000000..3170448
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -0,0 +1,76 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Comment template will determine the contents of the email related to a
+ * user submitting comments on changes.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ * @param commentFiles
+ */
+{template .Comment kind="text"}
+  {$fromName} has posted comments on this change.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}{\n}
+    {\n}
+  {/if}
+
+  {for $group in $commentFiles}
+    {$group.link}{\n}
+    {$group.title}:{\n}
+    {\n}
+
+    {for $comment in $group.comments}
+      {if $comment.isRobotComment}
+        Robot Comment from {$comment.robotId} (run ID {$comment.robotRunId}):
+        {\n}
+      {/if}
+
+      {for $line in $comment.lines}
+        {if isFirst($line)}
+          {if $comment.startLine != 0}
+            {$comment.link}
+          {/if}{\n}
+          {$comment.linePrefix}
+        {else}
+          {$comment.linePrefixEmpty}
+        {/if}
+        {$line}{\n}
+      {/for}
+      {if length($comment.lines) == 0}
+        {$comment.linePrefix}{\n}
+      {/if}
+
+      {if $comment.parentMessage}
+        >{sp}{$comment.parentMessage}{\n}
+      {/if}
+      {$comment.message}{\n}
+      {\n}
+      {\n}
+    {/for}
+  {/for}
+  {\n}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/CommentFooter.soy b/resources/com/google/gerrit/server/mail/CommentFooter.soy
new file mode 100644
index 0000000..3998438
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/CommentFooter.soy
@@ -0,0 +1,25 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .CommentFooter template will determine the contents of the footer text
+ * that will be appended to emails related to a user submitting comments on
+ * changes.
+ */
+{template .CommentFooter kind="text"}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy b/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
new file mode 100644
index 0000000..033c1b1
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
@@ -0,0 +1,20 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .CommentFooterHtml}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/CommentHtml.soy b/resources/com/google/gerrit/server/mail/CommentHtml.soy
new file mode 100644
index 0000000..d554258
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -0,0 +1,175 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param commentFiles
+ * @param commentCount
+ * @param email
+ * @param labels
+ * @param patchSet
+ * @param patchSetCommentBlocks
+ */
+{template .CommentHtml}
+  {let $commentHeaderStyle kind="css"}
+    margin-bottom: 4px;
+  {/let}
+
+  {let $blockquoteStyle kind="css"}
+    border-left: 1px solid #aaa;
+    margin: 10px 0;
+    padding: 0 10px;
+  {/let}
+
+  {let $ulStyle kind="css"}
+    list-style: none;
+    padding: 0;
+  {/let}
+
+  {let $fileLiStyle kind="css"}
+    margin: 0;
+    padding: 0;
+  {/let}
+
+  {let $commentLiStyle kind="css"}
+    margin: 0;
+    padding: 0 0 0 16px;
+  {/let}
+
+  {let $voteStyle kind="css"}
+    border-radius: 3px;
+    display: inline-block;
+    margin: 0 2px;
+    padding: 4px;
+  {/let}
+
+  {let $positiveVoteStyle kind="css"}
+    {$voteStyle}
+    background-color: #d4ffd4;
+  {/let}
+
+  {let $negativeVoteStyle kind="css"}
+    {$voteStyle}
+    background-color: #ffd4d4;
+  {/let}
+
+  {let $neutralVoteStyle kind="css"}
+    {$voteStyle}
+    background-color: #ddd;
+  {/let}
+
+  {if $patchSetCommentBlocks}
+    {call .WikiFormat}{param content: $patchSetCommentBlocks /}{/call}
+  {/if}
+
+  {if length($labels) > 0}
+    <p>
+      Patch set {$patchSet.patchSetId}:
+      {for $label in $labels}
+        {if $label.value > 0}
+          <span style="{$positiveVoteStyle}">
+            {$label.label}{sp}+{$label.value}
+          </span>
+        {elseif $label.value < 0}
+          <span style="{$negativeVoteStyle}">
+            {$label.label}{sp}{$label.value}
+          </span>
+        {else}
+          <span style="{$neutralVoteStyle}">
+            -{$label.label}
+          </span>
+        {/if}
+      {/for}
+    </p>
+  {/if}
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $commentCount == 1}
+    <p>1 comment:</p>
+  {elseif $commentCount > 1}
+    <p>{$commentCount} comments:</p>
+  {/if}
+
+  <ul style="{$ulStyle}">
+    {for $group in $commentFiles}
+      <li style="{$fileLiStyle}">
+        <p>
+          <a href="{$group.link}">{$group.title}:</a>
+        </p>
+
+        <ul style="{$ulStyle}">
+          {for $comment in $group.comments}
+            <li style="{$commentLiStyle}">
+              {if $comment.isRobotComment}
+                <p style="{$commentHeaderStyle}">
+                  Robot Comment from{sp}
+                  {if $comment.robotUrl}<a href="{$comment.robotUrl}">{/if}
+                  {$comment.robotId}
+                  {if $comment.robotUrl}</a>{/if}{sp}
+                  (run ID {$comment.robotRunId}):
+                </p>
+              {/if}
+
+              <p style="{$commentHeaderStyle}">
+                <a href="{$comment.link}">
+                  {if $comment.startLine == 0}
+                    Patch Set #{$group.patchSetId}:
+                  {else}
+                    Patch Set #{$group.patchSetId},{sp}
+                    Line {$comment.startLine}:
+                  {/if}
+                </a>{sp}
+                {if length($comment.lines) == 1}
+                  <code style="font-family:monospace,monospace">
+                    {$comment.lines[0]}
+                  </code>
+                {/if}
+              </p>
+
+              {if length($comment.lines) > 1}
+                <p>
+                  <blockquote style="{$blockquoteStyle}">
+                    {call .Pre}{param content kind="html"}
+                      {for $line in $comment.lines}
+                        {$line}{\n}
+                      {/for}
+                    {/param}{/call}
+                  </blockquote>
+                </p>
+              {/if}
+
+              {if $comment.parentMessage}
+                <p>
+                  <blockquote style="{$blockquoteStyle}">
+                    {$comment.parentMessage}
+                  </blockquote>
+                </p>
+              {/if}
+
+              {call .WikiFormat}{param content: $comment.messageBlocks /}{/call}
+            </li>
+          {/for}
+        </ul>
+      </li>
+    {/for}
+  </ul>
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/DeleteReviewer.soy b/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
new file mode 100644
index 0000000..065348a
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
@@ -0,0 +1,44 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .DeleteReviewer template will determine the contents of the email related
+ * to removal of a reviewer (and the reviewer's votes) from reviews.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .DeleteReviewer kind="text"}
+  {$fromName} has removed{sp}
+  {for $reviewerName in $email.reviewerNames}
+    {if not isFirst($reviewerName)},{sp}{/if}
+    {$reviewerName}
+  {/for}{sp}
+  from this change.{sp}
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy b/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
new file mode 100644
index 0000000..0599b52
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
@@ -0,0 +1,43 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param email
+ * @param fromName
+ */
+{template .DeleteReviewerHtml}
+  <p>
+    {$fromName}{sp}
+    <strong>
+      removed{sp}
+      {for $reviewerName in $email.reviewerNames}
+        {if not isFirst($reviewerName)}
+          {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
+        {/if}
+        {$reviewerName}
+      {/for}
+    </strong>{sp}
+    from this change.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/DeleteVote.soy b/resources/com/google/gerrit/server/mail/DeleteVote.soy
new file mode 100644
index 0000000..724e90d
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/DeleteVote.soy
@@ -0,0 +1,37 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .DeleteVote template will determine the contents of the email related
+ * to removing votes on changes.
+ * @param change
+ * @param coverLetter
+ * @param fromName
+ */
+{template .DeleteVote kind="text"}
+  {$fromName} has removed a vote on this change.{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy b/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
new file mode 100644
index 0000000..cb8162d
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
@@ -0,0 +1,38 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .DeleteVoteHtml}
+  <p>
+    {$fromName} <strong>removed a vote</strong> from this change.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $coverLetter}
+    <div style="white-space:pre-wrap">{$coverLetter}</div>
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/Footer.soy b/resources/com/google/gerrit/server/mail/Footer.soy
new file mode 100644
index 0000000..e1890a8
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/Footer.soy
@@ -0,0 +1,29 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+*/
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Footer template will determine the contents of the footer text
+ * appended to the end of all outgoing emails after the ChangeFooter and
+ * CommentFooter.
+ * @param footers
+ */
+{template .Footer kind="text"}
+  {for $footer in $footers}
+    {$footer}{\n}
+  {/for}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/FooterHtml.soy b/resources/com/google/gerrit/server/mail/FooterHtml.soy
new file mode 100644
index 0000000..938655c
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/FooterHtml.soy
@@ -0,0 +1,29 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+*/
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param footers
+ */
+{template .FooterHtml}
+  {\n}
+  {\n}
+  {for $footer in $footers}
+    <div style="display:none">{sp}{$footer}{sp}</div>{\n}
+  {/for}
+  {\n}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/HeaderHtml.soy b/resources/com/google/gerrit/server/mail/HeaderHtml.soy
new file mode 100644
index 0000000..4710d8c
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/HeaderHtml.soy
@@ -0,0 +1,20 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+*/
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .HeaderHtml}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/Merged.soy b/resources/com/google/gerrit/server/mail/Merged.soy
new file mode 100644
index 0000000..40924e6
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/Merged.soy
@@ -0,0 +1,42 @@
+
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Merged template will determine the contents of the email related to
+ * a change successfully merged to the head.
+ * @param change
+ * @param email
+ * @param fromName
+ */
+{template .Merged kind="text"}
+  {$fromName} has submitted this change and it was merged.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {\n}
+  {$email.changeDetail}
+  {$email.approvals}
+  {if $email.includeDiff}
+    {\n}
+    {\n}
+    {$email.unifiedDiff}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/MergedHtml.soy b/resources/com/google/gerrit/server/mail/MergedHtml.soy
new file mode 100644
index 0000000..b11c5e5
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -0,0 +1,42 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param diffLines
+ * @param email
+ * @param fromName
+ */
+{template .MergedHtml}
+  <p>
+    {$fromName} <strong>merged</strong> this change.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  <div style="white-space:pre-wrap">{$email.approvals}</div>
+
+  {call .Pre}{param content: $email.changeDetail /}{/call}
+
+  {if $email.includeDiff}
+    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/NewChange.soy b/resources/com/google/gerrit/server/mail/NewChange.soy
new file mode 100644
index 0000000..f11edfe
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/NewChange.soy
@@ -0,0 +1,81 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .NewChange template will determine the contents of the email related to a
+ * user submitting a new change for review.
+ * @param change
+ * @param email
+ * @param ownerName
+ * @param patchSet
+ * @param projectName
+ */
+{template .NewChange kind="text"}
+  {if $email.reviewerNames}
+    Hello{sp}
+    {for $reviewerName in $email.reviewerNames}
+      {if not isFirst($reviewerName)},{sp}{/if}
+      {$reviewerName}
+    {/for},
+
+    {\n}
+    {\n}
+
+    I'd like you to do a code review.
+
+    {if $email.changeUrl}
+      {sp}Please visit
+
+      {\n}
+      {\n}
+
+      {sp}{sp}{sp}{sp}{$email.changeUrl}
+
+      {\n}
+      {\n}
+
+      to review the following change.
+    {/if}
+  {else}
+    {$ownerName} has uploaded this change for review.
+    {if $email.changeUrl} ( {$email.changeUrl}{/if}
+  {/if}{\n}
+
+  {\n}
+  {\n}
+
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+
+  {\n}
+
+  {$email.changeDetail}{\n}
+
+  {if $email.sshHost}
+    {\n}
+    {sp}{sp}git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
+        {sp}{$patchSet.refName}
+    {\n}
+  {/if}
+
+  {if $email.includeDiff}
+    {\n}
+    {$email.unifiedDiff}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
new file mode 100644
index 0000000..5bce806
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -0,0 +1,61 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param diffLines
+ * @param email
+ * @param fromName
+ * @param ownerName
+ * @param patchSet
+ * @param projectName
+ */
+{template .NewChangeHtml}
+  <p>
+    {if $email.reviewerNames}
+      {$fromName} would like{sp}
+      {for $reviewerName in $email.reviewerNames}
+        {if not isFirst($reviewerName)}
+          {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
+        {/if}
+        {$reviewerName}
+      {/for}{sp}
+      to <strong>review</strong> this change.
+    {else}
+      {$ownerName} has uploaded this change for <strong>review</strong>.
+    {/if}
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {call .Pre}{param content: $email.changeDetail /}{/call}
+
+  {if $email.sshHost}
+    {call .Pre}{param content kind="html"}
+      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
+          {sp}{$patchSet.refName}
+    {/param}{/call}
+  {/if}
+
+  {if $email.includeDiff}
+    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/Private.soy b/resources/com/google/gerrit/server/mail/Private.soy
new file mode 100644
index 0000000..bb32a7e9
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/Private.soy
@@ -0,0 +1,121 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/*
+ * Private templates that cannot be overridden.
+ */
+
+/**
+ * Private template to generate "View Change" buttons.
+ * @param email
+ */
+{template .ViewChangeButton}
+  <a href="{$email.changeUrl}">View Change</a>
+{/template}
+
+/**
+ * Private template to render PRE block with consistent font-sizing.
+ * @param content
+ */
+{template .Pre}
+  {let $preStyle kind="css"}
+    font-family: monospace,monospace; // Use this to avoid browsers scaling down
+                                      // monospace text.
+    white-space: pre-wrap;
+  {/let}
+  <pre style="{$preStyle}">{$content|changeNewlineToBr}</pre>
+{/template}
+
+/**
+ * Take a list of unescaped comment blocks and emit safely escaped HTML to
+ * render it nicely with wiki-like format.
+ *
+ * Each block is a map with a type key. When the type is 'paragraph', or 'pre',
+ * it also has a 'text' key that maps to the unescaped text content for the
+ * block. If the type is 'list', the map will have a 'items' key which maps to
+ * list of unescaped list item strings. If the type is quote, the map will have
+ * a 'quotedBlocks' key which maps to the blocks contained within the quote.
+ *
+ * This mechanism encodes as little structure as possible in order to depend on
+ * the Soy autoescape mechanism for all of the content.
+ *
+ * @param content
+ */
+{template .WikiFormat}
+  {let $blockquoteStyle kind="css"}
+    border-left: 1px solid #aaa;
+    margin: 10px 0;
+    padding: 0 10px;
+  {/let}
+
+  {let $pStyle kind="css"}
+    white-space: pre-wrap;
+    word-wrap: break-word;
+  {/let}
+
+  {for $block in $content}
+    {if $block.type == 'paragraph'}
+      <p style="{$pStyle}">{$block.text|changeNewlineToBr}</p>
+    {elseif $block.type == 'quote'}
+      <blockquote style="{$blockquoteStyle}">
+        {call .WikiFormat}{param content: $block.quotedBlocks /}{/call}
+      </blockquote>
+    {elseif $block.type == 'pre'}
+      {call .Pre}{param content: $block.text /}{/call}
+    {elseif $block.type == 'list'}
+      <ul>
+        {for $item in $block.items}
+          <li>{$item}</li>
+        {/for}
+      </ul>
+    {/if}
+  {/for}
+{/template}
+
+/**
+ * @param diffLines
+ */
+{template .UnifiedDiff}
+  {let $addStyle kind="css"}
+    color: hsl(120, 100%, 40%);
+  {/let}
+
+  {let $removeStyle kind="css"}
+    color: hsl(0, 100%, 40%);
+  {/let}
+
+  {let $preStyle kind="css"}
+    font-family: monospace,monospace; // Use this to avoid browsers scaling down
+                                      // monospace text.
+    white-space: pre-wrap;
+  {/let}
+
+  <pre style="{$preStyle}">
+    {for $line in $diffLines}
+      {if $line.type == 'add'}
+        <span style="{$addStyle}">
+      {elseif $line.type == 'remove'}
+        <span style="{$removeStyle}">
+      {else}
+        <span>
+      {/if}
+        {$line.text}
+      </span><br>
+    {/for}
+  </pre>
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
new file mode 100644
index 0000000..2886cc0
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
@@ -0,0 +1,54 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .RegisterNewEmail template will determine the contents of the email
+ * related to registering new email accounts.
+ * @param email
+ */
+{template .RegisterNewEmail kind="text"}
+  Welcome to Gerrit Code Review at {$email.gerritHost}.{\n}
+
+  {\n}
+
+  To add a verified email address to your user account, please{\n}
+  click on the following link
+  {if $email.userNameEmail}
+    {sp}while signed in as {$email.userNameEmail}
+  {/if}:{\n}
+
+  {\n}
+
+  {$email.gerritUrl}#/VE/{$email.emailRegistrationToken}{\n}
+
+  {\n}
+
+  If you have received this mail in error, you do not need to take any{\n}
+  action to cancel the account.  The address will not be activated, and{\n}
+  you will not receive any further emails.{\n}
+
+  {\n}
+
+  If clicking the link above does not work, copy and paste the URL in a{\n}
+  new browser window instead.{\n}
+
+  {\n}
+
+  This is a send-only email address.  Replies to this message will not{\n}
+  be read or answered.{\n}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
new file mode 100644
index 0000000..1cb0110
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
@@ -0,0 +1,63 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .ReplacePatchSet template will determine the contents of the email
+ * related to a user submitting a new patchset for a change.
+ * @param change
+ * @param email
+ * @param fromEmail
+ * @param fromName
+ * @param patchSet
+ * @param projectName
+ */
+{template .ReplacePatchSet kind="text"}
+  {if $email.reviewerNames and $fromEmail == $change.ownerEmail}
+    Hello{sp}
+    {for $reviewerName in $email.reviewerNames}
+      {$reviewerName},{sp}
+    {/for}{\n}
+    {\n}
+    I'd like you to reexamine a change.
+    {if $email.changeUrl}
+      {sp}Please visit
+      {\n}
+      {\n}
+      {sp}{sp}{sp}{sp}{$email.changeUrl}
+      {\n}
+      {\n}
+      to look at the new patch set (#{$patchSet.patchSetId}).
+    {/if}
+  {else}
+    {$fromName} has uploaded a new patch set (#{$patchSet.patchSetId})
+    {if $fromEmail != $change.ownerEmail}
+      {sp}to the change originally created by {$change.ownerName}
+    {/if}.
+    {if $email.changeUrl} ( {$email.changeUrl} ){/if}
+  {/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {\n}
+  {$email.changeDetail}{\n}
+  {if $email.sshHost}
+    {sp}{sp}git pull ssh:{print '//'}{$email.sshHost}/{$projectName}{sp}
+        {$patchSet.refName}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
new file mode 100644
index 0000000..e618bef
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -0,0 +1,52 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param change
+ * @param email
+ * @param fromName
+ * @param fromEmail
+ * @param patchSet
+ * @param projectName
+ */
+{template .ReplacePatchSetHtml}
+  <p>
+    {$fromName} <strong>uploaded patch set #{$patchSet.patchSetId}</strong>{sp}
+    to{sp}
+    {if $fromEmail == $change.ownerEmail}
+      this change.
+    {else}
+      the change originally created by {$change.ownerName}.
+    {/if}
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {call .Pre}{param content: $email.changeDetail /}{/call}
+
+  {if $email.sshHost}
+    {call .Pre}{param content kind="html"}
+      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}{sp}
+          {$patchSet.refName}
+    {/param}{/call}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/Restored.soy b/resources/com/google/gerrit/server/mail/Restored.soy
new file mode 100644
index 0000000..4fc6d8c
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/Restored.soy
@@ -0,0 +1,39 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Restored template will determine the contents of the email related to a
+ * change being restored.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .Restored kind="text"}
+  {$fromName} has restored this change.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/RestoredHtml.soy b/resources/com/google/gerrit/server/mail/RestoredHtml.soy
new file mode 100644
index 0000000..bb856ac
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/RestoredHtml.soy
@@ -0,0 +1,33 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param email
+ * @param fromName
+ */
+{template .RestoredHtml}
+  <p>
+    {$fromName} <strong>restored</strong> this change.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/Reverted.soy b/resources/com/google/gerrit/server/mail/Reverted.soy
new file mode 100644
index 0000000..09e32ff
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/Reverted.soy
@@ -0,0 +1,39 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Reverted template will determine the contents of the email related
+ * to a change being reverted.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .Reverted kind="text"}
+  {$fromName} has reverted this change.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/RevertedHtml.soy b/resources/com/google/gerrit/server/mail/RevertedHtml.soy
new file mode 100644
index 0000000..63ad6f0
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/RevertedHtml.soy
@@ -0,0 +1,33 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param email
+ * @param fromName
+ */
+{template .RevertedHtml}
+  <p>
+    {$fromName} <strong>reverted</strong> this change.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/SetAssignee.soy b/resources/com/google/gerrit/server/mail/SetAssignee.soy
new file mode 100644
index 0000000..98290e9
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/SetAssignee.soy
@@ -0,0 +1,71 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .SetAssignee template will determine the contents of the email related
+ * to a user being assigned to a change.
+ * @param change
+ * @param email
+ * @param fromName
+ * @param patchSet
+ * @param projectName
+ */
+{template .SetAssignee kind="text"}
+  Hello{sp}
+  {$email.assigneeName},
+
+  {\n}
+  {\n}
+
+  {$fromName} has assigned a change to you.
+
+  {sp}Please visit
+
+  {\n}
+  {\n}
+
+  {sp}{sp}{sp}{sp}{$email.changeUrl}
+
+  {\n}
+  {\n}
+
+  to view the change.
+
+  {\n}
+  {\n}
+
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+
+  {\n}
+
+  {$email.changeDetail}{\n}
+
+  {if $email.sshHost}
+    {\n}
+    {sp}{sp}git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
+        {sp}{$patchSet.refName}
+    {\n}
+  {/if}
+
+  {if $email.includeDiff}
+    {\n}
+    {$email.unifiedDiff}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy b/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
new file mode 100644
index 0000000..dbd3fae
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
@@ -0,0 +1,50 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param diffLines
+ * @param email
+ * @param fromName
+ * @param patchSet
+ * @param projectName
+ */
+{template .SetAssigneeHtml}
+  <p>
+    {$fromName} has <strong>assigned</strong> a change to{sp}
+    {$email.assigneeName}.{sp}
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {call .Pre}{param content: $email.changeDetail /}{/call}
+
+  {if $email.sshHost}
+    {call .Pre}{param content kind="html"}
+      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
+          {sp}{$patchSet.refName}
+    {/param}{/call}
+  {/if}
+
+  {if $email.includeDiff}
+    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
similarity index 100%
rename from gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
rename to resources/com/google/gerrit/server/mime/mime-types.properties
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/TOC b/resources/com/google/gerrit/server/tools/root/TOC
similarity index 100%
rename from gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/TOC
rename to resources/com/google/gerrit/server/tools/root/TOC
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/bin/gerrit-cherry-pick b/resources/com/google/gerrit/server/tools/root/bin/gerrit-cherry-pick
similarity index 100%
rename from gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/bin/gerrit-cherry-pick
rename to resources/com/google/gerrit/server/tools/root/bin/gerrit-cherry-pick
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
similarity index 100%
rename from gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
rename to resources/com/google/gerrit/server/tools/root/hooks/commit-msg
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh b/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh
similarity index 100%
rename from gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh
rename to resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/reposize.sh b/resources/com/google/gerrit/server/tools/root/scripts/reposize.sh
similarity index 100%
rename from gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/reposize.sh
rename to resources/com/google/gerrit/server/tools/root/scripts/reposize.sh
diff --git a/resources/log4j.properties b/resources/log4j.properties
new file mode 100644
index 0000000..28c0ee4
--- /dev/null
+++ b/resources/log4j.properties
@@ -0,0 +1,50 @@
+# Copyright (C) 2008 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+log4j.rootCategory=INFO, stderr
+log4j.appender.stderr=org.apache.log4j.ConsoleAppender
+log4j.appender.stderr.target=System.err
+log4j.appender.stderr.layout=org.apache.log4j.PatternLayout
+log4j.appender.stderr.layout.ConversionPattern=[%d] [%t] %-5p %c %x: %m%n
+
+# Silence non-critical messages from MINA SSHD.
+#
+log4j.logger.org.apache.mina=WARN
+log4j.logger.org.apache.sshd.common=WARN
+log4j.logger.org.apache.sshd.server=WARN
+log4j.logger.org.apache.sshd.common.keyprovider.FileKeyPairProvider=INFO
+log4j.logger.com.google.gerrit.sshd.GerritServerSession=WARN
+
+# Silence non-critical messages from mime-util.
+#
+log4j.logger.eu.medsea.mimeutil=WARN
+
+# Silence non-critical messages from openid4java
+#
+log4j.logger.org.apache.http=WARN
+log4j.logger.org.apache.xml=WARN
+log4j.logger.org.openid4java=WARN
+log4j.logger.org.openid4java.consumer.ConsumerManager=FATAL
+log4j.logger.org.openid4java.discovery.Discovery=ERROR
+log4j.logger.org.openid4java.server.RealmVerifier=ERROR
+log4j.logger.org.openid4java.message.AuthSuccess=ERROR
+
+# Silence non-critical messages from c3p0 (if used).
+#
+log4j.logger.com.mchange.v2.c3p0=WARN
+log4j.logger.com.mchange.v2.resourcepool=WARN
+log4j.logger.com.mchange.v2.sql=WARN
+
+# Silence non-critical messages from apache.http
+log4j.logger.org.apache.http=WARN
diff --git a/tools/bzl/asciidoc.bzl b/tools/bzl/asciidoc.bzl
index faf172d..62fa4c6 100644
--- a/tools/bzl/asciidoc.bzl
+++ b/tools/bzl/asciidoc.bzl
@@ -17,21 +17,6 @@
     "revnumber=%s",
   ]
 
-def release_notes_attributes():
-  return [
-    'toc',
-    'newline="\\n"',
-    'asterisk="&#42;"',
-    'plus="&#43;"',
-    'caret="&#94;"',
-    'startsb="&#91;"',
-    'endsb="&#93;"',
-    'tilde="&#126;"',
-    'last-update-label!',
-    'stylesheet=DEFAULT',
-    'linkcss=true',
-  ]
-
 def _replace_macros_impl(ctx):
   cmd = [
     ctx.file._exe.path,
diff --git a/tools/bzl/gwt.bzl b/tools/bzl/gwt.bzl
index 9cec4f4..a733b0c 100644
--- a/tools/bzl/gwt.bzl
+++ b/tools/bzl/gwt.bzl
@@ -79,7 +79,7 @@
 ]
 
 DEPS = GWT_TRANSITIVE_DEPS + [
-    "//gerrit-gwtexpui:CSS",
+    "//java/com/google/gwtexpui/css",
     "//lib:gwtjsonrpc",
     "//lib/gwt:dev",
     "//lib/jgit/org.eclipse.jgit:jgit-source",
@@ -283,7 +283,7 @@
     deps = [
       '//gerrit-gwtui-common:diffy_logo',
       '//gerrit-gwtui-common:client',
-      '//gerrit-gwtexpui:CSS',
+      '//java/com/google/gwtexpui/css',
       '//lib/codemirror:codemirror' + suffix,
       '//lib/gwt:user',
     ],
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index ed84f7c..945b8e1 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -80,7 +80,11 @@
     "cd bower_components",
     "unzip %s" % ctx.path(download_name),
     "cd ..",
-    "zip -r %s bower_components" % renamed_name,]))
+    "find . -exec touch -t 198001010000 '{}' ';'",
+    "zip -r %s bower_components" % renamed_name,
+    "cd ..",
+    "rm -rf ${TMP}",
+  ]))
 
   dep_version = ctx.attr.semver if ctx.attr.semver else ctx.attr.version
   ctx.file(version_name,
@@ -257,6 +261,7 @@
         "version_json": "%{name}-versions.json",
     },
 )
+
 """Groups a set of bower components together in a zip file.
 
 Outputs:
@@ -268,9 +273,12 @@
 """
 
 def _vulcanize_impl(ctx):
-  # intermediate artifact.
-  vulcanized = ctx.new_file(
-    ctx.configuration.genfiles_dir, ctx.outputs.html, ".vulcanized.html")
+  # intermediate artifact if split is wanted.
+  if ctx.attr.split:
+    vulcanized = ctx.new_file(
+      ctx.configuration.genfiles_dir, ctx.outputs.html, ".vulcanized.html")
+  else:
+    vulcanized = ctx.outputs.html
   destdir = ctx.outputs.html.path + ".dir"
   zips =  [z for d in ctx.attr.deps for z in d.transitive_zipfiles ]
 
@@ -315,22 +323,30 @@
     command = cmd,
     **node_tweaks)
 
-  hermetic_npm_command = "export PATH && " + " ".join([
-    'python',
-    ctx.file._run_npm.path,
-    ctx.file._crisper_archive.path,
-    "--always-write-script",
-    "--source", vulcanized.path,
-    "--html", ctx.outputs.html.path,
-    "--js", ctx.outputs.js.path])
+  if ctx.attr.split:
+    hermetic_npm_command = "export PATH && " + " ".join([
+      'python',
+      ctx.file._run_npm.path,
+      ctx.file._crisper_archive.path,
+      "--always-write-script",
+      "--source", vulcanized.path,
+      "--html", ctx.outputs.html.path,
+      "--js", ctx.outputs.js.path])
 
-  ctx.actions.run_shell(
-    mnemonic = "Crisper",
-    inputs = [ctx.file._run_npm, ctx.file.app,
-              ctx.file._crisper_archive, vulcanized],
-    outputs = [ctx.outputs.js, ctx.outputs.html],
-    command = hermetic_npm_command,
-    **node_tweaks)
+    ctx.actions.run_shell(
+      mnemonic = "Crisper",
+      inputs = [ctx.file._run_npm, ctx.file.app,
+                ctx.file._crisper_archive, vulcanized],
+      outputs = [ctx.outputs.js, ctx.outputs.html],
+      command = hermetic_npm_command,
+      **node_tweaks)
+
+def _vulcanize_output_func(name, split):
+  _ignore = [name]  # unused.
+  out = {"html": "%{name}.html"}
+  if split:
+    out["js"] = "%{name}.js"
+  return out
 
 _vulcanize_rule = rule(
     _vulcanize_impl,
@@ -348,6 +364,7 @@
             ".ico",
         ]),
         "pkg": attr.string(mandatory = True),
+        "split": attr.bool(default = True),
         "_run_npm": attr.label(
             default = Label("//tools/js:run_npm_binary.py"),
             allow_single_file = True,
@@ -361,12 +378,13 @@
             allow_single_file = True,
         ),
     },
-    outputs = {
-        "html": "%{name}.html",
-        "js": "%{name}.js",
-    },
+    outputs = _vulcanize_output_func,
 )
 
 def vulcanize(*args, **kwargs):
-  """Vulcanize runs vulcanize and crisper on a set of sources."""
+  """Vulcanize runs vulcanize and (optionally) crisper on a set of sources."""
+  _vulcanize_rule(*args, pkg=PACKAGE_NAME, **kwargs)
+
+def polygerrit_plugin(*args, **kwargs):
+  """Bundles plugin dependencies for deployment."""
   _vulcanize_rule(*args, pkg=PACKAGE_NAME, **kwargs)
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index e722584..a8ccbee 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -17,18 +17,18 @@
 jar_filetype = FileType([".jar"])
 
 LIBS = [
-    "//gerrit-war:init",
-    "//gerrit-war:log4j-config",
-    "//gerrit-war:version",
+    "//java/com/google/gerrit/common:version",
+    "//java/com/google/gerrit/httpd/init",
     "//lib:postgresql",
     "//lib/bouncycastle:bcpkix",
     "//lib/bouncycastle:bcprov",
     "//lib/bouncycastle:bcpg",
     "//lib/log:impl_log4j",
+    "//resources:log4j-config",
 ]
 
 PGMLIBS = [
-    "//gerrit-pgm:pgm",
+    "//java/com/google/gerrit/pgm",
 ]
 
 def _add_context(in_file, output):
@@ -45,7 +45,8 @@
 
   if short_path.startswith('gerrit-'):
     n = short_path.split('/')[0] + '-' + n
-
+  elif short_path.startswith('java/'):
+    n = short_path[5:].replace('/', '_')
   output_path += n
   return [
     'test -L %s || ln -s $(pwd)/%s %s' % (output_path, input_path, output_path)
@@ -147,8 +148,8 @@
     libs = LIBS + doc_lib,
     pgmlibs = PGMLIBS,
     context = doc_ctx + context + ui_deps + [
-      '//gerrit-main:main_bin_deploy.jar',
-      '//gerrit-war:webapp_assets',
+      '//java:gerrit-main-class_deploy.jar',
+      '//webapp:assets',
     ],
     **kwargs
   )
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 33f2e6a..8ef8b7b 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -9,11 +9,12 @@
     "gwt_binary",
 )
 
-PLUGIN_DEPS = ["//gerrit-plugin-api:lib"]
-PLUGIN_DEPS_NEVERLINK = ["//gerrit-plugin-api:lib-neverlink"]
+PLUGIN_DEPS = ["//plugins:plugin-lib"]
+
+PLUGIN_DEPS_NEVERLINK = ["//plugins:plugin-lib-neverlink"]
 
 PLUGIN_TEST_DEPS = [
-    "//gerrit-acceptance-framework:lib",
+    "//java/com/google/gerrit/acceptance:lib",
     "//lib/bouncycastle:bcpg",
     "//lib/bouncycastle:bcpkix",
     "//lib/bouncycastle:bcprov",
diff --git a/tools/bzl/plugins.bzl b/tools/bzl/plugins.bzl
index 149dbb5..7fd7625 100644
--- a/tools/bzl/plugins.bzl
+++ b/tools/bzl/plugins.bzl
@@ -1,4 +1,5 @@
 CORE_PLUGINS = [
+    "codemirror-editor",
     "commit-message-length-validator",
     "download-commands",
     "hooks",
diff --git a/tools/coverage.sh b/tools/coverage.sh
index 8fa979f..22b40d8 100755
--- a/tools/coverage.sh
+++ b/tools/coverage.sh
@@ -22,7 +22,7 @@
 
 # coverage is expensive to run; use --jobs=2 to avoid overloading the
 # machine.
-bazel coverage -k --jobs=${COVERAGE_CPUS:-2} -- ... -//gerrit-common:auto_value_tests
+bazel coverage -k --jobs=${COVERAGE_CPUS:-2} -- ... -//javatests/com/google/gerrit/common:auto_value_tests
 
 # The coverage data contains filenames relative to the Java root, and
 # genhtml has no logic to search these elsewhere. Workaround this
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index 303c6f3..67763e2 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -8,22 +8,16 @@
 )
 
 TEST_DEPS = [
-    "//gerrit-gpg:gpg_tests",
     "//gerrit-gwtui:ui_tests",
-    "//gerrit-httpd:httpd_tests",
-    "//gerrit-index:index_tests",
-    "//gerrit-patch-jgit:jgit_patch_tests",
-    "//gerrit-reviewdb:client_tests",
-    "//gerrit-server:server_tests",
+    "//javatests/com/google/gerrit/server:server_tests",
 ]
 
 DEPS = [
-    "//gerrit-acceptance-tests:lib",
     "//gerrit-gwtdebug:gwtdebug",
     "//gerrit-gwtui:ui_module",
-    "//gerrit-main:main_lib",
     "//gerrit-plugin-gwtui:gwtui-api-lib",
-    "//gerrit-server:server",
+    "//java/com/google/gerrit/acceptance:lib",
+    "//java/com/google/gerrit/server",
     "//lib/asciidoctor:asciidoc_lib",
     "//lib/asciidoctor:doc_indexer_lib",
     "//lib/auto:auto-value",
diff --git a/tools/eclipse/gerrit_daemon.launch b/tools/eclipse/gerrit_daemon.launch
index 9495884..d00f7bf 100644
--- a/tools/eclipse/gerrit_daemon.launch
+++ b/tools/eclipse/gerrit_daemon.launch
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
 <launchConfiguration type="org.eclipse.jdt.launching.localJavaApplication">
 <listAttribute key="org.eclipse.debug.core.MAPPED_RESOURCE_PATHS">
-<listEntry value="/gerrit/gerrit-main/src/main/java/Main.java"/>
+<listEntry value="/gerrit/java/Main.java"/>
 </listAttribute>
 <listAttribute key="org.eclipse.debug.core.MAPPED_RESOURCE_TYPES">
 <listEntry value="1"/>
diff --git a/tools/eclipse/gerrit_gwt_debug.launch b/tools/eclipse/gerrit_gwt_debug.launch
index 9f2bf2b..593837a 100644
--- a/tools/eclipse/gerrit_gwt_debug.launch
+++ b/tools/eclipse/gerrit_gwt_debug.launch
@@ -16,7 +16,7 @@
 </listAttribute>
 <booleanAttribute key="org.eclipse.jdt.launching.DEFAULT_CLASSPATH" value="false"/>
 <stringAttribute key="org.eclipse.jdt.launching.MAIN_TYPE" value="com.google.gerrit.gwtdebug.GerritGwtDebugLauncher"/>
-<stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="-strict -noprecompile -src ${resource_loc:/gerrit} -workDir ${resource_loc:/gerrit}/.gwt_work_dir com.google.gerrit.GerritGwtUI -src ${resource_loc:/gerrit}/gerrit-plugin-gwtui/src/main/java -- --console-log --show-stack-trace -d ${resource_loc:/gerrit}/../gerrit_testsite"/>
+<stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="-strict -noprecompile -src ${resource_loc:/gerrit}/java -workDir ${resource_loc:/gerrit}/.gwt_work_dir com.google.gerrit.GerritGwtUI -src ${resource_loc:/gerrit}/gerrit-plugin-gwtui/src/main/java -- --console-log --show-stack-trace -d ${resource_loc:/gerrit}/../gerrit_testsite"/>
 <stringAttribute key="org.eclipse.jdt.launching.PROJECT_ATTR" value="gerrit"/>
 <stringAttribute key="org.eclipse.jdt.launching.VM_ARGUMENTS" value="-Xmx1024M&#10;-XX:MaxPermSize=256M&#10;-Dgerrit.disable-gwtui-recompile=true"/>
 </launchConfiguration>
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index d8072e2..448d940 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -51,10 +51,22 @@
                 action='store_true')
 opts.add_option('--name', help='name of the generated project',
                 action='store', default='gerrit', dest='project_name')
+opts.add_option('-b', '--batch', action='store_true',
+                dest='batch', help='Bazel batch option')
 args, _ = opts.parse_args()
 
+batch_option = '--batch' if args.batch else None
+
+def _build_bazel_cmd(*args):
+  cmd = ['bazel']
+  if batch_option:
+    cmd.append('--batch')
+  for arg in args:
+    cmd.append(arg)
+  return cmd
+
 def retrieve_ext_location():
-  return check_output(['bazel', 'info', 'output_base']).strip()
+  return check_output(_build_bazel_cmd('info', 'output_base')).strip()
 
 def gen_bazel_path():
   bazel = check_output(['which', 'bazel']).strip()
@@ -66,7 +78,7 @@
   deps = []
   t = cp_targets[target]
   try:
-    check_call(['bazel', 'build', t])
+    check_call(_build_bazel_cmd('build', t))
   except CalledProcessError:
     exit(1)
   name = 'bazel-bin/tools/eclipse/' + t.split(':')[1] + '.runtime_classpath'
@@ -154,7 +166,7 @@
       src.add(m.group(1))
       # Exceptions: both source and lib
       if p.endswith('libquery_parser.jar') or \
-         p.endswith('libprolog-common.jar'):
+         p.endswith('libgerrit-prolog-common.jar'):
         lib.add(p)
       # JGit dependency from external repository
       if 'gerrit-' not in p and 'jgit' in p:
@@ -173,6 +185,9 @@
     if m:
       gwt_src.add(m.group(1))
 
+  classpathentry('src', 'java')
+  classpathentry('src', 'javatests', out='eclipse-out/test')
+  classpathentry('src', 'resources')
   for s in sorted(src):
     out = None
 
@@ -274,7 +289,7 @@
     makedirs(path.join(ROOT, gwt_working_dir))
 
   try:
-    check_call(['bazel', 'build', MAIN, GWT, '//gerrit-patch-jgit:libEdit-src.jar'])
+    check_call(_build_bazel_cmd('build', MAIN, GWT, '//java/org/eclipse/jgit:libEdit-src.jar'))
   except CalledProcessError:
     exit(1)
 except KeyboardInterrupt:
diff --git a/tools/js/bower2bazel.py b/tools/js/bower2bazel.py
index b55a643..7479021 100755
--- a/tools/js/bower2bazel.py
+++ b/tools/js/bower2bazel.py
@@ -33,6 +33,7 @@
 
 # list of licenses for packages that don't specify one in their bower.json file.
 package_licenses = {
+  "codemirror-minified": "codemirror-minified",
   "es6-promise": "es6-promise",
   "fetch": "fetch",
   "font-roboto": "polymer",
@@ -60,6 +61,7 @@
   "paper-input": "polymer",
   "paper-item": "polymer",
   "paper-listbox": "polymer",
+  "paper-toggle-button": "polymer",
   "paper-styles": "polymer",
   "polymer": "polymer",
   "polymer-resin": "polymer",
diff --git a/tools/js/npm_pack.py b/tools/js/npm_pack.py
index 9eb6e34..9e8482e 100755
--- a/tools/js/npm_pack.py
+++ b/tools/js/npm_pack.py
@@ -13,6 +13,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+"""This downloads an NPM binary, and bundles it with its dependencies.
+
+This is used to assemble a pinned version of crisper, hosted on the
+Google storage bucket ("repository=GERRIT" in WORKSPACE).
+"""
+
 from __future__ import print_function
 
 import atexit
diff --git a/tools/maven/BUILD b/tools/maven/BUILD
index d46a954..10ed27d 100644
--- a/tools/maven/BUILD
+++ b/tools/maven/BUILD
@@ -7,21 +7,21 @@
 
 maven_package(
     src = {
-        "gerrit-acceptance-framework": "//gerrit-acceptance-framework:liblib-src.jar",
-        "gerrit-extension-api": "//gerrit-extension-api:libapi-src.jar",
-        "gerrit-plugin-api": "//gerrit-plugin-api:plugin-api-sources_deploy.jar",
+        "gerrit-acceptance-framework": "//java/com/google/gerrit/acceptance:libframework-lib-src.jar",
+        "gerrit-extension-api": "//java/com/google/gerrit/extensions:libapi-src.jar",
+        "gerrit-plugin-api": "//plugins:plugin-api-sources_deploy.jar",
         "gerrit-plugin-gwtui": "//gerrit-plugin-gwtui:gwtui-api-source_deploy.jar",
     },
     doc = {
-        "gerrit-acceptance-framework": "//gerrit-acceptance-framework:acceptance-framework-javadoc",
-        "gerrit-extension-api": "//gerrit-extension-api:extension-api-javadoc",
-        "gerrit-plugin-api": "//gerrit-plugin-api:plugin-api-javadoc",
+        "gerrit-acceptance-framework": "//java/com/google/gerrit/acceptance:framework-javadoc",
+        "gerrit-extension-api": "//java/com/google/gerrit/extensions:extension-api-javadoc",
+        "gerrit-plugin-api": "//plugins:plugin-api-javadoc",
         "gerrit-plugin-gwtui": "//gerrit-plugin-gwtui:gwtui-api-javadoc",
     },
     jar = {
-        "gerrit-acceptance-framework": "//gerrit-acceptance-framework:acceptance-framework_deploy.jar",
-        "gerrit-extension-api": "//gerrit-extension-api:extension-api_deploy.jar",
-        "gerrit-plugin-api": "//gerrit-plugin-api:plugin-api_deploy.jar",
+        "gerrit-acceptance-framework": "//java/com/google/gerrit/acceptance:framework_deploy.jar",
+        "gerrit-extension-api": "//java/com/google/gerrit/extensions:extension-api_deploy.jar",
+        "gerrit-plugin-api": "//plugins:plugin-api_deploy.jar",
         "gerrit-plugin-gwtui": "//gerrit-plugin-gwtui:gwtui-api_deploy.jar",
     },
     repository = MAVEN_REPOSITORY,
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
new file mode 100644
index 0000000..a0f2e67
--- /dev/null
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -0,0 +1,86 @@
+<project>
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>com.google.gerrit</groupId>
+  <artifactId>gerrit-acceptance-framework</artifactId>
+  <version>2.16-SNAPSHOT</version>
+  <packaging>jar</packaging>
+  <name>Gerrit Code Review - Acceptance Test Framework</name>
+  <description>Framework for Gerrit's acceptance tests</description>
+  <url>https://www.gerritcodereview.com/</url>
+
+  <licenses>
+    <license>
+      <name>The Apache Software License, Version 2.0</name>
+      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
+      <distribution>repo</distribution>
+    </license>
+  </licenses>
+
+  <scm>
+    <url>https://gerrit.googlesource.com/gerrit</url>
+    <connection>https://gerrit.googlesource.com/gerrit</connection>
+  </scm>
+
+  <developers>
+    <developer>
+      <name>Alice Kober-Sotzek</name>
+    </developer>
+    <developer>
+      <name>Andrew Bonventre</name>
+    </developer>
+    <developer>
+      <name>Becky Siegel</name>
+    </developer>
+    <developer>
+      <name>Dave Borowitz</name>
+    </developer>
+    <developer>
+      <name>David Ostrovsky</name>
+    </developer>
+    <developer>
+      <name>David Pursehouse</name>
+    </developer>
+    <developer>
+      <name>Edwin Kempin</name>
+    </developer>
+    <developer>
+      <name>Hugo Arès</name>
+    </developer>
+    <developer>
+      <name>Kasper Nilsson</name>
+    </developer>
+    <developer>
+      <name>Logan Hanks</name>
+    </developer>
+    <developer>
+      <name>Martin Fick</name>
+    </developer>
+    <developer>
+      <name>Saša Živkov</name>
+    </developer>
+    <developer>
+      <name>Shawn Pearce</name>
+    </developer>
+    <developer>
+      <name>Viktar Donich</name>
+    </developer>
+    <developer>
+      <name>Wyatt Allen</name>
+    </developer>
+  </developers>
+
+  <mailingLists>
+    <mailingList>
+      <name>Repo and Gerrit Discussion</name>
+      <post>repo-discuss@googlegroups.com</post>
+      <subscribe>https://groups.google.com/forum/#!forum/repo-discuss</subscribe>
+      <unsubscribe>https://groups.google.com/forum/#!forum/repo-discuss</unsubscribe>
+      <archive>https://groups.google.com/forum/#!forum/repo-discuss</archive>
+    </mailingList>
+  </mailingLists>
+
+  <issueManagement>
+    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
+    <system>Gerrit Issue Tracker</system>
+  </issueManagement>
+</project>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
new file mode 100644
index 0000000..a8ae2e6
--- /dev/null
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -0,0 +1,92 @@
+<project>
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>com.google.gerrit</groupId>
+  <artifactId>gerrit-extension-api</artifactId>
+  <version>2.16-SNAPSHOT</version>
+  <packaging>jar</packaging>
+  <name>Gerrit Code Review - Extension API</name>
+  <description>API for Gerrit Extensions</description>
+  <url>https://www.gerritcodereview.com/</url>
+
+  <licenses>
+    <license>
+      <name>The Apache Software License, Version 2.0</name>
+      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
+      <distribution>repo</distribution>
+    </license>
+  </licenses>
+
+  <scm>
+    <url>https://gerrit.googlesource.com/gerrit</url>
+    <connection>https://gerrit.googlesource.com/gerrit</connection>
+  </scm>
+
+  <developers>
+    <developer>
+      <name>Alice Kober-Sotzek</name>
+    </developer>
+    <developer>
+      <name>Andrew Bonventre</name>
+    </developer>
+    <developer>
+      <name>Becky Siegel</name>
+    </developer>
+    <developer>
+      <name>Dave Borowitz</name>
+    </developer>
+    <developer>
+      <name>David Ostrovsky</name>
+    </developer>
+    <developer>
+      <name>David Pursehouse</name>
+    </developer>
+    <developer>
+      <name>Edwin Kempin</name>
+    </developer>
+    <developer>
+      <name>Hugo Arès</name>
+    </developer>
+    <developer>
+      <name>Kasper Nilsson</name>
+    </developer>
+    <developer>
+      <name>Logan Hanks</name>
+    </developer>
+    <developer>
+      <name>Luca Milanesio</name>
+    </developer>
+    <developer>
+      <name>Martin Fick</name>
+    </developer>
+    <developer>
+      <name>Patrick Hiesel</name>
+    </developer>
+    <developer>
+      <name>Saša Živkov</name>
+    </developer>
+    <developer>
+      <name>Shawn Pearce</name>
+    </developer>
+    <developer>
+      <name>Viktar Donich</name>
+    </developer>
+    <developer>
+      <name>Wyatt Allen</name>
+    </developer>
+  </developers>
+
+  <mailingLists>
+    <mailingList>
+      <name>Repo and Gerrit Discussion</name>
+      <post>repo-discuss@googlegroups.com</post>
+      <subscribe>https://groups.google.com/forum/#!forum/repo-discuss</subscribe>
+      <unsubscribe>https://groups.google.com/forum/#!forum/repo-discuss</unsubscribe>
+      <archive>https://groups.google.com/forum/#!forum/repo-discuss</archive>
+    </mailingList>
+  </mailingLists>
+
+  <issueManagement>
+    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
+    <system>Gerrit Issue Tracker</system>
+  </issueManagement>
+</project>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
new file mode 100644
index 0000000..84df44a
--- /dev/null
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -0,0 +1,86 @@
+<project>
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>com.google.gerrit</groupId>
+  <artifactId>gerrit-plugin-api</artifactId>
+  <version>2.16-SNAPSHOT</version>
+  <packaging>jar</packaging>
+  <name>Gerrit Code Review - Plugin API</name>
+  <description>API for Gerrit Plugins</description>
+  <url>https://www.gerritcodereview.com/</url>
+
+  <licenses>
+    <license>
+      <name>The Apache Software License, Version 2.0</name>
+      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
+      <distribution>repo</distribution>
+    </license>
+  </licenses>
+
+  <scm>
+    <url>https://gerrit.googlesource.com/gerrit</url>
+    <connection>https://gerrit.googlesource.com/gerrit</connection>
+  </scm>
+
+  <developers>
+    <developer>
+      <name>Alice Kober-Sotzek</name>
+    </developer>
+    <developer>
+      <name>Andrew Bonventre</name>
+    </developer>
+    <developer>
+      <name>Becky Siegel</name>
+    </developer>
+    <developer>
+      <name>Dave Borowitz</name>
+    </developer>
+    <developer>
+      <name>David Ostrovsky</name>
+    </developer>
+    <developer>
+      <name>David Pursehouse</name>
+    </developer>
+    <developer>
+      <name>Edwin Kempin</name>
+    </developer>
+    <developer>
+      <name>Hugo Arès</name>
+    </developer>
+    <developer>
+      <name>Kasper Nilsson</name>
+    </developer>
+    <developer>
+      <name>Logan Hanks</name>
+    </developer>
+    <developer>
+      <name>Martin Fick</name>
+    </developer>
+    <developer>
+      <name>Saša Živkov</name>
+    </developer>
+    <developer>
+      <name>Shawn Pearce</name>
+    </developer>
+    <developer>
+      <name>Viktar Donich</name>
+    </developer>
+    <developer>
+      <name>Wyatt Allen</name>
+    </developer>
+  </developers>
+
+  <mailingLists>
+    <mailingList>
+      <name>Repo and Gerrit Discussion</name>
+      <post>repo-discuss@googlegroups.com</post>
+      <subscribe>https://groups.google.com/forum/#!forum/repo-discuss</subscribe>
+      <unsubscribe>https://groups.google.com/forum/#!forum/repo-discuss</unsubscribe>
+      <archive>https://groups.google.com/forum/#!forum/repo-discuss</archive>
+    </mailingList>
+  </mailingLists>
+
+  <issueManagement>
+    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
+    <system>Gerrit Issue Tracker</system>
+  </issueManagement>
+</project>
diff --git a/tools/maven/gerrit-plugin-gwtui_pom.xml b/tools/maven/gerrit-plugin-gwtui_pom.xml
new file mode 100644
index 0000000..cc9aafc
--- /dev/null
+++ b/tools/maven/gerrit-plugin-gwtui_pom.xml
@@ -0,0 +1,86 @@
+<project>
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>com.google.gerrit</groupId>
+  <artifactId>gerrit-plugin-gwtui</artifactId>
+  <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>
+  <url>https://www.gerritcodereview.com/</url>
+
+  <licenses>
+    <license>
+      <name>The Apache Software License, Version 2.0</name>
+      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
+      <distribution>repo</distribution>
+    </license>
+  </licenses>
+
+  <scm>
+    <url>https://gerrit.googlesource.com/gerrit</url>
+    <connection>https://gerrit.googlesource.com/gerrit</connection>
+  </scm>
+
+  <developers>
+    <developer>
+      <name>Alice Kober-Sotzek</name>
+    </developer>
+    <developer>
+      <name>Andrew Bonventre</name>
+    </developer>
+    <developer>
+      <name>Becky Siegel</name>
+    </developer>
+    <developer>
+      <name>Dave Borowitz</name>
+    </developer>
+    <developer>
+      <name>David Ostrovsky</name>
+    </developer>
+    <developer>
+      <name>David Pursehouse</name>
+    </developer>
+    <developer>
+      <name>Edwin Kempin</name>
+    </developer>
+    <developer>
+      <name>Hugo Arès</name>
+    </developer>
+    <developer>
+      <name>Kasper Nilsson</name>
+    </developer>
+    <developer>
+      <name>Logan Hanks</name>
+    </developer>
+    <developer>
+      <name>Martin Fick</name>
+    </developer>
+    <developer>
+      <name>Saša Živkov</name>
+    </developer>
+    <developer>
+      <name>Shawn Pearce</name>
+    </developer>
+    <developer>
+      <name>Viktar Donich</name>
+    </developer>
+    <developer>
+      <name>Wyatt Allen</name>
+    </developer>
+  </developers>
+
+  <mailingLists>
+    <mailingList>
+      <name>Repo and Gerrit Discussion</name>
+      <post>repo-discuss@googlegroups.com</post>
+      <subscribe>https://groups.google.com/forum/#!forum/repo-discuss</subscribe>
+      <unsubscribe>https://groups.google.com/forum/#!forum/repo-discuss</unsubscribe>
+      <archive>https://groups.google.com/forum/#!forum/repo-discuss</archive>
+    </mailingList>
+  </mailingLists>
+
+  <issueManagement>
+    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
+    <system>Gerrit Issue Tracker</system>
+  </issueManagement>
+</project>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
new file mode 100644
index 0000000..c43c098
--- /dev/null
+++ b/tools/maven/gerrit-war_pom.xml
@@ -0,0 +1,86 @@
+<project>
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>com.google.gerrit</groupId>
+  <artifactId>gerrit-war</artifactId>
+  <version>2.16-SNAPSHOT</version>
+  <packaging>war</packaging>
+  <name>Gerrit Code Review - WAR</name>
+  <description>Gerrit WAR</description>
+  <url>https://www.gerritcodereview.com/</url>
+
+  <licenses>
+    <license>
+      <name>The Apache Software License, Version 2.0</name>
+      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
+      <distribution>repo</distribution>
+    </license>
+  </licenses>
+
+  <scm>
+    <url>https://gerrit.googlesource.com/gerrit</url>
+    <connection>https://gerrit.googlesource.com/gerrit</connection>
+  </scm>
+
+  <developers>
+    <developer>
+      <name>Alice Kober-Sotzek</name>
+    </developer>
+    <developer>
+      <name>Andrew Bonventre</name>
+    </developer>
+    <developer>
+      <name>Becky Siegel</name>
+    </developer>
+    <developer>
+      <name>Dave Borowitz</name>
+    </developer>
+    <developer>
+      <name>David Ostrovsky</name>
+    </developer>
+    <developer>
+      <name>David Pursehouse</name>
+    </developer>
+    <developer>
+      <name>Edwin Kempin</name>
+    </developer>
+    <developer>
+      <name>Hugo Arès</name>
+    </developer>
+    <developer>
+      <name>Kasper Nilsson</name>
+    </developer>
+    <developer>
+      <name>Logan Hanks</name>
+    </developer>
+    <developer>
+      <name>Martin Fick</name>
+    </developer>
+    <developer>
+      <name>Saša Živkov</name>
+    </developer>
+    <developer>
+      <name>Shawn Pearce</name>
+    </developer>
+    <developer>
+      <name>Viktar Donich</name>
+    </developer>
+    <developer>
+      <name>Wyatt Allen</name>
+    </developer>
+  </developers>
+
+  <mailingLists>
+    <mailingList>
+      <name>Repo and Gerrit Discussion</name>
+      <post>repo-discuss@googlegroups.com</post>
+      <subscribe>https://groups.google.com/forum/#!forum/repo-discuss</subscribe>
+      <unsubscribe>https://groups.google.com/forum/#!forum/repo-discuss</unsubscribe>
+      <archive>https://groups.google.com/forum/#!forum/repo-discuss</archive>
+    </mailingList>
+  </mailingLists>
+
+  <issueManagement>
+    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
+    <system>Gerrit Issue Tracker</system>
+  </issueManagement>
+</project>
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/tools/workspace-status.cmd b/tools/workspace-status.cmd
new file mode 100644
index 0000000..4a3b88e
--- /dev/null
+++ b/tools/workspace-status.cmd
@@ -0,0 +1,2 @@
+echo STABLE_BUILD_GERRIT_LABEL dev
+echo STABLE_WORKSPACE_ROOT %cd%
diff --git a/version.bzl b/version.bzl
index 804803a..340ba87 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,11 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "2.15-rc2"
+GERRIT_VERSION = "2.16-SNAPSHOT"
+
+def check_version(x):
+    if native.bazel_version == "":
+        # experimental / unreleased Bazel.
+        return
+    if native.bazel_version < x:
+        fail("\nERROR: Current Bazel version is {}, expected at least {}\n".format(native.bazel_version, x))
diff --git a/webapp/BUILD b/webapp/BUILD
new file mode 100644
index 0000000..f907be9
--- /dev/null
+++ b/webapp/BUILD
@@ -0,0 +1,12 @@
+load("//tools/bzl:genrule2.bzl", "genrule2")
+
+genrule2(
+    name = "assets",
+    srcs = glob(
+        ["**/*"],
+        exclude = ["BUILD"],
+    ),
+    outs = ["assets.zip"],
+    cmd = "cd webapp; zip -qr $$ROOT/$@ .",
+    visibility = ["//visibility:public"],
+)
diff --git a/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit-jetty.sh b/webapp/WEB-INF/extra/jetty7/gerrit-jetty.sh
similarity index 100%
rename from gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit-jetty.sh
rename to webapp/WEB-INF/extra/jetty7/gerrit-jetty.sh
diff --git a/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit.xml b/webapp/WEB-INF/extra/jetty7/gerrit.xml
similarity index 100%
rename from gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit.xml
rename to webapp/WEB-INF/extra/jetty7/gerrit.xml
diff --git a/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/jetty_sslproxy.xml b/webapp/WEB-INF/extra/jetty7/jetty_sslproxy.xml
similarity index 100%
rename from gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/jetty_sslproxy.xml
rename to webapp/WEB-INF/extra/jetty7/jetty_sslproxy.xml
diff --git a/gerrit-war/src/main/webapp/WEB-INF/web.xml b/webapp/WEB-INF/web.xml
similarity index 100%
rename from gerrit-war/src/main/webapp/WEB-INF/web.xml
rename to webapp/WEB-INF/web.xml
diff --git a/gerrit-war/src/main/webapp/favicon.ico b/webapp/favicon.ico
similarity index 100%
rename from gerrit-war/src/main/webapp/favicon.ico
rename to webapp/favicon.ico
Binary files differ
diff --git a/gerrit-war/src/main/webapp/robots.txt b/webapp/robots.txt
similarity index 100%
rename from gerrit-war/src/main/webapp/robots.txt
rename to webapp/robots.txt
diff --git a/website/releases/index.html b/website/releases/index.html
deleted file mode 100644
index 582b495..0000000
--- a/website/releases/index.html
+++ /dev/null
@@ -1,170 +0,0 @@
-<html>
-<head>
-  <title>Gerrit Code Review - Releases</title>
-  <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.0.2/jquery.min.js"></script>
-  <style>
-  #diffy_logo {
-    float: left;
-    width: 75px;
-    height: 70px;
-    margin-right: 20px;
-  }
-  #download_container table {
-    border-spacing: 0;
-  }
-  #download_container td {
-    padding-right: 5px;
-  }
-  .latest-release {
-    background-color: lightgreen;
-  }
-  .rc {
-    padding-left: 1em;
-    font-style: italic;
-  }
-  .size {
-    text-align: right;
-  }
-  </style>
-</head>
-<body>
-
-<h1>Gerrit Code Review - Releases</h1>
-<a href="https://www.gerritcodereview.com/">
-  <img id="diffy_logo" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAtCAYAAADoSujCAAAABGdBTUEAALGPC/xhBQAACkFpQ0NQSUNDIFByb2ZpbGUAAEgNnZZ3VFPZFofPvTe90BIiICX0GnoJINI7SBUEUYlJgFAChoQmdkQFRhQRKVZkVMABR4ciY0UUC4OCYtcJ8hBQxsFRREXl3YxrCe+tNfPemv3HWd/Z57fX2Wfvfde6AFD8ggTCdFgBgDShWBTu68FcEhPLxPcCGBABDlgBwOFmZgRH+EQC1Py9PZmZqEjGs/buLoBku9ssv1Amc9b/f5EiN0MkBgAKRdU2PH4mF+UClFOzxRky/wTK9JUpMoYxMhahCaKsIuPEr2z2p+Yru8mYlybkoRpZzhm8NJ6Mu1DemiXho4wEoVyYJeBno3wHZb1USZoA5fco09P4nEwAMBSZX8znJqFsiTJFFBnuifICAAiUxDm8cg6L+TlongB4pmfkigSJSWKmEdeYaeXoyGb68bNT+WIxK5TDTeGIeEzP9LQMjjAXgK9vlkUBJVltmWiR7a0c7e1Z1uZo+b/Z3x5+U/09yHr7VfEm7M+eQYyeWd9s7KwvvRYA9iRamx2zvpVVALRtBkDl4axP7yAA8gUAtN6c8x6GbF6SxOIMJwuL7OxscwGfay4r6Df7n4Jvyr+GOfeZy+77VjumFz+BI0kVM2VF5aanpktEzMwMDpfPZP33EP/jwDlpzcnDLJyfwBfxhehVUeiUCYSJaLuFPIFYkC5kCoR/1eF/GDYnBxl+nWsUaHVfAH2FOVC4SQfIbz0AQyMDJG4/egJ961sQMQrIvrxorZGvc48yev7n+h8LXIpu4UxBIlPm9gyPZHIloiwZo9+EbMECEpAHdKAKNIEuMAIsYA0cgDNwA94gAISASBADlgMuSAJpQASyQT7YAApBMdgBdoNqcADUgXrQBE6CNnAGXARXwA1wCwyAR0AKhsFLMAHegWkIgvAQFaJBqpAWpA+ZQtYQG1oIeUNBUDgUA8VDiZAQkkD50CaoGCqDqqFDUD30I3Qaughdg/qgB9AgNAb9AX2EEZgC02EN2AC2gNmwOxwIR8LL4ER4FZwHF8Db4Uq4Fj4Ot8IX4RvwACyFX8KTCEDICAPRRlgIG/FEQpBYJAERIWuRIqQCqUWakA6kG7mNSJFx5AMGh6FhmBgWxhnjh1mM4WJWYdZiSjDVmGOYVkwX5jZmEDOB+YKlYtWxplgnrD92CTYRm40txFZgj2BbsJexA9hh7DscDsfAGeIccH64GFwybjWuBLcP14y7gOvDDeEm8Xi8Kt4U74IPwXPwYnwhvgp/HH8e348fxr8nkAlaBGuCDyGWICRsJFQQGgjnCP2EEcI0UYGoT3QihhB5xFxiKbGO2EG8SRwmTpMUSYYkF1IkKZm0gVRJaiJdJj0mvSGTyTpkR3IYWUBeT64knyBfJQ+SP1CUKCYUT0ocRULZTjlKuUB5QHlDpVINqG7UWKqYup1aT71EfUp9L0eTM5fzl+PJrZOrkWuV65d7JU+U15d3l18unydfIX9K/qb8uAJRwUDBU4GjsFahRuG0wj2FSUWaopViiGKaYolig+I1xVElvJKBkrcST6lA6bDSJaUhGkLTpXnSuLRNtDraZdowHUc3pPvTk+nF9B/ovfQJZSVlW+Uo5RzlGuWzylIGwjBg+DNSGaWMk4y7jI/zNOa5z+PP2zavaV7/vCmV+SpuKnyVIpVmlQGVj6pMVW/VFNWdqm2qT9QwaiZqYWrZavvVLquNz6fPd57PnV80/+T8h+qwuol6uPpq9cPqPeqTGpoavhoZGlUalzTGNRmabprJmuWa5zTHtGhaC7UEWuVa57VeMJWZ7sxUZiWzizmhra7tpy3RPqTdqz2tY6izWGejTrPOE12SLls3Qbdct1N3Qk9LL1gvX69R76E+UZ+tn6S/R79bf8rA0CDaYItBm8GooYqhv2GeYaPhYyOqkavRKqNaozvGOGO2cYrxPuNbJrCJnUmSSY3JTVPY1N5UYLrPtM8Ma+ZoJjSrNbvHorDcWVmsRtagOcM8yHyjeZv5Kws9i1iLnRbdFl8s7SxTLessH1kpWQVYbbTqsPrD2sSaa11jfceGauNjs86m3ea1rakt33a/7X07ml2w3Ra7TrvP9g72Ivsm+zEHPYd4h70O99h0dii7hH3VEevo4bjO8YzjByd7J7HTSaffnVnOKc4NzqMLDBfwF9QtGHLRceG4HHKRLmQujF94cKHUVduV41rr+sxN143ndsRtxN3YPdn9uPsrD0sPkUeLx5Snk+cazwteiJevV5FXr7eS92Lvau+nPjo+iT6NPhO+dr6rfS/4Yf0C/Xb63fPX8Of61/tPBDgErAnoCqQERgRWBz4LMgkSBXUEw8EBwbuCHy/SXyRc1BYCQvxDdoU8CTUMXRX6cxguLDSsJux5uFV4fnh3BC1iRURDxLtIj8jSyEeLjRZLFndGyUfFRdVHTUV7RZdFS5dYLFmz5EaMWowgpj0WHxsVeyR2cqn30t1Lh+Ps4grj7i4zXJaz7NpyteWpy8+ukF/BWXEqHhsfHd8Q/4kTwqnlTK70X7l35QTXk7uH+5LnxivnjfFd+GX8kQSXhLKE0USXxF2JY0muSRVJ4wJPQbXgdbJf8oHkqZSQlKMpM6nRqc1phLT4tNNCJWGKsCtdMz0nvS/DNKMwQ7rKadXuVROiQNGRTChzWWa7mI7+TPVIjCSbJYNZC7Nqst5nR2WfylHMEeb05JrkbssdyfPJ+341ZjV3dWe+dv6G/ME17msOrYXWrlzbuU53XcG64fW+649tIG1I2fDLRsuNZRvfbore1FGgUbC+YGiz7+bGQrlCUeG9Lc5bDmzFbBVs7d1ms61q25ciXtH1YsviiuJPJdyS699ZfVf53cz2hO29pfal+3fgdgh33N3puvNYmWJZXtnQruBdreXM8qLyt7tX7L5WYVtxYA9pj2SPtDKosr1Kr2pH1afqpOqBGo+a5r3qe7ftndrH29e/321/0wGNA8UHPh4UHLx/yPdQa61BbcVh3OGsw8/rouq6v2d/X39E7Ujxkc9HhUelx8KPddU71Nc3qDeUNsKNksax43HHb/3g9UN7E6vpUDOjufgEOCE58eLH+B/vngw82XmKfarpJ/2f9rbQWopaodbc1om2pDZpe0x73+mA050dzh0tP5v/fPSM9pmas8pnS8+RzhWcmzmfd37yQsaF8YuJF4c6V3Q+urTk0p2usK7ey4GXr17xuXKp2737/FWXq2euOV07fZ19ve2G/Y3WHruell/sfmnpte9tvelws/2W462OvgV95/pd+y/e9rp95Y7/nRsDiwb67i6+e/9e3D3pfd790QepD14/zHo4/Wj9Y+zjoicKTyqeqj+t/dX412apvfTsoNdgz7OIZ4+GuEMv/5X5r0/DBc+pzytGtEbqR61Hz4z5jN16sfTF8MuMl9Pjhb8p/rb3ldGrn353+71nYsnE8GvR65k/St6ovjn61vZt52To5NN3ae+mp4req74/9oH9oftj9MeR6exP+E+Vn40/d3wJ/PJ4Jm1m5t/3hPP7MjpZfgAAAAlwSFlzAAAN1wAADdcBQiibeAAADVtJREFUaAXNWQl0FEUa/qu7ekKGJJBkQg4SQsKhCZHlCCJKkKCIoBtWJCggCusK+9xdUFFUEJldn3sIeLAYH1lkOfbBI0BwMUIEISEi4TCGmxBIyA05h4RJJjPT3bV/dc9MghKut0r+96qrp7qO7/uvqu4B+P8IYYwR11RBx8+dW52bl/epe+rTp08b3PedsXYDh51ZWZMriovzWX4+a75czfbknXjVDTgtjSEJRrFIQ4d+L2VlMYqkJV6ysrKw/S4J17xb+zt3716A94z9kMdY5pdMsTWysppzf80yw00B4ijBbDYLd4UGLuwBeLasZL61wSJbdmazspOX2IHDtexPLx/ZLIqls/veU/6iKO6cnpT01vRdu1a/UFqaM/nw4QMJCLqbG/jtkvCY3z3BHdQcvOwa5x/v/9zvRv8+abEdfH33Z1Q7rE1+Bm/fYCg46wOUBoMs+yn9+7fAY49ZhIQEh/3++43NPj7GqvPnK5cNHz5kPVqCzJkzh6ampvI52R3guZ0hsyV3b1EUn9+zN6uwvKKKvf7GSkUUk1VRXMBEcaEsiksUX98PGKVrmMGQh21MKzExjL3//iV26lRBY3V1WeXRowWT3fPxmsdGcnKa2L7tx/d3agEyerRZzM42cy11W/b3T5aMHjdyrtXbKp6/fA7uC74PrBUqe+ftXeRofgN26c569waFsRZoaWnB3wlgt/+GNTX5oIZVDO7yfZmZF2v9/WV7fPzYJdihDIuKRZO0tDRxypQp/PdPLHInBHBMGgbbFGXw4OmxJ044Uj5LefbhiZMS4Gh5rqOwplB4atgksXdANCkuLoOt23Jg4dvHcW0nFj8NEALG+nEskxCUhHM5bIxlFC1bKpseHRtjM3o7S47lV5Y+M/WbfYqyYjt2tHLXIuTPuLbZQ4xPdrsEPOAl6fFhAHEbVNV0z4BYGcKjBOfQeJP08Mg46NMnHCIjegIhelLhRHJzT8H+/efgwAErWK3N0KuXEYYOfRrCwwPU+vpKIXOrCcpKQuHNf1CYmCRDvz4EKsurYVv62dwFC1a8KctffcsBIwGctI3EbRLg2cYsS9K4Iap632ZCgvp26eKQbbZWnNiBzwzQkxrgwSQfiPuVCYYMiYLY2CgIDQ0Cg4GCzWaHZmsLKCqDVrsNqi83wtGjBkhPD1L3HwwgmpHAhm5pEd5bKojJk7tD34gu8G3OD/VjHlkxTZbX7dY46IrX3Ok2CPCATUU/6B9N6RNo1tCBAK1OxhQxNFQUunYV4MIF7iZ2LDw0dJf19fWGZ54NhcGDekJUVA/o1s0HidjgwvkSyNrXFzZvidcwJQeWsafq9xLDjJNg63uFeS0JZfkwWumzcSBMnRgk1dQUVu7NOTR+1nOzTqI7iehPCh94iwR0zWN/b0rnbgIIm4gaR5QKmpMIfC9DwMTXh0BgoAiCSKCpSYW6Or6Gm5SDr4fijcWCpSeWP2AxwTtRe+DVi+NBGYTUcXYhFOAiHkSMiwBWwx711Ix4+dO3JEPT1QPrhw0b+wIO0jJUYmKirDspb+lYuM9xlWIef+kVgEAEz0FxDetOTggjgkDgqhWgpFSG4mIn+rmKriNCZKQ39IroDv6BQdg/EIsRC5LwGYBGNAF0BSg1RkItPAHCKHTCkCjYn9MPRiyKAflJgFd91wpZG1QhdkAXWPZez/EAiyJxAtxlfLnyyQ1zLO8IsAp9O0OVpAnxwCJXAOoZCXB1evYArRteCE7JiaB5wYkcOYnGRixNDLwog2iTAHWqBK8NaIC1D+yAEQYjpF+IhhNqCBTZRkFAjRcYB2ZAUV0D2L6og+kVAAGOk2AZ+DQJHO8kYcYqY8IjxedyD+bk4UanMqYt6V7+enUyEtyi+ZokzV3NWPiL6PdoDYbtntPn9QZqbZwQjzRcCPoggSInhX70Cmz/9W64N/iMZsidpR9D0jezEakBwhqa4NHQfBg8+luILzgDfidNsEmeCiEfV8DMWVNBwlVtrb3tTU0lR/btExbMmuU8dJMYWIVanuOk9LkHAcIzALz8cVWMUi8vHVqH2D0POIEoA4OLdjQk2CB7Qg6MDM8FVe6NsVICDD1x7dlN8NLBZP1E1AgQDA6IIc2QHYTGrpEgK/sTljByPvdZXhCTCJWVSvHKlco45NShoO9naNoXhPgpjDmGADRfJaRrAEYDH8Sx3VABvEM4ar6MZ1gElZ6YB2Mj9+NIA1qlHlS1LxPEBoj228aYPEY9WNGLZ2JoRgOX+HkzaBARsFV9Z9EG8Pc/hlgHYbnMMSl+fsQUEiIilY6FBzjHIIiiXxxAj3hCwqJ1zHacWMDn18fPW/nAMARfKfMlFFiXcBye7peJIwSFqYIiYCUQPGawSOhiaBSGBeUI/X16Qo3Fn1W0YmTjxovDcA1JmDq1TPD1zQRFkTEWW3BCQcNttzNMEx2KGZ9layYThEkL0IsTGLMUowYshHihFdCLQECcP40F7vMBmFlrFGSAZv90+Bl15r3/VXFZAQSDQAQH3qEC+G/xCvINRhIVMChkC0mOOkQeDRVIsNiVlDX7QZPsgBPHjWcuXJCW+PvvT7XZglsIaQ43GqFrSwvpdn0VaqTMaEwzZpswE6XT9iIO9L2yLxnr/Swh3XuhVpEc41a6Rjji8B4qyA5RvWwR4G+Di4Q34jdofZzV3kC62UA2EgW3QItDFqrtdvWSXYYyuxrWahCViACvahMqQD5eDzUrTnxY9NWl2COKYsnBs1ete6HFi2HA889L86xWZroRAXRcnv+nBlPaPJsQ3zBV7fkY1tGYidAPnWihLjgnNyKHzYUoEm5rDnQQbvp5/avg3eGfg1G1t9SGdWXO/i3GioNs1eF1Quq/nc66UwD1OIhv3Vqs8RkAVqBpQ3DCKe6dT2vlKRMgWSBEz4paI1/EdXOdyszdB2VTNSF+NYyFzcQawQciKV9EzTcz93Dc1hlxSAYiOhQ89HDfYnVVI2JyMq8aW5d9tqn3qAMjbXPDZwHp/QEJ88pyOv5Tp2UUfrbWwLMsPTMAzEVCbvDcPbWUjcCxJ2xRzWY+tyd2nW4E/OmPxOx+hu+8gRMwiFHdcbhgJALka2KKw4DECzIRRF8/L4MDl46Jqb+yddsPsDVt4+bk9OPjI1LUN+Y5i/ICfZjJUgstJm9IemEgPSVW0YLsfVLq9u3SUL4wSQQ5C9+dzZw8guQ16IKKJFyZPKwZElB5eHOLcDIIpiPJdj1YRdFsRaoaUUWIN6I+i+140AE/NLOClvDC4sBDWn15YyP7MDb2y8IJ44vSHQ427uXX+kSkfFhUuWK5NFNtZV3yj0GSjzfzMwULo69Y2CibXR1DKem3caO0fto054ZEM8gcFEGQ+uIdezgng31+mkFcqHnFR2vOLUnScMYWvIuug5a4im0iFooa4o+tBUajdWtT05G1ZnNe+bBh4vTBg32WmUzOgLo6e83lKlJy4hQ5tGOHc2l6OlTwibm88gqEjhgujcfT9QOtrWDEI0fawoXKDv1p29qu3x1WHVPUhuiZCAkMYWzGLoCgHhhz6CjdMdAcBQDVn1O6b0tr68VS3n3evF5PjhnT+GBoaMtFb29Q/P1hlMWiJlZVgWS1kkLskiVJ8takJDitTY+XmTMhJD8f/B56CMSUFLiITfzlwqM8d787qNs+NknSmn9SmoIv5ctbKc3B+pBTFP+Fp1K3JIvh4cneycnaWdndqNVxcRD89dfS7G3bpLQ1a2jmunV0KYLuxx+iu1zjwjge3fGaNkzd2qGRt7vLTZSOPXVhfDAeoTMTKL10hdIGBF7bTPF7GqWW71B33V39rgGhpzv9SfsrzxwIPOSjjyC6XRbRuriAt+/OA9gdxO3bb/VeB4+9fSkt+kYHzexYq/r9scW3OtPP3O961mhzHUrzP3CBR63L6D78e07dZUnaFP8zA7vV6blbtRd+/tdFkvbOpNSKwLnLqHikZrwwSbqw1t2nE9QevIiFb1z62YbStY9QWmfRwXPgGgEkcsVKacY4HbjHze4mD0+coC8xVzC+HieKVUUu8A5KFW4Fl/aLNrahvfEnv7Z+P+udRqAdeIigtOxom+abEbyCJLTMgxbZnKjD6RTa51A4ge+1dIk3PSi9kO0Cj9mmCrVeysFrmUeSClL4CF24u3UK8cSAP6WFmS7wqPU69PUjtZTaNe2LYnUJwF/wOwiXTqN9DkZTZDdKC75oA29rwd32MJKo09tUJPT927w3gud5l5fOIgR32uPtwHOwhzMoPZvbRqjyIKLFrxFc+FcKj9xtItr6eFQWWnFrd0lhJiGNFxkb8oTe0ORg7ORyvMdvgdx1CH+LcQv3P+2LnbvhF67d6y+7F90Ffb22QJKW/1YUG8rd2pekc+vbQLVtcq42TwC19flF79rWl6Q9f5Sk1BmSVJTmBo9HhlKA+bE6JE+m+kUR3tZilH73phs8nnswFg7N1yfQdue77e8dcXH9haI99nIdj/mPyr2y/MBKfdQWDt4TJXqbdu0sewF/69ckkNLKY5Q2ofbXP6w33dB1OgMBrty2XVUUtyVRuvN9HTy/3vSdua3rXbr7H0SXfo3+OPT1AAAAAElFTkSuQmCC" />
-</a>
-
-<div id='download_container'>
-</div>
-
-<script>
-$.getJSON(
-'https://www.googleapis.com/storage/v1/b/gerrit-releases/o?projection=noAcl&fields=items(name%2Csize)&callback=?',
-function(data) {
-  var doc = document;
-  var frg = doc.createDocumentFragment();
-  var rx = /^gerrit(?:-full)?-([0-9.]+(?:-rc[0-9]+)?)[.]war/;
-  var dl = 'https://www.gerritcodereview.com/download/';
-  var docs = 'https://gerrit-documentation.storage.googleapis.com/';
-  var src = 'https://gerrit.googlesource.com/gerrit/+/'
-
-  var items = data.items.filter(function(i) {
-    return i.name.indexOf('gerrit-snapshot-') != 0;
-  });
-
-
-  items.sort(function(a,b) {
-    var av = rx.exec(a.name);
-    var bv = rx.exec(b.name);
-    if (!av || !bv) {
-      return a.name > b.name ? 1 : -1;
-    }
-
-    var an = av[1].replace('-rc', '.rc').split('.')
-    var bn = bv[1].replace('-rc', '.rc').split('.')
-    while (an.length < bn.length) an.push('0');
-    while (an.length > bn.length) bn.push('0');
-    for (var i = 0; i < an.length; i++) {
-      var ai = an[i].indexOf('rc') == 0
-        ? parseInt(an[i].substring(2))
-        : 1000 + parseInt(an[i]);
-
-      var bi = bn[i].indexOf('rc') == 0
-        ? parseInt(bn[i].substring(2))
-        : 1000 + parseInt(bn[i]);
-
-      if (ai != bi) {
-        return ai > bi ? -1 : 1;
-      }
-    }
-    return 0;
-  });
-
-  var latest = false;
-  for (var i = 0; i < items.length; i++) {
-    var f = items[i];
-    var v = rx.exec(f.name);
-
-    if ('index.html' == f.name) {
-      continue;
-    }
-
-    var tr = doc.createElement('tr');
-    var td = doc.createElement('td');
-    var a = doc.createElement('a');
-    a.href = dl + f.name;
-    if (v) {
-      a.appendChild(doc.createTextNode('Gerrit ' + v[1]));
-    } else {
-      a.appendChild(doc.createTextNode(f.name));
-    }
-    if (f.name.indexOf('-rc') > 0) {
-      td.className = 'rc';
-    } else if (!latest) {
-      latest = true;
-      tr.className='latest-release';
-    }
-    td.appendChild(a);
-    tr.appendChild(td);
-
-    td = doc.createElement('td');
-    td.className = 'size';
-    if (f.size/(1024*1024) < 1) {
-      sizeText = Math.round(f.size/1024*10)/10 + ' KiB';
-    } else {
-      sizeText = Math.round(f.size/(1024*1024)*10)/10 + ' MiB';
-    }
-    td.appendChild(doc.createTextNode(sizeText));
-    tr.appendChild(td);
-
-    td_rel = doc.createElement('td');
-    td_doc = doc.createElement('td');
-    if (v && f.name.indexOf('-rc') < 0) {
-      // Release notes link
-      a = doc.createElement('a');
-      a.href = docs + 'ReleaseNotes/ReleaseNotes-' + v[1] + '.html';
-      a.appendChild(doc.createTextNode('Release Notes'));
-      td_rel.appendChild(a);
-
-      // Documentation link
-      a = doc.createElement('a');
-      a.href = docs + 'Documentation/' + v[1] + '/index.html';
-      a.appendChild(doc.createTextNode('Documentation'));
-      td_doc.appendChild(a);
-    }
-    tr.appendChild(td_rel);
-    tr.appendChild(td_doc);
-
-    td = doc.createElement('td');
-    if (v) {
-      a = doc.createElement('a');
-      a.href = src + 'v' + v[1];
-      a.appendChild(doc.createTextNode('src'));
-      td.appendChild(a);
-    }
-    tr.appendChild(td);
-
-    frg.appendChild(tr);
-  }
-
-  var tr = doc.createElement('tr');
-  var th = doc.createElement('th');
-  th.appendChild(doc.createTextNode('File'));
-  tr.appendChild(th);
-
-  th = doc.createElement('th');
-  th.appendChild(doc.createTextNode('Size'));
-  tr.appendChild(th);
-
-  tr.appendChild(doc.createElement('th'));
-  tr.appendChild(doc.createElement('th'));
-
-  var table = doc.createElement('table');
-  table.appendChild(tr);
-  table.appendChild(frg);
-  doc.getElementById('download_container').appendChild(table);
-});
-</script>
-
-</body>
-</html>